latency.lab
Algorithms · Topic 18

Sorting

Level: foundationTool: Comparison sorts, custom comparators, and counting/bucket techniquesTime: ~75 min

By the end you can

You already call std::sort without thinking twice. This topic is about what's underneath that call, and — more importantly — when reaching for it isn't actually the fastest option. Comparison-based sorting (the kind std::sort does) has a hard, provable floor: no algorithm that only compares pairs of elements can sort in better than O(n log n) time, in the worst case, ever. But several of this topic's problems beat that floor entirely, because they aren't really comparison problems in disguise — they're counting problems, or partitioning problems, that happen to look like sorting.

Comparison sort, implemented: merge sort's guarantee

Sort an Array asks you to build a sort yourself. Merge sort earns its place as the default teaching example over quicksort for one concrete reason: quicksort's O(n log n) is an average, and a bad pivot choice degrades it to O(n^2) in the worst case, while merge sort's divide-exactly-in-half recursion structure guarantees O(log n) levels no matter what the input looks like. That reliability is also why std::stable_sort is typically merge-sort-based under the hood, while std::sort usually leans on a quicksort variant with fallbacks. Sort List asks the same question with one constraint removed: a linked list has no random access, so finding the midpoint needs its own technique (the slow/fast pointer trick) before the same divide-and-merge structure applies.

2
5
8
left half
3
6
9
right half
Merging two already-sorted halves [2,5,8] and [3,6,9]: compare the two marked fronts (2 vs 3), take the smaller (2), and advance only that side's pointer to 5. Each of the 6 elements is placed exactly once across the whole merge — the O(n) step that makes the full sort O(n log n).

Partial sorting and constrained operations

Kth Largest Element doesn't need the whole array sorted — it needs one position. Quickselect reuses quicksort's partition step but discards the side that can't contain the answer, turning O(n log n) into O(n) on average. Sort Array by Parity is the simplest version of the same idea: partitioning isn't sorting, but it's often all a problem actually needs. Pancake Sort flips the question around entirely — instead of asking "how do I sort fastest," it asks "how do I sort at all, using only this one restricted operation (reversing a prefix)?" The answer (repeatedly moving the current max to the front, then flipping it into place) is a reminder that sorting algorithms are shaped as much by what operations you're allowed as by what's fastest.

The pattern to notice

Whenever a problem only asks for a boundary, a threshold, or one specific rank — not a full order — that's the signal partial sorting (quickselect) or a targeted partition (parity split) will beat a full sort. And whenever a problem restricts *how* you're allowed to rearrange things, the allowed operation itself usually dictates the algorithm's shape.

Custom comparators — and the trap of assuming numeric order

Largest Number is the clearest lesson in this topic on why "sort descending" isn't always well-defined without saying descending by what. Comparing 3 and 30 numerically puts 30 first — but "330" loses to "303" as a concatenated number. The fix is a comparator that directly answers the question that matters: does a + b or b + a produce the larger string? Relative Sort Array and Custom Sort String need a "sort by this other order" too, but their answer is different: since their value ranges are small and fixed (0–1000; the 26 lowercase letters), counting sort with a custom bucket sequence beats a comparator-based sort outright, at O(n + k) instead of O(n log n).

Beating O(n log n): counting, bucketing, and pigeonholes

This is where the "sorting" label gets misleading. Relative Sort Array and Custom Sort String both need a "sort by this other order," and since their value ranges are small and fixed (0–1000; the 26 lowercase letters), counting sort with a custom bucket sequence beats a comparator-based sort outright, at O(n + k) instead of O(n log n). Maximum Gap is the sharpest example: it needs the largest gap between sorted-adjacent elements, but never needs the full sorted order to get there. Splitting the value range into exactly n−1 buckets guarantees — by the pigeonhole principle — that at least one bucket is empty, which means the true maximum gap can only occur between buckets, never within one. That turns an O(n log n) sort into an O(n) scan of bucket boundaries.

When sorting first turns a hard question into an easy scan

H-Index and Third Maximum Number both show that a full sort isn't always the cheapest way to use "sortedness." H-Index sorts citations descending, then a single linear scan finds the crossover point — turning a question that sounds combinatorial ("how many papers have at least this many citations?") into one pass over already-ordered data. Third Maximum Number skips sorting entirely: since only the top 3 distinct values ever matter, tracking three running values in one pass beats an O(n log n) sort outright — the same "you don't need the whole order, just a small piece of it" idea as quickselect, taken to its logical extreme. Wiggle Sort II shows the "sort first" instinct needs a real subtlety on top: sorting and then naively interleaving front-to-back breaks the moment there are duplicate values, and the fix — filling both halves back-to-front — is specifically what keeps equal values from landing adjacent to each other.

Common mistakes

  • Assuming quicksort's average case is its worst case — an adversarial or already-sorted input can degrade naive quicksort to O(n^2); merge sort's guaranteed O(n log n) is why it's the safer default when a real worst-case bound matters.
  • Comparing numbers numerically when the real question is about concatenation — Largest Number's a + b > b + a comparator exists specifically because numeric or length-based comparisons give the wrong order.
  • Reaching for a full sort when only a rank, boundary, or a handful of top values is actually needed — Kth Largest, Sort Array by Parity, and Third Maximum Number are all faster without ever producing a total order.
  • Interleaving a sorted array front-to-back for Wiggle Sort — this looks reasonable but places equal adjacent values next to each other the moment duplicates are heavy; filling both halves back-to-front avoids it.
  • Using a same-width sentinel value to mean "not set yet" — Third Maximum Number's classic trap is using INT_MIN as an int placeholder for "no value yet," which collides with an array that genuinely contains INT_MIN; a wider sentinel type sidesteps the collision.
  • Missing that a small, fixed value range means counting sort beats comparison sort — Relative Sort Array and Custom Sort String are both faster at O(n + k) than a general O(n log n) comparator sort, once the range is bounded by something smaller than n.

Takeaways

  • Comparison sorting has a real O(n log n) floor — beating it requires a problem that's secretly about counting or partitioning, not comparing.
  • A custom comparator needs to directly answer the actual ordering question (concatenation, a fixed external order) — not just "ascending" or "descending" by value.
  • When a problem only needs a rank, boundary, or partition — not a full order — quickselect or a targeted partition beats a full sort.
  • Sorting first can turn a hard-looking question into a simple linear scan, as long as you watch for the resulting edge cases (duplicates, in-place overwrite direction) it introduces.

Try it: a guaranteed O(n log n) sort

Sort an Array — implement merge sort from scratch, no library sort allowed.

Live

Loading starter code…

Try it: a comparator that isn't numeric order

Largest Number — work through the classic 3-vs-30 trap before checking the hint.

Live

Loading starter code…

Try it: beating O(n log n) with pigeonholes

Maximum Gap — the sharpest example in this topic of a sorting-shaped problem that isn't really about comparisons at all.

Live

Loading starter code…

Keep going

That's 3 of the 12 problems in this topic. Kth Largest Element is worth doing next — it's the clearest demonstration of quickselect's "only recurse into one side" idea. See the full Sorting set →

Checkpoint · Sorting

5 questions · pass at 70%

Q01
Sort an Array uses merge sort rather than a typical quicksort implementation. Given that quicksort's average-case time is also O(n log n), why prefer merge sort here?
WhyQuicksort's expected O(n log n) time relies on reasonably balanced partitions -- a bad pivot choice on certain inputs can produce highly unbalanced partitions, degrading to O(n^2) in the worst case. Merge sort's recursion always splits the array exactly in half regardless of the data's contents, guaranteeing O(log n) recursion depth and therefore a real worst-case O(n log n) bound, which is why it's the safer choice when a guaranteed bound (not just an average) matters.
Q02
Largest Number sorts numbers-as-strings using a comparator that checks whether a + b > b + a, rather than sorting the numbers in descending numeric order. Why does descending numeric order give the wrong answer?
WhyThe actual question is 'which arrangement produces the largest combined string,' not 'which numbers are numerically larger.' Comparing 3 and 30 numerically ranks 30 first, but the concatenation "303" is smaller than "330" -- so numeric ordering can directly contradict the correct concatenation ordering. The a + b > b + a comparator sidesteps the issue by directly comparing the two possible concatenations against each other, which is exactly the question being asked.
Q03
Kth Largest Element in an Array uses quickselect (average O(n)) instead of fully sorting the array (O(n log n)) and reading off one position. What makes quickselect faster despite reusing quicksort's own partition step?
WhyQuicksort recurses into both partitions because it needs the ENTIRE array sorted. Quickselect only needs one specific rank, so after partitioning, it can tell which single side contains that rank and discard the other side completely, without ever needing to sort it. Doing only half (roughly) the work at each level, repeatedly, is what sums to an expected O(n) total instead of the O(n log n) a full sort would need.
Q04
Maximum Gap finds the largest gap between sorted-adjacent elements in O(n) time by splitting the value range into exactly n-1 buckets, without ever fully sorting the array. What guarantees this approach is correct?
WhyWith n-1 buckets sized to evenly span the value range, and only the n-2 'interior' values (excluding the global min and max, which are handled separately as boundaries) being placed into buckets, the pigeonhole principle guarantees at least one bucket is empty. That empty bucket is exactly where the maximum gap must occur -- between the end of one non-empty bucket and the start of the next -- because no gap that stays entirely within a single bucket could exceed the bucket's own width, which is smaller than the true maximum gap by construction.
Q05
Wiggle Sort II sorts the array and then fills the result's even indices from the smaller half IN REVERSE, and odd indices from the larger half IN REVERSE, rather than just interleaving the two sorted halves front-to-back. Why does the reversed, back-to-front filling matter?
WhyWith heavy duplicates, naively interleaving the two sorted halves front-to-back can place equal values from the boundary of the two halves directly adjacent to each other in the result, breaking the required strict inequality. Filling both halves starting from their far ends and working backward pushes equal values as far apart as possible in the output, which is exactly what preserves the strict wiggle property even in duplicate-heavy inputs.

Finished Sorting?

Pass the quiz to complete it automatically.