BFS 与最短路径

BFS 算法框架

它就是层序遍历,多了一个 visited

二叉树的层序遍历已经把框架写过了: 队列、每轮记下当前层的节点数、弹一个推它的孩子。

搬到图上只需要加一件事:visited。树没有环,节点不会被重复访问; 图处处是环,不记就会绕回去(同网格 DFS那一条)。

function bfs(start, target, neighborsOf) {
  const q = [start];
  const visited = new Set([start]);
  let step = 0;
  let head = 0;                       // 下标当队头,别用 shift()

  while (head < q.length) {
    const sz = q.length - head;       // 🚨 先存下来,同层序遍历
    for (let i = 0; i < sz; i++) {
      const cur = q[head++];

      if (cur === target) return step; // ⭐ 第一次碰到就是最短,见下

      for (const next of neighborsOf(cur)) {
        if (visited.has(next)) continue;
        visited.add(next);            // 🚨 入队时就标记,不是出队时
        q.push(next);
      }
    }
    step++;                            // 一层走完,步数加一
  }

  return -1;
}

⭐ 为什么第一次碰到就是最短

BFS 是一圈一圈往外扩的:先走完所有 1 步能到的,再走 2 步能到的。 所以当它第一次碰到目标时,不可能存在更短的路径 —— 更短意味着在更早的某一圈就该碰到,而那一圈已经完整走过了。

这条性质是 BFS 相对 DFS 的唯一优势,也是它存在的全部理由。 DFS 也能找到路径,但找到的是「碰巧先撞上的那条」,不是最短的。

⚠️ 前提是每条边的代价相同(都算 1 步)。边有不同权重时这条性质立刻失效, 那时要换成 Dijkstra。

📌 判据很好记:题目问「最少几步」「最短距离」而每步代价一样 → BFS。 问「路径存不存在」「有多少条路径」→ DFS 更省内存。

🚨 visited 必须在入队时标记,不是出队时

这是 BFS 最经典的错误,而且它不影响正确性、只影响性能,所以极难发现。

出队时才标记的话,同一个节点会被多个邻居先后推进队列,队列里躺着一堆重复项。 答案照样对(第一次弹出来的仍然是最短的那条),但队列会膨胀。

拿一个 12 节点的完全图遍历到底实测:

入队标记:入队 12 次,队列峰值 11
出队标记:入队 67 次,队列峰值 56     ← 5.6 倍 / 5.1 倍

🚨 但「5.6 倍」是完全图给的错觉,网格上它是组合爆炸

同一段代码换成网格,遍历到底的入队次数:

        入队标记   出队标记        膨胀
4×4        16         69          4.3×
6×6        36        923         25.6×
8×8        64     12,869        201.1×
10×10     100    184,755      1,847.5×
12×12     144  2,704,155     18,778.9×
14×14     196 40,116,599    204,676.5×
15×15     225   跑不完 —— 入队数超过 JS 数组长度上限,抛 RangeError

⭐ 不是「乘个常数」,是每个节点被出队的次数等于起点到它的最短路径条数。 10×10 网格里最角上那个格子被出队 48620 次 —— 那正是 C(18,9)。 于是总入队次数有闭式解:

n×n 网格从角出发,总入队次数 = Σ C(r+c, r)
实测入队    Σ 最短路径条数    Σ C(r+c,r)
4×4        69              69              69
6×6       923             923             923
8×8    12,869          12,869          12,869
10×10 184,755         184,755         184,755
12×12 2,704,155     2,704,155       2,704,155     ← 三者逐位相同

⚠️ 这个刻画有条件:只在二分图上成立(层内没有边)。 网格、树、转盘锁都是二分图;完全图不是 —— 它同层之间也连边, 所以 K12 上每个节点只被出队 1~11 次,看起来温和得多。 正文最早那个「5.6 倍」正是这么来的:拿唯一一个不适用的例子去量它。

📌 判据:要量一个「重复项会不会失控」的 bug,别用完全图。 完全图每层几乎就是全图,重复项没有繁殖的余地; 稀疏的层状图(网格、状态空间)才是它真正的舞台。

⚠️ 症状是「小数据全过、大数据超时或爆内存」,看起来像算法选错了, 实际只差这一行的位置。而答案从头到尾都是对的,所以对拍发现不了 —— 2042 组随机起终点上答案错 0 组。

⚠️ 另有一个变体值得单独说:如果你把入队处的标记删了, 却忘了把 visited 的初始值也一起改(仍写 new Set([start])), 那么起点一出队就被当成「访问过」直接 continue,BFS 一步都走不出去 —— 2042 组里错 2042 组,全部返回 -1。这一版不是慢,是彻底不工作。

双向 BFS

起点和终点都知道时,可以从两头同时扩,碰面就结束。

理论上省下的是数量级:单向 BFS 要扩到深度 d,节点数约 b^d(b 是分支数); 双向只需要各扩 d/2,加起来 2·b^(d/2)。

🚨 但这个理论值在很多题上根本兑现不了,实测才发现。 三个图各跑一遍 (🚨 先说清口径:单向数「出队的节点数」,双向数「扩展过的节点数」, 即双向那个 for (const cur of a) 执行了多少次):

                              单向出队   双向扩展   省下
4 叉 6 层的树(5461 个节点)      5,461        15   364×
60×60 网格,走对角               3,600     3,482   1.03×
转盘锁 0000 → 5555              10,000     7,491   1.33×

⚠️ 这两列不是同一个口径,比值会被这件事撑大。 双向 BFS 除了「扩展」 还要把邻居塞进 visited,那些也是访问过的状态。换成两边都数 「碰过多少个状态」(单向的出队数 vs 双向的 visited.size):

                              单向   双向visited   省下
4 叉 6 层的树                 5,461          46   118.7×   ← 不是 364×
60×60 网格                    3,600       3,600   1.00×    ← 一个都没省
转盘锁 0000 → 5555           10,000      10,000   1.00×    ← 一个都没省

⭐ 网格和锁那两行原来的 1.03× / 1.33× 全部来自口径差。 同口径下双向一个状态都没省 —— 它把整个空间都碰了一遍, 只是「扩展」的那一部分少些。原文写「少 1.0 倍」,字面意思正是「没少」。

⭐ 差别在于状态空间有没有边界:

  • 树是指数扩张的,越走越宽,永远填不满 —— 少扩一半深度就是天壤之别
  • 网格和转盘锁是有界的,一共就那么多状态。 单向 BFS 反正也要把它们基本走完,双向连常数都改不动

⚠️ 还有一件表里看不出来的事:那两行的单向格恰好等于状态空间总数。 3600 和 10000 就是 60×60 和 10⁴ —— 因为终点选的是离起点最远的那个 (对角 118 步、5555 需要 20 步,都是直径)。换个近一点的终点, 两者立刻都省下大半:

转盘锁,起点固定 0000        距离  单向出队  双向visited   省下
0000 → 5555                20    10,000      10,000    1.00×
0000 → 2222                 8     2,308         457    5.05×
0000 → 0005                 5       677         168    4.03×
0000 → 1111                 4       161          51    3.16×

📌 判据:「双向省多少」不是算法的性质,是「终点有多远」的性质。 拿直径当终点量出来的比值是这道题的下界,不是它的典型值。

👉 更准的判据:状态空间远大于「必须访问的部分」时,双向才划算。 BFS 无论如何都要填满整个空间的题(终点在直径上就是这种),双向 BFS 是白忙。

⚠️ 顺带说一句:「转盘锁用双向 BFS 优化」是流传很广的说法, 但最远那一档实测同口径下 1.00 倍 —— 一个状态都没省。 真要有收益,得是终点不太远的那些用例。

function bidirectionalBFS(start, target, neighborsOf) {
  if (start === target) return 0;

  let a = new Set([start]);        // 从起点扩的边界
  let b = new Set([target]);       // 从终点扩的边界
  const visited = new Set([start, target]);
  let step = 0;

  while (a.size > 0 && b.size > 0) {
    // ⭐ 每轮都从**小的那一头**扩。两边规模常常差很多,
    //    总是扩小的能显著减少总访问量 —— 这一行是双向 BFS 的关键优化,
    //    不是可有可无的整理。
    if (a.size > b.size) [a, b] = [b, a];

    const next = new Set();
    for (const cur of a) {
      for (const n of neighborsOf(cur)) {
        if (b.has(n)) return step + 1;   // 撞上另一头了
        if (visited.has(n)) continue;
        visited.add(n);
        next.add(n);
      }
    }
    a = next;
    step++;
  }

  return -1;
}

⭐ 那行「总扩小的那一头」不是整理代码,是真优化。同一棵 4 叉 6 层的树上实测: 换边扩展 15 个节点,不换边 1365 个 —— 91 倍。 两头规模常常差很多(终点那边往往只有一条路上来),总扩小的那头能少走一大片。

⚠️ 这个倍数随树的深度一直往上涨,不是固定值:

4 叉 d 层        换边扩展   不换边扩展   倍数
4 层(341)           4         85     21.3×
5 层(1365)          8        341     42.6×
6 层(5461)         15      1,365     91.0×
7 层(21845)        31      5,461    176.2×

⭐ 规律很干净:不换边就等于从起点单向扩到底(85/341/1365/5461 正是 上一层的节点总数),换边则只沿着终点那条细链上来。 所以这不是「优化了一个常数」,是把指数的底数换掉了 —— 数据越大差得越多,永远不要因为「小数据上差不多」就把这行删了。

⚠️ 双向 BFS 有个硬前提:必须事先知道终点。 「找出所有距离起点 3 步的节点」这种题用不上它。

⚠️ 另外它用 Set 而不是队列 —— 因为要频繁做「另一头有没有这个节点」的查询。 用数组的话每次查询是 O(n),优化直接白做。

典型题的共同点:状态就是节点

网格题里「节点」是显然的(一个格子)。但 BFS 真正的威力在于 把抽象状态当成节点:

  • 打开转盘锁 —— 节点是四位数字的当前状态("0000"), 邻居是拨动任意一位得到的 8 种状态
  • 最小基因变化 —— 节点是基因串,邻居是改一个字符后仍在库里的串
  • 单词接龙 —— 节点是单词,邻居是差一个字母的词

⭐ 这三道题的图不是给你的,是你自己定义出来的。 一旦想清楚「什么是节点、什么是邻居」,剩下的就是套上面那个框架。

📌 卡住的时候先问这两个问题,而不是先想怎么写代码。

复杂度

时间 O(V + E),每个节点进出队列一次、每条边看一次。 空间 O(V),队列最坏装下一整层,visited 装下所有节点。

⚠️ BFS 的空间常常比 DFS 大得多:DFS 的栈深是路径长度, BFS 的队列宽是一层的节点数。宽而浅的图上,BFS 可能爆内存而 DFS 不会。 「用 BFS 还是 DFS」有时候是被内存逼出来的,不是被题意。

⚠️ 但「BFS 更费内存」只在树那一类图上成立,网格上正好相反。 实测四个图:

                BFS队列峰值   DFS栈深(下界)   节点数
4 叉 6 层树          4,096            ≥6      5,461   ← BFS 贵 680 倍
60×60 网格              60         ≥3,599      3,600   ← DFS 贵 60 倍
转盘锁               1,342         ≥9,999     10,000   ← DFS 贵 7 倍
K200 完全图            199           ≥199        200   ← 打平

⭐ 网格上 BFS 的队列峰值只有 60(那是对角线的长度), 而 DFS 能沿着一条蛇形路径走遍全部 3599 个格子 —— 递归深度直接爆栈。 📌 判据:分支多而浅 → BFS 费内存;分支少而深 → DFS 费内存。 网格四邻里有两个方向是「回头」,真正的分支只有 2,属于后者。

练习

勾选记录做过哪些,0 / 6 道。进度只存在这台设备的浏览器里;同一道题在别篇勾过,这里也会显示已做。