一道題看懂如何解決動態規劃問題並優化(找零錢問題)
目錄
文章內容來自對牛客網左神課程的整理。
很多同學(包括今天之前的我)都認為動態規劃很難,其實很大程度上是因為不知道動態規劃是怎麼從其他演算法一步步優化演變來的,上來就介紹動態規劃的方法論,難免接受度比較低。
“輸出是最好的學習方法”,因此本文旨在整理左神課程中對動態規劃的講解,增加圖示內容,一是對個人學習效果的檢驗,二是希望大家能真正理解並熟練運用動態規劃演算法。
為什麼要從一道題目開始引入?
演算法重在應用,相較於理論說教,在應用場景裡去體會演算法的優劣和演化是非常重要的。
在此文後會接連推出對經典動態規劃問題的題解。
ps:要先自己理解透徹演算法的計算路徑再去看程式碼,看不懂程式碼是因為演算法還沒有理解,而不是因為其他。
首先,大部分暴力搜尋問題都能優化到動態規劃問題。
暴力搜尋->記憶化搜尋->動態規劃->動態規劃的進一步優化 是常見的演算法優化路徑。
找零錢問題:
給定陣列penny,陣列元素值都為正數且不重複。每個值代表一種面值的貨幣,每種面值的貨幣可以使用任意張,再給定一個整數aim代表要找的錢數,求換錢有多少種方法。
假設penny[]為{5,8,10,15,20},aim 為120
最簡單的:暴力搜尋
暴力搜尋就是把所有的情況都列出,如果能得到一種方法湊成aim,就在計數器上加1.
res1、res2 等需要繼續分解計算,因此可通過遞迴演算法進行程式碼實現。
暴力搜尋程式碼實現:
首先找出遞迴演算法需要哪些數值:陣列penny,陣列中可以選擇貨幣面值的起始位置index,還需要兌換多少錢aim。
即每次遞迴是在penny[index],penny[index+1],..penny[N-1]中尋找可兌換成aim的方法種類數。
class Exchange { public: int process(vector<int>penny,int index,int n,int aim){//penny為面值陣列,index為陣列中表示可使用貨幣的子陣列的起始位置, //n 為陣列大小,aim為當前要兌換的面值。 int ress = 0; if(index == n)//遞迴邊界,當前沒有零錢貨幣可以選擇 { ress = aim == 0?1:0;//如果aim為0,也就是在沒有零錢貨幣可以選擇並且當前需要兌換面值為0時,這種方案是可行的,則res=1; //否則當前沒有零錢可以選擇去兌換aim,且aim不為0,這種情況是不可行的,因此可行的方案數res為0; return ress; } else{//index<=n; for(int i = 0;penny[index]*i<=aim;i++){ ress+=process(penny,index+1,n,aim-penny[index]*i); } return ress; } } int countWays(vector<int> penny, int n, int aim) { // write code here if(aim<0 || n == 0 || penny.size()==0) return 0; int rest = process(penny,0,n,aim); return rest; } };
遞迴計算res1時,把在{8,10,15,20}中換120元的基礎上,選0張8元貨幣,在{10,15,20}中換120元這種情況稱為res11;選擇5張8元貨幣,在{10,15,20}中換80元這種情況成稱為res16;
遞迴計算res11,即在{10,15,20}中換120元。我們可以選擇5張10元,在{15,20}中換70元。
遞迴計算res16,即在{10,15,20}中換80元。我們可以選擇1張10元,在{15,20}中換70元。
有些同學可能已經注意到,在兩個遞迴過程中,取{15,20}中任意數量的貨幣來兌換70元的情況都出現了。也就是說,暴力搜尋是有冗餘計算的。
針對冗餘計算的進一步優化:記憶化搜尋
記憶化搜尋的本質是把每次遞迴過程中已經計算出的情況記下來,再出現這種情況時,直接取值即可而不必再計算。
在暴力搜尋遞迴函式process(vector<int>penny,int index,int n,int aim)中,陣列penny和n是不變的,始終變化的是index和aim,因此可以用p(index,aim),我們現在把遞迴過程記錄下來,以(index,aim)為鍵建立全域性雜湊表,記錄每次遞迴過程計算結果,計算前先判斷這個位置是不是已經計算過了。
報錯:
In file included from a.cc:2:
./solution.h:3:65: error: array has incomplete element type 'int []'
int process(vectorpenny,int index,int n,int aim,int map[][]){//penny為面值陣列,index為陣列中表示可使用貨幣的子陣列的起始位置,
^
1 error generated.
在c++ 中二維陣列名作為函式形參時,一定要給定第二個維度的大小,不然編譯不通過。
一維陣列作為函式引數時,要給定陣列大小,不然編譯通過,但是呼叫時會出錯。
class Exchange {
public:
int process(vector<int>penny,int index,int n,int aim,int map[][1002]){//penny為面值陣列,index為陣列中表示可使用貨幣的子陣列的起始位置,
//n 為陣列大小,aim為當前要兌換的面值。
int ress = 0;
if(map[index][aim]!=0)//之前計算過
{
if(map[index][aim] != -1)//index,aim的組合下有可行方案
ress += map[index][aim];
}
else{
if(index == n)//遞迴邊界,當前沒有零錢貨幣可以選擇
{
ress = aim == 0?1:0;
}
else{//index<n;
for(int i = 0;penny[index]*i<=aim;i++){
ress+=process(penny,index+1,n,aim-penny[index]*i,map);
}
}
}
map[index][aim] = ress == 0?-1:ress;
return ress;
}
int countWays(vector<int> penny, int n, int aim) {
// write code here
if(aim<0 || n == 0 || penny.size()==0)
return 0;
int map[51][1002] = {0};//初始化
return process(penny,0,n,aim,map);
}
};
進一步優化:動態規劃
生成行數為N,列數為aim+1的矩陣dp,dp[i][j]表示用penny[0,...i]來兌換j元錢的方法數。
第一列中aim為0,dp[i][0]指的是用penny[0,...i]來兌換0元錢的方法數,顯然為1,因為方法就是每種零錢的數量都是0.
第一行中指的是僅僅用penny[0],兌換aim元錢的方法數,因此當aim是penny[i]的整數倍時,該值為1,其他情況該值為0.
每次遍歷求dp[i][j]的時間複雜度為O(aim),一共有N*aim個dp值,因此時間複雜度為O(N*aim^2).
記憶化搜尋和動態規劃的聯絡
1、記憶化搜尋時間複雜度也為O(N*aim^2).,就是某種形態的動態規劃
2、記憶化搜尋不關心到達某個遞迴過程的路徑,而只是簡單的對計算過的遞迴過程進行記錄
3、動態規劃則是規定好每一個遞迴過程的計算順序,依次進行計算,後面的計算過程嚴格依賴前面的計算過程。
程式碼實現:
class Exchange {
public:
int countWays(vector<int> penny, int n, int aim) {
// write code here
int dp[52][1002]={0};//動態規劃矩陣
for(int j = 0;j<=aim;j++){//第一行
dp[0][j] = j%penny[0]?0:1;
}
for(int i = 1;i<n;i++){
for(int j = 0;j<=aim;j++){
int k = 0;
while(j-k*penny[i]>=0){
dp[i][j] += dp[i-1][j-k*penny[i]];
k++;
}
}
}
return dp[n-1][aim];
}
};
動態規劃的簡化: 去掉列舉過程,簡化動態規劃方程
dp[i][j] = dp[i-1][j] + dp[i-1][j-penny[i]]+dp[i-1][j-penny[i]*2]...(1)
又有dp[i][j-penny[i]] = dp[i-1][j-penny[i]]+dp[i-1][j-penny[i]*2]...
因此對(1)進一步簡化: dp[i][j] = dp[i-1][j] + dp[i][j-penny[i]];
這樣省去了列舉過程,時間複雜度為O(N*aim)。在這裡看到了規定計算順序對動態規劃的重要性。
優化後的動態規劃程式碼:
class Exchange {
public:
int countWays(vector<int> penny, int n, int aim) {
// write code here
int dp[52][1002]={0};//動態規劃矩陣
for(int j = 0;j<=aim;j++){//第一行
dp[0][j] = j%penny[0]?0:1;
}
for(int i = 1;i<n;i++){
for(int j = 0;j<=aim;j++){
if(j>=penny[i])
dp[i][j] = dp[i][j-penny[i]]+dp[i-1][j];
else
dp[i][j] = dp[i-1][j];
}
}
return dp[n-1][aim];
}
};
總結
類似問題面試優化路徑:
暴力搜尋->記憶化搜尋->動態規劃->動態規劃的優化
1、寫出暴力搜尋方法
2、看哪些引數能代表一個遞迴過程,找到那些引數
3、將引數整體當做key,把遞迴結果作為value放入map中,實現記憶化搜尋
4、整理各個狀態的依賴關係,分析記憶化搜尋的依賴路徑,簡單的、可以直接計算的狀態計算,依賴簡單計算結果的狀態後計算,實現動態規劃
5、看動態規劃中狀態的計算能否得到簡化,得到更加簡單的動態規劃方程。
幾乎所有暴力搜尋問題都可以套用以上路徑進行優化,但是經典的動態規劃問題要直接記住動態規劃方法的過程,因為經典已經非常經典了,面試要求會更高。