sorting and comparators
Comparable, Comparator.comparing().thenComparing(), and TimSort.
- chapter
- chapter 06.11
- reading time
- 6 min read
Comparable is an object's one natural order (compareTo). a Comparator is any other order, built from small pieces: Comparator.comparing(...).thenComparing(...).reversed().
objects are sorted with TimSort: it finds runs that are already in order and merges them. it's stable, so equal elements keep their original order, which is what makes chained sorts work.
the picture
you know how you sort a spreadsheet by city, then by name, and expect names to stay sorted within each city? that only works because the sort is stable, and it only reads well in code because java lets you say it as one chain.
a comparator is a judge with one job: given two things, say which goes first (negative), second (positive), or "don't care" (zero). everything else, the sort does.
Comparable: the natural order
a class that has one obvious order implements Comparable<T> and its compareTo. String (alphabetical by char code), Integer, LocalDate, BigDecimal all do. that's what Collections.sort(list), TreeMap and TreeSet use when you don't pass anything.
record Player(String name, int score) implements Comparable<Player> {
public int compareTo(Player o) {
return Integer.compare(score, o.score);
}
}
void main() {
var ps = new ArrayList<>(List.of(new Player("ada", 30), new Player("bob", 10), new Player("cy", 20)));
Collections.sort(ps);
IO.println(ps.stream().map(Player::name).toList());
ps.sort(Comparator.comparing(Player::name).reversed());
IO.println(ps.stream().map(Player::name).toList());
}[bob, cy, ada] [cy, bob, ada]
the contract, in short: compareTo must be antisymmetric (if a < b then b > a), transitive (a < b and b < c means a < c), and should agree with equals (returns 0 exactly when equals is true). break the last one and hash-based and tree-based collections disagree about duplicates (the BigDecimal story in sets).
Comparator: every other order
java.util.Comparator has a set of static and default methods that compose like lego. you almost never write compare by hand anymore.
| # | name | age | city | was at |
|---|---|---|---|---|
| 0 | abe | 25 | london | 6 |
| 1 | bob | 25 | null | 1 |
| 2 | dan | 25 | paris | 3 |
| 3 | ada | 36 | london | 0 |
| 4 | eve | 36 | berlin | 2 |
| 5 | fay | 36 | null | 5 |
| 6 | cat | 41 | berlin | 4 |
the input order is ada, bob, eve, dan, cat, fay, abe. the primary key column is amber. set then to none and sort by age: equal ages keep their input order, because java's object sort is stable.
age with no tie-breaker and look at the "was at" column: ties keep their input order. then sort by city without handling nulls and see what two missing cities do. results match java's List.sort exactly (every combination was checked on jdk 25).comparing(keyExtractor): compare by a key's natural order.comparingInt/comparingLong/comparingDoubleskip the boxing.comparing(key, keyComparator): compare by a key, with a custom order for that key.thenComparing(...): tie-breaker, only consulted when everything before it says 0.reversed(): flips the whole chain built so far. to reverse only one key, reverse its key comparator:comparing(Person::age, reverseOrder()).nullsFirst(cmp)/nullsLast(cmp): decide wherenulls go, instead of throwing.naturalOrder()/reverseOrder(): theComparableorder and its opposite.
inside the sort: TimSort
List.sort, Collections.sort, Arrays.sort(Object[]) and stream().sorted() all end up in TimSort (named after Tim Peters, who wrote it for Python). its insight: real data is rarely random. it's often partly sorted, or sorted in chunks, or reversed. so instead of ignoring that order, TimSort hunts for it.
24 numbers. timsort walks left to right looking for runs that are already in order. (minRun is 4 here so you can see the merges. the real one picks 16 to 32 for big arrays, and arrays under 32 never merge at all.)
- find a run: the longest stretch starting here that's ascending, or strictly descending (reversed in place, which keeps it stable since no equal elements swap).
- extend short runs: if a run is shorter than
minRun(16 to 32 for big arrays), binary insertion sort grows it, which is fast on tiny ranges. - push and keep the stack balanced: each run on the stack must be longer than the one above it, and longer than the two above it combined. whenever that breaks, merge. this keeps merges between runs of similar size, which is where merging is cheapest.
- merge what's left at the end.
the real merge also "gallops": when one run keeps winning, it switches to exponential search and copies whole blocks at once. arrays under 32 elements skip all of it and just get one run plus binary insertion sort.
the result: O(n log n) worst case, O(n) on already-sorted or reversed input, and stable. the price: up to n/2 extra references of scratch space for merging.
Comparison method violates its general contract!+
TimSort trusts your comparator. when merging it relies on the runs being consistently ordered, and if it catches a contradiction it throws IllegalArgumentException: Comparison method violates its general contract!. you see it with comparators that aren't transitive, use randomness, read mutable state, or mishandle NaN and null. small inputs often sort "fine" (just wrong, like the subtraction example), because the check only fires during merges, so the bug hides until the list grows past 32.
Comparable= the natural order, inside the class.Comparator= any order, outside it.- build comparators:
comparing,thenComparing,reversed,nullsFirst. - never compare with subtraction. use
Integer.compareorcomparingInt. - object sorts are TimSort: stable, and fast on partly sorted data.
- primitive arrays use dual-pivot quicksort. stability doesn't matter there.