← All posts
Last-minute prep

Last-minute Kotlin prep for DSA interviews

A practical Kotlin cheat sheet for coding rounds: strings, collections, loops, functions, sorting, queues, edge cases, and the APIs worth having at your fingertips.

Your interview starts soon. This is not the moment to learn a new graph algorithm. It is the moment to make sure Kotlin syntax does not get between you and a solution you already understand.

Use this as a quick scan before a data structures and algorithms round. The examples are deliberately small. Type a few of them yourself, especially the ones you tend to forget.

10-minute Kotlin sprint Can you read the output and write the missing code? Take the focused test, then come back to the sections you missed.

The basic program shape

Most coding platforms call a top-level main function:

// Input: Ada
fun main() {
    val name = readln()
    println("Hello, $name")
}
Console output
Hello, Ada

On sites such as LeetCode, the platform may call your method for you. For example, it may provide class Solution with a method such as fun twoSum(nums: IntArray, target: Int): IntArray. Add your logic inside that method. Do not rename the class, change the parameter types, or add main unless the platform asks for it, because its test runner calls that exact signature.

For space-separated input, split is fine for modest test cases:

// Input:
// 5 9
// 2 7 9 3 1
fun main() {
    val (n, target) = readln().trim().split(" ").map(String::toInt)
    val nums = readln().trim().split(" ").map(String::toInt)

    println("n = $n, target = $target, nums = $nums")
}
Console output
n = 5, target = 9, nums = [2, 7, 9, 3, 1]

readln() assumes another line exists. readLine() returns null at the end of input, which can be useful when the number of lines is unknown. Large competitive programming inputs may need buffered input, but do not build a fast scanner in an interview unless input size makes it necessary.

Build output with StringBuilder when printing inside a large loop:

val output = StringBuilder()
repeat(3) { i -> output.appendLine("Case ${i + 1}") }
print(output)
Console output
Case 1
Case 2
Case 3

Variables, types, and conversions

Prefer val. Use var only when the reference itself must change.

val limit: Int = 10
var answer = 0
val big: Long = 3_000_000_000L
val ok: Boolean = true
val letter: Char = 'k'
val text: String = "kotlin"

println("limit = $limit, answer = $answer, big = $big, ok = $ok, letter = $letter, text = $text")
Console output
limit = 10, answer = 0, big = 3000000000, ok = true, letter = k, text = kotlin

Kotlin does not perform implicit numeric widening. Convert explicitly:

val count = "42".toInt()
val safeCount = "42x".toIntOrNull()       // null
val total = count.toLong() + 3_000_000_000L
val digit = '7'.digitToInt()
val digitChar = 7.digitToChar()

println("count = $count, safeCount = $safeCount, total = $total, digit = $digit, digitChar = $digitChar")
Console output
count = 42, safeCount = null, total = 3000000042, digit = 7, digitChar = 7

Use Long when a sum, product, distance, or count can exceed about 2.1 billion. Converting after the arithmetic is too late:

val wrong = (1_000_000 * 1_000_000).toLong() // Int overflows first
val right = 1_000_000L * 1_000_000L
println("wrong = $wrong")
println("right = $right")
Console output
wrong = -727379968
right = 1000000000000

If, when, and comparisons

Both if and when are expressions, so they can produce a value.

val score = 72
val command = 'L'
val label = if (score >= 60) "pass" else "retry"

val direction = when (command) {
    'L' -> "left"
    'R' -> "right"
    'U', 'D' -> "vertical"
    else -> "unknown"
}
println("With score = $score and command = $command:")
println("label = $label, direction = $direction")
Console output
With score = 72 and command = L:
label = pass, direction = left

when can also express ranges and conditions:

val age = 16
val group = when {
    age < 0 -> "invalid"
    age in 0..12 -> "child"
    age in 13 until 18 -> "teen"
    else -> "adult"
}
println("With age = $age:")
println("group = $group")
Console output
With age = 16:
group = teen

Use == and != for structural equality. === and !== compare object identity, which is almost never what a DSA solution needs.

Boolean operators are &&, ||, and !. They short-circuit, so this is safe:

val nums = intArrayOf(4, 8, 12)
val index = 1
val target = 8
if (index < nums.size && nums[index] == target) {
    println(index)
}
Console output
1

Ranges and loops

These are the loop forms worth memorising:

val n = 4
val nums = intArrayOf(10, 20, 30, 40)
val exclusive = (0 until n).toList()
val inclusive = (0..n).toList()
val descending = (n downTo 0).toList()
val everyOther = (0 until n step 2).toList()

println("until: $exclusive")
println("range: $inclusive")
println("downTo: $descending")
println("step: $everyOther")
println("indices: ${nums.indices.toList()}")

for ((index, value) in nums.withIndex()) {
    println("nums[$index] = $value")
}

repeat(n) { index ->
    println("repeat: $index")
}
Console output
until: [0, 1, 2, 3]
range: [0, 1, 2, 3, 4]
downTo: [4, 3, 2, 1, 0]
step: [0, 2]
indices: [0, 1, 2, 3]
nums[0] = 10
nums[1] = 20
nums[2] = 30
nums[3] = 40
repeat: 0
repeat: 1
repeat: 2
repeat: 3

until excludes n; .. includes it. 0..<n is another spelling of 0 until n on newer Kotlin versions. Use downTo for descending ranges, step to skip values, indices for valid indexes, and withIndex() when you need both the index and value.

In a nested loop, labels let you continue or break the outer loop:

val grid = arrayOf(intArrayOf(1, 2), intArrayOf(3, 9))
val target = 9

outer@ for (row in grid.indices) {
    for (col in grid[row].indices) {
        if (grid[row][col] == target) {
            println("found at row $row, column $col")
            break@outer
        }
    }
}
Console output
found at row 1, column 1

Functions you can write quickly

A normal function declares parameter types and a return type:

fun maxOfThree(a: Int, b: Int, c: Int): Int {
    return maxOf(a, maxOf(b, c))
}

println("maxOfThree(3, 9, 4) = ${maxOfThree(3, 9, 4)}")
Console output
maxOfThree(3, 9, 4) = 9

Use an expression body for a short function:

fun isEven(n: Int): Boolean = n % 2 == 0
println("isEven(8) = ${isEven(8)}")
println("isEven(7) = ${isEven(7)}")
Console output
isEven(8) = true
isEven(7) = false

Default and named arguments can keep helper calls readable:

fun search(nums: IntArray, target: Int, start: Int = 0): Int {
    for (i in start until nums.size) {
        if (nums[i] == target) return i
    }
    return -1
}

val values = intArrayOf(4, 8, 12)
val index = search(nums = values, target = 12)
println("index = $index")
Console output
index = 2

Use vararg when a function accepts any number of values. The spread operator passes an existing array:

fun total(vararg values: Int) = values.sum()

val nums = intArrayOf(2, 4, 6)
val sum = total(*nums)
println("sum = $sum")
Console output
sum = 12

Local functions are handy for DFS because they can capture nearby state:

class Node(
    val value: Int,
    val left: Node? = null,
    val right: Node? = null,
)

fun countNodes(root: Node?): Int {
    fun dfs(node: Node?): Int {
        if (node == null) return 0
        return 1 + dfs(node.left) + dfs(node.right)
    }
    return dfs(root)
}

val root = Node(1, left = Node(2), right = Node(3))
println("For a three-node tree: countNodes(root) = ${countNodes(root)}")
Console output
For a three-node tree: countNodes(root) = 3

Remember the base case before writing a recursive call. For deep inputs, prefer an iterative stack because recursion can exhaust the call stack.

Strings and characters

Strings are immutable. Operations such as reversed, replace, and substring return a new value.

val s = "Kotlin"
println("length = ${s.length}")
println("first = ${s.first()}")
println("last = ${s.last()}")
println("substring = ${s.substring(1, 4)}")
println("lowercase = ${s.lowercase()}")
println("startsWith = ${s.startsWith("Kot")}")
println("contains t = ${'t' in s}")
Console output
length = 6
first = K
last = n
substring = otl
lowercase = kotlin
startsWith = true
contains t = true

Common character checks:

val s = "Kotlin"
for (ch in s) {
    when {
        ch.isDigit() -> println(ch.digitToInt())
        ch.isLetter() -> println(ch.lowercaseChar())
        ch.isWhitespace() -> continue
    }
}
Console output
k
o
t
l
i
n

Use a CharArray or StringBuilder when modifying characters:

val s = "Kotlin"
val chars = s.toCharArray()
chars[0] = chars[0].lowercaseChar()
val changed = chars.concatToString()

val reversed = StringBuilder(s).reverse().toString()
println("changed = $changed")
println("reversed = $reversed")
Console output
changed = kotlin
reversed = niltoK

Two-pointer palindrome check:

fun isPalindrome(s: String): Boolean {
    var left = 0
    var right = s.lastIndex

    while (left < right) {
        if (s[left] != s[right]) return false
        left++
        right--
    }
    return true
}
println("isPalindrome(\"racecar\") = ${isPalindrome("racecar")}")
println("isPalindrome(\"kotlin\") = ${isPalindrome("kotlin")}")
Console output
isPalindrome("racecar") = true
isPalindrome("kotlin") = false

For lowercase English letters, an IntArray(26) is a compact frequency table:

val word = "aba"
val frequency = IntArray(26)
for (ch in word) frequency[ch - 'a']++
println("With word = $word: a = ${frequency[0]}, b = ${frequency[1]}, every other count = 0")
Console output
With word = aba: a = 2, b = 1, every other count = 0

Use a map instead when the character set is not limited to those 26 letters. Also remember that a Kotlin Char is one UTF-16 code unit, not necessarily one complete user-perceived character. Interview problems usually state a restricted alphabet when that distinction does not matter.

Arrays

Primitive arrays avoid boxed values:

val zeros = IntArray(5)                 // [0, 0, 0, 0, 0]
val squares = IntArray(5) { i -> i * i }
val given = intArrayOf(4, 1, 9)
println("zeros = ${zeros.contentToString()}")
println("squares = ${squares.contentToString()}")
println("given = ${given.contentToString()}")
Console output
zeros = [0, 0, 0, 0, 0]
squares = [0, 1, 4, 9, 16]
given = [4, 1, 9]

General arrays can hold objects or nested arrays:

val names = arrayOf("Ada", "Lin")
val rows = 3
val cols = 4
val grid = Array(rows) { IntArray(cols) }
grid[1][2] = 7
println("names = ${names.contentToString()}, grid[1][2] = ${grid[1][2]}")
Console output
names = [Ada, Lin], grid[1][2] = 7

Useful array operations:

val given = intArrayOf(4, 1, 9)
println("size = ${given.size}")
println("lastIndex = ${given.lastIndex}")
println("getOrNull(10) = ${given.getOrNull(10)}")
println("range = ${given.copyOfRange(1, 3).contentToString()}")
given.fill(-1)
println("given = ${given.contentToString()}")
println("sum = ${given.sum()}, min = ${given.minOrNull()}, max = ${given.maxOrNull()}")
Console output
size = 3
lastIndex = 2
getOrNull(10) = null
range = [1, 9]
given = [-1, -1, -1]
sum = -3, min = -1, max = -1

IntArray and Array<Int> are different types. Coding platforms commonly use IntArray in Kotlin method signatures.

Lists, sets, and maps

List exposes read-only operations. It is not a guarantee that the backing collection can never change. Use MutableList when your code must add, remove, or replace values.

val fixed: List<Int> = listOf(1, 2, 3)
val nums = mutableListOf(1, 2, 3)

nums.add(4)
nums += 5
nums[0] = 9
nums.removeAt(1)                 // removes by index
nums.remove(3)                   // removes the value 3
println("nums = $nums")
Console output
nums = [9, 4, 5]

Create a sized mutable list when you need indexed assignment:

val n = 4
val start = 1
val distance = MutableList(n) { Int.MAX_VALUE }
distance[start] = 0
println(distance)
Console output
[2147483647, 0, 2147483647, 2147483647]

Sets answer membership and uniqueness questions:

val nums = listOf(2, 1, 2, 3, 1)
val seen = mutableSetOf<Int>()
for (value in nums) {
    if (!seen.add(value)) println("duplicate: $value")
}
Console output
duplicate: 2
duplicate: 1

Maps store key-value relationships:

val word = "aba"
val frequency = mutableMapOf<Char, Int>()
for (ch in word) {
    frequency[ch] = frequency.getOrDefault(ch, 0) + 1
}

for ((key, value) in frequency) {
    println("$key -> $value")
}
Console output
a -> 2
b -> 1

getOrPut is convenient when each key owns a mutable collection:

val word = "cat"
val groups = mutableMapOf<Int, MutableList<String>>()
groups.getOrPut(word.length) { mutableListOf() }.add(word)
println("groups = $groups")
Console output
groups = {3=[cat]}

A map lookup returns a nullable value because the key may be absent. Avoid !! when getOrDefault, getOrPut, or an explicit null check expresses the case.

Transforming collections

These standard operations are useful when they make the solution clearer:

val nums = listOf(1, 2, 3)
val words = listOf("a", "bb", "a")
val doubled = nums.map { it * 2 }
val evens = nums.filter { it % 2 == 0 }
val firstLarge = nums.firstOrNull { it > 100 }
val hasZero = nums.any { it == 0 }
val allPositive = nums.all { it > 0 }
val grouped = words.groupBy { it.length }
val counts = words.groupingBy { it }.eachCount()
val total = nums.fold(0L) { acc, value -> acc + value }
println("doubled = $doubled")
println("evens = $evens")
println("firstLarge = $firstLarge")
println("hasZero = $hasZero, allPositive = $allPositive")
println("grouped = $grouped")
println("counts = $counts")
println("total = $total")
Console output
doubled = [2, 4, 6]
evens = [2]
firstLarge = null
hasZero = false, allPositive = true
grouped = {1=[a, a], 2=[bb]}
counts = {a=2, bb=1}
total = 6

Most of these create new collections. In a hot loop or memory-constrained problem, a direct loop is often easier to reason about and cheaper to run. Do not hide the important state of a sliding window or traversal inside a clever chain.

Midpoint check Put the collection APIs into practice Output questions and short Kotlin prompts, with answers revealed only after you commit.

Know whether an operation mutates or returns a copy:

val a = intArrayOf(3, 1, 2)
a.sort()                         // mutates a
val descending = a.sortedDescending() // returns List<Int>
val copy = a.sortedArray()       // returns IntArray

val words = mutableListOf("pear", "fig", "apple")
words.sortBy { it.length }
words.sortWith(compareBy<String> { it.length }.thenBy { it })
println("a = ${a.contentToString()}")
println("descending = $descending")
println("copy = ${copy.contentToString()}")
println("words = $words")
Console output
a = [1, 2, 3]
descending = [3, 2, 1]
copy = [1, 2, 3]
words = [fig, pear, apple]

Avoid comparators such as { a, b -> a - b }, which can overflow. Use compareBy, compareValues, or a.compareTo(b).

Binary search requires sorted input:

val values = intArrayOf(2, 5, 8, 12)
val index = values.binarySearch(8) // 2
println("index = $index")
Console output
index = 2

If the value is absent, binarySearch returns a negative value that encodes the insertion point. Do not treat every negative result as a usable index.

Stack, queue, deque, and heap

ArrayDeque covers both stack and queue behavior:

val stack = ArrayDeque<Int>()
stack.addLast(10)
stack.addLast(20)
val top = stack.removeLast()

val queue = ArrayDeque<Int>()
queue.addLast(10)
queue.addLast(20)
val first = queue.removeFirst()
println("top = $top")
println("first = $first")
Console output
top = 20
first = 10

Check isNotEmpty() before removal unless the algorithm guarantees an element. Calling removeFirst() or removeLast() on an empty deque throws NoSuchElementException.

On the JVM, use PriorityQueue for a heap:

import java.util.PriorityQueue

val minHeap = PriorityQueue<Int>()
minHeap.add(7)
minHeap.add(2)
minHeap.add(5)
val smallest = minHeap.poll()    // 2

val maxHeap = PriorityQueue<Int>(compareByDescending { it })
println("smallest = $smallest")
println("maxHeap is created empty and orders larger values first")
Console output
smallest = 2
maxHeap is created empty and orders larger values first

peek() and poll() return null when the queue is empty. The default is a min-heap.

Pairs, data classes, and nodes

Use Pair for a tiny local relationship:

val row = 2
val col = 3
val cell = row to col
val (r, c) = cell
println("With row = $row and col = $col: r = $r, c = $c")
Console output
With row = 2 and col = 3: r = 2, c = 3

For three or more fields, or when names improve the algorithm, use a data class:

data class State(val row: Int, val col: Int, val distance: Int)
println(State(1, 2, 4))
Console output
State(row=1, col=2, distance=4)

Typical interview node definitions look like this:

class ListNode(var value: Int, var next: ListNode? = null)

class Node(
    val value: Int,
    var left: Node? = null,
    var right: Node? = null,
)

println("The classes define node shapes. Example: Node(7).value = ${Node(7).value}")
Console output
The classes define node shapes. Example: Node(7).value = 7

Follow the platform’s supplied definition when it provides one.

Null safety without noise

Nullable values use ?:

class Node(val value: Int, val left: Node? = null)

val root = Node(1, left = Node(5))
val text: String? = null
val node: Node? = root.left
val value = node?.value              // null if node is null
val size = text?.length ?: 0         // Elvis operator

if (node != null) {
    println(node.value)              // smart cast inside this branch
}
Console output
5

Use ?.let only when it genuinely reads better than an if:

fun pairWithTarget(map: Map<Int, Int>, target: Int, i: Int): IntArray? {
    return map[target]?.let { index -> intArrayOf(index, i) }
}

val found = pairWithTarget(mapOf(9 to 3), target = 9, i = 8)
val missing = pairWithTarget(emptyMap(), target = 9, i = 8)
println("found = ${found?.contentToString()}")
println("missing = $missing")
Console output
found = [3, 8]
missing = null

The not-null assertion !! throws NullPointerException when its value is null. In interview code, it is usually clearer to prove the value exists with a guard or handle the absent case explicitly.

Math and bit operations

Common math helpers:

import kotlin.math.abs
import kotlin.math.ceil
import kotlin.math.floor
import kotlin.math.max
import kotlin.math.min
import kotlin.math.sqrt

val a = 9
val b = 4
val left = 0
val right = 8
val distance = abs(a - b)
val middle = left + (right - left) / 2
println("distance = $distance, middle = $middle")
Console output
distance = 5, middle = 4

Integer division truncates toward zero. 7 / 2 is 3, while 7.0 / 2 is 3.5. A common positive-integer ceiling division is:

val items = 10
val size = 3
val groups = (items + size - 1) / size
println("groups = $groups")
Console output
groups = 4

That expression can overflow for very large values, so use Long or an overflow-safe rearrangement when constraints require it.

Kotlin uses named functions for bit operations on integers:

val position = 2
val mask = 10
val bit = 1 shl position
val isSet = (mask and bit) != 0
val added = mask or bit
val toggled = mask xor bit
val shifted = mask shr 1          // keeps sign
val unsignedShift = mask ushr 1
val ones = mask.countOneBits()
println("bit = $bit, isSet = $isSet, added = $added, toggled = $toggled")
println("shifted = $shifted, unsignedShift = $unsignedShift, ones = $ones")
Console output
bit = 4, isSet = false, added = 14, toggled = 14
shifted = 5, unsignedShift = 5, ones = 2

Four patterns worth being able to type

Frequency map

fun counts(nums: IntArray): Map<Int, Int> {
    val result = mutableMapOf<Int, Int>()
    for (n in nums) result[n] = result.getOrDefault(n, 0) + 1
    return result
}
println("counts([2, 1, 2]) = ${counts(intArrayOf(2, 1, 2))}")
Console output
counts([2, 1, 2]) = {2=2, 1=1}

Two pointers on a sorted array

fun hasPair(nums: IntArray, target: Int): Boolean {
    var left = 0
    var right = nums.lastIndex

    while (left < right) {
        val sum = nums[left] + nums[right]
        when {
            sum == target -> return true
            sum < target -> left++
            else -> right--
        }
    }
    return false
}
println("hasPair([1, 3, 4, 8], 7) = ${hasPair(intArrayOf(1, 3, 4, 8), 7)}")
Console output
hasPair([1, 3, 4, 8], 7) = true

Sliding window

fun maxWindowSum(nums: IntArray, k: Int): Int {
    require(k in 1..nums.size)
    var window = 0
    for (i in 0 until k) window += nums[i]

    var best = window
    for (right in k until nums.size) {
        window += nums[right] - nums[right - k]
        best = maxOf(best, window)
    }
    return best
}
println("maxWindowSum([2, 1, 5, 1, 3], 3) = ${maxWindowSum(intArrayOf(2, 1, 5, 1, 3), 3)}")
Console output
maxWindowSum([2, 1, 5, 1, 3], 3) = 9

require throws IllegalArgumentException when its condition is false. In a platform method, you may return a specified sentinel instead if invalid input is part of the problem contract.

fun shortestSteps(graph: List<List<Int>>, start: Int): IntArray {
    val distance = IntArray(graph.size) { -1 }
    val queue = ArrayDeque<Int>()

    distance[start] = 0
    queue.addLast(start)

    while (queue.isNotEmpty()) {
        val node = queue.removeFirst()
        for (next in graph[node]) {
            if (distance[next] != -1) continue
            distance[next] = distance[node] + 1
            queue.addLast(next)
        }
    }
    return distance
}
val graph = listOf(listOf(1, 2), listOf(3), listOf(3), emptyList())
println(shortestSteps(graph, 0).contentToString())
Console output
[0, 1, 1, 2]

For a grid, store (row, col) pairs or a small State, and keep directions in one place:

val directions = arrayOf(
    intArrayOf(-1, 0),
    intArrayOf(1, 0),
    intArrayOf(0, -1),
    intArrayOf(0, 1),
)
println(directions.joinToString(prefix = "[", postfix = "]") { it.contentToString() })
Console output
[[-1, 0], [1, 0], [0, -1], [0, 1]]

Complexity traps in friendly-looking code

  • list.contains(value) and list.remove(value) are linear searches. A hash set usually gives average constant-time membership.
  • substring, filter, map, reversed, and sorted create results. Repeating them inside a loop can quietly increase time and memory use.
  • Adding to the end of a mutable list is normally cheap. Removing index 0 shifts the remaining elements, so use ArrayDeque for a queue.
  • Sorting is usually O(n log n). Hash maps and sets have average O(1) lookup, but that is not a worst-case guarantee.
  • String concatenation in a long loop can repeatedly copy text. Use StringBuilder.
  • A recursive DFS uses call-stack space proportional to its depth.

The final five-minute checklist

Before you submit, say these questions out loud:

  1. What happens for empty input, one element, duplicates, and all-equal values?
  2. Are my range endpoints inclusive or exclusive?
  3. Can a sum, product, or midpoint overflow Int?
  4. Does this lookup return null, and have I handled it?
  5. Am I mutating the original collection or creating a copy?
  6. Does my comparator handle equal values and extreme integers?
  7. Have I tested the loop once by hand, including the condition that stops it?
  8. Can I state the time and space complexity in one sentence?

The goal is not to show off every Kotlin feature. It is to make the algorithm obvious. Use the smallest API that expresses your intent, name your state well, and narrate the trade-off you are making. Clean, boring code is excellent interview code.

Ready to check it? Take the last-minute Kotlin DSA test Ten focused questions. Output tracing, tiny functions, no Android trivia.

If you have more than a few minutes, pair this reference with the focused coding interview practice guide and the broader Android interview plan.

Discussion

Comments are powered by GitHub Discussions. Sign in with GitHub to join in.