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]; }
對數器的概念和使用
- 有一個你想要測的方法a;
- 實現一個絕對正確但是複雜度不好的方法b;
- 實現一個隨機樣本產生器 ;
- 實現比對的方法 ;
- 把方法a和方法b比對很多次來驗證方法a是否正確;
- 如果有一個樣本使得比對出錯,列印樣本分析是哪個方法出錯 ;
- 當樣本數量很多時比對測試依然正確,可以確定方法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)nlogba=n0=Θ(1),滿足Master定理第二條,可以得到其時間複雜度為T(n) = θ(log n)。
再看一個例子,T(n) = 9 T(n / 3) + n,可知nlogba=n2nlogba=n2,令ε取1,顯然滿足Master定理第一條,可以得到T(n) = θ(n ^ 2)。
來一個稍微複雜一點兒例子,T(n) = 3 T(n / 4) + n log n。nlogba=O(n0.793)nlogba=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=n1nlogba=n1,而f(n) = n log n,顯然不滿足Master定理第二條。但對於第一條和第三條,也無法找到大於零的ε使得nlogn=O(n1−ε)nlogn=O(n1−ε)或者nlogn=Ω(n1+ε)nlogn=Ω(n1+ε),因此不能用Master定理求解,只能尋求別的方式求解。比如可以利用遞迴樹求出該演算法的複雜度為T(n)=O(nlog2n)T(n)=O(nlog2n)。簡單的說一下計算過程:
遞迴樹的建立過程,就像是模擬演算法的遞推過程。樹根對應的是輸入的規模為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的子問題,無須再分治)。最後將每一層消耗的時間累加起來,得到: