拓扑排序(Topological Sort)的核心机制是基于节点“度”的迭代剥除(Degree-driven Pruning)。这一思想不仅能用于有向图依赖排序,还能推广到无向树的中心查找问题。
核心思想
- 有向图判环(Kahn 算法):基于入度(indegree)。寻找入度为 0 的节点(前置依赖消清)并依次剥除。
- 无向树找中心(剥洋葱算法):基于度(degree)。寻找度为 1 的节点(最外层叶子)分层向内剥除。
有向图:Kahn 算法(判环与拓扑排序)
在有向图中,利用拓扑排序检测环:若最终访问的节点数少于总节点数,说明图中存在环。
数据结构
| 概念 | 变量 | 作用 |
|---|---|---|
| 邻接表 | adjList [][]int | 记录节点指向的后继节点 |
| 入度数组 | indegree []int | 记录节点的未完成前置依赖数 |
| 队列 | queue []int | 存放当前入度为 0 的可访问节点 |
算法步骤
- 构建邻接表并统计各节点的入度
indegree。 - 将所有
indegree == 0的节点入队。 - BFS 遍历:弹出队首节点
p,将其后继节点的入度减 1;若后继节点入度降为 0,则入队。 - 统计访问节点数
visitedCnt。若visitedCnt < n,判定有环。
代码实现 (Go)
func canFinish(numCourses int, prerequisites [][]int) bool {
adjList := make([][]int, numCourses)
indegree := make([]int, numCourses)
for _, p := range prerequisites {
src, dest := p[1], p[0]
adjList[src] = append(adjList[src], dest)
indegree[dest]++
}
queue := make([]int, 0)
for i := 0; i < numCourses; i++ {
if indegree[i] == 0 {
queue = append(queue, i)
}
}
visitedCnt := 0
for len(queue) > 0 {
p := queue[0]
queue = queue[1:]
visitedCnt++
for _, c := range adjList[p] {
indegree[c]--
if indegree[c] == 0 {
queue = append(queue, c)
}
}
}
return visitedCnt >= numCourses
}
环的判定原理
在有向环中,每个节点的入度均大于 0 且依赖环内其他节点,入度无法归零,因此无法入队,最终导致 visitedCnt < n。
无向树:剥洋葱算法(找树中心 / 最小高度树)
对于无向树,最小高度树(Minimum Height Trees, MHT)的根节点即为树的中心(最多有 1 或 2 个)。
数据结构
| 概念 | 变量 | 作用 |
|---|---|---|
| 邻接表 | adjList [][]int | 记录节点的相连邻居 |
| 度数组 | degree []int | 记录节点的边数(相连邻居数) |
| 队列 | leaves []int | 存放当前轮次所有的叶子节点(degree == 1) |
原理解析:分支长度与分层收敛
从树的质心点看各个方向的分支长度:
分支长度定义为:$\text{分支长度} = \text{maxDepth}(\text{child}) + 1$。
| 从 a 出发的方向 | 分支长度 | 说明 |
|---|---|---|
| 往 b 方向 | $\text{maxDepth}(b) + 1 = 2$ | b 的最大深度为 1(挂有 e) |
| 往 c 方向 | $\text{maxDepth}(c) + 1 = 1$ | c 是叶子节点(深度 0) |
| 往 d 方向 | $\text{maxDepth}(d) + 1 = 1$ | d 是叶子节点(深度 0) |
以 a 为根的树高度即最长分支:$\max(2, 1, 1) = 2$。
收敛推论:外层叶子节点距离质心的轮次取决于分支深度。必须按轮次**同步剥除(Level-order)**当前所有叶子,才能使收敛界面以相同速度向内缩减,直到剩余节点数 $\le 2$。
算法步骤
- 构建无向邻接表并计算每个节点的度
degree。 - 将所有
degree == 1的叶子节点入队leaves。 - 分层剥离:若剩余节点数
remaining > 2,按层将当前leaves全部出队,更新其邻居的度;邻居度变 1 则放入newLeaves。 - 重复直至
remaining <= 2,剩余节点即为中心。
代码实现 (Go)
func findMinHeightTrees(n int, edges [][]int) []int {
if n == 1 {
return []int{0}
}
adjList := make([][]int, n)
degree := make([]int, n)
for _, edge := range edges {
u, v := edge[0], edge[1]
adjList[u] = append(adjList[u], v)
adjList[v] = append(adjList[v], u)
degree[u]++
degree[v]++
}
leaves := make([]int, 0)
for i := 0; i < n; i++ {
if degree[i] == 1 {
leaves = append(leaves, i)
}
}
remaining := n
for remaining > 2 {
newLeaves := make([]int, 0)
remaining -= len(leaves)
for _, leaf := range leaves {
degree[leaf] = 0
for _, nbr := range adjList[leaf] {
if degree[nbr] > 0 {
degree[nbr]--
if degree[nbr] == 1 {
newLeaves = append(newLeaves, nbr)
}
}
}
}
leaves = newLeaves
}
return leaves
}
BFS 入队机制对比
对比标准 BFS 与条件剥除 BFS 的入队条件:
- 标准 BFS(访问即可入队):
条件为
visited == false。节点到达即入队,无周边状态门槛。 - 条件剥离 BFS(受制于邻居状态):
- 正向 Kahn:节点需满足
indegree == 0(前置依赖消除)。 - 反向 Kahn:节点需满足
degree == 1(末梢分支剪尽)。 节点入队取决于周围依赖或分支是否已完全清理。
- 正向 Kahn:节点需满足
范式对比
| 维度 | Kahn 算法(有向图判环) | 剥洋葱算法(无向树找中心) |
|---|---|---|
| 核心指标 | 入度 (indegree) | 度 (degree) |
| 处理对象 | 有向图 | 无向树 |
| 边界节点 | 入度为 0 的节点 | 度为 1 的叶子节点 |
| 边更新机制 | 单向更新后继入度 | 双向更新邻居的度 |
| 队列处理方式 | 逐个处理即可 | 必须分层处理 (Level-order) |
| 终止条件 | 队列为空 (visitedCnt < n) | 剩余节点数 remaining <= 2 |
参考题目
- 207. 课程表 — 有向图拓扑排序判环
- 310. 最小高度树 — 无向树拓扑剪枝找中心