動態規劃-硬幣問題
有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初始化為很大的一個整數,例如呢?如果一開始就就這麼大,ans=max(ans, dp(i) + 1)還能把abs變回正常值嗎?如果改成很小的整數,例如 呢?從目前來看,它也會被認為是還沒算過,但至少可以和所有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中的迴圈。