In the past two sections, we introduced two recursive sorting algorithms, mergesort and quicksort. We’ll now analyse the running time of these two algorithms, which will use the same technique that we introduced in 15.4 Running-Time Analysis for Tree Operations. As a reminder before we begin, here is the approach we use for this type of running-time analysis:
Recall the mergesort algorithm:
def mergesort(lst: list) -> list:
if len(lst) < 2:
return lst.copy() # Use the list.copy method to return a new list object
else:
# Divide the list into two parts, and sort them recursively.
= len(lst) // 2
mid = mergesort(lst[:mid])
left_sorted = mergesort(lst[mid:])
right_sorted
# Merge the two sorted halves. Using a helper here!
return _merge(left_sorted, right_sorted)
Let’s now analyse the running time of this algorithm. First we need
to analyse the structure of the recursive calls made. Suppose we start
with a list of length mergesort
taking as
input a list of length mergesort
is called on a list of
length 1.
For example, here are the lists that would be passed as inputs to
mergesort
from an initial call to mergesort
on
the list [3, -1, 7, 10, 6, 2, -3, 0]
.
In other words, our recursive call diagram is a binary tree: every
non-base-case call to mergesort
makes two more recursive
calls, one of the left half of its input and one on the right half. But
that’s not all: because we know that the input list decreases in size by
a factor of 2 at each recursive call, we know what the height of the
tree is. If we start with an input list of height
So in general our recursion diagram for mergesort looks like this:
Next, let’s analyse the non-recursive running time of
mergesort
. Consider a list lst
of length
The if condition check (len(lst) < 2
) and
calculation of mid
take constant time.
The list slicing operations lst[:mid
] and
lst[mid:]
each take time proportional to the length of the
slice, which is
We’ll leave the running-time analysis of _merge
as
an exercise, but the running time is
So in the recursive step, left_sorted
and
right_sorted
each have size _merge(left_sorted, right_sorted)
is
So the total non-recursive running time of mergesort
is
(Note: when
Now that we have this non-recursive running time, we can fill in our
recursion tree. We’re going to make another simplification to our
analysis: we’ll use
mergesort
call on a list of length mergesort
calls on a list of
length mergesort
calls on a list
of length
Okay, so we’ve filled in our tree; the last remaining step is to add up all of the numbers. This might look daunting at first, since we have several different numbers to add up, so we can’t simply multiply the number of nodes by the non-recursive running time of a single one.
Here is our key observation: each level in the tree has
nodes with the same running time, and since this is a binary tree we can
find a simple formula for the number of nodes at any depth in the tree.
More precisely, at depth
Now we can use our previous observation that there are
Now let’s turn our attention to quicksort. Is it possible to do the same running-time analysis that we did for mergesort? Not quite.
def quicksort(lst: list) -> list:
if len(lst) < 2:
return lst.copy()
else:
# Partition the list using the first element as the pivot
= lst[0]
pivot = _partition(lst[1:], pivot)
smaller, bigger
# Sort each part recursively
= quicksort(smaller)
smaller_sorted = quicksort(bigger)
bigger_sorted
# Combine the two sorted parts. No need for a helper here!
return smaller_sorted + [pivot] + bigger_sorted
First let’s start with the parts that are similar. The
non-recursive running time of quicksort is also lst[1:]
is relatively inefficient, it still just takes
The key difference is that with mergesort we know that we’re
splitting up the list into two equal halves (each of size
Suppose we get lucky, and at each recursive call we choose the pivot
to be the median of the list, so that the two partitions both have size
(roughly)
But this relies on making an assumption about our choice of pivot.
Consider another possibility: what if we’re always extremely unlucky,
and always choose the smallest list element? In this case, the
partitions are as uneven as possible, with smaller
having
no elements, and bigger
having all remaining
In this case, the height of the tree is bigger
partition just decreases by 1 at each call. There
are smaller
). Adding the total non-recursive
running time gives us:
And so for this case, the running time is actually
In fact, these two cases mark the extremes of the running time ranges
for quicksort. It’s possible to show (with a more formal analysis) that
the worst-case running time for this implementation of quicksort is
Given the running-time analyses of mergesort and quicksort we’ve done in the previous section, you might wonder whether quicksort why quicksort would ever be used over mergesort. In practice, however, there are a few complexities that make things less clear cut.
The main strength of asymptotic Theta notation is that is simplifies
our running-time analyses, so that we don’t need to worry about getting
constant factors exactly right. But this same simplification also has
the effect of flattening reported running times, so that all
In fact, with some more background in probability theory, we can talk
about the performance of quicksort on a random list of length
There are some other interesting points we’ll make before wrapping
up. The first is that there actually exists a linear-time algorithm for
computing the median of a list of
numbers.This is known as the median of medians
algorithm, which we encourage you to look up! This means that we
can always choose the median of the list as the pivot in quicksort’s
partitioning step, making the worst-case running time
And finally, we would be remiss if we didn’t answer the question that
must be on your mind: what sorting algorithm do Python’s built-in
sorted
and list.sort
functions use?! It
turns out that Python uses a sorting algorithm known as Timsort,
which was invented by Tim Peters in 2002 specifically for the Python
programming language. You might be disappointed that we didn’t cover
that algorithm in this course, but in fact, Timsort is essentially a
highly optimized version of mergesort. Timsort uses the same basic idea
of merging sorted sublists, but does so in a much smarter and more
efficient way than our mergesort
implementation, and adds a
few tweaks based on empirical tests. One of those tweaks is switching to
insertion sort to sort small sublists, which again reveals how
our asymptotic worst-case running time doesn’t tell the full story.