Dynamic programming is a method for solving a complex problem by breaking it down into a collection of simpler subproblems,solving each of those subproblems just once,and storing their solutions.java
由一個衆所周知的例子入手,在學習C語言的時候,講到遞歸的時候的經典例子,Fibonacci數列的求解。題目以下:算法
Fibonacci(n) = 1; n = 0 Fibonacci(n) = 1; n = 1 Fibonacci(n) = Fibonacci(n - 1) + Fibonacci(n - 2)
下面就是當時簡單粗暴的解法,利用遞歸的方式。數組
int fib(int n) { if(n <= 0) return 0; if(n == 1) return 1; return fib(n - 1) + fib(n - 2); }
下面看看算法的執行流程,假如輸入6,那麼執行的遞歸樹以下所示。post
上面的每一個節點都會被執行一次,致使一樣的節點被重複的執行,好比fib(2)被執行了5次。這樣致使時間上的浪費,若是遞歸調用也會致使空間的浪費,致使棧溢出的問題。學習
下面說一個比較無聊的問題,什麼是動態規劃?動態規劃和分治法看起來是很是像的思想,可是二者的區別也是很是明顯的。分治法是將問題劃分爲互不相交的子問題,遞歸的求解子問題,再將它們的解組合起來,求出原問題的解。而動態規劃是應用於子問題重疊的狀況,即不一樣的子問題具備公共的子子問題。也就是上面Fibonacci的例子,上面的遞歸的求解方式就是分治算法,因爲它的子問題是相互相關的,此時利用分治法就作了不少重複的工做,它會反覆求解那些公共子子問題。而動態規劃算法對每個子子問題只求解一次,將其保存在一個表格中,從而避免重複計算。spa
下面咱們介紹兩種方法來講明動態規劃的算法:自頂向下的備忘錄法和自底向上的方法。.net
一、自頂而下的備忘錄法code
int Fibonacci(int n) { if(n <= 0) return 0; int meno[n + 1]; for(int i = 0; i <= n; i++) meno[i] = -1; return fib(n, meno); } int fib(int n, int *meno) { if(meno[n] != -1) return meno[n]; if(n <= 2) meno[n] = 1; else meno[n] = fib(n - 1, meno) + fib(n - 2, meno); return meno[n]; }
上述算法中,利用meno數組來存放斐波拉契數列中的每個值,因爲是自頂往下遞歸,它仍是會最早遞歸到meno[3],今後刻開始在往上計算,而後依次保存計算結果在meno數組中,避免了重複運算。blog
下面是枯燥的概念,能夠直接跳過。在動態規劃當中包含三個重要的概念:最優子結構、邊界、狀態轉移公式。對於上面這個算法來講,meno[10]的最優子結構就是fib(9,meno)和fib(8,meno)了;邊界就是meno[2]與meno[1]了;狀態轉移方程就是meno[n] = fib(n - 1, meno) + fib(n - 2, meno)。注意最優子結構和狀態轉移方程的區別,我的理解是最優子結構是針對具體某個值來講的,而狀態方程就是它的那個總體的推算方程。遞歸
二、自底而上的方法
自頂而下的方式來計算最終的結果,仍是有一個遞歸的過程。既然最終是從fib(1)開始算,那麼直接從fib(1)計算不就得了,先算子問題,再算父問題。
int fib(int n) { if(n <= 0) return n; int meno[n + 1]; meno[0] = 0 meno[1] = 1; for(int i = 2; i <= n; i++) meno[i] = meno[i - 1] + meno[i - 2]; return meno[n]; }
咱們從接觸斐波拉契數列開始,就是遞歸的方式,弄到最後,發現仍是直接來方便不少啊。可是該方法對於空間仍是有必定的浪費,下面,咱們對其空間再壓縮一點。
int fib(int n) { if(n <= 1) return n; int meno_i_1 = 0; int meno_i_2 = 1; int meno_i = 1; for(int i = 2; i <= n; i++) { meno_i = meno_i_1 + meno_i_2; meno_i_2 = meno_i_1; meno_i_1 = meno_i; } return meno_i; }
從上面的例子能夠看到自頂向下的方式的動態規劃其實包含了遞歸,而遞歸就會有額外的開銷的;而使用自底向上的方式能夠避免。看來斐波拉契真是個好東西,遞歸的時候用它來入門,如今動態規劃也是用它來入門。
拓展例題:有一座n級臺階的樓梯,從下往上走,每跨一步只能向上1級或者2級臺階。要求用程序來求有多少種走法。(分析一下,其實就是斐波拉契數列!)
下面繼續探討動態規劃的問題,繼續一個栗子:求一個數列中最長上升子序列的長度(LIS,Longest Increasing Subsequence)的問題。
例如以下的一個數列:
它的最長上升子數列就是這樣的了:
[1,2,3,4],長度爲4,因此這個數列的最長上升子數列長度就是4。
對於這個問題,最簡單的求解方式就是暴力求解了,直接窮舉。
直接這樣找出全部的上升子序列,而後用肉眼觀察哪一個是最長的。顯然,1,2,3,4是最長的,因此最長上升子序列的長度是4。
咱們來看看這個方法的時間複雜度:
這就太消耗時間了。咱們如今用動態規劃試一下,看看有什麼驚喜。
根據動態規劃的定義,首先咱們須要把原來的問題分解成了幾個類似的子問題。可是,不一樣於斐波拉契數列的例子,這個如何分解原問題並非那麼一目瞭然。
原來的問題是求LIS(n),如今咱們須要找的就是LIS(n)和LIS(k)之間的關係1<=k<=n。以下所示:
這裏咱們能夠看到,LIS(K+1)要麼等於LIS(K),要麼加了一。其實也很好理解,基本上就是,在前面全部的LIS種找到一個最長的LIS(i),若是A(K)比這個找到LIS(i)的尾項A(i)要大,則LIS(K)=LIS(i)+1,不然LIS(K)=LIS(i)。
這樣的話,咱們就分解了原問題,而且找到了原問題和子問題之間的關係:
i是對應的最大LIS(i)。也就是說,計算LIS(n)須要計算以前全部的LIS(K):
同理,咱們能夠儲存子問題的結果,讓每一個子問題只被計算一次。須要計算的子問題就只剩下藍色標出的部分了:
也就是(紅色箭頭表示調用了儲存的數據,並未進行計算):
咱們能夠看到,採用了動態規劃以後,時間複雜度大大下降了:縱軸方向的遞歸計算返回時間複雜度是O(n),橫軸方向每行求Max的時間複雜度是O(logn),因此總共的時間複雜度就是O(nlogn),遠遠小於暴力窮舉法的O(n!)。
下面是動態規劃的代碼:
int lis(int *arr, int n) { int dp[n]; memset(dp, 0, sizeof(dp)); int maxLen = 1; for (int i = 0; i < n; ++i) { dp[i] = 1; for (int j = 0; j < i; ++j) { if (a[i] > a[j] && dp[i] < (dp[j] + 1)) { dp[i] = dp[j] + 1; maxLen = ((maxLen > dp[i]) ? maxLen : dp[i]); } } } return maxLen; }
可是上面的這個方法的時間複雜度是O(n^2),並無達到O(nlogn)。其中的dp[j](0 <= j <= i)來表示在i以前的LIS的長度,而dp[i]表示以i結尾的子序列中LIS的長度。在判斷中加入 dp[i] < (dp[j] + 1)這個判斷,來減小重複計算。
可是上面的講解不是說有時間複雜度爲O(nlogn)的算法的嗎?有!利用二分法+動態規劃就成了。代碼以下:
int binarySearch(int *ans, int n,int left, int right) { while(left<right){ int mid = left+(right-left)/2; if(ans[mid]>=arr[i]) right=mid; else left=mid+1; } return left; } int findLongest(int *arr, int n) { int ans[n]; ans[0] = arr[0]; int len=0; for(i = 1; i < n; ++i){ if(arr[i]>ans[len]) ans[++len]=arr[i]; else{ int pos=binarySearch(i); ans[pos] = arr[i]; } } return len + 1; }
下面以一個數組舉例來講明這種算法是怎麼實現的?對於arr[9]={2,1,5,3,6,4,8,9,7}數組,咱們來一步一步推理。
第一步:把arr[0]=2放入ans數組中,注意,這個ans數組用於存放最大上升子序列的元素,令ans[0]=2,此時len=1;
第二步:把arr[1]=1放入ans數組中,令ans[0]=1,也就是說長度爲1的LIS的最小末尾是1,而ans[0]=2沒有做用了,此時len=1;
第三步:arr[2]=5,arr[2]>ans[0],因此令ans[1]=arr[2]=5,此時len=2,也就是ans[2]={1,5},len=2;
第四步,arr[3]=3,它正好在1,5之間,放在1處確定是不行的,由於1<3,長度爲1的LIS最小末尾應該是1,長度爲2的LIS最小末尾是3,因而能夠把5淘汰掉,此時ans[2]={1,3},len=2;
第五步:arr[4]=6,它在3的後面,則能夠將6放在3後面,此時ans[3]={1,3,6},len=3;
第六步:arr[5]=4,它在3與6,因而將6去掉,此時ans[3]={1,3,4},len=3;
第七步:arr[6]=8,它比4大,直接放在ans數組末尾,此時ans[4]={1,3,4,8},len=4;
第八步:arr[7]=9,它比8大,直接放在ans數組末尾,此時ans[4]={1,3,4,8,9},len=5;
第九步:arr[8]=7,它四、8之間,此時ans[4]={1,3,4,7,9},可是len不會更新,仍然是5。
特別提醒:這個1,3,4,7,9不是LIS字符串,本題中的LIS字符串應該是1,3,4,8,9。7表明的意思是存儲5位長度LIS的最小末尾是7,因此在咱們的ans數組,是儲存對應長度LIS的最小末尾。有了這個末尾,咱們就能夠一個一個插入數據。例如這道題,若是這個arr數組7後面還有八、9,那麼就能夠繼續的更新數據了,獲得LIS的長度爲6。
在插入數據的過程當中,咱們是替換而沒有挪動數據,那麼插入算法的話,就是二分查找來插入了,時間複雜度爲O(nlogn)。
這是《算法導論》上面動態規劃章節的例題,上面已經描述得很是清楚了,下面咱們也是用三種方法來解這個問題。
遞歸版本
int cut(int *p,int n) { if(n==0) return 0; int q=0; for(int i=1;i<=n;i++) { q = (q > (p[i-1]+cut(p, n-i)) ? q : (p[i-1]+cut(p, n-i))); } return q; }
這種自頂向下遞歸實現的效率會很是低,由於它會對相同的參數值進行遞歸調用,反覆求解子問題。時間複雜度爲O(2^n)。
備忘錄版本
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=(q > cut(p, n-i,r)+p[i-1]) ? q : cut(p, n-i,r)+p[i-1] ; } r[n]=q; return q; } int cutMeno(int *p, int n) { int r[n + 1]; for(int i = 0;i <= n;i++) r[i] = -1; return cut(p, n, r); }
自底向上的動態規劃
int buttom_up_cut(int *p,int n) { int r[n + 1]; for(int i = 1;i <= n; i++) { int q=-1; for(int j = 1;j <= i; j++) q = (q> p[j-1]+r[i-j] ? q : p[j-1]+r[i-j]); r[i] = q; } return r[n]; }
自底向上的動態規劃問題中最重要的是理解第二個for循環,這裏外面的循環是求r[1],r[2]……,裏面的循環是求出r[1],r[2]……的最優解,也就是說r[i]中保存的是鋼條長度爲i時劃分的最優解,這裏面涉及到了最優子結構問題,也就是一個問題取最優解的時候,它的子問題也必定要取得最優解。下面是長度爲4的鋼條劃分的結構圖。
有一個國家發現了金礦,每座金礦的黃金儲量不一樣,須要參與的人不一樣。參與挖礦的人爲10人,每座金礦要麼挖,要麼不挖。每座金礦的黃金數與須要的人以下圖所示,怎麼樣分配才能挖到最多的黃金呢?
對於每一個金礦都有挖和不挖兩種選擇,因此問題的最優子結構有兩個,例如如今有4個金礦挖,則剩餘的人要麼是10個,要麼就是10我的減第5個須要的人。
咱們令金礦數爲n,工人數爲w,金礦的黃金量爲g[],金礦的用工量爲p[]。有以下關係式。
int getMostGold(int n, int w, int* g, int* p) { if (n > g.length) printf("輸入的n值大於給定的金礦數\n"); if (w < 0) printf("輸入的工人數w不能爲負數\n"); if (n < 1 || w == 0) return 0; int col = w+1; ////由於F(x,0)也要用到,因此表格應該有w+1列 int preResult[col]; int result[col]; //初始化第一行(邊界) for (int i = 0; i < col; i++) { if (i < p[0]) preResult[i] = 0; else preResult[i] = g[0]; } if (n == 1) return preResult[w]; //用上一行推出下一行,外循環控制遞推的輪數,內循環進行遞推 for (int i = 1; i < n; i++) { for (int j = 0; j < col; j++) { if (j < p[i]) result[j] = preResult[j]; else result[j] =(preResult[j]> preResult[j-p[i]] + g[i] ? preResult[j] : preResult[j-p[i]] + g[i]); } for (int j = 0; j < col; j++) //更新上一行的值,爲下一輪遞推作準備 preResult[j] = result[j]; } return result[w]; }
該方法的時間複雜度爲O(n*w),空間複雜度爲O(w)。對於動態規劃方法解法來講,當輸入的礦山數多的時候,它的效率會很是高,可是當工人數多的時候,它的效率會低,並且低於簡單的遞歸。
最後結尾,補充知乎關於動態規劃問題的一個問答總結!
一個問題是該用遞推、貪心、搜索仍是動態規劃,徹底是由這個問題自己階段間狀態的轉移方式決定的! 每一個階段只有一個狀態->遞推; 每一個階段的最優狀態都是由上一個階段的最優狀態獲得的->貪心; 每一個階段的最優狀態是由以前全部階段的狀態的組合獲得的->搜索; 每一個階段的最優狀態能夠從以前某個階段的某個或某些狀態直接獲得而無論以前這個狀態是如何獲得的->動態規劃。
http://www.javashuo.com/article/p-mjgpkedi-hq.html
https://www.zhihu.com/question/23995189
http://www.javashuo.com/article/p-qkmdpfdg-ep.html
《算法導論》