Algorithmen und Datenstrukturen Werner Struckmann Wintersemester 2005/06 1. Der Algorithmenbegriff 1.1 Der intuitive Algorithmenbegriff 1.2 Ein Beispiel: Sortieren durch Einfügen 1.3 Programmiersprachen: der praktische Algorithmenbegriff 1.4 Berechenbarkeit: der formale Algorithmenbegriff 2. Imperative Algorithmen 3. Sortieralgorithmen 4. Listen und abstrakte Datentypen 5. Objektorientierte Algorithmen 6. Bäume 7. Mengen, Verzeichnisse und Hash-Verfahren 8. Graphen 9. Entwurf von Algorithmen 10. Funktionale und deduktive Algorithmen Der intuitive Algorithmenbegriff Gegeben sei ein „Problem“. Eine Handlungsvorschrift, deren mechanisches Befolgen ◮ ohne Verständnis des Problems ◮ mit sinnvollen Eingabedaten ◮ zur Lösung des Problems führt, wird Algorithmus genannt. Ein Problem, für dessen Lösung ein Algorithmus existiert, heißt berechenbar. 1.1 Der intuitive Algorithmenbegriff 1-1 Beispiele für Algorithmen ◮ Zerlegung handwerklicher Arbeiten in einzelne Schritte, ◮ Kochrezepte, ◮ Verfahren zur schriftlichen Multiplikation, ◮ Algorithmus zur Bestimmung des größten gemeinsamen Teilers zweier natürlicher Zahlen. 1.1 Der intuitive Algorithmenbegriff 1-2 Präzisierung des Begriffs Ein Algorithmus ist eine wohldefinierte Rechenvorschrift, die eine (evtl. leere) Menge von Größen als Eingabe verwendet und eine Menge von Größen als Ausgabe erzeugt. Ein Algorithmus ist also eine Abfolge von Rechenschritten, die die Eingabe in die Ausgabe umwandelt. ◮ Der Algorithmus muss durch einen endlichen Text in einer wohldefinierten Sprache beschrieben sein. ◮ Die Objekte der Berechnung müssen klar sein. ◮ Die Operationen müssen mechanisch ausführbar sein. ◮ Die Reihenfolge der Operationen muss feststehen. 1.1 Der intuitive Algorithmenbegriff 1-3 Eigenschaften von Algorithmen ◮ Terminierend: Für alle korrekten Eingaben hält der Algorithmus nach endlich vielen Schritten an. ◮ Vollständigkeit: Alle Fälle, die bei korrekten Eingabedaten auftreten können, werden berücksichtigt. ◮ Determiniert: Der Algorithmus liefert bei jedem Ablauf mit den gleichen Eingaben das gleiche Ergebnis. ◮ Deterministisch: Der Algorithmus läuft bei jedem Ablauf mit den gleichen Eingaben durch diesselbe Berechnung. 1.1 Der intuitive Algorithmenbegriff 1-4 Beispiele zu Eigenschaften Nichtterminierender Algorithmus: 1. Wähle zufällig eine natürliche Zahl. 2. Ist die Zahl gerade, wiederhole ab 1. 3. Ist die Zahl ungerade wiederhole ab 1. Nicht vollständiger Algorithmus: 1. Wähle zufällig eine Zahl x . 2. Wähle zufällig eine Zahl y . 3. Das Ergebnis ist x /y . Was ist, wenn y = 0 sein sollte? 1.1 Der intuitive Algorithmenbegriff 1-5 Beispiele zu Eigenschaften Nicht determinierter Algorithmus: 1. Wähle zufällig eine natürliche Zahl zwischen 260 und 264 . 2. Prüfe, ob die Zahl eine Primzahl ist. 3. Falls nicht, wiederhole ab 1. Das Ergebnis ist immer eine Primzahl, aber nicht immer die gleiche, daher ist der Algorithmus nicht determiniert. Nichtdeterministischer Algorithmus, jedoch determiniert: 1. Mische das Eingabefeld zufällig. 2. Prüfe, ob jedes Element kleiner als das rechts von ihm stehende ist. 3. Falls nicht, wiederhole ab 1. 1.1 Der intuitive Algorithmenbegriff 1-6 Algorithmen und Datenstrukturen ◮ Algorithmen ◮ ◮ ◮ ◮ ◮ ◮ elementare Operationen sequenzielle, bedingte, wiederholte Ausführung Unterprogramme die Schritte werden an anderer Stelle beschrieben und sind mehrfach verwendbar. Rekursionen derselbe Algorithmus wird auf ein oder mehrere gleichartige Teilprobleme angewendet. „gleichzeitige“ Ausführung Datenstrukturen ◮ ◮ einfache Daten: Zahlen, Zeichen, Wahrheitswerte komplexe Datenstrukturen: Felder, Listen, Bäume 1.1 Der intuitive Algorithmenbegriff 1-7 Codierung von Daten ◮ Ein Code ist eine (berechenbare) Funktion, die jeder Zeichenkette einer Urbildmenge eindeutig eine Zeichenkette aus einer Bildmenge zuordnet. ◮ In technischen Systemen dienen Codes überwiegend der Darstellung von Nachrichten. ◮ Codierungen ermöglichen beispielsweise die Verwendung von Nachrichten in Rechnern und die Übertragung von Nachrichten. ◮ Beispiel „Zahldarstellung in Stellenwertsystemen“: (22)10 = (112)4 = (10110)2 ◮ Beispiel „ASCII-Code zur Darstellung von Zeichen“: B 7−→ 66 7−→ 1000010 1.1 Der intuitive Algorithmenbegriff 1-8 Sortieren durch Einfügen ◮ ◮ Spezifikation: Eine Liste < a1 , a2 , . . . , an > von n ganzen Zahlen, n ≥ 1, soll aufsteigend sortiert werden. Objekte: Zahlen, Listen von Zahlen. ◮ Operationen: Vergleiche, Einfügen in Liste, Löschen aus Liste. ◮ Eingabe: Eine Liste < a1 , a2 , . . . , an > von n Zahlen. ◮ Ausgabe: Eine Permutation < a1′ , a2′ , . . . , an′ > der Eingabe mit a1′ ≤ a2′ ≤ . . . ≤ an′ . Die zu sortierenden Zahlen werden auch als Schlüssel bezeichnet. Beim Sortieren durch Einfügen wird jede Zahl aus der Ausgangsliste an die richtige Stelle in der Zielliste eingefügt. 1.2 Ein Beispiel: Sortieren durch Einfügen 1-9 Sortieren durch Einfügen 1. Fange beim zweiten Element an. 2. Bewege die Zahl an die richtige Stelle der Liste 3. Schiebe dazu solange Elemente in der Liste nach rechts bis die richtige Stelle erreicht ist. 4. Wiederhole die Schritte mit dem nächsten Element bis das Ende der Liste erreicht ist. 5. Die Liste hat jetzt die richtige Sortierung und kann zurückgegeben werden 1.2 Ein Beispiel: Sortieren durch Einfügen 1-10 Beispiel 5 2 4 6 1 3 2 4 5 6 1 3 2 5 4 6 1 3 1 2 4 5 6 3 2 4 5 6 1 3 1 2 3 4 5 6 1.2 Ein Beispiel: Sortieren durch Einfügen 1-11 Pseudocode insertionSort(A) j ← 2; while j ≤ length(A) do key ← A[j]; // insert A[j] into the // sorted sequence A[1 .. j-1] i ← j - 1; while i > 0 und A[i] > key do A[i + 1] ← A[i]; i ← i - 1; od; A[i + 1] ← key; j ← j + 1; od; 1.2 Ein Beispiel: Sortieren durch Einfügen 1-12 Korrektheit und Komplexität ◮ Korrektheit: Leistet der Algorithmus das Gewünschte? Das heißt: Sortiert der Algorithmus die Liste und terminiert dann? ◮ Komplexität: Wie viele Rechenschritte und wie viel Speicher benötigt der Algorithmus? 1.2 Ein Beispiel: Sortieren durch Einfügen 1-13 Schleifeninvariante In jeder Iteration gilt: ◮ A [1..j − 1] enthält immer die Elemente, die auch vorher dort lagen. ◮ Die Elemente in diesem Bereich sind sortiert. Eine Schleifeninvariante ◮ Initialisierung: gilt vor dem ersten Schleifendurchlauf. ◮ Fortsetzung: gilt vor dem n + 1-Schleifendurchlauf, falls sie vor dem n-Schleifendurchlauf galt. ◮ Terminierung: liefert eine nützliche Bedingung, sobald die Schleife abbricht. 1.2 Ein Beispiel: Sortieren durch Einfügen 1-14 Schleifeninvariante im Beispiel Schleifeninvariante: Das Teilfeld A [1..j − 1] besteht aus den ursprünglich in A [1..j − 1] enthaltenen Elementen in geordneter Reihenfolge. Initialisierung: j = 2. A [1] besteht aus dem Element, was vorher auch schon dort war. Die Liste A [1] ist sortiert. Fortsetzung: A [j − 1], A [j − 2], . . . werden jeweils nach rechts verschoben, A [j ] an der richtigen Stelle eingefügt. Terminierung: j = n + 1 eingesetzt in die Invariante ergibt: A [1..n] enthält die Elemente, die vorher in A [1..n] enthalten waren, in geordneter Reihenfolge. 1.2 Ein Beispiel: Sortieren durch Einfügen 1-15 Übersicht Code Kosten insertionSort(A) j ← 2; c1 while j ≤ length(A) do c2 key ← A[j]; c3 i ← j - 1; c4 while i > 0 und A[i] > key c5 do; A[i + 1] ← A[i]; c6 c7 i ← i - 1; od A[i + 1] ← key; c8 j ← j + 1; c9 od; Anzahl 1 n n−1 n−1 Pn j =2 tj Pn (tj − 1) j = 2 Pn j =2 (tj − 1) n−1 n−1 tj : Anzahl, wie oft der Test der inneren Schleife ausgeführt wird 1.2 Ein Beispiel: Sortieren durch Einfügen 1-16 Laufzeit Die Gesamtlaufzeit ergibt sich zu: T (n) = c1 + c2 n + c3 (n − 1) + c4 (n − 1) + c5 · n X tj j =2 n n X X + c6 · (tj − 1) + c7 · (tj − 1) j =2 j =2 + c8 (n − 1) + c9 (n − 1) 1.2 Ein Beispiel: Sortieren durch Einfügen 1-17 Günstigster Fall Wenn das Feld sortiert ist, gilt immer A[i] ≤ key , also tj = 1. T (n) = c1 + c2 n + c3 (n − 1) + c4 (n − 1) + c5 (n − 1) + c8 (n − 1) + c9 (n − 1) = an + b = Θ(n) 1.2 Ein Beispiel: Sortieren durch Einfügen 1-18 Ungünstigster Fall Wenn das Feld umgekehrt sortiert ist, gilt nie A[i] ≤ key , also tj = j . T (n) = c1 + c2 n + c3 (n − 1) + c4 (n − 1) ! 1 + c5 n(n + 1) − 1 2 1 1 + c6 (n − 1)n + c7 (n − 1)n 2 2 + c8 (n − 1) + c9 (n − 1) = an2 + bn + c = Θ(n2 ) j Mittlerer Fall: tj ≈ 2 . Ergibt ebenfalls Θ(n2 ). 1.2 Ein Beispiel: Sortieren durch Einfügen 1-19 Programm und Programmiersprache Ein Programm ist die Formulierung eines Algorithmus und seiner Datenbereiche in einer Programmiersprache. Eine Programmiersprache erlaubt es, Algorithmen präzise zu beschreiben. Insbesondere legt eine Programmiersprache ◮ die elementaren Operationen, ◮ die Möglichkeiten zu ihrer Kombination und ◮ die zulässigen Datenbereiche eindeutig fest. Unter „programmieren“ versteht man den Vorgang des Erstellens eines Programms. 1.3 Programmiersprachen: der praktische Algorithmenbegriff 1-20 Entwicklung der Programmiersprachen JAVA 1995 93 91 SCHEME−Standard 89 87 C++ 85 83 ADA 79 CSP PROLOG PASCAL 71 ALGOL68 67 59 Algol 68 ◮ Modula-2 ◮ Scheme ◮ Scheme/Java ◮ Java LOGO 69 61 ◮ SCHEME C 73 63 Algol SMALLTALK80 MODULA2 77 65 ◮ OCCAM 81 1975 Programmiersprachen in der Informatikausbildung SIMULA PL/I BASIC COBOL ALGOL LISP 57 1955 FORTRAN 1.3 Programmiersprachen: der praktische Algorithmenbegriff 1-21 Definition von Programmiersprachen Unter Semiotik versteht man die Lehre von der Entstehung, dem Aufbau und der Wirkungsweise von Zeichen und Zeichenkomplexen. Sie umfasst die folgenden Bereiche. Die lexikalische Struktur einer Programmiersprache bestimmt die textuellen Grundbausteine der Programme. Solche Bausteine sind etwa Schlüsselwörter und Bezeichner. Sie werden zum Beispiel durch Aufzählung oder reguläre Ausdrücke bestimmt. Die Syntax einer Programmiersprache beschreibt, wie aus den Grundbausteinen vollständige Programme gebildet werden können. In den meisten Fällen wird die Syntax einer Programmiersprache durch eine kontextfreie Grammatik festgelegt. 1.3 Programmiersprachen: der praktische Algorithmenbegriff 1-22 Definition von Programmiersprachen Die Bedeutung der syntaktisch korrekten Programme ist durch die Semantik der Sprache gegeben. Sie kann beispielsweise mithilfe von Zustandsfolgen (operationelle Semantik) oder durch Funktionen, die den syntaktischen Einheiten zugeordnet sind (denotationale Semantik), definiert werden. Die Pragmatik einer Programmiersprache untersucht ihre Anwendbarkeit und Nützlichkeit. Sie gehört nicht zur Definition der Sprache. 1.3 Programmiersprachen: der praktische Algorithmenbegriff 1-23 Definition von Programmiersprachen Lexikalische Struktur: Bezeichner: Buchstabe · (Buchstabe, Ziffer)∗ Schlüsselwörter: while, do, od Syntax: <Folge> <Anweisung> <Zuweisung> <While> ::= ::= ::= ::= <Anweisung> ; <Folge> | <Anweisung> <Zuweisung> | <While> | ... <Bezeichner> := <arith. Ausdruck> while <log. Ausdruck> do <Folge> od (Operationelle) Semantik: Eine (partielle) Funktion f, die Zustände auf Zustände abbildet. 1.3 Programmiersprachen: der praktische Algorithmenbegriff 1-24 Klassifikation der Programmiersprachen Die Programmiersprachen lassen sich grob in drei Klassen einteilen: ◮ Maschinensprachen Bits und Bytes, für den menschlichen Leser kaum verständlich ◮ Maschinenorientierte Sprachen (Assembler) stellen die Befehle in einem Mnemo-Code dar ADDIC 23, R0 STO R0, #12004 ◮ Problemorientierte Sprachen imperative, funktionale, objektorientierte, deduktive Sprachen Ein Computer versteht nur Maschinensprachen! 1.3 Programmiersprachen: der praktische Algorithmenbegriff 1-25 Implementierung von Programmiersprachen Compiler übersetzen Quellprogramme aus problemorientierten Sprachen in äquivalente Zielprogramme in Maschinensprachen: cc -o prog prog.c prog input output Interpreter lesen das Programm zusammen mit den Eingabedaten ein und führen es aus: scm prog.scm input output Mischverfahren übersetzen das Programm zunächst mit einem Compiler in eine Zwischensprache. Das übersetzte Programm wird anschließend interpretiert: javac prog.java java prog input output 1.3 Programmiersprachen: der praktische Algorithmenbegriff 1-26 Verarbeitung von Java-Programmen ◮ Zuerst wird ein Quellprogramm vom Compiler in Bytecode übersetzt. ◮ Im zweiten Schritt wird der Bytecode vom Interpreter ausgeführt. Der Bytecode kann als Maschinencode der sogenannten virtuellen Java-Maschine angesehen werden. Bytecode ist portabel. Java−Quellprogramm javac Java−Bytecode java VMfürWindows java VMfürLinux 1.3 Programmiersprachen: der praktische Algorithmenbegriff 1-27 Berechenbarkeit/Entscheidbarkeit ◮ Um zu zeigen, dass ein Problem berechenbar ist, kann man einen Algorithmus angeben. ◮ Gibt es mathematisch beschreibbare Problemstellungen, die nicht berechenbar sind? Solch ein Nachweis setzt eine mathematisch exakte Formulierung des Algorithmenbegriffs voraus. ◮ Es existieren verschiedene Ansätze zur Präzisierung des Algorithmenbegriffs: Turing-Maschinen, Markov-Algorithmen, partiell-rekursive Funktionen,. . . 1.4 Berechenbarkeit: der formale Algorithmenbegriff 1-28 Turing-Maschine ◮ Alan M. Turing (1912–1954), britischer Mathematiker: „Berechenbar“ heißt auf einer Maschine ausführbar. ◮ Turing-Maschine: mathematisches Modell einer Rechenmaschine. 1.4 Berechenbarkeit: der formale Algorithmenbegriff 1-29 Bestandteile einer Turing-Maschine ◮ beliebig langes Band bestehend aus einzelnen Feldern, ◮ Alphabet B von Zeichen, die in den Feldern gespeichert werden können, ◮ Lese-/Schreibkopf für genau ein Feld, Aktionen r , l , s < B , ◮ ◮ ◮ ◮ ◮ Kopf ein Feld nach rechts bewegen: r , Kopf ein Feld nach links bewegen: l , stoppen: s , schreiben von x ∈ B : x , ◮ Turing-Tafel, ◮ Steuereinheit steuert Bewegungen und Schreibaktionen, jeweils in einem von endlich vielen Zuständen. 1.4 Berechenbarkeit: der formale Algorithmenbegriff 1-30 Turing-Tafel Eine Turing-Tafel besteht aus einer Folge von Anweisungen der Gestalt (z , x , a , z ′ ) mit ◮ z ∈ Z = {0, 1, . . . , m}: Zustand der Maschine vor Ausführung der Anweisung, ◮ x ∈ B : Zeichen auf dem Band, ◮ ◮ a ∈ A = {r , l , s } ∪ B : Aktion, z ′ ∈ Z ∪ {⊥}: Folgezustand nach Ausführung der Anweisung. ⊥ ist der Endzustand. Für alle z ∈ Z , x ∈ B gibt es genau eine Anweisung (z , x , a , z ′ ), mit a ∈ A , z ′ ∈ Z ∪ {⊥}. Eine Turing-Maschine arbeitet also deterministisch. 1.4 Berechenbarkeit: der formale Algorithmenbegriff 1-31 Aktionen z a=l ... 1 0 3 ... a=s ... 1 0 3 ... a=2 a=r z′ = ⊥ ... 1 0 3 ... 1.4 Berechenbarkeit: der formale Algorithmenbegriff z′ z′ ... 1 0 3 ... z′ ... 1 2 3 ... 1-32 Alternative Darstellung Zustandsüberführungsdiagramm: x3 : a3 z0 x0 : a0 z1 x2 : a2 z11 x1 : a1 z2 1.4 Berechenbarkeit: der formale Algorithmenbegriff ... 1-33 Die churchsche These ◮ Eine (partielle) Funktion f : N0 → N0 heißt turing-berechenbar, wenn es eine Turing-Maschine gibt, die für jeden Eingabewert n aus dem Definitionsbereich nach endlich vielen Schritten mit der Bandinschrift f (n) anhält. ◮ Die Turing-Berechenbarkeit ist ein mathematisches Modell zur Beschreibung von Algorithmen. ◮ These von Church: Der intuitive Begriff „berechenbar“ wird durch den mathematischen Begriff „turing-berechenbar“ erfasst. Es handelt sich hier um eine prinzipiell nicht beweisbare These. ◮ Weitere mathematische Modelle (s. oben) erwiesen sich als äquivalent. Dies ist ein starkes Indiz für die Gültigkeit der churchschen These. 1.4 Berechenbarkeit: der formale Algorithmenbegriff 1-34 Das Halteproblem ◮ Das Halteproblem für Turing-Maschinen ist nicht entscheidbar (berechenbar), d. h., es gibt keinen Algorithmus (Turing-Maschine), der für alle Turing-Maschinen und für alle möglichen Eingaben entscheidet, ob die Turing-Maschine mit dieser Eingabe anhält oder nicht. ◮ Dieser Satz schließt nicht aus, dass man für spezielle Turing-Maschinen entscheiden kann, ob sie halten. Er besagt lediglich, dass es kein allgemeines Verfahren gibt. 1.4 Berechenbarkeit: der formale Algorithmenbegriff 1-35 1. Der Algorithmenbegriff 2. Imperative Algorithmen 2.1 Variable, Anweisungen und Zustände 2.2 Felder 2.3 Komplexität von Algorithmen 2.4 Korrektheit von Algorithmen 2.5 Konzepte imperativer Sprachen 2.6 Rekursionen 3. Sortieralgorithmen 4. Listen und abstrakte Datentypen 5. Objektorientierte Algorithmen 6. Bäume 7. Mengen, Verzeichnisse und Hash-Verfahren 8. Graphen 9. Entwurf von Algorithmen 10. Funktionale und deduktive Algorithmen Paradigmen zur Algorithmenbeschreibung In einem imperativen Algorithmus gibt es Variable, die verschiedene Werte annehmen können. Die Menge aller Variablen und ihrer Werte sowie der Programmzähler beschreiben den Zustand zu einem bestimmten Zeitpunkt. Ein Algorithmus bewirkt eine Zustandstransformation. Ein funktionaler Algorithmus formuliert die Berechnung durch Funktionen. Die Funktionen können rekursiv sein; auch gibt es Funktionen höherer Ordnung. 2.1 Variable, Anweisungen und Zustände 2-1 Paradigmen zur Algorithmenbeschreibung In einem objektorientierten Algorithmus werden Datenstrukturen und Methoden zu einer Klasse zusammengefasst. Von jeder Klasse können Objekte gemäß der Datenstruktur erstellt und über die Methoden manipuliert werden. Ein logischer (deduktiver) Algorithmus führt Berechnungen durch, indem er aus Fakten und Regeln durch Ableitungen in einem logischem Kalkül weitere Fakten beweist. 2.1 Variable, Anweisungen und Zustände 2-2 Beispiel: Algorithmus von Euklid Der folgende, in einer imperativen Programmiersprache formulierte Algorithmus von Euklid berechnet den größten gemeinsamen Teiler der Zahlen x , y ∈ N mit x ≥ 0 und y > 0: a := x; b := y; while b do r := a := b := od # 0 a mod b; b; r Anschließend gilt a = ggT(x , y ). 2.1 Variable, Anweisungen und Zustände 2-3 Beispiel: Algorithmus von Euklid Variable r a b z2 – 36 52 z5 36 52 36 z8 16 36 16 z11 4 16 4 z14 0 4 0 ggT(36, 52) = 4 Durchlaufene Zustände: z0 , z1 , z2 , . . . , z14 Zustandstransformation: z0 7−→ z14 2.1 Variable, Anweisungen und Zustände 2-4 Imperative Konzepte ◮ Variable: Abstraktion eines Speicherbereichs, ein Wert eines gegebenen Datentyps kann gespeichert und beliebig oft gelesen werden, solange nicht ein neuer Wert gespeichert und der alte damit überschrieben wird. ◮ Zustand: Abstraktion des Speicherinhalts, Gesamtheit der momentanen Werte aller Variablen, ändert sich durch die Ausführung von Anweisungen. ◮ Anweisung: Vorschrift zur Ausführung einer Operation, ändert im Allgemeinen den Zustand. 2.1 Variable, Anweisungen und Zustände 2-5 Datentypen ◮ Datentyp: Die Zusammenfassung von Wertebereichen und Operationen zu einer Einheit. ◮ Abstrakter Datentyp: Schwerpunkt liegt auf den Eigenschaften, die die Operationen und Wertebereiche besitzen. ◮ Konkreter Datentyp: Beschreibt die Implementierung eines Datentyps. 2.1 Variable, Anweisungen und Zustände 2-6 Grundlegende Datentypen Oft werden mathematische Konzepte als Grundlage für einen Datentyp verwendet. Ein Datentyp besteht aus einem Wertebereich und einer Menge von Operationen. ◮ bool: die booleschen Werte „wahr“ und „falsch“ Operationen: logische Verknüpfungen, wie zum Beispiel „und“ und „oder“. ◮ int: ganze Zahlen, {minint, · · · , −1, 0, 1, · · · , maxint} ⊆ Z minint und maxint besitzen etwa den gleichen Betrag. Operationen: arithmetische und Vergleichsoperationen. Die Rechengesetze gelten in der Regel nicht, wenn man über die Darstellungsgrenzen gerät. 2.1 Variable, Anweisungen und Zustände 2-7 Grundlegende Datentypen ◮ real, float: Näherungswerte für reelle Zahlen, dargestellt durch Gleitpunktzahlen: r = m · be m∈Z Mantisse, b ∈N Basis, e∈Z Exponent Die Darstellung erschließt den Zahlenbereich mit konstanter relativer Genauigkeit. Operationen: arithmetische und Vergleichsoperationen. Die Rechengesetze gelten im Allgemeinen nur näherungsweise. Gleitpunktzahlen sollten nicht auf Gleichheit überprüft werden, stattdessen sollte |x − y | < ε für einen kleinen Wert von ε getestet werden. 2.1 Variable, Anweisungen und Zustände 2-8 Grundlegende Datentypen ◮ char: Zeichen aus einem Alphabet Mit Vergleichsoperationen und den Funktionen ord : char → int und chr : int → char, die eine Zuordnung zwischen den Zeichen aus dem Alphabet und ganzen Zahlen herstellen. 2.1 Variable, Anweisungen und Zustände 2-9 Variable ◮ ◮ Variable x : t: Abstraktion eines Speicherplatzes und Zuordnung eines Datentyps X Menge der Variablen x:t x∈X t = τ(x ) Typ von x v = σ(x ) Wert von x v ∈ W (t ) Wertemenge von t Deklaration: Ein Variablenname wird einem Speicherbereich und einem Typ zugeordnet. var i, j: int; var r: real; var c: char; 2.1 Variable, Anweisungen und Zustände 2-10 Zustand Ein Zustand bezeichnet die Belegung aller Variablen zu einem Zeitpunkt. ◮ Modellierung: σ : x1 7→ v1 , . . . , xn 7→ vn xi ∈ X , vi ∈ W (ti ) ∀i ∈ {1, . . . , n} ◮ ◮ x1 · · · xn Tabellarisch: v1 · · · vn Mathematisch: Abbildung σ : X → W = {v1 , . . . , vn }, wobei σ(xi ) ∈ W (ti ) ∀i ∈ {1, . . . , n} σ(x ) ist die Belegung der Variablen x im Zustand σ. 2.1 Variable, Anweisungen und Zustände 2-11 Zuweisung ◮ ◮ Syntax: x ← v v ∈ W (τ(x )) Zuweisung, Grundbaustein: Nach Ausführung der Zuweisung gilt σ(x ) = v . Ist σ : X → W ein Zustand und wählt man eine Variable x ∈ X sowie einen Wert v vom passenden Typ, so ist der transformierte Zustand σ<x ←v > wie folgt definiert: σ<x ←v > (y ) = 2.1 Variable, Anweisungen und Zustände ( v σ(y ) falls x = y sonst 2-12 Semantik Die Semantik beschreibt die Bedeutung eines Algorithmus. Sie ist eine (partielle) Funktion, die jeder Anweisung eine Zustandsänderung zuordnet: [ ] : A → ((X → W ) → (X → W )) mit [a ](σ) = σ′ Semantik einer Zuweisung: [x ← v ](σ) = σ<x ←v > 2.1 Variable, Anweisungen und Zustände 2-13 Ausdrücke/Terme Beispiele für Ausdrücke: ◮ Konstante: 3, 2.7182, ′ A ′ ◮ Variable: x1 , x2 , y ◮ Operatoren und Funktionsaufrufe: x1 + 3, f (x1 ) ◮ Zusammengesetzte Ausdrücke: f (x1 + 3) − 2.7182 + y Falls ein Ausdruck t die Variablen x1 , . . . , xn enthält, schreiben wir t (x1 , . . . , xn ). Beispiel eines booleschen Ausdrucks: x ≤ 5 ∧ y < 4 2.1 Variable, Anweisungen und Zustände 2-14 Wert eines Ausdrucks Die Auswertung von Ausdrücken mit Variablen ist zustandsabhängig. An die Stelle der Variablen wird ihr aktueller Wert gesetzt. Beispiel: Für den Ausdruck 2 · x + 1 ist sein Wert im Zustand σ durch 2σ(x ) + 1 gegeben. Der so bestimmte Wert des Ausdrucks t (x1 , . . . , xn ) im Zustand σ wird mit σ(t (x1 , . . . , xn )) bezeichnet. Beispiel: σ(2 · x + 1) = 2σ(x ) + 1 2.1 Variable, Anweisungen und Zustände 2-15 Zuweisungen Diese Festlegung erlaubt Wertzuweisungen mit Ausdrücken auf der rechten Seite y ← t (x1 , . . . , xn ) Der transformierte Zustand hierfür ist wie folgt definiert: [y ← t (x1 , . . . , xn )](σ) = σ<y ←σ(t (x1 ,...,xn ))> 2.1 Variable, Anweisungen und Zustände 2-16 Zuweisungen Beispiel: Semantik der Zuweisung x ←2·x +1 Transformation: [x ← 2 · x + 1](σ) = σ<x ←2·σ(x )+1> Hier handelt es sich nicht um eine rekursive Gleichung für x , da auf der rechten Seite der „alte“ Wert von x benutzt wird. Wertzuweisungen sind die einzigen elementaren Anweisungen imperativer Algorithmen. Aus ihnen werden zusammengesetzte Anweisungen gebildet, aus denen imperative Algorithmen bestehen. 2.1 Variable, Anweisungen und Zustände 2-17 Zusammengesetzte Anweisungen Elementare Anweisungen können auf unterschiedliche Arten zu komplexen Anweisungen zusammengesetzt werden: 1. sequenzielle Ausführung, 2. bedingte Ausführung, 3. wiederholte Ausführung, 4. Ausführung als Unterprogramm, 5. rekursive Ausführung eines Unterprogramms. Diese Möglichkeiten werden als Kontrollstrukturen bezeichnet. Wir betrachten jetzt die ersten drei dieser zusammengesetzten Anweisungen. Auf die anderen kommen wir am Schluss des Kapitels zu sprechen. 2.1 Variable, Anweisungen und Zustände 2-18 Sequenzielle Ausführung von Anweisungen ◮ Definition: Sind a1 und a2 Anweisungen, so auch a1 ; a2 ◮ Informelle Bedeutung: „Führe erst a1 , dann a2 aus.“ ◮ Semantik: [a1 ; a2 ](σ) = [a2 ]([a1 ](σ)) ◮ Darstellung im Flussdiagramm: a1 a2 2.1 Variable, Anweisungen und Zustände 2-19 Bedingte Ausführung von Anweisungen ◮ Definition: Sind a1 und a2 Anweisungen und P ein boolescher Ausdruck, so ist auch if P then a1 else a2 fi eine Anweisung. ◮ Voraussetzung: σ(P ) kann ausgewertet werden (ansonsten ist die Anweisung undefiniert) ◮ Informelle Bedeutung: „Falls P gilt, führe a1 aus, sonst a2 .“ 2.1 Variable, Anweisungen und Zustände 2-20 Bedingte Ausführung von Anweisungen ◮ Semantik: ◮ [a1 ](σ) falls σ(P ) = true [ if P then a1 else a2 fi ](σ) = [a2 ](σ) falls σ(P ) = false Darstellung im Flussdiagramm: true false P a1 2.1 Variable, Anweisungen und Zustände a2 2-21 Wiederholte Ausführung von Anweisungen ◮ Definition: Ist a eine Anweisung und P ein boolescher Ausdruck, so ist auch while P do a od eine Anweisung. ◮ Voraussetzung: σ(P ) kann ausgewertet werden (ansonsten ist die Anweisung undefiniert) ◮ Informelle Bedeutung: „Solange P gilt, führe a aus.“ 2.1 Variable, Anweisungen und Zustände 2-22 Wiederholte Ausführung von Anweisungen ◮ Semantik: [ while P do a od ](σ) = falls σ(P ) = false σ [ while P do a od ]([a ](σ)) sonst ◮ Diese Definition ist rekursiv. While-Schleifen müssen nicht terminieren. Darstellung im Flussdiagramm: false P true a 2.1 Variable, Anweisungen und Zustände 2-23 Flussdiagramme Normierte Methode (DIN 66001) zur Darstellung von Programmen ◮ Beginn: Start ◮ Ende: Stop ◮ Elementare Anweisung: a P ◮ Entscheidung durch booleschen Ausdruck: ◮ Eingabe nach n: ◮ Ausgabe von p: 2.1 Variable, Anweisungen und Zustände Eingabe n Ausgabe p 2-24 Konkrete Umsetzung ◮ Sprachen mit nur diesen Anweisungen sind bereits berechnungsuniversell. ◮ In existierenden Programmiersprachen gibt es fast immer diese Anweisungen, oft jedoch mehr. Beispiel: case- oder repeat-Anweisung. ◮ Schlüsselwörter und Syntax der Kontrollstrukturen variieren von Sprache zu Sprache. Beispiel: end if statt fi. ◮ Hierarchische Struktur der Anweisungen: Anweisungen können Bestandteil anderer Anweisungen sein. 2.1 Variable, Anweisungen und Zustände 2-25 Imperative Algorithmen ◮ Es werden die Datentypen int, real und bool verwendet. ◮ Aufbau imperativer Algorithmen (Syntax): <Programmname> var x ,y ,. . . : int ; p ,q: bool ; input : x1 , . . . , xn ; a; output : y1 , . . . , ym ◮ Variablendeklarationen Eingabevariablen Anweisung(en) Ausgabevariablen Die Semantik wird durch eine Zustandsüberführungsfunktion beschrieben. 2.1 Variable, Anweisungen und Zustände 2-26 Imperative Algorithmen Die Semantik eines imperativen Algorithmus ist eine partielle Funktion [PROG ] : W1 × · · · × Wn → V1 × · · · × Vm [PROG ](w1 , . . . , wn ) = (σ(y1 ), . . . , σ(ym )) σ = [a ](σ0 ) σ0 (xi ) = wi ∈ Wi für i = 1, . . . , n wobei PROG: <Programmname> Wi : Wertebereich des Typs von xi , für i = 1, . . . , n Vj : Wertebereich des Typs von yj , für j = 1, . . . , m 2.1 Variable, Anweisungen und Zustände 2-27 Imperative Algorithmen ◮ Der Algorithmus legt eine Zustandstransformation fest. Die Eingabe befindet sich im Zustand σ0 . ◮ Die Ausführung imperativer Algorithmen besteht aus einer Abfolge von Zuweisungen. Diese Folge wird mittels „Sequenz“, „bedingter Ausführung“ und „wiederholter Ausführung“ aus den Zuweisungen gebildet. ◮ Jede Zuweisung definiert eine Transformation des Zustands. Die Semantik des Algorithmus ist durch die Kombination all dieser Zustandstransformationen festgelegt. ◮ Falls die Auswertung von a nicht terminiert, ist die Funktion [PROG ] nicht definiert. 2.1 Variable, Anweisungen und Zustände 2-28 Algorithmus von Euklid Der Algorithmus von Euklid lautet in dieser Notation: EUKLID var a, b, r, x, y: int; input x, y; a ← x; b ← y; while b # 0 do r ← a mod b; a ← b; b ← r; od; output a; 2.1 Variable, Anweisungen und Zustände 2-29 Fakultätsfunktion FAK k! = 1 · 2 · 3 · · · k var x, y: int; input x; y ← 1; while x > 1 do y ← y * x; x ← x - 1; od; output y; Start Eingabe x y←1 x>1 true y ←y·x Es gilt: [FAK ](w ) = false Ausgabe y Stop ( w ! für w > 0 1 2.1 Variable, Anweisungen und Zustände x←x−1 sonst 2-30 Auswertung der Fakultätsfunktion ◮ ◮ Signatur der Semantikfunktion: [FAK ] : int → int Das Ergebnis der Berechnung ist die Belegung der Variablen y im Endzustand: σ(y ). ◮ Der Endzustand σ ist laut Definition σ = [a ](σ0 ), wobei a die Folge aller Anweisungen des Algorithmus ist. ◮ σ0 ist der Startzustand. Die Eingabe befindet sich in σ0 (x ). ◮ Da nur die zwei Variablen x und y auftreten, können wir einen Zustand σ als Paar (σ(x ), σ(y )) schreiben. ◮ ⊥ bedeutet, dass der Wert der Variablen keine Rolle spielt bzw. undefiniert ist. 2.1 Variable, Anweisungen und Zustände 2-31 Auswertung der Fakultätsfunktion Gesucht: [FAK ](3) σ = [a ](σ0 ) = [a ](3, ⊥) = [y ← 1; while x > 1 do y ← y ∗ x ; x ← x − 1 od ](3, ⊥) = [ while x > 1 do y ← y ∗ x ; x ← x − 1 od ]([y ← 1](3, ⊥)) =: [ while B do β od ]([y ← 1](3, ⊥)) = [ while B do β od ](3, 1) ( σ falls σ(B ) = false = [ while B do β od ]([β](σ)) sonst ( (3, 1) falls σ(x > 1) = false = [ while B do β od ]([y ← y ∗ x ; x ← x − 1](σ)) sonst 2.1 Variable, Anweisungen und Zustände 2-32 Auswertung der Fakultätsfunktion · · · = [ while B do β od ]([y ← y ∗ x ; x ← x − 1](3, 1)) = [ while B do β od ]([x ← x − 1]([y ← y ∗ x ](3, 1))) = [ while B do β od ]([x ← x − 1](3, 3)) = [ while B do β od ](2, 3) = [ while B do β od ]([y ← y ∗ x ; x ← x − 1](2, 3)) = [ while B do β od ](1, 6) ( (1, 6) falls σ(x > 1) = false = [ while B do β od ]([y ← y ∗ x ; x ← x − 1](1, 6)) sonst = (1, 6) [FAK](3) = 6 2.1 Variable, Anweisungen und Zustände 2-33 Fibonacci-Zahlen (iterativ) f0 = 0, f1 = 1, fn = fn−1 + fn−2 , n > 1 FIB: var x,a,b,c: int; input x; a ← 0; b ← 1; while x > 0 do c ← a + b; a ← b; b ← c; x ← x - 1; od; output a; [FIB ](x ) = 2.1 Variable, Anweisungen und Zustände ( die x -te Fibonacci-Zahl, falls x > 0, 0, sonst. 2-34 Fibonacci-Zahlen (rekursiv) Einzelheiten zu rekursiven Programmen werden später behandelt! f0 = 0, f1 = 1, fn = fn−1 + fn−2 , n > 1 FIB: var n, x: int; input n; if n = 0 then x ← 0 fi; if n = 1 then x ← 1 fi; if n > 1 then x ← FIB(n - 1) + FIB(n - 2) fi; output x; Ein rekursiv ausgedrückter Algorithmus ist häufig eleganter als sein iteratives Äquivalent. Nachteil ist evtl. eine längere Laufzeit. 2.1 Variable, Anweisungen und Zustände 2-35 Felder ◮ Definition: Ein Feld ist eine indizierte Menge von Variablen des gleichen Typs. Felder sind generische Datentypen. ◮ Deklaration: var x[I]: T; var coords[1..3]: real; var str[0..4095]: char; ◮ Indexmenge: I endlich, häufig I ⊆ N, aber andere Indexmengen sind möglich. ◮ Zugriff: x [i ] sowohl lesend als auch schreibend, wobei i ∈ I . Längenänderungen von Feldern sind nicht in allen Programmiersprachen möglich. Felder werden auch als Arrays bezeichnet. 2.2 Felder 2-36 Verbreitete Spezialfälle von Feldern ◮ Vektor: var v[1..3]: real; ◮ Matrix: var v[1..4, 1..4]: real; ◮ Strings: var str[0..102]: char; 2.2 Felder 2-37 For-Schleife Die For-Schleife ist eine weitere Kontrollstruktur zur Wiederholung von Anweisungen. Es seien j , k ∈ N Konstante, var i : int eine Variable und a eine Anweisung. for i ← j to k do a; od entspricht i ← j; while i ≤ k do a; i ← i + 1; od i wird Laufvariable der Schleife genannt. Viele Programmiersprachen enthalten Varianten dieser Schleife. 2.2 Felder 2-38 Maximale Summe eines Teilfelds Die folgenden Programme berechnen die maximale Summe aufeinanderfolgender Zahlen in einem Feld var x [1..n] : int . j X m = max x [ k ] | 1 ≤ i ≤ j ≤ n k =i x sei das Feld: 2 3 4 5 6 7 8 9 10 1 31 −41 59 26 −53 58 97 −93 −23 84 Die maximale Summe aufeinanderfolgender Zahlen ist x [3] + x [4] + x [5] + x [6] + x [7] = 187. Keine andere Summe besitzt einen höheren Wert. Beispielsweise ist x [1] + x [2] + x [3] + x [4] = 75. 2.2 Felder 2-39 Maximale Summe eines Teilfelds Idee: Berechne alle möglichen Summen. MaxSum1 var x[1..n]: int; var i,j,k,s,m: int; input x; m ← 0; // Wert der leeren Summe for i ← 1 to n do for j ← i to n do s ← 0; for k ← i to j do s ← s + x[k]; od; m ← max(m, s); od; od; output m; Dieser Algorithmus enthält drei ineinandergeschachtelte Schleifen. 2.2 Felder 2-40 Maximale Summe eines Teilfelds Idee: Einsparen der inneren Schleife durch Wiederverwenden bereits bekannter Summen. MaxSum2 var x[1..n]: int; var i,j,s,m: int; input x; m ← 0; // Wert der leeren Summe for i ← 1 to n do s ← x[i]; m ← max(m, s); for j = i+1 to n do s ← s + x[j]; m ← max(m, s); od; od; output m; Dieser Algorithmus besitzt zwei ineinandergeschachtelte Schleifen. 2.2 Felder 2-41 Maximale Summe eines Teilfelds Idee: Genau überlegen, was eine hohe Summe ausmacht. Diese Lösung stammt von M. Shamos (1977). MaxSum3 var x[1..n]: int; var i,s,m: int; input x; m ← 0; s ← 0; for i ← 1 to n do s ← max(0, s + x[i]); m ← max(m, s); od; output m; Dieser Algorithmus hat nur noch eine Schleife. 2.2 Felder 2-42 Einführung ◮ In der Regel lässt sich ein Problem durch verschiedene Algorithmen lösen (zum Beispiel „maximale Summe aufeinanderfolgender Elemente“). ◮ Welcher Algorithmus soll gewählt werden? ◮ Die Algorithmen müssen hinsichtlich ihres Verhaltens verglichen werden. ◮ Man benötigt ein Maß für den Aufwand eines Algorithmus. 2.3 Komplexität von Algorithmen 2-43 Komplexität von Algorithmen und Problemen ◮ Unter der Komplexität eines Algorithmus versteht man den Aufwand, den der Algorithmus zur Lösung des Problems benötigt. Typischerweise ist hier die ◮ ◮ Laufzeit des Algorithmus oder der Speicherbedarf des Algorithmus gemeint. Man unterscheidet die ◮ ◮ ◮ ◮ Komplexität im günstigsten Fall (best-case), die Komplexität im mittleren Fall (average-case) und die Komplexität im ungünstigsten Fall (worst-case). Unter der Komplexität eines Problems versteht man die Komplexität des besten Algorithmus zur Lösung des Problems im ungünstigsten Fall. 2.3 Komplexität von Algorithmen 2-44 Komplexität eines Algorithmus ◮ Umfang n eines Problems: „Anzahl der Eingabewerte“ oder „Größe der Eingabewerte“ oder . . . ◮ Aufwand T (n) eines Algorithmus: „Anzahl der Schritte“ oder „Anzahl bestimmter Operationen“ oder „Anzahl der benötigten Speicherplätze“ oder . . . , die der Algorithmus braucht, um ein Problem vom Umfang n zu lösen. Um sinnvolle Aussagen über die Komplexität eines Algorithmus zu treffen, müssen n und T (n) mit Bedacht gewählt werden. Beispiel: Im Algorithmus „Sortieren durch Einfügen“ war n die Anzahl der zu sortierenden Zahlen und T (n) die Anzahl der benötigten Vergleiche. 2.3 Komplexität von Algorithmen 2-45 Wachstum von Funktionen ◮ In der Regel stellt die Wachstumsrate der Laufzeit ein einfaches und geeignetes Kriterium zur Messung der Effizienz eines Algorithmus dar. ◮ Die Wachstumsrate erlaubt es uns, die relative Leistungsfähigkeit alternativer Algorithmen zu vergleichen. ◮ Die Wachstumsrate von Algorithmen wird meistens mithilfe der asymptotischen Notation angegeben. ◮ Die asymptotische Notation lässt konstante Faktoren unberücksichtigt. 2.3 Komplexität von Algorithmen 2-46 Asymptotische Notation Es sei eine Funktion g : N −→ R gegeben. Θ(g ) = {f : N −→ R | ∃c1 > 0, c2 > 0, n0 > 0 ∀n ≥ n0 . 0 ≤ c1 g (n) ≤ f (n) ≤ c2 g (n)} O (g ) = {f : N −→ R | ∃c > 0, n0 > 0 ∀n ≥ n0 . 0 ≤ f (n) ≤ cg (n)} Ω(g ) = {f : N −→ R | ∃c > 0, n0 > 0 ∀n ≥ n0 . 0 ≤ cg (n) ≤ f (n)} o (g ) = {f : N −→ R | ∀c > 0 ∃n0 > 0 ∀n ≥ n0 . 0 ≤ f (n) < cg (n)} ω(g ) = {f : N −→ R | ∀c > 0 ∃n0 > 0 ∀n ≥ n0 . 0 ≤ cg (n) < f (n)} 2.3 Komplexität von Algorithmen 2-47 Gebräuchliche Wachstumsklassen Θ(1) Θ(log(n)) Θ(n) Θ(n log(n)) Θ(n2 ) Θ(nk ) Θ(2n ) konstantes Wachstum logarithmisches Wachstum lineares Wachstum „fast lineares“ Wachstum quadratisches Wachstum polynomiales Wachstum exponentielles Wachstum O (n2 ) Ω(n) höchstens quadratisches Wachstum mindestens lineares Wachstum Beispiel: Die Laufzeit beim „Sortieren durch Einfügen“ beträgt im günstigsten Fall Θ(n) und im ungünstigsten Falls Θ(n2 ). Die Laufzeit kann also durch Ω(n) nach unten und durch O (n2 ) nach oben abgeschätzt werden. Die Laufzeit wird auch durch O (n3 ) nach oben abgeschätzt! In der Praxis werden häufig möglichst scharfe obere Schranken gesucht. 2.3 Komplexität von Algorithmen 2-48 Graphen einiger Funktionen n log(n) n log(n) 1 2.3 Komplexität von Algorithmen 2-49 Graphen einiger Funktionen 2n n3 n2 2.3 Komplexität von Algorithmen 2-50 Exemplarische Werte n= log2 (n) ≈ n log2 (n) ≈ n2 = 2n ≈ 1 0 0 1 2 10 3 33 100 103 100 7 664 10000 1030 1000 10 9966 1000000 10301 10000 13 132877 100000000 103010 Maximales n bei gegebener Zeit, Ann.: 1 Schritt benötigt 1µs n n2 n3 2n 1 Min. 1 Std. 1 Tag 1 Woche 1 Jahr 6 · 107 7750 391 25 3.6 · 109 6 · 104 1530 31 8.6 · 1010 2.9 · 105 4420 36 6 · 1011 7.9 · 105 8450 39 3 · 1013 5.6 · 106 31600 44 2.3 Komplexität von Algorithmen 2-51 Asymptotische Notationen in Gleichungen ◮ 2n2 + 3n + 1 = Θ(n2 ) heißt 2n2 + 3n + 1 ∈ Θ(n2 ). ◮ 2n2 + 3n + 1 = 2n2 + Θ(n) heißt: Es gibt eine Funktion f (n) ∈ Θ(n) mit 2n2 + 3n + 1 = 2n2 + f (n). ◮ 2n2 + Θ(n) = Θ(n2 ) heißt: Für jede Funktion f (n) ∈ Θ(n) gibt es eine Funktion g (n) ∈ Θ(n2 ) mit 2n2 + f (n) = g (n). ◮ In Gleichungsketten 2n2 + 3n + 1 = 2n2 + Θ(n) = Θ(n2 ) werden die Gleichungen einzeln gelesen: Die erste Gleichung besagt, dass es eine Funktion f (n) ∈ Θ(n) mit 2n2 + 3n + 1 = 2n2 + f (n) gibt. Die zweite Gleichung sagt aus, dass es für jede Funktion g (n) ∈ Θ(n) eine Funktion h (n) ∈ Θ(n2 ) mit 2n2 + g (n) = h (n) gibt. Aus der Hintereinanderausführung folgt 2n2 + 3n + 1 = Θ(n2 ). 2.3 Komplexität von Algorithmen 2-52 Eigenschaften der asymptotischen Notation Eine Funktion f : N −→ R heißt asymptotisch positiv, wenn gilt: ∃n0 > 0 ∀n ≥ n0 . f (n) > 0. Für alle asymptotisch positiven Funktionen f , g : N −→ R gelten die folgenden Aussagen. Transitivität: f (n) = Θ(g (n)) ∧ g (n) = Θ(h (n)) =⇒ f (n) = Θ(h (n)), f (n) = O (g (n)) ∧ g (n) = O (h (n)) =⇒ f (n) = O (h (n)), f (n) = Ω(g (n)) ∧ g (n) = Ω(h (n)) =⇒ f (n) = Ω(h (n)), f (n) = o (g (n)) ∧ g (n) = o (h (n)) =⇒ f (n) = o (h (n)), f (n) = ω(g (n)) ∧ g (n) = ω(h (n)) =⇒ f (n) = ω(h (n)). 2.3 Komplexität von Algorithmen 2-53 Eigenschaften der asymptotischen Notation Reflexivität: f (n) = Θ(f (n)), f (n) = O (f (n)), f (n) = Ω(f (n)). Symmetrie: f (n) = Θ(g (n)) ⇐⇒ g (n) = Θ(f (n)). Austausch-Symmetrie: f (n) = Θ(g (n)) ⇐⇒ f (n) = O (g (n)) ∧ f (n) = Ω(g (n)), f (n) = O (g (n)) ⇐⇒ g (n) = Ω(f (n)), f (n) = o (g (n)) ⇐⇒ g (n) = ω(f (n)). Θ, O , Ω, o und ω werden landausche Symbole genannt. 2.3 Komplexität von Algorithmen 2-54 Beispiele 8 = Θ(1) 3n2 − 5n + 8 = Θ(n2 ) 3n2 − 5n + 8 = O (n2 ) 3n2 − 5n + 8 = Ω(n2 ) logb (n) loga (n) = logb (a ) Θ(loga (n)) = Θ(logb (n)) 12 log10 (n) = Θ(log(n)) 12 log2 (n) = Θ(log(n)) 2.3 Komplexität von Algorithmen 2-55 Laufzeit von imperativen Algorithmen Folgende Annahmen werden zur Analyse der Laufzeit von imperativen Algorithmen getroffen: ◮ Zuweisung: Die Laufzeit ist konstant. ◮ Sequenz: Die Laufzeit ist die Summe der Laufzeiten der Einzelanweisungen. ◮ Alternative: Die Laufzeit ist im ungünstigsten Fall die Laufzeit der Bedingungsauswertung plus dem Maximum der Laufzeiten der Alternativen. ◮ Iteration: Die Laufzeit errechnet sich aus dem Produkt der Laufzeit der inneren Anweisung und der Anzahl der Iterationen. Hinzu kommt die Laufzeit für einen weiteren Test. 2.3 Komplexität von Algorithmen 2-56 Sortieren durch Einfügen Code Kosten insertionSort(A) c1 j ← 2; c2 while j ≤ length(A) do key ← A[j]; c3 i ← j - 1; c4 while i > 0 und A[i] > key c5 do A[i + 1] ← A[i]; c6 c7 i ← i - 1; od; A[i + 1] ← key; c8 j ← j + 1; c9 od; 2.3 Komplexität von Algorithmen Anzahl 1 n n−1 n−1 Pn j =2 tj Pn (tj − 1) j = 2 Pn j =2 (tj − 1) n−1 n−1 2-57 Abschätzung durch die O-Notation Code Kosten insertionSort(A) j ← 2; O (1) O (n) while j ≤ length(A) do key ← A[j]; O (n) i ← j - 1; O (n) while i > 0 und A[i] > key; O (n2 ) do A[i + 1] ← A[i]; O (n2 ) i ← i - 1; O (n2 ) od; O (n) A[i + 1] ← key; j ← j + 1; O (n) od; Die Laufzeit T (n) liegt in O (n2 ), d. h. T (n) = O (n2 ). 2.3 Komplexität von Algorithmen 2-58 Korrektheit von Softwaresystemen In vielen Situationen ist eine korrekte Funktionsweise eines Softwaresystems von großer Bedeutung. Dies gilt insbesondere, wenn das System ◮ sicherheitskritisch (z. B. Atomreaktor), ◮ kommerziell kritisch (z. B. massenproduzierte Chips) oder ◮ politisch kritisch (z. B. Militär) ist. 2.4 Korrektheit von Algorithmen 2-59 Korrektheit von Softwaresystemen Es gibt mehrere Möglichkeiten, die Zuverlässigkeit von Softwaresystemen zu erhöhen: ◮ Software Engineering: Maßnahmen während des gesamten Softwareentwicklungsprozesses. ◮ Programmierung: Beispiel: Ausnahmebehandlung, Zusicherungen. ◮ Validation: Systematische Tests unter Einsatzbedingungen; Tests zeigen die Anwesenheit, aber nicht die Abwesenheit von Fehlern. ◮ Verifikation: Mathematischer Nachweis der Korrektheit von Algorithmen. 2.4 Korrektheit von Algorithmen 2-60 Korrektheit von Softwaresystemen Um ein System zu verifizieren zu können benötigt man Methoden, Werkzeuge und Sprachen, zur ◮ Modellierung von Systemen auf hoher Abstraktionsebene, ◮ Spezifikation nachzuweisender Eigenschaften dieser Systeme (Terminierungsverhalten, berechnete Funktionswerte, . . . ) und zur ◮ Verifikation, d. h. zum formalen Beweis, dass ein implementiertes System die spezifizierten Eigenschaften hat. In diesem Abschnitt behandeln wir eine Möglichkeit zur Spezifikation und Verifikation imperativer Algorithmen. 2.4 Korrektheit von Algorithmen 2-61 Hoaresche Logik Es seien ein Algorithmus S sowie Bedingungen p und q gegeben. ◮ Wir schreiben in diesem Fall {p} S {q} und nennen ◮ p Vorbedingung, ◮ q Nachbedingung und ◮ (p,q) Spezifikation von S. 2.4 Korrektheit von Algorithmen 2-62 Hoaresche Logik ◮ S heißt partiell-korrekt bezüglich der Spezifikation (p,q), wenn jede Ausführung von S, die in einem Zustand beginnt, der p erfüllt und die terminiert, zu einem Zustand führt, der q erfüllt. |= {p} S {q} ◮ S wird total-korrekt bezüglich der Spezifikation (p,q) genannt, wenn jede Ausführung von S, die in einem Zustand beginnt, der p erfüllt, terminiert und zu einem Zustand führt, der q erfüllt. 2.4 Korrektheit von Algorithmen 2-63 Beispiele ◮ ◮ ◮ ◮ ◮ ◮ ◮ ◮ ◮ ◮ |= {true }x ← 1{x = 1} |= {x = 1}x ← x + 1{x = 2} |= {y = a }x ← y {x = a ∧ y = a } |= {x = a ∧ y = b }z ← x ; x ← y ; y ← z {x = b ∧ y = a } |= {false }x ← 1{x = 42} |= {true } while 0 = 0 do x ← 1 od {x = 23} |= {x > 0} while x , 0 do x ← x − 1 od {x = 0} |= {true } while x , 0 do x ← x − 1 od {x = 0} |= {true } while p (x ) do α od {¬p (x )} |= {x + y = a } while x , 0 do x ← x − 1; y ← y + 1 od {x = 0 ∧ x + y = a} 2.4 Korrektheit von Algorithmen 2-64 Hoarescher Kalkül Zuweisung: Sequenz: If: While: Anpassungsregel: 2.4 Korrektheit von Algorithmen {pxt } x ← t {p } {p } S1 {r }, {r } S2 {q} {p } S 1 ; S 2 {q } {p ∧ e } S1 {q}, {p ∧ ¬e } S2 {q} {p } if e then S1 else S2 fi {q} {p ∧ e } S {p } {p } while e do S od {p ∧ ¬e } p ⊃ q, {q} S {r }, r ⊃ s {p } S {s } 2-65 Hoarescher Kalkül ◮ Der hoaresche Kalkül besteht aus dem Axiomenschema für die Zuweisung und Ableitungsregeln. ◮ Falls {p} S {q} mithilfe des hoareschen Kalküls hergeleitet werden kann, schreibt man ⊢ {p} S {q}. 2.4 Korrektheit von Algorithmen 2-66 Schleifeninvariante Die Bedingung p in der While-Regel heißt Schleifeninvariante. {p ∧ e } S {p } {p } while e do S od {p ∧ ¬e } Eine Schleifeninvariante gilt vor jedem Schleifendurchlauf und damit auch nach jedem Schleifendurchlauf, speziell also nach Beendigung der Wiederholungsanweisung. Dann gilt zudem ¬e . 2.4 Korrektheit von Algorithmen 2-67 Schleifeninvariante Beispiel: Für den folgenden Algorithmus ist q = k − 1 eine Schleifeninvariante. {q = 0 ∧ k = 1} while k , n + 1 do q ← q + 1; k ← k + 1; od; {q = n } Nach Ausführung der Schleife gilt: q = k − 1 ∧ ¬(k , n + 1) Daraus folgt: q = n 2.4 Korrektheit von Algorithmen 2-68 Eigenschaften des Kalküls Korrektheit: ⊢ {p } S {q} =⇒ |= {p } S {q} relative Vollständigkeit: ⊢ {p } S {q} ⇐= |= {p } S {q} 2.4 Korrektheit von Algorithmen 2-69 Beispiel: Division mit Rest ◮ Es sollen zwei ganze Zahlen x , y ∈ Z mit x ≥ 0 und y > 0 durcheinander dividiert werden. ◮ Das Ergebnis der Division x /y ist der Quotient q und der Rest r mit x = qy + r ∧ 0 ≤ r < y . var x, y, q, r: int; input x, y; q ← 0; r ← x; while r >= y do r = r - y; q = q + 1; od; output q, r; 2.4 Korrektheit von Algorithmen 2-70 Beispiel: Division mit Rest ◮ Mithilfe des hoareschen Kalküls leiten wir jetzt den Ausdruck {x ≥ 0} S {x = qy + r ∧ 0 ≤ r < y } her, wobei S der obige imperative Algorithmus ist. ◮ Wegen der Korrektheit des Kalküls können wir dann schließen, dass der Algorithmus bezüglich der angegebenen Vor- und Nachbedingung partiell-korrekt ist. ◮ Die Bedingung y > 0 wird zum Nachweis der totalen Korrektheit benötigt. 2.4 Korrektheit von Algorithmen 2-71 Beispiel: Division mit Rest Wir zeigen zuerst, dass x = qy + r ∧ 0 ≤ r eine Schleifeninvariante ist. Dies ergibt sich aus: x = qy + r ∧ 0 ≤ r ∧ r ≥ y 2.4 Korrektheit von Algorithmen =⇒ x = (q + 1)y + (r − y ) ∧ 0 ≤ r − y 2-72 Beispiel: Division mit Rest Die Behauptung folgt dann aus den beiden Aussagen x ≥ 0 =⇒ 0 ≤ x und x = qy + r ∧ 0 ≤ r ∧ ¬(r ≥ y ) 2.4 Korrektheit von Algorithmen =⇒ x = qy + r ∧ 0 ≤ r < y . 2-73 Beispiel: Division mit Rest ◮ Da nach Voraussetzung y > 0 gilt, durchläuft die Variable r eine monoton streng fallende Folge natürlicher Zahlen: r0 , r1 , r2 , . . . ◮ Da y konstant ist, wird deshalb für ein i die Bedingung ri < y wahr. Das heißt, das Programm terminiert schließlich und ist deshalb total-korrekt bezüglich der angegebenen Bedingungen. Bemerkung: Es gibt auch Kalküle zum Nachweis der totalen Korrektheit. 2.4 Korrektheit von Algorithmen 2-74 Beispiel: Division mit Rest Vor- und Nachbedingung, Schleifeninvariante als Annotation: static private int remainder (int x, int y) { assert x >= 0 && y > 0; int q = 0, r = x; assert x == q * y + r && 0 <= r; while (r >= y) { r = r - y; q = q + 1; assert x == q * y + r && 0 <= r; } assert x == q * y + r && 0 <= r && r < y; return r; } 2.4 Korrektheit von Algorithmen 2-75 Wunschtraum Es ist ein Algorithmus gesucht, der für beliebige p, S und q beweist oder widerlegt, dass ⊢ {p} S {q} gilt. Solch ein Algorithmus kann nicht existieren! Dann existiert natürlich erst recht kein analoger Algorithmus für die totale Korrektheit. 2.4 Korrektheit von Algorithmen 2-76 Totale Korrektheit ◮ Zum Nachweis der totalen Korrektheit muss zusätzlich zur partiellen Korrektheit die Terminierung gezeigt werden. ◮ Um die Terminierung von Schleifen nachzuweisen, gibt es keine allgemeine Methode, oft funktioniert aber die Vorgehensweise vom obigen Beispiel: 1. Man suche einen Ausdruck u, dessen Wert eine natürliche Zahl ist. 2. Man beweise, dass u bei jedem Schleifendurchlauf echt kleiner wird. 3. Da die natürlichen Zahlen wohlgeordnet sind, muss die Schleife terminieren. 2.4 Korrektheit von Algorithmen 2-77 Beispiel: Insertionsort P (j ): Das Feld A [1 · · · j ] ist eine Permutation des Ausgangsfeldes A [1 · · · j ]. S (j ): Das Feld A [1 · · · j ] ist sortiert. A ∗ [j ]: Der ursprünglich in A [j ] enthaltene Wert. Pk∗ (j ): Das Feld A [1 · · · k − 1, k + 1 · · · j ] ist eine Permutation des Ausgangsfeldes A [1 · · · j − 1]. Sk∗ (j ): Das Feld A [1 · · · k − 1, k + 1 · · · j ] ist sortiert. 2.4 Korrektheit von Algorithmen 2-78 Beispiel: Insertionsort {n ≥ 1} {P (1) ∧ S (1) ∧ n ≥ 1} j ← 2; {P (j − 1) ∧ S (j − 1) ∧ 2 ≤ j ≤ n + 1} while j ≤ n do key ← A[j]; i ← j - 1; while i > 0 ∧ A[i] > key do A[i+1] ← A[i]; i ← i - 1; od; A[i+1] ← key; j ← j + 1; od; {P (j − 1) ∧ S (j − 1) ∧ 2 ≤ j ≤ n + 1 ∧ j > n} {P (n) ∧ S (n)} 2.4 Korrektheit von Algorithmen 2-79 Beispiel: Insertionsort {P (j − 1) ∧ S (j − 1) ∧ 2 ≤ j ≤ n + 1 ∧ j ≤ n} {Pj∗ (j ) ∧ Sj∗(j ) ∧ 0 ≤ j − 1} key ← A[j]; i ← j - 1; {Pi∗+1 (j ) ∧ Si∗+1 (j ) ∧ key < A [i + 2], . . . , A [j ] ∧0 ≤ i ≤ j − 1 ∧ key = A ∗ [j ]} while i > 0 ∧ A[i] > key do A[i+1] ← A[i]; i ← i - 1; od; A[i+1] ← key; j ← j + 1; {P (j − 1) ∧ S (j − 1) ∧ 2 ≤ j ≤ n + 1} 2.4 Korrektheit von Algorithmen 2-80 Konzepte imperativer Sprachen ◮ Anweisungen ◮ ◮ ◮ Ausdrücke ◮ ◮ ◮ primitive Anweisungen: Zuweisung, Block, Prozeduraufruf zusammengesetzte Anweisungen: Sequenz, Auswahl, Iteration primitive Ausdrücke: Konstante, Variable, Funktionsaufruf zusammengesetzte Ausdrücke: Operanden/Operatoren Datentypen ◮ ◮ primitive Datentypen: Wahrheitswerte, Zeichen, Zahlen, Aufzählung zusammengesetzte Datentypen: Felder, Verbund, Vereinigung, Zeiger 2.5 Konzepte imperativer Sprachen 2-81 Konzepte imperativer Sprachen ◮ Abstraktion ◮ ◮ ◮ ◮ Anweisung: Prozedurdeklaration Ausdruck: Funktionsdeklaration Datentyp: Typdeklaration Weitere Konzepte ◮ ◮ ◮ ◮ ◮ Ein- und Ausgabe Ausnahmebehandlung Bibliotheken Parallele und verteilte Berechnungen ... 2.5 Konzepte imperativer Sprachen 2-82 Blöcke ◮ Motivation: Ein Block fasst mehrere Anweisungen und Deklarationen zu einer Einheit zusammen. ◮ Syntax: Die Abgrenzung erfolgt durch syntaktische Elemente, wie z. B. Schlüsselwörter (begin, end), Klammerung oder Einrückung. Blöcke dürfen überall statt einer einzelnen Anweisung stehen. ◮ Kontrollfluss: Die Ausführung eines Blocks beginnt mit der ersten Anweisung und wird im Normalfall nach der letzten beendet. Es gibt auch Anweisungen zum Verlassen des Blocks: break, return. ◮ Lokale Variablen: Innerhalb eines Blocks können Variablen deklariert werden, die nur in diesem Block verfügbar sind. 2.5 Konzepte imperativer Sprachen 2-83 Blöcke ◮ Globale Variable: Innerhalb eines Blocks sind alle Bezeichner aus den umschließenden Blöcken sichtbar, soweit sie nicht von einer inneren Deklaration überdeckt werden. ◮ Gültigkeitsbereich (scope): Ein Bezeichner ist innerhalb des Blocks gültig, in dem er deklariert wurde, nicht aber außerhalb. Die Gültigkeit ist eine statische Eigenschaft, die sich aus dem Programmtext ableitet. ◮ Lebensdauer: Ist eine dynamische Eigenschaft und bezeichnet den Zeitraum der Verfügbarkeit eines Wertes während der Laufzeit. Werte von überdeckten Variablen sind nach Beendigung des überdeckenden Blocks wieder verfügbar. 2.5 Konzepte imperativer Sprachen 2-84 Blöcke Code var x,y: int; · · · do var x: int; ··· ··· od; ··· Gültig | | | x Sichtbar | | | | | | | y | | | | | | | x | | | x | | | | | | | y | | | | x Einzelheiten lernen Sie in der Vorlesung „Programmieren“. 2.5 Konzepte imperativer Sprachen 2-85 Prozeduren ◮ Abstraktion: Eine Prozedur fasst mehrere Anweisungen zusammen und gibt ihnen einen Namen. Der Aufruf einer Prozedur führt die Anweisungen aus und wirkt dabei wie eine elementare Anweisung. Über Parameter können die Anweisungen gesteuert werden. ◮ Wiederverwertung: Eine Prozedur wird nur einmal deklariert und kann beliebig oft verwendet werden. ◮ Modularisierung: Die Implementation der Prozedur muss dem aufrufenden Programm nicht bekannt sein. Veränderungen innerhalb der Prozedur erfordern keine Änderung des aufrufenden Programms. 2.5 Konzepte imperativer Sprachen 2-86 Deklaration von Prozeduren ◮ Deklaration: proc P(p1 , . . . , pn ) begin a; end ◮ Name: P ist der Name der Prozedur und frei wählbar. ◮ Parameter: p1 , . . . , pn sind die Parameter der Prozedur. Sie sind lokale Variablen mit eigenem Typ (formale Parameter), denen beim Aufruf der Prozedur Werte (aktuelle Parameter) zugewiesen werden. Die Gültigkeit der Parameter ist auf den Rumpf der Prozedur beschränkt. ◮ Rumpf: a ist der Rumpf der Prozedur. Er enthält die auszuführenden Anweisungen. 2.5 Konzepte imperativer Sprachen 2-87 Werte- und Referenzparameter Werteparameter (call by value): ◮ Der aktuelle Parameter kann ein Ausdruck oder speziell auch eine Variable sein. ◮ Es wird der Wert des Ausdrucks übergeben. ◮ Die Deklaration erfolgt (zum Beispiel) ohne das Präfix var. 2.5 Konzepte imperativer Sprachen 2-88 Werte- und Referenzparameter Referenzparameter (call by reference): ◮ Der aktuelle Parameter muss eine Variable sein. ◮ Es wird die Variable (Adresse) übergeben. ◮ In der Deklaration werden Referenzparameter (zum Beispiel) mit var bezeichnet. Es gibt weitere Arten der Parameterübergabe. 2.5 Konzepte imperativer Sprachen 2-89 Beispiel Aufgabe: Vertausche die Inhalte der Variablen x und y und addiere zu beiden den Wert a . proc vertausche(a: int; var x, y: int) begin var z: int; // lokale Variable z ← x; x ← y; y ← z; x ← x + a; y ← y + a; end Die Wirkung ist die „simultane“ Ersetzung. (x , y ) ← (y + a , x + a ) 2.5 Konzepte imperativer Sprachen 2-90 Beispiel Aufrufen lässt sich die Prozedur in unterschiedlichen Umgebungen mit verschiedenen aktuellen Parametern: vertausche(0, i, j); vertausche(3, a[1], a[2]); vertausche(a[3], a[1], a[2]); vertausche(i, i, j); 2.5 Konzepte imperativer Sprachen 2-91 Funktionen ◮ Abstraktion: Funktionen sind Abstraktionen von Ausdrücken. Der Aufruf einer Funktion berechnet einen Wert eines Typs τ. ◮ Deklaration: func F(p1 , . . . , pn ): τ begin a; end ◮ Auswertung: Der Rückgabewert der Funktion wird (zum Beispiel) durch eine spezielle return-Anweisung angegeben. Diese Anweisung verlässt den Funktionsblock mit sofortiger Wirkung. Häufig wird auch der letzte Term innerhalb einer Funktion als Rückgabewert genommen, oder eine Zuweisung zum Funktionsnamen legt den Rückgabewert fest. ◮ Seiteneffekt: Wenn der Aufruf einer Funktion den Wert einer globalen Variablen verändert, spricht man von einem Seiteneffekt. 2.5 Konzepte imperativer Sprachen 2-92 Beispiel func EUKLID(x, y: int): int begin var a,b,r: int a ← x; b ← y; while b # 0 do r ← a mod b; a ← b; b ← r; od; return a; end Für negative Werte der Parameter x und y hängt das Verhalten der Funktion von der Implementierung des mod-Operators ab. 2.5 Konzepte imperativer Sprachen 2-93 Rekursive Definitionen Die Funktion f : N −→ N wird durch 1 1 f (n) = f n2 f (3n + 1) n = 0, n = 1, n ≥ 2, n gerade, n ≥ 2, n ungerade. rekursiv definiert. Damit eine rekursive Funktion einen Wert liefern kann, muss mindestens eine Alternative eine Abbruchbedingung enthalten. 2.6 Rekursionen 2-94 Auswertung von Funktionen Funktionsdefinitionen können als Ersetzungssysteme gesehen werden. Funktionswerte lassen sich aus dieser Sicht durch wiederholtes Einsetzen berechnen. Die Auswertung von f (3) ergibt f (3) → f (10) → f (5) → f (16) → f (8) → f (4) → f (2) → f (1) → 1. Terminiert der Einsetzungsprozess stets? 2.6 Rekursionen 2-95 Formen der Rekursion ◮ Lineare Rekursion In jedem Zweig einer Fallunterscheidung tritt die Rekursion höchstens einmal auf. Bei der Auswertung ergibt sich eine lineare Folge von rekursiven Aufrufen. ◮ Endrekursion Der Spezialfall einer linearen Rekursion bei dem in jedem Zweig die Rekursion als letzte Operation auftritt. Endrekursionen können effizient implementiert werden. 2.6 Rekursionen 2-96 Formen der Rekursion ◮ ◮ Verzweigende Rekursion oder Baumrekursion ! k = 0, k = n, 1 n ! ! = n−1 n−1 k + sonst. k −1 k Geschachtelte Rekursion m+1 n = 0, f (n, m) = f (n − 1, 1) m = 0, f (n − 1, f (n, m − 1)) sonst. 2.6 Rekursionen 2-97 Formen der Rekursion ◮ Verschränkte Rekursion oder wechselseitige Rekursion Der rekursive Aufruf erfolgt indirekt. n = 0, true even(n) = odd(n − 1) n > 0. n = 0, false odd(n) = even(n − 1) n > 0. 2.6 Rekursionen 2-98 Fibonacci-Zahlen Definition: n=0 0 fib(n) = 1 n=1 fib(n − 1) + fib(n − 2) n ≥ 2 Funktionswerte: 0, 1, 1, 2, 3, 5, 8, 13, 21, 34, . . . 2.6 Rekursionen 2-99 Fibonacci-Zahlen Auswertung: fib5 fib4 fib3 fib3 fib2 2.6 Rekursionen fib1 fib0 1 0 fib2 fib2 fib1 fib1 fib1 fib0 fib1 fib0 1 1 0 1 0 1 2-100 Algorithmus von Euklid Rekursive Version: falls b = 0 a ggT(a , b ) = ggT(b , a mod b ) falls b > 0 Auswertung: ggT(36, 52) → ggT(52, 36) → ggT(36, 16) → ggT(16, 4) → ggT(4, 0) → 4 ggT(36, 52) = 4 2.6 Rekursionen 2-101 Fakultät Definition: Auswertung: x≤0 1 x! = x · (x − 1)! sonst 4! = 4 · 3! = 4 · 3 · 2! = 4 · 3 · 2 · 1! = 4 · 3 · 2 · 1 · 0! = 4 · 3 · 2 · 1 · 1 = 24 2.6 Rekursionen 2-102 Fakultät (rekursiv) func FAK(x: int): int begin if x <= 0 then return 1 else return x * FAK(x - 1) fi end 2.6 Rekursionen 2-103 Fakultät (rekursiv) Berechnung von FAK(2): 1. Anlegen einer lokalen Variable x , mit Wert 2. 2. x , 0, daher Rückgabewert x · FAK (x − 1) = 2 · FAK (1) 2.1 Anlegen einer neuen lokalen Variable x mit dem Wert 1. 2.2 x , 0, daher Rückgabewert x · FAK (x − 1) = 1 · FAK (0) 2.2.1 Anlegen einer neuen lokalen Variable x mit dem Wert 0. 2.2.2 x = 0, daher ist der Rückgabewert 1. 2.3 Rückgabewert ist 1 · 1 = 1 3. Rückgabewert ist 2 · 1 = 2 2.6 Rekursionen 2-104 Fakultät (iterativ) func FAK(x: int): int begin var result: int; result ← 1; while x > 0 do result ← result * x; x ← x - 1; od; return result; end Diese Lösung hat einen von x unabhängigen Speicherbedarf. 2.6 Rekursionen 2-105 Rekursive Probleme Viele Probleme besitzen eine rekursive Struktur. Für sie können rekursive Lösungen häufig kurz und elegant formuliert werden: ◮ Berechnung rekursiv definierter mathematischer Funktionen, ◮ Überprüfen der Syntax von Ausdrücken, ◮ Operationen auf rekursiv definierten Datenstrukturen. Wichtige Unterschiede: ◮ Rekursive Formulierungen sparen explizite Laufvariablen und Kontrollstrukturen. ◮ Rekursive Aufrufe verbrauchen Speicher auf dem Stack, es sei denn, es handelt sich um Endrekursion, die von modernen Compilern optimiert wird. 2.6 Rekursionen 2-106 1. Der Algorithmenbegriff 2. Imperative Algorithmen 3. Sortieralgorithmen 3.1 Einfache Sortierverfahren 3.2 Sortieren durch Mischen 3.3 Quicksort 3.4 Eine untere Schranke für Sortierverfahren 3.5 Sortieren in linearer Zeit 4. Listen und abstrakte Datentypen 5. Objektorientierte Algorithmen 6. Bäume 7. Mengen, Verzeichnisse und Hash-Verfahren 8. Graphen 9. Entwurf von Algorithmen 10. Funktionale und deduktive Algorithmen Problemstellung ◮ Gegeben: Eine Folge ganzer Zahlen < a1 , . . . , an >. Die Folge < a1 , . . . , an > darf gleiche Elemente enthalten. ◮ Gesucht: Eine Sortierung der Eingabefolge, d. h. eine Permutation < a1′ , . . . , an′ > der Eingabefolge derart, dass a1′ ≤ a2′ ≤ · · · ≤ an′ gilt. Die Problemstellung kann auf beliebige Datentypen übertragen werden, auf denen eine Ordnungsrelation ≤ gegeben ist. Eine Sortierung heißt stabil, wenn sie die Reihenfolge gleicher Listenelemente nicht ändert. Wenn ein Sortieralgorithmus nur stabile Sortierungen liefert, heißt er ebenfalls stabil. Ein Sortieralgorithmus arbeitet in situ (in place), wenn er nur konstanten/geringen zusätzlichen Speicher benötigt. 3.1 Einfache Sortierverfahren 3-1 Einfache Sortierverfahren ◮ Sortieren durch Einfügen (Insertionsort) Dieses Verfahren wurde bereits ausführlich behandelt. ◮ Sortieren durch Auswählen (Selectionsort) ◮ Sortieren durch Vertauschen (Bubblesort) 3.1 Einfache Sortierverfahren 3-2 Sortieren durch Auswählen min(unsortiert) Beim Sortieren durch Auswählen wird stets das kleinste Element der noch unsortierten Folge gesucht und am Ende der sortierten Folge eingefügt. sortiert 3.1 Einfache Sortierverfahren unsortiert 3-3 Sortieren durch Auswählen for i ← 1 to n-1 do mp ← i; for j ← i+1 to n do if a[j] < a[mp] then mp ← j; fi; od; tmp ← a[i]; a[i] ← a[mp]; a[mp] ← tmp; od; 3.1 Einfache Sortierverfahren 3-4 Sortieren durch Auswählen Die Anzahl der Vergleiche beträgt: n−1 X n X i =1 j =i +1 n−1 X 1= (n − (i + 1) + 1) i =1 n−1 X = (n − i ) i =1 =n n−1 X i =1 1− n−1 X i i =1 1 2 = n(n − 1) − (n − 1)n 1 2 1 n − n 2 2 = Θ(n2 ) = 3.1 Einfache Sortierverfahren 3-5 Sortieren durch Vertauschen min(unsortiert) Beim Sortieren durch Vertauschen werden zwei benachbarte Elemente, die sich nicht in der richtigen Reihenfolge befinden, vertauscht. Dadurch bewegt sich das kleinste Element der unsortierten Folge nach vorne. r l sortiert 3.1 Einfache Sortierverfahren unsortiert 3-6 Sortieren durch Vertauschen for l ← 1 to n-1 do for r ← n downto l + 1 do if a[r-1] > a[r] then tmp ← a[r]; a[r] ← a[r - 1]; a[r - 1] ← tmp; fi; od; od; 3.1 Einfache Sortierverfahren 3-7 Sortieren durch Vertauschen Die Anzahl der Vergleiche beträgt: n−1 X n X l =1 r =l +1 n−1 X 1= (n − (l + 1) + 1) l =1 n−1 X (n − l ) = l =1 =n n−1 X l =1 1− n−1 X l l =1 1 2 = n(n − 1) − (n − 1)n 1 2 1 n − n 2 2 = Θ(n2 ) = 3.1 Einfache Sortierverfahren 3-8 Sortieren durch Mischen Sortieren durch Mischen (Mergesort) arbeitet rekursiv nach folgendem Schema: 1. Teile die Folge in zwei Teilfolgen auf. 2. Sortiere die beiden Teilfolgen. 3. Mische die sortierten Teilfolgen. 3.2 Sortieren durch Mischen 4 2 9 4 2 9 2 4 1 5 2 1 6 5 8 2 1 6 5 9 1 2 6 8 2 2 6 8 9 4 8 5 3-9 Sortieren durch Mischen Die Funktion Mergesort: func Mergesort(a, p, r): begin if p < r then q ← ⌊(p+r)/2⌋; Mergesort(a,p,q); Mergesort(a,q+1,r); Merge(a,p,q,r); fi; end Aufruf: Mergesort(a,1,n) 3.2 Sortieren durch Mischen 3-10 Sortieren durch Mischen func Merge(a, p, q, r): begin n1 ← q-p+1; n2 ← r-q; for i ← 1 to n1 do l[i] ← a[p+i-1]; od; for j ← 1 to n2 do r[j] ← a[q+j]; od; l[n1+1] ← ∞; r[n2+1] ← ∞; i ← 1; j ← 1; for k ← p to r do if l[i] ≤ r[j] then a[k] ← l[i]; i ← i+1; else a[k] ← r[j]; j ← j+1; od; end 3.2 Sortieren durch Mischen 3-11 Sortieren durch Mischen Schleifeninvariante: Zu Beginn jeder Iteration der For-Schleife enthält das Teilfeld a [p ..k − 1] die k − p kleinsten Elemente aus l [1..n1 + 1] und r [1..n2 + 1] in sortierter Reihenfolge. Darüber hinaus sind l [i ] und r [j ] die kleinsten Elemente ihrer Felder, die noch nicht nach a zurückkopiert wurden. 3.2 Sortieren durch Mischen 3-12 Sortieren durch Mischen ◮ ◮ Der Aufruf Merge(a, p, q, r) benötigt Θ(n) Schritte, wobei n = r − p + 1 ist. Die Anzahl der Schritte des Aufrufs Mergesort(a,1,n) ist durch die folgende Rekursionsgleichung gegeben: falls n = 1, Θ(1), T (n) = 2T (n/2) + Θ(n), falls n > 1. ◮ Wir zeigen gleich: T (n) = Θ(n log(n)). ◮ Mergesort ist damit einer der schnellsten Algorithmen, die nur mit Vergleichen zwischen Elementen arbeiten. Der Algorithmus wird z. B. von Sun in den Java-Bibliotheken verwendet. ◮ Mergesort ist ein Teile-und-Beherrsche-Algorithmus. 3.2 Sortieren durch Mischen 3-13 Teile-und-Beherrsche-Algorithmen ◮ Die Teile-und-Beherrsche-Methode (divide-and-conquer) ist eine Vorgehensweise zum Entwurf von Algorithmen. ◮ Diese Methode kann bei einer rekursiven Problemstruktur eingesetzt werden. Teile-und-Beherrsche-Algorithmen bestehen aus drei Phasen: ◮ ◮ ◮ ◮ Teile das Problem in eine Anzahl von gleichartigen Teilproblemen auf. Beherrsche diese Teilprobleme durch Rekursion. Wenn ein Teilproblem hinreichend klein ist, wird es direkt gelöst. Verbinde die Lösungen der Teilprobleme zur Gesamtlösung. ◮ Mergesort: Ein Problem der Größe n wird in 2 Teilprobleme der Größe n/2 aufgeteilt. ◮ Allgemeiner: Ein Problem der Größe n wird in a Teilprobleme der Größe n/b aufgeteilt. 3.2 Sortieren durch Mischen 3-14 Rekursionsgleichungen Bei der Analyse von Teile-und-Beherrsche-Algorithmen treten häufig Rekursionsgleichungen der folgenden Form auf: falls n klein ist, Θ(1), T (n) = aT (n/b ) + f (n), falls n groß genug ist. n/b ist hier als geeignete Rundung ⌊ bn ⌋ oder ⌈ bn ⌉ zu lesen. Kurzschreibweise: T (n) = aT (n/b ) + f (n) Lösungsmethoden: ◮ Substitutionsmethode ◮ Rekursionsbaummethode ◮ Mastertheorem 3.2 Sortieren durch Mischen 3-15 Rekursionsgleichungen Mastertheorem (Beweis s. Cormen et al.): ◮ 1. Fall: f (n) ist „polynomial kleiner“ als nlogb a . Dann gilt T (n) = Θ(nlogb a ). ◮ 2. Fall: f (n) ist „genauso groß“ wie nlogb a . Dann gilt T (n) = Θ(nlogb a log(n)). ◮ 3. Fall: f (n) ist „polynomial größer“ als nlogb a , und es gilt die Regularitätsbedingung af (n/b ) ≤ cf (n) für eine Konstante c < 1 und große n. Dann gilt T (n) = Θ(f (n)). 3.2 Sortieren durch Mischen 3-16 Quicksort 1. Zerlege das zu sortierende Feld a [p ..r ] so in zwei (möglicherweise leere) Teilfelder a [p ..q − 1] und a [q + 1..r ], dass jedes Element von a [p ..q − 1] kleiner oder gleich a [q] ist und dieses wiederum kleiner oder gleich jedem Element von a [q + 1..r ] ist. Das Element a [q] heißt Pivot-Element. 2. Sortiere die Teilfelder a [p ..q − 1] und a [q + 1..r ] rekursiv. 3. Da die Teilfelder in-place sortiert werden, ist das Feld a [p ..r ] danach sortiert. 4. Aufruf: quicksort(a,1,n). 3.3 Quicksort 3-17 Quicksort proc quicksort(a: int []; p, r: int) begin var q: int; if p < r then q ← partition(a, p, r); quicksort(a, p, q-1); quicksort(a, q+1, r); fi; end 3.3 Quicksort 3-18 Quicksort partition(a, p, r) ◮ Eingabeparameter: Das Feld a sowie die Grenzen p und r . ◮ Aufgabe: Aufteilen des Felds a in zwei Teilfelder a [p ..q − 1] und a [q + 1..r ]. Die Teilfelder können leer sein. ◮ Rückgabewert: Index q. Anschließend gilt: ∀k ∈ {p , . . . , q − 1}.a [k ] ≤ a [q], ∀k ∈ {q + 1, . . . , r }.a [q] ≤ a [k ]. 3.3 Quicksort 3-19 Quicksort 1. Zwei Indizes i und j durchlaufen das Feld von links nach rechts. 2. Initialisierung: i = p − 1, j = p . 3. Es gilt: p − 1 ≤ i ≤ j ≤ r − 1. 4. i und j teilen a [p ..r ] in vier Teilfelder auf: p≤k ≤i: i+1≤k ≤j−1: j ≤k ≤r −1: k =r: 3.3 Quicksort a [k ] ≤ x . a [k ] > x . noch unbestimmt. a [k ] = x . 3-20 Quicksort func partition(a: int []; p, r: int): int begin var x, i, j: int; x ← a[r]; i ← p-1; for j ← p to r-1 do if a[j] ≤ x then i ← i+1; swap(a[i], a[j]); fi; od; swap(a[i+1], a[r]); return i+1; end 3.3 Quicksort 3-21 Laufzeit 3.3 Quicksort ◮ Die Laufzeit von Quicksort hängt davon ab, ob die Zerlegung balanciert oder unbalanciert ist. ◮ Dies wiederum hängt von der Wahl der Pivot-Elemente ab. ◮ Wenn die Zerlegung balanciert ist, läuft der Algorithmus asymptotisch so schnell wie „Sortieren durch Mischen“. ◮ Wenn die Zerlegung unbalanciert ist, kann der Algorithmus asymptotisch so langsam wie „Sortieren durch Einfügen“ laufen. 3-22 Laufzeit im ungünstigsten Fall ◮ Für die Laufzeit im ungünstigsten Fall erhalten wir T (n) = max (T (q) + T (n − q − 1)) + Θ(n). 0≤q ≤n −1 Es gilt dann: T (n) = O (n2 ). 3.3 Quicksort ◮ Außerdem gilt: Im unbalancierten Fall ist die Laufzeit von Quicksort Θ(n2 ). ◮ Aus beiden Aussagen folgt: Die Laufzeit von Quicksort ist im ungünstigsten Fall Θ(n2 ). 3-23 Laufzeit im günstigsten und im mittleren Fall ◮ Günstigster Fall: Dieser Fall tritt ein, wenn die Aufteilung so gleichmäßig wie möglich erfolgt. Dann ist T (n) ≤ 2T (n/2) + Θ(n). Aus dem Mastertheorem folgt T (n) = O (n log(n)). ◮ Mittlerer (erwarteter) Fall: Es seien alle Eingabefolgen gleich wahrscheinlich. Dann gilt (ohne Beweis) T (n) = O (n log(n)). 3.3 Quicksort 3-24 Modifikationen von Quicksort Der Analyse für den mittleren Fall geht davon aus, dass alle Eingaben gleich wahrscheinlich sind. Dies ist oft nicht der Fall. So treten z. B. teilweise vorsortierte Folgen auf. Man kann durch geeignete Verfahren Zufälligkeit erzwingen: ◮ Die Eingabe wird vor dem Sortieren nach einem Zufallsprinzip gemischt. Dies ist in O (n) Schritten möglich. ◮ Wähle das Pivot-Element zufällig. Der ungünstigste Fall kann dann zwar noch vorkommen, ist jedoch unwahrscheinlicher geworden. 3.3 Quicksort 3-25 Vorheriges Mischen proc randomize(a: int []; p, r : int) begin for i ← p to r do swap(a[i], a[random(p, r)]); od; end Dadurch erhält die Eingabe in O(n) Schritten eine zufällige Reihenfolge. Der Aufruf lautet dann: randomize(a,1,n); quicksort(a,1,n); 3.3 Quicksort 3-26 Zufälliges Pivot-Element func randomizedPartition(a: int []; p, r: int): int begin var i: int; i ← random(p,r); swap(a[i], a[r]); return partition(a,p,r); end proc randomizedQuicksort(a: int []; p, r: int) begin var q: int; if p < r then q ← randomizedPartition(a, p, r); randomizedQuicksort(a, p, q-1); randomizedQuicksort(a, q+1, r); fi; end 3.3 Quicksort 3-27 Eine untere Schranke für Sortierverfahren ◮ Satz: Die Laufzeit von Sortieralgorithmen, die auf Vergleichen von Elementen beruhen, liegt in Ω(n log n). ◮ Beweis: Ein Entscheidungsbaum ist ein Binärbaum, dessen Knoten die Markierung < ai : aj > für einen durchgeführten Vergleich von ai und aj haben. Die Wurzel entspricht dem ersten Vergleich zweier Elemente. Das linke Kind von < ai : aj > entspricht dem Vergleich, der im Falle ai < aj folgt, das rechte Kind dem Vergleich, der im Falle ai ≥ aj folgt. < ai , aj > < 3.4 Eine untere Schranke für Sortierverfahren ≥ < ak , al > < am , an > < ··· ≥ < ··· ≥ 3-28 Eine untere Schranke für Sortierverfahren ◮ Ein Entscheidungsbaum von Insertionsort zur Folge (a1 , a2 , a3 ) ist: < a1 , a2 > ≥ < < a2 , a3 > < (a1 , a2 , a3 ) < a1 , a3 > ≥ < < a1 , a3 > < (a1 , a3 , a2 ) (a2 , a1 , a3 ) ≥ (a3 , a1 , a2 ) ≥ < a2 , a3 > < (a2 , a3 , a1 ) ≥ (a3 , a2 , a1 ) ◮ Die Blätter repräsentieren die sortierten Felder. Alle 3! = 6 Möglichkeiten sind berücksichtigt. ◮ Jeder Entscheidungsbaum zu einer Folge (a1 , . . . , an ) hat unabhängig vom Vergleichsverfahren n! Blätter. 3.4 Eine untere Schranke für Sortierverfahren 3-29 Eine untere Schranke für Sortierverfahren ◮ Satz: Jeder Entscheidungsbaum zu einer Folge (a1 , . . . , an ) besitzt die Höhe Ω(n log n). ◮ Beweis: Ein Entscheidungsbaum zu (a1 , . . . , an ) hat n! Blätter. Binärbäume der Höhe h haben höchstens 2h Blätter. ◮ ◮ ◮ n! ≤ 2h ⇒ log2 (n!) ≤ log2 (2h ) = h n √ Für große n gilt (Stirlingsche Formel): n! ≈ 2πn en n Es folgt: h ≥ log2 en = n log2 (n) − n log2 (e ) = Ω(n log n) ◮ Jeder Lauf eines auf Vergleichen basierenden Sortieralgorithmus entspricht genau einem Pfad von der Wurzel zu einem Blatt des zugehörigen Entscheidungsbaumes. ◮ Die Laufzeit ist daher nach unten durch die Höhe des Entscheidungsbaumes beschränkt, liegt also in Ω(n log n). ◮ Entscheidungsbäume berücksichtigen ausschließlich die vorgenommenen Vergleiche, keine weiteren Operationen. 3.4 Eine untere Schranke für Sortierverfahren 3-30 Sortieren in linearer Zeit ◮ Die bisher besprochenen Sortieralgorithmen haben im günstigsten Fall eine Laufzeit von Ω(n log n). ◮ Das Sortieren von n Elementen erfordert mindestens das Betrachten jedes Elements, die Laufzeit liegt also in Ω(n). ◮ Es gibt Verfahren, die in linearer Zeit arbeiten, diese verwenden aber nicht den direkten Vergleich von Elementen (s. obigen Satz). 3.5 Sortieren in linearer Zeit 3-31 Countingsort Voraussetzungen: ◮ ◮ Jeder der n zu sortierenden Werte ist eine natürliche Zahl aus {0, . . . , k } mit k ∈ N. Die zu sortierende Zahlenfolge ist in dem Feld a [1..n] gespeichert. Algorithmus: 1. Bestimme für jeden Wert x aus a die Anzahl count(x ) von Werten aus a , die kleiner oder gleich x sind. 2. Speichere die Elemente x aus a von hinten beginnend im Ergebnisfeld an der Position count(x ) und dekrementiere count(x ). 3.5 Sortieren in linearer Zeit 3-32 Countingsort var var for for count: int [0..k]; result: int [1..n]; i ← 0 to k do count[i] ← 0; od; i ← 1 to n do count[a[i]] ← count[a[i]] + 1; od; // count[a [i ]] = |{x | x = i }| for i ← 1 to k do count[i] ← count[i] + count[i-1]; od; // count[a [i ]] = |{x | x ≤ i }| for i ← n downto 1 do result[count[a[i]]] ← a[i]; count[a[i]] ← count[a[i]] - 1; od; 3.5 Sortieren in linearer Zeit 3-33 Countingsort var var for for count: int [0..k]; result: int [1..n]; i ← 0 to k do count[i] ← 0; od; i ← 1 to n do count[a[i]] ← count[a[i]] + 1; od; for i ← 1 to k do count[i] ← count[i] + count[i-1]; od; for i ← n downto 1 do result[count[a[i]]] ← a[i]; count[a[i]] ← count[a[i]] - 1; od; 3.5 Sortieren in linearer Zeit Θ(k ) Θ(n) Θ(k ) Θ(n) 3-34 Countingsort ◮ Die Laufzeit liegt in Θ(n + k ). ◮ Das Verfahren ist schnell, wenn k nicht wesentlich größer als n ist. ◮ Wenn k als konstant gesehen wird, liegt die Laufzeit in Θ(n). ◮ Countingsort ist ein stabiler Sortieralgorithmus. 3.5 Sortieren in linearer Zeit 3-35 Radixsort Voraussetzungen: ◮ ◮ Jeder der n zu sortierenden Werte ist eine natürliche Zahl aus {0, . . . , 10m − 1} mit m ∈ N. Die zu sortierende Zahlenfolge ist in dem Feld a [1..n] gespeichert. Algorithmus: 1. Sortiere für alle Stellen j = 0, . . . , m − 1 das Feld a mit einem stabilen Sortieralgorithmus, der in Θ(n) arbeitet. 2. Beginne mit der niederwertigsten Stelle. 3. Countingsort ist stabil, arbeitet in Θ(n) und kann daher als Sortieralgorithmus eingesetzt werden. 3.5 Sortieren in linearer Zeit 3-36 Radixsort Beispiel: m = 3. Die Werte sind dreistellig und liegen daher im Bereich 0, . . . , 999. Index 1 2 3 4 5 3.5 Sortieren in linearer Zeit a 293 127 126 325 225 j=0 293 325 225 126 127 j=1 325 225 126 127 293 j=2 126 127 225 293 325 3-37 Radixsort ◮ Countingsort wird m-mal aufgerufen. Die Laufzeit von Countingsort beträgt Θ(n + k ) mit k = 10. ◮ Somit gilt für die Laufzeit T (n) von Radixsort T (n) = Θ((n + k )m) = Θ((n + 10)m) = Θ(nm + 10m) ◮ Wenn m als konstant angesehen wird, folgt also T (n) = Θ(n). 3.5 Sortieren in linearer Zeit 3-38 Radixsort ◮ Die Stabilität des verwendeten Sortieralgorithmus ist von entscheidender Bedeutung. ◮ Radixsort ist auch auf komplexe Vergleichsobjekte anwendbar. So kann zum Beispiel bei Datumsangaben Countingsort auf Tag, Monat und Jahr (in dieser Reihenfolge) angewendet werden. ◮ Radixsort wird schneller, wenn größere Basen als 10 gewählt werden. 3.5 Sortieren in linearer Zeit 3-39 Bucketsort Voraussetzungen: ◮ Die n zu sortierenden Werte entstammen einer zufälligen Auswahl. Hier: Sie sind gleichverteilt aus dem Intervall [0, 1). ◮ Die zu sortierende Zahlenfolge ist in dem Feld a [1..n] gespeichert. Algorithmus: 1. Unterteile das Intervall in n gleich große Bereiche. 2. Ordne jedem dieser Bereiche einen „Behälter“ (Bucket) zur Aufnahme der in dieses Intervall gehörenden Elemente zu. 3. Speichere jeden der n Werte in dem ihm zugeordneten Behälter. 4. Sortiere die Elemente in jedem Behälter. 5. Gib Behälter für Behälter die darin enthaltenen Elemente aus. 3.5 Sortieren in linearer Zeit 3-40 Bucketsort var b: Liste [0..n-1] von int; for i ← 1 to n do Füge a [i ] zum Bucket b [⌊n · a [i ]⌋] hinzu. od; for i ← 0 to n-1 do Sortiere b [i ], z. B. mit Insertionsort. od; for i ← 0 to n-1 do Gib Behälter b [i ] aus. od; Für die erwartete Laufzeit von Bucketsort gilt T (n) = Θ(n). Einen Beweis dieser Aussage findet man in Cormen et al. Die Laufzeit für den ungünstigsten Fall liegt offensichtlich in O (n2 ). 3.5 Sortieren in linearer Zeit 3-41 Bucketsort 3.5 Sortieren in linearer Zeit 1 0.78 () 0 2 0.17 (0.12, 0.17) 1 3 0.39 (0.21, 0.23, 0.26) 2 4 0.23 (0.39) 3 5 0.72 () 4 6 0.94 () 5 7 0.26 (0.68) 6 8 0.12 (0.72, 0.78) 7 9 0.21 () 8 10 0.68 (0.94) 9 Feld a Feld b 3-42 Zusammenfassender Vergleich der Sortieralgorithmen Algorithmus Worst-Case Average-Case In situ Insertionsort Selectionsort Bubblesort Mergesort Quicksort Heapsort (später) Countingsort Radixsort Bucketsort O (n2 ) O (n2 ) O (n2 ) O (n log n) O (n2 ) O (n log n) O (n) O (n) O (n2 ) O (n2 ) O (n2 ) O (n2 ) O (n log n) O (n log n) O (n log n) O (n) O (n) O (n) Ja Ja Ja Nein Ja Ja Nein* Nein* Nein *) Nicht bei der hier gegebenen Implementierung. 3.5 Sortieren in linearer Zeit 3-43 1. Der Algorithmenbegriff 2. Imperative Algorithmen 3. Sortieralgorithmen 4. Listen und abstrakte Datentypen 4.1 Abstrakte Datentypen 4.2 Listen 4.3 Keller 4.4 Schlangen 5. Objektorientierte Algorithmen 6. Bäume 7. Mengen, Verzeichnisse und Hash-Verfahren 8. Graphen 9. Entwurf von Algorithmen 10. Funktionale und deduktive Algorithmen Datentypen Unter einem Datentyp versteht man die Zusammenfassung von Wertebereichen und Operationen zu einer Einheit. ◮ Abstrakter Datentyp: Schwerpunkt liegt auf den Eigenschaften, die die Wertebereiche und Operationen besitzen. ◮ Konkreter Datentyp: Realisierung der Wertebereiche und Operationen stehen im Vordergrund. ◮ Primitive Datentypen: bool, char, int, real, . . . 4.1 Abstrakte Datentypen 4-1 Datentypen Komplexe Datentypen, sog. Datenstrukturen, werden durch Kombination primitiver Datentypen gebildet. Sie besitzen selbst spezifische Operationen. Datenstrukturen können vorgegeben oder selbstdefiniert sein. Dabei wird über das Anwendungsspektrum unterschieden in ◮ Generische Datentypen: Werden für eine Gruppe ähnlicher Problemstellungen entworfen und sind oft im Sprachumfang bzw. der Bibliothek einer Programmiersprache enthalten (Feld, Liste, Keller, Schlange, Verzeichnis, . . . ). ◮ Spezifische Datentypen: Dienen der Lösung einer eng umschriebenen Problemstellung und werden im Zusamenhang mit einem konkreten Problem definiert. 4.1 Abstrakte Datentypen 4-2 Entwurfsprinzipien für Datentypen Anforderungen an die Definition eines Datentyps: ◮ Die Spezifikation eines Datentyps sollte unabhängig von seiner Implementierung erfolgen. Dadurch kann die Spezifikation für unterschiedliche Implementierungen verwendet werden. ◮ Reduzierung der von außen sichtbaren (zugänglichen) Aspekte auf die Schnittstelle des Datentyps. Dadurch kann die Implementierung später verändert werden, ohne dass Programmteile, die den Datentyp benutzen, angepasst werden müssen. 4.1 Abstrakte Datentypen 4-3 Entwurfsprinzipien für Datentypen Aus diesen Anforderungen heraus ergeben sich zwei Prinzipien: ◮ Kapselung (encapsulation): Alle Zugriffe geschehen immer nur über die Schnittstelle des Datentyps. ◮ Geheimnisprinzip (programming by contract): Die interne Realisierung des Datentyps bleibt dem Benutzer verborgen. 4.1 Abstrakte Datentypen 4-4 Abstrakte Datentypen Informatik-Duden: Ein Datentyp, von dem nur die Spezifikation und Eigenschaften (in Form von zum Beispiel Regeln oder Gesetzmäßigkeiten) bekannt sind, heißt abstrakt. Man abstrahiert hierbei von der konkreten Implementierung. Dies kann für ◮ eine klarere Darstellung, ◮ für den Nachweis der Korrektheit oder ◮ für Komplexitätsuntersuchungen von Vorteil sein. Ein abstrakter Datentyp wird kurz als ADT bezeichnet. Ein ADT wird ohne Kenntnis der internen Realisierung verwendet (Geheimnisprinzip). Dabei wird nur von der Schnittstelle (Kapselung) Gebrauch gemacht. 4.1 Abstrakte Datentypen 4-5 Abstrakte Datentypen Wir werden ADTen durch algebraische Spezifikationen beschreiben: ◮ Eine Signatur bildet die Schnittstelle eines ADTs. ◮ Mengen und Funktionen, die zur Signatur „passen“, heißen Algebren. ◮ Axiome schränken die möglichen Algebren ein. Der Themenkomplex „algebraische Spezifikationen“ wird hier nur einführend behandelt. 4.1 Abstrakte Datentypen 4-6 Signaturen Eine Signatur Σ = (S , Ω) besteht aus ◮ einer Menge von Sorten S und ◮ einer Menge von Operatorsymbolen Ω. Jedes Operatorsymbol f : s1 . . . sn → s besteht aus einem Namen f , einer Folge s1 , . . . , sn ∈ S , n ≥ 0, von Argumentsorten und einer Wertesorte s ∈ S . Operatorsymbole ohne Parameter heißen Konstante. 4.1 Abstrakte Datentypen 4-7 Algebren Es sei eine Signatur Σ = (S , Ω) gegeben. Eine Algebra AΣ = (AS , AΩ ) zur Signatur Σ besteht aus ◮ ◮ den Trägermengen As der Sorten s ∈ S , d. h. AS = {As | s ∈ S }, und (partiellen) Funktionen auf den Trägermengen Af : As1 × . . . × Asn → As , d. h. AΩ = {Af | f : s1 . . . sn → s ∈ Ω}. 4.1 Abstrakte Datentypen 4-8 Beispiel Eine Signatur für den ADT „Bool“ sei (vorläufig) gegeben durch: S = {Bool } Ω = {true :→ Bool , false :→ Bool } Mögliche Algebren für diese Spezifikation sind: ABool = {T , F } Atrue ≔ T Afalse ≔ F erwartungskonform ABool = N Atrue ≔ 1 Afalse ≔ 0 große Trägermenge ABool = {1} Atrue ≔ 1 Afalse ≔ 1 kleine Trägermenge 4.1 Abstrakte Datentypen 4-9 Axiome ◮ Die Zahl der möglichen Algebren kann durch Axiome eingeschränkt werden. ◮ Axiome sind (hier) Gleichungen, die die Funktionen in ihrer Wirkung einengen. ◮ Eine Menge von Axiomen bezeichnen wir mit Φ. 4.1 Abstrakte Datentypen 4-10 Signaturdiagramme Signaturen lassen sich übersichtlich durch Signaturdiagramme mit Sorten als Knoten und Operatorsymbolen als Kanten darstellen: s0 f .. . s sn Ausblick: Signaturdiagramme sind Beispiele für Graphen, die wir in Kürze betrachten werden. 4.1 Abstrakte Datentypen 4-11 Notationen für Operatorsymbole Mit dem Platzhaltersymbol _ für Argumente von Funktionen führen wir die folgenden Notationen ein: Präfix: f (_), + + _, . . . f (x ), + + i Infix: _ ≤ _, _ + _, _ ∨ _, . . . Postfix: _!, _2 , . . . a ≤ b , m + n, p ∨ q Mixfix: |_|, if _then_else _fi , . . . |x | n!, x 2 Bei der Präfixnotation schreiben wir auch kurz f . 4.1 Abstrakte Datentypen 4-12 ADT der Wahrheitswerte S = {Bool } Ω = {true :→ Bool , false :→ Bool , ¬_ : Bool → Bool , _ ∨ _ : Bool × Bool → Bool , _ ∧ _ : Bool × Bool → Bool } Φ = {x ∧ false = false ∧ x = false , x ∧ true = true ∧ x = x , true Bool ¬ false ∨, ∧ x ∨ true = true ∨ x = true , false ∨ false = false , ¬false = true , ¬true = false } 4.1 Abstrakte Datentypen 4-13 ADT der natürlichen Zahlen S = {Nat } Ω = {0 :→ Nat , succ 0 Nat succ : Nat → Nat } Φ = {} ◮ Damit wird z. B. die Zahl 3 als succ (succ (succ (0))) = succ 3 (0) dargestellt. ◮ Der Term succ n (0) stellt die natürliche Zahl n dar. Da es keine Axiome gibt, kann dieser Term nicht vereinfacht werden. 4.1 Abstrakte Datentypen 4-14 ADT der natürlichen Zahlen S = {Nat } Ω = {0 :→ Nat , succ : Nat → Nat , add : Nat × Nat → Nat } succ 0 Nat Φ = {add (x , 0) = x , add (x , succ (y )) = add succ (add (x , y ))} Dies ist eine formale Spezifikation der natürlichen Zahlen mit der Konstanten 0, der Nachfolgerfunktion und der Addition. Implementierungen sind nicht verpflichtet, die Operationen gemäß der Axiome zu realisieren. Sie müssen lediglich das durch die Axiome beschriebene Verhalten gewährleisten. 4.1 Abstrakte Datentypen 4-15 Beispiel Es soll 2 + 3 berechnet werden. 2 + 3 = add (succ (succ (0)), succ (succ (succ (0)))) = succ (add (succ (succ (0)), succ (succ (0)))) = succ (succ (add (succ (succ (0)), succ (0)))) = succ (succ (succ (add (succ (succ (0)), 0)))) = succ (succ (succ (succ (succ (0))))) Der ADT Nat erfüllt eine besondere Eigenschaft: Jeder Term besitzt eine eindeutige Normalform succ n (0). Diese entsteht, wenn man die Gleichungen von links nach rechts anwendet, bis alle add-Operationssymbole verschwunden sind. 4.1 Abstrakte Datentypen 4-16 Implementierung eines abstrakten Datentyps Implementierung eines ADTs heißt: ◮ Realisierung der Sorten s ∈ S durch Datenstrukturen As Beispiel: Nat { B m (m-stellige Vektoren über {0, 1}) ◮ Realisierung der Operatoren f : s1 . . . sn → s durch Funktionen Af : As1 × . . . × Asn → As Beispiel: add : Nat × Nat → Nat { _ + _ : B m × B m → B m ◮ Sicherstellen, dass die Axiome (in den Grenzen der darstellbaren Werte bzw. der Darstellungsgenauigkeit) gelten. 4.1 Abstrakte Datentypen 4-17 Implementierung eines abstrakten Datentyps Beispiel: ANat = B m Darstellung von x ∈ N mit m Binärziffern zm−1 , . . . , z0 ∈ B : x= m −1 X i =0 zi · 2i Darstellbarer Zahlenbereich: 0 ≤ x ≤ 2m − 1 Die Gültigkeit der Rechengesetze muss gewährleistet sein. 4.1 Abstrakte Datentypen 4-18 Alternative Notation Im Folgenden wird alternativ zur mathematischen Schreibweise folgende an Programmiersprachen angelehnte Notation genutzt: S = {Nat } Ω = {0 :→ Nat , succ : Nat → Nat , add : Nat × Nat → Nat } Φ = {add (x , 0) = x , add (x , succ (y )) = succ (add (x , y ))} 4.1 Abstrakte Datentypen type Nat import ∅ operators 0 :→ Nat succ : Nat → Nat add : Nat × Nat → Nat axioms ∀i , j ∈ Nat add (i , 0) = i add (i , succ (j )) = succ (add (i , j )) 4-19 Algebraische Spezifikationen Eine Import-Anweisung erlaubt die Angabe der Sorten, die zusätzlich zur zu definierenden Sorte benötigt werden. Eine algebraische Spezifikation eines ADTs besteht aus ◮ einer Signatur und ◮ aus Axiomen und ggf. zusätzlich ◮ aus Import-Anweisungen. Eine Algebra, die die Axiome erfüllt, heißt Modell der Spezifikation. Auf die Frage nach der Existenz und Eindeutigkeit von Modellen können wir hier nicht eingehen. 4.1 Abstrakte Datentypen 4-20 Lineare Datentypen 4.2 Listen ◮ In diesem Kapitel besprechen wir die linearen Datentypen Liste, Keller und Schlange. ◮ Nichtlineare Datentypen sind zum Beispiel Bäume und Graphen, die später behandelt werden. ◮ In vielen Programmiersprachen sind lineare Datentypen und ihre grundlegenden Operationen Bestandteil der Sprache oder in Bibliotheken verfügbar. ◮ Listen spielen in der funktionalen Programmierung eine große Rolle. 4-21 Listen ◮ Eine (lineare) Liste ist eine Folge von Elementen eines gegebenen Datentyps. ◮ Es können jederzeit Elemente in eine Liste eingefügt oder Elemente aus einer Liste gelöscht werden. ◮ Der Speicherbedarf einer Liste ist daher dynamisch, d. h., er steht nicht zur Übersetzungszeit fest, sondern kann sich noch während der Laufzeit ändern. ◮ Listen und ihre Varianten sind die wichtigsten dynamischen Datenstrukturen überhaupt. x1 4.2 Listen x2 x3 ... xn 4-22 Typische Operationen für Listen 4.2 Listen ◮ Erzeugen einer leeren Liste ◮ Testen, ob eine Liste leer ist ◮ Einfügen eines Elements am Anfang/Ende einer Liste ◮ Löschen eines Elements am Anfang/Ende einer Liste ◮ Rückgabe des ersten/letzten Elements einer Liste ◮ Bestimmen von Teillisten, zum Beispiel: Liste ohne Anfangselement ◮ Testen, ob ein gegebener Wert in einer Liste enthalten ist ◮ Berechnen der Länge einer Liste ◮ Bestimmen des Vorgängers/Nachfolgers eines Listenelements 4-23 Parametrisierte Datentypen 4.2 Listen ◮ Die im Folgenden eingeführten abstrakten Datentypen sind parametrisiert. ◮ Die Spezifikation eines abstrakten Datentyps kann ein oder mehrere Sortenparameter enthalten, die unterschiedlich instanziiert werden können. ◮ Beispiel: In der Spezifikation der Listen tritt der Parameter T auf. List (T ) kann dann beispielsweise durch List (Bool ), List (Nat ) oder List (List (Nat )) instanziiert werden. 4-24 Listen [] length head T Liste : 4.2 Listen Nat tail type List (T ) import Nat operators [] :→ List _ : _ : T × List → List head : List → T tail : List → List length : List → Nat axioms ∀l ∈ List , ∀x ∈ T head (x : l ) = x tail (x : l ) = l length ([]) = 0 length (x : l ) = succ (length (l )) 4-25 Implementierungen 4.2 Listen ◮ Listen können mithilfe verketteter Strukturen implementiert werden. Hier gibt es viele Varianten: einfache und doppelte Verkettung, Zeiger auf das erste und/oder letzte Element der Liste, zirkuläre Listen, . . . ◮ Alternativ können Listen durch Felder implementiert werden. Die Methoden sehen dann natürlich anders aus. Beispielsweise müssen beim Einfügen eines Elements am Anfang der Liste alle anderen Elemente um eine Position nach hinten verschoben werden. ◮ Dynamische Datenstrukturen nutzen den zur Verfügung stehenden Speicherplatz weniger effizient aus. Wenn der benötigte Speicherplatz vor dem Programmlauf genau abgeschätzt werden kann, können statische Strukturen sinnvoll sein. 4-26 Implementierungen Eine Liste kann als Verkettung einzelner Objekte implementiert werden. Man spricht von einer einfach verketteten Liste. Beispiel: Liste von Namen („Oliver“, „Peter“, „Walter“) head tail Oliver Peter Walter Die Kästen stellen sogenannte Knoten (engl. node) dar. Jeder Knoten enthält einen Wert vom Typ T und eine Referenz auf den nächsten Knoten. Der letzte Knoten enthält den leeren Verweis. 4.2 Listen 4-27 Bestimmen des ersten Listenelements func head(l: Liste): T begin var k: <Referenz auf Knoten>; k ← <Kopf der Liste l>; if k <ungültige Referenz> then return <keinen Wert>; fi; return k.wert; end Aufwand: Θ(1) 4.2 Listen 4-28 Einfügen eines Listenelements am Anfang 1. Erzeugen eines neuen Knotens. 2. Referenz des neuen Knotens auf den ehemaligen Kopfknoten setzen. 3. Kopfreferenz der Liste auf den neuen Knoten setzen. head X Oliver Peter Walter Aufwand: Θ(1) 4.2 Listen 4-29 Einfügen eines Listenelements am Anfang func addFirst(v: T; l: Liste): Liste begin var k: <Referenz auf Knoten>; k ← <neuer Knoten>; k.wert ← v; k.referenz ← <Kopf der Liste l>; <Kopf der Liste l> ← k; return l; end 4.2 Listen 4-30 Einfügen eines Listenelements am Ende 1. Navigieren zum letzen Knoten. 2. Erzeugen eines neuen Knotens. 3. Einhängen des neuen Knotens. head X Oliver Peter Walter Aufwand: Θ(n) 4.2 Listen 4-31 Einfügen eines Listenelements am Ende func addLast(v: T; l: Liste): Liste begin var k: <Referenz auf Knoten>; var nk: <Referenz auf Knoten>; k ← <Kopf der Liste l>; nk ← <neuer Knoten>; nk.wert ← v; nk.referenz ← <ungültige Referenz>; if k <ungültige Referenz> then <Kopf der Liste l> ← nk; return l; fi; while k.referenz <gültige Referenz> do k ← k.referenz; od; k.referenz ← nk; return l; end 4.2 Listen 4-32 Vorgänger in einfach verketteten Listen 1. Navigieren bis zum Knoten k , dabei Referenz v auf Vorgängerknoten des aktuell betrachteten Knotens mitführen. 2. Rückgabe des Knoten v . Aufwand: Θ(n) Beispiel: Bestimmung des Vorgängers von „Walter“ Schritt 1 (v =⊥) Betrachteter Knoten: α Schritt 2 (v = α) Betrachteter Knoten: β Schritt 3 (v = β) Betrachteter Knoten: γ Objekt gefunden, Rückgabe von v = β head α Oliver 4.2 Listen γ β Peter Walter 4-33 Vorgänger in einfach verketteten Listen Finden des m-ten Vorgängers eines Knotens k in einer Liste: 1. Navigieren bis zum Knoten k , die Referenz v wird wie vorher mitgeführt, beginnt aber erst am Kopf der Liste, sobald der (m + 1)-te Knoten betrachtet wird. 2. Rückgabe von v . Beispiel: Bestimmung des zweiten Vorgängers von „Walter“ Schritt 1 (v =⊥) Betrachteter Knoten: α Schritt 2 (v =⊥) Betrachteter Knoten: β Schritt 3 (v = α) Betrachteter Knoten: γ Objekt gefunden, Rückgabe von v = α head α Oliver 4.2 Listen γ β Peter Walter 4-34 Vorgänger in doppelt verketteten Listen Alternativ kann man auch die Datenstruktur ändern: Jeder Knoten wird um eine Referenz auf den Vorgängerknoten ergänzt. Die Suche nach dem m-ten Vorgänger von k erfolgt dann von k aus nach vorne. Schritt 2 Betrachteter Knoten: α Schritt 1 Betrachteter Knoten: β head α Oliver 4.2 Listen γ β Peter Walter 4-35 Doppelt verkettete Listen ◮ Der Zugriff auf den Vorgängerknoten wird vereinfacht. ◮ Es wird zusätzlicher Speicherplatz pro Element verbraucht. ◮ Verwaltung des zweiten Zeigers bedeutet zusätzlichen Aufwand. Beispiel: Löschen eines Knotens head Oliver 4.2 Listen Peter Walter 4-36 Keller ◮ Ein Keller (stack) ist eine Liste, auf die nur an einem Ende zugegriffen werden kann. ◮ Keller arbeiten nach dem Last-In-First-Out-Prinzip und werden deshalb auch LIFO-Speicher genannt. x5 x4 x4 x3 x2 x1 4.3 Keller 4-37 Operationen für Keller ◮ Erzeugen eines leeren Kellers (empty) ◮ Testen, ob ein Keller leer ist (empty?) ◮ Rückgabe des ersten Elements eines Kellers (top) ◮ Einfügen eines Elements am Anfang eines Kellers (push) ◮ Löschen eines Elements am Anfang eines Kellers (pop) Keller dürfen nur mithilfe dieser Operationen bearbeitet werden. 4.3 Keller 4-38 Implementierungen ◮ Realisierung durch eine Liste: top ... xn ◮ x1 Realisierung durch ein Feld: top x1 x2 x3 4.3 Keller ... xn 4-39 Keller empty top T empty? Stack push Bool pop Anmerkung: pop (empty ) =⊥ top (empty ) =⊥ Diese Fälle bleiben undefiniert. 4.3 Keller type Stack (T ) import Bool operators empty :→ Stack push : Stack × T → Stack pop : Stack → Stack top : Stack → T empty ? : Stack → Bool axioms ∀s ∈ Stack , ∀x ∈ T pop (push (s , x )) = s top (push (s , x )) = x empty ?(empty ) = true empty ?(push (s , x )) = false 4-40 Implementierungen 4.3 Keller ◮ Es ist sinnvoll, bei der Implementierung von Datenstrukturen auf bereits vorhandene Strukturen zurückzugreifen. ◮ Der abstrakte Datentyp „Keller“ wird durch Rückgriff auf den Datentyp „Liste“ realisiert. 4-41 Implementierungen ◮ Die Sorte Stack(T) wird implementiert durch die Menge AList (T ) der Listen über T . ◮ Die Operatoren werden durch die folgenden Funktionen implementiert: empty = [] push (l , x ) = x : l pop (x : l ) = l top (x : l ) = x empty ?([]) = true empty ?(x : l ) = false Die Fehlerfälle pop (empty ) und top (empty ) bleiben unbehandelt. In einer konkreten Realisierung müssen hierfür natürlich Vorkehrungen getroffen werden. 4.3 Keller 4-42 Anwendungen Keller gehören zu den wichtigsten Datenstrukturen überhaupt. Sie werden zum Beispiel ◮ zur Bearbeitung von Klammerstrukturen, ◮ zur Auswertung von Ausdrücken und ◮ zur Verwaltung von Rekursionen benötigt. 4.3 Keller 4-43 Anwendungsbeispiel: Überprüfung von Klammerstrukturen 4.3 Keller ◮ Wir werden jetzt an einem konkreten Beispiel erläutern, wie Keller in der Praxis benutzt werden. ◮ Ziel ist es, einen Algorithmus zu entwickeln, der eine Datei daraufhin überprüft, ob die in dieser Datei enthaltenen Klammern (, ), [, ], { und } korrekt verwendet wurden. Beispielsweise ist die Folge "( [a] b {c} e )" zulässig, nicht aber "( ] ]". 4-44 Anwendungsbeispiel: Überprüfung von Klammerstrukturen ◮ ◮ Es wird ein anfangs leerer Keller erzeugt. Es werden alle Symbole bis zum Ende der Eingabe gelesen: ◮ ◮ Eine öffnende Klammer wird mit push auf den Keller geschrieben. Bei einer schließenden Klammer passiert folgendes: ◮ ◮ ◮ 4.3 Keller Fehler, falls der Keller leer ist. Sonst wird die Operation pop durchgeführt. Fehler, falls das Symbol, das vom Keller entfernt wurde, nicht mit der schließenden Klammer übereinstimmt. Alle anderen Symbole werden überlesen. ◮ Fehler, falls der Keller am Ende der Eingabe nicht leer ist. ◮ Die Eingabe ist zulässig. 4-45 Schlangen ◮ Ein Schlange (queue) ist eine Liste, bei der an einem Ende Elemente hinzugefügt und am anderen entfernt werden können. ◮ Schlangen arbeiten nach dem First-In-First-Out-Prinzip und werden deshalb auch FIFO-Speicher genannt. x5 x1 x5 x4 x3 x2 x1 4.4 Schlangen 4-46 Operationen für Schlangen ◮ Erzeugen einer leeren Schlange (empty) ◮ Testen, ob eine Schlange leer ist (empty?) ◮ Einfügen eines Elements am Ende einer Schlange (enter) ◮ Löschen eines Elements am Anfang einer Schlange (leave) ◮ Rückgabe des ersten Elements einer Schlange (front) Schlangen dürfen nur mithilfe dieser Operationen bearbeitet werden. 4.4 Schlangen 4-47 Implementierungen ◮ Realisierung durch eine Liste: Ende Anfang ... xn ◮ Realisierung durch ein zyklisch verwaltetes Feld: Ende - 4.4 Schlangen x1 - xn ... Anfang x3 x2 x1 - ... - 4-48 Schlangen empty front empty? Queue T enter Bool leave Anmerkung: leave (empty ) =⊥ front (empty ) =⊥ Diese Fälle bleiben undefiniert. 4.4 Schlangen type Queue (T ) import Bool operators empty :→ Queue enter : Queue × T → Queue leave : Queue → Queue front : Queue → T empty ? : Queue → Bool axioms ∀q ∈ Queue , ∀x ∈ T leave (enter (empty , x )) = empty leave (enter (enter (q, x ), y )) = enter (leave (enter (q, x )), y ) front (enter (empty , x )) = x front (enter (enter (q, x ), y )) = front (enter (q, x )) empty ?(empty ) = true empty ?(enter (q, x )) = false 4-49 Implementierungen Der abstrakte Datentyp „Schlange“ wird ebenfalls durch den Rückgriff auf den abstrakten Datentyp „Liste“ implementiert. ◮ Die Sorte Queue(T) wird implementiert durch die Menge AList (T ) der Listen über T . ◮ Die Operatoren werden durch die folgenden Funktionen implementiert: empty = [] enter (l , x ) = x : l leave (x : []) = [] leave (x : l ) = x : leave (l ) 4.4 Schlangen front (x : []) = x front (x : l ) = front (l ) empty ?([]) = true empty ?(x : l ) = false 4-50 Anwendungen Eine häufige Anwendung sind Algorithmen zur Vergabe von Ressourcen an Verbraucher. ◮ Prozessverwaltung: ◮ ◮ ◮ ◮ Druckerverwaltung: ◮ ◮ ◮ 4.4 Schlangen Ressource: Rechenzeit Elemente der Warteschlange: rechenwillige Prozesse Grundidee: Jeder Prozess darf eine feste Zeit lang rechnen, wird dann unterbrochen und hinten in die Warteschlange wieder eingereiht, falls weiterer Bedarf an Rechenzeit vorhanden ist. Ressource: Drucker Elemente der Warteschlange: Druckaufträge Grundidee: Druckaufträge werden nach der Reihenfolge ihres Eintreffens abgearbeitet. 4-51 Deques 4.4 Schlangen ◮ Eine deque (double-ended queue) ist eine Liste, bei der an beiden Enden Elemente hinzugefügt und entfernt werden können. ◮ Nach den vorangegangenen Beispielen sollte klar sein, welche Operationen eine Deque besitzt und wie diese implementiert werden können. 4-52 1. Der Algorithmenbegriff 2. Imperative Algorithmen 3. Sortieralgorithmen 4. Listen und abstrakte Datentypen 5. Objektorientierte Algorithmen 5.1 Objekte und Klassen 5.2 Vererbung 5.3 Abstrakte Klassen 5.4 Objektorientierte Softwareentwicklung 6. Bäume 7. Mengen, Verzeichnisse und Hash-Verfahren 8. Graphen 9. Entwurf von Algorithmen 10. Funktionale und deduktive Algorithmen Einführung Das objektorientierte Paradigma der Algorithmenentwicklung hat verschiedene Wurzeln: ◮ Realisierung abstrakter Datentypen ◮ rechnergeeignete Modellierung der realen Welt (objektorientierte Analyse) ◮ problemnaher Entwurf von Sofwaresystemen (objektorientiertes Design) ◮ problemnahe Implementierung (objektorientierte Programmierung) 5.1 Objekte und Klassen 5-1 Grundlagen der Objektorientierung Klassen Begriffswelt des Modellierenden Person Patient Mitarbeiter Arzt Chefarzt Krankenhaus Mitarbeiter Patienten Schwestern Verwaltung Ärzte Prof. Dr. Sauerbruch Dr. Quincy Fr. Müller Objekte/Instanzen 5.1 Objekte und Klassen Realität 5-2 Objekte Ein Objekt ist die Repräsentation eines Gegenstands oder Sachverhalts der realen Welt oder eines rein gedanklichen Konzepts. Es ist gekennzeichnet durch ◮ eine eindeutige Identität, durch die es sich von anderen Objekten unterscheidet, ◮ statische Eigenschaften zur Darstellung des Zustands des Objekts in Form von Attributen, ◮ dynamische Eigenschaften in Form von Methoden, die das Verhalten des Objekts beschreiben. 5.1 Objekte und Klassen 5-3 Beispiele für Objekte ◮ Eine Person mit Namen „Müller“ und Geburtsdatum „12.12.1953“ (Attribute mit Belegungen) und Methode „alter(): int“. ◮ Eine rationale Zahl mit Zähler und Nenner (Attribute) und Methoden „normalisiere()“ und „addiere(r: RationaleZahl)“ Es findet in der Regel eine Abstraktion statt. Gewisse Aspekte (zum Beispiel das „Gewicht“ einer Person) werden nicht berücksichtigt. Der Zustand eines Objekts zu einem Zeitpunkt entspricht der Belegung der Attribute des Objekts zu diesem Zeitpunkt. Der Zustand eines Objekts kann mithilfe von Methoden erfragt und geändert werden. 5.1 Objekte und Klassen 5-4 Methoden ◮ Methoden sind in der programmiersprachlichen Umsetzung Prozeduren oder Funktionen, denen Parameter übergeben werden können. ◮ Der Zustand eines eine Methode ausführenden Objekts (und nur der dieses Objekts) ist der Methode im Sinne einer Menge globaler Variablen direkt zugänglich. Er kann daher sowohl gelesen als auch verändert werden. 5.1 Objekte und Klassen 5-5 Objektmodelle ◮ Wertbasierte Objektmodelle: In diesem Modell besitzen Objekte keine eigene Identität im eigentlichen Sinn. Zwei Objekte werden schon als identisch angesehen, wenn ihr Zustand gleich ist. Für zwei Objekte zur Datumsangabe d1 = „6.12.1986“ und d2 = „6.12.1986“ gilt in diesem Modell d1 = d2 . ◮ Identitätsbasierte Objektmodelle: Jedem Objekt innerhalb des Systems wird eine vom Wert unabhängige Identität zugeordnet. Zwei Objekte für Personen p1 = „Müller, 12.12.1953“ und p2 = „Müller, 12.12.1953“ sind in diesem Modell nicht identisch: p1 , p2 . 5.1 Objekte und Klassen 5-6 Kapselung und Geheimnisprinzip Objekte verwenden das Geheimnisprinzip und das Prinzip der Kapselung. Sie verbergen ihre Interna ◮ Zustand (Belegung der Attribute), ◮ Implementierung ihres Zustands, ◮ Implementierung ihres Verhaltens. Objekte sind nur über ihre Schnittstelle, also über die Menge der vom Objekt der Außenwelt zur Verfügung gestellten Methoden, zugänglich. Man spricht von den Diensten des Objekts. 5.1 Objekte und Klassen 5-7 Nachrichten Objekte interagieren über Nachrichten: ◮ Ein Objekt x sendet eine Nachricht n an Objekt y . ◮ y empfängt die Nachricht n von x . ◮ Innerhalb einer Programmiersprache wird dieser Vorgang meist durch einen Methodenaufruf implementiert. ◮ Nachrichten (Methodenaufrufe) können den Zustand eines Objektes verändern. ◮ Ein Objekt kann sich selbst Nachrichten schicken. 5.1 Objekte und Klassen 5-8 Beziehungen zwischen Objekten Objekte können in Beziehungen zueinander stehen. ◮ Die Beteiligten an einer Beziehung nehmen Rollen ein. Rolle des Arztes: „behandelnder Arzt“, Rolle des Patienten: „Patient“. ◮ Ein Objekt kann mit mehreren Objekten in der gleichen Beziehung stehen. Rolle von Arzt: „behandelnder Arzt“, Rolle von Patient 1: „Patient“, Rolle von Patient 2: „Patient“. ◮ Nachrichten können nur ausgetauscht werden, wenn eine Beziehung besteht. ◮ Beziehungen können sich während der Lebenszeit eines Objekts verändern. 5.1 Objekte und Klassen 5-9 Klassen ◮ Objekte besitzen eine Identität, verfügen über Attribute und Methoden, gehen Beziehungen zu anderen Objekten ein und interagieren über Nachrichten. ◮ Es gibt in der Regel Objekte, die sich bezüglich Attribute, Methoden und Beziehungen ähnlich sind. Daher bietet es sich an, diese Objekte zu einer Klasse zusammenzufassen. 5.1 Objekte und Klassen 5-10 Klassen Patient name: String geburtsdatum: Date diagnose: String p1: Patient name: Müller“ ” geburtsdatum: 03.01.1987 diagnose: Grippe“ ” 5.1 Objekte und Klassen p2: Patient name: Meier“ ” geburtsdatum: 06.12.1986 diagnose: Husten“ ” p3: Patient name: Schulz“ ” geburtsdatum: 22.09.1993 diagnose: Kleinwuchs“ ” 5-11 Klassen Eine Klasse ist die Beschreibung von Objekten, die über eine gleichartige Menge von Attributen und Methoden verfügen. Sie beinhaltet auch Angaben darüber, wie Objekte dieser Klasse verwaltet (zum Beispiel erzeugt oder gelöscht) werden können. ◮ Klassendefinitionen sind eng verwandt mit abstrakten Datentypen. Sie legen Attribute und Methoden der zugehörigen Objekte fest. ◮ Objekte einer Klasse nennt man auch Instanzen dieser Klasse. ◮ Beziehungen (Assoziationen) zwischen den Objekten werden auf Klassenebene beschrieben. Ein Konstruktor ist eine Methode zum Erzeugen von Objekten. 5.1 Objekte und Klassen 5-12 Klassenvariable und -methoden ◮ Es gibt Attribute von Klassen, die nicht an konkrete Instanzen gebunden sind. Diese heißen Klassenvariable oder statische Variable. In dieser Sprechweise werden instanzgebundene Attribute auch als Instanzvariable bezeichnet. ◮ Klassenvariable existieren für die gesamte Lebensdauer einer Klasse genau einmal – unabhängig davon, wie viele Objekte erzeugt wurden. ◮ Neben Klassenvariablen gibt es auch Klassenmethoden, d. h. Methoden, deren Existenz nicht an konkrete Objekte gebunden ist. Klassenmethoden werden auch statische Methoden genannt. 5.1 Objekte und Klassen 5-13 Vererbung ◮ Häufig gibt es Klassen, die sich in Attributen, Methoden und Beziehungen ähnlich sind. Beispiel: Zahl, natürliche Zahl, ganze Zahl, rationale Zahl. 5.2 Vererbung ◮ Man versucht, zu ähnlichen Klassen eine gemeinsame Oberklasse zu finden, die die Ähnlichkeiten subsumiert und ergänzt die Unterklassen nur um die individuellen Eigenschaften. ◮ Eine Unterklasse erbt die Attribute und Methoden der Oberklasse. 5-14 Vererbung Person name: String geburtsdatum: Date Patient name: String geburtsdatum: Date patientenNr: Integer diagnose: String 5.2 Vererbung Arzt Gemeinsamkeiten name: String geburtsdatum: Date mitarbeiterNr: Integer fachrichtung: String 5-15 Vererbung ◮ ◮ ◮ 5.2 Vererbung B erbt alle Attribute und Methoden von A und fügt in der Regel weitere hinzu. AO A ist die Basisklasse (Oberklasse) und B die abgeleitete Klasse (Unterklasse). B B ist eine Spezialisierung von A und A eine Generalisierung von B. 5-16 Vererbung und Polymorphismus ◮ „Jede Instanz b ∈ B ist auch ein a ∈ A.“ ◮ Einfachvererbung: Jede abgeleitete Klasse besitzt genau eine Vaterklasse. ◮ In Java: class B extends A { ... } AO B Eine Variable vom Typ einer Basisklasse kann während ihrer Lebensdauer sowohl Objekte ihres eigenen Typs als auch solche von abgeleiteten Klassen aufnehmen. Dies wird als Polymorphismus bezeichnet. 5.2 Vererbung 5-17 Vererbung 5.2 Vererbung ◮ Eine Unterklasse erbt von ihrer Oberklasse alle Attribute und Methoden und kann diese um weitere Attribute und Methoden ergänzen. ◮ „Erben“ heißt: Die Attribute und Methoden der Oberklasse können in der Unterklasse verwendet werden, als wären sie in der Klasse selbst definiert. 5-18 Vererbung ◮ Vererbungen können mehrstufig sein. ◮ Jede abgeleitete Klasse erbt die Attribute und Methoden der jeweiligen Oberklasse. ◮ Es entstehen Vererbungshierarchien. AO BO C 5.2 Vererbung 5-19 Vererbung ◮ Vererbungshierarchien können sehr komplex sein. ◮ Sie lassen sich durch azyklische gerichtete Graphen darstellen. A F O X22 B 22 22 22 22 22 2 C D F O X22 E 5.2 Vererbung 22 22 22 22 22 2 F G 5-20 Verdecken von Variablen 5.2 Vererbung ◮ Eine Unterklasse kann eine Variable deklarieren, die denselben Namen trägt, wie eine der Oberklasse. ◮ Hierdurch wird die weiter oben liegende Variable verdeckt. ◮ Dies wird häufig dazu benutzt, um den Typ einer Variablen der Oberklasse zu überschreiben. ◮ In manchen Programmiersprachen gibt es Konstrukte, die den Zugriff auf verdeckte Variable ermöglichen (in Java: Verwendung des Präfixes „super“). 5-21 Überlagern von Methoden 5.2 Vererbung ◮ Methoden, die aus der Basisklasse geerbt werden, dürfen in der abgeleiteten Klasse überlagert, d. h. neu definiert, werden. ◮ Da eine Variable einer Basisklasse Werte von verschiedenen Typen annehmen kann, entscheidet sich bei überlagerten Methoden im Allgemeinen erst zur Laufzeit, welche Methode zu verwenden ist: Dynamische Methodensuche. ◮ Wird eine Methode in einer abgeleiteten Klasse überlagert, wird die ursprüngliche Methode verdeckt. Aufrufe der Methode beziehen sich auf die überlagernde Variante. ◮ In manchen Programmiersprachen gibt es Konstrukte, die den Zugriff auf überlagerte Methoden ermöglichen (in Java: Verwendung des Präfixes „super“). 5-22 Modifikatoren Mithilfe von Modifikatoren können Sichtbarkeit und Eigenschaften von Klassen, Variablen und Methoden beeinflusst werden. ◮ Die Sichtbarkeit bestimmt, ob eine Klasse, Variable oder Methode in anderen Klassen genutzt werden kann. ◮ Eigenschaften, die über Modifikatoren gesteuert werden können, sind z. B. die Lebensdauer und die Veränderbarkeit. Beispiele für Modifikatoren in Java sind: public, protected, private, static, final, . . . 5.2 Vererbung 5-23 Mehrfachvererbung AX11 B 11 11 11 11 11 1 F C 5.2 Vererbung ◮ Eine Klasse kann im Allgemeinen mehrere Oberklassen besitzen. In diesem Fall spricht man von Mehrfachvererbung (multiple inheritance) im Gegensatz zur Einfachvererbung (single inheritance). ◮ Problematisch ist die Behandlung von Konflikten, wenn gleichnamige Attribute oder Methoden in verschiedenen Oberklassen definiert werden. ◮ Mehrfachvererbung ist in Java nur für Schnittstellen erlaubt. 5-24 Abstrakte Methoden ◮ Eine Methode heißt abstrakt, wenn ihre Deklaration nur die Schnittstelle, nicht aber die Implementierung enthält. Im Gegensatz dazu stehen konkrete Methoden, deren Deklarationen auch Implementierungen besitzen. ◮ Java: Die Deklaration einer abstrakten Methode erfolgt durch den Modifikator abstract. Anstelle des Rumpfes steht lediglich ein Semikolon. ◮ Abstrakte Methoden können nicht aufgerufen werden, sie definieren nur eine Schnittstelle. Erst durch Überlagerung in einer abgeleiteten Klasse und durch Angabe der fehlenden Implementierung wird eine abstrakte Methode konkret. 5.3 Abstrakte Klassen 5-25 Abstrakte Klassen ◮ Eine Klasse, die nicht instanziiert werden kann, heißt abstrakte Klasse. Klassen, von denen Objekte erzeugt werden können, sind konkrete Klassen. ◮ Jede Klasse, die mindestens eine abstrakte Methode besitzt, ist abstrakt. ◮ Java: Eine Klasse ist abstrakt, wenn sie mindestens eine abstrakte Methode enthält. Die Deklaration einer abstrakten Klasse erfolgt ebenfalls durch den Modifikator abstract. ◮ Java: Es ist erforderlich, abstrakte Klassen abzuleiten und in der abgeleiteten Klasse eine oder mehrere abstrakte Methoden zu implementieren. Die Konkretisierung kann über mehrere Stufen erfolgen. 5.3 Abstrakte Klassen 5-26 Schnittstellen ◮ Java: Eine Schnittstelle (Interface) ist eine Klasse, die ausschließlich Konstanten und abstrakte Methoden enthält. ◮ Java: Zur Definition einer Schnittstelle wird das Schlüsselwort class durch das Schlüsselwort interface ersetzt. ◮ Java: Alle Methoden einer Schnittstelle sind implizit abstrakt und öffentlich, alle Konstanten final, statisch und öffentlich. Redundante Modifikatoren dürfen angegeben werden. ◮ Java: Ein Interface darf keine Konstruktoren enthalten. 5.3 Abstrakte Klassen 5-27 Generizität ◮ Unter Generizität versteht man die Parametrisierung von Klassen, Datentypen, Modulen, Prozeduren, Funktionen, . . . ◮ Als Parameter werden in der Regel Datentypen (manchmal auch Algorithmen in Form von Prozeduren) verwendet. ◮ Deklariert man beispielsweise eine Liste, so sollte die Liste generisch angelegt werden. Dann kann man später Listen von Zahlen, Zeichen, o. ä. erzeugen. ◮ Auch ein Sortieralgorithmus kann generisch definiert werden. Man setzt nur voraus, dass der Datentyp eine Ordnungsrelation ≤ zur Verfügung stellt. 5.3 Abstrakte Klassen 5-28 Realisierung von abstrakten Datentypen Die folgende Elemente müssen im Programm abgebildet werden: ◮ Name des ADT: wird üblicherweise der Klassenname ◮ Importierte ADTen: werden sowohl zu Definitionen mit dem entsprechenden importierten Typ, als auch zu Import-Anweisungen innerhalb des Programms ◮ Objekterzeugende Operatoren: sogenannte Konstruktoren werden in (meist spezielle) Klassenmethoden abgebildet, die ein neues Objekt des gewünschten Typs zurückliefern ◮ Lesende Operatoren: sogenannte Selektoren werden zu Methoden, die auf die Attribute nur lesend zugreifen ◮ Schreibende Operatoren: sogenannte Manipulatoren werden zu Methoden, die den Zustand des Objekts verändern ◮ Axiome: müssen sichergestellt werden 5.4 Objektorientierte Softwareentwicklung 5-29 Realisierung eines Kellers in Java (Idee) type Stack (T ) import Bool operators empty :→ Stack push : Stack × T → Stack pop : Stack → Stack top : Stack → T empty ? : Stack → Bool axioms ∀s ∈ Stack , ∀x ∈ T pop (push (s , x )) = s top (push (s , x )) = x empty ?(empty ) = true empty ?(push (s , x )) = false 5.4 Objektorientierte Softwareentwicklung Die Implementation kann beispielsweise durch Rückgriff auf die in Java existierenden Listen geschehen. 5-30 Realisierung eines Kellers in Java (Idee) import java.util.*; class Stack<T> { protected List<T> data; public Stack() { ... } public void push(T elem) { ... } public void pop() throws EmptyStackException { ... } public T top() throws EmptyStackException { ... } public boolean isEmpty() { ... } } 5.4 Objektorientierte Softwareentwicklung 5-31 Problembereich Problemlösung Analyse Softwareentwicklung Analysemodelle Design Programmsystem Entwurfsmodelle 5.4 Objektorientierte Softwareentwicklung Implementierung 5-32 Was ist Software-Technik? W. Hesse, H. Keutgen, A. L. Luft, H. D. Rombach: Ein Begriffsystem für die Software-Technik, Informatik-Spektrum, 7, 1984, S. 200–213: Software-Technik (Software-Engineering) ist das Teilgebiet der Informatik, das sich mit der Bereitstellung und systematischen Verwendung von Methoden und Werkzeugen für die Herstellung und Anwendung von Software beschäftigt. 5.4 Objektorientierte Softwareentwicklung 5-33 Software-Technik ◮ setzt solide Kenntnisse in (mindestens) einer Programmiersprache voraus, ◮ ist nicht auf eine spezielle Programmiersprache (sondern evtl. eher auf ein Paradigma) zugeschnitten, ◮ ist ein weites und wichtiges Gebiet der Informatik. 5.4 Objektorientierte Softwareentwicklung 5-34 Teilgebiete der Software-Technik ◮ Software-Entwicklung ◮ Software-Management ◮ Software-Qualitätssicherung 5.4 Objektorientierte Softwareentwicklung 5-35 Software-Entwicklung ◮ Planungsphase ◮ Definitionsphase ◮ Entwurfsphase ◮ Implementierungsphase ◮ Abnahme- und Einführungsphase ◮ Wartungs- und Pflegephase 5.4 Objektorientierte Softwareentwicklung 5-36 Software-Management ◮ Planung ◮ Organisation ◮ Personaleinsatz ◮ Leitung ◮ Kontrolle 5.4 Objektorientierte Softwareentwicklung 5-37 Software-Qualitätssicherung ◮ Qualitätssicherung ◮ Prüfmethoden ◮ Prozessqualität ◮ Produktqualität 5.4 Objektorientierte Softwareentwicklung 5-38 Weitere Aspekte ◮ Werkzeuge, Computer Aided Software Engineering (CASE) ◮ Wiederverwendbarkeit von Software ◮ Modellierung, z. B. Unternehmensmodellierung ◮ Modellierungssprachen, z. B. UML 5.4 Objektorientierte Softwareentwicklung 5-39 Unified Modelling Language ◮ UML (Unified Modelling Language) wurde in den 1990er Jahren mit dem Ziel, eine einheitliche – auch grafische – Notation für die objektorientierte Software-Entwicklung zur Verfügung zustellen, definiert. ◮ Sie enthält ca. ein Dutzend verschiedener Modelltypen zur Beschreibung der verschiedenen Systemaspekte. ◮ Entwickler der UML waren (und sind) Grady Booch, Ivar Jacobson und James Rumbaugh. ◮ UML ist der De-facto-Standard für objektorientierte Analyse und Design. ◮ Object Constraint Language (OCL) zur Formulierung von Bedingungen. ◮ Für UML ist eine Vielzahl an Werkzeugen verfügbar. ◮ UML werden Sie in der Vorlesung „Software Engineering“ und in Praktika gründlich kennen lernen. 5.4 Objektorientierte Softwareentwicklung 5-40 Unified Modelling Language ◮ ◮ Anwendungsfalldiagramme (Benutzersicht) Implementierungsdiagramme (statische Systemstruktur) ◮ ◮ ◮ ◮ Objekt- und Klassendiagramme, Paketdiagramme Komponentendiagramme Verteilungsdiagramme Verhaltensdiagramme (dynamisches Systemverhalten) ◮ ◮ ◮ ◮ Aktivitätsdiagramme Kollaborationsdiagramme Sequenzdiagramme Zustandsdiagramme 5.4 Objektorientierte Softwareentwicklung 5-41 Anwendungsfalldiagramme ◮ modellieren die Einbettung eines Systems in seine Umgebung, ◮ beschreiben die Sicht auf Systemfunktionalität von außen und ◮ werden zur Spezifikation der globalen Systemanforderungen eingesetzt. Kreditkarten-Validerungsytem Fuehre Kartentransaktion durch Kunde Haendler Bearbeite Rechnung Einzelkunde Firmenkunde eV rwalte Kundenkoto Finazstitut 5.4 Objektorientierte Softwareentwicklung 5-42 Klassendiagramme ◮ stellen die statische Systemstruktur dar und ◮ beschreiben die Systemelemente und ihre Beziehungen zueinander. Bitmap Bildschirm zeichnen() +Elemente Pixel x:Integer y:Integer Farbe:Colour 5.4 Objektorientierte Softwareentwicklung Quadrat Dreieck Icon 5-43 Sequenzdiagramme ◮ stellen die Abfolge der Nachrichten dar. ◮ Sie basieren auf „Message Sequence Charts“. Reader Librarian System hand_bok enter_bok_data update [reserved]notify acknowledge_librarian acknowledge_reader 5.4 Objektorientierte Softwareentwicklung 5-44 Object Constraint Language ◮ Object Constraint Language (OCL) ist eine Sprache, in der z. B. Vor- und Nachbedingungen sowie Schleifeninvarianten ausgedrückt werden können. ◮ OCL ist kein Bestandteil von UML, sondern ein Vorschlag für eine Sprache zur Formulierung von Erläuterungen (annotations). Prinzipiell kann hierfür jede Sprache, auch Deutsch oder Englisch, verwendet werden. ◮ OCL basiert auf der Prädikatenlogik. ◮ Einige Typen von OCL: Void, Boolean, Integer, Real, String, Tupel, Set, Ordered Set, Bag, Sequence. 5.4 Objektorientierte Softwareentwicklung 5-45 Objektorientierte Programmiersprachen Algol 60 andere PLs Ada 83 Modula-2 Simula Einfache Vererbung Koroutinen Abstrakte Datentypen Keine Vererbung Eiffel OOPLs Klassenkonzept Vererbung Smalltalk C++ Java sowie verschiedene OO-Derivate anderer Programmiersprachen (Visual-Basic, Delphi) 5.4 Objektorientierte Softwareentwicklung 5-46 Objektorientiertes Programmieren ◮ Identifizieren Sie die Klassen und die Beziehungen der Klassen untereinander. Achten Sie auf Datenkapselung und Wiederverwendbarkeit. ◮ Definieren Sie die Methoden. Denken Sie an die Möglichkeit von abstrakten Methoden und Klassen. ◮ Attribute und Methoden, die unabhängig von Instanzen existieren, sind als Klassenvariable bzw. -methoden zu vereinbaren. In der Regel sollten Sie sich auf wenige Klassenvariable und -methoden beschränken. ◮ Achten Sie auf Programmiersicherheit. Vergeben Sie nicht mehr Zugriffsrechte als erforderlich (Geheimnisprinzip). ◮ Variable, die nicht verändert werden, sollten auch als Konstante im Programm deklariert werden. 5.4 Objektorientierte Softwareentwicklung 5-47 1. Der Algorithmenbegriff 2. Imperative Algorithmen 3. Sortieralgorithmen 4. Listen und abstrakte Datentypen 5. Objektorientierte Algorithmen 6. Bäume 6.1 Bäume 6.2 Binäre Suchbäume 6.3 Ausgeglichene Bäume 6.4 Heapsort 7. Mengen, Verzeichnisse und Hash-Verfahren 8. Graphen 9. Entwurf von Algorithmen 10. Funktionale und deduktive Algorithmen Listen und Bäume Listen und Bäume: ◮ Listen: Jedes Listenelement besitzt genau einen Vorgänger und einen Nachfolger. Ausnahmen: Das erste Element besitzt keinen Vorgänger und das letzte keinen Nachfolger. ◮ Bäume (engl.: trees): Jedes Element besitzt genau einen Vorgänger und mehrere Nachfolger. Ausnahmen: Das erste Element besitzt keinen Vorgänger und die letzten Elemente keine Nachfolger. Anwendungen von Bäumen: 6.1 Bäume ◮ Ausdrücke, ◮ Speicherung von Mengen, ◮ hierarchisch strukturierte Daten, z. B. Dateibäume, ◮ Sortieralgorithmen. 6-1 Grundbegriffe ◮ Die Elemente eines Baums heißen Knoten. ◮ Das erste Element eines Baums ist die Wurzel. ◮ Die letzten Elemente werden Blätter genannt. ◮ Innere Knoten sind Elemente, die keine Blätter sind. 11 5 17 2 7 6 ◮ 6.1 Bäume 13 22 16 Jeder Knoten kann einen Schlüssel und weitere Informationen speichern. 6-2 Grundbegriffe ◮ ◮ ◮ 6.1 Bäume Nachfolgerknoten werden auch Kindknoten und Vorgängerknoten Vaterknoten genannt. Bäume sind wie Listen dynamische Datenstrukturen. Knoten können eingefügt und gelöscht werden. Bäume sind wie Listen rekursive Datenstrukturen. Jeder Knoten kann als die Wurzel eines (Teil-)Baums angesehen werden. 11 5 17 2 7 6 13 22 16 6-3 Pfade 11 ◮ ◮ In jedem Baum gibt es von der Wurzel zu einem beliebigen Knoten genau einen Pfad. ◮ Jeder Baum ist zusammenhängend. ◮ 6.1 Bäume Ein Pfad in einem Baum ist eine Folge aufeinanderfolgender Knoten. Die Anzahl der Knoten eines Pfades minus 1 heißt dessen Länge. Jeder Baum ist zyklenlos. 5 17 2 7 6 13 22 16 [17, 13, 16] ist der Pfad vom Knoten mit Schlüssel 17 zum Knoten mit Schlüssel 16. Die Länge dieses Pfades beträgt 2. 6-4 Niveau und Höhe ◮ ◮ ◮ ◮ 6.1 Bäume Die Länge des Pfades von der Wurzel zu einem Knoten heißt Niveau des Knotens. Knoten, die auf dem gleichen Niveau liegen, heißen Nachbarknoten. Die Höhe eines Baums ist das Maximum der Längen aller Pfade im Baum. Die Höhe eines Knotens ist definiert als das Maximum der Längen aller Pfade von diesem Knoten zu einem Blatt. 11 Niveau 0 5 17 2 7 6 13 Niveau 1 22 16 Niveau 2 Niveau 3 [11, 17, 13, 16] und [11, 5, 7, 6] sind die längsten Pfade. Da ihre Länge 3 beträgt, ist die Höhe des Baumes 3. Die Höhe von Knoten „5“ ist 2. 6-5 Spezielle Bäume Ist k ∈ N die Maximalzahl der Kinder eines Knotens, so spricht man von einem k -nären Baum. Beispiele: ◮ Binärbäume (binäre Bäume) sind 2-näre Bäume. Ternärbäume (ternäre Bäume) sind 3-näre Bäume. ◮ B-Bäume werden später behandelt. Sind die Kinder eines Knotens in einer definierten Weise geordnet, so spricht man von einem geordneten Baum. Beispiele: 6.1 Bäume ◮ Binäre Suchbäume sind geordnete Binärbäume. ◮ Heaps werden später behandelt. 6-6 Binärbäume Binärbäume sind 2-näre Bäume. Die maximale Anzahl von Kindern eines Knotens ist 2. Die Kinder eines Knotens bilden die Wurzeln des linken und des rechten Teilbaums des Knotens. 11 5 17 2 7 6 13 22 16 Höhe des Baumes: 3 Max. Anzahl Knoten: 15 Max. Anzahl Blätter: 8 Max. Anzahl innerer Knoten: 7 6.1 Bäume 6-7 Binärbäume als abstrakter Datentyp type BinTree (T ) import Bool operators empty :→ BinTree bin : BinTree × T × BinTree → BinTree left : BinTree → BinTree right : BinTree → BinTree value : BinTree → T empty ? : BinTree → Bool axioms ∀t ∈ T , ∀x , y ∈ BinTree left (bin(x , t , y )) = x right (bin(x , t , y )) = y value (bin(x , t , y )) = t empty ?(empty ) = true empty ?(bin(x , t , y )) = false 6.1 Bäume 6-8 Eigenschaften binärer Bäume Bezeichnungen: ◮ Höhe des Baums: h ◮ Anzahl der Knoten: n In einem nichtleeren binären Baum gilt: ◮ ◮ Maximalzahl der inneren Knoten: 2h − 1 Maximalzahl der Blätter: 2h ◮ h + 1 ≤ n ≤ 2h +1 − 1 ◮ Folgerung: log2 (n + 1) − 1 ≤ h ≤ n − 1 Das heißt: h liegt zwischen Θ(log(n)) und Θ(n). 6.1 Bäume 6-9 Traversierung eines Binärbaums Unter der Traversierung eines Binärbaums versteht man ein Verfahren, bei dem jeder Knoten eines Baums genau einmal besucht wird. ◮ Beim Tiefendurchlauf (engl.: depth-first-search, DFS) wird zuerst in die „Tiefe“ und erst dann in die „Breite“ gegangen. Man besucht von einem Knoten erst die Kindknoten und setzt dort das Verfahren rekursiv fort. Die Verfahren unterscheiden sich darin, wann der Knoten selbst bearbeitet wird. ◮ ◮ ◮ ◮ 6.1 Bäume Inorder-Durchlauf Preorder-Durchlauf Postorder-Durchlauf Bei der Breitensuche (engl.: breadth-first-search, BFS) geht man von einem Knoten zuerst zu allen Nachbarknoten, bevor die Kindknoten besucht werden. 6-10 Tiefendurchlauf ◮ ◮ ◮ 6.1 Bäume Inorder-Durchlauf: linker Teilbaum, Knoten, rechter Teilbaum 2, 5, 6, 7, 11, 13, 16, 17, 22 Preorder-Durchlauf: Knoten, linker Teilbaum, rechter Teilbaum 11, 5, 2, 7, 6, 17, 13, 16, 22 Postorder-Durchlauf: linker Teilbaum, rechter Teilbaum, Knoten 2, 6, 7, 5, 16, 13, 22, 17, 11 11 1. 2. 5 17 2 7 6 13 22 16 6-11 Inorder-Durchlauf proc inorder(x) begin if x , nil then inorder(left(x)); bearbeite(x); inorder(right(x)); fi; end 6.1 Bäume 6-12 Preorder- und Postorder-Durchlauf proc preorder(x) begin if x , nil then bearbeite(x); preorder(left(x)); preorder(right(x)); fi; end proc postorder(x) begin if x , nil then postorder(left(x)); postorder(right(x)); bearbeite(x); fi; end 6.1 Bäume 6-13 Breitendurchlauf 11 1. ◮ Levelorder-Durchlauf: 11, 5, 17, 2, 7, 13, 22, 6, 16 2. 3. 6.1 Bäume 5 17 2 7 6 13 22 16 6-14 Levelorder-Durchlauf proc levelorder(x) begin var q: queue; enter(q,x); while not isEmpty(q) do y ← front(q); leave(q); bearbeite(y); if left(y) , nil then enter(q,left(y)); if right(y) , nil then enter(q,right(y)); od; end 6.1 Bäume 6-15 Binäre Suchbäume ◮ Der Wert eines Knotens ist ein eindeutiger Schlüssel aus einer Grundmenge. ◮ Die Grundmenge ist durch ≤ bzw. < total geordnet. ◮ Die Knoten enthalten zusätzliche Nutzdaten. ◮ Die Anordnung der Knoten im Baum basiert auf der Ordnungsrelation. 6.2 Binäre Suchbäume Person 5 root 11 5 Person 11 2 7 17 13 22 Person 2 Person 6 Person 17 Person 22 6 Person 7 16 Person 16 Person 13 6-16 Binäre Suchbäume Ein binärer Suchbaum ist ein Binärbaum, bei dem für jeden Knoten v , seinen linken Teilbaum b1 und seinen rechten Teilbaum b2 gilt: Für jeden Knoten v1 aus b1 ist v1 < v und für jeden Knoten v2 aus b2 ist v2 > v . 11 < 11 > 11 5 17 2 7 6 13 22 16 Die Nutzdaten werden im Folgenden nicht betrachtet. 6.2 Binäre Suchbäume 6-17 Basisalgorithmen für binäre Suchbäume ◮ Suchen eines Knotens, ◮ Bestimmen des Minimums oder Maximums, ◮ Bestimmen des Nachfolgers oder Vorgängers eines Knotens, ◮ Einfügen eines Knotens, ◮ Löschen eines Knotens. Die Operationen müssen die Suchbaumeigenschaft aufrechterhalten. 6.2 Binäre Suchbäume 6-18 Suchen eines Knotens (rekursiv) 11 7 < 11 5 17 7>5 2 7 6 13 22 16 func search(x,k) begin if x = nil oder k = schlüssel(x) then return x; fi; if k < schlüssel(x) then return search(links(x),k); else return search(rechts(x),k); fi; end 6.2 Binäre Suchbäume 6-19 Suchen eines Knotens (iterativ) 11 7 < 11 5 17 7>5 2 7 6 13 22 16 func search(x,k) begin while x , nil und k , schlüssel(x) do if k < schlüssel(x) then x ← links(x); else x ← rechts(x); fi; od; return x; end 6.2 Binäre Suchbäume 6-20 Minimum und Maximum 11 5 17 2 7 6 13 22 16 func minimum(x) begin while links(x) , nil do x ← links(x); od; return x; end func maximum(x) begin while rechts(x) , nil do x ← rechts(x); od; return x; end 6.2 Binäre Suchbäume 6-21 Nachfolger und Vorgänger Bestimmung des Nachfolgers (Knoten mit dem nächsthöheren Schlüssel) eines Knotens k : ◮ ◮ Falls k kein rechtes Kind hat, ist der Nachfolger der nächste Vorgänger von k , dessen linkes Kind k oder ein Vorgänger von k ist. Falls k das Maximum im Baum ist, existiert kein derartiger Vorgänger. Falls k ein rechtes Kind hat, ist der Nachfolger das Minimum im vom rechten Kind aufgespannten Teilbaum. 6.2 Binäre Suchbäume Nachfolger von 7: 11 5 17 2 7 6 13 22 16 6-22 Nachfolger und Vorgänger func successor(x) begin if rechts(x) , nil then return minimum(rechts(x)); fi; y ← vater(x); while y , nil und x = rechts(y) do x ← y; y ← vater(y); od; return y; end Die Bestimmung des Vorgängers erfolgt analog. 6.2 Binäre Suchbäume 6-23 Einfügen eines Knotens Einfügen des Schlüssels 12: 11 12 > 11 5 17 12 < 17 2 7 13 22 12 < 13 6 6.2 Binäre Suchbäume 12 16 6-24 Einfügen eines Knotens proc insert(T, z): begin y ← nil; x ← wurzel(T); while x , nil do y ← x; if schlüssel(z) < schlüssel(x) then x ← links(x); else x ← rechts(x); fi; od; vater(z) ← y; if y = nil then wurzel(T) ← z; else if schlüssel(z) < schlüssel(y) then links(y) ← z; else if schlüssel(z) > schlüssel(y) then rechts(y) ← z; else error(”Doppelter Schlüssel”); fi; fi; fi; end 6.2 Binäre Suchbäume 6-25 Löschen eines Knotens Beim Löschen eines Knotens können 3 Fälle auftreten. Der Knoten hat keine Kinder: Er wird einfach gelöscht. Der Knoten hat ein Kind: Er wird ausgeschnitten. 11 11 5 17 2 7 6 6.2 Binäre Suchbäume 13 5 22 16 17 2 7 6 13 22 16 6-26 Löschen eines Knotens Der Knoten hat zwei Kinder: Aus dem rechten Teilbaum wird der Nachfolger bestimmt und dieser dort gelöscht. Dieser Nachfolger hat höchstens ein Kind. Der Nachfolger wird anstelle des zu löschenden Knotens verwendet. Alternativ kann der Vorgänger im linken Teilbaum genommen werden. 11 5 17 2 7 6 6.2 Binäre Suchbäume 13 22 16 6-27 Löschen eines Knotens func delete(T, z): Knoten begin if links(z) = nil oder rechts(z) = nil then y ← z; else y ← successor(z); fi; if links(y) , nil then x ← links(y); else x ← rechts(y); fi; if x , nil then vater(x) ← vater(y); fi; if vater(y) = nil then wurzel(T) ← x; else if y = links(vater(y)) then links(vater(y)) ← x; else rechts(vater(y)) ← x; fi; fi; if y , z then schlüssel(z) ← schlüssel(y); kopiere Nutzdaten; fi; return y; end 6.2 Binäre Suchbäume 6-28 Laufzeiten der Basisalgorithmen Die Analyse der Algorithmen liefert den Satz: Die Laufzeiten der Algorithmen ◮ Suchen eines Knotens, ◮ Minimum, Maximum, ◮ Nachfolger, Vorgänger, ◮ Einfügen eines Knotens und ◮ Löschen eines Knotens liegen bei geeigneter Implementierung in der Zeit O (h ), wobei h die Höhe des binären Suchbaums ist. 6.2 Binäre Suchbäume 6-29 Random-Tree-Analyse Annahmen: 1. Die Schlüssel sind paarweise verschieden. 2. Die Bäume entstehen durch Einfüge-, aber nicht durch Löschoperationen. 3. Jede der n! Permutationen der Eingabe ist gleichwahrscheinlich. Es gilt der Satz: Für die mittlere Knotentiefe P (n) in einem zufällig erzeugten binären Suchbaum gilt P (n) = O (log(n)). Es gilt sogar schärfer der Satz: Die erwartete Höhe eines zufällig erzeugten binären Suchbaums mit n Schlüsseln ist O (log(n)). Beweis: s. Cormen et al., S. 266–269. 6.2 Binäre Suchbäume 6-30 Gestaltsanalyse Satz: Für die Anzahl bn der strukturell verschiedenen Binärbäume gilt: ! falls n = 0, 1 2n 1, = bn = Pn−1 n+1 n falls n > 0. k =0 bk bn−1−k , Annahmen: 1. Die Schlüssel sind paarweise verschieden. 2. Jeder der bn Binärbäume ist gleichwahrscheinlich. Satz: Der mittlere Abstand eines von der Wurzel eines √Knotens Binärbaums mit n Knoten ist O n . Beweis: s. Ottmann/Widmayer, S. 271–275. 6.2 Binäre Suchbäume 6-31 Ausgeglichene Bäume ◮ Höhe des Binärbaums: h ◮ Anzahl der Knoten: n ◮ Es gilt: log2 (n + 1) − 1 ≤ h ≤ n − 1. 1 2 Binäre Suchbäume können zu Listen „entarten“: 3 5 8 13 21 6.3 Ausgeglichene Bäume 6-32 Ausgeglichene Bäume Definition: M sei eine Klasse von Bäumen. Für T ∈ M sei n(T ) die Knotenzahl und h (T ) die Höhe von T . M heißt ausgeglichen (ausgewogen), wenn die beiden folgenden Bedingungen erfüllt sind: 1. Ausgeglichenheitsbedingung: ∃c > 0 ∀T ∈ M : h (T ) ≤ c · log n(T ). 2. Rebalancierungsbedingung: Falls eine Einfüge- oder Löschoperation, ausgeführt in einem Baum T ∈ M , einen unausgeglichenen Baum T ′ < M erzeugt, dann soll es möglich sein, T ′ mit Zeitaufwand O (log n) zu einem Baum T ′′ ∈ M zu rebalancieren. 6.3 Ausgeglichene Bäume 6-33 AVL-Bäume Definition: Ein binärer Suchbaum ist ein AVL-Baum, wenn für jeden Knoten p des Baums gilt, dass sich die Höhe des linken Teilbaums von der Höhe des rechten Teilbaums von p höchstens um 1 unterscheidet. G. M. Adelson-Velskii, E. M. Landis (1962) 6.3 Ausgeglichene Bäume 6-34 AVL-Bäume Ein AVL-Baum: Kein AVL-Baum: 11 11 5 17 2 7 6 6.3 Ausgeglichene Bäume 13 5 17 2 7 6 6-35 AVL-Bäume Satz: Es sei ein beliebiger AVL-Baum der Höhe h mit n Knoten gegeben. Dann gilt h < 1.441 log2 (n + 2) − 0.328. Die Klasse der AVL-Bäume erfüllt daher die Ausgeglichenheitsbedingung. Wir werden gleich sehen, dass sie auch die Rebalancierungsbedingung erfüllt. Damit gilt: Satz: Die Klasse der AVL-Bäume ist ausgeglichen. 6.3 Ausgeglichene Bäume 6-36 Basisalgorithmen für AVL-Bäume Da die Höhe h eines AVL-Baums durch h < 1.441 log2 (n + 2) − 0.328 beschränkt ist, liegen die Laufzeiten der Algorithmen ◮ Suchen eines Knotens, ◮ Minimum, Maximum, ◮ Nachfolger und Vorgänger in O (log n). 6.3 Ausgeglichene Bäume 6-37 Einfügen in AVL-Bäume 1. Wenn der einzufügende Schlüssel noch nicht im Baum vorkommt, endet die Suche in einem Blatt. Der Schlüssel wird dort wie bisher eingefügt. 2. Danach kann die AVL-Eigenschaft eines inneren Knotens k verletzt sein. 3. Wir unterscheiden abhängig von der aufgetretenen Stelle die folgenden Fälle: 1. Einfügen in linken Teilbaum des linken Kindes von k , 2. Einfügen in rechten Teilbaum des linken Kindes von k , 3. Einfügen in rechten Teilbaum des rechten Kindes von k , 4. Einfügen in linken Teilbaum des rechten Kindes von k . Die Fälle 1 und 3 sowie die Fälle 2 und 4 sind symmetrisch. Die Rebalancierung erfolgt durch so genannte Rotationen bzw. Doppelrotationen. 6.3 Ausgeglichene Bäume 6-38 Einfügen in AVL-Bäume Fall 1: Einfügen in den linken Teilbaum des linken Kindes y x -2 x Rotation -1 y c Einfügen a 0 0 a b b c Rotiert wird hier das linke Kind nach rechts. Fall 3 läuft spiegelbildlich ab. 6.3 Ausgeglichene Bäume 6-39 Einfügen in AVL-Bäume Fall 2: Einfügen in den rechten Teilbaum des linken Kindes z x -2 y Doppelrotation +1 x 0 0/-1 z 0/+1 y a d Einfügen b a b c d c Es hat eine Doppelrotation mit dem linken Kind nach links und bzgl. des Vaters nach rechts stattgefunden. Fall 4 läuft spiegelbildlich ab. 6.3 Ausgeglichene Bäume 6-40 Einfügen in AVL-Bäume 3 3 0 2 3 -1 1 2 3 2 -2 RR -1 4 2 1 3 2 1 3 1 5 LR 2 1 3 2 1 1 4 5 6.3 Ausgeglichene Bäume 2 1 4 6 1 4 3 1 2 1 5 2 4 3 1 1 5 6 6-41 Einfügen in AVL-Bäume LR 7 4 2 1 5 3 4 1 2 6 1 LR 1 5 3 4 2 6 2 1 1 6 3 5 7 7 9 4 2 1 8 1 6 3 5 4 1 2 1 7 1 6 3 5 2 7 9 9 -1 8 6.3 Ausgeglichene Bäume 6-42 Einfügen in AVL-Bäume DR r/l RR LR 4 2 1 4 6 3 5 1 2 2 7 1 1 8 1 6 3 5 8 7 9 9 6.3 Ausgeglichene Bäume 6-43 Einfügen in AVL-Bäume 1. Fall Einfügen in linken Teilbaum des linken Kindes: Rechtsrotation 2. Fall Einfügen in rechten Teilbaum des linken Kindes: Doppelrotation, links/rechts 3. Fall Einfügen in rechten Teilbaum des rechten Kindes: Linksrotation 4. Fall Einfügen in linken Teilbaum des rechten Kindes: Doppelrotation, rechts/links 6.3 Ausgeglichene Bäume 6-44 Löschen in AVL-Bäumen 1. Der Schlüssel wird wie bisher gesucht und gelöscht. 2. Danach kann die AVL-Eigenschaft eines Knotens verletzt sein. 3. Durch Rotationen kann die Ausgeglichenheit erreicht werden, allerdings wird dadurch u. U. die AVL-Eigenschaft weiter oben im Baum verletzt. Die Balance muss also ggf. rekursiv bis zur Wurzel wiederhergestellt werden. Da für die Höhe h des Baums h < 1.441 log2 (n + 2) gilt, bleibt die Rebalancierungsbedingung erfüllt. 4 -1 4 Löschen von 5 2 1 6.3 Ausgeglichene Bäume 1 2 Rechtsrotation 5 3 -2 2 1 1 3 4 -1 3 6-45 Rot-Schwarz-Bäume Ein Rot-Schwarz-Baum ist ein binärer Suchbaum, in dem jeder Knoten über ein Zusatzbit zur Speicherung einer Farbe (rot oder schwarz) verfügt. Idee: Durch Einschränkungen bei der Färbung der Knoten auf den Pfaden von der Wurzel zu einem Blatt wird sichergestellt, dass jeder Pfad, der in der Wurzel beginnt, maximal doppelt so lang ist, wie jeder andere solche Pfad. Für nicht vorhandene Kind-Knoten, wird ein spezieller Null-Knoten als Kind eingefügt. 8 4 12 2 6 1 Null 6.3 Ausgeglichene Bäume 3 Null Null 10 5 Null Null 7 Null Null 14 9 Null Null 11 Null Null 13 Null Null 15 Null Null Null 6-46 Rot-Schwarz-Bäume Die Bedingungen an einen Rot-Schwarz-Baum lauten: 1. Jeder Knoten ist entweder rot oder schwarz. 2. Die Wurzel ist schwarz. 3. Jedes Blatt (Null-Knoten) ist schwarz. 4. Wenn ein Knoten rot ist, so sind beide Kinder schwarz. 5. Für jeden Knoten p gilt, dass alle Pfade vom Knoten zu einem Blatt die selbe Anzahl schwarzer Knoten beinhalten. 8 4 12 2 6 1 Null 6.3 Ausgeglichene Bäume 3 Null Null 10 5 Null Null 7 Null Null 14 9 Null Null 11 Null Null 13 Null Null 15 Null Null Null 6-47 Rot-Schwarz-Bäume Die gemäß Bedingung 5 eindeutig bestimmte Zahl wird Schwarzhöhe bh (p ) eines Knotens p genannt. Hierbei wird p selbst nicht mitgezählt. bh(8) = 3 8 bh(12) = 2 4 12 2 6 10 14 bh(11) = 1 1 Null 6.3 Ausgeglichene Bäume 3 Null Null 5 Null Null 7 Null Null 9 Null Null 11 Null Null 13 Null Null 15 Null Null Null 6-48 Rot-Schwarz-Bäume Satz: Es sei ein beliebiger Rot-Schwarz-Baum der Höhe h mit n Knoten gegeben. Dann gilt h < 2 log2 (n + 1). Die Klasse der Rot-Schwarz-Bäume erfüllt daher die Ausgeglichenheitsbedingung. Wir werden gleich sehen, dass sie auch die Rebalancierungsbedingung erfüllt. Damit gilt: Satz: Die Klasse der Rot-Schwarz-Bäume ist ausgeglichen. 6.3 Ausgeglichene Bäume 6-49 Basisalgorithmen für Rot-Schwarz-Bäume Da die Höhe h eines Rot-Schwarz-Baums durch h < 2 log2 (n + 1) beschränkt ist, liegen die Laufzeiten der Algorithmen ◮ Suchen eines Knotens, ◮ Minimum, Maximum, ◮ Nachfolger und Vorgänger in O (log n). 6.3 Ausgeglichene Bäume 6-50 Einfügen in Rot-Schwarz-Bäume 1. Einfügen eines Schlüssels mit bisherigem Algorithmus. 2. Der neue Knoten wird rot und erhält zwei Null-Knoten als Kinder. 3. Danach kann die Rot-Schwarz-Eigenschaft verletzt sein: 3.1 Jeder Knoten ist entweder rot oder schwarz. Wird nicht verletzt. 3.2 Die Wurzel ist schwarz. Wird verletzt, falls in den leeren Baum eingefügt wird. Der neue Knoten wird dann schwarz gefärbt. 3.3 Jedes Blatt ist schwarz. Wird nicht verletzt. 3.4 Wenn ein Knoten rot ist, so sind beide Kinder schwarz. Nicht durch k verletzt, da beide Kinder schwarze Null-Knoten sind. Die Eigenschaft wird verletzt, falls k als Kind eines roten Vaterknotens eingefügt wird. 3.5 Für jeden Knoten gilt, dass alle Pfade vom Knoten zu einem Blatt die selbe Anzahl schwarzer Knoten beinhalten. Da nur ein roter Knoten hinzukommt, wird diese Eigenschaft nicht verletzt. 6.3 Ausgeglichene Bäume 6-51 Einfügen in Rot-Schwarz-Bäume Maßnahmen, die die Rot-Schwarz-Eigenschaft 4 wiederherstellen: ◮ Links-, Rechts- und Doppel-Rotationen zwecks Höhenausgleich. Dabei gibt die Einfärbung der Knoten Aufschluss über die notwendigen Rotationen. ◮ Korrektur der Einfärbung der falsch eingefärbten Knoten. Die Einfärbung wird in Richtung der Wurzel korrigiert, wodurch ein Dienst zum Zugriff auf den Vaterknoten eines Knotens nötig wird. Dieser sei im Folgenden mit parent (k ) beschrieben. Sechs Fälle sind zu unterscheiden: 1. parent (k ) ist linkes Kind von parent (parent (k )) 1.1 Der Onkel von k ist rot. 1.2 Der Onkel von k ist schwarz und k ist rechtes Kind. 1.3 Der Onkel von k ist schwarz und k ist linkes Kind. 2. parent (k ) ist rechtes Kind von parent (parent (k )): analog. 6.3 Ausgeglichene Bäume 6-52 Einfügen in Rot-Schwarz-Bäume Fall 1.1: Der Onkel von k ist rot: y y Umfärben w z x k z x a d b w c e k a d b e c Knoten y kann ebenfalls wieder Kind eines roten Knotens sein (erneute Verletzung der Eigenschaft 4). In diesem Fall wird für y rekursiv die Eigenschaft 4 wiederhergestellt. Da die Wurzel schwarz ist, terminiert das Verfahren. Der Fall, dass k linkes Kind von parent (k ) ist, wird analog behandelt. 6.3 Ausgeglichene Bäume 6-53 Einfügen in Rot-Schwarz-Bäume Fall 1.2: Der Onkel von k ist schwarz und k ist rechtes Kind: y y Linksrotation w z x k z w a d b x c e c a d e b Durch Linksrotation entsteht Fall 1.3. 6.3 Ausgeglichene Bäume 6-54 Einfügen in Rot-Schwarz-Bäume Fall 1.3: Der Onkel von k ist schwarz und k ist linkes Kind: y x Rechsrotation Umfärben x w z k z c a y w b d e a b c d e Es wird eine Rechtsrotation und eine Umfärbung durchgeführt. 6.3 Ausgeglichene Bäume 6-55 B-Bäume ◮ Der Zugriff auf den Primärspeicher (RAM, Hauptspeicher) ist bezüglich der Zugriffszeit „billig“, wohingegen der Zugriff auf den Sekundärspeicher (Festplatte) „teuer“ ist, vor allem dann, wenn das Auslesen der Daten eine Änderung der Position des Lesekopfes nötig macht oder der Beginn des einzulesenden Bereiches abgewartet werden muss. ◮ Daher ist es sinnvoll, zusammenhängende Daten, auf die komplett zugegriffen wird, möglichst beieinander liegend zu speichern. ◮ Diese Idee kann man auf Suchbäume übertragen. 6.3 Ausgeglichene Bäume 6-56 B-Bäume B-Bäume sind ausgeglichene geordnete k -näre Suchbäume, deren Knoten ◮ ◮ maximal k − 1 Schlüssel tragen können und auf maximal k Kindknoten verweisen. B-Bäume sind nicht binär, das B steht für „balanced“. 10 13 6.3 Ausgeglichene Bäume 14 17 20 30 23 24 27 6-57 B-Bäume Ein 2t -närer Baum T heißt B-Baum der Ordnung t , t ≥ 2, wenn er die folgenden Eigenschaften erfüllt: 1. Jeder Knoten x besitzt die folgenden Felder bzw. Funktionen: ◮ n (x ) ist die Anzahl der in Knoten x gespeicherten Schlüssel ◮ die n (x ) Schlüssel sind in aufsteigender Weise geordnet: key1 (x ) ≤ key2 (x ) ≤ · · · ≤ keyn(x ) (x ) ◮ leaf (x ) ist eine boolesche Funktion, die angibt, ob x ein Blatt ist. 2. Jeder innere Knoten x trägt n(x ) + 1 Zeiger c1 (x ), c2 (x ), . . . , cn(x )+1 (x ) auf seine Kindknoten. 3. Die Schlüssel keyi (x ) unterteilen die in den Teilbäumen gespeicherten Werte. Sei treeKeys (y ) die Menge aller in einem B-Baum mit Wurzel y gespeicherten Schlüssel, so gilt für 1 ≤ i ≤ n(x ): ∀vi ∈ treeKeys (ci (x )), vi +1 ∈ treeKeys (ci +1 (x )) : vi ≤ keyi (x ) ≤ vi +1 . 6.3 Ausgeglichene Bäume 6-58 B-Bäume 4. Jedes Blatt hat das gleiche Niveau. Es entspricht der Höhe h des Baums. 5. Die Ordnung t ≥ 2 legt die obere und die untere Grenze der Anzahl der Schlüssel und der Kindknoten eines Knotens fest: ◮ Jeder Knoten mit Ausnahme der Wurzel hat wenigstens t − 1 ◮ ◮ ◮ Schlüssel. Jeder innere Knoten mit Ausnahme der Wurzel hat wenigstens t Kindknoten. Ist der Baum nicht leer, so trägt die Wurzel mindestens einen Schlüssel. Jeder Knoten trägt maximal 2t − 1 Schlüssel. Damit hat jeder innere Knoten höchstens 2t Kinder. Ein Knoten heißt voll, wenn er genau 2t − 1 Schlüssel trägt. Es gilt: t − 1 ≤ n(x ) ≤ 2t − 1 für alle Knoten mit Ausnahme der Wurzel und t ≤ #Kinder von x ≤ 2t für alle inneren Knoten mit Ausnahme der Wurzel. 6.3 Ausgeglichene Bäume 6-59 B-Bäume Beispiel: B-Baum der Ordnung 3 Wurzel 50 10 20 30 40 k 45 55 59 70 ... 3 ◮ ◮ ◮ ◮ 7 8 9 Blatt 13 14 17 Blatt 75 ... 65 66 Blatt Für alle Knoten x mit Ausnahme der Wurzel gilt: 3 − 1 = 2 ≤ n(x ) ≤ 2 · 3 − 1 = 5. Für alle inneren Knoten mit Ausnahme der Wurzel gilt: 3 ≤ # Kinder von x ≤ 2 · 3 = 6 Der Knoten k ist voll, sein rechter Bruder nicht. Es gibt insgesamt 11 Blätter, die alle das Niveau 2 haben. 6.3 Ausgeglichene Bäume 6-60 B-Bäume ◮ Alle Pfade von der Wurzel bis zu den Blättern sind in einem B-Baum gleich lang. ◮ Typischerweise werden B-Bäume hoher Ordnung benutzt. Die Knoten enthalten dadurch sehr viele Werte, die Höhe des Baumes ist aber gering. ◮ B-Bäume werden im Zusammenhang mit Datenbanken zum Beispiel für Indizierungen verwendet. ◮ Die Anzahl der Knoten eines Niveaus nimmt bei einem vollständigen B-Baum der Ordnung t exponentiell zur Basis 2t zu: Jeder Knoten hat 2t Kinder, auf Niveau n befinden sich (2t )n Knoten. ◮ Die Ordnung t eines B-Baumes wird auch als minimaler Grad bezeichnet. 6.3 Ausgeglichene Bäume 6-61 B-Bäume Wie für die binären Suchbäume ist die Laufzeit (und damit die Anzahl der Festplattenzugriffe) für die meisten B-Baum-Operationen abhängig von der Höhe des Baums. Satz: Ist n ≥ 1 die Anzahl der Schlüssel eines B-Baumes der Höhe h der Ordnung t , so gilt h ≤ logt 6.3 Ausgeglichene Bäume ! n+1 . 2 6-62 Suchen in B-Bäumen Suche in B-Bäumen kombiniert die Suche in binären Suchbäumen mit der Suche in Listen. Jeder Knoten muss dazu durch einen Festplattenzugriff erst in den Speicher geladen werden, bevor er bearbeitet werden kann. Um einen Schlüssel k zu suchen: 1. Lies den Wurzelknoten x ein. 2. Vergleiche in x beginnend mit i = 1 jeden Schlüssel keyi (k ) mit k bis ein Wert keyi (x ) ≥ k oder i = n(x ) ist. 2.1 Ist k = keyi (x ), dann liefere Knoten x und Index i zurück und beende die Suche. 2.2 Ist k , keyi (x ) und x ein Blatt, so ist der Schlüssel nicht enthalten, und die Suche ist fehlgeschlagen. 2.3 Ist keyi (x ) > k bzw. keyi (x ) < k und i = n(x ), so lies Knoten ci (x ) bzw. ci +1 (x ) ein und fahre mit Schritt 2 fort. 6.3 Ausgeglichene Bäume 6-63 Suchen in B-Bäumen Suchen des Buchstabens Q in B-Bäumen der Ordnung 3: Q>N N C A B D E F K H S Q<S L M O Q R W T V X Y Z X Y Z Q=Q Q>O Q>N N C A B D E F K H Suche erfolgreich S Q<S L M O P R W T V Q<R Q>O Q>P 6.3 Ausgeglichene Bäume Suche fehlgeschlagen 6-64 Suchen in B-Bäumen 1. Die Suche innerhalb eines Knotens erfolgt linear und ist beendet, wenn ein Schlüssel größer oder gleich dem gesuchten Schlüssel ist oder alle n(x ) Werte des Knotens betrachtet worden sind. In einem B-Baum der Ordnung t ist n(x ) ≤ 2t . Daher liegt die Laufzeit dieser lokalen Suche in O (t ). 2. Wird der Schlüssel in einem inneren Knoten nicht gefunden, so wird der nächste Knoten in Richtung der Blätter bearbeitet. Die Anzahl der besuchten Knoten (die Anzahl der Festplattenzugriffe) ist damit abhängig von der Höhe h des Baumes. Sie liegt nach obigem Satz in Θ(h ) = Θ(logt n). 3. Die Laufzeit des gesamten Algorithmus liegt also in O (t · h ) = O (t logt n). 6.3 Ausgeglichene Bäume 6-65 Einfügen in B-Bäume Das Einfügen eines Wertes in einen B-Baum der Ordnung t ist komplizierter als bei binären Suchbäumen: ◮ Suche analog zum binären Suchbaum zuerst ein Blatt, in dem der Wert gespeichert werden kann. Sollte das Blatt vor dem Einfügen bereits voll gewesen sein, so verstößt der Baum danach gegen die B-Baum-Definition. ◮ Der übervolle Knoten wird in zwei Knoten am Median des ursprünglichen Knotens aufgeteilt. ◮ Beispiel: B-Baum der Ordnung 3 S Einfügen von W C 6.3 Ausgeglichene Bäume K S X Y C K W X Y 6-66 Einfügen in B-Bäume ◮ Der neue Vaterknoten muss in den ursprünglichen Vaterknoten integriert werden, wodurch wiederum die B-Baum-Eigenschaft verletzt sein kann. ◮ In Richtung Wurzel ist demnach solange jeder entstehende übervolle Knoten aufzuteilen, bis spätestens ein neuer Vater die neue Wurzel des Baumes bildet. ◮ Das beschriebene Verfahren durchläuft den Baum ggf. zweimal: Erst wird der Baum in Richtung eines Blattes durchsucht, der Knoten eingefügt und dann in Richtung der Wurzel korrigiert. ◮ Ein effizienteres Verfahren, dass den Baum nur einmal durchläuft, teilt auf dem Suchpfad in Richtung des Zielblattes vorsorglich jeden vollen Knoten auf und fügt zum Schluss den Wert in einen Knoten ein, der noch nicht voll ist (One-Pass-Verfahren). 6.3 Ausgeglichene Bäume 6-67 Einfügen in B-Bäume Beispiel: B-Baum der Ordnung 3 L C F K Q H S F K L W C F Q M H S L Q V K C K T S R C F H V W Q S T V N K T L C F H L S M N Q T V W R T V W P K C 6.3 Ausgeglichene Bäume F H L M N S P Q R 6-68 Einfügen in B-Bäume A B X Y K A B C F H L M N S Q P R T V W X Y D C A B D F H L K N M S P Q S W P Q R T V W X Y Z C A B 6.3 Ausgeglichene Bäume D F H L K M N R T V X Y Z 6-69 Einfügen in B-Bäume E C A B D E F H L K N M S W P Q ohne One-Pass-Verfahren R T V X Y Z E N A B D 6.3 Ausgeglichene Bäume C K E F mit One-Pass-Verfahren S H L M P Q R W T V X Y Z 6-70 Löschen in B-Bäumen Um einen Schlüssel in einem B-Baum der Ordnung t zu löschen: Suche den Knoten x , in dem der Schlüssel k = keyi (x ) gelöscht werden soll. Wird der Schlüssel aus dem betreffenden Knoten x entfernt, so können mehrere Fälle auftreten, von denen wir uns nur drei beispielhaft ansehen: 1. x ist ein Blatt und trägt mehr als t − 1 Schlüssel. Dann kann der Schlüssel einfach gelöscht werden. 6.3 Ausgeglichene Bäume 6-71 Löschen in B-Bäumen 2. x ist ein Blatt, trägt die minimale Anzahl von t − 1 Schlüsseln und ein Bruder b von x trägt mindestens t Schlüssel. Dann findet vor dem Löschen eine Rotation des kleinsten bzw. größten Schlüssels von b mit dem Schlüssel des Vaterknotens statt, dessen Teilbäume b und x sind. K N S K P S Löschen von M F H 6.3 Ausgeglichene Bäume L M P Q R T V F H L N Q R T V 6-72 Löschen in B-Bäumen 3. x ist ein Blatt und trägt die minimale Anzahl von t − 1 Schlüsseln. Gleiches gilt für beide Brüder. Dann findet eine Verschmelzung von x und einem Bruder b statt, wobei der Schlüssel des Vaterknotens, dessen Teilbäume b und x sind, in der Mitte des neuen Knoten zwischen den Werten von x und b gespeichert wird. Dabei kann der Vaterknoten die kritische Größe t − 1 Schlüssel unterschreiten. Ggf. muss also rekursiv nach oben verschmolzen werden K N S N S Löschen von H F H 6.3 Ausgeglichene Bäume L M P Q R T V F K L M P Q R T V 6-73 Löschen in B-Bäumen ◮ Die anderen Fälle werden ähnlich behandelt. Es erfolgt vor dem Löschen evtl. ein Zusammenfügen oder ein Aufspalten einzelner Knoten. Dabei muss ggf. rekursiv vorgegangen werden. ◮ Man kann zeigen, dass die Laufzeit der beiden Algorithmen zum Einfügen und Löschen ebenfalls in O (t logt n) liegt. ◮ Einzelheiten kann und sollte man in der Literatur (zum Beispiel Cormen et al., S. 439–457) nachlesen. 6.3 Ausgeglichene Bäume 6-74 Überblick 6.4 Heapsort ◮ Heapsort ist ein Sortieralgorithmus, der ein gegebenes Feld in-place sortiert. ◮ Heapsort verwendet eine auf Binärbäumen basierende Datenstruktur, den binären Heap. ◮ Das zu sortierende Feld wird dabei als ausgeglichener binärer Baum aufgefasst, der bis auf die Ebene der Blätter vollständig gefüllt ist. ◮ Die Ebene der Blätter ist von links bis zu einem Endpunkt gefüllt. ◮ In dem Binärbaum gilt nicht die Suchbaumeigenschaft, sondern die Heap-Eigenschaft. 6-75 Heaps Ein binärer Max-Heap ist ein Binärbaum, bei dem der Schlüssel eines Vaterknotens größer oder gleich den Schlüsseln seiner Kindknoten ist. Die Schlüssel der Kinder stehen untereinander in keiner Beziehung. Beim Heapsort wird das zu sortierende Feld a mit der Indexmenge 1 . . . n im Bereich 1 . . . h , h ≤ n, (Heap-Size) als binärer Heap interpretiert: ◮ a [1] ist die Wurzel des Baumes. ◮ left (k ) = 2k ist der Index des linken Kindes des Knotens k . ◮ right (k ) = 2k + 1 ist der Index des rechten Kindes des Knotens k . ◮ parent (k ) = ⌊ k2 ⌋ ist der Index des Vaters des Knotens k . ◮ ◮ 6.4 Heapsort a .size = n ist die Gesamtlänge des Felds. a .heapSize = h ist die Länge des Heaps. 6-76 Heaps In einem Max-Heap gilt die Max-Heap-Eigenschaft: ∀k ∈ {2, . . . , h }.a [parent (k )] ≥ a [k ] D. h., die Schlüssel der Kinder eines Knotens sind kleiner oder gleich dem Schlüssel des Vaterknotens. n=9 9 h=6 9 6 8 1 5 7 2 0 3 6.4 Heapsort 6 1 4 1 2 5 8 5 7 3 6 6-77 Heapsort Der Algorithmus benötigt 3 Methoden: 6.4 Heapsort ◮ Max-Heapify: stellt die Max-Heap-Eigenschaft für einen Teilbaum sicher. ◮ Build-Max-Heap: konstruiert ausgehend von einem unsortierten Feld einen Max-Heap. ◮ Heapsort: sortiert ein ungeordnetes Feld in-place. 6-78 Max-Heapify 6.4 Heapsort ◮ Max-Heapify bekommt eine Referenz auf das Feld a übergeben, in dem ggf. die Heap-Eigenschaft an nur einer Stelle verletzt ist. ◮ Außerdem einen Index k , der denjenigen Knoten in a angibt, der die Heap-Eigenschaft verletzt. Die Teilbäume unterhalb von k verletzen die Heap-Eigenschaft nach Voraussetzung nicht. ◮ Max-Heapify stellt die Heap-Eigenschaft her. 6-79 Max-Heapify 17 1 6 11 4 17 1 10 3 2 4 5 1 6 11 2 7 7 6 5 8 8 9 2 10 3 11 10 3 4 5 4 1 6 7 7 5 8 8 9 2 10 3 11 17 1 11 2 8 4 10 3 4 5 1 6 7 7 5 8 6 9 2 10 3 11 6.4 Heapsort 6-80 Max-Heapify proc Max-Heapify(a: <Referenz auf T[]>; k: int) begin var max: int; max ← k; if left(k) ≤ a.heapSize && a[left(k)] > a[max] then max ← left(k); fi; if right(k) ≤ a.heapSize && a[right(k)] > a[max] then max ← right(k); fi; if k , max then swap(a[k], a[max]); Max-Heapify(a, max); fi; end 6.4 Heapsort 6-81 Max-Heapify 6.4 Heapsort ◮ Alle Operationen in Max-Heapify bis auf den rekursiven Aufruf sind in O (1) implementierbar. ◮ Falls ein rekursiver Aufruf erfolgt, dann nur auf Knoten, die unterhalb von k liegen. ◮ Die Aufruffolge ist daher durch die Höhe des von k aufgespannten Teilbaums nach oben begrenzt. ◮ Da auch der Teilbaum vollständig ist, liegt die Laufzeit von Max-Heapify innerhalb von O (log n). 6-82 Build-Max-Heap 6.4 Heapsort ◮ Build-Max-Heap erhält als Eingabe eine Referenz auf ein unsortiertes Feld und stellt sicher, dass im gesamten Feld die Max-Heap-Eigenschaft gilt. ◮ Dies geschieht, indem Max-Heapify auf alle Knoten angewendet wird. ◮ Da Max-Heapify erwartet, dass die Teilbäume unterhalb eines Knotens die Heap-Eigenschaft erfüllen, muss vom Ende des Felds begonnen werden. ◮ Alle inneren Knoten des Heaps liegen im Bereich ⌊ n2 ⌋ . . . 1. Auf diese Knoten muss also Max-Heapify angewendet werden. 6-83 Build-Max-Heap proc Build-Max-Heap(a: <Referenz auf T[]>) begin var i: int; a.heapSize ← a.length; for i ← ⌊a.length / 2⌋ downto 1 do Max-Heapify(a, i); od; end 6.4 Heapsort 6-84 Build-Max-Heap 12 1 12 1 1. Iteration 6 2 11 4 5 8 1 3 3 5 8 9 17 6 6 2 10 7 11 4 2 10 4 11 5 8 1 3 4 5 8 9 10 7 2 10 3 11 12 1 12 1 2. Iteration 3. Iteration 6 2 11 4 5 8 6.4 Heapsort 17 6 8 9 1 3 4 5 2 10 3 11 17 6 6 2 10 7 11 4 5 8 8 9 17 3 4 5 1 6 10 7 2 10 3 11 6-85 Build-Max-Heap 12 1 12 1 4. Iteration 11 2 6 4 5 8 17 3 4 5 8 9 1 6 11 2 10 7 2 10 3 11 8 4 5 8 6 9 17 3 4 5 1 6 10 7 2 10 3 11 17 1 5. Iteration 11 2 8 4 5 8 6.4 Heapsort 6 9 12 6 1 11 3 17 10 5 8 2 4 12 3 4 5 2 10 3 11 1 6 10 7 17 11 12 8 4 1 10 5 6 2 3 6-86 Build-Max-Heap Der Aufruf von Max-Heapify liegt jeweils in O (log n), Max-Heapify wird O (n)-mal aufgerufen, daher ergibt sich eine Laufzeit innerhalb von O (n log n). Allerdings ist eine genauere Abschätzung möglich: Es gilt, dass ein Heap mit n Elementen die Höhe ⌊ld (n)⌋ hat und dass höchstens ⌈ 2hn+1 ⌉ Knoten die Höhe h haben. ∞ X h h =0 ⌊ld (h )⌋ X h =0 n 2h +1 2h =2 ⌊ld (h )⌋ ∞ X h X h = O (n) = O n O (h ) = O n 2h 2h h =0 h =0 Build-Max-Heap hat also eine lineare Laufzeit. 6.4 Heapsort 6-87 Heapsort Eingabe für Heapsort ist ein Referenzparameter auf ein unsortiertes Feld a , Ausgabe ist das sortierte Feld a . Der Algorithmus arbeitet folgendermaßen: 1. Konstruiere einen Max-Heap. 2. Vertausche das erste Element (Wurzel, größtes Element) und das letzte Element (Blatt ganz rechts) des Heaps. 3. Reduziere den Max-Heap um das Blatt ganz rechts. 4. Wende Max-Heapify auf die Wurzel an. 5. Solange Blätter vorhanden und nicht gleich der Wurzel sind, fahre mit Schritt 2 fort. 6.4 Heapsort 6-88 Heapsort proc Heapsort(a: <Referenz auf T[]>) begin var i: int; Build-Max-Heap(a); for i ← a.length downto 2 do swap(a[1], a[i]); a.heapSize ← a.heapSize - 1; Max-Heapify(a, 1); od; end Die Laufzeit von Heapsort liegt in O (n log n). 6.4 Heapsort 6-89 Heapsort 12 1 Heap 6 2 12 6 1 11 3 17 10 5 8 2 4 11 4 5 8 1 3 3 5 8 9 10 7 2 10 4 11 17 1 3 1 swap MaxHeapify 11 2 8 4 5 8 6.4 Heapsort 17 6 6 9 12 3 4 5 2 10 3 11 1 6 11 2 10 7 8 4 5 8 6 9 12 3 4 5 1 6 10 7 2 10 17 11 6-90 Heapsort 12 1 2 1 swap MaxHeapify 11 2 8 4 5 8 10 3 4 5 6 9 1 6 11 2 3 7 2 10 17 11 8 4 5 8 10 3 4 5 3 7 6 9 12 10 17 11 11 1 10 1 MaxHeapify swap, MaxHeapify 8 2 6 4 5 8 6.4 Heapsort 1 6 10 3 4 5 2 9 12 10 17 11 1 6 8 2 3 7 6 4 3 3 4 5 1 6 2 7 5 8 11 9 12 10 17 11 6-91 Heapsort 8 1 6 1 MaxHeapify swap,MaxHeapify 6 2 5 4 3 3 4 5 1 6 5 2 2 7 2 4 10 8 11 9 12 10 17 11 3 3 4 5 1 6 8 7 10 8 11 9 12 10 17 11 1 1 Array ··· 2 2 4 4 1 2 3 4 5 6 8 10 11 12 17 3 3 5 5 6 6 8 7 10 8 11 9 12 10 17 11 6.4 Heapsort 6-92 Prioritätswarteschlangen Heaps können nicht nur zum Sortieren benutzt werden, sondern auch für weitere Anwendungen. Eine wollen wir jetzt betrachten. Eine Max-Prioritätswarteschlange ist eine Datenstruktur zur Speicherung einer Menge S , deren Elementen ein Schlüssel (Priorität) zugeordnet ist. Prioritätswarteschlangen besitzen die folgenden Operationen: ◮ maximum(S) gibt das Element von S mit dem maximalen Schlüssel zurück. ◮ extract-max(S) entfernt aus S das maximale Element. ◮ increase-key(S,x,k) erhöht den Schlüssel von x auf k . ◮ insert(S,x) fügt x in S ein. Max-Prioritätswarteschlangen können effizient durch Max-Heaps implementiert werden. 6.4 Heapsort 6-93 Prioritätswarteschlangen proc maximum(S) begin return S[1]; end proc extract-max(S) begin if heap-groesse(S) < 1 then error fi; max ← S[1]; S[1] ← S[heap-groesse(S)]; heap-groesse(S) ← heap-groesse(S)-1; Max-Heapify(S,1); return max; end 6.4 Heapsort 6-94 Prioritätswarteschlangen proc increase-key(S,x,k) begin if k < S[x] then error fi; S[x] ← k; while x > 1 ∧ S[vater(x)] < S[x] do swap (S[x],S[vater(x)]); x ← vater(x); od; end proc insert(S,x) begin heap-groesse(S) ← heap-groesse(S)+1; S[heap-groesse(S)] ← −∞; increase-key(S,heap-groesse(S),k); end 6.4 Heapsort 6-95 Prioritätswarteschlangen 6.4 Heapsort ◮ Die Laufzeiten der vier Operationen für Prioritätswarteschlangen der Größe n liegen in O (log n). ◮ Analog zu Max-Heaps und Max-Prioritätswarteschlangen lassen sich Min-Heaps und Min-Prioritätswarteschlangen definieren. 6-96 1. Der Algorithmenbegriff 2. Imperative Algorithmen 3. Sortieralgorithmen 4. Listen und abstrakte Datentypen 5. Objektorientierte Algorithmen 6. Bäume 7. Mengen, Verzeichnisse und Hash-Verfahren 7.1 Mengen 7.2 Verzeichnisse 7.3 Hashverfahren 8. Graphen 9. Entwurf von Algorithmen 10. Funktionale und deduktive Algorithmen Mengen Unter einer Menge verstehen wir jede Zusammenfassung M von bestimmten, wohlunterschiedenen Objekten m unserer Anschauung oder unseres Denkens (welche die Elemente von M genannt werden) zu einem Ganzen. Es kommt es nicht darauf an, wie oft und in welcher Reihenfolge die Elemente einer Menge aufgeführt werden. Es gilt {2, 4, 6, 8} = {6, 8, 2, 4, 4, 6, 8} = {8, 6, 2, 4}. Für Mengen sind unter anderem die folgenden Operationen definiert: ◮ ◮ ◮ ◮ 7.1 Mengen Elementbeziehung m ∈ M Vereinigungsbildung M ∪ N Durchschnittsbildung M ∩ N Differenzmengenbildung M \N 7-1 ADT Menge type : Set (T ) import : Bool operators : ∅ :→ Set empty ? : Set → Bool is _in : Set × T → Bool insert : Set × T → Set delete : Set × T → Set union : Set × Set → Set intersection : Set × Set → Set difference : Set × Set → Set ··· axioms ∀s ∈ Set , ∀x , y ∈ T ··· 7.1 Mengen 7-2 Geordnete Mengen 7.1 Mengen ◮ Die Elemente einer Menge können durch eine Ordnungsrelation angeordnet sein. ◮ Eine Ordnungsrelation ist eine reflexive, transitive und antisymmetrische Relation. ◮ Wenn je zwei Elemente vergleichbar sind, heißt die Ordnungsrelation linear oder total. 7-3 ADT Geordnete Menge type : OrderedSet (T ) import : Bool operators : ∅ :→ OrderedSet empty ? : OrderedSet → Bool is _in : OrderedSet × T → Bool insert : OrderedSet × T → OrderedSet delete : OrderedSet × T → OrderedSet union : OrderedSet × OrderedSet → OrderedSet intersection : OrderedSet × OrderedSet → OrderedSet difference : OrderedSet × OrderedSet → OrderedSet min : OrderedSet → T max : OrderedSet → T ··· axioms ∀s ∈ OrderedSet , ∀x , y ∈ T ··· 7.1 Mengen 7-4 Mengenimplementierungen Mengen können auf verschiedene Weisen realisiert werden. Einige davon besprechen wir in diesem Kapitel. Wir befassen uns nur mit endlichen Mengen. Ungeordnete Mengen: 1. Bitfelder 2. Listen 3. Felder Geordnete Mengen: 1. Listen 2. Suchbäume 7.1 Mengen 7-5 Kopieren von Objekten 7.1 Mengen ◮ Häufig besteht der Bedarf, ein Objekt zu kopieren, d. h. ein zweites Objekt zur Verfügung zu haben, das dem ersten vollständig gleicht. ◮ In objektorientierten Programmiersprachen ist eine Anweisung der Form a ← b hierfür oft nicht geeignet. ◮ Es wird der Variablen a lediglich eine Referenz auf das ursprüngliche Mengenobjekt zugewiesen. Manipulationen des durch a referenzierten Objekts verändern so zugleich das durch b bestimmte Objekt. ◮ Man unterscheidet zwischen so genannten flachen und tiefen Kopien. ◮ Programmtechnische Details sind nicht Gegenstand dieser Vorlesung. 7-6 Implementierung durch Bitfelder Dieses Verfahren ist geeignet, Mengen A zu realisieren, die Teilmenge einer kleinen endlichen Grundmenge G = {g1 , . . . , gn } sind. ◮ ◮ Es wird ein Bitfeld A mit |G | = n Bits vereinbart. Es gilt gi ∈ A ⇔ A [i ] = 1. Wir vereinbaren: 0 , false, 1 , true. g1 1 g1 ∈ A g2 1 g2 ∈ A g3 0 g3 6∈ A ··· gn 7.1 Mengen 0 gn 6∈ A 7-7 Implementierung durch Bitfelder proc empty() begin var A: BitSet; var i: int; for i ← 1 to n do A[i] ← false; od; return A; end proc isIn(A: BitSet, i: int): bool begin return A[i]; end 7.1 Mengen 7-8 Implementierung durch Bitfelder func intersection(B, C: BitSet): Bitset begin var A: BitSet; var i: int; for i ← 1 to n do A[i] ← B[i] && C[i]; od; return A; end func union(B, C: BitSet): Bitset begin var A: BitSet; var i: int; for i ← 1 to n do A[i] ← B[i] || C[i]; od; return A; end 7.1 Mengen 7-9 Implementierung durch Bitfelder func difference(B, C: BitSet): BitSet begin var A: BitSet; var i: int; for i ← 1 to n do A[i] ← B[i] && ! C[i]; od; return A; end 7.1 Mengen 7-10 Implementierung durch Bitfelder Die Laufzeit von isIn, insert und delete liegt in O (1). Die Laufzeiten der Operationen union, intersection und difference sind abhängig von der Kardinalität der Grundmenge und liegen in O (n). Vorteile: ◮ Für kleine Grundmengen ist die Implementierung sehr effizient. ◮ Die Operationen besitzen teilweise eine konstante Laufzeit. Nachteile: 7.1 Mengen ◮ Die Grundmenge muss vorher bekannt sein. ◮ Ggf. muss ein Wert in einen Index umgewandelt werden. 7-11 Implementierung durch Listen 7.1 Mengen ◮ Die Elemente der Menge werden in einer verketteten Liste gespeichert. ◮ Die Reihenfolge der Elemente spielt keine Rolle. ◮ Elemente dürfen nicht mehrfach vorkommen. ◮ Die Anzahl der Elemente der Menge braucht nicht beschränkt zu sein. ◮ Der Platzbedarf richtet sich nach der Größe der Menge, nicht nach der Größe der Grundmenge. ◮ Die Sortierung innerhalb der Liste kann zusätzliche Informationen enthalten (Beispiel: Reihenfolge des Einfügens). 7-12 Implementierung durch Listen 7.1 Mengen ◮ Varianten: einfach und mehrfach verkettete Listen (siehe Abschnitt über Listen). ◮ Pro Element wird mehr Speicherplatz verbraucht als bei der Bitfeldimplementierung. ◮ Operationen sind aufwändiger als bei der Bitfeldimplementierung. ◮ Im Folgenden: ausgewählte Algorithmen (diesmal aus objektorientierter Sicht). 7-13 Implementierung durch Listen func copy(): ListSet begin var result: ListSet; var tmp: T; foreach tmp ← L.elements() do result.insert(tmp); od; return result; end 7.1 Mengen 7-14 Implementierung durch Listen func equals(other: Set): bool begin var tmp: T; if size , other.size then return false; foreach tmp ← L.elements() do if! other.isIn(tmp) then return false; od; return true; end 7.1 Mengen 7-15 Implementierung durch Listen func union(other: Set): ListSet begin var result: ListSet; var tmp: T; result ← other.copy(); foreach tmp ← L.elements() do result.insert(tmp); od; return result; end 7.1 Mengen 7-16 Implementierung durch Listen func intersection(other: Set): ListSet begin var result: ListSet; var tmp: T; foreach tmp ← L.elements() do if other.isIn(tmp) then result.insert(tmp); od; return result; end 7.1 Mengen 7-17 Implementierung durch Listen func difference(other: Set): ListSet begin var result: ListSet; var tmp: T; foreach tmp ← L.elements() do if! other.isIn(tmp) then result.insert(tmp); od; return result; end 7.1 Mengen 7-18 Vergleich Operation Liste Bitfeld isIn insert delete copy union difference intersection equal O (|s |) O (|s |) O (|s |) O (|s |) O (|t | + |t | · |s |) O (|s | · |t |) O (|s | · |t |) O (|s | · |t |) O (1) O (1) O (1) O (|G |) O (|G |) O (|G |) O (|G |) O (|G |) Die Operation insert muss „Dubletten“ verhindern. 7.1 Mengen 7-19 Implementierung durch Felder ◮ ◮ ◮ 7.1 Mengen Die Elemente der Menge s werden in einem Feld A fester Länge gespeichert. Dabei belegen die Mengenelemente die Felder 1 bis |s |. Der Index des höchsten belegten Feldes wird in einer Variablen size = |s | gespeichert. Die Größe der Menge ist (in vielen Sprachen) durch die Feldgröße beschränkt. ◮ Es ist keine dynamische Speicherverwaltung notwendig. ◮ Die Sortierung innerhalb des Felds kann zusätzliche Informationen enthalten (zum Beispiel die Reihenfolge des Einfügens). 7-20 Implementierung durch Felder func isIn(t: T): bool begin var i: int; for i ← 1 to size do if A[i] = t then return true; od; return false; end 7.1 Mengen 7-21 Implementierung durch Felder proc insert(t: T) begin if! isIn(t) then if size = A.size then error(“maximale Größe erreicht”); A[size] ← t; size ← size + 1; fi; end func copy(): ArraySet begin var result: ArraySet; var i: int; for i ← 1 to size do result.A[i] ← A[i]; od; result.size ← size; return result; end 7.1 Mengen 7-22 Implementierung durch Felder 7.1 Mengen ◮ Die Laufzeit fast aller Operationen ist denen der Listenimplementierung ähnlich. Bei der Operation delete müssen evtl. Bereiche des Felds verschoben werden. ◮ Pro Element ist der Speicherverbrauch geringer als bei Implementation durch verkettete Listen. ◮ Allerdings verbrauchen auch nicht belegte Felder Speicherplatz. ◮ Der Speicherplatzbedarf ist unabhängig von der tatsächlichen Größe der Menge. ◮ Der Speicherplatzbedarf ist bei geringem Füllstand unnötig groß. ◮ Einige Programmiersprachen bieten die Möglichkeit, die Größe eines Felds zu verändern. 7-23 Implementierung geordneter Mengen durch Listen 7.1 Mengen ◮ Die Grundidee folgt der Implementierung von (ungeordneten) Mengen durch Listen. ◮ Allerdings sind die Elemente in der Liste jetzt entsprechend der Ordnungsrelation angeordnet. ◮ Einige Operationen lassen sich dadurch beschleunigen. 7-24 Implementierung geordneter Mengen durch Listen Als Beispiel betrachten wir die Durchschnittsbildung. Jede Liste wird mit einem Zeiger durchlaufen. 1. Setze zv auf den Anfang der ersten und zw auf den Anfang der zweiten Liste. 2. Schreite mit zw solange fort, bis sein Wert w größer oder gleich dem Wert v von zv ist. 3. Fallunterscheidung: 3.1 w = v : Nimm v in die Zielmenge auf und setze zv weiter. 3.2 w > v : Setze zv weiter. 4. Solange keine Zeiger am Ende angekommen ist, fahre mit 2 fort. zv 1 8 head zw head 7.1 Mengen 6: Treffer 7: Ende 4 22 49 2 3 1 5 12 49 55 7-25 Implementierung geordneter Mengen durch Listen func intersection(other: SortListSet): SortListSet begin var result: SortListSet; var i, j: <Referenz auf ListElement>; i ← L.head; j ← other.head; while i <gültige Referenz> && j <gült. Ref.> do if i.wert = j.wert then result.insert(j.wert); if i.wert <= j.wert then i ← i.next; else j ← j.next; fi; od; return result; end 7.1 Mengen 7-26 Implementierung geordneter Mengen durch Listen 7.1 Mengen ◮ Die Operation insert muss die Ordnung der Elemente beachten. ◮ Die Operationen isIn und delete müssen im Misserfolgsfall nicht bis zum Ende der Liste suchen. Dies führt zwar nicht zu einer Verbesserung der Komplexitätsklasse, dennoch bedeutet es eine Effizienzsteigerung. ◮ Bei einigen Operationen (zum Beispiel union, intersection, difference) kann durch die Ordnung der Liste eine Laufzeitverbesserung erreicht werden, die zu einer anderen Laufzeitklasse führt. 7-27 Implementierung geordneter Mengen durch Bäume 7.1 Mengen ◮ Anstatt die Elemente in einer Liste abzulegen, kann auch ein binärer Suchbaum verwendet werden. ◮ Für die Operationen intersection, union, difference kann analog zur Verwendung von Listen bei geordneten Mengen effizienzsteigernd das gleichzeitige Durchwandern beider Bäume durchgeführt werden. ◮ Insbesondere verbessern sich die Laufzeiten von isIn, insert und delete. Dies hängt vom verwendeten Baumtyp ab. 7-28 Verzeichnisse Es seien zwei (endliche) Mengen A und B gegeben. ◮ Ein Verzeichnis (Dictionary) ist eine Relation D ⊆ A × B mit ∀a ∈ A , b1 , b2 ∈ B : (a , b1 ) ∈ D ∧ (a , b2 ) ∈ D ⇒ b1 = b2 . ◮ Mathematisch gesehen ist ein Verzeichnis eine partielle Abbildung von A nach B . ◮ Bezeichnungen: A a∈A B (a , b ) ∈ D 7.2 Verzeichnisse Schlüsselmenge, Definitionsmenge Schlüssel Wertemenge Assoziation 7-29 Verzeichnisse ◮ Verzeichnisse werden verwendet, um Schlüssel auf Datenobjekte abzubilden. ◮ Beispiele: ◮ ◮ ◮ 7.2 Verzeichnisse Ergebnisliste einer Klausur: (Matrikelnummer, Note) Dateisystem: (Dateiname, Datenblockmenge) Da ein Verzeichnis als Menge von Paaren definiert ist, können die besprochenen Mengenimplementierungen verwendet werden. 7-30 Einige Operationen keys : P(A × B ) → P(A ) keys (D ) = {a ∈ A | ∃b ∈ B .(a , b ) ∈ D } delete : A × P(A × B ) → P(A × B ) delete (a , D ) = D \{(a , b ) | ∃b ∈ B .(a , b ) ∈ D } associate : (A × B ) × P(A × B ) → P(A × B ) associate ((a , b ), D ) = delete (a , D ) ∪ {(a , b )} 7.2 Verzeichnisse 7-31 Einige Operationen value : A × P(A × B ) → B value (a , D ) = b falls ∃b ∈ B .(a , b ) ∈ D , sonst undefiniert values : P(A × B ) → P(B ) values (D ) = {b ∈ B | ∃a ∈ A .(a , b ) ∈ D } size : P(A × B ) → N0 size (D ) = |D | 7.2 Verzeichnisse 7-32 Verzeichnisse Verzeichnis von Namen und Telefonnummern A Zeichenketten, B Zeichenketten, D = ∅ associate("Linus", "05313250", D) associate("Lena", "05315148", D) associate("Johannes", "01727512", D) Führt zu D = {("Linus", "05313250"), ("Lena", "05315148"), ("Johannes", "01727512")}. keys(D) = {"Linus", "Lena", "Johannes"} values(D) = {"05313250", "05315148", "01727512"} value(Linus, D) = "05313250" value(Peter, D) = ⊥ size(D) = 3 delete(Johannes, D) = {("Linus", "05313250"), ("Lena", "05315148")} 7.2 Verzeichnisse 7-33 Hashverfahren - Einführung ◮ Bei der Listenimplementierung einer Menge erfolgt der Zugriff auf ein Element in der Zeit O (n). ◮ Bitfeldimplementierungen benötigen nur die Zeit O (1), können jedoch sinnvoll nur kleine Mengen behandeln. ◮ Idee: Die beiden Möglichkeiten werden kombiniert. ◮ Hashverfahren basieren auf Verzeichnissen D ⊆ A × B ◮ ◮ für deren Schlüsselmenge A = {0, . . . , n − 1} ⊆ N0 gilt und deren Schlüssel berechnet werden mithilfe einer so genannten Hashfunktion h : B → A . ◮ Das Verzeichnis heißt Hashtabelle. ◮ Für die zu speichernde Menge M ist M ⊆ B . ◮ 7.3 Hashverfahren Die Berechnung der Werte der Hashfunktion erfolgt in O (1) Schritten. 7-34 Einführung Beispiel: A = {0, . . . , 9}, B = N0 , h : B → A , h (j ) = j mod 10 Die zu speichernde Menge sei M = {35, 67} ⊆ N0 . Das Einfügen der Zahlen 35 und 67 führt wegen h (35) = 5 und h (67) = 7 zu folgender Hashtabelle: 7.3 Hashverfahren Schlüssel 0 1 2 3 4 5 6 7 8 9 Werte 35 67 7-35 Einführung ◮ Gegeben sei eine Nummerierung der Buchstaben (A=1, B=2, usw.) ◮ Hashfunktion für Vor- und Nachnamen: h1 : Addiere Nummer des ersten Buchstabens des Vornamens zur Nummer des ersten Buchstabens des Nachnamens. h2 : Addiere Nummer des zweiten Buchstabens des Vornamens zur Nummer des zweiten Buchstabens des Nachnamens. ◮ Der Hashwert ergibt sich aus diesem Wert durch Restbildung modulo Feldgröße, zum Beispiel mod 10. ◮ Beispiel: h1 („Andreas Rot“) = (1 + 18) mod 10 = 9 h2 („Andreas Rot“) = (14 + 15) mod 10 = 9 7.3 Hashverfahren 7-36 Einführung Ausgangsstruktur ist ein Feld, die Hashtabelle, ◮ dessen Indexmenge der Schlüsselmenge A entspricht und ◮ deren Felder, genannt Buckets, die Elemente der zu speichernden Menge aufnehmen. Ein Element der Wertemenge wird mithilfe der Hashfunktion auf einen Index des Felds abgebildet und in diesem Feld gespeichert. Ein Bucket kann im Allgemeinen mehrere Elemente aufnehmen. Da die Wertemenge üblicherweise sehr groß ist, wird die Hashfunktion nicht injektiv sein. 7.3 Hashverfahren 7-37 Einführung Zusammenfassung des Prinzips: ◮ ◮ ◮ ◮ Es ist eine Menge M ⊆ B zu speichern. B ist die Wertemenge. Die Schlüsselmenge sei A = {0, . . . , n − 1} ⊆ N0 . h : B → A ist eine Hashfunktion. Das Feld zur Speicherung der Elemente von Menge M ist die Hashtabelle R . A ist der Indexbereich von R . ∀i ∈ A .R [i ] = {b ∈ M | h (b ) = i } ⊆ B ∀i ∈ A .Bi = {b ∈ B | h (b ) = i } = h −1 (i ) ⊆ B n [ B= Bi disjunkte Zerlegung von B M= i =0 n [ i =0 7.3 Hashverfahren b ∈ B | b ∈ M , h (b ) = i 7-38 Einführung Bi Bj Bk h Menge M h h Bucket 0: Bucket 1: Bucket 2: ··· Bucket n: M ∩ B0 M ∩ B1 M ∩ B2 M ∩ Bn Wertemenge B isIn(M , b ) für b ∈ B : 1. Ermitteln des Buckets i = h (b ) in O (1) Schritten. 2. Durchsuchen des Buckets R [i ] in O (|R [i ]|) Schritten. |R [h (b )]| sollte möglichst klein sein, ideal wäre 1. 7.3 Hashverfahren 7-39 Kollisionen Je mehr Elemente die Menge enthält, desto wahrscheinlicher ist, dass es zu einer Kollision kommt: ∃b , b ′ ∈ M .b , b ′ ∧ h (b ) = h (b ′ ). Das heißt, h ist in diesem Fall nicht injektiv. ◮ ◮ 7.3 Hashverfahren Open Hashing: Größe der Buckets erhöhen (d. h. mehrere Elemente aufnehmen). |M | ist dann unbegrenzt. Closed Hashing: Auf einen anderen Bucket ausweichen (d. h. maximal ein Element pro Bucket). Dann ist |M | ≤ |R |. 7-40 Hashfunktionen Hashfunktionen sollten surjektiv sein. Für B ⊆ N0 sind zwei Beispiele für Hashfunktionen wie folgt definiert. ◮ Divisionsmethode: h : B → A mit h (j ) = j ◮ ◮ ◮ ◮ mod |A | h (j ) ist schnell zu berechnen. Vermieden werden sollte |A | = 2k , k ∈ N. Besser: |A | ist Primzahl nicht zu nahe an 2k , k ∈ N. Multiplikationsmethode: h : B → A mit h (j ) = ⌊|A | · (j · c ◮ ◮ 7.3 Hashverfahren x mod 1 = x − ⌊x ⌋ Günstig (nach Knuth): c = mod 1)⌋, 0 < c < 1 √ 5 −1 2 7-41 Open Hashing (with Chaining) Feld R von Listen mit Indexmenge A = {0, 1, . . . , n − 1}. Bei Kollisionen wird das neue Element b der Liste R [h (b )] am Kopf hinzugefügt. Beispiel: M = {0, 1, 10, 11, 30, 41, 49, 51, 59}, n = 10, h (b ) = b mod n 0 30 10 0 1 51 41 11 ··· 9 7.3 Hashverfahren 1 h− (9) ∩ M = {59, 49} 59 49 7-42 Open Hashing (with Chaining) ◮ ◮ Die Liste kann einfach oder doppelt verkettet sein. Einfügen, Löschen und Suchen eines Elements b erfordern ◮ das Berechnen des Hash-Wertes h (b ) sowie ◮ das Ausführen der entsprechenden Listenfunktion (isIn(b), insert(b), delete(b)) für die Liste R [h (b )]. Da die Hashfunktion in O (1) ausgeführt wird, dominiert somit die jeweilige Listenfunktion die Laufzeit der Operation. ◮ Die Operationen union, intersection und difference können sukzessive für jede Liste durchgeführt werden. ◮ Ist auf B eine Ordnung definiert, bieten sich neben geordneten Listen auch andere Strukturen zur Implementierung geordneter Mengen an. 7.3 Hashverfahren 7-43 Open Hashing (with Chaining) Die Laufzeit der Operation isIn(b) ◮ beträgt im ungünstigsten Fall O (|M |). Dieser Fall tritt ein, wenn alle Elemente von M in dem Bucket R [h (b )] liegen. ◮ beträgt im günstigsten Fall O (1). Dieser Fall tritt für |R [h (b )]| = 0 ein. Aussagekräftig ist daher nur der mittlere Fall. Es gilt: Satz: Die Menge M sei durch „zufälliges Ziehen“ von Elementen b1 , . . . , bm aus B entstanden. Für m =| M | ist die mittlere Laufzeit der Operation isIn() ! m . T (m) = O 1 + |A | 7.3 Hashverfahren 7-44 Hashfunktionen Bespiel 1: h(Name) = Anfangsbuchstabe des Namens ◮ Bei deutschen Nachnamen sind die Buckets ’S’ und ’E’ sehr voll, die Buckets ’C’, ’Y’, ’X’, ’Q’ dagegen sehr leer. ◮ Besser ist es, bei Namen bzw. Zeichenketten y = y1 y2 y3 . . . yz die folgende Hashfunktion zu verwenden. z X h (y ) = OrdnungCodeX (yi ) j =1 7.3 Hashverfahren mod (|A |) 7-45 Hashfunktionen Beispiel 2: Ein Compiler trägt die Bezeichner eines Programms in eine Hashtabelle ein. M sei die Menge der Bezeichner, |M | = m. Die Laufzeit für den Eintrag eines Bezeichers x ergibt sich durch: Auswerten von h (x ): Überprüfen von R [h (x )]: evtl. Anfügen von x : 7.3 Hashverfahren 1 | R [h (x )] | +1 1 | R [h (x )] | +3 7-46 Hashfunktionen Unter idealen Verhältnissen, also wenn nach dem Einfügen der Elemente b1 , b2 , . . . , bi −1 i−1 |R [h (bi )]| = |A | ist, dann gilt für die Laufzeit zum Einfügen von m Elementen: ! ! m m X X i−1 m+1 1 T (m) = (|R [h (bi )]| + 3) = +3 =m − +3 |A | 2|A | |A | i =1 i =1 7.3 Hashverfahren 7-47 Hashfunktionen Wenn der Programmierer nur Bezeichner der Art „A00“ bis „A99“ verwendet werden, also M = {A 00, A 01, . . . , A 99} ist, und außerdem z X h (y ) = OrdnungASCII (yi ) j =1 mod (|A |) und A = {0, . . . , 99} gelten, dann ist zwar |A | = |M |, aber es werden nur 19 der 100 Buckets benutzt. 7.3 Hashverfahren 7-48 Hashfunktionen j0 A00 j0 + 1 A01 A10 j0 + 2 A02 A11 A20 ··· ··· ··· ··· ··· j0 + 9 A09 A18 A27 ··· A90 j0 + 10 A19 A28 ··· A91 ··· ··· ··· ··· j0 + 18 A99 Es gibt also einen Bucket mit 10 Elementen und je zwei mit 9, 8, 7, . . . , 1 Elementen. Die Laufzeit für das Auffüllen eines Buckets mit m Elementen beträgt l (m) = 3 + 4 + 5 + · · · + (m + 3), die Gesamtlaufzeit also l (10) + 2 9 X l (i ) = 742 > 349, 5 = T (100). i =1 Ursache: Der Trend aus M (benachbarte Bezeichner) schlägt auf h (b ) (benachbarte Buckets) durch. 7.3 Hashverfahren 7-49 Open Hashing (with Chaining) Zusammenfassung: ◮ Das Problem der Kollisionen wird durch Bucketlisten gelöst. ◮ Die Menge M bzw. die Buckets können (im Prinzip) beliebig groß werden. ◮ Falls die Listen lang werden, steigt die Zugriffszeit. 7.3 Hashverfahren 7-50 Closed Hashing Jedes Element von R fasst maximal ein Element aus B . Es wird bei einer Kollision eine Folge von Hashfunktionen verwendet: h0 : B → A , h1 : B → A , . . . , hn : B → A . Einfügen von b ∈ B : 1. i ← 0 2. Fallunterscheidung bezüglich des Felds R [hi (b )]: ◮ ◮ ◮ Feld ist frei: Füge b ein und beende den Algorithmus. Feld enthält bereits b : Beende den Algorithmus. Feld ist bereits durch ein Element b ′ , b belegt (Kollision): i ← i + 1. 3. Falls i < n wiederhole Schritt 2, andernfalls beende den Algorithmus mit Fehler. 7.3 Hashverfahren 7-51 Closed Hashing Einfügen von 1, 6, 5, 3, die Indexmenge sei A = {0, 1, 2, 3} und h0 : B → A hi : B → A 0 1 2 3 +1 h0 (1) = 1 0 1 1 2 3 h0 (j ) = j mod |A | = j mod 4 mit mit +6 h0 (6) = 2 hi (j ) = h0 (j ) + i mod |A |; 0 1 1 2 6 3 +5 h0 (5) = 1 0 1 1 2 6 3 5 h1 (5) = 2 h2 (5) = 3 +3 h0 (3) = 3 i>0 0 1 2 3 3 1 6 5 h1 (3) = 0 Das Feld R ist nun voll, 5 und 3 konnten nicht am „eigentlich“ vorgesehenen Platz eingefügt werden. 7.3 Hashverfahren 7-52 Closed Hashing Suchen von b ∈ B 1. i ← 0 2. Fallunterscheidung bezüglich des Felds R [hi (b )]: ◮ ◮ ◮ Feld ist frei: b ist nicht enthalten, beende den Algorithmus. Feld enthält b : b ist gefunden, beende den Algorithmus. Feld ist durch ein Element b ′ , b belegt: i ← i + 1 3. Falls i < n wiederhole Schritt 2, andernfalls beende den Algorithmus (Das Element ist im vollständig gefüllten Feld nicht enthalten). 5? h0 (5) = 1 7.3 Hashverfahren 0 1 2 3 3 1 6 5 1 6= 5; h1 (5) = 2 6 6= 5; h2 (5) = 3 7-53 Closed Hashing Löschen eines Elements: Problem: Das gelöschte Feld muss von einem leeren Feld unterscheidbar sein, da es auf dem Suchpfad eines Elements liegen kann. Eine Lösung: Das Element bleibt gespeichert, das Feld wird dafür mit einem booleschen Flag versehen, dass angibt, ob es sich um ein aktives oder ein gelöschtes Element handelt. Notwendige Änderungen: ◮ Der Algorithmus zum Suchen muss über als gelöscht markierte Felder hinweggehen. ◮ Der Algorithmus zum Einfügen kann Elemente auch auf als gelöscht markierten Feldern einfügen. Folgerung: Die Laufzeit der Suche/des Löschens hängt nicht länger vom tatsächlichen Füllgrads des Felds ab. 7.3 Hashverfahren 7-54 Lineare Kollisionsbehandlung Bei der linearen Kollisionsbehandlung werden Hashfunktionen der folgenden Form verwendet: ◮ ◮ h0 : B → A Hashfunktion, hi : B → A mit hi (j ) = (h0 (j ) + i ) mod |A | für i > 0. Nach Bestimmung des ersten Hashwertes mit h0 berechnet h1 den nächsten Index durch Addition von 1. Bei Erreichen des letzten Indexes wird 0 als nächster Index bestimmt. 7.3 Hashverfahren 7-55 Lineare Kollisionsbehandlung Die lineare Kollisionsbehandlung kann zu einer Clusterbildung (primary clustering) führen. Je größer ein Cluster, desto höher die Wahrscheinlichkeit, dass der Hashwert h0 (z ) eines einzufügenden Elements z in dem Cluster liegt, wodurch dieser verlängert wird. Dies hat negative Auswirkungen auf die Laufzeit der Suche nach freien Feldern. 7.3 Hashverfahren Cluster belegter Felder h0 (j) h1 (j) h2 (j) h3 (j) 7-56 Quadratische Kollisionsbehandlung Bei der quadratischen Kollisionsbehandlung werden Hashfunktionen der folgenden Form verwendet: ◮ ◮ ◮ ◮ ◮ ◮ 7.3 Hashverfahren h0 : B → A Hashfunktion, hi : B → A mit hi (j ) = (h0 (j ) + c1 · i + c2 · i 2 ) mod |A | für i > 0 und Konstanten c1 , c2 , 0. Bezüglich des Clusterbildung ist die quadratische Kollisionsbehandlung günstiger als die lineare. Um die Hashtabelle vollständig auszunutzen, sind c1 und c2 geeignet zu wählen. Da sich auch hier die Hashwerte von hi zweier Elemente b , b ′ ∈ B mit h0 (b ) = h0 (b ′ ) im weiteren Verlauf gleichen, kommt es ebenfalls zu Clustern, wenn auch in abgemilderter Form (secondary clustering). Bei der linearen und der quadratischen Kollisionsbehandlung hängt das weitere Verhalten nicht vom Element ab. 7-57 Doppeltes Hashing Beim doppelten Hashing werden Hashfunktionen der folgenden Form verwendet: ◮ ◮ ha : B → A , hb : B → A hi : B → A mit hi (j ) = (ha (j ) + i · hb (j )) für i ≥ 0 ◮ Eine der besten Methoden für Closed Hashing, da die Folgen von Hashwerten nahezu zufällig erscheinen. ◮ Der erste Hashwert eines Elements hängt von nur einer Hashfunktion ha ab. ◮ Der zweite dann auch von der zweiten Funktion hb . ◮ Dass beide Hashfunktionen für zwei unterschiedliche Elemente aus B die gleichen Resultate erzeugen, ist (abhängig von der Wahl von ha und hb ) unwahrscheinlich. 7.3 Hashverfahren 7-58 Doppeltes Hashing hb (j ) und |A | sollten teilerfremd sein, damit die gesamte Indexmenge durchsucht wird. Möglichkeiten: ◮ ◮ |A | = 2k und hb (j ) produziert nur ungerade Zahlen. |A | ist eine Primzahl und ∀j ∈ B : 0 < hb (j ) < |A | Beispiel (Cormen): ha : B → A mit ha (j ) = j mod |A | und hb : B → A mit hb (j ) = 1 + (j mod k ) mit k etwas kleiner als |A |. j = 123456, |A | = 701, k = 700 führt zu ha (j ) = 80, hb (j ) = 257 Der erste untersuchte Feld hat den Index 80, danach wird jedes 257te Feld (modulo 701) untersucht bis alle Felder betrachtet wurden. 7.3 Hashverfahren 7-59 Closed Hashing ◮ Ist die Hashfunktion bzgl. B identisch gleichverteilt, so sind 1 bei einem Füllfaktor α = |An | < 1 höchstens 1−α Versuche notwendig, um zu entscheiden, dass ein Element nicht in M enthalten ist. ◮ Ist die Hashfunktion bzgl. B identisch gleichverteilt, so sind 1 bei einem Füllfaktor α = |An | < 1 höchstens 1−α Versuche notwendig, um ein Element einzufügen. ◮ Ist die Hashfunktion bzgl. B identisch gleichverteilt, so sind 1 bei einem Füllfaktor α = |An | < 1 höchstens α1 ln 1−α Versuche notwendig, um ein Element zu finden. (Falls die Wahrscheinlichkeit, mit der nach einem Element gesucht wird, für alle Elemente gleich ist.) Die Beweise dieser Aussagen findet man beispielsweise bei Cormen et al., S. 242 ff. 7.3 Hashverfahren 7-60 Zusammenfassung Operation isIn(x ), n = |M | Anzahl der Elemente der Menge Implementierung Bitfeld Liste unsortiert Liste sortiert Rot-Schwarz-Baum Open Hashing Closed Hashing 7.3 Hashverfahren Worst Case O (1) O (n) O (n) O (log(n)) O (n) O (|A |) Average Case O (1) O (n) O (n) O (log n) O (1 + mn ) 1 O ( 1−α ) 7-61 1. Der Algorithmenbegriff 2. Imperative Algorithmen 3. Sortieralgorithmen 4. Listen und abstrakte Datentypen 5. Objektorientierte Algorithmen 6. Bäume 7. Mengen, Verzeichnisse und Hash-Verfahren 8. Graphen 8.1 Mathematische Grundlagen 8.2 Darstellung von Graphen 8.3 Ausgewählte Algorithmen für ungewichtete Graphen 8.4 Ausgewählte Algorithmen für gewichtete Graphen 9. Entwurf von Algorithmen 10. Funktionale und deduktive Algorithmen Einführung Knoten Ein Graph besteht aus Knoten (vertices oder nodes), die durch Kanten (edges) verbunden sind. Die Kanten können ◮ ungerichtet oder ◮ gerichtet sein. Die entsprechenden Graphen heißen ungerichtete bzw. gerichtete Graphen. Kante 1 1 6 8 3 5 3 2 Gewicht 3 Zyklus Ungerichteter gewichteter zyklischer Graph Graphen, deren Kanten durch eine Zahl gewichtet sind, nennt man gewichtete Graphen. Graphen können Zyklen enthalten. Gerichteter azyklischer Graph 8.1 Mathematische Grundlagen 8-1 Einführung Knoten können durch eine Kante mit sich selbst verknüpft sein (Schlinge). ungerichteter zyklischer Graph mit Schlingen Knoten können Attribute (zum Beispiel einen Wert oder eine Farbe) besitzen. gerichteter azyklischer gefärbter Graph 8.1 Mathematische Grundlagen 8-2 Anwendungsbeispiele Ungerichtete Graphen: ◮ Kommunikationsnetz ◮ ◮ Gewichte als Dauer einer Datenübertragung. Gesucht ist der schnellste Übertragungsweg von a nach b . Gewichte als Kosten einer Datenübertragung. Gesucht ist der günstigste Übertragungsweg von a nach b . Gerichtete Graphen: ◮ Straßennetz ◮ ◮ Gewicht als Länge einer Wegstrecke. Gesucht ist die kürzeste Strecke von a nach b . Begriffsmodellierung, semantische Netze ◮ Unterbegriffe: ein „Auto“ ist ein „Fahrzeug“. 8.1 Mathematische Grundlagen 8-3 Anwendungsbeispiele Gerichtete Graphen: ◮ Kontrollfluss in Programmen ◮ Welche Programmabschnitte werden bei gegebener Eingabe nicht ausgeführt? Gerichtete azyklische Graphen: ◮ Stammbäume ◮ ◮ Gesucht sind die Vorfahren von x . Vererbungshierachie in der objektorientierten Programmierung 8.1 Mathematische Grundlagen 8-4 Gerichtete und ungerichtete Graphen ◮ Ein gerichteter Graph (Digraph) G ist ein Paar (V , E ) mit: ◮ ◮ ◮ ◮ V ist eine endliche Menge (Knoten, Knotenmenge). E ⊆ V × V ist eine Relation auf V (Kanten, Kantenmenge). Eine Kante (u, u) ∈ E heißt Schlinge. Ein ungerichteter Graph G ist ein Paar (V , E ) mit: ◮ ◮ ◮ ◮ ◮ V ist eine endliche Menge (Knoten, Knotenmenge). E ⊆ {{u, v } | u, v ∈ V } ist eine Menge, deren Elemente einoder zweielementige Teilmengen von V sind. (Kanten, Kantenmenge). Eine einelementige Teilmenge {u} heißt Schlinge. E kann als symmetrische Relation E ⊆ V × V angesehen werden. Man schreibt häufig (u, v ) ∈ E statt {u, v } ∈ E . 8.1 Mathematische Grundlagen 8-5 Adjazenz Es sei ein gerichteter oder ungerichteter Graph G = (V , E ) gegeben. Falls (u, v ) ∈ E ist sagt bzw. schreibt man: ◮ (u, v ) tritt aus u aus, ◮ (u, v ) tritt in v ein, ◮ u und v sind adjazent (benachbart), ◮ u → v. E ist die Adjazenzrelation. 8.1 Mathematische Grundlagen 8-6 Grad eines Knotens Ungerichteter Graph: ◮ Der Grad eines Knotens ist die Anzahl der mit ihm in Relation stehenden Knoten. ◮ Ein Knoten mit dem Grad 0 heißt isoliert. Gerichteter Graph: ◮ Der Ausgangsgrad eines Knotens ist die Anzahl seiner austretenden Kanten. ◮ Der Eingangsgrad eines Knotens ist die Anzahl seiner eintretenden Kanten. ◮ Der Grad eines Knotens ist die Summe aus Ausgangs- und Eingangsgrad. 8.1 Mathematische Grundlagen 8-7 Pfade Es sei ein Graph G = (V , E ) gegeben. ◮ ◮ Ein Pfad p der Länge k in G von u ∈ V zu u′ ∈ V ist eine Folge p = (v0 , v1 , . . . , vk ) von Knoten mit u = v0 , u′ = vk und (vi −1 , vi ) ∈ E , i = 1, . . . k . Der Pfad p enthält die Knoten v0 , v1 , . . . , vk und die Kanten (v0 , v1 ),. . . ,(vk −1 , vk ). ◮ Wenn es einen Pfad p von u ∈ V zu u′ ∈ V gibt, heißt u′ von u über p erreichbar. ◮ Ein Pfad heißt einfach, wenn alle Knoten verschieden sind. ◮ Ein Teilpfad eines Pfads p = (v0 , v1 , . . . , vk ) ist eine Teilfolge benachbarter Knoten. 8.1 Mathematische Grundlagen 8-8 Zyklen ◮ Ein Pfad p = (v0 , v1 , . . . , vk ) heißt Zyklus, wenn v0 = vk und k > 0 ist. ◮ Ein Zyklus ist einfach, wenn seine Knoten paarweise verschieden sind. ◮ Ein Graph ohne Schlingen wird einfach genannt. ◮ Ein Graph ohne Zyklen wird als azyklisch bezeichnet. 8.1 Mathematische Grundlagen 8-9 Zusammenhang ◮ Ein ungerichteter Graph heißt zusammenhängend, wenn jedes Knotenpaar durch einen Pfad verbunden ist. ◮ Die Zusammenhangskomponenten eines ungerichteten Graphen sind die Äquivalenzklassen bezüglich der Äquivalenzrelation „ist erreichbar von“. ◮ Ein gerichteter Graph heißt stark zusammenhängend, wenn jeder Knoten von jedem anderen Knoten aus erreichbar ist. ◮ Die starken Zusammenhangskomponenten eines gerichteten Graphen sind die Äquivalenzklassen bezüglich der Äquivalenzrelation „sind gegenseitig erreichbar“. 8.1 Mathematische Grundlagen 8-10 Teilgraphen und Isomorphie ◮ Ein Graph G ′ = (V ′ , E ′ ) ist ein Teilgraph von G = (V , E ), falls V ′ ⊆ V und E ′ ⊆ E gilt. ◮ Ist eine Teilmenge V ′ ⊆ V gegeben, dann ist der durch V ′ induzierte Teilgraph Graph G ′ = (V ′ , E ′ ) von G = (V , E ) durch E ′ = {(u, v ) ∈ E | u, v ∈ V ′ }. bestimmt. ◮ Zwei Graphen G = (V , E ) und G ′ = (V ′ , E ′ ) sind isomorph, wenn es eine bijektive Abbildung f : V → V ′ mit (u, v ) ∈ E ⇔ (f (u), f (v )) ∈ E ′ gibt. 8.1 Mathematische Grundlagen 8-11 Spezielle Graphen ◮ Ein vollständiger Graph ist ein Graph, in dem jedes Knotenpaar benachbart ist: u, v ∈ V ⇒ (u, v ) ∈ E ◮ Ein bipartiter Graph ist ein Graph, in dem die Knotenmenge V in zwei Teilmengen V1 und V2 zerlegt werden kann, dass alle Kanten zwischen V1 und V2 verlaufen: (u, v ) ∈ E ⇒ (u ∈ V1 , v ∈ V2 ) ∨ (u ∈ V2 , v ∈ V1 ) 8.1 Mathematische Grundlagen 8-12 Bäume ◮ Ein Wald ist ein azyklischer, ungerichteter Graph. ◮ Ein (freier) Baum ist ein zusammenhängender Wald. ◮ Ein (gerichteter) Baum ist ein freier Baum, in dem einer der Knoten vor den anderen ausgzeichnet ist. Dieser Knoten heißt Wurzel. ◮ In einem geordneten Baum sind die Kinder jedes Knotens geordnet. Mit diesen Definitionen, können wir die Begriffe Kind, Vater, Vorfahre, Höhe, Tiefe, binärer Baum, k -närer Baum, . . . graphentheoretisch interpretieren. 8.1 Mathematische Grundlagen 8-13 Gewichtete Graphen ◮ Ein gewichteter Graph G = (V , E , w ) besteht aus einem Graphen (V , E ) und einer Gewichtsfunktion w : E → R, die jeder Kante e ∈ E eine reelle Zahl w (e ) als Gewicht zuordnet. ◮ Das Gewicht w (p ) eines Pfads p = (v0 , v1 , . . . , vk ) ist die Summe der einzelnen Kantengewichte: w (p ) := k −1 X w ((vi , vi +1 )) i =0 8.1 Mathematische Grundlagen 8-14 Kürzeste Pfade ◮ Ungewichtete Graphen: Ein Pfad minimaler Länge zwischen zwei Knoten heißt kürzester Pfad zwischen diesen Knoten. ◮ Gewichtete Graphen: Ein Pfad minimalen Gewichts zwischen zwei Knoten heißt kürzester Pfad zwischen diesen Knoten. ◮ Die Länge bzw. das Gewicht des kürzesten Pfades zwischen zwei Knoten ist die Distanz der beiden Knoten. ◮ Kürzeste Pfade müssen nicht existieren (Beispiel: es existiert ein Zyklus mit negativem Gewicht, der beliebig oft durchlaufen werden kann). ◮ Kürzeste Pfade sind im Allgemeinen nicht eindeutig bestimmt. 8.1 Mathematische Grundlagen 8-15 Erweiterungen ◮ Ein Multigraph ist ein Graph, der Mehrfachkanten enthalten kann. ◮ Eine Kante in einem Hypergraphen kann mehr als zwei Knoten verbinden. 8.1 Mathematische Grundlagen 8-16 Nützliche Funktionen Bei gerichteten Graphen: ◮ ◮ ◮ ◮ Ausgangskanten: ak : V → P(E ), ak (u) = {(u, v ) | (u, v ) ∈ E } Eingangskanten: ek : V → P(E ), ek (u) = {(v , u) | (v , u) ∈ E } Ausgangsgrad: ag : V → N0 , ag (u) = |ak (u)| Eingangsgrad: eg : V → N0 , eg (u) = |ek (u)| 8.2 Darstellung von Graphen f e g c a b d ak (a ) = {(a , g ), (a , b )} ek (g ) = {(a , g ), (b , g ), (f , g )} 8-17 Nützliche Funktionen ◮ ◮ Nachfolgerknoten: nk : V → P(V ), nk (u) = {v | (u, v ) ∈ E } Vorgängerknoten: vk : V → P(V ), vk (u) = {v | (v , u) ∈ E } 8.2 Darstellung von Graphen f e g c a b d nk (a ) = {g , b } 8-18 Möglichkeiten zur Speicherung von Graphen ◮ Kantenlisten ◮ Knotenlisten ◮ Adjazenzmatrizen ◮ Adjazenzlisten 8.2 Darstellung von Graphen 8-19 Kantenlisten ◮ ◮ ◮ ◮ ◮ Nummerierung der Knoten von 1 bis |V | = n Speicherung: |V |, |E |, Paare (a , b ) mit (a , b ) ∈ E . Es werden 2 + |E | ∗ 2 Werte unsortiert gespeichert. Einfügen von Kanten und Knoten: O (1) Löschen von Kanten erfordert ein Durchsuchen der Liste: O (|E |) 6 5 7 3 1 2 4 Kantenliste: 7, 9, 1, 2, 1, 7, 2, 7, 3, 2, 3, 4, 3, 5, 4, 5, 5, 6, 6, 7 Löschen von Knoten erfordert ein erneutes Nummerieren der Knoten und ggf. Löschen von Kanten: O (|E |) 8.2 Darstellung von Graphen 8-20 Knotenliste ◮ ◮ Nummerierung der Knoten von 1 bis |V | = n Speicherung: |V |, |E |, (ag (v ), nk (v )) mit v ∈ V aufsteigend sortiert. Es werden 2 + |V | + |E | Werte gespeichert. ◮ Einfügen von Knoten: O (1) ◮ Einfügen und Löschen von Kanten erfordert ein Durchsuchen der Liste: O (|E | + |V |) ◮ 6 5 7 3 1 2 4 Knotenliste: 7, 9, 2, 2, 7, 1, 7, 3, 2, 4, 5, 1, 5, 1, 6, 1, 7, 0 Löschen von Knoten erfordert ein erneutes Nummerieren der Knoten und ggf. Löschen von Kanten: O (|E | + |V |) 8.2 Darstellung von Graphen 8-21 Adjazenzmatrizen Ein Graph G = (V , E ) mit |V | = n wird als quadratische n × n-Matrix a von booleschen Werten gespeichert. Es gilt a [i , j ] = true ⇔ (i , j ) ∈ E . Beispiel für einen gerichteten Graphen: 1 3 2 4 1 1 0 1 0 0 0 1 0 0 1 1 0 0 0 0 Bei ungerichteten Graphen braucht aus Symmetriegründen nur eine Hälfte der Matrix gespeichert zu werden. 8.2 Darstellung von Graphen 8-22 Adjazenzliste ◮ ◮ Nummerierung der Knoten von 1 bis |V |. Implementierung durch |V | + 1 Listen: ◮ ◮ Basisliste: Liste aller Knoten des Graphen. Pro Knoten: Liste der Nachfolger des Knotens. 6 7 5 1 2 2 7 7 4 3 ··· 7 1 8.2 Darstellung von Graphen 2 8-23 Vergleich der Implementierungen Es sei |V | = n, |E | = m. Speicherbedarf Kante Einfügen Kante Löschen Knoten Einfügen Knoten Löschen Kantenliste Knotenliste Adjazenzmatrix Adjazenzliste O(m) O (1) O (m) O (1) O (m) O (n + m) O (n + m) O (n + m) O (1) O (n + m) O (n2 ) O (1) O (1) O (n2 ) O (n2 ) O (n + m) O (n)∗ O (n + m)∗ O (1) O (n + m) ∗) 8.2 Darstellung von Graphen für die hier gegebene Implementierung 8-24 Übersicht In diesem Abschnitt wollen wir beispielhaft einige Algorithmen für ungewichtete Graphen vorstellen. ◮ Systematisches Durchsuchen eines Graphen ◮ ◮ Breitensuche (breadth-first search) Tiefensuche (depth-first search) ◮ Zyklenfreiheit ◮ Topologisches Sortieren ◮ Erreichbarkeit 8.3 Ausgewählte Algorithmen für ungewichtete Graphen 8-25 Breitensuche Besuch aller Knoten eines Graphen G = (V , E ), die von einem Startknoten s erreichbar sind. ◮ Es wird von s ausgegangen. ◮ Zuerst werden alle von s über eine Kante erreichbaren Knoten besucht. ◮ Dann alle über zwei Kanten erreichbaren Knoten. ◮ usw. 2. Iteration Startknoten 1. Iteration 8.3 Ausgewählte Algorithmen für ungewichtete Graphen 8-26 Breitensuche ◮ Iterative Kontrollstruktur ◮ Schlange Q zur Speicherung der gerade bearbeiteten Knoten in Reihenfolge der Iterationsschritte Drei Verzeichnisse (Abbildungen, Dictionaries) ◮ d : V → N0 bildet jeden Knoten auf seine Entfernung vom ◮ ◮ ◮ Startknoten ab. p : V → V bildet jeden Knoten auf den Vorgängerknoten ab, von dem ausgehend er erreicht worden ist. p ergibt nach der Abarbeitung einen Breitensuchbaum. c : V → {weiß, schwarz, grau} ordnet jedem Knoten eine Farbe abhängig von seinem Bearbeitungszustand zu: ◮ ◮ ◮ weiß: noch unentdeckt, grau: Entfernung bereits bestimmt, schwarz: abgearbeitet. 8.3 Ausgewählte Algorithmen für ungewichtete Graphen 8-27 Breitensuche proc BFS(G,s) begin end foreach u ∈ V \ {s} do c(u) ← weiß; d(u) ← ∞; p(u) ← nil od; c(s) ← grau; d(s) ← 0; p(s) ← nil; Q ← empty; enter(Q,s); while! isempty(Q) do u ← front(Q); leave(Q); foreach v ∈ nk(u) do if c(v) = weiß then c(v) ← grau; d(v) ← d(u)+1; p(v) ← u; enter(Q,v) fi; od; c(u) ← schwarz; od; 8.3 Ausgewählte Algorithmen für ungewichtete Graphen 8-28 Breitensuche r s v w t u r s x y v w q: s t u r s t u x y v w x q: r, t, x y q: w, r r s t u r s t u r s t u v w x q: t, x, v y v w x q: x, v, u y v w x q: v, u, y y r s t u r s t u r s t u v w x y v w x y v w x y q: u, y 8.3 Ausgewählte Algorithmen für ungewichtete Graphen q: y q: leer 8-29 Breitensuche Satz: Es sei G = (V , E ) ein gerichteter oder ungerichteter Graph, auf dem die Prozedur BFS für einen Startknoten s ∈ V ausgeführt wird. 1. Die Prozedur entdeckt jeden Knoten v ∈ V , der von s aus erreichbar ist. Bei der Terminierung ist d (v ) gleich der Distanz von v von s für alle v ∈ V . 2. Die Laufzeit der Breitensuche liegt in O (|V | + |E |), das heißt, die Laufzeit ist linear in der Größe der Adjazenzliste. 8.3 Ausgewählte Algorithmen für ungewichtete Graphen 8-30 Breitensuche Der implizit über das Verzeichnis p erzeugte Breitensuchbaum ist r s t u v w x y Die Pfade von jedem Knoten in diesem Baum zum Startknoten entsprechen kürzesten Pfaden in G . 8.3 Ausgewählte Algorithmen für ungewichtete Graphen 8-31 Tiefensuche Besuch aller Knoten eines Graphen G = (V , E ). ◮ Es wird von einem Startknoten s ausgegangen. ◮ Es wird rekursiv so weit wie möglich auf einem Pfad vorangeschritten. ◮ Danach wird zu einer Verzweigung mit einem noch nicht besuchten Knoten zurückgegangen (Backtracking). 1 a 14 j 13 11 10 i 6 2 b 8.3 Ausgewählte Algorithmen für ungewichtete Graphen d 12 3 16 15 f e 9 7 h c 4 8 g 5 8-32 Tiefensuche ◮ Rekursive Kontrollstruktur ◮ Prozeduren DFS(G) und DFS-visit(u) ◮ Zeitstempel Vier Verzeichnisse (Abbildungen, Dictionaries) ◮ d : V → N0 Beginn der Bearbeitung eines Knotens ◮ f : V → N0 Ende der Bearbeitung eines Knotens ◮ p : V → V Vorgängerknoten ◮ c : V → {weiß, schwarz, grau} wie oben ◮ 8.3 Ausgewählte Algorithmen für ungewichtete Graphen 8-33 Tiefensuche proc DFS(G) begin foreach u ∈ V do c(u) ← weiß; p(u) ← nil od; zeit ← 0; foreach u ∈ V do if c(u) = weiß then DFS-visit(u) fi; od; end 8.3 Ausgewählte Algorithmen für ungewichtete Graphen 8-34 Tiefensuche proc DFS-visit(u): begin c(u) ← grau; zeit ← zeit+1; d(u) ← zeit; foreach v ∈ nk(u) do if c(v) = weiß then p(v) ← u; DFS-visit(v) fi; od; c(u) ← schwarz; zeit ← zeit+1; f(u) ← zeit; end 8.3 Ausgewählte Algorithmen für ungewichtete Graphen 8-35 Tiefensuche 1/ u / v / w 1/ u 2/ v / w 1/ u 2/ v / w 1/ u 2/ v / w x / y / z / x / y / z / x / y 3/ z / x 4/ y 3/ z / 1/ u 2/ v / w 1/ u 2/ v / w 1/ u 2/ v / w 1/ u 2/7 v / w y 3/6 z / B x 4/ B y 3/ z / x 4/5 B y 3/ 8.3 Ausgewählte Algorithmen für ungewichtete Graphen z / x 4/5 B y 3/6 z / x 4/5 8-36 Tiefensuche 1/ u 2/7 v / w FB 1/8 u 2/7 v 1/8 u / w FB 2/7 v 9/ w FB 1/8 u 2/7 v FB 9/ w C x 4/5 y 3/6 z / x 4/5 y 3/6 z / x 4/5 y 3/6 z / x 4/5 y 3/6 z / 1/8 u 2/7 v 9/ w 1/8 u 2/7 v 9/ w 1/8 u 2/7 v 9/ w 1/8 u 2/7 v 9/12 w FB x 4/5 FB C y 3/6 z 10/ x 4/5 FB C y 3/6 8.3 Ausgewählte Algorithmen für ungewichtete Graphen z 10/ B x 4/5 C y 3/6 z B 10/11 FB x 4/5 C y 3/6 z B 10/11 8-37 Tiefensuche Der implizit über das Verzeichnis p erzeugte Tiefensuchwald ist 1/8 u 2/7 v FB x 4/5 8.3 Ausgewählte Algorithmen für ungewichtete Graphen 9/12 w C y 3/6 z B 10/11 8-38 Tiefensuche Klassifikation der Kanten: 1. Baumkanten sind Kanten im Tiefensuchwald. 2. Rückwärtskanten B sind Kanten, die einen Knoten mit einem Vorfahren im Tiefensuchwald verbinden. 3. Vorwärtskanten F sind diejenigen Nichtbaumkanten, die einen Knoten mit einem Nachfahren im Tiefensuchwald verbinden. 4. Querkanten C sind alle übrigen Kanten. Sie können zwischen verschiedenen Tiefensuchbäume verlaufen. Bei einer Tiefensuche auf einem ungerichteten Graphen ist jede Kante eine Baumkante oder eine Rückwärtskante. 8.3 Ausgewählte Algorithmen für ungewichtete Graphen 8-39 Zyklenfreiheit Mithilfe der Tiefensuche können Zyklen in gerichteten Graphen ermittelt werden. Es gilt: Ein gerichteter Graph G ist genau dann azyklisch, wenn der Tiefensuchalgorithmus auf G keine Rückwärtskanten liefert. Beweis: s. Cormen et al., Seite 554. 8.3 Ausgewählte Algorithmen für ungewichtete Graphen 8-40 Topologisches Sortieren ◮ Gegeben sei ein gerichteter azyklischer Graph G = (V , E ). Solche Graphen werden als DAG (directed acyclic graph) bezeichnet. ◮ Eine topologische Sortierung von G ist eine lineare Anordnung seiner Knoten mit der Eigenschaft, dass u vor v liegt, wenn es einen Pfad von u nach v gibt. ◮ Gesucht ist ein Algorithmus, der zu einem DAG G = (V , E ) eine topologische Sortierung seiner Knotenmenge V berechnet. 8.3 Ausgewählte Algorithmen für ungewichtete Graphen 8-41 Topologisches Sortieren Beispiel: Gesucht ist die Reihenfolge beim Ankleiden. Nach Festlegung der Reihenfolge einzelner Kleidungsstücke entsteht folgender Graph: Socken Unterhose 8.3 Ausgewählte Algorithmen für ungewichtete Graphen Schuhe Hose Hemd Gürtel Fliege Uhr Jacke 8-42 Topologisches Sortieren Mit Tiefensuche kann für jeden Knoten die Endzeit seiner Bearbeitung bestimmt werden. Sie ergibt eine topologische Sortierung. Ist die Bearbeitung eines Knotens abgeschlossen, so wird er am Kopf der zu Beginn leeren Ergebnisliste eingefügt. Die Ergebnisliste gibt die Sortierung an. Reihenfolge: Socken (18), Unterhose (16), Hose (15), Schuhe (14), Uhr (10), Hemd (8), Gürtel (7), Fliege (5), Jacke (4) 8.3 Ausgewählte Algorithmen für ungewichtete Graphen Socken 17/18 Unterhose 11/16 Schuhe 13/14 Hose 12/15 Hemd 1/8 Gürtel 6/7 Fliege 2/5 Uhr 9/10 Jacke 3/4 8-43 Floyd-Warshall-Algorithmus Es soll die reflexive, transitive Hülle einer Relation bestimmt werden. Dieses Problem entspricht dem Erreichbarkeitsproblem in einem Graphen. 0 1 0 0 0 0 0 1 0 0 0 1 0 0 0 0 8.3 Ausgewählte Algorithmen für ungewichtete Graphen 1 2 3 4 1 1 0 1 0 1 0 1 0 0 1 1 0 0 0 1 8-44 Floyd-Warshall-Algorithmus Es sei V = {1, . . . , n}. Die Relation E ⊆ V × V liege als Adjazenzmatrix r vor. Für 1 ≤ i , j ≤ n sei true r [i , j ] = false falls sonst (i , j ) ∈ E func FloyWars(r: bool [n,n]): bool [n,n] begin var i,j,k: int; for i ← 1 to n do r[i,i] ← true; od; for k ← 1 to n do for i ← 1 to n do for j ← 1 to n do r[i,j] ← r[i,j] ∨ (r[i,k] ∧ r[k,j]); od; od; od; return r; end 8.3 Ausgewählte Algorithmen für ungewichtete Graphen 8-45 Übersicht In diesem Abschnitt wollen wir beispielhaft einige Algorithmen für gewichtete Graphen vorstellen. ◮ Minimale Spannbäume ◮ ◮ ◮ Algorithmus von Kruskal Algorithmus von Prim Kürzeste Pfade von einem Startknoten ◮ ◮ Algorithmus von Dijkstra Bellmann-Ford-Algorithmus 8.4 Ausgewählte Algorithmen für gewichtete Graphen 8-46 Minimale Spannbäume ◮ ◮ Gegeben sei ein gewichteter Graph G = (V , E , w ). Gesucht ist eine azyklische Teilmenge T ⊆ E , die alle Knoten verbindet und deren Gesamtgewicht w (T ) = X w (e ) e ∈T ◮ ◮ minimal ist. Eine Kantenmenge, die azyklisch ist und alle Knoten verbindet, ist ein Baum, der Spannbaum genannt wird. Es ist also ein minimaler Spannbaum gesucht. 4 3 3 3 2 6 8 6 4 ◮ 5 5 7 6 2 Dieser Baum ist im Allgemeinen nicht eindeutig bestimmt. 8.4 Ausgewählte Algorithmen für gewichtete Graphen 8-47 Basisalgorithmus ◮ Der Algorithmus verwaltet eine Kantenmenge A , die den minimalen Spannbaum Kante für Kante aufbaut. ◮ A ist stets Teilmenge eines minimalen Spannbaums. ◮ Eine Kante e ∈ E , die zu A hinzugefügt werden kann, ohne die Eigenschaft zu verletzen, dass A Teilmenge eines minimalen Spannbaums ist, heißt sichere Kante für A . proc MST-Basis(G) begin A ← ∅; while A bildet keinen Spannbaum do bestimme eine Kante e ∈ E , die sicher für A ist; A ← A ∪ {e}; od; return A; end 8.4 Ausgewählte Algorithmen für gewichtete Graphen 8-48 Basisalgorithmus Satz: Es sei G = (V , E ) ein zusammenhängender, gewichteter Graph. A sei eine Teilmenge eines minimalen Spannbaums und C = (VC , EC ) eine Zusammenhangskomponente aus dem Wald GA = (V , A ). Dann gilt: Falls e ∈ E eine Kante mit minimalen Gewicht ist, die C mit einer anderen Komponente von GA verbindet, dann ist e sicher für A . Beweis: s. Cormen, S. 569 f. 8.4 Ausgewählte Algorithmen für gewichtete Graphen 8-49 Operationen für disjunkte Mengen ◮ MakeSet(x) erzeugt die einelementige Menge {x }. x darf nicht bereits in einer anderen Menge enthalten sein. ◮ Union(x,y) bildet die Vereinigungsmenge x ∪ y . Es wird x ∩ y = ∅ vorausgesetzt. ◮ FindSet(x) liefert einen Zeiger auf den Repäsentanten der eindeutig bestimmten Menge, die x enthält. 8.4 Ausgewählte Algorithmen für gewichtete Graphen 8-50 Algorithmus von Kruskal ◮ Selektiere fortwährend eine verbleibende Kante mit geringstem Gewicht, die keinen Zyklus erzeugt, bis alle Knoten verbunden sind (Kruskal, 1956). ◮ Eine eindeutige Lösung ist immer dann vorhanden, wenn alle Gewichte verschieden sind. 4 3 8 3 2 6 6 4 3 5 5 7 6 2 Nach Wahl der Kanten 2, 2, 3 und 3 darf die verbleibende 3 nicht gewählt werden, da sonst ein Zyklus entstünde. 8.4 Ausgewählte Algorithmen für gewichtete Graphen 8-51 Algorithmus von Kruskal proc MST-Kruskal(G) begin A ← ∅; foreach v ∈ V do MakeSet(v) od; sortiere die Kanten aufsteigend nach ihrem Gewicht; foreach (u,v) ∈ E do if FindSet(u) , FindSet(v) then A ← A ∪ {(u,v)} UnionSet(FindSet(u), FindSet(v)); fi; od; return A; end 8.4 Ausgewählte Algorithmen für gewichtete Graphen 8-52 Algorithmus von Kruskal ◮ A ist zu jedem Zeitpunkt ein Wald, dessen Komponenten nach und nach zu einem minimalen Spannbaum verbunden werden. ◮ Die Laufzeit hängt von der Implementierung der disjunkten Mengen ab. ◮ Bei einer geeigneten Realisierung der disjunkten Mengen liegt die Laufzeit des Algorithmus von Kruskal in O (|E | log |V |). 8.4 Ausgewählte Algorithmen für gewichtete Graphen 8-53 Algorithmus von Prim ◮ Beim Algorithmus von Prim bildet die Kantenmenge A stets einen Baum. ◮ Der Baum startet bei einem beliebigen Wurzelknoten und wächst, bis er V aufspannt. ◮ In jedem Schritt wird eine Kante hinzugefügt, die A mit einem isolierten Knoten von GA = (V , A ) verbindet und die bezüglich dieser Eigenschaft minimal ist. ◮ Der Algorithmus verwendet zur Verwaltung der Knoten eine Min-Prioritätswarteschlange Q , die auf einem Attribut schlüssel basiert. Für jeden Knoten v ist schlüssel(v) das kleinste Gewicht aller Kanten, die v mit einem Knoten des Baums verbinden. ◮ p (v ) bezeichnet den Vater von v . 8.4 Ausgewählte Algorithmen für gewichtete Graphen 8-54 Algorithmus von Prim proc MST-Prim(G,r) begin foreach u ∈ V do schlüssel(u) ← ∞; p(u) ← nil; od; schlüssel(r) ← 0; Q ← V; while Q , ∅ do u ← ExtractMin(Q); foreach v ∈ nk(u) do if v ∈ Q und w(u,v) < schlüssel(v) then p(v) ← u; schlüssel(v) ← w(u,v); fi; od; od; end 8.4 Ausgewählte Algorithmen für gewichtete Graphen 8-55 Algorithmus von Prim ◮ Die Laufzeit des Algorithmus von Prim hängt von der Implementierung der Min-Prioritätswarteschlange Q ab. ◮ Die Anweisung schlüssel(v) ← w(u,v) ist beispielsweise eine Decrease-Operation. ◮ Wenn Q als binärer Min-Heap realisiert wird, liegt die Laufzeit des Algorithmus von Prim in O (|E | log |V |). Dies entspricht der Laufzeit des Algorithmus von Kruskal. ◮ Durch Verwendung von so genannten Fibonacci-Heaps kann die Laufzeit des Algorithmus von Prim auf O (|E | + |V | log |V |) verbessert werden. 8.4 Ausgewählte Algorithmen für gewichtete Graphen 8-56 Problem der kürzesten Pfade bei einem Startknoten ◮ ◮ Gegeben ist ein gewichteter Graph G = (V , E , w ) und ein Startknoten s ∈ V . Gesucht ist für jeden Knoten v ∈ V ein Pfad p = (v0 , . . . , vk ) von s = v0 nach v = vk , dessen Gewicht w (p ) = k X i =1 ◮ w (vi −1 , vi ) minimal wird. Falls kein Pfad von s nach v existiert, sei das Gewicht ∞. Die Gewichte können im Allgemeinen negativ sein. 8.4 Ausgewählte Algorithmen für gewichtete Graphen 8-57 Problem der kürzesten Pfade bei einem Startknoten Beispiel: Bestimme den kürzesten Weg von Frankfurt nach Celle Augsburg 3 2 9 9 4 Frankfurt Braunschweig 9 1 8 Erfurt 2 3 2 8 Celle 9 3 6 Darmstadt Der kürzester Weg ist (Frankfurt, Augsburg, Braunschweig, Celle). Er hat das Gewicht 6. 8.4 Ausgewählte Algorithmen für gewichtete Graphen 8-58 Bellmann-Ford-Algorithmus ◮ Der Algorithmus verwendet so genannte Relaxationen und bestimmt so immer kleiner werdende Schätzungen d (v ) für das Gewicht eines kürzesten Pfads vom Startknoten s aus zu allen Knoten v ∈ V , bis er das tatsächliche Gewicht erreicht hat. ◮ Der Algorithmus gibt genau dann wahr zurück, wenn der Graph keine Zyklen mit negativem Gewicht enthält, die von s aus erreichbar sind. ◮ p (v ) ist wie bisher der Vaterknoten von v . ◮ Der Algorithmus führt |V | − 1 Durchläufe über die Kanten des Graphen aus. 8.4 Ausgewählte Algorithmen für gewichtete Graphen 8-59 Bellmann-Ford-Algorithmus Initialisierung: proc Init(G,s) begin foreach v ∈ V do d(v) ← ∞; p(v) ← nil; od; d(s) ← 0; end Relaxation: proc Relax(u,v,w) begin if d(v) > d(u) + w(u,v) then d(v) ← d(u) + w(u,v); p(v) ← u; fi; end 8.4 Ausgewählte Algorithmen für gewichtete Graphen 8-60 Bellmann-Ford-Algorithmus proc Bellmann-Ford(G, s) boolean begin Init(G,s); for i ← 1 to |V| - 1 do foreach (u,v) ∈ E do Relax(u,v,w); od; od; foreach (u,v) ∈ E do if d(v) > d(u) + w(u,v) then return false; fi; od; return true; end Die Laufzeit des Bellmann-Ford-Algorithmus liegt in O (|E | · |V |). 8.4 Ausgewählte Algorithmen für gewichtete Graphen 8-61 Algorithmus von Dijkstra ◮ Der Algorithmus von Dijkstra löst das Problem der kürzesten Pfade bei einem Startknoten, falls alle Gewichte nichtnegativ sind. ◮ Wir setzen daher w (e ) ≥ 0 für alle e ∈ E voraus. ◮ Die Laufzeit des Dijkstra-Algorithmus ist bei guter Implementierung besser als die des Bellmann-Ford-Algorithmus. 8.4 Ausgewählte Algorithmen für gewichtete Graphen 8-62 Algorithmus von Dijkstra ◮ Der Dijkstra-Algorithmus verwaltet eine Menge S von Knoten, deren endgültige Gewichte der kürzesten Pfade vom Startknoten aus bereits bestimmt wurden. ◮ Der Algorithmus wählt in jedem Schritt denjenigen Knoten u ∈ V \ S mit der kleinsten Schätzung des kürzesten Pfads aus, fügt u zu S hinzu und relaxiert alle aus u austretenden Kanten ◮ In der Implementierung wird eine Min-Prioritätswarteschlange Q für Knoten verwendet. Dabei dienen die d -Werte als Schlüssel. 8.4 Ausgewählte Algorithmen für gewichtete Graphen 8-63 Algorithmus von Dijkstra proc Dijkstra(G, s) begin Init(G,s); S ← ∅; Q ← V; while Q , ∅ do u ← ExtractMin(Q); S ← S ∪ {u}; foreach v ∈ nk(u) Relax(u,v,w); od; od; end Die Laufzeit des Dijkstra-Algorithmus hängt von der Implementierung der Min-Prioritätswarteschlange Q ab. Bei guter Implementierung von Q liegt die Laufzeit des Dijkstra-Algorithmus in O (|E | + |V | · log |V |). 8.4 Ausgewählte Algorithmen für gewichtete Graphen 8-64 Algorithmus von Dijkstra t ∞ x ∞ 1 t 10 3 2 5 9 6 4 s 0 3 5 y Q = [s,t,x,y,z] t 8 2 6 4 s 0 5 5 y 5 9 6 4 2 7 z Q = [t,x] 8.4 Ausgewählte Algorithmen für gewichtete Graphen x 9 1 10 3 2 5 7 5 y s 0 7 z 2 t 8 10 2 4 Q = [z,t,x] x 9 1 6 7 ∞ z 2 t 8 10 3 2 9 3 Q = [y,t,x,z] x 13 1 9 7 ∞ z 2 x 14 1 10 5 7 ∞ y s 0 t 8 10 10 s 0 x ∞ 1 9 6 4 2 Q = [x] 3 2 5 7 5 y s 0 7 z 9 6 4 7 5 y 2 7 z Q = [] 8-65 Ausblick Die Graphentheorie ist ein umfangreiches Gebiet, in dem viele weitere Fragestellungen untersucht werden. Wir stellen drei davon kurz vor: ◮ Problem des Handlungsreisenden, ◮ planare Graphen, ◮ Färbungen von Graphen. 8.4 Ausgewählte Algorithmen für gewichtete Graphen 8-66 Problem des Handlungsreisenden ◮ ◮ Gegeben seien n durch Straßen verbundene Städte mit Reisekosten c (i , j ) zwischen je zwei Städten i und j , 1 ≤ i , j ≤ n. Gesucht ist die billigste Rundreise, die jede Stadt genau einmal besucht (Traveling Salesman Problem, TSP). Augsburg 3 2 9 9 4 Frankfurt Braunschweig 9 1 8 Erfurt 2 3 2 8 Celle 9 3 6 Darmstadt Die billigste Rundreise kostet 13 Einheiten. 8.4 Ausgewählte Algorithmen für gewichtete Graphen 8-67 Planare Graphen ◮ Gegeben sei ein beliebiger Graph G . Lässt sich G planar zeichnen, das heißt, ohne sich schneidende Kanten? ◮ Im Beispiel unten ist dies möglich, im Allgemeinen jedoch nicht. ◮ Anwendung: Chip- oder Leiterplattendesign. Leiterbahnen sollen möglichst kreuzungsfrei gestaltet werden. 8.4 Ausgewählte Algorithmen für gewichtete Graphen 8-68 Färbungen von Graphen ◮ ◮ ◮ Gegeben sei ein Graph G . Die Knoten von G sollen derart gefärbt werden, dass benachbarte Knoten verschiedene Farben besitzen. Wie viele Farben werden benötigt? Im Beispiel unten reichen bereits drei Farben. Für planare Graphen werden im Allgemeinen vier Farben benötigt. Dieses Ergebnis wurde 1976 von K. Appel und W. Haken gezeigt (Vierfarbenproblem). Der Beweis war sehr umfangreich und computergestützt. Anwendungen: Einfärben von Landkarten (Knoten , Land, Kante , Grenze), Vergabe überschneidungsfreier Klausurtermine (Knoten , Fach, Kante , beide Fächer werden vom gleichen Studenten gehört, Farbe , Termin) 8.4 Ausgewählte Algorithmen für gewichtete Graphen 8-69 1. Der Algorithmenbegriff 2. Imperative Algorithmen 3. Sortieralgorithmen 4. Listen und abstrakte Datentypen 5. Objektorientierte Algorithmen 6. Bäume 7. Mengen, Verzeichnisse und Hash-Verfahren 8. Graphen 9. Entwurf von Algorithmen 9.1 Einführung 9.2 Teile-und-Beherrsche-Algorithmen 9.3 Gierige Algorithmen 9.4 Backtracking-Algorithmen 9.5 Dynamische Programmierung 10. Funktionale und deduktive Algorithmen Entwurf von Algorithmen In diesem Kapitel stellen wir anhand von Beispielen einige typische Prinzipien für den Entwurf von Algorithmen vor. Die folgenden Techniken haben wir (implizit oder explizit) bereits kennen gelernt. 9.1 Einführung ◮ Schrittweise Verfeinerung des Problems ◮ Reduzierung der Problemgröße durch Rekursion ◮ Einsatz von Algorithmenmustern 9-1 Schrittweise Verfeinerung des Problems 9.1 Einführung ◮ Die erste Formulierung des Problems erfolgt in einem sehr abstrakten Pseudocode. ◮ Die schrittweise Verfeinerung basiert auf dem Ersetzen von Pseudocode durch verfeinerten Pseudocode ◮ und letztlich durch konkrete Algorithmenschritte. 9-2 Problemreduzierung durch Rekursion 9.1 Einführung ◮ Diese Technik kann angewendet werden, wenn das Problem auf ein gleichartiges, aber kleineres Problem zurückgeführt werden kann. ◮ Die Rekursion muss schließlich auf ein oder mehrere kleine Probleme führen, die sich direkt lösen lassen. ◮ Rekursion bietet sich an, wenn die Problemstruktur rekursiv aufgebaut ist. Beispiele: Listen, Bäume. ◮ Zu rekursiven Lösungen gibt es iterative Entsprechungen (zum Beispiel durch Einsatz eines Kellers, s. Aufgabe 23). Bei der Auswahl zwischen iterativer und rekursiver Lösung ist die Effizienz der Realisierung zu berücksichtigen. 9-3 Einsatz von Algorithmenmustern Beispiele für Algorithmenmuster: ◮ Inkrementelle Vorgehensweise ◮ Teile-und-Beherrsche-Algorithmen ◮ Gierige Algorithmen (Greedy Algorithmen) ◮ Backtracking-Algorithmen ◮ Dynamische Programmierung Die Zuordnung eines Musters zu einem Algorithmus ist nicht immer eindeutig – und manchmal sogar unmöglich. Beispielsweise kann der Algorithmus von Kruskal als inkrementeller und als gieriger Algorithmus gesehen werden. Es gibt weitere Algorithmenmuster. 9.1 Einführung 9-4 Inkrementelle Vorgehensweise Beispiel: Sortieren durch Einfügen benutzt eine inkrementelle Herangehensweise. Nachdem das Teilfeld a [1..j − 1] sortiert wurde, wird das Element a [j ] an der richtigen Stelle eingefügt, woraus sich das sortierte Teilfeld a [1..j ] ergibt. Weitere Beispiele: ◮ Algorithmus von Kruskal ◮ Algorithmus von Prim Beide Algorithmen bauen schrittweise einen minimalen Spannbaum auf. 9.1 Einführung 9-5 Teile-und-Beherrsche-Algorithmen ◮ Teile das Problem in eine Anzahl von Teilproblemen auf. ◮ Beherrsche die Teilprobleme durch rekusives Lösen. Wenn die Teilprobleme hinreichend klein sind, dann löse sie auf direktem Wege. ◮ Verbinde die Lösungen der Teilprobleme zur Lösung des Ausgangsproblems. 9.2 Teile-und-Beherrsche-Algorithmen 9-6 Beispiel: Sortieren durch Mischen Sortieren durch Mischen (Mergesort, vgl. Abschnitt 3.2) arbeitet rekursiv nach folgendem Schema: 1. Teile die Folge in zwei Teilfolgen auf. 2. Sortiere die beiden Teilfolgen. 3. Mische die sortierten Teilfolgen. 9.2 Teile-und-Beherrsche-Algorithmen 4 2 9 4 2 9 2 4 1 5 2 1 6 5 8 2 1 6 5 9 1 2 6 8 2 2 6 8 9 4 8 5 9-7 Beispiel: Sortieren durch Mischen ◮ Alternativ könnte man die Liste auch in mehr als zwei Listen aufteilen, hätte dann aber in der Mischphase größeren Aufwand. ◮ Allgemein: Die Rekursionstiefe kann durch stärkere Spaltung verringert werden. Dies bedingt allerdings einen größeren Aufwand in der Teile- und der Zusammenführungsphase. 9.2 Teile-und-Beherrsche-Algorithmen 9-8 Beispiel: Türme von Hanoi ◮ n Scheiben verschiedener Größe sind aufeinandergestapelt. Es liegen stets nur kleinere Scheiben auf größeren. ◮ Der gesamte Stapel soll Scheibe für Scheibe umgestapelt werden. ◮ Ein dritter Stapel darf zur Zwischenlagerung benutzt werden, ansonsten dürfen die Scheiben nirgendwo anders abgelegt werden. ◮ Auch in jedem Zwischenzustand dürfen nur kleinere Scheiben auf größeren liegen. 9.2 Teile-und-Beherrsche-Algorithmen 9-9 Beispiel: Türme von Hanoi Gesucht ist ein Algorithmus, der dieses Problem löst. Dazu muss der Algorithmus angeben, in welcher Reihenfolge die Scheiben zu bewegen sind. 1. Bringe die obersten n − 1 Scheiben von Turm 1 zu Turm 3. 2. Bewege die unterste Scheibe von Turm 1 zu Turm 2. 3. Bringe die n − 1 Scheiben von Turm 3 zu Turm 2. 1 2 9.2 Teile-und-Beherrsche-Algorithmen 3 9-10 Beispiel: Türme von Hanoi 1. Bringe die obersten n − 1 Scheiben von Turm 1 zu Turm 3. 2. Bewege die unterste Scheibe von Turm 1 zu Turm 2. 3. Bringe die n − 1 Scheiben von Turm 3 zu Turm 2. proc hanoi(n: int; t1, t2, t3: Turm) begin if n > 1 then hanoi(n-1, t1, t3, t2); fi; <bewege die Scheibe von t1 nach t2>; if n > 1 then hanoi(n-1, t3, t2, t1); fi; end 9.2 Teile-und-Beherrsche-Algorithmen 9-11 Beispiel: Türme von Hanoi ◮ Diese Lösung besteht aus einer rekursiven Prozedur hanoi(n, t1, t2, t3). ◮ Die Problemgröße ist n. ◮ Der Aufruf hanoi(n, t1, t2, t3) bewegt den Stapel von t1 nach t2 und verwendet t3 als Hilfsstapel. ◮ Für die Anzahl T (n) der notwendigen Schritte gilt ◮ 1 T (n) = 2T (n − 1) + 1 für n = 1, für n > 1. Explizit ergibt sich T (n) = 2n − 1. 9.2 Teile-und-Beherrsche-Algorithmen 9-12 Komplexität von Teile-und-Beherrsche-Algorithmen Die Problemgröße sei durch eine natürliche Zahl n gegeben. Die Berechnung der Komplexität führt häufig auf Rekurrenzgleichungen der Form oder falls n klein ist, Θ(1), T (n) = aT (n/b ) + f (n), falls n groß genug ist, T (n) = f (T (n − 1), . . . , T (n − k )) mit gegebenen Anfangswerten T (0),. . . ,T (k − 1). Beispiel: Sortieren durch Mischen, Türme von Hanoi. Wir wiederholen jetzt das Mastertheorem und das Verfahren zur Lösung linearer Rekurrenzgleichungen mit konstanten Koeffizienten. 9.2 Teile-und-Beherrsche-Algorithmen 9-13 Beispiel: Die Multiplikation nach Karatsuba ◮ Wie groß ist die Komplexität des klassischen Verfahrens zur Multiplikation natürlicher Zahlen? ◮ Wenn der erste Faktor n-stellig und der zweite m-stellig ist, dann müssen zuerst n · m Einzelmultiplikationen durchgeführt werden. Anschließend sind m Zahlen der Maximallänge n + m zu addieren. Das Ergebnis ist im Allgemeinen eine (n + m)-stellige Zahl. ◮ Den größten Anteil trägt offenbar das Produkt n · m bei. ◮ Die Komplexität des Verfahrens liegt daher in Θ(n · m) bzw. in Θ(n2 ), wenn beide Zahlen die Länge n besitzen. 9.2 Teile-und-Beherrsche-Algorithmen 9-14 Beispiel: Die Multiplikation nach Karatsuba ◮ Im Jahre 1962 stellte A. Karatsuba ein schnelleres Verfahren zur Multiplikation vor. ◮ Die Idee besteht darin, die Zahlen x und y der Länge ≤ n in Stücke der Länge ≤ n/2 aufzuteilen, sodass x y = a · 10n/2 + b = c · 10n/2 + d gilt. ◮ Beispiel: n = 4, 9.2 Teile-und-Beherrsche-Algorithmen x = 3141 = 31 · 102 + 41 9-15 Beispiel: Die Multiplikation nach Karatsuba ◮ Wir erhalten: x ·y = (a · 10n/2 + b )(c · 10n/2 + d ) = ac · 10n + (ad + bc ) · 10n/2 + bd = ac · 10n + ((a + b )(c + d ) − ac − bd ) · 10n/2 + bd ◮ ◮ Die Berechnung des Produkts zweier Zahlen x und y der Länge ≤ n wird zurückgeführt auf die Berechnung der drei Produkte ac , bd und (a + b )(c + d ) der Länge ≤ n/2. Dann wird dasselbe Verfahren rekursiv auf diese drei Produkte angewendet. 9.2 Teile-und-Beherrsche-Algorithmen 9-16 Beispiel: Die Multiplikation nach Karatsuba Beispiel: x = 3141, y = 5927 x ·y = 3141 · 5927 = 31 · 59 · 104 + ((31 + 41)(59 + 27) − 31 · 59 − 41 · 27) · 102 + 41 · 27 = 31 · 59 · 104 + (72 · 86 − 31 · 59 − 41 · 27) · 102 + 41 · 27 = 1829 · 104 + (6192 − 1829 − 1107) · 102 + 1107 = 1829 · 104 + 3256 · 102 + 1107 = 18616707 9.2 Teile-und-Beherrsche-Algorithmen 9-17 Beispiel: Die Multiplikation nach Karatsuba ◮ ◮ Für die Komplexität T (n) des Verfahrens gilt: n=1 k , T (n) = 3 · T n + kn, n > 1 2 Diese Rekurrenzgleichung besitzt die Lösung T (n) = 3kn ◮ log2 (3) log2 (3) 1,585 =Θ n . − 2kn = Θ n Das ist deutlich günstiger als Θ n2 . Allerdings wirken sich die Verbesserungen erst bei großen Zahlen aus. 9.2 Teile-und-Beherrsche-Algorithmen 9-18 Beispiel: Die Multiplikation nach Karatsuba ◮ Wir haben oben die Faktoren x und y in je zwei Teile zerlegt. Durch Aufspalten in noch mehr Teile können wir die Laufzeit weiter verbessern: Für jedes ε > 0 gibt es ein Multiplikationsverfahren, das höchstens c (ε)n1+ε Schritte benötigt. Die Konstante c (ε) hängt nicht von n ab. ◮ In den 1970er Jahren wurde diese Schranke auf O (n log(n) log(log(n))) verbessert. 9.2 Teile-und-Beherrsche-Algorithmen 9-19 Gierige Algorithmen Annahmen: ◮ Es gibt eine endliche Menge von Eingabewerten. ◮ Es gibt eine Menge von Teillösungen, die aus den Eingabewerten berechnet werden können. ◮ Es gibt eine Bewertungsfunktion für Teillösungen. ◮ Die Lösungen lassen sich schrittweise aus Teillösungen, beginnend bei der leeren Lösung, durch Hinzunahme von Eingabewerten ermitteln. ◮ Gesucht wird eine/die optimale Lösung. Vorgehensweise: ◮ Nimm (gierig) immer das am besten bewertete Stück. 9.3 Gierige Algorithmen 9-20 Beispiel: Algorithmus zum Geldwechseln ◮ Münzwerte: 1, 2, 5, 10, 20, 50 Cent und 1, 2 Euro. ◮ Wechselgeld soll mit möglichst wenig Münzen ausgezahlt werden. 1,42 €: 1 € + 20 Cent + 20 Cent + 2 Cent Allgemein: Wähle im nächsten Schritt die größtmögliche Münze. In unserem Münzsystem gibt diese Vorgehensweise immer die optimale Lösung. Im Allgemeinen gilt dies nicht. Angenommen, es stünden 1, 5 und 11 Cent Münzen zur Verfügung. Um 15 Cent herauszugeben, ergäbe sich: ◮ gierig: 11 + 1 + 1 + 1 + 1, ◮ optimal: 5 + 5 + 5. 9.3 Gierige Algorithmen 9-21 Gierige Algorithmen func greedy(E: Eingabemenge): Ergebnis begin var L: Ergebnismenge; var x: Element; E.sort(); while! E.empty() do x ← E.first(); E.remove(x); if valid(L ∪ {x }) then L.add(x); fi; od; return L; end 9.3 Gierige Algorithmen 9-22 Beispiel: Bedienreihenfolge im Supermarkt ◮ n Kunden warten vor einer Kasse. ◮ Der Bezahlvorgang von Kunde i dauere ci Zeiteinheiten. ◮ Welche Reihenfolge der Bedienung der Kunden führt zur Minimierung der mittleren Verweilzeit (über alle Kunden)? Die Gesamtbedienzeit Tges = Die mittlere Verweilzeit ist Pn i =1 ci ist konstant. 1 T = (c1 + (c1 + c2 ) + · · · + (c1 + · · · + cn )) n 1 = (nc1 + (n − 1)c2 + (n − 2)c3 + · · · + 2cn−1 + cn ) n n 1X (n − k + 1)ck = n k =1 9.3 Gierige Algorithmen 9-23 Beispiel: Bedienreihenfolge im Supermarkt Die mittlere Verweilzeit pro Kunde ◮ steigt, wenn Kunden mit langer Bedienzeit vorgezogen werden. ◮ sinkt, wenn Kunden mit kurzer Bedienzeit zuerst bedient werden. ◮ wird minimal, wenn die Kunden nach ci aufsteigend sortiert werden. Konsequenzen: ◮ Greedy-Algorithmus ist geeignet. ◮ Die Funktion zur Bestimmung des nächsten Kandidaten wählt den Kunden mit minimaler Bedienzeit. Frage: Ist dies eine geeignete Strategie für die Prozessorvergabe? 9.3 Gierige Algorithmen 9-24 Beispiel: Algorithmus von Kruskal ◮ Selektiere fortwährend eine verbleibende Kante mit geringstem Gewicht, die keinen Zyklus erzeugt, bis alle Knoten verbunden sind (Kruskal, 1956). ◮ Eine eindeutige Lösung ist immer dann vorhanden, wenn alle Gewichte verschieden sind. 4 3 8 3 2 6 6 4 3 5 5 7 6 2 Nach Wahl der Kanten 2, 2, 3 und 3 darf die verbleibende 3 nicht gewählt werden, da sonst ein Zyklus entstünde. 9.3 Gierige Algorithmen 9-25 Matroide ◮ Greedy-Algorithmen liefern nicht immer eine optimale Lösung. ◮ Mithilfe der Theorie der gewichteten Matroide kann bestimmt werden, wann ein Greedy-Algorithmus eine optimale Lösung liefert. ◮ Die Theorie der bewerteten Matroide deckt nicht alle Fälle ab. 9.3 Gierige Algorithmen 9-26 Matroide Ein Matroid ist ein Paar M = (S , I) mit folgenden Eigenschaften: ◮ S ist eine endliche Menge. ◮ I ist eine nichtleere Familie von Teilmengen von S . ◮ ◮ Vererbungseigenschaft: Sind A ⊆ B und B ∈ I, so ist A ∈ I. Austauscheigenschaft: Sind A , B ∈ I und |A | < |B |, so gibt es ein x ∈ (B \ A ) mit A ∪ {x } ∈ I. Die Mengen in I heißen unabhängig. Eine unabhängige Menge A ∈ I heißt maximal, wenn es keine Erweiterung x mit A ∪ {x } ∈ I gibt. 9.3 Gierige Algorithmen 9-27 Matroide ◮ ◮ Ein Matroid M = (S , I) heißt gewichtet, wenn es eine Gewichtsfunktion w : S → R+ gibt. Die Gewichtsfunktion lässt sich auf Teilmengen A ⊆ S durch w (a ) = X w (A ) x ∈A erweitern. ◮ Eine Menge A ∈ I mit maximalem Gewicht heißt optimal. 9.3 Gierige Algorithmen 9-28 Matroide Satz: Es sei M = (S , I) ein gewichtetes Matroid mit der Gewichtsfunktion w : S → R+ . Der folgende gierige Algorithmus gibt eine optimale Teilmenge zurück. func greedy(M, w): I begin A ← ∅; sortiere S in monoton fallender Reihenfolge nach dem Gewicht w; foreach x ∈ S do if A ∪ {x} ∈ I then A ← A ∪ {x}; return A; end 9.3 Gierige Algorithmen 9-29 Matroide Satz: Die Komplexität des gierigen Algorithmus liegt in O (n log n + n · f (n)), wobei ◮ n log n der Aufwand für das Sortieren und ◮ f (n) der Aufwand für den Test A ∪ {x } ist. n ist die Anzahl der Elemente von S , d. h. |S | = n. 9.3 Gierige Algorithmen 9-30 Matroide Beispiel: Es sei G = (V , E ) ein ungerichteter Graph. Dann ist MG = (SG , IG ) ein Matroid, dabei gilt: ◮ SG = E , ◮ A ⊆ E : A ∈ IG ⇔ A azyklisch. Eine Menge A von Kanten ist genau dann unabhängig, wenn der Graph GA = (V , A ) einen Wald bildet. Der Algorithmus von Kruskal ist ein Beispiel für das obige allgemeine Verfahren. 9.3 Gierige Algorithmen 9-31 Backtracking-Algorithmen ◮ Das Backtracking realisiert eine systematische Suchtechnik, die die Menge aller möglichen Lösungen eines Problems vollständig durchsucht. ◮ Führt die Lösung auf einem Weg nicht zum Ziel, wird zur letzten Entscheidung zurückgegangen und dort eine Alternative untersucht. ◮ Da alle möglichen Lösungen untersucht werden, wird eine Lösung – wenn sie existiert – stets gefunden. 9.4 Backtracking-Algorithmen 9-32 Beispiel: Wegsuche in einem Labyrinth Gesucht ist ein Weg in einem Labyrinth von einem Start- zu einem Zielpunkt. ◮ Gehe zur ersten Kreuzung und schlage dort einen Weg ein. ◮ Markiere an jeder Kreuzung den eingeschlagenen Weg. ◮ Falls eine Sackgasse gefunden wird, gehe zurück zur letzten Kreuzung, die einen noch nicht untersuchten Weg aufweist und gehe diesen Weg. 9.4 Backtracking-Algorithmen 9-33 Beispiel: Wegsuche in einem Labyrinth ◮ Die besuchten Kreuzungspunkte werden als Knoten eines Baumes aufgefasst. ◮ Der Startpunkt bildet die Wurzel dieses Baumes. Blätter sind die Sackgassen und der Zielpunkt. ◮ Der Baum wird beginnend mit der Wurzel systematisch aufgebaut. ◮ Wegpunkte sind Koordinatentupel (x , y ). Im Beispiel ist (1, 1) der Startpunkt und (1, 3) der Zielpunkt. (1,1) (2,1) (2,2) (1,2) (2,3) (3,3) 9.4 Backtracking-Algorithmen (3,1) (3,2) (1,3) 9-34 Backtracking-Algorithmen ◮ ◮ Es gibt eine endliche Menge K von Konfigurationen. K ist hierarchisch strukturiert: ◮ Es gibt eine Ausgangskonfiguration k0 ∈ K . ◮ Zu jeder Konfiguration kx ∈ K gibt es eine Menge kx , . . . , kx 1 nx von direkt erreichbaren Folgekonfigurationen. ◮ Für jede Konfiguration ist entscheidbar, ob sie eine Lösung ist. ◮ Gesucht werden Lösungen, die von k0 aus erreichbar sind. 9.4 Backtracking-Algorithmen 9-35 Backtracking-Algorithmen proc backtrack(k: Konfiguration) Konfiguration begin if k ist Lösung then print(k); fi; foreach Folgekonfiguration k’ von k do backtrack(k’); od; end ◮ Dieses Schema terminiert nur, wenn der Lösungsraum endlich und wiederholte Bearbeitung einer bereits getesteten Konfiguration ausgeschlossen ist (keine Zyklen). ◮ Kritisch ist ggf. der Aufwand. Er ist häufig exponentiell. 9.4 Backtracking-Algorithmen 9-36 Backtracking-Algorithmen (Varianten) ◮ Lösungen werden bewertet. Zuletzt wird die beste ausgewählt. ◮ Das angegebene Schema findet alle Lösungen. Oft genügt es, den Algorithmus nach der ersten Lösung zu beenden. ◮ Aus Komplexitätsgründen wird eine maximale Rekursionstiefe vorgegeben. Als Lösung dient dann beispielsweise die am besten bewertete Lösung aller bisher gefundenen. ◮ Branch-and-Bound-Algorithmen. 9.4 Backtracking-Algorithmen 9-37 Branch-and-Bound-Algorithmen ◮ Das angegebene Schema untersucht jeden Konfigurationsteilbaum. ◮ Oft kann man schon im Voraus entscheiden, dass es sich nicht lohnt, einen bestimmten Teilbaum zu besuchen. ◮ Dies ist zum Beispiel bei einer Sackgasse der Fall oder wenn man weiß, dass die zu erwartende Lösung auf jeden Fall schlechter sein wird, als eine bisher gefundene. ◮ In diesem Fall kann auf die Bearbeitung des Teilbaums verzichtet werden. 9.4 Backtracking-Algorithmen 9-38 Branch-and-Bound-Algorithmen Beispiele: ◮ Spiele (insbesondere rundenbasierte Strategiespiele), zum Beispiel Schach. Konfigurationen entsprechen den Stellungen, Nachfolgekonfigurationen sind durch die möglichen Spielzüge bestimmt. Nachweisbar schlechte Züge müssen nicht untersucht werden. ◮ Erfüllbarkeitstests von logischen Aussagen. ◮ Planungsprobleme. ◮ Optimierungsprobleme. 9.4 Backtracking-Algorithmen 9-39 Beispiel: Das N-Damen-Problem Es sollen alle Stellungen von n Damen auf einem n × n-Schachbrett ermittelt werden, bei denen keine Dame eine andere bedroht. Es dürfen also nicht zwei Damen in der gleichen Zeile, Spalte oder Diagonale stehen. 8 7 6 5 4 3 2 1 8 7 6 5 4 3 2 1 1 2 3 4 5 6 7 8 9.4 Backtracking-Algorithmen 1 2 3 4 5 6 7 8 9-40 Beispiel: Das N-Damen-Problem ◮ ◮ ◮ K sei die Menge aller Stellungen mit einer Dame in jeder der ersten m Zeilen, 0 ≤ m ≤ n, sodass je zwei Damen sich nicht bedrohen. K enthält alle Lösungen. Nicht jede Stellung lässt sich allerdings zu einer Lösung erweitern. So ist zum Beispiel unten jedes Feld in der 7. Zeile bereits bedroht, sodass dort keine Dame mehr gesetzt werden kann. Durch Ausnutzung von Symmetrien lässt sich der Aufwand verringern. 8 7 6 5 4 3 2 1 1 2 3 4 5 6 7 8 9.4 Backtracking-Algorithmen 9-41 Beispiel: Das N-Damen-Problem proc platziere(zeile: int) begin var i: int; for i ← 1 to n do if <feld (zeile, i) nicht bedroht> then <setze Dame auf (zeile, i)>; if zeile = n then <gib Konfiguration aus>; else platziere(zeile + 1); fi; fi; od; end 9.4 Backtracking-Algorithmen 9-42 Beispiel: Das N-Damen-Problem 4 3 2 1 4 3 2 1 4 3 2 1 Sackgasse 1 2 3 4 1 2 3 4 4 3 2 1 4 3 2 1 1 2 3 4 Sackgasse 1 2 3 4 Lösung 1 2 3 4 4 3 2 1 4 3 2 1 1 2 3 4 9.4 Backtracking-Algorithmen 4 3 2 1 1 2 3 4 4 3 2 1 1 2 3 4 1 2 3 4 9-43 Beispiel: Das N-Damen-Problem ◮ Das N-Damen-Problem ist für n ≥ 4 lösbar. Wenn die erste Dame nicht richtig gesetzt ist, werden allerdings bis zu (n − 1)! Schritte benötigt, um dies herauszufinden. Nach der stirlingschen Formel ist n n! ≈ n · e −n √ 2πn, der Aufwand also exponentiell. ◮ Für jedes n ≥ 4 ist ein Verfahren bekannt (Ahrens, 1912), das in linearer Zeit eine Lösung findet (nur eine, nicht alle). Es basiert auf der Beobachtung, dass in Lösungsmustern häufig Rösselsprung-Sequenzen auftreten. ◮ Im Jahre 1990 ist ein schneller probabilistischer Algorithmus veröffentlicht worden, dessen Laufzeit in O (n3 ) liegt. 9.4 Backtracking-Algorithmen 9-44 Beispiel: Problem des Handlungsreisenden ◮ ◮ ◮ ◮ Gegeben seien n durch Straßen verbundene Städte mit Reisekosten c (i , j ) zwischen je zwei Städten i und j , 1 ≤ i , j ≤ n. Gesucht ist die billigste Rundreise, die jede Stadt genau einmal besucht (Traveling Salesman Problem, TSP). Eine solche Kantenfolge heißt hamiltonscher Zyklus. Dieser Graph ist vollständig. Augsburg 3 2 9 9 4 Frankfurt Braunschweig 9 1 8 Erfurt 2 3 2 8 Celle 9 3 6 Darmstadt Die billigste Rundreise kostet 13 Einheiten. 9.4 Backtracking-Algorithmen 9-45 Beispiel: Problem des Handlungsreisenden ◮ Ein naiver Algorithmus beginnt bei einem Startknoten und sucht dann alle Wege ab. ◮ Die Komplexität dieses Verfahrens liegt in O (n!). Das folgende Verfahren führt zu einer Näherungslösung: ◮ 1. Die Kanten werden nach ihren Kosten sortiert. 2. Man wählt die billigste Kante unter den beiden folgenden Nebenbedingungen: ◮ ◮ 9.4 Backtracking-Algorithmen Es darf kein Zyklus entstehen (außer am Ende der Rundreise). Kein Knoten darf zu mehr als 2 Kanten adjazent sein. 9-46 Beispiel: Problem des Handlungsreisenden ◮ Die Laufzeit dieses gierigen Algorithmus liegt in O (n2 log n2 ). ◮ Das Verfahren führt nicht immer zu einer optimalen Lösung. Trotzdem wird es in der Praxis erfolgreich eingesetzt. ◮ Branch-and-Bound: Wenn man weiß, dass eine Lösung mit Kosten k existiert (zum Beispiel durch obigen Algorithmus), dann kann ein Backtrack-Algorithmus alle Teillösungen abschneiden, die bereits teurer als k sind. 9.4 Backtracking-Algorithmen 9-47 Dynamische Programmierung Rekursive Problemstruktur: 1. Aufteilung in abhängige Teilprobleme. 2. Berechnen und Zwischenspeichern wiederbenötigter Teillösungen. 3. Bestimmung des Gesamtergebnisses unter Verwendung der Teillösungen. Die dynamische Programmierung ist mit der Teile-und-Beherrsche-Methode verwandt. Die Teilprobleme sind aber abhängig. Einmal berechnete Teillösungen werden wiederverwendet. Die dynamische Programmierung wird häufig bei Optimierungsproblemen angewendet. 9.5 Dynamische Programmierung 9-48 Beispiel: Fibonacci-Zahlen n=0 0 fib(n) = 1 n=1 fib(n − 1) + fib(n − 2) n ≥ 2 0, 1, 1, 2, 3, 5, 8, 13, 21, 34, . . . func fib(n: int): int begin if n < 2 then return n; fi; return fib(n-1) + fib(n-2); end 9.5 Dynamische Programmierung 9-49 Beispiel: Fibonacci-Zahlen Berechnung von fib(5): fib5 fib4 fib3 fib3 fib2 fib1 fib0 1 0 9.5 Dynamische Programmierung fib2 fib2 fib1 fib1 fib1 fib0 fib1 fib0 1 1 0 1 0 1 9-50 Beispiel: Fibonacci-Zahlen Die Berechnung von fib(5) führt zweimal auf die Berechnung von fib(3). Die zugehörigen Teilbäume werden zweimal ausgewertet. Aufwandsabschätzung: T (n) = Anzahl der Funktionsaufrufe n = 0, n = 1 1 = 1 + T (n − 1) + T (n − 2) n ≥ 2 T (n) wächst exponentiell. Wir haben in der Übung gezeigt: T (n) = 1 + √ n √ n ! ! √ 1 + 5 1 − 5 1 + 1 − − 1 5 5 1√ 5 2 5 2 ≈ 1,45 · 1,62n . 9.5 Dynamische Programmierung 9-51 Beispiel: Fibonacci-Zahlen Iterative dynamische Lösung (vgl. Abschnitt 2.1): func fibDyn(n: int): int begin var i, result, minus1, minus2: int; if n < 2 then return n; fi; minus2 ← 0; minus1 ← 1; for i ← 2 to n do result ← minus1 + minus2; minus2 ← minus1; minus1 ← result; od; return result; end 9.5 Dynamische Programmierung 9-52 Beispiel: Das Rucksackproblem Das Rucksackproblem (knapsack problem): Ein Wanderer findet einen Schatz aus Edelsteinen. ◮ Jeder Edelstein hat ein bestimmtes Gewicht und einen bestimmten Wert. ◮ Er hat nur einen Rucksack, dessen Kapazität durch ein maximales Gewicht begrenzt ist. Gesucht ist ein Algorithmus, der diejenige Befüllung des Rucksacks ermittelt, die einen maximalen Wert hat, ohne die Gewichtsbeschränkung zu verletzen. 9.5 Dynamische Programmierung 9-53 Beispiel: Das Rucksackproblem Gegeben: ◮ ◮ ◮ ◮ Kapazität c ∈ N, Menge O mit n ∈ N Objekten o1 , . . . , on , Gewichtsfunktion g : O → N mit Bewertungsfunktion w : O → N. P j ∈O g (j ) > c , Gesucht ist eine Menge O ′ ⊆ O mit X j ∈O ′ g (j ) ≤ c und X w (j ) maximal. j ∈O ′ Da Gegenstände nur vollständig oder gar nicht eingepackt werden, spricht man auch vom 0-1-Rucksackproblem. Beim fraktionalen Rucksackproblem können auch Teile eines Gegenstands ausgewählt werden. 9.5 Dynamische Programmierung 9-54 Beispiel: Das Rucksackproblem Der gierige Algorithmus führt nicht zur Lösung: O = {o1 , o2 , o3 }, Kapazität c = 5. Gewichte: g (o1 ) = 1, g (o2 ) = 2, g (o3 ) = 3. Werte: w (o1 ) = 6, w (o2 ) = 10, w (o3 ) = 12. Ein gieriger Algorithmus wählt das Objekt mit dem größten relativen Wert. w (o ) , r (o1 ) = 6, r (o2 ) = 5, r (o3 ) = 4. r (o ) = g (o ) O ′ = {o1 , o2 }, X j ∈O ′ g (j ) = 3 ≤ 5 = c , X w (j ) = 16. j ∈O ′ Die optimale Lösung ist O ′′ = {o2 , o3 }, 9.5 Dynamische Programmierung X j ∈O ′′ g (j ) = 5 = c , X w (j ) = 22. j ∈O ′′ 9-55 Beispiel: Das Rucksackproblem Backtracking liefert die korrekte Lösung, ist aber ineffizient. O = {o1 , o2 , o3 , o4 }, c = 10. Gewichte: g (o1 ) = 2, g (o2 ) = 2, g (o3 ) = 6, g (o4 ) = 5. Werte: w (o1 ) = 6, w (o2 ) = 3, w (o3 ) = 5, w (o4 ) = 4. 10 nein zur Disposition o : (g, w) o1 : (2, 6) ja 10 8 o2 : (2, 3) 10 8 8 6 o3 : (6, 5) 10 4 8 2 8 2 6 0 o4 : (5, 4) 10 5 4 8 3 2 8 3 2 6 1 0 (0/0), (5/4), (6/5), (2/3), (7/7), (8/8), (2/6), (7/10), (8/11), (4/9), (9/13), (10/14), jeweils (Gewicht/Wert). Es ist O ′ = {o1 , o2 , o3 } mit g (O ′ ) = 10 und w (O ′ ) = 14. 9.5 Dynamische Programmierung 9-56 Beispiel: Das Rucksackproblem Rückgabewert ist der Wert der optimalen Füllung. Aufruf: btKnapsack(1,c). func btKnapsack(i, rest: int): int begin if i = n then if g(i) > rest then return 0; else return w(i); fi; else if g(i) > rest then return btKnapsack(i+1,rest); else return max(btKnapsack(i+1,rest), btKnapsack(i+1,rest-g(i))+w(i)); fi; fi; end Das Optimierungspotential durch Vermeidung wiederkehrender Berechnungen wird nicht genutzt. 9.5 Dynamische Programmierung 9-57 Beispiel: Das Rucksackproblem Dynamische Programmierung: O = {o1 , . . . , o5 }, c = 10. Gewichte: g (o1 ) = 2, g (o2 ) = 2, g (o3 ) = 6, g (o4 ) = 5, g (o5 ) = 4. Werte: w (o1 ) = 6, w (o2 ) = 3, w (o3 ) = 5, w (o4 ) = 4, w (o5 ) = 6. Es wird ein zweidimensionalen Feld f [i , r ] berechnet: r→ i↓ 5 4 3 2 0 0 0 0 0 1 0 0 0 0 2 0 0 0 3 3 0 0 0 3 4 6 6 6 6 5 6 6 6 6 6 6 6 6 9 7 6 6 6 9 8 6 6 6 9 9 6 10 10 10 10 6 10 11 11 f [4, 9] = 10: Wenn o4 und o5 bei der Restkapazität 9 zur Disposition stehen, beträgt der Wert der zusätzlichen Füllung 10. 9.5 Dynamische Programmierung 9-58 Beispiel: Das Rucksackproblem Die zentrale Anweisung in btKnapsack: return max(btKnapsack(i+1,rest), btKnapsack(i+1,rest-g(i))+w(i)); r→ i↓ 5 4 3 2 0 0 0 0 0 1 0 0 0 0 2 0 0 0 3 3 0 0 0 3 4 6 6 6 6 5 6 6 6 6 6 6 6 6 9 7 6 6 6 9 8 6 6 6 9 9 6 10 10 10 10 6 10 11 11 f [3, 8] = max (f [4, 8], f [4, 2] + 5) = max (6, 0 + 5) = 6 9.5 Dynamische Programmierung 9-59 Beispiel: Das Rucksackproblem Der Algorithmus arbeitet folgendermaßen: 1. Berechne zunächst die Werte f [n, 0],. . . ,f [n, c ]. 2. Berechne anschließend f [i , 0],. . . ,f [i , c ] für i = n − 1 bis i = 2 unter Rückgriff auf die bereits berechneten Werte der Zeile i + 1. 3. Das Gesamtergebnis f [1, c ] ergibt sich dann aus f [1, 10] = max (f [2, 10], f [2, 8] + 6) = max (11, 9 + 6) = 15. 9.5 Dynamische Programmierung 9-60 Beispiel: Das Rucksackproblem func dynKnapsack(n, c: int): int begin var f[2..n, 0..c]: int; var i, r: int; for r ← 0 to c do if g(n) > r then f[n,r] ← 0; else f[n,r] ← w(n); fi; for i ← n - 1 downto 2 do for r ← 0 to c do if g(i) > r then f[i,r] ← f[i+1,r]; else f[i, r] ← max(f[i+1,r],f[i+1,r-g(i)]+w(i)); fi; od; od; if g(1) > c then return f[2, c]; else return max(f[2,c],f[2,c-g(1)]+w(1)); end 9.5 Dynamische Programmierung 9-61 Beispiel: Suche in einem Text Im Folgenden werden Zeichenketten als Felder behandelt. Gegeben: ◮ Feld t [1..n] von Zeichen, der Text, ◮ Feld p [1..m] von Zeichen, das Muster (pattern). Es sei m ≤ n. In der Regel ist sogar m << n. Gesucht: Vorkommen von p in t , d. h. Indices s , 0 ≤ s ≤ n − m, mit t [s + 1..s + m] = p [1..m]. 9.5 Dynamische Programmierung 9-62 Beispiel: Suche in einem Text Naive Lösung: Vergleiche für alle s = 0..n − m und für alle i = 0..m die Zeichen p [i ] = t [s + i ]. proc naiv(t, p): begin var i,s: int; for s ← 0 to n - m do i ← 1; while i ≤ m && p[i] = t[s+i] do i ← i+1; od; if i = m+1 then print(s); fi; od; end 9.5 Dynamische Programmierung 9-63 Beispiel: Suche in einem Text ◮ Als Maß für die Laufzeit nehmen wir die Anzahl der ausgeführten Tests der inneren Schleife. ◮ Der schlimmste Fall tritt ein, wenn für jeden Wert s die Zeichenkette p bis zum letzten Zeichen mit t verglichen werden muss. Beispiel: t = "aaaaaaaaaaaaaaaaab", p = "aaab". ◮ Die Laufzeit liegt in O ((n − m)m) = O (nm). ◮ Gesucht ist ein effizienterer Algorithmus. 9.5 Dynamische Programmierung 9-64 Beispiel: Suche in einem Text s = 4, q = 5: t: bacbababaabcbab p: ababaca s ′ = s + 2: t: bacbababaabcbab p: ababaca Die nächste möglicherweise erfolgreiche Verschiebung ist s ′ = s + (q − d [q]). 9.5 Dynamische Programmierung 9-65 Beispiel: Suche in einem Text Es seien die Musterzeichen p [1..q] gegeben, die mit den Textzeichen t [s + 1..s + q] übereinstimmen. Wie groß ist die geringste Verschiebung s ′ > s für die p [1..k ] = t [s ′ + 1..s ′ + k ] mit s ′ + k = s + q gilt? 9.5 Dynamische Programmierung 9-66 Beispiel: Suche in einem Text Die Präfixfunktion für das Muster ababababca: i p[i] d[i] 9.5 Dynamische Programmierung 1 a 0 2 b 0 3 a 1 4 b 2 5 a 3 6 b 4 7 a 5 8 b 6 9 c 0 10 a 1 9-67 Beispiel: Suche in einem Text proc Berechnung der Präfixfunktion d begin d(1) ← 0; k ← 0; for q ← 2 to m do while k > 0 ∧ p[k+1] , p[q]; do k ← d[k]; od; if p[k+1] = p[q] then k ← k+1; fi; d[q] ← k; od; return d; end 9.5 Dynamische Programmierung 9-68 Beispiel: Suche in einem Text Der Knuth-Morris-Pratt-Algorithmus: proc kmp(t, p): begin berechne d; q ← 0; for i ← 1 to n do while q > 0 ∧ p[q+1] , t[i] do q ← d[q]; od; if p[q+1] = t[i] then q ← q+1; fi; if q = m then print(i-m); q ← d[q]; fi; od; end Die Laufzeit dieses Algorithmus liegt in O (n + m). 9.5 Dynamische Programmierung 9-69 Weitere Algorithmenmuster ◮ Zufallsgesteuerte Algorithmen ◮ Verteilte und parallele Algorithmen ◮ Lokale Suche ◮ Amortisierte Analyse ◮ Approximative Algorithmen ◮ Genetische Algorithmen ◮ Schwarmbasierte Algorithmen und Koloniealgorithmen ◮ ... 9.5 Dynamische Programmierung 9-70 Weitere Gebiete ◮ Mathematische Algorithmen ◮ Geometrische Algorithmen ◮ Algorithmen für Texte ◮ Lineare Programmierung ◮ ... Große Bedeutung für die Theorie der Algorithmen besitzt die Komplexitätstheorie. Hierzu zählen zum Beispiel die Untersuchung von Komplexitätsklassen wie P und NP und die so genannte NP-Vollständigkeit. Die Komplexitätstheorie wird in der Vorlesung „Theoretische Informatik II“ behandelt. 9.5 Dynamische Programmierung 9-71 1. Der Algorithmenbegriff 2. Imperative Algorithmen 3. Sortieralgorithmen 4. Listen und abstrakte Datentypen 5. Objektorientierte Algorithmen 6. Bäume 7. Mengen, Verzeichnisse und Hash-Verfahren 8. Graphen 9. Entwurf von Algorithmen 10. Funktionale und deduktive Algorithmen 10.1 Partielle und totale Funktionen 10.2 Funktionale Algorithmen 10.3 Prädikatenlogik 10.4 Deduktive Algorithmen Einführung Grundidee: ◮ Ein Algorithmus wird durch eine Funktion f realisiert. ◮ Die Eingabe des Algorithmus ist ein Element w aus dem Definitionsbereich von f . ◮ Die Ausgabe des Algorithmus ist der Wert f (w ) aus dem Wertebereich von f . 10.1 Partielle und totale Funktionen 10-1 Einführung ◮ Mathematische Funktionen sind häufig deklarativ definiert: Sie beinhalten keine Angaben zur Durchführung ihrer Berechnung. ◮ Beispiele: f (n, m) = n · m, f (n) = n!. ◮ Wie kann ein Algorithmus den Funktionswert f (w ) berechnen? ◮ Können alle berechenbaren Probleme derart gelöst werden? 10.1 Partielle und totale Funktionen 10-2 Partielle und totale Funktionen Eine partielle Funktion f : A −→ B ordnet jedem Element x einer Teilmenge Df ⊆ A genau ein Element f (x ) ∈ B zu. Die Menge Df heißt Definitionsbereich von f . f ist eine totale Funktion, wenn Df = A gilt. Beispiel: f : R −→ R, Df = R \ {0}, 1 f (x ) = x Algorithmen können undefinierte Ausdrücke enthalten und müssen nicht in jedem Fall terminieren, d. h.: Algorithmen berechnen partielle Funktionen! 10.1 Partielle und totale Funktionen 10-3 Definition von Funktionen ◮ Wenn der Definitionsbereich einer Funktion endlich ist, lässt sie sich durch Angabe aller Funktionswerte in einer Tabelle definieren. ◮ Beispiel: ∧:B×B→B false false true true 10.1 Partielle und totale Funktionen false true false true false false false true 10-4 Definition von Funktionen ◮ In den meisten Fällen wird eine Funktion f : A → B durch einen Ausdruck, der zu jedem Element aus Df genau einen Wert von B liefert, beschrieben. ◮ Beispiel: max : R × R → R x max(x , y ) = y x≥y x<y = if x ≥ y then x else y fi 10.1 Partielle und totale Funktionen 10-5 Rekursive Definitionen (Wiederholung) Die Funktion f : N −→ N wird durch 1 1 f (n) = n f 2 f (3n + 1) n = 0, n = 1, n ≥ 2, n gerade, n ≥ 2, n ungerade. rekursiv definiert. 10.1 Partielle und totale Funktionen 10-6 Auswertung von Funktionen (Wiederholung) Funktionsdefinitionen können als Ersetzungssysteme gesehen werden. Funktionswerte lassen sich aus dieser Sicht durch wiederholtes Einsetzen berechnen. Die Auswertung von f (3) ergibt f (3) → f (10) → f (5) → f (16) → f (8) → f (4) → f (2) → f (1) → 1. Terminiert der Einsetzungsprozess stets? 10.1 Partielle und totale Funktionen 10-7 Formen der Rekursion (Wiederholung) ◮ Lineare Rekursion, ◮ Endrekursion, ◮ Verzweigende Rekursion (Baumrekursion), ◮ Geschachtelte Rekursion, ◮ Verschränkte Rekursion (wechselseitige Rekursion). 10.1 Partielle und totale Funktionen 10-8 Funktionen höherer Ordnung Funktionen können selbst Argumente oder Werte sein. In diesem Fall spricht man von Funktionen höherer Ordnung oder Funktionalen. f : (A1 → A2 ) × A3 → B g : A → (B1 → B2 ) h : (A1 → A2 ) → (B1 → B2 ) 10.1 Partielle und totale Funktionen 10-9 Funktionen höherer Ordnung Beispiele: ◮ Summe: b X f (i ) i =a ◮ Komposition von Funktionen: ◮ Auswahl zwischen Funktionen: ◮ Bestimmtes Integral: Z f ◦g if p then f else g fi b f (x ) dx a 10.1 Partielle und totale Funktionen 10-10 Funktionale Algorithmen ◮ Ein Algorithmus heißt funktional, wenn die Berechnungsvorschrift mittels einer Sammlung (partieller) Funktionen definiert wird. ◮ Die Funktionsdefinitionen dürfen insbesondere Rekursionen und Funktionen höherer Ordnung enthalten. 10.2 Funktionale Algorithmen 10-11 Funktionale Algorithmen Beispiel: f (0) = 0 f (1) = 1 f (n) = nf (n − 2) Wenn wir als Datenbereich die Menge der ganzen Zahlen zugrunde legen, berechnet dieser Algorithmus die Funktion f : Z → Z mit Df = N und 0 n gerade n −1 f (n) = 2 Y (2i + 1) n ungerade i =0 10.2 Funktionale Algorithmen 10-12 Funktionale Programmiersprachen Programmiersprachen, die in erster Linie für die Formulierung funktionaler Algorithmen gedacht sind, heißen funktional. Funktionale Programmiersprachen sind beispielsweise ◮ Lisp, ◮ Scheme, ◮ ML, SML und ◮ Haskell. Man kann in vielen imperativen/objektorientierten Programmiersprachen funktional programmieren – und umgekehrt! 10.2 Funktionale Algorithmen 10-13 Lisp und Scheme ◮ Lisp wurde Ende der 50er Jahre von John McCarthy entwickelt. ◮ Im Laufe der Jahre wurden viele Lisp-Dialekte, u. a. Common Lisp und Scheme definiert. ◮ Die erste Version von Scheme stammt aus dem Jahre 1975. Autoren waren Guy Lewis Steele Jr. und Gerald Jay Sussman. ◮ Lisp und Scheme werden in der Regel interpretiert, nicht compiliert. 10.2 Funktionale Algorithmen 10-14 Algorithmus von Euklid Funktional geschrieben hat der Algorithmus von Euklid die Form: ggT(a , 0) = a ggT(a , b ) = ggT(b , a mod b ) Beispiel: ggT(36, 52) → ggT(52, 36) → ggT(36, 16) → ggT(16, 4) → ggT(4, 0) → 4 10.2 Funktionale Algorithmen 10-15 Scheme: Algorithmus von Euklid Der funktionale Algorithmus von Euklid lautet beispielsweise als Scheme-Programm: (define (ggT a b) (if (= b 0) a (ggT b (remainder a b)))) (ggT 36 52) 4 10.2 Funktionale Algorithmen 10-16 Terme Terme sind aus ◮ Konstanten, ◮ Variablen, ◮ Funktions- und ◮ Relationssymbolen zusammengesetzte Zeichenketten. Terme besitzen einen Typ. Beispiele: ◮ ◮ ◮ ◮ Die Konstanten . . . , −2, −1, 0, 1, 2, . . . sind int-Terme. 13 − √ 2 + 3 ist ein real-Term. 4 · (3 − 2) + 3 · i ist ein int-Term, falls i eine Variable vom Typ int ist. Ist b ein bool-Term und sind t , u int-Terme, so ist auch if b then t else u fi ein int-Term. 10.2 Funktionale Algorithmen 10-17 Funktionsdefinitionen Die Gleichung f (x1 , . . . , xn ) = t (x1 , . . . , xn ) heißt Definition der Funktion f vom Typ τ, wenn gilt: ◮ f ist der Funktionsname. ◮ x1 , . . . , xn sind Variable, die formale Parameter genannt werden. Die Typen von x1 , . . . , xn seien τ1 , . . . , τn . ◮ t ist ein Term, der die Variablen x1 , . . . , xn enthält. Der Typ von t sei τ. ◮ f : τ1 × · · · × τn → τ heißt Signatur von f . Der Fall n = 0 ist zugelassen. In diesem Fall liefert die Auswertung stets das gleiche Ergebnis. Die Funktion entspricht somit einer Konstanten. 10.2 Funktionale Algorithmen 10-18 Funktionsdefinitionen Beispiele: ZylVol(h , r ) π max(x , y ) f (p , q, x , y ) g (x ) h (p , q) 10.2 Funktionale Algorithmen = h · π · r2 (Signatur ZylVol: real×real→real) = 3.1415926535 . . . (Signatur: π :→real) = if x > y then x else y fi (Signatur max: int×int→int) = if p ∨ q then 2 · x + 1 else 3 · y − 1 if (Signatur f: bool×bool×int×int→int) = if even(x ) then x2 else 3 · x + 1 fi (Signatur g: int→int) = if p then q else false fi (Signatur h: bool×bool→bool) 10-19 Funktionsanwendungen ◮ Unter einer Anwendung (Applikation) einer Funktion f (x1 , . . . , xn ) = t (x1 , . . . , xn ) versteht man einen Ausdruck f (a1 , . . . , an ). ◮ Für die formalen Parameter werden Ausdrücke (aktuelle Parameter) eingesetzt, die den richtigen Typ besitzen müssen. ◮ Die Auswertung liefert eine Folge f (a1 , . . . , an ) → t (a1 , . . . , an ) → · · · . ◮ Es muss genau festgelegt werden, wie und in welcher Reihenfolge auftretende (Teil-)Ausdrücke ausgewertet werden. ◮ Diese Folge muss nicht terminieren. 10.2 Funktionale Algorithmen 10-20 Funktionsanwendungen f (p , q, x , y ) = if p ∧ q then 2 · x + 1 else 3 · y − 1 fi f (false , false , 3, 4) = if false ∧ false then 2 · x + 1 else 3 · y − 1 fi = if false then 2 · 3 + 1 else 3 · 4 − 1 fi = 3 · 4 − 1 = 11 x g (x ) = if even(x ) then else 3 · x + 1 fi 2 1 g (1) = if even(1) then else 3 · 1 + 1 fi 2 =3·1+1=4 10.2 Funktionale Algorithmen 10-21 Funktionsanwendungen h (p , q) = if p then 8 else 8/q fi h (true , 2) = if true then 8 else 8/2 fi =8 h (false , 2) = if false then 8 else 8/2 fi =4 h (false , 0) = if false then 8 else 8/0 fi undefiniert h (true , 0) = if true then 8 else 8/0 fi =8 Bei der Auswertung des Terms if b then t1 else t2 fi wird zunächst der boolesche Term b ausgewertet, und dann, abhängig vom Ergebnis, t1 oder t2 . 10.2 Funktionale Algorithmen 10-22 Funktionsdefinitionen Eine Funktion kann auch in mehreren Gleichungen definiert werden, jeweils für einen Teil der Argumente. Beispiel: f (0) = 0 f (1) = 2 f (−1) = 2 f (x ) = if x > 1 then x (x − 1) else − x (x − 1) fi Die Auswertung erfolgt dabei von oben nach unten, wobei die erste passende linke Seite für die Berechnung angewendet wird. Es kommt daher auf die Reihenfolge der Gleichungen an. 10.2 Funktionale Algorithmen 10-23 Funktionsdefinitionen Folgendes Gleichungssystem definiert eine andere Funktion. f (−1) = 2 f (x ) = if x > 1 then x (x − 1) else − x (x − 1) fi f (1) = 2 f (0) = 0 Hier sind die letzten beiden Gleichungen überflüssig. Man kann mehrere Definitionsgleichungen immer in einer Gleichung zusammenfassen, indem man geschachtelte if-then-else-fi Konstrukte verwendet. 10.2 Funktionale Algorithmen 10-24 Funktionsdefinitionen Das erste Beispiel oben lässt sich in einer Gleichung schreiben: f (x ) = if x = 0 then 0 else if x = 1 then 2 else if x = −1 then 2 else if x > 1 then x (x − 1) else − x (x − 1) fi fi fi fi Die Schreibweise mit mehreren Gleichungen ist in der Regel übersichtlicher. 10.2 Funktionale Algorithmen 10-25 Funktionsdefinitionen Ein Wächter (guard) ist eine Vorbedingung, die für die Anwendung einer Definitionsgleichung erfüllt sein muss. Beispiel: f (0) = 0 f (1) = 2 f (−1) = 2 x > 1 :f (x ) = x (x − 1) f (x ) = −x (x − 1) 10.2 Funktionale Algorithmen 10-26 Funktionsdefinitionen Eine Funktionsdefinition kann unvollständig sein. Beispiel: f (0) = 0 f (1) = 2 f (−1) = 2 x > 1 :f (x ) = x (x − 1) Gegenüber dem vorigen Beispiel fehlt hier die letzte Gleichung. Es gibt daher keine Berechnungsvorschrift für Werte < −1. D. h., die Funktion ist dort nicht definiert. 10.2 Funktionale Algorithmen 10-27 Funktionsdefinitionen Funktionen können unter Verwendung von Hilfsfunktionen definiert werden. Beispiel: Volumen(h , r , a , b , c ) = ZylVol(h , r ) + QuadVol(a , b , c ) ZylVol(h , r ) = h · KreisFl(r ) KreisFl(r ) = πr 2 QuadVol(a , b , c ) = a · b · c Einsetzen führt zu einer einzeiligen Definition: Volumen(h , r , a , b , c ) = h πr 2 + a · b · c 10.2 Funktionale Algorithmen 10-28 Auswertung von Funktionen Volumen(3, 2, 5, 1, 5) = ZylVol(3, 2) + QuadVol(5, 1, 5) = 3 · KreisFl(2) + QuadVol(5, 1, 5) = 3π22 + QuadVol(5, 1, 5) = 3π22 + 5 · 1 · 5 = 12π + 25 ≈ 62.699111843 Alternativ kann einem Term ein Name gegeben werden, der dann (mehrfach) verwendet werden kann: f (a , b ) = x · x where x = a + b ist gleichbedeutend mit f (a , b ) = (a + b ) · (a + b ). 10.2 Funktionale Algorithmen 10-29 Applikative Algorithmen Ein applikativer (funktionaler) Algorithmus ist eine Liste von Funktionsdefinitionen f1 (x1,1 , . . . , x1,n1 ) = t1 (x1,1 , . . . , x1,n1 ), f2 (x2,1 , . . . , x2,n2 ) = t2 (x2,1 , . . . , x2,n2 ), .. .. .=. fm (xm,1 , . . . , xm,nm ) = tm (xm,1 , . . . , xm,nm ). Die erste Funktion ist die Bedeutung (Semantik) des Algorithmus. Die Funktion wird für eine Eingabe (a1 , . . . , an1 ) wie beschrieben ausgewertet. Die Ausgabe ist f1 (a1 , . . . , an1 ). 10.2 Funktionale Algorithmen 10-30 Gültigkeit und Sichtbarkeit Beispiel: f (a , b ) = g (b ) + a g (a ) = a · b b=3 f (1, 2) = g (2) + 1 = 2 · b + 1 = 2 · 3 + 1 = 7 Die globale Definition von b wird in der Definition von f durch die lokale Definition verdeckt. Es treten also auch hier die Fragen nach dem Gültigkeits- und dem Sichtbarkeitsbereich von Variablen auf, wie wir sie in Kapitel 2 bei den imperativen Algorithmen angetroffen haben. 10.2 Funktionale Algorithmen 10-31 Undefinierte Funktionswerte Die Fakultätsfunktion ist definiert durch: Fac(0) = 1 Fac(n) = n · Fac(n − 1) Für negative Parameter terminiert die Berechnung nicht: Fac(−1) = −1 · Fac(−2) = −1 · −2 · Fac(−3) = · · · Die Funktion Fac ist also partiell. Es gibt drei mögliche Ursachen für undefinierte Funktionswerte: ◮ Die Parameter führen zu einer nicht terminierenden Berechnung. ◮ Eine aufgerufene Funktion ist für einen Parameter undefiniert (zum Beispiel Division durch 0). ◮ Die Funktion ist unvollständig definiert. 10.2 Funktionale Algorithmen 10-32 Komplexe Datentyen Komplexe Datentypen (Datenstrukturen) entstehen durch Kombination elementarer Datentypen und besitzen spezifische Operationen. Sie können vorgegeben oder selbstdefiniert sein. Die grundlegenden Datentypen werden auch Atome genannt. Übliche Atome sind die Typen int, bool, float und char sowie Variationen davon. Es gibt in Bezug auf das Anwendungsspektrum eine Unterscheidung in ◮ generische Datentypen: Sie werden für eine große Gruppe ähnlicher Problemstellungen entworfen und sind oft im Sprachumfang enthalten (Liste, Keller, Feld, Verzeichnis, . . . ). ◮ spezifische Datentypen: Sie dienen der Lösung einer eng umschriebenen Problemstellung und werden im Zusammenhang mit einem konkreten Problem definiert (Adresse, Person, Krankenschein, . . . ). 10.2 Funktionale Algorithmen 10-33 Generische Datentypen der funktionalen Programmierung In der funktionalen Programmierung spielen die folgenden generischen Datentypen eine hervorgehobene Rolle: ◮ Listen, ◮ Texte (Liste von Zeichen), ◮ Tupel, ◮ Funktionen. 10.2 Funktionale Algorithmen 10-34 Listen Die Datenstruktur funktionaler Sprachen und Programmierung. Die funktionale Programmierung wurde anfangs auch Listenverarbeitung genannt. Lisp steht für „List Processor“. Beispiele: ◮ Liste von Messwerten, geordnet nach Aufzeichnungszeitpunkt, z. B. Zimmertemperatur (° C) im Informatikzentrum nach Ankunft: [17.0, 17.0, 17.1, 17.2, 17.4, 17.8]. ◮ Alphabetisch geordnete Liste von Namen z. B. Teilnehmer der kleinen Übung: [„Baltus“, „Bergmann“, „Cäsar“]. ◮ Alphabetisch geordnete Liste von Namen mit Vornamen, d. h. Liste von zweielementigen Listen mit Namen und Vornamen: [[„Kundera“, „M.“], [„Hesse“, „S.“], [„Einstein“, „A.“]]. 10.2 Funktionale Algorithmen 10-35 Listen Syntax und Semantik: ◮ [D ] ist der Datentyp der Listen, d. h. der endlichen Folgen, über D . ◮ Notation: [x1 , x2 , . . . , xn ] ∈ [D ] für x1 , x2 , . . . , xn ∈ D . Beispiele: ◮ [real]: Menge aller Listen von Fließkommazahlen, z. B. Messwerte, Vektoren über R. ◮ [char]: Menge aller Listen von Buchstaben, z. B. Namen, Befunde, Adressen. ◮ [[char]]: Menge aller Listen von Listen von Buchstaben, z. B. Namensliste. 10.2 Funktionale Algorithmen 10-36 Typische Listenoperationen ◮ []: leere Liste. ◮ e: l: Verlängerung einer Liste l nach vorn um ein Einzelelement e , z. B. 1 : [2, 3] = [1, 2, 3]. ◮ length(l): Länge einer Liste l , z. B. length ([4, 5, 6]) = 3. ◮ head(l): erstes Element e einer nichtleeren Liste l = e : l ′ , z. B. head ([1, 2, 3]) = 1. ◮ tail(l): Restliste l ′ einer nichtleeren Liste l = e : l ′ nach Entfernen des ersten Elementes, z. B. tail ([1, 2, 3]) = [2, 3]. ◮ last(l): letztes Element einer nichtleeren Liste, z. B. last ([1, 2, 3]) = 3. 10.2 Funktionale Algorithmen 10-37 Typische Listenoperationen ◮ init(l): Restliste einer nichtleeren Liste nach Entfernen des letzten Elements, z. B. init ([1, 2, 3]) = [1, 2]. ◮ l++l’: Verkettung zweier Listen l und l ′ , z. B. [1, 2] + +[3, 4] = [1, 2, 3, 4]. ◮ l!!n: Das n-te Element der Liste l , wobei 1 ≤ n ≤ length (l ), z. B. [2, 3, 4, 5]!!3 = 4. Vergleichsoperationen = und ,: (e1 : t1 ) = (e2 : t2 ) ⇔ e1 = e2 ∧ t1 = t2 , l1 , l2 ⇔ ¬(l1 = l2 ). [i , i + 1, i + 2, . . . , j − 1, j ] [i , . . . , j ] = [] 10.2 Funktionale Algorithmen falls i ≤ j , falls i > j . 10-38 Typische Listenoperationen Die folgende Funktion berechnet rekursiv das Spiegelbild einer Liste. mirror :[int ] → [int ] mirror ([]) = [] mirror (l ) = last (l ) : mirror (init (l )) mirror ([1, 2, 3, 4]) = 4 : mirror (init ([1, 2, 3, 4])) = 4 : mirror ([1, 2, 3]) = 4 : (3 : mirror ([1, 2])) = 4 : (3 : (2 : mirror ([1]))) = 4 : (3 : (2 : (1 : mirror ([])))) = 4 : (3 : (2 : (1 : []))) = 4 : (3 : (2 : [1])) = 4 : (3 : [2, 1]) = 4 : [3, 2, 1] = [4, 3, 2, 1] 10.2 Funktionale Algorithmen 10-39 Typische Listenoperationen Die folgende Funktion berechnet rekursiv das Produkt der Elemente einer Liste. prod :[int ] → int prod ([]) = 1 prod (l ) = head (l ) · prod (tail (l )) Die folgende Funktion konkateniert rekursiv eine Liste von Listen. concat :[[t ]] → [t ] concat ([]) = [] concat (l ) = head (l ) + +concat (tail (l )) concat ([[1, 2], [], [3], [4, 5, 6]]) = [1, 2, 3, 4, 5, 6] 10.2 Funktionale Algorithmen 10-40 Sortierverfahren Alle Algorithmen aus den vorhergehenden Kapiteln lassen sich auch funktional beschreiben, häufig sehr viel eleganter. Als Beispiel betrachten wir zwei Sortierverfahren. Wiederholung: Es sei eine Ordungsrelation ≤ auf dem Elementdatentyp D gegeben. ◮ ◮ Eine Liste l = (x1 , . . . , xn ) ∈ [D ] heißt sortiert, wenn x1 ≤ x2 ≤ · · · ≤ xn gilt. Eine Liste l ′ = [D ] heißt Sortierung von l ∈ [D ], wenn l und l ′ die gleichen Elemente haben und l ′ sortiert ist. ◮ Eine Sortierung l ′ von l heißt stabil, wenn sie gleiche Listenelemente nicht in ihrer Reihenfolge vertauscht. l = [5, 9, 3, 8, 8], l ′ = [3, 5, 8, 8, 9] (nicht stabil wäre l ′′ = [3, 5, 8, 8, 9]) ◮ Ein Sortieralgorithmus heißt stabil, wenn er stabile Sortierungen liefert 10.2 Funktionale Algorithmen 10-41 Sortieren durch Einfügen Beim Sortieren durch Einfügen wird die Ordnung hergestellt, indem jedes Element an der korrekten Position im Feld eingefügt wird. insert (x , []) = [x ] x ≤ y :insert (x , y : l ) = x : y : l insert (x , y : l ) = y : insert (x , l ) Für das Sortieren einer unsortierten Liste gibt es zwei Varianten: sort 1([]) = [] sort 1(l ) = insert (head (l ), sort 1(tail (l ))) sort 2([]) = [] sort 2(l ) = insert (last (l ), sort 2(init (l ))) Welche dieser Algorithmen sind stabil? 10.2 Funktionale Algorithmen 10-42 Sortieren durch Auswählen Beim Sortieren durch Auswählen wird das kleinste (größte) Element an den Anfang (das Ende) der sortierten Liste angefügt. Die folgende Funktion löscht ein Element aus einer Liste: delete (x , []) = [] x = y :delete (x , y : l ) = l delete (x , y : l ) = y : delete (x , l ) Für das Sortieren einer unsortierten Liste gibt es wieder zwei Varianten: sort 3(l ) = x : sort 3(delete (x , l )) where x = min(l ) sort 4(l ) = sort 4(delete (x , l )) + +[x ] where x = max (l ) Wie lauten min und max ? Was lässt sich über die Stabilität dieser beiden Algorithmen aussagen? 10.2 Funktionale Algorithmen 10-43 Extensionale und intensionale Beschreibungen Bisher wurden Listen durch Aufzählung oder Konstruktion beschrieben. Man spricht von einer extensionalen Beschreibung. Mengen werden implizit durch einen Ausdruck der Form {t (x ) | p (x )} angegeben. Beispiel: {x 2 | x ∈ N ∧ x mod 2 = 0} = {4, 16, . . .} Analog hierzu bedeutet [t (x ) | x ← l , p (x )] die Liste aller Werte t (x ), die man erhält, wenn x die Liste l durchläuft, wobei nur die Elemente aus l ausgewählt werden, die der Bedingung p (x ) genügen. [t (x ) | x ← l , p (x )] ist eine intensionale Definition. t (x ) ist ein Term. x ← l heißt Generator und p (x ) ist eine Auswahlbedingung. 10.2 Funktionale Algorithmen 10-44 Intensionale Beschreibungen [x | x ← [1 . . . 5]] = [1, 2, 3, 4, 5] [x 2 | x ← [1 . . . 5]] = [1, 4, 9, 16, 25] [x 2 | x ← [1 . . . 5], odd (x )] = [1, 9, 25] Eine intensionale Beschreibung kann auch mehrere Generatoren enthalten: [x 2 − y | x ← [1 . . . 3], y ← [1 . . . 3]] = [0, −1, −2, 3, 2, 1, 8, 7, 6] [x 2 − y | x ← [1 . . . 3], y ← [1 . . . x ]] = [0, 3, 2, 8, 7, 6] [x 2 − y | x ← [1 . . . 4], odd (x ), y ← [1 . . . x ]] = [0, 8, 7, 6] [x 2 − y | x ← [1 . . . 4], y ← [1 . . . x ], odd (x )] = [0, 8, 7, 6] Man vergleiche die Effizienz der beiden letzten Beschreibungen. 10.2 Funktionale Algorithmen 10-45 Intensionale Beschreibungen teiler (n) = [i | i ← [1 . . . n], n mod i = 0] teiler (18) = [1, 2, 3, 6, 9, 18] ggT (a , b ) = max ([d | d ← teiler (a ), b mod d = 0]) ggT (18, 8) = max ([d | d ← [1, 2, 3, 6, 9, 18], 8 mod d = 0]) = max ([1, 2]) = 2 primzahl (n) = (teiler (n) = [1, n]) primzahl (17) = (teiler (17) = [1, 17]) = true concat (l ) = [x | l ′ ← l , x ← l ′ ] concat ([[1, 2, 3],[4, 5, 6]]) = [1, 2, 3, 4, 5, 6] 10.2 Funktionale Algorithmen 10-46 Tupel Tupel sind Listen fester Länge. Beispiele: ◮ ◮ ◮ (1.0, −3.2) als Darstellung für die komplexe Zahl 1 − 3.2i . (4, 27) als Abbildung eines Messzeitpunkts (4 ms ) auf einen Messwert (27 V ). (2, 3.4, 5) als Darstellung eines Vektors im R3 . Der Typ t eines Tupels ist das kartesische Produkt der seiner Elementtypen: t = t1 × t2 × . . . × tn Schreibweise für Elemente des Typs t : (x1 , x2 , . . . , xn ) Man nennt (x1 , x2 , . . . , xn ) ein n-Tupel. Tupel sind grundlegende Typen aller funktionalen Sprachen. 10.2 Funktionale Algorithmen 10-47 Tupel Auf der Basis von Tupeln lassen sich spezifische Datentypen definieren: ◮ date: int × text × int . Datumsangaben mit Werten wie (2, „Mai“, 2001). Es dürfen nur gültige Werte aufgenommen werden. ◮ rat: int × int . Rationale Zahlen mit Werten wie (2,3) für 23 . Das 2-Tupel (Paar) (1, 0) stellt keinen gültigen Wert dar. Beispiele für Funktionen auf rat: ratAdd , ratMult :rat × rat → rat kuerze :rat → rat kuerze (z , n) = (z div g , n div g ) where g = ggT (z , n) ratAdd ((z1 , n1 ), (z2 , n2 )) = kuerze (z1 n2 + z2 n1 , n1 n2 ) ratMult ((z1 , n1 ), (z2 , n2 )) = kuerze (z1 z2 , n1 n2 ) 10.2 Funktionale Algorithmen 10-48 Funktionen höherer Ordnung ◮ Funktionen als Datentypen machen es möglich, Funktionen auf Funktionen anzuwenden. ◮ Eine Funktion f : A → B ist vom Typ A → B . ◮ Die Operation → sei rechtsassoziativ, d. h. A → B → C = A → (B → C ) 10.2 Funktionale Algorithmen 10-49 Currying ◮ Das Currying vermeidet kartesische Produkte: Eine Abbildung f :A ×B →C kann als eine Abbildung f : A → (B → C ) = A → B → C gesehen werden. ◮ Beispiel: f : int × int mit f (x , y ) = x + y entspricht fg : int → int → int mit f (x ) = gx : int → int und gx (y ) = x + y . Hintereinanderausführung: (fg (x ))(y ) = gx (y ) = x + y = f (x , y ) 10.2 Funktionale Algorithmen 10-50 Funktionen höherer Ordnung Funktionen können als Werte und Argumente auftreten. Beispiel: Ein Filter, der aus einer Liste diejenigen Elemente auswählt, die einer booleschen Bedingung genügen. Spezifikation: filter (p , l ) = [x | x ← l , p (x )] Definition: filter filter (p , []) : (t → bool ) × [t ] → [t ] = [] p (x ) :filter (p , x : l ) = x : filter (p , l ) filter (p , x : l ) = filter (p , l ) 10.2 Funktionale Algorithmen 10-51 Funktionen höherer Ordnung Fortsetzung zum Filter, Anwendung: p : int → bool even(i ) :p (i ) p (i ) = true = false filter (p , [1 . . . 5]) = [2, 4] 10.2 Funktionale Algorithmen 10-52 Deduktive Algorithmen deduktiver Algorithmus logische Aussagen Anfrage Auswertungsalgorithmus für Anfragen Logisches Paradigma Die wichtigste logische Programmiersprache ist Prolog. 10.3 Prädikatenlogik 10-53 Prädikatenlogik ◮ Grundlage des logischen Paradigmas ist die Prädikatenlogik. ◮ Beispiel einer Aussage: „Susanne ist Tochter von Petra“. ◮ Eine Aussageform ist eine Aussage mit Unbestimmten: x ist Tochter von y . ◮ Durch eine Belegung der Unbestimmten kann eine Aussageform in eine Aussage transformiert werden: x ← Susanne , y ← Petra . ◮ Statt natürlichsprachiger Aussagen und Aussageformen, werden in deduktiven Algorithmen atomare Formeln verwendet: Tochter (x , y ). 10.3 Prädikatenlogik 10-54 Prädikatenlogik Alphabet: ◮ Konstante: a , b , c , . . .. ◮ Unbestimmte/Variable: x , y , z , . . .. ◮ Prädikatssymbole: P , Q , R , . . . mit Stelligkeit. ◮ Logische Verknüpfungen: ∧, ⇒, . . .. Atomare Formeln: P (t1 , . . . , tn ). Fakten: Atomare Formeln ohne Unbestimmte. Regeln haben die Form (αi ist atomare Formel): α1 ∧ α2 ∧ · · · ∧ αn ⇒ α0 α1 ∧ α2 ∧ · · · ∧ αn wird als Prämisse, α0 als Konklusion der Regel bezeichnet. 10.3 Prädikatenlogik 10-55 Beispiel Zwei Fakten: ◮ Tochter (Susanne , Petra ) ◮ Tochter (Petra , Rita ) Eine Regel mit Unbestimmten: ◮ Tochter (x , y ) ∧ Tochter (y , z ) ⇒ Enkelin(x , z ) Die Ableitung neuer Fakten erfolgt analog zur Implikation: 1. Finde eine Belegung der Unbestimmten einer Regel, so dass auf der linken Seite (Prämisse) bekannte Fakten stehen. 2. Die rechte Seite ergibt den neuen Fakt. 10.3 Prädikatenlogik 10-56 Beispiel Belegung der Unbestimmten der Regel: x ← Susanne , y ← Petra , z ← Rita Anwendung der Regel ergibt neuen Fakt: Enkelin(Susanne , Rita ) (Erste) Idee deduktiver Algorithmen: 1. Definition einer Menge von Fakten und Regeln sowie einer Anfrage in Form einer zu prüfenden Aussage; z. B. Enkelin(Susanne , Rita ). 2. Prüfen und Anwenden der Regeln, bis keine neuen Fakten mehr erzeugt werden können. 3. Prüfen, ob Anfrage in Faktenmenge enthalten ist. 10.3 Prädikatenlogik 10-57 Deduktive Algorithmen ◮ Ein deduktiver Algorithmus D besteht aus einer Menge von Fakten und Regeln. ◮ Aus einem deduktiven Algorithmus sind neue Fakten ableitbar. Die Menge aller Fakten F (D ) enthält alle direkt oder indirekt aus D ableitbaren Fakten. ◮ Ein deduktiver Algorithmus definiert keine Ausgabefunktion wie applikative oder imperative Algorithmen. Erst die Beantwortung von Anfragen liefert ein Ergebnis. ◮ Eine Anfrage γ ist eine Konjunktion von atomaren Formeln mit Unbestimmten: γ = α1 ∧ α2 ∧ · · · ∧ αn 10.4 Deduktive Algorithmen 10-58 Beispiel: Addition zweier Zahlen Fakten: ◮ suc (n, n + 1) für alle n ∈ Regeln: 1. true ⇒ add (x , 0, x ) 2. add (x , y , z ) ∧ suc (y , v ) ∧ suc (z , w ) ⇒ add (x , v , w ) Anfrage: add (3, 2, 5) liefert true. Auswertung: ◮ Regel 1 mit der Belegung x = 3: add (3, 0, 3) ◮ Regel 2 mit der Belegung x = 3, y = 0, z = 3, v = 1, w = 4: add (3, 1, 4) ◮ Regel 2 mit der Belegung x = 3, y = 1, z = 4, v = 2, w = 5: add (3, 2, 5) 10.4 Deduktive Algorithmen 10-59 Beispiel: Addition zweier Zahlen ◮ add (3, 2, x ) liefert x = 5. ◮ add (3, x , 5) liefert x = 2. ◮ add (x , y , 5) liefert (x , y ) ∈ {(0, 5), (1, 4), (2, 3), (3, 2), (4, 1), (5, 0)}. ◮ add (x , y , z ) liefert eine unendliche Ergebnismenge. ◮ add (x , x , 4) liefert x = 2. ◮ add (x , x , x ) liefert x = 0. ◮ add (x , x , z ) ∧ add (x , z , 90) liefert (x , z ) = (30, 60). Deduktive Algorithmen sind deklarativ (s. oben). Im Vergleich zu applikativen und imperativen Algorithmen sind sie sehr flexibel – und häufig ineffizient. 10.4 Deduktive Algorithmen 10-60 Auswertungsalgorithmus Dieser informelle nichtdeterministische Algorithmus wertet Anfragen aus: 1. Starte mit der Anfrage γ (anzusehen als Menge atomarer Formeln). 2. Suche Belegungen, die entweder ◮ einen Teil von γ mit Fakten gleichsetzen (Belegung von Unbestimmten von γ) oder ◮ einen Fakt aus γ mit einer rechten Seite einer Regel gleichsetzen (Belegungen von Unbestimmten in einer Regel). Setze diese Belegung ein. 3. Wende passende Regeln rückwärts an, ersetze also in γ die Konklusion durch die Prämisse. 4. Entferne gefundene Fakten aus der Menge γ. 5. Wiederhole diese Schritte bis γ leer ist. 10.4 Deduktive Algorithmen 10-61 Beispiel: Addition zweier Zahlen 1. add (3, 2, 5). 2. add (3, y ′ , z ′ ), suc (y ′ , 2), suc (z ′ , 5). 3. y ′ = 1, dadurch Fakt suc (1, 2) streichen. 4. add (3, 1, z ′ ), suc (z ′ , 5). 5. z ′ = 4, dadurch Fakt suc (4, 5) streichen. 6. add (3, 1, 4). 7. add (3, y ′′ , z ′′ ), suc (y ′′ , 1), suc (z ′′ , 4). 8. y ′′ = 0, z ′′ = 3 beide Fakten streichen. 9. add (3, 0, 3) streichen, damit zu bearbeitende Menge leer, also 10. true . 10.4 Deduktive Algorithmen 10-62 Beispiel: Addition zweier Zahlen 1. add (3, 2, x ). 2. add (3, y ′ , z ′ ), suc (y ′ , 2), suc (z ′ , x ). 3. y ′ = 1, dadurch Fakt suc (1, 2), streichen. 4. add (3, 1, z ′ ), suc (z ′ , x ). 5. add (3, y ′′ , z ′′ ), suc (y ′′ , 1), suc (z ′′ , z ′ ), suc (z ′ , x ). 6. y ′′ = 0, dadurch Fakt suc (0, 1), streichen. 7. add (3, 0, z ′′ ), suc (z ′′ , z ′ ), suc (z ′ , x ). 8. z ′′ = 3, dadurch Regel 2 erfüllt, streichen. 9. suc (3, z ′ ), suc (z ′ , x ). 10. z ′ = 4, dadurch Fakt suc (3, 4) streichen. 11. suc (4, x ). 12. x = 5, die zu bearbeitende Menge ist leer und eine Lösung für x bestimmt. 10.4 Deduktive Algorithmen 10-63 Deduktive Algorithmen ◮ Für eine Anfrage können unendlich viele Lösungen existieren: add (x , y , z ). ◮ Die Bestimmung aller möglichen Berechnungspfade kann durch Backtracking erfolgen. Das angegebene Verfahren ist sehr vereinfacht: ◮ ◮ ◮ 10.4 Deduktive Algorithmen Wie wird verhindert, dass ein unendlich langer Weg eingeschlagen wird? Was ist mit Negationen? 10-64