题目地址:
https://leetcode.com/problems/course-schedule/
给定一个正整数代表需要修的课程数量,再给定修各个课程的先后次序关系(是一个二维数组,每个位置的 ( a , b ) (a,b) (a,b)代表 b b b是 a a a的前置课程)。问是否存在一种上课顺序能上完所有的课。
用图论建模,可以把每个课程想象成图中的顶点,如果课程 v 1 v_1 v1是 v 2 v_2 v2的先修课程,就从 v 1 v_1 v1到 v 2 v_2 v2之间加一条边 v 1 → v 2 v_1\to v_2 v1→v2。这样问题就转化为这个有向图能否被拓扑排序。
法1:BFS。先以邻接表方式建图,然后计算一下每个点的入度,用一个哈希表进行存储。接下来进行BFS,先将入度为 0 0 0的点都入队,然后从队列中取数访问,每访问一个数,就将其出发的边删掉(实现上就是将其指向的点的入度减去 1 1 1),如果某个点的入度减成了 0 0 0,那就将其入队,并从哈希表中将这个点删掉。如此直到队列为空。如果此时仍然有某个点的入度非 0 0 0,说明存在环,返回false。否则返回true。代码如下:
import java.util.*;
public class Solution {
public boolean canFinish(int numCourses, int[][] prerequisites) {
if (prerequisites == null || prerequisites.length == 0 || prerequisites[0].length == 0) {
return true;
}
// 用邻接表建图,key代表点,value代表这个点所指向的顶点;
// 这里建图的时候如果某个点没有邻边,keySet里不会有这个点
Map<Integer, List<Integer>> graph = buildGraph(prerequisites);
// 存储每个点的入度
int[] indegrees = new int[numCourses];
for (int[] prerequisite : prerequisites) {
indegrees[prerequisite[0]]++;
}
Queue<Integer> queue = new LinkedList<>();
// 将indegree里没有的点入队;indegree里没有的点入度是0
for (int i = 0; i < numCourses; i++) {
if (indegrees[i] == 0)) {
queue.offer(i);
}
}
while (!queue.isEmpty()) {
int cur = queue.poll();
// 先要判断一下graph里有没有cur这个点;如果没有的话,说明这个点没有邻居;
// 但这句话仍然要加,否则会出现NPE
if (graph.containsKey(cur)) {
// 接着开始遍历其邻居
for (int next : graph.get(cur)) {
indegrees[next]--;
if (indegrees[next] == 0) {
queue.offer(next);
}
}
}
}
for (int indegree : indegrees) {
// 如果发现还有入度非零的顶点,说明有环,返回false
if (indegree != 0) {
return false;
}
}
// 所有点入度都变成0了,返回true
return true;
}
private Map<Integer, List<Integer>> buildGraph(int[][] prerequisites) {
Map<Integer, List<Integer>> map = new HashMap<>();
for (int[] prerequisite : prerequisites) {
map.putIfAbsent(prerequisite[1], new ArrayList<>());
map.get(prerequisite[1]).add(prerequisite[0]);
}
return map;
}
}
时空复杂度 O ( V + E ) O(V+E) O(V+E)。
法2:DFS。还是以邻接表方式建图,同时用一个int数组visited
记录每个点的访问状态(注意是int数组,不是boolean数组,后面会解释原因并举出反例)。其中,visited[i] = -1
表示未访问过,visited[i] = 0
表示正在访问,visited[i] = 1
表示已经访问。“正在访问”的意思是,某次在调用DFS函数进行深搜,访问到某个节点的时候,本轮DFS之前访问的节点,这类节点是标记为
0
0
0。但本轮DFS结束后访问过的点就全都标记为
1
1
1了。接下来就开始从某个未访问过的顶点(也就是标记为
−
1
-1
−1的顶点)开始DFS。一路访问下去的时候将顶点逐个标记为
0
0
0,并且每次只访问标记为
−
1
-1
−1的顶点(意思是,标记为
1
1
1的也不需要访问了,因为之前某轮DFS访问过,已经check过无环了,所以不需要再来一遍)。如果某一次发现下一个要访问的顶点已经标记为
0
0
0了,说明有环(譬如说某次访问顺序是
v
0
→
v
1
→
.
.
.
→
v
t
→
v
s
v_0\to v_1\to ...\to v_t\to v_s
v0→v1→...→vt→vs,其中
0
≤
s
≤
t
−
1
0\le s\le t-1
0≤s≤t−1,那么环就是
v
s
→
.
.
.
→
v
t
−
1
→
v
t
→
v
s
v_s\to ...\to v_{t-1}\to v_t\to v_s
vs→...→vt−1→vt→vs),否则一直访问到远端,然后回溯,回溯的时候逐个把访问过的点标记为
1
1
1,表示已访问。接下来再从下一个标记为
−
1
-1
−1的顶点开始访问,执行同样的操作,直到整个图被访问完毕。如果发现某次访问找到了环,则直接返回false,否则返回true。代码如下:
import java.util.*;
public class Solution {
public boolean canFinish(int numCourses, int[][] prerequisites) {
if (prerequisites == null || prerequisites.length == 0 || prerequisites[0].length == 0) {
return true;
}
Map<Integer, List<Integer>> graph = buildGraph(prerequisites);
int[] visited = new int[numCourses];
// 先将所有顶点标记为未访问
Arrays.fill(visited, -1);
for (int i = 0; i < numCourses; i++) {
// 每次只从未访问过的顶点开始DFS
if (graph.containsKey(i) && visited[i] == -1) {
if (dfs(i, graph, visited)) {
return false;
}
}
}
return true;
}
// 函数功能是从cur开始做DFS,返回值是本次DFS有没有发现环;有环则返回true,否则返回false
private boolean dfs(int cur, Map<Integer, List<Integer>> graph, int[] visited) {
// 先标记为本轮DFS正在访问
visited[cur] = 0;
if (graph.containsKey(cur)) {
for (int next : graph.get(cur)) {
// 如果下一个要访问的点中有被标记为0的,说明发现了环,直接返回true;
// 否则访问下一个未访问过的顶点,如果发现有环也直接返回true
if (visited[next] == 0) {
return true;
} else if (visited[next] == -1) {
if (dfs(next, graph, visited)) {
return true;
}
}
}
}
// 回溯之前把每个访问过的点都标记为1,下次DFS需要避开这些已经考察过的顶点;
visited[cur] = 1;
// 同时上面一直没返回true,说明本轮DFS未发现环,所以返回false
return false;
}
private Map<Integer, List<Integer>> buildGraph(int[][] prerequisites) {
Map<Integer, List<Integer>> map = new HashMap<>();
for (int[] prerequisite : prerequisites) {
map.putIfAbsent(prerequisite[1], new ArrayList<>());
map.get(prerequisite[1]).add(prerequisite[0]);
}
return map;
}
}
时空复杂度一样。
注解:
之所以要用int数组而不是boolean数组,原因是我们需要对每个点表达三种状态,即
−
1
-1
−1表示从未访问,
0
0
0表示本轮DFS已经访问过(也就是上面说的“正在访问”的意思),
1
1
1表示之前几轮DFS已经访问过。如果只用两个状态表示的话,就会导致在访问某个点的时候,如果它的邻居已经访问过,我们并不能断定有环,因为有可能这个邻居是上几轮DFS访问过标记的,和本轮访问并没有关系。举个例子,如果有这样一个图,一共只有三个点两条边,边为
0
→
2
0\to 2
0→2,
1
→
2
1\to 2
1→2。第一轮DFS将
0
0
0和
2
2
2标记为已访问过,并且未发现环;接下来从
1
1
1开始DFS,发现
2
2
2被访问过了,但此时不能说明有环,因为
2
2
2是上一轮DFS访问的,并不是本轮。而用三个状态就能避免这个问题。
C++:
class Solution {
public:
bool canFinish(int n, vector<vector<int>>& pre) {
int ind[n];
fill(ind, ind + n, 0);
unordered_map<int, vector<int>> g;
for (auto &e : pre) {
int a = e[0], b = e[1];
g[b].push_back(a);
ind[a]++;
}
queue<int> q;
for (int i = 0; i < n; i++)
if (!ind[i]) q.push(i);
int cnt = 0;
while (q.size()) {
int t = q.front(); q.pop();
cnt++;
for (auto &x : g[t]) {
ind[x]--;
if (!ind[x]) q.push(x);
}
}
return cnt == n;
}
};
时空复杂度一样。