🔗 拓扑排序 — 有序地解开依赖

任务调度、编译顺序、课程安排——拓扑排序是处理依赖关系的终极武器

📖 拓扑排序的核心概念

拓扑排序(Topological Sort)是对有向无环图(DAG)的顶点的一种线性排序,使得对于每条有向边 u→v,u 在排序中出现在 v 之前。如果图中有环,则不存在拓扑排序。

拓扑排序示例: 课程依赖 0 → 1 → 3 有效拓扑序: [0, 2, 1, 3] ↑ ↑ 0 在 1 前面 ✓ └─ 2 ─┘ 2 在 1 前面 ✓ (2→1) 1 在 3 前面 ✓ 如果存在环: 0 → 1 → 0 → 无拓扑序! 因为 0 必须在 1 前,1 也必须在 0 前 → 矛盾

1. 两种实现方法

Kahn 算法(BFS):不断移除入度为 0 的顶点,是面试最常用的方法。
步骤:(1) 计算所有顶点入度 (2) 入度为 0 的入队 (3) 出队一个顶点,其邻居入度 -1 (4) 邻居入度变 0 则入队 (5) 重复直到队列空
如果出队数 = 顶点数,则无环;否则有环。
DFS 后序反转:DFS 遍历图,节点完成时加入结果,最后反转。
三种状态:未访问(0)、访问中(1)、已完成(2)。遇到"访问中"的节点说明有环。
适合需要检测环的具体位置的场景。

2. Python 拓扑排序模板

from collections import defaultdict, deque

def topological_sort(n, edges):
    """Kahn 算法(BFS)
    n: 顶点数, edges: [(from, to), ...]
    返回: 拓扑序 or None(有环)
    """
    graph = defaultdict(list)
    in_degree = [0] * n
    for u, v in edges:
        graph[u].append(v)
        in_degree[v] += 1
    
    queue = deque([i for i in range(n) if in_degree[i] == 0])
    order = []
    
    while queue:
        node = queue.popleft()
        order.append(node)
        for neighbor in graph[node]:
            in_degree[neighbor] -= 1
            if in_degree[neighbor] == 0:
                queue.append(neighbor)
    
    return order if len(order) == n else None
Kahn 算法天然可以检测环:如果最终 len(order) != n,说明有环。这比 DFS 检测环更简洁。面试推荐用 Kahn 算法!

🎯 题目一:课程表 (LC 207)

题目描述:你在这个学期必须选修 numCourses 门课程,记为 0numCourses-1。给你一个数组 prerequisites,其中 prerequisites[i] = [ai, bi] 表示必须先修 bi 才能修 ai。判断是否可能完成所有课程。

示例: numCourses=2, prerequisites=[[1,0]] 课程0 → 课程1 0 没有前置 → 先修 1 依赖 0 → 后修 可以完成! → True 示例: numCourses=2, prerequisites=[[1,0],[0,1]] 课程0 → 课程1 ↑ | └─────────┘ 循环依赖! 无法完成! → False

思路分析

本质:检测有向图是否有环。无环 → 可以拓扑排序 → 可以完成课程;有环 → 无法完成。
用 Kahn 算法,统计能出队的顶点数,等于课程数则无环。

代码实现

class Solution:
    def canFinish(self, numCourses: int, prerequisites: List[List[int]]) -> bool:
        from collections import defaultdict, deque
        graph = defaultdict(list)
        in_degree = [0] * numCourses
        
        for course, prereq in prerequisites:
            graph[prereq].append(course)
            in_degree[course] += 1
        
        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

复杂度分析

时间复杂度:O(V+E) — 每个顶点和边访问一次

空间复杂度:O(V+E) — 邻接表 + 入度数组 + 队列

🎯 题目二:课程表 II (LC 210)

题目描述:在 LC 207 的基础上,返回一个有效的修课顺序。如果不可能完成,返回空数组。

示例: numCourses=4, prerequisites=[[1,0],[2,0],[3,1],[3,2]] 0 → 1 → 3 0 → 2 → 3 有效顺序: [0,1,2,3] 或 [0,2,1,3] 0必须在1,2前面; 1,2必须在3前面

思路分析

在 Kahn 算法中记录出队顺序即为拓扑序。LC 207 只需要判断能否完成,LC 210 需要具体的顺序。核心代码完全相同,只是多维护一个 order 列表。

代码实现

class Solution:
    def findOrder(self, numCourses: int, prerequisites: List[List[int]]) -> List[int]:
        from collections import defaultdict, deque
        graph = defaultdict(list)
        in_degree = [0] * numCourses
        
        for course, prereq in prerequisites:
            graph[prereq].append(course)
            in_degree[course] += 1
        
        queue = deque([i for i in range(numCourses) if in_degree[i] == 0])
        order = []
        
        while queue:
            node = queue.popleft()
            order.append(node)
            for neighbor in graph[node]:
                in_degree[neighbor] -= 1
                if in_degree[neighbor] == 0:
                    queue.append(neighbor)
        
        return order if len(order) == numCourses else []

复杂度分析

时间复杂度:O(V+E)

空间复杂度:O(V+E)

拓扑序可能不唯一。当多个节点入度同时为 0 时,出队顺序不同导致不同的拓扑序。如果题目要求字典序最小的拓扑序,用优先队列替代普通队列。

🎯 题目三:矩阵中的最长递增路径 (LC 329)

题目描述:给定一个 m x n 整数矩阵,找出最长递增路径的长度。对于每个格子,只能上下左右移动到更小的格子。

示例矩阵: 9 9 4 6 6 8 最长递增路径: 1 → 2 → 6 → 9 2 1 1 长度 = 4 每个格子出发的最长路径 → 记忆化DFS 或: 将所有格子按值排序 → DP

思路分析

方法一:记忆化 DFS:对每个格子做 DFS,求从该格子出发的最长递增路径长度。用 memo 缓存结果,避免重复计算。
递归:对于格子 (r,c),检查四个方向,如果邻居值更大,路径长度 = 1 + dfs(邻居)
时间复杂度:O(m×n),空间复杂度:O(m×n)
方法二:拓扑排序 DP:将矩阵视为图,每个格子是一个节点,从小到大的方向建立边。按拓扑序(从小到大)DP,dp[r][c] = 1 + max(dp[更小的邻居])。
本质与记忆化 DFS 等价,但避免了递归。面试中记忆化 DFS 更简洁。

代码实现(记忆化 DFS)

class Solution:
    def longestIncreasingPath(self, matrix: List[List[int]]) -> int:
        if not matrix:
            return 0
        rows, cols = len(matrix), len(matrix[0])
        memo = {}  # (r, c) → 最长路径长度
        
        def dfs(r, c):
            if (r, c) in memo:
                return memo[(r, c)]
            best = 1  # 至少包含自己
            for dr, dc in [(0, 1), (0, -1), (1, 0), (-1, 0)]:
                nr, nc = r + dr, c + dc
                if 0 <= nr < rows and 0 <= nc < cols and matrix[nr][nc] > matrix[r][c]:
                    best = max(best, 1 + dfs(nr, nc))
            memo[(r, c)] = best
            return best
        
        return max(dfs(r, c) for r in range(rows) for c in range(cols))

复杂度分析

时间复杂度:O(m×n) — 每个格子只计算一次

空间复杂度:O(m×n) — memo + 递归栈

为什么不是 O(m×n×4)?因为 memo 确保每个格子只被完整计算一次。后续访问直接返回缓存值 O(1)。没有 memo 的话会指数爆炸!

变体练习

🔬 拓扑排序的进阶应用

拓扑排序不仅限于课程安排,它在许多场景中都有应用:

1. 检测有向图中的环

拓扑排序天然具有环检测能力。如果排序后顶点数 < 原顶点数,说明有环。这比 DFS 三色标记法更直观。

# 环检测只需判断 len(order) vs n
if len(order) == n:
    print("无环,可拓扑排序")
else:
    print("有环,剩余节点构成环")

2. 关键路径(最长路径)

在 DAG 上求最长路径,可以用拓扑排序 + DP。按照拓扑序依次更新 dp 值,保证依赖在前。

# DAG 上最长路径
for node in topological_order:
    for neighbor in graph[node]:
        dp[neighbor] = max(dp[neighbor], dp[node] + weight[node][neighbor])

3. 字典序最小的拓扑序

当多个节点入度同时为 0 时,用优先队列(最小堆)替代普通队列,可以保证字典序最小。

import heapq
heap = [i for i in range(n) if in_degree[i] == 0]
heapq.heapify(heap)
while heap:
    node = heapq.heappop(heap)  # 每次取最小的
    for nb in graph[node]:
        in_degree[nb] -= 1
        if in_degree[nb] == 0:
            heapq.heappush(heap, nb)
LC 329 矩阵最长递增路径本质上是 DAG 上的最长路径问题。因为只能往更大的格子走,不会形成环,所以是有向无环图。记忆化 DFS 和拓扑排序 DP 是等价的解法。
成就解锁:拓扑大师 — 掌握Kahn算法、DFS拓扑排序、DAG最长路径三大核心技能
LeetCode AC验证:LC 207 Course Schedule ✅ | LC 210 Course Schedule II ✅ | LC 329 Longest Increasing Path ✅

📝 课后练习

  1. LC 802 找到最终的安全状态(反向图+拓扑)
  2. LC 1136 并行课程(拓扑排序求层数)
  3. LC 269 火星词典(异构字典序,拓扑排序)
  4. LC 444 序列重建(验证拓扑序唯一性)
  5. LC 1857 有向图中最大颜色值(拓扑+DP)

🔑 本课要点回顾

📚 扩展阅读

思考题:如果要在拓扑排序中输出所有可能的拓扑序列,该怎么做?提示:使用回溯法,每步选择一个入度为0的节点,选完后更新入度,递归,然后撤销选择恢复入度。