Kotlin Sort and Custom Comparators: The Coding Interview Reference

**sort***mutates in-place and returnsUnit;**sorted***returns a new list and works on read-only collections- Never subtract integers in a comparator: use
**compareBy { it.x }**or.compareTo()to avoid silent integer overflow compareByhandles multi-key sorts with chained selectors left-to-right; mix**thenBy**and**thenByDescending**for mixed-direction sortsIntArray.sort()is not stable; convert with.toTypedArray()first when sort stability mattersPriorityQueueaccepts the samecompareByDSL as list sorting;**reverseOrder()**gives a max-heap on natural ordernullsFirst/nullsLastwrap any comparator to safely sort collections with nullable fields
You type val sorted = list.sort(). The compiler is happy. You call .first() on sorted three lines later and everything explodes. Five minutes of confusion later you realize: sorted is Unit. You sorted into the void. Congratulations on your zero-result sort.
This reference covers the two traps that catch nearly everyone the first time, plus the full comparator API so you can sort by one key, three keys, or "smallest distance, break ties by row" without pausing mid-explanation to remember syntax.
The Naming Split You Must Memorize
Kotlin's sort functions come in two families, and the naming tells you which is which.
Functions starting with sort mutate in-place and return Unit. Functions starting with sorted return a new list and leave the original alone.
val list = mutableListOf(3, 1, 4, 1, 5) list.sort() // sorts list in-place, returns Unit list.sortBy { it } // in-place, returns Unit list.sortWith(compareBy { it }) // in-place, returns Unit val copy = list.sorted() // returns new List<Int> val copy2 = list.sortedBy { it } // returns new List<Int> val copy3 = list.sortedWith(compareBy { it }) // returns new List<Int>
The in-place variants (sort, sortBy, sortWith) only compile on MutableList or arrays. The sorted* variants work on any Iterable, including read-only List.
The classic interview bug: val sorted = list.sort() gives you Unit. Not null. Not a crash. Your variable holds the return value of a void call, and .first() explodes for reasons that cost you a full minute of staring at code that looks correct. One minute feels long at minute 40 of a 45-minute interview.
Natural Order Is One Call
For primitive-ish lists, natural order requires no comparator at all:
val nums = mutableListOf(5, 2, 8, 1) nums.sort() // [1, 2, 5, 8] nums.sortDescending() // [8, 5, 2, 1] val strs = listOf("banana", "apple", "cherry") val asc = strs.sorted() // ["apple", "banana", "cherry"] val desc = strs.sortedDescending() // ["cherry", "banana", "apple"]
For arrays, you get subrange sorting as a bonus:
val arr = intArrayOf(3, 1, 4, 1, 5) arr.sort() // sorts in-place arr.sort(1, 3) // sorts subrange [1, 3) in-place val boxed = arrayOf(3, 1, 4) boxed.sort() // also in-place val copy = boxed.sortedArray() // returns a new sorted Array<Int>
sortedDescending() and sortDescending() follow the same in-place vs. new-list convention as their ascending counterparts. Pick the wrong one and you're back to debugging Unit.
One Key? Use sortedBy
When you sort by one property, sortedBy is the right tool. Hand it a selector lambda that returns something Comparable, and Kotlin handles the rest.
data class Task(val name: String, val priority: Int) val tasks = listOf( Task("deploy", 2), Task("fix bug", 1), Task("write tests", 3) ) val byPriority = tasks.sortedBy { it.priority } // [fix bug (1), deploy (2), write tests (3)] val byPriorityDesc = tasks.sortedByDescending { it.priority } // [write tests (3), deploy (2), fix bug (1)]
sortedBy accepts any Comparable return type, including String, Int, Double, and even Pair (which compares lexicographically by first then second).
Two Keys or More: compareBy
The moment you need a secondary sort key, compareBy is cleaner than any manual comparator. You want "sort by length, then alphabetically on ties"? One expression:
val words = listOf("fig", "ant", "bee", "cat", "ox") val sorted = words.sortedWith(compareBy({ it.length }, { it })) // ["ox", "ant", "bee", "cat", "fig"]
Multiple lambdas inside compareBy are evaluated left to right. The first one that produces unequal results wins. This is equivalent to chaining with thenBy:
val sorted2 = words.sortedWith( compareBy<String> { it.length }.thenBy { it } )
Use thenByDescending when you want the secondary criterion reversed:
data class Student(val grade: Int, val name: String) val students = listOf( Student(90, "Zara"), Student(90, "Alice"), Student(85, "Bob") ) val result = students.sortedWith( compareByDescending<Student> { it.grade }.thenBy { it.name } ) // [Alice (90), Zara (90), Bob (85)]
Call .reversed() on any comparator to flip its direction without rewriting the whole expression.
val byLengthDesc = compareBy<String> { it.length }.reversed()
The Overflow Trap (Silent Wrong Answer Edition)
This one is everywhere in interview submissions, and it fails without a sound.
// WRONG: overflows when a is Int.MIN_VALUE and b is positive val wrong = tasks.sortedWith { a, b -> a.priority - b.priority }
When a.priority is -2_147_483_648 and b.priority is 1, the subtraction wraps around to a large positive number. Your comparator returns the wrong sign. The sort is wrong. No exception. No warning. You get a wrong answer on a problem that looks solved, and the test case that catches it looks completely normal.
Never subtract integers in a comparator. Use compareTo or the compareBy DSL instead.
// Correct option 1: compareTo val right = tasks.sortedWith { a, b -> a.priority.compareTo(b.priority) } // Correct option 2: compareBy (preferred) val right2 = tasks.sortedWith(compareBy { it.priority })
The compareBy DSL avoids the trap entirely because Kotlin calls .compareTo() internally. Default to it. The raw lambda gives you nothing except more ways to be wrong.
Three More Ways to Lose a Minute
sort() on a read-only List won't compile. If you receive a List<T> parameter and call sortBy, the compiler refuses before you even run it. Call sortedBy instead, or convert with .toMutableList() first.
fun process(items: List<Int>): List<Int> { // items.sortBy { it } // compile error return items.sortedBy { it } // fine }
IntArray.sort() is not stable. Kotlin's List and Array<T> sorts delegate to the JVM's merge-based algorithm, which is stable. IntArray, LongArray, and other primitive arrays delegate to Java's dual-pivot quicksort, which is not. If you need stable sort on a primitive array, convert to Array<Int> first.
val primitive = intArrayOf(3, 1, 4, 1, 5) primitive.sort() // fast, but not stable val boxed = primitive.toTypedArray() boxed.sort() // stable (merge-based)
Null handling requires an explicit wrapper. sortedBy { it?.name } compiles when name is nullable, but if any element is itself null the comparator throws a NullPointerException. Use nullsFirst or nullsLast from kotlin.comparisons:
data class Person(val name: String?) val people = listOf(Person("Zara"), Person(null), Person("Alice")) val sorted = people.sortedWith( compareBy(nullsLast(naturalOrder())) { it.name } ) // [Alice, Zara, null]
nullsFirst puts nulls at the front. nullsLast sends them to the back. Both wrap an inner comparator that handles non-null values.
Heaps Take the Same Comparator
Sorting collections is half the picture. In interviews you often need a heap, and Kotlin's PriorityQueue is the JVM class. You pass a Comparator<T>. The compareBy DSL drops in directly, so once you know one you know both.
import java.util.PriorityQueue data class Cell(val row: Int, val col: Int, val dist: Int) // min-heap by distance val minHeap = PriorityQueue(compareBy<Cell> { it.dist }) minHeap.add(Cell(0, 0, 5)) minHeap.add(Cell(1, 1, 2)) println(minHeap.poll()) // Cell(row=1, col=1, dist=2) // max-heap on integers val maxHeap = PriorityQueue<Int>(reverseOrder()) maxHeap.addAll(listOf(3, 1, 4, 1, 5)) println(maxHeap.poll()) // 5
reverseOrder() is Kotlin's shorthand for a reversed natural-order comparator. It replaces Collections.reverseOrder() from Java.
For multi-key heaps, compareBy with multiple selectors works directly as the constructor argument, no extra variable needed:
val heap = PriorityQueue(compareBy<Cell>({ it.dist }, { it.row }))
Kotlin Sort Comparator: Quick Reference
| Goal | Expression |
|---|---|
| Sort list in-place | list.sort() / list.sortBy { it.x } |
| Sort, get new list | list.sorted() / list.sortedBy { it.x } |
| Descending | sortedDescending() / sortedByDescending { it.x } |
| Multi-key ascending | sortedWith(compareBy({ it.a }, { it.b })) |
| Multi-key mixed | compareByDescending<T> { it.a }.thenBy { it.b } |
| Flip any comparator | comparator.reversed() |
| Nulls first | compareBy(nullsFirst(naturalOrder())) { it.x } |
| Nulls last | compareBy(nullsLast(naturalOrder())) { it.x } |
| Min-heap | PriorityQueue(compareBy { it.x }) |
| Max-heap (natural) | PriorityQueue(reverseOrder()) |
Get It Into Your Hands
Reading comparator syntax is not the same as having it. When you hit an interval scheduling problem and need to sort by start time with end time as a tiebreaker, the answer needs to arrive in one line without a pause to think. Same when you build a Dijkstra heap that breaks ties on column index.
SpaceComplexity runs voice-based DSA mock interviews where you narrate your approach out loud, including sorting decisions. Getting the comparator syntax wrong mid-explanation is audible in a way that grinding LeetCode quietly never reveals.
Key Takeaways
sort= in-place, returnsUnit.sorted= new list, works on read-only collections.- Never subtract integers in a comparator. Use
compareByor.compareTo(). IntArray.sort()is not stable.List<T>.sortedWith()andArray<T>.sort()are.compareBy({ it.a }, { it.b })handles multi-key ascending. MixthenByDescendingfor mixed-direction sorts.nullsFirst/nullsLastwrap any comparator to handle nullable selectors.PriorityQueue(compareBy { it.x })accepts the same comparator DSL as list sorting.