Data Structures, Algorithms and Programming Patterns · Part 9 of 9
Data Structures and Algorithms, Part 9: Study Path and Quicksheet
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.
Ois 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
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 | The one idea to remember |
|---|---|
| 1. Start here | Count steps as the input grows. Write the slow, correct version first. |
| 2. Arrays and hashing | A hash map turns "search the whole list" into "look it up". |
| 3. Stacks and queues | Last in, first out (stack) and first in, first out (queue) solve many ordering problems. |
| 4. Trees and heaps | Shape matters: a balanced tree gives O(log n); a heap gives the smallest item fast. |
| 5. Graphs | Many problems are really "things and connections". BFS finds fewest steps; DFS explores deep. |
| 6. Sorting and searching | Sorted data lets you throw away half the search space each step. |
| 7. Recursion and DP | Solve small versions first; remember answers you have already computed. |
| 8. Patterns | Familiar 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).
| Symbol | Say it | Plain meaning | Like |
|---|---|---|---|
O(g) | big-O | The cost grows no faster than g. | ≤ (at most) |
Ω(g) | big-omega | The cost grows at least as fast as g. | ≥ (at least) |
Θ(g) | theta | The 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.
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ⁿ
| n | log₂ n | n | n log₂ n | n² | n³ | 2ⁿ | n! |
|---|---|---|---|---|---|---|---|
| 10 | 3 | 10 | 33 | 100 | 1,000 | 1,024 | 3.6 million |
| 100 | 7 | 100 | 664 | 10,000 | 1 million | 1.3 × 10³⁰ | 9 × 10¹⁵⁷ |
| 1,000 | 10 | 1,000 | 9,966 | 1 million | 1 billion | more than the atoms in the universe | unimaginable |
Rules for simplifying
- Drop constants:
5nisΘ(n). - Keep the biggest term:
n² + 100n + 7isΘ(n²). - Steps in a row add:
O(n) + O(n²) = O(n²). - Steps inside steps multiply: a loop of
naround a body ofO(log n)isO(n log n). - Different inputs, different letters: looping over lists of sizes
mandnisO(m · n), notO(n²). - The base of a logarithm does not matter:
log₂ nandlog₁₀ ndiffer by a constant.
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.
| Recurrence | What it looks like | Result | Example |
|---|---|---|---|
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)
- A loop from 1 to
ncontaining another loop from 1 ton. Θ(n²) - A loop that does
i *= 2until it passesn. Θ(log n) - Two separate loops over
n, one after the other. Θ(n), becausen + n = 2n. - An outer loop over
nand an inner loop that runsitimes. Θ(n²), since1 + 2 + ... + n = n(n+1)/2. - 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
| Structure | Access | Search | Insert | Delete | Space |
|---|---|---|---|---|---|
| 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 pointer | head: Θ(1); given node: O(n) to find the previous one | O(n) |
| Doubly linked list | Θ(n) / O(n) | Θ(n) / O(n) | both ends: Θ(1) | given node: Θ(1) | O(n) |
| Stack | top: Θ(1) | Θ(n) / O(n) | push: Θ(1) | pop: Θ(1) | O(n) |
| Queue / deque | ends: Θ(1) | Θ(n) / O(n) | enqueue: Θ(1) | dequeue: Θ(1) | O(n) |
| Hash table | n/a | Θ(1) / O(n) | Θ(1) / O(n) | Θ(1) / O(n) | O(n) |
| Binary search tree | n/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 heap | min or max: Θ(1) | Θ(n) / O(n) | Ω(1), average Θ(1), O(log n) | remove top: Θ(log n) / O(log n) | O(n) |
| Trie | n/a | Θ(k) / O(k) | Θ(k) / O(k) | Θ(k) / O(k) | O(total letters) |
| Union-find (path compression + rank) | n/a | find: almost Θ(1) | union: almost Θ(1) | n/a | O(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
| Representation | Space | Is there an edge a to b? | List the neighbours of a | Add edge | Best for |
|---|---|---|---|---|---|
| Adjacency list | O(V + E) | O(degree of a) | O(degree of a) | O(1) | sparse graphs (most real ones) |
| Adjacency matrix | O(V²) | O(1) | O(V) | O(1) | dense graphs, fast edge lookups |
| Edge list | O(E) | O(E) | O(E) | O(1) | sorting edges (Kruskal) |
Sorting algorithms
| Algorithm | Ω best | Θ average | O worst | Extra space | Stable? |
|---|---|---|---|---|---|
| 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 | Θ average | O worst | Extra space | Needs |
|---|---|---|---|---|---|
| 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
| Problem | Time (Θ) | Extra space | Note |
|---|---|---|---|
| Fibonacci, naive recursion | Θ(2ⁿ) | O(n) | repeats sub-problems |
| Fibonacci, memo or table | Θ(n) | O(n), or O(1) with two variables | remember each answer once |
| Climbing stairs, house robber | Θ(n) | O(1) with rolling variables | one-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 only | two 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) depth | the output itself is huge |
| All permutations (backtracking) | Θ(n · n!) | O(n) depth | only for small n |
Space complexity in one glance
O(1): a few variables (iterative loops, two pointers, in-place swaps).O(log n): recursion that halves the input (binary search, quicksort’s call stack).O(n): one extra copy or table (hash set, merge sort buffer, memo array).O(n²): a table over pairs (adjacency matrix, LCS table).- Recursion depth counts as space: a recursion
ncalls deep usesO(n)stack, even if it allocates nothing else.
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
| Structure | Access by position | Search | Insert | Delete | Notes |
|---|---|---|---|---|---|
| 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 middle | O(1) at the end, O(n) elsewhere | Doubles its storage when full, so occasional copies average out. |
| Linked list | O(n) | O(n) | O(1) once you hold the node | O(1) once you hold the node | Extra memory for the links. Poor for cache. |
Stack (ArrayDeque) | top only | O(n) | push O(1) | pop O(1) | Last in, first out. |
Queue (ArrayDeque) | front only | O(n) | enqueue O(1) | dequeue O(1) | First in, first out. |
| Hash map / set | n/a | O(1) average, O(n) worst | O(1) average | O(1) average | No useful order. Worst case needs many collisions. |
| BST, unbalanced | n/a | O(h), up to O(n) | O(h) | O(h) | Height h can be n if you insert sorted data. |
BST, balanced (TreeMap) | n/a | O(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). |
| Trie | n/a | O(k) | O(k) | O(k) | Cost depends on word length, not on how many words. Space can be large. |
| Graph, adjacency list | n/a | BFS or DFS O(V + E) | add edge O(1) | remove edge O(degree) | Space O(V + E). Adjacency matrix: space O(V²). |
Algorithms
| Algorithm | Time | Extra space | Notes |
|---|---|---|---|
| Linear search | O(n) | O(1) | Works on any list. |
| Binary search | O(log n) | O(1) | Needs sorted data. |
| Insertion sort | O(n²) worst, O(n) if nearly sorted | O(1) | Good for tiny or almost-sorted input. Stable. |
| Merge sort | O(n log n) | O(n) | Stable. Predictable. |
| Quick sort | O(n log n) average, O(n²) worst | O(log n) | Fast in practice. Bad pivots hurt. |
| Heap sort | O(n log n) | O(1) | In place, not stable. |
| Counting sort | O(n + m) | O(m) | m is the range of the values. |
| BFS / DFS | O(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 operation | O(n) | Grows like the inverse Ackermann function, which is under 5 for any real input. |
| Fibonacci, naive recursion | O(2ⁿ) | O(n) (stack) | Repeats the same sub-problems. |
| Fibonacci, DP | O(n) | O(1) with two variables | Remember the last two answers. |
| Longest common subsequence, DP | O(m · n) | O(m · n), or O(min(m, n)) if you keep one row | Two strings of lengths m and n. |
| 0/1 knapsack, DP | O(n · W) | O(W) with one row | W 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… | Use | Why | Watch out for |
|---|---|---|---|
| Look up a value by key, fast | HashMap | O(1) average lookup. | No order. Keys need good hashCode and equals. |
| Check "have I seen this?" | HashSet | O(1) average membership test. | Stores values only. |
| Walk keys in sorted order | TreeMap / TreeSet | Balanced tree, O(log n) per operation, ordered iteration. | Slower than a hash map for plain lookup. |
| Keep insertion order | LinkedHashMap | Hash lookup plus a list of the order. | A little more memory. |
| Always take the smallest (or largest) next | PriorityQueue | Top 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), BFS | Queue (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 prefix | Trie | Cost depends on the prefix length. | Uses a lot of memory for many short words. |
| Ask "are these two things connected?" while links keep being added | Union-find | Nearly constant per operation. | Cannot split groups apart again. |
| Find the fewest steps (all steps cost the same) | Graph and BFS | BFS reaches nodes in order of distance. | Weighted edges need Dijkstra. |
| Find the cheapest path with weights | Graph and Dijkstra (heap) | Always expands the cheapest known node. | Fails with negative weights. |
| Get an item by position | Array / ArrayList | O(1) access. | Insert in the middle is O(n). |
| Add and remove in the middle often, holding the node | Linked list | O(1) splice. | In practice arrays are often faster, so measure first. |
| Search sorted data by value | Sorted array and binary search | O(log n). | Keeping it sorted after inserts costs O(n). |
| Sum or count over many ranges | Prefix sums | O(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.
- Read one short section. Do not take notes yet.
- 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.
- Run. Compare the output with your prediction.
- Break it. Change a line on purpose: an off-by-one, a wrong comparison, an empty input. See what fails and how it fails.
- Rewrite from memory. Close the page and write the code again. Peek only when you are truly stuck.
- 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
- Spaced repetition. Revisit a topic after 1 day, 3 days, 1 week and 3 weeks. Rewrite the key code each time. Short and often beats long and once.
- Keep a mistakes log. A simple file with the date, the problem, what went wrong and the fix. Read it every week. You will notice you make the same three or four mistakes.
- Time-box being stuck. Give yourself 20 to 30 minutes of honest struggle. Then read a hint, then more of the solution. After that, close it and solve it yourself the next day. Struggling a little builds skill; struggling for hours just drains you.
- Trace by hand with a table. Take a tiny input and write one row per loop step with the variables as columns. The next demo does this in code.
- Change one thing at a time. When you modify code, run it after each small change.
An 8-week example schedule
About 4 to 6 hours a week works well. Adjust to your timetable; finishing slowly is better than rushing.
| Week | Read | Do | Review |
|---|---|---|---|
| 1 | Part 1 | Both exercises. Count steps for ten small functions you have written. | Redo the six-step method on a new problem. |
| 2 | Part 2 | All exercises. Rebuild a word counter with a HashMap. | Rewrite Part 1 exercises from memory. |
| 3 | Part 3 | Implement a stack, a queue and a linked list by hand. | Part 2 hash map idea in your own words. |
| 4 | Part 4 | Write a BST insert and search. Build a small trie. | Parts 2 and 3 key code from memory. |
| 5 | Part 5 | BFS and DFS on a grid and on an adjacency list. | Draw a tree and a graph and walk both on paper. |
| 6 | Part 6 | Write merge sort and binary search without looking. | Trace binary search by hand with a table. |
| 7 | Part 7 | Fibonacci three ways, subsets, a small DP table. | Part 5 BFS from memory. |
| 8 | Part 8 and this part | Start 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.
- Write the small tests first. Before the code, list inputs and expected outputs: empty, one item, two items, all equal, already sorted, reversed, duplicates, negative numbers.
- Keep the brute force. The slow, obviously correct version is your answer key for the fast one.
- Print the state. Print the variables at the top of each loop step, the same columns as your hand trace. Remove the prints when it works.
- Name the invariant. An invariant is a sentence that is true at the start of every loop step, such as "if the target exists, it is inside
a[lo..hi]". If you can say it, you can check it withcheck(...). - Shrink the failing case. Once a test fails, try to make the input smaller while it still fails. A bug on three numbers is much easier to see than a bug on three thousand.
Off-by-one checklist
- Does the range include or exclude the last index? (
0 until nversus0..n.) - Is the loop
<or<=? Try a list with one element. - In binary search, does
loorhimove pastmid, or can it get stuck? - Is
midcomputed aslo + (hi - lo) / 2, which cannot overflow? - Do you handle the empty input before touching
a[0]? - Is a "length" confused with a "last index"? Length is one more.
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.
| Where | Structure or algorithm | What it does there |
|---|---|---|
| Databases | B-trees and hash indexes | A 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 editors | Stacks | Undo and redo are two stacks. The browser back button is a stack of pages. Bracket matching in an editor uses one too. |
| Operating system schedulers | Heaps and queues | Tasks wait in queues. A priority queue picks the most urgent one next. |
| Maps and navigation | Graphs, Dijkstra and relatives | Roads are edges, junctions are nodes, travel time is the weight. |
| Spell check and autocomplete | Tries | Walk the letters typed so far and list the words below that node. |
| Git | A directed acyclic graph (DAG) | Each commit points to its parents. Branches are labels on nodes. Finding a common ancestor is a graph walk. |
| Caches | Hash map plus linked list | An LRU cache keeps the most recently used items and drops the one used longest ago. |
| Compilers | Trees, stacks, hash maps | Source code becomes a syntax tree; a symbol table (hash map) remembers names. |
| Search engines | Inverted index (hash map of word to documents) | Look up each word and combine the lists. |
| Social networks | Graphs and union-find | Friends 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.
| Project | Spec | What you practise |
|---|---|---|
| Autocomplete with a trie | Load 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 engine | Take 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 Dijkstra | Represent 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 queue | Tasks 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 LCS | Given 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 hand | Replace 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.
- Introduction to Algorithms by Cormen, Leiserson, Rivest and Stein. The standard university textbook. Thorough and mathematical; read it slowly, one chapter at a time.
- The Algorithm Design Manual by Steven Skiena. Friendly and practical, with a catalogue of problems and the structure that fits each.
- Algorithms by Robert Sedgewick and Kevin Wayne. Clear explanations with pictures; the companion website has code and slides.
- Grokking Algorithms by Aditya Bhargava. A very gentle, illustrated introduction. Good as a second look at Parts 1 to 5.
- Algorithms by Dasgupta, Papadimitriou and Vazirani. A short, well-written book with a free draft available from its authors.
- visualgo.net. Animated visualisations of sorting, trees, heaps, graphs and more. Step through an algorithm and watch the data change.
- The Kotlin documentation at kotlinlang.org. Look at the pages on collections, especially the lists, sets, maps and sequences, so you know what the standard library already gives you.
- The source of your language's standard collections. Reading how
ArrayList,HashMapandPriorityQueueare written is a great second pass once you know the basics.
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
- Pick the structure from the operation, then check the cost in the table.
- Brute force is the answer key for your fast solution.
- Seeded random tests find bugs your hand-written tests miss, and can be repeated.
- Practise a little, often, and rewrite code from memory.
- Real systems use the same structures you have just built: trees, heaps, tries, graphs and hash maps.
- Keep building. A small project teaches more than another chapter.
| Term | In plain words |
|---|---|
| Amortised cost | The average cost per operation over a long run of operations. |
| Invariant | A statement that stays true at the start of every loop step. |
| Property-style test | A test that checks a rule on many generated inputs, such as "fast equals brute force". |
| Seed | The starting value of a random generator; the same seed gives the same numbers. |
| LRU | Least recently used: an eviction rule that drops the item untouched for the longest time. |
| DAG | Directed acyclic graph: arrows between nodes with no way to loop back. |
| Spaced repetition | Reviewing material at growing gaps so it stays in memory. |