Data Structures, Algorithms and Programming Patterns · Part 9 of 9

Data Structures and Algorithms, Part 9: Study Path and Quicksheet

Khairul Alam·October 2026·~40 min with exercises
Data structuresAlgorithmsQuicksheetStudy planKotlinFor beginners

You now know the main data structures and the main ways to solve problems with them. This last part is the one to bookmark: a recap, a Big-O quicksheet, a table for choosing a structure, a practice routine, testing habits, real systems that use these ideas, and projects to build next.

This part in 60 seconds
  • O is an upper bound, Ω a lower bound and Θ a tight bound; best, average and worst case are a separate idea.
  • The quicksheets list the best, average and worst cost and the space of every structure and algorithm in the series.
  • Choose a structure by the operation you need most: lookup, order, smallest-first, LIFO or FIFO, prefix, connectivity, shortest path.
  • Study by predicting, running, breaking and rewriting from memory, with spaced repetition and a mistakes log.
  • Test fast code against a brute-force version on many seeded random inputs.
  • Build one of the capstone projects to make the ideas yours.
Contents
  1. The series on one page
  2. Big-O, Theta and Omega
  3. Complete quicksheet: best, average and worst
  4. Big-O quicksheet
  5. Which structure should I use?
  6. How to study this material
  7. Debugging and testing algorithms
  8. Where this shows up in real systems
  9. Capstone mini-projects
  10. Further reading
  11. Takeaways

1. The series on one page

You have come a long way. This part is a map you can come back to: what each part gave you, a quicksheet of costs, and a plan for what to do next. Keep it open in a tab while you practise.

Part 1CostBig-O, time vs space, six-step method Part 2Arrays / strings / hash maps / setsthe workhorses Part 3Stacks / queues / deques / linked listsorder rules Part 4Trees / BST / heaps / trieshierarchy, "smallest first" Part 5Graphs: BFS, DFS, shortest path, union-findconnections Part 6Sorting and binary searchput things in order, find fast Part 7Recursion, backtracking, greedy, DPbreak problems down Part 8Patterns: two pointers, window, prefix sums, ... Part 9Study path and quicksheetyou are here
Each part builds on the one before it.
PartThe one idea to remember
1. Start hereCount steps as the input grows. Write the slow, correct version first.
2. Arrays and hashingA hash map turns "search the whole list" into "look it up".
3. Stacks and queuesLast in, first out (stack) and first in, first out (queue) solve many ordering problems.
4. Trees and heapsShape matters: a balanced tree gives O(log n); a heap gives the smallest item fast.
5. GraphsMany problems are really "things and connections". BFS finds fewest steps; DFS explores deep.
6. Sorting and searchingSorted data lets you throw away half the search space each step.
7. Recursion and DPSolve small versions first; remember answers you have already computed.
8. PatternsFamiliar shapes (window, two pointers, prefix sums) turn O(n²) into O(n).

2. Big-O, Theta and Omega

Part 1 used Big-O for everything. There are really three symbols, and together they say how a cost sits against a growth rate. Think of a speed limit sign.

Driving to work. O is the speed limit: you never go faster than this (an upper bound). Ω is the minimum speed on a motorway: you never go slower (a lower bound). Θ is when the two signs show the same number: you drive at exactly that pace, give or take a constant (a tight bound).

SymbolSay itPlain meaningLike
O(g)big-OThe cost grows no faster than g.≤ (at most)
Ω(g)big-omegaThe cost grows at least as fast as g.≥ (at least)
Θ(g)thetaThe cost grows exactly like g: both O and Ω.= (the same rate)

The definition, in words

Say f(n) is the number of steps. We write f(n) = O(g(n)) when, for large enough n, f(n) is never more than some constant times g(n). We write f(n) = Ω(g(n)) when it is never less than some (smaller) constant times g(n). We write f(n) = Θ(g(n)) when both are true.

stepsn c2 * g(n)upper bound: f is under this (O) f(n) c1 * g(n)lower bound: f is over this (Omega) Theta = f(n) squeezed betweenc1 * g(n) and c2 * g(n) n0(the bounds only need to hold from here on)
Theta means f is sandwiched between two scaled copies of g once n is big enough.

Example: f(n) = 3n + 10. It is O(n) because 3n + 10 ≤ 4n once n ≥ 10. It is Ω(n) because 3n + 10 ≥ 3n. So it is Θ(n). It is also true, but loose, that it is O(n²). Always give the tightest bound you can.

Run it: watch the bounds hold

The ratio f/n settles near a constant, which is what Θ(n) means. The ratio f/n² keeps falling to zero, so n² is a true but loose upper bound.

fun f(n: Int): Long = 3L * n + 10        // the real cost, in steps

fun main() {
    println("n         f(n)        f/n     f/n^2")
    for (n in listOf(10, 100, 1000, 10_000, 100_000)) {
        val steps = f(n)
        println("%-9d %-11d %-7.2f %.6f".format(n, steps, steps.toDouble() / n, steps.toDouble() / n / n))
    }
    // f/n settles near 3: f is Theta(n). f/n^2 keeps shrinking: f is O(n^2) but not Omega(n^2).
}
Open in Kotlin Playground ↗

Best case and worst case are not the same thing as O and Ω

This trips up almost everyone. Best, average and worst describe which input you feed the code. O, Ω and Θ describe how you bound a growth rate. You can use any symbol on any case: “the worst case is Θ(n)” and “the best case is O(1)” are both fine sentences.

Quicksheets squeeze this into one convention, and so do we below: Ω = best case, Θ = average (typical) case, O = worst case. It is a handy shortcut, not a law. Where an algorithm costs the same for every input, one Θ covers all three.

Run it: one algorithm, three costs

The best case stays at 1 step for any n, the typical case is about half the list, and the worst case is the whole list.

// Linear search: how many items do we look at?
fun stepsToFind(list: List<Int>, target: Int): Int {
    var steps = 0
    for (x in list) { steps++; if (x == target) return steps }
    return steps                       // not found: we looked at everything
}

fun main() {
    println("n        best (first)   typical (middle)   worst (last or missing)")
    for (n in listOf(10, 1000, 100_000)) {
        val list = List(n) { it }
        val best = stepsToFind(list, 0)
        val middle = stepsToFind(list, n / 2)
        val worst = stepsToFind(list, -1)
        println("%-8d %-14d %-18d %d".format(n, best, middle, worst))
    }
    // best is Omega(1) (always 1), typical is Theta(n) (about n/2), worst is O(n).
}
Open in Kotlin Playground ↗

Growth rates, ranked

Learn this ladder by heart. Each rung eventually beats every rung below it, however large the constants.

1 < log n < √n < n < n log n < n² < n³ < 2ⁿ < n! < nⁿ

nlog₂ nnn log₂ nn²n³2ⁿn!
10310331001,0001,0243.6 million
100710066410,0001 million1.3 × 10³⁰9 × 10¹⁵⁷
1,000101,0009,9661 million1 billionmore than the atoms in the universeunimaginable

Rules for simplifying

Common recurrences

Recursive code is analysed with a recurrence: the cost of size n in terms of smaller sizes. These four cover most of what you will meet.

RecurrenceWhat it looks likeResultExample
T(n) = T(n-1) + O(1)one call, one fewer itemΘ(n)sum a list recursively
T(n) = T(n/2) + O(1)one call on halfΘ(log n)binary search
T(n) = 2T(n/2) + O(n)two halves, linear mergeΘ(n log n)merge sort
T(n) = 2T(n-1) + O(1)two calls, one fewer itemΘ(2ⁿ)naive Fibonacci, all subsets
Quick quiz: name the class (answers hidden)
  1. A loop from 1 to n containing another loop from 1 to n. Θ(n²)
  2. A loop that does i *= 2 until it passes n. Θ(log n)
  3. Two separate loops over n, one after the other. Θ(n), because n + n = 2n.
  4. An outer loop over n and an inner loop that runs i times. Θ(n²), since 1 + 2 + ... + n = n(n+1)/2.
  5. Reading one item from a HashMap. Θ(1) on average, O(n) in the worst case.

Try it: see Ω and O with your own eyes

Write insertionSortSteps: sort a copy of the list with insertion sort and return how many comparisons it made. A sorted list should cost about n (the best case, Ω(n)); a reversed list about n²/2 (the worst case, O(n²)). Open the starter in the Kotlin Playground, press Run, edit the code, and run again until every line says PASS.

fun insertionSortSteps(input: List<Int>): Int {
    TODO("sort a copy and count how many times you compare two items")
}
Open in Kotlin Playground ↗

Hint: Count one comparison each time you test a[j] > key, including the test that stops the inner loop.

Show a solution
fun insertionSortSteps(input: List<Int>): Int {
    val a = input.toMutableList()
    var comparisons = 0
    for (i in 1 until a.size) {
        val key = a[i]
        var j = i - 1
        while (j >= 0) {
            comparisons++
            if (a[j] > key) { a[j + 1] = a[j]; j-- } else break
        }
        a[j + 1] = key
    }
    return comparisons
}

3. Complete quicksheet: best, average and worst

Columns follow the convention above: Ω best, Θ average or typical, O worst. When one value is shown, all three agree. Variables: n items, V vertices, E edges, k key or word length, m a value range.

Data structures: average and worst case

StructureAccessSearchInsertDeleteSpace
ArrayΘ(1) / O(1)Θ(n) / O(n)Θ(n) / O(n)Θ(n) / O(n)O(n)
Dynamic array (ArrayList)Θ(1) / O(1)Θ(n) / O(n)end: Θ(1) amortised, O(n) worst; middle: O(n)end: Θ(1); middle: O(n)O(n)
Singly linked listΘ(n) / O(n)Θ(n) / O(n)head: Θ(1); tail: O(n), or O(1) with a tail pointerhead: Θ(1); given node: O(n) to find the previous oneO(n)
Doubly linked listΘ(n) / O(n)Θ(n) / O(n)both ends: Θ(1)given node: Θ(1)O(n)
Stacktop: Θ(1)Θ(n) / O(n)push: Θ(1)pop: Θ(1)O(n)
Queue / dequeends: Θ(1)Θ(n) / O(n)enqueue: Θ(1)dequeue: Θ(1)O(n)
Hash tablen/aΘ(1) / O(n)Θ(1) / O(n)Θ(1) / O(n)O(n)
Binary search treen/aΘ(log n) / O(n)Θ(log n) / O(n)Θ(log n) / O(n)O(n)
Balanced tree (AVL, red-black, TreeMap)n/aΘ(log n) / O(log n)Θ(log n) / O(log n)Θ(log n) / O(log n)O(n)
B-tree (databases, file systems)n/aΘ(log n) / O(log n)Θ(log n) / O(log n)Θ(log n) / O(log n)O(n)
Binary heapmin or max: Θ(1)Θ(n) / O(n)Ω(1), average Θ(1), O(log n)remove top: Θ(log n) / O(log n)O(n)
Trien/aΘ(k) / O(k)Θ(k) / O(k)Θ(k) / O(k)O(total letters)
Union-find (path compression + rank)n/afind: almost Θ(1)union: almost Θ(1)n/aO(n)

A heap insert only moves up when the new item is smaller than its parent. On random data that is rare, so the average is constant; the worst case climbs the whole height, log n. Building a heap from n items at once is Θ(n).

Graph representations

RepresentationSpaceIs there an edge a to b?List the neighbours of aAdd edgeBest for
Adjacency listO(V + E)O(degree of a)O(degree of a)O(1)sparse graphs (most real ones)
Adjacency matrixO(V²)O(1)O(V)O(1)dense graphs, fast edge lookups
Edge listO(E)O(E)O(E)O(1)sorting edges (Kruskal)

Sorting algorithms

AlgorithmΩ bestΘ averageO worstExtra spaceStable?
Bubble (with early exit)Ω(n)Θ(n²)O(n²)O(1)yes
SelectionΩ(n²)Θ(n²)O(n²)O(1)no
InsertionΩ(n)Θ(n²)O(n²)O(1)yes
MergeΩ(n log n)Θ(n log n)O(n log n)O(n)yes
QuickΩ(n log n)Θ(n log n)O(n²)O(log n)no
HeapΩ(n log n)Θ(n log n)O(n log n)O(1)no
Tim sort (sorted() in Kotlin)Ω(n)Θ(n log n)O(n log n)O(n)yes
CountingΩ(n + m)Θ(n + m)O(n + m)O(m)yes
Radix (d digits)Ω(d · (n + m))Θ(d · (n + m))O(d · (n + m))O(n + m)yes
BucketΩ(n + m)Θ(n + m)O(n²)O(n + m)yes

No sort that works only by comparing items can beat Ω(n log n) in the worst case (Part 6 shows why). Counting, radix and bucket sort escape that limit by not comparing.

Searching, graph and other algorithms

AlgorithmΩ bestΘ averageO worstExtra spaceNeeds
Linear searchΩ(1)Θ(n)O(n)O(1)nothing
Binary searchΩ(1)Θ(log n)O(log n)O(1)sorted data
Hash lookupΩ(1)Θ(1)O(n)O(n)a good hash function
Quickselect (k-th smallest)Ω(n)Θ(n)O(n²)O(1)nothing
BFS and DFSΘ(V + E)Θ(V + E)O(V + E)O(V)adjacency list
Topological sortΘ(V + E)Θ(V + E)O(V + E)O(V)a DAG
Dijkstra (binary heap)Θ((V + E) log V)Θ((V + E) log V)O((V + E) log V)O(V)no negative weights
Bellman-FordΩ(E)Θ(V · E)O(V · E)O(V)handles negative weights
Floyd-Warshall (all pairs)Θ(V³)Θ(V³)O(V³)O(V²)matrix of distances
Kruskal (minimum spanning tree)Θ(E log E)Θ(E log E)O(E log E)O(V)sorted edges, union-find
Prim (binary heap)Θ(E log V)Θ(E log V)O(E log V)O(V)adjacency list
Merge k sorted lists (heap)Θ(N log k)Θ(N log k)O(N log k)O(k)N items in total
Sieve of EratosthenesΘ(n log log n)Θ(n log log n)O(n log log n)O(n)primes up to n
Euclid’s gcdΩ(1)Θ(log n)O(log n)O(1)two integers
Fast power (xⁿ by squaring)Θ(log n)Θ(log n)O(log n)O(1)nothing

Dynamic programming and backtracking

ProblemTime (Θ)Extra spaceNote
Fibonacci, naive recursionΘ(2ⁿ)O(n)repeats sub-problems
Fibonacci, memo or tableΘ(n)O(n), or O(1) with two variablesremember each answer once
Climbing stairs, house robberΘ(n)O(1) with rolling variablesone-dimensional DP
Coin change (fewest coins)Θ(n · amount)O(amount)n coin types
Longest common subsequenceΘ(m · n)O(m · n), or O(min(m, n)) for the length onlytwo strings
Edit distanceΘ(m · n)O(m · n)two strings
0/1 knapsackΘ(n · W)O(W)W is the capacity
All subsets (backtracking)Θ(n · 2ⁿ)O(n) depththe output itself is huge
All permutations (backtracking)Θ(n · n!)O(n) depthonly for small n

Space complexity in one glance

Quick reading tips. If you see a sort anywhere, add n log n. If you see a hash map, assume O(1) per operation. If you see nested loops, multiply. If you see recursion that branches twice with only one fewer item, think 2ⁿ.

4. Big-O quicksheet

Costs below are for the usual implementation and for the worst case unless the note says otherwise. n is the number of items, V and E are vertices and edges of a graph, and k is the length of a word or key.

Data structures

StructureAccess by positionSearchInsertDeleteNotes
Array (fixed)O(1)O(n)O(n) (shift)O(n) (shift)Space O(n). Size cannot change.
Dynamic array (ArrayList)O(1)O(n)O(1) amortised at the end, O(n) in the middleO(1) at the end, O(n) elsewhereDoubles its storage when full, so occasional copies average out.
Linked listO(n)O(n)O(1) once you hold the nodeO(1) once you hold the nodeExtra memory for the links. Poor for cache.
Stack (ArrayDeque)top onlyO(n)push O(1)pop O(1)Last in, first out.
Queue (ArrayDeque)front onlyO(n)enqueue O(1)dequeue O(1)First in, first out.
Hash map / setn/aO(1) average, O(n) worstO(1) averageO(1) averageNo useful order. Worst case needs many collisions.
BST, unbalancedn/aO(h), up to O(n)O(h)O(h)Height h can be n if you insert sorted data.
BST, balanced (TreeMap)n/aO(log n)O(log n)O(log n)Keeps keys in order; in-order walk is O(n).
Binary heap (PriorityQueue)min or max O(1)O(n)O(log n)remove top O(log n)Building a heap from n items is O(n).
Trien/aO(k)O(k)O(k)Cost depends on word length, not on how many words. Space can be large.
Graph, adjacency listn/aBFS or DFS O(V + E)add edge O(1)remove edge O(degree)Space O(V + E). Adjacency matrix: space O(V²).

Algorithms

AlgorithmTimeExtra spaceNotes
Linear searchO(n)O(1)Works on any list.
Binary searchO(log n)O(1)Needs sorted data.
Insertion sortO(n²) worst, O(n) if nearly sortedO(1)Good for tiny or almost-sorted input. Stable.
Merge sortO(n log n)O(n)Stable. Predictable.
Quick sortO(n log n) average, O(n²) worstO(log n)Fast in practice. Bad pivots hurt.
Heap sortO(n log n)O(1)In place, not stable.
Counting sortO(n + m)O(m)m is the range of the values.
BFS / DFSO(V + E)O(V)BFS uses a queue, DFS a stack or recursion.
Dijkstra (with heap)O((V + E) log V)O(V)Non-negative edge weights only.
Union-find (with path compression)almost O(1) per operationO(n)Grows like the inverse Ackermann function, which is under 5 for any real input.
Fibonacci, naive recursionO(2ⁿ)O(n) (stack)Repeats the same sub-problems.
Fibonacci, DPO(n)O(1) with two variablesRemember the last two answers.
Longest common subsequence, DPO(m · n)O(m · n), or O(min(m, n)) if you keep one rowTwo strings of lengths m and n.
0/1 knapsack, DPO(n · W)O(W) with one rowW is the capacity.
Subsets / permutations (backtracking)O(2ⁿ) / O(n!)O(n) (depth)Output itself is that big.

Amortised means "averaged over many operations". One ArrayList.add may copy everything, but across a million adds the cost per add is still constant.

Standard-library names to remember: sorted() and sortedBy are O(n log n), HashMap is the hash map, TreeMap is the balanced tree map, PriorityQueue is the heap, and ArrayDeque is both stack and queue.

5. Which structure should I use?

Start from what you need to do, not from the structure. Ask which operation will run most often, then pick the structure that makes it cheap.

I need to…UseWhyWatch out for
Look up a value by key, fastHashMapO(1) average lookup.No order. Keys need good hashCode and equals.
Check "have I seen this?"HashSetO(1) average membership test.Stores values only.
Walk keys in sorted orderTreeMap / TreeSetBalanced tree, O(log n) per operation, ordered iteration.Slower than a hash map for plain lookup.
Keep insertion orderLinkedHashMapHash lookup plus a list of the order.A little more memory.
Always take the smallest (or largest) nextPriorityQueueTop in O(1), remove in O(log n).Not sorted inside; you can only take the top cheaply.
Undo, match brackets, backtrack (LIFO)Stack (ArrayDeque)Push and pop at one end.Use addLast / removeLast consistently.
Process in arrival order (FIFO), BFSQueue (ArrayDeque)Add at one end, remove at the other.Do not use removeAt(0) on an ArrayList; it is O(n).
Find all words with a given prefixTrieCost depends on the prefix length.Uses a lot of memory for many short words.
Ask "are these two things connected?" while links keep being addedUnion-findNearly constant per operation.Cannot split groups apart again.
Find the fewest steps (all steps cost the same)Graph and BFSBFS reaches nodes in order of distance.Weighted edges need Dijkstra.
Find the cheapest path with weightsGraph and Dijkstra (heap)Always expands the cheapest known node.Fails with negative weights.
Get an item by positionArray / ArrayListO(1) access.Insert in the middle is O(n).
Add and remove in the middle often, holding the nodeLinked listO(1) splice.In practice arrays are often faster, so measure first.
Search sorted data by valueSorted array and binary searchO(log n).Keeping it sorted after inserts costs O(n).
Sum or count over many rangesPrefix sumsO(1) per range after O(n) setup.Needs the data to stay fixed.

A rule of thumb. When in doubt, start with an ArrayList and a HashMap. They solve a surprising share of problems. Reach for something fancier when you can name the exact operation that is too slow.

6. How to study this material

Reading is the easy part and the weakest. You learn algorithms the way you learn an instrument: by playing, making mistakes and repeating. Here is a routine that works for one session of about an hour.

  1. Read one short section. Do not take notes yet.
  2. Predict. Before you run a demo, write down what you think it prints. Being wrong here is useful; it shows where your mental model is off.
  3. Run. Compare the output with your prediction.
  4. Break it. Change a line on purpose: an off-by-one, a wrong comparison, an empty input. See what fails and how it fails.
  5. Rewrite from memory. Close the page and write the code again. Peek only when you are truly stuck.
  6. Explain aloud. Tell a friend, a rubber duck or an empty room how it works and what it costs. Gaps in your explanation are gaps in your understanding.

Habits that make it stick

Trace of binary search for 7 in [1, 3, 5, 7, 9, 11] steplohimida[mid]decision 105255 < 7, go right: lo = 3 235499 > 7, go left: hi = 3 33337found at index 3
A hand trace is just a table. Draw it on paper before you trust the code.

An 8-week example schedule

About 4 to 6 hours a week works well. Adjust to your timetable; finishing slowly is better than rushing.

WeekReadDoReview
1Part 1Both exercises. Count steps for ten small functions you have written.Redo the six-step method on a new problem.
2Part 2All exercises. Rebuild a word counter with a HashMap.Rewrite Part 1 exercises from memory.
3Part 3Implement a stack, a queue and a linked list by hand.Part 2 hash map idea in your own words.
4Part 4Write a BST insert and search. Build a small trie.Parts 2 and 3 key code from memory.
5Part 5BFS and DFS on a grid and on an adjacency list.Draw a tree and a graph and walk both on paper.
6Part 6Write merge sort and binary search without looking.Trace binary search by hand with a table.
7Part 7Fibonacci three ways, subsets, a small DP table.Part 5 BFS from memory.
8Part 8 and this partStart one capstone project (section 7).Reread your mistakes log. Redo the three weakest exercises.

7. Debugging and testing algorithms

Algorithm bugs are sneaky: the code runs, the answer is just wrong for one input. A few habits catch most of them.

Off-by-one checklist

Watching an invariant at work

Run it: binary search with a checked invariant

The same trace table as before, now printed by the program. The two check lines would stop the run if the invariant ever became false.

fun binarySearch(a: IntArray, target: Int): Int {
    var lo = 0
    var hi = a.size - 1
    var step = 1
    while (lo <= hi) {
        // Invariant: everything left of lo is smaller than target,
        // everything right of hi is bigger than target.
        if (lo > 0) check(a[lo - 1] < target) { "invariant broken on the left" }
        if (hi < a.size - 1) check(a[hi + 1] > target) { "invariant broken on the right" }

        val mid = lo + (hi - lo) / 2
        println("step $step: lo=$lo hi=$hi mid=$mid a[mid]=${a[mid]}")
        step++
        when {
            a[mid] == target -> return mid
            a[mid] < target -> lo = mid + 1
            else -> hi = mid - 1
        }
    }
    return -1
}

fun main() {
    val a = intArrayOf(1, 3, 5, 7, 9, 11)
    println("found at ${binarySearch(a, 7)}")
    println("found at ${binarySearch(a, 4)}")
}
Open in Kotlin Playground ↗

A tiny property-style test

Hand-written tests only cover the cases you thought of. A randomized test generates hundreds of small inputs and compares the fast solution with the brute force. Using a fixed seed means the "random" inputs are the same on every run, so a failure can be repeated.

Below, the maximum sum of a contiguous slice. maxSumBuggy looks right but starts its best at 0, so it is wrong when every number is negative. Watch the test find that.

Run it: fast versus brute force on random inputs

Run it. The correct version passes; the buggy one is caught and the program prints a small input that breaks it.

import kotlin.random.Random

// Slow but obviously correct: try every slice. O(n^2)
fun maxSumBrute(a: List<Int>): Int {
    var best = a[0]
    for (i in a.indices) {
        var sum = 0
        for (j in i until a.size) {
            sum += a[j]
            if (sum > best) best = sum
        }
    }
    return best
}

// Fast and correct: best slice that ends here. O(n)
fun maxSumFast(a: List<Int>): Int {
    var current = a[0]
    var best = a[0]
    for (i in 1 until a.size) {
        current = maxOf(a[i], current + a[i])
        best = maxOf(best, current)
    }
    return best
}

// Fast but wrong: starting at 0 means "take nothing" is allowed.
fun maxSumBuggy(a: List<Int>): Int {
    var current = 0
    var best = 0
    for (x in a) {
        current = maxOf(0, current + x)
        best = maxOf(best, current)
    }
    return best
}

fun compare(name: String, fast: (List<Int>) -> Int) {
    val random = Random(12345)               // fixed seed: same inputs every run
    repeat(1000) { trial ->
        val size = random.nextInt(1, 8)       // small inputs are easy to read
        val a = List(size) { random.nextInt(-5, 6) }
        val expected = maxSumBrute(a)
        val actual = fast(a)
        if (actual != expected) {
            println("$name FAILED on trial $trial: input=$a expected=$expected got=$actual")
            return
        }
    }
    println("$name passed 1000 random trials")
}

fun main() {
    compare("maxSumFast ", ::maxSumFast)
    compare("maxSumBuggy", ::maxSumBuggy)
}
Open in Kotlin Playground ↗

Why small inputs? A failure on a list of three numbers is easy to trace by hand. Random tests with huge inputs find bugs too, but they are painful to read.

Now try the idea yourself. The function below is slow. Write the fast version so that it matches the brute force and also handles a large input.

Try it: count pairs quickly

The function countPairsBrute counts index pairs i < j with nums[i] + nums[j] == target in O(n²). Fill in countPairsFast in O(n). One test compares the two on random inputs; another uses 100,000 numbers. Open the starter in the Kotlin Playground, press Run, edit the code, and run again until every line says PASS.

fun countPairsBrute(nums: List<Int>, target: Int): Long {
    var count = 0L
    for (i in nums.indices) for (j in i + 1 until nums.size) {
        if (nums[i] + nums[j] == target) count++
    }
    return count
}

fun countPairsFast(nums: List<Int>, target: Int): Long {
    TODO("walk once; keep a map of how many times each value has appeared")
}
Open in Kotlin Playground ↗

Hint: For each number x, the pairs it completes with earlier numbers equal how many times target - x was seen before. Add that, then record x.

Show a solution
fun countPairsBrute(nums: List<Int>, target: Int): Long {
    var count = 0L
    for (i in nums.indices) for (j in i + 1 until nums.size) {
        if (nums[i] + nums[j] == target) count++
    }
    return count
}

fun countPairsFast(nums: List<Int>, target: Int): Long {
    val seen = HashMap<Int, Int>()
    var count = 0L
    for (x in nums) {
        count += seen[target - x] ?: 0
        seen[x] = (seen[x] ?: 0) + 1
    }
    return count
}

8. Where this shows up in real systems

None of this is only for exercises. The same ideas sit under software you use every day.

WhereStructure or algorithmWhat it does there
DatabasesB-trees and hash indexesA B-tree is a wide, shallow balanced tree. It keeps rows ordered on disk so a lookup touches only a few pages. A hash index answers exact-match lookups.
Browsers and editorsStacksUndo and redo are two stacks. The browser back button is a stack of pages. Bracket matching in an editor uses one too.
Operating system schedulersHeaps and queuesTasks wait in queues. A priority queue picks the most urgent one next.
Maps and navigationGraphs, Dijkstra and relativesRoads are edges, junctions are nodes, travel time is the weight.
Spell check and autocompleteTriesWalk the letters typed so far and list the words below that node.
GitA directed acyclic graph (DAG)Each commit points to its parents. Branches are labels on nodes. Finding a common ancestor is a graph walk.
CachesHash map plus linked listAn LRU cache keeps the most recently used items and drops the one used longest ago.
CompilersTrees, stacks, hash mapsSource code becomes a syntax tree; a symbol table (hash map) remembers names.
Search enginesInverted index (hash map of word to documents)Look up each word and combine the lists.
Social networksGraphs and union-findFriends are edges; finding groups or shortest connections is BFS.

Example: an LRU cache

LRU stands for least recently used. A cache is a small, fast store in front of something slow. When it is full and a new item arrives, it throws out the item that has gone unused the longest. To do that in O(1) you need two things: a hash map for fast lookup, and a list that remembers the usage order. Java and Kotlin's LinkedHashMap is exactly that pair in one class. With accessOrder = true, every get moves the entry to the back, and the oldest entry is at the front.

Run it: an LRU cache in a few lines

Run it and follow the order of the keys. The leftmost key is the next to be evicted.

class LruCache<K, V>(private val capacity: Int) :
    LinkedHashMap<K, V>(16, 0.75f, true) {          // true = order by access

    // Called after every put. Returning true removes the oldest entry.
    override fun removeEldestEntry(eldest: MutableMap.MutableEntry<K, V>?): Boolean {
        return size > capacity
    }
}

fun main() {
    val cache = LruCache<String, Int>(3)
    cache["a"] = 1
    cache["b"] = 2
    cache["c"] = 3
    println("start:        ${cache.keys}")      // [a, b, c]

    cache["a"]                                  // read a: it becomes the newest
    println("after get a: ${cache.keys}")      // [b, c, a]

    cache["d"] = 4                              // full, so the oldest (b) is evicted
    println("after put d: ${cache.keys}")      // [c, a, d]
    println("b present? ${cache.containsKey("b")}")
}
Open in Kotlin Playground ↗

Under the hood. The hash map gives O(1) lookup. Each entry also sits in a doubly linked list, so moving it to the back and removing the front are both O(1). You will build this idea by hand in the exercise below.

Your turn.

Try it: build an LRU cache

Write LRUCache with get(key) (returns -1 if missing and marks the key as most recently used) and put(key, value) (inserts or updates, then evicts the least recently used key if the size goes over capacity). Use a plain LinkedHashMap and remember that it keeps insertion order. Open the starter in the Kotlin Playground, press Run, edit the code, and run again until every line says PASS.

class LRUCache(private val capacity: Int) {
    private val map = LinkedHashMap<Int, Int>()

    fun get(key: Int): Int {
        TODO("return -1 if missing, otherwise move the key to the end and return the value")
    }

    fun put(key: Int, value: Int) {
        TODO("insert or update as most recent; evict the first key when over capacity")
    }
}
Open in Kotlin Playground ↗

Hint: Removing a key and putting it back moves it to the end. The oldest key is map.keys.first().

Show a solution
class LRUCache(private val capacity: Int) {
    private val map = LinkedHashMap<Int, Int>()

    fun get(key: Int): Int {
        val value = map.remove(key) ?: return -1
        map[key] = value
        return value
    }

    fun put(key: Int, value: Int) {
        map.remove(key)
        map[key] = value
        if (map.size > capacity) {
            val oldest = map.keys.first()
            map.remove(oldest)
        }
    }
}

9. Capstone mini-projects

The best way to make all of this yours is to build something. Each project below is small, uses one or two structures, and can be finished in a weekend. Pick one, write a few tests first, and use the randomized comparison trick from earlier where you can.

ProjectSpecWhat you practise
Autocomplete with a trieLoad a list of words. Given a prefix, return up to 5 matching words, in alphabetical order. Bonus: remember how often each word was chosen and sort suggestions by that.Tries, recursion or a stack, sorting, hash maps.
A tiny search engineTake 10 to 20 short text documents. Build an index mapping each word to the set of documents that contain it. Answer queries such as "cat AND dog" by intersecting sets. Bonus: rank by how many times the words appear.Hash maps and sets, string splitting, sorting.
A route finder with DijkstraRepresent a small map as a weighted graph (about 10 cities). Given a start and an end, print the cheapest route and its cost. Bonus: also show the route with the fewest stops, using BFS.Graphs, priority queue, reconstructing a path.
A task scheduler with a priority queueTasks have a name, a priority and a duration. Always run the highest-priority ready task next and print a timeline. Bonus: add deadlines and report which tasks were late.Heaps, comparators, simulation loops.
A text diff with LCSGiven two lists of lines, compute their longest common subsequence with DP and print lines as unchanged, added (+) or removed (-), like a small version of git diff.Dynamic programming, walking a table backwards.
An LRU cache you write by handReplace LinkedHashMap with your own doubly linked list plus a hash map. Both operations must be O(1).Linked lists, pointer care, invariants.

For each project, write down before you start: what the input is, what the output is, which structure fits each need (use the table in section 3) and what the cost should be. Afterwards, compare your plan with what you built.

10. Further reading

These are well-known books and tools. Several have free versions or free course material; search for the title to find the official source.

What to do next

Pick one capstone project, put the quicksheet beside you, and start with the smallest working version. When it works, write the tests, then make it faster. That loop of build, test, improve is the whole craft.

11. Takeaways

TermIn plain words
Amortised costThe average cost per operation over a long run of operations.
InvariantA statement that stays true at the start of every loop step.
Property-style testA test that checks a rule on many generated inputs, such as "fast equals brute force".
SeedThe starting value of a random generator; the same seed gives the same numbers.
LRULeast recently used: an eviction rule that drops the item untouched for the longest time.
DAGDirected acyclic graph: arrows between nodes with no way to loop back.
Spaced repetitionReviewing material at growing gaps so it stays in memory.
← Back to all writing