图论-广度优先遍历及其拓展

图论宽度优先遍历, 朴素,按层,01, 双向,应用。

引言

广度优先遍历(Breadth-First Search, BFS)是图论和树论中基本的查找搜索算法,是广大图算法的基石。

BFS 的细节和可玩性极高。它是很多上层算法的原型:

  • 拓扑排序的 Kahn 算法
  • 最小生成树的 Prim 算法
  • 单源最短路径的 Dijkstra 算法
  • 网络流的 Edmonds-Karp 算法
本篇涵盖

朴素 BFS、按层 BFS、多源 BFS、01-BFS、双向 BFS、剪枝策略,外加两个经典应用:连通分量、判环。 Java 图论 搜索

前置知识

数据结构:队列、双端队列。 :经典 BFS、按层 BFS(即树的层序遍历)。 编程语言:由于该文最早在CSDN写下, 那个时候我使用 Java ,因此本文主要使用 Java 演示, 补充C++


BFS 的用途与适用条件

  • 遍历图中所有的顶点
  • 求两点之间所有的路径
  • 求两点之间的最短路径(限定条件,下面详述)
  • 求图的连通性连通分量
  • 各种图算法(Kahn、Prim、Dijkstra…)的底层支持

求最短路径的适用条件

🚨 必须记住的限制

BFS 求最短路径的适用范围比 Dijkstra/SPFA/Bellman-Ford 都要小

  1. 无权图 → 等价于求最短边数。
  2. 带权图 → 边权必须非负所有边权相同(不要求统一为 1,但必须全部相等)。

不满足以上条件,请绕道 Dijkstra 等更通用的最短路径算法。

BFS 的"最短"本质上是层数最浅——从源点出发先扩展到的点一定层数最浅,这是它的根基。

朴素 BFS

图解:从节点 f 出发

BFS 的扩散过程

先看 f 的邻居: b、e、c、g。

再看这四个的邻居: a、d 浮出水面。

f 是第一层, b·e·c·g 是第二层, a、d 是第三层—— 广度优先树就此长成。

核心容器
  • 队列:先进先出,保证近的节点先被处理
  • 哈希集合 / visited 数组:记忆化,防止重复访问,避免环中死循环

算法流程

  • 给定源节点 s,加入队列,标记为已访问
  • 弹出队头节点,扫描它的所有邻居:未访问的入队并标记
  • 重复直至队列为空;弹出时可执行任意处理(打印、收集、更新答案……)

三种建图方式实现

任选一种你喜欢的方式即可。三种方式数据结构不同,但 BFS 的主体逻辑完全一致。

三种建图怎么选?
  • 链式前向星:竞赛/算法题首选,数组实现快、内存可控
  • 邻接矩阵:稠密图(边数 ≈ V2V^2)或需要 O(1)O(1) 查询某条边是否存在
  • 邻接表:工程代码、节点稀疏、写起来直观

按层 BFS(Level-Order BFS)

朴素 BFS 不区分层,按层 BFS 显式地把层切分出来。这是树的层序遍历的图论推广。

关键技巧

在每轮 while 循环开始时,先记录当前队列大小 size,然后只处理这 size 个节点——它们就是同一层。
按层模板java
public static int bfsByLevel(int start) {
    Queue<Integer> queue = new ArrayDeque<>();
    boolean[] visited = new boolean[MAXN];
    queue.add(start);
    visited[start] = true;

    int level = 0;
    while (!queue.isEmpty()) {
        int size = queue.size();      // ⭐ 锁定当前层节点数
        for (int i = 0; i < size; i++) {
            int cur = queue.poll();
            // 处理同层节点 cur,level 即它所在层
            for (int next : graph.get(cur)) {
                if (!visited[next]) {
                    visited[next] = true;
                    queue.add(next);
                }
            }
        }
        level++;                      // ⭐ 这一层处理完,层号 +1
    }
    return level;
}

例题:LeetCode 102. 二叉树的层序遍历

🌲 LeetCode 102 完整代码
LC102.javajava
class Solution {
    public List<List<Integer>> levelOrder(TreeNode root) {
        List<List<Integer>> ans = new ArrayList<>();
        if (root == null) return ans;
        Deque<TreeNode> queue = new ArrayDeque<>();
        queue.offer(root);
        while (!queue.isEmpty()) {
            int size = queue.size();
            List<Integer> level = new ArrayList<>();
            for (int i = 0; i < size; i++) {
                TreeNode cur = queue.poll();
                level.add(cur.val);
                if (cur.left != null) queue.offer(cur.left);
                if (cur.right != null) queue.offer(cur.right);
            }
            ans.add(level);
        }
        return ans;
    }
}

例题:LeetCode 127. 单词接龙

🔤 LeetCode 127 思路+代码

思路:把每个单词当作图的节点,相差一个字母的两个单词之间有一条边。从 beginWordendWord 的最少转换次数 = BFS 的最少层数 + 1。

优化建图:不要两两枚举单词建图(O(N2L)O(N^2 \cdot L))。改用虚拟节点:单词 hot*ot/h*t/ho* 三个虚拟节点相连。


多源 BFS(Multi-Source BFS)

什么是多源 BFS?

多个起点同时出发,求所有节点到"任意一个起点"的最短距离(边数)。

核心技巧

把所有源点一次性全部入队,然后跑普通 BFS。神奇的是——队列里同时存在的节点们,"距离最近的某个源点的层数"是相同的。

为什么这是对的?想象虚拟超级源点 SS,它向所有真实源点连一条权 0 的边。从 SS 出发的 BFS = 把所有真实源点同时入队的 BFS。

例题:LeetCode 994. 腐烂的橘子

🍊 LeetCode 994 完整代码

例题:LeetCode 542. 01 矩阵

🧊 LeetCode 542 思路+代码

逆向思考:求每个 1 到最近 0 的距离 = 把所有 0 当源点,多源 BFS 一次扩散。

LC542.javajava
class Solution {
    public int[][] updateMatrix(int[][] mat) {
        int m = mat.length, n = mat[0].length;
        int[][] dist = new int[m][n];
        Deque<int[]> queue = new ArrayDeque<>();

        for (int i = 0; i < m; i++) {
            for (int j = 0; j < n; j++) {
                if (mat[i][j] == 0) queue.offer(new int[]{i, j});
                else dist[i][j] = -1;     // 用 -1 标记未访问
            }
        }

        int[][] dirs = {{-1,0},{1,0},{0,-1},{0,1}};
        while (!queue.isEmpty()) {
            int[] cur = queue.poll();
            for (int[] d : dirs) {
                int ni = cur[0] + d[0], nj = cur[1] + d[1];
                if (ni >= 0 && ni < m && nj >= 0 && nj < n && dist[ni][nj] == -1) {
                    dist[ni][nj] = dist[cur[0]][cur[1]] + 1;
                    queue.offer(new int[]{ni, nj});
                }
            }
        }
        return dist;
    }
}

01-BFS(Zero-One BFS)

适用场景

边权只有 0 和 1 两种值的最短路径问题。Dijkstra 可以做,但 O((V+E)logV)O((V+E) \log V) 略慢;01-BFS 可以做到 O(V+E)O(V + E)

核心思想

普通 BFS 用队列,01-BFS 用双端队列

  • 权 0 边:邻居加到队头 addFirst(不增加距离,优先处理)
  • 权 1 边:邻居加到队尾 addLast(增加 1 距离,正常排队)
这样可以保证:双端队列中元素的距离值始终单调不减——和 Dijkstra 的优先队列性质等价,但不需要堆。

例题:LeetCode 1368. 使网格图至少有一条有效路径的最小代价

题意

网格中每个格子有一个箭头方向(1 右、2 左、3 下、4 上)。从 (0,0)(m-1,n-1),沿箭头方向走代价 0,逆箭头走代价 1,求最小代价。

🎯 LeetCode 1368 完整代码
LC1368.javajava
class Solution {
    public int minCost(int[][] grid) {
        int m = grid.length, n = grid[0].length;
        int[][] dist = new int[m][n];
        for (int[] row : dist) Arrays.fill(row, Integer.MAX_VALUE);
        dist[0][0] = 0;

        // 方向编号 1234 对应 dirs[1..4]
        int[][] dirs = {{0,0}, {0,1}, {0,-1}, {1,0}, {-1,0}};

        Deque<int[]> deque = new ArrayDeque<>();
        deque.offerFirst(new int[]{0, 0});

        while (!deque.isEmpty()) {
            int[] cur = deque.pollFirst();
            int x = cur[0], y = cur[1];
            for (int k = 1; k <= 4; k++) {
                int nx = x + dirs[k][0], ny = y + dirs[k][1];
                if (nx < 0 || nx >= m || ny < 0 || ny >= n) continue;
                int cost = (grid[x][y] == k) ? 0 : 1;
                int nd = dist[x][y] + cost;
                if (nd < dist[nx][ny]) {
                    dist[nx][ny] = nd;
                    if (cost == 0) deque.offerFirst(new int[]{nx, ny}); // ⭐
                    else deque.offerLast(new int[]{nx, ny});             // ⭐
                }
            }
        }
        return dist[m-1][n-1];
    }
}

例题:LeetCode 2290. 到达角落需要移除障碍物的最小数目

🧱 LeetCode 2290 思路+代码

思路:空格走代价 0,障碍走代价 1 → 完美匹配 01-BFS。

LC2290.javajava
class Solution {
    public int minimumObstacles(int[][] grid) {
        int m = grid.length, n = grid[0].length;
        int[][] dist = new int[m][n];
        for (int[] row : dist) Arrays.fill(row, Integer.MAX_VALUE);
        dist[0][0] = 0;

        int[][] dirs = {{-1,0},{1,0},{0,-1},{0,1}};
        Deque<int[]> dq = new ArrayDeque<>();
        dq.offerFirst(new int[]{0, 0});

        while (!dq.isEmpty()) {
            int[] cur = dq.pollFirst();
            int x = cur[0], y = cur[1];
            if (x == m - 1 && y == n - 1) return dist[x][y];
            for (int[] d : dirs) {
                int nx = x + d[0], ny = y + d[1];
                if (nx < 0 || nx >= m || ny < 0 || ny >= n) continue;
                int nd = dist[x][y] + grid[nx][ny];
                if (nd < dist[nx][ny]) {
                    dist[nx][ny] = nd;
                    if (grid[nx][ny] == 0) dq.offerFirst(new int[]{nx, ny});
                    else dq.offerLast(new int[]{nx, ny});
                }
            }
        }
        return dist[m-1][n-1];
    }
}
易错点:dist 必须比较再更新

01-BFS 中同一个节点可能被多次入队(先以较大距离入,后被更短路径"覆盖")。所以每次出队要再判断一次当前的 dist 是否就是最优值,否则跳过——或者像上面这样,在入队前比较 dist


优先 BFS(与堆结合)

核心区别

普通 BFS 使用的是 FIFO 队列,谁先入队谁先出队,因此它天然按照“层数”扩散。

优先 BFS 则把普通队列换成了 优先级队列 / 堆,每次都取当前优先级最高的节点进行扩展。

简单说:

text
普通 BFS:按入队顺序扩展
优先 BFS:按优先级扩展

核心技巧

  • 队列换成堆
    普通 BFS:
    cpp
    queue<Node> q;
    

    优先 BFS:
    cpp
    priority_queue<Node> heap;
    

    如果每次要取最小值,一般使用小根堆。
  • BFS 框架不变
    优先 BFS 仍然保留 BFS 的基本流程:
    text
    取出一个点
    枚举它的上下左右 / 相邻节点
    没访问过就加入容器
    

    区别只是容器从 queue 换成了 priority_queue
  • 优先级怎么定?
    由题目决定。
    比如:
    text
    最短路问题:距离越小,优先级越高
    接雨水 II:边界高度越低,优先级越高
    最小代价问题:总代价越小,优先级越高
    
  • 本质理解
    text
    优先 BFS = BFS 扩展框架 + 堆控制扩展顺序
    

    它经常和 Dijkstra 很像,因为二者都在做:
    text
    每次从当前最优状态继续扩展
    

例题:Leetcode 接雨水 II

木桶效应

二维接雨水的关键是:水能不能存住,取决于当前边界里最低的那块板。

所以我们不能随便扩展,而是必须每次从当前最低边界开始向内扩展。

思路流程

  • 先把矩阵最外圈全部加入小根堆
  • 最外圈无法接水,因为水会从边界流出去
  • 每次弹出当前最低的边界
  • 用这个边界去检查上下左右四个格子
  • 如果邻居比当前水线低,就说明它可以接水
  • 邻居入堆时,它的新高度要变成:
    cpp
    max(heightMap[nx][ny], waterLine)
    

为什么是 max(height, waterLine)

假设当前最低边界高度是 w,邻居原本高度是 h

如果:

text
h < w

说明这个位置可以接水:

text
接水量 = w - h

但是它被水填平以后,对后面的格子来说,它已经不是高度 h 了,而是高度 w 的“新边界”。

所以入堆高度是:

cpp
max(h, w)

也就是:

text
新边界高度 = max(自身高度, 当前水线高度)

通用模板

cpp
while (!heap.empty()) {
    auto [w, x, y] = heap.top();
    heap.pop();

    for (四个方向) {
        int nx = x + dx;
        int ny = y + dy;

        if (越界 || 已访问) continue;

        visited[nx][ny] = true;

        int h = heightMap[nx][ny];

        if (h < w) {
            ans += w - h;
        }

        heap.push({max(h, w), nx, ny});
    }
}

总结

优先 BFS 就是用堆控制扩展顺序的 BFS。普通 BFS 按层扩展,优先 BFS 按“当前最优状态”扩展。

双向 BFS(Bidirectional BFS)

动机

朴素 BFS 的搜索空间是 O(bd)O(b^d)(分支因子 bb,深度 dd)。从两端同时搜,每边只需要搜深度 d/2d/2,总规模降为 O(2bd/2)O(2 \cdot b^{d/2}) —— 指数级提速。

核心技巧

  • 维护两个集合 beginSetendSet,分别存当前层的节点
  • 每轮总是扩展规模较小的那个集合(这是关键优化)
  • 扩展时如果触碰到对方集合 → 找到了,返回当前层数
  • visited 集合去重(双向都要标)

例题:LeetCode 127. 单词接龙(双向版)

同样是 LC127,朴素 BFS 处理大数据集会超时——双向 BFS 才是面试官期待的标准答案。
🚀 LeetCode 127 双向 BFS 完整代码

例题:LeetCode 752. 打开转盘锁

🔒 LeetCode 752 思路

思路:每个 4 位密码是一个状态节点,相邻状态相差一位(每位 ±1,共 8 种邻居)。从 "0000"target,避开 deadends。状态空间 10410^4 不大但双向 BFS 仍然能显著加速。

实现模板与 LC127 完全一致,只把"换字母"换成"转拨号"即可。

什么时候不要用双向 BFS?
  • 状态空间小(<104 < 10^4):朴素 BFS 已经够快
  • 终点未知或终点是"任意满足条件的状态"(无法从终点反向扩展)
  • 边权不一致(双向 BFS 假设搜索代价对称)

BFS 剪枝策略

剪枝的本质:不入队那些不可能更优的节点

1. 状态去重

最常见的剪枝。visited 数组/哈希集合就是它。

2. 距离剪枝

如果已知答案上界 best,跳过 dist[next] >= best 的扩展。

3. 状态压缩

当节点状态包含多个维度(位置 + 持有钥匙集合等),把状态打包成 int/long 入队。

🔑 例题:LeetCode 864. 获取所有钥匙的最短路径

状态(x, y, keysBitmask)。同一坐标但持钥匙不同算不同状态。

4. 启发式剪枝(A* 的雏形)

为每个状态估计一个到目标的下界 h,在队列中优先扩展 dist + h 较小者。这就是 A* 算法。BFS 是 h ≡ 0 的特例。

一句话总结剪枝思路

少入队、晚出队——能不入队的状态绝不入队,能延后处理的状态延后处理。


应用一:无向图连通分量

题目:LeetCode 323(会员)

求无向图的连通分量个数。

思路:遍历所有顶点,遇到未访问的就 BFS 一次染色,BFS 次数即连通分量数。

BFS_Components.javajava
class Solution {
    public int countComponents(int n, int[][] edges) {
        List<List<Integer>> graph = new ArrayList<>();
        for (int i = 0; i < n; i++) graph.add(new ArrayList<>());
        for (int[] e : edges) {
            graph.get(e[0]).add(e[1]);
            graph.get(e[1]).add(e[0]);
        }
        boolean[] visited = new boolean[n];
        int components = 0;
        Deque<Integer> queue = new ArrayDeque<>();

        for (int i = 0; i < n; i++) {
            if (visited[i]) continue;
            queue.offer(i);
            visited[i] = true;
            while (!queue.isEmpty()) {
                int cur = queue.poll();
                for (int next : graph.get(cur)) {
                    if (!visited[next]) {
                        visited[next] = true;
                        queue.offer(next);
                    }
                }
            }
            components++;     // ⭐ 每发起一次 BFS,分量数 +1
        }
        return components;
    }
}
另一种解法

连通分量也可以用并查集做(每条边合并两端点,最后剩余集合数即答案),代码更短、常数更小。详见文末推荐阅读区。


应用二:判环问题

题目:LeetCode 684. 冗余连接

给一棵树多加了一条边变成图,找出这条多余的边。

思路:对每条新边 (u, v),加入前先 BFS 查询 u → v 是否已经连通。已连通 ⇒ 该边形成环。

判环:BFS 不是最优
  • 无向图判环:并查集是最优解(O(α(n))O(\alpha(n)))。
  • 有向图判环:用拓扑排序——Kahn 算法(BFS)或 Tarjan 算法(DFS),都是 O(V+E)O(V + E)

暴力 BFS/DFS 判环只是基础解法,面试遇到优先用并查集或拓扑排序。


总结

🎀 BFS 全景速记

变种关键改动典型用途
朴素 BFS队列 + visited遍历、连通性
按层 BFS每轮记 size层序遍历、最短转换次数
多源 BFS所有源点同时入队网格距离扩散
01-BFS双端队列,0 头 1 尾边权 ∈ {0,1} 的最短路
双向 BFS两端同时扩展,扩小集合状态空间巨大、终点已知

BFS 是最朴素的图算法,也是最深刻的图算法——拓扑排序、最小生成树、最短路径的种子都埋在它的队列里。


推荐阅读

二叉树专题重置
内存池仿Nginx_C++实现

评论区

评论加载中...