Programming 1, 2020-1
Lecture 5: Notes

Some of today's topics are covered in these sections of Think Python:

And in these chapters of Introducing Python:

Here are some additional notes.

Conditional expressions

In Python, you may use if and else inside an expression; this is called a conditional expression.

For example, consider this code:

if x > 100:

  print('x is big')

else:

  print('x is not so big')

We may rewrite it using a conditional expression:

print('x is ' + ('big' if x > 100 else 'not so big'))

As another example, here is a function that takes two integers and returns whichever has the greater magnitude (i.e. absolute value):

def greater(i, j):

 if abs(i) >= abs(j):
   return i
 else:
   return j

We can rewrite it using a conditional expression:

def greater(i, j):
  return i if abs(i) >= abs(j) else j

(If you already know C or another C-like language, you may notice this feature's resemblance to the ternary operator (? :) in those languages.)

recursion

A recursive function calls itself. Recursion is a powerful technique that can help us solve many problems.

When we solve a problem recursively, we express its solution in terms of smaller instances of the same problem. The idea of recursion is fundamental in mathematics as well as in computer science.

As a first example, consider this function that computes n! for any positive integer n. (Recall that n! = n · (n – 1) · … · 3 · 2 · 1.) It uses a loop, so this is an iterative function:

def factorial(n):
    p = 1

    for i in range(1, n + 1):
        p *= i

    return p

We can rewrite this function using recursion. Now it calls itself:

def factorial(n):
    if n == 0:
        return 1     # 0! is 1
    
    return n * factorial(n - 1)

Whenever we write a recursive function, there is a base case and a recursive case.

The base case is an instance that we can solve immediately. In the function above, the base case is when n == 0. A recursive function must always have a base case – otherwise it would loop forever since it would always call itself.

In the recursive case, a function calls itself recursively, passing it a smaller instance of the given problem. Then it uses the return value from the recursive call to construct a value that it itself can return.

In the recursive case in this example, we recursively call factorial(n - 1). We then multiply its return value by n, and return that value. That works because

n! = n · (n – 1)! whenever n ≥ 1

(In fact, we may treat this equation as a recursive definition of the factorial function, mathematically speaking.)

We have seen that we can write the factorial function either iteratively (i.e. using loops) or recursively. In theory, any function can be written either iteratively or recursively. We will see that for some problems a recursive solution is easy and an iterative solution would be quite difficult. Conversely, some problems are easier to solve iteratively. Python lets us write functions either way.

Here's another example. Consider the implementation of Euclid's algorithm that we saw in an earlier lecture:

# gcd, iteratively
def gcd(a, b):
    while b > 0:
        a, b = b, a % b

    return a

We can rewrite this function using recursion:

# gcd, recursively
def gcd(a, b):
    if b == 0:
        return a

    return gcd(b, a % b)

Broadly speaking, we will see that "easy" recursive functions such as gcd call themselves only once, and it would be straightforward to write them either iteratively or recursively. Soon we will see recursive functions that call themselves two or more times. Those functions will let us solve more difficult tasks that we could not easily solve iteratively.

For now, here is another example of a recursive function:

def hi(x):
    if x == 0:
        print('hi')
        return
    
    print('start', x)
    hi(x - 1)
    print('done', x)

If we call hi(3), the output will be

start 3
start 2
start 1
hi
done 1
done 2
done 3

Be sure you understand why the lines beginning with 'done' are printed. hi(3) calls hi(2), which calls hi(1), which calls hi(0). At the moment that hi(0) runs, all of these function invocations are active and are present in memory on the call stack:

hi(3)
→ hi(2)
  → hi(1)
    → hi(0)

Each function invocation has its own value of the parameter x. (If this procedure had local variables, each invocation would have a separate set of variable values as well.)

When hi(0) returns, it does not exit from this entire set of calls. It returns to its caller, i.e. hi(1). hi(1) now resumes execution and writes 'done 1'. Then it returns to hi(2), which writes 'done 2', and so on.

Here is another recursive function:

def sum_n(n):
    if n == 0:
        return 0
  
    return n + sum_n(n - 1)

What does this function do? Suppose that we call sum(3). It will call sum(2), which calls sum(1), which calls sum(0). The call stack now looks like this:

sum(3)
→ sum(2)
  → sum(1)
    → sum(0)

Now

We see that given any n, the function returns the sum 1 + 2 + 3 + … + n.

We were given this function and had to figure out what it does. But more often we will go in the other direction: given some problem, we'd like to write a recursive function to solve it. How can we do that?

Here is some general advice. To write any recursive function, first look for base case(s) where the function can return immediately. (As we will soon see, a function may sometimes have more than one base case.) Now you need to write the recursive case, where the function calls itself. At this point you may wish to pretend that the function "already works". Write the recursive call and believe that it will return a correct solution to a subproblem, i.e. a smaller instance of the problem. Now you must somehow transform that subproblem solution into a solution to the entire problem, and return it. This is really the key step: understanding the recursive structure of the problem, i.e. how a solution can be derived from a subproblem solution.