Kotlin Interview Idioms: The Shortcuts That Actually Save Time

May 29, 202610 min read
interview-prepdsaalgorithmskotlin
Kotlin Interview Idioms: The Shortcuts That Actually Save Time
TL;DR
  • ArrayDeque handles both stack and queue with O(1) at both ends — skip Stack and LinkedList entirely
  • compareByDescending { it } builds max-heaps without the subtraction overflow trap
  • getOrPut initializes adjacency lists and frequency maps in one call instead of three lines
  • takeUnless { it == -1 } converts the indexOf sentinel to nullable, enabling clean Elvis chaining
  • tailrec eliminates StackOverflowError on deep inputs at compile time with no algorithm change
  • sortedBy returns a new list; sortBy mutates — mixing them compiles cleanly and fails silently
  • in list is O(n) — convert to HashSet before 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

GoalIdiom
StackArrayDeque + addLast / removeLast / last
QueueArrayDeque + addLast / removeFirst / first
Min-heapPriorityQueue<Int>()
Max-heapPriorityQueue(compareByDescending { it })
Frequency mapfreq[n] = (freq[n] ?: 0) + 1
Adjacency listgraph.getOrPut(u) { mutableListOf() }.add(v)
Group by keylist.groupBy { keyFn(it) }
Split by predicatelist.partition { predicate }
Count matcheslist.count { predicate }
Adjacent pairslist.zipWithNext()
Sliding windowlist.windowed(k) { transform }
Range [0, n)0 until n
Reverse rangen - 1 downTo 0
Null to defaultvalue ?: default
Null-safe branchvalue?.let { ... } ?: fallback
Conditional nullvalue.takeIf { condition }
Deep recursiontailrec fun f(...)
Safe sumlist.sumOf { it.toLong() }
True list copylist.toList()

Further Reading