Tuyển sinh 🎓 Mình nhận đào tạo lập trình giải thuật thi đấu: thi vào lớp 10 chuyên tin · thi học sinh giỏi tin học cấp tỉnh/thành · thi vào đội tuyển quốc gia · Olympic tin học sinh viên / ICPC · phỏng vấn thuật toán. Bấm vào đây để xem thông tin khóa học → Xem khóa học
Thi thử miễn phí
🏆 Thi thử HSG Tin học tuần này trên ClueOJ
Mở công khai cho mọi người · đề do thầy Nguyễn Việt Nam Sơn biên soạn
Lịch thi, đề cũ & lời giải →

Parallel Courses II (LeetCode 1494)

Parallel Courses II (LeetCode 1494)
Nguồn: LeetCode 1494 Độ khó: Khó Quy hoạch động Bitmask Đồ thị

Đề bài

1494. Parallel Courses II – Các khóa học song song II (LeetCode, độ khó Hard)

Cho một số nguyên $n$, cho biết có $n$ khóa học được đánh số từ $1$ tới $n$. Cho thêm mảng relations trong đó relations[i] = [prevCoursei, nextCoursei] biểu diễn quan hệ tiên quyết giữa khóa prevCoursei và khóa nextCoursei: khóa prevCoursei phải được học trước khóa nextCoursei. Ngoài ra còn cho số nguyên $k$.

Trong một học kỳ, bạn có thể học tối đa $k$ khóa học, miễn là với mỗi khóa đang học, bạn đã học xong tất cả các khóa tiên quyết của nó ở các học kỳ trước.

Hãy trả về số học kỳ ít nhất cần thiết để học hết tất cả các khóa học. Dữ liệu được sinh sao cho luôn có thể học hết mọi khóa.

Ràng buộc:

  • $1 \le n \le 15$
  • $1 \le k \le n$
  • $0 \le$ relations.length $\le n \cdot (n - 1) / 2$
  • relations[i].length == 2
  • $1 \le$ prevCoursei, nextCoursei $\le n$
  • prevCoursei != nextCoursei
  • Tất cả các cặp [prevCoursei, nextCoursei] đều khác nhau.
  • Đồ thị đã cho là đồ thị có hướng không chu trình (DAG).

Định dạng vào/ra để chạy thử trên máy: trên LeetCode các bạn chỉ cần viết hàm minNumberOfSemesters trong class Solution. Để chạy được như một chương trình bình thường (và dùng được khung chạy tay bên dưới), mình đặt thêm định dạng:

  • Dữ liệu vào: dòng đầu gồm ba số nguyên $n$, $m$, $k$ ($m$ là số quan hệ). Tiếp theo là $m$ dòng, mỗi dòng gồm hai số prevCourse nextCourse.
  • Kết quả: một số nguyên là số học kỳ ít nhất.

1494. Parallel Courses II (LeetCode, Hard)

You are given an integer n, which indicates that there are n courses labeled from 1 to n. You are also given an array relations where relations[i] = [prevCoursei, nextCoursei], representing a prerequisite relationship between course prevCoursei and course nextCoursei: course prevCoursei has to be taken before course nextCoursei. Also, you are given the integer k.

In one semester, you can take at most k courses as long as you have taken all the prerequisites in the previous semester for the courses you are taking.

Return the minimum number of semesters needed to take all courses. The testcases will be generated such that it is possible to take every course.

Constraints:

  • 1 <= n <= 15
  • 1 <= k <= n
  • 0 <= relations.length <= n * (n-1) / 2
  • relations[i].length == 2
  • 1 <= prevCoursei, nextCoursei <= n
  • prevCoursei != nextCoursei
  • All the pairs [prevCoursei, nextCoursei] are unique.
  • The given graph is a directed acyclic graph.

Example 1: Input: n = 4, relations = [[2,1],[3,1],[1,4]], k = 2 – Output: 3.
Explanation: The figure below represents the given graph. In the first semester, you can take courses 2 and 3. In the second semester, you can take course 1. In the third semester, you can take course 4.

Example 2: Input: n = 5, relations = [[2,1],[3,1],[4,1],[1,5]], k = 2 – Output: 4.
Explanation: The figure below represents the given graph. In the first semester, you can only take courses 2 and 3 since you cannot take more than two per semester. In the second semester, you can take course 4. In the third semester, you can take course 1. In the fourth semester, you can take course 5.

Example 3: Input: n = 11, relations = [], k = 2 – Output: 6.

2314
Đồ thị ví dụ 1
23415
Đồ thị ví dụ 2
Ví dụ 1 – LeetCode
Input: n = 4, relations = [[2,1],[3,1],[1,4]], k = 2
Output: 3
Ví dụ 1 – stdin / stdout
4 3 2
2 1
3 1
1 4
---
3
Ví dụ 2 – LeetCode
Input: n = 5, relations = [[2,1],[3,1],[4,1],[1,5]], k = 2
Output: 4
Ví dụ 2 – stdin / stdout
5 4 2
2 1
3 1
4 1
1 5
---
4
Ví dụ 3 – LeetCode
Input: n = 11, relations = [], k = 2
Output: 6
Ví dụ 3 – stdin / stdout
11 0 2
---
6

Giải thích ví dụ của đề:

  • Ví dụ 1: học kỳ 1 học khóa 2 và 3; học kỳ 2 học khóa 1; học kỳ 3 học khóa 4. Tổng cộng 3 học kỳ.
  • Ví dụ 2: học kỳ 1 chỉ học được khóa 2 và 3 vì mỗi kỳ không học quá 2 khóa; học kỳ 2 học khóa 4; học kỳ 3 học khóa 1; học kỳ 4 học khóa 5. Tổng cộng 4 học kỳ.
  • Ví dụ 3: không có ràng buộc nào, 11 khóa, mỗi kỳ học tối đa 2 khóa nên cần $\lceil 11 / 2 \rceil = 6$ học kỳ.

Lời mở đầu

Bài này mình được một bạn học viên hỏi nên sẵn tiện mình viết bài giải chi tiết để nhiều bạn được tham khảo luôn. Hy vọng mang đến được giá trị cho các bạn 😇.

Lưu ý nhỏ: khi nộp trên CLUE OJ các bạn dán toàn bộ code, gồm cả hàm main, vì đề trên CLUE OJ đọc dữ liệu vào và in kết quả đúng theo định dạng ở phần đề bài, giống như khi các bạn chạy thử trên máy hoặc ở trang Chạy code online của blog.

Tóm tắt đề bài: có $n$ khóa học và một danh sách quan hệ: quan hệ $(a, b)$ nghĩa là phải học khóa $a$ trước khóa $b$. Mỗi học kỳ học tối đa $k$ khóa. Tuy nhiên trong một học kỳ, ta không được học 2 khóa mà giữa chúng có quan hệ trước – sau (kể cả gián tiếp), tức là các khóa học cùng kỳ phải hoàn toàn độc lập với nhau, và mọi khóa phải học trước chúng đều đã học xong ở các kỳ trước. Hãy tính số học kỳ tối thiểu để học hết $n$ khóa.

Mình nói thêm về ví dụ 2 cho bạn nào thắc mắc: khóa 1 cần cả 3 khóa 2, 3, 4 học xong trước, mà mỗi kỳ chỉ học được 2 khóa nên 3 khóa này tốn ít nhất 2 kỳ, rồi khóa 1 một kỳ, khóa 5 một kỳ nữa – tổng 4 kỳ. Bài này dễ "dính bẫy" tham lam: không phải lúc nào chọn đại $k$ khóa đang học được cũng tối ưu, vì chọn khóa nào ở kỳ này sẽ ảnh hưởng tới những khóa được "mở khóa" ở kỳ sau. Vì vậy ta phải xét hết các khả năng – và $n \le 15$ chính là gợi ý để dùng bitmask.

Link nộp bài

Bấm vào nút bên dưới để nộp bài và được hệ thống CLUE OJ chấm tự động.

Nộp bài trên CLUE OJ

Lộ trình lời giải, bấm vào từng phần để nhảy tới:

Cách 1Quy hoạch động + bitmask – O(3^N · N)

Để ý là $n \le 15$, nên một tập hợp các khóa học có thể biểu diễn bằng một số nguyên $15$ bit gọi là mask: bit thứ $i$ bằng 1 nghĩa là khóa thứ $i$ (đánh số từ 0 trong code, tức khóa $i + 1$ của đề) có trong tập. Ta mô hình hóa bài toán thành bài toán đồ thị, mỗi đỉnh là một khóa học.

Tập phải học trước – fact[i]
Gọi $fact[i]$ là tập hợp các khóa học BUỘC phải học trước khi học khóa $i$ (cả trực tiếp lẫn gián tiếp). $fact[i]$ là một dãy bit, bit $b = 1$ tức khóa học $b$ phải được học trước khóa học $i$. Trong code, $fact[i]$ còn chứa luôn chính bit $i$ (vì DFS bắt đầu từ $i$ bật bit $i$ lên trước).

Để tính $fact[i]$, mình lưu đồ thị ngược: với quan hệ $(a, b)$ thêm cạnh $b \to a$ (từ khóa sau chỉ về khóa phải học trước). Khi đó DFS từ $i$ theo đồ thị ngược sẽ đi qua đúng các khóa phải học trước $i$, ta bật bit của chúng vào biến mask.

Trạng thái quy hoạch động
Gọi $f[mask]$ là số học kỳ tối thiểu để học được tập các khóa học là $mask$. Bài toán cơ sở: $f[0] = 0$ (chưa học gì thì tốn 0 kỳ).

Với $f[mask]$ hiện tại, ta duyệt nextmask là danh sách các khóa học tiếp theo ta muốn học (là tập con của các khóa chưa học restmask). Lưu ý, nextmask phải thỏa điều kiện: với mỗi khóa $i$ có trong nextmask:

  • Trong nextmask không có khóa nào phải học trước $i$: fact[i] & nextmask chỉ còn đúng 1 bit (chính là bit $i$). Nhờ vậy các khóa trong nextmask hoàn toàn độc lập với nhau.
  • Tập các khóa phải học trước $i$ đã xuất hiện trong mask rồi: fact[i] & (mask | nextmask) == fact[i] (bit $i$ nằm trong nextmask, các bit còn lại phải nằm trong mask).

Nếu thỏa mãn, ta cập nhật:

Công thức chuyển
$f[mask \mid nextmask] = \min\big(f[mask \mid nextmask],\ f[mask] + cost[nextmask]\big)$
với $cost[x] = \left\lceil \dfrac{popcount(x)}{k} \right\rceil$ là số học kỳ tối thiểu cần thiết để học hết các khóa trong tập $x$ (mỗi kỳ học tối đa $k$ khóa).
Đáp án
$f[2^n - 1]$ – tập gồm tất cả các khóa học.
Trả lời câu hỏi của một bạn: mask | nextmask là gì?
Dấu | là phép OR theo bit. Vì mask và nextmask không có bit chung, mask | nextmask chính là hợp của hai tập: các khóa đã học cộng thêm các khóa vừa học. Ví dụ mask = 0110 (đã học khóa 2, 3 của đề) và nextmask = 0001 (học thêm khóa 1) thì mask | nextmask = 0111. Còn dp[mask | nextmask] là số học kỳ ít nhất để học xong tập hợp đó.
Vì sao nextmask được phép có nhiều hơn k khóa?
Các khóa trong nextmask độc lập với nhau và mọi khóa phải học trước chúng đã nằm trong mask, nên chia nextmask thành từng nhóm $k$ khóa và học ở $\lceil |nextmask| / k \rceil$ kỳ liên tiếp theo thứ tự nào cũng được. Ngược lại, mỗi học kỳ của một cách học hợp lệ bất kỳ cũng chính là một nextmask thỏa điều kiện với chi phí 1. Vì vậy quy hoạch động không bỏ sót cũng không tính sai cách học nào. Cho phép nextmask lớn giúp ví dụ 3 (không có quan hệ) nhảy thẳng từ $f[0]$ lên $f[2^{11} - 1] = \lceil 11/2 \rceil = 6$.

Cách duyệt tất cả tập con của restmask là mẹo quen thuộc: for (nextmask = restmask; nextmask > 0; nextmask = (nextmask - 1) & restmask). Phép (nextmask - 1) & restmask cho ra tập con liền trước (theo giá trị) của restmask, nên vòng lặp đi qua đủ mọi tập con khác rỗng.

Minh hoạ quy hoạch động trên tập

Em nhập $n$ (tối đa 6), $k$ và các quan hệ (mỗi quan hệ là 2 số a b: học $a$ trước $b$), rồi bấm Tiếp. Mỗi bước là một lần chuyển hợp lệ từ $f[mask]$ sang $f[mask \mid nextmask]$. Vòng tròn xanh lá là khóa đã học (trong mask), viền xanh dương là khóa đang học được, vàng là các khóa của nextmask:

Ví dụ nhanh:
Bẫy tham lam
Em bấm ví dụ Bẫy tham lam ($n = 6$, $k = 2$, quan hệ $1 \to 2 \to 3$ và $4 \to 5$, khóa 6 tự do). Ở kỳ đầu có 3 khóa học được là 1, 4, 6. Nếu chọn bừa {4, 6} thì chuỗi $1 \to 2 \to 3$ còn nguyên, tốn thêm 3 kỳ nữa (tổng 4 kỳ). Chọn đúng {1, 4} thì chỉ cần 3 kỳ: {1, 4} → {2, 5} → {3, 6}. Quy hoạch động thử hết các khả năng nên không bị bẫy này.

Code dưới đây của mình đã AC với tư tưởng đã trình bày ở trên, anh em có thể tham khảo nhé. Mảng toàn cục adj lưu đồ thị ngược, hàm dfs gom tập phải học trước vào biến toàn cục mask:

#include <bits/stdc++.h>
using namespace std;

int dp[(1 << 15) + 5];
int fact[15];
int cost[(1 << 15) + 5];
vector<int> adj[15];
int mask = 0;
const int inf = 10005;

class Solution {
public:
    void dfs(int u) {
        mask |= (1 << u);
        for (int i = 0; i < (int) adj[u].size(); i++) {
            int v = adj[u][i];
            dfs(v);
        }
    }
    int minNumberOfSemesters(int n, vector<vector<int>>& a, int k) {
        for (int i = 0; i < n; i++) {
            adj[i].clear();
        }
        for (int i = 0; i < (int) a.size(); i++) {
            adj[a[i][1] - 1].push_back(a[i][0] - 1);
        }
        for (int i = 0; i < n; i++) {
            mask = 0;
            dfs(i);
            fact[i] = mask;
        }
        for (int mask = 1; mask < (1 << n); mask++) {
            int cnt = (int) __builtin_popcount(mask);
            cost[mask] = (cnt + k - 1) / k;
        }
        for (int mask = 0; mask < (1 << n); mask++) {
            dp[mask] = inf;
        }
        dp[0] = 0;
        for (int mask = 0; mask < (1 << n); mask++) {
            if (dp[mask] == inf) {
                continue;
            }
            int restmask = (1 << n) - 1 - mask;
            for (int nextmask = restmask; nextmask > 0; nextmask = (nextmask - 1) & restmask) {
                if ((mask & nextmask) > 0) {
                    continue;
                }
                bool check = true;
                for (int i = 0; i < n; i++) {
                    if ((nextmask & (1 << i)) > 0) {
                        int tmpmask = fact[i] & nextmask;
                        if ((int) __builtin_popcount(tmpmask) != 1) {
                            check = false;
                            break;
                        }
                        tmpmask = mask | nextmask;
                        if ((fact[i] & tmpmask) != fact[i]) {
                            check = false;
                            break;
                        }
                    }
                }
                if (check == true) {
                    dp[mask | nextmask] = min(dp[mask | nextmask], dp[mask] + cost[nextmask]);
                }
            }
        }
        return dp[(1 << n) - 1];
    }
};

int main() {
    int n, m, k;
    cin >> n >> m >> k;
    vector<vector<int>> a(m, vector<int>(2));
    for (int i = 0; i < m; i++) {
        cin >> a[i][0] >> a[i][1];
    }
    Solution sol;
    cout << sol.minNumberOfSemesters(n, a, k);
    return 0;
}
import sys
from typing import List

INF = 10005

class Solution:
    def minNumberOfSemesters(self, n: int, a: List[List[int]], k: int) -> int:
        adj = [[] for _ in range(n)]
        for u, v in a:
            adj[v - 1].append(u - 1)

        # fact[i]: môn i và tất cả các môn phải học trước môn i (trực tiếp lẫn gián tiếp)
        fact = [0] * n
        for i in range(n):
            mask = 0
            stack = [i]
            while stack:
                u = stack.pop()
                if mask >> u & 1:
                    continue
                mask |= 1 << u
                stack.extend(adj[u])
            fact[i] = mask

        full = (1 << n) - 1
        cost = [0] * (1 << n)
        for mask in range(1, 1 << n):
            cost[mask] = (bin(mask).count("1") + k - 1) // k

        dp = [INF] * (1 << n)
        dp[0] = 0
        for mask in range(1 << n):
            if dp[mask] == INF:
                continue
            # các môn chưa học mà mọi môn phải học trước nó đều đã nằm trong mask
            avail = 0
            for i in range(n):
                if not (mask >> i & 1) and (fact[i] & ~(1 << i)) & ~mask == 0:
                    avail |= 1 << i
            cur = dp[mask]
            nextmask = avail
            while nextmask > 0:
                t = mask | nextmask
                c = cur + cost[nextmask]
                if c < dp[t]:
                    dp[t] = c
                nextmask = (nextmask - 1) & avail
        return dp[full]

def main():
    data = sys.stdin.read().split()
    n, m, k = int(data[0]), int(data[1]), int(data[2])
    a = []
    for i in range(m):
        a.append([int(data[3 + 2 * i]), int(data[4 + 2 * i])])
    print(Solution().minNumberOfSemesters(n, a, k))

if __name__ == "__main__":
    main()
Bản Python duyệt gọn hơn một chút
Hai điều kiện kiểm tra trong vòng for i của bản C++ gộp lại đúng bằng: mọi khóa trong nextmask đều chưa học và có tất cả khóa phải học trước nằm trong mask (khi đó các khóa trong nextmask tự động độc lập với nhau). Nên ở bản Python mình tính trước tập avail gồm các khóa như vậy rồi chỉ duyệt tập con của avail: vẫn đúng các phép chuyển như bản C++, chỉ là không phải duyệt rồi loại bớt nữa (Python chạy chậm nên tiết kiệm được chút nào hay chút đó).

Khung chạy tay bên dưới chạy đúng từng dòng của code C++ ở trên (cả hàm main, hàm dfs lẫn hàm minNumberOfSemesters). Mảng dp vẽ theo chỉ số mask (ô vàng là dp[mask] đang xét, ô viền vàng là ô vừa được cập nhật):

C++▶ Chạy tay từng dòng codeQHĐ bitmask: dp[mask] = số kỳ ít nhất để học xong tập mask, duyệt tập con nextmask, O(3^N · N).
Dữ liệu mẫu:
1#include <bits/stdc++.h>
2using namespace std;
3
4int dp[(1 << 15) + 5];
5int fact[15];
6int cost[(1 << 15) + 5];
7vector<int> adj[15];
8int mask = 0;
9const int inf = 10005;
10
11class Solution {
12public:
13 void dfs(int u) {
14 mask |= (1 << u);
15 for (int i = 0; i < (int) adj[u].size(); i++) {
16 int v = adj[u][i];
17 dfs(v);
18 }
19 }
20 int minNumberOfSemesters(int n, vector<vector<int>>& a, int k) {
21 for (int i = 0; i < n; i++) {
22 adj[i].clear();
23 }
24 for (int i = 0; i < (int) a.size(); i++) {
25 adj[a[i][1] - 1].push_back(a[i][0] - 1);
26 }
27 for (int i = 0; i < n; i++) {
28 mask = 0;
29 dfs(i);
30 fact[i] = mask;
31 }
32 for (int mask = 1; mask < (1 << n); mask++) {
33 int cnt = (int) __builtin_popcount(mask);
34 cost[mask] = (cnt + k - 1) / k;
35 }
36 for (int mask = 0; mask < (1 << n); mask++) {
37 dp[mask] = inf;
38 }
39 dp[0] = 0;
40 for (int mask = 0; mask < (1 << n); mask++) {
41 if (dp[mask] == inf) {
42 continue;
43 }
44 int restmask = (1 << n) - 1 - mask;
45 for (int nextmask = restmask; nextmask > 0; nextmask = (nextmask - 1) & restmask) {
46 if ((mask & nextmask) > 0) {
47 continue;
48 }
49 bool check = true;
50 for (int i = 0; i < n; i++) {
51 if ((nextmask & (1 << i)) > 0) {
52 int tmpmask = fact[i] & nextmask;
53 if ((int) __builtin_popcount(tmpmask) != 1) {
54 check = false;
55 break;
56 }
57 tmpmask = mask | nextmask;
58 if ((fact[i] & tmpmask) != fact[i]) {
59 check = false;
60 break;
61 }
62 }
63 }
64 if (check == true) {
65 dp[mask | nextmask] = min(dp[mask | nextmask], dp[mask] + cost[nextmask]);
66 }
67 }
68 }
69 return dp[(1 << n) - 1];
70 }
71};
72
73int main() {
74 int n, m, k;
75 cin >> n >> m >> k;
76 vector<vector<int>> a(m, vector<int>(2));
77 for (int i = 0; i < m; i++) {
78 cin >> a[i][0] >> a[i][1];
79 }
80 Solution sol;
81 cout << sol.minNumberOfSemesters(n, a, k);
82 return 0;
83}

Đánh giá độ phức tạp của cách làm này:

  • Không gian: các mảng dp, cost có $2^n$ phần tử nên là $O(2^N)$.
  • Thời gian: với mỗi mask, ta duyệt mọi tập con của phần bù restmask. Tổng số cặp (mask, nextmask) là $3^n$ (mỗi khóa có 3 khả năng: thuộc mask, thuộc nextmask, hoặc không thuộc cả hai). Mỗi cặp kiểm tra thêm $n$ khóa nên tổng là $O(3^N \cdot N)$. Với $n = 15$ thì $3^{15} \approx 1.4 \cdot 10^7$ cặp.
Không gian$O(2^N)$
Thời gian$O(3^N \cdot N)$
Nhớ xóa dữ liệu cũ
Trên LeetCode hàm được gọi nhiều lần trong một lần chạy, mà adj, fact, dp lại là biến toàn cục, nên đầu hàm phải clear() danh sách kề và gán lại dp bằng inf cho mọi mask. Quên bước này là test sau "dính" dữ liệu của test trước.
Ghi chú của mình: code C++ và Python đều cho đúng 3 ví dụ của đề, và đã được so với một chương trình vét cạn độc lập (BFS trên tập khóa đã học, mỗi kỳ thử mọi cách chọn từ 1 tới $k$ khóa học được) trên 400 bộ test ngẫu nhiên với $n \le 9$ – khớp hết. Trường hợp nặng nhất là $n = 15$ và không có quan hệ nào (mọi tập đều tới được, đủ $3^{15}$ cặp): trên máy mình (khá chậm, một vòng lặp $10^9$ phép cộng mất hơn 6 giây) bản C++ mất khoảng 4–7 giây, bản Python khoảng 10–13 giây; trên máy chấm của LeetCode bản C++ của mình đã AC.
Rất vui khi anh em nào đã đọc đến những dòng cuối cùng này. Anh em đọc chắc cũng mỏi mắt luôn chứ 😅 vậy anh em nghĩ người viết ra hết đống ở trên còn thế nào nữa 🤪. Vậy nên đừng tiếc gì cho mình một lời bình luận hoặc chia sẻ bài viết để động viên mình nè. Chúc anh em học tốt và gặt hái nhiều thành công 😇
Tự học thuật toán: khó ở đâu?
Đọc xong một bài giải là bước đầu. Phần lớn học sinh tự học sẽ gặp những trở ngại sau:
  • Không biết học gì trước, học gì sau, học lan man nên kiến thức bị hổng.
  • Bí bài không ai gỡ, ngồi cả buổi vẫn không ra rồi bỏ cuộc.
  • Code chạy được nhưng không đủ điểm, không biết sai ở đâu, chậm ở đâu.
  • Không có đề thi thử và người chấm, nên không biết mình đang ở mức nào.
  • Dễ nản giữa chừng khi không có ai theo sát.
Học cùng thầy Nam Sơn, các trở ngại đó được gỡ bỏ:
  • Lộ trình 111 buổi video sắp sẵn từ dòng code đầu tiên đến đề HSG thật của các tỉnh.
  • Thầy trực tiếp gỡ bí, chữa bài cho từng em qua nhóm Zalo riêng.
  • Hệ thống chấm bài tự động 24/7 và thi thử HSG hằng tuần để biết chính xác mình đang ở đâu.
  • Đóng học phí một lần, thầy kèm lâu dài: từ thi chuyên Tin đến HSG quốc gia và cả khi lên đại học.

Nhận xét