Algorithmen und Datenstrukturen

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