演算法筆記 動態規劃 遞推寫法

2021-08-15 00:11:14 字數 3792 閱讀 7758

什麼是動態規劃

動態規劃是一種用來解決一類最優化問題的演算法思想。簡單來說,動態規劃將乙個複雜的問題分解成若干個子問題,通過綜合子問題的最優解來得到原問題打的最優解。需要注意的是,動態規劃會將每個求解過的子問題的解記錄下來,這樣當下一次碰到同樣的子問題時,就可以直接使用之前記錄的結果,而不是重複計算。注意:雖然動態規劃採用這種方式來提高計算效率,但不能說這種做法就是動態規劃的核心。

一般可以使用遞迴或者遞推來實現動態規劃,其中遞迴寫法在此處又稱作記憶化搜尋。

動態規劃的遞推寫法

以經典的樹塔問題為例,如果所示,將一些數字排成樹塔的形狀,其中第一層有乙個數字,第二層有兩個數字……第n層有n個數字。現在要從第一層走到第n層,每次只能走向下乙個鏈結的兩個數字中的乙個,問:最後路徑上所有的數字相加後得到的和最大是多少?

按照題目的意思,如果開乙個二維陣列f,其中f[i][j]存放第i層的第j個數字,那麼就有f[1][1] = 5,f[2][1] = 8,f[2][2] = 3,f[3][1] = 12, … ,f[5][4] = 9,f[5][5] = 4.

此時,如果嘗試窮舉所有路徑,然後記錄路徑上數字和的最大值,那麼由於每層中 的每個數字都會有兩條分支路徑,因此可以得到時間複雜度為o(2^n),這在n很大的情況下是不可接受的。那麼,產生這麼大複雜度的原因是什麼?下面來分析一下。

一開始,從第一層的5出發,按5->8->7的路線來到7,並列舉從7出發的到達最底層的所有路徑。但是,之後當按5->3->7的路線再次來到7時,又會去列舉從7出發的到達最底層的所有路徑,這就導致了從7出發的到達最底層的所有路徑都被反覆的訪問 ,做了許多多餘的計算。事實上,可以在第一次列舉從7出發的到達最低層的所有路徑時就把路徑上能產生的最大和記錄下來,這樣當再次訪問到7這個數字時就可以直接獲取最大值,避免重複計算。

由上面的考慮,不妨令dp[i][j]表示從第i行第j個數字出發的到達最底層的所有路徑中能得到的最大和,例如dp[3][2]就是圖中的7到最底層的路徑最大和。在定義這個陣列之後,dp[1][1]就是最終想要的答案,現在想辦法求出它。

注意到乙個細節:如果想要求出「從位置(1,1)到達最底層的最大和dp[2][1]」和「從位置(2,2)到達最底層的最大和dp[2][2]」,即進行了一次決策:走數字5的左下還是右下。於是dp[1][1]就是dp[2][1]和dp[2][2]的較大值加上5。寫成式子就是:

dp[1][1] = max(dp[2][1],dp[2][2]) + f[1][1]

由此可歸納得到這麼乙個資訊:如果要求出dp[i][j],那麼一定要先求出它的兩個子問題「從位置(i+1,j)到達最底層的最大和dp[i+1][j]」和「從位置(i+1,j+1)到達最底層的最大和dp[i+1][j+1]」,即進行了一次決策:走位置(i,j)的左下還是右下。於是dp[i][j]就是dp[i+1][j]和dp[i+1][j+1]的較大值加上f[i][j]。寫成式子就是:

dp[i][j] = max(dp[i+1][j],dp[i+1][j+1]) + f[i][j]

把dp[i][j]稱為問題的狀態,而把上面的式子稱作狀態轉移方程,它把狀態dp[i][j]轉移為dp[i+1][j]和dp[i+1][j+1]。可以發現,狀態dp[i][j]只與第i+1層的狀態有關,而與其他層的狀態無關,這樣層號為i的狀態就總是可以由層號為i+1的兩個子狀態得到。那麼,如果總是將層號增大,什麼失=時候會到頭呢 ?可以發現,數塔的最後一層的dp值總是等於元素本身,即dp[n][j] == f[n][j](1<= j <= n),把這種可以直接確定其結果的部分稱為邊界,而動態規劃的遞推寫法總是從這些邊界出發,通過狀態轉移方程擴散到整個dp陣列。

這樣就可以從最底層各位置的dp值開始,不斷往上求出每一層各位置的dp值,最後就會得到dp[1][1],即為想要的答案。

下面根據這種思想寫出動態規劃的**:

#include

#include

using

namespace

std;

const

int maxn = 1000;

int f[maxn][maxn],dp[maxn][maxn];

int main()

}//邊界

for(int j = 1;j <= n;j++)

//從第n-1層不斷往上計算出dp[i][j]

for(int i = n - 1;i >= 1;i--)

}printf("%d\n",dp[1][1]);//dp[1][1]即為需要的答案

return

0;}

輸入圖中的資料:

5

58 3

12 7 16

4 10 11 6

9 5 3 9 4

輸出結果:

44
從圖中也可以知道,路徑5->3->16->11->9所得到的即為最大和44.

顯然,使用遞迴也可以實現上面的例子(即從dp[1][1]開始遞迴,直至到達邊界時返回結果)。兩者的區別在於:使用遞推寫法的計算方式是自底向上,即從邊界開始,不斷向上解決問題,直到解決了目標問題;而使用遞迴寫法的計算方式的自頂向下,即從目標問題開始,將它分解成子問題的組合,直到分解至邊界為止。

通過上面的例子再引申出乙個概念:如果乙個問題的最優解可以由其子問題的最優解有效的構造出來,那麼稱這個問題擁有最優子結構。最優子結構保證了動態規劃中原問題的最優解可以由子問題的最優解推導而來。因此,乙個問題必須擁有最優子結構,才能使用動態規劃去解決。例如數塔問題中,每乙個位置的dp值都可以由它的兩個子問題推導得到。

至此,重疊子問題和最優子結構的內容已介紹完畢。需要指出,乙個問題必須擁有重疊子問題和最優子結構,才能使用動態規劃去解決。下面指出這兩個概念的區別:

分治與動態規劃。分治和動態規劃都是將問題分解為子問題,然後合併子問題的解得到原問題的解。但是不同的是,分治法分解出的子問題是不重疊的,因此分治法解決的問題不擁有重疊子問題,而動態規劃解決的問題擁有重疊子問題。例如,歸併排序和快速排序都是分別處理左序列和右序列,然後將左右序列的結果合併,過程中不出現重疊子問題,因此它們使用的都是分治法。另外,分治法解決的問題不一定是最優化問題,而動態規劃解決的問題一定是最優化問題。

貪心與動態規劃。貪心和動態規劃都要求原問題必須擁有最優子結構。二者的區別在於,貪心法採用的計算方式類似於上面介紹的「自頂向下」,但是並不等待子問題求解完畢後再選擇使用哪乙個,而是通過一種策略直接選擇乙個子問題去求解,沒被選擇的子問題就不去求解了,直接拋棄。也就是說,它總是只在上一步選擇的基礎上繼續選擇,因此整個過程以一種單鏈的流水方式進行,顯然這種所謂「最優選擇」的正確性需要用歸納法證明。例如,對數塔問題而言,貪心法從最上層開始,每次選擇左下和右下兩個數字中較大的乙個,一直到最底層得到最後結果,顯然這不一定可以得到最優解。而動態規劃不管是採用自底向上還是自頂向下的計算方式,都是從邊界開始向上得到目標問題的解。也就是說,它總是會考慮所有子問題,並選擇繼承能得到最優結果的那個,對暫時沒被繼承的子問題,由於重疊子問題的存在,後期可能會再次考慮它們,因此還有機會成為全域性最優的一部分,不需要放棄。所以貪心是一種壯士斷腕的決策,只要進行了選擇,就不後悔;動態規劃則要看哪個選擇笑到了最後,暫時的領先說明不了什麼。

摘自《演算法筆記》胡凡,曾磊主編

動態規劃 遞推

hdu2044 1到n的路徑數f n 有兩種 f n 1 的路徑,f n 2 的路徑 編號a到b的蜂房可以看作編號1到b a的蜂房 include using namespace std long long f 55 int n int main int a,b cin n while n retu...

演算法筆記 動態規劃

動態規劃要記錄子問題的解,避免下次遇到相同的子問題時的重複計算 在計算斐波那契數列時,我們採用遞迴的寫法 int f int n 這時候會涉及很多重複計算,如當n 5時,計算f 5 f 4 f 3 在接下來計算f 4 f 3 f 2 時,有重複計算了f 3 為了避免重複計算,引入dp陣列,用來儲存已...

遞迴 遞推 動態規劃

問題一 已知f 1 1 f 2 1 且f n f n 1 f n 2 則f n 等於多少?解法一 遞迴 找到遞迴關係和遞迴出口 include using namespace std int n int sum int i int main 解法二 遞推 include using namespac...