算法導論讀書筆記(2)

算法導論讀書筆記(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 爲數組, pqr 都是下標,有 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 <= cc 爲一個常量),則獲得它的直接解的時間爲常量,寫做 Θ (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)!個逆序對。

腳註

1

FLOOR(x) 記號表示小於等於 x 的最大整數, CEIL(x) 表示大於等於 x 的最小整數。

相關文章
相關標籤/搜索