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.
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
Here's a puzzle that feels like cheating the first time you see it. You have a million numbers and someone asks "is 847,203 in here?" Unsorted, you have no choice but to look at every single one — a million checks in the worst case. But sort the list once, and suddenly the same question takes about twenty checks, forever after. You didn't get a faster computer; you paid a one-time cost to impose order, and order is what lets you skip. That trade — spend once to sort, then reap cheap lookups again and again — is why sorting is the quiet workhorse under so much software.
Think of a physical dictionary versus a shoebox of loose word-cards. Both hold the same words, but the dictionary is sorted, so you find "penguin" by flipping to roughly the middle, then the middle of what's left — never reading front to back. The shoebox forces you to paw through every card. Sorting is the act of turning the shoebox into the dictionary. And because it's so foundational, it's also the most-studied problem in all of computer science: decades of clever people have squeezed it, which is exactly why it's the perfect capstone — it ties together divide-and-conquer, heaps, recursion, and the cost model from every earlier part of this track.
The common beginner mistake is to treat "sort it" as free or magical — to reach for sorted() reflexively without a feel for what it costs. It is not free: it has a price, and remarkably, there's a hard floor on that price that no amount of cleverness can break through for the general case. That floor has a name — the O(n log n) wall — and understanding why it exists is what separates someone who memorises sort names from someone who knows when a linear-time trick is even possible. So before we write a single sort, let's name the wall.
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.
Read the two panels left-to-right: the left is the world without sorting, the right is the world after one sort.
- Left (unsorted): the grey boxes are the raw list. The grey arrow sweeping across all of them is one query — it must touch every one of the
nitems because there is no order to exploit. The note below reminds you this O(n) scan repeats for the next query, and the next. - The dashed line just separates the two independent scenarios.
- Right (sorted): the green boxes are the same values, now in order. The amber line marks the one-time O(n log n) cost you pay to get there. After that, each query (blue box) follows the short arrow down to a single O(log n) answer — it halves the range instead of scanning.
- The payoff element is the contrast: the left repeats expensive work forever; the right front-loads one cost and then answers cheaply again and again.
In short: Sorting trades one O(n log n) payment now for O(log n) queries forever after — worth it the moment you'll ask more than a few questions of the same data.
2 · The O(n²) sorts — learn them, then never use them basic
Think about how you'd sort a hand of playing cards. Most people do it without being taught: you pick up cards one at a time and slide each new one into its right spot among the cards you're already holding. That instinct is a sorting algorithm — insertion sort — and it works beautifully for thirteen cards. The trouble starts when the "hand" is thirteen million records, because the very thing that makes it feel natural — comparing the new item against the ones you've already placed — means the work piles up as a loop inside a loop. That's the O(n²) shape from D1, and it's the reason these three sorts are a rite of passage you learn and then retire.
Why bother learning sorts you're told never to ship? Two reasons. First, they're the clearest possible illustration of the O(n²) trap — nested loops, work growing with every pair of elements — so they cement the cost intuition you'll apply to your own code for the rest of your career. Second, one of them (insertion sort) is genuinely useful in a niche the fast sorts can't beat: tiny or nearly-sorted arrays, which is why production sorts quietly fall back to it. Bubble and selection, by contrast, earn their keep purely as teaching tools; you will essentially never choose them on purpose in real code.
The three differ only in how they move elements toward order — bubble swaps neighbours until the big ones float to the end, selection repeatedly hunts down the smallest and places it, insertion grows a sorted region one card at a time — but they share the same fatal ceiling: all three re-scan the data again and again, so ten times more input means roughly a hundred times more work. The common mistake is assuming "it ran fine on my test data" tells you anything; a hidden O(n²) sort sails through a 100-item test and then melts on a 100,000-item production load. The diagram lines up all three strategies so you can see they reach the same destination by different (equally slow) roads.
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
xis the next item. The back-pointing arrow means "slidexleft 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.
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
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.
- bubble_sort: the outer
for iis one full pass; the innerfor jcompares each neighbour paira[j]anda[j+1]and swaps them if the left is bigger. Theswappedflag is an optimisation: if a whole pass made no swaps the list is already sorted, sobreakexits early — that's the O(n) best case. - selection_sort:
m = min(range(i, len(a)), key=lambda k: a[k])finds the index of the smallest value from positionionward, then the swapa[i], a[m] = a[m], a[i]moves that minimum to the front of the unsorted part. - insertion_sort:
keyis the item we're placing. Thewhileloop walks left, shifting bigger items one slot right (a[j+1] = a[j]), until it finds the gap wherekeybelongs, then drops it in witha[j+1] = key. - 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.
3 · Merge sort — divide & conquer advanced
Imagine you and three friends have to alphabetise a giant stack of exam papers. The clumsy way: one person tries to sort the whole pile alone. The smart way: split the pile into four, each person sorts their quarter, and then you combine the four sorted quarters into one. Merging already-sorted piles is startlingly easy — you just keep taking whichever pile's top paper comes first alphabetically. That "split the work, solve the pieces, cleverly combine" instinct is divide-and-conquer, and merge sort is its purest expression. It's also the moment this track levels up: we finally break the O(n²) ceiling.
The key realisation — and it surprises people — is that merging two already-sorted lists is cheap, only O(n). Because each list is sorted, you never search: you just compare the two front items, take the smaller, and advance. So the plan writes itself: keep splitting the array in half until each piece is a single element (a one-item list is trivially sorted — there's nothing to arrange), then merge pairs back up. Halving gives about log n levels, and each level's merges together touch all n items once, so the total is n × log n — guaranteed, in the best and worst case, because the split doesn't care what the data looks like.
Merge sort buys you two things quadratic sorts can't: a rock-solid O(n log n) with no bad-luck worst case, and stability (equal items keep their original order, thanks to one <= in the merge). The price — and there's always a price — is O(n) extra memory for the scratch space the merge needs. The common mistake is forgetting that cost and merge-sorting data that barely fits in RAM, doubling your footprint; when memory is tight you'd reach for an in-place sort like quicksort or heapsort instead. Read the diagram as a tree: grey arrows going down are the split, blue arrows coming back up are the merge.
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.
≤ in the merge keeps equal items in order → stable.
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.
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]
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.
- 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. - Divide:
mid = len(a) // 2splits in half.merge_sort(a[:mid])sorts the left half andmerge_sort(a[mid:])the right — each call keeps halving until it hits the base case. - Combine in _merge: two pointers
iandjwalk the two sorted inputs. Each step appends the smaller front item and advances that pointer, sooutcomes out sorted in one linear pass. - The stability trick: using
<=inif 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. - 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.
heapq.merge() function does exactly this lazily over any number of sorted iterables.4 · Quicksort — partition in place advanced
Picture a room full of people you need to line up by height, but you're not allowed a second empty room to sort them in. Merge sort needed that spare room (its O(n) scratch space); quicksort refuses it. Instead you grab one person — call them the pivot — and bark a single instruction: "everyone shorter, go to my left; everyone taller, go to my right." Now the pivot is standing in their exact final position, and you've got two smaller groups to sort by the same rule. You never left the room. That in-place shuffle is quicksort, and it's the sort your language's standard library most likely uses under the hood for raw speed.
The clever bit is that one partition pass does something merge sort's split doesn't: it places the pivot permanently. After partitioning, the pivot never moves again — everything smaller is already left of it, everything larger right of it — so quicksort needs no separate merge step at the end. Recurse on the left group and the right group and the whole array falls into order for free. When the pivot lands near the middle each time, you get about log n levels of halving over n-work partitions: the familiar O(n log n) average, but with tiny constants and cache-friendly memory access, which is why it's usually the fastest in practice.
Here's the catch, and it's the thing interviewers love to probe. Quicksort's speed depends entirely on picking a balanced pivot. Choose badly — say, always grab the last element — and hand it an already-sorted array, and every partition splits off just one element instead of half. Now you have n levels instead of log n, and the whole thing collapses to O(n²) — slower than merge sort exactly when you'd least expect it. The common mistake is shipping a fixed-pivot quicksort and getting bitten by real data that happens to arrive pre-sorted; the fix is a single line — pick the pivot at random — which makes the bad case astronomically unlikely. The diagram shows one partition step; watch the pivot snap into its final home.
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).
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)andquicksort(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.
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)
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).
- Readable version: pick
pivot = a[len(a) // 2](the middle element), then use three list comprehensions to buildless,equal, andgreater. The answer isquicksort(less) + equal + quicksort(greater)— sort the two outer groups and glue everything together with the equal block in the middle. - In-place version, the setup:
loandhimark the slice being sorted.if hi is None: hi = len(a) - 1fills in the end on the first call, andif lo >= hi: returnis the base case (0 or 1 elements). - The Lomuto partition loop: the pivot is the last element
a[hi]. A pointerimarks the boundary of the "smaller-than-pivot" zone. For eachj, ifa[j] < pivotwe swap it into that zone and growi. So all small values pile up on the left. - Placing the pivot: after the loop, one final swap
a[i], a[hi] = a[hi], a[i]drops the pivot at indexi— 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.
| Sort | Best | Average | Worst | Space | Stable? |
|---|---|---|---|---|---|
| bubble/insertion | O(n) | O(n²) | O(n²) | O(1) | yes |
| selection | O(n²) | O(n²) | O(n²) | O(1) | no |
| merge | O(n log n) | O(n log n) | O(n log n) | O(n) | yes |
| quick | O(n log n) | O(n log n) | O(n²) | O(log n) | no |
| heap | O(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
Suppose you're organising a talent show and you have a magic box: drop any number of contestants in, and it always hands you back the single best one on demand. Sorting everyone becomes trivial — ask the box for the best, write them down, ask again, write down the next-best, and repeat until the box is empty. What comes out is a perfectly ranked list. That magic box is a heap (you built one from scratch in D4), and using it this way to sort is heapsort. It's the sort that gives you the strong guarantees of merge sort without merge sort's extra memory.
Mechanically it's two phases. First, heapify the array — rearrange it in place so it obeys the heap's "each parent beats its children" rule; surprisingly, this costs only O(n), not O(n log n). Then repeatedly pull the minimum off the top, each pop costing O(log n) to re-settle the heap. Do that n times and you've spent n log n total — and crucially, the whole dance happens inside the original array, so extra memory is O(1). That combination — guaranteed O(n log n) worst case and constant extra space — is heapsort's unique selling point; neither quicksort (bad worst case) nor merge sort (extra space) offers both.
So why isn't heapsort the default everywhere? Because raw wall-clock speed usually loses to quicksort. The reason is cache behaviour: heapsort hops around the array to parent and child indices that are far apart in memory, defeating the CPU's habit of pre-loading nearby data, whereas quicksort scans in tidy neighbouring runs. It's also not stable — equal items can get reordered. The common mistake is picking heapsort for general sorting because its Big-O looks best on paper; in practice you'd reach for it specifically when you need the worst-case guarantee and can't spare memory (embedded systems, real-time deadlines). For everyday work, the heap's real home is top-k: keep only the best few from a huge stream, which is exactly the RAG pattern you'll see in the used-in-the-course notes.
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.
Read the two panels left-to-right: phase 1 turns the array into a heap; phase 2 drains that heap into sorted output.
- Left (phase 1 — heapify): the boxes are drawn as a tree because a heap is a tree laid over the array. The rule it enforces is "each parent ≤ its children", so the smallest value (1) ends up on top. Doing this in place costs only O(n).
- The dashed line separates the two phases.
- Right (phase 2 — pop): you repeatedly take the top (the minimum) into the green output row. After each pop the heap re-settles, which costs O(log n). Doing that
ntimes is the n × log n shown below. - The payoff line at the bottom is heapsort's unique selling point: the whole dance stays inside the original array, so it adds O(1) extra memory — the guarantee neither quicksort nor merge sort gives you.
In short: Heapify once (O(n)), then pop the minimum n times (O(log n) each) → O(n log n) with O(1) extra space.
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]
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.
h = a[:]copies the list first so the original isn't disturbed.heapq.heapify(h)rearrangeshinto a min-heap in O(n) — after this,h[0]is always the smallest element.- 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. - The
_loop variable is a convention meaning "I don't care about this value, I just want to repeatlen(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
We keep calling O(n log n) a "wall" — so how do the pros sometimes sort in flat linear time? The trick is a loophole hiding in the fine print of that lower bound. The proof says any sort that works by comparing pairs of elements can't beat O(n log n). The escape, then, is obvious once you see it: don't compare. If you never ask "is A bigger than B?", the proof simply doesn't apply to you, and the wall isn't your wall.
Here's the everyday version of the idea. Imagine sorting a stack of exam papers by score, where every score is 0 to 100. Instead of comparing papers to each other, you set out 101 labelled trays and drop each paper into the tray matching its score. When you're done, just gather the trays in order — the papers come out sorted, and you never once compared two papers. That's counting sort: one pass to tally how many of each value you have (O(n)), one pass over the possible values to emit them in order (O(n + k), where k is the size of the value range). Radix sort extends the same tally-don't-compare idea to bigger numbers or strings by sorting one digit at a time, from least significant to most, giving O(d·(n+k)) for d digits.
But loopholes have conditions, and here's the one that bites. Counting sort needs one tray per possible value, so its cost and memory scale with k, the value range — not just n, the number of items. Sorting five numbers that could each be up to a billion would demand a billion trays: technically linear in k, catastrophically wasteful in reality. The common mistake is being seduced by "linear time!" and reaching for counting sort on wide-ranging data (arbitrary floats, huge IDs, timestamps), where it's dramatically worse than a plain comparison sort. These sorts win only when the range is small and known: ages, test scores, single bytes, fixed-length keys. Everywhere else, the comparison sorts you just learned remain the right default. The diagram walks through a tiny counting sort on values in 0..3.
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)).
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 2we 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.
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]
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.
counts = [0] * (max_val + 1)makes one counter slot for every possible value from 0 up tomax_val, all starting at zero.- First loop (count): for each
xin the input,counts[x] += 1. One pass over the data — O(n). - Second loop (emit):
enumerate(counts)gives eachvalueand its countc.out.extend([value] * c)appends that valuectimes. Because we walk values 0, 1, 2, … in order, the output comes out sorted. - There is no
if a < banywhere — 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.
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
Here's a bug that has burned every data engineer at least once. You have a table of orders already sorted by date. You sort it again by customer name to group each customer's orders together — and to your horror, within each customer the dates are now scrambled. You didn't touch the dates; the sort silently reshuffled equal-name rows. That single frustrating experience is why the property called stability exists, and why it's one of the most quietly important guarantees a sort can make.
A sort is stable if, when two items compare equal on the sort key, it keeps them in the order they already had. Picture sorting a deck of cards first by number, then by suit: a stable sort leaves the numbers in order within each suit; an unstable one might jumble them. Why does anyone care? Because stability is what makes multi-key sorting work by layering. Want results ordered by score, and ties broken by source? Sort by the secondary key (source) first, then by the primary key (score) — a stable sort preserves the source order inside each group of equal scores, so two simple sorts compose into one compound ordering. Without stability that trick is impossible and you'd need a single, fiddlier comparison.
Python hands you all of this for free: sorted() and list.sort() use Timsort, a hybrid invented by Tim Peters for CPython that merges the best of two worlds — merge sort's guaranteed O(n log n) worst case and stability, plus insertion sort's speed on small chunks. Its real superpower is being adaptive: it detects already-sorted stretches (called "runs") in your data and skips work, so nearly-sorted input runs in close to O(n). The common mistake is assuming every language's sort is stable and porting the "sort twice" idiom to one whose default sort isn't (C's qsort, for instance) — where it quietly breaks. In Python you can rely on it; the lab shows the layered-sort payoff.
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).
Follow the arrows top-to-bottom: it's the same three records passing through two sorts, in a deliberate order.
- Top-left (start): three records, each carrying a
doc, ascore, and asource. We want them ordered by score (high first), and ties broken by source. - Step ① (amber, top-right): first sort by the secondary key,
source— that arranges them kb, kb, web. - Step ② (blue arrow down): now sort by the primary key,
scoredescending, using a stable sort. The two records tied at .9 (A and B) keep the order step ① gave them. - The payoff (green line): A stays before B among the equal-score group — the secondary order survived. The red line warns what an unstable sort would do: silently flip A and B, the classic multi-key bug.
In short: Sort by the least-important key first, the most-important key last, and let stability carry the tie-breaks — two easy sorts instead of one fiddly comparison.
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
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.
resultsis a list of dictionaries, each with adoc, ascore, and asource. We want them ordered by score (high first), and for equal scores, by source.- 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. key=lambda r: ...tells sort what to compare for each item — here, reach into the dictionary and pull out a field.reverse=Trueflips to descending.- 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 bydoc— 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.
8 · Binary search — the payoff of sorted data advanced
Remember the "guess my number between 1 and 100" game? The winning strategy isn't to guess 1, 2, 3… — it's to guess 50, learn "higher" or "lower," then guess 75 or 25, halving what's left every time. You corner any number in at most seven guesses. Binary search is exactly that game played on a sorted array, and it's the reward you collect for all the sorting effort in this chapter: because the data is in order, one look at the middle tells you which half to throw away.
The power is hard to overstate until you see the numbers. Every comparison discards half the remaining candidates, so the cost is O(log n) — and logarithms grow at a crawl. A thousand items take about ten checks; a million take about twenty; a billion take about thirty. Doubling your data adds a single extra step. That's the difference between an O(n) scan that grinds through everything and an O(log n) search that barely notices the size. But it only works on sorted data — order is the whole reason you can trust that the target must be on one particular side. On an unsorted list you have no such clue and you're back to looking at everything.
There's a reason binary search is a rite of passage: it is famously, notoriously bug-prone. It's a handful of lines, yet getting the boundaries right defeats even strong programmers — a landmark study found most published implementations were subtly broken for years. The traps are all off-by-one: is the range inclusive [lo, hi] or half-open [lo, hi)? Should the loop be while lo <= hi or while lo < hi? Do you move to mid + 1 or mid? The common mistake is mixing conventions mid-function — pairing an inclusive range with a half-open update — which produces a search that works on most inputs and infinite-loops or misses on a few. The cure is discipline: pick one convention, apply it consistently, and lean on the standard library's bisect whenever you can. The diagram traces a search for 11 so you can watch half the array vanish each step.
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.
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 13remain. 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.
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
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.
- binary_search setup:
lo, hi = 0, len(a) - 1. The comment "the range is inclusive [lo, hi]" meanshiis a valid index, which is why the loop condition iswhile lo <= hi(with<=, not<). - The loop:
mid = (lo + hi) // 2is the middle index. Ifa[mid]equals the target we returnmid. If it's too small we movelo = mid + 1(search the right half); if too big,hi = mid - 1(search the left half). Returning-1means "not found". - 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, keepingasorted. Use these instead of writing your own. - lower_bound uses a different convention:
lo, hi = 0, len(a)is "half-open [lo, hi)" —hiis one past the end — so its loop iswhile lo < hiand it never doeshi = 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.
9 · Quickselect — the k-th element in O(n) expert expert
Someone hands you a million exam scores and asks for the median. The obvious move is to sort them all and grab the middle one — O(n log n), done. But pause: sorting arranges every element relative to every other, and you only wanted one value. That's like alphabetising an entire library just to find the book that would sit exactly in the middle of the shelf. You did a mountain of work you're about to throw away. There's a way to get that single answer in O(n) — linear time — and it recycles machinery you already own.
Quickselect is quicksort with a lazy streak. It partitions around a pivot just like quicksort — after which the pivot sits in its final sorted position, smaller values left, larger right. Now comes the insight: you know the pivot's exact rank (its index). If that rank equals the k you're hunting, you're done — the pivot is the answer. If k is smaller, it must be in the left group, so recurse there; if larger, recurse right. Either way you throw one whole side away and never look at it again. Quicksort recurses into both halves (that's where its extra log n factor comes from); quickselect recurses into one.
That single change is the whole speed-up. When each pivot roughly halves the range, the work is n + n/2 + n/4 + …, which sums to about 2n — linear on average, versus the O(n log n) of a full sort. It shares quicksort's Achilles' heel — a pathological pivot gives an O(n²) worst case, cured the same way, with a random pivot. Knowing quickselect matters because "find the k-th thing" is everywhere: medians, percentiles, and top-k selection. The common mistake is reflexively sorting to answer these; but note the flip side — if you need several order statistics or the data is already sorted for other reasons, a full sort can be the better call. The trade-offs box after the lab lays out when to pick quickselect, a heap, or a plain sort. The diagram shows one partition where k lands right on the pivot.
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).
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 nfactor). 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.
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)
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.
a = a[:]copies so we don't disturb the caller. The nested functionselectcan seeaandkfrom the outer scope.- 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. - Three-way split:
less,equal, andgreaterhold the values below, equal to, and above the pivot within the current slicea[lo:hi+1]. - 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 theequalblock wereturn pivot(found it); otherwise recurse into thegreatergroup, shifting the start index pastlessandequal. Only one branch recurses — that's the speed-up. - Each branch first writes the reordered slice back with
a[lo:hi+1] = less + equal + greaterso 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.
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
- Make quicksort choose a random pivot and confirm it no longer degrades on sorted input.
- Use merge sort's merge step to count "inversions" (out-of-order pairs) in O(n log n).
- Given two sorted lists, find their median in O(log(m+n)) with binary search.
- Use
bisectto implement an autocomplete that returns all words in a sorted list with a given prefix. - Sort a list of (name, score) by score descending, breaking ties by name ascending — with one
sortedcall.
🎯 Interview practice interview
The interview questions this topic gets asked — worked, with code. For the full pattern catalog see D8 · Big Tech DSA patterns.
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
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.
heapq.nlargest(k, nums)returns thekbiggest 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.- 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.
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
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.
- Standard binary-search skeleton:
lo,hi, andmid = (lo + hi) // 2. Ifnums[mid]is the target, return it. - 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. - 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. - 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_boundwithout 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.
🪜 Practice ladder beginner → industry
Six graded exercises, easy to real-world. Try each before opening its solution.
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.
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.
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.
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.
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.
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
Why is O(n log n) considered a wall for comparison-based sorts, and how do counting/radix sort beat it?
Show answer
What does it mean for a sort to be stable, and why does Python's Timsort guarantee stability?