Der Heapsort-Algorithmus

Der Heapsort-Algorithmus

🌐 Read in English
📅 2026-09-11 ✍ Andreas Wittmann đŸ‘ïž ... algorithm sorting computer-graphics

Merge Sort garantiert O(n log n) mithilfe eines zusÀtzlichen Arrays. Heapsort gibt dieselbe Garantie in-place, ganz ohne zweites Array, indem es sich eine Datenstruktur borgt, die man sonst eher mit Priority Queues verbindet: den binÀren Heap.

Die Idee

Ein binĂ€rer Heap, der in einem einfachen Array gespeichert wird, nutzt Arithmetik statt Zeiger: FĂŒr jeden Index i liegen seine Kinder bei 2i + 1 und 2i + 2. Ein Max-Heap hĂ€lt ĂŒberall in diesem impliziten Baum eine Invariante ein: Jedes Elternelement ist grĂ¶ĂŸer oder gleich beiden seinen Kindern — was den einzigen grĂ¶ĂŸten Wert an die Wurzel, Index 0, zwingt, aber nichts ĂŒber die Reihenfolge des Rests aussagt.

Heapsort arbeitet in zwei Phasen. Zuerst wird aus dem gesamten Array ein Max-Heap aufgebaut — eine beliebige Anordnung wird in eine umgewandelt, die die Heap-Invariante ĂŒberall erfĂŒllt, ohne dabei schon irgendetwas vollstĂ€ndig zu sortieren. Danach wird wiederholt das Maximum extrahiert: Die Wurzel ist immer der grĂ¶ĂŸte verbleibende Wert, also wird sie ans Ende des noch unsortierten Bereichs getauscht, der Heap um eins verkleinert, und die Heap-Invariante an der Wurzel wiederhergestellt. Das einmal pro verbleibendem Element zu tun sortiert das gesamte Array, vom grĂ¶ĂŸten zum letzten, eine Extraktion nach der anderen.

Beide Phasen beruhen auf derselben Operation: Sift Down (Versickern). Verletzt ein Knoten möglicherweise die Heap-Invariante gegenĂŒber seinen Kindern, wird er mit beiden verglichen, mit dem grĂ¶ĂŸeren Kind getauscht, falls eines grĂ¶ĂŸer ist, und das an der neuen Position wiederholt — den Baum hinab, nie zurĂŒck nach oben — bis der Knoten entweder ein Blatt ist oder grĂ¶ĂŸer als beide seine Kinder.

Implementierung

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

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

int heapSize = N;
int buildIndex = N / 2 - 1;
bool building = true;
int extractEnd = N - 1;
int cur = -1;   // node currently being sifted down; -1 = need the next task
bool done = false;
int tick = 0;

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;
    }
}

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 (cur == -1) {
            if (building) {
                if (buildIndex >= 0) {
                    cur = buildIndex;
                    buildIndex--;
                } else {
                    building = false; // heap property established, extract next
                }
            } else if (extractEnd <= 0) {
                done = true;
            } else {
                int tmp = values[0];
                values[0] = values[extractEnd];
                values[extractEnd] = tmp;
                settled[extractEnd] = true; // max of the remaining heap, now final
                heapSize = extractEnd;
                extractEnd--;
                cur = 0;
            }
        } else {
            int left = 2 * cur + 1;
            int right = 2 * cur + 2;
            int largest = cur;
            if (left < heapSize && values[left] > values[largest]) largest = left;
            if (right < heapSize && values[right] > values[largest]) largest = right;

            if (largest != cur) {
                int tmp = values[cur];
                values[cur] = values[largest];
                values[largest] = tmp;
                cur = largest;
            } else {
                cur = -1; // sifted as far down as it needs to go
            }
        }
    }

    PlotBackground(250, 250, 250);

    int barWidth = plot_width / N;
    for (int idx = 0; idx < N; idx++) {
        if (done) {
            PlotColor(70, 170, 90);   // fully sorted
        } else if (idx == cur) {
            PlotColor(220, 60, 60);   // being sifted down right now
        } else if (settled[idx]) {
            PlotColor(70, 170, 90);   // extracted into final position
        } else {
            PlotColor(30, 100, 200);  // part of the heap, not yet extracted
        }

        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 →

cur hĂ€lt den Knoten, der gerade versickert, oder -1, wenn nichts versickert und die nĂ€chste Aufgabe ausgewĂ€hlt werden muss: In der Aufbauphase bedeutet das, an buildIndex (rĂŒckwĂ€rts gezĂ€hlt ab der Mitte des Arrays — BlĂ€tter brauchen kein Versickern, da sie keine Kinder haben) ein Versickern zu starten; in der Extraktionsphase bedeutet das, die Wurzel mit values[extractEnd] zu tauschen, diese Position als final zu markieren, heapSize zu verkleinern und ein Versickern an der Wurzel zu starten. Ein Aufruf von frame() fĂŒhrt einen Vergleichs- und gegebenenfalls Tausch-Schritt des jeweils aktiven Versickerns aus.

Schritt fĂŒr Schritt durchgerechnet

[1, 8, 4] versickern (Index 0 enthÀlt 1, seine Kinder enthalten 8 und 4), um die Heap-Invariante an der Wurzel wiederherzustellen:

Knoten linkes Kind rechtes Kind grĂ¶ĂŸeres Kind tauschen?
0 (Wert 1) 8 (Idx 1) 4 (Idx 2) 8 ja, mit Idx 1 tauschen

Nach dem Tausch ist das Array [8, 1, 4], und die Max-Heap-Invariante gilt: 8 ≄ 1 und 8 ≄ 4. Index 1 hat hier keine Kinder, also stoppt das Versickern. Einen vollstĂ€ndigen Heap aufzubauen wiederholt das fĂŒr jeden Nicht-Blatt-Knoten, beginnend bei den untersten, sodass bereits alles darunter ein gĂŒltiger (kleinerer) Heap ist, wenn ein Versickern einen höheren Knoten erreicht.

Warum die In-place-Garantie wichtig ist

Heapsort erreicht dieselbe Worst-Case-Grenze von O(n log n) wie Merge Sort — beide leisten log n Ebenen Arbeit, O(n) pro Ebene, bedingungslos — aber mit O(1) zusĂ€tzlichem Speicher statt eines zweiten, eingabegroßen Arrays. Das macht Heapsort genau dort attraktiv, wo Merge Sorts zusĂ€tzliche Allokation ein Problem ist: speicherbeschrĂ€nkte Systeme, oder ĂŒberall, wo eine In-place-Worst-Case-Garantie gebraucht wird, ohne Quicksorts eingabeabhĂ€ngiges Risiko.

Der Kompromiss ist das Cache-Verhalten: Heapsorts Zugriffe ĂŒber 2i + 1 / 2i + 2 springen im Array herum statt es sequenziell zu durchlaufen, wie es Merge Sorts lineare Merges und Quicksorts lineare Partitionierungs-Scans tun. In der Praxis macht das Heapsort bei typischen Eingaben eher langsamer als Quicksort oder Merge Sort, trotz gleichwertiger oder besserer asymptotischer Garantien — genau deshalb verwendet Introsort standardmĂ€ĂŸig Quicksort und fĂ€llt nur als Grenze fĂŒr den Worst Case auf Heapsort zurĂŒck, statt es direkt zu verwenden.

Fazit

Heapsort fasst Sortieren als eine Folge von Priority-Queue-Operationen auf: eine Struktur aufbauen, die ihr Maximum immer an der Wurzel zeigt, dann dieses Maximum entfernen, ein Element nach dem anderen, gĂŒnstig genug, dass n Extraktionen aus einem schrumpfenden Heap trotzdem nur O(n log n) ergeben. Es ist eine gute Erinnerung daran, dass "eine Liste sortieren" und "wiederholt das grĂ¶ĂŸte verbleibende Element finden" dasselbe Problem aus zwei Blickwinkeln sind.