Algorithmen und Datenstrukturen Dr. Beatrice Amrhein 5. Januar 2016 ii Zur umfassenden Ausbildung eines Software-Ingenieurs gehören grundlegende Kenntnisse der wichtigsten Datenstrukturen und wie man diese verarbeitet (Algorithmen). Das Kennen von geeigneten Datenstrukturen hilft dem Programmierer, die Informationen richtig zu organisieren und besser strukturierte Programme zu schreiben. Lerninhalte - Abstrakte Datentypen, Spezifikation Komplexität von Algorithmen, Algorithmen-Schemata: Greedy, Iteration, Rekursion Wichtige Datenstrukturen: Listen, Stacks, Queues, Bäume, Heaps Suchen und Sortieren, Hash-Tabellen Endliche Automaten, reguläre Sprachen, Pattern Matching Kontextfreie Grammatiken, Parser Lernziele Die Studierenden kennen die wichtigsten Datenstrukturen mit ihren Methoden. Sie kennen die klassischen Algorithmen und können sie anwenden. Ausserdem kn̈nen sie Komplexitätsabschätzungen von Algorithmen vornehmen. Informationen zum Unterricht Grundlage ist ein Skript, das die wichtigsten Lerninhalte umfasst. Unterrichtssprache: Deutsch (Fachliteratur zum Teil in Englisch) Umfang: 12 halbtägige Blöcke à 4 Lektionen Dozentin: Beatrice Amrhein, Empfohlene Literatur: - Reinhard Schiedermeier Programmieren mit Java, Eine methodische Einführung. Pearson Studium ISBN 3-8273-7116-3. - Robert Sedgewick Algorithms in Java. Addison-Wesley Professional; 2002 ISBN 978-0-20136120-9 - M. T. Goodrich & R. Tamassia Algorithm Design: Foundations, Analysis, and Internet Examples. John Wiley & Sons, Inc. ISBN: 0-471-38365-1. - Gunter Saake, Kay-Uwe Sattler Algorithmen und Datenstrukturen, Eine Einführung mit Java. dpunkt, 2004. ISBN 3-89864-255-0. Inhaltsverzeichnis 1 Einführung 1-1 1.1 Die wichtigsten Ziele dieses Kurses . . . . . . . . . . . . . . . . . . . . . . . . . . . . 1-1 1.2 Einige Begriffe: Datenstrukturen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 1-2 1.3 Einige Begriffe: Algorithmen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 1-9 1.4 Algorithmen Schema: Iteration . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 1-12 1.5 Algorithmen Schema: Greedy (die gierige Methode) . . . . . . . . . . . . . . . . . . . . 1-12 1.6 Algorithmen Schema: Rekursion . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 1-15 1.7 Übung 1 2 . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 1-20 Komplexität von Algorithmen 2.1 Komplexitätstheorie 3 2-1 . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 2-1 2.2 Komplexitätsanalyse . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 2-2 2.3 Asymptotische Komplexität . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 2-5 2.4 Übung 2 2-9 . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . Datentypen: Listen, Stacks und Queues 3-1 3.1 Array Listen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 3-1 3.2 Doppelt verkettete Listen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 3-5 3.3 Stacks und Queues . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 3-11 3.4 Iteratoren . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 3-13 3.5 Übung 3 4 . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 3-14 Datentypen: Bäume, Heaps 4-1 4.1 Baumdurchläufe . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 4-4 4.2 Binäre Suchbäume . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 4-8 4.3 B-Bäume . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 4-10 4.4 Priority Queues . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 4-15 4.5 Übung 4 5 6 . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 4-21 Suchen 5-1 5.1 Grundlagen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 5-1 5.2 Lineare Suche . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 5-2 5.3 Binäre Suche . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 5-3 5.4 Hashing . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 5-5 5.5 Übung 5 . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 5-14 Sortieren 6-1 . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 6-2 6.2 Insertion Sort . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 6-4 6.3 Divide-and-Conquer Sortieren . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 6-5 6.4 Quicksort . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 6-6 6.1 Selection Sort iv Inhaltsverzeichnis 6.5 Sortieren durch Mischen (Merge Sort) . . . . . . . . . . . . . . . . . . . . . . . . . . . 6.6 Übung 6 7 Pattern Matching 7-1 7.1 Beschreiben von Pattern, Reguläre Ausdrücke . . . . . . . . . . . . . . . . . . . . . . 7-1 7.2 Endliche Automaten . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 7-3 7.3 Automaten zu regulären Ausdrücken . . . . . . . . . . . . . . . . . . . . . . . . . . . . 7-7 7.4 Übung 7 8 . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 7-10 Top Down Parser 8.1 Kontextfreie Grammatik 8-1 . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 8-2 8.2 Top-Down Parser . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 8-7 8.3 Übung 8 9 6-9 . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 6-12 . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 8-14 Kryptologie 9-1 9.1 Grundlagen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 9-2 9.2 Einfache Verschlüsselungmethoden . . . . . . . . . . . . . . . . . . . . . . . . . . . . 9-3 9.3 Vernamchiffre, One Time Pad . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 9-5 9.4 Moderne symmetrische Verfahren . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 9-6 9.5 Asymmetrische Verfahren: Public Key Kryptosysteme . . . . . . . . . . . . . . . . . . . 9-7 9.6 Übung 9 . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 9-12 1 Einführung 1.1 Die wichtigsten Ziele dieses Kurses Die wichtigsten Ziele des Algorithmen und Datenstrukturen Kurses sind: • Die Studierenden kennen die wichtigsten Datenstrukturen, können damit arbeiten, und kennen deren Vor- und Nachteile sowie deren Anwendungsgebiete. • Die Studierenden erhalten die Grundlagen, um während der Design Phase die richtigen Datenstrukturen auszuwählen und dann richtig einzusetzen. • Die Studierenden kennen die wichtigsten Komplexitätsklassen und deren Einfluss auf das Laufzeitverhalten eines Systems. • Die Studierenden kennen die klassischen Algorithmen und können diese anwenden. Sie kennen deren Einsatzgebiete (wann soll welcher Algorithmus benutzt werden) und kennen die Komplexität dieser Algorithmen (in Abhängigkeit der darunterliegenden Datenstrukturen). • Die Studierenden erhalten einen Überblick über verschiedene Vorgehensweisen bei Problemlösungen und kennen deren Stärken und Schwächen. 1-2 1 Einführung 1.2 Einige Begriffe: Datenstrukturen Definition: Daten sind Information, welche (maschinen-) lesbar und bearbeitbar sind und in einem Bedeutungskontext stehen. Die Information wird dazu in Zeichen oder Zeichenketten codiert. Die Codierung erfolgt gemäss klarer Regeln, der sogenannten Syntax. Daten sind darum Informationen mit folgenden Eigenschaften: 1. Die Bezeichnung erklärt den semantischen Teil (die Bedeutung) des Datenobjekts. 2. Die Wertemenge bestimmt die Syntax (die Form oder Codier-Regel) des Datenobjekts. 3. Der Speicherplatz lokalisiert das Datenobjekts im Speicher und identifiziert dieses eindeutig. Definition: Ein Datentyp ist eine (endliche) Menge (der Wertebereich des Typs) zusammen mit einer Anzahl Operationen. Der Wertebereich eines Datentyps bestimmt, was für Werte ein Objekt dieses Typs annehmen kann. Die Elemente des Wertebereichs bezeichnet man auch als Konstanten des Datentyps. Dazu gehören die Methoden oder Operatoren, welche auf dem Wertebereich definiert sind und somit auf Objekte dieses Typs angewandt werden können. Beispiel: Der Wertebereich des Datentyps int besteht aus Auf diesem Datentyp gibt es die Operationen Es ist wichtig, dass wir zwischen der (abstrakten) Beschreibung eines Datentyps (Spezifikation) und dessen Implementierung unterscheiden. Wenn wir komplizierte Probleme lösen wollen, müssen wir von den Details abstrahieren können. Wir wollen nicht wissen (müssen) wie genau ein Datentyp implementiert ist, sondern bloss, wie wir den Datentyp verwenden können (welche Dienste er anbietet). 1.2 Einige Begriffe: Datenstrukturen 1-3 Jedes Objekt besitzt einen Datentyp, der bestimmt, welche Werte dieses Objekt annehmen kann und welche Operationen auf diesen Werten erlaubt sind. In allen Programmiersprachen gibt es nun Variablen, welche diese Objekte repräsentieren können. Es stellt sich nun die Frage, ob auch den Variablen zwingend ein Datentyp zugewiesen werden soll. Diese Frage wird in verschiedenen Programmiersprachen unterschiedlich beantwortet. In untypisierten Sprachen wird den Variablen keinen Datentyp zugeordnet. Das heisst, jede Variable kann Objekte von einem beliebigen Typ repräsentieren. Die Programmiersprachen Smalltalk und Lisp sind typische Repräsentanten dieser Philosophie. In untypisierten Sprachen kann der Compiler keine sogenannten Typentests durchführen. Zur Kompilationszeit sind alle Operationen auf allen Variablen möglich. Es wird also zur Compilationszeit nicht nachgeprüft, ob gewisse Operationen überhaupt erlaubt sind. Unerlaubte Operationen führen zu Laufzeitfehlern. In typisierten Sprachen wird allen Variablen ein Datentyp zugeordnet. Entweder müssen alle Variablen deklariert werden, wie in den Sprachen Pascal, C, C++ oder Eiffel, oder der Datentyp wird aus der Notation der Variablen klar wie etwa in der Sprache Fortran oder Basic (in Basic sind Variablen, welche mit dem Zeichen % enden, vom Typ Integer). In einer typisierten Sprache kann schon der Compiler entscheiden, ob die angegebenen Operationen typkorrekt sind oder nicht. Als atomare Typen bezeichnen wir Datentypen, die in einer Sprache schon vordefiniert sind. Die atomaren Typen sind die grundlegenden Bausteine des Typsystems einer Programmiersprache. Aus diesen atomaren Typen können mit Hilfe von Mengenoperationen (Subtypen, Kartesische Produkte, Listen, ...) weitere Typen abgeleitet werden. Welche atomaren Typen zur Verfügung stehen, hängt von der gewählten Programmiersprache ab. In allen wichtigen Programmiersprachen existieren die atomaren Typen Integer (ganze Zahlen), Float (reelle Zahlen, Fliesskomma), Boolean (logische Werte) und Char (Schriftzeichen). Dabei ist zu bemerken, dass diese atomaren Typen natürlich nur eine endliche Teilmenge aus dem Bereich der ganzen, bzw. der reellen Zahlen darstellen können. 1-4 1 Einführung Beispiel: Der strukturierte Typ Array wird aus zwei gegebenen Datentypen, einem Grundtyp und einem Indextyp konstruiert. Der Grundtyp ist ein beliebiger atomarer oder abgeleiteter Datentyp. Der Indextyp ist normalerweise ein Subtyp (oder Intervall) des Typs int . Auf Arrays ist immer ein Selektor definiert, welcher es erlaubt, ein einzelnes Element des Arrays zu lesen oder zu schreiben. Definition: Ein strukturierter Datentyp (eine Klasse) entsteht, wenn wir Elemente von beliebigen Typen zu einer Einheit zusammenfassen. Ein solcher Typ ist formal gesprochen das kartesische Produkt von beliebigen Datentypen. DT = DT1 × DT2 × DT3 × . . . × DTn Die Datentypen DT1 , . . . , DTn können atomare oder auch strukturierte Typen sein. Dazu gehört ausserdem die Spezifikation der zugehörigen Operationen oder Methoden auf DT . Beispiel: Wir definieren ein einfaches Interface PushButton als Basis für einen Button auf einer Benutzeroberfläche). 1.2 Einige Begriffe: Datenstrukturen 1-5 Abstrakter Datentyp Der abstrakte Datentyp ist ein wichtiges Konzept in der modernen Informatik: Die Philosophie der objektorientierten Sprachen basiert genau auf dieser Idee. Der abstrakte Datentyp dient dazu, Datentypen unabhängig von deren Implementation zu definieren. Die Idee des abstrakten Datentyps beruht auf zwei wichtigen Prinzipien: dem Geheimnisprinzip und dem Prinzip der Wiederverwendbarkeit. Geheimnisprinzip: Dem Benutzer eines Datentyps werden nur die auf diesem Datentyp erlaubten Operationen (mit deren Spezifikation) bekanntgegeben. Die Implementation des Datentyps bleibt für den Benutzer verborgen (abstrakt, Kapselung). Die Anwendung dieses Prinzips bringt folgende Vorteile: • Der Anwender kann den Datentyp nur im Sinne der Definition verwenden. Er hat keine Möglichkeit, Eigenschaften einer speziellen Implementation auszunutzen. • Die Implementation eines Datentyps kann jederzeit verändert werden, ohne dass die Benutzer des Datentyps davon betroffen sind. • Die Verantwortungen zwischen dem Anwender und dem Implementator des Datentyps sind durch die Interface-Definitionen klar geregelt. Die Suche nach Fehlern wird dadurch erheblich vereinfacht. Wiederverwendbarkeit: Ein Datentyp (Modul) soll in verschiedenen Applikationen wiederverwendbar sein, wenn ähnliche Probleme gelöst werden müssen. Die Idee hinter diesem Prinzip ist klar. Es geht darum, die Entwicklungszeit von Systemen zu reduzieren. Das Ziel ist, Softwaresysteme gleich wie Hardwaresysteme zu bauen, das heisst, die einzelnen Komponenten eines Systems werden eingekauft, eventuell parametrisiert und zum Gesamtsystem verbunden. 1-6 1 Einführung Ein abstrakter Datentyp definiert einen Datentyp nur mit Hilfe des Wertebereichs und der Menge der Operationen auf diesem Bereich. Jede Operation ist definiert durch ihre Spezifikation, also die Input- und Output-Parameter und die Vor- und Nachbedingungen. Die Datenstruktur ist dann eine Instanz eines (abstrakten) Datentyps. Sie beinhaltet also die Repräsentation der Daten und die Implementation von Prozeduren für alle definierten Operatoren. Wir sprechen hierbei auch von der logischen, bzw. der physikalischen Form von Datenelementen. Die Definition des abstrakten Datentyps ist die logische, deren Implementation die physikalische Form des Datenelements. Der abstrakte Datentyp spezifiziert einen Typ nicht mit Hilfe einer Implementation, sondern nur als eine Liste von Dienstleistungen, die der Datentyp dem Anwender zur Verfügung stellt. Die Dienstleistungen nennt man auch Operationen, Methoden oder Funktionen. Ein abstrakter Datentyp kann viele verschiedene Implementationen oder Darstellungen haben. Der abstrakte Datentyp gibt darum nicht an, wie die verschiedenen Operationen implementiert oder die Daten repräsentiert sind. Diese Details bleiben vor dem Benutzer verborgen. Beispiel: Der abstrakte Datentyp Stack wird durch die Menge der angebotenen Dienste definiert: Einfügen eines Elements (push ), entfernen eines Elements (pop ), lesen des obersten Elements (peek ) und prüfen auf leer (empty ). Eine solche Beschreibung berücksichtigt also nur, was ein Stack dem Anwender zu bieten hat. Bei den verschiedenen Methoden muss stehen, was die Methoden tun oder bewirken (Nachbedingung) und was für Voraussetzungen (Einschränkungen, Vorbedingungen) an die Verwendung der Methoden gestellt sind.1 In Java könnte ein Interface für einen Stack wie folgt aussehen: 1 Optimalerweise steht noch dabei, welchen Aufwand die Methode hat. 1.2 Einige Begriffe: Datenstrukturen 1-7 public interface Stack<E> { /** * Pushes an item onto the top of this stack. * @param item the item to be pushed onto this stack. * @return the item argument. */ E push(E item); /** * Removes the object at the top of this stack and returns that * object as the value of this function. * @return The object at the top of this stack (the last * item of the Vector object). * @exception EmptyStackException if this stack is empty. */ E pop(); /** * Looks at the object at the top of this stack without removing * it from the stack. * @return the object at the top of this stack (the last * item of the Vector object). * @exception EmptyStackException if this stack is empty. */ E peek(); /** * Tests if this stack is empty. * @return true if and only if this stack contains no items; */ boolean empty(); } Bei den Methoden push und empty gibt es keine Vorbedingungen. Die Methoden pop und peek werfen eine Runtime-Exception, wenn der Stack leer ist. 1-8 1 Einführung Die Spezifikation eines Datentyps muss vollständig, präzise und eindeutig sein. Weiter wollen wir keine Beschreibung, die auf der konkreten Implementation des Datentyps basiert, obwohl diese die geforderten Kriterien erfüllen würde. Eine Beschreibung, die auf der Implementation basiert, führt zu einer Überspezifikation des Datentyps. Konkret können wir den Datentyp Stack zum Beispiel als Arraystruktur (mit einem Zeiger auf das aktuelle oberste Element head des Stacks) implementieren. Flexibler ist allerdings die Implementation mit Hilfe einer Listenstruktur.. 1.3 Einige Begriffe: Algorithmen 1-9 1.3 Einige Begriffe: Algorithmen Ein Algorithmus2 beschreibt das Vorgehen oder eine Methode, mit der eine Aufgabe oder ein Problem gelöst werden kann, bzw. mit der eine Funktion berechnet werden kann. Ein Algorithmus besteht aus einer Folge von einfachen (Rechen-)Schritten (Anweisungen), welche zur Lösung der gestellten Aufgabe führen. Der Algorithmusgedanke ist keine Besonderheit der Informatik. In fast allen Naturwissenschaften aber auch im Alltag werden Arbeitsvorgänge mit Hilfe von Algorithmen beschrieben. Jeder Algorithmus muss die folgenden Eigenschaften erfüllen: 1. Er muss aus einer Reihe von konkret ausführbaren Schritten bestehen. 2. Er muss in einem endlichen Text beschreibbar sein. 3. Er darf nur endlich viele Schritte benötigen (Termination). 4. Er darf zu jedem Zeitpunkt nur endlich viel Speicherplatz benötigen. 5. Er muss bei der gleichen Eingabe immer das selbe Ergebnis liefern. 6. Nach der Ausführung eines Schrittes ist eindeutig festgelegt, welcher Schritt als nächstes auszuführen ist. 7. Der vom Algorithmus berechnete Ausgabewert muss richtig sein (Korrektheit). Bemerkung: Die Forderung nach Eindeutigkeit wird etwa in parallelen oder probabilistischen Algorithmen zum Teil fallengelassen. Nach dem Abschluss eines einzelnen Schrittes ist der nächste Schritt nicht eindeutig bestimmt, sondern es existiert eine endliche Menge von (möglichen) nächsten Schritten. Die Auswahl des nächsten Schrittes aus der gegebenen Menge ist nichtdeterministisch. Der Anspruch, dass alle Algorithmen terminieren müssen, bedeutet, dass nicht alle von uns benutzten Programme Algorithmen sind. Editoren, Shells oder das Betriebssystem sind alles Programme, die nicht (von selber) terminieren. Wir können aber jedes dieser Programme als Sammlung von verschiedenen Algorithmen betrachten, welche in verschiedenen Situationen zur Anwendung kommen. 2 Das Wort Algorithmus stammt vom Persischen Autor Abu Ja’far Mohammed ibn Mûsâ al-Khowârizmı̂, welcher ungefähr 825 vor Christus ein Buch über arithmetische Regeln schrieb. 1-10 1 Einführung Algorithmen werden der Einfachheit halber oft in einer Pseudocode Sprache formuliert. Damit erspart man sich alle technischen Probleme, welche die konkrete Umsetzung in eine Programmiersprache mitbringen könnte. Beispiel: Grösster gemeinsamer Teiler von m und n: Die kleinere der beiden Zahlen wird so lange von der grösseren subtrahiert, bis beide Werte gleich sind. Dies ist dann der GgT von m und n. Initialisiere m und n Wiederhole solange m und n nicht gleich sind Ja Ist m > n ? Verringere m um n Verringere n um m Gib m aus Siehe auch [4]: Programmieren in Java, Kapitel 3. Beispiele von Algorithmen in Java int proc( int n ) { return n/2; } bool isPrim( int n ) { return false; } // Nein return true if n is a prime 1.3 Einige Begriffe: Algorithmen Das nächste Beispiel stammt von L. Collatz (1937): long stepNum( long n ) { // return number of steps long m = 0; while( n > 1 ) { if( n%2 == 0 ){ n = n/2; } else { n = 3*n + 1; } m++; } return m; } 1-11 1-12 1 Einführung 1.4 Algorithmen Schema: Iteration Unter einem Algorithmen-Schema verstehen wir ein Verfahrens-Muster oder eine allgemeine Methode, mit welcher ein Problem gelöst werden kann. Nicht jede Methode ist für jedes Problem gleich gut geeignet. Umso wichtiger ist es also, die verschiedenen Algorithmen-Schemata zu kennen. Ein Problem wird durch Iteration gelöst, falls der zugehörige Algorithmus einen Loop (while- oder forSchleife) benutzt. Iteration ist zum Beispiel dann sinnvoll, wenn die Daten in einem Array (oder einer Liste) abgelegt sind und wir mit jedem Element des Array die gleichen Schritte durchführen müssen3 . Beispiel: Das Addieren zweier Vektoren kann wie folgt implementiert werden: public DVector sum(DVector v1) throws VectorException { if (v1.size != size) throw new VectorException("Incompatible vector length"); DVector res = new DVector(size); for (int i = 0; i < size; i++) res.value[i] = v1.value[i] + value[i]; return res; } 1.5 Algorithmen Schema: Greedy (die gierige Methode) Greedy-Verfahren werden vor allem dann erfolgreich eingesetzt, wenn von n möglichen Lösungen eines Problems die bezüglich einer Bewertungsfunktion f optimale Lösung gesucht wird (Optimierungsprobleme). Die Greedy-Methode arbeitet in Schritten, ohne mehr als einen Schritt voraus- oder zurückzublicken. Bei jedem Schritt wird aus einer Menge von möglichen Wegen derjenige ausgesucht, der den Bedingungen des Problems genügt und lokal optimal ist. 3 Solche Algorithmen lassen sich oft auch sehr einfach parallelisieren. 1.5 Algorithmen Schema: Greedy (die gierige Methode) 1-13 Wir wollen die Arbeitsweise dieser Methode an einem anschaulichen Beispiel illustrieren. Wir nehmen an, dass jemand sich irgendwo auf einem Berg befindet und so schnell wie möglich zum Gipfel kommen möchte. Eine einfache Greedy-Strategie für dieses Problem ist die folgende: Bewege dich immer entlang der grössten Steigung nach oben bis dies nicht mehr möglich ist, das heisst, bis in allen Richtungen die Wege nur noch nach unten führen. Dieser Ansatz ist in der Abbildung 1.1 dargestellt. Es ist ein typischer Greedy-Ansatz. Man schaut nicht zurück und wählt jeweils die lokal optimale Strategie. Abbildung 1.1: Hill climbing Maximum Lokales Maximum Abbildung 1.2: Erreichen eines lokalen Maximums mit Greedy In der Abbildung 1.2 sehen wir aber, dass diese Strategie nicht unbedingt zum (optimalen) Ziel führt. Hat der Berg mehrere Nebengipfel, so bleiben wir vielleicht auf einem solchen Nebengipfel stehen. Bei Problemen dieser Art liefert oft nur ein exponentieller Algorithmus eine global beste Lösung, während ein heuristischer Ansatz4 mit Greedy nicht immer die beste Lösung liefert, dies aber in polynomialer Zeit. Ähnliche Probleme sind das Finden von kürzesten Wegen, oder besten (Spiel-)Strategien, Verpackungsprobleme (möglichst viele verschieden grosse Kisten in einen Lastwagen packen) oder Scheduling von verschieden langen Prozessen auf Mehrprozessor-Rechnern. 4 Eine Heuristik ist eine Richtlinie, ein generelles Prinzip oder eine Daumenregel, welche als Entscheidungshilfe benutzt werden kann. 1-14 1 Einführung Ein weiteres Problem dieser Art ist das Suchen eines minimalen Pfades in einem allgemeinen Graphen. Um eine optimale Lösung zu finden, müssten wir im wesentlichen sämtliche Pfade abgehen und deren Gewichte aufschreiben. Ein Greedy-Algorithmus löst das Problem viel schneller, indem er jeweils lokal den kürzesten (leichtesten) Pfad wählt. Allerdings findet man mit dieser Methode nicht unbedingt den insgesamt kürzesten Pfad. Es existieren aber auch Probleme, bei denen der Greedy-Ansatz zum optimalen Ergebnis führt. Ein Greedy-Algorithmus löst das folgende Problem: Finde ein minimales (maximales) Gerüst in einem gewichteten Graphen. Dabei wählt man jeweils die Kante, die das kleinste (grösste) Gewicht hat und keinen Zyklus verursacht. Der Algorithmus ist fertig, sobald ein zusammenhängender Teilgraph entstanden ist. 9 9 3 3 7 5 6 6 5 5 x 7 5 4 4 6 8 7 6 8 7 3 5 1 8 8 7 8 8 4 3 5 1 4 y 3 7 9 5 x 1.6 Algorithmen Schema: Rekursion 1-15 1.6 Algorithmen Schema: Rekursion Rekursion ist ein fundamentales Konzept der Informatik. Eine Prozedur heisst rekursiv, wenn sie sich direkt oder indirekt selber aufruft. Dabei müssen wir darauf achten, dass eine Abbruchbedingung existiert, damit die Prozedur in jedem Fall terminiert. Beispiele: Die rekursive Implementation der Fakultätsfunktion: long factorial( int n ) { if( n <= 1 ) return 1; return n*factorial(--n); } Der rekursive Aufruf kann auch indirekt erfolgen: int proc( int a, int b ) { if( b-a < 5 ) return sub( b ); return a * proc(a-1, b/2) } int sub( int c ) { if( c%2 == 0 ) return c*c; return proc(c-2,c+1); } Bei einer rekursiven Prozedur sind die folgenden Punkte besonders zu beachten: • Die Rekursion darf nicht unendlich sein. Es muss also in der Prozedur ein Instruktionszweig existieren, der keinen Aufruf der Prozedur enthält. Diesen Teil der Prozedur nennt man den Rekursionsanfang. Bei indirekter Rekursion (Prozedur A ruft Prozedur B auf und B ruft wieder A auf) ist jeweils besondere Vorsicht geboten. • Es muss sichergestellt sein, dass die Anzahl der hintereinander ausgeführten rekursiven Aufrufe (also die Rekursionstiefe) vernünftig bleibt, da sonst zu viel Speicher verwendet wird. Beim rekursiven Sortieren von n Elementen sollten zum Beispiel nur O(log(n)) rekursive Aufrufe nötig sein. • Rekursion soll dann angewandt werden, wenn die Formulierung der Lösung dadurch klarer und kürzer wird. Auch darf der Aufwand der rekursiven Lösung in der Ordnung nicht grösser werden 1-16 1 Einführung als der Aufwand der iterativen Lösung. Insbesondere kann die Rekursion leicht eliminiert werden, wenn die Prozedur nur einen rekursiven Aufruf enthält und dieser Aufruf die letzte Instruktion der Prozedur ist (tail recursion, diese wird von einem optimierenden Compiler normalerweise automatisch eliminiert.) Beispiel Die Fibonacci Funktion ist wie folgt definiert: fibonacci(0) = 1 fibonacci(1) = 1 fibonacci(n + 2) = fibonacci(n + 1) + fibonacci(n) Diese Definition kann direkt in dieser Form als Rekursion implementiert werden: Diese Implementierung führt zu einem exponentiellen Aufwand5 . Auf jeder Stufe sind zwei rekursive Aufrufe nötig, welche jeweils unabhängig voneinander die gleichen Funktionswerte berechnen. Eine bessere Implementation (ohne Rekursion) benötigt nur linearen Aufwand (vgl. Übung). 1.6.1 Rekursionselimination Wie bereits vorher erwähnt, soll Rekursion nur dann verwendet werden, wenn dadurch die Programme einfacher lesbar werden und die Komplexität nicht grösser als die der iterativen Lösung ist. Ist ein Problem durch eine (unnötig aufwändige) Rekursion formuliert, stellt sich die Frage, ob und wie sich die Rekursion allenfalls eliminieren lässt. Prinzipiell kann dies durch folgendes Vorgehen versucht werden: 5 Die Prozedur benötigt zum Berechnen von fib(n) in der Grössenordnung von 2·fib(n) rekursive Aufrufe. 1.6 Algorithmen Schema: Rekursion 1-17 Umdrehen der Berechnung (von unten nach oben). Abspeichern der Zwischenresultate in einen Array, eine Liste oder einen Stack. Beispiel: Gegeben ist die folgende rekursive Funktion, die wir in eine nichtrekursive Prozedur umschreiben wollen: long rekFunction(int x, int y) { if( x <= 0 || y <= 0 ) return 0; return x + y + rekFunction(x-1, y); } Etwas komplizierter wird die Rekursionselimination, wenn die Funktion, wie im folgenden Beispiel, von zwei Parametern abhängt: long Pascal(int x, int y) { if( x <= 0 || y <= 0 ) return 1; return Pascal(x-1, y) + Pascal(x, y-1); } 1-18 1 Einführung 1.6.2 Divide and Conquer Die Divide and Conquer Methode (kurz: DAC) zerlegt das zu lösende Problem in kleinere Teilprobleme (divide) bis die Lösung der einzelnen Teilprobleme (conquer) einfach ist. Anschliessend werden die Teillösungen zur Gesamtlösung vereinigt (merge)6 . Da das Problem in immer kleinere Teilprobleme zerlegt wird, welche alle auf die gleiche Art gelöst werden, ergibt sich normalerweise ein Lösungsansatz mit Rekursion. Ein DAC-Algorithmus hat also folgende allgemeine Form: void DAC( problem P ) { if( Lösung von P sehr einfach ) { return Lösung(P) // conquer } else { divide( P, Teil1 , . . . ,Teiln ); return combine( DAC(Teil1 ),. . .,DAC(Teiln ) ); } } DAC-Algorithmen können grob in die beiden folgenden Kategorien unterteilt werden. • Das Aufteilen in Teilprobleme (divide) ist einfach, dafür ist das Zusammensetzen der Teillösungen (merge) schwierig. • Das Aufteilen in Teilprobleme (divide) ist schwierig, dafür ist das Zusammensetzen der Teillösungen (merge) einfach. Wenn sowohl das Aufteilen in Teilprobleme als auch das Zusammensetzen der Teillösungen schwierig ist, ist Divide and Conquer vermutlich nicht der richtige Ansatz. 6 Das Divide and Conquer Schema eignet sich vor allem auch zum parallelen oder verteilten Lösen von Problemen. 1.6 Algorithmen Schema: Rekursion 1-19 Bekannte Beispiele für Divide and Conquer sind die Sortieralgorithmen Quicksort und Mergesort. Quicksort : (Hard Split Easy Join) Die Elemente werden gemäss einem Pivotelement in verschiedene Mengen aufgeteilt. Das Einsammeln ist dann trivial. Mergesort : (Easy Split Hard Join) Die Elemente werden in beliebige (gleichgrosse) Mengen aufgeteilt. Beim Einsammeln der verschiedenen (sortierten) Mengen muss nachsortiert werden. void Sort( Menge P ) { if( P besteht aus wenigen Elementen ) // zum Beispiel aus weniger als 10 { verwende einfachen (linearen) Sortieralgorithmus und gib sortierte Menge zurück } else { divide( P, Teil1 , . . . ,Teiln ); // Zerteile P in n Teile // Füge die sortierten Mengen zusammen (trivial oder durch Nachsortieren). return merge( Sort(Teil1 ),. . .,Sort(Teiln ) ); } } 1-20 1 Einführung 1.7 Übung 1 1. Nichtdeterministischer Primzahltest Das folgende Verfahren testet, ob ein Kandidat P eine Primzahl ist: Wählen Sie eine genügend grosse Menge beliebiger (zufälliger) Zahlen zi und versuchen Sie nacheinander, P durch zi zu teilen. Falls keine der Zahlen zi ein Teiler ist, geben Sie true zurück, andernfalls false. Formulieren Sie für das Verfahren einen Algorithmus in Pseudocode (Initialisierung, sequenzelle Anweisungen, if, while, ...) 2. Rekursionselimination: Gegeben ist die folgende Implementation der Fibonacci-Funktion: public long fibonacci( int n ) { if( n < 2 ) return 1; return fibonacci(n-1) + fibonacci(n-2); } Finden Sie eine effizientere Implementierung ohne Rekursion für die Berechnung der Fibonacci-Zahlen. 3. Rekursionselimination: Eliminieren Sie aus den der folgenden Prozedur die Rekursion: public long procRek(int n) { if(n<=3) return 2; else return 2*procRek(n-1) + procRek(n-2)/2 - procRek(n-3); } 4. Rekursion: Zählen der Knoten eines Baumes Implementieren Sie eine Methode countNodes(), welche mit Hilfe einer Rekursion die Anzahl Knoten eines Baumes berechnet. Die Anzahl Knoten eines Baumes sind rekursiv wie folgt definiert: Wenn ein Baum nur aus einem Blatt (leaf) besteht, dann gilt countNodes(leaf) = 1. Sonst gilt countNodes(node) = 1 + sum(countNodes(c): c the children of node) Rahmenprogramme finden Sie unter www.sws.bfh.ch/ ∼amrhein/AlgoData/uebung1 2 Komplexität von Algorithmen 2.1 Komplexitätstheorie Nicht alle (mathematischen) Probleme (Funktionen) sind algorithmisch lösbar (berechenbar). Ausserdem sind unter den berechenbaren Funktionen viele, deren Berechnung sehr aufwändig und deshalb undurchführbar ist. In diesem Abschnitt wollen wir nun die prinzipiell berechenbaren Probleme weiter unterteilen: in solche, die mit vernünftigem Aufwand lösbar sind und die restlichen. Alle Funktionen Berechenbare Funktionen Durchführbare Algorithmen Abbildung 2.1: Durchführbare Algorithmen Für lösbare Probleme ist es wichtig zu wissen, wieviele Betriebsmittel (Ressourcen) für ihre Lösung erforderlich sind. Nur solche Algorithmen, die eine vertretbare Menge an Betriebsmitteln benötigen, sind tatsächlich von praktischem Interesse. 2-2 2 Komplexität von Algorithmen Die Komplexitätstheorie stellt die Frage nach dem Gebrauch von Betriebsmitteln und versucht diese zu beantworten. Normalerweise werden für einen Algorithmus die Betriebsmittel Zeit- und Speicherbedarf untersucht. Mit Zeitbedarf meint man die Anzahl benötigter Rechenschritte1 . 2.2 Komplexitätsanalyse Mit Hilfe der Komplexitätsanalyse können wir die Effizienz verschiedener Algorithmen vergleichen, bzw. versuchen zu entscheiden, ob ein Algorithmus das Problem im Allgemeinen innert nützlicher Frist löst. Eine Möglichkeit, die Effizienz verschiedener Algorithmen zu vergleichen wäre, alle Algorithmen zu implementieren und die benötigte Zeit und den Platzverbrauch zu messen. Allerdings ist dieses Verfahren höchst ineffektiv. Es muss unnötig viel programmiert werden. Wir können auch nicht einschätzen, ob nicht ein Algorithmus schlechter programmiert wurde als die anderen oder ob die Testbeispiele eventuell einen Algorithmus begünstigen2 . Auch mit Hilfe einer Komplexitätsanalyse können wir nicht wirklich entscheiden, ob ein Programm schnell laufen wird. Vielleicht kann ein optimierender Compiler den einen Code besser unterstützen als den anderen. Vielleicht sind gewisse Speicherzugriffe übers Netz nötig, die den Code langsam machen. Möglicherweise ist der Algorithmus auch einfach schlecht implementiert. Dennoch kann eine Komplexitätsanalyse einen Hinweis geben, ob ein Algorithmus überhaupt prinzipiell für unser Problem in Frage kommt. Durch das Zählen der Anzahl nötiger Rechenschritte können wir zumindest verschiedene Algorithmen einigermassen fair vergleichen. Ein Rechenschritt besteht dabei aus einer einfachen Operation, einer Zuweisung oder einem Vergleich (was normalerweise in einer Programmzeile steht). Algorithmen nehmen Eingabedaten entgegen und führen mit diesen eine Verarbeitung durch. Die Anzahl Rechenschritte hängt normalerweise von der Länge (Grösse) der Eingabedaten ab. • Ein Problem kann durch verschiedene Algorithmen mit verschiedener Komplexität gelöst werden. Für Probleme, welche sehr oft gelöst werden müssen, ist es von grossem Interesse, einen Algorithmus zu finden, welcher möglichst wenig Betriebsmittel erfordert. 1 Die Komplexität eines Algorithmus ist natürlich unabhängig von der Geschwindigkeit des verwendeten Computers. 2 Wir müssten fairerweise alle möglichen Eingaben testen, was natürlich nicht machbar ist. 2.2 Komplexitätsanalyse 2-3 • Die Komplexität eines Algorithmus hängt von der Grösse der Eingabedaten ab. Je grösser die Dimension n der Matrizen, desto länger wird die Ausführung des Algorithmus dauern. Im Allgemeinen können wir die Komplexität eines Algorithmus als Funktion der Länge der Eingabedaten angeben. Als Vereinfachung betrachten wir normalerweise nicht die (exakte) Länge der Eingabe (zum Beispiel in Anzahl Bytes), sondern grössere, für das Problem natürliche Einheiten. Man spricht dann von der natürlichen Länge des Problems. Will man nur eine Grössenordnung für die Komplexität eines Algorithmus angeben, so zählt man auch nicht alle Operationen, sondern nur diejenigen, welche für die Lösung des Problems am wichtigsten (zeitintensivsten) sind. In der folgenden Tabelle sind Probleme mit ihrer natürlichen Länge und ihren wichtigsten Operationen angegeben. Problem natürliche Einheit Operationen Algorithmen auf ganzen Zahlen (z.B. Primzahlalgorithmen ) Suchalgorithmen Sortieralgorithmen Algorithmen auf reellen Zahlen Matrix Algorithmen Anzahl Ziffern Operationen in Anzahl Elemente Anzahl Elemente Länge der Eingabe Dimension der Matrix Vergleiche Vergleiche, Vertauschungen Operationen in IR Operationen in IR Beispiel: Wir berechnen die Komplexität der folgenden Prozeduren, indem wir die Anzahl Aufrufe von do something() (abhängig vom Input n ) zählen. int proc1( int n ) { int res = 0; for( i = 0; i < n; i++ ) res = do_something(i); return res; } int proc2( int n ) { int res = 0; for( i = 0; i < n; i++ ) for( j = 0; j < n; j++ ) res = do_something(i, j); return res; } Wir verändern die Prozedur etwas und berechnen wiederum die Komplexität. 2-4 2 Komplexität von Algorithmen int proc3( int n ) { int res = 0; for( i = 0; i <= n; i++ ) for( j = 0; j <= i; j++ ) res = do_something(i, j); return res; } Wir zählen auch hier, wie oft do something() aufgerufen wird. 2.2.1 Best-Case, Average-Case, Worst-Case Analyse Je nach Input kann die Anzahl benötigter Rechenschritte sehr stark schwanken. Dies geschieht beispielsweise dann, wenn die Prozedur Fallunterscheidungen (if/else) enthält. int proc3( int n ) { int res = 1; if( n % 2 == 0 ) return res; for( int i=0; i<n; i++ ) res = do_something(res, i); return res; } 2.3 Asymptotische Komplexität 2-5 Bei Suchalgorithmen zählen wir die Anzahl nötiger Vergleiche. public int indexOf( Object elem ) { // lineare Suche, elem != null for( int i=0; i < size; i++ ) { if( elem.equals(elementData[i]) ) return i; } return -1; } n = size int procRek( int n ) { int res = do_something(n); if( n <= 1 ) return res; if( n % 2 == 0 ) return procRek(n/2); return procRek(n+1); } 2.3 Asymptotische Komplexität Eine Vereinfachung ergibt sich, wenn man nur das asymptotische Verhalten der Komplexität eines Algorithmus betrachtet. Das asymptotische Verhalten eines Polynoms entspricht dessem grössten Term. Konstante Faktoren werden dabei ignoriert. Beispiel: In der Funktion f (n) = 2n2 − 10n + 20 fällt für wachsendes n der Ausdruck 10n + 20 gegenüber dem Ausdruck 2n2 immer weniger ins Gewicht. Der dominierende Ausdruck ist in diesem Fall 2n2 . 2-6 2 Komplexität von Algorithmen 10000 200 8000 150 6000 100 4000 50 0 2000 2 4 6 8 10 12 0 14 20 40 n 60 80 100 n Das asymptotische Verhalten von f ist also n2 . Man schreibt auch f (n) ∈ O(n2 ) um das Wachstumsverhalten einer Funktion zu klassifizieren. 2 n 2n 1600 2 10n log(n) 1400 20 n 1200 1000 800 10 n 600 400 log(n) 200 10 20 30 40 50 Wir sagen, eine Funktion f hat exponentielles Wachstumsverhalten, wenn der dominierende Term von f (n) von der Form kcn ist, f hat polynomiales Wachstum, falls er von der Form knc ist (c fest!), lineares Wachstum, falls er von der Form kn ist und logarithmisches Wachstum, falls der dominierende Term von der Form k log(n) ist. Wie schon vorher erwähnt, interessiert uns bei der asymptotischen Komplexität nur das proportionale Verhalten. Die O-Notation gibt uns ein Mittel, dies mathematisch auszudrücken: 2.3 Asymptotische Komplexität 2-7 Definition: [O-Notation] Eine Funktion f (n) ist aus O(g(n)), falls es Konstanten c und N gibt, so dass für alle m > N die Beziehung f (m) < cg(m) gilt. Die Notation sagt genau das aus, was wir vorher schon etwas salopp formuliert hatten: Eine Funktion f (n) gehört zu O(g(n)), falls sie (bis auf eine Konstante) nicht schneller wächst als g(n). Man sagt auch, f hat das gleiche asymptotische Verhalten wie g. So gehören zum Beispiel die Funktionen 300n2 + 2n − 1, 10n + 12 und 5n3/2 + n Hingegen gehören die Funktionen 2n oder n3 nicht zu O(n2 ). alle zu O(n2 ). Umgekehrt sagt das Wissen, dass eine Funktion f zu O(g) gehört, nichts über die Konstanten c und N aus. Diese können sehr gross sein, was gleichbedeutend damit ist, dass ein Algorithmus mit dieser (asymptotischen) Komplexität eventuell erst für sehr grosse Eingabewerte sinnvoll einsetzbar ist 3 . Nachfolgend sind einige wichtige Regeln (ohne Beweis) angegeben: • Die Ordnung des Logarithmus ist kleiner als die Ordnung einer linearen Funktion. log(n) ∈ O(n) n ̸∈ O(log(n)) • Die Ordnung eines Polynoms ist gleich der Ordnung des Terms mit der höchsten Potenz. ak nk + ak−1 nk−1 + . . . + a1 n + a0 ∈ O(nk ) • Für zwei Funktionen f und g gilt: O( f + g) = max{O( f ), O(g)} O( f ∗ g) = O( f ) · O(g) • Die Ordnung der Exponentialfunktion ist grösser als die Ordnung eines beliebigen Polynoms. Für alle c > 1 und k gilt: cn ̸∈ O(nk ) 3 Der FFT-Algorithmus für Langzahlarithmetik ist zum Beispiel erst für Zahlen, die mehrere hundert Stellen lang sind, interessant. 2-8 2 Komplexität von Algorithmen Beispiel: Wir berechnen die asymptotische Komplexität der folgenden Prozeduren. int proc4( int n ) { int res = 0, m = n*n; for( i = m; i > 1; i=i/2 ) res = do_something(res, i); return res; } Wir zählen wieder, wie oft do something() aufgerufen wird: Eine andere Methode benötigen wir zum Berechnen der Komplexität im folgenden Beispiel. Der Einfachheit halber nehmen wir an, n sei eine Zweierpotenz (n = 2k ). int procRec( int n ) { int res = 0; if(n <= 1) return res; for( int i = 0; i < n; i++ ) res = do_something(res, i); return procRec(n/2); } Wir zählen wiederum, wie oft do something() aufgerufen wird. 2.4 Übung 2 2-9 2.4 Übung 2 1. Komplexiät von einfachen Prozeduren Berechnen Sie die Komplexität der Prozeduren 1 bis 4. Wie oft wird do something() aufgerufen? Überprüfen Sie Ihre Lösungen, indem Sie die Prozeduren in Java implementieren und einen Zähler einbauen. void procedure1 ( int n ) { for(int i=0; i<=n; i++) do something(i,n); for(int j=n; j>=0; j--) do something(j,n); } void procedure2 ( int n ) { for(int i=0; i<n; i++) for(int j=0; j<2*i; j++) do something(i,j,n); } void procedure3 ( int n ) { for(int i=0; i<n; i++) { int j = 0; while( j < 2*n ) { j++; do something(i,j,n); } } } void procedure4 ( int n ) { int j=n; while( j > 0 ) { j = j/2; do something(i,j,n); } } 2-10 2 Komplexität von Algorithmen 2. Komplexität rekursiver Prozeduren Berechnen Sie die Komplexität der folgenden rekursiven Prozeduren. Wie oft wird do something() ausgeführt? Wählen Sie für n eine Zweierpotenz: n = 2k . void procRec1( int n ) { if( n<=1 ) return; int procRec2( int n, int res ) { res = do_something(res, n); if( n <= 1 ) return res; res = procRec2(n/2, res); res = procRec2(n/2, res); return res; do_something(n) procRec1(n/2); } } 3. Komplexität verschiedener Java Methoden Bestimmen Sie von den Java Klassen ArrayList und LinkedList die asymptotische Komplexität der Methoden - public public public public public public public boolean contains(Object o) E get(int index) E set(int index, E element) boolean add(E o) void add(int index, E element) E remove int(index) boolean remove(Object o) Sie müssen dazu die Algorithmen nicht im Detail verstehen. Es genügt, die Iterationen (auch der benötigten Hilfsfunktionen) zu zählen (wir werden diese Algorithmen in einem späteren Kapitel noch genauer betrachten). 3 Datentypen: Listen, Stacks und Queues Listen, Stacks und Queues können entweder arraybasiert oder zeigerbasiert implementiert werden. Die Implementierung mit Hilfe von Arrays hat den Vorteil, dass ein wahlfreier Zugriff besteht. Der Nachteil hingegen ist, dass wir schon zu Beginn wissen müssen, wie viele Elemente die Liste maximal enthält. Viele Kopieraktionen sind nötig, wenn der gewählte Bereich zu klein gewählt wurde, oder wenn in der Mitte einer Liste ein Element eingefügt oder gelöscht werden soll. Eine flexiblere Implementation bietet die Realisation von Listen mit Hilfe von Zeigerstrukturen. 3.1 Array Listen In einer Array Liste werden die einzelnen Elemente (bzw. die Referenzen auf die Elemente) in einen Array (vom generischen Typ E) abgelegt. initialCapacity E[ ] elementData .... size Der Vorteil von Array Listen ist der direkte Zugriff auf das n-te Element. Der Nachteil ist allerdings, dass bei jedem Einfügen oder Löschen von Elementen der Array (in sich) umkopiert werden muss. Ausserdem 3-2 3 Datentypen: Listen, Stacks und Queues muss der Array in einen neuen, grösseren Array umkopiert werden, sobald die initiale Anzahl Elemente überschritten wird. Die ArrayList benutzt also einen Array von (Zeigern auf) Elementen E als Datenspeicher: public class ArrayList<E> extends AbstractList<E> { private Object[] elementData; private int size; // The number of elements. /** Constructs an empty list with the specified initial capacity. */ public ArrayList(int initialCapacity) { ... } /** Returns true if this list contains no elements. public boolean isEmpty() { ... } */ /** Returns the index of the first occurrence of the specified element. */ public int indexOf(Object elem) { ... } /** Returns the element at the specified position in this list. */ public E get(int index) { ... } /** Inserts the element at the specified position in this list. Shifts any subsequent elements to the right. */ public void add(int index, E element) { ... } /** Removes the element at the specified position in this list. Shifts any subsequent elements to the left. */ public E remove(int index) { ... } /** Increases the capacity of this ArrayList instance. */ public void ensureCapacity(int minCapacity) { ... } ... } 3.1 Array Listen 3-3 Im Konstruktor wird der elementData Array mit Länge initialCapacity initialisiert: public ArrayList(int initialCapacity) { if (initialCapacity < 0) throw new IllegalArgumentException( ... ); elementData = new Object[initialCapacity]; } Der Zugriff auf ein Element an einer gegebenen Stelle ist direkt und damit sehr schnell. public E get(int index) { if (index >= size || index < 0) throw new IndexOutOfBoundsException( ... ); return (E) elementData[index]; } Das Einfügen von neuen Elementen in den Array hingegen ist aufwändig, da der hintere Teil des Array umkopiert werden muss. arrayCopy .... add public void add(int index, E element) { if (index > size || index < 0) throw new IndexOutOfBoundsException(...) ensureCapacityInternal(size + 1); System.arraycopy(elementData, index, elementData, index + 1, size - index); elementData[index] = element; size++; } 3-4 3 Datentypen: Listen, Stacks und Queues Das Gleiche gilt für das Löschen von Elementen aus einer ArrayList. Alle Elemente hinter dem gelöschten Element müssen umkopiert werden. public E remove(int index) { if (index >= size || index < 0) throw new IndexOutOfBoundsException( ... ); E oldValue = elementData(index); int numMoved = size - index - 1; if (numMoved > 0) System.arraycopy(elementData, index+1, elementData, index, numMoved); elementData[--size] = null; // Let gc do its work return oldValue; } Sobald der aktuell angelegte Array voll ist, muss ein neuer Datenspeicher angelegt und der gesamte Array umkopiert werden. public void ensureCapacity(int minCapacity) { if (minCapacity - elementData.length > 0) // -> grow int oldCapacity = elementData.length; int newCapacity = oldCapacity + (oldCapacity >> 1); if (newCapacity - minCapacity < 0) newCapacity = minCapacity; // copy all elements to new (larger) memory area elementData = Arrays.copyOf(elementData, newCapacity); } } 3.2 Doppelt verkettete Listen 3-5 3.2 Doppelt verkettete Listen In einer doppelt verketteten Liste besteht jedes Listenelement aus einem Datenfeld (bzw. einer Referenz auf ein Datenfeld) (element) und zwei Zeigern (next und prev). Als Listenelemente dient die Klasse Node. private static class Node<E> { E item; Node<E> next; Node<E> prev; Node(Node<E> prev, E element, Node<E> next) { this.item = element; this.next = next; this.prev = prev; } } Die Klasse Node ist eine innere Klasse von List und wird einzig zum Verpacken der Datenelemente bentutzt. 3-6 3 Datentypen: Listen, Stacks und Queues Eine (doppelt) verkettete Liste entsteht dann durch Zusammenfügen einzelner Node Elemente. Besondere Node Elemente bezeichnen dabei den Listenanfang und das Ende. Die Definition einer Liste sieht dann zum Beispiel wie folgt aus: public class transient transient transient LinkedList<E> { Node<E> first; Node<E> last; int size = 0; /** * Returns true if this list contains no elements. */ boolean isEmpty(){ ... }; /** * Returns the element at the specified position in this list. * Throws IndexOutOfBoundsException if the index is out of range. */ E get(int index){ ... }; /** * Inserts the element at the specified position in this list. * Throws IndexOutOfBoundsException if the index is out of range. */ void add(int index, E element){ ... }; /** * Removes the element at position index in this list. * Returns the element previously at the specified position. * Throws IndexOutOfBoundsException if the index is out of range. 3.2 Doppelt verkettete Listen */ E remove(int index){ ... }; /** * Returns the index of the first occurrence of the specified * element, or -1 if this list does not contain this element. */ int indexOf(Object o){ ... }; . . . } Wir betrachten hier je eine Implementation für das Einfügen und für das Löschen eines Elementes. Suchen einer bestimmten Stelle Node<E> node(int index) { if (index < (size >> 1)) { Node<E> x = first; for (int i = 0; i < index; i++) x = x.next; return x; } else { Node<E> x = last; for (int i = size - 1; i > index; i--) x = x.prev; return x; } } 3-7 3-8 Einfügen an einer bestimmten Stelle public void add(int index, E element) { checkPositionIndex(index); if (index == size) linkLast(element); else linkBefore(element, node(index)); } void linkLast(E e) { final Node<E> l = last; final Node<E> newNode = new Node<>(l, e, null); last = newNode; if (l == null) first = newNode; else l.next = newNode; size++; } 3 Datentypen: Listen, Stacks und Queues 3.2 Doppelt verkettete Listen void linkBefore(E e, Node<E> succ) { final Node<E> pred = succ.prev; // assert succ != null; final Node<E> newNode = new Node<>(pred, e, succ); succ.prev = newNode; if (pred == null) first = newNode; else pred.next = newNode; size++; } void addFirst(E e) { // oder linkFirst final Node<E> f = first; final Node<E> newNode = new Node<>(null, e, f); first = newNode; if (f == null) last = newNode; else f.prev = newNode; size++; } public void add(E e) { // add at the end final Node<E> l = last; final Node<E> newNode = new Node<>(l, e, null); last = newNode; if (l == null) first = newNode; else l.next = newNode; size++; } Am effizientesten ist also das nicht-sortierte Einfügen, das heisst am Ende oder am Anfang. 3-9 3-10 3 Datentypen: Listen, Stacks und Queues Löschen Beim Löschen von Elementen muss geprüft werden, ob ev. first und/oder last korrigiert werden müssen. public E remove(int index) { if(index >= 0 && index < size) return unlink(node(index)); else throw new IndexOutOfBoundsException( ... ); } E unlink(Node<E> x) { final E element = x.item; final Node<E> next = x.next; final Node<E> prev = x.prev; if (prev == null) { first = next; } else { prev.next = next; } if (next == null) { last = prev; } else { next.prev = prev; } x.item = null; size--; return element; } x.prev = null; x.next = null; 3.3 Stacks und Queues 3-11 3.3 Stacks und Queues Ein Interface für einen Stack hatten wir im Abschnitt 1.2 bereits gesehen. Stacks sind einfache Listenstrukturen, bei denen bloss am Kopf Elemente eingefügt, gelesen, bzw. gelöscht werden dürfen. Wir betrachten hier die Implementation eines Stacks mit Hilfe von Zeigerstrukturen. public class Stack<E> { private Node<E> first; private int size = 0; public E push(E item) { first = new Node<E>(item, first); size++; return item; } public E pop() { if (size==0) throw new EmptyStackException(); Node<E> e = first; E result = e.element; first = e.next; e.element = null; e.next = null; size--; return result; } public E peek() { if (size==0) throw new EmptyStackException(); return first.element; 3-12 3 Datentypen: Listen, Stacks und Queues } public boolean empty() { return size == 0; } private static class Node<E> { E element; Node<E> next; Node(E element, Node<E> next) { this.element = element; this.next = next; } } } In einer Queue können Elemente nur am Ende angefügt werden. Nur am Kopf der Queue können Elemente gelesen, bzw. gelöscht werden. 3.4 Iteratoren 3-13 3.4 Iteratoren Auf Listenstrukturen hat man üblicherweise eine Hilfsklasse, welche zum Durchlaufen der Liste dient. Die zwei wichtigsten Methoden von Iterator Klassen sind hasNext zum Prüfen, ob das Ende der Liste erreicht ist, sowie die Methode next, welche den Inhalt des nächsten Elements zurückgibt. public interface Iterator<E> { /** * Returns true if the iteration has more elements. */ boolean hasNext(); /** * Returns the next element in the iteration. * @exception NoSuchElementException iteration has no more elements. */ E next(); ... } 3-14 3 Datentypen: Listen, Stacks und Queues 3.5 Übung 3 Für die Implementationsaufgabe finden Sie Rahmenprogramme unter www.sws.bfh.ch/∼amrhein/AlgoData/ 1. List Iterator Entwerfen Sie (ausgehend vom Rahmenprogramm) eine Klasse ListItr, welche als Iterator für die LinkedList verwendet werden kann. Implementieren Sie dazu in der MyLinkedList Klasse eine innere Klasse ListItr mit einem Konstruktor ListItr(int index), welcher ein ListIterator Objekt erzeugt, welches an die Position index zeigt. Implementieren Sie ausserdem die Methoden Object next(), boolean hasNext(), boolean hasPrevious() und Object previous(). 2. Queue Implementieren Sie eine Queue gemäss dem gegebenen Rahmenprogramm. - Implementieren Sie die Queue zuerst als Liste. - Als zweites implemtieren Sie die Queue als Array. In der Array-basierten Queue dürfen Sie annehmen, dass die Queue nicht mehr als MAX viele Elemente enthalten muss. Überlegen Sie sich eine Implementierung, welche nicht nach jedem Einfügen oder Löschen den ganzen Array umkopiert. 3. Das Collection Interface Zeichnen Sie die Klassenhierarchie der (wichtigsten) Collection Klassen. Zeichnen Sie die Hierarchie der Interfaces List, Queue, Set und SortedSet, sowie der Klassen ArrayList, HashSet, LinkedHashSet, LinkedList, PriorityQueue, Stack, TreeSet, Vector 4 Datentypen: Bäume, Heaps Alle bisher betrachteten Strukturen waren linear in dem Sinn, dass jedes Element höchstens einen Nachfolger hat. In einem Baum kann jedes Element keinen, einen oder beliebig viele Nachfolger haben. Bäume sind wichtig als Strukturen in der Informatik, da sie auch oft im Alltag auftauchen: zum Darstellen von Abhängigkeiten oder Strukturen, als Organigramme von Firmen, als Familienstammbaum, aber auch zum Beschleunigen der Suche. Definition: Ein Graph ist definiert als ein Paar B = (E, K) bestehend aus je einer endlichen Menge E von Ecken (Knoten, Punkten) und einer Menge von Kanten. Eine Kante wird dargestellt als Zweiermenge von Ecken {x, y}, den Endpunkten der Kante. Ein Baum ist ein Graph mit der zusätzliche Einschränkung, dass es zwischen zwei Ecken nur eine (direkte oder indirekte) Verbindung gibt1 . Wir befassen uns hier zuerst vor allem mit einer besonderen Art von Bäumen: den Binärbaumen. Ein Baum heisst binär, falls jeder Knoten höchstens zwei Nachfolger hat. 1 Ein Baum ist ein zusammenhängender Graph ohne Zyklen. 4-2 . 4 Datentypen: Bäume, Heaps . . . . . . .. . .. Definition: Ein binärer Baum besteht aus einer Wurzel (Root) und (endlich vielen) weiteren Knoten und verbindenden Kanten dazwischen. Jeder Knoten hat entweder keine, ein oder zwei Nachfolgerknoten. Ein Weg in einem Baum ist eine Liste von disjunkten, direkt verbunden Kanten. Ein binärer Baum ist vollständig (von der Höhe n), falls alle inneren Knoten zwei Nachfolger haben und die Blätter maximal Weglänge n bis zur Wurzel haben. Jedem Knoten ist eine Ebene (level) im Baum zugeordnet. Die Ebene eines Knotens ist die Länge des Pfades von diesem Knoten bis zur Wurzel. Die Höhe (height) eines Baums ist die maximale Ebene, auf der sich Knoten befinden. Ein binärer Baum besteht also aus Knoten mit einem (Zeiger auf ein) Datenelement data , einem linken Nachfolgerknoten left und einem rechten Nachfolgerknoten right . left public class BinaryTreeNode<T> { protected T data; protected BinaryTreeNode<T> leftChild; protected BinaryTreeNode<T> rightChild; right 4-3 public BinaryTreeNode(T item){ data=item; } // tree traversals public BinaryTreeNode<T> inOrderFind(T item) { . . . } public BinaryTreeNode<T> postOrderFind(T item) { . . . } public BinaryTreeNode<T> preOrderFind(T item) { . . .} // getter and setter methods . . . public class BinaryTree<T> { protected BinaryTreeNode<T> rootTreeNode; public BinaryTree(BinaryTreeNode<T> root) { this.rootTreeNode = root; } // tree traversals public BinaryTreeNode<T> inOrderFind(T item) { return rootTreeNode.inOrderFind(item); } public BinaryTreeNode<T> preOrderFind(T item) { ... } public BinaryTreeNode<T> postOrderFind(T item) { ... } public BinaryTreeNode<T> postOrderFindStack(T item) { ... } //getter and setter methods . . . 4-4 4 Datentypen: Bäume, Heaps 4.1 Baumdurchläufe Bäume können auf verschiedene Arten durchlaufen werden. Die bekanntesten Verfahren sind Tiefensuche (depth-first-search, DFS) und Breitensuche (breadth-first-search, BFS). Tiefensuche kann unterschieden werden in die drei Typen präorder, postorder und inorder, abhängig von der Reihenfolge der rekursiven Aufrufe. 4.1.1 Tiefensuche Präorder • Betrachte zuerst den Knoten (die Wurzel des Teilbaums), • durchsuche dann den linken Teilbaum, • durchsuche zuletzt den rechten Teilbaum. Inorder • Durchsuche zuerst den linken Teilbaum, • betrachte dann den Knoten, • durchsuche zuletzt den rechten Teilbaum. Postorder • Durchsuche zuerst den linken Teilbaum, • durchsuche dann den rechten Teilbaum, • betrachte zuletzt den Knoten. 4.1 Baumdurchläufe . . . .. . . . . . . . .. . .. . 4-5 . . . . .. . . . . . . . .. . .. Wir betrachten als Beispiel für die Tiefensuche den Präorder-Durchlauf. public BinaryTreeNode<T> preOrderFind(T item) { if (data.equals(item)) return this; if (leftChild != null) { BinaryTreeNode<T> result = leftChild.preOrderFind(item); if (result != null) return result; } if (rightChild != null) { BinaryTreeNode<T> result = rightChild.preOrderFind(item); if (result != null) return result; } return null; 4-6 4 Datentypen: Bäume, Heaps } 4.1.2 Tiefensuche mit Hilfe eines Stacks Mit Hilfe eines Stacks können wir die rekursiven Aufrufe in der präorder Tiefensuche vermeiden. Auf dem Stack werden die später zu behandelnden Baumknoten zwischengespeichert. public BinaryTreeNode<T> preOrderFindStack(T item) { Stack<BinaryTreeNode<T>> stack = new Stack<BinaryTreeNode<T>>(); stack.push(this.rootTreeNode); while (!stack.isEmpty()) { BinaryTreeNode<T> tmp = stack.pop(); if (tmp.getData().equals(item)) return tmp; if (tmp.getRightChild() != null) stack.push(tmp.getRightChild()); if (tmp.getLeftChild() != null) stack.push(tmp.getLeftChild()); } return null; } 4.1 Baumdurchläufe 4-7 4.1.3 Breitensuche mit Hilfe einer Queue Bei der Breitensuche besucht man jeweils nacheinander die Knoten der gleichen Ebene: • Starte bei der Wurzel (Ebene 0). • Bis die Höhe des Baumes erreicht ist, setze den Level um eines höher und gehe von links nach rechts durch alle Knoten dieser Ebene. . . . . . . . .. . .. Bei diesem Verfahren geht man nicht zuerst in die Tiefe, sondern betrachtet von der Wurzel aus zuerst alle Elemente in der näheren Umgebung. Um mittels Breitensuche (levelorder) durch einen Baum zu wandern, müssen wir uns alle Baumknoten einer Ebene merken. Diese Knoten speichern wir in einer Queue ab, so dass wir später darauf zurückgreifen können. public BinaryTreeNode<T> levelOrderFind(T item) { QueueImpl<BinaryTreeNode<T>> queue = new QueueImpl<BinaryTreeNode<T>>(); queue.add(rootTreeNode); while (!queue.isEmpty()) { BinaryTreeNode<T> tmp = queue.poll(); if (tmp.getData().equals(item)) return tmp; if (tmp.getLeftChild() != null) queue.add(tmp.getLeftChild()); if (tmp.getRightChild() != null) queue.add(tmp.getRightChild()); } return null; } 4-8 4 Datentypen: Bäume, Heaps 4.2 Binäre Suchbäume Ein binärer Suchbaum ist ein Baum, welcher folgende zusätzliche Eigenschaft hat: Alle Werte des linken Nachfolger-Baumes eines Knotens K sind kleiner, alle Werte des rechten Nachfolger-Baumes von K sind grösser als der Wert von K selber. Der grosse Vorteil von binären Suchbäumen ist, dass wir sowohl beim Einfügen als auch beim Suchen von Elementen immer bloss einen der zwei Nachfolger untersuchen müssen. Falls der gesuchte Wert kleiner ist als der Wert des Knotens, suchen wir im linken Teilbaum, anderenfalls im rechten Teilbaum weiter. Beispiel: Die folgenden zwei Bäume entstehen durch Einfügen der Zahlen 37, 43, 53, 11, 23, 5, 17, 67, 47 und 41 in einen leeren Baum. Einmal werden die Zahlen von vorne nach hinten eingefügt, das zweite Mal von hinten nach vorne. 4.2 Binäre Suchbäume public class BinarySearchTreeNode <T extends Comparable<T>> { public void add(T item) { int compare = data.compareTo(item); if (compare > 0) { // (data > item)? if (leftChild == null) leftChild = new BinarySearchTreeNode<T>(item); else leftChild.add(item); // left recursion } else { // (item >= data) if (rightChild == null) rightChild = new BinarySearchTreeNode<T>(item); else rightChild.add(item); // right recursion } } public BinarySearchTreeNode<T> find(T item) { int compare = data.compareTo(item); if (compare == 0) return this; if (compare > 0 && leftChild != null) // data > item return leftChild.find(item); if (compare < 0 && rightChild != null) // data < item return rightChild.find(item); return null; } . . . } 4-9 4-10 4 Datentypen: Bäume, Heaps 4.3 B-Bäume Ein B-Baum ist ein stets vollständig balancierter und sortierter Baum. Ein Baum ist vollständig balanciert, wenn alle Äste gleich lang sind. In einem B-Baum darf die Anzahl Kindknoten variieren. Ein 3-4-5 BBaum ist zum Beispiel ein Baum, in welchem jeder Knoten maximal 4 Datenelemente speichern und jeder Knoten (ausser der Wurzel und den Blättern) minimal 3 und maximal 5 Nachfolger haben darf (der Wurzelknoten hat 0-4 Nachfolger, Blätter haben keine Nachfolger). Durch die flexiblere Anzahl Kindknoten ist das Rebalancing weniger häufig nötig. Ein Knoten eines B-Baumes speichert: • • • • eine variable Anzahl s von aufsteigend sortierten Daten-Elementen k1 , . . . , ks eine Markierung isLeaf, die angibt, ob es sich bei dem Knoten um ein Blatt handelt. s + 1 Referenzen auf Kindknoten, falls der Knoten kein Blatt ist. Jeder Kindknoten ist immer mindestens zur Hälfte gefüllt. Die letzte Bedingung lautet formal: es gibt eine Schranke m, so dass m <= s <= 2m gilt. Das heisst, jeder Kindknoten hat mindestens m, aber höchstens 2m Daten-Elemente. Die Werte von k1 , . . . , ks dienen dabei als Splitter. Die Daten-Elemente der Kindknoten ganz links müssen kleiner sein als k1 , diejenigen ganz rechts grösser als ks . Dazwischen müssen die Daten-Elemente des i-ten Kindes grösser als ki und kleiner als ki+1 sein. Das folgende Bild zeigt einen B-Baum mit m gleich 2. Jeder innere Knoten hat also mindestens 2 und maximal 5 Nachfolger. 4.3 B-Bäume 4-11 Operationen in B-Bäumen Suchen Die Suche nach einem Datenelement e läuft in folgenden Schritten ab: Beginne bei der Wurzel als aktuellen Suchknoten k. • Suche in k von links her die Position p des ersten Daten-Elementes x, welches grösser oder gleich e ist. • • • • Falls alle Daten-Elemente von k kleiner sind als e, führe die Suche im Kindknoten ganz rechts weiter. Falls x gleich e ist, ist die Suche zu Ende. Anderfalls wird die Suche beim p-ten Kindelement von k weitergeführt. Falls k ein Blatt ist, kann die Suche abgebrochen werden (fail). Einfügen Beim Einfügen muss jeweils beachtet werden, dass nicht mehr als 2m Daten-Elemente in einem Knoten untergebracht werden können. Zunächst wird das Blatt gesucht, in welches das neue Element eingefügt werden müsste. Dabei kann gleich wie beim Suchen vorgegegangen werden, ausser dass wir immer bis zur Blatt-Tiefe weitersuchen (sogar, wenn wir den Wert unterwegs gefunden haben). Falls es in dem gesuchten Blatt einen freien Platz hat, wird der Wert dort eingefügt. Einfügen des Werts 31 in den folgenden Baum: 4-12 4 Datentypen: Bäume, Heaps Der Wert 31 sollte in das Blatt (30,34,40,44) eingefügt werden. Dieses ist aber bereits voll, muss also aufgeteilt werden. Dies führt dazu, dass der Wert in der Mitte (34) in den Vorgänger- Knoten verschoben wird. Da das alte Blatt ganz rechts vom Knoten (20,28) liegt, wird der Wert 34 rechts angefügt (neuer, grösster Wert dieses Knotens). Damit erhält dieser Knoten neu 3 Werte und 4 Nachfolger. Dieser Prozess muss eventuell mehrmals (in Richtung Wurzel) wiederholt werden, falls durch das Hochschieben des Elements jeweils der Vorgänger-Knoten ebenfalls überläuft. 4.3 B-Bäume 4-13 Löschen von Elementen Beim Löschen eines Elementes muss umgekehrt beachtet werden, dass jeder Knoten nicht weniger als m Datenelemente enthalten muss. Falls das gelöschte Element in einem Blatt liegt, welches mehr als m Datenelemente hat, kann das Element einfach gelöscht werden. Andernfalls können entweder Elemente vom benachbarte Blatt verschoben oder (falls zu wenig Elemente vorhanden sind) zwei Blätter verschmolzen werden. Verschiebung Aus dem linken B-Baum soll das Element 18 gelöscht werden. Dies würde dazu führen, dass das linke Blatt zu wenig Datenelemente hat. Darum wird aus dem rechten Nachbarn das kleinste Element nach oben, und das Splitter-Element des Vorgängers in das linke Blatt verschoben. Analog könnte (falls vorhanden) aus einem linken Nachbarn das grösste Element verschoben werden. Falls ein Element eines inneren Knotens (z.B. das Element 34) gelöscht wird, muss entweder von den linken Nachfolgern das grösste, oder von den rechten Nachfolgern das kleinste Element nach oben verschoben werden, damit weiterhin genügend Elemente (als Splitter) vorhanden sind, und die Ordnung bewahrt wird. 4-14 4 Datentypen: Bäume, Heaps Verschmelzung Aus dem linken B-Baum soll das Element 60 gelöscht werden. Dies würde dazu führen, dass das mittlere Blatt zu wenig Datenelemente hat. Weder der rechte noch der linke Nachbar hat genügend Elemente, um eine Verschiebung durch zu führen - es müssen zwei Blätter verschmolzen werden. Das linke Blatt erhält vom mittleren Blatt das Element 55, sowie von der Wurzel das Element 50. Die Wurzel muss ebenfalls ein Element abgeben, da nach der Verschmelzung bloss noch 2 Nachfolge-Knoten existieren. Das rechte Blatt bleibt unverändert. Mit Hilfe der Verschiebung- und Verschmelzungs-Operation können wir nun beliebige Elemente aus einem B-Baum löschen. Beispiel Aus dem folgenden Baum löschen wir zuerst das Element 75, danach das Element 85: 4.4 Priority Queues 4-15 4.4 Priority Queues In vielen Applikationen will man die verschiedenen Elemente in einer bestimmten Reihenfolge (Priorität) abarbeiten. Allerdings will man das (aufwändige!) Sortieren dieser Elemente nach möglichkeit vermeiden. Eine der bekanntesten Anwendungen in diesem Umfeld sind Scheduling-Algorithmen mit Prioritäten. Alle Prozesse werden gemäss ihrer Priorität in einer Priority Queue gesammelt, so dass immer das Element mit höchster Priorität verfügbar ist. Priority Queues haben aber noch weit mehr Anwendungen, zum Beispiel bei Filekomprimierungs- oder bei Graph-Algorithmen. Eine elegante Möglichkeit der Implementierung einer Priority Queue ist mit Hilfe eines Heaps. 4.4.1 Heaps Ein Heap ist ein (fast) vollständiger Baum, in welchem nur in der untersten Ebene von rechts her Blätter fehlen dürfen. 65 56 52 37 25 48 31 18 45 6 3 15 4-16 4 Datentypen: Bäume, Heaps Definition: [Heap] Ein Heap ist ein vollständiger binärer Baum, dem nur in der untersten Ebene ganz rechts Blätter fehlen dürfen mit folgenden Zusatzeigenschaften. 1. Jeder Knoten im Baum besitzt eine Priorität und eventuell noch weitere Daten. 2. Die Priorität eines Knotens ist immer grösser als (oder gleich wie) die Priorität der Nachkommen. Diese Bedingung heisst Heapbedingung. Aus der Definition kann sofort abgelesen werden, dass die Wurzel des Baumes die höchste Priorität besitzt. Weil der Heap im wesentlichen ein vollständiger binärer Baum ist, lässt er sich einfach als Array2 implementieren. Wir numerieren die Knoten des Baumes von oben nach unten und von links nach rechts. Die so erhaltene Nummerierung ergibt für jeden Knoten seinen Index im Array. Die dargestellten Werte im Baum sind natürlich bloss die Prioritäten der Knoten. Die eigentlichen Daten lassen wir der Einfachheit halber weg. public class Heap<T extends Comparable<T>> { private List<T> heap; public Heap() { heap = new ArrayList<T>(); } public T removeMax() { . . . } public void insert(T data) { . . . } private private private private boolean isLeaf(int position) { . . . } int parent(int position) { . . . } int leftChild(int position) { . . . } int rightChild(int position){ . . . } 2 Dies hat den Nachteil, dass die maximale Anzahl Elemente (size ) beim Erzeugen des Heaps bekannt sein muss. 4.4 Priority Queues 4-17 65 56 52 37 25 48 31 18 15 45 6 3 ... Werden die Knoten auf diese Weise in den Array abgelegt, so gelten für alle i, 0 ≤ i < length die folgenden Regeln: • Der linke Nachfolger des Knotens i befindet sich im Array-Element Ferner gilt: heap[i] heap[ ] • Der rechte Nachfolger des Knotens i befindet sich im Array Element Ferner gilt: heap[i] heap[ ] • Der direkte Vorfahre eines Knotens i befindet sich im Array-Element Ferner gilt: heap[i] heap[ ] Wir sind jetzt in der Lage, die beiden wichtigen Operationen insert und removeMax zu formulieren. 4-18 4 Datentypen: Bäume, Heaps insert Da ein Element hinzugefügt werden muss, erhöhen wir zuerst length um eins. Das neue Element wird dann an der Stelle length-1 eingefügt. Der Array repräsentiert immer noch einen vollständigen binären Baum mit nur rechts unten fehlenden Blättern. Das neue Element verletzt aber eventuell die Heapbedingung. Um wieder einen Heap zu erhalten, vertauschen wir das neue Element solange mit seinen direkten Vorgängern, bis die Heapbedingung wieder erfüllt ist. Diese Methode verfolgt einen direkten Weg von einem Blatt zur Wurzel. Da der binäre Baum vollständig ist, hat ein solcher Weg höchstens die Länge der Höhe des Baumes. Mit anderen Worten, wir brauchen höchstens log2 (n) Vertauschoperationen, um ein Element im Heap einzufügen. 65 52 56 37 25 48 31 18 45 6 3 15 55 public void insert(T data) { heap.add(data); int crt = heap.size() - 1; while ((crt != 0) // heap[crt] > heap[parent(crt)] && (heap.get(crt).compareTo(heap.get(parent(crt))) > 0)) { Collections.swap(heap, crt, parent(crt)); crt = parent(crt); } } } 4.4 Priority Queues 4-19 removeMax Das Element mit der höchsten Priorität befindet sich im Element heap[0] und wird vom Heap entfernt. heap[0] wird nun mit heap[length-1] überschrieben und length um eins verringert. Damit erhalten wir wieder einen fast vollständigen binären Baum. Das neue Element heap[0] verletzt nun vermutlich die Heapbedingung. Wir vertauschen also heap[0] mit dem grösseren seiner beiden Nachfolger und fahren so fort, bis die Heapbedingung wieder erfüllt ist. 65 public T removeMax() { if (heap.isEmpty()) return null; Collections.swap(heap, 0, heap.size() - 1); T element = heap.remove(heap.size() - 1); if (heap.size() > 1) siftDown(0); return element; } 4-20 4 Datentypen: Bäume, Heaps private void siftDown(int position) { while (!isLeaf(position)) { int j = leftChild(position); if ((j < heap.size() - 1) // heap[j] < heap[j+1] && (heap.get(j).compareTo(heap.get(j + 1)) < 0)) { j++; } // heap[position] >= heap[j] if (heap.get(position).compareTo(heap.get(j)) >= 0) { return; } Collections.swap(heap, position, j); position = j; } } 4.5 Übung 4 4-21 4.5 Übung 4 Binäre Suchbäume Bauen Sie aus der folgenden Zahlenreihe zwei binäre Suchbäume, indem Sie die Zahlen einmal von links nach rechts und einmal von rechts nach links lesen. 39, 40, 50, 10, 25, 5, 19, 55, 35, 38, 12, 16, 45 Heaps Löschen Sie aus dem folgenden Heap zuerst drei Elemente, fügen Sie danach ein neues Element mit Priorität 42 in den Heap ein. 52 43 32 35 17 20 22 12 15 6 8 18 5 3 BTree Fügen Sie im folgenden BTree zuerst das Element 42 ein, löschen Sie dann die Elemente 28 und 45. 4-22 5 Suchen 5.1 Grundlagen Suchen ist eine der häufigsten Operationen, die mit dem Computer ausgeführt werden. Normalerweise geht es darum, in einer Menge von Daten die richtigen Informationen zu finden. Wir kennen zum Beispiel einen Namen und suchen die zugehörige Mitgliedernummer. Oder wir geben (mit Hilfe einer EC-Karte) eine Kontonummer ein und das System sucht das dazugehörige Konto. Oder wir kennen eine Telefonnummer und suchen den dazugehörigen Abonnenten, usw. Wenn wir im folgenden jeweils Listen von Zahlen durchsuchen, so tun wir das bloss der Einfachheit halber. Die gleichen Algorithmen können natürlich für beliebige Objekte angewandt werden. Ein Objekt kann zum Beispiel eine Klasse Adresse mit den Member-Variablen name, vorname, strasse, wohnort, telefonNummer, kundenNummer sein. Dann verwenden wir eine der Member-Variablen als Suchschlüssel, also zum Beispiel Adresse.name . Da Suchalgorithmen so häufig verwendet werden, lohnt es sich, diese effizient zu implementieren. Anderseits spielt natürlich die Länge der zu durchsuchenden Datenmenge eine entscheidende Rolle: Je grösser die Datenmenge, desto wichtiger die Effizienz der Suche. Ausserdem spielt die benutzte Datenstruktur eine entscheidende Rolle. Wir werden hier jeweils annehmen, dass wir auf alle Elemente der Datenfolge schnellen wahlfreien Zugriff haben (wie z. Bsp. in einer ArrayList). Falls dies nicht der Fall ist, sind gewisse Suchalgorithmen sehr viel weniger effizient. 5-2 5 Suchen Falls kein wahlfreier Zugriff existiert, kann dies mit Hilfe eines Pointer-Arrays simuliert werden, in welchem die Adressen der Daten-Objekte gespeichert sind. Die richtige Wahl der benutzten Datenstruktur ist entscheidend, ob ein Algorithmus effizient implementiert werden kann oder nicht. 5.2 Lineare Suche Wie der Name schon sagt, gehen wir bei der linearen Suche linear durch die Suchstruktur und testen jedes Element, bis wir das gesuchte finden oder ans Ende gelangen. /** * Searches for the first occurence of the given argument. * @param elem an object. * @return the index of the first occurrence of the argument in * this list; returns -1 if the object is not found. */ public int indexOf(Object elem) { if (elem == null) { for (int i = 0; i < size; i++) if (elementData[i]==null) return i; } else { for (int i = 0; i < size; i++) if (elem.equals(elementData[i])) return i; } return -1; } Die for -Schleife bricht spätestens dann ab, wenn das letzte Element der Liste geprüft ist. Komplexität der linearen Suche Um die Effizienz der linearan Suche zu bestimmen, bestimmen wir die Anzahl der nötigen Vergleiche in Abängigkeit von der Länge n der Folge. 5.3 Binäre Suche 5-3 5.3 Binäre Suche Eine Folge, auf die sehr häufig zugegriffen (und nicht so häufig verändert) wird, sollte wenn möglich sortiert gehalten werden1 . Dies lässt sich leicht realisieren, indem die neuen Elemente jeweils an der richtigen Stelle einsortiert werden2 . Falls die Daten so dargestellt sind, kann das Suchen auf sehr viel schnellere Art und Weise realisiert werden, zum Beispiel durch binäre Suche. Bei der binären Suche wird die Folge in zwei Teile Elem[0] | · · · Elem[p-1] {z } Elem[p+1] | · · · Elem[len] {z } geteilt und das Element Elem[p] mit dem zu suchenden Element a verglichen. Falls Elem[p] kleiner als a ist, suchen wir in der rechten Teilfolge weiter, andernfalls in der linken. r = size l=0 a p = (r+l)/2 Die ersten drei Schritte der binären Suche Da die Folge sortiert ist, ist dieses Vorgehen korrekt. 1 Dies setzt natürlich voraus, dass sich die Datenelemente sortieren lassen, also eine Ordnungsrelation < auf den Datenschlüsseln existiert. In Java bedeutet dies, die Elemente müssen das Comparable Interface erfüllen, d.h. eine compareTo() Methode haben. 2 Im Abschnitt über Bäume sind wir bereits der speziell dafür konzipierten Datenstruktur des binären Baumes begegnet 5-4 5 Suchen Wenn wir die Folge jeweils nicht in der Mitte teilen, sondern p = l oder p = r wählen, erhalten wir die lineare Suche als Spezialfall der binären Suche. /** * return index where item is found, or -1 */ public int binarySearch( Comparable<T>[ ] a, T x ) { int l = 0; int p; int r = a.length - 1; while( l <= r ) { p = ( l + r ) / 2; if( a[p].compareTo( x ) < 0 ) // l = p + 1; else if( a[p].compareTo( x ) > 0 ) r = p - 1; // else return p; } return -1 } Komplexität der binären Suche a[p] < x a[p] > x 5.4 Hashing 5-5 5.4 Hashing Eine Hash-Tabelle ist eine Übersetzungstabelle, mit der man rasch auf jedes gesuchte Element einer Liste zugreifen kann, welche aber trotzdem die Flexibilität einer zeigerbasierten Liste bietet. Hashtabellen werden zum Beispiel auch bei Compilern benutzt, um die Liste der Variablen (und ev. deren Typen) zu verwalten. Ein einfaches Beispiel einer Hash-Tabelle ist ein Array. In einem Array können wir auf jedes Element direkt zugreifen. Einen Array als Hash-Tabelle zu benutzen ist dann günstig, wenn wir genügend Platz haben, um einen Array der Länge Anzahl möglicher Schlüssel anzulegen. Beispiel: Falls alle Mitglieder eines Vereins unterschiedliche Initialen haben, können wir dies als Schlüssel für die Hashtabelle benutzen. Berta Amman Doris Bucher Chris Carter Friedrich Dünner BA DB CC FD 7→ 7 → 7 → 7 → (2,1) (4,2) (3,3) (6,4) Mit einem Array der Länge 26×26 können wir auf jedes Mitglied direkt zugreifen. Hash−Tabelle (H,R) 1 Menge aller Schlüssel (D,H) (U,M) (C,F) ... (H,A) (B,A) (C,C) (W,S) 2 3 4 ... Daten(BA) Daten(DB) Daten(CC) ... Benötigte Schlüssel (D,B) (F,D) 1 2 3 4 5 6 Daten(FD) Der Nachteil hierbei ist, dass wir bei dieser Methode offensichtlich viel Speicherplatz verschwenden. Ausserdem können wir nicht sicher sein, dass nicht eines Tages ein neues Mitglied mit bereits existierenden Initialen in unseren Verein eintreten will. Für beide Probleme versuchen wir im folgenden Lösungen zu finden. 5-6 5 Suchen 5.4.1 Hash-Funktionen Eine Hash-Funktion ist eine Methode, mit welcher wir mit Hilfe von einfachen arithmetischen Operationen die Speicherstelle eines Elementes aus seinem Schlüssel berechnen können. Optimalerweise sollte eine Hash-Funktion jedem Element einer Menge einen anderen Funktionswert zuordnen (vermeiden von Kollisionen). Dies ist leider in der Regel nicht möglich. Allerdings gibt es Hash-Funktionen, welche diesen Anspruch besser, und solche, welcher ihn weniger gut erfüllen. Definition: Eine Hashfunktion sollte mindestens die folgenden Eigenschaften erfüllen. • Der Hashwert eines Objekts muss während der Ausführung der Applikation gleich bleiben. Der Hashwert darf aber bei einer nächsten Ausführung der Applikation anders sein. • Falls zwei Objekte gleich sind gemäss der equals() Methode, muss der Hashwert der beiden Objekte gleich sein. • Zwei verschiedene Objekte können den gleichen Hashwert haben. Allerdings wird die Performance von Applikationen verbessert, wenn unterschiedliche Objekte unterschiedliche Hashwerte haben. Die folgende Hash-Funktion summiert die ASCII-Werte der Buchstaben eines Strings. Für das Einfügen in die Hashtabelle muss dieser Wert dann modulo der Länge der Hashtabelle genommen werden. long Elem_hash(Object key) { String keyString = key.toString(); int sum = 0; for (int i = 0; i < keyString.length(); i++) sum = sum + keyString.charAt(i); return sum; } Wie wir am folgenden Beispiel sehen, funktioniert diese Methode leider nicht allzu gut. Alle Wörter, die aus den gleichen Buchstaben (in anderer Reihenfolge) bestehen, haben den gleichen Funktionswert. Aber auch sonst verteilt diese Methode verschiedene Wörter offensichtlich nicht optimal (erzeugt viele Kollisionen). 5.4 Hashing 5-7 Anna Jochen Otto Gabi Tina Kurt Elem_hash() Tina Otto Jochen Anna Kurt 0 1 2 3 4 Gabi 5 6 7 8 9 10 Eine oft benutzte, gut streuende Hash-Funktion für Strings ist die ELF hash -Methode. long ELFhash(String key) { long h = 0; for (int i = 0; i < key.length(); i++) { h = (h << 4) + (int) key.charAt(i); long g = h & 0xF0000000L; // AND if (g != 0) h = h ˆ g >>> 24; // XOR, Shift right h = h & ˜g; } return h; } Anna Jochen Otto Gabi Tina Kurt ELF_hash() Kurt 0 Otto 1 2 Gabi Anna 3 4 5 Tina 6 Jochen 7 8 9 10 5-8 5 Suchen Natürlich kann auch die ELF hash -Methode nicht alle Kollisionen vermeiden. Die ELF hash -Methode mit Tabellenlänge 11 erhält zum Beispiel für “Otto” und “Martin” den gleichen Schlüssel. Die Frage ist darum: wie behandelt man Kollisionen? 5.4.2 Double Hashing Bei Double Hashing verwenden wir zwei verschiedene Hashfunktionen Hash1 und Hash2 3 . Hash1 dient dazu, die Hashadresse eines Schlüssels in der Tabelle zu suchen. Hash2 dient dazu, bei einer Kollision in der Tabelle den nächsten freien Platz zu suchen. Hash 1 (k) 0 Hash 2(k) leeres Feld besetztes Feld 2 neuer Eintrag 3 4 5 6 7 8 9 10 11 Einfügen eines Objekts mit Schlüssels k: Zuerst wird die Hashadresse Hash1 (k) mod N berechnet. Ist dieser Tabellenplatz noch leer, dann wird das Objekt dort eingetragen. Ist der Tabellenplatz schon besetzt, so wird nacheinander bei den Adressen (Hash1 (k) + Hash2 (k)) mod N, (Hash1 (k) + 2 · Hash2 (k)) mod N, ... 3 Im einfachsten Fall wählen wir Hash2 (k) = p für p gleich 1 oder für eine Primzahl p, welche die Länge N der Hashtabelle nicht teilt. Dann erhalten wir den sog. Linear Probing Algorithmus. Im Allgemeinen kann Hash2 (k) eine beliebige Funktion sein, welche Werte relativ prim zu N liefert. 5.4 Hashing 5-9 gesucht, bis ein freier Platz gefunden wird (N = Länge der Tabelle). An der ersten freien Stelle wird das Objekt eingetragen. Damit bei der Suche nach einem freien Platz alle Elemente der Tabelle durchsucht werden können, muss Hash2 (k) für alle k relativ prim zu N sein (d.h. Hash2 (k) und N haben keine gemeinsamen Primfaktoren). Sobald die Hashtabelle den maximalen Füllstand übersteigt (z.B. 60%), muss die Hashtabelle vergrössert und ein Rehashing vorgenommen werden (alle Elemente neu zuteilen). Suchen des Objekts mit Schlüssels k: Die Hashadresse Hash1 (k) mod N wird berechnet. Ist das gesuchte Element mit Schlüssel k an dieser Adresse gespeichert, ist die Suche erfolgreich. Falls nicht, wird der Wert Hash2 (k) berechnet und das Element in den Tabellenplätzen (Hash1 (k) + Hash2 (k)) mod N, (Hash1 (k) + 2 · Hash2 (k)) mod N, ... gesucht. Die Suche wird durch eine der drei folgenden Bedingungen abgebrochen: • Das Element wird gefunden. Die Suche ist erfolgreich abgeschlossen. • Ein leerer Tabellenplatz wird gefunden, • oder wir geraten bei der Suche in einen Zyklus (hypothetischer Fall). In den beiden letzten Fällen ist das gesuchte Element nicht in der Tabelle. Hash1 (k) Hash2 (k) leeres Feld besetztes Feld gelöschtes Feld 5-10 5 Suchen Löschen eines Schlüssels: Das Löschen von Elementen aus einer Tabelle mit Double Hashing ist etwas heikel. Damit ein in der Tabelle eingetragener Schlüssel in jedem Fall wieder gefunden wird, dürfen wir die Elemente aus der Tabelle nicht einfach löschen, da wir nicht wissen, ob sie eventuell als Zwischenschritt beim Einfügen von anderen Elementen benutzt wurden. Jeder Tabelleneintrag muss darum ein Flag besitzen, welches angibt, ob ein Eintrag leer, benutzt oder gelöscht ist. Die oben beschriebene Suche darf dann nur bei einem leeren Element abgebrochen werden. Beim Löschen eines Schlüssels aus der Tabelle muss der Tabellenplatz mit gelöscht markiert werden. Als zweite Hashfunktion genügt oft eine sehr einfache Funktion, wie zum Beispiel Hash2 (k) = k mod 8 + 1. Um sicherzustellen, dass diese Funktionswerte teilerfremd zur Länge der Hashtabelle sind, kann man zum Beispiel als Tabellenlänge eine Primzahl wählen. 5.4 Hashing 5-11 5.4.3 Bucket Hashing Eine andere Möglichkeit, Kollisionen zu behandeln, ist das Aufteilen des Hash-Arrays in verschiedene buckets. Eine Hashtabelle der Länge H wird dabei aufgeteilt in H/B Teile (buckets), von der Grösse B. Es wird nur eine Hashfunktion benutzt, Elemente mit gleichem Hashwert (modulo Hashsize) werden im selben Bucket abgelegt. Die Hashfunktion sollte die Datenelemente möglichst gleichmässig über die verschiedenen Buckets verteilen. Hashtable Bucket Hashing, Buckets der Länge 4 Overflow 0 leer 1 besetzt 2 gelöscht 3 4 5 6 7 8 ... Einfügen: Die Hashfunktion ordnet das Element dem entsprechenden Bucket zu. Falls der erste Platz im Bucket bereits besetzt ist, wird das Element im ersten freien Platz des Bucket abgelegt. Falls ein Bucket voll besetzt ist, kommt der Eintrag in einen Überlauf (overflow bucket) von genügender Länge am Ende der Tabelle. Alle Buckets teilen sich den selben Überlauf-Speicher. Natürlich soll der Überlauf-Speicher möglichst gar nie verwendet werden. Sobald die Hashtabelle einen gewissen Füllgrad erreicht hat, muss ein Rehashing erfolgen, so dass die Zugriffe schnell bleiben (die Buckets nicht überlaufen). Suchen: Um ein Element zu suchen, muss zuerst mittels der Hashfunktion der Bucket gesucht werden, in welchem das Element liegen sollte. Falls das Element nicht gefunden wurde und im Bucket noch freie Plätze sind, kann die Suche abgebrochen werden. Falls der Bucket aber keine freien Plätze mehr hat, muss der Overflow durchsucht werden bis das Element gefunden wurde, oder alle Elemente des Overflow überprüft sind. Löschen: Auch beim Bucket Hashing müssen wir beim Löschen vorsichtig sein. Falls der Bucket noch freie Plätze hat, können wir den Tabellen-Platz einfach freigeben. Falls nicht, muss der Platz als “gelöscht” markiert werden, damit beim Suchen der Elemente auch der Overflow durchsucht wird. 5-12 5 Suchen Eine Variante des Bucket Hashing ist die folgende: Wir wählen wiederum eine Bucket-Grösse B. Wir teilen die Hashtabelle aber nicht explizit in Buckets auf, sondern bilden jeweils einen virtuellen Bucket rund um den Hashwert. Dies hat den Vorteil, dass jeder Tabellenplatz als Ausgangsposition für das Einfügen benutzt werden kann, was die Anzahl Kollisionen bei gleicher Tabellen-Grösse vermindert. Bucket Hashing Variante, Buckets der Länge 5 Hashtable Overflow leer besetzt gelöscht P neuer Eintrag P+1 P+2 Einfügen: Die Hashfunktion berechnet den Platz P in der Hashtabelle. Falls dieser Platz bereits besetzt ist, werden die Elemente rund um P in der Reihenfolge P + 1, P + 2, . . ., P + B − 2, P + B − 1 durchsucht, bis ein freier Platz gefunden wurde. Falls kein freier Platz in dieser Umgebung gefunden wird, kommt das Element in den Überlauf-Speicher. Suchen: Um ein Element zu suchen, muss zuerst mittels der Hashfunktion der Platz P in der Hashtabelle bestimmt werden. Falls das Element an der Stelle P nicht gefunden wird, werden die Elemente rund um P in der selben Reihenfolge wie oben durchsucht. Falls wir das Element finden, oder auf einen leeren Platz stossen, kann die Suche abgebrochen werden. Andernfalls muss der Overflow durchsucht werden bis das Element gefunden wurde, oder der ganze Overflow durchsucht ist. Löschen: Das gelöschte Feld muss auch in dieser Variante markiert werden, damit wir beim Suchen keinen Fehler machen. 5.4 Hashing 5-13 5.4.4 Separate Chaining Die flexibelste Art, um Kollisionen zu beheben, ist mit Hilfe von Separate Chaining. Bei dieser Methode hat in jedes Element der Hashtabelle einen next-Zeiger auf eine verkettete Liste. Einfügen des Schlüssels k: Zuerst wird die Hashadresse Hash(k) berechnet. Dann wird der neue Schlüssel am Anfang der verketteten Liste eingefügt. Suchen des Schlüssels k: Die Hashadresse Hash(k) wird berechnet. Dann wird k mit den Schlüsseln in der entsprechenden Liste verglichen, bis k entweder gefunden wird oder das Ende der Liste erreicht ist. Löschen des Schlüssels k: Das Element wird einfach aus der Liste in Hash(k) entfernt. Hashing mit Separate Chaining 5-14 5 Suchen 5.5 Übung 5 Linear Probing Fügen Sie mit den Hashfunkionen Hash1 (k) = k und Hash2 (k) = 1 die Liste der Zahlen 2, 3, 14, 12, 13, 26, 28, 15 in eine leere Hashtabelle der Länge 11 ein. Wieviele Vergleiche braucht man, um festzustellen, dass die Zahl 46 nicht in der Hashtabelle ist? Double Hashing Fügen Sie mit den Hashfunkionen Hash1 (k) = k und Hash2 (k) = k mod 8 + 1 die Liste der Zahlen 2, 3, 14, 12, 13, 26, 28, 15 in eine leere Hashtabelle der Länge 11 ein. Wieviele Vergleiche braucht man, um festzustellen, dass die Zahl 46 nicht in der Hashtabelle vorkommt? Bucket Hashing Fügen Sie mit der Hashfunkion Hash(k) = k die Zahlen 4, 3, 14, 12, 13, 26, 28, 15, 2, 20 leere Hashtabelle der Länge 8 ein mit Bucket Grösse 3 ein. in eine Overflow: Java Hashtabelle Sie finden unter ∼amrhein/AlgoData/uebung5 eine vereinfachte Version der Klasse Hashtable der java.util Library. Finden Sie heraus, welche der verschiedenen im Skript vorgestellten Varianten in der Java Library benutzt werden. 6 Sortieren Sortierprogramme werden vorallem für die die Präsentation von Daten benötigt, wenn die Daten zum Beispiel sortiert nach Zeit, Grösse, letzten Änderungen, Wert, ... dargestellt werden sollen. Wenn die Mengen nicht allzu gross sind (weniger als 500 Elemente), genügt oft ein einfach zu implementierender, langsamer Suchalgorithmus. Diese haben normalerweise eine Komplexität von O(n2 ), wobei der Aufwand bei fast sortierten Mengen geringer sein kann (Bubble Sort). Zum Sortieren von grossen Datenmengen lohnt es sich allerdings, einen O(n log2 (n)) Algorithmus zu implementieren. Noch mehr als bei Suchalgorithmen spielen bei Sortieralgorithmen die Datenstrukturen eine entscheidende Rolle. Wir nehmen an, dass die zu sortierende Menge entweder eine Arraystruktur (ein BasisTyp-Array wie float[n] oder eine ArrayList ) oder eine Listenstruktur (wie zum Beispiel LinkedList ) ist. Listen erlauben zwar keinen wahlfreien Zugriff, dafür können Listenelemente durch Umketten (also ohne Umkopieren) von einer unsortierten Folge F in eine sortierte Folge S überführt werden. Arrays erlauben wahlfreien Zugriff. Dafür sind beim Einfügen und Löschen (Umsortieren) von Elementen viele Kopier-Schritte nötig. Speziell ist zu beachten, dass viele Sortier-Algorithmen auf Array-Strukturen zwar sehr schnell aber nicht stabil sind. Definition: Ein Sortier-Algorithmus heisst stabil, falls Elemente, welche gemäss der Vergleichsfunktion gleich sind, ihre Originalreihenfolge behalten. 6-2 6 Sortieren 6.1 Selection Sort Beim Sortieren durch Auswählen teilen wir die zu sortierende Menge F (scheinbar) in eine unsortierte Teilmenge U und eine sortierte Menge S. Aus U wird jeweils das kleinste Element gesucht und mit dem letzten Element von S vertauscht. Wegen dieser Vertausch-Aktionen ist dieser Algorithmus eher geeignet für Arraystrukturen. Die Vertauschungen führen aber dazu, dass der Algorithmus nicht stabil ist. public void selectionSort(int l, int r) { int min_pos; for( int i=l; i<r; i++) { min_pos = findMin(i,r); swap(i, min_pos); } } private int findMin(int n, int r) { // find position with minimal Element int min = n; for( int i = n+1; i<r; i++ ) if( array.get(i).compareTo(array.get(min)) < 0 ) min = i; return min; } Die Array-Prozedur find min(i) sucht jeweils das kleinste Element aus dem Rest-Array. find min betrachtet also nur die Elemente mit Positionen j ≥ i im Array (der unsortieren Teilmenge U ). Das neue kleinste Element min (an der Position min pos ) wird mit dem Element an der Position i vertauscht (dem letzten der Teilmenge S). 6.1 Selection Sort 6-3 Beispiel Wir sortieren die Folge F = (21, 5, 12, 1, 27, 3) 21 5 12 1 27 3 Dass der Algorithmus nicht stabil ist, sehen wir auch am folgenden Beispiel. Die Folge Beat Suter, Claudia Meier, Daniel Suter, Emil Bucher, Fritz Abegg ist bereits sortiert nach Vornamen. Sie soll nun mit Hilfe von Selection Sort gemäss den Nachnamen sortiert werden. BeatSuter ClaudiaMeier DanielSuter EmilBucher FritzAbegg 6-4 6 Sortieren 6.2 Insertion Sort Beim Sortieren durch Einfügen nimmt man jeweils das erste Element aus der unsortierten Folge F und fügt es an der richtigen Stelle in die sortierte Folge S ein. Dieser Algorithmus ist nicht geeignet für arraybasierte Folgen, da in jedem Schritt einige Elemente im Array nach hinten verschoben werden müssten. Wir betrachten nochmals die Folge vom Beispiel vorher: 21 5 12 1 27 3 Falls das Element beim Ein-Sortieren jeweils am Ende aller (gemäss der Ordnung) gleichen Elemente eingefügt wird, ist dieser Algorithmus stabil. public void insertSorted(MSEntry<T> o) { MSEntry<T> e = header; // find place for o while (e.next != null && o.element.compareTo(e.next.element) > 0) e = e.next; // while(o.element < e.next.element) o.next = e.next; // insert o between e and e.next e.next = o; if (e == tail) tail = o; size++; } 6.3 Divide-and-Conquer Sortieren 6-5 public MSList<E> insertSort(MSList<E> list) { MSList<E> newList = new MSList<E>(); newList.addFirst(list.removeFirst()); while (list.size() > 0) newList.insertSorted(list.removeFirst()); return newList; } 6.3 Divide-and-Conquer Sortieren Das abstrakte Divide-and-Conquer Prinzip (vgl. Kap. 1.6.2) lautet wie folgt: 1. Teile das Problem (divide) 2. Löse die Teilprobleme (conquer ) 3. Kombiniere die Teillösungen (join) Beim Sortieren gibt es zwei Ausprägungen: Hard split / Easy join: Dabei wird die gesamte Arbeit beim Teilen des Problems verrichtet und die Kombination ist trivial, das heisst, F wird so in Teilfolgen F1 und F2 partitioniert, dass zum Schluss die sortierten Teilfolgen einfach aneinandergereiht werden können: S = S1 S2 . Dieses Prinzip führt zum Quicksort-Algorithmus. Easy split / Hard join: Dabei ist die Aufteilung in Teilfolgen F = F1 F2 trivial und die Hauptarbeit liegt beim Zusammensetzen der sortierten Teilfolgen S1 und S2 zu S. Dieses Prinzip führt zum MergesortAlgorithmus. 6-6 6 Sortieren 6.4 Quicksort Einer der schnellsten und am meisten benutzten Sortieralgorithmen auf Arrays ist der Quicksort-Algorithmus (C.A.R. Hoare, 1960). Seine Hauptvorteile sind, dass er nur wenig zusätzlichen Speicherplatz braucht, und dass er im Durchschnitt nur O(n log2 (n)) Rechenschritte benötigt. Beim Quicksort wird zuerst ein Pivot-Element q ausgewählt1 . Dann wird der zu sortierende Bereich F so in zwei Teilbereiche Fklein und Fgross partitioniert, dass in Fklein alle Elemente kleiner als q und in Fgross alle Elemente grösser gleich q liegen. Danach wird rekursiv Fklein und Fgross sortiert. Am Ende werden die je sortierten Folgen wieder zusammengefügt. Die Prozedur partition sucht jeweils von links ein Element g, welches grösser und von rechts ein Element k, welches kleiner als der Pivot ist. Diese zwei Elemente g und k werden dann vertauscht. Dies wird so lange fortgesetzt, bis sich die Suche von links und von rechts zusammen trifft. Zurückgegeben wird der Index der Schnittstelle. Anschliessend an partition wird der Pivot an der Schnittstelle eingesetzt (vertauscht mit dem dortigen Element). Um zu sehen, was beim Quicksort-Algorithmus passiert, betrachten wir das folgende Tracing: 1 q kann beliebig gewählt werden, optimalerweise ist q aber der Median aller zu sortierenden Werte. Da die Berechnung des Medians viel zu aufwändig wäre, wählt man als Pivot oft einfach das erste Element. Eine bessere Wahl ist das Element an der Stelle length/2 im Array als Pivot; dies führt bei beinahe sortierten Folgen zu einem optimalen Algorithmenverlauf. 6.4 Quicksort 54 93 6-7 83 22 7 19 94 48 27 72 39 70 13 28 95 36 100 4 12 6-8 6 Sortieren private int partition( int l, int r, E pivot ) { do{ // Move the bounds inward until they meet while( array.get(++l).compareTo(pivot) < 0 && l < r ); // Move left bound right while( array.get(--r).compareTo(pivot) > 0 && l < r); // Move right bound left if( l < r ) swap( l, r ); // Swap out-of-place values } while(l < r ); // Stop when they cross if( array.get(r).compareTo(pivot) < 0 ) return r+1; return r; // Return position for pivot } Quicksort teilt die zu sortierende Menge so lange auf, bis die Teile eine kritische Länge (z.B. 4 oder 8) unterschritten haben. Für kürzere Listen wird der einfachere Selection-Sort Algorithmus benutzt, da dieser für kurze Listen einen kleineren Overhead hat. public void quickSort( int i, int j ) { int pivotindex = findPivot(i, j); swap(pivotindex, i); // stick pivot at i int k = partition(i, j+1, array.get(i)); // k is the position for the pivot swap(k, i); // put pivot in place if( (k-i) > LIMIT ) // sort left partition quickSort( i, k-1 ); else // with selection-sort selectionSort( i, k-1 ); // for short lists if( (j-k) > LIMIT ) quickSort( k+1, j ); // sort right partition else selectionSort( k+1, j ); } 6.5 Sortieren durch Mischen (Merge Sort) 6-9 Die Komplexität des Quicksort-Algorithmus ist im besten Fall O(n log2 (n). Im schlechtesten Fall (worst case), wenn das Pivot-Element gerade ein Rand-Element ist, dann wird die Komplexität O(n2 ). Das Finden eines günstigen Pivot Elements ist zentral für die Komplexität des Quicksort Algorithmus. Es gibt verschiedene Verfahren zum Lösen diesese Problems wie, wähle als Pivot das erste Element des Arrays, wähle das Element in der Mitte des Arrays oder wähle drei zufällige Elemente aus dem Array und nimm daraus das mittlere. Eine Garantie für eine gute Wahl des Pivots liefert aber keines dieses Verfahren. Ausserdem ist der Quicksort Algorithmus nicht stabil. 6.5 Sortieren durch Mischen (Merge Sort) Der Merge-Sort-Algorithmus ist einer der besten für Datenmengen, die als Listen dargestellt sind. Die Rechenzeit ist in (best case und worst case) O(n log2 (n)). Ausserdem wird kein zusätzlicher Speicher benötigt und der Algorithmus ist stabil. Beim Merge-Sort wird die Folge F mit Hilfe einer Methode divideList() grosse Hälften geteilt. private MSList<E>[] divideList(MSList<E> list) { MSList<E> result[] = (MSList<E>[]) new MSList[2]; result[0] = new MSList<E>(); int length = list.size(); for (int i = 0; i < length / 2; i++) result[0].addFirst(list.removeFirst()); result[1] = list; return result; } in zwei (möglichst) gleich 6-10 6 Sortieren Die (im rekursiven Aufruf) sortierten Folgen werden dann in einem linearen Durchgang zur sortierten Endfolge zusammen gefügt (gemischt). private MSList<E> mergeSort(MSList<E> list) { if (list.size() < LIMIT) return insertSort(list); // divide list into two sublists MSList<E>[] parts = divideList(list); MSList<E> leftList = parts[0]; MSList<E> rightList = parts[1]; leftList = mergeSort(leftList); rightList = mergeSort(rightList); // left recursion // right recurstion return merge(leftList, rightList); } Das Mischen geschieht dadurch, dass jeweils das kleinere Kopfelement der beiden Listen ausgewählt, herausgelöst und als nächstes Element an die neue Liste angefügt wird. 4 7 3 3 13 4 8 28 7 12 8 15 6.5 Sortieren durch Mischen (Merge Sort) 6-11 private MSList<E> merge(MSList<E> left, MSList<E> right) { MSList<E> newList = new MSList<E>(); while (left.size() > 0 && right.size() > 0) { if (left.getFirst().compareTo(right.getFirst()) < 0) newList.addLast(left.removeFirst()); else newList.addLast(right.removeFirst()); } for (int i = left.size(); i > 0; i--) newList.addLast(left.removeFirst()); for (int i = right.size(); i > 0; i--) newList.addLast(right.removeFirst()); return newList; } divide Beispiel: Wie Mergesort genau funktioniert, wollen wir anhand dieses Beispiel nachvollziehen. merge sort 3 2 1 6 3 2 1 6 3 2 1 6 3 4 8 2 2 3 1 6 3 4 2 8 2 3 1 6 3 4 2 8 3 4 3 2 1 4 3 2 3 4 2 8 6 2 1 2 8 2 8 3 6-12 6 Sortieren 6.6 Übung 6 InsertSort, SelectionSort Erstellen Sie ein Tracing vom InsertSort-, bzw. vom SelectionSort-Algorithmus auf der Eingabe F = {42, 3, 24, 17, 13, 5, 10} Insert Sort 42 3 24 17 Selection Sort 13 5 10 42 3 24 17 13 5 10 Mergesort, Quicksort Erstellen Sie je ein Tracing vom Mergesort-, bzw. vom Quicksort-Algorithmus (einmal mit dem PivotElement an der Stelle 1, dann an der Stelle (r+l)/2 , r der rechte, l der Linke Rand des Bereichs) auf der Eingabe F = {42, 3, 24, 33, 13, 5, 7, 25, 28, 14, 46, 16, 49, 15} HeapSort Überlegen Sie sich ein Verfahren welches eine gegebene Menge mit Hilfe eines Heaps sortiert. Welchen Aufwand hat dieses Verfahren im besten / im schlechtesten Fall? Selbststudium: BucketSort, RadixSort Lesen Sie die Unterlagen zum Selbststudium über die zwei weiteren Suchalgorithmen: Bucket Sort und Radix Sort. Sortieren Sie die obige Menge F mit Hilfe von RadixSort mit 5 Buckets. 6.6 Übung 6 6-13 Quick Sort 42 3 24 33 13 5 7 25 28 14 46 16 49 15 42 3 24 33 13 5 7 25 28 14 46 16 49 15 6-14 7 Pattern Matching Pattern Matching ist eine Technik, mit welcher ein String aus Text oder Binärdaten nach einer Zeichenfolge durchsucht wird. Die gesuchten Zeichenfolgen werden dabei in Form eines Suchmusters (Pattern) angegeben. Solche Algorithmen werden in der Textverarbeitung (Suchen nach einem Zeichenstring in einer Datei) aber auch von Suchmaschinen auf dem Web verwendet. Das Hauptproblem beim Pattern-Matching ist: Wie kann entschieden werden, ob ein Text ein gegebenes Muster erfüllt. 7.1 Beschreiben von Pattern, Reguläre Ausdrücke Zunächst brauchen wir eine Sprache, mit welcher wir die Pattern (hier reguläre Ausdrücke) beschreiben können (um zu sagen, welche Art von Wörtern oder Informationen wir suchen). Sei E ein Alphabet, d.h. eine endliche Menge von Zeichen. Ein Wort über E ist eine endliche Folge von Zeichen aus E : w = e1 e2 . . . en , ei ∈ E Das leere Wort, das aus keinem Zeichen besteht, bezeichnen wir mit λ. Mit E ∗ bezeichnen wir die Menge aller Wörter über dem Alphabet E . 7-2 7 Pattern Matching Beispiel: Für E = {A, B} ist E ∗ = Die Pattern bauen wir mit Hilfe der folgenden drei Regeln zusammen: Definition: Ein (einfacher) regulärer Ausdruck ist ein Wort (String), welches mit Hilfe der folgenden drei Operationen zusammengebaut wird: Konkatenation setzt zwei Wörter zusammen. So wird aus den Wörtern ’AB’ und ’BC’ das neue Wort ’ABBC’. Auswahl erlaubt uns, zwischen einem der Wörter auszuwählen. Das heisst AB|B ist entweder das Wort ’AB’ oder das Wort ’B’. Wir nennen solche Terme auch Or-Ausdrücke. Iteration repetiert das gegebene Wort beliebig oft (auch 0 mal). A(AB)∗ entspricht also den Wörtern ’A’, ’AAB’, ’AABAB’, · · ·. Zu beachten ist in diesem Zusammenhang die Bindungsstärke der drei Operationen: Die Iteration (∗ ) bindet stärker als die Konkatenation. Am schwächsten bindet der Oder-Strich (|). Beispiele Der Ausdruck (A|BC)∗ AB erzeugt die Wörter: Der Ausdruck AB∗ C|A(BC)∗ erzeugt die Wörter: Wir beschränken uns hier auf diese wenigen Möglichkeiten zum Erzeugen von Pattern, obwohl es natürlich noch viele weitere, sehr elegante Operationen gibt: Zum Beispiel mit dem Zeichen ’.’ für einen beliebigen Buchstaben könnten lange Or-Ausdrücke viel kürzer dargestellt werden. AB(A|B| · · · |Z)∗ G entspricht dann AB( . )∗ G. Allerdings können wir damit keine neuen Wortmengen definieren. Darum begenügen wir uns vorerst mit den obigen Operationen. Um zu entscheiden, ob ein Wort einen regulären Ausdruck erfüllt, setzen wir endliche Automaten ein. 7.2 Endliche Automaten 7-3 7.2 Endliche Automaten Endlichen Automaten begegnen wir im täglichen Leben in Form von Getränkeautomaten, Billetautomaten, Bancomaten ... . Allen ist gemeinsam, dass sie ein einfaches endliches Eingabealphabet und ein einfaches endliches Ausgabealphabet haben und jeweils eine endliche Menge von Zuständen annehmen können. Beispiel Ein Bancomat funktioniert wie folgt: Nachdem die EC-Karte eingeschoben wurde, muss der Benutzer die Geheimzahl (Pincode) eingeben. Falls der Pincode korrekt war, bekommt der Benutzer eine Auswahl präsentiert (Geld abheben, Kontostand abfragen, abbrechen). Je nach Verhalten des Benutzers gibt der Bancomat den gewünschten Geldbetrag aus und/oder gibt die EC-Karte wieder zurück. Geldbetrag Geld ausgeben Auswahl Abbruch Karte ausgeben Auswahl Geld 5 korrekter Pincode Auswahl Kontostand 3 Karte 1 einschieben ok 2 4 falscher Pincode 6 Error Karte ausgeben Definition: Ein endlicher (deterministischer) Automat besteht aus einer endlichen Menge von Zuständen, einem Anfangs- (oder Start-) Zustand und einem oder mehreren Endzuständen (oder akzeptierenden Zuständen). • Eingabe: Ein Automat wird von aussen bedient, d.h. er wird mit Eingabedaten versorgt. Es gibt also eine endliche Menge E von Eingabezeichen, die der Automat lesen kann und die eine gewisse Aktion auslösen. Die Menge E heisst Eingabealphabet. • Zustand: Ein deterministischer Automat befindet sich stets in einem bestimmten Zustand. Die endliche Menge Z der möglichen Zustände heisst die Zustandsmenge. 7-4 7 Pattern Matching • Zustandsübergang: Die Verarbeitung eines einzelnen Eingabezeichens kann durch eine Nachfolgefunktion, ein Zustandsdiagramm oder durch eine Zustandstafel beschrieben werden. Unter der Einwirkung der Eingabe kann er von einem Zustand in einen andern übergehen. • Ausgabe: Im Laufe seiner Arbeit kann der Automat eine Ausgabe produzieren, d.h. er kann Ausgabedaten ausgeben. Die endliche Menge A der produzierten Ausgabezeichen heisst Ausgabealphabet. Beispiel: Für den Bancomat setzen wir Eingabe: E = {EC-Karte, Pincode (korrekt/falsch), Auswahl (Geld, Kontostand ...), Geldbetrag, Ok } Zustände: Z = {1 (Startzustand), 2 (warten auf Pincode), 3 (warten auf Auswahl), 4 (Kontostand anzeigen), 5 (warten auf Betrag) } Ausgabe: A = {EC-Karte, Kontostand, Geld}. und definieren die Funktionsweise durch die folgende Zustandstafel: Zustand Eingabe 1 2 3 4 5 6 (Error) Karte (E) korrekter (K) Pincode falscher Pincode (F) Auswahl (G) Geld Auswahl Kontostand (S) Auswahl Abbruch (A) Geldbetrag (B) Ok (O) Mit Hilfe von endlichen Automaten können wir entscheiden, ob ein gegebenes Wort einem regulären Ausdruck entspricht, also ob ein gefundenes Wort in unser Schema (das vorgegebene Pattern) passt. 7.2 Endliche Automaten 7-5 Beispiel: Wörter, welche vom obigen Automaten (Bancomaten) akzeptiert werden, sind: Wörter, welche nicht zum obigen Automaten gehören (nicht akzeptiert werden) sind: Ein endlicher Automat heisst deterministisch, falls jede Eingabe des Eingabealphabetes in jedem Zustand erlaubt ist und zu einem eindeutigen Nachfolgezustand führt. Ein nichtdeterministischer endlicher Automat kann für jeden Zustand und jede Eingabe null, einen oder mehrere Nachfolgezustände haben. Der Automat des vorigen Beispiels ist ein deterministischer endlicher Automat, da alle Eingaben nur in einen eindeutigen Nachfolgezustand führen. Nichtdeterministische Automaten können aber immer in deterministische Automaten übergeführt werden, indem neue mehrdeutige Zustände eingeführt werden. Beispiele Das folgende ist ein deterministischer, endlicher Automat: in jedem Zustand führt jede Eingabe zu einem eindeutigen Nachfolgezustand: A A A 1 4 0 B B A C 2 A B C C B 3 Zustand B C C Eingabe A B C 0 1 2 3 4 7-6 7 Pattern Matching Das folgende ist ein nichtdeterministischer, endlicher Automat: Manche Eingaben führen in gewissen Zuständen zu keinem oder zu mehr als einem Nachfolgezustand: A A 1 C C Zustand 4 0 A Eingabe A B 2 1 2 3 4 A B B 0 C A B 3 C C Ein weiteres Konstrukt in nichtdeterministischen Automaten sind sogenannte leere Übergänge, das heisst Übergänge ohne gelesenes Zeichen. Solche Übergänge heissen auch epsilon-Übergänge und werden mit ε bezeichnet. A A 1 C C Zustand 4 0 Eingabe B C B A A ε ε 2 C B 3 B C 0 1 2 3 4 7.3 Automaten zu regulären Ausdrücken 7-7 7.3 Automaten zu regulären Ausdrücken Wir suchen nun einen zu einem regulären Ausdruck äquivalenten Automaten. Ausgehend vom regulären Ausdruck können wir mit den folgenden Regeln den entsprechenden nichtdeterministischen endlichen Automaten herleiten. Durch das Symbol bezeichnet. wird der Startzustand, durch der akzeptierende Zustand des Automaten Konkatenation: E1 E2 E1 : 1 2 E2 : 3 4 Beispiel: B A B C A A . 7-8 7 Pattern Matching Auswahl: E1 | E2 E1 : 1 2 E2 : 3 4 . Beispiel: B A B C A A Iteration: E∗ E: 1 2 Beispiel: A B C . 7.3 Automaten zu regulären Ausdrücken 7-9 Im allgemeinen kann man sehr viel kleinere Automaten konstruieren, welche den gleichen regulären Ausdruck beschreiben. Es gibt auch einen Algorithmus, welcher aus einem nichtdeterministischen Automaten einen deterministischen Automaten (ohne leere Übergänge) erzeugt. Diesen Algorithmus wollen wir hier aber nicht behandeln. Beispiel 1: Ein endlicher Automat für den Ausdruck (AB|C)∗ (A|B) Beispiel 2: Ein Automat für den regulären Ausdruck (AB|BC)∗ (C|BC) 7-10 7 Pattern Matching 7.4 Übung 7 1. Finden Sie jeweils einen endlichen Automaten und/oder einen regulären Ausdruck, welcher die folgende Menge von Wörtern über dem Alphabet {A, B,C} beschreibt: - Alle Wörter, welche mit A anfangen und mit C enden. - Alle Wörter, welche eine gerade Anzahl A enthalten und mit C enden. - Alle Wörter, deren Anzahl Buchstaben durch 3 teilbar sind. - Alle Wörter, welche eine gerade Anzahl B und eine gerade Anzahl C enthalten. 2. Erzeugen Sie je einen nichtdeterministischen Automaten für die regulären Ausdrücke: - (A*BC)*|BB - ((AB|B)(BA|CB))* - (BB|CAB*|AB)* Sie müssen dabei die angegebenen Regeln zur Erzeugung des Automaten nicht strikt befolgen – Sie können auch versuchen, einen Automaten mit weniger Zuständen zu finden. 3. Erzeugen Sie eine Zustandsübergangs-Tabelle für den folgenden Automaten. ε ε 0 C B ε 1 A A 2 B 4 B 3 A 7.4 Übung 7 7-11 4. Lesen Sie die Wörter AACBC, ABACA und ACACB mit Hilfe der von Ihnen erzeugten Zustandsübergangstabelle, indem Sie Schritt für Schritt den aktuellen Zustand notieren. 5. Holen Sie sich das Java Programm unter ∼amrhein/AlgoData/uebung7 und beantworten Sie die folgenden Fragen: - Erklären Sie die Methoden find , group , start , end , split und replaceAll des java.regex Package. - Ergänzen Sie das gegebene Programm: Geben Sie vom Input String alle Wörter aus, welche weniger als 5 Buchstaben haben. 7-12 7 Pattern Matching Die wichtigsten regulären Ausdrücke Bedeutung \ . x ˆx [x] () | {x} {x,} {x,y} ? * + ˆ $ Escape, um Instanzen von Zeichen zu finden, welche als Metazeichen benutzt werden (wie Punkt, Klammer, ... ) Ein beliebiges Zeichen (ausser newline) Eine Instanz von x Jedes Zeichen ausser x Alle Zeichen in diesem Bereich (z. B. [abuv] die Buchstaben a, b, u oder v, [a-z] alle Kleinbuchstaben) runde Klammern dienen für die Gruppierung der OR Operator (Auswahl) (a|A) Der Ausdruck muss genau x mal vorkommen Der Ausdruck muss mindestens x mal vorkommen Der Ausdruck kommt mindestens x mal und höchstens y mal vor Abkürzung für {0, 1} Abkürzung für {0, } Abkürzung für {1, } Start einer neuen Zeile Ende der Zeile Beispiele [a-zA-Z]* Beliebig viele Zeichen aus Buchstaben (z. B. ugHrB). [A-Z0-9]{8} Acht Zeichen aus A bis Z und 0 bis 9, (z. B. RX6Z45UB). [A-Z]([a-z])+ Ein Grossbuchstabe gefolgt von mindestens einem Kleinbuchstaben (z.Bsp Stu). ([0-9]-){2}[0-9]} Drei Zahlen, durch Striche getrennt (z. B. 2-1-8). [A-G]{2,} Mindestens zwei Grossbuchstaben aus A bis G (z. B. BGA). [bRxv]{3} Drei Buchstaben aus b, R, x und v (z. B. xxv). 8 Top Down Parser Höhere Programmiersprachen wie Java oder C++ können von einem Prozessor nicht direkt verarbeitet werden. Die in diesen Sprachen geschriebenen Programme müssen zuerst in eine für den gewählten Prozessor geeignete Maschinensprache übersetzt werden. Programme, die diese Aufgabe übernehmen, nennt man Compiler. Ein Compiler hat im Prinzip zwei Aufgaben zu lösen: 1. Erkennen der legalen Programme der gegebenen Sprache. Das heisst, der Compiler muss testen, ob die Syntax des Programms korrekt ist oder nicht. Diese Operation nennt man parsing und das Programm, das diese Aufgabe löst, einen Parser. 2. Generieren von Code für die Zielmaschine. Auch wenn wir keinen Compiler schreiben wollen, so kommt es doch oft vor, dass wir komplizierte Benutzereingaben wie zum Beispiel - Arithmetische Ausdrücke: 2(a − 3) + b − 21 - Polynome: a + 3x2 − 4x + 16 - Boole’sche Ausdrücke a ⊙ (b ⊙ c) ⊕ b - eine Befehlssprache - ein Datenübermittlungsprotokoll - ... erkennen und verarbeiten müssen. 8-2 8 Top Down Parser 8.1 Kontextfreie Grammatik Mit Hilfe einer Grammatik beschreiben wir den Aufbau oder die Struktur (-Regeln) einer Sprache. Kontextfreie Grammatiken1 dienen als Notation zur Spezifikation einer Sprachsyntax. Beispiel: Ein einfacher deutscher Satz kann zum Beispiel eine der folgenden Strukturen annehmen: S ’ist’ A S ’ist’ A ’und ’ A S ’ist’ A ’oder ’ A wobei S normalerweise die Form Artikel Nomen hat und A ersetzt werden kann durch ein Adjektiv wie ’schön’, ’gross’, ’schnell’ oder ’lang’. Eine kontextfreie Grammatik (bzw. ein Syntaxdiagramm) für dieses Konstrukt sieht dann etwa wie folgt aus: Grammatik (EBNF: Extended Backus-Naur Form) Satz S Artikel Artikel Nomen Nomen ::= ::= ::= ::= ... ::= ::= S ’ist’ A A A Adjektiv Adjektiv ::= ::= ::= ::= ::= ... Satz S S Artikel A ist Artikel Nomen ’der’ Nomen ’ein’ ’Baum’ Artikel der ein ’Hund’ ... A Syntax-Diagramm A Adjektiv Adjektiv Adjektiv ’und’ A und Adjektiv ’oder’ A oder ’schön’ ’gross’ Adjektiv A ... 1 Es gibt gemäss Chomsky-Hierarchie vier Typen von Grammatiken: Typ 3: lineare Grammatik (entpricht endlichem Automat), Typ 2: kontextfreie Grammatik (nur Nichtterminale auf der linken Seite), Typ 1: kontextsensitive Grammatik (nichtverkürzend), Typ 0: allgemeine Grammatik (ohne Einschränkung). 8.1 Kontextfreie Grammatik 8-3 Eine solche Ersetzungs-Regel heisst eine Produktion. Ein Satz, welcher dieser Grammatik entspricht, ist: Ein Satz, welcher nicht dieser Grammatik entspricht, ist: Eine kontextfreie Grammatik beschreibt eine Sprache (Menge von Wörtern oder Strings). Definition: Zu einer kontextfreien Grammatik gehören vier Komponenten. 1. Eine Menge von Terminalen, d.h. von Symbolen, die am Ende einer Herleitung stehen (hier fettgedruckte Zeichenketten wie ’ist’, ’und’ oder ’oder’). 2. Eine Menge von Nichtterminalen, d.h. von Namen oder Symbolen, die weiter ersetzt werden (hier kursiv gedruckte Zeichenketten). 3. Eine Menge von Produktionen (l ::= r). Jede Produktion besteht aus einem Nichtterminal, das die linke Seite der Produktion bildet, einem Pfeil und einer Folge von Terminalen und/oder Nichtterminalen, die die rechte Seite der Produktion darstellen. 4. Ein ausgezeichnetes Nichtterminal dient als Startsymbol. Das Startsymbol ist immer das Nichtterminal auf der linken Seite der ersten Produktion. Als vereinfachte Schreibweise fassen wir alle rechten Seiten der Produktionen zusammen, welche das gleiche Nichtterminal als linke Seite haben, wobei die einzelnen Alternativen durch ‘|’ (zu lesen als “oder”) getrennt werden. Ziffer Ziffer .. . ::= ’0’ ::= ’1’ Ziffer ::= ’9’ kann also auch als Ziffer ::= ’0’ | ’1’ | ’2’ | ’3’ | ’4’ | ’5’ | ’6’ | ’7’ | ’8’ | ’9’ geschrieben werden. 8-4 8 Top Down Parser Definition: Die Herleitung eines Wortes aus einer Grammatik geschieht wie folgt: Wir beginnen mit dem Startsymbol und ersetzen dann jeweils in dem hergeleiteten Wort ein Nichtterminal durch die rechte Seite einer Produktion. Dies wird so lange wiederholt, bis im Ausdruck keine Nichtterminale mehr vorkommen. Alle Wörter, die aus dem Startsymbol herleitbar sind, bilden zusammen die von der Grammatik definierte Sprache. Beispiel: Die Grammatik G1 besteht aus den Produktionen: G1 : S ::= ′ A′ | ′ A′ S | b b ::= ′ B′ | ′ B′ b G1 erzeugt die folgende Menge von Wörtern: G2 besteht aus den Produktionen: G2 : S ::= ′ A′ | ′ B′ | ′ A′ b | ′ B′ a a ::= ′ A′ | ′ A′ b b ::= ′ B′ | ′ B′ a G2 erzeugt die Wörter: 8.1 Kontextfreie Grammatik 8-5 Beispiel Die Menge der arithmetischen Ausdrücke wird durch die folgende Grammatik erzeugt: expression term factor number digit ::= term { ′ + ′ term | ′ − ′ term } ::= factor { ′ ∗ ′ factor } ::= ′ ′ ( expression ′ )′ | number ::= digit { digit } ::= ′ ′ 0 | ′ 1′ | ′ 2′ | ′ 3′ | ′ 4′ | ′ 5′ | ′ 6′ | ′ 7′ | ′ 8′ | ′ 9′ Geschweifte Klammern bedeuten in der EBNF-Schreibweise null oder beliebig viele Wiederholungen. Eine Expression ist also ein Term oder eine Summe von Termen. Ein Term ist ein Faktor oder ein Produkt von Faktoren. Ein Faktor ist ein Klammer-Ausdruck oder eine Zahl. Eine Zahl besteht aus einer oder mehreren Ziffern. + expression term * term factor − Um zu überprüfen, ob ein Ausdruck ein korrekter arithmetischer Ausdruck ist, beschreiben wir den Herleitungsprozess mit Hilfe eines Herleitungsbaumes (Parsetree): Der Parsetree von 12 ∗ 4 − 3 ∗ (2 + 14) + 21 8-6 8 Top Down Parser Beispiel: Suchmaschinen erlauben üblicherweise die komplexe Suche nach Mustern wie ⊕ (schule software ⊙ schweiz) Dafür braucht man eine Sprache, mit welcher der Benutzer diese (boole’schen) Ausdrücke über Zeichenfolgen eingeben kann. Die Menge der boole’schen Ausdrücke kann zum Beispiel wie folgt erzeugt werden: bexpr ::= bterm [ ′ ⊕ ′ bexpr ] bterm ::= bfac [ ′ ⊙ ′ bterm ] bfac ::= [ ′ − ′ ] ′ (′ bexpr ′ )′ | [ ′ − ′ ] string string ::= letter { letter } letter ::= ′ ′ a | ′ b′ | . . . | ′ z′ Eckige Klammern bezeichnet in der EBNF Schreibweise ein optionales Vorkommen (null oder einmal). .. bexpr .... bterm ............. .... ... .... .... ..... ... . . . . .... ...... +g . ................ bexpr Wir zeichnen den Parsetree des boole’schen Ausdrucks ( a ⊙ b ) ⊙ ac ⊕ abc 8.2 Top-Down Parser 8-7 8.2 Top-Down Parser Aus einer solchen Grammatik lässt sich nun relativ leicht ein (rekursiver) Top-Down Parser herleiten: Aufpassen müssen wir dabei nur, dass wir keine nichtterminierenden Zyklen einbauen. Vermeiden können wir das, indem wir falls nötig ein oder zwei Zeichen vorauslesen. Dies kann bei Klammerausdrücken oder Operationszeichen nötig sein, bzw. immer dann, wenn es mehrere Möglichkeiten gibt. 8.2.1 Parser für arithmetische Ausdrücke Zum Implementieren des Parsers für arithmetische Ausdrücke schreiben wir eine Klasse ArithmeticExpressionParser . Darin definieren wir die Member-Variablen parseString für den zu parsenden String, position für die aktuelle Lese-Position und length die Länge des zu parsenden Strings. public class ArithmeticExpressionParser { private String parseString; private int position; private int length; public void parse(String aExpr) throws ParseException { . . . } // one method per grammar line . . . } Die Methode parse() ruft die Startfunktion expression() String gelesen wurde. auf und testet am Schluss, ob der ganze public void parse(String aExpr) throws ParseException { position = 0; parseString = aExpr; length = parseString.length(); expression(); if (position < length) throw new ParseException(position, "parse"); } 8-8 8 Top Down Parser expression() fängt (laut Grammatik) immer mit einem Term an, also rufen wir zuerst die Prozedur term() auf. Falls ein ’+ ’ oder ein ’- ’ im Ausdruck folgt, haben wir weitere Terme und es ist ein Loop nötig. private void expression() throws ParseException { term(); while (position < length) { if (getNext() == ’+’ || getNext() == ’-’) { position++; term(); } } } getNext() liest das folgende Zeichen (ohne die Position zu verändern). private char getNext() { return parseString.charAt(position); } term() fängt (laut Grammatik) mit einem Faktor an. Falls darauf ein ’* ’ folgt, ist ein rekursiver Aufruf nötig. private void term() throws ParseException { factor(); while (position < length) { if (getNext() == ’*’) { position++; factor(); } } } 8.2 Top-Down Parser 8-9 In factor() ist nun eine Abfrage nötig, um sicherzustellen, dass jede geöffnete Klammer wieder geschlossen wurde. Falls keine Klammer gelesen wurde, muss an dieser Stelle eine oder mehrere Ziffern stehen. private void factor() throws ParseException { if (position < length && getNext() == ’(’) { position++; expression(); if (position < length && getNext() == ’)’) { position++; } else { throw new ParseException(position, "factor, ’)’ expected"); } } else { number(); } } number() muss mindestens eine Ziffer lesen können, andernfalls ist an dieser Stelle im Ausdruck ein Fehler. private void number() throws ParseException { int n = position; while (position < length && isNumber(getNext())) { position++; } if (n == position) { throw new ParseException(position, "number, digit expected"); } } 8-10 8 Top Down Parser 8.2.2 Parser für boole’sche Ausdrücke Als nächstes bauen wir einen Parser für die von der Grammatik von Seite 8-6 erzeugten boole’schen Ausdrücke: bexpr ::= bterm [ ′ ⊕ ′ bexpr ] bterm ::= bfac [ ′ ⊙ ′ bterm ] bfac ::= [ ′ − ′ ] ′ (′ bexpr ′ )′ | [ ′ − ′ ] string string ::= letter { letter } letter ::= ′ ′ a | ′ b′ | . . . | ′ z ′ Da wir auf der normalen Tastatur die Zeichen ein + für ′ ⊕ ′ und ein ∗ für ′ ⊙ ′ . ′ ⊙ ′ und ′ ⊕ ′ nicht haben, schreiben wir im Programm Die Methode parse() können wir (fast) aus dem letzten Abschnitt kopieren. 8.2 Top-Down Parser public void parse(String aExpr) throws ParseException { position = 0; parseString = aExpr; length = parseString.length(); booleanExpression(); if (position < length) throw new ParseException(position, "parse"); } Ein boole’scher Ausdruck ist entweder ein einfacher Term oder ein OR-Ausdruck (bterm + bexpr)2 . private void booleanExpression( ) throws ParseException { 2 vgl. ∼ amrhein/AlgoData/Parser 8-11 8-12 8 Top Down Parser Ein bterm ist ein AND-Term, also ein bfac oder ein bfac multipliziert mit einem bterm. private void booleanTerm( ) throws ParseException { Ein booleanFactor ist ein String, ein negierter String, ein boole’scher Ausdruck oder ein negierter boole’scher Ausdruck. private void booleanFactor( ) throws ParseException { 8.2 Top-Down Parser booleanName liest so lange als Buchstaben im Ausdruck erscheinen. void booleanName() throws ParseException { int n = position; while (position < length && isAlpha(parseString.charAt(position))) position++; if (n == position) throw new ParseException(position, "booleanVariable: letter expected"); } 8-13 8-14 8 Top Down Parser 8.3 Übung 8 Syntax-Diagramm Gegeben ist das folgende Syntaxdiagramm: S T T ( + S - S F * T ) / F F a b c 1. Schreiben Sie es in eine Grammatik um. 2. Welche der folgenden Terme sind durch diese Grammatik erzeugt worden? (a) a*(b-c) (c) a+(a*b)+c (e) (a+b)/(a-b) (b) a/(b*c) (d) a+b+(c*b) (f) ((a*b)/(b*c)) Parser Gegeben sei die folgende Grammatik: r ::= s ::= t ::= ′ ′ ′ L +′ s | s N ′ | ′ N ′ ′ (′ t ′ )′ ′ ′ ′ ′ M + r | r ′ 1. Welche der folgenden Ausdrücke sind von dieser Grammatik erzeugt worden? (a) L + N (d) L + N (N + M) (b) N (M + N) (e) N (L + N (M))) (c) L + N (L + N) (f) N (N (L + M)) 2. Schreiben Sie einen Parser für diese Grammatik. Grammatiken 1. Geben Sie eine Grammatik für die Menge aller Strings über dem Alphabet { A,B } an, welche höchstens zwei gleiche Buchstaben in Folge haben. 2. Erweitern Sie die Grammatik für arithmetische Ausdrücke so, dass auch Exponentiation (∧ ) und Division (/) als Operationen erlaubt sind. 9 Kryptologie Mit der zunehmenden Vernetzung, insbesondere seit das Internet immer mehr Verbreitung findet, sind Methoden zum Verschlüsseln von Daten immer wichtiger geworden. Kryptologie fand ihren Anfang vor allem in militärischen Anwendungen. Seit der Erfindung des elektronischen Geldes findet die Kryptologie1 aber immer mehr Anwendungen im kommerziellen Bereich. Die wichtigsten Stichworte sind hier: Geheimhaltung, Authentifizierung(Nachweisen der eigenen Identität), Integriät (Fälschungssicherheit). Um zu wissen, wie sicher eine Verschlüsselungsmethode ist, müssen wir aber auch die andere Seite kennen. Während sich die Kryptographie (Lehre des Verschlüsselns) mit den verschiedenen Verschlüsselungs Methoden beschäftigt, lehrt die Kryptoanalyse, wie man Codes knackt. Nur wenn wir die Methoden der Code-Knacker kennen, können wir beurteilen, ob eine Verschlüsselungsmethode für unser Ansinnen brauchbar (d.h. genügend sicher) ist. Kryptologie = Kryptographie + Kryptoanalyse In der Regel gilt, je kritischer die Daten sind, desto sicherer muss die Verschlüsselung sein, und desto aufwändiger ist das Verschlüsselungsverfahren. Je schwieriger nämlich ein Code zu knacken ist, desto teurer ist eine Attacke für den Kryptoanalytiker. Er wird also nur dann eine aufwändige Attacke versuchen, wenn er sich einen entsprechenden Gewinn erhoffen kann. Prinzipiell gilt: • es gibt keine einfachen Verfahren, die trotzdem einigermassen sicher sind. • (fast) jeder Code ist knackbar, falls genügend Zeit und genügend verschlüsselter Text vorhanden ist. 1 Weitere Informationen zu Kryptographie findet man zum Beispiel unter home.nordwest.net/hgm/krypto . 9-2 9 Kryptologie 9.1 Grundlagen Ein klassisches Kryptosystem besteht aus einem Sender, einem Empfänger und einer Datenleitung, an welcher ein Horcher2 zuhört. Horcher Klartext Sender unsichere Datenleitung Schlüssel Empfänger Klartext Schlüssel sichere Übertragung Der Sender verschlüsselt eine Meldung (den Klartext) und sendet diesen über die unsichere Datenleitung dem Empfänger. Das Verschlüsseln geschiet entweder Zeichenweise (Stromchiffre) oder der Text wird erst in Blöcke aufgeteilt und dann werden die Blöcke verschlüsselt (Blockchiffre). Der Emfpänger entschlüsselt dann die Meldung wieder mit der Umkehrfunktion. Der verwendete Schlüssel muss vorher auf sicherem Weg (zum Beispiel per Kurier) übermittelt werden. Diese Situation ist heute Normalfall nicht (mehr) gewährleistet. So möchte der Kunde im WWW nicht vorher per Post mit jedem Anbieter geheime Schlüssel austauschen. Trotzdem will er sicher sein, dass kein Horcher das Bankpasswort, die Bestell/Transaktionsdaten oder die Kreditkartennummer erfährt. Ausserdem ist das Verwalten von vielen Schlüsseln sehr aufwändig (und unsicher). Das Ziel ist also, ein möglichst effizientes und doch sicheres System zu haben, welches mit möglichst wenig Verwaltungsaufwand auskommt. 2 Als Sender/Empfänger müssen wir davon ausgehen, dass der Horcher weiss, welches Verschlüsselungsverfahren angewandt wurde. 9.2 Einfache Verschlüsselungmethoden 9-3 9.2 Einfache Verschlüsselungmethoden Eine der einfachsten und ältesten Methoden zur Verschlüsselung von Texten wird Kaiser Caesar zugeschrieben. Die Methode heisst darum auch die Caesar Chiffre. Dabei wird jeder Buchstabe des Textes durch den um k Buchstaben verschobenen Buchstaben ersetzt (Stromchiffre). Beispiel Klartext: A B C D E F G H I Verschlüsselt: D E F G H I J K L ··· ··· W X Z Y Z A B C Der Klartext geheimer Text wird damit zu Klartext: G E H Verschlüsselt: J H K E I M E R T E X T Der Schlüssel ist k = 3, da alle Buchstaben um drei verschoben werden. Dieser Code lässt sich sehr leicht knacken, auch wenn wir den Schlüssel nicht kennen. Nach höchstens 27 Versuchen haben wir den Code entziffert. Verschlüsselt: X Y W J S L E L J M J N R Klartext: Eine etwas verbesserte Methode ist, ein Schlüsselwort als Additionstabelle zu benutzen (Blockchiffre). Diese Verschlüsselungsart ist bekannt als Vigenère Chiffre. Dabei ändert sich die Anzahl zu verschiebenden Buchstaben jeweils in einem Zyklus, welcher gleich lang wie das Schlüsselwort ist. Beispiel Wir benutzen das Wort geheim als Schlüssel. Die Verschlüsselung geschieht dann nach folgendem Schema: Klartext: D I E S E R Schlüssel G E H E I M Verschlüsselt: G T E X T E H E I I M S T ··· 9-4 9 Kryptologie Eine weitere Möglichkeit besteht darin, alle Buchstaben zufällig zu permutieren. Dies führt zu einer Permutationschiffre. Der Schlüssel ist dann die auf den Buchstaben benutzte Permutationsfunktion (bzw. die Umkehrung davon). Klartext: A B C D E F G H I J K Verschlüsselt: S H X A K D G R . . . L ··· Eine leicht verbesserte Version von Permutationschiffren benutzt Permutationen von Blöcken, zum Beispiel von Zweierblöcken: Klartext: AA AB AC AD AE AF AG AH AI AJ Verschlüsselt: RS HI WX CD JK RE HG UV . . ··· Damit müssten im Prinzip 27! (bzw. 272 !) Variationen getestet werden, um den Text zu entziffern. Leider ist die Kryptoanalyse auch für diese zwei Verschlüsselungsmethoden einfach, sofern ein normaler (deutscher) Text übermittelt wird. Um solche Texte zu knacken, arbeitet man mit Häufigkeitsanalysen. So lässt sich zum Beispiel die Verschlüsselung des Buchstabens E schnell erraten, da dies mit grossem Abstand der häufigste Buchstabe ist. E - N R I S T A D H U L 15.36 15.15 8.84 6.86 6.36 5.39 4.73 4.58 4.39 4.36 3.48 2.93 ... Am zweithäufigsten ist das Leerzeichen, dann der Buchstabe N usw.. Da Sprache im allgemeinen stark redundant ist, kann man oft mit Hilfe von ein paar wenigen erkannten Buchstaben bereits den ganzen Text entziffern. Auch für Blockpermutationen (mit Blöcken kurzer Länge) funktioniert die gleiche Attacke. Für diese braucht man eine Häufigkeitsanalyse der Zweier- (Dreier-)Blöcke einer Sprache. Der häufigste Zweierblock in der deutschen Sprache ist die Silbe en. Der Text muss allerdings genügend lang sein, d.h. genügend viele Blöcke aufweisen, um eine aussagekräftige Statistik zu erhalten. Solche Häufigkeitsanalysen können genau gleich auf Vigenère Blockchiffren angewendet werden (separat auf jeden Buchstaben des Schlüsselwortes), falls die Texte viel länger sind als das Schlüsselwort. 9.3 Vernamchiffre, One Time Pad 9-5 9.3 Vernamchiffre, One Time Pad Falls in der Viginère Chiffre das Schlüsselwort aus einer zufälligen Buchstabenfolge besteht, die ebenso lang ist wie der verschlüsselte Text, und falls jeder Schlüssel nur einmal verwendet wird, ist das Verschlüsselungs-Verfahren absolut sicher. Man nennt dieses Verfahren die Vernam-Chiffre oder das One Time Pad. Die Vernam-Chiffre ist allerdings sehr umständlich, da für jedes zu übermittelnde Zeichen zuvor ein Zeichen über einen sicheren Weg transportiert werden muss. Dennoch wird sie benutzt, wenn absolute, beweisbare Sicherheit nötig ist. Statt einer zufälligen Buchstabenfolge (welche auf einem sicheren Weg übermittelt werden muss), wird als Schlüssel häufig eine Zufalls-Zahlenreihe benutzt. Diese wird mit Hilfe einer geheimen, vorher ausgemachten Zufallsfunktion generiert. Solange der Horcher diese Funktion nicht durch Sabotage erfährt, ist dieses Verfahren sehr effizient und ebenfalls sicher. Oft werden sogar mehrere Zufalls-Funktionen kombiniert oder abwechlungsweise benutzt. Beispiel: Eine Funktion, welche eine gute “Zufallsfolge” herstellt, ist zum Beispiel die Funktion f (n) = ⌊100(sin(n) + 1)⌋. Die erzeugte Zahlenreihe ist: n : 1 2 3 4 5 6 7 8 9 10 11 12 ... f (n) : 184 190 114 24 4 72 165 198 141 45 0 46 ... f (n) mod 27 : 22 1 6 24 4 18 3 9 6 18 0 19 ... Mit Hilfe von solchen Funktionen werden sogennante Verschlüsselungs/Entschlüsselungs-Maschinen gebaut, zum Beispiel für Telefone, die typischerweise die Übertragung von grossen Datenmengen nötig machen. Beide Geräte erzeugen jeweils die gleiche Zufallsfolge zum (binären) Ver- bzw. Entschlüsseln der geheimen Texte. Die im Telefon eingebauten Schlüsselerzeuger sind dann im Prinzip nichts anderes als gute und effiziente Zufallszahlen-Generatoren. 9-6 9 Kryptologie 9.4 Moderne symmetrische Verfahren Die meisten der heute eingesetzten symmetrischen Verfahren sind Blockchiffren. Dabei wird der Text in Blöcke einer gewissen Länge (häufig 8 oder 16 Zeichen, also 64 oder 128 Bits) zerlegt. Auf diese Blöcke wird dann eine Kombination von verschiedenen einfachen Verschlüsselungsverfahren (z.B. modulare Addition, Substitution, Linear-Transformation, Vertauschen von Teilblöcken, ...) angewandt. Das folgende Bild zeigt zum Beispiel die Anordnung für DES. Normalerweise wird ein geheimer, vorher vereinbarter Schlüssel verwendet. Das Entschlüsseln geschieht indem alle Operationen in umgekehrter Reihenfolge (invers) angewandt werden. Beispiele von Blockhiffren sind: DES (Data Encryption Standard) oder DEA (Data Encryption Algorithm) , benutzt einen Schlüssel der Länge 56. Triple-DES: Dreimaliges Anwenden von DES mit zwei oder drei verschiedenen Schlüsseln. RC4 Rivest Cipher, 1987 von Ronald L. Rivest, eine Stromchiffre IDEA: arbeitet ähnlich wie DES, CAST, ... SSL (Secure Socket Layer) verwendet RC4, DES oder Triple-DES. S-HTTP (Secure HTTP) verwendet DES, Triple-DES oder IDEA. PGP (Pretty Good Privacy) benutzt verschiedene der Verfahren IDEA, Triple-DES, RSA, ... 9.5 Asymmetrische Verfahren: Public Key Kryptosysteme 9-7 9.5 Asymmetrische Verfahren: Public Key Kryptosysteme Bei kommerziellen Applikationen wie zum Beispiel Telebanking, beim Benutzen von elektronischem Geld oder beim Versenden von (geheimer) Email ist es zu aufwändig, mit jedem Kunden/Partner vorher geheime Schlüssel auszutauschen. Um genügend Sicherheit zu bieten, müssten lange Schlüssel verwendet werden, welche häufig gewechselt werden. (Die mit den Banken vereinbarten Schlüssel/Passwörter dienen beim Telebanking in der Regel in erster Linie zur Authentifikation/Identifikation des Kunden.) Es gibt aber Verfahren, welche ohne die Verteilung von geheimen Schlüsseln auskommen, und die darum Public Key Kryptosysteme (PKK) genannt werden3 . Wie der Name sagt, benutzen PKKs nicht geheime, sondern öffentliche Schlüssel zum Verschlüsseln der Texte. Verschlüsselung öffentlicher Schlüssel des Empfängers Entschlüsselung Übermittlung geheimer Schlüssel des Empfängers In PKKs werden für die Verschlüsselung Funktionen benutzt, welche leicht zu berechnen, aber ohne zusätzliches Wissen nicht invertierbar sind. Diese Einwegfunktionen können dann öffentlich bekannt gegeben werden. Da die Funktionen schwierig zu invertieren sind, ist das Entschlüsseln nicht einfach möglich. Es gilt dann also: • Jeder kann mit dem öffentlichen Schlüssel Meldungen codieren. • Nur wer den geheimen Schlüssel kennt, kann die Meldung decodieren. Definition: Eine bijektive Funktion f : X → Y heisst eine Einwegfunktion falls gilt: • Die Funktion f ist leicht (mit wenig Rechenaufwand) zu berechnen. 3 Da für diese Verfahren zwei verschiedene Schlüssel zum Ver- bzw. Entschlüsseln verwendet werden, spricht man auch von asymmetrischen Verfahren. 9-8 9 Kryptologie • Die Umkehrfunktion f −1 ist ohne zusätzliche (geheime) Informationen sehr schwierig (mit grossem Aufwand) zu berechnen. Ein einfaches Modell für eine Einwegfunktion ist ein Telefonbuch, mit welchem sehr schnell zu jedem Namen mit Adresse die Telefonnummer gefunden werden kann. Hingegen ist es sehr aufwändig, mit Hilfe eines Telefonbuchs zu einer Telefonnummer den zugehörigen Namen zu finden. Es müsste das ganze Buch durchsucht werden. Das Finden von sicheren Einwegfunktionen ist nicht einfach. Wenn wir den Schlüssel kennen, können wir in allen bisherigen Verfahren sehr leicht die Umkehrfunktion berechnen. Das Suchen von guten, schnellen und beweisbar sicheren Einwegfunktionen ist ein zentrales Forschungsgebiet der Kryptologie. Heute sind aber schon einige praktisch verwendbare (nicht beweisbar sichere) Einwegfunktionen bekannt. Die Idee bei einem Public Key Kryptosystem ist, dass jeder Teilnehmer für sich ein Paar von Schlüsseln generiert: einen öffentlichen Schlüssel zum Verschlüsseln der Meldungen, und einen privaten, geheimen Schlüssel zum Entschlüsseln. Der geheime Schlüssel darf aus dem öffentlichen Schlüssel nur mit riesigem Aufwand oder gar nicht berechnet werden können. Der erste Schlüssel generiert dann eine Einwegfunktion, welche nur mit Kenntnis des zweiten Schlüssels (oder nur mit sehr grossem Aufwand) invertiert werden kann. Um einem Teilnehmer eine Meldung zu verschicken, verschlüsseln wir die Meldung mit dessen öffentlichem Schlüssel. Nur der Adressat, der den geheimen Schlüssel kennt, kann die Einwegfunktion invertieren, also die verschlüsselte Meldung wieder entschlüsseln. Definition: Für ein PKK müssen die folgenden Bedingungen erfüllt sein: 1. Es gibt genügend viele Paare (V, E) von Verschlüsselungs- und Entschlüsselungsfunktionen (bzw. von öffentlichen und geheimen Schlüsseln (v,e)). 2. Für jede Meldung M gilt E(V (M)) = M . 3. V ist eine Einwegfunktion. 4. V und E sind leicht zu berechnen, wenn man den Schlüssel v, bzw. e kennt. 9.5 Asymmetrische Verfahren: Public Key Kryptosysteme 9-9 Das erste PKK ist der Diffie-Hellmann Algorithmus von 1976. Auf einer ähnlichen Idee basiert der um 1978 von R. Rivest, A. Shamir und L. Adleman gefundene RSA Algorithmus. Die darauf basierenden Verfahren werden deshalb RSA-Kryptosysteme genannt. 9.5.1 Das RSA Verfahren Das RSA-Verfahren ist heute das am meisten benutzte PKK. RSA bildet die Grundlage für SSL (Secure Socket Layer), welche vor allem für WWW gebraucht werden, für SET (Secure Electronic Transactions), welche im Zusammenhang mit elektronischem Geld wichtig sind, für S/Mime, also sichere Email und vieles mehr (z.B. S-HTTP, SSH, ...). Die Sicherheit des RSA-Verfahren basiert auf dem Problem, eine grosse Zahl in ihre Primfaktoren zu zerlegen und aus dem Problem des diskreten Logarithmus. Das Berechnen von M v modm ist relativ einfach. Für das Berechnen der v-ten Wurzel modulo m ist bisher kein schneller Algorithmus bekannt. Die beiden Schlüssel werden so erzeugt: • Wähle zwei verschiedene grosse Primzahlen p und q und berechne deren Produkt: m = p ∗ q. Setze n = (p − 1) · (q − 1). • Wähle einen beliebigen Wert e, der kleiner ist als m und teilerfremd zu n. Zu diesem wird dasjenige v berechnet, für das gilt: e · v = 1 + l · n (Euklid’scher Algorithmus). Es gilt dann für das Verschlüsseln: V (M) = M v mod m für das Entschlüsseln: E(M̃) = M̃ e mod m. v V(M) = M mod m öffentlicher Schlüssel des Empfängers Übermittlung e E(M) = M mod m geheimer Schlüssel des Empfängers Es gilt nämlich nach dem Satz von Fermat E(V (M)) = (V (M))e mod m = M ve mod m = M (1+ln) modm = M 9-10 9 Kryptologie 9.5.2 Authentifikation mit Hilfe von RSA Mit Hilfe eines RSA-Kryptosystems können wir auch feststellen, ob der Absender einer Meldung tatsächlich derjenige ist, der er zu sein vorgibt. Durch umgekehrtes Anwenden des RSA-Verfahrens kann der Empfänger nachprüfen, ob die Meldung vom richtigen Sender stammt. Wir wissen bereits, dass E(V (M)) = M ve mod m = M gilt. Die Potenzoperation ist aber kommutativ, so dass auch M ev mod m = M gilt. Wir können also die Operationen Verschlüsseln/Entschlüsseln auch umdrehen. Verschlüsselung Übermittlung Entschlüsselung öffentlicher Schlüssel des Senders geheimer, privater Schlüssel des Senders Nur wenn beim Entschlüsseln eines Textes mit dem öffentlichen Schlüssel des Absenders Klartext entsteht, stammt die Meldung von diesem Absender. Da nur der Absender den zu seinem öffentlichen Schlüssel passenden geheimen Schlüssel kennt, kann nur dieser eine solche Meldung verfassen. 9.5.3 Integritätsprüfung: Fingerabdruck, Message Digest Ein zentrales Problem vor allem von grossen Anbietern (Banken, Online-Verkäufern, ...) ist, den Kunden zu garantieren, dass eine (unverschlüsselte) Webseite tatsächlich die richtige Seite ist (und nicht eine gefälschte). Dieses Problem kann mit Hilfe einer Hashfunktion und eines PKKs gelöst werden. Übermittlung Hash Funktion 1 0 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 =? Hash Funktion Verschlüsselung geheimer, privater Schlüssel des Senders Entschlüsselung öffentlicher Schlüssel des Senders 9.5 Asymmetrische Verfahren: Public Key Kryptosysteme 9-11 Falls beim Entschlüsseln des Hashcodes mit dem öffentlichen Schlüssel des Absenders der gleiche Wert herauskmmt, sind wir sicher, dass unterwegs niemand die Meldung verändert hat. Allerdings funktioniert dies nur, wenn der uns bekannte öffentliche Schlüssel korrekt ist, uns also kein falscher Schlüssel vorgetäuscht wird. Dies garantieren spezielle Firmen und Institutionen, sogenannte Trustcenter wie TC TrustCenter, VeriSign oder Thawte. 9.5.4 Kombinierte (Hybride) Verfahren Eine kombinierte Methode verbindet die Sicherheit von RSA mit der Schnelligkeit von symmetrischen Verschlüsselungsmethoden, wie zum Beispiel DES. DES DES Schlüssel 0110 000000000 111111111 111111111 000000000 111111111 000000000 111111111 000000000 111111111 000000000 Übermittlung Digital Envelope Verschlüsselung öffentlicher Schlüssel des Empfängers Ein Digital Envelope wird erzeugt, indem der Text durch ein schnelles, weniger sicheres Verfahren verschlüsselt wird (zum Beispiel mit DES), der DES-Schlüssel selber wird mit dem öffentlichen RSA-Schlüssel des Empfängers verschlüsselt. Auf diese Weise können die Vorteile beider Systeme kombiniert werden. Es müssen keine geheimen Schlüssel auf einem (langsamen) sicheren Weg vorher vereinbart werden. Ausserdem kann für jede Übermittlung ein neuer DES-Schlüssel verwendet werden. Durch einmaliges Verwenden jedes DESSchlüssels wird die Sicherheit des DES-Verfahrens erheblich verbessert. Nur der Empfänger kann den DES-Schlüssel lesen, da er dazu seinen privaten RSA-Schlüssel braucht. Danach kann er mit Hilfe des DES-Schlüssels den Text entziffern. 9-12 9 Kryptologie 9.6 Übung 9 Fragen / Aufgaben zur Kryptologie Sie finden die Lösungen zu den Fragen entweder im Skript oder unter dem Link Ëinführung in die Kryptologieäuf der Übungsseite. 1. Welches sind heute die zentralen Einsatzgebiete der Kryptologie? 2. Was bedeutet Kryptografie? 3. Was bedeutet Kryptoanalyse? 4. Was ist Steganografie? 5. Wie heisst der Überbegriff für die Verfahren, bei welcher die Verschlüsselung Zeichenweise abläuft? 6. Gewisse Verfahren teilen den Text zuerst in gleich grosse Blöcke (zum Beispiel 64 Bit) auf: Welches ist der Überbegriff für diese Verfahren? 7. Skizzieren Sie ein symmetrisches Kryptosystem. 8. Verschlüsseln Sie mit der Cäsar Chiffre und dem Schlüssel B (k=1) Ihren Namen. 9. Verschlüsseln Sie mit der Viginère Chiffre und dem Schlüssel BBC Ihren Namen. 10. Welches sind die Vor- und Nachteile des One Time Pad (Vernam Chiffre)? 11. Was waren die Hauptgründe dafür, dass die Enigma im zweiten Weltkrieg geknackt werden konnte? (Punkte 3, 6 und 7 der Aufzählung unter http://www.nwn.de/hgm/krypto/ → Enigma) 12. Welche symmetrischen Kryptoverfahren werden heute (noch) verwendet? 13. Skizzieren Sie ein asymmetrisches Kryptosystem. 14. Welches ist der Hauptunterschied zwischen symmetrischen und asymmtetrischen Verfahren? 15. Wer hat das erste PKK erfunden? 16. Worauf basiert die Sicherheit von RSA? 17. Wie löst man mit einem PKK das Authentizitätsproblem? 18. Wie löst man mit einem PKK das Integritätsproblem? 19. Was ist der Vorteil von hybriden Kryptoverfahren? Literaturverzeichnis [AHU74] Aho, Hopcroft, and Ullman. The Design and Analysis of Computer Algorithms. Addison Wesley, Reading, Massachusetts, 1974. ISBN 0-201-00029-6. [AU95] A.V. Aho and J.D. Ullman. Foundations Of Computer Science C Edition. Computer Science Press An Imprint of W.H. Freeman and Company, New York, 1995. ISBN 0-7167-8284-7. [Bud94] Timothy A. Budd. Classic Data Structures in C++. Addison-Wesley, Reading, MA, 1994. [Knu73a] D.E. Knuth. The Art of Computer Programing, volume 3 Sorting and Searching. Addison Wesley, Reading, MA, 1973. ISBN 0-201-03803-X. [Knu73b] D.E. Knuth. The Art of Computer Programing, volume 1 Fundamental Algorithms. Addison Wesley, Reading, MA, 1973. ISBN 0-201-03809-9. [Knu81] D.E. Knuth. The Art of Computer Programing, volume 2 Seminumerical algorithms. Addison Wesley, Reading, MA, 1981. ISBN 0-201-03822-6. [Sed92] Robert Sedgewick. Algorithmen in C++. Addison Wesley, Bonn, 1992. ISBN 3-89319-462-2. [Sed03] Robert Sedgewick. Algorithmen in Java. Grundlagen, Datenstrukturen, Sortieren, Suchen.. Pearson Studium, 2003. ISBN 3-82737-072-8. [Sha97] Clifford A. Shaffer. A Practical Introduction to Data Structures and Algorithm Analysis. Prentice Hall, London, 1997. ISBN 0-131-90752-2, C++ Version. [Sha98] Clifford A. Shaffer. A Practical Introduction to Data Structures and Algorithm Analysis: Java Edition. Prentice Hall, London, 1998. ISBN 0-136-60911-2. [Wir86] N. Wirth. Algorithmen und Datenstrukturen mit Modula-2. Teubner, Stuttgart, 1986. ISBN 0-13-629031-0. [SaSa04] Gunter Saake, Kay-Uwe Sattler Algorithmen und Datenstrukturen, Eine Einführung mit Java. dpunkt, 2004. ISBN 3-89864-255-0. [Schied05] Reinhard Schiedermeier Programmieren mit Java, Eine methodische Einführung. Pearson Studium, 2005 ISBN 3-8273-7116-3. W-2 Index abstrakter Datentyp, 1-5, 1-6 Algorithmus, 1-9 asymptotisches Verhalten, 2-5, 2-7 Komplexität, 2-1 O-Notation, 2-7 Allgemeine Strukturen, 1-4 Array Grundtyp, 1-4 Indextyp, 1-4 Selektor, 1-4 Atomare Typen, 1-3 Auswahl, 7-2 Automat, 7-3 ε-Übergang, 7-6 Ausgabe, 7-4 deterministischer, 7-5 Eingabe, 7-3 Eingabealphabet, 7-3 endlicher, 7-2 leerer Übergang, 7-6 nichtdeterministischer, 7-5 Zustand, 7-3 Zustandsdiagramm, 7-4 Zustandstafel, 7-4 B-Baum, 4-10 Bäume, 4-1 BinNode, 4-2 Baumdurchläufe, 4-4 Breitensuche, 4-7 Inorder, 4-4 Levelorder, 4-7 Postorder, 4-4 Präorder, 4-4 Tiefensuche, 4-4, 4-6 Binärbaum, 4-1, 4-2 Binäre Suchbäume, 4-8 Caesar Chiffre, 9-3 Compiler, 8-1 Daten, 1-2 Bezeichung, 1-2 Semantik, 1-2 Syntax, 1-2 Wertemenge, 1-2 Datenstruktur, 1-6 Datentyp, 1-2 Konstanten, 1-2 Methoden, 1-2 Operatoren, 1-2 Wertebereich, 1-2 Divide and Conquer, 1-18 EBNF, 8-2 Einwegfunktion, 9-8 Extended Backus-Naur Form, 8-2 Grammatik, 8-2 Herleitung, 8-3 kontextfreie, 8-2 Nichtterminalsymbol, 8-3 Produktion, 8-3 Terminalsymbol, 8-3 Hashing, 5-5 Bucket Hashing, 5-11 Double Hashing, 5-8 Kollision, 5-6 Linear Probing, 5-8 Separate Chaining, 5-13 Heap, 4-15 Heapbedingung, 4-16 Iteration, 1-12, 7-2 Klassen, 1-4 Komplexität, 2-1 Best-Case, 2-4 Worst-Case, 2-4 Konkatenation, 7-2 Kryptoanalyse, 9-1 Kryptologie, 9-1 Caesar Chiffre, 9-3 Einwegfunktion, 9-8 Permutationschiffre, 9-4 Public Key Kryptosystem, 9-7, 9-8 RSA-Kryptosystem, 9-9 Vernam-Chiffre, 9-5 Vigenère Chiffre, 9-3 Kryptosystem, 9-2 Liste, 3-1 Array Liste, 3-1 doppelt verkettet, 3-5 Node, 3-5 O-Notation, 2-6 Parser, 8-1 Pattern, 7-1 Alphabet, 7-1 Wort, 7-1 Pattern Matching, 7-1 Permutationschiffre, 9-4 Priority Queue, 4-15 Priority Queues, 4-15 Pseudocode, 1-10 Public Key Kryptosystem, 9-7, 9-8 Queue, 3-1, 3-11 Regulärer Ausdruck, 7-2 Rekursion, 1-15 RSA-Kryptosystem, 9-9 Sortieren Insertion-Sort, 6-4 Merge-Sort, 6-9 Quick-Sort, 6-6 Selection-Sort, 6-2 stabiler Algorithmus, 6-1 Spezifikation, 1-8 Sprache, 8-3 Herleitung, 8-3 Stack, 3-1, 3-11 Suchen, 5-1 Binäre Suche, 5-3 Lineare Suche, 5-2 Top Down Parser, 8-1 Trustcenter, 9-11 Vernam-Chiffre, 9-5 Verschlüsselung, 9-1 Vigenère Chiffre, 9-3