Kotlin Sort and Custom Comparators: The Coding Interview Reference

May 29, 20268 min read
dsaalgorithmsinterview-prepkotlin
Kotlin Sort and Custom Comparators: The Coding Interview Reference
TL;DR
  • **sort*** mutates in-place and returns Unit; **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
  • compareBy handles multi-key sorts with chained selectors left-to-right; mix **thenBy** and **thenByDescending** for mixed-direction sorts
  • IntArray.sort() is not stable; convert with .toTypedArray() first when sort stability matters
  • PriorityQueue accepts the same compareBy DSL as list sorting; **reverseOrder()** gives a max-heap on natural order
  • nullsFirst / nullsLast wrap 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

GoalExpression
Sort list in-placelist.sort() / list.sortBy { it.x }
Sort, get new listlist.sorted() / list.sortedBy { it.x }
DescendingsortedDescending() / sortedByDescending { it.x }
Multi-key ascendingsortedWith(compareBy({ it.a }, { it.b }))
Multi-key mixedcompareByDescending<T> { it.a }.thenBy { it.b }
Flip any comparatorcomparator.reversed()
Nulls firstcompareBy(nullsFirst(naturalOrder())) { it.x }
Nulls lastcompareBy(nullsLast(naturalOrder())) { it.x }
Min-heapPriorityQueue(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, returns Unit. sorted = new list, works on read-only collections.
  • Never subtract integers in a comparator. Use compareBy or .compareTo().
  • IntArray.sort() is not stable. List<T>.sortedWith() and Array<T>.sort() are.
  • compareBy({ it.a }, { it.b }) handles multi-key ascending. Mix thenByDescending for mixed-direction sorts.
  • nullsFirst / nullsLast wrap any comparator to handle nullable selectors.
  • PriorityQueue(compareBy { it.x }) accepts the same comparator DSL as list sorting.

Further Reading