Trò đoán số từ 1 đến 100, mỗi lần chỉ được nghe “lớn hơn” hay “nhỏ hơn”: người chơi giỏi đoán 50 trước, rồi 25 hoặc 75, mỗi lần loại một nửa, nên bảy lần là chắc chắn ra. Tìm kiếm nhị phân (binary search) làm đúng như vậy trên dãy đã sắp: dãy phần tử chỉ cần khoảng 18 lần so sánh, thay vì duyệt cả dãy.
Giá trị lớn hơn của kỹ thuật là nó không cần mảng. Chỉ cần một điều kiện mà các giá trị lần lượt cho kết quả sai, sai, …, sai, đúng, …, đúng, thì ranh giới giữa hai phần tìm được sau vài chục lần kiểm. Đó là chặt nhị phân trên đáp án: thời gian nhỏ nhất, độ dài lớn nhất, giá trị lớn nhất mà tổng vẫn không vượt giới hạn. Phần lớn bài của chủ đề này thuộc dạng đó.
Tìm trong dãy đã sắp
Bài toán: cho và số , tìm vị trí đầu tiên có . Câu hỏi này luôn có đáp án, dù vắng mặt hay lặp lại nhiều lần (nếu không có vị trí nào thì quy ước đáp án là ), và từ nó đọc ra mọi thứ khác: có mặt khi vị trí đó và giá trị ở đó bằng .
Xét vị trí giữa . Nếu thì là một ứng viên, và vị trí đầu tiên cần tìm không nằm sau . Nếu thì mọi vị trí từ trở về trước đều nhỏ hơn vì dãy tăng. Mỗi lần so sánh loại được ít nhất một nửa đoạn còn nghi ngờ.
ll l = 1, r = n, res = n + 1;
while (l <= r) {
ll mid = (l + r) >> 1;
if (a[mid] >= x) r = (res = mid) - 1;
else l = mid + 1;
}
Vì sao đúng. Đoạn là các vị trí chưa biết. Mọi vị trí trước đã biết là , mọi vị trí sau đã biết là . Biến res luôn bằng : lúc đầu , mỗi lần r = (res = mid) - 1 lại gán cả hai cùng lúc, còn nhánh l = mid + 1 không đụng tới . Vì luôn nằm trong nên đoạn chưa biết co lại ít nhất một ô mỗi vòng, và vòng lặp dừng khi : không còn vị trí nào chưa biết, mọi vị trí trước có , mọi vị trí từ trở đi có . Vậy đúng là vị trí đầu tiên cần tìm, kể cả khi bằng .
Với và : , nên ; , nên , ; , nên , . Dừng: vị trí đầu tiên có là 6.
Mỗi vòng đoạn còn không quá một nửa, nên sau tối đa vòng là xong: 18 vòng với , 60 vòng với miền cỡ . Ba chỗ hay sai, và đều tránh được nếu giữ đúng khuôn:
- Lặp vô hạn. Viết
while (l < r)rồi nhánhl = midthì khi ta có , không nhúc nhích và vòng lặp không bao giờ dừng. Khuôn trên luôn bỏ ra khỏi đoạn chưa biết (mid - 1hoặcmid + 1) nên không rơi vào chuyện đó. - Tràn số.
(l + r) >> 1dịch tổng sang phải một bit, tức chia đôi rồi bỏ phần lẻ, như(l + r) / 2với số không âm. Nó an toàn vớilong longkhi hai đầu không vượt cỡ . Vớiint, tổng hai số cỡ đã tràn. - Điều kiện không đơn điệu. Chương trình vẫn dừng và vẫn in ra một số, nhưng số đó vô nghĩa. Trước khi viết code, hãy nói được vì sao nếu thỏa thì (hay ) cũng thỏa.
Hỏi nhanh
Một bạn viết chặt nhị phân tìm vị trí đầu tiên có a[i] >= x như sau: l = 1, r = n; lặp while (l < r) với mid = (l + r) / 2; nếu a[mid] < x thì l = mid, ngược lại r = mid. Với n = 2, a = {1, 5} và x = 5, chuyện gì xảy ra?
Ban đầu l = 1, r = 2, mid = 1. Vì a[1] = 1 < 5 nên chương trình gán l = mid = 1, không đổi gì, và vòng sau y hệt vòng trước. Khi hai đầu kề nhau, phép chia làm tròn xuống đưa mid về l, và nhánh l = mid không loại được gì dù đã biết a[mid] < x. Cách sửa là viết l = mid + 1, đúng như khuôn ở trên. Điều kiện l < r chỉ bảo đảm dừng khi mỗi vòng thật sự thu hẹp đoạn.
Ví dụĐếm Không Vượt Ngưỡng
Đề. Mảng gồm số, truy vấn . Với mỗi đếm số phần tử .
Lời giải. Sắp một lần. Số phần tử không vượt chính là vị trí đầu tiên có , trừ 1: mọi phần tử trước vị trí đó đều . Tìm vị trí đó bằng khuôn trên với điều kiện a[mid] > b. Cả bài .
#include <bits/stdc++.h>
using namespace std;
#define ll long long
const ll N = 2e5 + 5;
ll n, m, a[N];
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
cin >> n >> m;
for (ll i = 1; i <= n; i++) cin >> a[i];
sort(a + 1, a + n + 1);
for (ll j = 1; j <= m; j++) {
ll b;
cin >> b;
ll l = 1, r = n, res = n + 1;
while (l <= r) {
ll mid = (l + r) >> 1;
if (a[mid] > b) r = (res = mid) - 1;
else l = mid + 1;
}
cout << res - 1 << " \n"[j == m];
}
}
6 4
-2 5 5 0 9 1
-3 0 5 10
0 2 5 6
Tìm ranh giới
Chương trình ở mục trước chỉ chạm vào mảng ở một biểu thức, a[mid] >= x. Thay nó bằng một điều kiện bất kỳ đơn điệu, tức nếu đúng thì đúng với mọi , thì cùng vòng lặp ấy tìm ra giá trị nhỏ nhất mà đúng. Dãy giá trị của có dạng sai, …, sai, đúng, …, đúng, và ta đi tìm ranh giới. Trong code, là một hàm check.
ll l = lo, r = hi, res = -1;
while (l <= r) {
ll mid = (l + r) >> 1;
if (check(mid)) r = (res = mid) - 1;
else l = mid + 1;
}
Nhiều bài hỏi giá trị lớn nhất thỏa điều kiện; khi đó điều kiện đúng ở bên trái và sai ở bên phải. Chỉ cần đổi hướng: thỏa thì ghi nhận rồi tìm tiếp bên phải.
if (check(mid)) l = (res = mid) + 1;
else r = mid - 1;
Chứng minh y hệt mục trước. res = -1 (hay một giá trị nằm ngoài miền) cho biết không có giá trị nào thỏa, ta kiểm bằng một phép so sánh, không cần nhánh xử lý riêng.
Ví dụ gọn nhất là tính với mà không dùng số thực. Điều kiện đúng với nhỏ, sai với lớn; đáp án là giá trị lớn nhất còn đúng. Miền tìm là , và vẫn nằm trong long long.
ll l = 0, r = 1e9, res = 0;
while (l <= r) {
ll mid = (l + r) >> 1;
if (mid * mid <= n) l = (res = mid) + 1;
else r = mid - 1;
}
Dạng “phần tử thứ ” cũng đi qua khuôn này. Nếu đếm nhanh được là số phần tử không vượt , thì phần tử thứ là giá trị nhỏ nhất có , kể cả khi ta không liệt kê nổi các phần tử. không giảm theo nên điều kiện đơn điệu.
Ví dụPhần Tử Thứ K Ngoài Các Bội
Đề. Xét dãy tăng các số nguyên dương không chia hết cho (). Tìm phần tử thứ (). test.
Lời giải. Trong có bội của , nên số không chia hết. Đáp án là nhỏ nhất có . Vì , cứ hai số liên tiếp có ít nhất một số không chia hết cho , nên và miền tìm chắc chắn chứa đáp án. Mỗi test khoảng 31 vòng.
#include <bits/stdc++.h>
using namespace std;
#define ll long long
ll t, n, k;
signed main() {
cin >> t;
while (t--) {
cin >> n >> k;
ll l = 1, r = 2 * k, res = 2 * k;
while (l <= r) {
ll mid = (l + r) >> 1;
if (mid - mid / n >= k) r = (res = mid) - 1;
else l = mid + 1;
}
cout << res << '\n';
}
}
4
4 8
2 6
5 1
100 105
10
11
1
106
lower_bound và upper_bound
Hai hàm của STL là chặt nhị phân trên mảng đã sắp, đóng gói sẵn. Với dãy tăng và số :
lower_boundtrả vị trí đầu tiên có , đúng kết quả của mục đầu;upper_boundtrả vị trí đầu tiên có .
Cả hai trả end khi không có vị trí nào. Đọc theo nghĩa đếm dễ nhớ hơn: với mảng , lower_bound(a + 1, a + n + 1, x) - a - 1 bằng số phần tử nhỏ hơn , còn upper_bound trừ tương tự bằng số phần tử không lớn hơn . Hiệu hai hàm là số phần tử bằng .
Gọi lb là vị trí lower_bound (đánh số từ 1), các câu hỏi thường gặp đọc thẳng ra: có mặt khi lb <= n && a[lb] == x; phần tử nhỏ nhất không nhỏ hơn là a[lb]; phần tử gần nhất là một trong hai a[lb - 1] và a[lb], vì mọi phần tử trước a[lb - 1] và sau a[lb] đều xa hơn.
Ba điều cần nhớ khi dùng. Dãy phải được sắp đúng thứ tự mà hàm dùng; sắp giảm dần rồi gọi lower_bound mặc định thì kết quả sai mà không báo lỗi. Với set và map, gọi hàm thành viên s.lower_bound(x), tốn ; hàm tự do lower_bound(s.begin(), s.end(), x) vẫn đúng nhưng đi từng nút và tốn . Và khi cần điều kiện khác “nhỏ hơn ”, viết khuôn ở mục trước còn nhanh hơn đi tìm hàm thư viện.
Dãy không đổi giữa các truy vấn thì đừng bỏ vào set: cùng mỗi truy vấn, nhưng mảng đã sắp nằm liền trong bộ nhớ, còn set là các nút rải rác.
Mảng đã sắp nhanh hơn khoảng 6 lần, tính cả lúc dựng. set chỉ đáng dùng khi phải chèn, xóa xen giữa các truy vấn.
Hỏi nhanh
Dãy đã sắp là a = {2, 4, 4, 4, 9} (lưu từ a[0]). Có bao nhiêu phần tử không vượt quá 4, và biểu thức nào cho đúng số đó?
Các phần tử không vượt quá 4 là 2, 4, 4, 4; vị trí đầu tiên có giá trị lớn hơn 4 là chỉ số 4, nên upper_bound trừ a bằng 4. lower_bound trừ a bằng 1 là số phần tử nhỏ hơn 4. Hiệu hai hàm bằng 3 là số phần tử bằng 4. binary_search chỉ trả lời có hay không.
Ví dụChi Phí Đưa Về Cùng Mốc
Đề. Dãy số, truy vấn (độc lập), mỗi truy vấn hỏi tổng . , .
Lời giải. Dấu giá trị tuyệt đối tách theo một điều kiện: phần tử nhỏ hơn góp , phần tử không nhỏ hơn góp . Nếu đã sắp thì nhóm “nhỏ hơn ” là phần tử đầu, với tìm bằng lower_bound, và tổng mỗi nhóm lấy bằng tổng tiền tố (bài 02):
Mỗi truy vấn ; duyệt cả dãy cho từng truy vấn tốn . Tích tới , dùng long long.
#include <bits/stdc++.h>
using namespace std;
#define ll long long
const ll N = 2e5 + 5;
ll n, q, a[N], pre[N];
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
cin >> n >> q;
for (ll i = 1; i <= n; i++) cin >> a[i];
sort(a + 1, a + n + 1);
for (ll i = 1; i <= n; i++) pre[i] = pre[i - 1] + a[i];
while (q--) {
ll x;
cin >> x;
ll k = lower_bound(a + 1, a + n + 1, x) - a - 1;
cout << k * x - pre[k] + (pre[n] - pre[k]) - (n - k) * x << '\n';
}
}
3 3
1 4 8
0
5
10
13
8
17
Chặt nhị phân trên đáp án
Nhiều bài không có mảng nào để tìm, nhưng đáp án là một số nguyên trong miền rất lớn, và có một câu hỏi rẻ hơn câu hỏi gốc: “giá trị đã đủ chưa?”. Nếu câu hỏi đó đơn điệu theo thì đáp án là ranh giới của nó.
Bài toán thử: sợi dây dài , cắt mỗi sợi thành các đoạn nguyên dài đúng , phần thừa bỏ đi, cần ít nhất đoạn; tìm lớn nhất. Thử từng tốn phép. Nhưng nếu độ dài cắt đủ đoạn thì mọi cũng đủ, vì : điều kiện đúng ở bên trái, sai ở bên phải, đúng dạng “giá trị lớn nhất”.
Mọi bài kiểu này đi qua bốn câu hỏi, trả lời chúng trước khi gõ code:
- Đáp án là gì, nằm trong miền nào? Câu trả lời cho hai đầu
l,rvà giá trị ban đầu củares(trường hợp vô nghiệm). - Hàm
check(x)là gì, tốn bao lâu? Thường là một lượt duyệt , đôi khi kèm tham lam. - Vì sao
checkđơn điệu? Một câu dạng “nếu làm được thì cũng làm được, vì…”. - Hàm kiểm tra có tràn số không? Tổng chi phí là ; đặt miền rộng gần như miễn phí vì độ dài miền chỉ nằm trong logarit, nhưng giá trị lớn có thể làm tổng trong
checkvượtlong long. Cách chữa quen thuộc là dừng ngay khi tổng đã đủ mức cần.
Vài dạng lặp lại để nhận ra khi đọc đề. Tối thiểu hóa giá trị lớn nhất: “chia dãy thành đoạn sao cho tổng đoạn lớn nhất nhỏ nhất”; hỏi ngược có làm được với mọi phần không vượt không, điều kiện đúng từ một nào đó trở lên. Tối đa hóa giá trị nhỏ nhất: “đặt trạm sao cho khoảng cách nhỏ nhất lớn nhất”; hỏi có làm được với mọi khoảng cách không nhỏ hơn không. Hàm kiểm tra của cả hai thường là một lượt tham lam (bài 04).
Hỏi nhanh
Một bạn định tìm số nguyên dương nhỏ nhất có tổng chữ số không nhỏ hơn 10, bằng chặt nhị phân trên đoạn từ 1 đến 100 với điều kiện “tổng chữ số của không nhỏ hơn 10”. Có làm được không?
Đáp án thật là 19, nhưng 20 có tổng chữ số 2: điều kiện đúng ở 19 rồi sai ở 20. Lần thử đầu tại 50 (tổng 5, sai) đã loại cả nửa đầu chứa 19, và kết quả cuối không còn là 19. Tổng chữ số tụt xuống mỗi khi có nhớ, nên không đơn điệu; mở rộng cận không sửa được gì. Nhiều số thỏa không phải trở ngại: khuôn tìm số đầu tiên thỏa, chỉ cần đơn điệu.
Ví dụChia Dãy Cân Bằng
Đề. Dãy số nguyên dương , chia thành đúng đoạn liên tiếp không rỗng; giá của cách chia là tổng lớn nhất trong các đoạn. Tìm giá nhỏ nhất.
Lời giải. Dạng tối thiểu hóa giá trị lớn nhất. Gọi là “chia được thành đoạn mà đoạn nào cũng có tổng không vượt ”; đúng thì đúng, nên tìm nhỏ nhất thỏa.
Kiểm bằng tham lam: quét từ trái, cộng phần tử vào đoạn hiện tại, khi cộng thêm sẽ vượt thì mở đoạn mới. Tham lam dùng ít đoạn nhất có thể, vì sau đoạn nó luôn phủ được ít nhất bằng đoạn đầu của bất kỳ cách chia hợp lệ nào (các số dương nên đoạn nào kéo dài thêm cũng chỉ tốn hơn). Nếu nó dùng ít hơn đoạn thì tách bớt đoạn dài là đủ đoạn, tổng không tăng. Vậy đúng khi và chỉ khi tham lam dùng không quá đoạn, với điều kiện không nhỏ hơn phần tử lớn nhất.
Miền tìm là từ phần tử lớn nhất tới tổng mọi phần tử, tối đa 48 lần kiểm, mỗi lần : cỡ phép tính. Tổng tới nên dùng long long.
#include <bits/stdc++.h>
using namespace std;
#define ll long long
const ll N = 2e5 + 5;
ll n, k, a[N];
bool check(ll M) {
ll parts = 1, cur = 0;
for (ll i = 1; i <= n; i++) {
if (cur + a[i] > M) {
parts++;
cur = a[i];
} else cur += a[i];
}
return parts <= k;
}
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
cin >> n >> k;
ll mx = 0, sum = 0;
for (ll i = 1; i <= n; i++) {
cin >> a[i];
mx = max(mx, a[i]);
sum += a[i];
}
ll l = mx, r = sum, res = sum;
while (l <= r) {
ll mid = (l + r) >> 1;
if (check(mid)) r = (res = mid) - 1;
else l = mid + 1;
}
cout << res << '\n';
}
7 3
4 2 7 3 5 1 6
12
Trên ví dụ: thử được hai đoạn, đúng; cần bốn đoạn , sai; được ba đoạn, đúng; được , đúng. Đáp án 12.
Trên số thực
Khi đáp án là số thực, không còn “hai số nguyên liền nhau” để làm điều kiện dừng. Vòng lặp vẫn là chia đôi miền, nhưng ta chạy một số vòng cố định:
double l = 0, r = 1e9;
for (ll it = 1; it <= 100; it++) {
double mid = (l + r) / 2;
if (check(mid)) l = mid;
else r = mid;
}
Sau vòng, độ dài miền còn . Với miền cỡ và , đó là khoảng , nhỏ hơn mọi chênh lệch mà double phân biệt được; các vòng thừa vô hại vì khi hai đầu đã sát nhau, mid trùng một đầu và phép gán không đổi gì. Nếu check nặng thì 50 vòng đã đủ cho sai số trên miền .
Lý do ta không viết while (r - l > 1e-9): double chỉ giữ được khoảng 16 chữ số có nghĩa, nên ở cỡ hai số liền nhau cách nhau khoảng . Tới lúc l và r liền nhau, r - l kẹt ở khoảng cách đó và vòng lặp không dừng.
Hỏi nhanh
Một bạn chặt nhị phân trên số thực với miền , đáp án cỡ , và viết while (r - l > 1e-9). Điều gì có thể xảy ra?
Mỗi vòng chia đôi khoảng cách, nên đi từ xuống chỉ mất khoảng 60 vòng. Vấn đề là r - l không thể nhỏ hơn khoảng cách giữa hai số double liền nhau; khi chạm tới đó, hai đầu đứng yên và điều kiện vẫn đúng mãi. double không tràn ở cỡ này. Lặp cố định 50 đến 100 lần tránh được chuyện này.
Chặt nhị phân chỉ tìm ranh giới của điều kiện đơn điệu. Hàm có một đỉnh (tăng rồi giảm) thì cần chặt tam phân, bài 57.
Ví dụChia Bánh Tròn
Đề. bánh tròn bán kính , chia cho người, mỗi người một miếng cùng diện tích , mỗi miếng nằm trọn trong một bánh, phần thừa bỏ đi. Tìm lớn nhất, sai số .
Lời giải. Bánh diện tích cho miếng; tổng số miếng không tăng khi tăng, nên điều kiện “đủ miếng” đúng với nhỏ, sai với lớn. Miền là từ 0 tới diện tích bánh lớn nhất. Mỗi lần kiểm , dừng sớm khi đã đủ người; 100 vòng mỗi test. Dùng acos(-1) cho .
#include <bits/stdc++.h>
using namespace std;
#define ll long long
ll t, n, f;
double area[10005];
bool check(double s) {
ll cnt = 0;
for (ll i = 1; i <= n; i++) {
cnt += (ll)(area[i] / s);
if (cnt >= f + 1) return true;
}
return false;
}
signed main() {
cin >> t;
double pi = acos(-1);
while (t--) {
cin >> n >> f;
double l = 0, r = 0;
for (ll i = 1; i <= n; i++) {
ll x;
cin >> x;
area[i] = pi * x * x;
r = max(r, area[i]);
}
for (ll it = 1; it <= 100; it++) {
double mid = (l + r) / 2;
if (check(mid)) l = mid;
else r = mid;
}
printf("%.4f\n", l);
}
}
2
3 3
4 4 4
2 1
1 2
25.1327
6.2832
Khi đi thi
- Thấy “lớn nhất có thể mà vẫn…”, “nhỏ nhất để…”, “tối thiểu hóa giá trị lớn nhất” và có cách kiểm một giá trị cụ thể trong hay , hãy nghĩ ngay chặt nhị phân trên đáp án; trước khi viết, nói được vì sao hàm kiểm tra đơn điệu.
- Cài bằng khuôn
l,r,resvớilong longcho cả hai đầu lẫn tổng trong hàm kiểm tra, rồi thử ba test nhỏ: miền chỉ có một giá trị, đáp án sát một đầu của miền, và trường hợp vô nghiệm. - Dãy tĩnh với nhiều câu hỏi “có bao nhiêu phần tử ”, “phần tử gần nhất” thì sắp mảng một lần rồi dùng
lower_bound,upper_bound, không cầnset; đáp án thực thì lặp cố định 100 lần chứ không dừng theoeps.
Tóm tắt
- Khuôn
while (l <= r): thỏa thìr = (res = mid) - 1(tìm nhỏ nhất) hoặcl = (res = mid) + 1(tìm lớn nhất), không thỏa thì bỏmidvề phía kia; mất khoảng (độ dài miền) vòng. - Khuôn đúng vì đoạn luôn là các giá trị chưa biết và
reslà giá trị tốt nhất đã biết; chỉ cần điều kiện đơn điệu. lower_boundcho số phần tử nhỏ hơn ,upper_boundcho số phần tử không lớn hơn ; vớisetdùng hàm thành viên.- Chặt nhị phân trên đáp án: viết
check(x), chứng minh đơn điệu, chọn miền; chi phí là (miền) nhân giá một lần kiểm tra, và hàm kiểm tra phải tránh tràn số. - Số thực: lặp số vòng cố định (50 đến 100) thay vì dừng theo
eps.
Bài tập
Đọc thêm
- Tìm kiếm nhị phânVNOI Wiki
- Binary Searchcp-algorithms
- 二分OI Wiki
- Binary SearchUSACO Guide