1.什麼是動態規劃算法?
動態規劃算法是指將複雜問題拆分爲簡單問題,並存儲簡單問題的結果,避免重複計算。
也就是說動態規劃算法需要滿足以下3個特點:
- 1.可以將問題分解爲相似的子問題,並且子問題有重複
- 2.每一個子問題只解決一次
- 3.存儲子問題解決後的結果
2.什麼樣的問題可以用動態規劃解決?
可以用動態規劃解決的問題主要有如下3個特徵:
- 1. 重複的子問題
- 2. 最優子結構
- 3. 無後效性
重複子問題是指:原問題分解得到的子問題中有完全相同的子問題
最優子結構就是指:原問題的最優解可以通過子問題的最優解的解決而解決
無後效性是指:原問題的解可以通過子問題的解獲得,而不用管子問題的解是如何獲得的 或者說 如果給定某一階段的狀態,則 在這一階段以後過程的發展不受這階段以前各段狀態的影響。
有點抽象,下面以例子進行說明
2.1以斐波那契數列(Fibonacci)爲例子
從n=0開始的Fibonacci數列:
0 , 1, 1, 2, 3, 5, 8, 13 ,21 ...
下面我們要計算Fibonacci的fib(n)如何計算呢?
首先我們看要計算fib(n)能否拆分爲子問題,在這裏很明顯
fib(n) = fib(n-1) + fib(n-2)
那麼我們要求fib(5),如何求解呢?
我們通過遞歸來求解:
int fib(int n)
{
if ( n <= 1 )
{
return n;
}
return fib(n-1) + fib(n-2);
}
fib(5)
/ \
fib(4) fib(3)
/ \ / \
fib(3) fib(2) fib(2) fib(1)
/ \ / \ / \
fib(2) fib(1) fib(1) fib(0) fib(1) fib(0)
/ \
fib(1) fib(0)
很明顯,在求解過程中,我們需要求解fib(3)兩次,fib(2)三次。。。。
求解了相同的子問題,每次求解子問題都需要耗費時間,那爲什麼不將子問題的結果存儲起來,再用到相同子問題的時候直接取出來結果就可以,而不用再次計算,從而節省了時間(用空間來換取時間);而這正是動態規劃的意義所在,降低程序時間複雜度,提高程序運行效率
在斐波那契數列求解fib(5)只需求解fib(4)和fib(3),最優子結構這一特徵並不明顯,但我們不用管fib(4)和fib(3)是怎麼求解出來的,直接拿過來用就行了,滿足無後效性。
所以,可以用動態規劃方法來求解。
動態規劃中有兩種不同的方法來存儲子問題的結果:
- 1.自上而下
針對問題的自上而下存儲子問題結果的程序類似於遞歸版本,只有一個小修改,它在計算解決方案之前會查找存儲表。 我們初始化一個查找數組,所有初始值都爲INT_MAX。 每當我們需要子問題的解決方案時,我們首先查看查找表。 如果預先計算的值在那裏,那麼我們返回該值,否則,我們計算該值並將結果放在查找表中,以便以後可以重用它。
vector<int> look_up(n+1,INT_MAX);//查找表一個包含n+1個元素,因爲有fib(0),全部初始化爲INT_MAX,不爲INT_MAX說明該子問題已經被解決過
int fib(int n)
{
if (look_up[n] == INT_MAX)//fib(n)還未被求解過
{
if (n <= 1)
lookup[n] = n;
else
lookup[n] = fib(n - 1) + fib(n - 2); //求解出來後保存到look_up
}
return look_up[n]; //否則,fib(n)已經求解過,再次用的時直接取出返回即可
}
- 2. 自下而上
自底部向上,一般以迭代的方式進行,給定問題以自下而上的方式構建表,並返回表中的最後一個值。 例如,對於相同的斐波納契數,我們首先計算fib(0)然後計算fib(1)然後計算fib(2)然後計算fib(3),依此類推。 通過建立fib(0)和fib(1)而建立出fib(2)
//迭代的方式自下而上
int fib(int n)
{
vector<int> look_up(n+1,INT_MAX);
int i;
look_up[0] = 0;
look_up[1] = 1;
for (i = 2; i <= n; i++)
{
look_up[i] = look_up[i-1] + look_up[i-2];
}
return look_up[n];
}
進一步優化:
通過上述程序,我們發現我們求解的目的是fib(n),但是我們保留了大量的中間結果,這是沒必要的,我們只需要保留要求解fib(i)所需要的前兩個狀態fib(i-1)與fib(i-2)
所以,我們可以用兩個變量來代表fib(i)之前的兩個狀態,減少所用的存儲空間
//迭代的方式自下而上,不保留中間結果進行優化
int fib(int n)
{
int i;
pre1 = 0;
pre2 = 1;
res = 0;//fib(n)最後返回結果
for (i = 1; i <= n; i++)
{
res = pre1 + pre2;
pre1 = pre2;
pre2 = res;
}
return res;
}
大多數動態規劃問題的解題思路可以按以下步驟進行:
- 找到遞歸關係(如何找其實就是如何拆分問題,下面會討論)
- 寫出遞歸程序
然後選擇一種方法優化,熟練之後可以省略寫遞歸程序這一步
- 遞歸程序+存儲子問題結果 自上而下
- 迭代程序+存儲子問題結果 自下而上
- 迭代程序 + N個變量 自下而上
2.2以最短路徑爲例
我們以最短路徑爲例主要說明最優子結構特性
最優子結構特性是指:原問題的最優解可以通過子問題的最優解推出來
以下圖有向圖爲例,求解節點q到節點t的最短路徑,很明顯是 q -> r -> t或者 q -> s ->t
若節點r在最短路徑中,那麼q -> t的最短路徑 就等於 q->r 和 r->t 的最短路徑,拆分爲了兩個子問題的最優解,求出子問題的最優解,那麼原問題的最優解就可以推出來
即最短路徑問題滿足最優子結構特性
但是並不是所有的問題均滿足最優子結構特性,比如最長路徑問題,還是以上圖爲例,
我們要求q -> t的最長路徑,是q -> r -> t或者 q -> s ->t,
若已知r在最長路徑中,那麼原問題是不能拆分爲 q->r 和 r->t的最長路徑的組合的,
因爲q->r的最長路徑是q->s->t->r, 而r->t的最長路徑是r->q->s->t,兩者結合並不能推出原問題的最優解
所以不符合最優子結構問題
3.怎麼解決動態規劃問題?
解決動態規劃問題的關鍵在於如何拆分問題,這是解決動態規劃問題的關鍵點
解決動態規劃問題時可以按以下思路進行嘗試:
- 將當前要解決的問題 i 視爲當前狀態,我們的目標是求出 f(i) ;比如我們求斐波那契數列的第n個數,n就是當前的狀態,f(n)是我們問題的目標
- 找目標f(i)與那些狀態有關,假設與狀態x有關,找出f(x)與f(i)之間的狀態轉移關係; 比如斐波那契數列目標f(n)與n-1狀態和n-2狀態有關,找出f(n)與f(n-1)與f(n-2)之間的狀態轉移關係, f(n) = f(n-1) + f(n-2)
- 有了狀態轉移關係就可以很容易運用
- 動態規劃方法來解決問題
以例子來進行實戰解決動態規劃問題:
- 上樓梯問題
問題描述:你正在爬樓梯。 你需要n步才能達到頂峯,每次你可以爬1或2步, 您可以通過多少不同的方式登頂?
設當期狀態爲 i,i 表示i步才能到達的地方,我們由 f(i) 種方式到達 i 處,下面我們來看看 f(i) 與哪些狀態有關,由於每次只能爬一步或者兩步,只要到達了 i-1 處或者 i-2 處,我們就可以輕鬆到達 i 處,所以 f(i) 的狀態與 f(i-1) 和 f(i-2)有關,所以狀態轉移關係爲:
f(i) = f(i-1) + f(i-2)
發現沒有,這其實就是上面提到的 斐波那契數列,所以程序不再贅述
- 搶劫問題
問題描述:
假設你是一個專業的強盜,計劃在街上搶劫房屋。 每個房子都有一定數量的錢存在,阻止你搶劫他們的唯一限制是鄰近的房子連接了安全系統,如果兩個相鄰的房子在同一個晚上被打破,它將自動聯繫警察。給出一個數組nums包括這條街道上房子包含的金額數,確定你今晚可以搶劫的最高金額
比如:[1,2,3,1] 則在不觸發安全系統的前提下你搶的金額最大爲 1 + 3 = 4
現在 假設你已經來到了第 i 間房子處,設你 搶到第 i 間房子處時,總最大金額爲 f(i)
爲了不觸發安全系統,且爲了達到最大淨額,f(i) 的狀態與 f(i-1) 和 f(i - 2)有關,假如你已經搶了第 i-1 間房子,那麼你肯定不能搶第 i 間房, f(i) = f(i-1);但若你搶的是第 i-2 間房,那麼你可以搶第 i 間房,總金額 f(i) = f(i-2) + nums[i]
nums[i] 表示從第 i 間房搶的金額
所以狀態轉移關係應該爲:
f(i) = max( f(i-1), f(i-2) + nums[i])
自上而下的遞歸程序爲:
class Solution {
public:
int rob(vector<int>& nums)
{
vector<int> memo(nums.size(),INT_MAX);
return rob(nums,nums.size()-1,memo);
}
private:
int rob(vector<int>& nums,int n,vector<int>& memo)
{
if(n < 0)
{
return 0;
}//遞歸結束條件
if(memo[n] != INT_MAX)
{
return memo[n];
}
int result = max(rob(nums,n-1,memo),rob(nums,n-2,memo)+nums[n]);
memo[n] = result;
return result;
}
- 必談的最長上升子序列(LIS)問題
給定長度爲n的序列a,從a中抽取出一個子序列,這個子序列需要單調遞增。問最長的上升子序列(LIS)的長度。
e.g. 1,7,2,8,3,4 中的最長上升子序列爲 1 2 3 4 ,所以長度爲4
現在我們開始拆分問題:假設我們當前狀態爲 i,表示序列a的下標,則我們一定可以找到一個以a[i]爲結尾的上升子序列,(即在這個上升序列中必須以a[i]爲結尾,無論它是否是最長的)
此時LIS爲f(i);下面找f(i)與哪些狀態有關,
很明顯,f(i)與a[i]之前的序列的最長上升序列有關;設之前的最大上升序列爲f(p),p爲a的下標,p可以是i之前的任意下標,而且f(p)是以a[p]爲結尾的最長子序列
那麼如果a[p] < a[i],那麼序列最長上升序列+1
所以寫成狀態轉移方程:
f(i) = max(f(p)) + 1; 0=< p < i
所以,程序如下:
class Solution {
public:
int lengthOfLIS(vector<int>& nums)
{
if(nums.size() == 0)
{
return 0;
}
vector<int> look_up(nums.size()+1,1);
for(int i = 0;i < nums.size();i++)//表示求以a[i]結尾的序列的最長子序列長度
{
for(int p = 0;p < i;p++)//將a[i]依次拼接在a[p]後面,判斷最長子序列長度是否變化
{
if(nums[p] < nums[i])
{
look_up[i] = max(look_up[i],look_up[p]+1);//選出拼接在a[p]後面的最大長度值
}
}
}
int ans = 1;
//look_up[i]中求的都是以a[i]爲結尾的最長上升子序列長度,所以需要遍歷找出最長的上升子序列長度
for(int j = 0;j < nums.size();j++)
{
ans = max(ans,look_up[j]);
}
return ans;
}
};
參考: https://www.geeksforgeeks.org/overlapping-subproblems-property-in-dynamic-programming-dp-1/
https://www.zhihu.com/question/23995189/answer/35429905
https://www.zhihu.com/question/23995189