Skip to content

26. 地铁线路图(图与 BFS)

知识点:图(graph)+ 广度优先搜索(BFS)—— 一圈一圈扩散,先到的就是最近(O(V+E))

项目:地铁换乘——最少坐几站?


故事开场:周末去游乐园

周末,你要坐地铁去游乐园。妈妈递给你一张地铁线路图:


     ↙ ↘
  公园   学校
   ↙ ↘    ↘
超市  游乐场  图书馆

      动物园

"从家到游乐场,最少坐几站?"妈妈问。

你盯着图:"家 → 公园 → 游乐场,2 站!比 家 → 学校 → 图书馆…… 不对, 图书馆也到不了游乐场。反正就是 2 站!"

"那从家到动物园呢?"妈妈又问。

"嗯……家 → 公园 → 游乐场 → 动物园,3 站?"你数了数,"还有别的路吗? 家 → 学校 → …… 学校只能去图书馆,图书馆哪也去不了,死路。 所以是 3 站。"

你忽然意识到,你刚才做的事情,其实是个"算法": 从家出发,先看 1 站能到哪,再看 2 站能到哪……一圈一圈往外扩。 第一个到达目标的圈数,就是最少的站数!


笨办法先行:DFS 乱走,绕远路

你决定把地铁图存进程序。每个站是一个节点,站与站之间的线路 是——这种"节点 + 边"的结构,就是图(graph)

存图最简单的方式,是邻接表:每个站,记一张"我能直达哪些站"的清单:

ts
// 地铁图:站点 → 能直达的站点列表(邻接表)
const metro = new Map<string, string[]>()

metro.set("家", ["公园", "学校"])
metro.set("公园", ["家", "超市", "游乐场"])
metro.set("学校", ["家", "图书馆"])
metro.set("超市", ["公园"])
metro.set("游乐场", ["公园", "动物园"])
metro.set("图书馆", ["学校"])
metro.set("动物园", ["游乐场"])

然后你想起第 23 章的迷宫 DFS,顺手就写了个"递归乱走":

ts
// 从 start 出发,DFS 乱走,看能不能到 target(顺便数步数)
let bestSteps = Infinity

function dfs(start: string, target: string, steps: number): void {
  if (start === target) {
    if (steps < bestSteps) {
      bestSteps = steps          // 记下目前最短的
    }
    return
  }
  const next = metro.get(start)
  if (next === undefined) { return }
  for (let i = 0; i < next.length; i++) {
    dfs(next[i], target, steps + 1)     // 每条路都试试
  }
}

dfs("家", "动物园", 0)
console.log("最少 " + bestSteps + " 站")

输出:

最少 3 站

算对了!可你把图改大一点(100 个站、每站 4 条线),这个 DFS 就开始发疯——它把所有路都走了一遍,包括绕地球一圈的 "家 → 公园 → 家 → 公园 → 家……"这种原地转圈的蠢路! 而且它不记得自己走过哪,转圈的次数没有上限。

"有没有更聪明的办法?"你想起第 19 章的层序遍历—— "一圈一圈走",好像就是干这个的!


引出知识点:BFS——"一圈一圈"扩散

广度优先搜索(Breadth-First Search,简称 BFS),就是 "一圈一圈扩散"的搜索:

  • 第一圈:起点 1 站能到的站(公园、学校)
  • 第二圈:2 站能到的站(超市、游乐场、图书馆)
  • 第三圈:3 站能到的站(动物园!)

第一个到达目标的圈,就是最短的路径——因为我们是按"距离 从近到远"一圈圈扩的,任何更近的路,早就被发现了!

BFS 的实现,用队列(第 7 章的老朋友!):

  1. 起点入队(第 0 圈)
  2. 从队首取出一个站,把它的邻居们全部入队(下一圈)
  3. 每次入队时,记录"这是第几圈"(走了几站)
  4. 出队时发现目标 → 它就是最短的!

还需要一个"记过的人"清单(Set):已经去过/排队中的站, 不再入队——不然又会原地转圈(家 → 公园 → 家 → 公园……)!

ts
const visited = new Set<string>()    // 记过的人(去过的站)

SetMap 是亲戚:Map 存"钥匙 → 值",Set 只存"钥匙"本身。 visited.has("公园") 问"去过公园吗",visited.add("公园") 记下"去过"。 还记得第 8 章吗?Map/Set 的查找都是 O(1)。)


动手实现:BFS 找最短站数

ts
// 地铁图:站点 → 能直达的站点列表
const metro = new Map<string, string[]>()
metro.set("家", ["公园", "学校"])
metro.set("公园", ["家", "超市", "游乐场"])
metro.set("学校", ["家", "图书馆"])
metro.set("超市", ["公园"])
metro.set("游乐场", ["公园", "动物园"])
metro.set("图书馆", ["学校"])
metro.set("动物园", ["游乐场"])

// BFS:从 start 到 target 最少几站?到不了返回 -1
function shortestStops(start: string, target: string): number {
  const queue: string[] = [start]       // 队列:待处理的站
  const visited = new Set<string>([start])   // 记过的人
  let stops = 0                          // 目前是第几圈

  while (queue.length > 0) {
    const size = queue.length            // 这一圈有多少个站
    for (let i = 0; i < size; i++) {
      const station = queue.shift()      // 出队
      if (station === undefined) { continue }

      if (station === target) {
        return stops                     // 这一圈找到目标!
      }

      const next = metro.get(station)    // 它的邻居们
      if (next !== undefined) {
        for (let j = 0; j < next.length; j++) {
          if (!visited.has(next[j])) {   // 没去过?
            visited.add(next[j])         // 记下来
            queue.push(next[j])          // 排到下一圈
          }
        }
      }
    }
    stops++                              // 这一圈走完了,进入下一圈
  }

  return -1                              // 所有站都走遍了,到不了
}

console.log("家 → 游乐场:" + shortestStops("家", "游乐场") + " 站")
console.log("家 → 动物园:" + shortestStops("家", "动物园") + " 站")
console.log("家 → 图书馆:" + shortestStops("家", "图书馆") + " 站")

输出:

家 → 游乐场:2 站
家 → 动物园:3 站
家 → 图书馆:2 站

全部正确! 而且 BFS 保证找到的一定是最短的——不是碰运气。

(注意 size 变量:先数清"这一圈有几个站",只处理它们; 处理完一圈 stops++,下一圈就是"再多坐一站"。这就是 "按圈扩散"的实现秘诀。)


跑起来:BFS vs DFS,谁先找到?

给 BFS 和 DFS 各加一个计数器,看看谁"走的步数"少:

ts
// BFS 版:数一数它一共访问了几个站
function shortestStopsCount(start: string, target: string): number {
  const queue: string[] = [start]
  const visited = new Set<string>([start])
  let stops = 0
  let visits = 1                 // 计数器:访问过的站

  while (queue.length > 0) {
    const size = queue.length
    for (let i = 0; i < size; i++) {
      const station = queue.shift()
      if (station === undefined) { continue }
      if (station === target) {
        console.log("BFS 访问了 " + visits + " 个站")
        return stops
      }
      const next = metro.get(station)
      if (next !== undefined) {
        for (let j = 0; j < next.length; j++) {
          if (!visited.has(next[j])) {
            visited.add(next[j])
            queue.push(next[j])
            visits++
          }
        }
      }
    }
    stops++
  }
  return -1
}

shortestStopsCount("家", "动物园")

输出:

BFS 访问了 7 个站

7 个站,BFS 全部访问到了——因为动物园在第 3 圈(第 3 站), 而它前面还排着图书馆。注意:BFS 仍然保证 3 站是最短的—— 因为它把第 2 圈(超市、游乐场、图书馆)全部检查完才进入 第 3 圈,任何 2 站能到的路早就被发现了。目标越近,它越早收工。


图的世界

这一章,你正式进入了**图(graph)**的世界——计算机科学里 最庞大、最实用的数据结构:

  • 社交网络:人是节点,朋友关系是边
  • 地图导航:路口是节点,道路是边
  • 网页:网页是节点,超链接是边
  • 棋盘:格子是节点,一步能到的地方是边

BFS 在图上能解决无数问题:最少换乘、社交网络"几度人脉"、 游戏最短路线、迷宫最短路径(第 23 章的迷宫,用 BFS 也能解, 而且找到的就是最短的!)。

BFS 的复杂度:每个站入队一次(V 个节点),每条线被看一次 (E 条边)——总共 O(V + E),图多大,它就走多久,不多不少。


小挑战

  1. 打印路径:改造 shortestStops,让它不仅能算"几站", 还能打印走的具体路线(提示:每个站入队时,记下"我是从 哪个站来的",找到目标后一路"倒着找爸爸")。
  2. 最少换乘:把图改成"线路"视角——站之间如果不用换乘 就是同一条线。想一想:怎么表示,才能算"最少换乘几次"? (提示:图里还可以加一种节点:把"线路"也当成节点!)
  3. 几度人脉:在社交网络图里,用 BFS 算"你到小明是几度人脉" (提示:一度 = 直接是朋友,二度 = 朋友的朋友……这就是 BFS 的"圈数"!)。

课后练习

第 1 题(动手题):我家的地铁图

画一张你家附近的地铁图(至少 5 个站、1 条换乘线),存成邻接表, 用 shortestStops 算 3 对站点之间的最少站数,手动验证。

参考答案(点开查看)

示例(把站名换成你家的):

ts
const metro = new Map<string, string[]>()
metro.set("家", ["医院", "学校"])
metro.set("医院", ["家", "商场"])
metro.set("学校", ["家", "公园"])
metro.set("商场", ["医院", "动物园"])
metro.set("公园", ["学校", "动物园"])
metro.set("动物园", ["商场", "公园"])

console.log(shortestStops("家", "动物园"))   // 3
console.log(shortestStops("家", "商场"))     // 2
console.log(shortestStops("医院", "公园"))   // 3

手动验证方法:在地图上沿着边数一数,对比程序输出。 如果程序给出的数字比手数的大,说明你漏画了一条边; 比手数的小,说明图画重了(或者程序有 bug——自己找找!)。

第 2 题(思考题):BFS 为什么最短?

BFS 是按"圈数"扩散的(第 0 圈 = 起点,第 1 圈 = 1 站可达……)。 为什么"第一圈到达目标"就一定是"最少站数"?会不会有更短的 路藏在后面的圈里?

参考答案(点开查看)

不会。

BFS 严格按距离从近到远扩散:第 0 圈是最短距离 0 的站, 第 1 圈是最短距离 1 的站……第 k 圈是最短距离 k 的站。

如果存在一条 2 站就能到的路,那个目标一定在第 2 圈就被发现了; 到第 5 圈才找到目标,说明它到起点的最短距离就是 5—— 任何更短的路,都会在更早的圈里被碰到。

这就像水波一圈圈荡开:第一个碰到石头的波纹,一定是最近 的那一圈。 所以 BFS 找到的路径,一定是全局最短的。

第 3 题(思考题):BFS vs DFS

BFS 用队列(先进先出),DFS 用递归/栈(后进先出)。想一想:

① 找"有没有路"(随便一条),用哪个都行? ② 找"最短的路",该用哪个?为什么? ③ 数"一共有几条不同的路",该用哪个?为什么?

参考答案(点开查看)

① 都行:只要连通性,两个都能找到(DFS 递归更简单好写)。 ② BFS:它一圈圈扩散,第一圈碰到就是最短;DFS 一条路走到黑, 找到的可能是绕远的路。 ③ DFS:它会把每条路都走到底(试遍所有可能性,像回溯!); BFS 找到一条最短的就停了,不会去数"所有的路"。

一句话:要"最近/最短"→ BFS;要"全部/所有可能"→ DFS(回溯)。 迷宫找路两个都行,但 BFS 保证最短——下一章的"迷宫寻宝", 我们试试 DFS 的另一面:把整个地图"逛遍"。


下一课预告:卫星图上的海洋里散落着几座小岛。老师说: "数一数有几座岛!"你从第一块陆地出发,一口气把整座岛 逛完、标记完,再找下一块没标记的陆地……这个"逛遍整座岛" 的办法,就是 DFS 的主场。