1. 程式人生 > 資訊 >索尼 Alpha 7R V 曝光:BIONZ XR 處理器,約 2.4 萬元

索尼 Alpha 7R V 曝光:BIONZ XR 處理器,約 2.4 萬元

動態規劃學習筆記

  之前都沒怎麼好好系統的學過動態規劃,遇到不會寫的就一直下次再說,一直逃。現在快畢業了,不管畢業後是要幹嘛,演算法這一關再也逃不了了,還是好好系統的學學吧。

1. 動態規劃題目型別/特點

  動態規劃的問題有很多種,在之前天真的以為只有求最值這類最優解的問題,後面發現還是自己太年輕,格局小了(發出菜比的聲音)。動態規劃主要分為下面幾個型別,每種型別下面是對應型別的例子。

  • 計數問題
    • 在一幅圖中,有多少種方式走到右下角;
    • 有多少種方式選出 k k k 個數使得和是 S u m Sum Sum,比如錢幣數問題。
  • 求最大值/最小值問題
    • 從左上角走到右下角路徑的最大數字和
    • 最長上升子序列長度
    • 最少錢幣數問題,如下圖第2個例子
  • 存在性問題
    • 取石子游戲,先手是否必勝(博弈問題),見下圖第3個例子;
    • 能不能選出 k k k 個數使得和是 S u m Sum Sum


注意: 動態規劃的問題分為上面幾種型別,但是不代表上面三種類型的題目都要用動態規劃來解決,比如在下面青蛙過河問題裡面,用貪心的演算法來解決的話,時間複雜度會更低。

  看個例子,給定一個矩陣網格,一個機器人從左上角出發,每次可以向下或向右走一步。問下面哪個問題可以用動態規劃演算法解決?

  • 題A:求有多少種方式走到右下角?
  • 題B:輸出所有走到右下角的路徑?


【答案】由上面的幾個例子可以知道,題A屬於動態規劃中的 計數問題,而題B通常用遞迴或者搜尋演算法來解決。

2. 動態規劃解題步驟

  其實動態規劃沒有固定的模板,不像BFS、DFS之類的,只需要根據實際題目將某一步進行修改即可。但是,動態規劃題目的分析大致可以分為以下5步。大致看一下即可,後面根據例題來具體講述,看完例題再來深究。

  1. 確定狀態
      確定狀態在動態規劃中的作用屬於定海神針。在解動態規劃題是通常需要開闢一個數組,陣列的每個元素 f[i] 或者 f[i][j] 代表的就是問題的狀態,也即待解決問題要求的東西。
      確定狀態主要分為兩步,一步是 確定最後一步
    ,一步是 根據最後一步確定子問題
  2. 轉移方程
      轉移方程就是根據原問題與子問題的關係給出的遞推關係。
  3. 初始條件
      對於有些資料,通過轉移方程求不出來,而在後續的計算中又需要用到,這時必須通過手動初始化。
  4. 邊界條件
      主要是針對一些在計算過程中會造成陣列越界的情況進行初始化。
  5. 計算順序
      計算順序的原則是,遞推過程中要用到的值必須要已經計算出來了。一般來說,都是按照所開一維陣列從小到大,二維陣列從左到右、從上到下的順序計算。

3. 例題1:買書

【題目】 有三種硬幣,發別面值2元、5元、7元,每種硬幣足夠做;買一本書需要27元,問如何用最少的硬幣組合正好付清,不需要對方找錢。

題目分析: 為最值型動態規劃問題。

  1. 確定狀態

    1.最後一步:最後一步指的就是最優策略中的最後一個決策。
      雖然我們最初不知道最優策略是什麼,但是我們可以知道,最優策略就肯定是 k k k 枚硬幣 a 1 , a 2 , … , a k a_1, a_2, …, a_k a1,a2,,ak 的面值加起來是 27 27 27。假設最後一枚硬幣為 a k a_k ak,那麼除掉最後這枚硬幣,前面的硬幣面值加起來就是 27 − a k 27-a_k 27ak
      我們不關心前面的 k − 1 k-1 k1 枚是怎麼拼出 27 − a k 27-a_k 27ak 的(可能有1種方案,也可能有100種方案),而且我們現在甚至不知道 k k k a k a_k ak 的具體值為多少,但是我們確定前面 k − 1 k-1 k1 枚硬幣拼出了 27 − a k 27-a_k 27ak


    2.子問題
      在上面確定最後一步後,我們的問題就轉換為 “求最少用多少枚硬幣可以拼出( 27 − a k 27-a_k 27ak)”,而原問題為 “最少用多少枚硬幣可以拼出 27 27 27”。因此,我們將原問題轉化成了一個子問題,只是規模更小( 27 − a k 27-a_k 27ak

    所以,我們確定狀態為:f(X) = 最少用多少枚硬幣拼出X

    但是我們現在還不知道最後一枚硬幣 a k a_k ak 為多少,可以為2、5或7任何一枚。
    • 如果 a k a_k ak是2,f(27) = f(27 - 2) + 1(加上最後這一枚硬幣2)
    • 如果 a k a_k ak是5,f(27) = f(27 - 5) + 1(加上最後這一枚硬幣5)
    • 如果 a k a_k ak是7,f(27) = f(27 - 7) + 1(加上最後這一枚硬幣7)
    • 因為需要最少的錢幣數,所以:


  2. 轉移方程
    對人任意的X,滿足下面的方程:


  3. 初始條件
      因為 f[0] 無法通過轉移方程計算出來,並且在計算時需要用到,因此進行手動初始化:f[0] = 0
  4. 邊界條件
    對於轉移方程存在兩個問題:
     1.X-2、X-5、X-7可能小於0,導致陣列越界
     2.程式什麼時候停下來?
    如果拼不出Y,就定義f[Y] = 正無窮,例如f[-1] = f[-2] = … = 正無窮
  5. 計算順序
    因為在計算 f[X] 時需要使用到 f[X-2]、f[X-5]、f[X-7],因此,計算順序採用從左到右。

程式碼實現:

public class Solution{
    public static void main(String[] args) {
    	int[] A = {2, 5, 7};
        int[] f = new int[28];
        //硬幣的種類數
        int n = A.length;
        
        //初始化操作
        f[0] = 0;
        
        int i, j;
        for(i = 1; i <= 27; ++i) {
            f[i] = Integer.MAX_VALUE;
            //邊界條件,最後一枚硬幣
            //f[i] = min{f[i - A[0]] + 1, ……, f[i - A[n-1]] + 1}
            for(j = 0; j < n; ++j) {
                //第一,因為i - A[j]可能為負數,所以首先要判斷i和A[j]的大小關係
                //第二,因為MAX_VALUE+1會造成陣列越界,所以先要判斷是否為MAX_VALUE
                if (i >= A[j] && f[i - A[j]] != Integer.MAX_VALUE) {
                    f[i] = Math.min(f[i - A[j]] + 1, f[i]);
                }
            }
        }
        
        if (f[27] == Integer.MAX_VALUE) {
            f[27] = -1;
        }
        System.out.println(f[27]);
    }
}

4. 例題2:Coin Change

​  給出不同面額的硬幣以及一個總金額。寫一個方法來計算給出的總金額可以換取的最少的硬幣數量. 如果已有硬幣的任意組合均無法與總金額面額相等, 那麼返回 -1

樣例

輸入:
[1, 2, 5]
11
輸出:3
解釋:11 = 5 + 5 + 1
輸入:
[2]
3
輸出:-1

題目分析

  • 最值型動態規劃
  • 動態規劃解題步驟
    • 確定狀態
      • 最後一步:最優策略中使用的最後一枚硬幣 a k a_k ak
      • 化成子問題:最少的硬幣拼出更小的面值 27 − a k 27-a_k 27ak
    • 轉移方程
      • f[X] = min{f[X - 2] + 1, f[X - 5] + 1, f[X - 7] + 1}
    • 初始條件
      • f[0] = 0
    • 邊界情況
      • 如果不能拼出Y,f[Y] = 正無窮
    • 計算順序
      • f[0], f[1], f[2], ……

程式碼實現:

public class Solution{
    // 硬幣面額放入陣列A[],要拼出的金額為M
    public int coinChange(int[] A, int M) {
        //關於陣列開多大的問題:
        //如果要用到n的話,開到n+1:0, 1, ……, n
        //如果不需要用到n的話,開到n:0, 1, ……, n-1
        //這個題因為要開到M,所以陣列大小為M + 1
        int[] f = new int[M + 1];
        //硬幣的種類數
        int n = A.length;
        
        //初始化操作
        f[0] = 0;
        
        int i, j;
        for(i = 1; i <= M; ++i) {
            f[i] = Integer.MAX_VALUE;
            //邊界條件,最後一枚硬幣
            //f[i] = min{f[i - A[0]] + 1, ……, f[i - A[n-1]] + 1}
            for(j = 0; j < n; ++j) {
                //第一,因為i - A[j]可能為負數,所以首先要判斷i和A[j]的大小關係
                //第二,因為MAX_VALUE+1會造成陣列越界,所以先要判斷是否為MAX_VALUE
                if (i >= A[j] && f[i - A[j]] != Integer.MAX_VALUE) {
                    f[i] = Math.min(f[i - A[j]] + 1, f[i]);
                }
            }
        }
        
        if (f[M] == Integer.MAX_VALUE) {
            f[M] = -1;
        }
        return f[M];
    }
}

小tips: for迴圈中+ +i和i++的區別

for迴圈的語法中 ++i 和 i++ 的結果是一樣的,但是效能是不同的。在大量資料的時候 ++i 的效能要比 i++ 的效能好,原因是:

  • i++ 由於是在使用當前值之後再+1,所以需要一個臨時的變數來轉存;
  • ++i 則是在直接+1,省去了對記憶體的操作的環節,相對而言能夠提高效能

5. 例題3:不同的路徑

  給定m行n列的網格,有一個機器人從左上角(0, 0)出發,每一步可以向下或者向右走一步,問有多少種方式走到右下角。

樣例

輸入: n = 1, m = 3
輸出: 1
解釋: 只有一條通往目標位置的路徑。
輸入:  n = 3, m = 3
輸出: 6	
解釋:
	D : Down
	R : Right
	1) DDRR
	2) DRDR
	3) DRRD
	4) RRDD
	5) RDRD
	6) RDDR

題目分析:

  • 計數型動態規劃
  • 確定狀態
    • 最後一步:無論機器人用何種方式到達右下角,總有最後挪動的一步:向右或者向下。
      • 右下角座標設為(m-1,n-1)
      • 那麼前一步,機器人一定是在(m-2,n-1)或者(m-1,n-2)
    • 子問題:如果機器人有X種方式從左上角走到(m-2,n-1),有Y種方式從左上角走到(m-1,n-2),則機器人有X+Y種方式走到(m-1,n-1)
      • 問題轉換為,機器人有多少種方式從左上角走到(m-2,n-1)和(m-1,n-2)
      • 原題要求有多少種方式從左上角走到(m-1,n-1)
    • 狀態: 設f[i] [j]為機器人有多少種方式從左上角走到(i,j)
  • 轉移方程:f[i][j] = f[i - 1][j] + f[i][j - 1]
  • 初始條件
    • f[0] [0] = 1,因為機器人只有一種方式走到左上角
  • 邊界情況
    • i = 0 或 j = 0,則前進一部只能有一個方向過來,即f[i] [0] = 1,f[0] [j] = 1
  • 計算順序:
    • f[0] [0] = 1
    • 計算第0行:f[0] [0], f[0] [1], …, f[0] [n - 1]
    • 計算第1行:f[1] [0], f[1] [1], …, f[1] [n - 1]
    • ……
    • 計算第m - 1行:f[m - 1] [0], f[m - 1] [1], …, f[m - 1] [n - 1]
  • 時間複雜度(計算步數):O(MN)
  • 空間複雜度(陣列大小):O(MN)

程式碼實現:

public class Solution{
    public int uniquePaths(int m, int n) {
        int[][] f = new int[m][n];
        // 從高到低
        for (int i = 0; i < m; ++i) {
            // 從左到右
            for (int j = 0; j < n; ++j) {
                // 第0行,第0列為1,將初始化與計算相結合
                if (i == 0 || j == 0) {
                    f[i][j] = 1;
                } else {
                    f[i][j] = f[i - 1][j] + f[i][j - 1];
                }
            }
        }
        
        return f[m - 1][n - 1];
    }
}

6. 例題4:Jump Game

  有n塊石頭分別在x軸的0,1,……,n-1位置,一隻青蛙在石頭0,想跳到石頭n-1。如果青蛙在第i塊石頭上,他最多可以向右跳距離 a i a_i ai,問青蛙能否跳到石頭n-1。

樣例

輸入 : [2,3,1,1,4]
輸出 : true
輸入 : [3,2,1,0,4]
輸出 : false

題目分析

  • 存在型動態規劃
  • 確定狀態
    • 最後一步
      • 如果青蛙能跳到最後一塊石頭n-1,我們考慮他跳的最後一步
      • 這一步是從石頭 i 跳過來的,i < n-1
      • 這需要兩個條件同時滿足:
        • 青蛙可以跳到石頭 i
        • 最後一步不超過跳躍的最大距離:n-1-i <= a i a_i ai
    • 子問題
      • 那麼,我們需要知道青蛙能不能跳到石頭 i(i < n - 1)
      • 而我們原來要求青蛙能不能跳到石頭 n - 1
    • 狀態:設 f[j] 表示青蛙能不能跳到石頭 j 。
  • 轉移方程
    • 設 f[j] 表示青蛙能不能跳到石頭 j:f[j] = O R 0 < = i < j OR_{0<=i<j} OR0<=i<j(f[i] AND (i + a[j] >= j))

    • OR表示在所有的 i 中,只要有一個滿足條件就行
  • 初始條件和邊界情況
    • 初始條件:f[0] = True,因為青蛙一開始就在石頭0上;
    • 無邊界條件
  • 計算順序
    • 初始化f[0] = True;
    • 計算f[1],f[2],……,f[n - 1]
    • 答案是f[n - 1]
  • 時間複雜度為:O( N 2 N^2 N2)
  • 空間複雜度為:O(N)

程式碼實現

public class Solution {
    public boolean canJump(int[] A) {
        int n = A.length;
        boolean[] f = new boolean[n];
        
        //初始化
        f[0] = true;
        for (int j = 1; j < n; ++j) {
            f[j] = false;
            for (int i = 0; i < j; ++i) {
                if (f[i] && (i + A[i] >= j)) {
                    f[j] = true;
                    break;
                }
            }
        }
        
        return f[n - 1];
    }
}

注意:這個題目還可以用貪心演算法來解決,他的時間複雜度更低,只有O(N)。每次都儘量往最遠的石頭跳。