🔴 Unique Paths - Unoptimized Recursive Solution
Understanding the naive approach and why it fails
📘 Unique Paths Series:
- 📘 The Discovery Journey - How humans naturally discover the solution
- 🔴 Unoptimized Recursive (You are here) - Pure recursion and its exponential complexity
- Plain English Proof - Accessible logical reasoning
- Formal Proof - Mathematical rigor and certainty
A robot is located at the top-left corner of an m × n grid. The robot can only move either down or right at any point in time.
Goal: How many possible unique paths are there to reach the bottom-right corner?
Example: 3×3 grid → 6 unique paths
Constraints: All cells are reachable (no obstacles)
1. Understanding the Recursive Approach
The First Instinct
When you first encounter this problem, the most intuitive approach is often recursive:
"To find the number of paths to reach
(i, j), I can sum up:
- The number of paths to reach the cell above
(i-1, j)- The number of paths to reach the cell to the left
(i, j-1)"
This is a beautiful insight and forms the foundation of the dynamic programming solution. But implementing it naively leads to severe performance issues.
Pure Recursive Implementation
public class UnoptimizedUniquePaths {
public int uniquePaths(int m, int n) {
return countPaths(m - 1, n - 1);
}
private int countPaths(int i, int j) {
// Base case 1: Reached the top row
if (i == 0) return 1;
// Base case 2: Reached the leftmost column
if (j == 0) return 1;
// Recursive case: sum paths from above and from left
int pathsFromAbove = countPaths(i - 1, j); // Count paths from cell above
int pathsFromLeft = countPaths(i, j - 1); // Count paths from cell to left
return pathsFromAbove + pathsFromLeft;
}
}
Understanding the Recursive Logic
Base Cases:
- If we're in the first row (
i == 0): Can only go right → 1 way - If we're in the first column (
j == 0): Can only go down → 1 way
Recursive Case (with explicit variables for clarity):
// Step 1: Calculate paths from cell above
int pathsFromAbove = countPaths(i - 1, j);
// Step 2: Calculate paths from cell to left
int pathsFromLeft = countPaths(i, j - 1);
// Step 3: Total paths = sum of both
return pathsFromAbove + pathsFromLeft;
Why use explicit variables?
- Makes the two recursive calls visible and separate
- Easy to see we're calling the same function twice with different arguments
- Shows the addition happens AFTER both recursive calls complete
- Better for debugging - you can inspect each value
- Clearer mental model of what's happening
Why Addition?
- Paths from above and paths from left are mutually exclusive
- They don't overlap (can't come from both simultaneously)
- Together they cover all possibilities
2. Why It Fails: Visualizing Exponential Complexity
Small Example: 3×3 Grid
Let's trace what happens when we call countPaths(2, 2) with explicit variables:
countPaths(2, 2):
// Not a base case, so continue...
int pathsFromAbove = countPaths(1, 2); // First recursive call
int pathsFromLeft = countPaths(2, 1); // Second recursive call
return pathsFromAbove + pathsFromLeft;
Full Execution Tree:
countPaths(2,2)
├─ Step 1: Calculate pathsFromAbove = countPaths(1,2)
│ ├─ Step 1a: pathsFromAbove = countPaths(0,2) → returns 1 ✓
│ ├─ Step 1b: pathsFromLeft = countPaths(1,1)
│ │ ├─ pathsFromAbove = countPaths(0,1) → returns 1 ✓
│ │ └─ pathsFromLeft = countPaths(1,0) → returns 1 ✓
│ │ └─ returns 1 + 1 = 2
│ └─ returns 1 + 2 = 3
│
└─ Step 2: Calculate pathsFromLeft = countPaths(2,1)
├─ Step 2a: pathsFromAbove = countPaths(1,1) ← COMPUTED AGAIN!
│ ├─ pathsFromAbove = countPaths(0,1) → returns 1 ✓
│ └─ pathsFromLeft = countPaths(1,0) → returns 1 ✓
│ └─ returns 1 + 1 = 2
└─ Step 2b: pathsFromLeft = countPaths(2,0) → returns 1 ✓
└─ returns 2 + 1 = 3
Final: returns 3 + 3 = 6 ✓
Notice that countPaths(1,1) is computed twice!
- Once when calculating
pathsFromAboveforcountPaths(1,2) - Once when calculating
pathsFromAboveforcountPaths(2,1)
The explicit variables make it clear:
// Inside countPaths(1,2):
int pathsFromLeft = countPaths(1,1); // Computes (1,1) → result: 2
// Inside countPaths(2,1):
int pathsFromAbove = countPaths(1,1); // Computes (1,1) AGAIN → result: 2
This is just a 3×3 grid. Imagine a 10×10 or 20×20 grid! 😱
Step-by-Step Execution (Understanding Variables)
Let's see exactly how the explicit variables help us understand the flow:
// Call: countPaths(2, 2)
countPaths(2, 2) {
// Check base cases: i=2, j=2 → not a base case
// Create variable for paths from above
int pathsFromAbove = countPaths(1, 2); // ← PAUSE HERE: Must compute this first!
// Call: countPaths(1, 2)
countPaths(1, 2) {
// Check base cases: i=1, j=2 → not a base case
int pathsFromAbove = countPaths(0, 2); // ← Base case! Returns 1
int pathsFromLeft = countPaths(1, 1); // ← Must compute this
// Call: countPaths(1, 1)
countPaths(1, 1) {
int pathsFromAbove = countPaths(0, 1); // Returns 1
int pathsFromLeft = countPaths(1, 0); // Returns 1
return 1 + 1 = 2; // ← RESULT STORED
}
return 1 + 2 = 3; // ← RESULT STORED
}
// pathsFromAbove now contains 3
// Create variable for paths from left
int pathsFromLeft = countPaths(2, 1); // ← Now compute this!
// Call: countPaths(2, 1)
countPaths(2, 1) {
int pathsFromAbove = countPaths(1, 1); // ← RECOMPUTING (1,1)!
// countPaths(1, 1) executes AGAIN!
// Returns 2 (same calculation as before)
int pathsFromLeft = countPaths(2, 0); // Returns 1
return 2 + 1 = 3;
}
// pathsFromLeft now contains 3
return pathsFromAbove + pathsFromLeft; // Returns 3 + 3 = 6
}
The explicit variables pathsFromAbove and pathsFromLeft show you:
- When each recursive call happens (sequentially, not simultaneously)
- What value each recursive call returns
- Where the repeated calculations occur
- How the final result is computed from the two values
Without explicit variables, all of this is hidden in a single line:
return countPaths(i - 1, j) + countPaths(i, j - 1); // What's happening? 🤔
Complete Recursion Tree
Here's the full recursion tree for a 3×3 grid (showing all repeated computations):
countPaths(2,2)
/ \
countPaths(1,2) countPaths(2,1)
/ \ / \
countPaths(0,2) countPaths(1,1) countPaths(1,1) countPaths(2,0)
| / \ / \ |
1 countPaths(0,1) countPaths(1,0) ... 1
| |
1 1
Repeated Subproblems:
countPaths(1,1)is computed 2 timescountPaths(0,1)is computed 2 timescountPaths(1,0)is computed 2 times
For larger grids, some cells are recomputed hundreds or thousands of times!
The Exponential Time Complexity
Why exponential?
Each recursive call makes two more calls:
countPaths(i,j) → countPaths(i-1,j) + countPaths(i,j-1)
The recursion tree has approximately:
- Height:
m + n - 2(number of moves needed) - Branches: 2 at each level
- Total nodes: Approximately
2^(m+n)
In practice:
- 3×3 grid: ~20 calls
- 5×5 grid: ~512 calls
- 10×10 grid: ~524,288 calls
- 20×20 grid: ~1,099,511,627,776 calls (1 trillion!) 💥
The call stack depth is at most m + n - 2:
- From
(m-1, n-1)to(0, 0) - Each recursive call reduces either
iorjby 1 - Maximum depth:
(m-1) + (n-1) = m + n - 2
This is actually reasonable - the problem is the time complexity!
Empirical Performance Testing
Let's see how long this takes in practice:
public class PerformanceTest {
public static void main(String[] args) {
UnoptimizedUniquePaths solver = new UnoptimizedUniquePaths();
// Test different grid sizes
System.out.println("3x3: " + solver.uniquePaths(3, 3)); // Fast
System.out.println("5x5: " + solver.uniquePaths(5, 5)); // Still fast
System.out.println("10x10: " + solver.uniquePaths(10, 10)); // Slow
System.out.println("15x15: " + solver.uniquePaths(15, 15)); // Very slow
System.out.println("20x20: " + solver.uniquePaths(20, 20)); // ☠️ Don't try this
}
}
Approximate Running Times:
Grid Size | Approximate Time
----------|------------------
3×3 | < 1 ms
5×5 | < 1 ms
10×10 | ~100 ms
15×15 | ~30 seconds
20×20 | ~2 hours
25×25 | ~several days
The Core Problem: Overlapping Subproblems
The recursive solution recalculates the same subproblem multiple times.
Example: For countPaths(4,4), the cell (2,2) might be computed in dozens of different ways:
countPaths(4,4) → ... → countPaths(2,2)
countPaths(4,4) → ... → countPaths(2,2) ← REPEATED!
countPaths(4,4) → ... → countPaths(2,2) ← REPEATED!
...
Each path to (2,2) in the recursion tree recomputes the entire subtree below it!
Visual Comparison
┌──────────────────────────────────────────────────────┐
│ UNOPTIMIZED RECURSION │
│ │
│ ✗ Recomputes same cells many times │
│ ✗ Exponential time: O(2^(m+n)) │
│ ✓ Simple to understand │
│ ✓ Directly mirrors the problem logic │
│ │
│ Example: 10×10 grid │
│ Calls: ~524,288 │
│ Time: ~100 ms │
│ Space: O(m+n) - call stack │
└──────────────────────────────────────────────────────┘
vs.
┌──────────────────────────────────────────────────────┐
│ DYNAMIC PROGRAMMING (Coming Next) │
│ │
│ ✓ Each cell computed exactly once │
│ ✓ Linear time: O(m×n) │
│ ✓ Can optimize space to O(n) │
│ ✗ Requires understanding DP │
│ │
│ Example: 10×10 grid │
│ Cells: 100 │
│ Time: < 1 ms │
│ Space: O(m×n) or O(n) │
└──────────────────────────────────────────────────────┘
3. Practical Application & Key Takeaways
When Pure Recursion Makes Sense
Despite its inefficiency, pure recursion is valuable for:
-
Understanding the Problem
- It directly mirrors the problem's recursive structure
- Easiest to understand and explain
-
Small Inputs
- For tiny grids (3×3, 4×4), the performance difference is negligible
- Good for testing and validation
-
Interview Communication
- Starting with recursion shows you understand the problem
- Natural stepping stone to the DP solution
-
Educational Purposes
- Demonstrates why we need dynamic programming
- Shows the importance of recognizing overlapping subproblems
The Learning Path
1. Pure Recursion (This Document)
↓
[Recognize repeated subproblems]
↓
2. Recursion with Memoization
↓
[Convert to iterative]
↓
3. Bottom-Up DP (2D Array)
↓
[Optimize space]
↓
4. Space-Optimized DP (1D Array)
Each step builds on the previous one!
Complete Working Code
Full Implementation with Comments
/**
* Unoptimized Recursive Solution for Unique Paths
*
* Time Complexity: O(2^(m+n)) - EXPONENTIAL!
* Space Complexity: O(m+n) - Call stack depth
*
* WARNING: Do not use for grids larger than 10×10!
*/
public class UnoptimizedUniquePaths {
/**
* Main entry point
* @param m number of rows
* @param n number of columns
* @return number of unique paths from (0,0) to (m-1,n-1)
*/
public int uniquePaths(int m, int n) {
// Convert to 0-indexed
return countPaths(m - 1, n - 1);
}
/**
* Recursive helper function
* @param i current row (0-indexed)
* @param j current column (0-indexed)
* @return number of ways to reach (i,j) from (0,0)
*/
private int countPaths(int i, int j) {
// Base case 1: First row - can only go right
if (i == 0) {
return 1;
}
// Base case 2: First column - can only go down
if (j == 0) {
return 1;
}
// Recursive case: sum paths from above and from left
// EXPLICIT VARIABLES make it easier to understand!
int pathsFromAbove = countPaths(i - 1, j); // How many ways to reach cell above?
int pathsFromLeft = countPaths(i, j - 1); // How many ways to reach cell to left?
// Total paths = paths from above + paths from left
return pathsFromAbove + pathsFromLeft;
}
/**
* Test the implementation
*/
public static void main(String[] args) {
UnoptimizedUniquePaths solver = new UnoptimizedUniquePaths();
// Small test cases (safe to run)
System.out.println("3×3 grid: " + solver.uniquePaths(3, 3)); // Expected: 6
System.out.println("3×7 grid: " + solver.uniquePaths(3, 7)); // Expected: 28
System.out.println("5×5 grid: " + solver.uniquePaths(5, 5)); // Expected: 70
// Larger cases (use with caution!)
// System.out.println("10×10 grid: " + solver.uniquePaths(10, 10));
// System.out.println("15×15 grid: " + solver.uniquePaths(15, 15));
}
}
Adding Debug Output
To see the repeated calculations in action:
public class DebugUniquePaths {
private int callCount = 0;
public int uniquePaths(int m, int n) {
callCount = 0;
int result = countPaths(m - 1, n - 1);
System.out.println("Total function calls: " + callCount);
return result;
}
private int countPaths(int i, int j) {
callCount++;
System.out.println("Computing countPaths(" + i + ", " + j + ")");
if (i == 0 || j == 0) {
return 1;
}
int pathsFromAbove = countPaths(i - 1, j);
int pathsFromLeft = countPaths(i, j - 1);
return pathsFromAbove + pathsFromLeft;
}
public static void main(String[] args) {
DebugUniquePaths solver = new DebugUniquePaths();
System.out.println("\n=== 3×3 Grid ===");
System.out.println("Result: " + solver.uniquePaths(3, 3));
}
}
Output for 3×3:
=== 3×3 Grid ===
Computing countPaths(2, 2)
Computing countPaths(1, 2)
Computing countPaths(0, 2)
Computing countPaths(1, 1)
Computing countPaths(0, 1)
Computing countPaths(1, 0)
Computing countPaths(2, 1)
Computing countPaths(1, 1) ← REPEATED!
Computing countPaths(0, 1) ← REPEATED!
Computing countPaths(1, 0) ← REPEATED!
Computing countPaths(2, 0)
Total function calls: 11
Result: 6
Key Takeaways
-
The Recursive Formula is Beautiful:
paths(i,j) = paths(i-1,j) + paths(i,j-1)This insight is the foundation of the solution.
-
But Naive Implementation is Exponential:
- O(2^(m+n)) time complexity
- Repeated subproblems kill performance
- Unusable for grids larger than ~15×15
-
This is Why We Need Dynamic Programming:
- DP solves each subproblem exactly once
- Stores results to avoid recomputation
- Reduces time from exponential to polynomial
-
The Pattern Recognition:
- When you see overlapping subproblems
- And optimal substructure (optimal solution uses optimal subsolutions)
- Think: Dynamic Programming!
-
Explicit Variables Aid Understanding:
- Makes recursive calls visible and trackable
- Shows when and how values are computed
- Essential for debugging and learning
- Transforms complex one-liners into clear steps
Next Steps
Now that you understand why the naive recursion fails, you're ready to learn how to fix it:
- Add Memoization - Cache results to avoid recomputation
- Convert to Bottom-Up DP - Build solutions iteratively
- Optimize Space - Use only O(n) space instead of O(m×n)
Each optimization builds on your understanding of this recursive foundation!
Understanding this unoptimized solution is essential for truly grasping dynamic programming.
The best engineers don't just memorize DP patterns - they understand why recursion fails and how DP fixes it! 🚀
Practice Problems
Understanding Questions
-
Why does the recursion tree have exponential nodes?
Click for answer
Each call branches into two more calls, creating a binary tree structure. With depth approximately
m+n, we get roughly2^(m+n)nodes. -
What's the maximum depth of the recursion?
Click for answer
Maximum depth is
m + n - 2because we need to reduceifromm-1to0andjfromn-1to0. -
How many times is
countPaths(5, 5)computed incountPaths(7, 7)?Click for answer
It's computed C(4, 2) = 6 times - once for each unique path from
(7,7)to(5,5)in the recursion tree.
Modification Challenges
- Add a counter to track how many times each cell is computed
- Modify to return the actual paths, not just the count
- Add memoization to avoid repeated calculations
Remember: This solution is "bad" in terms of performance, but essential for understanding. Master this, and the DP optimization will make perfect sense! 💡