Thuật toán tìm kiếmSearch Algorithms
Mục lục
- Tổng quan
- Kiến thức nền tảng
- Linear search
- Binary search — ý tưởng
- Template kinh điển (biên đóng)
- Một lượt trace cụ thể
- Khái niệm chính
- lower_bound và upper_bound — template bạn thật sự nên dùng
- Điểm danh các cái bẫy off-by-one
- bisect của Python — thứ bạn dùng trên production
- Binary search trên đáp án
- Vài thuật toán tìm kiếm liên quan đáng biết
- Bảng tổng hợp độ phức tạp
- Khi nào array đã sắp + binary search thắng hash table
- Best Practices
- Tài liệu tham khảo
Table of contents
- Overview
- Fundamentals
- Linear search
- Binary search — the idea
- The classic template (inclusive bounds)
- A worked trace
- Key Concepts
- lower_bound and upper_bound — the templates you should actually use
- The off-by-one pitfalls, named
- Python’s bisect — what you use in production
- Binary search on the answer
- Related search algorithms worth knowing
- Complexity summary
- When a sorted array + binary search beats a hash table
- Best Practices
- References
Thuộc bộ kiến thức Data Structures & Algorithms Roadmap.
Tổng quan
Tìm kiếm là việc xác định một phần tử cụ thể, hoặc một nhóm phần tử, trong một tập dữ liệu. Roadmap nêu bốn thuật toán tìm kiếm chính: linear search, binary search, depth-first search, và breadth-first search. Hai cái đầu làm việc trên các tập dữ liệu tuyến tính và là nội dung của bài này; DFS và BFS làm việc trên tree và graph, được trình bày ở ./10-tree-data-structures.md và ./13-graph-data-structures.md.
Linear search và binary search trông như một chủ đề nhỏ đến mức tầm thường — hai thuật toán, mỗi cái mười dòng. Không phải vậy, vì hai lý do.
Lý do thứ nhất là binary search nổi tiếng là khó viết cho đúng. Jon Bentley kể rằng khi ông ra bài này cho các lập trình viên chuyên nghiệp suốt nhiều năm, khoảng 90% viết ra phiên bản có bug dù được cho bao nhiêu thời gian tùy thích. Arrays.binarySearch của Java ra đời cùng một bug tràn số nguyên tồn tại chín năm và chỉ được phát hiện năm 2006. Thuật toán chỉ có bốn dòng và mỗi dòng đều có một cái bẫy off-by-one. Cách chữa không phải là cẩn thận hơn; mà là cam kết với một template duy nhất có bất biến được viết ra rõ ràng và không bao giờ ứng biến.
Lý do thứ hai là binary search tổng quát hơn nhiều so với “tìm một phần tử trong array đã sắp xếp”. Yêu cầu thật sự của nó là một monotonic predicate — một hàm boolean trên một không gian tìm kiếm có thứ tự, nhận giá trị false, false, …, false, true, true, …, true. Mọi hàm như vậy đều có thể đảo ngược trong O(log n) lần đánh giá. Khi đã nhìn ra điều đó, “binary search trên đáp án” trở thành một trong những kỹ thuật giải bài toán có đòn bẩy lớn nhất: nó biến “tìm X nhỏ nhất sao cho Y khả thi” thành “kiểm tra xem một X cho trước có khả thi không”, mà việc sau thường dễ hơn nhiều.
Mạch thứ ba xuyên suốt bài này là sự so sánh với hash table. Hash table trả lời “có x không?” trong O(1) còn array đã sắp cần O(log n), nên hash table có vẻ thắng tuyệt đối. Không hẳn vậy, và phần nói về khi nào array đã sắp thắng chính là phần hữu dụng nhất về mặt thực tế của bài này.
Kiến thức nền tảng
Linear search
Kiểm tra từng phần tử một cho tới khi tìm thấy hoặc hết phần tử. Còn gọi là sequential search.
def linear_search(arr, target):
"""Trả về chỉ số lần xuất hiện đầu tiên của target, hoặc -1. O(n)."""
for i, x in enumerate(arr):
if x == target:
return i
return -1
Độ phức tạp: best case O(1) (phần tử đầu tiên), trung bình O(n) (n/2 phép so sánh khi tìm thành công với vị trí phân bố đều) và worst case O(n), bộ nhớ O(1).
Ưu điểm của nó đều nằm ở việc không đòi hỏi điều kiện tiên quyết nào:
- Không cần dữ liệu đã sắp xếp. Nó chạy trên dữ liệu chưa sắp, đó là lựa chọn duy nhất khi dữ liệu không thể sắp xếp hoặc khi việc sắp xếp không đáng giá
O(n log n). - Không cần random access. Nó chạy trên linked list (./05-linked-lists.md), trên generator, trên một file đang được đọc từng dòng, hoặc trên một network stream. Binary search thì không: đi tới giữa một linked list tự nó đã tốn
O(n), phá hỏng toàn bộ mục đích. - Không cần tiền xử lý. Với một lần tìm kiếm duy nhất trên dữ liệu bạn sẽ không tìm lại nữa, sắp xếp trước (
O(n log n)) rõ ràng tệ hơn là chỉ quét (O(n)). nnhỏ thì nó thắng. Dưới khoảng 20–100 phần tử, quét tuyến tính trên array liền kề thắng binary search, vì việc quét là tuần tự và dễ dự đoán nhánh, còn binary search thì nhảy lung tung và dự đoán sai ở mọi bước. Các thư viện thật đều tận dụng điều này.
Trong Python bạn sẽ viết target in arr, arr.index(target), hoặc next((i for i, x in enumerate(arr) if pred(x)), None). Cả ba đều O(n), và cả ba đều chạy vòng lặp bằng C. Performance bug kinh điển là một linear search lồng bên trong một vòng lặp khác — for x in a: if x in b là O(n·m), và chuyển b thành set trước sẽ làm nó thành O(n + m) (./07-hash-tables.md).
Binary search — ý tưởng
Trên một array đã sắp xếp, so sánh target với phần tử ở giữa. Một trong ba điều đúng: bạn tìm thấy nó, hoặc target phải nằm ở nửa trái, hoặc nó phải nằm ở nửa phải. Cách nào cũng vậy, bạn loại bỏ một nửa số ứng viên còn lại chỉ bằng một phép so sánh.
tìm 8 trong [1, 3, 5, 7, 9, 11, 13]
bước 1: 1 3 5 [7] 9 11 13 mid = 7, 7 < 8 → bỏ nửa trái và cả mid
bước 2: 9 [11] 13 mid = 11, 11 > 8 → bỏ 11 và mọi thứ bên phải
bước 3: [9] mid = 9, 9 > 8 → bỏ nó
bước 4: (rỗng) → không tìm thấy; 8 thuộc về vị trí index 4
Chia đôi không gian tìm kiếm k lần còn lại n/2ᵏ ứng viên; việc tìm kiếm kết thúc khi n/2ᵏ < 1, tức là k > log₂ n. Đó là toàn bộ lập luận về độ phức tạp: O(log n), và logarit cơ số 2 rất nhỏ — một array một tỷ phần tử chỉ tốn 30 phép so sánh.
Điều kiện tiên quyết là không thể thương lượng: array phải đã được sắp xếp theo đúng phép so sánh đang dùng. Binary search trên dữ liệu chưa sắp không thỉnh thoảng trả về sai; nó trả về rác, một cách im lặng.
Template kinh điển (biên đóng)
def binary_search(arr, target):
"""Trả về một chỉ số của target trong list đã sắp arr, hoặc -1 nếu không có.
Bất biến: nếu target có trong arr, chỉ số của nó nằm trong đoạn ĐÓNG [lo, hi]."""
lo, hi = 0, len(arr) - 1 # hi là biên ĐÓNG (bao gồm)
while lo <= hi: # [lo, hi] vẫn khác rỗng khi lo == hi
mid = lo + (hi - lo) // 2 # tương đương (lo + hi) // 2, nhưng không tràn số
if arr[mid] == target:
return mid
if arr[mid] < target:
lo = mid + 1 # mid quá nhỏ — loại nó ra
else:
hi = mid - 1 # mid quá lớn — loại nó ra
return -1 # lo > hi: khoảng đã rỗng
Bốn quy tắc làm cho phiên bản này đúng, và mọi bug kinh điển đều là vi phạm một trong số đó:
hi = len(arr) - 1vìhilà biên đóng. Viếtlen(arr)ở đây là đọc vượt ra ngoài array.while lo <= hi, không phải<. Khilo == hikhoảng vẫn còn đúng một ứng viên; dấu<sẽ bỏ qua nó và báo “không tìm thấy” với các khoảng một phần tử.mid + 1vàmid - 1, không bao giờ làmid.arr[mid]đã được so sánh và biết chắc không phải target, nên nó phải rời khỏi khoảng — nếu không, khoảng không bao giờ co lại và vòng lặp treo.lo + (hi - lo) // 2, không phải(lo + hi) // 2. Trong Python số nguyên có độ chính xác tùy ý nên cả hai đều được, nhưng trong Java, C, C++, Go, và Rust thìlo + hitràn kiểuint32-bit ngay khi array vượt 2³⁰ phần tử. Đây đúng là bug mà Joshua Bloch phát hiện trong JDK năm 2006 — chín năm sau khi phát hành, trong đoạn code từng được chứng minh hình thức là đúng dựa trên một mô hình giả định số nguyên không giới hạn.
Một lượt trace cụ thể
arr = [1, 3, 5, 7, 9, 11, 13], tìm 7:
| Bước | lo | hi | mid | arr[mid] | So sánh | Hành động |
|---|---|---|---|---|---|---|
| 1 | 0 | 6 | 3 | 7 | 7 == 7 | return 3 |
Quá dễ — lượt trace thú vị là lượt tìm không thành công. Tìm 8:
| Bước | lo | hi | mid | arr[mid] | So sánh | Hành động |
|---|---|---|---|---|---|---|
| 1 | 0 | 6 | 3 | 7 | 7 < 8 | lo = 4 |
| 2 | 4 | 6 | 5 | 11 | 11 > 8 | hi = 4 |
| 3 | 4 | 4 | 4 | 9 | 9 > 8 | hi = 3 |
| 4 | 4 | 3 | — | — | lo > hi | return −1 |
Chú ý bước 3: đây chính là trường hợp mà while lo < hi sẽ bỏ qua, âm thầm trả về “không tìm thấy” mà chưa bao giờ xét arr[4]. Mỗi khi bạn viết một binary search, hãy trace bằng tay khoảng hai phần tử và khoảng một phần tử. Bug nằm ở đó.
Khái niệm chính
lower_bound và upper_bound — template bạn thật sự nên dùng
Lệnh return mid sớm trong template kinh điển là một điểm yếu. Khi có phần tử trùng lặp nó trả về một chỉ số khớp bất kỳ, hiếm khi là thứ bạn muốn, và nó không trả lời được câu “phần tử này sẽ nằm ở đâu nếu tôi chèn vào?”. Các template tìm biên dạng nửa mở hữu dụng hơn hẳn và ít bẫy hơn.
lower_bound(arr, x)— chỉ số của phần tử đầu tiên≥ x. Tương đương: có bao nhiêu phần tử nhỏ hơn hẳnx; tương đương: vị trí trái nhất màxcó thể chèn vào mà vẫn giữ array đã sắp.upper_bound(arr, x)— chỉ số của phần tử đầu tiên> x. Vị trí chèn phải nhất tương ứng.
def lower_bound(arr, target):
"""Chỉ số i đầu tiên có arr[i] >= target; bằng len(arr) nếu không có.
Bất biến: đáp án luôn nằm trong khoảng NỬA MỞ [lo, hi)."""
lo, hi = 0, len(arr) # hi là biên MỞ — chú ý: len(arr), không phải len(arr)-1
while lo < hi: # [lo, hi) rỗng khi lo == hi
mid = lo + (hi - lo) // 2 # lo <= mid < hi, nên mid luôn là chỉ số hợp lệ
if arr[mid] < target:
lo = mid + 1 # arr[mid] quá nhỏ: nó và mọi thứ bên trái
else: # đều bị loại
hi = mid # arr[mid] CÓ THỂ là đáp án: giữ lại trong khoảng
return lo # lo == hi: chính là biên
def upper_bound(arr, target):
"""Chỉ số i đầu tiên có arr[i] > target; bằng len(arr) nếu không có."""
lo, hi = 0, len(arr)
while lo < hi:
mid = lo + (hi - lo) // 2
if arr[mid] <= target: # khác biệt DUY NHẤT so với lower_bound: <= thay vì <
lo = mid + 1
else:
hi = mid
return lo
Mọi thứ khác đều suy ra từ hai hàm này, không cần thêm binary search nào nữa để mà viết sai:
a = [1, 3, 3, 3, 7, 9]
lower_bound(a, 3) # 1 — chỉ số đầu tiên của 3
upper_bound(a, 3) # 4 — ngay sau chỉ số cuối cùng của 3
upper_bound(a, 3) - lower_bound(a, 3) # 3 — có bao nhiêu số 3 (0 nghĩa là "không có")
lower_bound(a, 5) # 4 — vị trí chèn cho một giá trị không tồn tại
upper_bound(a, 3) - 1 # 3 — chỉ số cuối cùng của 3
lower_bound(a, 4) - 1 # 3 — predecessor: phần tử cuối cùng < 4
lower_bound(a, 4) # 4 — successor: phần tử đầu tiên >= 4
def contains(a, x):
"""Kiểm tra thành viên dựa trên lower_bound — chú ý phải kiểm tra biên trước."""
i = lower_bound(a, x)
return i < len(a) and a[i] == x
Trace của lower_bound([1, 3, 5, 7, 9, 11, 13], 7):
| Bước | lo | hi | mid | arr[mid] | arr[mid] < 7? | Hành động |
|---|---|---|---|---|---|---|
| 1 | 0 | 7 | 3 | 7 | Không | hi = 3 |
| 2 | 0 | 3 | 1 | 3 | Có | lo = 2 |
| 3 | 2 | 3 | 2 | 5 | Có | lo = 3 |
| 4 | 3 | 3 | — | — | — | lo == hi → return 3 |
Và của lower_bound(arr, 8) — giá trị không tồn tại:
| Bước | lo | hi | mid | arr[mid] | arr[mid] < 8? | Hành động |
|---|---|---|---|---|---|---|
| 1 | 0 | 7 | 3 | 7 | Có | lo = 4 |
| 2 | 4 | 7 | 5 | 11 | Không | hi = 5 |
| 3 | 4 | 5 | 4 | 9 | Không | hi = 4 |
| 4 | 4 | 4 | — | — | — | return 4 (vị trí chèn) |
Hãy để ý trường hợp “không tìm thấy” trả về một thứ có ích thay vì -1: vị trí mà giá trị đó thuộc về.
Điểm danh các cái bẫy off-by-one
| Cái bẫy | Triệu chứng | Cách sửa |
|---|---|---|
hi = len(arr) với template biên đóng | IndexError, hoặc bỏ sót phần tử cuối | Biên đóng → len(arr) - 1; nửa mở → len(arr) — đừng bao giờ trộn lẫn |
while lo < hi với template biên đóng | Khoảng một phần tử không bao giờ được kiểm tra | Dùng while lo <= hi với biên đóng |
lo = mid thay vì lo = mid + 1 | Vòng lặp vô hạn khi hi == lo + 1 (vì mid làm tròn xuống thành lo) | Loại mid ở phía đã bị loại trừ, hoặc làm tròn mid lên |
(lo + hi) // 2 trong ngôn ngữ số nguyên độ rộng cố định | Kết quả sai âm thầm trên array > 2³⁰ | lo + (hi - lo) // 2 |
while hi - lo > eps với số thực | Không kết thúc khi eps nhỏ hơn độ phân giải float | Lặp một số lần cố định (~100) thay vì vậy |
| Tìm kiếm trên array chưa sắp | Kết quả sai âm thầm, không báo lỗi | Assert hoặc ghi rõ điều kiện tiên quyết |
Cái bẫy lo = mid xứng đáng có template riêng, vì bài toán “tìm vị trí cuối cùng mà predicate còn đúng” thật sự cần nó:
def last_true(lo, hi, pred):
"""x lớn nhất trong [lo, hi] có pred(x) đúng, giả định pred có dạng
true, true, ..., true, false, ..., false. Trả về lo - 1 nếu không có."""
lo -= 1 # lo - 1 biểu diễn "không có đáp án hợp lệ"
while lo < hi:
# Làm tròn LÊN. Với `(lo + hi) // 2`, trường hợp hi == lo + 1 cho mid == lo,
# và `lo = mid` không làm lo thay đổi — vòng lặp vô hạn.
mid = lo + (hi - lo + 1) // 2
if pred(mid):
lo = mid # mid thỏa: nó là ứng viên, giữ lại
else:
hi = mid - 1 # mid không thỏa: loại nó ra
return lo
bisect của Python — thứ bạn dùng trên production
Thư viện chuẩn cài đặt đúng lower_bound và upper_bound, bằng C, với bug đã được gỡ sẵn.
import bisect
a = [1, 3, 3, 3, 7, 9]
bisect.bisect_left(a, 3) # 1 — lower_bound
bisect.bisect_right(a, 3) # 4 — upper_bound (bisect.bisect là alias)
bisect.bisect_right(a, 3) - bisect.bisect_left(a, 3) # 3 lần xuất hiện
# insort giữ cho list luôn được sắp xếp. Phần TÌM KIẾM là O(log n) nhưng phần CHÈN
# là O(n), vì chèn vào list phải đẩy mọi phần tử phía sau. Chấp nhận được khi thỉnh
# thoảng mới chèn vào list vừa phải; dùng balanced tree hoặc heap cho luồng ghi nóng.
bisect.insort(a, 5) # a == [1, 3, 3, 3, 5, 7, 9]
# Từ Python 3.10, `key=` giúp tránh phải dựng một list sort key song song.
records = [("a", 1), ("b", 3), ("c", 7)]
i = bisect.bisect_left(records, 3, key=lambda r: r[1]) # 1
# lo/hi giới hạn phạm vi tìm kiếm về một lát cắt mà không cần copy nó
bisect.bisect_left(a, 5, lo=2, hi=6)
Idiom kinh điển của bisect là phân loại một giá trị liên tục vào các hạng mục có thứ tự — rẻ hơn và rõ hơn một chuỗi if/elif:
def grade(score, breakpoints=(60, 70, 80, 90), letters="FDCBA"):
"""O(log k) thay vì O(k) phép so sánh, và bảng phân loại là dữ liệu chứ không phải code."""
return letters[bisect.bisect_right(breakpoints, score)]
assert [grade(s) for s in (33, 65, 77, 89, 95)] == ["F", "D", "C", "B", "A"]
Binary search trên đáp án
Đây là kỹ thuật biến binary search thành một công cụ giải bài toán chứ không chỉ là một thao tác trên array. Bỏ array sang một bên: thứ mà binary search thật sự cần là
một không gian tìm kiếm có thứ tự, và một monotonic predicate
pred(x)trên nó — false, false, …, false, true, true, …, true.
Có được điều đó, O(log n) lần đánh giá pred sẽ tìm ra biên. Phiên bản trên array chỉ là trường hợp riêng pred(i) = (arr[i] >= target), đơn điệu vì array đã được sắp xếp.
def first_true(lo, hi, pred):
"""x nhỏ nhất trong khoảng nửa mở [lo, hi) có pred(x) đúng.
Giả định pred đơn điệu: false ... false, true ... true.
Trả về hi nếu pred sai ở mọi nơi."""
while lo < hi:
mid = lo + (hi - lo) // 2
if pred(mid):
hi = mid # mid có thể là biên — giữ lại
else:
lo = mid + 1 # mid sai — loại ra
return lo
# Việc tìm kiếm trên array giờ chỉ còn một dòng trên cùng một hàm nguyên thủy:
def lower_bound_via_pred(arr, target):
return first_true(0, len(arr), lambda i: arr[i] >= target)
Đòn bẩy đến từ những bài toán mà đáp án là một con số, việc kiểm tra một đáp án ứng viên thì dễ, còn tìm trực tiếp thì khó:
def min_ship_capacity(weights, days):
"""Sức chứa tàu nhỏ nhất chở được toàn bộ kiện hàng, đúng thứ tự đó,
trong vòng `days` ngày. Cách ngây thơ là thử từng sức chứa một."""
def feasible(cap):
"""Kiểm tra kiểu greedy: với sức chứa `cap` thì mất bao nhiêu ngày?
O(n) — và dễ viết đúng, đó chính là điều quan trọng."""
needed, load = 1, 0
for w in weights:
if load + w > cap:
needed += 1
load = 0
load += w
return needed <= days
# Đơn điệu: nếu sức chứa c khả thi thì mọi sức chứa > c cũng khả thi.
# Cận dưới của không gian tìm kiếm: ít nhất phải chứa được kiện nặng nhất.
# Cận trên: chở tất cả cùng lúc thì luôn xong trong một ngày.
return first_true(max(weights), sum(weights) + 1, feasible)
assert min_ship_capacity([1, 2, 3, 4, 5, 6, 7, 8, 9, 10], days=5) == 15
Chi phí là O(log(range) · cost(pred)) — ở đây là O(n log(sum(weights))), với một triệu kiện hàng thì tương đương khoảng 20–50 lần chạy một vòng quét tuyến tính. Các hình dáng bài toán lặp đi lặp lại:
- “Cực tiểu hóa giá trị lớn nhất” / “cực đại hóa giá trị nhỏ nhất” — cách diễn đạt này gần như chắc chắn là tín hiệu đáp án có thể binary search được. (Tổng lớn nhất nhỏ nhất khi chia đoạn, khoảng cách nhỏ nhất lớn nhất giữa các vật được đặt, thời gian nhỏ nhất để hoàn thành với
kcông nhân.) - Bài toán tốc độ / nhịp độ — “tốc độ chậm nhất mà công việc vẫn kịp hoàn thành”. Kiểm tra tính khả thi ở một tốc độ chỉ là một mô phỏng đơn giản.
- Phân bổ tài nguyên — “sức chứa / ngân sách / số máy nhỏ nhất mà vẫn đủ”.
Hai biến thể xuất hiện liên tục:
def integer_sqrt(n):
"""x lớn nhất có x*x <= n. `x*x > n` là đơn điệu theo x."""
return first_true(0, n + 2, lambda x: x * x > n) - 1
assert [integer_sqrt(k) for k in (0, 1, 15, 16, 17, 10**18)] == [0, 1, 3, 4, 4, 10**9]
def sqrt_float(n):
"""Binary search trên khoảng liên tục. Đừng bao giờ lặp theo `hi - lo > eps`:
khi xuống dưới độ phân giải của float, điều kiện đó không bao giờ sai được nữa.
Hãy lặp một số lần cố định — 100 lần chia đôi đã vượt xa độ chính xác double."""
lo, hi = 0.0, max(1.0, float(n))
for _ in range(100):
mid = (lo + hi) / 2
if mid * mid < n:
lo = mid
else:
hi = mid
return lo
Danh sách kiểm tra trước khi áp dụng: predicate có thật sự đơn điệu không? Nếu pred đúng, rồi sai, rồi lại đúng, binary search sẽ trả về một biên tùy ý và bạn nhận về một đáp án sai trông rất thuyết phục. Hãy chứng minh tính đơn điệu, hoặc chí ít kiểm tra bằng brute force trên input nhỏ rồi đối chiếu với kết quả binary search — phép kiểm tra đối chiếu đó bắt được gần như mọi sai lầm của kỹ thuật này.
Vài thuật toán tìm kiếm liên quan đáng biết
| Thuật toán | Độ phức tạp | Khi nào dùng |
|---|---|---|
| Exponential (galloping) search | O(log i) với i là chỉ số của đáp án | Dữ liệu đã sắp nhưng không giới hạn hoặc dạng stream, hoặc khi target nằm gần đầu: nhân đôi biên cho tới khi vượt qua, rồi binary search trong [b/2, b]. Được dùng bên trong bước merge của Timsort. |
| Interpolation search | Trung bình O(log log n), worst case O(n) | Key số phân bố đều — đoán vị trí từ giá trị thay vì luôn lấy điểm giữa. Suy giảm rất tệ với dữ liệu lệch. |
| Ternary search | O(log n) | Tìm cực trị của một hàm unimodal, không phải trên array đã sắp. |
| Binary search trên array xoay vòng | O(log n) | Một trong hai nửa của phép chia luôn đã sắp; xác định nửa nào rồi áp dụng phép kiểm tra thông thường. |
| BFS / DFS | O(V + E) | Tìm kiếm trên tree hoặc graph chứ không phải tập tuyến tính — ./10-tree-data-structures.md, ./13-graph-data-structures.md. |
Bảng tổng hợp độ phức tạp
| Cách tiếp cận | Tiền xử lý | Tìm kiếm | Chèn | Bộ nhớ | Thao tác có thứ tự (range, min/max, successor) |
|---|---|---|---|---|---|
| Linear search, array chưa sắp | không | O(n) | O(1) amortized | O(n) | O(n) |
| Binary search, array đã sắp | sort O(n log n) | O(log n) | O(n) (phải dịch) | O(n) | O(log n + k) |
| Hash table | không | O(1) TB, O(n) worst | O(1) amortized | O(n), gấp ~2–3 lần dữ liệu thô | O(n log n) — phải sort |
| Balanced BST / skip list | không | O(log n) | O(log n) | O(n) + chi phí node | O(log n + k) |
| B-tree (trên đĩa) | không | O(log_B n) — rất ít I/O | O(log_B n) | O(n) | O(log_B n + k) |
Khi nào array đã sắp + binary search thắng hash table
O(log n) so với O(1) khiến hash table trông như bất khả chiến bại, nên rất đáng nói cụ thể về rất nhiều trường hợp mà nó không phải vậy.
- Range query. “Mọi key giữa
avàb” là một lầnlower_bound(a)rồi đi tuyến tính —O(log n + k). Hash table phải quét tất cả,O(n). Riêng lý do này là vì sao B-tree index trong database áp đảo hash index: chúng phục vụ equality lẫn range lẫnORDER BYtừ cùng một cấu trúc (../../postgresql-dba/vi/08-indexing-strategies.md, ./18-indexing.md). - Predecessor / successor / gần nhất. “Key lớn nhất ≤ x” chỉ là một lần gọi
bisect. Hash table hoàn toàn không trả lời được nếu không quét toàn bộ. Các truy vấn theo timestamp (“phiên bản config có hiệu lực tại thời điểm T”, “khung giá cho số tiền này”) chính là dạng truy vấn này. - Order statistics và duyệt theo thứ tự. Min, max, trung vị, “100 phần tử nhỏ nhất”, “duyệt theo thứ tự” đều là
O(1)hoặc một lát cắt trên array đã sắp, còn với hash table thì lần nào cũng cần một lần sortO(n log n). - Bộ nhớ. Một
array('q')đã sắp chứa một triệu số nguyên 64-bit tốn 8 MB. Cùng dữ liệu đó trongsetcủa Python tốn khoảng 30–60 MB, do phần dư dành cho hashing (bảng cố ý được giữ trống một phần ba) và chi phí mỗi object. Khi tập dữ liệu làm việc phải nằm vừa cache hoặc trong một tiến trình bị giới hạn, hệ số 5 lần này quan trọng hơn hệ số 20 lần ở chi phí lookup. - Dữ liệu chỉ đọc, dựng một lần. Một bảng tra cứu tĩnh — danh sách từ được biên dịch sẵn, bảng định tuyến, một file index đã sắp — có thể
mmapthẳng từ đĩa và binary search luôn, không cần deserialize gì cả. Hash table phải được dựng lại trong bộ nhớ trước lần lookup đầu tiên. Đây là lý do các định dạng index trên đĩa là array đã sắp hoặc B-tree, không bao giờ là hash table. - Cache locality khi
nnhỏ. Với vài trăm phần tử, một array đã sắp nằm gọn trong một hai cache line cạnh tranh ngang hoặc nhanh hơn một lần hash lookup, vì hash lookup tốn một lần tính hash cộng một lần truy cập bộ nhớ ngẫu nhiên, còn tìm kiếm trên array thì vẫn nằm trong cache. - Key đắt để hash. Hash một chuỗi dài phải đọc toàn bộ chuỗi. Một phép so sánh trong binary search thường phân kỳ ngay ở vài byte đầu. Với key rất dài,
O(log n)phép so sánh rẻ có thể thắng một lần hash đắt. - Worst case dự đoán được. Binary search luôn là
O(log n). Hash table làO(n)với key mang tính đối kháng — tấn công hash flooding mô tả trong ./07-hash-tables.md. Ở nơi kẻ tấn công kiểm soát key và tail latency quan trọng, bảo đảm tất định này đáng để trả giá. - Xử lý phần tử trùng lặp.
upper_bound - lower_boundđếm số lần xuất hiện trongO(log n)và cho bạn nguyên cả cụm dưới dạng một lát cắt liền kề. Một hash multimap cần một list riêng cho mỗi key.
Ngược lại, hash table thắng áp đảo khi: lookup thuần túy là kiểm tra bằng nhau, dữ liệu thay đổi thường xuyên (chèn vào array đã sắp là O(n)), key không có thứ tự tự nhiên, hoặc n đủ lớn để log n ≈ 20+ phép so sánh thật sự đắt hơn một lần hash. Thú thật thì đó là phần lớn thời gian — nên nó mới là lựa chọn mặc định. Vấn đề là nhận ra những trường hợp còn lại.
Vùng trung gian, khi bạn cần cả truy vấn có thứ tự lẫn cập nhật rẻ, là balanced BST, skip list (./11-balanced-and-multiway-trees.md), hoặc — trong Python, nơi không có sẵn thứ nào trong số đó — thư viện bên thứ ba sortedcontainers, đạt được O(log n) trên thực tế cho thao tác chèn bằng cách dùng list lồng list thay vì tree.
Best Practices
- Chọn một template binary search duy nhất và đừng bao giờ ứng biến. Dạng nửa mở
lower_bound/first_truelà dạng đáng thuộc lòng: không córeturnsớm, không cómid ± 1ở cả hai phía, và nó cho thông tin hữu ích khi target không tồn tại. Mọi biến thể khác đều dựng được từ nó. - Viết bất biến vào comment ngay trên vòng lặp. “Đáp án nằm trong
[lo, hi)” không phải trang trí — nó chính là thứ cho bạn biết nên cập nhật thànhmidhaymid + 1. - Luôn trace bằng tay các trường hợp rỗng, một phần tử, và hai phần tử. Mọi lỗi off-by-one đều nằm ở đó, và ba dòng trace bằng tay rẻ hơn một giờ trong debugger (./01-programming-fundamentals-and-pseudocode.md).
- Dùng
bisecttrong Python thay vì tự viết. Nó là code C, nó đúng, vàbisect_left/bisect_rightchính làlower_bound/upper_bound. Hãy nhớ cái nào là cái nào —bisecttrần làbisect_right, điều khiến nhiều người bất ngờ. - Nhớ rằng
bisect.insortlàO(n). Phần tìm kiếm là logarit, phần chèn vào list thì không. Dựng một list đã sắp bằnginsorttrong vòng lặp làO(n²); thu thập rồi sort một lần làO(n log n). - Cứ viết
lo + (hi - lo) // 2kể cả trong Python. Nó chẳng tốn gì và là thói quen giữ cho cùng đoạn code đó vẫn đúng khi bạn viết bằng Go hay Java. - Đừng bao giờ binary search trên linked list. Đi tới điểm giữa tốn
O(n), khiến cả lần tìm kiếm thànhO(n)với hằng số nhân tệ hơn một lần quét thường. Random access là điều kiện tiên quyết, không phải tiện nghi. - Đừng binary search dưới ~50 phần tử nếu chưa đo đạc. Quét tuyến tính trên array liền kề thường nhanh hơn ở kích thước đó, và đơn giản hơn.
- Kiểm chứng tính đơn điệu trước khi binary search trên đáp án. Brute force các trường hợp nhỏ rồi đối chiếu với kết quả binary search; một predicate không đơn điệu sẽ cho ra đáp án sai một cách tự tin, chứ không báo lỗi.
- Giới hạn số vòng lặp khi tìm kiếm trên số thực. Một số lần lặp cố định (khoảng 100) sẽ kết thúc;
while hi - lo > epsthì có thể không. - Sort một lần, tìm kiếm nhiều lần. Trả
O(n log n)một lần (./08-sorting-algorithms.md) để đổi lấy lookupO(log n)là đáng từ truy vấn thứ hai hoặc thứ ba trở đi. Với một lần lookup duy nhất trên dữ liệu sẽ bỏ đi, cứ quét thôi. - Chọn cấu trúc dựa trên hỗn hợp các loại truy vấn, không chỉ dựa vào độ phức tạp lookup. Chỉ equality và ghi nhiều → hash table. Range, thứ tự, hoặc dataset tĩnh chủ yếu đọc → array đã sắp. Cần cả hai, ghi nhiều → balanced tree.
Tài liệu tham khảo
- roadmap.sh — Data Structures & Algorithms
- Binary search algorithm — Wikipedia
- Linear search — Wikipedia
- CLRS — Introduction to Algorithms (binary search, searching)
- Python Documentation —
bisect— Array bisection algorithm - cp-algorithms — Binary search
- Google Research blog — “Nearly All Binary Searches and Mergesorts are Broken” (Joshua Bloch, 2006)
std::lower_bound/std::upper_bound— cppreference- Exponential search — Wikipedia
- Interpolation search — Wikipedia
- MIT 6.006 — Introduction to Algorithms (OpenCourseWare)
- Big-O Cheat Sheet
- VisuAlgo — Binary Search (in Sorting/Searching visualizations)
Part of the Data Structures & Algorithms Roadmap knowledge base.
Overview
Searching is finding a specific item, or group of items, among a collection of data. The roadmap names four main search algorithms: linear search, binary search, depth-first search, and breadth-first search. The first two operate on linear collections and are the subject of this note; DFS and BFS operate on trees and graphs and are covered in ./10-tree-data-structures.md and ./13-graph-data-structures.md.
Linear and binary search look like a trivially small topic — two algorithms, ten lines each. They are not, for two reasons.
The first is that binary search is notoriously hard to write correctly. Jon Bentley reported that when he set the problem to professional programmers over several years, roughly 90% produced a buggy version despite having as much time as they wanted. Java’s Arrays.binarySearch shipped with an integer-overflow bug that survived nine years and was only found in 2006. The algorithm is four lines and every one of them has an off-by-one trap. The cure is not care; it is committing to one template with a stated invariant and never improvising.
The second is that binary search is much more general than “find an element in a sorted array”. Its actual requirement is a monotonic predicate — a boolean function over an ordered search space that is false, false, …, false, true, true, …, true. Any such function can be inverted in O(log n) evaluations. Once you see it that way, “binary search on the answer” becomes one of the highest-leverage problem-solving techniques there is: it turns “find the minimum X such that Y is achievable” into “check whether a given X is achievable”, which is usually far easier.
The third thread running through this note is the comparison with hash tables. A hash table answers “is x present?” in O(1) and a sorted array needs O(log n), so the hash table appears to win outright. It does not, and the section on when a sorted array wins is the practically most useful part of this note.
Fundamentals
Linear search
Check every element in turn until you find a match or run out of elements. Also called sequential search.
def linear_search(arr, target):
"""Return the index of the first occurrence of target, or -1. O(n)."""
for i, x in enumerate(arr):
if x == target:
return i
return -1
Complexity: O(1) best case (first element), O(n) average (n/2 comparisons on a successful search over a uniformly random position) and O(n) worst case, O(1) space.
Its virtues are all about the absence of preconditions:
- No ordering requirement. It works on unsorted data, which is the only option when the data cannot be sorted or sorting is not worth
O(n log n). - No random access requirement. It works on a linked list (./05-linked-lists.md), a generator, a file being read line by line, or a network stream. Binary search cannot: reaching the middle of a linked list is itself
O(n), which destroys the whole point. - No preprocessing. For a one-shot search over data you will never search again, sorting first (
O(n log n)) is strictly worse than just scanning (O(n)). - Small
nwins. Below roughly 20–100 elements, a linear scan over a contiguous array beats a binary search, because the scan is sequential and branch-predictable while binary search jumps around and mispredicts at every step. Real library implementations exploit this.
In Python you would write target in arr, arr.index(target), or next((i for i, x in enumerate(arr) if pred(x)), None). All are O(n), all run the loop in C. The classic performance bug is a linear search nested inside another loop — for x in a: if x in b is O(n·m), and converting b to a set first makes it O(n + m) (./07-hash-tables.md).
Binary search — the idea
On a sorted array, compare the target with the middle element. One of three things is true: you found it, or the target must lie in the left half, or it must lie in the right half. Either way you discard half the remaining candidates with a single comparison.
search for 8 in [1, 3, 5, 7, 9, 11, 13]
step 1: 1 3 5 [7] 9 11 13 mid = 7, 7 < 8 → drop the left half and mid
step 2: 9 [11] 13 mid = 11, 11 > 8 → drop 11 and everything right
step 3: [9] mid = 9, 9 > 8 → drop it
step 4: (empty) → not found; 8 belongs at index 4
Halving the search space k times leaves n/2ᵏ candidates; the search ends when n/2ᵏ < 1, i.e. k > log₂ n. That is the entire complexity argument: O(log n), and the base-2 logarithm is small — a billion-element array takes 30 comparisons.
The precondition is non-negotiable: the array must be sorted with respect to the comparison being used. Binary search on unsorted data does not return a wrong answer occasionally; it returns garbage, silently.
The classic template (inclusive bounds)
def binary_search(arr, target):
"""Return an index of target in the sorted list arr, or -1 if absent.
Invariant: if target is in arr, its index lies in the CLOSED range [lo, hi]."""
lo, hi = 0, len(arr) - 1 # hi is INCLUSIVE
while lo <= hi: # [lo, hi] is non-empty while lo == hi
mid = lo + (hi - lo) // 2 # same as (lo + hi) // 2, without overflow
if arr[mid] == target:
return mid
if arr[mid] < target:
lo = mid + 1 # mid is too small — exclude it
else:
hi = mid - 1 # mid is too large — exclude it
return -1 # lo > hi: the range is empty
Four rules make this version correct, and every classic bug is a violation of one of them:
hi = len(arr) - 1becausehiis inclusive. Writinglen(arr)here reads past the end.while lo <= hi, not<. Whenlo == hithe range still holds exactly one candidate;<would skip it and report “not found” for single-element ranges.mid + 1andmid - 1, nevermid.arr[mid]has been compared and is known not to be the target, so it must leave the range — otherwise the range never shrinks and the loop hangs.lo + (hi - lo) // 2, not(lo + hi) // 2. In Python integers are arbitrary precision so both are fine, but in Java, C, C++, Go, and Rustlo + hioverflows a 32-bitintonce the array exceeds 2³⁰ elements. This is exactly the bug Joshua Bloch found in the JDK in 2006 — nine years after it shipped, in code that had been formally proved correct against a model that assumed unbounded integers.
A worked trace
arr = [1, 3, 5, 7, 9, 11, 13], searching for 7:
| Step | lo | hi | mid | arr[mid] | Comparison | Action |
|---|---|---|---|---|---|---|
| 1 | 0 | 6 | 3 | 7 | 7 == 7 | return 3 |
That was too easy — the interesting trace is the unsuccessful one. Searching for 8:
| Step | lo | hi | mid | arr[mid] | Comparison | Action |
|---|---|---|---|---|---|---|
| 1 | 0 | 6 | 3 | 7 | 7 < 8 | lo = 4 |
| 2 | 4 | 6 | 5 | 11 | 11 > 8 | hi = 4 |
| 3 | 4 | 4 | 4 | 9 | 9 > 8 | hi = 3 |
| 4 | 4 | 3 | — | — | lo > hi | return −1 |
Note step 3: this is the case that while lo < hi would have skipped, silently returning “not found” without ever examining arr[4]. Whenever you write a binary search, trace a two-element and a one-element range by hand. That is where the bugs live.
Key Concepts
lower_bound and upper_bound — the templates you should actually use
The classic template’s early return mid is a liability. With duplicates it returns an arbitrary matching index, which is rarely what you want, and it cannot answer “where would this element go if I inserted it?”. The half-open boundary-finding templates are strictly more useful and have fewer traps.
lower_bound(arr, x)— index of the first element≥ x. Equivalently: how many elements are strictly less thanx; equivalently: the leftmost position wherexcould be inserted keeping the array sorted.upper_bound(arr, x)— index of the first element> x. The rightmost such insertion position.
def lower_bound(arr, target):
"""First index i with arr[i] >= target; len(arr) if there is none.
Invariant: the answer always lies in the HALF-OPEN range [lo, hi)."""
lo, hi = 0, len(arr) # hi is EXCLUSIVE — note: len(arr), not len(arr)-1
while lo < hi: # [lo, hi) is empty when lo == hi
mid = lo + (hi - lo) // 2 # lo <= mid < hi, so mid is always a valid index
if arr[mid] < target:
lo = mid + 1 # arr[mid] is too small: it and everything
else: # to its left are excluded
hi = mid # arr[mid] may BE the answer: keep it in range
return lo # lo == hi: the boundary
def upper_bound(arr, target):
"""First index i with arr[i] > target; len(arr) if there is none."""
lo, hi = 0, len(arr)
while lo < hi:
mid = lo + (hi - lo) // 2
if arr[mid] <= target: # the ONLY difference from lower_bound: <= vs <
lo = mid + 1
else:
hi = mid
return lo
Everything else follows from these two, with no additional binary searches to get wrong:
a = [1, 3, 3, 3, 7, 9]
lower_bound(a, 3) # 1 — first index of 3
upper_bound(a, 3) # 4 — one past the last index of 3
upper_bound(a, 3) - lower_bound(a, 3) # 3 — how many 3s (0 means "absent")
lower_bound(a, 5) # 4 — insertion point for a missing value
upper_bound(a, 3) - 1 # 3 — last index of 3
lower_bound(a, 4) - 1 # 3 — predecessor: last element < 4
lower_bound(a, 4) # 4 — successor: first element >= 4
def contains(a, x):
"""Membership test built on lower_bound — note the bounds check first."""
i = lower_bound(a, x)
return i < len(a) and a[i] == x
A trace of lower_bound([1, 3, 5, 7, 9, 11, 13], 7):
| Step | lo | hi | mid | arr[mid] | arr[mid] < 7? | Action |
|---|---|---|---|---|---|---|
| 1 | 0 | 7 | 3 | 7 | No | hi = 3 |
| 2 | 0 | 3 | 1 | 3 | Yes | lo = 2 |
| 3 | 2 | 3 | 2 | 5 | Yes | lo = 3 |
| 4 | 3 | 3 | — | — | — | lo == hi → return 3 |
And of lower_bound(arr, 8) — the missing value:
| Step | lo | hi | mid | arr[mid] | arr[mid] < 8? | Action |
|---|---|---|---|---|---|---|
| 1 | 0 | 7 | 3 | 7 | Yes | lo = 4 |
| 2 | 4 | 7 | 5 | 11 | No | hi = 5 |
| 3 | 4 | 5 | 4 | 9 | No | hi = 4 |
| 4 | 4 | 4 | — | — | — | return 4 (the insertion point) |
Notice that the “not found” case returns something useful rather than -1: the position where the value belongs.
The off-by-one pitfalls, named
| Pitfall | Symptom | Fix |
|---|---|---|
hi = len(arr) with an inclusive template | IndexError, or a missed last element | Inclusive → len(arr) - 1; half-open → len(arr) — never mix |
while lo < hi with an inclusive template | Single-element ranges never checked | while lo <= hi for inclusive bounds |
lo = mid instead of lo = mid + 1 | Infinite loop when hi == lo + 1 (because mid rounds down to lo) | Exclude mid on the side you have ruled out, or round mid up |
(lo + hi) // 2 in a fixed-width language | Silent wrong answers on arrays > 2³⁰ | lo + (hi - lo) // 2 |
while hi - lo > eps on floats | Non-termination when eps is below float resolution | Loop a fixed ~100 iterations instead |
| Searching an unsorted array | Silently wrong results, not an error | Assert or document the precondition |
The lo = mid trap deserves its own template, because “find the last position where a predicate holds” genuinely needs it:
def last_true(lo, hi, pred):
"""Largest x in [lo, hi] with pred(x) true, assuming pred is
true, true, ..., true, false, ..., false. Returns lo - 1 if none."""
lo -= 1 # lo - 1 represents "no valid answer"
while lo < hi:
# Round UP. With `(lo + hi) // 2`, the case hi == lo + 1 gives mid == lo,
# and `lo = mid` leaves lo unchanged — an infinite loop.
mid = lo + (hi - lo + 1) // 2
if pred(mid):
lo = mid # mid works: it is a candidate, keep it
else:
hi = mid - 1 # mid fails: exclude it
return lo
Python’s bisect — what you use in production
The standard library implements exactly lower_bound and upper_bound, in C, with the bugs already removed.
import bisect
a = [1, 3, 3, 3, 7, 9]
bisect.bisect_left(a, 3) # 1 — lower_bound
bisect.bisect_right(a, 3) # 4 — upper_bound (bisect.bisect is an alias)
bisect.bisect_right(a, 3) - bisect.bisect_left(a, 3) # 3 occurrences
# insort keeps a list sorted. The SEARCH is O(log n) but the INSERT is O(n),
# because a list insertion shifts every following element. Fine for occasional
# inserts into a modest list; use a balanced tree or a heap for a hot write path.
bisect.insort(a, 5) # a == [1, 3, 3, 3, 5, 7, 9]
# Since Python 3.10, `key=` avoids building a parallel list of sort keys.
records = [("a", 1), ("b", 3), ("c", 7)]
i = bisect.bisect_left(records, 3, key=lambda r: r[1]) # 1
# lo/hi restrict the search to a slice without copying it
bisect.bisect_left(a, 5, lo=2, hi=6)
The canonical bisect idiom is bucketing a continuous value into ranked categories — cheaper and clearer than a chain of if/elif:
def grade(score, breakpoints=(60, 70, 80, 90), letters="FDCBA"):
"""O(log k) instead of O(k) comparisons, and the table is data, not code."""
return letters[bisect.bisect_right(breakpoints, score)]
assert [grade(s) for s in (33, 65, 77, 89, 95)] == ["F", "D", "C", "B", "A"]
Binary search on the answer
This is the technique that makes binary search a problem-solving tool rather than an array operation. Strip away the array: what binary search actually needs is
a search space with an order, and a monotonic predicate
pred(x)over it — false, false, …, false, true, true, …, true.
Given that, O(log n) evaluations of pred find the boundary. The array version is just the special case pred(i) = (arr[i] >= target), which is monotonic because the array is sorted.
def first_true(lo, hi, pred):
"""Smallest x in the half-open range [lo, hi) with pred(x) true.
Assumes pred is monotonic: false ... false, true ... true.
Returns hi if pred is false everywhere."""
while lo < hi:
mid = lo + (hi - lo) // 2
if pred(mid):
hi = mid # mid might be the boundary — keep it
else:
lo = mid + 1 # mid is false — discard it
return lo
# The array search is now a one-liner over the same primitive:
def lower_bound_via_pred(arr, target):
return first_true(0, len(arr), lambda i: arr[i] >= target)
The leverage comes from problems where the answer is a number, checking a candidate answer is easy, and finding it directly is hard:
def min_ship_capacity(weights, days):
"""Smallest ship capacity that can carry all packages, in the given order,
within `days` days. Naively you would search capacities one at a time."""
def feasible(cap):
"""Greedy check: with capacity `cap`, how many days does it take?
O(n) — and easy to get right, which is the whole point."""
needed, load = 1, 0
for w in weights:
if load + w > cap:
needed += 1
load = 0
load += w
return needed <= days
# Monotonic: if capacity c is feasible, every capacity > c is too.
# Lower bound of the search space: we must at least fit the heaviest package.
# Upper bound: carrying everything at once always finishes in one day.
return first_true(max(weights), sum(weights) + 1, feasible)
assert min_ship_capacity([1, 2, 3, 4, 5, 6, 7, 8, 9, 10], days=5) == 15
The cost is O(log(range) · cost(pred)) — here O(n log(sum(weights))), which for a million packages is about 20–50 evaluations of a linear scan. The recurring shapes:
- “Minimize the maximum” / “maximize the minimum” — the phrasing is a near-certain signal that the answer is binary-searchable. (Minimum largest split sum, maximum minimum distance between placed objects, minimum time to finish given
kworkers.) - Rate/speed problems — “the slowest speed at which the task still finishes in time”. Feasibility at a given speed is a simple simulation.
- Resource allocation — “the smallest capacity / budget / number of machines that suffices”.
Two variants that come up constantly:
def integer_sqrt(n):
"""Largest x with x*x <= n. `x*x > n` is monotonic in x."""
return first_true(0, n + 2, lambda x: x * x > n) - 1
assert [integer_sqrt(k) for k in (0, 1, 15, 16, 17, 10**18)] == [0, 1, 3, 4, 4, 10**9]
def sqrt_float(n):
"""Binary search on a continuous range. Never loop on `hi - lo > eps`:
below float resolution that condition can never become false. Loop a fixed
number of times instead — 100 halvings is far past double precision."""
lo, hi = 0.0, max(1.0, float(n))
for _ in range(100):
mid = (lo + hi) / 2
if mid * mid < n:
lo = mid
else:
hi = mid
return lo
Checklist before applying it: is the predicate really monotonic? If pred is true, then false, then true again, binary search returns an arbitrary boundary and you get a plausible-looking wrong answer. Prove monotonicity, or at least verify it by brute force on small inputs against the binary-searched result — that comparison test catches essentially every mistake in this technique.
Related search algorithms worth knowing
| Algorithm | Complexity | When |
|---|---|---|
| Exponential (galloping) search | O(log i) where i is the answer’s index | Unbounded or streamed sorted data, or when the target is near the start: double the bound until you overshoot, then binary search inside [b/2, b]. Used inside Timsort’s merge. |
| Interpolation search | O(log log n) average, O(n) worst | Uniformly distributed numeric keys — guesses the position from the value rather than always taking the midpoint. Degrades badly on skewed data. |
| Ternary search | O(log n) | Finding the extremum of a unimodal function, not a sorted array. |
| Binary search on a rotated array | O(log n) | One half of the split is always sorted; decide which, then apply the usual test. |
| BFS / DFS | O(V + E) | Searching a tree or graph rather than a linear collection — ./10-tree-data-structures.md, ./13-graph-data-structures.md. |
Complexity summary
| Approach | Preprocessing | Search | Insert | Space | Ordered ops (range, min/max, successor) |
|---|---|---|---|---|---|
| Linear search, unsorted array | none | O(n) | O(1) amortized | O(n) | O(n) |
| Binary search, sorted array | O(n log n) sort | O(log n) | O(n) (shift) | O(n) | O(log n + k) |
| Hash table | none | O(1) avg, O(n) worst | O(1) amortized | O(n), ~2–3× the raw data | O(n log n) — must sort |
| Balanced BST / skip list | none | O(log n) | O(log n) | O(n) + node overhead | O(log n + k) |
| B-tree (disk) | none | O(log_B n) — few I/Os | O(log_B n) | O(n) | O(log_B n + k) |
When a sorted array + binary search beats a hash table
O(log n) versus O(1) makes the hash table look unbeatable, so it is worth being concrete about the many cases where it is not.
- Range queries. “All keys between
aandb” islower_bound(a)followed by a linear walk —O(log n + k). A hash table has to scan everything,O(n). This single reason is why database B-tree indexes dominate hash indexes: they serve equality and range andORDER BYfrom one structure (../../postgresql-dba/en/08-indexing-strategies.md, ./18-indexing.md). - Predecessor / successor / nearest. “The largest key ≤ x” is one
bisectcall. A hash table cannot answer it at all without a full scan. Timestamp lookups (“the config version in effect at time T”, “the price bucket for this amount”) are exactly this query. - Order statistics and sorted iteration. Min, max, median, “the 100 smallest”, “iterate in order” are
O(1)or a slice on a sorted array, and require anO(n log n)sort every time on a hash table. - Memory. A sorted
array('q')of a million 64-bit integers is 8 MB. The same data in a Pythonsetis roughly 30–60 MB, because of hashing headroom (the table is deliberately kept a third empty) and per-object overhead. When the working set has to fit in cache or in a constrained process, the factor of 5 matters more than the factor of 20 in lookup cost. - Read-only data built once. A static lookup table — a compiled-in word list, a routing table, a sorted index file — can be
mmaped straight from disk and binary-searched with zero deserialization. A hash table must be rebuilt in memory before the first lookup. This is why on-disk index formats are sorted arrays or B-trees, never hash tables. - Cache locality at small
n. For a few hundred elements, a sorted array in one or two cache lines is competitive with or faster than a hash lookup, because the hash lookup costs a hash computation plus a random memory access, while the array search stays in cache. - Expensive-to-hash keys. Hashing a long string reads the whole string. A binary search comparison usually diverges within the first few bytes. For very long keys,
O(log n)cheap comparisons can beat one expensive hash. - Predictable worst case. Binary search is
O(log n)always. A hash table isO(n)under adversarial keys — the hash-flooding attack described in ./07-hash-tables.md. Where an attacker controls the keys and tail latency matters, the deterministic bound is worth paying for. - Duplicate handling.
upper_bound - lower_boundcounts occurrences inO(log n)and gives you the whole run as a contiguous slice. A hash multimap needs a separate list per key.
Conversely, the hash table wins decisively when: lookups are pure equality tests, the data changes often (a sorted array insert is O(n)), the keys have no natural order, or n is large enough that log n ≈ 20+ comparisons genuinely cost more than one hash. Which is, admittedly, most of the time — hence the default. The point is to recognize the cases where it isn’t.
The middle ground, when you need both ordered queries and cheap updates, is a balanced BST, a skip list (./11-balanced-and-multiway-trees.md), or — in Python, where none of those is built in — the third-party sortedcontainers library, which reaches effectively O(log n) inserts using a list of lists rather than a tree.
Best Practices
- Pick one binary search template and never improvise. The
lower_bound/first_truehalf-open form is the one to memorize: it has no early return, nomid ± 1on both sides, and it produces useful information when the target is absent. Every other variant can be built from it. - Write the invariant in a comment above the loop. “The answer is in
[lo, hi)” is not decoration — it is what tells you whether the update should bemidormid + 1. - Always trace the empty, one-element, and two-element cases by hand. That is where every off-by-one lives, and three rows of a hand-trace cost less than an hour in a debugger (./01-programming-fundamentals-and-pseudocode.md).
- Use
bisectin Python rather than writing your own. It is C, it is correct, andbisect_left/bisect_rightare exactlylower_bound/upper_bound. Learn which is which —bisectbare isbisect_right, which surprises people. - Remember
bisect.insortisO(n). The search is logarithmic, the list insertion is not. Building a sorted list withinsortin a loop isO(n²); collecting and sorting once isO(n log n). - Write
lo + (hi - lo) // 2even in Python. It costs nothing and it is the habit that keeps the same code correct when you write it in Go or Java. - Never binary-search a linked list. Reaching the midpoint is
O(n), which makes the whole searchO(n)with a worse constant than a plain scan. Random access is a precondition, not a convenience. - Do not binary-search below ~50 elements without measuring. A linear scan of a contiguous array is often faster at that size, and it is simpler.
- Verify monotonicity before binary-searching on the answer. Brute-force the small cases and compare against the binary-searched result; a non-monotonic predicate produces a confidently wrong answer, not an error.
- Bound the loop when searching over floats. A fixed iteration count (about 100) terminates;
while hi - lo > epsmay not. - Sort once, search many. Paying
O(n log n)once (./08-sorting-algorithms.md) to enableO(log n)lookups is worth it from the second or third query onwards. For a single lookup on data you will discard, just scan. - Choose the structure from the query mix, not from the lookup complexity alone. Equality only and frequent writes → hash table. Ranges, ordering, or a read-mostly static dataset → sorted array. Both, with heavy writes → balanced tree.
References
- roadmap.sh — Data Structures & Algorithms
- Binary search algorithm — Wikipedia
- Linear search — Wikipedia
- CLRS — Introduction to Algorithms (binary search, searching)
- Python Documentation —
bisect— Array bisection algorithm - cp-algorithms — Binary search
- Google Research blog — “Nearly All Binary Searches and Mergesorts are Broken” (Joshua Bloch, 2006)
std::lower_bound/std::upper_bound— cppreference- Exponential search — Wikipedia
- Interpolation search — Wikipedia
- MIT 6.006 — Introduction to Algorithms (OpenCourseWare)
- Big-O Cheat Sheet
- VisuAlgo — Binary Search (in Sorting/Searching visualizations)