Bỏ qua điều hướng, tới nội dung chính
Học C
Bài 9.522 phút đọc

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

tuyen-tinh.c
/* 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ợpSố phép so sánhKhi nào xảy ra
Tốt nhất1Phần tử cần tìm nằm ngay đầu
Trung bìnhkhoảng n chia 2Vị trí ngẫu nhiên
Xấu nhấtnNằ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

Tìm nhị phân
Thuật toán chỉ dùng được với mảng đã sắp xếp. Mỗi lượt, nó so với phần tử ở giữa khoảng đang xét rồi loại bỏ hẳn một nửa.
Mỗi lượt loại bỏ một nửa số phần tử còn lại. Ô mờ là phần đã bị loại khỏi vùng tìm kiếm.
nhi-phan.c
/* 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

nua-mo.c
/* 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 hin - 1n
Điều kiện lặplo <= hilo < hi
Thu hẹp phảihi = mid - 1hi = mid
Rủi ro với size_tCó, khi mid bằng 0Không
Hoạt động khi n bằng 0Cần kiểm tra riêngTự 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 lohi đề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.

lohi(lo + hi) / 2 với intlo + (hi - lo) / 2
0944
20000000002000000100âm, do tràn2000000050

#So sánh hai thuật toán

Số phần tửTuyến tính, xấu nhấtNhị phân, xấu nhất
10104
1 0001 00010
1 000 0001 000 00020
1 000 000 0001 000 000 00030

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ỹ.

Dùng tìm nhị phân trên mảng chưa sắp xếp
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. */
Sắp xếp trước, hoặc dùng tìm tuyến tính
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

bsearch.c
#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;
}
terminal
./bsearch
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 nmembSố phần tử
size_t sizeKí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ử

  1. 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ử.
  2. Đế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.
  3. Cài vi_tri_chen và dùng nó để đếm số phần tử nhỏ hơn một giá trị cho trước.
  4. Cố tình viết (lo + hi) / 2 với kiểu int và hai giá trị gần hai tỷ, in kết quả để thấy tràn số.
  5. 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.
  6. Đ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₂ n lượ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 bsearchqsort nên viết (a > b) - (a < b), không viết a - b.