LC Patterns — Binary Search
◆ The Master Insight
Binary search does not need a sorted array. It needs a monotonic yes/no question: a question over some search space whose answers, read in order, look like FFFF…FTTT…T. Looking something up in a sorted array is just the special case where the question is "is arr[i] ≥ target?"
§0The Three Templates & Universal Pitfalls
int lo = 0, hi = n - 1;
while (lo <= hi) {
int mid = lo + (hi - lo) / 2;
if (arr[mid] == target) return mid;
else if (arr[mid] < target) lo = mid + 1;
else hi = mid - 1;
}
return -1; // not found; loop ends with lo > hi
C++ · Template B — boundary search (lo < hi) ← the workhorse, learn cold
int lo = 0, hi = n; // hi = n so "no valid index" is representable
while (lo < hi) {
int mid = lo + (hi - lo) / 2;
if (predicate(mid)) hi = mid; // mid is Yes — keep it, it might be the boundary
else lo = mid + 1; // mid is No — boundary strictly to the right
}
return lo; // lo == hi == first Yes
C++ · Template C — last Yes (lo < hi, right-biased mid)
while (lo < hi) {
int mid = lo + (hi - lo + 1) / 2; // RIGHT-biased — required when moving lo = mid
if (predicate(mid)) lo = mid; // searching for the LAST Yes
else hi = mid - 1;
}
return lo;
The 6 ways binary search goes wrong
1. Infinite loop. lo = mid with a floor-mid, or hi = mid with a ceil-mid. Rule: the side that keeps mid must round toward that side.
2. Off-by-one at the boundary. Decide up front: first Yes or last No? Write the invariant as a comment ("everything before lo is No; everything from hi on is Yes") — then the ±1s write themselves.
3. Overflow. (lo+hi)/2 overflows in C++ — always lo + (hi−lo)/2.
4. Wrong initial range. Answer-space searches fail by leaving the true answer outside the range — hi must be a valid upper bound (max(piles), sum(weights)), lo a valid lower bound (max(weights) for capacity, not 1).
5. Non-monotonic question. If the No/Yes pattern isn't one clean boundary, binary search silently returns garbage. Check monotonicity before coding.
6. Empty range / not-found. Decide what lo == n means before you return it.
1Classic Search on Sorted Array
What it is: The textbook case — a sorted array and one target, return its index (or where it would go). Compare the middle to the target, throw away the half that can't contain it, repeat until found or empty.
Intuition: the sorted order is the monotonic question ("is arr[i] ≥ target?"). Halve the space each step → O(log n).
int search(vector<int>& nums, int target) {
int lo = 0, hi = nums.size() - 1;
while (lo <= hi) {
int mid = lo + (hi - lo) / 2;
if (nums[mid] == target) return mid;
else if (nums[mid] < target) lo = mid + 1;
else hi = mid - 1;
}
return -1;
}
Challenges & pitfalls
None conceptually — this pattern drills the mechanics (mid, bounds, termination) so the later ones are pure thinking. If you make ±1 errors here, drill Template B until you don't. Variants: a sorted API you can only probe (guess number); unknown-size array (double your bounds first, then search).
2First/Last Occurrence (Lower & Upper Bound)
What it is: The array is sorted but the target appears many times (or you want its insertion edge) — find the first or last index satisfying a condition, not just any match. Keep narrowing until the search lands exactly on the edge of the matching block.
Intuition: with duplicates, "find target" becomes "find the edge of the target block." First occurrence = first index with arr[i] ≥ target (lower bound); last = upper_bound − 1. This is Template B in its purest form — and it's the foundation: every remaining pattern is a boundary search in disguise.
int lowerBound(vector<int>& a, int target) { // first index with a[i] >= target
int lo = 0, hi = a.size();
while (lo < hi) {
int mid = lo + (hi - lo) / 2;
if (a[mid] >= target) hi = mid;
else lo = mid + 1;
}
return lo;
}
vector<int> searchRange(vector<int>& a, int target) {
int first = lowerBound(a, target);
if (first == (int)a.size() || a[first] != target) return {-1, -1};
int last = lowerBound(a, target + 1) - 1; // upper_bound - 1
return {first, last};
}
Challenges & pitfalls
Resisting the urge to find any occurrence then scan outward (O(n) worst case with duplicates — the interviewer is waiting for it); keeping the two questions straight (≥ vs >).
3Rotated / Modified Sorted Arrays
What it is: A sorted array rotated at an unknown pivot (e.g. [4,5,6,7,0,1,2]) — no longer sorted overall, but still two sorted pieces stuck together. At each step work out which half around mid is properly sorted, then use that half's range to decide where the target could be.
Intuition: a rotated sorted array is two sorted runs. At any mid, at least one half is fully sorted — figure out which (compare arr[mid] with arr[lo]), check whether the target falls inside that sorted half's range, and go there. You're binary-searching on structure, not raw values.
int search(vector<int>& nums, int target) {
int lo = 0, hi = nums.size() - 1;
while (lo <= hi) {
int mid = lo + (hi - lo) / 2;
if (nums[mid] == target) return mid;
if (nums[lo] <= nums[mid]) { // left half sorted
if (nums[lo] <= target && target < nums[mid]) hi = mid - 1;
else lo = mid + 1;
} else { // right half sorted
if (nums[mid] < target && target <= nums[hi]) lo = mid + 1;
else hi = mid - 1;
}
}
return -1;
}
Challenges & pitfalls
Duplicates break the sorted-half test (arr[lo]==arr[mid]==arr[hi] tells you nothing → shrink by one, lo++/hi--, degrading worst case to O(n) — say this trade-off aloud); comparing against arr[hi] vs arr[lo] gives different edge behavior — pick one; the "is it even rotated?" edge. For the minimum (153): Template B with arr[mid] < arr[hi].
4Peak / Local Structure (search on slope)
What it is: The array isn't sorted at all — it rises and falls like a mountain — and you must find a peak (bigger than both neighbors). You still halve the space because the direction of the slope at mid tells you which side a peak must be on.
nums[1]=2 < nums[2]=3 → still climbing → a peak is to the right (lo=2). Lands on index 2. No sorted order needed — just the slope.Intuition: the slope at mid points to a peak. If arr[mid] < arr[mid+1], you're climbing → a peak lies to the right (the climb must turn or hit the end); otherwise a peak is at mid or left. The question "am I past the peak?" is monotonic on a mountain. Generalization: binary search works whenever a local comparison provably lets you throw away half.
int findPeakElement(vector<int>& nums) {
int lo = 0, hi = nums.size() - 1;
while (lo < hi) {
int mid = lo + (hi - lo) / 2;
if (nums[mid] < nums[mid + 1]) lo = mid + 1; // climbing → peak on the right
else hi = mid; // descending → peak here or left
}
return lo;
}
Challenges & pitfalls
Convincing yourself (and the interviewer) why throwing away half is safe — the one-liner: "walking toward the ascending side, it either rises until the array ends (the end is a peak) or it turns (the turn is a peak)"; boundary handling at mid+1 — use lo < hi so mid+1 is always in range.
5Binary Search on the Answer (feasibility / min-max) ★ most common interview pattern
What it is: The problem asks for an optimal number — minimum speed, minimum capacity, fewest days, largest size — and there's no array to search at all. You guess a candidate answer, run a cheap "would this work?" check, and binary-search the smallest (or largest) guess that passes. The array is only used inside the checker.
FFTT. First Yes = 4. The array only fed the checker.Intuition: you're usually asked to minimize a maximum (or maximize a minimum): "min eating speed to finish in h hours," "min ship capacity for d days," "min largest subarray sum over k splits." Don't search the array — search the answer space. The check canDo(x) is monotonic: if speed x works, every speed above works too → FFFTTT → find the first Yes.
int minEatingSpeed(vector<int>& piles, int h) {
int lo = 1, hi = *max_element(piles.begin(), piles.end());
auto hours = [&](int x) {
long long t = 0;
for (int p : piles) t += (p + x - 1) / x; // ceil(p / x)
return t;
};
while (lo < hi) {
int mid = lo + (hi - lo) / 2;
if (hours(mid) <= h) hi = mid; // fast enough → try slower
else lo = mid + 1;
}
return lo;
}
Challenges & pitfalls
Spotting that it IS this pattern (trigger words: minimize the maximum, maximize the minimum, least/most X such that, min speed/capacity/days); getting lo right (the subtle bug: lo=1 instead of lo=max(weights) for shipping); the checker must be a correct greedy — that's where the real logic lives, the binary search is trivial.
- Range:
lo = max(weights) = 10(a day must hold the heaviest box),hi = sum = 55. Checker greedily fills days up to capacity x. - Converges to capacity 15. Getting
lo = 10(not 1) is the classic correctness point. ✓
6Value Space with a Counting Function (k-th smallest)
What it is: You need the k-th smallest element in a huge structured collection (a sorted matrix, all pairwise distances) too big to build and sort. Binary-search a candidate value and, using the structure, quickly count how many elements are ≤ it — the smallest value whose count reaches k is the answer.
count(≤ 13) = 8 first reaches k
→ answer 13
staircase from bottom-left = O(n) per count
Intuition: a sibling of Pattern 5 for k-th smallest in a structured space. The question count(x) = how many elements ≤ x only goes up as x grows → binary-search the smallest x with count(x) ≥ k. The structure (sorted rows) is what computes count(x) fast without enumerating.
int kthSmallest(vector<vector<int>>& m, int k) {
int n = m.size(), lo = m[0][0], hi = m[n-1][n-1];
auto countLE = [&](int x) { // O(n) staircase from bottom-left
int cnt = 0, r = n - 1, c = 0;
while (r >= 0 && c < n) {
if (m[r][c] <= x) { cnt += r + 1; ++c; }
else --r;
}
return cnt;
};
while (lo < hi) {
int mid = lo + (hi - lo) / 2;
if (countLE(mid) >= k) hi = mid;
else lo = mid + 1;
}
return lo;
}
Challenges & pitfalls
The mental leap that you binary-search over values that might not exist — it still lands on a real value (the count jumps exactly at data points — be ready to prove it); writing the O(n) counter instead of a lazy O(n log n) one; duplicates in counting (be consistent about ≤ vs <).
7Binary Search on Real Numbers (precision-terminated)
What it is: Same guess-and-check as Pattern 5, but the answer is a real number (a square root, a distance, an average), so there's no exact integer to land on. Halve the interval until it's smaller than the precision you need (or loop a fixed ~100 times) and return the midpoint.
Intuition: same monotonic-question idea, continuous answer. No integer to converge to — stop on precision (hi − lo > eps) or a fixed iteration count (~100 iterations covers any double — the cleaner choice). For integer problems like sqrt, prefer an integer Template C over floats.
int mySqrt(int x) {
long long lo = 0, hi = x;
while (lo < hi) {
long long mid = lo + (hi - lo + 1) / 2; // right-biased: last mid with mid² ≤ x
if (mid * mid <= x) lo = mid;
else hi = mid - 1;
}
return lo;
}
// continuous version: for (int it = 0; it < 100; ++it) { double mid = (lo+hi)/2; ... }
| mid | mid² | ≤ 8? | move |
|---|---|---|---|
| 4 | 16 | no | hi = 3 |
| 2 | 4 | yes | lo = 2 |
| 3 | 9 | no | hi = 2 |
Challenges & pitfalls
eps too tight → time limit; too loose → wrong answer (fixed-iteration sidesteps both — recommend it); floating-point comparison inside the checker (avoid ==); for integer answers, do it in integers.
8Implicit / Computed Search Space (the array isn't there)
What it is: The values you're searching over are never stored — they're computed from a formula on the index, like "how many numbers are missing before position i." You invent that formula, confirm it's monotonic, and binary-search it exactly as if it were a sorted array.
missing(i) ≥ 5 is index 4 → answer = k + index = 5 + 4 = 9. (missing positives: 1,5,6,8,9,… → 5th is 9.) We searched a formula, never an actual array.Intuition: the "array" is defined by a formula, not memory. The missing-count only grows → binary-search over it (1539). Index parity flips right after the single unpaired element (540). A pigeonhole count count(≤ mid) exposes the duplicate (287). You can binary-search any monotonic function of the index — the function stands in for arr[mid].
int findKthPositive(vector<int>& arr, int k) {
int lo = 0, hi = arr.size();
while (lo < hi) { // first index with missing(i) >= k
int mid = lo + (hi - lo) / 2;
if (arr[mid] - (mid + 1) >= k) hi = mid;
else lo = mid + 1;
}
return lo + k; // k + number of reals we skipped past
}
Challenges & pitfalls
Finding f — a spot-the-invariant puzzle, different each problem (that's why this feels hard: the search is trivial, the function is the insight); off-by-one in the derived formula (test f on a 4-element example by hand before trusting it).
9Partition Between Two Sorted Arrays
What it is: Two sorted arrays, and you need their combined median (or k-th element) without merging. Binary-search where to cut the first array; the size requirement forces the cut in the second, and comparing the four elements around the two cuts tells you whether to slide left or right.
A[i−1] ≤ B[j] and B[j−1] ≤ A[i]: 1 ≤ +∞ ✓ and 2 ≤ 3 ✓. Odd total → median = max(left) = 2. No merging — one well-placed cut.Intuition: the median = the partition line cutting both arrays so the left halves together are half the total and every left element ≤ every right element. Binary-search the cut in the smaller array — the other cut is forced by the size rule. The question "is my cut too far right?" (A[i-1] > B[j]) is monotonic. O(log min(m, n)).
double findMedianSortedArrays(vector<int>& A, vector<int>& B) {
if (A.size() > B.size()) swap(A, B); // always search the smaller
int m = A.size(), n = B.size(), half = (m + n + 1) / 2;
int lo = 0, hi = m;
while (lo <= hi) {
int i = lo + (hi - lo) / 2, j = half - i; // cut positions (0..m), not indices
int Al = i ? A[i-1] : INT_MIN, Ar = i < m ? A[i] : INT_MAX;
int Bl = j ? B[j-1] : INT_MIN, Br = j < n ? B[j] : INT_MAX;
if (Al <= Br && Bl <= Ar) {
if ((m + n) & 1) return max(Al, Bl);
return (max(Al, Bl) + min(Ar, Br)) / 2.0;
} else if (Al > Br) hi = i - 1;
else lo = i + 1;
}
return 0.0;
}
Challenges & pitfalls
The hardest mechanics in this file — INT_MIN/INT_MAX sentinels for empty sides; searching cut-positions (0..m inclusive), not element indices; always search the smaller array (or j goes negative); odd/even total cases. Nobody gets this cold — write it out twice before any interview where LC 4 could appear.
10Predecessor Search on Versions / Timestamps
What it is: Values written with timestamps or version numbers, and queries ask "what was the value at time t?" — where t may fall between writes. Each key's history is naturally sorted by time, so the query is a binary search for the rightmost entry with timestamp ≤ t.
bar
bar2
Intuition: systems-flavored — values stored with timestamps; queries ask "the value at time ≤ t" → rightmost element ≤ t = classic (upper_bound − 1) over that key's list. This is Pattern 2 in a system-design costume (it's literally how LSM SSTables and snapshots do reads — nice to mention). Append-only ⇒ the list is sorted for free.
C++ · Time Based Key-Value Store (LC 981)class TimeMap {
unordered_map<string, vector<pair<int,string>>> m; // key → (timestamp, value), increasing
public:
void set(string key, string value, int timestamp) {
m[key].push_back({timestamp, value}); // append-only ⇒ sorted for free
}
string get(string key, int timestamp) {
auto& v = m[key];
int lo = 0, hi = v.size();
while (lo < hi) { // first index with ts > timestamp
int mid = lo + (hi - lo) / 2;
if (v[mid].first <= timestamp) lo = mid + 1;
else hi = mid;
}
return lo == 0 ? "" : v[lo - 1].second; // step back to rightmost ts ≤ t
}
};
Challenges & pitfalls
Recognizing "history/snapshot/at time t" = predecessor search; the not-found edge (a query before the first write); in snapshot problems, binary-searching only the changed versions per cell (sparse storage) instead of materializing every snapshot.
11Binary Search as a Subroutine (inside greedy/DP)
What it is: Here binary search isn't the solution — it's the speed-up inside a bigger greedy or DP. You maintain a sorted helper array as you scan (the smallest tails of increasing subsequences, jobs by end time), and each element does one binary search into it, turning an O(n²) loop into O(n log n).
tails isn't the actual subsequence, but its length is the answer; each element did one O(log n) search.Intuition: binary search is the log-factor accelerator inside another algorithm. Patience sorting for LIS: keep tails[] (the smallest possible tail of an increasing subsequence of each length — provably sorted), each new element binary-searches where it belongs → O(n log n). Weighted job scheduling: a DP where each job binary-searches its latest non-overlapping predecessor.
int lengthOfLIS(vector<int>& nums) {
vector<int> tails; // tails[k] = smallest tail of an LIS of length k+1
for (int x : nums) {
auto it = lower_bound(tails.begin(), tails.end(), x);
if (it == tails.end()) tails.push_back(x); // x extends the longest run
else *it = x; // otherwise tighten a tail
}
return tails.size();
}
Challenges & pitfalls
Knowing tails[] stays sorted (exchange argument — rehearse the proof); lower_bound vs upper_bound decides strictly-vs-non-strictly increasing (LIS vs longest non-decreasing — a one-character bug); in 2-D versions (envelopes), the sort trick (width ascending, height descending) that reduces it to 1-D LIS.
▤Recognition Cheat Sheet
| # | Pattern | Trigger phrase | Search space | Question example |
|---|---|---|---|---|
| 1 | Classic | "sorted array, find target" | indices | arr[i] ≥ target |
| 2 | Boundary | "first/last occurrence", "count of" | indices | ≥ target vs > target |
| 3 | Rotated | "rotated sorted array" | indices | "which half is sorted?" |
| 4 | Peak | "peak", "mountain", "local max" | indices | arr[i] > arr[i+1] |
| 5 | BS on answer | "min X such that", "minimize the max" | answer values | canDo(x) greedy check |
| 6 | Value + count | "k-th smallest in ⟨structure⟩" | value range | count(≤ x) ≥ k |
| 7 | Real numbers | continuous answer, "within 10⁻⁶" | reals | monotonic check, eps/100-iter |
| 8 | Implicit array | "missing", "single one", "duplicate" | indices | a monotonic f(i) you derive |
| 9 | Two-array partition | "median/kth of two sorted arrays" | cut positions | A[i-1] ≤ B[j] |
| 10 | Predecessor/versions | "at timestamp t", "snapshot" | timestamps | rightmost ts ≤ t |
| 11 | Subroutine | inner loop asks a sorted question | maintained array | lower_bound into an invariant |