01 Β· DSA β file 08
Sorting & Searching
You'll rarely be asked to implement quicksort cold β but you will be asked "how does sorting work / which would you use / why is this stable," and binary search shows up constantly wearing disguises. This page gives you the three n log n sorts as ideas, the non-comparison escape hatch, Timsort (what Python actually runs), and a binary search template that doesn't off-by-one.
01Why n log n is the wall (and how to tunnel under it)
Comparison sorting is 20-questions with the data: each comparison yields one bit of information. n elements have n! possible orderings; to identify the true one you need at least logβ(n!) β n log n bits. No comparison sort can beat O(n log n) in the worst case β not cleverness, information theory.
The escape hatch: stop comparing. If you know things about the keys (small integer range, fixed digits), you can sort by arithmetic instead β that's counting/radix/bucket sort (Β§5), and it's how you answer the trick question "sort a million values in O(n)."
02Merge sort β trust, then merge
The idea: split in half, recursively sort each half (leap of faith β file 07), then merge two sorted lists β which is easy: repeatedly take the smaller head. Two pointers, one pass.
[38, 27, 43, 3]
/ \
[38, 27] [43, 3]
/ \ / \
[38] [27] [43] [3] β log n levels of splitting
\ / \ /
[27, 38] [3, 43] β merge back up: each level touches
\ / all n elements once
[3, 27, 38, 43] β n work Γ log n levels = O(n log n)
def merge_sort(a):
if len(a) <= 1:
return a
mid = len(a) // 2
left, right = merge_sort(a[:mid]), merge_sort(a[mid:])
out, i, j = [], 0, 0
while i < len(left) and j < len(right):
if left[i] <= right[j]: # <= keeps it STABLE (see Β§4)
out.append(left[i]); i += 1
else:
out.append(right[j]); j += 1
out += left[i:] + right[j:]
return out
Character sheet: worst case O(n log n) guaranteed β no bad inputs, ever. Stable. Costs O(n) extra memory. Predictability is its brand β and because it consumes data as sequential streams, it's the backbone of external sorting: sorting data too big for RAM by merging sorted runs from disk. This is what a database does for a huge ORDER BY that spills β Postgres literally reports "external merge" in EXPLAIN ANALYZE.
The merge step alone is a top-tier interview primitive: merge two sorted lists/arrays, k-way merge via heap (file 06).
03Quicksort β partition, then trust
Merge sort does its work after the recursive calls (merging); quicksort does its work before (partitioning). Pick a pivot, sweep once moving everything smaller to its left and everything bigger to its right β now the pivot is in its final resting place, and the two sides can be sorted independently. No merge needed.
[3, 8, 2, 5, 1, 4] pivot = 4
partition β [3, 2, 1] [4] [8, 5]
β²
4 is DONE β final position
recurse left recurse right
import random
def quicksort(a, lo=0, hi=None):
if hi is None:
hi = len(a) - 1
if lo >= hi:
return
p = partition(a, lo, hi)
quicksort(a, lo, p - 1)
quicksort(a, p + 1, hi)
def partition(a, lo, hi):
r = random.randint(lo, hi) # randomized pivot β the safety line
a[r], a[hi] = a[hi], a[r]
pivot, write = a[hi], lo
for i in range(lo, hi): # Lomuto: writer collects the "smalls"
if a[i] < pivot:
a[i], a[write] = a[write], a[i]
write += 1
a[write], a[hi] = a[hi], a[write] # drop pivot into its slot
return write
Character sheet: average O(n log n) with small constants, in-place (O(log n) stack only), not stable.
Worst case O(nΒ²) β interviewers will ask; have this ready. It happens when pivots are consistently terrible: sorted input + "always pick first element" gives one-sided splits of size nβ1, nβ2, ... The fixes: random pivot or median-of-three β then adversarial inputs can't reliably hurt you.
The spinoff that matters β Quickselect
To find the k-th smallest, partition and recurse into only the side containing k. Expected O(n) β the work shrinks geometrically: n + n/2 + n/4 + ... = 2n. This is the "Kth Largest Element without sorting" answer (alongside the size-k min-heap version, O(n log k) β file 06).
04Heapsort, stability, and the comparison-sort scoreboard
Heapsort in one breath: heapify the array in place (O(n), file 06), then extract-max n times, parking each max in the shrinking tail β O(n log n) worst case, O(1) extra space, not stable. It's the guaranteed-bound, no-extra-memory option with worse constants than quicksort (cache-hostile jumps). Its cameo in the real world: C++ introsort = quicksort that switches to heapsort if recursion gets suspiciously deep β best average case and guaranteed worst case.
Stability β the sleeper interview topic
A sort is stable if equal keys keep their original relative order. Why you care: multi-key sorting by successive passes. Sort employees by name, then stable-sort by department β departments grouped, names alphabetical within each. It's ORDER BY dept, name built from two passes β and it only works because the second sort didn't scramble the first. Python's sort is stable, and the docs bless exactly this idiom.
pass 1 β sort by name
(Asha, Infra)
(Bo, ML)
(Chen, Infra)
(Devi, ML) # names now alphabetical
pass 2 β STABLE sort by dept
(Asha, Infra) # Infra group: Asha before Chen β
(Chen, Infra) # pass 1's order SURVIVED the tie
(Bo, ML) # ML group: still alphabetical
(Devi, ML) # = ORDER BY dept, name
An unstable second pass could emit Chen before Asha inside Infra β the name ordering you paid for in pass 1 would be scrambled on equal keys.
| Merge | Quick | Heap | |
|---|---|---|---|
| Worst | O(n log n) | O(nΒ²) | O(n log n) |
| Average | O(n log n) | O(n log n), best constants | O(n log n) |
| Extra space | O(n) | O(log n) stack | O(1) |
| Stable | yes | no | no |
| Superpower | predictability, streams/external | in-place speed, quickselect | guaranteed + in-place |
05Non-comparison sorts: cheating with key knowledge
Counting sort β keys are integers in a small range [0, k): count occurrences, then replay the counts in order. O(n + k), stable (in the prefix-sum formulation).
def counting_sort(a, k): # values in 0..k-1
counts = [0] * k
for x in a:
counts[x] += 1
out = []
for v in range(k):
out.extend([v] * counts[v])
return out
a = [3, 1, 3, 0, 1] β counts [1, 2, 0, 2] β [0, 1, 1, 3, 3]
no comparisons happened β we used the values AS indexes
Sorting a million HTTP status codes? k β 500 β O(n). Sorting bytes, ages, grades, enum values β counting sort territory. (You've already used this idea: the 26-slot anagram counter in file 02.)
Radix sort β big integers / fixed-length strings: counting-sort by the least significant digit, then the next, ... Stability is what makes it correct: each pass preserves the order established by previous passes. O(d Β· (n + b)) for d digits in base b.
Bucket sort β uniformly distributed floats: scatter into n buckets, sort each tiny bucket, concatenate. Average O(n); degrades if the distribution clumps.
The catch (why these don't replace quicksort): they need structural knowledge of keys, k or d must be modest, and they generalize poorly to arbitrary comparator logic. Comparison sorts sort anything you can compare.
06Timsort β what sorted() actually does
Python's built-in sort (also Java's objects sort, Rust's stable-sort ancestry) is Timsort: merge sort engineered around one observation β real-world data is rarely random. Logs are nearly time-ordered; you're re-sorting a list that was sorted before the append.
- Scan for runs β stretches already ascending (or descending β reverse in place, O(n/2) swaps).
- Runs shorter than ~32β64 elements are extended via binary insertion sort (small-n constants beat asymptotics β file 01 Q7 made flesh).
- Merge runs with a stack of pending runs and invariants that keep merges balanced; galloping mode exploits one run dominating.
Net effect: worst case O(n log n), stable β but O(n) on already-sorted, reversed, or piecewise-sorted data.
The interview sentence: "Python uses Timsort β a stable, adaptive merge sort that finds existing runs, so nearly-sorted data sorts in near-linear time." That plus "it's why re-sorting mostly-sorted data is cheap" is full marks.
Practical corollary: never hand-roll a sort in an interview unless asked β call .sort() with a key function and spend your effort on the algorithm around it. Custom comparison logic goes in key= (file 09).
07Sort visualizer β see the comparisons happen
What the counter teaches: insertion sort on random data does ~nΒ²/4 comparisons (each element walks halfway back on average), but on nearly-sorted data almost every element is already in place β one comparison and done, ~O(n) total. Timsort's whole design is "find the parts where that's true (runs), insertion-sort the small gaps, merge the runs."
08Binary search β the idea, the template, the disguises
The idea, properly stated
Binary search does not require "a sorted array." It requires a monotonic predicate: a yes/no question over an ordered domain that flips once β N N N N Y Y Y β and never flips back. Sorted-array lookup is just the special case where the predicate is a[i] >= target. State it this way and every "disguise" becomes the same problem: find the first Y.
index: 0 1 2 3 4 5 6 7
predicate: N N N Y Y Y Y Y
β²
find this boundary in O(log n)
The bug-free template (memorize exactly one)
Off-by-one bugs come from mixing conventions. Fix one convention and reuse it forever:
def first_true(lo, hi, pred):
"""Smallest x in [lo, hi] with pred(x) True; hi+1 if none.
Invariant: answer is always inside [lo, hi]."""
while lo < hi: # strict < (no <=)
mid = (lo + hi) // 2 # mid rounds LOW β mid < hi always
if pred(mid):
hi = mid # mid could be the answer β keep it
else:
lo = mid + 1 # mid is a N β discard it
return lo # lo == hi == the boundary
Why it can't infinite-loop or skip the answer: mid is always < hi, so hi = mid strictly shrinks; lo = mid + 1 strictly shrinks; the answer is never discarded (we only drop confirmed Ns, and keep candidate Ys). Loop ends with lo == hi = first Y. Three decisions, made once, never re-derived under pressure: while lo < hi, hi = mid, lo = mid + 1.
Everything reduces to first_true:
# classic lookup: first index where a[i] >= target, then check equality
i = first_true(0, len(a), lambda m: a[m] >= target) # == bisect_left!
found = i < len(a) and a[i] == target
# last N variant ("last index where a[i] <= target") = first_true(...) - 1
And in real code, don't hand-roll at all: bisect.bisect_left(a, target) is this function (file 09).
The disguises (ranked by interview frequency)
Disguise 1 β First/last occurrence in a sorted array with duplicates
bisect_left for the first, bisect_right β 1 for the last. Together they give the count of occurrences: bisect_right(a, x) β bisect_left(a, x).
Disguise 2 β Binary search on the answer (Koko's bananas, ship capacity, split array)
The domain is candidate answers, the predicate is a feasibility check ("can we finish at speed s?"). Feasibility is monotone: if s works, s+1 works β that's the N N Y Y shape. Recognize the phrasing "minimize the max / maximize the min." Full treatment in file 06 Β§4.
Disguise 3 β Rotated sorted array (search / find minimum)
One half is always properly sorted; check which by comparing a[mid] to an endpoint, decide which half the target can be in, discard the other. Still "discard half by a provable rule."
Disguise 4 β Search a 2D sorted matrix
Treat it as one flat sorted array of rowsΒ·cols elements: a[mid] β matrix[mid // cols][mid % cols]. One ordinary binary search, O(log(rowsΒ·cols)).
Disguise 5 β Peak finding / local structure
Follow the uphill side of mid; a peak must exist there. The monotone signal is derived ("am I on an ascending slope?") rather than sitting in the array.
Disguise 6 β First bad version / git bisect
The predicate is literally an API call (isBadVersion(v)) β good good good bad bad = N N N Y Y. And git bisect on a regression is you running this algorithm at work β a one-liner worth saying in the interview.
The recognition rule: answer space is ordered + you can test a candidate in reasonable time + testing is monotone β binary search, even with no array anywhere in sight.
09Common interview questions
Q1. Why can't comparison sorts beat n log n?
n! orderings; each comparison gives β€ 1 bit; you need β₯ logβ(n!) β n log n comparisons to distinguish them.
Q2. Quicksort worst case β when, and what's the fix?
Consistently unbalanced pivots (e.g., sorted input + first-element pivot) β O(nΒ²). Fix: randomized pivot / median-of-three; introsort falls back to heapsort when recursion gets too deep.
Q3. Merge vs quick β when prefer merge?
Need stability, guaranteed worst case, linked lists, or external/streamed data. Quick when in-place speed on in-memory arrays matters.
Q4. What is stability and why care?
Equal keys keep input order; it enables multi-key sorting via successive stable passes (sort by name, then by dept = ORDER BY dept, name).
Q5. Sort 10 million values in O(n) β possible?
If keys are small-range integers: counting sort O(n + k). Fixed-digit keys: radix. Arbitrary comparables: no β the information-theoretic wall stands.
Q6. What sort does Python use and why is it fast on real data?
Timsort: adaptive stable merge sort; detects pre-sorted runs, so nearly-sorted input is ~O(n).
Q7. Kth largest without a full sort?
Min-heap of size k, O(n log k); or quickselect, expected O(n).
Q8. bisect_left vs bisect_right?
Leftmost/rightmost insertion point keeping order: left = first index with a[i] β₯ x; right = first index with a[i] > x. Their difference = count of x.
Q9. Search in rotated sorted array β the invariant?
At every mid, at least one half is sorted; test whether the target lies within that sorted half's range; recurse into the correct half. O(log n).
Q10. Why does binary search need the predicate monotone?
Discarding half is only sound if the answer provably isn't there; a predicate that flips multiple times gives no such proof β that's when you need a linear scan or other structure.
10Self-test
- Reproduce the comparison-sort lower bound argument in two sentences.
- Merge sort: where does the log come from, where does the n come from, and which single character in the merge makes it stable?
- Walk Lomuto partition on [3, 8, 2, 5, 1, 4] with pivot 4 β show the array after each swap.
- Quickselect: why is it expected O(n) and not O(n log n)? (What does it not do that quicksort does?)
- Fill in the scoreboard from memory: worst case / space / stability for merge, quick, heap.
- When is counting sort applicable, what's its complexity, and why does radix sort require a stable inner sort?
- Describe Timsort in β€ 3 sentences, including the word "runs" and the small-n trick.
- Write
first_truefrom memory. Justify each of the three decisions (loop condition, hi branch, lo branch) against infinite loops and lost answers. - Express "count occurrences of x in a sorted array" using two bisects.
- For each, name the binary-search disguise: (a) first failing build in CI history, (b) min capacity to ship packages in D days, (c) find minimum in rotated array, (d) find x in a sorted matrix.
Spot-checks
- n! candidate orders, 1 bit per comparison, logβ(n!) β n log n comparisons needed.
- Halving depth = log n levels; each level merges all n; the
<=(take left on ties). - [3,8,2,5,1,4] β writer collects 3, then 2 (swap with 8), then 1 (swap with 8)... ending near [3,2,1,4,8,5] with the pivot at index 3 (exact intermediate states depend on swaps β trace them).
- It recurses into ONE side only: n + n/2 + n/4 β 2n.
- See the scoreboard in Β§4.
- Small integer key range; O(n+k); later radix passes must preserve earlier passes' order or the digits scramble.
- Adaptive merge sort: finds ascending/descending runs, extends short ones with binary insertion sort, merges with balance invariants; nearly-sorted β near O(n); stable.
lo<hi+ mid-rounds-low β both branches strictly shrink;hi=midkeeps candidate Ys,lo=mid+1drops only proven Ns.bisect_right(a,x) β bisect_left(a,x).- (a) first bad version; (b) binary search on the answer with a greedy feasibility check; (c) rotated-min via comparing mid to the right end; (d) flat-index mapping
mid//cols, mid%cols.