ALGORITHMS: Understanding Recursion And Its Risks

ALGORITHMS: Understanding Recursion And Its Risks

Introduction

Imagine John Doe, our building’s maintenance worker, gets a call about a call about a blinking light somewhere in our massive 10-story building. Suppose he does not get the floor number or the room number where this issue is happening - just a complaint about a bad light that needs fixing. John Doe is heading out with his tools to fix a light, but this simple maintenance call is about to reveal how recursion works in programming.

How would you solve this issue if you were John? Think about an approach before you continue. John won't check all the lights in the building simultaneously. Instead, he will start on the first floor, enter the first room and check the lights, move to the next room, repeat the same thing, and so on. If he finds offices within a room, he checks those too.

This method of navigating from the lard building down to a simple light fixture mirrors recursion. Breaking down a big task into smaller ones - checking one floor, then one room, then one light at a time. Once he finds the light that needs to be fixed, he stops searching, fixes it and works his way back through the building, collecting his tools along the way.

Recursive functions break down complex problems into smaller solvable problems.

💡
Recursion is a problem-solving pattern where a function calls itself to break complicated problems into simpler versions until they can be solved directly.

Prerequisites

To understand this topic, you need to have a solid grasp of basic programming concepts like control structures (loops and conditional statements). Recursion revolves around repeating tasks and decision-making, functions and call stack. Outside programming, the ability to break down big problems into smaller solvable ones.

How Recursion Works

Basic Structure of a Recursive Function

When writing a recursion function, it is very important to structure the code correctly to ensure it works as it should and avoids infinite loops. A recursive function is essentially made up of 2 components:

  • Base Case: The base case is the condition that stops the recursion. This is important because if the base case is not well implemented, the function will run forever, leading to a stack overflow error. Think of the base case as the emergency brake that stops the function from calling itself when it reaches the simplest version of the problem—the version that can be solved directly without further breakdown. This ensures that the recursion ends. For example, if we are writing a function to calculate the factorial of a number, the emergency brake will be activated when the input is either 0 or 1, since 1! = 1 and 0! = 1.

      const factorial  = (x) => { 
          if (x===0 || x === 1) {
                  return 1; // This is the base case (emmergency brake)
          }
      }
    
  • Recursive case: this occurs when the function calls itself with a smaller version of the original problem. With each recursive call, the problem becomes smaller, gradually moving closer to the base case. Continuing with the factorial example, the recursive case reduces x!to x * factorial(x -1) this happens until it reaches the base case. In a nutshell, the recursive case is used to break the problem into smaller sub-problems.

      const factorial  = (x) => { 
          if (x===0 || x === 1) {
                  return 1; // This is the base case (emmergency brake)
          }
    
          return x * factorial(x - 1) // recursive case
      }
      factorial(5)
    

To explain this better, you can think of a recursive function like sharing a circular cake:

  • Base Case: You have reached the last slice in the center. Nothing is left to cut.

  • Recursive Case: Each slice makes the remaining cake smaller. If you don’t cut the cake (reduce the problem), you will just be staring at the same whole cake forever.

The Function Call Stack

The function call stack is a data structure created by the runtime environment of a programming language (typically managed by the interpreter or compiler) to manage function calls and local variables. The call stack uses the Last In First Out (LIFO) principle, meaning the last function that gets called will be the first to complete and exit. The function call stack is like the browsing history on your web browser’s back button. When you click links to visit new pages, it is added to your browsing history. To get back to where you started, click the back button through the pages stored in your browsing history in reverse order, meaning you can only return to the recently visited pages first. Suppose you start at Hashnode, then click on Linkedin, then follow a link to this article. To get back to Hashnode, you must first go to Linkedin (the most recent previous page), and then back to Hashnode. In a call stack, you can not skip back to Hashnode, you have to retrace your steps in the exact reverse order. This explains the LIFO principle I spoke about earlier. The last page you visited is the first one you must go back through. Just like how you traced your steps back in the analogy above (this article → LinkedIn → Hashnode), a stack trace shows the order of function calls that lead to the current point in the program. And just as each webpage has its unique content and state, each function call in the call stack has its own “execution context” with local variables and data specific to that function.

How Recursion Uses the Call Stack

In recursion, every time a function calls itself, a new frame (this is a block of memory containing the local variables and data specific to that function) is pushed onto the stack. The stack keeps a record of all the unfinished calls, holding them until the base case is reached to terminate the recursion.

How it works:

  1. Each recursive call pushes a new frame onto the call stack.

  2. Once the base case is reached, the function stop making new recursive calls

  3. The call stack starts to “unwind”. It clears each frame as the function completes and returns its result.

It is important to have a proper base case because the stack size is limited. If a recursion does not have a proper base case, this can exceed the stack’s capacity, causing a stack overflow error.

Visualizing Recursion Using the Call Stack

Let us go back to our factorial example and walk through the changes in the stack. Suppose we use call factorial(5) this is what will happen.

  1. factorial(5) is called. Remember, the recursive case is x * factorial(x-1)

  2. It calls factorial(4), which calls factorial(3), which calls factorial(2), which calls factorial(1), which finally calls factorial(0).

  3. When factorial(0) gets called, the base case is met, and 1 is returned.

  4. The stack begins to unwind:

    • factorial(0) returns 1

    • factorial(1) returns 1 * 1 = 1

    • factorial(2) return 2 * 1 = 2

    • factorial(3) return 3 * 2 = 6

    • factorial(4) returns 4 * 6 = 24

    • factorial(5) returns 5 * 24 = 120

    // the stack starts growing
    factorial(5)
        -> factorial(4)
                    -> factorial(3)
                            -> factorial(2)
                                        -> factorial(1)
                                                -> factorial(0) // Base case reached

                                                factorial(0) returns 1
                                factorial(1)    <- returns 1
                        factorial(2)    <- returns 2
                factorial(3)    <- returns 6
        factorial(4)    <- returns 24
    factorial(5) <-    returns 120

A Simple Recursive Example

Let us write a recursive function that returns the sum of the elements in an array.

const sum = (array, index = 0) => {
    if (array.length <= index) {
        return 0; // base case 
    }
    return array[index]  + sum(array, index + 1) // recursive case
}

// call the function
sum([2, 2, 4], 0) // returns 8

Code analysis

  • Base case: the function reaches the base case when all the numbers in the array have been checked, meaning when the array is empty. In the example above, the function will hit the base case when the index = 3 at that point, and return 0.

  • Recursive case: if the base case is not satisfied, then add the element in the current index position in the array, with the rest of the elements in the array.

Call Stack Walkthrough

Call stack growth

  1. sum([2, 2, 4], 0) → calls sum([2, 2, 4], 1)

  2. sum([2, 2, 4], 1) → calls sum([2, 2, 4], 2)

  3. sum([2, 2, 4], 2) → calls sum([2, 2, 4], 3)

  4. sum([2, 2, 4], 3) → reaches the base case and 0 is returned.

  5. sum([2, 2, 4], 3)

Call stack shrinking

  1. sum([2, 2, 4], 3) returns 0

  2. sum([2, 2, 4], 2) returns 4 + 0 = 4

  3. sum([2, 2, 4], 1) returns 2 + 4 = 6

  4. sum([2, 2, 4], 0) returns 2 + 6 = 8

The final result returned by the function is 8

Common Pitfalls in Recursion

  • Stack Overflow: this occurs when the recursion depth surpasses the maximum stack capacity of the runtime environment. This happens when there are too many recursive calls before reaching the base case. To avoid this, ensure the recursion depth is reasonable, and consider using loops for functions with large inputs.

    For example, if we call factorial(1000000), this may lead to a stack overflow depending on the runtime environment.

  • Infinite Recursion: this occurs when the function does not have a base case or when the base case is implemented incorrectly. The function will call itself until a stack overflow happens. To avoid this, define a proper base case and be sure to test edge cases so it stops the recursion. Happens

    For example, if we remove the base case from the factorial example, the function will run until a stack overflow happens.

  • Excessive Memory Usage: it is very important to note that recursive functions can potentially consume a lot of memory because each function call adds a new frame to the call stack, especially when working with large input sizes. To avoid this, you can reduce the memory overhead of the function with optimization techniques like memoization or dynamic programming. You can also use an iterative approach to solve the problem.

Recursion is just a very elegant way to solve problems. Virtually every recursive solution you provide can be solved using iteration too. The choice, like every other technique in computer science, depends on some trade-offs - you will have to take the performance, code readability and the nature of the problem into consideration.

When to Use Recursion (And When Not To)

  • When to Use Recursion:

    • Mathematical Problems: Recursion is perfect when dealing with mathematical calculations where problems naturally break down into smaller or simpler versions of themselves. For example, finding a factorial, Fibonacci sequence.

    • Divide-and-conquer Problems: Recursion is great for problems that can be broken into smaller problems. For example, the quicksort algorithm.

    • Graph and tree Traversals: Recursion works well when traversing trees and graphs. For example, depth-first search algorithm.

  • When to Avoid Recursion:

    • Performance Constraints: If the solution causes performance overhead because of excessive memory usage, consider using an iterative approach.

    • Simple Iterative Tasks: If the problem in question can be solved with a simple loop, then consider using this approach because it is often much more efficient than a recursive solution.

    • Problems with Deep recursion Risk: If the solution provided could exceed the maximum stack size, causing a stack overflow, use an iterative approach instead.

Conclusion

  • Recursion is a programming technique where a function calls itself to solve smaller subproblems.

  • Recursion depends on the base case to terminate the function and the recursive case to break the problem down into smaller subproblems.

  • The function call stack is used to manage the recursive calls. It stores the states and ensures proper execution order.

  • While recursion is very elegant, ensure to have a properly implemented base case to avoid infinite recursion or stack overflow. Also, ensure the stack depth is reasonable to avoid excessive memory usage.

To truly understand recursion, you have to understand recursion. See what I did there? On a more serious note, practice recursion by solving problems recursively and also learning about graphs and tree structures. Also, I suggest you test your knowledge of recursion by converting recursive functions to a function that uses the iterative approach to better help you understand both approaches