1 概述
本文会介绍课程表系列的题目,包括思路以及详细代码。
2 课程表1
2.1 原题

2.2 思路
课程表本质上就是求有向图中有没有环的题目,有环就无解,无环就有解。
而求有没有环的一个经典方法,就是拓扑排序。
拓扑排序是有向无环图的一种线性排序,使得对于图中每一条有向边u->v,节点u的排序都出现在节点v的前面。具体做法:
- 计算每个节点的入度,也就是有多少个节点指向该节点
- 将所有入度为0的节点加入队列
- 遍历该节点的邻居,将其入度减1
- 若邻居入度为0,将其入队
- 重复直到队列为空
- 统计入度数组,如果每个数值都为0,表示没有环,否则有环
拓扑排序动图示例如下:

代码如下:
cpp
class Solution {
public:
bool canFinish(int numCourses, vector<vector<int> > &prerequisites) {
// 节点队列
queue<int> q;
// 入度数组
vector in_degree(numCourses, 0);
// 邻接表
vector graph(numCourses, vector<int>());
for (auto &p: prerequisites) {
// 统计入度
++in_degree[p[0]];
// 构建图,主要这里只需要存储p[1]->p[0]即可,有向图
graph[p[1]].push_back(p[0]);
}
for (int i = 0; i < numCourses; ++i) {
// 判断哪个节点入度为0
if (in_degree[i] == 0) {
// 如果入度为0就入队
q.push(i);
}
}
// 不断遍历队列直到为空
while (!q.empty()) {
// 获取当前队列的大小
const int q_size = static_cast<int>(q.size());
// 遍历队列
for (int i = 0; i < q_size; ++i) {
// 拿到当前节点
const int front = q.front();
// 出队
q.pop();
// 遍历当前节点的邻居
for (int v: graph[front]) {
// 入度减1
--in_degree[v];
// 如果入度为0,入队
if (in_degree[v] == 0) {
q.push(v);
}
}
}
}
// 判断入度为0的个数是否等于节点总数,等于表示没有环,否则有环
return ranges::count(in_degree, 0) == numCourses;
}
}
2.3 三色标记法
另一种判断有没有环的方法,就是三色标记法。
三色标记法的原理是将所有节点都标记三种颜色之一:
- 白色:未访问
- 灰色:正在访问
- 黑色:已访问完成
如果在DFS过程中遇到了一个灰色的节点的,表明节点有环。
具体思路:
- 所有节点初始化成白色
- 对每个白色节点进行
DFS - 进入节点时标记为灰色,退出时标记为黑色
- 如果当前节点的邻居时碰到了灰色节点,表明有环
动图示例:

代码如下:
cpp
class Solution {
public:
bool canFinish(int numCourses, vector<vector<int> > &prerequisites) {
vector graph(numCourses, vector<int>());
for (auto &p: prerequisites) {
// 构建图,主要这里只需要存储p[1]->p[0]即可,有向图
graph[p[1]].push_back(p[0]);
}
// 0表示白色,1表示灰色,2表示黑色
vector color(numCourses, 0);
// 表示是否有环
bool cycle = false;
auto dfs = [&](this auto &&dfs, int node) {
// 如果有环直接返回
if (cycle) {
return;
}
// 当前节点标记灰色
color[node] = 1;
// 遍历邻居
for (const int v: graph[node]) {
// 如果邻居是白色
if (color[v] == 0) {
// 访问
dfs(v);
} else if (color[v] == 1) {
// 如果是灰色,表示有环,返回
cycle = true;
return;
}
}
// 遍历完所有邻居后,当前节点标记黑色
color[node] = 2;
};
// 遍历所有白色节点
for (int i = 0; i < numCourses; ++i) {
if (color[i] == 0) {
dfs(i);
}
}
// 没有环返回true
return !cycle;
}
};
2.4 Java版本
2.4.1 拓扑排序
java
import java.util.*;
public class Solution {
public boolean canFinish(int numCourses, int[][] prerequisites) {
LinkedList<Integer> q = new LinkedList<>();
int[] inDegree = new int[numCourses];
List<List<Integer>> list = new ArrayList<>();
for (int i = 0; i < numCourses; i++) {
list.add(new ArrayList<>());
}
for (int[] p : prerequisites) {
++inDegree[p[0]];
list.get(p[1]).add(p[0]);
}
for (int i = 0; i < numCourses; i++) {
if (inDegree[i] == 0) {
q.addLast(i);
}
}
while (!q.isEmpty()) {
int size = q.size();
for (int i = 0; i < size; i++) {
int front = q.pollFirst();
for (int v : list.get(front)) {
if (--inDegree[v] == 0) {
q.addLast(v);
}
}
}
}
for (int i = 0; i < numCourses; i++) {
if(inDegree[i] != 0) {
return false;
}
}
return true;
}
}
2.4.2 三色标记
java
import java.util.*;
public class Solution {
private final List<List<Integer>> list = new ArrayList<>();
private int[] color;
private boolean cycle;
private void dfs(int node) {
if (cycle) {
return;
}
color[node] = 1;
for (int v : list.get(node)) {
if (color[v] == 0) {
dfs(v);
} else if (color[v] == 1) {
cycle = true;
return;
}
}
color[node] = 2;
}
public boolean canFinish(int numCourses, int[][] prerequisites) {
for (int i = 0; i < numCourses; i++) {
list.add(new ArrayList<>());
}
for (int[] p : prerequisites) {
list.get(p[1]).add(p[0]);
}
color = new int[numCourses];
for (int i = 0; i < numCourses; i++) {
if (color[i] == 0) {
dfs(i);
}
}
return !cycle;
}
}
2.5 Go版本
2.5.1 拓扑排序
go
func canFinish(numCourses int, prerequisites [][]int) bool {
q, inDegree, graph := make([]int, 0), make([]int, numCourses), make([][]int, numCourses)
for i := range graph {
graph[i] = make([]int, 0)
}
for _, p := range prerequisites {
inDegree[p[0]]++
graph[p[1]] = append(graph[p[1]], p[0])
}
for i := 0; i < numCourses; i++ {
if inDegree[i] == 0 {
q = append(q, i)
}
}
for len(q) > 0 {
qLen := len(q)
for i := 0; i < qLen; i++ {
front := q[0]
q = q[1:]
for _, v := range graph[front] {
inDegree[v]--
if inDegree[v] == 0 {
q = append(q, v)
}
}
}
}
for i := 0; i < numCourses; i++ {
if inDegree[i] != 0 {
return false
}
}
return true
}
2.5.2 三色标记
go
func canFinish(numCourses int, prerequisites [][]int) bool {
graph, color, cycle := make([][]int, numCourses), make([]int, numCourses), false
for i := range graph {
graph[i] = make([]int, 0)
}
for _, p := range prerequisites {
graph[p[1]] = append(graph[p[1]], p[0])
}
var dfs = func(int) {}
dfs = func(node int) {
if cycle {
return
}
color[node] = 1
for _, v := range graph[node] {
if color[v] == 0 {
dfs(v)
} else if color[v] == 1 {
cycle = true
return
}
}
color[node] = 2
}
for i := 0; i < numCourses; i++ {
if color[i] == 0 {
dfs(i)
}
}
return !cycle
}
3 课程表2
3.1 原题

3.2 思路
这道题和课程表1本质上是一样的,只是在拓扑排序的时候,将节点存储起来就可以了。
具体来说,就是在入队的时候,存储当前节点。因为入队的节点入度已经是0了,直接存储即可,也符合题目的没有顺序要求。
cpp
class Solution {
public:
vector<int> findOrder(int numCourses, vector<vector<int> > &prerequisites) {
// 存储
vector<int> res;
// 节点队列
queue<int> q;
// 入度数组
vector in_degree(numCourses, 0);
// 邻接表
vector graph(numCourses, vector<int>());
for (auto &p: prerequisites) {
// 统计入度
++in_degree[p[0]];
// 构建图,主要这里只需要存储p[1]->p[0]即可,有向图
graph[p[1]].push_back(p[0]);
}
for (int i = 0; i < numCourses; ++i) {
// 判断哪个节点入度为0
if (in_degree[i] == 0) {
// 存储结果
res.push_back(i);
// 如果入度为0就入队
q.push(i);
}
}
// 不断遍历队列直到为空
while (!q.empty()) {
// 获取当前队列的大小
const int q_size = static_cast<int>(q.size());
// 遍历队列
for (int i = 0; i < q_size; ++i) {
// 拿到当前节点
const int front = q.front();
// 出队
q.pop();
// 遍历当前节点的邻居
for (int v: graph[front]) {
// 入度减1
--in_degree[v];
// 如果入度为0,入队
if (in_degree[v] == 0) {
// 入队的时候存储结果
res.push_back(v);
q.push(v);
}
}
}
}
// 判断入度为0的个数是否等于节点总数,等于表示没有环,否则有环
return ranges::count(in_degree, 0) == numCourses ? res : vector<int>();
}
};
3.3 三色标记法
同样道理也能用三色标记法解决。
具体做法就是在遍历完成节点,将节点染成黑色的时候,加入结果集。
这样的话结果集相当于是逆向加入的,最后返回的时候需要反转一下,其他代码与课程表1一致。
cpp
class Solution {
public:
vector<int> findOrder(int numCourses, vector<vector<int> > &prerequisites) {
// 存储结果
vector<int> res;
vector graph(numCourses, vector<int>());
for (auto &p: prerequisites) {
// 构建图,主要这里只需要存储p[1]->p[0]即可,有向图
graph[p[1]].push_back(p[0]);
}
// 0表示白色,1表示灰色,2表示黑色
vector color(numCourses, 0);
// 表示是否有环
bool cycle = false;
auto dfs = [&](this auto &&dfs, int node) {
// 如果有环直接返回
if (cycle) {
return;
}
// 当前节点标记灰色
color[node] = 1;
// 遍历邻居
for (const int v: graph[node]) {
// 如果邻居是白色
if (color[v] == 0) {
// 访问
dfs(v);
} else if (color[v] == 1) {
// 如果是灰色,表示有环,返回
cycle = true;
return;
}
}
// 遍历完所有邻居后,加入结果集
res.push_back(node);
// 当前节点标记黑色
color[node] = 2;
};
// 遍历所有白色节点
for (int i = 0; i < numCourses; ++i) {
if (color[i] == 0) {
dfs(i);
}
}
// 反转结果集
ranges::reverse(res);
return cycle ? vector<int>() : res;
}
};
3.4 Java版本
3.4.1 拓扑排序
java
import java.util.*;
public class Solution {
public int[] findOrder(int numCourses, int[][] prerequisites) {
List<Integer> res = new ArrayList<>();
LinkedList<Integer> q = new LinkedList<>();
int[] inDegree = new int[numCourses];
List<List<Integer>> list = new ArrayList<>();
for (int i = 0; i < numCourses; i++) {
list.add(new ArrayList<>());
}
for (int[] p : prerequisites) {
++inDegree[p[0]];
list.get(p[1]).add(p[0]);
}
for (int i = 0; i < numCourses; i++) {
if (inDegree[i] == 0) {
res.add(i);
q.addLast(i);
}
}
while (!q.isEmpty()) {
int size = q.size();
for (int i = 0; i < size; i++) {
int front = q.pollFirst();
for (int v : list.get(front)) {
if (--inDegree[v] == 0) {
res.add(v);
q.addLast(v);
}
}
}
}
for (int i = 0; i < numCourses; i++) {
if (inDegree[i] != 0) {
return new int[]{};
}
}
return res.stream().mapToInt(i -> i).toArray();
}
}
3.4.2 三色标记法
java
import java.util.*;
public class Solution {
private final List<List<Integer>> list = new ArrayList<>();
private int[] color;
private boolean cycle;
private final List<Integer> res = new ArrayList<>();
private void dfs(int node) {
if (cycle) {
return;
}
color[node] = 1;
for (int v : list.get(node)) {
if (color[v] == 0) {
dfs(v);
} else if (color[v] == 1) {
cycle = true;
return;
}
}
res.add(node);
color[node] = 2;
}
public int[] findOrder(int numCourses, int[][] prerequisites) {
for (int i = 0; i < numCourses; i++) {
list.add(new ArrayList<>());
}
for (int[] p : prerequisites) {
list.get(p[1]).add(p[0]);
}
color = new int[numCourses];
for (int i = 0; i < numCourses; i++) {
if (color[i] == 0) {
dfs(i);
}
}
Collections.reverse(res);
return cycle ? new int[]{} : res.stream().mapToInt(i -> i).toArray();
}
}
3.5 Go版本
3.5.1 拓扑排序
go
func findOrder(numCourses int, prerequisites [][]int) []int {
q, inDegree, graph, res := make([]int, 0), make([]int, numCourses), make([][]int, numCourses), make([]int, 0)
for i := range graph {
graph[i] = make([]int, 0)
}
for _, p := range prerequisites {
inDegree[p[0]]++
graph[p[1]] = append(graph[p[1]], p[0])
}
for i := 0; i < numCourses; i++ {
if inDegree[i] == 0 {
res, q = append(res, i), append(q, i)
}
}
for len(q) > 0 {
qLen := len(q)
for i := 0; i < qLen; i++ {
front := q[0]
q = q[1:]
for _, v := range graph[front] {
inDegree[v]--
if inDegree[v] == 0 {
res, q = append(res, v), append(q, v)
}
}
}
}
for i := 0; i < numCourses; i++ {
if inDegree[i] != 0 {
return []int{}
}
}
return res
}
3.5.2 三色标记法
go
func findOrder(numCourses int, prerequisites [][]int) []int {
graph, color, cycle, res := make([][]int, numCourses), make([]int, numCourses), false, make([]int, 0)
for i := range graph {
graph[i] = make([]int, 0)
}
for _, p := range prerequisites {
graph[p[1]] = append(graph[p[1]], p[0])
}
var dfs = func(int) {}
dfs = func(node int) {
if cycle {
return
}
color[node] = 1
for _, v := range graph[node] {
if color[v] == 0 {
dfs(v)
} else if color[v] == 1 {
cycle = true
return
}
}
res = append(res, node)
color[node] = 2
}
for i := 0; i < numCourses; i++ {
if color[i] == 0 {
dfs(i)
}
}
if cycle {
return []int{}
}
slices.Reverse(res)
return res
}
4 课程表3
4.1 原题

4.2 思路
课程表3的话就和环没有什么关系了,求的是最多能修多少门课。
首先看看范围,10^4,意味着基本上就是O(n)或者O(n log n)的解法,O(n^2)的做法可以放弃了。
相比起普通的课程,每门课程都带了一个最后截止时间,这个截止时间意味着:
- 如果当前的天数大于这个时间,修不了
- 如果当前天数小于这个时间,但是修了这门课程之后,天数大于截止时间,也修不了
因为每门课程都是等价的,如果想要修尽可能多的课程,那么最后截止时间越早,应该越提前修。
所以,一个简单的思路就是,将课程按照截止时间排序, 贪心修截止时间早的课程。
例子里面的:
bash
[[100, 200], [200, 1300], [1000, 1250], [2000, 3200]]
排序后就变成
bash
[[100, 200],[1000, 1250], [200, 1300] [2000, 3200]]
按顺序修了前3门课,就是结果值3。
但是如果给出的课程如下(已按照截止时间排序):
bash
[[1,5],[6,8],[2,9],[3,10]]
按照上面的思路只能修[1,5]+[6,8]两门课程,而实际上,可以修[1,5]+[2,9]+[3,10]三门课程。
问题就在于,当修完[6,8]之后,没有修[2,9],第三门课程[2,9]是一个更好的选择。
为了处理这种类型的问题,就需要把之前贪心的结果[6,8]去掉,而选择一个更好的选择[2,9]。
这种做法就叫反悔贪心。
本质上来说,就是修完当前课程之后,如果发现时间超过了截止时间,就把之前持续时间最大的一门课程反悔掉,这样就能修当前的课程了。
也就是说,需要一个数据结构支持加入数据,以及删除最大的数据,毫无疑问这个就是优先队列。
所以做法就出来了:
- 按照截止时间排序
- 遍历每门课程
- 修当前课程,并将当前课程的持续时间加入优先队列
- 如果修完之后的时间大于截止时间,反悔,从优先队列删除掉一门持续时间最大的课程,并累减当前时间
- 最后的结果就是优先队列的大小
代码如下:
cpp
class Solution {
public:
int scheduleCourse(vector<vector<int> > &courses) {
// 按照截止时间排序
ranges::sort(courses, [](const auto &a, const auto &b) {
return a[1] < b[1];
});
// 当前时间,尽管题目说明从1开始,取0方便计算
int cur = 0;
// 优先队列,存储持续时间
priority_queue<int> q;
// 遍历
for (auto &c: courses) {
// 修当前课程
cur += c[0];
// 加入持续时间到优先队列中
q.push(c[0]);
// 如果当前时间超出了截止时间
if (cur > c[1]) {
// 反悔,不修之前的持续时间最长的一门课程
cur -= q.top();
// 出队
q.pop();
}
}
// 优先队列的长度就是结果
return static_cast<int>(q.size());
}
};
4.3 Java版本
java
import java.util.*;
public class Solution {
public int scheduleCourse(int[][] courses) {
int cur = 0;
Arrays.sort(courses, Comparator.comparingInt(a -> a[1]));
PriorityQueue<Integer> q = new PriorityQueue<>(Comparator.reverseOrder());
for (int[] c : courses) {
cur += c[0];
q.add(c[0]);
if (cur > c[1]) {
cur -= q.poll();
}
}
return q.size();
}
}
4.4 Go版本
go
type PriorityQueue []int
func (pq PriorityQueue) Len() int {
return len(pq)
}
func (pq PriorityQueue) Less(i, j int) bool {
return pq[i] > pq[j]
}
func (pq PriorityQueue) Swap(i, j int) {
pq[i], pq[j] = pq[j], pq[i]
}
func (pq *PriorityQueue) Push(x interface{}) {
*pq = append(*pq, x.(int))
}
func (pq *PriorityQueue) Pop() interface{} {
old := *pq
n := len(old)
item := old[n-1]
*pq = old[0 : n-1]
return item
}
func scheduleCourse(courses [][]int) int {
slices.SortFunc(courses, func(a, b []int) int {
if a[1] < b[1] {
return -1
}
if a[1] > b[1] {
return 1
}
return 0
})
cur := 0
pq := &PriorityQueue{}
heap.Init(pq)
for _, c := range courses {
cur += c[0]
heap.Push(pq, c[0])
if cur > c[1] {
cur -= heap.Pop(pq).(int)
}
}
return pq.Len()
}
5 课程表4
5.1 原题

5.2 思路
课程表4和之前几个题目有很大不同,求的是先决条件。
由于题目是给出多个查询条件,并且一个课程有多个先决条件,所以需要一种方法存储课程的所有先决条件,然后查询的时候直接取出结果即可。
在本题中,先决条件本质上是一个布尔值,如果使用二维布尔数组存储的话,2是1的先决条件可以简单表示成:
cpp
parent[1][2] = true;
所以,只需要一个二维数组存储一个课程的所有先决条件即可。
但是这里有一个问题就是先决条件之间怎么转移,例如知道课程2的先决条件有[3,5,6,7,8],修完课程2之后可以修课程9,那么转移的时候就要遍历所有课程2的先决条件并添加到课程9上。另一方面,课程9的先决条件也不一定只有课程2,可能还有课程10、课程11等,这意味着计算等时候需要把课程9的所有先决条件的先决条件都加到课程9的先决条件中,每一个都需要遍历,这样复杂度非常高。
因此,需要对复杂度进行压缩,由于只需要一个布尔值就能表示,这就是bitset的天然使用场景。每个课程的所有先决条件可以简单地使用bitset<100>去存储(由于n的范围[2,100]),并且转移的时候可以非常简单地通过按位或运算处理,速度非常快。
cpp
vector parent(n, bitset<100>());
// 遍历各个邻居,front是队头
for (const int v: graph[front]) {
// 添加先决条件,将所有front的先决条件(包括front)添加到v的先决条件中
parent[v] |= parent[front];
}
另一方面先决条件有多个,这里需要使用拓扑排序的方式去处理,先处理完当前课程的先决条件再处理下一个课程的。
代码如下:
cpp
class Solution {
public:
vector<bool> checkIfPrerequisite(int n, vector<vector<int> > &prerequisites,
vector<vector<int> > &queries) {
// 入度
vector in_degree(n, 0);
// 存储先决条件,这里用bitset<>数组,parent[i].test(j)表示i的先决条件包含j
// 将一个课程的所有parent压缩在一个bitset内存储,方便后续计算
vector parent(n, bitset<100>());
// 存储图
vector graph(n, vector<int>());
for (auto &p: prerequisites) {
// 入度+1
++in_degree[p[1]];
// 表示p[1]的先决条件包含p[0]
parent[p[1]].set(p[0]);
// 存储图
graph[p[0]].push_back(p[1]);
}
queue<int> q;
for (int i = 0; i < n; ++i) {
// 入度为0,入队
if (in_degree[i] == 0) {
q.push(i);
}
}
// 一直遍历直到队列为空
while (!q.empty()) {
const int q_size = static_cast<int>(q.size());
for (int i = 0; i < q_size; ++i) {
// 队头
const int front = q.front();
// 出队
q.pop();
// 遍历各个邻居
for (const int v: graph[front]) {
// 添加先决条件,将所有front的先决条件(包括front)添加到v的先决条件中
parent[v] |= parent[front];
// 入度减1
--in_degree[v];
// 入队
if (in_degree[v] == 0) {
q.push(v);
}
}
}
}
vector<bool> res;
res.reserve(queries.size());
// 处理结果,如果query[1].test(query[0])为true,表示是先决条件
for (const auto &query: queries) {
res.push_back(parent[query[1]].test(query[0]));
}
return res;
}
};
5.3 Java版本
java
import java.util.*;
public class Solution {
public List<Boolean> checkIfPrerequisite(int numCourses, int[][] prerequisites, int[][] queries) {
List<Boolean> res = new ArrayList<>();
int[] inDegree = new int[numCourses];
List<BitSet> parent = new ArrayList<>();
List<List<Integer>> graph = new ArrayList<>();
for (int i = 0; i < numCourses; i++) {
parent.add(new BitSet(100));
graph.add(new ArrayList<>());
}
for (int[] p : prerequisites) {
++inDegree[p[1]];
parent.get(p[1]).set(p[0]);
graph.get(p[0]).add(p[1]);
}
LinkedList<Integer> q = new LinkedList<>();
for (int i = 0; i < numCourses; i++) {
if (inDegree[i] == 0) {
q.addLast(i);
}
}
while (!q.isEmpty()) {
int size = q.size();
for (int i = 0; i < size; i++) {
int front = q.pollFirst();
for (int v : graph.get(front)) {
parent.get(v).or(parent.get(front));
--inDegree[v];
if (inDegree[v] == 0) {
q.addLast(v);
}
}
}
}
for (int[] query : queries) {
res.add(parent.get(query[1]).get(query[0]));
}
return res;
}
}
5.4 Go版本
由于Go没有内置的bitset,并且这里的应用场景较为简单,手动实现一个简单的bitset。
由于长度最大只有100,所以实现逻辑是使用两个int64。
这里还有一个注意的点就是,理论上来说需要使用int64(1) << p[0]去代替1 << p[0],由于LC上的int是int64,所以可以使用1<<p[0],而不需要使用int64(1) << p[0]。
go
func checkIfPrerequisite(numCourses int, prerequisites [][]int, queries [][]int) []bool {
inDegree, parent, graph, q, res := make([]int, numCourses), make([][]int64, numCourses),
make([][]int, numCourses), make([]int, 0), make([]bool, len(queries))
for i := range parent {
parent[i] = []int64{0, 0}
}
for _, p := range prerequisites {
inDegree[p[1]]++
graph[p[0]] = append(graph[p[0]], p[1])
if p[0] < 64 {
parent[p[1]][0] |= 1 << p[0]
} else {
parent[p[1]][1] |= 1 << (p[0] - 64)
}
}
for i, v := range inDegree {
if v == 0 {
q = append(q, i)
}
}
for len(q) > 0 {
qLen := len(q)
for i := 0; i < qLen; i++ {
front := q[0]
q = q[1:]
for _, v := range graph[front] {
parent[v][0] |= parent[front][0]
parent[v][1] |= parent[front][1]
inDegree[v]--
if inDegree[v] == 0 {
q = append(q, v)
}
}
}
}
for i, query := range queries {
if query[0] < 64 {
if parent[query[1]][0]&(1<<query[0]) != 0 {
res[i] = true
}
} else {
if parent[query[1]][1]&(1<<(query[0]-64)) != 0 {
res[i] = true
}
}
}
return res
}
6 总结
本文主要介绍了拓扑排序以及三色标记法的实现,在课程表1、2、4都有用到。另外的课程表3是属于反悔贪心,这类题目比较特殊,并不多。
附录放上所有题目的链接。