I think there are two separate aspects to understanding recursion:
- How to use recursion to solve problems
- How recursion is evaluated
How to use recursion
Let's look at an example: maximum depth of a binary tree (where a tree with a single node is depth 1). A recursive solution in python might look like this:
def max_depth(root):
if root == None:
return 0
else:
return 1 + max(max_depth(root.left), max_depth(root.right))
You don't need to think about how this is actually evaluated; you just define it how you would mathematically. If I somehow know that the maximum depth of the left branch is $x$ and the maximum depth of the right branch is $y$ and that $x > y$ (without loss of generality), then I know that the maximum depth of the entire tree is $1 + x$. This way of thinking about problems is one way you can use recursion.
Of course, you need to make sure that the inputs to the recursive calls are in some way smaller than the input to the function that makes the recursive call, which segues into how recursion is evaluated.
How recursion is evaluated
To think about how recursion is evaluated, we can use the same example. In the example above, there are pending recursive calls, waiting to return (note that recursion does not necessarily have this, see tail call). To see this, suppose our tree looks like this
O <- root
/ \
O O
/ \
O O
and you have a variable r storing the root. If you call max_depth(r), it will try to evaluate the else clause; it will evaluate max(max_depth(r.left), ...) and then add 1 to that result. To the r.left recursive call, the input looks like this
O
/ \
O O
And for the r.right recursive call, it is simply this:
O
The r.left recursive call evaluates else clause, which means it tries to evaluate the max depth of the left and right subtrees, and add 1 to whichever is greater.
The r.right recursive call also evaluates the else clause.
So at this point you think of the return value of the original call to max_depth looking like this:
return 1 + max(
1 + max(max_depth(root.left.left), max_depth(root.left.right),
1 + max(max_depth(root.right.left), max_depth(root.right.right))
)
Both recursive calls of the left branch have trees that look like this now
O
while both recursive calls of the right branch, since it was just a node without any children, have trees that are empty, which is None in python. Thus, they fall to the base-case, which means it is evaluated as 0.
So now the original return value is like this
return 1 + max(
1 + max(
1 + max(max_depth(root.left.left.left), max_depth(root.left.left.right)),
1 + max(max_depth(root.left.right.left), max_depth(root.left.right.right))
),
1 + max(
0,
0
)
)
All recursive paths from the right branch already reached the base-case, and now on this recursive call, all children of the left branch will as well, which means the return value of the original function is now
return 1 + max(
1 + max(
1 + max(
0,
0
),
1 + max(
0,
0)
),
1 + max(
0,
0
)
)
which evaluates to
return 1 + max(
1 + max(
1 + 0,
1 + 0
),
1 + 0
)
and
return 1 + max(
1 + 1,
1 + 0
)
and
return 1 + 2
finally
3
Note, this evaluation was not performed according to any formal evaluation model (see substitution model or environment model); it was merely to help build intuition for how evaluation could be done.
Extra (tail-recursion): I mentioned earlier about how recursion does not necessarily have these pending function calls that much be recursed back to once they finally have the result of the recursive call. Consider a recursive function to find the sum of a list (ignoring the python built-in sum()):
def sum_list(lst):
if lst == []:
return 0
else:
return lst[0] + sum_list(lst[1:])
This indeed would have pending recursive calls, as it waits to add lst[0] to the result of the recursive calls. We can make this tail-recursive by accumulating the sum of the list in an argument that is simply returned unmodified at the end:
def sum_list_tail_recursive(lst, result):
if lst == []:
return result
else:
return sum_list_tail_recursive(lst[1:], result + lst[0])
Once this evaluation reaches the base-case, it has the the result of the original call to sum_list(), and it does not need to be added to, or modified in any way as you recurse back up.