算法系列-動態規划(2):切割鋼材問題


切割鋼材問題

接上回說到,斐波那契數列是動態規划最簡單應用,但動態規划卻不是為了用來算那數列。

當時留了個尾巴,就是切割鋼材的問題。

不同長度的鋼材價格不一樣,我現在有一根長度為n的鋼材,要怎么切割才能利益最大化?
其中鋼材的價格如下

長度 0 1 2 3 4 5 6 7 8 9 10
價格 0 1 5 8 9 10 17 17 20 24 30

羅拉老早就讓告訴她動態規划怎么做這玩意,沒辦法,只能嘮嗑嘮嗑了。


如何下手?

看到這個問題,沒頭沒腦的怎么下手?臣妾做不大啊。

遇事不決,舉個栗子。

假設我現在有一段長度n=4的鋼材,我有哪些切割方案呢?

我們把所有切割方案和對應的利潤列出來如下:

切割方案.png

1,1,2 和2,1,1之類的是一樣的就不列了
總共的方案其實是有2^{n-1}種,因為截斷鋼材包含1,所以,從右邊往左邊看,對於每一個長度1,都可以選擇切割或者不切割。
當然,我們這邊不是排列,是組合問題,數量肯定會少於2^{n-1}
具體的組合數量這里就不詳細說明了,有興趣的可一自己試試。

從圖中可以看出,最佳的方案是將鋼材截成2 + 2兩段。

此時的利潤為5 + 5 = 10

從上圖中,我們可以得出一個結論,即:

如果我們有一個最優解可以將鋼材為k段,(0 <= k <= n)

對於每一段鋼材的長度i和長度n之間有:n=i_1+i_2+...+i_k

而對於最大的利潤r和每一段鋼材的利潤p之間有:r_n=p_{i1}+p_{i2}+...+p_{in}

按照我們之前說的,對於每一個長度1的鋼材,我們都可以選擇切割或不切割

我們只要選擇兩者中利潤大的方案即可。

這樣我們就可以通過比較不切割鋼材的收益和鋼材切割后的收益來確定方案。

對於不切割鋼材,我們可以通過價格表直接得到利潤。

對於切割鋼材,我們可以把它看作是兩個子問題。

比如我切成1 和 n-1,1 和 n-1再分成兩個子問題,即對長度為1 和 n-1進行分析。

那此時,對於切割的方案利潤為:
r=r_1+r_{n-1}

而我切割可選的方案有n-1種(先不考慮剔除重復的情況):
r_1+r_{n-1},r_2+r_{n-2},...,r_{n-1}+r_1

這樣我們就可以推導出對於切割長度n的鋼材的最大利潤的公式為:
r_n = max(p_n,r_1+r_{n-1},r_2+r_{n-2},...,r_{n-1}+r_1)

p_n對應不切割情況,其他n-1個參數對應其他n-1種情況

這時我們可以發現,我們原本求解n的問題,切割后就變成了:r_1 和 r_{n-1}之類的的子問題.

而這些子問題的求解形式和n的求解形式完全一樣。

我們可以將切割后的鋼條完全當作兩個獨立的切割鋼條實例。

通過求解所有可能的兩段切割方案,從中選取最優的組合使得利潤最大化。
從而得到組成切割長度n的鋼材最優解。

你們仔細品,細細品,通過利潤公式,這玩意怎么看都是遞歸的親生兒砸。

這感覺用遞歸也能做了吧?


如何用遞歸解決切割鋼材問題呢?

在上一篇
算法系列-動態規划(1):初識動態規划中,八哥說了,切割鋼材問題用遞歸沒那么好做,但是也說了也不是不能做。

按照上面分析,用遞歸好像也沒那難吧?

羅拉動手試着寫了一下

public class CutRod {
    //鋼材價格,0~10英寸的價格
    static int[] p = {0, 1, 5, 8, 9, 10, 17, 17, 20, 24, 30};

    public static void main(String[] args) {
        int n = 4;
        System.out.println("長度為" + n + "的最大收益為:" + rec_cutRod(n));
        n = 10;
        System.out.println("長度為" + n + "的最大收益為:" + rec_cutRod(n));
    }

    public static int rec_cutRod(int n) {
        //當長度為0,自然沒有收益,返回0
        if (n == 0) return p[0];
        int rvnd = 0;
        for (int i = 1; i <= n; i++) {
            //對比當前利潤,和 切成 i 與 對 n-i 繼續切割之和,取較大的組合
            rvnd = p[i];
            for (int j = 1; j < i; j++)
                rvnd = Math.max(rvnd, rec_cutRod(j) + rec_cutRod(i - j));
        }
        return rvnd;
    }
}

//結果
長度為4的最大收益為:10
長度為10的最大收益為:30

不錯,不講碼德,先把代碼寫出來在再考慮優化。

接下來就是優化了,根據 算法系列-動態規划(1):初識動態規划中的案例,就是用備忘錄的遞歸了。

羅拉昨天可是花了功夫去理解的,啪...啪...優化后,帶備忘錄的代碼敲出來了

public class CutRod {
    //鋼材價格,0~10英寸的價格
    static int[] p = {0, 1, 5, 8, 9, 10, 17, 17, 20, 24, 30};

    public static void main(String[] args) {
        int n = 4;
        System.out.println("長度為" + n + "的最大收益為:" + men1(n));
        n = 10;
        System.out.println("長度為" + n + "的最大收益為:" + men1(n));
    }

    public static int men1(int n) {
        if (n == 0) return p[0];
        int[] men = new int[n + 1];
        men[0] = 0;
        for (int i = 1; i < n + 1; i++) men[i] = Integer.MIN_VALUE;
        return menHelper1(men, n);
    }

    public static int menHelper1(int[] men, int n) {
        //當長度為0,自然沒有收益,返回0
        if (n == 0) return p[0];
        if (men[n] >= 0) return men[n];
        else {
            int rvnd = 0;
            for (int i = 1; i <= n; i++) {
                //對比當前利潤,和 切成 i 與 對 n-i 繼續切割之和,取較大的組合
                rvnd = p[i];
                for (int j = 1; j < i; j++) {
                    rvnd = Math.max(rvnd, menHelper1(men, j) + menHelper1(men, i - j));
                }
            }
            men[n] = rvnd;
            return rvnd;
        }
    }
}
//結果
長度為4的最大收益為:10
長度為10的最大收益為:30

不錯,輕松就寫出來了,但是有沒有發現還有優化的空間哦。

羅拉聞言檢查了幾遍代碼,然而也沒發現繼續優化的點,不免有些懷疑。

其實代碼部分差不多了,但是我們分析問題的方式可以變一下,

比如代碼中rvnd = Math.max(rvnd, menHelper1(men, j) + menHelper1(men, i - j))這部分,我們可以優化一下。

按照我們之前分析公式,這里是相當於把切割后的鋼材當做兩個子問題繼續切割吧?

這就導致會出現二重循環和menHelper1(men, j) + menHelper1(men, i - j)這一部分。

時間復雜度顯然大於O(n)小於O(n^2)

我們還可以對切割的方案進行優化。

優化切割方案

之前切割后的鋼材當做兩個子問題,現在換個思路:

假設我們有一段鋼材長度為n,我們從左側切割長度為i,對這一部分整體出售,對於右側部分n-i則繼續按照子問題的方式繼續切割。

那么對於不切割的部分i,收益為:p_i

對於需要繼續切割的部分n-i,收益則為:r_{n-1}

切割可選的范圍為0<=i<=n,那么對於此時的可以獲得的最大利潤為:
r_n = \max_{0\leq i \leq=n}(p_i,r_{n-i})

經過這一波操作,我們將之前切割方案會出現兩個子問題變為只剩下一個子問題。

那么此時,我們很容易就寫出一個遞歸的代碼。

羅拉果然冰雪聰明,一定就通。

啪..啪..,很快哈,一分鍾多一點,就寫出了代碼。

public class CutRod {
    //鋼材價格,0~10英寸的價格
    static int[] p = {0, 1, 5, 8, 9, 10, 17, 17, 20, 24, 30};

    public static void main(String[] args) {
        int n = 4;
        System.out.println("長度為" + n + "的最大收益為:" + rec_cutRod(p, n));
        n = 10;
        System.out.println("長度為" + n + "的最大收益為:" + rec_cutRod(p, n));
    }

    public static int rec_cutRod(int[] p, int n) {
        //當長度為0,自然沒有收益,返回0
        if (n == 0) return 0;
        int rvnd = Integer.MIN_VALUE;
        for (int i = 1; i <= n; i++) {
            //對比當前利潤,和 切成 i 與 對 n-i 繼續切割之和,取較大的組合
            rvnd = Math.max(rvnd, p[i] + rec_cutRod(p, n - i));
        }
        return rvnd;
    }
}

//輸出結果
長度為4的最大收益為:10
長度為10的最大收益為:30

老規矩,備忘錄優化一下

於是,羅拉又一陣...啪...啪...,又整了一份帶備忘錄的遞歸出來。

public class CutRod {
    //鋼材價格,0~10英寸的價格
    static int[] p = {0, 1, 5, 8, 9, 10, 17, 17, 20, 24, 30};

    public static void main(String[] args) {
        int n = 4;
        System.out.println("長度為" + n + "的最大收益為:" + men_cutRod(n));
        n = 10;
        System.out.println("長度為" + n + "的最大收益為:" + men_cutRod(n));
    }

    public static int men_cutRod(int n) {
        //記錄計算過的利潤
        int[] men = new int[n + 1];//因為后續需要比較,先將備忘錄所有值置為-1
        for (int i = 0; i < men.length; i++) men[i] = -1;
        return menHelper(men, n);
    }

    public static int menHelper(int[] men, int n) {
        // 備忘錄中已經記錄了n的最大利潤,直接取
        if (men[n] >= 0) return men[n];
        //備忘錄沒有記錄,則執行常規遞歸
        if (n == 0) return 0;
        else {
            int r = -1;
            for (int i = 1; i <= n; i++) {
                r = Math.max(r, p[i] + menHelper(men, n - i));
            }
            men[n] = r;
        }
        return men[n];
    }
}

//輸出結果:
長度為4的最大收益為:10
長度為10的最大收益為:30

這個時候我們再看看時間復雜度,只有一個for循環,雖然有 r = Math.max(r, p[i] + menHelper(men, n - i));,但是由於備忘錄的存在,時間復雜度還是O(n)

這個不僅僅簡化了了理解,也提高效率。

我們可以看到,備忘錄的遞歸本質還是從大問題分成小問題,即計算的過程是:n,n-1....2,1

ps: 雖然代碼有for (int i = 1; i <= n; i++),但仔細分析即可發現,我們實際上處理的n的規模,即我們划分的子問題的n是下降的。

這也符合我們之前說的自頂向下的方法。

至於帶備忘錄的遞歸是不是動態規划,這個后面會說明


如何用dp數組切割鋼材問題呢

備忘錄都寫出來,那后面dp數組形式的應該也不難了。

首先我們需要記錄計算過的值,所以我們用一個數組dp來記錄計算過的最佳利潤。(為了方便最好長度設為n+1

然后是確定初始值,既然是自底向上,那肯定是從1,2...,n-1,n(對於這個問題是這樣)的順序計算。

指定初始值:對於(n=0、n=1)我們可以直接得出結果,所以dp[0]=0,dp[1]=1

對於n>=2:我們分析過可以通過左側不切割整體出售,右側繼續切割化為一個子問題的形式。通過組合所有的方案選出最優方案。

所以我們不難寫出推導公式(ps:用md太難寫了,寫了半天廢了,直接畫圖吧):
手繪公式圖.png

有了上面的分析,羅拉耐不住手癢,於是啪...啪...代碼出爐

public class CutRod {
    //鋼材價格,0~10英寸的價格
    static int[] p = {0, 1, 5, 8, 9, 10, 17, 17, 20, 24, 30};

    public static void main(String[] args) {
        int n = 4;
        System.out.println("長度為" + n + "的最大收益為:" + cutRod(n));
        n = 10;
        System.out.println("長度為" + n + "的最大收益為:" + cutRod(n));
    }

    public static int cutRod(int n) {
        if (n == 0) return 0;
        int[] dp = new int[n + 1]; //因為dp[0]=0,所以不需要額外賦值
        for (int i = 1; i <= n; i++) {
            int r = p[i];//不切割是保底的,所以直接等於p[i]即可。
            for (int j = 1; j < i; j++) {//遍歷所有的切割方案,選擇大得
                r = Math.max(r, p[j] + dp[i - j]);
            }
            dp[i] = r;
        }
        return dp[n];
    }
}

//結果
長度為4的最大收益為:10
長度為10的最大收益為:30

怎樣,很簡單吧?

但是問題來了,dp數組又回到了雙重for循環,時間復雜度顯然介於O(n)O(n^2)

能不能再優化,目前八哥也沒想到。有更好的優化手段可以評論區留一下。

到此,切割鋼材的問題就算是解決,如果羅拉去面試,能到這步,不說offer一定拿,蹭多一杯咖啡還是可以的吧。


疑問+總結

通過上篇文章和本文,對動態應該有點感覺了吧。
接下來就對動態規划做點總結吧,包括填一下前面的坑。

動態規划的思想是什么?
  1. 仔細安排求解的順序
  2. 每個子問題只求解一次。(將結果保存下來)
  3. 遇到求解過的子問題直接從保存的結果獲取。
  4. 時空權衡,典型的是空間換時間。(付出額外內存空間保存計算過的結果)

一般的遞歸之所以效率低,就是因為它會反復求解相同的子問題,所以對於計算順序,計算結果的保存都是優化遞歸的重要手段。


怎么判斷一個問題能不能用動態規划?

這就得扯一扯了。

首先,什么樣的問題我們能聯想到動態規划呢?

最起碼,這個問題得能分為很多子問題;
其次,通過這些子問題我們可以得到最終問題的答案。
簡單的說就是f(n)f(p)存在關系,其中p是比n小的問題。

接下來,就判斷這問題能不能用動態規划?
  • 無后效性
    即過去的內容不會影響將來的內容。(如fn(n)=2f(n-1),對於f(n)而言,我只需要知道f(n-1)即可,之前的我不需要知道)
  • 具備最優子結構
    結合我們的案例,切割鋼材,我們的對於鋼材可以選擇切割與不切割。
    我們$r_n$本就是最優解,但是r_n=max(p_n,r_i+r_{n-i}),r_n的最優解與r_i 、 r_{n-i}有關。
    即最終問題的最優解可以由小問題的最優解得到,這個性質叫做最優子結構。
  • 子問題重疊性
    指在遞歸算法自頂向下對問題求解的時候,每次產生的子問題都不是新的問題,很多子問題會被多次重復計算。
    動態規划利用這種特性對子問題只計算一次,將之存入一個數組。當再次遇到該問題的時候直接從數組獲得結果,提高效率。

只要問題具備無后效性、最優子結構和子問題重疊,就可以用動態規划。


動態規划的步驟?
  1. 尋找最優子結構。(干什么?設計狀態的表示形式)
  2. 歸納出狀態轉移方程。(怎么做?)
  3. 初始化。(即初始化動態起點)

這只是常用的步驟,動態遇多了就會發現什么亂七八糟的玩意都有。重點是理解思想,掌握核心科技,一點都不慌。


什么是自頂向下、帶備忘錄的自頂向下,自底向上?
首先說說自頂向下:

自頂向下就是,從最終狀態開始,找到可以到達當前狀態的狀態,如果該狀態還沒處理,就先處理該狀態。

大白話就是:
老板交代給你一個任務,你丫的不講武德,直接去問下面的人。下面的人也不知道啊,也跑去問下面的人,直到問到知道的人,再層層上報。

那什么是帶備忘錄的自頂向下呢?

備忘錄就是將計算過的值記錄下來,下次用到的時候直接從備忘錄中查找。減少遞歸的時間。

大白話就是:
每次老板發任務,就是上一層領導都過來詢問,之前傻傻的來一個領導我重新做一遍。
現在我學乖了,第一個領導過來,我做一遍,把結果寫在紙上,后面再有領導過來直接告訴他。

那什么是自底向上呢?

我們知道了所有遞歸的邊界,列出了所有的狀態。並且當前的狀態可以影響、更新后面的狀態,直到所有的狀態被更新為止。

大白話就是:
知道老板要發布任務,不需要領導來問我,我自己主動把自己這一部分的工作做了,報給我直系領導,
我直系領導得到我的反饋,把自己那部分也做了,再上報給他的直系領導,直到老板得到結果。

如果還不清楚,那再舉兩個例子。

  • 故事一
    某日小明上數學課,他的老師給了很多個不同的直角三角板讓小明用尺子去量三角板的三個邊,並將長度記錄下來。
    兩個小時過去,小明完成任務,把數據拿給老師。
    老師給他說,還有一個任務就是觀察三條邊之間的數量關系。
    又是兩個小時,小明說:“老師,我找到了,三條邊之中有兩條,它們的平方和約等於另外一條的平方。”
    老師拍拍小明的頭,“你今天學會了一個定理,勾股定理。就是直角三角形有兩邊平方和等於第三邊的平方和”。
  • 故事二
    某日老師告訴小明“今天要教你一個定理,勾股定理。”
    小明說,“什么是勾股定理呢?”
    “勾股定理是說,直角三角形中有兩條邊的平方和等於第三邊的平方。”
    然后老師給了一大堆直角三角板給小明,讓他去驗證。
    兩個小時后,小明告訴老師定理是正確的.

其中故事一就是自底向上,故事二就是自頂向下。


備忘錄的遞歸算不算動態規划?

如果單純看前文,可能給大家一個感覺,就是動態規划只有dp數組的形式,備忘錄遞歸不屬於動態。其實不然,下面詳細總結一下吧。

動態規划有兩種等價的實現方法:

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

  1. 此方法一般按照自然遞歸的形式編寫過程。
  2. 在計算的過程中會保存每一個子問題的解,即備忘錄(一般是數組或散列表)
  3. 當需要求解問題是,先去備忘錄尋找,若備忘錄有,直接提取,若無,按照常規方式求解。

之所以叫做備忘錄,是因為它“記住”了之前計算過的結果。

自底向上法(bottom-up method)

  1. 一般要恰當定義子問題“規模”,使得任何子問題的求解只依賴更小子問題的求解。
  2. 將子問題按照規模排序,從小到大的順序進行求解,記錄每一個每一個子問題的結果。(一般是dp數組)
  3. 當我們計算一個子問題的時候,它依賴的更小子問題都已經求解完畢,我們直接可以從保存的結果中取值。直到求解到我們問題的規模。

兩種實現方法的優劣

這玩意得看具體問題。

一般來說這兩種方法效率相差不大。
但是仔細分析一下也可以知道,帶備忘錄的遞歸本質還是遞歸,
頻繁的函數調用是少不掉,即使你用了備忘錄,
所以自底向上的方法在時間復雜度上可能會有一點優勢。

但是這個不絕對。
就比如切割鋼材,帶備忘錄的遞歸(自頂向下)我可以把時間復雜度下降到O(n)$,但是dp數組(自底向上)的時間復雜度卻在O(n)O(n^2) 之間

總之就是看問題,別人都叫動態了,有固定才怪了。


大概先寫這么多吧,動態規划理解思想,多找幾個題目練練手,也就差不多了。
本文很多內容都是八哥看【算法導論三】和其他博文總結。
后續找幾個案例讓羅拉練練手,免得下次面試又掛了。


如果公式看的難受,可以去公眾號看,博客園md支持太操蛋了

如想了解更多,歡迎關注【兔八哥雜談】,會持續更新一些有意思的內容。

本文為原創文章,轉載請注明出處!!!


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM