Recursion Fundamentals
factorial(5)
└─ 5 × factorial(4)
└─ 4 × factorial(3)
└─ 3 × factorial(2)
└─ 2 × factorial(1)
└─ 1 ← base case!
| Real-World Analogy | Recursion Pattern |
| Russian dolls | Open doll → find smaller doll → repeat |
| Browser back button | Each page remembers the previous one |
| Undo stack | Each action can be unwound in reverse order |
- Base case stops the recursion — without it, infinite loop & stack overflow
- Recursive case makes the problem smaller each time
- Natural fit for trees, graphs, divide-and-conquer, backtracking
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
- Each call creates a stack frame (local vars, return address, parameters)
- Deep recursion causes StackOverflowError
- Solutions: convert to iteration, use tailrec, or increase stack size with
-Xss
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
- Every recursive function has base case + recursive case + combine
- Multiple base cases possible (e.g., empty list AND single element)
- Branching recursion (e.g., fib) creates call trees, not call chains
// 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
}
| Criterion | Recursion | Iteration |
| Clarity | More intuitive for trees/graphs | Simpler for linear tasks |
| Space | O(n) stack frames | O(1) |
| Stack overflow | Risk for deep input | No risk |
| Performance | Function call overhead | Slightly faster |
| Backtracking | Natural fit | Requires explicit stack |
- Every recursion can be converted to iteration (with explicit stack if needed)
- Use recursion for trees, divide-and-conquer, backtracking
- Convert to iteration for large inputs to avoid stack overflow
// 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)
}
}
- Tail call = recursive call is the LAST operation (no work after it)
- tailrec keyword compiles to a loop — O(1) space, no stack overflow
- Accumulator pattern converts non-tail to tail by passing result forward
Classic Recursion
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 — 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.
- Fibonacci naive is O(2^n) — always memoize or use tail recursion
- Fast power halves exponent each step — O(log n) multiplications
▶ Visualize
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.
- String recursion shrinks by removing characters from front or both ends
- Subsequences = include/exclude each char = 2^n results
▶ Visualize
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.
- Recursion unwinds from tail to head — builds result on return trip
- Recursive reversal uses O(n) stack — prefer iterative for large lists
▶ Visualize
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.
- Trees are recursion's natural home — each subtree is a subproblem
- Pattern 1: return bottom-up (height, count, LCA)
- Pattern 2: pass info top-down (BST bounds, path sum target)
▶ Visualize
// 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.
- Three steps: divide → conquer → combine
- Subproblems are INDEPENDENT (unlike DP where they overlap)
- Merge sort is the canonical example — O(n log n) guaranteed
▶ Visualize
Memoization
// 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))
}
| Aspect | Memoization (Top-Down) | Tabulation (Bottom-Up) |
| Direction | Starts from original problem | Starts from base cases |
| Implementation | Recursion + cache | Loops + table |
| Subproblems solved | Only needed ones | All subproblems |
| Space optimization | Harder | Easier (rolling array) |
| Stack overflow | Possible for deep recursion | No 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.
- Transforms O(2^n) to O(n) by caching results of subproblems
- Check cache first, then compute — avoid redundant work
- Use getOrPut pattern in Kotlin for clean memoization
▶ Visualize
Backtracking Patterns
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)
| Approach | Branches Explored | When |
| Brute force | All possible | No constraints to prune |
| Backtracking | Only valid branches | Can detect dead ends early |
- Choose → Explore → Unchoose is the core rhythm
- Pruning makes it dramatically faster than brute force
- Backtracking = DFS through the decision tree
// 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()
}
}
- All backtracking follows one of 4 templates
- Template choice depends on what you need: all / any / count / best
// 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.
- Include/exclude each element = 2^n subsets
- Sort + skip for duplicates:
if (i > start && nums[i] == nums[i-1]) continue
- Bitmask alternative: iterate masks 0..2^n-1
▶ Visualize
// 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²).
- Loop from 0 with used[] (not from start like subsets)
- Sort + skip unused duplicates:
nums[i] == nums[i-1] && !used[i-1]
- Swap-based permutation is in-place but harder to handle duplicates
▶ Visualize
// 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.
- Start index controls reuse: i+1 = no reuse, i = reuse allowed
- Prune when
candidate > remaining (sort input first)
▶ Visualize
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.
- Constraint: can add
'(' if open < n, can add ')' if close < open
- Number of results = Catalan number C(n)
▶ Visualize
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
}
| n | Brute Force | With Pruning | Solutions |
| 4 | 256 | 16 | 2 |
| 8 | 16,777,216 | 15,720 | 92 |
| 12 | 8.9 × 10^12 | 856,188 | 14,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.
- Place row by row, track cols + diag1(row-col) + diag2(row+col)
- Pruning eliminates 99.9% of search space
▶ Visualize
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.
- Try digits 1–9 in each empty cell, backtrack on invalid
- Optimization: fill most-constrained cell first (MRV heuristic)
▶ Visualize
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.
- Mark cell visited before recursing, RESTORE after (backtrack)
- 4-directional exploration using direction array
▶ Visualize
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."
- At each position try all palindromic prefixes, recurse on remainder
▶ Visualize
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.
- Track lastOperand to handle * precedence (undo last add, apply multiply)
- No leading zeros:
if (j > i && num[i] == '0') break
▶ Visualize
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.
- Exactly 4 parts, each 0–255, no leading zeros
- Bounded O(1) since max 81 combinations for any input
▶ Visualize
Pruning Strategies
// 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 }
| Problem | Without Pruning | With Pruning |
| N-Queens (n=8) | 16,777,216 | 15,720 |
| Sudoku (hard) | ~10^48 | ~1,000 |
- Pruning is the difference between theory and practice
- Sort input for early breaks (larger candidates pruned sooner)
- Skip duplicates at the same decision level to avoid redundant subtrees
Common 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
- Always copy current state when recording:
current.toList()
- Sort before skipping dups — skip logic requires sorted input
- Start index: i+1 for no reuse / i for reuse / 0 with used[] for permutations
Quick Reference
| Algorithm | Time | Space |
| Factorial | O(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 Hanoi | O(2^n) | O(n) |
| Merge Sort | O(n log n) | O(n) |
| Quicksort (avg) | O(n log n) | O(log n) |
| Binary Search | O(log n) | O(log n) |
| Algorithm | Time | Space |
| Subsets | O(n×2^n) | O(n) |
| Permutations | O(n×n!) | O(n) |
| Combinations C(n,k) | O(k×C(n,k)) | O(k) |
| Combination Sum | O(n^(T/min)) | O(T/min) |
| Generate Parentheses | O(4^n/√n) | O(n) |
| N-Queens | O(n!) | O(n) |
| Sudoku | O(9^empty) | O(1) |
| Word Search | O(m×n×4^L) | O(L) |
| Palindrome Partition | O(n×2^n) | O(n) |
| Expression Operators | O(4^n) | O(n) |
| Signal | Pattern |
| Generate all subsets | Subsets template |
| All orderings | Permutations |
| Choose k items | Combinations |
| Sum to target | Combination Sum |
| Valid arrangement | N-Queens / Sudoku |
| Path in grid | Word Search |
| Split string | Partitioning |
| Need count / optimal | Add memoization |
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
- All backtracking is choose → explore → unchoose
- The loop start and when you record determine the pattern