题目描述:
在一个3×3的网格中,1~8这8个数字和一个“X”恰好不重不漏地分布在这3×3的网格中。
例如:
1 2 3
X 4 6
7 5 8
在游戏过程中,可以把“X”与其上、下、左、右四个方向之一的数字交换(如果存在)。
我们的目的是通过交换,使得网格变为如下排列(称为正确排列):
1 2 3
4 5 6
7 8 X
例如,示例中图形就可以通过让“X”先后与右、下、右三个方向的数字交换成功得到正确排列。
交换过程如下:
1 2 3 1 2 3 1 2 3 1 2 3
X 4 6 4 X 6 4 5 6 4 5 6
7 5 8 7 5 8 7 X 8 7 8 X
把“X”与上下左右方向数字交换的行动记录为“u”、“d”、“l”、“r”。
现在,给你一个初始网格,请你通过最少的移动次数,得到正确排列。
输入格式
输入占一行,将3×3的初始网格描绘出来。
例如,如果初始网格如下所示:
1 2 3
x 4 6
7 5 8
则输入为:1 2 3 x 4 6 7 5 8
输出格式
输出占一行,包含一个字符串,表示得到正确排列的完整行动记录。如果答案不唯一,输出任意一种合法方案即可。
如果不存在解决方案,则输出”unsolvable”。
输入样例:
2 3 4 1 5 x 7 6 8
输出样例
ullddrurdllurdruldr
分析:
八数码问题是典型的求从一种状态转化到另一种状态的最短步数模型,可以使用BFS实现,本题要输出合法方案,之前写过只输出最小步数的八数码问题的BFS的解法的题解AcWing 845 八数码,当然,这里将使用更高效的启发式搜索算法A*去求解,人们经常把BFS算法、dijkstra算法以及A*算法放在一起进行比较,也可以看出A*算法的难度与前两种算法的难度差不多,但是第一次接触要想很快理解A*算法,也是比较困难的。网上有不少什么关于A*算法详解的文章,号称最好最全,看后完全懂了,且不说是否讲的全面,反正我是没耐心看下去,这些文章基本与通俗易懂这个词无关,唯一看见一篇写A*的觉得很适合入门的文章就是A*,那个传说中的算法了。下面我尽可能按照自己的理解比较精简的去说下自己印象里的A*算法。
我们知道,BFS相对于DFS的优势是状态多的时候不会爆栈,解决最短路问题效率也比较高。在上一题子串变换中,因为需要搜索状态的层数太多,我们不得已才使用了双向BFS去优化,BFS的劣势在于状态扩展得太快,如果一种状态可以扩展成x种状态,则第n层就要扩展x^n种状态,状态数成指数式趋势增加,空间和时间都会激增。如果有一种算法能够在搜索时只去扩展其中一部分状态,却又能很快的找到终点状态的最短路径,就可以大幅提高搜索的效率了。BFS算法是一层层的扩展里起点距离是d的状态,只要从起点经过d步可以转化到的状态,都会毫无遗漏的被BFS扩展到,dijkstra算法也是每次选择还没加入点集中的离起点最近的顶点去扩展,为了说明方便,我就用一维的模型去描述搜索的过程了。
如上图所示,BFS和dijkstra都是按离起点的路径由近到远逐渐扩展状态的,或者说,我们对终点的位置一无所知,只能左顾右盼,不停的跳跃式搜索,直至搜到终点。但实际上往往我们对终点的信息并不是一无所知,而且恰恰相反,我们是知道终点是什么才去搜索的。比如起点是0,终点是100,BFS搜索状态的顺序往往类似于-1,1,-2,2,-3,3,...,-100,100,我们既然知道100在0的右边,为何还要去搜索左边呢?直接1,2,3,...,100不就行了。在这个简单的一维模型中利用上终点的信息我们都减少了一半状态的搜索,在实际问题中能够砍掉的状态远不止原状态的一半。总结下这个简单例子带给我们的启发,我们之前的搜索算法只关注当前搜索状态到起点的距离,只利用了起点的信息,如果有一种算法能够把终点的信息也考虑进来,那么效率将大幅提高。
现在假设我们在一个迷宫的起点,我们想找到到达迷宫终点的最短路径,并且尝试过的位置尽可能的少,显然BFS需要搜索的冗余状态太多了。假设我们知道的信息是当前位置到起点和终点的距离,设当前位置是u,g(u)表示u到起点的距离,d(u)表示u到终点的距离,我们需要转移到下一个位置上。现在有三个候选位置:
v1: g(v1) = 1,d[v1] = 9;
v2: g(v2) = 4,d[v2] = 4;
v3: g(v3) = 8,d[v3] = 2。
我们会优先考虑扩展到哪个状态呢?BFS和dijkstra的思路肯定是扩展到离起点最近的v1点,但是可以发现1 + 9并不是最短路径,所以v1点不会是最短路径上的点,这次扩展并没有必要。如果你想尽快到达终点,肯定优先考虑扩展到里终点最近的点v3,因为再有2步就到达终点了,到达终点时候发现8 + 2依旧不是最短路径长度,不满足我们要以最短路径到达终点的条件,这次扩展也是徒劳的,所以我们最终会选择扩展到v2点,尽管它里起点和终点都不是最近,但是4 + 4是最小的,选择v2既能够保证选择的路径是最短的,又能够保证不去扩展明显不必要的状态。这个例子给我们的启发是,在知道状态到起点和终点的距离的前提下,我们需要优先选择状态中里起点和终点距离和最小的状态。但是往往我们对终点的信息知道的并没有那么清楚,比如要求最短路径的长度,如果我们一开始就知道起点到终点的距离,那就没必要进行搜索了,我们知道的可能只是部分信息,利用这些信息可以估计当前状态到终点状态大致的距离,我们将计算某个状态到目标状态所需代价的估计值的函数称为估价函数。
现在可以开始讲解A*算法了,因为已经引出了A*算法的核心估价函数。除了BFS和DFS外,还有一种使用较多的搜索叫PFS,优先扩展优先级最高的状态,比如dijkstra就可以视为一种PFS算法,优先级被设置为离起点最近的距离。A*算法只不过需要修改dijkstra算法在优先级设置上的定义,其它过程完全一致。A*算法流程如下:
将初始状态加入优先级队列,当优先级队列非空时,执行以下操作:
取出队头元素,(优先级队列中优先级被设置为g[u] + f[u]越小优先级越高,其中g[u]表示u到起点的距离,f[u]表示u到终点的估计距离),将队头元素能够扩展到的状态加入到优先级队列中。
当目标状态第一次出队时,结束搜索,当前目标状态离起点的距离就是最短距离。
知道了上面这么多,A*算法算是已经入门一半了,还有一半可能大多数文章一笔带过,但是个人觉得还是相当重要的,如果我不说这么多,直接说A*算法就是将优先级改为g[u] + f[u]最小的dijkstra算法,相信大多数人找到如何去实现,但是不明白其中缘由的。下面继续分析A*算法的核心估价函数的选取。在百科中,我们不难看到有以下描述:
我们以d(n)表达状态n到目标状态的距离,那么f(n)的选取大致有如下三种情况:
估价函数选取的一个重要标准就是f(n) <= d(n),即估计距离一定要小于实际距离,不然搜索得到的不一定是最优解。我觉得很多文章的缺陷在于没有把上面三点的原因说出来,尽管原因是显然的,但是我还是在这里卡了不少时间。
如上图所示,B,C节点到终点的距离均被高估了,要求A到D的最短路径长度,首先将初始状态A加入到队列,出队,加入相邻的两个状态B、C,g(B) + f(B) = 1 + 6 = 7,g(C) + f(C) = 1 + 10 = 11,此时优先级队列的队头是B,故将B出队,加入相邻的状态D,此时g(D) + f(D) = 6 + 0 = 6,优先级队列中有CD两个状态,并且D位于队头,将D出队,由于D是终点,则终止算法,输出最短距离6。我们分析,当B、C到终点的估计距离比实际距离大时,求出的最短路径长度是6,而不是我们的最优解4,这就是估价函数的值为什么不能大于实际值的原因,可能会导致搜不到最优解。当f(n) = d(n)时,正如开头举的例子,可以特别迅速的搜到最优解,当f(n) = 0时,优先级队列的优先级完全取决于g(n),这就是dijkstra,效率较低,也就是说,估价函数值不能大于实际值,且与实际值越接近效率越高。下面再以一个实际例子证明为什么估价函数值小于真实值时,终点状态第一次出队时一定是最优解。
现在B、C到终点的距离均被低估了,并且B低估的更多,还是先加入A,然后A出队加入BC,g(B) + f(B) = 1 + 1 = 2,g(C) + f(C) = 1 + 2 = 3,所以B在队头,B出队,将相邻状态D入队,g(D) + f(D) = 6 + 0 = 6,队头元素是C,将C的相邻状态D入队,由于D是第二次入队,我用D1表示区分开,g(D1) + f(D1) = 4 + 0 = 4,D1在队头,所以先出队,此时D1的最短距离恰好是最优解4。通过这个例子可以看出两点,第一点,每个状态可能多次入队,但是终点第一次出队时一定是最优解。第二点就是为何估价函数小于真实值时一定能搜到最优解。正如例子所示,终点状态第一次入队求得的距离不是最短距离,设最短距离是d,而这次入队求得的最短距离是d1,则d1 > d,又终点状态第一次入队时队列中一定含有最优路径中的点(比如上图中的C点),并且g(C) + f(C) <= g(C) + d(C) = d > d1,所以C点必然先于终点出队,并将真正的终点状态D1入队,正因为最短路径上点的估价函数被低估,才会导致还没被更新到最优解的终点入队后不可能优先级最高,不会提前出队。这就是关于估价函数三个说明的解释。
关于估价函数,还有两点要补充。第一点是知道了估价函数不能超过真实值,那么要如何确定估价函数,在开头给出的参考文章中已经详细说明了,一般定义估价函数为曼哈顿距离(行距离+列距离)或者欧氏距离(坐标之间的距离)。第二点是BFS、dijkstra和A*的区别。BFS每个顶点只会入队一次,所以已经入队的顶点不会再入队;dijkstra算法中每个状态可能被更新多次,但是已经出过队的状态离起点的距离已经是最短的了,不会再去更新其他状态了,所以可以在出队时剪枝。A*中由于优先级就是经过当前点起点到终点的估计距离,所以某个点第一次出队时离起点的距离不一定是最小的,还可能继续被更新,所以不能设置访问数组,已经出过队的节点再次出队时依旧要继续去更新状态。比如两条路径A-B-C-D和A-E-D,D不是终点,任意相邻两点间路径长度均是1,ABCD的估价函数值均是0,E的估价函数值是100,所以当A*沿着ABCD扩展到D时,尽管此刻D离起点A的距离不是最近的,但是由于到D的最短路要经过E,E的估价函数太大,导致经过ABC到达D的路径更新的D先出队去更新后面的状态,当块更新到终点时,发现走错了才会回来重新从E扩展到D。正因为除了起点终点外的点可能多次被扩展,所以不能用访问数组去剪枝。
关于A*算法已经说的太多了,下面就简要的说下八数码问题如何用A*算法解决。首先将x横着移动,不改变序列中逆序对的数目,竖着移动,将导致与x相邻的两个元素与x的相对位置反转了,所以有要么这三个元素见是逆序对数目从0变成2,要么从2变成0横着从1变成1,也就是说,移动x并不会导致逆序对总数奇偶性的变化,所以当逆序对数目为奇数时,就可以判定为无解。
本题用A*具体的实现过程见代码,已经加了足够多的注释:
#include
#include
#include
#include
#include
#include
#include
using namespace std;
string en = "12345678x";
typedef pair PIS;
priority_queue,greater > q;//优先级队列,{g[u]+f[u],u}
unordered_map d;//到起点距离
unordered_map > pre;//存储上一步操作
int dx[] = {-1,1,0,0};
int dy[] = {0,0,-1,1};
char op[] = {'u','d','l','r'};
int f(string st){//估值函数,哈密顿距离
int res = 0;
for(int i = 0;i < st.size();i++){
if(st[i] != 'x'){//其他位置归位,x自然归位了
int dis = st[i] - '1';
int x = dis / 3,y = dis % 3;
res += abs(x - i / 3) + abs(y - i % 3);
}
}
return res;
}
void bfs(string st){
d[st] = 0;
q.push({f(st),st});
while(q.size()){
string t = q.top().second;//出队后只会用到状态本身
q.pop();
if(t == en) break;
int loc,dis = d[t] + 1;
for(int i = 0;i < t.size();i++){//寻找x的位置
if(t[i] == 'x'){
loc = i;
break;
}
}
string s = t;
for(int i = 0;i < 4;i++){
int nx = loc / 3 + dx[i],ny = loc % 3 + dy[i];
if(nx < 0 || nx >= 3 || ny < 0 || ny >= 3) continue;
swap(t[loc],t[nx * 3 + ny]);
if(!d.count(t) || d[t] > dis){//未被扩展过或者扩展后最短距离变小才扩展
d[t] = dis;
pre[t] = {s,op[i]};
q.push({d[t] + f(t),t});
}
swap(t[loc],t[nx * 3 + ny]);//恢复状态
}
}
}
int main(){
string c,st,seq,res;
while(cin>>c){
st += c;
if(c != "x") seq += c;
}
int cnt = 0;
for(int i = 0;i < seq.size();i++){
for(int j = i + 1;j < seq.size();j++){
if(seq[i] > seq[j]) cnt++;
}
}
if(cnt & 1) puts("unsolvable");
else{
bfs(st);
while(st != en){
res += pre[en].second;
en = pre[en].first;
}
cout<