Sorting with Custom Comparators
Almost every greedy on this phase begins with a sort. The algorithm is usually three lines; the sort key is the insight. “Sort by end time” is interval scheduling. “Sort by how much cheaper A is than B” is the solution to Two City Scheduling.
Python gives you two ways to express an ordering, and knowing which to reach for matters:
key=— map each element to a value that sorts naturally. Fast, idiomatic, and correct for almost everything.cmp_to_key— define a pairwise comparison. Necessary only when the ordering genuinely cannot be expressed per-element.
This page covers both, plus the traps in mixed-direction sorting.
What you’ll learn
Section titled “What you’ll learn”- Tuple keys for multi-level sorting, and negation for descending fields.
- Why negating a string is impossible, and what to do instead.
functools.cmp_to_key— and how to recognise the rare case that needs it.- Why sort stability is a feature you can design around.
- Three real LeetCode problems solved in the browser: 179, 406, 1029.
The cue
Section titled “The cue”Visual intuition
Section titled “Visual intuition”The algorithm is never the interesting part — sorting is whichever one runs. The key is the answer. Here is one array sorted three ways:
Watch the tie in step 3: 'bb' and 'dd' have equal length, and Python's sort is stable, so they keep their original relative order. That guarantee is what makes the tuple key (len, s) an explicit tie-break rather than a hope.
Tool 1 — tuple keys
Section titled “Tool 1 — tuple keys”Tuples compare lexicographically, so a tuple key sorts by the first field, then breaks ties with the second, and so on.
people = [("alice", 30), ("bob", 25), ("carol", 30)]
print(sorted(people, key=lambda p: p[1])) # by age ascending
print(sorted(people, key=lambda p: -p[1])) # by age descending
print(sorted(people, key=lambda p: (-p[1], p[0]))) # age desc, then name ascThat last line is the pattern worth memorising: negate the fields you want descending, leave the rest ascending. It handles mixed directions in a single pass with no comparator function.
Tool 2 — cmp_to_key for pairwise rules
Section titled “Tool 2 — cmp_to_key for pairwise rules”Some orderings cannot be expressed as a per-element key at all. LC 179 is the
canonical example: to build the largest number from [3, 30, 34, 5, 9], you
need "3" before "30" because "330" > "303" — a fact about the pair,
not about either string alone.
from functools import cmp_to_key
def compare(a, b):
"""Return negative if a should come first, positive if b should."""
if a + b > b + a:
return -1 # a first
if a + b < b + a:
return 1 # b first
return 0 # equal
nums = ["3", "30", "34", "5", "9"]
print(sorted(nums, key=cmp_to_key(compare))) # ['9', '5', '34', '3', '30']The convention is C’s: negative means “first argument comes first”, positive means the second does, zero means equal. Getting the sign backwards silently reverses your sort.
| Speed | When to use | |
|---|---|---|
key= | Fast — computes n keys, then sorts | Almost always |
cmp_to_key | Slower — wraps every element, calls Python per comparison | Only when the rule is inherently pairwise |
Dry run
Section titled “Dry run”LC 179 — largest number from [3, 30, 34, 5, 9]. The comparison is about the pair, not
either element:
| pair | a + b | b + a | verdict |
|---|---|---|---|
"3", "30" | 330 | 303 | 3 first — even though 30 is the larger number |
"30", "34" | 3034 | 3430 | 34 first |
"5", "9" | 59 | 95 | 9 first |
Sorted: 9 5 34 3 30 → 9534330.
- Plain descending sort gives
9534303— off by 27, and only in the last two digits. It looks almost right, which is exactly why this problem is asked:"30" > "3"lexicographically, but"330" > "303"is what actually matters. - No per-element key can express this. A key maps each element to a value independently, and
here the correct position of
"3"depends on what it is being compared against. That is the precise condition for reaching forcmp_to_key. - The
s * 10trick works only because of the constraints. Repeating each string past the maximum length makes lexicographic order agree with the pairwise rule — clever, but it depends on a bound the problem happens to give.cmp_to_keystates the intent directly.
LC 406 — reconstruct the queue from [[7,0],[4,4],[7,1],[5,0],[6,1],[5,2]]. Sort by height
descending, then k ascending: [7,0] [7,1] [6,1] [5,0] [5,2] [4,4]. Then insert each
person at index k:
| insert | at index | queue after |
|---|---|---|
[7,0] | 0 | [[7,0]] |
[7,1] | 1 | [[7,0],[7,1]] |
[6,1] | 1 | [[7,0],[6,1],[7,1]] |
[5,0] | 0 | [[5,0],[7,0],[6,1],[7,1]] |
[5,2] | 2 | [[5,0],[7,0],[5,2],[6,1],[7,1]] |
[4,4] | 4 | [[5,0],[7,0],[5,2],[6,1],[4,4],[7,1]] |
- Tallest-first is what makes insertion safe. Everyone already placed is at least as tall as the
person being inserted, so inserting at index
kputs exactlyktaller-or-equal people in front — and shorter people added later never disturb that count, because they do not count toward anyone’sk. - The
k-ascending tie-break is not decoration. Among equal heights, inserting the smallerkfirst keeps both correct; reversed, the second insertion displaces the first. - The sort is the algorithm. The insertion loop is three lines and would be meaningless under any other order.
Complexity
Section titled “Complexity”| Approach | Time | Space | Notes |
|---|---|---|---|
sorted(xs) / .sort() | / -ish | Timsort; .sort() is in place | |
key= function | , n key calls | for the keys | the key is computed once per element, not once per comparison |
| Tuple key | same | same | comparison is lexicographic, element by element |
cmp_to_key | , comparator calls | wrapper objects | noticeably slower — a Python call per comparison |
| Two-pass stable sort | — | sort by the secondary key first, then the primary | |
| LC 406 insertion loop | list insertion is ; the sort is not the bottleneck |
Why key= beats cmp_to_key in practice: the key function runs exactly n times and the
comparisons then happen in C. A cmp_to_key comparator is a Python-level call on every
comparison — roughly of them — which is typically several times slower. Reach for it only
when the ordering genuinely cannot be expressed per element.
The variant map
Section titled “The variant map”| Variant | The ordering | Canonical problem |
|---|---|---|
| Pairwise concatenation | a + b vs b + a via cmp_to_key | 179 Largest Number |
| Mixed directions | Tuple key (-height, k) | 406 · 692 |
| Derived quantity | Sort by cost_a - cost_b | 1029 Two City Scheduling |
| Descending string field | Two stable sorts, secondary first | 692 Top K Frequent Words |
| Category then content | Tuple key with a category flag first | 937 Reorder Data in Log Files |
| Sort to enable a greedy | Sort by end / by start | Interval scheduling |
Practice — real LeetCode problems
Section titled “Practice — real LeetCode problems”LC 179 — Largest Number · Medium
Section titled “LC 179 — Largest Number · Medium”Problem. Given a list of non-negative integers, arrange them so that they form the largest possible number. Return it as a string.
Constraints. 1 <= len(nums) <= 100, 0 <= nums[i] <= 10^9.
Examples. [10,2] gives "210" · [3,30,34,5,9] gives "9534330" ·
[0,0] gives "0"
Editorial — approach, complexity, follow-ups
The ordering is pairwise: a should precede b exactly when the
concatenation a + b is larger than b + a. No per-element score captures
this, because whether "3" beats "30" depends on "30".
Time comparisons, each on string length, so . Space .
Two details:
- The all-zeros guard.
[0,0]sorts to["0","0"]and joins to"000"— wait, to"00"— either way not"0". Since the comparator puts the largest first, if the leading character is"0"then every element is zero, so the answer is"0". Checkingresult[0] == "0"is sufficient and cheaper than stripping. - Transitivity. This comparator is a valid total order (the relation
a+b > b+ais transitive on digit strings), which is what makes it safe to hand to a sort. A non-transitive comparator produces undefined results, and it is a fair thing to be asked about.
[432, 43, 43] giving "4343432" is the case that catches naive descending
sorts: plain string-descending order would put "432" first and produce
"4324343", which is smaller.
Follow-ups you should expect: “Can you avoid cmp_to_key?” — yes, the
key=lambda s: s * 10 trick, valid because values are bounded to 10 digits;
say why it works and why the comparator is safer. “Prove the comparator is
transitive” — a legitimate deeper question. “Smallest number instead?” — flip
the comparison, and mind leading zeros differently.
LC 406 — Queue Reconstruction by Height · Medium
Section titled “LC 406 — Queue Reconstruction by Height · Medium”Problem. Each person is [h, k]: height h, and k = the number of
people in front of them with height greater than or equal to h.
Reconstruct and return the queue.
Constraints. 1 <= len(people) <= 2000, 0 <= h <= 10^6,
0 <= k < len(people). A valid queue is always possible.
Examples. [[7,0],[4,4],[7,1],[5,0],[6,1],[5,2]] gives
[[5,0],[7,0],[5,2],[6,1],[4,4],[7,1]]
Editorial — approach, complexity, follow-ups
Process people tallest first. When you insert someone, everyone already
in the list is at least as tall, so among those people the person’s k is
exactly their target index — insert them there.
And inserting a shorter person later never invalidates an earlier
placement, because k counts only people of height >= h. A shorter person
does not count towards anyone taller. That monotonicity is why the greedy
works.
Time — list.insert shifts elements, n times. Space
. This is accepted at n <= 2000; a Fenwick tree or balanced BST that
finds the “k-th empty slot” reduces it to , which is the
optimisation to name if pushed.
The tuple key (-p[0], p[1]) is the crux:
-p[0]gives descending height.p[1]gives ascendingkwithin equal heights. This part is essential, not cosmetic: among people of the same height, the one with smallerkmust be inserted first, or they would land behind their same-height peer and both counts would be wrong. Try[[7,0],[7,1]]with thekorder reversed and the output is[[7,1],[7,0]], which is invalid.
Follow-ups you should expect: “Why tallest first?” — so that everyone
already placed counts towards k, making k an exact index. “Why is k
ascending within a height?” — as above; a great test of whether you reasoned
or memorised. “Reduce the ?” — Fenwick tree over empty slots, or a
segment tree, for . “What if k counted strictly taller
people?” — the same-height tie-break changes, and you must re-derive it.
LC 1029 — Two City Scheduling · Medium
Section titled “LC 1029 — Two City Scheduling · Medium”Problem. 2n candidates must be flown to interviews. costs[i] = [aCost, bCost] gives the cost of flying candidate i to city A or city B.
Exactly n must go to each city. Return the minimum total cost.
Constraints. 2 <= len(costs) <= 100, len(costs) is even,
1 <= aCost, bCost <= 1000.
Examples. [[10,20],[30,200],[400,50],[30,20]] gives 110 ·
[[259,770],[448,54],[926,667],[184,139],[840,118],[577,469]] gives 1859
Editorial — approach, complexity, follow-ups
Send everyone to city B as a baseline, costing sum(bCost). Moving person
i to A instead changes the total by aCost[i] - bCost[i]. You must move
exactly n people, so choose the n with the smallest (most negative)
differences.
Sorting by c[0] - c[1] puts exactly those first, so the answer is the A
costs of the first half plus the B costs of the second.
Time . Space beyond the sort.
The lesson is the derived key: aCost - bCost appears nowhere in the
input, and no ordering of aCost or bCost alone solves the problem.
Sorting by aCost ascending fails — someone cheap for A might be even
cheaper for B, and the exact-split constraint is what makes only the
difference meaningful.
An exchange argument confirms optimality: if an optimal assignment sends p
to B and q to A where diff(p) < diff(q), swapping them changes the total
by diff(p) - diff(q) < 0, an improvement. So no optimal solution can have
such an inversion, which is exactly what sorting by difference eliminates.
Follow-ups you should expect: “Why not sort by aCost?” — the
counterexample above. “Prove it” — the exchange argument. “What if the split
were k and 2n - k?” — same sort, split at k. “What if there were three
cities?” — greedy breaks; it becomes min-cost flow or DP over counts, a
genuinely harder problem worth flagging.
LeetCode problem set
Section titled “LeetCode problem set”Generated from the problem database, so each entry carries its sheet membership and reported companies. Tick them off as you go — progress is saved in this browser, and the Export button writes it to a file you can keep.
Work down the ladder. Tick each problem off as you go — progress is saved in this browser, and the Export button in the filter bar writes it to a file you can keep.
- 179Largest NumbermediumPairwise `a+b` vs `b+a`; needs `cmp_to_key`
- 406Queue Reconstruction by HeightmediumTuple key `(-h, k)`, then insert at index `k`
- 692Top K Frequent WordsmediumCount descending **and** word ascending -- two stable sorts, or a `(-count, word)` key
- 853Car Fleetmedium
- 937Reorder Data in Log FilesmediumCategory first, then content, then identifier -- and digit logs keep original order (stability)
- 1029Two City SchedulingmediumSort by the derived `aCost - bCost`
- 354Russian Doll Envelopeshard
Interview follow-ups
Section titled “Interview follow-ups”| They ask | What they’re checking | The answer |
|---|---|---|
“key or cmp_to_key?” | Judgement | key unless the rule is inherently pairwise; cmp_to_key wraps every element and calls back into Python, so it is slower |
| “Sort descending on a string field?” | Language fluency | You cannot negate a string: use two stable sorts (secondary first), or a comparator |
| “Is Python’s sort stable?” | Useful detail | Yes, guaranteed — and you can design keys that rely on it (LC 937) |
| “Why does the tie-break direction matter in 406?” | Whether you reasoned it out | Among equal heights, smaller k must be inserted first, or same-height people land in the wrong order |
| “Prove your ordering is optimal” | Rigour | An exchange argument: show that any inversion relative to your order can be swapped for an improvement |
| “Is your comparator a valid total order?” | Depth | It must be transitive, or the sort’s behaviour is undefined — worth checking for cmp_to_key rules |
| “Can you avoid the inserts in 406?” | Optimisation | A Fenwick tree or segment tree over empty slots gives |
Edge-case checklist
Section titled “Edge-case checklist”- All zeros (LC 179) — must return
"0", not"00". - Single element — every sort is trivially correct; check the wrapper code still works.
- Ties on the primary key — the case that reveals whether your secondary direction is right.
- Equal heights in LC 406 —
[[7,0],[7,1]]; the tie-break test. - Negative derived keys (LC 1029) —
aCost - bCostis often negative; do not assume a sign. - Descending string fields — reach for two stable sorts, never
-string. - Duplicate elements — fine, but make sure your comparator returns
0for genuinely equal items rather than an arbitrary sign. - Comparator sign convention — negative means the first argument comes first; reversing it silently inverts the whole sort.
Self-check
Section titled “Self-check”-
When is `cmp_to_key` genuinely necessary rather than just convenient?
A key maps each element independently. If the right position of an element depends on what it is compared against, no key exists — that is the precise test.
pch.quizShowAnswer
B — When the ordering depends on the PAIR rather than on either element alone — LC 179 needs '3' before '30' because '330' > '303', which no per-element key can express — A key maps each element independently. If the right position of an element depends on what it is compared against, no key exists — that is the precise test.
-
Why is `key=` preferred over `cmp_to_key` when both work?
Both are O(n log n) in the abstract; the constant differs by several times. Naming the reason — n key calls versus n log n Python calls — is the substantive answer.
pch.quizShowAnswer
B — The key function runs exactly n times and the comparisons then happen in C, whereas a comparator is a Python-level call on every one of the ~n log n comparisons — Both are O(n log n) in the abstract; the constant differs by several times. Naming the reason — n key calls versus n log n Python calls — is the substantive answer.
-
You need to sort by count DESCENDING then by word ASCENDING. Why can't a single tuple key do it?
The tuple trick relies on being able to negate. `key=lambda s: -s` on a string is a TypeError, and that is exactly when the two-pass stable sort earns its place.
pch.quizShowAnswer
B — It can here, because count is numeric and negatable — but if the primary key were a STRING needing reverse order there is no negation, and the fix is two stable passes: secondary first, then primary with reverse=True — The tuple trick relies on being able to negate. `key=lambda s: -s` on a string is a TypeError, and that is exactly when the two-pass stable sort earns its place.
-
What does the sign convention in a `cmp_to_key` comparator mean?
Silently reversed is the dangerous part: the code runs and produces a plausible ordering. Writing one example comparison by hand before trusting it is worth the ten seconds.
pch.quizShowAnswer
B — Negative means the FIRST argument comes first, positive means the second, zero means equal — C's convention, and getting it backwards silently reverses the sort — Silently reversed is the dangerous part: the code runs and produces a plausible ordering. Writing one example comparison by hand before trusting it is worth the ten seconds.
-
In LC 406, why sort by height descending before inserting at index k?
The k-ascending tie-break among equal heights matters too: reversed, the second insertion displaces the first. The sort is the algorithm; the insertion loop is three lines.
pch.quizShowAnswer
B — Because everyone already placed is at least as tall, so inserting at index k puts exactly k taller-or-equal people ahead — and shorter people added later never count toward anyone's k — The k-ascending tie-break among equal heights matters too: reversed, the second insertion displaces the first. The sort is the algorithm; the insertion loop is three lines.
-
Does Python's sort guarantee stability?
Because it is guaranteed rather than incidental, you can build compound orderings by sorting repeatedly — and the trace on this page shows equal-length words keeping their input order under key=len.
pch.quizShowAnswer
B — Yes — it is a documented guarantee, which is what makes the two-pass compound sort (secondary key first, then primary) reliable rather than lucky — Because it is guaranteed rather than incidental, you can build compound orderings by sorting repeatedly — and the trace on this page shows equal-length words keeping their input order under key=len.
Recall card
Section titled “Recall card”- Cue — the sort order is not the natural one: multiple criteria, mixed directions, or a rule about pairs.
- Default reach — a tuple key:
key=lambda x: (-x.count, x.name). Compared element by element, so it expresses “primary, then tie-break” directly. - Negation only works on numbers.
key=lambda s: -son a string is aTypeError. - Two stable passes for mixed directions on non-negatable keys: sort by the secondary key
first, then the primary with
reverse=True. - Stability is guaranteed in Python — equal keys keep their input order.
cmp_to_keyonly when the order depends on the pair (LC 179:a + b > b + a). Sign convention is C’s: negative = first argument first.- Cost — either way, but
key=runsntimes in Python and compares in C, while a comparator is a Python call per comparison. - The sort is often the whole algorithm — LC 406’s insertion loop is three lines and only works
because of the height-descending,
k-ascending order.
- In most greedy problems the algorithm is trivial and the sort key is the insight.
- Tuple keys handle multi-level ordering; negate the fields you want
descending:
(-height, k). - You cannot negate a string. Use two stable sorts, secondary key first.
cmp_to_keyis for genuinely pairwise rules like LC 179’sa+bvsb+a. Negative means the first argument comes first, and the relation must be transitive.- Python’s sort is stable, and that is a feature you can design keys around (LC 937).
- The best keys are often derived quantities the input never states —
aCost - bCostin LC 1029 is the whole solution. - Whatever ordering you choose, be ready to justify it with an exchange argument.
Next: the linked-list patterns — fast/slow pointers, in-place reversal, and dummy-head rewiring.
pch.coffeeTagline
pch.coffeeCtapch.feedbackHeading
pch.feedbackSubheading