1. 程式人生 > >動態規劃-數字三角形問題

動態規劃-數字三角形問題

有一個由非負整陣列成的三角形,第一行只有一個數,除了最下行之外每個數的左下方和右下方各有一個數.

    1

   3 2

 4 10 1

4 3 2 20

從第一行的數開始,每次可以往左下或右下走一格,直到走到最下行,把沿途經過的數全部加起來,如何走才能使得這個和儘量大?

輸入:三角形的行數n,數字三角形的各個數(從上到下,從左到右)

輸出:最大的和。

執行結果:

如果熟悉回溯法,可能會立刻發現這是一個動態的決策問題:每次有兩種選擇-左下或右下。如果用回溯法求出所有可能的路線,就可以從中選出最優路線。但和往常一樣,回溯法的效率太低;一個n層數字三角形的完整路線有2^{n-1}

條,當n很大時回溯法的速度將讓人無法忍受。

為了得到高效的演算法,需要用抽象的方法思考問題:把當前的位置(i,j)看成一個狀態(還記得麼?)然後定義狀態(i,j)的指標函式d(i,j)為從格子(i,j)出發時能得到的最大和(包括格子(i,j)本身的值)。在這個狀態定義下,原問題的解是d(1,1)

下面看看不同狀態之間是如何轉移的。從格子(i,j)出發有兩種決策。如果往左走,則走到(i+1,j)後需要求“從(i+1,j)”出發後能得到的最大和“這一問題,即d(i+1,j)。類似得,往右走之後需要求解d(i+1,j+1)。由於可以在這兩個決策中自由選擇,所以應選擇d(i+1,j)和d(i+1,j+1)中較大的一個,換句話說,得到了所謂的狀態轉移方程:

d(i,j) = a(i,j) + max{d(i+1,j),d(i+1,j+1)}

如果往左走,那麼最好情況等於(i,j)格子裡的值a(i,j)與”從(i+1,j)出發的最大總和“之和,此時需注意這裡的最大二字。如果連”從(i+1,j)出發走到底部“這部分的和都不是最大的,加上a(i,j)之後肯定也不是最大的。這個性質稱為最優子結構,也可以描述成全域性最優解包含區域性最優解。不管怎樣,狀態和狀態轉移方程一起完整的描述了具體的演算法。

int a[100][100],n,d[100][100];

第一種方法是遞迴計算(需注意邊界處理)。

int solve1(int i,int j)
{
    /* 遞迴 (重複計算,效率低) O(2^n)
    把當前位置(i,j)看成一個狀態 d[i][j]為從格子出發能得到的最大和 解為d[1][1]
    d(i,j)=a(i,j) +max {d(i+1,j),d(i+1,j+1)} */
    return a[i][j]+(i==n? 0 : max(solve1(i+1,j),solve1(i+1,j+1)));
}

這樣做是正確的,但時間效率太低,其原因在於重複計算。在solve(1,1)對應的呼叫關係樹中,solve(3,2)被計算了兩次(一次是solve(2,1)需要的,一次是solve(2,2)需要的)。也許讀者會認為重複算一兩個數沒有太大影響,但事實是:這樣的重複不是單個結點,而是一顆子樹。如果原來的三角形有n層,則呼叫關係樹也會有n層,一共有2^{n}-1個結點。

遞推計算(需再次注意邊界處理):

int solve2()
{
    /*遞推 (逆序列舉) O(n^2)
    i是逆序列舉的,計算d[i][j]前 所需要的d[i+1][j]
    和d[i+1][j+1]一定計算出來了*/
    int i,j;
    for(j=1;j<=n;j++)
        d[n][j]=a[n][j];
    for(i=n-1;i>=1;i--)
        for(j=1;j<=i;j++)
           d[i][j]=a[i][j]+max(d[i+1][j],d[i+1][j+1]);
    return d[1][1];
}

程式的時間複雜度顯然是O(n^{2}),但為什麼可以這樣計算呢?原因在於:i是逆序列舉的,因此在計算d[i][j]前,它所需要的d[i+1][j]和d[i+1][j+1]一定已經計算出來了。

可以用遞推法計算狀態轉移方程。遞推的關鍵是邊界和計算順序。在多數情況下,遞推法的時間複雜度是:狀態總數 * 每個狀態的決策個數 * 決策時間。如果不同狀態的決策個數不同,需具體問題具體分析。

記憶化搜尋。程式分成兩部分。首先用 memset(d,-1,sizeof(d)); 把d全部初始化為-1,然後編寫遞迴函式。

int solve3(int i,int j)
{
    //記憶化搜尋 O(n^2)
     //題目中所說的各個數都是非負的 因此如果計算過某個d[i][j] 則應該非負
    if(d[i][j]>=0) //已經計算過
        return d[i][j];
    return d[i][j]=a[i][j]+(i==n? 0 : max(solve1(i+1,j),solve1(i+1,j+1)));
}

上述程式仍然是遞迴的,但同時也把計算結果儲存在陣列d中。題目中所各個數都是非負的,因此如果已經計算過某個d[i][j],則它應是非負的。這樣,只需把所有d初始化為-1,即可通過判斷是否d[i][j]>=0得知它是否已經被計算過。

最後,千萬不要忘記把計算之後把它儲存在d[i][j]中,根據c語言 賦值語句本身有返回值的規定,可以把儲存d[i][j]的工作合併到函式的返回語句中。

上述程式的方法稱為記憶化,它雖然不像遞推法那樣顯式地指明瞭計算順序,然仍然可以保證每個結點只訪問一次。

由於i和j都在1-n之間,所有不相同的結點一共只有O(n^{2})個。無論以怎樣的順序訪問,時間複雜度均為O(n^{2})。從2^{n}-n^{2}是一個巨大的優化,這正是利用了數字三角形具有大量重疊子問題的特點。