ZJNU 2471 - Kronican (最小树形图/状压DP)
COCI 2016/2017 CONTEST #3 - Kronican
题意
有\(n\)个无限体积的杯子,里面都有一些水,Mislav想喝掉所有的水,但他只想喝最多\(k\)杯水
所以他需要将这\(n\)杯水进行合并,将第\(i\)杯水倒进第\(j\)杯所需要的花费为\(C_{i,j}\)
问喝到所有水的最小花费
限制
\(1\leq k\leq n\leq 20\)
\(0\leq C_{i,j}\leq 10^5\)
思路一(最小树形图)
由于数据范围只有\(20\),首先可以枚举出最后剩下的\(k\)杯水是哪些,最多的情况数为\(C_{20}^{10}\)
- 可以以\(0/1\)两种状态表示每个杯子最终状态,存在一个长度为\(n\)的数组内,以next_permutation来遍历所有\(C_n^k\)种情况即可,也可通过二进制枚举方法等。
对于枚举出来的每一个状态,确定了最后“剩下的水杯”是哪些。
将水杯看作一张有向图中的节点,就表示需要找出一张最小树形图(森林),使得每个“空水杯”顺着箭头走最终都会指向那些“剩下的水杯”。
又贪心可得,“剩下的水杯”严格为\(k\)杯时答案最优,所以这可能是一张包含\(k\)棵有向树的森林,故需要建立一个虚根,让所有“剩下的水杯”指向这个虚根,再对虚根做一遍最小树形图即可。
- 下图为\(n=11,k=3\)时的树形图(注意在跑最小树形图算法时应反向建边)
程序一
#include<bits/stdc++.h>
using namespace std;
const int INF=0x3f3f3f3f;
struct Edge{
int u,v,dis;
Edge(){}
Edge(int u,int v,int dis):u(u),v(v),dis(dis){}
};
struct Directed_MT{
int n,m;
Edge edges[400];
int vis[25],pre[25],id[25],in[25];
void init(int n){
this->n=n;
m=0;
}
void addedge(int u,int v,int dis){
edges[m++]=Edge(u,v,dis);
}
int DirMt(int root){
int ans=0;
while(1){
for(int i=0;i<n;i++)in[i]=INF;
for(int i=0;i<m;i++){
int u=edges[i].u,v=edges[i].v;
if(edges[i].dis<in[v]&&u!=v){
in[v]=edges[i].dis;
pre[v]=u;
}
}
for(int i=0;i<n;i++){
if(i==root)continue;
if(in[i]==INF)return -1;
}
int cnt=0;
memset(id,-1,sizeof(id));
memset(vis,-1,sizeof(vis));
in[root]=0;
for(int i=0;i<n;i++){
ans+=in[i];
int v=i;
while(vis[v]!=i&&id[v]==-1&&v!=root){
vis[v]=i;
v=pre[v];
}
if(v!=root&&id[v]==-1){
for(int u=pre[v];u!=v;u=pre[u])
id[u]=cnt;
id[v]=cnt++;
}
}
if(cnt==0)break;
for(int i=0;i<n;i++)
if(id[i]==-1)id[i]=cnt++;
for(int i=0;i<m;i++){
int v=edges[i].v;
edges[i].v=id[edges[i].v];
edges[i].u=id[edges[i].u];
if(edges[i].u!=edges[i].v)
edges[i].dis-=in[v];
}
n=cnt;
root=id[root];
}
return ans;
}
}MT;
int n,k,a[30];
int cost[30][30];
int solve()
{
MT.init(n+1);
for(int i=1;i<=n;i++)
{
if(a[i]==1)
MT.addedge(n,i-1,0); //注意编号从0开始,故全部-1
}
for(int i=1;i<=n;i++)
if(a[i]==0)
{
for(int j=1;j<=n;j++)
if(i!=j)
MT.addedge(j-1,i-1,cost[i][j]); //建立反向边
}
return MT.DirMt(n);
}
int main()
{
scanf("%d%d",&n,&k);
for(int i=1;i<=n;i++)
for(int j=1;j<=n;j++)
scanf("%d",&cost[i][j]);
for(int i=1;i<=n-k;i++)
a[i]=0;
for(int i=n-k+1;i<=n;i++)
a[i]=1;
int ans=INF;
do{
ans=min(ans,solve());
}while(next_permutation(a+1,a+1+n));
printf("%d\n",ans);
return 0;
}
思路二(状压DP)
以二进制存储当前\(n\)个杯子是否有水
每一次倒水一定是由“有水的杯子”倒向“有水的杯子”
故可以枚举状态\(S\),二进制上以\(1\)代表有水,以\(0\)代表无水
那么对于每一种状态\(S\),枚举其中两个有水的杯子\(i,j\),表示此时枚举的是将\(i\)杯子中的水倒入\(j\)杯子中
倒完后,\(i\)杯子将会成为空杯子,故此时是由状态\(S\)转移到状态\(S\ xor\ 2^i\)(将\(S\)中第\(i\)个位置的\(1\)变为\(0\))的,得到状态转移方程为
由于枚举的\(i,j\)两位置在\(S\)内保证为\(1\)(有水),故\(S\ xor\ 2^i\lt S\)一定成立,所以对于状态\(S\)只需要从大到小枚举即可(自\(2^n-1\)到\(0\))
程序二
#include<bits/stdc++.h>
using namespace std;
const int INF=0x3f3f3f3f;
int n,k,cost[25][25];
int dp[1<<21];
int main()
{
scanf("%d%d",&n,&k);
for(int i=0;i<n;i++)
for(int j=0;j<n;j++)
scanf("%d",&cost[i][j]);
dp[(1<<n)-1]=0; //初始状态花费为0
for(int S=(1<<n)-2;S>=0;S--)
dp[S]=INF;
for(int S=(1<<n)-1;S>=0;S--) //枚举状态S
{
for(int i=0;i<n;i++) //枚举倒出的杯子
{
if(S&(1<<i)) //倒出的杯子中有水
{
for(int j=0;j<n;j++) //枚举倒入的杯子
{
if(i==j)
continue;
if(S&(1<<j)) //倒入的杯子中有水
dp[S^(1<<i)]=min(dp[S^(1<<i)],dp[S]+cost[i][j]);
}
}
}
}
int ans=INF;
for(int S=(1<<n)-1;S>=0;S--)
if(__builtin_popcount(S)==k) //如果剩下的有水杯子个数为k,取一次答案
ans=min(ans,dp[S]);
printf("%d\n",ans);
return 0;
}