Quicksort and Partition Algorithms
published
last modified
17kb
Quicksort is a fundamental in-place sorting algorithm used to sort arrays. It uses the divide and conquer approach, a famous method in tackling programming problems. It is a recursive algorithm that continually operates on sub-arrays of the original list to sort the array.
How Does Quicksort Work?
Quicksort works by using something called a pivot value which is used to partition the array into left and right sub-arrays. Everything on the left is less than the pivot and everything to the right is greater than the pivot.
Subsequently, recursive calls are made to the sort function on the left and right sub arrays until the list is sorted. At each stage the pivot value is placed in it's correct place, so once we get to an array of size one we are done.
Time Complexity
On average the time complexity for this sorting algorithm is , when the array is divided in half every time. In the worst case the time complexity is when the pivot is the largest or smallest value or there are lots of duplicate values. This makes sense if we think about it:
- If the pivot is the largest or smallest, the subarray array will be (all values are less or greater than the pivot) in size and we will only be reducing the size by one each time, culminating in a bunch of calls.
- If the array has lots of duplicate values and we pick this duplicate as the pivot the same thing will happen and it may return a size of subarray close to the original, with the consequence similar to the above.
Obviously it is important to try create equal partitions in each step, which is important to remember when comparing partition functions.
The Partition Functions
Something that has not been specified, which is quite important, is how to partition the data. The choice of the partition algorithm and choices of where to pick the pivot are important points to consider and affect the performance of the algorithm considerably.
As a result quicksort can be thought of, really, as a family of algorithms that are composed by picking a partition function and specifying it's parameters. To be honest I found this quite confusing as there wasn't a great deal about this online that explained it well.
There are two main partition functions that are used to implement quicksort:
- The Lomuto Partition function
- The Hoare Partition function
Let's go through both of these functions and how they pick pivots and partition the data.
Lomuto Partition Function
The Lomuto partition function is the slightly simpler one to understand. Essentially it works like this:
- We pick the last value as the pivot and set a pivot index to the beginning of the array minus one
i = low - 1
- We start from the beginning of the array up until
high - 1
and scan through it checking ifA[j] <= pivot
. If it is then we incrementi
and swap the values that are atA[j]
andA[i]
. - At the end
i + 1
is the pivot index and we swap this with the value atA[high]
(the pivot value).
The Conceptual Idea
The idea is we are building the left sub-array by swapping any values into this portion of the array. If we perform a swap it means the pivot point should be moved forward hence the increment to i
(i.e put the partition point to the right of any values less than or equal to the pivot).
We increment i
again at the end of the loop because we have <=
checks. This means values equal to the pivot will appear in the left part of partition and the first value larger than the pivot will be next to this point (i.e i + 1
) which will be where we want to swap our pivot value at A[high]
.
Note that it is not a requirement here to have the left or right portions sorted. Only that everything to the left is less than or equal to the pivot and everything to the right is greater than or equal to the pivot.
The correct pivot index is returned, which is important to note for the next algorithm. Notice too, that the bounds checking here is <=
. Keep these two points in mind.
Psuedocode and Implementation
Below is the psuedocode for the algorithm:
Here is an implementation in c++
on an array.
Hoares Partition Function
Now for the fun part. Hoares partition function. This algorithm is slightly different to Lomuto and works better for duplicate values and already sorted arrays. You will find out why in a few minutes, but if you want learn more in-depth check out this great cs stack exchange answer on the topic, which also explains a popular reason why Hoares is better:
because it performs three times less swaps (again check out the link above to find out why).
I think it's best to just dive in with the description of the algorithm and implementation and I'll outline what I found confusing and how I reasoned about it to understand it.
Description, Pseudocode, Implementation
This algorithm uses two pointers. Let's call them i
and j
, both pointing to the start and end of the array respectively. We move them towards each other until:
- We increment
i
as long as it is, and this is important, less than the pivot (i.eA[i] < pivot
) - We decrement
j
as long as it is, and this is important greater than the pivot (i.eA[j] > pivot
) - Once both conditions are met we swap both the values at these points.
- We do this until
i >= j
at which point we returnj
.
Essentially this is just swapping values that are not in the correct relative position to the pivot. Values that are greater than the pivot should be on the right and vice versa. So we find a pair that are in the wrong place, swap and move forward. Okay no big deal.
Here is the psuedocode:
and it's implementation in c++
(I use the start of the array as the pivot in this version)
Now check out what we pass to our parition function:
One thing that struck me immediately when I looked at this is:
why do we pass in
p
to the first recursive call.
Intuitively, in Lomuto we left out the pivot (we pass (low...p - 1) and (p + 1...high)
) as it was in it's correct place and now here we don't? This lead me down the rabbit hole of quicksort which opened up more questions, which I've actually quite enjoyed and hoped I've come to a better understanding of as a result of the rabbit hole. They ain't so bad!
More Questions
- Why do we use
<
and>
here instead of>=
like Lomuto? - Why do we include
p
? - Why do we return
j
? - Why does wikipedia say we can't use
high
as the pivot in some cases and we can't uselow
in other cases? - Why have I seen implementations that return
i
?
This left me with more questions than answers and me being me I wanted to fully understand why we are making all these choices. I will try tackle these to the best of my understanding and try learn something along the way.
This one was one of the more simple ones. Per wikipedia, I found the explanation a bit hard to follow, but the gist is we do this so we don't have to run any bounds checks on our pointers. Why? Let me demonstrate with a simple example.
Say we have a simple array:
We set our pivot = 0
. If we perform >= and <=
following the pseudocode:
- We increment
i
first as this is ado while
loop - We enter the
while loop
and sincea[i] <= 0
is always true, we incrementi
until it hits 2 and checka[2] <= 0
and get an out of bounds exception.
Okay we can't have that happen. I will leave it up to the reader to see what happens if we use > and <
instead (it's nothing phenomenal, we do a useless swap and return j
because we increment both pointers after the swap because of the nature of a do while
loop, by which I mean we always run the code before the check).
We Do We Include P?
Okay this is an interesting one. Best demonstrated through an example. Let's say we have an array:
and from this array we pick the pivot p=3
.
If we run Hoares partition function on this array we get back this:
and the pivot index returned is 2
.
Huh? Isn't the pivot value at index
3
? If we used Lomuto (i.ep + 1, p - 1
) we would be skippping1
which isn't correct
By the way, to be super clear this is happening because in this function we are actively swapping the pivot value and we can't know where it will end up in the great dividing range.
Okay... so it seems in this case we should include the pivot. But wait. There are two options now, why? becuase the pivot can be in either sub array:
or
Great. Now which one do we choose? Well it depends. What does it depend on? It depends on the whether we return i
or j
as the pivot index and whether we choose to use the last or first value of the array as our pivot. I will try to address the last three points of questions with the decisions we have to take above.
Last Three Points
Okay let's tackle returning i
or j
. Again, I think these are best explained through an example.
Say we have an array:
And we go for the low, p
variant. We run Hoares on that thing and we get:
i = 1
j = 0
Alright good so far. I wanna return i
, because why not? Let's just pass this into our sorting function.
Oh shit. That's the exact same array. We are now going to go down a not very good rabbit hole of infinite recursion. There is this mouthful which boils down to:
pick
j
as the returned index, as the pivot is in the left sub array. It is our job (partition function) to exclude the "tail" (end) of the array in scenarios where infinite recursion can happen.
Yeah okay that makes sense. Let's use j
to trim this array and avoid infinite recursion, because you don't want to hear the words infinite and recursion together.
In the wiki link above the terms "former" and "latter" are used to describe indices i
and j
which I believe is easier to think about as the indices that point to the "start" or "end" of the array initially. These descriptions are important when considering the next point.
Wikipedia Says We Can't Use High as the Pivot?
You can probably guess what I'm going to say by this point. Let's walk through an example again to illustrate the point. Let's say we've got an array. But this time (plot twist) it is sorted.
Let's perform blashpemy and pick the last value as the pivot value and go with the (low, p)
variant. We run Hoares on the thing and get:
i = 1
j = 1
- becuase
i <= j
we returnj
I think you can see where this is going... pass that to our sort function:
Same array again. Damn this isn't easy. So it is obvious now that in this case (low, p), (p + 1, high)
we can't use the last value as the pivot. What we want to happen is that the end index actually crosses the start index and we return j = 0
(we want the former to cross the latter in wikipedia parlance).
Because this article is getting too long, I will leave it up to the reader as an exercise to see when this can become the case.
Hint: see what happens when you use (low, p + 1), (p, high)
using i
and j
etc.
Pretty much it's the reverse of the above. We have to return i
and never pick the first element as the pivot to avoid infinite recursion.
Bonus Points
Okay that was a lot. As an added bonus I just want to show why Hoares performs better than Lomuto for arrays with duplicate values. Remember how I said that if we don't partition the function evenly then our performace degrades? Well Hoare's deals with this pretty well.
Say we have an array:
With Lomuto the pivot p
, leads to, a[i] <= p
always evaluating to true, so we perform useless swaps and move i
to the n - 1
position, creating a very unbalanced array.
On the other hand, with Hoare's a[i] < p
is always false, so perform useless swaps but the pointers always move toward each other and will cross at the mid point, creating a balanced split.
This is the exact behaviour we want to avoid unbalanced splits.
Conclusion
There is a lot going on with quicksort! I hope I've helped with understanding it a little better. Thanks for reading folks, that's all for today.
Contact
If you have any comments or thoughts you can reach me at any of the places below.