Recursion & Backtracking — The Complete Visual Reference

Recursion fundamentals, call stack mechanics, tail recursion, divide-and-conquer, memoization, backtracking templates — 20+ algorithm patterns
What Is Recursion?
factorial(5) └─ 5 × factorial(4) └─ 4 × factorial(3) └─ 3 × factorial(2) └─ 2 × factorial(1) └─ 1 ← base case!
Real-World AnalogyRecursion Pattern
Russian dollsOpen doll → find smaller doll → repeat
Browser back buttonEach page remembers the previous one
Undo stackEach action can be unwound in reverse order
The Call Stack & Memory
GROWING (push frames) UNWINDING (pop frames) ┌──────────────────┐ │ factorial(1)=1 │ ─────→ return 1 ├──────────────────┤ │ factorial(2) │ ─────→ return 2×1 = 2 ├──────────────────┤ │ factorial(3) │ ─────→ return 3×2 = 6 ├──────────────────┤ │ factorial(4) │ ─────→ return 4×6 = 24 └──────────────────┘
Stack Frame Memory Each frame: ~32–128 bytes Max depth: ~5,000–15,000 Deeper → StackOverflowError
Anatomy of a Recursive Function
fun solve(problem: Problem): Result { // 1. Base case — smallest subproblem if (problem.isSimple()) return trivialAnswer // 2. Recursive case — break down val sub = solve(problem.smaller()) // 3. Combine — build answer return combine(problem, sub) }
// Sum of array — traced step by step fun sum(arr: IntArray, i: Int = 0): Int { if (i == arr.size) return 0 // base return arr[i] + sum(arr, i + 1) // recurse + combine } // sum([3,1,4]) → 3 + sum([1,4]) → 3 + 1 + sum([4]) → 3 + 1 + 4 + 0 = 8
Recursion vs Iteration
// Recursive fun factRec(n: Int): Long = if (n <= 1) 1 else n * factRec(n - 1) // Iterative fun factIter(n: Int): Long { var result = 1L for (i in 2..n) result *= i return result }
CriterionRecursionIteration
ClarityMore intuitive for trees/graphsSimpler for linear tasks
SpaceO(n) stack framesO(1)
Stack overflowRisk for deep inputNo risk
PerformanceFunction call overheadSlightly faster
BacktrackingNatural fitRequires explicit stack
Tail Recursion & tailrec
// NON-tail: multiplication happens AFTER recursive call fun fact(n: Int): Long = if (n <= 1) 1 else n * fact(n - 1) // × after call! // TAIL: recursive call IS the last operation tailrec fun factTR(n: Int, acc: Long = 1): Long = if (n <= 1) acc else factTR(n - 1, n * acc)
// Kotlin tailrec examples — compile to loops! tailrec fun gcd(a: Int, b: Int): Int = if (b == 0) a else gcd(b, a % b) tailrec fun sumTR(arr: IntArray, i: Int = 0, acc: Int = 0): Int = if (i == arr.size) acc else sumTR(arr, i + 1, acc + arr[i]) tailrec fun binSearch( arr: IntArray, t: Int, lo: Int = 0, hi: Int = arr.size - 1 ): Int { if (lo > hi) return -1 val mid = (lo + hi) / 2 return when { arr[mid] == t -> mid arr[mid] < t -> binSearch(arr, t, mid + 1, hi) else -> binSearch(arr, t, lo, mid - 1) } }
Tree Traversals → recursion's natural home (see Trees page)  |  Recursive Reversal → linked list reversal via recursion (see Linked Lists page)  |  Memoization → top-down DP is recursion + caching (see DP page)
📊
Factorial, Fibonacci, Power
O(n) / O(2^n) / O(log n)
// Factorial — O(n) fun factorial(n: Int): Long = if (n <= 1) 1 else n * factorial(n - 1) // Fibonacci naive — O(2^n) TIME! fun fibNaive(n: Int): Int = if (n <= 1) n else fibNaive(n - 1) + fibNaive(n - 2) // Fibonacci memoized — O(n) fun fibMemo(n: Int, memo: MutableMap<Int, Int> = mutableMapOf()): Int = memo.getOrPut(n) { if (n <= 1) n else fibMemo(n - 1, memo) + fibMemo(n - 2, memo) } // Fibonacci tail-recursive — O(1) space tailrec fun fibTR(n: Int, a: Int = 0, b: Int = 1): Int = if (n == 0) a else fibTR(n - 1, b, a + b)
// Fast Power — O(log n) binary exponentiation fun fastPow(base: Long, exp: Int, mod: Long = 1_000_000_007): Long { if (exp == 0) return 1 val half = fastPow(base, exp / 2, mod) return if (exp % 2 == 0) half * half % mod else half * half % mod * base % mod }
When to Use
factorial call stackWhy: n! = n × (n-1)!. Direct recursion with base case n≤1. Foundation for permutation counting. fibonacci naiveWhy: Two-branch recursion: fib(n) = fib(n-1) + fib(n-2). MUST memoize — naive is O(2^n). fast power (binary exponentiation)Why: Fast power: x^n = (x^(n/2))². Halves exponent → O(log n). Used in cryptography, competitive programming. tower of hanoiWhy: Move n-1 to aux, move largest to target, move n-1 from aux to target. 2^n - 1 moves. Classic recursion demo.
▶ Visualize
🔠
Recursion on Strings
O(n) / O(2^n) O(n)
fun reverseString(s: String): String = if (s.isEmpty()) s else reverseString(s.substring(1)) + s[0] fun isPalindrome(s: String, l: Int = 0, r: Int = s.lastIndex): Boolean = l >= r || (s[l] == s[r] && isPalindrome(s, l + 1, r - 1)) // Generate all subsequences — O(2^n) fun subsequences(s: String, i: Int = 0, cur: String = ""): List<String> { if (i == s.length) return listOf(cur) return subsequences(s, i + 1, cur) + // exclude subsequences(s, i + 1, cur + s[i]) // include }
When to Use
reverse stringWhy: reverse(s) = reverse(s[1..]) + s[0]. Base case: empty or single char. O(n) calls, O(n) stack. palindrome checkWhy: Compare s[left] with s[right], recurse inward. Base case: left ≥ right → true. Converging recursion. generate subsequencesWhy: At each char: include it or exclude it. Two branches → 2^n total subsequences. Classic include/exclude recursion. string permutationsWhy: Fix each char at position 0, recursively permute the rest. n! results.
▶ Visualize
Recursion on Linked Lists
O(n) O(n) stack
fun reverseList(head: ListNode?): ListNode? { if (head?.next == null) return head val newHead = reverseList(head.next) head.next!!.next = head head.next = null return newHead } fun mergeTwoLists(l1: ListNode?, l2: ListNode?): ListNode? { if (l1 == null) return l2 if (l2 == null) return l1 return if (l1.`val` <= l2.`val`) { l1.next = mergeTwoLists(l1.next, l2); l1 } else { l2.next = mergeTwoLists(l1, l2.next); l2 } } fun removeElements(head: ListNode?, v: Int): ListNode? { if (head == null) return null head.next = removeElements(head.next, v) return if (head.`val` == v) head.next else head }
When to Use
reverse linked listWhy: Recurse to tail, then rewire: head.next.next = head, head.next = null. Builds reversed list on unwind. O(n) stack. merge two sorted listsWhy: Pick smaller head, recurse on remaining. Base case: either list null. Natural recursive structure. remove elements by valueWhy: Recurse to end, on return: skip matching nodes. head.next = removeElements(head.next, val). palindrome linked listWhy: Recurse to end, compare with front pointer on unwind. Front advances forward, recursion unwinds backward.
▶ Visualize
Recursion on Trees
O(n) O(h)
fun maxDepth(root: TreeNode?): Int = if (root == null) 0 else 1 + maxOf(maxDepth(root.left), maxDepth(root.right)) fun countNodes(root: TreeNode?): Int = if (root == null) 0 else 1 + countNodes(root.left) + countNodes(root.right) fun isSameTree(p: TreeNode?, q: TreeNode?): Boolean = (p == null && q == null) || (p != null && q != null && p.`val` == q.`val` && isSameTree(p.left, q.left) && isSameTree(p.right, q.right)) fun lca(root: TreeNode?, p: TreeNode, q: TreeNode): TreeNode? { if (root == null || root == p || root == q) return root val left = lca(root.left, p, q) val right = lca(root.right, p, q) return if (left != null && right != null) root else left ?: right }
Pattern 1: Return bottom-up Pattern 2: Pass top-down maxDepth(node) isValid(node, min, max) ↑ return max(L,R)+1 ↓ pass bounds down / \ / \ L R L R
When to Use
max depth / heightWhy: Bottom-up: max(left, right) + 1. The simplest tree recursion. Base case: null → 0. count nodes / sum valuesWhy: 1 + count(left) + count(right). Same bottom-up pattern, different combine operation. same tree / symmetricWhy: Compare root values, then recurse on both subtrees. Two-tree recursion. lca / bst validationWhy: LCA: bottom-up (check both subtrees). BST valid: top-down (pass min/max bounds). Two fundamental patterns. tree traversalsWhy: Inorder/preorder/postorder = when you process root relative to children. Recursion makes this trivial.
▶ Visualize
Divide and Conquer
O(n log n) O(n)
// Merge Sort — canonical D&C fun mergeSort(arr: IntArray): IntArray { if (arr.size <= 1) return arr val mid = arr.size / 2 val left = mergeSort(arr.sliceArray(0 until mid)) val right = mergeSort(arr.sliceArray(mid until arr.size)) return merge(left, right) } // Count Inversions — merge sort variant fun countInversions(arr: IntArray): Long { if (arr.size <= 1) return 0 val mid = arr.size / 2 val left = arr.sliceArray(0 until mid) val right = arr.sliceArray(mid until arr.size) var count = countInversions(left) + countInversions(right) // count split inversions during merge var i = 0; var j = 0 for (k in arr.indices) { if (j >= right.size || (i < left.size && left[i] <= right[j])) arr[k] = left[i++] else { arr[k] = right[j++]; count += left.size - i } } return count }
When to Use
merge sortWhy: Split in half, sort each, merge. O(n log n) guaranteed. The canonical divide-and-conquer algorithm. quick sort (d&c)Why: Partition around pivot, recurse on each side. O(n log n) average. In-place but O(n²) worst case. count inversionsWhy: Modified merge sort: count cross-inversions during merge step. O(n log n) — much better than O(n²) brute force. closest pair of pointsWhy: Divide points by x-coordinate, solve each half, check strip. O(n log n). maximum subarray (d&c)Why: Max subarray is in left half, right half, or crosses the midpoint. Combine by checking crossing sum.
▶ Visualize
💾
Memoization — Top-Down DP
O(2^n) → O(n) O(n) cache
// Memoization template fun solve(state: State, memo: MutableMap<State, Result>): Result { memo[state]?.let { return it } // 1. check cache if (isBase(state)) return baseVal // 2. base case val result = /* recurse */ // 3. recurse memo[state] = result // 4. cache result return result }
// Fibonacci with memo fun fib(n: Int, m: MutableMap<Int,Int> = mutableMapOf()): Int = m.getOrPut(n) { if (n <= 1) n else fib(n-1,m) + fib(n-2,m) } // Climb Stairs — ways to climb n steps (1 or 2 at a time) fun climbStairs(n: Int, m: MutableMap<Int,Int> = mutableMapOf()): Int = m.getOrPut(n) { if (n <= 1) 1 else climbStairs(n-1,m) + climbStairs(n-2,m) } // Coin Change — min coins to make amount fun coinChange(coins: IntArray, amt: Int, m: MutableMap<Int,Int> = mutableMapOf()): Int = m.getOrPut(amt) { if (amt == 0) 0 else if (amt < 0) Int.MAX_VALUE else coins.minOf { coinChange(coins, amt - it, m) } .let { if (it == Int.MAX_VALUE) it else it + 1 } } // LCS — Longest Common Subsequence fun lcs(a: String, b: String, i: Int = 0, j: Int = 0, m: MutableMap<Pair<Int,Int>,Int> = mutableMapOf()): Int = m.getOrPut(i to j) { if (i == a.length || j == b.length) 0 else if (a[i] == b[j]) 1 + lcs(a, b, i+1, j+1, m) else maxOf(lcs(a, b, i+1, j, m), lcs(a, b, i, j+1, m)) }
AspectMemoization (Top-Down)Tabulation (Bottom-Up)
DirectionStarts from original problemStarts from base cases
ImplementationRecursion + cacheLoops + table
Subproblems solvedOnly needed onesAll subproblems
Space optimizationHarderEasier (rolling array)
Stack overflowPossible for deep recursionNo risk
When to Use
climb stairsWhy: Same subproblems computed many times. Cache fib(k) → each computed once. O(2^n) → O(n). coin change (min coins)Why: memo[amount] = min coins. Try each coin, recurse on amount-coin. Without memo → exponential. longest common subsequenceWhy: memo[i][j] = LCS of s1[i..] and s2[j..]. Two indices → 2D cache. O(m×n) with memo. any recursive solution with repeatsWhy: If the same arguments appear multiple times in the recursion tree → add cache. Universal optimization.
▶ Visualize
Backtracking Fundamentals
Decision Tree — Choose → Explore → Unchoose [ ] / | \ [1] [2] [3] ← choose / \ | ← explore deeper [1,2] [1,3] [2,3] | | | [1,2,3] ... ... ← record / backtrack ← unchoose (remove last)
ApproachBranches ExploredWhen
Brute forceAll possibleNo constraints to prune
BacktrackingOnly valid branchesCan detect dead ends early
📝
The Backtracking Templates
Varies by problem O(n) depth
// Template 1 — Collect ALL solutions fun backtrack(state: State, choices: List, result: MutableList<List>) { if (isGoal(state)) { result.add(state.toList()); return } for (choice in choices) { if (!isValid(choice)) continue // prune state.add(choice) // choose backtrack(state, nextChoices) // explore state.removeLast() // unchoose } }
// Template 2 — Find ANY solution (return true/false) fun backtrack(state: State): Boolean { if (isGoal(state)) return true for (choice in choices) { if (!isValid(choice)) continue state.add(choice) if (backtrack(state)) return true // found! state.removeLast() } return false // no solution }
// Template 3 — Count solutions fun backtrack(state: State): Int { if (isGoal(state)) return 1 var count = 0 for (choice in choices) { if (!isValid(choice)) continue state.add(choice) count += backtrack(state) state.removeLast() } return count }
// Template 4 — Optimize (find best, prune with bound) var best = Int.MAX_VALUE fun backtrack(state: State, cost: Int) { if (cost >= best) return // prune — can't beat best if (isGoal(state)) { best = cost; return } for (choice in choices) { if (!isValid(choice)) continue state.add(choice) backtrack(state, cost + choice.cost) state.removeLast() } }
Subsets (Power Set)
O(n×2^n) O(n)
// All subsets fun subsets(nums: IntArray): List<List<Int>> { val result = mutableListOf<List<Int>>() fun bt(start: Int, cur: MutableList<Int>) { result.add(cur.toList()) // record at every node for (i in start until nums.size) { cur.add(nums[i]) bt(i + 1, cur) cur.removeLast() } } bt(0, mutableListOf()) return result } // Subsets with duplicates — sort + skip fun subsetsWithDup(nums: IntArray): List<List<Int>> { nums.sort() val result = mutableListOf<List<Int>>() fun bt(start: Int, cur: MutableList<Int>) { result.add(cur.toList()) for (i in start until nums.size) { if (i > start && nums[i] == nums[i-1]) continue // skip dup cur.add(nums[i]); bt(i + 1, cur); cur.removeLast() } } bt(0, mutableListOf()) return result } // Bitmask alternative fun subsetsBitmask(nums: IntArray): List<List<Int>> = (0 until (1 shl nums.size)).map { mask -> nums.filterIndexed { i, _ -> mask and (1 shl i) != 0 } }
When to Use
all subsetsWhy: Include/exclude each element. Record at EVERY node (not just leaves). 2^n results. Loop from start. subsets with duplicatesWhy: Sort first, then skip same-value elements at the same decision level: if (i > start && nums[i] == nums[i-1]) continue. power set applications (feature selection)Why: Any problem asking "all possible selections" — feature selection, test case generation, set cover. bitmask enumerationWhy: Alternative to recursion: iterate mask 0..2^n-1. Bit i set → include nums[i]. Same 2^n results, iterative.
▶ Visualize
Permutations
O(n×n!) O(n)
// All permutations — loop from 0 with used[] fun permutations(nums: IntArray): List<List<Int>> { val result = mutableListOf<List<Int>>() val used = BooleanArray(nums.size) fun bt(cur: MutableList<Int>) { if (cur.size == nums.size) { result.add(cur.toList()); return } for (i in nums.indices) { if (used[i]) continue used[i] = true; cur.add(nums[i]) bt(cur) cur.removeLast(); used[i] = false } } bt(mutableListOf()) return result } // Permutations with duplicates — sort + skip fun permuteUnique(nums: IntArray): List<List<Int>> { nums.sort() val result = mutableListOf<List<Int>>() val used = BooleanArray(nums.size) fun bt(cur: MutableList<Int>) { if (cur.size == nums.size) { result.add(cur.toList()); return } for (i in nums.indices) { if (used[i]) continue if (i > 0 && nums[i] == nums[i-1] && !used[i-1]) continue used[i] = true; cur.add(nums[i]) bt(cur) cur.removeLast(); used[i] = false } } bt(mutableListOf()) return result }
When to Use
all permutationsWhy: Order matters, use all elements. Loop from 0 with used[] array. Record at LEAVES (when cur.size == n). n! results. permutations with duplicatesWhy: Sort + skip: if (nums[i] == nums[i-1] && !used[i-1]) continue. Prevents duplicate orderings. next permutationWhy: Not backtracking — use the algorithmic approach (find rightmost ascent, swap, reverse suffix). O(n). anagram generationWhy: Permutations of characters in a string. Use permutations with duplicates to avoid repeated anagrams. permutation sequence (kth)Why: Don't generate all n! — use factorial number system to directly compute the kth permutation in O(n²).
▶ Visualize
Combinations & Combination Sum
O(k×C(n,k)) O(k)
// C(n, k) — choose k from 1..n fun combine(n: Int, k: Int): List<List<Int>> { val result = mutableListOf<List<Int>>() fun bt(start: Int, cur: MutableList<Int>) { if (cur.size == k) { result.add(cur.toList()); return } for (i in start..n) { cur.add(i); bt(i + 1, cur); cur.removeLast() } } bt(1, mutableListOf()) return result } // Combination Sum — reuse allowed (use i, not i+1) fun combinationSum(cands: IntArray, target: Int): List<List<Int>> { val result = mutableListOf<List<Int>>() fun bt(start: Int, rem: Int, cur: MutableList<Int>) { if (rem == 0) { result.add(cur.toList()); return } for (i in start until cands.size) { if (cands[i] > rem) break // prune cur.add(cands[i]) bt(i, rem - cands[i], cur) // i, not i+1 → reuse cur.removeLast() } } cands.sort() bt(0, target, mutableListOf()) return result } // Combination Sum 2 — no reuse, skip duplicates fun combinationSum2(cands: IntArray, target: Int): List<List<Int>> { cands.sort() val result = mutableListOf<List<Int>>() fun bt(start: Int, rem: Int, cur: MutableList<Int>) { if (rem == 0) { result.add(cur.toList()); return } for (i in start until cands.size) { if (cands[i] > rem) break if (i > start && cands[i] == cands[i-1]) continue cur.add(cands[i]) bt(i + 1, rem - cands[i], cur) // i+1 → no reuse cur.removeLast() } } bt(0, target, mutableListOf()) return result }
Subsets Permutations Combinations Record: every Record: leaves Record: size=k node only only Loop: start→n Loop: 0→n Loop: start→n with used[]
When to Use
combinations c(n,k)Why: Choose k items from n, order doesn't matter. Loop from start, record when size == k. C(n,k) results. combination sum (reuse)Why: Find combos summing to target, each element reusable. Recurse with i (not i+1). Sort + prune when candidate > remaining. combination sum ii (no reuse)Why: Same but each element used once. Recurse with i+1. Sort + skip duplicates at same level. combination sum iiiWhy: Choose k numbers from 1-9 summing to n. Fixed-size combination with target constraint. letter combinations of phoneWhy: Each digit maps to 3-4 letters. At each digit position, try all mapped letters. Product of choices.
▶ Visualize
Generate Parentheses
O(4^n/√n) O(n)
fun generateParenthesis(n: Int): List<String> { val result = mutableListOf<String>() fun bt(cur: StringBuilder, open: Int, close: Int) { if (cur.length == 2 * n) { result.add(cur.toString()); return } if (open < n) { cur.append('(') bt(cur, open + 1, close) cur.deleteCharAt(cur.lastIndex) } if (close < open) { cur.append(')') bt(cur, open, close + 1) cur.deleteCharAt(cur.lastIndex) } } bt(StringBuilder(), 0, 0) return result }
Decision tree for n=2 "" | "(" / \ "((" "()" | | "(()" "()(" | | "(())" "()()" ← both valid!
When to Use
generate valid parenthesesWhy: Build valid combos of n pairs. Add '(' if open < n, ')' if close < open. Catalan(n) results. valid parentheses generationWhy: Any problem generating all valid nested/balanced structures — HTML tags, bracket expressions. count bst structures (catalan)Why: Catalan number counts both valid parenthesizations and unique BST shapes. Same recurrence.
▶ Visualize
N-Queens
O(n!) O(n)
fun solveNQueens(n: Int): List<List<String>> { val result = mutableListOf<List<String>>() val cols = mutableSetOf<Int>() val diag1 = mutableSetOf<Int>() // row - col val diag2 = mutableSetOf<Int>() // row + col val board = IntArray(n) { -1 } // board[row] = col fun bt(row: Int) { if (row == n) { result.add(board.map { col -> ".".repeat(col) + "Q" + ".".repeat(n - col - 1) }) return } for (col in 0 until n) { if (col in cols || row-col in diag1 || row+col in diag2) continue board[row] = col cols.add(col); diag1.add(row-col); diag2.add(row+col) bt(row + 1) cols.remove(col); diag1.remove(row-col); diag2.remove(row+col) } } bt(0) return result }
nBrute ForceWith PruningSolutions
4256162
816,777,21615,72092
128.9 × 10^12856,18814,200
When to Use
n-queensWhy: Place n queens so none attack. Row-by-row placement with col/diag1/diag2 constraint sets. O(n!) with pruning. n-queens ii (count)Why: Same algorithm but just increment counter instead of building board strings. Slightly faster. constraint satisfactionWhy: Any placement problem with row/column/diagonal constraints — bishops, rooks, knights on a board.
▶ Visualize
Sudoku Solver
O(9^empty) O(empty)
fun solveSudoku(board: Array<CharArray>): Boolean { for (r in 0..8) for (c in 0..8) { if (board[r][c] != '.') continue for (d in '1'..'9') { if (isValidPlacement(board, r, c, d)) { board[r][c] = d if (solveSudoku(board)) return true board[r][c] = '.' // backtrack } } return false // no digit works } return true // all filled } fun isValidPlacement(b: Array<CharArray>, r: Int, c: Int, d: Char): Boolean { for (i in 0..8) { if (b[r][i] == d) return false // row if (b[i][c] == d) return false // col if (b[r/3*3+i/3][c/3*3+i%3] == d) return false // box } return true }
When to Use
sudoku solver (4×4)Why: Try digits 1-9 in each empty cell, check row/col/box validity, backtrack on failure. Template 2 (find ANY solution). crossword puzzle fillerWhy: Same pattern: try words in each slot, check constraints (intersecting letters), backtrack on conflict. constraint grid problemsWhy: Any fill-in puzzle with row/column/region rules. MRV heuristic (most-constrained cell first) dramatically speeds up.
▶ Visualize
🔍
Word Search (Grid)
O(m×n×4^L) O(L)
fun exist(board: Array<CharArray>, word: String): Boolean { val m = board.size; val n = board[0].size val dirs = intArrayOf(0,1,0,-1,0) fun dfs(r: Int, c: Int, k: Int): Boolean { if (k == word.length) return true if (r !in 0 until m || c !in 0 until n) return false if (board[r][c] != word[k]) return false val tmp = board[r][c] board[r][c] = '#' // mark visited for (d in 0..3) { if (dfs(r + dirs[d], c + dirs[d+1], k + 1)) return true } board[r][c] = tmp // RESTORE — backtrack return false } for (r in 0 until m) for (c in 0 until n) if (dfs(r, c, 0)) return true return false }
When to Use
word searchWhy: Match word letter-by-letter on a 2D board. DFS with visited marking + restore. 4-directional. O(m×n×4^L). word search II (multiple words)Why: Build Trie from word list, DFS the board following Trie edges. Prune branches not in Trie. path finding with constraintsWhy: Any grid path search where cells can only be visited once. Mark → explore → restore is the pattern. rat in a mazeWhy: Find all paths from start to end. Mark visited, try 4 directions, backtrack. Collect valid paths.
▶ Visualize
Palindrome Partitioning
O(n×2^n) O(n)
fun palindromePartition(s: String): List<List<String>> { val result = mutableListOf<List<String>>() fun isPalin(l: Int, r: Int): Boolean { var i = l; var j = r while (i < j) { if (s[i++] != s[j--]) return false } return true } fun bt(start: Int, cur: MutableList<String>) { if (start == s.length) { result.add(cur.toList()); return } for (end in start until s.length) { if (isPalin(start, end)) { cur.add(s.substring(start, end + 1)) bt(end + 1, cur) cur.removeLast() } } } bt(0, mutableListOf()) return result }
When to Use
palindrome partitioningWhy: Split string so every part is a palindrome. Try all palindromic prefixes at each position, recurse on remainder. word break iiWhy: Same partitioning pattern: try all valid dictionary-word prefixes, recurse on remainder. Collect all segmentations. string segmentation problemsWhy: Any "split string into valid parts" problem. Try all valid prefixes, recurse. Constraint = what makes a part "valid."
▶ Visualize
Expression Add Operators
O(4^n) O(n)
fun addOperators(num: String, target: Long): List<String> { val result = mutableListOf<String>() fun bt(i: Int, expr: String, eval: Long, last: Long) { if (i == num.length) { if (eval == target) result.add(expr) return } for (j in i until num.length) { if (j > i && num[i] == '0') break // no leading zeros val cur = num.substring(i, j + 1).toLong() if (i == 0) { bt(j + 1, "$cur", cur, cur) } else { bt(j+1, "$expr+$cur", eval + cur, cur) bt(j+1, "$expr-$cur", eval - cur, -cur) // * precedence: undo last, apply multiply bt(j+1, "$expr*$cur", eval - last + last * cur, last * cur) } } } bt(0, "", 0, 0) return result }
When to Use
expression add operatorsWhy: Insert +, -, * between digits to reach target. Track lastOperand to handle * precedence (undo add, apply multiply). target number with operatorsWhy: Any "insert operators between elements to reach target" problem. Enumerate all operator placements. expression evaluationWhy: Generate all possible expressions from digits. Handle multi-digit numbers and operator precedence.
▶ Visualize
🌐
Restore IP Addresses
O(1) — max 81 O(1)
fun restoreIpAddresses(s: String): List<String> { val result = mutableListOf<String>() fun bt(start: Int, parts: MutableList<String>) { if (parts.size == 4) { if (start == s.length) result.add(parts.joinToString(".")) return } for (len in 1..3) { if (start + len > s.length) break val part = s.substring(start, start + len) if (part.length > 1 && part[0] == '0') continue // no leading 0 if (part.toInt() > 255) continue parts.add(part) bt(start + len, parts) parts.removeLast() } } bt(0, mutableListOf()) return result }
When to Use
restore ip addressesWhy: Split digit string into exactly 4 valid octets (0-255, no leading zeros). Fixed-depth backtracking, bounded O(1). fixed-part string splittingWhy: Any "split string into exactly k valid parts" problem. Same structure: try lengths 1-3 at each position. decode ways (related)Why: Similar: split digit string into valid 1-2 digit codes (1-26). DP version more efficient, backtracking enumerates all.
▶ Visualize
Making Backtracking Fast
// 1. Constraint propagation — check validity BEFORE recursing if (col in cols || diag1 in d1 || diag2 in d2) continue // 2. Sort for early termination cands.sort() if (cands[i] > remaining) break // all remaining too large // 3. Skip duplicates at same decision level if (i > start && nums[i] == nums[i-1]) continue // 4. Symmetry breaking — only explore first option if (row == 0 && col > n / 2) break // N-Queens: half of first row // 5. Bound-based pruning — can't beat current best if (cost + lowerBound(remaining) >= best) return // 6. Memoization — cache repeated subproblems memo[state]?.let { return it } // 7. Most-constrained variable — fill hardest cell first val cell = emptyCells.minByOrNull { it.numOptions }
ProblemWithout PruningWith Pruning
N-Queens (n=8)16,777,21615,720
Sudoku (hard)~10^48~1,000
Recursion & Backtracking Pitfalls
// BUG 1: Missing base case → infinite recursion fun bad(n: Int): Int = n * bad(n - 1) // never stops! fun good(n: Int): Int = if (n <= 1) 1 else n * good(n - 1) // BUG 2: Incomplete base cases fun bad(n: Int): Int = if (n == 0) 1 else n * bad(n - 1) // fails for negative! // BUG 3: Forgetting to backtrack (undo choice) cur.add(nums[i]) bt(i + 1, cur) // cur.removeLast() ← MISSING! State leaks to next iteration // BUG 4: Adding reference instead of copy result.add(current) // BAD — same list mutated later result.add(current.toList()) // GOOD — snapshot copy // BUG 5: Not sorting before duplicate skip // nums = [1, 2, 1] → skip logic FAILS without sort nums.sort() // [1, 1, 2] → now skip works if (i > start && nums[i] == nums[i-1]) continue // BUG 6: Exponential time from missing memoization fun fib(n: Int): Int = if (n <= 1) n else fib(n-1) + fib(n-2) // O(2^n)! // BUG 7: Wrong start index // i+1 → no reuse (subsets, combSum2) // i → reuse allowed (combSum) // 0 → permutations (with used[]) // BUG 8: Mutating global state without restoring visited[r][c] = true dfs(r + 1, c) // visited[r][c] = false ← MISSING! Blocks other paths
Recursion Complexities
AlgorithmTimeSpace
FactorialO(n)O(n)
Fibonacci (naive)O(2^n)O(n)
Fibonacci (memo)O(n)O(n)
Power (fast)O(log n)O(log n)
Tower of HanoiO(2^n)O(n)
Merge SortO(n log n)O(n)
Quicksort (avg)O(n log n)O(log n)
Binary SearchO(log n)O(log n)
Backtracking Complexities
AlgorithmTimeSpace
SubsetsO(n×2^n)O(n)
PermutationsO(n×n!)O(n)
Combinations C(n,k)O(k×C(n,k))O(k)
Combination SumO(n^(T/min))O(T/min)
Generate ParenthesesO(4^n/√n)O(n)
N-QueensO(n!)O(n)
SudokuO(9^empty)O(1)
Word SearchO(m×n×4^L)O(L)
Palindrome PartitionO(n×2^n)O(n)
Expression OperatorsO(4^n)O(n)
Which Pattern?
SignalPattern
Generate all subsetsSubsets template
All orderingsPermutations
Choose k itemsCombinations
Sum to targetCombination Sum
Valid arrangementN-Queens / Sudoku
Path in gridWord Search
Split stringPartitioning
Need count / optimalAdd memoization
🎯
Five Essential Backtracking Patterns
Subsets Record at every node, loop from start Permutations Record at leaves, loop from 0 with used[] Combinations Record when size = k, loop from start Partitioning Try split points, recurse on remainder Grid Search 4-directional DFS with visited backtrack