重讀算法導論之算法基礎
重讀算法導論之算法基礎
插入排序
? 對於少量數據的一種有效算法。原理:
- 整個過程中將數組中的元素分為兩部分,已排序部分A和未排序部分B
- 插入過程中,從未排序部分B取一個值插入已排序的部分A
- 插入的過程采用的方式為: 依次從A中下標最大的元素開始和B中取出的元素進行對比,如果此時該元素與B中取出來的元素大小關系與期望不符,則將A中元素依次向右移動
? 具體代碼如下:
public static void insertionSort(int[] arr) { // 數組為空或者只有一個元素的時候不需要排序 if (arr == null || arr.length <= 1) { return
; } // 開始插入排序,先假設元素組第一個元素屬於已經排好序的A部分,依次從B部分取出元素,進行比較插入 for (int j = 1; j < arr.length; j++) { int key = arr[j]; int i = j - 1; for (; i >= 0; i--) { if (arr[i] > key) { arr[i + 1] = arr[i]; } else { break; } } arr[i+1] = key; } }
? 易錯點為,最後應該是設置arr[i + 1] = key。 可以設想假設A中所有元素都比B中選出來的數小的時候,此時key所在的下標應該不變,此時i + 1 = j 。所以有arr[i + 1] = key。
循環不變式
? 循環不變式主要用來幫助我們理解算法的正確性。要證明一個算法是循環不變式,必須證明該算法滿足三條性質:
- 初始化:循環的第一次叠代之前,它為真
- 保持:如果循環的某次叠代之前它為真,那麽進行完當前叠代,下次叠代之前仍然為真
- 終止:在循環終止時,不變式為我們提供了一個有用的性質,該性質有助於證明算法是正確的
? 循環不變式類似於數學上的歸納法。只不過在歸納法中,歸納步是無限地使用的,而這裏存在循環終止,停止歸納。
用循環不變式驗證插入排序
初始化: 從上面的代碼可以看到。在循環之前,我們假設排好序的部分A只包含一個元素,此時A當然是滿足排好序的。即初始化A滿足循環不變式
保持:下面分析每一個循環的過程。從未排序的B中取出key,如果A中的arr[i] > key, 說明此時key所在的位置並不是合適的位置,此時需要將A中的arr[i]往右移動到arr[i+1](arr[i + 1] = arr[i])。直到找到arr[i] <= key, 說明此時arr[i+1]就應該是key的正確位置,arr[i + 1] = key。完成該輪叠代之後,因為A原來就是排好序的,所以只是將A中不符合條件的元素右移,不會影響其排好序的特性。而key插入到正確位置之後,也保證了A+key之後的新的A滿足循環不變式
終止:代碼中的j表示未排好序的B部分的最左元素下標。可以看到循環的最終條件是j=arr.length。即此時A包含數組的所有元素。因此終止條件也滿足循環不變式
快速排序的整個流程圖如下:
分析算法
? 主要涉及兩個重要的概念
- 輸入規模: 最佳概念依賴於研究的問題。對於許多問題,比如排序或者計算離散傅裏葉變換,最自然的度量是輸入中的項數。對於其他許多問題,比如兩數相乘,輸入規模的最佳度量則是用通常的二進制幾號表示輸入所需的總位數。有時候,輸入規模可能需要用多個數來表示。比如某個算法輸入的是一個圖,則輸入規模可能用該圖中的頂點數和邊數來描述更加合適。
- 運行時間: 指算法執行的基本操作數或步數。之所以定義成步的概念,是為了獨立於機器。
? 對於每一行代碼所執行的耗時,我們可以用c~i~ 來表示。每一行執行的次數我們可以用t~j~ 來表示。假設輸入數組的規模為n。下面我們針對快排的代碼來進行時間分析:
代碼 | 執行耗時 | 執行次數 |
---|---|---|
for (int j = 1; j < arr.length; j++) { | c~1~ | n |
int key = arr[j]; | c~2~ | n - 1 |
int i = j - 1; | c~3~ | n - 1 |
for (; i >= 0; i--) { if (arr[i] > key) { | c~4~ | t~j~ 之和 ( 2=< j <=n ) |
arr[i + 1] = arr[i]; | c~5~ | (t~j~ - 1)之和 ( 2=< j <=n ) |
arr[i+1] = key; | c~6~ | n - 1 |
註意:上面for循環的語句執行次數比循環內部多一次,因為for循環最後會多執行一次第三個遞增語句。
? 通過上面的表格分析可以發現,影響算法效率的關鍵在第4行和第5行。最好的情況下,數組本身就是排好序的,那麽t~j~ 就都是1,此時總的耗時即為n的倍數。最壞的情況下,數組剛好是逆序排好的,則此時第4行和第5行要執行的步數與j有關,此時和為(2 + 3 + 4 + ... + n ), 其結果與\(n^2\)有關。
最壞情況與平均情況分析
? 一個算法的最佳情況往往象征著我們的美好願望,沒有什麽實際的研究意義。我們更關註於一個算法的最壞情況和平均情況。因為最壞情況代表著程序運行的上界,他可以讓我們對最糟糕的情況有所準備。而平均情況,可以給出我們不刻意構造輸入的時候最可能運行的時間消耗,可以認為是最接近自然的時間消耗。當然大部分情況下平均情況都與最壞情況一致。比如插入排序,可以認為第4/5行代碼的平均運行次數為j的一半,最終相加之後發現其結果也是\(n^2\) 相關的。
增長量級的概念
? 我們真正感興趣的不在於具體運行時間表達多項式的值為多少。而是運行時間的增長率/增長量級。對於時間表達的多項式而言,我們只關註多項式的最高次數的項即可。這裏我們引入Theta符號: \[\Theta\] 。
? 我們將插入排序的最壞運行時間記為: \(\Theta\)(\(n^2\)) 。 如果一個算法比另一個算法具有更低的增量級,我們通常可以認為具有較低增量級的算法更有效。
設計算法之分治算法
? 有時候一個問題如果作為一個整體來解決會顯得比較棘手,此時可以考慮將一個大問題分為多個規模較小的問題。如果小問題的規模足夠小的時候就可以直接求解,最後再將每個小問題的求解合並,就完成了對整個問題的求解。本人認為其實就像現在的流水作業,細化分工,本質上也是一種分治算法。一個公司的業務涉及方方面面,我們可以把每一個方面看做一個小規模的問題,公司的正常發展只要人人各司其職,解決好自己那一方面的問題就行了。至於老板,則需要整體問題的把握能力,負責整合各方面的"解"。
? 好了,不扯遠了。直接來看下分治算法求解的三個步驟:
- 分解:分解原問題為若幹子問題,這些子問題就是原問題規模較小的實例
- 解決:遞歸地求解各個子問題
- 合並:合並子問題的解成原問題的解
? 算法上的分治一種最常見的表現就是遞歸調用。遞歸調用就是一個方法不停地對自己進行調用,每次調用的問題規模都會縮小,直至達到方法返回的臨界值。歸並排序就是分治算法思想的一個典型應用。下面直接看歸並排序的代碼:
public static void mergeSort(int[] arr, int startIndex, int endIndex) {
// 這裏endIndex表示不可達的下標,所以如果元素個數小於等於1則不用再排序的情況是 endIndex <= startIndex + 1
if (endIndex <= startIndex + 1) {
return;
}
int midIndex = (endIndex + startIndex) / 2;
// 遞歸調用求解子問題,每個子問題規模為原來的1/2
mergeSort(arr, startIndex, midIndex);
mergeSort(arr, midIndex, endIndex);
// 合並子問題的解:此時滿足 [startIndex, midIndex)和[midIndex, endIndex)已經排好序
merge(arr, startIndex, midIndex, endIndex);
}
private static void merge(int[] arr, int startIndex, int midIndex, int endIndex) {
/**
* 合並策略:
* 1. 新建兩個數組,分別存取左半部分排好序的數組和右半部分排好序的數組
* 2. 分別從左右兩個數組最開始下標開始遍歷,選取較小的依次放入原數組對應位置
* 3. 最終如果左右數組中有一個已經遍歷完成,另一個數組所剩的元素直接放入元素組後面部分即可
*/
// STEP1
int[] leftArr = new int[midIndex - startIndex];
int[] rightArr = new int[endIndex - midIndex];
System.arraycopy(arr, startIndex, leftArr, 0, leftArr.length);
System.arraycopy(arr, midIndex, rightArr, 0, rightArr.length);
// STEP2
int k = startIndex; // 存儲原數組中的下標
int i = 0; // 存儲左邊數組的下標
int j = 0; // 存儲右邊數組的下標
while (i < leftArr.length && j < rightArr.length) {
// 將較小的元素復制到元素組對應下標k,並且移動較小元素所在數組下標
if (leftArr[i] < rightArr[j]) {
arr[k++] = leftArr[i++];
} else {
arr[k++] = rightArr[j++];
}
}
// STEP3
if (i < leftArr.length) {
System.arraycopy(leftArr, i, arr, k, leftArr.length - i);
} else if (j <= rightArr.length) {
System.arraycopy(rightArr, j, arr, k, rightArr.length - j);
}
}
? 以上代碼有以下三點需要註意:
- mergeSort遞歸的結束條件
- midIndex的計算方式
- 數組拷貝的時候我們利用了
System.arraycopy
方法,該方法是native方法,具有更好的效率。Arrays.copyOf
相關方法最終也是調用了該native方法,這點可以直接在jdk源碼中看到
歸並排序流程圖
? 流程圖大致如下,其中原數組中陰影部分表示還未排好序的部分。左右數組中陰影部分表示已經處理過的部分。在《算法導論》中使用了一個哨兵元素來判斷是否已經到左右元素末尾,在上面的源碼中我們直接根據下標來進行判斷:
? 當然這整個流程也可以用樹表示如下:
?
歸並排序算法分析
? 根據歸並排序的算法步驟,我們來逐步分析最壞情況下的時間復雜度:
- 分解:每一步分解相當於只是求解了待排序部分的中間位置下標,需要的是常量時間,因此:D(n) = \(\Theta\)(1)
- 解決:解決的過程是遞歸地解決n/2規模的問題,將需要2T(n/2)運行時間
- 合並:每一次合並其實就是遍歷左右數組的元素,可以認為: C(n) = \(\Theta\)(n)
? 我們為了分析總的運行時間,則需要將D(n)與C(n)進行相加。這時候其實就是相當於\(\Theta\)(n)與\(\Theta\)(1)相加。相加的和仍然是一個n的線性函數。所以我們可以仍然表示成\(\theta\)(n)。再將其與步驟2中的表達式相加,得到歸並排序最壞情況運行時間的T(n)遞歸式:
? 我們將時間常量用c表示,將上式簡化為:
?
? 為了求解遞歸式我們需要借助遞歸樹。為了方便起見我們假設問題的規模正好為2的冪(這樣的話正好是一個完全二叉樹)。利用遞歸樹分析如下圖:
? 其實不難發現每一層運行的時間代價都是cn。所以整個算法的復雜度的關鍵在於求究竟有多少層。我們知道最上層問題的規模是n,最下層問題的規模是1。然後每次問題的規模縮減為原來的一半。不難得出這顆遞歸樹的總層數為:\(\log\)~2~n + 1,簡單記為:lgn + 1。所以遞歸表達式的總時間消耗為: cnlgn + cn。忽略低階項和常量項,得到最壞運行時間的表達式為: \(\Theta\)(nlgn)。
算法基礎相關練習與思考
遞歸實現二分查找法
? 其實也是一種分治的思想,逐步的減小問題的規模,直至找到對應的元素。只不過該算法最終不需要將每個小規模的求解合並。
private static int binarySearch(int[] arr, int lowIndex, int upIndex, int searchValue) {
if (upIndex < lowIndex + 1) {
return -1;
}
int midIndex = (lowIndex + upIndex) / 2;
if (arr[midIndex] == searchValue) {
return midIndex; // 相等則返回當前下標
} else if (arr[midIndex] < searchValue) {
// 小於要查找的值則查找右半部分大值區間
return binarySearch(arr, midIndex + 1, upIndex, searchValue);
} else {
// 大於要查找的則查找左半部分小值區間
return binarySearch(arr, lowIndex, midIndex, searchValue);
}
}
? 該算法的時間復雜度就在於分解成子問題的次數,因為每個子問題都是直接取中間元素與當前元素對比,可以認為時間復雜度為\(\Theta\)(1)。所以整個二分查找法的最壞時間復雜度為:\(\Theta\)(lgn)。
二分查找法優化插入排序效率
? 由上面對插入排序的最壞時間分析可知。插入排序的最壞時間出現在輸入數組正好與希望的排序結果倒序排列。對於下標為i的元素,此時仍需要比較i次。用二分查找雖然可以加快得出元素應該插入的位置,但如果輸入數組還是逆序的話,移動次數不會改變,所以無法正真優化插入排序的最壞時間。
冒泡排序
? 冒泡排序的得名來自於其算法的過程類似於冒泡:以從小到大的順序來說,每次交換相鄰的兩個元素,直至最小的元素冒泡到未排序的部分最左邊。其java實現代碼如下:
private static void bubbleSort(int[] arr) {
// i可以看做是未排序數組的最左端元素下標,每次循環最左端冒泡出最小的元素,i依次遞增
for (int i = 0; i < arr.length - 1; i++) {
// j可以看做未做排序的部分元素下標集合,依次對比冒泡
for (int j = arr.length - 1; j > i; j--) {
if (arr[j] < arr[j - 1]) {
int temp = arr[j];
arr[j] = arr[j - 1];
arr[j - 1] = temp;
}
}
}
}
? 其實冒泡排序和選擇排序的過程基本類似,只不過選擇排序是每次遍歷未排序部分選擇最小元素,冒泡排序時對未排序部分依次兩兩對比。最壞時間復雜度都是: \(\Theta\)(\(n^2\))。
歸並排序中對小數組使用插入排序優化
? 雖然歸並排序的最壞情況運行時間為Θ(nlgn),而插入排序的最壞情況運行時間為Θ(n2),但是插入排序中的常量因子可能使得它在n較小時,在許多機器上實際運行得更快。因此,在歸並排序中當子問題變得足夠小時,采用插入排序來使遞歸的葉變粗是有意義的。考慮對歸並排序的一種修改,其中使用插入排序來排序長度為k的n/k個子表,然後使用標準的合並機制來合並這些子表,這裏k是一個待定的值。
- 證明:插入排序最壞情況可以在\(\Theta\)(nk)時間內排序每個長度為k的n/k個子表。
- 表明在最壞情況下如何在\(\Theta\)(nlg(n/k))時間內合並這些子表。
- 假定修改後的算法的最壞情況運行時間為Θ(nk+nlg(n/k)),要使修改後的算法與標準的歸並排序具有相同的運行時間,作為n的一個函數,借助Θ記號,k的最大值是什麽?
- 在實踐中,我們應該如何選擇k?
- 由前面得插入排序的最壞時間復雜度為: \(\Theta\)(n/k * \(k^2\)) = \(\Theta\)(nk)
- 因為最終采用分治算法分到最底層每組元素為k。那麽這個分組實際上一共經過了: lg(n/k) + 1次。又每一層合並所需時間為:cn。所以最壞時間復雜度為: \(\Theta\)(nlg(n/k))
- 如果修改後的歸並排序時間與原來時間一致,則有: \(\Theta\)(nlg(n)) = \(\Theta\)(nk + nlg(n/k)) \(\Rightarrow\) Θ(k+lg(n/k)) = Θ(lgn) \(\Rightarrow\) k的最大值應該為lgn
- 實踐中,k的值應該選為使得插入排序比合並排序快的最大的數組長度。很容易理解,假設k=1,那麽退化為標準合並排序,那麽要提高效率需放大k,k放大到使得array[k]使用插入排序比合並排序快,而array[k+1]的插入排序效率不如或等於合並排序
重讀算法導論之算法基礎