動態規划入門
動態規划題目特點
-
計數
- 有多少種方式走到右下角
- 有多少種方法選出k個數使得和是Sum
-
求最大值最小值
- 從左上角走到右下角路徑的最大數字和
- 最長上升序列長度
-
求存在性
- 取石子游戲, 先手是否必勝
- 能不能選出k個數使得和是Sum
解題步驟
1. 確定步驟
狀態在動態規划中的作用屬於定海神針。
簡單來說, 解動態規划的時候需要開一個數組, 數組的每個元素f【i】或者f【i】【j】代表什么
確定狀態需要兩個意識:
- 最后一步
- 子問題
2. 轉移方程
f【x】 = f【x-1】
3. 初始化條件和邊界情況
初始條件, 用轉移方程算不出來, 需要手動定義
邊界情況, 數組不要越界,向上越界,向下越界
4. 計算順序
當我們計算到f【x】時, 右邊的都已經得到結果了
求最值
題目一
- 你有三種硬幣, 分別面值2元, 5元和7元, 每種硬幣都有足夠多
- 買一本書需要27元
- 如何用最少的硬幣組合正好付清, 不需要對方找錢
最后一步
雖然我們不知道最優策略是什么, 但是最優策略肯定是K枚硬幣a1, a2... ak面值加起來是27
所以一定有一枚最后的硬幣:ak
除掉這枚硬幣, 前面的面值加起來是27 - ak
關鍵點
我們不關心前面的K-1枚硬幣是怎么拼出27-ak的(可能有一種拼法, 可能有100中拼法), 而且我們現在甚至還不知道ak和K, 但是我們確定前面的硬幣拼出了27-ak
因為是最優策略, 所以拼出27-ak的硬幣數一定要最少, 否則這就不是最優策略了
子問題
所以我們就要求: 最少用多少枚硬幣可以拼出27-ak
原問題是最少用多少問題拼出27
我們將原問題轉化成了一個子問題, 而且規模更小: 27-ak
為了簡化定義, 我們設狀態f(x) = 最少用多少枚硬幣拼出x
等等,我們還不知道最后那一枚硬幣ak是多少
最后那枚硬幣ak只可能是2,5或者7
如果ak是2,f(27)應該是f(27-2)+1(加上最后這一枚硬幣2)
如果ak是5,f(27)應該是f(27-5)+1(加上最后這一枚硬幣5)
如果ak是7,f(27)應該是f(27-7)+1(加上最后這一枚硬幣7)
除此之外, 沒有其他的可能了
需要求最少的硬幣數,所以:
f(27) = min(f(27-2)+1, f(27-5)+1, f(27-7)+1)
轉移方程
設狀態f【X】=最少用多少枚硬幣拼出X
對於任意X
f【x】= min{ f【x-2】+1, f【x-5】+1, f【x-7】+1 }
初始化條件和邊界情況
f【x】= min{ f【x-2】+1, f【x-5】+1, f【x-7】+1 }
兩個問題
- x-2,x-5或者x-7小於0怎么辦?
- 什么時候停下來
如果不能拼出Y,就定義f【Y】=正無窮, 例如f【-1】=f【-2】 = 。。。 = 正無窮
所以f【1】 = min【f【-1】+1,f【-4】+1, f【-6】+1】 = 正無窮, 表示拼不出來1
初始條件: f【0】= 0
計算順序
從小到大,一個for循環搞定
/**
*
* @param {int []} A
* @param {int} M
*/
function coinChange(A, M){
var f = new Array(M+1);
// f.fill(0,0, f.length) // 用0初始化數組
var n = A.length // number of kinds of coins
// initialization
f[0] = 0;
var i, j;
// f[1], f[2], .... f[27]
for(i = 1; i <= M; ++i){
f[i] = Number.MAX_VALUE;
// last coin a[j]
for(j = 0; j <= n; ++j){
if(i >= A[j] && f[i - A[j]] != Number.MAX_VALUE){
var a = f[i - A[j]] + 1;
var b = f[i];
f[i] = Math.min.apply(null, [a, b])
}
}
}
if(f[M] == Number.MAX_VALUE){
return -1;
}
return f[M]
}
小結
求最值規划組成的部分:
-
確定狀態
- 最后一步(最優策略中使用的最后一枚硬幣ak)
- 化成子問題(最少的硬幣拼出更小的面值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】。。。。, 因為計算f【2】時需要f【1】f【0】都計算完畢
動態規划:
消除冗余, 加速計算
計數型動態規划
題目二
給定m行n列的網格, 有一個機器人從左上角(0,0)出發, 每一步可以向下或者向右走一步。
問有多少種不同的方式走到右下角。
確定狀態
最后一步: 無論機器人用何種方式到達右下角, 總有最后挪動的一步: 向右或者向下
右下角坐標設為(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)
對於任意一個格子(i,j)
f【i】【j】=f【i-1】【j】 + f【i】f【j-1】
機器人有多少種方式走到(i,j) = 機器人有多少種方式走到(i-1,j) + 機器人有多少種方式走到(i,j-1)
初始條件和邊界情況
初始條件:f【0】【0】=1,因為機器人只有一種方式到左上角
邊界情況:i = 0 或 j = 0, 則前一步只能有一個方向過來->f【i】【j】= 1
計算順序
f【0】【0】 = 1
計算第0行: f【0】【0】, f【0】【1】
答案是f【m-1】【n-1】
代碼
function uniquePaths(m, n) {
var f = new Array(m);
f.fill(new Array(n).fill(0, 0), 0);
var i, j;
for (i = 0; i < m; ++i) {
// row
for (j = 0; j < n; ++j) {
// column
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];
}
存在性動態規划
青蛙跳石頭
有n塊石頭分別在x軸的0, 1, 。。。n-1位置
一只青蛙在石頭0, 想跳到石頭n-1
如果青蛙在第i塊石頭上,他最多可以向右跳距離ai
問青蛙能否跳到石頭n-1
例子
- 輸入: a=【2,3,1,1,4】
- 輸出: True
- 輸入: a=【3,2,1,0,4】
- 輸出: False
確定狀態
- 最后一步:如果青蛙能跳到最后一塊石頭n-1,我們考慮它跳的最后一步
- 這一步是從石頭i跳過來, i < n -1
這需要兩個條件同時滿足
- 青蛙可以跳到石頭i
- 最后一步不超過跳躍的最大距離: n - 1 -i <= ai
子問題
狀態: 設f【j】表示青蛙能不能跳到石頭j
f【j】= OR(f【i】AND i + a【i】>= j)
0 <= i < j: 枚舉上一個跳到的石頭i
f【i】: 青蛙能不能跳到石頭i
i + a【i】>= j:最后一步的距離不能超過ai
初始條件和邊界情況
初始條件: f【0】 = True, 因為青蛙一開始就在石頭0
沒有邊界情況: 因為枚舉不存在越界情況
計算順序
從左到右
答案是f【n-1】
時間復炸度:O(N^2),空間復雜度數組大小:O(N)
代碼
function conJump(A) {
var n = A.length;
f = new Array(n);
f[0] = true;
var i, j;
for (j = 1; j < n; ++j) {
f[j] = false;
for (i = 0; i < j; ++i) {
if (f[i] && i + A[i] >= j) {
f[j] = true;
break;
}
}
}
return f[n - 1];
}
var r1 = conJump([2, 3, 1, 1, 4]);
var r2 = conJump([3, 2, 1, 0, 4]);
console.log(r1);
console.log(r2);
動態規划入門總結
四個組成部分
確定狀態
- 研究最優策略的最后一步
- 化為子問題
轉移方程
- 根據子問題定義直接得到
初始條件和邊界情況
- 細心,考慮周全
計算順序
- 利用之前的計算結果