Kotlin Interview Idioms: The Shortcuts That Actually Save Time

ArrayDequehandles both stack and queue with O(1) at both ends — skipStackandLinkedListentirelycompareByDescending { it }builds max-heaps without the subtraction overflow trapgetOrPutinitializes adjacency lists and frequency maps in one call instead of three linestakeUnless { it == -1 }converts theindexOfsentinel to nullable, enabling clean Elvis chainingtailreceliminatesStackOverflowErroron deep inputs at compile time with no algorithm changesortedByreturns a new list;sortBymutates — mixing them compiles cleanly and fails silentlyin listis O(n) — convert toHashSetbefore any repeated membership check
You picked Kotlin for your interview. Respect. The null safety model alone eliminates entire categories of bugs that eat Java engineers alive, and the standard library is genuinely expressive once you know where to look.
The problem is knowing which idioms to grab under pressure. Knowing a language and knowing what to reach for when you have 35 minutes and an interviewer watching you type are different skills. This guide is about the second one.
Every section maps to something that either saves real time or introduces a silent bug when you get it wrong. No coroutines. No Android APIs. Nothing you'd never touch on LeetCode.
If you're still picking a language, best language for coding interviews covers the full tradeoff. Coming from Java, Java vs Kotlin for coding interviews goes deeper on what actually changes. For the full stdlib collection decision table, Kotlin for coding interviews has you.
Stack and Queue Are the Same Type. Stop Importing Two.
ArrayDeque handles both stack and queue operations with O(1) at both ends. No java.util.Stack with its synchronization overhead. No LinkedList theater.
val stack = ArrayDeque<Int>() stack.addLast(1) // push stack.removeLast() // pop stack.last() // peek (throws on empty, use lastOrNull()) val queue = ArrayDeque<Int>() queue.addLast(1) // enqueue queue.removeFirst() // dequeue queue.first() // peek
Kotlin's ArrayDeque is backed by a circular array, so both-end operations are O(1) amortized. It's in the stdlib. No imports. Use it everywhere you'd reach for Stack or LinkedList in Java, which is to say: constantly.
Heap Comparators That Won't Silently Eat Your Data
Kotlin wraps java.util.PriorityQueue. Default is min-heap. For everything else, use compareBy and skip the subtraction trick that looks clever until it overflows.
// Min-heap val minHeap = PriorityQueue<Int>() // Max-heap: compareByDescending, not { a, b -> b - a } val maxHeap = PriorityQueue<Int>(compareByDescending { it }) // Multi-field: frequency descending, then value ascending data class Entry(val value: Int, val freq: Int) val heap = PriorityQueue<Entry>( compareBy<Entry> { it.freq }.thenByDescending { it.value } )
compareBy { ... }.thenBy { ... } composes comparators without overflow risk. The subtraction comparator { a, b -> b - a } silently wraps when b is Int.MIN_VALUE and a is positive. You will ship it, it will pass most test cases, and then it will fail on a test case involving -2147483648. compareByDescending never does this.
Frequency Maps in One Line (No, Really)
The cleanest frequency counter uses the Elvis operator directly on the map read:
val freq = mutableMapOf<Int, Int>() for (n in nums) { freq[n] = (freq[n] ?: 0) + 1 }
getOrDefault works too, but ?: 0 is shorter and most interviewers find it readable:
freq[n] = freq.getOrDefault(n, 0) + 1
When you need to initialize a key before mutating its value, getOrPut does it in one call:
val graph = mutableMapOf<Int, MutableList<Int>>() graph.getOrPut(u) { mutableListOf() }.add(v)
One call instead of the classic three-liner: check if key exists, create list, append. You've written that three-liner a hundred times. You can stop now.
Sorting Without a Comparator Class
sortedBy for single-key ascending. sortedByDescending for single-key descending. sortedWith(compareBy(...).thenBy(...)) for multi-key.
// Single key val byLength = words.sortedBy { it.length } // Multi-key: length descending, then alphabetical val sorted = words.sortedWith( compareByDescending<String> { it.length }.thenBy { it } ) // In-place on MutableList val list = mutableListOf(3, 1, 2) list.sortBy { it } // mutates list, returns Unit
sortedBy returns a new list. sortBy mutates in place and returns Unit. Both compile. The bug is silent. You call sortBy, use the discarded result, get confused, restart your solution from scratch, and lose six minutes. The interviewer has seen this happen before. They are not going to say anything.
Collection Ops That Kill Nested Loops
groupBy
Groups elements by a key and returns Map<K, List<V>>. The anagram grouping problem collapses to one line:
val groups = strs.groupBy { it.toCharArray().also { arr -> arr.sort() }.joinToString("") }
partition
Splits a collection into two lists based on a predicate. One pass, zero extra loops.
val (evens, odds) = nums.partition { it % 2 == 0 }
count, sumOf, maxOf, minOf
val evenCount = nums.count { it % 2 == 0 } // no intermediate list vs filter().size val total = nums.sumOf { it.toLong() } // toLong() prevents Int overflow val biggest = nums.maxOrNull() ?: 0 // safe on empty; maxOf() throws val mostFrequent = freq.maxByOrNull { it.value }?.key
count { } is faster and cleaner than filter { }.size because it skips building the filtered list. Same logic applies to any { }, all { }, and none { }. Every time you write filter { }.size, a compiler warning gets its wings and silently disappears because Kotlin doesn't actually warn you.
Iteration Constructs Worth Drilling
indices and withIndex
for (i in arr.indices) { /* 0..arr.size-1 */ } for ((i, v) in arr.withIndex()) { // both index and value, no manual increment }
Range operators
for (i in 0 until n) { } // [0, n), safe when n == 0 for (i in n - 1 downTo 0) { } // reverse for (i in 0..n step 2) { } // every other element
0 until n is safer than 0..n-1 when n might be zero. The intent is clearer and it won't bite you on an empty input edge case you definitely forgot to test.
zipWithNext and windowed
// Check if strictly increasing in one expression val ok = nums.zipWithNext().all { (a, b) -> a < b } // Sliding window sums of size k val windowSums = nums.windowed(k) { window -> window.sum() }
zipWithNext pairs [a, b, c, d] as [(a, b), (b, c), (c, d)]. Covers "check all adjacent pairs" without an index loop. windowed manages the subarray bounds so you don't manage off-by-one on the window edges yourself. These two replace a surprising number of clunky for-loops.
Null Safety as Control Flow, Not Just Defense
Kotlin's null operators aren't just defensive. They structure the logic. Used well, they replace early returns and nested conditionals.
Elvis for early return
fun process(root: TreeNode?): Int { val node = root ?: return 0 // node is guaranteed non-null past this line }
let for null-safe transformation
val result = map[key]?.let { transform(it) } ?: defaultValue
takeIf and takeUnless
val positive = n.takeIf { it > 0 } // null when n <= 0 // Convert -1 sentinel to null, then use ?: for the default val idx = list.indexOf(target).takeUnless { it == -1 } ?: return false
takeUnless { it == -1 } turns the indexOf sentinel into nullable, letting you chain Elvis instead of breaking the flow with an if-else block. The -1 check is still there. It's just not an if-statement anymore.
Scope Functions: The Two You Actually Need
apply and also cover 90% of scope function use in an interview context.
// apply: configure an object, returns the object val node = ListNode(value).apply { next = existingNode } // also: side effect without breaking a chain, returns the original val result = buildAnswer() .also { println("debug: $it") } .filter { it > 0 }
with, let, and run are useful in production code. In an interview, reaching for run when you meant let is a great way to confuse yourself at exactly the wrong moment. Stick with let for null checks and apply for construction. That's the two.
tailrec: Stack Safety Without Rewriting Your Algorithm
tailrec converts a tail-recursive function into a loop at compile time. It prevents StackOverflowError on deep inputs without you having to think about iteration.
tailrec fun gcd(a: Int, b: Int): Int = if (b == 0) a else gcd(b, a % b)
The constraint: the recursive call must be the very last operation. No addition, no wrapping in another function call after the recursive call. If it's not in tail position, the compiler warns and leaves it as regular recursion. Which is fine. At least it warned you.
Practice that recursion-to-communication transition on SpaceComplexity, which runs rubric-based voice mock interviews so you practice narrating your reasoning, not just writing it.
The Traps
This is the fun part. "Fun" meaning "things that will betray you in front of an interviewer."
Int overflow is silent
Kotlin's Int is 32-bit. Multiplications and large sums overflow without any exception. No warning. No stack trace. Just a wrong answer.
// WRONG: product of large nums overflows Int silently val product = nums.fold(1) { acc, n -> acc * n } // RIGHT: start accumulator as Long val product = nums.fold(1L) { acc, n -> acc * n }
Starting the accumulator as 1L instead of 1 is the entire fix. Also reach for sumOf { it.toLong() } any time the sum could exceed Int.MAX_VALUE. The test case that triggers this is always the one with [1000000, 1000000, 1000000].
List is read-only, not immutable
List<T> is a read-only interface. The underlying object can still be a MutableList. If you alias it, mutations through the mutable reference are visible through the read-only one.
val mutable = mutableListOf(1, 2, 3) val readOnly: List<Int> = mutable // no copy mutable.add(4) println(readOnly.size) // 4, not 3
When you need a genuine snapshot, call toList(). It allocates a new list. The alias trap shows up most painfully in backtracking problems where you're building result lists and wondering why everything is empty at the end.
in on a List is O(n)
if (x in list) calls contains(), which is a linear scan. Convert to a HashSet before repeated membership checks.
val seen = nums.toHashSet() if (x in seen) { ... } // O(1)
This one is easy to miss because in looks like a set operation and Kotlin's syntax makes it feel fast. It is not fast. The runtime doesn't care how clean the syntax is.
Array.sort stability
IntArray.sort() uses dual-pivot quicksort internally, which is unstable. Array<Int>.sort() (the boxed version) uses Timsort and is stable. If your solution depends on stable sort, either use a List and sortedBy, or box the array. Unstable sort on a stable-sort problem is another "passes most cases, fails mysteriously on duplicates" situation.
Kotlin Interview Idioms: Quick Reference
| Goal | Idiom |
|---|---|
| Stack | ArrayDeque + addLast / removeLast / last |
| Queue | ArrayDeque + addLast / removeFirst / first |
| Min-heap | PriorityQueue<Int>() |
| Max-heap | PriorityQueue(compareByDescending { it }) |
| Frequency map | freq[n] = (freq[n] ?: 0) + 1 |
| Adjacency list | graph.getOrPut(u) { mutableListOf() }.add(v) |
| Group by key | list.groupBy { keyFn(it) } |
| Split by predicate | list.partition { predicate } |
| Count matches | list.count { predicate } |
| Adjacent pairs | list.zipWithNext() |
| Sliding window | list.windowed(k) { transform } |
| Range [0, n) | 0 until n |
| Reverse range | n - 1 downTo 0 |
| Null to default | value ?: default |
| Null-safe branch | value?.let { ... } ?: fallback |
| Conditional null | value.takeIf { condition } |
| Deep recursion | tailrec fun f(...) |
| Safe sum | list.sumOf { it.toLong() } |
| True list copy | list.toList() |
Further Reading
- Kotlin Collections Overview, read-only vs mutable distinction, the full collection hierarchy
- Kotlin Scope Functions,
let,run,with,apply,alsowith a decision table - Kotlin Stdlib: Collections API, full reference for every function mentioned here
- Tail-Recursive Functions,
tailrecconstraints and what the compiler generates - Effective Kotlin: groupingBy vs groupBy, when
groupingByis worth the extra complexity overgroupBy