LC Patterns — Searching & Selection
The Master Insight (read before the patterns)
Sorting answers every question about order — but it costs O(n log n), and most questions don't need every element in order. Selection is the art of answering one order question ("what's the k-th? the median? the most common?") without paying for all of them.
The tool ladder, cheapest-adequate wins — memorize it as a ladder, because interviews walk you down it rung by rung ("can you do better?"):
| Rung | Tool | Cost | When it's the right stop |
|---|---|---|---|
| 1 | Full sort | O(n log n) | you need many order questions answered, or n is tiny — always name this baseline first |
| 2 | Heap of size k | O(n log k) | k ≪ n, or the data is a stream you can't hold (file 08, Pattern 1) |
| 3 | Quickselect | O(n) average | one k-th/median question, data in memory, offline |
| 4 | Counting / buckets | O(n) worst case | values (or counts) live in a small known range — pigeonhole does the work |
| 5 | Voting / arithmetic | O(n) time, O(1) space | the target is so special (majority, median-position) that a scan with 2 variables finds it |
The second theme of this file: searching means eliminating. Binary search eliminates half an array per comparison; quickselect eliminates one side of a partition per pass; staircase search eliminates a whole row or column per comparison. Every pattern here is "one cheap test → throw away a provable chunk." When you present any of these, lead with what one comparison eliminates and why — that's the proof, and it's always one sentence.
§0Mechanics & Universal Pitfalls
The partition — the engine under half this file
def partition(nums, lo, hi): # Lomuto: simple, interview-safe
p = random.randint(lo, hi) # random pivot — NOT optional (see pitfalls)
nums[p], nums[hi] = nums[hi], nums[p]
pivot, store = nums[hi], lo
for i in range(lo, hi):
if nums[i] < pivot:
nums[store], nums[i] = nums[i], nums[store]
store += 1
nums[store], nums[hi] = nums[hi], nums[store]
return store # pivot's final, correct positionAfter one partition: everything left of store is smaller than the pivot, everything right is ≥ — and store is exactly where the pivot would land in a full sort. That last fact is what quickselect exploits.
- Lomuto vs Hoare: Lomuto (above) is easier to get right live; Hoare does fewer swaps but its return value is not the pivot's final index — mixing up the two conventions is a classic silent bug. Pick Lomuto and say why.
- Know the index vocabulary: "k-th largest" = index
n − kin sorted (ascending) order. Convert once, at the top, and work with the sorted-position index from then on.
Universal pitfalls
- Fixed pivot = O(n²) on adversarial input. Sorted (or all-equal) arrays kill first/last-element pivots. Randomize the pivot and say "expected O(n)"; name median-of-medians as the guaranteed-O(n) variant (know it exists and its idea — groups of 5, recursive median — but never code it in an interview).
- Quickselect mutates the input. Say so; if the problem forbids it, that's a point for the heap instead.
- Duplicates stalling partitions — all-equal arrays make
< pivotput nothing on the left, shrinking by 1 per pass. The fix is 3-way partitioning (Pattern 2) — recognize when duplicates are heavy. - Off-by-one between "k-th" (1-based, human) and array indices (0-based). One conversion line up front; never juggle both mid-loop.
- Reaching for rung 3 when rung 4 is sitting there — frequencies are bounded by n, h-index is bounded by n, digits are bounded by 10: when the range is small, counting beats comparing, every time.
1Quickselect (partition until the k-th lands) ★
What it is: Find the k-th smallest/largest element in O(n) average time, in place, without sorting: run one partition — the pivot lands at its true sorted position — then recurse into only the side that contains index k, unlike quicksort which recurses into both.
Intuition: one partition tells you the pivot's exact rank. If that rank is k, done. If k is smaller, the answer lives entirely in the left chunk — the right chunk can be thrown away unread (and vice versa). Discarding one side per round gives the geometric series n + n/2 + n/4 + … = 2n = O(n) expected — that series is the proof, and it's the sentence to say when asked "why is this linear when quicksort isn't?" (quicksort keeps both sides: n log n; quickselect keeps one: n). Median (462) is selection at k = n//2: to make all elements equal with fewest ±1 moves, meet at the median (not the mean — the median minimizes the sum of absolute distances; moving away from it makes more elements farther than closer, a one-line exchange argument), so the whole problem is one quickselect plus one sum.
Approach: convert k-th largest → sorted index n − k; loop (iterative beats recursive here — no stack, no tail-call fiddling): partition, compare returned index with target, shrink lo/hi to the surviving side. K closest points (973): same code with squared distance as the comparison key — quickselect doesn't need the order, only the comparisons (no sqrt: monotone, so skip it and say why). Always offer the ladder first: "sort O(n log n), heap O(n log k) — cross-ref file 08 — or quickselect O(n) average; for k ≪ n the heap may actually win, and it doesn't mutate input."
Challenges & pitfalls
the random pivot (§0 pitfall 1 — the interviewer's sorted-input test is waiting for you); recursing/looping into both sides out of habit (that's quicksort — you only ever follow one); the k conversion off-by-one (test on a 3-element array: 2nd largest of [1,2,3] should be index 1); duplicates degrading performance (mention 3-way as the fix); articulating expected-vs-worst honestly ("expected O(n) with random pivots; worst O(n²) is possible but vanishingly unlikely; median-of-medians guarantees O(n) if required").
- Convert: 2nd largest = sorted index 6 − 2 = 4. From now on we hunt index 4.
- Partition (say the pivot randomly picked is 4): smaller things go left → [3, 2, 1, 4, 6, 5]; the pivot 4 lands at index 3.
- 3 < 4, so the answer is in the right chunk — indices 4..5. Throw away everything left of it, unread.
- Partition [6, 5] (pivot 5): [5, 6] — pivot lands at index 4. That's our target index. Answer: 5.
- Total work: 6 elements touched, then 2 — shrinking chunks, not repeated full passes. That shrinking is where O(n) comes from.
- Meet at the median. Quickselect for index n//2 = 2 → the sorted order would be [1, 2, 9, 10], median element 9 (or 2 — for even n, any point between the two middles gives the same total; say that).
- Cost = sum of distances to the median: |1−9| + |10−9| + |2−9| + |9−9| = 8 + 1 + 7 + 0 = 16.
- Why the median and not the mean? Stand at the median and step left or right: you move closer to at most half the elements and farther from at least half — the total can only go up.
The thing to say out loud: "One partition places the pivot at its true rank; I keep only the side containing k, so the work is n + n/2 + n/4 + … = O(n) expected."
2Partitioning as the Whole Answer (2-way and Dutch National Flag)
What it is: Sometimes you don't need any k-th element — the partitioned arrangement itself is the answer: evens before odds, colors in 0/1/2 order, small-median-large blocks. One pass, pointer regions, in place.
Intuition: maintain regions with meanings, and an invariant that every step preserves. Two-way (905, evens first): left = boundary of the settled-evens region; scan with i, swap evens back into the region. Three-way — the Dutch National Flag (75, sort 0s/1s/2s): three regions and a scanner — [0..lo) all 0s, [lo..mid) all 1s, [hi+1..end] all 2s, [mid..hi] unknown; look at nums[mid]: 0 → swap to lo (both advance), 1 → just advance mid, 2 → swap to hi (only hi shrinks — the swapped-in element is unexamined, so mid must stay: the classic bug of this algorithm, and the reason to state the invariant before coding). Wiggle Sort II (324) is the boss fight: quickselect the median (Pattern 1), then Dutch-flag around it — with a virtual index mapping that interleaves the halves; know the two-phase plan (select, then 3-way partition into interleaved slots) and be honest that the index mapping is fiddly.
Approach: write the region meanings as a comment first (# [0..lo) zeros | [lo..mid) ones | [mid..hi] unknown | (hi..] twos), then each branch of the loop is forced by "restore the invariant." Loop condition mid <= hi (the unknown region is inclusive — off-by-one #2). For 905-style 2-way, the same discipline with two regions. This "regions + invariant" discipline is exactly the binary-search file's advice (§0 there) transplanted to partitioning — one mental model, two files.
Challenges & pitfalls
advancing mid after swapping with hi (you just pulled in an unexamined element — inspect it before moving on); the mid <= hi boundary; explaining why the 0-swap can advance both pointers (the element coming from lo is always a 1 or the scanner itself — provable from the invariant, and worth proving aloud); 324's virtual indexing (map logical position i → physical (1 + 2i) % (n | 1) — if you use it, derive nothing, state it as a known mapping and verify on 6 elements); counting sort as the honest alternative for 75 (two passes, trivially correct — name it, then deliver the one-pass version as the point of the exercise).
- Regions: zeros settle at the front (boundary
lo), twos at the back (boundaryhi),midscans the unknown middle. Start: lo = 0, mid = 0, hi = 5. - nums[mid]=2 → swap with hi: [0, 0, 2, 1, 1, 2], hi=4. Don't move mid — the 0 that arrived is unexamined!
- nums[mid]=0 → swap with lo (self-swap here), lo=1, mid=1.
- nums[mid]=0 → swap with lo (self-swap again), lo=2, mid=2.
- nums[mid]=2 → swap with hi: [0, 0, 1, 1, 2, 2], hi=3.
- nums[mid]=1 → just mid=3. nums[mid]=1 → mid=4 > hi. Done: [0, 0, 1, 1, 2, 2].
- Every step restored the invariant "0s left of lo, 1s in [lo, mid), 2s right of hi" — with that sentence written down, each branch of the code writes itself.
The thing to say out loud: "Let me define the regions and their invariant first — then each case is just 'restore the invariant', including the subtle one: after swapping from the right, the new element is unexamined, so the scanner stays."
3Boyer–Moore Voting (majority by cancellation)
What it is: Find the element appearing more than n/2 times (169) — or the up-to-two elements appearing more than n/3 times (229) — in one pass and O(1) space, by letting different elements cancel each other out and keeping only a candidate and a counter.
Intuition: pair up every majority-element occurrence with one non-majority occurrence and destroy both — the majority appears more than everything else combined, so after all the canceling, it must be what's left standing. The scan implements this: keep candidate + count; same element → count++, different → count−− (a mutual kill), count hits 0 → adopt the current element as the new candidate. For n/3 (229): at most two elements can exceed n/3 (three would need > n total — say this pigeonhole first), so run the same game with two candidate/count pairs; a new element only decrements both when it matches neither. Crucial asterisk: the scan finds the only possible candidates — if the problem doesn't guarantee a majority exists (229 doesn't), you must verify with a second counting pass.
Approach: 169: one pass, two variables; if a majority is guaranteed, return the candidate. 229: two pairs with a strict branch order — match candidate1? match candidate2? adopt into an empty (count-0) slot? only then decrement both — then a verification pass keeping counts > n/3. The check-adoption-before-decrement order matters: get it wrong and a legitimate candidate gets evicted by its own supporters.
Challenges & pitfalls
believing it (trace a hostile example before trusting — the count going to 0 and re-adopting feels wrong until you watch it work); skipping the verification pass in 229 (the scan can end holding garbage candidates when no n/3-majority exists — the verify is part of the algorithm, not paranoia); the branch order in 229 (equality checks strictly before the count-0 adoption — [1, 1, 2]-style cases break otherwise); explaining the cancellation argument crisply ("every decrement kills one candidate vote and one opposing vote — the majority has votes to spare"); knowing the alternatives for 169 to ladder through (Counter O(n)/O(n) space, sort-and-take-middle O(n log n) — then voting as the O(1)-space finale).
- candidate = 2, count = 1. Next 2 → count 2. Then 1 → count 1 (a 2-vote and a 1-vote cancel). Then 1 → count 0.
- Count hit 0 → adopt next element: candidate = 1, count = 1. Then 2 → count 0. Adopt: candidate = 2, count = 1. Then 2 → count 2.
- Survivor: 2. ✓ (2 appears 4 times out of 7.)
- Why it must work: 2 has 4 votes, everything else combined has 3 — even if every non-2 vote cancels a 2-vote, one 2 survives.
- At most two winners can exist (three elements each > n/3 would sum past n) — so track two candidates.
- 3: adopt into slot 1 (c1=3, n1=1). 2: adopt into slot 2 (c2=2, n2=1). 3: matches c1 → n1=2.
- Scan ends with candidates {3, 2}. Now verify: count 3 → appears 2 > 1 ✓; count 2 → appears 1, not > 1 ✗.
- Answer [3]. The verification pass wasn't optional — the scan alone would have returned a wrong extra element.
4Counting & Bucket Selection (pigeonhole beats comparison)
What it is: When the values you're selecting over live in a small, known range — counts are between 0 and n, an h-index can't exceed n, gaps obey a pigeonhole bound — replace comparison-based work with addressing: drop items into indexed buckets, then read the buckets in order. O(n) worst case, no randomness, no log factors.
Intuition: h-index (274): h is capped at n, so make count buckets 0..n (citations above n clamp to n — they can't help beyond h = n); sweep h from n downward accumulating "papers with ≥ h citations", stop when the running total first reaches h. No sort — the answer space was small, so you counted it. Top-k frequent (347): frequencies are between 1 and n → bucket elements by frequency and read buckets from n down until k are collected — the O(n) answer to the heap's O(n log k) (file 08, Pattern 1's follow-up, closed here). Maximum gap (164) is pigeonhole at its prettiest: with n numbers spanning [min, max], the largest adjacent-in-sorted-order gap is at least (max − min)/(n − 1) — so make buckets slightly smaller than that bound, and no two numbers in the same bucket can form the max gap: only per-bucket min/max matter, and the answer is a gap between buckets. Sorting avoided by an argument, not a trick.
Approach: identify the bounded quantity (value? count? rank?), allocate range+1 buckets, one pass to fill, one ordered pass to read. 274: clamp-then-count, suffix-sum downward. 347: Counter, then buckets[freq].append(elem), read high→low. 164: bucket width max(1, (hi − lo) // (n − 1)), keep (min, max) per bucket, then one sweep comparing each nonempty bucket's min with the previous nonempty bucket's max. State the pigeonhole sentence before coding 164 — it's the entire justification for ignoring within-bucket pairs.
Challenges & pitfalls
spotting the bounded range at all (the ladder habit: before sorting, ask "is anything here bounded by n or by a constant?"); the clamp in 274 (citations of 1000 count toward h = n, no further); reading buckets in the right direction (top-k = high frequency first); 164's edge cases (n < 2 → 0; all elements equal → width-0 buckets, hence the max(1, …)); the within-bucket blindness worry in 164 (answer it with the pigeonhole bound: same-bucket gaps are < the guaranteed minimum max-gap, so they can never win); claiming O(n) honestly (bucket count is O(n) — space went up to buy the time down; that's the same trade as every hash map).
- h can't exceed n = 5, so buckets 0..5; anything above 5 clamps to 5.
- Counts: bucket 0: one paper, bucket 1: one, bucket 3: one, bucket 5: two (the 6 clamped, and the 5).
- Sweep h from 5 down, accumulating papers with ≥ h citations: h=5 → 2 papers (2 < 5, no). h=4 → 2. h=3 → 3 papers ≥ 3 → 3 ≥ 3 ✓. Answer: 3.
- No sort happened — the answer lived in 0..5, so we just counted into those six slots.
- min = 1, max = 9, n = 4 → some adjacent gap must be at least (9−1)/3 ≈ 2.67 (spread 8 across 3 gaps — pigeonhole).
- Make buckets of width 2 (smaller than 2.67): [1–2], [3–4], [5–6], [7–8], [9–10]. Numbers land: 1 | 3 | 6 | — | 9.
- Two numbers in the same bucket differ by < 2.67, so the max gap must straddle buckets → only compare each bucket's min against the previous nonempty bucket's max: 3−1 = 2, 6−3 = 3, 9−6 = 3.
- Answer: 3. We never sorted; the pigeonhole bound let us ignore everything inside a bucket.
5Staircase Search (sorted 2D: eliminate a row or column per step)
What it is: Search a matrix whose rows and columns are each sorted. Stand at the top-right corner (or bottom-left): one comparison either eliminates a whole column (too big → step left) or a whole row (too small → step down). O(m + n), no binary search needed.
Intuition: the top-right element is the largest in its row and the smallest in its column — so if it's bigger than the target, its entire column is bigger (gone); if smaller, its entire row is smaller (gone). Each comparison permanently discards a full line of the matrix, and you take at most m + n steps before running out. This is "one test eliminates a provable chunk" (the master insight) with the chunk being a row/column instead of a half. Distinguish the two classic matrices: 74 is fully sorted (each row continues the last) — that's a single sorted list wearing a matrix costume: one binary search on indices 0..m·n−1 with divmod decoding, O(log mn). 240 is only row-and-column sorted (rows don't continue) — binary search per row costs O(m log n); the staircase does O(m + n). Count negatives (1351, rows/cols sorted descending): same walk, but count what each step eliminates instead of hunting one target — the staircase as an accumulator.
Approach: 240: r, c = 0, n − 1; loop while in bounds: equal → found; matrix[r][c] > target → c -= 1; else r += 1. 74: compute mid = (lo+hi)//2, decode matrix[mid // n][mid % n], standard binary search (cross-ref file 05 Template A). 1351: walk from a corner keeping a running count of eliminated negatives per step. Choosing the corner: it must be a max-of-one-line and min-of-the-other — top-right or bottom-left work; top-left doesn't (it's the min of both — a comparison eliminates nothing; say this when explaining the choice).
Challenges & pitfalls
using the staircase on 74 (works, O(m+n) — but the fully-sorted structure supports O(log mn), and taking the weaker bound signals a missed observation; conversely binary-searching each row of 240 is the reverse miss — diagnose which matrix you have first, out loud); the corner-choice justification (rehearse the "largest in its row, smallest in its column" sentence); loop bounds (r < m and c >= 0 — walking off either edge means "not present"); descending-sorted variants flipping the step directions (1351 — re-derive from the elimination logic, don't pattern-match the moves); the divmod decode in 74 (row = mid // n with n = columns — swap them and everything breaks silently on non-square matrices; test decode on index n).
[1, 4, 7, 11]
[2, 5, 8, 12]
[3, 6, 9, 16]- Start top-right at 11. 11 > 5 → the whole last column is ≥ 11 → step left.
- At 7: 7 > 5 → that column is gone too → step left.
- At 4: 4 < 5 → everything left of 4 in this row is ≤ 4 → the row is gone → step down.
- At 5: found. Four comparisons, and each one wiped out a full row or column.
- Why top-right works and top-left wouldn't: top-right is the max of its row and min of its column, so either comparison outcome kills a full line. Top-left is the min of everything — "target is bigger" eliminates nothing.
The diagnosis to say out loud: "Do the rows continue each other (74 — it's one sorted array, binary search in O(log mn)) or are they only independently sorted (240 — staircase in O(m + n))? Let me check the first element of row 2 against the last of row 1."
6Choosing the Tool for "K-th in a Structured Space" (the synthesis)
What it is: "K-th smallest in a sorted matrix / among all pairs / in a multiplication table" — the element count is huge or implicit, and three different files of this series each offer a tool. This pattern is the decision procedure; the mechanics live in the cross-referenced files.
Intuition: three tools, three preconditions. (a) Heap frontier (file 08, Pattern 4): treat the space as k sorted sequences and pop k times — O(k log k)-ish; right when k is small and you can enumerate "successors" of an element. (b) Binary search on the value (file 05, Pattern 6): if you can count elements ≤ x quickly (sorted rows → staircase count — Pattern 5 of this file doing the counting!), binary-search the smallest x with count ≥ k — O(n log(range)); right when k is huge (popping k times is too slow) but counting is cheap. (c) Quickselect (Pattern 1): only when the candidates can be materialized in memory — then it's O(n) on the flattened data. The deciding questions, in order: Can I hold all elements? (yes → quickselect/sort). Is k small? (yes → heap). Can I count ≤ x cheaply? (yes → binary search the value). For 378 (n×n matrix) all three genuinely work — which is why interviewers love it: the conversation is the comparison. For 668 / 719, k and the space are enormous → only (b) survives.
Approach: say the three-question decision procedure out loud, pick, then execute from the owning file's template. For 378 specifically, know the staircase-count subroutine cold (start bottom-left; walk up/right accumulating "elements ≤ x per column" — O(n) per count) since it's the piece this file contributes to the file-05 template.
Challenges & pitfalls
defaulting to the heap because it's familiar (for 719, k can be ~10⁹ — popping k times is dead on arrival; run the decision questions before committing); the boundary-existence worry in tool (b) ("the value I converge on — does it exist in the data?" — yes: the count function only jumps at actual data values; file 05 rehearses the proof); mixing tools' complexities when comparing aloud (heap: O(k log n); value-search: O(n log(range)) — plug in the actual numbers, the winner is usually obvious); forgetting this decision itself is the deliverable — on 378, a candidate who compares the three before coding one beats a candidate who codes the best one silently.
- Q1: can I materialize? 90,000 elements — yes, actually: flatten + quickselect O(n²) works. Note it as the baseline.
- Q2: is k small? Could be 90,000 — heap costs O(k log n) ≈ 90,000 × 9 ≈ 810K ops. Fine here, but scales badly.
- Q3: can I count ≤ x cheaply? Rows and columns sorted → staircase count in O(n) = 300 ops; binary search over the value range adds a ~32× factor → ~10K ops. Winner for large inputs.
- The interview answer: name all three with their costs, then implement (b) with the staircase counter — and mention that for tiny k the heap would win.
- Materialize 5×10⁷ pairs? Too much. Heap-pop k ≈ 10⁷ times? Too slow. Count pairs with distance ≤ x? Sort once, then a two-pointer sweep counts in O(n) — yes.
- Only tool (b) survives: binary search the distance, count with two pointers. The decision took four sentences and eliminated two dead ends before writing any code.
▤Summary Table — Recognition Cheat Sheet
| # | Pattern | Trigger phrase in problem | One test eliminates… | Canonical trap |
|---|---|---|---|---|
| 1 | Quickselect | "k-th largest", "median", "O(n)?" | one whole side of a partition | fixed pivot; recursing both sides |
| 2 | Dutch flag / partition | "sort 0s 1s 2s", evens-first, wiggle | nothing — it builds regions | advancing mid after the hi-swap |
| 3 | Boyer–Moore voting | "more than n/2 (n/3) times", O(1) space | one vote of each side (cancellation) | skipping the verify pass (229) |
| 4 | Counting / buckets | h-index, top-k frequent, "max gap in O(n)" | comparisons entirely (addressing) | missing the bounded range; 164's width-0 buckets |
| 5 | Staircase search | matrix "sorted rows and columns" | a full row or column | wrong corner; 74-vs-240 diagnosis |
| 6 | K-th tool choice | k-th in matrix/pairs/table | two of the three tools (by the 3 questions) | defaulting to the familiar tool |