什么是拓扑排序

引用百度百科的拓扑排序定义:

对一个有向无环图(Directed Acyclic Graph, DAG)GG 进行拓扑排序,是将 GG 中所有顶点排成一个 线性序列 ,使得图中任意一对顶点 uuvv,若边 <u,v>E(G)<u,v> \in E(G),则 uu 在线性序列中出现在 vv 之前。通常,这样的线性序列称为满足拓扑次序(Topological Order)的序列,简称 拓扑序列

拓扑排序可以被理解为对一个有向无环图进行排序的操作。在这个排序中,图中的顶点被排列成一个线性序列,满足以下条件:对于图中的任意一对顶点 uuvv,如果存在一条边 <u,v><u,v>,那么在线性序列中,顶点 uu 出现在顶点 vv 之前

换句话说,拓扑排序可以将有向无环图中的顶点按照它们的依赖关系排序,使得所有的依赖关系都被满足。这对于处理任务的依赖关系非常有用,例如工程项目中的任务调度,编译器中的源代码依赖等。

例如,下图中 1->2->3->4->5 是一个正确的拓扑排序,每个节点都在它所依赖的节点后面。而 1->2->4->3->5 则不满足拓扑排序的要求,4 依赖于 3,却出现在了 3 前面。

拓扑排序基本原理

拓扑排序(Topological Sorting)是一种用于有向无环图(DAG)的排序算法,它将图中的节点按照依赖关系进行排序,使得所有的依赖关系都被满足。

拓扑排序的一些重要知识点:

  1. 有向无环图(DAG):拓扑排序只能应用于没有环的有向图,因为有环的图存在循环依赖,无法进行拓扑排序。

  2. 入度和出度:在拓扑排序中,入度表示指向某个节点的边的数量,出度表示从某个节点发出的边的数量。

  3. 拓扑排序算法:拓扑排序算法可以通过深度优先搜索或广度优先搜索(Kahn 算法)来实现。DFS 通常使用递归或栈来实现,而 BFS 则使用队列来实现

  4. 多个拓扑排序结果:一个有向图可能存在多种不同的拓扑排序结果,这取决于节点的访问顺序。一种常见的方法是使用优先队列或字典序来选择节点的顺序,以得到特定的排序结果。

要解决的问题

对于有向图进行拓扑排序要解决两个问题:

  • 一是要判断待排序的有向图是不是无环;
  • 二是按照依赖关系生成正确的序列。

基本思想

拓扑排序的基本思想是:

  • 入度思想:首先找到图中没有前驱节点的节点(即入度为 0 的节点),将其 顺序 加入结果列表中,并将其从图中删除。然后,继续寻找新的没有前驱节点的节点,并重复以上过程,直到所有的节点都被加入结果列表中或者无法再找到没有前驱节点的节点为止。

  • 出度思想:首先找到图中没有后驱节点的节点(即出度为 0 的节点),将其 逆序 加入结果列表中,并将其从图中删除。然后,继续寻找新的没有后驱节点的节点,并重复以上过程,直到所有的节点都被加入结果列表中或者无法再找到没有后驱节点的节点为止。

BFS/Kaha 拓扑排序算法

拓扑排序的具体步骤:

  1. 初始化一个结果列表(或者称为拓扑序列)和一个队列。
  2. 遍历图中的所有节点(对),统计每个节点的入度(即有多少条边指向该节点)。
  3. 将入度为 0 的节点加入队列中。
  4. 当队列不为空时,执行以下操作:
    • 取出队首节点,并将其加入结果列表中。
    • 遍历该节点的所有邻接节点(即该节点指向的节点):
      • 将邻接节点的入度减 1。
      • 如果邻接节点的入度减为 0,将其加入队列中。
  5. 如果结果列表的长度等于图中的节点数,则说明拓扑排序成功,返回结果列表,表示拓扑序列。
  6. 如果结果列表的长度小于图中的节点数,则说明图中存在环,无法进行拓扑排序。

通过循环找到入度为 0 的节点,并将其加入结果列表,然后更新与该节点相邻的节点的入度,重复此过程,最终得到一个满足拓扑次序的序列,或者判断出图中存在环。

BFS / Kaha 拓扑排序算法是从入度的角度着手的,这种方法可称之为 入度方法

BFS 拓扑排序实现

Leetcode 课程表 II 为例,给出 BFS 拓扑排序算法的实现过程。

题目大意:一共有 n 门课程,课程之间存在依赖关系,比如先修完 A 课程,才能修 B 课程。问这个学生能不能修完所有课程,能的话则返回一种可能的课程学习顺序。

代码实现中的关键变量:

  • indegree[]:初始化每个节点的入度,并随着节点出队更新入度值;
  • edges[][]:记录每个节点影响的其它节点,用于该节点的入度变为 0 时更新“影响的其它节点”的入度值;
  • queue:队列用于入队、出队所有入度为 0 的节点;
  • ans[]:结果队列用于保存出队的节点;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
#define ELEM_TYPE int

// Define the structure for a node in the queue
typedef struct Node {
ELEM_TYPE data;
struct Node* next;
} Node;

// Define the structure for the queue
typedef struct Queue {
int size;
Node* front;
Node* rear;
} Queue;

// Function to create a new node
Node* createNode(ELEM_TYPE data) {
Node* newNode = (Node *)malloc(sizeof(Node));
newNode->data = data;
newNode->next = NULL;
return newNode;
}

// Function to create an empty queue
Queue* createQueue() {
Queue* queue = (Queue *)malloc(sizeof(Queue));
queue->front = NULL;
queue->rear = NULL;
queue->size = 0;
return queue;
}

// Function to check if the queue is empty
bool isEmpty(Queue* queue) {
return queue->size == 0;
}

// Function to enqueue an element into the queue
void enQueue(Queue* queue, ELEM_TYPE data) {
Node* newNode = createNode(data);
if (isEmpty(queue)) {
queue->front = queue->rear = newNode;
} else {
queue->rear->next = newNode;
queue->rear = newNode;
}
queue->size += 1;
}

// Function to dequeue an element from the queue
ELEM_TYPE deQueue(Queue* queue) {
if (isEmpty(queue)) {
return -1;
}
Node* temp = queue->front;
ELEM_TYPE data = temp->data;
queue->front = queue->front->next;
queue->size -= 1;
free(temp);
return data;
}

int* findOrder(int numCourses, int** prerequisites, int prerequisitesSize, int* prerequisitesColSize, int* returnSize) {
int indegree[numCourses]; // 每个节点的入度,入度为 0 表示不受其它节点影响
int **edges = (int **)malloc(numCourses * sizeof(int *)); // 二维数组, 记录每个节点影响的节点们
int edgeNums[numCourses]; // 记录每个节点影响的节点数量, 用于动态扩展空间
int *ans = (int *)malloc(numCourses * sizeof(int));

// 初始化
for (int i = 0; i < numCourses; ++i) {
indegree[i] = 0;
edgeNums[i] = 0;
edges[i] = (int *)malloc(0 * sizeof(int)); // 后续按需扩展
}

// 遍历所有节点对
for (int i = 0; i < prerequisitesSize; ++i) {
int suff = prerequisites[i][0], pre = prerequisites[i][1];
edgeNums[pre]++; // suff 受到了 pre 影响, 故 pre 节点影响的节点数量 +1
indegree[suff]++; // suff 受到了 pre 影响,其入度要 +1
edges[pre] = (int *)realloc(edges[pre], edgeNums[pre] * sizeof(int)); // 扩展内存
edges[pre][edgeNums[pre] - 1] = suff; // 在索引(pre, 最后位置) 记录 suff
}

Queue* queue = createQueue();
// 入度所有不受影响的节点
for(int i = 0; i < numCourses; ++i) {
if (indegree[i] == 0) {
enQueue(queue, i);
}
}
int count = 0;
while (!isEmpty(queue)) {
ELEM_TYPE cur = deQueue(queue);
ans[count++] = cur;

// 遍历出队的节点影响的所有节点, 它们的受影响度将 -1
for (int i = 0; i < edgeNums[cur]; ++i) {
int x = edges[cur][i];
indegree[x]--;
if (indegree[x] == 0) {
enQueue(queue, x);
}
}
}

for (int i = 0; i < numCourses; ++i) {
free(edges[i]);
}
free(edges);

// 判断结果队列长度,确定是否可以完成拓扑排序
if (count == numCourses) {
(*returnSize) = count;
} else {
(*returnSize) = 0;
}
return ans;
}

DFS 拓扑排序算法

DFS 拓扑排序算法的思想和 BFS 拓扑排序算法相似,但在具体步骤上有所不同。

DFS 拓扑排序算法的基本思想是:通过递归遍历图中的节点,从一个起始节点开始,当遍历到一个节点时,先将其所有未访问的邻居节点进行递归遍历,之后将当前节点添加到结果列表中。如果在递归的过程中,发现当遍历到一个节点时,存在已经被访问过的邻居节点,则说明途中存在环,无法进行拓扑排序。

DFS 拓扑排序的具体步骤如下:

  1. 初始化一个空的结果列表和一个空的访问状态列表。
  2. 对于图中的每个节点,如果该节点未被访问,则调用 DFS 函数进行遍历。
  3. 在 DFS 函数中,首先将当前节点标记为正在访问。
  4. 递归遍历当前节点的所有未访问的邻居节点,将每个邻居节点作为新的起始节点进行递归遍历。
  5. 当没有未访问的邻居节点时,将当前节点的访问状态更新为已访问,并逆序添加到结果列表中。
  6. 最终,结果列表中的节点正序顺序即为 DFS 拓扑排序的结果。

需要注意的是:在进行 DFS 拓扑排序的过程中,如果发现某个节点的邻居节点已经被访问过(即已经在结果列表中),那么说明存在环,无法进行拓扑排序。在这种情况下,可以中断排序过程并返回一个表示存在环的标志。

代码实现中的关键变量:

  • visited[]:记录每个节点的访问状态,0 表示未访问,1 表示正在访问,2 表示访问完成;
  • edges[][]:记录每个节点所有邻居节点;
  • ans[]:结果队列;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
void dfs(int node, int* visited, int** edges, int* edgeNums, int* ans, int* cnt, bool* valid) {
visited[node] = 1;

for (int i = 0; i < edgeNums[node]; ++i) {
int neigNode = edges[node][i];
if (visited[neigNode] == 0) {
dfs(neigNode, visited, edges, edgeNums, ans, cnt, valid);
// 这行代码测试发现不是必须的, 为什么不是必须的?加上可以有剪枝(提前终止)的作用
if (!(*valid)) {return;}
} else if (visited[neigNode] == 1) {
*valid = false;
return;
}
}

visited[node] = 2;
ans[--(*cnt)] = node;
}

int* findOrder(int numCourses, int** prerequisites, int prerequisitesSize, int* prerequisitesColSize, int* returnSize) {
int* visited = (int *)malloc(numCourses * sizeof(int)); // 记录每一个节点的访问状态
int** edges = (int **)malloc(numCourses * sizeof(int *)); // 二维数组, 记录每个节点所有邻居节点
int* edgeNums = (int *)malloc(numCourses * sizeof(int)); // 记录每个节点的邻居节点数量
int* ans = (int *)malloc(numCourses * sizeof(int));
int cnt = numCourses;
bool valid = true;

memset(visited, 0, numCourses * sizeof(int));
memset(edgeNums, 0, numCourses * sizeof(int));
for (int i = 0; i < numCourses; ++i) {
edges[i] = (int *)malloc(0 * sizeof(int));
}

// 构建邻接表
for (int i = 0; i < prerequisitesSize; ++i) {
int suff = prerequisites[i][0], pre = prerequisites[i][1]; // edge: pre->suff
edges[pre] = (int *)realloc(edges[pre], (edgeNums[pre] + 1) * sizeof(int));
edges[pre][edgeNums[pre]] = suff;
edgeNums[pre]++;
}

// DFS 遍历
for (int i = 0; i < numCourses && valid; ++i) {
if (!visited[i]) {
dfs(i, visited, edges, edgeNums, ans, &cnt, &valid);
}
}

// 释放内存
for (int i = 0; i < numCourses; ++i) {
free(edges[i]);
}
free(edges);
free(visited);
free(edgeNums);

*returnSize = (cnt > 0 || !valid) ? 0 : numCourses;
return ans;
}

参考链接:https://jingsam.github.io/2020/08/11/topological-sort.html