算法——動態規劃(分治遞歸)

前言

本文翻譯自TopCoder上的一篇文章: Dynamic Programming: From novice to advanced ,並非嚴格逐字逐句翻譯,其中加入了自己的一些理解。水平有限,還望指摘。

我們遇到的問題中,有很大一部分可以用動態規劃(簡稱DP)來解。 解決這類問題可以很大地提升你的能力與技巧,我會試着幫助你理解如何使用DP來解題。 這篇文章是基於實例展開來講的,因爲乾巴巴的理論實在不好理解。

簡介

什麼是動態規劃,我們要如何描述它?

動態規劃算法通常基於一個遞推公式及一個或多個初始狀態。 當前子問題的解將由上一次子問題的解推出。使用動態規劃來解題只需要多項式時間複雜度, 因此它比回溯法、暴力法等要快許多。

現在讓我們通過一個例子來了解一下DP的基本原理。
首先,我們要找到某個狀態的最優解,然後在它的幫助下,找到下一個狀態的最優解。
“狀態”代表什麼及如何找到它?
“狀態”用來描述該問題的子問題的解。原文中有兩段作者闡述得不太清楚,跳過直接上例子。

入門 最少硬幣數

如果我們有面值爲1元、3元和5元的硬幣若干枚,如何用最少的硬幣湊夠11元? 
(表面上這道題可以用貪心算法,但貪心算法無法保證可以求出解,比如1元換成2元的時候)

首先我們思考一個問題,如何用最少的硬幣湊夠i元(i<11)?爲什麼要這麼問呢? 兩個原因:1.當我們遇到一個大問題時,總是習慣把問題的規模變小,這樣便於分析討論。 2.這個規模變小後的問題和原來的問題是同質的,除了規模變小,其它的都是一樣的, 本質上它還是同一個問題(規模變小後的問題其實是原問題的子問題)。

好了,讓我們從最小的i開始吧。
當i=0,即我們需要多少個硬幣來湊夠0元。 由於1,3,5都大於0,即沒有比0小的幣值,因此湊夠0元我們最少需要0個硬幣。 (這個分析很傻是不是?彆着急,這個思路有利於我們理清動態規劃究竟在做些什麼。) 這時候我們發現用一個標記來表示這句“湊夠0元我們最少需要0個硬幣。”會比較方便, 如果一直用純文字來表述,不出一會兒你就會覺得很繞了。那麼, 我們用d(i)=j來表示湊夠i元最少需要j個硬幣。於是我們已經得到了d(0)=0, 表示湊夠0元最小需要0個硬幣。
當i=1時,只有面值爲1元的硬幣可用, 因此我們拿起一個面值爲1的硬幣,接下來只需要湊夠0元即可,而這個是已經知道答案的, 即d(0)=0。所以,d(1)=d(1-1)+1=d(0)+1=0+1=1。
當i=2時, 仍然只有面值爲1的硬幣可用,於是我拿起一個面值爲1的硬幣, 接下來我只需要再湊夠2-1=1元即可(記得要用最小的硬幣數量),而這個答案也已經知道了。 所以d(2)=d(2-1)+1=d(1)+1=1+1=2。一直到這裏,你都可能會覺得,好無聊, 感覺像做小學生的題目似的。因爲我們一直都只能操作面值爲1的硬幣!耐心點, 讓我們看看i=3時的情況。
當i=3時,我們能用的硬幣就有兩種了:1元的和3元的( 5元的仍然沒用,因爲你需要湊的數目是3元!5元太多了親)。 既然能用的硬幣有兩種,我就有兩種方案。如果我拿了一個1元的硬幣,我的目標就變爲了: 湊夠3-1=2元需要的最少硬幣數量。即d(3)=d(3-1)+1=d(2)+1=2+1=3。 這個方案說的是,我拿3個1元的硬幣;第二種方案是我拿起一個3元的硬幣, 我的目標就變成:湊夠3-3=0元需要的最少硬幣數量。即d(3)=d(3-3)+1=d(0)+1=0+1=1. 這個方案說的是,我拿1個3元的硬幣。好了,這兩種方案哪種更優呢? 記得我們可是要用最少的硬幣數量來湊夠3元的。所以, 選擇d(3)=1,怎麼來的呢?具體是這樣得到的:d(3)=min{d(3-1)+1, d(3-3)+1}。

OK,碼了這麼多字講具體的東西,讓我們來點抽象的。從以上的文字中, 我們要抽出動態規劃裏非常重要的兩個概念:狀態和狀態轉移方程。

上文中d(i)表示湊夠i元需要的最少硬幣數量,我們將它定義爲該問題的”狀態”, 這個狀態是怎麼找出來的呢?我在另一篇文章 動態規劃之揹包問題(一)中寫過: 根據子問題定義狀態。你找到子問題,狀態也就浮出水面了。 最終我們要求解的問題,可以用這個狀態來表示:d(11),即湊夠11元最少需要多少個硬幣。 那狀態轉移方程是什麼呢?既然我們用d(i)表示狀態,那麼狀態轉移方程自然包含d(i), 上文中包含狀態d(i)的方程是:d(3)=min{d(3-1)+1, d(3-3)+1}。沒錯, 它就是狀態轉移方程,描述狀態之間是如何轉移的。當然,我們要對它抽象一下,

d(i)=min{ d(i-vj)+1 },其中i-vj >=0,vj表示第j個硬幣的面值;

有了狀態和狀態轉移方程,這個問題基本上也就解決了。當然了,Talk is cheap,show me the code!

僞代碼如下:
這裏寫圖片描述
下圖是當i從0到11時的解:
這裏寫圖片描述
從上圖可以得出,要湊夠11元至少需要3枚硬幣。

此外,通過追蹤我們是如何從前一個狀態值得到當前狀態值的, 可以找到每一次我們用的是什麼面值的硬幣。比如,從上面的圖我們可以看出, 最終結果d(11)=d(10)+1(面值爲1),而d(10)=d(5)+1(面值爲5),最後d(5)=d(0)+1 (面值爲5)。所以我們湊夠11元最少需要的3枚硬幣是:1元、5元、5元。

int min2(int a, int b)
{
    return a>b ? b:a;
}

int min3(int a, int b, int c)
{
    int tmp;
    return (tmp = a>b ? b:a) > c ? c:tmp;
}

int d(int i)  //湊夠i元最少需要多少個硬幣
{
    if(i == 0)
        return 0;
    else if(i>=1 && i<3)  //只能選擇1元的硬幣
    {
        return 1+d(i-1);
    }
    else if(i>=3 && i<5)  //可以選擇1元的或3元的硬幣
    {
        return min2(1+d(i-1),1+d(i-3));
    }
    else if(i>=5)  // 1 3 5都可以選擇
    {
        return min3(1+d(i-1), 1+d(i-3), 1+d(i-5));
    }
}

初級 最長非降子序列的長度

上面討論了一個非常簡單的例子。現在讓我們來看看對於更復雜的問題, 如何找到狀態之間的轉移方式(即找到狀態轉移方程)。 爲此我們要引入一個新詞叫遞推關係來將狀態聯繫起來(說的還是狀態轉移方程)

OK,上例子,看看它是如何工作的。

一個序列有N個數:A[1],A[2],…,A[N],求出最長非降子序列的長度。 (講DP基本都會講到的一個問題LIS:longest increasing subsequence)

正如上面我們講的,面對這樣一個問題,我們首先要定義一個“狀態”來代表它的子問題, 並且找到它的解。注意,大部分情況下,某個狀態只與它前面出現的狀態有關, 而獨立於後面的狀態。

讓我們沿用“入門”一節裏那道簡單題的思路來一步步找到“狀態”和“狀態轉移方程”。 假如我們考慮求A[1],A[2],…,A[i]的最長非降子序列的長度,其中i

#include <iostream>
using namespace std;

int lis(int A[], int n)
{
    int *d = new int[n];
    int len = 1;
    for(int i=0; i<n; ++i)
    {
        d[i] = 1;
        for(int j=0; j<i; ++j)
            if(A[j]<=A[i] && d[j]+1>d[i])
                d[i] = d[j] + 1;
        if(d[i]>len) len = d[i];
    }
    delete[] d;
    return len;
}
int main()
{
    int A[] = {
        5, 3, 4, 8, 6, 7
    };
    cout<<lis(A, 6)<<endl;
    return 0;
}

該算法的時間複雜度是O(n2 ),並不是最優的解法。 還有一種很巧妙的算法可以將時間複雜度降到O(nlogn),網上已經有各種文章介紹它, 這裏就不再贅述。傳送門: LIS的O(nlogn)解法。 此題還可以用“排序+LCS”來解,感興趣的話可自行Google。

練習題

無向圖G有N個結點(1<N<=1000)及一些邊,每一條邊上帶有正的權重值。 找到結點1到結點N的最短路徑,或者輸出不存在這樣的路徑。

提示:在每一步中,對於那些沒有計算過的結點, 及那些已經計算出從結點1到它的最短路徑的結點,如果它們間有邊, 則計算從結點1到未計算結點的最短路徑。

中級 二維DP問題 走格子問題

接下來,讓我們來看看如何解決二維的DP問題

例:平面上有N*M個格子,每個格子中放着一定數量的蘋果。你從左上角的格子開始, 每一步只能向下走或是向右走,每次走到一個格子上就把格子裏的蘋果收集起來, 這樣下去,你最多能收集到多少個蘋果。

解這個問題與解其它的DP問題幾乎沒有什麼兩樣。第一步找到問題的“狀態”, 第二步找到“狀態轉移方程”,然後基本上問題就解決了。

首先,我們要找到這個問題中的“狀態”是什麼?我們必須注意到的一點是, 到達一個格子的方式最多隻有兩種:從左邊來的(除了第一列)和從上邊來的(除了第一行)。 因此爲了求出到達當前格子後最多能收集到多少個蘋果, 我們就要先去考察那些能到達當前這個格子的格子,到達它們最多能收集到多少個蘋果。 (是不是有點繞,但這句話的本質其實是DP的關鍵:欲求問題的解,先要去求子問題的解)

經過上面的分析,很容易可以得出問題的狀態和狀態轉移方程。 狀態****S[i][j]表示我們走到(i, j)這個格子時,最多能收集到多少個蘋果。那麼, 狀態轉移方程如下:

    S[i][j]=A[i][j] + max(S[i-1][j], if i>0 ; S[i][j-1], if j>0)

其中i代表行,j代表列,下標均從0開始;A[i][j]代表格子(i, j)處的蘋果數量。

S[i][j]有兩種計算方式:1.對於每一行,從左向右計算,然後從上到下逐行處理;2. 對於每一列,從上到下計算,然後從左向右逐列處理。 這樣做的目的是爲了在計算S[i][j]時,S[i-1][j]和S[i][j-1]都已經計算出來了。

僞代碼如下:
這裏寫圖片描述

中高級 最短路徑

這一節要討論的是帶有額外條件的DP問題
以下的這個問題是個很好的例子。

無向圖G有N個結點,它的邊上帶有正的權重值。你從結點1開始走,並且一開始的時候你身上帶有M元錢。如果你經過結點i, 那麼你就要花掉S[i]元(可以把這想象爲收過路費)。如果你沒有足夠的錢, 就不能從那個結點經過。在這樣的限制條件下,找到從結點1到結點N的最短路徑。 或者輸出該路徑不存在。如果存在多條最短路徑,那麼輸出花錢數量最少的那條。 限制:1<N<=100 ; 0<=M<=100 ; 對於每個i,0<=S[i]<=100;

正如我們所看到的, 如果沒有額外的限制條件(在結點處要收費,費用不足還不給過),那麼, 這個問題就和經典的迪傑斯特拉問題一樣了(找到兩結點間的最短路徑)。 在經典的迪傑斯特拉問題中, 我們使用一個一維數組來保存從開始結點到每個結點的最短路徑的長度, 即M[i]表示從開始結點到結點i的最短路徑的長度。然而在這個問題中, 我們還要保存我們身上剩餘多少錢這個信息。因此,很自然的, 我們將一維數組擴展爲二維數組。M[i][j]表示從開始結點到結點i的最短路徑長度, 且剩餘j元。通過這種方式,我們將這個問題規約到原始的路徑尋找問題。
在每一步中,對於已經找到的最短路徑,我們找到它所能到達的下一個未標記狀態(i,j), 將它標記爲已訪問(之後不再訪問這個結點),並且在能到達這個結點的各個最短路徑中, 找到加上當前邊權重值後最小值對應的路徑,即爲該結點的最短路徑。 (寫起來真是繞,建議畫個圖就會明瞭很多)。不斷重複上面的步驟, 直到所有的結點都訪問到爲止(這裏的訪問並不是要求我們要經過它, 比如有個結點收費很高,你沒有足夠的錢去經過它,但你已經訪問過它) 最後Min[N-1][j]中的最小值即是問題的答案(如果有多個最小值, 即有多條最短路徑,那麼選擇j最大的那條路徑,即,使你剩餘錢數最多的最短路徑)。
僞代碼:
這裏寫圖片描述

高級

以下問題需要仔細的揣摩才能將其規約爲可用DP解的問題。

問題:StarAdventure - SRM 208 Div 1:

給定一個M行N列的矩陣(M*N個格子),每個格子中放着一定數量的蘋果。 你從左上角的格子開始,只能向下或向右走,目的地是右下角的格子。 你每走過一個格子,就把格子上的蘋果都收集起來。然後你從右下角走回左上角的格子, 每次只能向左或是向上走,同樣的,走過一個格子就把裏面的蘋果都收集起來。 最後,你再一次從左上角走到右下角,每過一個格子同樣要收集起裏面的蘋果 (如果格子裏的蘋果數爲0,就不用收集)。求你最多能收集到多少蘋果。注意:當你經過一個格子時,你要一次性把格子裏的蘋果都拿走。限制條件:1 < N, M <= 50;每個格子裏的蘋果數量是0到1000(包含0和1000)。

如果我們只需要從左上角的格子走到右下角的格子一次,並且收集最大數量的蘋果, 那麼問題就退化爲“中級”一節裏的那個問題。將這裏的問題規約爲“中級”裏的簡單題, 這樣一來會比較好解。讓我們來分析一下這個問題,要如何規約或是修改才能用上DP。
首先,對於第二次從右下角走到左上角得出的這條路徑, 我們可以將它視爲從左上角走到右下角得出的路徑,沒有任何的差別。 (即從B走到A的最優路徑和從A走到B的最優路徑是一樣的)通過這種方式, 我們得到了三條從頂走到底的路徑。對於這一點的理解可以稍微減小問題的難度。 於是,我們可以將這3條路徑記爲左,中,右路徑。對於兩條相交路徑(如下圖):
這裏寫鏈接內容
在不影響結果的情況下,我們可以將它們視爲兩條不相交的路徑:
這裏寫圖片描述

這樣一來,我們將得到左,中,右3條路徑。此外,如果我們要得到最優解, 路徑之間不能相交(除了左上角和右下角必然會相交的格子)。因此對於每一行y( 除了第一行和最後一行),三條路徑對應的x座標要滿足:x1[y] < x2[y] < x3[y]。 經過這一步的分析,問題的DP解法就進一步地清晰了。讓我們考慮行y, 對於每一個x1[y-1],x2[y-1]和x3[y-1],我們已經找到了能收集到最多蘋果數量的路徑。 根據它們,我們能求出行y的最優解。現在我們要做的就是找到從一行移動到下一行的方式。 令Max[i][j][k]表示到第y-1行爲止收集到蘋果的最大數量, 其中3條路徑分別止於第i,j,k列。對於下一行y,對每個Max[i][j][k] 都加上格子(y,i),(y,j)和(y,k)內的蘋果數量。因此,每一步我們都向下移動。 我們做了這一步移動之後,還要考慮到,一條路徑是有可能向右移動的。 (對於每一個格子,我們有可能是從它上面向下移動到它, 也可能是從它左邊向右移動到它)。爲了保證3條路徑互不相交, 我們首先要考慮左邊的路徑向右移動的情況,然後是中間,最後是右邊的路徑。 爲了更好的理解,讓我們來考慮左邊的路徑向右移動的情況,對於每一個可能的j,k對(j

其它

當閱讀一個題目並且開始嘗試解決它時,首先看一下它的限制。 如果要求在多項式時間內解決,那麼該問題就很可能要用DP來解。遇到這種情況, 最重要的就是找到問題的“狀態”和“狀態轉移方程”。(狀態不是隨便定義的, 一般定義完狀態,你要找到當前狀態是如何從前面的狀態得到的, 即找到狀態轉移方程)如果看起來是個DP問題,但你卻無法定義出狀態, 那麼試着將問題規約到一個已知的DP問題(正如“高級”一節中的例子一樣)。

March 26, 2013
作者:Hawstein
出處:http://hawstein.com/posts/dp-novice-to-advanced.html
聲明:本文采用以下協議進行授權: 自由轉載-非商用-非衍生-保持署名|Creative Commons BY-NC-ND 3.0 ,轉載請註明作者及出處。

動態規劃之揹包問題

1、簡易揹包問題(無物件價值)
有一個揹包,能盛放的物品總重量爲S,設有N件物品,其重量分別爲w1,w2,…,wn,希望從N件物品中選擇若干物品,所選物品的重量之和恰能放進該揹包,即所選物品的重量之和即是S。遞歸和非遞歸解法都能求得“揹包題目”的一組解。
遞歸解法:

/*
*   首先嚐試將最後一件物品放入揹包,則物品減少一件,揹包可用體積相應減少,然後對當前狀態進行遞歸……
*   若有解則遞歸結束;若無解則拋棄最後一件物品,然後對當前狀態進行遞歸
*/
//s揹包體積n物品數量w物品重量
int knap(int s, int n, int w[]) 
{    
    if ( s<0 || s>0 && n<1 )  //排除不符合要求的參數
        return(0);
    else if ( s == 0 )    //遞歸結束標誌,s體積爲0
        return (1);
    else if ( knap(s - w[n-1], n - 1, w) == 1 ) 
    { 
        printf("result: n=%d ,w[%d]=%d  \n", n, n-1, w[n-1]);
        return (1);
    }
    else
        return ( knap(s, n - 1, w) );
}

2、複雜揹包問題
話說有一哥們去森林裏玩發現了一堆寶石,他數了數,一共有n個。 但他身上能裝寶石的就只有一個揹包,揹包的容量爲C。這哥們把n個寶石排成一排並編上號: 0,1,2,…,n-1。第i個寶石對應的體積和價值分別爲V[i]和W[i] 。排好後這哥們開始思考: 揹包總共也就只能裝下體積爲C的東西,那我要裝下哪些寶石才能讓我獲得最大的利益呢?

OK,如果是你,你會怎麼做?你斬釘截鐵的說:動態規劃啊!恭喜你,答對了。 那麼讓我們來看看,動態規劃中最最最重要的兩個概念: 狀態和狀態轉移方程在這個問題中分別是什麼。

我們要怎樣去定義狀態呢?這個狀態總不能是憑空想象或是從天上掉下來的吧。 爲了方便說明,讓我們先實例化上面的問題。
實例化問題:
一般遇到n,你就果斷地給n賦予一個很小的數, 比如n=3。然後設揹包容量C=10,三個寶石的體積爲5,4,3,對應的價值爲20,10,12。 對於這個例子,我想智商大於0的人都知道正解應該是把體積爲5和3的寶石裝到揹包裏, 此時對應的價值是20+12=32。接下來,我們把第三個寶石拿走, 同時揹包容量減去第三個寶石的體積(因爲它是裝入揹包的寶石之一), 於是問題的各參數變爲:n=2,C=7,體積{5,4},價值{20,10}。好了, 現在這個問題的解是什麼?我想智商等於0的也解得出了:把體積爲5的寶石放入揹包 (然後剩下體積2,裝不下第二個寶石,只能眼睜睜看着它溜走),此時價值爲20。 這樣一來,我們發現,n=3時,放入揹包的是0號和2號寶石;當n=2時, 我們放入的是0號寶石。這並不是一個偶然,沒錯, 這就是傳說中的“全局最優解包含局部最優解”(n=2是n=3情況的一個局部子問題)。 繞了那麼大的圈子,你可能要問,這都哪跟哪啊?說好的狀態呢?說好的狀態轉移方程呢? 別急,它們已經呼之欲出了。

我們再把上面的例子理一下。
當n=2時,我們要求的是前2個寶石, 裝到體積爲7的揹包裏能達到的最大價值;
當n=3時,我們要求的是前3個寶石, 裝到體積爲10的揹包裏能達到的最大價值。
有沒有發現它們其實是一個句式!OK, 讓我們形式化地表示一下它們, 定義d(i,j)前i個寶石裝到剩餘體積爲j的揹包裏能達到的最大價值。 那麼上面兩句話即爲:d(2, 7)和d(3, 10)。這樣看着真是爽多了, 而這兩個看着很爽的符號就是我們要找的狀態了。 即狀態d(i,j)表示前i個寶石裝到剩餘體積爲j的揹包裏能達到的最大價值。 上面那麼多的文字,用一句話概括就是:根據子問題定義狀態!你找到子問題, 狀態也就浮出水面了。而我們最終要求解的最大價值即爲d(n, C):前n個寶石 (0,1,2…,n-1)裝入剩餘容量爲C的揹包中的最大價值。狀態好不容易找到了, 狀態轉移方程呢?顧名思義,狀態轉移方程就是描述狀態是怎麼轉移的方程(好廢話!)。 那麼回到例子,d(2, 7)和d(3, 10)是怎麼轉移的?來,我們來說說2號寶石 (記住寶石編號是從0開始的)。
從d(2, 7)到d(3, 10)就隔了這個2號寶石。 它有兩種情況或者不裝入揹包。
如果裝入,在面對前2個寶石時, 揹包就只剩下體積7來裝它們,而相應的要加上2號寶石的價值12, d(3, 10)=d(2, 10-3)+12=d(2, 7)+12;
如果不裝入,體積仍爲10,價值自然不變了, d(3, 10)=d(2, 10)。
記住,d(3, 10)表示的是前3個寶石裝入到剩餘體積爲10 的揹包裏能達到的最大價值,既然是最大價值,就有
d(3, 10) = max{ d(2, 10), d(2, 7)+12 }。
好了,這條方程描述了狀態d(i, j)的一些關係, 沒錯,它就是狀態轉移方程了。把它形式化一下:
d(i, j) = max{ d(i-1, j), d(i-1,j-V[i-1]) + W[i-1] }。
注意討論前i個寶石裝入揹包的時候, 其實是在考查第i-1個寶石裝不裝入揹包(因爲寶石是從0開始編號的)。至此, 狀態和狀態轉移方程都已經有了。接下來,直接上代碼。

//i表示寶石的編號,j表示揹包剩餘的體積
for(int i=0; i<=n; ++i)
{
    for(int j=0; j<=C; ++j)
    {
        d[i][j] = i==0 ? 0 : d[i-1][j];
        if(i>0 && j>=V[i-1])  
            d[i][j] >?= d[i-1][j-V[i-1]]+W[i-1];  //取兩者的較大值
            //d[i][j] = d[i][j] > (d[i-1][j-V[i-1]] + W[i-1]) ? d[i][j] : d[i-1][j-V[i-1]] + W[i-1];
    }
 }

i=0時,d(i, j)爲什麼爲0呢?因爲前0個寶石裝入揹包就是沒東西裝入,所以最大價值爲0。 if語句裏,j>=V[i-1]說明只有當揹包剩餘體積j大於等於i-1號寶石的體積時, 我才考慮把它裝進來的情況,不然d[i][j]就直接等於d[i-1][j]。i>0不用說了吧, 前0個寶石裝入揹包的情況是邊界,直接等於0,只有i>0纔有必要討論, 我是裝呢還是不裝呢。簡單吧,核心算法就這麼一丁點,接下來上完整代碼knapsack.cpp。

/**0-1 knapsack d(i, j)表示前i個物品裝到剩餘容量爲j的揹包中的最大重量**/
#include<cstdio>
using namespace std;
#define MAXN 1000
#define MAXC 100000

int V[MAXN], W[MAXN];
int d[MAXN][MAXC];

int main()
{
    freopen("data.in", "r", stdin);//重定向輸入流
    freopen("data.out", "w", stdout);//重定向輸出流
    int n, C;
    while(scanf("%d %d", &n, &C) != EOF)
    {
        for(int i=0; i<n; ++i)   
            scanf("%d %d", &V[i], &W[i]);

        for(int i=0; i<=n; ++i)
        {
            for(int j=0; j<=C; ++j)
            {
                d[i][j] = i==0 ? 0 : d[i-1][j];
                if(i>0 && j>=V[i-1])  d[i][j] >?= d[i-1][j-V[i-1]]+W[i-1];
            }
        }
        printf("%d\n", d[n][C]);//最終求解的最大價值
    }
    fclose(stdin);
    fclose(stdout);
    return 0;
}

其中freopen函數將標準輸入流重定向到文件data.in, 這比運行程序時一點點手輸要方便許多,將標準輸出流重定向到data.out。 data.in中每組輸入的第一行爲寶石數量n及揹包體積C,接下來會有n行的數據, 每行兩個數對應的是寶石的體積及價值。本測試用例data.in如下:

5 10
4 9
3 6
5 1
2 4
5 1
4 9
4 20
3 6
4 20
2 4
5 10
2 6
2 3
6 5
5 4
4 6

data.out爲算法輸出結果,對應該測試用例,輸出結果如下:

19
40
15

2、揹包裏裝入哪些寶石?
好,至此我們解決了揹包問題中最基本的0/1揹包問題。等等,這時你可能要問, 我現在只知道揹包能裝入寶石的最大價值,但我還不知道要往揹包裏裝入哪些寶石啊。嗯, 好問題!讓我們先定義一個數組x,對於其中的元素爲1時表示對應編號的寶石放入揹包, 爲0則不放入。讓我們回到上面的例子,對於體積爲5,4,3,價值爲20,10,12的3個寶石 ,如何求得其對應的數組x呢?(明顯我們目測一下就知道x={1 0 1}, 但程序可目測不出來)OK,讓我們還是從狀態說起。
如果我們把2號寶石放入了揹包, 那麼是不是也就意味着,
前3個寶石放入揹包的最大價值 要比
前2個寶石放入揹包的價值 大,
即:d(3, 10) > d(2, 10)。
d(i,j)前i個寶石裝到剩餘體積爲j的揹包裏能達到的最大價值
再用字母代替具體的數字 (不知不覺中我們就用了不完全歸納法哈),
當d(i, j) > d(i-1, j)時,x(i-1) = 1;OK, 上代碼:

//輸出打印方案
int j = C;
for(int i=n; i>0; --i)
{
    if(d[i][j] > d[i-1][j])
    {
        x[i-1] = 1;
        j = j - V[i-1];//裝入第i-1個寶石後背包能裝入的體積就只剩下j - V[i-1]
    }
}
for(int i=0; i<n; ++i)  
    printf("%d ", x[i]);

好了,加入這部分內容,knapsack.cpp變爲如下:

/**0-1 knapsack d(i, j)表示前i個物品裝到剩餘容量爲j的揹包中的最大重量**/
#include<cstdio>
using namespace std;
#define MAXN 1000
#define MAXC 100000

int V[MAXN], W[MAXN], x[MAXN];
int d[MAXN][MAXC];

int main()
{
    freopen("data.in", "r", stdin);
    freopen("data.out", "w", stdout);
    int n, C;
    while(scanf("%d %d", &n, &C) != EOF)
    {
        for(int i=0; i<n; ++i)   
            scanf("%d %d", &V[i], &W[i]);
        for(int i=0; i<n; ++i)   
            x[i] = 0; //初始化打印方案

        for(int i=0; i<=n; ++i)
        {
            for(int j=0; j<=C; ++j)
            {
                d[i][j] = i==0 ? 0 : d[i-1][j];
                if(i>0 && j>=V[i-1])  d[i][j] >?= d[i-1][j-V[i-1]]+W[i-1];
            }
        }
        printf("%d\n", d[n][C]);

        //輸出打印方案
        int j = C;
        for(int i=n; i>0; --i)
        {
            if(d[i][j] > d[i-1][j])
            {
                x[i-1] = 1;
                j = j - V[i-1];
            }
        }
        for(int i=0; i<n; ++i)   
            printf("%d ", x[i]);
        printf("\n");
    }
    fclose(stdin);
    fclose(stdout);
    return 0;
}

至此,好像該解決的問題都解決了。當一個問題找到一個放心可靠的解決方案後, 我們往往就要考慮一下是不是有優化方案了。爲了保持代碼的簡潔, 我們暫且把寶石裝包方案的求解去掉。該算法的時間複雜度是O(nC), 即時間都花在兩個for循環裏了,這個應該是沒辦法再優化了。再看看空間複雜度, 數組d用來保存每個狀態的值,空間複雜度爲O(nC); 數組V和W用來保存每個寶石的體積和價值,空間複雜度爲O(n)。程序總的空間複雜度爲 O(nC),這個是可以進一步優化的。
首先,我們先把數組V和W去掉, 因爲它們沒有保存的必要,改爲一邊讀入一邊計算

int V = 0, W = 0;
for(int i=0; i<=n; ++i)
{
    if(i>0) scanf("%d %d", &V,&W);
    for(int j=0; j<=C;++j)
    {
        d[i][j] = i==0 ? 0 : d[i-1][j];
        if(j>=V && i>0) d[i][j] >?= d[i-1][j-V]+W;
    }
}

好了,接下來讓我們繼續壓榨空間複雜度。保存狀態值我們開了一個二維數組d, 在看過把一維數組V和W變爲一個變量後,我們是不是要思考一下, 有沒有辦法將這個二維數組也壓榨一下呢?換言之, 這個二維數組中的每個狀態值我們真的有必要都保存麼? 讓我們先來看一下以下的一張示意圖(參照《算法競賽入門經典》P169的圖畫的)
這裏寫圖片描述
由上面那一小段優化過後的代碼可知,狀態轉移方程爲:
d(i, j)=max{ d(i-1, j), d(i-1, j-V)+W },
也就是在計算d(i, j)時我們用到了d(i-1,j)和d(i-1, j-V)的值。 如果我們只用一個一維數組d(0)~d(C)來保存狀態值可以麼?將i方向的維數去掉, 我們可以將原來二維數組表示爲一維數據:d(i-1, j-V)變爲d(j-V), d(i-1, j)變爲d(j)。當我們要計算d(i, j)時,只需要比較d(j)和d(j-V)+W的大小, 用較大的數更新d(j)即可。等等,如果我要計算d(i, j+1),而它恰好要用到d(i-1, j)的值, 那麼問題就出來了,因爲你剛剛纔把它更新爲d(i, j)了。那麼,怎麼辦呢? 按照j遞減的順序即可避免這種問題。比如,你計算完d(i, j), 接下來要計算的是d(i,j-1),而它的狀態轉移方程爲d(i, j-1)=max{ d(i-1, j-1), d(i-1, j-1-V)+W },它不會再用到d(i-1,j)的值!所以, 即使該位置的值被更新了也無所謂。好,上代碼:

memset(d, 0, sizeof(d));
for(int i=0; i<=n; ++i)
{
    if(i>0) scanf("%d %d", &V,&W);
    for(int j=C;j>=0; --j)
    {
        if(j>=V && i>0) d[j] >?= d[j-V]+W;
    }
}

優化後的完整代碼如下,此時空間複雜度僅爲O(C)。

/**0-1 knapsack d(i, j)表示前i個物品裝到剩餘容量爲j的揹包中的最大重量**/
#include<cstdio>
#include<cstdlib>
#include<cstring>
using namespace std;

int main()
{
    freopen("data.in", "r", stdin);
    freopen("data.out", "w", stdout);
    int n, C, V = 0, W = 0;
    while(scanf("%d %d", &n, &C) != EOF)
    {
        int* d = (int*)malloc((C+1)*sizeof(int));
        memset(d, 0, (C+1)*sizeof(int));

        for(int i=0; i<=n; ++i)
        {
            if(i>0)   
                scanf("%d %d", &V, &W);

            for(int j=C; j>=0; --j)
            {
                if(j>=V && i>0)    
                    d[j] >?= d[j-V]+W;
            }
        }
        printf("%d\n", d[C]);
        free(d);
    }
    fclose(stdin);
    fclose(stdout);
    return 0;
}
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章