「演算法思想」分治、動態規劃、回溯、貪心一鍋燉

童歐巴發表於2020-06-15

圖怪獸_c5c42253908d961e6bf8aa14f1356dce_23687.png
觀感度:?????

口味:東北一鍋出

烹飪時間:10min

本文已收錄在Github github.com/Geekhyt,感謝Star。

資料結構與演算法系列專欄第四彈來襲,往期專欄連結如下:

初學者一聽到演算法思想,就會覺得它們高深莫測,只能望而卻步。

但如果你看過《事實》這本書,你就不會被大腦中的慣性思維所影響。
只要我們理解演算法思想的關鍵點,多做題練習並加深理解記憶。其實演算法思想就像切菜一樣簡單。

上一篇演算法系列專欄中我們搞明白了遞迴。其實遞迴這種程式設計技巧是很多演算法的基礎。

還看過的同學建議先移步這篇專欄你真的懂遞迴嗎?

比如本文講到的這幾種演算法思想,大部分都是基於遞迴思想基礎上的。

一句話理解四種演算法思想

分治:分而治之,先解決子問題,再將子問題的解合併求出原問題。

貪心:一條路走到黑,選擇當下區域性最優的路線,沒有後悔藥。

回溯:一條路走到黑,手握後悔藥,可以無數次重來。(英雄聯盟艾克大招無冷卻)。

動態規劃:上帝視角,手握無數平行宇宙的歷史存檔,同時發展出無數個未來。

接下來我們一起庖丁解牛,將這幾種演算法思想一鍋燉。

分治演算法 Divide and Conquer

分治演算法思想很大程度上是基於遞迴的,也比較適合用遞迴來實現。顧名思義,分而治之。一般分為以下三個過程:

  • 分解:將原問題分解成一系列子問題。
  • 解決:遞迴求解各個子問題,若子問題足夠小,則直接求解。
  • 合併:將子問題的結果合併成原問題。

比較經典的應用就是歸併排序 (Merge Sort) 以及快速排序 (Quick Sort) 等。我們來從歸併排序理解分治思想,歸併排序就是將待排序陣列不斷二分為規模更小的子問題處理,再將處理好的子問題合併起來。

上程式碼。

const mergeSort = function(arr) {
    const len = arr.length;
    if (len > 1) {
        // 對半分解
        const middle = Math.floor(len / 2);
        const left = arr.slice(0, middle);
        const right = arr.slice(middle, len);
        let i = 0; 
        let j = 0;
        let k = 0;
        // 分別對左右進行排序
        mergeSort(left);
        mergeSort(right);
        while(i < left.length && j < right.length) {
            if (left[i] < right[j]) {
                arr[k] = left[i];
                i++;
            } else {
                arr[k] = right[j];
                j++;
            }
            k++;
        }
        // 檢查餘項
        while(i < left.length) {
            arr[k] = left[i];
            i++;
            k++;
        }
        while(j < right.length) {
            arr[k] = right[j];
            j++;
            k++;
        }
    }
    return arr;
}

複雜度分析

  • 時間複雜度:O(nlogn)
  • 空間複雜度:O(n)

動態規劃 Dynamic Programming

LeetCode真題

70. 爬樓梯

雖然動態規劃的最終版本 (降維再去維) 大都不是遞迴,但解題的過程還是離開不遞迴的。新手可能會覺得動態規劃思想接受起來比較難,確實,動態規劃求解問題的過程不太符合人類常規的思維方式,我們需要切換成機器思維。

使用動態規劃思想解題,首先要明確動態規劃的三要素。

動態規劃三要素

  • 重疊子問題
  • 最優子結構
  • 狀態轉移方程

重疊子問題

切換機器思維,自底向上思考。

爬第 n 階樓梯的方法數量,等於兩部分之和:

  • 爬上 n-1 階樓梯的方法數量
  • 爬上 n-2 階樓梯的方法數量

最優子結構

子問題的最優解能夠推出原問題的優解。

狀態轉移方程

dp[n] = dp[n-1] + dp[n-2]

具備三要素,確認邊界條件,初始化狀態,開始切菜:

  • dp[0] = 1
  • dp[1] = 1
const climbStairs = function(n) {
    const dp = [];
    dp[0] = 1;
    dp[1] = 1;
    for (let i = 2; i <= n; i++) {
        dp[i] = dp[i-1] + dp[i-2];
    }
    return dp[n];
};

複雜度分析

  • 時間複雜度:O(n)
  • 空間複雜度:O(n)

優化

在此基礎上,我們還可以通過壓縮空間來對演算法進行優化。因為 dp[i]只與 dp[i-1]dp[i-2] 有關,沒有必要儲存所有出現過的 dp 項,只用兩個臨時變數去儲存這兩個狀態即可。

const climbStairs = function(n) {
    let a1 = 1;
    let a2 = 1;
    for (let i = 2; i <= n; i++) {
        [a1, a2] = [a2, a1 + a2];
    }
    return a2;
}

複雜度分析

  • 時間複雜度:O(n)
  • 空間複雜度:O(1)

貪心演算法 Greedy

最近某音很火的貪心土味情話

喂,不是吧。今天喝了脈動啊,吃了果凍啊,但是,還是忍不住對你心動啊。

回到演算法中,貪心演算法動態規劃演算法的一個子集,可以更高效解決一部分更特殊的問題。實際上,用貪心演算法解決問題的思路,並不總能給出最優解。因為它在每一步的決策中,選擇目前最優策略,不考慮全域性是不是最優。

LeetCode真題

LeetCode 455. 分發餅乾

思路

貪心演算法+雙指標求解。

  • 給一個孩子的餅乾應當儘量小並且能滿足孩子,大的留來滿足胃口大的孩子
  • 因為胃口小的孩子最容易得到滿足,所以優先滿足胃口小的孩子需求
  • 按照從小到大的順序使用餅乾嘗試是否可滿足某個孩子
  • 當餅乾 j >= 胃口 i 時,餅乾滿足胃口,更新滿足的孩子數並移動指標 i++ j++ res++
  • 當餅乾 j < 胃口 i 時,餅乾不能滿足胃口,需要換大的 j++

關鍵點

將需求因子 g 和 s 分別從小到大進行排序,使用貪心思想配合雙指標,每個餅乾只嘗試一次,成功則換下一個孩子來嘗試。

複雜度分析

  • 時間複雜度:O(nlogn)
  • 空間複雜度:O(1)
const findContentChildren = function (g, s) {
    g = g.sort((a, b) => a - b);
    s = s.sort((a, b) => a - b);
    let gi = 0; // 胃口值
    let sj = 0; // 餅乾尺寸
    let res = 0;
    while (gi < g.length && sj < s.length) {
        if (s[sj] >= g[gi]) {
            gi++;
            sj++;
            res++;
        } else {
            sj++;
        }
    }
    return res;
};

回溯演算法 Backtracking

回溯演算法本質上就是列舉,使用摸著石頭過河的查詢策略,還可以通過剪枝少走冤枉路。

LeetCode真題

LeetCode 17.電話號碼的字母組合

思路

使用回溯法進行求解,回溯是一種通過窮舉所有可能情況來找到所有解的演算法。如果一個候選解最後被發現並不是可行解,回溯演算法會捨棄它,並在前面的一些步驟做出一些修改,並重新嘗試找到可行解。究其本質,其實就是列舉。

如果沒有更多的數字需要被輸入,說明當前的組合已經產生。

如果還有數字需要被輸入:

  • 遍歷下一個數字所對應的所有對映的字母
  • 將當前的字母新增到組合最後,也就是 str + tmp[r]

關鍵點

在for迴圈中呼叫遞迴。

複雜度分析

N+M 是輸入數字的總數

  • 時間複雜度:O(3^N * 4^M)
  • 空間複雜度:O(3^N * 4^M)
const letterCombinations = function (digits) {
    if (!digits) {
        return [];
    }
    const len = digits.length;
    const map = new Map();
    map.set('2', 'abc');
    map.set('3', 'def');
    map.set('4', 'ghi');
    map.set('5', 'jkl');
    map.set('6', 'mno');
    map.set('7', 'pqrs');
    map.set('8', 'tuv');
    map.set('9', 'wxyz');
    const result = [];

    function generate(i, str) {
        if (i == len) {
            result.push(str);
            return;
        }
        const tmp = map.get(digits[i]);
        for (let r = 0; r < tmp.length; r++) {
            generate(i + 1, str + tmp[r]);
        }
    }
    generate(0, '');
    return result;
};

相關文章