算法導論筆記動態規劃DP詳解-鋼條切割的分析與實現

DP和分治的類似

  • 都是經過組合子問題的解來求解原問題。算法

DP中的「programming」指的是一種表格法,而非coding。數組


DP和分治的不一樣

  • 分治步驟:(例如歸併排序)函數

    • 將問題劃分爲互不相交的子問題優化

    • 遞歸地求解子問題spa

    • 組合子問題的解,求出原問題的解code

  • 對於DP:排序

    • 應用於子問題重疊的狀況,即不一樣的子問題具備公共的子子問題(子問題的求解是遞歸進行的,將其劃分爲更小的子子問題)遞歸

      • 這種狀況下分治會作不少沒必要要的工做,會反覆求解哪些公共子問題。rem

      • 而DP對每一個子子問題只求解一次,將其解保存在一個表格中,無需每次都從新計算,避免重複工做。get


DP一般用來求解最優化問題(optimization problem)

  • 這種問題能夠有不少可行的解,每一個解都有一個值,但願找到最優值(最大或最小)的解。稱這樣的解爲問題的一個最優解(an optimal solution),而不是最優解(the optimal solution),由於可能有多個解都達到最優。

DP的四個步驟

  1. 刻畫一個最優解的結構特徵。

  2. 遞歸地定義最優解的值。

  3. 計算最優解的值,一般採用自底向上法。

  4. 利用計算出的信息構造一個最優解。

前三步是DP求解的基礎。若僅須要一個最優解的值,而非解自己,可忽略第四步。若需第四步,有時需在執行第3步的過程當中維護一些額外的信息,以便構造一個最優解。


鋼條切割例子

場景:把長鋼條切割爲短鋼條出售。切割工序自己無成本。求最佳切割方案。

假定:出售一段長度爲 i 英寸的鋼條的價格爲Pi(i = 1, 2, …, )單位:$,鋼條長度均爲整英寸。下圖爲價格表。

問題描述:給定一段長度爲n英寸的鋼條和一個價格表,求切割方案,使銷售收益Rn最大。注:若長度爲n英寸的鋼條的價格Pn足夠大,最優解可能就是徹底不須要切割。

考慮長度爲4的狀況,下圖給出了4英寸鋼條的全部切割方案。

切成兩段各長2英寸的鋼條,將產生P2 + P2 = 5 + 5 = 10 的收益,爲最優解。

長度爲n英寸的鋼條共有2^(n-1)種不一樣切割方案,由於在距離鋼條左端 i (i=1, 2, … , n-1)英寸處,老是能夠選擇切割或者不切割。用普通的加法符號表示切割方案,所以7=2+2+3表示將長度爲7的鋼條切割爲3段:2英寸,2英寸,3英寸。

若一個最優解將鋼條切割爲k段(1≤k≤n),那麼最優切割方案 n = i1 + i2 + … + ik.

將鋼條切割爲長度分別爲i1, i2, … , ik的小段,獲得的最大收益爲 Rn = Pi1 + Pi2+…+Pik

對於上面表格的價格樣例,能夠觀察全部最優收益值Ri (i: 1~10)以及最優方案:

長度爲1:切割方案1=1(無切割)。最大收益R1 = 1

長度爲2:切割方案2=2(收益5),1+1=2(收益2)。最大收益R2 = 5

長度爲3:切割方案3=3(收益8),1+2=3(收益6),2+1=3(收益6)。最大收益8

長度爲4:切割方案4=4(收益9),1+3=4(收益9),2+2=4(收益10),3+1=4(收益9),1+1+2=4(收益7),1+2+1=4(收益7),2+1+1=4(收益7),1+1+1+1=4(收益4)。最大收益10

長度爲5:切割方案5=5(10),1+4=5(10),2+3=5(13),1+1+3=5(10),2+2+1=5(11),1+1+1+1+1=5(5),其餘是前面的排列。最大收益13

依次求出。。。

更通常的,對於Rn(n≥1),能夠用更短的鋼條的最優切割收益來描述它:

Rn = max(Pn, R1+Rn-1, R2 + Rn-2, … , Rn-1 + R1)

  • 第一個參數Pn對應不切割,直接出售長度爲n的方案。

  • 其餘n-1個參數對應n-1種方案。對每一個i=1,2,….,n-1,將鋼條切割爲長度爲i和n-i的兩段,接着求解這兩段的最優切割收益Ri和Rn-i;(每種方案的最優收益爲兩段的最優收益之和)。

  • 因爲沒法預知哪一種方案會得到最優收益,必須考察全部可能的 i ,選取其中收益最大者。若不切割時收益最大,固然選擇不切割。

注意到:

  • 爲了求解規模爲n的原問題,先求解子問題(子問題形式徹底同樣,但規模更小)。

  • 即首次完成切割後,將兩段鋼條當作兩個獨立的鋼條切割問題實例。

  • 經過組合兩個相關子問題的最優解,並在全部可能的兩段切割方案中獲取收益最大者,構成原問題的最優解。

稱鋼條切割問題知足最優子結構性質:

問題的最優解由相關子問題的最優解組合而成,而這些子問題能夠獨立求解。

除上述解法,問題可化簡爲一種類似的遞歸:從左邊切割下長度爲 i 的一段,只對右邊剩下的長度爲 n-i 的一段進行繼續切割(遞歸求解),對左邊一段則再也不進行切割。

即問題分解的方式爲:將長度爲n的鋼條分解爲左邊開始一段,以及剩餘部分繼續分解的結果。(這樣,不作任何切割的方案能夠描述爲:第一段長度爲n,收益爲Pn,剩餘部分長度爲0,對應收益爲R0 = 0)。因而獲得上面公式的簡化版本:

在此公式中,原問題的最優解只包含一個相關子問題(右端剩餘部分的解),而不是兩個。

自頂向下遞歸實現的僞代碼:Cut-Rod(p, n)

Cut-Rod(p, n)
1    if n==0
2        return 0
3    q = -∞
4    for i = 1 to n
5        q = max(q, p[i] + Cut-Rod(p, n-i))
6    return q

該過程以價格數組p[1...n]和整數n爲輸入,返回長度爲n的鋼條的最大收益。

若n=0,不可能有任何收益,因此第二行返回0.

第3行將最大收益初始化爲負無窮,以便第4第5行的for循環能正確計算。

第6行返回結果。Java實現以下:

/**
 * 鋼條切割
 */
public class CutRob {
    public static int[] prices = {1,5,8,9,10,17,17,20,24,30};
    public static int solution(int length){
        if(length == 0)    return 0;
        int result = Integer.MIN_VALUE;
        for(int i = 1; i <= length; i++){
            result = Math.max(result, prices[i-1] + solution(length-i));
        }
        return result;
    }
    public static void main(String[] args) {
        for(int i=1; i<= prices.length; i++)
            System.out.println("長度爲"+i+"的最大收益爲:"+solution(i));
    }
}

結果:

長度爲1的最大收益爲:1
長度爲2的最大收益爲:5
長度爲3的最大收益爲:8
長度爲4的最大收益爲:10
長度爲5的最大收益爲:13
長度爲6的最大收益爲:17
長度爲7的最大收益爲:18
長度爲8的最大收益爲:22
長度爲9的最大收益爲:25
長度爲10的最大收益爲:30

該遞歸很好理解,可是一旦規模較大,程序運行時間會暴漲,課本上說對n=40要好幾分鐘,極可能超過1小時,本次實驗一下n=33. (假設從鋼條長度超過10開始價格就一直保持在30美圓)

public class CutRob {
    public static int[] prices = 
            {1,5,8,9,10,17,17,20,24,30,
            30,30,30,30,30,30,30,30,30,30,
            30,30,30,30,30,30,30,30,30,30,
            30,30,30};
//    public static int[] prices = {1,5,8,9,10,17,17,20,24,30};
    public static int solution(int length){
        if(length == 0)    return 0;
        int result = Integer.MIN_VALUE;
        for(int i = 1; i <= length; i++){
            result = Math.max(result, prices[i-1] + solution(length-i));
        }
        return result;
    }
    public static void main(String[] args) {
        long curr = System.currentTimeMillis();
        System.out.println("長度爲33的最大收益爲:"+solution(33));
        System.out.println(System.currentTimeMillis() - curr);
    }
}

長度爲33的最大收益爲:98
25507

該遞歸計算結果用了幾乎26秒。當輸入長度繼續增大,會消耗更長的時間。

爲何效率這麼差?緣由在於,CutRob反覆的用相同的參數值對自身進行遞歸調用,即反覆的求解子問題。

下圖顯示了n=4時的調用過程CutRob(p, n)對i=1,2,…,n調用CutRob( p,n-i ),等價於對j=0,1,…,n-1調用CutRob( p, j ),該遞歸展開時,所作的工做量會爆炸性增加。

爲了分析該算法運行時間,令T(n)表示第二個參數值爲n時函數的調用次數。此值等於遞歸調用樹中 根爲n的子樹中的節點總數,注意,此值包含了根節點對應的最初的一次調用。所以T(0)=1,且

第一項 ’1’ 表示函數的第一次調用(遞歸調用樹的根節點),T( j )爲調用cutrob(p, n-i)所產生的全部調用次數。T(n) = 2^n。即該算法的運行時間爲n的指數函數。

回頭看下,該運行時間並不使人驚訝。對於長度爲n的鋼條,該算法顯然考察了全部2^(n-1)種可能的切割方案。遞歸調用樹中共有2^(n-1)個葉子節點,每一個葉子節點對應一種可能的切割方案。對每條從根到葉子的路徑,路徑上的標號給出了每次切割前右邊剩餘部分的長度(子問題規模)。也就是說,標號給出了對應的切割點(從鋼條右端測量)。


看完了遞歸解法以及其暴漲的複雜度。下面來看下用DP怎麼來解決鋼條切個問題。思想以下:

  • 已經看到,樸素遞歸算法之因此效率低,是由於反覆求解相同的子問題。

  • DP會仔細安排求解順序,對每一個子問題只求解一次,並將結果保存下來。若是隨後再次須要次子問題的解,只需查找保存的結果,而沒必要從新計算。

  • 所以DP用空間來節省時間。是典型的時空權衡例子(time-memory trade-off)。

  • 若是子問題數量是輸入規模的多項式函數,則能夠在多項式時間內求解出每一個子問題。其總時間複雜度就是多項式階的。

DP有兩種等價的實現方法:帶備忘的自頂向下法(top-down with memoization)& 自底向上法(bottom-up method)。

帶備忘的自頂向下法(top-down with memoization)

  • 此方法仍然按照天然的遞歸形式編寫過程,可是過程會保存每一個子問題的解(一般保存在一個數組或散列表中)。

  • 當須要一個子問題的解時,過程首先檢查是否已經保存過此解,

    • 若是是,則直接返回保存的值,從而節省了計算時間;

    • 不然,按照一般方式計算這個子問題。

自底向上法(bottom-up method)

  • 該方法通常須要恰當定義子問題「規模」的概念,使得任何子問題的求解都只依賴於「更小的」子問題的求解。

  • 於是能夠將子問題按規模排序,按由小到大的順序進行求解。

  • 當求解某個子問題時,所依賴的那些更小的子問題都已經求解完畢,結果已經保存。

  • 每一個子問題只求解一次,當求解它時(也是第一次遇到它),全部前提子問題都已經求解完成。

兩種方法獲得的算法具備相同的漸進運行時間,

  • 僅有的差別是在某些特殊狀況下,自頂向下方法並未真正遞歸地考察全部可能的子問題。

  • 因爲沒有頻繁的遞歸調用開銷,自底向上的複雜度函數一般具備更小的係數。

算法僞代碼-帶備忘的自頂向下法(top-down with memoization)

mem-cut-rod(p, n)
1    let r[0…n] be a new array
2    for i=0 to n
3        r[i] = -∞
4    return mem-cut-rod-aux(p, n, r)

mem-cut-rod-aux(p, n, r)
1    if r[n] >= 0
2        return r[n]
3    if n == 0
4        q = 0
5    else 
6        q = -∞
7        for i=1 to n
8            q = max(q, p[i] + mem-cut-rod-aux(p, n-i, r))
9    r[n] = q
10   return q

主過程 mem-cut-rod(p, n)將輔助數組r[0...n]初始化爲負無窮,而後調用輔助過程mem-cut-rod-aux(最初cut-rob引入備忘機制的版本)。僞代碼解讀:

首先檢查所需值是否已知(第1行);
若是是,則第2行直接返回保存的值;
不然第3~8行用一般方法計算所需值q;
第9行將q存入r[n]中;
第10行返回;

自底向上版本更簡單:

bottom-up-cut-rod(p, n)
1    let r[0…n] be a new array
2    r[0] = 0
3    for j=1 to n
4        q = -∞
5        for i=1 to j
6            q = max(q, p[i] + r[j-i])
7        r[j] = q
8    return r[n]

自底向上版本採用子問題的天然順序:若i<j,則規模爲i的子問題比規模爲j的子問題「更小」。所以,過程依次求解規模爲j=0,1,…,n的子問題。僞代碼詳解:

第1行建立一個新數組r[0...n]來保存子問題的解;
第2行將r[0]初始化爲0,由於長度爲0的鋼條沒有收益;
第3~6行對j=1...n按升序求解每一個規模爲j的子問題。求解方法與cut-rod採用的方法相同,只是如今直接訪問數組元素r[j-i]來獲取規模爲j-i的子問題的解,而沒必要進行遞歸調用;
第7行將規模爲 j 的子問題的解存入r[j];
第8行返回r[n],即最優解

兩種方法具備相同的漸進運行時間。

bottom-up-cut-rod 主體是嵌套的雙層循環,內層循環(5~6行)的迭代次數構成一個等差數列和,不難分析時間爲n^2.

mem-cut-rod 運行時間也是n^2,其分析略難:當求解一個以前已經計算出結果的子問題時,遞歸調用會當即返回,即每一個子問題只求解一次,而它求解了規模爲0,1,。。。,n的子問題;爲求解規模爲n的子問題,第6~7行的循環會迭代n次;所以進行的全部遞歸調用執行此for循環的迭代次數也是一個等差數列,其和也是n^2。


Java實現:

public class CutRob {
    public static int[] prices = {1,5,8,9,10,17,17,20,24,30};    
    /** 自頂向下*/
    public static int mem_cut_rod(int n){
        int[] dp = new int[n+1];            // 輔助數組dp
        Arrays.fill(dp, Integer.MIN_VALUE);  // 初始化爲負無窮
        return mem_cut_rod_aux(n, dp);
    }
    /** 自頂向下法的輔助函數*/
    private static int mem_cut_rod_aux(int n, int[] dp) {
        if(dp[n] >= 0)    return dp[n]; // 若是子問題已經解過,直接返回
        int max = Integer.MIN_VALUE;
        if(n==0)    max = 0;          // 若是長度爲0,則最大收益爲0
        else{                        // 長度若不爲0
            for(int i = 1; i<=n; i++)    // 找到最大收益
                max = Math.max(max, prices[i-1] + mem_cut_rod_aux(n-i, dp));
        }
        dp[n] = max;            // 把計算獲得的最大收益存入結果
        return max;                // 返回結果
    }

    public static void main(String[] args) {
        for(int i=1; i<=prices.length; i++)
            System.out.println("長度爲"+i+"的最大收益爲:"+mem_cut_rod(i));
    }
}


長度爲1的最大收益爲:1
長度爲2的最大收益爲:5
長度爲3的最大收益爲:8
長度爲4的最大收益爲:10
長度爲5的最大收益爲:13
長度爲6的最大收益爲:17
長度爲7的最大收益爲:18
長度爲8的最大收益爲:22
長度爲9的最大收益爲:25
長度爲10的最大收益爲:30

自底向上:

public class CutRob {
    public static int[] prices = {1,5,8,9,10,17,17,20,24,30};
        /** 自底向上法*/
    private static int bottom_up_cut_rod(int n){
        int[] dp = new int[n+1];
        dp[0] = 0;
        for(int j=1; j<=n; j++){
        int max = Integer.MIN_VALUE;
        for(int i=1; i<=j; i++){
            max = Math.max(max, prices[i-1] + dp[j-i]);
        }
        dp[j] = max;
        }
        return dp[n];
    }
    public static void main(String[] args) {
        for(int i=1; i<=prices.length; i++)
            System.out.println("長度爲"+i+"的最大收益爲:"+bottom_up_cut_rod(i));
    }
}

下面來從運行結果的時間上作一個對比,這裏拿自底向上法來和前面的遞歸作對比。

上面的樸素遞歸只把輸入的n增長到33,就運行了25507毫秒。下面來看下自底向上。

public class CutRob {
    public static int[] prices = 
            {1,5,8,9,10,17,17,20,24,30,
            30,30,30,30,30,30,30,30,30,30,
            30,30,30,30,30,30,30,30,30,30,
            30,30,30,30,30,30,30,30,30,30,
            30,30,30,30,30,30,30,30,30,30,
            30,30,30};
//    public static int[] prices = {1,5,8,9,10,17,17,20,24,30};
    /** 自底向上法*/
    private static int bottom_up_cut_rod(int n){
        int[] dp = new int[n+1];
        dp[0] = 0;
        for(int j=1; j<=n; j++){
            int max = Integer.MIN_VALUE;
            for(int i=1; i<=j; i++){
                max = Math.max(max, prices[i-1] + dp[j-i]);
            }
            dp[j] = max;
        }
        return dp[n];
    }
    public static void main(String[] args) {
        long curr = System.currentTimeMillis();
        System.out.println(「長度爲53的最大收益爲:"+bottom_up_cut_rod(53));
        System.out.println(System.currentTimeMillis() - curr);
    }
}

用自底向上把輸入增長到53,整個過程也就運行了1毫秒。

輸出:
長度爲53的最大收益爲:158
1

子問題圖:

當思考一個DP問題時,應弄清楚所涉及的子問題以及子問題之間的依賴關係。

問題的子問題圖準確的表達了這些信息。下圖展現了n=4時鋼條切割問題的子問題圖。

它是一個有向圖,每一個頂點惟一對應一個子問題。

若求子問題x的最優解時須要直接用到子問題y的最優解,那麼在子問題圖中就會有一條從子問題x的頂點到子問題y的頂點的有向邊。

  • 例如,若自頂向下過程在求解x時須要直接遞歸調用自身來求解y,那麼子問題圖就包含從x到y的一條有向邊。

  • 能夠將子問題圖看作自頂向下遞歸調用樹的「簡化版」或「收縮版」,由於樹中全部對應相同子問題的節點合併爲圖中的單一頂點,相關的全部邊都從父節點指向子節點。

自底向上的DP方法處理子問題圖中頂點的順序爲:對於一個給定的子問題x,在求解它以前求解鄰接至它的子問題y。

  • 用22章的術語說,自底向上動態規劃算法是按「逆拓撲排序」或者「反序的拓撲排序」來處理子問題圖中的頂點。

  • 換句話說,對於任何子問題,直至它所依賴的全部子問題均已求解完成,纔會求解它。

  • 相似的,能夠用22章中的術語「深搜」來描述(帶備忘機制的)自頂向下動態規劃算法處理子問題圖的順序。(22.3節)

子問題圖G = ( V, E )的規模能夠幫助咱們肯定DP算法的運行時間。

  • 因爲每一個子問題只求解一次,所以算法運行時間等於每一個子問題求解時間之和。

  • 一般,一個子問題的求解時間與子問題圖中對應頂點的度(出射邊的數目)成正比,而子問題的數目等於子問題圖的頂點數。

  • 所以,DP算法運行時間與頂點和邊的數量成線性關係。


重構解

前文給出的鋼條切割DP算法返回最優解的收益值,並未返回解自己(一個長度列表,給出切割後每段鋼條的長度)。

咱們能夠擴展DP算法,使之對於每一個問題不只保存最優收益值,還保存對應的切割方案。利用這些信息,就能輸出最優解。

下面給出bottom-up-cut-rob的擴展版本,它對於長度爲j 的鋼條不只計算最大收益值Rj, 還保存最優解對應的第一段鋼條的切割長度Sj:

extended-bottom-up-cut-rod(p, n)
1    let r[0…n] and s[0…n] be new arrays
2    r[0] = 0
3    for j=1 to n
4        q = -∞
5        for i=1 to j
6            if q < p[i]+r[j-i]
7                q = p[i]+r[j-i]
8                s[j] = i
9        r[j] = q
10    return r and s

此過程和bottom-up-cut-rob很類似,差異只是在第1行建立了數組s,並在求解規模爲j的子問題時,將第一段鋼條的最優切割長度i保存在s[ j ]中(第8行)。

下面的過程接受兩個參數:價格表p和鋼條長度n,而後調用extended-bottom-up-cut-rod來計算切割下來的每段鋼條的長度s[1...n],最後輸出長度爲n的鋼條的完整的最優切割方案:

print-cut-rob-solution(p, n)
1    (r, s) = extended-bottom-up-cut-rod(p, n)
2    while n>0
3        print s[n]
4        n = n-s[n]

對於前文給出的鋼條切割實例,extended-bottom-up-cut-rod(p, 10)會返回下面的數組:

這個表必定要根據代碼邏輯親手畫一遍。體會其構建過程。

i 0 1 2 3 4 5 6 7 8 9 10
r [ i ] 0 1 5 8 10 13 17 18 22 25 30
s [ i ] 0 1 2 3 2 2 6 1 2 3 10

對此例調用print-cut-rod-solution(p,10)只會輸出10,但對n=7,會輸出最優方案R7切割出的兩段鋼條長度1和6。看看Java代碼實現:

public class CutRob {
    public static int[] prices = {1,5,8,9,10,17,17,20,24,30};
    private static int[] path; 
    /** 帶切割方案的自底向上擴展方案*/
    public static int extended_bottom_up_cut_rod(int n){
        int[] dp = new int[n+1];
        path = new int[n+1];
        dp[0] = 0;
        for(int j = 1; j<=n; j++){
            int max = Integer.MIN_VALUE;
            for(int i=1; i<=j; i++){
                if(max < (prices[i-1] + dp[j-i])){
                    max = prices[i-1] + dp[j-i];
                    path[j] = i;
                }
            }
            dp[j] = max;
        }
        return dp[n];
    }
    /** 獲得切割方案(一個最優解)*/
    public static ArrayList<Integer> getACutSolution(int n){
        ArrayList<Integer> result = new ArrayList<>();
        while(n > 0){
            result.add(path[n]);
            n -= path[n];
        }
        return result;
    }
    public static void main(String[] args) {
        System.out.println("長度爲7的最大收益爲:"+extended_bottom_up_cut_rod(7));
        System.out.println(getACutSolution(7));
    }
}

輸出:
長度爲7的最大收益爲:18
[1, 6]

至此,對DP有了一個剛剛開始的瞭解。

相關文章
相關標籤/搜索