UVA 10181 题解
UVA 10181 题解
题意
十五数码问题。对于无解情况输出This puzzle is not solvable.
。保证所有有解情况都能在最多 \(45\) 步内解决,要求对于所有有解情况在 \(50\) 步以内解决,输出方案。
思路
如何判断无解情况
首先考虑如何判断无解情况。可以根据逆序对个数判断无解,可以参考 zjjws 的题解,讲的很清晰。
直接爆搜?
十五数码问题比八数码问题的搜索量要大得多,因此直接进行爆搜会 T 掉,因此考虑优化。
如何优化
回顾八数码问题,当时我们通过各种各样的优化,因此本题也可以借鉴八数码问题的思路优化。那到底用哪种方法呢?这里我选用估价函数。
估价函数是启发式函数,用于启发式搜索中,我认为可以看作一种很强大的剪枝。实际上,估价函数可以说是一种贪心与搜索的结合,通过估价函数,我们可以更快的搜索到我们需要的答案。
如何设计估价函数
关于设计估价函数,一般有几点要求:
- 估价函数的预估代价一定要保证预估代价 \(\le\) 真实代价。
- 估价函数一定要有一定的题目依据,根据题目情况设计合理的估价函数。
在本题中,我们可以这样设计估价函数:
设估价函数 \(f\) 表示当前状态 \(state\) 转移到目标状态 \(end\) 所需要的步数。其中步数采用曼哈顿距离。
关于正确性:由于是曼哈顿距离,因此实际步数一定不会优于我们直接用曼哈顿距离算得的步数。
实际操作中,我们需要统计除 \(0\) 外的所有点到其正确位置的曼哈顿距离之和。
实现
A* 实现
设计完估价函数后,我们就可以考虑使用搜索了。对于这种最短步数的题目,很容易想到使用 bfs,结合估价函数,我们就实现了A* 算法。
由于需要统计方案数,因此需要开 unordered_map 存储转移中的所有方案和转移步数,过程中 unordered_map 也直接实现了判重的操作,因此不会转移到我们已经搜索过的状态。在维护过程中每次弹出当前状态步数 + 估价函数预估步数最小的方案维护,可以用优先队列实现。
如果只是这样的话,由于状态量很大,可能会爆空间,因此需要使用状态压缩。在八数码问题中,我们可以直接将八个数码存储成字符,但是本题显然不太行,因为超过 \(10\) 的数字都有两位,无法用一个字符存储。因此这里我考虑将 \(0\) 到 \(15\) 一一映射到对应的英文字母 a-p 中,这样实现了状态压缩。
我又长又臭的 A* 代码
#include<queue>
#include<unordered_map>
#include<iostream>
using namespace std;
unordered_map<int,char>h;//存储数字映射到字符
unordered_map<string,int>dist;//存储当前状态的步数
unordered_map<string,string>pre;//存储当前状态是由哪种状态转移来的
typedef pair<int,string>PIS;
#define x first
#define y second
int ha[16]={15,0,1,2,3,4,5,6,7,8,9,10,11,12,13,14},num[16];//ha存储每个数对应的正确位置,num存储初始状态
int f(string s)//返回预估距离
{
int cnt=0;
for(int i=0;i<16;i++)
{
int p=s[i]-'a';
if(!p)continue;
int sx=i%4,sy=i/4;
cnt+=abs(sx-ha[p]%4)+abs(sy-ha[p]/4);
}
return cnt;
}
bool solvable()//判断有无解,参考了 zombie462 的代码
{
int cnt=0,tot=0;
for(int i=0;i<16;i++)
if(!num[i])cnt+=3-i/4;
else
for(int j=0;j<i;j++)
if(num[j]&&num[j]>num[i])cnt++;
return cnt&1;
}
int Astar(string sta)
{
priority_queue<PIS,vector<PIS>,greater<PIS>>q;//第一维存储预估价值,第二位存储状态
q.push({f(sta),sta});
dist[sta]=0;
while(q.size())
{
auto t=q.top();
q.pop();
string s=t.y;
int step=t.x;
if(!f(s)&&dist[s]<=45)return dist[s];//判断有解
if(step>45)break;//判断无解
int zero;//记录当前状态0的位置
for(int i=0;i<16;i++)
if(s[i]=='a')
{
zero=i;
break;
}
if(zero<12)//向下转移
{
string ss=s;
swap(ss[zero],ss[zero+4]);
if(!dist.count(ss)&&ss!=sta)
{
dist[ss]=dist[s]+1;
q.push({dist[ss]+f(ss),ss});
pre[ss]=s;
}
}
if(zero>3)//向上转移
{
string ss=s;
swap(ss[zero],ss[zero-4]);
if(!dist.count(ss)&&sta!=ss)
{
dist[ss]=dist[s]+1;
q.push({dist[ss]+f(ss),ss});
pre[ss]=s;
}
}
if(zero%4)//想左转移
{
string ss=s;
swap(ss[zero],ss[zero-1]);
if(!dist.count(ss)&&sta!=ss)
{
dist[ss]=dist[s]+1;
q.push({dist[ss]+f(ss),ss});
pre[ss]=s;
}
}
if((zero+1)%4)//向右转移
{
string ss=s;
swap(ss[zero],ss[zero+1]);
if(!dist.count(ss)&&ss!=sta)
{
dist[ss]=dist[s]+1;
q.push({dist[ss]+f(ss),ss});
pre[ss]=s;
}
}
}
return 51;//返回无解
}
char check(string a,string b)//通过状态a->b 返回是哪一种操作
{
int d1,d2;
for(int i=0;i<a.size();i++)
if(a[i]=='a')
{
d1=i;
break;
}
for(int i=0;i<b.size();i++)
if(b[i]=='a')
{
d2=i;
break;
}
if(d1-4==d2)return 'U';
if(d1==d2-4)return 'D';
if(d1-1==d2)return 'L';
if(d1+1==d2)return 'R';
}
int main()
{
ios::sync_with_stdio(0);
cin.tie(0),cout.tie(0);
freopen("test.in","r",stdin);
freopen("test.out","w",stdout);
for(int i=0;i<=15;i++)h[i]='a'+i;
int T=1;
cin>>T;
while(T--)
{
string s;
dist.clear();//多测不清空,爆零两行泪
pre.clear();
for(int i=0,x;i<16;i++)
{
cin>>x;
num[i]=x;
s+=h[x];
}
if(solvable())//判断无解
{
cout<<"This puzzle is not solvable.\n";
continue;
}
int res=Astar(s);
string ss="bcdefghijklmnopa",ans;
while(ss!=s)//寻找方案
{
string sss=pre[ss];
ans+=check(sss,ss);
ss=sss;
}
for(int i=ans.size()-1;i>=0;i--)cout<<ans[i];//倒序输出
cout<<"\n";
}
return 0;
}
其实还有一定的优化空间,但是我懒得继续优化了:可以在记录上一次转移状态的时候直接记录一下方案,这样就不用最后再寻找通过状态寻找方案了
IDA* 实现
A* 实现中,有许多的缺点难以避免:在维护过程中需要记录所有搜索到的状态,需要大量的空间存储状态进行判重,也需要一些时间进行排序。虽然本题可以通过,但是在遇到更大的情况时难以避免空间或时间爆炸的情况。
而对于 bfs 来说,判重的问题一般是没有什么办法能解决的。因此我们考虑使用 dfs 来实现。但是 dfs 也有一些缺点,dfs 可能会搜索一大堆极深的无用节点导致效率下降,由于本题限制了答案步数在比较小的范围内,因此我们考虑引入 IDDFS,即迭代加深。迭代加深是一种限制步数的深搜方式,它具备深搜的特点,但是每次只搜索深度限制范围内的所有点。
再加上我们的估价函数进行剪枝,IDA* 就这样实现了。相较于 A,IDA 效率高、空间消耗小、码量相对 A* 更少。
IDA* 代码
#include<iostream>
using namespace std;
const int N=4,M=N*N;
int num[M];//存储状态
typedef pair<int,int>PII;
#define x first
#define y second
PII tmp[M]=
{//存储所有点对应正确位置的坐标
{3,3},{0,0},{0,1},{0,2},
{0,3},{1,0},{1,1},{1,2},
{1,3},{2,0},{2,1},{2,2},
{2,3},{3,0},{3,1},{3,2}
};
int dx[]={0,1,0,-1},dy[]={1,0,-1,0};
char res[60];//存储答案
int f(int state[])//估价函数返回当前状态到达最终状态的预估步数
{
int cnt=0;
for(int i=0;i<16;i++)
{
if(!state[i])continue;
int sx=i/4,sy=i%4;
cnt+=abs(sx-tmp[state[i]].x)+abs(sy-tmp[state[i]].y);
}
return cnt;
}
bool solvable(int&x,int&y)//判断无解
{
int cnt=0,tot=0;
for(int i=0;i<16;i++)
if(!num[i])cnt+=3-i/4,x=i%4,y=i/4;
else for(int j=0;j<i;j++)
if(num[j]&&num[j]>num[i])cnt++;
return cnt&1;
}
int get_num(char s)//通过字符映射到对应方案的数字
{
switch(s)
{//DRUL
case 'D':return 0;
case 'R':return 1;
case 'U':return 2;
case 'L':return 3;
}
}
char get_res(int x)//通过数字映射到相应方案的字符
{
switch(x)
{
case 0:return 'D';
case 1:return 'R';
case 2:return 'U';
case 3:return 'L';
}
}
bool IDAstar(int max_dep,int depth,int x,int y)
{//最大深度、当前深度、0点 的坐标
int cnt=f(num);
if(!cnt)return 1;//返回有解
if(depth+cnt>max_dep)return 0;//返回当前状态无解
int flag=4;
if(depth)flag=(get_num(res[depth])+2)%4;//剪枝:当前不用再转移回上一个状态
for(int i=0;i<4;i++)
{
int nx=x+dx[i],ny=y+dy[i];
if(nx<0||nx>3||ny<0||ny>3||i==flag)continue;
swap(num[x+y*4],num[nx+ny*4]);
res[depth+1]=get_res(i);//记录答案
if(IDAstar(max_dep,depth+1,nx,ny))return 1;//搜索下一层
swap(num[x+y*4],num[nx+ny*4]);
}
return 0;//返回无解
}
int main()
{
ios::sync_with_stdio(0);
cin.tie(0),cout.tie(0);
freopen("test.in","r",stdin);
freopen("test.out","w",stdout);
int T=1;
cin>>T;
while(T--)
{
for(int i=0;i<16;i++)cin>>num[i];
int dep=0,x,y;
if(solvable(x,y))//判断无解
{
cout<<"This puzzle is not solvable.\n";
continue;
}
while(!IDAstar(dep,0,x,y))dep++;//每次叠加深度
for(int i=1;i<=dep;i++)cout<<res[i];
cout<<dep<<"\n";
}
return 0;
}
update
补充内容:
- 小常数优化:可以用 \(\&3\) 代替 \(\%4\) 运算,会减少一些常数。但是亲测二者相差不大,甚至模运算可能会更快?蒟蒻不懂,有没有懂的大佬解释下原因?
后记:本文难免会有错误,各位大佬轻喷请多指教,蒟蒻在此感激不尽。如有问题,欢迎讨论。