跳转至

拓扑排序

概述

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

拓扑排序特性
  • 仅适用于 DAG:有环图不存在拓扑排序
  • 结果不唯一:一个 DAG 可能有多个合法的拓扑排序
  • 线性序列:所有顶点排成线性序列
  • 依赖关系:完美刻画先决条件/依赖关系

生活类比

想象大学课程安排:有些课程需要先修其他课程(如"数据结构"需要先修"程序设计")。拓扑排序就是找到一个合理的课程学习顺序,确保每门课的先修课都已学完。

核心概念

DAG 与拓扑排序

graph TB
    subgraph "DAG示例(有拓扑排序)"
        A["课程A<br>程序设计"] --> B["课程B<br>数据结构"]
        A --> C["课程C<br>离散数学"]
        B --> D["课程D<br>算法"]
        C --> D
        B --> E["课程E<br>数据库"]
        D --> E
    end
    
    style A fill:#E3F2FD,stroke:#2196F3
    style E fill:#E8F5E9,stroke:#4CAF50
Text Only
合法的拓扑排序:
┌─────────────────────────────────────────────────────────────┐
│ 排序1: A → B → C → D → E                                     │
│ 排序2: A → C → B → D → E                                     │
│ 排序3: A → B → C → E → D  ✗ 错误!D必须在E之前              │
│ 排序4: C → A → B → D → E                                     │
└─────────────────────────────────────────────────────────────┘

验证: 对于每条边 (u, v),u 在序列中位于 v 之前
  边(A,B): A在B前 ✓    边(A,C): A在C前 ✓
  边(B,D): B在D前 ✓    边(C,D): C在D前 ✓
  边(B,E): B在E前 ✓    边(D,E): D在E前 ✓

有环图无拓扑排序

graph LR
    subgraph "有环图(无拓扑排序)"
        A["A"] --> B["B"]
        B --> C["C"]
        C --> A
    end
    
    style A fill:#FFF3E0,stroke:#FF9800
Text Only
为什么有环图不存在拓扑排序?

假设存在拓扑排序,考虑环 A → B → C → A:
  由边(A,B): A 必须在 B 之前
  由边(B,C): B 必须在 C 之前
  由边(C,A): C 必须在 A 之前
  
  传递性: A 在 B 之前,B 在 C 之前 → A 在 C 之前
  但边(C,A)要求 C 在 A 之前
  
  矛盾!所以不存在拓扑排序。

入度与出度

入度(In-degree)与出度(Out-degree)

入度:指向该顶点的边的数量

出度:从该顶点出发的边的数量

在拓扑排序中,入度为 0 的顶点可以优先输出。

Text Only
示例图的入度计算:
        A ──→ B ──→ D
        │     │     │
        ↓     ↓     ↓
        C ──→ E ←───┘

┌─────────────────────────────────────────────────────────────┐
│ 顶点   │ 入度   │ 入边来源                          │
├─────────────────────────────────────────────────────────────┤
│   A    │   0    │ 无 (起点)                          │
│   B    │   1    │ A                                  │
│   C    │   1    │ A                                  │
│   D    │   1    │ B                                  │
│   E    │   3    │ C, B, D                           │
└─────────────────────────────────────────────────────────────┘

Kahn 算法(BFS)

算法原理

Kahn 算法基于入度,不断选择入度为 0 的顶点输出。

flowchart TB
    subgraph "Kahn 算法流程"
        A["计算所有顶点的入度"] --> B["将入度为0的顶点入队"]
        B --> C["队列非空?"]
        C -->|是| D["取出队首顶点 v"]
        D --> E["输出 v"]
        E --> F["对 v 的所有邻居 w"]
        F --> G["将 w 的入度减1"]
        G --> H["w 的入度变为0?"]
        H -->|是| I["将 w 入队"]
        H -->|否| F
        I --> C
        C -->|否| J["所有顶点都输出?"]
        J -->|是| K["成功,输出拓扑序列"]
        J -->|否| L["失败,图中存在环"]
    end
    
    style K fill:#E8F5E9,stroke:#4CAF50
    style L fill:#FFF3E0,stroke:#FF9800

算法执行过程

Text Only
示例图:
        A ──→ B ──→ D
        │     │
        ↓     ↓
        C     E

初始状态:
┌─────────────────────────────────────────────────────────────┐
│ 入度: A=0, B=1, C=1, D=1, E=1                               │
│ 队列: [A] (入度为0的顶点)                                    │
│ 输出: []                                                     │
└─────────────────────────────────────────────────────────────┘

步骤1: 取出 A
┌─────────────────────────────────────────────────────────────┐
│ 输出: [A]                                                    │
│ A 的邻居: B, C                                               │
│   B 的入度: 1 → 0, 入队                                      │
│   C 的入度: 1 → 0, 入队                                      │
│ 队列: [B, C]                                                 │
└─────────────────────────────────────────────────────────────┘

步骤2: 取出 B
┌─────────────────────────────────────────────────────────────┐
│ 输出: [A, B]                                                 │
│ B 的邻居: D, E                                               │
│   D 的入度: 1 → 0, 入队                                      │
│   E 的入度: 1 → 0, 入队                                      │
│ 队列: [C, D, E]                                              │
└─────────────────────────────────────────────────────────────┘

步骤3: 取出 C
┌─────────────────────────────────────────────────────────────┐
│ 输出: [A, B, C]                                              │
│ C 没有邻居                                                   │
│ 队列: [D, E]                                                 │
└─────────────────────────────────────────────────────────────┘

步骤4: 取出 D
┌─────────────────────────────────────────────────────────────┐
│ 输出: [A, B, C, D]                                           │
│ D 没有邻居                                                   │
│ 队列: [E]                                                    │
└─────────────────────────────────────────────────────────────┘

步骤5: 取出 E
┌─────────────────────────────────────────────────────────────┐
│ 输出: [A, B, C, D, E]                                        │
│ E 没有邻居                                                   │
│ 队列: []                                                     │
└─────────────────────────────────────────────────────────────┘

最终结果: A → B → C → D → E (或 A → B → C → E → D 等多种可能)

实现

C
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

#define MAX_V 100

int* topologicalSortKahn(int graph[MAX_V][MAX_V], int n, int *returnSize) {
    // 计算入度
    int *inDegree = (int*)calloc(n, sizeof(int));
    
    printf("计算入度:\n");
    for (int i = 0; i < n; i++) {
        for (int j = 0; j < n; j++) {
            if (graph[i][j]) {
                inDegree[j]++;
            }
        }
    }
    
    for (int i = 0; i < n; i++) {
        printf("  顶点 %c: 入度 = %d\n", 'A' + i, inDegree[i]);
    }
    printf("\n");
    
    // 初始化队列(入度为0的顶点)
    int *queue = (int*)malloc(sizeof(int) * n);
    int front = 0, rear = 0;
    
    for (int i = 0; i < n; i++) {
        if (inDegree[i] == 0) {
            queue[rear++] = i;
            printf("顶点 %c 入度=0,入队\n", 'A' + i);
        }
    }
    printf("\n");
    
    // 拓扑排序
    int *result = (int*)malloc(sizeof(int) * n);
    int count = 0;
    
    printf("Kahn 算法执行:\n");
    
    while (front < rear) {
        int v = queue[front++];
        result[count++] = v;
        printf("步骤 %d: 输出 %c\n", count, 'A' + v);
        
        // 更新邻居的入度
        for (int i = 0; i < n; i++) {
            if (graph[v][i]) {
                inDegree[i]--;
                printf("  邻居 %c 入度: %d → %d", 'A' + i, inDegree[i] + 1, inDegree[i]);
                
                if (inDegree[i] == 0) {
                    queue[rear++] = i;
                    printf(", 入队");
                }
                printf("\n");
            }
        }
    }
    
    free(inDegree);
    free(queue);
    
    // 检查是否有环
    if (count != n) {
        printf("\n检测到环!只输出了 %d/%d 个顶点\n", count, n);
        free(result);
        *returnSize = 0;
        return NULL;
    }
    
    printf("\n拓扑排序结果: ");
    for (int i = 0; i < n; i++) {
        printf("%c ", 'A' + result[i]);
    }
    printf("\n");
    
    *returnSize = n;
    return result;
}

int main() {
    // 示例图
    int n = 5;
    int graph[MAX_V][MAX_V] = {0};
    
    // 添加边: A→B, A→C, B→D, B→E
    graph[0][1] = 1;  // A → B
    graph[0][2] = 1;  // A → C
    graph[1][3] = 1;  // B → D
    graph[1][4] = 1;  // B → E
    
    printf("图的边:\n");
    printf("  A → B\n  A → C\n  B → D\n  B → E\n\n");
    
    int returnSize;
    int *order = topologicalSortKahn(graph, n, &returnSize);
    
    if (order) {
        free(order);
    }
    
    return 0;
}

DFS 算法

算法原理

DFS 算法基于后序遍历,顶点完成时加入结果,最后逆序输出。

flowchart TB
    subgraph "DFS 拓扑排序"
        A["对每个未访问顶点执行 DFS"] --> B["访问顶点 v"]
        B --> C["递归访问 v 的所有邻居"]
        C --> D["所有邻居访问完成"]
        D --> E["将 v 加入结果"]
        E --> F["最终逆序输出结果"]
    end
    
    style F fill:#E8F5E9,stroke:#4CAF50

算法执行过程

Text Only
示例图:
        A ──→ B ──→ D
        │     │
        ↓     ↓
        C     E

DFS 从 A 开始:
┌─────────────────────────────────────────────────────────────┐
│ DFS(A):                                                      │
│   访问 A                                                      │
│   DFS(B):  (A→B)                                             │
│     访问 B                                                    │
│     DFS(D):  (B→D)                                           │
│       访问 D                                                  │
│       D 无邻居,完成,加入结果: [D]                           │
│     DFS(E):  (B→E)                                           │
│       访问 E                                                  │
│       E 无邻居,完成,加入结果: [D, E]                        │
│     B 完成,加入结果: [D, E, B]                               │
│   DFS(C):  (A→C)                                             │
│     访问 C                                                    │
│     C 无邻居,完成,加入结果: [D, E, B, C]                    │
│   A 完成,加入结果: [D, E, B, C, A]                           │
└─────────────────────────────────────────────────────────────┘

逆序输出: A → B → C → E → D (或 A → B → C → D → E 等)

实现

C
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

#define MAX_V 100

int visited[MAX_V];
int inStack[MAX_V];    // 检测环
int result[MAX_V];
int resultIndex;
int hasCycle;

void dfsTopo(int graph[MAX_V][MAX_V], int v, int n) {
    if (hasCycle) return;
    
    visited[v] = 1;
    inStack[v] = 1;
    
    printf("  进入 %c\n", 'A' + v);
    
    for (int i = 0; i < n; i++) {
        if (graph[v][i]) {
            if (!visited[i]) {
                dfsTopo(graph, i, n);
            } else if (inStack[i]) {
                // 发现回边,存在环
                printf("  检测到环: %c → %c 形成回边\n", 'A' + v, 'A' + i);
                hasCycle = 1;
                return;
            }
        }
    }
    
    inStack[v] = 0;
    result[resultIndex++] = v;
    printf("  完成 %c, 加入结果\n", 'A' + v);
}

int* topologicalSortDFS(int graph[MAX_V][MAX_V], int n, int *returnSize) {
    memset(visited, 0, sizeof(visited));
    memset(inStack, 0, sizeof(inStack));
    resultIndex = 0;
    hasCycle = 0;
    
    printf("DFS 拓扑排序:\n");
    
    for (int i = 0; i < n; i++) {
        if (!visited[i]) {
            printf("DFS 从 %c 开始:\n", 'A' + i);
            dfsTopo(graph, i, n);
        }
    }
    
    if (hasCycle) {
        printf("\n图中存在环,无拓扑排序\n");
        *returnSize = 0;
        return NULL;
    }
    
    // 逆序输出
    int *topoOrder = (int*)malloc(sizeof(int) * n);
    for (int i = 0; i < n; i++) {
        topoOrder[i] = result[n - 1 - i];
    }
    
    printf("\nDFS 后序: ");
    for (int i = 0; i < n; i++) {
        printf("%c ", 'A' + result[i]);
    }
    printf("\n");
    
    printf("拓扑排序: ");
    for (int i = 0; i < n; i++) {
        printf("%c ", 'A' + topoOrder[i]);
    }
    printf("\n");
    
    *returnSize = n;
    return topoOrder;
}

Kahn vs DFS 对比

graph TB
    subgraph "Kahn 算法"
        A1["基于入度"] --> B1["BFS 风格"]
        B1 --> C1["可检测环"]
        C1 --> D1["时间: O(V+E)<br>空间: O(V)"]
    end
    
    subgraph "DFS 算法"
        A2["基于后序遍历"] --> B2["DFS 风格"]
        B2 --> C2["可检测环"]
        C2 --> D2["时间: O(V+E)<br>空间: O(V)"]
    end
    
    style D1 fill:#E3F2FD,stroke:#2196F3
    style D2 fill:#E8F5E9,stroke:#4CAF50
特性 Kahn 算法 DFS 算法
基本思想 入度为 0 优先输出 后序遍历逆序
数据结构 队列 递归栈
环检测 输出数量 < 顶点数 回边检测
适合场景 需要特定顺序时 自然递归问题
实现方式 迭代 递归

应用场景

1. 课程安排

Text Only
问题: 判断能否完成所有课程,并给出学习顺序

示例: numCourses = 4, prerequisites = [[1,0], [2,0], [3,1], [3,2]]

图:
        0 ──→ 1 ──→ 3
        │          ↑
        └──→ 2 ────┘

解读:
  课程 1 需要先修课程 0
  课程 2 需要先修课程 0
  课程 3 需要先修课程 1 和 2

拓扑排序: 0 → 1 → 2 → 3 或 0 → 2 → 1 → 3
学习顺序: 先学 0,再学 1 和 2(顺序可换),最后学 3
C
int canFinish(int numCourses, int prerequisites[][2], int prerequisitesSize) {
    int **graph = (int**)malloc(sizeof(int*) * numCourses);
    int *inDegree = (int*)calloc(numCourses, sizeof(int));
    
    for (int i = 0; i < numCourses; i++) {
        graph[i] = (int*)calloc(numCourses, sizeof(int));
    }
    
    // 建图: prerequisites[i] = [course, prereq]
    for (int i = 0; i < prerequisitesSize; i++) {
        int course = prerequisites[i][0];
        int prereq = prerequisites[i][1];
        graph[prereq][course] = 1;
        inDegree[course]++;
    }
    
    // Kahn 算法
    int *queue = (int*)malloc(sizeof(int) * numCourses);
    int front = 0, rear = 0;
    
    for (int i = 0; i < numCourses; i++) {
        if (inDegree[i] == 0) {
            queue[rear++] = i;
        }
    }
    
    int count = 0;
    while (front < rear) {
        int v = queue[front++];
        count++;
        
        for (int i = 0; i < numCourses; i++) {
            if (graph[v][i]) {
                inDegree[i]--;
                if (inDegree[i] == 0) {
                    queue[rear++] = i;
                }
            }
        }
    }
    
    // 释放内存
    for (int i = 0; i < numCourses; i++) free(graph[i]);
    free(graph);
    free(inDegree);
    free(queue);
    
    return count == numCourses;
}

2. 编译依赖

Text Only
问题: Makefile 中目标的编译顺序

示例依赖关系:
  main.o: main.c utils.h
  utils.o: utils.c utils.h
  app: main.o utils.o

依赖图:
  main.c ──→ main.o ──┐
                      ├──→ app
  utils.c ──→ utils.o ─┘
  utils.h ────┘

编译顺序: main.c, utils.h, utils.c → main.o, utils.o → app

3. 关键路径(AOE 网)

C
void criticalPath(int graph[MAX_V][MAX_V], int n, int weights[MAX_V][MAX_V]) {
    int returnSize;
    int *topoOrder = topologicalSortKahn(graph, n, &returnSize);
    
    if (topoOrder == NULL) {
        printf("图中有环,无法计算关键路径\n");
        return;
    }
    
    // 计算最早发生时间
    int *earliest = (int*)calloc(n, sizeof(int));
    
    for (int i = 0; i < n; i++) {
        int v = topoOrder[i];
        for (int j = 0; j < n; j++) {
            if (graph[j][v]) {
                int finish = earliest[j] + weights[j][v];
                if (finish > earliest[v]) {
                    earliest[v] = finish;
                }
            }
        }
    }
    
    // 计算最晚发生时间
    int *latest = (int*)malloc(sizeof(int) * n);
    for (int i = 0; i < n; i++) {
        latest[i] = earliest[topoOrder[n - 1]];
    }
    
    for (int i = n - 1; i >= 0; i--) {
        int v = topoOrder[i];
        for (int j = 0; j < n; j++) {
            if (graph[v][j]) {
                int start = latest[j] - weights[v][j];
                if (start < latest[v]) {
                    latest[v] = start;
                }
            }
        }
    }
    
    // 输出关键活动
    printf("关键活动(松弛时间为0):\n");
    for (int i = 0; i < n; i++) {
        for (int j = 0; j < n; j++) {
            if (graph[i][j]) {
                int e = earliest[i];
                int l = latest[j] - weights[i][j];
                if (e == l) {
                    printf("  %c → %c (权值=%d)\n", 
                           'A' + i, 'A' + j, weights[i][j]);
                }
            }
        }
    }
    
    free(earliest);
    free(latest);
    free(topoOrder);
}

4. 死锁检测

Text Only
1
2
3
资源分配图转换为等待图,检测是否存在环

如果等待图中存在环 → 可能发生死锁

复杂度分析

操作 时间复杂度 空间复杂度 说明
Kahn 算法 O(V + E) O(V) 遍历所有顶点和边
DFS 算法 O(V + E) O(V) 递归栈深度

常见问题

1. 判断图中是否有环

C
int hasCycle(int graph[MAX_V][MAX_V], int n) {
    int returnSize;
    int *order = topologicalSortKahn(graph, n, &returnSize);
    
    if (order) {
        free(order);
        return 0;  // 无环
    }
    return 1;  // 有环
}

2. 所有拓扑排序

C
// 使用回溯法生成所有拓扑排序
void allTopologicalSorts(int graph[MAX_V][MAX_V], int n, 
                         int *result, int count, 
                         int *inDegree, int *visited) {
    if (count == n) {
        // 输出一个排序
        for (int i = 0; i < n; i++) {
            printf("%c ", 'A' + result[i]);
        }
        printf("\n");
        return;
    }
    
    for (int i = 0; i < n; i++) {
        if (!visited[i] && inDegree[i] == 0) {
            // 选择顶点 i
            result[count] = i;
            visited[i] = 1;
            
            // 更新入度
            int temp[MAX_V];
            for (int j = 0; j < n; j++) {
                temp[j] = inDegree[j];
                if (graph[i][j]) {
                    inDegree[j]--;
                }
            }
            
            // 递归
            allTopologicalSorts(graph, n, result, count + 1, inDegree, visited);
            
            // 回溯
            visited[i] = 0;
            for (int j = 0; j < n; j++) {
                inDegree[j] = temp[j];
            }
        }
    }
}

3. 字典序最小的拓扑排序

C
// 使用最小堆(优先队列)替代普通队列
int* topologicalSortLexicographically(int graph[MAX_V][MAX_V], int n, int *returnSize) {
    int *inDegree = (int*)calloc(n, sizeof(int));
    
    for (int i = 0; i < n; i++) {
        for (int j = 0; j < n; j++) {
            if (graph[i][j]) {
                inDegree[j]++;
            }
        }
    }
    
    // 使用最小堆(这里简化实现)
    int *result = (int*)malloc(sizeof(int) * n);
    int count = 0;
    
    while (count < n) {
        // 找入度为0的最小顶点
        int min = -1;
        for (int i = 0; i < n; i++) {
            if (inDegree[i] == 0) {
                if (min == -1 || i < min) {
                    min = i;
                }
            }
        }
        
        if (min == -1) {
            free(result);
            *returnSize = 0;
            return NULL;
        }
        
        result[count++] = min;
        inDegree[min] = -1;  // 标记已处理
        
        for (int i = 0; i < n; i++) {
            if (graph[min][i]) {
                inDegree[i]--;
            }
        }
    }
    
    *returnSize = n;
    return result;
}

参考资料