问题描述:编号为0~8的正方形滑块被摆成3行3列(一个格子留空,用编号0表示),每次可以把与空格相邻的滑块和空格交换位置,给定初始局面和目标局面,计算出到达目标局面最小需要的移动步数,如果无法到达,则输出-1
sample input:
2 6 4 1 3 7 0 5 8
8 1 5 7 3 6 4 0 2
sample output:
31
关于八数码问题有很多种经典解法,这里先只讨论使用盲目搜索的BFS
基本思想与迷宫类问题类似,对于每个状态结点需找其0的位置,再向四个方向搜索产生扩展结点(产生扩展结点的条件是未遍历,不越界的合法移动)
这里有两个难点,一个是如何保存当前状态,另一个是如何判断新的状态是否与之前的状态重复
保存当前状态我们采用一个一维数组即可,但是下标注意应从0开始,即0~8表示每一个位置的状态,那么下标i的行数为i/3,列数为i%3
值得一提,这里有一个小优化:因为本题会涉及到大量状态比较,即一维数组的标记,当比较两个状态是否相同和复制状态时 我们采用memcmp和memcpy 完成整块内存的比较和复制,就如memset一样是整块操作,比常规循环操作更快
对于判重问题是本文的重点内容,我们稍后重点讨论,现给出不涉及判重部分的全部代码:
#include <cstdio>
#include <cstring>
typedef int State[9]; //定义状态的数据类型
const int MAXSTATE=1000000; //定义最大状态数 八数码问题状态数为0~8的全排 即9!=362880
State st[MAXSTATE],goal; //st保存所有状态 goal保存目标状态
int dist[MAXSTATE]; //距离数组 保存当前状态离初始状态的步数
const int dx[4]={-1,1,0,0};
const int dy[4]={0,0,-1,1};
//注意 这里的返回值为目标状态在st数组的下标
int bfs()
{
init_lookup_table();
int front,rear;
dist[1]=0;
front=1,rear=2; //这里避免使用下标0 我们把下标0看作不存在解
while(front<rear)
{
State& s=st[front];
if (memcmp(goal,s,sizeof(s))==0) //到达终点
{
return front;
}
int z;
for (z=0;z<9;z++) //寻找0的位置
{
if (!s[z])
{
break;
}
}
int x=z/3; //获取行号和列号
int y=z%3;
for (int d=0;d<4;d++)
{
int newx=x+dx[d];
int newy=y+dy[d];
int newz=newx*3+newy; //0的新位置
if (newx>=0 && newx<3 && newy>=0 && newy<3 ) //移动合法
{
State& t=st[rear];
memcpy(&t,&s,sizeof(s)); //扩展了一个和状态s一样的状态结点
t[newz]=s[z];
t[z]=s[newz]; //将s的t和newt位置的编号互换后赋给t 即把0移动
dist[rear]=dist[front]+1;
if (try_to_insert(rear)) //看新状态能否成功插入 即判重
{
rear++;
}
}
}
front++;
}
return -1; //寻找失败
}
int main()
{
int i,ans;
for (i=0;i<9;i++)
{ //这里下标从0开始 以便获取行列号
scanf("%d",&st[1][i]);
}
for (i=0;i<9;i++)
{
scanf("%d",&goal[i]);
}
ans=bfs();
if (ans>0)
{
printf("%d\n",dist[ans]);
}
else
{
printf("-1\n");
}
return 0;
}
以上代码是除了init_lookup_table初始化查找表函数和try_to_insert判重并插入查找表这两个用于判重的函数没实现以外,其他都是完整的代码,依旧采用数组模拟队列,接下来我们讨论如何判重
按照以往的思想,开一个vis的九维数组进行判重,这样数组大小为9^9=387420489项 而排列的所有方式也只有9!即362880种 这样造成了极大的空间浪费,并且在一般情况下会超空间,因此我们需要寻求其他更优秀的判重方法,这里我们给出三种最常用和最经典的判重方式,其方法可以扩展到大多数的BFS问题中
方法1:
使用STL的set进行动态判重,创建一个set,每次将当前状态在其中寻找有没有相同的状态,没有则插入其中,每次寻找的时间复杂度是logn,随着set内容的增多,效率会越来越慢
优点:代码简单,空间消耗小
缺点:时间较慢,每次查询和插入的复杂度都为O(logn)
为节省空间,我们把每一个状态按位置压缩到一个int变量保存,代码如下:
#include <cstdio>
#include <cstring>
#include <set>
using namespace std;
typedef int State[9]; //定义状态的数据类型
const int MAXSTATE=1000000; //定义最大状态数 八数码问题状态数为0~8的全排 即9!=362880
State st[MAXSTATE],goal; //st保存所有状态 goal保存目标状态
int dist[MAXSTATE]; //距离数组 保存当前状态离初始状态的步数
const int dx[4]={-1,1,0,0};
const int dy[4]={0,0,-1,1};
//用STL的set实现判重
set<int>vis;
void init_lookup_table()
{
vis.clear();
}
int try_to_insert(int s)
{
int v=0;
for(int i=0;i<9;i++)
{
v=v*10+st[s][i];
}
if (vis.find(v)!=vis.end())
{
return 0;
}
else
{
vis.insert(v);
return 1;
}
}
//注意 这里的返回值为目标状态在st数组的下标
int bfs()
{
init_lookup_table();
int front,rear;
dist[1]=0;
front=1,rear=2; //这里避免使用下标0 我们把下标0看作不存在解
while(front<rear)
{
State& s=st[front];
if (memcmp(goal,s,sizeof(s))==0) //到达终点
{
return front;
}
int z;
for (z=0;z<9;z++) //寻找0的位置
{
if (!s[z])
{
break;
}
}
int x=z/3; //获取行号和列号
int y=z%3;
for (int d=0;d<4;d++)
{
int newx=x+dx[d];
int newy=y+dy[d];
int newz=newx*3+newy; //0的新位置
if (newx>=0 && newx<3 && newy>=0 && newy<3 ) //移动合法
{
State& t=st[rear];
memcpy(&t,&s,sizeof(s)); //扩展了一个和状态s一样的状态结点
t[newz]=s[z];
t[z]=s[newz]; //将s的t和newt位置的编号互换后赋给t 即把0移动
dist[rear]=dist[front]+1;
if (try_to_insert(rear)) //看新状态能否成功插入 即判重
{
rear++;
}
}
}
front++;
}
return -1; //寻找失败
}
int main()
{
int i,ans;
for (i=0;i<9;i++)
{ //这里下标从0开始 以便获取行列号
scanf("%d",&st[1][i]);
}
for (i=0;i<9;i++)
{
scanf("%d",&goal[i]);
}
ans=bfs();
if (ans>0)
{
printf("%d\n",dist[ans]);
}
else
{
printf("-1\n");
}
return 0;
}
对于其他的大多数问题,很难像上面一样把一个状态转化为一个int值进行比较并存储到set中,我们需要自己定义状态之间的比较,重载运算符
代码如下:
struct cmp
{
bool operator()(int a,int b)const
{
return memcmp(&st[a],&st[b],sizeof(st[a]))<0;
}
};
set<int,cmp>vis;
int try_to_insert(int s)
{
if (vis.find(s)!=vis.end())
{
return 0;
}
else
{
vis.insert(s);
return 1;
}
}
void init_lookup_table()
{
vis.clear();
}
注意:对于STL容器,为了节省空间的开销,我们往往只将结构体的下标存入STL容器中,而不直接存整个结构体,因此在重载比较函数的时候要稍微注意一下,可以参照上面的代码进行重载
而这种情况对于此题从时间上显然会比之前压缩到一个int中的情况要慢很多,因为每次比较是整块的比较,因此在大多数对时间要求紧迫的情况下,我们不能使用set
方法2::
接下来给大家介绍一种更加常用和主流的方法——哈希(hash)技术
首先,建立一个大小为MAXHASHSIZE的哈希链表
然后,通过某些计算方式,将一个状态映射成一个范围在0~MAXHASHSIZE(哈希表的最大大小)中的值,而hash表的每一条链表就储存hash值相同的状态
查找的时候就将该状态转化为hash值并在相应的链表中搜寻,插入的时候则插入到对应的hash值的链表中即可
注意,我们通常用两个数组head和next实现,状态的数量不超过head和next数组的总空间
而hash技术的关键在于hash函数的设计,因为一个hash值对应的状态数越少,那么效率则越高
代码如下:
//用Hash实现判重
const int MAXHASHSIZE=1000003;
int head[MAXHASHSIZE],next[MAXHASHSIZE];
void init_lookup_table()
{
memset(head,0,sizeof(head));
}
int hash(State& s) //哈希函数 将一个状态转化为一个哈希值 一个哈希值对应的状态数越少越好
{
int v=0;
for (int i=0;i<9;i++)
{
v=v*10+s[i];
}
return v%MAXHASHSIZE;
}
int try_to_insert(int s)
{
int h=hash(st[s]);
int u=head[h]; //这里是一个hash链表
//head[i]表示哈希值为i的第1个点的编号 而next[i]表示结点i在链表里的下一个结点的编号,值为0表示到了链尾
while(u)
{
if (memcmp(st[u],st[s],sizeof(st[u]))==0)
{
return 0;
}
u=next[u];
}
next[s]=head[h]; //将该结点从链头插入
head[h]=s;
return 1;
}
这段程序的代码会在空间上造成额外的开销,但是当hash函数设计得当时,它的查找和插入的时间复杂度是O(1)的
方法3:
接下来介绍最后一种方法,即编码解码法
即将所有状态通过某种运算编码成一一对应的数字,然后通过逆运算将数字转换为相应的状态,其实在方法1的第一种set方法中,我们就使用了编码技术,当我们通过编码技术找到一套编码方案可以把所有的状态数(对于八数码问题状态数为362879个)编码,并且编码的范围不大,那么我们只需要开一个一维数组,就能实现O(1)的查找和插入
从某种意义上来看,编码解码法就相当于是hash的一种变体,可以理解为完美hash
下面给出八数码的编码解码法供大家参考:
int vis[36288],fact[9]; void init_lookup_table() //初始化查找表,主要是初始化vis数组和fact数组 { memset(vis,0,sizeof(vis)); fact[0]=1; for (int i=1;i<9;i++) { fact[i]=fact[i-1]*i; } } int try_to_insert(int x) { int code=0; for (int i=0;i<9;i++) { int cnt=0; for (int j=i+1;j<9;j++) { if (st[x][j]<st[x][i]) { cnt++; } } code+=fact[8-i]*cnt; } if (vis
)
{
return 0;
}
else
{
vis=1;
return 1;
}
}