7號晚聽了鄒博一次微課,正好是自己最近正在死磕的動態規划,所以搬好小板凳聽鄒博講解動態規划。現將內容整理如下:
內容主要分為兩個部分:
1. 動態規划和貪心的認識——工具:馬爾科夫過程
2. 動態規划,通過3個DP中的經典問題詳細講解
1)最長遞增子序列LIS
2)格子取數/走棋盤問題及應用
3)找零錢/背包問題
正題開始。首先,人們認識事物的方法有三種:通過概念(即對事物的基本認識)、通過判斷(即對事物的加深認識)、和推理(對事物的深層認識)。其中,推理又包含歸納法和演繹法。(這些從初中高中一直到大學我們都是一直在學習的,關鍵是理解)
歸納法是從特殊到一般,屬於發散思維;(如:蘇格拉底會死;張三會死;李四會死;王五會死……,他們都是人。所以,人都會死。)
演繹法是從一般到特殊,屬於匯聚思維。(如:人都會死的;蘇格拉底是人。所以,蘇格拉底會死。)
那么,如何用歸納法解決數學問題,進行應用呢?
已知問題規模為n的前提A,求解一個未知解B。(我們用An表示“問題規模為n的已知條件”)
此時,如果把問題規模降到0,即已知A0,可以得到A0->B.
- 如果從A0添加一個元素,得到A1的變化過程。即A0->A1; 進而有A1->A2; A2->A3; …… ; Ai->Ai+1. 這就是嚴格的歸納推理,也就是我們經常使用的數學歸納法;
- 對於Ai+1,只需要它的上一個狀態Ai即可完成整個推理過程(而不需要更前序的狀態)。我們將這一模型稱為馬爾科夫模型。對應的推理過程叫做“貪心法”。
然而,Ai與Ai+1往往不是互為充要條件,隨着i的增加,有價值的前提信息越來越少,我們無法僅僅通過上一個狀態得到下一個狀態,因此可以采用如下方案:
- {A1->A2}; {A1, A2->A3}; {A1,A2,A3->A4};……; {A1,A2,...,Ai}->Ai+1. 這種方式就是第二數學歸納法。
- 對於Ai+1需要前面的所有前序狀態才能完成推理過程。我們將這一模型稱為高階馬爾科夫模型。對應的推理過程叫做“動態規划法”。
上述兩種狀態轉移圖如下圖所示:
下面通過分析幾個經典問題來理解動態規划。
實例一:最長遞增子序列(Longest Increasing Subsequence)。
問題描述。給定長度為N的數組A,計算A的最長單調遞增的子序列(不一定連續)。如給定數組A{5,6,7,1,2,8},則A的LIS為{5,6,7,8},長度為4.
思路:因為子序列要求是遞增的,所以重點是子序列的起始字符和結尾字符,因此我們可以利用結尾字符。想到:以A[0]結尾的最長遞增子序列有多長?以A[1]結尾的最長遞增子序列有多長?……以A[n-1]結尾的最長遞增子序列有多長?分析如下圖所示:
- (動態規划solution)所以我們可以使用一個額外的空間來保存前面已經算得的最長遞增子序列,然后每次更新當前的即可。也就是問題演化成:已經計算得到了b[0,1,2,……,i-1],如何計算得到b[i]呢?
顯然,如果ai>=aj,則可以將ai放到b[j]的后面,得到比b[j]更長的子序列。從而:b[i] = max{b[j]}+1. s.t. A[i] > A[j] && 0 <= j < i.
所以計算b[i]的過程是,遍歷b[i]之前的所有位置j,找出滿足關系式的最大的b[j].
得到b[0...n-1]之后,遍歷所有的b[i]找到最大值,即為最大遞增子序列。 總的時間復雜度為O(N2).
我實現的Java版代碼為:

publi int LIS(int[] A) { if(A == null || A.length == 0) return 0; int[] b = new int[A.length]; b[0] = 1; int result = 1; for(int i=1; i<A.length; i++) { int max = -1; for(int j=0; j<i; j++) { if(A[j] < A[i] && b[j] > max) max = b[j]; } b[i] = max + 1; result = Math.max(result, b[i]); } return result; }
進而,如果不僅是求LIS的長度,而要求LIS本身呢?我們可以通過記錄前驅的方式,從該位置找到其前驅,進而找到前驅的前驅……
Java代碼如下:

public static ArrayList<Integer> LISDetail(int[] A) { if(A == null || A.length == 0) return null; int[] b = new int[A.length]; int[] b1 = new int[A.length]; b[0] = 1; b1[0] = -1; int result = 1; int index = 0; for(int i=1; i<A.length; i++) { int max = 0; boolean flag = false; for(int j=0; j<i; j++) { if(A[j] < A[i] && b[j] > max) { flag = true; max = b[j]; b1[i] = j; } } if(flag == false) b1[i] = -1; b[i] = max + 1; if(result < b[i]) { result = b[i]; index = i; } } ArrayList<Integer> res = new ArrayList<Integer>(); //res.add(A[index]); for(;index >=0; ) { res.add(A[index]); index = b1[index]; } Collections.reverse(res); return res; }
使用動態規划方法的到O(N2)的時間復雜度算法,能否有更優的方法呢?
- (貪心算法solution)我們仍然使用上面的例子,用其他的思路試試。我們遞增式的選擇元素,讓每一次的選擇盡可能的小,實際操作如下:
最開始,緩沖區里為空;
看到了字符“1”,添加到緩沖區的最后,即緩沖區中是“1”;
看到了字符“4”,“4”比緩沖區的所有字符都大,因此將“4”添加到緩沖區的最后,得到“14”;
看到了字符“6”,“6”比緩沖區的所有字符都大,因此將“6”添加到緩沖區的最后,得到“146”;
看到了字符“2”,“2”比“1”大,比“4”小,因此將“4”直接替換成“2”,得到“126”;
看到了字符“8”,“8”比緩沖區的所有字符都大,因此將“8”添加到緩沖區的最后,得到“1268”;
看到了字符“9”,“9”比緩沖區的所有字符都大,因此將“9”添加到緩沖區的最后,得到“12689”;
看到了字符“7”,“7”比“6”大,比“8”小,因此將“8”直接替換成“7”,得到“12679”;
現在,緩沖區的字符數目為5,因此,數組A的LIS的長度就是5!
這樣,時間復雜度變為每次都在一個遞增的序列中替換或插入一個新的元素,所以為O(nlogn)。
代碼為:

public int len = 0; public int LIS1(int[] A) { if(A == null || A.length == 0) return 0; int[] b = new int[A.length]; b[0] = A[0]; len = 1; for(int i=1; i<A.length; i++) { insert(b, A[i]); } return len; } public int[] insert(int[] a, int val) { if(val < a[0]) { a[0] = val; return a; } if(val > a[len-1]) { a[len] = val; len++; return a; } int left = 0, right = len-1, mid = (left + right) / 2; while(left < right) { mid = (left + right) / 2; if(a[mid] < val && a[mid+1] >= val) { a[mid+1] = val; return a; } if(a[mid] >= val && a[mid-1] < val) { a[mid] = val; return a; } if(a[mid] < val) left = mid+1; if(a[mid] > val) right = mid-1; } return a; }
但后來我分析了這種方法只能得到長度,不能得到子序列本身。(老師上課時提示說考慮序列長度變化的時候,對於示例數組{1,4,6,2,8,9,7}來說可以解決,即當序列變長的時候,元素1,4,6,8,9正好是最終的字長遞增子序列;當如果原數組是{10,9,2,5,3,7,101,18}時,就不是這么回事了。目前我沒有找到求解子序列本身的方法,留作以后思考。)
實例二:格子取數/走棋盤問題
問題描述。給定一個m*n的矩陣,每個位置是一個非負整數,從左上角開始放一個機器人,它每次只能朝右和下走,走到右下角,求機器人的所有路徑中,總和最小的那條路徑。如下圖所示,其中圖中所示的彩色方塊是已知的某些非負整數值。
考慮一般情況下位於機器人位於某點(x, y)處,那么它是怎么來的呢?只可能來自於左邊或者上邊。即:
dp[x, y] = min(dp[x-1, y], dp[x, y-1]) + a[x, y],其中a[x, y]是棋盤中(x, y)點的權重取值。
然后考慮位於最左邊一列與左上邊的一行,得到所有的狀態轉移方程為:
所以,代碼如下:

public int minPath(int[][] chess) { int row = chess.length; int col = chess[0].length; int[][] dp = new int[row][col]; dp[0][0] = chess[0][0]; for(int i=1; i<row; i++) dp[i][0] = dp[i-1][0] + chess[i][0]; for(int j=1; j<col; j++) dp[0][j] = dp[0][j-1] + chess[0][j]; for(int i=1; i<row; i++) { for(int j=1; j<col; j++) { dp[i][j] = (dp[i-1][j] < dp[i][j-1] ? dp[i-1][j] : dp[i][j-1]) + chess[i][j]; } } return dp[row-1][col-1]; }
觀察狀態轉移方程發現,每次更新(x, y),只需要最多知道上一行即可,沒必要知道更早的數據。凡是滿足這樣條件的動態規划問題,都可以用“滾動數組”的方式做空間上的優化。
使用滾動數組的狀態轉移方程如上圖所示。
代碼如下:

public int minPath1(int[][] chess) { int row = chess.length; int col = chess[0].length; int[] dp = new int[col]; dp[0] = chess[0][0]; for(int j=1; j<col; j++) dp[j] = dp[j-1] + chess[0][j]; for(int i=1; i<row; i++) { for(int j=0; j<col; j++) { if(j == 0) dp[j] += chess[i][j]; else dp[j] = (dp[j] < dp[j-1] ? dp[j] : dp[j-1]) + chess[i][j]; } } return dp[col-1]; }
實例三:找零錢問題/0-1背包問題
問題描述。給定某不超過100萬元的現金總額,兌換成數量不限的100、50、20、10、5、2、1元的紙幣組合,共有多少種組合?
思路:此問題涉及兩個類別:面值和總額。所以我們定義dp[i][j]表示使用小於等於i的紙幣,湊成j元錢,共有多少種組合方法。比如dp[100][500]表示使用面值不大於100的紙幣,湊出500塊錢,共有多少種組合方法。
進一步思考,如果面值都是1元的,則無論總額多少,可行的組合數都為1.比如只用1元的紙幣湊出100元,顯然只有一種組合方法。那么如果多出一種面值呢?組合數有什么變化?
回到dp[100][500],既然用小於等於100的紙幣湊出500塊錢,則組合中只會要么包含至少一張100塊的紙幣,要么不包含100塊的紙幣。所以我們可以分成兩種情況考慮:
1)如果沒有包括100元,則用到的最大面值可能為50元,即使用面值小於等於50的紙幣,湊出500塊錢,表示形式為:dp[50][500];
2)如果必須包含100元,怎么計算呢?既然至少包含100元,我們先拿出100塊錢,則還需要湊出400塊錢即可完成。用小於或等於100元的紙幣湊出400塊錢,表示形式為dp[100][400];
將兩者綜合起來為:dp[100][500] = dp[50][500] + dp[100][400];
為了方便表示,我們定義紙幣面值為一個數組:dom[] = {1,2,5,10,20,50,100},這樣dom[i]和dom[i-1]就表示相鄰的紙幣面額了。i的意義從面值變成了面值下標。
根據上面分析,對於一般情況,我們有dp[i][j] = dp[i-1][j] + dp[i][j-dom[i]]. ]有了一般情況,在考慮兩種特殊情況:
如果dp[i][0]應該返回啥?dp[i][0]表示用小於等於i的紙幣,湊出0塊錢,我們可以定義這種情況的值為1;
如果dp[0][j]應該返回啥?dp[0][j]表示用小於等於0的紙幣,湊出j塊錢,我們可以定義這種情況的值為1.
再看dp[100][78],用小於等於100元的紙幣湊出78塊錢,這時組合中一定不會包含100塊的紙幣,因此dp[100][78] = dp[50][78],即當j < dom[i]時,dp[i][j] = dp[i-1][j]。
這樣整個dp的過程就出來了:
代碼為:

public int charge(int[] money, int total) { int row = money.length; int col = total + 1; int[][] dp = new int[row][col]; for(int j=0; j<col; j++) dp[0][j] = 1; //表示用1塊錢湊出任何金額的組合數都為1 for(int i=1; i<row; i++) { dp[i][0] = 1; for(int j=1; j<col; j++) { if(j < money[i]) //表示要湊出的金額數小於當前的紙幣面額,如dp[100][87] = dp[50][87] dp[i][j] = dp[i-1][j]; else dp[i][j] = dp[i-1][j] + dp[i][j-money[i]]; } } return dp[row-1][col-1]; }
總結,什么時候適合用動態規划呢?
總之,動態規划只是一種解決問題的思路,要靈活運用這種方法,多做練習,就能很快找到靈感了。