题目:
你这个学期必须选修 numCourses
门课程,记为 0
到 numCourses - 1
。
在选修某些课程之前需要一些先修课程。 先修课程按数组 prerequisites
给出,其中 prerequisites[i] = [ai, bi]
,表示如果要学习课程 ai
则 必须 先学习课程 bi
。
- 例如,先修课程对
[0, 1]
表示:想要学习课程0
,你需要先完成课程1
。
请你判断是否可能完成所有课程的学习?如果可以,返回 true
;否则,返回 false
。
前言
本题是一道经典的【拓扑排序】问题。给定一个包含n个节点的有向图,我们给出它的节点编号的一种排列,如果满足:
对于图G中的任意一条有向边(u, v),u在排列中都出现在v的前面
那么称该排列是图G的拓扑排序。根据上述的定义,我们可以得出两个结论:
1、如果图G中存在环(即图G不是【有向无环图】,那么图G不存在拓扑排序。这是因为假设图中存在环),那么
在排序中必须出现在
的前面,但
同时也必须出现在
的前面,因此不存在一个满足要求的排列,也就不存在拓扑排序;
2、如果图G是有向无环图,那么它的拓扑排序可能不止一种。举一个最极端的例子,如果图G值包含n个节点却没有任何边,那么任意一种编号的排列都可以作为拓扑排序。
有了上述的节点分析,我们就可以将本题建模成一个求拓扑排序的问题了:
1、我们将每一门课看出一个节点;
2、如果想要学习课程A之前必须完成课程B,那么我们从B到A连接一条有向边。这样以来,在拓扑排序中,B一定出现在A的前面。
求出该图是否存在拓扑排序,就可以判断是否有一种符合要求的课程学习顺序。事实上,由于求出一种拓扑排序方法的最优时间复杂度是O(n+m),其中n和m分别是有向图G的节点数和边数。而判断图G是否存在拓扑排序,至少也要对其进行一次完整的遍历,时间复杂度为O(n+m)。因此不可能存在一种仅判断图是否存在拓扑排序的方法,它的时间复杂度在渐进意义上严格优于O(n+m)。
解法一(深度优先搜索):
我们可以将深度优先搜索的流程与拓扑排序的求解联系起来,用一个栈来存储所有已经搜索完成的节点。对于一个节点u,如果它的所有相邻节点都已经搜索完成,那么在搜索回溯到u的时候,u本身也会变成一个已经搜索完成的节点。这里的【相邻节点】指的是从u出发通过一条有向边可以到达的所有节点。
假设我们当前搜索到了节点u,如果它的所有相邻节点都已经搜索完成,那么这些节点都已经在栈中了,此时我们就可以把u入栈。可以发现,如果我们从栈顶往栈底的顺序看,由于u处于栈顶的位置,那么u出现在所有u的相邻节点的前面。因此对于u这个节点而言,它是满足拓扑排序要求的。这样以来,我们对图进行一遍深度优先搜索。当每个节点进行回溯的时候,我们把该节点放入栈中。最终从栈顶到栈底的序列就是一种拓扑排序。
深度优先搜索算法思路:对于图中的任意一个节点,它在搜索的过程中有三种状态,即:
1、【未搜索】:我们还没有搜索到这个节点;
2、【搜索中】:我们搜索过这个节点,但还没有回溯到该节点,即该节点还没有入栈,还有相邻的节点没有搜索完成;
3、【已完成】:我们搜索过并且回溯过这个节点,即该节点已经入栈,并且所有该节点的相邻节点都出现在栈的更底部的位置,满足拓扑排序的要求。
通过上述的三种状态,我们就可以给出使用深度优先搜索得到拓扑排序的算法流程,在每一轮的搜索开始时,我们任取一个【未搜索】的节点开始进行深度优先搜索。我们将当前搜索的节点u标记为【搜索中】,遍历该节点的每一个相邻节点v:
1、如果v为【未搜索】,那么我们开始搜索v,待搜索完成回溯到u;
2、如果v为【搜索中】,那么我们就找到了图中的一个环,因此是不存在拓扑排序的;
3、如果v为【已完成】,那么说明v已经在栈中了,而u还不在栈中,因此u无论何时入栈都不会影像到(u, v)之间的拓扑关系,以及不用进行任何操作。
4、当u的所有相邻节点都为【已完成】时,我们将u放入栈中,并将其标记为【已完成】。
在整个深度优先搜索的过程结束后,如果我们没有找到图中的环,那么栈中存储这所有的n个节点,从栈顶到栈底的顺序即为一种拓扑排序。由于我们只需要判断是否存在一种拓扑排序,而栈的作用仅仅是存放最终的拓扑排序结果,因此我们可以只记录每个节点的状态,而省去对应的栈。如下为实现代码:
class Solution {
private:
//edges为有向边,visited为遍历的节点,valid表示是否是有向无环图
//edges中第一层元素表示起点为u节点的所有相邻节点vector<int>(第二层元素)
vector<vector<int>> edges;
vector<int> visited;
bool valid = true;
public:
void dfs(int u) {
//搜索中设置为1
visited[u] = 1;
//遍历所有其相邻节点,如果相邻节点均完成遍历则设置为2
//若在遍历相邻节点时,其他相邻节点的visited状态为搜索中,则构成了环,不是有向无环图了
for (int v: edges[u]){
if (visited[v] == 0){
dfs(v);
if (!valid) {
return;
}
}
else if (visited[v] == 1) {
//设置valid为false,即不是有向无环图了
valid = false;
return;
}
}
visited[u] = 2;
}
bool canFinish(int numCourses, vector<vector<int>>& prerequisites) {
//通过对vector进行resize,可以用来增加或减少vector的元素个数
edges.resize(numCourses);
visited.resize(numCourses);
//构建edges有向边,外层元素表示节点信息,内层元素表示外层节点所对应的其所有相邻节点
for (const auto& info: prerequisites) {
edges[info[1]].push_back(info[0]);
}
for (int i = 0; i < numCourses && valid; ++i) {
//深度搜索遍历未访问的节点
if (!visited[i]) {
dfs(i);
}
}
return valid;
}
};
时间复杂度: O(n+m),其中 n 为课程数,m 为先修课程的要求数。这其实就是对图进行深度优先搜索的时间复杂度。空间复杂度: O(n+m)。题目中是以列表形式给出的先修课程关系,为了对图进行深度优先搜索,我们需要存储成邻接表的形式,空间复杂度为 O(n+m)。在深度优先搜索的过程中,我们需要最多 O(n) 的栈空间(递归)进行深度优先搜索,因此总空间复杂度为 O(n+m)。
解法二(广度优先搜索):
深度优先搜索方法是一种「逆向思维」:最先被放入栈中的节点是在拓扑排序中最后面的节点。我们也可以使用正向思维,顺序地生成拓扑排序,这种方法也更加直观。我们考虑拓扑排序中最前面的节点,该节点一定不会有任何入边,也就是它没有任何的先修课程要求。当我们将一个节点加入答案中后,我们就可以移除它的所有出边,代表着它的相邻节点少了一门先修课程的要求。如果某个相邻节点变成了【没有任何入边的节点】,那么就代表着这门课可以开始学习了。按照这样的流程,我们不断地将没有入边的节点加入答案,直到答案中包含所有的节点(得到一种拓扑排序)或者不存在没有入边的节点(图中包含环)。上面的想法类似于广度优先搜索,因此我们可以将广度优先搜索的流程与拓扑排序的求解联系起来。
广度优先搜索算法:我们使一个队列来进行广度优先搜索。初始时,所有入度为0的节点都被放入队列中,它们就是可以作为拓扑排序最前面的节点,并且它们之间的相对顺序是无关紧要。
在广度优先搜索的每一步中,我们取出队首的节点u:
1、我们将u放入答案中;
2、我们移除u的所有出边,也就是将u的所有相邻节点的入度减少1。如果某个相邻节点v的入度变为0,那么我们就将v放入队列中。
在广度优先搜索的过程结束后。如果答案中包含了这n个节点,那么我们就找到了一种拓扑排序,否则说明图中存在环,也就不存在拓扑排序了。由于我们只需要判断是否存在一种拓扑排序,因此我们省去存放答案数组,而是只用一个变量记录被放入答案数组的节点个数。在广度优先搜索结束之后,我们判断该变量的值是否等于课程数,就能知道是否存在一种拓扑排序,如下为实现代码:
class Solution {
private:
vector<vector<int>> edges;
vector<int> indeg;
public:
bool canFinish(int numCourses, vector<vector<int>>& prerequisites) {
//通过对vector进行resize,可以用来增加或减少vector的元素个数
edges.resize(numCourses);
indeg.resize(numCourses);
//构建edges有向边,外层元素表示节点(先修课程)信息,内层元素表示外层节点所对应的其所有相邻节点(需要修的课程)
for (const auto& info: prerequisites) {
edges[info[1]].push_back(info[0]);
//通过indeg记录每个节点入度的数量情况(需要先修多少课程)
++indeg[info[0]];
}
//遍历所有初始节点关系,将初始所有入度为0的节点添加到队列q中
queue<int> q;
for (int i = 0; i < numCourses; ++i) {
if (indeg[i] == 0) {
q.push(i);
}
}
//通过visited记录满足入度为0的所有节点的数量(在广度搜索时不断添加更新)
int visited = 0;
while (!q.empty()) {
++visited;
int u = q.front();
q.pop();
//取出队列中一个入度为0的节点,并剔除其所有入度节点相邻节点的边
for (int v: edges[u]) {
--indeg[v];
if (indeg[v] == 0) {
q.push(v);
}
}
}
return visited == numCourses;
}
};
时间复杂度: O(n+m),其中 n 为课程数,m 为先修课程的要求数。这其实就是对图进行广度优先搜索的时间复杂度。空间复杂度: O(n+m)。题目中是以列表形式给出的先修课程关系,为了对图进行广度优先搜索,我们需要存储成邻接表的形式,空间复杂度为 O(n+m)。在广度优先搜索的过程中,我们需要最多 O(n) 的队列空间(迭代)进行广度优先搜索。因此总空间复杂度为 O(n+m)。
笔者小记:
1、拓扑排序:是一种对有向图的顶点进行排序的方法,特别适用于有向无环图。拓扑排序的主要目标是排列图中的节点,使得每个节点都排在它所有的后继节点之前。拓扑排序在很多实际问题中都有应用,例如:1、任务调度:比如项目管理中的任务依赖关系,某个任务必须在另一个任务完成后才能开始;2、编译顺序:编程语言的编译过程中的模块依赖关系;3、课程安排:例如,课程安排中,某些课程需要先修其他课程。特性:有向图必须是无环图;不唯一:对于一个图,可能有多个有效的拓扑排序。
拓扑排序的两种常见算法:
1. Kahn算法(基于入度的算法):
- 计算图中所有顶点的入度(即有多少条边指向该顶点)。
- 将所有入度为0的顶点加入队列。
- 每次从队列中取出一个入度为0的顶点,加入拓扑排序结果中,并将其所有的邻接顶点的入度减1。如果某个邻接顶点的入度变为0,将其加入队列。
- 这个过程持续进行,直到队列为空。
时间复杂度:O(V + E),其中V是图中的顶点数,E是图中的边数。
2. 深度优先搜索(DFS)法:
- 对每个未被访问过的顶点,进行深度优先搜索。
- 在递归回溯的过程中,将顶点添加到栈中。
- 最终栈中的顶点顺序即为拓扑排序的结果。
时间复杂度:O(V + E),其中V是图中的顶点数,E是图中的边数。
2、拓扑排序实现通过:深度优先搜索结合栈的数据结构实现,广度优先搜索结合队列数据结构实现。