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

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

Bất biến của vòng lặp
Mệnh đề đúng trước mỗi lần lặp và sau mỗi lần lặp. Với tìm nhị phân, bất biến là: nếu 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.
nhi-phan.c
#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;
}
  1. Khởi tạo khoảng bằng toàn mảng

    lo bằng 0 và hi bằng n. Bất biến đúng vì nếu x có mặt thì nó chắc chắn nằm trong toàn mảng.

  2. Lấy phần tử giữa và so

    Bằng thì trả về ngay. Nhỏ hơn x thì x chỉ có thể ở bên phải, nên lo nhảy tới giua + 1. Lớn hơn thì x chỉ có thể ở bên trái, nên hi thành giua.

  3. Lặp cho tới khi khoảng rỗng

    Khoảng rỗng nghĩa là lo bằng hi. Bất biến nói x phải nằm trong khoảng, mà khoảng rỗng, nên x không có mặt.

Bướclohigiuaa[giua]Việc làm
Tìm 60 trong 10 20 30 40 50 60 70 80
10845050 < 60, lo = 5
25867070 > 60, hi = 6
356560bằng, trả về 5

#Lỗi tràn kinh điển

Cộng rồi chia
size_t giua = (lo + hi) / 2;      /* tràn khi lo + hi vượt SIZE_MAX */
Trừ, chia, rồi cộng
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ạolo = 0, hi = nlo = 0, hi = n - 1
Điều kiện lặplo < hilo <= hi
Nhánh tráihi = giuahi = giua - 1
Nhánh phảilo = giua + 1lo = giua + 1
Số phần tử trong khoảnghi - lohi - lo + 1
Khoảng rỗnglo == hi, tự nhiênhi < lo, phải cẩn thận
Với size_t và n bằng 0hi = 0, vòng lặp không chạyhi = 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.

bien.c
/* 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;
}
main.c
#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;
}
terminal
gcc -std=c17 -Wall -Wextra bien.c main.c -o t && ./t
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

dung-bsearch.c
#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;
}
terminal
gcc -std=c17 -Wall -Wextra dung-bsearch.c -o t && ./t
thay 60 tai chi so 5
bsearchTự viết
Dùng được với mọi kiểuCóPhải viết lại cho từng kiểu
Tốc độChậm hơn 3 tới 5 lầnNhanh
Trả về biên tráiKhô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ấyKhôngCó với bien_trai
Số dòng phải viếtChỉ hàm so sánhKhoảng 12
terminal
./do-bsearch 10000000
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.

Tìm nhị phân trên đáp án
Thay vì tìm trong dữ liệu, ta tìm trong miền giá trị của đáp án. Điều kiện: phải có một vị từ 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.
chia-keo.c
#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;
}
terminal
gcc -std=c17 -Wall -Wextra chia-keo.c -o t && ./t
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ử

  1. Cài tim_nhi_phan bả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.
  2. Cài bản khoảng đóng với size_t, gọi nó với n bằng 0, và chạy dưới -fsanitize=address.
  3. Cài bien_trai và bien_phai, kiểm kết quả với mảng có phần tử trùng theo bảng trong bài.
  4. Viết hàm so sánh dùng a - b, gọi qsort trên mảng chứa cả số rất lớn và rất nhỏ, rồi chạy dưới -fsanitize=undefined.
  5. 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) / 2 trà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ùng size_t, vì nó không bao giờ tính n - 1 hay giua - 1.
  • bien_trai và bien_phai chỉ 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.