Data Structures & Algorithms Cheatsheet
Binary Search
Use this Data Structures & Algorithms reference while you build software engineering projects, review code, or refresh the syntax you reach for most.
When To Reach For This
Binary search needs one thing only: a monotone predicate. Somewhere in the search space, the answer flips from false to true exactly once and never flips back. Sortedness is just the most common way to get that.
Signals in a problem statement:
- The input is sorted, or rotated-sorted, or the rows and columns of a matrix are sorted.
- "Find the minimum k such that..." or "the maximum capacity such that...". That is search on the answer.
- The constraint is huge (n up to 10⁹ or 10¹⁸) but checking one candidate is cheap. Target complexity O(n log n) or O(log n) with an O(n) check.
- The words first, last, insertion position, floor, or ceiling.
- "Minimize the maximum" or "maximize the minimum", which is the signature phrasing of the answer-space variant.
Classic problems it solves:
- Binary Search and Search Insert Position
- Find First and Last Position of Element in Sorted Array
- Search in Rotated Sorted Array and Find Minimum in Rotated Sorted Array
- Koko Eating Bananas (search on the answer)
- Split Array Largest Sum and Capacity To Ship Packages Within D Days
- Median of Two Sorted Arrays
- Find Peak Element
- Kth Smallest Element in a Sorted Matrix
Complexity
| Variant | Time | Space |
|---|---|---|
| classic search on a sorted array | O(log n) | O(1) |
| lower_bound or upper_bound | O(log n) | O(1) |
| first and last occurrence | O(log n) | O(1) |
| rotated sorted array, distinct | O(log n) | O(1) |
| rotated sorted array, duplicates | O(n) worst | O(1) |
| search on the answer, check is O(n) | O(n log R) | O(1) |
| sorted matrix, m × n staircase walk | O(m + n) | O(1) |
| binary search on prefix sums | O(log n) per query | O(n) |
R is the size of the numeric answer range, so log R is around 30–60 for typical bounds.
Exact Match Template
def binary_search(a, target): """Return an index of target, or -1. Inclusive bounds.""" lo, hi = 0, len(a) - 1 while lo <= hi: # <= because hi is a real index mid = lo + (hi - lo) // 2 # overflow-safe in Java and C++ if a[mid] == target: return mid if a[mid] < target: lo = mid + 1 else: hi = mid - 1 return -1
The three moving parts that must agree: hi is inclusive, the loop is <=, and both branches move past mid. Change one and you get an infinite loop.
Boundary Templates (lower_bound And upper_bound)
These are the two templates worth memorizing. Everything else is a wrapper around them. Both use half-open bounds and lo < hi, and neither ever compares for equality.
def lower_bound(a, target): """First index i with a[i] >= target. Returns len(a) if none.""" lo, hi = 0, len(a) # hi is exclusive while lo < hi: mid = (lo + hi) // 2 if a[mid] < target: lo = mid + 1 # mid is too small, discard it else: hi = mid # mid might be the answer, keep it return lo def upper_bound(a, target): """First index i with a[i] > target. Returns len(a) if none.""" lo, hi = 0, len(a) while lo < hi: mid = (lo + hi) // 2 if a[mid] <= target: lo = mid + 1 else: hi = mid return lo
The only difference is < versus <=. Derived queries:
| Query | Expression |
|---|---|
| first index of target | lower_bound(a, t), valid if < len(a) and a[i] == t |
| last index of target | upper_bound(a, t) - 1 |
| count of target | upper_bound(a, t) - lower_bound(a, t) |
| insertion position (leftmost) | lower_bound(a, t) |
| floor, largest value ≤ target | a[upper_bound(a, t) - 1] |
| ceiling, smallest value ≥ target | a[lower_bound(a, t)] |
| strictly less than target, count | lower_bound(a, t) |
Standard library equivalents, which you should use when allowed:
import bisect bisect.bisect_left(a, t) # lower_bound bisect.bisect_right(a, t) # upper_bound bisect.insort(a, t) # insert keeping sorted, O(n) for the shift bisect.bisect_left(a, t, lo, hi) # restricted range
int i = Arrays.binarySearch(a, t); // any match, or -(insertion) - 1 int ins = i < 0 ? -i - 1 : i; // recover the insertion point // Collections.binarySearch works the same on a sorted List // TreeMap: floorKey, ceilingKey, lowerKey, higherKey
auto it = lower_bound(a.begin(), a.end(), t); // first >= t auto jt = upper_bound(a.begin(), a.end(), t); // first > t int count = jt - it; bool found = binary_search(a.begin(), a.end(), t);
Search On The Answer
When the answer is a number and you can verify a candidate faster than you can construct the answer, binary search the answer range. Write the check as a boolean feasible(x) that is monotone, then find the boundary.
def koko_eating_bananas(piles, h): """Minimum eating speed to finish in h hours. Feasible is monotone increasing in speed, so find the FIRST feasible.""" def hours(speed): return sum((p + speed - 1) // speed for p in piles) # ceil divide lo, hi = 1, max(piles) while lo < hi: mid = (lo + hi) // 2 if hours(mid) <= h: hi = mid # feasible, try slower else: lo = mid + 1 # too slow return lo def split_array_largest_sum(nums, k): """Minimize the largest subarray sum across k splits.""" def parts_needed(cap): parts, cur = 1, 0 for x in nums: if cur + x > cap: parts += 1 cur = x else: cur += x return parts lo, hi = max(nums), sum(nums) while lo < hi: mid = (lo + hi) // 2 if parts_needed(mid) <= k: hi = mid else: lo = mid + 1 return lo
The recipe, every time:
- Identify the answer variable and its inclusive range
[lo, hi]. - Write
feasible(x), and confirm it is monotone. Iffeasible(x)is true thenfeasible(x + 1)must also be true (or the reverse, consistently). - Use the
lo < hiwithhi = midtemplate to find the first true, orlo = midwith a ceiling mid to find the last true. - Set
loandhito genuinely valid extremes.lo = max(nums)above matters, a smallerlomakes the answer infeasible for every candidate below it.
Floating point version runs a fixed iteration count rather than comparing bounds:
def search_float(feasible, lo=0.0, hi=1e9, rounds=100): """100 halvings is well past float64 precision, so no epsilon needed.""" for _ in range(rounds): mid = (lo + hi) / 2 if feasible(mid): hi = mid else: lo = mid return lo
Rotated And Unsorted Variants
def search_rotated(a, target): """Distinct values. Decide which half is sorted, then whether the target lies inside it. O(log n).""" lo, hi = 0, len(a) - 1 while lo <= hi: mid = (lo + hi) // 2 if a[mid] == target: return mid if a[lo] <= a[mid]: # left half is sorted if a[lo] <= target < a[mid]: hi = mid - 1 else: lo = mid + 1 else: # right half is sorted if a[mid] < target <= a[hi]: lo = mid + 1 else: hi = mid - 1 return -1 def find_min_rotated(a): """Smallest element, distinct values. Compare mid to the RIGHT end.""" lo, hi = 0, len(a) - 1 while lo < hi: mid = (lo + hi) // 2 if a[mid] > a[hi]: lo = mid + 1 # minimum is strictly right of mid else: hi = mid # mid could be the minimum return a[lo] def find_peak(a): """Any local peak in an array with no equal neighbors. O(log n), works without sorting because the slope is the predicate.""" lo, hi = 0, len(a) - 1 while lo < hi: mid = (lo + hi) // 2 if a[mid] < a[mid + 1]: lo = mid + 1 # uphill, a peak lies right else: hi = mid return lo def search_matrix(m, target): """Rows and columns sorted ascending. Staircase from the top-right, O(rows + cols).""" r, c = 0, len(m[0]) - 1 while r < len(m) and c >= 0: if m[r][c] == target: return True if m[r][c] > target: c -= 1 else: r += 1 return False
Compare
midagainsthi, not againstlo, when hunting the rotation minimum. Comparing tolofails on an array that was never rotated.
Gotchas
- Infinite loops. With
lo < hiandhi = mid, the mid must round down. If you ever writelo = midyou must round the mid up with(lo + hi + 1) // 2, orlonever advances. - Overflow.
(lo + hi) / 2overflows a Java or C++intnear 2³¹. Uselo + (hi - lo) / 2. Python is immune. - Inclusive versus exclusive
hi. Pick one convention per function and never mix.hi = len(a) - 1pairs withlo <= hi,hi = len(a)pairs withlo < hi. - Duplicates in a rotated array break the O(log n) guarantee. When
a[lo] == a[mid] == a[hi]you can only shrink by one, giving O(n) worst case. - The predicate must be monotone. Binary searching a non-monotone check returns an arbitrary index, silently.
Arrays.binarySearchreturns any match, not the first. For first and last occurrence write the boundary template yourself.bisect.insortis O(n), because the list shift dominates the O(log n) search. For many inserts use a heap or a balanced structure.- Verify the returned boundary.
lower_boundreturnslen(a)when nothing qualifies, so check the index before dereferencing it.