應用動態規劃思想解決實際問題

li27z發表於2017-04-01

從概念和原理出發去學習某個知識點,往往有種晦澀、無從下手的感覺,本文列舉兩個示例,從實際應用的角度來理解“動態規劃”思想。

數字三角形問題

“數字三角形”是動態規劃的經典入門問題:

問題描述:
            7
          3   8
        8   1   0
      2   7   4   4
    4   5   2   6   5

給定一個數字三角形(例如上圖),尋找一條從頂部到底邊的路徑,使得路徑上所經過的數字之和最大。路徑上的每一步都只能往左下或右下走。只需要求出這個最大和即可,不必給出具體路徑。三角形的行數大於1且小於等於100,數字範圍為0-99。

輸入描述:第一行輸入三角形的行數,接下來輸入數字三角形
5            
7
3 8
8 1 0
2 7 4 4
4 5 2 6 5

輸出描述:輸出路徑的最大和
30

分析:我們用一個二維陣列來儲存數字三角形,其中D[i][j]表示第i行第j個數字(i, j從1開始算)。用MaxSum(i, j)表示從D[i][j]到底邊的各條路徑中最佳路徑的數字之和,所以本題即為求MaxSum(1, 1),是一個典型的遞迴問題。

1.遞迴
由題意,當從D[i][j]出發時,下一步的選擇只能是D[i + 1][j]或D[i + 1][j + 1]。所以,對於n行的三角形,可得到以下遞迴式:

// 如果D[i][j]就在底邊,則其MaxSum的值即為它本身
if(i == n)
    MaxSum(i, j) = D[i][j]
// 如果D[i][j]不在底邊,則其MaxSum的值為它左下和右下元素的MaxSum值的較大者加上它本身
else
    MaxSum(i, j) = Max{ MaxSum(i + 1, j), MaxSum(i + 1, j + 1) } + D[i][j]

由此可寫出“遞迴”思路的程式碼,如下:

#include <iostream>    
#include <algorithm>    
using namespace std;   

#define MAX 101 

int D[MAX][MAX];    
int n;    

int MaxSum(int i, int j)
{      
    if(i == n)    
        return D[i][j];      
    int x = MaxSum(i + 1, j);      
    int y = MaxSum(i + 1, j + 1);      
    return max(x, y) + D[i][j];    
}  

int main()
{      
    int i, j;      
    cin >> n;      
    for(i = 1; i <= n; i++)     
        for(j = 1; j <= i; j++)          
            cin >> D[i][j];      
    cout << MaxSum(1,1) << endl;
    return 0;    
} 

遞迴的思路簡單,但由於存在大量重複計算,時間複雜度很高(O(2^n))。例如下圖,我們從第二行的數字3和8出發,求其到底邊的最佳路徑之和時,均需要計算一次第三行數字1到底邊的最佳路徑之和,重複計算浪費了時間。

這裡寫圖片描述

2.記憶遞迴型的動態規劃:
怎麼降低上述方案的時間複雜度呢,我們可以想到每計算出一個MaxSum(i, j)就儲存起來,下次用到其值的時候直接取用,則可免去重複計算。這種做法的時間複雜度下降到了O(n^2)。程式碼如下:

#include <iostream>    
#include <algorithm>   
using namespace std;  

#define MAX 101  

int D[MAX][MAX];      
int n;    
int maxSum[MAX][MAX];  

int MaxSum(int i, int j)
{  
    // 如果該maxSum值存在則直接返回,不再進行重複計算      
    if( maxSum[i][j] != -1 )           
        return maxSum[i][j];        
    if(i==n)     
        maxSum[i][j] = D[i][j];       
    else{      
        int x = MaxSum(i+1, j);         
        int y = MaxSum(i+1, j+1);         
        maxSum[i][j] = max(x, y)+ D[i][j];       
    }       
    return maxSum[i][j];   
}   
int main()
{      
    int i,j;      
    cin >> n;      
    for(i = 1; i <= n; i++)     
        for(j = 1; j <= i; j++) {         
            cin >> D[i][j]; 
            // 還未計算maxSum值時,初始化為-1        
            maxSum[i][j] = -1;     
        }      
    cout << MaxSum(1,1) << endl;
    return 0;   
}   

3.遞迴轉為遞推
如果不採用遞迴的方法,怎麼解決這個問題呢?此時我們需要將遞迴轉為遞推——由已知推出未知:從最底層開始,一層層依次向上遞推求每個數字的MaxSum值,過程如下圖:

這裡寫圖片描述

這種方法稱為“人人為我”型遞推動態規劃(時間複雜度為O(n^2)),程式碼如下:

#include <iostream>    
#include <algorithm>   
using namespace std;   

#define MAX 101    

int D[MAX][MAX];     
int n;    
int maxSum[MAX][MAX];  

int main()
{      
    int i, j;      
    cin >> n;      
    for(i = 1; i <= n; i++)     
        for(j = 1; j <= i; j++)          
            cin >> D[i][j];   
    // 為第n行的maxSum值賦值  
    for(int i = 1; i <= n; ++i)       
        maxSum[n][i] = D[n][i];
    // 依次向上類推出每個數字的maxSum值     
    for(int i = n - 1; i >= 1;  --i)       
        for(int j = 1; j <= i; ++j)           
            maxSum[i][j] = max(maxSum[i + 1][j], maxSum[i + 1][j + 1]) + D[i][j];      
    cout << maxSum[1][1] << endl;   
    return 0; 
}   

另外,在此基礎上,我們還可以從空間複雜度上去優化程式碼(時間複雜度不變):我們沒必要用二維陣列來儲存每一個maxSum值,只用一維陣列maxSum[100]儲存一行MaxSum值就行了,如下圖:

這裡寫圖片描述

這樣,我們所求的最佳路徑數字之和即為maxSum[1]。

進一步考慮,連一維maxSum陣列都可以不要,直接用D的第n行替代maxSum即可,程式碼如下:

#include <iostream>    
#include <algorithm>   
using namespace std;   

#define MAX 101    

int D[MAX][MAX];    
int n;   
int *maxSum;   

int main()
{      
    int i,j;      
    cin >> n;      
    for(i = 1; i <= n; i++)     
        for(j = 1; j <= i; j++)          
            cin >> D[i][j];     
    maxSum = D[n]; //maxSum指向第n行      
    for(int i = n - 1; i >= 1;  --i)       
        for(int j = 1; j <= i; ++j)         
            maxSum[j] = max(maxSum[j], maxSum[j+1]) + D[i][j];      
    cout << maxSum[1] << endl;   
    return 0; 
}  

以上就是關於“數字三角形”問題的討論,從中我們可以總結出動態規劃解題的一般思路

(1)將原問題分解為子問題
子問題與原問題形式相似,只不過規模變小了,子問題都解決了,原問題就解決了。“數字三角形”問題中的原問題即為求頂端到底邊的最大路徑和,它的子問題則為求頂端的左下和右下元素到底邊路徑的較大者。

(2)確定狀態
“狀態”是指和子問題相關的各個變數的一組取值,例如“數字三角形”中位置(i, j)對應的數字到底邊的最大路徑和就為一個狀態。

(3)確定一些初始狀態的值
以“數字三角形”為例,初始狀態就是底邊數字,值就是底邊數字值。

(4)確定狀態轉移方程
定義出什麼是“狀態”,以及在該“狀態”下的“值”後,就要找出不同的狀態之間如何遷移,即如何從一個或多個“值”已知的 “狀態”,求出另一個“狀態”的“值”(遞推型動態規劃)。

下面我們嘗試運用這種思路來解決“構造迴文問題”。

構造迴文問題

(騰訊2017暑期實習生程式設計題)

問題描述:
給定一個字串s,你可以從中刪除一些字元,使得剩下的串是一個迴文串。如何刪除才能使得迴文串最長呢?輸出需要刪除的字元個數。

輸入描述:輸入資料有多組,每組包含一個字串s,且保證字串的長度大於等於1且小於等於100。

輸出描述:對於每組資料,輸出一個整數,代表最少需要刪除的字元個數。

輸入例子:
abcda
google

輸出例子:
2
2

分析
(1)我們需要利用迴文串的特點:源字串逆轉後,迴文串(不一定連續)相當於順序沒變;
(2)利用動態規劃的思想求源字串和逆轉字串的最大公共子序列(非子串)的長度;
(3)字串的總長度減去最大公共子序列的長度即為最少需要刪除的字元個數。

解題思路
運用動態規劃的一般思路分析如何求解字串的最大公共子序列(LCS)

(1)將原問題分解為子問題
設字串s1=<x1,x2,…,xm>,s2=<y1,y2,…,yn>
原問題:求字串s1、s2的最大公共子序列長度。
子問題:由最大公共子序列的性質,若xm == yn,則需進一步解決子問題s1’=<x1,x2,…,xm-1>和s2’=<y1,y2,…,yn-1>的最大公共子序列長度;若xm != yn,則需進一步解決子問題s1’=<x1,x2,…,xm-1>和s2=<y1,y2,…,y>的最大公共子序列長度與s1=<x1,x2,…,xm>和s2’=<y1,y2,…,yn-1>的最大公共子序列長度的較大值。

(2)確定狀態
用一個二維陣列MaxLen儲存兩個字串的最大公共子序列長度,“狀態”MaxLen[i][j](i,j均從1開始)表示s1左邊i個字元與s2左邊j個字元的最大公共子序列長度。

(3)確定一些初始狀態的值
MaxLen[i][j] = 0 (當序列s1的長度為0或s2的長度為0)

(4)確定狀態轉移方程
MaxLen[i][j]的狀態轉移關係如下:

// 若s1第i個字元與s2第j個字元相匹配
if(s1[i - 1] == s2[j - 1])
    MaxLen[i][j] = MaxLen[i - 1][j - 1] + 1
// 若s1第i個字元與s2第j個字元不匹配
else
    MaxLen[i][j] = max(MaxLen[i - 1][j], MaxLen[i][j - 1])

程式碼實現如下:

#include <iostream>
#include <string>
#include <algorithm>
using namespace std;

const int MAX = 101;
int MaxLen[MAX][MAX]; 

int maxLen(string s1, string s2)
{
    int length1 = s1.size();
    int length2 = s2.size();
    for (int i = 0; i < length1; ++i)
        MaxLen[i][0] = 0;
    for (int i = 0; i < length2; ++i)
        MaxLen[0][i] = 0;

    for (int i = 1; i <= length1; ++i)
    {
        for (int j = 1; j <= length2; ++j)
        {
            if (s1[i-1] == s2[j-1])
            {
                MaxLen[i][j] = MaxLen[i-1][j - 1] + 1;
            }
            else
            {
                MaxLen[i][j] = max(MaxLen[i - 1][j], MaxLen[i][j - 1]);
            }
        }
    }
    return MaxLen[length1][length2];
}

int main()
{
    string s;
    while (cin >> s)
    {
        int length = s.size();
        if (length == 1)
        {
            cout << 1 << endl;
            continue;
        }
        // 利用迴文串的特點
        string s2 = s;
        reverse(s2.begin(), s2.end());
        int max_length = maxLen(s, s2);
        cout << length - max_length << endl;
    }
    return 0;
}

總結

我們在討論“數字三角形”問題時,用到了遞迴和遞推的思路,但是,我們需要知道:

動態規劃的本質不在於是遞推或是遞迴,也不需要糾結是不是記憶體換時間。動態規劃的本質,是對問題狀態的定義和狀態轉移方程的定義,是通過拆分問題,定義問題狀態和狀態之間的關係,使得問題能夠以遞推(或者說分治)的方式去解決。

在網上看到了這張解釋動態規劃的趣圖:
這裡寫圖片描述

它反映了可用動態規劃解決的問題的特點:一個階段的最優可以由前一個階段的最優得到。具體來說:
(1)問題具有最優子結構性質。如果問題的最優解所包含的子問題的解也是最優的,我們就稱該問題具有最優子結構性質。
(2)無後效性。當前的若干個狀態值一旦確定,則此後過程的演變就只和這若干個狀態的值有關,和之前是採取哪種手段或經過哪條路徑演變到當前的這若干個狀態,沒有關係。


參考資料:
教你徹底學會動態規劃——入門篇. http://blog.csdn.net/baidu_28312631/article/details/47418773
視訊講解 - http://v.youku.com/v_show/id_XODkxMDg0OTUy.html
動態規劃解最長公共子序列問題. http://blog.csdn.net/yysdsyl/article/details/4226630/
https://www.zhihu.com/question/23995189/answer/35429905

相關文章