算法-动态规划 Dynamic Programming--从菜鸟到老鸟
前言
最近在牛客網(wǎng)上做了幾套公司的真題,發(fā)現(xiàn)有關(guān)動(dòng)態(tài)規(guī)劃(Dynamic Programming)算法的題目很多。相對(duì)于我來(lái)說(shuō),算法里面遇到的問(wèn)題里面感覺(jué)最難的也就是動(dòng)態(tài)規(guī)劃(Dynamic Programming)算法了,于是花了好長(zhǎng)時(shí)間,查找了相關(guān)的文獻(xiàn)和資料準(zhǔn)備徹底的理解動(dòng)態(tài)規(guī)劃(Dynamic Programming)算法。一是幫助自己總結(jié)知識(shí)點(diǎn),二是也能夠幫助他人更好的理解這個(gè)算法。后面的參考文獻(xiàn)只是我看到的文獻(xiàn)的一部分。
動(dòng)態(tài)規(guī)劃算法的核心
理解一個(gè)算法就要理解一個(gè)算法的核心,動(dòng)態(tài)規(guī)劃算法的核心是下面的一張圖片和一個(gè)小故事。
A * "1+1+1+1+1+1+1+1 =?" *A : "上面等式的值是多少" B : *計(jì)算* "8!"A *在上面等式的左邊寫(xiě)上 "1+" * A : "此時(shí)等式的值為多少" B : *quickly* "9!" A : "你怎么這么快就知道答案了" A : "只要在8的基礎(chǔ)上加1就行了" A : "所以你不用重新計(jì)算因?yàn)槟阌涀×说谝粋€(gè)等式的值為8!動(dòng)態(tài)規(guī)劃算法也可以說(shuō)是 '記住求過(guò)的解來(lái)節(jié)省時(shí)間'"由上面的圖片和小故事可以知道動(dòng)態(tài)規(guī)劃算法的核心就是記住已經(jīng)解決過(guò)的子問(wèn)題的解。
動(dòng)態(tài)規(guī)劃算法的兩種形式
上面已經(jīng)知道動(dòng)態(tài)規(guī)劃算法的核心是記住已經(jīng)求過(guò)的解,記住求解的方式有兩種:①自頂向下的備忘錄法 ②自底向上。
為了說(shuō)明動(dòng)態(tài)規(guī)劃的這兩種方法,舉一個(gè)最簡(jiǎn)單的例子:求斐波拉契數(shù)列**Fibonacci **。先看一下這個(gè)問(wèn)題:
以前學(xué)c語(yǔ)言的時(shí)候?qū)戇^(guò)這個(gè)算法使用遞歸十分的簡(jiǎn)單。先使用遞歸版本來(lái)實(shí)現(xiàn)這個(gè)算法:
public int fib(int n) {if(n<=0)return 0;if(n==1)return 1;return fib( n-1)+fib(n-2); } //輸入6 //輸出:8先來(lái)分析一下遞歸算法的執(zhí)行流程,假如輸入6,那么執(zhí)行的遞歸樹(shù)如下:
上面的遞歸樹(shù)中的每一個(gè)子節(jié)點(diǎn)都會(huì)執(zhí)行一次,很多重復(fù)的節(jié)點(diǎn)被執(zhí)行,fib(2)被重復(fù)執(zhí)行了5次。由于調(diào)用每一個(gè)函數(shù)的時(shí)候都要保留上下文,所以空間上開(kāi)銷(xiāo)也不小。這么多的子節(jié)點(diǎn)被重復(fù)執(zhí)行,如果在執(zhí)行的時(shí)候把執(zhí)行過(guò)的子節(jié)點(diǎn)保存起來(lái),后面要用到的時(shí)候直接查表調(diào)用的話可以節(jié)約大量的時(shí)間。下面就看看動(dòng)態(tài)規(guī)劃的兩種方法怎樣來(lái)解決斐波拉契數(shù)列**Fibonacci **數(shù)列問(wèn)題。
①自頂向下的備忘錄法
public static int Fibonacci(int n) {if(n<=0)return n;int []Memo=new int[n+1]; for(int i=0;i<=n;i++)Memo[i]=-1;return fib(n, Memo);}public static int fib(int n,int []Memo){if(Memo[n]!=-1)return Memo[n];//如果已經(jīng)求出了fib(n)的值直接返回,否則將求出的值保存在Memo備忘錄中。 if(n<=2)Memo[n]=1;else Memo[n]=fib( n-1,Memo)+fib(n-2,Memo); return Memo[n];}備忘錄法也是比較好理解的,創(chuàng)建了一個(gè)n+1大小的數(shù)組來(lái)保存求出的斐波拉契數(shù)列中的每一個(gè)值,在遞歸的時(shí)候如果發(fā)現(xiàn)前面fib(n)的值計(jì)算出來(lái)了就不再計(jì)算,如果未計(jì)算出來(lái),則計(jì)算出來(lái)后保存在Memo數(shù)組中,下次在調(diào)用fib(n)的時(shí)候就不會(huì)重新遞歸了。比如上面的遞歸樹(shù)中在計(jì)算fib(6)的時(shí)候先計(jì)算fib(5),調(diào)用fib(5)算出了fib(4)后,fib(6)再調(diào)用fib(4)就不會(huì)在遞歸fib(4)的子樹(shù)了,因?yàn)閒ib(4)的值已經(jīng)保存在Memo[4]中。
②自底向上的動(dòng)態(tài)規(guī)劃
備忘錄法還是利用了遞歸,上面算法不管怎樣,計(jì)算fib(6)的時(shí)候最后還是要計(jì)算出fib(1),fib(2),fib(3)…,那么何不先計(jì)算出fib(1),fib(2),fib(3)…,呢?這也就是動(dòng)態(tài)規(guī)劃的核心,先計(jì)算子問(wèn)題,再由子問(wèn)題計(jì)算父問(wèn)題。
public static int fib(int n) {if(n<=0)return n;int []Memo=new int[n+1];Memo[0]=0;Memo[1]=1;for(int i=2;i<=n;i++){Memo[i]=Memo[i-1]+Memo[i-2];} return Memo[n]; }自底向上方法也是利用數(shù)組保存了先計(jì)算的值,為后面的調(diào)用服務(wù)。觀察參與循環(huán)的只有 i,i-1 , i-2三項(xiàng),因此該方法的空間可以進(jìn)一步的壓縮如下。
public static int fib(int n){if(n<=1)return n;int Memo_i_2=0;int Memo_i_1=1;int Memo_i=1;for(int i=2;i<=n;i++){Memo_i=Memo_i_2+Memo_i_1;Memo_i_2=Memo_i_1;Memo_i_1=Memo_i;} return Memo_i;}一般來(lái)說(shuō)由于備忘錄方式的動(dòng)態(tài)規(guī)劃方法使用了遞歸,遞歸的時(shí)候會(huì)產(chǎn)生額外的開(kāi)銷(xiāo),使用自底向上的動(dòng)態(tài)規(guī)劃方法要比備忘錄方法好。
你以為看懂了上面的例子就懂得了動(dòng)態(tài)規(guī)劃嗎?那就too young too simple了。動(dòng)態(tài)規(guī)劃遠(yuǎn)遠(yuǎn)不止如此簡(jiǎn)單,下面先給出一個(gè)例子看看能否獨(dú)立完成。然后再對(duì)動(dòng)態(tài)規(guī)劃的其他特性進(jìn)行分析。
動(dòng)態(tài)規(guī)劃小試牛刀
例題:鋼條切割
上面的例題來(lái)自于算法導(dǎo)論
關(guān)于題目的講解就直接截圖算法導(dǎo)論書(shū)上了這里就不展開(kāi)講。現(xiàn)在使用一下前面講到三種方法來(lái)來(lái)實(shí)現(xiàn)一下。
①遞歸版本
遞歸很好理解,如果不懂可以看上面的講解,遞歸的思路其實(shí)和回溯法是一樣的,遍歷所有解空間但這里和上面斐波拉契數(shù)列的不同之處在于,在每一層上都進(jìn)行了一次最優(yōu)解的選擇,q=Math.max(q, p[i-1]+cut(p, n-i));這個(gè)段語(yǔ)句就是最優(yōu)解選擇,這里上一層的最優(yōu)解與下一層的最優(yōu)解相關(guān)。
②備忘錄版本
public static int cutMemo(int []p){int []r=new int[p.length+1];for(int i=0;i<=p.length;i++)r[i]=-1; return cut(p, p.length, r);}public static int cut(int []p,int n,int []r){int q=-1;if(r[n]>=0)return r[n];if(n==0)q=0;else {for(int i=1;i<=n;i++)q=Math.max(q, cut(p, n-i,r)+p[i-1]);}r[n]=q;return q;}有了上面求斐波拉契數(shù)列的基礎(chǔ),理解備忘錄方法也就不難了。備忘錄方法無(wú)非是在遞歸的時(shí)候記錄下已經(jīng)調(diào)用過(guò)的子函數(shù)的值。這道鋼條切割問(wèn)題的經(jīng)典之處在于自底向上的動(dòng)態(tài)規(guī)劃問(wèn)題的處理,理解了這個(gè)也就理解了動(dòng)態(tài)規(guī)劃的精髓。
③自底向上的動(dòng)態(tài)規(guī)劃
public static int buttom_up_cut(int []p){int []r=new int[p.length+1];for(int i=1;i<=p.length;i++){int q=-1;//①for(int j=1;j<=i;j++)q=Math.max(q, p[j-1]+r[i-j]);r[i]=q;}return r[p.length];}自底向上的動(dòng)態(tài)規(guī)劃問(wèn)題中最重要的是理解注釋①處的循環(huán),這里外面的循環(huán)是求r[1],r[2]…,里面的循環(huán)是求出r[1],r[2]…的最優(yōu)解,也就是說(shuō)r[i]中保存的是鋼條長(zhǎng)度為i時(shí)劃分的最優(yōu)解,這里面涉及到了最優(yōu)子結(jié)構(gòu)問(wèn)題,也就是一個(gè)問(wèn)題取最優(yōu)解的時(shí)候,它的子問(wèn)題也一定要取得最優(yōu)解。下面是長(zhǎng)度為4的鋼條劃分的結(jié)構(gòu)圖。我就偷懶截了個(gè)圖。
動(dòng)態(tài)規(guī)劃原理
雖然已經(jīng)用動(dòng)態(tài)規(guī)劃方法解決了上面兩個(gè)問(wèn)題,但是大家可能還跟我一樣并不知道什么時(shí)候要用到動(dòng)態(tài)規(guī)劃。總結(jié)一下上面的斐波拉契數(shù)列和鋼條切割問(wèn)題,發(fā)現(xiàn)兩個(gè)問(wèn)題都涉及到了重疊子問(wèn)題,和最優(yōu)子結(jié)構(gòu)。
①最優(yōu)子結(jié)構(gòu)
用動(dòng)態(tài)規(guī)劃求解最優(yōu)化問(wèn)題的第一步就是刻畫(huà)最優(yōu)解的結(jié)構(gòu),如果一個(gè)問(wèn)題的解結(jié)構(gòu)包含其子問(wèn)題的最優(yōu)解,就稱此問(wèn)題具有最優(yōu)子結(jié)構(gòu)性質(zhì)。因此,某個(gè)問(wèn)題是否適合應(yīng)用動(dòng)態(tài)規(guī)劃算法,它是否具有最優(yōu)子結(jié)構(gòu)性質(zhì)是一個(gè)很好的線索。使用動(dòng)態(tài)規(guī)劃算法時(shí),用子問(wèn)題的最優(yōu)解來(lái)構(gòu)造原問(wèn)題的最優(yōu)解。因此必須考查最優(yōu)解中用到的所有子問(wèn)題。
②重疊子問(wèn)題
在斐波拉契數(shù)列和鋼條切割結(jié)構(gòu)圖中,可以看到大量的重疊子問(wèn)題,比如說(shuō)在求fib(6)的時(shí)候,fib(2)被調(diào)用了5次,在求cut(4)的時(shí)候cut(0)被調(diào)用了4次。如果使用遞歸算法的時(shí)候會(huì)反復(fù)的求解相同的子問(wèn)題,不停的調(diào)用函數(shù),而不是生成新的子問(wèn)題。如果遞歸算法反復(fù)求解相同的子問(wèn)題,就稱為具有重疊子問(wèn)題(overlapping subproblems)性質(zhì)。在動(dòng)態(tài)規(guī)劃算法中使用數(shù)組來(lái)保存子問(wèn)題的解,這樣子問(wèn)題多次求解的時(shí)候可以直接查表不用調(diào)用函數(shù)遞歸。
動(dòng)態(tài)規(guī)劃的經(jīng)典模型
線性模型
線性模型的是動(dòng)態(tài)規(guī)劃中最常用的模型,上文講到的鋼條切割問(wèn)題就是經(jīng)典的線性模型,這里的線性指的是狀態(tài)的排布是呈線性的。【例題1】是一個(gè)經(jīng)典的面試題,我們將它作為線性模型的敲門(mén)磚。
**【例題1】**在一個(gè)夜黑風(fēng)高的晚上,有n(n <= 50)個(gè)小朋友在橋的這邊,現(xiàn)在他們需要過(guò)橋,但是由于橋很窄,每次只允許不大于兩人通過(guò),他們只有一個(gè)手電筒,所以每次過(guò)橋的兩個(gè)人需要把手電筒帶回來(lái),i號(hào)小朋友過(guò)橋的時(shí)間為T(mén)[i],兩個(gè)人過(guò)橋的總時(shí)間為二者中時(shí)間長(zhǎng)者。問(wèn)所有小朋友過(guò)橋的總時(shí)間最短是多少。
每次過(guò)橋的時(shí)候最多兩個(gè)人,如果橋這邊還有人,那么還得回來(lái)一個(gè)人(送手電筒),也就是說(shuō)N個(gè)人過(guò)橋的次數(shù)為2*N-3(倒推,當(dāng)橋這邊只剩兩個(gè)人時(shí)只需要一次,三個(gè)人的情況為來(lái)回一次后加上兩個(gè)人的情況…)。有一個(gè)人需要來(lái)回跑,將手電筒送回來(lái)(也許不是同一個(gè)人,realy?!)這個(gè)回來(lái)的時(shí)間是沒(méi)辦法省去的,并且回來(lái)的次數(shù)也是確定的,為N-2,如果是我,我會(huì)選擇讓跑的最快的人來(lái)干這件事情,但是我錯(cuò)了…如果總是跑得最快的人跑回來(lái)的話,那么他在每次別人過(guò)橋的時(shí)候一定得跟過(guò)去,于是就變成就是很簡(jiǎn)單的問(wèn)題了,花費(fèi)的總時(shí)間:
T = minPTime * (N-2) + (totalSum-minPTime)
來(lái)看一組數(shù)據(jù) 四個(gè)人過(guò)橋花費(fèi)的時(shí)間分別為 1 2 5 10,按照上面的公式答案是19,但是實(shí)際答案應(yīng)該是17。
具體步驟是這樣的:
第一步:1和2過(guò)去,花費(fèi)時(shí)間2,然后1回來(lái)(花費(fèi)時(shí)間1);
第二歩:3和4過(guò)去,花費(fèi)時(shí)間10,然后2回來(lái)(花費(fèi)時(shí)間2);
第三部:1和2過(guò)去,花費(fèi)時(shí)間2,總耗時(shí)17。
所以之前的貪心想法是不對(duì)的。我們先將所有人按花費(fèi)時(shí)間遞增進(jìn)行排序,假設(shè)前i個(gè)人過(guò)河花費(fèi)的最少時(shí)間為opt[i],那么考慮前i-1個(gè)人過(guò)河的情況,即河這邊還有1個(gè)人,河那邊有i-1個(gè)人,并且這時(shí)候手電筒肯定在對(duì)岸,所以opt[i] = opt[i-1] + a[1] + a[i] (讓花費(fèi)時(shí)間最少的人把手電筒送過(guò)來(lái),然后和第i個(gè)人一起過(guò)河)如果河這邊還有兩個(gè)人,一個(gè)是第i號(hào),另外一個(gè)無(wú)所謂,河那邊有i-2個(gè)人,并且手電筒肯定在對(duì)岸,所以opt[i] = opt[i-2] + a[1] + a[i] + 2a[2] (讓花費(fèi)時(shí)間最少的人把電筒送過(guò)來(lái),然后第i個(gè)人和另外一個(gè)人一起過(guò)河,由于花費(fèi)時(shí)間最少的人在這邊,所以下一次送手電筒過(guò)來(lái)的一定是花費(fèi)次少的,送過(guò)來(lái)后花費(fèi)最少的和花費(fèi)次少的一起過(guò)河,解決問(wèn)題)
所以 opt[i] = min{opt[i-1] + a[1] + a[i] , opt[i-2] + a[1] + a[i] + 2a[2] }
區(qū)間模型
區(qū)間模型的狀態(tài)表示一般為d[i][j],表示區(qū)間[i, j]上的最優(yōu)解,然后通過(guò)狀態(tài)轉(zhuǎn)移計(jì)算出[i+1, j]或者[i, j+1]上的最優(yōu)解,逐步擴(kuò)大區(qū)間的范圍,最終求得[1, len]的最優(yōu)解。
【例題2】給定一個(gè)長(zhǎng)度為n(n <= 1000)的字符串A,求插入最少多少個(gè)字符使得它變成一個(gè)回文串。
典型的區(qū)間模型,回文串擁有很明顯的子結(jié)構(gòu)特征,即當(dāng)字符串X是一個(gè)回文串時(shí),在X兩邊各添加一個(gè)字符’a’后,aXa仍然是一個(gè)回文串,我們用d[i][j]來(lái)表示A[i…j]這個(gè)子串變成回文串所需要添加的最少的字符數(shù),那么對(duì)于A[i] == A[j]的情況,很明顯有 d[i][j] = d[i+1][j-1] (這里需要明確一點(diǎn),當(dāng)i+1 > j-1時(shí)也是有意義的,它代表的是空串,空串也是一個(gè)回文串,所以這種情況下d[i+1][j-1] = 0);當(dāng)A[i] != A[j]時(shí),我們將它變成更小的子問(wèn)題求解,我們有兩種決策:
1、在A[j]后面添加一個(gè)字符A[i];
2、在A[i]前面添加一個(gè)字符A[j];
根據(jù)兩種決策列出狀態(tài)轉(zhuǎn)移方程為:
d[i][j] = min{ d[i+1][j], d[i][j-1] } + 1; (每次狀態(tài)轉(zhuǎn)移,區(qū)間長(zhǎng)度增加1)
空間復(fù)雜度O(n2),時(shí)間復(fù)雜度O(n2), 下文會(huì)提到將空間復(fù)雜度降為O(n)的優(yōu)化算法。
背包模型
背包問(wèn)題是動(dòng)態(tài)規(guī)劃中一個(gè)最典型的問(wèn)題之一。由于網(wǎng)上有非常詳盡的背包講解,這里只將常用部分抽出來(lái)。
**【例題3】**有N種物品(每種物品1件)和一個(gè)容量為V的背包。放入第 i 種物品耗費(fèi)的空間是Ci,得到的價(jià)值是Wi。求解將哪些物品裝入背包可使價(jià)值總和最大。f[i][v]表示前i種物品恰好放入一個(gè)容量為v的背包可以獲得的最大價(jià)值。決策為第i個(gè)物品在前i-1個(gè)物品放置完畢后,是選擇放還是不放,狀態(tài)轉(zhuǎn)移方程為:
f[i][v] = max{ f[i-1][v], f[i-1][v – Ci] +Wi }
時(shí)間復(fù)雜度O(VN),空間復(fù)雜度O(VN) (空間復(fù)雜度可利用滾動(dòng)數(shù)組進(jìn)行優(yōu)化達(dá)到O(V) )。
動(dòng)態(tài)規(guī)劃題集整理
1、最長(zhǎng)單調(diào)子序列
Constructing Roads In JG Kingdom★★☆☆☆
Stock Exchange ★★☆☆☆
2、最大M子段和
Max Sum ★☆☆☆☆
最長(zhǎng)公共子串 ★★☆☆☆
3、線性模型
Skiing ★☆☆☆☆
總結(jié)
弄懂動(dòng)態(tài)規(guī)劃問(wèn)題的基本原理和動(dòng)態(tài)規(guī)劃問(wèn)題的幾個(gè)常見(jiàn)的模型,對(duì)于解決大部分的問(wèn)題已經(jīng)足夠了。希望能對(duì)大家有所幫助,轉(zhuǎn)載請(qǐng)標(biāo)明出處https://blog.csdn.net/u013309870/article/details/75193592,創(chuàng)作實(shí)在不容易,這篇博客花了我將近一個(gè)星期的時(shí)間。
參考文獻(xiàn)
1.算法導(dǎo)論
總結(jié)
以上是生活随笔為你收集整理的算法-动态规划 Dynamic Programming--从菜鸟到老鸟的全部?jī)?nèi)容,希望文章能夠幫你解決所遇到的問(wèn)題。
- 上一篇: 【区块链 | Polygon】Polyg
- 下一篇: 英创力电子IPO被终止:年营收10亿 深