算法分析與設計:動態規劃

1、動態規劃

動態規劃算法通常用於求解具有某種最優性質的問題。這類問題通常會有許多可行解,每個解對應一個值,而我們希望在可行解中找到最優解

(1) 基本思想

動態規劃的基本思想是將待求問題分解爲若干個子問題,先求解子問題,再從子問題得到原問題的解。與分治法不同,動態規劃適合於分解後的子問題非獨立的情況。動態規劃用一個記錄已解決子問題的答案,可以避免大量的重複計算

在LeetCode做題時碰到很多”自上而下“型的動態規劃算法。這種算法的基本思路與“自下而上”的動態規劃是一致的,區別在於分析問題的角度與得出答案的方式。自上而下型從原問題出發,逐級向下計算所需子問題的解,通常使用遞歸;自下而上型從最小的子問題出發,逐級向上構造較大問題的解,通常使用迭代或遞推。

(2) 基本步驟

  1. 找出最優解的性質,並刻畫其結構特徵
  2. 遞歸地定義最優值(動態規劃方程);
  3. 自下而上的方式計算出最優值;
  4. 根據計算最優值過程得到的信息,構造最優解

(3) 問題特徵

  1. 最優子結構:問題的最優解包含了其子問題的最優解。
  2. 重疊子問題:每次產生的子問題並不總是新問題。

2、動態規劃典型問題

(1) 矩陣連乘問題

● 問題描述

普通方法下,矩陣連續相乘的運算次數與矩陣相乘的順序有關。
假設有n個矩陣,爲A1A2…An,其中Ai維數爲 Pi-1 × Pi;試確定這n個矩陣相乘所需的最少相乘次數。

● 問題分析

m[i,j] 表示對從Ai到Aj的最少相乘次數,那麼該次數必然可以分解爲兩個部分相乘次數之和,再加上兩個部分合併的代價。設k爲將矩陣Ai到Aj的切分位置,其中i ≤ k ≤ j。遍歷所有的k並找到最少的相乘次數,就是m[i,j]的值。而每一個k所切分出的Ai到Ak與Ak+1到Aj,也應當是這部分的最少相乘次數m[i,k]m[k+1,j]
由此,該問題具有如上的最優子結構性質,根據該性質可以對問題求解。

● 建立方程

由上面的分析,可以寫出動態規劃方程
矩陣連乘問題的動態規劃方程

● 計算最優值

根據動態規劃方程,可以設計對應算法計算最優值。
大致步驟爲:首先將i=j,也即單個矩陣的情況最優值設爲0;然後逐漸向上計算多個矩陣的情況,最後得到n個矩陣的情況。
對於n個矩陣A1A2…An,其最優值爲m[1][n]。

//p儲存矩陣的維數;m存儲矩陣Ai到Aj的最小乘積次數;s存儲最小乘積次數對應的切斷點。
void minMetrixChain(int n,int* p,int** m,int** s)  //  動態規劃,自下而上求所有子集的乘積最小次數
{
    for(int i = 0;i <= n;i++){
        m[i][i] = 0;    //令對角線元素爲0
    }
    for(int r = 1;r <= n - 1;r++){          //i與j的差,最大爲n-1
        for(int i = 1;i <= n - r;i++){      //i從1開始求m和s的元素
            int j = i + r;
            m[i][j] = m[i+1][j] + p[i-1]*p[i]*p[j]; //初值,切在i位置,即第一項單獨計算,後面r-i項一起算
            s[i][j] = i;    //初值記錄s
            for(int k = i + 1;k <j;k++){    //切在k位置,i~k爲一組,k+1~j爲一組
                int t = m[i][k] + m[k+1][j] + p[i-1]*p[k]*p[j];
                if(t < m[i][j]){
                    m[i][j] = t;
                    s[i][j] = k;
                }
            }
        }
    }
}
● 構造最優解

上一步計算最優值的過程已經得到了構造最優解的所需信息。由此設計算法構造最優解:

//s中儲存了分割點的信息
void traceBack(int i,int j,int **s)
{
    if(i == j)
        return;
    traceBack(i,s[i][j],s);
    traceBack(s[i][j]+1,j,s);
    cout <<"Multiply A" << i << "," << s[i][j] << " and A" << s[i][j] + 1 << "," << j << endl;
}

(2) 最長公共子序列(LCS)

● 問題描述

子序列:對於X1X2…Xn,有一個序列Xi…Xj的下標嚴格遞增,則稱後一序列爲前一序列的子序列。
子序列不要求連續,但要求子序列內的元素順序必須在原序列中有序。
試求兩個序列X=x1x2…xm和Y=y1y2…yn最長公共子序列。這一問題又稱爲LCS問題

● 問題分析

設Z=z1z2…zk是X和Y的最長公共子序列,則LCS具有如下最優子結構性質

  1. 若xm=yn,則Zk-1是Xm-1和Yn-1的最長公共子序列;
  2. 若xm≠yn,且zk≠xm,則Z是Xm-1和Y的最長公共子序列;
  3. 若xm≠yn,且zk≠yn,則Z是X和Yn-1的最長公共子序列。
● 建立方程

c[i,j]爲Xi與Yj的最長公共子序列長度,根據上面的性質,可以得到動態規劃方程
LCS的動態規劃方程

● 計算最優值

根據動態規劃方程,可以設計算法計算最優值。
大致步驟爲:令c[i][0]和c[0][j]均爲0,再根據方程,用兩循環嵌套逐步向上計算LCS值。

void longestCommonString(int *X,int n,int *Y,int m,int **lcsNum,int **lcsWay)
{
    //初始化將空序列與任意序列的最大子序列設爲無
    for(int i = 1;i <= n;i++)
        lcsNum[i][0] = 0;
    for(int i = 1;i <= m;i++)
        lcsNum[0][i] = 0;
    //根據遞推公式構造
    for(int i = 1;i <=n;i++){
        for(int j = 1;j <= m;j++){
            if(X[i] == Y[j]){
                lcsNum[i][j] = lcsNum[i-1][j-1] + 1;
                lcsWay[i][j] = 1;
            }
            else if(lcsNum[i-1][j] > lcsNum[i][j-1]){
                lcsNum[i][j] = lcsNum[i-1][j];
                lcsWay[i][j] = 2;
            }
            else{
                lcsNum[i][j] = lcsNum[i][j-1];
                lcsWay[i][j] = 3;
            }
        }
    }
}
● 構造最優解

上面計算最優值的過程已經得到了構造最優解的所需信息。

//lcsWay存有LCS的構造路徑。
void printLCS(int n,int m,int **lcsNum,int **lcsWay,int *X)
{
    if(n == 0 || m == 0)    return;
    if(lcsWay[n][m] == 1){
        printLCS(n-1,m-1,lcsNum,lcsWay,X);
        cout << X[n];
    }
    else if(lcsWay[n][m] == 2)
        printLCS(n-1,m,lcsNum,lcsWay,X);
    else if(lcsWay[n][m] == 3)
        printLCS(n,m-1,lcsNum,lcsWay,X);
}

(3) 0-1揹包問題

● 問題描述

給定一個物品集合s ={1,2,3,…,n},物品i的重量是wi,價值是vi,揹包的容量爲W。在限定的重量內,怎麼裝物品才能使物品的總價最大。
當物品允許拆分時,稱爲揹包問題,適用貪心算法
當物品不允許拆分時,稱爲0-1揹包問題,需要使用動態規劃
下面討論0-1揹包問題的求解。

● 問題分析

用數學語言描述該問題,就是找到一個s的子集s‘滿足:
0-1揹包問題數學表達
前式稱爲目標函數,後式稱爲約束方程

● 建立方程

p[i,j] 表示在可選物品爲s’={i,i+1…n}和剩餘容量爲j的情況下,對應的最大價值即最優值。
對於任意的p[i,j],都有如下關係:

  1. 當wi>j時,無法裝入物品i,因此最優值一定爲p[i+1,j];
  2. 當wi<j時,可以裝入物品,此時最優值可能爲p[i+1,j-wi]+vi,需要取較大值。

由此得到動態規劃方程
0-1揹包問題的動態規劃方程

● 計算最優值

由動態規劃方程,可以設計算法計算最優值:

//n爲物品總數,c爲最大容量,w儲存物品重量、v儲存物品價值、p用於遞推
void knapSack(int n,int c,int *w,int *v,int **p)
{
    //jMax表示物品能否存入的邊界情況;防止出現c < w[i]的情況造成越界
    int jMax = min(w[n]-1,c);
    //對小於w[n]的j
    for(int j = 0;j <= jMax;j++)
        p[n][j] = 0;
    //對大於等於w[n]的j
    for(int j = w[n];j <= c;j++)
        p[n][j] = v[n];
    
    //由動態規劃方程遞推
    for(int i = n - 1;i > 0;i--){
        jMax = min(w[i]-1,c);
        for(int j = 0;j <= jMax;j++)
            p[i][j] = p[i+1][j];
        for(int j = w[i];j <= c;j++)
            p[i][j] = max(p[i+1][j],p[i+1][j-w[i]]+v[i]);
    }
}
● 構造最優解

計算最優值的過程已經獲得了構造最優解的所需信息。

//x儲存物品的選擇情況;0爲未選擇,1爲已選擇
void traceBack(int c,int n,int* w,int *v,int **p,int *x)
{
    for(int i = 1;i < n;i++){
        if(p[i][c] == p[i+1][c])
            x[i] = 0;
        else{
            x[i] = 1;
            c -= w[i];
        }
    }
    x[n] = p[n][c] == 0 ? 0 : 1;
}

動態規劃在不同的問題中,難度也有變化。在刷題時,發現難題的動態規劃簡直看答案都看不明白。這個算法還需要多多體會、多多學習。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章