UVA 10181 题解

UVA 10181 题解

题意

十五数码问题。对于无解情况输出This puzzle is not solvable.。保证所有有解情况都能在最多 \(45\) 步内解决,要求对于所有有解情况在 \(50\) 步以内解决,输出方案。

思路

如何判断无解情况

首先考虑如何判断无解情况。可以根据逆序对个数判断无解,可以参考 zjjws 的题解,讲的很清晰。

直接爆搜?

十五数码问题比八数码问题的搜索量要大得多,因此直接进行爆搜会 T 掉,因此考虑优化。

如何优化

回顾八数码问题,当时我们通过各种各样的优化,因此本题也可以借鉴八数码问题的思路优化。那到底用哪种方法呢?这里我选用估价函数

估价函数是启发式函数,用于启发式搜索中,我认为可以看作一种很强大的剪枝。实际上,估价函数可以说是一种贪心与搜索的结合,通过估价函数,我们可以更快的搜索到我们需要的答案。

如何设计估价函数

关于设计估价函数,一般有几点要求:

  1. 估价函数的预估代价一定要保证预估代价 \(\le\) 真实代价
  2. 估价函数一定要有一定的题目依据,根据题目情况设计合理的估价函数。

在本题中,我们可以这样设计估价函数:
设估价函数 \(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\) 运算,会减少一些常数。但是亲测二者相差不大,甚至模运算可能会更快?蒟蒻不懂,有没有懂的大佬解释下原因?

后记:本文难免会有错误,各位大佬轻喷请多指教,蒟蒻在此感激不尽。如有问题,欢迎讨论。

posted @ 2023-09-02 09:30  week_end  阅读(23)  评论(0编辑  收藏  举报