Post

Ten Coding Interview Questions in Kotlin, With the Gotchas Interviewers Are Watching For

Architect loops still have a coding screen. Here are the ten problems that cover the ten patterns that matter, each with an idiomatic Kotlin solution, the mistakes interviewers are listening for, and the Kotlin-specific traps that trip up people coming from Java.

Ten Coding Interview Questions in Kotlin, With the Gotchas Interviewers Are Watching For

Architect interview loops still have a coding round. It is usually shorter and easier than the one a new graduate gets, and the interviewer is not checking whether you can invert a binary tree. They are checking whether you can still write correct code under mild pressure, whether you know the standard patterns well enough to reach for the right one in thirty seconds, and whether you talk about complexity, edge cases, and trade-offs without being prompted. A senior candidate who fumbles a sliding window loses more credibility than a junior who does, because the bar for “obviously fluent” is higher.

This post is ten problems, chosen so that each one is the canonical example of a pattern: hash map, stack, sort and sweep, sliding window, grid breadth-first search, cache design, heap, binary search, linked-list pointers, and dynamic programming. Each comes with an idiomatic Kotlin solution and the specific mistakes interviewers listen for. The language-level traps that cut across all ten are collected in the gotchas section, because most of them are the same trap wearing different clothes.

The Question

The coding round in an architect loop tends to take one of these shapes:

  • “Here is a problem. Solve it in the language of your choice.” Then a follow-up that changes a constraint.
  • “Implement an LRU cache.” A design-flavored coding question, the most common one for senior candidates.
  • “Here is a function with a bug. Find it.” Increasingly common, and the bug is usually one of the gotchas below.
  • “Write the core of the thing you just designed.” A hold, a rate limiter’s token bucket, a dedupe check.

Kotlin is a good choice if you know it well and a poor one if you are translating from Java in your head. The interviewer will notice Array<Int> where IntArray belonged.

What They Are Really Checking

  1. Pattern recognition. Do you see “longest substring without” and reach for a sliding window in the first ten seconds? Naming the pattern out loud is worth as much as the code.
  2. Correctness at the edges. Empty input, one element, duplicates, the last element, the boundary case that the naive version gets wrong. Senior candidates enumerate these before writing.
  3. Complexity, unprompted. Time and space, stated when you finish, with the alternative you did not choose and why.
  4. Fluency in the language you picked. Idiomatic collections, no boxing where a primitive array exists, the right deque API, and the difference between == and ===.
  5. Talking while coding. The interviewer cannot read your mind. A silent minute followed by correct code scores lower than a narrated minute with one typo.

The Gotchas

These are the Kotlin-specific traps that recur across all ten problems. They are the ones that make an interviewer conclude you write Kotlin occasionally rather than daily.

Gotcha 1: Array<Int> where IntArray belongs. Array<Int> boxes every element. IntArray, CharArray, BooleanArray, and LongArray are primitive and are what the standard library and any interviewer expect for numeric problems.

Gotcha 2: Mixing Kotlin’s ArrayDeque with Java’s. kotlin.collections.ArrayDeque uses addLast, removeLast, removeFirst, and last(). java.util.ArrayDeque uses push, pop, poll, and peek. Import one, use its API, and do not switch halfway through the whiteboard.

Gotcha 3: == when you meant ===. == calls equals. On a data class that compares fields. For linked-list nodes, sentinels, and anything where identity matters, use ===.

Gotcha 4: Expecting a smart cast on a mutable property. if (node.next != null) node.next.value does not compile, because next could change between the check and the use. Copy to a local val, or use ?. and !! deliberately.

Gotcha 5: .. versus until. 0..n includes n. 0 until n does not. Most Kotlin off-by-one bugs in interviews are this one.

Gotcha 6: Negative modulus. -1 % 5 is -1, as in Java. Ring buffers and hash bucketing need Math.floorMod or ((x % n) + n) % n.

Gotcha 7: Forgetting that PriorityQueue and LinkedHashMap overrides are Java. They live in java.util. The import is easy to forget when the rest of the file is pure Kotlin, and object : LinkedHashMap<K, V>(...) with removeEldestEntry is Java’s API, not a Kotlin one.

Gotcha 8: Int overflow in sums, midpoints, and sentinels. Int.MAX_VALUE + 1 wraps. Use lo + (hi - lo) / 2 for midpoints, amount + 1 as a dynamic-programming infinity, and Long for accumulators when the input bounds justify it.

Gotcha 9: sortedBy when you meant sortBy. sortedBy allocates a new list. sortBy and sortWith sort in place. Say which one you are using and why; in-place sorting mutates the caller’s input.

Gotcha 10: Recursion where the JVM stack cannot follow. Depth-first search on a large grid and recursive list reversal both overflow on realistic inputs. Iterative with an explicit queue or loop is the answer, and saying why is the point.

How to Answer

For each problem: name the pattern, state the edge cases, write the code, state the complexity, and mention the alternative. The code below is what the finished whiteboard should look like.

1. Two Sum (hash map)

Return the indices of two numbers that add to target.

1
2
3
4
5
6
7
8
fun twoSum(nums: IntArray, target: Int): IntArray {
    val seen = HashMap<Int, Int>()            // value -> index
    for ((i, n) in nums.withIndex()) {
        seen[target - n]?.let { return intArrayOf(it, i) }
        seen[n] = i
    }
    throw IllegalArgumentException("no solution")
}

O(n) time, O(n) space. The alternative is sort and two-pointer at O(n log n) with O(1) extra space, which loses the original indices.

  • Check the map before inserting the current number, or [3, 3] with target 6 returns the same index twice.
  • If inputs can be near Int.MAX_VALUE, target - n overflows. Say it, and use Long if asked.

2. Valid Parentheses (stack)

1
2
3
4
5
6
7
8
9
fun isValid(s: String): Boolean {
    val pairs = mapOf(')' to '(', ']' to '[', '}' to '{')
    val stack = ArrayDeque<Char>()
    for (c in s) {
        if (c in pairs.values) stack.addLast(c)
        else if (stack.isEmpty() || stack.removeLast() != pairs[c]) return false
    }
    return stack.isEmpty()
}

O(n) time and space.

  • The final stack.isEmpty() check. "((" passes the loop and is still invalid.
  • Check isEmpty() before popping, or ")" throws.

3. Merge Intervals (sort and sweep)

1
2
3
4
5
6
7
8
9
10
11
12
13
fun merge(intervals: Array<IntArray>): Array<IntArray> {
    if (intervals.isEmpty()) return intervals
    intervals.sortBy { it[0] }
    val out = ArrayList<IntArray>()
    var cur = intervals[0]
    for (i in 1 until intervals.size) {
        val next = intervals[i]
        if (next[0] <= cur[1]) cur[1] = maxOf(cur[1], next[1])
        else { out.add(cur); cur = next }
    }
    out.add(cur)
    return out.toTypedArray()
}

O(n log n) for the sort, O(n) space for the output.

  • Sort by start first. Nothing works without it.
  • maxOf(cur[1], next[1]), not next[1]. [1, 10] followed by [2, 3] must stay [1, 10].
  • <= merges touching intervals like [1, 4] and [4, 5]. Ask whether touching counts as overlapping.
  • This mutates the input, both by sorting in place and by writing cur[1]. Say so, or copy first.

4. Longest Substring Without Repeating Characters (sliding window)

1
2
3
4
5
6
7
8
9
10
11
fun lengthOfLongestSubstring(s: String): Int {
    val lastSeen = HashMap<Char, Int>()
    var best = 0
    var left = 0
    for ((right, c) in s.withIndex()) {
        lastSeen[c]?.let { if (it >= left) left = it + 1 }
        lastSeen[c] = right
        best = maxOf(best, right - left + 1)
    }
    return best
}

O(n) time, O(k) space for the alphabet.

  • The it >= left guard. Without it, "abba" moves left backwards when the second a is seen and the answer is wrong. This is the single most common bug in this problem.
  • The window only ever moves right. If you find yourself shrinking from the right, the invariant is wrong.
  • Char is a UTF-16 code unit. If the interviewer mentions emoji, say the solution counts code units, not characters.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
fun numIslands(grid: Array<CharArray>): Int {
    if (grid.isEmpty()) return 0
    val rows = grid.size
    val cols = grid[0].size
    val dirs = intArrayOf(1, 0, -1, 0, 0, 1, 0, -1)
    val queue = ArrayDeque<Int>()
    var count = 0
    for (r in 0 until rows) for (c in 0 until cols) {
        if (grid[r][c] != '1') continue
        count++
        grid[r][c] = '0'
        queue.addLast(r * cols + c)
        while (queue.isNotEmpty()) {
            val cell = queue.removeFirst()
            val cr = cell / cols
            val cc = cell % cols
            for (d in 0 until 8 step 2) {
                val nr = cr + dirs[d]
                val nc = cc + dirs[d + 1]
                if (nr in 0 until rows && nc in 0 until cols && grid[nr][nc] == '1') {
                    grid[nr][nc] = '0'
                    queue.addLast(nr * cols + nc)
                }
            }
        }
    }
    return count
}

O(rows × cols) time; O(min(rows, cols)) queue space in practice, O(rows × cols) worst case.

  • Recursive depth-first search overflows the JVM stack on a large grid. Iterative breadth-first search is the safe default, and saying why is worth points.
  • Mark a cell visited when you enqueue it, not when you dequeue it. Otherwise the same cell enters the queue several times.
  • Encoding the cell as one Int avoids allocating a Pair per cell.
  • This mutates the grid. Ask if that is allowed; if not, use a BooleanArray visited set.

6. LRU Cache (design)

1
2
3
4
5
6
7
class LRUCache(private val capacity: Int) {
    private val map = object : LinkedHashMap<Int, Int>(capacity, 0.75f, true) {
        override fun removeEldestEntry(eldest: MutableMap.MutableEntry<Int, Int>) = size > capacity
    }
    fun get(key: Int): Int = map[key] ?: -1
    fun put(key: Int, value: Int) { map[key] = value }
}

O(1) for both operations.

  • The third constructor argument, accessOrder = true, is what makes get count as a use. Without it you have a first-in-first-out cache.
  • Most interviewers will say “now do it without LinkedHashMap.” Be ready to write the hash map plus doubly linked list version: a Node(key, value, prev, next), sentinel head and tail, moveToFront, and removeLast. Practice it once so the pointer surgery is automatic.
  • get on an access-ordered map is a structural modification. Iterating while calling get throws.
  • Not thread-safe. Say so before they ask.

7. Top K Frequent Elements (heap)

1
2
3
4
5
6
7
8
9
10
11
12
import java.util.PriorityQueue

fun topKFrequent(nums: IntArray, k: Int): IntArray {
    val freq = HashMap<Int, Int>()
    for (n in nums) freq[n] = (freq[n] ?: 0) + 1
    val heap = PriorityQueue<Map.Entry<Int, Int>>(compareBy { it.value })   // min-heap on count
    for (e in freq.entries) {
        heap.add(e)
        if (heap.size > k) heap.poll()
    }
    return IntArray(heap.size) { heap.poll().key }
}

O(n log k) time, O(n) space for the frequency map.

  • A min-heap of size k, not a max-heap of everything. That is the difference between O(n log k) and O(n log n), and it is the point of the question.
  • compareBy { it.value } returns a Comparator, which is what the constructor wants.
  • The result order is unspecified. If they want descending, reverse it, or use bucket sort by frequency for O(n).
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
fun search(nums: IntArray, target: Int): Int {
    var lo = 0
    var hi = nums.size - 1
    while (lo <= hi) {
        val mid = lo + (hi - lo) / 2
        when {
            nums[mid] == target -> return mid
            nums[lo] <= nums[mid] ->                              // left half is sorted
                if (target in nums[lo] until nums[mid]) hi = mid - 1 else lo = mid + 1
            else ->                                               // right half is sorted
                if (target in nums[mid] + 1..nums[hi]) lo = mid + 1 else hi = mid - 1
        }
    }
    return -1
}

O(log n) time, O(1) space.

  • nums[lo] <= nums[mid] with <=, not <. The two-element case [3, 1] breaks otherwise.
  • Duplicates break the “which half is sorted” test. If the interviewer adds them, you need an extra lo++ when nums[lo] == nums[mid] == nums[hi], and the worst case becomes O(n).
  • until excludes the end, which is correct here because mid was already checked.

9. Reverse a Linked List and Detect a Cycle (pointers)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class ListNode(var value: Int, var next: ListNode? = null)

fun reverse(head: ListNode?): ListNode? {
    var prev: ListNode? = null
    var cur = head
    while (cur != null) {
        val next = cur.next
        cur.next = prev
        prev = cur
        cur = next
    }
    return prev
}

fun hasCycle(head: ListNode?): Boolean {
    var slow = head
    var fast = head
    while (fast?.next != null) {
        slow = slow!!.next
        fast = fast.next!!.next
        if (slow === fast) return true
    }
    return false
}

Both O(n) time, O(1) space. The cycle check is Floyd’s tortoise and hare.

  • Save next before rewiring cur.next, and return prev, not cur. Both are classic off-by-one-pointer mistakes.
  • === for reference equality. If ListNode were a data class, == would compare values and two different nodes with the same value would look like a cycle.
  • Recursive reversal is elegant and overflows on long lists. Iterative is the answer.

10. Coin Change (dynamic programming)

Fewest coins to make amount, or -1.

1
2
3
4
5
6
7
8
9
fun coinChange(coins: IntArray, amount: Int): Int {
    val inf = amount + 1                       // safe "infinity"
    val dp = IntArray(amount + 1) { inf }
    dp[0] = 0
    for (a in 1..amount) for (c in coins) {
        if (c <= a && dp[a - c] + 1 < dp[a]) dp[a] = dp[a - c] + 1
    }
    return if (dp[amount] == inf) -1 else dp[amount]
}

O(amount × coins) time, O(amount) space. Pseudo-polynomial, because the runtime depends on the magnitude of amount, not the size of the input.

  • Int.MAX_VALUE as infinity overflows the moment you add 1 to it. amount + 1 is the idiom.
  • Bottom-up avoids the recursion depth of memoized top-down. If you write the recursive version, say why you would not ship it.
  • “Fewest coins” and “number of ways” are different problems with different loop orders. For counting ways, the coin loop goes on the outside to avoid counting permutations.

The patterns, in one table

Problem Pattern Time Space The one thing to say
Two Sum Hash map O(n) O(n) Check before insert
Valid Parentheses Stack O(n) O(n) Empty stack at the end
Merge Intervals Sort and sweep O(n log n) O(n) Sort first; maxOf on the end
Longest Substring Sliding window O(n) O(k) it >= left
Number of Islands Grid BFS O(rc) O(rc) Mark on enqueue; iterative
LRU Cache Hash map plus linked list O(1) O(capacity) accessOrder = true; know the manual version
Top K Frequent Min-heap of size k O(n log k) O(n) Min-heap, not max
Rotated Search Binary search O(log n) O(1) <= on the sorted-half test
Reverse and Cycle Pointers O(n) O(1) Save next; ===
Coin Change Bottom-up DP O(amount × coins) O(amount) amount + 1 as infinity

Follow-Up Questions to Expect

  • “What is the complexity?” You should already have said it. If not, say it now with the alternative you rejected.
  • “Can you do it with less memory?” Two Sum has a sort-and-two-pointer version. Islands can use the grid as its own visited set. Coin Change is already O(amount). Know which of your solutions has a cheaper-space cousin.
  • “Make the LRU cache thread-safe.” A lock around both operations is the honest answer. Striped locks or a concurrent map with a separate eviction structure are the next level, and the trade-off is contention versus complexity.
  • “The input does not fit in memory.” Top K becomes a streaming problem: count-min sketch or a heavy-hitters algorithm. Merge Intervals becomes an external sort. Saying “that changes the problem class” is the senior answer.
  • “Write a test.” Pick the edge case you named first: empty input, one element, the "abba" case, the [3, 1] rotation. Interviewers grade the choice of test as much as the test.
  • “Why Kotlin?” Because you use it daily. If that is not true, this is the moment it shows.

Key Takeaways

  • Name the pattern before writing. Ten patterns cover the coding round for a senior loop.
  • Enumerate the edge cases out loud, then write code that handles them.
  • State complexity when you finish, with the alternative and why you did not choose it.
  • IntArray not Array<Int>; === for nodes; no smart casts on mutable properties; .. includes the end.
  • Iterative over recursive whenever the input can be large.
  • Know the LRU cache both ways. It is the most common senior coding question.
  • Narrate. Silent correct code scores lower than narrated code with a typo.

Further Reading

  • Kotlin documentation, Collections overview and ArrayDeque
  • Java documentation, LinkedHashMap (the accessOrder constructor and removeEldestEntry) and PriorityQueue
  • Gayle Laakmann McDowell, Cracking the Coding Interview, for the pattern catalogue
  • The Blind 75 and NeetCode 150 problem lists, which these ten are drawn from
This post is licensed under CC BY 4.0 by the author.