1. 程式人生 > >從例項中瞭解動態規劃的基本思想

從例項中瞭解動態規劃的基本思想

寫在最前面

當時大學開的那麼多演算法課為啥一節都不好好聽講!

什麼是動態規劃

動態規劃,是一種解決棘手問題的方法,它將問題分成小問題,並從解決小問題作為起點,從而解決最終問題的一種方法。

看不明白沒關係,後面我們會從幾個例項中逐漸讓大家摸清規律。

問題一 爬梯子問題

假設你正在爬樓梯。需要 n 階你才能到達樓頂。

每次你可以爬 1 或 2 個臺階。你有多少種不同的方法可以爬到樓頂呢?

注意:給定 n 是一個正整數。

  • 示例 1:

輸入: 2 輸出: 2 解釋: 有兩種方法可以爬到樓頂。

  1. 1 階 + 1 階
  2. 2 階
  • 示例 2:

輸入: 3 輸出: 3 解釋: 有三種方法可以爬到樓頂。

  1. 1 階 + 1 階 + 1 階
  2. 1 階 + 2 階
  3. 2 階 + 1 階
你可能會這麼想

走1階臺階只有一種走法,但是走2階臺階有兩種走法(如示例1),如果n是雙數,我們可以湊成m個2級臺階,每個m都有兩種走法,如果n是單數,那麼我們可以湊成m個2級臺階加上一個1級臺階,這樣就似乎於一個排列組合題目了,但是開銷貌似比較大。

如何將整個問題化成一個一個的小問題

這個時候使用動態規劃就很有用,因為這個問題其實是由一個很簡單的小問題組成的。 觀察這種小問題,簡單地我們可以採用首位或者中間態進行一次分析,比如我們從最終態進行分析:

走N階臺階,最後一步必定是1步或者2步到達。

那麼N階臺階的走法不就相當於最後走一步和最後走兩步的走法的總和嗎?換一種方式來說,我們取一箇中間態:如果總共有3級臺階,3級臺階的走法只會存在兩種大的可能:走了1階臺階+走兩步、走了兩級臺階+走一步,即3級臺階的所有走法就是走了1階臺階的走法加上走了2階臺階的走法

,而1階臺階的走法只有一種,2階臺階的走法有2種,所有3階臺階的走法有3種,我們使用一種更通用的方式進行表達的話就是所謂的狀態轉換方程

$$ 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 條路徑可以到達右下角。

  1. 向右 -> 向右 -> 向下
  2. 向右 -> 向下 -> 向右
  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;
}

動態規劃可用的總結

(參考《演算法圖解》)

  • 需要在給定約束條件下優化某種指標時,動態規劃很有用。
  • 問題可分解為離散子問題時,可使用動態規劃來解決。
  • 每種動態規劃解決方案都涉及網格。
  • 單元格中的值通常就是你要優化的值。
  • 每個單元格都是一個子問題,因此你需要考慮如何將問題分解為子問題。
  • 沒有放之四海皆準的計算動態規劃解決方案的公式。