動態規劃問題一直是算法面試當中的重點和難點,而且動態規劃這種經過空間換取時間的算法思想在實際的工做中也會被頻繁用到,這篇文章的目的主要是解釋清楚 什麼是動態規劃,還有就是面對一道動態規劃問題,通常的 思考步驟 以及其中的注意事項等等,最後經過幾道題目將理論和實踐結合。java
若是你尚未據說過動態規劃,或者僅僅只有耳聞,或許你能夠看看 Quora 上面的這個 回答。程序員
用一句話解釋動態規劃就是 「記住你以前作過的事」,若是更準確些,實際上是 「記住你以前獲得的答案」。web
我舉個你們工做中常常遇到的例子。面試
在軟件開發中,你們常常會遇到一些系統配置的問題,配置不對,系統就會報錯,這個時候通常都會去 Google 或者是查閱相關的文檔,花了必定的時間將配置修改好。算法
過了一段時間,去到另外一個系統,遇到相似的問題,這個時候已經記不清以前修改過的配置文件長什麼樣,這個時候有兩種方案,一種方案仍是去 Google 或者查閱文檔,另外一種方案是借鑑以前修改過的配置,第一種作法實際上是萬金油,由於你遇到的任何問題其實均可以去 Google,去查閱相關文件找答案,可是這會花費必定的時間,相比之下,第二種方案確定會更加地節約時間,可是這個方案是有條件的,條件以下:json
固然在這個例子中,能夠看到的是,上面這兩個條件均知足,大可去到以前配置過的文件中,將配置拷貝過來,而後作些細微的調整便可解決當前問題,節約了大量的時間。數組
不知道你是否從這些描述中發現,對於一個動態規劃問題,咱們只須要從兩個方面考慮,那就是 找出問題之間的聯繫,以及 記錄答案,這裏的難點實際上是找出問題之間的聯繫,記錄答案只是順帶的事情,利用一些簡單的數據結構就能夠作到。markdown
上面的解釋若是你們能夠理解的話,接數據結構
動態規劃算法是經過拆分問題,定義問題狀態和狀態之間的關係,使得問題可以以遞推(或者說分治)的方式去解決。它的幾個重要概念以下所述。app
階段:對於一個完整的問題過程,適當的切分爲若干個相互聯繫的子問題,每次在求解一個子問題,則對應一個階段,整個問題的求解轉化爲按照階段次序去求解。
狀態:狀態表示每一個階段開始時所處的客觀條件,即在求解子問題時的已知條件。狀態描述了研究的問題過程當中的情況。
決策:決策表示當求解過程處於某一階段的某一狀態時,能夠根據當前條件做出不一樣的選擇,從而肯定下一個階段的狀態,這種選擇稱爲決策。
策略:由全部階段的決策組成的決策序列稱爲全過程策略,簡稱策略。
最優策略:在全部的策略中,找到代價最小,性能最優的策略,此策略稱爲最優策略。
狀態轉移方程:狀態轉移方程是肯定兩個相鄰階段狀態的演變過程,描述了狀態之間是如何演變的。
通常解決動態規劃問題,分爲四個步驟,分別是
這裏面的重點實際上是前兩個,若是前兩個步驟順利完成,後面的遞推方程推導和代碼實現會變得很是簡單。
這裏仍是拿 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 篇算法文章,歡迎訪問學習)
知乎:程序員吳師兄
一個正在學習算法的人,致力於將算法講清楚!