Disclaimer: Diese Mitschrift ist als Ergänzung zur Vorlesung gedacht. Wir erheben keinen Anspruch auf Vollständigkeit und Korrektheit. Wir sind jederzeit froh um Hinweise zu Fehlern oder Unklarheiten an grafdan@ethz.ch.
Wenn Sie an dieser Mitschrift mitarbeiten möchten, melden Sie sich hier.
Was bisher geschah:
Wir haben letzte Woche gesehen, wie wir in einem sortierten Array schnell suchen können oder Daten mit Hashing speichern. Bei der binären Suche haben wir gesehen, dass eine doppelte Datenmenge einen weiteren Suchschritt bedeutet. Das ist zwar grandios wenig, aber wächst halt doch mit der Datenmenge an. Beim Hashing war dies nicht der Fall und wir hatten eine konstante Suchzeit, zumindest im Erwartungswert. Doch wir opfern dabei nicht nur die Worst-Case-Garantie sondern auch die Ordnung. Wollen wir von einem Schlüssel zum nächstgrösseren Schlüssel in der Datenmenge (z.B. E-Mails nach Datum sortiert anzeigen, Terminkalender in chronologischer Reihenfolge), so ist dies mit Hashing nicht schnell möglich. Wie wir Datenmengen ordnen, oder eben sortieren, schauen wir uns diese Woche an.
Es gibt Leute, die sagen, dass Sortieren für mehr als 90% der weltweit aufgewendeten Rechnerzeit verantwortlich ist. Das ist vielleicht ein wenig übertrieben, aber Sortieren ist ein sehr wichtiges und häufiges Problem.
Will man einen Datensatz sortieren, so kann man die einzelnen Daten vergleichen. Handelt es sich beispielsweise um eine Menge von Mails, die man chronologisch sortieren will, so vergleicht man nicht zwei vollständige Mails, sondern betrachtet nur das Datum. Der Teil des Datensatzes, den man für das Sortieren verwendet, nennt man Schlüssel.
Wenn man die Effizienz eines Sortieralgorithmus analysiert, betrachtet man die Anzahl Schlüsselvergleiche, die ein Sortieralgorithmus durchführt. Je mehr Schlüsselvergleiche durchgeführt werden, desto schlechter bzw. langsamer läuft eine Sortieralgorithmus. Zusätzlich ist für die Effizienz auch die Anzahl der Bewegungen (z.B. Vertauschung zweier Elemente) von Daten wichtig.
Zuerst muss das Ziel klar sein: Wie erkennen wir ob ein Array sortiert ist?
Das Array heisse und sei indexiert von bis . Wir können das Prüfen mit einer simplen Schleife umsetzen.
Diese Überprüfung braucht nur maximal Vergleiche und kostet also Vergleiche, Zeit und Extraplatz.
Jetzt wollen wir aber nicht nur prüfen sondern auch selber sortieren. Dazu soll uns der Prüf-Algorithmus als Vorlage dienen. Denn sobald wir abbrechen beim Prüfen, wissen wir zwei Elemente, die wir vertauschen sollten.
Das alleine genügt noch nicht. Die Sequenz 453 wird zu 435 ist also noch nicht sortiert. Also ist ein einziger solcher Durchlauf noch nicht genug. Darum wiederholen wir diesen Loop mehrmals, so lange bis sich nichts mehr ändert. Wie lange geht das?
Behauptung: Nach höchstens Wiederholungen ist der Array sortiert.
Idee: Nach dem ersten Durchlauf steht das grösste Element an der Stelle ganz rechts und wird nachher nie mehr verschoben. Damit ist nach dem zweiten Durchlauf das zweitgrössten Element an der korrekten Stelle usw. Nach Durchläufen sind somit alle Schlüssel am korrekten Platz.
Da die Schlüssel hier Runde für Runde an den korrekten Ort "blubbern", nennt man dieses Verfahren Bubblesort.
Wir können frühzeitig abbrechen, wenn sich in einer Runde nichts mehr ändert.
Bringt diese Abbruchbedingung immer etwas?[1]
Bis jetzt haben wir jeweils nur auf die Anzahl Schlüsselvergleiche und auf den Extra-Platz geschaut. Was auch relevant ist, ist die Anzahl Bewegungen von Datensätzen.
Der schlimmste Fall ist die absteigend sortierte Folge als Eingabe. Dann benötigen wir Vertauschungen und Bewegungen.
Für einen bereits sortierten Array sind wir schon nach einer einzigen Schleife fertig, wenn wir Bubble-Sort klug implementiert haben. Wir können also davon profitieren, dass bereits vorsortiert ist.
Ein schöner Aspekt des Bubblesorts ist, dass es sich leicht und schön parallelisieren lässt. Dafür brauchen wir nur einen Prozessor, der 'Compare&Swap' unterstützt, der also zwei Elemente vergleicht und sie vertauscht, falls sie verkehrt herum sortiert sind. Wenn wir Prozessoren zur Verfügung haben, dann kann man die Arbeit schön aufteilen:
Ungerade Runden:
Gerade Runden:
So sind wir nach Runden auch fertig, aber brauchen nur (oder für Prozessoren) Schritte und damit ist die Laufzeit nur noch (resp. ).
Dies nennt man Odd-Even-Transposition Sort.
Ausser der Parallelisierbarkeit hat Bubblesort aber keine wirklichen Vorteile. Darum suchen wir nach was besserem.
Wir wollen nun unser Lieblingsverfahren zum Algorithmenentwurf bemühen, die Induktion. Wir nehmen als Invariante, dass wir nach dem -ten Schritt die ersten Elemente des Arrays sortiert sind und wir diese Elemente nie mehr verschieben müssen (Präfix ist "fix fertig"). Um diese Invariante aufrecht zu erhalten, fügen wir in jedem Schritt das Minimum des noch unsortierten rechten Teils ans Ende des sortierten linken Teils, also
| sortierter Teil | i | unsortierter, aber grösserer Teil |
Als Pseudocode:
Um das Maximum zu finden brauchen wir Vergleiche. Somit beläuft sich die Anzahl Vergleiche wieder auf die arithmetische Reihe .
Laufzeit: Vergleiche, Tauschoperationen, daher Zeit .
Extraplatz: .
Gegenüber Bubblesort haben wir also in der Laufzeit nichts gewonnen, aber immerhin führen wir nur noch linear viele Vertauschungen durch. Die quadratische Anzahl Vergleiche gefällt uns noch nicht. Hilft uns hier eine andere Invariante?
Nehmen wir als Invariante, dass das Präfix der Länge sortiert ist, aber nicht dass die Elemente bereits an ihrem finalen Platz sind. Diese Invariante können wir aufrecht erhalten, indem wir das neue Element einfach an der korrekten Stelle ins bisherige Teilarray einsortieren.
[1 2 7 9 | 4 | Rest] -> [1 2 4 7 9 | Rest]
Wie finden wir den Ort, wo das neue Element eingefügt werden sollte?
Wir können die Position mit binärer Suche finden: Vergleiche.
Dann nehmen wir das Teilstück, das einen Platz nach rechts muss, und schieben es Element für Element um eine Position nach rechts: Tauschoperationen.
Total: Vergleiche und Tauschoperationen (Bewegungen) und daher Laufzeit[1].
Können wir diese vielen Vertauschungen umgehen, indem wir, anstatt eines Arrays, eine linear verkettete Liste benutzen? Weil dann könnten wir ja durch das Umbiegen zweier Zeiger ein neues Element einfügen. Das Problem: Auf einer verlinkten Liste können wir nicht binär suchen.
Bis jetzt haben wir noch keinen Algorithmus, der schneller ist als quadratische Zeit. Aber es besteht Hoffnung: Wir haben ein Verfahren mit linear vielen Bewegungen und eines mit Vergleichen. Gelingt es uns das beste aus diesen zwei Welten zu kombinieren?
Wie macht man das? Das Problem bis jetzt war, dass wir jeweils viel Aufwand (lineare Zeit) betreiben mussten, um ein neues Element zu unserer Invariante hinzuzufügen. Versuchen wir unsere Invariante etwas aufzuweichen, sodass dies schneller geht.
Wir wollen uns vom Sortieren durch Auswahl inspirieren lassen, dabei aber versuchen das Auswählen ohne die linear vielen Vergleiche hinzubekommen. Wir wollen dazu dem noch unsortierten Teil des Array ein bisschen Ordnung aufdrücken, ihn also weder komplett unsortiert zu lassen noch komplett zu sortieren.
Wir stellen uns dazu vor, dass die Zahlen in A einen Binärbaum darstellen. Dabei sollen alle bis auf das letzte Niveau des Baumes voll sein. Das letzte Niveau füllen wir von links nach rechts.
Wurzel
5 Vater A: 1 2 3 4 5 6
/ \ 5 7 1 6 4 2
7 1 Kind
/ \ /
6 4 2
Blätter
Das können wir implizit machen. Die beiden Kinder von Knoten , stehen an Position und . Und der Vater von Knoten i steht an .
Warum ist dies eine nützliche Idee? Weil diese Baumstruktur nur eine geringe Höhe hat. Bei den Rechnungen der Binären Suche haben wir gesehen, dass wir mit einem Baum der Höhe schon Elemente unterbringen können. Wenn wir also auf diesen Niveaus des Baumes operieren können, dann können wir uns vielleicht Laufzeiten von pro Operation erhoffen.
Im Moment haben die Elemente im Baum aber noch keinerlei Struktur. Wir wollen also etwas Struktur verlangen, aber auch nicht zu viel, sonst wird es wieder linear. Was wir verlangen wollen: Für jede Eltern-Kind Beziehung, soll das grössere Element oben stehen. Der Wert im Elternknoten soll also immer grösser sein als der Wert im Kinderknoten. Dies nennt man die Heap-Bedingung.
Nehmen wir mal kurz an, dass die Eingabe "per Zufall" schon die Heapbedingung erfüllt, zum Beispiel so:
Wurzel
7 A: 1 2 3 4 5 6
/ \ 7 6 4 5 1 2
6 4
/ \ /
5 1 2
Blätter
Jetzt wollen wir die sortierte Folge schrittweise aus diesem Heap extrahieren. Wir können schnell herausfinden, was das letzte Element der sortierten Folge ist, denn das grösste Element muss an der Wurzel des Heaps stehen. Tauschen wir also die mit der aus.
2 A: 1 2 3 4 5 6
/ \ 2 6 4 5 1|7
6 4
/ \ X
5 1 7
Nun zwacken wir die ab und nehmen an, dass sie nicht mehr Teil vom Heap ist. Um induktiv argumentieren zu können, wäre es gut, wenn der Rest (also die ersten Elemente) wieder einen Heap bilden würden. Da nun aber die an der Wurzel steht, ist die Heapbedingung dort verletzt. Wir können die Heap-Bedingung aber leicht wieder herstellen, indem wir die neue Wurzel "versickern" lassen. Dazu schauen wir die beiden Kinder von an und nehmen das grössere Element nach oben:
6 A: 1 2 3 4 5 6
/ \ 6 2 4 5 1|7
2 4
/ \ X
5 1 7
Jetzt stimmt die Heap-Bedingung an der Wurzel wieder, aber die sollte auch noch mit der die Plätze tauschen, also weiter versickern.
6 A: 1 2 3 4 5 6
/ \ 6 5 4 2 1|7
5 4
/ \ X
2 1 7
Jetzt haben wir wieder einen Heap und - oh Wunder - das global zweitgrösste Element steht nun an der Wurzel. Also vertauschen wir die mit der , zwacken die ab und lassen die versickern.
1 A: 1 2 3 4 5 6
/ \ 1 5 4 2|6 7
5 4
/ X X
2 6 7
5 A: 1 2 3 4 5 6
/ \ 5 1 4 2|6 7
1 4
/ X X
2 6 7
5 A: 1 2 3 4 5 6
/ \ 5 2 4 1|6 7
2 4
/ X X
1 6 7
Was noch fehlt: Wie bilden wir einen solchen Heap?
Induktiv: Nehmen wir an wir hätten schon zwei kleine Heaps und wollen ein neues Element als Wurzel der beiden hinzufügen und danach einen grossen Heap daraus erstellen. Genau dies, leistet die Funktion versickere
.
x
/ \
H1 H2
Machen wir die versickere
-Funktion noch explizit:
Nun müssen wir uns noch um die initiale Heapherstellung kümmern. Wir machen dazu Induktion von unten, um auch zum Bauen des Heaps wiederholt zwei kleinere Heaps zusammenzufügen. Dabei sind alle Blätter für sich alleine genommen bereits korrekte kleine Heaps. Wenn wir danach die Positionen von bis der Reihe nach runter gehen, dann haben wir jeweils einen neuen Knoten mit zwei Teilbäumen bei und darunter, die bereits die Heapbedingung erfüllen. Ein einzelnes versickere(i,n)
stellt daher die Heapbedingung für den ganzen Baum an Position her.
Wie verwenden wir also dieses versickere
nun genau?
Wieviele Vergleiche machen wir?
Das versickere
läuft quasi einfach einem Pfad dem Baum entlang, sieht also höchstens Niveaus. Auf jedem Niveau müssen wir bis zu zwei Schlüsselvergleiche machen. Somit machen wir rund Vergleiche pro versickere
.
Somit machen wir insgesamt Vergleiche (ohne den Heap zu bauen). Dies absolut ohne Extraplatz! Ein Wermutstropfen (z.B. gegenüber dem Mergesort, das wir noch sehen werden) ist der Faktor , dazu unten mehr.
Wieviele Bewegungen machen wir?
Entlang eines Versickerpfades machen wir maximal Vertauschungen. Somit ist die Anzahl Bewegungen auch in .
Wieviel kommt noch dazu beim Bauen des Heaps?
Wir rufen versickern mal auf, also höchstens . Aber wenn man genauer hinschaut, sieht man, dass unsere Versickerungspfade oft viel kürzer sind: Für die Knoten an Position bis ist der Pfad höchstens lang, für bis höchstens usw. Erst für die Wurzel kann es werden.
Wenn wir das so aufsummieren, kommt heraus, dass das Herstellen des Heaps sogar in linearer Zeit geht:
Das Phänomen, das wir hier ausnutzen: Fast alle Knoten eines Baumes sind Blätter oder sehr weit unten im Baum. Nur sehr wenige Knoten haben einen logarithmisch langen Versickerungspfad, sodass die Versickerungslänge im Schnitt konstant ist..
Total: Vergleiche und Bewegungen.
Ein Wermutstropfen beim Heapsort ist der Umstand, dass wir zwei Vergleiche brauchen, um den Schlüssel um eine Ebene zu versickern. Was wir aber sehen können: Schon nach dem ersten Vergleich (Vergleich der beiden Kinder) wissen wir, ob der Schlüssel, falls er versickert, nach links oder rechts versickert. Mit dieser Beobachtung können wir den gesamten potentiellen "Versickerpfad" des Schlüssels finden, ohne den Schlüssel selbst zu kennen. Daher können wir zuerst diesen Weg finden in Vergleichen. Anschliessend können wir die Position des Schlüssels auf diesem Weg in Vergleichen mittels binärer Suche finden.
Statt in geht es also auch in Schritten.
Weitere Idee: Drei Viertel aller Knoten sind auf den untersten zwei Ebenen. Das heisst, wenn wir auf dem Versickerpfad den Knoten suchen, dann landen wir fast immer ganz unten. Darum ist es eine gute Idee statt der binären Suche auf dem Versickerpfad einfach von hinten an linear zu suchen und fast immer werden wir sehr schnell fündig. Dies nennt man Bottom-Up Heapsort.
Es gibt Leute, die sagen, dass Bottom-Up Heapsort für grosse Datenmengen sehr schnell ist, schneller als Mergesort, das wir noch sehen werden.
Eine häufige Kritik an Heapsort ist aber seine schlechte Lokalität. Das Ablaufen eines Pfades entspricht einem wilden Umherhüpfen im Array, was es in der Praxis verlangsamt, da Caching nicht gut funktioniert. Mergesort wird hier viel lokaler sein.