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.
Erinnern wir uns daran, was wir bei Quicksort gesehen haben:
Bevor wir uns noch weitere Sortierverfahren anschauen, wollen wir uns mal kurz besinnen und fragen, wie lange wir dieses Optimierungsspiel noch weiterspielen können. Wie schnell kann ein Sortierverfahren überhaupt sein?
Wir möchten eine untere Schranke, die für jeden Sortieralgorithmus gilt. Wir wollen also zeigen, dass für das Sortierproblem eine bestimmte Anzahl an Vergleichen mindestens notwendig sind für jeden erdenklichen, vergleichsbasierten Sortieralgorithmus.
Die einzige Einschränkung ist also: Wir gehen davon aus, dass der Algorithmus nichts macht ausser Schlüsselvergleiche. Wir wollen also nicht Verfahren anschauen, bei denen wir mit den Schlüsseln mehr machen können, als sie untereinander zu vergleichen, z.B. sie miteinander zu verrechnen oder quasi in die Schlüssel hineinzuschauen. Aber alle Verfahren, die wir bis jetzt gesehen haben, waren auch so - rein vergleichsbasiert.
Wir nehmen als Eingabe eine Schlüsselmenge
Ein korrektes Sortierverfahren muss jede mögliche Permuatation erzeugen können, denn wir können jede Permutation als Ausgabe erzwingen indem wir den Schlüsseln zum Beispiel entsprechende ganzzahlige Werte von
Wir können uns ein Sortierverfahren als Entscheidungsbaum vorstellen. Wir beginnen oben und notieren in jedem Knoten den Schlüsselvergleich, den wir durchführen. So kann ein Verfahren zum Beispiel mit dem Vergleich "Ist
Die Laufzeiteigenschaften eines Sortierverfahrens können wir so aus seinem Entscheidungsbaum ablesen.
Die Anzahl Vergleiche eines Sortiervorganges ist dann die Anzahl besuchter Knoten auf dem Weg durch den Entscheidungsbaum. Im schlimmsten Fall besuchen wir das tiefste Blatt und im besten Fall das oberste Blatt, das am nächsten bei der Wurzel liegt. Wenn wir die mittlere Laufzeit bestimmen möchten, können wir dazu die mittlere Blatttiefe des Entscheidungsbaumes bestimmen. So eine Blatttiefen-Analyse kommt uns von der binären Suche her bekannt vor.
Wir wissen, dass der Entscheidungsbaum
Wir behaupten, dass die mittlere Tiefe
Behauptung:
Verankerung:
Wählen wir das kleinste
ein Widerspruch zur Annahme, dass
Wir wissen nun, dass der Entscheidungsbaum
Nun wollen wir unseren Beweis gleich selbst relativieren und die
Stellen wir uns vor, wir wissen bereits im Vorfeld, dass die Schlüssel einfach die Zahlen von
Sind die Schlüssel ganze Zahlen oder allgemein aus einem beschränkten Universum, so können wir ebenfalls etwas besseres tun. Nehmen wir an, wir haben nur zehnstellige Zahlen. Zum Beispiel tausend zehnstellige Zahlen. Wir schauen uns zuerst nur die ersten drei Stellen an und sortieren die Zahlen in entsprechende Gruppen, sogenannte Buckets, ein. Die Zahl
Ein weiteres Verfahren, das Bucketsort quasi wiederholt anwendet, ist das Sortieren durch Fachverteilen, englisch Radixsort. Es wurde zum Beispiel früher zum Sortieren von Lochkarten genutzt und wird auch heute noch zum Sortieren von Briefen nach Postleitzahl genutzt.[1]
Nehmen wir als Beispiel die Schlüssel:
Die Idee ist nun, die Zahlen Ziffer für Ziffer zu betrachten, daher auch der Name Radix (lateinisch Wurzel), nach der "Wurzel" des Zahlensystems.
So teilen wir die Zahlen gemäss ihrer führenden Ziffer in eine von
In unserem Beispiel erhält nur ein Bucket mehr als einen Schlüssel: der erste Bucket enthält
Da wir bei diesem Verfahren mit der führenden Ziffer beginnen, nennt man dieses Verfahren MSD Radix Sort (für Most Significant Digit). In der Praxis macht man das natürlich nicht unbedingt im Zehnersystem, sondern eher im Binärsystem, wir schauen uns also Bit für Bit an.
Für dieses Verfahren brauchen wir also Zugriff auf die einzelnen Ziffern der Schlüssel und wir nehmen an, dass Zugriff auf die Ziffern die konstante Kosten haben. Die Anzahl der Fächer hängt von der Art der Schlüssel ab. Falls wir z.B. Buchstaben und Wörter sortieren wollen, dann benötigen wir
Für die Implementierung müssen wir uns auch überlegen, wie wir das genau machen mit dem Fachverteilen. Wir wissen ja nicht im Voraus wie viel in jedem Fach landen wird. Wir können jedes Fach als Liste implementieren und den Platz dafür vorzu allozieren aber es gibt einen einfachen Trick, der dies erübrigt: Wir können in einem ersten Durchgang auszählen wie gross jedes Fach sein muss. Dann legen wir ein einzelnes Array der gesamten Länge
Die Anzahl Sortierschritte hängt nun von der Länge der Zahl ab. Sei dazu
Nehmen wir nochmals das gleiche Beispiel, verteilen wir aber zuerst nach der letzten Ziffer auf die Fächer auf:
Die Eingabe
0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
---|---|---|---|---|---|---|---|---|---|
12 | 04 | 17 | 19 | ||||||
64 | 27 | ||||||||
34 |
Danach sammeln wir die Zahlen von links nach rechts ein:
0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
---|---|---|---|---|---|---|---|---|---|
4 | 12 | 27 | 34 | 64 | |||||
17 | |||||||||
19 |
Nun haben wir die Schlüssel sortiert vor uns:
Warum funktioniert das? Das Einsammeln und Neuverteilen führt dazu, dass die Vorsortierung nach der hintersten Ziffer beim Sortieren nach der zweithintersten Ziffer erhalten bleibt.
Und auch das lässt sich wieder leicht implementieren. Entweder durch das Anlegen solcher Listen oder durch das Auszählen der Korbgrössen, wie wir vorher gesehen haben.
Die Laufzeit ist erneut
Bis jetzt haben wir uns darauf beschränkt, Schlüssel zu suchen und wir haben gesehen, dass wir das schnell können, wenn die Schlüssel sortiert sind. Nun will man aber eigentlich auch Schlüssel hinzufügen und löschen können.
Ein abstrakter Datentyp ist eine Beschreibung von Daten und den verschiedenen darauf anwendbaren Operationen (eben z.B. Einfügen, Löschen, Suchen, usw.). Eine Implementierung eines abstrakten Datentyps nennt man Datenstruktur.
Wenn man von Efiizienz von den Datenstrukturen spricht, dann soll man Bescheid geben, ob eine Folge von Operationen möglichst günstig sein soll, oder die Laufzeit einer Einfügen-Operation, einer Entfernen-Operation usw. angeben.
Wollen wir beispielsweise nur Einfügen und Löschen unterstützen, ohne irgendwelche Abfragen, lässt sich das trivial in konstanter Zeit pro Operation mittels maximaler Faulheit implementieren. Bei einer Einfüge-Operation sagen wir einfach "Ja, erledigt", tun aber gar nichts und ebenso fürs Entfernen. Da der Benutzer der Datenstruktur nie was über den aktuellen Zustand erfahren kann, müssen wir uns auch nichts merken.
Spannend wird es erst, wenn wir auch Suchen unterstützen wollen, oder wenn die Einfüge-Operation auch zurückgibt, ob der Datensatz schon da war, oder wenn wir beim Entfernen antworten, ob der Datensatz überhaupt vorkam.
Wir sind nun an Datenstrukturen für einen abstrakten Datentyp interessiert, der die Operationen Einfügen, Löschen und Sortieren umfasst, interessiert. Eine Datenstruktur, die diesen abstrakten Datentyp implementiert, nennt man, etwas unmotiviert, Wörterbuch-Datenstruktur. Wir schauen uns nun an, wie man so ein "Wörterbuch" implementieren kann.
Die einfachste Implementierung eines Wörterbuches gelingt uns mit einem Array, das wir fortlaufend sortiert halten. Suchen kostet uns mittels binärer Suche
Bringt uns die Sortierung was? Wenn wir das Array unsortiert halten, dann ist das Einfügen trivial (wir hängen in konstanter Zeit hinten an), aber die Suche und das Entfernen dauert dann lineare Zeit.
Wir tauschen also eine lineare Operation gegen eine andere, aber eigentlich würden wir doch gerne alles in logarithmischer Zeit erledigen. Ein Array erfüllt unsere Anforderungen also weder sortiert noch unsortiert.
In einer sortierten lineare Liste zu Suchen, Einfügen und zu Entfernen kostet uns je lineare Zeit
Bei einer linearen Liste wird ein Element mit einem Zeiger mit seinem Nachfolger verbunden. Im Unterschied zu einem Array können wir nicht mehr auf jeden beliebigen Eintrag in konstanter Zeit zugreifen, sondern müssen den Zeigern folgen, bis wir auf das gesuchte Element treffen.
Wenn wir bereits wüssten wo in der Liste wir eine Element einfügen oder löschen möchten, so könnten wir in konstanter Zeit, indem wir die Zeiger entsprechend verbiegen. Für die Suche wird aber lineare Zeit benötigt, selbst wenn die Liste sortiert wird, da wir ja den Zeigern folgen müssen und nicht direkt auf beliebige Elemente in der Liste zugreifen können. Damit benötigt auch das Löschen lineare Zeit (egal ob sortiert oder nicht). Falls wir beim Einfügen zusätzlich prüfen wollen, ob das Element bereits vorhanden ist, dann benötigt auch diese Operation lineare Zeit in unsortierten Listen.
Unser Traum ist nach wie vor unerfüllt:
Eine weitere Idee ist es, die sortierte Liste mit zusätzlichen Zeigern auszustatten, sodass wir weit umherspringen können und so eine binäre Suche erlauben können. Skizziert für sieben Elemente sieht das so aus:
Dieses Zeiger-Konstrukt nennt man Skipliste, eben weil man weite Distanzen skippen kann. Wir sagen eine Skipliste hat die Höhe
Das Suchen in einer Skip-Liste kostet uns wie gesagt
Wie erhalten wir eine ähnliche Zeigerstruktur effizient? Wir lassen die Skip-Zeiger nicht genau in die Mitte, genau in einen Viertel etc. zeigen sondern, wir entscheiden randomisiert, wie viele Links ein Element braucht.
Wir werfen dazu nach dem Einfügen eines neuen Elementes eine Münze, die uns sagt, ob wir noch einen weiteren Zeiger hinzufügen sollen oder nicht. Zeigt die Münze Kopf, fügen wir einen weiteren Link ein und teilen dazu den vorhergehenden Zeiger auf dem gleichen Level, der das neue Element überspannt, entsprechend auf. Dies wiederholen wir so lange bis die Münze einmal Zahl zeigt. Somit hat das durchschnittliche Element nur wenige Skip-Links, aber ein paar Element erlauben uns weiterhin weit zu springen.
Die Analyse und Implementierung lassen wir hier weg, uns geht es nur um die Idee. Die Essenz ist, dass wir, in bester Binärsuchtradition, weiterhin über viele Elemente hinwegspringen können wollen und dies erreichen wir auch mit der folgenden Datenstruktur.
Ein Suchbaum ist ein Baum mit den Schlüsseln in den Knoten, der folgende Bedingung erfüllt: Die Schlüssel im linken Teilbaum sind immer kleiner als die Wurzel und die Schlüssel im rechten Teilbaum sind immer grösser als die Wurzel. Diese Bedingung soll für jeden Teilbaum gelten.
So einen Baum kann für die Suche sehr gut verwendet werden: Wir beginnen bei der Wurzel und vergleichen den dort gespeicherten Schlüssel mit dem gesuchten Schlüssel. Dann wissen wir, ob wir nach rechts oder nach links gehen müssen (oder den Schlüssel bereits gefunden haben). Dasselbe machen wir dann für den entsprechenden Teilbaum, bis wir an ein Blatt gelangen oder den Schlüssel gefunden haben. Sind wir an einem Blatt angekommen, ohne den Schlüssel gefunden zu haben, so können wir sicher sein, dass der Schlüssel nicht im Baum gespeichert ist.
Wenn wir uns an den Heap erinnern, sehen wir, dass dieser Baum eine strengere Bedingung erfüllt. Dadurch können wir in so einem binären Suchbaum suchen, während das in einem Heap nicht so einfach und schnell möglich ist.
Bevor wir uns Gedanken dazu machen, wie wir einen Suchbaum bauen und verwalten, wollen wir uns (ähnlich wie letzte Woche beim Sortieren) überlegen, wie wir denn einen gültigen Suchbaum erkennen können.
Wir können die Gültigkeit eines Suchbaumes
Inorder-Traversierung (deutsch: Symmetrische Reihenfolge).
spucke T aus:
falls T nicht leer
spucke T.links aus
zeige Schlüssel der Wurzel
spucke T.rechts aus
Im Beispiel kommt so
Statt die Wurzel in der Mitte auszugeben, können wir das auch anders machen. Auch diese beiden Varianten haben spezielle Namen:
Preorder-Traversierung (deutsch: Hauptreihenfolge)
preorder(T)
- Wurzel
- preorder(T.links)
- preorder(T.rechts)
Im Beispiel:
Postorder-Traversierung (deutsch: Nebenreihenfolge)
postorder(T)
- postorder(T.links)
- postorder(T.rechts)
- Wurzel
Im Beispiel: