【算法】采集能量 (BFS)

2 阅读7分钟

题目

在《无主星渊》的太空战场中,玩家操控飞船从起点 S 出发,在 n×m 的网格中以最短时间采集一定能量。飞船每次仅能向上、下、左、右四个方向移动一个网格,能量必须以固定顺序被采集,飞船触碰到能量即视为采集能量。

一共有 5 个能量,依次编号为 1~5,玩家必须先采集编号小的能量,即先采集 1,再采集 2,最终采集 5
网格包含以下元素:

  • #:不可穿越的障碍物
  • .:可自由航行的太空区域
  • 1~5:表示 5 个能量的编号
  • S:飞船起点

请你帮玩家算一算,玩家最少移动几次可以集齐这 5 个能量。


输入描述

第一行:

n m(1 ≤ n, m ≤ 200)

表示网格的行数和列数。

接下来 n 行,每行一个长度为 m 的字符串,表示网格地图。


输出描述

输出一个整数,表示玩家最少所需的移动次数。
数据保证一定有解。


测试用例

样例 1

输入:

4 4
1.2.
.##3
.##4
S..5

输出:

9

样例 2

输入:

5 5
..4##
3....
##S..
#5...
##1.2

输出:

19

一开始容易想到的思路:递归 / DFS 搜路

这种题第一眼看上去很像迷宫题,很容易产生两个直觉:

  1. S 开始递归搜索;
  2. 每次尝试向四个方向走,看看能不能把 1~5 全部吃掉。

这个思路的问题在于:题目问的是“最少移动次数” ,而不是“能不能到达”。

这两个目标对应的常见算法并不一样:

  • 如果题目问的是 有没有路径,那 DFS / BFS 都可以;
  • 如果题目问的是 最少步数 / 最短路,那优先应该想到 BFS

也就是说,这题从一开始就不应该往递归 DFS 上靠,而应该优先往 网格最短路 上靠。


我在这题里踩过的坑

这题我不是一下子就写对的,中间踩了不少坑。这里把它们完整整理出来。

坑 1:把这题当成“普通迷宫最短路”

一开始如果只看到:

  • 上下左右走
  • # 不能走
  • S 走到某些点

很容易直接写一个普通 BFS,把所有非 # 的格子都当成可走区域。

但这题多了一个关键条件:

必须按顺序采集 1 -> 2 -> 3 -> 4 -> 5,且飞船触碰到能量就算采集。

这意味着:

  • 在去 1 的路上,不能提前踩到 2、3、4、5
  • 在去 2 的路上,不能提前踩到 3、4、5
  • 在去 3 的路上,不能提前踩到 4、5

所以这题不是普通 BFS,而是:

带编号上界限制的分段 BFS


坑 2:没意识到这题可以“拆段”

一开始很容易把整题想成:

S 一路搜到最终状态(已经吃完 1~5

这当然也能做,但状态会复杂很多。

实际上,这题因为采集顺序是固定的,所以天然可以拆成 5 段:

S -> 1
1 -> 2
2 -> 3
3 -> 4
4 -> 5

总答案就是这 5 段最短路之和。

也就是说:

整题不是一个“大搜索”,而是 5 个“小搜索”的和。

这是这道题最关键的建模转折。


坑 3:手搓四个方向判断,容易写乱

我一开始写 BFS 时,习惯手写四个方向:

这样写的问题是:

  • 很容易把 n / m 写反
  • 很容易某个方向条件不统一
  • 很容易漏掉越界判断

例如我就写错过:

  • xm
  • yn

这其实是非常典型的网格题 bug。

后面我才意识到,网格四方向应该直接模板化:

int dx[4] = {-1, 1, 0, 0};
int dy[4] = {0, 0, -1, 1};

然后统一循环四个方向。


坑 4:想手工维护层数 ans++

我一开始还尝试过:

  • 用双队列分层
  • 每扩展一层时手工 ans++

这种写法不是不能做,但非常容易出问题:

  • 容易把“扩展一个节点”错当成“走了一步”
  • 容易把 ans++ 放错位置
  • 容易在 BFS 中同时维护队列和层数,代码变复杂

后来我才意识到:

最短步数 BFS 最稳的写法,是直接用 dist 数组记录到每个点的最短距离。

比如:

dist[nx][ny] = dist[x][y] + 1;

这样第一次到终点时,dist 就已经是答案,根本不需要手工数层数。


坑 5:CanGo() 没抽出来,导致条件判断又长又容易错

我一开始把“一个格子能不能走”的判断,直接写在 BFS 的四个方向扩展里。
结果就是:

  • 条件很长
  • 容易重复
  • 容易漏判

尤其在这题里,合法格子不只是“不是 #”这么简单,还要考虑当前目标编号 k

  • # 不能走
  • . 能走
  • S 能走
  • 数字 <= k 能走
  • 数字 > k 不能走

如果不抽成函数,代码会非常乱。

正确做法应该是:

bool CanGo(char c, int k)

把这个逻辑收口到一个地方。


坑 6:C++ 容器语法不熟导致的实现错误

这题里我还暴露出几个 BFS 常见的 C++ 基础问题:

问题 1:queue 不能范围 for

不能写:

for (auto x : q)

queue 不是可迭代容器。

正确写法是:

auto cur = q.front();
q.pop();

问题 2:queue 没有 clear()

如果你想清空队列,不能直接写 q.clear()


问题 3:pair<int,int> 不能写 i[0]

应该写:

i.first
i.second

或者:

auto [x, y] = cur;

问题 4:main 里不能 return ans

如果你想输出答案,应该:

cout << ans << endl;

不是 return ans;

因为 return 返回的是程序退出码,不是题目输出。


正确思路

一、固定顺序,天然拆成 5 段

因为必须按顺序采集,所以整题可以拆成:

S -> 1
1 -> 2
2 -> 3
3 -> 4
4 -> 5

总答案就是:

dist(S,1) + dist(1,2) + dist(2,3) + dist(3,4) + dist(4,5)

二、每一段都是无权图最短路

网格中每走一步代价都相同,都是 1。
所以每一段都是标准的 无权图最短路问题,用 BFS 最合适。


三、BFS 时加上当前目标编号限制

当前目标是 k 时,一个格子能不能走,规则如下:

  • #:不能走
  • .:能走
  • S:能走
  • 1~5:只有当编号 <= k 时能走

所以我们可以抽出:

bool CanGo(char c, int k)

四、用 dist 记录最短步数

定义:

dist[i][j]

表示从当前段起点走到 (i,j) 的最短距离。

初始化:

  • 全部设成 -1
  • 起点设成 0

转移时:

dist[nx][ny] = dist[x][y] + 1;

第一次到达终点时,直接返回 dist[x][y]


完整正确代码

#include <iostream>
#include <vector>
#include <queue>
#include <string>
using namespace std;

bool CanGo(char c, int k) {
    if (c == '#') return false;
    if (c == '.' || c == 'S') return true;
    if (c >= '1' && c <= '5') return (c - '0') <= k;
    return false;
}

int BFS(const vector<string>& mp, pair<int, int> start, pair<int, int> target, int k) {
    int n = mp.size();
    int m = mp[0].size();

    vector<vector<int>> dist(n, vector<int>(m, -1));
    queue<pair<int, int>> q;

    q.push(start);
    dist[start.first][start.second] = 0;

    int dx[4] = {-1, 1, 0, 0};
    int dy[4] = {0, 0, -1, 1};

    while (!q.empty()) {
        auto cur = q.front();
        q.pop();

        int x = cur.first;
        int y = cur.second;

        if (x == target.first && y == target.second) {
            return dist[x][y];
        }

        for (int t = 0; t < 4; t++) {
            int nx = x + dx[t];
            int ny = y + dy[t];

            if (nx < 0 || nx >= n || ny < 0 || ny >= m) continue;
            if (dist[nx][ny] != -1) continue;
            if (!CanGo(mp[nx][ny], k)) continue;

            dist[nx][ny] = dist[x][y] + 1;
            q.push({nx, ny});
        }
    }

    return -1;
}

int main() {
    int n, m;
    cin >> n >> m;

    vector<string> mp(n);
    vector<pair<int, int>> pos(6);

    for (int i = 0; i < n; i++) {
        cin >> mp[i];
        for (int j = 0; j < m; j++) {
            if (mp[i][j] == 'S') {
                pos[0] = {i, j};
            } else if (mp[i][j] >= '1' && mp[i][j] <= '5') {
                pos[mp[i][j] - '0'] = {i, j};
            }
        }
    }

    int ans = 0;
    ans += BFS(mp, pos[0], pos[1], 1);
    ans += BFS(mp, pos[1], pos[2], 2);
    ans += BFS(mp, pos[2], pos[3], 3);
    ans += BFS(mp, pos[3], pos[4], 4);
    ans += BFS(mp, pos[4], pos[5], 5);

    cout << ans << endl;
    return 0;
}

复杂度分析

设网格大小为 n × m

每一段 BFS 最多遍历整个网格一次,所以复杂度为:

O(n*m)

总共 5 段,因此总复杂度为:

O(5*n*m) = O(n*m)

空间复杂度为:

O(n*m)

用于保存 dist 数组和 BFS 队列。


这题最后提炼出的经验

  1. 看到“最少步数 / 最短移动次数”,优先想 BFS。
  2. 顺序固定的问题,优先考虑拆段,不要一上来就做大状态搜索。
  3. 网格 BFS 里,四方向扩展优先用 dx / dy
  4. 重复判断优先抽函数,例如 CanGo()
  5. BFS 求最短路优先用 dist,不要手工数层数。