本文介紹一種算法技術--動態規劃。
一、什麼是動態規劃
動態規劃與分治方法相似,通過組合子問題的解來求解問題。分治法會在計算時做許多不必要的工作(記不記得之前的一篇博客分析過沒有優化過的斐波那契數列遞歸實現的時間複雜度高達指數級),然而動態規劃則會把求過的子問題記錄到一個容器中,從而避免了一些重複的工作。
我們常常使用動態規劃來求解最優化問題,步驟如下:
1、刻畫一個最優解的結構特徵;
2、遞歸定義最優解的值;
3、計算最優解的值,通常我們採用自底向上的方法;
4、利用已經構造出來的信息構造一個最優解。
動態規劃通常會比樸素的遞歸算法多一些空間開銷,但是對時間複雜度的減少作用是巨大的,可以把指數級的時間複雜度減少爲多項式級的時間複雜度。
動態規劃的關鍵在於狀態轉移方程的定義。本質思想是每個階段的最優狀態可以從之前某個階段的某個或某些狀態直接得到而不管之前這個狀態是如何得到的,相當於一種大事化小,小事化了的思想。
二、動態規劃的例子以及與其他算法的比較
1、鋼條切割
我們先來看一個簡單的例子:給定一個長度爲n的鋼條和一個價格表,嘗試對鋼條進行切割,也可能不切割直接達到最大利益,切割不消耗收益。
例子要求求解的最大利益,這是一個求解一個最優解的問題,對於這種問題,問題的最優解可以由相關子問題的最優解組合而成,對於子問題再獨立求解。
實際上上面這個題目我們可以使用下面這個狀態轉化公式將一個問題轉化爲一個子問題:
也就是對於一個長度爲n的鋼條的切割問題,我們把整根鋼條轉化切割成兩部分,一部分長爲i,一部分長爲n-i,我們對長爲i的鋼條部分不做切割,對n-i的長度進行切割,且對n-i的長度的切割時最優切割。這樣我們就把一個問題轉化成了一個子問題,對n的最優切割的利益就變成了求對n-i長度進行切割的利益加上i長度鋼條的利益再i屬於[1,n]範圍內的最大值。
基於這個想法我們使用下面幾種方法來用Java實現:
①、自頂向下遞歸實現(樸素算法)
public static int cutRodWithRecursion(int[] price, int n){
if(n<0||price.length==0)
throw new IllegalArgumentException("非法參數");
if(n == 0)
return 0;
int result = Integer.MIN_VALUE;
for(int i = 1; i <= n; i++){
if(i == price.length)
break;
int temp = cutRodWithRecursion(price, n-i) + price[i];
if(result < temp)
result = temp;
}
return result;
}
代碼不用做太多的解釋,完全就是基於我們分析思想的翻譯。我們需要分析的是這種方法的缺點,就像之前寫的斐波那契數列數列求解的複雜度分析(想要回顧請點擊此處)一樣,這裏的樸素遞歸存在大量的計算重複,它的計算的時間複雜度是指數級別的,非常糟糕。
②、動態規劃
我們可以把樸素遞歸轉化爲一個更高效的動態規劃算法。樸素遞歸算法之所以效率低,是因爲它反覆求解相同的子問題,因此動態規劃問題要求仔細安排求解順序,對每個子問題只求解一次。當然這種記錄會導致空間上會付出一些額外的東西,但是對於時間上的節省是巨大的。
動態規劃有兩種等價的實現方式:
a、帶備忘錄的自頂向下法:這個方法的核心思想在於在樸素遞歸的基礎上,增加了一個備忘機制(使用數組或者散列表之類的數據結構),需要一個子問題的解時,可以直接檢查備忘,判斷之前是否計算過這個子問題,如果是直接返回這個子問題的解,否則進行子問題的計算。我們來進行實現:
private static int cutRodMemo(int[] price, int length, int[] memo){
//已經存在備忘錄對應的元素
if(memo[length]>=0)
return memo[length];
int temp = 0;
//要切割的鋼條爲0的時候直接返回0
if(length != 0){
temp = Integer.MIN_VALUE;
for( int i = 1; i < price.length&&i <= length ; i++){
int result = 0;
result = price[i]+cutRodMemo(price, length-i, memo);
if(temp < result)
temp = result;
}
}
memo[length] = temp;
return temp;
}
public static int cutRodWithMemoUpToDown(int[] price, int length){
int[] memo = new int[length+1];
for(int i = 0; i <= length; i++)
memo[i] = Integer.MIN_VALUE;
return cutRodMemo(price, length, memo);
}
我們在cutRodMemo方法中封裝了對鋼條切割最優情況的求解,傳入了一個數組memo表示備忘機制。我們在cutRodMemoUpToDown方法中創建memo,對cutRodMemo再做一步封裝,對外只暴露出cutRodMemo方法。
b、自底向上法:自頂向下法是我們需要求解一個問題的時候,再嘗試去求解其子問題,然而自底向上法是我們先求解子問題,在子問題的基礎上去求解這些子問題組成的問題,當我們需要求解一個有子問題的問題時,發現其子問題已經被求解出來了。實現如下:
public static int cutRodDownToUp(int[] price, int length){
int[] memo = new int[length+1];
memo[0] = 0;
for(int j = 1; j <= length; j++){
int temp = Integer.MIN_VALUE;
for(int i = 1 ; i <= j && i < price.length ; i++)
if(temp < price[i] + memo[j - i])
temp = price[i] + memo[j - i];
memo[j] = temp;
}
return memo[length];
}
同樣我們創建一個memo數組來存儲子問題的解,每當我們需要求解一個問題而又需要其子問題的解時,我們就去memo數組查找存儲的子問題的解。本質就是先求解子問題,記錄,然後利用已有子問題的解獲取更大的問題的解。
這兩種問題的漸進運行時間相同,都是Θ(n^2),但是自底向上方法的係數更小。
2、卡特蘭數
卡特蘭數是一種經典的組合數,滿足以下性質:
h(0) = 1,h(1) = 1,catalan數滿足遞推式:h(n) = h(0)*h(n-1)+h(1)+h(n-2)+...+h(n-1)*h(0) (n>=2)。
當然上面這個遞推公式還可以再進一步化簡:
卡特蘭數有很多的應用:
a、出棧次序:對於一個棧進棧次序爲1、2、3...n。求不同的出棧次序的種類。
b、二叉樹構成問題:有n個結點,求總共能夠成多少種不同的二叉樹。
c、凸多邊形的三角形劃分:一個凸的n邊形,用直線連接兩個定點使之分成多個三角形,直線不相交。
d、其他
當然我們可以直接通過遞推公式化簡之後的公式直接計算得出結果,函數如下:
private static int getCatalanByCalDirectlyMemo(int n, ArrayList<Integer> factorial){
return combination(n, n<<1, factorial)/(n+1);
}
public static int getCatalanByCalDirectly(int n){
ArrayList<Integer> factorial = new ArrayList<Integer>();
return getCatalanByCalDirectlyMemo(n, factorial);
}
//計算階乘 存儲在備忘機制中
private static void calFactorial(ArrayList<Integer> factorial, int n){
if( factorial.size() == 0)
factorial.add(1);
for(int i = factorial.size(); i <= n; i++){
factorial.add(factorial.get(i-1)*i);
}
}
//計算組合
private static int combination(int superScript, int subScript, ArrayList<Integer> factorial){
return arrange(superScript, subScript, factorial)/factorial.get(superScript);
}
//計算排列
private static int arrange(int superScript, int subScript, ArrayList<Integer> factorial){
//開闢一個備忘錄存儲前n個元素的階乘
calFactorial(factorial, subScript);
return factorial.get(subScript)/factorial.get(subScript-superScript);
}
但是我們這裏假裝不知道這個遞推公式的化簡公式,我們使用動態規劃利用遞推式來求解這個問題:
a、帶備忘自頂向下遞歸
public static int getCatalanUpToDownWithMemo(int n){
int[] catalan = new int[n+1];
catalan[0] = 1;
catalan[1] = 1;
return catalanMemo(n, catalan);
}
private static int catalanMemo(int n , int[] catalan){
if(catalan[n]>0)
return catalan[n];
int result = 0,i = 0;
for(i = 0; i < n ; i++){
result += catalanMemo(i,catalan)*catalanMemo(n-i-1,catalan);
}
catalan[i] = result;
return result;
}
b、自底向上
public static int getCatalanBottomToUp(int n){
if(n==1||n==0)
return 1;
int catalan[] = new int[n+1];
catalan[0] = 1;
catalan[1] = 1;
for(int i = 2; i <= n; i++){
catalan[i] = 0;
for(int j = 0; j < i; j++)
catalan[i] += catalan[j]*catalan[i-1-j];
}
return catalan[n];
}
這些東西和第一個例子基本一致,不做過多分析了。
3、爬樓梯情況
假設對於一座n層高的樓梯,我們從下往上走,每次只能上1級或者2級臺階,求解到底有多少種走法。
如果使用排列組合的思想使用多層嵌套循環遍歷複雜度實在是太高,高達了指數級,顯然不是我們想要的,我們也就不實現了。
我們以動態規劃思路爲核心來思考這個問題。比如現在我們想爬到第十層,實際上對於第十層我們只能由兩個狀態得到,就是從第九層跨一步到第十層以及從第八層跨兩步到第十層,也就是對於到第十層的可能等於到第八層的肯可能到可能到第八層的可能。我們把這個推論擴大,使用F(h)表示到第n層的可能,F(n) = F(n-1)+F(n-2)在n>=2的時候成立的,我們再來看看F(1)和F(2),明顯F(1) = 1,F(2) = 2。這樣我們就獲得了狀態轉移方程和邊界值,求解到第n層的可能也就演變爲求到第n-1層的可能和第n-2層的可能,到第n-1層的可能就等於第n-2層的可能加上第n-2層的可能...直到達到邊界值F(1)和F(2)。我們來進行實現:
a、帶備忘的自頂向下
public static int getResultUpToDownWithMemo(int n){
int[] memo = new int[n+1];
memo[0] = Integer.MIN_VALUE;
memo[1] = 1;
memo[2] = 2;
return getResultUpToDownMemo(memo, n);
}
private static int getResultUpToDownMemo(int[] memo, int n){
if(memo[n]>0)
return memo[n];
memo[n] = getResultUpToDownMemo(memo, n-1)+getResultUpToDownMemo(memo, n-2);
return memo[n];
}
代碼很簡單,不做過多說明,時間複雜度和空間複雜度都是多項式級別的,對於這個空間複雜度我們可以再做一步優化,我們可以改爲自底向上,只保存前兩項,使得空間複雜度變爲常數級別。
b、自底向上法
public static int getResultDownToUp(int n){
int resultBeforeTwo = 1;
int resultBeforeOne = 2;
int result = 0;
for(int i = 3; i <= n; i++){
result = resultBeforeOne + resultBeforeTwo;
resultBeforeTwo = resultBeforeOne;
resultBeforeOne = result;
}
return result;
}
代碼也很簡單,但是空間複雜度被優化爲了常數級別。
這些只是動態規劃的基礎,我們就不在這裏對動態規劃做深入學習了,以後遇到深入一些的題目再分享給大家。