Bài giải thích miễn phí · Video · Code · Trắc nghiệm
Tìm kiếm nhị phân (Binary Search)
Tìm một giá trị trong mảng đã sắp xếp bằng cách nhìn phần tử ở giữa rồi bỏ đi nửa chắc chắn không chứa nó — một triệu phần tử chỉ mất khoảng 20 bước. Kèm luôn lowerBound, dạng hay gặp nhất khi phỏng vấn.
- Độ khó
- Dễ
- Thời gian
- O(log n)
- Bộ nhớ
- O(1)
Tự tay chạy thử
Chạy từng bước theo nhịp của bạn — đây là Binary Search Animation, chạy ngay trên trang.
Đang chạyBinary Search Animation
Đang tải bản xem trước…
Cách hoạt động
Ý tưởng, một ví dụ chạy thử, và những bẫy nên tránh.
Ý tưởng
Thử chơi trò "đoán số từ 1 đến 100". Đoán lần lượt 1, 2, 3… là cách tệ nhất. Cách khôn hơn là đoán 50, nghe "lớn hơn" thì đoán 75, nghe "nhỏ hơn" thì đoán 62… Mỗi câu trả lời loại được một nửa số khả năng còn lại, nên không bao giờ cần quá 7 lần đoán.
Tìm kiếm nhị phân chính là trò chơi đó, chơi trên một mảng đã sắp xếp. Nhìn phần tử ở giữa. Nếu đúng giá trị cần tìm thì xong. Nếu nó nhỏ hơn, giá trị cần tìm chỉ có thể nằm bên phải; nếu nó lớn hơn thì chỉ có thể nằm bên trái. Bỏ nửa kia đi và làm lại với phần còn lại.
Điều kiện duy nhất là mảng phải được sắp xếp. Với dữ liệu lộn xộn, bước "bỏ một nửa" không còn đúng nữa, và thuật toán sẽ lặng lẽ trả về kết quả sai.
Chạy thử từng bước
Tìm 12 trong [1, 3, 5, 7, 9, 11, 13, 15] (chỉ số từ 0 đến 7). Ta giữ một khoảng [lo, hi]: nếu 12 có trong mảng thì nó phải nằm trong khoảng này.
| Bước | lo | hi | mid | sorted[mid] | Quyết định |
|---|---|---|---|---|---|
| 1 | 0 | 7 | 3 | 7 | 7 < 12, sang phải: lo = 4 |
| 2 | 4 | 7 | 5 | 11 | 11 < 12, sang phải: lo = 6 |
| 3 | 6 | 7 | 6 | 13 | 13 > 12, sang trái: hi = 5 |
| 4 | 6 | 5 | – | – | lo > hi: khoảng rỗng, trả về -1 |
Chỉ cần nhìn vào mảng 3 lần là đủ chắc 12 không có trong 8 phần tử. Nếu tìm 11 thì dừng ngay ở bước 2 và trả về 5. Để ý chỗ lo dừng lại khi tìm thất bại: chỉ số 6, đúng vị trí cần chèn 12 vào để mảng vẫn được sắp xếp. Đó chính là ý tưởng của lowerBound ở dưới.
Vì sao thuật toán đúng
Vòng lặp luôn giữ một lời hứa, gọi là bất biến (invariant): nếu giá trị cần tìm có trong mảng, nó nằm trong [lo, hi].
- Lúc đầu khoảng là cả mảng, nên lời hứa đúng.
- Khi
sorted[mid] < target, mọi phần tử từlođếnmidđều không lớn hơnsorted[mid], tức là cũng quá nhỏ. Dờilolênmid + 1vẫn giữ được lời hứa. Nhánh còn lại đối xứng y hệt. - Mỗi bước loại ít nhất phần tử
mid, nên khoảng luôn co lại và vòng lặp chắc chắn kết thúc.
Khi khoảng rỗng, lời hứa cho biết giá trị đó không có trong mảng. Chứng minh chỉ có vậy.
lowerBound: phiên bản bạn sẽ dùng nhiều nhất
lowerBound(sorted, target) trả về chỉ số đầu tiên có giá trị ≥ target. Nếu mọi phần tử đều nhỏ hơn, nó trả về sorted.length. Hàm này dùng khoảng nửa mở [lo, hi) với một lời hứa khác: mọi phần tử trước lo đều < target, mọi phần tử từ hi trở đi đều ≥ target. Khi lo gặp hi, điểm gặp nhau chính là đáp án.
Nó trả lời được nhiều câu hỏi hơn bản tìm kiếm thường:
- Vị trí xuất hiện đầu tiên của một giá trị bị lặp: lấy
lowerBound(a, x)rồi kiểm traa[i] === x. - Số lần
xxuất hiện:lowerBound(a, x + 1) - lowerBound(a, x). - Vị trí chèn
xđể mảng vẫn được sắp xếp.
Thư viện chuẩn nào cũng có: bisect.bisect_left của Python, std::lower_bound của C++.
Độ phức tạp
Mỗi bước chia đôi khoảng tìm kiếm: n, n/2, n/4… cho tới 1. Như vậy mất khoảng log₂ n bước, nên độ phức tạp thời gian là O(log n).
| n | Số bước (tệ nhất) |
|---|---|
| 1.000 | 10 |
| 1.000.000 | 20 |
| 1.000.000.000 | 30 |
Bộ nhớ là O(1). Bản dùng vòng lặp chỉ cần lo, hi và mid. Bản đệ quy làm cùng việc nhưng tốn thêm O(log n) ngăn xếp.
Lỗi hay gặp
- Trộn hai kiểu khoảng.
while (lo <= hi)đi vớihi = n - 1vàhi = mid - 1(khoảng đóng).while (lo < hi)đi vớihi = nvàhi = mid(khoảng nửa mở). Trộn lẫn hai kiểu là bỏ sót phần tử cuối hoặc lặp vô hạn. Mỗi hàm chỉ chọn một kiểu, và ghi chú rõ là kiểu nào. - Viết
lo = midthay cholo = mid + 1. Khihi = lo + 1thìmidbằnglo, không có gì thay đổi, và vòng lặp chạy mãi. - Tràn số ở
(lo + hi) / 2. Với số nguyên 32 bit,lo + hicó thể vượt 2.147.483.647 và hóa thành số âm. Chính lỗi này đã nằm trongArrays.binarySearchcủa Java khoảng chín năm trước khi bị phát hiện.lo + (hi - lo) / 2thì không bao giờ tràn. Số trong JavaScript và số nguyên trong Python không bị tràn ở đây, nhưng viết theo dạng an toàn chẳng tốn gì, nên cứ tập thành thói quen. - Tưởng sẽ nhận được vị trí đầu tiên. Khi có phần tử trùng, bản cổ điển trả về một vị trí khớp nào đó. Cần vị trí đầu tiên thì dùng
lowerBound.
Khi nào nên dùng, khi nào không
Nên dùng khi dữ liệu đã sắp xếp và cần tra cứu nhiều lần. Cũng dùng được khi bạn tìm trên một câu hỏi có/không chỉ đổi đáp án đúng một lần, kiểu "có xong kịp trong t ngày không?". Kỹ thuật này gọi là tìm kiếm nhị phân trên đáp án.
Không nên dùng khi:
- Dữ liệu chưa sắp xếp và chỉ tìm một lần. Sắp xếp tốn O(n log n), lâu hơn một lần duyệt O(n).
- Dữ liệu thay đổi liên tục. Cây cân bằng hoặc tập băm hợp hơn.
- Bạn chỉ cần tra đúng giá trị. Bảng băm làm việc đó trong O(1) trung bình.
- Dữ liệu là danh sách liên kết. Không thể nhảy thẳng tới phần tử ở giữa.
Biến thể khi phỏng vấn
- Tìm trong mảng sắp xếp bị xoay (
[4, 5, 6, 7, 0, 1, 2]). Ở bất kỳmidnào, luôn có một nửa vẫn được sắp xếp. Kiểm tra xem giá trị cần tìm có nằm trong khoảng giá trị của nửa đó không; nếu không thì sang nửa còn lại. Vẫn là O(log n). - Vị trí đầu và cuối của một giá trị. Vị trí đầu là
lowerBound(a, x). Vị trí cuối làlowerBound(a, x + 1) - 1. Hai lần tìm, không phải duyệt tuyến tính. - Tìm kiếm nhị phân trên đáp án. "Tải trọng tàu nhỏ nhất để chở hết hàng trong D ngày", "căn bậc hai nguyên", "Koko ăn chuối". Không hề có mảng, nhưng
feasible(answer)lần lượt là false…false rồi true…true. Tìm đáp án đầu tiên cho ratruebằng chính vòng lặp củalowerBound.
Code
Cùng một thuật toán, từng bước như nhau, bằng 4 ngôn ngữ.
// Binary search on an array sorted in ascending order.
// Every step looks at the middle of the range that can still hold the answer
// and throws away the half that cannot — so n items take about log2(n) steps.
/**
* Finds `target` in `sorted`.
* With duplicates it returns one of the matching indexes, not necessarily the first —
* use `lowerBound` when you need the first one.
*
* @param {number[]} sorted values in ascending order
* @param {number} target the value to look for
* @returns {number} an index i with sorted[i] === target, or -1 if there is none
*/
export function binarySearch(sorted, target) {
let lo = 0;
let hi = sorted.length - 1; // the answer, if any, is inside [lo, hi] (both ends included)
while (lo <= hi) {
// Same as (lo + hi) / 2, but lo + hi can overflow a fixed-size integer in other languages.
const mid = lo + Math.floor((hi - lo) / 2);
if (sorted[mid] === target) return mid;
if (sorted[mid] < target) {
lo = mid + 1; // sorted[lo..mid] are all too small
} else {
hi = mid - 1; // sorted[mid..hi] are all too big
}
}
return -1; // the range is empty: target is not in the array
}
/**
* The first index whose value is >= `target` — where `target` would be inserted to keep the
* array sorted. Returns `sorted.length` when every value is smaller than `target`.
*
* @param {number[]} sorted values in ascending order
* @param {number} target the value to look for
* @returns {number} an index in [0, sorted.length]
*/
export function lowerBound(sorted, target) {
let lo = 0;
let hi = sorted.length; // the answer is inside [lo, hi) — hi itself is a valid answer
while (lo < hi) {
const mid = lo + Math.floor((hi - lo) / 2);
if (sorted[mid] < target) {
lo = mid + 1; // mid and everything before it are too small
} else {
hi = mid; // mid might be the answer, so keep it in the range
}
}
return lo; // lo === hi: the first index whose value is >= target
}
# Binary search on a list sorted in ascending order.
# Every step looks at the middle of the range that can still hold the answer
# and throws away the half that cannot — so n items take about log2(n) steps.
from typing import Sequence
def binary_search(sorted_values: Sequence[int], target: int) -> int:
"""Return an index i with sorted_values[i] == target, or -1 if there is none.
With duplicates it returns one of the matching indexes, not necessarily
the first — use lower_bound when you need the first one.
"""
lo = 0
hi = len(sorted_values) - 1 # the answer, if any, is inside [lo, hi] (both ends included)
while lo <= hi:
# Python integers never overflow, but this form is safe in every language.
mid = lo + (hi - lo) // 2
if sorted_values[mid] == target:
return mid
if sorted_values[mid] < target:
lo = mid + 1 # sorted_values[lo..mid] are all too small
else:
hi = mid - 1 # sorted_values[mid..hi] are all too big
return -1 # the range is empty: target is not in the list
def lower_bound(sorted_values: Sequence[int], target: int) -> int:
"""Return the first index whose value is >= target.
That is where target would be inserted to keep the list sorted, so it is
len(sorted_values) when every value is smaller (same as bisect.bisect_left).
"""
lo = 0
hi = len(sorted_values) # the answer is inside [lo, hi) — hi itself is a valid answer
while lo < hi:
mid = lo + (hi - lo) // 2
if sorted_values[mid] < target:
lo = mid + 1 # mid and everything before it are too small
else:
hi = mid # mid might be the answer, so keep it in the range
return lo # lo == hi: the first index whose value is >= target
/**
* Binary search on an array sorted in ascending order.
* Every step looks at the middle of the range that can still hold the answer
* and throws away the half that cannot — so n items take about log2(n) steps.
*/
public final class BinarySearch {
private BinarySearch() {
}
/**
* Returns an index i with sorted[i] == target, or -1 if there is none.
* With duplicates it returns one of the matching indexes, not necessarily the first —
* use {@link #lowerBound} when you need the first one.
*/
public static int binarySearch(int[] sorted, int target) {
int lo = 0;
int hi = sorted.length - 1; // the answer, if any, is inside [lo, hi] (both ends included)
while (lo <= hi) {
// (lo + hi) / 2 overflows when lo + hi > Integer.MAX_VALUE; this form never does.
int mid = lo + (hi - lo) / 2;
if (sorted[mid] == target) {
return mid;
}
if (sorted[mid] < target) {
lo = mid + 1; // sorted[lo..mid] are all too small
} else {
hi = mid - 1; // sorted[mid..hi] are all too big
}
}
return -1; // the range is empty: target is not in the array
}
/**
* Returns the first index whose value is >= target — where target would be inserted to keep
* the array sorted. Returns sorted.length when every value is smaller than target.
*/
public static int lowerBound(int[] sorted, int target) {
int lo = 0;
int hi = sorted.length; // the answer is inside [lo, hi) — hi itself is a valid answer
while (lo < hi) {
int mid = lo + (hi - lo) / 2;
if (sorted[mid] < target) {
lo = mid + 1; // mid and everything before it are too small
} else {
hi = mid; // mid might be the answer, so keep it in the range
}
}
return lo; // lo == hi: the first index whose value is >= target
}
}
// Binary search on a vector sorted in ascending order.
// Every step looks at the middle of the range that can still hold the answer
// and throws away the half that cannot — so n items take about log2(n) steps.
// (The standard library has it too: std::binary_search and std::lower_bound in <algorithm>.)
#include <vector>
// Returns an index i with sorted[i] == target, or -1 if there is none.
// With duplicates it returns one of the matching indexes, not necessarily the first —
// use lowerBound when you need the first one.
int binarySearch(const std::vector<int>& sorted, int target) {
int lo = 0;
int hi = static_cast<int>(sorted.size()) - 1; // the answer, if any, is inside [lo, hi]
while (lo <= hi) {
// (lo + hi) / 2 overflows when lo + hi > INT_MAX; this form never does.
int mid = lo + (hi - lo) / 2;
if (sorted[mid] == target) {
return mid;
}
if (sorted[mid] < target) {
lo = mid + 1; // sorted[lo..mid] are all too small
} else {
hi = mid - 1; // sorted[mid..hi] are all too big
}
}
return -1; // the range is empty: target is not in the vector
}
// Returns the first index whose value is >= target — where target would be inserted to keep
// the vector sorted. Returns sorted.size() when every value is smaller than target.
int lowerBound(const std::vector<int>& sorted, int target) {
int lo = 0;
int hi = static_cast<int>(sorted.size()); // the answer is inside [lo, hi) — hi itself is valid
while (lo < hi) {
int mid = lo + (hi - lo) / 2;
if (sorted[mid] < target) {
lo = mid + 1; // mid and everything before it are too small
} else {
hi = mid; // mid might be the answer, so keep it in the range
}
}
return lo; // lo == hi: the first index whose value is >= target
}
Hỏi về thuật toán này
Kẹt ở bước nào? Hỏi thêm ví dụ hoặc vì sao nó chạy đúng.
Tự kiểm tra
10 câu hỏi nhanh xem bạn đã nắm chưa.
Câu 1 · binarySearch tìm 23 trong [2, 5, 8, 12, 16, 23, 38, 56, 72, 91]. Vòng lặp lần lượt nhìn vào những chỉ số nào?
Dùng ngay trong dự án
- Animation
Binary Search Animation
Tìm kiếm nhị phân chạy từng bước, ba con trỏ lo / mid / hi lướt trên mảng — sẵn sàng cho bài giảng, slide và blog
Miễn phí
