专栏文章
《三国杀》张昌蒲严教算法
算法·理论参与者 1已保存评论 0
文章操作
快速查看文章及其快照的属性,并进行相关操作。
- 当前评论
- 0 条
- 当前快照
- 1 份
- 快照标识符
- @miq7vh1v
- 此快照首次捕获于
- 2025/12/04 00:23 3 个月前
- 此快照最后确认于
- 2025/12/04 00:23 3 个月前
玩《三国杀》的时候想出了这个问题。
严教:出牌阶段限一次,你可以选择一名其他角色。从牌堆顶亮出四张牌,该角色将这些牌分成点数之和相等的两组,你与其各获得其中一组,然后将剩余未分组的牌置入弃牌堆。若未分组的牌超过一张,你本回合手牌上限-1。
严教:出牌阶段限一次,你可以选择一名其他角色。从牌堆顶亮出四张牌,该角色将这些牌分成点数之和相等的两组,你与其各获得其中一组,然后将剩余未分组的牌置入弃牌堆。若未分组的牌超过一张,你本回合手牌上限-1。张昌蒲可以亮出牌堆顶的n张牌(初始n=4),经典天平问题。每张牌有三种策略:放左边,放右边,弃置。那么我们编写一个程序,将所有牌的点数作为输入,输出所有的可行方案。
算法一:直接暴力深搜,最后使用13进制哈希(因为卡牌的点数A到K,分别对应0到12,注意不是1到13)去除重复方案。复杂度O(3^n)
优化:将点数一样的牌一起处理,可以减少一部分重复。
可行性剪枝:计算卡牌点数的后缀和。如果将所有剩余卡牌都放在天平轻的一侧仍然轻,那么此状态无解,直接return;
CPP#include<bits/stdc++.h>
using namespace std;
int a[12],numa[12],n,s,h[12],l[12],r[12],num,num2,numans,w;
bool b[1000005];
char c;
struct answer
{
int num,num2,l[12],r[12];
};
answer ans[10005];
bool mycmp(answer x1,answer x2)
{return x1.num+x1.num2>x2.num+x2.num2;}
void f(int t)
{
if(t>n)
{
if(!w&&num)
{
int hash=l[1]-1,hash2=r[1]-1;
for(int i=2;i<=num;i++)
hash=(hash*13+l[i]-1)%999983;
for(int i=2;i<=num2;i++)
hash2=(hash2*13+r[i]-1)%999983;
if(!b[hash]||!b[hash2])//13进制哈希查重
{
b[hash]=b[hash2]=1;
numans++;
ans[numans].num=num,ans[numans].num2=num2;
for(int i=1;i<=num;i++)
ans[numans].l[i]=l[i];
for(int i=1;i<=num2;i++)
ans[numans].r[i]=r[i];
}
}
return;
}
if(abs(w)<=h[t])//可行性剪枝
{
for(int i=1;i<=numa[t];i++)//全放左侧
l[++num]=a[t];
w+=a[t]*numa[t];
f(t+numa[t]);
for(int i=1;i<=numa[t];i++)//逐个拿下,再放入右侧
{
num--;
w-=a[t];
f(t+numa[t]);
r[++num2]=a[t];
w-=a[t];
f(t+numa[t]);
}
num2-=numa[t];
w+=a[t]*numa[t];//回溯
}
}
int main()
{
while(scanf("%c",&c))
{
if(c==' ')
{
a[++n]=s;
s=0;
}
else if(c=='\n')
{
a[++n]=s;
break;
}
else s=s*10+c-48;
}
sort(a+1,a+n+1);
for(int i=n;i;i--)//后缀和
h[i]=h[i+1]+a[i];
for(int i=1;i<=n;i+=numa[i])
{
numa[i]=1;
for(int j=i+1;a[i]==a[j];j++)
numa[i]++;
}
f(1);
sort(ans+1,ans+numans+1,mycmp);
for(int i=1;i<=numans;i++)
{
printf("方案%d:",i);
for(int j=1;j<ans[i].num;j++)
printf("%d+",ans[i].l[j]);
printf("%d=",ans[i].l[ans[i].num]);
for(int j=1;j<ans[i].num2;j++)
printf("%d+",ans[i].r[j]);
printf("%d\n",ans[i].r[ans[i].num2]);
}
return 0;
}
算法二:使用对称性剪枝,保证在搜索时就能去除重复,故删除哈希。镜像重复的方案(手性)仅保留将第一张不对称卡牌放在左侧多的情况。
CPP#include<bits/stdc++.h>
using namespace std;
int a[12],numa[12],n,s,h[12],l[12],r[12],num,num2,numans,w;
char c;
struct answer
{
int num,num2,l[12],r[12];
};
answer ans[10005];
bool mycmp(answer x1,answer x2)
{return x1.num+x1.num2>x2.num+x2.num2;}
void f(int t,bool fl)
{
if(t>n)
{
if(!w&&num)
{
numans++;
ans[numans].num=num,ans[numans].num2=num2;
for(int i=1;i<=num;i++)
ans[numans].l[i]=l[i];
for(int i=1;i<=num2;i++)
ans[numans].r[i]=r[i];
}
return;
}
if(abs(w)<=h[t])//可行性剪枝
{
for(int i=1;i<=numa[t];i++)//全放左侧
l[++num]=a[t];
w+=a[t]*numa[t];
f(t+numa[t],1);
//对称性剪枝,规定"打破对称的"第一张牌必须放左侧
if(fl)//无对称性要求
{
for(int i=1;i<=numa[t];i++)//逐个拿下,再放入右侧
{
num--;
w-=a[t];
f(t+numa[t],1);
r[++num2]=a[t];
w-=a[t];
f(t+numa[t],1);
}
num2-=numa[t];
w+=a[t]*numa[t];//回溯
}
else//有对称性要求
{
if(num&1)//奇数
{
for(int i=1;i<=numa[t]>>1;i++)//逐个拿下,再放入右侧
{
num--;
w-=a[t];
f(t+numa[t],1);
r[++num2]=a[t];
w-=a[t];
f(t+numa[t],1);
}
num--;
w-=a[t];
f(t+numa[t],0);
}
else
{
for(int i=1;i<=(numa[t]-1)>>1;i++)//逐个拿下,再放入右侧
{
num--;
w-=a[t];
f(t+numa[t],1);
r[++num2]=a[t];
w-=a[t];
f(t+numa[t],1);
}
num--;
w-=a[t];
f(t+numa[t],1);
r[++num2]=a[t];
w-=a[t];
f(t+numa[t],0);
}
}
}
}
int main()
{
while(scanf("%c",&c))
{
if(c==' ')
{
a[++n]=s;
s=0;
}
else if(c=='\n')
{
a[++n]=s;
break;
}
else s=s*10+c-48;
}
sort(a+1,a+n+1);
for(int i=n;i;i--)//后缀和
h[i]=h[i+1]+a[i];
for(int i=1;i<=n;i+=numa[i])
{
numa[i]=1;
for(int j=i+1;a[i]==a[j];j++)
numa[i]++;
}
f(1,0);
sort(ans+1,ans+numans+1,mycmp);
for(int i=1;i<=numans;i++)
{
printf("方案%d:",i);
for(int j=1;j<ans[i].num;j++)
printf("%d+",ans[i].l[j]);
printf("%d=",ans[i].l[ans[i].num]);
for(int j=1;j<ans[i].num2;j++)
printf("%d+",ans[i].r[j]);
printf("%d\n",ans[i].r[ans[i].num2]);
}
return 0;
}
算法三:使用记忆化搜索,复杂度降低到O(min(3^n,n*S))。S为卡牌点数和范围,S<=13n,显然复杂度达不到上界。
为w设置初始偏移量65(为了避免数组下标负数)。设状态dp[n][w]表示在当前状态下继续搜索下去是否有解(状态里没有fl,因为fl对是否有解无影响)。将f函数从void改为bool,传递当前状态的dp值。状态转移方程:dp[n][w]=max(dp[n+numa[n]][所有可能到达的状态w])
有了dp[n][w]的记录之后,所有重叠子问题最多计算一次,第二次遇到“此路不通”的情况会直接“掉头”,而遇到“有路”的情况只会在“正确的路上”行走,免去搜索直奔答案,不会走“岔路”。就像有了雅努斯(门径之泰坦)的指引一样顺利。
对于这类问题,我一般用记忆化搜索完成DP,理由:1.一般的DP需要用循环,这样会循环到很多不可能到达的状态,白白浪费时间。而记搜搜过的所有状态都是能到达的状态。2.这个问题需要输出答案,而且是多个答案,一般的DP写法很难存储多个答案,要么需要用邻接表建图保存答案,要么就要做完DP之后再次根据DP值深搜出答案,非常繁琐。虽然深搜的常数比较大。
CPP#include<bits/stdc++.h>
using namespace std;
int a[12],numa[12],n,s,h[12],l[12],r[12],num,num2,numans,w=65;
char c;
bool dp[12][135],vis[12][135];
struct answer
{
int num,num2,l[12],r[12];
};
answer ans[10005];
bool mycmp(answer x1,answer x2)
{return x1.num+x1.num2>x2.num+x2.num2;}
bool f(int t,bool fl)
{
if(t>n)
{
if(w!=65||!num) return 0;
ans[++numans].num=num;
ans[numans].num2=num2;
for(int i=1;i<=num;i++)
ans[numans].l[i]=l[i];
for(int i=1;i<=num2;i++)
ans[numans].r[i]=r[i];
return 1;
}
if(abs(w-65)>h[t]||(vis[t][w]&&!dp[t][w]))
return 0;//可行性剪枝
vis[t][w]=1;
for(int i=1;i<=numa[t];i++)//全放左侧
l[++num]=a[t];
w+=a[t]*numa[t];
bool flag=0;
flag|=f(t+numa[t],1);
//对称性剪枝,规定"打破对称的"第一张牌必须放左侧
if(fl)//无对称性要求
{
for(int i=1;i<=numa[t];i++)//逐个拿下,再放入右侧
{
num--;
w-=a[t];
flag|=f(t+numa[t],1);
r[++num2]=a[t];
w-=a[t];
flag|=f(t+numa[t],1);
}
num2-=numa[t];
w+=a[t]*numa[t];//回溯
}
else//有对称性要求
{
if(num&1)//奇数
{
for(int i=1;i<=numa[t]>>1;i++)//逐个拿下,再放入右侧
{
num--;
w-=a[t];
flag|=f(t+numa[t],1);
r[++num2]=a[t];
w-=a[t];
flag|=f(t+numa[t],1);
}
num--;
w-=a[t];
flag|=f(t+numa[t],0);
}
else
{
for(int i=1;i<=(numa[t]-1)>>1;i++)//逐个拿下,再放入右侧
{
num--;
w-=a[t];
flag|=f(t+numa[t],1);
r[++num2]=a[t];
w-=a[t];
flag|=f(t+numa[t],1);
}
num--;
w-=a[t];
flag|=f(t+numa[t],1);
r[++num2]=a[t];
w-=a[t];
flag|=f(t+numa[t],0);
}
}
return dp[n][w]=flag;
}
int main()
{
while(scanf("%c",&c))
{
if(c==' ')
{
a[++n]=s;
s=0;
}
else if(c=='\n')
{
a[++n]=s;
break;
}
else s=s*10+c-48;
}
sort(a+1,a+n+1);
for(int i=n;i;i--)//后缀和
h[i]=h[i+1]+a[i];
for(int i=1;i<=n;i+=numa[i])
{
numa[i]=1;
for(int j=i+1;a[i]==a[j];j++)
numa[i]++;
}
f(1,0);
sort(ans+1,ans+numans+1,mycmp);
for(int i=1;i<=numans;i++)
{
printf("方案%d:",i);
for(int j=1;j<ans[i].num;j++)
printf("%d+",ans[i].l[j]);
printf("%d=",ans[i].l[ans[i].num]);
for(int j=1;j<ans[i].num2;j++)
printf("%d+",ans[i].r[j]);
printf("%d\n",ans[i].r[ans[i].num2]);
}
return 0;
}
算法四:考虑到这个问题只有一个起点(w=0)和一个终点(也是w=0),故从两端开始搜索,在中间汇合。大大降低时间复杂度。但我认为对这道题来说使用这个策略是有些多此一举的,甚至不一定比得过算法三。两端搜索的策略适合指数爆炸式复杂度的问题,适合越往深处搜索,结点数量便指数爆炸式增长的问题。而这个问题当n过半之后,范围S由于可行性剪枝反而缩小了。呈现“两头尖,中间大”的形态。两端搜索的目的就是为了达成“两头尖,中间大”的形态并在中间汇合,这个目的已经达成了,再用两端搜索就多此一举了。而且两端搜索还会增加代码量,需要两半对接答案,也更繁琐。
策略游戏嘛,算法打击!
相关推荐
评论
共 0 条评论,欢迎与作者交流。
正在加载评论...