Sắp xếp số bằng cách chèn từng số vào đúng chỗ tốn cỡ phép so sánh; với đó là , quá xa giới hạn một giây. Nhưng nếu hai nửa của dãy đã sắp xong thì ghép chúng chỉ mất phép so sánh, và để sắp mỗi nửa ta dùng lại đúng cách ấy trên một nửa nhỏ hơn. Chia để trị (divide and conquer) là khuôn thiết kế đó: đưa bài toán về các bài con cùng dạng nhưng nhỏ hơn, giải từng bài con bằng đệ quy, rồi gộp lời giải lại. Cùng khuôn này cho ra lũy thừa nhanh, đếm nghịch thế, cặp điểm gần nhất, và sẽ gặp lại ở cây phân đoạn (bài 17) cùng chia để trị CDQ (bài 60).
Chia, trị, gộp: lũy thừa nhanh
Một thuật toán chia để trị gồm ba việc.
- Chia (divide): đưa bài toán cỡ về vài bài con cùng dạng, cỡ nhỏ hơn.
- Trị (conquer): giải từng bài con bằng chính thuật toán đang viết, tức đệ quy. Bài đủ nhỏ thì giải thẳng; đó là điều kiện dừng.
- Gộp (combine): ghép lời giải các bài con thành lời giải của bài ban đầu.
Ví dụ ngắn nhất là lũy thừa nhanh (fast exponentiation): tính với tới . Nhân từng thừa số cần phép nhân, không thể kịp. Nhưng khi chẵn và khi lẻ. Chia là đổi thành ; trị là tính bằng đệ quy; gộp là bình phương kết quả và nhân thêm nếu lẻ; điều kiện dừng là , khi đó .
Chạy tay với , . Các lời gọi đi xuống theo , rồi kết quả đi ngược lên: , , , , . Chỉ 4 phép bình phương và 3 phép nhân thêm, thay cho 12 phép nhân; tổng quát là khoảng thay vì . Gọi là thời gian chạy với số mũ . Mỗi lời gọi làm việc rồi gọi một lần với số mũ nửa, nên : có khoảng lời gọi, tức .
Điểm mấu chốt là kết quả được tính một lần rồi lưu vào biến: chia để trị chỉ có lợi khi các bài con độc lập và mỗi bài con được giải một lần. Nếu các bài con chồng lên nhau thì đệ quy trần giải đi giải lại cùng một bài; viết thành hai lời gọi cho thời gian tăng theo cấp số nhân, và chỗ của kiểu bài đó là quy hoạch động (bài 12).
Hỏi nhanh
Một bạn viết lũy thừa nhanh như sau: khi chẵn thì return power(x, n / 2) * power(x, n / 2) % m, gọi hai lần cho cùng một giá trị. Kết quả vẫn đúng. Thời gian chạy theo là bao nhiêu?
Hai lời gọi cho . Mức của cây đệ quy có lời gọi, nên tổng số lời gọi là : cái lợi của chia đôi bị phép gọi đôi xóa sạch.
hấp dẫn vì độ sâu đệ quy đúng là , nhưng thời gian là số lời gọi chứ không phải độ sâu. nhầm mỗi lời gọi tốn . Hai lời gọi không dùng chung gì nên không có ; muốn dùng chung phải lưu kết quả vào một biến.
Ví dụLũy Thừa Rút Gọn
Đề. Cho ba số nguyên dương . In .
Lời giải. Lũy thừa nhanh đúng như trên, với một lời gọi đệ quy mỗi mức. Ba chi tiết: điều kiện dừng trả 1 % C để vẫn cho 0; lấy A % C trước lần gọi đầu để mọi số đem nhân đều nhỏ hơn ; và vì nên tích hai số dư nhỏ hơn , vừa ll. tới nên đệ quy chỉ sâu khoảng 31 mức. Với tới thì tích phải tính bằng __int128.
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
ll A, B, C;
ll power(ll x, ll n) {
if (n == 0) return 1 % C;
ll h = power(x, n / 2);
ll res = h * h % C;
if (n % 2 == 1) res = res * x % C;
return res;
}
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
cin >> A >> B >> C;
cout << power(A % C, B) << '\n';
}
7 13 10
7
Cây đệ quy và độ phức tạp
Giả sử mỗi lời gọi cỡ sinh bài con cỡ và phần việc riêng của nó (chia cộng gộp) tốn . Khi đó thời gian chạy thỏa hệ thức truy hồi
Cách giải thực dụng nhất là vẽ cây đệ quy và cộng chi phí theo từng mức. Mức có nút, mỗi nút cỡ nên cả mức tốn ; cây dừng ở mức , số lần chia cho tới khi còn 1 (giống ở bài 03, chỉ thay 2 bằng ). Với sắp xếp trộn, và vì gộp hai nửa tốn : mức có nút cỡ nên tốn , mọi mức bằng nhau. Có mức, vậy .
Khi , chi phí mức là với , nên chi phí mỗi mức gấp lần mức ngay trên nó, và là tổng của mức như vậy. Có ba khả năng.
- : các mức giảm dần, tổng cỡ số hạng đầu, . Việc ở gốc quyết định.
- : mọi mức bằng nhau, .
- : các mức tăng dần, tổng cỡ mức cuối, tức số lá của cây vì mỗi lá tốn . Chẳng hạn , : mỗi mức số nút gấp 4, có mức nên có lá và . Tổng quát số lá là . Việc ở các lá quyết định.
Đó là định lý thợ (master theorem) cho dạng hay gặp; bản đầy đủ cho tùy ý xem ở phần Đọc thêm. Các hệ thức sẽ gặp trong bài:
| Hệ thức | Thời gian | Gặp ở đâu | |
|---|---|---|---|
| lũy thừa nhanh, chặt nhị phân | |||
| gọi đôi lũy thừa nhanh; đoạn con lớn nhất với bốn số | |||
| sắp xếp trộn, nghịch thế, cặp điểm gần nhất | |||
| chia bảng thành bốn phần tư, là cạnh | |||
| quickselect khi pivot luôn chia đôi | |||
| nhân hai số lớn kiểu Karatsuba |
Cây chỉ đẹp như vậy khi các bài con co lại theo tỉ lệ. Nếu mỗi lần chỉ bớt một phần tử thì và cây sâu mức, có thể tràn ngăn xếp; đó là trường hợp xấu nhất của quicksort. Chia đôi theo chỉ số luôn cân bằng (chỉ sâu mức với ); chia theo giá trị quanh một pivot thì cân bằng tùy dữ liệu, vì thế mới cần pivot ngẫu nhiên.
Hỏi nhanh
Một chương trình kiểu quicksort luôn lấy phần tử đầu của đoạn làm pivot, chia các phần tử còn lại thành nhóm nhỏ hơn pivot và nhóm không nhỏ hơn pivot, rồi đệ quy vào cả hai nhóm. Dãy vào đã sắp tăng, . Số phép so sánh cỡ bao nhiêu?
Pivot là phần tử nhỏ nhất nên nhóm nhỏ hơn rỗng và nhóm kia còn phần tử: , tức . Con số chỉ đúng khi hai nhóm cỡ gần nhau. Dãy đã sắp không giúp gì vì mỗi vòng vẫn phải so pivot với mọi phần tử còn lại, và độ sâu là của cây cân bằng, cây ở đây sâu .
Ví dụĐộ Sâu Theo Cực Đại
Đề. Cho một hoán vị (, tối đa 100 test). Gắn nó với cây nhị phân: phần tử lớn nhất là gốc, phần bên trái nó tạo cây con trái, phần bên phải tạo cây con phải, theo cùng quy tắc. In độ sâu của từng phần tử theo thứ tự trong hoán vị, gốc có độ sâu 0.
Lời giải. Định nghĩa đã là một lời gọi đệ quy: trên đoạn , tìm vị trí của số lớn nhất, gán độ sâu hiện tại cho , rồi giải và với độ sâu tăng 1; đoạn rỗng thì dừng. Điểm chia là vị trí số lớn nhất chứ không phải điểm giữa, nên cây không cân bằng: với hoán vị tăng dần, mỗi lần chỉ tách được một phần tử và , đúng trường hợp xấu đã nói. Với chuyện đó chẳng sao, nhưng với thì cần cách khác (bài 17).
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
ll t, n, a[105], d[105];
void go(ll l, ll r, ll dep) {
if (l >= r) return;
ll p = l;
for (ll i = l + 1; i < r; i++)
if (a[i] > a[p]) p = i;
d[p] = dep;
go(l, p, dep + 1);
go(p + 1, r, dep + 1);
}
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
cin >> t;
while (t--) {
cin >> n;
for (ll i = 0; i < n; i++) cin >> a[i];
go(0, n, 0);
for (ll i = 0; i < n; i++) cout << d[i] << ' ';
cout << '\n';
}
}
1
5
2 5 1 4 3
1 0 2 1 2
Sắp xếp trộn
Bài toán: sắp số tăng dần bằng sắp xếp trộn (merge sort). Gộp hai dãy đã sắp thành một dãy sắp chỉ cần một lượt duyệt, nên ta giao việc sắp mỗi nửa cho đệ quy.
Quy ước chỉ số của cả bài. Mọi đoạn trong đệ quy là đoạn nửa mở , đánh số từ 0: gồm các vị trí , độ dài . Đặt thì hai nửa và không giao nhau, hợp lại đúng , không phải cộng trừ 1 ở đâu cả. Đoạn dài 0 hoặc 1 đã sắp sẵn: điều kiện dừng là r - l <= 1.
Giả sử hai nửa đã tăng. Đặt hai con trỏ , vào phần tử đầu chưa lấy của mỗi nửa. Phần tử nhỏ nhất trong mọi phần tử chưa lấy chắc chắn là một trong hai phần tử đó, nên mỗi bước ta so hai phần tử ấy, ghi cái nhỏ hơn vào mảng phụ rồi dịch con trỏ. Khi một nửa hết, chép nguyên phần còn lại của nửa kia. Ví dụ gộp với : lấy , rồi , rồi , nửa trái hết nên chép , ra .
Tính đúng chứng minh bằng quy nạp theo độ dài đoạn. Đoạn dài không quá 1 đúng hiển nhiên. Đoạn dài hơn thì hai lời gọi con sắp đúng hai nửa, và bước gộp giữ bất biến “sau bước, phần tử đã ghi là phần tử nhỏ nhất của cả đoạn, theo thứ tự tăng”, vì phần tử nhỏ nhất chưa lấy luôn là đầu của một trong hai nửa.
Gộp tốn nên , cỡ phép toán với . Mảng phụ khai báo một lần, toàn cục, dùng chung cho mọi lời gọi; nếu mỗi lời gọi tạo một vector mới thì tổng cấp phát lên và chậm đi thấy rõ. Lấy bên trái khi hai phần tử bằng nhau giữ nguyên thứ tự của các phần tử bằng nhau (sắp ổn định), và ở ví dụ dưới chính chỗ này quyết định cặp bằng nhau có bị tính hay không. Khi chỉ cần sắp xếp thì cứ gọi sort; ta học sắp xếp trộn vì bước gộp là nơi tính thêm những thứ khác ngay lúc ghép hai nửa.
Hỏi nhanh
Một bạn viết điều kiện dừng if (r <= l) return; thay cho if (r - l <= 1) return; trong hàm sắp đoạn nửa mở với . Chuyện gì xảy ra khi đoạn có đúng một phần tử?
Với ta có . Nửa trái rỗng nên dừng ngay, nhưng nửa phải trùng với đoạn hiện tại, nên lời gọi lặp lại y hệt mãi tới khi tràn ngăn xếp. Điều kiện dừng phải bắt được mọi đoạn mà phép chia đôi không còn thu nhỏ được, tức độ dài 1 cũng như 0. Lời gọi trên đoạn rỗng không đụng tới mảng nên không có lỗi ngoài biên.
Ví dụĐếm Cặp Ngược Thứ Tự
Đề. Cho dãy số nguyên, . Đếm số cặp với (nghịch thế; hai số bằng nhau không tính).
Lời giải. Duyệt mọi cặp tốn . Chia đôi tại : mỗi nghịch thế có hai chỉ số cùng ở nửa trái, cùng ở nửa phải, hoặc chéo (). Hai loại đầu giao cho đệ quy. Số nghịch thế chéo chỉ phụ thuộc tập giá trị của mỗi nửa chứ không phụ thuộc thứ tự bên trong nửa, nên ta được quyền sắp mỗi nửa trước, và sắp xếp trộn làm sẵn việc đó. Trong bước gộp, mỗi lần lấy phần tử phải vì phần tử trái đang trỏ thì, do nửa trái tăng, mọi phần tử trái từ tới đều lớn hơn : cộng ngay .
Mỗi cặp chéo được cộng đúng một lần, lúc được lấy: khi đó chưa được lấy, vì nếu được lấy trước thì phần tử đầu nửa phải lúc đó là và điều kiện lấy trái cho , mâu thuẫn. Bằng nhau thì lấy trái trước, nên cặp bằng nhau không bị cộng. Chạy tay trên : hai nửa có 1 nghịch thế mỗi bên và thành , ; gộp thì với lấy trái, với lấy phải cộng 1, với lấy phải cộng 1. Tổng . Đáp án tới nên dùng ll.
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
const ll N = 2e5 + 5;
ll n, a[N], t[N];
ll calc(ll l, ll r) {
if (r - l <= 1) return 0;
ll mid = (l + r) >> 1;
ll res = calc(l, mid) + calc(mid, r);
ll i = l, j = mid, k = l;
while (i < mid || j < r) {
if (j == r || (i < mid && a[i] <= a[j])) t[k++] = a[i++];
else {
res += mid - i;
t[k++] = a[j++];
}
}
for (ll q = l; q < r; q++) a[q] = t[q];
return res;
}
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
cin >> n;
for (ll i = 0; i < n; i++) cin >> a[i];
cout << calc(0, n) << '\n';
}
4
3 1 2 1
4
Hỏi nhanh
Trong bước gộp trên, một bạn đổi điều kiện lấy bên trái từ a[i] <= a[j] thành a[i] < a[j], mọi thứ khác giữ nguyên. Với dãy , chương trình in gì?
Khi , điều kiện mới sai nên chương trình rẽ sang nhánh lấy bên phải và cộng : cặp bị tính, trái với đề. Dãy vẫn được sắp đúng, nhưng thứ tự lấy các phần tử bằng nhau đã đổi nên số đếm đổi theo. Đáp án là 1 chứ không phải 2 vì chỉ có một cặp. Nếu đề tính cả cặp bằng nhau () thì đây mới là điều kiện đúng.
Đếm nghịch thế cũng làm được bằng cây Fenwick (bài 16); cách chia để trị này ngắn hơn và là điểm khởi đầu của CDQ (bài 60).
Phần cắt ngang: việc của bước gộp
Khuôn “trái, phải, chéo” của đếm nghịch thế dùng được cho nhiều bài về đoạn hay cặp. Bài thứ nhất: cho dãy số nguyên, có thể âm, tìm tổng lớn nhất của một đoạn con liên tiếp không rỗng. Bài này còn có lời giải tuyến tính khác; ta làm bằng chia để trị vì nó dạy đúng khuôn.
Chia dãy tại . Mỗi đoạn nằm hẳn trong nửa trái, nằm hẳn trong nửa phải, hoặc cắt ngang đường chia (). Hai loại đầu giao cho đệ quy; bước gộp chỉ lo loại thứ ba. Đoạn cắt ngang là một hậu tố của nửa trái nối với một tiền tố của nửa phải, cả hai không rỗng. Hai phần chọn độc lập, nên ta chọn riêng hậu tố tốt nhất và tiền tố tốt nhất, mỗi thứ bằng một lượt quét từ ra một phía.
Có thể bỏ cả lượt quét bằng cách để mỗi lời gọi trả về bốn số mô tả đoạn của nó: tổng cả đoạn, tiền tố tốt nhất, hậu tố tốt nhất và đoạn con tốt nhất. Tiền tố tốt nhất của đoạn gộp nằm trong nửa trái, hoặc là cả nửa trái nối tiền tố tốt nhất của nửa phải; hậu tố đối xứng; đoạn tốt nhất là của nửa trái, của nửa phải, hoặc hậu tố trái nối tiền tố phải:
struct Info {
ll sum, pre, suf, best;
};
Info combine(Info a, Info b) {
return {a.sum + b.sum, max(a.pre, a.sum + b.pre), max(b.suf, b.sum + a.suf),
max({a.best, b.best, a.suf + b.pre})};
}
Gộp tốn nên . Ý “tóm tắt mỗi đoạn bằng vài con số mà hai bản tóm tắt gộp được trong ” chính là thứ mỗi nút của cây phân đoạn (bài 17) lưu.
Ví dụĐoạn Liên Tiếp Tốt Nhất
Đề. Cho dãy số nguyên, . In tổng lớn nhất của một đoạn con liên tiếp không rỗng.
Lời giải. Cài bản quét từ giữa ra. Với và : nửa trái có đáp án riêng , nửa phải có đáp án riêng (đoạn ). Hậu tố của nửa trái có tổng , tốt nhất ; tiền tố của nửa phải có tổng , tốt nhất . Đoạn cắt ngang tốt nhất , đáp án . Hai biến hậu tố, tiền tố tốt nhất khởi tạo bằng chứ không phải 0: khởi tạo 0 là cho phép phần rỗng, và với dãy toàn số âm chương trình sẽ in 0, tổng của một đoạn không tồn tại. Tổng tới nên dùng ll; thời gian .
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
const ll N = 2e5 + 5;
ll n, a[N];
ll solve(ll l, ll r) {
if (r - l == 1) return a[l];
ll mid = (l + r) >> 1;
ll res = max(solve(l, mid), solve(mid, r));
ll s = 0, bl = -1e18, br = -1e18;
for (ll i = mid - 1; i >= l; i--) {
s += a[i];
bl = max(bl, s);
}
s = 0;
for (ll j = mid; j < r; j++) {
s += a[j];
br = max(br, s);
}
return max(res, bl + br);
}
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
cin >> n;
for (ll i = 0; i < n; i++) cin >> a[i];
cout << solve(0, n) << '\n';
}
7
-4 6 -2 5 -7 4 3
9
Ví dụBình Phương Khoảng Cách Nhỏ Nhất
Đề. Cho điểm nguyên, , hai điểm khác chỉ số có thể trùng tọa độ. In giá trị nhỏ nhất của trên mọi cặp : bài cặp điểm gần nhất (closest pair of points).
Lời giải. Sắp các điểm theo một lần, rồi chia theo chỉ số tại ; đường chia là . Giải hai nửa bằng đệ quy, gọi là bình phương khoảng cách nhỏ hơn trong hai kết quả và . Còn lại các cặp chéo.
Cặp chéo muốn tốt hơn thì hai điểm cách nhau theo hoành độ chưa tới , mà đường chia nằm giữa chúng, nên cả hai thuộc dải . Sắp các điểm trong dải theo ; với điểm , chỉ so với các điểm đứng trước có . Những điểm đó cùng nằm trong hình chữ nhật . Nửa trái của hình chữ nhật là hình vuông mà các điểm bên trong đôi một cách nhau ít nhất ; chia nó thành bốn ô , đường chéo mỗi ô là nên mỗi ô chứa không quá một điểm. Vậy hình chữ nhật chứa không quá 8 điểm kể cả , và vòng so của mỗi điểm chạy tối đa 7 bước.
Mọi so sánh dùng bình phương bằng số nguyên, không có căn: điều kiện dải là . Code gồm ba bước trong hàm solve(l, r), trả về đáp án của đoạn và để lại đoạn đó đã sắp theo .
Bước 1: chia và giải hai nửa. Đoạn dưới hai điểm thì chưa có cặp nào, trả về vô cùng. Hoành độ đường chia mx phải lưu trước khi đệ quy, vì sau đó đoạn đã bị sắp lại theo .
if (r - l <= 1) return 1e18;
ll mid = (l + r) >> 1, mx = p[mid].first;
ll d = min(solve(l, mid), solve(mid, r));
Bước 2: trộn hai nửa theo . Hai nửa đã sắp theo , trộn như sắp xếp trộn trong . Nhờ vậy không phải gọi sort theo ở mỗi lời gọi, việc sẽ nhân độ phức tạp thêm một thừa số .
ll i = l, j = mid, k = l;
while (i < mid || j < r) {
if (j == r || (i < mid && p[i].second <= p[j].second)) t[k++] = p[i++];
else t[k++] = p[j++];
}
for (ll q = l; q < r; q++) p[q] = t[q];
Bước 3: quét dải. Đi qua đoạn theo thứ tự , giữ các điểm của dải trong t[l..l + m). Điểm nằm ngoài dải thì bỏ. Điểm trong dải so với các điểm dải đứng trước, lùi dần khi hiệu tung độ còn nhỏ hơn , rồi tự nhập vào dải. Theo lập luận ở trên, vòng lùi chạy tối đa 7 bước.
ll m = 0;
for (ll q = l; q < r; q++) {
if (sq(p[q].first - mx) >= d) continue;
for (ll s = m - 1; s >= 0 && sq(p[q].second - t[l + s].second) < d; s--)
d = min(d, sq(p[q].first - t[l + s].first) + sq(p[q].second - t[l + s].second));
t[l + m++] = p[q];
}
Điểm trùng nhau cho , khi đó dải rỗng và chương trình vẫn đúng. Với test ví dụ, nửa trái cho và không cặp chéo nào tốt hơn.
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
const ll N = 1e5 + 5;
ll n;
pair<ll, ll> p[N], t[N];
ll sq(ll v) {
return v * v;
}
ll solve(ll l, ll r) {
if (r - l <= 1) return 1e18;
ll mid = (l + r) >> 1, mx = p[mid].first;
ll d = min(solve(l, mid), solve(mid, r));
ll i = l, j = mid, k = l;
while (i < mid || j < r) {
if (j == r || (i < mid && p[i].second <= p[j].second)) t[k++] = p[i++];
else t[k++] = p[j++];
}
for (ll q = l; q < r; q++) p[q] = t[q];
ll m = 0;
for (ll q = l; q < r; q++) {
if (sq(p[q].first - mx) >= d) continue;
for (ll s = m - 1; s >= 0 && sq(p[q].second - t[l + s].second) < d; s--)
d = min(d, sq(p[q].first - t[l + s].first) + sq(p[q].second - t[l + s].second));
t[l + m++] = p[q];
}
return d;
}
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
cin >> n;
for (ll i = 0; i < n; i++) cin >> p[i].first >> p[i].second;
sort(p, p + n);
cout << solve(0, n) << '\n';
}
5
0 0
3 4
1 1
9 9
1 4
2
, tức cộng một lần sắp ban đầu.
Chỉ đi vào một nhánh
Có khi bước gộp chẳng còn việc gì vì lời giải nằm trọn trong một bài con; ta chỉ cần biết nên đi vào bài con nào. Cây đệ quy khi đó rút thành một đường đi. Có hai dạng hay gặp.
Phần tử thứ (quickselect). Tìm số đứng thứ (đánh số từ 0) khi sắp tăng, không sắp cả dãy. Chọn một pivot ngẫu nhiên, chia các số thành ba nhóm: nhỏ hơn ( số), bằng ( số), lớn hơn . Số cần tìm nằm trong nhóm nhỏ nếu ; bằng nếu ; ngược lại nằm ở vị trí của nhóm lớn. Chỉ một nhóm cần đi tiếp.
mt19937 rng(712367);
ll kth(vector<ll> a, ll k) {
while (true) {
ll x = a[rng() % a.size()];
vector<ll> lo, eq, hi;
for (ll v : a) {
if (v < x) lo.push_back(v);
else if (v == x) eq.push_back(v);
else hi.push_back(v);
}
if (k < (ll)lo.size()) a = lo;
else if (k < (ll)(lo.size() + eq.size())) return x;
else {
k -= lo.size() + eq.size();
a = hi;
}
}
}
Xấu nhất là nếu pivot luôn rơi vào đầu mút, nhưng với pivot ngẫu nhiên trung bình chỉ . Gọi pivot là tốt nếu nó nằm trong nửa giữa của thứ tự sắp, xác suất ít nhất ; khi đó mỗi nhóm có không quá phần tử. Trung bình chờ 2 vòng là gặp pivot tốt, mỗi vòng tốn tuyến tính theo cỡ hiện tại, nên trung bình tổng công việc không quá ; tổng trong ngoặc bằng 4, nên cả bài trung bình không quá . Thư viện có nth_element làm đúng việc này tại chỗ.
Hỏi nhanh
Một bạn cài quickselect với hai nhóm: “nhỏ hơn pivot” và “lớn hơn hoặc bằng pivot”, bỏ riêng pivot ra ngoài. Cho số giống hệt nhau và . Số phép so sánh cỡ bao nhiêu?
Mọi số khác đều “lớn hơn hoặc bằng pivot” nên dồn hết vào nhóm thứ hai: dãy chỉ ngắn đi một phần tử mỗi vòng, . Với ba nhóm như trong code trên, nhóm bằng pivot chứa cả số và chương trình dừng sau một vòng; đó là lý do phân hoạch ba phía. Hai đáp án đầu tin rằng pivot luôn chia cân bằng; đáp án quên rằng mỗi vòng vẫn duyệt cả dãy.
Vị trí trong dãy sinh theo quy tắc. Nhiều đề cho một dãy sinh bằng quy tắc đệ quy, dài tới , rồi hỏi phần tử thứ . Không dựng được cả dãy, nhưng cấu trúc của nó đã là một lời gọi đệ quy sẵn. Ví dụ và : hai bản sao của kẹp số ở giữa. Vậy và . Phần tử thứ (từ 1) của chỉ ở ba chỗ: bản sao trái nếu ; phần tử giữa, bằng , nếu ; còn lại là bản sao phải, nơi nó là phần tử thứ của . Ta chỉ lưu độ dài từng cấp rồi đi xuống một nhánh.
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
ll q, len[61];
ll at(ll k, ll p) {
if (k == 0) return 0;
if (p <= len[k - 1]) return at(k - 1, p);
if (p == len[k - 1] + 1) return k;
return at(k - 1, p - len[k - 1] - 1);
}
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
len[0] = 1;
for (ll k = 1; k <= 60; k++) len[k] = 2 * len[k - 1] + 1;
cin >> q;
while (q--) {
ll k, p;
cin >> k >> p;
cout << at(k, p) << '\n';
}
}
3
3 6
3 8
60 1000000000000000000
1
3
18
Mỗi truy vấn qua tối đa lời gọi. Nhìn kết quả thấy phần tử thứ của chính là số mũ của 2 trong (vì nên ra 18), một công thức đóng; viết vài phần tử đầu ra giấy luôn đáng làm. Cùng khuôn này giải các bài về dãy bit tự đảo, xâu mở rộng hay thứ tự loại trong vòng tròn.
Ví dụĐếm Một Trong Dãy Gấp Đôi
Đề. Với , dãy là nếu , và là , rồi , rồi lại nếu . Cho và , đếm số phần tử bằng 1 ở các vị trí từ tới (đánh số từ 1).
Lời giải. Đếm trên đoạn bằng hiệu hai tiền tố: gọi là số số 1 trong phần tử đầu của , đáp án là . Đặt . Nếu , cả phần tử nằm trong bản sao trái: đi vào nó. Nếu thì phần tử đầu gồm trọn bản sao trái, phần tử giữa , và phần tử đầu của bản sao phải.
Đi vào bản sao phải là một nhánh, nhưng “trọn bản sao trái” lại là thêm một nhánh nữa, và hai nhánh mỗi mức là lời gọi. Mẹo là có đúng số 1: số số 1 thỏa , đúng hệ thức của chính , và hai bên bằng nhau ở . Vậy trọn bản sao trái đóng góp không cần gọi gì, và mỗi mức chỉ còn một lời gọi. Độ sâu là mức, mỗi mức tính thêm 60 bước.
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
ll n, l, r;
ll len(ll m) {
return m <= 1 ? 1 : 2 * len(m / 2) + 1;
}
ll pre(ll m, ll k) {
if (k <= 0) return 0;
if (m <= 1) return m;
ll h = len(m / 2);
if (k <= h) return pre(m / 2, k);
return m / 2 + m % 2 + pre(m / 2, k - h - 1);
}
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
cin >> n >> l >> r;
cout << pre(n, r) - pre(n, l - 1) << '\n';
}
5 2 6
3
Chia thành bốn phần: lát bảng bằng mảnh chữ L
Trên bảng vuông ta chia thành bốn phần tư. Ví dụ kinh điển: bảng khuyết đúng một ô; lát kín ô còn lại bằng các mảnh chữ L, mỗi mảnh phủ 3 ô (chia hết vì chia 3 luôn dư 1). Chia thành bốn phần tư thì chỉ phần tư chứa ô khuyết là bài con cùng dạng, ba phần tư kia thì không. Mẹo là tạo ra ô khuyết: đặt một mảnh chữ L vào giữa bảng, phủ ba ô sát tâm thuộc ba phần tư không chứa ô khuyết. Giờ mỗi phần tư thiếu đúng một ô và là bài con cùng dạng; bảng là điều kiện dừng. Tính đúng suy ra từ quy nạp theo .
Chương trình dưới đọc cạnh (lũy thừa của 2) và ô khuyết , tính từ 0, rồi in số hiệu mảnh phủ từng ô; ô khuyết giữ số 0. Hàm tile(r0, c0, s, hr, hc) lát hình vuông cạnh , góc trên trái , thiếu ô . Hai mảng mr, mc là hai hàng và hai cột sát tâm.
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
ll n, hr, hc, id, b[130][130];
void tile(ll r0, ll c0, ll s, ll hr, ll hc) {
if (s == 1) return;
ll h = s / 2, cur = ++id;
ll mr[2] = {r0 + h - 1, r0 + h}, mc[2] = {c0 + h - 1, c0 + h};
for (ll i = 0; i < 2; i++)
for (ll j = 0; j < 2; j++) {
ll x = r0 + i * h, y = c0 + j * h;
if (x <= hr && hr < x + h && y <= hc && hc < y + h) tile(x, y, h, hr, hc);
else {
b[mr[i]][mc[j]] = cur;
tile(x, y, h, mr[i], mc[j]);
}
}
}
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
cin >> n >> hr >> hc;
tile(0, 0, n, hr, hc);
for (ll i = 0; i < n; i++) {
for (ll j = 0; j < n; j++) cout << b[i][j] << ' ';
cout << '\n';
}
}
4 1 2
2 2 3 3
2 1 0 3
4 1 1 5
4 4 5 5
Mảnh số 1 là mảnh giữa bảng, phủ các ô , , của ba phần tư không chứa ô khuyết . Mỗi lời gọi tốn ngoài đệ quy và sinh 4 bài con cạnh một nửa: với là cạnh bảng. Ở đây nên các lá quyết định, , đúng bằng số ô phải ghi.
Ví dụNhững Mảnh Giấy Hai Màu
Đề. Tờ giấy (, lũy thừa của 2), mỗi ô trắng (0) hoặc xanh (1). Một vùng vuông cùng màu thì giữ nguyên thành một mảnh; có cả hai màu thì chia thành bốn hình vuông bằng nhau và làm lại cho từng phần. In số mảnh trắng, rồi số mảnh xanh.
Lời giải. Đề đã là lời gọi chia bốn. Việc còn lại là hỏi nhanh “vùng này có cùng màu không”: đếm số ô xanh trong vùng bằng tổng tiền tố hai chiều (bài 02) trong ; bằng 0 là mảnh trắng, bằng là mảnh xanh, còn lại thì chia bốn.
Bước 1: tổng tiền tố khi đọc. s[i][j] là số ô xanh trong hình chữ nhật từ tới , tính ngay khi đọc từng ô bằng công thức bao hàm loại trừ.
for (ll i = 1; i <= n; i++)
for (ll j = 1; j <= n; j++) {
ll v;
cin >> v;
s[i][j] = s[i - 1][j] + s[i][j - 1] - s[i - 1][j - 1] + v;
}
Bước 2: đếm ô xanh của một vùng. Vùng vuông cạnh có góc trên trái là ô (vì s đánh số từ 1) chứa get(x, y, k) ô xanh.
ll get(ll x, ll y, ll k) {
return s[x + k][y + k] - s[x][y + k] - s[x + k][y] + s[x][y];
}
Bước 3: chia bốn. Vùng toàn trắng hay toàn xanh thì đếm một mảnh và dừng; ngược lại gọi đệ quy cho bốn hình vuông cạnh .
void go(ll x, ll y, ll k) {
ll c = get(x, y, k);
if (c == 0) {
w++;
return;
}
if (c == k * k) {
g++;
return;
}
ll h = k / 2;
go(x, y, h);
go(x, y + h, h);
go(x + h, y, h);
go(x + h, y + h, h);
}
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
ll n, w, g, s[130][130];
ll get(ll x, ll y, ll k) {
return s[x + k][y + k] - s[x][y + k] - s[x + k][y] + s[x][y];
}
void go(ll x, ll y, ll k) {
ll c = get(x, y, k);
if (c == 0) {
w++;
return;
}
if (c == k * k) {
g++;
return;
}
ll h = k / 2;
go(x, y, h);
go(x, y + h, h);
go(x + h, y, h);
go(x + h, y + h, h);
}
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
cin >> n;
for (ll i = 1; i <= n; i++)
for (ll j = 1; j <= n; j++) {
ll v;
cin >> v;
s[i][j] = s[i - 1][j] + s[i][j - 1] - s[i - 1][j - 1] + v;
}
go(0, 0, n);
cout << w << '\n' << g << '\n';
}
4
0 0 1 1
0 0 1 1
1 1 0 0
1 1 0 1
4
3
Số vùng được xét không quá , mỗi vùng , nên cả bài . Thiếu tổng tiền tố thì mỗi vùng tốn để kiểm, thành , vẫn qua với nhưng không còn đẹp.
Khi đi thi
- Bài đếm hoặc tối ưu trên các cặp cần quan hệ giữa và mà chậm: nghĩ tới chia đôi chỉ số, giao hai nửa cho đệ quy và xử lý cặp chéo lúc gộp hai nửa đã sắp; nếu còn có cập nhật xen giữa thì chuyển sang cây Fenwick (bài 16) hoặc CDQ (bài 60).
- Trước khi cài, viết hệ thức và đọc độ phức tạp từ cây đệ quy: bài con phải co lại theo tỉ lệ và mỗi bài chỉ giải một lần. Cài với đoạn nửa mở , điều kiện dừng
r - l <= 1và một mảng phụ toàn cục dùng chung. - Dãy hoặc xâu sinh theo quy tắc đệ quy dài tới thì đừng dựng nó: lưu độ dài từng cấp rồi đi một nhánh theo vị trí, và so với bản vét cạn trên cỡ nhỏ trước khi nộp.
Tóm tắt
- Chia để trị gồm chia, trị (đệ quy, có điều kiện dừng) và gộp; các bài con phải cùng dạng, độc lập và co lại theo tỉ lệ. Dùng đoạn nửa mở với .
- Hệ thức đọc bằng cây đệ quy: cộng chi phí theo mức rồi xem mức đầu, mọi mức hay mức cuối trội hơn. Nhớ , , .
- Sắp xếp trộn gộp hai nửa đã sắp bằng hai con trỏ trong ; lấy bên trái khi bằng nhau để giữ ổn định.
- Phần khó là cặp chéo, việc của bước gộp: cộng khi gộp (nghịch thế), quét từ giữa ra (đoạn con lớn nhất), lọc dải rồi so với tối đa 7 điểm kế tiếp (cặp điểm gần nhất).
- Khi lời giải nằm trọn trong một bài con thì chỉ đi một nhánh: quickselect với phân hoạch ba phía trung bình , vị trí trong dãy sinh theo quy tắc cần lời gọi.
Bài tập
Đọc thêm
- Chia để trịVNOI WikiBài tiếng Việt về ba bước chia, trị, gộp và cách tính độ phức tạp bằng định lý thợ.
- Chia để trị và một số bài toán thú vịVibloBa ví dụ tiếng Việt về đi một nhánh: tìm kiếm nhị phân, xâu Fibonacci, dãy gấp đôi.
- 递归 & 分治OI WikiPhân biệt đệ quy với chia để trị, và khi nào các bài con chồng nhau thì phải chuyển sang quy hoạch động.
- 归并排序OI WikiSắp xếp trộn từ trên xuống và từ dưới lên, tính ổn định, đếm nghịch thế ngay trong bước gộp.
- Finding the nearest pair of pointscp-algorithmsChứng minh chặn số điểm trong hình chữ nhật và cách gộp theo .
- Master theoremWikipediaPhát biểu đầy đủ ba trường hợp với điều kiện chính quy, và những hệ thức mà định lý thợ không áp dụng được.