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