Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

Recursion

Open in Colab

Recursive Functions

Definition 1 A function is recursive if it is recursive.

Definition 2 A function is recursive if it refers to itself.

It is probably easier to see some examples.

Divide and Conquer

A common situation in which recursion is used is in Divide and Conquer algorithms.

The basic idea is to break up a problem into two (or more) sub-problems and combine the answer to get the final solution

Finding the maximum element of a list

One way to find the maximum element of a list A is to find the maximum element in the first half of the list, and find the maximum element in the second half of the list, and then take the maximum of these two values.

How do we find the maximum element of each half of the list? We can use this function recursively to break up the problem into smaller and smaller pieces.

At some point, the pieces will be trivial to solve. In this case, we don’t recurse further, we just solve the problem explicitly. For this problem, we’ll just solve the problem directly if the list is length 3 or less by using the built-in max function:

Mergesort

Mergesort is an example of a sorting algorithm. The input is a list a, and the output is a list b which has the same elements as a, but appearing in sorted order.

Python has the sorted function built-in to sort iterables like lists:

There are a variety of sorting algorithms which use different techniques.

Merge sort uses the observation that if two arrays a1 and a2 are already sorted, that it is easy to merge the two arrays into a single sorted array in a single loop

The divide-and-conquer strategy is to start with an input list a, sort the left and right halves, and then merge the two halves to sort a. We can employ recursion by using merge sort to sort each half. By definition, an list with 1 or no elements is already sorted.

Quicksort

Quicksort was named by SIAM editors as one of the top-10 algorithms of the 20th century. Like mergesort, it also uses a divide and conquer strategy.

Quicksort works by partitioning a list into two halves divided by a pivot. All elements less than the pivot are moved to the first part of the list, and all elements greater than the pivot are moved to the second part of the list.

Binary search seeks to find where an element x appears in a sorted list a. The divide and conquer idea is to look at the element i=n//2 of the center of the list. If x = a[i] then we’re done. Otherwise, we can recurse into either the left-hand or right-hand side of the list depending on whether x<a[i] or x>a[i]. We should also handle the case where x does not appear in the list - in this case, we return -1.

Analysis of Recursive Algorithms

Recursive algorithms are usually easy to implement, but they aren’t always fast. You’ll see an example in homework 0.

On the other hand, mergesort has optimal asymptotic complexity for a sorting algorithm: O(nlog⁡n)O(n\log n)

if quicksort is very unlucky with choice of pivot, it can take O(n2)O(n^2) time, but its expected runtime is O(nlog⁡n)O(n \log n). It also does all operations in-place, unlike mergesort, which uses auxillary memory.

The Master Theorem

The master theorem helps us to obtain asymptotic time complexity for divide and conquer algorithms. The first step is to write down the time it takes to run a function on input size nn, T(n)T(n), in terms of the time it takes to run subproblems

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

where f(n)f(n) is the work done to stitch the subproblems together.

The critical exponent is defined as c=log⁡bac = \log_b a. The amount of work to complete all trivial subproblems is Θ(nc)\Theta(n^c). There are three regimes:

  1. f(n)=o(nc)f(n) = o(n^c). In this case, the work to do the subproblems dominate, and the time complexity is T(n)=Θ(nc)T(n) = \Theta(n^c).

  2. f(n)=Θ(nc)f(n) = \Theta(n^c). In this case, the work to do subproblems and combine them is comparible. The complexity is T(n)=Θ(nclog⁡n)T(n) = \Theta(n^c \log n)

  3. f(n)=ω(nc)f(n) = \omega(n^c). In this case, the work to combine subproblems dominates, and the complexity is T(n)=Θ(f(n))T(n) = \Theta(f(n)).

For example, in our maximum function, we split into two subproblems of size n/2n/2, so a=2,b=2a = 2, b=2, and c=1c = 1. f(n)f(n) is the time to take the max of two numbers, so is O(1)O(1). This means we are in case 1 above, and the time complexity of maximum is Θ(nc)=Θ(n)\Theta(n^c) = \Theta(n).

Let’s apply the Master theorem to our binary_search function. It takes a constant amount of work to compute the center index of the array a between lo and hi, and perform the comparison of a[i] with x. We then recurse into (at most) one subproblem of half the size. So in the above expression, a=1a = 1, b=2b = 2, and f(n)f(n) is O(1)O(1). c=log⁡21=0c = \log_2 1 = 0 so ncn^c is O(1)O(1) and thus f(n)=Θ(nc)f(n) = \Theta(n^c). We can conclude that the time complexity of binary_search is Θ(nclog⁡n)=Θ(log⁡n)\Theta(n^c \log n) = \Theta(\log n).

Exercise

Apply the master theorem to obtain the complexity of merge sort.

O(nlog⁡n)O(n \log n)

Exercise

Compare the runtime of mergesort and quicksort.

Exercise

Implement the correlation coefficient defined in this paper