Skip to content

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.

  • 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 algorithm is never the interesting part — sorting is O(nlogn)O(n \log n) whichever one runs. The key is the answer. Here is one array sorted three ways:

sortSame four words, three keys, three different ordersthe key IS the algorithm
bb0a1ccc2dd3
original
bb0a1ccc2dd3
n4
inputOne array, three questions. Sorting is $O(n \log n)$ whichever key you pick, so the algorithm is never the interesting part here — **the key is the answer**. Watch how the same four words land in three different orders.
1/4

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.

Tuples compare lexicographically, so a tuple key sorts by the first field, then breaks ties with the second, and so on.

tuple_keys.py
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 asc

That 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.

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.

cmp_to_key.py
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.

SpeedWhen to use
key=Fast — computes n keys, then sortsAlmost always
cmp_to_keySlower — wraps every element, calls Python per comparisonOnly when the rule is inherently pairwise

LC 179 — largest number from [3, 30, 34, 5, 9]. The comparison is about the pair, not either element:

paira + bb + averdict
"3", "30"3303033 first — even though 30 is the larger number
"30", "34"3034343034 first
"5", "9"59959 first

Sorted: 9 5 34 3 309534330.

  • 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 for cmp_to_key.
  • The s * 10 trick 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_key states 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:

insertat indexqueue 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 k puts exactly k taller-or-equal people in front — and shorter people added later never disturb that count, because they do not count toward anyone’s k.
  • The k-ascending tie-break is not decoration. Among equal heights, inserting the smaller k first 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.
ApproachTimeSpaceNotes
sorted(xs) / .sort()O(nlogn)O(n \log n)O(n)O(n) / O(1)O(1)-ishTimsort; .sort() is in place
key= functionO(nlogn)O(n \log n), n key callsO(n)O(n) for the keysthe key is computed once per element, not once per comparison
Tuple keysamesamecomparison is lexicographic, element by element
cmp_to_keyO(nlogn)O(n \log n), O(nlogn)O(n \log n) comparator callsO(n)O(n) wrapper objectsnoticeably slower — a Python call per comparison
Two-pass stable sort2×O(nlogn)2 \times O(n \log n)sort by the secondary key first, then the primary
LC 406 insertion loopO(n2)O(n^2)O(n)O(n)list insertion is O(n)O(n); 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 nlognn \log n of them — which is typically several times slower. Reach for it only when the ordering genuinely cannot be expressed per element.

VariantThe orderingCanonical problem
Pairwise concatenationa + b vs b + a via cmp_to_key179 Largest Number
Mixed directionsTuple key (-height, k)406 · 692
Derived quantitySort by cost_a - cost_b1029 Two City Scheduling
Descending string fieldTwo stable sorts, secondary first692 Top K Frequent Words
Category then contentTuple key with a category flag first937 Reorder Data in Log Files
Sort to enable a greedySort by end / by startInterval scheduling

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 O(nlogn)O(n \log n) comparisons, each O(L)O(L) on string length, so O(nLlogn)O(n L \log n). Space O(nL)O(nL).

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". Checking result[0] == "0" is sufficient and cheaper than stripping.
  • Transitivity. This comparator is a valid total order (the relation a+b > b+a is 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 O(n2)O(n^2)list.insert shifts O(n)O(n) elements, n times. Space O(n)O(n). This is accepted at n <= 2000; a Fenwick tree or balanced BST that finds the “k-th empty slot” reduces it to O(nlogn)O(n \log n), 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 ascending k within equal heights. This part is essential, not cosmetic: among people of the same height, the one with smaller k must be inserted first, or they would land behind their same-height peer and both counts would be wrong. Try [[7,0],[7,1]] with the k order 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 O(n2)O(n^2)?” — Fenwick tree over empty slots, or a segment tree, for O(nlogn)O(n \log n). “What if k counted strictly taller people?” — the same-height tie-break changes, and you must re-derive it.

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 O(nlogn)O(n \log n). Space O(1)O(1) 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.

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.

7 problems
0 easy6 medium1 hard

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.

They askWhat they’re checkingThe answer
key or cmp_to_key?”Judgementkey 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 fluencyYou cannot negate a string: use two stable sorts (secondary first), or a comparator
“Is Python’s sort stable?”Useful detailYes, 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 outAmong equal heights, smaller k must be inserted first, or same-height people land in the wrong order
“Prove your ordering is optimal”RigourAn exchange argument: show that any inversion relative to your order can be swapped for an improvement
“Is your comparator a valid total order?”DepthIt must be transitive, or the sort’s behaviour is undefined — worth checking for cmp_to_key rules
“Can you avoid the O(n2)O(n^2) inserts in 406?”OptimisationA Fenwick tree or segment tree over empty slots gives O(nlogn)O(n \log n)
  • 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 - bCost is 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 0 for 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.
pch.quizTag Custom comparators — self-check
  1. When is `cmp_to_key` genuinely necessary rather than just convenient?

    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.

  2. Why is `key=` preferred over `cmp_to_key` when both work?

    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.

  3. You need to sort by count DESCENDING then by word ASCENDING. Why can't a single tuple key do it?

    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.

  4. What does the sign convention in a `cmp_to_key` comparator mean?

    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.

  5. In LC 406, why sort by height descending before inserting at index k?

    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.

  6. Does Python's sort guarantee stability?

    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.

  • 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: -s on a string is a TypeError.
  • 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_key only when the order depends on the pair (LC 179: a + b > b + a). Sign convention is C’s: negative = first argument first.
  • CostO(nlogn)O(n \log n) either way, but key= runs n times 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_key is for genuinely pairwise rules like LC 179’s a+b vs b+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 - bCost in 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.coffeeCta

pch.feedbackHeading

pch.feedbackSubheading