動態規劃(1)——算法導論(16)

咱們先從兩個問題入手,來學習動態規劃java

1. 鋼條切割問題

1.1 提出問題

某公司想要把一段長度爲n的鋼條切割成若干段後賣出,目前市場上長度爲i(0<i<10,i爲整數)的鋼條的價格行情以下:學習

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

不考慮切割的成本(即切割成多少段隨意),請你給出一個使收益最大化的切割方案。測試

1.2 分析問題

長度爲n的鋼條共有\(2^{n-1}\)種切割方案,最簡單作法是採用暴力方法,比較全部方案的收益,找出最大值。spa

暴力破解方法的關鍵是如何對全部的可能狀況進行不重不漏的分類。做以下考慮:code

由於不管咱們怎麼切割,總能夠當作是一段完整的長度爲i的鋼條加上另外一部分總長度爲n-i的鋼條(可能被切割,也可能沒有)。
那麼切割長度爲n的鋼條的最大收益能夠用以下公式表出:遞歸

\[ r_n = \max_{1\leq i\leq n}( p_i + r_{n-1}) \]數學

所以,咱們能夠採用一種被叫作自頂向下的遞歸方法去解決該問題。table

1.3 自頂向下遞歸實現

public static int cutRod(int[] p, int n) {
    if (n == 0) {
        return 0;
    }
    int max = Integer.MIN_VALUE;
    for (int i = 1; i <= n && i <= p.length; i++) {
        max = Integer.max(max, p[i - 1] + cutRod(p, n - i));
    }
    return max;
}

在測試時,咱們發現,當鋼條的長度稍微變大(好比n=30)時,上述程序的運行時間會大大增大。仔細考慮緣由,會發現實際上咱們作了不少重複的遞歸操做。好比在求解cutRod(p, n)過程當中,咱們會遞歸求解cutRod(p, 0 ~ n-1),而在求解cutRod(p, n-1)的過程當中,一樣咱們會遞歸求解cutRod(p, 1 ~ n-1),能夠看出,僅僅就是這兩次的調用,就重複調用了n-2次。時間效率固然會降低。class

用一個樹狀圖很容易就能看出(以n=4爲例):效率

遞歸調用樹

設對於長度爲n的鋼條,上述程序的運行時間爲T(n),則T(n)可用以下遞歸式表示:

\[ T(n) = 1+ \sum_{i=0}^{n-1}T(j) \]

用數學概括法很容易計算出\(T(n) = 2^n\)。這也就解釋了爲何隨着n的「簡單」增大,時間會猛增。

1.3 自頂向下遞歸的改進實現

既然上述程序重複計算了不少次,那麼咱們能夠將每次計算的結果保存起來,下次再須要計算一樣的問題時,就直接取出咱們計算的結果。

下面是改進以後的代碼:

public static int memoziedCutRod(int[] p, int n) {
    memoziedArray = memoziedArray == null ? new int[n] : memoziedArray;
    if (n == 0) {
        return 0;
    }
    if (memoziedArray[n - 1] > 0) {
        return memoziedArray[n - 1];
    }
    int max = Integer.MIN_VALUE;
    for (int i = 1; i <= n && i <= p.length; i++) {
        max = Integer.max(max, p[i - 1] + memoziedCutRod(p, n - i));
    }
    memoziedArray[n - 1] = max;
    return max;
}

再次測試發現,時間效率有明顯的提升。

若是上述代碼再加上一個記錄各長度最優分割方案的「完整段」的長度的「備忘錄」,咱們即可以很容易的找出長度爲1~n的全部狀況的詳細的最優切割方案。

如下是n=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

1.4 自底向上的實現

咱們用一個簡化版的遞歸樹再來描述上述調用過程,以n=4爲例子:

簡化版遞歸調用樹

上述的自頂向下的調用過程在解決問題時,是從最大規模的問題入手,而最大規模的問題是依賴比它小的子問題的,所以便遞歸的去解決子問題,直到全部的子問題都解決了,該問題也就解決了。

既然咱們已經知道了,每一個問題的求解必將依賴它的子問題的解,那麼咱們何不直接按問題規模的從小到大的順序去依次解決呢?

public static int bottomUpCutRod(int[] p, int n) {
    int[] memoziedArray = new int[n + 1];
    memoziedArray[0] = 0;
    int max = Integer.MIN_VALUE;
    for (int i = 1; i <= n; i++) {
        for (int j = 1; j <= i && j <= p.length; j++) {
            max = Integer.max(max, p[j - 1] + memoziedArray[i - j]);
        }
        memoziedArray[i] = max;
    }
    return max;
}

在上述代碼中,咱們用了兩層嵌套循環替換了遞歸調用。其中,外層循環來控制問題的規模(規模從i~n);內層循環來求解當前規模的問題。由於在每次求解規模爲i的問題時,其子問題,即問題規模爲1~i-1的問題都已經求解出(存放在)memoziedArray中,所以能夠從memoziedArray中直接讀取出最優解。

1.5 時間複雜度分析

上面咱們已經說過,不帶「備忘」的遞歸調用的時間複雜度爲\(2^n\);帶了「備忘」的自底向上的方法的時間複雜度爲\(n^2\),實際上就是一個等差數列的求和;能夠想象,自定向下的遞歸調用實際上和自底向上的方法沒有太大的差異(只是求解方向順序的不一樣,固然,遞歸調用因爲須要壓棧,空間複雜度會較大一些),所以時間複雜度也是\(n^2\)

下一篇會從矩陣乘法鏈問題入手,繼續探討動態規劃問題。

相關文章
相關標籤/搜索