本文参考刘汝佳《算法竞赛入门经典》(第2版)*
动态规划的核心是状态和状态转移方程**
硬币问题
【分析】
类似于矩形嵌套问题,这个题目也属于DAG上的动态规划问题。d(i)表示凑得钱数之和为i所需要的最大或最小硬币数,若当前状态是i,用硬币j后,状态转移到i-Vj,每个状态看成一个点,这些点就形成了一个DAG图。硬币使用数目最大和最少分别对应了DAG图中的最长路和最短路。与嵌套矩形问题相比,若初始状态为S,末状态是0(若达不到0,题目无解,稍后解释),就类似于大矩形嵌套小矩形,大矩形到小矩形之间有边,即如果存在Vj,使得d(i)=d(i-Vj)+1,那么i与i-Vj之间有边。若初状态为0,末状态为S(若达不到S,题目无解,稍后解释),就类似于小矩形嵌套到大矩形,小矩形到大矩形之间有边,即如果存在Vj,使得d(i)=d(i-Vj)+1,那么i-Vj与i之间有边。我们以最长路为例。
- 状态转移方程:d(i)=max{d(i-Vj)+1 | i>=Vj}
- 即:d(i)=max{d(j)+1 | (i,j)∈E}(d(i)表示从i出发的最长路,类似于大矩形套小矩形,此时建图是从状态S到状态0建图)
- 再考虑下,如果是以i结束的最长路,那么状态转移方程则是:d(i)=max{d(j)+1 | (j,i)∈E},这类似于小矩形嵌套在大矩形中,d(i)表示以i结束的最长路,此时建立的图是从状态0到状态S建图
无解分析:比如只有一种面值为3元的硬币,要凑够10元,这显然无解。
使用状态转移方程d(i)=max{d(j)+1 | (i,j)∈E}和记忆化搜素方法求最大最小硬币数
具体可以结合紫书,看下面的代码!笔者进行了详细研究并实现。注意要心中有图,本题建图的过程是从S状态出发向0状态推进的,本博文研究的两个状态转移方程的求解都是建立在从S状态到0状态推进建图的基础上的。
#include<iostream>
#include<cstring>
using namespace std;
const int maxn=105;
int coin[maxn];//记录各类硬币面值
int coin_cnt[maxn];//记录每种面值硬币使用数
int n;//硬币种类数
int S;//要凑到的目标总钱数
int d_max[maxn*maxn];//dp关键数组,d_max[i]表示从i出发的最长路,存储的是边
int d_min[maxn*maxn];//dp关键数组,d_min[i]表示从i出发的最短路,存储的是边
int path[maxn*maxn];//打印所有路径的时候需要
int scheme_max[maxn*10][maxn];//记录所有的最多硬币方案数,假设最多方案不超过1000个
int scheme_cnt_max;//最多硬币方案总数
int scheme_min[maxn*10][maxn];//记录所有的最少硬币方案数,假设最多方案不超过1000个
int scheme_cnt_min;//最少硬币方案总数
void read(){
cout<<"硬币种类总数:";
cin>>n;
cout<<"各类硬币面值分别是:";
for(int i=0;i<n;i++){
cin>>coin[i];
}
cout<<"目标钱数:";
cin>>S;
}
//求最大硬币数
int dp_max(int i){
int &ans=d_max[i];
if(ans!=-1) return ans;//注意初始化d_max数组为-1,表示尚未计算过,d_max[0]=0,
//也可以开个vis数组进行标记:if(vis[i]) return ans; vis[i]=1;
//紫书:如果状态复杂,用map存放状态值,判断状态i是否算过,只需要if(d.count(i))即可
ans=-(1<<30);//注意这里初始化为一个很小的值,一是用来区分尚未做过的-1,二是可以表示如果无解的情况
//注意这里ans一定不能初始化为0,否则当无解的时候,会返回一个错误的结果
for(int j=0;j<n;j++){
if(i>=coin[j]){
ans=max(ans,dp_max(i-coin[j])+1);
}
}
return ans;
}
//求最小硬币数
int dp_min(int i){
int &ans=d_min[i];
if(ans!=-1) return ans;
ans=(1<<30);
for(int j=0;j<n;j++){
if(i>=coin[j]){
ans=min(ans,dp_min(i-coin[j])+1);
}
}
return ans;
}
//打印硬币数最大时候的字典序最小的路径,打印的是所有边,边是硬币面值
void print_min1(int i){
for(int j=0;j<n;j++){
if(i>=coin[j] && d_max[i]==d_max[i-coin[j]]+1){
cout<<coin[j]<<" ";
print_min1(i-coin[j]);
break;
}
}
}
//打印从S状态出发的满足最多硬币的"所有方案"(按字典序从小到大打印,
//这里说的所有方案是指在图中走的路径不同,有可能多种方案其实是一种方案,
//因为如果多个方案对应的每种硬币的使用数目都相同的话,其实就是一种方案
void print_max_all(int i,int cnt){
if(0==i){
//这打印的是图中的所有路径,并非是真的方案
for(int ii=0;ii<cnt;ii++){
cout<<coin[path[ii]]<<" ";
}
cout<<endl;
memset(coin_cnt,0,sizeof(coin_cnt));
for(int k=0;k<cnt;k++){
coin_cnt[path[k]]++;
}
//判断之前的方案中是否已经存在和本方案相同的方案,若存在返回true
bool flag=false;//注意这里初始化是false
for(int k=0;k<scheme_cnt_max;k++){
flag=true;
for(int kk=0;kk<n;kk++){
if(coin_cnt[kk]!=scheme_max[k][kk]){
flag=false;
break;
}
}
if(flag) break;
}
if(!flag){
for(int kk=0;kk<n;kk++){
scheme_max[scheme_cnt_max][kk]=coin_cnt[kk];
}
scheme_cnt_max++;
/*
for(int k=0;k<n;k++){
cout<<coin_cnt[k]<<" ";
}
cout<<endl;
*/
}
return;
}
for(int j=0;j<n;j++){
//如果把d_max[i]==d_max[i-coin[j]]+1去掉,相当于dfs了
if(i>=coin[j] && d_max[i]==d_max[i-coin[j]]+1){
path[cnt]=j;
print_max_all(i-coin[j],cnt+1);
}
}
}
//打印硬币数最小时候的字典序最小的路径,打印的是所有边,边是硬币面值
void print_min2(int i){
for(int j=0;j<n;j++){
if(i>=coin[j] && d_min[i]==d_min[i-coin[j]]+1){
cout<<coin[j]<<" ";
print_min2(i-coin[j]);
break;
}
}
}
//打印满足最多硬币的所有路径,顺便求出所有方案,必须先调用这个函数,才能打印所有方案
//打印从S状态出发的满足最少硬币的"所有方案"(按字典序从小到大打印,
//这里说的所有方案是指在图中走的路径不同,有可能多种方案其实是一种方案,
//因为如果多个方案对应的每种硬币的使用数目都相同的话,其实就是一种方案
void print_min_all(int i,int cnt){
if(0==i){
//这打印的是图中的所有路径,并非是真的方案
for(int ii=0;ii<cnt;ii++){
cout<<coin[path[ii]]<<" ";
}
cout<<endl;
memset(coin_cnt,0,sizeof(coin_cnt));
for(int k=0;k<cnt;k++){
coin_cnt[path[k]]++;
}
//判断之前的方案中是否已经存在和本方案相同的方案,若存在返回true
bool flag=false;//注意这里初始化是false
for(int k=0;k<scheme_cnt_min;k++){
flag=true;
for(int kk=0;kk<n;kk++){
if(coin_cnt[kk]!=scheme_min[k][kk]){
flag=false;
break;
}
}
if(flag) break;
}
if(!flag){
for(int kk=0;kk<n;kk++){
scheme_min[scheme_cnt_min][kk]=coin_cnt[kk];
}
scheme_cnt_min++;
/*
for(int k=0;k<n;k++){
cout<<coin_cnt[k]<<" ";
}
cout<<endl;
*/
}
return;
}
for(int j=0;j<n;j++){
//如果把d_max[i]==d_max[i-coin[j]]+1去掉,相当于dfs了
if(i>=coin[j] && d_min[i]==d_min[i-coin[j]]+1){
path[cnt]=j;
print_min_all(i-coin[j],cnt+1);
}
}
}
int main()
{
memset(d_max,-1,sizeof(d_max));
memset(d_min,-1,sizeof(d_min));
d_max[0]=d_min[0]=0;
read();
int max_coin=dp_max(S);
int min_coin=dp_min(S);
if(min_coin==(1<<30) || max_coin<0){
cout<<"无解"<<endl;
return 0;
}else{
cout<<"---------------------------------------------------------"<<endl;
cout<<"最大硬币数:"<<max_coin<<endl;
cout<<"最大硬币数对应的字典序最小的路径(每个硬币的面值):";
print_min1(S);
cout<<endl;
cout<<"最大硬币数的所有路径(从0到n-1每种硬币数目):"<<endl;
print_max_all(S,0);
cout<<"最大硬币数的所有方案(每个硬币的面值):"<<endl;
for(int i=0;i<scheme_cnt_max;i++){
for(int j=0;j<n;j++){
cout<<scheme_max[i][j]<<" ";
}
cout<<endl;
}
cout<<"--------------------------------------------------------"<<endl;
cout<<"最小硬币数:"<<min_coin<<endl;
cout<<"最小硬币数对应的字典序最小的路径(每个硬币的面值):";
print_min2(S);
cout<<endl;
cout<<"最小硬币数的所有路径(从0到n-1每种硬币数目):"<<endl;
print_min_all(S,0);
cout<<"最小硬币数的所有方案(每个硬币的面值):"<<endl;
for(int i=0;i<scheme_cnt_min;i++){
for(int j=0;j<n;j++){
cout<<scheme_min[i][j]<<" ";
}
cout<<endl;
}
}
return 0;
}
使用递推法,同时记录最小字典序路径
#include<iostream>
#include<cstring>
using namespace std;
const int maxn=105;
const int INF=(1<<30);
int coin[maxn];
int S;
int n;
int d_max[maxn*maxn];
int d_min[maxn*maxn];
int path_max[maxn];//记录最长路
int path_min[maxn];//记录最短路
void read(){
cin>>n;
for(int i=0;i<n;i++){
cin>>coin[i];
}
cin>>S;
}
void dp(){
//初始化,如果最后需要的硬币数是个很大的数或很小的数,说明无解
for(int i=0;i<=S;i++){
d_max[i]=-INF;
d_min[i]=INF;
}
d_max[0]=d_min[0]=0;//从0状态到0状态的硬币数是0,这个很关键
for(int i=0;i<=S;i++){
for(int j=0;j<n;j++){
if(i>=coin[j]){
if(d_max[i]<d_max[i-coin[j]]+1){
d_max[i]=d_max[i-coin[j]]+1;
path_max[i]=j;//同时记录路径
}
if(d_min[i]>d_min[i-coin[j]]+1){
d_min[i]=d_min[i-coin[j]]+1;
path_min[i]=j;
}
}
}
}
}
void print_path_max(int i){
while(i){
cout<<coin[path_max[i]]<<" ";
i-=coin[path_max[i]];
}
cout<<endl;
}
void print_path_min(int i){
while(i){
cout<<coin[path_min[i]]<<" ";
i-=coin[path_min[i]];
}
cout<<endl;
}
int main()
{
read();
dp();
cout<<d_max[S]<<endl;
cout<<d_min[S]<<endl;
print_path_max(S);
print_path_min(S);
return 0;
}
使用状态转移方程d(i)=max{d(j)+1 | (j,i)∈E}加记忆化搜索求最大硬币数
因为上面已经详细求解了最大硬币和最小硬币数,并详细打印了方案和路径,这里仅仅以求最大硬币数为例,展示使用第二个状态转移方程的应用,一样可以解决问题。不过值得注意的事情是,有的题目两个状态转移方程都很容易求解,但有的问题要选择其中一个容易求解的状态转移方程进行求解。
#include<iostream>
#include<cstring>
using namespace std;
const int maxn=105;
int coin[maxn];
int S;
int n;
int d_max[maxn];
void read(){
cin>>n;
for(int i=0;i<n;i++){
cin>>coin[i];
}
cin>>S;
}
int dp(int i){
int & ans=d_max[i];
if(ans!=-1) return ans;
ans=-(1<<30);
for(int j=0;j<n;j++){
if(i+coin[j]<=S){
ans=max(ans,dp(i+coin[j])+1);
}
}
return ans;
}
void print_min1(int i){
for(int j=0;j<n;j++){
if(i+coin[j]<=S && d_max[i]==d_max[i+coin[j]]+1){
cout<<coin[j]<<" ";
print_min1(i+coin[j]);
break;
}
}
}
int main()
{
memset(d_max,-1,sizeof(d_max));
read();
d_max[S]=0;//之前在没有读入S时,进行了此步,导致迷之错误
dp(0);
cout<<d_max[0]<<endl;
print_min1(0);
return 0;
}