GUIDE · Sorting

Sorting in practice

comparison sorts (n log n floor) vs non-comparison sorts (linear on constrained keys).

lesson12 sectionsruns in browser35 asserts188 lines

At a glance

WHAT
comparison sorts (n log n floor) vs non-comparison sorts (linear on constrained keys).
TELL
"kth smallest/largest" (quickselect), "small integer range" (counting), "nearly sorted" (insertion), "stable" (merge/TimSort), "in place O(1) space" (heap/quick), "top N".
SAY
comparatorstablein-placedivide and conquerpivotpartition (Lomuto / Hoare)quickselectcounting sortbucket sortheapifyTimSort
BIG-O
merge/heap O(n log n) always; quick O(n log n) avg O(n^2) worst; counting O(n + k).
NOTE
Array.prototype.sort is TimSort in V8 (stable since ES2019). Default comparator is STRING order: [10, 9, 1].sort() -> [1, 10, 9]. Always pass (a, b) => a - b for numbers.

The idea#

Picture a class lining up for a photo. The photographer shouts one rule: shortest in front. That shout is the whole sort. Change it to "tallest in front" or "last name first" and the same kids form a different line. The shout is a comparator. It looks at two kids and answers with a sign. Negative means the first kid stands ahead. Zero means leave them where they already stand. Positive means the second kid stands ahead.

A hundred kids still have to move. You can split the mob in half, get each half into order, and zipper the two ordered lines together. That zipper is merge sort. You can point at one kid, Jordan, and send everyone shorter to Jordan's left and everyone taller to Jordan's right, then do the same inside each side. Jordan is the pivot. The step that plants Jordan in a final spot is the partition. Lomuto parks the pivot at the end and scans once. Hoare walks two fingers inward and returns a split point.

Those sorts only compare. If the heights are small whole numbers, you can tally instead: three kids at 48 inches, five at 49, then read the tally back out. That is counting sort. The line is the picture. The shout, and whether you zipper, pivot, or tally, is the algorithm.

Why it matters#

The built-in array sort is TimSort, stable since ES2019, so equal items stay in the order you handed them. The default comparator sorts as strings. [10, 9, 1].sort() comes back [1, 10, 9], because "10" comes before "9". For numbers, pass (a, b) => a - b.

You reach for a particular sort when the problem names the constraint. "kth smallest" or "kth largest" is quickselect: partition once and walk into one side. A small integer range is counting sort. Nearly sorted data is insertion sort, the way you sort a hand of cards one card at a time. "Stable" points at merge sort or TimSort. "In place, constant extra space" points at heap sort or quick sort. "Top N" is a sort plus a slice, and a heap of size N is the tighter tool once the list is large.

Merge sort and heap sort are O(n log n) every time. Quick sort is O(n log n) on average and O(n²) in the worst case. Counting sort is O(n + k), where k is the size of the key range.

A small example#

JS
function mergeSorted(left, right) {  const out = [];  let i = 0;  let j = 0;  while (i < left.length && j < right.length) {    if (left[i] <= right[j]) out.push(left[i++]);    else out.push(right[j++]);  }  while (i < left.length) out.push(left[i++]);  while (j < right.length) out.push(right[j++]);  return out;} const line = mergeSorted([1, 4, 8], [2, 3, 9]);

<= keeps an equal from the left line ahead of an equal from the right. That is stability on two already-sorted halves. line is [1, 2, 3, 4, 8, 9].

What the code below is doing#

The sheet opens with the comparator recap, including the string-order trap, numeric ascending, descending, and localeCompare. Merge sort splits, sorts, and merges: stable, always O(n log n), O(n) extra space. Quick sort partitions and recurses both sides. A random pivot swap guards the sorted-input case, where a fixed end pivot degrades to quadratic. It is unstable, with O(log n) stack when the splits stay balanced.

Quickselect is that same partition, then one side only, for the kth smallest: average linear, worst quadratic. Counting sort tallies count[v - min] and emits, and a prefix-sum variant of the tally makes it stable. Bucket sort drops values into k buckets by range, sorts each, and concatenates, average O(n + k). Heap sort heapifies, then parks the root at the end and sifts down: always O(n log n), constant extra space, unstable. Insertion sort grows a sorted prefix and shifts larger items right: quadratic at worst, linear when the line is nearly sorted, stable.

The "when to choose which" block is the menu in one place: stable and guaranteed n log n, in-place and fast on average, guaranteed n log n with constant space, small integer keys, uniform floats in a unit range, tiny or nearly sorted, or kth only. Top-N sorts by a custom key and slices, with a note that a heap is O(n log N) for large n. Sort colors is the Dutch national flag, a 3-way partition in one linear pass. Merge intervals is the application: sort by start, then extend the last block when the next one overlaps.

When you talk about it, say this: name the comparator, say whether you need stable or in place, and pick the algorithm from the constraint.

Everything below is the demonstration. Same idea, written as code you can run.

JSalgorithms/sorting.js · setup
'use strict';const assert = require('node:assert/strict');
01

Comparator recap#

negative => a first; zero => keep order (stable); positive => b first

JS
const people = [{ n: 'a', age: 30 }, { n: 'b', age: 25 }, { n: 'c', age: 30 }];// stable: equal keys keep input order
Verified by 7 asserts
JS
assert.deepEqual([10, 9, 1].sort(), [1, 10, 9]); // the trapassert.deepEqual([10, 9, 1].sort((a, b) => a - b), [1, 9, 10]); // numbers ascassert.deepEqual([10, 9, 1].sort((a, b) => b - a), [10, 9, 1]); // descassert.deepEqual(['b', 'a', 'C'].sort((a, b) => a.localeCompare(b)), ['a', 'b', 'C']); // strings, locale-awareassert.deepEqual(['b', 'a', 'C'].sort(), ['C', 'a', 'b']); // default: UTF-16 code unitsassert.deepEqual( // multi-key: by age desc, then name asc; `||` chains tie-breakers  people.sort((x, y) => y.age - x.age || x.n.localeCompare(y.n)).map((p) => p.n), ['a', 'c', 'b']);assert.deepEqual([{ k: 1, id: 'x' }, { k: 0, id: 'y' }, { k: 1, id: 'z' }].sort((a, b) => a.k - b.k).map((o) => o.id), ['y', 'x', 'z']);
02

Merge sort#

split in half, sort each, merge

O(n log n) / O(n), stable
JS
function mergeSort(a) {  if (a.length <= 1) return a;  const mid = a.length >> 1;  const L = mergeSort(a.slice(0, mid)), R = mergeSort(a.slice(mid));  const out = [];  let i = 0, j = 0;  while (i < L.length && j < R.length) out.push(L[i] <= R[j] ? L[i++] : R[j++]); // <= keeps stability  return out.concat(L.slice(i), R.slice(j));}
Verified by 3 asserts
JS
assert.deepEqual(mergeSort([5, 2, 4, 6, 1, 3]), [1, 2, 3, 4, 5, 6]);assert.deepEqual(mergeSort([-1, -5, 0]), [-5, -1, 0]);assert.deepEqual(mergeSort([]), []);
03

Quick sort#

partition around pivot, recurse both sides

O(n log n) avg, O(n^2) worst / O(log n) stack, not stable
JS
//        Lomuto: pivot at end, single scan, returns pivot's final index. (implemented below)//        Hoare : pivots in middle, two pointers converge, returns split point (not pivot index); fewer swaps.function lomuto(a, lo, hi) {  const p = a[hi];  let i = lo;  for (let j = lo; j < hi; j++) if (a[j] < p) { [a[i], a[j]] = [a[j], a[i]]; i++; }  [a[i], a[hi]] = [a[hi], a[i]];  return i;}function quickSort(a, lo = 0, hi = a.length - 1) {  if (lo >= hi) return a;  // random pivot swap defends against sorted-input O(n^2)  const r = lo + Math.floor(Math.random() * (hi - lo + 1));  [a[r], a[hi]] = [a[hi], a[r]];  const p = lomuto(a, lo, hi);  quickSort(a, lo, p - 1);  quickSort(a, p + 1, hi);  return a;}
Verified by 3 asserts
JS
assert.deepEqual(quickSort([3, 6, 1, 8, 2, 9, 2]), [1, 2, 2, 3, 6, 8, 9]);assert.deepEqual(quickSort([1]), [1]);assert.deepEqual(quickSort([]), []);
04

Quickselect (kth smallest, 0-indexed)#

partition once, recurse into ONE side

O(n) avg, O(n^2) worst / O(1)
JS
function quickselect(a, k) {  a = [...a];  let lo = 0, hi = a.length - 1;  while (lo <= hi) {    const r = lo + Math.floor(Math.random() * (hi - lo + 1));    [a[r], a[hi]] = [a[hi], a[r]];    const p = lomuto(a, lo, hi);    if (p === k) return a[p];    if (p < k) lo = p + 1; else hi = p - 1;  }  return undefined;}const kthLargest = (a, k) => quickselect(a, a.length - k);
Verified by 3 asserts
JS
assert.equal(quickselect([3, 2, 1, 5, 6, 4], 1), 2);assert.equal(kthLargest([3, 2, 1, 5, 6, 4], 2), 5);assert.equal(kthLargest([3, 2, 3, 1, 2, 4, 5, 5, 6], 4), 4); // duplicates
05

Counting sort#

count[v - min]++, then emit

O(n + k) / O(k), stable with prefix-sum variant
JS
function countingSort(a) {  if (a.length === 0) return [];  const min = Math.min(...a), max = Math.max(...a);  const count = new Array(max - min + 1).fill(0);  for (const x of a) count[x - min]++;  const out = [];  for (let v = 0; v < count.length; v++) for (let c = 0; c < count[v]; c++) out.push(v + min);  return out;}
Verified by 3 asserts
JS
assert.deepEqual(countingSort([4, 2, 2, 8, 3, 3, 1]), [1, 2, 2, 3, 3, 4, 8]);assert.deepEqual(countingSort([-2, 0, -2]), [-2, -2, 0]);assert.deepEqual(countingSort([]), []);
06

Bucket sort#

distribute into k buckets by value range, sort each, concat

O(n + k) avg / O(n)
JS
function bucketSort(a, k = 5) {  if (a.length === 0) return [];  const min = Math.min(...a), max = Math.max(...a), span = (max - min) / k || 1;  const buckets = Array.from({ length: k }, () => []);  for (const x of a) buckets[Math.min(k - 1, Math.floor((x - min) / span))].push(x);  return buckets.flatMap((b) => b.sort((x, y) => x - y));}
Verified by 2 asserts
JS
assert.deepEqual(bucketSort([0.42, 0.32, 0.23, 0.52, 0.25, 0.47, 0.51]), [0.23, 0.25, 0.32, 0.42, 0.47, 0.51, 0.52]);assert.deepEqual(bucketSort([5, 5, 5]), [5, 5, 5]);
07

Heap sort sketch#

heapify (O(n)), then repeatedly swap root to end + sift down

O(n log n) / O(1), not stable
JS
function heapSort(a) {  const n = a.length;  const sift = (i, size) => {    for (;;) {      let big = i;      const l = 2 * i + 1, r = 2 * i + 2;      if (l < size && a[l] > a[big]) big = l;      if (r < size && a[r] > a[big]) big = r;      if (big === i) return;      [a[i], a[big]] = [a[big], a[i]];      i = big;    }  };  for (let i = (n >> 1) - 1; i >= 0; i--) sift(i, n); // build max-heap bottom-up  for (let end = n - 1; end > 0; end--) { [a[0], a[end]] = [a[end], a[0]]; sift(0, end); }  return a;}
Verified by 3 asserts
JS
assert.deepEqual(heapSort([5, 3, 8, 1, 9, 2]), [1, 2, 3, 5, 8, 9]);assert.deepEqual(heapSort([2, 2, 1]), [1, 2, 2]);assert.deepEqual(heapSort([]), []);
08

Insertion sort#

grow a sorted prefix; shift larger elements right

O(n^2) worst, O(n) nearly sorted / O(1), stable
JS
//        SAY: used for small n (TimSort/introsort switch to it under ~32) and for online / nearly-sorted input.function insertionSort(a) {  for (let i = 1; i < a.length; i++) {    const x = a[i];    let j = i - 1;    while (j >= 0 && a[j] > x) { a[j + 1] = a[j]; j--; }    a[j + 1] = x;  }  return a;}
Verified by 3 asserts
JS
assert.deepEqual(insertionSort([4, 3, 2, 10, 12, 1, 5, 6]), [1, 2, 3, 4, 5, 6, 10, 12]);assert.deepEqual(insertionSort([1, 2, 3]), [1, 2, 3]);assert.deepEqual(insertionSort([]), []);
09

When to choose which#

JS
//   need stable + guaranteed n log n  -> merge sort (or built-in sort)//   in-place, avg fast, cache-friendly -> quick sort (random pivot)//   guaranteed n log n + O(1) space    -> heap sort//   small integer keys, range k ~ n    -> counting / radix//   uniform floats in [0,1)            -> bucket//   n < ~32 or nearly sorted           -> insertion//   kth element only                   -> quickselect (or heap of size k: O(n log k))
10

Sort by custom key then slice (top-N)#

O(n log n); for large n prefer heap O(n log N)

JS
const topN = (arr, n, key) => [...arr].sort((a, b) => key(b) - key(a)).slice(0, n);
Verified by 2 asserts
JS
assert.deepEqual(topN([{ s: 3 }, { s: 9 }, { s: 5 }], 2, (o) => o.s), [{ s: 9 }, { s: 5 }]);assert.deepEqual(topN([], 2, (o) => o.s), []);
11

Sort colors (Dutch national flag)#

3-way partition in one pass

O(n) / O(1)
JS
function sortColors(a) {  let lo = 0, mid = 0, hi = a.length - 1;  while (mid <= hi) {    if (a[mid] === 0) [a[lo++], a[mid++]] = [a[mid], a[lo]];    else if (a[mid] === 2) [a[mid], a[hi--]] = [a[hi], a[mid]];    else mid++;  }  return a;}
Verified by 2 asserts
JS
assert.deepEqual(sortColors([2, 0, 2, 1, 1, 0]), [0, 0, 1, 1, 2, 2]);assert.deepEqual(sortColors([1]), [1]);
12

Merge intervals (sorting application)#

sort by start; extend last if overlapping

O(n log n) / O(n)
JS
function mergeIntervals(iv) {  iv = [...iv].sort((a, b) => a[0] - b[0]);  const out = [];  for (const [s, e] of iv) {    if (out.length && s <= out[out.length - 1][1]) out[out.length - 1][1] = Math.max(out[out.length - 1][1], e);    else out.push([s, e]);  }  return out;}
Verified by 3 asserts
JS
assert.deepEqual(mergeIntervals([[1, 3], [2, 6], [8, 10], [15, 18]]), [[1, 6], [8, 10], [15, 18]]);assert.deepEqual(mergeIntervals([[1, 4], [4, 5]]), [[1, 5]]);assert.deepEqual(mergeIntervals([]), []);

See also