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 →

Xếp Lịch (XEPLICH)

Xếp Lịch (XEPLICH)
Nguồn: NTUCoder (XEPLICH) Độ khó: Trung bình Quy hoạch động QHĐ trên lưới Đệ quy Vét cạn

Đề bài

Để hưởng ứng ngày quốc tế lao động 1 - 5, năm nay Thành Phố tổ chức buổi lao động tập thể cho tất cả các trường trên địa bàn. Tổng số lớp tham gia là $k$ lớp và số công việc cần giải quyết là $n$ công việc. Do mỗi lớp có số học sinh nam và nữ khác nhau nên hiệu quả khi giải quyết các công việc là khác nhau. Với mỗi lớp $i$, ta biết hiệu quả khi làm công việc $j$ là $v[i,j]$.

Yêu cầu: Hãy sắp xếp công việc cho các lớp thỏa mãn: lớp có số hiệu nhỏ sẽ thực hiện các công việc trước lớp có số hiệu lớn hơn sao cho hiệu quả của buổi lao động thu được là lớn nhất?

Dữ liệu: vào từ tệp văn bản XEPVIEC.INP gồm:

  • Dòng đầu tiên là 2 giá trị $k$ và $n$ $(1 \le k \le n \le 100)$.
  • Từ dòng thứ 2 trở đi ghi các giá trị $v[i,j]$, với $i = 1..k$ và $j = 1..n$.

Kết quả: ghi ra tệp văn bản XEPVIEC.OUT gồm 1 số duy nhất là hiệu quả lớn nhất thu được.

Giới hạn: thời gian 1 giây, bộ nhớ 512 MB.

(Trên hệ thống chấm online thì đọc dữ liệu từ bàn phím và in ra màn hình như bình thường nhé. Ảnh đề gốc ghi "(<=k<=n<=100)", bị mất số 1 ở đầu, mình ghi lại là $1 \le k \le n \le 100$.)

XEPVIEC.INP
4 6
1 1 6 4 3 10
9 1 4 7 2 7
7 2 6 10 2 3
6 10 7 1 3 9
XEPVIEC.OUT
24

Lời mở đầu

Bài này đề viết hơi "khó hiểu" một chút nên trước tiên mình sẽ tóm tắt lại đề một cách đơn giản nhất để bạn nào chưa hiểu như sau 😇:

Đề bài nói gọn lại
Cho $k$ lớp, $n$ công việc ($1 \le k \le n \le 100$) và bảng $v[k][n]$ với $v[i][j]$ là hiệu quả của lớp $i$ khi làm công việc $j$. Phải thỏa 2 điều kiện:
  • Mỗi lớp phải thực hiện đúng một công việc (mỗi công việc không quá một lớp).
  • Lớp có số thứ tự nhỏ hơn phải làm công việc có số thứ tự nhỏ hơn (công việc trước) lớp có số thứ tự lớn hơn.
Hãy in ra tổng hiệu quả lớn nhất của tất cả các lớp.

Bài này hiện chưa có link nộp, các bạn có thể chạy thử code ở trang Chạy code online của blog với các test ví dụ nhé.

Mình giải thích ví dụ mẫu để bạn nào chưa hiểu đề sẽ hiểu rõ. Với $k = 4$, $n = 6$, các ô gạch chân (tô xanh) là các công việc được chọn trong đáp án:

Việc 1Việc 2Việc 3Việc 4Việc 5Việc 6
Lớp 11164310
Lớp 2914727
Lớp 37261023
Lớp 46107139

Lớp 1, 2, 3, 4 lần lượt làm công việc 1, 3, 4, 6 (chỉ số tăng dần đúng điều kiện) và tổng hiệu quả là $1 + 4 + 10 + 9 = 24$. Các bạn thử tham lam xem: lớp 1 mà chọn việc 6 (hiệu quả 10, lớn nhất hàng) thì các lớp sau hết việc để làm luôn; lớp 2 chọn việc 1 (hiệu quả 9) thì lớp 1 lại không còn việc nào đứng trước. Nên không thể cứ chọn ô lớn nhất được, phải cân nhắc toàn cục – đây chính là chỗ cần đến quy hoạch động.

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 1Vét cạn bằng đệ quy – hàm mũ

Cách cơ bản nhất chính là vét cạn: xét hết tất cả các trường hợp có thể có, tạo ra mọi độ hiệu quả rồi so sánh, lấy cái lớn nhất thì đó chính là kết quả. Ta dùng đệ quy cho cách này: hàm Try(sttLop, sttCongViec, sum) (stt là số thứ tự) cho lớp sttLop thử lần lượt từng công việc j từ sttCongViec đến $n$, sum là tổng hiệu quả của các lớp trước đó.

  • Nếu sttLop là lớp $k$ (lớp cuối cùng) thì ta cập nhật kết quả res = max(res, sum + v[sttLop][j]).
  • Nếu chưa phải lớp cuối thì thử tiếp với lớp sttLop + 1, bắt đầu từ công việc j + 1 (vì lớp sau phải làm công việc nằm sau công việc của lớp trước nó).

Source code cho cách làm vét cạn mình để đây cho anh em cần thì tham khảo nhé:

#include <bits/stdc++.h>
#define endl '\n'
using namespace std;
typedef long long ll;
ll k, n, res;
ll v[105][105];

void Try(ll sttLop, ll sttCongViec, ll sum){
    for(int j = sttCongViec; j <= n; ++j) {
        if(sttLop == k) {
            res = max(res, sum + v[sttLop][j]);
        }
        else {
            Try(sttLop + 1, j + 1, sum + v[sttLop][j]);
        }
    }
}

int main(){
    ios::sync_with_stdio(0);
    cin.tie(0);
    cin >> k >> n;
    for(int i = 1; i <= k; ++i) {
        for(int j = 1; j <= n; ++j) {
            cin >> v[i][j];
        }
    }
    Try(1, 1, 0);
    cout << res;
    return 0;
}
import sys

data = sys.stdin.read().split()
k, n = int(data[0]), int(data[1])
v = [[0] * (n + 1) for _ in range(k + 1)]
pos = 2
for i in range(1, k + 1):
    for j in range(1, n + 1):
        v[i][j] = int(data[pos])
        pos += 1
res = 0


def Try(sttLop, sttCongViec, s):
    global res
    for j in range(sttCongViec, n + 1):
        if sttLop == k:
            res = max(res, s + v[sttLop][j])
        else:
            Try(sttLop + 1, j + 1, s + v[sttLop][j])


Try(1, 1, 0)
print(res)

2 dòng đầu hàm main (ios::sync_with_stdio(0); cin.tie(0);) giúp đẩy nhanh tốc độ nhập xuất khi code C++ (các bạn cứ gõ mấy từ này lên Google là ra rất nhiều bài giải thích nhé). Kinh nghiệm: bài nào cần nhập xuất nhiều thì nên thêm 2 dòng này vào đầu cho chương trình chạy nhanh hơn.

C++▶ Chạy tay từng dòng codeCách 1: Try(lớp, việc bắt đầu, tổng) thử mọi cách chọn việc tăng dần, cập nhật res ở lớp cuối.
Dữ liệu mẫu:
1#include <bits/stdc++.h>
2#define endl '\n'
3using namespace std;
4typedef long long ll;
5ll k, n, res;
6ll v[105][105];
7
8void Try(ll sttLop, ll sttCongViec, ll sum){
9 for(int j = sttCongViec; j <= n; ++j) {
10 if(sttLop == k) {
11 res = max(res, sum + v[sttLop][j]);
12 }
13 else {
14 Try(sttLop + 1, j + 1, sum + v[sttLop][j]);
15 }
16 }
17}
18
19int main(){
20 ios::sync_with_stdio(0);
21 cin.tie(0);
22 cin >> k >> n;
23 for(int i = 1; i <= k; ++i) {
24 for(int j = 1; j <= n; ++j) {
25 cin >> v[i][j];
26 }
27 }
28 Try(1, 1, 0);
29 cout << res;
30 return 0;
31}

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

  • Không gian: $O(k \cdot n)$ cho mảng $v$ (cộng thêm độ sâu đệ quy $k$).
  • Thời gian: số trường hợp phải xét chính là số cách chọn $k$ công việc có thứ tự tăng dần trong $n$ công việc, tức là $C_n^k$ – một hàm số mũ.
Không gian$O(k \cdot n)$
Thời gian$O(C_n^k)$ – hàm mũ
Chắc chắn TLE
Trong bài gốc mình ước lượng thô là $O(k^n)$. Đếm kỹ hơn thì số trường hợp là $C_n^k$; với $k = n$ thì chỉ có đúng 1 cách, nhưng tệ nhất là khi $k = n/2$: $C_{100}^{50} \approx 10^{29}$ trường hợp. Với tốc độ cỡ $10^8$ phép tính mỗi giây thì cần khoảng $10^{21}$ giây, tức cỡ 3·1013 năm, gấp hàng nghìn lần tuổi của cả vũ trụ (khoảng 13,8 tỷ năm) 😱. Mình thử chạy với $k = 12$, $n = 26$ ($C_{26}^{12} \approx 9.7$ triệu trường hợp) đã mất khoảng 0.3 giây rồi.
Vẫn nên cài đặt vét cạn
Tuy chắc chắn TLE, nhưng các bạn vẫn nên cài nó để sau này dùng làm công cụ kiểm tra tính chính xác cho cách tối ưu: sinh test nhỏ ngẫu nhiên rồi so kết quả 2 cách với nhau. Mình cũng làm đúng như vậy để kiểm tra code trong bài này.

Cách 2Quy hoạch động – O(k·n)

Bài này ta sẽ áp dụng phương pháp Quy Hoạch Động (Dynamic Programming) nhé các bạn. Theo 4 bước giải một bài quy hoạch động:

Bước 1 – Bài toán con: gọi $F[i][j]$ là độ hiệu quả lớn nhất có thể có được khi xét $i$ lớp đầu tiên và $j$ công việc đầu tiên (mỗi lớp trong $i$ lớp đầu làm một việc trong $j$ việc đầu, đúng thứ tự).

Bước 2 – Bài toán cơ sở:

  • Ban đầu khởi tạo tất cả $F[i][j] = 0$ để tránh $F[i][j]$ mang giá trị ngẫu nhiên tuỳ compiler (thậm chí giá trị rác) làm sai các bước sau. Cách đơn giản nhất: khai báo mảng $F$ là biến toàn cục thì mọi phần tử được gán mặc định bằng 0.
  • Xét 0 lớp nào thì chắc chắn hiệu quả bằng 0: $F[0][j] = 0$.
  • Xét 0 công việc nào thì hiệu quả cũng bằng 0: $F[i][0] = 0$.
  • Vì lớp số hiệu nhỏ phải làm việc trước, nên lớp thứ $i$ chỉ có thể chọn từ công việc thứ $i$ trở lên (tối thiểu $i - 1$ công việc trước đó dành cho $i - 1$ lớp đầu). Vậy $F[i][j] = 0$ khi $j \lt i$ (trường hợp không hợp lệ, ta để nguyên 0 và không tính).

Bước 3 – Đáp án: quá rõ ràng là $F[k][n]$, vì ta cần hiệu quả lớn nhất khi xét toàn bộ $k$ lớp và $n$ công việc.

Bước 4 – Công thức truy hồi: khi xét $i$ lớp đầu và $j$ công việc đầu ($i \gt 0$, $j \ge i$), ta luôn có 2 lựa chọn:

TH1: Lớp i không làm việc j
Thì việc $j$ bỏ trống, hiệu quả lớn nhất bằng khi xét $i$ lớp đầu và $j - 1$ công việc đầu: $F[i][j] = F[i][j-1]$.
TH2: Lớp i làm việc j
Thì $i - 1$ lớp trước không được chọn việc nào có số thứ tự $\ge j$, tức chỉ được chọn trong các việc $1..j-1$: $F[i][j] = F[i-1][j-1] + v[i][j]$.
Công thức truy hồi
Cần hiệu quả lớn nhất nên: $F[i][j] = \max\big(F[i][j-1],\ F[i-1][j-1] + v[i][j]\big)$ khi $j \ge i$.
Vì sao ô j < i để bằng 0 lại không làm sai?
Ở ô $F[i][i]$, lựa chọn TH1 lấy $F[i][i-1] = 0$ (thực chất là trường hợp không hợp lệ). Vì các hiệu quả $v[i][j]$ là số không âm, nên TH2 luôn cho giá trị $\ge 0$ và được chọn (hoặc bằng nhau, không ảnh hưởng đáp án). Do đó để 0 cho các ô không hợp lệ là an toàn với bài này.

Minh hoạ điền bảng F và truy vết

Em nhập $k$, $n$ và bảng $v$ (mỗi hàng một lớp, các hàng cách nhau dấu |) rồi bấm Tiếp để xem từng ô $F[i][j]$ được điền. Ô nền xanh nhạt/tím là 2 ô được dùng; cuối cùng mình truy ngược để chỉ ra lớp nào làm việc nào:

Ví dụ nhanh:

Source code cho các bạn tham khảo cách làm trên:

#include <bits/stdc++.h>
#define endl '\n'
using namespace std;
typedef long long ll;
ll v[105][105];
ll f[105][105];

int main(){
    ios::sync_with_stdio(0);
    cin.tie(0);
    int k, n;
    cin >> k >> n;
    for(int i = 1; i <= k; ++i) {
        for(int j = 1; j <= n; ++j) {
            cin >> v[i][j];
        }
    }
    for(int i = 1; i <= k; ++i) {
        for(int j = 1; j <= n; ++j) {
            if(j >= i) f[i][j] = max(f[i][j - 1], f[i - 1][j - 1] + v[i][j]);
        }
    }
    cout << f[k][n];
    return 0;
}
import sys

data = sys.stdin.read().split()
k, n = int(data[0]), int(data[1])
v = [[0] * (n + 1) for _ in range(k + 1)]
pos = 2
for i in range(1, k + 1):
    for j in range(1, n + 1):
        v[i][j] = int(data[pos])
        pos += 1
f = [[0] * (n + 1) for _ in range(k + 1)]
for i in range(1, k + 1):
    for j in range(1, n + 1):
        if j >= i:
            f[i][j] = max(f[i][j - 1], f[i - 1][j - 1] + v[i][j])
print(f[k][n])
C++▶ Chạy tay từng dòng codeCách 2: đọc bảng v, rồi điền f[i][j] = max(f[i][j-1], f[i-1][j-1] + v[i][j]) với j ≥ i.
Dữ liệu mẫu:
1#include <bits/stdc++.h>
2#define endl '\n'
3using namespace std;
4typedef long long ll;
5ll v[105][105];
6ll f[105][105];
7
8int main(){
9 ios::sync_with_stdio(0);
10 cin.tie(0);
11 int k, n;
12 cin >> k >> n;
13 for(int i = 1; i <= k; ++i) {
14 for(int j = 1; j <= n; ++j) {
15 cin >> v[i][j];
16 }
17 }
18 for(int i = 1; i <= k; ++i) {
19 for(int j = 1; j <= n; ++j) {
20 if(j >= i) f[i][j] = max(f[i][j - 1], f[i - 1][j - 1] + v[i][j]);
21 }
22 }
23 cout << f[k][n];
24 return 0;
25}

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

  • Không gian: $O(k \cdot n)$, thực chất là $O(2 \cdot k \cdot n)$ cho 2 mảng $v$ và $F$.
  • Thời gian: $O(k \cdot n)$, thực chất là $O(2 \cdot k \cdot n)$: một lượt đọc bảng $v$ và một lượt điền bảng $F$.
Không gian$O(k \cdot n)$
Thời gian$O(k \cdot n)$
Nhớ dùng long long
Đề không cho giới hạn của $v[i][j]$, nên mình dùng long long cho chắc: tổng của tối đa 100 số, mỗi số cỡ $10^9$ thì đã vượt int rồi.

Cách 3Quy hoạch động ngay khi đọc dữ liệu – O(k·n)

Các bạn để ý: khi tính $F[i][j]$ ta chỉ cần đúng một giá trị $v[i][j]$, và các ô được tính theo đúng thứ tự $v$ được đọc vào (hàng trên xuống, trái sang phải). Mà $F[i][j-1]$, $F[i-1][j-1]$ khi đó cũng đã được tính rồi.

Ý tưởng
Thực hiện luôn thuật toán khi đọc dữ liệu: đọc $v[i][j]$ vào một biến x rồi dùng ngay để tính $F[i][j]$. Không cần mảng $v$ nữa, và chỉ còn một lượt duyệt thay vì hai.

Đây là source code cho bạn nào muốn tham khảo:

#include <bits/stdc++.h>
#define endl '\n'
using namespace std;
typedef long long ll;
ll f[105][105];

int main(){
    ios::sync_with_stdio(0);
    cin.tie(0);
    int k, n;
    cin >> k >> n;
    for(int i = 1; i <= k; ++i) {
        for(int j = 1; j <= n; ++j) {
            ll x;
            cin >> x;
            if(j >= i) f[i][j] = max(f[i][j - 1], f[i - 1][j - 1] + x);
        }
    }
    cout << f[k][n];
    return 0;
}
import sys

data = sys.stdin.read().split()
k, n = int(data[0]), int(data[1])
f = [[0] * (n + 1) for _ in range(k + 1)]
pos = 2
for i in range(1, k + 1):
    for j in range(1, n + 1):
        x = int(data[pos])
        pos += 1
        if j >= i:
            f[i][j] = max(f[i][j - 1], f[i - 1][j - 1] + x)
print(f[k][n])
C++▶ Chạy tay từng dòng codeCách 3: đọc x = v[i][j] tới đâu thì tính f[i][j] tới đó, không cần mảng v.
Dữ liệu mẫu:
1#include <bits/stdc++.h>
2#define endl '\n'
3using namespace std;
4typedef long long ll;
5ll f[105][105];
6
7int main(){
8 ios::sync_with_stdio(0);
9 cin.tie(0);
10 int k, n;
11 cin >> k >> n;
12 for(int i = 1; i <= k; ++i) {
13 for(int j = 1; j <= n; ++j) {
14 ll x;
15 cin >> x;
16 if(j >= i) f[i][j] = max(f[i][j - 1], f[i - 1][j - 1] + x);
17 }
18 }
19 cout << f[k][n];
20 return 0;
21}

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

  • Không gian: $O(k \cdot n)$, lúc này đúng là $O(k \cdot n)$ rồi (chỉ còn mảng $F$).
  • Thời gian: $O(k \cdot n)$, lúc này cũng đúng là $O(k \cdot n)$ (chỉ còn một lượt duyệt).
Không gian$O(k \cdot n)$
Thời gian$O(k \cdot n)$

Ở trường hợp xấu nhất $k = n = 100$ ta chỉ phải làm $100^2 = 10^4$ phép tính, mất khoảng $10^4 / 10^8 = 0.0001$ giây. Nhanh chưa nào anh em, từ cách vét cạn chạy lâu hơn cả tuổi vũ trụ, ta đã rút về còn 0.0001 giây, quá chi là nhanh luôn 😎.

Ghi chú của mình: code C++ và Python của cả 3 cách đã được kiểm tra với 400 bộ test ngẫu nhiên ($n \le 8$, $v[i][j]$ từ 0 đến $10^9$), so với code vét cạn duyệt mọi tổ hợp – khớp hết. Với $k = n = 100$, $v[i][j]$ tới $10^9$, cách 2 và cách 3 (C++) chạy khoảng 0.03 giây, bản Python khoảng 0.1 giây. Lời giải giả định $v[i][j] \ge 0$ (hiệu quả không âm, như ví dụ của đề); nếu đề có hiệu quả âm thì phải khởi tạo các ô không hợp lệ bằng $-\infty$ thay vì 0.
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