第12章:图算法精选

图是最能体现关系复杂性的数据结构。在 LeetCode 中,图题目往往让人望而生畏,但其实大多数图算法都有固定的模式。本章将从图的基础表示开始,逐步深入到经典的图算法,帮助你建立处理图问题的系统性思维。关键在于识别问题的图结构本质,选择合适的算法框架。

12.1 图的表示:邻接表vs邻接矩阵

核心概念

图的表示方式直接影响算法的实现和效率。选择合适的表示方式,往往能让代码更加简洁。

邻接表:稀疏图的首选

邻接表适合边数远小于 $V^2$ 的稀疏图,空间复杂度为 $O(V + E)$。

图结构示例:
0 --> [1, 2]
1 --> [2, 3]
2 --> [3]
3 --> []

Python 实现模式

# 使用字典 + 列表
graph = defaultdict(list)
for u, v in edges:
    graph[u].append(v)
    graph[v].append(u)  # 无向图

# 使用字典 + 集合(去重)
graph = defaultdict(set)
for u, v in edges:
    graph[u].add(v)
    graph[v].add(u)

C++ 实现模式

vector<vector<int>> graph(n);
for (auto& edge : edges) {
    graph[edge[0]].push_back(edge[1]);
    graph[edge[1]].push_back(edge[0]);  // 无向图
}

邻接矩阵:稠密图与快速查询

邻接矩阵适合边数接近 $V^2$ 的稠密图,或需要 $O(1)$ 判断边是否存在的场景。

矩阵表示:
    0  1  2  3
0 [ 0  1  1  0 ]
1 [ 0  0  1  1 ]
2 [ 0  0  0  1 ]
3 [ 0  0  0  0 ]

实战例题:克隆图 (#133)

克隆图的关键是处理节点的引用关系,避免重复创建:

def cloneGraph(node):
    if not node:
        return None

    visited = {}  # 原节点 -> 克隆节点的映射

    def dfs(node):
        if node in visited:
            return visited[node]

        # 创建克隆节点
        clone = Node(node.val)
        visited[node] = clone

        # 递归克隆邻居
        for neighbor in node.neighbors:
            clone.neighbors.append(dfs(neighbor))

        return clone

    return dfs(node)

实战例题:最小高度树 (#310)

找到最小高度树的根节点,本质是找图的中心:

def findMinHeightTrees(n, edges):
    if n <= 2:
        return list(range(n))

    # 构建邻接表
    graph = [set() for _ in range(n)]
    for u, v in edges:
        graph[u].add(v)
        graph[v].add(u)

    # 从叶子节点开始剥离
    leaves = [i for i in range(n) if len(graph[i]) == 1]

    remaining = n
    while remaining > 2:
        remaining -= len(leaves)
        new_leaves = []

        for leaf in leaves:
            # 移除叶子节点
            neighbor = graph[leaf].pop()
            graph[neighbor].remove(leaf)

            if len(graph[neighbor]) == 1:
                new_leaves.append(neighbor)

        leaves = new_leaves

    return leaves

关键洞察:树的中心最多有两个节点,通过逐层剥离叶子节点可以找到。

12.2 最短路径:Dijkstra、Bellman-Ford

Dijkstra算法:非负权重的最优选择

Dijkstra算法基于贪心思想,每次选择距离最短的未访问节点进行松弛操作。

算法框架

def dijkstra(graph, start):
    n = len(graph)
    dist = [float('inf')] * n
    dist[start] = 0

    # 优先队列:(距离, 节点)
    pq = [(0, start)]

    while pq:
        d, u = heappop(pq)

        if d > dist[u]:  # 已经有更短路径
            continue

        for v, weight in graph[u]:
            if dist[u] + weight < dist[v]:
                dist[v] = dist[u] + weight
                heappush(pq, (dist[v], v))

    return dist

实战例题:网络延迟时间 (#743)

def networkDelayTime(times, n, k):
    # 构建邻接表
    graph = defaultdict(list)
    for u, v, w in times:
        graph[u].append((v, w))

    # Dijkstra算法
    dist = {i: float('inf') for i in range(1, n + 1)}
    dist[k] = 0
    pq = [(0, k)]

    while pq:
        d, u = heappop(pq)

        if d > dist[u]:
            continue

        for v, w in graph[u]:
            if dist[u] + w < dist[v]:
                dist[v] = dist[u] + w
                heappush(pq, (dist[v], v))

    max_dist = max(dist.values())
    return max_dist if max_dist < float('inf') else -1

Bellman-Ford算法:处理负权边

Bellman-Ford可以检测负权环,通过 $V-1$ 轮松弛操作保证找到最短路径。

算法框架

def bellmanFord(edges, n, start):
    dist = [float('inf')] * n
    dist[start] = 0

    # V-1轮松弛
    for _ in range(n - 1):
        for u, v, w in edges:
            if dist[u] != float('inf'):
                dist[v] = min(dist[v], dist[u] + w)

    # 检测负权环
    for u, v, w in edges:
        if dist[u] != float('inf') and dist[u] + w < dist[v]:
            return None  # 存在负权环

    return dist

实战例题:K站中转内最便宜的航班 (#787)

这题需要限制路径长度,适合用 Bellman-Ford 的变体:

def findCheapestPrice(n, flights, src, dst, k):
    # 最多 k+1 条边
    dist = [float('inf')] * n
    dist[src] = 0

    for _ in range(k + 1):
        # 复制当前距离数组
        new_dist = dist[:]

        for u, v, price in flights:
            if dist[u] != float('inf'):
                new_dist[v] = min(new_dist[v], dist[u] + price)

        dist = new_dist

    return dist[dst] if dist[dst] != float('inf') else -1

关键技巧:使用临时数组避免在同一轮中使用更新后的值。

12.3 最小生成树:Kruskal、Prim

Kruskal算法:基于边的贪心

Kruskal算法按边权重排序,使用并查集判断是否形成环。

算法框架

def kruskal(edges, n):
    # 并查集
    parent = list(range(n))

    def find(x):
        if parent[x] != x:
            parent[x] = find(parent[x])
        return parent[x]

    def union(x, y):
        px, py = find(x), find(y)
        if px == py:
            return False
        parent[px] = py
        return True

    # 按权重排序
    edges.sort(key=lambda x: x[2])

    mst_weight = 0
    mst_edges = []

    for u, v, w in edges:
        if union(u, v):
            mst_weight += w
            mst_edges.append((u, v, w))
            if len(mst_edges) == n - 1:
                break

    return mst_weight, mst_edges

实战例题:连接所有点的最小费用 (#1584)

def minCostConnectPoints(points):
    n = len(points)

    # 计算所有边的权重(曼哈顿距离)
    edges = []
    for i in range(n):
        for j in range(i + 1, n):
            dist = abs(points[i][0] - points[j][0]) + abs(points[i][1] - points[j][1])
            edges.append((dist, i, j))

    # Kruskal算法
    edges.sort()
    parent = list(range(n))

    def find(x):
        if parent[x] != x:
            parent[x] = find(parent[x])
        return parent[x]

    cost = 0
    edges_used = 0

    for dist, u, v in edges:
        pu, pv = find(u), find(v)
        if pu != pv:
            parent[pu] = pv
            cost += dist
            edges_used += 1
            if edges_used == n - 1:
                break

    return cost

Prim算法:基于点的贪心

Prim算法从一个点开始,逐步扩展最小生成树。

算法框架

def prim(graph, n):
    visited = [False] * n
    min_heap = [(0, 0)]  # (权重, 节点)
    mst_weight = 0

    while min_heap:
        weight, u = heappop(min_heap)

        if visited[u]:
            continue

        visited[u] = True
        mst_weight += weight

        for v, w in graph[u]:
            if not visited[v]:
                heappush(min_heap, (w, v))

    return mst_weight

实战例题:水资源分配优化 (#1168)

这题需要巧妙地将打井成本转化为虚拟节点:

def minCostToSupplyWater(n, wells, pipes):
    # 添加虚拟节点0,连接到所有房子
    edges = []

    # 打井相当于从虚拟节点0连边
    for i in range(n):
        edges.append((wells[i], 0, i + 1))

    # 添加管道
    for house1, house2, cost in pipes:
        edges.append((cost, house1, house2))

    # Kruskal算法
    edges.sort()
    parent = list(range(n + 2))

    def find(x):
        if parent[x] != x:
            parent[x] = find(parent[x])
        return parent[x]

    total_cost = 0
    edges_used = 0

    for cost, u, v in edges:
        pu, pv = find(u), find(v)
        if pu != pv:
            parent[pu] = pv
            total_cost += cost
            edges_used += 1
            if edges_used == n:
                break

    return total_cost

关键洞察:通过虚拟节点统一处理两种不同的连接方式。

12.4 并查集:连通性与等价类

核心思想

并查集(Union-Find)是处理动态连通性问题的利器。它支持两个核心操作:

  • Find:查找元素所属的集合
  • Union:合并两个集合

标准实现模板

class UnionFind:
    def __init__(self, n):
        self.parent = list(range(n))
        self.rank = [0] * n
        self.count = n  # 连通分量数

    def find(self, x):
        # 路径压缩
        if self.parent[x] != x:
            self.parent[x] = self.find(self.parent[x])
        return self.parent[x]

    def union(self, x, y):
        px, py = self.find(x), self.find(y)
        if px == py:
            return False

        # 按秩合并
        if self.rank[px] < self.rank[py]:
            self.parent[px] = py
        elif self.rank[px] > self.rank[py]:
            self.parent[py] = px
        else:
            self.parent[py] = px
            self.rank[px] += 1

        self.count -= 1
        return True

    def connected(self, x, y):
        return self.find(x) == self.find(y)

时间复杂度分析

  • 路径压缩 + 按秩合并:每次操作接近 $O(\alpha(n))$,其中 $\alpha$ 是反阿克曼函数
  • 实际应用中可以认为是 $O(1)$

实战例题:冗余连接 (#684)

找到形成环的最后一条边:

def findRedundantConnection(edges):
    n = len(edges)
    parent = list(range(n + 1))

    def find(x):
        if parent[x] != x:
            parent[x] = find(parent[x])
        return parent[x]

    for u, v in edges:
        pu, pv = find(u), find(v)
        if pu == pv:
            # 这条边形成了环
            return [u, v]
        parent[pu] = pv

    return []

关键洞察:树有 $n-1$ 条边,第 $n$ 条边必然形成环。

实战例题:账户合并 (#721)

合并拥有相同邮箱的账户:

def accountsMerge(accounts):
    # 邮箱到索引的映射
    email_to_id = {}
    email_to_name = {}

    # 给每个邮箱分配唯一ID
    for account in accounts:
        name = account[0]
        for email in account[1:]:
            if email not in email_to_id:
                email_to_id[email] = len(email_to_id)
            email_to_name[email] = name

    # 并查集
    uf = UnionFind(len(email_to_id))

    # 合并同一账户的邮箱
    for account in accounts:
        first_email = account[1]
        for email in account[2:]:
            uf.union(email_to_id[first_email], email_to_id[email])

    # 收集结果
    id_to_emails = defaultdict(list)
    for email, email_id in email_to_id.items():
        root_id = uf.find(email_id)
        id_to_emails[root_id].append(email)

    # 格式化输出
    result = []
    for emails in id_to_emails.values():
        name = email_to_name[emails[0]]
        result.append([name] + sorted(emails))

    return result

高级技巧:带权并查集

带权并查集可以维护节点到根的关系(如距离、比例等):

class WeightedUnionFind:
    def __init__(self, n):
        self.parent = list(range(n))
        self.weight = [1.0] * n  # 到父节点的权重

    def find(self, x):
        if self.parent[x] != x:
            root = self.find(self.parent[x])
            # 路径压缩时更新权重
            self.weight[x] *= self.weight[self.parent[x]]
            self.parent[x] = root
        return self.parent[x]

    def union(self, x, y, value):
        # x / y = value
        px, py = self.find(x), self.find(y)
        if px == py:
            return

        # weight[x] * ? = weight[y] * value
        self.parent[px] = py
        self.weight[px] = self.weight[y] * value / self.weight[x]

实战例题:除法求值 (#399)

def calcEquation(equations, values, queries):
    # 变量到索引的映射
    var_to_id = {}

    def get_id(var):
        if var not in var_to_id:
            var_to_id[var] = len(var_to_id)
        return var_to_id[var]

    # 带权并查集
    uf = WeightedUnionFind(len(equations) * 2)

    # 建立等式关系
    for (a, b), value in zip(equations, values):
        id_a, id_b = get_id(a), get_id(b)
        uf.union(id_a, id_b, value)

    # 处理查询
    result = []
    for c, d in queries:
        if c not in var_to_id or d not in var_to_id:
            result.append(-1.0)
            continue

        id_c, id_d = get_id(c), get_id(d)
        if uf.find(id_c) != uf.find(id_d):
            result.append(-1.0)
        else:
            # c / d = weight[c] / weight[d]
            uf.find(id_c)  # 确保权重更新
            uf.find(id_d)
            result.append(uf.weight[id_c] / uf.weight[id_d])

    return result

本章小结

图算法的核心在于选择合适的数据结构和算法框架:

  1. 图的表示: - 稀疏图用邻接表(空间 $O(V+E)$) - 稠密图用邻接矩阵(空间 $O(V^2)$) - 根据需求选择列表或集合存储邻居

  2. 最短路径算法选择: - 非负权重:Dijkstra($O((V+E)\log V)$) - 有负权重:Bellman-Ford($O(VE)$) - 限制路径长度:Bellman-Ford变体 - 所有点对:Floyd-Warshall($O(V^3)$)

  3. 最小生成树算法选择: - 稀疏图:Kruskal + 并查集($O(E\log E)$) - 稠密图:Prim + 优先队列($O(E\log V)$)

  4. 并查集的应用场景: - 动态连通性判断 - 等价类划分 - 环检测 - 带权关系维护

常见陷阱与错误

1. 图的遍历陷阱

错误:忘记标记访问过的节点导致死循环

# 错误示例
def dfs(graph, node):
    for neighbor in graph[node]:
        dfs(graph, neighbor)  # 可能死循环!

# 正确示例
def dfs(graph, node, visited):
    visited.add(node)
    for neighbor in graph[node]:
        if neighbor not in visited:
            dfs(graph, neighbor, visited)

2. Dijkstra算法陷阱

错误:对负权边使用Dijkstra

# Dijkstra不能处理负权边!
# 如果有负权边,必须使用Bellman-Ford

错误:重复处理已确定最短路径的节点

# 错误:没有跳过已处理的节点
while pq:
    d, u = heappop(pq)
    for v, w in graph[u]:
        # 可能重复处理!

# 正确:检查是否已有更短路径
while pq:
    d, u = heappop(pq)
    if d > dist[u]:  # 关键检查
        continue
    for v, w in graph[u]:
        ...

3. 并查集陷阱

错误:忘记路径压缩导致性能退化

# 错误:没有路径压缩
def find(x):
    while parent[x] != x:
        x = parent[x]
    return x

# 正确:带路径压缩
def find(x):
    if parent[x] != x:
        parent[x] = find(parent[x])  # 路径压缩
    return parent[x]

错误:Union操作后继续使用旧的根节点

# 错误示例
root_a = find(a)
root_b = find(b)
union(a, b)
# root_a和root_b可能已经过时!

# 正确:union后重新find
union(a, b)
root = find(a)  # 重新查找

4. 边界条件

错误:空图或单节点图的特殊处理

# 始终检查边界条件
if not edges or n <= 1:
    return special_case_result

错误:节点编号从0还是从1开始

# LeetCode题目可能从0或1开始编号
# 仔细阅读题目,必要时做偏移
parent = list(range(n + 1))  # 1-indexed
parent = list(range(n))      # 0-indexed

调试技巧

  1. 可视化图结构:打印邻接表帮助理解
  2. 跟踪算法过程:记录每步的距离更新或合并操作
  3. 验证不变量:检查MST是否恰好n-1条边
  4. 测试特殊图:完全图、链、星形图、环

记住:图算法的关键是理解问题的图结构本质,然后套用合适的算法模板。大多数图题都是经典算法的直接应用或简单变体。