常見的動態規划問題分析與求解


  動態規划(Dynamic Programming,簡稱DP),雖然抽象后進行求解的思路並不復雜,但具體的形式千差萬別,找出問題的子結構以及通過子結構重新構造最優解的過程很難統一,並不像回溯法具有解決絕大多數問題的銀彈(全面解析回溯法:算法框架與問題求解)。為了解決動態規划問題,只能靠多練習、多思考了。本文主要是對一些常見的動態規划題目的收集,希望能有所幫助。難度評級受個人主觀影響較大,僅供參考。

 

目錄(點擊跳轉)

動態規划求解的一般思路

備忘錄法

1.硬幣找零

  擴展1:單路取蘋果

  擴展2:裝配線調度

2.字符串相似度/編輯距離(edit distance)

  應用1:子串匹配

  應用2:最長公共子序列

3.最長公共子序列(Longest Common Subsequence,lcs)

  擴展1:輸出所有lcs

  擴展2:通過LCS獲得最長遞增自子序列

4.最長遞增子序列(Longest Increasing Subsequence,lis)

  擴展:求解lis的加速

5.最大連續子序列和/積

  擴展1:正浮點數數組求最大連續子序列積

  擴展2:任意浮點數數組求最大連續子序列積

6.矩陣鏈乘法

  擴展:矩陣鏈乘法的備忘錄解法(偽碼)

7.0-1背包問題

8.有代價的最短路徑

9.瓷磚覆蓋(狀態壓縮DP)

10.工作量划分

11.三路取蘋果

參考資料

附錄1:其他的一些動態規划問題與解答(鏈接)

附錄2:《算法設計手冊》第八章 動態規划 面試題解答

 

動態規划求解的一般思路: 

  判斷問題的子結構(也可看作狀態),當具有最優子結構時,動態規划可能適用。

  求解重疊子問題。一個遞歸算法不斷地調用同一問題,遞歸可以轉化為查表從而利用子問題的解。分治法則不同,每次遞歸都產生新的問題。

  重新構造一個最優解。

 

備忘錄法:

  動態規划的一種變形,使用自頂向下的策略,更像遞歸算法。

  初始化時表中填入一個特殊值表示待填入,當遞歸算法第一次遇到一個子問題時,計算並填表;以后每次遇到時只需返回以前填入的值。

  實例可以參照矩陣鏈乘法部分。 

 

1.硬幣找零

難度評級:★

  假設有幾種硬幣,如1、3、5,並且數量無限。請找出能夠組成某個數目的找零所使用最少的硬幣數。 

解法:

  用待找零的數值k描述子結構/狀態,記作sum[k],其值為所需的最小硬幣數。對於不同的硬幣面值coin[0...n],有sum[k] = min(sum[k-coin[0]] , sum[k-coin[1]], ...)+1。對應於給定數目的找零total,需要求解sum[total]的值。

typedef struct {
    int nCoin; //使用硬幣數量
    //以下兩個成員是為了便於構造出求解過程的展示
    int lastSum;//上一個狀態
    int addCoin;//從上一個狀態達到當前狀態所用的硬幣種類
} state;
state *sum = malloc(sizeof(state)*(total+1));

//init
for(i=0;i<=total;i++) 
    sum[i].nCoin = INF;
sum[0].nCoin = 0;
sum[0].lastSum = 0;

for(i=1;i<=total;i++)
    for(j=0;j<n;j++)
        if(i-coin[j]>=0 && sum[i-coin[j]].nCoin+1<sum[i].nCoin)
        {
            sum[i].nCoin = sum[i-coin[j]].nCoin+1;
            sum[i].lastSum = j;
            sum[i].addCoin = coin[j];
        }

    if(sum[total].nCoin == INF) 
    {
        printf("can't make change.\n");
        return 0;
    }
    else
        //output
    ;

  通過sum[total].lastSum和sum[total].addCoin,很容易通過循環逆序地或者編寫遞歸調用的函數正序地輸出從結束狀態到開始狀態使用的硬幣種類。以下各題輸出狀態轉換的方法同樣,不再贅述。下面為了方便起見,有的題沒有在構造子結構的解時記錄狀態轉換,如果需要請類似地完成。

 

擴展:

(1)一個矩形區域被划分為N*M個小矩形格子,在格子(i,j)中有A[i][j]個蘋果。現在從左上角的格子(1,1)出發,要求每次只能向右走一步或向下走一步,最后到達(N,M),每經過一個格子就把其中的蘋果全部拿走。請找出能拿到最多蘋果數的路線。

難度評級:★

分析:

  這道題中,當前位置(i,j)是狀態,用M[i][j]來表示到達狀態(i,j)所能得到的最多蘋果數,那么M[i][j] = max(M[i-1][j],M[i][j-1]) + A[i][j] 。特殊情況是M[1][1]=A[1][1],當i=1且j!=1時,M[i][j] = M[i][j-1] + A[i][j];當i!=1且j=1時M[i][j] = M[i-1][j] + A[i][j]。

  求解程序略。

 

(2)裝配線調度(《算法導論》15.1)

難度評級:★

  

  

2.字符串相似度/編輯距離(edit distance)

難度評級:★

  對於序列S和T,它們之間距離定義為:對二者其一進行幾次以下的操作(1)刪去一個字符;(2)插入一個字符;(3)改變一個字符。每進行一次操作,計數增加1。將S和T變為同一個字符串的最小計數即為它們的距離。給出相應算法。

解法:

  將S和T的長度分別記為len(S)和len(T),並把S和T的距離記為m[len(S)][len(T)],有以下幾種情況:

如果末尾字符相同,那么m[len(S)][len(T)]=m[len(S)-1][len(T)-1];

如果末尾字符不同,有以下處理方式

  修改S或T末尾字符使其與另一個一致來完成,m[len(S)][len(T)]=m[len(S)-1][len(T)-1]+1;

  在S末尾插入T末尾的字符,比較S[1...len(S)]和S[1...len(T)-1];

  在T末尾插入S末尾的字符,比較S[1...len(S)-1]和S[1...len(T)];

  刪除S末尾的字符,比較S[1...len(S)-1]和S[1...len(T)];

  刪除T末尾的字符,比較S[1...len(S)]和S[1...len(T)-1];

  總結為,對於i>0,j>0的狀態(i,j),m[i][j] = min( m[i-1][j-1]+(s[i]==s[j])?0:1 , m[i-1][j]+1, m[i][j-1] +1)。

  這里的重疊子結構是S[1...i],T[1...j]。

  以下是相應代碼。考慮到C語言數組下標從0開始,做了一個轉化將字符串后移一位。

#include <stdio.h>
#include <string.h>
#define MAXLEN 20
#define MATCH 0
#define INSERT 1
#define DELETE 2

typedef struct {
    int cost;
    int parent;
} cell;

cell m[MAXLEN+1][MAXLEN+1];

int match(char a,char b)
{
    //cost of match
    //match:    0
    //not match:1
    return (a==b)?0:1;
}

int string_compare(char *s,char *t)
{
    int i,j,k;
    int opt[3];
    
    //row_init(i);
    for(i=0;i<=MAXLEN;i++) {
        m[i][0].cost = i;
        if(i==0)
            m[i][0].parent = -1;
        else
            m[i][0].parent = INSERT;
    }

    //column_init(i);
    for(i=0;i<=MAXLEN;i++) {
        m[0][i].cost = i;
        if(i==0)
            continue;
        else
            m[0][i].parent = INSERT;
    }
    
    char m_s[MAXLEN+1] = " ",m_t[MAXLEN+1] =" ";
    strcat(m_s,s);
    strcat(m_t,t);

    for(i=1;i<=strlen(s);i++)
    {
        for(j=1;j<=strlen(t);j++) {
            opt[MATCH] = m[i-1][j-1].cost + match(m_s[i],m_t[j]);
            opt[INSERT] = m[i][j-1].cost + 1;
            opt[DELETE] = m[i-1][j].cost + 1;
            m[i][j].cost = opt[MATCH];
            m[i][j].parent = MATCH;
            for(k=INSERT;k<=DELETE;k++)
                if(opt[k]<m[i][j].cost)
                {
                    m[i][j].cost = opt[k];
                    m[i][j].parent = k;
                }
        }
    }
    i--,j--;
    //goal_cell(s,t,&i,&j);
    return m[i][j].cost;
}

int main() {
    char t[] = "you should not";
    char p[] = "thou shalt not";

    int n = string_compare(t,p);
    printf("%d\n",n);
}
字符串相似度/edit distance

 

應用:

  (1)子串匹配

  難度評級:★★

  修改兩處即可進行子串匹配:

row_init(int i)
{
    m[0][i].cost = 0; /* note change */
    m[0][i].parent = -1; /* note change */
}

goal_cell(char *s, char *t, int *i, int *j)
{
    int k; /* counter */
    *i = strlen(s) - 1;
    *j = 0;
    for (k=1; k<strlen(t); k++)
        if (m[*i][k].cost < m[*i][*j].cost) *j = k;
}
修改部分

  如果j= strlen(S) - strlen(T),那么說明T是S的一個子串。

  (這部分是根據《算法設計手冊》8.2.4和具體實例Skiena與Skienaa、Skiena與somta的分析獲得的,解釋不夠全面,可能有誤,請注意)

 

  (2)最長公共子序列

  難度評級:★★

  將match時不匹配的代價轉化為最大長度即可:

int match(char c, char d)
{
    if (c == d) return(0);
    else return(MAXLEN);
}
match()

  此時,最小值是兩者不同部分的距離。

  (這部分同樣也不好理解,對於最長公共子序列,建議直接使用下一部分中的解法)

 

擴展:

  如果在編輯距離中各個操作的代價不同,如何尋找最小代價? 

3.最長公共子序列(Longest Common Subsequence,lcs)

難度評級:★

  對於序列S和T,求它們的最長公共子序列。例如X={A,B,C,B,D,A,B},Y={B,D,C,A,B,A}則它們的lcs是{B,C,B,A}和{B,D,A,B}。求出一個即可。

解法:

  和2類似,對於X[1...m]和Y[1...n],它們的任意一個lcs是Z[1...k]。

  (1)如果X[m]=Y[n],那么Z[k]=X[m]=Y[n],且Z[1...k-1]是X[1...m-1]和Y[1...n-1]的一個lcs;

  (2)如果X[m]!=Y[n],那么Z[k]!=X[m]時Z是X[1...m-1]和Y的一個lcs;

  (3)如果X[m]!=Y[n],那么Z[k]!=Y[n]時Z是X和Y[1...n-1]的一個lcs;

  下面是《算法導論》上用偽碼描述的lcs算法。其中c[i][j]記錄當前lcs長度,b[i][j]記錄到達該狀態的上一個狀態。

 

擴展1:

  如何輸出所有的LCS?

難度評級:★

分析:

  根據上面c[i,j]和b[i,j]的構造過程可以發現如果c[i-1,j]==c[i,j-1],那么分別向上和向左返回的上一個狀態都是可行的。如果將其標記為“左/上”並通過遞歸調用來生成從c[m,n]到c[1,1]的所有路徑,就能找出所有的LCS。時間復雜度上界為O(mn)。

 

擴展2:

  通過LCS獲得最長遞增自子序列。

分析:

  對於1個序列,如243517698,最大值9,最小值1,那么通過將它與123456789求LCS得到的就是最長連續遞增子序列23568。

  這種做法不適用於最長連續非遞減子序列,除非能獲得重復最多的元素數目,如2433517698,那么可以用112233445566778899與之比較。

  使用專門的最長遞增子序列算法可以進行優化,詳見下一部分。

 

4.最長遞增子序列(Longest Increasing Subsequence,lis)

難度評級:★

   對於一個序列如1,-1,2,-3,4,-5,6,-7,其最長第增子序列為1,2,4,6。

解法:

  除了利用3中lcs來求解,這里使用求解lis問題的專門方法。

  先看看如何確定子結構的表示。對於長度為k的序列s[1...k],如果用lis[k]記錄這個序列中最長子序列似乎沒什么用,因為在構造lis[k+1]時,需要比較s[k]與前面長度為lis[k]的lis的最后一個元素、s[1...k]中長度為lis[k]-1的序列的最后一個元素等等,沒有提供什么便利,這個方案被否決。

  為了將每個lis[k]轉化為構造lis[k+1]時有用的數據,把子結構記為以s[k]為結尾的lis的長度,那么對於s[k+1],需要檢查所有在它前面且小於它的元素s[i],並令lis[k+1] = max(lis[i]+1),(i=1 to k,s[k+1]>s[i])。這樣,一個O(n2)的算法便寫成了。為了在處理完成后不必再一次遍歷lis[1...n],可以使用一個MaxLength變量保存當前記錄中最長的lis。

 

typedef struct {
    int length;
    int prev;
} state;

//算法核心
state *a = malloc(sizeof(state) * n);
for(i=0;i<n;i++) {
    a[i].length = 1;
    a[i].prev = -1;
}

for(i=1;i<n;i++)
    for(j=1;j<i;j++)
        if(array[i]>array[j] && a[i].length < a[j].length + 1)
        {
            a[i].length = a[j].length + 1;
            a[i].prev = j;
            if(a[i].length > max_length) {
                max_length = a[i].length;
                max_end = i;
            }
        }

 

 

擴展:

  求解lis的加速

 難度評級:★★

分析:

  在構造lis[k+1]的時候可以發現,對於s[k+1],真正有用的元素s[i]<s[k+1]且lis[i]最大。如果記錄了不同長度的lis的末尾元素,那么對於新加入的元素s[k+1],找出前面比它小的且對應lis最長的,就是以s[k+1]為結尾的lis[k+1]的長度。

  可以發現使用數組MaxV[1...MAXLENGTH]其中MaxV[i]表示長度為i的lis的最小末尾元素,完全可以在s[k+1]時進行lis[k+1]的更新。進一步地發現,其實lis[]數組已經沒有用了,對於MaxV[1...MAXLENGTH]中值合法對應的最大下標,就是當前最長的lis,也即利用MaxV[]更新自身。

  同時,根據MaxV[]的更新過程,可以得出當i<j時,MaxV[i]<MaxV[j](假設出現了i>j且Max[i]=>Max[j]的情況,那么在之前的處理中,在發現j長度的lis時,應用它的第i個元素來更新Max[i],仍會導致MaxV[i]<MaxV[j],這與這個現狀發生了矛盾,也即這個情況是不可能到達的)。這樣,在尋找小於s[k+1]的值時,可以使用二分查找,從而把時間復雜度降低至O(nlogn)

 

int lis_ologn(int *array, int length) {
    int i, left,right,mid,max_len = 1;
    int *MaxV;
    if(!array)
        return 0;
    MaxV = (int*)malloc(sizeof(int)*(length+1));
    MaxV[0] = -1;
    MaxV[1] = array[0];

    for(i=1;i<length;i++){
        //尋找范圍是MaxV[1, ... , max_len]
        left = 1;
        right = max_len;
        //二分查找MaxV中第一個大於array[i]的元素
        while(left<right) {
            mid = (left+right)/2;
            if(MaxV[mid]<=array[i])
                left = mid + 1;
            else if(MaxV[mid]>array[i])
                right = mid;
        }
        if((MaxV[right]>array[i])&&(MaxV[right-1]<array[i]))
            MaxV[right] = array[i];
        else if (MaxV[right]<array[i]) {
            MaxV[right+1] = array[i];
            max_len++;
        }
    }
    return max_len;
}

   在這個解法下,不妨考慮如何重構這個lis。

 

5.最大連續子序列和/積

難度評級:★

  輸入是具有n個數的向量x,輸出時輸入向量的任何連續子向量的最大和。

解法:

  求和比較簡單,以前寫過比較全面的分析:http://www.cnblogs.com/wuyuegb2312/p/3139925.html#title4

  這里只把O(n)的動態規划解法列在下面,其中只用一個變量保存過去的狀態

int max_array_v4(int *array,int length) {
    int i;
    int maxsofar = NI;
    int maxendinghere = 0;
    for(i=0;i<length;i++) {
        maxendinghere = maxnum(maxendinghere + array[i],array[i]);
        //分析:maxendinghere必須包含array[i]
        //當maxendinghere>0且array[i]>0,maxendinghere更新為兩者和
        //當maxendinghere>0且array[i]<0,maxendinghere更新為兩者和
        //當maxendinghere<0且array[i]<0,maxendinghere更新為array[i]
        //當maxendinghere<0且array[i]>0,maxendinghere更新為array[i]
        maxsofar = maxnum(maxsofar,maxendinghere);
    }
    return maxsofar;
}

擴展1:

難度評級:★

  給定一個正浮點數數組,求它的一個最大連續子序列乘積的值。

解法:

  對數組中每個元素取對數,構成新的數列,在新的數列上使用求最大連續子序列的算法。

  如果求對數開銷較大,建議使用擴展2的方法。

 

擴展2:

難度評級:★

  給定一個浮點數數組,其值可正可負可零,求它的一個最大連續子序列乘積的值。(假定計算過程中,任意一個序列的積都不超過浮點數最大表示)

解法:

  在最大連續子序列和算法的基礎上進行修改。由於負負得正,對於當前狀態array[k],需要同時計算出它的最大值和最小值。即:

  new_maxendinghere = max3(maxendinghere*array[k],minendinghere*array[k],array[k])

  new_minendinghere = min3(maxendinghere*array[k],minendinghere*array[k],array[k])

  此后對已遍歷部分的最大積進行更新:

  maxsofar = max(maxsofar,new_maxendinghere)

  如果不習慣用常數個變量來表示,可以看看http://blog.csdn.net/wzy_1988/article/details/9319897,再想想用數組保存是不是浪費了空間。(計算max[k]、min[k]只用到了max[k-1]、min[k-1],沒有必要保存全部狀態)

 

6.矩陣鏈乘法

難度評級:★

  一個給定的矩陣序列A1A2...An計算連乘乘積,有不同的結合方法,並且在結合時,矩陣的相對位置不能改變,只能相鄰結合。根據矩陣乘法的公式,10*100和100*5的矩陣相乘需要做10*100*5次標量乘法。那么對於維數分別為10*100、100*5、5*50的矩陣A、B、C,用(A*B)*C來計算需要10*100*5 + 10*5*500 =7500次標量乘法;而A*(B*C)則需要100*5*50+10*100*50=75000次標量乘法。

  那么對於由n個矩陣構成的鏈<A1,A2,...,An>,對i-1,2,...n,矩陣Ai的維數為pi-1*pi,對乘積A1A2...An求出最小化標量乘法的加括號方案。

解法:

  盡管可以通過遞歸計算取1<=k<n使得P(n)=∑P(k)P(n-k),遍歷所有P(n)種方案,但這並不是一個高效率的解法。

  經過以上幾道題的鍛煉,很容易看出,子結構是求Ai...Aj括號方法m[i][j]可遞歸地定義為

\[m[i][j]=\left\{\begin{matrix} 0& if \ i=j\\ \underset{i\leqslant k<j}{min}\begin{Bmatrix} m[i][k] + & m[k+1][j] +& p_{i-1}p_{k}p_{j} \end{Bmatrix} & if \ i<j \end{matrix}\right.\]

  這樣,只需利用子結構求解m[1][n]即可,並在在構造m[1][n]的同時,記錄狀態轉換。下面的代碼展示了這個過程,不再仔細分析。

 

#include <stdio.h>
#include <stdlib.h>
#define ULT    2147483647

int print_optimal_parens(int **s,int i, int j)
{
    if (i==j)
        printf("A%d",i+1);
    else    {
        printf("(");
        print_optimal_parens(s,i,*(*(s+i)+j));
        print_optimal_parens(s,*(*(s+i)+j)+1,j);
        printf(")");
    }
    return 1;
}



int matrix_chain_order(int *p, int n) {
    int i,j,k,l,q;
    int **m, **s;
    m=(int **)malloc(n*sizeof(int*));
    for(i=0;i<n;i++)
        m[i]=(int*)malloc(n*sizeof(int));
    s=(int **)malloc(n*sizeof(int*));
    for(i=0;i<n;i++)
        s[i]=(int*)malloc(n*sizeof(int));

    for(i=0;i<n;i++)
        s[i][i] = 0;
//m,s可以被壓縮存儲在上三角矩陣

    for(l=2;l<=n;l++) {
        for (i=0;i<n-l+1;i++) {
            j = i+l-1;
            m[i][j] = ULT;
            for (k=i; k<j;k++)    {
                q = m[i][k] + m[k+1][j] + p[i-1+1]*p[k+1]*p[j+1];
                if (q<m[i][j])        {
                    m[i][j] = q;
                    s[i][j] = k;
                }
            }
        }
    }

/*display m[i][j]*/
//    for (i=0;i<n;i++) {
//        for (j=0;j<n;j++)
//            printf("%d ",m[i][j]);
//        printf("\n");
//    }
    print_optimal_parens(s,0,5);
    printf("\n");
    return 1;
}

int main() {
    int p[] = {30,35,15,5,10,20,25};
    int n;
    n = (sizeof(p)/sizeof(int))-1;
    matrix_chain_order(p,n);
    return 1;
}
矩陣鏈乘法

 

擴展:

  矩陣鏈乘法的備忘錄解法(偽碼),來自《算法導論》第15章。

 

7.0-1背包

難度評級:★★

  一個賊在偷竊一家商店時發現了n件物品,其中第i件值vi元,重wi磅。他希望偷走的東西總和越值錢越好,但是他的背包只能放下W磅。請求解如何放能偷走最大價值的物品,這里vi、wi、W都是整數。

解法:

  如果每個物品都允許切割並只帶走其一部分,則演變為部分背包問題,可以用貪心法求解。0-1背包問題經常作為貪心法不可解決的實例(可通過舉反例來理解),但可以通過動態規划求解。

  為了找出子結構的形式,粗略地分析發現,對前k件物品形成最優解時,需要決策第k+1件是否要裝入背包。但是此時剩余容量未知,不能做出決策。因此把剩余容量也考慮進來,形成的狀態由已決策的物品數目和剩余容量兩者構成。這樣,所有狀態可以放入一個n*(W+1)的矩陣c中,其值為當前包中物品總價值,這時有

\[c[i][j]=\left\{\begin{matrix} c[i-1][j]& if \ w_{i}>j\\ \max\begin{Bmatrix} c[i-1][j-w_{i}]+v_{i} \ ,\ c[i-1][j] \end{Bmatrix} & if \ w_{i}\leqslant j \end{matrix}\right.\]

  根據這個遞推公式,很容易寫出求解代碼。

#include <stdio.h>
#include <stdlib.h>

int package_dp(int *v,int *w,int n,int total) {
    int i,j,tmp1,tmp2;
    int **c = (int **)malloc((n+1)*sizeof(int *));
    for(i=0;i<n+1;i++)
        c[i]=(int *)malloc((total+1)*sizeof(int));
    for(i=0,j=0;j<total;j++)
        c[i][j] = 0;
    for(i=1;i<=n;i++) {
        c[i][0] = 0;
        for(j=1;j<=total;j++) {
            if(w[i]>j)
                c[i][j] = c[i-1][j];
            else {
                tmp1 = v[i]+c[i-1][j-w[i]];
                tmp2 = c[i-1][j];
                c[i][j]=(tmp1>tmp2?tmp1:tmp2);
            }
        }
    }
    printf("c[%d][%d]:%d\n",n,total,c[n][total]);
    return 0;
}

int main() {
    int v[] = {0,10,25,40,20,10};
    int w[] = {0,40,50,70,40,20};
    int total = 120;
    package_dp(v,w,sizeof(v)/sizeof(int)-1,total);
    return 0;
}
0-1背包問題示例代碼

 

8.有代價的最短路徑

難度評級:★★

  無向圖G中有N個頂點,並通過一些邊相連接,邊的權值均為正數。初始時你身上有M元,當走過i點時,需要支付S(i)元,如果支付不起表示不能通過。請找出頂點1到頂點N的最短路徑。如果不存在則返回一個特殊值,如果存在多條則返回最廉價的一條。限制條件:1<N<=100; 0<=M<=100 ; 對任意i, 0<=S[i]<=100。

解法:

  如果不考慮經過頂點時的花費,這就簡化成了一個一般的兩點間最短路徑問題,可以用Dijkstra算法求解。加入了花費限制之后,就不能直接求解了。

  考察從頂點0到達頂點i的不同狀態,會發現它們之間的區別是:總花費相同但路徑長度不同、總花費不同但路徑長度不同。為了尋找最短路徑,必然要保存到達i點的最短路徑;同時為了找到最小開銷,應該把到達i點的開銷也進行保存。根據題目的數值限制,可以將總開銷作為到達頂點i的一個狀態區分。這樣,就可以把Min[i][j]表示為到達頂點i(並經過付錢)時還剩余j元錢的最短路徑的長度。在此基礎上修改Dijkstra算法,使其能夠保存到達同一點不同花費時的最短長度,最終的Min[N-1][0...M]中最小的即為所求。以下是求解過程的偽代碼。

  

//初始化
對所有的(i,j),Min[i][j] = ∞,state[i][j] = unvisited;
Min[0][M] = 0;

while(1) {
    for 所有unvisited的(i,j)找出M[i][j]最小的,記為(k,l)
    if Min[k][l] =break;

    state[k][l] = visited;
    for 所有頂點k的鄰接點p
        if (l-S[p]>=0 && Min[p][1-S[p]]>Min[k][l]+Dist[k][p])
            Min[p][1-S[p]] = Min[k][l]+Dist[k][p];

    //通過Dijstra算法尋找不同花費下的最小路徑
}

for 所有j,找出Min[N-1][j]最小的
    如果存在多個j,那么選出其中j最大的

 

 

9.瓷磚覆蓋(狀態壓縮DP)

難度評級:★★

  用 1 * 2 的瓷磚覆蓋 n * m 的地板,問共有多少種覆蓋方式?

 解法:

  (啟發來自於:poj 2411 & 編程之美 4.2 瓷磚覆蓋地板,下文敘述做了點修改)

  分析子結構,按行鋪瓷磚。一塊1*2瓷磚,橫着放對下一行的狀態沒有影響;豎着放時,下一行的對應一格就會被占用。因此,考慮第i行的鋪法時只需考慮由第i-1行造成的條件限制。枚舉枚舉第i-1行狀態即可獲得i行可能的狀態,這里為了與鏈接一文一致,第i-1行的某格只有兩個狀態:空或者放置。空表示第i行對應位置需要放置一個豎着的瓷磚,這時在鋪第i行時,除去限制以外,只需考慮放還是不放橫着的瓷磚這2種情況即可(不必分為放還是不放、橫到下一層還是豎着一共4種)。同時對於第i-1行的放法,用二進制中0和1表示有無瓷磚,那么按位取反恰好就是第i行的限制條件。

 

//原作者:limchiang
//出處:http://blog.csdn.net/limchiang/article/details/8619611
#include <stdio.h>
#include <string.h>

/** n * m 的地板 */
int n,m;

/** dp[i][j] = x 表示使第i 行狀態為j 的方法總數為x */
__int64 dp[12][2049];

/* 該方法用於搜索某一行的橫向放置瓷磚的狀態數,並把這些狀態累加上row-1 行的出發狀態的方法數
 * @name row 行數 
 * @name state 由上一行決定的這一行必須放置豎向瓷磚的地方,s的二進制表示中的1 就是這些地方
 * @name pos 列數
 * @name pre_num row-1 行的出發狀態為~s 的方法數
 */ 
void dfs( int row, int state, int pos, __int64 pre_num )
{
    /** 到最后一列  */
    if( pos == m ){
        dp[row][state] += pre_num;
        return;
    }

    /** 該列不放 */
    dfs( row, state, pos + 1, pre_num );

    /** 該列和下一列放置一塊橫向的瓷磚 */
    if( ( pos <= m-2 ) && !( state & ( 1 << pos ) ) && !( state & ( 1 << ( pos + 1 ) ) ) )
        dfs( row, state | ( 1 << pos ) | ( 1 << ( pos + 1 ) ), pos + 2, pre_num );
}
int main()
{    
    while( scanf("%d%d",&n,&m) && ( n || m ) ){
        /** 對較小的數進行狀壓,已提高效率 */
        if( n < m ){
            n=n^m;
            m=n^m;
            n=n^m;
        }

        memset( dp, 0, sizeof( dp ) );

        /** 初始化第一行 */
        dfs( 1, 0, 0, 1 );

        for( int i = 2; i <= n; i ++ ) 
            for( int j = 0; j < ( 1 << m ); j ++ ){
                if( dp[i-1][j] ){
                    __int64 tmp = dp[i-1][j];

                    /* 如果i-1行的出發狀態某處未放,必然要在i行放一個豎的方塊,
                     * 所以我對上一行狀態按位取反之后的狀態就是放置了豎方塊的狀態
                     */
                    dfs( i, ( ~j ) & ( ( 1 << m ) - 1 ), 0, tmp ) ;
                }
                else continue;        
            }

        /** 注意並不是循環i 輸出 dp[n][i]中的最大值 */
        printf( "%I64d\n",dp[n][(1<<m)-1] ); 

    }
    return 0;
}

 

 

10.工作量划分

難度評級:★★

  假設書架上一共有9本書,每本書各有一定的頁數,分配3個人來進行閱讀。為了便於管理,分配時,各書要求保持連續,比如第1、2、3本書分配給第1人,4、5分配給第二人,6,7,8,9分配給第3人,但不能1,4,2分配給第1人,3,5,6分配給第2人。即用兩個隔板插入8個空隙中將9本書分成3部分,書不能換位。同時,分配時必須整本分配,同一本書不能拆成兩部分分給兩個人。為了公平起見,需要將工作量最大的那一部分最小化,請設計分配方案。用s1,...,sn表示各本書的頁數。

解法:

  繼續從子結構的角度出發,發現如果前面的k-1份已經分好了,那么第k份自然也就分好了。用M[n][k]表示將n本書分成k份時最小化的k份中的最大工作量,從第k份也就是最后一份的角度來看,總數-它的不同情況下數量 = 前k-1份的數量和。

\[M[n][k] = \overset{n}{\underset{i=1}{min}}\max(M[i][k-1],\sum_{j=i+1}^{n}s_{j})\]

   除此以外,初始化為

\[M[1][k] = s_{1},for \ all \ k>0\\ M[n][1] = \sum_{i=1}^{n}s_{1}\]

  自底向上地可以求得使M[n][k]最小化的解。

#include <stdio.h>
#define MAXN 9
#define MAXINT 32767

void print_books(int s[],int start,int end);
int reconstruct_partition(int s[],int d[MAXN+1][MAXN+1],int n,int k);
int max(int a,int b);

int max(int a,int b)
{
    if(a>b)
        return a;
    else
        return b;
}

int partition(int s[],int n,int k)
{
    int m[MAXN+1][MAXN+1];
    int d[MAXN+1][MAXN+1];
    int p[MAXN+1];
    int cost;
    int i,j,x;

    p[0] = 0;
    for(i=1;i<=n;i++)
        p[i] = p[i-1]+s[i-1];
    for(i=1;i<=n;i++)
        m[i][1] = p[i];
    for(j=1;j<=k;j++)
        m[1][j] = s[0];
    for(i=2;i<=n;i++)
        for(j=2;j<=k;j++)
        {
            m[i][j] = MAXINT;
            for(x=1;x<=(i-1);x++) 
            {
                cost = max(m[x][j-1],p[i]-p[x]);
                if(m[i][j]>cost) {
                    m[i][j] = cost;
                    d[i][j] = x;
                }
            }
        }
    reconstruct_partition(s,d,n,k);
}

int reconstruct_partition(int s[],int d[MAXN+1][MAXN+1],int n,int k)
{
    if(k==1)
        print_books(s,1,n);
    else
    {
        reconstruct_partition(s,d,d[n][k],k-1);
        print_books(s,d[n][k]+1,n);
    }
    return 0;
}

void print_books(int s[],int start,int end)
{
    int i;
    for(i=start;i<=end;i++)
        printf(" %d ",s[i-1]);
    printf("\n");
    return;
}

int main() {
    int a[] = {1,1,1,1,1,1,1,1,1};
    int b[] = {1,2,3,4,5,6,7,8,9};
    printf("first:\n");
    partition(a,9,3);
    printf("\nsecond:\n");
    partition(b,9,3);
    return 0;
}
工作量划分

 

其他:

  這個問題被稱為線性分割(linear partition)問題,有不少的應用情形。如,一系列任務分配給幾個並行進程,那么分配工作量最大任務的那個進程將成為影響最終完成時間的瓶頸。將最大的工作量盡量減少,能夠使所有工作更快地完成。

 

11.三次撿蘋果

難度評級:★★

  (問題1的相關問題(1)的進一步擴展)一個矩形區域被划分為N*M個小矩形格子,在格子(i,j)中有A[i][j]個蘋果。現在從左上角的格子(1,1)出發,要求每次只能向右走一步或向下走一步,每經過一個格子就把其中的蘋果全部拿走,最后到達(N,M)。此時,只允許向上或向左走一步,反方向走回(1,1)。這一趟可以不走第一趟的路線,但當經過第一趟所經過的格子時,里面已經沒有蘋果了。到達(1,1)后,再次反方向地只允許向右或向下走,走到(N,M),同樣可以不走前兩趟走過的路線。求這三趟的走法,使得最終能拿取最多數量的蘋果。

解法:

  這個問題有兩個難點,首先要理清三條路線的關系。可以發現,雖然第二趟方向相反,但其實和從(1,1)走到(N,M)是一樣的,即三趟路線全部可以轉化為從(1,1)向下或向右走到(N,M)的過程。
  觀察三條路線可以發現,實際中走的時候如果路線有交叉,可以把這種情況轉化為相遇而不交錯的情況如下圖:

這樣做的好處是,對於紅線和藍線上同一行j的點的坐標(i,j)(i',j),總有i<=i',這樣就能夠把三條路線划分成左、中、右三條有序的路線。

  經過兩次轉化,可以構造子結構了。用Max[y-1][i][j][k]表示在y-1行時,三條路線分別在i、j、k時所能取得的最大蘋果數,用Max[y-1][i][j][k]可以求解任意的Max[y][i'][j'][k'],其中i' = i to j' , j' = j to k', k' = k to M. 如果線路重疊,蘋果已經被取走,不用重復考慮。因此處理每一行時為了簡單起見最好維護一個該位置蘋果是否被取走的標志位,方便在路線重疊時計算。根據上面的范圍關系,先求k'的所有情況,然后是j',最后才是i'。這樣Max[N][M][M][M]就是三趟后所能取得的最多蘋果數。

 

參考資料

《算法導論》第15章動態規划、第16章貪心算法

《算法設計手冊》第八章動態規划

《編程珠璣》相關問題

《編程之美》相關問題

poj 2411 & 編程之美 4.2 瓷磚覆蓋地板

最大連續子序列乘積

Dynamic Programming: From novice to advanced

 

附錄1:其他的一些動態規划問題與解答(鏈接)

雙調歐幾里德旅行商問題(算法導論思考題15-1)

   評:網絡上的很多中文版本,都不如直接看這篇文章里的英文原版解答理解的清楚。

 

整齊打印(算法導論思考題15-4)

  評:難度不高,注意要求的是空格數的立方和最小。

 

動態規划應用:Viterbi算法

  評:需要一些馬爾科夫鏈的知識。理解起來不是很容易,理解以后是不是有種像是更多個生產線的裝備線調度?

 

最高效益的調度(算法導論思考題15-7)

  評:和0-1背包問題何其相似。

 

附錄2:《算法設計手冊》第八章 動態規划 面試題解答

8-24.

  給定一個硬幣種類的集合,找出湊出給定一個值所用的最小硬幣數目。

解答:

  正文問題1已做解答,略。

 

8-25.

  長度為n的數組,其中元素可正可負可零。找出數組索引i,j使得從i到j的元素之和最大。

解答:

  最大連續自序列和問題,請參考正文問題5的解答。

 

8-26.

  假設你有一頁紙,正面和背面都寫滿了字母,當剪掉正面上一個字母時,這一頁的背面的字母也會被剪掉。設計一個算法來驗證是否能通過這張紙生成一個給定的字符串?提供一個函數,當你輸入一個字母的位置時能夠返回它背面的字母。(敘述關鍵思路即可)

解答:

  目前我所看到的最容易理解的解法是使用最大流來解的:http://stackoverflow.com/questions/6135443/dynamic-programming-question

  下面把思路大意翻譯一下。

  假設需要拼成的字符串是"FOO",同時這張紙的正反面對應位置上的內容(可以通過提供的函數獲得)分別是:

位置 1 2 3 4
正面 F C O Z
反面 O O K Z

  由於位置4上的字母的正反面都用不到,忽略。

  把這個表格轉化成一個兩層結點的流量網絡

  其中源點下面第一層表示拼成所需字符串的所有字母,源點到達該點的流量是需要的數目。第一層與第二層相連接表示某一位置上對應的是哪個所需字母,比如位置1正反面分別是F和O,表示它能提供1個F的入度和1個O的入度,但又由於一個片紙無論正反面只能用一次,那么它只能提供1的出度,直接連接匯點。

  這個問題是否有解,等價於這個流量網絡中是否存在一個流使得源點的流的出度等於匯點流的的入度,即一個最大流問題。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM