7號晚聽了鄒博一次微課,正好是本身最近正在死磕的動態規劃,因此搬好小板凳聽鄒博講解動態規劃。現將內容整理以下:算法
內容主要分爲兩個部分:數組
1. 動態規劃和貪心的認識——工具:馬爾科夫過程dom
2. 動態規劃,經過3個DP中的經典問題詳細講解ide
1)最長遞增子序列LIS工具
2)格子取數/走棋盤問題及應用學習
3)找零錢/揹包問題優化
正題開始。首先,人們認識事物的方法有三種:經過概念(即對事物的基本認識)、經過判斷(即對事物的加深認識)、和推理(對事物的深層認識)。其中,推理又包含概括法和演繹法。(這些從初中高中一直到大學咱們都是一直在學習的,關鍵是理解)spa
概括法是從特殊到通常,屬於發散思惟;(如:蘇格拉底會死;張三會死;李四會死;王五會死……,他們都是人。因此,人都會死。)3d
演繹法是從通常到特殊,屬於匯聚思惟。(如:人都會死的;蘇格拉底是人。因此,蘇格拉底會死。)code
那麼,如何用概括法解決數學問題,進行應用呢?
已知問題規模爲n的前提A,求解一個未知解B。(咱們用An表示「問題規模爲n的已知條件」)
此時,若是把問題規模降到0,即已知A0,能夠獲得A0->B.
然而,Ai與Ai+1每每不是互爲充要條件,隨着i的增長,有價值的前提信息愈來愈少,咱們沒法僅僅經過上一個狀態獲得下一個狀態,所以能夠採用以下方案:
上述兩種狀態轉移圖以下圖所示:
下面經過分析幾個經典問題來理解動態規劃。
實例一:最長遞增子序列(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]結尾的最長遞增子序列有多長?分析以下圖所示:
顯然,若是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)的時間複雜度算法,可否有更優的方法呢?
最開始,緩衝區裏爲空;
看到了字符「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、十、五、二、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]; }
總結,何時適合用動態規劃呢?
總之,動態規劃只是一種解決問題的思路,要靈活運用這種方法,多作練習,就能很快找到靈感了。