AI EngineeringZero to ProductionHome·About·Contact
Appendix · Data Structures & Algorithms · Part 6

Sorting & Searching

The capstone of the track. Sorting is the most-studied problem in computer science — and the ideas behind it (divide-and-conquer, partitioning, heaps) power far more than ordering a list. We build every classic sort, prove why O(n log n) is the comparison-sort limit, meet the linear-time exceptions, then use sorted data for the fastest search there is: binary search and its cousins.

⏱️ ~2.5 hours🎯 Basic → Expert🔀 divide & conquerrunnable

Learning objectives

  • Implement bubble/selection/insertion sort and know their (bad) complexity.
  • Build merge sort and quicksort and explain divide-and-conquer.
  • Understand heapsort, and the linear-time counting/radix sorts and their preconditions.
  • Define stability and know what Python's Timsort guarantees.
  • Write binary search and its variants without off-by-one bugs, and use bisect.
  • Find the k-th element in O(n) average with quickselect.

1 · Why sorting matters (and the O(n log n) wall) basic

Sorting unlocks other algorithms: binary search needs sorted data, deduplication and median-finding get easy, and many two-pointer tricks (D1) require order. Comparison sorts — those that only compare pairs of elements — cannot beat O(n log n) in the worst case. That's a proven lower bound: there are n! possible orderings and each comparison gives one bit, so you need at least log₂(n!) ≈ n log n comparisons. Only sorts that don't compare (counting/radix) escape it, under special conditions.

2 · The O(n²) sorts — learn them, then never use them basic

bubble swap adjacent 5 2 biggest "bubbles" right selection pick min, place 1 smallest → front, repeat insertion grow sorted prefix x insert x into its place all O(n²): work grows with every pair of elements The three quadratic sorts, at a glance. Bubble swaps neighbours, selection repeatedly extracts the min, insertion grows a sorted prefix. All are O(n²) — but insertion is O(n) on nearly-sorted data, which is why real sorts use it for small chunks.
🗺️ How to read this diagram

This one picture puts the three simplest (and slowest) sorts side by side. Each panel shows a different strategy for getting a list into order, but all three end up doing work proportional to every pair of elements — that's the red O(n²) warning at the bottom.

  • Left — bubble: the two little boxes (5 and 2) with the curved arrow under them mean "compare neighbours and swap if they're out of order". Do this over and over and the biggest value keeps bubbling to the right end, one pass at a time.
  • Middle — selection: the green box (1) is the smallest value found in the unsorted part. Selection scans the whole rest, picks that minimum, and drops it at the front. Then it repeats on what's left.
  • Right — insertion: the two green boxes are an already-sorted prefix; the plain box x is the next item. The back-pointing arrow means "slide x left until it sits in the right spot", growing the sorted part by one.
  • The vertical dashed lines just separate the three independent panels — they are not arrows or data.

In short: Three ways to reach the same sorted list. They differ in how they move items, but they all re-scan the data again and again, which is why none scale — O(n²) means 10× more data is ~100× more work.

Example code for learning — review, test, and adapt it before running against real or production systems. Commands can create, change, or delete resources. See the Terms & Disclaimer.
Try it — the three classics
pythondef bubble_sort(a):            # repeatedly swap adjacent out-of-order pairs
    a = a[:]                        # copy — don't mutate caller's list
    for i in range(len(a)):
        swapped = False
        for j in range(len(a) - 1 - i):   # biggest "bubbles" to the end
            if a[j] > a[j+1]:
                a[j], a[j+1] = a[j+1], a[j]; swapped = True
        if not swapped: break       # already sorted -> O(n) best case
    return a

def selection_sort(a):         # pick the smallest, put it first, repeat
    a = a[:]
    for i in range(len(a)):
        m = min(range(i, len(a)), key=lambda k: a[k])
        a[i], a[m] = a[m], a[i]
    return a

def insertion_sort(a):         # grow a sorted prefix, inserting each item
    a = a[:]
    for i in range(1, len(a)):
        key, j = a[i], i - 1
        while j >= 0 and a[j] > key:
            a[j+1] = a[j]; j -= 1
        a[j+1] = key
    return a
▶ How this works

Here are the three quadratic sorts in real code. Each takes a list a, makes its own copy with a = a[:] so the caller's list is never changed, and returns a new sorted list. Watch the indentation: the lines indented under a for/while run once per pass of that loop.

  1. bubble_sort: the outer for i is one full pass; the inner for j compares each neighbour pair a[j] and a[j+1] and swaps them if the left is bigger. The swapped flag is an optimisation: if a whole pass made no swaps the list is already sorted, so break exits early — that's the O(n) best case.
  2. selection_sort: m = min(range(i, len(a)), key=lambda k: a[k]) finds the index of the smallest value from position i onward, then the swap a[i], a[m] = a[m], a[i] moves that minimum to the front of the unsorted part.
  3. insertion_sort: key is the item we're placing. The while loop walks left, shifting bigger items one slot right (a[j+1] = a[j]), until it finds the gap where key belongs, then drops it in with a[j+1] = key.
  4. The pattern a[j], a[j+1] = a[j+1], a[j] is Python's one-line swap — the two values trade places without a temporary variable.

What the output means: All three return the same sorted list, e.g. [5,2,8,1] → [1,2,5,8]. Bubble is the only one that can finish early (on already-sorted input); the other two always do the full O(n²) work.

Try this: Add a print(a) inside bubble_sort's outer loop and run it on [5,2,8,1] — you'll watch the largest value march to the right end one pass at a time.

Insertion sort isn't uselessIt's O(n²) in general but O(n) on nearly-sorted data and very fast for tiny arrays — which is why real sorts (including Python's Timsort) switch to insertion sort for small chunks. Bubble and selection sort, by contrast, are purely educational.

3 · Merge sort — divide & conquer advanced

Merge sort splits the array in half, sorts each half recursively, then merges the two sorted halves in linear time. Guaranteed O(n log n) in all cases and stable — but needs O(n) extra space for the merge.

divide to single elements, then merge back sorted [5, 2, 8, 1] [5, 2][8, 1] [5][2] [8][1] [2, 5][1, 8] [1, 2, 5, 8] Divide & conquer. Halving gives log n levels; merging each level touches all n elements → O(n log n) guaranteed. Using in the merge keeps equal items in order → stable.
🗺️ How to read this diagram

This is a tree read top-to-bottom, then bottom-to-top. The top half of the picture splits the list; the bottom half merges it back together in sorted order. Grey arrows go down (dividing), blue arrows go up (merging).

  • Going down (grey arrows): [5,2,8,1] splits into [5,2] and [8,1], and those split again until every piece is a single element. A one-element list is trivially already sorted — that's where the splitting stops.
  • Going up (blue arrows): pairs are merged back. [5] and [2] combine into the green [2,5]; [8] and [1] into [1,8]. Green means "this piece is now sorted".
  • The final merge zips the two sorted halves [2,5] and [1,8] into [1,2,5,8] at the bottom.
  • Why O(n log n): the number of levels is how many times you can halve n — that's log n. Merging one whole level touches all n items. Levels × per-level work = n × log n.

In short: Divide until pieces are trivial, then merge sorted pieces upward. The merge step is the clever part; the splitting is just bookkeeping.

Try it
pythondef merge_sort(a):
    if len(a) <= 1:
        return a                    # base case
    mid = len(a) // 2
    left = merge_sort(a[:mid])      # divide + conquer each half
    right = merge_sort(a[mid:])
    return _merge(left, right)      # combine

def _merge(left, right):           # merge two sorted lists — O(n)
    out, i, j = [], 0, 0
    while i < len(left) and j < len(right):
        if left[i] <= right[j]:       # <= keeps it STABLE (ties keep order)
            out.append(left[i]); i += 1
        else:
            out.append(right[j]); j += 1
    out.extend(left[i:]); out.extend(right[j:])   # leftovers
    return out

print(merge_sort([5, 2, 8, 1, 9, 3]))    # [1,2,3,5,8,9]
▶ How this works

Merge sort in code is two functions. merge_sort does the divide (split and recurse); the helper _merge does the combine (zip two already-sorted lists into one). Together they guarantee O(n log n) in every case.

  1. Base case first: if len(a) <= 1: return a. A list of 0 or 1 items is already sorted, so recursion stops here — without a base case the function would call itself forever.
  2. Divide: mid = len(a) // 2 splits in half. merge_sort(a[:mid]) sorts the left half and merge_sort(a[mid:]) the right — each call keeps halving until it hits the base case.
  3. Combine in _merge: two pointers i and j walk the two sorted inputs. Each step appends the smaller front item and advances that pointer, so out comes out sorted in one linear pass.
  4. The stability trick: using <= in if left[i] <= right[j] means when two items are equal, the one from the left half is taken first — so equal items keep their original order. That's what makes merge sort stable.
  5. After one side empties, out.extend(left[i:]) / out.extend(right[j:]) tacks on whatever's left over (already sorted).

What the output means: merge_sort([5,2,8,1,9,3]) prints [1, 2, 3, 5, 8, 9].

Try this: Change <= to < in _merge. Sorting numbers you won't see a difference, but for equal keys the order flips — that single character is the whole difference between a stable and unstable sort.

The merge step is the reusable ideaMerging k sorted streams underlies external sorting (data bigger than RAM), merging sorted retrieval result-lists, and the "merge" in map-reduce. The heapq.merge() function does exactly this lazily over any number of sorted iterables.

4 · Quicksort — partition in place advanced

Quicksort picks a pivot, partitions the array into "less than pivot" and "greater than pivot," and recurses on each side. Average O(n log n), in-place (O(log n) stack), and usually the fastest in practice — but a bad pivot on already-sorted data gives O(n²) worst case (fixed by randomizing the pivot).

partition around pivot = 5, then recurse on each side [7, 2, 9, 1, 5, 3] pivot = 5 2 1 3 5 7 9 < pivot pivot (final spot) > pivot quicksort(left) quicksort(right) Partition, then recurse. After one pass the pivot sits in its final position, everything smaller to its left, larger to its right. Recursing on the two sides sorts the whole array — O(n log n) average, in place.
🗺️ How to read this diagram

This shows a single partition step, the heart of quicksort. You pick one value as the pivot (here 5) and shuffle the list so everything smaller ends up left of it and everything bigger ends up right of it.

  • The row of boxes is the list after partitioning: green boxes (2, 1, 3) are the "< pivot" group, the blue box (5) is the pivot, and the amber boxes (7, 9) are the "> pivot" group.
  • The key insight in the blue label — "pivot (final spot)" — the pivot is now in the exact position it will occupy in the fully sorted array. It never needs to move again.
  • The two curved arrows point to quicksort(left) and quicksort(right): quicksort now calls itself on each side independently. Neither side needs to look at the other.
  • Why average O(n log n): if the pivot roughly halves the list each time, you get about log n levels of recursion, and one partition pass over a level costs n — same math as merge sort. A bad pivot (always the smallest) gives one-sided splits and the O(n²) worst case.

In short: Partition puts the pivot in its final home and separates smaller from larger; then you solve the two smaller sides the same way. No merge step is needed — the ordering is already right.

Try it — readable version, then the in-place partition
python# Clear (not in-place) — great for understanding
def quicksort(a):
    if len(a) <= 1:
        return a
    pivot = a[len(a) // 2]
    less = [x for x in a if x < pivot]
    equal = [x for x in a if x == pivot]
    greater = [x for x in a if x > pivot]
    return quicksort(less) + equal + quicksort(greater)

# In-place Lomuto partition — how it's really done (O(1) extra space)
def quicksort_inplace(a, lo=0, hi=None):
    if hi is None: hi = len(a) - 1
    if lo >= hi: return
    pivot = a[hi]; i = lo
    for j in range(lo, hi):
        if a[j] < pivot:
            a[i], a[j] = a[j], a[i]; i += 1
    a[i], a[hi] = a[hi], a[i]           # pivot to its final place
    quicksort_inplace(a, lo, i - 1)
    quicksort_inplace(a, i + 1, hi)
▶ How this works

Two versions of quicksort. The first is the readable one — it literally builds three lists — so you can see the idea. The second is the in-place version that real code uses: it rearranges the array using only swaps, so it needs no extra lists (O(1) extra space).

  1. Readable version: pick pivot = a[len(a) // 2] (the middle element), then use three list comprehensions to build less, equal, and greater. The answer is quicksort(less) + equal + quicksort(greater) — sort the two outer groups and glue everything together with the equal block in the middle.
  2. In-place version, the setup: lo and hi mark the slice being sorted. if hi is None: hi = len(a) - 1 fills in the end on the first call, and if lo >= hi: return is the base case (0 or 1 elements).
  3. The Lomuto partition loop: the pivot is the last element a[hi]. A pointer i marks the boundary of the "smaller-than-pivot" zone. For each j, if a[j] < pivot we swap it into that zone and grow i. So all small values pile up on the left.
  4. Placing the pivot: after the loop, one final swap a[i], a[hi] = a[hi], a[i] drops the pivot at index i — its final sorted position. Then we recurse on [lo, i-1] and [i+1, hi], the two sides around it.

What the output means: Both sort the same. The readable one returns a new list; the in-place one mutates a directly and returns nothing — you'd read the sorted result from a afterward.

Try this: Run the readable version on [3,3,3]: less and greater are empty, equal is all three, so it returns instantly. Handling duplicates cleanly is why the three-way (less/equal/greater) split is nice.

SortBestAverageWorstSpaceStable?
bubble/insertionO(n)O(n²)O(n²)O(1)yes
selectionO(n²)O(n²)O(n²)O(1)no
mergeO(n log n)O(n log n)O(n log n)O(n)yes
quickO(n log n)O(n log n)O(n²)O(log n)no
heapO(n log n)O(n log n)O(n log n)O(1)no
Timsort (Python)O(n)O(n log n)O(n log n)O(n)yes

5 · Heapsort advanced

Heapsort uses the heap from D4: build a heap (O(n)), then repeatedly pop the min/max (O(log n) each) into the output. Guaranteed O(n log n) and O(1) extra space — but not stable and typically slower than quicksort due to poor cache behaviour.

Try it — heapsort in three lines with heapq
pythonimport heapq

def heapsort(a):
    h = a[:]
    heapq.heapify(h)                    # O(n) — turn list into a min-heap in place
    return [heapq.heappop(h) for _ in range(len(h))]   # pop in sorted order

print(heapsort([5, 2, 8, 1]))     # [1, 2, 5, 8]
▶ How this works

Heapsort in three lines by reusing Python's heapq. A heap is a structure that always lets you pull out the smallest item cheaply, so sorting becomes "pull the smallest, then the next smallest, …" until empty.

  1. h = a[:] copies the list first so the original isn't disturbed.
  2. heapq.heapify(h) rearranges h into a min-heap in O(n) — after this, h[0] is always the smallest element.
  3. The list comprehension [heapq.heappop(h) for _ in range(len(h))] pops the smallest item repeatedly. Each pop is O(log n) and hands them back in increasing order — that's the sorted list.
  4. The _ loop variable is a convention meaning "I don't care about this value, I just want to repeat len(h) times".

What the output means: heapsort([5,2,8,1]) prints [1, 2, 5, 8]. Total cost: O(n) to build plus n pops of O(log n) each = O(n log n).

Try this: This is the same tool RAG uses for top-k: a heap is the go-to when you want the smallest/largest items pulled off cheaply, one at a time.

6 · Beating the wall — counting & radix sort expert expert

These don't compare elements, so they sidestep the O(n log n) bound — at the cost of only working on specific data. Counting sort sorts integers in a known small range in O(n + k) by tallying counts. Radix sort applies counting sort digit-by-digit for larger integers/strings, O(d·(n+k)).

counting sort of [3, 1, 3, 0, 2] · values in range 0..3 input 3 1 3 0 2 counts 1 1 1 2 v=0123 ← tally how many of each value output 0 1 2 3 3 (emit v, counts[v] times) Counting sort doesn't compare — it tallies. One pass counts each value, a second emits them in order → O(n + k), beating the O(n log n) comparison bound. The catch: it needs a small value range k (radix sort extends it digit-by-digit).
🗺️ How to read this diagram

This diagram shows a sort that doesn't compare any two elements — it just counts. That's how it beats the O(n log n) comparison limit. It works only when values are integers in a small known range (here 0..3).

  • Top row (input): the raw list 3 1 3 0 2 we want to sort.
  • Middle row (counts): four boxes, one per possible value 0, 1, 2, 3 (the small labels underneath). Each box holds how many times that value appeared: value 3 shows 2 because there are two 3s; the others show 1.
  • Bottom row (output): walk the count boxes left to right and emit each value as many times as its count — giving the sorted 0 1 2 3 3. No comparisons happened at all.
  • Why O(n + k): one pass over the n inputs to tally, one pass over the k possible values to emit. If k (the value range) is small, that's basically linear.

In short: Counting sort trades comparisons for memory: it needs one slot per possible value. Fast when the range k is small (ages, test scores, bytes); wasteful when values can be huge.

Try it — counting sort
pythondef counting_sort(a, max_val):        # a: non-negative ints in [0, max_val]
    counts = [0] * (max_val + 1)
    for x in a:
        counts[x] += 1                   # tally each value — O(n)
    out = []
    for value, c in enumerate(counts):
        out.extend([value] * c)          # emit in order — O(n + k)
    return out

print(counting_sort([3, 1, 3, 0, 2], max_val=3))   # [0,1,2,3,3]
▶ How this works

Counting sort in code. Instead of comparing items, it builds a counts array where counts[v] tells you how many times value v appears, then rebuilds the sorted list from those tallies.

  1. counts = [0] * (max_val + 1) makes one counter slot for every possible value from 0 up to max_val, all starting at zero.
  2. First loop (count): for each x in the input, counts[x] += 1. One pass over the data — O(n).
  3. Second loop (emit): enumerate(counts) gives each value and its count c. out.extend([value] * c) appends that value c times. Because we walk values 0, 1, 2, … in order, the output comes out sorted.
  4. There is no if a < b anywhere — that's the whole point. Not comparing is how it sidesteps the O(n log n) wall.

What the output means: counting_sort([3,1,3,0,2], max_val=3) prints [0, 1, 2, 3, 3].

Try this: Try max_val=100 on the same tiny list. It still works but now allocates 101 slots for 5 numbers — a vivid demo of why counting sort only pays off when the value range is small.

The catchCounting sort's k is the value range — sorting five numbers up to a billion would allocate a billion-slot array. These sorts win only when the range is small relative to n (ages, scores, bytes, fixed-length IDs). Otherwise, comparison sorts remain king.

7 · Stability & Python's Timsort intermediate

A sort is stable if equal elements keep their original relative order. This matters when you sort by multiple keys in stages. Python's sorted() / list.sort() use Timsort — a hybrid of merge sort and insertion sort that's stable, adaptive (O(n) on nearly-sorted data by exploiting existing "runs"), and worst-case O(n log n).

Try it — stable multi-key sorting
pythonresults = [
    {"doc": "A", "score": 0.9, "source": "kb"},
    {"doc": "B", "score": 0.9, "source": "web"},
    {"doc": "C", "score": 0.7, "source": "kb"},
]
# Because Timsort is STABLE, sort by the secondary key first, then primary:
results.sort(key=lambda r: r["source"])          # secondary
results.sort(key=lambda r: r["score"], reverse=True)  # primary — ties keep source order

# key= computes a sort key once per item (the "decorate-sort-undecorate" idiom)
top = sorted(results, key=lambda r: (-r["score"], r["doc"]))   # tuple = multi-key
▶ How this works

This lab shows the everyday payoff of a stable sort: sorting by more than one key. Python's built-in sort (Timsort) is stable, meaning items with equal keys keep the order they were already in — and that lets you layer sorts.

  1. results is a list of dictionaries, each with a doc, a score, and a source. We want them ordered by score (high first), and for equal scores, by source.
  2. The layered trick: sort by the secondary key first (key=lambda r: r["source"]), then by the primary key (key=lambda r: r["score"], reverse=True). Because the sort is stable, the second sort preserves the source order among items with equal scores.
  3. key=lambda r: ... tells sort what to compare for each item — here, reach into the dictionary and pull out a field. reverse=True flips to descending.
  4. The one-call alternative: sorted(results, key=lambda r: (-r["score"], r["doc"])) uses a tuple as the key. Python compares tuples left-to-right, so this sorts by -score (negative = descending) and breaks ties by doc — both keys in a single sort.

What the output means: Both approaches order A and B (both score 0.9) ahead of C (0.7); the tuple key additionally guarantees A-before-B by document name.

Try this: Negating a number (-r["score"]) is the standard way to sort one field descending while others stay ascending inside a single tuple key — a trick worth memorising for interviews.

🔗 Used in the courseRAG re-ranking sorts candidate chunks by score, then by recency/source as tiebreakers — relying on stable sort (Ch 3). Sorting eval results by pass/fail then latency (Ch 5) uses the same tuple-key trick.

8 · Binary search — the payoff of sorted data advanced

On a sorted array, binary search finds an element in O(log n) by repeatedly halving the search range. Getting the boundaries right is famously bug-prone; here's the canonical correct form, plus Python's batteries-included bisect.

sorted array · find target = 11 · discard half each step 135 791113 mid=7 < 11 → go right 91113 mid=11 = 11 ✓ found 7 items → found in 2 comparisons each step discards half → O(log n) (1M items ≈ 20 steps) Binary search halves the problem every step. Compare the middle; if the target is larger, discard the left half, else the right. 1000 items take ~10 comparisons, a million ~20 — the power of O(log n).
🗺️ How to read this diagram

This traces a binary search on a sorted array looking for the value 11. The big idea: check the middle, and each check lets you throw away half of what's left.

  • Step 1 (top row): the full array 1 3 5 7 9 11 13. The amber box marks the middle element, 7. Since 7 < 11, the target must be to the right, so the entire left half (1, 3, 5, and 7 itself) is discarded — see the note "mid=7 < 11 → go right".
  • Step 2 (second row): only 9 11 13 remain. The new middle is 11 (green box), which equals the target — found in just 2 comparisons.
  • The bottom lines state the payoff: 7 items took 2 checks; each step halves the range, which is the definition of O(log n). A million items need only about 20 comparisons.
  • Why sorted matters: halving only works because order tells you which side the target must be on. On an unsorted list you'd have no such clue and would be stuck at O(n).

In short: Compare the middle to the target; keep the half that could contain it; repeat. Doubling the data adds just one extra step — that's the magic of logarithmic time.

Try it — correct binary search + bisect
pythondef binary_search(a, target):        # a must be sorted ascending
    lo, hi = 0, len(a) - 1
    while lo <= hi:                  # <= : the range is inclusive [lo, hi]
        mid = (lo + hi) // 2         # no overflow in Python
        if a[mid] == target:
            return mid
        elif a[mid] < target:
            lo = mid + 1            # search the right half
        else:
            hi = mid - 1            # search the left half
    return -1                       # not found

# The standard library — O(log n) search & sorted insertion point
import bisect
a = [1, 3, 5, 7, 9]
bisect.bisect_left(a, 5)        # 2 — index of 5 (or where it'd go)
bisect.insort(a, 6)             # keeps a sorted: [1,3,5,6,7,9]

# Variant: leftmost index whose value >= target (a "lower bound")
def lower_bound(a, target):
    lo, hi = 0, len(a)          # half-open [lo, hi)
    while lo < hi:
        mid = (lo + hi) // 2
        if a[mid] < target: lo = mid + 1
        else: hi = mid
    return lo
▶ How this works

Three related tools for searching sorted data: a hand-written binary_search, the standard library's bisect, and a lower_bound variant. Binary search is famous for off-by-one bugs, so the boundary conventions here matter.

  1. binary_search setup: lo, hi = 0, len(a) - 1. The comment "the range is inclusive [lo, hi]" means hi is a valid index, which is why the loop condition is while lo <= hi (with <=, not <).
  2. The loop: mid = (lo + hi) // 2 is the middle index. If a[mid] equals the target we return mid. If it's too small we move lo = mid + 1 (search the right half); if too big, hi = mid - 1 (search the left half). Returning -1 means "not found".
  3. bisect: bisect.bisect_left(a, 5) returns the index where 5 is (or where it would be inserted to stay sorted) — O(log n). bisect.insort(a, 6) inserts 6 in the correct spot, keeping a sorted. Use these instead of writing your own.
  4. lower_bound uses a different convention: lo, hi = 0, len(a) is "half-open [lo, hi)" — hi is one past the end — so its loop is while lo < hi and it never does hi = mid - 1. It returns the leftmost index whose value is >= target, handy for finding insertion points and counting duplicates.

What the output means: binary_search([...], target) returns the index or -1; bisect_left(a, 5) returns 2; after insort(a, 6), a is [1,3,5,6,7,9].

Try this: Notice the two functions use different range conventions (inclusive [lo,hi] vs half-open [lo,hi)). Pick one convention and stick to it — mixing them is the #1 source of binary-search bugs.

Binary search beyond arraysThe pattern — "guess, check, halve the search space" — solves far more than array lookup: finding a threshold ("smallest capacity that handles the load"), square roots, and "binary search on the answer" optimization problems. Any monotonic yes/no question is binary-searchable in O(log range).

9 · Quickselect — the k-th element in O(n) expert expert

To find the k-th smallest (or the median, or top-k), you don't need a full sort. Quickselect reuses quicksort's partition but only recurses into the side that contains k — giving O(n) average time instead of O(n log n).

find k=2 (3rd smallest) · partition, then recurse ONE side 2 1 5 7 9 < pivot pivot @ index 2 > pivot k = 2 == pivot's index → answer is 5, done only one side is ever recursed into → O(n) average Quickselect = quicksort that recurses once. After partitioning, the pivot sits at its final index; compare that to k and dive into only the side holding k (or stop if it is k). Discarding half the work each step gives O(n) average — no full sort needed.
🗺️ How to read this diagram

Quickselect finds the k-th smallest value without fully sorting. It partitions like quicksort, but then recurses into only one side — the side that contains position k. Here we want k=2 (the 3rd smallest, since k is 0-indexed).

  • The boxes show the list after one partition around pivot 5 (blue box): green boxes (2, 1) are smaller, the plain boxes (7, 9) are larger. The label "pivot @ index 2" says the pivot landed at index 2.
  • The decision: we wanted k=2, and the pivot sits at index 2 — they match! So the pivot value 5 is the answer and we stop immediately (the brand-coloured line "k = 2 == pivot's index → answer is 5, done").
  • If they hadn't matched: if k were smaller than the pivot's index we'd recurse only into the left (green) side; if larger, only into the right side. The other side is thrown away — never examined again.
  • Why O(n) average: a full sort recurses into both sides (that's the extra log n factor). Quickselect discards one side each step, so the total work shrinks geometrically to about 2n — linear on average.

In short: Same partition as quicksort, but chase only the half that holds your target position. Doing half the recursion is exactly what turns O(n log n) sorting into O(n) selection.

Try it
pythonimport random

def quickselect(a, k):               # k-th smallest, 0-indexed
    a = a[:]
    def select(lo, hi):
        pivot = a[random.randint(lo, hi)]   # random pivot avoids worst case
        # three-way partition around the pivot value
        less = [x for x in a[lo:hi+1] if x < pivot]
        equal = [x for x in a[lo:hi+1] if x == pivot]
        greater = [x for x in a[lo:hi+1] if x > pivot]
        if k - lo < len(less):
            a[lo:hi+1] = less + equal + greater
            return select(lo, lo + len(less) - 1)
        elif k - lo < len(less) + len(equal):
            return pivot                    # k lands in the pivot block
        else:
            a[lo:hi+1] = less + equal + greater
            return select(lo + len(less) + len(equal), hi)
    return select(0, len(a) - 1)

nums = [7, 2, 9, 1, 5]
print(quickselect(nums, 2))          # 5 — the 3rd smallest (0-indexed k=2)
▶ How this works

Quickselect in code: find the k-th smallest value in O(n) average time by partitioning and recursing into just one side. The inner select(lo, hi) does the work on the slice between indices lo and hi.

  1. a = a[:] copies so we don't disturb the caller. The nested function select can see a and k from the outer scope.
  2. Random pivot: pivot = a[random.randint(lo, hi)] picks a random element. Randomising avoids the pathological O(n²) case that a fixed pivot hits on already-sorted input.
  3. Three-way split: less, equal, and greater hold the values below, equal to, and above the pivot within the current slice a[lo:hi+1].
  4. The three branches decide where k is: if k - lo < len(less) the target is in the smaller group, so recurse there; else if it falls inside the equal block we return pivot (found it); otherwise recurse into the greater group, shifting the start index past less and equal. Only one branch recurses — that's the speed-up.
  5. Each branch first writes the reordered slice back with a[lo:hi+1] = less + equal + greater so indices line up before recursing.

What the output means: quickselect([7,2,9,1,5], 2) prints 5 — the 3rd smallest value (k=2 because k is counted from 0).

Try this: Change k to 0 (smallest) and 4 (largest) and confirm the answers. Finding the median is just quickselect with k = len(a)//2 — no full sort.

When to use what for "top-k"k small, n huge: a size-k heap — heapq.nlargest — O(n log k), streaming-friendly (D2/D4). Need the exact k-th value once: quickselect — O(n) average. Need everything sorted anyway: just sorted(). RAG retrieval picks the heap; a one-off median picks quickselect.

Exercises expert

Practice
  1. Make quicksort choose a random pivot and confirm it no longer degrades on sorted input.
  2. Use merge sort's merge step to count "inversions" (out-of-order pairs) in O(n log n).
  3. Given two sorted lists, find their median in O(log(m+n)) with binary search.
  4. Use bisect to implement an autocomplete that returns all words in a sorted list with a given prefix.
  5. Sort a list of (name, score) by score descending, breaking ties by name ascending — with one sorted call.

🎯 Interview practice interview

The interview questions this topic gets asked — worked, with code. For the full pattern catalog see D8 · Big Tech DSA patterns.

Kth largest element (classic) — heap

A size-k min-heap keeps the k largest; its root is the answer. O(n log k).

pythonimport heapq
def find_kth_largest(nums, k):
    return heapq.nlargest(k, nums)[-1]   # or maintain a size-k heap
▶ How this works

The classic interview question "find the k-th largest element" in one line, using a heap. The comment hints at the fuller technique interviewers like to hear about.

  1. heapq.nlargest(k, nums) returns the k biggest values, already sorted largest-first. Indexing [-1] grabs the last of those — the smallest of the top k, which is exactly the k-th largest overall.
  2. The comment "or maintain a size-k heap" points at the streaming version: keep a min-heap of size k, push each number, and pop the smallest whenever the heap grows past k. The root is then the k-th largest — this runs in O(n log k) and never stores more than k items, so it works on data too big to hold at once.

What the output means: For nums=[3,2,1,5,6,4], k=2 it returns 5 — the 2nd largest.

Try this: Say the trade-off out loud in an interview: nlargest is O(n log k) and simple; quickselect is O(n) average but trickier; full sorted() is O(n log n). Picking the right one for the constraints is what's being tested.

Search in rotated sorted array (classic) — binary search

One half is always sorted; decide which, then whether the target lies in it.

pythondef search(nums, target):
    lo, hi = 0, len(nums) - 1
    while lo <= hi:
        mid = (lo + hi) // 2
        if nums[mid] == target: return mid
        if nums[lo] <= nums[mid]:            # left half sorted
            if nums[lo] <= target < nums[mid]: hi = mid - 1
            else: lo = mid + 1
        else:                                 # right half sorted
            if nums[mid] < target <= nums[hi]: lo = mid + 1
            else: hi = mid - 1
    return -1
▶ How this works

A favourite interview twist: binary-search a sorted array that has been rotated (e.g. [4,5,6,7,0,1,2]). The array isn't fully sorted, but at any midpoint one half still is — and that's enough to keep halving.

  1. Standard binary-search skeleton: lo, hi, and mid = (lo + hi) // 2. If nums[mid] is the target, return it.
  2. Which half is sorted? if nums[lo] <= nums[mid] the left half is in order; otherwise the right half is. Exactly one side is always cleanly sorted, even after rotation.
  3. Is the target in the sorted half? When the left is sorted, check nums[lo] <= target < nums[mid] — if so, search left (hi = mid - 1), otherwise search right. The mirror logic applies when the right half is sorted.
  4. Because each step still discards half the array, it stays O(log n) — the rotation costs nothing asymptotically.

What the output means: search([4,5,6,7,0,1,2], 0) returns 4 (the index of 0); a missing target returns -1.

Try this: The reusable idea: even when data isn't fully sorted, if you can identify a sorted region at each step you can still binary-search it. Spotting that invariant is the whole trick here.

Checkpoint — you've completed the DSA track when you can… expert

  • Explain the O(n log n) comparison-sort lower bound and when counting/radix beat it.
  • Implement merge sort and quicksort and state their space/stability trade-offs.
  • Define stability and know Timsort's guarantees + the multi-key sort idiom.
  • Write binary search and lower_bound without off-by-one errors, and pick heap vs quickselect vs sort for top-k.
  • See the through-line: arrays → linear structures → hashing → trees/heaps → graphs → sorting/searching, and where each lives in your agent code.
🏗️ Where this lands in the courseYou now have the CS foundation the whole course quietly relies on: RAG retrieval is a heap over cosine scores; the agent loop is a stack/queue of messages; tool dispatch and dedup are hash maps; a DevOps plan is a topological sort over a DAG; re-ranking is a stable multi-key sort; chunk lookup is binary search. Head back to Ch 3 (RAG) or the Capstone and you'll see the structures everywhere.

🪜 Practice ladder beginner → industry

Six graded exercises, easy to real-world. Try each before opening its solution.

Exercise 1 · Sort keys and stabilityBeginner

Context: Python's built-in sort is Timsort, and its stability guarantee is something you can rely on when sorting by multiple keys.

Your task: Sort a list of (name, age) tuples by age with Python's Timsort and show that it is stable — equal ages keep their input order.

Requirements:

  • Sort by the age key with sorted/key=
  • Records with equal ages preserve their original relative order
  • Explain what stability means and why it enables multi-key sorting
  • O(n log n)

💡 Hint: Give two entries the same age and confirm the earlier input still comes first after sorting.

Show solution
people = [("ada", 30), ("bob", 25), ("cy", 30), ("dot", 25)]
by_age = sorted(people, key=lambda p: p[1])   # O(n log n), stable
print(by_age)
# [('bob',25), ('dot',25), ('ada',30), ('cy',30)]
# bob before dot, ada before cy -> original order preserved within equal ages

Stability lets you sort by a secondary key first, then a primary key, and keep the secondary order within ties.

Exercise 2 · Merge sortIntermediate

Context: Merge sort is the guaranteed-O(n log n), stable divide-and-conquer sort, and implementing the merge step by hand is the core skill.

Your task: Implement merge sort — divide, recursively sort halves, and merge — with guaranteed O(n log n) time, O(n) space, and stability.

Requirements:

  • Recursively split the list until sublists are trivially sorted
  • Merge two sorted halves into one sorted list
  • Guaranteed O(n log n) regardless of input
  • Stable: equal elements keep their order
  • O(n) auxiliary space

💡 Hint: The whole algorithm hinges on a correct two-pointer merge that takes the smaller front element first (and ties from the left half to stay stable).

Show solution
def merge_sort(a):                     # O(n log n) time, O(n) space
    if len(a) <= 1:
        return a
    mid = len(a) // 2
    left, right = merge_sort(a[:mid]), merge_sort(a[mid:])
    merged, i, j = [], 0, 0
    while i < len(left) and j < len(right):
        if left[i] <= right[j]:        # <= keeps it stable
            merged.append(left[i]); i += 1
        else:
            merged.append(right[j]); j += 1
    return merged + left[i:] + right[j:]

print(merge_sort([5, 2, 9, 1, 5, 6]))   # [1, 2, 5, 5, 6, 9]

Splitting is log n levels deep and each level merges all n elements — the O(n log n) that beats the O(n^2) sorts.

Exercise 3 · Quicksort with in-place partitionAdvanced

Context: Quicksort with in-place Lomuto partition is fast in practice but has a worst-case pitfall worth understanding.

Your task: Implement quicksort using Lomuto partition, and note it averages O(n log n) but degrades to O(n²) on already-sorted input with a bad pivot.

Requirements:

  • Partition the array in place around a pivot
  • Recurse on the sub-ranges left and right of the pivot's final position
  • Average O(n log n)
  • State the O(n²) worst case (sorted input, last-element pivot)
  • In-place, no merge buffer

💡 Hint: Lomuto keeps a boundary index for elements smaller than the pivot and swaps the pivot into place at the end; a fixed end-pivot is what a sorted array punishes.

Show solution
def quicksort(a, lo=0, hi=None):       # avg O(n log n), worst O(n^2)
    if hi is None: hi = len(a) - 1
    if lo >= hi: return a
    pivot = a[hi]; i = lo
    for j in range(lo, hi):
        if a[j] < pivot:
            a[i], a[j] = a[j], a[i]; i += 1
    a[i], a[hi] = a[hi], a[i]          # pivot to its final place
    quicksort(a, lo, i - 1)
    quicksort(a, i + 1, hi)
    return a

print(quicksort([3, 6, 1, 8, 2, 9, 4]))   # [1, 2, 3, 4, 6, 8, 9]

Partitioning is in place (O(1) extra), which is why quicksort is cache-friendly and fast in practice — randomizing the pivot avoids the sorted-input worst case.

Exercise 4 · Binary search + first/last occurrenceExpert

Context: Binary search is the base of many "search the answer" problems, and finding the first/last index of a repeated value (classic) is the common variant.

Your task: Binary-search a sorted array for a target in O(log n), then find the first and last index of a value that repeats.

Requirements:

  • Plain search returns an index (or not-found) in O(log n)
  • First-occurrence search continues left after a match instead of stopping
  • Last-occurrence search continues right after a match
  • Correct on duplicates and on absent values

💡 Hint: For the boundary variants, don't return on the first hit — keep narrowing toward the side you want while remembering the best index so far.

Show solution
def bisect_left(a, x):                 # first index where a[i] >= x
    lo, hi = 0, len(a)
    while lo < hi:
        mid = (lo + hi) // 2
        if a[mid] < x: lo = mid + 1
        else:          hi = mid
    return lo

def search_range(a, x):                # O(log n)
    lo = bisect_left(a, x)
    if lo == len(a) or a[lo] != x:
        return [-1, -1]
    hi = bisect_left(a, x + 1) - 1     # first index > x, minus one
    return [lo, hi]

print(search_range([5, 7, 7, 8, 8, 8, 10], 8))   # [3, 5]
print(search_range([5, 7, 7, 8, 8, 8, 10], 6))   # [-1, -1]

Searching for the left boundary of x and of x+1 brackets the whole run in two O(log n) searches.

Exercise 5 · Quickselect — k-th smallest in O(n)Professional

Context: Quickselect finds the k-th smallest without fully sorting — the partition-based trick behind kth-largest (classic).

Your task: Find the k-th smallest element without fully sorting, using partition (quickselect), averaging O(n) with an O(n²) worst case.

Requirements:

  • Partition around a pivot, then recurse into only the side containing k
  • Average O(n) because you discard one side each step
  • Worst case O(n²) on bad pivots
  • Return the k-th smallest value
  • Reuses the partition logic from quicksort

💡 Hint: After a partition the pivot is in its final sorted position — compare that position to k and recurse into just one side, never both.

Show solution
import random

def quickselect(a, k):                 # k is 1-based; avg O(n)
    a = a[:]                           # copy so we don't mutate caller's list
    lo, hi = 0, len(a) - 1
    target = k - 1
    while lo < hi:
        p = random.randint(lo, hi)     # random pivot avoids worst case
        a[p], a[hi] = a[hi], a[p]
        pivot = a[hi]; i = lo
        for j in range(lo, hi):
            if a[j] < pivot:
                a[i], a[j] = a[j], a[i]; i += 1
        a[i], a[hi] = a[hi], a[i]
        if i == target: return a[i]
        if i < target:   lo = i + 1    # recurse into one side only
        else:            hi = i - 1
    return a[lo]

print(quickselect([7, 2, 9, 1, 5, 3], 3))   # 3  (3rd smallest)

Unlike quicksort, quickselect recurses into only the side containing k, so the work is n + n/2 + n/4 + … = O(n) on average.

Exercise 6 · Merge K sorted listsIndustry scenario

Context: Merging K sorted streams with a heap (classic problem pattern) is how you combine sharded, pre-sorted data such as log files.

Your task: Merge K sorted streams into one sorted output using a heap, in O(N log K) for N total items across K lists.

Requirements:

  • Seed a min-heap with the head of each of the K lists
  • Repeatedly pop the smallest and push the next item from that list
  • Output is fully sorted
  • O(N log K)
  • Handle lists of differing lengths and empty lists

💡 Hint: The heap only ever holds one candidate per list (K entries), so each of the N pops costs log K.

Show solution
import heapq

def merge_k_sorted(lists):             # O(N log K)
    heap = []
    for li, lst in enumerate(lists):   # seed with each list's head
        if lst:
            heapq.heappush(heap, (lst[0], li, 0))
    out = []
    while heap:
        val, li, idx = heapq.heappop(heap)
        out.append(val)
        if idx + 1 < len(lists[li]):   # push the next item from that list
            heapq.heappush(heap, (lists[li][idx + 1], li, idx + 1))
    return out

print(merge_k_sorted([[1, 4, 7], [2, 5, 8], [3, 6, 9]]))
# [1, 2, 3, 4, 5, 6, 7, 8, 9]

The heap holds only one front element per list (K items), so each of N pops/pushes costs log K — the standard external-merge and log-aggregation primitive.

Knowledge check check yourself

✓ Knowledge check

Why is O(n log n) considered a wall for comparison-based sorts, and how do counting/radix sort beat it?

Show answer
Any sort that only compares elements needs at least O(n log n) comparisons in the worst case. Counting and radix sort avoid comparisons entirely — they bucket by key/digit value — achieving roughly O(n) when the key range is bounded.
✓ Knowledge check

What does it mean for a sort to be stable, and why does Python's Timsort guarantee stability?

Show answer
A stable sort preserves the original relative order of records with equal keys. Timsort is stable, which lets you sort by multiple keys in sequence (sort by secondary key first, then primary) and keep the earlier ordering intact.
© 2026 studybydoing.in · AI Engineering: Zero to Production · All rights reserved. · About · Privacy Policy · Terms · Contact
Educational content, provided as-is and without warranty. Code samples are examples — review, test, and adapt them before using in production. See the Terms of Use & Disclaimer. Use at your own risk.
© studybydoing.in