第 16 章

图的表示与遍历

第十六章:图的表示与遍历

在前面的章节中,我们学习了各种线性和层次结构——数组、链表、树。这些结构有一个共同特点:它们的连接关系是"规则的"。数组中每个元素有前驱和后继,树中每个节点有一个父节点和若干子节点。但现实世界中,许多关系远比这些复杂——社交网络中的好友关系是多对多的、城市之间的道路网络是纵横交错的、互联网的网页通过超链接彼此指向。

图(Graph) 是描述这些复杂关系的终极数据结构。它由顶点(Vertex/Node)边(Edge) 组成,可以表达任意两个实体之间的关系。图论不仅是计算机科学的基础,更是离散数学中最富有成果的分支之一。

本章我们将解决三个核心问题:(1) 如何在计算机中高效地表示一个图?(2) 如何系统地访问图中的每一个顶点?(3) 遍历的过程能告诉我们关于图的哪些结构信息?


Level 1 · 你需要知道的

16.1 图的基本概念

定义:图 G = (V, E),其中 V 是顶点集合,E 是边集合。每条边连接两个顶点。

根据边是否有方向,图分为两大类:

其他重要术语:

术语 含义
度(Degree) 与某顶点相连的边数。有向图中分入度和出度
路径(Path) 从顶点 u 到顶点 v 经过的顶点序列
环/回路(Cycle) 起点和终点相同的路径
连通(Connected) 无向图中任意两个顶点之间都有路径
强连通(Strongly Connected) 有向图中任意两个顶点之间都有有向路径
权重(Weight) 边上的数值,表示距离、代价等
无向图示例(社交网络):       有向图示例(网页链接):
  A --- B                      A → B
  |   / |                      ↑   ↓
  |  /  |                      D ← C
  C --- D

无向图:边没有箭头            有向图:边有方向
A-B 表示 A和B互为好友         A→B 表示 A链接到B

16.2 图的两种存储方式

计算机中存储图,主要有两种经典方式:邻接矩阵和邻接表。

16.2.1 邻接矩阵(Adjacency Matrix)

用一个 |V|×|V| 的二维数组 matrix 表示图。matrix[i][j] = 1(或权重值)表示从顶点 i 到顶点 j 有一条边;matrix[i][j] = 0 表示无边。

class GraphMatrix:
    """基于邻接矩阵的图表示"""
    
    def __init__(self, num_vertices: int, directed: bool = False):
        self.n = num_vertices
        self.directed = directed
        # 创建 n×n 的零矩阵
        self.matrix = [[0] * num_vertices for _ in range(num_vertices)]
    
    def add_edge(self, u: int, v: int, weight: int = 1):
        """添加边 u → v"""
        self.matrix[u][v] = weight
        if not self.directed:
            self.matrix[v][u] = weight  # 无向图:双向
    
    def has_edge(self, u: int, v: int) -> bool:
        """查询边是否存在 —— O(1)"""
        return self.matrix[u][v] != 0
    
    def get_neighbors(self, u: int) -> list:
        """获取所有邻居 —— O(V)"""
        neighbors = []
        for v in range(self.n):
            if self.matrix[u][v] != 0:
                neighbors.append(v)
        return neighbors
    
    def degree(self, u: int) -> int:
        """顶点的度"""
        return sum(1 for v in range(self.n) if self.matrix[u][v] != 0)


# 示例:创建一个无向图
#   0 --- 1
#   |   / |
#   |  /  |
#   2 --- 3
g = GraphMatrix(4, directed=False)
g.add_edge(0, 1)
g.add_edge(0, 2)
g.add_edge(1, 2)
g.add_edge(1, 3)
g.add_edge(2, 3)

print(g.has_edge(0, 1))  # True
print(g.has_edge(0, 3))  # False
print(g.get_neighbors(1))  # [0, 2, 3]

邻接矩阵的特点

操作 时间复杂度
查询边 (u, v) 是否存在 O(1)
获取所有邻居 O(V)
添加边 O(1)
空间复杂度 O(V²)

优点:查询边的存在性极快(O(1)随机访问);实现简单。

缺点:空间 O(V²),对稀疏图(边远少于 V²)非常浪费。

16.2.2 邻接表(Adjacency List)

对每个顶点,维护一个列表,记录所有与之相邻的顶点。这是实际工程中最常用的表示。

from collections import defaultdict

class GraphList:
    """基于邻接表的图表示"""
    
    def __init__(self, directed: bool = False):
        self.directed = directed
        self.adj = defaultdict(list)  # 邻接表
    
    def add_edge(self, u, v, weight=1):
        """添加边 u → v"""
        self.adj[u].append((v, weight))
        if not self.directed:
            self.adj[v].append((u, weight))  # 无向图:双向
    
    def has_edge(self, u, v) -> bool:
        """查询边是否存在 —— O(degree(u))"""
        return any(neighbor == v for neighbor, _ in self.adj[u])
    
    def get_neighbors(self, u) -> list:
        """获取所有邻居 —— O(degree(u))"""
        return [v for v, _ in self.adj[u]]
    
    def degree(self, u) -> int:
        """顶点的度"""
        return len(self.adj[u])
    
    def vertices(self) -> set:
        """所有顶点"""
        return set(self.adj.keys())


# 示例:同样的图用邻接表表示
g = GraphList(directed=False)
g.add_edge(0, 1)
g.add_edge(0, 2)
g.add_edge(1, 2)
g.add_edge(1, 3)
g.add_edge(2, 3)

print(g.get_neighbors(1))  # [0, 2, 3](含权重版本会是元组列表)
print(g.degree(1))  # 3

邻接表的特点

操作 时间复杂度
查询边 (u, v) 是否存在 O(degree(u))
获取所有邻居 O(degree(u))
添加边 O(1)
空间复杂度 O(V + E)

优点:空间高效(只存实际存在的边);遍历邻居快。

缺点:查询特定边不如邻接矩阵快。

16.2.3 如何选择?

条件 推荐表示
稠密图(E ≈ V²) 邻接矩阵
稀疏图(E << V²) 邻接表
需要频繁查询特定边 邻接矩阵
需要遍历所有邻居 邻接表
顶点数极大(如百万级) 邻接表(矩阵放不下)

关键直觉:现实世界的大多数图都是稀疏的。社交网络有数十亿用户,但每人平均只有几百个好友;互联网有数十亿网页,但每个网页只链接到有限的其他页面。因此,邻接表是工程中的默认选择。

16.3 广度优先搜索(BFS)

BFS 从一个起始顶点出发,先访问所有距离为 1 的顶点,再访问所有距离为 2 的顶点……像水波一样一层层向外扩散。

核心思想:使用队列(FIFO),每次从队列中取出一个顶点,将其所有未访问的邻居加入队列。

from collections import deque

def bfs(graph: GraphList, start) -> list:
    """
    广度优先搜索
    返回 BFS 访问顺序
    """
    visited = set()
    order = []
    queue = deque([start])
    visited.add(start)
    
    while queue:
        node = queue.popleft()
        order.append(node)
        
        for neighbor in graph.get_neighbors(node):
            if neighbor not in visited:
                visited.add(neighbor)
                queue.append(neighbor)
    
    return order


# 示例
g = GraphList()
g.add_edge(0, 1)
g.add_edge(0, 2)
g.add_edge(1, 3)
g.add_edge(2, 3)
g.add_edge(3, 4)

print(bfs(g, 0))  # [0, 1, 2, 3, 4]

BFS 的关键性质

  1. 层序遍历:BFS 按距离起点的层数逐层访问。
  2. 最短路径(无权图):BFS 首次到达某顶点时,经过的边数就是从起点到该顶点的最短路径长度。
  3. 时间复杂度:O(V + E)——每个顶点入队出队一次,每条边被检查一次。
  4. 空间复杂度:O(V)——visited 集合和队列最多存 V 个顶点。

BFS 求最短路径(无权图)

def bfs_shortest_path(graph: GraphList, start, end) -> list:
    """
    BFS 找从 start 到 end 的最短路径(无权图)
    返回路径顶点列表,或空列表(不可达)
    """
    if start == end:
        return [start]
    
    visited = {start}
    queue = deque([(start, [start])])  # (当前节点, 从起点到此的路径)
    
    while queue:
        node, path = queue.popleft()
        
        for neighbor in graph.get_neighbors(node):
            if neighbor not in visited:
                new_path = path + [neighbor]
                if neighbor == end:
                    return new_path
                visited.add(neighbor)
                queue.append((neighbor, new_path))
    
    return []  # 不可达


# 更高效的版本:只记录 parent,最后回溯路径
def bfs_shortest_path_v2(graph: GraphList, start, end) -> list:
    """高效版本:用 parent 字典回溯路径"""
    if start == end:
        return [start]
    
    visited = {start}
    parent = {start: None}
    queue = deque([start])
    
    while queue:
        node = queue.popleft()
        for neighbor in graph.get_neighbors(node):
            if neighbor not in visited:
                visited.add(neighbor)
                parent[neighbor] = node
                if neighbor == end:
                    # 回溯路径
                    path = []
                    cur = end
                    while cur is not None:
                        path.append(cur)
                        cur = parent[cur]
                    return path[::-1]
                queue.append(neighbor)
    
    return []

16.4 深度优先搜索(DFS)

DFS 从起始顶点出发,沿着一条路径尽可能深地探索,直到无法继续,然后回溯,尝试其他路径。

核心思想:使用栈(隐式的递归调用栈或显式栈),每次深入一个未访问的邻居。

def dfs_recursive(graph: GraphList, start) -> list:
    """
    深度优先搜索 —— 递归版本
    """
    visited = set()
    order = []
    
    def _dfs(node):
        visited.add(node)
        order.append(node)
        for neighbor in graph.get_neighbors(node):
            if neighbor not in visited:
                _dfs(neighbor)
    
    _dfs(start)
    return order


def dfs_iterative(graph: GraphList, start) -> list:
    """
    深度优先搜索 —— 迭代版本(显式栈)
    """
    visited = set()
    order = []
    stack = [start]
    
    while stack:
        node = stack.pop()
        if node in visited:
            continue
        visited.add(node)
        order.append(node)
        
        # 逆序压栈,保证按邻居顺序访问
        for neighbor in reversed(graph.get_neighbors(node)):
            if neighbor not in visited:
                stack.append(neighbor)
    
    return order


# 示例
g = GraphList()
g.add_edge(0, 1)
g.add_edge(0, 2)
g.add_edge(1, 3)
g.add_edge(2, 3)
g.add_edge(3, 4)

print(dfs_recursive(g, 0))  # [0, 1, 3, 2, 4] 或其他 DFS 序
print(dfs_iterative(g, 0))  # [0, 1, 3, 2, 4] 或其他 DFS 序

DFS 的关键性质

  1. 深入优先:尽可能沿着一个方向深入,然后回溯。
  2. 时间复杂度:O(V + E)——和 BFS 相同。
  3. 空间复杂度:O(V)——递归调用栈深度最坏为 V(链状图)。
  4. 用途广泛:检测环、拓扑排序、求连通分量、二分图判定等。

BFS vs DFS 对比

特性 BFS DFS
数据结构 队列
遍历顺序 按层(由近及远) 按深度(一路到底)
最短路径 可求(无权图) 不保证
空间 O(宽度) O(深度)
适用场景 最短路径、层序遍历 环检测、拓扑排序、连通性

16.5 连通分量

对于无向图,如果图不连通(不是所有顶点都能互达),那么图由若干连通分量(Connected Component) 组成。每个连通分量是一个最大连通子图。

def find_connected_components(graph: GraphList) -> list:
    """
    找出无向图的所有连通分量
    返回:列表的列表,每个子列表是一个连通分量中的顶点
    """
    visited = set()
    components = []
    
    for vertex in graph.vertices():
        if vertex not in visited:
            # 从这个顶点开始 BFS/DFS,找到整个连通分量
            component = []
            queue = deque([vertex])
            visited.add(vertex)
            
            while queue:
                node = queue.popleft()
                component.append(node)
                for neighbor in graph.get_neighbors(node):
                    if neighbor not in visited:
                        visited.add(neighbor)
                        queue.append(neighbor)
            
            components.append(component)
    
    return components


# 示例:两个不连通的子图
g = GraphList()
g.add_edge(0, 1)
g.add_edge(1, 2)  # 连通分量1: {0, 1, 2}
g.add_edge(3, 4)  # 连通分量2: {3, 4}

print(find_connected_components(g))  # [[0, 1, 2], [3, 4]]

核心思路:遍历所有顶点;对于每个未访问的顶点,启动一次 BFS/DFS,能到达的所有顶点构成一个连通分量。

时间复杂度:O(V + E)——虽然有外层循环,但每个顶点/边只被访问一次。

16.6 二分图判定

二分图(Bipartite Graph):如果图的顶点可以分成两组 A 和 B,使得所有的边都连接 A 中的顶点和 B 中的顶点(即同组内没有边),则图是二分图。

判定方法:尝试用两种颜色给图着色。如果可以让相邻顶点颜色不同,则是二分图;否则不是。

def is_bipartite(graph: GraphList) -> bool:
    """
    判断无向图是否是二分图
    使用 BFS 染色法
    """
    color = {}  # 顶点 → 颜色(0 或 1)
    
    for start in graph.vertices():
        if start in color:
            continue
        
        # BFS 染色
        queue = deque([start])
        color[start] = 0
        
        while queue:
            node = queue.popleft()
            for neighbor in graph.get_neighbors(node):
                if neighbor not in color:
                    # 邻居染相反颜色
                    color[neighbor] = 1 - color[node]
                    queue.append(neighbor)
                elif color[neighbor] == color[node]:
                    # 邻居颜色相同 → 不是二分图
                    return False
    
    return True


# 示例1:二分图
#   0 --- 1
#   |     |
#   3 --- 2
# 可以将 {0,2} 染红,{1,3} 染蓝
g1 = GraphList()
g1.add_edge(0, 1)
g1.add_edge(1, 2)
g1.add_edge(2, 3)
g1.add_edge(3, 0)
print(is_bipartite(g1))  # True

# 示例2:非二分图(有奇数环)
#   0 --- 1
#    \   /
#      2
g2 = GraphList()
g2.add_edge(0, 1)
g2.add_edge(1, 2)
g2.add_edge(2, 0)
print(is_bipartite(g2))  # False(三角形有奇数环)

定理:一个图是二分图,当且仅当它不包含奇数长度的环(König, 1916)。

直觉:如果有一个奇数环,从某顶点出发沿环走一圈回来,颜色必然冲突。

16.7 常见错误

错误1:忘记标记已访问

# 错误:没有 visited 集合,会陷入无限循环
def bfs_wrong(graph, start):
    queue = deque([start])
    while queue:
        node = queue.popleft()
        for neighbor in graph.get_neighbors(node):
            queue.append(neighbor)  # 无限循环!

错误2:DFS 递归深度超限

# Python 默认递归深度限制是 1000
# 对于大图(如 10000 个节点的链),递归 DFS 会栈溢出
import sys
sys.setrecursionlimit(100000)  # 临时解决方案
# 更好的方案:使用迭代 DFS

错误3:有向图中混淆连通性

# 有向图中 A→B 不代表 B→A
# "A 能到 B" ≠ "B 能到 A"
# 无向图的"连通"在有向图中对应"强连通"

Level 2 · 它是怎么运行的

16.8 图的存储优化:稀疏 vs 稠密

让我们量化分析两种表示在不同密度图上的表现。

图的密度定义:密度 d = |E| / (|V|² / 2)(无向图),其中 |V|²/2 是最大可能边数。当 d 接近 1 时为稠密图,接近 0 时为稀疏图。

现实中各类图的密度对比:

图类型 典型规模 典型密度 推荐表示
社交网络 V=10⁹, E=10¹¹ ~10⁻⁷ 邻接表
道路网络 V=10⁷, E=3×10⁷ ~10⁻⁶ 邻接表
全连接神经网络层 V=10³, E≈V² ~1 邻接矩阵(即权重矩阵)
分子结构图 V=10¹~10², E≈3V ~0.06 邻接表

内存对比实验

import sys

def memory_comparison(V: int, E: int):
    """对比两种表示的内存使用"""
    
    # 邻接矩阵:V×V 个整数
    # 每个 Python int 约 28 字节,但 numpy 可以用 1 字节
    matrix_memory_naive = V * V * 28  # Python list
    matrix_memory_numpy = V * V * 1   # numpy bool array
    
    # 邻接表:每条边存两次(无向图)
    # 每个列表项约 8 字节(指针)+ 28 字节(int对象)
    adjlist_memory = E * 2 * 36  # 无向图每条边存两次
    
    print(f"图规模: V={V}, E={E}, 密度={2*E/(V*V):.6f}")
    print(f"邻接矩阵 (numpy): {matrix_memory_numpy / 1024 / 1024:.1f} MB")
    print(f"邻接表:           {adjlist_memory / 1024 / 1024:.1f} MB")
    print(f"比值: {matrix_memory_numpy / max(adjlist_memory, 1):.2f}")
    print()

# 稀疏图:10000 节点,30000 条边
memory_comparison(10000, 30000)
# 邻接矩阵: 95.4 MB  vs  邻接表: 2.1 MB → 邻接表胜

# 稠密图:1000 节点,400000 条边
memory_comparison(1000, 400000)
# 邻接矩阵: 1.0 MB   vs  邻接表: 27.5 MB → 矩阵胜

工程中的优化策略

  1. 压缩稀疏行(CSR)格式:用于超大规模稀疏图。用三个数组表示:
    • values[]:所有边的权重
    • col_indices[]:每条边指向的目标顶点
    • row_offsets[]:每个顶点的邻居在 col_indices 中的起始位置
class GraphCSR:
    """压缩稀疏行格式 —— 适合大规模只读图"""
    
    def __init__(self, num_vertices, edges):
        """
        edges: List of (u, v, weight) 元组,已排序
        """
        self.n = num_vertices
        
        # 按源顶点排序
        edges.sort(key=lambda e: e[0])
        
        self.col_indices = [e[1] for e in edges]
        self.weights = [e[2] for e in edges]
        
        # 计算每行的起始偏移
        self.row_offsets = [0] * (num_vertices + 1)
        for u, v, w in edges:
            self.row_offsets[u + 1] += 1
        for i in range(1, num_vertices + 1):
            self.row_offsets[i] += self.row_offsets[i - 1]
    
    def get_neighbors(self, u):
        """O(degree(u)) 获取邻居"""
        start = self.row_offsets[u]
        end = self.row_offsets[u + 1]
        return list(zip(self.col_indices[start:end], self.weights[start:end]))
  1. 邻接集合(Adjacency Set):如果需要频繁查询边是否存在,可以把邻接表中的 list 换成 set,将查询降为摊还 O(1):
class GraphSet:
    """邻接集合:兼顾空间效率和快速边查询"""
    
    def __init__(self, directed=False):
        self.directed = directed
        self.adj = defaultdict(set)
    
    def add_edge(self, u, v):
        self.adj[u].add(v)
        if not self.directed:
            self.adj[v].add(u)
    
    def has_edge(self, u, v) -> bool:
        return v in self.adj[u]  # 平均 O(1)

16.9 BFS 与无权图最短路径

BFS 最重要的理论性质是它能在无权图中找到最短路径。这个结论虽然直观,但需要严格证明。

定理:在无权图 G = (V, E) 中,BFS 从源点 s 出发时,对于每个顶点 v,BFS 发现 v 时经过的边数 d(s,v) 等于 s 到 v 的最短路径长度。

证明思路

设 δ(s,v) 为 s 到 v 的真实最短距离(最少边数),d(s,v) 为 BFS 发现 v 时的层数。

  1. d(s,v) ≥ δ(s,v):BFS 发现 v 经过的路径是一条合法路径,不可能短于最短路径。
  2. d(s,v) ≤ δ(s,v):用归纳法。假设对所有距离 < k 的顶点成立。对于 δ(s,v) = k 的顶点 v,必有某邻居 u 满足 δ(s,u) = k-1。由归纳假设,BFS 在第 k-1 层发现了 u,然后在处理 u 的邻居时会发现 v(如果 v 尚未被发现),此时 v 的层数为 k。

因此 d(s,v) = δ(s,v)。□

BFS 带层次信息的完整实现

def bfs_with_levels(graph: GraphList, start):
    """
    BFS 同时记录每个节点的距离和前驱
    返回 (distance, parent) 字典
    """
    dist = {start: 0}
    parent = {start: None}
    queue = deque([start])
    
    while queue:
        node = queue.popleft()
        for neighbor in graph.get_neighbors(node):
            if neighbor not in dist:
                dist[neighbor] = dist[node] + 1
                parent[neighbor] = node
                queue.append(neighbor)
    
    return dist, parent


def reconstruct_path(parent: dict, start, end) -> list:
    """从 parent 字典回溯最短路径"""
    if end not in parent:
        return []  # 不可达
    path = []
    cur = end
    while cur is not None:
        path.append(cur)
        cur = parent[cur]
    return path[::-1]


# 示例:六度分隔理论
# 在社交网络图中,BFS 可以找到两个人之间的最短关系链
g = GraphList()
edges = [(0,1),(0,2),(1,3),(2,4),(3,5),(4,5),(5,6)]
for u, v in edges:
    g.add_edge(u, v)

dist, parent = bfs_with_levels(g, 0)
print(f"从 0 到 6 的最短距离: {dist[6]}")  # 3
print(f"最短路径: {reconstruct_path(parent, 0, 6)}")  # [0, 1, 3, 5, 6]

多源 BFS

有时需要同时从多个源点出发做 BFS。例如,"找到每个空地到最近的建筑物的距离"。技巧是将所有源点同时放入初始队列:

def multi_source_bfs(graph: GraphList, sources: list) -> dict:
    """
    多源 BFS:同时从多个源点出发
    返回每个顶点到最近源点的距离
    """
    dist = {}
    queue = deque()
    
    for s in sources:
        dist[s] = 0
        queue.append(s)
    
    while queue:
        node = queue.popleft()
        for neighbor in graph.get_neighbors(node):
            if neighbor not in dist:
                dist[neighbor] = dist[node] + 1
                queue.append(neighbor)
    
    return dist

16.10 DFS 的时间戳与边的分类

DFS 有一个极其强大的工具:时间戳(Timestamp)。在 DFS 过程中,为每个顶点记录两个时间:

class DFSWithTimestamp:
    """带时间戳的 DFS"""
    
    def __init__(self, graph: GraphList):
        self.graph = graph
        self.discovery = {}   # 发现时间
        self.finish = {}      # 完成时间
        self.parent = {}      # DFS 树中的父节点
        self.time = 0         # 全局时钟
        self.edge_type = {}   # 边的分类
    
    def dfs(self):
        """对整个图执行 DFS"""
        for v in self.graph.vertices():
            self.parent[v] = None
        
        for v in self.graph.vertices():
            if v not in self.discovery:
                self._dfs_visit(v)
    
    def _dfs_visit(self, u):
        """访问顶点 u"""
        self.time += 1
        self.discovery[u] = self.time
        
        for v in self.graph.get_neighbors(u):
            if v not in self.discovery:
                # v 未被发现 → 树边
                self.edge_type[(u, v)] = "tree"
                self.parent[v] = u
                self._dfs_visit(v)
            elif v not in self.finish:
                # v 已发现但未完成 → 回边(检测到环!)
                self.edge_type[(u, v)] = "back"
            elif self.discovery[u] < self.discovery[v]:
                # v 已完成,且 v 在 u 之后被发现 → 前向边
                self.edge_type[(u, v)] = "forward"
            else:
                # v 已完成,且 v 在 u 之前被发现 → 交叉边
                self.edge_type[(u, v)] = "cross"
        
        self.time += 1
        self.finish[u] = self.time


# 示例:有向图
g = GraphList(directed=True)
g.add_edge(0, 1)
g.add_edge(0, 2)
g.add_edge(1, 3)
g.add_edge(3, 0)  # 回边:形成环 0→1→3→0
g.add_edge(2, 3)

dfs = DFSWithTimestamp(g)
dfs.dfs()

print("发现/完成时间:")
for v in sorted(dfs.discovery.keys()):
    print(f"  顶点 {v}: d={dfs.discovery[v]}, f={dfs.finish[v]}")

print("\n边的分类:")
for edge, etype in dfs.edge_type.items():
    print(f"  {edge[0]} → {edge[1]}: {etype}")

四种边的含义

边类型 定义 意义
树边(Tree Edge) 通向未发现顶点 DFS 树的边
回边(Back Edge) 通向祖先(已发现但未完成) 存在回边 ⟺ 图有环
前向边(Forward Edge) 通向后代(已完成,发现时间更晚) 只在有向图中
交叉边(Cross Edge) 通向非祖先非后代的已完成顶点 只在有向图中

括号定理(Parenthesis Theorem)

对于任意两个顶点 u 和 v,以下三种情况恰好有一种成立:

  1. 区间 [d[u], f[u]] 和 [d[v], f[v]] 完全不相交 → u 和 v 在 DFS 树中无祖先后代关系
  2. [d[u], f[u]] 完全包含 [d[v], f[v]] → v 是 u 的后代
  3. [d[v], f[v]] 完全包含 [d[u], f[u]] → u 是 v 的后代

这就像括号的嵌套关系——发现时间是"左括号",完成时间是"右括号",括号要么嵌套要么不相交,不会交叉。

白色路径定理(White Path Theorem)

在 DFS 中,v 是 u 的后代,当且仅当在发现 u 的时刻,存在一条从 u 到 v 的路径,路径上所有顶点都是白色的(未被发现)。

这两个定理是 DFS 众多应用(拓扑排序、强连通分量、割点/桥)的理论基础。

16.11 利用 DFS 检测环

无向图的环检测

在无向图中,如果 DFS 遇到一个已访问且不是当前节点父节点的邻居,则存在环。

def has_cycle_undirected(graph: GraphList) -> bool:
    """无向图环检测"""
    visited = set()
    
    def dfs(node, parent):
        visited.add(node)
        for neighbor in graph.get_neighbors(node):
            if neighbor not in visited:
                if dfs(neighbor, node):
                    return True
            elif neighbor != parent:
                # 遇到已访问的非父节点 → 有环
                return True
        return False
    
    for v in graph.vertices():
        if v not in visited:
            if dfs(v, -1):
                return True
    return False

有向图的环检测

有向图中,环的存在等价于 DFS 发现回边。

def has_cycle_directed(graph: GraphList) -> bool:
    """有向图环检测:三色标记法"""
    # WHITE=未访问, GRAY=正在访问(在递归栈中), BLACK=已完成
    WHITE, GRAY, BLACK = 0, 1, 2
    color = {v: WHITE for v in graph.vertices()}
    
    def dfs(node):
        color[node] = GRAY
        for neighbor in graph.get_neighbors(node):
            if color[neighbor] == GRAY:
                return True  # 回边 → 有环
            if color[neighbor] == WHITE:
                if dfs(neighbor):
                    return True
        color[node] = BLACK
        return False
    
    for v in graph.vertices():
        if color[v] == WHITE:
            if dfs(v):
                return True
    return False

三色标记法的直觉

如果从灰色节点出发遇到另一个灰色节点,说明我们找到了一条从该灰色节点到自身的路径——即一个环。

16.12 图遍历的应用模式

模式1:网格图(Grid Graph)

许多面试题将二维网格视为图,每个格子是一个顶点,上下左右相邻的格子之间有边。

def grid_bfs(grid: list, start_row: int, start_col: int):
    """二维网格上的 BFS"""
    rows, cols = len(grid), len(grid[0])
    directions = [(0, 1), (0, -1), (1, 0), (-1, 0)]  # 右左下上
    
    visited = set()
    visited.add((start_row, start_col))
    queue = deque([(start_row, start_col, 0)])  # (行, 列, 距离)
    
    while queue:
        r, c, dist = queue.popleft()
        
        for dr, dc in directions:
            nr, nc = r + dr, c + dc
            if (0 <= nr < rows and 0 <= nc < cols and 
                (nr, nc) not in visited and grid[nr][nc] != '#'):
                visited.add((nr, nc))
                queue.append((nr, nc, dist + 1))

模式2:隐式图(Implicit Graph)

有些问题的图不是显式给出的,而是由状态和转移规则隐式定义的。例如,"从数字 x 出发,每次可以 +1、×2、-1,最少几步到达 y?"——这就是在隐式图上做 BFS。

def min_operations(start: int, target: int) -> int:
    """从 start 到 target,每步可以 +1, -1, ×2"""
    if start >= target:
        return start - target  # 只能 -1
    
    visited = set([start])
    queue = deque([(start, 0)])
    
    while queue:
        num, steps = queue.popleft()
        for next_num in [num + 1, num - 1, num * 2]:
            if next_num == target:
                return steps + 1
            if 0 <= next_num <= 2 * target and next_num not in visited:
                visited.add(next_num)
                queue.append((next_num, steps + 1))
    
    return -1

Level 3 · 规范怎么定义的

16.13 欧拉与图论的诞生:七桥问题(1736)

图论作为数学分支的诞生,可以精确追溯到一个时刻:1736年,瑞士数学家莱昂哈德·欧拉(Leonhard Euler) 发表了论文《Solutio problematis ad geometriam situs pertinentis》(关于一个与位置几何有关的问题的解),解决了著名的柯尼斯堡七桥问题

问题背景:18世纪的柯尼斯堡(今俄罗斯加里宁格勒)有一条河(普雷格尔河)穿城而过,河中有两个岛。城市的四块陆地由七座桥相连。居民们提出一个问题:能否找到一条路线,恰好经过每座桥一次,然后回到起点?

柯尼斯堡七桥的抽象图:

         A
        / \
       /   \
      1     2
     /       \
    B ---3--- C
     \       /
      4     5
       \   /
        \ /
         D
        / \
       6   7
      /     \
     B       C

简化为多重图:
  A(度=5): 连接B×2, C×2, D×1
  B(度=3): 连接A×2, D×1  
  C(度=3): 连接A×2, D×1
  D(度=3): 连接A×1, B×1, C×1

(注:这里用 A,B,C,D 代表四块陆地)
实际是 A-B 有2座桥, A-C 有2座桥, A-D 有1座桥, B-D 有1座桥, C-D 有1座桥
→ A 度=5, B 度=3, C 度=3, D 度=3

欧拉的解法

欧拉做了一个天才的抽象——他不关心陆地的面积、桥的长度、路线的形状,只关心连接关系。他将四块陆地抽象为顶点,七座桥抽象为边,从而将一个地理问题转化为纯粹的数学问题。

这个抽象本身就是图论的诞生时刻。

欧拉定理:一个连通图存在欧拉回路(经过每条边恰好一次并回到起点)的充要条件是:每个顶点的度数都是偶数。存在欧拉路径(经过每条边恰好一次但不要求回到起点)的充要条件是:恰好有 0 个或 2 个奇数度顶点。

证明(必要性):如果存在欧拉回路,那么每次路径经过一个顶点时,必然"从一条边进入,从另一条边离开"——消耗两条边。对于非起始顶点,所有边都是成对消耗的,所以度数必须是偶数。起始顶点最初"离开"用一条边,最后"回来"用一条边,其余也是成对的,所以度数也是偶数。

应用到七桥问题:四个顶点的度数分别是 5, 3, 3, 3——全是奇数!因此欧拉回路不存在,欧拉路径也不存在(奇数度顶点有4个,不是0或2)。

def has_eulerian_circuit(graph: GraphList) -> bool:
    """判断无向连通图是否有欧拉回路"""
    for v in graph.vertices():
        if graph.degree(v) % 2 != 0:
            return False
    return True

def has_eulerian_path(graph: GraphList) -> tuple:
    """
    判断无向连通图是否有欧拉路径
    返回 (是否存在, 起点) 
    """
    odd_degree_vertices = [v for v in graph.vertices() if graph.degree(v) % 2 != 0]
    
    if len(odd_degree_vertices) == 0:
        # 欧拉回路(也是欧拉路径),任意起点
        return True, next(iter(graph.vertices()))
    elif len(odd_degree_vertices) == 2:
        # 欧拉路径,必须从奇数度顶点出发
        return True, odd_degree_vertices[0]
    else:
        return False, None

历史意义

欧拉的贡献不仅在于解决了七桥问题,更在于:

  1. 创立了图论:将具体问题抽象为"顶点+边"的数学结构
  2. 开创了拓扑学的思想:只关心连接关系,不关心度量(距离、面积)
  3. 证明了不可能性:不是找到了答案,而是证明了答案不存在——这是数学中极为深刻的思维方式

16.14 图的表示在社交网络与推荐系统中的应用

现代互联网的核心产品——社交网络和推荐系统——本质上是大规模图算法的工程实践。

社交网络:Facebook 的 TAO

Facebook(Meta)的社交图有约 30 亿顶点和数千亿条边。他们的图存储系统 TAO(The Associations and Objects)在 2013 年由 Bronson et al. 发表于 USENIX ATC。

TAO 的核心设计决策:

  1. 分离对象和关联:顶点(用户、帖子、评论)存为 Object,边(好友关系、点赞、评论)存为 Association。
  2. 邻接表分片:按源顶点 ID 哈希分片,每个分片存储一部分顶点的邻接表。
  3. 反向边索引:对于关系查询"谁关注了我",维护反向邻接表。
  4. 分层缓存:热门顶点(如名人)的邻接表缓存在内存中。
# TAO 的简化模型
class SocialGraph:
    """社交网络图存储的简化模型"""
    
    def __init__(self):
        # 正向邻接表:user_id → set of (friend_id, timestamp, edge_type)
        self.forward = defaultdict(set)
        # 反向邻接表:user_id → set of (follower_id, timestamp)
        self.reverse = defaultdict(set)
    
    def add_follow(self, follower: int, followee: int, timestamp: int):
        """A 关注 B"""
        self.forward[follower].add((followee, timestamp, "follow"))
        self.reverse[followee].add((follower, timestamp))
    
    def get_following(self, user: int) -> list:
        """获取某人关注的人列表"""
        return [(uid, ts) for uid, ts, _ in self.forward[user]]
    
    def get_followers(self, user: int) -> list:
        """获取某人的粉丝列表"""
        return list(self.reverse[user])
    
    def mutual_friends(self, u: int, v: int) -> set:
        """共同好友 —— 图的邻居交集"""
        friends_u = {uid for uid, _, _ in self.forward[u]}
        friends_v = {uid for uid, _, _ in self.forward[v]}
        return friends_u & friends_v

推荐系统:基于图的协同过滤

推荐系统的一种经典方法是将用户和物品构建为二分图,然后利用图结构做推荐。

思路:如果用户 A 和用户 B 有很多共同喜欢的物品(在二分图中有很多长度为 2 的路径),那么 A 喜欢而 B 未接触的物品可以推荐给 B。

def recommend_items(user_item_graph: dict, target_user: int, top_k: int = 5) -> list:
    """
    基于二分图的简单推荐
    user_item_graph: {user_id: set of item_ids}
    """
    target_items = user_item_graph[target_user]
    
    # 找到相似用户(有共同喜好的用户)
    item_scores = defaultdict(float)
    
    for other_user, other_items in user_item_graph.items():
        if other_user == target_user:
            continue
        
        # 相似度 = 共同物品数 / 总物品数(Jaccard 系数)
        common = target_items & other_items
        if not common:
            continue
        similarity = len(common) / len(target_items | other_items)
        
        # 推荐 other_user 喜欢而 target_user 未接触的物品
        for item in other_items - target_items:
            item_scores[item] += similarity
    
    # 按分数排序,返回 top_k
    recommended = sorted(item_scores.items(), key=lambda x: -x[1])
    return [item for item, score in recommended[:top_k]]

图数据库:Neo4j 与属性图模型

图数据库使用属性图模型(Property Graph Model):顶点和边都可以有类型和属性。这比简单的邻接表丰富得多。

Neo4j 的查询语言 Cypher 示例:

// 找到某用户的二度好友中喜欢 Python 的人
MATCH (me:User {name: "Alice"})-[:FRIEND*2]-(fof:User)
WHERE fof.language = "Python" AND fof <> me
RETURN fof.name

这本质上是在图上做模式匹配——一种比 BFS/DFS 更复杂但表达力更强的图遍历。

Google 的 PageRank:BFS 思想的推广

Larry Page 和 Sergey Brin 在 1998 年提出的 PageRank 算法,本质上是在有向图(网页链接图)上的随机游走(Random Walk)。它可以看作 BFS 的概率版本:

def pagerank(graph: GraphList, damping: float = 0.85, iterations: int = 100):
    """
    简化版 PageRank 算法
    damping: 阻尼因子(继续沿链接走的概率)
    """
    vertices = list(graph.vertices())
    n = len(vertices)
    rank = {v: 1.0 / n for v in vertices}
    
    for _ in range(iterations):
        new_rank = {}
        for v in vertices:
            # 随机跳转的贡献
            new_rank[v] = (1 - damping) / n
            # 来自入链的贡献
            # 需要知道谁指向 v(反向边)
        
        for u in vertices:
            neighbors = graph.get_neighbors(u)
            if neighbors:
                share = damping * rank[u] / len(neighbors)
                for v in neighbors:
                    new_rank[v] = new_rank.get(v, (1-damping)/n) + share
        
        rank = new_rank
    
    return rank

16.15 图遍历的形式化:来自 Cormen et al. (CLRS) 的定义

《算法导论》(Introduction to Algorithms,Cormen, Leiserson, Rivest, Stein,2009年第三版)第22章给出了 BFS 和 DFS 的标准形式化描述。

BFS 的不变量(Invariant)

在 BFS 的任何时刻,队列中的顶点按距离排列:如果 u 在 v 前面入队,则 d[u] ≤ d[v],且队列中相邻元素的距离差最多为 1。

这个不变量的数学表述:设队列为 ⟨v₁, v₂, ..., vₖ⟩,则 d[vₖ] ≤ d[v₁] + 1,且 d[vᵢ] ≤ d[vᵢ₊₁]。

这保证了 BFS 的"一层一层"扩展行为。

DFS 的结构定理

CLRS 证明了 DFS 产生的时间戳满足:

  1. 括号定理(如 16.10 节所述)
  2. 后代定理:在 DFS 森林中,v 是 u 的后代 ⟺ d[u] < d[v] < f[v] < f[u]
  3. 回边定理:有向图 G 有环 ⟺ G 的 DFS 产生回边

这些定理将 DFS 从"一种搜索算法"提升为"一种分析图结构的通用工具"。


Level 4 · 边界与陷阱

16.16 LeetCode 200:岛屿数量

题目:给定一个 m×n 的二维网格,'1' 表示陆地,'0' 表示水。计算岛屿的数量。岛屿由水平或垂直相邻的陆地连接形成。

分析:这本质上是求网格图中值为 '1' 的连通分量个数。

from collections import deque

def numIslands(grid: list) -> int:
    """
    LeetCode 200: Number of Islands
    时间 O(m×n),空间 O(m×n)
    """
    if not grid or not grid[0]:
        return 0
    
    rows, cols = len(grid), len(grid[0])
    count = 0
    
    def bfs(r, c):
        """从 (r,c) 开始 BFS,标记整个岛屿"""
        queue = deque([(r, c)])
        grid[r][c] = '0'  # 标记为已访问
        
        while queue:
            row, col = queue.popleft()
            for dr, dc in [(0,1),(0,-1),(1,0),(-1,0)]:
                nr, nc = row + dr, col + dc
                if (0 <= nr < rows and 0 <= nc < cols 
                    and grid[nr][nc] == '1'):
                    grid[nr][nc] = '0'
                    queue.append((nr, nc))
    
    for i in range(rows):
        for j in range(cols):
            if grid[i][j] == '1':
                count += 1
                bfs(i, j)
    
    return count


# DFS 版本(更简洁,但大网格可能栈溢出)
def numIslands_dfs(grid: list) -> int:
    if not grid:
        return 0
    
    rows, cols = len(grid), len(grid[0])
    count = 0
    
    def dfs(r, c):
        if r < 0 or r >= rows or c < 0 or c >= cols or grid[r][c] == '0':
            return
        grid[r][c] = '0'
        dfs(r+1, c)
        dfs(r-1, c)
        dfs(r, c+1)
        dfs(r, c-1)
    
    for i in range(rows):
        for j in range(cols):
            if grid[i][j] == '1':
                count += 1
                dfs(i, j)
    
    return count


# 测试
grid = [
    ['1','1','0','0','0'],
    ['1','1','0','0','0'],
    ['0','0','1','0','0'],
    ['0','0','0','1','1']
]
print(numIslands([row[:] for row in grid]))  # 3

面试要点

16.17 LeetCode 133:克隆图

题目:给定一个无向连通图中的节点引用,返回该图的深拷贝。每个节点包含一个整数值和一个邻居列表。

分析:这是图遍历 + 哈希映射的经典组合。BFS 或 DFS 遍历原图,对每个节点创建克隆,用字典记录"原节点→克隆节点"的映射。

class Node:
    def __init__(self, val=0, neighbors=None):
        self.val = val
        self.neighbors = neighbors if neighbors is not None else []

def cloneGraph(node: 'Node') -> 'Node':
    """
    LeetCode 133: Clone Graph
    BFS + HashMap
    时间 O(V+E),空间 O(V)
    """
    if not node:
        return None
    
    # old_node → new_node 的映射
    cloned = {node: Node(node.val)}
    queue = deque([node])
    
    while queue:
        current = queue.popleft()
        
        for neighbor in current.neighbors:
            if neighbor not in cloned:
                # 创建克隆节点
                cloned[neighbor] = Node(neighbor.val)
                queue.append(neighbor)
            # 建立克隆节点之间的邻接关系
            cloned[current].neighbors.append(cloned[neighbor])
    
    return cloned[node]


# DFS 版本
def cloneGraph_dfs(node: 'Node') -> 'Node':
    """DFS + HashMap"""
    if not node:
        return None
    
    cloned = {}
    
    def dfs(n):
        if n in cloned:
            return cloned[n]
        
        clone = Node(n.val)
        cloned[n] = clone
        
        for neighbor in n.neighbors:
            clone.neighbors.append(dfs(neighbor))
        
        return clone
    
    return dfs(node)

面试要点

16.18 LeetCode 207:课程表

题目:有 n 门课程(0 到 n-1),prerequisites[i] = [a, b] 表示学 a 之前必须先学 b。判断是否能完成所有课程(即有向图是否有环)。

分析:将课程建模为有向图的顶点,先修关系为边。问题等价于判断有向图是否有环(有环则无法完成所有课程)。

def canFinish(numCourses: int, prerequisites: list) -> bool:
    """
    LeetCode 207: Course Schedule
    有向图环检测 —— DFS 三色标记法
    时间 O(V+E),空间 O(V+E)
    """
    # 建图
    graph = defaultdict(list)
    for course, prereq in prerequisites:
        graph[prereq].append(course)
    
    # 三色标记
    WHITE, GRAY, BLACK = 0, 1, 2
    color = [WHITE] * numCourses
    
    def has_cycle(node):
        color[node] = GRAY
        for neighbor in graph[node]:
            if color[neighbor] == GRAY:
                return True  # 发现回边 → 有环
            if color[neighbor] == WHITE:
                if has_cycle(neighbor):
                    return True
        color[node] = BLACK
        return False
    
    for i in range(numCourses):
        if color[i] == WHITE:
            if has_cycle(i):
                return False
    
    return True


# BFS 版本(Kahn 算法 —— 拓扑排序)
def canFinish_bfs(numCourses: int, prerequisites: list) -> bool:
    """BFS 拓扑排序:如果能排完所有节点,则无环"""
    graph = defaultdict(list)
    in_degree = [0] * numCourses
    
    for course, prereq in prerequisites:
        graph[prereq].append(course)
        in_degree[course] += 1
    
    # 所有入度为 0 的节点入队
    queue = deque([i for i in range(numCourses) if in_degree[i] == 0])
    count = 0
    
    while queue:
        node = queue.popleft()
        count += 1
        for neighbor in graph[node]:
            in_degree[neighbor] -= 1
            if in_degree[neighbor] == 0:
                queue.append(neighbor)
    
    return count == numCourses  # 能排完所有课程 → 无环


# 测试
print(canFinish(2, [[1,0]]))          # True: 0→1
print(canFinish(2, [[1,0],[0,1]]))    # False: 0→1→0 有环
print(canFinish(4, [[1,0],[2,1],[3,2]]))  # True: 0→1→2→3

16.19 LeetCode 130:被围绕的区域

题目:给定 m×n 矩阵,包含 'X' 和 'O'。将所有被 'X' 围绕的 'O' 翻转为 'X'。边界上的 'O' 以及与边界 'O' 相连的 'O' 不被翻转。

分析:逆向思维!不是找"被围绕的 O",而是找"不被围绕的 O"(从边界出发能到达的 O),其余的 O 全部翻转。

def solve(board: list) -> None:
    """
    LeetCode 130: Surrounded Regions
    从边界 BFS,标记不被围绕的 'O'
    时间 O(m×n),空间 O(m×n)
    """
    if not board or not board[0]:
        return
    
    rows, cols = len(board), len(board[0])
    
    def bfs(r, c):
        """从边界上的 'O' 开始,标记所有相连的 'O' 为 'S'(safe)"""
        queue = deque([(r, c)])
        board[r][c] = 'S'
        while queue:
            row, col = queue.popleft()
            for dr, dc in [(0,1),(0,-1),(1,0),(-1,0)]:
                nr, nc = row + dr, col + dc
                if (0 <= nr < rows and 0 <= nc < cols 
                    and board[nr][nc] == 'O'):
                    board[nr][nc] = 'S'
                    queue.append((nr, nc))
    
    # 步骤1:从边界上的 'O' 出发,标记所有安全的 'O'
    for i in range(rows):
        if board[i][0] == 'O':
            bfs(i, 0)
        if board[i][cols-1] == 'O':
            bfs(i, cols-1)
    for j in range(cols):
        if board[0][j] == 'O':
            bfs(0, j)
        if board[rows-1][j] == 'O':
            bfs(rows-1, j)
    
    # 步骤2:遍历整个矩阵
    # 'O' → 'X' (被围绕的)
    # 'S' → 'O' (不被围绕的,恢复)
    for i in range(rows):
        for j in range(cols):
            if board[i][j] == 'O':
                board[i][j] = 'X'
            elif board[i][j] == 'S':
                board[i][j] = 'O'


# 测试
board = [
    ['X','X','X','X'],
    ['X','O','O','X'],
    ['X','X','O','X'],
    ['X','O','X','X']
]
solve(board)
for row in board:
    print(row)
# ['X','X','X','X']
# ['X','X','X','X']
# ['X','X','X','X']
# ['X','O','X','X']  ← 左下角的 O 与边界相连,不翻转

面试要点

16.20 图遍历面试的通用框架

面试中图的题目变化多端,但底层思路可以归结为以下框架:

步骤1:识别"这是一个图的问题"

信号词:连通、相邻、可达、最短路径、依赖关系、网格、矩阵遍历。

步骤2:建模

步骤3:选择算法

目标 算法
最短路径(无权) BFS
连通分量/可达性 BFS 或 DFS
环检测 DFS(三色标记)
拓扑排序 DFS 或 BFS(Kahn)
二分图判定 BFS 染色

步骤4:考虑边界情况

# 图面试题的通用模板
def graph_problem_template(input_data):
    """
    1. 建图
    2. 初始化 visited / 其他状态
    3. 遍历(可能需要对所有连通分量都遍历)
    4. 返回结果
    """
    # 建图
    graph = defaultdict(list)
    # ... 根据输入建图 ...
    
    # BFS/DFS
    visited = set()
    result = None
    
    for start_node in all_possible_starts:
        if start_node not in visited:
            # BFS or DFS from start_node
            # 更新 result
            pass
    
    return result

16.21 本章总结

概念 核心要点
邻接矩阵 O(1) 查边,O(V²) 空间,适合稠密图
邻接表 O(V+E) 空间,适合稀疏图,工程默认选择
BFS 队列,层序遍历,无权图最短路径
DFS 栈/递归,时间戳,环检测,边分类
连通分量 遍历所有顶点,对未访问顶点启动 BFS/DFS
二分图 BFS 两色染色,有奇数环则非二分图

图是计算机科学中最通用的数据结构——几乎任何关系都可以建模为图。掌握了图的表示和遍历,你就拥有了解决大量实际问题的基础工具。下一章,我们将深入探讨有向图的一个关键应用:拓扑排序。

本章评分
4.5  / 5  (18 评分)

💬 留言讨论