It was named as one of the most important algorithms of the twentieth century and
it's widely used for system sorts and many other applications.
Last lecture, we looked at Mergesort, another classic sorting algorithm, that's
used in many systems, and today we are looking at Quicksort which is used in many
others. You can even get a Quicksort t-shirt
nowadays. So what is the Quicksort method?
It's also a recursive method, but the basic idea behind Quicksort is that it
does the recursion after it does the work, whereas Mergesort did it before it did the
work. So, the idea is first randomly shuffle the
array. That's an important step that we'll talk
about later, and then partition the array, so that's to divide it so that for sum
value j the entry a of j is in place in the array.
There's no larger entry to the left of j and no smaller entry to the right of j.
Once we have the array partitioned in that way, shown here in the middle.
Right here, we have K in its position. And we have everybody to the left.
There's nobody greater than K. And everybody to the right, there's nobody
less. Once we have it arranged in that way, then
we recursively sort the two parts. Sort the left part, sort the right part.
And then after those two things are done, the whole thing is sorted.
This method was invented in 1961 by Tony Hore, who won the Turing Award in 1980 for
this and other work. So let's look at a demo of how Quicksort
partitioning works. The idea is to arbitrarily choose the
first element to be the partitioning element.
Since we shuffled the array, that's our random element from the array.
And then we're going to maintain an I pointer that moves from left to right, and
a J pointer that moves from right to left. Let's look how it works in the demo.
So we start again by picking K as the partitioning element.
And then our method is to move the I pointer from left to right.
As long as what we have is less than the partitioning element.
And move the j pointer from right to left as long as it points to an item that's
greater than the partitioning element. So, in this example the i pointer stops
right away because it's pointing to an r which is bigger than the partitioning
element. The j pointer decrements until it gets to
the c which it stops there which is less than the partitioning element.
And so now what's going to happen is those two elements are out of place.
The partitioning elements in between them and they're in the wrong order.
So what we want to do is exchange those. And then move on.
Now we increment I, as long as it's pointing to an element that's less than
the partitioning element. Stop here at t cuz that's bigger.
And now we decrement j, as long as it's pointing to something that's bigger than
the partitioning element. Stop her at I because that's less.
Again, t and I are in the wrong places. If we exchange them, we'll maintain the
invariant that everything to the left of I is less than the partitioning element, or
nothing to the left of I is greater than the partitioning element, and nothing to
the right of j is less than the partitioning element.
So exchange increment I as long as it's less.
Stop at l increment j decrement j as long as it's greater.
Stop at e those two elements are out of position so exchange them.
Now increment i, stop at the l which is greater than k decrement j stop at the e
which is less than k and now at this point the partitioning process is complete,
coomplete cause the pointers have crossed and we have looked at everything in the
array. In fact.
J points to the, rightmost element in the left subfiles, everything that's not
greater than K. So we can just exchange J with our
partitioning element. And now we've achieved the goal of
partitioning the array. So that A of J is in its position.
Nobody to the left is greater. Nobody to the right is less.
Now, the code for partitioning is straight forward to implement.
Down below. Shows the state of the array before
partitioning. During and after partitioning.
So in the end, the J pointer is pointing to the partitioning element V, which was
in position V in the first place. In the, all during the partitioning
process, the code is maintaining this invariant.
Where everything to the left of I is less than or equal to V.
Everything to the right of J is greater than or equal to V.
And we haven't looked at things in between.
So, finding, incrementing I, as long as it's less is a simple while loop.
And then we put a test to make sure we don't run off the right end of the array.
And decrementing J. As long as it's pointing to a bigger
element that's similarly just a wide loop we put in to test to make sure we don't
run off the left end of the array. Then there's a test to see if the pointers
cross. Swap the elements of I and j.
When we get to the pointers cross we break out of the loop and exchange the
partitioning element into position. So that's a quick implementation of the
Quicksort partitioning method. Now, Quicksort itself then is going to be
a recursive program that uses that partitioning method.
First thing we do is the public sort method that takes the array of comparable
items as its argument. It's gonna to do a shuffle.
And that shuffle is needed to make sure that we can guarantee that the performance
is gonna be good. And then it calls the recursive method
that takes as arguments the limits of the subarray that's gonna be sorted.
So then partitioning. Simply does the partitioning.
Tells us where, which element is in position, and then recursively sorts the
last part that's loaded, J -one. And then the right part, that's J + one to
high. That's a complete implementation of
Quicksort. Again, as with Mergesort, studying a
recursive trace is instructive. And this one is kind of upside down as
compared to Mergesort. The first line shows the partitioning
where k is put into position. Then the method calls the sort for the
left subfile first, and then that's gonna be partitioned on this e, and so forth.
And eventually we get down to small subfiles, actually our code doesn't do
anything at all for subarrays of size one, so we just leave those in gray, and then
it does the right subfile, and so forth. Again, studying this, a, a trace like
this, gives a, a good feeling for exactly what's going on in the recursive program.
Let's look at an animation of Quicksort in operation.
There's the partition. Now it's working on the left.
Now it's partitioning the right. Now it's working on the left part of the
right. Now it's partitioning what's left.
Doing the left part of that. And working from left to right, by
dividing each sub-array in half as it goes.
So let's look. Consider some of the details in
implementation of partitioning with quick sort.
So first thing is the partition is in place.
You could use an extra array and the partitioning code would be a little bit
easier. But one of the big advantages of Quicksort
over Mergesort is that it doesn't take any extra space.
It gets the sort done in place. Now you have to be a little bit careful
with terminating the loop. When we give you working code it's not
hard to see why it works. And you might go trough the exercise of
trying to implement Quicksort without looking at our code, and you'll find that
testing when the pointers cross can be a little bit tricky, particulary in the
presence of duplicate keys. Also staying in bounds.
And I, actually, in our implementation the test of the J pointer running off the left
end is redundant. Why is it redundant?
Well, the partitioning element is sitting there and it'll stop when it hits the
partitioning element. But the other test is not in our
implementation. And the key thing, one key thing is that
the way that these implementations work. If the in-, the file is, the array is
randomly ordered, then the two sub-arrays after partitioning will also be randomly
ordered. Actually, some implementations of Quick
Sort out in the wild don't have this property, and they suffer a little bit in
performance. That random shuffle at the beginning is
important and needed for guaranteeing performance.
And the other thing I have referred to but not talked about in detail is the presence
of equal keys. You might think it would be better to
handle equal keys in some special way. We'll talk about that in a second.
But this general purpose implementation stops the pointers on keys equal to the
partitioning items key and we'll take a look at why that's important in a minute.
So now let's look at the running time estimates about why we care about
Quicksort vs Mergesort. This is extending the table we looked at
last time, and you can see over in the right column here, Quicksort is quite a
bit faster than Mergesort. And again, a good algorithm is much better
than having a super computer. Even on your PC you can sort huge array of
a million items in less then a second and a million items in only a few minutes.
So again this time, sort of timing is why Quicksort is so widely used.
Cuz it's simply just faster than Mergesort.
Well in the best case Quick Sort will divide everything exactly in half.
And that makes it kind of like Merge Sort. It's about analog in.
And in the worst case if the random shuffle winds up putting the items exactly
in order, then partitioning doesn't, doesn't really do anything except find the
smallest, peel off the smallest item. Kind of discover that everything to the
right is greater. That's a bad case.
But if we shuffled randomly, it's extremely unlikely to happen.
Most interesting thing about the study of Quicksort is the average case analysis.
This is a somewhat detailed mathematical derivation, but it is worthwhile going
through the steps, to really get a feeling for why it is that, Quicksort is quick.
So what we do is, as we did for Merge Sort, is write down a mathematical
recurrence relation that corresponds to what the program does.
In the case of Quick Sort, the number of comparisons taken to sort N items is N+1
for the partitioning. Plus what happens next depends on what the
partitioning element was. If the partitioning element is K.
Any particular value happens with probability one over n, and if it's k,
then the left subfile has k - one items in it, and the right subfile has n - k items
in it. So, for every value of k, if you add those
up the probability that the partitioning element is k, plus the cost for the two
subfiles, we get this equation. This looks like a fairly daunting
equation, but actually it's not too difficult to solve.
First thing we do is just multiply by N and collect terms.
So NCN N times N + one. And then these terms, every size appears
twice. So it's twice the sum of from C0 to CN -
one. It's a simpler equation already.
Now what we can do is get rid of that sum by subtracting the same equation for N
minus one. So NCN - N - one, CN - one then the N, N +
one - N - one N is just 2N. And then the sum collapses just leaving
the last term. This sum, minus the same sum for N - one,
just leaves the 2CN - one. Now that's looking like a much simpler
equation. Rearrange the terms, so we get n+1 cn-1
and then divided by n, n+1. That's a kind of a magic step, but we will
see that it makes possible to solve the equation easily.
Because that equation, with C over N plus one equals CN minus one over N, is an
equation that telescopes the first term at the right.
It's the same as the term on the left. So we can apply the same equation so its
two over n + one. We apply for n - one we get one less here
and we can throw out a lot two over n. And continue that way throwing out two
over decreasing numbers all the way down until we get down to two elements, c1
which is zero. Substitute the previous equation
telescope. And then that gives us an easy sum that we
can approximate by an integral. It's one over X from three to N+1.
And that's a pretty close approximation, in this case.
And that approximation gives us, it's about two M+1 natural log N comparisons
for Quicksort. About 1.39 N log N.
That's the average number of comparisons taken by Quicksort, and actually they for
a random permutation of the elements which is what we do with the shuffle.
They the expected number of comparisons is concentrated around this value.
It's very likely to be very near this value is then as large.
So the worst case quick sort is quadratic. So complexity's going to tell us that it's
a quadratic algorithm if that's what its worst case is.
But with random, the random shuffle it's more likely that this lecture will end,
because of a lightning strike. Or your computer will be struck by a
lightning bolt. So we can discount that.
The average case, which is extremely likely for any practical application, is
going to be about 1.39 n log n. So that's more compares than Mergesort
uses. But Quicksort is much faster, because it
doesn't do much corresponding to each compare.
It just does the compare and increment a pointer.
Whereas, Mergesort has to move the items into and out of the auxiliary array, which
is more expensive. So the random shuffle is a key for good
performance in Quicksort. It gives us the guarantee that the worst
case is not gonna happen. And also, it allows us to develop a math
model that we can go ahead and validate with experimentation.
You run Quick Sort and you count compares. If you did the random shuffle, it'll be
about 1.39 n log n compares. And its running time will be proportional
to n log n, and it'll be a fast sort. And that's what people do, and that's why
people use it. Now there are some things that you have to
watch out for with Quicksort because the implementation is a bit fragile and it's
easy to make mistakes. And you'll find textbook implementations
or implementations out on the web that wind up running in quadratic time in
certain situations. You have to be a little bit careful of
that and even if everything is randomized if there's lots of duplicates and the
implementation is not done quite right the quick sort might take quadratic time.
So, let's summarize the properties of Quicksort.
It's in place. It doesn't use any extra space.
The depth of recursion. So tha, that's.
Again, dependent on the random shuffling, is going to be logarithmic.
You can, limit the depth of recursion by always doing the smaller sub-array before
the larger sub-array. But that's not really necessary nowadays,
as long as you've done the, random shuffle Oh, and by the way, Quicksort is not
stable cuz partitioning does one of those long range exchanges that might put a, a
key with equal value over a key another key with the same value.
So it's a little more work to make Quicksort stable, maybe using extra space.
Un, so what about in actually in practice? This is our fastest sorting algorithm, and
there's a few ways to make it even faster. And these, we looked at some similar
things with for the Word, Mergesort. And it's definitely worthwhile taking
implementing for a Quicksort. First thing is small sub-arrays.
Even Quicksort has more overhead than you want for a tiny array, like one of size
two or three or four. So can implement it to cut off to
insertion sort for small arrays. And the exact number they use is not too,
critical. Okay.
Anywhere between ten and twenty will improve the running time by maybe twenty%.
Also you could just not do anything for small arrays, and then do the insertion
sorting in one pass at the end. So, that's a first improvement.
A second improvement is to, try to estimate the partitioning element to be
near the middle. Rather than just arbitrarily using the
first element. Which on average will be at the middle.
So one thing that we can do is sample the items, and then take a median of the
sample. And that's actually not worth the cost for
enlarged samples, not usually. But for three it's worthwhile.
Slightly reduces the number of compares. Increases the number of exchanges
paradoxically, cuz more exchanges are required when the partition is right in
the middle. So that'll also improve the running time
by maybe ten%. So this is a summary of the optimized
Quicksort with cut off the small subfiles in median-of-three partitioning.
So partition usually happens pretty close to the middle when you do that sample
median-of-three and then small subfiles can just be left unsorted to be picked up
with insertion sort right at the end. So this gives a feeling for the.
Number of items that have to be touched during quick sort.
And kind of an explanation for how it gets the sort done so quickly.
That's a summary of Quicksort, our best sorting algorithm that we've seen to date.