Pivoting To Understand Quicksort [Part 2]
This is the second installment in a two-part series on Quicksort. If you haven’t read Part 1 of this series, I recommend checking that out first!
In part 1 of this series, we walked through how the quicksort algorithm works on a high level. In case you need a quick refresher, this algorithm has two important aspects: a pivot element, and two partitions around the pivot.
We’ll remember that quicksort functions by choosing a pivot point (remember, this is only somewhat random!), and sorting the remaining elements so that items smaller than the pivot are to the left, or in front of the pivot, and items that are larger than the pivot are to the right, or behind the pivot. These two halves become the partitions, and the algorithm recursively calls itself upon both of these partitions until the entire list is divided down into single-item lists. Then, it combines them all back together again.
Quicksort is widely-used and researched, and is often synonymous with the the phrase “most efficient algorithm”. However, because there’s just so much to know about this algorithm, even though we covered a lot of ground last week, we still had so many unanswered questions!
Today, we’ll get to the bottom of these mysteries and try to understand what makes quicksort so lightning-quick…and why, sometimes, it isn’t!
Good algorithm gone bad
Something we keep hearing again and again is that the pivot is a somewhat arbitrary element in the unsorted list. We know that what we choose as the pivot can be slightly random, but we should really be aiming for the “middle-most” element, or close to the median of the entire collection.
One of the reasons that this is important is because we want our two partitioned halves — the ones that we’ll call quicksort on recursively — to be equal in size. Last week, we learned that two unequally-sized partitions can be super problematic. But…we still don’t know why!
Time to find out. The best way to illustrate what why this is a problem is by using an example, of course! In the illustration below, we have a list that’s actually nearly sorted. Let’s say that we choose the last element,
16, as our pivot element.
We’ll create two pointer references in order to do our swapping, and compare all of the elements to the pivot.
But guess what? All of the elements are smaller than our pivot! So, we’re not really doing any swapping here, are we?
Okay, this is still fine, we’re doing (n-1) comparisons here, where n is the total number of elements to be sorted.
But what happens the second time around? Well, our pivot is
15. Again, as we start to compare all the elements to it, almost all of them are smaller than the pivot element.
Not only are our partitions lopsided here, but there’s something else bad going on, too! We’re making only one less comparison with each pass through the array. That’s not that great, is it? In fact, it’s terrible! If you’re cringing at this and feeling like you’ve seen this before — well, you’re right! The number of comparisons we’re making here is actually rather reminiscent of the bubble sort algorithm, which had a quadratic runtime, or O(n²).
If we continued sorting our list here, we’d eventually execute n² comparison operations. That’s not ideal, at all — which is exactly why it’s a great example of what happens if we don’t choose a pivot correctly! If we choose a pivot that doesn’t fall close to the median of the collection that we’re trying to sort, we’ll have to compare it to almost all of the elements, since they’ll either all be mostly larger or smaller than the pivot.
There are some cases where we’re likely to fall into the trap of choosing a pivot that’s not near the median of a dataset. In fact, we kind of fell right into this trap just now!
For arrays that are nearly or completely sorted, quicksort operates at its worst. In other words, the average runtime for a sorted or a nearly-sorted list is quicksort’s worst case runtime: O(n²).
This is particularly important to remember if we know that we’ll be dealing with data that’s mostly sorted. In those situations, we want to stay away from quicksort, since it’s going to take a really long time to run.
Okay, now that we’ve gone through the in’s and out’s of swapping elements in the two partition halves and choosing a pivot element, let’s look at a quicksort algorithm in action!
console.log’s that’ll come in handy when we run the code in just a minute.
So, let’s get down to business. We’ll start by looking at the external-most function, first. This is the actual
quickSort function itself, which takes in three arguments: an array of
items to be sorted, and a
rightIndex, which are the left and right references to keep track of which elements will (potentially) be swapped.
There are quite a few things going on in this
quickSort function, so let’s break it down. First, we declare an
index variable that will be our pivot reference. Then, we only actually run this algorithm if the array of
items being passed in has more than one item in it, which makes sense because if it’s empty or if there’s only one element, we wouldn’t want to even bother sorting it!
We’ll notice that there are two
if conditional statements within the outer
if: the logic in both of these is what is responsible for determining whether the
rightIndex reference and
leftIndex reference need to be compared still, or whether they can be recursively
quickSorted into smaller parts.
But what about the work of partitioning? Well, there’s a function for that! We’ll notice that we’re invoking
partition on line 9. This is what that function might look like:
As the comments explain, the
partition function takes in the
items that we’re trying to partition, and the
right indices that represent the start and end of the list of items.
We can see that we have two
while loops nested inside of a larger
while loop. This is where we do the work of comparing the
right references to the pivot, which is ultimately what is responsible for determining whether two items need to be swapped.
We can also see that the
partition function is calling out to the
swap function! We looked at this briefly last week; this is what the
swap function does:
Compared to the two larger functions we just saw, the
swap function has very little responsibility: it creates a
tempReference that it uses in order to swap two elements at two index, that we pass into it as arguments. This is a really nice little bit of logic encapsulation, since it doesn’t have any concept of the
partition functions at all!
Alright, let’s try and run this code. We’ll try our hand at sorting an array of items that looks like this:
[19, 22, 63, 105, 2, 46].
Awesome! So, what’s going on here? Well, to start, we pass in our array, as well as both its start and end index to the external
quickSort algorithm. We can see that this particular implementation of quicksort chooses the element at the middle of this list as its pivot; in our case, that element is
63. Recall that it doesn’t really matter whether the pivot is the first or the last or middle element — just as long as we’re not dealing with the worst case sorting for quicksort.
Notice how we keep incrementing the left pointer until we hit a scenario where the element at the
left reference is greater than the element located at the right reference. We can see this happening if we watch how the value of
left keeps changing. On line 5,
left is pointing to
19; on line 7, we see that this reference is incremented to point to
22. It is only when
left is pointing to
63 and right is pointing to
46 that the algorithm is actually in a position to swap — which is exactly what we do on line 9. Once we understand how that first pass through the array is functioning, it should hopefully be a little bit easier to discern what’s happening in the subsequent passes.
This is, of course, only one way to go about implementing quicksort, and I tend to like it because it’s mostly easy to follow what’s going on. We could also write this same algorithm functionally and imperatively. And we could even try our hand at implementing it iteratively, without using recursion directly. But that’s probably a challenge for another day!
Weighing the cost-benefit of sorting
Now that we’re a bit more well-versed in the implementation(s) of quicksort, there’s one last bit to cover: just how good is quicksort — and when would we want to use it?
Let’s start be taking a look at how the quicksort algorithm compares to its peers. We’re already familiar with the time complexity of quicksort. It’s average performance and best-case performance at runtime are the same as merge sort: it runs in linearithmic time, or O(n logn). So what other defining characteristics does this algorithm have?
Well, based upon my research, quicksort’s space complexity is a bit of a discussion. In case we need a refresher, space complexity is determined by how much additional memory or space an algorithm needs in order to run. The dilemma around quicksort is that it does require a certain amount of extra space — the space for each recursive algorithm to allocate
right pointer references as it continues to partition and sort the array down to single-element lists.
Here’s the rub, however: even though quicksort needs more than the traditional constant, or O(1), amount of space, it doesn’t need a whole lot more space than that. It requires an extra O(log²n) amount of space, which is still pretty small, but technically it’s more than O(1) constant space. Some people seem to think that this makes it an out-of-place algorithm, but most of the texts and course material that I’ve read imply that algorithms which require a minimal amount of space, including O(log²n), still count as in-place. So we’ll say that quicksort is an in-place sorting algorithm.
Quicksort has one major difference with merge sort, and this ends up being a defining point between these otherwise super-similar algorithms. This is the fact that quicksort is an unstable algorithm, meaning that it won’t be guaranteed to preserve the order of elements as it reorders; two elements that are exactly the same in the unsorted array could show up in a reversed order in the sorted one! Stability ends up being what causes people to choose merge sort over quicksort, and it’s the one area where merge sort appears as the obvious winner.
Similarly, because quicksort can sort items without creating a new or duplicated list, it doesn’t require external memory; instead, it can store its entire dataset without using external memory, making it an internal sorting algorithm. We already know that quicksort tends to use recursion to all itself from within itself, which means that we can classify it as a recursive algorithm as well. And, we also know that it uses comparison operators like
>, so we also can be sure that it is a comparison algorithm.
These characteristics are the key to understanding why so many people swore by quicksort long ago, and haven’t looked back since. Based upon this two-part series, we already have to knowledge to determine when quicksort is the right tool for the job!
Quicksort is usually a great choice if we find ourselves meeting these conditions:
- We don’t care about maintaining the order of our items (stability isn’t important to us).
- We need to use an algorithm that can fit all of our data to be sorted into memory, without using any external memory.
- We are sure that we’ll never have to deal with data that’s sorted — or even mostly sorted.
As it turns out, programmers love quicksort so much that they’ve even figured out ways how to optimize this algorithm even further!
We can optimize quicksort by running the recursive calls in parallel.
We will anyway have to call quicksort on both the left and right partitions of a dataset. The cool thing is that, because neither of these two partitions have to be compared to one another after the initial division of elements, we can run both recursive calls on both partitions in parallel.
When we run two quicksort calls in parallel, we are effectively spawning two different tasks in order to run them at the same time. We sort each partition of the array independently, using the same amount of space but only 1/2 the time we did before. This is often referred to as parallel quicksort.
Another great optimization is being a little smarter about which pivot to choose. Rather than just choosing the first or the last element — which is a lot of what we’ve been doing — we could be a little bit more clever with our algorithm.
We could look at the last few elements in the list, and select the median element from amongst them. This gives us a better idea of what types of items are in the list, and then we can do a little bit of math to select the best item as the pivot.
There are many times that even an optimized quicksort doesn’t fulfill all of our conditions. So how do programmers deal with this case? Why, they use quicksort conditionally, of course!
Node, for example, uses quicksort under the hood of its
Array.sort function; however, for shorter arrays (in node’s case, those with a length less than or equal to 10), it uses insertion sort! You can see the logic they use to determine that deep within v8’s source code.
Quicksort is so widely-loved that, if we start keeping an eye out for it, we’ll start to see it all around us! For example, Ruby’s interpreter uses quicksort behind the scenes; if we dig around, we’ll find a method called
ruby_qsort that pulls back the curtain behind this abstraction. Many modern-day browsers use an implementation of quicksort in their various versions of
sort methods! In fact, the browser that you’re using right now probably has an internal definition of quicksort hidden deep within its sort code. Of course, it’s just up to you to find it.
So, go forth — you know exactly what to look for now!
Quicksort can be written in many different ways — imperatively, functionally, and even iteratively (not recursively!). Each implementation has its benefits and drawbacks, and it’s important to understand the different design decisions being made in the process of writing a quicksort algorithm. If you’d like to try writing your own, or want to see different implementations and what makes each of them efficient, check out the resources below!