分治法演算法學習(一)——歸併排序、求最大子陣列和

TripleGold.發表於2020-12-24

最近在學習演算法,看了MOOC上北航童詠昕老師的課後收穫很多,這篇文章算是我的學習筆記。

什麼是分治法?

這個問題其實很好回答,就是將一個大問題拆解成很多個小問題,依次解決每個小問題,最後整理結果合併為大問題的答案,兵法中這叫逐個擊破。

哪些時候適合用分治法?

以分治法的三個步驟為思路,首先這個問題要能被拆解,這點大部分問題都滿足,因為問題的複雜性往往和問題的規模正相關。其次是能被拆解為若干個相同的子問題,即具有最優子結構性質。最後也是分治法最關鍵的特點,即每個子問題的解最終能合併為大問題的解。

分治法框架

  1. 分解原問題
  2. 解決子問題
  3. 合併問題解

典型案例——歸併排序


首先是分解:

  function mergeSort(arr) {
    let len = arr.length
    if (len < 2) return arr
    let mid = Math.floor(len/2)
    let left=arr.slice(0,mid)
    let right=arr.slice(mid)
    return merge(mergeSort(left),mergeSort(right))
}

可以看到我們這裡遞迴地將原陣列分解為兩個子陣列,既然是遞迴那麼終止條件尤為重要,在本題中為len<2,即被分解為僅有一個元素的陣列。其次是解決子問題,也就是merge()函式的編寫

function merge(left,right){
    let result=[]
    while(left.length&&right.length){
        if(left[0]>=right[0]){
            result.push(right.shift())
        }
        else {
            result.push(left.shift())
        }
    }
    while(left.length){
        result.push(left.shift())
    }
    while(right.length){
        result.push(right.shift())
    }
    return result
}

因為每個子陣列都是被排好序的(當僅有一個元素時也相當於排好序了),所以從每個子陣列的頭部開始依次比較,這樣也就利用了每個子問題的特點。本題因為恰好是排序所以在最關鍵的合併部分體現的並不是那麼明顯,那麼來看看下一個例子。

求最大子陣列和

給定一個陣列,求陣列中最大的子陣列和。

首先這道題暴力求解怎麼做?例舉出所有子陣列和然後比大小,2個迴圈分別表示子陣列頭尾列舉出所有情況,最後一個迴圈求和,演算法複雜度為O(n3)。優化一下,在每次子陣列頭部不變尾巴變長時我們可以利用之前的結果,使用迭代的技巧,這樣就減少了一個迴圈,演算法複雜度為O(n2)。那麼還可以更快嘛?這裡給出了分治法的求解思路:


本題的難點就在於最後合併的設計,因為是將原本連續的陣列拆解了,因此在求解子問題的過程中並沒有考慮到跨越分割點的子陣列,所以在最後合併問題時我們需要把這種情況重新考慮。(可以說本題的子問題間並不是完全獨立的,它們有相交的部分,對於這種情況雖然分治法可以解,但是常常使用動態規劃)。

解釋下程式碼中的maxl,maxm,maxr分別代表著左邊的最大子陣列和,跨越分割點的最大子陣列和,右邊的最大子陣列和。maxCrossSubArray()函式則專門用來求解跨越分割點的最大子陣列和。(所以說子問題相關時分治法的設計會變難很多啊!)

function maxSubArray(arr, left, right) {
    let max = 0
    if (left === right) {
        return arr[left]
    } else {
        let mid = Math.floor((left + right) / 2)
        let maxl = maxSubArray(arr, left, mid)
        let maxr = maxSubArray(arr, mid + 1, right)
        let maxm = maxCrossSubArray(arr, left, mid, right)
        max = maxl >= maxm ? maxl : maxm
        max = max >= maxr ? max : maxr
        return max
    }
}

重點考慮如何設計maxCrossSubArray()函式,圖中S3就表示跨越分割點的這種情況對應的最大子陣列和。

Left我們可以從分割點開始向左遍歷,Right則從分割點開始向右遍歷,分別求出Left和RIght的最大子陣列和後相加即可。

function maxCrossSubArray(arr, left, mid, right) {
    let suml = -Infinity,
        sumr = -Infinity,
        sum = 0
    let templ = 0,
        tempr = 0
    for (let i = mid; i >= left; i--) {
        templ = templ + arr[i]
        suml = templ > suml ? templ : suml
    }
    for (let j = mid + 1; j <= right; j++) {
        tempr = tempr + arr[j]
        if (tempr > sumr) sumr = tempr
    }
    sum = suml + sumr
    return sum
}

可見子問題相關確實令分治法的求解複雜了很多。關於這題其實還可以讓我們輸出最大子陣列,無非是將程式碼中的sum改為陣列用來記錄每次新增的元素,隨後求和比較,核心思想是沒有改變的。

總結

分治法思想很簡單,但是對應到實際問題中細節確實非常多,尤其是當子問題相關時,體現在最後合併的設計上需要考慮很多新情況。不過這個思想不僅僅對我們的程式設計有幫助,在解決很多問題是都可以嘗試分而治之。我始終認為學習帶給我的提升是多方面的,在鍛鍊程式設計能力的同時,實現個人思維方式的昇華才是更有意義的。

相關文章