算法筆記:動態規劃(1)

何時能夠使用動態規劃

       動態規劃(Dynamic Programming, DP)與其說是一種算法,更準確地說是一種解決問題的思維方式,因爲其並沒有對所有相關問題抽象出一種通用的算法程序,而是要在解題時根據具體的問題運用動態規劃的思想進行問題的建模並編碼求解。因此在理解動態規劃解題之前,首先要了解什麼樣的問題能夠用動態規劃的思想解決。

        本質上來說,動態規劃是一種更高效的遞歸算法的實現,所以在學習動態規劃之前要對遞歸算法有比較深刻的理解。

在用樸素遞歸解決問題時,首先將目標問題分解成多個子問題,而在解決這些子問題時又被分解成更小的子問題……如此重複,直到分解到遞歸的邊界爲止。在分解過程中,同一個子問題可能會被多次重複解決。

        動態規劃的思想就是,每解決一個子問題,都將該子問題的結果保存起來,則每個子問題只需要被解決一次。那麼,哪些遞歸算法可以用動態規劃的思想去實現呢?最簡單的方法就是看樸素遞歸的算法有沒有將同一個子問題一次次重複計算(可以畫一個簡單的遞歸樹,能夠很直觀地發現,如果同一個子問題在樹中不斷出現,並且不止一次地出現在非葉節點上,那麼就可以判斷該子問題被重複計算了)。

        一般來說,動態規劃是解決組合對象上優化問題的方法,這些對象的組成具有固定的從左到右的順序。這類對象包括字符串、根樹(rooted trees)、多邊形和整數列等。 [1](這段話我也沒太看明白,不知道是不是對原文有什麼誤解。不過既然是在書上看到的,先摘抄下來。)

        如果要給動態規劃的適用情況做更嚴謹的定義,那麼具有以下性質的問題能夠用動態規劃的思想解決:[2]

  • 最優子結構性質。如果問題的最優解所包含的子問題的解也是最優的,我們就稱該問題具有最優子結構性質(即滿足最優化原理)。最優子結構性質爲動態規劃算法解決問題提供了重要線索。
  • 無後效性。即子問題的解一旦確定,就不再改變,不受在這之後、包含它的更大的問題的求解決策影響。
  • 子問題重疊性質。子問題重疊性質是指在用遞歸算法自頂向下對問題進行求解時,每次產生的子問題並不總是新問題,有些子問題會被重複計算多次。動態規劃算法正是利用了這種子問題的重疊性質,對每一個子問題只計算一次,然後將其計算結果保存在一個表格中,當再次需要計算已經計算過的子問題時,只是在表格中簡單地查看一下結果,從而獲得較高的效率。

解題基本思路

  1. 確定狀態。前文提到,對於每一個子問題都只計算一次,然後將其計算結果保存在一個表格中;對動態規劃進行問題建模的第一步也是最困難的一步就是設計這個表格,實現時一般用一個整數數組dp[](進階題也可能是一個二維數組)。dp[i]就代表着第i個子問題的狀態,難點就在於這個dp[i]到底代表什麼。一般情況下,問題問什麼,dp[i]就代表什麼,即迭代完成後的dp[N]就是問題的最終解,這種情況下dp[i]實際上就是子問題i的最優解。但是也有一些難題,迭代完成後的dp[N]需要進行進一步的計算纔是最終問題的解,此時dp[i]就只是能導出子問題i最優解的狀態值。因此dp[]數組稱爲狀態數組更爲合理。
  2. 確定邊界值(初始狀態)。由於動態規劃解決的問題都是一些遞歸問題,那麼也有遞歸問題中的邊界值的問題。通常情況下是第一個或者最前面的兩個子問題的最優解。
  3. 確定狀態轉移方程。即找出dp[i]與dp[i-1],dp[i-2]…的關係。
  4. 優化:如果dp[i]只與固定的前k項有關,那麼對算法的空間複雜度進行優化,即只保存前k個狀態,而無需保存完整的dp[]數組。

例題

斐波那契數列

基本的動態規劃實現

        斐波那契數列是經典的遞歸問題,遞歸式爲F(n)=F(n1)+F(n2)F(n) = F(n-1)+F(n-2),計算斐波那契數列的第nn項的樸素遞歸實現代碼如下:

int fib_r(int n) {
    if(n == 0) return 0;
    if(n == 1) return 1;
    
    return fib_r(n-1) + fib_r(n-2);
}

在這種實現下,畫出計算F(6)的遞歸樹如下:

f6

可以看出其中F(2)F(2)F(3)F(3)F(4)F(4)等都計算了多次,是符合動態規劃應用條件的,因此可以按照動態規劃的解題思路:

  1. 確定狀態:創建狀態數組dp[n+1],其中dp[i]就表示斐波那契數列中的第ii
  2. 邊界值:在斐波那契數列的定義中已經直接給出了邊界值,dp[0] = 0, dp[1] = 1
  3. 狀態轉移方程:F(n)=F(n1)+F(n2)F(n) = F(n-1)+F(n-2)

據此就可以得到動態規劃版本的實現:

int fib_dp(int n) {
    if(n == 0) return 0;
    if(n == 1) return 1;
    int[] dp = new int[n+1];
    dp[0] = 0;
    dp[1] = 1;
    for(int i = 2;i <= n; i++) {
        dp[i] = dp[i-1] + dp[i-2];
    }
    return dp[n];
}

動態規劃版本的實現的時間複雜度爲O(N)O(N),空間複雜度也爲O(N)O(N)。與樸素遞歸相比都有所優化。

優化

       在迭代過程中只需要保留前兩位便可,算法的空間複雜度進一步優化到O(1)O(1)

int fib_bp_opt(int n) {
    if(n == 0) return 0;
    if(n == 1) return 1;
    int prev = 0, cur = 1, tmp;
    for(int i = 2; i <= n; i++) {
        tmp = cur;
        cur = cur + prev;
        prev = tmp;
    }
    return cur;
}

最大子序和

基本的動態規劃實現

       (LeetCode.53)給定一個整數數組 nums ,找到一個具有最大和的連續子數組(子數組最少包含一個元素),返回其最大和。

示例:

輸入: [-2,1,-3,4,-1,2,1,-5,4],
輸出: 6
解釋: 連續子數組 [4,-1,2,1] 的和最大,爲 6。

  1. 確定狀態:dp[i]代表以第i個數結尾的最大子序列和。
  2. 確定邊界值:dp[0] = nums[0]
  3. 狀態轉移方程:dp[i] = (dp[i-1]>0)?(dp[i-1] + nums[i]):(nums[i])

其中dp數組的含義和狀態轉移方程都有一些難度,並且dp[n]也不是最終結果,需要在迭代的過程中記錄最大值。問題建模完成後,就有如下實現:

int max_subarray(int[] nums) {
    int[] dp = new int[nums.length];
    int max = nums[0];

    dp[0] = nums[0];
    
    for(int i = 1; i < nums.length; i++) {
        dp[i] = (dp[i-1] > 0)?(dp[i-1] + nums[i]):(nums[i]);
        max = Math.max(dp[i], max);
    }
    
    return max;
}

優化

       從狀態轉移方程可以發現,dp[i]的計算只與dp[i-1]有關,因此可以優化空間複雜度到O(1)O(1)

int max_subarray_opt(int[] nums) {
    // 只用一個pre變量保存dp[i-1]
    int pre = nums[0], max = nums[0];

    for(int i = 1; i < nums.length; i++) {
        pre = (pre > 0)?(pre + nums[i]):(nums[i]);
        max = Math.max(pre, max);
    }

    return max;
}

最終經過優化的算法的時間複雜度爲O(N)O(N),空間複雜度爲O(1)O(1)

PS: 數據結構課程的1.3節對本題的各種解法有詳細講解。

參考

  1. Skiena S S. The Algorithm Design Manual,(2008)[J]. URl: http://dx.doi.org/10.1007/978-1-84800-070-4.
  2. 維基百科(404警告)
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章