Skip to content

Intermediate: core algorithms · Sorting and searching · topic 29 of 61 · 40 min

Binary search

Explanation

Binary search finds a value in a SORTED list by checking the middle and throwing away the half that cannot contain it, so it needs only about log₂ n steps: 20 steps for a million items. The idea is simple; the details (lo, hi, mid and the loop condition) are where bugs hide. Lower bound and upper bound find the first and last positions of a value, Python's bisect module gives them for free, and a small twist searches a rotated sorted array.

The idea

You met recursive binary search in Module 5. Here is the loop version, which uses O(1) space, and its variants. The list must be sorted. Look at the middle item. If it is the target, stop. If it is smaller than the target, the target can only be to the right, so discard the left half; otherwise discard the right half. Each step halves the range, so a million items need at most 20 steps (2²⁰ is about a million).

Think of it like this: Finding a word in a paper dictionary: open it near the middle, see that "mango" comes after the page you opened, and ignore the whole first half. Nobody reads a dictionary page by page.

The exact loop

python
def binary_search(nums, target):
    lo, hi = 0, len(nums) - 1          # the search range is nums[lo..hi], both included
    while lo <= hi:                    # <= : a one-item range must still be checked
        mid = (lo + hi) // 2
        if nums[mid] == target:
            return mid
        if nums[mid] < target:
            lo = mid + 1               # mid is too small: drop it and everything left
        else:
            hi = mid - 1               # mid is too big: drop it and everything right
    return -1

print(binary_search([1, 3, 5, 7, 9, 11], 7))   # Output: 3
print(binary_search([1, 3, 5, 7, 9, 11], 4))   # Output: -1
O(log n) time, O(1) space.

The invariant (a fact that stays true every round) is: if the target is anywhere, it is inside nums[lo..hi]. Every line keeps that true, and the range shrinks by at least one each round, so the loop ends. When lo passes hi, the range is empty and the target is missing.

Off-by-one mistakes

  • while lo < hi with inclusive bounds: the last remaining item is never checked.
  • lo = mid instead of lo = mid + 1: when hi = lo + 1, mid equals lo and the loop runs forever.
  • hi = len(nums) with inclusive bounds: nums[mid] can go past the end.
  • In Java or C++, (lo + hi) / 2 can overflow a 32-bit int for huge arrays; write lo + (hi − lo) / 2. Python integers do not overflow, but interviewers like this point.

Lower bound and upper bound

Often you need a position, not just "found or not". The lower bound of x is the first index whose value is ≥ x; the upper bound is the first index whose value is > x. Both are also the places to insert x and keep the list sorted. These use a half-open range [lo, hi), where hi is NOT included, and the loop runs while lo < hi. The same template finds "the first index where a condition becomes true", which is the heart of Topic 5.

python
def lower_bound(nums, x):
    lo, hi = 0, len(nums)              # half-open: nums[lo..hi-1]
    while lo < hi:
        mid = (lo + hi) // 2
        if nums[mid] < x:
            lo = mid + 1
        else:
            hi = mid                   # mid might be the answer: keep it
    return lo

nums = [1, 2, 4, 4, 4, 7]
print(lower_bound(nums, 4), lower_bound(nums, 5), lower_bound(nums, 9))   # Output: 2 5 6

Python's bisect module does exactly this in C: bisect.bisect_left(nums, x) is the lower bound and bisect.bisect_right(nums, x) is the upper bound. bisect.insort(nums, x) inserts x in order (the search is O(log n), but inserting into the list shifts items, O(n)). Since Python 3.10 these functions also accept key=.

python
from bisect import bisect_left, bisect_right

nums = [1, 2, 4, 4, 4, 7]
print(bisect_left(nums, 4), bisect_right(nums, 4))   # Output: 2 5
print(bisect_right(nums, 4) - bisect_left(nums, 4))  # Output: 3   (how many 4s)

First and last position of a value

With duplicates, the first position of x is lower_bound(x) if that index holds x; the last position is upper_bound(x) − 1. Two binary searches, O(log n) total. Scanning left and right from any match would be O(n) when the whole list is x.

Search in a rotated sorted array

A sorted list of distinct values is rotated at some unknown point, for example [4, 5, 6, 7, 0, 1, 2]. It is no longer sorted, but at any mid, at least one of the halves nums[lo..mid] and nums[mid..hi] IS sorted. Check which: if nums[lo] ≤ nums[mid], the left half is sorted. If the target lies within that sorted half's range, search there; otherwise search the other half. Still O(log n).

Common mistakes

  • Running binary search on unsorted data. Fix: sort first (O(n log n)) or use a set or dict for single lookups.
  • Mixing the inclusive template (lo <= hi, hi = mid − 1) with the half-open one (lo < hi, hi = mid). Fix: pick one template and keep all four lines consistent.
  • Returning any match when the problem asks for the first one. Fix: use the lower bound, and keep searching left after a match.
  • Using nums.index(x) or "x in nums" inside a loop, which is O(n) each. Fix: bisect for sorted data.
  • Forgetting the empty list. Fix: with hi = len(nums) − 1 = −1 the loop simply does not run; check that your code returns the right value.

Need help?

Stuck on a word or an idea? Search this course, or every course, and jump straight to the explanation or a worked question.

Try:

Watch the lesson

Binary search · 1/8

Binary search

This lesson teaches binary search properly: the idea, the exact loop, and the variants interviewers love.

Prefer a video by a teacher? Find free videos on “binary search lower bound upper bound bisect python rotated sorted array explained” on YouTube ↗

In real life: Finding a PNR in a sorted booking log

A railway booking system writes bookings to a log in order of booking ID (a PNR-like number that only grows). To find a booking, or all bookings in an ID range, the system does not scan millions of lines; it binary searches the sorted IDs. Database indexes (B-trees) use the same "keep the data sorted, halve the search" idea at a larger scale, which Module 10 of the SQL course covers.

python
from bisect import bisect_left, bisect_right

pnrs = [2104, 2110, 2111, 2150, 2188, 2190, 2205]
lo = bisect_left(pnrs, 2110)
hi = bisect_right(pnrs, 2190)
print(pnrs[lo:hi])
Shows [2110, 2111, 2150, 2188, 2190]: every booking from 2110 to 2190, found with two O(log n) searches.

Practice (0/4 done)

Task 1 · Code · Python

Write binary_search(nums, target) with a LOOP (not recursion). nums is sorted in ascending order with distinct values. Return the index of target, or -1 if it is missing. O(log n) time, O(1) extra space; do not use nums.index or the in operator. Example: binary_search([1, 3, 5, 7, 9, 11], 7) returns 3.

Task 2 · Code · Python

Write first_and_last(nums, target). nums is sorted in non-decreasing order and may contain duplicates. Return [first, last]: the first and last index where target appears, or [-1, -1] if it does not appear. Use two binary searches (lower bound and upper bound) for O(log n) time; you may write them yourself or use the bisect module. Example: first_and_last([5, 7, 7, 8, 8, 10], 8) returns [3, 4].

Task 3 · Code · Python

A list of DISTINCT integers was sorted in ascending order and then rotated at an unknown point, for example [0, 1, 2, 4, 5, 6, 7] might become [4, 5, 6, 7, 0, 1, 2]. Write search_rotated(nums, target) that returns the index of target or -1, in O(log n) time: at each step, one of the two halves around mid is sorted; decide whether target lies inside it. Example: search_rotated([4, 5, 6, 7, 0, 1, 2], 0) returns 4.

Task 4 · Code · Python

Write insert_position(nums, target). nums is sorted in non-decreasing order (duplicates allowed). Return the SMALLEST index at which target could be inserted so that nums stays sorted: the same answer as bisect.bisect_left. Write the binary search yourself in O(log n). Example: insert_position([1, 3, 5, 6], 2) returns 1.

Quick quiz

Question 1

1.At most how many steps does binary search need on 1,000,000 sorted items?

Question 2

2.With an inclusive range (hi = len(nums) − 1), why must the loop be while lo <= hi?

Question 3

3.nums = [1, 2, 4, 4, 4, 7]. What is bisect_right(nums, 4)?

Question 4

4.Why can lo = mid (instead of mid + 1) loop forever in the inclusive template?

Question 5

5.In a rotated sorted array, nums[lo] <= nums[mid]. What do you know?

Read more (free)

Open a question to see the answer and why. The first ones are this topic's own quiz, so try the quiz above before you peek.

At most how many steps does binary search need on 1,000,000 sorted items?

Answer: About 20

Each step halves the range; 2²⁰ is just over a million.

With an inclusive range (hi = len(nums) − 1), why must the loop be while lo <= hi?

Answer: So a range with one item is still checked

When lo == hi one item remains; while lo < hi would stop without checking it.

nums = [1, 2, 4, 4, 4, 7]. What is bisect_right(nums, 4)?

Answer: 5

bisect_right is the first index with a value greater than 4, which is index 5 (the 7).

Why can lo = mid (instead of mid + 1) loop forever in the inclusive template?

Answer: When hi = lo + 1, mid equals lo, so the range never shrinks

(lo + hi) // 2 rounds down, so with two items mid = lo, and lo = mid changes nothing.

In a rotated sorted array, nums[lo] <= nums[mid]. What do you know?

Answer: The left half nums[lo..mid] is sorted

If the start of the range is not bigger than mid, there is no rotation point between them.

About how many steps does binary search need for 1,000,000 sorted items?Related

Answer: About 20

Halving 1,000,000 about 20 times gets down to 1, because 2 to the power 20 is about a million.

From Big-O and searching

Rotating a list of 5 items right by 12 is the same as rotating by…Related

Answer: 2

12 % 5 = 2. Every full 5 rotations bring the list back to where it started.

From Arrays: traversal and in-place updates

Why pass lo and hi instead of slicing nums[mid + 1:] in recursive binary search?Related

Answer: Slicing copies the half, adding O(n) work and memory

A slice copies about n/2 items, so the search would cost O(n) instead of O(log n).

From Divide and conquer

Why write mid = lo + (hi - lo) / 2?Related

Answer: It avoids int overflow of lo + hi on huge arrays

lo + hi can exceed Integer.MAX_VALUE.

From Two-dimensional arrays and array algorithms

Pass the quiz (60% or more) or mark this topic as done when you are ready.

Your progress is saved on this device only.

Next steps

  1. 1 more topic in “Sorting and searching” before its exam: Binary search on the answer.
  2. Module 6 exam: Sorting and searching · 14 questions · 25 min, without help. 5 topics of this module still to finish first.
  3. Then the next module, Trees and binary search trees. 61 topics left in the course before the final exam.

← Previous: Sorting in real code: keys, comparators, counting sort