從例項中瞭解動態規劃的基本思想
寫在最前面
當時大學開的那麼多演算法課為啥一節都不好好聽講!
什麼是動態規劃
動態規劃,是一種解決棘手問題的方法,它將問題分成小問題,並從解決小問題作為起點,從而解決最終問題的一種方法。
看不明白沒關係,後面我們會從幾個例項中逐漸讓大家摸清規律。
問題一 爬梯子問題
假設你正在爬樓梯。需要 n 階你才能到達樓頂。
每次你可以爬 1 或 2 個臺階。你有多少種不同的方法可以爬到樓頂呢?
注意:給定 n 是一個正整數。
- 示例 1:
輸入: 2 輸出: 2 解釋: 有兩種方法可以爬到樓頂。
- 1 階 + 1 階
- 2 階
- 示例 2:
輸入: 3 輸出: 3 解釋: 有三種方法可以爬到樓頂。
- 1 階 + 1 階 + 1 階
- 1 階 + 2 階
- 2 階 + 1 階
你可能會這麼想
走1階臺階只有一種走法,但是走2階臺階有兩種走法(如示例1),如果n是雙數,我們可以湊成m個2級臺階,每個m都有兩種走法,如果n是單數,那麼我們可以湊成m個2級臺階加上一個1級臺階,這樣就似乎於一個排列組合題目了,但是開銷貌似比較大。
如何將整個問題化成一個一個的小問題
這個時候使用動態規劃就很有用,因為這個問題其實是由一個很簡單的小問題組成的。 觀察這種小問題,簡單地我們可以採用首位或者中間態進行一次分析,比如我們從最終態進行分析:
走N階臺階,最後一步必定是1步或者2步到達。
那麼N階臺階的走法不就相當於最後走一步和最後走兩步的走法的總和嗎?換一種方式來說,我們取一箇中間態:如果總共有3級臺階,3級臺階的走法只會存在兩種大的可能:走了1階臺階+走兩步、走了兩級臺階+走一步,即3級臺階的所有走法就是走了1階臺階的走法加上走了2階臺階的走法
$$ ways[n]=ways[n-1]+ways[n-2] $$
有了這個公式,我們就可以使用迭代來完成整個過程,尋求到最終的ways[n]的值了,迭代的開始即我們已知的確定條件:一階臺階只有一種走法:ways[1]=1、兩階臺階有兩種走法:ways[2]=2,程式碼如下:
實現程式碼
public int climbStairs(int n) { if(n==1){ return 1; }else if(n==2){ return 2; } //避免使用0,即下標從1開始,更好理解 int ways[]=new int[n+1]; //賦值迭代初始條件 ways[1]=1; ways[2]=2; //利用狀態轉換方式進行迭代 for(int i=3;i<=n;i++){ ways[i]=ways[i-1]+ways[i-2]; } return ways[n]; }
基本流程
從上面的解決途徑我們可以發現基本流程是這樣的:
- 從一個現實方案中找到狀態轉換的特有規律
- 從特有規律中提取出狀態轉換方程
- 找到狀態轉換方程的迭代初始值(確定值)
- 解決問題
問題二 不同路徑
一個機器人位於一個 m x n 網格的左上角 (起始點在下圖中標記為“Start” )。
機器人每次只能向下或者向右移動一步。機器人試圖達到網格的右下角(在下圖中標記為“Finish”)。
問總共有多少條不同的路徑?
例如,上圖是一個7 x 3 的網格。有多少可能的路徑?
說明:m 和 n 的值均不超過 100。
- 示例 1:
輸入: m = 3, n = 2 輸出: 3 解釋: 從左上角開始,總共有 3 條路徑可以到達右下角。
- 向右 -> 向右 -> 向下
- 向右 -> 向下 -> 向右
- 向下 -> 向右 -> 向右
- 示例 2:
輸入: m = 7, n = 3 輸出: 28
解決方法
相信沿用問題一的套路很多人已經知道該怎麼辦了,從一個二維陣列的左上(0,0)走到右下(m,n)有多少種走法,且只能往右和往下走,那麼如果要走到(m,n),那麼我們的上一步只能是(m-1,n)或者(m,n-1),所以走到(m,n)的所有走法就是走到(m-1,n)的所有走法+走到(m,n-1)的所有走法,即可以得到狀態轉換方程:
$$ways[m][n]=ways[m-1][n]+ways[m][n-1]$$
但是,這個問題還有一些其他的問題限制需要我們考慮到,即走到兩側的時候,只會有一個方向的走法,(上方只會有ways[m-1][n]一個方式,左側只會有ways[m][n-1]一個方式)即下圖:
我們需要對這兩種方式進行限制,在這裡我在外圍再擴充套件了一圈,將整個方格擴充套件為**(m+1)*(n+1)**的方格,來避開限制,當然也可以直接限制(後續會講到),但是將其所有的值都設定為0,即相當於設定了限制。
實現程式碼
public static int uniquePaths(int m, int n) {
int[][] ways=new int[m+1][n+1];
//上方擴充套件一行,使其值為0
for(int i=0;i<=n;i++){
ways[0][i]=0;
}
//邊上擴充套件一列,使其值為0
for(int j=0;j<=m;j++){
ways[j][0]=0;
}
//設定初始值,起點走法為1,只能一步一步走
ways[1][1]=1;
for(int a=1;a<=m;a++){
for(int b=1;b<=n;b++){
if(a==1&&b==1){
continue;
}
//套用狀態轉換方程
ways[a][b]=ways[a][b-1]+ways[a-1][b];
}
}
return ways[m][n];
}
問題三 最小路徑和
給定一個包含非負整數的 m x n 網格,請找出一條從左上角到右下角的路徑,使得路徑上的數字總和為最小。
說明:每次只能向下或者向右移動一步。
- 示例:
輸入: [ [1,3,1], [1,5,1], [4,2,1] ] 輸出: 7 解釋: 因為路徑 1→3→1→1→1 的總和最小。
解決方法
這個問題與問題二及其相似,但是其涉及到一個最優解的問題,現在每一個點都有一個類似權重的值,我們要使這個值最小,其實用問題二的想法,我們很快就能得到答案:走到(m,n)只能從(m-1,n)和(m,n-1)兩個地方走過來,那麼要保證(m,n)的權重最小,那麼我們只需要選擇走到(m-1,n)和(m,n-1)權重較小的那一邊即可,那麼我們就可以得到新的狀態轉移方程:
$$sum[m][n]=MIN(sum[m-1][n],sum[m][n-1])+table[m][n]$$
即 走到當前點的權重=走到前一步權重的較小值+當前點的權重 ,並且該問題也有針對邊上元素的特殊處理
程式碼
public static int minPathSum(int[][] grid) {
//權重儲存陣列
int[][] sum=new int[grid.length][grid[0].length];
//起點初始權重確定值
sum[0][0]=grid[0][0];
for(int i=0;i<grid.length;i++){
for(int j=0;j<grid[0].length;j++){
if(i==0&&j==0){
continue;
}
//邊上的權重處理
if(i-1<0){
sum[i][j]=sum[i][j-1]+grid[i][j];
}else if(j-1<0){
sum[i][j]=sum[i-1][j]+grid[i][j];
}else{
sum[i][j]=Math.min(sum[i-1][j],sum[i][j-1])+grid[i][j];
}
}
}
return sum[grid.length-1][grid[0].length-1];
}
問題四 三角形最小路徑和
給定一個三角形,找出自頂向下的最小路徑和。每一步只能移動到下一行中相鄰的結點上。
- 例如,給定三角形:
[ [2], [3,4], [6,5,7], [4,1,8,3] ] 自頂向下的最小路徑和為 11(即,2 + 3 + 5 + 1 = 11)。
解決方法
這個問題可以理解為問題三的變種,但是他沒有一個固定的終點,因為我們之前的方法都是從最後一步開始分析的,所以很多人也就對該問題無從下手了。但是其實我們也可以將最後一行的任何一個元素作為終點,因為該問題起點確定,並且終點必定在最後一行。但是為了代表性,我們還是選取1或8為例子,如果最終達到1,需要上一排達到6或5。如果要達到5,那麼需要上一排達到3或4,所以我們由此可以得到該問題的狀態轉移方程:
sum[m][n]=MIN(sum[m-1][n-1],sum[i-1][j])+table[m][n]
這樣我們就可以根據問題三的模式找到達到最後一排所有可能終點(4,1,8,3)的最小權重,我們再從所有權重中選取最小值即可,該問題也有針對邊上元素的特殊處理。
實現程式碼
public static int minimumTotal(List<List<Integer>> triangle) {
//建立狀態儲存陣列
int[][] sum=new int[triangle.size()][triangle.size()];
//起點確定,權重確定
sum[0][0]=triangle.get(0).get(0);
for(int i=0;i<triangle.size();i++){
for(int j=0;j<triangle.get(i).size();j++){
if(i==0&&j==0){
continue;
}
//邊上元素的特殊處理
if(j==0){
sum[i][j]=sum[i-1][j]+triangle.get(i).get(j);
}
if(j==triangle.get(i).size()-1){
sum[i][j]=sum[i-1][j-1]+triangle.get(i).get(j);
}
if(j!=0&&j!=triangle.get(i).size()-1){
sum[i][j]=Math.min(sum[i-1][j-1],sum[i-1][j])+triangle.get(i).get(j);
}
}
}
//針對最後一行,選擇最小的權重和
int min=1000000000;
for(int a=0;a<sum[sum.length-1].length;a++){
if(sum[sum.length-1][a]<min){
min=sum[sum.length-1][a];
}
}
return min;
}
動態規劃可用的總結
(參考《演算法圖解》)
- 需要在給定約束條件下優化某種指標時,動態規劃很有用。
- 問題可分解為離散子問題時,可使用動態規劃來解決。
- 每種動態規劃解決方案都涉及網格。
- 單元格中的值通常就是你要優化的值。
- 每個單元格都是一個子問題,因此你需要考慮如何將問題分解為子問題。
- 沒有放之四海皆準的計算動態規劃解決方案的公式。