1. 程式人生 > >動態規劃-硬幣問題

動態規劃-硬幣問題

有n種硬幣,面值分別為V1,V2,....,Vn,每種都有無限多。給定非負整數S,可以選用多少個硬幣,使得面值之和恰好為S?輸出硬幣數目的最小值和最大值。 1<=n<=100 0<=S<=10000 1<=Vi<=S

輸入:硬幣的種類n,各個硬幣的面值V1,V2,....Vn,非負整數S

輸出:輸出硬幣數目的最小值、最大值、最小值的方案、最大值的方案。

執行結果:

最長路和最短路的求法是類似的,下面只考慮最長路。由於終點固定,d(i)的確定含義變為 “從結點i出發到結點0的最長路徑長度”。下面是求最長路的程式碼:

int vis[100],V[100],d[100],n;
int dp(int S)
{
    int &ans = d[S];
    if(ans >= 0)
        return ans;
    ans = 0;
    for(int i = 1; i <= n; ++i)
    if(S >= V[i])
        ans = max(ans, dp(S-V[i]) + 1);
    return ans; 
}

注意到區別了麼?由於在本題中,路徑長度是可以為0的(S本身可以是0),所以不能再用d=0表示“這個d值還沒有算過“。相應的,初始化時也不能再把d全設為0,而要設定為一個負值-在正常情況下是娶不到的。常見的方式是用-1來表示沒有算過,則初始化時只需用memset(d,-1,sizeof(d))即可。至此,已完整解釋了上面的程式碼為什麼把if(ans > 0) 改成了 if(ans >= 0 )。

不知讀者有沒有看出,上述程式碼有一個致命的錯誤:即由於結點S不一定真的能到達結點0,所以需要用特殊的d[S]值表示無法到達,但在上述程式碼中,如果S根本無法繼續往前走,返回值是0,將被誤認為是 不用走,已經到達終點的意思。如果把ans初始化為-1呢?別忘了-1代表還沒算過,所以返回-1相當於放棄了自己的勞動成果。如果把ans初始化為很大的一個整數,例如2^{30}呢?如果一開始就就這麼大,ans=max(ans, dp(i) + 1)還能把abs變回正常值嗎?如果改成很小的整數,例如 -2^{30}呢?從目前來看,它也會被認為是還沒算過,但至少可以和所有d的初始分開-只需把程式碼中if(ans>=0)改為if(ans != -1)即可。

int dp(int S)
{
    int &ans = d[S];
    if(ans != -1)
        return ans;
    ans = -(1<<30);
    for(int i = 1; i <= n; ++i)
    if(S >= V[i])
        ans = max(ans, dp(S-V[i]) + 1);
    return ans; 
}

上述錯誤都是很常見的,甚至頂尖高手有時也會一時糊塗,掉入陷阱,意識到這些問題,尋求解決方案是不難的,但就怕除錯很久以後仍然沒有發現是哪裡出了問題。另一個解決問題是不用特殊值表示還沒算過,而用另外一個數組vis[i]表示狀態i是否被訪問過。

int dp1(int S)
{
    //遞迴
    int i;
    if(vis[S]) //訪問過
        return d[S];
    vis[S]=1;
    int &ans=d[S];
    ans=-INF; //如果到達不了,返回-INF
    for(i=1;i<=n;i++)
       if(S>=V[i])
           ans=max(ans,dp1(S-V[i])+1);
    return ans;
}

儘管多了一個數組,但可讀性增強了許多:再也不用擔心特殊值之間的衝突了,在任何情況下,記憶化搜尋的初始化都可以用 memset(vis,0,sizeof(vis))實現。

本題要求最大、最小兩個值,記憶化搜尋就必須寫兩個。在這種情況下,用遞推更加方便(此時注意遞推的順序):

void dp2(int S)
{
    //遞推
    int i,j,maxv[100],minv[100];
    maxv[0]=minv[0]=0;
    for(i=1;i<=S;i++)
    {
        minv[i]=INF;
        maxv[i]=-INF;
    }

    for(i=1;i<=S;i++)
        for(j=1;j<=n;j++)
            if(i>=V[j])
            {
                minv[i]=min(minv[i],minv[i-V[j]]+1);
                maxv[i]=max(maxv[i],maxv[i-V[j]]+1);
            }

    printf("%d %d\n",minv[S],maxv[S]);
}

如果輸出字典序最小的方案呢?之前介紹的方法仍然適用。

void print_ans(int *d,int S)
{
    //輸出字典序最小的解
    int i,j;
    for(i=1;i<=n;i++)
        if(S>=V[i]&&d[S]==d[S-V[i]]+1)
        {
            printf("%d ",V[i]);
            print_ans(d,S-V[i]);
            break;
        }
}

然後分別呼叫print_ans(min,S)(注意在後面要加一個回車符)和print_ans(max,S)即可。輸出路徑和上題的區別是,上題列印的是路徑上的點,而這裡列印的是路徑上的邊,還記得陣列可以作為指標傳遞麼?這裡強調的一點是:陣列作為指標傳遞時,不會複製陣列中的資料,因此不必擔心這樣會帶來不必要的時間開銷。

很多使用者喜歡另外一種列印路徑的方法:遞推時直接用min_coin[S]記錄滿足min[S] == min[S-V[i]] + 1的最小的i,則列印路徑時可以省去

print_ans函式中的迴圈,並可以方便的把遞迴改成迭代(原來的也可以改成迭代,但不這麼自然)。具體來說,需要把遞推過程改成一下形式。

int min_coin[100],max_coin[100];
void dp2(int S)
{
    int i,j,maxv[100],minv[100];
    minv[0]=maxv[0]=0; //空間換時間

    for(i=1;i<=S;i++)
    {
        minv[i]=INF;
        maxv[i]=-INF;
    }

    for(i=1;i<=S;i++)
        for(j=1;j<=n;j++)
            if(i>=V[j])
            {
               if(minv[i]>minv[i-V[j]]+1)
               {
                  minv[i]=minv[i-V[j]]+1;
                  min_coin[i]=j; //順便記錄最優解
               }
               if(maxv[i]<maxv[i-V[j]]+1)
               {
                  maxv[i]=maxv[i-V[j]]+1;
                  max_coin[i]=j;
               }
            }
}

注意。判斷中用的是”>“和”<“,而不是">="和”<=“,原因在於字典序最小解要求當min/max值相同時取最小的i值,反過來,如果j是從大到小列舉的,就需要把”>“和"<"改成">="和"<="才能求出字典序最小解。

在求出  min_coin和max_coin後,只需呼叫print_ans(min_coin,S)和print_ans(max_coin,S)即可。

void print_ans(int *d,int S)
{
    while(S)
    {
        printf("%d ",d[S]);
        S-=V[d[S]];
    }
}

該方法是一個用空間換時間的經典例子-用min_coin和max_coin陣列消除了原來print_ans中的迴圈。