算法導論讀書筆記(2)
分治法
算法設計的方法有不少。插入排序 使用的是 增量 (incremental)方法:在排好子數組 A [ 1 .. j - 1 ]後,將元素 A [ j ]插入,造成排好序的子數組 A [ 1 .. j ]。html
此外,有不少算法在結構上是 遞歸 的:爲了解決一個給定的問題,算法要一次或屢次地遞歸調用其自身來解決相關子問題。這些算法採用的是 分治策略 (divide-and-conquer):將原問題劃分紅 n 個規模較小而結構與原問題類似的子問題;遞歸地解決這些子問題,而後再合併其結果,就獲得原問題的解。java
分治模式在每一層遞歸上都有三個步驟:算法
- 分解(Divide):
- 將原問題分解成一系列子問題;
- 解決(Conquer)
- 遞歸地解各個子問題。若子問題足夠小,則直接求解;
- 合併(Combine)
- 將子問題的結果合併成原問題的解。
歸併排序
歸併排序(merge sort)徹底依照了上述模式,直觀的操做以下:sql
- 分解:
- 將 n 個元素分紅各含 n / 2個元素的子序列;
- 解決:
- 用歸併排序法對兩個子序列遞歸地排序;
- 合併:
- 合併兩個已排序的子序列以獲得排序結果。
對子序列排序時,其長度爲1時遞歸結束。單個元素被視爲是已排好序的。數組
歸併排序的關鍵步驟在於合併兩個已排序的子序列。這裏引入一個輔助過程 MERGE(A, p, q, r)
,其中 A 爲數組, p , q 和 r 都是下標,有 p <= q < r 。該過程假設子數組 A [ p .. q ]和 A [ q + 1 .. r ]都已排好序,並將它們合併成一個已排好序的子數組代替當前子數組 A [ p .. r ]。bash
MERGE(A, p, q, r) 1 n1 = q - p + 1 2 n2 = r - q 3 let L[1 .. n1 + 1] and R[1 .. n2 + 1] be new arrays 4 for i = 1 to n1 5 L[i] = A[p + i - 1] 6 for j = 1 to n2 7 R[j] = A[q + j] 8 L[n1 + 1] = MAX 9 R[n2 + 1] = MAX 10 i = 1 11 j = 1 12 for k = p to r 13 if L[i] <= R[j] 14 A[k] = L[i] 15 i = i + 1 16 else 17 A[k] = R[j] 18 j = j + 1
MERGE
過程的時間代價爲 Θ ( n ),其中 n = r - p + 1。ide
如今,就能夠講 MERGE
過程做爲歸併排序中的一個子程序來使用了。下面的過程 MERGE-SORT(A, p, r)
對子數組 A [ p .. r ]排序。若是 p >= r ,則該子數組中至多隻有一個元素,視爲已排序。不然,分解步驟就計算出一個下標 q ,將 A [ p .. r ]分紅 A [ p .. q ]和 A [ q + 1 .. r ],各含 FLOOR(n / 2)
1 個元素。函數
MERGE-SORT(A, p, r) 1 if p < r 2 q = (p + r) / 2 3 MERGE-SORT(A, p, q) 4 MERGE-SORT(A, q + 1, r) 5 MERGE(A, p, q, r)
下圖自底向上地展現了當 n 爲2的冪時,整個過程當中的操做。算法將兩個長度爲1的序列合併成已排序的,長度爲2的序列,接着又將長度爲2的序列合併成長度爲4的序列,直到最終造成排好序的 n 的序列。post
歸併排序的簡單Java實現:spa
/** * 歸併排序 * * @param array */ public static void mergeSort(int[] array) { mergeSort(array, 0, array.length - 1); } private static void mergeSort(int[] array, int p, int r) { int q; if (p < r) { q = (p + r) >> 1; mergeSort(array, p, q); mergeSort(array, q + 1, r); merge(array, p, q, r); } } private static void merge(int[] array, int p, int q, int r) { int lLen = q - p + 1; int rLen = r - q; int[] left = new int[lLen + 1]; int[] right = new int[rLen + 1]; int i, j; for (i = 0; i < lLen; i++) left[i] = array[p + i]; for (j = 0; j < rLen; j++) right[j] = array[q + j + 1]; left[i] = Integer.MAX_VALUE; right[j] = Integer.MAX_VALUE; i = j = 0; for (int k = p; k <= r; k++) { if (left[i] <= right[j]) array[k] = left[i++]; else array[k] = right[j++]; } }
分治法分析
當一個算法中含有對其自身的遞歸調用時,其運行時間能夠用一個 遞歸方程 (或 遞歸式 )來表示。該方程經過描述子問題與原問題的關係,來給出總的運行時間。
設 T ( n )爲一個規模爲 n 的問題的運行時間。若是問題的規模足夠小,如 n <= c ( c 爲一個常量),則獲得它的直接解的時間爲常量,寫做 Θ (1)。假設咱們把原問題分解成 a 個子問題,每個的大小是原來的1 / b 。若是分解該問題和合並解的時間各爲 D ( n )和 C ( n ),則獲得遞歸式:
歸併排序算法的分析
爲簡化分析,假定原問題的規模是2的冪,這樣每次分解產生的子序列長度就剛好爲 n / 2。
如下給出了歸併排序 n 個數的運行時間。歸併排序一個元素的時間是常量。當 n > 1時,將運行時間分解以下:
- 分解:
- 計算出子數組的中間位置,須要常量時間,於是 D ( n ) = Θ (1)。
- 解決:
- 遞歸地解兩個規模爲 n / 2的子問題,時間爲2 T ( n / 2)。
- 合併:
-
MERGE
過程的運行時間爲 Θ ( n ),則 C ( n ) = Θ ( n )。
如此獲得歸併排序最壞狀況下運行時間 T ( n )的遞歸表示:
遞歸式1
此處能夠直觀地看出 T ( n ) = Θ ( n lg n),重寫遞歸式以下:
遞歸式2
其中常量 c 表明規模爲1的問題所需的時間。
下圖說明了如何解遞歸式2。它將 T ( n )擴展一種等價樹形式。 c n 是樹根(即頂層遞歸的代價),根的兩棵子樹是兩個更小一點的遞歸式 T ( n / 2),它們的代價都是 c n / 2.繼續擴展直到問題的規模降到了1,此時每一個問題的代價爲 c 。
接下來將樹的每一層代價相加。通常來講,最頂層之下的第 i 層有 2i 個結點,每一個的代價都是 c ( n / 2i ),因而,第 i 層的總代價爲 2i c ( n / 2i )。
要計算遞歸式的總代價,只要將遞歸樹中各層的代價加起來就能夠。在該樹中,共有lg n + 1層,每一層的代價都是 c n ,因而,樹的總代價爲 c n (lg n + 1) = c n lg n + c n 。忽略低階項和常量,獲得結果 Θ ( n lg n )。
練習
2.3-2
改寫 MERGE
過程,不使用哨兵元素。
MERGE(A, p, q, r) 1 n1 = q - p + 1; 2 n2 = r - q; 3 let L[1 .. n1] and R[1 .. n2] be new arrays 4 for i = 1 to n1 5 L[i] = A[p + i - 1] 6 for j = 1 to n2 7 R[j] = A[q + j] 8 i = j = 1 9 k = p 10 while i <= n1 and j <= n2 11 if L[i] <= R[j] 12 A[k] = L[i] 13 k = k + 1 14 i = i + 1 15 else 16 A[k] = R[j] 17 j = j + 1 18 while i <= n1 19 A[k] = L[i] 20 k = k + 1 21 i = i + 1 22 while j <= n2 23 A[k] = R[j] 24 j = j + 1
2.3-4
將插入排序改寫成遞歸過程,並寫出運行時間的遞歸式。
INSERTION-SORT-RECURSIVE(A, p) 1 if p > 1 2 key = A[p] 3 p = p - 1 4 INSERTION-SORT-RECURSIVE(A, p) 5 INSERTION-ELEMENT(A, p, key)
INSERTION-ELEMENT(A, p, key) 1 while p > 0 and A[p] > key 2 A[p + 1] = A[p] 3 p = p - 1 4 A[p + 1] = key
該過程的運行時間以下分解:
- 分解:
- 縮小子數組規模,須要常量時間 D ( n ) = Θ (1)。
- 解決:
- 遞歸地解一個規模爲 n - 1的子問題,時間爲 T ( n - 1)。
- 合併:
-
INSERTION-ELEMENT
過程的運行時間是線性的,即 C ( n ) = Θ ( n )。
則遞歸版本插入排序的遞歸式可寫爲 T ( n ) = T ( n - 1) + Θ ( n )。最終結果就是 T ( n ) = Θ ( n2 )。
2.3-5
二分查找僞碼:
BINARY-SEARCH(A, v) 1 front = 1 2 end = A.length 3 while front < end 4 middle = (front + end) / 2 5 if A[middle] < v 6 front = middle + 1 7 else if A[middle] > v 8 end = middle - 1 9 else 10 return middle 11 return -1
2.3-7
設計算法:查找集合 S 中是否存在兩個其和等於 x 的元素。
CHECK-SUM(S, x) 1 A = MERGE-SORT(S) 2 for i = 1 to A.length 3 v = x - A[i] 4 if BINARY-SEARCH(A, v) > 0 5 return true 6 return false
思考題
在歸併排序中對小數組採用插入排序
儘管歸併排序的最壞狀況運行時間爲 Θ ( n lg n ),插入排序的最壞狀況運行時間爲 Θ ( n2 ),但插入排序中的常數因子使得它在 n 比較小時,運行得要更快一些。所以,在歸併排序算法中,當子問題足夠小的時候,採用插入排序就比較合適了。考慮對歸併排序做這樣的修改,即採用插入排序策略,對 n / k 個長度爲 k 的子列表進行排序,而後,再用標準的合併機制將它們合併起來,此處 k 是一個待定值。
假設 n / k 是2的冪(這樣能夠很容易的算出樹的高度),設 T ( n )爲該算法最壞狀況運行時間,則函數的等價樹結構以下:
能夠看到,樹共有lg( n / k ) + 1層,最底層共有 n / k 個結點,每一個結點都是長度爲 k 的子列表。規模爲 k 的插入排序的最壞狀況運行時間是關於 k 的二次函數,表示爲 T ( k ) = a k2 + b k + c 。共有 n / k 個這樣的子序列,因此總的運行時間 L ( n ) = ( n / k ) T ( k )。最終可知, n / k 個子列表(每一個子列表的長度爲 k )能夠用插入排序在 Θ ( n k )時間內完成排序。
可知樹共有lg( n / k ) + 1層。除最後一層外,其他各層所有用於合併子列表,每一層的代價都是 c n 。最後一層的時間代價已知爲 Θ ( n k )。因此算法總的運行時間就是 T ( n ) = c n lg( n / k ) + Θ ( n k )。捨棄低階項和常數因子,有 T ( n ) = Θ ( n lg( n / k ))。
逆序對
設 A [ 1 .. n ]是一個包含 n 個不一樣數的數組。若是在 i < j 的狀況下,有 A [ i ] > A [ j ],則( i , j )就稱爲 A 中的一個 逆序對 (inversion)。
降冪排列的數組擁有的逆序對是最多的,對於長度爲 n 的數組來講,共有( n - 1)!個逆序對。
腳註
FLOOR(x)
記號表示小於等於 x 的最大整數, CEIL(x)
表示大於等於 x 的最小整數。