"Find the Bug in This Function": Ten Planted Kotlin Bugs and How to Find Each One in Under a Minute
The debugging round hands you working-looking code and asks what is wrong. Here are ten Kotlin functions, each with one bug drawn from the gotchas in the earlier posts, the input that exposes it, the fix, and the method that finds bugs like these in under a minute. Every buggy and fixed version was compiled and run to prove the bug reproduces and the fix works.
There is a coding round that is becoming more common in senior loops, because it measures something the “write a function” round cannot: can you read code you did not write, form a hypothesis, name the input that breaks it, and make the smallest change that fixes it. It is closer to what an architect does all day than any whiteboard problem.
This post is ten Kotlin functions with one planted bug each. Every bug is one of the gotchas from the first, second, and concurrency posts, which means each one has been seen in real interviews and real code. For each: the buggy function, the input that exposes it, the bug, and the fix. Both versions of every function were compiled and run against the named input before publishing, so the bug reproduces and the fix holds.
Try each one before reading the answer. Time yourself. A minute per function is the target.
The Question
- “Here is a function. Something is wrong with it. What?”
- “This passes the example in the ticket but fails in production. Why?”
- “Review this pull request.” (the same question in disguise)
- “This test is flaky. Find out why.” (usually a concurrency version of the same question)
The interviewer usually has one bug in mind and one input that triggers it. Sometimes they have two bugs and are waiting to see if you stop after the first.
What They Are Really Checking
- Do you read code, or skim it? The bug is in a line that looks fine. Candidates who read every line find it; candidates who pattern-match on the shape do not.
- Can you produce the failing input? “It is wrong” is not an answer. “It returns 3 for
"abba"and should return 2” is. Interviewers grade the input as much as the fix. - Do you fix the cause or the symptom? Adding a special case for the failing input is a symptom fix. Moving the guard is a cause fix.
- Do you make the smallest change? Rewriting the function from scratch signals that you could not find the bug and hoped a fresh version would not have it.
- Do you name the class of bug? “This is an aliasing bug,” “this is check-then-act,” “this swallows cancellation.” Naming it tells the interviewer you will recognize the next one.
The Gotchas
These are about the debugging exercise itself. The bugs in the functions are the ones from the earlier posts.
Gotcha 1: Rewriting instead of fixing. The interviewer asked for the bug, not a new function. A rewrite hides whether you found it and usually introduces a different one.
Gotcha 2: Not naming the failing input. A hypothesis without a reproducing input is a guess. Say the input, say the wrong output, say the right output, then fix.
Gotcha 3: Fixing the symptom. if (s == "abba") return 2 is a joke, but if (left > it) ... placed somewhere that happens to make the example pass is the same mistake dressed up. Ask why the wrong value arose.
Gotcha 4: Changing more than needed. A one-line bug gets a one-line fix. Every additional changed line is a place you might have introduced a new bug and a thing the interviewer now has to verify.
Gotcha 5: Not rerunning the original example after the fix. The example in the ticket still has to pass. Say “and the original example still gives 3.”
Gotcha 6: Silent reading. Thirty seconds of quiet is fine. Three minutes is not. Narrate the hypotheses as you discard them.
Gotcha 7: Assuming the bug is where the interviewer pointed. “The problem is somewhere in the loop” might be true or might be a test of whether you check the initialization above it.
Gotcha 8: Not asking what correct means. Whether [1, 4] and [4, 5] overlap is a specification question. Ask before declaring a bug that might be a requirement.
Gotcha 9: Stopping after the first bug. Once you find one, say “let me check whether there is another,” and look. Sometimes there is, and always the interviewer notices you looked.
Gotcha 10: Not stating the test you would add. The fix is half the answer. “I would add "abba" as a regression test” is the other half, and it is the sentence that sounds like someone who has fixed production bugs.
How to Answer
The method, in four steps
- Read the contract. What does the function promise? Name, parameters, return type, any comment. Write the promise down in one sentence if it is not obvious.
- Trace a small input by hand. Not the happy-path example. The smallest input that exercises the edge: two elements, a duplicate, an empty collection, a cancellation.
- Locate the divergence. The first line where what the code does differs from what the contract needs. That line, or the line before it that set up the wrong state, is the bug.
- Make the minimal fix, name the class, name the test. Change as little as possible, say what kind of bug it was, say the regression test.
Now the ten.
1. Two Sum
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[n] = i
seen[target - n]?.let { return intArrayOf(it, i) }
}
throw IllegalArgumentException("no solution")
}
Failing input: nums = [3, 3], target = 6. Returns [0, 0]. Should return [0, 1].
The bug: the current number is inserted before the complement is looked up, so an element can pair with itself. Order-of-operations bug.
The fix: look up first, then insert.
1
2
3
4
5
6
7
8
fun twoSum(nums: IntArray, target: Int): IntArray {
val seen = HashMap<Int, Int>()
for ((i, n) in nums.withIndex()) {
seen[target - n]?.let { return intArrayOf(it, i) }
seen[n] = i
}
throw IllegalArgumentException("no solution")
}
2. Longest Substring Without Repeating Characters
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 { left = it + 1 }
lastSeen[c] = right
best = maxOf(best, right - left + 1)
}
return best
}
Failing input: "abba". Returns 3. Should return 2.
The bug: when the second a is seen, lastSeen['a'] is 0, so left moves backwards from 2 to 1, and the window "bba" is counted. The window must never move left. Invariant violation.
The fix: only move left forward.
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
}
3. LRU Cache
1
2
3
4
5
6
7
class LRUCache(private val capacity: Int) {
private val map = object : LinkedHashMap<Int, Int>(capacity, 0.75f) {
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 }
}
Failing input: capacity 2; put(1, 1), put(2, 2), get(1), put(3, 3), then get(1). Returns -1. Should return 1, because 1 was used more recently than 2.
The bug: the two-argument constructor builds an insertion-ordered map. get does not move the entry, so eviction is first-in-first-out. Wrong data structure configuration.
The fix: the three-argument constructor with accessOrder = true.
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 }
}
4. Maximum Subarray
1
2
3
4
5
6
7
8
9
fun maxSubArray(nums: IntArray): Int {
var best = 0
var cur = 0
for (n in nums) {
cur = maxOf(n, cur + n)
best = maxOf(best, cur)
}
return best
}
Failing input: [-3, -1, -2]. Returns 0. Should return -1.
The bug: best starts at 0, which is the sum of the empty subarray, and the problem requires a non-empty one. Every all-negative input returns 0. Wrong initial value.
The fix: start both at the first element.
1
2
3
4
5
6
7
8
9
fun maxSubArray(nums: IntArray): Int {
var best = nums[0]
var cur = nums[0]
for (i in 1 until nums.size) {
cur = maxOf(nums[i], cur + nums[i])
best = maxOf(best, cur)
}
return best
}
5. Validate a Binary Search Tree
1
2
3
4
5
6
7
8
class TreeNode(var value: Int, var left: TreeNode? = null, var right: TreeNode? = null)
fun isValidBST(root: TreeNode?): Boolean {
if (root == null) return true
root.left?.let { if (it.value >= root.value) return false }
root.right?.let { if (it.value <= root.value) return false }
return isValidBST(root.left) && isValidBST(root.right)
}
Failing input: root 5, left 4, right 6 whose left child is 3. Returns true. Should return false, because 3 is in the right subtree of 5 and is less than 5.
The bug: each node is checked against its parent only. The constraint is transitive: every node in a right subtree must exceed every ancestor it is to the right of. Missing invariant propagation.
The fix: carry bounds down the recursion.
1
2
3
4
5
6
7
8
9
10
class TreeNode(var value: Int, var left: TreeNode? = null, var right: TreeNode? = null)
fun isValidBST(root: TreeNode?): Boolean {
fun valid(node: TreeNode?, lo: Long, hi: Long): Boolean {
if (node == null) return true
if (node.value <= lo || node.value >= hi) return false
return valid(node.left, lo, node.value.toLong()) && valid(node.right, node.value.toLong(), hi)
}
return valid(root, Long.MIN_VALUE, Long.MAX_VALUE)
}
6. Subsets
1
2
3
4
5
6
7
8
9
10
11
12
13
14
fun subsets(nums: IntArray): List<List<Int>> {
val result = ArrayList<List<Int>>()
val path = ArrayList<Int>()
fun backtrack(start: Int) {
result.add(path)
for (i in start until nums.size) {
path.add(nums[i])
backtrack(i + 1)
path.removeAt(path.lastIndex)
}
}
backtrack(0)
return result
}
Failing input: [1, 2, 3]. Returns eight lists, all empty. Should return the eight distinct subsets.
The bug: result.add(path) stores a reference to the one mutable list. By the time the function returns, every backtrack has undone its additions and the list is empty, eight times. Aliasing bug.
The fix: store a copy.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
fun subsets(nums: IntArray): List<List<Int>> {
val result = ArrayList<List<Int>>()
val path = ArrayList<Int>()
fun backtrack(start: Int) {
result.add(ArrayList(path))
for (i in start until nums.size) {
path.add(nums[i])
backtrack(i + 1)
path.removeAt(path.lastIndex)
}
}
backtrack(0)
return result
}
7. Make a Grid
1
2
3
4
fun makeGrid(rows: Int, cols: Int): Array<IntArray> {
val row = IntArray(cols)
return Array(rows) { row }
}
Failing input: makeGrid(2, 2), then set grid[0][0] = 1. Now grid[1][0] is also 1.
The bug: the lambda returns the same row for every index, so the grid is one row referenced twice. Aliasing bug, the two-dimensional version of the previous one.
The fix: allocate inside the lambda.
1
2
3
fun makeGrid(rows: Int, cols: Int): Array<IntArray> {
return Array(rows) { IntArray(cols) }
}
8. Trie
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class Trie {
private class Node { val children = arrayOfNulls<Node>(26) }
private val root = Node()
fun insert(word: String) {
var node = root
for (c in word) {
val i = c - 'a'
node = node.children[i] ?: Node().also { node.children[i] = it }
}
}
fun search(word: String): Boolean = find(word) != null
fun startsWith(prefix: String): Boolean = find(prefix) != null
private fun find(s: String): Node? {
var node = root
for (c in s) node = node.children[c - 'a'] ?: return null
return node
}
}
Failing input: insert("apple"), then search("app"). Returns true. Should return false; startsWith("app") should be true.
The bug: search and startsWith are the same function. There is nothing in a node that says “a word ends here.” Missing state.
The fix: a terminal flag, set on insert and checked on search.
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
class Trie {
private class Node {
val children = arrayOfNulls<Node>(26)
var terminal = false
}
private val root = Node()
fun insert(word: String) {
var node = root
for (c in word) {
val i = c - 'a'
node = node.children[i] ?: Node().also { node.children[i] = it }
}
node.terminal = true
}
fun search(word: String): Boolean = find(word)?.terminal == true
fun startsWith(prefix: String): Boolean = find(prefix) != null
private fun find(s: String): Node? {
var node = root
for (c in s) node = node.children[c - 'a'] ?: return null
return node
}
}
9. A Worker That Should Stop When Cancelled
1
2
3
4
5
6
7
8
9
10
11
12
13
14
import kotlinx.coroutines.*
class Worker(private val scope: CoroutineScope) {
@Volatile var committed = false
fun start(): Job = scope.launch {
try {
delay(10_000) // waiting for an upstream signal
} catch (e: Exception) {
// log and carry on
}
committed = true // must not happen if we were cancelled
}
}
Failing input: start the worker, cancel its job after a few milliseconds, join. committed is true. It should be false; a cancelled worker must not commit.
The bug: catch (e: Exception) catches CancellationException, because it is an IllegalStateException. The cancellation is swallowed, the coroutine continues past the try, and the side effect runs. Swallowed cancellation.
The fix: rethrow cancellation before handling anything else.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import kotlinx.coroutines.*
class Worker(private val scope: CoroutineScope) {
@Volatile var committed = false
fun start(): Job = scope.launch {
try {
delay(10_000)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
// log and carry on
}
committed = true
}
}
10. Cleanup That Must Run
1
2
3
4
5
6
7
8
9
import kotlinx.coroutines.*
suspend fun withCleanup(body: suspend () -> Unit, release: suspend () -> Unit) {
try {
body()
} finally {
release() // suspends: e.g. returns a connection over the network
}
}
Failing input: call it with a body that waits and a release that suspends before setting a flag; cancel the coroutine mid-body; join. The flag is never set. The connection is never returned.
The bug: once the coroutine is cancelled, the first suspension inside release throws CancellationException immediately, before the cleanup’s work happens. The finally block runs, but the code inside it does not complete. Suspension after cancellation.
The fix: run the cleanup in a NonCancellable context.
1
2
3
4
5
6
7
8
9
import kotlinx.coroutines.*
suspend fun withCleanup(body: suspend () -> Unit, release: suspend () -> Unit) {
try {
body()
} finally {
withContext(NonCancellable) { release() }
}
}
The ten, in one table
| Function | Failing input | Class of bug | Fix size |
|---|---|---|---|
| Two Sum | [3, 3], target 6 |
Order of operations | Swap two lines |
| Longest Substring | "abba" |
Invariant violation (window moved left) | Add one guard |
| LRU Cache | put, put, get, put, get | Wrong configuration (insertion order) | One constructor argument |
| Maximum Subarray | All negative | Wrong initial value | Two initializers |
| Validate BST | Grandchild violates grandparent | Missing invariant propagation | Carry bounds |
| Subsets | Any input | Aliasing (live list stored) | Copy on add |
| Make a Grid | Write one cell | Aliasing (shared row) | Allocate in the lambda |
| Trie | Prefix of an inserted word | Missing state (terminal flag) | One field, two lines |
| Worker | Cancel mid-wait | Swallowed cancellation | Rethrow one exception type |
| Cleanup | Cancel mid-body | Suspension after cancellation | Wrap in NonCancellable |
Notice the fix sizes. Not one of these is a rewrite. That is the point of the exercise.
Follow-Up Questions to Expect
- “How would you have caught this before production?” The regression test with the failing input, and for the two coroutine bugs, a test using virtual time that cancels mid-operation and asserts the side effect.
- “Is there a second bug?” Look. For the trie, the interviewer may point out that
c - 'a'throws on uppercase or non-ASCII input, which is a specification question, not a bug, and you should say so. - “What tooling would find this class of bug?” Aliasing bugs: none reliably; code review and tests. Swallowed cancellation: a lint rule that flags
catch (e: Exception)andrunCatchinginside coroutines, which some teams enforce. Wrong initial values: property-based testing with random inputs, which finds the all-negative case in seconds. - “Why does
catch (e: Exception)catch cancellation?” BecauseCancellationExceptionextendsIllegalStateException, which extendsRuntimeException, which extendsException. It was designed that way so that ordinary code does not need to know about it, and the cost is that ordinary catch blocks swallow it. - “Which of these have you actually shipped?” Be honest. Most people have shipped at least the aliasing one and the initial-value one. Saying so is credible; claiming none is not.
- “Fix it without changing the signature.” All ten fixes above keep the signature. If the interviewer’s version does not, ask whether the signature is part of the contract before touching it.
Key Takeaways
- Read the contract, trace a small input, find the first divergence, make the minimal fix, name the class, name the test.
- Always produce the failing input. It is the difference between a hypothesis and a finding.
- Fix the cause, not the symptom, and change as little as possible.
- The classes that recur: order of operations, invariant violation, wrong initial value, missing state, aliasing, swallowed cancellation, suspension after cancellation.
- Aliasing bugs are the ones you will ship. Any time a mutable object is stored, ask whether it is stored by reference.
- In coroutines,
catch (e: Exception)andrunCatchingare bugs until proven otherwise. - Say the regression test out loud.
Further Reading
- Andreas Zeller, Why Programs Fail, on the scientific method applied to debugging
- The three Kotlin posts these bugs were drawn from: ten questions, ten more, and concurrency
- Kotlin documentation, Cancellation and timeouts, for the two coroutine bugs in their official form