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 →

Pilots (MPILOT)

Pilots (MPILOT)
Nguồn: VNOJ (MPILOT) Độ khó: Khá Quy hoạch động Tham lam Hàng đợi ưu tiên

Đề bài

Pilots (MPILOT)

Charlie sở hữu vài cái máy bay bà già và cần tối ưu chi phí để kiếm lời.

Có $N$ phi công ($N$ chẵn) và cần có $N/2$ phi hành đoàn. Mỗi phi hành đoàn gồm 2 người – 1 lái chính, 1 trợ lí. Lái chính phải cao tuổi hơn trợ lí. Hợp đồng cho mỗi phi công có ghi mức lương nếu anh ta là lái chính hoặc là trợ lí. Với mỗi hợp đồng thì lương lái chính lớn hơn lương trợ lí.

Tìm cách ghép cặp sao cho tổng lương phải trả cho $N$ người là ít nhất.

Input:

  • Dòng đầu là $N$ ($N$ chẵn), số phi công, $2 \le N \le 10\,000$.
  • $N$ dòng tiếp theo, mỗi dòng là 2 số $X$, $Y$ là lương phi công thứ $i$ nếu làm lái chính hoặc trợ lí, $1 \le Y \lt X \le 100\,000$. Các phi công sắp tăng dần theo tuổi.

Output: Lương nhỏ nhất cần trả.

Sample 1 – input
4
5000 3000
6000 2000
8000 1000
9000 6000
Sample 1 – output
19000
Sample 2 – input
6
10000 7000
9000 3000
6000 4000
5000 1000
9000 3000
8000 6000
Sample 2 – output
32000

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 😇. Bài này mình áp dụng kiến thức về đệ quy (vét cạn), rồi quy hoạch động (cả top-down lẫn bottom-up, và rút gọn mảng 2 chiều về 1 chiều giống bài Perfect Sum Problem), cuối cùng là tham lam với hàng đợi ưu tiên.

Trước khi vào bài: ngưỡng để không bị TLE
Với các hệ thống chấm giới hạn 1 giây, tổng số phép toán (ước lượng theo số lần các vòng lặp chạy) chỉ nên ở ngưỡng $(3 \text{ đến } 5) \cdot 10^7$ là an toàn; một số hệ thống server mạnh hơn có thể nâng lên $10^8$ nhưng hên xui nhé. Ngưỡng này là cho C/C++; Python, C#, Java chạy chậm hơn nên ngưỡng phải thấp hơn (hoặc đề cho thêm thời gian cho các ngôn ngữ đó). Đề cho 2 giây thì nhân 2 ngưỡng lên, 3 giây nhân 3, 100 ms thì chia 10, cứ thế thôi. Bài này giới hạn 1 giây.

Mình tóm tắt lại đề bài một cách đơn giản nhất để bạn nào chưa hiểu: cho $N$ phi công ($N$ chẵn), cần ghép thành $N/2$ cặp. Mỗi cặp gồm 1 lái chính + 1 trợ lý (mình gọi trợ lý là lái phụ luôn nha), lái chính phải lớn tuổi hơn lái phụ. Phi công thứ $i$ làm lái chính thì nhận lương $X[i]$, làm lái phụ thì nhận $Y[i]$, và $X[i] \gt Y[i]$. Các phi công đã được sắp tăng dần theo tuổi, nên phi công số lớn hơn thì già hơn. Tìm cách ghép để tổng lương ít nhất.

Giải thích ví dụ để bạn nào chưa hiểu đề sẽ hiểu rõ. Mình viết cặp dưới dạng (lái chính, lái phụ) bằng số thứ tự phi công:

  • Sample 1: $X = \{5000, 6000, 8000, 9000\}$, $Y = \{3000, 2000, 1000, 6000\}$. Ghép $(2, 1)$ và $(4, 3)$: tiền phải trả $= X[2] + Y[1] + X[4] + Y[3] = 6000 + 3000 + 9000 + 1000 = 19000$.
  • Sample 2: $X = \{10000, 9000, 6000, 5000, 9000, 8000\}$, $Y = \{7000, 3000, 4000, 1000, 3000, 6000\}$. Ghép $(3, 1)$, $(4, 2)$, $(6, 5)$: tiền phải trả $= X[3] + Y[1] + X[4] + Y[2] + X[6] + Y[5] = 6000 + 7000 + 5000 + 3000 + 8000 + 3000 = 32000$.

Để ý phi công $1$ (trẻ nhất) bắt buộc làm lái phụ, còn phi công $N$ (già nhất) bắt buộc làm lái chính.

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

Cách làm đơn giản nhất là vét cạn: tạo ra mọi cách ghép cặp có thể, kiểm tra hợp lệ, tính tiền rồi lấy số tiền nhỏ nhất. Nghĩ thì rất đơn giản, nhưng bắt tay vào cài đặt "ghép cặp" thì sẽ rất phức tạp. Nên mình đi luôn vào một ý tưởng để vét cạn gọn hơn. Ta cần làm rõ và giả định vài thứ:

Làm rõ: các phi công được sắp tăng dần theo tuổi. Phi công $i$ khi ghép với các phi công $1 \ldots i-1$ thì làm lái chính, còn khi ghép với các phi công $i+1 \ldots n$ thì làm lái phụ. Nghe có vẻ hiển nhiên, nhưng chốc nữa ta sẽ làm hơi "ngược" nó một chút 😉.

Giả định: ta thuê $n$ phi công, trong đó $cnt$ người làm lái chính, những người còn lại làm lái phụ. Vì mỗi lái chính phải đi cùng một lái phụ nên số lái chính không được quá một nửa: $cnt \le n / 2$. Ta cho phép có những lái phụ đi một mình, không ghép với lái chính nào. Ví dụ thuê 6 phi công mà chỉ 1 lái chính: 5 người còn lại làm lái phụ, 1 lái phụ đi cùng lái chính, 4 lái phụ đi một mình.

Giả định như vậy có ích gì? Thuê $n$ phi công thì càng nhiều lái chính càng tốn tiền (lương lái phụ rẻ hơn mà). Rẻ nhất là tất cả làm lái phụ. Rồi ta tìm số tiền ít nhất khi có 1 lái chính, 2 lái chính, …, tăng dần tới $n/2$ lái chính thì đúng yêu cầu đề. Nên $0 \le cnt \le n/2$.

Ý rất quan trọng: những lái phụ đi một mình thì không bị ràng buộc "trẻ hơn lái chính" nữa. Nên khi xét $i$ người đầu tiên với $cnt$ lái chính, người thứ $i$ (già nhất trong $i$ người) cũng có thể làm lái phụ – đứng một mình tạm thời, để sau này ghép với một lái chính già hơn ($i+1$, $i+2$, …).

Hàm Try(n, cntMainPilots)
Trả về số tiền ít nhất để thuê $n$ phi công đầu tiên, trong đó có $cntMainPilots$ người làm lái chính; những người còn lại làm lái phụ (có thể có lái phụ đứng một mình).

Điều kiện dừng:

  1. Số lái chính quá một nửa: $cntMainPilots \gt n / 2$ – trường hợp không hợp lệ. Hàm đang tìm số tiền ít nhất nên ta trả về một giá trị rất lớn để không ảnh hưởng kết quả. Tệ nhất thì lương lái chính $10^5$, lái phụ $10^5 - 1$, nên tổng tiền tối đa xấp xỉ $10^5 \cdot 10^4 = 10^9$. Vậy cần trả về giá trị $\gt 10^9$. Hàm kiểu int thì mình trả về $2^{30}$ (1<<30, phép dịch bit trái; $2^{30} \approx 1.07 \cdot 10^9 \gt 10^9$). Không nên trả về giá trị tối đa của int, vì cộng thêm gì vào là tràn số ngay.
  2. Hết phi công: $n = 0$ thì không thuê ai, trả về $0$.

Xử lý chạy: đến đây chắc chắn $n \gt 0$ và $cntMainPilots \le n/2$. Có 2 trường hợp lớn:

  1. Không thuê lái chính nào ($cntMainPilots = 0$): cả $n$ người làm lái phụ. Số tiền = (tiền thuê $n - 1$ người đầu với $0$ lái chính) + (phi công $n$ làm lái phụ) = Try(n - 1, cntMainPilots) + Y[n].
  2. Còn lại ($1 \le cntMainPilots \le n/2$), xét phi công thứ $n$ có 2 lựa chọn:
    • Làm lái chính: trước đó chỉ cần $n - 1$ người với $cntMainPilots - 1$ lái chính. Vì số lái chính $\le$ một nửa nên trong $n - 1$ người trước kiểu gì cũng có lái phụ để nó ghép cặp. Tiền: Try(n - 1, cntMainPilots - 1) + X[n].
    • Làm lái phụ (đứng một mình, chờ ghép với lái chính già hơn trong tương lai): trước đó vẫn cần đủ $cntMainPilots$ lái chính trong $n - 1$ người. Tiền: Try(n - 1, cntMainPilots) + Y[n].
    Lấy min của 2 lựa chọn.
Đáp án
Try(n, n / 2): thuê $n$ phi công với đúng $n/2$ lái chính.

Liệu có sai khi cho phi công thứ $n$ (già nhất) làm lái phụ? Thử với sample 2, $n = 6$: Try(6, 3) = min(Try(5, 2) + X[6], Try(5, 3) + Y[6]). Mà Try(5, 3) không hợp lệ: thuê 5 người thì tối đa $5 / 2 = 2$ lái chính (3 lái chính cần 3 lái phụ, tổng 6 người mà ta chỉ có 5), nên nó trả về $2^{30}$. Vậy chắc chắn Try(6, 3) = Try(5, 2) + X[6] – phi công thứ 6 làm lái chính, ghép với lái phụ đang đứng một mình trong 5 người trước đó. Nó khá rối đúng không 😅, nếu thấy rối các bạn thử đổi tên "xét $n$ người đầu tiên" thành "xét $i$ người đầu tiên" xem, biết đâu dễ hình dung hơn.

Các bạn có thể tham khảo source code về ý tưởng trên ở dưới đây, có khi nó sẽ dễ hiểu hơn đó:

#include<bits/stdc++.h>
#define endl '\n'

using namespace std;
typedef long long ll;

const int nMax = (int)1e4 + 5;
int X[nMax], Y[nMax];

int Try(int n, int cntMainPilots) {
    if(cntMainPilots > (n / 2)) {
        return (1<<30);
    }
    if(n == 0) {
        return 0;
    }
    if(cntMainPilots == 0) {
        return Try(n - 1, cntMainPilots) + Y[n];
    }
    return min(Try(n - 1, cntMainPilots - 1) + X[n], Try(n - 1, cntMainPilots) + Y[n]);
}

int main() {
    ios::sync_with_stdio(0);
    cin.tie(0);
    cout.tie(0);
    int n;
    cin >> n;
    for(int i = 1; i <= n; ++i) {
        cin >> X[i] >> Y[i];
    }
    cout << Try(n, n / 2);
    return 0;
}
import sys
sys.setrecursionlimit(100000)
INF = 1 << 30

def Try(n, cntMainPilots):
    if cntMainPilots > n // 2:
        return INF
    if n == 0:
        return 0
    if cntMainPilots == 0:
        return Try(n - 1, cntMainPilots) + Y[n]
    return min(Try(n - 1, cntMainPilots - 1) + X[n], Try(n - 1, cntMainPilots) + Y[n])

data = sys.stdin.read().split()
n = int(data[0])
X = [0] * (n + 1)
Y = [0] * (n + 1)
for i in range(1, n + 1):
    X[i] = int(data[2 * i - 1])
    Y[i] = int(data[2 * i])
print(Try(n, n // 2))
C++▶ Chạy tay từng dòng codeCách 1: Try(n, cnt) = min(Try(n-1, cnt-1) + X[n], Try(n-1, cnt) + Y[n]); cnt > n/2 thì trả về 2^30.
Dữ liệu mẫu:
1#include<bits/stdc++.h>
2#define endl '\n'
3
4using namespace std;
5typedef long long ll;
6
7const int nMax = (int)1e4 + 5;
8int X[nMax], Y[nMax];
9
10int Try(int n, int cntMainPilots) {
11 if(cntMainPilots > (n / 2)) {
12 return (1<<30);
13 }
14 if(n == 0) {
15 return 0;
16 }
17 if(cntMainPilots == 0) {
18 return Try(n - 1, cntMainPilots) + Y[n];
19 }
20 return min(Try(n - 1, cntMainPilots - 1) + X[n], Try(n - 1, cntMainPilots) + Y[n]);
21}
22
23int main() {
24 ios::sync_with_stdio(0);
25 cin.tie(0);
26 cout.tie(0);
27 int n;
28 cin >> n;
29 for(int i = 1; i <= n; ++i) {
30 cin >> X[i] >> Y[i];
31 }
32 cout << Try(n, n / 2);
33 return 0;
34}

Giải thích 1 số chỗ trong code ở trên:

  1. 3 dòng đầu hàm main (ios::sync_with_stdio(0); cin.tie(0); cout.tie(0);) giúp đẩy nhanh tốc độ nhập xuất nếu các bạn code C++. Bài này đầu vào có thể tới $2 \cdot 10^4$ số nên các bạn nên thêm vào nha.
  2. const int nMax = (int)1e4 + 5; là giá trị tối đa của $n$ ($10^4$), mình hay cộng thêm 5 cho chắc.
  3. return (1<<30); chính là return $2^{30}$ đó nha.
Không gian$O(n)$
Thời gianhàm mũ
TLE chắc chắn
Mỗi lời gọi tách thành 2 lời gọi con nên số lời gọi tăng theo hàm mũ. Mình đo bản C++: $n = 36$ mất khoảng 4.4 giây, $n = 40$ mất gần 60 giây, trong khi $n$ tới $10^4$. Tuy vậy vẫn nên cài đặt để làm công cụ kiểm tra tính chính xác cho các cách tối ưu, và để cải tiến thành quy hoạch động top-down.

Cách 2Đệ quy có nhớ (QHĐ top-down)

Thực sự mà nói, nghĩ ra ý tưởng trên rồi cài đặt được thì rất khoai đúng không anh em 😅. Nhưng đã có ý tưởng rồi thì không khó để làm luôn quy hoạch động. Code trên lâu vì Try(n, cntMainPilots) đã tính rồi mà gặp lại vẫn phải tính lại. Ta lưu kết quả lại:

  • Cả 2 tham số $n$ và $cntMainPilots$ đều thay đổi khi gọi đệ quy, nên tạo mảng 2 chiều $F$, với $F[n][cntMainPilots]$ lưu kết quả của Try(n, cntMainPilots).
  • $n$ tối đa $10^4$, $cntMainPilots$ tối đa $10^4 / 2$, nên mảng là f[nMax][nMax / 2], khai báo toàn cục để tránh tràn bộ nhớ stack.
  • Hàm không bao giờ trả về $-1$, nên lấp đầy $F$ bằng $-1$. Sau điều kiện dừng, nếu f[n][cntMainPilots] != -1 thì trả về luôn; ngược lại tính rồi lưu vào.

Source code để các bạn tham khảo nếu làm theo hướng này:

#include<bits/stdc++.h>
#define endl '\n'

using namespace std;
typedef long long ll;

const int nMax = (int)1e4 + 5;
int X[nMax], Y[nMax];
int f[nMax][nMax / 2];

int Try(int n, int cntMainPilots) {
    if(cntMainPilots > (n / 2)) {
        return (1<<30);
    }
    if(n == 0) {
        return 0;
    }
    if(f[n][cntMainPilots] != -1) {
        return f[n][cntMainPilots];
    }
    if(cntMainPilots == 0) {
        return f[n][cntMainPilots] = Try(n - 1, cntMainPilots) + Y[n];
    }
    return f[n][cntMainPilots] = min(Try(n - 1, cntMainPilots - 1) + X[n], Try(n - 1, cntMainPilots) + Y[n]);
}

int main() {
    ios::sync_with_stdio(0);
    cin.tie(0);
    cout.tie(0);
    int n;
    cin >> n;
    fill(&f[0][0], &f[n][(n / 2) + 1], -1);
    for(int i = 1; i <= n; ++i) {
        cin >> X[i] >> Y[i];
    }
    cout << Try(n, n / 2);
    return 0;
}
import sys
import threading
INF = 1 << 30

def main():
    data = sys.stdin.read().split()
    n = int(data[0])
    X = [0] * (n + 1)
    Y = [0] * (n + 1)
    for i in range(1, n + 1):
        X[i] = int(data[2 * i - 1])
        Y[i] = int(data[2 * i])
    f = [[-1] * (n // 2 + 1) for _ in range(n + 1)]

    def Try(n, cntMainPilots):
        if cntMainPilots > n // 2:
            return INF
        if n == 0:
            return 0
        if f[n][cntMainPilots] != -1:
            return f[n][cntMainPilots]
        if cntMainPilots == 0:
            f[n][cntMainPilots] = Try(n - 1, cntMainPilots) + Y[n]
        else:
            f[n][cntMainPilots] = min(Try(n - 1, cntMainPilots - 1) + X[n],
                                      Try(n - 1, cntMainPilots) + Y[n])
        return f[n][cntMainPilots]

    print(Try(n, n // 2))

sys.setrecursionlimit(1000000)
threading.stack_size(512 * 1024 * 1024)
t = threading.Thread(target=main)
t.start()
t.join()
C++▶ Chạy tay từng dòng codeCách 2: như cách 1 nhưng nhớ Try(n, cnt) vào f[n][cnt], gặp lại thì trả về luôn.
Dữ liệu mẫu:
1#include<bits/stdc++.h>
2#define endl '\n'
3
4using namespace std;
5typedef long long ll;
6
7const int nMax = (int)1e4 + 5;
8int X[nMax], Y[nMax];
9int f[nMax][nMax / 2];
10
11int Try(int n, int cntMainPilots) {
12 if(cntMainPilots > (n / 2)) {
13 return (1<<30);
14 }
15 if(n == 0) {
16 return 0;
17 }
18 if(f[n][cntMainPilots] != -1) {
19 return f[n][cntMainPilots];
20 }
21 if(cntMainPilots == 0) {
22 return f[n][cntMainPilots] = Try(n - 1, cntMainPilots) + Y[n];
23 }
24 return f[n][cntMainPilots] = min(Try(n - 1, cntMainPilots - 1) + X[n], Try(n - 1, cntMainPilots) + Y[n]);
25}
26
27int main() {
28 ios::sync_with_stdio(0);
29 cin.tie(0);
30 cout.tie(0);
31 int n;
32 cin >> n;
33 fill(&f[0][0], &f[n][(n / 2) + 1], -1);
34 for(int i = 1; i <= n; ++i) {
35 cin >> X[i] >> Y[i];
36 }
37 cout << Try(n, n / 2);
38 return 0;
39}

Giải thích: fill(&f[0][0], &f[n][(n / 2) + 1], -1); là lấp đầy mảng $F$ bằng $-1$ (từ ô $f[0][0]$ tới hết ô $f[n][n/2]$). Không biết hàm fill thì dùng vòng for cũng được, độ phức tạp như nhau.

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

  • Không gian: $O(n^2 / 2)$. Mảng $F$ rất to: $n = 10^4$ thì chứa $5 \cdot 10^7$ phần tử. Giới hạn bộ nhớ bài cho là 256 MB, code này chiếm tới khoảng 200 MB – rất suýt soát.
  • Thời gian: $O(n^2 / 2)$. Trường hợp xấu nhất phải lấp đầy mảng $F$, cỡ $5 \cdot 10^7$ phép tính, vẫn dưới 1 giây nên AC được (mình đo khoảng 0.4 giây).
Không gian$O(n^2 / 2)$
Thời gian$O(n^2 / 2)$

Đây chưa phải cách tốt nhất (lâu và tốn bộ nhớ), nhưng khi đi thi thì anh em cứ ưu tiên cách nào làm nhanh nhất và đúng nhất để AC nha.

Cách 3Quy hoạch động bottom-up – O(N²)

Đã có top-down thì cũng có bottom-up. Công thức truy hồi giống hệt phần top-down, chỉ cần thay $n = i$ (số phi công đã xét) và $cntMainPilots = j$ (số lái chính). Nhưng mình sẽ trình bày lại theo 4 bước, cho trường hợp các bạn nghĩ thẳng ra QHĐ mà không làm 2 cách trên:

1Bài toán con: $F[i][j]$ là số tiền ít nhất để thuê $i$ phi công đầu tiên, trong đó $j$ người làm lái chính ($0 \le j \le i/2$), còn lại làm lái phụ.
2Cơ sở: $F[0][0] = 0$; mọi ô khác ban đầu là $2^{30}$ (không hợp lệ).
3Đáp án: $F[n][n/2]$.
4Truy hồi: $F[i][0] = F[i-1][0] + Y[i]$; $F[i][j] = \min(F[i-1][j-1] + X[i],\ F[i-1][j] + Y[i])$ với $1 \le j \le i/2$.

Bước 1: Xác định bài toán con

Gọi $F[i][j]$ là số tiền ít nhất phải trả để thuê $i$ phi công đầu tiên, trong đó có $j$ phi công là lái chính, những người còn lại làm lái phụ, với $0 \le j \le i / 2$. Ta chấp nhận có nhiều lái phụ hơn lái chính ở các trường hợp $j \lt i/2$ hoặc ($j = i/2$ nhưng $i$ lẻ) – những trường hợp đó vẫn đúng là "số tiền ít nhất" (càng nhiều lái phụ càng đỡ tốn tiền 😁), và nhờ chúng ta xây dựng được các trường hợp với nhiều phi công và nhiều lái chính hơn.

Bước 2: Xác định trường hợp cơ sở

Những trường hợp số lái chính quá một nửa ($j \gt i/2$) là không hợp lệ. Ví dụ 6 phi công thì tối đa 3 lái chính; 7 phi công cũng chỉ tối đa $7 / 2 = 3$ lái chính (3 lái chính đi kèm 3 lái phụ, người dư làm lái phụ đứng một mình). Ta đang tìm min, nên các ô không hợp lệ gán bằng giá trị rất lớn mà chắc chắn không bao giờ phải trả nhiều đến thế: lớn hơn $10^9$. Mình chọn $2^{30}$ (lớn hơn $10^9$ và nhỏ hơn INT_MAX).

Đừng ham dùng long long ở đây!
Các bạn bảo thế dùng luôn long long cho đỡ lo tràn? Làm thế là chết á 😱. Mảng $F$ có khoảng $n \cdot n/2 = 5 \cdot 10^7$ phần tử; long long mỗi phần tử 8 byte nên cần $4 \cdot 10^8$ byte $\approx 381$ MB – quá giới hạn 256 MB. Còn int thì chỉ một nửa, khoảng 190 MB. Không gán bằng INT_MAX ($2^{31} - 1$) vì cộng thêm $Y[i]$ vào là tràn. Với $2^{30}$ thì $2^{30} + 10^5$ vẫn còn trong phạm vi int.

Khi không thuê ai ($i = 0$) thì số lái chính là $0$ và tiền là $0$: $F[0][0] = 0$. Tóm lại: f[0][0] = 0; và fill(&f[0][1], &f[n][(n / 2) + 1], 1<<30); (lấp đầy $2^{30}$ trừ ô $F[0][0]$).

Bước 3: Xác định đáp án của bài toán

Hiển nhiên là $F[n][n/2]$: số tiền ít nhất cho $n$ phi công với $n/2$ lái chính.

Bước 4: Xác định công thức truy hồi

  • $j = 0$: phi công $i$ làm lái phụ: $F[i][0] = F[i-1][0] + Y[i]$.
  • $1 \le j \le i/2$: phi công $i$ hoặc làm lái chính (nó già nhất trong $i$ người nên ghép được với bất kỳ lái phụ nào): $F[i-1][j-1] + X[i]$; hoặc làm lái phụ (đứng một mình, chờ ghép với lái chính già hơn trong tương lai): $F[i-1][j] + Y[i]$.
Công thức truy hồi
$F[i][0] = F[i-1][0] + Y[i]$
$F[i][j] = \min\big(F[i-1][j-1] + X[i],\ F[i-1][j] + Y[i]\big)$ với $1 \le j \le i/2$
Trên bảng: ô $(i, j)$ = min(ô chéo trên-trái + $X[i]$, ô ngay trên + $Y[i]$).

Công thức có đúng không, khi phi công $1$ bắt buộc là lái phụ và phi công $n$ bắt buộc là lái chính? Các bạn nên lấy nháp vẽ bảng $F$ ra là thấy ngay:

  • Phi công 1: $F[1][0] = F[0][0] + Y[1]$ (làm lái phụ); còn $F[1][1]$ không hợp lệ vì $1 / 2 = 0$, giữ $2^{30}$.
  • Với $n = 6$, tại $j \lt n/2$ phi công thứ $n$ vẫn có thể làm lái phụ đứng một mình. Nhưng tại $j = n/2$: $F[6][3] = \min(F[5][2] + X[6],\ F[5][3] + Y[6])$, mà $F[5][3]$ không hợp lệ, nên chắc chắn $F[6][3] = F[5][2] + X[6]$ – phi công thứ $n$ làm lái chính. Vì $n$ chẵn và cần $n/2$ lái chính thì với $n - 1$ người không thể có $n/2$ lái chính được.

Minh hoạ: lấp bảng F[i][j]

Các bạn nhập lương lái chính $X$ và lái phụ $Y$ rồi bấm Tiếp. Ô vàng là ô đang tính, ô tím viền là 2 ô nó lấy từ đó; ∞ là $2^{30}$ (không hợp lệ):

Ví dụ nhanh:

Source code để các bạn tham khảo nếu làm theo hướng này:

#include<bits/stdc++.h>
#define endl '\n'

using namespace std;
typedef long long ll;

const int nMax = (int)1e4 + 5;
int X[nMax], Y[nMax];
int f[nMax][nMax / 2];

int main() {
    ios::sync_with_stdio(0);
    cin.tie(0);
    cout.tie(0);
    int n;
    cin >> n;
    f[0][0] = 0;
    fill(&f[0][1], &f[n][(n / 2) + 1], 1<<30);
    for(int i = 1; i <= n; ++i) {
        cin >> X[i] >> Y[i];
    }
    for(int i = 1; i <= n; ++i) {
        f[i][0] = f[i - 1][0] + Y[i];
        for(int j = 1; j <= i / 2; ++j) {
            f[i][j] = min(f[i - 1][j - 1] + X[i], f[i - 1][j] + Y[i]);
        }
    }
    cout << f[n][n / 2];
    return 0;
}
import sys
INF = 1 << 30

data = sys.stdin.read().split()
n = int(data[0])
X = [0] * (n + 1)
Y = [0] * (n + 1)
for i in range(1, n + 1):
    X[i] = int(data[2 * i - 1])
    Y[i] = int(data[2 * i])
f = [[INF] * (n // 2 + 1) for _ in range(n + 1)]
f[0][0] = 0
for i in range(1, n + 1):
    f[i][0] = f[i - 1][0] + Y[i]
    for j in range(1, i // 2 + 1):
        f[i][j] = min(f[i - 1][j - 1] + X[i], f[i - 1][j] + Y[i])
print(f[n][n // 2])
C++▶ Chạy tay từng dòng codeCách 3: lấp bảng f[i][j], j từ 0 đến i/2; ô ngoài vùng hợp lệ giữ 2^30.
Dữ liệu mẫu:
1#include<bits/stdc++.h>
2#define endl '\n'
3
4using namespace std;
5typedef long long ll;
6
7const int nMax = (int)1e4 + 5;
8int X[nMax], Y[nMax];
9int f[nMax][nMax / 2];
10
11int main() {
12 ios::sync_with_stdio(0);
13 cin.tie(0);
14 cout.tie(0);
15 int n;
16 cin >> n;
17 f[0][0] = 0;
18 fill(&f[0][1], &f[n][(n / 2) + 1], 1<<30);
19 for(int i = 1; i <= n; ++i) {
20 cin >> X[i] >> Y[i];
21 }
22 for(int i = 1; i <= n; ++i) {
23 f[i][0] = f[i - 1][0] + Y[i];
24 for(int j = 1; j <= i / 2; ++j) {
25 f[i][j] = min(f[i - 1][j - 1] + X[i], f[i - 1][j] + Y[i]);
26 }
27 }
28 cout << f[n][n / 2];
29 return 0;
30}

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

  • Không gian: $O(n^2 / 2)$ – chủ yếu do mảng $f$, mảng $X$, $Y$ không đáng kể. Mình đo thực tế khoảng 194 MB (đúng như tính toán ~190 MB, phần lệch là do mảng $X$, $Y$).
  • Thời gian: $O(n^2)$, vì bước lấp đầy mảng $F$ bằng $2^{30}$ tệ nhất phải lấp đầy toàn mảng; còn 2 vòng for lồng nhau chỉ tốn $O(n^2 / 2)$.
Không gian$O(n^2 / 2)$
Thời gian$O(n^2)$

So với top-down thì code này chạy nhanh hơn rất nhiều và ăn ít bộ nhớ hơn (không tốn cho đệ quy). Nhưng vẫn phải dùng mảng 2 chiều rất lớn.

Cách 4Rút gọn về mảng 1 chiều – bộ nhớ O(N)

Giống bài Perfect Sum Problem, ta cải tiến mảng $F$ từ 2 chiều về 1 chiều, tiết kiệm bộ nhớ hơn rất nhiều. Bỏ chiều $[i]$ đi: khai báo int F[nMax / 2];. Lúc này $F[j]$ là số tiền ít nhất để có $j$ lái chính khi đã thuê $i$ phi công đầu tiên (vòng for(i) vẫn chạy ở ngoài, chỉ là bỏ $[i]$ khỏi $F$).

  • Cơ sở: $F[0] = 0$, các phần tử khác $= 2^{30}$. Đến cuối, khi $i = n$, thì $F[n/2]$ là đáp án.
  • Thứ tự tính: vòng for(i) giữ nguyên; bên trong đảo ngược lại: chạy vòng for(j) ngược từ $i/2$ về $1$ trước, xong mới cập nhật $F[0]$.
Công thức 1 chiều
for(int j = i / 2; j >= 1; --j) F[j] = min(F[j - 1] + X[i], F[j] + Y[i]);
F[0] += Y[i];

Vì $j$ chạy ngược nên lúc tính $F[j]$ thì $F[j-1]$ chưa được xét ở vòng $i$ này, bản chất vẫn là $F[i-1][j-1]$. Còn $F[j]$ đang xét nhưng chưa cập nhật, bản chất là $F[i-1][j]$. $F[0]$ cập nhật cuối cùng nên lúc tính $F[1]$ nó vẫn là $F[i-1][0]$. Vậy công thức hoàn toàn đúng.

Source code để các bạn tham khảo nếu làm theo hướng này:

#include<bits/stdc++.h>
#define endl '\n'

using namespace std;
typedef long long ll;

const int nMax = (int)1e4 + 5;
int X[nMax], Y[nMax], f[nMax / 2];

int main() {
    ios::sync_with_stdio(0);
    cin.tie(0);
    cout.tie(0);
    int n;
    cin >> n;
    f[0] = 0;
    fill(&f[1], &f[(n / 2) + 1], (1<<30));
    for(int i = 1; i <= n; ++i) {
        cin >> X[i] >> Y[i];
    }
    for(int i = 1; i <= n; ++i) {
        for(int j = i / 2; j >= 1; --j) {
            f[j] = min(f[j - 1] + X[i], f[j] + Y[i]);
        }
        f[0] += Y[i];
    }
    cout << f[n / 2];
    return 0;
}
import sys
INF = 1 << 30

data = sys.stdin.read().split()
n = int(data[0])
X = [0] * (n + 1)
Y = [0] * (n + 1)
for i in range(1, n + 1):
    X[i] = int(data[2 * i - 1])
    Y[i] = int(data[2 * i])
f = [INF] * (n // 2 + 1)
f[0] = 0
for i in range(1, n + 1):
    x, y = X[i], Y[i]
    for j in range(i // 2, 0, -1):
        f[j] = min(f[j - 1] + x, f[j] + y)
    f[0] += y
print(f[n // 2])
C++▶ Chạy tay từng dòng codeCách 4: một mảng f[0..n/2]; mỗi phi công i cập nhật j từ i/2 về 1, rồi f[0] += Y[i].
Dữ liệu mẫu:
1#include<bits/stdc++.h>
2#define endl '\n'
3
4using namespace std;
5typedef long long ll;
6
7const int nMax = (int)1e4 + 5;
8int X[nMax], Y[nMax], f[nMax / 2];
9
10int main() {
11 ios::sync_with_stdio(0);
12 cin.tie(0);
13 cout.tie(0);
14 int n;
15 cin >> n;
16 f[0] = 0;
17 fill(&f[1], &f[(n / 2) + 1], (1<<30));
18 for(int i = 1; i <= n; ++i) {
19 cin >> X[i] >> Y[i];
20 }
21 for(int i = 1; i <= n; ++i) {
22 for(int j = i / 2; j >= 1; --j) {
23 f[j] = min(f[j - 1] + X[i], f[j] + Y[i]);
24 }
25 f[0] += Y[i];
26 }
27 cout << f[n / 2];
28 return 0;
29}

Đánh giá độ phức tạp: thời gian vẫn như cách trên, nhưng không gian chỉ còn $O(n)$ (đúng ra là $O(2n + n/2)$ cho mảng $X$, $Y$, $F$, theo quy tắc Big-O là $O(n)$). Nhờ rút gọn bộ nhớ, và truy cập mảng 1 chiều nhanh hơn mảng 2 chiều, nên code chạy nhanh hơn và ăn ít bộ nhớ hơn rất nhiều lần (mình đo $n = 10^4$: khoảng 0.03 giây, 11 MB). Tuy vậy nhìn bảng xếp hạng thì đây vẫn chưa phải cách nhanh nhất.

Không gian$O(n)$
Thời gian$O(n^2)$

Cách 5Tham lam với hàng đợi ưu tiên – O(N log N)

Cách nhanh nhất là dùng kỹ thuật tham lam (Greedy). Nói nhanh cho bạn nào chưa biết: tham lam là mỗi khi đứng trước nhiều lựa chọn thì chọn cách tốt nhất ở hiện tại rồi đi theo nó; lại gặp nhiều lựa chọn thì lại chọn cái tốt nhất – cứ tốt nhất mà triển, nên mới gọi là tham lam. Nó không có công thức từng bước cụ thể như các thuật toán khác, miễn tư tưởng là luôn chọn theo hướng tốt nhất.

Điều gì khiến ta thuê rẻ hơn? So sánh: thuê $i$ phi công tất cả làm lái chính, với thuê $i$ phi công tất cả làm lái chính trừ phi công $i$ làm lái phụ. Chênh lệch chính là $X[i] - Y[i]$ – đổi phi công $i$ từ lái chính sang lái phụ sẽ giảm đúng lượng tiền đó. Ví dụ sample 1: thuê 2 phi công đầu đều làm lái chính tốn $11000$; bắt phi công 2 làm lái phụ thì còn $11000 - (6000 - 2000) = 7000$; bắt phi công 1 làm lái phụ thì còn $11000 - (5000 - 3000) = 9000$.

Ý tưởng: xét lần lượt từng phi công (từ trẻ đến già):

  • Cứ cho phi công $i$ làm lái chính trước: cộng $X[i]$ vào kết quả, và đẩy độ chênh lệch $X[i] - Y[i]$ vào hàng đợi ưu tiên.
  • Nếu $i$ lẻ: trong $i$ người đầu tiên phải có ít nhất $(i + 1)/2$ lái phụ (để các lái chính sau này có người ghép). Ta cần chọn thêm 1 người làm lái phụ – tham lam chọn người có chênh lệch lớn nhất trong số các lái chính hiện tại và phi công $i$ (chênh lệch càng nhiều thì giảm tiền càng nhiều). Trừ chênh lệch đó khỏi kết quả và bỏ nó ra khỏi hàng đợi.
  • Nếu $i$ chẵn: cứ để nó làm lái chính.

Tại sao được chọn một lái chính lúc trước làm lái phụ? Được chứ: chọn lái chính trước đó làm lái phụ thì cử phi công mới vào thế chỗ làm lái chính, phi công mới đang già nhất mà. Còn nếu chính phi công $i$ có chênh lệch lớn nhất thì nó làm lái phụ.

Để lấy phần tử lớn nhất nhanh, ta dùng priority_queue (hàng đợi ưu tiên) của C++: ở chế độ mặc định phần tử đầu (top()) luôn là phần tử lớn nhất; thêm (push) và xoá (pop) chỉ mất $O(\log N)$, xem phần tử đầu mất $O(1)$. Rất hợp để áp dụng vào bài này.

Minh hoạ: tham lam từng phi công

Các bạn bấm Tiếp: khung xanh dương là lái chính, xanh lá là lái phụ; dòng dưới là hàng đợi ưu tiên (ô vàng là top()). Bước cuối mình ghép cặp lại để các bạn thấy cách chọn của tham lam luôn ghép được hợp lệ:

Ví dụ nhanh:

Cách trên khá khó hình dung, nhưng mình nghĩ các bạn nhìn vào code thì sẽ dễ thấy ý tưởng hơn đó. Source code để các bạn tham khảo:

#include<bits/stdc++.h>
#define endl '\n'

using namespace std;
typedef long long ll;

int main() {
    ios::sync_with_stdio(0);
    cin.tie(0);
    cout.tie(0);
    int n;
    cin >> n;
    priority_queue<int> pq;
    int res = 0;
    for(int i = 1; i <= n; ++i) {
        int x, y;
        cin >> x >> y;
        res += x;
        pq.push(x - y);
        if(i % 2) {
            res -= pq.top();
            pq.pop();
        }
    }
    cout << res;
    return 0;
}
import sys
import heapq

data = sys.stdin.read().split()
n = int(data[0])
pq = []  # heapq là min-heap nên ta lưu số đối (-(x - y)) để lấy được max
res = 0
for i in range(1, n + 1):
    x = int(data[2 * i - 1])
    y = int(data[2 * i])
    res += x
    heapq.heappush(pq, -(x - y))
    if i % 2:
        res -= -heapq.heappop(pq)
print(res)
C++▶ Chạy tay từng dòng codeCách 5: ai cũng làm lái chính trước; ở mỗi i lẻ, đổi người có X - Y lớn nhất thành lái phụ.
Dữ liệu mẫu:
1#include<bits/stdc++.h>
2#define endl '\n'
3
4using namespace std;
5typedef long long ll;
6
7int main() {
8 ios::sync_with_stdio(0);
9 cin.tie(0);
10 cout.tie(0);
11 int n;
12 cin >> n;
13 priority_queue<int> pq;
14 int res = 0;
15 for(int i = 1; i <= n; ++i) {
16 int x, y;
17 cin >> x >> y;
18 res += x;
19 pq.push(x - y);
20 if(i % 2) {
21 res -= pq.top();
22 pq.pop();
23 }
24 }
25 cout << res;
26 return 0;
27}

Giải thích 1 số chỗ trong code ở trên:

  1. priority_queue<int> pq; lưu độ chênh lệch giữa làm lái chính và làm lái phụ của những người đang làm lái chính (và phi công lẻ đang xét – người đang đứng trước lựa chọn bị chọn làm lái phụ hoặc làm lái chính thay cho người khác).
  2. res += x; cứ cho phi công $i$ làm lái chính đã, rồi mới tính chuyện chênh lệch.
  3. if(i % 2) { res -= pq.top(); pq.pop(); }: $i$ lẻ thì chọn ra người lái phụ tiềm năng nhất (chênh lệch lớn nhất) trong những người đang làm lái chính cộng phi công $i$. Nếu người đó chính là phi công $i$ thì đơn giản là phi công $i$ nhận lương lái phụ; nếu không thì phi công $i$ thế chỗ làm lái chính cho người được chọn. Sau đó nhớ xoá chênh lệch đó khỏi pq. Còn $i$ chẵn thì chỉ đẩy chênh lệch vào pq, để nhỡ tương lai sẽ chọn nó làm lái phụ.
  4. Bản Python dùng heapq – là min-heap – nên mình lưu số đối -(x - y) để lấy được phần tử lớn nhất.

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

  • Không gian: bỏ được cả mảng $X$, $Y$ lẫn mảng $f$, chỉ dùng priority_queue chứa tối đa khoảng $n/2$ phần tử: $O(n / 2)$.
  • Thời gian: một vòng for(i), mỗi lần thêm/xoá trên hàng đợi kích thước $n/2$ mất $O(\log(n/2))$, nên tổng $O(n \cdot \log(n / 2))$.
Không gian$O(n / 2)$
Thời gian$O(n \log(n / 2))$
Tham lam này có đúng 100% không?
Lúc đầu mình cũng không dám đảm bảo cách tham lam đúng 100% (như bài đổi tiền đó các bạn), nên mình đã xây dựng tool test, cho chạy so sánh với cách quy hoạch động bằng các test random rất lâu mà không tìm ra trường hợp nào sai. Thật ra có thể chứng minh được: một cách chia vai lái chính/lái phụ ghép được thành các cặp hợp lệ khi và chỉ khi với mọi $i$, trong $i$ phi công trẻ nhất có ít nhất $\lceil i/2 \rceil$ lái phụ (mỗi lái chính cần một lái phụ trẻ hơn chưa ai ghép). Tham lam ở trên chính là đảm bảo điều kiện này tại mỗi $i$ lẻ, mỗi lần chọn người "giảm tiền nhiều nhất" – lập luận đổi chỗ quen thuộc cho thấy không có cách chọn nào tốt hơn. Dù vậy, trong phòng thi mà chưa chắc chắn, các bạn cứ ưu tiên làm cách quy hoạch động để đảm bảo độ chính xác; còn sắp hết giờ mà nghĩ ra cách tham lam thì cứ nộp thôi, biết đâu AC 😁.
Ghi chú của mình: code C++ và Python của cả 5 cách đều cho đúng output của 2 sample, và đã được so với code vét cạn (liệt kê mọi cách ghép cặp hợp lệ) trên 400 bộ test ngẫu nhiên nhỏ ($N \le 10$) – khớp hết; thêm 200 bộ test vừa ($N$ tới 1000) so giữa cách 2, 3, 4, 5 – khớp hết. Với $N = 10^4$: bản C++ cách 2 khoảng 0.4 giây (~195 MB), cách 3 khoảng 0.17 giây (~194 MB), cách 4 khoảng 0.03 giây, cách 5 gần như tức thì. Bản Python: cách 5 khoảng 0.03 giây, cách 4 khoảng 7 giây (TLE), cách 3 khoảng 13 giây và tốn hơn 1 GB bộ nhớ – bản Python của cách 2, 3 chỉ để các bạn tham khảo ý tưởng thôi nha.
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