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 →

Điệu Nhảy Mùa Xuân (DPRCCOW)

Điệu Nhảy Mùa Xuân (DPRCCOW)
Nguồn: VinhDinhCoder Độ khó: Khá Quy hoạch động Đệ quy có nhớ Sắp xếp

Đề bài

DPRCCOW – Điệu nhảy mùa xuân

Đó là điệu nhảy mùa xuân trong một lần hiếm hoi $N$ con bò đực đã được mời nhảy với $M$ con bò cái (miễn là chúng giữ được hành vi tốt nhất của chúng).

Nông dân John, gần như bị ám ảnh trong tổ chức các điệu nhảy của mình, muốn cảnh tượng đó hấp dẫn nhất có thể. Vì vậy, anh ta muốn ghép cặp $N$ bò đực với các đối tác bò cái sao cho tổng số tất cả các khác biệt về chiều cao của các cặp được giảm thiểu. Bò đực có chiều cao $B_i$ và bò cái có chiều cao $C_i$.

Tất nhiên, một số con bò cái sẽ không thể ghép cặp được vì $N \lt M$. Những con bò cái đó ta không tính vào đáp án.

Dữ liệu vào:

  • Dòng 1: Hai số nguyên được phân tách bằng dấu cách: $N$ và $M$.
  • Dòng $2..N + 1$: Dòng $i + 1$ chứa một số nguyên duy nhất: $B_i$.
  • Dòng $N + 2..M + N + 1$: Dòng $i + N + 1$ chứa một số nguyên duy nhất: $C_i$.

Kết quả: Một số nguyên duy nhất là tổng tối thiểu của giá trị tuyệt đối chênh lệch chiều cao.

Ràng buộc: $1 \le N \le 5000$; $N \lt M \le 5000$; $1 \le C_i \le 1\,000\,000$; $1 \le B_i \le 1\,000\,000$.

Ví dụ – input
5 7
10 16 12 10 13
7 17 12 10 9 6 11
Ví dụ – output
4

Ví dụ của trang chấm ghi các chiều cao trên cùng một dòng, còn đề nói mỗi số một dòng – đọc bằng cin / split() thì kiểu nào cũng được.

Lời mở đầu

Bài này là một bài quy hoạch động rất "kinh điển" về ghép cặp hai dãy. Cái khó không nằm ở công thức, mà nằm ở chỗ vì sao được phép sắp xếp rồi ghép theo thứ tự – mình sẽ chứng minh kỹ phần này cho các bạn. Sau đó là đủ bộ: vét cạn → đệ quy có nhớ → bottom up → rút gọn còn mảng 1 chiều. Hy vọng mang đến được giá trị cho các bạn 😇.

Trước tiên mình tóm tắt lại đề cho đơn giản. Cho $n \lt m$; chiều cao của $n$ con bò đực là $a[1..n]$ (đề gọi là $B_i$), chiều cao của $m$ con bò cái là $b[1..m]$ (đề gọi là $C_i$). Độ chênh lệch của một cặp (bò đực $i$, bò cái $j$) là $|a[i] - b[j]|$. Hãy tạo ra $n$ cặp (mỗi con bò đực một bạn nhảy, mỗi con bò cái nhảy với tối đa một con) sao cho tổng độ chênh lệch nhỏ nhất. Những con bò cái bị thừa thì không tính.

Với ví dụ: $a = \{10, 16, 12, 10, 13\}$, $b = \{7, 17, 12, 10, 9, 6, 11\}$. Cách ghép tối ưu là:

(10, 10)(10, 9)(12, 11)(13, 12)(16, 17)

Tổng chênh lệch $= 0 + 1 + 1 + 1 + 1 = 4$. Hai con bò cái cao $6$ và $7$ bị thừa. Vậy output là $4$.

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 1Sắp xếp rồi vét cạn bằng đệ quy

Vét cạn "thô" thì quá nhiều trường hợp

Cách đơn giản nhất là thử mọi trường hợp: chọn ra $n$ con bò cái trong $m$ con – có $C_m^n$ (tổ hợp chập $n$ của $m$) cách, rồi còn phải sắp xếp $n$ con được chọn để ghép với từng con bò đực – nhân thêm $n!$. Tổng cộng chính là chỉnh hợp chập $n$ của $m$: $A_m^n = \dfrac{m!}{(m - n)!}$. Giả sử $m = 5000$, $n = 2500$ thì có $5000 \cdot 4999 \cdots 2501$ trường hợp – không thể nào chạy kịp, mà code cũng khá phức tạp. Nên ta tới bản cải tiến.

Sắp xếp cả 2 dãy – và chứng minh vì sao được

Ta sắp xếp cả 2 mảng $a$ và $b$ tăng dần. Giả sử ta đã chọn cặp $(a[i], b[j])$. Khi xét tới con bò đực tiếp theo $a[i + 1]$, nó có thể ghép với bất kỳ con bò cái nào chưa được chọn. Nhưng mình sẽ chứng minh: chỉ cần xét các con bò cái từ $j + 1$ tới $m$, không cần xét các con từ $1$ tới $j - 1$. Theo trực giác thì "bé đi với bé, lớn đi với lớn", nhưng nhiều lúc trực giác không đúng nên ta chứng minh cho chắc nha.

Đặt $A = a[i]$, $B = b[j]$, $C = a[i + 1]$, $D = b[j + 1]$, $E = b[j - 1]$. Ta đang chọn cặp $(A, B)$, và lưu ý là trước đó ta đã từng thử trường hợp chọn cặp $(A, E)$ rồi. Lấy $E$ vì nó là con bò cái cao nhất mà vẫn thấp hơn $B$. Câu hỏi là: sau $(A, B)$ có cần thử $(C, E)$ không? Nếu chọn $(A, B)$ rồi $(C, E)$ thì tổng là $AB + CE$; còn trường hợp cũ $(A, E)$ rồi $(C, B)$ thì tổng là $AE + CB$ (độ chênh lệch của cặp $(X, Y)$ = độ dài đoạn $XY$ trên trục số). Ta sẽ thấy luôn có $AB + CE \ge AE + CB$:

Trường hợp 1: B < A (nhớ $C \gt A$ vì $a$ tăng dần). Để $(C, ?)$ tối ưu thì $?$ nên lớn hơn $B$, con đầu tiên cao hơn $B$ chính là $D$. Còn nếu $CE \lt CD$ như hình dưới:
-------E--B---A--C---------------------D----
thì $AB + CE = AE + CB$ (cả hai vế đều bằng $A + C - B - E$) – chọn $(C, E)$ chẳng khác gì trường hợp cũ đã làm, nên khỏi xét.
Trường hợp 2: B = A (hai điểm trùng nhau, viết là AB)
---------------E--AB---------C-------------D-
Trường hợp cũ $(A, E)$ rồi $(C, B)$: tổng $= AE + CA = CE$. Bây giờ $(A, B)$ rồi $(C, E)$: tổng $= 0 + CE = CE$. Như nhau, nên vẫn khỏi xét.
Trường hợp 3: B > A
3.1 C nằm giữa A và B: E giữa C, B: ----------A----C---E---B----- AB + CE = AE + CB E giữa A, C: ----------A-E--C-------B----- AB + CE > AE + CB E trước A: -------E--A----C-------B----- AB + CE > AE + CB 3.2 C nằm trên B: E giữa A, B: ----------A---E---B---C------ AB + CE > AE + CB E trước A: ------E---A-------B---C------ AB + CE > AE + CB
Trường hợp nào cũng vậy: chọn $(C, E)$ sau $(A, B)$ hoặc là giống hệt trường hợp đã làm, hoặc còn tệ hơn.

$E$ là con bò cái gần $B$ nhất mà còn không cần xét, thì những con thấp hơn nữa (thấp hơn $E$) tất nhiên xét cũng bị thừa. Kết luận:

Nhận xét then chốt
Sắp xếp $a$ và $b$ cùng chiều. Nếu đã chọn cặp $(i, j)$ thì cặp tiếp theo chỉ cần xét từ $(i + 1, j + 1)$ trở đi. Nói cách khác: các con bò đực theo thứ tự sẽ ghép với một dãy con tăng dần các con bò cái.

Hàm đệ quy Try(n, m)

Gọi Try(n, m) trả về độ chênh lệch tối thiểu khi ghép cặp $n$ con bò đực đầu tiên với $m$ con bò cái đầu tiên (ghép hết $n$ con bò đực).

Điều kiện dừng:

  1. $n = 0$: hết bò đực để ghép, tức đã ghép xong nhiệm vụ → trả về $0$. Code: if(n == 0) return 0;
  2. $m \lt n$: số bò cái còn lại không đủ cho số bò đực – trường hợp không hợp lệ. Hàm tìm min nên ta trả về một giá trị rất to để nó không bao giờ được chọn. To cỡ nào? Tối đa $5000$ cặp, mỗi cặp chênh lệch chưa tới $10^6$, tổng chưa tới $5 \cdot 10^9$. Vậy cần trả về một số $\gt 5 \cdot 10^9$, mình chọn $10^{15}$: if(m < n) return (ll)1e15; (anh em thích số nào $\gt 10^{10}$ đều được).
Đừng return LLONG_MAX
Ở những chỗ này nhiều bạn hay return LLONG_MAX (giá trị lớn nhất của long long, $2^{63} - 1$). Làm thế là tràn số ngay, vì ở phần xử lý ta còn cộng thêm: Try(...) + abs(...). Số đã chạm trần mà cộng thêm là tràn. Nên chọn số to vừa đủ như $10^{15}$.

Xử lý chạy: ở đây ta đang xét cặp (bò đực thứ $n$, bò cái thứ $m$) – tức là đi ngược từ cuối về đầu. Có ảnh hưởng tới điều vừa chứng minh không? Không nha: ta chỉ cần sắp xếp 2 mảng giảm dần. Sắp giảm dần rồi duyệt từ cuối về đầu thì khác gì sắp tăng dần rồi duyệt từ đầu về cuối đâu. Khi đó ta có 2 lựa chọn:

  1. Chọn con bò cái thứ $m$ ghép với con bò đực thứ $n$: cặp tiếp theo xét là $(n - 1, m - 1)$, kết quả $=$ Try(n - 1, m - 1) + (ll)abs(a[n] - b[m]) (ép kiểu long long cho chắc).
  2. Không chọn con bò cái thứ $m$: sang con bò cái thứ $m - 1$, kết quả $=$ Try(n, m - 1).
Đệ quy
return min(Try(n - 1, m - 1) + (ll)abs(a[n] - b[m]), Try(n, m - 1));

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 = 5005;
ll a[nMax], b[nMax];

ll Try(int n, int m) {
    if(n == 0) {
        return 0;
    }
    if(m < n) {
        return (ll)1e15;
    }
    return min(Try(n - 1, m - 1) + (ll)abs(a[n] - b[m]), Try(n, m - 1));
}

int main() {
    // freopen("dprccow.inp", "r", stdin);
    // freopen("dprccow.out", "w", stdout);
    ios_base::sync_with_stdio(0);
    cin.tie(0);

    int n, m;
    cin >> n >> m;
    for(int i = 1; i <= n; ++i) {
        cin >> a[i];
    }
    for(int i = 1; i <= m; ++i) {
        cin >> b[i];
    }
    sort(a + 1, a + n + 1, greater<ll>());
    sort(b + 1, b + m + 1, greater<ll>());
    cout << Try(n, m);
    return 0;
}
import sys
sys.setrecursionlimit(20000)

def Try(n, m):
    if n == 0:
        return 0
    if m < n:
        return 10 ** 15
    return min(Try(n - 1, m - 1) + abs(a[n] - b[m]), Try(n, m - 1))

data = sys.stdin.read().split()
n, m = int(data[0]), int(data[1])
a = [0] + sorted(map(int, data[2:2 + n]), reverse=True)
b = [0] + sorted(map(int, data[2 + n:2 + n + m]), reverse=True)
print(Try(n, m))

Giải thích 1 số chỗ trong code:

  1. const int nMax = 5005; là giá trị lớn nhất của $n$, $m$; mình hay $+5$ khi khai báo mảng cho chắc.
  2. sort(a + 1, a + n + 1, greater<ll>()); sắp xếp $a[1..n]$ giảm dần. Tham số greater<ll>() giúp sort giảm dần; ae nào chưa biết thì có thể tự viết hàm bool comp cũng được nha.
  3. Gọi Try(n, m) vì ta cần tổng chênh lệch tối thiểu khi ghép $n$ con bò đực đầu tiên với $m$ con bò cái đầu tiên.
  4. Code gốc của mình có 2 dòng freopen("dprccow.inp", ...) / freopen("dprccow.out", ...) để đọc ghi file. Nộp trên CLUE OJ (đọc/ghi chuẩn) thì để chúng ở dạng chú thích như trên; thi offline yêu cầu file thì bỏ dấu // đi nhé.

Các bạn bấm Tiếp để xem cây đệ quy được mở ra thế nào – để ý có những Try(n, m) bị gọi đi gọi lại nhiều lần:

C++▶ Chạy tay từng dòng codeCách 1: sắp xếp giảm dần, Try(n, m) = min(chọn bò cái m, bỏ bò cái m).
Dữ liệu mẫu:
1#include <bits/stdc++.h>
2#define endl '\n'
3using namespace std;
4typedef long long ll;
5
6const int nMax = 5005;
7ll a[nMax], b[nMax];
8
9ll Try(int n, int m) {
10 if(n == 0) {
11 return 0;
12 }
13 if(m < n) {
14 return (ll)1e15;
15 }
16 return min(Try(n - 1, m - 1) + (ll)abs(a[n] - b[m]), Try(n, m - 1));
17}
18
19int main() {
20 // freopen("dprccow.inp", "r", stdin);
21 // freopen("dprccow.out", "w", stdout);
22 ios_base::sync_with_stdio(0);
23 cin.tie(0);
24
25 int n, m;
26 cin >> n >> m;
27 for(int i = 1; i <= n; ++i) {
28 cin >> a[i];
29 }
30 for(int i = 1; i <= m; ++i) {
31 cin >> b[i];
32 }
33 sort(a + 1, a + n + 1, greater<ll>());
34 sort(b + 1, b + m + 1, greater<ll>());
35 cout << Try(n, m);
36 return 0;
37}

Đánh giá độ phức tạp của cách làm này: mỗi lời gọi tách thành 2 nhánh, số lời gọi tăng theo hàm mũ (cỡ số cách chọn $n$ con bò cái trong $m$ con, $C_m^n$). Mình đo thử: $n = 12$, $m = 24$ chạy rất nhanh, nhưng $n = 20$, $m = 40$ thì chạy hơn 2 phút vẫn chưa xong.

Không gian$O(N + M)$
Thời giancỡ $O(C_M^N)$ – hàm mũ
TLE từ test 12 trở đi
Nộp cách này thì bị TLE từ test 12 trở đi. Tuy nhiên các bạn vẫn nên cài đặt nó, vừa để 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ả), vừa vì biết đâu ta cải tiến được nó thành quy hoạch động top down – và đúng là được thật 😉.

Cách 2Đệ quy có nhớ (quy hoạch động top down) – O(N·M)

Bạn nào code được phần vét cạn kia thì chỉ cần cải tiến thêm vài dòng là có thể AC bài này rồi. Code vét cạn chạy lâu vì gặp những trường hợp đã tính rồi lại đi tính lại (các bạn thấy ở khung chạy tay trên đó). Để giải quyết, ta tạo một bảng nhớ lưu lại kết quả đã tính.

Nhìn vào hàm Try(): đầu vào có 2 biến $n$ và $m$, cả 2 đều thay đổi mỗi lần gọi đệ quy. Vậy ta tạo mảng 2 chiều f với f[n][m] = Try(n, m). $n$, $m$ tối đa $5000$ nên khai báo long long f[nMax][nMax]; (mảng toàn cục để tránh tràn stack).

  • Lấp đầy mảng f bằng $-1$ – giá trị mà Try không bao giờ tạo ra. f[n][m] != -1 nghĩa là đã tính rồi → return f[n][m]; (đặt ngay sau điều kiện dừng).
  • Nếu f[n][m] == -1 thì tính, gán rồi trả về: return f[n][m] = min(Try(n - 1, m - 1) + (ll)abs(a[n] - b[m]), Try(n, m - 1));
  • Hàng $n = 0$ thì Try(0, m) luôn bằng $0$ (và còn bị điều kiện dừng chặn trước), mảng toàn cục sẵn bằng $0$ rồi, nên chỉ cần gán từ f[1][0] tới f[n][m]: fill(&f[1][0], &f[n][m + 1], -1);
Vì sao là &f[n][m + 1]?
Hàm fill gán trên đoạn $[$đầu, cuối$)$ – ngoặc vuông là có tính phần tử mút, ngoặc tròn là không. Nên mút cuối phải là phần tử ngay sau f[n][m], chính là f[n][m + 1]. (Mảng 2 chiều nằm liên tiếp trong bộ nhớ theo từng hàng, nên đoạn này phủ hết các hàng $1..n$ – hàng $n$ thì tới cột $m$.)

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 = 5005;
ll a[nMax], b[nMax];
ll f[nMax][nMax];

ll Try(int n, int m) {
    if(n == 0) {
        return 0;
    }
    if(m < n) {
        return (ll)1e15;
    }
    if(f[n][m] != -1) {
        return f[n][m];
    }
    return f[n][m] = min(Try(n - 1, m - 1) + (ll)abs(a[n] - b[m]), Try(n, m - 1));
}

int main() {
    // freopen("dprccow.inp", "r", stdin);
    // freopen("dprccow.out", "w", stdout);
    ios_base::sync_with_stdio(0);
    cin.tie(0);

    int n, m;
    cin >> n >> m;
    fill(&f[1][0], &f[n][m + 1], -1);
    for(int i = 1; i <= n; ++i) {
        cin >> a[i];
    }
    for(int i = 1; i <= m; ++i) {
        cin >> b[i];
    }
    sort(a + 1, a + n + 1, greater<ll>());
    sort(b + 1, b + m + 1, greater<ll>());
    cout << Try(n, m);
    return 0;
}
import sys
sys.setrecursionlimit(20000)

def Try(n, m):
    if n == 0:
        return 0
    if m < n:
        return 10 ** 15
    if f[n][m] != -1:
        return f[n][m]
    f[n][m] = min(Try(n - 1, m - 1) + abs(a[n] - b[m]), Try(n, m - 1))
    return f[n][m]

data = sys.stdin.read().split()
n, m = int(data[0]), int(data[1])
a = [0] + sorted(map(int, data[2:2 + n]), reverse=True)
b = [0] + sorted(map(int, data[2 + n:2 + n + m]), reverse=True)
f = [[-1] * (m + 1) for _ in range(n + 1)]
print(Try(n, m))
C++▶ Chạy tay từng dòng codeCách 2: như cách 1, thêm bảng nhớ f[n][m] (-1 = chưa tính).
Dữ liệu mẫu:
1#include <bits/stdc++.h>
2#define endl '\n'
3using namespace std;
4typedef long long ll;
5
6const int nMax = 5005;
7ll a[nMax], b[nMax];
8ll f[nMax][nMax];
9
10ll Try(int n, int m) {
11 if(n == 0) {
12 return 0;
13 }
14 if(m < n) {
15 return (ll)1e15;
16 }
17 if(f[n][m] != -1) {
18 return f[n][m];
19 }
20 return f[n][m] = min(Try(n - 1, m - 1) + (ll)abs(a[n] - b[m]), Try(n, m - 1));
21}
22
23int main() {
24 // freopen("dprccow.inp", "r", stdin);
25 // freopen("dprccow.out", "w", stdout);
26 ios_base::sync_with_stdio(0);
27 cin.tie(0);
28
29 int n, m;
30 cin >> n >> m;
31 fill(&f[1][0], &f[n][m + 1], -1);
32 for(int i = 1; i <= n; ++i) {
33 cin >> a[i];
34 }
35 for(int i = 1; i <= m; ++i) {
36 cin >> b[i];
37 }
38 sort(a + 1, a + n + 1, greater<ll>());
39 sort(b + 1, b + m + 1, greater<ll>());
40 cout << Try(n, m);
41 return 0;
42}

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

  • Không gian: $O(N \cdot M)$. Thực chất là $O(N \cdot M + N + M)$ vì còn 2 mảng $a$, $b$, nhưng so với mảng $f$ thì không đáng kể.
  • Thời gian: $O(N \cdot M)$ – trường hợp xấu nhất ta lấp đầy toàn bộ mảng $f$, mỗi ô tính 1 lần với $O(1)$. Tức khoảng $5000 \cdot 5000 = 2.5 \cdot 10^7$ phép tính, dư sức chạy dưới 1 giây. Vậy là AC 🎉.
Không gian$O(N \cdot M)$
Thời gian$O(N \cdot M)$
Để ý bộ nhớ
Mảng long long f[5005][5005] chiếm khoảng $5005 \cdot 5005 \cdot 8 \approx 200$ MB. Mình đo với $N = 2500$, $M = 5000$ thì chương trình dùng khoảng 194 MB. Nếu hệ thống chấm cho bộ nhớ ít hơn thì các bạn dùng cách 4 (mảng 1 chiều) nhé.

Phương pháp trên được gọi là quy hoạch động top down – đệ quy có nhớ. Để làm được thì mấu chốt là anh em phải xem biến nào thay đổi khi ta gọi đệ quy.

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

Chúng ta dp top down được thì cũng dp bottom up được – tức là tạo ra công thức truy hồi. Cho ae nào đã biết cơ bản về quy hoạch động rồi thì ta có 4 bước:

Bước 1 – Bài toán con: gọi $F[i][j]$ là tổng độ chênh lệch tối thiểu khi ghép cặp $i$ con bò đực đầu tiên với $j$ con bò cái đầu tiên.

Bước 2 – Bài toán cơ sở: $i = 0$ (không có con bò đực nào) thì $F[0][j] = 0$ với $0 \le j \le m$ – mảng toàn cục sẵn bằng $0$. Còn các trường hợp $j \lt i$ (thiếu bò cái) là không hợp lệ, gán $10^{15}$. Cho tiện ta gán toàn bộ (trừ hàng $0$) bằng $10^{15}$: fill(&f[1][0], &f[n][m + 1], (ll)1e15);

Bước 3 – Đáp án: hiển nhiên là $F[n][m]$.

Bước 4 – Công thức truy hồi: chuyển thẳng từ hàm Try. Xét con bò đực thứ $i$ và con bò cái thứ $j$ ($i \ne 0$, $j \ge i$):

  1. Chọn con bò cái thứ $j$ ghép với con bò đực thứ $i$: trước đó chỉ còn $i - 1$ con bò đực và $j - 1$ con bò cái, nên được $F[i - 1][j - 1] + |a[i] - b[j]|$.
  2. Không chọn con bò cái thứ $j$: kết quả bằng lần trước đó – khi xét $i$ con bò đực và $j - 1$ con bò cái: $F[i][j - 1]$.
Công thức truy hồi
$F[i][j] = \min\big(F[i-1][j-1] + |a[i] - b[j]|,\ F[i][j-1]\big)$  với $1 \le i \le n$, $i \le j \le m$.

Giờ ta duyệt xuôi ($i$ con bò đực đầu, $j$ con bò cái đầu, từ đầu về cuối) nên cho chắc thì sắp xếp 2 mảng tăng dần. Vòng for(i) chạy từ $1$ tới $n$ (tránh $i = 0$), vòng for(j) chạy từ $i$ tới $m$ (tránh $j \lt i$).

Minh hoạ bảng F

Các bạn nhập 2 dãy rồi bấm Tiếp để xem từng ô $F[i][j]$ được tính: ô viền xanh lá là $F[i-1][j-1]$ (lựa chọn ghép), ô viền xanh dương là $F[i][j-1]$ (lựa chọn bỏ qua). Cuối cùng mình lần ngược để chỉ ra các cặp được ghép:

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 = 5005;
ll a[nMax], b[nMax];
ll f[nMax][nMax];

int main() {
    // freopen("dprccow.inp", "r", stdin);
    // freopen("dprccow.out", "w", stdout);
    ios_base::sync_with_stdio(0);
    cin.tie(0);

    int n, m;
    cin >> n >> m;
    for(int i = 1; i <= n; ++i) {
        cin >> a[i];
    }
    for(int i = 1; i <= m; ++i) {
        cin >> b[i];
    }
    sort(a + 1, a + n + 1);
    sort(b + 1, b + m + 1);
    fill(&f[1][0], &f[n][m + 1], (ll)1e15);
    for(int i = 1; i <= n; ++i) {
        for(int j = i; j <= m; ++j) {
            f[i][j] = min(f[i - 1][j - 1] + (ll)abs(a[i] - b[j]), f[i][j - 1]);
        }
    }
    cout << f[n][m];
    return 0;
}
import sys

data = sys.stdin.read().split()
n, m = int(data[0]), int(data[1])
a = [0] + sorted(map(int, data[2:2 + n]))
b = [0] + sorted(map(int, data[2 + n:2 + n + m]))
INF = 10 ** 15
f = [[0] * (m + 1)] + [[INF] * (m + 1) for _ in range(n)]
for i in range(1, n + 1):
    fi, fp, ai = f[i], f[i - 1], a[i]
    for j in range(i, m + 1):
        fi[j] = min(fp[j - 1] + abs(ai - b[j]), fi[j - 1])
print(f[n][m])
C++▶ Chạy tay từng dòng codeCách 3: sắp xếp tăng dần, f[i][j] = min(f[i-1][j-1] + |a[i] - b[j]|, f[i][j-1]).
Dữ liệu mẫu:
1#include <bits/stdc++.h>
2#define endl '\n'
3using namespace std;
4typedef long long ll;
5
6const int nMax = 5005;
7ll a[nMax], b[nMax];
8ll f[nMax][nMax];
9
10int main() {
11 // freopen("dprccow.inp", "r", stdin);
12 // freopen("dprccow.out", "w", stdout);
13 ios_base::sync_with_stdio(0);
14 cin.tie(0);
15
16 int n, m;
17 cin >> n >> m;
18 for(int i = 1; i <= n; ++i) {
19 cin >> a[i];
20 }
21 for(int i = 1; i <= m; ++i) {
22 cin >> b[i];
23 }
24 sort(a + 1, a + n + 1);
25 sort(b + 1, b + m + 1);
26 fill(&f[1][0], &f[n][m + 1], (ll)1e15);
27 for(int i = 1; i <= n; ++i) {
28 for(int j = i; j <= m; ++j) {
29 f[i][j] = min(f[i - 1][j - 1] + (ll)abs(a[i] - b[j]), f[i][j - 1]);
30 }
31 }
32 cout << f[n][m];
33 return 0;
34}

Đánh giá độ phức tạp: cả không gian lẫn thời gian đều như cách top down, $O(N \cdot M)$. Nhưng trớ trêu ở chỗ: dù không phải tốn thời gian và bộ nhớ cho việc gọi đệ quy, cách bottom up này khi mình nộp lại còn chạy lâu hơn cả cách top down 😅 (thời gian đo trên hệ thống chấm có dao động; mình chạy thử trên máy thì cách 3 lại nhanh hơn cách 2 một chút). Tuy vậy vẫn AC, và nhờ nó ta cải tiến được thành một cách ăn ít bộ nhớ và chạy nhanh hơn nữa ở ngay dưới đây.

Không gian$O(N \cdot M)$
Thời gian$O(N \cdot M)$

Cách 4Rút gọn còn mảng 1 chiều – tham khảo

Phần này tham khảo thôi nha ae, sẽ khá khó hiểu đó. Ta giảm mảng 2 chiều $F[i][j]$ về mảng 1 chiều $F[i]$ (tổng chênh lệch tối thiểu khi xét $i$ con bò đực), đáp án là $F[n]$. Công thức

F[i][j] = min(F[i - 1][j - 1] + |a[i] - b[j]|, F[i][j - 1])

trở thành

F[i] = min(F[i], F[i - 1] + |a[i] - b[j]|)

Đã bỏ $[j]$ đi thì ta phải đẩy vòng for(j) ra ngoài – ngầm hiểu là với mỗi con bò cái, nó đi ghép thử với mọi con bò đực có thể ghép – còn vòng for(i) bị đẩy vào trong và phải chạy ngược để tránh bị trùng. Vì sao chạy ngược? Khi tính $F[i]$ ở lượt $j$, ta cần $F[i - 1]$ của lượt $j - 1$ (chính là $F[i-1][j-1]$). Chạy $i$ giảm dần thì lúc tính $F[i]$, ô $F[i - 1]$ chưa bị ghi đè ở lượt $j$, nên nó vẫn là giá trị cũ. Còn $F[i]$ lúc chưa gán chính là $F[i][j - 1]$.

Chạy ngược từ đâu? Con bò đực thứ $i$ chỉ ghép được với các con bò cái $j = i, i + 1, \ldots, m$. Nghĩ ngược lại: con bò cái thứ $j$ chỉ ghép được với các con bò đực từ $1$ tới $j$ (ví dụ con bò cái thứ $1$ chỉ đi với con bò đực thứ $1$; con bò cái thứ $5$ đi được với bò đực $1..5$, bò đực thứ $6$ tối thiểu phải đi với con cái thứ $6$). Mà chỉ có $n$ con bò đực, nên vòng for(i) chạy ngược từ $\min(j, n)$ về $1$.

Trường hợp cơ sở chỉ còn $F[0] = 0$, còn lại gán $10^{15}$: fill(&f[1], &f[n + 1], (ll)1e15);. Thứ tự duyệt ngược của for(i) không ảnh hưởng gì tới việc sắp xếp, bản chất vẫn là công thức truy hồi cũ, nên vẫn sắp xếp 2 mảng tăng dần. Khó hình dung thì các bạn bấm chạy tay bên dưới, để ý mảng f sau mỗi vòng $j$ chính là một cột của bảng $F$ ở cách 3.

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 = 5005;
ll a[nMax], b[nMax];
ll f[nMax];

int main() {
    // freopen("dprccow.inp", "r", stdin);
    // freopen("dprccow.out", "w", stdout);
    ios_base::sync_with_stdio(0);
    cin.tie(0);

    int n, m;
    cin >> n >> m;
    for(int i = 1; i <= n; ++i) {
        cin >> a[i];
    }
    for(int i = 1; i <= m; ++i) {
        cin >> b[i];
    }
    sort(a + 1, a + n + 1);
    sort(b + 1, b + m + 1);
    fill(&f[1], &f[n + 1], (ll)1e15);
    for(int j = 1; j <= m; ++j) {
        for(int i = min(j, n); i >= 1; --i) {
            f[i] = min(f[i], f[i - 1] + (ll)abs(a[i] - b[j]));
        }
    }
    cout << f[n];
    return 0;
}
import sys

data = sys.stdin.read().split()
n, m = int(data[0]), int(data[1])
a = [0] + sorted(map(int, data[2:2 + n]))
b = [0] + sorted(map(int, data[2 + n:2 + n + m]))
INF = 10 ** 15
f = [0] + [INF] * n
for j in range(1, m + 1):
    bj = b[j]
    for i in range(min(j, n), 0, -1):
        f[i] = min(f[i], f[i - 1] + abs(a[i] - bj))
print(f[n])
C++▶ Chạy tay từng dòng codeCách 4: vòng j ở ngoài, vòng i chạy ngược từ min(j, n) về 1 trên mảng f 1 chiều.
Dữ liệu mẫu:
1#include <bits/stdc++.h>
2#define endl '\n'
3using namespace std;
4typedef long long ll;
5
6const int nMax = 5005;
7ll a[nMax], b[nMax];
8ll f[nMax];
9
10int main() {
11 // freopen("dprccow.inp", "r", stdin);
12 // freopen("dprccow.out", "w", stdout);
13 ios_base::sync_with_stdio(0);
14 cin.tie(0);
15
16 int n, m;
17 cin >> n >> m;
18 for(int i = 1; i <= n; ++i) {
19 cin >> a[i];
20 }
21 for(int i = 1; i <= m; ++i) {
22 cin >> b[i];
23 }
24 sort(a + 1, a + n + 1);
25 sort(b + 1, b + m + 1);
26 fill(&f[1], &f[n + 1], (ll)1e15);
27 for(int j = 1; j <= m; ++j) {
28 for(int i = min(j, n); i >= 1; --i) {
29 f[i] = min(f[i], f[i - 1] + (ll)abs(a[i] - b[j]));
30 }
31 }
32 cout << f[n];
33 return 0;
34}

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

  • Không gian: $O(M)$. Thực chất là $O(N + M + N)$ cho 2 mảng $a$, $b$ và mảng $f$; với $n = 4999$, $m = 5000$ thì xấp xỉ $3 \cdot m$, theo quy tắc big O là $O(M)$. Ăn ít bộ nhớ hơn rất nhiều rồi.
  • Thời gian: vẫn $O(N \cdot M)$, nhưng mảng 1 chiều nhỏ nằm gọn trong bộ nhớ đệm nên truy xuất nhanh hơn, code chạy nhanh hơn hẳn.
Không gian$O(M)$
Thời gian$O(N \cdot M)$
Bạn nào code Python để ý
Với $N = 2500$, $M = 5000$ (khoảng $9.4$ triệu lần cập nhật) bản Python của cách 4 chạy mất khoảng 4.6 giây, bản Python cách 3 tốn thêm khoảng 200 MB cho bảng 2 chiều; còn bản Python của cách 2 (đệ quy có nhớ) thì đệ quy quá sâu và quá nhiều lời gọi, chỉ dùng để tham khảo với dữ liệu nhỏ. Bài này nên nộp bằng C++.
Ghi chú của mình: cả 4 code C++ và 4 code Python ở trên đều cho đúng output của ví dụ đề, và đã được so với code vét cạn (thử mọi cách chọn và xếp $n$ con bò cái – chỉnh hợp chập $n$ của $m$) trên 400 bộ test ngẫu nhiên nhỏ ($n \le 5$, $m \le 7$, chiều cao tới $10^6$) – khớp hết. Với $N = 2500$, $M = 5000$: cách 2 (C++) chạy khoảng 0.2 giây, cách 3 khoảng 0.08 giây, cách 4 khoảng 0.03 giây; cách 2 và 3 dùng khoảng 194 MB bộ nhớ, cách 4 chỉ khoảng 11 MB.
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