1. 程式人生 > >Java演算法從入門到精通(一)

Java演算法從入門到精通(一)

認識時間複雜度

    常數時間的操作:一個操作如果和資料量沒有關係,每次都是 固定時間內完成的操作,叫做常數操作。

    時間複雜度為一個演算法流程中,在最差的資料情況下,常數運算元量的指標。常用O (讀作big O)來表示。具體來說,在常數運算元量的表示式中, 只要高階項,不要低階項,也不要高階項的係數,剩下的部分 如果記為f(N),那麼時間複雜度為O(f(N))。

   評價一個演算法流程的好壞,先看時間複雜度的指標,然後再分 析不同資料樣本下的實際執行時間,也就是常數項時間。

氣泡排序細節的講解與複雜度分析

時間複雜度O(N^2),額外空間複雜度O(1)

程式碼實現:

 public static void bubbleSort(int[] arr) {
		if (arr == null || arr.length < 2) {
			return;
		}
		for (int e = arr.length - 1; e > 0; e--) {
			for (int i = 0; i < e; i++) {
				if (arr[i] > arr[i + 1]) {
					swap(arr, i, i + 1);
				}
			}
		}
	}

 public static void swap(int[] arr, int i, int j) {
		arr[i] = arr[i] ^ arr[j];
		arr[j] = arr[i] ^ arr[j];
		arr[i] = arr[i] ^ arr[j];
	}

選擇排序的細節講解與複雜度分析

時間複雜度O(N^2),額外空間複雜度O(1)

程式碼實現:

public static void selectionSort(int[] arr) {
		if (arr == null || arr.length < 2) {
			return;
		}
		for (int i = 0; i < arr.length - 1; i++) {
			int minIndex = i;
			for (int j = i + 1; j < arr.length; j++) {
				minIndex = arr[j] < arr[minIndex] ? j : minIndex;
			}
			swap(arr, i, minIndex);
		}
	}

public static void swap(int[] arr, int i, int j) {
		int tmp = arr[i];
		arr[i] = arr[j];
		arr[j] = tmp;
	}

插入排序的細節講解與複雜度分析

時間複雜度O(N^2),額外空間複雜度O(1)

程式碼實現:

public static void insertionSort(int[] arr) {
		if (arr == null || arr.length < 2) {
			return;
		}
		for (int i = 1; i < arr.length; i++) {
			for (int j = i - 1; j >= 0 && arr[j] > arr[j + 1]; j--) {
				swap(arr, j, j + 1);
			}
		}
	}

public static void swap(int[] arr, int i, int j) {
		arr[i] = arr[i] ^ arr[j];
		arr[j] = arr[i] ^ arr[j];
		arr[i] = arr[i] ^ arr[j];
	}

對數器的概念和使用

  1. 有一個你想要測的方法a;
  2. 實現一個絕對正確但是複雜度不好的方法b;
  3. 實現一個隨機樣本產生器 ;
  4. 實現比對的方法 ;
  5. 把方法a和方法b比對很多次來驗證方法a是否正確;
  6. 如果有一個樣本使得比對出錯,列印樣本分析是哪個方法出錯 ;
  7. 當樣本數量很多時比對測試依然正確,可以確定方法a已經 正確。

我們以氣泡排序為例

程式碼如下:

import java.util.Arrays;

public class BubbleSort {

	public static void bubbleSort(int[] arr) {
		if (arr == null || arr.length < 2) {
			return;
		}
		for (int e = arr.length - 1; e > 0; e--) {
			for (int i = 0; i < e; i++) {
				if (arr[i] > arr[i + 1]) {
					swap(arr, i, i + 1);
				}
			}
		}
	}

        public static void swap(int[] arr, int i, int j) {
		arr[i] = arr[i] ^ arr[j];
		arr[j] = arr[i] ^ arr[j];
		arr[i] = arr[i] ^ arr[j];
	}

	// for test
	public static void comparator(int[] arr) {
		Arrays.sort(arr);
	}

	// for test
	public static int[] generateRandomArray(int maxSize, int maxValue) {
		int[] arr = new int[(int) ((maxSize + 1) * Math.random())];
		for (int i = 0; i < arr.length; i++) {
			arr[i] = (int) ((maxValue + 1) * Math.random()) - (int) (maxValue * Math.random());
		}
		return arr;
	}

	// for test
	public static int[] copyArray(int[] arr) {
		if (arr == null) {
			return null;
		}
		int[] res = new int[arr.length];
		for (int i = 0; i < arr.length; i++) {
			res[i] = arr[i];
		}
		return res;
	}

	// for test
	public static boolean isEqual(int[] arr1, int[] arr2) {
		if ((arr1 == null && arr2 != null) || (arr1 != null && arr2 == null)) {
			return false;
		}
		if (arr1 == null && arr2 == null) {
			return true;
		}
		if (arr1.length != arr2.length) {
			return false;
		}
		for (int i = 0; i < arr1.length; i++) {
			if (arr1[i] != arr2[i]) {
				return false;
			}
		}
		return true;
	}

	// for test
	public static void printArray(int[] arr) {
		if (arr == null) {
			return;
		}
		for (int i = 0; i < arr.length; i++) {
			System.out.print(arr[i] + " ");
		}
		System.out.println();
	}

	// for test
	public static void main(String[] args) {
		int testTime = 500000;
		int maxSize = 100;
		int maxValue = 100;
		boolean succeed = true;
		for (int i = 0; i < testTime; i++) {
			int[] arr1 = generateRandomArray(maxSize, maxValue);
			int[] arr2 = copyArray(arr1);
			bubbleSort(arr1);
			comparator(arr2);
			if (!isEqual(arr1, arr2)) {
				succeed = false;
				break;
			}
		}
		System.out.println(succeed ? "Nice!" : "Fucking fucked!");

		int[] arr = generateRandomArray(maxSize, maxValue);
		printArray(arr);
		bubbleSort(arr);
		printArray(arr);
	}

}

平時設計或者閱讀一個演算法的時候,必然會提到演算法的複雜度(包括時間複雜度和空間複雜度)。比如我們說一個二分查詢演算法的平均時間複雜度為O(log n),快速排序可能是O(n log n)。那這裡的O是什麼意思?這樣的表達是否準確呢?

今天來複習一下與演算法複雜度相關的知識:函式漸進階,記號O、Ω、θ和o;Master定理。

先插一句,在演算法複雜度分析中,log通常表示以2為底的對數。

演算法複雜度(演算法複雜性)是用來衡量演算法執行所需要的計算機資源(時間、空間)的量。通常我們利用漸進性態來描述演算法的複雜度。

用n表示問題的規模,T(n)表示某個給定演算法的複雜度。所謂漸進性態就是令n→∞時,T(n)中增長最快的那部分。嚴格的定義是:如果存在T˜(n)T~(n),當n→∞時,有

比如T(n) = 2 * n ^ 2 + n log n + 3,那麼顯然它的漸進性態是 2 * n ^ 2,因為當n→∞時,後兩項的增長速度要慢的多,可以忽略掉。引入漸進性態是為了簡化演算法複雜度的表示式,只考慮其中的主要因素。當比較兩個演算法複雜度的時候,如果他們的漸進複雜度的階不相同,那隻需要比較彼此的階(忽略常數係數)就可以了。

總之,分析演算法複雜度的時候,並不用嚴格演算出一個具體的公式,而是隻需要分析當問題規模充分大的時候,複雜度在漸進意義下的階。記號O、Ω、θ和o可以幫助我們瞭解函式漸進階的大小。

假設有兩個函式f(n)和g(n),都是定義在正整數集上的正函式。上述四個記號的含義分別是:

可見,記號O給出了函式f(n)在漸進意義下的上界(但不一定是最小的),相反,記號Ω給出的是下界(不一定是最大的)。如果上界與下界相同,表示f(n)和g(n)在漸進意義下是同階的(θ),亦即複雜度一樣。

列舉一些常見的函式之間的漸進階的關係:

有些人可能會把這幾個記號跟演算法的最壞、最好、平均情況複雜度混淆,它們有區別,也有一定的聯絡。

即使問題的規模相同,隨著輸入資料本身屬性的不同,演算法的處理時間也可能會不同。於是就有了最壞情況、最好情況和平均情況下演算法複雜度的區別。它們從不同的角度反映了演算法的效率,各有用處,也各有侷限。

有時候也可以利用最壞情況、最好情況下演算法複雜度來粗略地估計演算法的效能。比如某個演算法在最壞情況下時間複雜度為θ(n ^ 2),最好情況下為θ(n),那這個演算法的複雜度一定是O(n ^ 2)、Ω(n)的。也就是說n ^ 2是該演算法複雜度的上界,n是其下界。

接下來看看Master定理。

有些演算法在處理一個較大規模的問題時,往往會把問題拆分成幾個子問題,對其中的一個或多個問題遞迴地處理,並在分治之前或之後進行一些預處理、彙總處理。這時候我們可以得到關於這個演算法複雜度的一個遞推方程,求解此方程便能得到演算法的複雜度。其中很常見的一種遞推方程就是這樣的:

設常數a >= 1,b > 1,f(n)為函式,T(n)為非負整數,T(n) = a T(n / b) + f(n),則有:

比如常見的二分查詢演算法,時間複雜度的遞推方程為T(n) = T(n / 2) + θ(1),顯然有nlogba=n0=Θ(1)nlogb⁡a=n0=Θ(1),滿足Master定理第二條,可以得到其時間複雜度為T(n) = θ(log n)。

再看一個例子,T(n) = 9 T(n / 3) + n,可知nlogba=n2nlogb⁡a=n2,令ε取1,顯然滿足Master定理第一條,可以得到T(n) = θ(n ^ 2)。

來一個稍微複雜一點兒例子,T(n) = 3 T(n / 4) + n log n。nlogba=O(n0.793)nlogb⁡a=O(n0.793),取ε = 0.2,顯然當c = 3 / 4時,對於充分大的n可以滿足a * f(n / b) = 3 * (n / 4) * log(n / 4) <= (3 / 4) * n * log n = c * f(n),符合Master定理第三條,因此求得T(n) = θ(n log n)。

運用Master定理的時候,有一點一定要特別注意,就是第一條和第三條中的ε必須大於零。如果無法找到大於零的ε,就不能使用這兩條規則。

舉個例子,T(n) = 2 T(n / 2) + n log n。可知nlogba=n1nlogb⁡a=n1,而f(n) = n log n,顯然不滿足Master定理第二條。但對於第一條和第三條,也無法找到大於零的ε使得nlogn=O(n1−ε)nlog⁡n=O(n1−ε)或者nlogn=Ω(n1+ε)nlog⁡n=Ω(n1+ε),因此不能用Master定理求解,只能尋求別的方式求解。比如可以利用遞迴樹求出該演算法的複雜度為T(n)=O(nlog2n)T(n)=O(nlog2⁡n)。簡單的說一下計算過程:

遞迴樹的建立過程,就像是模擬演算法的遞推過程。樹根對應的是輸入的規模為n的問題,在遞迴處理子問題之外,還需要n log n的處理時間。然後根據遞推公式給根節點新增子節點,每個子節點對應一個子問題。這裡需要兩個子節點,每個節點處理規模為n / 2的問題,分別需要(n / 2) * log(n / 2)的時間。因此在第二層一共需要n * (log n - 1)的時間。第三層節點就是將第二層的兩個節點繼續分裂開,得到四個各需要(n / 4) * log(n / 4)時間的節點,總的時間消耗為n * (log n - 2)。依此類推,第k(設樹根為k = 0)層有2 ^ k的節點,總的時間為n * (log n - k)。而且可以知道,這棵樹總共有log n層(最後一層每個節點只處理規模為1的子問題,無須再分治)。最後將每一層消耗的時間累加起來,得到: