带你学习BFS最小步数模型
最小步数模型
一、简介
最小步数模型和最短路模型的区别?
最短路模型:某一个点到另一个点的最短距离(坐标与坐标之间)
最小步数模型:不再是点(坐标),而是状态到另一个状态的转变
BFS难点所在(最短路问题):
存储的数据结构:队列
状态如何存储到队列里边(以什么形式)?
状态怎么表示,怎么转移?
3.
dist
如何记录每一个状态的距离
技巧:在最小步数模型中状态和状态的距离通常用哈希表来进行存储(存在key-value
的映射关系!),如map
,unordered_map
。
思路:将初始状态加入队列,然后去搜索扩展,直到搜索到目标状态为止。
注:
在搜索过程中可能由状态的切换,如一维坐标切换到二维坐标,字符串切换到二标坐标形式的状态等等!
二、练习
1. 八数码
【题目链接】845. 八数码 - AcWing题库
在一个 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
现在,给你一个初始网格,请你求出得到正确排列至少需要进行多少次交换。
输入格式
输入占一行,将 3×33×3 的初始网格描绘出来。
例如,如果初始网格如下所示:
1 2 3 x 4 6 7 5 8
则输入为:
1 2 3 x 4 6 7 5 8
输出格式
输出占一行,包含一个整数,表示最少交换次数。
如果不存在解决方案,则输出 −1−1。
输入样例:
2 3 4 1 5 x 7 6 8
输出样例
19
1、题目的目标
求最小步数 -> 用BFS
2、移动情况
移动方式:
转以后:a = x + dx[i], b = y + dy[i].
思想:把每一个状态看作图论的一个节点(节点a到节点b的距离为1——状态能转移)——> 起点到终点最少需要多少步
从初始状况移动到目标情况 —> 求最短路
3、问题
第一点:状态如何存储到队列里?
第二点:如何记录每一个状态的“距离”(即需要移动的次数)?
第三点:队列怎么定义(队列存储的是状态,状态可以用字符串表示),dist数组怎么定义(每一个状态到起始状态的距离——两个关键字:状态(字符串)、距离(int))——key-value
?——哈希表
4、解决方案
将 “3*3矩阵” 转化为 “字符串”
如:
所以:
队列可以用 queue<string>
//直接存转化后的字符串
dist数组用 unordered_map<string, int>
//将字符串和数字联系在一起,字符串表示状态,数字表示距离
5、矩阵与字符串的转换方式
【参考代码】
#include <iostream>
#include <cstring>
#include <algorithm>
#include <queue>
#include<unordered_map>
using namespace std;
//状态转移
int dx[4] = {-1, 0, 1, 0}, dy[4] = {0, 1, 0, -1};
int bfs(string strat)
{
//定义目标状态、
string end = "12345678x";
//定义队列和dist数组
queue<string> q;
unordered_map<string, int> d;
//初始化队列和dist数组
q.push(strat);
d[strat] = 0;
while(q.size())
{
// 获取队头元素,弹出队列
auto t = q.front();
q.pop();
//记录当前状态的距离,如果为最终状态则返回距离结果
int distance = d[t];
if(t == end) return d[t];
//查询x在一位数组中的下标,进行状态转换
int k = t.find('x');
int x = k / 3, y = k % 3;
//扩展队头元素(邻接节点入队)
for(int i = 0; i < 4; i ++)
{
//转移后的x的坐标(邻接节点)
int a = x + dx[i], b = y + dy[i];
//没有越界
if(a >= 0 && b >= 0 && a < 3 && b < 3)
{
//转移x
swap(t[k], t[a * 3 + b]);
//如果当前状态是第一次遍历,记录距离,入队
if(!d.count(t))
{
d[t] = distance + 1;
q.push(t);
}
//还原状态,为下一种转换情况做准备!
swap(t[k], t[a * 3 + b]);
}
}
}
//无法转换到目标状态,返回-1
return -1;
}
int main()
{
string strat;
// 输入起始状态
for (int i = 0; i < 9; i ++ )
{
char c;
cin >> c;
strat += c;
}
cout << bfs(strat);
}
2.魔板
【题目链接】1107. 魔板 - AcWing题库
Rubik 先生在发明了风靡全球的魔方之后,又发明了它的二维版本——魔板。
这是一张有 8 个大小相同的格子的魔板:
1 2 3 4 8 7 6 5
我们知道魔板的每一个方格都有一种颜色。
这 8 种颜色用前 8 个正整数来表示。
可以用颜色的序列来表示一种魔板状态,规定从魔板的左上角开始,沿顺时针方向依次取出整数,构成一个颜色序列。
对于上图的魔板状态,我们用序列 (1,2,3,4,5,6,7,8) 来表示,这是基本状态。
这里提供三种基本操作,分别用大写字母 A,B,C 来表示(可以通过这些操作改变魔板的状态):
A:交换上下两行;
B:将最右边的一列插入到最左边;
C:魔板中央对的4个数作顺时针旋转。下面是对基本状态进行操作的示范:
A:
8 7 6 5 1 2 3 4
B:
4 1 2 3 5 8 7 6
C:
1 7 2 4 8 6 3 5
对于每种可能的状态,这三种基本操作都可以使用。
你要编程计算用最少的基本操作完成基本状态到特殊状态的转换,输出基本操作序列。
注意:数据保证一定有解。
输入格式
输入仅一行,包括 8 个整数,用空格分开,表示目标状态。
输出格式
输出文件的第一行包括一个整数,表示最短操作序列的长度。
如果操作序列的长度大于0,则在第二行输出字典序最小的操作序列。
数据范围
输入数据中的所有数字均为 1 到 88 之间的整数。
输入样例:
2 6 8 4 5 7 3 1
输出样例:
7
BCABCCB
思路:
-
将初始状态加入队列,然后去搜索扩展,直到搜索到目标状态为止。每一次扩展ABC三种操作后,如果该状态没被遍历过,更新最短距离并加入队列。
-
由于要存储路径,因此我们开一个
pre
数组,记录当前状态的前驱状态,最终由终点逆推回去即可获得整个路径(上一篇博客的迷宫问题也是存储路径),在记录前驱状态的同时也把操作方式记录下来 -
状态的存储:看代码解释
-
状态的切换:字符串与一个
2行4列
的一个二维表的相互切换,以这个二维表为桥梁具体实现A、B、C三种操作
我们只需要按照先进行操作A,再B后C的操作顺序,最终得到的结果就会满足字典序最小。
【代码实现】
#include <iostream>
#include <cstring>
#include <algorithm>
#include <queue>
#include <unordered_map>
using namespace std;
char g[2][4];//状态图(状态图作为操作状态转换的一个桥梁)
// key-value
unordered_map<string, int> dist;// 记录到当前状态的最小步数
unordered_map<string, pair<char, string>> pre;// 存储当前状态的前驱,和操作方式
queue<string>q;
//在操作前 先把字符串变化到 2行4列的图表状态,方便我们操作的实现
void set(string state)
{
for(int i = 0; i < 4; i ++) g[0][i] = state[i];
for(int i = 7, j = 0; j < 4; i --, j ++) g[1][j] = state[i];
}
//在set得到的图基础上 在进行了ABC操作后得到新的状态图 将改图转换为状态字符串,最为操作后得到的字符串结果返回
string get()
{
string res;
for(int i = 0; i < 4; i ++) res += g[0][i];
for(int j = 3; j >= 0; j --) res += g[1][j];
return res;
}
string move0(string state)//操作A:交换上下两行
{
set(state);
for(int i = 0; i < 4; i ++) swap(g[0][i], g[1][i]);
return get();
}
string move1(string state)//操作B:将最右边的一列插入到最左边
{
set(state);
//(先将最后一列存下来,将前3列后移一列,最后一列移到最左边)
char v0 = g[0][3], v1 = g[1][3];
for(int i = 3; i >= 0; i --)
{
g[0][i] = g[0][i - 1];
g[1][i] = g[1][i - 1];
}
g[0][0] = v0, g[1][0] = v1;
return get();
}
string move2(string state)//操作C:魔板中央对的4个数作顺时针旋转
{
set(state);
char v = g[0][1];
g[0][1] = g[1][1];
g[1][1] = g[1][2];
g[1][2] = g[0][2];
g[0][2] = v;
return get();
}
int bfs(string start, string end)
{
if(start == end) return 0;//如果一开始的状态即为目标状态
q.push(start);
dist[start] = 0;
//扩展队头元素
while(q.size())
{
auto t = q.front();
q.pop();
//每种状态有三种操作方式,每次操作后得到对应的字符串结果
string m[3];
m[0] = move0(t);
m[1] = move1(t);
m[2] = move2(t);
//遍历三种状态方式,看看选哪个(按照先A后B再C操作得到的结果满足字典序从小到大)
for(int i = 0; i < 3; i ++)
{
if(!dist.count(m[i]))//如果这个状态没有被遍历过
{
q.push(m[i]);
dist[m[i]] = dist[t] + 1;//更新步数
pre[m[i]] = {'A' + i ,t};//将操作方式,是该邻接点的前驱记录下来
if(m[i] == end) return dist[end];// 如果在遍历中发现已经到达目标状态直接返回结果
}
}
}
return -1;
}
int main()
{
string start, end;
start = "12345678";
for(int i = 0; i < 8; i ++)
{
int x;
cin >> x;
end += (x + '0');
}
// cout << end << endl;
int step = bfs(start, end);
cout << step << endl;
//通过终点的前驱逐一反推获取操作的方式
string res;
while (end != start)
{
res += pre[end].first;//操作
end = pre[end].second;//状态字符串
}
reverse(res.begin(), res.end());//由于是逆推求前驱,因此结果要翻转
if (step > 0) cout << res << endl;
return 0;
}
三、总结
最小步数模型的难点在于状态的表示、切换还有存储,理清它们之间的逻辑关系代码就好实现了!
技巧:一维数组与二维数组坐标的转换
设[一维数组]下标为index(从0开始),二维数组长度为m * n,则:
一维数组转换为二维数组
x = index / n
y = index % n
二维数组转换为一维数组
index = x + y * n
技巧:在最小步数模型中状态和状态的距离通常用哈希表来进行存储(存在key-value的映射关系!),如map
,unordered_map
。
技巧:BFS存储路径问题,通常通过设置一个前驱pre
数组来记录当前节点或者状态的前驱,最后再逆推找出路径
学习内容参考:
acwing算法基础课、提高课
注:如果文章有任何错误或不足,请各位大佬尽情指出,评论留言留下您宝贵的建议!如果这篇文章对你有些许帮助,希望可爱亲切的您点个赞推荐一手,非常感谢啦