1. 程式人生 > 實用技巧 >ACM程式設計--深入淺出理解動態規劃 | 最優子結構

ACM程式設計--深入淺出理解動態規劃 | 最優子結構

原文連結:原文來自公眾號:C you again

    我們在《深入淺出理解動態規劃(一) | 交疊子問題》中討論過,使用動態規劃能解決的問題具有下面兩個主要的性質:

    第一,交疊子問題
    第二,最優子結構

    本期討論動態規劃的主要性質--最優子結構。

最優子結構

    對於一個給定的問題,當該問題可以由其子問題的最優解獲得時,則該問題具有“最優子結構”性質。

    例如,“最短路徑”問題具有如下的“最優子結構”性質:

    如果一個結點x在從起點u到終點v的最短路徑上,則從u到v的最短路徑由從u到x的最短路徑和從x到v的最短路徑構成。像Floyd-Warshall(弗洛伊德—沃舍爾)和Bellman-Ford(貝爾曼—福特)演算法就是典型的動態規劃的例子。

    另外,“最長路徑”問題不具有“最優子結構”性質。我們這裡所說的最長路徑是兩個節點之間的最長簡單路徑(路徑沒有環),由CLRS(Thomas H. Cormen,Charles E. Leiserson,Ronald L. Rivest,Clifford Stein)編寫的《演算法導論》(Introduction to Algorithms)這本書中給出了下面的無權圖。

    從q到t有兩條最長的路徑:q→r→t與q→s→t。與最短路徑不同,這些最長路徑沒有“最優子結構”性質。例如,最長路徑q→r→t不是由q 到r的最長路徑和r到t的最長路徑構成的,因為從q到r的最長路徑是 q→s→t→r,從r到t的最長路徑是r→q→s→t。

經典例題:數字三角形

題目描述:
    下圖給出了一個數字三角形,從三角形的頂部到底部有很多條不同的路徑,對於每條路徑,把路徑上面的數加起來可以得到一個和,你的任務就是找到最大的和。

注意:路徑上的每一步只能從一個數走到下一層上和它最近的左邊的那個數或者右邊的那個數。

輸入:

    輸入一個正整數N (1 < N <= 100),給出三角形的行數,下面的N行給出數字三角形,數字三角形上的數的範圍都在0和100之間。

輸出:

    輸出最大的和。

樣例輸入:

5
7
3 8
8 1 0
2 7 4 4
4 5 2 6 5

樣例輸出:

30

解題思路:

    動態規劃通常用來求最優解。能用動態規劃解決的求最優解問題,必須滿足最優解的每個區域性解也都是最優的。以上題為例,最佳路徑中的每個數字到底部的那一段路徑,都是從該數字出發到底部的最佳路徑。

    實際上,遞迴的思想在程式設計時未必要實現為遞迴函式。在上面的例子中,有遞推公式:

    不需要寫遞迴函式,從最後一行的元素開始向上逐行遞推,就能求得最終 dp[1][1]的值。程式如下:

#include<stdio.h>
#include<string.h>

#define MAX_NUM 1000
int D[MAX_NUM + 10][MAX_NUM + 10];  /* 儲存數字三角形 */
int N;                                    /* 數字三角形的行數 */
int dp[MAX_NUM + 10][MAX_NUM + 10]; /* 狀態陣列 */

int max(int x, int y) {
    return x > y ? x : y;
}

int main() {
    int i, j;

    scanf("%d", &N);

    memset(dp, 0, sizeof(dp));/* 狀態陣列全部初始化為0 */

    for (i = 1; i <= N; ++i)
        for (j = 1; j <= i; ++j)
            scanf("%d", &D[i][j]); /* 輸入數字三角形 */

    for (j = 1; j <= N; j++) { /* 處理最底層一行 */
        dp[N][j] = D[N][j]; /* 最底層一行狀態陣列的值即為該數字本身 */
    }

    for (i = N - 1; i >= 1; i--) { /* 從倒數第二層開始直至最頂層 */
        for (j = 1; j <= i; j++) {
            dp[i][j] = max(dp[i + 1][j], dp[i + 1][j + 1]) + D[i][j];
        }
    }

    printf("%d\n", dp[1][1]);       /* 頂點(1,1)即為最大值 */s

    return 0;
}

    如下圖所示,方框裡的數字是能取得的最大值。相信大家看完這個圖,對動態規劃的理解不那麼困難了。

    實際上,因為dp[i][j]的值在用來計算出dp[i-1][j]後已經無用,所以可以將計算出的dp[i-1][j]的值直接存放在dp[i][j]的位置。這樣,計算出 dp[N-1][1]替換原來的 dp[N][1],計算出 dp[N-1][2]替換原來的dp[N][2]......計算出 dp[N-1][N-1]替換原來的 dp[N][N-1],dp陣列實際上只用最後一行,就能夠存放上面程式中本該存放在dp[N-1]那一行的全部結果。同理,再一行行向上遞推,dp陣列只需要最後一行就可以存放全部中間計算結果,最終的結果(本該是dp[1][1])也可以存放在dp[N][1])。因此,實際上dp不需要是二維陣列,一維陣列就足夠了。

    改寫後的程式如下:

#include<stdio.h>
#include<string.h>

#define MAX_NUM 1000
int D[MAX_NUM + 10][MAX_NUM + 10];  /* 儲存數字三角形 */
int N;    /* 數字三角形的行數 */
int *dp; /* 狀態陣列 */

int max(int x, int y) {
    return x > y ? x : y;
}

int main() {
    int i, j;

    scanf("%d", &N);

    for (i = 1; i <= N; ++i)
        for (j = 1; j <= i; ++j)
            scanf("%d", &D[i][j]); /* 輸入數字三角形 */

    dp = D[N];  /* dp指向第N行 */

    for (i = N - 1; i >= 1; i--) { /* 從倒數第二層開始直至最頂層 */
        for (j = 1; j <= i; j++) {
            dp[j] = max(dp[j], dp[j + 1]) + D[i][j];
        }
    }

    printf("%d\n", dp[1]);       /* (1,1)即為最大值 */

    return 0;
}

這種用一維陣列取代二維陣列進行遞推、節省空間的技巧叫“滾動陣列”。上面的程式雖然節省了空間,但是沒有降低時間複雜度,時間複雜度依然是O(N^2)的,從程式使用了兩重迴圈就可以看出。

總結

    許多求最優解的問題可以用動態規劃來解決。用動態規劃解題,首先要把原問題分解為若干個子問題,這一點和前面的遞迴方法類似。區別在於,單純的遞迴往往會導致子問題被重複計算,而用動態規劃的方法,子問題的解一旦求出就會被儲存,所以每個子問題只需求解一次。

    子問題經常和原問題形式相似,有時甚至完全一樣,只不過規模從原來的n變成n-1, 或從原來的n×m變成n×(m-1)。找到子問題,就意味著找到了將整個問題逐漸分解的辦法,因為子問題可以用相同的思路一直分解下去,直到最底層規模最小的子問題可以一目瞭然地看出解(像上面數字三角形的遞推公式中,當i=N時,解就可以直接得到)。每一層子問題的解決會導致上一層子問題的解決,逐層向上,就會導致最終整個問題的解決。如果從最底層的子問題開始,自底向上地推匯出一個個子問題的解,那麼程式設計時就不需要寫遞迴函數了。

文章推薦

推薦一:深入淺出理解動態規劃(一) | 交疊子問題,文章內容:動態規劃--交疊子問題(記憶化搜尋演算法、打表法求解第n個斐波那契數)。

推薦二:深入淺出理解動態規劃(二) | 最優子結構,文章內容:動態規劃--最優子結構(經典例題:數字三角形求解)。

公眾號推薦(資源加油站)

瞭解更多資源請關注個人公眾號:C you again,你將收穫以下資源

1、PPT模板免費下載,簡歷模板免費下載
2、基於web的機票預訂系統基於web的圖書管理系統
3、貪吃蛇小遊戲原始碼
4、各類IT技術分享