Skip to content

Binary Search Template and Variants

Binary search is a five-line algorithm that developers still get wrong under interview pressure — off-by-one errors and infinite loops are the two most common bugs in all of interview coding. Learn one correct template, understand why each line is there, and you’ll never second-guess it again.

What you’ll learn

  • The canonical, bug-free binary search template — lo <= hilo <= hi vs lo < hilo < hi, and when to use each.
  • The overflow-free midmid habit: mid = lo + (hi - lo) // 2mid = lo + (hi - lo) // 2.
  • lower_boundlower_bound / upper_boundupper_bound, hand-rolled and via Python’s bisectbisect module.
  • Binary search on the answer — searching a range of possible answers instead of an array.
  • Why binary search is O(logn)O(\log n), and the classic ways it breaks.

The canonical template: does the target exist?

The most common shape: search a sorted array for an exact value, return its index or -1-1. Search space is inclusive on both ends, so the loop keeps going while lo <= hilo <= hi — the space is empty exactly when lolo crosses past hihi.

binary_search_exact.py
def binary_search(arr, target):
    lo, hi = 0, len(arr) - 1
    while lo <= hi:
        mid = lo + (hi - lo) // 2   # overflow-free habit -- see note below
        if arr[mid] == target:
            return mid
        elif arr[mid] < target:
            lo = mid + 1            # target is to the right -- discard mid and everything left
        else:
            hi = mid - 1            # target is to the left -- discard mid and everything right
 
    return -1
 
 
arr = [1, 3, 4, 7, 9, 11, 13, 18, 21, 25]
print(binary_search(arr, 13))   # expect 6
print(binary_search(arr, 6))    # expect -1 -- not present
binary_search_exact.py
def binary_search(arr, target):
    lo, hi = 0, len(arr) - 1
    while lo <= hi:
        mid = lo + (hi - lo) // 2   # overflow-free habit -- see note below
        if arr[mid] == target:
            return mid
        elif arr[mid] < target:
            lo = mid + 1            # target is to the right -- discard mid and everything left
        else:
            hi = mid - 1            # target is to the left -- discard mid and everything right
 
    return -1
 
 
arr = [1, 3, 4, 7, 9, 11, 13, 18, 21, 25]
print(binary_search(arr, 13))   # expect 6
print(binary_search(arr, 6))    # expect -1 -- not present

Watch lolo, midmid, hihi narrow the search space

sketch Binary search: lo, mid, hi narrowing on a sorted array p5.js
Each step computes mid halfway between lo and hi, compares arr[mid] to the target, then discards the half that can't contain it.

lo <= hilo <= hi vs lo < hilo < hi: two templates, two purposes

There isn’t one “correct” binary search loop condition — there are two common templates, each suited to a different question:

TemplateBoundsQuestion it answers
while lo <= hiwhile lo <= hilo, hi = 0, len(arr) - 1lo, hi = 0, len(arr) - 1 (inclusive both ends)“Does the target exist? Give me its index.”
while lo < hiwhile lo < hilo, hi = 0, len(arr)lo, hi = 0, len(arr) (hi is exclusive)“Give me the boundary — first index satisfying some condition.”

The lo < hilo < hi template converges until lo == hilo == hi, landing exactly on the boundary you’re looking for — which is exactly what lower_boundlower_bound and upper_boundupper_bound need.

lower_boundlower_bound / upper_boundupper_bound, hand-rolled

lower_bound(arr, target)lower_bound(arr, target) finds the first index where arr[i] >= targetarr[i] >= target. upper_bound(arr, target)upper_bound(arr, target) finds the first index where arr[i] > targetarr[i] > target. Together they bracket every occurrence of targettarget in a sorted array.

lower_upper_bound.py
def lower_bound(arr, target):
    lo, hi = 0, len(arr)   # hi is EXCLUSIVE here -- "one past the end"
    while lo < hi:
        mid = lo + (hi - lo) // 2
        if arr[mid] < target:
            lo = mid + 1
        else:
            hi = mid
    return lo
 
 
def upper_bound(arr, target):
    lo, hi = 0, len(arr)
    while lo < hi:
        mid = lo + (hi - lo) // 2
        if arr[mid] <= target:
            lo = mid + 1
        else:
            hi = mid
    return lo
 
 
arr = [1, 3, 3, 3, 5, 7, 9]
print("lower_bound(3):", lower_bound(arr, 3))   # first 3 -> index 1
print("upper_bound(3):", upper_bound(arr, 3))   # first index after all 3s -> index 4
print("count of 3s:   ", upper_bound(arr, 3) - lower_bound(arr, 3))
lower_upper_bound.py
def lower_bound(arr, target):
    lo, hi = 0, len(arr)   # hi is EXCLUSIVE here -- "one past the end"
    while lo < hi:
        mid = lo + (hi - lo) // 2
        if arr[mid] < target:
            lo = mid + 1
        else:
            hi = mid
    return lo
 
 
def upper_bound(arr, target):
    lo, hi = 0, len(arr)
    while lo < hi:
        mid = lo + (hi - lo) // 2
        if arr[mid] <= target:
            lo = mid + 1
        else:
            hi = mid
    return lo
 
 
arr = [1, 3, 3, 3, 5, 7, 9]
print("lower_bound(3):", lower_bound(arr, 3))   # first 3 -> index 1
print("upper_bound(3):", upper_bound(arr, 3))   # first index after all 3s -> index 4
print("count of 3s:   ", upper_bound(arr, 3) - lower_bound(arr, 3))

lower_boundlower_bound / upper_boundupper_bound via bisectbisect

The standard library already implements exactly this, in C, via the bisectbisect module — no need to hand-roll it in production code.

bisect_module.py
import bisect
 
arr = [1, 3, 3, 3, 5, 7, 9]
 
print("bisect_left(3): ", bisect.bisect_left(arr, 3))    # same as hand-rolled lower_bound
print("bisect_right(3):", bisect.bisect_right(arr, 3))   # same as hand-rolled upper_bound
 
# insort keeps a list sorted after inserting -- finds the spot in O(log n),
# but the actual insertion still shifts elements, so it's O(n) overall
bisect.insort(arr, 4)
print("after insort(4):", arr)
bisect_module.py
import bisect
 
arr = [1, 3, 3, 3, 5, 7, 9]
 
print("bisect_left(3): ", bisect.bisect_left(arr, 3))    # same as hand-rolled lower_bound
print("bisect_right(3):", bisect.bisect_right(arr, 3))   # same as hand-rolled upper_bound
 
# insort keeps a list sorted after inserting -- finds the spot in O(log n),
# but the actual insertion still shifts elements, so it's O(n) overall
bisect.insort(arr, 4)
print("after insort(4):", arr)

Binary search on the answer

The pattern that turns binary search from “a way to search arrays” into “a way to solve optimization problems”: instead of searching an array, search the range of possible answers, using a feasibility check to decide which half to keep.

Koko Eating Bananas: Koko has pilespiles of bananas and hh hours. Each hour she picks one pile and eats up to speedspeed bananas from it (finishing a pile early doesn’t help — the rest of that hour is wasted). Find the minimum integer speedspeed that lets her finish every pile within hh hours.

The key insight: as speedspeed increases, the hours needed only ever decreases (or stays the same) — that monotonic relationship is exactly what binary search needs.

koko_eating_bananas.py
import math
 
def min_eating_speed(piles, h):
    def hours_needed(speed):
        return sum(math.ceil(pile / speed) for pile in piles)
 
    lo, hi = 1, max(piles)   # answer is somewhere in [1, max(piles)]
    while lo < hi:
        mid = lo + (hi - lo) // 2
        if hours_needed(mid) <= h:
            hi = mid          # speed=mid WORKS -- try to go slower (smaller)
        else:
            lo = mid + 1      # speed=mid too slow -- need to go faster (bigger)
    return lo
 
 
print(min_eating_speed([3, 6, 7, 11], 8))    # expect 4
print(min_eating_speed([30, 11, 23, 4, 20], 5))   # expect 30
koko_eating_bananas.py
import math
 
def min_eating_speed(piles, h):
    def hours_needed(speed):
        return sum(math.ceil(pile / speed) for pile in piles)
 
    lo, hi = 1, max(piles)   # answer is somewhere in [1, max(piles)]
    while lo < hi:
        mid = lo + (hi - lo) // 2
        if hours_needed(mid) <= h:
            hi = mid          # speed=mid WORKS -- try to go slower (smaller)
        else:
            lo = mid + 1      # speed=mid too slow -- need to go faster (bigger)
    return lo
 
 
print(min_eating_speed([3, 6, 7, 11], 8))    # expect 4
print(min_eating_speed([30, 11, 23, 4, 20], 5))   # expect 30

Every “minimize the maximum” or “maximize the minimum” phrasing — ship capacity over nn days, minimum time to complete tasks, splitting an array to minimize the largest subarray sum — reduces to this same shape: binary search over the answer, with an O(f(n))O(f(n)) feasibility check inside.

diagram Binary search on the answer: narrowing the feasible speed mermaid

Time and space complexity

OperationComplexity
Binary search on a sorted arrayO(logn)O(\log n)
lower_boundlower_bound / upper_boundupper_bound (hand-rolled or bisectbisect)O(logn)O(\log n)
Binary search on the answer (feasibility check costs O(f(n))O(f(n)))O(f(n)log(range))O(f(n) \log(\text{range}))
SpaceO(1)O(1)

LeetCode problem set

#ProblemDifficultyThe twist
704Binary SearchEasyThe canonical exact-match template, direct application
35Search Insert PositionEasyExactly lower_boundlower_bound: the first index where the target could be inserted to keep the array sorted
34Find First and Last Position of Element in Sorted ArrayMediumlower_boundlower_bound and upper_boundupper_bound back to back
875Koko Eating BananasMediumBinary search on the answer, as above
33Search in Rotated Sorted ArrayMediumThe exact-match template, with one extra check per step to figure out which half is still sorted

Practice — real LeetCode problems

Each exercise is the actual LeetCode problem with its real method signature and LeetCode’s own examples as the test. Write the body, press Run, and match the expected output.

LC 704 — Binary Search · Easy

Problem. Given a sorted array of distinct integers and a targettarget, return its index, or -1-1 if absent. Must run in O(logn)O(\log n).

Constraints. 1 <= len(nums) <= 10^41 <= len(nums) <= 10^4, sorted ascending, all values distinct.

Examples. [-1,0,3,5,9,12], target = 9[-1,0,3,5,9,12], target = 9 gives 44 · target = 2target = 2 gives -1-1

Editorial

The canonical exact search. Each comparison halves the range.

Time O(logn)O(\log n). Space O(1)O(1).

Getting the loop shape right is the whole content, and it is worth naming the two forms explicitly because mixing them is the most common binary-search bug:

  • while lo <= hiwhile lo <= hi with lo = mid + 1lo = mid + 1 / hi = mid - 1hi = mid - 1 — for finding an exact value. midmid is fully excluded once tested, and the loop must consider the case lo == hilo == hi, hence <=<=.
  • while lo < hiwhile lo < hi with hi = midhi = mid — for finding a boundary, where midmid may itself be the answer and must stay in range.

([5], 5)([5], 5) and ([5], -5)([5], -5) are the single-element tests: with lo < hilo < hi the loop body would never run and the first would wrongly return -1-1.

(lo + hi) // 2(lo + hi) // 2 cannot overflow in Python. In C++ or Java you would write lo + (hi - lo) // 2lo + (hi - lo) // 2, which is worth mentioning since it is the reason that idiom exists.

Follow-ups: “Where would it be inserted if absent (LC 35)?” — next problem, and it needs the boundary shape. “First and last occurrence with duplicates (LC 34)?” — two boundary searches. “Rotated array (LC 33)?” — see Binary Search on Rotated Arrays. “Use bisectbisect?” — bisect_leftbisect_left does this; mention it, then write the loop.

LC 35 — Search Insert Position · Easy

Problem. Given a sorted array of distinct integers and a targettarget, return its index if found, otherwise the index where it would be inserted to keep the array sorted. Must be O(logn)O(\log n).

Constraints. 1 <= len(nums) <= 10^41 <= len(nums) <= 10^4, sorted ascending, distinct.

Examples. [1,3,5,6], target = 5[1,3,5,6], target = 5 gives 22 · target = 2target = 2 gives 11 · target = 7target = 7 gives 44 · target = 0target = 0 gives 00

Editorial

This is lower bound: the first index whose value is >= target>= target. That single definition answers both cases — if the target is present, its own index is the first such position; if absent, that position is exactly where it belongs.

Time O(logn)O(\log n). Space O(1)O(1).

Two changes from LC 704, both necessary:

  • hi = len(nums)hi = len(nums), not len(nums) - 1len(nums) - 1. target = 7target = 7 must return 44, which is one past the last index. Starting hihi at 33 makes that answer unreachable.
  • hi = midhi = mid, never mid - 1mid - 1. midmid is a live candidate for the boundary.

The loop terminates with lo == hilo == hi, and that common value is the answer — which is why nothing is returned from inside the loop.

This is precisely bisect.bisect_left(nums, target)bisect.bisect_left(nums, target). Knowing that the standard library already has both bisect_leftbisect_left (lower bound) and bisect_rightbisect_right (upper bound) is worth stating — and knowing which is which is what LC 34 tests.

Follow-ups: “First and last occurrence (LC 34)?” — next problem: bisect_leftbisect_left and bisect_rightbisect_right. “With duplicates, which index does this give?” — the leftmost. “How do you remember the two shapes?” — exact search excludes midmid and needs <=<=; boundary search keeps midmid and needs <<.

LC 34 — Find First and Last Position of Element in Sorted Array · Medium

Problem. Given a sorted array, return the starting and ending index of a given targettarget, or [-1, -1][-1, -1] if it is absent. Must be O(logn)O(\log n).

Constraints. 0 <= len(nums) <= 10^50 <= len(nums) <= 10^5, sorted ascending, values may repeat.

Examples. [5,7,7,8,8,10], target = 8[5,7,7,8,8,10], target = 8 gives [3,4][3,4] · target = 6target = 6 gives [-1,-1][-1,-1] · [], target = 0[], target = 0 gives [-1,-1][-1,-1]

Editorial

Two boundary searches, differing by one comparison:

  • Lower bound — first index with nums[i] >= targetnums[i] >= target.
  • Upper bound — first index with nums[i] > targetnums[i] > target. The last occurrence is one before it.

Time O(logn)O(\log n) — two logarithmic passes. Space O(1)O(1).

The presence check is essential and easy to omit. The lower bound returns an insertion point whether or not the target exists, so you must verify both that it is in range (first == len(nums)first == len(nums) guards the empty array and past-the-end cases) and that the value there is the target. ([], 0)([], 0) and ([5,7,7,8,8,10], 6)([5,7,7,8,8,10], 6) both exercise it.

Once you recognise these as bisect_leftbisect_left and bisect_rightbisect_right, the whole problem is two library calls:

python
from bisect import bisect_left, bisect_right
lo = bisect_left(nums, target)
if lo == len(nums) or nums[lo] != target:
    return [-1, -1]
return [lo, bisect_right(nums, target) - 1]
python
from bisect import bisect_left, bisect_right
lo = bisect_left(nums, target)
if lo == len(nums) or nums[lo] != target:
    return [-1, -1]
return [lo, bisect_right(nums, target) - 1]

Worth showing — it demonstrates you know the standard library — but interviewers usually want the hand-written version, since the point is the boundary logic.

([2,2], 2)([2,2], 2) giving [0,1][0,1] confirms the range spans all duplicates.

Follow-ups: “Count occurrences?” — upper - lowerupper - lower, no extra work. “Only the first occurrence?” — one search. “Why two searches and not one plus a scan?” — a linear scan over duplicates would be O(n)O(n), which breaks the requirement.

Recap

  • One template, two flavors: lo <= hilo <= hi with inclusive bounds for exact matches; lo < hilo < hi with an exclusive hihi for boundary-finding (lower_boundlower_bound/upper_boundupper_bound).
  • mid = lo + (hi - lo) // 2mid = lo + (hi - lo) // 2 is an overflow-free habit worth keeping even in Python, where integers never actually overflow.
  • bisect.bisect_leftbisect.bisect_left / bisect.bisect_rightbisect.bisect_right are lower_boundlower_bound / upper_boundupper_bound, already written and tested in the standard library.
  • Binary search on the answer: when a feasibility check’s result is monotonic in some parameter, binary search that parameter’s range instead of an array — turns many “minimize the maximum” problems into O(f(n)log(range))O(f(n) \log(\text{range})).
  • Off-by-one bugs and infinite loops almost always trace back to mixing bounds conventions — pick inclusive or exclusive hihi and stay consistent.

You’ve now covered sorting and searching, the two building blocks behind nearly every “optimize this over sorted or monotonic data” interview question.

If this helped you, consider buying me a coffee ☕

Buy me a coffee

Was this page helpful?

Let us know how we did