基本概念
BFS搜索处理的对象不仅可以是一个数,还可以是一种“状态”。八数码问题是典型的状态图搜索问题
八数码问题
在一个3x3的棋盘上放置编号为1~8的8个方块,每个方块占一格,另外还有一个方块有一个空格。与空格相邻的数字方块可以移动到空格里。任务1:指定初始棋局和目标棋局(如下图)计算出最少移动步数;任务2:输出数码的移动序列
把空格看成0,一共有9个数字。
输入样例:
1 2 3 0 8 4 7 6 5
1 0 3 8 2 4 7 6 5
输出样例:
2
八数码问题思路
针对上面这个问题,把一个棋盘看作一个状态图,总共有9!=362 880个状态。从初始棋局开始,每次移动转到下一个状态,到达目标棋局后停止。
八数码问题是一个经典的BFS问题。前面的博客中提到BFS是从近到远的扩散过程,适合解决最短距离问题。八数码从初始状态出发,每次转移都是逐步逼近目标状态。每转移一次,步数加一,当到达目标时,经过的步数就是最短路径。
如下图。是样例的转换过程。该图中起点为(A,0),A表示状态,即{1 2 3 0 8 4 7 6 5}这个棋局;0是距离起点的步数。从初始状态A出发,移动数字0到邻居位置,按左,上,右,下的顺时针顺序,有3个转移状态B,C,D;目标状态F,停止。
用队列描述这个BFS过程:
- A进队,当前队列是{A};
- A出队,A的邻居B,C,D进队,当前队列是{B,C,D}。步数为1;
- B出队,E进队,当前队列是{C,D,E},E的步数为2;
- C出队,转移到F,检验F是目标状态,停止,输出F的步数2。
仔细分析上面的过程,发现从B状态出发实际上有E,X两个转移方向,而X正好是初始状态A,重复了。同理Y状态也是重复的。如果不去掉这些重复状态,程序会产生很多无效操作,复杂度大大增加。因此,八数码的重要问题其实是判重。
如果用暴力法判重,每次把新状态与9!=362880个状态进行对比,可能有9!x9!次检查,不可行。因此需要一个快速的判重方法
下面针对八数码中的判重问题,使用一个数学方法来解决问题“康托拓展(Cantor Expansion)”来判重
康托展开
康托展开是一种特殊的哈希函数。在本题中康托展开完成了如下表的工作。
第一行是0~8这9个数字的全排列,共9!=362880个,按从小到大排序。第2行是每个排列对应的位置,例如最小的{012345678}在第0个位置,最大的{876543210}在最后的362800-1这个位置。
函数Cantor()实现的功能是:输入一个排列,即第一行的某个排列,计算出与它的Cantor值,即第二行对应的数。
Cantor()复杂度为O(n^2),n是集合中元素的个数。在本题中,完成搜索和判重的总复杂度是O(n ! n^2),远比暴力判重的总复杂度O(n! n!)小。
有了这个函数,八数码的程序能很快判重:每转移到一个新状态,就用Cantor()判断这个状态是否处理过,如果处理过,则不转移。
下面举列讲解康托展开的原理。
康托展开的原理
案例:判断2143是{1,2,3,4}的全排类中第几大的数
题目
判断2143是{1,2,3,4}的全排类中第几大的数
思路
计算排在2143前面的排列数目,可以将问题转化为以下排列的和:
- 首位小于2的所有排列。比2小的只有1一个数,后面3个数的排列有3x2x1=3!个(即1234,1243,1324,1342,1423,1432),写成1x3!=6。
- 首位为2、第2位小于1的所有全排列。无,写成0x2!=0。
- 前两位为21,第三位小于4的所有全排列。只有3一个数(即2134),写成1x1!=1.
- 前3位为214,第4位小于3的所有全排列。无,写成0x0!=0。
求和的7,所有2143是第8大的数。如果用int visited[24]数组记录各排列的位置,{2143}就是visited[7];第一次访问这个排列时,置visited[7]=1;当再次访问这个排列的时候发现visited[7]=1,说明已经访问过了,判重。
根据上面的推导式可以得到康托展开式。
把一个集合产生的全排列按字典排序,第X个排列的计算公式如下:
X=a[n]x(n-1)! + a[n-1]x(n-2)!+…+a[i]x(i-1!)+…a[2]x1!+a[1]x0![1]。
其中,a[i]为当前未出现的元素排在第几个(从0开始),并且有0<=a[i]<i(1<=i<=n)。
上述过程的反过程是康托逆展开:某个集合的全排列,输入一个数字k,返回第k大的排列。
下面的源代码程序用“BFS+Cantor”解决了八数码问题,其中BFS用STL的queue实现。
源码(8数码问题)
#include<iostream>
#include<queue>
using namespace std;
const int LEN = 362880; //共9!种状态
struct node {
int state[9]; //记录一个八数码的排列,即一个状态
int dis; //记录到起点的距离
};
int dir[4][2] = {
{-1,0},//向左
{0,-1},//向上
{1,0},//向右
{0,1}//向下
};
int visited[LEN] = { 0 }; //与每个状态对应的记录,Cantor()函数对它的置数,并重判
int start[9]; //开始状态
int goal[9];//目标状态
long int factory[] = { 1,1,2,6,24,120,720,5040,40320,362880 };//Cantor()用到的常数
//使用康托展开判重
bool Cantor(int str[], int n) {
long result = 0;
for (int i = 0; i < n; i++) {
int counted = 0;
for (int j = i + 1; j < n; j++) {
if (str[i] > str[j]) {//当前未出现的元素排在第几个
counted++;
}
}
result += counted * factory[n - i - 1];//康托拓展公式
}
if (!visited[result]) {
visited[result] = 1;//没有被访问过
return 1;
}
else {
return 0;
}
}
int BFS() {
node head;
memcpy(head.state, start, sizeof(head.state));//复制起点状态
head.dis = 0;
queue<node>q;//队列的内容是记录状态
Cantor(head.state, 9);//用康托展开判重,目的是对起点的visited[]赋初值
q.push(head);//第一个进队列的是起点状态
while (!q.empty()) {
head = q.front();
q.pop();//出队列
cout << "出队:";//打印输出结果
for (int i = 0; i < 9; i++) {
cout << head.state[i] << " ";
}
cout << endl;
int z;
for ( z = 0; z < 9; z++) {//找到这个状态元素0的位置
if (head.state[z] == 0) {
break;
}
}
//将一维转化为二维,方便表达左上右下四个方向的移动表示,左上角是(0,0)
int x = z % 3; //0的横坐标
int y = z / 3;//0的纵坐标
for (int i = 0; i < 4; i++) {//操作0进行上下左右,4个方向的0移动
int newx = x + dir[i][0];//左右移
int newy = y + dir[i][1];//上下移
int nz = newx + 3 * newy;//再转换为一维方便进行条件判断
if (newx >= 0 && newx < 3 && newy >= 0 && newy < 3) {//未出界
node newnode;//创建一个新节点
memcpy(&newnode, &head, sizeof(struct node));//复制新状态
swap(newnode.state[z], newnode.state[nz]);//把0的位置移动到新位置
newnode.dis++;
if (memcmp(newnode.state, goal, sizeof(goal)) == 0) {//与目标状态一样
return newnode.dis;//输出结果
}
if (Cantor(newnode.state, 9)) {//判断是否重复,如果没放入队列
q.push(newnode);
}
}
}
}
return -1;
}
int main() {
cout << "请输入初始状态:";
for (int i = 0; i < 9; i++) {
cin >> start[i];
}
cout << "请输入目标状态:";
for (int i = 0; i < 9; i++) {
cin >> goal[i];
}
int result_num = BFS();
if (result_num != -1) {
cout << "result:" << result_num << endl;
}
else {
cout << "Impossible" << endl;
}
system("pause");
return 0;
}
运行结果
分析
下面对每个代码进行分析,细节很多,所以希望大家自己也能敲以下。加深记忆和理解
拓展提示:15数码的问题
八数码问题只有9!中状态,对于更大的需求,例如4x4棋盘的15数码问题,有16!大约2x10^13中状态,如果还用数组就肯定不够了,就需要更好的算法,这里给大家推荐一个连接,大家可以了解一下
八数码的多种解法
总结
下一篇博客是BFS与A*算法。
其实到现在相信大家也已经明白了BFS算法,从老鼠走迷宫,黑红瓷砖,八数码。对于BFS其实就可以理解为使用的队列来抽象扩散的过程,这个扩散过程也有树的感觉,就是说由一点散发。所以我想称为广度优先算法,正如其意,通过外围的大量的数据进行测试,往目标不断地缩小范围,以找到我们最终地答案。在找到这个答案的时候,通过我们从外围的数据测试做下的标记,就找到了它的最短路径。