职业发展与面试准备

BFS 面试题

几乎没有人会被要求直接实现 BFS。你拿到的是一个看起来不像图的问题,真正的考验是你能否看出它其实是图。这里有八道反复出现的题目,每道都附有解答、面试官接下来会问的追问,以及让你丢掉 offer 的具体错误。

阅读时间 16 分钟 更新时间:2026 年 9 月 中级
Mohammed Islam Hadjoudj
Mohammed Islam Hadjoudj
Expert Operations Research Engineer

1. BFS 题目真正考察的是什么

几乎没有人会被要求“实现 BFS”。你拿到的是一个看起来不像图的问题,而面试考察三件事:你能否看出其中的图,是否知道 BFS 是合适的工具,以及能否写出没有 bug 的代码。

信号是最少这个词,或者它的任何同义词:最少步数、最短变换、最早的时刻、最近的出口。BFS 能回答这些问题,但前提是每一步的代价都相同。这个条件就是关键所在:当每步代价相同时,BFS 能在 O(V + E)内给出精确的最小值;当代价不同时,它就是错的,而下意识地用 BFS 正是这道题想要引出的错误。

下面八道是反复出现的题目。每道题都按实际面试的流程展开:问题、解答、面试官接下来会问的追问,以及让你丢掉 offer 的错误。每个示例都经过脚本实际运行验证。

2. 需要默写的模板

一个模板就能覆盖这里的所有题目。你应该能在两分钟内不假思索地写出来,因为面试时间应该花在建模上,而不是打字上。

from collections import deque

def bfs(start, neighbours):
    dist = {start: 0}
    q = deque([start])
    while q:
        u = q.popleft()
        for v in neighbours(u):
            if v not in dist:          # 入队时标记,绝不在出队时标记
                dist[v] = dist[u] + 1
                q.append(v)
    return dist

有四个细节决定了你的表现是干净利落还是磕磕绊绊。

一个有七个顶点的图,从顶点零开始运行广度优先搜索,按层依次绘制。第零层包含顶点 0,第一层包含顶点 1 和 2,第二层包含顶点 3 和 4,第三层包含顶点 5,第四层包含顶点 6。旁边的表格记录了每一步的队列:出队的顶点、新入队的顶点以及队列内容,最终得到距离数组 0, 1, 1, 2, 2, 3, 4。
BFS 按层访问。在任何时刻,队列中最多包含两个相邻的层,这就是它内存开销的来源。

让这一切成立的性质是层不变式:每条边连接的顶点要么在同一层,要么在相邻两层,从不跨层。上图的八条边都满足这一点,这也是 BFS 第一次到达某个顶点时一定沿着最短路径的原因。要把它说出来:只说“BFS 能找到最短路径”而不给理由,听起来像是背的。

3. 无权图中的最短路径

题目。给定一个无权图和两个顶点,返回最短路径的长度以及路径本身。

这是基础题。相比模板,唯一增加的是父指针。

def shortest_path(adj, src, dst):
    dist, parent = {src: 0}, {src: None}
    q = deque([src])
    while q:
        u = q.popleft()
        if u == dst:                     # 提前退出:在出队时停止
            break
        for v in adj[u]:
            if v not in dist:
                dist[v] = dist[u] + 1
                parent[v] = u
                q.append(v)
    if dst not in dist:
        return None
    path, cur = [], dst
    while cur is not None:
        path.append(cur)
        cur = parent[cur]
    return dist[dst], path[::-1]

在图中的例子上,它返回距离 4 和路径 0 → 1 → 3 → 5 → 6。要主动说明这是一条最短路径,而不是唯一的一条: 0 → 2 → 3 → 5 → 6 同样短,你得到哪一条取决于邻接表的顺序。

追问:能提前退出吗?可以,微妙之处在于在哪里退出。在出队时检测目标总是正确的。在入队时检测对普通 BFS 也可行,还能省下一层,但一旦出现权重就不再正确,所以出队时检查才是值得养成的习惯。最坏情况仍然是 O(V + E)。

陷阱。当面试官说“现在边有权重了”,不要去修补 BFS。改用 Dijkstra 算法;如果权重只有 0 和 1,则改用第 8 节中的双端队列技巧。为了应对权重而让 BFS 重复访问顶点的候选人,其实是在无意中写一个又慢又有 bug 的 Bellman-Ford。

4. 岛屿数量

题目。给定一个由 '1' (陆地)和 '0' (水)组成的网格,统计相连的陆地块数。对角线不算相连。

这里没有显式的图,而这正是重点。顶点是陆地格子,边是共享的边界,所以一个格子最多有四个邻居,你根本不需要构建邻接结构。

def num_islands(grid):
    if not grid: return 0
    R, C = len(grid), len(grid[0])
    seen, count = set(), 0
    for i in range(R):
        for j in range(C):
            if grid[i][j] != '1' or (i, j) in seen:
                continue
            count += 1
            seen.add((i, j))
            q = deque([(i, j)])
            while q:
                r, c = q.popleft()
                for dr, dc in ((1,0), (-1,0), (0,1), (0,-1)):
                    a, b = r + dr, c + dc
                    if 0 <= a < R and 0 <= b < C \
                       and grid[a][b] == '1' and (a, b) not in seen:
                        seen.add((a, b))
                        q.append((a, b))
    return count

每个格子最多入队一次,每次只做常数量的工作,所以时间复杂度是 O(R × C)。空间是已访问集合加上队列,在整个网格都是陆地的最坏情况下同样是 O(R × C)。

追问:BFS 还是 DFS?两者都可以,因为你是在标记连通分量,而不是测量距离。出于实际原因更推荐 BFS:在一个 106 个格子、全是陆地的网格上,递归 DFS 会递归一百万层,导致栈溢出。如果你选择 DFS,要说明你会用迭代方式来写;这句话往往就是这个追问的全部用意。两者的比较见 BFS 与 DFS。

陷阱。直接修改输入网格,也就是把 '0' 写到陆地上,而不是维护一个已访问集合,这是一种合理的优化,但要说出来。悄悄破坏调用方的数据,是代码审查中的失败,而不是聪明。

5. 腐烂的橘子:多源 BFS

题目。网格中有空格(0)、新鲜橘子(1)和腐烂橘子(2)。每分钟,每个腐烂的橘子都会让上下左右相邻的新鲜橘子腐烂。返回直到没有新鲜橘子为止所需的分钟数;如果这永远不会发生,返回 -1。

这道题能区分出背下 BFS 的人和真正理解 BFS 的人。直觉做法是从每个腐烂的橘子分别运行一次 BFS,再合并结果,既复杂又慢。正确答案是在循环开始前把所有腐烂的橘子都放进队列。这样 BFS 会扩展一个共同的波前,每个格子都会最先被离它最近的源点到达。

def oranges_rotting(grid):
    R, C = len(grid), len(grid[0])
    q, fresh = deque(), 0
    for i in range(R):
        for j in range(C):
            if grid[i][j] == 2: q.append((i, j, 0))
            elif grid[i][j] == 1: fresh += 1

    minutes = 0
    while q:
        r, c, t = q.popleft()
        minutes = max(minutes, t)
        for dr, dc in ((1,0), (-1,0), (0,1), (0,-1)):
            a, b = r + dr, c + dc
            if 0 <= a < R and 0 <= b < C and grid[a][b] == 1:
                grid[a][b] = 2                  # 入队时标记
                fresh -= 1
                q.append((a, b, t + 1))
    return -1 if fresh else minutes

在这个网格上的演算:

2 1 1 0          每格腐烂的分钟:            0  1  2  .
1 1 0 2                                      1  2  .  0
0 1 1 1                                      .  3  2  1

两个源点,7 个新鲜橙子,剩余 0 个,答案 = 3
一个三行四列的橘子网格,每个格子标有它腐烂的分钟数。有两个格子在第零分钟就已腐烂,一个在左上角,另一个在中间一行的右端。每个波前每分钟向外扩展一格,两者在最下面一行相遇,所以最后一个新鲜橘子在第三分钟腐烂。一条注释记录了:两个源点,初始七个新鲜橘子,没有剩余,答案是三分钟。
两个源点,一个波前。每个格子归最先到达它的腐烂橘子所有,两个波前在第 3 分钟于最下面一行相遇。

复杂度是 O(R × C),与源点数量无关。多源 BFS 与单源 BFS 代价相同,这正是题目要考察的洞见。

追问:如果某个橘子永远不会腐烂呢?这就是返回 -1 的情况,也是需要 fresh 计数器的原因。不要通过比较已访问格子数和网格大小来判断:空格不是橘子,算术会出错。先统计新鲜橘子的数量,每腐烂一个就减一,最后检查剩余数量。如果把左下角那个橘子旁边的两个格子清空,它就被隔离了,于是有一个始终保持新鲜,答案是 -1。

陷阱。空网格。零个新鲜橘子和零个腐烂橘子应该返回 0,而差一错误返回 1 是最常见的错误提交。

6. 单词接龙:隐式图与中间相遇

题目。给定一个起始单词、一个目标单词和一个字典,求最短转换链的长度,要求每一步恰好改变一个字母,并且每个中间单词都在字典中。

这个图中每个字典单词是一个顶点,只有一个位置不同的两个单词之间有一条边。显式构建它需要 O(N2 L) 的时间,这是大多数候选人首先写出的慢速解法。快速解法从不构建它:在 L 个位置中的每一个上尝试全部 26 个字母,按需生成邻居,并用哈希集合检查,正如下面的代码所做的那样。每个位置上有一次替换会生成单词本身,它会被已访问检查丢弃。对于十个字母的单词,每个顶点需要 260 次查找,与字典大小无关。

def ladder_length(begin, end, word_list):
    words = set(word_list)
    if end not in words: return 0
    q, dist = deque([begin]), {begin: 1}
    while q:
        w = q.popleft()
        if w == end: return dist[w]
        for i in range(len(w)):
            for ch in "abcdefghijklmnopqrstuvwxyz":
                nxt = w[:i] + ch + w[i+1:]
                if nxt in words and nxt not in dist:
                    dist[nxt] = dist[w] + 1
                    q.append(nxt)
    return 0

追问:让它更快。期望的答案是双向 BFS,由 Pohl 于 1971 年提出:同时从起点向前、从终点向后搜索,每次扩展较小的那个边界,在两者相遇时停止。深度为 d、分支因子为 b 的单向搜索大约触及 bd 个顶点;两次深度为 d/2 的搜索触及 2bd/2个。这是指数减半,而不是常数倍的差别。

单向与双向广度优先搜索的对比。左边,一棵搜索树从起点扩展到深度六。右边,两棵较小的树分别从起点和终点各扩展到深度三,并在中间相遇。表格给出了分支因子为十时的节点数:深度为六时,单向搜索访问 1,111,111 个节点,双向搜索访问 2,222 个,相差 500 倍;深度为四时,分别是 11,111 和 222,相差 50 倍。
分支因子为 10、深度为 6 时,中间相遇把 110 万个被访问的顶点减少到约 2,200 个。

答案越深,收益越大。

陷阱。双向 BFS 要求获取前驱与获取后继一样廉价:这里是免费的,因为关系是对称的,但在有向图上需要一份反向副本。相遇点也需要小心。答案是两个深度之和,只有在每次扩展一整层时,一旦某个顶点同时出现在两个已访问集合中就立即停止才是正确的。

7. 二叉树的层序遍历

题目。返回二叉树的节点值,按深度分组,每层一个列表。

唯一的新想法是一次处理一整层,技巧在于在内层循环之前记下队列的长度。

def level_order(root):
    if not root: return []
    out, q = [], deque([root])
    while q:
        level = []
        for _ in range(len(q)):        # 先记下这一层的大小
            node = q.popleft()
            level.append(node.val)
            if node.left:  q.append(node.left)
            if node.right: q.append(node.right)
        out.append(level)
    return out

把 len(q) 放在 range 调用中捕获,正是这一点让它能正确工作:即使队列在循环中不断增长,循环也恰好执行该层节点数那么多次。在循环内部读取长度会悄悄把多层合并在一起,这就是这里的经典 bug。

树不需要已访问集合:没有环,每个节点只有一个父节点。要说明你省略它,是因为输入是一棵树,因为在图上悄悄省略它会导致死循环。

追问。之字形遍历是在奇数深度上反转 level,而不是倒着入队。右视图就是每一层的最后一个元素。最小深度就是第一个出队的叶子的深度,在这里 BFS 确实胜过 DFS,因为 DFS 必须遍历整棵树。

8. 0-1 BFS:BFS 胜过 Dijkstra 的场景

题目。每条边的权重为 0 或 1;求从某个源点出发的最短距离。变体包括某些移动免费的网格,或者“最少需要拆掉几堵墙”。

Dijkstra 可以在 O(E log V) 内解决,也会被接受。而面试官真正想要的答案运行时间是 O(V + E):使用双端队列,经过权重为 0 的边松弛的顶点压到队首,经过权重为 1 的边松弛的顶点压到队尾。这样,双端队列中同时最多只有两个不同的距离值,这恰好就是优先队列原本提供的顺序。

def zero_one_bfs(adj, src, n):        # adj[u] = [(v, w), ...] 其中 w 属于 {0, 1}
    dist = [float('inf')] * n
    dist[src] = 0
    dq = deque([src])
    while dq:
        u = dq.popleft()
        for v, w in adj[u]:
            if dist[u] + w < dist[v]:
                dist[v] = dist[u] + w
                if w == 0: dq.appendleft(v)
                else:      dq.append(v)
    return dist

在边为 0-1 (权重 1)、 0-2 (0)、 2-3 (1)、 1-3 (0)、 3-4 (1)和 2-4 (1)的图上,它返回 0, 1, 0, 1, 1,与 Dijkstra 完全一致。普通 BFS 返回 0, 1, 1, 2, 2,五个顶点中错了三个,因为它数的是边数,而不是把权重相加。这种对比是展示 BFS 实际优化目标的最清晰方式。

这项技术属于标号修正算法家族,Bertsekas 在 1993 年给出了其一般形式。它与普通 BFS 有一个重要的结构差异:一个顶点可能会被松弛不止一次,所以守卫条件是距离比较,而不是已访问检查。

陷阱。写成 if v not in visited 而不是 if dist[u] + w < dist[v]。已访问检查让最先到达者获胜,而经过权重为 0 的边时,最先到达的未必是最好的。代码照样能运行,并返回看似合理的数字。

9. 这个图是二分图吗?

题目。能否把顶点分成两个集合,使每条边都横跨这两个集合?面试中常见的说法是“把这些人分成两组,使任意两个敌人不在同一组”,或者“这个图能否 2-着色”。

把源点涂成 0,把每个邻居涂成相反的颜色,一旦遇到已经和自己同色的邻居就判定失败。

def is_bipartite(adj, n):
    colour = [-1] * n
    for s in range(n):
        if colour[s] != -1: continue      # 一个新的连通分量
        colour[s] = 0
        q = deque([s])
        while q:
            u = q.popleft()
            for v in adj[u]:
                if colour[v] == -1:
                    colour[v] = 1 - colour[u]
                    q.append(v)
                elif colour[v] == colour[u]:
                    return False
    return True

清晰的解释是:颜色就是 BFS 层号的奇偶性。冲突意味着有一条边连接了同一层的两个顶点,从而闭合了一个长度为奇数的环,而一个图是二分图当且仅当它没有奇环。4 元环是二分图,5 元环不是,算法对两者都能给出正确判断。

陷阱,它导致的错误提交比其他任何陷阱都多:外层的 for s in range(n) 循环。对于不连通的图,需要从每个未着色的顶点重新启动 BFS,所以只从顶点 0 开始的解法能通过所有连通的测试,一旦有两个连通分量就会失败。统计连通分量和检测环都需要同样的循环。

10. 课程表:用 BFS 做拓扑排序

题目。给定 n 门课程和一组先修关系对,能否修完所有课程?追问会要求给出一个合法的顺序。

这是有向图上的环检测,BFS 的解法是 Kahn 算法(1962):反复取出一个没有剩余先修课的顶点,将其删除,并把它的后继的入度减一。

def find_order(n, prerequisites):
    adj = [[] for _ in range(n)]
    indeg = [0] * n
    for course, prereq in prerequisites:
        adj[prereq].append(course)
        indeg[course] += 1

    q = deque(i for i in range(n) if indeg[i] == 0)
    order = []
    while q:
        u = q.popleft()
        order.append(u)
        for v in adj[u]:
            indeg[v] -= 1
            if indeg[v] == 0:
                q.append(v)
    return order if len(order) == n else []    # 变短 == 有环

有 6 门课程、先修关系为 1←0, 2←0, 3←1, 3←2, 4←3, 5←4 时,它把六门课全部排为 0, 1, 2, 3, 4, 5。对于有环的集合 1←0, 2←1, 0←2,它排出的课程一门也没有:每个顶点的初始入度都是 1,所以初始队列为空。一个检查同时覆盖两种情况,这正是答案的核心:如果输出的长度小于 n,剩下的顶点就构成了环。

陷阱。把边的方向弄反。先修对 [a, b] 表示“要修 a,必须先修 b”,所以边的方向是 b → a,增加的是 a的入度。如果弄反了,你会得到反向图的一个合法拓扑序:它看起来是对的,也能通过环检测,但它是错的。写循环之前,先把方向大声说出来。更完整的讨论见拓扑排序。

对应的深度优先版本,涵盖环检测、拓扑排序、强连通分量和桥,请见 DFS 面试题。

11. 面试官期望的复杂度回答

BFS 面试有一半是分析。该说什么,以及为什么:

问题类型时间空间应给出的理由
图,邻接表O(V + E)O(V)每个顶点入队一次,每条边被检查两次
图,邻接矩阵O(V2)O(V)查找一个顶点的邻居需要扫描一整行
网格, R × CO(R × C)O(R × C)V = RC 且 E < 2RC,所以 V + E 与格子数呈线性关系
多源网格O(R × C)O(R × C)不变:源点只是为同一个波前播种
单词接龙: N 个单词,长度 LO(N × L2 × 26)O(N × L)26L 个候选,每个需要 O(L) 来构造并计算哈希
双向,分支因子 b,深度 dO(bd/2)O(bd/2)两次半深度搜索,所以指数减半
0-1 BFSO(V + E)O(V)用双端队列代替堆,所以没有 log 因子

有两点值得主动提出。空间复杂度 O(V) 并非偶然:BFS 会保存一整层,而在很宽的图上,这就是大部分顶点。这才是在又深又窄的图上更倾向于 DFS 的真正原因,也比“DFS 更省内存”这种并不总是成立的回答更好。另外,边的项在有向图中是 E,而在无向图中是 2E。Cormen、Leiserson、Rivest 和 Stein 给出了完整的分析;Sedgewick 和 Wayne 给出了最清晰的简短分析。

BFS 在有名字之前就被发表过两次:Moore 在 1959 年用它求迷宫中的最短路径,Lee 在 1961 年用它为电路板布线。Lee 的版本恰恰就是第 4 节和第 5 节中的网格 BFS,这也是网格寻路至今有时仍被称为 Lee 算法的原因。

一旦边带有不同的权重,队列就变成了优先队列;这类问题在 Dijkstra 面试题中有详细讲解。

12. 导致面试失败的错误

按出现频率而非严重程度排序。前三个错误占了大多数被拒的解答。

有一个习惯胜过以上所有建议。写代码之前,先大声说出顶点是什么、边是什么、每一步的代价是多少。如果每一步的代价相同,BFS 就是正确的;如果不同,你就避开了陷阱。McDowell 在一般意义上也提出了同样的观点,而在图问题上它最为关键,因为图往往是隐藏的。

13. 常见问题

如何判断一个问题需要 BFS 而不是 DFS?

+

留意“最少”这个词或它的同义词:最少步数、最短变换、最早的时刻、最近的出口。只要每一步的代价相同,BFS 就能精确回答这些问题。如果题目只问可达性或连通分量,两种遍历都可以,而 BFS 能避免在大输入上出现深层递归。

为什么必须在入队时就把顶点标记为已访问?

+

因为在入队和出队之间,同一边界上的其他顶点可能会再次发现它。在出队时标记,会让它每条入边都入队一次,于是队列中有 O(E) 个条目而不是 O(V) 个。距离仍然是正确的,这正是这个 bug 容易被忽略的原因。

什么是多源 BFS,什么时候需要它?

+

在循环开始之前,把所有源点都以距离零放入队列。BFS 会扩展一个共同的波前,所以每个格子都会最先被离它最近的源点到达。它的代价与单源 BFS 相同,都是 O(V + E)。腐烂的橘子和最近出口类问题是标准例子。

BFS 能处理带权的边吗?

+

只有当所有权重都是 0 或 1 时才可以。这时使用双端队列,经过权重为零的边就压到队首,经过权重为一的边就压到队尾,能在 O(V + E) 内给出正确答案,没有对数因子。对于其他任何权重,BFS 都是错的,因为它最小化的是边数而不是总权重,这时你需要 Dijkstra。

双向 BFS 能快多少?

+

它把指数减半,而不是除以一个常数:大约把 b 的 d 次方变成 2 乘以 b 的 d/2 次方。在分支因子为 10、深度为 6 时,就是 1,111,111 个顶点对比约 2,222 个,相差 500 倍。它要求获取前驱与获取后继一样廉价:无向图天然满足,有向图需要一份反向副本。

网格 BFS 应该给出什么复杂度?

+

时间和空间都是 O(R 乘以 C)。应给出的理由是:网格是一个有 R 乘以 C 个顶点、少于 2 R C 条边的图,所以 V 加 E 与格子数呈线性关系。空间是已访问集合加上队列,队列可能同时容纳网格中很大一部分格子。

在树上运行 BFS 需要已访问集合吗?

+

不需要。树没有环,每个节点只有一个父节点,所以没有节点会被到达两次,这个集合永远不会拒绝任何东西。要说明你为什么省略它,而不是悄悄省略:在一般图上同样的省略会造成死循环,而面试官无法分辨你指的是哪种情况。

14. 参考文献

提出这些技术的论文以及分析它们的教材,按时间顺序排列。

  1. Moore, E. F. (1959). “The shortest path through a maze.” Proceedings of an International Symposium on the Theory of Switching, Harvard University Press, 285–292.
  2. Lee, C. Y. (1961). “An algorithm for path connections and its applications.” IRE Transactions on Electronic Computers, EC-10(3), 346–365.
  3. Kahn, A. B. (1962). “Topological sorting of large networks.” Communications of the ACM, 5(11), 558–562.
  4. Pohl, I. (1971). “Bi-directional search.” In Machine Intelligence 6, Edinburgh University Press, 127–140.
  5. Bertsekas, D. P. (1993). “A simple and fast label correcting algorithm for shortest paths.” Networks, 23(8), 703–709.
  6. Cormen, T. H., Leiserson, C. E., Rivest, R. L. and Stein, C. (2009). Introduction to Algorithms,第 3 版,第 22.2 节。MIT Press。
  7. Sedgewick, R. and Wayne, K. (2011). Algorithms,第 4 版,第 4.1 节。Addison-Wesley。
  8. McDowell, G. L. (2015). Cracking the Coding Interview,第 6 版。CareerCup。
  9. Skiena, S. S. (2020). The Algorithm Design Manual,第 3 版,第 5 章。Springer。

看看边界如何推进

搭建第 2 节中的七顶点图,运行 BFS,观察队列如何一层一层地填满和清空。亲眼看到边界,是不再把“最先到达”与“沿最短路径到达”混为一谈的最快方法。

打开 BFS 可视化工具