導言:java
動態規劃問題一直是算法面試當中的重點和難點,而且動態規劃這種經過空間換取時間的算法思想在實際的工做中也會被頻繁用到,這篇文章的目的主要是解釋清楚什麼是動態規劃,還有就是面對一道動態規劃問題,通常的思考步驟以及其中的注意事項等等,最後經過幾道題目將理論和實踐結合面試
若是你尚未據說過動態規劃,或者僅僅只有耳聞,或許你能夠看看 Quora 上面的這個 回答。用一句話解釋動態規劃就是 「記住你以前作過的事」,若是更準確些,實際上是 「記住你以前獲得的答案」。我舉個本身工做中常常遇到的例子,在軟件開發中,我常常會遇到一些系統配置的問題,配置不對,系統就會報錯,這個時候我通常都會去 Google 或者是查閱相關的文檔,花了必定的時間將配置修改好,過了一段時間,我去到另外一個系統,遇到相似的問題,這個時候我已經記不清以前修改過的配置文件長什麼樣,這個時候有兩種方案,一種方案仍是去 Google 或者查閱文檔,另外一種方案是借鑑以前修改過的配置,第一種作法實際上是萬金油,由於你遇到的任何問題其實均可以去 Google,去查閱相關文件找答案,可是這會花費必定的時間,相比之下,第二種方案確定會更加地節約時間,可是這個方案是有條件的,條件以下:算法
固然在這個例子中,能夠看到的是,上面這兩個條件均知足,我大可去到以前配置過的文件中,將配置拷貝過來,而後作些細微的調整便可解決當前問題,節約了大量的時間。數組
不知道你是否從這些描述中發現,對於一個動態規劃問題,咱們只須要從兩個方面考慮,那就是找出問題之間的聯繫,以及記錄答案,這裏的難點實際上是找出問題之間的聯繫,記錄答案只是順帶的事情,利用一些簡單的數據結構就能夠作到。bash
通常解決動態規劃問題,分爲四個步驟,分別是數據結構
這裏面的重點實際上是前兩個,若是前兩個步驟順利完成,後面的遞推方程推導和代碼實現會變得很是簡單。這裏仍是拿 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。spa
定義好了狀態,遞推方程就變得很是簡單,就是 dp[i] = dp[i - 1] + 1
,這裏的 dp[i]
記錄的是當前問題的答案,也就是當前的狀態,dp[i - 1]
記錄的是以前相鄰的問題的答案,也就是以前的狀態,它們之間經過 +1 來實現狀態的變動。code
最後一步就是實現了,有了狀態表示和遞推方程,實現這一步上須要重點考慮的實際上是初始化,就是用什麼樣的數據結構,根據問題的要求須要作那些初始值的設定。leetcode
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 上面的幾道題目,經過題目再來走一下這些個分析步驟。
題目解析:
爬樓梯,能夠爬一步也能夠爬兩步,問有多少種不一樣的方式到達終點,咱們按照上面提到的四個步驟進行分析:
問題拆解:
咱們到達第 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];
}
複製代碼
題目解析:
給定一個三角形數組,須要求出從上到下的最小路徑和,也和以前同樣,按照四個步驟來分析:
問題拆解:
這裏的總問題是求出最小的路徑和,路徑是這裏的分析重點,路徑是由一個個元素組成的,和以前爬樓梯那道題目相似,[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];
}
複製代碼
這裏有一個小小的空間上面的優化,就是每次咱們更新狀態(dp)數組都是基於以前的結果,咱們並不須要知道以前的以前的結果,平行的狀態之間也沒有相互影響,所以只用開一維數組便可
參考代碼(空間優化後)
public int minimumTotal(List<List<Integer>> triangle) {
int n = triangle.size();
int[] dp = new int[n];
List<Integer> lastRow = triangle.get(n - 1);
for (int i = 0; i < n; ++i) {
dp[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) { // i + 1 == row.size()
dp[j] = Math.min(dp[j], dp[j + 1]) + row.get(j);
}
}
return dp[0];
}
複製代碼
題目解析:
求最大子數組和,很是經典的一道題目,這道題目有不少種不一樣的作法,並且不少算法思想均可以在這道題目上面體現出來,好比動態規劃、貪心、分治,還有一些技巧性的東西,好比前綴和數組,這裏仍是使用動態規劃的思想來解題,套路仍是以前的四步驟:
問題拆解:
問題的核心是子數組,子數組能夠看做是一段區間,所以能夠由起始點和終止點肯定一個子數組,兩個點中,咱們先肯定一個點,而後去找另外一個點,好比說,若是咱們肯定一個子數組的截止元素在 i 這個位置,這個時候咱們須要思考的問題是 「以 i 結尾的全部子數組中,和最大的是多少?」,而後咱們去試着拆解,這裏其實只有兩種狀況:
你能夠看到,咱們把第 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;
}
複製代碼
經過這幾個簡單的例子,相信你不難發現,解動態規劃題目其實就是拆解問題,定義狀態的過程,嚴格說來,動態規劃並非一個具體的算法,而是凌駕於算法之上的一種思想,這種思想強調的是從局部最優解經過必定的策略推得全局最優解,從子問題的答案一步步推出整個問題的答案,而且利用空間換取時間。從不少算法之中你均可以看到動態規劃的影子,因此,仍是那句話 技術都是相通的,找到背後的本質思想是關鍵。