Tìm nhị phân
Sau bài này bạn sẽ làm được
- Cài tìm nhị phân đúng với bất biến rõ ràng
- Giải thích vì sao cách tính trung điểm ngây thơ sai và viết lại cho đúng
- Cài tìm biên trái và biên phải
- Dùng bsearch của thư viện chuẩn
Tìm nhị phân là thuật toán nổi tiếng khó viết đúng. Jon Bentley từng cho hàng trăm lập trình viên chuyên nghiệp làm bài này, và chỉ khoảng mười phần trăm viết được bản không lỗi trong hai giờ. Cái khó nằm ở biên.
#Ý tưởng và bất biến
x có trong mảng thì nó nằm trong khoảng đang xét. Viết bất biến ra trước khi viết mã là cách chắc chắn nhất để không sai biên.#include <stddef.h>
/* Tìm x trong mảng ĐÃ SẮP TĂNG DẦN.
Trả về chỉ số một phần tử bằng x, hoặc n nếu không có.
Bất biến: nếu x có trong a thì nó nằm trong nửa khoảng [lo, hi). */
size_t tim_nhi_phan(const int *a, size_t n, int x)
{
size_t lo = 0;
size_t hi = n; /* hi nằm NGOÀI khoảng */
while (lo < hi) {
size_t giua = lo + (hi - lo) / 2; /* KHÔNG viết (lo + hi) / 2 */
if (a[giua] == x) return giua;
else if (a[giua] < x) lo = giua + 1; /* loại cả a[giua] */
else hi = giua; /* giua đã nằm ngoài, loại đúng */
}
return n;
}Khởi tạo khoảng bằng toàn mảng
lobằng 0 vàhibằngn. Bất biến đúng vì nếuxcó mặt thì nó chắc chắn nằm trong toàn mảng.Lấy phần tử giữa và so
Bằng thì trả về ngay. Nhỏ hơn
xthìxchỉ có thể ở bên phải, nênlonhảy tớigiua + 1. Lớn hơn thìxchỉ có thể ở bên trái, nênhithànhgiua.Lặp cho tới khi khoảng rỗng
Khoảng rỗng nghĩa là
lobằnghi. Bất biến nóixphải nằm trong khoảng, mà khoảng rỗng, nênxkhông có mặt.
| Bước | lo | hi | giua | a[giua] | Việc làm |
|---|---|---|---|---|---|
| Tìm 60 trong 10 20 30 40 50 60 70 80 | |||||
| 1 | 0 | 8 | 4 | 50 | 50 < 60, lo = 5 |
| 2 | 5 | 8 | 6 | 70 | 70 > 60, hi = 6 |
| 3 | 5 | 6 | 5 | 60 | bằng, trả về 5 |
#Lỗi tràn kinh điển
size_t giua = (lo + hi) / 2; /* tràn khi lo + hi vượt SIZE_MAX */size_t giua = lo + (hi - lo) / 2; /* hi - lo không bao giờ tràn */#Vì sao dùng nửa khoảng
| Nửa khoảng [lo, hi) | Khoảng đóng [lo, hi] | |
|---|---|---|
| Khởi tạo | lo = 0, hi = n | lo = 0, hi = n - 1 |
| Điều kiện lặp | lo < hi | lo <= hi |
| Nhánh trái | hi = giua | hi = giua - 1 |
| Nhánh phải | lo = giua + 1 | lo = giua + 1 |
| Số phần tử trong khoảng | hi - lo | hi - lo + 1 |
| Khoảng rỗng | lo == hi, tự nhiên | hi < lo, phải cẩn thận |
| Với size_t và n bằng 0 | hi = 0, vòng lặp không chạy | hi = n - 1 quấn thành SIZE_MAX, hỏng |
#Biên trái và biên phải
Với mảng có phần tử trùng, bản trên trả về một chỉ số bất kỳ trong số các vị trí bằng x. Thường ta cần cụ thể hơn.
/* Biên trái: chỉ số ĐẦU TIÊN có a[i] >= x.
Bằng n nếu mọi phần tử đều nhỏ hơn x.
Tương đương lower_bound của C++. */
size_t bien_trai(const int *a, size_t n, int x)
{
size_t lo = 0, hi = n;
while (lo < hi) {
size_t giua = lo + (hi - lo) / 2;
if (a[giua] < x) lo = giua + 1; /* a[giua] chắc chắn không đủ */
else hi = giua; /* a[giua] có thể là đáp án */
}
return lo;
}
/* Biên phải: chỉ số ĐẦU TIÊN có a[i] > x.
Tương đương upper_bound của C++. */
size_t bien_phai(const int *a, size_t n, int x)
{
size_t lo = 0, hi = n;
while (lo < hi) {
size_t giua = lo + (hi - lo) / 2;
if (a[giua] <= x) lo = giua + 1;
else hi = giua;
}
return lo;
}
/* Đếm số phần tử bằng x, chỉ hai lần tìm nhị phân. */
size_t dem_bang(const int *a, size_t n, int x)
{
return bien_phai(a, n, x) - bien_trai(a, n, x);
}
/* Có x trong mảng không */
int co_mat(const int *a, size_t n, int x)
{
size_t i = bien_trai(a, n, x);
return i < n && a[i] == x;
}#include <stdio.h>
int main(void)
{
int a[] = { 10, 20, 20, 20, 30, 40, 40, 50 };
size_t n = sizeof a / sizeof a[0];
printf("mang: ");
for (size_t i = 0; i < n; ++i) printf("%d ", a[i]);
printf("\n\n%6s %10s %10s %8s %8s\n",
"x", "bien_trai", "bien_phai", "so luong", "co mat");
int thu[] = { 5, 10, 20, 25, 40, 50, 99 };
for (size_t k = 0; k < sizeof thu / sizeof thu[0]; ++k) {
int x = thu[k];
printf("%6d %10zu %10zu %8zu %8d\n",
x, bien_trai(a, n, x), bien_phai(a, n, x),
dem_bang(a, n, x), co_mat(a, n, x));
}
return 0;
}mang: 10 20 20 20 30 40 40 50
x bien_trai bien_phai so luong co mat
5 0 0 0 0
10 0 1 1 1
20 1 4 3 1
25 4 4 0 0
40 5 7 2 1
50 7 8 1 1
99 8 8 0 0#bsearch của thư viện chuẩn
#include <stdio.h>
#include <stdlib.h>
/* Hàm so sánh: âm nếu a trước b, 0 nếu bằng, dương nếu a sau b.
Cùng hợp đồng với qsort, xem Bài 13.6. */
static int so_sanh_int(const void *pa, const void *pb)
{
int a = *(const int *)pa;
int b = *(const int *)pb;
return (a > b) - (a < b); /* KHÔNG viết a - b, sẽ tràn */
}
int main(void)
{
int a[] = { 10, 20, 30, 40, 50, 60, 70, 80 };
size_t n = sizeof a / sizeof a[0];
int x = 60;
int *p = bsearch(&x, a, n, sizeof *a, so_sanh_int);
if (p != NULL)
printf("thay %d tai chi so %td\n", *p, p - a);
else
printf("khong thay %d\n", x);
return 0;
}thay 60 tai chi so 5
| bsearch | Tự viết | |
|---|---|---|
| Dùng được với mọi kiểu | Có | Phải viết lại cho từng kiểu |
| Tốc độ | Chậm hơn 3 tới 5 lần | Nhanh |
| Trả về biên trái | Không, trả về vị trí bất kỳ | Có nếu bạn viết |
| Cho biết chèn vào đâu khi không thấy | Không | Có với bien_trai |
| Số dòng phải viết | Chỉ hàm so sánh | Khoảng 12 |
bsearch : 1.842 s tu viet : 0.412 s nhanh hon : 4.5 lan
#Tìm nhị phân trên đáp án
Tìm nhị phân không chỉ dùng cho mảng. Bất cứ khi nào bạn có một hàm đơn điệu và cần tìm điểm chuyển, tìm nhị phân đều áp dụng được.
duoc(x) đơn điệu, tức nếu duoc(x) đúng thì duoc(y) cũng đúng với mọi y lớn hơn x.#include <stdio.h>
/* Bài toán: n gói kẹo, gói thứ i có a[i] viên.
Chia cho k đứa trẻ, mỗi đứa nhận kẹo từ MỘT gói duy nhất,
và mọi đứa phải nhận cùng số viên. Không được ghép hai gói.
Hỏi mỗi đứa nhận nhiều nhất bao nhiêu viên. */
/* Vị từ đơn điệu: với v viên mỗi đứa, chia đủ cho k đứa không.
Nếu chia đủ với v thì cũng chia đủ với mọi giá trị nhỏ hơn v,
nên hàm này đơn điệu GIẢM theo v. */
static int du_cho(const long *a, size_t n, long v, long k)
{
if (v <= 0) return 1;
long tong = 0;
for (size_t i = 0; i < n; ++i) {
tong += a[i] / v;
if (tong >= k) return 1; /* dừng sớm, tránh tràn */
}
return 0;
}
/* Tìm v lớn nhất mà du_cho vẫn đúng. */
long chia_keo(const long *a, size_t n, long k)
{
long lo = 0; /* luôn đủ */
long hi = 0; /* tìm cận trên */
for (size_t i = 0; i < n; ++i)
if (a[i] > hi) hi = a[i];
++hi; /* hi chắc chắn KHÔNG đủ */
/* Bất biến: du_cho(lo) đúng, du_cho(hi) sai. */
while (hi - lo > 1) {
long giua = lo + (hi - lo) / 2;
if (du_cho(a, n, giua, k)) lo = giua;
else hi = giua;
}
return lo;
}
int main(void)
{
long a[] = { 12, 8, 5, 20, 3 };
size_t n = sizeof a / sizeof a[0];
for (long k = 1; k <= 8; ++k)
printf("k = %ld -> moi dua nhan %ld vien\n", k, chia_keo(a, n, k));
return 0;
}k = 1 -> moi dua nhan 20 vien k = 2 -> moi dua nhan 12 vien k = 3 -> moi dua nhan 10 vien k = 4 -> moi dua nhan 8 vien k = 5 -> moi dua nhan 6 vien k = 6 -> moi dua nhan 6 vien k = 7 -> moi dua nhan 5 vien k = 8 -> moi dua nhan 5 vien
Tự làm thử
- Cài
tim_nhi_phanbản nửa khoảng, kiểm với mảng rỗng, một phần tử, và phần tử nằm ở hai đầu. - Cài bản khoảng đóng với
size_t, gọi nó vớinbằng 0, và chạy dưới-fsanitize=address. - Cài
bien_traivàbien_phai, kiểm kết quả với mảng có phần tử trùng theo bảng trong bài. - Viết hàm so sánh dùng
a - b, gọiqsorttrên mảng chứa cả số rất lớn và rất nhỏ, rồi chạy dưới-fsanitize=undefined. - Giải bài chia kẹo bằng tìm nhị phân trên đáp án, rồi kiểm bằng cách duyệt vét cạn với dữ liệu nhỏ.
Trình chấm điểm tự động sẽ được bổ sung ở giai đoạn sau. Hiện tại bạn tự chạy thử trên máy.
Tóm tắt
- Luôn viết
lo + (hi - lo) / 2. Cách viết(lo + hi) / 2tràn, và lỗi đó từng nằm trong thư viện chuẩn Java chín năm. - Nửa khoảng
[lo, hi)an toàn hơn khoảng đóng khi dùngsize_t, vì nó không bao giờ tínhn - 1haygiua - 1. bien_traivàbien_phaichỉ khác nhau một dấu bằng, và từ chúng dựng được đếm, kiểm tra tồn tại và truy vấn theo khoảng.- Trong hàm so sánh, đừng viết
a - b. Dùng(a > b) - (a < b)để không bao giờ tràn. - Tìm nhị phân áp dụng cho mọi vị từ đơn điệu, không chỉ cho mảng. Đó là kỹ thuật tìm nhị phân trên đáp án.