索尼 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步。大致看一下即可,後面根據例題來具體講述,看完例題再來深究。
- 確定狀態
確定狀態在動態規劃中的作用屬於定海神針。在解動態規劃題是通常需要開闢一個數組,陣列的每個元素f[i]
或者f[i][j]
代表的就是問題的狀態,也即待解決問題要求的東西。
確定狀態主要分為兩步,一步是 確定最後一步
- 轉移方程
轉移方程就是根據原問題與子問題的關係給出的遞推關係。
- 初始條件
對於有些資料,通過轉移方程求不出來,而在後續的計算中又需要用到,這時必須通過手動初始化。
- 邊界條件
主要是針對一些在計算過程中會造成陣列越界的情況進行初始化。
- 計算順序
計算順序的原則是,遞推過程中要用到的值必須要已經計算出來了。一般來說,都是按照所開一維陣列從小到大,二維陣列從左到右、從上到下的順序計算。
3. 例題1:買書
【題目】 有三種硬幣,發別面值2元、5元、7元,每種硬幣足夠做;買一本書需要27元,問如何用最少的硬幣組合正好付清,不需要對方找錢。
題目分析: 為最值型動態規劃問題。
- 確定狀態
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 27−ak。
我們不關心前面的 k − 1 k-1 k−1 枚是怎麼拼出 27 − a k 27-a_k 27−ak 的(可能有1種方案,也可能有100種方案),而且我們現在甚至不知道 k k k 和 a k a_k ak 的具體值為多少,但是我們確定前面 k − 1 k-1 k−1 枚硬幣拼出了 27 − a k 27-a_k 27−ak。
2.子問題:
在上面確定最後一步後,我們的問題就轉換為 “求最少用多少枚硬幣可以拼出( 27 − a k 27-a_k 27−ak)”,而原問題為 “最少用多少枚硬幣可以拼出 27 27 27”。因此,我們將原問題轉化成了一個子問題,只是規模更小( 27 − a k 27-a_k 27−ak)
所以,我們確定狀態為: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)
- 因為需要最少的錢幣數,所以:
- 轉移方程
對人任意的X,滿足下面的方程:
- 初始條件
因為f[0]
無法通過轉移方程計算出來,並且在計算時需要用到,因此進行手動初始化:f[0] = 0
。
- 邊界條件
對於轉移方程存在兩個問題:
1.X-2、X-5、X-7可能小於0,導致陣列越界
2.程式什麼時候停下來?
如果拼不出Y,就定義f[Y] = 正無窮
,例如f[-1] = f[-2] = … = 正無窮
。
- 計算順序
因為在計算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 27−ak
- 轉移方程
- 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,因為機器人只有一種方式走到左上角
- f[0] [0] = 1,因為機器人只有一種方式走到左上角
- 邊界情況
- i = 0 或 j = 0,則前進一部只能有一個方向過來,即f[i] [0] = 1,f[0] [j] = 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[j] 表示青蛙能不能跳到石頭 j:f[j] =
O
R
0
<
=
i
<
j
OR_{0<=i<j}
OR0<=i<j(f[i] AND (i + a[j] >= j))
- 初始條件和邊界情況
- 初始條件: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)。每次都儘量往最遠的石頭跳。