题目描述
给你一个整数 n 表示某所大学里课程的数目,编号为 1 到 n ,数组 dependencies 中, dependencies[i] = [xi, yi] 表示一个先修课的关系,也就是课程 xi 必须在课程 yi 之前上。同时你还有一个整数 k 。
在一个学期中,你 最多 可以同时上 k 门课,前提是这些课的先修课在之前的学期里已经上过了。
请你返回上完所有课最少需要多少个学期。题目保证一定存在一种上完所有课的方式。
思路
错误思路一:拓扑排序
直接进行拓扑排序,每一轮从当前入度为0的课中选择,看需要多少个学期能把当前入度为0的课全部上完。
class Solution {
int[] h;
int[] e;
int[] ne;
int idx = 0;
public int minNumberOfSemesters(int n, int[][] relations, int k) {
int size = Math.max(n, relations.length) + 1;
h = new int[size];
e = new int[size];
ne = new int[size];
Arrays.fill(h, -1);
int[] inDegree = new int[n + 1];
for (int[] r : relations) {
int a = r[0], b = r[1];
add(a, b);
inDegree[b]++;
}
int[] q = new int[n];
int hh = 0, tt = -1;
int ans = 0;
for (int i = 1; i <= n; i++) {
if (inDegree[i] == 0) q[++tt] = i;
}
while (tt >= hh) {
size = tt - hh + 1;
ans += size / k;
if (size % k != 0) ans++;
for (int j = 0; j < size; j++) {
int x = q[hh++];
for (int i = h[x]; i != -1; i = ne[i]) {
int u = e[i];
if (--inDegree[u] == 0) q[++tt] = u;
}
}
}
return ans;
}
private void add(int a, int b) {
e[idx] = b;
ne[idx] = h[a];
h[a] = idx++;
}
}
反例:
n = 12,k = 2
dependencies = [[1,2], [1,3], [7,5], [7,6],[4,8],[8,9],[9,10],[10,11],[11,12]]
按照拓扑排序的过程,队列中的元素情况如下
第一轮,队列中有 [1,7,4],全部修完需要2个学期
第二轮,队列中有[2,3,5,6,8],全部修完需要3个学期
第三轮,队列中有[9],需要1学期
第四轮,队列中有[10],需要1学期
第五轮,队列中有[11],需要1学期
第六轮,队列中有[12],需要1学期
共需要9个学期
而实际可以这样:
第一学期,修1,4
第二学期,修7,8
第三学期,修2,9
第四学期,修3,10
第五学期,修5,11
第六学期,修6,12
共需要6个学期
错误思路二:拓扑排序+贪心(按出度)
根据错误思路一,容易知道,我们要让每个学期尽可能上更多的课,怎样能让每学期上更多的课呢?这样想,什么时候能修某一门课?只有这门课的先修课都被修完了。那么为了尽可能地多上课,我们需要尽早地把某些课的先修课修掉,这样就能把后面的课解放出来。怎样能更快的解放全部的课?如果某一门课是很多门课的先修课,则我们先修这门课,就能让更多课的先修条件减1。所以,用贪心的思想,我们每次从入度为0的课中,优先选择出度更大的课,修掉。
class Solution {
int[] h;
int[] e;
int[] ne;
int idx = 0;
public int minNumberOfSemesters(int n, int[][] relations, int k) {
int size = Math.max(n, relations.length) + 1;
h = new int[size];
e = new int[size];
ne = new int[size];
Arrays.fill(h, -1);
int[] inDegree = new int[n + 1];
int[] outDegree = new int[n + 1];
for (int[] r : relations) {
int a = r[0], b = r[1];
add(a, b);
inDegree[b]++;
outDegree[a]++;
}
// 按照出度最大的来排序
PriorityQueue<Node> priorityQueue = new PriorityQueue<>((o1, o2) -> o2.out - o1.out);
int[] q = new int[n];
int hh = 0, tt = -1;
int ans = 0;
for (int i = 1; i <= n; i++) {
if (inDegree[i] == 0) priorityQueue.offer(new Node(inDegree[i], outDegree[i], i));
}
while (!priorityQueue.isEmpty()) {
Node node = priorityQueue.poll();
q[++tt] = node.val;
}
while (tt >= hh) {
size = tt - hh + 1;
ans++;
for (int j = 0; j < k && j < size; j++) {
int x = q[hh++];
for (int i = h[x]; i != -1; i = ne[i]) {
int u = e[i];
if (--inDegree[u] == 0) priorityQueue.offer(new Node(inDegree[u], outDegree[u], u));
}
}
while (!priorityQueue.isEmpty()) {
Node node = priorityQueue.poll();
q[++tt] = node.val;
}
}
return ans;
}
private void add(int a, int b) {
e[idx] = b;
ne[idx] = h[a];
h[a] = idx++;
}
class Node {
int in;
int out;
int val;
public Node(int in, int out, int val) {
this.in = in;
this.out = out;
this.val = val;
}
}
}
还是利用上面那个数据用例。如果按照这种贪心的思路,得出的流程如下:
第一轮,队列中有[1,7,4],出度最大的2个点为[1,7],那么第一学期修掉 [1,7]
第二轮,队列中有[2,3,5,6,4],出度最大的点为4,其余点都为0,那么不妨假设第二学期修掉[4,2]
第三轮,队列中有[3,5,6,8],第三学期修掉[8,3]
第四轮,队列中有[5,6,9],第四学期修掉[9,5]
第五轮,队列中有[6,10],第五学期修掉[6,10]
第六轮,队列中有[11],第六学期修掉[11]
第七轮,队列中有[12],第七学期修掉[12]
总共需要7个学期
可见答案仍然是错误的。
这个用例,似乎告诉我们,如果换用某个点的依赖链的深度来做贪心,好像就可以得出正确答案。比如点1依赖深度为1,点7的深度也为1,点4的深度为5,点8的深度为4,点9的深度为3,…
错误思路三:拓扑排序+贪心(按依赖链深度)
我们试一下
class Solution {
int[] h;
int[] e;
int[] ne;
int[] deep;
int idx = 0;
public int minNumberOfSemesters(int n, int[][] relations, int k) {
int size = Math.max(n, relations.length) + 1;
h = new int[size];
e = new int[size];
ne = new int[size];
Arrays.fill(h, -1);
int[] inDegree = new int[n + 1];
deep = new int[n + 1];
Arrays.fill(deep, -1);
for (int[] r : relations) {
int a = r[0], b = r[1];
add(a, b);
inDegree[b]++;
}
// 按照深度最大的来排序
PriorityQueue<Node> tempQueue = new PriorityQueue<>((o1, o2) -> o2.depth - o1.depth);
PriorityQueue<Node> queue = new PriorityQueue<>((o1, o2) -> o2.depth - o1.depth);
// 计算每个点的最大深度, 用dfs遍历整棵树
for (int i = 1; i <= n; i++) {
dfs(i);
//System.out.printf("i = %d, deep = %d\n", i, deep[i]);
}
int ans = 0;
for (int i = 1; i <= n; i++) {
if (inDegree[i] == 0) tempQueue.offer(new Node(inDegree[i], deep[i], i));
}
while (!tempQueue.isEmpty()) {
Node node = tempQueue.poll();
queue.offer(node);
}
while (!queue.isEmpty()) {
size = queue.size();
ans++;
// System.out.printf("第%d学期: ", ans);
for (int j = 0; j < k && j < size; j++) {
Node node = queue.poll();
int x = node.val;
// System.out.printf("修%d,", x);
for (int i = h[x]; i != -1; i = ne[i]) {
int u = e[i];
if (--inDegree[u] == 0) tempQueue.offer(new Node(inDegree[u], deep[u], u));
}
}
// System.out.println();
while (!tempQueue.isEmpty()) {
Node node = tempQueue.poll();
queue.offer(node);
}
}
return ans;
}
private void dfs(int x) {
if (deep[x] != -1) return ; // 已计算出来
int ans = 1;
for (int i = h[x]; i != -1; i = ne[i]) {
int u = e[i];
dfs(u);
ans = Math.max(ans, deep[u] + 1);
}
deep[x] = ans;
}
private void add(int a, int b) {
e[idx] = b;
ne[idx] = h[a];
h[a] = idx++;
}
class Node {
int in;
int depth;
int val;
public Node(int in, int depth, int val) {
this.in = in;
this.depth = depth;
this.val = val;
}
}
}
我们把System.out.print
注释取消,看一下上面的那个数据样例的输出
i = 1, deep = 2
i = 2, deep = 1
i = 3, deep = 1
i = 4, deep = 6
i = 5, deep = 1
i = 6, deep = 1
i = 7, deep = 2
i = 8, deep = 5
i = 9, deep = 4
i = 10, deep = 3
i = 11, deep = 2
i = 12, deep = 1第1学期: 修4,修1,
第2学期: 修8,修7,
第3学期: 修9,修6,
第4学期: 修10,修3,
第5学期: 修11,修2,
第6学期: 修5,修12,
该样例能正确通过,我们尝试提交一下
可知仍然是错误的,我们将这个用例画出来。
再运行一下,看上面按照深度贪心的方式,输出是什么
i = 1, deep = 2
i = 2, deep = 2
i = 3, deep = 2
i = 4, deep = 2
i = 5, deep = 1
i = 6, deep = 1
i = 7, deep = 1
i = 8, deep = 1
i = 9, deep = 1第1学期: 修1,修2,修3,
第2学期: 修4,修5,
第3学期: 修9,修8,修7,
第4学期: 修6,
而实际上,可以按照下面的方式,只需要3学期
[1,2,4]
[3,5,6]
[7,8,9]
所以,根据任何维度的贪心都是有问题的。
正确思路:状压DP
我们只能在每一轮,枚举所有可能的上课方式,然后最后算出把所有课都上完时,需要的最少学期。
再看一眼这道题的数据范围,1 <= n <= 15
,我们可以用状态压缩DP来做。怎么做呢?
一共是n
门课,编号1-n
,我们将每一门课的状态(是否上过)用一个二进制位来表示。假设n=5
,则一共5门课,我们用5个二进制位来表示,第1个二进制位对应编号为1
的课,第5个二进制位对应编号为5
的课。某一位上为0,表示这门课还没有上,为1,表示这门课上过了。
我们用这个5个二进制位,就能表示出课程的全部状态。比如
11111
表示全部课都已经上过,00001
表示,只有第1
门课上过,其余4门都还没上。
还是以n=5
为例。我们可以用f(xxxxx)
来表示,课程状态达到xxxxx
时,需要的最少学期数。
容易知道,边界条件为f(00000) = 0
,一门课都没上时,需要的最少学期数是0。
接下来我们看如何进行状态转移。
首先,第一学期我们只能上那些没有先修课的课,即,第一学期只能从那些入度为0的课中进行选择。
我们假设k=2
,即一学期最多上2门课。我们假设各个课程之间的依赖关系如下
容易得知,1
和5
入度为0,则我们第一学期最多能修1和5这两门课,即第一学期我们能修的课,用5位二进制位来表示(1表示修,2表示不修),即为10001
。那么我们第一学期的上课方案,一定是这个状态的子集。什么意思呢。就是我们第一学期,最多能修的课是[1,5]
,则我们可以选择修[1,5]
,或[1]
,或[5]
,或[]
,我们需要枚举该学期所有的上课方案,由这些上课方案,就能得到下学期开始时,我们课程完成状态的所有可能。(假设第一学期修[1]
,则下学期开始时,我们的课程完成状态就是00001
)。
这样,我们枚举全部的课程完成状态,即从00000
,枚举到11111
,每次计算:处于当前课程完成状态时,需要的最少学期数。
由于我们每过一学期,上课状态xxxxx
都会变大(1的个数会增多),所以我们总是需要用更小的xxxxx
去更新更大的xxxxx
。枚举每一种课程完成状态,并更新由该课程完成状态,能够转移过去的所有课程完成状态。当把全部课程状态都枚举完了后,我们就能得到最终的答案,即f(11111)
。
具体见下方代码以及注释:(状态压缩DP中,需要用到很多的位运算技巧)
class Solution {
public int minNumberOfSemesters(int n, int[][] relations, int k) {
int N = 1 << n; // 比如n为5, 那么这里N就等于二进制的100000, 则N-1为11111
int[] f = new int[N];
// f[11111] 表示, 将全部的课上了, 需要花的最少学期
// 枚举所有状态, 进行转移
Arrays.fill(f, 16); // 因为最大n=15, 最多15门课, 则最多需要的学期不超过16, 相当于初始化为 +∞
f[0] = 0; // f[00000] 全0状态, 一门课不修, 需要花费0学期
int[] preCondition = new int[n]; // 某门课的先修课状态, 1对应第0个二进制位, ...., n对应n-1
for (int[] r : relations) {
int x = r[0], y = r[1];
preCondition[y - 1] |= 1 << (x - 1);
// 假设x = 2, y = 4, 则 preCondition[3] = 10 (二进制表示)
}
int[] cnt = new int[N]; // 某个状态xxxxx中, 为1的位的数量
for (int i = 0; i < N; i++) {
// 枚举全部的状态, 从00000到11111
// 小技巧, 因为状态是从小到大进行迭代计算的, 每次只关注状态的最后一位是否为1, 前面的用先前的计算结果
cnt[i] = cnt[i >> 1] + (i & 1);
}
// 课程完成的全部可能状态
for (int alreadyTaken = 0; alreadyTaken < N; alreadyTaken++) {
// 看一下这学期能上哪些课
int available = 0; // 能上的课的状态, 为1的位表示能上的课
// 这学期能上的课要满足的条件: 1.还没有被上过 2.该课的先修课都上完了
// 枚举全部的课
for (int i = 0; i < n; i++) {
if ((alreadyTaken & (1 << i)) == 0 && (preCondition[i] & alreadyTaken) == preCondition[i]) {
available |= 1 << i; // 这门课能上
}
}
// 不能用贪心, 不是所有能上的课都要上, 枚举能上的课的全部子集 (就是该学期的所有上课方案)
// 通过 (x - 1) & y 的方式来枚举全部子集
for (int subSet = available; subSet > 0; subSet = (subSet - 1) & available) {
if (cnt[subSet] > k) continue; // 该种上课方案, 数量超过k, 无效
// 更新一下按当前上课方案, 能转移过去的课程完成状态, 能得到的最小学期数
f[alreadyTaken | subSet] = Math.min(f[alreadyTaken | subSet], f[alreadyTaken] + 1);
}
}
return f[N - 1];
}
}