Welcome back. Today we're going to look at Quicksort. 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.