πŸ”ƒ Sorting

A learning sheet β€” you'll rarely write a sort, but you must know each one's guarantees, the divide-and-conquer subroutines, and the meta-move: "sort first, and the problem collapses."

🧠 1 · How to think (and invent it)

Sorting wears two hats. Hat 1 β€” the toolbox: the library sorts in O(n log n), so you usually just call it β€” but you must know which sort it uses and its guarantees (stable? in-place? worst case?), because those decide correctness. Hat 2 β€” the weapon: a huge class of problems isn't "sort," but becomes trivial once sorted β€” enabling two pointers, greedy, sweep lines, or a custom comparator.

Underneath the named algorithms sit three reusable subroutines that show up as building blocks far beyond sorting: the merge (combine two sorted runs), the partition (split around a pivot), and quickselect (find the k-th element without fully sorting).

Watch it get invented β€” Merge Sort (divide & conquer)

  1. Start brute. Insertion/selection sort is O(nΒ²) β€” every element compared against many others.
  2. The leap. Split the array in half, sort each half (recursively), then notice: two already-sorted halves can be stitched into one sorted whole in a single linear pass β€” the merge step (a two-sequence two-pointer).
  3. The recurrence. T(n) = 2Β·T(n/2) + O(n) β†’ O(n log n). Log levels of halving, each level doing O(n) of merging.
  4. Payoff. Guaranteed O(n log n), and stable (keep <= in the merge) β€” the cost is O(n) extra space.
[3, 1, 4, 2] [3, 1][4, 2] [3][1] [4][2] [1, 3][2, 4] [1, 2, 3, 4] split down… …merge up
Split until trivial, then merge sorted runs back up. The merge is the only real work.
Mental checklist Before reaching for any sort, ask: (1) do I need the whole thing sorted, or just the top-k / k-th? (2) is the data a bounded integer range (β†’ linear sort)? (3) do I need stability? (4) is the real problem actually "sort first, then sweep/two-pointer"?

🎯 2 · Attack plan

AskIf yes β†’Pattern
Need stable and/or guaranteed O(n log n) (or sorting a linked list)?merge sortMerge sort (Β§4.1)
Want in-place, fast average, stability not needed?quicksort / the library defaultQuicksort (Β§4.2)
Only need the k-th element or top-k by value?don't sort β€” partition around itQuickselect (Β§4.3)
Keys are a small bounded range of integers?count / bucket / radix β†’ O(n)Linear sorts (Β§4.4)
Not "sort," but easy once sorted?sort, then two-pointer / greedy / sweep / comparatorSort-as-prep (Β§4.5)
When sorting is the WRONG / overkill tool Β· You only need top-k or the k-th β†’ quickselect (O(n) avg) or a heap (O(n log k)) beat a full O(n log n) sort.
Β· Keys are bounded integers β†’ counting/radix is O(n); the n log n lower bound only binds comparison sorts.
Β· Data is streaming / you must preserve original indices β†’ use a heap, or sort indices / pair with a tie-break.

πŸ”Ž 3 Β· Recognition map

Words in the promptReach for
"stable", "count inversions", "merge sorted", "sort a linked list"Merge Β§4.1
"in-place", "sort the array" (generic), "partition around a pivot"Quicksort Β§4.2
"k-th largest/smallest", "top k closest/frequent", "median of a stream"Quickselect Β§4.3 (or heap)
"sort colors / 0-1-2", "values in [0, k]", "bucket", "radix", "maximum gap"Linear Β§4.4
"merge intervals", "meeting rooms", "largest number", "group anagrams", "3Sum"Sort-as-prep Β§4.5

🧩 4 · The five core patterns

Β§4.1–4.3 are the divide-and-conquer trio (merge, partition, select). Β§4.4–4.5 are the "beat or avoid the sort" moves. Each: a one-liner, a πŸ”‘ invariant, a template, a 🎀 interview lens.

4.1 Β· Merge sort

β€œSort each half, then zip the two sorted runs together.”

πŸ”‘ Invariant β€” why it's safe Each recursive call returns a fully-sorted sub-array. The merge always emits the smaller of the two current heads, so its output is sorted and contains every element β€” and using <= on ties preserves left-before-right order (stability).
function mergeSort(a: number[]): number[] {
  if (a.length <= 1) return a;
  const mid = a.length >> 1;
  return merge(mergeSort(a.slice(0, mid)), mergeSort(a.slice(mid)));
}
// the merge step IS a two-sequence two-pointer
function merge(a: number[], b: number[]): number[] {
  const out: number[] = []; let i = 0, j = 0;
  while (i < a.length && j < b.length) out.push(a[i] <= b[j] ? a[i++] : b[j++]); // <= β†’ stable
  while (i < a.length) out.push(a[i++]);
  while (j < b.length) out.push(b[j++]);
  return out;
}
🎀 Interview lens Probed: stability, the O(n) space, and the O(n log n) recurrence. Β· Escalation: sort β†’ "Count Inversions" (count cross-pairs during merge) β†’ "Merge k Sorted Lists" β†’ external merge sort for data that doesn't fit in memory.

4.2 Β· Quicksort & partition

β€œPick a pivot, shove smaller-left / bigger-right, recurse on each side.”

Setup: the partition is the reusable jewel β€” it places the pivot at its final sorted index and returns it. Quicksort just recurses on both sides; quickselect (Β§4.3) recurses on one.

2 1 7 6 3 < pivot β‰₯ pivot pivot = 3 pivot lands at its final sorted index
One sweep partitions around the pivot; the pivot is now correct forever, and the two sides are independent.
πŸ”‘ Invariant β€” why it's safe During the sweep, a[lo..i) are all < pivot and a[i..j) are all β‰₯ pivot. Swapping the pivot into slot i at the end puts it exactly where it belongs, so each side can be sorted independently.
function quickSort(a: number[], lo = 0, hi = a.length - 1): void {
  if (lo >= hi) return;
  const p = partition(a, lo, hi);
  quickSort(a, lo, p - 1);
  quickSort(a, p + 1, hi);
}
// Lomuto partition; randomize the pivot to dodge the O(nΒ²) worst case
function partition(a: number[], lo: number, hi: number): number {
  const r = lo + Math.floor(Math.random() * (hi - lo + 1));
  [a[r], a[hi]] = [a[hi], a[r]];          // random pivot β†’ swap to the end
  const pivot = a[hi]; let i = lo;
  for (let j = lo; j < hi; j++) if (a[j] < pivot) { [a[i], a[j]] = [a[j], a[i]]; i++; }
  [a[i], a[hi]] = [a[hi], a[i]];
  return i;
}
🎀 Interview lens Probed: the O(nΒ²) worst case (sorted input + last-element pivot) and the random-pivot fix; that it's in-place but not stable. Β· Escalation: partition β†’ quickselect (Β§4.3) β†’ 3-way partition (Dutch flag) when there are many duplicate keys.

4.3 Β· Quickselect β€” the k-th without sorting

β€œPartition, then recurse into only the side that holds k.”

Setup: a full sort is wasteful if you only need rank k. After a partition, the pivot's final index tells you which side k is on β€” keep only that side.

πŸ”‘ Invariant β€” why it's O(n) average Each partition discards (on average) half the remaining elements, so work is n + n/2 + n/4 + … = O(n) (a geometric series, unlike sort's O(n log n)). Because you recurse into one side, the log factor disappears.
// k-th SMALLEST (0-indexed k); for k-th largest use a.length-1-k
function quickselect(a: number[], k: number): number {
  let lo = 0, hi = a.length - 1;
  while (true) {
    const p = partition(a, lo, hi);   // same partition as Β§4.2
    if (p === k) return a[p];
    if (p < k) lo = p + 1;            // k is to the right
    else hi = p - 1;                  // k is to the left
  }
}
🎀 Interview lens Probed: the average O(n) geometric-series argument and the O(nΒ²) worst case (random pivot or median-of-medians for a guaranteed bound). Β· Escalation: k-th largest β†’ "Top K Frequent Elements" (or bucket sort) β†’ "K Closest Points to Origin."

4.4 Β· Non-comparison (linear) sorts

β€œBounded integer keys? Count them β€” no comparisons, O(n).”

Setup: comparison sorts are stuck at Ξ©(n log n). If keys are integers in a small range, you can tally instead of compare and beat that bound.

πŸ”‘ Invariant β€” why it's safe count[v] is the exact number of times v appears. Emitting values 0, 1, 2, … in order, each repeated its count, produces a sorted array β€” correctness comes from the range being known and small, not from comparisons.
// Counting sort β€” keys in [0, maxVal]; O(n + maxVal)
function countingSort(a: number[], maxVal: number): number[] {
  const count = new Array(maxVal + 1).fill(0);
  for (const x of a) count[x]++;
  const out: number[] = [];
  for (let v = 0; v <= maxVal; v++) while (count[v]-- > 0) out.push(v);
  return out;
}
🎀 Interview lens Probed: when O(n) actually beats O(n log n) (range β‰ͺ n log n), and counting sort's stability if you emit by prefix-sum position. Β· Escalation: counting β†’ "Sort Colors" (3-way Dutch flag, one pass) β†’ bucket sort for "Top K Frequent" β†’ radix sort ("Maximum Gap").

4.5 Β· Sort-as-preprocessing (the meta-move)

β€œThe problem isn't sorting β€” but sorting makes it fall over.”

Setup: sorting imposes order, which unlocks linear follow-ups: two pointers, greedy-by-order, sweep lines, grouping, or a custom comparator that defines the order you actually need.

// Largest Number β€” order so that concatenation is biggest (custom total order)
function largestNumber(nums: number[]): string {
  const s = nums.map(String).sort((x, y) => (y + x).localeCompare(x + y)); // "x then y" vs "y then x"
  return s[0] === '0' ? '0' : s.join('');   // all zeros β†’ "0"
}
The "sort unlocks…" menu two pointers: 3Sum, pair-sum on sorted (two-pointers). greedy by order: assign meetings, schedule, activity selection. sweep line: merge intervals, meeting rooms (sort by start; see the Intervals sheet). grouping: group anagrams (sorted string as the key). custom comparator: reconstruct queue, largest number.
🎀 Interview lens Probed: define the comparator's total order and argue it's transitive (a non-transitive comparator silently corrupts the sort). Β· Escalation: largest number β†’ "Merge Intervals" / "Meeting Rooms II" (sort + sweep) β†’ "Reconstruct the Queue" β†’ "Group Anagrams."

⏱️ 5 · The sort table

AlgorithmBestAvgWorstSpaceStable?In-place?
InsertionO(n)O(nΒ²)O(nΒ²)O(1)βœ…βœ…
MergeO(n log n)O(n log n)O(n log n)O(n)βœ…βŒ
QuicksortO(n log n)O(n log n)O(nΒ²)O(log n)βŒβœ…
HeapsortO(n log n)O(n log n)O(n log n)O(1)βŒβœ…
Counting / RadixO(n+k)O(n+k)O(n+k)O(n+k)βœ…βŒ
Quickselect (k-th)O(n)O(n)O(nΒ²)O(1)β€”βœ…
What to actually remember The library sort is O(n log n) (Timsort: stable; introsort: not). Reach for merge when you need stability/guarantees, quickselect/heap for top-k, counting/radix for bounded integers. The Ξ©(n log n) floor is for comparison sorts only.

⚠️ 6 Β· Pitfalls β€” and the test that catches each

BugThe input that exposes it
Quicksort worst case from a fixed (last-element) pivot.Already-sorted [1,2,…,n] β†’ O(nΒ²) / stack overflow. Fix: random or median-of-three pivot.
JS default sort is lexicographic, not numeric.[10, 2, 1].sort() β†’ [1, 10, 2]. Pass a numeric comparator (a,b)=>a-b.
Assuming stability from an unstable sort.Sort records by one key expecting prior order kept β†’ ties reorder unexpectedly.
Counting sort with negatives or a huge range.Negative keys β†’ index out of bounds; range 10⁹ β†’ out of memory.
Non-transitive custom comparator.A comparator where a<b, b<c, but c<a β†’ engine produces garbage / throws.
Full sort when only top-k is needed."k-th largest of 10⁷ items" β†’ O(n log n) when quickselect/heap is faster; flagged in review.
πŸ”— This pattern, elsewhere The merge step is the two-sequence pointer from two-pointers.html; 3-way partition is its read/write cousin (Dutch flag). Quickselect "recurse one side" is the same discard-half idea as binary search. Heapsort & top-k lean on a heap (heap: streaming; quickselect: one-shot); merge sort's recursion lives in recursion.html. Sort-then-sweep is the gateway to intervals.
βž• Sorting-adjacent tricks β€” First Missing Positive (41) Β· Car Fleet (853) Β· Longest Consecutive (128) Β· Disappeared Numbers (448)
When the values are bounded by n the array can be its own hash table (41, 448) β€” cyclic sort or sign-marking, O(n) with no sort. 128 is "don't sort, use a set and start only at sequence heads". 853 is sort-then-one-pass.
// 41 First Missing Positive β€” cyclic sort: put v at index v-1
function firstMissingPositive(nums: number[]): number {
  const n = nums.length;
  for (let i = 0; i < n; i++) {
    while (nums[i] >= 1 && nums[i] <= n && nums[nums[i] - 1] !== nums[i]) {
      const j = nums[i] - 1, tmp = nums[i];          // temp swap β€” [nums[i], nums[nums[i]-1]] = ... reads the modified nums[i]
      nums[i] = nums[j]; nums[j] = tmp;
    }
  }
  for (let i = 0; i < n; i++) if (nums[i] !== i + 1) return i + 1;
  return n + 1;
}

// 853 Car Fleet β€” sort by position (closest to target first), compare arrival times
function carFleet(target: number, position: number[], speed: number[]): number {
  const cars = position.map((p, i) => [p, (target - p) / speed[i]]).sort((a, b) => b[0] - a[0]);
  let fleets = 0, lead = 0;
  for (const [, t] of cars) if (t > lead) { fleets++; lead = t; }   // slower than the fleet ahead β†’ new fleet
  return fleets;
}

// 128 Longest Consecutive Sequence β€” O(n): only count from sequence HEADS
function longestConsecutive(nums: number[]): number {
  const set = new Set(nums); let best = 0;
  for (const x of set) {
    if (set.has(x - 1)) continue;                    // not a head
    let len = 1; while (set.has(x + len)) len++;
    best = Math.max(best, len);
  }
  return best;
}

// 448 Find All Numbers Disappeared β€” mark presence by negating nums[v-1]
function findDisappearedNumbers(nums: number[]): number[] {
  for (const x of nums) { const i = Math.abs(x) - 1; if (nums[i] > 0) nums[i] = -nums[i]; }
  const res: number[] = [];
  nums.forEach((v, i) => { if (v > 0) res.push(i + 1); });
  return res;
}
⚠️ Traps 41: compare the target slot's value in the while condition (comparing to i loops forever on duplicates). 853: merge on t ≀ lead. 128: skip non-heads or it is O(nΒ²). 448: read with Math.abs, negate only if still positive.

πŸ“Š Coverage β€” pattern β†’ LeetCode (medium & hard)

Own each pattern and these are in range. Bold = Hard tier.

PatternRepresentative LeetCodeTier
Merge sort912 Sort an Array Β· 148 Sort List Β· 315 Count of Smaller Β· 493 Reverse PairsMed β†’ Hard
Quick sort912 Sort an Array Β· 75 Sort ColorsMed
Quickselect215 Kth Largest Β· 973 K Closest Points Β· 347 Top K FrequentMed
Linear (counting / radix)1122 Relative Sort Β· 164 Maximum Gap Β· 451 Sort by Frequency Β· 41 First Missing Positive Β· 448 Disappeared NumbersMed β†’ Hard
Sort as prep56 Merge Intervals Β· 252/253 Meeting Rooms Β· 179 Largest Number Β· 853 Car Fleet Β· 128 Longest Consecutive (set beats sort)Med

βœ… 7 Β· Self-check

Cover the right column (blurred β€” hover/tap to reveal). Say your answer out loud first.

PromptAnswer
What three properties decide which sort you pick?Stable? In-place? Worst-case bound? (Plus: is the data bounded-range or do you only need top-k?)
Why is merge sort O(n log n) and what does it cost?log n levels of halving Γ— O(n) merging per level; it costs O(n) extra space (and is stable).
What does the partition guarantee after one call?The pivot is at its final sorted index; everything left is < it, everything right is β‰₯ it. The two sides are independent.
Why is quickselect O(n) average but sort is O(n log n)?Quickselect recurses into one side (n + n/2 + … = O(n)); sort recurses into both, adding the log factor.
When does an O(n) sort exist?Non-comparison sorts (counting/radix) on bounded integer keys β€” the Ξ©(n log n) floor is for comparison sorts only.
What must a custom comparator be, or the sort breaks?A consistent total order β€” in particular transitive; and in JS it must return a number, not a boolean.
Name three problems that "collapse" once sorted.3Sum (sort + two-pointer), Merge Intervals (sort + sweep), Group Anagrams (sorted-string key). Many more.
First Missing Positive β€” why is the answer always in [1, n+1]?n numbers can cover at most 1..n; if all of them are present the answer is n+1. That bound lets the array itself act as the hash table.
Longest Consecutive Sequence β€” why is it O(n) with a while inside a for?You only start counting at a sequence head (xβˆ’1 absent), so every element is walked at most once from its head.

Write these cold (no peeking)

For each: state the guarantee (stable? in-place? worst case?) as a comment before the code.