Tìm kiếm
Sau bài này bạn sẽ làm được
- Cài tìm tuyến tính và tìm nhị phân đúng ở mọi trường hợp biên
- Giải thích vì sao lo + (hi - lo) / 2 an toàn hơn (lo + hi) / 2
- Chọn đúng thuật toán theo kích thước và tính chất dữ liệu
- Dùng bsearch của thư viện chuẩn
Tìm kiếm là bài toán đầu tiên mà lựa chọn thuật toán tạo ra khác biệt khổng lồ. Với một triệu phần tử, một cách cần tối đa một triệu phép so sánh, cách kia cần đúng hai mươi. Bài này cài cả hai và chỉ ra cái bẫy tràn số nổi tiếng nhất trong lịch sử lập trình.
#Tìm tuyến tính
/* Trả về chỉ số nếu tìm thấy, âm một nếu không.
Kiểu trả về có dấu vì phải diễn tả được giá trị âm một. */
long tim_tuyen_tinh(const int *a, size_t n, int x)
{
for (size_t i = 0; i < n; ++i)
if (a[i] == x)
return (long)i;
return -1;
}| Trường hợp | Số phép so sánh | Khi nào xảy ra |
|---|---|---|
| Tốt nhất | 1 | Phần tử cần tìm nằm ngay đầu |
| Trung bình | khoảng n chia 2 | Vị trí ngẫu nhiên |
| Xấu nhất | n | Nằm cuối, hoặc không có trong mảng |
Biến thể: tìm mọi vị trí
size_t tim_het(const int *a, size_t n, int x, size_t *ket_qua, size_t co)
{
size_t dem = 0;
for (size_t i = 0; i < n; ++i)
if (a[i] == x && dem < co)
ket_qua[dem++] = i;
return dem;
}Chú ý điều kiện dem < co. Nếu không có nó, hàm sẽ ghi tràn mảng kết quả khi số lần xuất hiện nhiều hơn chỗ người gọi cấp. Đây là thói quen bạn nên tập ngay: mọi hàm ghi vào bộ đệm của người gọi đều phải nhận kích thước bộ đệm đó.
#Tìm nhị phân
/* Khoảng đóng [lo, hi]. Cả hai đầu đều nằm trong vùng đang xét. */
long tim_nhi_phan(const int *a, size_t n, int x)
{
if (n == 0) return -1;
size_t lo = 0;
size_t hi = n - 1;
while (lo <= hi) {
size_t mid = lo + (hi - lo) / 2;
if (a[mid] == x)
return (long)mid;
if (a[mid] < x)
lo = mid + 1;
else {
if (mid == 0) break; /* tránh trừ dưới 0 với size_t */
hi = mid - 1;
}
}
return -1;
}Phiên bản khoảng nửa mở, gọn hơn
/* Khoảng nửa mở [lo, hi). Đầu hi KHÔNG nằm trong vùng xét.
Cách này tránh hẳn phép trừ nên không có bẫy nào. */
long tim_nhi_phan2(const int *a, size_t n, int x)
{
size_t lo = 0;
size_t hi = n; /* n chứ không phải n - 1 */
while (lo < hi) { /* dấu bé hơn chứ không phải bé hơn hoặc bằng */
size_t mid = lo + (hi - lo) / 2;
if (a[mid] == x) return (long)mid;
if (a[mid] < x) lo = mid + 1;
else hi = mid; /* không có phép trừ */
}
return -1;
}| Khoảng đóng [lo, hi] | Khoảng nửa mở [lo, hi) | |
|---|---|---|
| Khởi tạo hi | n - 1 | n |
| Điều kiện lặp | lo <= hi | lo < hi |
| Thu hẹp phải | hi = mid - 1 | hi = mid |
| Rủi ro với size_t | Có, khi mid bằng 0 | Không |
| Hoạt động khi n bằng 0 | Cần kiểm tra riêng | Tự nhiên đúng |
Biến thể: tìm vị trí chèn
/* Trả về vị trí đầu tiên mà a[i] >= x. Nếu x lớn hơn mọi phần tử thì trả về n.
Đây là công cụ để chèn giữ thứ tự, hoặc đếm số phần tử nhỏ hơn x. */
size_t vi_tri_chen(const int *a, size_t n, int x)
{
size_t lo = 0, hi = n;
while (lo < hi) {
size_t mid = lo + (hi - lo) / 2;
if (a[mid] < x) lo = mid + 1;
else hi = mid;
}
return lo;
}Hàm này hữu ích hơn hàm tìm thông thường. Nó cho biết cả câu trả lời có hay không, lẫn chỗ nên chèn nếu chưa có. Kiểm tra i < n && a[i] == x là biết phần tử có trong mảng hay không.
#Bẫy tràn khi tính điểm giữa
size_t mid = (lo + hi) / 2; /* cách viết trực giác, nhưng có thể tràn */
size_t mid = lo + (hi - lo) / 2; /* cách viết đúng */Nếu lo và hi đều rất lớn, tổng của chúng vượt quá giới hạn kiểu và cho ra một số sai. Với int ba hai bit, chuyện này xảy ra khi mảng có hơn khoảng một tỷ phần tử. Nghe xa vời, nhưng nó đã thật sự xảy ra.
Vì sao lo + (hi - lo) / 2 an toàn: hiệu hi - lo luôn nhỏ hơn hoặc bằng hi, nên không tràn. Chia đôi rồi cộng vào lo cũng không vượt quá hi. Kết quả toán học giống hệt cách viết kia.
| lo | hi | (lo + hi) / 2 với int | lo + (hi - lo) / 2 |
|---|---|---|---|
| 0 | 9 | 4 | 4 |
| 2000000000 | 2000000100 | âm, do tràn | 2000000050 |
#So sánh hai thuật toán
| Số phần tử | Tuyến tính, xấu nhất | Nhị phân, xấu nhất |
|---|---|---|
| 10 | 10 | 4 |
| 1 000 | 1 000 | 10 |
| 1 000 000 | 1 000 000 | 20 |
| 1 000 000 000 | 1 000 000 000 | 30 |
Số lượt của tìm nhị phân là log₂ n làm tròn lên. Mỗi lần dữ liệu tăng gấp đôi, nó chỉ tốn thêm đúng một lượt. Đó là lý do thuật toán này đáng học kỹ.
int a[5] = { 5, 1, 4, 2, 8 };
tim_nhi_phan(a, 5, 1); /* trả về âm một dù số 1 có trong mảng */
/* Trớ trêu là tim_nhi_phan(a, 5, 4) lại trả về đúng chỉ số 2, hoàn toàn do
may mắn vì phần tử giữa tình cờ là giá trị cần tìm. Đó là điều làm lỗi này
nguy hiểm: nó đúng với vài dữ liệu thử rồi sai với dữ liệu thật. */int a[5] = { 5, 1, 4, 2, 8 };
/* Nếu chỉ tìm một lần thì tìm tuyến tính rẻ hơn nhiều */
tim_tuyen_tinh(a, 5, 1);
/* Nếu sẽ tìm nhiều lần thì sắp xếp một lần rồi tìm nhị phân */
qsort(a, 5, sizeof a[0], so_sanh_int);
tim_nhi_phan(a, 5, 1);#bsearch của thư viện chuẩn
#include <stdlib.h>
#include <stdio.h>
static int so_sanh_int(const void *x, const void *y)
{
int a = *(const int *)x;
int b = *(const int *)y;
return (a > b) - (a < b); /* an toàn, không bị tràn như a - b */
}
int main(void)
{
int a[] = { 2, 5, 8, 12, 16, 23, 38, 56, 72, 91 };
size_t n = sizeof a / sizeof a[0];
int can_tim = 23;
int *p = bsearch(&can_tim, a, n, sizeof a[0], so_sanh_int);
if (p != NULL)
printf("Tim thay tai chi so %td\n", p - a);
else
printf("Khong tim thay\n");
return 0;
}Tim thay tai chi so 5
| Tham số của bsearch | Ý nghĩa |
|---|---|
| const void *key | Địa chỉ của giá trị cần tìm |
| const void *base | Địa chỉ đầu mảng |
| size_t nmemb | Số phần tử |
| size_t size | Kích thước mỗi phần tử tính bằng byte |
| int (*compar)(...) | Hàm so sánh, trả về âm, 0 hoặc dương |
Hàm bsearch hoạt động với mọi kiểu phần tử, kể cả struct, đúng nhờ hai tham số size và hàm so sánh. Cơ chế void * đứng sau nó là chủ đề của Chương 30. Nhược điểm là nó chậm hơn bản tự cài cho int vì mỗi phép so sánh là một lời gọi hàm qua con trỏ.
Tự làm thử
- Cài cả hai phiên bản tìm nhị phân, thử với mảng rỗng, mảng một phần tử, và giá trị nhỏ hơn mọi phần tử.
- Đếm số lượt lặp thật sự của tìm nhị phân với n bằng 1 000, 1 000 000 và 1 000 000 000, đối chiếu với công thức log cơ số hai.
- Cài
vi_tri_chenvà dùng nó để đếm số phần tử nhỏ hơn một giá trị cho trước. - Cố tình viết
(lo + hi) / 2với kiểuintvà hai giá trị gần hai tỷ, in kết quả để thấy tràn số. - Dùng
bsearchđể tìm trong mảng struct sinh viên đã sắp theo mã số, viết hàm so sánh riêng. - Đo thời gian tìm tuyến tính và tìm nhị phân trên mảng một triệu phần tử với mười nghìn lần tìm.
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
- Tìm tuyến tính chạy với mọi mảng, tìm nhị phân chỉ chạy với mảng đã sắp xếp.
- Tìm nhị phân tốn
log₂ nlượt: hai mươi lượt cho một triệu phần tử, ba mươi lượt cho một tỷ. - Luôn tính điểm giữa bằng
lo + (hi - lo) / 2để tránh tràn số. - Phiên bản khoảng nửa mở gọn hơn, không có trường hợp đặc biệt, và đúng tự nhiên với mảng rỗng.
- Hàm so sánh cho
bsearchvàqsortnên viết(a > b) - (a < b), không viếta - b.