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

Data Structures and Algorithms, Part 7: Recursion, Backtracking, Greedy Choices and Dynamic Programming

Khairul Alam·October 2026·~45 min with exercises
RecursionBacktrackingGreedyDynamic programmingKotlinFor beginners

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.

This part in 60 seconds
  • 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
  1. Recursion: a function that calls itself
  2. Divide and conquer, a short recap
  3. Backtracking: try, look ahead, undo
  4. Greedy: take the best choice now
  5. Dynamic programming: remember what you solved
  6. Fibonacci four ways
  7. Classic one-dimensional problems
  8. Two-dimensional problems and the filled table
  9. Top-down or bottom-up?
  10. Choosing the right tool
  11. Practice
  12. 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:

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.)

factorial(3) is running. Time moves left to right.call factorial(3)n=3 waitscall factorial(2)n=3 waitsn=2 waitscall factorial(1)n=3 waitsn=2 waitsn=1 basebottom of stackreturns...top: returns 1then n=2 gets 1,returns 2*1 = 2then n=3 gets 2,returns 3*2 = 6
Each call adds a frame. The base case is the first one to finish, and the answers flow back down.

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 ↗
TimeExtra space
Factorial / sum (n calls deep)O(n)O(n) for the call stack
Fast powerO(log e)O(log e) for the call stack
Same job as a loopsameO(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.

Subsets of [1, 2, 3]: at each item decide "take it" or "skip it".take 1skip 1take 2skip 2take 2skip 2take 3skip 3take 3skip 3take 3skip 3take 3skip 3[][1][][1,2][1][2][][1,2,3][1,2][1,3][1][2,3][2][3][]8 leaves = 2^3 subsets. The tree has 2^n leaves, so the cost is exponential.
A decision tree. Backtracking walks this tree depth first, going down and coming back up.

The template: choose, explore, un-choose

Nearly every backtracking solution has the same three steps inside a loop:

  1. Choose: add one option to the current state.
  2. Explore: recurse to make the next decision.
  3. 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 ↗
ProblemNumber of answersTypical cost
Subsets of n items2^nO(n * 2^n)
Permutations of n itemsn!O(n * n!)
Combinations, k of nn! / (k! (n-k)!)about that, times k
N-queensdepends on nexponential, 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 ↗
How to tell greedy from dynamic programming
  • 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:

The four steps

  1. Define the state. Say in one sentence what dp[i] (or dp[i][j]) means. For example, “the fewest coins that make amount i”. This sentence is the most important line you write.
  2. 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.
  3. Set the base cases. The smallest states, whose answers you simply know: dp[0] = 0.
  4. 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):

fib(1)fib(0)fib(2)fib(1)fib(3)fib(1)fib(0)fib(2)fib(4)fib(1)fib(0)fib(2)fib(1)fib(3)fib(5)fib(3) is computed twicefib(2) three timesThat is the overlap.
The call tree of naive Fibonacci. The same subproblems appear again and again.

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 ↗
VersionTimeSpace
Naive recursionexponential, about O(1.6^n)O(n) call stack
MemoisedO(n)O(n)
TabulatedO(n)O(n)
Two variablesO(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?

  1. State: ways[i] = the number of ways to reach step i.
  2. Recurrence: the last move was a 1-step from i-1 or a 2-step from i-2, so ways[i] = ways[i-1] + ways[i-2].
  3. Base: ways[0] = 1 (do nothing), ways[1] = 1.
  4. Order: i from 2 up to n.

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?

  1. State: best[i] = the most money from the first i houses.
  2. 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.
  3. Base: no houses gives 0; one house gives its money.
  4. 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 ↗
ProblemTimeSpace
Climbing stairsO(n)O(1)
House robberO(n)O(1)
Coin changeO(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.

3 rows x 4 columns. Each cell = cell above + cell to the left.col0col1col2col3row01111row11234row213610answer: 10 routesbase: first row and columnabove + left
The filled table for grid paths. Every number comes from its two neighbours.

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.

  1. State: dp[i][j] = the LCS length of the first i letters of s and the first j letters of t.
  2. Recurrence: if the letters s[i-1] and t[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]).
  3. Base: with an empty string the answer is 0, so row 0 and column 0 are zeros.
  4. Order: rows top to bottom, columns left to right.
s = "ABCD" (down), t = "ACBD" (across)""ACBD""ABCD0000001111011220122201223The cell for (C, C) = diagonal 1 + 1 = 2,because the letters match.The cell for (B, C) = max(above 1, left 1) = 1,because they do not.Bottom-right: LCS length 3("ACD" or "ABD")
A filled LCS table. The answer is the bottom-right corner.

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)
ShapeA recursive function plus a cacheLoops filling an array
OrderThe computer works it out from the callsYou must choose it yourself
Work doneOnly the states that are really neededUsually every state in the table
RiskDeep recursion can overflow the stackNone, but you must get the loop order right
Space savingHardEasy: keep only the last row or two values
Easiest toWrite first, straight from the recurrenceMake 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.

ApproachUse it whenSigns in the problemTypical cost
Brute forceThe input is tiny, or you need a trusted answer to test against“try every…” with small nOften exponential
BacktrackingYou 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
GreedyA locally best step is provably safeSorting then one pass; “earliest end”, “farthest reach”; you cannot find a counter-exampleUsually O(n log n) or O(n)
Dynamic programmingThe 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 sequencesNumber of states times work per state

A short routine for a new problem:

  1. Write the brute-force recursion. Is it correct on small inputs?
  2. Draw its call tree. Do the same arguments appear more than once? If so, add a cache (DP).
  3. 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.
  4. 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

TermIn plain words
RecursionA function that solves a problem by calling itself on a smaller piece of it.
Base caseThe input so small that the answer is known without another call.
Recursive caseThe part that calls the function again on a smaller input and combines the result.
Call stackThe pile of frames, one per active function call, that remembers where to continue.
Stack overflowThe error when recursion goes deeper than the call stack can hold.
Tail callA recursive call that is the last thing a function does.
Divide and conquerSplit into non-overlapping pieces, solve each, combine the answers.
BacktrackingBuilding an answer step by step and undoing a step when it leads nowhere.
StateThe information that describes where you are in a search or a table.
PruningSkipping a branch of the search as soon as it cannot give a valid answer.
Greedy algorithmAn algorithm that always takes the best-looking choice now and never undoes it.
SubproblemA smaller version of the same problem.
Overlapping subproblemsThe same subproblem is needed many times by different bigger problems.
Optimal substructureThe best answer to the whole is made of the best answers to its parts.
MemoisationTop-down DP: keep the recursion and cache each result.
TabulationBottom-up DP: fill a table with loops from the smallest case upward.
RecurrenceA formula that gives one state from smaller states.
← Back to all writing