0 - 1 BFS 算法详解:从基本原理到经典题目建模

目录

[一、认识 0-1 BFS](#一、认识 0-1 BFS)

[1.1 什么是 0-1 BFS](#1.1 什么是 0-1 BFS)

[1.2 为什么不直接使用普通 BFS](#1.2 为什么不直接使用普通 BFS)

[二、0-1 BFS 的核心原理](#二、0-1 BFS 的核心原理)

[2.1 使用双端队列维护节点](#2.1 使用双端队列维护节点)

[2.2 松弛操作](#2.2 松弛操作)

[三、0-1 BFS 的代码实现](#三、0-1 BFS 的代码实现)

[3.1 算法复杂度](#3.1 算法复杂度)

[四、0-1 BFS 的正确性证明](#四、0-1 BFS 的正确性证明)

[4.1 双端队列的处理顺序](#4.1 双端队列的处理顺序)

[4.2 为什么第一次有效取出时,距离已经最短](#4.2 为什么第一次有效取出时,距离已经最短)

五、经典题目中的建模思路

[5.1 洛谷 P4554:小明的游戏](#5.1 洛谷 P4554:小明的游戏)

[5.2 洛谷 P1346:电车](#5.2 洛谷 P1346:电车)

[5.3 AtCoder ABC176 D:Wizard in Maze](#5.3 AtCoder ABC176 D:Wizard in Maze)

[5.4 Codeforces 1063B:Labyrinth](#5.4 Codeforces 1063B:Labyrinth)

六、进阶题目与进一步思考

[6.1 洛谷 P4667:Switch the Lamp On](#6.1 洛谷 P4667:Switch the Lamp On)

[6.2 Codeforces 590C:Three States](#6.2 Codeforces 590C:Three States)

[6.3 洛谷 P1948:Telephone Lines](#6.3 洛谷 P1948:Telephone Lines)

[七、0-1 BFS 的常见错误](#七、0-1 BFS 的常见错误)

[7.1 使用普通队列](#7.1 使用普通队列)

[7.2 将第一次发现节点当作最短距离](#7.2 将第一次发现节点当作最短距离)

[7.3 忽略有向图和无向图的区别](#7.3 忽略有向图和无向图的区别)

[7.4 将 0-1 BFS 用于任意非负边权](#7.4 将 0-1 BFS 用于任意非负边权)

八、总结


一、认识 0-1 BFS

在图论中,最短路问题是一个非常重要的知识点。对于不同的边权情况,我们通常会选择不同的算法解决。

例如:

  • 当所有边权都相同 时,可以使用普通 BFS 求最短路。

  • 当边权非负,但不一定相同时,可以使用 Dijkstra 算法求最短路。

  • 当边权只有 0 和 1 两种取值时,我们还可以使用一种特殊的最短路算法------0-1 BFS。

0-1 BFS 可以看作对普通 BFS 的一种扩展。它利用双端队列维护待处理的节点,在边权只有 0 和 1 的情况下,可以在线性时间内求出单源最短路。

1.1 什么是 0-1 BFS

1.2 为什么不直接使用普通 BFS

普通 BFS 适用于无权图,或者所有边权都相同的图。

在普通 BFS 中,我们按照节点距离起点的层数依次遍历。由于每经过一条边,路径长度都会增加相同的值,因此第一次到达某个节点时,就已经获得了它的最短距离。

但在 0-1 权图中,情况发生了变化。

假设从起点 (s) 到节点 (v) 存在两条路径:

  • 路径 A:经过 3 条边,每条边权均为 0,总权值为 0。

  • 路径 B:只经过 1 条边,但这条边的权值为 1,总权值为 1。

显然,路径 A 才是最短路。

这说明:经过的边数更少,并不意味着路径权值之和更小。

普通 BFS 无法直接按照边数确定最短路,因此我们需要改变节点的处理顺序。

二、0-1 BFS 的核心原理

2.1 使用双端队列维护节点

普通 BFS 使用普通队列 queue,而 0-1 BFS 使用双端队列 deque。

双端队列既可以从队头插入和删除元素,也可以从队尾插入和删除元素。

在 0-1 BFS 中,我们根据边权决定新节点的插入位置:

  • 如果边权为 0,将更新后的节点加入队头

  • 如果边权为 1,将更新后的节点加入队尾

通过这种方式,0-1 BFS 利用双端队列维护待处理节点的距离顺序,使距离较小的节点优先得到处理,从而高效地求解最短路问题。

2.2 松弛操作

0-1 BFS 依然使用最短路算法中的松弛操作。

然后根据边权,将节点 v 插入双端队列的队头或队尾。

这里需要注意,不能仅仅因为某个节点已经被发现,就认为它的最短距离已经确定。

三、0-1 BFS 的代码实现

cpp 复制代码
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
/*
*/
static const int moves[] = {-1, 0, 1, 0, -1};

void solve()
{
    int n, a, b;
    cin >> n >> a >> b;
    vector<vector<pair<int, int>>> grahp(n + 1);
    for(int i = 1; i <= n; ++i)
    {
        int sz;
        cin >> sz;
        for(int j = 0; j < sz; ++j)
        {
            int v, w = 1;
            cin >> v;
            if(j == 0)
                w = 0;
            grahp[i].push_back({v, w});
        }
    }
    // a -> b 的最短路径
    vector<int> dis(n + 1, INT_MAX);
    deque<int> dq;
    dq.emplace_front(a);
    dis[a] = 0;
    while(!dq.empty())
    {
        int node = dq.front();
        dq.pop_front();
        if(node == b)
        {
            cout << dis[node] << "\n";
            return ;
        }
        for(auto &[next, w] : grahp[node])
        {
            if(dis[node] + w < dis[next])
            {
                dis[next] = dis[node] + w;
                if(w == 0)
                    dq.push_front(next);
                else
                    dq.push_back(next);
            }
        }
    }
    cout << -1 << "\n";
}

int main()
{
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    int t = 1;
    // cin >> t;
    while (t--)
        solve();

    return 0;
}

这里使用邻接表存储图,graphu 中的每个元素都是一个二元组:

  • v:当前边指向的节点

  • w:当前边的权值,必须为 0 或 1

需要注意,这个模板默认使用非负的 0/1 边权。如果原图是无向图,建图时需要将每条边分别加入两个方向;如果原图是有向图,则只添加题目允许的方向。

3.1 算法复杂度

对于具有 (N) 个节点、(M) 条边的图,0-1 BFS 的时间复杂度为:O(N + M)

空间复杂度为:O(N + M)

其中,邻接表用于存储图,距离数组和双端队列用于维护最短路计算过程中的状态。

在边权严格限制为 0 和 1 的情况下,0-1 BFS 通常比使用堆优化的 Dijkstra 更简单,也具有更低的常数开销。

四、0-1 BFS 的正确性证明

为什么只根据边权决定队头或队尾,就能够保证求出最短路?

我们可以从普通 BFS 和 Dijkstra 的思路出发进行理解。

4.1 双端队列的处理顺序

假设当前处理的节点距离为 (d)。

对于权值为 0 的边,新节点距离仍然为 (d),因此将它加入队头。

对于权值为 1 的边,新节点距离为 (d+1),因此将它加入队尾。

在这个过程中,双端队列能够按照有效距离的非递减顺序处理节点。这与 Dijkstra 算法使用优先队列,每次取出当前距离最小的节点,有相似的思想。

4.2 为什么第一次有效取出时,距离已经最短

假设节点 (u) 第一次以当前最短距离对应的有效记录被取出,但它的距离还不是最短距离。

那么必然存在另一条更短的路径到达 (u)。

沿着这条更短的路径,考虑第一个尚未被以最短距离处理的节点。它的前驱节点必然能够先以不大于 (distu) 的距离得到处理,并通过权值为 0 或 1 的边将这个节点加入双端队列。

由于更短路径对应的距离更小,这个节点应当优先于距离较大的 (u) 被处理,与 (u) 已经以当前距离成为第一个有效取出节点相矛盾。

因此,节点第一次以有效记录被取出时,其距离已经是最短距离。

这也解释了为什么过期记录可以直接跳过:后续路径不会再将一个已经以最短距离处理的节点更新为更短距离。

五、经典题目中的建模思路

掌握 0-1 BFS 的模板只是第一步。实际做题时,更重要的是判断题目中的什么操作对应权值为 0,什么操作对应权值为 1。

5.1 洛谷 P4554:小明的游戏

题目链接:P4554 --- 小明的游戏

这道题将网格中的相邻移动转换为边。

当从一个格子移动到相邻格子时:

  • 如果两个格子的字符相同,移动代价为 0。

  • 如果两个格子的字符不同,移动代价为 1。

因此,我们可以直接将每个格子看作图中的节点,并根据相邻格子的字符关系确定边权。

这道题的重点在于理解:移动距离不一定等于移动步数,真正需要最小化的是路径上的总代价。

5.2 洛谷 P1346:电车

题目链接:P1346 --- 电车

这道题的关键是根据题意构造有向图。

每个路口可以通向若干其他路口,其中:

  • 第一条指定方向的线路代价为 0。

  • 选择其他方向的线路,需要付出代价 1。

因此,我们将路口作为节点,将可以通行的线路作为有向边,并根据是否需要改变原先指定的方向设置边权。

这里的难点不在于双端队列的操作,而在于将实际问题中的选择和代价抽象成有向图中的边权。

5.3 AtCoder ABC176 D:Wizard in Maze

题目链接:ABC176 D --- Wizard in Maze

这道题进一步改变了普通网格图的转移方式。

我们可以将移动操作分为两类:

  • 普通上下左右移动:代价为 0。

  • 使用魔法移动到允许的范围内:代价为 1。

于是,网格中的每个可通行格子仍然作为一个节点,普通移动对应权值为 0 的边,魔法移动对应权值为 1 的边。

这道题说明,0-1 BFS 并不要求图必须具有某种固定结构。只要能够把状态之间的转移表示为权值为 0 或 1 的边,就可以考虑使用它。

5.4 Codeforces 1063B:Labyrinth

题目链接:CF1063B --- Labyrinth

这道题的关键是将移动限制转换为最短路问题。

题目分别限制向左和向右移动的次数。我们可以将:

  • 向左移动的代价设置为 1。

  • 向右、向上、向下移动的代价设置为 0。

通过 0-1 BFS,我们可以计算到达每个格子所需要的最少向左移动次数。

设起点的列坐标为 (s_y),当前格子的列坐标为 (y),最少向左移动次数为 (L),向右移动次数为 (R)。

由于每次向右移动使列坐标增加 1,每次向左移动使列坐标减少 1,因此:

y - s_y = R - L

整理可得:

R = L + y - s_y

这样,我们就可以根据计算得到的 (L) 推导出 (R),进而判断当前格子是否满足向左、向右移动次数的限制。

最短路算法不仅可以求解路径代价,还可以通过恰当的代价定义,将题目中的约束转换为可以计算的量。

六、进阶题目与进一步思考

完成前四道题之后,接下来可以逐步尝试更复杂的建模问题。

6.1 洛谷 P4667:Switch the Lamp On

题目链接:P4667 --- Switch the Lamp On

这道题不再是简单地将每个格子作为节点,而是需要重新定义图中的节点和边。

一种常见建模方式是:

  • 将网格交点作为节点。

  • 将相邻交点之间的对角线连接关系作为边。

  • 如果当前格子的斜线方向符合穿越要求,边权为 0。

  • 如果需要旋转斜线才能通过,边权为 1。

这道题的重点是如何从图形结构中提取节点、边以及对应的修改代价。

此外,还需要注意网格的奇偶性:如果起点与终点之间的格点奇偶性不满足可达条件,就不存在合法路径。

6.2 Codeforces 590C:Three States

题目链接:CF590C --- Three States

这道题涉及三个不同的区域,需要考虑如何连接它们。

可以分别从三个区域出发进行最短路计算:

  • 进入普通空地需要付出代价 1。

  • 进入区域本身的格子不需要额外付出代价。

  • 墙壁不能通行。

每次计算都可以使用 0-1 BFS 得到对应区域到其他位置的最小代价。

之后,需要考虑如何组合三组距离,得到连接三个区域的最小总代价。

特别要注意,当三个区域通过同一个普通空地连接时,这个空地可能会在三组距离中重复计费,因此组合答案时必须修正重复计算的部分。

这道题进一步训练的是多源最短路结果的组合,而不只是单次最短路的计算。

6.3 洛谷 P1948:Telephone Lines

题目链接:P1948 --- Telephone Lines

这道题的核心是将二分答案与 0-1 BFS 结合起来。

假设当前二分的候选答案为 (x),那么我们可以将每条边的权值重新定义为:

此时,0-1 BFS 求出的最短距离,就表示从起点到终点的路径中,权值超过 (x) 的边的最少数量。

我们只需要判断这个数量是否不超过题目允许的免费边数量,就能检查当前的候选答案是否可行。

这道题的难点在于:先通过二分答案确定判定条件,再把判定过程转化为 0-1 BFS。

它说明,算法的价值不仅在于单独解决某个问题,也在于和其他算法组合后解决更复杂的问题。

七、0-1 BFS 的常见错误

7.1 使用普通队列

如果仍然使用普通队列,并且所有新节点都从队尾加入,就无法保证节点按照最短距离的顺序处理。因此,必须根据边权选择双端队列的插入位置。

7.2 将第一次发现节点当作最短距离

在最短路算法中,一个节点被发现,不意味着它的最短距离已经确定。

如果后续找到了更短的路径,就需要更新距离。

在本文的模板中,我们使用松弛操作更新距离,并通过距离记录判断队列中的旧状态是否已经失效。

7.3 忽略有向图和无向图的区别

有向图中的边只能沿指定方向通行,而无向图中的边通常需要同时添加两个方向。

如果建图时没有正确处理边的方向,即使 0-1 BFS 模板完全正确,也可能得到错误答案。

7.4 将 0-1 BFS 用于任意非负边权

0-1 BFS 的高效性建立在边权只有 0 和 1 的条件上。

如果边权可能为 2、3 或其他非负数,就不能直接使用这个模板。一般情况下,应考虑使用 Dijkstra 等适合相应边权范围的算法。

八、总结

从 P4554 到 P1346,再到 ABC176 D 和 CF1063B,虽然它们都可以使用 0 - 1 BFS,但每道题的建模方式并不相同:

  • 有的题目根据格子属性确定移动代价。

  • 有的题目根据操作选择确定边权。

  • 有的题目将普通移动和特殊移动划分为两类。

  • 有的题目将移动次数限制转换为最短路距离。

对于 0-1 BFS,我认为最重要的不是记住双端队列的两种插入方式,而是建立这样一种思考习惯:

当题目中的转移代价只有 0 和 1 时,能否将问题转换为图上的最短路?如果可以,节点、边以及边权应该如何定义?

当能够独立回答这些问题时,才算真正开始掌握 0-1 BFS。

相关推荐
飞Link3 小时前
什么是 PX4?一文了解 PX4 在空地协同中的作用
算法·无人机
进化中的刀盾兽3 小时前
从零实现 C++ Json-Rpc(十):把消息交给真实连接——MuduoConnection 与 ConnectionFactory
c++·rpc
一去不复返的通信er9 小时前
力扣-图论-岛屿数量
算法·leetcode·图论
青瓦梦滋10 小时前
WebSocket协议
linux·服务器·网络·c++·websocket·网络协议·http
程序 代码狂人12 小时前
切片表与分区表的区别(数据开发面试版)
java·大数据·开发语言·数据仓库·算法
HDU-Dade13 小时前
【校招笔试真题】简要介绍 Self-Attention 的前沿算法
人工智能·算法
小郎君65815 小时前
Vue keep-alive 切标签后滚动位置回顶?因为 onDeactivated 里读到的 scrollTop 是 0
算法
CodeYp201915 小时前
第15讲:二叉搜索树
开发语言·c++·二叉搜索树
邵奈一15 小时前
GPT-6.1 Terra速度是Sol的3倍,价格减半
算法