動態規劃先導

動態規劃問題的通常形式就是求最值。動態規劃實際上是運籌學的一種最優化方法,只不過在計算機問題上應用比較多。
求解動態規劃的核心問題是窮舉。由於要求最值,須要將全部可行的答案窮舉出來,而後在其中找最值。
動態規劃的窮舉點有點特別,由於這類問題存在[重疊子問題],若是經過暴力窮舉的話效率會及其低下,因此須要[備忘錄]或者[DP table]來優化窮舉的過程,避免沒必要要的計算。
並且,動態規劃問題必定具備[最優子結構],才能經過子問題的最值獲得原問題的最值。
另外,雖然動態規劃的核心思想就是窮舉求值,可是問題能夠變幻無窮,窮舉全部的可行解並非一件很容易的事,只有列出正確的[狀態轉移方程],才能正確地窮舉。
上面提到的重疊子問題、最優子結構、狀態轉移方程就是動態規劃的三要素。在實際的算法問題中,寫出狀態轉移方程是最困難的,這也是動態規劃問題的難點和痛點。
明確 base case->明確[狀態]->明確[選擇]->定義dp數組/函數的含義。
最後造成了下面這個框架算法

//初始化base case
dp[0][0][...]=base
//進行狀態轉移
for 狀態1 in 狀態1的全部取值
  for 狀態2 in 狀態2的全部取值
    for...
      dp[狀態1][狀態2][...]=求最值(選擇1,選擇2...)

1、斐波那契數列
東哥講得頗有道理,簡單的例子去參透本質,每種題型的細節須要大量的練習。
1.暴力遞歸
int fib(int N){
if(N==1||N==2){
return 1;
}
return fib(N-1)+fib(N-2);
}
看看算法的時間複雜度,子問題的個數就是遞歸樹中全部節點的總數。顯然二叉樹的節點總數爲指數級別,因此子問題個數爲O(2^n)。
觀察遞歸樹,很明顯發現了算法低效的緣由:存在大量重複計算,好比f(18)被計算了兩次...
重疊子問題就出現了。
2.帶備忘錄的遞歸解法
明確了問題,其實就是把問題解決了一半。既然耗時的緣由是重複計算,那麼咱們能夠造一個[備忘錄],每次算出某個子問題的答案後先不着急返回,先記到[備忘錄]中;每次遇到一個子問題先去[備忘錄]裏查一查,若是發現以前已經解決過這個問題了,直接把答案拿出來用,不要再耗時去計算了。數組

int fib(int N){
 if(N<1) return 0;
 //備忘錄全初始化爲0
 vecotor<int> memo(N+1,0);
 //進行備忘錄的遞歸
 return helper(memo,N);
}

int helper(vector<int >& memo,int n){
//base case
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];
}

選區_214.png
實際上,帶[備忘錄]的遞歸算法,把一棵存在巨量冗餘的遞歸樹經過[剪枝],改形成了一幅不存在冗餘的遞歸圖,極大地減小了子問題(即遞歸圖中節點)的個數。
選區_215.png
遞歸算法的時間複雜度計算方式就是子問題個數乘以解決一個子問題須要的時間。
3.dp數組的迭代解法
有了上一步[備忘錄] 的啓發,咱們能夠把這個[備忘錄]獨立出來稱爲一張表,這叫作DP table吧,在這張表上完成[自底向上]的推算豈不美哉框架

int fib(int N){
if(N<1){
return 0;
}
if(N==1|| N==2){
return 1;
}
vector<int> dp(N+1,0);
//base case
dp[1]=dp[2]=1;
for(int i=3;i<=N;i++){
dp[i]=dp[i-1]+dp[i-2];
}
return dp[N];
}

選區_216.png
咱們能夠發現,和上面的剪枝以後的結果很像,只不過就是遞歸的方式不一樣而已
因此這麼看來,備忘錄和DP table解法實際上是差很少的,效率也基本相同。
選區_217.png
[狀態轉移方程]其實就是爲了聽起來高端。你把f(n)想作一個狀態n,這個狀態n是由n-1和狀態n-2相加轉移而來,這就叫作狀態轉移,僅此而已。
上面幾種解法中都是圍繞狀態轉移方程列出不一樣的表達式,例如return f(n-1)+f(n-2),dp[i]=dp[i-1]+dp[i-2]。可見狀態轉移方程的重要性,它是解決問題的核心。並且很容易發現,其實狀態轉移方程直接表明着暴力解法。
動態規劃問題最要命的是寫出暴力解,即狀態轉移方程,當咱們寫出暴力解以後,優化方法無非是用備忘錄或者DP table,再無奧妙可言。
斐波那契數列問題的小技巧,當前狀態只和前兩個狀態有關,因此咱們可使用兩個變量來存儲以前的兩個狀態。函數

int fib(int n){
if(n<1) return 0;
if(n==2||n==1){
return 1;
}
int prev=1,curr=1;
for(int i=3;i<=n;i++){
int sum=prev+curr;
prev=curr;
curr=sum;
}
return curr;
}

上面的小技巧叫作狀態壓縮,以前咱們也有使用過這些技巧,只不過沒有理論化而已。
2、湊零錢問題
先看下題目:給你k種面值的硬幣,面值分別爲c1,c2...ck,每種硬幣的數量無限,再給一個總金額amount,問你最少須要幾枚硬幣湊出這個金額,若是不能湊出,算法返回-1。優化

//coins中是可選硬幣面值,amount是目標金額
int coinChange(int[] coins,int amount);

1.暴力遞歸
首先,這個問題是動態規劃問題,由於它具備[最優子結構]。要符合[最優子結構],子問題間必需要互相獨立
東哥舉的例子就是,考試中每門成績都是獨立的。你的問題考出最高分,那麼子問題就是把語文考到最高,數學考到最高...爲了每門課考到最高,你要把每門課的選擇題分數拿到最高,填空題分數拿到最高...固然,最終就是你每門課都是滿分,這就是最高的總成績。
獲得了正確的結果:最高的成績就是總分。由於這個過程符合最優子結構,"每門科目考到最高"這些子問題是相互獨立,互不干擾的。
可是,若是加一個條件:"你的語文成績和數學成績會互相制約,數學分數高,語文分數就會下降,反之亦然。"若是再按照剛纔的思路就會獲得錯誤的結果。由於子問題並不獨立,語文數學乘積沒法同時最優,因此最優子結構被破壞。
回到湊零錢問題,爲何說它是符合最優子結構呢?好比你想求amount=11時的最少硬幣數(原問題),若是你知道湊出amount=10的最少硬幣數(子問題),你只須要把子問題的答案加一就是原問題的答案。由於硬幣的數量是沒有限制的,因此子問題之間是相互獨立的。
既然知道了這個是動態規劃問題,就要思考如何列出正確的狀態轉移方程?
1.肯定base case,這個很簡單,顯然目標金額amount爲0時算法返回0,由於不須要任何硬幣就已經湊出目標金額了。
2.肯定[狀態],也就是原問題和子問題中會變化的量。因爲硬幣數量無限,硬幣的金額也是題目給定的,只有目標金額會不斷地向base case靠近,因此惟一的[狀態]就是目標金額amount。
3.肯定選擇,也就是致使[狀態]產生變化的行爲。目標金額爲何變化呢,由於你在選擇硬幣,你每選擇一枚硬幣,就至關於減小了目標金額。因此說全部硬幣的面值就是你的[選擇]
4.明確dp函數/數組的定義。咱們這裏講的是自頂向下的解法,因此會有一個遞歸的dp函數,通常來講函數的參宿就是狀態轉移中會變化的量,也就是說上面說到的[狀態];函數的返回值就是求咱們計算的量。就本題來講,狀態只有一個,即[目標金額],題目要求咱們計算湊出金額所需的最少硬幣數量。因此咱們能夠這樣定義dp函數:
dp(n)的定義:輸入一個目標金額n,返回湊出目標金額n的最少硬幣數量。
僞代碼spa

def coinChange(coins:List[int],amount:int):

#定義:要湊出金額n,至少要dp(n)個硬幣
def dp(n):
        #作選擇,選擇須要硬幣數量最少的那個結果
        for coin in coins:
           res=min(res,1+dp(n-coin));
        return res;
 
#題目要求的最終結果是dp(amount)
return dp(amount);

e便可獲得最終的答案。顯然目標爲0時,所需硬幣數量是0;當目標金額小於0時,無解,返回-1;code

def coinChange(coins:List[int],amount:int):

def dp(n):
#base case
if n==0: return 0;
if n<0:return 1;
#求最小值,因此初始化爲正無窮
res=float('INF');
for coin in coins:
subproblem=dp(n-coin);
#子問題無解,跳過
if subproblem==-1:continue;
res=min(res,1+subproblem);

return res if res!=float('INF') else -1;

return dp(amount);

再給咱們的僞碼加上base case遞歸

def coinChange(coins:List[int],amount:int);

def dp(n):
#base case
if n==0:return 0;
if n<0:return -1;
#求最小值,因此初始化爲正無窮
res=float('INF')
for coin in coins:
subproblem=dp(n-coin);
#子問題無解,跳過
if subproblem==-1:continue;
res=min(res,1+subproblem);

return res if res !=floa('INF') else -1;
return dp(amount);

至此,狀態轉移方程已經完成了,以上算法已是暴力解法了索引

選區_218.png
至此,這個問題其實就解決了,只不過須要消除一下重疊子問題,好比amount=11,coins={1,2,5}時畫出遞歸樹看看:
選區_219.png
咱們來看一下時間複雜度,總數爲遞歸樹節點個數,是O(n^k),總之是指數級別的。每一個子問題中含有一個for循環,複雜度爲O(k)。因此總時間複雜度爲O(k*n^k),指數級別。
2.帶備忘錄的遞歸rem

def coinChange(coins:List[int],amount:int):
#備忘錄
memo=dict();
def dp(n):
#查備忘錄,避免重複計算
if n in memo:return memo[n]
#base case
if n==0:return 0;
if n<0:return -1;
res=float('INF')
for coin in coins:
subproblem=dp(n-coin)
if(subproblem==-1):continue;
res=min(res,1+subproblem);

#記入備忘錄
memo[n]=res if res=float('INF') else -1;
return memo[n];

return dp(amount);

3.dp數組的迭代解法
固然,咱們也能夠自底向上使用dp table來消除重疊子問題,關於狀態和base case與以前沒有區別,dp數組的定義和剛纔dp函數相似,也是把[狀態]也就是目標金額做爲變量。不過dp函數體如今函數參數,而dp數組體如今數組索引:
dp數組的定義:當目標金額爲i時,至少須要dp[i]枚硬幣湊出。

int coinChange(vector<int>& coins,int amount){
//數組大小爲amount+1,初始值爲amount+1
vector<int> dp(amount+1,amount+1);
//base case
dp[0]=0;
//外層for循環在遍歷全部狀態的全部取值
for(int i=0;i<dp.size();i++){
//內層for循環在求全部選擇的最小值
for(int coin:coins){
//子問題無解,跳過
if(i-coin<0){
continue;
}
dp[i]=min(dp[i],1+dp[i-coin]);
}
}
return (dp[amount]==amount+1)?-1:dp[amount];
}

選區_220.pngps:這裏的amount+1至關於初始化爲正無窮總結1.先想出如何窮舉2.再想辦法優化窮舉動態規劃之因此難,主要是由於一是窮舉須要遞歸實現,二是有的問題自己的解空間複雜,不那麼容易窮舉完整。備忘錄、DP Table就是在用空間換時間。

相關文章
相關標籤/搜索
本站公眾號
   歡迎關注本站公眾號,獲取更多信息