Quick sort and the bad pivot
In short: Quick sort partitions the array in place around a pivot and sorts both parts: O(n log n) on average, but a bad pivot on sorted input makes it O(n²).
Problem
The sorted price list freezes
The shop sorts its 100,000 products with quick sort: with a shuffled catalogue that is 2,136,015 comparisons, and the page opens at once. Then the supplier starts sending its price list already sorted by price. The same "Sort by price" button thinks for a long time and stops with an error:
prices = sorted(catalog(2000))try: quick_sort(prices, 0, len(prices) - 1) print("Sorted")except RecursionError as e: print("Out of stack: " + type(e).__name__)Out of stack: RecursionErrorThe input is not bigger, only sorted. A sorted input should be the easiest, and here it is the hardest. To see why, first look at how quick sort works.
The idea
Pivot and partition
Merge sort splits the list into two halves without looking at the prices, and all the work is in the merging. Quick sort does the opposite: the work is in the splitting, and there is no merging. It is still divide and conquer, as in merge sort.
- Pick one price as the pivot. Here it is the first price of the part.
- Partition: the prices smaller than the pivot go to the left, the rest to the right, and the pivot lands between them. That is also its final place.
- Sort the left and the right part with the same method. A part with one price, or an empty one, is already sorted: that is the base case of the recursion.
Partitioning swaps prices in the same list, with no copies. That is why quick sort does not need O(n) extra memory like merge sort.
See it
Partitioning step by step
Here the partition is Lomuto's: j walks over the prices one by one, and i marks the end of the left zone, where the prices below the pivot are. Every smaller price joins the zone with one swap. At the end the pivot swaps with prices[i] and lands in its place:
def partition(prices, lo, hi): pivot = prices[lo] i = lo for j in range(lo + 1, hi + 1): if prices[j] < pivot: i += 1 prices[i], prices[j] = prices[j], prices[i] prices[lo], prices[i] = prices[i], prices[lo] return iStep by step, in words
- The pivot is the first price, 45. i = 0 marks the end of the left zone, the prices below 45. It is still empty.
- j = 1: 80 < 45? No. 80 stays on the right, i does not move.
- j = 2: 20 < 45? Yes. i becomes 1 and 20 swaps places with 80: the left zone grows.
- j = 3: 65 < 45? No. 65 stays on the right, i does not move.
- j = 4: 10 < 45? Yes. i becomes 2 and 10 swaps places with 80: the left zone grows.
- j = 5: 90 < 45? No. 90 stays on the right, i does not move.
- j = 6: 30 < 45? Yes. i becomes 3 and 30 swaps places with 65: the left zone grows.
- j = 7: 55 < 45? No. 55 stays on the right, i does not move.
- j has reached the end: 7 comparisons for 8 prices, n − 1. The pivot swaps with prices[i] = 30 and lands at index 3.
- On the left 30, 20, 10, all below 45; on the right 80, 90, 65, 55. 45 is in its final place, and the two parts are sorted separately, in the same array, with no extra memory.
prices = [45, 80, 20, 65, 10, 90, 30, 55]p = partition(prices, 0, len(prices) - 1)print(f"{prices}, p = {p}")[30, 20, 10, 45, 80, 90, 65, 55], p = 3partition returns where the pivot landed, p = 3. There are n − 1 comparisons: every price except the pivot is compared with it exactly once.
prices[i], prices[j] = prices[j], prices[i] swaps the two slots: the right side is evaluated in full before the left side is written.See it
The whole quick sort
quick_sort partitions the part from lo to hi and calls itself for both sides of the pivot. The pivot itself is already in place and goes into neither:
def quick_sort(prices, lo, hi): if lo >= hi: return p = partition(prices, lo, hi) quick_sort(prices, lo, p - 1) quick_sort(prices, p + 1, hi)prices = [45, 80, 20, 65, 10, 90, 30, 55]print(f"Before: {prices}")quick_sort(prices, 0, len(prices) - 1)print(f"After: {prices}")Before: [45, 80, 20, 65, 10, 90, 30, 55]After: [10, 20, 30, 45, 55, 65, 80, 90]The idea
n · log n on average, sometimes n²
What quick sort costs depends on the pivot.
- A good pivot is somewhere near the middle. The two parts are about half each, and as in merge sort there are about log₂ n levels. On each level the partitions together pass over all n prices once: about n · log₂ n in total. With randomly shuffled prices the first price is random, so that is the average case.
- A bad pivot is the smallest (or the largest) price in the part. The left part is empty, and the right part loses only the pivot. There are n levels and (n − 1) + (n − 2) + … + 1 = n(n − 1)/2 comparisons. That is O(n²), as much as the simple sorts.
With sorted input the first price of every part is the smallest. So the easiest input becomes the worst case:
def quick_sort(prices, lo, hi): if lo >= hi: return p = partition(prices, lo, hi) quick_sort(prices, lo, p - 1) quick_sort(prices, p + 1, hi)def quick_sort(prices, lo, hi): if lo >= hi: return k = random.randint(lo, hi) prices[lo], prices[k] = prices[k], prices[lo] p = partition(prices, lo, hi) quick_sort(prices, lo, p - 1) quick_sort(prices, p + 1, hi)Step by step, in words
- The price list is already sorted, 7 to 80. The pivot is the first price of each part.
- The pivot 7 is the smallest price: 7 comparisons and nothing goes to the left. The left part is empty, the right part has 7 prices.
- The same on every level: the pivot is the smallest again, and the part loses only one price. Each level is one more nested call.
- 8 levels and 7 + 6 + 5 + 4 + 3 + 2 + 1 = 28 comparisons, n(n − 1)/2. With 100,000 prices: 4,999,950,000 comparisons and 100,000 nested calls, more than the stack holds.
- The fix: before partitioning, swap a random price of the part to the front. The rest of the code is the same.
- One possible run on the same sorted list. A random pivot is usually somewhere in the middle, so both parts get prices.
- 5 levels instead of 8 and 17 comparisons instead of 28. Another run would differ a little, but the order of the input no longer matters: about n · log₂ n on average.
Try it
Predict the output
Five prices, and the pivot is the first one, 50. What does the program print?
def partition(prices, lo, hi): pivot = prices[lo] i = lo for j in range(lo + 1, hi + 1): if prices[j] < pivot: i += 1 prices[i], prices[j] = prices[j], prices[i] prices[lo], prices[i] = prices[i], prices[lo] return i
prices = [50, 70, 20, 90, 40]p = partition(prices, 0, len(prices) - 1)print(f"{prices}, p = {p}")j walks over 70, 20, 90, 40. Smaller than 50 are 20 and 40: 20 swaps with 70 (i = 1), then 40 with 70 (i = 2). At the end 50 swaps with prices[2] = 40. On the left are 40 and 20, smaller than 50 but not in order: partitioning does not sort the parts, it only separates them.
See it
Measure it: the first pivot and a random pivot
Run quick sort on a shuffled and on a sorted catalogue. The count is of comparisons with the pivot. The first pivot on 100,000 sorted prices is not really run: Bench estimates it from the curve.
- Quick sort, first pivot, shuffled prices
O(n log n) - Quick sort, first pivot, sorted prices
O(n²) - Quick sort, random pivot, sorted prices
O(n log n)
| Algorithm | n = 10 | n = 1,000 | n = 10,000 | n = 100,000 |
|---|---|---|---|---|
| Quick sort, first pivot, shuffled prices | 35 | 10,591 | 151,493 | 2,136,015 |
| Quick sort, first pivot, sorted prices | 45 | 499,500 | 49,995,000 | ≈ 4,999,500,000 |
| Quick sort, random pivot, sorted prices | 21 | 10,576 | 153,725 | 2,316,280 |
def quick_sort(prices, lo, hi): if lo >= hi: return k = random.randint(lo, hi) prices[lo], prices[k] = prices[k], prices[lo] p = partition(prices, lo, hi) quick_sort(prices, lo, p - 1) quick_sort(prices, p + 1, hi)On a shuffled catalogue of 1000 prices the first pivot makes 10,591 comparisons. On the same catalogue, sorted, it makes 499,500, exactly 1000 · 999 / 2. A random pivot on the sorted catalogue: 10,576, almost as many as on the shuffled one. With 100,000 prices the random pivot makes 2,316,280 comparisons, and the first pivot would make n(n − 1)/2 = 4,999,950,000 (Bench estimates about 5 billion from the curve).
More precisely, if you are curious
The same numbers in your language. The catalogue and the random positions come from a small seeded generator, so they are the same on every run, in every language and in Bench. This is the final version from the lesson (with recursion into the smaller part, see below), and it also prints how deep the stack got:
comparisons = 0seed = 0deepest = 0
def catalog(n): prices = [] x = 2026 for _ in range(n): x = x * 48271 % 2147483647 prices.append(1 + x % 10000) return prices
def quick_sort(prices, lo, hi, random_pivot, depth): global seed, deepest deepest = max(deepest, depth) while lo < hi: if random_pivot: seed = seed * 48271 % 2147483647 k = lo + seed % (hi - lo + 1) prices[lo], prices[k] = prices[k], prices[lo] p = partition(prices, lo, hi) if p - lo < hi - p: quick_sort(prices, lo, p - 1, random_pivot, depth + 1) lo = p + 1 else: quick_sort(prices, p + 1, hi, random_pivot, depth + 1) hi = p - 1
def partition(prices, lo, hi): global comparisons pivot = prices[lo] i = lo for j in range(lo + 1, hi + 1): comparisons += 1 if prices[j] < pivot: i += 1 prices[i], prices[j] = prices[j], prices[i] prices[lo], prices[i] = prices[i], prices[lo] return i
runs = [ ("first pivot, shuffled", 1000, False, False), ("first pivot, shuffled", 100000, False, False), ("first pivot, sorted", 1000, True, False), ("random pivot, sorted", 1000, True, True), ("random pivot, sorted", 100000, True, True),]for label, n, is_sorted, random_pivot in runs: prices = sorted(catalog(n)) if is_sorted else catalog(n) comparisons = 0 seed = 12345 deepest = 0 quick_sort(prices, 0, len(prices) - 1, random_pivot, 1) print(f"{label}, n = {n}: {comparisons} comparisons, depth {deepest}")first pivot, shuffled, n = 1000: 10591 comparisons, depth 7first pivot, shuffled, n = 100000: 2136015 comparisons, depth 11first pivot, sorted, n = 1000: 499500 comparisons, depth 2random pivot, sorted, n = 1000: 10576 comparisons, depth 7random pivot, sorted, n = 100000: 2316280 comparisons, depth 11Merge sort makes 1,536,292 comparisons on the same 100,000 prices, fewer than quick sort. Even so, quick sort is often faster in practice: it swaps prices in the same array, with no copies, and reads memory in order.
The idea
Why the stack runs out
Every call of quick_sort stays on the call stack until it returns. With a bad pivot the right part is only one price smaller, so the calls nest one inside the other: n levels for n prices. The stack is limited, often about 1 MB, and with 100,000 nested calls it runs out.
RecursionError. The limit can be raised with sys.setrecursionlimit, but that only moves the problem: with deep enough recursion the process crashes.Separate from the stack is the time: n(n − 1)/2 comparisons. With 100,000 prices that is about 5 billion, which is why the button "thinks" before it fails.
The idea
Three fixes
A random pivot. Before partitioning, a random price of the part moves to the front and becomes the pivot. Then no input is bad on purpose: sorted, reversed and shuffled all give about n · log₂ n on average. The worst case still exists, but its probability is negligible: on every level the random price would have to be exactly the smallest or the largest in the part.
def quick_sort(prices, lo, hi): if lo >= hi: return k = random.randint(lo, hi) prices[lo], prices[k] = prices[k], prices[lo] p = partition(prices, lo, hi) quick_sort(prices, lo, p - 1) quick_sort(prices, p + 1, hi)Median of three. Instead of a random price you take the middle one by value of the first, the middle-by-position and the last price. With sorted input that is exactly the middle. This fix is the exercise at the end of the lesson.
Recursion into the smaller part. The call is only for the smaller of the two parts, and the larger one stays for the loop: lo or hi moves and the loop partitions it again. The smaller part is at most half, so the stack is at most log₂ n levels with any pivot: up to 17 for 100,000 prices (the counting program gets to 11). The comparisons do not go down, so this fix goes together with the random pivot:
def quick_sort(prices, lo, hi): while lo < hi: k = random.randint(lo, hi) prices[lo], prices[k] = prices[k], prices[lo] p = partition(prices, lo, hi) if p - lo < hi - p: quick_sort(prices, lo, p - 1) lo = p + 1 else: quick_sort(prices, p + 1, hi) hi = p - 1prices = sorted(catalog(100000))quick_sort(prices, 0, len(prices) - 1)print("Sorted: " + str(prices == sorted(prices)))Sorted: TrueTry it
Find the bug
The eight prices come out almost sorted:
prices = [45, 80, 20, 65, 10, 90, 30, 55]print(f"Before: {prices}")quick_sort(prices, 0, len(prices) - 1)print(f"After: {prices}")Before: [45, 80, 20, 65, 10, 90, 30, 55]After: [20, 30, 10, 45, 65, 80, 90, 55]This is the program's partition. Which line has the bug?
Pick the line with the bug.
hi is the last slot of the part, not the slot after it. The loop stops one price early, and the last price of every part is never compared with the pivot: it stays on the right even when it is smaller. That is why 55 and 10 end up out of place. The condition has to let j go up to and including hi.
Try it
Fill in the blank
The recursion is into the smaller, left part. What is the new value of lo, so that the loop goes on with the right part?
def quick_sort(prices, lo, hi):
while lo < hi:
k = random.randint(lo, hi)
prices[lo], prices[k] = prices[k], prices[lo]
p = partition(prices, lo, hi)
if p - lo < hi - p:
quick_sort(prices, lo, p - 1)
lo =
else:
quick_sort(prices, p + 1, hi)
hi = p - 1The left part is from lo to p - 1, and the call has already sorted it. The pivot is at p. What is left is the right part, from p + 1 to hi. If lo becomes p, the pivot goes into the next partition for no reason. With the first pivot it is the smallest again, stays at p again, and the loop spins in place.
Exercise
Median of three
Solve it in your editor.
Median of three. Write median(prices, lo, hi), which looks at three prices: at lo, at the middle lo + (hi - lo) / 2 and at hi, and returns the position of the middle one by value. In quick_sort (the plain recursion from the lesson), swap that price with prices[lo] before partitioning. Count the comparisons with the pivot in partition on the sorted catalogue from the lesson (catalog from the counting program, then sorted) with 1000 and with 100,000 prices.
median of three, sorted, n = 1000: 8005 comparisonsmedian of three, sorted, n = 100000: 1773717 comparisonsCompare with the first pivot: 499,500 comparisons for 1000 sorted prices, and with 100,000 the stack runs out.
More tasks
- A reversed price list. Pass the catalogue sorted from the most expensive to the cheapest. How many comparisons does the first pivot make? And the median of three?
- Equal prices. Pass 1000 equal prices. How many comparisons does Lomuto's partition make, and why? (Hint: nothing is strictly smaller than the pivot.)
- Most expensive first. Change one character in
partitionso that the list comes out from the most expensive to the cheapest.
More precisely
Quick sort inside the built-in sort
Quick sort was invented by Tony Hoare in 1959, and it is still at the heart of the built-in sort of many languages, but not in its pure form: each one adds guards against the bad pivot.
More precisely, if you are curious
Lomuto and Hoare. The partition here is Lomuto's: it is easy to read, but it swaps more. Hoare's partition, the original one, runs two pointers from both ends towards each other and swaps only pairs of prices that are on the wrong side. It makes about three times fewer swaps and copes better with equal prices, but the pivot does not necessarily land in its final place, and the code is easier to get wrong.
Equal prices. With many equal prices Lomuto still gives bad parts: everything equal to the pivot goes to the right. A three-way partition makes three zones: smaller, equal and larger. The equal ones are done and do not go into the recursion.
The built-in sort. In Python, sorted and list.sort are not quick sort but TimSort, a variant of merge sort: stable and fast on partly sorted input. Quick sort shows up in NumPy: numpy.sort is introsort by default.
Quick sort is not stable: a swap can jump over an equal price, and equal products change their order. What that means and when it matters is in the lesson Stable sorting.
Test
Check yourself
Related lessons
Section titled "Related lessons"- Builds on: Best, average and worst case · Space complexity · Insertion sort and selection sort · Merge sort and "divide and conquer"
- Continues in: Heap and heap sort · Stable sorting and the built-in sort · Divide and conquer and the recursion tree · Binary search tree
- Tasks on this topic: Sorting