Skip to content

Recursive Functions: Solving Problems by Calling Functions Themselves

What is Recursion

Stand between two parallel mirrors and you'll see countless reflections of yourself—each reflection contains a smaller reflection, extending infinitely into the distance. This is the intuitive embodiment of recursion: self-reference, self-inclusion.

In programming, recursion is a technique where a function calls itself. A recursive function breaks down a large problem into smaller identical problems until the problem becomes small enough to solve directly.

Recursion has two key elements:

  1. Base Case: The termination condition of recursion
  2. Recursive Case: The function calls itself to solve smaller problems

Understanding Recursive Thinking

1. The Simplest Recursion Example

javascript
// Countdown: Count from n to 1
function countdown(n) {
  // Base case: Stop when n is 0
  if (n === 0) {
    console.log("Launch!");
    return;
  }

  // Recursive case: Output current number, then recursively call with smaller number
  console.log(n);
  countdown(n - 1);
}

countdown(5);
// 5
// 4
// 3
// 2
// 1
// Launch!

How does this function work? Let's trace the call stack:

countdown(5)
  -> Output 5, call countdown(4)
    -> Output 4, call countdown(3)
      -> Output 3, call countdown(2)
        -> Output 2, call countdown(1)
          -> Output 1, call countdown(0)
            -> Output "Launch!", return
          <- Return
        <- Return
      <- Return
    <- Return
  <- Return

2. Calculating Factorial

Factorial is a classic example of recursion. 5! = 5 × 4 × 3 × 2 × 1 = 120:

javascript
// Recursive way
function factorial(n) {
  // Base case
  if (n === 0 || n === 1) {
    return 1;
  }

  // Recursive case: n! = n × (n-1)!
  return n * factorial(n - 1);
}

console.log(factorial(5)); // 120
console.log(factorial(0)); // 1
console.log(factorial(10)); // 3628800

// Compare with iterative approach
function factorialIterative(n) {
  let result = 1;
  for (let i = 2; i <= n; i++) {
    result *= i;
  }
  return result;
}

The recursive version is closer to the mathematical definition and easier to understand, but it creates multiple function calls.

3. Fibonacci Sequence

Each number is the sum of the two preceding numbers: 0, 1, 1, 2, 3, 5, 8, 13, 21...

javascript
// Basic recursive implementation
function fibonacci(n) {
  // Base case
  if (n <= 1) {
    return n;
  }

  // Recursive case: fib(n) = fib(n-1) + fib(n-2)
  return fibonacci(n - 1) + fibonacci(n - 2);
}

console.log(fibonacci(6)); // 8
console.log(fibonacci(10)); // 55

// Call tree visualization (fibonacci(5))
//                 fib(5)
//              /          \
//         fib(4)          fib(3)
//        /      \        /      \
//    fib(3)   fib(2)  fib(2)  fib(1)
//    /   \    /   \   /   \
// fib(2) fib(1) ...  ...
// Note: Lots of repeated calculations!

Common Recursion Patterns

1. Summing Arrays

javascript
// Recursive sum
function sum(arr) {
  // Base case: Empty array
  if (arr.length === 0) {
    return 0;
  }

  // Recursive case: First element + sum of remaining elements
  return arr[0] + sum(arr.slice(1));
}

console.log(sum([1, 2, 3, 4, 5])); // 15

// More efficient tail recursive version
function sumTail(arr, accumulator = 0) {
  if (arr.length === 0) {
    return accumulator;
  }

  return sumTail(arr.slice(1), accumulator + arr[0]);
}

console.log(sumTail([1, 2, 3, 4, 5])); // 15

2. Finding Maximum Value

javascript
function findMax(arr) {
  // Base case: Only one element
  if (arr.length === 1) {
    return arr[0];
  }

  // Recursive case: Current element vs maximum of remaining elements
  const maxOfRest = findMax(arr.slice(1));
  return arr[0] > maxOfRest ? arr[0] : maxOfRest;
}

console.log(findMax([3, 1, 4, 1, 5, 9, 2, 6])); // 9

3. Array Flattening

javascript
// Recursively flatten nested arrays
function flatten(arr) {
  let result = [];

  for (const item of arr) {
    if (Array.isArray(item)) {
      // Recursively flatten sub-array
      result = result.concat(flatten(item));
    } else {
      result.push(item);
    }
  }

  return result;
}

const nested = [1, [2, [3, [4]], 5], 6];
console.log(flatten(nested)); // [1, 2, 3, 4, 5, 6]

// Functional version
function flattenFunctional(arr) {
  return arr.reduce(
    (acc, item) =>
      Array.isArray(item)
        ? acc.concat(flattenFunctional(item))
        : acc.concat(item),
    []
  );
}

Recursive Tree Traversal

Recursion is particularly suitable for handling tree structures:

1. DOM Tree Traversal

javascript
// Recursively traverse DOM tree
function traverseDOM(node, callback, level = 0) {
  // Base case: No node
  if (!node) return;

  // Process current node
  callback(node, level);

  // Recursively process child nodes
  for (const child of node.children) {
    traverseDOM(child, callback, level + 1);
  }
}

// Usage example
traverseDOM(document.body, (node, level) => {
  const indent = "  ".repeat(level);
  console.log(
    `${indent}${node.tagName} #${node.id || ""} .${node.className || ""}`
  );
});

2. File System Traversal

javascript
// Recursively traverse file system structure
function traverseFileSystem(node, callback, path = "") {
  const currentPath = path + "/" + node.name;

  // Process current node
  callback(node, currentPath);

  // If it's a directory, recursively process children
  if (node.type === "directory" && node.children) {
    for (const child of node.children) {
      traverseFileSystem(child, callback, currentPath);
    }
  }
}

// Sample data
const fileSystem = {
  name: "root",
  type: "directory",
  children: [
    {
      name: "documents",
      type: "directory",
      children: [
        { name: "resume.pdf", type: "file" },
        { name: "letter.docx", type: "file" },
      ],
    },
    {
      name: "images",
      type: "directory",
      children: [{ name: "photo1.jpg", type: "file" }],
    },
    { name: "notes.txt", type: "file" },
  ],
};

// Usage
traverseFileSystem(fileSystem, (node, path) => {
  const type = node.type === "directory" ? "📁" : "📄";
  console.log(`${type} ${path}`);
});

3. JSON Deep Clone

javascript
function deepClone(obj) {
  // Base case: Primitive types
  if (obj === null || typeof obj !== "object") {
    return obj;
  }

  // Handle arrays
  if (Array.isArray(obj)) {
    return obj.map((item) => deepClone(item));
  }

  // Handle dates
  if (obj instanceof Date) {
    return new Date(obj);
  }

  // Handle objects
  const cloned = {};
  for (const key in obj) {
    if (obj.hasOwnProperty(key)) {
      cloned[key] = deepClone(obj[key]);
    }
  }

  return cloned;
}

// Test
const original = {
  name: "John",
  age: 30,
  address: {
    city: "New York",
    coordinates: {
      lat: 40.7128,
      lng: -74.006,
    },
  },
  hobbies: ["reading", "coding"],
  joined: new Date("2020-01-01"),
};

const cloned = deepClone(original);
cloned.address.city = "Los Angeles";

console.log(original.address.city); // "New York" (unchanged)
console.log(cloned.address.city); // "Los Angeles"

Recursion Optimization

1. Tail Recursion Optimization

Tail recursion means the recursive call is the last operation of the function:

javascript
// ❌ Non-tail recursive - There's still operation after recursive call (multiplication)
function factorial(n) {
  if (n === 0) return 1;
  return n * factorial(n - 1); // Still need to do multiplication
}

// ✅ Tail recursive - Recursive call is the last operation
function factorialTail(n, accumulator = 1) {
  if (n === 0) return accumulator;
  return factorialTail(n - 1, n * accumulator); // Last operation
}

console.log(factorialTail(5)); // 120

The advantage of tail recursion is that it can be optimized by the compiler into iteration, avoiding stack growth.

2. Trampoline Function

JavaScript doesn't guarantee tail recursion optimization, but we can simulate it with trampoline functions:

javascript
// Trampoline function
function trampoline(fn) {
  return function (...args) {
    let result = fn(...args);

    while (typeof result === "function") {
      result = result();
    }

    return result;
  };
}

// Transform into trampoline version of Fibonacci
function fibTrampoline(n, a = 0, b = 1) {
  if (n === 0) return a;
  if (n === 1) return b;

  // Return a function instead of direct recursion
  return () => fibTrampoline(n - 1, b, a + b);
}

const fib = trampoline(fibTrampoline);
console.log(fib(10)); // 55
console.log(fib(1000)); // Can handle large numbers without stack overflow

3. Memoized Recursion

Avoid repeated calculations through caching:

javascript
function memoize(fn) {
  const cache = new Map();

  return function (...args) {
    const key = JSON.stringify(args);

    if (cache.has(key)) {
      return cache.get(key);
    }

    const result = fn(...args);
    cache.set(key, result);
    return result;
  };
}

// Memoized Fibonacci
const fibonacci = memoize(function fib(n) {
  if (n <= 1) return n;
  return fib(n - 1) + fib(n - 2);
});

console.time("fib(40)");
console.log(fibonacci(40)); // 102334155
console.timeEnd("fib(40)"); // A few milliseconds

// Compare with unoptimized version
function fibSlow(n) {
  if (n <= 1) return n;
  return fibSlow(n - 1) + fibSlow(n - 2);
}

console.time("fibSlow(40)");
console.log(fibSlow(40)); // 102334155
console.timeEnd("fibSlow(40)"); // Several seconds!

Real-world Applications of Recursion

1. Permutations and Combinations

javascript
// Generate all permutations of an array
function permutations(arr) {
  // Base case: Empty or single-element array
  if (arr.length <= 1) {
    return [arr];
  }

  const result = [];

  // For each element as the first element
  for (let i = 0; i < arr.length; i++) {
    const current = arr[i];
    const remaining = [...arr.slice(0, i), ...arr.slice(i + 1)];

    // Recursively get permutations of remaining elements
    const remainingPerms = permutations(remaining);

    // Add current element to the beginning of each permutation
    for (const perm of remainingPerms) {
      result.push([current, ...perm]);
    }
  }

  return result;
}

console.log(permutations([1, 2, 3]));
// [[1,2,3], [1,3,2], [2,1,3], [2,3,1], [3,1,2], [3,2,1]]

// Generate combinations
function combinations(arr, k) {
  // Base case
  if (k === 0) return [[]];
  if (arr.length === 0) return [];

  const [first, ...rest] = arr;

  // Combinations including first element
  const withFirst = combinations(rest, k - 1).map((combo) => [first, ...combo]);

  // Combinations not including first element
  const withoutFirst = combinations(rest, k);

  return [...withFirst, ...withoutFirst];
}

console.log(combinations([1, 2, 3, 4], 2));
// [[1,2], [1,3], [1,4], [2,3], [2,4], [3,4]]

2. Quick Sort

javascript
function quickSort(arr) {
  // Base case: Empty or single-element array is already sorted
  if (arr.length <= 1) {
    return arr;
  }

  // Select pivot value (choose middle element here)
  const pivotIndex = Math.floor(arr.length / 2);
  const pivot = arr[pivotIndex];

  // Partition
  const left = [];
  const equal = [];
  const right = [];

  for (const item of arr) {
    if (item < pivot) {
      left.push(item);
    } else if (item === pivot) {
      equal.push(item);
    } else {
      right.push(item);
    }
  }

  // Recursively sort left and right partitions, then merge
  return [...quickSort(left), ...equal, ...quickSort(right)];
}

const numbers = [3, 1, 4, 1, 5, 9, 2, 6, 5, 3, 5];
console.log(quickSort(numbers));
// [1, 1, 2, 3, 3, 4, 5, 5, 5, 6, 9]

3. Path Finding

javascript
// Find path in maze
function findPath(maze, start, end, path = [], visited = new Set()) {
  const [x, y] = start;
  const [endX, endY] = end;
  const key = `${x},${y}`;

  // Out of bounds or visited
  if (x < 0 || y < 0 || x >= maze.length || y >= maze[0].length) {
    return null;
  }
  if (maze[x][y] === 1 || visited.has(key)) {
    return null;
  }

  // Found the end
  if (x === endX && y === endY) {
    return [...path, [x, y]];
  }

  // Mark as visited
  visited.add(key);
  const newPath = [...path, [x, y]];

  // Try four directions
  const directions = [
    [x + 1, y], // Down
    [x - 1, y], // Up
    [x, y + 1], // Right
    [x, y - 1], // Left
  ];

  for (const [nextX, nextY] of directions) {
    const result = findPath(
      maze,
      [nextX, nextY],
      end,
      newPath,
      new Set(visited)
    );

    if (result) {
      return result;
    }
  }

  return null;
}

// Maze: 0 = path, 1 = wall
const maze = [
  [0, 0, 1, 0],
  [0, 0, 0, 0],
  [1, 1, 0, 1],
  [0, 0, 0, 0],
];

const path = findPath(maze, [0, 0], [3, 3]);
console.log("Path:", path);

When to Use Recursion

Recursion is suitable for the following scenarios:

  1. Tree structures: DOM traversal, file systems, JSON operations
  2. Divide and conquer algorithms: Quick sort, merge sort, binary search
  3. Backtracking problems: Mazes, eight queens, Sudoku
  4. Mathematical problems: Factorial, Fibonacci, combinations
  5. Self-similar problems: Fractals, recursive graphics

Recursion is not suitable for scenarios:

  1. Simple loops: Use for/while which is simpler
  2. Deep recursion: May cause stack overflow
  3. Performance-critical code: Function call overhead is large

Summary

Recursion is a powerful programming technique that enables us to:

  1. Solve complex problems with concise code: Especially tree and divide-and-conquer problems
  2. Be closer to mathematical definitions: Recursive versions are usually easier to understand
  3. Elegantly handle self-similar structures: Like trees, graphs, fractals

But also note:

  • Ensure there's a clear base case to avoid infinite recursion
  • Consider performance, use memoization or switch to iteration when necessary
  • Pay attention to call stack depth limits

Mastering recursive thinking will give you a new perspective and tool for handling complex problems.