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.

MergeQuickHeap
WorstO(n log n)O(nΒ²)O(n log n)
AverageO(n log n)O(n log n), best constantsO(n log n)
Extra spaceO(n)O(log n) stackO(1)
Stableyesnono
Superpowerpredictability, streams/externalin-place speed, quickselectguaranteed + 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

Insertion sort & Timsort's insight

16 bars. Shuffle then Insertion sort: the highlighted element walks left to its slot β€” watch the comparison counter on random vs nearly-sorted input. Nearly-sorted demo loads sorted-ish data and marks the ascending runs Timsort would detect.

comparisons: 0

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

  1. Reproduce the comparison-sort lower bound argument in two sentences.
  2. Merge sort: where does the log come from, where does the n come from, and which single character in the merge makes it stable?
  3. Walk Lomuto partition on [3, 8, 2, 5, 1, 4] with pivot 4 β€” show the array after each swap.
  4. Quickselect: why is it expected O(n) and not O(n log n)? (What does it not do that quicksort does?)
  5. Fill in the scoreboard from memory: worst case / space / stability for merge, quick, heap.
  6. When is counting sort applicable, what's its complexity, and why does radix sort require a stable inner sort?
  7. Describe Timsort in ≀ 3 sentences, including the word "runs" and the small-n trick.
  8. Write first_true from memory. Justify each of the three decisions (loop condition, hi branch, lo branch) against infinite loops and lost answers.
  9. Express "count occurrences of x in a sorted array" using two bisects.
  10. 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
  1. n! candidate orders, 1 bit per comparison, logβ‚‚(n!) β‰ˆ n log n comparisons needed.
  2. Halving depth = log n levels; each level merges all n; the <= (take left on ties).
  3. [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).
  4. It recurses into ONE side only: n + n/2 + n/4 β‰ˆ 2n.
  5. See the scoreboard in Β§4.
  6. Small integer key range; O(n+k); later radix passes must preserve earlier passes' order or the digits scramble.
  7. Adaptive merge sort: finds ascending/descending runs, extends short ones with binary insertion sort, merges with balance invariants; nearly-sorted β‡’ near O(n); stable.
  8. lo<hi + mid-rounds-low β‡’ both branches strictly shrink; hi=mid keeps candidate Ys, lo=mid+1 drops only proven Ns.
  9. bisect_right(a,x) βˆ’ bisect_left(a,x).
  10. (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.