← Cấu trúc dữ liệu & Giải thuật← Data Structures & Algorithms
Cấu trúc dữ liệu & Giải thuậtData Structures & Algorithms7 Th8, 2026Aug 7, 202628 phút đọc24 min read

Thuật toán tìm kiếmSearch Algorithms

Mục lục
  1. Tổng quan
  2. Kiến thức nền tảng
  3. Linear search
  4. Binary search — ý tưởng
  5. Template kinh điển (biên đóng)
  6. Một lượt trace cụ thể
  7. Khái niệm chính
  8. lower_bound và upper_bound — template bạn thật sự nên dùng
  9. Điểm danh các cái bẫy off-by-one
  10. bisect của Python — thứ bạn dùng trên production
  11. Binary search trên đáp án
  12. Vài thuật toán tìm kiếm liên quan đáng biết
  13. Bảng tổng hợp độ phức tạp
  14. Khi nào array đã sắp + binary search thắng hash table
  15. Best Practices
  16. Tài liệu tham khảo
Table of contents
  1. Overview
  2. Fundamentals
  3. Linear search
  4. Binary search — the idea
  5. The classic template (inclusive bounds)
  6. A worked trace
  7. Key Concepts
  8. lower_bound and upper_bound — the templates you should actually use
  9. The off-by-one pitfalls, named
  10. Python’s bisect — what you use in production
  11. Binary search on the answer
  12. Related search algorithms worth knowing
  13. Complexity summary
  14. When a sorted array + binary search beats a hash table
  15. Best Practices
  16. 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./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

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:

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 bO(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ố đó:

  1. hi = len(arr) - 1hi là biên đóng. Viết len(arr) ở đây là đọc vượt ra ngoài array.
  2. while lo <= hi, không phải <. Khi lo == hi khoả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ử.
  3. mid + 1mid - 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.
  4. 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 + hi tràn kiểu int 32-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ướclohimidarr[mid]So sánhHành động
106377 == 7return 3

Quá dễ — lượt trace thú vị là lượt tìm không thành công. Tìm 8:

Bướclohimidarr[mid]So sánhHành động
106377 < 8lo = 4
24651111 > 8hi = 4
344499 > 8hi = 3
443lo > hireturn −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_boundupper_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.

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ướclohimidarr[mid]arr[mid] < 7?Hành động
10737Khônghi = 3
20313lo = 2
32325lo = 3
433lo == hireturn 3

Và của lower_bound(arr, 8) — giá trị không tồn tại:

Bướclohimidarr[mid]arr[mid] < 8?Hành động
10737lo = 4
247511Khônghi = 5
34549Khônghi = 4
444return 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ẫyTriệu chứngCách sửa
hi = len(arr) với template biên đóngIndexError, hoặc bỏ sót phần tử cuốiBiê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 đóngKhoảng một phần tử không bao giờ được kiểm traDùng while lo <= hi với biên đóng
lo = mid thay vì lo = mid + 1Vò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ố địnhKết quả sai âm thầm trên array > 2³⁰lo + (hi - lo) // 2
while hi - lo > eps với số thựcKhông kết thúc khi eps nhỏ hơn độ phân giải floatLặp một số lần cố định (~100) thay vì vậy
Tìm kiếm trên array chưa sắpKết quả sai âm thầm, không báo lỗiAssert 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_boundupper_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 bisectphâ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:

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ạpKhi nào dùng
Exponential (galloping) searchO(log i) với i là chỉ số của đáp ánDữ 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 searchTrung 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 searchO(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òngO(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 / DFSO(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ậnTiền xử lýTìm kiếmChènBộ nhớThao tác có thứ tự (range, min/max, successor)
Linear search, array chưa sắpkhôngO(n)O(1) amortizedO(n)O(n)
Binary search, array đã sắpsort O(n log n)O(log n)O(n) (phải dịch)O(n)O(log n + k)
Hash tablekhôngO(1) TB, O(n) worstO(1) amortizedO(n), gấp ~2–3 lần dữ liệu thôO(n log n) — phải sort
Balanced BST / skip listkhôngO(log n)O(log n)O(n) + chi phí nodeO(log n + k)
B-tree (trên đĩa)khôngO(log_B n) — rất ít I/OO(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.

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

Tài liệu tham khảo

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

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:

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:

  1. hi = len(arr) - 1 because hi is inclusive. Writing len(arr) here reads past the end.
  2. while lo <= hi, not <. When lo == hi the range still holds exactly one candidate; < would skip it and report “not found” for single-element ranges.
  3. mid + 1 and mid - 1, never mid. 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.
  4. 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 Rust lo + hi overflows a 32-bit int once 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:

Steplohimidarr[mid]ComparisonAction
106377 == 7return 3

That was too easy — the interesting trace is the unsuccessful one. Searching for 8:

Steplohimidarr[mid]ComparisonAction
106377 < 8lo = 4
24651111 > 8hi = 4
344499 > 8hi = 3
443lo > hireturn −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.

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):

Steplohimidarr[mid]arr[mid] < 7?Action
10737Nohi = 3
20313Yeslo = 2
32325Yeslo = 3
433lo == hireturn 3

And of lower_bound(arr, 8) — the missing value:

Steplohimidarr[mid]arr[mid] < 8?Action
10737Yeslo = 4
247511Nohi = 5
34549Nohi = 4
444return 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

PitfallSymptomFix
hi = len(arr) with an inclusive templateIndexError, or a missed last elementInclusive → len(arr) - 1; half-open → len(arr) — never mix
while lo < hi with an inclusive templateSingle-element ranges never checkedwhile lo <= hi for inclusive bounds
lo = mid instead of lo = mid + 1Infinite 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 languageSilent wrong answers on arrays > 2³⁰lo + (hi - lo) // 2
while hi - lo > eps on floatsNon-termination when eps is below float resolutionLoop a fixed ~100 iterations instead
Searching an unsorted arraySilently wrong results, not an errorAssert 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:

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.

AlgorithmComplexityWhen
Exponential (galloping) searchO(log i) where i is the answer’s indexUnbounded 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 searchO(log log n) average, O(n) worstUniformly distributed numeric keys — guesses the position from the value rather than always taking the midpoint. Degrades badly on skewed data.
Ternary searchO(log n)Finding the extremum of a unimodal function, not a sorted array.
Binary search on a rotated arrayO(log n)One half of the split is always sorted; decide which, then apply the usual test.
BFS / DFSO(V + E)Searching a tree or graph rather than a linear collection — ./10-tree-data-structures.md, ./13-graph-data-structures.md.

Complexity summary

ApproachPreprocessingSearchInsertSpaceOrdered ops (range, min/max, successor)
Linear search, unsorted arraynoneO(n)O(1) amortizedO(n)O(n)
Binary search, sorted arrayO(n log n) sortO(log n)O(n) (shift)O(n)O(log n + k)
Hash tablenoneO(1) avg, O(n) worstO(1) amortizedO(n), ~2–3× the raw dataO(n log n) — must sort
Balanced BST / skip listnoneO(log n)O(log n)O(n) + node overheadO(log n + k)
B-tree (disk)noneO(log_B n) — few I/OsO(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.

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

References