Sorting and custom comparators
Sorting puts items in order so that equal or close things end up next to each other, which turns many search problems into one linear scan. Library sorts are O(n log n), and no comparison sort can do better. In interviews you’ll rarely write a sort from scratch, but you’ll constantly write comparators (sort by several keys, some descending) and need to know whether your sort is stable (equal items keep their input order).
The idea in plain words
Section titled “The idea in plain words”Think of a shuffled deck of cards. Once it’s sorted, questions that were hard become easy. “Are there two of the same card?” Just look at neighbours. “What’s the 10th smallest?” Look at position 10. “Which cards are between 5 and 9?” They sit in one contiguous block.
That is the real reason sorting shows up in interviews: sort first, then scan turns an O(n²) “compare everything with everything” into O(n log n) + O(n).
There are two classic ways to sort by comparing, and they are mirror images:
- Merge sort does the easy part first: split in half without looking at anything, sort both halves, then do the real work while merging two sorted lists.
- Quicksort does the hard part first: partition around a pivot so everything smaller is on the left, then sort both sides. Joining them back needs no work at all.
Both are divide and conquer. Both take O(n log n) when the halves are balanced.
See it
Section titled “See it”Pick an algorithm, edit the values, and step through. Things to try:
- Merge sort on any input uses about the same number of comparisons. Its recursion is always balanced.
- Quicksort with “last element” pivot on the
sortedpreset makes the most lopsided split possible every time. Count the comparisons, then switch the pivot to random. - Insertion sort on
sortedvsreversed: n − 1 comparisons vs n(n−1)/2.
When to reach for it
Section titled “When to reach for it”| The problem says… | Why sorting fits |
|---|---|
| “find duplicates / closest pair / anything about neighbours” | After sorting, related items are adjacent; one scan finds them. |
| “pairs or triples that…” on a list where order doesn’t matter | Sorting enables two pointers moving inward. |
| “order by X, then by Y descending, ties by name” | That’s a multi-key comparator. Get the tie-breaking exactly right. |
| “process events / intervals in time order” | Sort by start time, then sweep. |
| “smallest value such that…”, “how many are ≤ x” | Sort once, then binary search per query. |
| “top k” or “k-th smallest” | Sorting works (O(n log n)); a heap (O(n log k)) or quickselect (O(n) expected) is better. Mention both. |
How it works
Section titled “How it works”Merge sort
Section titled “Merge sort”- If the list has 0 or 1 items, it’s sorted. Return it.
- Split it in half and sort each half recursively.
- Merge: compare the front of each half, take the smaller, repeat. On a tie, take from the left half; this one rule is what makes merge sort stable.
- When one half runs out, copy the rest of the other half.
def merge_sort(xs: list[int]) -> list[int]: """Stable O(n log n) sort. Returns a new list; O(n) extra space.""" if len(xs) <= 1: # 1. a list of 0 or 1 items is already sorted return xs[:] mid = len(xs) // 2 left = merge_sort(xs[:mid]) # 2. sort each half recursively right = merge_sort(xs[mid:]) out: list[int] = [] i = j = 0 while i < len(left) and j < len(right): if left[i] <= right[j]: # 3. merge: <= takes from the LEFT on ties (stable) out.append(left[i]) i += 1 else: out.append(right[j]) j += 1 out.extend(left[i:]) # 4. one side ran out; copy the rest of the other out.extend(right[j:]) return out
if __name__ == "__main__": assert merge_sort([5, 2, 4, 6, 1, 3]) == [1, 2, 3, 4, 5, 6] assert merge_sort([]) == [] assert merge_sort([3, 3, -1, 0]) == [-1, 0, 3, 3] data = [9, 7, 5, 3, 1, 2, 4, 6, 8, 0] assert merge_sort(data) == sorted(data) and data[0] == 9 # input untouched print("ok")import assert from "node:assert/strict";
/** Stable O(n log n) sort. Returns a new array; O(n) extra space. */export function mergeSort(xs: number[]): number[] { if (xs.length <= 1) return [...xs]; // 1. 0 or 1 items is already sorted const mid = Math.floor(xs.length / 2); const left = mergeSort(xs.slice(0, mid)); // 2. sort each half recursively const right = mergeSort(xs.slice(mid)); const out: number[] = []; let i = 0, j = 0; while (i < left.length && j < right.length) { // 3. merge: <= takes from the LEFT on ties, which keeps the sort stable out.push(left[i] <= right[j] ? left[i++] : right[j++]); } // 4. one side ran out; copy the rest of the other return out.concat(left.slice(i), right.slice(j));}
assert.deepEqual(mergeSort([5, 2, 4, 6, 1, 3]), [1, 2, 3, 4, 5, 6]);assert.deepEqual(mergeSort([]), []);assert.deepEqual(mergeSort([3, 3, -1, 0]), [-1, 0, 3, 3]);const data = [9, 7, 5, 3, 1, 2, 4, 6, 8, 0];assert.deepEqual(mergeSort(data), [...data].sort((a, b) => a - b));assert.equal(data[0], 9); // input untouchedconsole.log("ok");// Stable O(n log n) sort. Returns a new array; O(n) extra space.static int[] MergeSort(int[] xs){ if (xs.Length <= 1) return (int[])xs.Clone(); // 1. 0 or 1 items is already sorted int mid = xs.Length / 2; var left = MergeSort(xs[..mid]); // 2. sort each half recursively var right = MergeSort(xs[mid..]); var outp = new int[xs.Length]; int i = 0, j = 0, k = 0; while (i < left.Length && j < right.Length) outp[k++] = left[i] <= right[j] ? left[i++] : right[j++]; // 3. <= keeps it stable while (i < left.Length) outp[k++] = left[i++]; // 4. copy whatever is left over while (j < right.Length) outp[k++] = right[j++]; return outp;}
static void Check(int[] got, int[] want){ if (!got.SequenceEqual(want)) throw new Exception($"got [{string.Join(",", got)}]");}
Check(MergeSort([5, 2, 4, 6, 1, 3]), [1, 2, 3, 4, 5, 6]);Check(MergeSort([]), []);Check(MergeSort([3, 3, -1, 0]), [-1, 0, 3, 3]);int[] data = [9, 7, 5, 3, 1, 2, 4, 6, 8, 0];Check(MergeSort(data), data.Order().ToArray());if (data[0] != 9) throw new Exception("input was modified");Console.WriteLine("ok");Quicksort (Lomuto partition)
Section titled “Quicksort (Lomuto partition)”- Pick a pivot. A random pivot makes the bad case vanishingly unlikely on any input. Park it at the end.
- Scan left to right with
j. Keep the invariant: everything in[lo..i-1]is smaller than the pivot. Whenxs[j]is smaller, swap it to positioniand advancei. - Swap the pivot into position
i. It is now in its final position. - Recurse on the left part and the right part.
import random
def partition(xs: list[int], lo: int, hi: int) -> int: """Lomuto partition of xs[lo..hi] around a random pivot. Returns the pivot's final index.""" p = random.randint(lo, hi) xs[p], xs[hi] = xs[hi], xs[p] # 1. random pivot, parked at the end pivot = xs[hi] i = lo # 2. invariant: xs[lo..i-1] < pivot, xs[i..j-1] >= pivot for j in range(lo, hi): if xs[j] < pivot: xs[i], xs[j] = xs[j], xs[i] # grow the "small" region by one i += 1 xs[i], xs[hi] = xs[hi], xs[i] # 3. drop the pivot between the two regions return i
def quicksort(xs: list[int], lo: int = 0, hi: int | None = None) -> None: """In-place, expected O(n log n), not stable.""" if hi is None: hi = len(xs) - 1 if lo >= hi: return m = partition(xs, lo, hi) quicksort(xs, lo, m - 1) # 4. the pivot is final; recurse on each side quicksort(xs, m + 1, hi)
if __name__ == "__main__": for _ in range(200): data = [random.randint(-5, 5) for _ in range(random.randint(0, 12))] want = sorted(data) quicksort(data) assert data == want print("ok")import assert from "node:assert/strict";
/** Lomuto partition of xs[lo..hi] around a random pivot. Returns the pivot's final index. */export function partition(xs: number[], lo: number, hi: number): number { const p = lo + Math.floor(Math.random() * (hi - lo + 1)); [xs[p], xs[hi]] = [xs[hi], xs[p]]; // 1. random pivot, parked at the end const pivot = xs[hi]; let i = lo; // 2. invariant: xs[lo..i-1] < pivot, xs[i..j-1] >= pivot for (let j = lo; j < hi; j++) { if (xs[j] < pivot) { [xs[i], xs[j]] = [xs[j], xs[i]]; // grow the "small" region by one i++; } } [xs[i], xs[hi]] = [xs[hi], xs[i]]; // 3. drop the pivot between the two regions return i;}
/** In-place, expected O(n log n), not stable. */export function quicksort(xs: number[], lo = 0, hi = xs.length - 1): void { if (lo >= hi) return; const m = partition(xs, lo, hi); quicksort(xs, lo, m - 1); // 4. the pivot is final; recurse on each side quicksort(xs, m + 1, hi);}
for (let t = 0; t < 200; t++) { const data = Array.from({ length: Math.floor(Math.random() * 13) }, () => Math.floor(Math.random() * 11) - 5); const want = [...data].sort((a, b) => a - b); quicksort(data); assert.deepEqual(data, want);}console.log("ok");var rng = new Random(42);
// Lomuto partition of xs[lo..hi] around a random pivot. Returns the pivot's final index.int Partition(int[] xs, int lo, int hi){ int p = rng.Next(lo, hi + 1); (xs[p], xs[hi]) = (xs[hi], xs[p]); // 1. random pivot, parked at the end int pivot = xs[hi]; int i = lo; // 2. invariant: xs[lo..i-1] < pivot, xs[i..j-1] >= pivot for (int j = lo; j < hi; j++) if (xs[j] < pivot) { (xs[i], xs[j]) = (xs[j], xs[i]); // grow the "small" region by one i++; } (xs[i], xs[hi]) = (xs[hi], xs[i]); // 3. drop the pivot between the two regions return i;}
// In-place, expected O(n log n), not stable.void Quicksort(int[] xs, int lo, int hi){ if (lo >= hi) return; int m = Partition(xs, lo, hi); Quicksort(xs, lo, m - 1); // 4. the pivot is final; recurse on each side Quicksort(xs, m + 1, hi);}
for (int t = 0; t < 200; t++){ var data = Enumerable.Range(0, rng.Next(13)).Select(_ => rng.Next(-5, 6)).ToArray(); var want = data.Order().ToArray(); Quicksort(data, 0, data.Length - 1); if (!data.SequenceEqual(want)) throw new Exception("not sorted");}Console.WriteLine("ok");Stability and multi-key sorting
Section titled “Stability and multi-key sorting”A sort is stable if items that compare equal come out in the same relative order they went in. It matters as soon as records have more than one field.
Stability gives you a second way to sort by several keys: sort by the least important key first, then stably by the more important ones. Each later pass keeps the earlier order inside its ties. This is how spreadsheets sort when you click column headers one after another.
Most of the time, though, you write one comparator that checks keys in order:
- Compare by the primary key. If they differ, that decides it.
- Otherwise compare by the secondary key, flipping the sign for descending.
- Keep going until a key differs. A final unique key (an id or name) makes the order fully deterministic, even with an unstable sort.
from dataclasses import dataclassfrom functools import cmp_to_key
@dataclass(frozen=True)class Emp: name: str dept: str salary: int
STAFF = [Emp("Kai", "ops", 90), Emp("Ana", "eng", 120), Emp("Bo", "eng", 120), Emp("Cy", "ops", 95), Emp("Di", "eng", 100)]WANT = ["Ana", "Bo", "Di", "Cy", "Kai"] # dept asc, salary desc, name asc
# 1. Key tuple: compared left to right. Negate a number to flip its direction.by_key = sorted(STAFF, key=lambda e: (e.dept, -e.salary, e.name))
# 2. Stable multi-pass: sort by the LEAST important key first. Works for keys you can't negate.passes = sorted(STAFF, key=lambda e: e.name)passes.sort(key=lambda e: e.salary, reverse=True) # reverse=True keeps ties stable toopasses.sort(key=lambda e: e.dept)
# 3. Old-style comparator: negative, zero or positive. Wrap it with cmp_to_key.def cmp(a: Emp, b: Emp) -> int: if a.dept != b.dept: return -1 if a.dept < b.dept else 1 if a.salary != b.salary: return b.salary - a.salary # descending: b before a return (a.name > b.name) - (a.name < b.name)
by_cmp = sorted(STAFF, key=cmp_to_key(cmp))
if __name__ == "__main__": for result in (by_key, passes, by_cmp): assert [e.name for e in result] == WANT print("ok")import assert from "node:assert/strict";
interface Emp { name: string; dept: string; salary: number }
const staff: Emp[] = [ { name: "Kai", dept: "ops", salary: 90 }, { name: "Ana", dept: "eng", salary: 120 }, { name: "Bo", dept: "eng", salary: 120 }, { name: "Cy", dept: "ops", salary: 95 }, { name: "Di", dept: "eng", salary: 100 },];const want = ["Ana", "Bo", "Di", "Cy", "Kai"]; // dept asc, salary desc, name asc
// Plain code-point order. localeCompare is locale-aware: fine for UI, surprising in tests.const cmpStr = (a: string, b: string): number => (a < b ? -1 : a > b ? 1 : 0);
// 1. Chain keys with ||: the first non-zero comparison wins.const byChain = [...staff].sort( (a, b) => cmpStr(a.dept, b.dept) || b.salary - a.salary || cmpStr(a.name, b.name),);
// 2. Stable multi-pass (Array.prototype.sort is stable since ES2019): least important key first.const passes = [...staff] .sort((a, b) => cmpStr(a.name, b.name)) .sort((a, b) => b.salary - a.salary) .sort((a, b) => cmpStr(a.dept, b.dept));
for (const result of [byChain, passes]) assert.deepEqual(result.map((e) => e.name), want);
// 3. The classic trap: without a comparator, numbers are sorted as strings.assert.deepEqual([10, 9, 1].sort(), [1, 10, 9]);assert.deepEqual([10, 9, 1].sort((a, b) => a - b), [1, 9, 10]);console.log("ok");Emp[] staff = [new("Kai", "ops", 90), new("Ana", "eng", 120), new("Bo", "eng", 120), new("Cy", "ops", 95), new("Di", "eng", 100)];string[] want = ["Ana", "Bo", "Di", "Cy", "Kai"]; // dept asc, salary desc, name asc
// 1. LINQ: OrderBy/ThenBy is STABLE and returns a new sequence. Pass Ordinal for code-point order.var byLinq = staff.OrderBy(e => e.Dept, StringComparer.Ordinal) .ThenByDescending(e => e.Salary) .ThenBy(e => e.Name, StringComparer.Ordinal) .ToList();
// 2. Array.Sort / List.Sort with a Comparison: in place, NOT stable, so break every tie yourself.var inPlace = (Emp[])staff.Clone();Array.Sort(inPlace, (a, b) =>{ int c = string.CompareOrdinal(a.Dept, b.Dept); if (c != 0) return c; c = b.Salary.CompareTo(a.Salary); // descending; CompareTo avoids a - b overflow return c != 0 ? c : string.CompareOrdinal(a.Name, b.Name);});
foreach (var result in new[] { byLinq.ToArray(), inPlace }) if (!result.Select(e => e.Name).SequenceEqual(want)) throw new Exception(string.Join(",", result.Select(e => e.Name)));Console.WriteLine("ok");
record Emp(string Name, string Dept, int Salary);What each language gives you
Section titled “What each language gives you”| Python | TypeScript / JS | C# | |
|---|---|---|---|
| In place | xs.sort(key=…, reverse=…) |
xs.sort((a, b) => …) |
Array.Sort(xs, cmp), list.Sort(cmp) |
| New copy | sorted(xs, key=…) |
xs.toSorted(cmp) (ES2023), or [...xs].sort(cmp) |
LINQ xs.OrderBy(…).ThenBy(…) |
| Stable? | yes (Timsort) | yes, required since ES2019 | Array.Sort / List.Sort: no (introsort). LINQ OrderBy: yes |
| Comparator style | a key function returning a tuple; functools.cmp_to_key for a two-argument function |
two-argument function returning negative / 0 / positive | Comparison<T>, IComparer<T>, Comparer<T>.Create, or a key selector in LINQ |
| Descending | reverse=True, or negate a numeric key |
swap a and b |
OrderByDescending, or b.CompareTo(a) |
| Default string order | code point | UTF-16 code unit, even for numbers | culture-sensitive; pass StringComparer.Ordinal for code-point order |
What it costs
Section titled “What it costs”| Algorithm | Best | Average | Worst | Extra space | Stable |
|---|---|---|---|---|---|
| Merge sort | n log n | n log n | n log n | O(n) | yes |
| Quicksort (random pivot) | n log n | n log n | n² (vanishingly rare) | O(log n) stack | no |
| Heapsort | n log n | n log n | n log n | O(1) | no |
| Insertion sort | n (already sorted) | n² | n² | O(1) | yes |
| Timsort (Python, V8) | n (already sorted) | n log n | n log n | O(n) | yes |
Introsort (.NET Array.Sort) |
n log n | n log n | n log n | O(log n) | no |
| Counting sort (keys in 0..k) | n + k | n + k | n + k | O(n + k) | yes |
Why n log n? Merge sort splits log₂ n times until pieces have size 1, and every level of merging touches all n items once: n work × log n levels.
Why you can’t beat it by comparing. There are n! possible orderings of n items, and each comparison has two outcomes, so it can at best halve the remaining possibilities. You need at least log₂(n!) ≈ n log₂ n − 1.44n comparisons. To go faster you must use something other than comparisons, such as the values themselves (counting sort, radix sort) when keys are small integers.
Comparing isn’t always O(1). Sorting n strings of length L costs O(L · n log n) in the worst case, because each comparison may read the whole string.
Variations you’ll meet
Section titled “Variations you’ll meet”- Sort indices, not values. Sort
range(n)byxs[i]to keep track of where each value came from (an “argsort”). - Sort by a derived key. Absolute value, length, distance from a point, a day-of-week index. Compute the key once (Python’s
key=does this for you; in JS, precompute into pairs if the key is expensive). - Counting / bucket sort. When keys are small integers (ages, scores 0–100, letters), count occurrences: O(n + k), no comparisons.
- Partial sorting. Only need the top k? A heap of size k is O(n log k). Only need the k-th? Quickselect is O(n) expected; see divide and conquer.
- External sorting. Data bigger than memory: sort chunks that fit, write them out, then k-way merge them with a heap.
- Custom orders that aren’t “by a field”. Example: arrange numbers to form the largest concatenation; the comparator is “does
a + bbeatb + aas strings?”. The comparator must still be consistent (transitive), or the result is undefined.
Pitfalls
Section titled “Pitfalls”- JavaScript’s default sort is by string.
[10, 9, 1].sort()gives[1, 10, 9]. Always pass(a, b) => a - bfor numbers. - Returning a boolean from a JS comparator.
(a, b) => a > breturnstrue/false, which become 1/0. It never says “less than” and sorts incorrectly. Return a negative, zero or positive number. a - boverflow in C#. Withintkeys near the limits, subtraction overflows and flips the sign. Usea.CompareTo(b).- Assuming C#
Array.Sortis stable. It isn’t. Use LINQOrderByor add a final tie-breaker key. - Culture-sensitive string comparison. C#
string.CompareToand JSlocaleComparefollow locale rules (case, accents), which may not match what tests expect. Use ordinal comparison unless you want human ordering. - Inconsistent comparators. A comparator that says a < b, b < c and c < a (or isn’t symmetric) can cause wrong output or even exceptions (.NET may throw “IComparer.Compare() method returns inconsistent results”).
- Sorting inside a loop. Re-sorting after every insert is O(n² log n). Keep a heap or sorted container instead.
- Mutating the caller’s data.
list.sort()and JSsort()work in place. If the problem says “don’t modify the input”, copy first.
In the interview
Section titled “In the interview”- Say what sorting buys you before you do it: “Sorting costs n log n, but then duplicates are adjacent and I can finish in one pass.”
- State the tie-breaking rule out loud and check it against the examples. Most comparator bugs are in the tie case.
- Mention stability when records have several fields: “I’m using a stable sort, so equal scores keep their input order.”
- Know the library’s guarantees for your language: stability, in-place vs copy, the default string order.
- Expect follow-ups: “Can you avoid the n log n?” (counting sort, heap for top-k, quickselect), “What if the data doesn’t fit in memory?” (external merge sort), “What’s quicksort’s worst case and how do you avoid it?” (random pivot, introsort), “Is your sort stable?”.
Check yourself
Section titled “Check yourself”Practice on your own
Section titled “Practice on your own”These are classic textbook exercises, not gym questions:
- Sort an array of 0s, 1s and 2s in a single pass and O(1) space (Dutch national flag).
- Arrange a list of non-negative integers to form the largest possible number when concatenated.
- Sort the characters of a string by how often they occur, most frequent first, ties alphabetical.
- Compute a researcher’s h-index from a list of citation counts.
- Implement merge sort bottom-up (iteratively, merging runs of 1, 2, 4, …) with no recursion.
Go deeper
Section titled “Go deeper”- Sedgewick and Wayne, Algorithms, ch. 2 “Sorting”: mergesort, quicksort (including 3-way partitioning), and the comparison lower bound.
- CLRS, 4th ed., ch. 7 “Quicksort” and ch. 8 “Sorting in Linear Time” (decision-tree lower bound, counting and radix sort).
- Python docs: Sorting Techniques: key functions, stability and multi-pass sorts,
cmp_to_key. - MDN:
Array.prototype.sort()and .NET:Array.Sort: default order, comparator contract and stability guarantees.