Recursion 101

Recursion 101

PREREQUISITE

  • Know Basic Programming Language

WHAT IS CALL-STACK

  • The call stack is a data structure in computer memory(RAM) used to keep track of function calls and their local variables.

  • Each time a function is called, a new stack frame is added to the call stack to store information about that function's execution, including its parameters and local variables. When the function returns, its stack frame is removed.

  • Every function remains in the stack until its execution is not over, If that function contains another function it will wait until the next function is complete so that the remaining execution is completed

  • Every function call has its memory allocated

EXAMPLE:

public class Solution {
    public static void main(String[] args) {
        System.out.println(average(10, 20));
    }

    public static int add(a, b) {
        return a + b;
    }

    public static int average(a, b) {
        return add(a, b) / 2;
    }
}

JavaScript Call Stack - step 3

WHAT IS RECURSION

Recursion is a programming technique in which a function calls itself to solve a problem. In other words, it's a process where a function performs an action in part and delegates the rest of the action to itself. Recursion is based on the idea of solving a complex problem by breaking it down into smaller, more manageable instances of the same problem.

WHEN TO USE RECURSION

  • Code Reusability: Recursively Call That Function

  • Call Helper Function to Perform Calculation

  • Use recursion when the problem can be naturally divided into smaller, similar subproblems, and solving each subproblem contributes to solving the overall problem.

COMPONENTS OF RECURSIVE FUNCTION

  • BASE CASE: Every recursive function must have a base case or termination condition. This is the condition under which the recursion stops, preventing infinite recursion. Ensure that your base case is well-defined and reachable.

  • RECURSIVE CASE: In addition to the base case, there should be a recursive case where the function calls itself with a modified or smaller version of the problem.

This is how the problem is broken down into smaller subproblems.

ERROR RELATED TO RECURSION

  • STACK OVERFLOW ERROR: Runtime error that occurs when a program's call stack exceeds its maximum allowed size.

VISUALISING RECURSION

Visualization is very important when trying to solve complex problem

Visualizing recursion using recursion trees can help you understand how the function calls stack up and how the problem is divided into subproblems.

HOW TO BREAK DOWN A PROBLEM

e.g. Find the nth Fibonacci Number using Recursion

STEP 1: ANALYSE THE PROBLEM, AND OUTPUT FOR THE GIVEN INPUT REQUIREMENT

STEP 2: IDENTIFY HOW TO BREAK THE PROBLEM INTO SUB-PROBLEMS

STEP 3: VISUALISE USING RECURSIVE TREE

  • A thorough understanding of the flow of a recursive tree will help you understand the steps and flow of code (how code will execute).
  • help understand the base case

  • If a recursive tree is forming it will follow pre-order traversal(root, left, right) of execution of a function

STEP 4: IDENTIFYING RECURSIVE PATTERN (BREAK PROBLEM IN SMALL PROBLEM)

Fn = Fn-1 + Fn-2

note: When you write recursion in the form of a formula is called a recurrence relation

STEP 5: FINDING BASE CASE

  • The smallest possible condition solution becomes the base condition

  • POINTS TO REMEMBER

    • Thoroughly understand the problem and Consider the problem's requirements and constraints.

    • The base condition often corresponds to the smallest subproblem that can be solved directly without further recursion. In many cases, this subproblem represents the simplest or most trivial instance of the problem.

    • Think about the boundary or edge cases of the problem. These are situations where the input is at its minimum or maximum valid value or size. The base condition should cover these cases.

    • Look at the problem's definition or mathematical properties. Are there any specific values or conditions that can be used as a base case? For example, in calculating factorials, 0! and 1! are defined as 1, so these are natural base cases.

    • Consider what condition, when met, indicates that further recursion is unnecessary. This could be when an index reaches the end of an array, when a value becomes zero, or when a certain condition is met.

    • Ensure that the base condition(s) are designed in such a way that the recursive function will eventually reach them for any valid input. Avoid infinite recursion by guaranteeing termination.

    • Ensure that the base condition(s) are designed in such a way that the recursive function will eventually reach them for any valid input. Avoid infinite recursion by guaranteeing termination.

STEP 6: DEFINE RECURSIVE CASE

  • Determine how you can break down the problem into one or more smaller, similar subproblems. Define the relationship between the original problem and its subproblems.

  • The recursive case is the part of the algorithm where the function calls itself with modified or smaller inputs.

  • IN-DEPTH STEPS

    1. Identify the Recursive Structure: First, understand the problem and identify how it can be divided into smaller instances of the same problem. You need to determine what changes in each recursive call to make it closer to the base case.

    2. Define the Recursive Function: Create a function that will solve the problem recursively. This function should take one or more arguments representing the current state or parameters of the problem. The function's return value will often involve recursive calls.

    3. Implement the Recursive Case: In the body of the recursive function, define the recursive case. This is where you express how to solve the current problem by breaking it down into smaller subproblems and making recursive calls with adjusted inputs.

    4. Make the Recursive Calls: Within the recursive case, call the same function with modified inputs. Typically, you will pass arguments with reduced size, changed state, or different parameters that move you closer to the base case. These recursive calls should be made to solve the subproblems.

    5. Combine Results (if needed): If the problem requires combining the results of multiple recursive calls to compute the final result, include this step in your recursive case. Combine the results as specified by the problem.

    6. Verify the Termination Condition: Ensure that the recursive case, along with the recursive calls, will eventually lead to reaching the base case. It's crucial to avoid infinite recursion.

    7. Test the Recursive Function: Write test cases to verify that your recursive function behaves as expected. Test it with various inputs, including edge cases, to ensure correctness.

STEP 7: AFTER IMPLEMENTING, LOOK FOR THE OPTIMAL SOLUTION

RECURSION EXAMPLE: REVERSE STRING

RECURRENCE RELATION

Recurrence relations are used to analyze and evaluate the time complexity of recursive algorithms. They help in understanding how the number of operations grows as a function of input size.

TYPES OF RECURRENCE RELATION

Recurrence relations can take various forms, depending on the problem being modelled. Here are some common types of recurrence relations:

  1. Linear Recurrence Relations:

    • Linear recurrence relations express the nth term of a sequence as a linear combination of its previous terms. They are often used to model sequences with constant coefficients. For example:

        a[n] = a[n-1] + a[n-2]  (Fibonacci sequence)
      
  2. Divide and Conquer Recurrence Relations:

    • These recurrence relations often arise in divide-and-conquer algorithms, where a problem is split into smaller subproblems. They can be more complex to solve and may involve recursion. For example, in merge sort:

        T(n) = 2T(n/2) + O(n)
      
    • Master Theorem Recurrence Relations:

      The Master Theorem is a general framework for solving divide and conquer recurrence relations, specifically those in the form:

        T(n) = aT(n/b) + f(n)
      

      It provides a method to determine the time complexity of algorithms.

How to decide what information to give to a recursive function as input.

  1. Define the Problem: Clearly understand the problem you want to solve recursively. Identify the key variables or parameters that are relevant to the problem.

  2. Identify What Changes: Determine what changes in each recursive call. Consider how you can break down the problem into smaller, similar subproblems.

  3. Select Relevant Parameters: Choose the parameters or inputs that need to be passed to the recursive function to describe the current state or context of the problem. These inputs should reflect what changes from one recursive call to the next.

  4. Consider the Base Case: Ensure that the base case (termination condition) is included in the parameters if it depends on the problem's state or context.

  5. Avoid Redundancy: Avoid passing unnecessary information that remains constant throughout the recursion, as this can increase computational overhead.

  6. Test with Examples: Test your choice of parameters with specific examples to ensure that they correctly represent the problem's state and lead to the desired solution.

  7. Iterate and Refine: If necessary, iterate and refine your choice of parameters as you work through the recursive algorithm. Adjust them based on the evolving problem state.

  8. Document Clearly: Document the parameters in the function's header and comments to make the purpose and usage of each parameter clear to anyone reading the code.

MISCELLANEOUS

  1. A recursive function of type void doesn't need to specify a return type

  2. Memory Usage: Some recursive algorithms can be memory-intensive, especially if they generate a large number of recursive calls. Be cautious of this and consider iterative alternatives for efficiency when necessary.

  3. Efficiency: Recursive solutions may not always be the most efficient for certain problems. Consider the time and space complexity of your recursive solution and compare it to iterative alternatives.

  4. Testing and Debugging: Debugging recursive code can be challenging. Use print statements, debuggers, and test cases to verify that your recursive function is behaving as expected.

  5. Tail Recursion: Tail recursion is a special form of recursion where the recursive call is the last operation in the function. Some programming languages can optimize tail-recursive functions to reduce stack space usage.

  6. Inductive Reasoning: When designing and analyzing recursive algorithms, use inductive reasoning to prove correctness. Establish the base case and the inductive step to show that the function works correctly for all cases.

OPTIMIZATION

Always optimize after finding a recursive answer. Direct optimization is very hard.

  1. TAIL CALL RECURSION OPTIMIZATION
  • the compiler in certain languages, especially functional programming languages, will optimize the number of stack frames that get added to the call stack to remove this idea of stack overflows in a lot of scenarios.

  • This works by ensuring the last function (return) call is a recursive one

  • Not Supported in Java, Python, JavaScript Safari

  1. MEMOIZATION & CACHING
  • TOP-DOWN APPROACH

  • Memoization is a specific technique used to optimize recursive algorithms by caching or memorizing the results of expensive function calls to avoid recalculating them.

  • Typically uses a data structure like a dictionary or an array to cache results based on the input arguments.

package DYNAMIC_PROGRAMMING.CODE.TOP_DOWN_APPROACH;

public class FibonacciNumber {

    static int fib(int n, int[] memo) {
//        CHECK: to prevent resolving Prblm.
        if (memo[n]==0) {
            if(n < 2) {
                memo[n] = n;
            } else {
                int left = fib(n-1, memo);
                int right = fib(n-2, memo);
                memo[n] = left + right;
            }
        }
        return memo[n];
    }

    public static void main(String[] args) {
        System.out.println(fib(6, new int[6+1]));

    }
}
  1. DYNAMIC PROGRAMMING APPROACH
  • BOTTOM-UP APPROACH

  • Dynamic programming is a general optimization technique used to solve problems by breaking them down into smaller overlapping subproblems. It involves solving these subproblems only once and storing their solutions in a table or array to avoid redundant work.

  • Mostly uses a table or an array to store the solutions to subproblems. These tables are filled in a systematic order, ensuring that each subproblem is solved once and its solution is reused when needed.

package DYNAMIC_PROGRAMMING.CODE.BOTTOM_UP_APPROACH;

public class FibonacciNumber {

    static int fib(int n){
        int[] table = new int[n+1];
        table[0]=0;
        table[1]=1;
        for (int i=2; i<=n; i++){
            table[i] = table[i-1] + table[i-2];
        }

        return table[n];
    }

    public static void main(String[] args) {
        System.out.println(fib(6));
    }
}

VIDEO REFERENCE