算法學習之動態規劃(java版)
動態規劃算法可以有效的解決窮舉問題。當一個窮舉問題存在「重疊子問題」這個特點時,那麼就可以嘗試使用動態規劃算法來解決。
概念
動態規劃算法有三個要素:
- 重疊子問題
- 最優子結構
- 狀態轉義方程
重疊子問題
以斐波那契數列問題爲例,其遞歸方法如下:
int fib(int N) {
if (N == 1 || N == 2)
return 1;
return fib(N - 1) + fib(N - 2);
}
fib(20)=fib(19)+fib(18)
,fib(19)=fib(18)+fib(17)
。根據這兩個例子就可以發現,fib(18)
被重複計算了兩次。整個算法的求解過程中會重複多次計算,可以通過下面這張圖發現這一點:
其中,整個問題呈一顆完全二叉樹,每個節點對應一個子問題,其問題個數爲,每個子問題的計算量爲,因此總的時間複雜度爲。
整棵樹中存在多個重疊子問題,因此是可以優化的。
最優子結構
最優子結構指的是,問題的最優解包含子問題的最優解。
舉個例子:
我們需要求學校中某一年級成績最好的人。這個問題可以拆解爲:求某一年級中每個班級成績最好的人,然後最後在這些人中求成績最好的。
這裏的子問題就是每個班級。
狀態轉義方程
還是以斐波那契數列爲例,我們直接給出其轉義方程
求解
在之前提到窮舉類問題存在重疊子問題的現象,動態規劃算法有兩種解決這一現象的方式:
- 「備忘錄」方法
- 「dpTable」方法
備忘錄
思路:開闢數組,當某一個數值被計算後,在數組中保存其計算結果,在之後需要再用到這個數值時,去數組中直接獲取。
int fib(int N){
if (N < 1) return 0;
int[] memo = new int[N+1];
for(int i = 0; i < N + 1; i++){
memo[i] = 0;
}
return helper(memo, N);
}
int helper(int[] memo, int n){
if(n == 1 || n == 2) return 1;
if(memo[n] != 0) return memo[n];
memo[n] = helper(memo, n-1)+helper(memo, n-2);
return memo[n];
}
代碼中,memo數組就是「備忘錄」,如果『備忘錄中』某一個數值結果已經計算好了,直接取出來用,否則計算,再存入計算結果。
dpTable方法
「DPTable」的思路類似於「備忘錄」方法,但是『DPTable』自下而上計算的。
int fib(int N){
if (N < 1) return 0;
int[] memo = new int[N+1];
for(int i = 0; i < N + 1; i++){
memo[i] = 0;
}
memo[1]=memo[2]=1;
for(int i = 3; i < N +1;i++){
memo[i] = memo[i-1] + memo [i-2];
}
return memo[N];
}
在這裏還可以繼續優化。可以發現在第二個for循環中,memo[i]
只與memo[i-1]
,memo[i-2]
相關,因此可以無需額外開闢數組。
int fib(int N){
if (N < 1) return 0;
int prev, curr, sum;
prev = curr = 1;
for(int i = 3; i < N +1;i++){
sum = prev + curr;
prev = curr;
curr = sum;
}
return sum;
}
例題
湊零錢問題
給你 k 種面值的硬幣,面值分別爲 c1, c2 … ck ,每種硬幣的數量無限,再給一個總金額 amount ,問你最少需要幾枚硬幣湊出這個 金額,如果不可能湊出,算法返回 -1
- 最優子結構
- 當手裏拿着n元硬幣,總金額爲amount時,此時的子問題的最優解爲f(amount-n)+1,就是在(amount-n)爲總金額的最優解基礎上加1個硬幣
- 重疊子問題
- 可以看到當把問題窮舉出來後,會有重複現象
- 轉移方程
備忘錄方法求解
int coinChange(int[] coins, int amount) {
int[] memo = new int[amount + 1];
for (int i = 0; i < amount + 1; i++) {
memo[i] = -1;
}
return dp(coins, amount, memo);
}
int dp(int[] coins, int amount, int[] memo) {
if (amount == 0) return 0;
if (amount < 0) return -1;
if (memo[amount] != -1) return memo[amount];
int res = amount + 1;
for (int coin : coins) {
int subProblem = dp(coins, amount - coin, memo);
if (subProblem == -1) continue;
res = Math.min(res, 1 + subProblem);
}
memo[amount] = res == amount + 1 ? -1 : res;
return memo[amount];
}
dpTable方法求解
int coinChange(int[] coins, int amount) {
int[] memo = new int[amount + 1];
for (int i = 0; i < amount + 1; i++) {
memo[i] = amount + 1;
}
return dp(coins, amount, memo);
}
int dp(int[] coins, int amount, int[] memo) {
memo[0] = 0;
for (int i = 0; i < amount + 1; i++) {
for (int coin : coins) {
if (i - coin < 0) continue;
memo[i] = Math.min(memo[i], 1 + memo[i - coin]);
}
}
return memo[amount] == amount + 1 ? -1 : memo[amount];
}
注意:慎用Integer.MAX_VALUE,容易出現溢出問題。
總結
先想辦法窮舉,然後列出轉移函數,通過備忘錄或者dp table的方式解除重疊子問題。
動態規劃三要素:重疊子問題,最優子結構,狀態轉移方程。
dp的遍歷順序需要注意:
- 遍歷順序中,所需的狀態必須是已經計算出來的
- 遍歷的終點必須是存儲結果的那個位置
申明:本博文是看了labuladong的算法小抄之後個人的理解以及總結。