Skip to content

算法模板 - 图论 ​

本文整理图论相关的算法模板,按难度递增排列:拓扑排序 → DFS 序 → 最短路系列 → 最小生成树 → LCA → 二分图 → 高级图论。

1. 拓扑排序 ​

适用场景:有向无环图(DAG)的线性排序,如任务调度、课程依赖。

核心思想:Kahn 算法——不断取入度为 0 的节点加入结果,同时更新邻居的入度。如果最终结果长度 < 节点数,说明有环。

python
from collections import deque

def topological_sort(adj: list) -> list:
    """
    拓扑排序(Kahn 算法)

    参数:
        adj: 邻接表,adj[u] = [v1, v2, ...] 表示 u → v
    返回:
        拓扑序列,如果存在环则返回 []
    """
    n = len(adj)
    in_degree = [0] * n
    for u in range(n):
        for v in adj[u]:
            in_degree[v] += 1

    q = deque([u for u in range(n) if in_degree[u] == 0])
    topo = []

    while q:
        u = q.popleft()
        topo.append(u)
        for v in adj[u]:
            in_degree[v] -= 1
            if in_degree[v] == 0:
                q.append(v)

    return topo if len(topo) == n else []  # 长度不等于 n 说明有环

# 示例:课程依赖
adj = [[1, 2], [3], [3], []]  # 0→1,3  0→2,3
print(topological_sort(adj))  # [0, 1, 2, 3] 或 [0, 2, 1, 3]

为什么能检测环? 如果有环,环上的节点入度永远不为 0,不会被加入队列,最终 topo 长度 < n。


2. DFS 序(判断祖先关系) ​

适用场景:O(1) 判断 u 是否是 v 的祖先。

核心思想:DFS 记录每个节点的进入时间 tin 和离开时间 tout。u 是 v 的祖先当且仅当 tin[u] ≤ tin[v] 且 tout[v] ≤ tout[u]。

python
def build_dfs_order(adj, n):
    """构建 DFS 序,adj[u] = [v1, v2, ...]"""
    tin = [0] * n
    tout = [0] * n
    timer = 0

    def dfs(u, fa):
        nonlocal timer
        timer += 1
        tin[u] = timer
        for v in adj[u]:
            if v != fa:
                dfs(v, u)
        timer += 1
        tout[u] = timer

    dfs(0, -1)
    return tin, tout

def is_ancestor(u, v, tin, tout):
    """判断 u 是否是 v 的祖先"""
    return tin[u] <= tin[v] and tout[v] <= tout[u]

为什么这样判断? DFS 的进入/离开时间形成了一个"括号序列"。如果 u 是 v 的祖先,那么 v 的整个 DFS 区间 [tin[v], tout[v]] 被包含在 u 的区间 [tin[u], tout[u]] 内。


3. Dijkstra(邻接表 + 优先队列) ​

适用场景:非负权图的单源最短路。最常用的最短路算法。

核心思想:贪心。每次从"未确定最短路的节点"中选距离最小的,用它去松弛邻居。为什么贪心正确?因为非负权保证了"当前最小的 dis[u] 不会被后续更新变小"。

python
from heapq import heappush, heappop

def dijkstra(adj: list, source: int, n: int) -> list:
    """
    Dijkstra 最短路算法(邻接表 + 堆优化)

    参数:
        adj: 邻接表,adj[u] = [(v, w), ...]
        source: 源点
        n: 节点数(0-indexed)
    返回:
        dis: dis[i] 表示 source 到 i 的最短距离
    """
    dis = [float('inf')] * n
    dis[source] = 0
    heap = [(0, source)]

    while heap:
        d, u = heappop(heap)
        if d > dis[u]:
            continue  # 已经找到更短的路,跳过(懒删除)
        for v, w in adj[u]:
            if dis[u] + w < dis[v]:
                dis[v] = dis[u] + w
                heappush(heap, (dis[v], v))
    return dis

# 示例
adj = [
    [(1, 1), (2, 3), (3, 1)],
    [(0, 1), (2, 1)],
    [(0, 3), (1, 1), (3, 1)],
    [(0, 1), (2, 1)]
]
print(dijkstra(adj, 0, 4))  # [0, 1, 2, 1]

为什么用堆? 每次暴力找最小值是 O(n),总共 O(n²)。用优先队列优化到 O((n+m) log n)。if d > dis[u] 是"懒删除"——堆中可能有同一个节点的多条记录,只取最新的。


4. Dijkstra(邻接矩阵版) ​

当图是稠密图(边数接近 n²)时,邻接矩阵 + O(n²) 的朴素 Dijkstra 比堆优化版更快(常数更小)。

python
def dijkstra_matrix(weight, source):
    """
    Dijkstra 邻接矩阵版,O(n²)

    参数:
        weight: 邻接矩阵,weight[i][j] 表示 i→j 的边权(无边为 inf)
        source: 源点
    """
    n = len(weight)
    visited = [False] * n
    dis = [float('inf')] * n
    dis[source] = 0

    for _ in range(n):
        u = -1
        for i in range(n):
            if not visited[i] and (u == -1 or dis[i] < dis[u]):
                u = i
        if dis[u] == float('inf'):
            break
        visited[u] = True
        for v in range(n):
            if not visited[v] and dis[u] + weight[u][v] < dis[v]:
                dis[v] = dis[u] + weight[u][v]

    return dis

# 示例
w = [[0, 1, 3, 1], [1, 0, 1, 999], [3, 1, 0, 1], [1, 999, 1, 0]]
print(dijkstra_matrix(w, 0))  # [0, 1, 2, 1]

邻接表 vs 邻接矩阵:

特性邻接表 + 堆邻接矩阵
时间O((n+m) log n)O(n²)
空间O(n+m)O(n²)
适用稀疏图 (m << n²)稠密图 (m ≈ n²)

5. Bellman-Ford ​

适用场景:含负权边的图,可以检测负环。

核心思想:对所有边做 n-1 轮松弛。如果第 n 轮还能松弛,说明存在负环。为什么是 n-1 轮?因为不含负环的图中,最短路最多经过 n-1 条边。

python
def bellman_ford(edges: list, n: int, source: int) -> tuple:
    """
    Bellman-Ford 最短路算法

    参数:
        edges: 边列表 [(u, v, w), ...]
        n: 节点数
        source: 源点
    返回:
        (dis, has_negative_cycle)
    """
    dis = [float('inf')] * n
    dis[source] = 0

    for i in range(n):
        updated = False
        for u, v, w in edges:
            if dis[u] == float('inf'):
                continue
            if dis[v] > dis[u] + w:
                dis[v] = dis[u] + w
                updated = True
        if not updated:
            break  # 没有松弛发生,提前退出
    else:
        return dis, True  # n 轮后仍在更新,有负环

    return dis, False

6. SPFA ​

适用场景:含负权边的图(Bellman-Ford 的队列优化),实际复杂度通常远优于 O(nm)。

核心思想:只有被松弛的节点才需要重新处理,用队列维护待处理的节点。

python
from collections import deque

def spfa(adj: list, n: int, source: int) -> tuple:
    """
    SPFA 最短路算法

    参数:
        adj: 邻接表,adj[u] = [(v, w), ...]
        n: 节点数
        source: 源点
    返回:
        (dis, has_negative_cycle)
    """
    dis = [float('inf')] * n
    in_queue = [False] * n
    cnt = [0] * n  # 入队次数,用于检测负环

    dis[source] = 0
    q = deque([source])
    in_queue[source] = True

    while q:
        u = q.popleft()
        in_queue[u] = False
        for v, w in adj[u]:
            if dis[v] > dis[u] + w:
                dis[v] = dis[u] + w
                cnt[v] = cnt[u] + 1
                if cnt[v] >= n:
                    return dis, True  # 负环
                if not in_queue[v]:
                    q.append(v)
                    in_queue[v] = True

    return dis, False

为什么 SPFA 比 Bellman-Ford 快? Bellman-Ford 每轮处理所有边,很多松弛是无效的。SPFA 只处理"刚被松弛的节点"的邻居,实际运行时每个节点平均只入队几次。

三种最短路算法对比:

算法时间复杂度负权边负环检测适用场景
DijkstraO((n+m) log n)❌❌非负权图(最常用)
Bellman-FordO(nm)✅✅需要检测负环
SPFA期望 O(m),最坏 O(nm)✅✅含负权边的稀疏图

7. 最小生成树 — Kruskal 算法 ​

适用场景:稀疏图。基于边排序 + 并查集。

核心思想:把所有边按权重排序,从小到大依次考虑。如果这条边连接的两个端点还不在同一个连通分量中(用并查集判断),就加入 MST。

python
def kruskal_mst(adj: list, n: int) -> tuple:
    """
    Kruskal 最小生成树算法

    参数:
        adj: 邻接表,adj[u] = [(v, w), ...]
        n: 节点数
    返回:
        (total_weight, mst_edges) 或 (-1, []) 如果图不连通
    """
    edges = []
    for u in range(n):
        for v, w in adj[u]:
            if u < v:
                edges.append((w, u, v))
    edges.sort()

    parent = list(range(n))
    rank = [0] * n

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

    def union(x, y):
        rx, ry = find(x), find(y)
        if rx == ry:
            return False
        if rank[rx] < rank[ry]:
            rx, ry = ry, rx
        parent[ry] = rx
        if rank[rx] == rank[ry]:
            rank[rx] += 1
        return True

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

    if len(mst_edges) != n - 1:
        return -1, []
    return total_weight, mst_edges

# 示例
adj = [[(1, 2), (3, 6)], [(0, 2), (2, 3), (3, 8), (4, 5)],
       [(1, 3), (4, 7)], [(0, 6), (1, 8)], [(1, 5), (2, 7)]]
weight, edges = kruskal_mst(adj, 5)
print(f"MST 权重: {weight}")  # 16

为什么 Kruskal 正确? 这是"切分定理"的直接应用——对于任意切分,横跨切分的最小边一定在 MST 中。按权重排序保证每次选的都是当前最小的横跨边。


8. 最小生成树 — Prim 算法 ​

适用场景:稠密图。基于顶点扩展 + 优先队列。

核心思想:从任意顶点开始,每次选连接"已选集合"和"未选集合"的最短边。

python
import heapq

def prim_mst(adj: list, n: int) -> tuple:
    if n == 0:
        return 0, []
    in_mst = [False] * n
    min_weight = [float('inf')] * n
    parent = [-1] * n
    min_weight[0] = 0
    heap = [(0, 0)]

    total_weight = 0
    mst_edges = []
    count = 0

    while heap:
        w, u = heapq.heappop(heap)
        if in_mst[u]:
            continue
        in_mst[u] = True
        total_weight += w
        count += 1
        if parent[u] != -1:
            mst_edges.append((parent[u], u, w))
        if count == n:
            break
        for v, weight in adj[u]:
            if not in_mst[v] and weight < min_weight[v]:
                min_weight[v] = weight
                parent[v] = u
                heapq.heappush(heap, (weight, v))

    if count != n:
        return -1, []
    return total_weight, mst_edges

Kruskal vs Prim:

特性KruskalPrim
思路按边排序,逐边加入按顶点扩展,逐点加入
数据结构并查集优先队列
时间O(m log m)O((n+m) log n)
适用稀疏图 (m << n²)稠密图 (m ≈ n²)

9. 最近公共祖先(LCA — 二进制提升) ​

适用场景:树上两点的最近公共祖先查询。

核心思想:预处理每个节点的 2^k 级祖先,查询时先将两个节点跳到同一深度,再同时往上跳。

python
from typing import List

class LcaBinaryLifting:
    def __init__(self, edges: List[List[int]]):
        """edges: [(x, y, w), ...],节点编号从 0 开始"""
        n = len(edges) + 1
        m = n.bit_length()
        g = [[] for _ in range(n)]
        for x, y, w in edges:
            g[x].append((y, w))
            g[y].append((x, w))

        depth = [0] * n
        dis = [0] * n
        pa = [[-1] * m for _ in range(n)]

        def dfs(x: int, fa: int) -> None:
            pa[x][0] = fa
            for y, w in g[x]:
                if y != fa:
                    depth[y] = depth[x] + 1
                    dis[y] = dis[x] + w
                    dfs(y, x)

        dfs(0, -1)

        for i in range(m - 1):
            for x in range(n):
                if (p := pa[x][i]) != -1:
                    pa[x][i + 1] = pa[p][i]

        self.depth = depth
        self.dis = dis
        self.pa = pa

    def get_kth_ancestor(self, node: int, k: int) -> int:
        for i in range(k.bit_length()):
            if k >> i & 1:
                node = self.pa[node][i]
        return node

    def get_lca(self, x: int, y: int) -> int:
        if self.depth[x] > self.depth[y]:
            x, y = y, x
        y = self.get_kth_ancestor(y, self.depth[y] - self.depth[x])
        if y == x:
            return x
        for i in range(len(self.pa[x]) - 1, -1, -1):
            if self.pa[x][i] != self.pa[y][i]:
                x, y = self.pa[x][i], self.pa[y][i]
        return self.pa[x][0]

    def get_dis(self, x: int, y: int) -> int:
        return self.dis[x] + self.dis[y] - 2 * self.dis[self.get_lca(x, y)]

二进制提升的本质:把"跳 k 步"拆成二进制——比如跳 13 步 = 跳 8 步 + 跳 4 步 + 跳 1 步,每一步都是 O(1) 的查表。


10. 匈牙利算法(二分图最大匹配) ​

适用场景:求解二分图的最大匹配、最小路径覆盖等问题。

核心思想:对每个左部节点,尝试找增广路径。如果右部节点未匹配,直接匹配;如果已匹配,递归让它的"前任"去找新的。

python
def hungarian(graph: list, n_left: int, n_right: int) -> int:
    """
    匈牙利算法求二分图最大匹配

    参数:
        graph: 左部节点的邻接表,graph[i] = [j1, j2, ...]
        n_left, n_right: 左右部节点数
    """
    match = [-1] * n_right  # match[j] = i 表示右部 j 匹配了左部 i

    def find(x: int, visited: list) -> bool:
        for y in graph[x]:
            if visited[y]:
                continue
            visited[y] = True
            if match[y] == -1 or find(match[y], visited):
                match[y] = x
                return True
        return False

    result = 0
    for i in range(n_left):
        visited = [False] * n_right
        if find(i, visited):
            result += 1
    return result

最小路径覆盖 ​

公式:最小路径数 = 顶点数 - 最大匹配数

python
def min_path_cover(n: int, edges: list) -> int:
    """有向图最小路径覆盖"""
    reach = [[False] * (n + 1) for _ in range(n + 1)]
    for a, b in edges:
        reach[a][b] = True
    for k in range(1, n + 1):
        for i in range(1, n + 1):
            for j in range(1, n + 1):
                reach[i][j] |= reach[i][k] and reach[k][j]

    graph = [[] for _ in range(n + 1)]
    for i in range(1, n + 1):
        for j in range(1, n + 1):
            if i != j and reach[i][j]:
                graph[i].append(j)

    match = [0] * (n + 1)
    def find(x, visited):
        for y in graph[x]:
            if not visited[y]:
                visited[y] = True
                if match[y] == 0 or find(match[y], visited):
                    match[y] = x
                    return True
        return False

    result = 0
    for i in range(1, n + 1):
        visited = [False] * (n + 1)
        if find(i, visited):
            result += 1
    return n - result

11. Floyd 最小环 ​

适用场景:求图中边数 ≥ 3 的最小环。

核心思想:在 Floyd 最外层循环的第 k 轮,在更新最短路之前,用 dis[i][j] + val[i][k] + val[k][j] 更新答案——这保证 i→j 的最短路不经过 k,而 i→k 和 k→j 是直接边。

python
from cmath import inf

def floyd_min_cycle(val, n):
    """
    val: 邻接矩阵,val[i][j] 表示 i→j 的边权(无边为 inf)
    n: 节点数(1-indexed)
    """
    dis = [row[:] for row in val]
    ans = inf

    for k in range(1, n + 1):
        # 先更新最小环(i→j 不经过 k)
        for i in range(1, k):
            for j in range(1, i):
                ans = min(ans, dis[i][j] + val[i][k] + val[k][j])
        # 再正常 Floyd 更新最短路
        for i in range(1, n + 1):
            for j in range(1, n + 1):
                dis[i][j] = min(dis[i][j], dis[i][k] + dis[k][j])

    return ans

12. 2-SAT ​

适用场景:有 n 个布尔变量,给定若干形如 "(x_i = a) OR (x_j = b)" 的约束,判断是否存在满足所有约束的赋值。

核心思想:将每个变量拆成两个节点(True/False),构建蕴含图,用 Tarjan 求 SCC。如果 x 和 ¬x 在同一个 SCC 中,无解。

python
class TwoSAT:
    def __init__(self, n: int):
        self.n = n
        self.N = 2 * n
        self.g = [[] for _ in range(self.N)]

    def lit(self, var: int, is_true: bool = True) -> int:
        return (2 * var) ^ (0 if is_true else 1)

    def neg(self, x: int) -> int:
        return x ^ 1

    def add_or(self, a: int, b: int) -> None:
        """(a OR b) 等价于 (¬a → b) 且 (¬b → a)"""
        self.g[self.neg(a)].append(b)
        self.g[self.neg(b)].append(a)

    def add_clause(self, var1, val1, var2, val2) -> None:
        self.add_or(self.lit(var1, val1), self.lit(var2, val2))

    def add_true(self, var: int) -> None:
        x = self.lit(var, True)
        self.g[self.neg(x)].append(x)

    def solve(self) -> list | None:
        N = self.N
        dfn = [0] * N; low = [0] * N; instk = [False] * N
        stk = []; comp = [-1] * N; time = 1

        def tarjan(u):
            nonlocal time
            dfn[u] = low[u] = time; time += 1
            stk.append(u); instk[u] = True
            for v in self.g[u]:
                if dfn[v] == 0:
                    tarjan(v)
                    low[u] = min(low[u], low[v])
                elif instk[v]:
                    low[u] = min(low[u], dfn[v])
            if dfn[u] == low[u]:
                while True:
                    x = stk.pop(); instk[x] = False; comp[x] = u
                    if x == u: break

        for i in range(N):
            if dfn[i] == 0: tarjan(i)

        assignment = [False] * self.n
        for i in range(self.n):
            if comp[2*i] == comp[2*i ^ 1]:
                return None  # 无解
            assignment[i] = comp[2*i] > comp[2*i ^ 1]
        return assignment

13. 最大团(Bron-Kerbosch 算法) ​

适用场景:找无向图中的所有最大团(完全子图)。

核心思想:维护三个集合 R(当前团)、P(候选)、X(排除)。当 P 和 X 都为空时,R 是一个最大团。用 pivot 优化减少递归分支。

python
def bron_kerbosch(R, P, X, adj, cliques):
    if not P and not X:
        cliques.append(R.copy())
        return
    pivot = next(iter(P | X)) if (P | X) else None
    for v in P - adj.get(pivot, set()):
        bron_kerbosch(R | {v}, P & adj[v], X & adj[v], adj, cliques)
        P.remove(v); X.add(v)

def find_max_cliques(edges):
    n = len(edges)
    adj = {i: set() for i in range(n)}
    for u in range(n):
        for v in edges[u]:
            adj[u].add(v); adj[v].add(u)
    cliques = []
    bron_kerbosch(set(), set(range(n)), set(), adj, cliques)
    if not cliques: return []
    max_size = max(len(c) for c in cliques)
    return [c for c in cliques if len(c) == max_size]

# 示例
edges = [[1, 2, 3], [0, 2], [0, 1, 3], [0, 2]]
print(find_max_cliques(edges))  # [{0, 1, 2}, {0, 2, 3}]

pivot 优化的直觉:如果 pivot 和 P 中的大部分节点都相邻,那么不与 pivot 相邻的节点很少,递归分支就很少。这把 O(3^(n/3)) 的实际运行时间大幅降低。


总结 ​

算法时间复杂度适用场景
拓扑排序O(n+m)DAG 线性排序
DFS 序O(n) 预处理,O(1) 查询判断祖先关系
Dijkstra(堆优化)O((n+m) log n)非负权单源最短路(稀疏图)
Dijkstra(邻接矩阵)O(n²)非负权单源最短路(稠密图)
Bellman-FordO(nm)含负权边,检测负环
SPFA期望 O(m)含负权边的稀疏图
KruskalO(m log m)稀疏图 MST
PrimO((n+m) log n)稠密图 MST
LCA(二进制提升)O(n log n) 预处理,O(log n) 查询树上祖先查询
匈牙利O(nm)二分图最大匹配
Floyd 最小环O(n³)求图中最小环
2-SATO(n+m)布尔可满足性
最大团O(3^(n/3))枚举所有最大团

模板源码:AlgorithmLearning