《演算法導論》學習筆記

迷路的約翰發表於2015-07-23

1.插入排序

JS實現如下:

function insertSort(arr){
    for(var i=1; i<arr.length; i++)
        for(var j=i-1;j>=0 && arr[j]>arr[j+1];j--)
            [arr[j], arr[j+1]] = [arr[j+1], arr[j]];
    return arr;
}

插入演算法的核心就是‘交換’二字。也就是程式碼中的第4行。

所以第4行的意思就是,如果arr[j]大於arr[j+1],那麼交換這兩個變數的值。其餘行就沒什麼可說的了,都是迴圈體,返回語句一類的必要的東西。

實現過程中出現過的bug:

  1 一開始沒有使用j變數,直接用的i-1,結果很明顯了,每次內部迴圈執行之後i--, 然後外部迴圈又i++,所以無限迴圈……火狐奔潰了……

  2 原先測試的時候使用的是while而不是for,當然這不是重點,重點是判斷的條件不是j>=0,而是arr[j]。我當時下意識地想啊如果arr[j]有意義那就執行迴圈體,當j<0的時候arr[j]變成undifined了就不執行了。稍微一想這實在是有點缺心眼,直接判斷j>0不就行了。而且真要判斷的話也不應該寫成while(arr[j]),而是while(arr[j]!=undefined), 不然碰到陣列裡的0就直接漏過去了。嗯所以下意識的想法往往禁不起推敲。最後為了縮短行數把while迴圈改寫成了for迴圈。

2 迴圈不變式

書中在介紹插入排序的正確性時拿出了這麼一個概念: 迴圈不變式,英文原文是loop invarition。判斷迴圈不變式的成立分為3個階段,若3個階段都成立則迴圈不變式成立。這3個階段就是:迴圈開始前,迴圈進行時,迴圈結束後。

迴圈不變式代表的是: 為了使迴圈保證演算法的有效性,必須在迴圈執行中一直保持為真值的某個條件,就好像是這樣子:

for(每個迴圈):
  if(!迴圈不變式)
    return 演算法不成立

 看起來就像是一個表示式,所以中文翻譯為 迴圈不變‘式’我覺得是有一定道理的。

具體到插入排序這個例子裡: 迴圈體的作用是每次迴圈,調整陣列中第0個數到第j個數的子陣列的有序狀態,使得其中的元素嚴格升序或降序排列。那麼為了保證迴圈不變式,就需要證明,在迴圈之前,arr.[0]到arr[j]是有序的;在迴圈的每個步驟結束後,arr[0]到arr[j]是有序的;迴圈終止時,保證迴圈終止條件正確,使得整個陣列是有序的。

在第一次迴圈開始之前,子陣列僅有一個數,因此是有序的,故階段一成立。

在每次迴圈迭代的過程中,如果arr[0]到arr[j-1]是有序的,我們的程式碼使得新加入的a[j]會依次移動到符合條件的位置,因此arr[0]到arr[j]也是有序的。因此階段二成立。

迴圈結束時,判斷條件為i=arr.length-1,此時已對arr[0]到arr[arr.length-1]進行排序,而這正是整個陣列,故階段三成立。

3 選擇排序

來自於第二章的課後題。描述是:首選選出陣列A中最小的數,然後與A[0]交換;接著找出第二小的數,與A[1]交換;直到遍歷結束。

虛擬碼實現:

Selection Sort Method(A):
    for i=0 to i=A.length-2:
        for j=i to j=A.length-1:
            let S = smallest variable among A[j] to A[A.length-1]
        swap A[i] and S
    return A

 JS實現

function selectionSort(N){
    for(var i=0; i<N.length-1; i++){
        var smallest=N[i],position=i;
        for(var j=i; j<N.length; j++)
            if(N[j]<smallest)
               [smallest,position] = [N[j],j];
        [N[i], N[position]] = [N[position], N[i]];
  }
    return N;
}

 選擇排序的每次迴圈要實現的目的是保證A[0]到A[i]之間的數有序(並且均小於後面的任何一個數)。同樣可以套用上面的步驟證明迴圈不變式的正確性。

4 歸併排序

歸併排序的概念大致上就是:假設有兩組已經排好序的序列,比如[1,3,5,7]和[2,4,6,8],我要將它們合併,形成一個新的有序序列[1,2,3,4,5,6,7,8]。怎麼做呢?很簡單,就像兩堆撲克牌,平放在桌面上,正面朝上,這時只能看到每一組的第一張,選出其中較小的一張放入佇列。接下來再從能看見的兩張裡選出一張,放入佇列……直到某一堆為空。

當然現實中往往沒有現成的兩組已經排好序的序列,比如是[1,5,3,7]和[2,6,4,8],這樣就不能直接套用上面的方法了。不過我們正好發現,給定的兩組序列正好各自符合上面的條件,那麼我們對每一組序列採用上面的方法排好序,再對這兩組序列進行排序就完工了。

如果還不是呢?那就繼續往下劃分。好在這樣的劃分是肯定能分到符合條件的情況的,因為劃分到最後序列裡只包含一個數的情況下那是肯定符合條件的。

書中給的虛擬碼如下:

MERGE-SORT(A, p, r)
    if p < r
        q = (p+r)/2
        MERGE-SORT(A, p, q);
        MERGE-SORT(A, q+1, r);
        MERGE(A, p, q, r);

 其中MERGE表示將兩組有序序列合併的方法。書中介紹的MERGE方法使用了一個‘哨兵牌’來簡化程式碼,即在每個陣列後面加上了一個無窮大的數,這樣就不必檢測兩個序列的數是否有一個被取完了。

根據上面的虛擬碼,JS實現如下:

function merge(A, p, q, r) {
    var L = [], R = [];
    L = A.slice(p, q+1);
    R = A.slice(q+1, r+1);
    L.push(Infinity);
    R.push(Infinity);

    var i = 0, j = 0;
    for(var k=p ; k<=r; k++) {
        if(L[i] < R[j]) {
            A[k] = L[i];
            i++;
        }
        else {
            A[k] = R[j];
            j++;
        }
    }
}

function mergeSort(A, p, r) {
    if(p < r) {
        var q = (p+r)>>1;
        mergeSort(A, p, q);
        mergeSort(A, (q+1), r);
        merge(A, p, q, r);
    }
}

實現過程中出現的一個嚴重的烏龍:在宣告q的時候忘記使用var語句,導致q成了一個全域性變數,每次遞迴呼叫時遞迴函式都會改變q的值而傳入父級遞迴函式,導致排序過程出現混亂。在我加上了無數console.log除錯之後發現q的值變化過程和草稿紙上寫的不一樣,才發現了這個顯而易見的錯誤,真是哭笑不得。

上面的MERGE實現過程採用了‘哨兵牌’來減少了程式碼的複雜程度。然而這樣無形中會增加了演算法的計算量。原本在MERGE函式裡,當發現其中一段序列已經遍歷完畢之後,應當直接將另一端序列取出即可。而採用了哨兵牌之後,在其中一列取完之後,另一列剩下的每一個數都要與INFINITY進行比較。這樣來說上面的這個演算法是不完美的。

其次MERGESORT過程也存在一定的問題,MERGESORT採用了很簡潔的遞迴形式,寥寥數行程式碼就完成了整個過程,然而簡潔的背後往往是複雜的計算。

舉例來說,對於序列[1,3,5,7,2,4,6,8],採用歸併排序,正常情況應該是這樣的:

  第一步,將序列分成左右兩部分[1,3,5,7]和[2,4,6,8]

  第二步,因兩部分有序,合併[1,3,5,7]和[2,4,6,8]得到結果

而在這個演算法裡,實際上是這樣的:

  第一步, 將序列分成[1,3,5,7]和[2,4,6,8]

      第二步, 將[1,3,,5,7]分成[1,3]和[5,7]

      第三步, 將[1,3]分成[1],[3]

  第四步,將[1] [3]合併成[1,3]

  第五步, 將[5,7]分成[5]和[7]

  ...

  第N步,合併[1,3,5,7] [2,4,6,8]得到[1,2,3,4,5,6,7,8]

所以這裡是強制將陣列劃分為單元陣列後再進行合併的。所以我認為其實上這個過程不用遞迴反而更加合適,直接將陣列分割為一個個單獨的數再進行合併豈不是省了好多步驟麼。

5 插入排序遞迴版

function insertSort_R(A, p) {
    if(p>0) {
        insertSort_R(A, p-1);
        for(var i=p- 1; i>=0; i--) {
            if(A[p] < A[i]) {
                [A[p], A[i]] = [A[i], A[p]];
                current--;
            }
        }
    }
}

 6 氣泡排序

這是許多人學習的第一個排序演算法吧。記得當初覺得它好複雜的說。

function bubbleSort(A){
    for(var i=0; i<A.length; i++){
        for(var j=A.length-1; j>=i; j--){
            if(A[j] < A[j-1])
                [A[j],A[j-1]]=[A[j-1],A[j]];
        }
    }
}

  7 分治法的應用 - 最大連續子序列和

才發現這個經典的問題原來出自這裡。

最大子序列和問題: 給定一個序列,求出該序列的所有連續子序列中和最大連續子序列,並求出這個子序列的和。

要應用分治法,首先要將子序列分類。這裡我們從元序列中取一個座標i,然後將所有 子序列分為3類: 第一類,所有元素的座標均<=i; 第二類,所有元素的座標均>i; 第三類,即存在<i又存在>i的元素的子序列。

分別求出這三類子序列中的最大成員,然後取其最大者即可得到答案。

 

光看程式碼可能有些不太好理解,這裡舉個實際的例子就好說多了:

給定序列A = [ -6,2,4,-7,5,3,2-1,6-9,10,-2]

選取A[5] = 3 作為中點,將序列分為兩部分[ -6,2,4,-7,5,3,2-1,6,-9,10,-2]

  第一類, 求序列[-6,2,4,-7,5,3]的最大子序列

    選取4為分割點,將其分割為兩個部分[-6,2,4,-7,5,3]

    第一類, 求序列[-6,2,4]的最大子序列

      嚴格來說還要繼續往下分隔,不過這裡已經足夠簡單了,可以目測出最大子序列為[2,4] = 6

    第二類, 求序列[-7,5,3]的最大子序列

      最大子序列為[5,3] = 8 

    第三類, 求包含兩部分的最大子序列

      最大子序列為[2,4,-7,5,3] = 7

    綜上可以得到左側的最大子序列[5,3] = 8

  

  第二類, 求序列[2,-1,6,-9,10,-2]的最大子序列

    選取6為分割點,將其分為兩列[2,-1,6,-9,10,-2]

    第一類 求[2,-1,6]的最大子序列

      可以看出結果為[2,-1,6] = 7

    第二類 求[-9,10,-2]的最大子序列

      可以看出結果為[10] = 10

    第三類 求包含兩部分的最大子序列

      可以看出結果為[2,-1,6,-9,10] = 8

    綜上可得到右側最大子序列[10] = 10

 

  第三類, 求包含兩部分的最大子序列

    這個子序列的左邊部分必須包含3,所以往左延伸就好了,不難看出3+5是最大值; 右邊部分必須包含2,不難看出2-1+6-9+10是最大值

    可以求出這個分類最大子序列為[3,5,2,-1,6,-9,10] = 16

 

最後,取三者中最大,得到最大子序列[3,5,2,-1,6,-9,10] = 16

 

下面是JS程式碼

function maxSubarrayCross(A, low, mid, high){
    var leftSum = -Infinity, rightSum = -Infinity;
    var sum = 0;
    var left = mid, right = mid;

    for(var i=mid; i>=low; i--){
        sum += A[i];
        if(sum > leftSum){
            leftSum = sum;
            left = i;
        }
    }

    sum = 0;
    for(var j=mid+1; j<=high; j++){
        sum += A[j];
        if(sum > rightSum){
            rightSum = sum;
            right = j;
        }
    }

    return [left, right, leftSum+rightSum];
}

function maxSubarray(A, low ,high){
    if(high == low)
        return [low, high, A[low]];
    else {
        var mid = low+high >> 1;
        var leftResult = maxSubarray(A, low, mid);
        var rightResult = maxSubarray(A, mid+1, high);
        var crossResult = maxSubarrayCross(A, low, mid, high);

        if(leftResult[2] > rightResult[2] && leftResult[2] > crossResult[2])
          return leftResult;
        else if(rightResult[2] > leftResult[2] && rightResult[2] > crossResult[2])
          return rightResult;
        else 
          return crossResult;
    }
}

 

 

 

 

--to be continue

相關文章