LeetCode 1494. 并行课程II(状态压缩DP)

题目描述

给你一个整数 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门课。我们假设各个课程之间的依赖关系如下

在这里插入图片描述

容易得知,15入度为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];
    }
}
  • 3
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值