動態規劃

开始优化發表於2024-04-04

什麼是動態規劃

基本概念

動態規劃過程是:每次決策依賴於當前狀態,又隨即引起狀態的轉移。一個決策序列就是在變化的狀態中產生出來的,所以,這種多階段最優化決策解決問題的過程就稱為動態規劃。動態規劃是一種被廣泛用於求解組合最優化問題的演算法。

演算法思想

演算法思想與分治法類似,也是將待求解的問題分解為若干個子問題(階段),按順序求解子階段,前一子問題的解,為後一子問題的求解提供了有用的資訊。在求解任一子問題時,列出各種可能的區域性解,通過決策保留那些有可能達到最優的區域性解,丟棄其他區域性解。依次解決各子問題,最後一個子問題就是初始問題的解。
由於動態規劃解決的問題多數有重疊子問題這個特點,為減少重複計算,對每一個子問題只解一次,將其不同階段的不同狀態儲存在一個二維陣列中。

與分治法的差別

適合於用動態規劃法求解的問題,經分解後得到的子問題往往不是互相獨立的(即下一個子階段的求解是建立在上一個子階段的解的基礎上,進行進一步的求解)。

適用的情況

能採用動態規劃求解的問題的一般要具有3個性質:

  • 最優化原理:如果問題的最優解所包含的子問題的解也是最優的,就稱該問題具有最優子結構,即滿足最優化原理。

  • 無後效性:即某階段狀態一旦確定,就不受這個狀態以後決策的影響。也就是說,某狀態以後的過程不會影響以前的狀態,只與當前狀態有關。

  • 有重疊子問題:即子問題之間是不獨立的,一個子問題在下一階段決策中可能被多次使用到。(該性質並不是動態規劃適用的必要條件,但是如果沒有這條性質,動態規劃演算法同其他演算法相比就不具備優勢

最長公共子序列問題

問題描述

給定兩個長度為n和m的字串A和B,確定A和B中最長公共子序列的長度。

解決思路

  • 傳統演算法 一種傳統的方式是使用蠻力搜尋的方法,列舉A所有的2^n個子序列對於每一個子序列子在senta(m)時間內來確定它是否也是B的子序列。該演算法的時間複雜性是senta(m2^n),是指數複雜性的。

  • 動態規劃演算法 尋找一個求最長公共子序列的遞推公式,令A=a_1a_2a_3….a_n和B=b_1b_2b_3…b_m,令L[i,j]表示a_1a_2_3…a_i和b_1b_2b_3…b_j的最長公共子序列的長度,則就有當i和j都大於0的時候,如果a_i=b_j,則L[i,j]=L[i-1,j-1]+1,反之,如果a_i!=b_j,則L[i,j]=max(L[i-1,j],L[i,j-1])所以就有以下遞推公式:

L[i,j]=0 i==0||j==0

L[i,j]=L[i-1,j-1]+1 i,j>0&&a_i==b_j

L[i,j]=max(L[i-1,j],L[i,j-1]) i,j>0&&a_i=b_j

程式碼實現

輸入A和B字串,返回二者的最長子序列長度

int Lcs(char *A, int n, char *B, int m) {//A[0...n] B[0...m]
    int L[n + 1][m + 1];
    for (int i = 0; i <= n; ++i) {
        L[i][0] = 0;
    }
    for (int j = 0; j <= m; ++j) {
        L[0][j] = 0;
    }

    for (int k = 1; k <= n; ++k) {
        for (int i = 1; i <= m; ++i) {
            if (A[k] == B[i])L[k][i] = L[k - 1][i - 1] + 1;
            else L[k][i] = L[k][i - 1] > L[k - 1][i] ? L[k][i - 1] : L[k - 1][i];
        }
    }

    return L[n][m];
}

注意,以上演算法需要的空間複雜度是senta(mn),但是因為計算表中每一項的計算僅僅需要其上一行和上一列的元素,所以對演算法進行改進可以使得空間複雜度降為senta(min(m,n)) (準確來說是需要2min(m,n)的空間,僅僅將前一行和當前行儲存下來即可)。

結論

最長公共子序列問題的最優解能夠在senta(mn)時間和senta(min(m,n))空間內計算得到。

矩陣鏈相乘

問題描述

給定一個n個矩陣的序列⟨A1,A2,A3…An⟩,我們要計算他們的乘積:A1A2A3…AnA1A2A3…An,由於矩陣乘法滿足結合律,加括號不會影響結果,但是不同的加括號方法,演算法複雜度有很大的差別:
考慮矩陣鏈⟨A1,A2,A3⟩,三個矩陣規模分別為10×100、100×5、5×50:

  • 按((A1A2)A3)方式,需要做10∗100∗5=5000次,再與A3相乘,又需要10∗5∗50=2500,共需要7500次運算;
  • 按(A1(A2A3))方式計算,共需要100∗5∗50+10∗100∗50=75000次標量乘法

以上兩種不同的加括號方式具有10倍的差別,可見一個好的加括號方式,對計算效率有很大影響。

解決思路

使用一個長度為n+1的一維陣列p來記錄每個矩陣的規模,其中n為矩陣下標,i的範圍1~n,例如對於矩陣Ai而言,它的規模應該是p[i-1]×p[i]。由於i是從1到n取值,所以陣列p的下標是從0到n。

用於儲存最少乘法執行次數和最佳分段方式的結構是兩個二維陣列m和s,都是從1~n取值。m[i][j]記錄矩陣鏈< Ai,Ai+1,…,Aj>的最少乘法執行次數,而s[i][j]則記錄 最優質m[i][j]的分割點k。

需要注意的一點是當i=j時,m[i][j]=m[i][i]=0,因為一個矩陣不需要任何乘法。

假設矩陣鏈從Ai到Aj,有j-i+1個矩陣,我們從k處分開,將矩陣鏈分為Ai~Ak和Ak+1到Aj兩塊,那麼我們可以比較容易的給出m[i][j]從k處分隔的公式:

   m[i][j]=m[i][k]+m[k+1][j]+p[i-1]*p[k]*p[j];

在一組確定的i和j值的情況下,要使m[i][j]的值最小,我們只要在所有的k取值中(i<=k< j),尋找一個讓m[i][j]最小的值即可。

假設L為矩陣鏈的長度,那麼L=j-i+1。當L=1時,只有一個矩陣,不需要計算。那麼我們可以從L=2到n進行迴圈,對每個合理的i和j值的組合,遍歷所有k值對應的m[i][j]值,將最小的一個記錄下來,儲存到m[i][j]中,並將對應的k儲存到s[i][j]中,就得到了我們想要的結果。

程式碼

/*
 * 輸入:ms[1...n+1],ms[i]表示第i個矩陣的行數,ms[i+1]表示第i個矩陣的列數
 * 輸出:n個矩陣的數量乘法的最小次數
 */

int dp[1024][1024] = { 0 };

struct Matrix {
    int row;
    int column;
};

int matrixChainCost(Matrix *ms, int n) {
    for (int scale = 2; scale <= n; scale++) {
        for (int i = 0; i <= n - scale; i++) {
            int j = i + scale - 1;
            dp[i][j] = INT_MAX;
            for (int k = i; k < j; k++) {
                dp[i][j] = std::min(dp[i][j], dp[i][k] + dp[k+1][j] + (ms[i].row*ms[k].column*ms[j].column));
            }
        }
    }
    return dp[0][n - 1];
}

複雜度分析

  • 時間複雜度:senta(n^3)
  • 空間複雜度:senta(n^2)

所有點對的最短路徑問題

問題描述

設G是一個有向圖,其中每條邊(i, j)都有一個非負的長度L[i, j],若點i 到點j 沒有邊相連,則設L[i, j] = ∞. 找出每個頂點到其他所有頂點的最短路徑所對應的長度。
例如:
這裡寫圖片描述

則 L: 0  2  9

    8  0  6

    1  ∞  0

解決思路(Floyd演算法)

Floyd演算法(所有點對最短路徑)就是每對可以聯通的頂點之間總存在一個藉助於其他頂點作為媒介而達到路徑最短的最短路徑值(這個值通過不斷增添的媒介頂點而得到更新,也可能不更新——通過媒介的路徑並不比其原路徑更短),所有的值儲存於鄰接矩陣中,這是典型的動態規劃思想。

值得注意的是,Floyd演算法本次的狀態的獲取只用到了上個階段的狀態,而沒有用到其他階段的狀態,這就為壓縮空間奠定了條件。

Floyd演算法能夠成功的關鍵之一就是D0(初始矩陣,即權重矩陣)的初始化,凡是不相連線的邊必須其dij必須等於正無窮且dii=0(矩陣對角線上的元素!) 

程式碼實現

/*
 * 輸入:n×n維矩陣l[1...n,1...n],對於有向圖G=({1,2,...n},E)中的邊(i,j)的長度為l[i,j]
 * 輸出:矩陣D,使得D[i,j]等於i到j的距離
 * l矩陣需要滿足:l[i,i]=0,對於m-->n沒有直接連線的有向邊(因為是有向圖,只考慮單邊),應有l[m,n]=INT.MAX(即無窮)
 *
 */

void Floyd(int **l,int n){
    int **d= reinterpret_cast<int **>(new int[n + 1][n + 1]);
    for (int i = 1; i <=n ; ++i) {
        for (int j = 1; j <=n ; ++j) {
            d[i][j]=l[i][j];
        }
    }

    for (int k = 1; k <=n ; ++k) {
        for (int i = 1; i <=n ; ++i) {
            for (int j = 1; j <=n ; ++j) {
                d[i][j]=min(d[i][j],d[i][k]+d[k][j]);
            }

        }
    }

}

複雜度分析

演算法的執行時間是senta(n^3)
演算法的空間複雜性是senta(n^2)

揹包問題

問題描述

設U={u1,u2,u3…un}是一個準備放入容量為C的揹包中的n項物品的集合。我們要做的是從U中拿出若干物品裝入揹包C,要求這些物品的總體積不超過C,但是要求裝入揹包的物品總價值最大。

解決思路

有 n 種物品,物品 i 的體積為 v[i], 價值為 p[i]. 假定所有物品的體積和價格都大於 0, 以及揹包的體積為 V.
mp[x][y] 表示體積不超過 y 且可選前 x 種物品的情況下的最大總價值
那麼原問題可表示為 mp[n][V]。
遞迴關係:

遞迴式 解釋
mp[0][y] = 0 表示體積不超過 y 且可選前 0 種物品的情況下的最大總價值,沒有物品可選,所以總價值為 0
mp[x][0] = 0 表示體積不超過 0 且可選前 x 種物品的情況下的最大總價值,沒有物品可選,所以總價值為 0
當 v[x] > y 時,mp[x][y] = mp[x-1][y] 因為 x 這件物品的體積已經超過所能允許的最大體積了,所以肯定不能放這件物品, 那麼只能在前 x-1 件物品裡選了
當 v[x] <= y 時,mp[x][y] = max{ mp[x-1][y], p[x] + mp[x-1][y-v[x]] } x 這件物品可能放入揹包也可能不放入揹包,所以取前兩者的最大值就好了, 這樣就將前兩種情況都包括進來了

程式碼

/*
 * 輸入:物品集合U={u1,u2,u3...un},體積為s1,s2,s3...sn,價值為v1,v2,v3...vn,容量為C的揹包
 * 輸出:滿足條件的最大價值
 *
 */
int Knapsack(int *s,int *v,int C,int n){
    int V[n+1][C+1];//V[i][j]表示從前i項找出的裝入體積為j揹包的最大值
    for (int i = 0; i <=n ; ++i) {
        V[i][0]=0;
    }
    for (int j = 0; j <=C ; ++j) {
        V[0][j]=0;
    }

    for (int k = 1; k <=n ; ++k) {
        for (int i = 1; i <=C ; ++i) {
            if(s[k]<=i){
                V[k][i]=max(V[k-1][i],V[k-1][i-s[k]]+v[k]);
            }
        }
    }

    return V[n][C];
}

演算法複雜度

揹包問題的最優解可以在senta(nC)時間內和senta(C)空間內解得。
注意,上述演算法的時間複雜性對輸入不是多項式的,但是它的執行時間關於輸入值是多項式的(時間複雜性+其他耗費時間),故認為它是偽多項式時間演算法。

相關文章