Search Tech Journey

Find topics, journeys and posts

back to blog
algorithmsintermediate 60m read

L13 · Binary Search I — The Template

One binary search template with a half-open invariant that never loops forever and never goes off by one, and the discipline of owning exactly one version of it.

🧩DSAPhase 1 · Core patterns· Session 013 of 130 60 min

🎯 Own one binary search template cold: the bisect-left 'first index where a predicate is true' form, with a loop invariant that guarantees termination and no off-by-one.

Series: LeetCode — From Basics to Interview-Ready · Session 13 / 65 · Phase 1 · Core patterns

Watch first

What this pattern IS

You have a sorted array and want to find where a value is (or where it would go). Scanning left to right is O(n). Binary search exploits the sortedness: look at the middle element; because the array is sorted, that one comparison tells you which half the answer is in, so you throw the other half away. Halving the search space each step gives O(log n) — 20 steps for a million elements, 30 for a billion.

The idea is trivial. The bugs are not. Binary search is notorious for three failure modes:

  1. Infinite loops — the range never shrinks because mid lands on a boundary you don't move past.
  2. Off-by-one — you return mid, mid − 1, or mid + 1 and can't remember which.
  3. Overflow(low + high) overflows in fixed-width languages (not Python, but interviewers still ask).

The cure is discipline: own exactly one template, with a crisp loop invariant, and reframe every problem as "find the first index where a monotone predicate becomes true." That single boundary-finding form solves search, insert-position, first/last occurrence, and (in L15) search-on-the-answer.

Guess the number
🌍 Real world
I pick a number 1–100; you guess, I say 'higher' or 'lower'. You always guess the middle of the remaining range, halving it each time — 7 guesses max. Binary search is this game where 'higher/lower' is a comparison with the middle element.
💻 Code world
mid = (lo + hi) // 2; if predicate(mid): hi = mid else lo = mid + 1.

First principles

From first principles
Start with the question
Why does the half-open 'first true' template always terminate and never go off by one?
  1. 1
    Keep the invariant: the answer (first index where predicate is true) lies in [lo, hi], with hi exclusive of the known-false region.
    forced by · Every step preserves this range so the answer is never discarded.
  2. 2
    The predicate is MONOTONE: false, false, ..., false, true, true, ..., true — it flips exactly once.
    forced by · Sorted data (or a monotone feasibility test) guarantees a single boundary between the false and true regions.
  3. 3
    If predicate(mid) is true, the boundary is at mid or left of it, so set hi = mid (keep mid as a candidate).
    forced by · mid itself might be the first true; discarding it could lose the answer.
  4. 4
    If predicate(mid) is false, the boundary is strictly right, so set lo = mid + 1 (discard mid).
    forced by · mid is known-false and can never be the first true, so excluding it is safe and guarantees progress.
  5. 5
    The range strictly shrinks every step and stops when lo == hi.
    forced by · One branch does hi = mid (< hi since mid < hi when lo < hi), the other does lo = mid + 1 (> lo); either way the interval narrows, so it terminates.
⇒ Therefore
lo == hi at the end IS the boundary: the first index where the predicate holds (or len(array) if none).

The templates

Template — the ONE to memorise: first index where predicate is true

def lower_bound(lo, hi, predicate):
    """Return the smallest x in [lo, hi] with predicate(x) == True.
    predicate must be MONOTONE: False...False, True...True.
    Returns hi if predicate is never true in range."""
    while lo < hi:                  # half-open: hi is exclusive of the answer region
        mid = lo + (hi - lo) // 2   # overflow-safe midpoint (matters in C++/Java)
        if predicate(mid):
            hi = mid                # mid might be the answer -> keep it
        else:
            lo = mid + 1            # mid is too small -> discard it
    return lo                       # lo == hi == the boundary

Applying it to plain search / insert position

def search_insert(nums, target):
    # first index i where nums[i] >= target  ->  predicate(i) = nums[i] >= target
    lo, hi = 0, len(nums)
    while lo < hi:
        mid = lo + (hi - lo) // 2
        if nums[mid] >= target:
            hi = mid
        else:
            lo = mid + 1
    return lo    # insertion point; nums[lo] == target if present
 
def contains(nums, target):
    i = search_insert(nums, target)
    return i < len(nums) and nums[i] == target

First and last occurrence (from the same template)

import bisect
def first_last(nums, target):
    left = bisect.bisect_left(nums, target)     # first index >= target
    right = bisect.bisect_right(nums, target)   # first index > target
    if left == len(nums) or nums[left] != target:
        return (-1, -1)
    return (left, right - 1)                     # inclusive range of target

bisect_left/bisect_right are the standard library's version of exactly this template — use them when allowed, and know how to write them by hand when not.

Mental model

Mental modelFind the boundary, not the value
A row of dominoes, all False then all True. You're hunting the FIRST True. mid True → the boundary is here or left (hi = mid). mid False → boundary is strictly right (lo = mid + 1). Stop when the range collapses to one point.
  • Half-open [lo, hi); loop while lo < hi; return lo.
  • predicate True → hi = mid (keep candidate). predicate False → lo = mid + 1 (discard).
  • mid = lo + (hi - lo) // 2 to avoid overflow.
  • Reframe EVERY problem as 'first index where predicate is true'.
🔔 Fires when you see
'sorted array', 'find/insert position', 'first/last occurrence', or 'smallest x satisfying a monotone condition'.

Memory / mnemonic

Own one template by burning in a four-line shape and a rule couplet.

  • The shape: while lo < hi: mid; if pred: hi = mid; else lo = mid+1; return lo. Four lines, always the same. Type it a dozen times until your fingers know it.
  • The rule couplet: "True keeps mid, False kills mid." True → hi = mid (mid survives as a candidate); False → lo = mid + 1 (mid is killed).
  • The midpoint rhyme: "lo plus half the gap"lo + (hi - lo) // 2, never (lo + hi) // 2 (overflow).
  • The return peg: half-open means the loop ends at lo == hi, and that point is the boundary → "return lo."

One template, memorised cold, beats three templates you half-remember and mix up under pressure.

The trap

Common misconception
✗ What most people think
You should use `while lo <= hi` with `hi = mid - 1` / `lo = mid + 1` and return mid — the 'classic' textbook binary search.
✓ What is actually true
That closed-interval form is fine for exact-match search but is a bug factory for boundary questions (first/last occurrence, insert position, search-on-answer). Own the half-open 'first true' template instead; it generalises to every variant.
Why the myth is so sticky
With `hi = mid - 1` you must track a separate 'best answer so far' variable for boundary problems, which is exactly where off-by-ones creep in. The half-open form makes lo == hi the answer automatically.
Prove it to yourself
Ask both templates for the FIRST index of a duplicated target. The half-open lower_bound returns it directly; the closed-interval version needs extra bookkeeping and is easy to get wrong.

What interviewers actually ask

Binary search is asked partly to see if you have a template you trust and partly to test boundary reasoning.

  • Binary Search (704) · Easy · Amazon, Microsoft — probes: do you have a clean, bug-free template? Follow-up: return the insert position if absent (35).
  • Search Insert Position (35) · Easy · Amazon — probes: lower-bound thinking (first index ≥ target). Follow-up: first index strictly greater (upper bound).
  • Find First and Last Position of Element in Sorted Array (34) · Medium · Amazon, Meta — probes: two boundaries (bisect_left and bisect_right) in O(log n). Follow-up: count occurrences = right − left.
  • First Bad Version (278) · Easy · Google, Amazon — probes: binary search over a monotone predicate isBadVersion, minimising API calls. Follow-up: the overflow-safe midpoint.
  • Sqrt(x) (69) · Easy · Amazon — probes: largest m with m*m ≤ x — binary search on the answer (previews L15). Follow-up: return more decimal places.
  • Guess Number Higher or Lower (374) · Easy · Amazon — probes: the raw guessing game with a given oracle. Follow-up: the overflow bug in (lo + hi) // 2.

Universal probe: "is your midpoint overflow-safe, and can you prove your loop terminates?" Have both answers ready.

Worked solutions

1. Binary Search (704)

Plain words: given a sorted array and a target, return its index, or −1 if absent.

Brute force: linear scan, O(n). Insight: use the boundary template to find the first index ≥ target, then check if that slot equals target.

def search(nums, target):
    lo, hi = 0, len(nums)
    while lo < hi:
        mid = lo + (hi - lo) // 2
        if nums[mid] >= target:        # predicate: value has reached target
            hi = mid
        else:
            lo = mid + 1
    return lo if lo < len(nums) and nums[lo] == target else -1

Complexity: O(log n) time, O(1) memory. Follow-up — "return insert position if absent" (LC35): just return lo without the equality check.

Try itRewrite to find the LAST index equal to target (or -1).
def searchLast(nums, target):
    lo, hi = 0, len(nums)
    while lo < hi:                     # first index where nums[mid] > target
        mid = lo + (hi - lo) // 2
        if nums[mid] > target:
            hi = mid
        else:
            lo = mid + 1
    i = lo - 1                         # step back to the last <= target
    return i if i >= 0 and nums[i] == target else -1
💡 Hint · Find the first index > target (upper bound), then step back one and verify.

2. Find First and Last Position (34)

Plain words: given a sorted array with possible duplicates and a target, return the first and last indices of the target as [first, last], or [-1, -1].

Insight: the first index is the lower bound (first ≥ target); the last is (upper bound) − 1 (upper bound = first > target). Two runs of the same template.

def searchRange(nums, target):
    def lower(x):                      # first index with nums[i] >= x
        lo, hi = 0, len(nums)
        while lo < hi:
            mid = lo + (hi - lo) // 2
            if nums[mid] >= x:
                hi = mid
            else:
                lo = mid + 1
        return lo
    left = lower(target)
    if left == len(nums) or nums[left] != target:
        return [-1, -1]
    right = lower(target + 1) - 1      # (first index > target) - 1
    return [left, right]

Complexity: O(log n) time, O(1) memory. Follow-up — "count occurrences": it's right − left + 1, or lower(target+1) − lower(target) directly.

3. First Bad Version (278)

Plain words: versions 1..n; once a version is bad, all later ones are bad. Given an isBadVersion(v) API, find the first bad version with as few calls as possible.

Insight: the predicate isBadVersion is monotone (False...False, True...True) — exactly the template, with the array replaced by the version range.

def firstBadVersion(n, isBadVersion):
    lo, hi = 1, n                      # answer in [1, n]
    while lo < hi:
        mid = lo + (hi - lo) // 2      # overflow-safe (the whole point of this problem)
        if isBadVersion(mid):
            hi = mid                   # bad -> boundary is here or left
        else:
            lo = mid + 1               # good -> boundary is strictly right
    return lo

Complexity: O(log n) API calls, O(1) memory. Follow-up — "why lo + (hi − lo)//2?": in a fixed-width language lo + hi can overflow near INT_MAX; the subtraction form can't.

Tradeoff

The tradeoff
Choose a binary search formulation to standardise on.
Half-open 'first true' (lo < hi, return lo)
+ you gain One template covers search, insert, first/last, and search-on-answer; no 'best so far' variable.
− you pay You must frame each problem as a monotone predicate.
pick when Default for everything — this is the one to own.
Closed interval (lo <= hi, return mid)
+ you gain Reads like the textbook; natural for pure exact-match.
− you pay Needs extra bookkeeping for boundary problems; more off-by-one surface.
pick when Only for a quick exact-match when you won't need boundaries.
Library bisect (bisect_left/right)
+ you gain Zero bugs; two lines.
− you pay Hidden mechanics; some interviewers want the hand-written version.
pick when In production and whenever the interviewer allows the standard library.
What a senior engineer actually does
Standardise on the half-open 'first true' template; know bisect for real code; only reach for the closed-interval form for trivial exact-match.

Where this shows up

Binary search underlies the standard-library bisect module and C++ lower_bound/upper_bound, which database and search engines use to locate keys in sorted index pages. Version-control git bisect is literally First Bad Version applied to commits: it binary-searches your history for the commit that introduced a bug, halving the suspect range with each test — the monotone predicate being "is the bug present at this commit."

Checkpoint

You can now…
  • Write the half-open 'first true' template from muscle memory.
  • State the loop invariant and argue termination and correctness.
  • Reframe search, insert-position, and first/last occurrence as one predicate.
  • Use bisect_left / bisect_right and know what each returns.
  • Explain and use the overflow-safe midpoint lo + (hi − lo)//2.
  • Apply the template to a monotone predicate that isn't even an array (First Bad Version).

Quiz

Recall check · click to reveal
★ = stretch question

Practice queue

+7d / +21d mark spaced-repetition anchors.