前幾天做leetcode的算法題很多題都提到了動態規划算法,那么什么是動態規划算法,它是什么樣的思想,適用於什么場景,就是我們今天的主題。
首先我們提出所有與動態規划有關的算法文章中都會提出的觀點: 將一個問題拆成幾個子問題,分別求解這些子問題,即可推斷出大問題的解。
什么都不了解的話看到這句話是懵逼的,我們也先略過,等看完整篇文章再回過頭來看一看這個觀點。
下面正式開始。
首先我們來看這個譯名,動態規划算法。這里的動態指代的是遞推的思想,算法在實現的過程中是動態延伸的,而不是提前控制的。規划指的是需要我們給出動態延伸的方法和方向。
這兩個就是算法的問題點。
我們來看個例子(例子來源:https://www.zhihu.com/question/23995189特別推薦這篇回答,非常的簡單且詳細)。
假設您是個土豪,身上帶了足夠的1、5、10、20、50、100元面值的鈔票。現在您的目標是湊出某個金額w,需要用到盡量少的鈔票。 依據生活經驗,我們顯然可以采取這樣的策略:能用100的就盡量用100的,否則盡量用50的……依次類推。在這種策略下,666=6×100+1×50+1×10+1×5+1×1,共使用了10張鈔票。 這種策略稱為“貪心”:假設我們面對的局面是“需要湊出w”,貪心策略會盡快讓w變得更小。能讓w少100就盡量讓它少100,這樣我們接下來面對的局面就是湊出w-100。長期的生活經驗表明,貪心策略是正確的。 但是,如果我們換一組鈔票的面值,貪心策略就也許不成立了。如果一個奇葩國家的鈔票面額分別是1、5、11,那么我們在湊出15的時候,貪心策略會出錯: 15=1×11+4×1 (貪心策略使用了5張鈔票) 15=3×5 (正確的策略,只用3張鈔票) 為什么會這樣呢?貪心策略錯在了哪里? 鼠目寸光。 剛剛已經說過,貪心策略的綱領是:“盡量使接下來面對的w更小”。這樣,貪心策略在w=15的局面時,會優先使用11來把w降到4;但是在這個問題中,湊出4的代價是很高的,必須使用4×1。如果使用了5,w會降為10,雖然沒有4那么小,但是湊出10只需要兩張5元。 在這里我們發現,貪心是一種只考慮眼前情況的策略。 那么,現在我們怎樣才能避免鼠目寸光呢? 如果直接暴力枚舉湊出w的方案,明顯復雜度過高。太多種方法可以湊出w了,枚舉它們的時間是不可承受的。我們現在來嘗試找一下性質。 重新分析剛剛的例子。w=15時,我們如果取11,接下來就面對w=4的情況;如果取5,則接下來面對w=10的情況。我們發現這些問題都有相同的形式:“給定w,湊出w所用的最少鈔票是多少張?”接下來,我們用f(n)來表示“湊出n所需的最少鈔票數量”。 那么,如果我們取了11,最后的代價(用掉的鈔票總數)是多少呢? 明顯 ,它的意義是:利用11來湊出15,付出的代價等於f(4)加上自己這一張鈔票。現在我們暫時不管f(4)怎么求出來。 依次類推,馬上可以知道:如果我們用5來湊出15,cost就是 。 那么,現在w=15的時候,我們該取那種鈔票呢?當然是各種方案中,cost值最低的那一個! - 取11: cost=f[4]+1=4+1=5 - 取5: cost=f[10]+1=2+1=3 - 取1: cost=f[14]+1=4+1=5
顯而易見,cost值最低的是取5的方案。我們通過上面三個式子,做出了正確的決策!
我們來看看這其中規划的展現部分,規划展現在情況分類上,我們只有三種錢幣,所以會要求實現f[n]中的n要大於某一個規定值才是可取的,要10元時候我們不可以取出11元,所以在這部分要進行單獨判斷。
再說動態部分,顯然,f[4],f[10],f[14]是我們需要單獨去計算的,那么如何計算呢,就需要他們動態的去遞推下一步如何計算。
來看看這個式子:f(n)=min{f(n-1),f(n-5),f(n-11)}+1
顯然f(n)直接由后三個值決定,我們只要算出這三個值即可,而這三個值又由同樣式的更小值決定,只要得出更小值,甚至最小值,我們就可以推導出f(n)。
我們以O(n)的復雜度解決了這個問題。現在回過頭來,我們看看它的原理: - f(n)只與f(n-1)f(n-5)f(n-11)的值相關。 - 我們只關心 的值,不關心是怎么湊出w的。 這兩個事實,保證了我們做法的正確性。它比起貪心策略,會分別算出取1、5、11的代價,從而做出一個正確決策,這樣就避免掉了“鼠目寸光”! 它與暴力的區別在哪里?我們的暴力枚舉了“使用的硬幣”,然而這屬於冗余信息。我們要的是答案,根本不關心這個答案是怎么湊出來的。譬如,要求出f(15),只需要知道f(14),f(10),f(4)的值。其他信息並不需要。我們舍棄了冗余信息。我們只記錄了對解決問題有幫助的信息——f(n). 我們能這樣干,取決於問題的性質:求出f(n),只需要知道幾個更小的f(c)。我們將求解f(c)稱作求解f(n)的“子問題”。
實際上是一種冗余信息的反向篩查算法,是暴力枚舉的簡化。
來看看使用DP算法的要求。
【無后效性】 一旦f(n)確定,“我們如何湊出f(n)”就再也用不着了。 要求出f(15),只需要知道f(14),f(10),f(4)的值,而f(14),f(10),f(4)是如何算出來的,對之后的問題沒有影響。 “未來與過去無關”,這就是無后效性。 (嚴格定義:如果給定某一階段的狀態,則在這一階段以后過程的發展不受這階段以前各段狀態的影響。) 【最優子結構】 回顧我們對f(n)的定義:我們記“湊出n所需的最少鈔票數量”為f(n). f(n)的定義就已經蘊含了“最優”。利用w=14,10,4的最優解,我們即可算出w=15的最優解。 大問題的最優解可以由小問題的最優解推出,這個性質叫做“最優子結構性質”。 引入這兩個概念之后,我們如何判斷一個問題能否使用DP解決呢? 能將大問題拆成幾個小問題,且滿足無后效性、最優子結構性質。
這也同時揭示了我一開始寫的那句話,大問題由小問題累積而成,是必要的。
【DP三連】
設計DP算法,往往可以遵循DP三連:
我是誰? ——設計狀態,表示局面
我從哪里來? 我要到哪里去? ——設計轉移
設計狀態是DP的基礎。接下來的設計轉移,有兩種方式:一種是考慮我從哪里來(本文之前提到的兩個例子,都是在考慮“我從哪里來”);另一種是考慮我到哪里去,這常見於求出f(x)之后,更新能從x走到的一些解。這種DP也是不少的,我們以后會遇到。
總而言之,“我從哪里來”和“我要到哪里去”只需要考慮清楚其中一個,就能設計出狀態轉移方程,從而寫代碼求解問題。前者又稱pull型的轉移,后者又稱push型的轉移。
實際的例子我用前兩天的幾道動態規划的算法題來舉例。
一道一道來:
1.Delete Operation for Two Strings
java:
class Solution { public int minDistance(String s1, String s2) { char[] cs1 = s1.toCharArray(), cs2 = s2.toCharArray(); int n = s1.length(), m = s2.length(); int[][] f = new int[n + 1][m + 1]; for (int i = 0; i <= n; i++) f[i][0] = i; for (int j = 0; j <= m; j++) f[0][j] = j; for (int i = 1; i <= n; i++) { for (int j = 1; j <= m; j++) { f[i][j] = Math.min(f[i - 1][j] + 1, f[i][j - 1] + 1); if (cs1[i - 1] == cs2[j - 1]) f[i][j] = Math.min(f[i][j], f[i - 1][j - 1]); } } return f[n][m]; } }
public class towString { String s1 = "ba"; String s2 = "ac"; int x =minDistance(s1,s2); public int minDistance(String s1,String s2){ char[] cs1 = s1.toCharArray(); char[] cs2 = s2.toCharArray(); //拆分為字符數組 int n = s1.length(); int m = s2.length(); int[][] f = new int[n+1][m+1];//+1其實是為了空出“哨兵”,也就是00位,不加在后面剪了也一樣 for(int i =0;i<=n;i++) { f[i][0] = i ; System.out.println("i="+i); } for(int j =0;j<=m;j++) { f[0][j] = j ; System.out.println("j="+j); }//雖然我們都知道里面是什么,但是還是輸出一下; for(int j =1;j<=m;j++){//注意這里從1開始 for(int i=1;i<=n;i++){ System.out.println("f[i-1][j] = "+f[i-1][j]); System.out.println("f[i][j-1] = "+f[i][j-1]); //輸出一下 f[i][j] = Math.min(f[i-1][j]+1,f[i][j-1]+1); System.out.println("f[i][j]="+f[i][j]); System.out.println("*****************"); System.out.println("cs1[i-1]="+cs1[i-1]); System.out.println("cs2[j-1]="+cs2[j-1]); if(cs1[i-1] == cs2[j-1]){ System.out.println("相同"); f[i][j] = Math.min(f[i][j],f[i-1][j-1]); } System.out.println("f[i][j]="+f[i][j]); } } for(int i =0;i<=n;i++){ for (int j =0 ;j<=m;j++){ System.out.print(f[i][j]+" "); } System.out.println("\n"); } return f[n][m]; } }
這里的特殊點在於需要每一步都比較,也就是[i-1][j]與[j][i-1],而不是[i][j];
5就不說了,是標准的最長公共子序列問題,上一道問題的縮減版,注意的是由於要求最長長度,所以用的是max。
class Solution { public boolean isMatch(String s, String p) { int m = s.length(); int n = p.length(); boolean[][] f = new boolean[m + 1][n + 1]; f[0][0] = true; for (int i = 0; i <= m; ++i) { for (int j = 1; j <= n; ++j) { if (p.charAt(j - 1) == '*') { f[i][j] = f[i][j - 2]; if (matches(s, p, i, j - 1)) { f[i][j] = f[i][j] || f[i - 1][j]; } } else { if (matches(s, p, i, j)) { f[i][j] = f[i - 1][j - 1]; } } } } return f[m][n]; } public boolean matches(String s, String p, int i, int j) { if (i == 0) { return false; } if (p.charAt(j - 1) == '.') { return true; } return s.charAt(i - 1) == p.charAt(j - 1); } }
這道題考慮到一個問題點就是在特殊情況下的判斷問題,我們拋開matches函數再看這道題的話,流程是一樣的,創建,問題拆分,返回。
一般難點在於問題拆分部分,想要節省空間可以從創建部分下手,部分情況下可以降低數組維度。
以上。
(其實比較難的題,我自己拆分問題很多情況下也拆不好hhhhh,看看題解吧。)