The Quicksort Algorithm

The Quicksort Algorithm

🌐 Auf Deutsch lesen
📅 2026-09-11 ✍️ Andreas Wittmann 👁️ ... algorithm sorting computer-graphics

Bubble sort, insertion sort, and selection sort are all O(n²): double the input, and the work roughly quadruples. Quicksort takes a fundamentally different strategy — instead of scanning the whole list repeatedly, it splits the problem in half (roughly) and recurses, landing at O(n log n) on average — the same complexity class as merge sort, but usually faster in practice because it needs no extra array to do it.

The idea

Pick one element from the list — the pivot — and rearrange everything else around it so that every value smaller than the pivot ends up to its left, and every value larger ends up to its right. This step is called partitioning. Once it is done, the pivot itself is in its final, correct position: nothing will ever move past it again.

That leaves two smaller, independent problems: sort everything left of the pivot, and sort everything right of it. Solving both the same way — pick a pivot, partition, recurse — is what turns one partition into a full sort. In the best and average case, each partition roughly halves the list, so the recursion is only log n levels deep; multiplied by the O(n) cost of each level's partitioning, that gives O(n log n) overall.

Implementation

#include "plot.hpp"
#include <stdlib.h>

const int N = 20;
int values[N];
bool settled[N];

// Explicit stack instead of real recursion, so a single partitioning step
// can happen per animation tick instead of the whole sort running at once.
int stackLo[64];
int stackHi[64];
int top = -1;

int lo, hi, pivotIdx, i, j;
bool partitioning = false; // false = need the next range from the stack
bool done = false;
int tick = 0;

void push(int a, int b) {
    if (a <= b) {
        top++;
        stackLo[top] = a;
        stackHi[top] = b;
    }
}

void setup() {
    PlotCanvas(640, 480);

    for (int n = 0; n < N; n++) {
        values[n] = n + 1;
        settled[n] = false;
    }

    srand(1);
    for (int n = N - 1; n > 0; n--) {
        int r = rand() % (n + 1);
        int tmp = values[n];
        values[n] = values[r];
        values[r] = tmp;
    }

    push(0, N - 1);
}

void frame() {
    // Slow the animation down: one step every few calls to frame() instead
    // of one per call, so individual comparisons stay visible.
    if (!done && ++tick >= 4) {
        tick = 0;

        if (!partitioning) {
            if (top < 0) {
                done = true;
            } else {
                lo = stackLo[top];
                hi = stackHi[top];
                top--;
                if (lo == hi) {
                    settled[lo] = true; // single element: already in place
                } else {
                    pivotIdx = hi; // Lomuto partition: last element is the pivot
                    i = lo - 1;
                    j = lo;
                    partitioning = true;
                }
            }
        } else if (j < hi) {
            if (values[j] < values[pivotIdx]) {
                i++;
                int tmp = values[i];
                values[i] = values[j];
                values[j] = tmp;
            }
            j++;
        } else {
            i++;
            int tmp = values[i];
            values[i] = values[hi];
            values[hi] = tmp;
            settled[i] = true; // pivot has reached its final position
            push(i + 1, hi);
            push(lo, i - 1);
            partitioning = false;
        }
    }

    PlotBackground(250, 250, 250);

    int barWidth = plot_width / N;
    for (int idx = 0; idx < N; idx++) {
        if (!done && partitioning && idx == pivotIdx) {
            PlotColor(220, 60, 60);   // current pivot
        } else if (settled[idx]) {
            PlotColor(70, 170, 90);   // in its final position
        } else {
            PlotColor(30, 100, 200);  // not yet settled
        }

        int height = values[idx] * (plot_height - 20) / N;
        int x0 = idx * barWidth + 1;
        int x1 = x0 + barWidth - 2;
        int y0 = plot_height - height;
        int y1 = plot_height;
        PlotFilledRectangle(x0, y0, x1, y1);
    }
}
Open in full editor →

Real recursion would call a function for each half and let the call stack track pending work, but that runs to completion in a single call instead of one visible step at a time. The sample keeps an explicit stack of (lo, hi) ranges instead, so frame() can perform one partitioning comparison per step and still pick up exactly where it left off next time.

This uses the Lomuto partition scheme: the pivot is always the last element of the current range (values[hi]). j scans the range; whenever it finds a value smaller than the pivot, that value gets swapped just past i, which marks the boundary of everything confirmed smaller so far. Once j reaches the pivot, one last swap puts the pivot right after that boundary — its final position — and both halves get pushed onto the stack for later.

Walking through an example

Partitioning [5, 1, 4, 2, 8] with pivot 8 (the last element) is a short example, since 8 is already the largest — everything ends up on its left and the pivot does not move. A more typical case is [4, 2, 8, 5, 1] with pivot 1:

j values[j] smaller than pivot (1)? action
0 4 no
1 2 no
2 8 no
3 5 no

Nothing was smaller than 1, so i never advanced past lo - 1. The final swap places the pivot at index 0: [1, 2, 8, 5, 4] — note the other elements also moved, since the earlier no-op comparisons still count as j advancing past them unchanged. The pivot 1 is now correctly at the very front, with the (still unsorted) remainder [2, 8, 5, 4] to its right, ready for its own, independent partitioning step.

Why the pivot choice matters

Quicksort's O(n log n) average case assumes each partition splits the list roughly in half. The sample always picks the last element as pivot, which is simple but has a weakness: on a list that is already sorted (or reverse-sorted), that pivot is always the largest or smallest remaining value, so one side of the partition is empty every time. That degrades to n levels of recursion instead of log n, each doing O(n) work — O(n²) overall, no better than the simple sorts. Production implementations avoid this by picking the pivot differently: a random element, the median of a few sampled elements, or (as in introsort, used by most std::sort) falling back to heapsort once the recursion goes suspiciously deep.

Unlike merge sort, quicksort needs no auxiliary array — partitioning works in place — which is the main reason it tends to win in practice despite sharing the same average-case complexity.

Conclusion

Where the simple sorts all do a fixed, quadratic amount of comparison work regardless of structure, quicksort's divide-and-conquer approach turns sorting n elements into log n rounds of O(n) partitioning — as long as the pivot choice keeps the halves roughly balanced. That trade — a worst case that depends on input order, in exchange for a much better typical case — is the same idea behind most O(n log n) sorting algorithms, and quicksort remains one of the clearest ways to see it in action.