討厭算法的程序員 5 - 合并算法

討厭算法的程序員系列入口

本篇介紹的“合并”算法,是為后面學習“歸并排序”的一個準備。合并算法是歸并排序中的一個子算法,請注意兩者之間的關系和差異。

之所以把它獨立成一篇,一方面是一旦了解了它再理解歸并排序就會簡單很多,另一方面是其本身就具有獨立性,可以解決很多常見問題,并不非得寄宿在歸并排序里面。

合并算法,就是將兩個已經各自排好序的序列,合并成一個排好序的大序列的方法

經典應用

兩摞撲克牌

《算法導論》里面給出的例子就很好理解。還是拿撲克牌來說事:桌上有兩摞牌,面朝上,每摞都已經按照從小到大排好序了。那么如何把它們合并成一摞并排好序呢?

日常生活中其實還有很多類似的應用。比如校園里學生按身高由低到高排隊,偶爾會遇到兩隊合一隊的情況,要求合并后仍然按照由低到高的順序。

合并算法就是解決此類問題的最佳方法。以撲克牌為例,其基本步驟是:

  • 1 比較兩堆牌最頂上的兩張牌,選最小的一張;
  • 2 將其拿出來(此時該堆頂上將露出一張新牌),面朝下放到輸出堆(就是最終的那一大摞);
  • 3 重復上面兩步,直到原來兩堆其中一個為空,此時將另一堆中的所有剩余的牌,直接面朝下放到輸出堆中。

假設最壞情況是兩摞牌要比到各自最后一張,此時算法時間復雜度是T(n) = Θ(n),這是因為整個算法最多只要遍歷一遍。

偽碼

接下來,用偽碼實現上面的思想,但有兩個額外的變化:

  • 撲克應用中的兩摞牌已經排好序換一種表達方式:A是一個數組,p、q和r是數組下標,滿足p≤q<r,假設A[p ‥ q]和A[q+1 ‥ r]都已排好序。期望的輸出是:A的子數組A[p ‥ r]是通過合并原A[p ‥ q]和A[q+1 ‥ r]形成的且已排好序的子數組。
  • 為了避免每次執行基本步驟都要檢查是否有堆為空,在每個堆的底部放置一張“哨兵”牌(哨兵通常包含一個特殊值,用于簡化代碼),值為∞。它可以保證直到兩堆牌都露出∞時,其他牌都已經放置到輸出堆。因為我們事先知道剛好r - p + 1張牌將被放置到輸出堆,所以一旦已執行r - p + 1個基本步驟,算法就可以停止了。

定義算法的名字為MERGE,偽碼如下:

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] = ∞
9  R[n2+1] = ∞
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 A[k] = R[j]
17     j = j + 1 

正確性證明

證明算法的正確性中提到:只要證明在初始、保持、和終止階段循環不變式都成立,從而可以通過終止時的不變式推斷出算法是正確的。

代碼中的12~17行是唯一的循環,循環不變式是什么呢?這里我們令輸出A[p ‥ k-1]作為循環不變式,迭代的任何過程中隨k的增加該數組總是按從小到大的順序包含原A[p ‥ r]中最小的元素,有如下證明:

  • 初始化:循環第一次迭代之前,k = p,所以子數組A[p ‥ k-1]為空;
  • 保持:即要證明某次迭代之前不變式為真,下次迭代之前不變式仍為真;
    • 假設某次迭代前,L[i] ≤ R[j],此時L[i]是未被復制回數組A的最小元素;
    • 與此同時,數組A[p ‥ k-1]包含k - p個最小元素,即迭代前不變式為真;
    • 第14行代碼將L[i]復制到A[k]之后,子數組A[p ‥ k]將包含k - p + 1個最小元素。增加k的值(for循環)和i的值(第15行代碼)后,即為下次迭代前重新建立了該循環不變式;
    • 反之,若L[i] > R[j],則第16~17代碼執行適當的操作來維持該循環不變式。
  • 終止:終止時k = r + 1。子數組A[p ‥ k-1]就是A[p ‥ r]且按從小到大的順序包含了L[1 ‥ n1+1]和R[1 ‥ n2+1]中的k - p = r - p + 1個最小元素。數組L和R一共包含n1 + n2 + 2 = r - p + 3個元素,多出的2個就是哨兵,其他所有元素都已經被復制回數組A。

時間復雜度

前面提到過MERGE的時間復雜度是Θ(n),其中n = r - p + 1。再快速算下:

  • 代碼13行和811行中的每行需要常量時間;
  • 代碼4~7行的for循環需要Θ(n1+n2) = Θ(n)的時間;
  • 代碼12~17行for循環有n次迭代,每次迭代需要常量時間。

Java實現

public class MergeSort {
public static void mergeInASC(int[] numbers, int p, int q, int r) throws Exception {
    if(numbers.length < 2 || p > q || q >= r)
        throw new Exception("Para error.");

    int n1 = q - p + 1;
    int n2 = r - q;

    int[] L = new int[n1 + 1];
    int[] R = new int[n2 + 1];

    for(int i  = 0; i < n1; i++){
        L[i] = numbers[p + i];
    }
    for(int j = 0; j < n2; j++){
        R[j] = numbers[q + 1 + j];
    }

    L[n1] = Integer.MAX_VALUE;
    R[n2] = Integer.MAX_VALUE;

    int i = 0;
    int j = 0;
    for(int k = p; k <= r; k++){
        if(L[i] > R[j]){
            numbers[k] = R[j];
            j++;
        }
        else{
            numbers[k] = L[i];
            i++;
        }
    }
}
}

MergeSort.java下載

上一篇 4 時間復雜度

下一篇 6 歸并排序


共享協議:署名-非商業性使用-禁止演繹(CC BY-NC-ND 3.0 CN)
轉載請注明:作者黑猿大叔(簡書)

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容

  • 版本記錄 前言 將數據結構和算法比作計算機的基石毫不為過,追求程序的高效是每一個軟件工程師的夢想。下面就是我對算法...
    刀客傳奇閱讀 2,947評論 0 0
  • 概述 排序有內部排序和外部排序,內部排序是數據記錄在內存中進行排序,而外部排序是因排序的數據很大,一次不能容納全部...
    蟻前閱讀 5,220評論 0 52
  • 概述:排序有內部排序和外部排序,內部排序是數據記錄在內存中進行排序,而外部排序是因排序的數據很大,一次不能容納全部...
    每天刷兩次牙閱讀 3,743評論 0 15
  • 這是我們魅力講師課程的第四天。 上課之前張志剛老師會帶著我們一起練習腹式呼吸,一起練習發音。 這兩天他一直強調在演...
    語馨_f389閱讀 213評論 0 0
  • 古箏十級已經考完,我卻還是只有四五級的水平,甚至這還算高估了自己的。一切似乎又從頭來過一樣。一個指頭一個指頭的練,...
    維C牛肉粒閱讀 159評論 0 0