Sorting Problems: Multi-Key Sorts, Custom Comparators, and When Sorting First Solves the Problem

Key takeaways

Most sorting problems are not about implementing a sort. They are about choosing the right order so that the rest of the problem becomes a single linear pass. This guide covers Python's stable sort and key functions, when you really need cmp_to_key, why interval and scheduling greedies sort the way they do, and the comparator and tuple-comparison bugs that quietly produce wrong answers.

Introduction

In interview and contest problems, sorting is rarely the goal. It is the step that turns a messy input into one where the answer can be read off in a single pass: overlapping intervals become adjacent, equal values end up next to each other, and “the best candidate so far” is always the next element. Once the data is sorted, a problem that looked like it needed comparing every pair often needs only a linear scan.

That is also why sorting solutions fail in ways that have nothing to do with sorting algorithms. The code calls sort() correctly and still gets the wrong answer, because it sorted by the wrong key, broke ties in an unintended way, or used a comparator that does not describe a consistent order. This article focuses on those decisions. How quicksort, merge sort and heap sort work internally is covered in Quick Sort, Merge Sort and Heap Sort.

The cost model to keep in mind: a comparison sort costs O(n log n), and it usually dominates the O(n) pass that follows. If a problem has n up to 10^5 or 10^6, “sort, then scan” is almost always fast enough. If you need something faster, the question becomes whether you really need a full order at all (see the last section).


How Python’s Sort Actually Behaves

sort() vs sorted()

list.sort() sorts in place and returns None. sorted() accepts any iterable and returns a new list. The choice matters more than it looks in an interview setting: if a function sorts its argument in place, the caller’s list is modified too, which breaks any later code that relied on the original order or on the original indices.

arr = [5, 2, 8, 1, 9]
result = arr.sort()
print(result)  # None  (a classic bug: x = x.sort())

arr = [5, 2, 8, 1, 9]
sorted_arr = sorted(arr)
print(arr)         # [5, 2, 8, 1, 9]  original untouched
print(sorted_arr)  # [1, 2, 5, 8, 9]

Both use Timsort, which is O(n log n) in the worst case and close to O(n) on input that is already mostly sorted, because it detects existing ascending and descending runs and merges them.

Stability is guaranteed, and it is a tool

Python’s sort is stable: elements that compare equal keep their original relative order. This holds with reverse=True too. Python does not sort ascending and then reverse the list, which would flip the order of ties; equal elements still appear in input order.

records = [("Bob", 90), ("alice", 85), ("Charlie", 85), ("dave", 90)]
print(sorted(records, key=lambda r: r[1], reverse=True))
# [('Bob', 90), ('dave', 90), ('alice', 85), ('Charlie', 85)]

Bob came before dave in the input and still does. Stability is what makes the two-pass technique below correct.

key functions are called once per element

A key function maps each element to a value, and the sort compares those values. Python calls the key exactly once per element and caches the results, so an expensive key (parsing a date, computing a length) costs O(n), not O(n log n). This is one reason key is preferred over a comparator.

Multi-condition sorting with tuples

Tuples compare element by element, so a tuple key expresses “sort by A, then by B” directly. To make a numeric field descending, negate it:

students = [("Alice", 85), ("Bob", 90), ("Charlie", 85), ("David", 90)]
students.sort(key=lambda s: (-s[1], s[0]))  # score desc, name asc
print(students)
# [('Bob', 90), ('David', 90), ('Alice', 85), ('Charlie', 85)]

Negation does not work for strings. A trick you will sometimes see, -ord(name[0]), only looks at the first character and silently mis-orders names that share it. The correct approach for “age ascending, name descending” is two stable passes, secondary key first:

people = [("Alice", 30), ("Bob", 25), ("Charlie", 30), ("Abe", 30)]
people.sort(key=lambda p: p[0], reverse=True)  # secondary: name desc
people.sort(key=lambda p: p[1])                # primary: age asc
print(people)
# [('Bob', 25), ('Charlie', 30), ('Alice', 30), ('Abe', 30)]

The second pass reorders by age, and because it is stable, people with the same age stay in the name-descending order the first pass produced.


Custom Comparators with cmp_to_key

Sometimes the order depends on how two elements relate to each other rather than on a property of each element alone. For those cases functools.cmp_to_key wraps a two-argument comparator that returns a negative number (a goes first), zero (tie), or a positive number (b goes first).

The bool-returning comparator bug

The most common mistake is returning a bool:

from functools import cmp_to_key

bad = sorted([3, 1, 2, 5, 4], key=cmp_to_key(lambda a, b: a > b))
print(bad)   # [3, 1, 2, 5, 4]  not sorted, and no error

good = sorted([3, 1, 2, 5, 4], key=cmp_to_key(lambda a, b: (a > b) - (a < b)))
print(good)  # [1, 2, 3, 4, 5]

cmp_to_key decides “a is less than b” by checking whether the comparator result is < 0. True is 1 and False is 0, so nothing is ever less than anything, every element looks equal to every other, and the stable sort returns the input unchanged. There is no exception, so this only shows up as a wrong answer.

I have made exactly this mistake while switching between languages: in C++ a comparator returns a bool meaning “a goes before b”, and carrying that habit into Python produced a list that was “sorted” into its original order. The fix takes seconds, but only once you know that a silent no-op is the symptom to look for.

Comparators must describe a consistent order

Whatever the language, a comparator must behave like a real ordering: if a < b and b < c then a < c, and no element is less than itself. Python will not crash on an inconsistent comparator, but the result depends on the order the algorithm happened to compare elements in, so the same code can pass small tests and fail hidden ones.

C++ is stricter. std::sort requires a strict weak ordering, and in particular comp(a, a) must be false. A comparator written with <= violates that, and the behavior is undefined:

#include <algorithm>
#include <vector>
#include <cstdio>

int main() {
    std::vector<int> v(100, 7);
    // Wrong: <= is not a strict weak ordering (comp(a, a) is true)
    std::sort(v.begin(), v.end(), [](int a, int b) { return a <= b; });
    std::printf("%d\n", v[0]);
}

When I compiled this with GCC 10 at -O2, it printed 0. A vector containing only 7s came back with a value that was never in it: the unguarded insertion step inside std::sort trusted the comparator to stop it and read past the start of the array. With -D_GLIBCXX_DEBUG the same program aborts with “comparison doesn’t meet irreflexive requirements”. On other inputs the same bug can segfault. Use <, never <=, and build with the debug mode when a sort misbehaves. (Java and C have a related trap: a comparator written as return a - b; overflows for large values of opposite sign and flips the result.)


Classic Problems and Why the Sort Order Works

Problem 1: Largest Number (LeetCode 179)

Arrange non-negative integers so that their concatenation is as large as possible. [3, 30, 34, 5, 9] becomes "9534330".

Sorting the numbers as strings in descending order does not work: "30" sorts before "3", but "330" is larger than "303". The right question is pairwise: should a come before b? Yes if a + b > b + a as strings. That relation is transitive (it is equivalent to comparing the infinite repetitions aaaa... and bbbb...), so it defines a valid order and a comparator is the natural fit.

from functools import cmp_to_key

def largest_number(nums):
    def compare(a, b):
        if a + b > b + a:
            return -1  # a first
        if a + b < b + a:
            return 1   # b first
        return 0

    strs = sorted(map(str, nums), key=cmp_to_key(compare))
    result = "".join(strs)
    return "0" if result[0] == "0" else result

print(largest_number([3, 30, 34, 5, 9]))  # 9534330
print(largest_number([0, 0, 0]))          # 0
print(largest_number([10, 2]))            # 210

The final check matters: [0, 0, 0] would otherwise return "000". It is the edge case this problem is known for.

Problem 2: H-Index (LeetCode 274)

The h-index is the largest h such that at least h papers have at least h citations each. After sorting descending, the paper at index i is the (i+1)-th most cited, so “at least i+1 papers have at least i+1 citations” is exactly citations[i] >= i + 1. Because the list is descending, that condition is true for a prefix and then false forever, so the first failure ends the search.

def h_index(citations):
    c = sorted(citations, reverse=True)  # do not mutate the caller's list
    h = 0
    for i, x in enumerate(c):
        if x >= i + 1:
            h = i + 1
        else:
            break
    return h

print(h_index([3, 0, 6, 1, 5]))   # 3
print(h_index([10, 8, 5, 4, 3]))  # 4
print(h_index([0, 0]))            # 0

This problem also shows when sorting is more than you need. The answer can never exceed n, so citations above n can be clamped to n and counted in buckets, giving O(n):

def h_index_counting(citations):
    n = len(citations)
    buckets = [0] * (n + 1)
    for c in citations:
        buckets[min(c, n)] += 1
    papers = 0
    for h in range(n, -1, -1):
        papers += buckets[h]  # papers with at least h citations
        if papers >= h:
            return h
    return 0

print(h_index_counting([3, 0, 6, 1, 5]))  # 3

Problem 3: Merge Intervals (LeetCode 56)

After sorting by start, an interval can only overlap the intervals that came before it through the most recently merged one. The invariant is: merged holds disjoint intervals in order, and every interval not yet processed starts at or after the start of the last merged interval. So each new interval either overlaps the last merged interval, or it starts after it ends and can never touch anything earlier.

def merge_intervals(intervals):
    if not intervals:
        return []
    intervals = sorted(intervals, key=lambda iv: iv[0])
    merged = [list(intervals[0])]
    for start, end in intervals[1:]:
        last = merged[-1]
        if start <= last[1]:              # overlaps (touching counts)
            last[1] = max(last[1], end)   # max: the new one may be contained
        else:
            merged.append([start, end])
    return merged

print(merge_intervals([[1, 3], [2, 6], [8, 10], [15, 18]]))  # [[1, 6], [8, 10], [15, 18]]
print(merge_intervals([[1, 10], [2, 3], [4, 5]]))            # [[1, 10]]
print(merge_intervals([[1, 4], [4, 5]]))                     # [[1, 5]]

Two details cause most wrong answers. Writing last[1] = end instead of max(last[1], end) breaks on contained intervals like [1, 10], [2, 3]. And whether [1, 4] and [4, 5] overlap is a problem-specific decision: <= merges them, < does not. Read the statement.

Problem 4: Maximum Non-Overlapping Meetings (Activity Selection)

Given meetings as (start, end), choose as many as possible without overlap. The greedy is: sort by end time, then take every meeting that starts after the last one you took ended.

Why end time? Consider any optimal schedule. Its first meeting ends no earlier than the meeting m that ends first overall. Replace it with m: the rest of the schedule still fits, because m frees the room at least as early. Repeating this argument meeting by meeting shows that the greedy choice never loses. Sorting by start time has no such guarantee:

def max_meetings(meetings):
    count = 0
    last_end = float("-inf")  # not 0: starts can be 0 or negative
    for start, end in sorted(meetings, key=lambda m: m[1]):
        if start >= last_end:
            count += 1
            last_end = end
    return count

meetings = [(1, 4), (3, 5), (0, 6), (5, 7), (3, 8), (5, 9), (6, 10), (8, 11)]
print(max_meetings(meetings))                   # 3: (1,4), (5,7), (8,11)
print(max_meetings([(0, 10), (1, 2), (3, 4)]))  # 2; sorting by start would give 1

The same algorithm solves LeetCode 435 (minimum intervals to remove is n minus this count) and LeetCode 452 (minimum arrows to burst balloons).

Problem 5: Minimum Meeting Rooms

A different question about the same input: how many rooms are needed so that all meetings happen? Sort by start, and keep a min-heap of end times for rooms in use. If the room that frees up earliest is free by the time the next meeting starts, reuse it; otherwise open a new room.

import heapq

def min_meeting_rooms(meetings):
    rooms = []  # min-heap of end times
    for start, end in sorted(meetings):
        if rooms and rooms[0] <= start:
            heapq.heapreplace(rooms, end)  # reuse the room that frees first
        else:
            heapq.heappush(rooms, end)
    return len(rooms)

print(min_meeting_rooms([(0, 30), (5, 10), (15, 20)]))  # 2
print(min_meeting_rooms([(1, 5), (5, 10)]))             # 1

Here sorting by start is the right choice, because the decision is made when a meeting begins. The contrast with Problem 4 is worth remembering: the sort key follows from the moment the greedy makes its decision.


Sorting as Preparation for Other Techniques

Sort + two pointers, without losing indices

After sorting, if arr[lo] + arr[hi] is too small, no pair using arr[lo] with anything smaller than arr[hi] can work either, so lo can move right. That monotonicity is why two pointers find a target pair in O(n) after the sort. The trap is that sorting destroys the original positions, and problems like Two Sum ask for indices. Sort the indices instead of the values:

def two_sum_indices(nums, target):
    order = sorted(range(len(nums)), key=lambda i: nums[i])
    lo, hi = 0, len(order) - 1
    while lo < hi:
        s = nums[order[lo]] + nums[order[hi]]
        if s == target:
            return sorted([order[lo], order[hi]])
        if s < target:
            lo += 1
        else:
            hi -= 1
    return None

print(two_sum_indices([3, 2, 4], 6))       # [1, 2]
print(two_sum_indices([2, 7, 11, 15], 9))  # [0, 1]

For plain Two Sum a hash map is O(n) and simpler. Sorting pays off when you need all pairs, triples (3Sum), or pairs closest to a target. More on the pattern in Two Pointers.

Once sorted, “how many elements are below x” and “how many equal x” become O(log n) queries with bisect:

from bisect import bisect_left, bisect_right

arr = sorted([5, 2, 8, 1, 9, 5])
print(arr)                                         # [1, 2, 5, 5, 8, 9]
print(bisect_left(arr, 6))                         # 4 elements are < 6
print(bisect_right(arr, 5) - bisect_left(arr, 5))  # 2 copies of 5

Sorting once and answering many queries is the win here; for a single query a linear scan is cheaper. The bounds are explained in Binary Search.

Sort + greedy matching

To give cookies to as many children as possible, sort both lists and give each cookie, smallest first, to the least greedy child it can satisfy. A cookie too small for the least greedy remaining child is too small for everyone, so skipping it loses nothing.

def assign_cookies(greed, cookies):
    greed, cookies = sorted(greed), sorted(cookies)
    child = 0
    for c in cookies:
        if child < len(greed) and c >= greed[child]:
            child += 1
    return child

print(assign_cookies([1, 2, 3], [1, 1]))  # 1
print(assign_cookies([1, 2], [1, 2, 3]))  # 2

Where Sorting Solutions Go Wrong

Most failed submissions I have seen on sorting problems, my own included, were not about complexity. They were about the order not being the one the code author thought it was.

Tuple keys that fall through to incomparable values. Sorting (priority, payload) tuples works until two priorities tie, at which point Python compares the payloads. If they are dicts or custom objects, you get TypeError: '<' not supported between instances of 'dict' and 'dict', but only on inputs with ties, so it can pass the samples and fail later. Sort with key=lambda t: t[0], or add a unique tiebreaker such as an index. The same issue appears with heapq when you push (priority, obj).

Numbers stored as strings. sorted(["10", "9", "2", "33"]) gives ['10', '2', '33', '9'], because strings compare character by character. Input read from a file or split from a line is text until you convert it; use key=int or convert first.

Case sensitivity. Uppercase letters sort before lowercase in ASCII, so ["apple", "Banana", "cherry"] sorts to ['Banana', 'apple', 'cherry']. Use key=str.lower (or str.casefold for non-English text) when the problem means alphabetical order.

NaN in float data. Every comparison with nan is false, which breaks the ordering. sorted([3.0, float("nan"), 1.0, 2.0]) returns [3.0, nan, 1.0, 2.0], not sorted at all, and moving the nan to the front changes the result again. Filter NaNs out before sorting.

Mutating the input. Calling .sort() on an argument changes the caller’s data. In a test harness that reuses the input to check your answer, or in a function called twice, that is a bug. Use sorted() unless you own the list.

The debugging habit I rely on is printing the sorted list for a small input that has ties before writing the scan that follows. If the ties come out in the wrong order, no amount of work on the second half of the solution will fix it.


When Not to Sort

Sorting gives you a full order in O(n log n). Many problems need less than that:

NeedBetter toolCost
Does a pair sum to target?Hash set/mapO(n)
Top k elements, k much smaller than nheapq.nlargest(k, arr)O(n log k)
k-th smallest elementQuickselectO(n) average
Values in a small known rangeCounting sort / bucketsO(n + range)
Most frequent elementsCounter + heap or bucketsO(n log k) or O(n)
import heapq
print(heapq.nlargest(3, [5, 1, 9, 3, 7, 2]))  # [9, 7, 5]

Sorting remains the right default when you need the elements in order for a later pass, when you need tie-breaking rules, or when n is small enough that the difference does not matter. The alternatives are worth reaching for when the problem statement explicitly asks for better than O(n log n).


Picking the sort key for a problem

Problem shapeSort byThen
Merge or count overlapsInterval startLinear scan against the last merged interval
Maximum non-overlapping selectionInterval endGreedy: take if it starts after the last end
Rooms / resources neededStartMin-heap of end times
Pairs or triples with a target sumValue (keep indices if needed)Two pointers
Many “how many below x” queriesValuebisect
Order defined by pairwise relationcmp_to_key comparatorJoin or scan
  • Python’s sort is stable, including with reverse=True; use tuple keys for multiple conditions and two stable passes for descending strings.
  • A cmp_to_key comparator returns negative, zero, or positive, never a bool.
  • In C++, a comparator must be a strict weak ordering: <, never <=.
  • Choose the sort key by asking at which moment your greedy makes its decision.

Warm-up

  • LeetCode 242: Valid Anagram
  • LeetCode 349: Intersection of Two Arrays
  • LeetCode 455: Assign Cookies

Core

  • LeetCode 56: Merge Intervals
  • LeetCode 179: Largest Number
  • LeetCode 274: H-Index
  • LeetCode 435: Non-overlapping Intervals
  • LeetCode 452: Minimum Number of Arrows to Burst Balloons

Harder

  • LeetCode 75: Sort Colors (one pass, no library sort)
  • LeetCode 347: Top K Frequent Elements (beat O(n log n))
  • LeetCode 315: Count of Smaller Numbers After Self (merge sort counting)