Python Arena
Step 3 of 6

Quicksort and partitioning

Quicksort is another divide and conquer sort, and many languages' sort functions have been built on it. Merge sort does its real work after the recursive calls, in the merge. Quicksort does it before them:

  1. Pick one item, the pivot.
  2. Partition the list: move the items smaller than the pivot before it, and the rest after it. The pivot is then in its final place, the place it has in the sorted list.
  3. Sort the part before the pivot and the part after it, with a call of quicksort each. Nothing is left to combine.

Partitioning in place

The partition works inside the list itself, with no new lists. Here the pivot is the last item. The index i marks the end of the "smaller" part, so everything in values[low:i] is smaller than the pivot. j walks over the other items, and each smaller item it finds is swapped to position i, which grows the smaller part by one. At the end, the pivot is swapped into position i, between the two parts:

def partition(values, low, high):
    pivot = values[high]
    i = low
    for j in range(low, high):
        if values[j] < pivot:
            values[i], values[j] = values[j], values[i]
            i += 1
    values[i], values[high] = values[high], values[i]
    return i


values = [7, 2, 9, 4, 1, 8, 5]
p = partition(values, 0, len(values) - 1)
print(values, "pivot", values[p], "at index", p)
Output
[2, 4, 1, 5, 9, 8, 7] pivot 5 at index 3

Neither side is sorted yet, but 5 is exactly where it belongs, with three smaller items before it and three bigger ones after. A partition looks at each item once, O(n), and needs no extra memory.

Sorting both sides

quicksort(values, low, high) sorts the items from values[low] to values[high]. A part with fewer than two items has nothing to sort. The pivot is left out of both calls: it's already in place, and leaving it out makes each call's part smaller, so the calls end. This version also returns how many comparisons it made, high - low for each partition:

import random


def partition(values, low, high):
    pivot = values[high]
    i = low
    for j in range(low, high):
        if values[j] < pivot:
            values[i], values[j] = values[j], values[i]
            i += 1
    values[i], values[high] = values[high], values[i]
    return i


def quicksort(values, low, high):
    if low >= high:
        return 0
    p = partition(values, low, high)
    comparisons = high - low
    comparisons += quicksort(values, low, p - 1)
    comparisons += quicksort(values, p + 1, high)
    return comparisons


values = list(range(500))
random.seed(1)
random.shuffle(values)
print(quicksort(values, 0, len(values) - 1), values[:5])
print(quicksort(values, 0, len(values) - 1), values[:5])
Output
4751 [0, 1, 2, 3, 4]
124750 [0, 1, 2, 3, 4]

On shuffled values, the pivot usually lands somewhere near the middle, the parts roughly halve, and quicksort costs O(n log n) like merge sort. The second line sorts the same list again, now that it's sorted, and that is quicksort's worst case. The last item is the biggest, so each partition leaves all the other items on one side: n - 1 + n - 2 + ... comparisons, O(n²).

In Python there's a worse problem: each of those levels is one more nested call, and a sorted list of 2,000 values needs 2,000 of them, more than Python allows:

def partition(values, low, high):
    pivot = values[high]
    i = low
    for j in range(low, high):
        if values[j] < pivot:
            values[i], values[j] = values[j], values[i]
            i += 1
    values[i], values[high] = values[high], values[i]
    return i


def quicksort(values, low, high):
    if low < high:
        p = partition(values, low, high)
        quicksort(values, low, p - 1)
        quicksort(values, p + 1, high)


values = list(range(2000))
quicksort(values, 0, len(values) - 1)
Python stops with
Traceback (most recent call last):
  File "main.py", line 20, in <module>
    quicksort(values, 0, len(values) - 1)
    ~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "main.py", line 15, in quicksort
    quicksort(values, low, p - 1)
    ~~~~~~~~~^^^^^^^^^^^^^^^^^^^^
  File "main.py", line 15, in quicksort
    quicksort(values, low, p - 1)
    ~~~~~~~~~^^^^^^^^^^^^^^^^^^^^
  File "main.py", line 15, in quicksort
    quicksort(values, low, p - 1)
    ~~~~~~~~~^^^^^^^^^^^^^^^^^^^^
  [Previous line repeated 995 more times]
  File "main.py", line 14, in quicksort
    p = partition(values, low, high)
RecursionError: maximum recursion depth exceeded

A better pivot

The cure is a pivot that is unlikely to be the biggest or the smallest item. The middle item is a simple choice: swap it to the end first, then partition as before. On sorted and reversed data the middle item is the best pivot there is.

middle = (low + high) // 2
values[middle], values[high] = values[high], values[middle]

But the middle item has bad inputs too. In a list that rises and then falls, such as [0, 2, 4, 6, 5, 3, 1], the middle item is the biggest, and round after round the pivot stays one of the biggest items left: 3,000 such values still go about 1,500 calls deep, a RecursionError again. Data with a shape like that is not rare, so on structured data the middle pivot's O(n²) worst case can really happen.

A random item, random.randint(low, high) swapped to the end, is safer: no order of the items makes it slow every time, and seeding random keeps your runs repeatable. With a random pivot, quicksort is O(n log n) on average on any list of different values, and its calls stay a few dozen levels deep even for 200,000 values. The worst case is still O(n²), but now it's very unlikely. Quicksort needs no extra lists, but it isn't stable: a swap can jump an item past an equal one.

Many equal values

This two-way partition has one more weak spot, and no choice of pivot cures it: every item equal to the pivot goes to the same side. If all the items are equal, none is smaller than the pivot, so they all land on one side: the sorted case again, O(n²), and for a long list a RecursionError. A list with only a few different values has the same trouble. (Some two-way partitions, such as Hoare's, split the equal items between the two sides and avoid this, but they're harder to get right.) A three-way partition splits the items into three parts: smaller than the pivot, equal to it, and bigger. The equal part is finished, and only the other two need more sorting. It keeps three indexes: items before lt are smaller, items from lt up to i are equal, items after gt are bigger, and i walks through the unknown part between i and gt:

def partition3(values, pivot):
    lt = 0
    i = 0
    gt = len(values) - 1
    while i <= gt:
        if values[i] < pivot:
            values[lt], values[i] = values[i], values[lt]
            lt += 1
            i += 1
        elif values[i] > pivot:
            values[i], values[gt] = values[gt], values[i]
            gt -= 1
        else:
            i += 1
    return lt, gt


values = [3, 1, 3, 5, 3, 2, 5, 3]
print(partition3(values, 3), values)
Output
(2, 5) [1, 2, 3, 3, 3, 3, 5, 5]

The result (2, 5) says that the 3s now fill indexes 2 to 5. When a bigger item is swapped to gt, i stays where it is: the item that came from the end hasn't been looked at yet. Each round either moves i on or moves gt back, so the partition is one pass, O(n).

Your task

Challenge 1 of 3

What does this program print? Follow the partition one item at a time. Type each line it prints.

Challenge 2

This quicksort uses the last item as its pivot. It sorts small lists, but a sorted list of 3,000 values stops it with a RecursionError. Change partition so that it uses the middle item of values[low] to values[high] as the pivot: swap it to the end first, and the rest of the function stays as it is. Then quicksort(values, 0, len(values) - 1) must sort sorted and reversed lists of 3,000 values too. (A list of many equal values would still need the three-way partition, as the lesson says.) Don't use sorted or sort.

For example, this program:

values = [6, 3, 9, 1, 8, 2, 4]
quicksort(values, 0, len(values) - 1)
print(values)

prints:

[1, 2, 3, 4, 6, 8, 9]

Challenge 3

Support tickets have a priority of 0 (urgent), 1 (normal) or 2 (low). Write sort_three(values), which sorts a list of such numbers in place with one three-way partition around 1: all the 0s first, then the 1s, then the 2s. It returns nothing. It must be fast for 200,000 tickets. Don't use sorted or sort.

For example, this program:

tickets = [2, 0, 1, 2, 1, 0, 0]
sort_three(tickets)
print(tickets)

prints:

[0, 0, 0, 1, 1, 2, 2]