带你学习BFS最小步数模型

最小步数模型

一、简介

最小步数模型和最短路模型的区别?

最短路模型:某一个点到另一个点的最短距离(坐标与坐标之间)

最小步数模型:不再是点(坐标),而是状态到另一个状态的转变

BFS难点所在(最短路问题):

  1. 存储的数据结构:队列

    状态如何存储到队列里边(以什么形式)?

  2. 状态怎么表示,怎么转移?

3. dist

如何记录每一个状态的距离

技巧:在最小步数模型中状态和状态的距离通常用哈希表来进行存储(存在key-value的映射关系!),如mapunordered_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、题目的目标

image

求最小步数 -> 用BFS

2、移动情况

image

移动方式:

转以后:a = x + dx[i], b = y + dy[i].

思想:把每一个状态看作图论的一个节点(节点a到节点b的距离为1——状态能转移)——> 起点到终点最少需要多少步

从初始状况移动到目标情况 —> 求最短路

3、问题

第一点:状态如何存储到队列里?

第二点:如何记录每一个状态的“距离”(即需要移动的次数)?

第三点:队列怎么定义(队列存储的是状态,状态可以用字符串表示),dist数组怎么定义(每一个状态到起始状态的距离——两个关键字:状态(字符串)、距离(int))——key-value?——哈希表

4、解决方案

将 “3*3矩阵” 转化为 “字符串”

如:

image

所以:

队列可以用 queue<string>
//直接存转化后的字符串
dist数组用 unordered_map<string, int>
//将字符串和数字联系在一起,字符串表示状态,数字表示距离

5、矩阵与字符串的转换方式

image

【参考代码】

#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的映射关系!),如mapunordered_map

技巧:BFS存储路径问题,通常通过设置一个前驱pre数组来记录当前节点或者状态的前驱,最后再逆推找出路径

学习内容参考:
acwing算法基础课、提高课

注:如果文章有任何错误或不足,请各位大佬尽情指出,评论留言留下您宝贵的建议!如果这篇文章对你有些许帮助,希望可爱亲切的您点个赞推荐一手,非常感谢啦

posted @ 2022-01-21 18:37  时间最考验人  阅读(259)  评论(0编辑  收藏  举报