13.11.2012 Aufrufe

Algorithmen und Datenstrukturen

Algorithmen und Datenstrukturen

Algorithmen und Datenstrukturen

MEHR ANZEIGEN
WENIGER ANZEIGEN

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

Hurra! Ihre Datei wurde hochgeladen und ist bereit für die Veröffentlichung.

Erfolgreich gespeichert!

Leider ist etwas schief gelaufen!