有了四步解題法模板,再也不害怕動態規劃!(看不懂算我輸)

導言

動態規劃問題一直是算法面試當中的重點和難點,並且動態規劃這種通過空間換取時間的算法思想在實際的工作中也會被頻繁用到,這篇文章的目的主要是解釋清楚 什麼是動態規劃,還有就是面對一道動態規劃問題,一般的 思考步驟 以及其中的注意事項等等,最後通過幾道題目將理論和實踐結合。

什麼是動態規劃

如果你還沒有聽說過動態規劃,或者僅僅只有耳聞,或許你可以看看 Quora 上面的這個 回答

How to explain dynamic

用一句話解釋動態規劃就是 “記住你之前做過的事”,如果更準確些,其實是 “記住你之前得到的答案”。

我舉個大家工作中經常遇到的例子。

在軟件開發中,大家經常會遇到一些系統配置的問題,配置不對,系統就會報錯,這個時候一般都會去 Google 或者是查閱相關的文檔,花了一定的時間將配置修改好。

過了一段時間,去到另一個系統,遇到類似的問題,這個時候已經記不清之前修改過的配置文件長什麼樣,這個時候有兩種方案,一種方案還是去 Google 或者查閱文檔,另一種方案是借鑒之前修改過的配置,第一種做法其實是萬金油,因為你遇到的任何問題其實都可以去 Google,去查閱相關文件找答案,但是這會花費一定的時間,相比之下,第二種方案肯定會更加地節約時間,但是這個方案是有條件的,條件如下:

  • 之前的問題和當前的問題有着關聯性,換句話說,之前問題得到的答案可以幫助解決當前問題
  • 需要記錄之前問題的答案

當然在這個例子中,可以看到的是,上面這兩個條件均滿足,大可去到之前配置過的文件中,將配置拷貝過來,然後做些細微的調整即可解決當前問題,節約了大量的時間。

不知道你是否從這些描述中發現,對於一個動態規劃問題,我們只需要從兩個方面考慮,那就是 找出問題之間的聯繫,以及 記錄答案,這裏的難點其實是找出問題之間的聯繫,記錄答案只是順帶的事情,利用一些簡單的數據結構就可以做到。

概念

上面的解釋如果大家可以理解的話,接

  動態規劃算法是通過拆分問題,定義問題狀態和狀態之間的關係,使得問題能夠以遞推(或者說分治)的方式去解決。它的幾個重要概念如下所述。

  階段:對於一個完整的問題過程,適當的切分為若干個相互聯繫的子問題,每次在求解一個子問題,則對應一個階段,整個問題的求解轉化為按照階段次序去求解。

  狀態:狀態表示每個階段開始時所處的客觀條件,即在求解子問題時的已知條件。狀態描述了研究的問題過程中的狀況。

  決策:決策表示當求解過程處於某一階段的某一狀態時,可以根據當前條件作出不同的選擇,從而確定下一個階段的狀態,這種選擇稱為決策。

  策略:由所有階段的決策組成的決策序列稱為全過程策略,簡稱策略。

  最優策略:在所有的策略中,找到代價最小,性能最優的策略,此策略稱為最優策略。

  狀態轉移方程:狀態轉移方程是確定兩個相鄰階段狀態的演變過程,描述了狀態之間是如何演變的。

思考動態規劃問題的四個步驟

一般解決動態規劃問題,分為四個步驟,分別是

  • 問題拆解,找到問題之間的具體聯繫
  • 狀態定義
  • 遞推方程推導
  • 實現

這裏面的重點其實是前兩個,如果前兩個步驟順利完成,後面的遞推方程推導和代碼實現會變得非常簡單。

這裏還是拿 Quora 上面的例子來講解,“1+1+1+1+1+1+1+1” 得出答案是 8,那麼如何快速計算 “1+ 1+1+1+1+1+1+1+1”,我們首先可以對這個大的問題進行拆解,這裏我說的大問題是 9 個 1 相加,這個問題可以拆解成 1 + “8 個 1 相加的答案”,8 個 1 相加繼續拆,可以拆解成 1 + “7 個 1 相加的答案”,… 1 + “0 個 1 相加的答案”,到這裏,第一個步驟 已經完成。

狀態定義 其實是需要思考在解決一個問題的時候我們做了什麼事情,然後得出了什麼樣的答案,對於這個問題,當前問題的答案就是當前的狀態,基於上面的問題拆解,你可以發現兩個相鄰的問題的聯繫其實是 后一個問題的答案 = 前一個問題的答案 + 1,這裏,狀態的每次變化就是 +1。

定義好了狀態,遞推方程就變得非常簡單,就是 dp[i] = dp[i - 1] + 1,這裏的 dp[i] 記錄的是當前問題的答案,也就是當前的狀態,dp[i - 1] 記錄的是之前相鄰的問題的答案,也就是之前的狀態,它們之間通過 +1 來實現狀態的變更。

最後一步就是實現了,有了狀態表示和遞推方程,實現這一步上需要重點考慮的其實是初始化,就是用什麼樣的數據結構,根據問題的要求需要做那些初始值的設定。

public int dpExample(int n) {
    int[] dp = new int[n + 1];  // 多開一位用來存放 0 個 1 相加的結果

    dp[0] = 0;      // 0 個 1 相加等於 0

    for (int i = 1; i <= n; ++i) {
        dp[i] = dp[i - 1] + 1;
    }

    return dp[n];
}

你可以看到,動態規劃這四個步驟其實是相互遞進的,狀態的定義離不開問題的拆解,遞推方程的推導離不開狀態的定義,最後的實現代碼的核心其實就是遞推方程,這中間如果有一個步驟卡殼了則會導致問題無法解決,當問題的複雜程度增加的時候,這裏面的思維複雜程度會上升。

接下來我們再來看看 LeetCode 上面的幾道題目,通過題目再來走一下這些個分析步驟。

題目實戰

爬樓梯

但凡涉及到動態規劃的題目都離不開一道例題:爬樓梯(LeetCode 第 70 號問題)。

題目描述

假設你正在爬樓梯。需要 n 階你才能到達樓頂。

每次你可以爬 1 或 2 個台階。你有多少種不同的方法可以爬到樓頂呢?

注意:給定 n 是一個正整數。

示例 1:

輸入: 2
輸出: 2
解釋: 有兩種方法可以爬到樓頂。

1. 1 階 + 1 階
2. 2 階

示例 2:

輸入: 3
輸出: 3
解釋: 有三種方法可以爬到樓頂。

1. 1 階 + 1 階 + 1 階
2. 1 階 + 2 階
3. 2 階 + 1 階

題目解析

爬樓梯,可以爬一步也可以爬兩步,問有多少種不同的方式到達終點,我們按照上面提到的四個步驟進行分析:

  • 問題拆解:

    我們到達第 n 個樓梯可以從第 n – 1 個樓梯和第 n – 2 個樓梯到達,因此第 n 個問題可以拆解成第 n – 1 個問題和第 n – 2 個問題,第 n – 1 個問題和第 n – 2 個問題又可以繼續往下拆,直到第 0 個問題,也就是第 0 個樓梯 (起點)

  • 狀態定義

    “問題拆解” 中已經提到了,第 n 個樓梯會和第 n – 1 和第 n – 2 個樓梯有關聯,那麼具體的聯繫是什麼呢?你可以這樣思考,第 n – 1 個問題裏面的答案其實是從起點到達第 n – 1 個樓梯的路徑總數,n – 2 同理,從第 n – 1 個樓梯可以到達第 n 個樓梯,從第 n – 2 也可以,並且路徑沒有重複,因此我們可以把第 i 個狀態定義為 “從起點到達第 i 個樓梯的路徑總數”,狀態之間的聯繫其實是相加的關係。

  • 遞推方程

    “狀態定義” 中我們已經定義好了狀態,也知道第 i 個狀態可以由第 i – 1 個狀態和第 i – 2 個狀態通過相加得到,因此遞推方程就出來了 dp[i] = dp[i - 1] + dp[i - 2]

  • 實現

    你其實可以從遞推方程看到,我們需要有一個初始值來方便我們計算,起始位置不需要移動 dp[0] = 0,第 1 層樓梯只能從起始位置到達,因此 dp[1] = 1,第 2 層樓梯可以從起始位置和第 1 層樓梯到達,因此 dp[2] = 2,有了這些初始值,後面就可以通過這幾個初始值進行遞推得到。

參考代碼

public int climbStairs(int n) {
    if (n == 1) {
        return 1;
    }

    int[] dp = new int[n + 1];  // 多開一位,考慮起始位置

    dp[0] = 0; dp[1] = 1; dp[2] = 2;
    for (int i = 3; i <= n; ++i) {
        dp[i] = dp[i - 1] + dp[i - 2];
    }

    return dp[n];
}

三角形最小路徑和

LeetCode 第 120 號問題:三角形最小路徑和。

題目描述

給定一個三角形,找出自頂向下的最小路徑和。每一步只能移動到下一行中相鄰的結點上。

例如,給定三角形:

[
     [2],
    [3,4],
   [6,5,7],
  [4,1,8,3]
]

自頂向下的最小路徑和為 11(即,2 + 3 + 5 + 1 = 11)。

說明:

如果你可以只使用 O(n) 的額外空間(n 為三角形的總行數)來解決這個問題,那麼你的算法會很加分。

題目解析

給定一個三角形數組,需要求出從上到下的最小路徑和,也和之前一樣,按照四個步驟來分析:

  • 問題拆解:

    這裏的總問題是求出最小的路徑和,路徑是這裏的分析重點,路徑是由一個個元素組成的,和之前爬樓梯那道題目類似,[i][j] 位置的元素,經過這個元素的路徑肯定也會經過 [i - 1][j] 或者 [i - 1][j - 1],因此經過一個元素的路徑和可以通過這個元素上面的一個或者兩個元素的路徑和得到。

  • 狀態定義

    狀態的定義一般會和問題需要求解的答案聯繫在一起,這裏其實有兩種方式,一種是考慮路徑從上到下,另外一種是考慮路徑從下到上,因為元素的值是不變的,所以路徑的方向不同也不會影響最後求得的路徑和,如果是從上到下,你會發現,在考慮下面元素的時候,起始元素的路徑只會從[i - 1][j] 獲得,每行當中的最後一個元素的路徑只會從 [i - 1][j - 1] 獲得,中間二者都可,這樣不太好實現,因此這裏考慮從下到上的方式,狀態的定義就變成了 “最後一行元素到當前元素的最小路徑和”,對於 [0][0] 這個元素來說,最後狀態表示的就是我們的最終答案。

  • 遞推方程

    “狀態定義” 中我們已經定義好了狀態,遞推方程就出來了

    dp[i][j] = Math.min(dp[i + 1][j], dp[i + 1][j + 1]) + triangle[i][j]
  • 實現

    這裏初始化時,我們需要將最後一行的元素填入狀態數組中,然後就是按照前面分析的策略,從下到上計算即可

參考代碼

public int minimumTotal(List<List<Integer>> triangle) {
    int n = triangle.size();

    int[][] dp = new int[n][n];

    List<Integer> lastRow = triangle.get(n - 1);

    for (int i = 0; i < n; ++i) {
        dp[n - 1][i] = lastRow.get(i);
    }

    for (int i = n - 2; i >= 0; --i) {
        List<Integer> row = triangle.get(i);
        for (int j = 0; j < i + 1; ++j) {
            dp[i][j] = Math.min(dp[i + 1][j], dp[i + 1][j + 1]) + row.get(j);
        }
    }

    return dp[0][0];
}

最大子序和

LeetCode 第 53 號問題:最大子序和。

題目描述

給定一個整數數組 nums ,找到一個具有最大和的連續子數組(子數組最少包含一個元素),返回其最大和。

示例:

輸入: [-2,1,-3,4,-1,2,1,-5,4],
輸出: 6
解釋: 連續子數組 [4,-1,2,1] 的和最大,為 6。

進階:

如果你已經實現複雜度為 O(n) 的解法,嘗試使用更為精妙的分治法求解。

題目解析

求最大子數組和,非常經典的一道題目,這道題目有很多種不同的做法,而且很多算法思想都可以在這道題目上面體現出來,比如動態規劃、貪心、分治,還有一些技巧性的東西,比如前綴和數組,這裏還是使用動態規劃的思想來解題,套路還是之前的四步驟:

  • 問題拆解:

    問題的核心是子數組,子數組可以看作是一段區間,因此可以由起始點和終止點確定一個子數組,兩個點中,我們先確定一個點,然後去找另一個點,比如說,如果我們確定一個子數組的截止元素在 i 這個位置,這個時候我們需要思考的問題是 “以 i 結尾的所有子數組中,和最大的是多少?”,然後我們去試着拆解,這裏其實只有兩種情況:

  • i 這個位置的元素自成一個子數組

  • i 位置的元素的值 + 以 i – 1 結尾的所有子數組中的子數組和最大的值

    你可以看到,我們把第 i 個問題拆成了第 i – 1 個問題,之間的聯繫也變得清晰

  • 狀態定義

    通過上面的分析,其實狀態已經有了,dp[i] 就是 “以 i 結尾的所有子數組的最大值

  • 遞推方程

    拆解問題的時候也提到了,有兩種情況,即當前元素自成一個子數組,另外可以考慮前一個狀態的答案,於是就有了

    dp[i] = Math.max(dp[i - 1] + array[i], array[i])

    化簡一下就成了:

    dp[i] = Math.max(dp[i - 1], 0) + array[i]
  • 實現

    題目要求子數組不能為空,因此一開始需要初始化,也就是 dp[0] = array[0],保證最後答案的可靠性,另外我們需要用一個變量記錄最後的答案,因為子數組有可能以數組中任意一個元素結尾

參考代碼

public int maxSubArray(int[] nums{
    if (nums == null || nums.length == 0) {
        return 0;
    }

    int n = nums.length;

    int[] dp = new int[n];

    dp[0] = nums[0];

    int result = dp[0];

    for (int i = 1; i < n; ++i) {
        dp[i] = Math.max(dp[i - 1], 0) + nums[i];
        result = Math.max(result, dp[i]);
    }

    return result;
}

總結

通過這幾個簡單的例子,相信你不難發現,解動態規劃題目其實就是拆解問題,定義狀態的過程,嚴格說來,動態規劃並不是一個具體的算法,而是凌駕於算法之上的一種 思想

這種思想強調的是從局部最優解通過一定的策略推得全局最優解,從子問題的答案一步步推出整個問題的答案,並且利用空間換取時間。從很多算法之中你都可以看到動態規劃的影子,所以,還是那句話 技術都是相通的,找到背後的本質思想是關鍵

公眾號:五分鐘學算法(ID:CXYxiaowu)
博客:www.cxyxiaowu.com(目前更新了 500 篇算法文章,歡迎訪問學習)
知乎:程序員吳師兄
一個正在學習算法的人,致力於將算法講清楚​!

本站聲明:網站內容來源於博客園,如有侵權,請聯繫我們,我們將及時處理

【其他文章推薦】

台北網頁設計公司這麼多,該如何挑選?? 網頁設計報價省錢懶人包"嚨底家"

網頁設計公司推薦更多不同的設計風格,搶佔消費者視覺第一線

※想知道購買電動車哪裡補助最多?台中電動車補助資訊懶人包彙整

南投搬家費用,距離,噸數怎麼算?達人教你簡易估價知識!