跳转至

图建模了关系和连接——从社交网络到道路地图再到依赖链。本文件涵盖图的表示、BFS、DFS、最短路径、拓扑排序和连通分量,包括遍历和寻路模式,这些是图面试题中的核心。

  • 我们在第12章(邻接矩阵、拉普拉斯矩阵、谱性质)和第13章(树、平面性、着色)中已经介绍了图论。这里我们专注于算法模式:如何在代码中遍历、搜索和优化图。

  • 两种基本的图算法是 BFSDFS。几乎所有图问题都可以归结为其中一种,可能带有修改。掌握这两种算法,你就能解决绝大多数图问题。

图的表示

  • 邻接表:对于每个节点,存储一个邻居列表。空间:\(O(|V| + |E|)\)。最适合稀疏图(大多数现实世界的图)。
# 无向图
graph = {
    0: [1, 2],
    1: [0, 3],
    2: [0, 3],
    3: [1, 2]
}

# 从边列表构建
def build_graph(n, edges):
    graph = {i: [] for i in range(n)}
    for u, v in edges:
        graph[u].append(v)
        graph[v].append(u)  # 有向图省略这一行
    return graph
  • 邻接矩阵\(n \times n\) 矩阵,其中 \(A[i][j] = 1\) 如果边 \((i, j)\) 存在。空间:\(O(|V|^2)\)。最适合稠密图或需要 \(O(1)\) 边查找时。

  • 何时使用哪种:绝大多数情况使用邻接表。只有当图很稠密(\(|E| \approx |V|^2\))或需要常数时间边存在性检查时才使用矩阵。

模式:BFS(广度优先搜索)

  • BFS 使用队列逐层探索节点。它是以下问题的首选算法:
    • 无权图中的最短路径
    • 层序遍历
    • 寻找连通分量
    • 任何询问"最小步数"的问题
from collections import deque

def bfs(graph, start):
    visited = {start}
    queue = deque([start])

    while queue:
        node = queue.popleft()
        for neighbour in graph[node]:
            if neighbour not in visited:
                visited.add(neighbour)
                queue.append(neighbour)
  • 关键:在入队时添加到 visited,而不是在出队时。如果你在出队时标记已访问,同一个节点可能被不同前驱多次入队,浪费时间并可能导致错误结果。

简单:岛屿数量

  • 问题:给定一个由 '1'(陆地)和 '0'(水)组成的 2D 网格,计算岛屿的数量。

  • 模式:遍历网格。当找到一个 '1' 时,使用 BFS/DFS 将所有连通的陆地单元格标记为已访问。每次开始 BFS 就是一个岛屿。

from collections import deque

def num_islands(grid):
    if not grid:
        return 0

    rows, cols = len(grid), len(grid[0])
    count = 0

    for r in range(rows):
        for c in range(cols):
            if grid[r][c] == '1':
                count += 1
                # BFS 标记整个岛屿
                queue = deque([(r, c)])
                grid[r][c] = '0'  # 标记已访问
                while queue:
                    cr, cc = queue.popleft()
                    for dr, dc in [(0,1),(0,-1),(1,0),(-1,0)]:
                        nr, nc = cr + dr, cc + dc
                        if 0 <= nr < rows and 0 <= nc < cols and grid[nr][nc] == '1':
                            grid[nr][nc] = '0'
                            queue.append((nr, nc))

    return count
  • 陷阱directions = [(0,1),(0,-1),(1,0),(-1,0)] 模式用于四连通网格邻居,几乎每个网格问题都会用到。记住它。对于八连通,加上对角线。

  • 陷阱:修改输入网格(grid[r][c] = '0')避免了需要单独的 visited 集合。在面试中这是可以接受的,但要明确说明权衡(改变了输入)。

中等:腐烂的橘子

  • 问题:新鲜橘子如果与腐烂橘子相邻则会腐烂。返回所有橘子都腐烂的最短时间(如果不可能则返回 -1)。

  • 模式:多源 BFS。将所有初始腐烂的橘子同时放入队列。每层 BFS 就是一个时间步。

from collections import deque

def oranges_rotting(grid):
    rows, cols = len(grid), len(grid[0])
    queue = deque()
    fresh = 0

    for r in range(rows):
        for c in range(cols):
            if grid[r][c] == 2:
                queue.append((r, c))
            elif grid[r][c] == 1:
                fresh += 1

    if fresh == 0:
        return 0

    time = 0
    while queue and fresh > 0:
        time += 1
        for _ in range(len(queue)):
            cr, cc = queue.popleft()
            for dr, dc in [(0,1),(0,-1),(1,0),(-1,0)]:
                nr, nc = cr + dr, cc + dc
                if 0 <= nr < rows and 0 <= nc < cols and grid[nr][nc] == 1:
                    grid[nr][nc] = 2
                    fresh -= 1
                    queue.append((nr, nc))

    return time if fresh == 0 else -1
  • 关键洞察:多源 BFS 同时处理所有源。这给出了从任何源的最短距离,这正是"最后一个新鲜橘子腐烂需要多长时间"。

模式:DFS(深度优先搜索)

  • DFS 尽可能深地探索,然后回溯。它使用栈(显式栈或通过递归使用调用栈)。DFS 是以下问题的首选:
    • 环检测
    • 拓扑排序
    • 连通分量
    • 回溯 / 穷举搜索
    • 带约束的寻路
def dfs(graph, node, visited=None):
    if visited is None:
        visited = set()
    visited.add(node)
    for neighbour in graph[node]:
        if neighbour not in visited:
            dfs(graph, neighbour, visited)

中等:课程表(环检测)

  • 问题:给定 \(n\) 门课程和先修条件,判断是否能完成所有课程(即,没有循环依赖)。

  • 模式:在有向图中检测环。使用带有三种状态的 DFS:未访问、正在进行(在当前 DFS 路径上)、已完成。

def can_finish(num_courses, prerequisites):
    graph = {i: [] for i in range(num_courses)}
    for course, prereq in prerequisites:
        graph[course].append(prereq)

    # 0 = 未访问, 1 = 进行中, 2 = 已完成
    state = [0] * num_courses

    def has_cycle(node):
        if state[node] == 1:
            return True   # 回边 → 环
        if state[node] == 2:
            return False  # 已经完全探索过

        state[node] = 1  # 标记为进行中
        for neighbour in graph[node]:
            if has_cycle(neighbour):
                return True
        state[node] = 2  # 标记为已完成
        return False

    for course in range(num_courses):
        if has_cycle(course):
            return False
    return True
  • 为什么需要三种状态:两种状态(已访问/未访问)无法区分"我正在探索这个节点"和"我已完成对这个节点的探索"。找到一个当前正在被探索的节点(状态 = 1)意味着我们发现了环。找到一个已经完全探索的节点(状态 = 2)只是交叉边,不是环。

中等:课程表 II(拓扑排序)

  • 问题:返回一个有效的课程顺序(拓扑排序)。

  • 模式(Kahn 算法——基于 BFS):从没有入边的节点(入度为 0)开始。处理它们,减少它们邻居的入度。重复。

from collections import deque

def find_order(num_courses, prerequisites):
    graph = {i: [] for i in range(num_courses)}
    indegree = [0] * num_courses

    for course, prereq in prerequisites:
        graph[prereq].append(course)
        indegree[course] += 1

    queue = deque([i for i in range(num_courses) if indegree[i] == 0])
    order = []

    while queue:
        node = queue.popleft()
        order.append(node)
        for neighbour in graph[node]:
            indegree[neighbour] -= 1
            if indegree[neighbour] == 0:
                queue.append(neighbour)

    return order if len(order) == num_courses else []  # 空 = 存在环
  • 陷阱:如果结果中的节点数少于图中的节点数,则存在环(某些节点的入度从未降到 0)。

最短路径

Dijkstra 算法

  • 非负加权图中从源点找到到所有其他节点的最短路径。使用优先队列(最小堆)。
import heapq

def dijkstra(graph, start):
    # graph: {node: [(neighbour, weight), ...]}
    dist = {node: float('inf') for node in graph}
    dist[start] = 0
    heap = [(0, start)]

    while heap:
        d, node = heapq.heappop(heap)
        if d > dist[node]:
            continue  # 过期条目

        for neighbour, weight in graph[node]:
            new_dist = d + weight
            if new_dist < dist[neighbour]:
                dist[neighbour] = new_dist
                heapq.heappush(heap, (new_dist, neighbour))

    return dist
  • 时间:使用二叉堆为 \(O((|V| + |E|) \log |V|)\)

  • 陷阱if d > dist[node]: continue 这行是必须的。没有它,你会处理过期的堆条目,可能退化到 \(O(|V|^2)\)

  • 陷阱:Dijkstra 不适用于负权重。如果一条边有负权重,贪心假设(一旦节点被确定,其距离就是最优的)就不成立了。应改用 Bellman-Ford。

困难:网络延迟时间

  • 问题:给定 \(n\) 个节点和加权有向边,找出信号从源点到达所有节点所需的时间。如果并非所有节点都可到达,返回 -1。
def network_delay(times, n, k):
    graph = {i: [] for i in range(1, n + 1)}
    for u, v, w in times:
        graph[u].append((v, w))

    dist = dijkstra(graph, k)
    max_time = max(dist.values())
    return max_time if max_time < float('inf') else -1

强连通分量

  • 在有向图中,强连通分量(SCC)是一个最大节点集合,其中每个节点都能到达其他所有节点。

  • Kosaraju 算法:(1) 在原始图上进行 DFS,记录完成顺序。(2) 转置图(反转所有边)。(3) 按完成顺序的逆序在转置图上进行 DFS。第3步中的每个 DFS 树就是一个 SCC。

  • 何时使用:寻找循环依赖、2-SAT、将有向图压缩为 SCC 的 DAG。


常见陷阱总结

陷阱 示例 修复
在出队时标记已访问 同一节点被多次入队 在入队时标记已访问
有向图中只有两种状态 无法区分回边和交叉边 使用三种状态:未访问/进行中/已完成
Dijkstra 用于负权重 错误的最短路径 使用 Bellman-Ford
忘记 if d > dist[node]: continue 处理过期堆条目 总是跳过当前距离更差的情况
网格边界检查 索引越界 0 <= nr < rows and 0 <= nc < cols
没有考虑 time=0 的边界情况 腐烂橘子:没有新鲜橘子 在 BFS 之前检查 fresh == 0
将有向图构建为无向图 先修条件是单向的 只在一个方向添加边

课后练习题(NeetCode)

BFS 模式

DFS 模式

最短路径

进阶