Data Structures, Algorithms and Programming Patterns · Part 2 of 9
Data Structures and Algorithms, Part 2: Arrays, Strings, Hash Maps and Sets
Arrays, strings, hash maps and hash sets cover most of what everyday programs store. This part shows how each one works inside, what every operation costs, and how a hash map can turn a slow search into a single step.
- An array gives
O(1)access by index, but inserting or removing in the middle shifts items:O(n). - A growable list doubles its array when full, so adding at the end is amortised
O(1). - Strings are immutable; build big ones with
StringBuilder. Count letters with an array of 26. - A hash table finds a key by computing a bucket, so lookup is average
O(1)but keys lose their order. LinkedHashMapkeeps insertion order,TreeMapkeeps sorted order atO(log n);data classgives youequalsandhashCode.
Contents
- Arrays: numbered boxes in a row
- Growable lists and the idea of amortised cost
- Strings: arrays of characters with rules
- Hashing: finding things without searching
- HashMap, HashSet and their relatives
- Counting and grouping with maps
- Worked problems: from slow to fast
- Two-dimensional arrays and grids
- The cost table
- Practice
- Takeaways
1. Arrays: numbered boxes in a row
An array is a row of boxes of the same size, sitting side by side in memory, each holding one value. Every box has a number called its index, and counting starts at 0.
Think of a row of lockers. Each locker has a number. If you know the number, you walk straight to it. You never open lockers one by one to find locker 7, because the position tells you exactly where it is.
That formula is the reason for the array's best trick: reading or writing arr[i] costs O(1). It does not matter whether the array has ten boxes or ten million.
The price is that the boxes are packed tightly. To put a new value in the middle, every value after it has to shuffle one box to the right to make room. To remove one, they shuffle left to close the gap.
Run it: index access and shifting
Run it and count the moves. Inserting at index 1 moved three values, inserting at the end moved none.
fun insertAt(arr: IntArray, size: Int, index: Int, value: Int): Int {
var moves = 0
for (i in size downTo index + 1) { // walk from the back so we do not overwrite anything
arr[i] = arr[i - 1]
moves++
}
arr[index] = value
return moves
}
fun main() {
val a = IntArray(6)
a[0] = 10; a[1] = 20; a[2] = 30; a[3] = 40 // size 4, two spare boxes
println("a[2] = ${a[2]}") // O(1): jump straight to box 2
val moves = insertAt(a, 4, 1, 15)
println(a.joinToString() + " moves=$moves") // three values had to shift
val appendMoves = insertAt(a, 5, 5, 99) // at the end
println(a.joinToString() + " moves=$appendMoves")
// The same effect with a MutableList: add(index, value) shifts for you.
val big = MutableList(1000) { it }
big.add(0, -1) // every one of the 1000 items moves
println("first=${big[0]} second=${big[1]} size=${big.size}")
}
Open in Kotlin Playground ↗
Fixed size
A plain array has a size you choose when you create it, and it cannot grow. In Kotlin, IntArray(5) makes five zeros and intArrayOf(1, 2, 3) makes an array with those values. Asking for arr[5] in a five-box array throws an IndexOutOfBoundsException, which is the computer saying “that box does not exist”.
2. Growable lists and the idea of amortised cost
Most of the time you do not know the size in advance. A growable list (Kotlin's MutableList, backed by ArrayList; Python's list; Java's ArrayList) hides a plain array and grows it for you.
It keeps a hidden array that is usually bigger than needed. The number of boxes is its capacity; the number actually used is its size. When you add an item and the array is full, the list allocates a new array about twice as big, copies everything across, and carries on.
So is add O(1) or O(n)? One unlucky add costs O(n) because of the copy. But look at many adds together. Doubling means the total copying over n adds is about 1 + 2 + 4 + … + n, which is less than 2n. Spread over n adds, that is about two copies per add. We say add is amortised O(1): on average over a long run it is constant, even though a single call can be slow.
Amortised is like a gym membership. One big payment up front looks expensive, but spread over every visit the cost per visit is small.
Run it: build a tiny ArrayList and count the copying
Run it. However large n gets, the number of copies per add stays below 2. That is amortised O(1).
class TinyList {
private var data = IntArray(1)
var size = 0
private set
var copies = 0
private set
fun add(x: Int) {
if (size == data.size) { // full: grow to double
val bigger = IntArray(data.size * 2)
for (i in 0 until size) { bigger[i] = data[i]; copies++ }
data = bigger
}
data[size] = x
size++
}
operator fun get(i: Int): Int {
if (i !in 0 until size) throw IndexOutOfBoundsException("index $i, size $size")
return data[i]
}
}
fun main() {
for (n in listOf(10, 1000, 100000)) {
val list = TinyList()
for (i in 1..n) list.add(i)
val perAdd = list.copies.toDouble() / list.size
println("n=$n copies=${list.copies} copies per add=${"%.2f".format(perAdd)}")
}
}
Open in Kotlin Playground ↗
What costs what in a list
| Operation | Cost | Why |
|---|---|---|
read or write list[i] | O(1) | index arithmetic |
add(x) at the end | amortised O(1) | sometimes a copy, usually not |
add(i, x) or removeAt(i) in the middle | O(n) | items shift |
contains(x) or indexOf(x) | O(n) | look at items one by one |
size | O(1) | a stored number |
Which to use in Kotlin? IntArray stores plain numbers with no wrapper objects, so it is smaller and faster; use it when the size is fixed. MutableList<Int> is the everyday choice when the size changes. Both are indexed, so both give O(1) access.
3. Strings: arrays of characters with rules
A string is a sequence of characters, and you can think of it as an array of Char. So s[i] is O(1) and s.length is O(1). Most things you do to the whole string, such as searching or comparing, are O(n) because they look at the characters.
Strings never change
In Kotlin, Java and Python a string is immutable: once created, it cannot be edited. Methods like uppercase() or replace() do not change the string, they build a new one. That is safe, because nobody can change a string under your feet, but it has a cost.
Consider building a long string with result = result + "x" in a loop. Each + creates a brand new string and copies everything so far. Copy 1 character, then 2, then 3, up to n: that is about n² / 2 copies in total, so O(n²). The fix is a StringBuilder: a growable buffer, just like the growable list, so appending is amortised O(1) and the whole build is O(n).
Run it: common string operations and StringBuilder
Read each result next to its line. Notice that s is unchanged after uppercase().
fun main() {
val s = "hello world"
val shout = s.uppercase()
println("$s -> $shout") // s itself did not change
println(s[4]) // 'o', O(1)
println(s.substring(0, 5)) // hello
println(s.indexOf("world")) // 6
println(s.contains("lo w")) // true
println(s.split(" ")) // [hello, world]
println(s.reversed()) // dlrow olleh
println(s.replace('o', '0')) // hell0 w0rld
// Build a string piece by piece with StringBuilder: O(n) overall.
val sb = StringBuilder()
for (i in 1..5) sb.append(i).append(',')
sb.setLength(sb.length - 1) // drop the last comma
println(sb.toString()) // 1,2,3,4,5
// Strings are sequences of Char, and Char is a number underneath.
println('a'.code) // 97
println('c' - 'a') // 2, the position of c in the alphabet
println('7'.isDigit()) // true
println('Q'.lowercaseChar()) // q
}
Open in Kotlin Playground ↗
Counting characters
Many string problems come down to how many of each letter are there? If the text only has lowercase letters a to z, you do not need a map. Use an array of 26 counters and turn a letter into an index with c - 'a'.
Palindromes and anagrams
A palindrome reads the same forwards and backwards, like “level”. A neat way to check it is with two pointers: one starts at the left end, one at the right end, and they walk toward each other comparing characters. Two strings are anagrams if they use exactly the same letters the same number of times, like “listen” and “silent”. If you count the letters of both, the counts must match.
Run it: two pointers and letter counts
The palindrome check never builds a reversed copy, so it uses no extra memory. The anagram check compares two 26-slot arrays.
fun isPalindrome(s: String): Boolean {
var left = 0
var right = s.length - 1
while (left < right) {
if (s[left] != s[right]) return false
left++
right--
}
return true // O(n) time, O(1) extra space
}
fun letterCounts(word: String): IntArray {
val counts = IntArray(26)
for (c in word) counts[c - 'a']++
return counts
}
fun sameLetters(a: String, b: String): Boolean =
letterCounts(a).contentEquals(letterCounts(b)) // O(n) time, O(1) space (26 never grows)
fun main() {
println(isPalindrome("level")) // true
println(isPalindrome("levels")) // false
println(isPalindrome("")) // true, nothing to disagree
println(sameLetters("listen", "silent")) // true
println(sameLetters("rat", "car")) // false
println(letterCounts("banana").take(14)) // counts for a..n
}
Open in Kotlin Playground ↗
4. Hashing: finding things without searching
Arrays give you instant access by position. But often you want access by meaning: the price of “apple”, the user named “sara”, whether the number 42 has appeared already. Searching a list for those is O(n). A hash table makes it O(1) on average.
Think of a library with numbered shelves. Instead of scanning every book, a librarian runs a rule on the title that gives a shelf number. To store a book, go to that shelf. To find it later, run the same rule and go to the same shelf. No scanning.
The rule is a hash function: it turns any key (a string, a number, an object) into an integer called a hash code. The table has a row of numbered slots called buckets. The bucket for a key is the hash code reduced to a valid bucket number, usually with a remainder: hashCode % bucketCount.
Collisions
There are far more possible keys than buckets, so two different keys sometimes land in the same bucket. This is called a collision, and it is normal. The simplest fix, used above, is chaining: each bucket holds a small list of the entries that landed there. To look up a key, jump to its bucket and check the few entries inside, comparing with equals.
If the hash function spreads keys evenly and the table has enough buckets, every chain stays tiny and a lookup is a couple of steps. When the table gets too full, real implementations resize: they make more buckets and re-place every entry, the same trick the growable list uses. That keeps the average cost O(1).
Run it: build a hash set with buckets
Run it and look at the bucket lines. Any bucket with more than one word is a collision. The second “cat” is refused, which is what makes it a set.
class MiniHashSet(private val bucketCount: Int = 8) {
private val buckets = Array(bucketCount) { mutableListOf<String>() }
fun bucketOf(key: String): Int = Math.floorMod(key.hashCode(), bucketCount)
fun add(key: String): Boolean {
val chain = buckets[bucketOf(key)]
if (key in chain) return false // already there
chain.add(key)
return true
}
fun contains(key: String): Boolean = key in buckets[bucketOf(key)]
fun show() {
for ((i, chain) in buckets.withIndex()) println("bucket $i: $chain")
}
}
fun main() {
val set = MiniHashSet()
val words = listOf("cat", "dog", "bird", "fish", "cow", "ant", "bee", "cat")
for (w in words) {
val added = set.add(w)
println("%-5s hashCode=%-9d bucket=%d added=%s".format(w, w.hashCode(), set.bucketOf(w), added))
}
println()
set.show()
println()
println("contains dog? ${set.contains("dog")}")
println("contains emu? ${set.contains("emu")}")
}
Open in Kotlin Playground ↗
Why only the average is O(1)
In the worst case every key collides into one bucket and the table degrades into a single long list: O(n). A good hash function and resizing make that practically never happen, so we say average O(1). This is also why a hash table gives up order: keys are placed by their hash code, not by when you added them or how they compare, so iterating a plain hash table gives no meaningful order.
- If two keys are
equals, they must have the samehashCode. Otherwise equal keys go to different buckets and you can never find them. - A key must not change while it is stored. If its hash code changes, it is now in the wrong bucket.
5. HashMap, HashSet and their relatives
Two structures are built on a hash table.
- A hash set stores unique keys and answers “is it in there?” in
O(1)on average. - A hash map (a dictionary) stores key-value pairs: look up a key, get its value. Keys are unique; values can repeat.
A set is really a map where you only care about the keys. In Kotlin, mapOf, mutableMapOf and HashMap are the usual map types, and setOf, mutableSetOf and HashSet the usual sets.
Four flavours and what order means
Sometimes you do need order. The standard library offers variations that differ in what order they keep and what that costs.
| Type | Order of iteration | Lookup, add, remove | Use it when |
|---|---|---|---|
HashMap / HashSet | no useful order | average O(1) | you only need fast lookup |
LinkedHashMap / LinkedHashSet | insertion order | average O(1) | you want fast lookup and a predictable order |
TreeMap / TreeSet | sorted by key | O(log n) | you need sorted keys, or “the next bigger key” |
A TreeMap keeps its keys sorted using a balanced tree (you will build such trees in Part 4). That costs O(log n) per operation instead of O(1), but it unlocks questions a hash table cannot answer cheaply: the smallest key, the largest, or the closest key to a given value.
Run it: the same data in three maps
Compare the three printed maps. The first one may come out in any order; do not rely on it.
import java.util.TreeMap
fun countWords(words: List<String>, into: MutableMap<String, Int>) {
for (w in words) into[w] = (into[w] ?: 0) + 1
}
fun main() {
val words = listOf("pear", "apple", "fig", "banana", "apple", "fig", "apple")
val hash = HashMap<String, Int>()
val linked = LinkedHashMap<String, Int>()
val tree = TreeMap<String, Int>()
countWords(words, hash)
countWords(words, linked)
countWords(words, tree)
println("HashMap $hash") // same pairs, order not meaningful
println("LinkedHashMap $linked") // first-seen order: pear, apple, fig, banana
println("TreeMap $tree") // alphabetical
println(linked["apple"]) // 3
println(linked["kiwi"]) // null, missing key
println(linked.getOrDefault("kiwi", 0)) // 0
println(tree.firstKey()) // apple
println(tree.lastKey()) // pear
println(tree.ceilingKey("b")) // banana, smallest key at or after "b"
println(tree.floorKey("c")) // banana, largest key at or before "c"
val seen = LinkedHashSet<Int>()
for (x in listOf(3, 1, 3, 2, 1)) seen.add(x)
println(seen) // [3, 1, 2]: duplicates gone, first-seen order kept
}
Open in Kotlin Playground ↗
Your own classes as keys
A hash table needs two things from a key: hashCode() to pick the bucket, and equals() to confirm a match inside the bucket. Strings and numbers have both built in. For your own class, Kotlin's data class writes both for you, based on the fields in the constructor. A plain class does not: two separate objects count as different even when all their fields match.
Run it: data class versus plain class as a key
This is why grids and coordinates are usually stored as data class Point (or as a pair) when you need to remember which cells you have visited.
data class Point(val x: Int, val y: Int)
class Cell(val x: Int, val y: Int) // plain class: no equals, no hashCode
fun main() {
val visited = HashSet<Point>()
visited.add(Point(1, 2))
println(Point(1, 2) in visited) // true: same fields, equal
println(Point(1, 2).hashCode() == Point(1, 2).hashCode()) // true
val plain = HashSet<Cell>()
plain.add(Cell(1, 2))
println(Cell(1, 2) in plain) // false: a different object
val names = HashMap<Point, String>()
names[Point(0, 0)] = "origin"
names[Point(3, 4)] = "far"
println(names[Point(0, 0)]) // origin
println(names[Point(9, 9)]) // null
}
Open in Kotlin Playground ↗
6. Counting and grouping with maps
Two jobs show up everywhere, and both are one loop over a map.
- Frequency counting: how many times does each thing occur? Map from item to count.
- Grouping: collect items that share a property. Map from the property (the key) to a list of items.
The counting pattern is always “read the old count, or zero if missing, and add one”. Kotlin gives shortcuts so you do not write it by hand: groupingBy { }.eachCount() to count, groupBy { } to group, and getOrPut to create a list the first time a key appears.
Run it: count words, group by length, group anagrams
Run it. The shortcut and the hand-written loop give the same counts. The last line shows three anagram groups.
fun groupAnagrams(words: List<String>): List<List<String>> {
val groups = HashMap<String, MutableList<String>>()
for (w in words) {
val key = w.toCharArray().sorted().joinToString("") // "eat" -> "aet"
groups.getOrPut(key) { mutableListOf() }.add(w)
}
return groups.values.toList()
}
fun main() {
val words = "to be or not to be".split(" ")
// Counting by hand
val counts = HashMap<String, Int>()
for (w in words) counts[w] = (counts[w] ?: 0) + 1
println(counts.toSortedMap())
// Counting with the shortcut
println(words.groupingBy { it }.eachCount().toSortedMap())
// Grouping by a property: here, word length
println(words.groupBy { it.length })
// Most common word
val best = counts.maxByOrNull { it.value }
println("most common: ${best?.key} x${best?.value}")
// Grouping anagrams, O(n * k log k) for n words of length k
val groups = groupAnagrams(listOf("eat", "tea", "tan", "ate", "nat", "bat"))
println(groups.sortedBy { it.first() })
}
Open in Kotlin Playground ↗
The cost of counting is O(n) time for n items and O(k) extra space, where k is the number of different items. For grouping anagrams, building each key sorts a word of length k, which costs O(k log k), so the total is O(n * k log k).
7. Worked problems: from slow to fast
Time to use the six-step method from Part 1. In each problem the slow version is the one you would write first, and a set or map removes the repeated searching.
Problem A: common items
Given two lists, which values appear in both? The slow way takes each item of the first list and scans the second one: O(n * m). The waste is the scanning. Put one list into a hash set, and each check becomes O(1), so the whole job is O(n + m).
Problem B: the first repeat
Return the first value that appears a second time as you read left to right. Keep a set of values seen so far. The first time add says “already there”, you have your answer. One pass, O(n).
Run it: two problems solved with a set
Both common-item versions agree. Only the cost differs, and it matters once the lists have thousands of items.
fun commonSlow(a: List<Int>, b: List<Int>): List<Int> {
val result = mutableListOf<Int>()
for (x in a) if (x in b && x !in result) result.add(x) // x in b scans b: O(n * m)
return result
}
fun commonFast(a: List<Int>, b: List<Int>): List<Int> {
val inB = b.toHashSet() // O(m)
val result = LinkedHashSet<Int>() // unique, keeps order
for (x in a) if (x in inB) result.add(x) // each check is O(1)
return result.toList()
}
fun firstRepeat(nums: List<Int>): Int? {
val seen = HashSet<Int>()
for (x in nums) {
if (!seen.add(x)) return x
}
return null
}
fun main() {
val a = listOf(4, 9, 5, 9, 4)
val b = listOf(9, 4, 9, 8, 4)
println(commonSlow(a, b)) // [4, 9]
println(commonFast(a, b)) // [4, 9]
println(firstRepeat(listOf(2, 5, 1, 2, 3, 5, 1))) // 2
println(firstRepeat(listOf(1, 2, 3))) // null
}
Open in Kotlin Playground ↗
Problem C: the longest run of consecutive numbers
Given unsorted numbers, find the length of the longest run of values that follow each other, like 1, 2, 3, 4. The numbers do not need to be next to each other in the input.
Sorting gives O(n log n), which is fine, but a set does it in O(n). The key idea: only start counting at the beginning of a run. A number x is the beginning of a run if x - 1 is not in the set. From each beginning, walk up x+1, x+2, ... while the numbers are in the set.
You will write this one yourself in the practice section. Before that, one more kind of data: tables of numbers.
8. Two-dimensional arrays and grids
A matrix or grid is an array of arrays: rows, each holding columns. You write grid[row][col]. Grids model spreadsheets, game boards, images (a grid of pixels) and maps.
Visiting every cell takes two nested loops, so a grid with R rows and C columns costs O(R * C). That is linear in the number of cells, not quadratic in anything, so name your sizes carefully when you state the cost.
Looking at neighbours
A common task is “what is next to this cell?”. Instead of four copy-pasted if blocks, store the four moves in two small arrays of row and column offsets, loop over them, and check the new position is inside the grid before reading it.
Run it: traversal, neighbours and transpose
Run it and check the transposed grid: the first row of the output should be the first column of the input.
fun main() {
val grid = arrayOf(
intArrayOf(1, 2, 3),
intArrayOf(4, 5, 6),
intArrayOf(7, 8, 9),
)
val rows = grid.size
val cols = grid[0].size
// Row by row: the natural, fast order for arrays.
for (r in 0 until rows) println(grid[r].joinToString(" "))
// Column sums: swap the loop order.
for (c in 0 until cols) {
var sum = 0
for (r in 0 until rows) sum += grid[r][c]
println("column $c sum = $sum")
}
// Neighbours with direction arrays, skipping cells outside the grid.
val dr = intArrayOf(-1, 1, 0, 0)
val dc = intArrayOf(0, 0, -1, 1)
fun neighbours(r: Int, c: Int): List<Int> {
val result = mutableListOf<Int>()
for (k in 0 until 4) {
val nr = r + dr[k]
val nc = c + dc[k]
if (nr in 0 until rows && nc in 0 until cols) result.add(grid[nr][nc])
}
return result
}
println(neighbours(1, 1)) // [2, 8, 4, 6] all four exist
println(neighbours(0, 0)) // [4, 2] corner: only two exist
// Transpose: cell (r, c) moves to (c, r). Rows become columns.
val t = Array(cols) { c -> IntArray(rows) { r -> grid[r][c] } }
for (row in t) println(row.joinToString(" "))
}
Open in Kotlin Playground ↗
Rotating a grid clockwise by 90 degrees is a transpose followed by reversing each row, or equivalently: the cell at row r, column c in the new grid comes from row rows - 1 - c, column r of the old one. You will try it below.
9. The cost table
Here is everything from this part on one page. Keep it nearby; choosing a structure is mostly looking up the operation you do most often.
| Operation | Array / list | HashMap / HashSet | TreeMap / TreeSet |
|---|---|---|---|
| Access by index | O(1) | not available | not available |
| Search by value / key | O(n) | O(1) average | O(log n) |
| Add at the end | amortised O(1) | n/a | n/a |
| Add or remove in the middle | O(n) | n/a | n/a |
| Add by key | n/a | O(1) average | O(log n) |
| Remove by key | O(n) (find, then shift) | O(1) average | O(log n) |
| Smallest / largest key | O(n) unsorted | O(n) | O(log n) |
| Iterate everything | O(n) | O(n), no useful order | O(n), sorted |
| Extra memory | compact | more: buckets and entries | more: tree nodes |
Choosing quickly
- You index by position or loop over everything: array or list.
- You ask “have I seen this?” or “what is the value for this key?”: hash set or hash map.
- You also want the keys in sorted order or nearby keys: TreeMap or TreeSet.
- You want a predictable order without sorting: LinkedHashMap or LinkedHashSet.
- Strings: use StringBuilder when you build one from many pieces.
10. Practice
Five exercises, from a warm-up with two pointers to one that rewards a hash set. Each has edge cases (empty, single item, duplicates) and some have large inputs, so a version with a nested loop will be very slow.
Try it: palindrome that ignores punctuation
Write isPalindrome(s). Ignore case and skip every character that is not a letter or digit, so “A man, a plan, a canal: Panama” counts. Use two pointers and no extra string. Open the starter in the Kotlin Playground, press Run, edit the code, and run again until every line says PASS.
fun isPalindrome(s: String): Boolean {
TODO("move two pointers inward, skipping characters that are not letters or digits")
}
Open in Kotlin Playground ↗
Hint: Loop while left < right. If the left character is not a letter or digit, move left and try again; same for the right. Compare with lowercaseChar().
Show a solution
fun isPalindrome(s: String): Boolean {
var left = 0
var right = s.length - 1
while (left < right) {
if (!s[left].isLetterOrDigit()) { left++; continue }
if (!s[right].isLetterOrDigit()) { right--; continue }
if (s[left].lowercaseChar() != s[right].lowercaseChar()) return false
left++
right--
}
return true
}
Try it: valid anagram
Write isAnagram(a, b): true if b uses exactly the same characters as a, the same number of times. It must run in O(n), so do not sort inside a loop or compare every pair. The last test has 200,000 characters. Open the starter in the Kotlin Playground, press Run, edit the code, and run again until every line says PASS.
fun isAnagram(a: String, b: String): Boolean {
TODO("count the characters of a, then use up the counts with b")
}
Open in Kotlin Playground ↗
Hint: First check the lengths. Count every character of a in a map, then subtract for each character of b. If a count would go below zero, return false.
Show a solution
fun isAnagram(a: String, b: String): Boolean {
if (a.length != b.length) return false
val counts = HashMap<Char, Int>()
for (c in a) counts[c] = (counts[c] ?: 0) + 1
for (c in b) {
val left = (counts[c] ?: 0) - 1
if (left < 0) return false
counts[c] = left
}
return true
}
Try it: first character that appears once
Write firstUniqueChar(s): return the index of the first character that occurs exactly once in the string, or -1 if there is none. This needs two passes: one to count, one to look. Open the starter in the Kotlin Playground, press Run, edit the code, and run again until every line says PASS.
fun firstUniqueChar(s: String): Int {
TODO("count every character, then scan again for the first with count 1")
}
Open in Kotlin Playground ↗
Hint: Build a map of counts in the first loop. In the second loop go left to right and return the first index whose character has a count of exactly 1.
Show a solution
fun firstUniqueChar(s: String): Int {
val counts = HashMap<Char, Int>()
for (c in s) counts[c] = (counts[c] ?: 0) + 1
for ((i, c) in s.withIndex()) {
if (counts[c] == 1) return i
}
return -1
}
Try it: longest consecutive run
Write longestConsecutive(nums): the length of the longest run of consecutive integers (like 7, 8, 9) that can be formed from the values, in any order. Duplicates count once. Aim for O(n) with a hash set: only start counting at a number whose predecessor is missing. The last two tests have 200,000 numbers. Open the starter in the Kotlin Playground, press Run, edit the code, and run again until every line says PASS.
fun longestConsecutive(nums: List<Int>): Int {
TODO("put the numbers in a set, and count upward from the start of each run")
}
Open in Kotlin Playground ↗
Hint: Loop over the set. If x - 1 is in the set, skip x. Otherwise count how far x + 1, x + 2, ... stay in the set and keep the maximum.
Show a solution
fun longestConsecutive(nums: List<Int>): Int {
val set = nums.toHashSet()
var best = 0
for (x in set) {
if ((x - 1) in set) continue // not the start of a run
var length = 1
while ((x + length) in set) length++
if (length > best) best = length
}
return best
}
Try it: rotate a grid clockwise
Write rotateClockwise(m): return a new grid turned 90 degrees clockwise. The grid does not have to be square, so a 2 by 3 grid becomes 3 by 2. The new cell at row r, column c comes from the old cell at row rows - 1 - c, column r. Open the starter in the Kotlin Playground, press Run, edit the code, and run again until every line says PASS.
fun rotateClockwise(m: List<List<Int>>): List<List<Int>> {
TODO("build the new grid with cols rows and rows columns")
}
Open in Kotlin Playground ↗
Hint: The result has cols rows and rows columns. Use List(cols) { r -> List(rows) { c -> ... } } and fill each cell from m[rows - 1 - c][r]. Handle the empty grid first.
Show a solution
fun rotateClockwise(m: List<List<Int>>): List<List<Int>> {
if (m.isEmpty()) return emptyList()
val rows = m.size
val cols = m[0].size
return List(cols) { r -> List(rows) { c -> m[rows - 1 - c][r] } }
}
Stuck? Try the six steps from Part 1: write the slow version, find the repeated work, then ask which structure removes it. After you pass the tests, say the time and space cost out loud.
11. Takeaways
- Position is cheap in arrays; searching by value is cheap in hash tables.
- Middle inserts and deletes in arrays cost
O(n); appends are amortisedO(1). - Never build a long string with repeated
+; useStringBuilder. - A hash map or set is the first tool to reach for when you notice repeated searching.
- Counting and grouping are one-pass jobs with a map: count with
eachCount, group withgroupBy. - Pick the map type by the order you need: none, insertion, or sorted.
- Next up: stacks, queues and linked lists, where the order of arrival decides what comes out first.
| Term | In plain words |
|---|---|
| Array | A fixed-size row of equal boxes in memory, each reached by its index. |
| Index | The number of a box in an array, starting at 0. |
| Capacity | How many items the hidden array of a growable list can hold before it must grow. |
| Amortised | The average cost per operation over a long run, even if a single operation is sometimes slow. |
| Immutable | Cannot be changed after it is created; changes produce a new value. |
| StringBuilder | A growable buffer for building a string without copying it over and over. |
| Palindrome | A sequence that reads the same forwards and backwards. |
| Anagram | A word made from the same letters as another, in a different order. |
| Hash function | A rule that turns a key into an integer called a hash code. |
| Bucket | One slot of a hash table; keys with the same bucket number share it. |
| Collision | Two different keys landing in the same bucket. |
| Hash map | A structure that stores key-value pairs and finds a value by its key in average O(1). |
| Hash set | A structure that stores unique values and answers “is it in here?” in average O(1). |
| Frequency count | A map from each item to how many times it appears. |
| Matrix / grid | A two-dimensional array, read as grid[row][col]. |