Bern University of Applied Sciences Engineering and Information Technology Algorithmen & Datenstrukturen P. Fierz Keywords: Datenstrukturen, Abstrakter Daten Typ (ADT), Algorithmen, Berechenbarkeit, Komplexität, Sortieren, Suchen, Graphen [File had.tex, Date 29.08.2011, Version V 3.1] © P. Fierz Zusammenfassung In diesem Skript werden wichtige Datenstrukturen wie der Stack, die Queue, der Baum, Graphen usw. als abstrakte Datentypen ADT vorgestellt. Dazu werden viele Algorithmen vorgestellt, die diese Datenstrukturen nutzen. Eine Einführung in die Berechenbarkeitstheorie und in die Komplexitätstheorie sind ebenfalls vorhanden. Kapitel 1 Daten 1.1 Einleitung Der Digital-Computer wurde entwickelt, um komplexe und aufwendige Berechnungen durchzuführen. Dabei werden reelle, ganze und natürliche Zahlen verwendet. Diese Objekte können im Computer auch sehr gut dargestellt und manipuliert werden (die Operationen auf Zahlen stehen auch als Maschineninstruktionen zur Verfügung). Bei den meisten Applikationen steht aber eine andere Eigenschaft des Computers im Vordergrund: seine Fähigkeit, grosse Datenmengen zu speichern. In diesen Fällen stellen die gespeicherten Daten eine Abstraktion eines Teils der realen Welt dar. Mit Abstraktion meinen wir eine Abbildung der realen Welt in ein gegebenes Datenmodell. Wie bei den meisten Modellen können dabei nicht alle Aspekte der Realität berücksichtigt werden, da das Modell dadurch viel zu komplex würde. Daher werden nur diejenigen Aspekte berücksichtigt, die für das zu lösende Problem als wichtig erachtet werden. Beispiel 1.1 [Personaldaten] Als Beispiel vergleichen wir die Personaldaten in einer Arbeitgeberkartei und in einer Arztkartei. Möglichkeiten für diese verschiedenen Abstraktionen sind in der Tabelle 1-1 dargestellt. Aufbau der Arbeitgeberkartei Name Adresse Funktion Gehalt Lohnentwicklung usw. Aufbau der Arztkartei Name Adresse Grösse Gewicht Krankengeschichte usw. Abbildung 1-1: Abstraktion von Personen Die Tabelle zeigt auch, dass Daten, die für den Arbeitgeber unwichtig sind, in der Arbeitgeberkartei einfach weggelassen werden. Dasselbe gilt auch für den Arzt und die Arztkartei. Somit kann also jede Abstraktion auch als Vereinfachung der realen Tatsachen gesehen werden. Bei der Abbildung der realen Welt in ein Datenmodell sind zwei wichtige Aspekte zu berücksichtigen. 1-1 Abstraktion Diesen Aspekt haben wir oben schon erwähnt. Es geht darum, von einem realen Objekt die für die Applikation wichtigen Daten zu identifizieren und unwichtige Daten wegzulassen. Darstellung Für die gewählten Daten der Abstraktion muss im Computer nun auch eine Darstellung gewählt werden. Diese Aufgabe ist in vielen Fällen schwierig. Die Wahl der Darstellung der Daten im Computer muss immer in Bezug auf die mit den Daten durchzuführenden Operationen (Algorithmen oder Programme) gesehen werden. Beispiel 1.2 [Darstellung von Zahlen] Wir wollen die Darstellung von Zahlen im kommerziellen und im wissenschaftlich-technischen Bereich betrachten. technische Darstellung Im technisch-wissenschaftlichen Bereich werden von Zahlen folgende Eigenschaften verlangt: • Das Rechnen ist die Hauptoperation und muss möglichst effizient sein. • Es werden sehr grosse und auch sehr kleine Zahlen benötigt. (Bereich ca. 10−40 bis 1040 ) • Im allgemeinen genügt eine Genauigkeit von 10 bis 16 signifikante Stellen zur Berechnung und Ausgabe der Resultate. • Die Druckoperation ist viel seltener als die Rechenoperationen. Diese Überlegungen legt die floating point-Darstellung nahe, die nachstehend erklärt ist. V orzeichen Exponent M antisse Das V orzeichen besteht aus einem Bit, dabei bedeutet 0 eine positive und 1 eine negative Zahl. Der Exponent (zur Basis 2) besteht aus 8 Bits und stellt eine binäre Zahl zwischen 0 und 255 dar. Um den effektiven Exponenten der Zahl zu bekommen, muss von der gespeicherten Zahl 127 abgezogen werden. Anders ausgedrückt: die Zahlen 1 bis 126 stellen die negativen, die Zahlen 127 bis 255 die positiven Exponenten dar. Falls der Exponent 0 ist, so ist die Zahl 0. Die M antisse besteht aus 23 Bits und stellt einen normalisierten Dualbruch dar. Normalisiert heisst, dass die erste signifikante Stelle immer unmittelbar vor dem Komma steht. Da die Stelle vor dem Komma immer 1 ist, wird diese nicht gespeichert. In einer Formel zusammengefasst gilt: zahl = V orzeichen · (2(Exponent−127) · 1.M antisse) kommerzielle Darstellung Im kommerziellen Bereich werden Zahlen vor allem zur Darstellung von Geldbeträgen und Mengenangaben verwendet. Für diese Objekte werden die folgenden Eigenschaften verlangt: • Mit den Zahlen wird wenig gerechnet (höchstens Additionen). Dafür werden die Zahlen sehr oft gedruckt. 1-2 • Der benötigte Bereich ist viel kleiner als im technischen Bereich. Vor allem sind keine sehr kleinen Zahlen notwendig, so dass die Anzahl Stellen nach dem Komma fix vereinbart werden kann (z.B. 4 Stellen). • Die Zahlen müssen sowohl beim Rechnen wie auch in der Ausgabe genau sein. Es dürfen also keine signifikanten Stellen verlorengehen. Diese Überlegungen legt die fix point-Darstellung nahe, die nachfolgend erklärt ist. n − m Vorkommastellen m Nachkommastellen Vorzeichen Dabei sind n und m vereinbarte Konstanten. Der Dezimalpunkt steht somit immer an derselben Stelle. Die Zahlen werden nicht in binärer, sondern in dezimaler Form gespeichert. Jede Dezimalstelle und das Vorzeichen werden durch je 4 Bits dargestellt. Mit diesen vier Bits können nun die Ziffern von 0 bis 9 dargestellt werden. Als Beispiel stellen wir die Zahl 235.7310 dar. 0000 | {z } 0000 | {z } . . . 0010 | {z } 0011 | {z } 0101 | {z } 0 0 2 3 5 0111 | {z } 0011 | {z } 0001 | {z } 7 3 1 0000 | {z } 0000 | {z } 0 + Wir wollen am Schluss dieser Einführung versuchen, den Begriff “Computer-Daten” genauer zu definieren. Definition 1.1 [Computer-Daten] Computer-Daten sind Objekte mit den drei folgenden Eigenschaften 1. Bezeichnung Die Bezeichnung trägt den semantischen Teil (Bedeutung) des Objektes (siehe auch Datentypen). 2. Wertemenge Die Wertemenge legt alle möglichen Werte fest, die ein Objekt dieses Types annehmen kann. Die Wertemenge bestimmt somit die Syntax (Form) eines Objektes. 3. Speicherplatz Der Speicherplatz lokalisiert das Objekt im Speicher und identifiziert dieses eindeutig. Beispiel 1.3 [Computer-Daten] Datenobjekt: "Müller" Bezeichnung: Familienname einer Person. Damit ist die Bedeutung des angegebenen Objekts festgelegt. Wertemenge: Alle Folgen von maximal n alphabetischen Zeichen. Das erste Zeichen muss ein grosser Buchstabe sein. Speicherplatz: Eine Adresse im Speicher, wo die gegebene Zeichenfolge beginnt. Bemerkung 1.1 [Semantik] In der Informatik wird die Semantik durch den Datentyp eines Objektes gegeben. Die Wertemenge ist im Prinzip durch die Anzahl reservierter Bits im Speicher gegeben. 1-3 1.2 Datentypen Wie in der Mathematik ist es in der Informatik üblich, Objekte nach bestimmten wichtigen Eigenschaften zu ordnen. In der Mathematik werden genaue Unterscheidungen zwischen einzelnen Werten, Mengen von Werten, Funktionen, Mengen von Funktionen u.s.w. gemacht. In der Informatik werden verschiedene Objekte wie Zahlen, Funktionen, Personaldaten, Wetterdaten, Rechnungen u.s.w. bearbeitet. Bei einer solchen Vielfalt ist es vielleicht noch wichtiger als in der Mathematik, diese Objekte nach ihren Eigenschaften zu ordnen. Dazu führen wir den Begriff des Datentyps ein. Definition 1.2 [Datentyp] Der Datentyp bestimmt eine Menge von Werten, welche die Objekte dieses Typs annehmen können. Diese Menge bezeichnet man als Wertemenge des Datentyps. Die Elemente des Wertebereichs bezeichnet man auch als Konstanten des Datentyps. Der Datentyp bestimmt auch die Operatoren, welche auf dem Wertemenge definiert sind und somit auf Objekte dieses Typs angewandt werden können. Beispiel 1.4 [Datentyp] Wir können die Wertemenge eines Datentyps vollständig beschreiben, indem wir alle Elemente der Menge aufzählen. In der Sprache C++ wird ein solcher Typ folgendermassen definiert: enum Farben {rot, blau, grün, braun}; Als Operator ist nur der Vergleich zweier Werte auf Gleichheit definiert. Weiter können in C++ weitere Operatoren wie ++, <, . . . definiert werden. Bemerkung 1.2 [Syntax und Semantik] Die Wertemenge des Datentyps legt die Syntax fest (Platzbedarf und Codierung). Die Operationen bestimmen den semantischen Teil des Objektes. 1.3 Atomare Typen Als atomare Typen bezeichnen wir Datentypen, die in einer Sprache schon definiert sind. Aus diesen atomaren Typen können mit Hilfe von Typkonstruktoren (siehe 1.4) weitere Typen abgeleitet werden. Die atomaren Typen sind also die Bausteine des ganzen Typsystems einer Programmiersprache. Welche atomaren Typen zur Verfügung stehen, hängt von der gewählten Programmiersprache ab. Im folgenden stellen wir vier wichtige Typen vor, die in den meisten höheren Programmiersprachen existieren. 1.3.1 Ganze Zahlen (INTEGER) Dieser Typ umfasst ein um die Zahl Null symmetrisches Intervall der ganzen Zahlen. Innerhalb dieses Intervalls können die Zahlen genau dargestellt werden. Als Operationen stehen die Addition, die Subtraktion, die Multiplikation und die ganzzahlige Division zur Verfügung. Die Operationen sind bezüglich der Menge INTEGER nicht abgeschlossen. Das Resultat einer Addition, einer Subtraktion oder einer Multiplikation kann grösser (bzw. kleiner) als die für INTEGER gegebenen Intervallgrenzen sein. Liegt das Resultat einer Operation ausserhalb des gegebenen Intervalls, so spricht man von Overflow. 1-4 1.3.2 Reelle Zahlen (REAL) Dieser Datentyp bezeichnet eine Teilmenge der reellen Zahlen. Besonders einschneidende Auswirkungen hat hier die Endlichkeit von mit dem Computer darstellbaren Wertemengen. Während bei den ganzen Zahlen noch gefordert werden konnte, dass jede arithmetische Operation bis auf Overflow-Situationen zu einem korrekten Resultat führt, gilt dies beim Rechnen mit Realzahlen nicht mehr. Der Grund liegt darin, dass jedes noch so kleine Intervall der reellen Zahlenachse unendlich viele Werte enthält. Der Datentyp REAL stellt nun eine endliche Menge von Repräsentanten von Intervallen der Zahlenachse dar. Dadurch wird die reelle Zahlenachse diskretisiert. Aus der Tatsache, dass jede Zahl nur durch einen Repräsentanten dargestellt werden kann, folgt, dass bei Berechnungen nur angenähert richtige Resultate erwartet werden können. Die Abschätzung der entstehenden Fehler ist das Anliegen der numerischen Mathematik. In der Abbildung 1-2 sind die REAL auf der reellen Zahlenachse Dargestellt. 0 −max +max Overflowbereich Overflowbereich Underflowbereich Abbildung 1-2: Die REALs auf der reellen Zahlenachse Bemerkung 1.3 [Under- und Overflow] Das Intervall der reellen Zahlen, das durch die REAL-Zahl 0 repräsentiert wird, heisst Underflow-Bereich. Fällt das Resultat einer Operation in diesen Bereich, spricht man von Underflow. Underflow ist für numerische Berechnungen gleich fatal wie Overflow. 1.3.2.1 Endliche Arithmetik Im Beispiel 1.2 haben wir gesehen, dass die reellen Zahlen als floating point-Zahlen dargestellt werden. Zahl = V orzeichen · (2(Exponent−127) · 1.M antisse) Diese Darstellung der reellen Zahlen nennt man eine endliche Arithmetik. Nachfolgend werden wir die reellen Zahlen mit R und die Menge der Zahlen einer endlichen Arithmetik mit M (M steht für Maschinenzahlen bezeichnen. Nachfolgend sind einige Eigenschaften einer endlichen Arithmetik aufgeführt. 1. Die Wertemenge M des Datentyps REAL ist eine Untermenge der reellen Zahlenmenge R. M⊂R 2. Jeder Zahl x ∈ R ist eindeutig ein x̃ ∈ M zugeordnet, welches als Repräsentant von x bezeichnet wird. 1-5 3. Jedes x̃ ∈ M repräsentiert alle Zahlen in einem Intervall um x̃. Die Grösse des Intervalls ist von der Anzahl signifikanter Stellen (Mantisse) und der Grösse der Zahl selbst abhängig. Aus der Darstellung von floating point-Zahlen ergibt sich, dass die Anzahl Repräsentanten von reellen Zahlen in M mit wachsendem x exponentiell abnimmt (siehe Abbildung 1-2). 4. Die reellen Zahlen 0 und 1 können in M exakt dargestellt werden. Es gilt: 0 = 0̃ und 1 = 1̃ 5. Das Intervall der reellen Zahlen, das durch die REAL-Zahl 0 repräsentiert wird heisst Underflow-Bereich 6. Sind x, y ∈ M, dann ist das Resultat der Addition im Allgemeinen nicht mehr in M. Dies gilt auch für die anderen arithmetischen Operatoren (−, ·, /). Damit das Resultat wieder in M zu liegen kommt, muss nach jeder Operation das Resultat auf die gegebene Anzahl Stellen der Mantisse gerundet werden. Aus diesem Grund gelten in einer endlichen Arithmetik nicht alle bekannten Gesetze der gewöhnlichen Arithmetik. • Das Assoziativgesetz der Addition gilt in einer endlichen Arithmetik im allgemeinen nicht: (x + y) + z 6= x + (y + z) Ein Zahlenbeispiel, welches das Gesetz verletzt, sei an einer vierstelligen (dezimalen) Arithmetik aufgezeigt. Intern wird mit 5 Stellen gerechnet und nach jeder Operation das Resultat auf 4 Stellen gerundet. x = 9.100 y = 3.0 · 10−4 z = 4.0 · 10−4 (x + y) + z = (9.100 + 0.0003 +0.0004 = 9.100 | {z gerundet=9.100 | {z } } gerundet=9.100 x + (y + z) = 9.100 + (0.0003 + 0.0004) = 9.101 | | {z gerundet=0.0007 {z } } gerundet=9.101 • Das Distributivgesetz gilt in einer endlichen Arithmetik im allgemeinen nicht: x · (y + z) 6= (x · y) + (x · z) Wir illustrieren dies wieder an einem Zahlenbeispiel. x = 2.000 y = 3.000 z = 3.000 · 10−4 x · (y + z) = 2.000 · (3.000 + 0.0003) = 6.000 | | {z gerundet=3.000 {z gerundet=6.000 1-6 } } (x · y) + (x · z) = (2.000 · 3.000) + (2.000 · 0.0003) = 6.001 | {z } gerundet=6.000 | 1.3.2.2 | {z } gerundet=0.0006 {z gerundet=6.001 } Absoluter und relativer Fehler Wir haben schon gesehen, dass nach jeder Operation in M das Resultat auf die Anzahl Stellen der Mantisse gerundet werden muss. Es entsteht also ein Rundungsfehler. Unsere Resultate sind immer nur Approximationen des exakten Resultats. Definition 1.3 [Fehler] Sei x̃ eine Approximation der Zahl x so definieren wir den absoluten Fehler als |x − x̃| und den relativen Fehler als |x − x̃| |x| Beim Rechnen interessiert uns vor allem der relative Fehler. 1.3.2.3 Numerische Auslöschung Die numerische Auslöschung ist ein spezieller Rundungsfehler, der auftritt, wenn zwei nahezu gleichgrosse Zahlen voneinander subtrahiert werden. Wir betrachten dazu die folgende Operation. 1.2345 · 100 − 1.2344 · 100 = 1.0000 · 10−4 Die Nullen nach dem Dezimalpunkt im Resultat sind nur richtig, wenn die beiden Operanden exakte Zahlen waren. Waren die Operanden schon durch einen Rundungsfehler verfälscht, so ist das exakte Resultat der Operation 1.xxxx · 10−4 wobei die mit xxxx bezeichneten Stellen nicht bekannt sind. Das heisst, der absolute Fehler liegt im Bereich 10−5 und der relative Fehler ist im Bereich 10−5 /10−4 = 10−1 . Der relative Fehler der Ausgangsdaten liegt aber im Bereich 10−5 /1.2 · 100 ≈ 10−5 . Mit anderen Worten der relative Fehler wird durch die Subtraktion von zwei fast gleich grossen Zahlen massiv vergrössert. 1.3.2.4 Numerische Stabilität Ein stabiler Algorithmus ist ein Algorithmus, der auch in endlicher Arthmetik “richtig” läuft. Unter “richtig” meint man dabei, dass die Rundungsfehler den Ablauf nicht zu sehr stören. Die folgende Definition von Stabilität stammt von Wilkinson: Definition 1.4 [Stabilität] Bei stabilen Algorithmen ist das vom Computer berechnete (durch Rundungsfehler verfälschte) Resultat, das exakte Resultat von leicht geänderten Anfangsdaten. 1-7 Beispiel 1.5 [Stabilität] Wenn man zur Auflösung von quadratischen Gleichungen der Form x2 + px + q = 0 die übliche Formel x1,2 −p ± = 2 s 2 p 2 −q verwendet und |p| ≫ |q| ist, wird die kleinere der beiden Lösungen ungenau berechnet, weil sie numerischer Auslöschung unterworfen ist. Es gilt dann s 2 p 2 −q ≈ p 2 und daher werden in der Formel zwei fast gleich grosse Zahlen voneinander subtrahiert. Wir betrachten das folgende Zahlenbeispiel. x2 + 108 x + 1 = 0 Mit einer 7-Stelligen Arithmetik erhalten wir nach obiger Formel das Resultat: x1 = −108 und x2 = 0 Dies ist aber das Resultat der Gleichung x2 + 108 x = 0 Der relative Fehler von x1 ist gering (Grössenordnung 10−9 ) das Resultat x2 ist hingegen wegen numerischer Auslöschung Falsch. Dieser Algorithmus ist also im allgemeinen numerisch nicht stabil. Wir können eine bessere Lösung erhalten, wenn wir die folgende Formel zur Berechnung der betragsmässig kleineren Lösung verwenden. Falls eine Lösung der Gleichung bekannt ist, kann die zweite Lösung mit der Formel x2 = q x1 berechnet werden. Mit dieser Formel erhalten wir für x2 nicht mehr 0 sondern x2 = 1 1 = − 8 = −10−8 x1 10 Wird diese neue Formel angewendet, so ist die Berechnung in jedem Fall numerisch stabil. 1-8 1.3.2.5 Kondition Stabile Algorithmen nützen nichts, wenn ein Problem schlecht konditioniert ist. Definition 1.5 [Schlecht konditioniert] Ein Problem ist schlecht konditioniert (ill posed problem), wenn die Lösung sich stark ändert, wenn man die Anfangsdaten wenig stört. In den Naturwissenschaften sind die Ausgangsdaten von Berechnungen meistens Messdaten. Messdaten sind aber immer ungenau. Ist das zu lösende Problem nun schlecht konditioniert, so wird das Resultat sehr häufig falsch sein. Beispiel 1.6 [Schlecht konditioniert] Das Polynom P (x) = (x − 1)3 = x3 − 3x2 + 3x − 1 hat die dreifache Nullstelle x1 = x2 = x3 = 1. Das P benachbarte Polynom Q(x) = x3 − 3.000001x2 + 3x − 0.999999 hat die Nullstellen x1 = 1, x2 ≈ 1.001414 und x3 ≈ 0.998586 Die Koeffizienten von P wurden um 10−6 gestört, was die Nullstellen um 10−3 veränderte. Die Störung wurde damit 1000 mal verstärkt. Das Polynom P ist schlecht konditioniert. 1.3.3 Logische Werte (BOOLEAN) Ein Objekt dieses Typs kann nur die Werte TRUE (wahr) und FALSE (falsch) annehmen. Als Operationen sind die Konjunktion (AND), die Disjunktion (OR) und die Negation (NOT) zugelassen. Man beachte, dass die Vergleichsoperatoren in vielen Sprachen ein Resultat vom Typ BOOLEAN liefern. Somit kann das Resultat eines Vergleichs einer Variablen des Typs BOOLEAN zugewiesen werden. In der Sprache Java etwa ist die folgende Zuweisung völlig korrekt. boolean Bool = (10 >= 8); 1.3.4 Text (CHAR) )Dieser Typ bezeichnet eine Menge von Schriftzeichen (zum Beispiel die ASCII-Zeichen. Von dieser Menge werden folgende Eigenschaften verlangt: • Die Menge muss mindestens die grossen und kleinen lateinischen Buchstaben, die 10 arabischen Ziffern, ein Leerzeichen sowie alle Satzzeichen enthalten. • Auf der Menge existiert eine totale Ordnung. Bezüglich dieser Ordnung müssen die Teilmengen der Buchstaben und der Ziffern je zusammenhängend und wie üblich geordnet sein. Das heisst, es gilt: A < B < C ··· < Z a < b < c··· < z 0 < 1 < 2··· < 9 1-9 • Die beiden Funktionen ord und char müssen zur Verfügung stehen. Mit N bezeichnen wir die Anzahl Elemente im gegebenen Zeichensatz. ord : CHAR 7→ {0, 1, . . . , N − 1} char : {0, 1, . . . , N − 1} 7→ CHAR Für die beiden Funktionen ord und char gelten die folgenden Regeln: ∀x, y ∈ CHAR; ∀i ∈ {0, 1, . . . , N } – x 6= y ⇔ ord(x) 6= ord(y) – x < y ⇔ ord(x) < ord(y) – ord(char(i)) = i – char(ord(x)) = x 1.4 Abgeleitete Typen Da ein Datentyp aus einer Wertemenge und dazugehörenden Operationen besteht, können aus bestehenden Datentypen mit Hilfe von Mengenoperationen neue Datentypen generiert werden. Die wichtigsten Mengenoperationen sind die Teilmengenbildung, das Kartesische Produkt und die Bildung der Potenzmenge. Bei der Teilmengenbildung spricht man von Unterbereichstypen oder von Subtypen, beim Kartesischen Produkt und der Potenzmenge von strukturierten Typen. In einigen sprachen kann man auch Funktionsräume als Datentypen definieren. Die Konstanten eines solchen Typs sind dann Funktionen mit einer gegebenen Signatur. 1.4.1 Unterbereichstypen In vielen Fällen nimmt ein Objekt Werte eines bestimmten Typs nur innerhalb eines gewissen Intervalls an. Für solche Objekte kann ein Unterbereichstyp mit der Angabe der Intervallgrenzen angegeben werden. Dies bedingt natürlich, dass auf dem Obertyp eine totale Ordnung definiert ist. In der Sprache Pascal etwa kann ein Unterbereichstyp folgendermassen definiert werden: TYPE T = min..max Dabei sind min und max Konstanten vom Typ INTEGER oder CHAR. Beispiel 1.7 [Unterbereichstypen] TYPE Grossbuchstabe = A..Z TYPE Ziffer = 0..9 TYPE Kleinezahl = -10..10 Die Wertemenge eines Unterbereichstypsist nach Definition eine Teilmenge der Wertemenge des Obertyps. Daher sind alle Operatoren, die auf dem Obertyp anwendbar sind auch auf dem Unterbereichstyp anwendbar. Es gilt dabei zu beachten, dass die Resultate der Operationen bezüglich des Unterbereichstyps im allgemeinen nicht abgeschlossen sind. 1-10 1.4.2 Subtypen Wir wollen das Konzept des Unterbereichstyps verallgemeinern. Bis jetzt haben wir nur Intervalle eines total geordneten Obertyps zugelassen. Wir können aber auch beliebige Untermengen der Wertemenge eines Obertyps als Datentyp zulassen. Beispiel 1.8 [Subtyp] Wir betrachten als Beispiel den Obertyp CHadr. Die Wertemenge des Typs sind alle existierenden Adressen in der Schweiz. Der Operator P LZ sei auf CHadr definiert und liefert als Resultat die Postleitzahl des Ortes. Wir können nun den neuen Subtyp BEadr folgendermassen definieren: BEadr = {x ∈ CHadr | P LZ(x) ≥ 3000 ∧ P LZ(x) ≤ 3999} Es ist sofort ersichtlich, dass alle Objekte des Subtyps auch Objekte des Obertyps sind, aber natürlich nicht umgekehrt. Objekte eines so definierten Subtyps sind mit Objekten ihres Obertyps zuweisungskompatibel. Die Operationen, die auf dem Obertyp definiert sind, können auf die Wertemenge des Untertyps eingeschränkt und somit auf diesen wieder angewandt werden. (Achtung: Das Resultat einer Operation angewendet auf zwei Elemente des Subtyps kann aus dem eingeschränkten Wertebreich herausführen). 1.4.3 Die Struktur Array Der strukturierte Typ Array wird aus zwei gegebenen Datentypen, dem Grundtyp (GT ) und dem Indextyp (ID) konstruiert. Der Grundtyp ist ein beliebiger atomarer oder abgeleiteter Datentyp. Der Indextyp ist ein Unterbereichstyp (Intervall) des Typs INTEGER. Die Wertemenge eines Arrays des Grundtyps GT und des Indextyps ID kann nun folgendermassen definiert werden. Dabei bezeichnen wir mit |ID| die Anzahl Elemente des Indextyps. Arraygtid = GT |ID| Die Wertemenge des definierten Datentyps besteht aus allen (geordneten) |ID|-Tupeln von Elementen des Grundtyps. Bei der Definition des Datentyps Array muss sowohl der Grundtyp wie auch der Indextyp angegeben werden. In der Sprache C wird der Datentyp Array folgendermassen definiert: typedef GT MyArray[max]; max ist eine INTEGER Konstante und bestimmt die Anzahl Elemente des Arrays. Der Indextyp ist das Intervall 0..(max-1) der Integerzahlen. Beispiel 1.9 [Array] Definition eines Arrays des Grundtyp REAL und des Indextyps 0..9. typedef double Myarray[10] Elemente der Wertemenge des definierten Datentyps MyArray sind 10-Tupel von REALZahlen. Das Tupel (1.0, 2.37, 0.5, 22.1, 300.0, 55.2, 18.0, 31.33, 2.5, 1.0) 1-11 ist ein möglicher Wert für Objekte des Datentyps MyArray. Auf Arrays ist die Operation Selektor definiert, die es erlaubt, ein einzelnes Element des Arrays zu lesen oder zu schreiben. Das i-te Element des Arrays A wird mit A[i] bezeichnet und repräsentiert ein Objekt des Grundtyps des Arrays, das gelesen oder geschrieben werden kann. x = A[7] + 10; /* Lesen der 7. Komponente des Arrays A */ A[3] = 27; /* Schreiben der 3. Komponente des Arrays A */ Bemerkung 1.4 [Gleichheit von Arrays] Zwei Arrays A und B vom gleichen Grundtyp und vom gleichen Indextyp werden nur dann als gleich bezeichnet, wenn alle Komponenten den gleichen Wert besitzen: A = B ⇔ A[i] = B[i] ∀i ∈ ID Wird der Wert einer Komponente eines Arrays A verändert, so gilt der ganze Array A als verändert. Was den Array besonders auszeichnet, ist die Verwendung von berechenbaren Grössen als Index. Der Zugriff auf eine Komponente kann zur Laufzeit berechnet werden. Dies birgt natürlich auch die Gefahr, dass ein Index ausserhalb des Indexbereichs zu liegen kommt, was einen Programmabsturz zur Folge haben kann. Als Beispiel für das Rechnen mit dem Index wollen wir das binäre Suchen betrachten. Algorithmus 1.1 [Binäres Suchen] Das binäre Suchen kann angewendet werden, um in einem sortierten Array (A[0] ≤ A[1] ≤ · · · ≤ A[n]) ein Element schnell zu finden. Die Idee ist einfach. Es wird getestet, ob der Wert des mittleren Elements gleich dem gesuchten Wert ist. Sind die Werte gleich, kann die Suche abgebrochen werden. Ist der gesuchte Wert kleiner als der Wert des mittleren Elements, wird im linken Teil des Arrays nach der gleichen Methode weitergesucht, sonst im rechten Teil des Arrays. static int binSearch(int[] a, int such) { int left = 0, right = a.length - 1, mid = 0; boolean found = false; while (right >= left && found == false) { //Testen des mittleren Elementes mid = (left + right) / 2; if (a[mid] == such) found = true; else if (a[mid] < such) //Das Element muss rechts der Mitte liegen. left = mid + 1; else // Das Element muss links der Mitte liegen. right = mid - 1; } return (found) ? mid : -1; } 1-12 Die maximale Anzahl Vergleiche, die nötig sind, um ein Element im Array zu finden, ist log2 (max). Dies ist klar, da bei jedem Vergleich die Hälfte der restlichen Elemente ausgeschlossen werden. 1.4.4 Allgemeine Struktur Die allgemeinste Methode, einen strukturierten Datentyp zu definieren, besteht darin, Elemente von beliebigen Typen zu einer Einheit zusammenzufassen. Ein solcher Typ ist formal gesprochen das kartesische Produkt von beliebigen Datentypen. Structur = DT1 × DT2 × DT3 × · · · × DTn Die Datentypen DT1 . . . DTn können selbst wieder strukturierte Typen sein. In der Sprache Pascal heissen solche Strukturen Record, in der Sprache C werden sie als struct bezeichnet. In der Spache C kann ein solcher Typ folgendermassen definiert werden: typedef struct { DT1 Name_1; DT2 Name_2; DT3 Name_3; . . DTn Name_n; } MyStruct; Beispiel 1.10 [Datum] Definition eines Datentyps für das Datum. Der Typ könnte auch als Array of INTEGER mit drei Elementen deklariert werden. Die Form der Struktur ist aber sprechender. Der Ausdruck Tagesdatum.Tag für den Zugriff auf den Tag ist viel klarer als der entsprechende Zugriff Tagesdatum[0] in einem Array. typedef struct { int Tag; int Monat; int Jahr; } Datum; Das nächste Beispiel zeigt, dass auch strukturierte Datentypen (Arrays oder Strukturen), als Datentyp der Komponenten einer Struktur zugelassen sind. 1-13 Beispiel 1.11 [Person] Wir wollen einen Datentyp Person definieren. Bei dieser Definition benutzen wir den Datentyp Datum aus dem vorhergehenden Beispiel. typedef struct { char Name[30]; char Vorname[20]; Datum Geburtsdatum; char Beruf[30]; double Gehalt; } Person; Eine Konstante des Datentyps Person ist ein geordnetes Tupel von Werten der einzelnen Komponenten der Struktur Person. ("Grubauer", "Peter", (1,7,1958), "Koch", 5600.50) Beim Beispiel steht "Grubauer" als Abkürzung für das Tupel (’G’,’r’,’u’,’b’,’a’,’u’,’e’,’r’, , ,. . . ). Beim Datentyp struct wird nicht mehr über einen Index auf die einzelnen Komponenten zugegriffen, sondern über festgelegte Namen. Will man auf das Gehalt vom Objekt Hans des Typs Person zugreifen, so steht der Selektor ’.’ zur Verfügung. Hans.Gehalt Selektoren können natürlich auch kombiniert werden, wie die zwei nächsten Beispiele zeigen: Hans.Geburtsdatum.Tag Hans.Name[0] 1.5 Zugriff auf den Tag des Geburtsdatums. Zugriff auf den ersten Buchstaben des Namens. Weitere Datenstrukturen Die bisher betrachteten Datenstrukturen sind Bestandteil der meisten Programmiersprachen. Die folgenden Datenstrukturen werden im allgemeinen nicht angeboten und müssen zum Beispiel als Java-Klassen implementiert werden. 1.5.1 Die Struktur lineare Liste Wie der Array besteht eine lineare Liste aus lauter Elementen vom gleichen Datentyp, dem Grundtyp GT . Anders als beim Array ist die Anzahl Elemente der linearen Liste nicht konstant. Es existieren die Operationen Einfügen eines neuen Elements und Löschen eines Elements. Die möglichen Werte einer linearen Liste sind also alle i-Tupel von Elementen vom Grundtyp mit i = 0, 1, 2, . . . Formal kann die Wertemenge der Struktur Liste folgendermassen definiert werden: ListeGT = ∞ [ GT i i=0 Aus dieser Definition können wir einige Eigenschaften von linearen Listen ableiten. 1-14 • Da wir i = 0 zulassen, ist eine leere Struktur auch ein legaler Wert für Listen. • Die Werte einer Liste sind als geordnete Tupel definiert. Daher können wir vom ersten und letzten Element der Liste sprechen und allgemein vom n-ten Element. Wir können auch vom Vorgänger und vom Nachfolger eines Elements sprechen. Die wichtigsten Operationen auf Listen sind: • Schreiben/Lesen des Wertes eines beliebigen Elements der Liste. • Einfügen eines Elements an einer beliebigen Stelle in der Liste. • Löschen eines beliebigen Elements der Liste. • Bestimmen der Länge der Liste. 1.5.1.1 Repräsentation von Listen In den meisten klassischen Programmiersprachen wird die Struktur lineare Liste nicht angeboten und muss implementiert werden. Dazu bieten sich zwei Möglichkeiten: 1. Sequentielle Repräsentation: Alle Elemente der Liste werden in aufeinanderfolgenden Speicherplätzen abgelegt. Vorteil: Der Zugriff auf ein Element der Liste ist sehr schnell, da dessen Adresse berechnet werden kann. Nachteil Das Einfügen oder Löschen eines Elements der Liste ist langsam, da alle hinteren Elemente geschoben werden müssen. 2. Verkettete Repräsentation: Zu jedem Element der Liste wird zusätzlich die (physische) Adresse seines Nachfolgers gespeichert. Vorteil: Das Löschen und Einfügen eines Elements der Liste ist schnell, da nur der Zeiger des Vorgängers verändert werden muss. Nachteil: Der Zugriff auf ein beliebiges Element ist langsam, da alle Vorgängerelemente der Liste gelesen werden müssen. 1.5.2 Spezielle Listen: Stack und Queue Bei der Definition von Subtypen wird die Wertemenge eines Datentyps auf eine Untermenge eingeschränkt. Bei der Definition der beiden nächsten Typen wird nicht die Wertemenge eingeschränkt, sondern die Operationen auf dieser Menge. Beide Typen gehen vom Obertyp lineare Liste aus. 1-15 1.5.2.1 Der Stack Der Stack ist eine lineare Liste, bei der die Operationen Einfügen, Löschen und Lesen eines Elements nur auf das erste Element der Liste erlaubt sind (siehe Abbildung 1-3). Beim Stack haben sich die Namen push und pop für das Einfügen bzw. Löschen eines Elements eingebürgert. Der Stack wird häufig als LiFo-Struktur bezeichnet (LiFo = Last-in-First-out). Mit anderen Worten, das letzte Element, das eingefügt wird, wird als erstes wieder entfernt. Dank dieser Eigenschaft eignet sich der Stack sehr gut für die Bearbeitung von verschachtelten Strukturen (Prozeduraufrufe, arithmetische Ausdrücke usw.). Sami push(Sami) Sami pop() Paul Paul Paul Peter Peter Peter Hans Hans Hans Fritz Fritz Fritz Abbildung 1-3: Die Struktur Stack 1.5.2.2 Die Queue Die Queue ist eine lineare Liste, bei der die Operationen Lesen und Löschen eines Elementes nur auf das erste Element der Liste erlaubt sind. Das Einfügen eines neuen Elementes darf nur nach dem letzten Element der Liste erfolgen (Siehe Abbildung 1-4). write(Sami) Sami read() Paul Paul Sami Peter Peter Paul Hans Hans Peter Fritz Fritz Hans Fritz Abbildung 1-4: Die Struktur Queue Die Queue wird häufig als FiFo-Struktur bezeichnet (FiFo = First-in-First-out). Mit anderen Worten, das Element, das zuerst eingetragen wird, wird auch als erstes wieder gelöscht. Eine Anwendung für die Queue sind Druckerwarteschlangen. 1.5.3 Der Baum Eine weitere wichtige Datenstruktur, die wir in diesem Abschnitt behandeln wollen, ist der Baum. Im Gegensatz zu eindimensionalen Strukturen wie der Liste oder dem Array ist der Baum dem Wesen nach eine zweidimensionale, verkettete Struktur. 1-16 Definition 1.6 [Baum] Ein Baum ist entweder 1. die leere Menge (leerer Baum) oder 2. eine endliche, nichtleere Menge von Elementen, wovon ein Element Wurzel (engl. root) genannt wird und die restlichen Elemente in m ≥ 0 disjunkte Teilmengen eingeteilt sind, wobei jede wieder ein Baum ist (Unterbäume). Im allgemeinen werden Bäume als Graphen dargestellt. Dabei wird die Wurzel durch m Kanten (engl. edges) mit den Wurzeln der m Unterbäume verbunden (siehe Abbildung 1-5). A Wurzel Geschwister Unterbaum B C D Hoehe E G F H I Innere Knoten J Blaetter Abbildung 1-5: Allgemeine Baumstruktur als Graph Nachfolgend noch einige wichtige Begriffe: • Die Elemente eines Baumes heissen Knoten (engl. vertices oder nodes). • Die Wurzel eines Baumes (oder Unterbaumes) wird oft als Vater (engl. father, parent), ein direkt nachfolgender Knoten als Sohn (engl. son, child) bezeichnet. Oft werden auch die Begriffe Vorgänger (engl. predecessor) und Nachfolger (engl. successor) verwendet. • Knoten mit Nachfolger heissen innere Knoten (engl. internal nodes). • Knoten ohne Nachfolger heissen Blätter (engl. leaves), äussere Knoten (engl. external node) oder Endknoten (engl. terminal nodes). • Die direkten Nachfolger eines Knotens heissen Brüder (engl. brothers) oder Geschwister (engl. siblings). • Das Niveau (engl. level) eines Knotens ist folgendermassen definiert: level(root) := 0 level(x) := level(f ather(x)) + 1 1-17 • Die Tiefe (engl. depth) oder Höhe (engl. height) eines Baumes ist definiert als: depth(b) := maxx∈b (level(x)) • Der Grad (engl. degree) eines Knotens ist gleich der Anzahl Nachfolger dieses Knotens. Der Grad des Baumes ist definiert als: degree(b) := maxx∈b (degree(x)) • Eine Menge von Bäumen heisst Wald (engl. forest). • Ein geordneter Baum (engl. ordered tree) ist ein Baum, bei dem die Reihenfolge der direkten Nachfolger eines Knotens angegeben ist. 1.5.3.1 Binäre Bäume Ein wichtiger Speziallfall von Bäumen sind die sogenannten binären Bäume. Definition 1.7 [Binäre Bäume] 1. Ein binärer Baum (engl. binary tree) ist ein geordneter Baum vom Grad ≤ 2. 2. Ein streng binärer Baum (engl. strictly binary tree) ist ein binärer Baum und alle inneren Knoten haben den Grad 2. 3. Ein vollständiger binärer Baum (engl. complete binary tree) ist ein streng binärer Baum und alle Blätter haben das gleiche Niveau. 4. Ein fast vollständiger binärer Baum (engl. allmost complete binary tree) ist ein vollständiger binärer Baum wobei ganz rechts auf der untersten Stufe des Baumes Knoten fehlen dürfen. Die verschiedenen Begriffe aus der Definition 1.7 sind in der Abbildung 1-6 dargestellt. 1 Binaerer Baum 2 Streng binaerer Baum 3 Vollstaendiger binaerer Baum 4 Fast vollstaendiger binaerer Baum Abbildung 1-6: Die verschiedenen binären Bäume Bemerkung 1.5 [Binärbaum ist geordnet] Da wir den binären Baum als geordneten Baum definiert haben, können wir bei jedem Knoten vom linken und vom rechten Unterbaum des Knotens sprechen. 1-18 1.5.3.2 Darstellung von Bäumen Ganz allgemein ist ein Baum durch die Struktur der Knoten definiert. Jeder Knoten enthält beliebige Nutzdaten und eine Menge (bei geordneten Bäumen eine Liste) von Zeigern auf die betreffenden Unterbäume. Wie bei doppelt verketteten Listen kann auch noch ein Zeiger auf den Vorgänger geführt werden, damit kann der Baum auch von Unten nach Oben durchlaufen werden. Nachfolgend ist eine mögliche Definition für den allgemeinen Baum in der Sprache Java. public class TreeNode<Element> { Element info; TreeNode<Element> father; List<TreeNode<Element>> sons; } Beim Kreieren eines Knotens muss natürlich die Liste sons für die Nachfolger auch kreiert werden. Bemerkung 1.6 [Keine Kreise] Beachten Sie, dass nach Definition des Baumes (siehe Definition 1.6) kein Knoten auf einen direkten oder indirekten Vorgänger zeigen darf. Diese Eigenschaft muss durch die insert Operation des Datentyps “Baum” garantiert werden. 1.5.3.3 Darstellung von binären Bäumen Bei binären Bäumen kennt man die maximale Anzahl Nachfolger eines Knotens zum Vornherein. Das heisst, ein Knoten enthält beliebige Nutzdaten, sowie einen linken und rechten Zeiger auf die Unterbäume. public class BinTreeNode<Element> { Element info; TreeNode<Element> father; TreeNode<Element> left; TreeNode<Element> rigth; } Die angegebene Struktur kann nicht nur für binäre Bäume verwendet werden, sondern auch zur Darstellung von allgemeinen Bäumen. Dabei muss nur die Interpretation der beiden Zeiger left und right geändert werden. Im Zeiger left wird der erste Nachfolger des entsprechenden Knotens gespeichert und im Zeiger right das nächste Geschwister des Knotens. Die Situation ist in der Abbildung 1-7 dargestellt. 1-19 A A B E C F B D G H E I C F D G H I J J Abbildung 1-7: Darstellung eines allgemeinen Baumes als binärer Baum 1.5.4 Rekursion und traversieren von Bäumen Wir haben bis jetzt nicht beschrieben, wie Bäume traversiert werden (traversieren = besuchen aller Knoten des Baumes). In diesem Abschnitt wollen wir nun dies nachholen. Zu diesem Zweck führen wir zuerst die Rekursion ein. Eine Prozedur (oder Funktion) heisst rekursiv, falls sie sich direkt oder indirekt selbst aufruft. Viele Probleme lassen sich rekursiv einfacher und klarer lösen als ohne Rekursion. Rekursion ist eine häufige Design-Methode für Algorithmen. Achtung Bei einer rekursiven Prozedur sind die folgenden Punkte besonders zu beachten. • Die Rekursion darf nicht unendlich sein. Das heisst, es muss in der Prozedur ein Instruktionszweig existieren, der keinen Aufruf der Prozedur enthält. Diesen Teil der Prozedur nennt man den Rekursionsgrund. Bei indirekter Rekursion (Prozedur A ruft Prozedur B auf und B ruft wieder A auf) ist besondere Vorsicht geboten. • Es muss sichergestellt sein, dass die Anzahl der hintereinander ausgeführten rekursiven Aufrufen (Rekursionstiefe) “vernünftig” bleibt, da sonst zu viel Speicher verwendet wird. Wenn beim Sortieren von n Elementen n rekursive Aufrufe der Prozedur nötig sind, ist dies sicher nicht mehr vernünftig. Beim Sortieren wäre eine Anzahl log2 (n) vernünftig. • Rekursion soll nur dann angewandt werden, wenn die Formulierung der Lösung dadurch klarer und kürzer wird. Auch darf der Aufwand der rekursiven Lösung in der Ordnung nicht grösser werden als der Aufwand der iterativen Lösung. Insbesondere kann die Rekursion sehr leicht eliminiert werden, wenn die Prozedur nur einen rekursiven Aufruf enthält und dieser Aufruf die letzte Instruktion der Prozedur ist. Als erstes wollen wir ein schlechtes Beispiel für die Rekursion angeben. 1.5.4.1 Berechnen der Fakultät einer Zahl Die Fakultät einer Zahl n! wird in der Mathematik rekursiv definiert. Daher wird in der Informatik häufig dieses Beispiel für die Rekursion gewählt. n! ist für n ∈ N folgendermassen definiert: 1-20 0! = 1 n! = n · (n − 1)! f ür n > 0 Diese Definition kann direkt in dieser Form implementiert werden. Wir erhalten die folgende rekursive Prozedur: Algorithmus 1.2 [Rekursive Berechnung der Fakultät] static long int fac(long int n) { if (n == 0) return 1; else return (n * fac(n - 1)); } Dies ist ein schlechtes Beispiel für die Rekursion. Die Funktion wird nur einmal rekursiv aufgerufen und der Aufruf ist die letzte Instruktion der Routine. Wir können die Rekursion sehr einfach durch eine Schleife ersetzen. Diesen Vorgang nennt man End-Recursion Removal. Nachstehend die iterative Lösung zur Berechnung der Fakultät. Algorithmus 1.3 [Iterative Berechnung der Fakultät] static long int fac(long int n) { long result = 1; for (long i = 1; i <= n; i++) result *= i; return result; } 1.5.4.2 Traversieren eines binären Baumes Da die Baumstruktur rekursiv definiert ist, liegt es natürlich nahe, die Traversierung eines Baumes rekursiv zu implementieren. Um diese Aufgabe zu lösen betrachten wir die folgenden drei Ansätze: Preorder Traversierung 1. Behandle die Wurzel des Baumes 2. Traversiere den linken Unterbaum in preorder 3. Traversiere den rechten Unterbaum in preorder Postorder Traversierung 1. Traversiere den linken Unterbaum in postorder 2. Traversiere den rechten Unterbaum in postorder 3. Behandle die Wurzel des Baumes 1-21 Inorder Traversierung 1. Traversiere den linken Unterbaum in inorder 2. Behandle die Wurzel des Baumes 3. Traversiere den rechten Unterbaum in inorder Algorithmus 1.4 [Inorder Traversierung eines binären Baumes] Der folgende Algorithmus traversiert einen binären Baum in inorder. void inorderTraverse(BinTreeNode bt) { // Rekursionsgrund falls der angegebene Knoten null ist if (bt != null) { // Rekursiver Aufruf fuer den linken Unterbaum InorderTraverse(bt.left); // ausgeben der Wurzel System.out.println(bt.info); // Rekursiver Aufruf fuer den rechten Unterbaum InorderTraverse(bt.right); } } Bemerkung 1.7 [Sinnvolle Rekursion] Im Gegensatz zur Berechnung der Fakultät einer Zahl ist der Einsatz der Rekursion bei der Traversierung von Bäumen sinnvoll. Für die iterative Implementation ist ein Stack notwendig. Die iterative Implementation ist auch weniger gut verständlich als die rekursive (siehe ??). Nachfolgend noch ein Beispiel zur Baumtraversierung: Beispiel 1.12 [Baumtraversierung] − 7 / c + a d e b Abbildung 1-8: Syntaxbaum für den Ausdruck ((a + b) · c − d/e) · 7 Zu jedem arithmetischen Ausdruck gehört ein sogenannter Syntaxbaum. Der Baum wird so aufgebaut, dass in den inneren Knoten des Baumes immer ein Operator (+, −, ·, /) steht. 1-22 Im linken Unterbaum steht der 1. Operand im rechten Unterbaum der linke Operand des Operators. In der Abbildung 1-8 ist der Syntaxbaum für den Ausdruck ((a + b) · c − d/e) · 7 angegeben. Traversieren wir den Baum in Preorder, so erhalten wir die folgende Reihenfolge für den Besuch der einzelnen Knoten: · − · + a b c / d e 7 (Polnische Notation) Traversieren wir den Baum in Postorder, so erhalten wir die folgende Reihenfolge für den Besuch der einzelnen Knoten: a b + c · d e / − 7 · (Umgekehrte polnische Notation) Traversieren wir den Baum in Inorder, so erhalten wir die folgende Reihenfolge für den Besuch der einzelnen Knoten: a + b · c − d / e · 7 (Infix Notation ohne Klammern) 1.6 1.6.1 Der Abstrakte Datentyp Definition Der abstrakte Datentyp (abgekürzt ADT) ist eines der wichtigsten Konzepte in der modernen Informatik. Die Philosophie der objektorientierten Sprachen basiert auf diesem Konzept. Der ADT dient dazu, Datentypen (Datenstrukturen) unabhängig von deren Implementation zu definieren. Beispiele solcher Datentypen sind Listen, Stacks und Queues. Neben diesen eher programmiertechnischen Strukturen können aber auch konkrete Objekte der Welt wie Motoren, Schrauben, Maschinen, Personaldaten u.s.w. mit Hilfe von abstrakten Datentypen modeliert werden. Die Idee des ADTs beruht auf zwei wichtigen Prinzipien: Das Geheimnisprinzip und das Prinzip der Wiederverwendbarkeit. 1.6.1.1 Das Geheimnisprinzip Das Geheimnisprinzip kann folgendermassen formuliert werden: Definition 1.8 [Geheimnisprinzip] Dem Benutzer eines Datentyps (Moduls) werden nur die auf diesem Datentyp erlaubten Operationen bekanntgegeben. Die Implementation des Datentyps bleibt für den Benutzer verborgen (abstrakt). Die Anwendung dieses Prinzips bringt folgende Vorteile: • Der Anwender kann den Datentyp nur im Sinne der Definition verwenden. Er hat keine Möglichkeit, Eigenschaften einer speziellen Implementation auszunutzen. • Die Implementation eines Datentyps kann jederzeit verändert werden, ohne dass die Benutzer des Datentyps davon betroffen sind. • Die Verantwortung zwischen dem Anwender und dem Implementator des Datentyps sind durch die Interface-Definitionen klar geregelt. Die Suche nach Fehlern wird dadurch erheblich vereinfacht. 1-23 1.6.1.2 Wiederverwendbarkeit Dieses Prinzip kann wie folgt formuliert werden: Definition 1.9 [Wiederverwendbarkeit] Ein Datentyp (Modul) soll in verschiedenen Applikationen wiederverwendbar sein, wenn ähnliche Probleme gelöst werden müssen. Die Idee hinter diesem Prinzip ist klar. Es geht darum, die Entwicklungszeit von Systemen zu reduzieren. Das Ziel ist, Softwaresysteme wie Hardwaresysteme zu bauen. Das heisst, die einzelnen Komponenten eines Systems werden eingekauft, eventuell parametrisiert und zum Gesamtsystem verbunden. 1.6.2 Formale Spezifikation von ADTs Die Spezifikation eines Datentyps muss vollständig, präzise und eindeutig sein. Weiter wollen wir keine Beschreibung, die auf der konkreten Implemenation des Datentyps basiert, obwohl diese die geforderten Kriterien erfüllt. Eine Beschreibung, die auf der Implementation basiert, führt zu einer Überspezifikation des Datentyps. Beispiel 1.13 [Der Stack als ADT] Wir können den Datentyp Stack als Array mit einem zusätzlichen Zeiger auf das aktuelle oberste Element des Stacks implementieren. Nachstehend ist die Implementation der Struktur und der Prozedur push in der Sprache C angegeben. struct stack { int stack_pointer; DT stack_element[MAX]; /* DT steht fuer irgend einen Datentyp */ }; void push(stack st, DT x) { ++st.stack_pointer; st.stack_element[st.stack_pointer] = x; } Wird nun der Datentyp Stack mit Hilfe dieser internen Struktur beschrieben, so ist der Datentyp überspezifiziert. Der Benutzer weiss, dass der Stack aus einem Array besteht und kann diese Tatsache ausnutzen, um auf beliebige Elemente des Arrays zuzugreifen. Ein solcher Zugriff widerspricht aber der Idee des Stacks. Der abstrakte Datentyp spezifiziert eine Datenstruktur nicht mit Hilfe der Implementation, sondern nur als eine Liste von Dienstleistungen, die der Datentyp dem Anwender zur Verfügung stellt. Die Eigenschaften der Dienstleistungen werden formal beschrieben. Die Dienstleistungen nennt man auch häufig Operationen, Funktionen oder Methoden. Beispiel 1.14 [Stack Spezifikation] Der abstrakte Datentyp Stack wird also als eine Menge von Funktionen wie Eintragen eines Elements (push), Entfernen eines Elements (pop), Lesen des obersten Elements (top) u.s.w. betrachtet. Eine solche Beschreibung berücksichtigt nicht, was ein Stack ist, sondern nur was ein Stack dem Anwender zu bieten hat. Für die formale Spezifikation des Datentyps übernehmen wir die Schreibweise von Bertrand Meyer meyer.88. Nach dieser Schreibweise besteht die Spezifikation aus vier Teilen. Nachstehend ist die Spezifikation eines Integerstacks als Beispiel angegeben. 1-24 TYPES StackOf Int FUNCTIONS new : empty : push : pop : top : 7→ StackOf Int StackOf Int 7→ BOOLEAN IN T EGER × StackOf Int 7→ StackOf Int StackOf Int ֒→ StackOf Int StackOf Int ֒→ IN T EGER PRECONDITIONS ∀s ∈ StackOf Int : pre pop(s) = ¬(empty(s)) pre top(s) = ¬(empty(s)) AXIOMS ∀i ∈ IN T EGER; ∀s ∈ StackOf Int : 1. empty(new) = T RU E 2. empty(push(i, s)) = F ALSE 3. top(push(i, s)) = i 4. pop(push(i, s)) = s Wir wollen nun die vier Teile der obigen Spezifikation des abstrakten Datentyps näher erläutern. 1.6.2.1 TYPES Der Abschnitt TYPES legt die Namen der Datentypen fest, die spezifiziert werden. In unserem Beispiel ist es nur einer, StackOf Int. 1.6.2.2 FUNCTIONS Der Abschnitt FUNCTIONS legt die Dienstleistungen fest, die auf den Objekten des Datentyps angeboten werden. Die Dienstleistungen werden als mathematische Konstanten und Funktionen spezifiziert. In unserem Beispiel sind dies new, empty, push, pop und top. Jede Zeile in diesem Abschnitt definiert die Signatur (d.h. Anzahl und Typ der Argumente und des Resultats). Eine Funtion f mit der Signatur f : A1 × A2 × · · · × Am 7→ B1 × B2 × · · · × Bn 1-25 erwartet m Argumente mit den Typen a1 ∈ A1 , . . . , am ∈ Am und liefert n Resultate mit den Typen b1 ∈ B1 , . . . , bn ∈ Bn . Mindestens eine der Mengen Ai oder Bj muss gleich dem spezifizierten Typ sein, in unserem Beispiel also StackOf Int. In der Praxis haben die meisten Funktionen nur ein Resultat (das heisst, n = 1). Falls der Definitionsbereich leer ist, handelt es sich um den Namen einer Konstanten. In unserem Beispiel ist new die einzige Konstante. Funktionen mit einem einfachen Pfeil (֒→) sind partielle Funktionen (normale Funktionen werden mit 7→ angegeben). D.h., sie sind nicht auf dem ganzen Definitionsbereich definiert. In unserem Beispiel sind die Funktionen pop und top solche partielle Funktionen (siehe PRECONDITIONS). Wir können die Funktionen eines abstrakten Datentyps T in zwei Kategorien einteilen: Accessoren sind Funktionen bei denen T nur im Definitionsbereich vorkommt. Solche Funktionen liefern Informationen über den aktuellen Wert eines Objekts des abstrakten Datentyps. In unserem Beispiel sind empty und top Accessor-Funktionen. Transformer sind Funktionen bei denen T sowohl im Definitionsbereich wie auch in der Wertemenge der Funktion vorkommt. Solche Funktionen bilden also einen Wert von T auf einen anderen Wert von T ab. In unserem Beispiel sind pop und push TransformerFunktionen. 1.6.2.3 PRECONDITIONS Eine Operation kann nicht immer auf alle Werte des Definitionsbereich angewendet werden. Das berühmteste Beispiel ist die Division durch Null. Partielle Funktionen führen in der Programmierung oft zu Fehlern. Es ist daher wichtig, die Vorbedingungen für die Anwendung einer partiellen Funktion klar zu definieren. Dies ist das Ziel des Abschnitts Preconditions (Vorbedingungen). In unserem Beispiel steht die Precondition: pre pop(s) = ¬(empty(s)) Auf der linken Seite steht der Name der partiellen Funktion, auf der rechten Seite die Vorbedingungen, die erfüllt sein müssen, damit die Funktion angewendet werden kann. Das obige Beispiel bedeutet, dass die Funktion pop auf einen leeren Stack nicht angewendet werden darf. Jede partielle Funktion aus dem Abschnitt Functions muss im Abschnitt Preconditions aufgeführt werden. In unserem Beispiel sind die Vorbedingungen für die Funktionen pop und top dieselben. 1.6.2.4 AXIOMS Bis jetzt passt die Beschreibung in unserem Beispiel nicht nur auf die Datenstruktur Stack. Jede Datenstruktur, die ähnliche Dienstleistungen wie der Stack anbietet, würde auch auf diese Beschreibung passen. Zum Beispiel eine Queue, eine lineare Liste u.s.w. Um wirklich die Spezifikation auf den Typ Stack einzuschränken, müssen wir die Semantik der Funktionen beschreiben. Das ist die Rolle der Axiome. Jedes der Axiome (oder eine Gruppe von Axiomen) beschreibt die Eigenschaften der vom Datentyp angebotenen Dienstleistungen. In unserem Beispiel beschreiben die Axiome • empty(new) = T RU E 1-26 • empty(push(i, s)) = F ALSE die Eigenschaften der Funktion empty. Diese zwei Axiome sagen aus, dass die Konstante new der leere Stack ist und dass der Wert der Funktion push nie der leere Stack sein kann. Die Axiome • top(push(i, s)) = i • pop(push(i, s)) = s beschreiben die typische Last-in-First-out (LiFo) Eigenschaft des Stacks. Bemerkung 1.8 [Transformer Funktionen] In der Definition des ADTs sind in unserem Beispiel nur reine transformer Funktionen definiert. Das heisst, die Wertemenge ist gleich dem definierten ADT. Die Funktion pop könnte auch so definiert werden: pop : StackOf Int 7→ IN T EGER × StackOf Int Die Funktion pop ist dann eine Mischung aus transformer- und accessor- Funktion. Sie verändert den Stack und gibt gleichzeitig das oberste Element zurück. Dies ist der klassische Fall einer Funktion mit Nebeneffekten. Solche Funktionen sind aber sehr häufig Fehler anfällig. Bertrand Meyer verlangt, dass die folgenden Prinzipien eingehalten werden: • Eine transformer-Funktion verändert den Zustand eines Objektes. Sie gibt keine weiteren Werte zurück. • Eine accessor-Funktion verändert den Zustand eines Objektes nie. Sie gibt nur Informationen über den Zustand des Objektes zurück. Wird eine accessor-Funktion mehrmals hintereinander ausgeführt, so gibt sie immer denselben Wert zurück. 1.6.3 Spezifikation des ADTs in Java Die formale Spezifikation eines ADT ist eine sehr schwierige Aufgabe, die viel Wissen und Erfahrung erfordert. In vielen Fällen genügt aber eine weniger formale Beschreibung des ADT. Diese Beschreibung kann zum Beispiel in Form eines Java-Interfaces geschehen. Auch dort können wir mit Preconditions und Postconditions vorgehen. Es ist aber auch erlaubt, Teile der Spezifikationen als Kommentare in natürlicher Sprache zu schreiben. Dabei ist darauf zu achten, dass die Aussagen möglichst eindeutig sind. Beispiel 1.15 [Stack Spezifikation in java] Als Beispiel wollen wir auch wieder den Stack von Integers ansehen. Eine Java Interfacedefinition könnte folgendermassen aussehen: /** * Dieses Interface definiert die Schnittstelle zu einem Stack * von Integers. Die Implementation steht noch offen. */ public interface StackOfInt { /** Preconditions: none * Postconditions: none 1-27 * @return true falls keine Elemente im Stack sind. */ public boolean empty(); /** Preconditions: none * Postconditions: none * @return true falls kein Element eingefuegt werden kann. */ public boolean full(); /** Preconditions: old.full() == false * Postconditions: -- Ein Element (item) mehr auf dem Stack * new.empty() == false * new.top() == item * @param item: neues Element zuoberst auf dem Stack */ public void push(int item); /** Preconditions: old.empty() != true * Postconditions: -- Ein Element weniger auf dem Stack * new.push(old.top()) == old * @return Oberstes Element des Stacks */ public int pop(); /** Preconditions: old.empty() != true * Postconditions: none * @return old.top() (Oberstes Element des Stacks) */ public int top(); } Hier noch zwei Kommentare zu dieser Definition: 1. Die Variable old bezeichnet das Objekt vor dem Aufruf der Funktion und new bezeichnet das Objekt nach dem Aufruf der Funktion. 2. Die Beschreibung der Funktionen ist nicht streng formal. Bemerkung 1.9 [Stack Implementation] Das Verhalten des Stacks ist davon abhängig, ob dieser mit einem Array oder als gelinkte Liste implementiert ist. Vor allem muss der Benutzer des Stacks wissen, wieviele Elemente eingefügt werden können. Diese Information gehört nicht im Interface sondern in der Schnittstellenbeschreibung der Klasse, welche das Interface implementiert. Nachstehend ist die Klasse StaticStackOfInt, welche den Stack mit Hilfe eines Arrays implementiert. In diesem Fall ist das Resultat der Methode full von der Grösse des Stacks (size) abhängig. /** 1-28 * Diese Klasse implementiert einen Stack von Integers. Die * Implementation ist statisch. D.h. beim kreieren des Stacks * muss der Anwender angeben, wieviele Elemente maximal * eingefuegt werden koennen. */ public class StaticStackOfInt implements StackOfInt { private int size; private int sp; private int stack[]; /** Preconditions: none * Postconditions: new.empty() == true * -- Im Stack koennen maximal _size Elemente * eingefuegt werden. * @param _size Die maximale Anzahl Elemente im Stack */ public StaticStackOfInt(int size){ stack = new int[size]; this.size = size; sp = -1; } /** Preconditions: none * Postconditions: none * @return true falls size Elemente eingefuegt wurden. */ public boolean full() { return (sp + 1 < size); } usw. } Die Klasse DynamicStackOfInt implementiert den Stack mit Hilfe der Java-Klasse Vector. In diesem Fall ist das Resultat der Methode full immer wahr. /** * * * * */ Diese Klasse implementiert einen Stack Implementation ist dynamisch. D.h. Die Elemente die eingefuegt werden koennen die groesse der Resourcen des Rechners von Integers. Die maximale Anzahl ist nur durch beschraenkt. import java.util.vector; public class DynamicStackOfInt implements StackOfInt { private int sp; private Vector stack; /** Preconditions: none * Postconditions: new.empty() == true 1-29 */ public DynamicStackOfInt(){ stack = new Vector(); sp = -1; } /** Preconditions: none * Postconditions: none * @return immer false (Stack ist nie voll) */ public boolean full() { return false; } usw. } 1.6.4 Generische Parameter Der bis jetzt eingeführte Begriff des abstrakten Datentyps genügt dem Geheimnisprinzip. Das Prinzip der Wiederverwendbarkeit ist aber nur sehr ungenügend realisiert. Wir haben einen Stack definiert, der INTEGERs aufnehmen kann. Brauchen wir nun einen Stack, der REALs oder CHARs aufnehmen kann, so müssen wir dafür neue ADTs definieren und anschliessend implementieren. Mit Hilfe von generischen Parametern kann der Stack noch abstrakter definiert werden, indem der Datentyp der Stackelemente erst beim Kreieren eines Stacks angegeben werden muss. In der nachstehenden Spezifikation ist der ADT Stack mit einem generischen Parameter definiert. Der generische Parameter Element ersetzt den Typ INTEGER und steht für einen beliebigen Datentyp. TYPES Stack[Element] FUNCTIONS new : empty : push : pop : top : 7→ Stack[Element] Stack[Element] 7→ BOOLEAN Element × Stack[Element] 7→ Stack[Element] Stack[Element] ֒→ Stack[Element] Stack[Element] ֒→ Element PRECONDITIONS ∀s ∈ Stack[Element] : pre pop(s) = ¬(empty(s)) pre top(s) = ¬(empty(s)) AXIOMS ∀x ∈ Element; ∀s ∈ Stack[Element] : 1-30 1. empty(new) = T RU E 2. empty(push(x, s)) = F ALSE 3. top(push(x, s)) = x 4. pop(push(x, s)) = s 1.6.5 Implementation in java Für diese neue Spezifikation sieht nun das Interface für den Stack folgendermassen aus: public interface Stack<Element> { /** * Dieses Interface definiert die Schnittstelle zu * einem Stack von beliebigen Elementen des Typs * Element Die Implementation steht noch offen. */ /** Preconditions: none * Postconditions: none * @return true falls keine Elemente im Stack sind. */ public boolean empty(); /** Preconditions: none * Postconditions: none * @return true falls kein Element eingefuegt werden kann. */ public boolean full(); /** Preconditions: old.full() == false * Postconditions: -- Ein Element (item) mehr auf dem Stack * new.empty() == false * new.top() == item * @param item: neues Element zuoberst auf dem Stack */ public void push(Element item); /** Preconditions: old.empty() != true * Postconditions: -- Ein Element weniger auf dem Stack * new.push(old.top()) == old * @return Oberstes Element des Stacks */ public Element pop(); /** Preconditions: old.empty() != true * Postconditions: none * @return old.top() (Oberstes Element des Stacks) */ public Element top(); 1-31 } Eine Implementation könnte in etwa so aussehen: import java.util.Vector; public class MyStack<Element> implements Stack<Element> { private Vector<Element> st; public MyStack() { st = new Vector<Element>(); } public boolean empty() { return st.size() == 0; } public boolean full() { // Immer false return false; } public Element pop() { return st.remove(0); } public void push(Element item) { st.add(item); } public Element top() { return st.get(0); } } Und nachstehend noch wie diese verwendet werden kann. ... MyStack<Integer> st = new MyStack<Integer>(); st.push(2); ... 1-32 Kapitel 2 Algorithmen 2.1 Einführung In dieser Einführung wollen wir den Begriff des Algoritmus nur intuitiv beschreiben. Die exakte Definition eines Algorithmus folgt im Kapitel über die Berechenbarkeit (2.2). Ein Algorithmus beschreibt die Methode, mit der eine Aufgabe gelöst wird. Er besteht aus einer Folge von Schritten (Anweisungen), deren korrekte Bearbeitung die gestellte Aufgabe löst. Die Bebarbeitung der Schritte bezeichnet man als Prozess. Der Algorithmusgedanke ist keine Besonderheit der Informatik. In fast allen Naturwissenschaften aber auch im Alltag werden Arbeitsvorgänge mit Hilfe von Algorithmen beschrieben. Der Prozess kann von einer Person, einem Computer, einem mechanischen Gerät usw. ausgeführt werden. Die ausführende Instanz heisst Prozessor. In der Tabelle 2-1 sind Beispiele für diverse Algorithmen angegeben. Prozess Pullover stricken Algorithmus Strickmuster Modellflugzeug bauen Kuchen backen GGT berechnen Montageanleitung Kochrezept Euklid Typische Schritte Stricke Rechtsmasche Stricke Linksmasche Leime Teil A an Flügel B. Nimm 3 Eier, schaumig schlagen. Abbildung 2-1: Diverse Algorithmen Für deterministische Algorithmen werden die drei folgenden Eigenschaften gefordert: Vollständigkeit Der Algorithmus besteht aus einer endlichen Anzahl Schritte, deren Beschreibung nur endlich viele Zeichen benötigt. Eindeutigkeit Die Wirkung jedes einzelnen Schrittes ist eindeutig festgelegt. Nach der Ausführung eines Schrittes steht eindeutig fest, welcher Schritt als nächstes auszuführen ist. 2-1 Effektivität Die Effektivität verlangt, dass die Ausführung eines einzelnen Schrittes nur endlich viel Zeit in Anspruch nimmt. Damit ist nur sichergestellt, dass nach einem Schritt der nachfolgende Schritt auch begonnen werden kann, aber nicht, dass der Algorithmus als Ganzes in endlicher Zeit terminiert. Bemerkung 2.1 [Parallele Algorithmen] Die Forderung nach Eindeutigkeit wird etwa in parallelen Algorithmen zum Teil fallengelassen. Nach dem Abschluss eines einzelnen Schrittes ist der nächste Schritt nicht eindeutig bestimmt, sondern es existiert eine endliche Menge von möglichen nächsten Schritten. Die Auswahl des nächsten Schrittes aus der gegebenen Menge ist nichtdeterministisch. 2.2 Berechenbarkeit Die Idee, einen Algorithmus oder ein Rezept zur Lösung irgendeiner Aufgabe zu finden, existiert schon seit tausenden von Jahren. Die grosse Frage ist, kann jedes formal definierte Problem mit Hilfe eines Algorithmus gelöst werden? Der Mathematiker David Hilbert (1862-1943) etwa suchte nach einem mathematischen System, in dem alle Probleme präzise als Aussagen formulierbar wären. Gleichzeitig suchte er nach einem Algorithmus, der zu einer gegebenen Aussage in seinem System entscheiden könnte, ob die Aussage wahr oder falsch ist. Dieses Problem ist unter dem Namen Hilbert’sches Entscheidungsproblem bekannt. Kurt Gödel zeigte 1931, dass ein solcher Algorithmus nicht existieren kann. Das heute berühmte Unvollständigkeitstheorem besagt unter anderem, dass es keinen Algorihmus gibt, der als Eingabe irgendeine Aussage über die natürlichen Zahlen erhält und feststellt, ob die Aussage wahr oder falsch ist. Mit anderen Worten, das Entscheidungsproblem ist im allgemeinen nicht berechenbar. 2.2.1 Definition Algorithmus Um präzise Aussagen über Algorithmen machen zu können, muss der Begriff natürlich auch präzise definiert werden. Eine Möglichkeit den Begriff zu definieren ist die Turingmaschine, die im Folgenden erklärt wird. 2.2.1.1 Die Turingmaschine Die Turingmaschine ist ein vom britischen Mathematiker Alan Turing 1936 entwickeltes mathematisches Modell, um eine Klasse von berechenbaren Funktionen zu bilden. Informelle Beschreibung Die Turingmaschine besteht aus: • Einem unendlich langen Speicherband, dass in unendlich vielen Speicherzellen unterteilt ist. In jeder dieser Zelle kann genau ein Zeichen eines gegebenen Alphabets gespeichert sein. • Einem Lese- und Schreibkopf, der sich auf dem Band zellenweise bewegen kann und die Zellen lesen oder schreiben kann. 2-2 • Einem Programm, das die Aktionen des Lese/Schreibkopf mit Hilfe von Maschinenzuständen steuert. Eine Turingmaschine modifiziert die Eingabe auf dem Band nach dem gegebenen Programm. Ist die Berechnung beendet, so befindet sich das Ergebnis auf dem Band. Es wird somit jedem Eingabewert ein Ausgabewert zugeordnet. Eine Turingmaschine muss aber nicht für alle Eingaben stoppen. In diesem Fall ist die Funktion für die Eingabe undefiniert. Die Turingmaschine ist in der Abbildung 2-2 dargestellt. Band mit Zellen Lese−/Schreibkopf Programm Abbildung 2-2: Die Turingmaschine Bei jedem Schritt kann die Turingmaschine das Zeichen beim Lese-/Schreibkopf lesen dann ein neues Zeichen schreiben und schliesslich den Kopf nach rechts oder nach links bewegen. Welche Aktionen durchgeführt werden ist vom aktuellen Zustand der Maschine und vom aktuell gelesenen Zeichen abhängig. Formale Beschreibung Formal kann eien Turingmaschine als 7-Tupel M = {Q, Σ, Γ, δ, q0 , 2, F } definiert werden. Dabei sind: • Q die Zustandsmenge • Σ ist das Eingabealphabet • Γ ⊃ Σ ist das Bandalphabet • δ : Q × Γ 7−→ Q × Γ × {L, 0, R} ist die Überführungsfunktion • q0 ist der Anfangszustand • 2 ∈ (Γ \ Σ) ist das Leerzeichen • F ⊆ Q ist die Menge der End- bzw. akzeptirenden Zustände. Die Turingmaschine führt eine Berechnung aus, indem sie schrittweise eine Eingabe in eine Ausgabe umwandelt. Ein-, Ausgabe und Zwischenergebnisse werden auf dem unendlich langen Band gespeichert. Zu Beginn steht ein Wort als Eingabe auf dem Band (pro Bandfeld ein Zeichen des Eingabewortes), der Rest des Bandes ist mit dem leeren Feld formatiert. Der Schreib-/Lesekopf steht auf dem ersten Zeichen auf der linken Seite der Eingabe und die TM befindet sich im (Start-)Zustand q0 . 2-3 Die Überführungsfunktion hat als Argumente den aktuellen Zustand und das Zeichen, das sich im aktuellen Schritt unter dem Schreib-/Lesekopf befindet. Als Ergebnis liefert sie dann genau einen Nachfolgezustand, ein Zeichen (mit diesem Zeichen wird dann der Inhalt des Feldes, auf das der Schreib-/Lesekopf weist, überschrieben) und entweder das Symbol L (in diesem Fall bewegt sich der Schreib-/Lesekopf um ein Feld nach links), ein R (in diesem Fall bewegt er sich ein Feld nach rechts) oder eine 0 (dann verharrt er auf dem selben Feld). Damit hat die Turingmaschine einen Schritt ihres Arbeitszyklus’ durchlaufen und steht für einen weiteren bereit. Erreicht die Turingmaschine einen Endzustand, also einen Zustand der Menge F, ist die Berechnung beendet. Die Ausgabe ist dann der Inhalt des Bandes (wobei die Felder, die mit Symbolen aus Γ \ Σ gefüllt sind, insbesondere dem Symbol 2, nicht berücksichtigt werden). Beispiel 2.1 [Palindrome] In diesem Beispiel soll Σ = {0, 1} und Γ = {0, 1, 2} sein. Wir wollen nun ein Programm entwickeln, das von einem gegebenen Eingabestring entscheidet, ob es ein Palindrom ist oder nicht. Die Ausgabe ist entweder eine 1 für “ja” oder eine 0 für “nein”. wir verwenden die folgende Maschine: T M = ({0, 1, 2, 3, 4, 5, 6, 7}, {0, 1}, {0, 1, 2}, δ, 0, 2, {7}) Die Überführungsfunktion δ ist in der folgenden Tabelle angegeben. Zustand 0 1 2 3 4 5 6 7 1, 1, 5, 3, 6, 5, 6, 1 2, 1, 2, 1, 2, 1, 2, R R L R L L L 3, 1, 6, 3, 5, 5, 6, 0 2, 0, 2, 0, 2, 0, 2, halt R R L R L L L 7, 2, 7, 4, 7, 0, 7, 2 1, 2, 1, 2, 1, 2, 0, 0 L 0 L 0 R 0 Wir sind nun in der Lage eine erste Definition des Begriffs Algorithmus zu geben. Definition 2.1 [Algorithmus (1.Version)] Ein Algorithmus zur Lösung eines Problems ist eine Folge von Anweisungen für die Turingmaschine, so dass die Turingmaschine für jede Eingabe in endlicher Zeit entweder die Lösung liefert oder meldet, dass keine Lösung existiert. 2.2.2 Weitere Definitionen von Algorithmus In der ersten Hälfte des 20. Jahrhunderts wurden (ausser der Turingmaschine) eine ganze Reihe von Ansätzen entwickelt, um zu einer genauen Definition des Begriffs Algorithmus zu kommen. 2.2.2.1 Rekursive Funktionen Bei diesem Ansatz definiert man einen kleinen Satz von Grundfunktionen (Konstante Funktion, Projektion und Nachfolgerfunktion) und Regeln (Komposition, primitive Rekursion und µ-Operator). Die Regeln geben an, wie aus einer Funktion weitere Funktionen gebildet werden können. Ein Algorithmus ist dann eine Folge von Regeln, die aus den Grundfunktionen eine kompliziertere Funktion konstruieren. 2-4 2.2.2.2 Lambda-Kalkül Der Lambda-Kalkül (λ-Kalkül) wurde von Alonzo Church und Stephen Kleene in den 1930er Jahren eingeführt. Im Lambda-Kalkül steht jeder Ausdruck für eine Funktion mit nur einem Argument. Sowohl die Argumente als auch die Resultate solcher Funktionen sind wiederum Funktionen. Eine Funktion kann anonym durch eine so genannte Lambda-Abstraktion definiert werden, die die Zuordnung des Arguments zum Resultat beschreibt. Zum Beispiel wird die erhöhe-um-2 Funktion f(x) = x + 2 im Lambda-Kalkül durch die Lambda-Abstraktion λx.x+2 beschrieben; f(3) (die so genannte Funktionsanwendung) kann daher als (λx.x + 2)3 geschrieben werden. Ein Algorithmus ist dann ein λ-Ausdruck, der aus Abstraktionen und Funktionsanwendungen gebildet wird. 2.2.2.3 Computersprachen Zur Definition eines Algorithmus können wir auch Computersprachen heranziehen dies führt zu der folgenden Definition von Algorithmus: Definition 2.2 [Algorithmus (2.Version)] Ein Algorithmus zur Lösung eines Problems ist ein Computerprogramm, das aus Sequenzen, Selektionen und Iterationen gebildet ist, die Grundoperationen für die ganzen Zahlen N zulässt und für jede Eingabe in endlicher Zeit entweder die Lösung liefert oder meldet, dass keine Lösung existiert. 2.2.3 Die Church-Turing-These Es ist mathematisch bewiesen, dass alle obigen Definitionen gleichwertig sind. Das heisst, wenn ein Problem mit Hilfe von µ-rekursiven Funktionen lösbar ist, so auch mit einer TuringMaschine, dem λ-Kalkül oder einem Computer und umgekehrt. Bis heute ist keine Definition bekannt, die nicht äquivalent zu den obigen wäre. Dies hat zur Formulierung der Church-Turing-These geführt. 1. Alle vernünftigen Definitionen von “Algorithmus”, soweit sie bekannt sind, sind gleichwertig und gleichbedeutend. 2. Jede vernünftige Definition von “Algorithmus” (auch zukünftige), ist gleichwertig und gleichbedeutend zu den heute bekannten Definitionen. Dies ist natürlich nur eine These und nicht beweisbar. Die These sagt aus, dass wir glauben, die beste Definition für den Begriff “Algorithmus” gefunden zu haben. 2.2.4 Das Halteproblem In diesem Abschnitt wird ein bekanntes, nicht berechenbares Problem vorgestellt. Das heisst, ein Problem für das kein allgemeiner Algorithmus gefunden werden kann. Gegeben: Gesucht: Ein beliebiges Comuterprogramm, das in einer beliebigen Programmiersprache geschrieben ist. Die Programmiersprache sowie die Hardware auf der das Programm läuft, spielen für die folgenden Betrachtungen keine Rolle. Gesucht ist ein Algorithmus, der zu einem beliebigen Programm und zu beliebigen Input-Daten entscheidet, ob das Programm terminiert oder nicht. 2-5 Dieses Problem ist unter dem Namen Halteproblem bekannt. Das Halteproblem ist nicht berechenbar. Wir wollen nun zeigen, dass diese Aussage stimmt. Wir gehen dabei indirekt vor. Das heisst, wir nehmen an, dass ein solches Programm existiert und werden zeigen, dass diese Annahme zu einem Widerspruch führt. Annahme: Die Funktion stopp_tester, existiert und entscheidet, ob ein beliebiges Programm P bei beliebigen Input-Daten I terminiert. Die Funktionsweise von stopp_tester ist in der Abbildung 2-3 schematisch dargestellt. Programm P Input I terminiert P bei Input I? Ja Nein OK NOK Abbildung 2-3: Ablauf der Funktion stopp_tester Wir benutzen nun die Funktion stopp_tester um eine eingeschränkte Funtion stopp_tester_neu zu definieren. Die neue Funktion testet, ob ein beliebiges Programm P terminiert, wenn die Input-Daten aus P selbst bestehen. Die neue Funktion sieht also folgendermassen aus: stopp_tester_neu(P) { return stopp_tester(P,P); } Die Funktionsweise dieser neuen Funktion ist in der Abbildung 2-4 schematisch dargestellt. Programm P terminiert P bei Input P? Ja Nein OK NOK Abbildung 2-4: Ablauf der Funktion stopp_tester_neu 2-6 Aus stopp_tester_neu können wir nun die folgende Funktion unsinn konstruieren. unsinn(P) { if (stopp_tester_neu(P) == NOK) exit; else while (1 == 1); } Die Funktion unsinn ist selbst wieder ein Programm. Also können wir unsinn als InputDaten für die Funktion unsinn verwenden. In der Abbildung 2-5 ist schematisch dargestellt, was dann passiert. Hier ist aber ein Widerspruch. Es wird behauptet, dass die Funktion unsinn bei Input unsinn terminiert, wenn sie nicht terminiert und nicht terminiert, wenn sie terminiert. Dieser Widerspruch kann nur aufgelöst werden, wenn man einräumt, dass die Funktion unsinn nicht existieren kann. Die einzige Voraussetzung zur Herleitung von unsinn ist aber die Existenz der Funktion stopp_tester. Also kann auch die Funktion stopp_tester nicht existieren. unsinn(unsinn) terminiert unsinn bei Input unsinn? Ja Nein Endlos Loop stopp Abbildung 2-5: Ablauf von unsinn(unsinn) 2.3 Komplexität von Algorithmen Im Abschnitt 2.2 haben wir nur betrachtet, ob ein Problem algorithmisch lösbar ist oder nicht. Für lösbare Probleme ist es interessant zu wissen, wieviele Betriebsmittel eines Computers für ihre Lösung erforderlich sind. Nur solche Algorithmen, die eine vertretbare Menge an Betriebsmitteln nutzen, sind von praktischem Interesse. Die Komplexitätstheorie stellt die Frage nach dem Gebrauch von Betriebsmitteln und versucht diese zu beantworten. Meistens werden die Betriebsmittel Zeit und Speicherbedarf eines Algorithmus untersucht. Zeit Die vom Start bis zum Ende der Ausführung eines Algorithmus vergangene Zeit. Speicherbedarf Die vom Algorihmus benötigte Speichermenge (Memory). Dabei wird der Speicherplatz gemessen, der benötigt wird, um Zwischenergebnisse zu speichern. 2-7 Wir bezeichnen Algorithmen, die eine vertretbare Menge an Betriebsmitteln verwenden als durchführbar (engl. feasible). In den folgenden Betrachtungen wird untersucht, wann ein Algorithmus durchführbar ist und wann nicht. Die Abbildung 2-6 zeigt den Zusammenhang zwischen der Menge aller endlichen Probleme, der Menge der berechenbaren Probleme und der Menge der durchführbaren Probleme. Alle Probleme Berechenbare Probleme Durchfuehrbare Probleme Abbildung 2-6: Durchführbare Algorithmen 2.3.1 Zeitaufwand von Algorithmen Algorithmen nehmen gewöhnlich Eingabedaten entgegen und führen mit diesen eine Verarbeitung durch. Der Bedarf an Zeit wird daher von der Menge der Eingabedaten abhängig sein. Als Beispiel betrachten wir den Multiplikationsalgorithmus in der Abildung 2-7. 1 9 1 8 4 6 6 3 5 0 7 3 × 2 3 4 1 1 6 6 7 1 3 8 7 8 0 7 1 5 4 3 2 5 9 2 Abbildung 2-7: Standardalgorithmus zur Multiplikation Falls der Algorithmus mit zwei n-stelligen Zahlen durchgeführt wird, entstehen n Zahlen mit n (oder n + 1) Ziffern. Jede dieser Zahlen kann in n Schritte berechnet werden. Die Addition der n Zahlen erfordert n × n Schritte. Die Ausführungszeit des Algorithmus ist daher proportional zu n2 . Es existieren aber auch Algorithmen (z.B. Karatsuba oder Schönhage-Strassen), die zwei Zahlen mit weniger Operationen multiplizieren können. Wir halten also fest: • Ein Problem kann durch verschiedene Algorithmen mit verschiedener Komplexität gelöst werden. In vielen Fällen ist es von Interesse, den Algorithmus zu finden, der am wenigsten Betriebsmittel erfordert. • Die Komplexität eines Algorithmus hängt von der Menge der Eingabedaten ab. Je mehr Ziffern die Zahlen bei der Multplikation enthalten, um so länger wird die Ausführung des Algorithmus dauern. Im allgemeinen können wir die Komplexität eines Algorihmus als Funktion der Länge der Eingabedaten angeben. Die Funktion geht also von N nach R. 2-8 In vielen Fällen betrachten wir nicht die Länge der Eingabe in Bytes, sondern in grösseren, für das Problem natürliche Einheiten. Man spricht dann von der natürlichen Länge des Problems. Will man nur eine Grössenordnung für die Komplexität eines Algorithmus angeben, so zählt man auch nicht alle Operationen, sondern nur die, für die Lösung des Problems wichtigsten Operationen. In der Tabelle 2-8 sind Probleme mit ihrer natürlichen Länge und wichtigsten Operationen angegeben. Problem Primzahlenalgorithmen Sortalgorithmen Matrix Algorithmen natürliche Einheit Anzahl Ziffern Anzahl Elemente Dimension der Matrix Operationen Operationen in N Vergleiche und Vertauschungen Operationen in R Abbildung 2-8: “Natürliche” Länge von Problemen Eine weitere Vereinfachung ergibt sich, wenn man nur das assymtotische Verhalten der Komplexität eines Algorithmus betrachtet und nicht eine genaue Zahl. Es gibt viele Funktionen, bei denen für wachsendes n ein Ausdruck in der Funktion dominiert. Beispiel 2.2 [Dominierender Ausdruck] Wir betrachten die Funktion f (n) = 3 · n2 + 5 · n In dieser Funktion fällt für wachsendes n der Ausdruck 5 · n gegenüber dem Ausdruck 3 · n2 immer weniger ins Gewicht. Der dominierende Ausdruck ist in diesem Fall 3 · n2 . Man schreibt f (n) = O(n2 ) Wir wollen das assymptotishe Verhalten einer Funktion noch genau definieren. Definition 2.3 [Ordnung] f und g seien zwei Funktionen von N in R. Man sagt die Ordnung von f sei kleiner oder gleich der Ordnung von g, falls zwei positive Konstanten n0 und c existieren, sodass f (n) ≤ c · g(n) ∀n ≥ n0 Man schreibt dafür auch: Beispiel 2.3 [Ordnung von Funktionen] Nachfolgend einige Beispiele für die Ordnung von Funktionen. 5 2 · n = O(n2 ) 5 · n4 + 2 · n3 − 7 = O(n4 ) n4 6= O(n3 ) n10 = O(2n ) 2-9 Definition 2.4 [assymptotisches Verhalten] Θ(f ) ist die Menge aller Funktionen g für die gilt: g = O(f ) ∧ f = O(g) Obschon Θ() als Menge definiert ist schreiben wir kurz: g = Θ(f ) Man sagt g hat dasselbe assymptotische Verhalten wie f . Nachfolgend sind einige wichtige Regeln (ohne Beweis) angegeben: • Die Ordnung des Logarithmus ist kleiner als die Ordnung einer linearen Funktion. log(n) = O(n) ∧ log(n) 6= Θ(n) • Die Ordnung eines Polynoms ist gleich der Ordnung des Terms mit der höchsten Potenz. ak nk + ak−1 nk−1 + · · · + a1 n + a0 = Θ(nk ) ak 6= 0 • Ein Polynom vom Grad k hat eine höhere Ordnung als ein beliebiges Polynom mit Grad < k. k0 , k1 ∈ N ∧ k0 < k1 ⇒ nk0 = O(nk1 ) ∧ nk0 6= Θ(nk1 ) • Die Ordnung der Exponentialfunktion ist grösser als die Ordnung eines beliebigen Polynoms. ∀c > 1, d > 1 : nd = O(cn ) ∧ nd 6= Θ(cn ) Bemerkung 2.2 [Worst Case und Averrage] Wenn wir vom Aufwand eines Algorithmus sprechen sind wir häufig an zwei Werten interessiert: 1. Aufwand im schlechtesten Fall (worst case). Diese Zahl interessiert uns vor allem in realtime Systemen, wo die Antwortzeit des Systems nach gewissen Ereignissen spezifiziert ist. Diese Zeit darf auch nicht im schlechtesten Fall überschritten werden. 2. Aufwand im Durchschnitt (average). Diese Zahl ist dann interessant, wenn in einem System ein Algorithmus häufig ausgeführt wird (zum Beispiel sortieren in einer kommerziellen Applikation). In einem solchen Fall ist nur wichtig, dass der Algorithmus im Durchschnitt ein gutes Verhalten zeigt. Man hofft, dass der schlechteste Fall eben nur selten eintrifft. Wir wollen uns nun der Frage zuwenden, wann ein Agorithmus durchführbar ist und wann nicht. Dafür betrachten wir die verschiedenen Zeitfunktionen in der Tabelle 2-9. Wir nehmen nun an, dass für alle in der Tabelle 2-9 angegebenen Algorithmen eine Operation in einer Nanosekunde ausgeführt werden kann. Die daraus resultierenden Zeiten für verschiedene n’s sind in der Tabelle 2-10 angegeben. 2-10 Zeitverhalten Θ(log2 (n)) Θ(n) Θ(n2 ) Θ(2n ) Algorihmus Binary Search mit n Elementen Sequentielles Suchen in einem Array der Länge n Addition von zwei n × n Matritzen Durchlaufen eines binären Baumes der Tiefe n Abbildung 2-9: Zeitverhalten von Algorithmen n 10 100 1000 10000 100000 Θ(log2 (n)) 3 · 10−9 7 · 10−9 1 · 10−8 1.3 · 10−8 1.7 · 10−8 s s s s s Θ(n) 1 · 10−8 1 · 10−7 1 · 10−6 1 · 10−5 1 · 10−4 s s s s s Θ(n2 ) 1 · 10−7 1 · 10−5 1 · 10−3 1 · 10−1 10 s s s s s Θ(2n ) 1 · 10−6 1013 10284 103002 1030095 s Jahre Jahre Jahre Jahre Abbildung 2-10: Ausführungszeiten von Algorithmen Aus der Tabelle 2-10 geht hervor, dass der Algorithmus mit einer Komplexität von 2n schon bei kleinsten n undurchführbar wird. Algorithmen mit einer Komplexität der Form nc (c konstant) sind dagegen durchführbar. Definition 2.5 [Polynomiale- und Exponentielle-Algorithmen] Algorithmen, deren asymtotisches Verhalten nc für eine Konstante c > 1 ist, heissen polynomiale Algorithmen. Auch Algorithmen mit kleinerer Komplexität wollen wir polynomial nennen. Algorithmen, deren asymtotisches Verhalten cn für eine Konstante c > 1 ist, heissen exponentielle Algorithmen. Ein polynomialer Algorithmus ist leider in der Praxis auch nicht immer durchführbar. Nehmen wir an, dass die Komplexität eines Algorithmus n100 sei. In diesem Fall ist zwar der Algorithmus polynomial, wie wir aber aus der Tabelle 2-10 entnehmen können schon für n = 2 undurchführbar. Die Grenze zwischen durchführbaren und undurchführbaren Algorithmen wird trotzdem zwischen polynomialen und exponentiellen Algorithmen gezogen, da bis heute kein besseres Abgrenzungskriterium bekannt ist. Definition 2.6 [Durchführbarkeit] Ein Algorithmus ist durchführbar, wenn er polynomial ist, sonst ist er undurchführbar. Da alle heutigen sequentiellen Computer einander simulieren können und der Zeitaufwand für die Simulation höchstens polynomial ist, ist diese Definition maschinenunabhängig. 2.3.2 Amortisierte Kosten Amortisation ist ein wichtiges analyse Werkzeug für Algorithmen bei denen die Kosten der verschiedenen Operationen stark varieren. Eine typische Datenstruktur stellt meistens viele verschiedene Operationen zur Verfügung um auf die einzelnen Elemente der Struktur zuzugreifen oder um diese zu verändern. Wir können diese Operationen auf ihr worst-case verhalten analysieren. Amortisation nimmt einen anderen Standpunkt ein. Statt jede Operation für sich zu betrachten werden Sequenzen von Operationen auf ihr Laufzeitverhalten untersucht. 2-11 2.3.2.1 Löschbare Tabelle Als Beispiel wollen wir eine sehr einfache Datenstruktur betrachten: die löschbare Tabelle. Diese Struktur speichert Elemente in einer Tabelle. Auf die Elemente kann mit ihrem Index in der Tabelle zugegriffen werden. Zusätzlich werden die beiden folgenden Operationen zur Verfügung gestellt: add(e): clear(): Das Element e wird in die nächste freie Zelle der Tabelle eingefügt Alle vorhandenen Elemente werden aus der Tabelle entfernt. Wir nehmen nun an, dass S eine löschbare Tabelle mit n Elementen ist. S sei als Array implementiert mit einer zusätzlichen Variablen N , die die Länge der Tabelle angibt. Beim löschen der Tabelle müssen alle Elemente auf null gesetzt werden um die Tabelle wirklich zu löschen. Das heisst, die Löschoperation braucht Θ(n) zeit. Nun betrachten wir eine Sequenz von n add und/oder clear Operationen. Falls wir eine worst-case Analyse durchführen ist der Aufwand für diese Sequenz O(n2 ), da der schlechteste Fall für eine Operation (clear) O(n) ist. Diese Analyse ist an sich korrekt aber übertrieben. Falls wir auch die Beziehungen zwischen den einzelnen Operationen in der Sequenz in betracht ziehen, können wir zeigen, dass der Zeitaufwand nur O(n) beträgt. Sei M0 , M1 . . . Mn−1 eine Sequenz von n Operationen, die mit einer leeren lösch Tabelle beginnt. Seien Mi0 , Mi1 . . . Mik−1 mit 0 ≤ i0 < · · · < ik−1 ≤ n − 1 die k clear Operationen. Die Laufzeit für die Operation Mij ist O(ij − ij−1 ). Daraus erhalten wir die Laufzei für alle clear Operationen. Dabei setzen wir den Wert von i−1 = −1 k−1 X Laufzeit clear Operationen = O j=0 (ij − ij−1 ) Dies ist eine sogenannte teleskopische Summe dabei heben sich fast alle Summanden auf i0 − i−1 + i1 − i0 + i2 − i1 · · · + ik−1 − ik−2 Es bleit also nur (ik−1 − i−1 ) übrig und das ist O(n). Da die add Operationen alle O(1) sind, erhalten wir für die ganze Sequenz M0 , M1 . . . Mn−1 eine Laufzeit von O(n). 2.3.2.2 Amortisationstechnik: Die Buchhaltungsmethode Es gibt verschiedene Techniken zum berechnen der amortisierten Kosten. Wir wollen nur die sogenannte Buchhaltungstechnik betrachten. Die Idee ist, dass jeder Zeitabschnitt, der zur Bewältigung einer Aufgabe benötigt wird, einen Cyberdollar kostet. Wir müssen dazu schauen, dass immer genügend Cyberdollars zur Verfügung stehen um eine Operation auszuführen. Trick: Gewisse Operationen werden mit höheren Kosten als die effektiven Kosten belastet. Das angehäufte Geld kann dazu verwendet werden um “teure” Operationen zu bezahlen. Wir versuchen nun, die Operationen so zu belasten, dass wir im Durchschnitt für jede Operation gleich viel bezahlt wird, ohne dass uns die Cyberdollars jemals ausgehen. Wir nennen dies ein amortisations Schema. Wir betrachten nun als Beispiel wieder die löschbare Tabelle. Wir nehmen an, dass die effektiven Kosten für eine add Operation 1 Cyberdollar betragen. In unserem amortisations Schema bezahlen wir nun für jede Operation 2 Cyberdollars. Bei der add Operation können wir also 2-12 einen Cyberdollar sparen. Wir denken uns, dass in jedem Element ein Cyberdollar gespeichert ist. Wird nun die clear Operation audgeführt, so haben wir genügend Cyberdollars um jedes Element zu löschen (Kosten pro Element 1 Cyberdollar). Das heisst, wenn wir bei jeder Operation 2 Cyberdollars bezahlen, so haben wir immer genug Geld um die clear Operationen zu bezahlen. Das heisst, der Auffand für eine Sequenz von n Operationen (beginnend mit einer leeren Tabelle) ist 2n also O(n). Die amortisierten Kosten für eine Operation sind in diesem Fall O(1). 2.3.2.3 Erweiterbarer Array Eine Schwäche der löschbaren Tabelle ist die vorgegebene Grösse N des Arrays. Falls nur wenige Elemente gespeichert sind, so geht viel Platz verloren. Falls die Anzahl Elemente n grösser als N wird, so wird das System abstürzen. Wir wollen eine Datenstruktur entwickeln, die den Array vergrössert, wenn dies nötig ist. In konventionellen Sprachen wie C oder Java kann die grösse des Arrays A nicht einfach verändert werden. Falls ein Overflow passiert wenden wir indessen die folgende Prozedur an: 1. Alloziere einen neuen Array B mit der Grösse 2N 2. Kopiere A[i] nach B[i] für i = 0, . . . N − 1 3. Setze A = B und dealloziere A Diese Strategie bezeichnet man als erweiterbarer Array (engl. extendable array). Das Wachstum des Arrays ist in der Abbildung 2-11 angegeben. Abbildung 2-11: (a) kreieren des Arrays B (b) kopieren der Elemente von A nach B (c) setzen von A=B Diese Strategie scheint nicht sehr effizient zu sein, da bei einer neuen Allokation des Arrays n Elemente kopiert werden müssen (dies bedeutet eine Komplexität von O(n)). Man beachte jedoch, dass nach der Allokation n Elemente eingetragen werden können, bevor der Array wieder erweitert werden muss. Dank dieser Tatsache können wir zeigen, dass eine Sequenz von Operationen, die mit einer leeren Tabelle beginnt sehr effizient ist. Wir benutzen dazu die amortisations Technik. Satz 2.1 [Erweiterbarer Array] Sei S eine Tabelle, die mittels eines erweiterbaren Arrays A implementiert ist. Die Laufzeit einer Sequenz von n add Operationen, die mit einer leeren Tabelle S beginnt wobei A die Grösse N = 1 hat, ist O(n). Beweis: Wir benutzen dazu die Buhhaltungsmethode: Wir nehmen an, dass eine add Operation (ohne overflow) effektiv einen Cyberdollar kostet. Wir nehmen ferner an, dass die Kosten um den Array von der Grösse k auf 2k zu expandieren k Cyberdollars kostet (einen Cyberdollar für das Kopieren eines Elements). Wir bezahlen nun drei Cyberdollar pro add Operation. Wir stellen uns vor, dass die 2-13 verbleibenden zwei Cyberdollars in dem entsprechenden Element gespeichert sind. Ein Overflow passiert, wenn in der Tabelle 2i Elemente für ein gegebnes i ≥ 0 gespeichert sind. Daher wird die Vergrösserung des Arrays 2i Cyberdollar kosten. Nun können dies 2i Cyberdollars in den Elementen 2i−1 bis 2i − 1 des Arrays gefunden werden (siehe Abbildung 2-12). Abbildung 2-12: (a) Array mit 8 Elementen. Die Elemente 4 bis 7 besitzen je 2 Cyberdollar (b) neuer Array die Cyberdollar der Elemente 4 bis 7 sind aufgebraucht. Das Element 8 speichert nun 2 Cyberdollars Man beachte, dass der letzte overflow beim einfügen des Elements 2i−1 stattgefunden hat und seit dem sind keine in den Elementen gespeicherten Cyberdollars ausgegeben worden. Das heisst, wenn wir pro add Operation 3 Cyberdollar bezahlen sind die Kosten für die ganze Sequenz 3n also O(n). Zum Schluss wollen wir noch betrachten welche Kosten entstehen, wenn der Array immer um eine fixe Anzahl Elemente c erweitert wird. Satz 2.2 [Fixer Inkrement] Falls wir eine Tabelle mit einem Array implementieren und der Array bei Bedarf um einen fixen Wert erweitert wird, so hat eine Sequenz von n add Operationen, die mit der leeren Tabelle beginnt eine Laufzeit von O(n2 ) Beweis: Sei c > 0 der Wert des Inkrements und c0 > 0 die Anfangskapazität des Arrays. Ein Overflow wird passieren, wenn die Anzahl der Elemente in der Tabelle die form c0 + ic für i = 0 . . . m − 1 hat mit m = ⌊(n − c0 )/c⌋. Das heisst, die gesmate Laufzeit für die Sequenz ist proportional zu m−1 X i=0 (c0 − ic) = c0 m + c m−1 X i=0 i = c0 m + c m(m − 1) 2 dies ist aber O(n2 ). In der Abbildung 2-13 ist der Vergleich der Laufzeit der beiden Methoden (verdoppeln der Grösse und erweitern um einen fixen Wert) dargestellt. 2-14 Abbildung 2-13: (a) Grösse wird verdoppelt (b) Array wird immer um 3 Elemente vergrössert 2.3.3 Die Klassen P und N P In diesem Abschnitt wollen wir eines der berühmtesten Probleme der theoretischen Informatik informell besprechen. Wie im Abschnitt 2.3.1 schon besprochen, sind exponentielle Algorithmen schon für kleine Eingabedaten undurchführbar. Leider existieren viele prakische Probleme, für die nur exponentielle Algorithmen bekannt sind. Auch ist die Grenze zwischen leichten und schwierigen Problemen oft sehr schmal. Dies wird durch das folgende Beispiel illustriert. Beispiel 2.4 [Polynomiale und Exponentielle Probleme] polynomial Existiert in einem gewichteten Graphen ein Weg von x nach y mit einem Gewicht G < M? exponentiell Existiert in einem gewichteten Graphen ein Weg von x nach y mit einem Gewicht G > M? Bis heute ist es nicht gelungen zu zeigen, dass das zweite Problem nur in exponentieller Zeit zu lösen ist. Es ist aber auch nicht gelungen, einen polynomialen Algorithmus zu finden. Wir wollen nun die zwei wichtigen Klassen P und N P definieren und Beispiele dazu angeben. Bemerkung 2.3 [Darstellung des Inputs] Für diese Betrachtungen wird die Komplexität eines Algorithmus als Funktion der Anzahl Bits des Inputs gegeben. Solange die Darstellung des Inputs “vernünftig” ist, spielt diese für die Komplexität keine grosse Rolle. Eine unvernünftige Darstellung für ganze Zahlen wäre die unäre Darstellung. 1 = 1 Bit, 2 = 2 Bits, 3 = 3 Bits u.s.w. Wir erwarten, dass die Anzahl benötigten Bits zur Darstellung einer ganzen Zahl M proportional zu log(M ) ist. 2.3.3.1 Entscheidungsprobleme Als erstes möchten wir eine Vereinfachung vornehmen und nur sogenannte Entscheidungsprobleme betrachten. Entscheidungsprobleme sind Probleme, für die zu einer gegebenen Eingabe als Lösung nur zwei Antworten (ja oder nein) vorgesehen sind. 2-15 Typische Entscheidungsprobleme 1. Gegeben sei eine Formel der Aussagenlogik in konjuktiver Normalform. Gibt es eine Belegung der Variablen (mit 0 und 1) so, dass die Formel wahr wird? 2. Gegeben seien zwei Strings P und T. Ist P als Substring in T enthalten? 3. Gegeben seien Zahlentrippel (a,b,c) mit a, b, c ∈ Z. Gilt die Aussage a · b = c ? 4. Gegeben sei ein gewichteter Graph G und eine Zahl k. Gibt es für G einen minimalen aufspannenden Baum mit einem Gewicht kleiner als k? An den Beispielen sieht man, dass die meisten relevanten Probleme auch als Entscheidungsprobleme formuliert werden können. Insbesondere lassen sich Optimierungsprobleme (Punkt 4) als Entscheidungsprobleme formulieren. Entscheidbare Sprachen Ein Entscheidungsproblem kann auch als formale Sprache L verstanden werden. Dazu definieren wir eine formale Sprache L (für language) als eine Menge von Strings über ein gegebnes endliches Alphabet Σ. Ein Entscheidungsproblem kann nun folgendermassen formuliert werden: Gegeben sei ein endliches Alphabet Σ eine formale Sprache L über Σ und ein Wort (String) über das Alphabet Σ. Die Frage ist nun, ob das Wort zur Sprache L gehört oder nicht. Eine formale Sprache L ist entscheidbar (rekursiv entscheidbar), wenn eine Turingmaschine existiert, die bei Eingabe eines Wortes der Sprache als Ausgabe entweder 1 (ja) oder 0 (nein) ausgibt. Bemerkung 2.4 [Entscheidbare Sprachen versus Entscheidungsproblem] Aus den obigen Betrachtungen geht hervor, dass es keine Rolle spielt ob wir von entscheidbaren Sprachen oder von Entscheidungsproblemen sprechen. 2.3.3.2 Die Klasse P Definition 2.7 [Klasse P] Die Klasse P ist die Menge aller Entscheidungsprobleme (oder Sprachen), die von einer deterministischen Turingmaschine in polynomialer Zeit entschieden werden können. Deterministisch heisst, dass nach jedem Schritt der Turingmaschine der nächste Zustand eindeutig definiert ist. Sehr viele praktische Probleme gehören zur Klasse P. • Gibt es in einem Graphen einen Weg von a nach b der kürzer ist als k? • Hat ein lineares Gleichungssystem mit n Unbekannten eine Lösung? • u.s.w. 2-16 2.3.3.3 Die Klasse N P Nichtdeterministische Turingmaschine Bei einer nichtdeterministischen Turingmaschine ist die Überführungsfunktion δ folgendermassen definiert: δ : Q × Γ 7−→ P(Q × Γ × {L, 0, R}) Hierbei steht P wie üblich für die Potenzmenge. Das heisst, dass das Ergebnis der Überführungsfunktion eine Menge von Überführungstripeln ist, und nicht mehr genau ein Überführungstripel. Durch diese Überführungsrelation ist der Folgezustand, der sich aus dem aktuellen Bandzeichen und dem aktuellen Zustand ergibt, nicht mehr eindeutig bestimmt. Die Turingmaschine hat also im Allgemeinen zu jedem Berechnungszeitpunkt eine Auswahl an Folgezuständen, wodurch verschiedene nicht eindeutig vorherbestimmte Rechenwege möglich sind. Alle diese Rechenwege können von der Maschine parallel ausgeführt werden. Die Eingabe wird von der nichtdeterministischen Turingmaschine akzeptiert, wenn es einen Rechenweg gibt, dessen Ausgabe “ja” ist. Wir können nun die Klasse N P (Nondeterministic Polynomial-Time Algorithms) definieren. Definition 2.8 [Klasse N P] Die Klasse N P enthält alle Entscheidungsprobleme (oder Sprachen), die von einer nichtdeterministischen Turingmaschine in polynomialer Zeit entschieden werden können. Aus dieser Definition können wir einige Schlüsse ziehen. • Jedes Problem in P ist natürlich auch in N P. Eine deterministische Turingmaschine kann als Spezialfall einer nichtdeterministischen Turingmaschine angesehen werden. Das heisst, P ⊆ NP • Auf den ersten Blick scheint es klar zu sein, dass nichtdeterministische Turingmaschinen mehr Probleme in polynomialer Zeit lösen können als deterministische. In unserer Sprache würde das heissen, dass P eine echte Teilmenge von N P ist • Bis heute ist es aber niemandem gelungen, diese Tatsache zu beweisen oder zu widerlegen. Das heisst, Es ist heute nicht bekannt, ob N P eine echte Obermenge von P ist oder nicht. Dieser Tatbestand ist ziemlich frustrierend, da wir heute für viele praktische Probleme in N P nur exponentielle Algorithmen kennen. Wir wissen aber nicht, ob doch ein polynomialer Algorithmus für das Problem existiert oder nicht. Man weiss zwar nicht, ob die Klassen P und N P gleich sind oder nicht, aber man kennt wenigsten die schwierigsten Probleme in der Klasse N P. Schwierig heisst in diesem Zusammenhang, dass wenn man von einem dieser Probleme beweisen kann, dass es in polynomialer Zeit lösbar ist, so sind alle Probleme in N P auch in polynomialer Zeit lösbar. 2-17 2.3.3.4 N P-Vollständigkeit Definition 2.9 [Polynomial reduzierbar] Eine Sprache L über das Alphabet Σ, die ein Entscheidungsproblem darstellt ist polynomial reduzierbar auf die Sprache M über das Alphabet Σ′ , falls eine Funktion f : Σ∗ 7→ Σ′∗ existiert, die in polynomialer Zeit berechenbar ist und für die gilt: x ∈ L ⇔ f (x) ∈ M Wir schreiben dafür L ≤p M Beispiel 2.5 [Polynomiale Reduktion] So lässt sich die Sprache aller Quadratzahlen LQ = {(a, b) ∈ Z2 : b = a2 } in polynomialer Zeit auf die Sprache der Multiplikationen mit zwei Faktoren LM = {(a1 , a2 , b) ∈ Z3 : b = a1 · a2 } über die Abbildung f : Z2 7→ Z3 , (a, b) 7→ (a, a, b) reduzieren. Mit dieser Reduktion ist gezeigt, dass ein Algorithmus, welcher zwei natürliche Zahlen miteinander multipliziert, auch eine natürliche Zahl quadrieren kann, das Quadrieren kann also auf das Multiplizieren reduziert werden. Definition 2.10 [N P-vollständigkeit] Eine Sprache M heisst N P-schwer falls ∀L ∈ N P gilt L ≤p M . Falls zusätzlich M ∈ N P gilt, so heisst die Sprache M N P-vollständig. Um zu zeigen, dass ein Problem N P-vollständig ist, muss man nur zeigen, dass es auf ein anderes N P-vollständiges Problem polynomial reduzierbar ist. Dazu muss aber mindestens ein N P-vollständiges Problem bekannt sein. 2.3.3.5 Cook’s Theorem Gegeben sei eine Formel der Aussagenlogik in konjunktiver Normalform. Das heisst, die Formel besteht aus beliebiegen Konjunktionen von Disjunktionstermen. Ein Disjunktionsterm ist eine Disjunktion von nichtnegierten oder negierten Variablen Beispiel 2.6 [Konjuktive Normalform] Die folgende Formel ist in konjunktiver Normalform. (x1 ∨ x2 ) ∧ (x3 ∨ x4 ∨ x5 ) Gesucht ist, ob eine Formel in konjunktiver Normalform erfüllbar ist. Das heisst, kann der Wert der Variablen so gewählt werden, dass die Formel wahr wird. Die zwei erlaubten Werte für die einzelnen Variablen sind wahr und falsch. Satz 2.3 [(Cook 1971)] Das Erfülbarkeitsproblem für Formeln in konjuktiver Normalform ist N P-vollständig. 2-18 Der Beweis dieses Theorems zeigt, dass zu jeder nichtdeterministischen Turingmaschine TM und zu jedem Input I der von TM in polynomialer Zeit entschieden wird, eine entsprechende logische Formel konstruiert werden kann, die genau dann erfüllbar ist, wenn TM I akzeptiert. Beispiel 2.7 [N P-vollständige Probleme] Wir wollen zuletzt noch einige Beispiele N Pvollständiger Probleme angeben. Für all diese Probleme sind also heute nur exponentielle Algorithmen bekannt. • Traveling Salesman: Gegeben ist eine Menge von Städten und die Distanzen zwischen all diesen Städten. Gesucht ist eine Tour durch alle Städte, so dass der gesammte Weg < M ist. • Hamilton Kreise: Gegeben sei ein Graph. Gesucht ist ein einfacher Kreis, der alle Ecken des Graphen durchläuft. • Partition: Gegeben sei eine Menge von ganzen Zahlen. Gesucht sind zwei disjunkte Teilmengen, so dass die Vereinigung der Teilmengen gleich der Menge ist und die Summe der Elemente in beiden Teilmengen dieselbe ist. 2.3.4 Lower Bound (LB) Die Lower-Bound-Theory hat zum Ziel, den minimalen Aufwand zu ermitteln, der zur Lösung eines Problems notwendig ist. Der minimale Aufwand gibt einen Hinweis auf das mögliche Verbesserungspotential von bekannten Algorithmen, die das Problem lösen. Den Lower-Bound für ein Problem zu finden ist im allgemeinen eine sehr schwierige Aufgabe. Zum Beispiel ist für alle N P-vollständigen Probleme der Lower-Bound nicht bekannt. Aber auch für viele andere Probleme kann der minimale Aufwand nur geschätzt werden (Angabe einer unteren Schranke). Für die folgenden Probleme ist der Lower-Bound (LB) bekannt. Dieser gilt nur für sequentielle Computer. Für parallele Maschinen kann der LB tiefer sein. • Suchen des grössten Elements in einem unsortierten Array der Länge n. LB = Θ(n) • Suchen eines Elementes x in einem sortierten Array der Länge n. LB = Θ(log2 (n)) • Sortieren der Elemente in einem Array der Länge n. LB = Θ(n · log2 (n)) Für die folgenden Probleme ist der Lower-Bound nicht bekannt. • Für alle N P-vollständigen Probleme ist LB nicht bekannt. 2-19 • Für die Multiplikation von zwei (n × n) Matritzen ist der LB auch nicht bekannt. Hier können wir aber die untere Schranke Θ(n2 ) angeben. • Für die Faktorisierung einer Natürlichen Zahl mit n Dezimalstellen. Resultat. 2-20 Kapitel 3 Sortieralgorithmen 3.1 Einführung Nachstehend sind die wichtigsten Begriffe, die im Zusammenhang mit dem Sortieren auftauchen zusammengefasst: • Ein File der Länge n ist eine Sequenz von n Records. • Ein Schlüssel oder Key besteht aus bestimmten Felder des Records (z.B. Der Ort und der Name einer Person im Telefonbuch bilden zusammen einen Schlüssel). • Wenn der Key im Record enthalten ist spricht man von internal oder embedded key. Wird der Key zusammen mit einem Zeiger auf die Daten in einer externen Tabelle gehalten, so spricht man von einem external key. • Ein Sortieralgorithmus ist ein Verfahren, das die Records in einem File der Länge n so anordnet das gilt: (∀i, j ∈ [0, n − 1])((i < j) ⇒ key(r(i)) ≤ key(r(j))) dabei bedeutet key(r(i)) der Schlüssel des i-ten Records. ≤ ist eine totale Ordnung auf die Menge der Schlüssel. • Hat das ganze File im Hauptspeicher Platz, so spricht man von einem internen Sortierverfahren. Wird ein File auf einer Disk oder auf einem Magnetband sortiert, so spricht man von einem externen Sortierverfahren. • Ein Inplace-Sortierverfahren sortiert einen Array an Ort und Stelle. Das heisst es gibt kein Umkopieren des Arrays in einem Hilfsarray. • Ein Sortieralgorithmus heisst stabil, wenn die ursprüngliche Reihenfolge zweier Records mit gleichem Key erhalten bleibt. 3.2 Elementare Sortierverfahren Elementare Sortierverfahren Selection-, Insertion- und Bubble-Sort eignen sich zum Sortieren von kleinen Arrays. Ihr Vorteil ist ihre Einfachheit. Für grosse Arrays sind diese Sortierverfahren nicht geeignet, da Sie eine Komplexität von O(n2 ) aufweisen. Bei allen drei Verfahren wollen wir die Elemente eines gegebenen Arrays in aufsteigender Reihenfolge inplace sortieren. 3-1 3.2.1 Sortieren durch Auswählen (selection sort) Dies ist wahrscheinlich das intuitivste Verfahren und funktioniert folgendermassen: • Suche im Array[1..n] das kleinste Element und vertausche dieses mit dem 1. Element. • Suche im Array[2..n] das kleinste Element und vertausche dieses mit dem 2. Element. • Suche im Array[3..n] das kleinste Element und vertausche dieses mit dem 3. Element. • usw. Der Algorithmus ist nachfolgend in Java angegeben Algorithmus 3.1 [Sortieren durch Auswählen] public class SelectionSort { public static void selectionSort(int buffer[]) { int left; int minpos; int size = buffer.length; for (left = 0; left < size - 1; left++) { minpos = left; // Suchen des Minimums for (int i = left; i < size; i++) { if (buffer[i] < buffer[minpos]) { minpos = i; } } // Elemente vertauschen int tmp = buffer[left]; buffer[left] = buffer[minpos]; buffer[minpos] = tmp; } } } 3.2.2 Sortieren durch Einfügen (insertion sort) Bei diesem Algorithmus werden die Elemente der Reihe nach im bereits sortierten Anfang des Arrays eingetragen. Dazu wird im bereits sortierten Teil des Arrays die richtige Stelle zum Einfügen von rechts her gesucht und gleichzeitig die bereits sortierten Elemente um eins nach rechts verschoben. Das neue Element wird dann an der auf diese Weise freigewordene Position eingefügt. Am Anfang besteht die sortierte Folge von Elementen nur aus dem Ersten Element im Array und das zweite Element wird eingefügt. Anschliessend wird das dritte Element in die sortierte Folge der zwei ersten Elemente eingefügt usw. 3-2 Der Algorithmus ist nachfolgend in Java angegeben Algorithmus 3.2 [Sortieren durch Einfügen] public class InsertionSort { public static void insertionSort(int buffer[]) { int left; int actual; int size = buffer.length; for (left = 1; left < size; left++) { actual = buffer[left]; // Verschieben der Elemente bis der Platz gefunden ist. int pos = left - 1; while (pos > 0 && actual <= buffer[pos]) { buffer[pos + 1] = buffer[pos]; --pos; } // Element an den richtigen Platz verschieben. buffer[pos] = actual; } } } 3.2.3 Sortieren durch Vertauschen (bubble sort) Dieser Algorithmus beruht auf folgender Idee: Der Array wird von Anfang bis zum Ende durchlaufen. Die Elemente werden dabei paarweise verglichen. Wenn beide in der richtigen Reihenfolge stehen, dann wird mit dem nächsten Paar fortgefahren. Stehen sie nicht in richtiger Reihenfolge, werden sie vertauscht, und anschliessend mit dem nächten Paar weitergefahren. Wenn der Array durchlaufen ist, so steht das grösste Element am Ende des Arrays. Der Prozess wird dann ohne das letzte Element wiederholt. Im nächsten Durchgang werden die letzten beiden Elemente ausgelassen usw. bis fertig sortiert ist. Der Algorithmus ist nachfolgend in Java angegeben Algorithmus 3.3 [Sortieren durch Vertauschen] public class BubbleSort { public static void bubbleSort(int buffer[]) { int size = buffer.length; int right = size - 1; while (right > 0) { int lastchange = 0; for (int i = 0; i < right; i++) { if (buffer[i] > buffer[i + 1]) { // Elemente vertauschen 3-3 int tmp = buffer[i]; buffer[i] = buffer[i + 1]; buffer[i + 1] = tmp; lastchange = i + 1; } } right = lastchange; } } } 3.2.4 Vergleich der elementaren Sortierverfahren Hier noch einige Bemerkungen zu den drei Elementarverfahren. • Die komplexität der drei elementaren Algorithmen ist (O(n2 )) (im Prinzip ein zweifach verschachtelter Loop über n Elemente). Die Aussagen können aber noch folgendermassen Präzisiert werden: – Selection-Sort benötigt ca. n2 2 Vergleiche und n Austauschoperationen. 2 2 – Insertion-Sort benötigt im Durchschnitt ca. n4 Vergleiche und n8 Austauschoperationen. Im schlechtesten Fall sind es doppelt so viele. In diesem Algorithmus werden Austauschoperationen nur halb gezählt, weil eine Austauschoperation nur aus dem schieben einer Zahl besteht (bei richtigen Austauschoperationen sind 3 Zuweisungen notwendig). – Bubbel-Sort benötigt ca. n2 2 Vergleiche und n2 2 Austauschoperationen. • Werden grosse Records Vertauscht, so ist Selection-Sort am besten, da dort am wenigsten Vertauschoperationen durchgeführt werden müssen. • Ist der Vergleich der Keys aufwendig (grosser Key), so ist Insertion-Sort am besten, da im durchschnitt am wenigsten Vergleiche notwendig sind. • Im besten Fall sind Insertion-Sort und Bubble-Sort linear. • In der Praxis ist der Bubble-Sort das schlechteste elementare Verfahren. Dies liegt daran, dass der innerste Loop des Algorithmus bezüglich den anderen Verfahren sehr aufwendig ist (siehe auch Übungen). 3.3 Heapsort Der Heapsort wird in der Praxis selten verwendet. Das Verfahren benötigt aber eine interessante Datenstruktur, die zur Implementation einer Priorityqueue verwendet werden kann. 3.3.1 Priorityqueue Eine Priorityqueue PQ ist eine Warteschlange, aus der die Elemente nicht in erster Linie nach Wartezeit, sondern nach Priorität ausgelesen werden. Wichtigste Anwendungen von PQ’s: 3-4 • Simulation • Job Scheduling in Computersystemen • Datenkompression • Graph-Algorithmen • usw. Die PQ kann natürlich wieder als ADT definiert werden. Die wichtigsten Operationen sind dabei: • Einfügen eines Elements. • Löschen des Elements mit grösster Priorität • Aufbauen einer PQ aus n gegebenen Elementen. Nachfolgend ist die Spezifikation des ADTs als Java-Interfacedefinition gegeben /** * Dieses Interface definiert die Schnittstelle zu einer * Prioritaetsschlange. Die Implementation steht noch offen */ public interface PrioQueue { public static class FullException extends Exception{} public static class EmptyException extends Exception{} /** Preconditions: full() != true<BR> * Postconditions: -- Ein Element mehr (mit Prioritaet Prio) * in der PQ<BR> * new.pqlength() = old.pqlength() + 1<BR> * new.readprio() = Max(old.readprio(), prio) * <BR> * * @param prio Prioritaet des neuen Elementes * @param obj Nutzdaten */ public void insert(Comparable prio, Object obj) throws PrioQueue.FullException; /** Preconditions: pqlength() != 0<BR> * Postconditions: -- Das Element mit groesster Prioritaet * wird entfernt. Sind mehrere * Elemente mit groesster Prioritaet * vorhanden, so ist die Wahl unter diesen * Elementen nicht deterministisch.<BR> * new.pqlength() = old.pqlength() - 1<BR> * new.readprio() <= old.readprio()<BR> */ public void remove() 3-5 throws PrioQueue.EmptyException; /** Preconditions: none<BR> * Postconditions: none<BR> * * @return: false falls Element eingefuegt werden kann */ public boolean full(); /** Preconditions: none<BR> * Postconditions: none<BR> * * @return: Anzahl Elemente in PQ */ public int pqlength(); /** Preconditions: pqlength() != 0<BR> * Postconditions: none<BR> * * @return: Daten des Elements mit der groessten Prioritaet. * Sind mehrere Elemente mit groesster Prioritaet * vorhanden, so ist die Wahl unter diesen Elementen * nicht deterministisch. */ public Object readData(); /** Preconditions: pqlength() != 0<BR> * Postconditions: none<BR> * * @return: Groesste Prioritaet in der PQ. */ public Comparable readPrio(); } 3.3.2 Implementation der PQ als Heap Eine PQ kann natürlich auf viele verschiedene Arten implementiert werden (ungeordnete Liste, geordnete Liste usw.). Im folgenden wird ein sogenannter Heap verwendet. Definition 3.1 [Heap] Ein Heap ist ein fast vollständiger binärer Baum mit den folgenden Zusatzeigenschaften. 1. Jeder Knoten im Baum besitzt eine Priorität und eventuell noch weitere Daten. 2. Die Priorität eines Knotens ist immer grösser oder gleich als die Priorität der Nachkommen. Diese Bedingung heisst Heapbedingung. Aus der definition kann sofort abgelesen werden, dass die Wurzel des Baumes die höchste Priorität besitzt. Weil der Heap ein fast vollständiger binärer Baum ist, lässt er sich einfach als Array implementieren. Wir numerieren die Knoten des Baumes von links nach rechts und von oben nach 3-6 unten. Die so erhaltene Nummerierung ergibt für jeden Knoten seinen Index im Array (siehe Abbildung 3-1). 1 2 4 8 30 3 60 5 50 9 65 49 1 10 37 2 3 6 48 12 11 4 5 6 12 7 8 45 47 7 18 8 9 10 11 12 65 60 47 50 48 45 18 30 49 37 12 8 Abbildung 3-1: Der Heap als Array Werden die Knoten wie in Abbildung 3-1 in den Array abgelegt, so gelten für alle Knoten i mit 1 ≤ i ≤ N die folgenden Regeln: 1. Der linke Nachfolger des Knotens i ist (falls vorhanden) der Knoten 2 · i. 2. Der rechte Nachfolger des Knotens i ist (falls vorhanden) der Knoten 2 · i + 1. 3. Der direkte Vorfahre eines Knotens i ist (falls vorhanden) der Knoten ⌊ 2i ⌋. Die drei wichtigsten Operationen insert, remove und build können nun folgendermassen implementiert werden: 3.3.2.1 insert Das Element wird als letztes Element im Array hinzugefügt. Der Array repräsentiert immer noch einen fast vollständigen binären Baum. Das neue Element verletzt aber eventuell die Heapbedingung. Um wieder einen Heap zu erhalten, vertauschen wir das neue Element solange mit seinen direkten Vorgängern, bis die Heapbedingung wieder erfüllt ist (siehe Abbildung 3-2). Dieses Verfahren wird upheap genannt. Die Methode verfolgt einen direkten Weg von einem Blatt zur Wurzel. Da der binäre Baum fast vollständig ist, hat ein solcher Weg höchstens die Länge log2 (n). Mit anderen Worten, wir brauchen höchstens log2 (n) Vertauschoperationen, um ein Element im Heap einzufügen. 3.3.2.2 remove Das erste Element im Array entspricht der Wurzel des Baumes. Da die Priorität der Wurzel maximal ist, entfernen wir dieses Element. Wir kopieren nun das letzte Element des Heaps an 3-7 65 55 60 50 30 47 48 49 37 12 8 18 45 55 Abbildung 3-2: Einfügen im Heap die Stelle der Wurzel im Array. Wieder erhalten wir einen fast vollständigen binären Baum. Die Wurzel verletzt aber eventuell die Heapbedingung. Die Wurzel wird nun mit dem grösseren ihrer beiden direkten Nachfolger vertauscht. Wir fahren so fort, bis die Heapbedingung nicht mehr verletzt ist (siehe Abbildung 3-3). Dieses Verfahren wird downheap genannt. Wie beim Einfügen, brauchen wir höchstens log2 (n)) Vertauschoperationen, um ein Element aus dem Heap zu löschen. 60 65 55 50 49 30 47 48 45 37 12 8 18 45 Abbildung 3-3: Löschen im Heap 3.3.2.3 build Als erstes wollen wir sehen, wie wir aus einem beliebigen Array von Elementen einen Heap konstruieren können. Der Array kann als fast vollständiger binärer Baum aufgefasst werden. Es bleibt also nur die Aufgabe die Heapbedingung für alle Knoten zu erzwingen. Man beachte, dass jedes Element des Arrays als Wurzel eines Heaps betrachtet werden kann. Wir beginnen nun zuhinterst im Array mit dem Aufbau der kleinsten Heaps und gehen so rückwärts bis zur Wurzel des ganzen Heaps. Die Heapbedingung kann dann für jede Wurzel mit Hilfe des Downheap-Verfahrens erzwungen werden. Man beachte ferner, dass alle Elemente des Arrays mit Index grösser als n2 Heaps mit nur einem Element darstellen und daher die Heapbedingung für diese trivialerweise erfüllt ist. Aus diesem Grund kann der Aufbau der Heaps beim Index n2 begonnen werden. Die Konstruktion des Heaps ist in der Abbildung 3-4 dargestellt. 3-8 1 2 3 4 5 12 50 37 65 8 8 4 8 60 9 50 8 9 48 11 9 49 10 8 6 11 30 12 12 8 10 30 11 12 6 48 8 1 2 3 11 4 5 6 18 7 18 37 3 10 7 65 5 49 18 47 45 60 50 9 7 47 45 6 48 49 45 3 5 9 12 65 60 2 8 30 48 1 4 10 11 12 5 2 8 7 10 65 60 4 49 6 47 18 60 49 48 30 45 30 7 12 8 9 45 47 37 10 11 12 65 60 47 50 48 45 18 12 49 8 30 37 Abbildung 3-4: Konstruktion des Heaps Der Aufbau des Heaps mit dieser Prozedur erfolgt in linearer Zeit. Um dies zu sehen, betrachten wir die Anzahl Vertauschungen (Vergleiche sind dann doppelt so viele) die notwendig sind um den Heap aufzubauen. Bei 127 Elementen wird das Downheap-Verfahren für 32 Heaps der Tiefe 1, 16 Heaps der Tiefe 2, 8 Heaps der Tiefe 3, 4 Heaps der Tiefe 4, 2 Heaps der Tiefe 5 und einem Heap der Tiefe 6 angewendet. Dies ergibt also maximal 32 · 1 + 16 · 2 + 8 · 3 + 4 · 4 + 2 · 5 + 1 · 6 = 120 Vertauschungen. Für n = 2k Elemente kann eine obere Schranke gefunden werden: V ertauschungen = k X (i − 1) · 2k−i = 2k − k − 1 < n i=1 Wir haben also gezeigt, dass das Verfahren eine lineare Komplexität besitzt. 3-9 3.3.3 Das Sortierverfahren (Heapsort) Wir sind nun in der Lage für einen Array den Heapsort zu formulieren. 1. Heap aufbauen: Mit Hilfe der vorher erklärten Prozedur wird der Array in einen Heap umgewandelt. 2. Array sortieren: Wir wissen nun, dass das grösste Element immer im ersten Element des Arrays gespeichert ist. Dieses Element vertauschen wir mit dem letzten Element des Arrays und wenden das Downheap-Verfahren auf die Elemente 1 . . . n − 1 des Arrays an. Nun ist das zweitgrösste Element an erster Stelle im Array und wir können mit dem Heap der Grösse n − 1 gleich weiterfahren. Da wir hier das Downheap-Verfahren für jedes Element anwenden ist die Komplexität dieser Prozedur O(n · log2 (n)). Da der Aufbau des Heaps in linearer Zeit möglich ist und das anschliessende sortieren eine Komplexität von O(n · log2 (n)) hat können wir sagen: T (Heapsort) = O(n + n · log2 (n)) = O(n · log2 (n) Dies gilt sowohl im Durchschnitt wie auch im schlechtesten Fall. 3.4 Quicksort Quicksort wurde 1960 von C.A.R. Hoare entwickelt und gilt heute als der Standard für internes Sortieren. Quicksort ist ein sogenannter Divide-And-Conquer Algorithmus. Was das heisst, ist im nächsten Abschnitt beschieben. 3.4.1 Divide-And-Conquer Methode Die Divide-And-Conquer Methode (kurz: DAC) zerlegt das zu lösende Problem in kleinere Teilprobleme, (divide) bis die Lösung der einzelnen Teilprobleme trivial ist. Anschliessend werden die Teillösungen zur Gesamtlösung vereinigt (conquer). Da das Problem immer wieder in kleinere Teilprobleme zerlegt wird und diese ihrerseits auch wieder zerlegt werden, ergibt sich sehr oft ein Lösungsansatz mit Rekursion. Ein DAC-Algorithmus hat also folgende allgemeine Form: void DAC(problem P) { if (Lösung von P trivial) return Lösung else { divide(P, Teil1 , . . . , Teiln ); return conquer(DAC(Teil1 ), . . . , DAC(Teiln )); } } 3-10 DAC-Algorithmen können in eine der beiden folgenden Kategorien eingereiht werden. • Das Aufteilen in Teilprobleme (divide) ist einfach, dafür ist das Zusammensetzen der Teillösungen (conquer) schwierig. • das Aufteilen in Teilprobleme (divide) ist schwierig, dafür ist das Zusammensetzen der Teillösungen (conquer) einfach. wenn sowohl das Aufteilen in Teilprobleme wie das Zusammensetzen der Teillösungen schwierig sind, so lohnt sich die DAC-Methode nicht. 3.4.2 Das Sortierverfahren Gegeben sei ein Array von Elementen a[left],. . . ,a[right] und eine totale Ordnungsfunktion ≤ auf diesen Elementen. left und right sind die Grenzen des zu sortierenden Unterarray. Der Quicksort Algorithmus hat den folgenden allgemeinen Aufbau: • Umordnen der Elemente a[left],. . . ,a[right], so dass die folgenden Bedingungen erfüllt sind. ∃pivot: left≤pivot≤right, so dass a[j]≤a[pivot] ∀j: j<pivot und a[j]≥a[pivot] ∀j: j>pivot • Das Element a[pivot] ist offensichtlich am richtigen Platz im Array. • Der Vorgang kann nun rekursiv auf die beiden Teilarrays a[left. . . pivot - 1] und a[pivot + 1. . . right] angewendet werden. Der wesentliche Teil des Algorithmus ist also die Prozedur Partition, die nachfolgend in der Sprache Java angegeben ist. Algorithmus 3.4 [Partitionierung] public static int partitionArray(int a[], int left, int right) { int pivot; int lindex; int rindex; int tmp; // Wir wollen das Element a[right] an die richtige // Stelle bringen. Diese Wahl ist willkuerlich. Wir // koennten ebensogut jedes andere Element waehlen. pivot = a[right]; lindex = left; rindex = right - 1; while(lindex <= rindex) { // Suchen ab dem Element a[left] aufwaerts, bis wir // ein Element a[lindex] >= pivot finden. while(lindex <= rindex && a[lindex] <= pivot) { 3-11 ++lindex; } // Suchen ab dem Element a[right - 1] abwaerts, bis wir // ein Element a[rindex] <= pivot finden. while(rindex >= lindex && a[rindex] > pivot) { --rindex; } // Die Elemente a[lindex] und a[rindex] sind offensichtlich // nicht am richtigen Platz, also werden sie vertauscht. if (lindex < rindex) { temp = a[lindex]; a[lindex] = a[rindex]; a[rindex] = temp; } } // Nun wird der pivot an die richtige Stelle gebracht. if (lindex < right) { temp = a[lindex]; a[lindex] = a[right]; a[right] = temp; } // Index der Partitionierung return lindex; } In der Abbildung 3-5 wird die Prozedur partition an einem Zahlenbeispiel verdeutlicht. 55 55 22 22 22 22 22 22 22 22 22 20 17 33 77 20 20 20 20 20 20 20 20 17 17 17 17 17 17 17 17 33 33 33 33 33 33 33 33 77 7 7 7 7 7 7 7 22 22 22 22 22 22 22 88 88 2 2 2 2 2 12 12 12 12 12 99 33 99 33 33 33 33 99 99 43 2 7 44 2 88 88 88 88 88 7 77 77 77 77 77 77 77 44 44 44 44 44 44 44 44 22 22 55 55 55 55 55 55 55 55 55 43 43 43 43 43 43 43 43 43 43 99 a[right]=43 suchen i,j 55 ↔ 22 suchen i,j 77 ↔ 7 suchen i,j 88 ↔ 2 suchen i,j 99 ↔ 33 i≥j 99 ↔ 43 Abbildung 3-5: Partitionierungsprozess von Quicksort Mit Hilfe der Prozedur partition kann nun Quicksort folgendermassen rekursiv implementiert werden. Algorithmus 3.5 [Quicksort rekursiv] public static int quickRekursiv(int a[], int left, int right) { int partitionElement; // Falls der Teilarray nur aus einem Element besteht gibt 3-12 // es nichts zu tun. if (left < right) { // Partitionieren partitionElement = partitionArray(a, left, right); // Linker Teil behandeln quickRekursiv(a, left, partitionElement - 1); // Rechter Teil behandeln quickRekursiv(a, partitionElement + 1, right); } } Die Prozedur quicksort in dieser Form hat einen gewaltigen Nachteil. Sind die Elemente im Array a schon sortiert, so wird der Array durch die Prozedur partition in einem Array der Länge right - left und einem Array der Länge 0 unterteilt. Dies hat zwei Konsequenzen: 1. Für einen Array der Länge n kann die Prozedur quicksort bis zu n mal aufgerufen werden. Die Stacktiefe kann also im schlechtesten Fall gleich n werden, was bei einer grossen Anzahl von Elementen nicht akzeptabel ist. 2. Der Zeitaufwand für das Sortieren eines Arrays der Länge n kann im schlechtesten Fall ≈ n · n2 = O(n2 ) betragen. Um Quicksort zu verbessern, werden wir noch folgende Änderungen anbringen. Eliminieren der Rekursion Wir eliminieren in der Prozedur quicksort die Rekursion mit Hilfe eines Stacks. Dabei wird nach der Partitionierung immer der kleinere Teilarray weiterverarbeitet und die Grenzen des grösseren Teilarrays auf den Stack abgelegt. So wird garantiert, dass die Stacktiefe höchsten 2 · log2 (n) werden kann. Bessere Wahl des Pivots Der Zeitaufwand von Quicksort ist am schlechtesten, wenn die Partitionierung immer einen Teilarray der Länge n − 1 und einen Teilarray der Länge 0 liefert. Wird als Pivot immer das rechte Element des Arrays gewählt, so tritt der schlechteste Fall (O(n2 )) auf, wenn die Elemente im Array schon sortiert sind. Dies ist unglücklich, da in der Praxis die Elemente sehr oft schon sortiert (oder fast sortiert) im Array abgelegt sind. Um diese Problem zu entschärfen kann man als Pivot das mittlere Element des Arrays annehmen. Falls die Elemente schon sortiert sind, so ist das mittlere Element sogar das beste. Auch mit dieser Wahl des Pivots lassen sich Verteilungen der Elemente finden, so dass Quicksort die Laufzeit O(n2 ) hat (siehe Übung ??). Im nächsten Abschnitt werden wir noch den randomized Quicksort kennen lernen, deren Laufzeit unabhängig von der Verteilung der Elemente im Array ist. Für den moment ändern wir die Prozedur partition nun folgendermassen. Am Anfang der Prozedur wird das mittlere Element des Arrays mit dem rechten Element vertauscht. mid = (left + right) / 2; temp = a[mid]; a[mid] = a[right]; a[right] = temp Mit dieser Änderung in der Prozedur partition ist die Wahrscheinlichkeit, dass der schlechteste Fall auftritt schon viel kleiner. 3-13 3.4.2.1 definitive Version von Quicksort Nachstehend ist die definitive Version der Prozedur quicksort angegeben. Die verwendete Prozedur einfuegen sortiert den gewünschten Teil des Arrays durch Einfügen (siehe 3.2). Algorithmus 3.6 [Quicksort iterativ] public static void quickSort(int a[]) { int stack[] = new int[200]; int sp = -1; int mid; int le; int ri; stack[++sp] = 0; stack[++sp] = a.length - 1; while (sp >= 0) { ri = stack[sp--]; le = stack[sp--]; while ((ri - le) > 0) { mid = partitionArray(a, le, ri); // sortieren des kleineren Teils if ((mid - le) > (ri - mid)) { // Grenzen des groesseren Teils auf Stack stack[++sp] = le; stack[++sp] = mid - 1; // Weiterfahren mit kleinerem Teil le = mid + 1; } else { // Grenzen des groesseren Teils auf Stack stack[++sp] = mid + 1; stack[++sp] = ri; // Weiterfahren mit kleinerem Teil ri = mid - 1; } } } } 3.4.2.2 Komplexität von Quicksort Wir wollen nun die Anzahl Vergleiche berechnen, die Quicksort im besten Fall benötigt, um die Elemente eines Arrays der Länge n zu sortieren. Der beste Fall liegt vor, wenn der Array durch die Prozedur partition in zwei gleich grosse Arrays geteilt wird. Der Einfachheit halber nehmen wir an, dass n die Form n = 2k − 1 hat. Wir nehmen ferner an, dass die Partitionierung den Array immer in zwei gleich grosse Teilarrays unterteilt. In diesem Fall ergibt sich die folgende Rekursionsformel für die Anzahl Vergleiche: V (k) = 0 für k = 0 V (k) = 2k − 2 + 2 · V (k − 1) für k > 0 3-14 Die Prozedur partition benötigt n − 1 Vergleiche. Zum Sortieren eines Teilarrays werden V (k − 1) Vergleiche verwendet. Die obige Rekursionsformel kann nun folgendermassen aufgelöst werden: V (k) = 2k − 2 + 2 · V (k − 1) = 2k − 2 + 2 · (2k−1 − 2) + 4 · V (k − 2) = 2k − 2 + 2k − 4 + 2k − 8 + 8 · V (k − 3) P k−1 k i V (k) = i=1 (2 − 2 )P k−1 i = (k − 1) · 2k − i=1 2 k k = (k − 1) · 2 − 2 − 2 = ⌊log2 (n)⌋(n + 1) − n + 1 Im besten Fall ist also die Komplexität von Quicksort O(n · log2 (n)). Im nächsten Abschnitt werden wir zeigen, dass die durchschnittliche Komplexität O(n log(n)) beträgt (log(n) ist hier nicht der Zweierlogarithmus). 3.4.2.3 Randomized Quicksort Die sicherste Methode um den Pivot auszuwählen ist mit einer Randomfunktion. In diesem Fall ist die Wahrscheinlichkeit, dass der schlechteste Fall eintrifft bei einer beliebigen Verteilung der Elemente im Array sehr klein. Wir müssen die Prozedur Partition dann folgendermassen ändern: ran = new Random(); . . randele = ran.nextInt(rigth - left + 1) + left; temp = a[randele]; a[randele] = a[right]; a[right] = temp Wir wollen nun die Komplexität des Algorithmus bestimmen, wenn wir den Pivot zufällig auswählen. Satz 3.1 [Laufzeit Quicksort] Die erwartete Laufzeit des randomized Quicksort einer Sequenz der Länge n ist O(n log(n)), falls zwei Elemente von S in der Zeit O(1) verglichen werden können. Beweis: Wir verwenden zum Beweis eine einfache Tatsache der Wahrscheinlichkeitsrechnung: Satz 3.2 [Erwartungswert] Der Erwartungswert der Anzahl Würfe um mit einer Münze k mal Kopf zu erreichen ist 2k Wir sagen im folgenden, dass die Partitionierung einer Sequenz von m Elementen bei Quicksort “gut” ist, wenn die zwei entstehenden Subsequenzen nicht kleiner al m 4 und nicht grösser als 3m m sind. Es existieren in S Pivots, die eine “gute” Partition erzeugen. Das heisst, die 4 2 1 Wahrscheinlichkeit, dass eine Partitionierung “gut” ist beträgt 2 . In der Abbildung 3-6 ist der Baum, der durch wiederholte Partitionierung bei Quicksort entsteht abgebildet. Falls ein Knoten r (mit Länge s(r)) im Baum “gut” partitioniert wird, so haben die Kinder von r höchstens die Länge s(r) 4 . Falls wir nun irgend einen Weg T von der 3 Wurzel zu einem Blatt des Baumes betrachten, dann ist die Länge von T höchstens so lang 3-15 bis wir log4/3 (n) “gute” Partitionierungen erreicht haben. Nach Lemma 3.2 also höchstens 2 log4/3 (n). Das heisst, dass die Länge von jedem Pfad T von der Wurzel zu einem Blatt höchstens O(log(n)) ist. Für jede Stufe des Baumes ist der Aufwand für die Partitionierung aber O(n). Die erwartete Laufzeit von Quicksort ist also O(n log(n)). Man kann ferner zeigen, dass Quicksort mit hoher Wahrscheinlichkeit in der Zeit O(n log(n)) abläuft. Diese Wahrscheinlichkeit beträgt 1 − n12 . Zeit pro Stufe Erwartete Höhe s(r) O(n) s(a) O(log(n)) s(c) s(b) s(d) s(e) O(n) s(f) Total erwartete Zeit: O(n) O(nlog(n)) Abbildung 3-6: Komplexitätsanalyse von randomized Quicksort 3.5 Externes Sortieren Unter externem Sortieren versteht man das Sortieren von grossen Dateien auf einem Sekundärspeicher. Im Gegensatz zum internen Sortieren kann nur noch sequentiell auf die Elemente zugegriffen werden. 3.5.1 Grundoperation Mischen Unter Mischen versteht man das Zusammenfügen von 2 bis n sortierten Files zu einem sortierten File. Das folgende Verfahren bezeichnet man als “n-Weg-Mischen”: 1. Lese das erste Element der Files 1 bis n in den Elementen 1 bis n eines Arrays im Hauptspeicher ein und markiere diese Elemente als “besetzt”. 2. Solange noch Elemente mit “besetzt” markiert sind 3. Bestimme den Index m des kleinsten besetzten Elements im Array und schreibe dieses Element auf das Resultatfile. 4. Falls noch ein Element im File m vorhanden ist, so wird dieses Element eingelesen und im Element m des Arrays gespeichert, sonst wird das Element m des Arrays mit “nicht besetzt” markiert. 5. Fahre bei Punkt 2 fort. Beispiel 3.1 [Mischen] Wir wollen als Beispiel ein 2-Weg Mischen betrachten: 3-16 Ausgangslage File 1: 78 File 2: 129 Resultatfile: 3 1.Schritt File 1: 78 File 2: 29 Resultatfile: 1 3.Schritt File 1: 8 File 2: 9 Resultatfile: 1 2 7 4.Schritt File 1: 3 File 2: 9 Resultatfile: 1 2 7 8 3.5.2 2.Schritt File 1: 78 File 2: 9 Resultatfile: 1 2 5.Schritt File 1: 3 File 2: 3 Resultatfile: 1 2 7 8 9 Ausgeglichenes Mehrweg-Mischen Mit diesem Algorithmus wird eine beliebige Datei mit Hilfe der Mischoperation sortiert. Der Algorithmus kann folgendermassen beschrieben werden: 1. Zerlege die Datei in Blöcke, die ungefähr die Grössse des Hauptspeichers haben und sortiere diese Blöcke. Die Sortierten Blöcke werden auf p Files gleichmässig verteilt (Sortier-Durchlauf). 2. Mische die ersten Blöcke der p Files zu einem grösseren (sortierten) Block. Wiederhohle diese Operation für alle Blöcke in den p Files. Die doppelt so grossen Blöcke werden wieder auf p Files gleichmässig verteilt (Misch-Durchgang). 3. Wiederhole Punkt 2, bis nur noch ein Block vorhanden ist. Beispiel 3.2 [Ausgeglichenes Mischen] Gegeben sei ein File mit den folgenden Elementen: 11 34 12 55 17 12 53 19 95 13 17 23 25 11 20 30 73 44 61 26 84 87 19 52 15 Wir nehmen an, dass gleichzeitig 3 Elemente sortiert werden können und dass 6 Files zur Verfügung stehen. Sortierdurchlauf File 1: 11 12 34 3 13 17 23 3 26 61 84 3 File 2: 12 17 55 3 11 20 25 3 19 52 87 3 File 3: 19 53 95 3 30 44 73 3 15 3 File 4: 3 File 5: 3 File 6: 3 1. Mischdurchlauf File 1: 3 File 2: 3 File 3: 3 File 4: 11 12 12 17 19 34 53 55 953 File 5: 11 13 17 20 23 25 30 44 733 File 6: 15 19 26 52 61 84 873 2. Mischdurchlauf File 1: 11 11 12 12 13 15 17 17 19 19 20 23 25 26 30 34 44 52 53 55 61 73 84 87 95 3 File 2: 3 File 3: 3 File 4: 3 File 5: 3 File 6: 3 3-17 Wir wollen noch die Komplexität des Algorithmus betrachten. Da die Operationen auf einem Sekundärspeicher (externe Operationen) viel aufwendiger sind als Operationen im Hauptspeicher, betrachten wir nur die externen Operationen. Wir stellen fest, dass bei jedem Durchlauf (Sortierdurchlauf und Mischdurchläufe) alle Elemente des Ursprünglichen Files einmal gelesen und einmal geschrieben werden. Die wichtige Grösse für die Komplexität ist also die Anzahl der benötigten Durchläufe. Wir nehmen an, dass wir N Elemente haben und im Sortierdurchlauf Blöcke der Länge M N Blöcke. Wenn wir nun 2 · p Files zur entstehen. Nach dem Sortierdurchlauf haben wir also M N Verfügung haben, so beträgt die Anzahl der Mischdurchläufe logp ( M ) (bei jedem Durchlauf wird die Anzahl der Blöcke um den Faktor p verringert). AnzahlDurchäuf e = 1 + logp ( 3.5.3 N ) M Replacement Selection Dieses Verfahren ist sehr ähnlich wie das ausgeglichene Mehrweg-Mischen. Der Unterschied liegt im Sortier-Durchlauf. Wir verwenden dazu einen Heap (siehe Abschnitt 3.3). Die Prozeduren werden so angepasst, dass zuerst immer das kleinste Element ausgegeben wird. Ferner wird noch eine Methode replace eingeführt, die das kleinste Element des Heaps mit einem neuen Element austauscht (ausser wenn das neue Element kleiner ist) Wir verwenden nun einen Heap der Grösse n (n soll möglichst gross sein) um die sortierten Blöcke im ersten Durchlauf zu erzeugen. Mit der nachfolgend erklärten Methode können so Blöcke erzeugt werden die grösser sind als n. 1. Lese n Elemente aus dem File. Die Elemente werden als zum Block 0 gehörend markiert und im Heap eingefügt. Die Heap-Routinen sind so modifiziert, dass die Priorität der Elemente sich aus Blocknummer und Wert des Elements zusammensetzt. 2. Lesen des nächsten Elements. Falls das Element kleiner als das zuletzt geschriebene Element ist, so wird es mit der Nummer des nächsten Blocks markiert, sonst mit der Nummer des aktuellen Blocks. 3. Mit der Methode replace wird nun das neue Element mit dem kleinsten Element des Heaps vertauscht. 4. Ist das kleinste Element mit einer grösseren Blocknummer markiert als die aktuelle Blocknummer, so wird der aktuelle Block abgeschlossen und ein Block mit der neuen Blocknummer begonnen. 5. Schreibe das Element auf den aktuellen Outputfile. 6. Wiederhole die Punkte 2 bis 5 bis keine Elemente mehr im Input-File vorhanden sind. Beispiel 3.3 [Replacement Selection] Um das ganze zu illustrieren wollen wir mit einem Heap der Grösse 3 und zwei Outputfiles arbeiten. Input: 7 3 9 2 10 8 1 11 4 7 Der Vorgang ist in der Abbildung 3-7 dargestellt. 3-18 Input−Sequenz: 7 3 9 2 10 8 1 11 4 7 0,3 0,3 0,7 0,9 0,7 0,9 0,10 1,1 0: 2 3 7 8 0,7 0,9 0,10 0,9 0: 2 0: 2 3 0,10 0,11 0,11 1,4 1,1 1,7 1: 1 1: 1 4 0,10 0,9 0: 2 3 7 1,1 1,1 0: 2 3 7 8 9 10 0: 2 3 7 8 9 1,4 0,8 1,4 1,7 0: 2 3 7 8 9 10 11 1,7 1: 1 4 7 Abbildung 3-7: Funktionsweise von Replacement Selection In diesem Beispiel erzeugen wir mit einem Heap der Grösse 3 einen Block der Grösse 7 und einen Block der Grösse 3. Wir sehen also, dass die Blöcke grösser als der Heap werden können. Der Vorteil dieser Methode ist, dass die sortierten Blöcke grösser werden als die Grösse des verwendeten Heaps damit wird die Zahl der Mischdurchgänge verringert. Für zufällige Schlüssel besitzen die Blöcke, die durch Replacement Selection erzeugt werden, ungefähr die doppelte Grösse des verwendeten Heaps. Aus dieser Tatsache können wir die nötige Anzahl Durchläufe berechnen. Falls der File N Schlüssel enthält, der Hauptspeicher M Schlüssel aufnehmen kann und 2·p Files zur Verfügung stehen gilt: N AnzahlDurchäuf e = 1 + logp ( ) 2·M 3.5.4 Mehrphasen-Mischen Ein Problem beim ausgeglichnenen Mehrweg-Mischen ist die hohe Anzahl verwendeter Files (dies ist ein Problem, wenn Bandstationen verwendet werden). Aus diesem Grund wurde das Mehrphasen-Mischen entwickelt. 1. Beim Sortier-Durchlauf werden die sortierten Blöcke ungleichmässig auf die Bänder verteilt, wobei ein Band leer bleibt. 2. Die Bänder werden nun auf das leere Band gemischt, bis ein anderes Band am Ende angelangt ist. Dieses Band wid zurückgespult und wird zum neuen Output-Band. 3. Das Band auf dem gemischt wurde wird zurückgespult und wird zu einem Input-Band. 4. Die Punkte 2 und 3 werden wiederholt bis nur noch ein Block existiert. 3-19 Das Problem bei diesem Algorithmus ist, die richtige Anfangsverteilung der Blöcke zu finden. Das folgende Beispiel soll dies illustrieren. Beispiel 3.4 [Mehrphasen-Mischen] Nach dem Sortierdurchgang entstehen 193 Blöcke und wir verwenden 4 Bänder. Die nachfolgende Tabelle zeigt, wie diese Blöcke am Anfang verteilt sein müssen, damit das Ganze aufgeht. Band Band Band Band 1: 2: 3: 4: 81 68 44 0 37 24 0 44 13 0 24 20 0 13 11 7 7 6 4 0 3 2 0 4 1 0 2 2 0 1 1 1 1 0 0 0 Die Frage ist nun, wie die Anfangsverteilung gewählt werden muss. Es ist einfach die Werte der obigen Tabelle zu erhalten, wenn man rückwärts vorgeht. Wenn eine Spalte gegeben ist, kann die vorhergehende Spalte folgendermassen berechnet werden: Man wähle die grösste Zahl in der Spalte, mache sie zu Null und addiere sie zu allen anderen Elementen in der Spalte. Diese Methode ist für eine beliebige Anzahl Bänder anwendbar. Die Schwierigkeit besteht darin, dass am Anfang nicht unbedingt bekannt ist, wieviele Blöcke entstehen. Ferner wird diese Anzahl wahrscheinlich nicht genau aufgehen. Daher müssen “Pseudoblöcke” hinzugefügt werden, damit die Zahl der Anfangsblöcke genau den gemäss der Tabelle benötigten Wert hat. Die durch die Tabelle erzeugten Zahlen sind “verallgemeinerte Fibonacci-Zahlen” (Bei drei Bändern entstehen die “normalen Fibonacci-Zahlen”). einen Array so, dass das Laufzeitverhalten dieses Algorithmus Θ(n2 ) wird? 3-20 Kapitel 4 Suchalgorithmen Suchen ist in der Informatik eine sehr häufige Aufgabe. Es geht darum, in einer gegebenen Menge von Elementen, Elemente nach einem bestimmten Merkmal (Schlüssel) zu finden. Neben dem Schlüssel sind in jedem Element noch weitere Informationen (oder Zeiger auf Informationen) gespeichert. 4.1 Suchstruktur In diesem Abschnitt untersuchen wir Suchalgorithmen, welche nach vollständigen Schlüssel suchen. Es ist dabei nicht möglich die Elemente in sortierter Reihenfolge zu lesen. Beispiel 4.1 [Anwendungen] Suchstrukturen können in den folgenden Anwendungen verwendet werden. Compiler Ein C-Compiler muss alle deklarierten Variablen und Funktionen kennen, damit getestet werden kann, ob Operationen auf diesen Objekten erlaubt sind oder nicht. Der Compiler baut eine Liste von Namen auf und speichert dazu Zusatzinformationen. Jedesmal wenn eine Variable oder Funktion im Code verwendet wird, muss diese über ihren Namen schnell in der Tabelle gefunden werden können. Datenbanken Das bekannteste Beispiel einer Suchstruktur sind sicher moderne Datenbanken. Meistens können dort Objekte nicht nur über einen Schlüssel gesucht werden, sondern über alle Merkmale des Objekts (Bsp. SQL). Die Suchstruktur kann als abstrakter Datentyp definiert werden. Der ADT muss mindestens die folgenden Dienstleistungen zur Verfügung stellen. • Einfügen eines neuen Elements mit den Daten ’data’ und dem Schlüssel ’key’. Ein Schlüssel darf nur einmal eingetragen werden. • Finden eines Elements, das den Schlüssel ’key’ besitzt und Lesen der dazugehörenden Daten. • Löschen eines mit ’key’ gefundenen Elements aus der Struktur. 4-1 4.1.1 Spezifikation der Suchstruktur in Java /** * Dieses Interface definiert die Schnittstelle zu einer * allgemeinen Suchstruktur ohne Ordnung der Elemente * (assoziative Menge). */ interface SuchStruct<Key, Info> { /** * Testet ob die Suchstruktur voll ist. * * <pre> * Preconditions: none * Postconditions: none * </pre> * * @return true falls kein platz mehr in der Struktur */ public boolean full(); /** * Gibt die Anzahl Elemente in der Suchstruktur * zur&uuml;ck. * * <pre> * Preconditions: none * Postconditions: none * </pre> * * @return Anzahl der Elemente in der Struktur */ public int cardinality(); /** * Sucht einen Schl&uuml;ssel in der SuchStruktur * * <pre> * Preconditions: none * Postconditions: none * </pre> * * @param key * dieser Schluessel wird gesucht * @return true falls der Schluessel gefunden wurde * */ public boolean search(Key key); /** 4-2 * Sucht einen Schl&uuml;ssel in der SuchStruktur * und gibt die assozierten Daten zur&uuml;ck. * * <pre> * Preconditions: old.search() = true; * Postconditions: none * </pre> * * @param key * dieser Schluessel wird gesucht * @return die zu diesem Schl&uuml;ssel * gespeicherte Information. * * @throws HTE.NotInTableException * Falls der Schl&uuml;ssel nicht existiert */ public Info getInfo(Key key) throws HTE.NotInTableException; /** * L&ouml;scht einen Schl&uuml;ssel aus der * Suchstruktur. * * <pre> * Preconditions: old.search(key) == true * Postconditions: new.search(key) == false * </pre> * * @throws HTE.NotInTableException * Falls der Schl&uuml;ssel nicht existiert */ public void remove(Key key) throws HTE.NotInTableException; /** * Einf&uuml;gen eines neuen Schl&uuml;ssels. * * <pre> * Preconditions: old.search(key) == false * old.full == false * Postconditions: new.search(key) == true * </pre> * * @param key * dieser Schluessel wird eingefuegt * @param data * Information zum Schluessel * @throws HTE.InTableException * Falls der Schl&uuml;ssel schon vorhanden * @throws HTE.TableFullException 4-3 * Falls die Struktur voll ist */ public void insert(Key key, Info data) throws HTE.InTableException, HTE.TableFullException; } 4.1.2 Implementation als Hashtabelle Die Idee vom Hashing ist in der Abbildung 4-1 dargestellt. Man versucht, durch eine arithmetische Transformation des Schlüssels direkt einen Index in einer Tabelle zu berechnen, in der die gesuchten Elemente gespeichert sind. Wir wollen nun das Problem genauer definieren und die wichtigsten Begriffe einführen. Hashtabelle Key Pointer auf Daten 0 1 Hashfunktion Keymenge 2 3 N−2 N−1 Hashadressen Abbildung 4-1: Hashing (Schlüsseltransformation) Menge der möglichen Schlüssel KeyM enge Die Art und Anzahl der möglichen Schlüssel ist von der Applikation abhängig. Die Anzahl Elemente in dieser Menge kann sehr gross werden. Nehmen wir als Beispiel die Menge aller Schlüssel, die genau aus 10 grossen Buchstaben bestehen, so sind das 2610 Schlüssel. Die Hashtabelle HashT able Dies ist eine Tabelle (Array), die eine vordefinierte Anzahl Schlüssel aufnehmen kann. Im Gegensatz zu Bäumen kann die Hashtabelle nicht dynamisch vergrössert werden. Die Hashfunktion Die Hashfunktion ist eine Abbildung: Hash : KeyM enge 7→ {0, 1, 2 . . . T abellenplätze − 1} 4-4 Die Funktion Hash bildet also einen Schlüssel k ∈ KeyM enge auf einen Index der Hashtabelle ab. Der Wert der Funktion Hash(k) heisst die Hashadresse des Schlüssels k. Kollisionen Wir haben schon gesehen, dass die Schlüsselmenge im allgemeinen sehr gross ist. Aus diesem Grund ist die Anzahl der Tabellenplätze im allgemeinen viel kleiner als die Anzahl der möglichen Schlüssel. Daraus folgt, dass die Hashfunktion nicht eineindeutig ist. D.h., verschiedene Schlüssel können auf dieselbe Hashadresse abgebildet werden. Formal ausgedrückt: ∃k1 , k2 ∈ KeyM enge : k1 6= k2 ∧ Hash(k1 ) = Hash(k2 ) Eine solche Situation wird als Kollision bezeichnet. Die Schlüssel k1 und k2 heissen Synonyme bezüglich der Funktion Hash. Beim Auftreten einer Kollision müssen trotzdem beide Schlüssel in die Tabelle eingetragen werden. Die Art und Weise wie dieses Problem gelöst wird, hat einen grossen Einfluss auf die Effizienz (und auf die Implementation) des Suchprozesses. 4.1.2.1 Die Wahl der Hashfunktion Die Hashfunktion muss leicht zu berechnen sein, die Zahl der Kollisionen minimieren und die Schlüssel möglichst gut auf die ganze Tabelle verteilen. Damit diese Forderungen erfüllt werden können, muss die Funktion den zwei folgenden Kriterien genügen: 1. Der Funktionswert muss von allen Zeichen des Schlüssels abhängig sein. Eine Funktion, die etwa nur vom ersten Zeichen des Schlüssels abhängt, wird viele Kollisionen verursachen. 2. Die Hashfunktion muss die Schlüssel gleichmässig über den gegebenen Indexbereich der Tabelle verteilen. D.h., wird k ∈ KeyM enge zufällig ausgewählt, so ist die Wahrscheinlichkeit, dass Hash(k) = i ist, gleich 1/T abellenplätze. Die Erfahrung hat gezeigt, dass die Wahl der Hashfunktion für die Effizienz der Suche viel weniger wichtig ist, als die Wahl der Strategie zur Behandlung von Kollisionen. Es genügt daher, eine Funktion zu wählen, die den oben genannten Kriterien genügt. Wir können die Evaluation der Hashfunktion in zwei Operationen aufteilen. Die erste Operation besteht darin, den Schlüssel auf eine Integerzahl abzubilden, den sogenannten Hashcode. Die zweite Operation besteht darin, den Hashcode auf einen Index abzubilden, der im Range unserer Hashtabelle liegt. Diese Operation heisst Kompression. Dieser Sachverhalt ist in der Abbildung 4-2 dargestellt. Als erstes müssen wir also eine Funktion finden, die die Schlüssel auf eine ganze Zahl abbildet (kann auch negativ sein). Wir möchten aber erreichen, dass bei der Berechnung des Hashcodes möglichst wenige Kollisionen auftreten. Summieren der Componenten Wir können schliesslich jeden Key als k-Tuple (x0 , x1 , . . . , xk−1 ) von Integers (oder von Bytes) auffassen. Nun können wir alle diese Zahlen Zusammenzählen und erhalten als Hashcode 4-5 Beliebige Schlüssel Hashcode −2 −1 0 1 2 Kompression 0 N−1 Abbildung 4-2: Die beiden Operationen der Hashfunktion k−1 X xi i=0 Diese Methode ist für Strings nicht sehr gut, da die Reihenfolge der Zeichen nicht berücksichtigt wird. Das heisst, die Strings temp01 und temp10 erhalten den gleichen Hashcode. In der Englischen sprache kollidieren alle folgenden Wörter stop, tops, pots und spot. Polynomialer Hashcode Ein besserer Hashcode erhält man, wenn wir die Reihenfolge der der xi ’s im k-Tuple (x0 , . . . , xk−1 ) berücksichtigen. Dies kann man erreichen, indem man eine Konstante a ≥ 1 wählt und den Hashcode folgendermassen berechnet: hc = x0 ak−1 + x1 ak−2 + · · · + xk−2 a + xk−1 Nach Horn’s Regel kann dies folgendermassen umgeschrieben werden: hc = xk−1 + a(xk−2 + a(xk−3 + · · · + a(x2 + a(x1 + ax0 ))...)) Mathematisch gesehen ist das die Auswertung eines Polynoms mit den Koeffizienten xk−1 . . . x0 an der Stelle a. Daher heisst dieser Code auch polynomialer Hashcode. Beispiel 4.2 [Berechnen der Hashfunktion] key = "HANS" a = 33 Wenn wir den ASCII-Code verwenden gilt nun (x0 , x1 , x2 , x3 ) = (72, 65, 78, 83). Mit diesen Angaben können wir nun den Hashcode für den Schlüssel "HANS" besrechnen: hc = 83 + 33(78 + 33(65 + 33 · 72)) = 2660906 Bemerkung 4.1 [Wahl der Basis] Die Frage ist nun, welchen Wert wir für a wählen sollen. Die Java-Methode hashCode() der Klasse String setzt a = 31. Im Buch von Goodrich und Tamassia [GT04] wird angegeben, dass sowohl 33, 37, 39 wie 41 eine sehr gute Wahl für a darstellen. In einem Experiment wurde der Hashcode von 50’000 verschiedenen Englischwörter berechnet. Mit der Wahl von a als 33, 37, 39 oder 41 gab es in allen Fällen weniger als 7 Kollisionen. 4-6 4.1.2.2 Die Kompressionsfunktion Wir müssen nun noch betrachten, wie der Hashcode auf die Menge der Indizes der Hashtabelle abgebildet wird. Im allgemeinen werden hier auch Kollisionen auftreten, da die Tabelle kleiner ist als das ganze Intervall des Hashcodes. Die Divisionsmethode Für die Kompression des Hashcodes (hc) können wir die folgende einfache Funktion anwenden h(hc) = |hc| mod N dies ist als Divisionsmethode bekannt. Bei der Divisionsmethode ist die Wahl der Tabellenlänge N kritisch. Eine wirklich gute Verteilung der Haschcodes auf das Intervall [0 . . . N − 1] erhält man nur, wenn N eine Primzahl ist. Falls N nicht prim ist, entsteht eine erhöte Wahrscheinlichkeit, dass sich Muster in der Verteilung der Zahlen ergeben und das führt zu einer erhöten Anzahl von Kollisionen. Beispiel 4.3 [Kollisionen] Die Tabellenlänge sei N = 100 und die Menge von Hashcodes sei {200, 205, 215, 220, . . . , 600}. In diesem Fall wird jeder Hashcode mit drei anderen Hashcodes kollidieren. Wählen wir hingegen N = 101 (eine Primzahl), so entsteht überhaupt keine Kollision. Auch wenn N eine Primzahl ist haben wir nicht die Garantie, dass die Verteilung immer gut ist. Falls wir beim Hashcode viele Zahlen der Form i · N + j für viele i’s haben, so gibt es immer noch viele Kollisionen. Die MAD-Methode MAD steht für multiply add and divide und ist eine etwas bessere Methode den Hashcode zu komprimieren. Mit dieser Methode werden sich wiederhohlende Muster in einer Menge von ganzen Zahlen besser behandelt. Für die Kompression verwenden wir die Funktion. h(hc) = |a · hc + b| mod N Dabei ist N eine Primzahl und a und b sind positive ganze Zahlen, die bei der Bestimmung der Funktion zufällig ausgewählt werden. Natürlich muss (a mod N 6= 0) gelten, da sonst der Wert der Funktion immer (b mod N ) ist. Diese Funktion verteilt die Werte einer Menge von Hashcodes fast gleichmässig auf das Intervall [0, N − 1]. 4.1.2.3 Behandlung von Kollisionen Beim Einfügen eines neuen Elements in die Hashtabelle kann es vorkommen, dass an der berechneten Hashadresse schon ein anderer Schlüssel gespeichert ist (Kollision). In diesem Fall muss der Schlüssel an einer anderen Stelle in der Tabelle eingetragen werden. Die Strategie zur Behandlung der Kollisionen bestimmt die Algorithmen für das Suchen, Einfügen und Löschen in der Tabelle. Nachfolgend sind zwei verschiedene Methoden beschrieben: 4-7 Separat chaining (direkte Verkettung) Bei dieser Methode wird in jedem Element der Hashtabelle neben dem Schlüssel ein Anker für eine verkettete Liste gespeichert. In der verketteten Liste werden alle Synonyme, des in der Tabelle gespeicherten Schlüssels abgelegt. Separat chaining ist in der Abbildung 4-3 dargestellt. Hash(Fritz) = Hash(Paul) = Hash(Hans) = Hash(Peter) Fritz Paul Gerda Sami Hans Peter Hash(Gerda) = Hash(Sami) Hash(Carmen) = Hash(Gina) = Hash(Herbert) Carmen Gina Herbert Abbildung 4-3: Hashing mit separat chaining Einfügen des Schlüssels k: Die Hashadresse Hash(k) wird berechnet. Ist der Tabellenplatz noch leer, so wird der Schlüssel dort eingetragen. Sonst wird der neue Schlüssel am Ende der verketteten Liste angehängt. Suchen des Schlüssels k: Die Hashadresse Hash(k) wird berechnet. Ist der Schlüssel k in diesem Tabellenplatz gespeichert, so ist die Suche erfolgreich beendet. Sonst wird k mit den Schlüsseln in der entsprechenden Liste verglichen, bis k gefunden wird oder das Ende der Liste erreicht ist. Löschen des Schlüssels k: Beim Löschen eines Schlüssel sind zwei Fälle möglich: 1. k ist in der verketetten Liste gespeichert. In diesem Fall wird dieses Element aus der Liste gelöscht. 2. k ist in der Hashtabelle gespeichert. In diesm Fall wird, falls vorhanden, das erste Element der verketteten Liste in die Tabelle kopiert und aus der verketteten Liste entfernt. double hashing Bei double hashing verwenden wir zwei verschiedene Hashfunktionen Hash1 und Hash2 . Hash1 dient dazu, die Hashadresse eines Schlüssels in der Tabelle zu Suchen. Hash2 dient dazu, bei einer Kollision, in der Tabelle den nächsten freien Platz zu suchen. Double hashing ist in der Abbildung 4-4 veranschaulicht. Einfügen des Schlüssels k: Die Hashadresse Hash1 (k) wird berechnet. Ist dieser Tabellenplatz noch leer, so wird der Schlüssel dort eingetragen. Ist der Tabellenplatz schon besetzt, so wird nacheinander bei den Adressen 4-8 Hash(Fritz) = Hash(Paul) = A0 A0 Fritz (A0 + 1 * Hash2(Paul) mod N Gerda (A0 + 2 * Hash2(Paul) mod N Paul Carmen Abbildung 4-4: Double hashing (Hash1 (k) + Hash2 (k)) mod N, (Hash1 (k) + 2 · Hash2 (k)) mod N, . . . gesucht, bis ein freier Platz gefunden wird (N = Länge der Tabelle). An dieser Stelle wird nun der Schlüssel eingetragen. Die Suche nach einem freien Platz wird abgebrochen, wenn (Hash1 (k)+n·Hash2 (k)) mod N = Hasch1 (k) gilt. In diesem Fall ist die Tabelle voll, und der Schlüssel kann nicht mehr eingetragen werden. Damit bei der Suche nach einem freien Platz alle Elemente der Tabelle durchsucht werden, muss Hash2 (k) für alle k relativ prim zu N sein (d.h. Hash2 (k) und N haben keine gemeinsamen Primfaktoren). Suchen des Schlüssels k: Die Hashadresse Hash1 (k) wird berechnet. Ist der Schlüssel k an dieser Adresse gespeichert, ist die Suche erfolgreich. Falls nicht, wird der Wert Hash2 (k) berechnet und der Schlüssel in den Tabellenplätzen (Hash1 (k) + Hash2 (k)) mod N, (Hash1 (k) + 2 · Hash2 (k)) mod N, . . . gesucht. Die Suche wird durch eine der drei folgenden Bedingungen abgebrochen: • Der Schlüssel wird gefunden. Die Suche kann erfolgreich abgeschlossen werden. • Ein leerer Tabellenplatz wird gefunden. Die Suche ist erfolglos. • Es gilt: (Hash1 (k) + n · Hash2 (k)) mod N = Hash1 (k)) Die Suche ist erfolglos. 4-9 Hash(Fritz) = Hash(Paul) = A0 A0 Fritz (A0 + 1 * Hash2(Paul) mod N Gerda (A0 + 2 * Hash2(Paul) mod N Gelöscht Weitersuchen Carmen (A0 + 3 * Hash2(Paul) mod N leer Platz leer Paul nicht gefunden Abbildung 4-5: Suchen mit double hashing Damit ein in der Tabelle eingetragener Schlüssel in jedem Fall gefunden wird, müssen wir folgendes beachten: Seien k1 6= k2 zwei Schlüssel in der Hashtabelle. k1 sei an der Adresse Hash1 (k2 ) und k2 an der Adresse (Hash1 (k2 )+Hash2 (k2 )) mod N ) eingetragen. Wird nun der Schlüssel k1 aus der Hashtabelle gelöscht, so wird k2 nicht mehr gefunden, weil der Tabellenplatz Hash1 (k2 ) jetzt leer ist. Um diese Situation zu vermeiden, muss jeder Tabelleneintrag einen Flag besitzen, der angibt, ob ein Eintrag leer, benutzt oder gelöscht ist. Die oben beschriebene Suche darf dann nur bei einem leeren Element abgebrochen werden. Diese Situation ist in der Abbildung 4-5 dargestellt. Löschen des Schlüssels k: Beim Löschen eines Schlüssels aus der Tabelle muss nur der Tabellenplatz mit gelöscht markiert werden. Zum Schluss wollen wir noch mögliche Funktionen Hash1 und Hash2 angeben. Die Funktion Hash1 wird wieder mit dem polynomialen Haschcode und der Divisionsmethode zur Kompression berechnet. Für die Funktion Hash2 wollen wir zwei Möglichkeiten betrachten: 1. Hash2 (k) = 1 ∀k ∈ KeyM enge In diesem Fall spricht man nicht mehr von double hashing sondern von linear probing (lineares Sondieren). Bei dieser Wahl von Hash2 tendieren die Schlüssel dazu, in der Tabelle in Bündeln aufzutreten. Die Verteilung der Schlüssel in der Tabelle ist nicht gleichmässig. Zu grosse Bündel erhöhen die Suchzeit eines Schlüssels wesentlich. 2. Hash2 (k) = (N − 2) − (|poly(k)| mod (N − 2)) Dabei bedeutet poly(k) die Berechnung des polynomialen Hashcodes von k. Diese Funktion hat sich in der Praxis bewährt. Falls die Tabellenlänge N eine Primzahl ist, sind 4-10 Hash2 (k) und N für alle k relativ prim zueinander (Hash2 (k) < N ). Die Idee ist die folgende: Falls k1 und k2 bezüglich Hash1 Synonyme sind, so gilt im allgemeinen Hash2 (k1 ) 6= Hash2 (k2 ) d.h., bezüglich Hash2 sind die Schlüssel keine Synonyme. Dies verhindert die Bildung von Bündeln wie sie beim linearen Sondieren auftreten. Vergleich der Methoden Am Ende wollen wir die drei Methoden separat chaining, linear probing und double hashing bezüglich ihrer Effizienz vergleichen. Die Anzahl Vergleiche, die nötig sind um einen Schlüssel zu suchen, sind vom Loadfactor α der Tabelle abhängig. Ist N die Grösse der Tabelle und M die Anzahl gespeicherter Schlüssel, so definieren wir: α = M/N Bei linear probing und double hashing gilt immer α ≤ 1 für separat chaining kann α auch grösser als 1 werden. In der nächsten Tabelle sind die durchnittlichen Anzahl Vergleiche für die drei Methoden angegeben. Wir unterscheiden noch zwischen erfolgreichem und erfolglosem Suchen eines Elements. Methode Separat chaining Linear probing Erfolglose Suche 1+α 1 1 2 (1 + (1−α)2 ) f alls α 6= 1 Erfolgreiche Suche 1 + α2 1 1 2 (1 + (1−α) ) f alls α 6= 1 Double hashing 1 1−α f alls α 6= 1 − ln(1−α) α f alls α 6= 1 Beispiel 4.4 [Aufwand beim Suchen] Eine Hashtabelle sei zu 90% gefüllt d.h. α = 0.9. Berechnen wir die nötigen Anzahl Vergleiche für eine erfolglose Suche nach obigen Formeln erhalten wir: Separat chaining Linear probing Double hashing 1.9 50.5 10.0 Ganz klar werden linear probing und double hashing für α ≈ 1 immer schlechter. In solchen Fällen muss die ganze Hashtabelle vergrössert und dann neu aufgebaut werden. 4.1.3 Universelles Hashing In diesem Abschnitt wollen wir noch Familien von universellen Hashfunktionen betrachten. Im folgenden setzten wir immer voraus, dass die Kollisionsbehndlung mittels separat chaining geschieht. Für ein gutes Hashing sind die folgenden Eigenschaften wichtig: 1. Die Schlüssel sollen gut (regelmässig) in der Hashtabelle verteilt sein, damit möglichst wenig Kollisionen entstehen. 2. Falls in einer Tabelle n Schlüssel gespeichert werden, sollte die Tabellengrösse N = O(n) sein, damit nicht zu viel Platz verlorengeht. 3. Die Hashfunktion ist in Zeit O(1) berechenbar. 4-11 Mit den obigen Voraussetzungen ist die Komplexität der Operationen (Suchen, Einfügen, Löschen) eines Element x gleich O(Länge der Liste beih(x)). Wir wollen also analysieren, wie gross die Listen werden. Intuition: Eine gute Verteilung der Schlüssel in der Tabelle wird mit Hilfe eines Randomgenerators erreicht. Dieser Ansatz ist aber nicht möglich, da wir dann die Schlüssel nie mehr finden könnten. Also muss man versuchen die Hashfunktion h so zu wählen, dass diese “pseudorandom” ist. Sei U die Menge der möglichen Schlüssel, M die Anzahl Schlüssel in der Tabelle und N die Grösse der Tabelle, dann gilt der folgende Satz: Satz 4.1 [Kollisionen] Für eine beliebige Hashfunktion h gilt: Falls |U | ≥ (M − 1)N + 1 ist, so existiert eine Menge S ⊂ U mit |S| = M , dessen Elemente alle dieselbe Hashadresse besitzen. Aus diesem Grund erscheint Hashing oft misteriös. Wie kann man behaupten Hashing sei gut, wenn jede Hashfunktion durch eine geschikte Wahl der Schlüssel “überlistet” werden kann? Um dieses Problem zu lösen und zu garantieren, dass das Hashing für alle Schlüsselmengen S gut funktioniert benutzen wir die Wahrscheinlichkeit in analogie zu randomized quicksort (siehe 3.4.2.3). Wir werden Randomnumber in der Konstruktion der Hashfunktion h verwenden (h wird dabei natürlich eine deterministische Funktion sein). Wir werden zeigen, dass für jede Menge S von Schlüsseln und einer mit Randomnumbers konstruierten Funktion h der Erwartungswert des Resultats gut ist. Dies ist die Idee vom universellen Hashing. 4.1.3.1 Universelle Familie von Hashfunktionen Nun das ganze etwas formaler. Als erstes nehmen wir ohne Einschränkung der Allgemeinheit, dass die Schlüssel ganze Zahlen im Bereich U = [0, K − 1] sind. Die entsprechenden Hashfunktionen sind dann Funktionen der Form h : [0, K − 1] → [0, N − 1]. Definition 4.1 [Universelle Hashfunktion] Eine Menge H von Hashfunktionen der Form h : [0, K − 1] → [0, N − 1] heisst universell (oder universelle Familie von Funktionen), falls für alle j, k ∈ [0, K − 1] mit j 6= k und für eine zufällig aus H gewählte Funktion gilt: 1 P r(h(j) = h(k)) ≤ N P r steht für “Probability”. Satz 4.2 [Erwartungswert von Kollisionen] Falls H eine universelle Familie von Hashfunktionen ist, so gilt für jede Teilmenge S ⊆ U mit |S| = M , jedes Element x ∈ U und einer zufällig aus H ausgewählten Funktion h, dass der Erwartungswert einer Kollision von x mit anderen Elementen aus S höchstens M N. Beweis: • Sei Cxy = 1 falls x und y kollidieren sonst 0. P • Cx sei die Summe aller Kollisionen von x also gilt Cx = y∈S,x6=y Cxy . • Weil H universell ist gilt, dass E[Cxy ] = P r(x und y kollidieren) ≤ N1 P • Die Linearität des Erwartungswertes ergibt: E[Cx ] = y∈S,x6=y E[Cxy ] ≤ M N Aus Satz 4.2 folgt sofort, dass das Suchen eines Schlüssels O(1 + M N ) Zeit braucht. Dies weil der Aufwand für das Suchen eines Schlüssels proportional zur Anzahl Kollisionen ist plus die konstante Zeit für die Berechnung von h. Das Suchen eines Schlüssels ist also proportional zum Loadfactor α = M N und somit also konstant für M = N . 4-12 4.1.3.2 Konstruktion einer Familie von Hashfunktionen Wir müssen noch zeigen, wir eine universelle Familie von Hashfunktionen konstruieren können. Wir verwenden dazu das Matrixverfahren Wir nehmen an, dass die Länge der Keys u−Bits ist und dass die Länge N der Hashtabelle eine Potenz von zwei ist es gilt also N = 2b . Wir wählen die Funktion h als eine zufällige u × b Matrix von 0 und 1. Dann ist h(x) = h~x. Dabei werden die Additionen modulo zwei durchgeführt. Die Menge H wird von allen u × b (0/1) Matritzen gebildet. Beispiel 4.5 [Hashfunktion] 1 0 0 0 0 1 1 1 1 1 1 0 1 0 1 0 1 = 1 0 Satz 4.3 [Universell] Für h ∈ H und x, y ∈ U mit x 6= y gilt: 1 1 = b N 2 Anders ausgedrückt: H ist eine universelle Familie von Hashfunktionen. P r[h(x) = h(y)] = Beweis: Die Multiplikation h~x kann auch berechnet werden indem alle Kolonnen von h, für die die entsprechende Komponente in ~x 1 ist modulo 2 addiert werden. Im Beispiel 4.5 werden also die Kolonnen 1 un 3 addiert. Wir betrachten nun x, y ∈ U mit x 6= y. i sei die erste Komponente wo x un y nicht übereinstimmen. Wir nehmen o.B.d.A. an, dass xi = 0 und yi = 1. Nun wählen wir alle Kolonnen von h ausser dert i-ten aus. Bei der Wahl der i − ten Kolonne ändert aber h(x) nicht da xi = 0. Aber jede von den 2b Möglichkeiten die Werte der i-ten Kolonne zu setzen liefert einen anderen Wert für h(y) (jedes mal wenn ein Bit in der i-ten Kolonne gedreht wird, wird das entsprechende Bit im Resultat auch gedreht). Daher ist die Wahrscheinlichkeit, dass h(x) = h(y) gleich 21b = N1 . Bemerkung 4.2 [Andere universelle Familie von Hashfunktionen] Die folgende Menge H von Hashfunktionen ist auch universell. p sei eine Primzahl mit |U | ≤ p < 2|U | wir definieren nun die folgende Familie von Funktionen. ha,b (x) = ((ax + b) mod p)mod N Die Menge H schliesslich ist folgendermassen definiert und ist universell. H = {ha,b |0 < a < p ∧ 0 ≤ b < p} 4.2 Sortiertes Suchen In diesem Abschnitt wird das Suchen um eine weitere Funktion erweitert. Wir möchten in der Lage sein, alle Elemente der Suchstruktur nach Schlüssel sortiert zu lesen. Es soll auch möglich sein, das sortierte Lesen ab einem gewünschten Schlüssel zu beginnen. Der Unterschied zur Struktur aus dem Abschnitt 4.1 sind die beiden Funktionen reset und nextkey. Dabei positioniert die Funktion reset das Lesen auf den kleinsten Schlüssel. Die Funktion nextkey positioniert auf den nächst grösseren Schlüssel. 4-13 4.2.1 Java Spezifikation der sortierten Suchstruktur public interface SortSuch<Key extends Comparable<Key>, Info> { /** * Suchen eines Elements mit Hilfe eines Keys. * * <pre> * Precond: none * Postcond: Falls das Element in der Suchstrucktur * new.found() == true * new.readKey() == sk * sonst * new.found() == false * </pre> * * @param sk der gesuchte Key. * @return true falls das Element in der Suchstruktur ist. */ public boolean search(Key sk); /** * N&auml;chstes Element lesen. * * <pre> * Precond: old.found() == true * Postcond: Falls new.found() == true * new.readKey() &gt; old.readKey() * kein key zwischen new und old * sonst * old.readKey() ist groesster Key * </pre> * * @return true falls noch weitere Elemente vorhanden * @throws BTE.NotFoundException * Falls keine Position existiert. */ public boolean nextKey() throws BTE.NotFoundException; /** * Positionieren auf das kleinste Element * * <pre> * Precond: none * Postcond: Falls new.found() == true * new.readKey() ist kleinster key * sonst * Keine Elemente in der Suchstruktur * </pre> * 4-14 * @return false falls der Baum leer ist. */ public boolean reset(); /** * Gibt die Anzahl Elemente zur&uuml;ck. * * <pre> * Precond: none * Postcond: none * </pre> * * @return Anzahl Schluessel in der Struktur */ public int getKeyCount(); /** * Testet ob ein Element positioniert ist. * * <pre> * Precond: none * Postcond: none * </pre> * * @return true falls ein key mit search(), nextKey() * oder reset() gefunden wurde. */ public boolean found(); /** * Einf&uuml;gen eines neuen Elements mit * gegebenem Key. * * <pre> * Precond: old.search(key) == false * Postcond: new.search(key) == true * new.found() == false * </pre> * * @param key der Key des neuen Elements * @param data Nutzdaten des neuen Elements * @throws BTE.InTreeException * Falls der Key schon im Baum ist. */ public void insert(Key key, Info data) throws BTE.InTreeException; /** * L&ouml;schen eines Elementes mit gegebenem Key. * 4-15 * <pre> * Precond: old.search(key) == true * Postcond: new.search(key) == false * new.found() == false * </pre> * * @param key Der Key, der gel&ouml;scht werden soll. * @throws BTE.NotInTreeException * Falls der Key nicht im Baum ist. */ public void remove(Key key) throws BTE.NotInTreeException; /** * Lesen der Information zum aktuell positionierten * Element. * * <pre> * Precond: old.found() == true * Postcond: none * </pre> * * @return Object: Informationen zum aktuellen Element * @throws BTE.NotFoundException Falls keine Position existiert. */ public Info readInfo() throws BTE.NotFoundException; /** * Lesen des Keys zum aktuell positionierten Element. * * <pre> * Precond: old.found() == true * Postcond: none * </pre> * * * * @return Aktueller Key * @throws BTE.NotFoundException * Falls keine Position existiert. */ public Key readKey() throws BTE.NotFoundException; } 4.2.2 Implementation als sortierter Array Falls das sortierte Lesen der Schlüssel gefordert wird, ist die Hashtabelle sicher keine geeignete Implementation. Die Sortierung in der Hashtabelle hat nichts mit der üblichen Sortierung zu tun. Für das sortierte Suchen müssen wir eine andere Implementation wählen. Die Elemente werden nach Schlüssel sortiert in einem Array gespeichert. Für die Implemen4-16 tation braucht es neben dem Array noch die Variable anzahl, die die Anzahl Elemente im Array zählt, und die Variable position, die beim sortierten Lesen verwendet wird. Den Array bezeichnen wir mit such. 4.2.2.1 Suchen eines Schlüssels k Da die Elemente im Array nach dem Schlüssel sortiert sind, kann k mit binärem Suchen gefunden werden (siehe Algorithmus 1.1). Der Pointer position für das sortierte Lesen wird auf den Index des gefundenen Elementes gesetzt. Falls das Element nicht im Array ist, setzen wir position gleich anzahl. Das Suchen eines Elements benötigt also log2 (anzahl) Vergleiche. 4.2.2.2 Sortiertes Lesen Die nextkey Operation inkrementiert den Pointer position um eins. Die reset Operation setzt den Pointer position auf 0. 4.2.2.3 Einfügen eines Elements Diese Operation ist in diesem Fall aufwendig. Wir müssen ja das Element an die richtige Position im Array bringen. Der folgende Java-Code (eine Anpassung von sortieren durch Einfügen) löst diese Aufgabe. Die Variable neu enthaltet das neue Element. Algorithmus 4.1 [Einfügen im sortierten Array] Der folgende Code zeigt das Einfügen eines Elements im Array. { int i = anzahl; while (i > 0 && such[i-1].key > neu.key) { // Verschieben aller Elemente mit groesserem // Schluessel als neu um eine Position nach oben. such[i] = such[i-1]; --i; } //Das neue Element an der richtigen Stelle Einfuegen. such[i] = neu; ++anzahl; } Für diese Operation sind im Durchschnitt anzahl/2 Vergleiche und Verschiebungen notwendig. 4.2.2.4 Löschen eines Elements Das Element mit dem Index position wird gelöscht. Alle Elemente mit einem Index grösser als position müssen um eine Stelle nach links verschoben werden. 4-17 Algorithmus 4.2 [Löschen im sortierten Array] Der folgende Pseudocode zeigt das Löschen eines Elements im Array. { int i = position; while (i < anzahl) { such[i] = such[i+1]; ++i; } --anzahl; } Auch diese Operation braucht im Durchschnitt anzahl/2 Verschiebungen. Die Implementation als Array eignet sich sicher nur für eine kleine Anzahl von Elementen, die alle im Hauptspeicher Platz finden. Für grosse Datenmengen ist das Einfügen und das Löschen viel zu aufwendig. 4.2.3 Implementation als binären Suchbaum Wir können das sortierte Suchen auch als binären Suchbaum implementieren. Wie wir aber aus den Übungen wissen, kann ein binärer Suchbaum im schlechtesten Fall zu einer linearen Liste entarten. In diesem Fall ist die Komplexität einer Suchoperation im Baum O(n). In diesem Abschnitt wollen wir zeigen, dass binäre Suchbäume so aufgebaut werden können, dass sie ausgeglichen bleiben. Das heisst, alle Wege von der Wurzel zu einem Blatt sind ungefähr gleich lang. 4.2.3.1 2-3-4-Bäume Wir nehmen vorerst an, dass die Knoten unseres Baumes mehr als einen Schlüssel aufnehmen können. Wir lassen insbesondere 2-Knoten, 3-Knoten und 4-Knoten zu, die einen bzw. zwei bzw. 3 Schlüssel aufnehmen können. Für innere Knoten des Baumes gilt: • Ein 2-Knoten hat genau zwei Unterbäume. Links sind alle kleineren rechts alle grösseren Schlüssel. • Ein 3-Knoten hat genau drei Unterbäume. Links sind alle kleineren rechts alle grösseren Schlüssel und in der Mitte alle Schlüssel, die zwischen den zwei Schlüsseln des Knotens liegen. • Ein 4-Knoten hat genau vier Unterbäume. Einen für jedes Intervall, dass durch die 3 Schlüssel definiert werden. Ein 2-3-4-Baum hat die folgenden Eigenschaften: 1. Grössen Eigenschaft: Jeder Interne Knoten hat mindestens zwei und höchstens vier Nachfolger. 2. Tiefen Eigenschaft: Alle Blätter des Baumes haben dieselbe Tiefe. 4-18 Aus diesen Eigenschaften können wir ableiten, dass die Tiefe eines 2-3-4-Baum der n Schlüssel enthält O(log2 (n)) ist. Die grösste Tiefe erhält man, wenn nur 2-Knoten vorhanden sind. In diesem Fall entspricht der 2-3-4-Baum einem vollständigen Binärbaum und hat die Tiefe log2 ( n+1 2 ). Die kleinste Tiefe erhält man, wenn nur 4-Knoten vorhanden sind in diesem Fall ist die Tiefe 12 log2 ( n−3 3 − 1) . Das heisst, die Tiefe ist O(log2 (n)). In der Figur 4-6 ist ein 2-3-4-Baum dargestellt. 50 70 10 30 33 55 58 62 100 Abbildung 4-6: Ein 2-3-4-Baum Das Suchen in einem 2-3-4-Baum funktioniert gleich wie das Suchen in einem binären Suchbaum. Ebenso ist das Traversieren des Baumes kein Problem. Wir wollen uns also der Hauptaufgabe widmen, das Einfügen. Einfügen eines Schlüssels in einem 2-3-4-Baum Das Einfügen eines Schlüssels in einem 2-3-4-Baum ist in der Abbildung 4-7 dargesetllt. 50 70 10 30 33 90 55 58 62 56 50 70 100 10 30 33 90 100 55 58 62 a) b) 58 50 58 70 35 70 30 50 10 30 33 55 56 62 90 100 10 33 35 c) 55 56 62 90 100 d) Abbildung 4-7: Einfügen von Schlüsseln im 2-3-4-Baum Das Einfügen kann folgendermassen umschrieben werden: 1. Suche das Blatt, wo der Knoten eingefügt werden muss. 2. Ist das Blatt ein 2-Knoten oder 3-Knoten, so kann der Schlüssel einfach eingefügt werden. Ein 2-Knoten wird dabei zu einem 3-Knoten und ein 3-Knoten zu einem 4-Knoten (Fall a) zu b) in der Abbildung 4-7). 3. Ist das Blatt ein 4-Knoten, so wird dieser in 2 2-Knoten aufgeteilt (split Operation). Der mittlere Schlüssel wird in den Vorgängerknoten eingetragen und der neue Schlüssel in einem der beiden 2-Knoten (Fall b) zu c) in der Abbildung 4-7). 4-19 4. Falls der Vorgängerknoten auch schon ein 4-Knoten ist, so wird er in zwei 2-Knoten aufgeteilt und der mittlere Schlüssel wird im Vorgänger dieses Knotens eingetragen. Dieser Prozess kann sich bis zur Wurzel fortsetzen. (Fall c) zu d) in der Abbildung 4-7). Alternative Methode zum Einfügen eines Schlüssels Um zu verhindern, dass beim Einfügen alle Knoten bis zur Wurzel aufgespalten werden müssen, kann der Algorithmus so abgeändert werden, dass auf dem Weg im Baum nach unten alle angetroffenen 4-Knoten aufgespalten werden. Dies garantiert uns, dass kein durchlaufener Knoten mehr einen 4-Knoten als Vorgänger hat. Dieser Vorgang ist in der Abbildung 4-8 dargestellt. 47 58 30 45 50 10 33 35 45 58 30 70 46 48 49 55 56 62 90 100 10 50 33 35 46 48 49 a) 70 55 56 62 90 100 b) 45 58 48 50 30 10 33 35 46 47 49 70 55 56 62 90 100 c) Abbildung 4-8: Aufspalten der 4-Knoten beim Einfügen Löschen eines Schlüssels in einem 2-3-4-Baum Beim Löschen eines Schlüssels müssen wir nur den Fall betrachten, wo der Schlüssel in einem Blatt des Baumes liegt. Der Fall bei dem der Schlüssel in einem internen Knoten liegt, kann auf den ersten Fall zurückgeführt werden. Wir nehmen an, dass der Schlüssel k in einem internen Knoten v an der Position i liegt. In diesem Fall werden die folgenden Schritte durchgeführt 1. Wir suchen im Unterbaum i des Knotens v das Blatt w, das am weitesten rechts liegt. (im Unterbaum immer dem Pointer ganz rechts verfolgen). 2. Wir Tauschen nun den Grössten Schlüssel im Knoten w mit dem Schlüssel k im Knoten v. 3. Nun kann der Schlüssel k im Blatt w gelöscht werden. Beim Löschen eines Schlüssels in einem Blatt gibt es nun 3 Fälle: 1. Das Blatt besitzt mehr als einen Schlüssel. In diesem Fall kann der Schlüssel einfach aus dem Blatt entfernt werden. Dieser Fall ist in der Abbildung 4-9 (löschen von 100) im Fall d) dargestellt. 4-20 58 58 70 30 50 70 33 50 10 33 35 90 100 62 55 30 35 90 100 62 55 a) b) 58 58 70 33 50 70 33 55 30 90 100 62 35 30 90 100 62 35 50 c) d) 58 33 58 70 33 90 30 30 35 50 62 35 50 62 70 f) e) Abbildung 4-9: Löschen im 234-Baum 2. Das Blatt enthält nur einen Schlüssel. In einem der Nachbarknoten sind aber mindestens 2 Schlüssel. In diesem Fall kann ein Schlüssel vom Nachbarknotenknoten ausgeliehen werden. Dieser Fall ist in der Abbildung 4-9 Fall a) dargestellt. 3. Das Blatt enthält nur einen Schlüssel. Beide Nachbarknoten besitzen auch nur einen Schlüssel. In diesem Fall wird der Knoten mit einem der Nachbarknoten zusammengelegt zusammen mit dem entsprechenden Schlüssel vom Vorgängerknoten. Dieser Fall ist in der Abbildung 4-9 Fall c) dargestellt. 4. Beim entnehmen des Schlüssels in den Vorgängerknoten kann es passieren, dass dieser nachher keinen Schlüssel mehr enthält. In diesem Fall wird dieselbe Prozedur auf der höheren Stufe angewendet. Dieser Vorgang kann sich bis zur Wurzel fortsetzen. Dieser Fall ist in der Abbildung 4-9 Fall e) dargestellt. 4.2.3.2 Rot-Schwarz-Bäume 2-3-4-Bäume können als gewöhnliche binäre Bäume dargestellt werden. Pro Knoten brauchen wir dabei nur ein zusätliches Bit das angibt, ob der Knoten “rot” oder “schwarz” ist. Ein 4-Knoten wird im Baum mit drei Knoten dargestellt, der Vater ist dabei schwarz und die beiden Kinder sind rot. Ein 3-Knoten mit Hilfe von zwei Knoten wobei das Kind wieder rot ist. Ein 2-Knoten ist ein schwarzer Knoten mit 2 schwarzen Kinder. In der Abbildung 4-10 ist die Darstellung von 4- und 3-Knoten als Rot-Schwarz-Baum angegeben. Rote Knoten und deren Verbindung zum Vorgängerknoten sind mit einem dicken Strich gezeichnet. Ein Rot-Schwarz-Baum hat die folgenden Eigenschaften: 4-21 70 50 20 50 100 40 40 70 20 oder 40 100 70 Abbildung 4-10: Darstellung von 4- und 3-Knoten 1. Wurzel Eigenschaft: Die Wurzel des Baumes ist immer schwarz. 2. Interne Eigenschaft: Die Kinder eines roten Knotens sind immer schwarz. 3. Tiefen Eigenschaft: Alle Blätter haben dieselbe schwarze Tiefe. Das heisst, auf jedem Weg von der Wurzel zu einem Blatt hat es gleich viele schwarze Knoten. In der Abbildung 4-11 ist der 2-3-4-Baum aus der Abbildung 4-6 als Rot-Schwarz-Baum dargestellt. Man sieht sofort, dass die Algorithmen zum Suchen eines Schlüssels oder zum sortierten Lesen der Elemente im Rot-Schwarz-Baum genau die gleichen sind wie für den binären Suchbaum. Im Folgenden müssen wir also zeigen, wie ein Knoten eingefügt und gelöscht werden kann. 50 70 70 50 10 30 33 55 58 62 100 100 30 10 58 33 55 62 Abbildung 4-11: Ein Rot-Schwarz-Baum Bemerkung 4.3 [Höhe des Rot-Schwarz-Baumes] Aus der Definition von Rot-SchwarzBäumen geht hervor, dass auf einem Weg von der Wurzel zu einem Blatt niemals zwei rote Knoten unmittelbar nacheinander auftreten können, und alle solche Wege haben gleich viele schwarze Knoten. Das heisst, ein Weg, bei dem rot und schwarz immer abwechselt kann maximal doppelt so lang werden, wie ein Weg, das nur schwarze Knoten enthält. Die Tiefe des entsprechenden 2-3-4-Baumes entspricht aber genau der schwarzen Tiefe des Rot-SchwarzBaumes. Aus diesen Tatsachen kann man schliessen, dass die Höhe des Rot-Schwarz-Baumes O(log2 (N )) ist. Einfügen in einem Rot-Schwarz-Baum Das Einfügen funktioniert vorerst wie in einem binären Suchbaum. Wir suchen zuerst den Schlüssel k bis wir einen leeren Pointer antreffen. Dort wird ein neuer Knoten kreiert und der Schlüssel k dort eingefügt. Ist der neue Knoten die Wurzel, so wird der neue Knoten schwarz gefärbt sonst rot. Durch diese Operation bleiben im Baum die Wurzel- und die TiefenEigenschaft erhalten, aber die Interne Eigenschaft kann verletzt sein. Falls der neue Knoten k nicht die Wurzel des Baumes ist und sein Vorgänger v ist selber auch schon rot, dann ist das Kind eines roten Knotens auch rot. Wir nennen diesen Fall “doppel rot”. Wir stellen fest, dass im Fall “doppel rot” v nicht die Wurzel des Baumes sein kann (Wurzel Eigenschaft), und dass der Vorgänger von v schwarz sein muss (Interne Eigenschaft). Wir können nun zwei Fälle unterscheiden: 4-22 Der Bruder des Knotens v ist schwarz. In diesem Fall bedeutet “doppel rot”, dass wir im entsprechenden 2-3-4-Baum einen neuen 4-Knoten kreiert haben, der aber im RotSchwarz-Baum falsch orientiert ist. Die möglichen Anordnungen der Knoten im RotSchwarz-Baum sind in der Abbildung 4-12 im Fall a) angegeben. In diesem Fall ist eine Restrukturierung des Baumes notwendig. Diese Operation kann folgendermassen beschrieben werden: • Nehme die Knoten k seinen Vater v und seinen Grossvater u und bennene diese als a, b und c so, dass diese der Grösse nach sortiert sind. • nun ersetze den Grossvaterknoten u mit Knoten b und mache a und c zu den Kindern von b. Nach dieser Restrukturierung wird b als schwarz, a und c als rot markiert. Dieser Vorgang ist in der Abbildung 4-12 angegeben. u 90 v 70 k 20 u 90 20 v 20 u 20 70 v k 70 u 90 90 k 70 v k a) b 70 a c 20 90 b) Abbildung 4-12: Restrukturierung des Rot-Schwarz-Baumes Der Bruder des Knotens v ist rot. In diesem Fall bedeutet “doppel rot”, dass im entsprechenden 2-3-4-Baum ein überlauf passiert ist (einfügen in einem 4-Knoten). Wir müssen also eine Splitoperation durchführen. Dies geschieht mit einer Umfärbung (siehe Abbildung 4-13). Die Knoten v und w werden schwarz gefärbt und der Knoten u wird rot gefärbt. Falls der Vorgänger von u schwarz ist, sind wir fertig. Sonst haben wir das “doppel rot” Problem auf einer höheren Stufe des Baumes. Das heisst, eine Umfärbung löst das “doppel rot” Problem beim Knoten k oder es gibt das Problem an den Knoten u weiter. Falls bei u das “doppel rot” Problem wieder auftaucht, so müssen wir dort entweder wieder Umfärben oder eine Restrukturierung vornehmen. Dieser Vorgang setzt sich fort, bis das “doppel rot” Problem durch eine Umfärbung oder eine Restrukturierung gelöst wird. Der Vorgang kann sich bis zur Wurzel des Baumes fortsetzen. Wir können noch überlegen, wieviele Operationen maximal nötig sind um ein neues Element in einem Rot-Schwarz-Baum einzufügen. Da eine Restrukturierung des Baumes das “doppel rot” Problem löst, brauchen wir höchstens eine Restrukturierungsoperation. Umfärbungen gibt es höchstens so viele wie die Hälfte der Höhe des Baumes und das ist log2 (n). Das heisst, das Einfügen eines neuen Elementes ist O(log2 (n)). 4-23 40 u 63 55 63 90 v 55 90 w v 55 k 40 63 ... u 63 90 w 40 55 90 k 40 Abbildung 4-13: Spliten eines 4-Knotens in Rot-Schwarz-Bäume Löschen in einem Rot-Schwarz-Baum Beim Löschen eines Elementes in einem Rot-Schwarz-Baum können wir vorerst gleich vorgehen wie beim binären Suchbaum. Wir suchen den Knoten k der gelöscht werden muss. Wir können dies immer so einrichten, dass k höchstens einen Nachfolger hat. Nun gibt es mehrere Fälle: Der Knoten k ist rot. In diesem Fall hat der Knoten keine Nachfolger und er kann einfach gelöscht werden. Der Knoten k ist schwarz und hat einen Nachfolger z. In diesem Fall muss der Nachfolger rot sein. Hätte der Knoten einen schwarzen Nachfolger, so würde dies bedeuten, dass im entsprechenden 2-3-4-Baum ein 2-Knoten mit nur einem Nachfolger existiert und das ist ja nicht erlaubt. In diesem Fall kann der Knoten k gelöscht werden und z wird am Vater u von k angehängt. Anschliessend wird z schwarz gefärbt. u ... 30 40 30 u 30 ... 30 r 25 r 40 50 z 40 z 50 k 25 25 40 25 Fall a) ... 30 30 u 30 u ... 30 r r 40 40 z 25 50 25 40 50 k z 25 25 40 Fall b) Abbildung 4-14: Verschmelzen zweier Knoten im Rot-Schwarz-Baum Der Knoten k ist schwarz und hat keinen Nachfolger. Dies entspricht im 2-3-4-Baum dem Fall, dass der Knoten leer wird. Wir müssen nun die folgenden Fälle unterscheiden: Fall 1: Der Bruder z des Knotens k ist schwarz und hat schwarze Kinder. Dies entspricht im 2-3-4-Baum dem Fall wo wir die Knoten z und k verschmelzen. Dies wird mittels einer Umfärbung der Knoten realisiert. Das heisst der Bruder z von k 4-24 wird rot. Falls der Vater r rot ist, wird er neu schwarz und die Löschoperation ist abgeschlossen. Dieser Fall ist in der Abbildung 4-14 im Fall a) dargestellt. Falls der Vater r schwarz ist, so wird die Tiefen Eigenschaft des Rot-SchwarzBaumes verletzt. Der schwarze Weg zum Knoten z ist um 1 kürzer. Wir färben den Knoten r doppel schwarz um diese Tatsache darzustellen. Im 2-3-4-Baum entspricht dies dem Fall, wo der Vaterknoten leer wird und wir müssen das “doppel schwarz” Problem auf der höheren Ebene des Baumes lösen. Dieser Fall ist in der Abbildung 4-14 im Fall b) dargestellt. Das “doppel schwarz” Problem kann sich bis zur Wurzel des Baumes fortpflanzen. Dieser Prozess ist in der Abbildung 4-15 dargestellt. 70 70 50 120 30 10 58 33 55 50 90 62 80 150 100 125 120 30 200 10 58 33 55 90 62 70 10 120 55 62 125 50 90 58 33 80 200 70 50 30 150 80 150 125 120 30 200 10 58 33 55 90 62 80 150 125 200 Abbildung 4-15: Fortpflanzung des “doppel schwarz” Problems im Rot-Schwarz-Bäume Fall 2: Der Bruder v von r ist schwarz und hat mindestens ein rotes Kind z. Die Lösung dieses Falles entspricht im 2-3-4-Baum dem Ausleihen eines Schlüssels beim Nachbarknoten. In diesem Fall führen wir dieselbe Restrukturierung vor wie beim Einfügen. Wir nehmen den Knoten z seinen Vater v und seinen Grossvater u und machen eine Umbennenung auf a, b und c so, dass die Schlüssel in aufsteigender Reihenfolge sortiert sind. Anschliessend machen wir b zum Vater von a und c a und c werden schwarz, b hat dieselbe Farbe wie u sie hatte. Nach der Restrukturierung existiert das “doppel schwarz” Problem nicht mehr. Das heisst, pro Löschvorgang wird höchstens eine Restrukturierung gemacht. Der Vorgang ist in der Abbildung 4-16 dargestellt. 4-25 ... 20 ... 30 30 u 20 b 30 10 10 20 v a 40 r 20 30 c 10 40 40 z 40 r 10 Fall a) ... 20 ... 30 30 u 20 b ... 10 ... 10 20 v a 40 r 10 30 30 c 10 40 40 z 40 r 20 Fall b) Abbildung 4-16: Ausleihen eines Schlüssels beim Nachbarn Fall 3: Der Bruder v von r ist rot. In diesem Fall führen wir eine Anpassung durch. Wenn z das linke Kind von v ist, so führen wir eine Restrukturierung mit den Knoten z, v und u durch. v wird schwarz und u wird rot. Wir haben nichts anderes getan, als den 3-Knoten (20,30) im 2-3-4-Baum im Rot-Schwar-baum anders darzustellen. Nun ist der Bruder x von r schwarz und wir haben entweder den Fall 1 oder den Fall 2. Man beachte, dass der Vater u von r rot ist. Das heisst, wenn der Fall 1 vorliegt, so kann es keine Fortpflanzung des “doppel schwarz” Problems geben. Das heisst, pro Löschvorgang gibt es höchstens eine Anpassungsoperation. Der Fall ist in der Abbildung 4-17 dargestellt. 20 30 u v 30 ... 10 ... 20 ... 27 ... v 40 r 20 z 30 u 10 40 z 10 27 x 27 x Abbildung 4-17: Anpassungsoperation im Rot-Schwarz-Baum 4-26 40 r Wir können noch überlegen wieviele Operationen nötig sind, um ein Schlüssel aus dem RotSchwarzbaum zu löschen. Es braucht dabei höchstens eine Anpassung und höchstens eine Restrukturierung. Eventuell müssen wir log2 (n) Umfärbeoperationen durchführen. Das heisst, das Löschen im Rot-Schwarz-Baum hat eine Komplexität von O(log2 (n)). 4.2.4 Implementation als Splay Baum In diesem Abschnitt wollen wir noch den Splay-Baum betrachten. Diese Datenstruktur unterscheidet sich wesentlich von den bisher besprochenen ausgelichenen Bäumen (2-3-4-Bäume, rot-schwarz-Bäume). Ein Splay-Baum hat keine Regeln, um die Ausgeglichenheit des Baumes zu garantieren. Um den Baum ausgeglichen zu halten (in einem amortisierten Sinne) wird nach jedem Zugriff eine sogenannte Splay-Operation ausgeführt. Die Splay-Operation wird auf dem tiefsten Knoten ausgeführt, der während einer Operation erreicht wird (Suchen, Einfügen oder Löschen). Die Struktur des Splay-Baumes ist ein gewöhnlicher binärer Suchbaum. Die amortisierten Kosten für das Suchen, Einfügen und Löschen in einem Splay-Baum sind logarithmisch. 4.2.4.1 Die Splay-Operation Eine Splay-Operation bewegt einen Knoten x bis zur Wurzel des Baumes mit Hilfe einer Sequenz von restrukturierungs Operationen. Die spezifischen Operationen, die ausgeführt werden hängen von der Position vom Knoten x, seinem Vorgänger y und (wenn er existiert) von seinem Vorvorgänger z ab. Nachfolgend sind die drei möglichen restrukturierungs Operationen angegeben. zig-zig Operation: Der Knoten x und sein Vorgänger sind beide linke oder beide rechte Nachfolger. Wir ersetzen z durch x, y wird nachfolger von x und z nachfolger von y. Die zig-zig Operation ist in der Abbildung 4-18 dargestellt. Abbildung 4-18: Die zig-zig Operation: (a) vor der Opeation (b) nach der Operation. Es gibt einen zweiten symmetrischen Fall, wenn x und y linke Nachfolger ist. zig-zag Operation: y ist ein rechter Nachfolger und x ein linker Nachfolger oder umgekehrt. Wir ersetzen z durch x. y und z werden zu den Nachfolgern von x. Die zig-zag Operation ist in der Abbildung 4-19 dargestellt. 4-27 Abbildung 4-19: Die zig-zag Operation: (a) vor der Operation (b) nach der Operation. Es gibt einen zweiten symmetrischen Fall, wenn y ein linker und x ein rechter Nachfolger ist. die zig Operation: Falls der Vorgänger y von x die Wurzel des Baumes ist, so wird x zur neuen Wurzel des Baumes. Die zig Operation ist in der Abbildung 4-20 dargestellt. Abbildung 4-20: Die zig Operation In den Abbildungen 4-21 und 4-22 ist das Suchen in einem splay Baum dargestellt. 4-28 Abbildung 4-21: Splay-Operation (1): (a) Splay von Knoten 14 beginnt mit einer zig-zag Operation. (b) nach zig-zag (c) der nächste Schritt ist eine zig-zig Operation 4-29 Abbildung 4-22: Splay-Operation (2): (d) nach zig-zig (e) nächster Schritt ist wieder zig-zig (f) die Splay-Operation ist beendet 4-30 Eine Splay-Operation auf einem Knoten x im Baum ist eine Folge von zig-zig, zig-zag und zig Operationen so, dass am Schluss der Knoten x zur neuen Wurzel des Baumes wird. In den Abbildungen 4-21 und 4-22 ist eine vollständige Splay-Operation dargestellt. Man beachte, dass bei jeder zig-zig oder zig-zag Operation die Tiefe des Knotens x um 2 abnimmt und bei einer zig Operation die Tiefe von x um 1 abnimmt. Falls die Tiefe des Knotens x gleich d ist, so braucht eine Splay-Operation gesammthaft ⌊ d2 ⌋ zig-zig und/oder zig-zag Operationen plus eine zig Operation falls d ungerade ist. Da zig-zig, zig-zag und zig Operationen eine Komplexität von O(1) haben, so hat eine Splay-Operation auf einen Knoten x mit Tiefe d eine Komlexität von O(d). Das heisst, die Komplexität der Splay-Operation ist gleich wie die Komplexität von Suchen, Einfügen oder Löschen eines Knotens. 4.2.4.2 Anwendung der Splay-Operation Suchen Falls der Schlüssel k im Knoten x gefunden wird, so wird die Splay-Operation auf den Knoten x ausgeführt. Falls die Suche erfolglos ist, so wird die Operation auf den Knoten ausgeführt, bei dem die Suche endet. Die Splay-Operation in der Abbildung 4-21 wird ausgeführt, wenn der Schlüssel 14 gefunden wird oder bei einer erfolglosen Suche nach dem Schlüssel 14.5. Einfügen Wenn der Schlüssel k eingefügt wird, so wird die Splay-Operation auf den neu kreierten Knoten x ausgeführt. In der Abbildung 4-23 Fall (f) und (g) wird die Operation auf dem neu kreierten Knoten mit Schlüssel 4 ausgeführt. Löschen Beim Löschen des Schlüssels k wir die Splay-Operation entweder auf den Vorgänger des Knotens x der k enthält oder den Vorgänger eines Nachfolgers von x (der Knoten mit grösstem Schlüssel im linken Unterbaum von x) ausgeführt. In der Abbildung 4-24 ist dieser Vorgang abgebildet. Im schlesteten Fall brauchen die Operationen Suchen, Einfügen und Löschen in einem Baum der Tiefe d mit n Elementen O(d) Operationen. Wie man in der Abbildung 4-23 sieht, kann die Tiefe aber n sein. Für den schlechtesten Fall ist der Splay-Baum also keine attraktive Datenstruktur. In der Abbildungen 4-23 ist das Einfügen in einem splay Baum dargestellt. In der Abbildungen 4-24 ist das Löschen in einem splay Baum dargestellt. 4.2.4.3 Amortisierte Kosten Wir haben schon festgestellt, dass der Splay-Baum sich im schlechtesten Fall nicht gut verhält (O(n)). Wir wollen aber in diesem Kapitel zeigen, dass die amortisierten Kosten sich logarithmisch verhalten. Da die Kosten für das Suchen, Einfügen und Löschen proportional zu der entsprechenden Splay-Operation sind, werden wir nur die Kosten der Splay-Operation untersuchen. T sei ein Splay-Baum mit n Knoten und x sei ein beliebiger Knoten. Wir definieren: size : s(x) = Anzahl Knoten im Unterbaum der durch x gegeben ist. rang : r(x) = log2 (s(x)) Wir bezahlen nun für eine zig-zig oder zig-zag Operation zwei Cyberdollars und für eine zig Operation einen Cyberdollar. Mit diesem Schema kostet die Splay-Operation für einen Knoten 4-31 Abbildung 4-23: Einfügen im Splay Baum: (a) Anfangsbaum (b) einfügen von 2 (c) nach der Splay-Operation (d) einfügen von 3 (e) nach der Splay-Operation (f) einfügen von 4 (g) nach der Splay-Operation x mit Tiefe d genau d Cyberdollars. Bei jedem Splay bezahlen wir eine gewisse Summe sum (der Betrag werden wir später festlegen). Damit ergeben sich drei Fälle. • Falls die Operationskosten genau sum sind, so benutzen wir den ganzen Betrag um die Operation zu bezahlen. • Falls sum grösser ist als die Kosten, so verteilen wir den Rest auf diverse Knoten im Baum • Falls sum kleiner ist als die effektiven Kosten, so entnehmen wir den Restbetrag von diversen Knoten im Baum um die Differenz zu begleichen. Wir wollen im System die folgende Invariante erzwingen: Vor und nach einer Splay-Operation besitzt jeder Knoten x ∈ T r(x) Cyberdollar Man beachte, dass diese Invariante gilt, wenn der Baum leer ist. Das heisst am Anfang sind keine Cyberdollars im Baum gespeichert. Wir zeigen nun, dass wir für jede Operation O(log2 (n)) Cyberdollars bezahlen müssen um das System am leben zu erhalten. Wir definieren r(T ) = X r(x) x∈T um die Invariante nach einer Splay-Operation zu erhalten müssen wir die Kosten der Operation plus die Änderung von r(T ) bezahlen. 4-32 Abbildung 4-24: Löschen im Splay Baum: (a) löschen des Schlüssels 8. Dabei wird der Knoten mit dem Schlüssel 7 entfernt. (b) zig-zig Operation (c) nach zig-zig (d) zig Operation (e) nach zig Wir bezeichnen zig-zig, zig-zag und zig Operationen als Schritte der Splay-Operation. Der Rang eines Knotens vor und nach einem Schritt bezeichnen wir mit r(x) beziehungsweise r′ (x). Wir beweisen nun den folgenden Satz über r(x) und r′ (x). Satz 4.4 [Änderung von r(T )] Die Änderung δ von r(T ), die durch einen Schritt der Splay-Operation eines Knotens x ensteht, kann folgendermassen begrenzt werden: δ ≤ 3(r′ (x) − r(x)) − 2 δ ≤ 3(r′ (x) − r(x)) für zig-zig und zig-zag Schritte für einen zig Schritt Beweis: Wir beweisen die Behauptung für jeden möglichen Schritt einzeln. Dabei werden wir die folgende Tatsache (ohne Beweis) verwenden: Seien a > 0, b > 0 und c > a + b dann gilt: log(a) + log(b) ≤ log(c) − 2 (4.1) zig-zig (Abbildung 4-18): Aus der Figur wird sofort klar, dass nur der Rang für die Knoten r(x), r(y) und r(z) ändert. Für alle anderen Knoten bleibt er gleich. Ferner sieht man, dass r′ (x) = r(z) r′ (y) ≤ r′ (x) r(x) ≤ r(y) 4-33 Nun können wir δ abschätzen: δ = r′ (x) + r′ (y) + r′ (z) − r(x) − r(y) − r(z) = r′ (y) + r′ (z) − r(x) − r(y) ≤ r′ (x) + r′ (z) − 2r(x) (4.2) Beachten Sie, dass n(x) + n′ (z) ≤ n′ (x) aus 4.1 folgt: r(x) + r′ (z) ≤ 2r′ (x) − 2 r′ (z) ≤ 2r′ (x) − r(x) − 2 (4.3) aus 4.2 und 4.3 folgt nun die Behauptung δ ≤ r′ (x) + (2r′ (x) − r(x) − 2) − 2r(x) = 3(r′ (x) − r(x)) − 2 (4.4) zig-zag (Abbildung 4-19): Auch in diesem Fall ändert nur der Rang der 3 Knoten x, y und z. Man sieht sofort, dass r′ (x) = r(z) r(x) ≤ r(y) Wir können also schreiben δ = r′ (x) + r′ (y) + r′ (z) − r(x) − r(y) − r(z) = r′ (y) + r′ (z) − r(x) − r(y) ≤ r′ (y) + r′ (z) − 2r(x) (4.5) Beachten Sie, dass n′ (y) + n′ (z) ≤ n′ (x) aus 4.1 folgt: r′ (y) + r′ (z) ≤ 2r′ (x) − 2 (4.6) Setzen wir 4.6 in 4.5 ein, so erhalten wir die Behauptung δ ≤ 2r′ (x) − 2 − 2r(x) ≤ 3(r′ (x) − r(x)) − 2 (4.7) zig (Abbildung 4-20): In diesem Fall kann nur der Rang von x und y ändern. Ferner gilt: r′ (y) ≤ r(y) r′ (x) ≥ r(x) Daraus folgt die Behauptung denn: δ = r′ (x) + r′ (y) − r(x) − r(y) ≤ r′ (x) − r(x) ≤ 3(r′ (x) − r(x)) (4.8) Satz 4.5 [Kosten einer Splay-Operation] T sei ein Splay-Baum mit Wurzel t und ∆ die Änderung von r(T ) bei einer Splay-Operation des Knotens x mit Tiefe d. Dann gilt: ∆ ≤ 3(r(t) − r(x)) − d + 2 4-34 Beweis: Die Splay-Operation beteht aus p = ⌈ d2 ⌉ Schritte, wobei jeder Schritt eine zig-zig oder zig-zag Operation ist. Falls d ungerade is kommt noch eine zig Operation dazu. Sei r0 (x) = r(x) und für i = 1..p sei ri der Rang von x und δi die Änderung von r(T ) nach dem i-ten Schritt der Splay-Operation. Nach dem Theorem 4.4 gilt nun: p δ ∆ = Ppi=1 i ≤ (3(r i (x) − ri−1 (x)) − 2) + 2 i=1 = 3(rp (x) − r0 (x)) − 2p + 2 ≤ 3(r(t) − r(x)) − d + 2 P Falls wir bei jeder Splay-Operation 3(r(t) − r(x)) + 2 Cyberdollar bezahlen so genügt dies nach Theorem 4.5 um die Splay-Operation zu bezahlen und gleichzetig zu garantieren, dass in jedem Knoten immer noch r(x) Cyberdollars gespeichert sind. Für die Wurzel t des Baumes T mit n Knoten gilt r(t) = log(n). Da r(x) < r(t) ist die Zahlung O(log(n)). Wir halten also fest: Die amortisierten Kosten einer Splay-Operation betragen O(log(n)) Bemerkung 4.4 [Einfügen und Löschen] Da die Kosten für eine Such, Einfügen oder Löschoperation proportional zu den Kosten einer Splay-Operation sind, kann man sagen, dass die amortisierten Kosten für diese Operationen im Splay-Baum O(log(n)) betragen. Für ein Sequenz von Operationen auf einen leeren Splay-Baum können wir also festhalten: Satz 4.6 [Amortisierte Kosten im Splay-Baum] Für eine Sequenz von Operationen (suchen, einfügen oder löschen) der Länge m in einem leeren Splay-Baum ist der totale Aufwand gleich O (m + Pm i=1 log(ni )) Dabei bezeichnet ni die Anzahl Knoten nach der i-ten Operation. Dies ist gleich O(m log(n)) wobei n = max1≤i≤m (ni ) Der Vorteil des Splay-Baumes ist, dass er adaptiv ist. Das heisst, Elemente auf die häufig zugegriffen wird sind nahe bei der Wurzel und daher ist der Zugriff auf solche Elemente schneller. Das nächste Theorem gibt darüber auskunft. Satz 4.7 [Splay-Baum ist adaptiv] Wir betrachten wieder eine Sequenz von m Operationen, die mit einem leeren Splay-Baum beginnt. f (i) bezeichne die Anzahl Zugriffe auf das Element i und n sei die totale Anzahl der Elemente. Falls auf jedes Element im Baum zugegriffen wird, so ist der totale Aufwand gleich O (m + Pn i=1 f (i) log(m/f (i))) Das heisst, die amortisierte Zeit für einen Zugriff auf ein Element i ist O(log(m/f (i))). Als Beispiel nehmen wir an, dass für das Element i gilt f (i) = m/4. In diesem Fall gilt O(log(m/(m/4))) = O(log(4)) = O(1). 4-35 4.2.5 Implementation als B-Tree B-Trees (balanced trees oder ausgeglichene Bäume) sind für das Suchen in grossen Datenmengen geeignet. Die Knoten im Baum enthalten meistens nur die Schlüssel und einen Zeiger auf die entsprechenden Daten. Bei grossen Datenmengen hat der ganze Baum keinen Platz im Speicher und wird deshalb auf einer Festplatte gespeichert. Der Zugriff auf einen Knoten des Baumes bedingt also einen Diskzugriff. Daher ist es sinnvoll, in jedem Knoten möglichst viele Schlüssel zu speichern (verringert auch die Höhe des Baumes). Vor allem muss darauf geachtet werden, dass die Höhe des Baumes ausgeglichen wächst (alle Wege von der Wurzel zu einem Blatt müssen gleich lang sein). Beispiel 4.6 [Tiefe von Bäumen] Nehmen wir an, dass in einem Baum 100 Schlüssel pro Knoten gespeichert werden können. Im ganzen Baum seien 106 Schlüssel gespeichert. Ist der Baum ausgeglichen, so wird man zum Finden eines Schlüssels log100 (106 ) = 3 Diskzugriffe benötigen. Ist der Baum nicht ausgeglichen, so können im schlimmsten Fall 106 = 104 100 Zugriffe nötig sein, um einen Schlüssel zu finden, was sicher nicht akzeptabel ist. Die folgenden Kriterien für einen ausgeglichenen Baum wurden 1970 von R. Bayer und E. McCreigth aufgestellt. Definition 4.2 [B-Tree] Ein B-Tree der Ordnung n (n ist eine Konstante des B-Trees) ist ein Baum mit folgenden Eigenschaften: 1. Jeder Knoten des Baumes enthält höchstens 2 · n Elemente. 2. Jeder Knoten ausser der Wurzel enthält mindestens n Elemente. Dieses Kriterium garantiert, dass der Baum mindestens zu 50% gefüllt ist. 3. Jeder Knoten ist entweder ein Blatt, d.h. hat keine Nachfolger, oder er hat m + 1 Nachfolger. m ist die Anzahl Schlüssel, die in diesem Knoten gespeichert ist. 4. Alle Blätter liegen auf der gleichen Stufe des Baumes. In der Abbildung 4-25 ist ein B-Tree der Ordnung 2 dargestellt. Der interne Aufbau eines Knotens ist in der Abbildung 4-26 aufgezeichnet. Die C-Definition des Knotens lautet: struct Knoten { int m; Knoten *Vorgaenger; Knoten *p[(2*n) + 1]; Keys key[2*n]; }; // // // // Anzahl Schluessel im Knoten Vorgaengerknoten Alle Nachfolger = m+1 Schluessel sortiert Die Schlüssel im Array key sind sortiert abgelegt und es gilt: 4-36 52 10, 20 2, 7, 8, 9 13, 16 70, 85 27, 33, 45 54, 63, 68 75, 78, 81 88, 91, 97, 99 Abbildung 4-25: B-Tree der Ordnung 2 mit 3 Stufen 1. Alle Schlüssel k im Baum mit k <key[0] sind im Unterbaum, der durch den Knoten p[0] definiert ist, gespeichert. 2. Alle Schlüssel k im Baum mit k >key[m-1] sind im Unterbaum, der durch den Knoten p[m] definiert ist, gespeichert. 3. Alle Schlüssel k im Baum mit key[i] < k < key[i+1] i = 0 . . . m-2 sind im Unterbaum, der durch den Knoten p[i+1] definiert ist, gespeichert. p[0],key[0],p[1],key[1], p[2], key[2], . . . ,p[m−1], key[m−1],p[m] Alle keys < key[0] Alle keys k mit key[0] < k < key[1] Abbildung 4-26: Knoten eines B-Trees Mit diesen Definitionen können nun die Operationen für den B-Tree angegeben werden. Wir nehmen vorerst an, dass alle Schlüssel im Baum verschieden sein müssen. 4.2.5.1 Suchen eines Schlüssels k 1. Einlesen der Wurzel und suchen von k in der Wurzel. Da die Elemente innerhalb eines Knotens sortiert abgelegt werden, kann dies mit binärem Suchen geschehen (siehe Algorithmus 1.1). 2. Falls k im Knoten ist, kann die Suche erfolgreich abgebrochen werden. Ist k nicht in diesem Knoten, sind 3 Fälle möglich: (a) k < key[0]: Die Suche wird im Knoten p[0] fortgesetzt. (b) k > key[m-1]: Die Suche wird im Knoten p[m] fortgesetzt. (c) key[i] < k < key[i+1]i = 0 . . . m-2: Die Suche wird im Knoten p[i+1] fortgesetzt. 3. Ist der gefundene Zeiger NULL, so ist der aktuelle Knoten ein Blatt. D.h. der Schlüssel k ist nicht im Baum. Die Suche wird abgebrochen. 4-37 4.2.5.2 Einfügen eines Schlüssels k Zuerst wird der Schlüssel k im Baum nach obiger Methode gesucht. Ist der Schlüssel noch nicht im Baum gespeichert, so endet die Suche in einem Blatt. Nun sind zwei Fälle möglich: 14 52 A 10, 20 70, 85 B 2, 7, 8, 9 C 13, 14, 16 D E 27, 33, 45 54, 63, 68 F 96 G 75, 78, 81 88, 91, 97, 99 H I 52 A 10, 20 70, 85, 96 B 2, 7, 8, 9 D C 13, 14, 16 E 27, 33, 45 F 54, 63, 68 G 75, 78, 81 88, 91 97, 99 I J H Abbildung 4-27: Einfügen im B-Tree 1. Im Blatt sind weniger als 2·n Elemente gespeichert. In diesem Fall kann k an die richtige Stelle im Array key eingefügt werden. 2. Im Blatt sind 2 · n Elemente gespeichert. In diesem Fall muss ein neuer Knoten kreiert werden. Dabei muss darauf geachtet werden, dass die Eigenschaften des B-Trees erhalten bleiben. Zum Verständnis dieses Vorgangs betrachte man das Einfügen des Schlüssels 96 in der Abbildung 4-27. (a) Der Schlüssel 96 muss im Knoten I eingetragen werden. Dieser Knoten enthält schon 2 · n Elemente. (b) Ein neuer Knoten J wird kreiert. (c) Die 2 · n + 1 vorhandenen Schlüssel werden gleichmässig auf die Knoten I und J verteilt, wobei der mittlere Schlüssel in den Vorgängerknoten C gebracht wird. Alle Eigenschaften des B-Trees bleiben damit erhalten. Insbesondere enthalten die Knoten I und J je n Schlüssel. Der Vorgängerknoten C enthält jetzt einen Schlüssel mehr und demzufolge hat er auch einen Nachfolger mehr. Das Einfügen des mittleren Elementes in den Vorgängerknoten kann diesen wieder zum Überlaufen bringen. In diesem Fall wird derselbe Prozess eine Stufe höher wiederholt. Im Extremfall muss der Prozess bis zur Wurzel fortgesetzt werden (Abbildung 4-28). 4.2.5.3 Löschen des Schlüssels k Zuerst wird der Schlüssel k im im Baum gesucht. Wird der Schlüssel gefunden, so gibt es zwei Fälle: 4-38 99 30, 60 A 15 41 B C 3, 13 20, 27 E F 35 G 66,80 D 54, 57 62,65 70,77 84,92 H I J K 60 O 30 80 A N 15 41 66 B C D 3, 13 20, 27 E F 35 G 92 M 54, 57 62,65 70,77 H I J 84 K 99 L Abbildung 4-28: Wachstum eines B-Tree der Ordnung 1 1. Der Schlüssel k befindet sich in einem Blatt. In diesem Fall kann das Element gelöscht werden und m um eins reduziert werden. 2. Der Schlüssel k befindet sich in key[i] und der Knoten ist kein Blatt. In diesem Fall ersetzen wir den Schüssel key[i] durch den grössten Schlüssel im Unterbaum, der durch p[i] gegeben ist. Dieser Schlüssel befindet sich im rechtesten Blatt des Unterbaumes. Um dieses Blatt zu finden, steigen wir entlang den Zeigern p[m] den Baum hinab, bis wir auf das gewünschte Blatt stossen. Der zu löschende Schlüssel wird nun mit dem Schlüssel key[m-1] des Blattes überschrieben. Im Blatt wird m um eins verringert. Das Löschen eines Schlüssels passiert also nur auf Blätter des Baumes. Nach dem Löschen muss in jedem Fall geprüft werden, ob mindestens noch n Schlüssel im Knoten vorhanden sind. Ist dies nicht der Fall, so muss der Baum wieder ausgeglichen werden. Auch hier gibt es zwei Fälle: Entleihen von Schlüsseln beim Nachbarknoten Zuerst können wir beim Nachbarknoten nachsehen, ob dieser mehr als n Schlüssel besitzt. Ist dies der Fall, so können die Schlüssel gleichmässig auf beide Knoten verteilt werden. Diesen Vorgang nennt man Ausgleichen (Abbildung 4-29). Zusammenlegen von zwei Knoten Hat keiner der zwei Nachbarn mehr als n Schlüssel, müssen wir den Knoten mit einem seiner Nachbarn verschmelzen. In diesen beiden Knoten sind 2 · n − 1 Schlüssel gespeichert. Wir nehmen dazu den mittleren Schlüssel aus dem Vorgängerknoten und speichern diese 2 · n Schlüssel in einem Knoten. Dies ist genau der zur Aufteilung eines Knotens inverse Vorgang (Abbildung 4-30). Nun muss auch nachgeprüft werden, ob der Vorgängerknoten immer noch mehr als n Schlüssel besitzt. Ist dies nicht der Fall, so wird derselbe Prozess auf der höheren Stufe fortgesetzt. Im Extremfall kann sich das Zusammenlegen von Knoten bis zur Wurzel fortsetzen (Abbildung 4-30). Bemerkung 4.5 [Interne Operationen im Knoten] Für die internen Operationen auf einem Knoten (Suchen, Einfügen und Löschen eines Schlüssels innerhalb des Knotens), können die im Abschnitt 4.2.2 beschriebenen Algorithmen verwendet werden. 4-39 97 52 10, 20 2, 7, 8, 9 13, 16 70, 85 27, 33, 45 54, 63, 68 75, 78, 81 97, 99 52 10, 20 2, 7, 8, 9 13, 16 70, 81 27, 33, 45 54, 63, 68 75, 78 85, 99 Abbildung 4-29: Ausgleich im B-Tree 52 85 10, 20 2, 7, 8, 9 13, 16 70, 81 27, 33, 45 54, 63, 68 75, 78 52 Zu wenig Elemente 10, 20 2, 7, 8, 9 13, 16 85, 99 70 27, 33, 45 54, 63, 68 75, 78, 81, 99 10, 20, 52, 70 2, 7, 8, 9 13, 16 27, 33, 45 54, 63, 68 75, 78, 81, 99 Abbildung 4-30: Zusammenlegen zweier Knoten im B-Tree 4-40 4.2.5.4 Mehrfachschlüssel Bis jetzt kann ein Schlüssel höchstens einmal im B-Tree gespeichert werden. In der Praxis kommen aber Mehrfachschlüssel sehr oft vor. Zum Beispiel bei Namen. Der B-Tree muss also noch erweitert werden, damit Mehrfachschlüssel eingefügt werden können. Dazu sind zwei Methoden denkbar: Separat chaining Diese Methode funktioniert wie beim Hashing. Der erste Schlüssel wird normal im Baum gespeichert. Alle anderen Schlüssel, die gleich sind, werden in einer verketteten Liste gespeichert. Einfügen im Baum Wir ergänzen die Bedingung an die Knoten folgendermassen: Ist der Schlüssel k in einem Knoten in key[i]i = 0 . . . m-1 gespeichert, so kann k auch in den durch p[i] und p[i+1] definierten Unterbäumen vorkommen. Wir können nicht verlangen, dass k nur in einem der Unterbäume vorkommt, wie die Abbildung 4-31 zeigt. Wählen wir diese Methode, so muss die Suchoperation angepasst werden: Wird der gewünschte Schlüssel in einem Knoten im Element key[i] gefunden, so kann die Suche nicht abgebrochen werden. Die Suche muss im Unterbaum der durch p[i] gegeben ist fortgeführt werden. Nur so kann garantiert werden, dass bei einem anschliessenden sortierten Lesen der Schlüssel im Baum alle Schlüssel mit Wert k auch gelesen werden. 33 33, 33, 33, 33 33 33, 33 33, 33 Abbildung 4-31: B-Tree mit lauter gleichen Schlüssel 4.2.5.5 Die Wahl der Ordnung eines B-Trees Die Wahl der Anzahl Schlüssel pro Knoten hängt von den Eigenschaften des zur Verfügung stehenden Rechnersystems ab. Als Faustregel gilt, dass das Lesen eines Knotens höchstens 4-41 einen Diskzugriff bedeutet. Wählt man als Grösse des Knotens die optimale Grösse einer Diskseite, kann man mit Hilfe dieser Grösse und der Schlüssellänge die Anzahl Schlüssel pro Knoten berechnen. 4.2.6 Implementation als Skipliste Die Skipliste ist eine interessante Implementation des sortierten Suchens. Die Skip-Liste benutzt zufällige Entscheide um die Schlüssel so zu organisieren, dass das Suchen, das Einfügen und das Löschen im Durchschnitt in logarithmischer Zeit durchgeführ werden können. 4.2.6.1 definition der Skipliste Eine Skipliste für eine Menge von Schlüsseln K besteht aus einer Menge von linearen Listen S = {S0 , S1 , . . . , Sh }. Jede Liste Si enthält eine Teilmenge der Schlüssel aus K in aufsteigender Reihenfolge plus zwei spezielle Schlüssel −∞ und +∞. −∞ ist kleiner als jeder Schlüssel in K und +∞ ist grösser als jeder Schlüssel in K. Die Listen in S genügen den folgenden Eigenschaften. • Die Liste S0 enthält alle Schlüssel aus K (plus die Elemente −∞ und +∞). • Für i = 1, 2, . . . , h−1 enthält die Liste Si eine zufällig generierte Teilmenge der Schlüssel aus der Liste Si−1 (plus die Elemente −∞ und +∞). • Die Liste Sh enthält nur die Elemente −∞ und +∞. In der Abbildung 4-32 ist eine Skipliste dargestellt. Es ist üblich die Liste S0 zuunterst und die Listen S1 , . . . , Sh darüber zu zeichnen. Auch bezeichnen wir h als die Höhe der Liste. S5 − S4 − 8 17 S3 − 8 17 25 S2 − 17 25 31 S1 − 8 12 17 25 31 38 S0 − 12 17 25 31 38 8 8 + 8 + 55 + 55 + 8 50 + 8 44 55 8 8 39 + 8 8 20 44 55 Abbildung 4-32: Beispiel einer Skipliste Wie wir beim Einfügen sehen werden, werden die Schlüssel in der Liste Si+1 zufällig aus der Liste Si so ausgewählt, dass jeder Schlüssel in Si mit einer Wahrscheinlichkeit von 12 auch in Si+1 ist. So erwarten wir, dass S1 etwa n2 , S2 etwa n4 und im allgemeinen Si etwa 2ni Schlüssel enthält. Mit anderen Worten erwarten wir, dass die Höhe h etwa log2 (n) sein wird. Die Halbierung der Elemente von einer Liste zur anderen wird aber nicht als Eigenschaft der Skipliste gefordert. Es wird nur mit Hilfe von Randomisierung in etwa erzielt. Wir können die Skipliste als zweidimensionale Struktur von Positionen ansehen die Horizontal in Ebenen und vertikal in Türme angeordnet sind. Durch diese Positionen können wir nun mit den vier folgenden Operationen navigieren: 4-42 after(p) before(p) below(p) above(p) Gibt Gibt Gibt Gibt Position Position Position Position nach p in derselben Ebene zurück. vor p in derselben Ebene zurück. unterhalb p im selben Turm zurück. oberhalb p im selben Turm zurück. Falls die gesuchte Position nicht existiert geben die vier Funktionen null zurück. Mit diesen Hilfsoperationen können wir nun das Suchen, das Einfügen und das Löschen eines Schlüssels beschreiben. 4.2.6.2 Suchen eines Schlüssels in der Skipliste Der folgende Algorithmus findet den grössten Schlüssel der kleiner oder gleich k ist. public SkipPosition skipSearch(k) { SkipPosition p; // p ist erstes Element der obersten Liste p = topLeft; while (below(p) != null) { p = below(p); while(after(p).key() <= k { p = after(p); } } } Das Suchen ist in der Abbildung 4-33 dargestellt. Wir suchen das Element 50. S5 − S4 − 8 17 S3 − 8 17 25 S2 − 17 25 31 S1 − 8 12 17 25 31 38 S0 − 12 17 25 31 38 8 8 + 8 + 55 + 55 + 8 50 + 8 44 55 8 8 39 + 8 8 20 44 55 Abbildung 4-33: Suchen des Schlüssels 50 in der Skipliste 4.2.6.3 Einfügen eines Schlüssels in der Skipliste Beim Einfügen eines neuen Schlüssels k suchen wir zuerst den Schlüssel und bekommen die position des grössten Schlüssels der kleiner oder gleich k ist. In der untersten Liste wird k gerade nach dieser Position eingefügt. Anschliessend benutzen wir die Random-Funtion um zu entscheiden in welchen Listen der neue Schlüssel eingefügt werden soll. Solange der Wert von Random kleiner als 21 ist, wird der Schlüssel auch in der nächsthöheren Liste eingetragen. Der Algorithmus ist nachfolgend angegeben. 4-43 public void skipInsert(k) { SkipPosition p; p = skipSearch(k); insertAfter(p, k); while (random() < 1/2) { while (above(p) == null) { p = before(p); } p = above(p); insertAfter(p, k); } } Das Einfügen eines Schlüssels ist in der Abbildung 4-34 dargestellt. Der Schlüssel 42 wird eingefügt. S5 − S4 − 8 17 S3 − 8 17 25 S2 − 17 25 31 S1 − 8 12 17 25 31 38 S0 − 12 17 25 31 38 8 8 + 8 + + 42 55 + 55 + 55 + 42 44 50 8 44 8 42 8 8 39 55 8 8 20 42 Abbildung 4-34: Einfügen des Schlüssels 42 in der Skipliste 4.2.6.4 Löschen eines Schlüssels in der Skipliste Auch das Löschen eines Elementes in der Skipliste ist relativ einfach. Zuerst suchen wir den Schlüssel. Falls dieser nicht vorhanden ist, so gibt es eine Exception. Sonst finden wir eine Position p mit Schlüssel k auf der untersten Ebene. Wir entfernen den Schlüssel aus der Liste und anschliessend auch in den oberen Ebenen, falls der Schlüssel dort vorhanden ist. Der folgende Algorithmus realisiert das Löschen. public void skipRemove(k) { SkipPosition p; p = skipSearch(k); if (p.key() != k) { throw new KeyNotFoundException(); } while (p != null) { removeAfter(before(p)); p = above(p); } } 4-44 Das Löschen eines Schlüssels ist in der Abbildung 4-35 dargestellt. Dabei wird der Schlüssel 25 entfernt. S5 − S4 − 8 17 S3 − 8 17 25 S2 − 17 25 31 S1 − 8 12 17 25 31 38 S0 − 12 17 25 31 38 8 8 + 8 + 55 + 55 + 8 50 + 8 44 55 8 8 39 + 8 8 20 44 55 Abbildung 4-35: Löschen des Schlüssels 25 aus der Skipliste Bemerkung 4.6 [Operationen before und above] Die Operationen before und above sind nicht unbedingt notwendig, da wir uns beim Suchen des Elements Pointers auf das letzte Element jeder Ebene merken können. Somit ist es nicht nötig, die Elemente der Liste doppelt zu verlinken. 4.2.6.5 Pointer auf das linke oberste Element Jede Skipliste muss einen Pointer topLeft auf das oberste linke Element besitzen, da die Suche in der Struktur immer dort beginnt. Ferner muss definiert werden was passieren soll, wenn beim Einfügen eines neuen Elementes der entstehende Turm höher wird als die aktuelle Höhe der Skipliste. Dafür gibt es zwei Strategien: • Wir beschränken die maximale Höhe der Liste auf einen fixen Wert, der davon abhängig ist, wieviele Elemente in der Liste sind. Wir werden bei der Analyse sehen, dass der Wert h = max(10, 3 · ⌈log2 (n)⌉) eine vernünftige Wahl ist. • Die andere Möglichkeit ist, wie im gegebenen Algorithmus, die Höhe wachsen zu lassen, solange random < 1/2 erfüllt ist. Wir werden in der Analyse des Algorithmus sehen, dass die Wahrscheinlichkeit, dass die Höhe grösser wird als O(log2 (n)) sehr klein ist. Daher ist diese Implementation auch möglich. 4.2.6.6 Analyse der Komplexität Abschätzen der maximalen Höhe einer Skipliste Als erstes wollen wir den Erwartungswert für die Höhe h der Liste S bestimmen. Wir nehmen an, dass beim Einfügen eines Elementes in der Skipliste die Höhe nicht beschränkt wird. Die Wahrscheinlichkeit, dass ein gegebener Schlüssel in der Ebene i gespeichert ist, ist 21i . Daher ist die Wahrscheinlichkeit Pi , dass überhaupt ein Schlüssel auf der Ebene i gespeichert ist höchstens: Pi ≤ n 2i Dies gilt, weil die Wahrscheinlichkeit, dass irgend ein Ereigniss von n verschiedenen Ereignissen eintrifft höchstens die Summe der Wahrscheinlichkeit der einzelen Ereignisse ist. 4-45 Die Wahrscheinlichkeit, dass die Höhe h von S grösser als i ist, ist gleich der Wahrscheinlichkeit, dass die Ebene i der Skipliste mindestens ein Element enthält und das ist nicht mehr als Pi . Das heisst, h ist höher als 3 log2 (n) mit einer Wahrscheinlichkeit von höchstens n 1 n = 3 = 2 n n 23 log2 (n) P3 log2 (n) ≤ Wir können diese Aussage veralgemeinern: Pc log2 (n) = 1 nc−1 f ür c>1 Das heisst, die Wahrscheinlichkeit, dass h kleiner oder gleich c log2 (n) ist, ist mindestens 1 . Also ist die Höhe h einer Skipliste mit hoher Wahrscheinlichkeit O(log2 (n)). 1 − nc−1 Anzahl Operationen beim Suchen Beim Suchen eines Schlüssels k in der Skipliste haben wir zwei ineinandergeschachtelte whileSchlaufen. Die innere Schlaufe sucht vorwärts auf einer Ebene der Skipliste nach einem Element das grösser oder gleich ist als k. Die äussere Schlaufe läuft von der Höhe h der Skipliste bis auf die Ebene 0. Da die Höhe h mit hoher Wahrscheinlichkeit O(log2 (n)) ist, ist die Anzahl Iterationen in der äusseren Schlaufe mit hoher Wahrscheinlichkeit O(log2 (n)). Nun müssen wir noch die Anzahl Vorwärtsschritte abschätzen, die wir bei jeder Ebene machen. Sei ni die Anzahl Schlüssel, die auf der Ebene i mit k verglichen werden. Man beachte, dass spätestens beim ersten Schlüssel, der auch zur Ebene i + 1 gehört, die Suche abgebrochen wird. Dies ist so, weil dieser Schlüssel bei der Suche schon auf einer höheren Ebene verglichen wurde und grösser als k ist. Die Wahrscheinlichkeit, dass ein Schlüssel der in der Ebene i gespeichert ist auch in der Ebene i + 1 gespeichert ist, ist ja 1/2. Das heisst, der Erwartungswert der Anzahl Vorwärtsschritte auf der Ebene i ist 2. Der Erwartungswert für die Anzahl vorwärtsschritte auf jeder Ebene der Skipliste ist O(1). Da die Skipliste mit hoher Wahrscheinlichkeit eine Höhe von O(log2 (n)) hat, so gilt: Komplexität der Suche in einer Skipliste = O(log2 (n)) Ähnlich kann man zeigen, dass der Erwartungswert für die Anzahl Operationen beim Einfügen und beim Löschen eines Schlüssels auch O(log2 (n)) ist. Platzbedarf für die Skipliste Wir wollen am Schluss noch zeigen, dass der Platzbedarf für die Skipliste nicht zu gross ist. Wie wir oben schon festgestellt haben, ist der Erwartungswert der Anzahl Schlüssel auf der Ebene i gleich 2ni . Daraus können wir den Erwartungswert für die gesammte Anzahl gespeicherter Schlüssel in S berechnen und die ist: h X n i=0 2i =n h X 1 i=0 2i < 2n Das heisst, der erwartete Platzbedarf für die Skipliste S ist O(n) 4-46 Kapitel 5 Graphen 5.1 Einleitung Sehr viele Probleme können auf natürliche Weise mit Hilfe von Objekten und Verbindungen zwischen diesen Objekten dargestellt werden. Im folgenden wollen wir als erstes solche Beispiele betrachten. 5.1.1 Klassische Probleme • Königsberger Brückenproblem (Euler 1736): Gibt es einen Spaziergang so, dass jede Brücke in der Abbildung 5-1 genau einmal begangen wird und dass man sich am Schluss wieder am Ausgangspunkt befindet? B Fluss A D C Abbildung 5-1: Königsberger Brückenproblem • Hamilton Problem (Hamilton 1859): Kann ein Graph (Netz) derart durchlaufen werden, dass jeder Knoten genau einmal besucht wird? 5.1.2 Angewandte Probleme • Finden eines kürzesten und eines billigsten Weges von einer Stadt zu einer anderen. 5-1 • Erstellen eines Netzplanes für die Projektplanung, so dass die Abfolge von Tätigkeiten logisch richtig ist. Finde in diesem Netzplan einen Lösungsweg so, dass das Projekt in minimaler Zeit fertiggestellt werden kann. • Darstellung von Petrinetzen. • Färben der Länder in einer Landkarte mit einer minimalen Anzahl Farben. • Darstellung der Verknüpfung der Atome in einem Molekül. • Finden der optimalen Verkabelung für einen Neubau. • Erstellen von Verkehrsleitsystemen, die Autos auf dem schnellstmöglichen Weg zum Ziel führen. • usw. 5.2 5.2.1 Begriffe Gerichtete Graphen In der Graphentheorie gibt es sehr viele Begriffe, die wir nachstehend definieren wollen. Definition 5.1 [Gerichteter Graph] Ein gerichteter Graph G = (V, E) besteht aus: 1. einer Menge V von Knoten (engl: vertices) und 2. einer binären Relation E auf V . Die Elemente (u, v) von E werden gerichtete Kanten (engl: edges) des gerichteten Graphen genannt. Eine gerichtete Kante ist also ein geordnetes Paar von Knoten. Falls (u, v) eine Kante des Graphen ist so sagt man, dass die Knoten u und v adjazent sind. Eine Kante e heisst inzident zum Knoten u, falls u einer der Endpunkte der Kante ist. Gerichtete Graphen können graphisch wie in der Abbildung 5-2 dargestellt werden. Jeder Knoten wird durch einen Kreis und einen Namen dargestellt und jede Kante durch einen Pfeil. N = {0, 1, 2, 3, 4} A = {(0,0), (0,2), (1,0), (1,4), (2,1), (2,3), (3,1), (4,2) (4,3)} 0 1 4 2 3 Abbildung 5-2: Beispiel eines gerichteten Graphen 5-2 In Texten ist es üblich, eine gerichtete Kante (u, v) als u → v darzustellen. Definition 5.2 [Vorgänger und Nachfolger] Wenn u → v eine gerichtete Kante ist, so ist u ein Vorgänger (engl: predecessor) von v und entsprechend ist v ein Nachfolger (engl: successor) von u. Beispiel 5.1 [Vorgänger und Nachfolger] Im Beispiel in der Abbildung 5-2 bedeutet die gerichtete Kante 1 → 0, dass 0 ein Nachfolger von 1 ist. Die gerichtete Kante 0 → 0 bedeutet, dass 0 sowohl sein eigener Vorgänger wie auch sein eigener Nachfolger ist. Definition 5.3 [Pfad in gerichteten Graphen] Ein Pfad (engl: path) in einem gerichteten Graphen ist eine Liste von Knoten (v1 , v2 , . . . , vk ) so, dass von jedem Knoten zum nächsten Knoten in der Liste eine gerichtete Kante existiert. Das heisst, vi → vi+1 i = 1, 2, . . . , k − 1 Die Länge des Pfades ist die Anzahl der gerichteten Kanten im Pfad also k − 1. Der triviale Fall k = 1 ist auch erlaubt. Das heisst, jeder Knoten v ist für sich ein Pfad der Länge 0 von v nach v. Beispiel 5.2 [Pfad] Im Graphen der Abbildung 5-2 bilden die Knoten (0, 2, 3) einen Pfad der Länge 2. Definition 5.4 [Zyklus] Ein Zyklus (engl: cycle) in einem gerichteten Graphen ist ein Pfad der Länge ≥ 1, mit gleichem Begin- und Endknoten. Ein Zyklus (v1 , v2 , . . . , vk , v1 ) heisst einfach (engl: simple), wenn die Knoten v1 , v2 , . . . , vk alle verschieden sind. Ein Zyklus der Länge 1 heisst auch Schlinge (engl: loop). Ein Graph heisst zyklisch (engl: cyclic), wenn er ein oder mehrere Zyklen enthält sonst azyklisch (engl: acyclic). Beispiel 5.3 [Zyklen] Der Graph in der Abbildung 5-2 enthält mehrere Zyklen. (1, 0, 2, 3, 1) ist ein einfacher Zyklus. (0, 0, 0) oder (1, 0, 0, 2, 3, 1) sind nicht einfache Zyklen. Man beachte, dass ein Pfad der Länge 0 kein Zyklus bildet. Satz 5.1 [Knoten in Zyklen] Jeder Knoten, der in einem Zyklus enthalten ist, ist auch in einem einfachen Zyklus enthalten. 5.2.2 Ungerichtete Graphen In vielen Fällen macht es Sinn, Knoten über ungerichtete Kanten zu verbinden. Formal ist eine ungerichtete Kante eine Menge die zwei Knoten enthält. Definition 5.5 [Ungerichteter Graph] Ein ungerichteter Graph (engl: undirected graph) G = (V, E) besteht aus einer Menge von Knoten V und einer Menge von ungerichteten Kanten (engl: edges) E. Eine Ungerichtete Kante ist eine Menge, die genau zwei Knoten enthält. Falls {u, v} eine ungerichtete Kante ist sagt man, dass die Knoten u und v adjazent sind. Eine Kante e heisst inzident zum Knoten u, falls u einer der Endpunkte der Kante ist. Beispiel 5.4 [Ungerichteter Graph] In der Abbildung 5-3 sind einige Bahnverbindungen zwischen einigen Städten als Graphen dargestellt. In diesem Fall ist es Sinnvoll mit einem ungerichteten Graphen zu arbeiten, da die Züge immer in beide Richtungen fahren können. Definition 5.6 [Pfad in ungerichteten Graphen] Ein Pfad (engl: path) in einem ungerichteten Graphen ist eine Liste von Knoten (v1 , v2 , . . . , vk ) so, dass von jedem Knoten zum nächten Knoten in der Liste eine ungerichtete Kante existiert. Das heisst, {vi , vi+1 } ∈ E i = 1, 2, . . . , k − 1 5-3 K = {Bern, Lausanne, Genf, Thun, Brig, Sion} E = {{Lausanne,Genf}, {Lausanne,Sion},{Lausanne,Bern},{Bern,Thun},{Thun,Brig},{Brig,Sion}} Bern Thun Lausanne Brig Genf Sion Abbildung 5-3: Beispiel eines ungerichteten Graphen Die Länge des Pfades ist die Anzahl der ungerichteten Kanten im Pfad also k − 1. Der triviale Fall k = 1 ist auch erlaubt. Das heisst, jeder Knoten v ist für sich ein Pfad der Länge 0 von v nach v. Die Definition von Zyklen in ungerichteten Graphen ist jedoch etwas schwieriger als in gerichteten Graphen. In der Abbildung 5-3 wollen wir den Pfad ({Sion,Brig,Sion}) sicher nicht als Zyklus betrachten. Wir müssen diesen Fall also ausschliessen. Die einfachste Variante ist im folgenden angegeben: Definition 5.7 [Einfacher Zyklus im ungerichteten Graphen] Ein einfacher Zyklus in einem ungerichteten Graphen ist ein Pfad (v1 , v2 , . . . , vk , v1 ) der Länge ≥ 3 der im gleichen Knoten beginnt und endet, wobei die Knoten v1 , v2 , . . . , vk alle verschieden sind. Bemerkung 5.1 [Zyklen in ungerichteten Graphen] In ungerichteten Graphen ist das Konzept eines nicht einfachen Zyklus im allgemeinen nicht sehr nützlich und wir werden es hier auch nicht weiter verfolgen. 5.3 Der Graph als ADT Der Graph ist eine komplexere Datenstruktur als die Datenstrukturen, die wir bis jetzt behandelt haben. Diese Komplexität kommt daher, dass Graphen zwei Arten von Objekten enthalten die Knoten und die Kanten. Daraus ergeben sich viele Methoden, die vom ADT angeboten werden müssen. Ferner gibt es ja ungerichtete und gerichtete Graphen was die Beschreibung nicht unbedingt erleichtert. Dieses Problem kann mit Hilfe von Vererbung gelöst werden dabei definieren wir eine abstrakte Klasse Graph mit zwei Subklassen DirectedGraph und UndirectedGraph. Da sowohl für Knoten wie auch für Kanten Zusatzinformationen existieren können, speichern wir zu jedem Knoten und zu jeder Kante ein Object o. 5.3.1 Methoden des Graphen Wir beginnen mit den Methoden, die zu jedem Graphen gehören und auch in dieser Klasse implementiert werden können. 5-4 verticeCount(): edgeCount(): vertices(): edges(): endVertices(e): areAdjacent(v,w): getEdge(v,w): Gibt Gibt Gibt Gibt Gibt Gibt Gibt die Anzahl Knoten des Graphen zurück die Anzahl Kanten zurück einen Iterator über die Knoten zurück einen Iterator über die Kanten zurück die beiden Endknoten einer Kante zurück zurück, ob die Knoten v un w adjazent sind. die Kante zwischen v und w zurück. Die folgenden Methoden dienen dazu den Graphen zu verändern. abstract insertEdge(v,w,o): insertVertex(o): removeEdge(e): removeVertex(v): 5.3.2 Einfügen einer Kante. Einfügen eines Knotens Löscht die Kante e. Löscht den Knoten v und alle inzidenten Kanten. Methoden des gerichteten Graphen Diese Methoden sind nur für einen gerichteten Graphen sinnvoll. Man beachte, dass die Methode insertEdge() hier speziell für gerichtete Graphen implementiert wird. insertEdge(v,w,o): origine(e): destination(e): inIncident(v): outIncident(v): 5.3.3 Einfügen einer gerichteten Kante von v nach w. Gibt den Anfangspunkt der Kante zurück. Gibt den Endpunkt der Kante zurück. Gibt einen Iterator über alle eingehenden Kanten von v zurück. Gibt einen Iterator über alle ausgehenden Kanten von v zurück. Methoden des ungerichteten Graphen Die folgenden Methoden werden nur für ungerichtete Graphen angeboten. Man beachte, dass auch hier die Methode insertEdge() speziell für ungerichtete Graphen implementiert wird. insertEdge(v,w,o): incident(v): 5.4 Einfügen einer ungerichteten Kante zwischen v und w. Gibt einen Iterator über alle zu v inzidenten Kanten zurück. Implementation von Graphen Es gibt zwei verschiedene “standards” um Graphen im Computer darzustellen. Die eine ist die Adjazenzliste, die ganz allgemein zur Darstellung von binären Relationen verwendet wird und die Adjazenzmatrix. Welche Darstellung Sinnvoll ist hängt vom betrachteten Graphen und von den gewünschten Operationen ab. 5.4.1 Implementation als Adjazenzliste Bei dieser Darstellung wird zu jedem Knoten eine verkettete Liste aller Nachfolger geführt. In Java kann die Definition folgendermassen lauten: 5-5 import java.util.LinkedList; public class Knoten { int nodeNr; LinkedList<Knoten> next; } Der Graph kann nun als Array von solchen Listen definiert werden also: Knoten successor[] Der Eintrag successor[u] enthält also eine Liste aller Nachfolger des Knotens u. Die Adjazenzliste für den Graph der Abbildung 5-2 ist in der Figur 5-4 angegeben. 0 0 2 1 0 4 2 1 3 3 1 4 2 3 Abbildung 5-4: Adjazenzliste Falls der Graph ungerichtet ist, werden die Knoten einfach symetrisch in den Listen aufgenommen. Das heisst, falls die ungerichtete Kante {u, v} existiert, so wird in der Liste des Knotens u der Knoten v eingetragen und in der Liste des Knotens v der Knoten u. In der Abbildung 5-5 ist die Adjazensliste für den Graphen in der Abbildung 5-3 angegeben. Bern Lausanne Thun Lausanne Bern Genf Genf Lausanne Sion Brig Lausanne Thun Bern Brig Brig Sion Thun Sion Abbildung 5-5: Adjazenzliste für ungerichteten Graphen 5.4.2 Implementation als Adjazenzmatrix In dieser Implementation wird der Graph einfach als Boolsche-Matrix dargestellt. boolean arcs[][] = new boolean [KMAX][KMAX] Der eintrag arcs[u,v] ist wahr, wenn eine gerichtete Kante u → v im Graphen existiert. In der Tabelle 5-6 ist der Graph aus der Abbildung 5-2 als Adjazenzmatrix dargestellt: 5-6 0 1 2 3 4 0 1 1 0 0 0 1 0 0 1 1 0 2 1 0 0 0 1 3 0 0 1 0 1 4 0 1 0 0 0 Abbildung 5-6: Adjazenzmatrix Entsprechend können natürlich auch ungerichtete Graphen dargestellt werden. In diesem Fall ist die Adjazenzmatrix immer symmetrisch. Der Graph der Abbildung 5-3 ist in der Tabelle 5-7 als Adjazenzmatrix dargestellt. Bern Lausanne Genf Sion Thun Brig Bern 0 1 0 0 1 0 Lausanne 1 0 1 1 0 0 Genf 0 1 0 0 0 0 Sion 0 1 0 0 0 1 Thun 1 0 0 0 0 1 Brig 0 0 0 1 1 0 Abbildung 5-7: Adjazenzmatrix für ungerichteten Graphen 5.4.3 Vergleich der Methoden Im allgemeinen wird die Adjazenzmatrix dann bevorzugt, wenn der Graph dicht (engl: dense) ist. Das heisst, die Anzahl Kanten des Graphen ist fast maximal (das Maximum für einen gerichteten Graphen mit n Knoten ist n2 ). Falls der Graph licht (engl: sparse) ist (nur wenige der möglichen Kanten sind vorhanden), so kann es manchmal von Vorteil sein, den Graphen als Adjazensliste darzustellen. Der Vorteil ist, dass bei grossen lichten Graphen weniger Platz verwendet wird. Wir nehmen an, dass integers und Pointers je 32 Bits verwenden und dass Boolsche Variablen mit einem Bit dargestellt werden. n sei die Anzahl Knoten und a die Anzahl Kanten im Graphen. Für die Darstellung als Adjazenzliste benötigen wir also 32 · n + 64 · a Bits zur darstellung des Graphen. Für die Darstellung als Adjazenzmatrix benötigen wir n2 Bits. Wir bestimmen nun, für welches a die Darstellung als Liste kleiner wird. Aus 32 · n + 64 · a < n2 erhalten wir a< Falls n gross ist, können wir n 2 n2 n − 64 2 vernachlässigen und erhalten a< n2 64 5-7 1 der möglichen Kanten vorhanden sind, so lohnt Mit anderen Worten heisst das, wenn nur 64 es sich den Graphen als Liste zu implementieren (natürlich nur für grosse n). 5.5 Algorithmen auf Graphen 5.5.1 Tiefensuche (Depth-First Search) Die Tiefensuche bei Graphen kann mit der rekursiven Traversierung von Bäumen verglichen werden. Wie dort beginnen wir bei einem Knoten und besuchen alle seine Nachfolger (Nachbarn). Das einzige Problem ist, dass diese Methode für zyklische Graphen unendlich ist. Wir müssen daher alle Knoten die besucht werden speziell markieren. Allgemeines Vorgehen 1. Alle Knoten als “unbesucht” markieren 2. Für jeden Knoten k der als “unbesucht” markiert ist besuche(k) Die Prozedur besuche(k) kann sowohl rekursiv wie auch iterativ implementiert werden. Sie besucht alle Knoten, die vom Knoten k aus über einen Pfad erreichbar sind und noch nie besucht wurden. Rekursive Implementation von besuche 1. markiere k als “besucht” 2. Für alle Nachfolger kn von k die als “unbesucht” markiert sind besuche(kn) Implementation von besuche mit einem Stack 1. Knoten k als besucht markieren und auf den Stack legen. 2. Solange der Stack nicht leer ist: (a) k = pop (b) Alle unmarkierten Nachfolger kn von k als besucht markieren und auf den Stack ablegen. Die folgende Prozedur implementiert die Tiefensuche (Java-artig) rekursiv. Algorithmus 5.1 [Tiefensuche] boolean arc[][] = new boolean[KMAX][KMAX]; boolean visited[] = new boolean[KMAX]; public void search() { for (int i = 0; i < KMAX; i++) visited[i] = false; for (int i = 0; i < KMAX; i++) if (visited[i] == false) 5-8 visit(i); } private void visit(int k) { visited[k] = true; // Knoten behandeln for (int i = 0; i < KMAX; i++) if (arc[k][i] == true) if (visited[i] == false) visit(i); } 5.5.2 Breitensuche (Breath-First Search) Die Breitensuche entsteht dadurch, dass wir in der nicht rekursiven Implementation der Tiefensuche den Stack durch eine Warteschlange ersetzen. Nun werden zuerst alle ersten “Nachkommen” eines Knotens besucht und dann alle “Nachkommen” der zweiten Generation usw. Daher nennt man diese Art der Traversierung Breitensuche. Implementation von besuche mit einer Warteschlange Algorithmus 5.2 [Breitensuche] 1. Knoten k als besucht markieren und in die Queue eintragen. 2. Solange die Queue nicht leer ist: (a) k = dequeue (b) Alle unmarkierten Nachfolger kn von k als besucht markieren und in die Queue ablegen. Bemerkung 5.2 [Gerichtet und Ungerichtet] Die oben angegebenen Algorithmen funktionieren sowohl für gerichtete wie auch für ungerichtete Graphen. In beiden Fällen wird jeder Knoten des Graphen genau einmal besucht. 5.5.3 Tiefensuche Wald Wir können verfolgen, in welcher Reihenfolge die Knoten eines gerichteten Graphen bei der Tiefensuche durchlaufen werden. Dabei kann für jeden Aufruf der Prozedur visit in search ein Tiefensuchbaum folgendermassen definiert werden: Wird die Prozedur visit für den Knoten v vom Knoten u aus aufgerufen, so ist u ein direkter Vorgänger vom Knoten v im Tiefensuchbaum. Die direkten Nachfolger von u werden von links nach rechts im Baum eingetragen. Die Menge der so erhaltenen Tiefensuchbäume wird Tiefensuchwald genannt. Beispiel 5.5 [Tiefensuche Wald] In der Abbildung 5-8 ist ein gerichteter Graph angegeben. In der Abbildung 5-9 ist der entsprechende Tiefensuchwald abgebildet. In der Abbildung 5-9 sind alle Kanten des Graphen, die nicht zum Tiefensuchwald gehören gestrichelt eingetragen. Wir können nun die Kanten eines Graphen G bezüglich seines Tiefensuchwaldes in vier Kategorien aufteilen: 5-9 1 3 6 5 8 9 10 11 7 4 2 12 13 Abbildung 5-8: Ein gerichteter Graph 1 10 8 2 3 6 11 12 9 4 5 13 7 Abbildung 5-9: Tiefenwald zum obigen Graphen 1. Baumkanten Kanten u → v für die gilt, dass visit(v) von visit(u) aufgerufen wird. 2. Vorwärtskanten Kanten u → v für die gilt, dass u ein Vorgänger von v im Tiefensuchbaum ist, aber kein direkter Vorgänger. In der Abbildung 5-9 ist die Kante 10 → 13 eine Vorwärtskante. 3. Rückwärtskanten Kanten u → v für die gilt, dass u ein Nachfahre von v im Baum ist. Der Fall u = v (Schlinge) gilt auch als Rückwärtskante. In der Abbildung 5-9 sind die Kanten 5 → 2, 7 → 1 und 8 → 8 Rückwärtskanten. 4. Querkanten Kanten u → v für die gilt, dass keines der beiden Knoten im Tiefenwald ein Vorfahre des anderen ist. In der Abbildung 5-9 sind die Kanten 6 → 3 und 7 → 5 Querkanten. Bemerkung 5.3 [Orientierung der Querkanten] Wir sehen, dass alle Querkanten in der Abbildung 5-9 von rechts nach links verlaufen. Dies ist kein Zufall und kommt daher, dass die Nachfahren eines Knotens u von links nach rechts im Tiefenbaum eingetragen werden. 5.5.4 Postorder Nummerierung Wenn wir einmal ein Tiefensuchbaum (Tiefensuchwald) des Graphen besitzen, können wir diesen in Postorder traversieren und die besuchten Knoten in dieser Reihenfolge nummerieren. Die Nummerierung der Knoten in postorder kann aber auch direkt während der Tiefensuche 5-10 geschehen. Der Algorithmus für die Tiefensuche muss in diesem Fall folgendermassen abgeändert werden. Algorithmus 5.3 [Postorder Nummerierung] boolean arc[][] = new boolean[KMAX][KMAX]; boolean visited[] = new boolean[KMAX]; int postnr[] = new int[KMAX]; int k1; public void search() { for (int i = 0; i < KMAX; i++) visited[i] = false; k1 = 0; for (int i = 0; i < KMAX; i++) if (visited[i] == false) visit(i); } private void visit(int k) { visited[k] = true; // Knoten behandeln for (int i = 0; i < KMAX; i++) if (arc[k][i] == true) if (visited[i] == false) visit(i); // Postorder Nummer verteilen ++k1; postnr[k] = k1; } Zwischen der Postordernummerierung der Knoten eines Graphens und den vier Arten von gerichteten Kanten besteht der folgende Zusammenhang. 1. Falls u → v eine Baumkante oder Vorwärtskante ist, so ist v ein Nachfahre von u im Tiefenbaum und somit ist die Postordernummer von v kleiner als die von u. 2. Falls u → v eine Querkante ist, so wissen wir, dass v links von u im Baum liegt. Auch hier ist also die Postordernummer von v kleiner als die von u. 3. Falls u → v eine Rückwärtskante ist und u 6= v, dann ist u ein Nachkomme von v im Baum und hat daher eine kleinere Postordernummer als v. Im Fall u = v ist die Postordernummer gleich. Wir können also sagen, eine Kante u → v ist dann und nur dann eine Rückwärtskante, wenn die Postordernummer von u kleiner oder gleich der Postordernummer von v ist. 5.5.5 5.5.5.1 Anwendungen der Tiefensuche Finden von Zyklen in einem gerichteten Graphen Ein gerichteter Graph, der eine Rückwätskante besitzt ist zyklisch. Diese Aussage ist nach Definition der Rückwärtskante banal (siehe auch die Abbildung 5-10). 5-11 v u Abbildung 5-10: Jede Rückwärtskante bildet mit Baumkanten einen Zykel Die Umkehrung der Aussage gilt aber auch. Das heisst, in einem zyklischen Graphen existiert mindestens eine Rückwärtskante. Um dies zu sehen betrachten wir einen Zyklus (v1 , v2 , . . . , vk , v1 ). Wir bezeichnen mit pi die Postordernummer von vi für i = 1, . . . , k. Falls keine der Kanten v1 → v2 bis vk−1 → vk eine Rückwärtskante ist, so gilt p1 > p2 > · · · > pk . Insbesondere gilt dann pk < p1 und somit ist die Kante vk → v1 eine Rückwärtskante. Wir können also einen Algorithmus schreiben, der entscheidet ob ein gerichteter Graph zyklisch ist oder nicht. 1. Führe die Tiefensuche auf den Graphen durch und nummeriere die Knoten in postorder. 2. Für alle gerichteten Kanten u → v Falls pu ≤ pv return zyklisch 3. return azyklisch 5.5.5.2 Topologisches Sortieren Gegeben sei ein gerichteter und azyklischer Graph G. Wir können mit der Tiefensuche jedem Knoten im Graphen eine Postordernummer zuweisen. Nun bilden wir eine Liste (v1 , v2 , . . . , vn ) aller Knoten des Graphen mit der Eigenschaft, dass pi > pj f alls i < j. Diese Ordnung der Knoten hat die Eigenschaft, dass für alle gerichteten Kanten des Graphen vi → vj gilt: i < j. Das heisst, alle Kanten gehen in dieser Ordnung vorwärts. Eine solche Ordnung heisst topologische Ordnung des Graphen. Bemerkung 5.4 [Topologische Ordnung nicht eindeutig] Die topologische Ordnung ist im allgemeinen nicht eindeutig wie das Beispiel in der Abbildung 5-11 zeigt. Dort ist sowohl die Liste (1, 5, 2, 3, 4, 6, 7, 8) wie auch die Liste (1, 5, 2, 6, 3, 7, 4, 8) eine topologische Ordnung. 1 4 3 2 7 6 5 8 Abbildung 5-11: Ein gerichteter azyklischer Graph Mit Hilfe der Tiefensuche kann eine topologische Ordnung einfach gefunden werden. Jeder Knoten wird nach der Zuweisung seiner Postordernummer auf einen Stack abgelegt. Am Ende der Tiefensuche können die Knoten in topologischer Ordnung vom Stack gelesen werden. 5-12 5.5.5.3 Zusammenhang in ungerichteten Graphen Gegeben sei ein ungerichteter Graph G und eine Relation P auf der Menge K der Knoten des Graphen, die folgendermassen definiert ist: uP v ⇔ es existiert ein Pfad von u nach v Die definierte Relation P auf der Menge der Knoten ist eine Äquivalenzrelation, das heisst: 1. Sie ist reflexsiv. Das heisst uP u ∀u ∈ K 2. Sie ist symmetrisch. Das heisst uP v ⇔ vP u 3. Sie ist transitiv. Das heisst (uP v ∧ vP w) ⇒ uP w Definition 5.8 [Zusammenhangskomponenten] Die durch die Relation P definierten Äquivalenzklassen eines ungerichteten Graphen G heissen Zusammenhangskomponenten von G. Ein ungerichteter Graph G heisst zusammenhängend, wenn er nur aus einer Zusammenhangskomponente besteht. Mit Hilfe der Tiefensuche (und des Tiefenwaldes) können die Zusammenhangskomponenten eines ungerichteten Graphes sehr einfach gefunden werden. Wir betrachten für diesen Fall einen ungerichteten Graphen als gerichteten Graphen, wobei jede ungerichtete Kante durch zwei entsprechende gerichtete Kanten in beiden Richtungen ersetzt werden. Nun können wir den Tiefensuchwald für diesen Graph konstruieren. Die Menge der Bäume im Wald entspricht dann 1 zu 1 den Zusammenhangskomponenten des Graphen. In der Abbildung 5-12 ist ein Graph mit seinem entsprechenden Tiefensuchwald angegeben. Der Graph hat 3 Zusammenhangskomponenten (bzw. drei Bäume im Tiefensuchwald) 1 7 8 9 10 11 6 3 5 4 2 12 13 a) Ein ungerichteter Graph 1 2 4 7 3 5 10 8 9 11 6 12 13 b) Der entsprechende Tiefensuchwald Abbildung 5-12: Zusammenhangskomponenten eines Graphen 5.5.5.4 Zusammenhang in gerichteten Graphen In gerichteten Graphen unterscheidet man zwischen schwach zusammenhängenden (weakly connected) und stark zusammenhängenden Graphen (strongly connected). Definition 5.9 [schwach zusammenhängender gerichteter Graph] Ein gerichteter Graph G heisst schwach zusammenhängend, falls aus G durch das Ersetzen aller gerichteten Kanten durch ungerichtete Kanten ein zusammenhängender ungerichteter Graph G′ entsteht. 5-13 Bei einem stark zusammenhängenden Graphen muss jeder Knoten von jedem Knoten aus erreichbar sein. Definition 5.10 [stark zusammenhängender gerichteter Graph] Ein gerichteter Graph G heisst stark zusammenhängend, falls für jedes Paar von Knoten u, v ∈ V sowohl ein gerichteter Pfad von u nach v wie auch von v nach u existiert. Wie bei ungerichteten Graphen kann man dann von schwachen und starken Zusammenhangskomponenten sprechen. Dies sind maximale zusammenhängende Subgraphen. 5.6 Gewichtete Graphen Bei vielen Problemen, die mit Hilfe von Graphen gelöst werden, spielt die Länge (oder die Kosten) der Kanten eine wichtige Rolle. Beispiel 5.6 [Gewichteter Graph] Ein Transportunternehmen wird versuchen, die Länge der Wege zwischen Startort und Zielort zu minimieren. Wenn die Landkarte als Graphen definiert wird mit den Orten als Knoten und den Strassen als Kanten, so kann das Problem nun gelöst werden, indem jeder Kante als Gewicht die Distanz zwischen den beiden Ortschaften zugewiesen wird. Definition 5.11 [Gewichteter Graph] Ein gewichteter Graph G = (V, E, w) ist ein gerichteter oder ungerichteter Graph mit einer zusätzlichen Funktion w : E 7→ R von der Menge der Kanten in die reellen Zahlen. Die Zahl w(e) bezeichnet man als Gewicht der Kante e. 5.6.1 Implementation von gewichteten Graphen Bei der Implementation als Adjazenzliste enthält jedes Listenelement das Gewicht der Kante als weitere Komponente. Wenn der Graph als Adjazenzmatrix implementiert wird, so können die Matrixelemente gerade das Gewicht der Kante enthalten. Dabei muss die Matrix natürlich als Integer- oder FloatMatrix definiert werden. 5.6.2 Minimal aufspanneder Baum Gegeben sei ein ungerichteter, gewichteter und zusammenhängender Graph G = (V, E, w). Gesucht wird ein Untergraph G′ = (V, E ′ , w/E ′ ) von G mit folgenden Eigenschaften. • G′ ist kreisfrei und zusammenhängend. Das heisst, G′ ist ein Baum, der alle Knoten von G verbindet. • G′ hat minimale Kosten. Das heisst, die Summe der Gewichte der Kanten in G′ ist minimal. Ein solcher Baum heisst minimaler aufspannender Baum von G (minimal spanning tree). Der folgende Algorithmus stammt von Kruskal. Bei jedem Schritt wird die kürzeste Kante aus den noch nicht betrachteten Kanten gewählt. Falls diese Kante zur Lösung hinzugefügt werden kann, ohne das dadurch ein Kreis entsteht, wird dies getan. Sonst wird diese Kante nie mehr betrachtet. Es werden also keine Züge zurückgenommen. In der Abbildung 5-13 ist die Methode bildlich dargestellt. Nachfolgend ist der Algorithmus in Pseudocode angegeben. 5-14 2 1 3 5 8 3 3 4 4 3 1 7 5 6 7 6 3 8 3 3 4 8 3 5 6 5 10 6 7 7 6 7 9 4) 6 6 7 9 9 5) 6) 2 1 8 5 1 7 7 10 3 3 4 1 5 7 2 3 5 4 3 3 2 1 2 4 10 2 1 7 2 3 3 4 2 1 3 5 4 2 3 3 4 5 10 1 4 5 7 7 9 2 4 1 7 6 6 7 3) 1 2 4 7 7 2) 5 5 10 6 2 8 1 7 9 1) 1 3 3 4 4 7 9 6 2 3 5 5 10 6 7 7 8 3 4 10 6 5 2 1 2 3 4 1 7 2 1 2 8 6 7 6 9 6 7 7) 8) Abbildung 5-13: Algorithmus von Kruskal E ′ = {} while (|E ′ | < |K| − 1) { suche in E eine Kante k mit w(k) minimal. E = E \ {k} if (G′ = (K, E ′ ∪ {k}) hat keinen Kreis) E ′ = E ′ ∪ {k} } Bemerkung 5.5 [Kruskal für unzusammenhängende Graphen] Der Algorithmus von Kruskal funktioniert auch, wenn der Graph nicht Zusammenhängend ist. In diesem Fall müssen einfach alle Kanten des Graphen untersucht werden. Als Resultat erhält man für jede Zusammenhangskomponente des Graphen einen minimalen aufspannenden Baum. Der heickle Punkt im Algorithmus ist festzustellen, ob die Hinzunahme einer Kante zum Resultat einen Kreis bildet oder nicht. Die Hinzunahme einer Kante (u, v) zum Resultat führt genau dann zu einem Kreis, wenn im bisher konstruierten aufspannenden Baum G′ ein Pfad von u nach v schon existiert. Das heisst, wenn u und v in der gleichen Zusammenhangskomponente des Graphen G′ enthalten sind. Wir müssen also während der Konstruktion des aufspannenden Baumes uns merken, welche Knoten in der gleichen Zusammenhangskomponente liegen. Dazu konstruieren wir einen “Wald” (forest) von Bäumen über die Knoten im ursprünglichen Graphen G. Zwei Knoten von G′ sind dann und nur dann im gleichen Baum, wenn sie in G′ in derselben Zusammenhangskomponente liegen. Zum Testen, ob die Hinzunahme einer Kante (u, v) einen Kreis bildet, muss man nun testen, ob die beiden Knoten u 5-15 und v schon im gleichen Baum liegen. Die Konstruktion des Waldes für das Beispiel aus der Abbildung 5-13 ist in der Abbildung 5-14 angegeben. 1 5 2 3 6 3 3 4 1 6 7 2 6 5 1) 3) 1 1 6 4 3 2 6 7 2 2) 1 4 7 5 3 1 4 2 4 7 3 2 4 7 6 7 5 5 5 4) 5) 6) 1 6 3 2 4 7 5 7) Abbildung 5-14: Bestimmen der Zusammenhangskomponenten 5.6.2.1 Erklärungen 1. Solange wir noch keine Kante aus G ausgewählt haben, ist jeder Knoten aus dem ursprünglichen Graphen G in einer eigenen Zusammenhangskomponente von G′ . Daher bildet jeder Knoten einen Baum in unserem Wald. 2. Die Kante (3, 5) wird zum Resultat hinzugefügt. Nun sind die Knoten 3 und 5 in der gleichen Zusammenhangskomponente in G′ und müssen daher im gleichen Baum zu liegen kommen. Der Knoten 3 wird zur Wurzel dieses Baumes. 3. Die Hinzunahme der Kante (1, 2) bildet keinen Kreis, da beide Knoten in Verschiedenen Bäume liegen. Nun sind die Knoten 1 und 2 in der gleichen Zusammenhangskomponente in G′ und müssen daher im gleichen Baum zu liegen kommen. Der Knoten 1 wird zur Wurzel dieses Baumes. 4. Die Hinzunahme der Kante (2, 5) bildet keinen Kreis, da der Knoten 2 im Baum mit Wurzel 1 und der Knoten 5 im Baum mit Wurzel 3 liegt. Nach Hinzunahme der Kante (2, 5) liegen die Knoten beider Bäume in derselben Zusammenhangskomponente. Daher wird der Baum mit der Wurzel 3 zu einem Unterbaum des Baumes mit der Wurzel 1. 5. Die Kante (3, 2) kann nicht hinzugefügt werden, da die Knoten 3 und 2 im gleichen Baum, also in der gleichen Zusammenhangskomponente von G′ liegen. Die Kante (3, 4) kann hinzugefügt werden. 5-16 6. Die Kante (1, 3) kann nicht hinzugefügt werden, da die Knoten 1 und 3 im gleichen Baum, also in der gleichen Zusammenhangskomponente von G′ liegen. Die Kante (4, 7) kann hinzugefügt werden. 7. Die Kante (5, 6) kann hinzugefügt werden. 5.6.2.2 Implementation Um die Kanten nach Gewicht zu sortieren kann ein Heap verwendet werden. Um den Wald zu konstruieren wird einfach ein Array verwendet, der die Knoten des Graphen G als Integerzahlen 1, . . . , n repräsentiert. Jeder Knoten enthält die Nummer seines Vorgängers im Baum. Knoten mit Wert ≤ 0 sind die Wurzeln der Bäume. Um zu entscheiden, ob zwei Knoten in der gleichen Zusammenhangskomponente liegen, verfolgt man die Vorfahren beider Knoten bis zur Wurzel des entsprechenden Baumes. Sind die Wurzeln für beide Knoten gleich, so heisst das, dass beide Knoten in der gleichen Zusammenhangskomponente liegen. Damit der Baum möglichst flach bleibt, können noch zwei weitere Massnahmen getroffen werden. 1. In jeder Wurzel wird (negativ) die Anzahl Knoten des entsprechenden Baumes gespeichert. Wenn zwei Bäume zusammengefügt werden, so wird die eine Wurzel zur neuen Wurzel des zusammengsetzten Baumes. Die Knoten des anderen Baumes rutschen somit eine Stufe Tiefer im Baum. Damit möglichst viele Knoten nahe bei der Wurzel bleiben, macht es Sinn, als neue Wurzel die Wurzel des Baumes mit mehr Knoten zu wählen. 2. Wenn wir die Wurzel eines Knotens gefunden haben, können wir den Pfad vom Ausgangsknoten zur Wurzel noch einmal durchlaufen und alle Knoten auf diesem Pfad direkt an der (jetzt bekannten) Wurzel anhängen. Dadurch bleibt der Baum immer sehr flach. Wir wollen noch die Komplexität des Algorithmus im schlechtesten Fall betrachten. Nachfolgend bezeichnen wir die Anzahl Kanten im Graphen mit |E|. Man kann zeigen, dass die Komplexität für den Aufbau des Waldes (falls die obigen Verbesserungen gemacht werden) im schlechtesten Fall O(|E|α(|E|)) beträgt, wobei die Funktion α so langsam wächst, dass man diese für alle praktischen Fälle als Konstante annehmen kann. Es gilt: α(|E|) < 4 bis |E| so gross ist, dass log2 (log2 (. . . 16mal . . . log2 (|E|) . . . )) > 1 ist. Wir müssen noch die Zeit betrachten, die der Algorithmus für die Heapmanipulationen verwendet. Wir brauchen dort im schlechtesten Fall (2|E|) Heapoperationen. Im ganzen bekommen wir also O(2|E|log2 (|E|) + |E|α(|E|)) Operationen auf Kanten. Wenn α(E) als Konstante betrachtet wird gilt: Komplexität(Kruskal) = O(|E| log2 (|E|)) 5.6.3 Kürzeste Pfade in Graphen (Dijkstra) In gewichteten Graphen taucht sehr oft die Frage nach dem kürzesten Pfad zwischen zwei Knoten u und v auf. Der Algorithmus von Dijkstra ist eine effiziente Methode um die kürzesten Pfade von einem gegebenen Knoten (der Source) zu allen anderen Knoten im Graphen zu 5-17 finden. Für die folgenden Betrachtungen nehmen wir an, dass G = (K, E, w) ein ungerichteter oder gerichteter Graph mit den Knoten K den Kanten E und der Gewichtsfunktion w sei. Der Algorithmus von Dijkstra unterteilt die Knoten des Graphen G in zwei Mengen S und K \ S. In S sind alle Knoten, für die die minimale Distanz zur Source s schon bekannt ist (gesetzte Knoten) und in K \ S, diejenigen für die das noch nicht der Fall ist. Für alle Knoten u ∈ / S berechnen wir die Länge des kürzesten sogenannten speziellen Pfades von s nach u. Ein spezieller Pfad von s nach u ist ein Pfad der Form (s, v1 , . . . , vn , u) mit vi ∈ S i = 1, . . . , n. Das heisst, nur die letzte Kante des Pfades führt aus S heraus. Falls für u noch kein solcher Pfad gefunden wurde, setzen wir für die Länge des speziellen Pfades eine definierte Konstante ∞ (für unendlich) ein. Zu jedem Knoten u im Graphen wird nun der Wert der Funktion dist(u) folgendermassen berechnet. 1. Falls u ∈ S, dann ist dist(u) die Länge des kürzesten Pfades von s nach u wobei alle Knoten des Pfades in S liegen. 2. Falls u ∈ / S, dann ist dist(u) der Wert des kürzesten speziellen Pfades von s nach u Die Zusammenhänge sind in der Abbildung 5-15 dargestellt. G S s v u spezieller Pfad Abbildung 5-15: Gesetzte Knoten und spezielle Pfade Am Anfang des Algorithmus ist nur die Source s in der Menge S enthalten und dist(s) = 0. Für alle andern Knoten u ist dist(u) = w((s, u)) falls die Kante s → u existiert, sonst ist dist(u) = ∞. Nachfolgend ist angegeben, wie der nächste Knoten nun “gesetzt” wird (d.h. in S aufgenommen wird). 1. Wähle den Knoten v ∈ (K \ S) mit dist(v) ist minimal. Der Knoten v wird in S aufgenommen und die Länge des kürzesten Pfades von s nach v ist dist(v). 2. Für jeden verbleibenden Knoten u ∈ (K \ S) wird dist(u) nach folgender Formel neu berechnet: distneu (u) = min(distalt (u), dist(v) + w((v, u))) Bemerkung 5.6 [Eigenschaften von Dijkstra] 5-18 • Damit nicht nur die Länge des kürzesten Pfades bekannt ist, sondern auch der kürzeste Pfad selbst, muss im Punkt zwei bei jedem Knoten auch der Vorgänger gespeichert werden, der zu diesem speziellen Pfad geführt hat. Der kürzeste Pfad kann anschliessend rückwärts verfolgt werden. • In die Menge S werden die Knoten in der Reihenfolge ihrer Distanz zur Source s aufgenommen (nahe Knoten zuerst, weite Knoten am schluss). • Wenn nur der kürzeste Pfad zwischen zwei Knoten u und v ermittelt werden muss, so kann der Algorithmus von Dijkstra mit u als Source angewendet werden. Das Prozedere kann aber abgebrochen werden, sobald der Knoten v gesetzt wird. Beispiel 5.7 [Dijkstra] Wir wollen den Algorithmus an Hand des Graphen in der Abbildung 5-16 verfolgen. 1 100 10 5 30 60 2 10 4 50 20 3 Abbildung 5-16: Ein gewichteter Graph gesetzt 1 2 4 3 5 Menge S 1 1,2 1,2,4 1,2,3,4 1,2,3,4,5 dist(2) 10 10 10 10 10 dist(3) ∞ 60 50 50 50 dist(4) 30 30 30 30 30 dist(5) 100 100 90 60 60 Wir wollen noch begründen, wieso der Algorithmus überhaupt funktioniert. Wir müssen folgendes zeigen: a) Für alle Knoten u in S ist dist(u) die Länge des kürzesten Pfades von s nach u und alle Knoten des Pfades sind in S. b) Für jeden Knoten u ∈ (K \ S) ist dist(u) die Länge eines kürzesten speziellen Pfades von s nach u (∞ falls kein spezieller Pfad existiert). Induktion über die Anzahl Elemente von S: • Für k = 1 gehört nur s zu S. Wir setzen dist(s) = 0 und damit ist a) erfüllt. Für alle andern Knoten u initialisieren wir dist(u) auf w(s → u) falls die Kante existiert, sonst auf ∞. Damit ist b) auch erfüllt. 5-19 • Wir nehmen an, a) und b) seien für k Elemente erfüllt. v sei der (k+1) Knoten der gesetzt wird. Wir zeigen, dass dist(v) die Länge des kürzesten Pfades von s nach v ist. Wir nehmen an, es gäbe einen kürzeren Pfad. Nach Induktionsannahme (Punkt b)) ist distv(v) die Länge des kürzesten speziellen Pfades von s nach v. Wenn ein kürzerer Pfad von s nach v existiert, so kann es also kein spezieller Pfad sein. Wie in der Abbildung 5-17 dargestellt, muss dieser Pfad bei einem bestimmten Knoten w (kann auch s sein) die Menge S verlassen und zu einem Knoten u ∈ / S ∧ u 6= v führen. Von u aus muss dann ein Pfad zu v existieren, der sowohl innerhalb wie ausserhalb von S verlaufen kann. Der Pfad von s über w nach u ist aber ein spezieller Pfad und daher gilt dist(v) ≤ dist(u) und damit ist der Pfad von s über w und u nach v mindestens so lang wie dist(v). Damit ist also a) auch für k+1 Elemente wahr. Man beachte, dass diese Argumentation nur gilt, wenn alle Gewichte positiv sind. G S v s w u Abbildung 5-17: kürzester Pfad nach v Wir müssen noch zeigen, dass b) nach der Aufnahme von v in S und der entsprechenden Korrekturen von dist(u) immer noch gilt. Wir betrachten irgend einen Knoten u ∈ (K \ S). Ein kürzester spezieller Pfad von s nach u enthält immer einen vorletzten Knoten. Dieser Knoten ist entweder v oder ein anderer Knoten w ∈ S. Diese Tatsache ist in der Abbildung 5-18 dargestellt. Nach Induktionsannahme (Punkt a)) enthält der Pfad s nach w nur Knoten, die vor v in S aufgenommen wurden und damit den Knoten v nicht. Die Aufnahme von v in S verändert die Länge des speziellen Pfades s über w nach u also nicht. Falls v der vorletzte Knoten im speziellen Pfad ist, so ist nach Konstruktion der Pfad über v kürzer als der früherere spezielle Pfad und daher auch wieder minimal. Damit ist auch b) bewiesen. G v S u s w Abbildung 5-18: Vorletzter Knoten des kürzesten speziellen Pfades 5-20 5.6.4 Kürzeste Pfade in Graphen (Floyd) Mit dem Algorithmus von Dijkstra können wir alle kürzesten Pfade zwischen einem Knoten und allen anderen Knoten im Graphen bestimmen. Lassen wir den Algorithmus über alle Knoten des Graphen laufen, so finden wir alle kürzesten Pfade zwischen zwei beliebigen Knoten im Graphen. Diese Aufgabe kann aber auch mit dem Algorithmus von Floyd gelöst werden. Wir nehmen wieder an, dass die Knoten des Graphen von 0 . . . n − 1 nummeriert sind. Die Idee des Algorithmus von Floyd ist, jeden Knoten u nacheinander als Charnier (Pivot) zu verwenden. Wenn u das Pivot ist, versuchen wir den Weg zwischen beliebigen Knoten v und w zu verkürzen, indem wir einen Weg über u wählen. In der Abbildung 5-19 ist dieser Vorgang skizziert. 1 2 3 u d n−1 e v f w Abbildung 5-19: Verwenden des Knotens u um den Weg zwischen v und w zu verkürzen Die Distanz f zwischen v und w wird durch d + e ersetzt, wenn diese Summe kleiner ist als f . Nachfolgend ist der Algorithmus von Floyd in Java angegeben. Algorithmus 5.4 [Floyd] { for (v = 0; v < MAX; v++) { for (w = 0; w < MAX; w++) { if (v == w) { dist[v][w] = 0; } else { dist[v][w] = arc[v][w]; } } } for (u = 0; u < MAX; u++) { for (v = 0; v < MAX; v++) { for (w = 0; w < MAX; w++) { if (dist[v,u] + dist[u,w] < dist[v,w]) { dist[v,w] = dist[v,u] + dist[u,w]; } } 5-21 } } } In den Zeilen 2 bis 10 wird die Adjazenzmatrix arc in die Distanzmatrix dist kopiert. Dabei ist wichtig, dass in der Diagonale immer 0 eingetragen wird, da ein kürzester Weg nie eine Schlinge enthält. In den Zeilen 12 bis 20 werden nacheinander alle Knoten als Pivot verwendet und wenn nötig wird die Distanz zwischen zwei Knoten wie oben beschrieben angepasst. Beispiel 5.8 [Floyd] Wir wollen den Algorithmus an Hand des Graphen in der Abbildung 5-20 verfolgen. 0 90 10 4 30 65 1 10 3 50 20 2 Abbildung 5-20: Ein gewichteter Graph Die Distanzmatrix sieht nach den Zeilen 2 bis 4 des Algorithmus folgendermassen aus. 0 1 2 3 4 0 0 ∞ ∞ ∞ ∞ 1 10 0 ∞ ∞ ∞ 2 ∞ 50 0 20 ∞ 3 30 ∞ ∞ 0 ∞ 4 90 ∞ 10 65 0 Als erstes stellen wir fest, dass keine Kante zum Knoten 0 führt. Das heisst, über den Knoten 0 kann kein Pfad zu einem anderen Knoten führen. Wir müssen also diesen Pivot nicht berücksichtigen. Weiter stellen wir fest, dass keine Kante aus dem Knoten 4 herausführt und daher können wir auch diesen Knoten als Pivot ausschliessen. Nachstehend verfolgen wir den Algorithmus für die Knoten 1 bis 3. Pivot = 1 0 1 2 3 4 0 0 ∞ ∞ ∞ ∞ 1 10 0 ∞ ∞ ∞ Pivot = 2 2 60 50 0 20 ∞ 3 30 ∞ ∞ 0 ∞ 4 90 ∞ 10 65 0 0 1 2 3 4 0 0 ∞ ∞ ∞ ∞ Pivot = 3 1 10 0 ∞ ∞ ∞ 2 60 50 0 20 ∞ 5-22 3 30 ∞ ∞ 0 ∞ 4 70 60 10 30 0 0 1 2 3 4 0 0 ∞ ∞ ∞ ∞ 1 10 0 ∞ ∞ ∞ 2 50 50 0 20 ∞ 3 30 ∞ ∞ 0 ∞ 4 60 60 10 30 0 5.6.4.1 Wieso funktioniert Floyds Algorithmus Wir wollen noch begründen, wieso der Algorithmus von Floyd überhaupt funktioniert. Wir nehmen wieder an, dass die Knoten im Graphen von 0 bis n − 1 nummeriert sind. Ein k-Pfad von v nach w ist ein Pfad von v nach w, der keinen Zwischenknoten enthält, der grösser als k ist. v und w hingegen sind beliebieg. Ein (−1)-Pfad enthält nach Definition keine Zwischenknoten und kann daher nur ein Pfad der Länge 0 oder 1 (das heisst eine Kante) sein. Ein (n − 1)-Pfad kann beliebiege Knoten enthalten ist also ein beliebiger Pfad des Graphen. Wir beweisen nun durch vollständige Induktion die folgende Aussage A(k): Bevor wir in den Zeilen 12 bis 20 in Floyds Algorithmus die Variable u auf k + 1 setzen, so steht in dist[v, w] die Länge des kürzesten k-Pfades von v nach w oder ∞ falls kein solcher Pfad existiert (v, w = 0 . . . n − 1). BASIS: In diesem Fall nehmen wir k = −1 als Basis. Gerade bevor wir u auf 0 setzen, haben wir die Matrix arc nach dist koppiert (Zeilen 2 bis 10). Da die Kanten und Pfade der Länge 0 die einzigen (−1)-Pfade sind gilt die behauptung für k = −1. INDUKTION. Wir nehmen nun an, A(k) sei wahr und betrachten was passiert, wenn die Variable u im Algorithmus den Wert k + 1 hat. Wir nehmen nun an, P sei ein kürzester (k + 1)-Pfad von v nach w. Wir betrachten nun zwei Fälle. 1. Falls P ein k-Pfad ist so führt P nicht durch den Knoten k + 1. Das heisst, nach Induktionsannahme enthält dist[v,w] schon die Länge von P . 2. Ist P ein (k + 1)-Pfad, so können wir annehmen, dass der Knoten k + 1 nur einmal vorkommt (Zyklen verlängern den Weg). Das heisst, P setzt sich aus einem k-Pfad Q von v nach k + 1 und einem k-Pfad R von k + 1 nach w zusammen (siehe Abbildung 5-21). v k−Pfad Q k+1 k−Pfad R w Abbildung 5-21: Ein (k + 1)-Pfad ist aus zwei k-Pfade R und Q zusammengesetzt. Nach Induktionsannahme enthalten dist[v,k+1] und dist[k+1,w] die Längen von Q respektive R nach der k-ten Iteration im Algorithmus. Es ist leicht zu sehen, dass diese Längen während der k+1-ten Iteration nicht ändern können. Der Test auf der Zeile 15 des Algorithmus vergleicht für beliebige Knoten v und w den kürzesten k-Pfad mit der Summe der kürzesten k-Pfade von v nach k + 1 und von k + 1 nach w. Falls P durch den Knoten k + 1 führt, so muss diese Summe kleiner sein und daher wird sie auch in dist[v,w] eingetragen. Wir haben gezeigt, dass in beiden Fällen in der k+1-ten Iteration dist[v,w] auf die Länge des kürzesten (k + 1)-Pfad von v nach w gesetzt wird. Das heisst, die Aussage A(k + 1) ist wahr. Um den Beweis abzuschliessen setzen wir k = n − 1. Das heisst, nach n Iterationen enthält dist[v,w] den kürzesten (n − 1)-Pfad von v nach w. Da jeder Pfad im Graph ein (n − 1)-Pfad ist, haben wir gezeigt, dass dist[v,w] die Länge des kürzesten Pfades im Graphen enthält. 5-23 5.7 Flussprobleme Ein klassisches Problem bei gewichteten Graphen ist das Flussproblem. Bei diesem Problem repräsentiert jede Kante im gerichteten Graphen eine “Röhre”, die ein gegebenes Gebrauchsgut transportieren kann. Die Gewichte geben dabei die Kapazität der Röhre an, das heisst, die Menge des Gebrauchsgut, das pro Zeiteinheit transportiert werden kann. Das Problem liegt nun darin, die maximale Menge, die von einem gegebenen Knoten q (Quelle) zu einem anderen Knoten s (Senke) transportiert werden kann, zu bestimmen. Beispiel 5.9 [Computer Netzwerk] Wir können ein Computernetzwerk als gerichteten Graphen G darstellen. Jeder Knoten repräsentiert einen Computer. Eine gerichtete Kante (u, v) repräsentiert einen unidirektionalen Kommunikationskanal zwischen dem Computer u und dem Computer v. Das Gewicht w der Kante (u, v) bezeichnet die Bandbreite des Kanals, das heisst, die maximale anzahl Bytes, die pro Sekunde von u nach v gesendet werden kann. Falls wir nun eine Meldung vom Computer s zum Computer t senden wollen, so ist die beste Methode die Meldung in Packete zu zerteilen und diese durch G nach dem Algorithmus für den maximalen Fluss zu schicken. Weitere Flussprobleme sind Verkehrsprobleme oder di maximale Abwassermenge, die durch das Kanalisationssystem fliessen kann. 5.7.1 Flussgraphen Definition 5.12 [Flussgraph] Ein Flussgraph N besteht aus den folgenden Komponenten: • Ein zusammenhängender gerichteter Graph G = (V, E, c) mit nichtnegativen ganzen Gewichten. Das Gewicht einer Kante e heisst die Kapazität der Kante c(e). • Zwei verschiedene Knoten q ∈ V und s ∈ V . q heisst die Quelle und hat keine eingehende Kanten. s heisst die Senke und hat keine ausgehende Kanten. Um den maximalen Fluss durch einen Flussgraphen zu finden müssen wir zuerst den Begriff “Fluss” genau definieren. Definition 5.13 [Fluss] Ein Fluss f in einem Flussgraphen G ist eine Funktion f : E 7→ N mit den folgenden zwei Eigenschaften: 1. Kapazitätsbeschränkung: ∀e ∈ E : 0 ≤ f (e) ≤ c(e) 2. Flusserhaltung: ∀v ∈ V \ {q, s} : X e∈E − (v) f (e) = X f (e) e∈E + (v) Dabei bedeuten E − (v) die Menge der eingehenden und E + (v) die Menge der ausgehenden Kanten des Knotens v. 5-24 Die Menge f (e) heisst Fluss der Kante e. Der Wert eines Flusses f in einem Flussgraphen N , das wir mit |f | bezeichnen, ist die Summe der Flüsse, der ausgehenden Kanten der Quelle q. |f | = X f (e) e∈E + (q) Das heisst, ein Fluss darf die Kapazität der einzelnen Kanten nicht übersteigen. Ferner muss für alle Knoten ausser für q und s gelten, dass die Summe der eingehenden Flüsse gleich der Summe der ausgehenden Flüsse ist. In der Abbildung 5-22 ist ein Flussgraph dargestellt mit einem Fluss, der allen Regeln der Definition genügt. v1 v4 2/2 3/7 1/1 q 2/5 v3 2/5 5/6 4/6 s 2/3 1/3 4/8 v2 v5 4/9 Abbildung 5-22: Ein Flussgraph mit einem gültigen Fluss f (mit Wert |f | = 10) Wir werden sehen, dass die Summe der Flüsse der eingehenden Kanten der Senke s in einem Flussgraphen N gleich dem Wert des Flusses des Graphen N ist. |f | = X f (e) e∈E − (s) Das heisst, ein Fluss spezifiziert wie ein Gebrauchsgut von der Quelle q durch den Flussgraphen N in die Senke s fliesst. Definition 5.14 [Maximaler Fluss] Ein maximaler Fluss für einen Flussgraphen N ist ein Fluss f mit maximalem Wert über alle möglichen Flüsse von N . in der Figur 5-23 ist ein maximaler Fluss für den Graphen dargestellt. Im folgenden werden wir einen Algorithmus entwickeln, um maximale Flüsse in Flussgraphen zu finden. 5.7.2 Schnitte Das Konzept des Flusses ist mit dem Konzept des Schnitts eng verwandt. Ein Schnitt ist nichts anderes als eine Aufteilung der Knoten eines Flussgraphen in zwei disjunkte Teilmengen, mit 5-25 v1 3/7 1/1 q v4 2/2 4/5 v3 5/5 6/6 6/6 s 2/3 6/8 0/3 v2 v5 6/9 Abbildung 5-23: Ein maximaler Fluss f ∗ (mit Wert |f ∗ | = 14) für den Flussgraphen N der Quelle q in der einen Menge und der Senke s in der anderen. Definition 5.15 [Schnitt] ein Schnitt eines Flussgraphen N ist eine Partition X = (Vq , Vs ) der Knoten von N mit q ∈ Vq und s ∈ Vs . Eine Kante (u, v) mit u ∈ Vq und v ∈ Vs heisst Vorwärtskante des Schnittes X . Eine Kante (u, v) mit u ∈ Vs und v ∈ Vq heisst Rückwärtskante des Schnittes X . Die Kapazität des Schnittes X ist die Summe der Kapazitäten der Vorwärtskanten von X (die Rückwärtskanten werden nicht berücksichtigt). Die Kapazität des Schnittes X bezeichnen wir mit c(X ). Unter einem minimalen Schnitt von N versteht man einen Schnitt mit minimaler Kapazität unter allen Schnitten von N . Der Fluss durch den Schnitt X , ist gleich der Summe der Flüsse der Vorwärtskanten von X minus die Summe der Flüsse der Rückwärtskanten von X . Der Fluss durch den Schnitt X bezeichnen wir mit f (X ). In der Abbildung 5-24 sind drei Schnitte des Flussgraphen N dargestellt. X1 und X2 haben nur Vorwärtskanten. X3 hat auch eine Rückwärtskante. Die Kapazitäten sind c(X1 ) = 14, c(X2 ) = 18 und c(X3 ) = 22. Der Schnitt X1 ist ein minimaler Schnitt für N Der folgende Satz sagt aus, dass unabhängig vom Schnitt, der q und s trennt, der Fluss durch den Schnitt gleich dem Fluss des gesammten Netzwerkes ist. Satz 5.2 [Fluss durch einen Schnitt] Sei N ein Flussgraph und f ein Fluss von N . Für jeden Schnitt X von N ist der Wert von f gleich dem Fluss durch X . Das heisst, |f | = f (X ). Beweis: Wir betrachten die folgende Summe F = X v∈Vq X f (e) − e∈E + (v) X e∈E − (v) P f (e) P Nach der Flusserhaltungsregel gilt ∀v ∈ Vq \ {q}, dass e∈E + (v) f (e) − e∈E − (v) f (e) = 0. P Daher gilt, F = e∈E + (q) f (e) und damit F = |f |. Auf der anderen Seite gilt für jede Kante e, die weder Vorwärts- noch Rückwärtskante von X ist, dass F sowohl den Term f (e) wie den Term −f (e) enthält, die einander aufheben. Für 5-26 v1 7 1 q X1 v4 2 5 6 v3 5 6 s 3 3 8 v2 v5 9 X3 X2 Abbildung 5-24: Ein Flussgraph mit drei Schnitte X1 , X2 und X3 Vorwärtskanten von X is nur der Term f (e) in der Summe F und für Rückwärtskanten nur der Term −f (e). Das heisst F = f (X ). Der nächste Satz zeigt, dass die Kapazität eines Schnittes X eine obere Schranke für jeden Fluss durch X darstellt. Satz 5.3 [Fluss kleiner gleich als Kapazität] Sei N ein Flussgraph und X ein Schnitt von N . Für jeden Fluss f von N gilt, dass der Fluss durch X kleiner oder gleich der Kapazität von X ist. Das heisst, f (X ) ≤ c(X ). Beweis: Wir bezeichnen mit E + (X ) die Vorwärtskanten von X und mit E − (X ) die Rückwärtskanten von X . Nach der Definition von f (X ) haben wir f (X ) = X f (e) − e∈E + (X ) X f (e) e∈E − (X ) Falls wir alle negativen Terme aus der obigen Summe entfernen erhalten wir X f (X ) ≤ f (e) e∈E + (X ) Nach der Kapazitätsregel für jede Kante e, f (e) ≤ c(e) erhalten wir f (X ) ≤ X c(e) = c(X ) e∈E + (X ) Wenn wir die beiden Sätze kombinieren erhalten wir das wichtige Resultat: Satz 5.4 [Fluss und Schnitte] Gegeben sei ein Flussgraph N . Für jeden beliebiegen Fluss f von N und für jeden beliebiegen Schnitt X von N gilt, dass der Wert von f die Kapazität von X nicht übersteigt. Das heisst |f | ≤ c(X ). Mit anderen Worten ist die Kapazität eines minimalen Schnitts von N eine obere Schranke für den Wert von jedem Fluss f von N . 5-27 5.7.3 Maximaler Fluss Nach dem Satz 5.4 können wir sagen, dass ein maximaler Fluss nicht grösser sein kann als die Kapazität eines minimalen Schnittes. Wir werden zeigen, dass beide Grössen gleich sind. 5.7.3.1 Restkapazität und nutzbare Pfade Gegeben sei ein Flussgraph N und ein Fluss f von N . Ferner sei e eine gerichtete Kante (u, v) von u nach v. Die Restkapazität von u nach v für den Fluss f ist ∆f (u, v) = c(e) − f (e) für die Richtung v nach u wird die Restkapazität definiert als ∆f (v, u) = f (e) Sei π ein Pfad von q nach s bei dem die Kanten in beiden Richtungen durchlaufen werden können. Das heisst eine Kante e = (u, v) kann sowohl von u nach v wie auch von v nach u durchlaufen werden. Eine Kante e = (u, v) die auf dem Pfad π von q nach s von u nach v passiert wird heisst Vorwärtskante von π sonst Rückwärtskante. Wir wollen die Definition der Restkapazität auf die Kanten e von π erweitern, so dass ∆f (e) = ∆f (u, v). Das heisst, ∆f (e) = ( c(e) − f (e) falls e eine Vorwärtskante ist f (e) falls e eine Rückwärtskante ist. Wir sind nun in der Lage die Restkapazität eines Pfades π und die nutzbaren Pfade zu definieren. Definition 5.16 [nutzbarer Pfad] Die Restkapazität eines Pfades π ist die minimale Restkapazität über die Kanten des Pfades ∆f (π) = min ∆f (e) e∈π Ein nutzbarer Pfad für den Fluss f im Flussgraph N ist ein Pfad π von der Quelle q zur Senke s mit ∆f (π) > 0. Für die Kanten e eines nutzbaren Pfades π gilt: • f (e) < c(e) falls e eine Vorwärtskante ist • f (e) > 0 falls e eine Rückwärtskante ist. In der Abbildung 5-25 ist ein Beispiel für einen nutzbaren Pfad dargestellt. Der folgende Satz zeigt, dass wenn zu einem Fluss die Restkapazität eines nutzbaren Pfades addiert wird, ein neuer gültiger Fluss f ′ entsteht. Satz 5.5 [Fluss und nutzbare Pfade] Sei π ein nutzbarer Pfad für den Fluss f im Flussgraphen N . Dann existiert ein Fluss f ′ von N mit dem Wert |f ′ | = |f | + ∆f (π). Beweis: Wir berechnen den neuen Fluss f ′ indem wir den Fluss aller Kanten im Pfad π folgendermassen verändern. 5-28 v1 5/8 2/5 3/6 q v4 2/4 v3 2/3 3/7 4/9 s 3/3 3/8 0/3 v2 v5 3/3 Abbildung 5-25: Ein nutzbarer Pfad π mit ∆f (π) = 2 für den Fluss f im Flussgraphen N ′ f (e) = ( f (e) + ∆f (π) falls e eine Vorwärtskante ist f (e) − ∆f (π) falls e eine Rückwärtskante ist. Da ∆f (π) ≥ 0 die minimale Restkapazität über alle Kanten in π ist, wird für eine Vorwärtskante keine Kapazitätsbeschränkung verletzt, falls wir ∆f (π) zum Fluss addieren. Aus dem gleichen Grund kann der Fluss auf einer Rückwärtskante nicht kleiner als 0 werden. Also ist f ′ ein gültiger Fluss für N und der Wert von f ′ ist |f | + ∆f (π), da die erste Kante von π eine ausgehende Kante von q ist. In der Abbildung 5-26 ist der Flussgraph dargestellt, der nach dem Addieren des Restflusses ∆f (π) = 2 aus der Abbildung 5-25 entsteht. v1 5/8 2/5 1/6 q v3 2/3 5/7 v4 4/4 6/9 s 3/3 3/8 2/3 v2 3/3 v5 Abbildung 5-26: Der Fluss f ′ der aus dem Fluss f entsteht Nach Satz 5.5 impliziert die Existenz eines nutzbaren Pfades π für einen Fluss f , dass f nicht maximal ist. Was ist nun, wenn kein nutzbarer Pfad π für den Fluss f im Flussgraphen N 5-29 existiert? In diesem Fall ist f maximal wie der folgende Satz zeigt. Satz 5.6 [Maximaler Fluss] Falls ein Flussgraph N keinen nutzbaren Pfad für den Fluss f besitzt, dann ist f ein maximaler Fluss. In diesem Fall existiert ein Schnitt X von N mit |f | = c(X ). Beweis: Sei f ein Fluss für N und es existiert keinen nutzbaren Pfad für f . Wir konstruieren einen Schnitt X = (Vq , Vs ) indem wir alle Knoten v in Vq aufnehmen, für die ein Pfad von q nach v existiert mit der Bedingung, dass die Restkapazität aller Kanten des Pfades nicht null ist. In diesem Fall gehört aber s zu Vs , da kein nutzbarer Pfad für f existiert. Das heisst, X = (Vq , Vs ) ist ein Schnitt. Nach der Definition von X haben alle Vorwärts- und Rückwärtskanten des Schnittes X eine Restkapazität von 0. Das heisst f (e) = ( c(e) falls e eine Vorwärtskante von X ist 0 falls e eine Rückwärtskante von X ist. Das heisst, f (X ) = c(X ) und nach Satz 5.2 gilt: |f | = c(X ) Nach Satz 5.4 folgt, dass f maximal ist. Als Konsequenz aus den Sätzen 5.4 und 5.6 ergibt sich der fundamentale Satz Satz 5.7 [Max-Flow, Min-Cut Theorem] Der Wert eines maximalen Flusses ist gleich der Kapazität eines minimalen Schnittes. 5.7.3.2 Der Algorithmus von Ford-Fulkerson Wir wenden uns nun dem Problem zu, einen maximalen Fluss in einem Flussgraphen zu finden. Die Idee des Algorithmus von Ford-Fulkerson ist, nutzbare Pfade zu finden und die Restkapazität zum Fluss zu addieren. Am Anfang ist der Fluss aller Kanten gleich null. Solange wir noch einen nutzbaren Pfad finden, wird die Restkapazität dieses Pfades zum Fluss addiert (wie im Beweis vom Satz 5.5). Der Algorithmus terminiert, wenn kein nutzbarer Pfad mehr gefunden werden kann. Der Vorgang ist in der Abbildung 5-27 abgebildet. Als erstes wollen wir überlegen, wie wir in einem Flussgraphen N mit fluss f einen Nutzbaren Pfad finden können. Wir benutzen dazu den Algorithmus zum traversieren von Graphen mit folgenden Modifikationen. Statt vom aktuellen Knoten v aus alle inzidenten Kanten zu betrachten, berücksichtigen wir nur die folgenden Kanten: • Aus v ausgehende Kanten e, falls f (e) < c(e) • In v eingehende Kanten e, falls f (e) > 0 Wir beginnen das Traversieren nun bei der Quelle q und halten an, wenn wir den Knoten s erreichen oder der Graph vollständig traversiert ist. In letzterem Fall gibt es keinen nutzbaren Pfad mehr. 5-30 v1 0/8 0/6 q v4 0/4 v3 0/3 0/7 0/5 0/9 v1 4/8 0/8 q v2 4/4 v1 0/7 4/9 4/8 s 3/3 0/3 s 3/3 0/8 0/3 v5 4/4 v4 0/6 q 0/8 0/9 0/3 v5 v3 3/3 0/7 v4 0/5 v3 3/3 0/3 0/6 0/5 0/6 q v4 0/4 0/8 s 0/3 0/3 v2 v1 v3 3/3 3/7 0/5 4/9 s 3/3 0/3 3/8 v2 0/3 v5 v2 3/3 v5 v1 4/4 v4 v1 4/4 v4 8/8 4/5 4/6 q v3 3/3 3/7 8/9 s 3/3 0/3 v2 8/8 q 3/8 3/3 4/6 v5 v3 3/3 4/7 5/5 9/9 s 3/3 1/3 v2 3/8 3/3 v5 Minimaler Schnitt Abbildung 5-27: Der Algorithmus von Ford-Fulkerson Nachfolgend ist der Algorithmus in Pseudocode angegeben. Algorithmus 5.5 [Ford-Fulkerson] foreach (e ∈ N ) f (e) := 0 done := false while (!done) { Suche einen nutzbaren Pfad π in N für f if (Pfad π existiert) { ∆ := ∞ foreach (e ∈ π) if (∆f (e) < ∆) ∆ := ∆f (e) foreach (e ∈ π) if (e ist eine Vorwärtskante) f (e) := f (e) + ∆ else f (e) := f (e) − ∆ } else done := true } Komplexität von Ford-Fulkerson Die Analyse des Algorithmus von Ford-Fulkerson is nicht so einfach. Das ist weil der Algorithmus nicht genau spezifiziert wie nutzbare Pfade gefunden werden sollen. Diese Wahl hat 5-31 aber einen entscheidenden Einfluss auf die Komplexität. Sei n = |V | die Anzahl Knoten und m = |E| die Anzahl Kanten des Flussgraphen und sei f ∗ ein maximaler Fluss. Da der zugrundeliegende Graph zusammenhängend ist gilt, n ≤ m + 1. Man beachte, dass jedesmal wenn wir einen nutzbaren Pfad finden der Fluss mindestens um 1 erhöht wird. Dies ist der Fall, weil für jede Kante sowohl die Kapazität wie auch der Fluss ganze nichtnegative Zahlen sind. Daher ist |f ∗ | eine obere Schranke für die Anzahl mal wo der Algorithmus einen nutzbaren Pfad sucht. Wir können mit der Graphtraversierung einen nutzbaren Pfad in O(m) Zeit finden. Wir können also sagen, dass die Komplexität des Algorithmus O(|f ∗ |m) ist. In der Abbildung 5-28 ist ein Beispiel wo dieser Wert tatsächlich erreicht wird, wenn die nutzbaren Pfade ungünstig gewählt werden. Der Algorithmus von Ford-Fulkerson ist ein sogenannter pseudo polynomialer Algorithmus. Dies ist, weil seine Komplexität nicht nur von der grösse des Inputs (n + m) sondern auch vom Wert eines numerischen Parameters |f ∗ | ist. Daher kann die Ausführung von Ford-Fulkerson sehr langsam sein, wenn |f ∗ | sehr gross ist und die nutzbaren Pfade schlecht gewählt werden. v1 0/1,000,000 0/1,000,000 0/1 q 0/1,000,000 s 0/1,000,000 q 0/1,000,000 1/1,000,000 1/1 s 1/1,000,000 0/1,000,000 v2 v2 v1 v1 0/1,000,000 1/1,000,000 1/1 q v1 1/1,000,000 1/1,000,000 s q 0/1,000,000 1/1,000,000 0/1 1/1,000,000 v2 s 1/1,000,000 v2 Abbildung 5-28: Falls die nutzbaren Pfade zwischen (q, v1 , v2 , s) und (q, v2 , v1 , s) alternieren so braucht es 2,000,000 Iterationen, obschon 2 Iterationen genügen würden. 5.7.3.3 Der Algorithmus von Edmonds-Karp Der Algorithmus von Edmonds-Karp ist eine Variation von Ford-Fulkersons Algorithmus. Der Unterschied liegt in der Wahl der nutzbaren Pfade. Bei jeder Iteration wird ein nutzbarer Pfad mit einer minimalen Anzahl Kanten gewählt. Dies kann mit einer modifizierten Breitensuche in O(m) Zeit gelöst werden. Wir werden zeigen, dass die obere Schranke für die Anzahl Iterationen bei nm liegt. Das heisst der Edmonds-Karp Algorithmus hat dann eine Komplexität von 5-32 O(nm2 ) Gegeben sei ein Flussgraph N und ein Fluss f für N . Für jeden Knoten v definieren wir df (v) als die Länge eines minimalen (bezüglich der Anzahl Kanten) nutzbaren Weges von q nach v. Als erstes hält der folgende Satz fest, dass bei jeder Iteration des Algorithmus df (v) monoton steigend ist. Satz 5.8 [Distanz und Fluss] Sei g ein Fluss der aus dem Fluss f durch addieren der Restkapazität eines nutzbaren Pfades π entstanden ist. Dann gilt für alle Knoten im Flussgraphen df (v) ≤ dg (v) Diesen Satz wollen wir nicht beweisen aber den folgenden Satz, der uns das gewünschte Resultat liefert. Satz 5.9 [Anzahl Iterationen Edmond-Karp] Wenn der Algorithmus von Edmonds-Karp auf einem Flussgraph mit n Knoten und m Kanten ausgeführt wird, so ist die Anzahl Iterationen nicht grösser als nm. Beweis: Sei fi der Fluss im Flussgraphen vor der i-ten Iteration und πi der bei dieser Iteration benutzte nutzbare Pfad. Man sagt dass die Kante e ∈ πi kritisch ist für πi falls ∆f (e) = ∆f (πi ). Es ist klar, dass jeder nutzbare Pfad mindestens eine kritische Kante besitzt. Wir betrachten nun zwei Knoten u und v die durch eine Kante e verbunden sind. Ferner nehmen wir an, dass e kritisch ist für πi und πk mit i < k und von u nach v geht (e = (u, v)). Aus diesen Voraussetzungen können wir folgendes ableiten: • ∆fi (u, v) > 0 weil (u, v) zum nutzbaren Pfad πi gehört. • ∆fi+1 (u, v) = 0 wei die Kante kritisch ist • ∆fk (u, v) > 0 weil (u, v) zum nutzbaren Pfad πk gehört. Das heisst, es muss ein j existieren mit i < j < k dessen nutzbarer Pfad πj die Kante e von v nach u passiert. Daraus ergibt sich: dfj (u) = dfj (v) + 1 weil πj ein kürzester Pfad ist ≥ dfi (v) + 1 nach Satz 5.8 ≥ dfi (u) + 2 weil πi ein kürzester Pfad ist Da ein nutzbarer Pfad immer kürzer ist als die Anzahl Knoten n kann jede Kante e während der Ausführung des Algorithmus höchstens n mal kritisch sein (n/2 mal für jede Richtung in die sie traversiert werden kann). Das heisst die Anzahl Iterationen kann nicht grösser sein als nm. Da die Suche eines nutzbaren Pfades mit einer Breitensuche in O(m) Zeit realisiert werden kann erhalten wir das gewünschte Resultat. Satz 5.10 [Komplexität von Edmonds-Karp] Gegeben sei ein Flussgraph N mit n Knoten und m Kanten, so wird der maximale Fluss vom Algorithmus von Edmonds-Karp in O(nm2 ) Zeit berechnet. 5-33 Kapitel 6 Verarbeitung von Strings Unter einem String verstehen wir eine beliebige Sequenz von Zeichen aus einem gegebenen Alphabet. Für die Wahl der Algorithmen sind zwei Faktoren von Bedeutung: 1. Länge des Strings (einige Zeichen oder ein ganzes Buch). Fast alle Algorithmen in diesem Kapitel sind nur sinnvoll, wenn sie auf sehr langen Strings angewendet werden. 2. Das Alphabet aus denen die Zeichen stammen (alphabetisch, alphanumerisch, binär usw). 6.1 Suchen in Strings Beim Suchen in Strings geht es darum in einem String einen Teilstring zu finden, welcher mit einem gegebenen String (Muster) übereinstimmt. Betrachtet man das Muster als Schlüssel, besteht eine Analogie zu den bisher behandelten Suchproblemen. Im folgenden wollen wir nun einige Algorithmen zur Lösung dieses Problems betrachten. 6.1.1 Brute Force Algorithmus Bei diesem Algorithmus wird das Muster Zeichenweise mit dem String verglichen. Sobald zwei Zeichen unterschiedlich sind wird das Muster um eine Stelle nach rechts verschoben und der Vergleich beginnt von neuem, bis eine Stelle im String gefunden wird, wo alle Zeichen des Musters mit den Zeichen des Strings übereinstimmen. Dieser Algorithmus funktioniert im Allgemeinen für normale Texte im Vergleich zu komplizierten Verfahren erstaunlich gut. Andererseits kann das Verfahren sehr langsam werden. Vor allem, wenn das Alphabet binär ist. Falls der String die Länge N hat und das Muster die Länge M , so braucht der Brute-Force Algorithmus im schlechtesten Fall O(N · M ) Vergleiche um das Muster zu finden wie das folgende Beispiel zeigt. Beispiel 6.1 [Brute Force] String: Muster: 111111111111111111110 N = 20 1110 M = 4 Das Muster wird N − M (im Beispiel also 16) mal verschoben. Wir müssen dabei jedesmal bis zum Ende des Musters Vergleichen um zu merken, dass das Muster nicht mit dem String übereinstimmt. Das heisst, wir brauchen 16 · 4 Vergleiche um das Muster zu finden. 6-1 Das Beispiel 6.1 zeigt also, dass der schlechteste Fall beim Brute Force Algorithmus (N −M )·M Vergleiche benötigt. Falls N viel grösser ist als M ist dies gleich O(N · M ). 6.1.2 Knuth-Morris-Pratt Algorithmus Im folgenden werden wir immer die beiden Zeiger i und j verwenden, die angeben, welches Zeichen im String bzw. im Muster gerade miteinander verglichen werden. Eine charakteristik des Brute Force Algorithmus ist, dass der Zeiger i bei nicht Übereinstimmung der Zeichen auf (i-(j-1)) gesetzt werden muss (das heisst, falls j>1 ist, muss i zurückgesetzt werden). Der Algorithmus von Knuth Morris und Pratt hingegen setzt den Zeiger i nie zurück. Der String wird also streng von links nach rechts gelesen. 6.1.2.1 Idee des Algorithmus Falls die ersten k Zeichen des Muster mit den Zeichen des Strings übereinstimmen, so kann diese Information verwendet werden, um zu bestimmen wie weit das Muster weitergeschoben werden darf. Beispiel 6.2 [Information über das Muster] Wir wollen die Idee des Algorithmus an einem einfachen Beispiel illustrieren. String: Muster: beliebig 10000 Wenn wir nun beim j-ten Zeichen des Musters keine Übereinstimmung haben, so wissen wir, das die j-1 vorhergehenden Zeichen im String keine 1 enthalten. Da das Muster aber mit einer 1 beginnt, so können wir das Muster bis zur i-ten Position im String durchschieben und der Vergleich bei der Position i+1 weiterfahren. Dieser Algorithmus kann aber nur verwendet werden, wenn das erste Zeichen im Muster nicht noch einmal im Muster erscheint. Das im Beispiel 6.2 angegebene Verfahren ist nicht besonders nützlich, da solche spezielle Muster nicht häufig vorkommen. Der Algorithmus von Knuth Morris und Pratt ist eine Verallgemeinerung dieses Verfahrens. Erstaunlicherweise ist es tatsächlich möglich den Algorithmus so zu formulieren, dass der Zeiger i nie dekrementiert werden muss. Wir können im allgemeinen nicht wie im Beispiel 6.2 bei der Feststellung einer Nichtübereinstimmung das ganze Muster überspringen. Es wäre ja möglich, dass das Muster an der Stelle der Nichtübereinstimmung mit sich selbst in Übereinstimmung gebracht werden könnte. Beispiel 6.3 [Muster wird verpasst] String: Muster: 11101001 1101 in diesem Fall haben wir bei i=2 und j=2 die erste Nichtübereinstimmung. Würden wir jetzt wie im vorigen Beispiel das Muster durchschieben, erhalten wir die folgende Situation: String: Muster: 11101001 1101 6-2 In diesem Fall verpassen wir gerade die richtige Stelle, die sich bei i=1 befindet. Das Problem im Beispiel 6.3 ist, dass wir die Wiederhohlungen des Anfangs des Musters im Muster selbst nicht berücksichtigen. Um dies zu tun führen wir eine Hilfstabelle next ein, die für jedes Zeichen im Muster mu den folgenden Wert bestimmt: next[i] = −1 falls i = 0 next[i] = max({0} ∪ {j|j > 0 ∧ j < i ∧ (mu[i − j + k] = mu[k] k = 0, . . . j − 1)}) sonst Das heisst, in der Tabelle next wird gespeichert, wieviele Zeichen links vom i-ten Zeichen mit dem Anfang des Musters übereinstimmen. Beispiel 6.4 [Tabelle zum Muster] Muster: 1001011100111 Die Tabelle next zu diesem String sieht nun folgendermassen aus: i next[i] 0 -1 1 0 2 0 3 0 4 1 5 2 6 1 7 1 8 1 9 2 10 3 11 4 12 1 Wenn nun die Zeiger i im String und j im Muster auf nicht übereinstimmende Zeichen zeigen, so beginnt die nächste mögliche Position für eine Übereinstimmung mit dem Muster bei der Position i - next[j] im String. Doch laut Definition der Tabelle next stimmen die ersten next[j] Zeichen in dieser Position mit den ersten next[j] Zeichen des Musters überein, so dass der Zeiger i unverändert bleibt und wir den Zeiger j einfach auf next[j] setzen können. Das heisst, der Zeiger i muss nie zurückgesetzt werden. Der String kann von links nach rechts gelesen werden. Beispiel 6.5 [Beispiel Knuth-Morris-Pratt] String: Muster: 100101110001100000 . . . 1001011100111 ↑ Vershieden bei i=j=10 Die next Tabelle für dieses Muster ist im Beispiel 6.4 angegeben. Wir sehen, dass der String und das Muster bei i = 10 und j = 10 nicht übereinstimmen. Das heisst, wir müssen j auf next[j] setzen und weiter mit i=10 weiterfahren. String: Muster: 100101110001100000 . . . 1001011100111 ↑ Ab hier muss weiter verglichen werden. Die folgende Java Klasse KnuthMorrisPratt implementiert den Algorithmus. Algorithmus 6.1 [Knuth-Morris-Pratt] public class KnuthMorrisPratt { public static int searchPattern(String str, String muster) { int next[] = initnext(muster); int n = str.length(); int m = muster.length(); int j = 0, i = 0; 6-3 while (i < n) { if (str.charAt(i) == muster.charAt(j)) { if (j == m - 1) { return i - m + 1; } ++i; ++j; } else if (j > 0) { j = next[j]; } else { ++i; } } return -1; } private static int[] initnext(String mu) { int m = mu.length(); int i = 0, j = -1; int next[] = new int[m]; while (i < m) { next[i] = j; while (j >= 0 && mu.charAt(i) != mu.charAt(j)) { j = next[j]; } ++i; ++j; } return next; } } Interessant ist vor allem die Funktion initnext. Im Grunde genommen ist es genau derselbe Algorithmus wie in der Hauptfunktion. Der Unterschied ist, dass wir das Muster auf Übereinstimmung mit sich selbst überprüfen. Unmittelbar nachdem i und j inkrementiert worden sind steht fest, dass die ersten j Zeichen des Musters mit den Zeichen mu[i-j-1],. . . mu[i-1] übereinstimmen. Das heisst, j ist gerade der richtige Wert von next[i]. Die Komplexität des Algorithmus ist gleich O(N + M ). 6.1.3 Boyer-Moore Algorithmus Der Algorithmus von Knuth Morris und Pratt ist nur dann effizient, wenn das Alphabet klein ist, und die Wahrscheinlichkeit, dass sich gewisse Zeichenkombinationen sowohl im Muster wie auch im String wiederhohlen, relativ gross ist. Ist hingegen das Alpahbet gross (z.B. die druckbaren ASCII-Zeichen), so kann eine weit effizientere Methode entwickelt werden. 6-4 Die Idee besteht darin, das Muster von rechts nach links zu untersuchen und dabei das Verschieben sowohl auf der Basis des nicht übereinstimmenden Zeichens im String als auch auf der Basis des Musters zu berechnen. Wir betrachten dazu wieder ein Beispiel. Beispiel 6.6 [Boyer-Moore] Die Situation ist in der folgenden Abbildung dargestellt: A STRING SEARCHING EXAMPLE CONSISTING OF ... v | | | | | | STING v | | | | | STING v | | | | STING v | | | STING v | | STING V | STING V STING STING Die Methode funktioniert nun folgendermassen. Da wir bei der Überprüfung im Text von rechts nach links vorgehen, vergleichen wir als erstes ein R mit einem G. Wir stellen nun fest, dass die Zeichen nicht übereinstimmen. Ferner sehen wir, dass das zeichen R im Muster überhaupt nicht vorkommt. Daher können wir das Muster ganz am Buchstaben R vorbeischieben. Der nächste Vergleich geschieht mit dem S im String und dem G im Muster. Wieder sind die Zeichen verschieden diesmal jedoch kommt das S auch im Muster vor, daher können wir das Muster nur soweit schieben, bis das S im Muster mit dem S im Text übereinstimmt. Beim Vorletzten Suchen stimmen das T im String und das G im Muster nicht überein. Wir können soviel schieben, bis das T vom Muster mit dem T im String übereinstimmt. Um den obigen Algorithmus zu implementieren brauchen wir eine Tabelle skip, die uns für jedes Zeichen des Alphabets angibt, wie weit wir im String springen können, falls dieses Zeichen mit dem Muster nicht übereinstimmt. Die Einträge in der Tabelle skip können für ein beliebiges Zeichen x folgendermassen berechnet werden: • skip[index(x)] = M falls x nicht im Muster vorkommt. • skip[index(x)] = M - j - 1 falls x im Muster an der Stelle j vorkommt. Das heisst, wir merken uns die Distanz vom Zeichen zum Ende des Musters. Um soviele Positionen können wir dann den Index i erhoehen, damit das im Muster übereinstimmende Zeichen an die richtige Stelle kommt. Falls das Zeichen x im Muster mehrmals vorkommt, so wird diese Distanz für das lezte vorkommende x berechnet. Die folgende Java Klasse BoyerMoore implementiert den Algorithmus. Dabei wird angenommen, dass das Alphabet den ASCII-Zeichensatz umfasst. Algorithmus 6.2 [Boyer-Moore] public class BoyerMoore { private static int skip[] = new int[127]; public static int searchPattern(String str, String muster) { int n = str.length(); 6-5 int m = muster.length(); int j, i, sk; initskip(muster); i = m - 1; j = m - 1; while (i < n && j >= 0) { while (i < n && str.charAt(i) != muster.charAt(j)) { sk = skip[(int)str.charAt(i)]; i += (m-j > sk) ? m-j : sk; j = m - 1; } --i; --j; } return (i < n) ? i+1 : -1; } private static void initskip(String muster) { int m = muster.length(); for (int j = 0; j < muster.length(); j++) skip[j] = m; for (int j = 0; j < m; j++) skip[(int)muster.charAt(j)] = m - j - 1; } } Im schlechtesten Fall braucht diese Methode O(N · M ) Schritte um das Problem zu loesen. Beispiel 6.7 [Worst Case Boyer-Moore] N String: Muster: }| z { aaaaaaaaaaaaa . . . a b aaaa | {z. . . a} M −1 In diesem Fall braucht es immer M vergleiche um festzustellen, dass das Muster nicht passt. Anschliessend kann das Muster nur um eine Position nach rechts verschoben werden. Daher braucht es in diesem Fall (N − M ) · M Vergleiche also O(N · M ). Ist aber das Alphabet relativ gross und das Muster kurz, so ist die Wahrscheinlichkeit, dass das Muster einfach durchgeschoben werden kann gross und wir erhalten im Durchschnitt eine Komplexität von O(N/M ) Vergleiche. 6.2 6.2.1 Datenkomprimierung Lauflängenkodierung Das einfachste Vorkommen von Redundanz in einem File sind lange Folgen sich wiederholender Zeichen, die wir Läufe (runs) nennen wollen. Diese Redundanz kann durch die Angabe des 6-6 sich wiederholenden Zeichens und der Anzahl Wiederholungen eliminiert werden. Um Läufe zu kodieren werden 3 Bytes verwendet. Ein Escape-Zeichen, das angibt, dass eine solche Kodierung folgt, ein Byte für die Anzahl und ein Byte für die Angabe des Zeichens. Da die Kodierung 3 Bytes benötigt, werden nur Läufe kodiert, die länger sind als 3. Beispiel 6.8 [Lauflängenkodierung] String: Escape-Zeichen: Kodierung: aaaaaabcbcccccccsssssccdddeaaaaaaaaaaaaaa \ \<6>abcb\<7>c\<5>sccddde\<14>a In diesem Fall hat der ursprüngliche String die Länge 41 der kodierte String nur noch die Länge 21. Wir sehen auch, dass der Lauf ddd nicht kodiert wird, da er nur die Länge 3 hat. Ein Problem ist noch das Escape-Zeichen. Was machen wir, wenn dieses Zeichen im String vorkommt? Dieses Problem wird gelöst, indem die Anzahl nach dem Escape-Zeichen auf 0 gesetzt wird. Das Escape-Zeichen wird also als \<0> kodiert. Beispiel 6.9 [Escape Zeichen] String: Escape-Zeichen: Kodierung: \a\b\c\d \ \<0>a\<0>b\<0>c\<0>d Es ist interessant, dass in diesem Fall der kodierte String länger als der ursprüngliche String ist. Insbesondere wird ein schon kodierter String durch eine zweite Kodierung verlängert. Für Textdateien ist dieses Verfahren nicht effizient, da sich in einem normalen Text keine Zeichen mehr als 3 mal Wiederholen. 6.2.2 Kodierung mit variabler Länge Die Kodierung mit variabler Länge kann in Textdateien (und auch anderen Dateien) eine beträchtliche Platzeinsparung bedeuten. Die Idee ist eigentlich ganz einfach. Statt für jedes Zeichen 7 oder 8 Bit zu verwenden, werden Zeichen, die häufig vorkommen, nur mit wenigen Bits dargestellt und Zeichen die selten vorkommen mit mehr Bits. Wir nehmen an, dass der Text nur aus grossen Buchstaben und Blanks besteht. Für die Kodierung werden in diesem Fall nur 5 Bits benötigt, wobei Blank mit 0 Kodiert wird. Der i-te Buchstabe im Alphabet wird als Binäre Zahl i dargestellt. Beispiel 6.10 [Variable Länge] String: Kodierung: EABAABABCCD 00101’00001’00010’00001’00001’00010’00001’00010 00011’00011’00100 Das Zeichen ’ gehört nicht zur Kodierung, sondern dient nur der besseren lesbarkeit. Diese Kodierung verwendet 45 Bits. Wir können nun versuchen den String zu komprimieren, indem wir zuerst feststellen, wie oft die einzelnen Zeichen vorkommen und anschliessend dem häufigsten Charakter die kürzeste Sequenz zuordnen. 6-7 Beispiel 6.11 [Variable Länge 2] Im String des Beispiels 6.10 kommt das ’A’ 4 mal, das ’B’ 3 mal, das C 2 mal das D und das E je 1 mal vor. Wir könnten also die Kodierung folgendermassen vornehmen: A = 0, B = 1, C = 01, D = 10 E = 11 Wir erhalten mit dieser Kodierung das folgende Resultat. String: Kodierung: EABAABABCCD 11 0 1 0 0 1 0 1 01 01 10 Das Resultat im Beispiel 6.11 is viel kürzer als das ursprüngliche. Im Prinzip brauchen wir jetzt nur 15 Bits um den String zu kodieren. Es sind aber noch nicht alle Probleme gelöst. Wollen wir den String dekodieren, so braucht es einen Begrenzer, um die Zeichen auseinanderhalten zu können. Ferner kann der kodierte String nur dekodiert werden, wenn der gewählte Code bekannt ist. Das heisst, der gewählte Code muss im kodierten String mitgespeichert werden. Um die Probleme zu lösen stellen wir fest, dass wir keine Begrenzer brauchen, wenn wir eine Kodierung wählen, bei der alle Zeichen anders beginnen. Beispiel 6.12 [Prefix verschieden] Wir wählen den Code nun folgendermassen aus: A = 11, B = 00, C = 10, D = 010 und E = 011 Mit dieser Wahl erhalten wir die folgende Kodierung: String: Kodierung: EABAABABCCD 011’11’00’11’11’00’11’00’10’10’010 Auch hier ist das Zeichen ’ nur zur besseren Lesbarkeit eingefügt worden. Wir brauchen mit dieser Kodierung nur 24 statt 45 Bits und wenn der Code bekannt ist, so ist die Dekodierung eindeutig. Das Problem ist nun, zu einem gegebenen Text den optimalen Code zu finden, der eindeutig dekodierbar ist. Zu diesem Zweck verwenden wir einen Trie. Der Trie ist ein spezieller binärer Baum, der Nachfolgend definiert ist. Definition 6.1 [Trie] Ein Trie (oder digitaler Suchbaum) ist ein binärer Baum mit den folgenden Eigenschaften: • Innere Knoten haben immer zwei Nachfolger. • Äussere Knoten enthalten ein Zeichen des Alphabets oder NULL. • Wenn ein Zeichen des Alphabets im Baum enthalten ist, so kann es folgendermassen gefunden werden: 1. Wir starten bei der Wurzel des Baumes und lesen das erste Bit des Zeichens. Ist dieses Bit 0 so gehen wir zum linken Nachfolger sonst zum rechten. 2. Nun wird das nächste Bit gelesen. Ist der Wert 0 gehen wir nach links sonst nach rechts. 6-8 3. Wenn man zu einem äusseren Knoten (Blatt) gelangt, so enthält dieser das gewünschte zum gegebenen Code gehörige Zeichen (oder NULL, wenn der Code keinem Zeichen des Alphabets entspricht). Beispiel 6.13 [Trie] In der Abbildung 6-1 ist der zum Code A = 11, B = 00, C = 10, D = 010 und E = 011 entsprechende Trie dargestellt. 0 0 1 1 0 B 1 C 0 A 1 D E Abbildung 6-1: Trie zum Code A = 11, B = 00, C = 10, D = 010 und E = 011 Die Dekodierung eines gegebenen Strings ist nun sehr einfach, wenn der Trie bekannt ist. In diesem Fall müssen wir im kodierten String die Bits von links nach rechts lesen und den entsprechenden Weg im Trie verfolgen. Wenn wir auf ein Blatt stossen, haben wir das nächste Zeichen gefunden und fahren mit dem nächsten Bit bei der Wurzel des Tries weiter. Dieser Vorgang ist für den String 011’11’00’11’11’010 (EABAAD) in der Abbildung 6-2 angegeben. 011 11 00 11 11 010 011 11 00 11 11 010 0 0 0 1 0 B 0 1 C 0 A A B C A E A E 011 11 00 11 11 010 B 0 C D C 1 0 0 1 1 D 1 1 B E 0 0 0 0 011 11 00 11 11 010 1 1 1 1 1 0 0 0 1 C D 1 D B E 0 0 0 0 0 011 11 00 11 11 010 1 1 1 D 011 11 00 11 11 010 1 0 1 A 0 B C 0 1 E 1 1 D 1 A 1 E Abbildung 6-2: Dekodieren mit Hilfe eines Tries Es bleibt noch die Aufgabe, zu einem gegebenen String den optimalen Trie aufzubauen. 6.2.3 Erzeugung des Huffman-Codes Um einen guten Code zu bekommen, müssen wir dafür sorgen, dass Zeichen die häufig vorkommen möglichst nahe bei der Wurzel zu liegen kommen (kurzer Code) und Zeichen die fast 6-9 nie vorkommen am weitesten von der Wurzel entfernt sind (langer Code). Eine Möglichkeit ist der sogenannte Huffman-Code, der im folgenden beschrieben ist. Der erste Schritt besteht darin, die Häufigkeit jedes Zeichens innerhalb des zu kodierenden Strings zu ermitteln und zu speichern. Der nächste Schritt ist der Aufbau des Tries gemäss den Häufigkeiten. Dabei gehen wir fogendermassen vor: 1. Wir betrachten alle Zeichen, zusammen mit ihrer Häufigkeit im String, als Wurzeln eines binären Waldes (Menge von binären Bäumen). Zeichen des Alphabets mit Häufigkeit null werden dabei nicht berücksichtigt. 2. Die zwei Bäume im Wald, die in der Wurzel die geringste Häufigkeit besitzen, werden jetzt ausgewählt (falls mehr als zwei Bäume mit dieser Eigenschaft existieren, so ist es gleichgültig, welche zwei benutzt werden). 3. Nun kreieren wir eine neue Wurzel (und damit auch einen neuen Baum), der die beiden ausgewählten Bäume als Nachfolger hat. Die Häufigkeit in der neuen Wurzel ist die Summe der Häufigkeiten der beiden Nachfolgerknoten. Der neue Baum wird jetzt auch im Wald aufgenommen. 4. Der Punkt 3 wird wiederholt, bis nur noch ein Baum im Wald vorhanden ist. Dies wird irgenwann der Fall sein, da wir bei jedem Durchlauf aus zwei Bäumen einen machen und somit die Anzahl Bäume im Wald um eins verringern. Beispiel 6.14 [Huffman Code] Wir wollen nun den Huffman-Code für den folgenden String bestimmen. String: KODIERTE ZEILEN SIND KURZ Wir erhalten die folgenden Häufigkeiten: bl O I R Z N U = = = = = = = 3 1 3 2 2 2 1 K D E T L S = = = = = = 2 2 4 1 1 1 Die Konstruktion des vollständigen Baumes ist in der Abbildung 6-3 dargestellt. Aus diesem Baum kann der vollständige Huffman-Code nun abgeleitet werden. Man muss nur den Baum entsprechend traversieren und sich den binären String dabei merken. Der Code ist nachstehend angegeben. I D U T = 001 = 0000 = 1011 = 01110 E Z K L = 010 = 0001 = 1110 = 01111 N = R = O = 100 0110 1111 bl = S = 6-10 110 1010 bl 3 K 2 O 1 D 2 I 3 E 4 R 2 T 1 Z 2 L 1 N 2 S 1 U 1 bl 3 K 2 O 1 D 2 I 3 E 4 R 2 T 1 bl 3 3 K 2 I 3 4 O 1 D 2 E 4 4 4 R 2 Z 2 N 2 2 T 1 6 bl 3 2 S 1 L 1 U 1 7 3 K 2 O 1 D 2 E 4 25 0 4 0 D 2 1 Z 2 0 I 3 E 4 4 1 0 R 2 1 2 0 1 T 1 L 1 S 1 2 U 1 L 1 6 1 N 2 4 0 2 10 1 0 8 1 N 2 4 1 15 1 7 0 U 1 4 R 2 Z 2 2 S 1 L 1 T 1 0 N 2 8 I 3 4 Z 2 2 0 2 0 1 S 1 U 1 bl 3 1 3 0 1 K 2 O 1 Abbildung 6-3: Erzeugung des Huffman-Codes 6.2.3.1 Länge des kodierten Strings Im Baum aus der Abbildung 6-3 kann noch die Länge des kodierten Strings (in Bit) abgelesen werden. In jedem Blatt steht ja, wie häufig das entsprechende Zeichen im Text vorkommt. Die Anzahl Bits, die es braucht um das entsprechende Zeichen zu kodieren ist gerade gleich dem Niveau (level siehe Abschnitt 1.5.3) des Knotens. Das heisst, die folgende Summe ist gleich der Länge des Kodierten Strings (H(x) ist dabei die Häufigkeit des entsprechenden Blattes): X level(x) · H(x) x=Blätter Man kann sogar zeigen, dass der so aufgebaute Baum optimal ist. 6.2.3.2 Implementation Beim Aufbau des Huffman-Baumes müssen wir immer die beiden Bäume mit minimaler Häufigkeit in der Wurzel bestimmen. Diese Aufgabe kann mit Hilfe einer Priorityqueue gelöst werden (siehe Abschnitt 3.3). Ein Problem haben wir noch nicht behandelt. Um den kodierten String wieder zu dekodieren, müssen wir den Huffman-Code zusammen mit dem kodierten String speichern. Da wir den 6-11 Huffman-Code mit Hilfe der Häufigkeiten und dem oben angegebenen Algorithmus konstruieren können, ist es nur notwendig, die Häufigkeiten als Integerarray am Anfang des kodierten Strings zu speichern. Vor dem Dekodieren wird dann mit Hilfe des vorigen Algorithmus zuerst der Huffman-Baum (und somit auch der Code) generiert. Da die Häufigkeit der einzelnen Zeichen auch im kodierten String gespeichert werden muss, macht diese Art von Kodierung nur Sinn, wenn der String gross ist. 6-12 Kapitel 7 Typen von Algorithmen In diesem Kapitel wollen wir einige Algorithmen-Typen (Strategien) kennenlernen. Unter dem Begriff “Algorithmen-Typ” verstehen wir die Wahl einer Strategie, mit der versucht wird, ein Problem zu lösen. Natürlich ist nicht jede Strategie für jedes Problem geeignet. Darum werden im folgenden verschiedene Design-Methoden vorgestellt und auch Beispiele dazu. 7.1 Rekursion Der im Abschnitt 1.5.4 beschriebene Verfahren der Rekursion ist eine mögliche Strategie zum Lösen von algorithmischen Problemen. Da wir dieses Verfahren aber schon genügend beschrieben haben, wird es in diesem Abschnitt nicht mehr im Detail behandelt. Bemerkung 7.1 [Rekursion] Andere Verfahren wie Backtracking und Divide and Conquer führen sehr oft auch zu rekursiven Algorithmen. 7.2 Backtracking Prinzip: Im Prinzip ist das ein “trial-and-error” Verfahren. Man dringt im Suchraum so weit vor, bis man in eine Sackgasse gerät. Anschliessend werden soviele Schritte zurückgenommen, bis ein weiterer möglicher Weg verfolgt werden kann. Beispiele: Hamilton-Zyklen: Gibt es in einem ungerichteten Graphen einen Zyklus, der durch alle Knoten geht? Traveling Salesman: Finde in einem gewichteten Graphen den kürzesten (einfachen) Zyklus, der durch alle Knoten führt. Färbungsproblem: Gegeben ist ein Graph: gesucht ist eine Färbung der Knoten mit einer minimalen Anzahl Farben so, dass benachbarte Knoten unterschiedlich gefärbt sind. Springer-Problem (Euler 1759): Gesucht ist die Bahn eines Springers auf einem Schachbrett so, dass jedes Feld genau einmal besucht wird. 7-1 7.2.1 Hamilton-Zyklus Wir wollen feststellen, ob in einem ungerichteten Graphen ein Zyklus der Länge n existiert, der durch alle n Knoten des Graphen geht. Ein solcher Zyklus heisst Hamilton-Zyklus. Bemerkung 7.2 [Einfacher Zyklus] Falls der Graph gerichtet ist, können wir die Frage stellen ob ein einfacher Zyklus existiert, der durch alle Knoten des Graphen geht. Um das Problem zu lösen verwenden wir nun Backtracking. In diesem Fall heisst dies, wir bestimmen alle möglichen Pfade im Graphen und testen für jeden Pfad, ob er ein HamiltonZyklus bildet. Um dieses Problem zu lösen, verändern wir den Algorithmus der Tiefensuche wie im nachfolgenden Programm angegeben. Algorithmus 7.1 [Hamilton Zyklus] public class Hamilton { private static boolean found = false; private static final int BEGIN = 0; private static int visited[]; private static int nodecount = 0; public static int cycle[]; public static boolean search(boolean arc[][]) visited = new int[arc.length]; cycle = new int[arc.length]; for (int i = 0; i < arc.length ; i++) { visited[i] = 0; } // Falls der Graph nicht zusammenhaengend // so existiert kein Hamilton-Zyklus. Wir // die Prozedur visit also nur fuer einen // aufrufen (z.B. Knoten BEGIN = 0). visit(arc, BEGIN); return found; } { ist, muessen Knoten private static void visit(boolean arc[][], int k) { // In der Variablen "nodecount" ist die Laenge des // aktuellen Astes im Suchbaum gespeichert. //In "cycle" die Knoten des aktuellen Astes. cycle[nodecount] = k; visited[k] = ++nodecount; // Falls alle Knoten auf dem aktuellen Weg liegen // und eine Kante zum Anfangsknoten fuehrt, so haben // wir einen Hamilton-Zyklus gefunden. if (nodecount == arc.length && arc[k][BEGIN] == true) { found = true; } else { // Wir verfolgen so lange weitere Wege, bis ein // Hamiltonzyklus gefunden ist. 7-2 int i = 0; while (i < arc.length && !found) { if (arc[k][i] && visited[i] == 0) visit(arc,i); ++i; } } // Backtracking Ansatz. Wenn wir die Prozedur // verlassen, machen wir alles rueckgaengig was // die Prozedur bisher gemacht hat. --nodecount; visited[k] = 0; } } Die Prozedur ist so ausgerichtet, dass abgebrochen wird, sobald der erste Hamilton-Zyklus gefunden wird. Will man alle solchen Zyklen, so muss einfach weitergefahren werden. Dabei ist zu beachten, dass jeder Zyklus dann zwei mal vorkommt (in beide Richtungen). Beispiel 7.1 [Hamilton] Wir wollen nun an einem Beispiel verfolgen, in welcher Reihenfolge die gegebene Prozedur visit aufgerufen wird. In der Abbildung 7-1 ist ein ungerichteter (ungewichteter) Graph abgebildet. 1 0 2 3 5 6 4 7 8 9 Abbildung 7-1: Ein ungerichteter Graph In der Abbildung 7-2 ist nun der Suchbaum für den Backtracking Prozess bis zum Finden des ersten Zyklus angegeben. Bemerkung 7.3 [Aufwand Backtracking] Der Aufwand eines solchen Backtrackings kann sehr gross werden. Ist die Anzahl Knoten im Graphen n und ist jeder Knoten mit jedem anderen Knoten durch eine Kante verbunden, so existieren n! Hamilton-Zyklen. Das heisst, falls wir alle Zyklen suchen, so ist die Komplexität des Algorithmus exponential. Bis jetzt sind keine Algorithmen bekannt, die nicht exponential wären (das Problem ist N P-vollständig). 7.2.2 Traveling Salesman Dies ist ein ähnliches Problem wie das von Hamilton-Zyklen. Zu einer gegebnen Menge von n Städten ist die kürzeste Route zu finden, die alle Städte verbindet, ohne dass eine Stadt zweimal besucht wird. Dieses Problem können wir mit Hilfe eines gewichteten Graphen lösen. Wir können alle 7-3 0 3 1 3 1 6 9 2 7 5 4 8 2 5 6 8 5 7 7 6 9 8 4 9 4 6 7 5 5 8 7 6 9 8 4 9 found = true 4 Abbildung 7-2: Suchbaum für den Graphen aus Abb. 7-1 Hamilton-Zyklen bestimmen und anschliessend den Zyklus wählen, der die minimale Länge (bezüglich Gewicht) aufweist. 7.3 Branch and Bound Prinzip: Jeder Ast (engl. branch) des Entscheidungsbaumes (oder Suchbaumes) entspricht einer Teillösung des Problems. Auf das Durchsuchen eines Astes kann verzichtet werden, wenn eine bestimmte Schranke (engl. bound) überschritten worden ist. Beispiel: Traveling Salesman: Die Suche kann verkürzt werden, wenn man als obere Schranke das Minimum aller bisher gefundenen Touren wählt. Springer-Problem: Ein Suchpfad wird abgebrochen, wenn ein nicht besuchtes Feld nicht mehr erreichbar ist. Man muss für jedes Feld zählen, von wievielen nichtbesuchten Feldern aus es noch erreichbar ist. 7.3.1 Traveling Salesman Um die Suche zu verkürzen, können wir unsere Prozedur visit folgendermassen ändern. Algorithmus 7.2 [Traveling Salesman] public class TravelingSalesman { private static final int BEGIN = 0; private static int visited[]; private static int cycle[]; private static int shortestcycle[]; 7-4 private private private private static static static static int int int int nodecount beginnode minlength actlength = = = = 0; 0; 0; 0; public static int[] search(int arc[][]){ visited = new int[arc.length]; cycle = new int[arc.length]; shortestcycle = new int[arc.length]; for (int i = 0; i < arc.length; i++) { visited[i] = 0; } // Falls der Graph nicht zusammenhaengend ist, so // existiert sicher keine Route. Wir muessen die Prozedur // visit also nur fuer einen Anfangsknoten aufrufen. visit(arc, BEGIN, 0); if (minlength > 0) { return shortestcycle; } else { return null; } } private static void visit(int arc[][], int k, int length) { // In "actlength" ist das Gewicht des aktuellen Astes // gespeichert. Falls dies Groesser ist als das aktuelle // Minimum koennen wir diesen Ast abbrechen. // Bound and Branch Ansatz. if (minlength > 0 && (actlength + length) >= minlength) { return; } actlength += length; // In "cycle" sind die Knoten des aktuellen Astes. // In "nodecount" ist die Laenge in Kanten des // aktuellen Astes. cycle[nodecount] = k; visited[k] = ++nodecount; // Falls alle Knoten auf dem aktuellen Weg liegen // und eine Kante zum Anfangsknoten fuehrt, so // haben wir einen Hamilton-Zyklus gefunden. // Testen ob er bisher der kuerzeste ist. if (nodecount == arc.length && arc[k][beginnode] >= 0 && (minlength == 0 || minlength > actlength + arc[k][beginnode])) { minlength = actlength + arc[k][beginnode]; // Kopieren der Loesung. for (int i = 0; i < arc.length ; i++) { shortestcycle[i] = cycle[i]; } 7-5 } else { // Wir verfolgen alle Wege. for (int i = 0; i < arc.length; i++) if (arc[k][i] >= 0 && visited[i] == 0) visit(arc, i, arc[k][i]); } // Backtracking Ansatz. Wenn wir die Prozedur // verlassen, machen wir alles rueckgaengig was // die Prozedur bisher gemacht hat. actlength -= length; --nodecount; visited[k] = 0; } } Bemerkung 7.4 [Komplexität von Branch and Bound] Durch die Anwendung von Branch And Bound wird die Komplexität des Algorithmus nicht verringert. Das Traveling Salesman Problem ist und bleibt wie das Problem der Hamilton-Zyklen N P-Vollständig 7.4 Divide-And-Conquer Diese Methode haben wir schon im Abschnitt 3.4 kennengelernt hier sei nur noch einmal das Pinzip wiederholt und ein weiteres Beispiel angeführt. Prinzip: Die Divide-And-Conquer Methode (kurz: DAC) zerlegt das zu lösende Problem in kleinere Teilprobleme, (divide) bis die Lösung der einzelnen Teilprobleme trivial ist. Anschliessend werden die Teillösungen zur Gesamtlösung vereinigt (conquer). Beispiele: Quicksort: Im Abschnitt 3.4 schon behandelt. Matrixmultiplikation: Die Komplexität der Matrixmultiplikation kann mit Hilfe von DAC verringert werden (Strassen) Multiplikationen von Integer: Die Anzahl Bit-Operationen bei der Multiplikation von zwei Integerzahlen kann mit DAC verringert werden. 7.4.1 Matrix-Multiplikation (Strassen) Der klassische Algorithmus für die Multiplikation von zwei (n × n) Matrizen benötigt • n3 Multiplikationen und • n3 − n2 Additionen 7-6 Strassen hat 1969 gezeigt, dass der Aufwand der Matrixmultiplikation mit Hilfe eines DACAgorithmus auf etwa n2.81 reduzierbar ist. Diesen Algorithmus wollen wir in diesem Abschnitt vorstellen. Bemerkung 7.5 [Strassen ist DAC] Strassen’s Algorithmus ist ein Beispiel für einen DAC-Algorithmus, wo das Aufteilen in Teilprobleme einfach ist, das Zusammensetzen zur Gesammtlösung aber schwierig. Wir betrachten zuerst die Multiplikation von zwei (2 × 2) Matrizen: A11 A12 A21 A22 ! × B11 B12 B21 B22 ! = C11 C12 C21 C22 ! Im klassischen Algorithmus wird das Resultat folgendermassen berechnet: C11 C12 C21 C22 = A11 · B11 + A12 · B21 = A11 · B12 + A12 · B22 = A21 · B11 + A22 · B21 = A21 · B12 + A22 · B22 Wir sehen, dass dazu 8 Multiplikationen und 4 Additionen benötigt werden. Strassen hat nun gezeigt, dass nur 7 Multiplikationen notwendig sind um die zwei Matrizen zu multiplizieren. Als erstes werden die Hilfszahlen m1 , . . . , m7 wie folgt berechnet: m1 m3 m5 m7 = (A12 − A22 ) · (B21 + B22 ) = (A11 − A21 ) · (B11 + B12 ) = A11 · (B12 − B22 ) = (A21 + A22 ) · B11 m2 = (A11 + A22 ) · (B11 + B22 ) m4 = (A11 + A12 ) · B22 m6 = A22 · (B21 − B11 ) Das Produkt der Matrizen A und B kann nun aus diesen Werten folgendermassen berechnet werden: C11 C12 C21 C22 = = = = m1 + m2 − m4 + m6 m5 + m4 m6 + m7 m2 − m3 + m5 − m7 Wir wollen am Beispiel von C12 zeigen, dass diese Berechnungen stimmen. C12 = = = = m4 + m5 A11 · (B12 − B22 ) + (A11 + A12 ) · B22 A11 · B12 − A11 · B22 + A11 · B22 + A12 · B22 A11 · B12 + A12 · B22 Für (2 × 2) Matrizen können wir nun festhalten: • Für die Multiplikation der Matrizen werden 7 Multiplikationen und 18 Additionen verwendet. • Bei der obigen Berechnungen von C11 . . . C22 mit Hilfe von m1 . . . m7 wird die Kommutativität der Multiplikation nirgends verwendet. Das heisst, die obige Berechnung kann auch durchgeführt werden, wenn A11 . . . A22 , B11 . . . B22 und C11 . . . C22 selbst wieder Matrizen sind. 7-7 Wir nehmen nun an, dass die Dimension der Matrizen die Form n = 2k hat. Die (n × n) Matrizen können also in vier ( n2 × n2 ) Matrizen unterteilt werden (divide). Das Resultat kann nun nach der Methode von Strassen berechnet werden (conquer). Da n = 2k ist, kann dieser Vorgang rekursiv fortgesetzt werden, bis die Dimension der Matrizen 1 ist. In diesem Fall ist die Matrixmultiplkation trivial (Multiplikation von zwei Zahlen). Wir haben nun einen DAC-Algorithmus für die Matrixmultiplikation gefunden. Nun müssen wir noch berechnen, wieviele Multiplikationen und Additionen dieser DAC-Algorithmus benötigt. n = 2k M (k) A(k) sei die Dimension der Matrizen wobei k ∈ N sei die Anzahl Multiplikationen von reellen Zahlen, die Strassens Methode verwendet um zwei (2k × 2k ) Matrizen zu multiplizieren sei die Anzahl Additionen von reellen Zahlen, die Strassens Methode verwendet um zwei (2k × 2k ) Matrizen zu multiplizieren Für die Multiplikationen erhalten wir folgende Rekursionsformel: M (0) = 1 M (k) = 7 · M (k − 1) f ür k > 0 Diese Rekursionsformel ist sehr einfach zu lösen und es gilt: M(k) = 7k = 7log2 (n) = nlog2 (7) ≈ n2.81 Die Berechnung der Anzahl Additionen von reellen Zahlen ist etwas komplizierter. Wir brauchen nach der Methode von Strassen 18 Additionen (bzw. subtraktion) von ( n2 × n2 ) Matrizen. Diese benötigen 18(2k−1 )2 Additionen von reellen Zahlen. Zusätzlich sind 7 Multiplikationen von ( n2 × n2 ) Matrizen nötig, die 7 · (A(k − 1)) Additionen benötigen. Die Rekursionsformel für die Additionen hat also folgende Form: A(0) = 0 A(k) = 18(2k−1 )2 + 7 · A(k − 1) Wir können A(k − 1) mit derselben Formel ersetzen und erhalten A(k) = 18(2k−1 )2 + 7 · 18(2k−2 )2 + 72 · A(k − 2) = 18(2k−1 )2 + 7 · 18(2k−2 )2 + 72 · 18(2k−3 )2 + 73 · A(k − 3) Wir können nun daraus die allgemeine Formel ableiten und erhalten: A(k) = k−1 X 7i · 18 · (2k−i−1 )2 i=0 k 2 Aus dieser Formel klammern wir 18 (24) aus und erhalten A(k) = 9 2 · 4k · Pk−1 7 i i=0 k 4 ( 47 ) −1 = 29 · 4k · 7 (4 )−1 k 7 −1 = 6 · 4k 4 = 6 · 7k − 6 · 4k = 6 · n2.81 − 6 · n2 7-8 Bemerkung 7.6 [Allgemeine Dimensionen] Bis jetzt haben wir angenommen, dass die Dimension der Matrizen die Form n = 2k besitzt. Wenn dies nicht der Fall ist, kann die Unterteilung der Matrizen in vier ( n2 × n2 ) bei ungeradem n ja nicht vorgenommen werden. In diesem Fall wird den Matrizen eine Zeile und ein Kolonne mit lauter Nullen angehängt. Nun ist die Dimension wieder gerade und die Unterteilung kann fortgesetzt werden. Auch für diesen abgeänderten Algorithmus kann gezeigt werden: Komplexität = O(n2.81 ) Zum Schluss noch ein paar Bemerkungen: • Das Hauptinteresse von Strassens Algorithmus ist theoretischer Natur. Erstmals wurde die Komplexität von O(n3 ) für die Matrixmuliplikation durchbrochen und damit auch für viele andere Matrizenoperationen wie Matrixinversion, Determinantenberechnung und Lösen von linearen Gleichungssystemen. Alle diese Operationen können auf die Matrixmultiplikation zurückgeführt werden und haben daher dieselbe Komplexität. • Der praktische Nutzen von Strassens Algorithmus ist gering, da der Zeitgewinn durch den grossen Verwaltungsaufwand wieder aufgehoben wird. • Die besten heute bekannten Algorithmen für die Matrixmultiplikation haben eine Komplexität von O(n2.4 ). Der lower Bound ist bis heute aber nicht bekannt. 7.5 Dynamic Programming (DP) Prinzip: Bei der DAC Methode werden Probleme in kleinere Probleme zerlegt, die dann für sich gelöst werden können. Dynamic Programming führt dieses Prinzip noch weiter: wissen wir nicht, welche kleineren Probleme wir lösen müssen, so werden alle gelöst. Diese Teillösungen werden gespeichert und später bei der Lösung der grösseren Probleme wieder verwendet. Beispiele: Floyd’s Algorithmus: Dieser Algorithmus ist im Abschnitt 5.6.4 beschrieben. Multiplikation von p × q Matrizen: Wenn mehrere Matrizen hintereinander multipliziert werden, so ist die Anzahl Multiplikationen von der Reihenfolge der Auswertung abhängig. Der Algorithmus soll die optimale Reihenfolge finden. Rucksak-Problem (Knapsack): Verpacken von Objekten in einem Rucksack mit beschränkter Kapazität so, dass der Wert der gewählten Objekte maximal wird. Mit der DP Technik sind zwei prinzipielle Schwierigkeiten verbunden: 1. Es ist nicht immer möglich, die Lösungen von zwei kleineren Problemen zur Lösung des gesammten Problems zu kombinieren. 2. Die Anzahl der kleineren Probleme kann so gross sein, dass das Speichern der Teillösungen nicht mehr möglich ist. 7-9 Wie schon bei den anderen Design-Methoden können nicht alle Probleme mit Hilfe von Dynamic Programming gelöst werden. Es gibt jedoch eine ganze Klasse von Problemen, die gut mit DP gelöst werden können. Vor allem Probleme, bei denen unter vielen verschiedenen Möglichkeiten etwas zu tun die beste gesucht wird. DP führt meistens zu iterativen Algorihmen. 7.5.1 Multiplikation von pxq Matrizen Wir möchten das Resultat des Produkts von n Matrizen M1 × M2 × · · · × Mn berechnen. Jede Matrix Mi hat ri−1 Zeilen und ri Kolonnen. Die Reihenfolge in der die Matrizen multipliziert werden beeinflusst die Anzahl Floatoperationen wesentlich wie das folgende Beispiel zeigt. Beispiel 7.2 [Reihenfolge der Multiplikation] Als Beispiel betrachten wir die Multiplikation der folgenden 4 Matrizen (die Dimension der Matrizen ist in [] angegeben). M = M1 × M2 × M3 × M4 |{z} [10×20] es gilt also für die Zeilen un Kolonnen: |{z} [20×50] |{z} [50×1] |{z} [1×100] r0 = 10, r1 = 20, r2 = 50, r3 = 1, r4 = 100 falls in M in der Reihenfolge M = M1 × (M2 × (M3 × M4 )) berechnet wird, so sind 125000 Multiplikationen notwendig. Wird M hingegen in der Reihenfolge M = (M1 × (M2 × M3 )) × M4 berechnet, so sind nur 2200 Multiplikationen notwendig. Die Reihenfolge in der die Matrizen multipliziert werden spielt also für die Anzahl Operationen eine wesentliche Rolle. Falls wir aber alle möglichen Reihenfolgen betrachten um n Matrizen zu multiplizieren, so wird der Algorithmus exponentiell und daher für grosse n undurchführbar. Der DP-Ansatz hingegen führt zu einem Algorithmus der Ordnung O(n3 ). mi,j sei die minimale Anzahl Operationen um dir Matrizen Mi × Mi+1 × . . . × Mj 1 ≤ i ≤ j ≤ n. Es ist klar, dass für mi,j folgendes gilt. mi,j = ( 0 : f alls i = j mini≤k<j (mi,k + mk+1,j + ri−1 rk rj) : f alls j > i (7.1) Dabei ist mi,k die minimale Anzahl Operationen für die Berechnung von M ′ = Mi × . . . × Mk und mk+1,j die minimale Anzahl Operationen für die Berechnung von M ′′ = Mk+1 × . . . × Mj . Der Term ri−1 rk rj ist die Anzahl Operationen die gebraucht werden, um M ′ × M ′′ zu berechnen. 7-10 Der DP-Ansatz berechnet nun alle mi,j und speichert diese in einem 2-dimensionalen Array. Die Reihenfolge der Berechnung ist folgendermassen. 1. Berechnen mi,i für 1 ≤ i ≤ n 2. Berechnen mi,i+1 für 1 ≤ i ≤ n − 1 3. Berechnen mi,i+2 für 1 ≤ i ≤ n − 2 4. usw Bei der Berechnung von mi,j stehen uns alle benötigten mi,k und mk+1,j aus der Formel (7.1) schon zur Verfügung. Da wir nicht nur an der minimalen Anzahl Operationen interessiert sind sondern auch an der Reihenfolge der Berechnung, müssen wir uns auch merken, welches k in der Formel (7.1) zum Minimum führt. Beispiel 7.3 [Multiplikation von Matritzen] Wir betrachten die Matrizen aus dem Beispiel 7.2. Wir erinnern uns, dass für die Zeilen und Kolonnen folgende Werte gelten: r0 = 10, r1 = 20, r2 = 50, r3 = 1, r4 = 100 Der DP-Algorithmus liefert nun die folgende Tabelle. m11 = 0 index = 0 m12 = 10000 index = 1 m13 = 1200 index = 1 m14 = 2200 index = 3 m22 = 0 index = 0 m23 = 1000 index = 2 m24 = 3000 index = 3 m33 = 0 index = 0 m34 = 5000 index = 3 m44 = 0 index = 0 Wir wollen etwa verfolgen, wie die Einträge der zwei letzten Zeilen zustande kommen wenn wir die Formel 7.1 anwenden (für die zwei ersten Zeilen ist die Berechnung trivial): Berechnen von m13 k=1: k=2: m11 + m23 + r0 r1 r3 m12 + m22 + r0 r2 r3 = = 0 + 1000 + 10 · 20 · 1 10000 + 0 + 10 · 50 · 1 = = 1200 10500 index = 1 Berechnen von m24 k=2: k=3: m22 + m34 + r1 r2 r4 m23 + m44 + r1 r3 r4 = = 0 + 5000 + 20 · 50 · 100 1000 + 0 + 20 · 1 · 100 index = 3 Berechnen von m14 7-11 = = 3000 15000 k=1: k=2: k=3: m11 + m24 + r0 r1 r4 m12 + m34 + r0 r2 r4 m13 + m44 + r0 r3 r4 = = = 0 + 3000 + 10 · 20 · 100 10000 + 5000 + 10 · 50 · 100 1200 + 0 + 10 · 1 · 100 = = = 23000 65000 2200 index = 3 Aus dem Index können wir nun zurückverfolen, dass zuerst die Matrizen M1 · M3 multipliziert werden müssen. Das Resultat wird mit M4 multipliziert. Um die Matrizen M1 · M3 zu multiplizieren wird zuerst M2 mit M3 multipliziert. Nun wird noch M1 mit dem Resultat multipliziert. 2 Die Komplexität des Algorithmus is wie gesagt O(n3 ). Wir berechnen n2 Minimas mi,j . Zur Berechnung dieser Minima müssen wir im Durchschnitt n2 Berechnungen in der Formel 7.1 2 vornehmen. Also haben wir ca. n2 · n2 Operationen und das ergibt Komplexität = O(n3 ) 7.5.2 Rucksack-Problem (Knapsack) Wir nehmen an, dass ein Räuber einen Panzerschrank geknackt hat und darin verschiedene Objekte von verschiedener Grösse und Wert vorfindet. Der Räuber hat einen Rucksack bei sich, mit einem Volumeninhalt von M , in dem er seine Beute verpacken kann. Wir nehmen ferner an, dass von jeder Objektart im Schrank genug Objekte vorhanden sind, um den Rucksack zu füllen. Die Frage ist nun, welche Objekte der Räuber im Rucksack einpacken muss, damit der Wert seiner Beute maximiert wird. Wir können natürlich das Problem lösen, indem wir alle möglichen Kombinationen einfach versuchen und die beste auswählen. Die Komplexität eines solchen Algoritmus ist aber exponentiell. Bei einem DP Ansatz berechnen wir die besten Kombinationen für alle Kapazitäten des Rucksacks bis zur Endkapazität M . Wenn diese Berechnungen in einer geschickten Reihenfolge ausgeführt werden, so entsteht ein effizienter Algorithmus, der nachstehend angegeben ist. Algorithmus 7.3 [Knapsack] for (i = 1; i <= N; i++) { for (j = 1; j <= M; j++) { if (j - size[i] >= 0) { if (cost[j] < cost[j - size[i]] + value[i]) { cost[j] = cost[j - size[i]] + value[i]; best[j] = i; } } } } 7.5.2.1 //(1) //(2) //(3) Erklärungen In den Arrays value und size sind der Wert und die Grösse der verschiedenen Objekte gespeichert. Im Array cost ist für jede Kapazität der maximal erreichbare Wert gespeichert 7-12 und im Array best wird das letzte hinzugefügte Objekt, das zu diesem maximalen Wert führt, gespeichert. best dient dazu, am Schluss zu bestimmen, welche Objekte gewählt werden müssen. (1) Loop über alle Objektarten. (2) Berechnen des optimalen Wertes für alle Kapazitäten indem nur die Objektarten 1 bis i verwendet werden. (3) An dieser Stelle testen wir, ob wir im Rucksack der Kapazität j ein Objekt der Art i hinzufügen sollen oder nicht. Der maximale Wert den wir erreichen, falls wir das Objekt hinzunehmen, ist der Wert des Objektes value[i] plus der maximale Wert eines Rucksacks der Kapazität j - size[i] und das ist gleich cost[j - size[i]], das ja schon berechnet ist. Ist nun dieser Wert grösser als cost[i], so wird das neue Objekt dazugenommen und die Array-Elemente cost[j] und best[j] entsprechend verändert. Beispiel 7.4 [Knapsack] Wir wollen den Inhalt der Arrays cost[] und best[] verfolgen, wenn die Kapazität des Rucksacks 17 ist, und die folgenden Objekte zur Verfügung stehen: Objekt Grösse Wert 1 3 4 2 4 5 3 7 10 4 8 11 5 9 13 Die Abbildung 7-3 zeigt wie die Arrays cost[] und best[] durch den DP Algorithmus aufgebaut werden. Kapazität 0 1 2 3 4 cost best 0 0 0 4 1 4 1 cost best 0 0 0 4 1 5 2 cost best 0 0 0 4 1 5 2 cost best 0 0 0 4 1 5 2 cost best 0 0 0 4 1 5 2 5 6 7 8 9 10 11 Objektart 1 4 8 8 8 12 12 12 1 1 1 1 1 1 1 Objektarten 1 und 2 5 8 9 10 12 13 14 2 1 2 2 1 2 2 Objektarten 1, 2 und 3 5 8 10 10 12 14 15 2 1 3 2 1 3 3 Objektarten 1, 2, 3 und 4 5 8 10 11 12 14 15 2 1 3 4 1 3 3 Objektarten 1, 2, 3 , 4 und 5 5 8 10 11 13 14 15 2 1 3 4 5 3 3 12 13 14 15 16 17 16 1 16 1 16 1 20 1 20 1 20 1 16 1 17 2 18 2 20 1 21 2 22 2 16 1 18 3 20 3 20 1 22 3 24 3 16 1 18 3 20 3 21 4 22 3 24 3 17 5 18 3 20 3 21 4 23 5 24 3 Abbildung 7-3: Ablauf des DP Algorithmus für knapsack Der maximale Wert, der erreicht werden kann, ist also 24. Welche Objekte mitgenommen werden müssen, können wir im Array best ablesen. Wir haben nicht nur das Resultat für die Kapazität M = 17 berechnet. Wir können in unserem Array den maximalen Wert für jede Kapazität von 1 bis 17 ablesen. Wir wollen nun mit Hilfe des Arrays best ermitteln, welche Objekte zum maximalen Wert führen. Nach Definition ist best[M] in der Lösung eingeschlossen. Die restlichen Objekte 7-13 sind dieselben wie diejenigen der otimalen Lösung für einen Rucksack der Kapazität M size[best[M]], also gehört best[M - size[best[M]] zur Lösung und so weiter. Für die Kapazität 17 bekommen wir also als Lösung: best[17] = 3 best[17 - size[3]] = best[10] = 3 best[10 - size[3]] = best[3] = 1 Es ist leicht zu sehen, dass die Komplexität des Algorithmus N ·M beträgt. Dies ist akzeptabel, solange M nicht zu gross wird (pseudo polynomial). Der Speicherbedarf muss bei DP Algorithmen immer in Betracht gezogen werden. Solche Algorithmen sind nur dann durchführbar, wenn die Anzahl der zu speichernden Teillösungen ’vernünftig’ bleibt. In unserem Fall sind dies die beiden Integer-Arrays cost und best. Das heisst, der Speicherbedarf ist proportional zur Kapazität M . Dies ist auch nur dann akzeptabel, wenn M nicht zu gross wird. Bemerkung 7.7 [Anzahl Objekte] Diese DP Methode funktioniert nur, falls von jeder Objektart beliebig viele Objekte vorhanden sind. Ist dies nicht der Fall, so wird das Problem N P-vollständig. 7.6 Greedy (die gierige Methode) Greedy ist eine Methode, die dann versucht werden kann, wenn von n möglichen Lösungen eines Problems, die bezüglich einer Bewertungsfunktion f optimale Lösung gesucht wird. Prinzip: Die Greedy-Methode arbeitet in Schritten, ohne voraus- oder zurückzublicken. Bei jedem Schritt wird aus einer Menge von möglichen Wegen derjenige ausgesucht, der den Bedingungen des Problems genügt und lokal optimal ist. Beispiele: Minimaler aufspannender Baum (Kruskal): Dieser Algorithmus wurde im Abschnitt 5.6.2 behandelt. Färbungsproblem: Färbe eine maximale Anzahl Knoten mit einer ersten Farbe, eine maximale Anzahl der verbleibenden Knoten mit einer zweiten Farbe, etc. Traveling Salesman Problem: Wähle in jedem Knoten jeweils die billigste Kante, welche zu einem noch unbesuchten Knoten führt (“best first search”). 7.6.1 Hill Climbing Wir wollen die Arbeitsweise dieser Methode an einem anschaulichen Beispiel illustrieren. Wir nehmen an, dass jemand sich irgendwo auf einem Berg befindet und so schnell wie möglich zum Gipfel kommen möchte. Eine einfache Greedy-Strategie für dieses Problem ist die folgende. Man bewege sich immer entlang der grössten Steigung nach oben bis dies nicht mehr möglich ist. Das heisst, bis in allen Richtungen die Wege nur noch nach unten führen. 7-14 Abbildung 7-4: Hill climbing Dieser Ansatz ist in der Abbildung 7-4 dargestellt. Es ist ein typischer Greedy Ansatz. Man schaut nicht zurück, und nach vorne wird die lokal optimale Strategie gewählt. In der Abbildung 7-5 sieht man aber, dass diese Strategie nicht unbedingt zum Ziel führt. Hat der Berg mehrere Nebengipfel, so kann man auf einem solchen Nebengipfel stehen bleiben. Wir haben also nur ein lokales Optimum erreicht. Maximum Lokales Maximum Abbildung 7-5: Erreichen eines lokalen Maximums mit Greedy Bei vielen Problemen derselben Art liefert nur ein exponentieller Algorithmus eine optimale Lösung, während ein heuristischer Ansatz mit Greedy nur “fast” optimale Lösungen liefert, dies aber in polynomialer Zeit. Es existieren aber auch Probleme, bei denen der Greedy-Ansatz zum optimalen Ergebnis führt, wie zum Beispiel der Algorithmus von Kruskal oder für das Task Scheduling Problem. 7.6.2 Task Scheduling In diesem Beispiel wollen wir ein Optimierungsproblem betrachten. Wir nehmen an, dass wir eine gegebene Menge T von n Tasks (Arbeiten) haben, so dass jeder Task i eine Startzeit si und eine Endzeit ei mit si < ei besitzt. Für jeden Task wird eine Maschine benutzt und jede Maschine kann nur einen Task gleichzeitig ausführen. Zwei Taks i und j sind konfliktfrei, falls ei ≤ sj oder ej ≤ si . Zwei Tasks können nur dann auf derselben Maschine ausgeführt werden, wenn sie konfliktfrei sind. Das Task Scheduling Problem besteht nun darin, alle Tasks in T auf eine minimale Anzahl Maschinen zu verteilen. Wir können zum Beispiel annehmen, dass die Tasks Sitzungen sind und dass wir diese Sitzungen auf eine minimale Anzahl Sitzungszimmer verteilen möchten. In der Abbildung 7-6 ist ein Beispiel für eine solche Verteilung. 7-15 Maschine 3 Maschine 2 Maschine 1 1 2 3 4 5 6 7 8 9 Abbildung 7-6: Verteilung der Tasks {(1, 3), (1, 4), (2, 5), (3, 7), (4, 7), (6, 9), (7, 8)} Der Greedyansatz ist nun, die Tasks nach Ihrer Startzeit zu sortieren und auf die erste Maschine zu verteilen, wo kein Konflikt entsteht. Es wird nie ein Zug zurückgenommen. Nachfolgend ist der Algorithmus in Pseudocode angegeben. Algorithmus 7.4 [TaskSchedule(T)] Input: Eine Menge T von Tasks mit Startzeit si und Endzeit ei . Output: Eine Verteilung der Tasks auf eine minimale Anzahl von Maschinen, so dass kein Konflikt entsteht. m = 0 // Anzahl Maschinen while T 6= ∅ Entferne aus T der Task i mit der kleinsten Startzeit si if es existiert eine Maschine j mit allen Task sind mit Task i konfliktfrei then Task i wird auf Maschine j ausgeführt else m=m+1 Task i wird auf Maschine m ausgeführt. endif endwhile Wir wollen noch zeigen, dass der Algorithmus wirklich eine oprimale Lösung findet. Das heisst, die Anzahl Maschinen, die vom Algorithmus ermittelt wird ist tatsächlich minimal. Dazu nehmen wir das Gegenteil an. Das heisst, der Algorithmus findet eine Verteilung mit k Maschinen aber es gäbe eine konfliktfreie Verteilung, die nur k − 1 Maschinen benötigt. Wir nehmen an, dass k die letzte vom Algorithmus allozierte Maschine ist und dass i der Task ist, der zur Allokation geführt hat. Nach Aufbau des Algorithmus wird die Maschine k nur dann alloziert, wenn auf jede Maschine 1 . . . k − 1 einen Task existiert, der einen konflikt mit dem Task i besitzt. Da wir die Task in der Reihenfolge ihrer Startzeit betrachten, haben alle betrachteten Tasks eine Startzeit die kleiner oder gleich ist wie si und haben eine Endzeit, die grösser als si ist. Das heisst aber, dass alle betrachteten Tasks auch untereinander einen Konflikt aufweisen (siehe Abbildung 7-7). Wir haben also k Tasks die alle untereinander einen Konflikt aufweisen. Diese können aber unmöglich auf nur k − 1 Maschinen verteilt werden. Daher ist k die minimale Anzahl Maschinen um das Problem zu lösen. 7-16 si Task i Alle Tasks die mit i einen Konflikt haben sind untereinander nicht konfliktfrei. Abbildung 7-7: Tasks mit Konflikten zum Task i 7.7 Parallele Algorithmen Prinzip: Um die Performance von Algorithmen zu verbessern, wird die zu lösende Aufgabe auf mehreren Prozessoren verteilt. Beispiele: Matrix Multiplikation: Strassen’s Algorithmus kann gut parallelisiert werden. Vektorströme: Multiplikation eines Vektorstroms mit einer Matrix. Lineare Algebra: Fast alle Algorithmen der linearen Algebra lassen sich gut parallelisieren. In diesem Abschnitt sollen kurz die wichtigsten parallelen Architekturen und zwei parallele Algorithmen vorgestellt werden. Diese kurze Einführung erhebt keinen Anspruch auf Vollständigkeit. 7.7.1 Pipelining Pipelining ist ein altes Konzept (zum Beispiel ist es schon im Z-80 Prozessor realisiert). Die Idee ist folgende: Die auszuführenden Operationen werden in Teiloperationen aufgeteilt und für jede Teiloperation existiert im Prozessor eine unabhängige Einheit, die diese Teiloperation ausführen kann. Nun kann die erste Teiloperation für die aktuellen Operanden ausgeführt werden. Sobald diese beendet ist, beginnt die zweite Teiloperation für die ersten Operanden und gleichzeitig die erste Operation für die nächsten Operanden. Beispiel 7.5 [Pipelining] In einem Mikroprozessor kann die Ausführung einer Instruktion in die folgenden Teiloperationen aufgeteilt werden: • Holen der Instruktion und der Operanden vom Memory. • Ausführen der Operation. 7-17 Existieren nun im Prozessor für diese Operationen zwei Einheiten, die unabhängig voneinander arbeiten können, so kann die nächste Instruktion vom Memory gelesen werden, währenddem die aktuelle Instruktion ausgeführt wird. Beispiel 7.6 [Addition von Vektoren] Wir wollen zwei n-dimensionale Vektoren addieren. (x1 , . . . , xn ) + (y1 , . . . , yn ) = (x1 + y1 , . . . , xn + yn ) Die Addition von Zahlen wird nun in kleinere Teiloperationen Op1 , . . . , Opm unterteilt. Für jede Teiloperation existiert im Prozessor eine Rechnungseinheit, die unabhängig von den anderen Rechnungseinheiten arbeiten kann. Sobald nun die Teiloperation Op1 für die ersten Komponenten der Vektoren beendet ist, kann mit der Teiloperation Op2 begonnen werden und gleichzeitig kann die Teiloperation Op1 für die zweite Komponente der Vektoren in Angriff genommen werden. Die Abbildung 7-8 veranschaulicht dieses Prinzip. x1 + y1 x2 + y2 x3 + y3 x4 + y4 x4 + y4 x3 + y3 x2 + y2 x1 + y1 t Zeitdifferenz Abbildung 7-8: Pipelining 7.7.2 Vektor Rechner Normalerweise besitzt ein Prozessor eine gewisse Anzahl Register in denen je ein Prozessorwort (binäre Zahl) gespeichert werden kann. Beim Vektorrechner besteht ein Register nicht aus einem skalaren Wert, sondern aus einem Vektor. Eine Addition von zwei Registern ist dann entsprechend eine Vektoraddition der beiden Vektoren. Die Addition der einzelnen Vektorkomponenten werden alle parallel ausgeführt. Solche Prozessoren sind natürlich vor allem zum Rechnen geeignet und werden in wissenschaftlichen Applikationen verwendet. Standardbeispiele für Vektorrechner sind die CRAY Supercomputer. Für diese Maschinen existieren FORTRAN Compiler, welche die Vektorarchitektur ausnutzen. Das Prinzip dabei ist, die innersten Loops eines Programms zu finden und diese wenn immer möglich durch Vektoroperationen zu ersetzen. Beispiel 7.7 [Vektoroperationen] Der Loop FOR i = 1 TO N C[i] := A[i] + B[i] kann durch die entsprechende Vektoraddition ~ := A ~+B ~ C 7-18 ersetzt werden. Der Vorteil bei diesem Vorgehen ist, dass bestehende Programme nicht speziell für die neue Architektur umprogrammiert werden müssen. 7.7.3 Multiprozessor Systeme Bei dieser Architektur wird die Parallelität durch den Einsatz von mehreren Prozessoren, die miteinander kommunizieren, erreicht. Dabei können zwei verschiedene Philosophien angewandt werden. 7.7.3.1 Eng gekoppelte Systeme Diese Systeme benutzen zur Kommunikation und Synchronisation ein gemeinsames Memory. Die Prozessoren sind über einen Bus verbunden. Jeder Prozessor kann zusätzlich über ein privates Cachememory verfügen, wo ein Teil der Informationen des Hauptspeichers dupliziert sind. Ein eng gekoppeltes System ist in der Abbildung 7-9 dargestellt. P1 P2 P3 Pn C C C C Memory Abbildung 7-9: Eng gekoppeltes System Bei eng gekoppelten Systemen kommt es sehr oft zur Überlastung des Buses. Werden zu viele Prozessoren an den Bus geschaltet, so verbringen diese die meiste Zeit mit Warten, weil der Bus nicht frei ist. Durch das zusätzliche Cachememory in jedem Prozessor wird diese Situation zwar entschärft, es entsteht aber das Problem der Cache Konsistenz. 7.7.3.2 Lose gekoppelte Systeme Diese Systeme basieren auf dem Prinzip des Nachrichtenaustausches über ein Netzwerk. Jeder Prozessor hat ein eigenes privates Memory und eine oder mehrere Punkt zu Punkt Verbindungen zu anderen Prozessoren im System. In einem solchen System existiert kein gemeinsames Memory. Alle Informationen zwischen Prozessoren werden ausschliesslich über Kommunikationskanäle ausgetauscht. Ein lose gekoppeltes System ist in der Abbildung 7-10 dargestellt. Typische Repräsentanten für solche Systeme sind Transputernetzwerke. 7-19 M P1 M P4 M M P3 P2 Abbildung 7-10: Lose gekoppeltes System 7.7.4 Klassifikation von Flynn Flynn hat für die Klassifikation von Computerarchitekturen folgendes Schema vorgeschlagen. SISD MIMD MISD SIMD S M Dabei bedeuten: I D Single Multiple Instruction stream Data stream Diese Klassifikation können wir folgendrmassen interpretieren: SISD: Single Instruction Single Data Das ist die klassische von Neumann Architektur. SIMD: Single Instruction Multiple Data Das sind die Vetorprozessoren, bei denen dieselbe Instruktion auf mehreren Datenströmen (Komponenten der Vektoren) gleichzeitig ausgeführt wird. MIMD: Multiple Instruction Multiple Data Das sind die Prozessornetzwerke. Auf verschiedenen Datenströmen können unabhängig voneinander auch verschiedene Instruktionen ausgeführt werden. MISD Multiple Instruction Single Data Bei dieser Klasse gibt es unter den Experten einen Streit. Die einen behaupten, dass eine solche Architektur nicht existiert. Andere interpretieren das Pipelining als MISD, was sicher auch vertretbar ist. Beim Pipelining werden auf demselben Datenstrom gleichzeitig mehrere Instruktionen ausgeführt. 7-20 7.7.5 Parallele Algorithmen In diesem Abschnitt werden zwei parallele Algorithmen vorgestellt. Der erste dieser Algorithmen illustriert, wie die Parallelität als Designmethode (oder auch als Denkweise) verwendet werden kann. Der zweite Algorithmus illustriert, wie die Lösung einer Aufgabe mit Hilfe der Parallelität verschnellert werden kann. 7.7.5.1 Lochkartenproblem Bei diesem Algorithmus wird die Denkweise beim parallelen Programmieren an Hand eines typischen Beispiel von Jackson illustriert. Aufgabe: • Es sollen Lochkarten a je 80 Zeichen gelesen werden. • Nach jeder Karte muss im Zeichenstrom ein Blank eingefügt werden. • Die Zeichen sollen in Zeilen a je 132 Zeichen auf einem Drucker ausgegeben werden. Nach 132 Zeichen muss also ein Newline im Datenstrom eingefügt werden. • Die letzte Zeile muss, falls nötig, mit Blank aufgefüllt werden. Dieses Problem ist klassisch nicht einfach zu lösen, da nicht klar ist, ob der äussere Loop über die Karten oder über die Druckerzeilen gehen soll. In einem parallelen System stellen wir folgende Überlegungen an: Die Teilaufgabe, einen Zeichenstrom zu lesen, und nach 80 Zeichen ein Blank einzufügen, ist logisch unabhängig von der Aufgabe einen Zeichenstrom zu lesen, und nach 132 Zeichen ein Newline einzufügen. Diese Überlegung führt dazu, zwei durch einen Kanal verbundene Prozesse einzuführen, die je eine der Teilaufgaben übernehmen. Die Anordnung der Prozesse und der Kanäle ist in der Abbildung 7-11 dargestellt. C1 C2 P1 C3 P2 Abbildung 7-11: Prozessgraph für das Druckproblem Wir wollen in Worten beschreiben, was die Prozesse P1 und P2 tun: Prozess P1 while noch Charakter von c1 lies 80 Charakter von c1 und schreibe diese auf c2. schreibe Blank auf c2. 7-21 Prozess P2 while noch Charakter von c2 lies 132 Charakter von c2 und schreibe diese auf c3. schreibe Newline auf c3. if noch Charakters vorhanden schreibe entsprechend viele Blanks und ein Newline auf c3 7.7.5.2 Multiplikation eines Vektorstroms mit einer Matrix Wir wollen ein Problem aus der linearen Algebra behandeln. Es geht darum, einen Strom von 3-dimensionalen Vektoren mit einer (3 × 3) Matrix zu multiplizieren. (Lineare Abbildung im Raum). Die für die Lösung dieser Aufgabe benötigten Prozesse und Kanäle sind in der Abbildung 7-12 dargestellt. 0 0 0 x1 x1 P[1,1] sum + a[1,1]*x1 x1 P[1,2] sum + a[1,2]*x1 sum + a[2,1]*x2 x2 P[2,2] sum + a[2,2]*x2 P[3,1] sum + a[3,1]*x3 S2 sum + a[2,3]*x2 x3 P[3,2] sum + a[3,2]*x3 y1 x2 P[2,3] x3 x3 S1 sum + a[1,3]*x1 x2 P[2,1] x2 x1 P[1,3] x3 P[3,3] S3 sum + a[3,3]*x3 y2 y3 Abbildung 7-12: Multiplikation von Vektoren mit einer Matrix Wir beschreiben nun den Anfangszustand und was die einzelnen Prozesse in diesem System tun. Beschreibung des Gesamtsystems gespeichert. • Im Prozess P[i,j] ist das Element a[i,j] der Matrix • Von den drei Kanälen im Westen können die einzelnen Komponenten des Vekorstreams gelesen werden. 7-22 • Die drei Kanäle im Norden produzieren je einen Datenstream, die aus lauter Nullen bestehen. • Das Resultat kann an den drei Kanälen im Süden abgelesen werden. Beschreibung der Prozesse S1 . . . S3 Diese drei Prozesse werden nur als Senken verwendet. Sie lesen den Input aus Westen und sonst nichts. Beschreibung der Prozesse P[i,j ] Alle neun Prozesse P[i,j] tun genau das Gleiche. 1. Lesen des Inputs von Westen auf die Variable x. 2. Lesen des Inputs von Norden auf die Variable sum. 3. Schreiben des Wertes von x nach Osten. 4. Schreiben des Wertes sum + a[i,j] * x nach Süden. Es ist einfach zu sehen, dass im Süden des Systems das gewünschte Resultat gelesen werden kann (siehe auch die Abbildung 7-12). Die Prozesse des obigen Systems können echt parallel arbeiten. 7-23 Abbildungsverzeichnis 1-1 Abstraktion von Personen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 1-1 1-2 Die REALs auf der reellen Zahlenachse . . . . . . . . . . . . . . . . . . . . . . . 1-5 1-3 Die Struktur Stack . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 1-16 1-4 Die Struktur Queue . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 1-16 1-5 Allgemeine Baumstruktur als Graph . . . . . . . . . . . . . . . . . . . . . . . . 1-17 1-6 Die verschiedenen binären Bäume . . . . . . . . . . . . . . . . . . . . . . . . . . 1-18 1-7 Darstellung eines allgemeinen Baumes als binärer Baum . . . . . . . . . . . . . 1-20 1-8 Syntaxbaum für den Ausdruck ((a + b) · c − d/e) · 7 . . . . . . . . . . . . . . . . 1-22 2-1 Diverse Algorithmen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 2-1 2-2 Die Turingmaschine . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 2-3 2-3 Ablauf der Funktion stopp_tester . . . . . . . . . . . . . . . . . . . . . . . . 2-6 2-4 Ablauf der Funktion stopp_tester_neu . . . . . . . . . . . . . . . . . . . . . 2-6 2-5 Ablauf von unsinn(unsinn) . . . . . . . . . . . . . . . . . . . . . . . . . . . . 2-7 2-6 Durchführbare Algorithmen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 2-8 2-7 Standardalgorithmus zur Multiplikation . . . . . . . . . . . . . . . . . . . . . . 2-8 2-8 “Natürliche” Länge von Problemen . . . . . . . . . . . . . . . . . . . . . . . . . 2-9 2-9 Zeitverhalten von Algorithmen . . . . . . . . . . . . . . . . . . . . . . . . . . . 2-11 2-10 Ausführungszeiten von Algorithmen . . . . . . . . . . . . . . . . . . . . . . . . 2-11 2-11 (a) kreieren des Arrays B (b) kopieren der Elemente von A nach B (c) setzen von A=B2-13 2-12 (a) Array mit 8 Elementen. Die Elemente 4 bis 7 besitzen je 2 Cyberdollar (b) neuer Array die C 2-13 (a) Grösse wird verdoppelt (b) Array wird immer um 3 Elemente vergrössert . 2-15 3-1 Der Heap als Array . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 3-7 3-2 Einfügen im Heap . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 3-8 3-3 Löschen im Heap . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 3-8 3-4 Konstruktion des Heaps . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 3-9 3-5 Partitionierungsprozess von Quicksort . . . . . . . . . . . . . . . . . . . . . . . 3-12 3-6 Komplexitätsanalyse von randomized Quicksort . . . . . . . . . . . . . . . . . . 3-16 3-7 Funktionsweise von Replacement Selection . . . . . . . . . . . . . . . . . . . . . 3-19 24 4-1 Hashing (Schlüsseltransformation) . . . . . . . . . . . . . . . . . . . . . . . . . 4-4 4-2 Die beiden Operationen der Hashfunktion . . . . . . . . . . . . . . . . . . . . . 4-6 4-3 Hashing mit separat chaining . . . . . . . . . . . . . . . . . . . . . . . . . . . . 4-8 4-4 Double hashing . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 4-9 4-5 Suchen mit double hashing . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 4-10 4-6 Ein 2-3-4-Baum . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 4-19 4-7 Einfügen von Schlüsseln im 2-3-4-Baum . . . . . . . . . . . . . . . . . . . . . . 4-19 4-8 Aufspalten der 4-Knoten beim Einfügen . . . . . . . . . . . . . . . . . . . . . . 4-20 4-9 Löschen im 234-Baum . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 4-21 4-10 Darstellung von 4- und 3-Knoten . . . . . . . . . . . . . . . . . . . . . . . . . . 4-22 4-11 Ein Rot-Schwarz-Baum . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 4-22 4-12 Restrukturierung des Rot-Schwarz-Baumes . . . . . . . . . . . . . . . . . . . . 4-23 4-13 Spliten eines 4-Knotens in Rot-Schwarz-Bäume . . . . . . . . . . . . . . . . . . 4-24 4-14 Verschmelzen zweier Knoten im Rot-Schwarz-Baum . . . . . . . . . . . . . . . 4-24 4-15 Fortpflanzung des “doppel schwarz” Problems im Rot-Schwarz-Bäume . . . . . 4-25 4-16 Ausleihen eines Schlüssels beim Nachbarn . . . . . . . . . . . . . . . . . . . . . 4-26 4-17 Anpassungsoperation im Rot-Schwarz-Baum . . . . . . . . . . . . . . . . . . . . 4-26 4-18 Die zig-zig Operation: (a) vor der Opeation (b) nach der Operation. Es gibt einen zweiten symme 4-19 Die zig-zag Operation: (a) vor der Operation (b) nach der Operation. Es gibt einen zweiten symm 4-20 Die zig Operation . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 4-28 4-21 Splay-Operation (1): (a) Splay von Knoten 14 beginnt mit einer zig-zag Operation. (b) nach zig-z 4-22 Splay-Operation (2): (d) nach zig-zig (e) nächster Schritt ist wieder zig-zig (f) die Splay-Operatio 4-23 Einfügen im Splay Baum: (a) Anfangsbaum (b) einfügen von 2 (c) nach der Splay-Operation (d) 4-24 Löschen im Splay Baum: (a) löschen des Schlüssels 8. Dabei wird der Knoten mit dem Schlüssel 7 4-25 B-Tree der Ordnung 2 mit 3 Stufen . . . . . . . . . . . . . . . . . . . . . . . . . 4-37 4-26 Knoten eines B-Trees . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 4-37 4-27 Einfügen im B-Tree . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 4-38 4-28 Wachstum eines B-Tree der Ordnung 1 . . . . . . . . . . . . . . . . . . . . . . . 4-39 4-29 Ausgleich im B-Tree . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 4-40 4-30 Zusammenlegen zweier Knoten im B-Tree . . . . . . . . . . . . . . . . . . . . . 4-40 4-31 B-Tree mit lauter gleichen Schlüssel . . . . . . . . . . . . . . . . . . . . . . . . 4-41 4-32 Beispiel einer Skipliste . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 4-42 4-33 Suchen des Schlüssels 50 in der Skipliste . . . . . . . . . . . . . . . . . . . . . . 4-43 4-34 Einfügen des Schlüssels 42 in der Skipliste . . . . . . . . . . . . . . . . . . . . . 4-44 4-35 Löschen des Schlüssels 25 aus der Skipliste . . . . . . . . . . . . . . . . . . . . . 4-45 5-1 Königsberger Brückenproblem . . . . . . . . . . . . . . . . . . . . . . . . . . . . 5-1 25 5-2 Beispiel eines gerichteten Graphen . . . . . . . . . . . . . . . . . . . . . . . . . 5-2 5-3 Beispiel eines ungerichteten Graphen . . . . . . . . . . . . . . . . . . . . . . . . 5-4 5-4 Adjazenzliste . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 5-6 5-5 Adjazenzliste für ungerichteten Graphen . . . . . . . . . . . . . . . . . . . . . . 5-6 5-6 Adjazenzmatrix . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 5-7 5-7 Adjazenzmatrix für ungerichteten Graphen . . . . . . . . . . . . . . . . . . . . 5-7 5-8 Ein gerichteter Graph . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 5-10 5-9 Tiefenwald zum obigen Graphen . . . . . . . . . . . . . . . . . . . . . . . . . . 5-10 5-10 Jede Rückwärtskante bildet mit Baumkanten einen Zykel . . . . . . . . . . . . 5-12 5-11 Ein gerichteter azyklischer Graph . . . . . . . . . . . . . . . . . . . . . . . . . . 5-12 5-12 Zusammenhangskomponenten eines Graphen . . . . . . . . . . . . . . . . . . . 5-13 5-13 Algorithmus von Kruskal . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 5-15 5-14 Bestimmen der Zusammenhangskomponenten . . . . . . . . . . . . . . . . . . . 5-16 5-15 Gesetzte Knoten und spezielle Pfade . . . . . . . . . . . . . . . . . . . . . . . . 5-18 5-16 Ein gewichteter Graph . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 5-19 5-17 kürzester Pfad nach v . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 5-20 5-18 Vorletzter Knoten des kürzesten speziellen Pfades . . . . . . . . . . . . . . . . . 5-20 5-19 Verwenden des Knotens u um den Weg zwischen v und w zu verkürzen . . . . . 5-21 5-20 Ein gewichteter Graph . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 5-22 5-21 Ein (k + 1)-Pfad ist aus zwei k-Pfade R und Q zusammengesetzt. . . . . . . . . 5-23 5-22 Ein Flussgraph mit einem gültigen Fluss f (mit Wert |f | = 10) . . . . . . . . . 5-25 5-23 Ein maximaler Fluss f ∗ (mit Wert |f ∗ | = 14) für den Flussgraphen N . . . . . 5-26 5-24 Ein Flussgraph mit drei Schnitte X1 , X2 und X3 . . . . . . . . . . . . . . . . . 5-27 5-25 Ein nutzbarer Pfad π mit ∆f (π) = 2 für den Fluss f im Flussgraphen N . . . . 5-29 5-26 Der Fluss f ′ der aus dem Fluss f entsteht . . . . . . . . . . . . . . . . . . . . . 5-29 5-27 Der Algorithmus von Ford-Fulkerson . . . . . . . . . . . . . . . . . . . . . . . . 5-31 5-28 Falls die nutzbaren Pfade zwischen (q, v1 , v2 , s) und (q, v2 , v1 , s) alternieren so braucht es 2,000,00 6-1 Trie zum Code A = 11, B = 00, C = 10, D = 010 und E = 011 . . . . . . . . . 6-9 6-2 Dekodieren mit Hilfe eines Tries . . . . . . . . . . . . . . . . . . . . . . . . . . . 6-9 6-3 Erzeugung des Huffman-Codes . . . . . . . . . . . . . . . . . . . . . . . . . . . 6-11 7-1 Ein ungerichteter Graph . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 7-3 7-2 Suchbaum für den Graphen aus Abb. 7-1 . . . . . . . . . . . . . . . . . . . . . 7-4 7-3 Ablauf des DP Algorithmus für knapsack . . . . . . . . . . . . . . . . . . . . . 7-13 7-4 Hill climbing . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 7-15 7-5 Erreichen eines lokalen Maximums mit Greedy . . . . . . . . . . . . . . . . . . 7-15 7-6 Verteilung der Tasks {(1, 3), (1, 4), (2, 5), (3, 7), (4, 7), (6, 9), (7, 8)} . . . . . . . . 7-16 26 7-7 Tasks mit Konflikten zum Task i . . . . . . . . . . . . . . . . . . . . . . . . . . 7-17 7-8 Pipelining . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 7-18 7-9 Eng gekoppeltes System . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 7-19 7-10 Lose gekoppeltes System . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 7-20 7-11 Prozessgraph für das Druckproblem . . . . . . . . . . . . . . . . . . . . . . . . 7-21 7-12 Multiplikation von Vektoren mit einer Matrix . . . . . . . . . . . . . . . . . . . 7-22 27 Literaturverzeichnis [AHU74] Aho, Hopcroft, and Ullman. The Design and Analysis of Computer Algorithms. Addison Wesley, Reading, Massachusetts, 1974. ISBN 0-201-00029-6. [AU95] A.V. Aho and J.D. Ullman. Foundations Of Computer Science C Edition. Computer Science Press An Imprint of W.H. Freeman and Company, New York, 1995. ISBN 0-7167-8284-7. [GT02] M.T. Goodrich and R. Tamassia. Algorithm Design Foundations, Analysis and Internet Examples. John Wiley & Sons,Inc., New York, 2002. ISBN 0-471-38365-1. [GT04] M.T. Goodrich and R. Tamassia. Data Structures and Algorithms in Java. John Wiley & Sons,Inc., New York, 3rd edition, 2004. ISBN 0-471-46983-1. [Knu73a] D.E. Knuth. The Art of Computer Programing, volume 1 Fundamental Algorithms. Addison Wesley, Reading, MA, 1973. ISBN 0-201-03809-9. [Knu73b] D.E. Knuth. The Art of Computer Programing, volume 3 Sorting and Searching. Addison Wesley, Reading, MA, 1973. ISBN 0-201-03803-X. [Knu81] D.E. Knuth. The Art of Computer Programing, volume 2 Seminumerical algorithms. Addison Wesley, Reading, MA, 1981. ISBN 0-201-03822-6. [Sed02] Robert Sedgewick. Algorithms in Java. Addison Wesley, 2002. ISBN 978-0-20136120-9. 28 Index 2-3-4-Baum, 4-18 Averrage, 2-10 Abgeleitete Typen Funktionsräume, 1-10 Strukturierter Typ, 1-10 Subtyp, 1-10 Unterbereichstyp, 1-10 Abstraktion, 1-1 Accessoren, 1-26 adjazent, 5-2, 5-3 Adjazenzliste, 5-6 Adjazenzmatrix, 5-6 ADT, 1-23, 4-1 Accessoren, 1-26 Formale Spezifikation, 1-24 Geheimnisprinzip, 1-23 generische Parameter, 1-30 Transformer, 1-26 Wiederverwendbarkeit, 1-24 Algorithmus, 2-4, 2-5 Brute Force, 6-1 durchführbar, 2-8, 2-11 polynomial, 2-11 exponentiell, 2-11 Alphabet, 6-1 Amortisation, 2-11 amortisations Schema, 2-12 Array Grundtyp, 1-11 Indextyp, 1-11 Selektor, 1-12 assymptotische Verhalten, 2-9 assymptotisches Verhalten, 2-10 Atomare Typen, 1-4 BOOLEAN, 1-9 CHAR, 1-9 INTEGER, 1-4 REAL, 1-5 Ausgeglichener Baum 2-3-4-Baum, 4-18 B-Tree, 4-36 Rot-Schwarz-Baum, 4-21 B-Tree, 4-36 Definition, 4-36 Ordnung, 4-36, 4-41 Backtracking, 7-1 Aufwand, 7-3 Baum Blatt, 1-17 geordnet, 1-18 Grad, 1-18 innerer Knoten, 1-17 Kante, 1-17 Knoten, 1-17 Tiefe, 1-18 Vorgänger, 1-17 Wurzel, 1-17 Betriebsmittel, 2-7 Binärer Baum fast vollständiger, 1-18 strenger, 1-18 vollständiger, 1-18 Binärer Suchbaum, 4-18 Binäres Suchen, 1-12 Blatt, 1-17 BOOLEAN, 1-9 Boyer-Moore Algorithmus, 6-4 Branch and Bound, 7-4 Aufwand, 7-6 Breitensuche, 5-9 Brute Force, 6-1 CHAR, 1-9 Computer-Daten, 1-3 Semantik, 1-3 Speicher, 1-3 Syntax, 1-3 Wertemenge, 1-3 Cooks Theorem, 2-18 DAC-Algorithmus, 3-10 Daten Abstraktion, 1-1 29 Darstellung, 1-2 Fixpoint, 1-3 Floatingpoint, 1-2 Datenmodell, 1-1 Datentyp, 1-4 atomar, 1-4 Konstante, 1-4 Operator, 1-4 Wertemenge, 1-4 deterministisch, 2-1, 2-16 Algorithmus Effektivität, 2-2 Eindeutigkeit, 2-1 Vollständigkeit, 2-1 digitaler Suchbaum, 6-8 Divide-And-Conquer, 7-6 Divisionsmethode, 4-7 double hashing, 4-8 downheap Verfahren, 3-8 durchführbar, 2-8, 2-11 Dynamic Programming, 7-9 Edmonds-Karp Algorithmus, 5-32 embedded key, 3-1 Endliche Arithmetik, 1-5–1-9 eng gekoppelt, 7-19 entscheidbar, 2-16 Exponent, 1-2 exponentiell, 2-11 externer Sort, 3-1 Färbungsproblem, 7-1 Fehler, 1-7 absolut, 1-7 relativ, 1-7 Fixpoint, 1-3 Floatingpoint, 1-2 Exponent, 1-2 Mantisse, 1-2, 1-5 normalisiert, 1-2 Floyd Algorithmus, 7-9 Fluss, 5-24 maximaler, 5-28 Flussgraph, 5-24 nutzbarer Pfad, 5-28 Quelle, 5-24 Schnitt, 5-26 Senke, 5-24 Flussprobleme, 5-24 Ford-fulkerson Algorithmus, 5-31 Funktion assymptotisches Verhalten, 2-10 dominierender Ausdruck, 2-9 Ordnung, 2-9 Funktionsräume, 1-10 Geheimnisprinzip, 1-23 geichtete Kante, 5-2 generische Parameter, 1-30 Gerichteter Graph, 5-2 gerichteter Graph schwach zusammenhängend, 5-13 stark zusammenhängend, 5-14 gewichteter Graph, 5-14 Grad, 1-18 Graph Fluss, 5-24 maximaler, 5-25 gerichtet, 5-2 gewichtet, 5-14 Postorder Nummerierung, 5-11 Traversierung Breitensuche, 5-9 Tiefensuche, 5-8 ungerichtet, 5-3 zusammenhängend, 5-13 zyklisch, 5-3 Graph als ADT, 5-4–5-5 greedy Methode, 7-14 Grundtyp, 1-11, 1-14 Hamilton Kreis, 2-19 Hamilton Problem, 5-1 Hamilton Zyklen, 7-1, 7-2 Hashcode, 4-5 Hashfunktion, 4-4 Hashing, 4-4 Divisionsmethode, 4-7 Kollisionen, 4-5 double hashing, 4-8 linear probing, 4-10 Separat Chaining, 4-8 Kompression, 4-5 Loadfactor, 4-11 MAD methode, 4-7 Matrixverfahren, 4-13 polynomialer Hashcode, 4-6 pseudorandom, 4-12 Synonyme, 4-5 Hashtabelle, 4-4 Heap, 3-6 Heap Aufbau, 3-8 30 Heapbedingung, 3-6 Hill climbing, 7-14 Huffman Kodierung, 6-9 Indextyp, 1-11 innerer Knoten, 1-17 Inorder, 1-22 inplace Sort, 3-1 INTEGER, 1-4 internal key, 3-1 interner Sort, 3-1 inzident, 5-2, 5-3 Kante, 1-17 adjazent, 5-2, 5-3 Gewicht, 5-14 inzident, 5-2, 5-3 Kapazität, 5-24 Restkapazität, 5-28 Kapazität, 5-24 Kapazität, 5-26 Klassifikation von Flynn, 7-20 Knapsack, 7-12 Knoten, 1-17, 5-2 Knuth-Morris-Pratt Algorithmus, 6-2 Kodierung Läufe, 6-6 runs, 6-6 Kodierung mit variabler Länge, 6-7 Königsberger Brückenproblem, 5-1 Kollisionen, 4-5 Komplexität Averrage, 2-10 Speicherbedarf, 2-7 Worst Case, 2-10 Zeit, 2-7 Kompression, 4-5 Konstante, 1-4 Kruskal Algorithmus, 5-14 kürzeste Pfade, 5-18 Dijkstra, 5-18–5-20, 5-23 Floyd, 5-21 Läufe, 6-6 Lauflängenkodierung, 6-6 linear probing, 4-10 Liste Grundty, 1-14 Sequentielle Repräsentation, 1-15 Verkettete Repräsentation, 1-15 Loadfactor, 4-11 lose gekoppelt, 7-19 MAD methode, 4-7 Mantisse, 1-2, 1-5 Maschinenzahlen, 1-5 Repräsentant, 1-5 Matrixmultiplikation, 7-6 maximaler Fluss, 5-25, 5-28 MIMD, 7-20 minimal spanning tree, 5-14 minimaler aufspannender Baum, 5-14 Mischen ausgeglichen, 3-17 Mehrphasen, 3-19 n-Weg, 3-16 Replacement Selection, 3-18 MISD, 7-20 Modell, 1-1 Muster, 6-1 natürlichen Länge, 2-9 Netzplan, 5-2 nichtdeterministisch, 2-17 nichtdeterministischen, 2-17 NP-schwer, 2-18 NP-vollständig, 2-18 numerische Auslöschung, 1-7 numerische Stabilität, 1-7 nutzbarer Pfad, 5-28 Operator, 1-4 Ordnung, 2-9 Overflow, 1-5 Palindrom, 2-4 parallele Algorithmen, 7-17 paralleler Algorithmus, 2-2 Partitionierung, 3-11 Petrinetze, 5-2 Pfad, 5-3 Pipelining, 7-17 pivot, 3-11, 3-13 polynomial, 2-11 polynomial reduzierbar, 2-18 polynomialer Hashcode, 4-6 Postorder, 1-21 Preorder, 1-21 pringer-Problem, 7-1 Priority Queue, 3-5 Prozess, 2-1 Prozessor, 2-1 31 pseudo polynomial, 7-14 Quicksort, 7-6 iterativ, 3-14 Komplexität, 3-14 randomized, 3-15 rekursiv, 3-12 Randomized Quicksort, 3-15 REAL, 1-5 Redundanz, 6-6 Rekursion, 1-20–1-23, 7-1 Rekursionsgrund, 1-20 Rekursionstiefe, 1-20 Restkapazität, 5-28 Rot-Schwarz-Baum, 4-21 Interne Eigenschaft, 4-22 Tiefen Eigenschaft, 4-22 Wurzel Eigenschaft, 4-22 Rucksack Problem, 7-12 runs, 6-6 schlecht konditioniert, 1-9 Schlinge, 5-3 Schlüssel, 3-1 Schnitt, 5-26 Kapazität, 5-26 minimaler, 5-26 Rückwärtskante, 5-26 Vorwärtskante, 5-26 schwierige Probleme, 2-17 Selektor, 1-12, 1-14 Semantik, 1-3, 1-4 Separat Chaining, 4-8 SIMD, 7-20 SISD, 7-20 Skipliste Definition, 4-42 Höhe, 4-45 Sortierverfahren Auswahl, 3-2 Einfügen, 3-3 extern, 3-1 inplace, 3-1 intern, 3-1 stabil, 3-1 Vertauschen, 3-3 Splay-Baum, 4-27 amortisierte Kosten, 4-31 Rang, 4-31 Size, 4-31 zig Operation, 4-28 zig-zag Operation, 4-27 zig-zig Operation, 4-27 Splay-Operation, 4-27 Sprache, 2-16 entscheidbar, 2-16 stabiler Sort, 3-1 Strassen Matrix-Multiplikation, 7-6 Strategien, 7-1 String, 6-1 Muster, 6-1 Strukturierter Typ, 1-10 Subtyp, 1-10 Suchstruktur, 4-1 Synonyme, 4-5 Syntax, 1-3, 1-4 Task scheduling, 7-15 teleskopische Summe, 2-12 Tiefe, 1-18 Tiefensuchbaum, 5-9 Tiefensuche, 5-8 Tiefensuchwald, 5-9 Baumkante, 5-10 Querkante, 5-10 Rückwärtskante, 5-10 Vorwärtskante, 5-10 topologische Ordnung, 5-12 Transformer, 1-26 Traveling Salesman, 7-1, 7-3 Traveling salesman, 2-19 Traversierung Inorder, 1-22 Postorder, 1-21 Preorder, 1-21 trial-and-error, 7-1 Trie, 6-8, 6-10 Typkonstruktor, 1-4 Underflow, 1-5, 1-6 ungerichteter Graph, 5-3 universelles Hashing, 4-12 Unterbäume, 1-17 Unterbereichstyp, 1-10 upheap Verfahren, 3-7 Vektor Rechner, 7-18 Vorgaenger Vorgänger, 5-3 Vorgänger, 1-17 32 Wertemenge, 1-3, 1-4 Wiederverwendbarkeit, 1-24 Worst Case, 2-10 Wurzel, 1-17 Zusammenhangskomponente, 5-13 Zyklus, 5-3, 5-4, 5-11 einfach, 5-3, 5-4 schlinge, 5-3 33 Inhaltsverzeichnis 1 Daten 1-1 1.1 Einleitung . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 1-1 1.2 Datentypen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 1-4 1.3 Atomare Typen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 1-4 1.4 1.5 1.6 1.3.1 Ganze Zahlen (INTEGER) . . . . . . . . . . . . . . . . . . . . . . . . . 1-4 1.3.2 Reelle Zahlen (REAL) . . . . . . . . . . . . . . . . . . . . . . . . . . . . 1-5 1.3.3 Logische Werte (BOOLEAN) . . . . . . . . . . . . . . . . . . . . . . . . 1-9 1.3.4 Text (CHAR) . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 1-9 Abgeleitete Typen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 1-10 1.4.1 Unterbereichstypen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 1-10 1.4.2 Subtypen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 1-11 1.4.3 Die Struktur Array . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 1-11 1.4.4 Allgemeine Struktur . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 1-13 Weitere Datenstrukturen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 1-14 1.5.1 Die Struktur lineare Liste . . . . . . . . . . . . . . . . . . . . . . . . . . 1-14 1.5.2 Spezielle Listen: Stack und Queue . . . . . . . . . . . . . . . . . . . . . 1-15 1.5.3 Der Baum . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 1-16 1.5.4 Rekursion und traversieren von Bäumen . . . . . . . . . . . . . . . . . . 1-20 Der Abstrakte Datentyp . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 1-23 1.6.1 Definition . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 1-23 1.6.2 Formale Spezifikation von ADTs . . . . . . . . . . . . . . . . . . . . . . 1-24 1.6.3 Spezifikation des ADTs in Java . . . . . . . . . . . . . . . . . . . . . . . 1-27 1.6.4 Generische Parameter . . . . . . . . . . . . . . . . . . . . . . . . . . . . 1-30 1.6.5 Implementation in java . . . . . . . . . . . . . . . . . . . . . . . . . . . 1-31 2 Algorithmen 2-1 2.1 Einführung . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 2-1 2.2 Berechenbarkeit . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 2-2 2.2.1 Definition Algorithmus . . . . . . . . . . . . . . . . . . . . . . . . . . . . 2-2 34 2.3 2.2.2 Weitere Definitionen von Algorithmus . . . . . . . . . . . . . . . . . . . 2-4 2.2.3 Die Church-Turing-These . . . . . . . . . . . . . . . . . . . . . . . . . . 2-5 2.2.4 Das Halteproblem . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 2-5 Komplexität von Algorithmen . . . . . . . . . . . . . . . . . . . . . . . . . . . . 2-7 2.3.1 Zeitaufwand von Algorithmen . . . . . . . . . . . . . . . . . . . . . . . . 2-8 2.3.2 Amortisierte Kosten . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 2-11 2.3.3 Die Klassen P und N P . . . . . . . . . . . . . . . . . . . . . . . . . . . 2-15 2.3.4 Lower Bound (LB) . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 2-19 3 Sortieralgorithmen 3-1 3.1 Einführung . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 3-1 3.2 Elementare Sortierverfahren . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 3-1 3.3 3.4 3.5 3.2.1 Sortieren durch Auswählen (selection sort) . . . . . . . . . . . . . . . . . 3-2 3.2.2 Sortieren durch Einfügen (insertion sort) . . . . . . . . . . . . . . . . . . 3-2 3.2.3 Sortieren durch Vertauschen (bubble sort) . . . . . . . . . . . . . . . . . 3-3 3.2.4 Vergleich der elementaren Sortierverfahren . . . . . . . . . . . . . . . . . 3-4 Heapsort . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 3-4 3.3.1 Priorityqueue . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 3-4 3.3.2 Implementation der PQ als Heap . . . . . . . . . . . . . . . . . . . . . . 3-6 3.3.3 Das Sortierverfahren (Heapsort) . . . . . . . . . . . . . . . . . . . . . . 3-10 Quicksort . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 3-10 3.4.1 Divide-And-Conquer Methode 3.4.2 Das Sortierverfahren . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 3-11 Externes Sortieren . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 3-16 3.5.1 Grundoperation Mischen . . . . . . . . . . . . . . . . . . . . . . . . . . . 3-16 3.5.2 Ausgeglichenes Mehrweg-Mischen . . . . . . . . . . . . . . . . . . . . . . 3-17 3.5.3 Replacement Selection . . . . . . . . . . . . . . . . . . . . . . . . . . . . 3-18 3.5.4 Mehrphasen-Mischen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 3-19 4 Suchalgorithmen 4.1 4.2 . . . . . . . . . . . . . . . . . . . . . . . 3-10 4-1 Suchstruktur . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 4-1 4.1.1 Spezifikation der Suchstruktur in Java . . . . . . . . . . . . . . . . . . . 4-2 4.1.2 Implementation als Hashtabelle . . . . . . . . . . . . . . . . . . . . . . . 4-4 4.1.3 Universelles Hashing . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 4-11 Sortiertes Suchen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 4-13 4.2.1 Java Spezifikation der sortierten Suchstruktur . . . . . . . . . . . . . . . 4-14 4.2.2 Implementation als sortierter Array . . . . . . . . . . . . . . . . . . . . 4-16 4.2.3 Implementation als binären Suchbaum . . . . . . . . . . . . . . . . . . . 4-18 35 4.2.4 Implementation als Splay Baum . . . . . . . . . . . . . . . . . . . . . . 4-27 4.2.5 Implementation als B-Tree 4.2.6 Implementation als Skipliste . . . . . . . . . . . . . . . . . . . . . . . . . 4-42 5 Graphen 5-1 5.1 5.2 5.3 5.4 5.5 5.6 5.7 . . . . . . . . . . . . . . . . . . . . . . . . . 4-36 Einleitung . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 5-1 5.1.1 Klassische Probleme . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 5-1 5.1.2 Angewandte Probleme . . . . . . . . . . . . . . . . . . . . . . . . . . . . 5-1 Begriffe . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 5-2 5.2.1 Gerichtete Graphen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 5-2 5.2.2 Ungerichtete Graphen . . . . . . . . . . . . . . . . . . . . . . . . . . . . 5-3 Der Graph als ADT . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 5-4 5.3.1 Methoden des Graphen . . . . . . . . . . . . . . . . . . . . . . . . . . . 5-4 5.3.2 Methoden des gerichteten Graphen . . . . . . . . . . . . . . . . . . . . . 5-5 5.3.3 Methoden des ungerichteten Graphen . . . . . . . . . . . . . . . . . . . 5-5 Implementation von Graphen . . . . . . . . . . . . . . . . . . . . . . . . . . . . 5-5 5.4.1 Implementation als Adjazenzliste . . . . . . . . . . . . . . . . . . . . . . 5-5 5.4.2 Implementation als Adjazenzmatrix . . . . . . . . . . . . . . . . . . . . 5-6 5.4.3 Vergleich der Methoden . . . . . . . . . . . . . . . . . . . . . . . . . . . 5-7 Algorithmen auf Graphen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 5-8 5.5.1 Tiefensuche (Depth-First Search) . . . . . . . . . . . . . . . . . . . . . . 5-8 5.5.2 Breitensuche (Breath-First Search) . . . . . . . . . . . . . . . . . . . . . 5-9 5.5.3 Tiefensuche Wald . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 5-9 5.5.4 Postorder Nummerierung . . . . . . . . . . . . . . . . . . . . . . . . . . 5-10 5.5.5 Anwendungen der Tiefensuche . . . . . . . . . . . . . . . . . . . . . . . 5-11 Gewichtete Graphen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 5-14 5.6.1 Implementation von gewichteten Graphen . . . . . . . . . . . . . . . . . 5-14 5.6.2 Minimal aufspanneder Baum . . . . . . . . . . . . . . . . . . . . . . . . 5-14 5.6.3 Kürzeste Pfade in Graphen (Dijkstra) . . . . . . . . . . . . . . . . . . . 5-17 5.6.4 Kürzeste Pfade in Graphen (Floyd) . . . . . . . . . . . . . . . . . . . . . 5-21 Flussprobleme . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 5-24 5.7.1 Flussgraphen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 5-24 5.7.2 Schnitte . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 5-25 5.7.3 Maximaler Fluss . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 5-28 36 6 Verarbeitung von Strings 6.1 6.2 6-1 Suchen in Strings . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 6-1 6.1.1 Brute Force Algorithmus . . . . . . . . . . . . . . . . . . . . . . . . . . 6-1 6.1.2 Knuth-Morris-Pratt Algorithmus . . . . . . . . . . . . . . . . . . . . . . 6-2 6.1.3 Boyer-Moore Algorithmus . . . . . . . . . . . . . . . . . . . . . . . . . . 6-4 Datenkomprimierung . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 6-6 6.2.1 Lauflängenkodierung . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 6-6 6.2.2 Kodierung mit variabler Länge . . . . . . . . . . . . . . . . . . . . . . . 6-7 6.2.3 Erzeugung des Huffman-Codes . . . . . . . . . . . . . . . . . . . . . . . 6-9 7 Typen von Algorithmen 7-1 7.1 Rekursion . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 7-1 7.2 Backtracking . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 7-1 7.3 7.2.1 Hamilton-Zyklus . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 7-2 7.2.2 Traveling Salesman . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 7-3 Branch and Bound . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 7-4 7.3.1 7.4 Divide-And-Conquer . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 7-6 7.4.1 7.5 7.6 7.7 Traveling Salesman . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 7-4 Matrix-Multiplikation (Strassen) . . . . . . . . . . . . . . . . . . . . . . 7-6 Dynamic Programming (DP) . . . . . . . . . . . . . . . . . . . . . . . . . . . . 7-9 7.5.1 Multiplikation von pxq Matrizen . . . . . . . . . . . . . . . . . . . . . . 7-10 7.5.2 Rucksack-Problem (Knapsack) . . . . . . . . . . . . . . . . . . . . . . . 7-12 Greedy (die gierige Methode) . . . . . . . . . . . . . . . . . . . . . . . . . . . . 7-14 7.6.1 Hill Climbing . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 7-14 7.6.2 Task Scheduling . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 7-15 Parallele Algorithmen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 7-17 7.7.1 Pipelining . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 7-17 7.7.2 Vektor Rechner . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 7-18 7.7.3 Multiprozessor Systeme . . . . . . . . . . . . . . . . . . . . . . . . . . . 7-19 7.7.4 Klassifikation von Flynn . . . . . . . . . . . . . . . . . . . . . . . . . . . 7-20 7.7.5 Parallele Algorithmen . . . . . . . . . . . . . . . . . . . . . . . . . . . . 7-21 Abbildungsverzeichnis 24 Literaturverzeichnis 28 Index 29 Inhaltsverzeichnis 34 37