Chia để trị
Chia để trị (divide and conquer) là kỹ thuật giải một bài toán bằng cách tách nó thành các bài toán con cùng dạng nhỏ hơn, giải từng bài con (thường bằng đệ quy), rồi kết hợp lời giải con thành lời giải cho bài gốc. Rất nhiều thuật toán nền tảng — sắp xếp trộn, tìm kiếm nhị phân, lũy thừa nhanh, đếm nghịch thế, nhân đa thức Karatsuba — đều là chia để trị, và nhờ nó mà độ phức tạp tụt từ xuống , hoặc từ xuống .
Một thuật toán chia để trị gồm ba bước:
- Chia (divide): tách bài toán kích thước thành bài con kích thước .
- Trị (conquer): giải mỗi bài con — thường gọi đệ quy; khi bài con đủ nhỏ (trường hợp cơ sở) thì giải trực tiếp.
- Kết hợp (combine): ghép kết quả các bài con thành kết quả bài gốc.
Ý tưởng / Trực giác
Vì sao chia để trị nhanh hơn cách ngây thơ? Mấu chốt nằm ở chỗ chi phí được phân tán trên một cây đệ quy có chiều cao chỉ .
Khi ta chia đôi liên tục, sau lần kích thước còn ; kích thước chạm khi . Cây đệ quy do đó chỉ cao tầng thay vì tầng như đệ quy giảm-một-đơn-vị. Nếu mỗi tầng tốn tổng cộng để chia và kết hợp, toàn bộ chỉ tốn .
Điều khiến kỹ thuật này đúng là tính chất "bài con độc lập và cùng dạng": lời giải của nửa trái không phụ thuộc vào nửa phải, nên ta giải riêng rồi mới gộp. Phần "kết hợp" chính là nơi chứa toàn bộ sự thông minh:
- Với merge sort, kết hợp = trộn hai dãy đã sắp xếp trong (con trỏ chạy thẳng, không quay lui).
- Với đếm nghịch thế, ngay trong bước trộn ta tranh thủ đếm luôn các cặp nghịch thế bắc cầu giữa hai nửa.
- Với lũy thừa nhanh, ta khai thác để chỉ cần một bài con thay vì nhân lần.
Master Theorem — công cụ tính độ phức tạp
Với công thức đệ quy dạng (có bài con kích thước , chi phí chia+kết hợp là ):
| Điều kiện | Độ phức tạp | Trực giác |
|---|---|---|
| tầng trên cùng (gốc) chiếm phần lớn chi phí | ||
| mọi tầng tốn như nhau, có tầng | ||
| số lá ở đáy cây chiếm phần lớn chi phí |
Ví dụ merge sort: , , , .
Ví dụ chạy tay: Merge Sort trên [5, 2, 4, 1, 3]
Pha chia — cắt đôi đệ quy đến khi mỗi đoạn còn 1 phần tử:
[5, 2, 4, 1, 3]
/ \
[5, 2] [4, 1, 3]
/ \ / \
[5] [2] [4] [1, 3]
/ \
[1] [3]
Pha trị + kết hợp — đi từ dưới lên, trộn các đoạn đã sắp xếp:
[5] + [2] -> [2, 5]
[1] + [3] -> [1, 3]
[4] + [1, 3] -> [1, 3, 4]
[2, 5] + [1, 3, 4] -> ?
Xem chi tiết bước trộn cuối [2, 5] với [1, 3, 4], hai con trỏ i, j chạy thẳng:
trái: [2, 5] phải: [1, 3, 4] kết quả: []
i=0 j=0
2 vs 1 -> lấy 1 (phải) j->1 [1]
2 vs 3 -> lấy 2 (trái) i->1 [1, 2]
5 vs 3 -> lấy 3 (phải) j->2 [1, 2, 3]
5 vs 4 -> lấy 4 (phải) j->3 (hết phải)[1, 2, 3, 4]
còn lại trái: 5 -> đổ nốt [1, 2, 3, 4, 5]
Mảng đã sắp xếp: [1, 2, 3, 4, 5]. Mỗi phần tử ở mỗi tầng chỉ bị "chạm" đúng một lần khi trộn, nên mỗi tầng tốn ; có tầng .
Cài đặt
Merge Sort
void merge_sort(vector<int>& a, int l, int r) {
if (l >= r) return; // trường hợp cơ sở: đoạn 0 hoặc 1 phần tử
int mid = l + (r - l) / 2; // tránh tràn khi l+r lớn
merge_sort(a, l, mid); // trị nửa trái
merge_sort(a, mid + 1, r); // trị nửa phải
// Kết hợp: trộn hai nửa đã sắp xếp trong O(độ dài)
vector<int> tmp;
tmp.reserve(r - l + 1);
int i = l, j = mid + 1;
while (i <= mid && j <= r)
tmp.push_back(a[i] <= a[j] ? a[i++] : a[j++]); // <= giữ tính ổn định
while (i <= mid) tmp.push_back(a[i++]);
while (j <= r) tmp.push_back(a[j++]);
for (int k = l; k <= r; k++) a[k] = tmp[k - l];
}
Tìm kiếm nhị phân (đệ quy)
// Trả về chỉ số của target trong mảng ĐÃ SẮP XẾP a[l..r], hoặc -1.
int binary_search(const vector<int>& a, int l, int r, int target) {
if (l > r) return -1; // không còn đoạn để xét
int mid = l + (r - l) / 2;
if (a[mid] == target) return mid;
if (a[mid] < target)
return binary_search(a, mid + 1, r, target); // chỉ giữ nửa phải
return binary_search(a, l, mid - 1, target); // chỉ giữ nửa trái
}
Đếm nghịch thế (gộp vào merge sort)
// Đếm số cặp (i, j), i < j, a[i] > a[j]. Trả về long long vì có thể tới N(N-1)/2.
long long merge_count(vector<int>& a, int l, int r) {
if (l >= r) return 0;
int mid = l + (r - l) / 2;
long long cnt = merge_count(a, l, mid) + merge_count(a, mid + 1, r);
vector<int> tmp;
int i = l, j = mid + 1;
while (i <= mid && j <= r) {
if (a[i] <= a[j]) tmp.push_back(a[i++]);
else {
cnt += mid - i + 1; // a[i..mid] đều > a[j] => góp (mid-i+1) nghịch thế
tmp.push_back(a[j++]);
}
}
while (i <= mid) tmp.push_back(a[i++]);
while (j <= r) tmp.push_back(a[j++]);
for (int k = l; k <= r; k++) a[k] = tmp[k - l];
return cnt;
}
Lũy thừa nhanh (binary exponentiation)
// Tính a^n mod m trong O(log n) bằng a^n = (a^(n/2))^2 [* a nếu n lẻ].
long long power(long long a, long long n, long long mod) {
a %= mod; // phòng a >= mod
if (n == 0) return 1 % mod; // 1 % mod để xử lý mod == 1
long long half = power(a, n / 2, mod);
long long res = half * half % mod; // bình phương
if (n & 1) res = res * a % mod; // nhân thêm a nếu n lẻ
return res;
}
def power(a, n, mod):
a %= mod
if n == 0:
return 1 % mod
half = power(a, n // 2, mod)
res = half * half % mod
if n & 1:
res = res * a % mod
return res
# Python có sẵn, cũng O(log n):
pow(a, n, mod)
Độ phức tạp
| Thuật toán | Thời gian | Bộ nhớ | Lý giải |
|---|---|---|---|
| Merge sort | tầng, mỗi tầng trộn ; cần mảng phụ tmp kích thước |
||
| Đếm nghịch thế | y hệt merge sort, chỉ thêm phép cộng mỗi bước | ||
| Tìm kiếm nhị phân | đệ quy / vòng lặp | mỗi bước bỏ một nửa; phiên bản đệ quy tốn stack | |
| Lũy thừa nhanh | đệ quy / vòng lặp | mỗi lần đệ quy số mũ giảm còn một nửa |
Với chiều cao stack đệ quy: mọi thuật toán trên đều chỉ đệ quy sâu (merge sort sâu vì luôn chia đôi cân bằng), nên không lo tràn stack ngay cả khi tới hàng triệu — khác hẳn đệ quy giảm-một-đơn-vị (sâu ).
⚠️ Lỗi thường gặp
- Tràn số khi tính
mid. Viếtmid = (l + r) / 2có thể trànintkhil + rvượt . Dùngmid = l + (r - l) / 2. - Quên
long longcho kết quả đếm nghịch thế. Số nghịch thế tối đa là ; với đã là , trànint. Biến đếm và các biến trung gian phải làlong long. - Overflow trong phép nhân của lũy thừa nhanh.
half * halfvớihalfcỡ cho tích cỡ , vượtintrất xa. Phải đểa,half,reskiểulong longvà lấy% modngay sau mỗi phép nhân. - Quên
1 % modở trường hợp cơ sở. Nếumod == 1, đáp án luôn phải là ; trả về1cứng sẽ sai. Trả về1 % mod. - Bài con không độc lập mà vẫn áp dụng. Chia để trị chỉ đúng khi hai nửa giải được riêng rồi gộp. Nếu lời giải nửa phải phụ thuộc nửa trái (vd cần trạng thái tích lũy), phải xử lý phần "vắt qua giữa" (cross-boundary) tách riêng — đây là chỗ hay quên (vd quên đếm các nghịch thế bắc cầu giữa hai nửa).
- Sai biên đệ quy gây vòng lặp vô hạn. Trong tìm kiếm nhị phân, viết
binary_search(a, l, mid, ...)(thay vìmid - 1) khia[mid] > targetsẽ không thu hẹp được đoạn khil == mid, lặp vô tận. Luôn loại bỏmidkhỏi đoạn con. - Tìm kiếm nhị phân trên mảng chưa sắp xếp. Thuật toán chỉ đúng khi mảng đã sắp xếp tăng dần; quên sắp xếp trước sẽ cho kết quả sai nhưng không báo lỗi.
Biến thể / Mở rộng
- Lũy thừa ma trận: thay phép nhân số bằng nhân ma trận trong lũy thừa nhanh để tính số Fibonacci thứ hay các hồi quy tuyến tính trong .
- Nhân nhanh Karatsuba và FFT/NTT để nhân hai đa thức / số lớn — đều dựa trên chia để trị.
- Closest pair of points (cặp điểm gần nhất) : chia mặt phẳng, xử lý dải giữa.
- Tối ưu DP bằng chia để trị (Divide and Conquer Optimization): khi điểm tối ưu đơn điệu, có thể tính một tầng DP trong thay vì . Đây là kỹ thuật nâng cao xuất hiện trong các bài hạng chuyên gia ở mục dưới.
Bài tập luyện
- Lũy thừa modulo (modexp) — (Nghiệp dư) cài đặt trực tiếp lũy thừa nhanh , bài nhập môn chuẩn của binary exponentiation.
- Lũy thừa bậc hai (expexp) — (Trung cấp) tính , phải lồng hai lớp lũy thừa nhanh và rút gọn số mũ theo định lý Fermat nhỏ.
- Nhà kính của Sprout (greenhouse) — (Chuyên gia) DP đặt điểm tối ưu, dùng kỹ thuật tối ưu DP bằng chia để trị / convex hull trick.
- Người Ăn Bánh (pieaters) — (Chuyên gia) bài DP nâng cao minh họa Divide and Conquer Optimization trên đoạn.