Data Structures, Algorithms and Programming Patterns · Part 7 of 9
Data Structures and Algorithms, Part 7: Recursion, Backtracking, Greedy Choices and Dynamic Programming
Some problems have too many possible answers to check one by one. This part shows four ways of handling them: recursion to break a problem into smaller copies, backtracking to explore choices and undo them, greedy steps when the best local choice is safe, and dynamic programming to stop solving the same subproblem twice.
- Recursion needs a base case (written first) and a recursive case on a smaller input; the call stack stores the waiting calls and can overflow.
- Backtracking is choose, explore, un-choose over a decision tree; pruning cuts dead branches early.
- Greedy takes the best local choice and is only right when you can argue it is safe; one counter-example breaks it.
- Dynamic programming = overlapping subproblems + optimal substructure, solved with: state, recurrence, base cases, order.
- Memoisation is top-down with a cache; tabulation is bottom-up with a table, often shrinkable to a few variables.
Contents
- Recursion: a function that calls itself
- Divide and conquer, a short recap
- Backtracking: try, look ahead, undo
- Greedy: take the best choice now
- Dynamic programming: remember what you solved
- Fibonacci four ways
- Classic one-dimensional problems
- Two-dimensional problems and the filled table
- Top-down or bottom-up?
- Choosing the right tool
- Practice
- Takeaways
1. Recursion: a function that calls itself
A recursive function is a function that calls itself to solve a smaller piece of the same problem. That sounds circular, but it works as long as the pieces keep getting smaller and there is a place to stop.
Russian dolls. To count the dolls, open the biggest one. If it is empty, the answer is zero. Otherwise the answer is one plus the number of dolls inside. You never need a plan for all dolls at once, only a rule for one doll and a rule for the empty one.
Every recursive function has two parts:
- The base case: the input so small that the answer is known without any more calls. It is the empty doll. Write it first.
- The recursive case: solve the problem by calling the same function on a smaller input, then combine the result with a little extra work.
If you forget the base case, or the input never gets smaller, the function calls itself forever. That is the number one recursion bug.
Three small examples
The factorial of n (written n!) is n * (n-1) * ... * 1. Notice that n! = n * (n-1)!. That is already a recursive rule. The base case is 1! = 1 (and 0! = 1).
The program below prints a line when each call starts and when it returns, indented by depth, so you can see the dolls opening and closing.
Run it: watch factorial open and close
Read the output from the top down for the calls, then from the bottom up for the returns.
fun factorial(n: Int, depth: Int = 0): Long {
val pad = " ".repeat(depth * 2)
println("${pad}factorial($n) called")
if (n <= 1) { // base case first
println("${pad}base case, return 1")
return 1
}
val result = n * factorial(n - 1, depth + 1) // recursive case
println("${pad}factorial($n) returns $result")
return result
}
fun main() {
println("answer = ${factorial(4)}")
}
Open in Kotlin Playground ↗
The call stack
How does the computer remember where it was in each call? It uses the call stack: a pile of “frames”, one per active call. A frame holds that call’s own variables and the place to continue after it finishes. A new call puts a frame on top. A return removes the top frame. (A stack is last in, first out, as Part 3 explains.)
Sum and power
The sum of 1..n is n plus the sum of 1..n-1, with 0 as the base case. Power is more interesting. Computing b^e as b * b^(e-1) takes e calls. But b^e = (b^(e/2))^2 halves the exponent each time, so it takes only about log e calls. Halving is the same trick as binary search.
Run it: sum, slow power and fast power
var slowCalls = 0
var fastCalls = 0
fun sumTo(n: Int): Int = if (n == 0) 0 else n + sumTo(n - 1)
// b^e as b * b^(e-1): e + 1 calls, O(e)
fun powerSlow(b: Long, e: Int): Long {
slowCalls++
return if (e == 0) 1 else b * powerSlow(b, e - 1)
}
// b^e as (b^(e/2))^2, times b once more if e is odd: O(log e)
fun powerFast(b: Long, e: Int): Long {
fastCalls++
if (e == 0) return 1
val half = powerFast(b, e / 2)
return if (e % 2 == 0) half * half else half * half * b
}
fun main() {
println("sumTo(100) = ${sumTo(100)}")
println("slow: ${powerSlow(2, 30)} in $slowCalls calls")
println("fast: ${powerFast(2, 30)} in $fastCalls calls")
}
Open in Kotlin Playground ↗
When the stack runs out
The call stack has a limited size. If recursion goes too deep (hundreds of thousands of frames, depending on the machine), the program stops with a stack overflow. In Kotlin and Java that is a StackOverflowError. It usually means a missing base case, or an input that is simply too deep for recursion.
There are two ways out. A tail call is a recursive call that is the very last thing a function does, with nothing left to do afterwards. Kotlin can turn those into a loop if you mark the function tailrec. The other way is to write the loop yourself. Any recursion can become a loop, and when the depth is huge, that is the safe choice.
Run it: stack overflow, tailrec and a loop
The plain recursive version breaks at a depth the loop does not even notice.
fun depth(n: Int): Int = if (n == 0) 0 else 1 + depth(n - 1) // work left after the call: not a tail call
tailrec fun sumTail(n: Long, acc: Long = 0): Long = // the call is the last thing: a tail call
if (n == 0L) acc else sumTail(n - 1, acc + n)
fun sumLoop(n: Long): Long { // the same thing as a plain loop
var total = 0L
for (i in 1..n) total += i
return total
}
fun main() {
println("depth(1000) = ${depth(1000)}")
try {
println(depth(10_000_000))
} catch (e: StackOverflowError) {
println("StackOverflowError: ten million frames do not fit")
}
println("tailrec = ${sumTail(10_000_000)}")
println("loop = ${sumLoop(10_000_000)}")
}
Open in Kotlin Playground ↗
| Time | Extra space | |
|---|---|---|
Factorial / sum (n calls deep) | O(n) | O(n) for the call stack |
| Fast power | O(log e) | O(log e) for the call stack |
| Same job as a loop | same | O(1) |
Hidden cost. Recursion looks free, but the call stack is real memory. A recursive function that goes n deep uses O(n) space even if it declares no arrays.
2. Divide and conquer, a short recap
Divide and conquer is a recursion shape you may have met in sorting (Part 6): split the problem into smaller copies, solve each copy, combine the answers. Fast power did it with one half. Binary search does it with one half. Merge sort does it with two halves.
The key property is that the pieces do not overlap. Each half of the list is a different set of items, so no work is repeated. Remember this, because dynamic programming exists for the case where the pieces do overlap.
Run it: the maximum by halves
This is no faster than a simple loop (O(n) time), but it shows the shape: split, solve, combine. Each item is visited once.
var calls = 0
fun maxRange(a: IntArray, lo: Int, hi: Int): Int {
calls++
if (lo == hi) return a[lo] // base case: one item
val mid = (lo + hi) / 2
val left = maxRange(a, lo, mid) // solve the left half
val right = maxRange(a, mid + 1, hi) // solve the right half
return maxOf(left, right) // combine
}
fun main() {
val a = intArrayOf(4, 9, 2, 7, 5, 1, 8, 3)
println("max = ${maxRange(a, 0, a.size - 1)}")
println("calls = $calls (n = ${a.size}, so about 2n - 1)")
}
Open in Kotlin Playground ↗
The cost of a divide-and-conquer algorithm depends on two numbers: how many pieces you make, and how much work the combine step is. Splitting in two and combining in O(n) gives O(n log n), as in merge sort. Splitting in one half with O(1) combining gives O(log n), as in binary search.
3. Backtracking: try, look ahead, undo
Backtracking is recursion used to search through many possible answers. You build an answer one decision at a time. When a decision leads to a dead end, you undo it and try the next option.
A maze. At each junction you pick a corridor and walk down it. If it ends at a wall, you walk back to the junction (undo) and try another corridor. You never need to map the whole maze, only remember where the last junction was. The call stack remembers that for you.
A few words first. The state is whatever describes where you are in the search: the choices made so far. A decision tree is the picture of all the choices, where each level is one decision.
The template: choose, explore, un-choose
Nearly every backtracking solution has the same three steps inside a loop:
- Choose: add one option to the current state.
- Explore: recurse to make the next decision.
- Un-choose: remove that option, so the state is exactly as it was before. This is the “back” in backtracking.
fun explore(state) {
if (state is a complete answer) { save a COPY of it; return }
for (option in options available now) {
if (option is not allowed) continue // pruning
choose(option)
explore(state)
unchoose(option)
}
}
Two details trip people up. First, when you save an answer, save a copy (current.toList()), because the same list keeps changing afterwards. Second, un-choose must exactly reverse choose.
Subsets and permutations
A subset is any selection of items, ignoring order. A permutation is an ordering of all the items. For three items there are 8 subsets and 6 permutations. In general, 2^n subsets and n! permutations, so these programs only work for small n.
Run it: all subsets
Starting the loop at start means each subset is built in one order only, so there are no repeats.
fun subsets(nums: List<Int>): List<List<Int>> {
val result = mutableListOf<List<Int>>()
val current = mutableListOf<Int>()
fun explore(start: Int) {
result.add(current.toList()) // every state on the way is a valid subset
for (i in start until nums.size) {
current.add(nums[i]) // choose
explore(i + 1) // explore: only look at later items
current.removeAt(current.size - 1) // un-choose
}
}
explore(0)
return result
}
fun main() {
for (s in subsets(listOf(1, 2, 3))) println(s)
println("count for 10 items = ${subsets(List(10) { it }).size}")
}
Open in Kotlin Playground ↗
Run it: all permutations
fun permutations(nums: List<Int>): List<List<Int>> {
val result = mutableListOf<List<Int>>()
val current = mutableListOf<Int>()
val used = BooleanArray(nums.size)
fun explore() {
if (current.size == nums.size) { // complete answer
result.add(current.toList())
return
}
for (i in nums.indices) {
if (used[i]) continue // already placed in this answer
used[i] = true // choose
current.add(nums[i])
explore() // explore
current.removeAt(current.size - 1) // un-choose
used[i] = false
}
}
explore()
return result
}
fun main() {
for (p in permutations(listOf(1, 2, 3))) println(p)
println("count for 6 items = ${permutations(List(6) { it }).size}")
}
Open in Kotlin Playground ↗
Pruning: stop early when a branch cannot work
Pruning means cutting off a branch of the decision tree as soon as you know it cannot lead to a valid answer. It does not change the worst-case Big-O, but it can remove almost all the work in practice. Choosing 2 numbers out of 1..4 (a combination, a subset of a fixed size) can stop as soon as the list reaches size 2.
The classic example is the n-queens puzzle: place n queens on an n by n chessboard so that none attacks another. We place one queen per row. For each row we try every column, but skip any square whose column or diagonal is already taken. The skipped squares are the pruning.
Run it: count the n-queens solutions
Without pruning, 8 queens would mean checking 8^8, about 16 million boards. With pruning the search is tiny.
fun countQueens(n: Int): Int {
val cols = BooleanArray(n)
val diagDown = BooleanArray(2 * n) // squares with the same row + col
val diagUp = BooleanArray(2 * n) // squares with the same row - col (shifted)
var count = 0
fun place(row: Int) {
if (row == n) { count++; return } // all rows filled: one solution
for (c in 0 until n) {
val d1 = row + c
val d2 = row - c + n
if (cols[c] || diagDown[d1] || diagUp[d2]) continue // prune: attacked square
cols[c] = true; diagDown[d1] = true; diagUp[d2] = true // choose
place(row + 1) // explore
cols[c] = false; diagDown[d1] = false; diagUp[d2] = false // un-choose
}
}
place(0)
return count
}
fun main() {
for (n in 1..8) println("n = $n -> ${countQueens(n)} solutions")
}
Open in Kotlin Playground ↗
| Problem | Number of answers | Typical cost |
|---|---|---|
Subsets of n items | 2^n | O(n * 2^n) |
Permutations of n items | n! | O(n * n!) |
Combinations, k of n | n! / (k! (n-k)!) | about that, times k |
| N-queens | depends on n | exponential, but pruning helps a lot |
Space. Apart from the saved answers, backtracking needs only the current state and the call stack, which is as deep as the number of decisions. That is why it fits in memory even when the number of answers does not.
4. Greedy: take the best choice now
A greedy algorithm makes the choice that looks best right now, never looks back, and never undoes it. There is no search and no recursion, usually just a sort and a loop, so greedy algorithms are fast and short.
The catch is that “best right now” is only correct for some problems. For the others, greedy gives a wrong answer without any warning. The skill is knowing which is which.
Coin change: greedy works, then it does not
Make 63 cents with the fewest coins. Take the biggest coin that fits, again and again. With coins 25, 10, 5, 1 that gives 25+25+10+1+1+1, six coins, and it is the best possible. Coin sets where this always works are called canonical. Most real currencies are designed that way.
Now change the coins to 1, 3, 4 and ask for 6. Greedy takes 4, then 1, then 1: three coins. But 3+3 is two coins. The locally best first step (the biggest coin) spoiled the whole answer.
Run it: greedy coin change and its counter-example
In the second line the two numbers disagree. One counter-example is enough to prove that greedy is wrong for a problem.
// Greedy: biggest coin first. Returns the coin count, or -1 if it gets stuck.
fun greedyCoins(coins: List<Int>, amount: Int): Int {
var left = amount
var count = 0
for (c in coins.sortedDescending()) {
count += left / c
left %= c
}
return if (left == 0) count else -1
}
// Exact answer by trying every coin for every amount (dynamic programming, explained soon).
fun bestCoins(coins: List<Int>, amount: Int): Int {
val inf = amount + 1
val dp = IntArray(amount + 1) { inf }
dp[0] = 0
for (a in 1..amount) for (c in coins) if (c <= a) dp[a] = minOf(dp[a], dp[a - c] + 1)
return if (dp[amount] >= inf) -1 else dp[amount]
}
fun main() {
val us = listOf(25, 10, 5, 1)
println("63 with $us: greedy=${greedyCoins(us, 63)} best=${bestCoins(us, 63)}")
val odd = listOf(1, 3, 4)
println("6 with $odd: greedy=${greedyCoins(odd, 6)} best=${bestCoins(odd, 6)}")
}
Open in Kotlin Playground ↗
Interval scheduling
You have a list of activities, each with a start and end time, and one room. Pick as many as possible so that none overlap. A tempting rule is “earliest start” or “shortest”, but both have counter-examples. The rule that is provably right: always pick the activity that ends earliest, because it leaves the most room for the rest. Sort by end time, then take each activity that starts after the last chosen one ends.
Run it: activity selection
Time O(n log n) for the sort, then one pass.
fun selectActivities(acts: List<Pair<Int, Int>>): List<Pair<Int, Int>> {
val chosen = mutableListOf<Pair<Int, Int>>()
var lastEnd = Int.MIN_VALUE
for (a in acts.sortedBy { it.second }) { // earliest end first
if (a.first >= lastEnd) { // starts after the last pick ended
chosen.add(a)
lastEnd = a.second
}
}
return chosen
}
fun main() {
val acts = listOf(1 to 4, 3 to 5, 0 to 6, 5 to 7, 3 to 9, 5 to 9, 6 to 10, 8 to 11)
val picked = selectActivities(acts)
println("picked ${picked.size}: $picked")
}
Open in Kotlin Playground ↗
Jump game
You stand at the start of an array. Each number says the longest jump you can make from that square. Can you reach the last square? Greedy view: walk left to right and track the farthest square reachable so far. If you ever stand on a square beyond that, you are stuck.
Run it: can you reach the end?
One pass, O(n) time, O(1) space. A brute-force search would try every jump length from every square.
fun canJump(nums: IntArray): Boolean {
var farthest = 0
for (i in nums.indices) {
if (i > farthest) return false // this square is out of reach
farthest = maxOf(farthest, i + nums[i]) // best landing spot seen so far
}
return true
}
fun main() {
println(canJump(intArrayOf(2, 3, 1, 1, 4))) // true
println(canJump(intArrayOf(3, 2, 1, 0, 4))) // false: every path ends on the 0
println(canJump(intArrayOf(0))) // true: already at the end
}
Open in Kotlin Playground ↗
- Ask: if I take the best option now, can it ever block a better total later? If you can argue “no” (the usual proof says any better answer can be changed to start with your greedy choice), use greedy.
- If you can find even one small counter-example by hand, like coins
1, 3, 4, greedy is out. Reach for dynamic programming or backtracking. - Always test a greedy idea against a slow, correct solution on small inputs before you trust it.
5. Dynamic programming: remember what you solved
Dynamic programming (DP) sounds mysterious, but the idea is plain: do not solve the same small problem twice. Solve it once, write the answer down, and look it up next time.
Homework you already did. If a teacher asks 12 x 12 ten times, you do not redo the sum each time. You remember 144. DP is a notebook of answers to small questions, used to answer bigger ones.
Two properties tell you DP can work:
- Overlapping subproblems. A subproblem is a smaller version of the same question. They overlap when the same subproblem is needed again and again by different bigger ones. (In divide and conquer they do not overlap.)
- Optimal substructure. The best answer to the big problem is built from the best answers to its subproblems.
The four steps
- Define the state. Say in one sentence what
dp[i](ordp[i][j]) means. For example, “the fewest coins that make amounti”. This sentence is the most important line you write. - Write the recurrence. A recurrence is a formula that gets one state from smaller states, for example
dp[i] = min(dp[i - coin] + 1)over all coins. - Set the base cases. The smallest states, whose answers you simply know:
dp[0] = 0. - Choose the order. Fill states so that everything a state depends on is already filled. Usually that means smaller indexes first. The answer is the state for the full problem.
There are two ways to carry out the plan. Memoisation (top-down) keeps the recursive function and adds a notebook: before computing, check it; after computing, save it. Tabulation (bottom-up) drops the recursion and fills a table from the smallest state to the largest with loops.
6. Fibonacci four ways
The Fibonacci numbers start 0, 1, 1, 2, 3, 5, 8, ... and each is the sum of the two before. The state is fib(n), the recurrence is fib(n) = fib(n-1) + fib(n-2), and the base cases are fib(0) = 0, fib(1) = 1. Here is what the naive recursion does for fib(5):
The number of calls roughly multiplies by 1.6 for every step of n, so naive Fibonacci is exponential. The program below runs four versions and counts the calls.
Run it: naive, memoised, tabulated, two variables
Compare the two call counts. The answer is the same, but the notebook turns millions of calls into about sixty.
var naiveCalls = 0L
var memoCalls = 0L
val memo = HashMap<Int, Long>()
// 1. Naive recursion: exponential time.
fun fibNaive(n: Int): Long {
naiveCalls++
return if (n < 2) n.toLong() else fibNaive(n - 1) + fibNaive(n - 2)
}
// 2. Top-down with a notebook (memoisation): O(n) time, O(n) space.
fun fibMemo(n: Int): Long {
memoCalls++
if (n < 2) return n.toLong()
memo[n]?.let { return it } // already solved: look it up
val answer = fibMemo(n - 1) + fibMemo(n - 2)
memo[n] = answer // write it in the notebook
return answer
}
// 3. Bottom-up table (tabulation): O(n) time, O(n) space.
fun fibTable(n: Int): Long {
if (n < 2) return n.toLong()
val dp = LongArray(n + 1)
dp[1] = 1
for (i in 2..n) dp[i] = dp[i - 1] + dp[i - 2]
return dp[n]
}
// 4. Keep only the last two values: O(n) time, O(1) space.
fun fibTwoVars(n: Int): Long {
var a = 0L
var b = 1L
repeat(n) { val next = a + b; a = b; b = next }
return a
}
fun main() {
val n = 30
println("naive : ${fibNaive(n)} calls = $naiveCalls")
println("memo : ${fibMemo(n)} calls = $memoCalls")
println("table : ${fibTable(n)}")
println("2 vars: ${fibTwoVars(n)}")
println("fib(90) = ${fibTwoVars(90)}")
}
Open in Kotlin Playground ↗
| Version | Time | Space |
|---|---|---|
| Naive recursion | exponential, about O(1.6^n) | O(n) call stack |
| Memoised | O(n) | O(n) |
| Tabulated | O(n) | O(n) |
| Two variables | O(n) | O(1) |
The last step is a habit worth learning: when dp[i] only needs the previous one or two entries, you do not have to keep the whole table. Keep a few variables.
7. Classic one-dimensional problems
A one-dimensional DP has a single index in its state. We will do three, each following the four steps.
Climbing stairs
You can climb 1 or 2 steps at a time. How many different ways reach step n?
- State:
ways[i]= the number of ways to reach stepi. - Recurrence: the last move was a 1-step from
i-1or a 2-step fromi-2, soways[i] = ways[i-1] + ways[i-2]. - Base:
ways[0] = 1(do nothing),ways[1] = 1. - Order:
ifrom 2 up ton.
It is Fibonacci in disguise. Spotting a familiar recurrence is a big part of the skill.
House robber
A row of houses holds some money each. You may not take from two neighbouring houses. What is the most you can take?
- State:
best[i]= the most money from the firstihouses. - Recurrence: for house
i, either skip it (best[i-1]) or take it and skip its neighbour (best[i-2] + money[i]). Choose the larger. - Base: no houses gives 0; one house gives its money.
- Order: left to right. Only the last two values matter, so two variables are enough.
Coin change, fewest coins
The same coin problem greedy got wrong, now solved properly. State: fewest[a] = the fewest coins to make amount a. Recurrence: try each coin c as the last coin: fewest[a] = 1 + min(fewest[a - c]). Base: fewest[0] = 0. Order: amounts from 1 up to the target. Amounts that no coin combination can make stay at “infinity” and we report -1.
Run it: stairs, robber and coin change
Change the numbers, and try writing the state sentence for each function before reading the code.
// Climbing stairs: ways[i] = ways[i-1] + ways[i-2]
fun climbStairs(n: Int): Long {
var prev = 1L // ways[0]
var cur = 1L // ways[1]
for (i in 2..n) { val next = prev + cur; prev = cur; cur = next }
return cur
}
// House robber: best[i] = max(best[i-1], best[i-2] + money[i])
fun rob(money: IntArray): Int {
var twoBack = 0 // best for the houses up to i-2
var oneBack = 0 // best for the houses up to i-1
for (m in money) {
val best = maxOf(oneBack, twoBack + m)
twoBack = oneBack
oneBack = best
}
return oneBack
}
// Coin change: fewest[a] = 1 + min over coins of fewest[a - coin]
fun coinChange(coins: IntArray, amount: Int): Int {
if (amount < 0) return -1
val inf = amount + 1 // bigger than any real answer
val fewest = IntArray(amount + 1) { inf }
fewest[0] = 0
for (a in 1..amount) {
for (c in coins) {
if (c <= a && fewest[a - c] + 1 < fewest[a]) fewest[a] = fewest[a - c] + 1
}
}
return if (fewest[amount] >= inf) -1 else fewest[amount]
}
fun main() {
println("stairs(5) = ${climbStairs(5)}, stairs(50) = ${climbStairs(50)}")
println("rob = ${rob(intArrayOf(2, 7, 9, 3, 1))}") // 2 + 9 + 1 = 12
println("coins 1,3,4 for 6 = ${coinChange(intArrayOf(1, 3, 4), 6)}") // 2
println("coins 2 for 3 = ${coinChange(intArrayOf(2), 3)}") // -1, impossible
println("coins 5 for 0 = ${coinChange(intArrayOf(5), 0)}") // 0
}
Open in Kotlin Playground ↗
| Problem | Time | Space |
|---|---|---|
| Climbing stairs | O(n) | O(1) |
| House robber | O(n) | O(1) |
| Coin change | O(amount * coins) | O(amount) |
8. Two-dimensional problems and the filled table
When the state needs two numbers, the table has two dimensions. A common case is two sequences, where i is how much of the first you have used and j how much of the second.
Grid paths
A robot starts at the top-left of a grid and can only move right or down. How many routes reach the bottom-right? State: paths[r][c] = routes to square (r, c). Recurrence: you arrived from above or from the left, so paths[r][c] = paths[r-1][c] + paths[r][c-1]. Base: the first row and first column are all 1. Order: row by row.
Longest common subsequence
A subsequence keeps letters in order but may skip some, like ACD from ABCD. The longest common subsequence (LCS) of two strings is the longest one that appears in both. It is the idea behind file-comparison tools.
- State:
dp[i][j]= the LCS length of the firstiletters ofsand the firstjletters oft. - Recurrence: if the letters
s[i-1]andt[j-1]match, extend the diagonal:dp[i-1][j-1] + 1. Otherwise take the better of dropping a letter from either string:max(dp[i-1][j], dp[i][j-1]). - Base: with an empty string the answer is 0, so row 0 and column 0 are zeros.
- Order: rows top to bottom, columns left to right.
Run it: grid paths and longest common subsequence
The printed table matches the diagram above. Time and space are both O(rows * cols).
fun gridPaths(rows: Int, cols: Int): Long {
val paths = Array(rows) { LongArray(cols) }
for (r in 0 until rows) for (c in 0 until cols) {
paths[r][c] = if (r == 0 || c == 0) 1 else paths[r - 1][c] + paths[r][c - 1]
}
return paths[rows - 1][cols - 1]
}
fun lcsTable(s: String, t: String): Array<IntArray> {
val dp = Array(s.length + 1) { IntArray(t.length + 1) } // row 0 and column 0 stay 0
for (i in 1..s.length) for (j in 1..t.length) {
dp[i][j] = if (s[i - 1] == t[j - 1]) dp[i - 1][j - 1] + 1
else maxOf(dp[i - 1][j], dp[i][j - 1])
}
return dp
}
fun main() {
println("grid 3x4 -> ${gridPaths(3, 4)}")
println("grid 18x18 -> ${gridPaths(18, 18)}")
val s = "ABCD"
val t = "ACBD"
val dp = lcsTable(s, t)
for (row in dp) println(row.joinToString(" "))
println("LCS length = ${dp[s.length][t.length]}")
}
Open in Kotlin Playground ↗
Edit distance
The edit distance between two words is the fewest single-letter edits (insert, delete or replace) that turn one into the other. Spell checkers use it to suggest corrections. The shape is the same as LCS. State: dp[i][j] = the edits to turn the first i letters of a into the first j of b. Base: turning a word into an empty one costs its length (delete everything), and the reverse costs the other length (insert everything). Recurrence: if the letters match, copy the diagonal for free. Otherwise it is one edit plus the cheapest of three neighbours: diagonal (replace), above (delete), left (insert).
Run it: how far apart are two words?
fun editDistance(a: String, b: String): Int {
val dp = Array(a.length + 1) { IntArray(b.length + 1) }
for (i in 0..a.length) dp[i][0] = i // delete i letters
for (j in 0..b.length) dp[0][j] = j // insert j letters
for (i in 1..a.length) for (j in 1..b.length) {
dp[i][j] = if (a[i - 1] == b[j - 1]) {
dp[i - 1][j - 1] // match: free
} else {
1 + minOf(dp[i - 1][j - 1], dp[i - 1][j], dp[i][j - 1]) // replace, delete, insert
}
}
return dp[a.length][b.length]
}
fun main() {
println(editDistance("kitten", "sitting")) // 3
println(editDistance("", "abc")) // 3
println(editDistance("same", "same")) // 0
println(editDistance("horse", "ros")) // 3
}
Open in Kotlin Playground ↗
Saving space. Row i of these tables only reads row i-1, so you can keep just two rows and use O(columns) space. You give up the ability to rebuild which letters were chosen, so keep the whole table if you need that.
9. Top-down or bottom-up?
Both styles give the same answers and the same Big-O in most problems. They differ in how you write them and what they cost in practice.
| Top-down (memoisation) | Bottom-up (tabulation) | |
|---|---|---|
| Shape | A recursive function plus a cache | Loops filling an array |
| Order | The computer works it out from the calls | You must choose it yourself |
| Work done | Only the states that are really needed | Usually every state in the table |
| Risk | Deep recursion can overflow the stack | None, but you must get the loop order right |
| Space saving | Hard | Easy: keep only the last row or two values |
| Easiest to | Write first, straight from the recurrence | Make fast and small afterwards |
A practical path: write the recurrence as plain recursion, add a cache to see it work (top-down), then convert to loops and shrink the memory (bottom-up). You do not have to pick one for life.
Run it: the same problem, both ways
Both print the same numbers. Only the way of getting there differs.
// Count the ways to climb n stairs taking 1, 2 or 3 steps at a time.
// Top-down: recursion plus a cache.
fun waysTopDown(n: Int, cache: HashMap<Int, Long> = HashMap()): Long {
if (n < 0) return 0
if (n == 0) return 1
cache[n]?.let { return it }
val result = waysTopDown(n - 1, cache) + waysTopDown(n - 2, cache) + waysTopDown(n - 3, cache)
cache[n] = result
return result
}
// Bottom-up: a table filled from the smallest case.
fun waysBottomUp(n: Int): Long {
val ways = LongArray(n + 1)
ways[0] = 1
for (i in 1..n) {
for (step in 1..3) if (i - step >= 0) ways[i] += ways[i - step]
}
return ways[n]
}
fun main() {
for (n in listOf(0, 1, 3, 10, 30)) {
println("n=$n top-down=${waysTopDown(n)} bottom-up=${waysBottomUp(n)}")
}
}
Open in Kotlin Playground ↗
10. Choosing the right tool
You now have four ways of attacking a problem that has many possible answers. Use this table to decide, and then confirm on a small example.
| Approach | Use it when | Signs in the problem | Typical cost |
|---|---|---|---|
| Brute force | The input is tiny, or you need a trusted answer to test against | “try every…” with small n | Often exponential |
| Backtracking | You must list or find answers built from a series of choices, and some choices can be ruled out early | “all subsets / arrangements / placements”, constraints such as “no two attack” | Exponential, shrunk by pruning |
| Greedy | A locally best step is provably safe | Sorting then one pass; “earliest end”, “farthest reach”; you cannot find a counter-example | Usually O(n log n) or O(n) |
| Dynamic programming | The best answer is built from smaller best answers, and the same subproblems repeat | “fewest / most / how many ways”; recursion that recomputes the same inputs; two sequences | Number of states times work per state |
A short routine for a new problem:
- Write the brute-force recursion. Is it correct on small inputs?
- Draw its call tree. Do the same arguments appear more than once? If so, add a cache (DP).
- If you need the actual list of answers rather than a count or a best value, you are probably doing backtracking, and the answers themselves set the cost.
- If sorting the input makes one simple rule work, test it against the brute force. If it never disagrees on many small cases, greedy may be right.
Counting versus listing. “How many subsets add up to 10?” can be answered by DP in polynomial time. “List all subsets that add up to 10” must produce each one, so it is backtracking. The words “how many” and “list” point to different tools.
11. Practice
Six exercises, one for each idea in this part. Start with the state sentence on paper for the DP ones, and test small cases by hand first.
Try it: fast power
Write power(base, exp) with recursion that halves the exponent, so it needs only about log exp calls. Assume exp >= 0. Open the starter in the Kotlin Playground, press Run, edit the code, and run again until every line says PASS.
fun power(base: Long, exp: Int): Long {
TODO("base case first, then use the half")
}
Open in Kotlin Playground ↗
Hint: Compute half = power(base, exp / 2) once, not twice. Square it, and multiply by base once more when exp is odd.
Show a solution
fun power(base: Long, exp: Int): Long {
if (exp == 0) return 1
val half = power(base, exp / 2)
return if (exp % 2 == 0) half * half else half * half * base
}
Try it: combinations by backtracking
Write combinations(n, k): every way to choose k numbers from 1..n, each combination in increasing order, and the list in dictionary order. Use choose, explore, un-choose. Open the starter in the Kotlin Playground, press Run, edit the code, and run again until every line says PASS.
fun combinations(n: Int, k: Int): List<List<Int>> {
TODO("backtrack with a current list and a start number")
}
Open in Kotlin Playground ↗
Hint: Stop when current.size == k and save a copy. In the loop, go from start to n and recurse with i + 1.
Show a solution
fun combinations(n: Int, k: Int): List<List<Int>> {
val result = mutableListOf<List<Int>>()
val current = mutableListOf<Int>()
fun explore(start: Int) {
if (current.size == k) {
result.add(current.toList())
return
}
for (i in start..n) {
current.add(i)
explore(i + 1)
current.removeAt(current.size - 1)
}
}
explore(1)
return result
}
Try it: jump game (greedy)
Each number in nums is the longest jump from that square. Write canJump that says whether you can reach the last square starting from the first. Track the farthest reachable square in one pass. Open the starter in the Kotlin Playground, press Run, edit the code, and run again until every line says PASS.
fun canJump(nums: IntArray): Boolean {
TODO("walk left to right, track the farthest square")
}
Open in Kotlin Playground ↗
Hint: If the current index is greater than farthest, return false. Otherwise update farthest with i + nums[i].
Show a solution
fun canJump(nums: IntArray): Boolean {
var farthest = 0
for (i in nums.indices) {
if (i > farthest) return false
farthest = maxOf(farthest, i + nums[i])
}
return true
}
Try it: house robber (1D DP)
Write rob(money): the largest total you can take from a row of houses without taking from two neighbours. Remember the empty row. Open the starter in the Kotlin Playground, press Run, edit the code, and run again until every line says PASS.
fun rob(money: IntArray): Int {
TODO("best so far = max(skip this house, take it plus best two back)")
}
Open in Kotlin Playground ↗
Hint: Keep two variables: the best total up to the previous house, and up to the one before that. For each house the new best is maxOf(oneBack, twoBack + m).
Show a solution
fun rob(money: IntArray): Int {
var twoBack = 0
var oneBack = 0
for (m in money) {
val best = maxOf(oneBack, twoBack + m)
twoBack = oneBack
oneBack = best
}
return oneBack
}
Try it: fewest coins (1D DP)
Write minCoins(coins, amount): the fewest coins that add up to amount, or -1 if it is impossible. A negative amount is impossible, and zero needs no coins. Greedy fails for some coin sets, so use a table. Open the starter in the Kotlin Playground, press Run, edit the code, and run again until every line says PASS.
fun minCoins(coins: IntArray, amount: Int): Int {
TODO("fewest[a] = 1 + min(fewest[a - coin])")
}
Open in Kotlin Playground ↗
Hint: Fill the table with a value bigger than any answer (like amount + 1), set fewest[0] = 0, then check at the end whether the target is still that big value.
Show a solution
fun minCoins(coins: IntArray, amount: Int): Int {
if (amount < 0) return -1
val inf = amount + 1
val fewest = IntArray(amount + 1) { inf }
fewest[0] = 0
for (a in 1..amount) {
for (c in coins) {
if (c <= a && fewest[a - c] + 1 < fewest[a]) fewest[a] = fewest[a - c] + 1
}
}
return if (fewest[amount] >= inf) -1 else fewest[amount]
}
Try it: longest common subsequence (2D DP)
Write lcsLength(s, t): the length of the longest subsequence that appears in both strings. Build the two-dimensional table from the diagram. Open the starter in the Kotlin Playground, press Run, edit the code, and run again until every line says PASS.
fun lcsLength(s: String, t: String): Int {
TODO("dp[i][j] for the first i letters of s and first j of t")
}
Open in Kotlin Playground ↗
Hint: Row 0 and column 0 stay zero. On a match use dp[i-1][j-1] + 1, otherwise the larger of the cell above and the cell to the left.
Show a solution
fun lcsLength(s: String, t: String): Int {
val dp = Array(s.length + 1) { IntArray(t.length + 1) }
for (i in 1..s.length) for (j in 1..t.length) {
dp[i][j] = if (s[i - 1] == t[j - 1]) dp[i - 1][j - 1] + 1
else maxOf(dp[i - 1][j], dp[i][j - 1])
}
return dp[s.length][t.length]
}
A good habit. After each DP solution, change the numbers in the tests and predict the output before you run it. If your prediction is wrong, that is exactly where the learning is.
12. Takeaways
- Write the base case first, and make sure each call moves toward it.
- Recursion uses call stack space; very deep recursion should become a loop.
- Backtracking: choose, explore, un-choose, and save a copy of each answer.
- Greedy is fast but must be justified; test it against a slow, correct solution.
- For DP, write the meaning of the state in one sentence before any code.
- If a recursion repeats the same arguments, add a cache. If only the last row or two values are needed, drop the full table.
- Next up: Part 8 collects the reusable programming patterns, such as two pointers and sliding windows.
| Term | In plain words |
|---|---|
| Recursion | A function that solves a problem by calling itself on a smaller piece of it. |
| Base case | The input so small that the answer is known without another call. |
| Recursive case | The part that calls the function again on a smaller input and combines the result. |
| Call stack | The pile of frames, one per active function call, that remembers where to continue. |
| Stack overflow | The error when recursion goes deeper than the call stack can hold. |
| Tail call | A recursive call that is the last thing a function does. |
| Divide and conquer | Split into non-overlapping pieces, solve each, combine the answers. |
| Backtracking | Building an answer step by step and undoing a step when it leads nowhere. |
| State | The information that describes where you are in a search or a table. |
| Pruning | Skipping a branch of the search as soon as it cannot give a valid answer. |
| Greedy algorithm | An algorithm that always takes the best-looking choice now and never undoes it. |
| Subproblem | A smaller version of the same problem. |
| Overlapping subproblems | The same subproblem is needed many times by different bigger problems. |
| Optimal substructure | The best answer to the whole is made of the best answers to its parts. |
| Memoisation | Top-down DP: keep the recursion and cache each result. |
| Tabulation | Bottom-up DP: fill a table with loops from the smallest case upward. |
| Recurrence | A formula that gives one state from smaller states. |