DSA sheet · Recursion · concept video

Recursion Patterns Overview

This is the map for the whole recursion topic. Most of us start recursion by memorising code and drawing call stacks without really knowing why they work. The teacher wants to fix that first. She says recursion is not just "a function that calls itself". It's about trusting the same function to solve a smaller copy of the problem. Then she sorts recursion problems into 4 patterns: linear recursion, divide & conquer, recursion on strings, and recursion on stacks / linked lists. If you can tell which pattern a question belongs to, you already know the shape of the code.

Recursion matters because trees, graphs, backtracking and dynamic programming are all built on it. Recursion is not a data structure. It's a way of writing an algorithm.

This is a concept video, so each pattern follows this order:
① what the pattern is → ② how to recognise it → ③ intuition → ④ every example problem, each with its own mini-section (question, base case, recursive case, code, line by line, recursion tree + call stack dry run, complexity) → ⑤ a pattern template in Python → ⑥ remember

Part 0 · Recursion from scratch

What is recursion?

Recursion means a function solves a problem by calling itself on a smaller version of the same problem. The teacher puts the weight on the second half of that sentence. The important part isn't the self-call. It's the trust: you hand a smaller input to the same function and believe it will bring back the right answer for that smaller input. Then you only do one small step of work yourself.

Every recursive function has exactly 2 parts

partwhat it doesfor factorial
Base caseThe input is so small that we know the answer directly. The function stops here and returns without calling itself again.if n == 0: return 1
Recursive case (recursive call)Break the problem into the same problem with a smaller input, call the function on it, and use its answer.return n * fact(n - 1)

Why the base case must exist

Without a base case, fact(5) calls fact(4), which calls fact(3) … fact(0), fact(-1), fact(-2) … forever. The teacher calls this "saving the function from an infinite loop". Each unfinished call takes memory. In Python the program crashes after about 1000 nested calls with RecursionError: maximum recursion depth exceeded.

Doubt: what if my input is genuinely bigger than 1000, like factorial of 5000?
→ Python's default limit is about 1000 nested calls. You can raise it with import sys; sys.setrecursionlimit(10**5). For really deep inputs, an iterative loop is safer, because the real machine stack can still overflow. In interviews, just mention the limit. It shows you understand what recursion costs.

The "leap of faith" (trusting the smaller call)

When you write return n * fact(n - 1), don't try to trace all the calls in your head. Assume fact(n - 1) already works, the same way you trust Python's built-in max(). Ask only: "if I had the answer for n − 1, how would I build the answer for n?" For factorial: multiply by n. That's the whole recursive case.

The call stack: push on call, pop on return

Python keeps a pile of unfinished calls called the call stack. When a function calls itself, the new call is pushed on top and the caller waits, paused in the middle of its line. When the top call returns, it is popped, and its answer goes to the call just below it, which then continues.

Work on the way down vs on the way back up

In factorial, nothing is multiplied on the way down. Every multiplication happens on the way back up.

The teacher's first example: 5!

Factorial of n (written n!) is the product of all whole numbers from 1 to n. 5! = 5 × 4 × 3 × 2 × 1 = 120. She points out that the tail 4 × 3 × 2 × 1 is itself 4!. So 5! = 5 × 4!, 4! = 4 × 3!, and so on, down to 0! = 1. This is the smaller copy of the same problem. We trace it fully in Part A.

Why patterns?"Base case + recursive call" sounds simple, but questions still feel hard because the shape of the recursion changes from problem to problem. The 4 patterns below are those shapes. Learn to spot the shape first, then the code follows.

Part A · Pattern 1: Linear recursion

1What the pattern is

Each call makes exactly one recursive call, usually with the input reduced by one step (n → n − 1, or the string one character shorter). The calls form a single straight chain.

2How to recognise it

3Intuition

Think of a queue of people passing a question backwards. Person 5 asks person 4, who asks person 3 … until the last person knows the answer without asking (the base case). The answer then travels forwards again, and each person adds their small piece before passing it on.

Teacher's tipStart recursion with this pattern. Solve every linear question with a stack diagram drawn in your notebook before moving on to the other patterns.

4aExample: Factorial of n

Question

Given n ≥ 0, return n! (with 0! = 1).

Base case

n == 0 → return 1. The teacher keeps breaking the problem down until she reaches f(0), and we know 0! is 1. So that's where the chain stops.

Recursive case

n! = n × (n − 1)!, so return n * fact(n - 1).

Code

Factorial (linear recursion)
def fact(n):
    if n == 0:                  # base case: 0! is 1
        return 1
    return n * fact(n - 1)      # trust fact(n-1), then multiply by n

Line by line

linewhat it means
if n == 0: return 1The stopping point. Without it, n would go 0, −1, −2 … and crash.
return n * fact(n - 1)Python first runs fact(n - 1) and pauses this call. When the answer comes back, it multiplies by n and returns.

Dry run for fact(5)

fact(5) = 5 × fact(4)            → returns 120
   └─ fact(4) = 4 × fact(3)      → returns 24
        └─ fact(3) = 3 × fact(2) → returns 6
             └─ fact(2) = 2 × fact(1)   → returns 2
                  └─ fact(1) = 1 × fact(0) → returns 1
                       └─ fact(0)  base case → returns 1
  1. fact(5) needs fact(4), so it pauses. Stack: fact(5)
  2. fact(4) needs fact(3), fact(3) needs fact(2), fact(2) needs fact(1), fact(1) needs fact(0). Each one pauses. Nothing has been multiplied yet.
  3. fact(0) hits the base case and returns 1. It is popped off the stack.
  4. fact(1) gets 1 → 1 × 1 = 1 → returns.
  5. fact(2) gets 1 → 2 × 1 = 2 → returns.
  6. fact(3) gets 2 → 3 × 2 = 6 → returns.
  7. fact(4) gets 6 → 4 × 6 = 24 → returns.
  8. fact(5) gets 24 → 5 × 24 = 120. The stack is empty. Final answer 120 ✓
step 3 (deepest)
fact(5)fact(4)fact(3)fact(2)fact(1)fact(0) → 1
step 6
fact(5)fact(4)fact(3) → 6
step 8
fact(5) → 120

Newest call on top (red). Each call leaves the stack the moment it returns.

Complexity

4bExample: Sum of the first n natural numbers

Question

Return 1 + 2 + … + n for n ≥ 0 (the sum is 0 when n = 0). The teacher lists it as a typical linear question.

Base case & recursive case

Base: n == 0 → 0 (adding nothing gives 0). Recursive: the sum up to n is n + the sum up to n − 1. It has the same shape as factorial, with + instead of × and 0 instead of 1.

Sum of 1..n (linear recursion)
def sum_n(n):
    if n == 0:                  # base case: empty sum
        return 0
    return n + sum_n(n - 1)     # my number + the sum of everything below me
linewhat it means
if n == 0: return 00 is the "do nothing" value for addition, like 1 is for multiplication.
return n + sum_n(n - 1)Trust the smaller sum, then add n on the way back up.

Dry run for sum_n(3)

sum_n(3) = 3 + sum_n(2)          → 6
   └─ sum_n(2) = 2 + sum_n(1)    → 3
        └─ sum_n(1) = 1 + sum_n(0) → 1
             └─ sum_n(0) base      → 0
  1. Going down: sum_n(3) → sum_n(2) → sum_n(1) → sum_n(0).
  2. sum_n(0) returns 0.
  3. Coming back up: 1 + 0 = 1, then 2 + 1 = 3, then 3 + 3 = 6 ✓
deepest
sum_n(3)sum_n(2)sum_n(1)sum_n(0) → 0
last return
sum_n(3) → 6

Complexity: n + 1 calls → Time O(n), Space O(n). (The formula n(n+1)/2 gives O(1), but the point here is to practise the pattern.)

4cExample: Reverse a string

Question

Return the string written backwards: "abc" → "cba".

Base case & recursive case

Base: a string of length 0 or 1 is its own reverse. Recursive: reverse the rest (everything after the first character), then stick the first character at the end. "abc" → reverse("bc") + "a" = "cb" + "a".

Reverse a string (linear recursion)
def reverse_string(s):
    if len(s) <= 1:                         # base case: "" or one letter
        return s
    return reverse_string(s[1:]) + s[0]     # reverse the rest, first letter goes last
linewhat it means
if len(s) <= 1: return sCovers both the empty string and a single letter.
reverse_string(s[1:]) + s[0]s[1:] is one character shorter, so we always move towards the base case.

Dry run for "abc"

rev("abc") = rev("bc") + "a"   → "cba"
   └─ rev("bc") = rev("c") + "b" → "cb"
        └─ rev("c")  base          → "c"
deepest
rev("abc")rev("bc")rev("c") → "c"
end
rev("abc") → "cba"

Complexity: n calls in a chain. Each call also copies the slice s[1:], which costs up to n, so the time is really O(n²) in Python. The stack is O(n). (To keep it O(n), swap characters in a list with two pointers. That's the string pattern in Part C.)

5Pattern template

linear recursion template
def solve(n):
    if n == 0:                         # 1. smallest input: answer known directly
        return BASE_ANSWER
    smaller = solve(n - 1)             # 2. ONE call on a smaller input (leap of faith)
    return combine(n, smaller)         # 3. add my small piece on the way back up

6Remember

Remember linear recursionOne call per function → the stack is a straight line → n levels → usually O(n) time and O(n) stack. Practise these first, with the stack drawn on paper.

Part B · Pattern 2: Divide & conquer

1What the pattern is

Break the problem into two (or more) smaller sub-problems, solve each one recursively, then combine their answers to get the final answer. Breaking = divide, combining = conquer. If you have seen merge sort, you've already seen this pattern.

2How to recognise it

3Intuition

A manager splits a big job between two team members, waits for both reports, and combines them into one answer. Each team member does the same thing with their half, until the job is tiny enough to do directly.

Doubt: the teacher says this pattern takes "logarithmic time instead of linear". Is that always true?
→ Not quite. What's always logarithmic is the depth when we halve the input: about log₂ n levels. The total time depends on how much work each level does. Binary search drops one half, so it's O(log n). Merge sort does O(n) merging per level, so it's O(n log n). Tree sum visits every node, so it's O(n). The win over a linear chain is the stack depth (log n instead of n) and, for searching, skipping half the input.

4aExample: Binary search (recursive)

Question

Given a sorted list nums and a target, return its index, or −1 if it's missing. The teacher's description: if the target is smaller than the middle value, go to the left half. If it's greater, go to the right half. Instead of moving lo/hi pointers in a while loop, we pass the new range to a recursive call.

Base case

Recursive case

Target smaller than the middle → search (lo, mid − 1). Target larger → search (mid + 1, hi). Only one half is ever searched, because sorting tells us which half can't contain the target.

Binary search (divide & conquer)
def binary_search(nums, target, lo, hi):
    if lo > hi:                          # empty range: not found
        return -1
    mid = (lo + hi) // 2
    if nums[mid] == target:              # found it
        return mid
    if target < nums[mid]:               # target can only be on the left
        return binary_search(nums, target, lo, mid - 1)
    return binary_search(nums, target, mid + 1, hi)   # else only on the right

def search(nums, target):
    return binary_search(nums, target, 0, len(nums) - 1)
linewhat it means
if lo > hi: return -1The pointers crossed, so nothing is left to search. This also handles an empty list (hi = −1).
mid = (lo + hi) // 2The middle of the current range.
if target < nums[mid]: … (lo, mid - 1)The right half is all bigger than the target, so throw it away.
return binary_search(…, mid + 1, hi)The left half is all smaller, so throw it away.

Dry run: nums = [1, 3, 5, 7, 9, 11, 13], target = 11

bs(lo=0, hi=6)  mid=3, nums[3]=7  < 11 → go right    → returns 5
   └─ bs(lo=4, hi=6)  mid=5, nums[5]=11 = 11 found     → returns 5
  1. bs(0, 6): mid 3, value 7. 11 is bigger, so call bs(4, 6). bs(0,6) waits.
  2. bs(4, 6): mid 5, value 11 → found → returns 5.
  3. bs(0, 6) passes the 5 straight up → final answer 5 ✓
deepest
bs(0,6)bs(4,6) → 5
target 4 (missing)
bs(0,6)bs(0,2)bs(2,2)bs(2,1) → −1

Complexity

Each call halves the range: 7 → 3 → 1 → 0. That's at most about log₂ n + 1 calls → Time O(log n), stack O(log n).

Doubt: binary search makes only one call per level. Isn't that linear recursion?
→ The stack shape is a chain, yes. The teacher puts it under divide & conquer because of the idea: split the range in half and decide which half holds the answer. Some books call it "decrease and conquer". The name matters less than seeing that the depth is log n, not n.

4bExample: Merge sort

Question

Sort a list. The teacher mentions that you can write the merge step with pointers in a loop, but the splitting itself is a natural recursive call.

Base case

A list with 0 or 1 elements is already sorted → return it.

Recursive case

Divide: sort the left half and the right half (two calls, trusting both). Conquer: merge the two sorted halves by repeatedly taking the smaller front element.

Merge sort (divide & conquer)
def merge_sort(arr):
    if len(arr) <= 1:                    # base case: already sorted
        return arr
    mid = len(arr) // 2
    left = merge_sort(arr[:mid])         # divide: sort left half
    right = merge_sort(arr[mid:])        #         sort right half
    return merge(left, right)            # conquer: combine

def merge(a, b):
    out, i, j = [], 0, 0
    while i < len(a) and j < len(b):     # take the smaller front element
        if a[i] <= b[j]:
            out.append(a[i]); i += 1
        else:
            out.append(b[j]); j += 1
    out.extend(a[i:])                    # leftovers are already sorted
    out.extend(b[j:])
    return out
linewhat it means
if len(arr) <= 1: return arrThe smallest piece. Nothing to sort.
left = merge_sort(arr[:mid]) right = merge_sort(arr[mid:])Two calls: this is what makes the recursion tree branch.
return merge(left, right)The work happens on the way back up: two sorted halves become one sorted list.
while i < len(a) and j < len(b)Compare the fronts and take the smaller one. When one side runs out, copy the rest of the other.

Dry run: [5, 2, 4, 1]

                ms([5,2,4,1]) → [1,2,4,5]
               /                        \
     ms([5,2]) → [2,5]            ms([4,1]) → [1,4]
       /         \                  /          \
ms([5])→[5]  ms([2])→[2]     ms([4])→[4]  ms([1])→[1]
  1. ms([5,2,4,1]) splits and calls ms([5,2]) first. The top call waits.
  2. ms([5,2]) calls ms([5]) → base → [5]. Then ms([2]) → base → [2].
  3. ms([5,2]) merges [5] and [2] → [2,5] and returns.
  4. The top call now calls ms([4,1]) → ms([4]) → [4], ms([1]) → [1] → merge → [1,4].
  5. The top call merges [2,5] and [1,4]: 1, 2, 4, 5 → [1,2,4,5] ✓
step 2 (deepest, left side)
ms([5,2,4,1])ms([5,2])ms([5]) → [5]
step 4
ms([5,2,4,1])ms([4,1])ms([1]) → [1]

Complexity

Count by levels: the list halves each level, so there are about log₂ n levels. Each level merges n elements in total → Time O(n log n). Calls: about 2n − 1 (7 calls for n = 4). The stack is only log n tall, but the merged lists use O(n) extra memory.

4cExample: Sum of a tree (left subtree + right subtree)

Question

Return the sum of all values in a binary tree. The teacher's tree example: to know a node's subtree sum, get the left subtree sum and the right subtree sum (divide), then add them to the node's own value (combine).

A subtree is a node together with everything below it. An empty tree (None) has sum 0.

Tree sum (divide & conquer)
class TreeNode:
    def __init__(self, val=0, left=None, right=None):
        self.val, self.left, self.right = val, left, right

def tree_sum(root):
    if root is None:                     # base case: empty subtree
        return 0
    left = tree_sum(root.left)           # divide: left subtree sum
    right = tree_sum(root.right)         #         right subtree sum
    return root.val + left + right       # conquer: combine
linewhat it means
if root is None: return 0The bottom of every branch. Adding 0 changes nothing.
left = … / right = …Two calls, each trusted to return the correct sum of its side.
return root.val + left + rightThe combine step, done on the way back up.

Dry run

        1                 sum(1) = 1 + 6 + 3        → 10
       / \                  ├─ sum(2) = 2 + 4 + 0   → 6
      2   3                 │    ├─ sum(4) = 4+0+0  → 4
     /                      │    └─ sum(None)       → 0
    4                       └─ sum(3) = 3 + 0 + 0   → 3
deepest
sum(1)sum(2)sum(4)sum(None) → 0
right side
sum(1)sum(3) → 3

Complexity: one call per node plus one per empty child → Time O(n). The stack is as tall as the tree: log n when balanced, n when it's a straight line.

5Pattern template

divide & conquer template
def solve(problem):
    if is_small(problem):                    # 1. small enough: answer directly
        return direct_answer(problem)
    part1, part2 = split(problem)            # 2. divide
    a = solve(part1)                         # 3. trust both calls
    b = solve(part2)
    return combine(a, b)                     # 4. conquer: merge / add / decide

6Remember

Remember divide & conquerBreak → solve the parts → combine. Halving gives about log n levels. The total time = (work per level) × (number of levels): binary search O(log n), merge sort O(n log n), tree sum O(n).

Part C · Pattern 3: Recursion on strings

1What the pattern is

The recursion walks over a string, usually with an index or two pointers as parameters, and at every call it checks or computes something on the characters: do they match, should this one be removed, and so on. The answer (True/False, or a new string) is decided along the way.

2How to recognise it

Doubt: if the stack looks linear, why is this a separate pattern?
→ The teacher's reason: in string questions, the call isn't the whole story. Each call has to look at characters and decide (return False now? keep this letter or drop it?). That way of thinking is different enough to deserve its own bucket. Any single-call recursion that does this kind of work on a string goes here.

3Intuition

Replace the loop's l += 1; r -= 1 with a new call that receives l + 1, r - 1. The parameters do the pointer moving for you.

4aExample: Check if a string is a palindrome

Question

A palindrome reads the same forwards and backwards. The first character must equal the last, the second must equal the second-last, and so on. Return True or False.

Recursive case (the main check)

Pass the string and two pointers l (left) and r (right). If s[l] != s[r], it's not a palindrome → return False immediately. Otherwise this pair is fine, so call the same function with l + 1 and r - 1, and return whatever it returns.

Base case (how the teacher finds it)

In her dry run (a 6-letter word like "abccba", indices 0 to 5), the pairs (0,5), (1,4), (2,3) all match. The next call has l = 3 and r = 2: l has crossed r. Everything was already checked, so there's nothing left to compare. So she continues only while l < r. Otherwise she returns True, because reaching this point means every pair matched.

Palindrome check (recursion on a string)
def is_pal(s, l, r):
    if l >= r:                       # pointers met or crossed: all pairs matched
        return True
    if s[l] != s[r]:                 # one mismatch is enough
        return False
    return is_pal(s, l + 1, r - 1)   # the call moves both pointers inward

def is_palindrome(s):
    return is_pal(s, 0, len(s) - 1)
linewhat it means
if l >= r: return TrueCovers both lengths: odd length → l == r (the middle letter matches itself), even length → l > r (crossed). Also covers "" and one letter.
if s[l] != s[r]: return FalseThe real test. It stops at the first mismatch.
return is_pal(s, l + 1, r - 1)Instead of l += 1; r -= 1, the next call gets the moved pointers.

Dry run: "abccba"

is_pal(0,5)  'a'=='a' ✓                 → True
  └─ is_pal(1,4)  'b'=='b' ✓            → True
       └─ is_pal(2,3)  'c'=='c' ✓       → True
            └─ is_pal(3,2)  l > r base  → True
  1. (0,5): a = a → call (1,4).
  2. (1,4): b = b → call (2,3).
  3. (2,3): c = c → call (3,2).
  4. (3,2): l has crossed r → return True. Each waiting call passes True back up.
"abccba" deepest
is_pal(0,5)is_pal(1,4)is_pal(2,3)is_pal(3,2) → True
"abcxba"
is_pal(0,5)is_pal(1,4)is_pal(2,3) → False

Complexity: about n/2 + 1 calls → Time O(n), stack O(n). It's a straight chain, so this is linear recursion.

4bExample: Remove a character from a string

Question

The teacher lists "remove characters" in this pattern. A common version: remove every occurrence of a given character ch from s. Example: remove 'a' from "banana" → "bnn".

Base case & recursive case

Base: the empty string has nothing to remove → "". Recursive: solve the rest of the string (s[1:]), then decide about the first character. Drop it if it's ch, otherwise put it back in front.

Remove a character (recursion on a string)
def remove_char(s, ch):
    if s == "":                          # base case: nothing left
        return ""
    rest = remove_char(s[1:], ch)        # trust: rest is already cleaned
    if s[0] == ch:                       # decide about my character
        return rest
    return s[0] + rest
linewhat it means
if s == "": return ""The string got shorter by one each call, so it always reaches "".
rest = remove_char(s[1:], ch)The leap of faith: the rest comes back with no ch in it.
if s[0] == ch: return restThe decision step that makes this a "string" pattern question.

Dry run: remove 'a' from "bat"

rc("bat") 'b' kept   → "b" + "t" = "bt"
  └─ rc("at") 'a' dropped → "t"
       └─ rc("t") 't' kept → "t" + "" = "t"
            └─ rc("") base   → ""
deepest
rc("bat")rc("at")rc("t")rc("") → ""
end
rc("bat") → "bt"

Complexity: n + 1 calls. Each slice copies up to n characters, so the time is O(n²) in Python, and the stack is O(n). (Passing an index instead of slicing makes the call count the only cost.)

"Different ways to add parentheses" is also in the teacher's list for this pattern, but it splits at every operator and makes many calls, so it's really a non-linear question. It's solved later in the sheet.

5Pattern template

string recursion template (two pointers)
def check(s, l, r):
    if l >= r:                         # 1. nothing left to compare
        return True
    if not ok(s[l], s[r]):             # 2. decide at this step
        return False
    return check(s, l + 1, r - 1)      # 3. parameters move the pointers

6Remember

Remember string recursionPointers become parameters. Base for two pointers is l >= r (met for odd length, crossed for even length). Decide at each call: return early on a mismatch, or keep/drop a character.

Part D · Pattern 4: Recursion on stacks & linked lists

1What the pattern is

Changing a data structure (a stack, a queue, a linked list) using recursion instead of a second, helper data structure. We hold elements "in our hands" inside the waiting calls while we go down, and put them back in the order we want while we come back up.

2How to recognise it

3Intuition: why recursion helps

The teacher's example: reverse the stack 1, 2, 3, 4 (4 on top). The normal way uses a second stack: pop 4 and push it into stack 2, then 3, then 2, then 1. Stack 2 is now reversed, but we needed a whole extra stack.

With recursion, we do nothing on the way down except pop and hold each element in a waiting call. On the way back up, we put the elements back in the order we want. No second stack is created.

Doubt: the teacher says recursion "saves extra space". Is the memory really zero?
→ No extra data structure is created, and that's what these questions ask for. But each waiting call still holds one element on Python's call stack, so the memory is still O(n). The difference is that the call stack holds it for us. Mention this in interviews.

4aExample: Reverse a stack using recursion

Question

Reverse a stack (a Python list, where the end of the list is the top) using only append / pop and recursion. No second stack. The teacher only describes the idea in this video. The full code is here so you can trace it.

Base case & recursive case

Reverse a stack (recursion on a stack)
def insert_at_bottom(st, x):
    if not st:                       # empty: x becomes the bottom
        st.append(x)
        return
    top = st.pop()                   # hold the top in this call
    insert_at_bottom(st, x)          # put x under the rest
    st.append(top)                   # on the way back up, restore top

def reverse_stack(st):
    if not st:                       # base case: nothing to reverse
        return
    x = st.pop()                     # hold the top
    reverse_stack(st)                # trust: the rest is now reversed
    insert_at_bottom(st, x)          # old top goes to the bottom
linewhat it means
x = st.pop()The way down: each call keeps one element in its local variable. This is our "hidden extra stack".
reverse_stack(st)Leap of faith: after this, the smaller stack is reversed.
insert_at_bottom(st, x)The way back up: the old top must become the new bottom.
insert_at_bottom: pop, recurse, appendDig down to the bottom, drop x there, then rebuild the stack on top of it.

Dry run: [1, 2, 3] (3 on top)

rev([1,2,3]) holds 3            → stack [3,2,1]
  └─ rev([1,2]) holds 2         → stack [2,1]
       └─ rev([1]) holds 1      → stack [1]
            └─ rev([]) base     → returns
          then insert_at_bottom(1) on []     → [1]
     then insert_at_bottom(2) on [1]         → [2,1]
then insert_at_bottom(3) on [2,1]            → [3,2,1]
  1. Going down: rev pops 3, then 2, then 1. The list is now empty. Three calls are waiting, holding 3, 2, 1.
  2. rev([]) returns.
  3. The call holding 1 inserts it at the bottom of [] → [1].
  4. The call holding 2 inserts it at the bottom: pop 1, place 2, push 1 back → [2, 1].
  5. The call holding 3 inserts it at the bottom → [3, 2, 1]. Now 1 is on top. Reversed ✓
deepest (list is empty)
rev, x=3rev, x=2rev, x=1rev([]) → return
step 5, inside insert
rev, x=3iab(3), top=1iab(3), top=2iab(3) on [] → push 3

Complexity

reverse_stack makes n + 1 calls. Each one calls insert_at_bottom, which digs through up to n elements: 1 + 2 + … + n ≈ n²/2 calls → Time O(n²). The deepest stack is about 2n calls → Space O(n) on the call stack, with no extra list.

4bExample: Delete the bottom (and the middle) element of a stack

Question

Deleting the top is just pop(). The teacher asks how to delete the bottom one: recursively go down to the last element, remove it, then put all the others back while returning. The same trick deletes the middle element: stop after the right number of pops instead of at the bottom.

Base case & recursive case

Delete bottom / middle of a stack
def delete_bottom(st):
    if not st:                       # empty: nothing to delete
        return
    if len(st) == 1:                 # base case: this IS the bottom
        st.pop()
        return
    top = st.pop()                   # hold it on the way down
    delete_bottom(st)
    st.append(top)                   # put it back on the way up

def delete_k_from_top(st, k):
    if k == 0:                       # reached the element to delete
        st.pop()
        return
    top = st.pop()
    delete_k_from_top(st, k - 1)
    st.append(top)

def delete_middle(st):
    if st:
        delete_k_from_top(st, len(st) // 2)
linewhat it means
if len(st) == 1: st.pop()The last element left is the original bottom.
top = st.pop() … st.append(top)Hold on the way down, restore on the way up, so every other element ends in its old place.
delete_k_from_top(st, len(st) // 2)Skip n // 2 elements from the top, then delete the next one.

Dry run: delete_bottom([1, 2, 3, 4]) (4 on top)

db([1,2,3,4]) holds 4   → pushes 4 back → [2,3,4]
  └─ db([1,2,3]) holds 3 → pushes 3 back → [2,3]
       └─ db([1,2]) holds 2 → pushes 2 back → [2]
            └─ db([1])  base: pop 1     → []
deepest
db, top=4db, top=3db, top=2db([1]) → pop 1
end
list = [2, 3, 4]

Complexity: n calls going down and n pushes coming back → Time O(n), call stack O(n), no extra list. delete_middle is the same, with about n/2 calls.

4cExample: Merge two sorted linked lists (recursively)

Question

LeetCode 21. Merge two sorted linked lists into one sorted list. The teacher mentions it as a linked list question that can also be solved by recursion.

Base case & recursive case

Base: if one list is empty, the answer is the other list. Recursive: the smaller head goes first. Its next becomes "the merge of everything that's left" (trust the call).

Merge two sorted lists (recursion on a linked list)
class ListNode:
    def __init__(self, val=0, next=None):
        self.val, self.next = val, next

class Solution:
    def mergeTwoLists(self, l1, l2):
        if l1 is None:                   # base: nothing left in l1
            return l2
        if l2 is None:                   # base: nothing left in l2
            return l1
        if l1.val <= l2.val:             # l1's head is smaller: it goes first
            l1.next = self.mergeTwoLists(l1.next, l2)
            return l1
        l2.next = self.mergeTwoLists(l1, l2.next)
        return l2
linewhat it means
if l1 is None: return l2Whatever remains of l2 is already sorted, so attach it as it is.
l1.next = self.mergeTwoLists(l1.next, l2)The links are set on the way back up, when each call returns the head of its merged part.

Dry run: 1→3 and 2→4

m(1→3, 2→4)  1 smaller → 1.next = m(3, 2→4)   → returns 1→2→3→4
  └─ m(3, 2→4)  2 smaller → 2.next = m(3, 4)  → returns 2→3→4
       └─ m(3, 4)  3 smaller → 3.next = m(None, 4) → returns 3→4
            └─ m(None, 4) base               → returns 4
deepest
m(1,2)m(3,2)m(3,4)m(None,4) → 4
end
m(1,2) → 1→2→3→4

Complexity: one call per node → Time O(n + m), call stack O(n + m). (The loop version needs only O(1) memory. For very long lists, raise the recursion limit or use the loop.)

5Pattern template

stack recursion template (hold on the way down, place on the way up)
def fix(st):
    if base_condition(st):            # 1. empty / one element / reached target
        handle_base(st)
        return
    held = st.pop()                   # 2. way down: hold one element
    fix(st)                           # 3. trust the smaller stack
    put_back(st, held)                # 4. way up: place it where it belongs

6Remember

Remember stack / linked list recursionPop and hold going down, place going up. No second stack is needed. Reverse = pop + reverse rest + insert at bottom (O(n²)). Delete bottom/middle = pop + recurse + push back.

Part E · Revision page

patternshape of the callsspot it byexamplestypical time
1. Linearone call, straight chainn → n − 1factorial, sum 1..n, reverse a stringO(n)
2. Divide & conquersplit into 2+ parts, then combinehalves / left + right subtreesbinary search, merge sort, tree sumlog n levels: O(log n), O(n log n), O(n)
3. Stringsusually one call, decides at each steppointers or index on a stringpalindrome, remove chars, (add parentheses)O(n)
4. Stack / linked listhold on the way down, place on the way up"without an extra stack"reverse stack, delete bottom/middle, merge listsO(n) to O(n²)
work done going downwork done coming up
factorial / sumnothingmultiply / add
palindromecompare a pair (may stop early)pass True/False back
merge sortsplitmerge
reverse stackpop and holdinsert at bottom
If you remember only 5 lines 1. Recursion = trust the same function on a smaller input, then do one small step.
2. Always 2 parts: a base case (stop) and a recursive call (shrink).
3. Linear: one call, straight stack, O(n). Start practising here.
4. Divide & conquer: split, solve, combine. Halving gives log n levels.
5. Strings: pointers become parameters. Stacks: hold going down, place going up.
Mistakes to avoid ✗ no base case, or a base case the input never reaches (RecursionError)
✗ tracing every call in your head instead of trusting the smaller call
✗ palindrome base as l == r only (fails for even length, use l >= r)
✗ thinking divide & conquer is always O(log n) (merge sort is O(n log n))
✗ claiming stack recursion uses zero memory (the call stack is still O(n))
✗ forgetting Python's ~1000 depth limit for large n
test it yourself (paste under the solutions above)
print(fact(5), sum_n(3), reverse_string("abc"))          # 120 6 cba
print(search([1, 3, 5, 7, 9, 11, 13], 11))               # 5
print(merge_sort([5, 2, 4, 1]))                          # [1, 2, 4, 5]
print(tree_sum(TreeNode(1, TreeNode(2, TreeNode(4)), TreeNode(3))))   # 10
print(is_palindrome("abccba"), is_palindrome("abcxba"))  # True False
print(remove_char("banana", "a"))                        # bnn
st = [1, 2, 3]; reverse_stack(st); print(st)             # [3, 2, 1]
st = [1, 2, 3, 4]; delete_bottom(st); print(st)          # [2, 3, 4]
st = [1, 2, 3, 4, 5]; delete_middle(st); print(st)       # [1, 2, 4, 5]

Based on this video: Recursion Patterns