Loading content...
Once you've identified that a problem has recursive structure, the next critical step is defining exactly how it decomposes. This isn't automatic—even problems with obvious recursive potential can be decomposed in multiple ways, each with different implications for correctness, efficiency, and code clarity.
Problem decomposition is where recursive thinking transforms from recognition into design. A skilled recursive thinker doesn't just see that a problem can be broken down—they see how it should be broken down to yield the most elegant, efficient solution.
This page develops your decomposition instincts, teaching you to see the natural fault lines in problems and split them cleanly into subproblems that recombine seamlessly.
By mastering this page, you will: (1) Understand the principles of effective problem decomposition, (2) Learn multiple decomposition strategies and when to apply each, (3) Develop skills to identify the optimal subproblem structure, (4) Practice decomposing real problems into recursive subproblems.
Not all decompositions are equal. An effective recursive decomposition satisfies several key principles:
Every recursive call must operate on a demonstrably smaller input. 'Smaller' can mean:
The size reduction must be guaranteed on every path, not just some paths. A decomposition that sometimes doesn't shrink the problem leads to infinite recursion.
Each subproblem should be a complete, self-contained instance of the problem type. The subproblem shouldn't need external context that isn't explicitly passed to it.
The cleanest decompositions create independent subproblems—solving one doesn't affect another. This enables both conceptual clarity and potential parallelization.
Different problem types call for different decomposition strategies. Here are the major approaches:
The simplest strategy: handle one element, recurse on the rest.
Pattern: solve(all) = combine(first, solve(rest))
Examples:
sum([a, ...rest]) = a + sum(rest)reverse(s) = reverse(s[1:]) + s[0]When to use: Sequential processing where each element is handled independently.
Split the problem into two roughly equal parts, solve each, combine.
Pattern: solve(all) = combine(solve(left_half), solve(right_half))
Examples:
When to use: When halving provides logarithmic depth benefits or when the problem has natural binary structure.
123456789101112131415161718192021222324252627282930313233
// Strategy 1: Peel-One-Off (Linear)function sumArray(arr: number[]): number { if (arr.length === 0) return 0; // Handle first, recurse on rest return arr[0] + sumArray(arr.slice(1));} // Strategy 2: Divide-in-Half (Binary)function maxElement(arr: number[]): number { if (arr.length === 1) return arr[0]; const mid = Math.floor(arr.length / 2); const leftMax = maxElement(arr.slice(0, mid)); const rightMax = maxElement(arr.slice(mid)); return Math.max(leftMax, rightMax);} // Strategy 3: Choice-Based (Branching)function subsets(nums: number[]): number[][] { if (nums.length === 0) return [[]]; const first = nums[0]; const restSubsets = subsets(nums.slice(1)); // Each subset spawns two: with and without first const result: number[][] = []; for (const sub of restSubsets) { result.push(sub); // Without first result.push([first, ...sub]); // With first } return result;}At each step, enumerate choices; each choice leads to a subproblem.
Pattern: solve(state) = for each choice: solve(state_after_choice)
Examples:
When to use: Combinatorial problems, search problems, games.
Define subproblems over ranges or intervals of the input.
Pattern: solve(i, j) = combine(solve(i, k), solve(k+1, j)) for some k
Examples:
When to use: Optimization over contiguous sequences where split point matters.
The same problem can often be decomposed multiple ways. How do you choose?
Let the data guide you:
Different decompositions yield different time complexities:
Sometimes multiple decompositions have similar performance. Choose the one that produces the clearest code—the one where the recursive structure most naturally mirrors the problem statement.
| Problem Type | Recommended Strategy | Typical Complexity | Example |
|---|---|---|---|
| Sequential processing | Peel-one-off | O(n) | Sum, reverse, filter |
| Divide-and-conquer sorting | Divide-in-half | O(n log n) | Merge sort, quick sort |
| Search in sorted data | Divide-in-half | O(log n) | Binary search |
| Generate all combinations | Choice-based | O(2^n) | Subsets, permutations |
| Optimal splitting | Range-based | O(n³) typically | Matrix chain, optimal BST |
If you find yourself adding complex bookkeeping or fighting the problem structure, you might have chosen the wrong decomposition. Step back and ask: 'Is there a more natural way to split this?' The right decomposition usually feels effortless.
Often, subproblems need more than just the smaller input—they need context about what came before or constraints that must be maintained.
1. Accumulated State: Information gathered during descent
2. Constraints/Bounds: Limits that subproblems must respect
3. Parent Information: Data about ancestors in the call tree
4. Configuration: Settings that affect how subproblems should be solved
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657
interface TreeNode { value: number; left?: TreeNode; right?: TreeNode;} // Example 1: Accumulated State - collect all root-to-leaf pathsfunction allPaths(node: TreeNode | undefined, currentPath: number[] = []): number[][] { if (!node) return []; const pathWithCurrent = [...currentPath, node.value]; // Leaf node: return the complete path if (!node.left && !node.right) { return [pathWithCurrent]; } // Internal node: collect paths from both subtrees return [ ...allPaths(node.left, pathWithCurrent), ...allPaths(node.right, pathWithCurrent), ];} // Example 2: Constraints - validate BST with boundsfunction isValidBST( node: TreeNode | undefined, min = -Infinity, max = Infinity): boolean { if (!node) return true; if (node.value <= min || node.value >= max) return false; // Pass tightened bounds to subtrees return ( isValidBST(node.left, min, node.value) && isValidBST(node.right, node.value, max) );} // Example 3: Parent info - find depth of each nodefunction labelDepths(node: TreeNode | undefined, depth = 0): Map<number, number> { if (!node) return new Map(); const result = new Map<number, number>(); result.set(node.value, depth); // Pass incremented depth to children for (const [val, d] of labelDepths(node.left, depth + 1)) { result.set(val, d); } for (const [val, d] of labelDepths(node.right, depth + 1)) { result.set(val, d); } return result;}When context parameters make your function signature complex, use a pattern with a public wrapper and private helper. The public function has a clean signature; the helper carries all the context. This keeps your API clean while enabling full context passing internally.
Certain decomposition patterns appear repeatedly across different problems. Internalizing these patterns accelerates your problem solving.
f([first, ...rest]) = combine(first, f(rest))
Base: f([]) = identity
Used for: list processing, string operations, sequential decisions.
f(arr) = combine(f(arr[0:mid]), f(arr[mid:]))
Base: f([single]) or f([])
Used for: divide-and-conquer, tree operations, balanced processing.
f(item, rest) = combine(f_with_item(rest), f_without_item(rest))
Base: no items left
Used for: subset generation, knapsack problems, decision trees.
f(state) = for each valid_choice: f(state + choice)
Base: state is complete/terminal
Used for: permutations, pathfinding, game search, backtracking.
| Pattern | Subproblems | Base Case | Classic Examples |
|---|---|---|---|
| First/Rest | 1 (rest of list) | Empty list | Sum, reverse, map |
| Left/Right | 2 (halves) | Single/empty | Merge sort, max |
| Include/Exclude | 2 (branches) | No items | Subsets, knapsack |
| Choose-and-Continue | k (choices) | Goal reached | Permutations, N-queens |
You now understand how to decompose problems into recursive subproblems effectively. The next page covers building solutions from smaller solutions—how subproblem results combine into final answers.