安琪拉教魯班學算法之動態規劃

《安琪拉與面試官二三事》系列文章
一個HashMap能跟面試官扯上半個小時
一個synchronized跟面試官扯了半個小時

《安琪拉教魯班學算法》系列文章

安琪拉教魯班學算法之動態規劃

動態規則是算法中非常重要的一類方法,書本上很多都是列出一堆公式,如果是初學者需要花費很多心血練習之後,纔會有那麼一刻有頓悟的感覺!本文希望通過使用王者峽谷二位脆皮英雄對話的方式講解動態規劃,讓大家在輕鬆愉快的氛圍中搞懂動態規劃。

後續會持續更新算法:重點講 排序、遞歸、回溯、貪心、深度/廣度優先搜索等,順序不定,希望儘早瞭解的可以給我留言。


魯班:安琪拉,你聽過動態規劃嗎?

安琪拉:當然聽說過,王者峽谷有個傳聞,誰掌握了動態規劃,就相當於拿到了屠龍刀,可以biubiubiu 三下砍死大龍。

魯班:安琪拉妹妹,你能給我講講什麼是動態規劃嗎?

安琪拉:魯班哥哥,看你求知慾這麼強,咱倆又都是脆皮的份上,我給你講講動態規劃。

魯班:已經搬好小板凳。

安琪拉:動態規劃是一種求問題最優解的方法。通用的思路:將問題的解轉化成==> 求解子問題,> 遞推,>最小子問題爲可直接獲得的初始狀態。

魯班:你這麼說完我還是很蒙啊! 能不能不打嘴炮,給我演示一下這個技能唄!

安琪拉:可以啊!我們找個小怪演示一下,請看第一題(斐波拉契數列):

image-20200404221620951

這個數列特點是從第三項開始,每一項都是前二項的和:2 = 1 + 1,3 = 1 + 2;

魯班:這個我知道怎麼做,你看我的,手寫代碼在此:

public class FibonacciSequence {
		//求斐波拉契第n項
    public int fibonacci(int n){
        if(n == 1 || n == 2){
            return 1;
        }else{
            return fibonacci(n-1) + fibonacci(n-2);
        }
    }
}

安琪拉:你這個解法其實就有動態規劃的思想,但是還可以做些優化!因爲中間有大量的重複計算。

比如,我們求數列中第100個數是多少? 遞歸會如下所示:

image-20200404225033261

安琪拉: 如👆圖所示,當求第100個斐波拉契數時,需要求第99 個和第 98個,在計算第99 個時獲得98之後,因爲沒有把第98項的結果保存下來,求第98 項時需要重新計算一次。同理,第97 項的計算也需要計算二次,依次類推,這其中存在大量重複計算。

魯班: 我發現了,求第n 個數,前n - 2 個數都會重複計算,時間複雜度爲O( 2n2^n ),那這個問題怎麼解決呢?

安琪拉:使用動態規劃,非常適合解決這類問題,動態規劃也有區分,一種是通過Map將中間結果保存起來,等到第二次求的時候直接從Map中獲取,還有一種只保留用於推導下一個值的最小數據,如下:

public int fibonacciDP(int n){
  if( n <= 2) {
    return 1;
  }
  //保存前一項 以及 前前一項
  int pre = 1, prepre = 1;
  int index = 3;
  int cur = pre + prepre;
  while (index <= n){
    //通過前一項和前前一項 得到當前值
    cur = pre + prepre;
    prepre = pre;
    pre = cur;
    index++;
  }
  return cur;
}

魯班:原來是這樣,把已經求過的值保存起來,後面的值根據前面的值推導出來,這樣不需要重複計算子問題,時間複雜度降低爲 O (n) 了,妙啊!

安琪拉:這個就是典型的使用動態規劃求解,魯班哥哥,我們先總結可以動態規劃求解問題的特點,待會再多看一些典型的問題。可以動態規劃求解問題的特點有二:

  1. 需要求解的問題具有相似子問題,重點在相似二個字,例如上面的求第100個數需要第99 和 第 98 個數,求第 99 個數需要求第 98 和第 97個數,求解子問題具有相似性;
  2. 需要求解的結果或最優結果由子問題的結果或最優結果推導而來,例如:F(n)= F(n-1)+ F(n-2)

魯班:哦哦,那我以後可以通過這二個特點判斷問題是否可以通過動態規劃求得。

安琪拉:是的,另外求動態規劃問題,也是有固定套路的,一般步驟如下:

  1. 定義數組變量保存歷史記錄,避免重複計算,一般程序中都用dp[]定義,定義數組元素或變量的含義;
  2. 找到數組元素之前的關係,這一步是最關鍵的,例如我們上面計算dp[n] 時,可通過dp[n-1] 和 dp[n-2] 獲得;
  3. 找到計算的起點以及值,例如上面斐波拉契數列的起點爲 第一項 :1,第二項:1,因爲起點決定終點 😆。

魯班:安琪拉妹妹,我好像已經學到精髓了,我五臟六腑的真氣快憋不住了,快出幾道題讓我去去火。

安琪拉:那我講幾道典型的動態規劃題目出給你,請👂題。

題目一:爬樓梯

假設你正在爬樓梯。從樓底到樓頂總共100 級臺階。

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

魯班:那我按照上面你教我的,判斷一下這個問題是否適合通過動態規劃求解:

  1. 需要求解的問題具有相似子問題:爬100 級臺階的走法和爬99 級臺階走法都是跨1 、2 步求得,滿足這個需求;
  2. 需要求解的結果或最優結果由子問題的結果或最優結果推導而來,爬100 級臺階可以通過 爬完99 級臺階 + 跨1 步 這種走法 加 爬完98 級臺階 + 跨2步來完成,可以以此類推,滿足特點;

安琪拉:通過二個特點驗證了這個問題可通過動態規劃求解,再試試通過三步驟把這個問題解出來吧。

魯班:好的, 三步驟如下:

  • 第一步:定義數組或變量,我定義一個dp[100] 分別保存 爬1級臺階走法,爬2級臺階走法 … 爬100級臺階的走法;

  • 第二步:找元素的關係,爬到第n 級臺階的走法有二種:

    1. 從第 n -1 級臺階 走 1 級上來
    2. 從第 n -2 級臺階 走 2 級上來

    因此總的走法爲 dp[n] = dp[n-1] + dp[n-2]

  • 第三步:找到計算的起點,

    1. 當n = 1時,只有1 級臺階,只能1步跨上來,因此dp[1] = 1;
    2. 當n = 2時,有2級臺階,可以直接1步跨2級 或 一次跨1級走2步,以此dp[2] = 2;

實現代碼:

public void climbStairs() {
  //1. 定義數組或變量
  long[] dp = new long[101];
  //3. 找到計算的起點
  dp[1] = 1;
  dp[2] = 2;

  int index = 3;
  while (index <= 100){
    //2. 找元素的關係 
    dp[index] = dp[index-1] + dp[index-2];
    index++;
  }

  System.out.println(dp[100]);
}

安琪拉:你看下,如果臺階數從100 變成 n,你的空間複雜度是 O(n),這個你覺得還有可優化的空間嗎?

魯班: 我想想,好像數組不需要這麼大,求 dp[n] 只需要用到前二個數 dp[n-1] 和 dp[n-2], 我只需要定義二個變量就好了,這就改代碼,如下:

/**
  * 爬樓梯
  * @param n 臺階數
  * @return
*/
public long climbStairs(int n) {
  if(n == 1 || n == 2){
    return n;
  }
  //1. 定義數組或變量  3. 找到計算的起點
  // dp_n_2 代表 dp[n-2]; dp_n_1代表 dp[n-1];
  long dp_n_2 = 1; 
  long dp_n_1 = 2;

  int index = 3;
  long cur = dp_n_1 + dp_n_2;
  while (index <= n){
    //2. 找元素的關係
    cur = dp_n_1 + dp_n_2;
    dp_n_2 = dp_n_1;
    dp_n_1 = cur;
    index++;
  }

  return cur;
}

安琪拉:不錯不錯,空間複雜度從 O (n) 降到只有 O (1), 我們再來看個稍微複雜一點的動態規劃問題。

題目二:最小路徑和

給定一個包含非負整數的 m x n 網格,請找出一條從左上角到右下角的路徑,使得路徑上的數字總和爲最小。

**說明:**每次只能向下或者向右移動一步。

輸入: 
[
 [1,3,1],
 [1,5,1],
 [4,2,1]
]
輸出: 7
解釋: 因爲路徑 1→3→1→1→1 的總和最小。
**備註**:輸入定義爲 value[m][n] 二維數組 m 行 n 列,這裏 m,n都爲3

魯班: 你都說了是動態規劃問題,我就不用二特性驗證是否可以用動態規劃求解,直接通過三步驟解決問題了:

  • 第一步:定義數組或變量,我定義一個dp[m][n] 的二維數組保存數組中任一位置到左上角起點的最小路徑和;

  • 第二步:找元素的關係,任意選取一個元素,看一下這個元素的值怎麼求得,以選取終點爲例,看下圖:

    image-20200405010724649

    1. 因爲條件限定了只能往右和往下走,如果要到終點,只能從UP元素或者Left元素過來,UP元素代表上方的元素,Left元素代表左側元素,元素名是我自己給起的,爲了方便說明;

    2. 那終點元素的值 = Math.min( UP元素的最小路徑和 , Left元素最小路徑和 ) + 終點元素的值(這裏是1);

      泛化成公式:dp[m] [n] = Math.min( dp[m-1] [n] ,dp[m] [n-1]) + value[m] [n];

      所有動態規劃的描述元素之間關係的公式都有個名字:狀態轉移方程

    除終點元素,其他元素也符合這個規則,但是需要考慮邊界問題,如果元素沒有左元素或者上元素,只需要簡單直接以左元素或者上元素中有的元素最小路徑和 + 當前元素值。

  • 第三步:找到計算的起點,

    1. dp[0] [0] 是左上角第一個元素,作爲起點,因爲不能別的元素過來,因此 dp[0] [0] = value[0] [0];

實現代碼如下:

public int minPathSum(int[][] grid) {
  //邊界檢查
  if(grid == null || grid.length < 1 || grid[0].length < 1){
    return 0;
  }
  //獲取行數 和 列數
  int m = grid.length;
  int n = grid[0].length;
  // 1. 定義二維數組 保存數組每個元素到起點的最小路徑和
  int[][] dp = new int[m][n];
  for(int i = 0; i < m; i++){
    for(int j = 0; j < n; j++){
      //2. 找元素的關係 & 考慮邊界條件
      if(i == 0 && j == 0){
        //第一個元素,沒有上元素,也沒有左元素
        dp[0][0] = grid[0][0];
      }else if(i == 0){
        //沒有上元素,只能從左元素走過來
        dp[i][j] = dp[i][j-1] + grid[i][j];
      }else if(j == 0){
        //沒有左元素,只能從上元素走過來
        dp[i][j] = dp[i-1][j] + grid[i][j];
      }else{
        //既有上元素,也有左元素
        dp[i][j] = Math.min(dp[i][j-1], dp[i-1][j]) + grid[i][j];
      }
    }
  }
  //終點最小路徑和
  return dp[m-1][n-1];
}

安琪拉: 嗯嗯,不錯,但是你看一下,時間複雜度是O(m * n), 空間複雜度是 O(m * n),你覺得空間複雜度能否再優化,另外邊界條件的判斷那裏我覺得也可以再優化。

魯班:我明白了,跟之前的問題一樣:

  1. 當前元素的dp 值只跟前二個元素dp 值有關,我只需要保存推導當前元素所需要的dp值就可以了,不需要申請完整的m * n 的額外空間;
  2. 我還可以把邊界檢查的順序優化一下,因爲 i = 0 或 j = 0 屬於特殊情況,檢查的判斷條件後置,從編譯的角度優先對最有可能進入的條件分支進行判斷。

優化後程序如下:

public int minPathSum(int[][] grid) {
  //省下 m * n 的額外空間,前提: grid 二維數組允許值被覆蓋
  for(int i = 0; i < grid.length; i++) {
    for(int j = 0; j < grid[0].length; j++) {
      //1. 最有可能的分支前置
      if(i != 0 && j != 0){
        grid[i][j] = Math.min(grid[i - 1][j], grid[i][j - 1]) + grid[i][j];
      }
      else if(i == 0)  grid[i][j] = grid[i][j - 1] + grid[i][j];
      else if(j == 0)  grid[i][j] = grid[i - 1][j] + grid[i][j];
      else continue;
    }
  }
  return grid[grid.length - 1][grid[0].length - 1];
}

安琪拉:不錯不錯,看來你掌握了動態規劃的精髓,我們來看更加複雜一點的動態規劃問題:

題目三:零錢兌換

給定不同面額的硬幣 coins 和一個總金額 amount。編寫一個函數來計算可以湊成總金額所需的最少的硬幣個數。如果沒有任何一種硬幣組合能組成總金額,返回 -1。

示例 1:

輸入: coins = [1, 2, 5], amount = 11
輸出: 3
解釋: 11 = 5 + 5 + 1
示例 2:

輸入: coins = [2], amount = 3
輸出: -1
說明: 你可以認爲每種硬幣的數量是無限的。

魯班:【抓耳撓腮思考】這個好像問題有點複雜,不像前二題,我都找不出元素之前的關係,元素怎麼定義也不清楚。😭

安琪拉: 這種問題在動態規劃中叫做求最優解空間,既要籌錢,又要使用的硬幣數量最少,最關鍵的還是找到動態規劃dp 怎麼定義,以及dp 元素之前的關係。我們還是按照三步驟來:

  • 第一步:定義dp數組或變量,首先明確題目說每種硬幣的數量是無限的,但是會給定一個固定的 amount 金額,我們需要用最少的硬幣數湊出這個金額,因此可以定義dp[n] 爲需要湊出金額n 所需要的最小硬幣數;

  • 第二步:找元素的關係,假設現在需要求dp[11](湊11塊錢),現在手裏有1,2,5塊三種硬幣,是不是10,9,6塊錢都+ 1個硬幣就可以湊齊11 塊了(10塊加1塊,9塊加2塊,6塊加5塊)。那我們應該選10,9,6塊中的哪一種呢?

    就看湊齊10,9,6三種金額哪種方式用到的硬幣數量最少,dp(11) = Math.min( dp(10), dp(9), dp(6) ) + 1;

    我們試着開始推導一下狀態轉化方程:

    1. dp[n] 爲湊齊金額爲n 所需要的最少硬幣數量;
    2. coins[] 爲硬幣種類數組,例如coins[3] = [1, 2, 5] 代表硬幣種類爲 1塊,2塊,5塊;
    3. 假設現在dp[0] ~ dp[n-1] 我們都求出來了,也就是湊齊0 ~ n-1元所需要的最少硬幣數都知道了,現在dp[n] 怎麼求? 因爲我們有1塊,2塊,5塊錢,是不是很自然的想到湊齊n 元只需要從 dp[n-1] , dp[n-2], dp[n-5] 中選一個最小的 + 1。泛化狀態轉移方程:dp[n]=minj=0n1dp(ncoin(j))+1dp[n] = \min_{j=0_\ldots n-1}dp(n-coin(j)) + 1
  • 第三步:找到計算的起點,dp[0] = 0 (湊齊0元不需要硬幣)

實現代碼如下:

public int coinChange(int[] coins, int amount) {
  //邊界條件檢查
  if(amount < 0){
    return -1;
  }
  if(amount == 0){
    return 0;
  }

  //1. 定義dp數組,dp[n]代表湊齊n元所屬最小硬幣數
  int[] dp = new int[amount+1];
  Arrays.fill(dp, amount+1);
  //3. 設置計算的起點
  dp[0] = 0;
  for(int i = 0; i <= amount; i++){
    //遍歷所有硬幣,試圖找到最小硬幣數的組合
    for(int coin : coins){
      if(i - coin >= 0){
        dp[i] = Math.min(dp[i], dp[i-coin]+1);
      }
    }
  }
  return dp[amount] > amount ? -1 : dp[amount];
}

魯班: 原來dp[n] 還能這樣定義,n直接當做金額下標,確實沒有想到,通過n - coin(硬幣面值) 確定作爲狀態轉移。

安琪拉:我們多看幾題鞏固一下動態規劃吧。

題目四:按摩師

一個有名的按摩師會收到源源不斷的預約請求,每個預約都可以選擇接或不接。在每次預約服務之間要有休息時間,因此她不能接受相鄰的預約。給定一個預約請求序列,替按摩師找到最優的預約集合(總預約時間最長),返回總的分鐘數。

示例 1:

輸入: [1,2,3,1]
輸出: 4
解釋: 選擇 1 號預約和 3 號預約,總時長 = 1 + 3 = 4。
示例 2:

輸入: [2,7,9,3,1]
輸出: 12
解釋: 選擇 1 號預約、 3 號預約和 5 號預約,總時長 = 2 + 9 + 1 = 12。
示例 3:

輸入: [2,1,4,5,3,1,1,3]
輸出: 12
解釋: 選擇 1 號預約、 3 號預約、 5 號預約和 8 號預約,總時長 = 2 + 4 + 3 + 3 = 12。

魯班:這題看着不太像是滿足動態規劃的二個特點的。按摩師不能連續二天按摩跟動態規劃有關係嗎?

安琪拉:那我們來回顧一下動態規劃的二個特點:

  1. 需要求解的問題具有相似子問題,例如:按摩師前天約了,今天才能約,如果昨天約過了,今天不能約,因此第n天接受預約能達到的最長預約時長爲 Math.max( 第n-2天最長預約時間 +今天的時間,第n-1天最長預約時間),子問題同樣依賴此公式;
  2. 需要求解的結果或最優結果由子問題的結果或最優結果推導而來,這裏是:dp(n)= Math.max(dp(n-1), dp(n-2)+value(今天)),預約不連續。

魯班:我似乎有點理解了,話不多說,直接擼代碼吧!

public int massage(int[] nums) {
  if(nums == null || nums.length ==0){
    return 0;
  }
  if(nums.length < 2){
    return nums[0];
  }
  // 第一步:定義dp數組
  int[] dp = new int[nums.length];
  // 第三步:初始化起點
  dp[0] = nums[0];
  dp[1] = Math.max(dp[0], nums[1]);
  int i = 2;
  while( i < nums.length){
    // 第二步:狀態轉移方程
    dp[i] = Math.max(dp[i-2]+nums[i], dp[i-1]);
    i++;
  }
  return dp[nums.length-1];
}

安琪拉:寫的不錯,看來漸入佳境了,最後二題將今天的動態規劃收尾吧!

題目五:打家劫舍

你是一個專業的小偷,計劃偷竊沿街的房屋。每間房內都藏有一定的現金,影響你偷竊的唯一制約因素就是相鄰的房屋裝有相互連通的防盜系統,如果兩間相鄰的房屋在同一晚上被小偷闖入,系統會自動報警。

給定一個代表每個房屋存放金額的非負整數數組,計算你在不觸動警報裝置的情況下,能夠偷竊到的最高金額。

示例 1:

輸入: [1,2,3,1]
輸出: 4
解釋: 偷竊 1 號房屋 (金額 = 1) ,然後偷竊 3 號房屋 (金額 = 3)。偷竊到的最高金額 = 1 + 3 = 4 。

示例 2:

輸入: [2,7,9,3,1]
輸出: 12
解釋: 偷竊 1 號房屋 (金額 = 2), 偷竊 3 號房屋 (金額 = 9),接着偷竊 5 號房屋 (金額 = 1)。偷竊到的最高金額 = 2 + 9 + 1 = 12 。

魯班: 啥話不說,寫就完了。安琪拉妹妹請過目:

public int rob(int[] nums) {
  int len;
  //邊界檢查
  if(nums == null || (len = nums.length) == 0){
    return 0;
  }
  if(len == 1){
    return nums[0];
  }
  //第一步:定義dp變量
  int dp_pre = nums[0];
  int dp_cur = Math.max(dp_pre, nums[1]);
  int i = 2;
  int tmp;
  while(i < len){
    tmp = dp_cur;
    //狀態轉移方程
    dp_cur = Math.max(dp_pre +nums[i] , dp_cur);
    dp_pre = tmp;
    i++;
  }
  return dp_cur;
}

安琪拉:小偷這題有個變種,你可以思考一下,留作家庭作業,你看如何?

魯班:放馬過來

安琪拉

題目六:打家劫舍2

你是一個專業的小偷,計劃偷竊沿街的房屋,每間房內都藏有一定的現金。這個地方所有的房屋都圍成一圈,這意味着第一個房屋和最後一個房屋是緊挨着的。同時,相鄰的房屋裝有相互連通的防盜系統,如果兩間相鄰的房屋在同一晚上被小偷闖入,系統會自動報警。

給定一個代表每個房屋存放金額的非負整數數組,計算你在不觸動警報裝置的情況下,能夠偷竊到的最高金額。

示例 1:

輸入: [2,3,2]
輸出: 3
解釋: 你不能先偷竊 1 號房屋(金額 = 2),然後偷竊 3 號房屋(金額 = 2), 因爲他們是相鄰的。

示例 2:

輸入: [1,2,3,1]
輸出: 4
解釋: 你可以先偷竊 1 號房屋(金額 = 1),然後偷竊 3 號房屋(金額 = 3)。
偷竊到的最高金額 = 1 + 3 = 4 。

魯班: soga,這個其實就是二選一,只能從第一家和最後一家選一個行竊,代碼如下:

class Solution {
    public int rob(int[] nums) {
        int len;
        if(nums == null || (len=nums.length) == 0){
            return 0;
        }
        if(len == 1){
            return nums[0];
        }
        //不偷第一個 或  不偷最後一個
        return Math.max(
            myRob(Arrays.copyOfRange(nums, 0, nums.length-1)), 
            myRob(Arrays.copyOfRange(nums, 1, nums.length))
            );
    }

    private int myRob(int[] nums){
        int pre = 0;
        int cur = 0;
        int tmp;

        for(int item : nums){
            tmp = cur;
            cur = Math.max(pre + item, cur);
            pre = tmp;
        }
        return cur;
    }
}

安琪拉: 看來你已經能熟練運用 二特性 三步驟 了,希望你以後能橫行峽谷,所向披靡,別輕易被秒了!

魯班:只要安琪拉妹妹不要草叢陰人,我還是很穩的!再見,下次你再教我別的算法技能吧!

安琪拉:關注【安琪拉的博客】,一切好說!

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章