Skript - oth-regensburg.de

Werbung
Algorithmen und Datenstrukturen
Prof. Jürgen Sauer
Algorithmen und Datenstrukturen
Skriptum zur Vorlesung im WS 2002 / 2003
1
Algorithmen und Datenstrukturen
1. Objektorientierte Programmierung mit Datenstrukturen und
Algorithmen
1.1 Ein einführendes Beispiel: Das Durchlaufen eines Binärbaums
1.1.1 Rekursive Problemlösung
1.1.2 Nichtrekursive Problemlösung
1.1.3 Verallgemeinerung
1.2 Begriffe und Erläuterungen zu Datenstrukturen und Programmierverfahren
1.2.1 Algorithmus (Verarbeitung von Daten)
1.2.1.1 Datenstruktur und Programmierverfahren
1.2.1.2 Der intuitive Algorithmus-Begriff
1.2.1.3 Bausteine für Algorithmen
1.2.1.4 Formeln, Eigenschaften von Algorithmen
1.2.1.5 Komplexität
1.2.1.6 Laufzeitberechnungen („Big-O“)
1.2.1.7 O(logN)-Algorithmen
1.2.1.8 Effizienz
1.2.2 Daten und Datenstrukturen
1.2.2.1 Der Begriff Datenstruktur
1.2.2.2 Relationen und Ordnungen
1.2.2.3 Klassifikation von Datenstrukturen
1. Lineare Ordnungsgruppen
2. Bäume
3. Graphen
4. Dateien
5. Datenbanken
1.2.3 Definitionsmethoden für Datenstrukturen
1.2.3.1 Der abstrakte Datentyp (ADT)
1.2.3.2 Die axiomatische Methode
1.2.3.3 Die konstruktive Methode
1.2.3.4 Die objektorientierte Modellierung abstrakter Datentypen
Die Spezifikation abstrakter Datentypen
Klassendiagramme der Unified Modelling Language
1.2.3.5 Die Implementierung abstrakter Datentypen in C++
1.2.3.5.1 Das Konzept der benutzerdefinierten Datentypen class bzw. struct
1.2.3.5.2 Generische ADT (Klassenschablonen)
1.2.3.6 Die Implementierung abstrakter Datentypen in Java
1.2.3.6.1 Modellierung von Zustand und Verhalten
1.2.3.6.2 Referenzen, einfache Typen und Referenztypen
1.2.3.6.3 Superklassen und Subklassen, Vererbung und Klassenhierarchie
1.2.3.6.4 Schnittstellen und Pakete
2
Algorithmen und Datenstrukturen
1.2.3.6.5 Polymorphismus und Binden
1.3 Sammlungen (Container) und Ordnungen
1.3.1 Ausgangspunkt: Das Konzept für Sammlungen in Smalltalk
1.3.2 Behälter-Klassen
1.3.2.1 Lineare Kollektionen
1. Sammlungen mit direktem Zugriff
2. Sammlungen mit sequentiellem Zugriff
1.3.2.2 Nichtlineare Kollektionen
1. Hierarchische Sammlung
2. Gruppenkollektionen
Set
Graph
Netzwerk
2. Implementierung von Datenstrukturen und Algorithmen in C++ und
Java
2.1 Datenstrukturen und Algorithmen in C++
2.1.1 Die C++-Standardbibliothek und die STL
2.1.2 Container
2.1.2.1 Grundlagen
2.1.2.2 STL-Container-Anwendungen
1. "pririty-queue" container adaption
2. Huffman-Coding mit "priority-queue"-Container
2.1.3 Iteratoren
2.1.4 Algorithmen
2.2 Datenstrukturen und Algorithmen in Java
2.2.1 Durchwandern von Daten mit Iteratoren
2.2.2 Die Klasse Vector
2.2.3 Die Klasse Stack
2.2.4 Die Klasse Bitset für Bitmengen
2.2.5 Die Klasse Hashtable und assoziative Speicher
2.2.6 Die abstrakte Klasse Dictionary
2.2.7 Die Klasse Properties
2.2.8 Collection-API
2.2.8.1 Die Schnittstelle Collection, Iterator, Comparator
2.2.8.2 Die Behälterklassen und Schnittstellen des Typs List
2.2.8.3 Die Behälterklassen des Typs Set
2.2.8.4 Die Behälterklassen des Typs Map
2.2.8.5 Implementierung von Graphen-Algorithmen
1. Kürzeste Pfade in gerichteten, ungewichteten Graphen
3
Algorithmen und Datenstrukturen
2. Berechnung der kürzesten Pfadlängen in gewichteten Graphen
3. Berechnung eines minimal spannenden Baums in einem zusammenhängenden
ungewichteten Graphen (Algorithmus von Prim)
2.2.9 Algorithmen
2.2.9.1 Datenmanipulation
2.2.9.2 Größter und kleinster Wert einer Collection
2.2.9.3 Sortieren
2.2.9.4 Suchen von Elemeneten
2.2.9.5 Typsichere Datenstrukturen
3. Algorithmen
3.1 Ausgesuchte algorithmische Probleme
3.1.1
3.1.1.1
Spezielle Sortieralgorithmen
Interne Sortierverfahren
Quicksort
Heap-Sort
Sortieren durch Mischen
3.1.1.2 Externe Sortierverfahren
3.1.1.2.1 Direktes Mischen
1. 2-Wege-Mischsortieren
2. Mehrwege-Mischsortieren
3. Merphasen-Mischsortieren
3.1.1.2.2 Natürliches Mischen
3.1.2
Suche in Texten
3.1.2.1 Ein einfacher Algorithmus zum Suchen in Zeichenfolgen
1. Das naive Verfahren zur Textsuche
2. Mustererkennung über den Vergleich des ersten und letzten Zeichens
3.1.2.2 Das Verfahren von Knuth-Morris-Pratt
3.1.2.3 Das Verfahren von Boyer-Moore
3.2 Entwurfstechniken von Algorithmen
3.2.1
3.2.1.1
3.2.1.2
3.2.2
3.2.3
Greedy-Algorithmen
Ein einfaches Planungsproblem
Huffman-Codes
Divide and Conquer
Dynamisches Programmieren
3.4 Rekursive Algorithmen
3.3.1
3.3.2
Prinzip der Rekursion an Beispielen
Rekursive Kurven
3.4 Backtracking-Algorithmen
4
Algorithmen und Datenstrukturen
4. Bäume
4.1 Grundlagen
4.1.1
4.1.2
4.1.3
4.1.4
Grundbegriffe und Definitionen
Darstellung von Bäumen
Berechnungsgrundlagen
KLassifizierung von Bäumen
4.2 Freie binäre Intervallbäume
4.2.1 Ordnungsrelation, Suchaufwand und Darstellung
4.2.2 Operationen
1. Generieren
a) Erzeugen eines binären Suchbaums in Pascal
b) Erzeugen von Binärbaumknoten bzw. eines binären Baums in C++
2. Suchen und Einfügen
a) Implementierung in Pascal
b) Implementierung in C++
3. Löschen von Knoten
4.2.3 Ordnungen und Durchlaufprinzipien
a) Implementierung in Pasacl
b) Funktionsschablonen für das Durchlaufen binärer Bäume in C++
4.2.4 Fädelung
4.3 Balancierte Bäume
4.3.1 Statisch optimierte Bäume
4.3.2 AVL-Baum
4.3.3 Splay-Bäume
4.3.4 Rot-Schwarz-Bäume
4.3.5 AA-Bäume
4.4 Die Bayer-Baum-Familie
4.4.1 Grundlagen und Definitionen
4.4.1.1 Ausgeglichene T-äre Bäume
4.4.1.2 (a,b)-Bäume
4.4.2 Darstellung
4.4.3 Suchen eines Schlüssels
4.4.4 Einfügen
4.4.5 Löschen
4.4.6 Auf Externspeichern mit Direktzugriff gespeicherte Bayer-Bäume
4.5 Alphabetische Suchbäume und mehrdimensionale Suchbäume
5
Algorithmen und Datenstrukturen
5. Graphenalgorithmen
5.1 Graphen
5.1.1 Gerichteter Graph
5.1.2 Ungerichteter Graph
5.2 Kürzeste Pfade
5.2.1 Grundlagen
5.2.2 Dijkstra´s Algorithmus
5.3 Minimale Spannbäume
5.3.1 Grundlagen
5.3.2 Kruskal’s Algorithmus
5.3.3 Der Algorithmus von Prim
5.4 Fluß in Netzwerken
6
Algorithmen und Datenstrukturen
1. Objektorientierte Programmierung mit Datenstrukturen und
Algorithmen
In den 50er Jahren bedeutete „Rechnen“ auf einem Computer weitgehend
„numerisches Lösen“ wissenschaftlich-technischer Probleme. Kontroll- und
Datenstrukturen waren sehr einfach und brauchten daher nicht weiter untersucht
werden. Ein bedeutender Anstoß kam hier aus der kommerziellen Datenverarbeitung (DV). So führte hier bspw. die Frage des Zugriffs auf ein Element einer
endlichen Menge zu einer großen Sammlung von Algorithmen 1, die grundlegende
Aufgaben der DV lösen. Dabei ergab sich: Die Leistungsfähigkeit dieser Lösungen
(Programme) ist wesentlich bestimmt durch geeignete Organisationsformen für die
zu bearbeitenden Daten.
Die Datenorganisation oder Datenstruktur und die zugehörigen Algorithmen sind
demnach ein entscheidender Bestandteil eines leistungsfähigen Programms. Ein
einführendes Beispiel soll diesen Sachverhalt vertiefen.
1.1 Ein einführendes Beispiel: Das Durchlaufen eines Binärbaums
Das ist eine Grundaufgabe zur Behandlung von Datenstrukturen. Ein binärer Baum
B ist entweder leer, oder er besteht aus einem linken Baum B L, einem Knoten W und
einem rechten Teilbaum BR. Diese Definition ist rekursiv. Den Knoten W eines
nichtleeren Baumes nennt man seine Wurzel. Beim Durchlaufen des binären
Baumes sind alle Knoten aufzusuchen (, z. B. in einer vorgegebenen „von links nach
rechts"-Reihenfolge,) mit Hilfe eines systematischen Weges, der aus Kanten
aufgebaut ist.
Die Darstellung bzw. die Implementierung eines binären Baums benötigt einen
Binärbaum-Knoten:
Dateninformation
Knotenzeiger
Links
Rechts
Zeiger
Zeiger
zum linken
zum rechten
Nachfolgeknoten
Abb. 1.1-0: Knoten eines binären Suchbaums
Eine derartige Struktur stellt die Klassenschablone baumKnoten bereit2:
#ifndef BAUMKNOTEN
#define BAUMKNOTEN
#ifndef NULL
const int NULL = 0;
#endif // NULL
// Deklaration eines Binaerbaumknotens fuer einen binaeren Baum
template <class T> class baumKnoten
1
2
D. E. Knuth hat einen großen Teil dieses Wissens in "The Art of Computer Programming" zusammengefaßt
vgl. pr11_1, baumkno.h
7
Algorithmen und Datenstrukturen
{
protected:
// zeigt auf die linken und rechten Nachfolger des Knoten
baumKnoten<T> *links;
baumKnoten<T> *rechts;
public:
// Das oeffentlich zugaenglich Datenelement "daten"
T daten;
// Konstruktor
baumKnoten (const T& merkmal, baumKnoten<T> *lzgr = NULL,
baumKnoten<T> *rzgr = NULL);
// virtueller Destruktor
virtual ~baumKnoten(void);
// Zugriffsmethoden auf Zeigerfelder
baumKnoten<T>* holeLinks(void) const;
baumKnoten<T>* holeRechts(void) const;
// Die Klasse binSBaum benoetigt den Zugriff auf
// "links" und "rechts"
};
// Schnittstellenfunktionen
// Konstruktor: Initialisiert "daten" und die Zeigerfelder
// Der Zeiger NULL verweist auf einen leeren Baum
template <class T>
baumKnoten<T>::baumKnoten (const T& merkmal, baumKnoten<T> *lzgr,
baumKnoten<T> *rzgr): daten(merkmal), links(lzgr), rechts(rzgr)
{}
// Die Methode holeLinks ermoeglicht den Zugriff auf den linken
// Nachfolger
template <class T>
baumKnoten<T>* baumKnoten<T>::holeLinks(void) const
{
// Rueckgabe des Werts vom privaten Datenelement links
return links;
}
// Die Methode "holeRechts" erlaubt dem Benutzer den Zugriff auf den
// rechten Nachfoger
template <class T>
baumKnoten<T>* baumKnoten<T>::holeRechts(void) const
{
// Rueckgabe des Werts vom privaten Datenelement rechts
return rechts;
}
// Destruktor: tut eigentlich nichts
template <class T>
baumKnoten<T>::~baumKnoten(void)
{}
#endif // BAUMKNOTEN
Mit der vorliegenden Implementierung zu einem Binärbaum-Knoten kann bspw. die
folgende Gestalt eines binären Baums erzeugt werden:
8
Algorithmen und Datenstrukturen
1
2
3
5
4
Abb1.1-1: Eine binäre Baumstruktur
Benötigt wird dazu die folgenden Anweisungen im Hauptprogrammabschnitt:
// Hauptprogramm
int main()
{
int zahl;
baumKnoten<int> *wurzel;
baumKnoten<int> *lKind, *rKind, *z;
lKind = new baumKnoten<int>(3);
rKind = new baumKnoten<int>(4);
z
= new baumKnoten<int>(2,lKind,rKind);
lKind = z;
rKind = new baumKnoten<int>(5);
z
= new baumKnoten<int>(1,lKind,rKind);
wurzel = z;
}
1.1.1 Rekursive Problemlösung
Rekursive Datenstrukturen (z.B. Bäume) werden zweckmäßigerweise mit Hilfe
rekursiv formulierter Zugriffsalgorithmen bearbeitet. Das zeigt die folgende Lösung in
C++:
#include<iostream.h>
#include<stdlib.h>
#include "baumkno.h"
// Funktionsschablone fuer Baumdurchlauf
template <class T> void wlr(baumKnoten<T>* b)
{
if (b != NULL)
{
cout << b->daten << ' ';
wlr(b->holeLinks());
// linker Abstieg
wlr(b->holeRechts());
// rechter Abstieg
}
}
// Hauptprogramm
int main()
9
Algorithmen und Datenstrukturen
{
int zahl;
baumKnoten<int> *wurzel;
baumKnoten<int> *lKind, *rKind, *z;
lKind = new baumKnoten<int>(3);
rKind = new baumKnoten<int>(4);
z
= new baumKnoten<int>(2,lKind,rKind);
lKind = z;
rKind = new baumKnoten<int>(5);
z
= new baumKnoten<int>(1,lKind,rKind);
wurzel = z;
cout << "Inorder: " << endl;
ausgBaum(wurzel,0);
wlr(wurzel);
// Rekursive Problemlösung
cout << endl;
// Nichtrekursive Problemlösung
wlrnr(wurzel);
cout << endl;
}
Das Durchlaufen geht offensichtlich von der Wurzel aus, ignoriert zuerst die rechten
Teilbäume, bis man auf leere Bäume stößt. Dann werden die Teiluntersuchungen
abgeschlossen und beim Rückweg die rechten Bäume durchlaufen.
Jeder Baumknoten enthält 2 Zeiger (Adressen von B L und BR). Die Zeiger, die auf
leere Bäume hinweisen, werden auf „NULL“ gestellt.
1.1.2 Nichtrekursive Problemlösung
Das vorliegende Beispiel ist in C++ notiert. C++ läßt rekursiv formulierte Prozeduren
zu. Was ist zu tun, wenn eine Programmiersprache rekursive Prozeduren nicht
zuläßt? Rekursive Lösungsangaben sind außerdem schwer verständlich, da ein
wesentlicher Teil des Lösungswegs dem Benutzer verborgen bleibt.
Die Ausführung rekursiver Prozeduren verlangt bekanntlich einen Stapel (stack). Ein
Stapel ist eine Datenstruktur, die auf eine Folge von Elementen 2 wesentliche
Operationen ermöglicht:
Die beiden wesentlichen Stackprozeduren sind PUSH und POP. PUSH fügt dem
Stapel ein neues Element an der Spitze (top of stack) hinzu. POP entfernt das
Spitzenelement. Die beiden Prozeduren sind mit der Typdefinition des Stapel
beschrieben. Der Stapel nimmt Zeiger auf die Baumknoten auf. Jedes Stapelelement
ist mit seinen Nachfolgern verkettet:
10
Algorithmen und Datenstrukturen
Zeiger auf Baumknoten
Top-Element
Zeiger auf Baumknoten
Zeiger auf Baumknoten
nil
nil
Abb. 1.1-2: Aufbau eines Stapels
Der nicht rekursive Baumdurchlauf-Algorithmus läßt sich mit Hilfe der
Stapelprozeduren der Containerklasse Stack der Standard Template Library (STL)
so formulieren:
template <class T> void wlrnr(baumKnoten<T>* z)
{
stack<baumKnoten<T>*, vector<baumKnoten<T>*> > s;
s.push(NULL);
while (z != NULL)
{
cout << z->daten << ' ';
if (z->holeRechts() != NULL)
s.push(z->holeRechts());
if (z->holeLinks() != NULL)
z = z->holeLinks();
else {
z = s.top();
s.pop();
}
}
}
Dieser Algorithmus ist zu überprüfen mit Hilfe des folgenden binären Baumes
11
Algorithmen und Datenstrukturen
Z1
1
Z2
Z5
2
5
Z3
Z4
3
4
Abb. 1.1-3: Zeiger im Binärbaum
Welche Baumknoten (bzw. die Zeiger auf die Baumknoten) werden beim Durchlaufen des vorliegenden Baumes (vgl. Abb. 1.1-3) über die Funktionsschablone
wlrnr aufgesucht? Welche Werte (Zeiger auf Baumknoten) nimmt der Stapel an?
Besuchte Knoten ¦
Stapel
-----------------+------------¦
Null
Z1
¦
Z5 Null
Z2
¦ Z4 Z5 Null
Z3
¦ Z4 Z5 Null
Z4
¦
Z5 Null
Z5
¦
Null
1.1.3 Verallgemeinerung
Bäume treten in vielen Situationen auf. Beispiele dafür sind:
- die Struktur einer Familie, z.B.:
Christian
Ludwig
Jürgen
Martin
Karl
Ernst
Abb. 1.1-4: Ein Familienstammbaum
- Bäume sind auch Verallgemeinerungen von Feldern (arrays), z.B.:
-- das 1-dimensionale Feld F
12
Fritz
Algorithmen und Datenstrukturen
F
F[1]
F[2]
F[3]
Abb. 1.1-5: Baumdarstellung eines eindimensionalen Felds
-- der 2-dimensionale Bereich
B
B[1,_]
B[2,_]
B[1,1]
B[1,2]
B[2,1]
B[2,2]
Abb. 1.1-6: Baumdarstellung eines zweidimensionalen Felds
-- Auch arithmetische Ausdrücke lassen sich als Bäume darstellen. So läßt sich
bspw. der Ausdruck ((3 * (5 - 2)) + 7) so darstellen:
+
*
7
3
-
5
2
Abb. 1.1-6: Baumdarstellung des arithmetischen Ausdrucks ((3 * (5 - 2)) + 7)
Durch das Aufnehmen des arithmetischen Ausdrucks in die Baumdarstellung können
Klammern zur Regelung der Abarbeitungsreihenfolge eingespart werden. Die
korrekte Auswertung des arithmetischen Ausdrucks ist auch ohne Klammern bei
geeignetem Durchlaufen und Verarbeiten der in den Baumknoten gespeicherten
Informationen gewährleistet.
Die Datenstruktur „Baum“ ist offensichtlich in vielen Anwendungsfällen die geeignete
Abbildung für Probleme, die mit Rechnerunterstützung gelöst werden sollen. Der zur
13
Algorithmen und Datenstrukturen
Lösung notwendige Verfahrensablauf ist durch das Aufsuchen der Baumknoten
festgelegt. Das einführende Beispiel zeigt das Zusammenwirken von Datenstruktur
und Programmierverfahren für Problemlösungen mit Hilfe von Datenverarbeitungsanlagen.
Bäume sind deshalb auch Bestandteil von Container-Klassen aktueller Compiler
(C++, Java). Die Java Foundation Classes (JFC) enthalten eine Klasse JTree aus
dem Paket javax.swing, die eine Baumstruktur darstellt3.
1.2 Begriffe und Erläuterungen zu Datenstrukturen und Programmierverfahren
1.2.1 Algorithmus (Verarbeitung von Daten)
1.2.1.1 Datenstruktur und Programmierverfahren
Datenorganisation heißt: Daten zweckmäßig so einrichten, daß eine möglichst
effektive Verarbeitung erreicht werden kann. So wurde im einführenden Beispiel die
Bearbeitung rekursiver Strukturen (Bäume) mit Hilfe der Datenstruktur Stapel und
den dazugehörigen Programmierverfahren (PUSH, POP) ermöglicht. Das braucht aber
immer nicht „von Anfang an“ untersucht bzw. implementiert zu werden. Bereits
vorhandenes
Wissen,
z.B.
über
Datenstrukturen
und
dazugehörige
Programmierverfahren, ist zu nutzen. Das Wissen über den Stapel und seine
Programmierung kann allgemein zur Bearbeitung der Rekursion auf einem
Digitalrechner benutzt werden und ist nicht nur auf die Bearbeitung von
Baumstrukturen beschänkt.
Datenstruktur und Programmierverfahren bilden (, wie das einführende Beispiel
zeigt,) eine Einheit. Bei der Formulierung des Lösungswegs ist man auf eine
bestimmte Darstellung der Daten festgelegt. Rein gefühlsmäßig könnte man sogar
sagen: Daten gehen den Algorithmen voran. Programmieren führt direkt zum
Denken in Datenstrukturen, um Datenelemente, die zueinander in bestimmten
Beziehungen stehen, zusammenzufassen. Mit Hilfe solcher Datenstrukturen ist es
möglich, sich auf die relevanten Eigenschaften der Umwelt zu konzentrieren und
eigene Modelle zu bilden. Die Leistung des Rechners wird dabei vom reinen
Zahlenrechnen auf das weitaus höhere Niveau der „Verarbeitung von Daten“
angehoben.
Die Programmierverfahren sind durch Algorithmen festgelegt.
1.2.1.2 Der intuitive Algorithmus-Begriff
Algorithmen spielen auch im täglichen Leben eine Rolle, z.B. in
- Bedienungsanleitungen
- Gebrauchsanleitungen
- Bauanleitungen
3
vgl. pr13229
14
Algorithmen und Datenstrukturen
Man kann deshalb zunächst einmal den Begriff Algorithmus intuitiv so festlegen:
Ein Algorithmus ist eine präzise (d.h. in einer festgelegten Sprache abgefasste) endliche Beschreibung
eines allgemeinen Verfahrens unter Angabe ausführbarer (Verarbeitungs-) Schritte.
Bei der Konzeption von Algorithmen spielen die Begriffe Terminierung und
Determinismus eine Rolle:
Ein Algorithmus heißt terminierend, wenn er (bei jeder erlaubten Eingabe von Parameterwerten) nach
endlich vielen Schritten abbricht.
bzw.
Ein Algorithmus terminiert, falls er für alle Eingaben nach endlich vielen Schritten ein Resultat liefert.
Ein deterministischer Ablauf ist bestimmt durch die eindeutige Vorgabe der
Schrittfolge. Ein determiniertes Ergebnis wird eindeutig erreicht nach vorgegebener
Eingabe. Nicht determiniert ist bspw. die zufällige Wahl einer Karte aus einem
Kartenstapel.
Ein Algorithmus heißt determiniert, falls er bei gleichen Eingaben und Startbedingungen stets dasselbe
Ergebnis liefert.
Ein Algorithmus heißt deterministisch, wenn zu jedem Zeitpunkt seiner Ausführung höchstens eine
Möglichkeit der Fortsetzung besteht; anderenfalls heißt er „nicht deterministisch“.
Nicht deterministische Algorithmen können zu einem determiniertem Ergebnis
führen, z.B.:
1. Nimm eine Zahl x ungleich Null
2. Entweder: Addiere das Dreifache von x zu x und teile das Ergebnis durch x
Oder: Subtrahiere 4 von x und subtrahiere das Ergebnis von x
3. Schreibe das Ergebnis auf
Deterministische,
terminierende
Algorithmen
definieren
jeweils
eine
Ein/Ausgabefunktion: f : Eingabewerte -> Ausgabewerte
Algorithmen geben eine konstruktiv ausführbare Beschreibung dieser Funktion, die
Funktion heißt Bedeutung (Semantik) des Algorithmus. Es kann mehrere
verschiedene Algorithmen mit der gleichen Bedeutung geben.
1.2.1.3 Bausteine für Algorithmen
Gängige Bausteine zur Beschreibung von Algorithmen sind:
- elementare Operationen
- sequentielle Ausführung (ein Prozessor)
Der Sequenzoperator ist „;“. Sequenzen ohne Sequenzoperator sind häufig
durchnummeriert und können schrittweise verfeinert werden, z.B:
(1) Koche Wasser
(2) Gib Kaffepulver in Tasse
(3) Fülle Wasser in Tasse
(2) kann verfeinert werden zu:
15
Algorithmen und Datenstrukturen
Öffne Kaffeedose;
Entnehme Löffel von Kaffee;
Kippe Löffel in Tasse;
Schließe Kaffeedose;
- parallele Ausführung
- bedingte Ausführung
Die Auswahl / Selektion kann allgemein so formuliert werden:
falls Bedingung, dann Schritt
bzw.
falls Bedingung
dann Schritt a
sonst Schritt b
„falls ... dann ... sonst ...“ entspricht in Programmiersprachen den Konstrukten:
if Bedingung then ... else … fi
if Bedingung then … else …endif
if (Bedingung) … else …
- Schleife (Iteration)
Dafür schreibt man allgemein
wiederhole Schritte
bis Abbruchkriterium
Häufig findet man auch die Variante
solange Bedingung
führe aus Schritte
bzw. die Iteration über festen Bereich
wiederhole für Bereichsangabe
Schleifenrumpf
Diese Schleifenkonstrukte
Konstrukten:
wiederhole ... bis ...
solange … führe aus
wiederhole für
entsprechen
jeweils
den
Programmiersprachen-
repeat ... until …
do …
while ...
while … do ...
while ( ... ) ...
for each ... do …
for ... do …
for ( ... ) ...
- Unterprogramm
- Rekursion
Eine Funktion (mit oder ohne Rückgabewert, mit oder ohne Parameter) darf in der
Deklaration ihres Rumpfes den eigenen Namen verwenden. Hierdurch kommt es zu
16
Algorithmen und Datenstrukturen
einem rekursiven Aufruf. Typischerweise werden die aktuellen Parameter so
modifiziert, daß die Problemgröße schrumpft, damit nach mehrmaligem Wiederholen
dieses Prinzips keine weiterer Aufruf erforderlich ist und die Rekursion abbrechen
kann.
1.2.1.4 Formale Eigenschaften von Algorithmen
Die wichtigste formale Eigenschaft eines Algorithmus ist die Korrektheit. Dazu muß
gezeigt werden, daß der Algorithmus die jeweils gestellte Aufgabe richtig löst. Man
kann die Korrektheit eines Algorithmus im Allg. nicht durch Testen an ausgewählten
Beispielen nachweisen4.
Die zweite wichtige Eigenschaft eines Algorithmus ist seine Effizienz. Die
wichtigsten Maße für die Effizienz sind der zur Ausführung des Algorithmus benötigte
Speicherplatz und die benötigte Rechenzeit. Man könnte beides durch
Implementierung des Algorithmus in einer konkreten Programmiersprache auf einem
konkreten Rechner für eine Menge repräsentativer Eingaben messen. Solche
experimentell ermittelten Meßergebnisse lassen sich nicht oder nur schwer auf
andere Implementierungen und andere Rechner übertragen.
Aus dieser Schwierigkeit bieten sich 2 Auswege an:
1. Man benutzt einen idealiserenden Modellrechner als Referenzmaschine und mißt die auf diesem
Rechner zur Ausführung des Algorithmus benötigte Zeit und benötigten Speicherplatz. Ein in der
Literatur5 zu diesem Zweck häufig benutztes Maschinenmodell ist das der RAM (Random-AccessMaschine). Eine solche Maschine verfügt über einige Register und eine (abzählbar unendliche) Menge
einzeln addressierbarer Speicherzellen. Register und Speicherzellen können je eine (im Prinzip)
unbeschränkt große (ganze oder reelle) Zahl aufnehmen. Das Befehlsrepertoire für eine RAM ähnelt
einer einfachen, herkömmlichen Assemblersprache. Die Kostenmaße Speicherplatz und Laufzeit
enthalten dann folgende Bedeutung: Der von einem Algorithmus benötigte Speicherplatz ist die Anzahl
der zur Ausführung benötigten RAM-Speicherzellen. Die benötigte Zeit ist die Zahl der ausgeführten
RAM-Befehle.
2. Bestimmung einiger für die Effizienz des Algorithmus besonders charakteristischer Parameter 6.
Laufzeit und Speicherbedarf eines Algorithmus hängen in der Regel von der Größe der Eingabe ab7.
Man unterscheidet zwischen dem Verhalten im besten Fall, dem Verhalten im Mittel (average case)
und dem Verhalten im schlechtesten Fall (worst case). In den meisten Fällen führt man eine worstcase Analyse für die Ausführung eines Algorithmus der Problengröße N durch. Dabei kommt es auf
den Speicherplatz nicht an, lediglich die Größenordnung der Laufzeit- und Speicherplatzfunktionen in
Abhängigkeit von der Größe der Eingabe N wird bestimmt. Zum Ausdruch dieser Größenordnung hat
sich eine besondere Notation eingebürgert: die O-Notation bzw. Big-O-Notation.
Statt „für die Laufzeit T(N) eines Algorithmus gilt für alle N: T ( N )  c1  N  c2 mit 2 Konstanten c1
und c2“ sagt man „T(N) ist von der Größenordnung N“ oder „T(N) ist O(N)“ oder „T(N) ist ein O(N)“ und
schreibt T ( N )  O( N ) .
Die weitaus häufigsten und wichtigsten Funktionen zur Messung der Effizienz von
Algorithmen in Abhängigkeit von der Problemgröße sind:
4
E. Dijkstra formulierte das so: Man kann durch Testen die Anwesenheit von Fehlern, aber nicht die
Abwesenheit von Fehlern nachweisen.
5
Vgl. Aho, Hopcroft, Ullman: The Design and Analysis of Computer Algorithms, Addison-Wesley Publishing
Company
6
So ist es bspw. üblich, die Laufzeit eines Verfahrens zum Sortieren einer Folge von Schlüsseln durch die
Anzahl der dabei ausgeführten Vergleichsoperationen zwischen Schlüsseln und die Anzahl der ausgeführten
Bewegungen von den jeweiligen betroffenen Datensätzen zu messen.
7
die im Einheitskostenmaß oder im logarithmischen Kostenmaß gemessen wird
17
Algorithmen und Datenstrukturen
Logarithmisches Wachstum
log N
N
N  log N
Lineares Wachstum
N  log N -Wachstum
Quadratisches, kubisches, ... Wachstum
N 2 , N 3 , ...
Exponentielles Wachstum
2 N , 3 N , ...
Abb.:
Es ist heute allgemeine Überzeugung, daß höchstens solche Algorithmen praktikabel
sind, deren Laufzeit durch ein Polynom in der Problemgröße beschränkt bleibt.
Algorithmen, die exponentielle Schrittzahl erfordern, sind schon für relativ kleine
Problemgrößen nicht mehr ausführbar.
1.2.1.5 Komplexität
Für die algorithmische Lösung eines gegebenen Problems ist es unerläßlich, daß der
gefundene Algorithmus das Problem korrekt löst. Darüber hinaus ist es natürlich
wünschenswert, daß er dies mit möglichst geringem Aufwand tut. Die Theorie der
Komplexität von Algorithmen beschäftigt sich damit, gegebene Algorithmen
hinsichtlich ihres Aufwands abzuschätzen und – darüber hinaus – für gegebene
Problemklassen anzugeben, mit welchem Mindestaufwand Probleme dieser Klasse
gelöst werden können.
Meistens geht es bei der Ananlyse der Komplexität von Algorithmen (bzw.
Problemklassen) darum, als Maß für den Aufwand eine Funktion f ( N )  N
anzugeben, wobei f ( )  a bedeutet: „ Bei einem Problem der Größe N ist der
Aufwand a“. Die Problemgröße „N“ bezeichnet dabei in der Regel ein grobes Maß für
den Umfang einer Eingabe, z.B. die Anzahl der Elemente in der Eingabeliste oder
die Größe eines bestimmten Eingabewertes. Der Aufwand „a“ ist in der Regel ein
grobes Maß für die Rechenzeit. Die Rechenzeit wird häufig dadurch abgeschätz, daß
man zählt, wie häufig eine bestimmte Operation ausgeführt wird, z.B.
Speicherzugriffe, Multiplikationen, Additionen, Vergleiche, etc.
Bsp.: Wie oft wird die Wertzuweisung „x = x + 1“ in folgenden Anweisungen
ausgeführt?
1. x = x + 1; ............
..1-mal
2. for (i=1; i <= n; i++) x = x + 1;..
..n-mal
3. for (i=1; i <= n; i++)
for (j = 1; j <= n; j++)
x = x + 1;................................... ......... n2-mal
Die Aufwandfunktion läßt sich in den wenigsten Fällen exakt bestimmen.
Vorherrschende Analysemethoden sind:
- Abschätzungen des Aufwands im schlechtesten Fall
- Abschätzungen des Aufwands im Mittel
Selbst hierfür lassen sich im Allg. keine exakten Angaben machen. Man beschränkt
sich dann auf „ungefähres Rechnen in Größenordnungen“.
Bsp.: Gegeben: n  0 a1 , a2 , a3 ,..., an  Z
Gesucht: Der Index i der (ersten) größten Zahl unter den a i (i=1,...,n)
18
Algorithmen und Datenstrukturen
Lösung:
max = 1;
for (i=2;i<=n;i++)
if (amax < ai) max = i
Wie oft wird die Anweisung „max = i“ im Mittel ausgeführt (abhängig von n)?
Die gesuchte mittlere Anzahl sei Tn. Offenbar gilt:
wenn ai das größte der Elemente
1  Tn  n . „max = i“ wird genau dann ausgeführt,
a1 , a 2 , a3 ,..., ai ist.
Angenommen wird Gleichverteilung: Für jedes i = 1, ... , n hat jedes der Elemente
a1 , a2 , a3 ,..., an die
gleiche Chance das größte zu sein, d.h.: Bei N Durchläufen wird N/n-mal die Anweisung „max = i“
ausgeführt.
Daraus folgt für N  Tn (Aufwendungen bei N Durchläufen vom „max = i“):
N  Tn  N 
N N
N
1 1
1
  ...   N (1    ...  )
2 3
n
2 3
n
Dies ist Hn, die n-te harmonische Zahl. Für Hn ist keine geschlossene Formel bekannt, jedoch eine
ungefähre Abschätzung: Tn  H n  ln n   8. Interessant ist nur, daß Tn logarithmisch von n
abhängt. Man schreibt Tn ist „von der Ordnung logn“, die multiplikative und additive Konstante sowie
die Basis des Logarithmus bleiben unspezifiziert.
Diese sog. O-Notation läßt sich mathematisch exakt definieren:
f (n)
ist für
g (n)
genügend große n durch eine Konstante c beschränkt. „f“ wächst nicht stärker als
„g“.
Diese Begriffsbildung wendet man bei der Analyse von Algorithmen an, um
Aufwandsfunktionen f :    durch Eingabe einer einfachen Vergleichsfunktion
g :    abzuschätzen, so daß f (n)  O( g (n)) gilt, also das Wachstum von f durch
das von g beschränkt ist.
Gebräuchliche Vergleichsfunktionen sind:
f (n)  O( g (n)) : c, n0 n  n0 : f (n)  c  g (n) mit f , g : N  N , d.h.
O-Notation
O(1)
Aufwand
Konstanter Aufwand
O (log n)
Logarithmischer Aufwand
O (n)
Linearer Aufwand
Problemklasse
Einige Suchverfahren für Tabellen
(„Hashing“)
Allgemeine Suchverfahren für Tabellen
(Binäre Suche)
Sequentielle Suche, Suche in Texten,
syntaktische Analyse in Programmen
„schlaues Sortieren“, z.B. Quicksort
O(n  log n)
O( n 2 )
Quadratischer Aufwand
Einige dynamische Optimierungsverfahren,
z.B. optimale Suchbäume); „dummes
Sortieren“, z.B. Bubble-Sort
Multiplikationen Matrix mal Vektor
Exponentieller Aufwand
Viele Optimierungsprobleme, automatisches
Beweisen (im Prädikatenkalkül 1. Stufe)
Alle Permutationen
O(n k ) für k  0
O( 2 n )
O (n!)
Abb.:
8
Eulersche Konstante
  0.57721566
19
Algorithmen und Datenstrukturen
Zur Veranschaulichung des Wachstums konnen die folgende Tabellen betrachtet
werden:
f(N)
ldN
N
N  ldN
N2
N3
2N
24=16
4
16
64
256
4096
65536
N=2
1
2
2
4
8
4
25=256
8
256
1808
65536
16777200
 1077
210
10
1024
10240
1048576
 109
 10308
220
20
1048576
20971520
 1012
 1018
 10315653
Unter der Annahme „1 Schritt dauert 1 s  10 6 s folgt für
N=
N
N2
N3
2N
3N
N!
10
10 s
100 s
1 ms
1 ms
59 ms
3,62 s
20
20 s
400 s
8 ms
1s
58 min
771 Jahre
30
30 s
900 s
27 ms
18 min
6.5 Jahre
1016 Jahre
40
40 s
1.6 ms
64 ms
13 Tage
3855 Jahre
1032 Jahre
50
50 s
2.5 ms
125 ms
36 Jahre
108 Jahre
1049 Jahre
60
60 s
3.6 ms
216 ms
366 Jahre
1013 Jahre
1066 Jahre
Abb.:
1.2.1.6 Laufzeitberechnungen („Big-O“-)
N
Ein einfaches Beispiel: Gegeben ist die folgende Funktion zur Berechnung von
i
3
i 1
public static int sum(int n)
{
/* 1 */ int teilSumme;
/* 2 */ teilSumme = 0;
/* 3 */ for (int i = 1; i <= n; i++)
/* 4 */
teilSumme += i * i * i;
/* 5 */ returm teilSumme;
}
Analyse zur Effizienz:
Zeile 1 und Zeile 2 zählen je einmal.
Zeile 4 zählt viermal (2 Multiplikationen, Zuweisung und 1. Additition) und
wird N-mal ausgeführt. Das ergibt 4N.
Zeile 3 zeigt die Deklaration und Initialisierung von i (zählt zweimal), den Test
i <= N und das Inkrementieren i++ (zählt jeweils N-mal).
Insgesamt führt das zu 2N + 2. Ignoriert man Aufruf und
Rückkehranweisung der Funktion erhält man 6N + 4. Man sagt dazu: Die
Funktion besitzt ein Leistungsverhalten von O(N).
Einiges kann bei der Abschätzung offensichtlich beschleunigt werden. In Zeile 3
steht bspw. eine O(1)-Anweisung. Es ist egal (für die Abschätzung der
Laufzeitberechnung), ob bei der Ausführung diese Anweisung 2fach oder 3fach
gezählt wird. Auch bzgl. der Schleife ist der Faktor 2 und die Addition von 2
unerheblich.
Das führt zu folgenden Regeln zur Abschätzung des Leistungsverhaltens nach der
„Big-O“-Notation:
20
Algorithmen und Datenstrukturen
1. Regel (für Schleifen): Die Laufzeit einer Schleife ist im wesentlichen bestimmt
durch die Anzahl der Anweisungen innerhalb des Schleifenkörpers multipliziert mit
der Anzahl der Iterationen.
2. Regel (für verschachtelte Schleifen): Die Laufzeit einer Anweisung innerhalb einer
Gruppe verschachtelter Schleifen ist bestimmt durch die Laufzeit der Anweisung
multipliziert mit dem Produkt aller Schleifengrößen.
Bsp.:
for (i=1; i <= n; i++)
for (j = 1; j <= n; j++)
k++;
ist einzuordnen unter O(N2).
3. Regel (aufeinanderfolgende Anweisungen): Der größte Wert zählt für das
Leistungsverhalten.
Bsp.:
for (int i=1; i <= n; i++)
a[i] = 0;
// O(N)
for (int i=1; i <= n; i++)
for (int j=1; j <= n; j++)
a[i] += i + j;
// O(N2)
Insgesamt ergibt sich das Leistungsverhalten O(N2).
4. Regel: Die Laufzeit einer „if“-Anweisung ist niemals größer als die Laufzeit des
Tests plus der größeren Laufzeit vom „ja“- bzw. „nein“-Zweig.
Rekursionen können häufig auf einfache Schleifen mit dem Leistungsverhalten O(N)
zurückgeführt werden, z.B.:
public static long fakultaet(int n)
{
if (n <= 1) return 1;
else return n * fakultaet(n-1);
Liegen in einer Funktion mehrere rekursive Aufrufe vor, dann ist die Umsetzung in
eine einfache Schleifenstruktur nicht so einfach.
Bsp.:
public static long fib(int n)
{
/* 1 */ if (n <= 1)
/* 2 */ return 1;
else
/* 3 */ returm fib(n-1) + fib(n-2);
}
Die Analyse ergibt unter der Annahme, daß T(N) die Laufzeit nach einem Aufruf von
fib(n) ist: Für N = 0, N = 1 ist T(0) = T(1) = 1 (irgendein konstanter Wert.
In Zeile 3 wird fib(N-1) aufgerufen, was eine Laufzeit von T(N-1) bewirkt.
Anschließend wird fib(N-2) aufgerufen, was eine Laufzeit von T(N-2) bewirkt.
Zusammengezählt ergibt das: T(N) = T(N-1) + T(N-2) + 2. Da fib(N) =
fib(N-1) + fib(N-2) ist, kann leicht gezeigt werden, daß T(N) >= fib(N) ist.
5
Man kann zeigen: fib( N )  ( ) N . Das bedeutet: Die Laufzeit dieses Programms
3
wächst exponentiell (schlechter geht es nicht mehr).
Man kann häufig dasselbe Problem mit verschieden Algorithmen lösen. Das Ziel ist
natürlich, den für das Problem besten Algorithmus zu finden bzw. zu implementieren:
21
Algorithmen und Datenstrukturen
Bsp.: Das Maximum-Subarray-Problem
Gegeben ist eine Folge X von N ganzen Zahlen in einem Array. Gesucht ist die maximale Summe aller
zusammenhängenden Teilfolgen. Sie wird als maximale Teilsumme bezeichnet.
So ist für die Eingabefolge
X[0]
31
X[1]
-41
X[2]
59
X[3]
26
X[4]
-53
X[5]
58
X[6]
97
X[7]
-93
X[8]
-23
X[9]
84
die Summe der Teilfolgen X[2] + X[3] + X[4] + X[5] + X[6] mit dem Wert (59 + 26 – 53 + 58 + 97) = 187
die Lösung des Problems. Lösungen zu diesem Problem können auf verschiedene Weise erreicht
werden:
1. Lösung
public
{
/* 1
/* 2
/* 3
/*
/*
/*
/*
/*
4
5
6
7
8
/* 9
static int
maxSubsum1(int a[])
*/ int maxSumme = 0;
*/ for (int i = 0; i < a.length;i++)
*/ for (int j = i; j < a.length; j++)
{
*/
int summe = 0;
*/
for (int k = i; k <= j; k++)
*/
summe += a[k];
*/
if (summe > maxSumme)
*/
maxSumme = summe;
}
*/ return maxSumme;
}
N
Die Analyse des Leistungsverhaltens wird bestimmt durch
N
j
1  O( N
3
) . Diese Summe
i 1 j i k i
berechnet, wieviele Male Zeile 6 ausgeführt wird.
2. Lösung
public static int maxSubsum2(int a[])
{
/* 1 */ int maxSumme = 0;
/* 2 */ for (int i = 0; i < a.length;i++)
{
/* 3 */ int summe = 0;
/* 4 */ for (int j = i; j < a.length; j++)
{
/* 5 */
summe += a[j];
/* 6 */
if (summe > maxSumme)
/* 7 */
maxSumme = summe;
}
}
/* 8 */ return maxSumme;
}
In dieser Lösung ist das Leistungsverhalten auf O(N2) reduziert.
3. Lösung
Diese Lösung folgt der „Divide-and-Conquer“-Strategie, die ein sehr allgemeines und mächtiges
Prinzip zur algorithmischen Lösung von Problemen darstellt. Das zugehörige Problemlösungsschema
kann allg. so formuliert werden:
22
Algorithmen und Datenstrukturen
1. Divide: Teile das Problem der Größe N in (wenigstens) 2 annähernd gleich große Teilprobleme,
wenn N > 1 ist, sonst löse das Problem der Größe 1 direkt.
2. Conquer: Löse die Teilprobleme auf dieselbe Art.
3. Merge: Füge die Teillösungen zur Gesamtlösung zusammen.
Abb.: Divide and Conquer-Verfahren zur Lösung eines Problems der Größe N
Bei der Anwendung dieses Algorithmus auf das vorliegende Problem bewirkt das Teilen der Folge in
Teilfolgen evtl. das Trennen der Teilfolge mit der größten Teilsumme, z.B.: Bei der Vorgabe
a[0]
4
a[1]
-3
a[2]
5
1. Häfte
a[3]
-2
a[4]
-1
a[5]
2
a[6]
6
2. Hälfte
a[7]
-2
ist die größte Teilsumme in der ersten Teilhälfte 6 (a[0] + a[1] + a[2]), die größte Teilsumme in der
zweiten Teilhälfte ist 8 (a[5] + a[6]). Die maximale Summe in der 1. Hälfte, die das letzte Element in der
1. Hälfte mit einschließt (a[0] + a[1] + a[2] + a[3]) ist 4. Die maximale Summe in der 2. Hälfte, die das
erste Element in der 2. Hälfte einschließt ist 7. Die maximale Summe, die beide Hälften überspannt ist
4 + 7 = 11. Der Algorithmus muß demnach Teilsummenbildungen über die jeweilige Teilhälften
berücksichtigen.
private static int maxSubsum(int a[], int links, int rechts)
{
/* 1 */
if (links == rechts)
/* 2 */
if (a[links] > 0)
// Falls dieses Element positiv ist
/* 3 */
return a[links];
// dann ist es die max.Teilsumme
/* 4 */
else return 0;
/* 5 */
int mitte = (links + rechts) / 2;
/* 6 */
int maxLinkeSumme = maxSubsum(a, links, mitte);
/* 7 */
int maxRechteSumme = maxSubsum(a, mitte + 1, rechts);
/* 8 */
int maxLinkeGrenzSumme = 0, linkeGrenzsumme = 0;
/* 9 */
/*10 */
/*11 */
/*12 */
/*13 */
/*14 */
/*15 */
/*16 */
/*17 */
/*18 */
for (int i = mitte; i >= links; i--)
{
linkeGrenzsumme += a[i];
if (linkeGrenzsumme > maxLinkeGrenzSumme)
maxLinkeGrenzSumme = linkeGrenzsumme;
}
int maxRechteGrenzSumme = 0, rechteGrenzsumme = 0;
for (int i = mitte + 1; i <= rechts; i++)
{
rechteGrenzsumme += a[i];
if (rechteGrenzsumme > maxRechteGrenzSumme)
maxRechteGrenzSumme = rechteGrenzsumme;
}
if (maxLinkeSumme > maxRechteSumme)
if (maxLinkeSumme > (maxRechteGrenzSumme + maxLinkeGrenzSumme))
return maxLinkeSumme;
else return (maxRechteGrenzSumme + maxLinkeGrenzSumme);
else if (maxRechteSumme > (maxRechteGrenzSumme +
maxLinkeGrenzSumme) )
return maxRechteSumme;
else return (maxRechteGrenzSumme + maxLinkeGrenzSumme);
}
public static int maxSubsum3(int a[])
{
return maxSubsum(a, 0, a.length - 1);
}
Die Anwendung des Lösungsverfahrens auf das
Implementierung mit dem Leistungsverhalten O(NlogN).
Max-Subarray-Problem
4. Lösung: Implementierung mit dem Leistungsverhalten O(N)
23
führt
zu
einer
Algorithmen und Datenstrukturen
Die Positionen 0,..,N-1 der Eingabefolge bilden eine aufsteigend sortierte, lineare Folge von
Inspektionsstellen (oder: Ereignispunkten). Man durchläuft die Eingabe in der durch die
Inspektionsstelleb vorgegebenen Reihenfolge und führt zugleich eine vom jeweiligen Problem
abhängige, dynamisch veränderliche, d.h. an jeder Informationsstelle gegebenenfalls zu korrigierende
Information mit. Im vorliegenden Fall ist das die maximale Summe einer Teilfolge (maxSumme) im
gesamten bisher inspizierten Anfangsteil und das an der Inspektionstelle endende rechte
Randmaximum (summe) des bisher inspizierten Anfangsstücks.
public static int maxSubsum4(int a[])
{
/* 1 */ int maxSumme = 0, summe = 0;
/* 2 */ for (int j = 0; j < a.length; j++)
{
/* 3 */
summe += a[j];
/* 4 */
if (summe > maxSumme)
/* 5 */
maxSumme = summe;
/* 6 */
else if (summe < 0)
/* 7 */
summe = 0;
}
/* 8 */ return maxSumme;
}
Das ist ein Algorithmus, der in linearer Zeit ausführbar ist. Zur Bestimmung der maximalen Teilfolge
müssen alle Folgeelemente wenigstens einmal betrachtet werden. Das sind insgesamt N Schritte.
1.2.1.7 O(logN)-Algorithmen
Gelingt es die Problemgröße in konstanter Zeit (O(1)) zu halbieren, dann zeigt der
zugehörige Algorithmus das Leistungsverhalten O(logN)). Nur spezielle Probleme
können dieses Leistungsverhalten erreichen.
Binäre Suche
Aufgabe: Gegeben ist eine Zahl X und eine sortiert vorliegenden Folge von
Ganzzahlen A0, A1, A2, ... , AN-1 im Arbeitsspeicher. Finde die Position i so, daß Ai=X
bzw. gib i=-1 zurück, wenn X nicht gefunden wurde.
Implementierung
public static int binaereSuche(Comparable a[], Comparable x)
{
/* 1 */ int links = 0, rechts = a.length - 1;
/* 2 */ while (links < rechts)
{
/* 3 */ int mitte = (links + rechts) / 2;
/* 4 */ if (a[mitte].compareTo(x) < 0)
/* 5 */
links = mitte + 1;
/* 6 */ else if (a[mitte].compareTo(x) > 0)
/* 7 */
rechts = mitte - 1;
else
/* 8 */
return mitte;
// Gefunden
}
/* 9 */ return -1;
// Nicht gefunden
}
Leistungsanalyse: Entscheidend für das Leistungsverhalten ist die Schleife (/* 2 */.
Sie beginnt mit (rechts – links) = N-1 und endet mit (rechts – links) =
-1. Bei jedem Schleifendurchgang muß (rechts – links) halbiert werden. Ist
bspw. (rechts – links) = 128, dann sind die maximalen Werte nach jeder
24
Algorithmen und Datenstrukturen
Iteration: 64, 32, 16, 8, 4, 2, 1, 0, -1. Die Laufzeit läßt sich demnach in der
Größenordnung O(logN) sehen.
Die binäre Suche ist eine Implementierung eines Algorithmus für eine Datenstruktur
(sequentiell gespeicherte Liste, Array). Zum Aufsuchen von Datenelementen wird
eine Zeit von O(logN) verbraucht. Alle anderen Operationen (z.B. Einfügen) nehmen
ein Leistungsverhalten von O(N) in Anspruch.
1.2.1.8 Effizienz
System-Effizienz und rechnerische Effizienz
Effiziente Algorithmen zeichnen sich aus durch
- schnelle Bearbeitungsfolgen (Systemeffizienz) auf unterschiedliche Rechnersystemen. Hier wird die
Laufzeit der diversen Suchalgorithmen auf dem Rechner (bzw. verschiedene Rechnersysteme)
ermittelt und miteinander verglichen. Die zeitliche Beanspruchung wird über die interne Systemuhr
gemessen und ist abhängig vom Rechnertyp
- Inanspruchnahme von möglichst wenig (Arbeits-) Speicher
- Optimierung wichtiger Leistungsmerkmale, z.B. die Anzahl der
Vergleichsbedingungen, die Anzahl der Iterationen, die Anzahl der
Anweisungen (, die der Algorithmus benutzt). Die Berechnungskriterien
bestimmen die sog. rechnerische Komplexität in einer Datensammlung.
Man spricht auch von der rechnerischen Effizienz.
Berechnungsgrundlagen für rechnerische Komplexität
Generell kann man für Algorithmen folgende Grenzfälle bzgl. der Rechenbarkeit
beobachten:
- kombinatorische Explosion
Es gibt eine Reihe von klassischen Problemen, die immer wieder in der Mathematik oder der DVLiteratur auftauchen, weil sie knapp darzustellen und im Prinzip einfach zu verstehen sind. Manche von
ihnen sind nur von theoretischen Interesse, wie etwa die Türme von Hanoi.
Ein anderes klassisches Problem ist dagegen das Problem des Handlungsreisenden9 (Travelling
Salesman Problem, TSP). Es besteht darin, daß ein Handlungsreisender eine Rundreise zwischen
einer Reihe von Städten machen soll, wobei er am Ende wieder am Abfahrtort ankommt. Dabei will er
den Aufwand (gefahrene Kilometer, gesamte Reisezeit, Eisenbahn- oder Flugkosten, je nach dem
jeweiligen Optimierungswunsch) minimieren. So zeigt bspw. die folgende Entfernungstabelle die zu
besuchenden Städte und die Kilometer zwischen ihnen:
München
Frankfurt
Heidelberg
Karlsruhe
Mannheim
9
Frankfurt
395
-
Heidelberg
333
95
-
Karlsruhe
287
143
54
-
Mannheim
347
88
21
68
-
Wiesbaden
427
32
103
150
92
Vorbild für viele Optimierungsaufgaben, wie sie vor allem im Operations Research immer wieder vorkommen.
25
Algorithmen und Datenstrukturen
Grundsätzlich (und bei wenigen Städten, wie in diesem Bsp., auch tatsächlich) ist die exakte Lösung
dieser Optimierungsaufgabe mit einem trivialen Suchalgorithmus zu erledigen. Man rechnet sich
einfach die Route der Gesamtstrecke aus und wählt die kürzeste.
Der benötigte Rechenaufwand steigt mit der Zahl N der zu besuchenden Städte sprunghaft an. Erhöht
man bspw. N von 5 auf 10, so verlängert sich die Rechenzeit etwa auf das „dreißigtausendfache“. Dies
nennt man kombinatorische Explosion, weil der Suchprozeß jede mögliche Kombination der für das
Problem relevanten Objekte einzeln durchprobieren muß. Der Aufwand steigt proportional zur Fakultät
(N!).
- exponentielle Explosion
Wie kann man die vollständige Prüfung aller möglichen Kombinationen und damit die kombinatorische
Explosion umgehen? Naheliegend für das TSP ist, nicht alle möglichen Routen zu berechnen und erst
dann die optimale zu suchen, sondern sich immer die bis jetzt beste zu merken und das Ausprobieren
einer neuen Wegkombination sofort abzubrechen, wenn bereits eine Teilstrecke zu größeren
Kilometerzahlen führt als das bisherige Optimum, z.B.:
Route 1
München
Karlsruhe
Heidelberg
Mannheim
Wiesbaden
Frankfurt
München
Streckensumme
0
287
341
362
454
486
881
Route 2
München
Wiesbaden
Karlsruhe
Frankfurt
Heidelberg
Streckensumme
0
429
722
865
960
Route 2 kann abgebrochen werden, weil die Teilstrecke der Route 2 (960) bereits länger ist als die
Gesamtstrecke der Route 1. Diese Verbesserung vermeidet die kombinatorische Explosion, ersetzt sie
aber leider nur durch die etwas schwächere exponentielle Explosion. Die Rechenzeit nimmt
exponetiell, d.h. mit aN für irgendeinen problemspezifischen Wert zu. Im vorliegenden Fall ist a etwa
1.26.
- polynomiales Zeitverhalten
In der Regel ist polynomiales Zeitverhalten das beste, auf das man hoffen kann. Hiervon redet man,
wenn man die benötigte Rechenzeit durch ein Polynom
T  a n N n  ...  a 2 N 2  a1 N  a 0
ausgedrückt werden. „N“ ist bestimmt durch die zu suchenden problemspezifischen Werte, n
beschreibt den Exponenten. Da gegen das erste Glied mit der höchsten Potenz bei größeren
Objektzahlen alle anderen Terme des Ausdrucks vernachlässigt werden können, klassifiziert man das
polynomiale Zeitverhalten nach dieser höchsten Potenz. Man sagt, ein Verfahren zeigt polynomiales
Zeitverhalten O(Nn), falls die benötigte Rechenzeit mit der nten Potenz der Zahl der zu bearbeitenden
Objekte anwächst.
Die einzigen bekannten Lösungen des TSP, die in polynomialer Zeit ablaufen, verzichten darauf, unter
allen Umständen die beste Lösung zu finden, sondern geben sich mit einer recht guten Lösung
zufrieden. In der Fachsprache wird das so ausgedrückt, daß das TSP NP-vollständig sei. Das
bedeutet: In polynomialer Zeit kann nur eine nichtdeterministische Lösung berechnet werden, also
eine, die nicht immer deterministisch ein und dasselbe (optimale) Ergebnis findet.
Ein Verfahren, für das nicht garantiert werden kann, daß es in allen Fällen ein exaktes Resultat liefert,
wird heuristisch genannt. Eine naheliegende heuristische Lösung für das TSP ist der „Nächste
Nachbarn-Algorithmus“. Er beginnt die Route mit der Stadt, die am nächsten zum Ausgangsort liegt
und setzt sie immer mit derjenigen noch nicht besuchten Stadt fort, die wiederum die nächste zum
jeweiligen Aufenthaltsort ist. Da in jeder der N Städte alle (d.h. im Durchschnitt (N-1)/2) noch nicht
besuchte Orte nach dem nächsten benachbarten durchsucht werden müssen, ist der Teitaufwand für
das Durchsuchen proportional N  ( N  1) / 2 , d.h. O(N2), und damit polynomial „in quadratischer Zeit“
Die meisten Algorithmen für Datenstrukturen bewegen sich in einem schmalen Band
rechnerischer Komplexität. So ist ein Algorithmus von der Ordnung O(1) unabhängig
26
Algorithmen und Datenstrukturen
von der Anzahl der Datenelemente in der Datensammlung. Der Algorithmus läuft
unter konstanter Zeit ab, z.B.: Das Suchen des zuletzt in eine Schlange eingefügten
Elements bzw. die Suche des Topelements in einem Stapel.
Ein Algorithmus mit dem Verhalten O(N) ist linear. Zeitlich verhält er sich proportional
zur Größe der Liste.
Bsp.: Das Bestimmen des größten Elements in einer Liste. Es sind N Elemente zu
überprüfen, bevor das Ende der Liste erkannt wird.
Andere Algorithmen zeigen „logarithmisches Verhalten“. Ein solches Verhalten läßt
sich beobachten, falls Teildaten die Größe einer Liste auf die Hälfte, ein Viertel, ein
Achtel... reduzieren. Die Lösungssuche ist dann auf die Teilliste beschränkt.
Derartiges Verhalten findet sich bei der Behandlung binärer Bäume bzw. tritt auf
beim „binären Suchen“. Der Algorithmus für binäre Suche zeigt das Verhalten
O(log 2 N ) , Sortieralgorithmen wie der Quicksort und Heapsort besitzen eine
rechnerische Komplexität von O( N log 2 N ) . Einfache Sortierverfahren (z.B. „BubbleSort“) bestehen aus Algorithmen mit einer Komplexität von O( N 2 ) . Sie sind deshalb
auch nur für kleine Datenmengen brauchbar. Algorithmen mit kubischem Verhalten
O( N 3 ) sind bereits äußerst langsam (z.B. der Algorithmus von Warshall zur
Bearbeitung von Graphen). Ein Algorithmus mit einer Komplexität von O( 2 N ) zeigt
exponentielle Komplexität. Ein derartiger Algorithmus ist nur für kleine N auf einem
Rechner lauffähig.
27
Algorithmen und Datenstrukturen
1.2.2 Daten und Datenstrukturen
1.2.2.1 Der Begriff Datenstruktur
Betrachtet wird ein Ausschnitt aus der realen Welt, z.B. die Hörer dieser Vorlesung
an einem bestimmten Tag:
Juergen
Josef
Liesel
Maria
........
Regensburg
.........
.........
.........
.........
Bad Hersfeld
.........
.........
.........
.........
13.11.70
........
........
........
........
Friedrich-. Ebertstr. 14
..........
..........
..........
..........
Diese Daten können sich zeitlich ändern, z.B. eine Woche später kann eine
veränderte Zusammensetzung der Zuhörerschaft vorliegen. Es ist aber deutlich
erkennbar: Die Modelldaten entsprechen einem zeitinvarianten Schema:
NAME
WOHNORT
GEBURTSORT
GEB.-DATUM
STRASSE
Diese Feststellung entspricht einem Abstraktionsprozeß und führt zur Datenstruktur.
Sie bestimmt den Rahmen (Schema) für die Beschreibung eines Datenbestandes.
Der Datenbestand ist dann eine Ansammlung von Datenelementen (Knoten), der
Knotentyp ist durch das Schema festgelegt.
Der Wert eines Knoten k  K wird mit wk bezeichnet und ist ein n  0 -Tupel von
Zeichenfolgen; w i k bezeichnet die i-te Komponente des Knoten. Es gilt
wk  ( w1k , w2 k ,...., wn k )
Die Knotenwerte des vorstehenden Beispiels sind:
wk1 = (Jürgen____,Regensburg,Bad Hersfeld,....__,Ulmenweg__)
wk2 = (Josef_____,Straubing_,......______,....__,........__)
wk3 = (Liesel____,....._____,......______,....__,........__)
..........
wkn = (__________,__________,____________,______,__________)
Welche Operationen sind mit dieser Datenstruktur möglich?
Bei der vorliegenden Tabelle sind z.B. Zugriffsfunktionen zum Einfügen, Löschen
und Ändern eines Tabelleneintrages mögliche Operationen. Generell bestimmen
Datenstrukturen auch die Operationen, die mit diesen Strukturen ausgeführt werden
dürfen.
Zusammenhänge zwischen den Knoten eines Datenbestandes lassen sich mit Hilfe
von Relationen bequem darstellen. Den vorliegenden Datenbestand wird man aus
Verarbeitungsgründen bspw. nach einem bestimmten Merkmal anordnen (Ordnungsrelation). Dafür steht hier (im vorliegenden Beispiel) der Name der Studenten:
28
Algorithmen und Datenstrukturen
Josef
Juergen
Liesel
Abb. 1.2-1: Einfacher Zusammenhang zwischen Knoten eines Datenbestandes
Datenstrukturen bestehen also aus Knoten(den einzelnen Datenobjekten) und
Relationen (Verbindungen). Die Verbindungen bestimmen die Struktur des
Datenbestandes.
Bsp.:
1. An Bayerischen Fachhochschulen sind im Hauptstudium mindestens 2
allgemeinwissenschaftliche Wahlfächer zu absolvieren. Zwischen den einzelnen
Fächern, den Dozenten, die diese Fächer betreuen, und den Studenten bestehen
Verbindungen. Die Objektmengen der Studenten und die der Dozenten ist nach den
Namen sortiert (geordnet). Die Datenstruktur, aus der hervorgeht, welche
Vorlesungen die Studenten bei welchen Dozenten hören, ist:
29
Algorithmen und Datenstrukturen
STUDENT
FACH
DATEN
DOZENT
DATEN
DATEN
DATEN
DATEN
DATEN
DATEN
DATEN
DATEN
DATEN
geordnet
(z.B. nach Matrikelnummern)
geordnet
(z.B. nach Titel
im Vorlesungsverzeichnis)
geordnet
(z.B. nach Namen)
Abb.: 1.2-2: Komplexer Zusammenhang zwischen den Knoten eines Datenbestands
2. Ein Gerät soll sich in folgender Form aus verschiedenen Teilen zusammensetzen:
Anfangszeiger Analyse
Anfangszeiger Vorrat
G1, 5
B2, 4
B1, 3
B3, 2
B4, 1
Abb. 1.2-3: Darstellung der Zusammensetzung eines Geräts
30
Algorithmen und Datenstrukturen
2 Relationen können hier unterschieden werden:
1) Beziehungsverhältnisse eines Knoten zu seinen unmittelbaren Nachfolgeknoten. Die Relation
Analyse beschreibt den Aufbau eines Gerätes
2) Die Relation Vorrat gibt die Knoten mit w2k <= 3 an.
Die Beschreibung eines Geräts erfordert in der Praxis eine weit komplexere
Datenstruktur (größere Knotenzahl, zusätzliche Relationen).
3. Eine Bibliotheksverwaltung soll angeben, welches Buch welcher Student entliehen
hat. Es ist ausreichend, Bücher mit dem Namen des Verfassers (z.B. „Stroustrup“)
und die Entleiher mit ihrem Vornamen (z.B. „Juergen“, „Josef“) anzugeben. Damit
kann die Bibliotheksverwaltung Aussagen, z.B. „Josef hat Stroustrup ausgeliehen“
oder „Juergen hat Goldberg zurückgegeben“ bzw. Fragen, z.B. „welche Bücher hat
Juergen ausgeliehen?“, realisieren. In die Bibliothek sind Objekte aufzunehmen, die
Bücher repäsentieren, z.B.:
Buch
„Stroustrup“
Weiterhin muß es Objekte geben, die Personen repräsentieren, z.B.:
Person
„Juergen“
Falls „Juergen“ Stroustrup“ ausleiht, ergibt sich folgende Darstellung:
Person
„Juergen“
Buch
„Stroustrup“
Abb. 1.2-4: Objekte und ihre Beziehung in der Bibliotheksverwaltung
Der Pfeil von „Stroustrup“ nach „Juergen“ zeigt: „Juergen“ ist der Entleiher von „Stroustrup“, der Pfeil
von „Juergen“ nach „Stroustrup“ besagt: „Stroustrup“ ist eines der von „Juergen“ entliehenen Bücher.
Für mehrere Personen kann sich folgende Darstellung ergeben:
31
Algorithmen und Datenstrukturen
Person
Person
„Juergen“
Person
„Josef“
Buch
Buch
„Stroustrup“
„Goldberg“
Buch
„Lippman“
Abb. 1.2-5: Objektverknüpfungen in der Bibliotheksverwaltung
Zur Verbindung der Klasse „Person“ bzw. „Buch“ wird eine Verbindungsstruktur benötigt:
Person
buecher =
Verbindungsstruktur
„Juergen“
Buch
„Stroustrup“
Abb.1.2-6: Verbindungsstruktur zwischen den Objekttypen „Person“ und „Buch“
Ein bestimmtes Problem kann auf vielfätige Art in Rechnersystemen abgebildet
werden. So kann das vorliegende Problem über verkettete Listen im Arbeitsspeicher
oder auf Externspeicher (Dateien) realisiert werden.
Die vorliegenden Beispiele können folgendermaßen zusammengefaßt werden:
Die Verkörperung einer Datenstruktur wird durch das Paar D = (K,R) definiert.
K ist die Knotenmenge (Objektmenge) und R ist eine endliche Menge von binären
Relationen über K.
32
Algorithmen und Datenstrukturen
1.2.2.2 Relationen und Ordnungen
Relationen
Zusammenhänge zwischen den Knoten eines Datenbestandes lassen sich mit Hilfe
von Relationen bequem darstellen.
Eine Relation ist bspw. in folgender Form gegeben:
R = {(1,2),(1,3),(2,4),(2,5),(2,6),(3,5),(3,7),(5,7),(6,7)}
Diese Relation bezeichnet eine Menge geordneter Paare oder eine Produktmenge
M  N . Sind M und N also Mengen, dann nennt man jede Teilmenge M  N eine
zweistellige oder binäre Relation über M  N (oder nur über M , wenn M  N ist).
Jede binäre Relation auf einer Produktmenge kann durch einen Graphen dargestellt
werden, z.B.:
1
3
2
5
6
4
7
Abb.: 1.2-7: Ein Graph zur Darstellung einer binären Relation
Bsp.: Gegeben ist S (eine Menge der Studenten) und V (eine Menge von
Vorlesungen). Die Beziehung ist: x  S hört y V . Diese Beziehung kann man
durch die Angabe aller Paare ( x , y ) beschreiben, für die gilt: Student x hört
Vorlesung y . Jedes dieser Paare ist Element des kartesischen Produkts S  V der
Mengen S und V .
Für Relationen sind aus der Mathematik folgende Erklärungen bekannt:
1. Vorgänger und Nachfolger
R ist eine Relation über der Menge M.
Gilt ( a, b)  R , dann sagt man: „a ist Vorgänger von b, b ist Nachfolger von a“.
Zweckmäßigerweise unterscheidet man in diesem Zusammenhang auch den
Definitions- und Bildbereich
Def(R) = { x | ( x , y )  R }
Bild(R) = { y | ( x , y )  R }
33
Algorithmen und Datenstrukturen
2. Inverse Relation (Umkehrrelation)
Relationen sind umkehrbar. Die Beziehungen zwischen 2 Grössen x und y können auch als Beziehung
zwischen y und x dargestellt werden, z.B.: Aus „x ist Vater von y“ wird durch Umkehrung „y ist Sohn
von x“.
Allgemein gilt:
R-1 = { (y,x) | ( x , y )  R }
3. Reflexive Relation
 ( x, x )  R (Für alle Elemente x aus M gilt, x steht in Relation zu x)
xM
Beschreibt man bspw. die Relation "... ist Teiler von ..." für die Menge M = {2,4,6,12} in einem Grafen,
so erhält man:
12
4
6
2
Abb.1.2-8: Die binäre Relation „... ist Teiler von ... “
Alle Pfeile, die von einer bestimmten Zahl ausgehen und wieder auf diese Zahl verweisen, sind
Kennzeichen einer reflexiven Relation ( in der Darstellung sind das Schleifen).
Eine Relation, die nicht reflexiv ist, ist antireflexiv oder irreflexiv.
4. Symmetrische Relation
Aus (( ( x , y )  R ) folgt auch (( ( y , x )  R ).
Das läßt sich auch so schreiben: Wenn ein geordnetes Paar (x,y) der Relation R angehört, dann
gehört auch das umgekehrte Paar (y,x) ebenfalls dieser Relation an.
Bsp.:
a) g ist parallel zu h
h ist parallel zu g
b) g ist senkrecht zu h
h ist senkrecht zu g
5. Asymmetrische Relation
Solche Relationen sind auch aus dem täglichen Leben bekannt. Es gilt bspw. „x ist Vater von y“ aber
nicht gleichzeitig „y ist Vater von x“.
Eine binäre Relation ist unter folgenden Bedingungen streng asymetrisch:
34
Algorithmen und Datenstrukturen
 ( x , y )  R  (( y , x )  R )
( x , y )R
Das läßt sich auch so ausdrücken: Gehört das geordnete Paar (x,y) zur Relation, so gehört das
entgegengesetzte Paar (y,x) nicht zur Relation.
Gilt für x <> y die vorstehende Relation und für x = y ( x , x )  R , so wird diese binäre Relation
"unstreng asymmetrisch" oder "antisymmetrisch" genannt.
6. Transitive Relation
Eine binäre Relation ist transitiv, wenn (( ( x , y )  R ) und (( ( y , z )  R ) ist, dann ist auch
(( ( x , z )  R ). x hat also y zur Folge und y hat z zur Folge. Somit hat x auch z zur Folge.
7. Äquivalenzrelation
Eine binäre Relation ist eine Äquivalenzrelation, wenn sie folgenden Eigenschaften entspricht:
- Reflexivität
- Transitivität
- Symmetrie
Bsp.: Die Beziehung "... ist ebenso gross wie ..." ist eine Äquivalenzrelation.
1. Wenn x1 ebenso groß ist wie x2, dann ist x2 ebenso groß wie x1. Die Relation ist symmetrisch.
2. x1 ist ebenso groß wie x1. Die Relation ist reflexiv.
3. Wenn x1 ebenso groß wie x2 und x2 ebenso gross ist wie x3, dann ist x1 ebenso groß wie x3. Die
Relation ist transitiv.
Klasseneinteilung
- Ist eine Äquivalenzrelation R in einer Menge M erklärt, so ist M in Klassen eingeteilt
- Jede Klasse enthält Elemente aus M, die untereinander äquivalent sind
- Die Einteilung in Klassen beruht auf Mengen M1, M2, ... , Mx, ... , My
Für die Teilmengen gilt:
Mx  M y  0
(2) M1  M 2 .... M y  M
(1)
(3) Mx <> 0 (keine Teilmenge ist die leere Menge)
Bsp.: Klasseneinteilungen können sein:
- Die Menge der Studenten der FH Regensburg: Äquivalenzrelation "... ist im gleichen Semester
wie ..."
- Die Menge aller Einwohner einer Stadt in die Klassen der Einwohner, die in der-selben Straße
wohnen: Äquivalenzrelation ".. wohnt in der gleichen Strasse wie .."
Aufgabe
1. Welche der folgenden Relationen sind transitiv bzw. nicht transitiv?
1)
2)
3)
4)
5)
...
...
...
...
...
ist
ist
ist
ist
ist
der Teiler von ....
der Kamerad von ...
Bruder von ...
deckungsgleich mit ...
senkrecht zu ...
(transitiv)
(transitiv)
(transitiv)
(transitiv)
(nicht transitiv)
2. Welche der folgenden Relationen sind Aequivalenzrelationen?
1) ... gehört dem gleichen Sportverein an ...
2) ... hat denselben Geburtsort wie ...
35
Algorithmen und Datenstrukturen
3) ... wohnt in derselben Stadt wie ...
4) ... hat diesselbe Anzahl von Söhnen
Ordnungen
1. Halbordnung
Eine binäre Relation ist eine "Halbordnung", wenn sie folgende Eigenschaften
besitzt: "Reflexivität, Transitivität"
2. Strenge Ordnungsrelation
Eine binäre Relation ist eine "strenge Ordnungsrelation", wenn sie folgende Eigenschaft besitzt: "Transitivität, Asymmetrie"
3. Unstrenge Ordnungsrelation
Eine binäre Relation ist eine "unstrenge Ordnungsrelation", wenn sie folgende
Eigenschaften besitzt: Transitivität, unstrenge Asymmetrie
4. Totale Ordnungsrelation und partielle Ordnungsrelation
Tritt in der Ordnungsrelation x vor y auf, so verwendet man das Symbol < (x < y).
Vergleicht man die Abb. 1.2-9, so kann man für (1) schreiben: e < a < b < d und c < d
Das Element c kann man weder mit den Elementen e, a noch mit b in eine
gemeinsame Ordnung bringen. Daher bezeichnet man diese Ordnungsrelation als
partielle Ordnung (teilweise Ordnung).
Eine totale Ordnungsrelation enthält im Gegensatz dazu Abb. 1.2-9 in (2): e < a <
b<c<d
Kann also jedes Element hinsichtlich aller anderen Elemente geordnet werden, so ist
die Ordnungsrelation eine totale, andernfalls heißt sie partiell.
(1)
(2)
a
a
b
e
e
b
d
c
d
Abb. 1.2-9: Totale und partielle Ordnungsrelationen
36
c
Algorithmen und Datenstrukturen
5. „Natürliche Ordnungsbeziehungen“ in Java
Das Comparable Interface aus dem Paket java.lang dient zum Herstellen
„natürlicher Ordnungsbeziehungen“:
/**************** Comparable.java *****************************
/** Das Interface deklariert eine Methode anhand der sich das
* das aufgerufene Objekt mit dem uebergebenen vergleicht.
* Fuer jeden vom Objekt abgeleiteten Datentyp muss eine solche
* Vergleichsklasse implementiert werden.
* Die Methode erzeugt eine Fehlermeldung, wenn "a" ein Objekt
* einer anderen Klasse als dieses Objekt ist.
*
* int compareTo(Comparable a)
*
liefert
0, wenn this == 0
*
liefert < 0, wenn this < a
*
liefert > 0, wenn this > a
*
*/
public interface Comparable
{
public int compareTo(Comparable a);
}
Seit dem JDK 1.2 wird das Comparable Interface bereits von vielen eingebauten
Klassen implementiert, etwa von String, Character, Double, usw. Die natürliche
Ordnung ergibt sich, indem man alle Elemente paarweise miteinander vergleicht und
dabei jeweils das kleinere vor der größere Element stellt.
Besitzt eine Klasse das Interface Comparable nicht, dann kann auch eine
Implementierung des Interface Comparator vorgesehen werden.
/**************** Comparator.java *****************************
/** Das Interface deklariert zwei Methoden zur Durchfuehrung von
* Vergleichen.
* Die Methode equals() führt auf den Rückgabewert true bzw. false,
* je nachdem ob this == o ist.
* compare() hat folgende Rückgabewerte:
* Falls das erste Element vor dem zweiten Element kommt,
* ist der Rückgabewert negativ.
* Falls das erste Element nach dem zweiten Element kommt,
* ist der Rückgabewert positiv
* Der Rückgabewert 0 signalisiert, dass die beiden Elemente an
* der gleichen Ordnungsposition eingeordnet werden.
*/
public interface Comparator
{
public int compare(Object element1, Object element2);
public boolean equals(Object o);
}
1.2.2.3 Klassifikation von Datenstrukturen
Eine Datenstruktur ist durch Anzahl und Eigenschaften der Relationen bestimmt.
Obwohl sehr viele Relationstypen denkbar sind, gibt es nur 4 fundamentale
Datenstrukturen10, die immer wieder verwendet werden und auf die andere
10
nach: Rembold, Ulrich (Hrsg.): "Einführung in die Informatik", München/Wien, 1987
37
Algorithmen und Datenstrukturen
Datenstrukturen zurückgeführt werden können. Den
gemeinsam, daß sie nur binäre Relationen verwenden.
4
Datenstrukturen
ist
1. Lineare Ordnungsgruppen
Sie sind über eine (oder mehrere) totale Ordnung(en) definiert. Die bekanntesten
Verkörperungen der linearen Ordnung sind:
- (ein- oder mehrdimensionale) Felder (lineare Felder)
- Stapel
- Schlangen
- lineare Listen
Lineare Ordnungsgruppen können sequentiell (seqentiell gespeichert) bzw. verkettet
(verkettet gespeichert) angeordnet werden.
2. Bäume
Sie sind im wesentlichen durch die Äquivalenzrelation bestimmt.
Bsp.: Gliederung zur Vorlesung Algorithmen und Datenstrukturen
Algorithmen und Datenstrukturen
Kapitel 1: Datenverarbeitung und
Datenorganisation
Abschnitt 1:
Ein einführendes Beispiel
Kapitel 2:
Suchverfahren
Abschnitt 2:
Begriffe
Abb.: 1.2-10: Gliederung zur Vorlesung Datenorganisation
Die Verkörperung dieser Vorlesung ist das vorliegende Skriptum. Diese Skriptum {Seite 1, Seite 2, .....
, Seite n} teilt sich in einzelne Kapitel, diese wiederum gliedern sich in Abschnitte. Die folgenden
Äquivalenzrelationen definieren diesen Baum:
1. Seite i gehört zum gleichen Kapitel wie Seite j
2. Seite i gehört zum gleichen Abschnitt von Kapitel 1 wie Seite j
3. ........
Die Definitionen eines Baums mit Hilfe von Äquivalenzrelationen regelt ausschließlich "Vorgänger/Nachfolger" - Beziehungen (in vertikaler Richtung) zwischen
den Knoten eines Baums. Ein Baum ist auch hinsichtlich der Baumknoten in der
horizontalen Ebene geordnet, wenn zur Definition des Baums neben der
Äquivalenzrelation auch eine partielle Ordnung (Knoten K i kommt vor Knoten Kj, z.B.
Kapitel1 kommt vor Kapitel 2) eingeführt wird.
3. Graphen
In seiner einfachsten Form besteht eine Verkörperung dieser Datenstruktur aus einer
Knotenmenge K (Objektmenge) und einer festen aber beliebigen Relation R über
38
Algorithmen und Datenstrukturen
dieser Menge11. Die folgende Darstellung zeigt einen Netzplan zur Ermittlung des
kritischen Wegs:
Die einzelnen Knoten des Graphen sind Anfangs- und Endereignispunkte der Tätigkeiten, die an den
Kanten angegeben sind. Die Kanten (Pfeile) beschreiben die Vorgangsdauer und sind Abbildungen
binärer Relationen. Zwischen den Knoten liegt eine partielle Ordnungsrelation.
Bestelle A
50 Tage
Baue B
1
Teste B
4
20 Tage
Korrigiere Fehler
2
25 Tage
3
15 Tage
Handbucherstellung
60 Tage
Abb. 1.2-11: Ein Graph der Netzplantechnik
4. Dateien
Damit ist eine Datenstruktur bestimmt, bei der Verbindungen zwischen den
Datenobjekten durch beliebige, binäre Relationen beschrieben werden. Die Anzahl
der Relationen ist somit im Gegensatz zur Datenstruktur Graph nicht auf eine
beschränkt. Verkörperungen solcher assoziativer Datenstrukturen sind vor allem
Dateien. In der Praxis wird statt mehrere binärer Relationen eine n-stellige Relation
(Datensatz) gespeichert. Eine Datei ist dann eine Sammlung von Datensätzen
gleichen Typs.
Bsp.: Studenten-Datei
Sie faßt die relevanten Daten der Studenten12 nach einem ganz bestimmten Schema zusammen. Ein
solches Schema beschreibt einen Datensatz. Alle Datensätze, die nach diesem Schema aufgestellt
werden, ergeben die Studenten-Datei. Es sind binäre Relationen (Student - Wohnort, Student Geburtsort, ...), die aus Speicheraufwandsgründen zu einer n-stelligen Relation (bezogen auf eine
Datei) zusammengefaßt werden.
5. Datenbanken
Eine Datenbank ist die Sammlung von verschiedenen Datensatz-Typen. Die
Datensätze sind in einer Codasyl-Datenbank13 untereinander verbunden, z.B. alle
Studenten im Fachbereich „Informatik und Mathematik“ der Fachhochschule
Regensburg, z.B.:
Fachbereich Informatik
Student_1
Student_2
....
Student_n
Abb.: 1.2-12: Erscheinungsbild der Datensätze „Fachbereich“ und „Student“
11
vgl. 1.2.2.2, Abb. 1.2-7
vgl. 1.2.2.1
13
Datenbank der Data Base Task Group der Conference on Data Systems Languages (CODASYL)
12
39
Algorithmen und Datenstrukturen
Der letzte Studentensatz zeigt auf den Satz „Fachbereich Informatik und
Mathematik“ zurück. Diese detaillierte Darstellung der physischen Struktur kann auf
folgende Beschreibung der logischen Datenstruktur zurückgeführt werden:
Fachbereich
betreut
Student
Abb.: 1.2-13: Logische Datenstruktur
Auch hier zeigt sich: Knoten bzw. Knotentypen und ihre Beziehungen bzw.
Beziehungstypen stehen im Mittelpunkt der Datenbank-Beschreibung. Statt Knoten
spricht man hier von Entitäten (bzw. Entitätstypen) und Beziehungen werden
Relationen genannt. Dies ist dann Basis für den Entity-Relationship (ER) -Ansatz
von P.S. Chen. Zur Beschreibung der Entitätstypen und ihrer Beziehungen benutzt
der ER-Ansatz in einem ER-Diagramm rechteckige Kanten bzw. Rauten:
Fachbereich
1
betreut
M
Student
Abb. 1.2-14: „ER“-Diagramm zur Darstellung der Beziehung „Fachbereich-Student“
Die als „1“ und „M“ an den Kanten aufgeschriebenen Zahlen zeigen: Ein Fachbereich betreut mehrere (viele) Studenten. Solche Beziehungen können vom Typ
1:M, 1:1, M:N sein. Es ist auch die Bezugnahme auf gleiche Entitätstypen möglich,
z.B.:
Person
1
1
Heirat
Abb.: 1.2-15: Bezugnahme auf den gleiche Entitätstyp „Person“
40
Algorithmen und Datenstrukturen
Die folgende Darstellung einer Datenbank in einem ER-Diagramm
Abt_ID
Bezeichnung
Job_ID
Titel
Abteilung
Gehalt
Job
Abt-Ang
Job-Ang
Qualifikation
Angestellte
Ang_ID
Name
GebJahr
Abb. 1.2-16: ER-Diagramm zur Datenbank Personalverwaltung
führt zum folgenden Schemaentwurf einer relationalen Datenbank
- Abteilung(Abt_ID,Bezeichnung)
- Angestellte(Ang_ID,Name,Gebjahr,Abt_ID,Job_ID)
- Job(Job_ID,Titel,Gehalt)
- Qualifikation(Ang_ID,Job_ID)
und resultiert in folgender relationalen Datnbank für das Personalwesen:
41
Algorithmen und Datenstrukturen
ABTEILUNG
ABT_ID
KO
OD
PA
RZ
VT
BEZEICHNUNG
Konstruktion
Organisation und Datenverarbeitung
Personalabteilung
Rechenzentrum
Vertrieb
ANG_ID
A1
A2
A3
A4
A5
A6
A7
A8
A9
A10
A11
A12
A13
A14
NAME
Fritz
Tom
Werner
Gerd
Emil
Uwe
Eva
Rita
Ute
Willi
Erna
Anton
Josef
Maria
ANGESTELLTE
GEBJAHR
2.1.1950
2.3.1951
23.4.1948
3.11.1950
2.3.1960
3.4.1952
17.11.1955
02.12.1957
08.09.1962
7.7.1956
13.10.1966
5.7.1948
2.8.1952
17.09.1964
ABT_ID
OD
KO
OD
VT
PA
RZ
KO
KO
OD
KO
OD
OD
KO
PA
JOB_ID
SY
IN
PR
KA
PR
OP
TA
TA
SY
IN
KA
SY
SY
KA
JOB
JOB_ID
KA
TA
SY
PR
OP
TITEL
Kaufm. Angestellter
Techn. Angestellter
Systemplaner
Programmierer
Operateur
GEHALT
3000,00 DM
3000,00 DM
6000,00 DM
5000,00 DM
3500,00 DM
QUALIFIKATION
ANG_ID
A1
A1
A1
A2
A2
A3
A4
A5
A6
A7
A8
A9
A10
A11
A12
A13
A14
JOB_ID
SY
PR
OP
IN
SY
PR
KA
PR
OP
TA
IN
SY
IN
KA
SY
IN
KA
Abb. 1.2-17: Tabellen zur relationalen Datenbank
42
Algorithmen und Datenstrukturen
Die relationale Datenbank besteht, wie die vorliegende Darstellung zeigt aus einer
Datenstruktur, die Dateien (Tabellen) vernetzt. Die Verbindung besorgen Referenzen
(Fremdschlüssel).
Die vorliegende Einteilung der Datenstrukturen zeigt: Sammeln und Ordnen der
durch die reale Welt vorgegebenen Objekte ist eine der wichtigsten und häufigsten
Anwendungen in der Datenverarbeitung. Leider unterstützen herkömmliche
Programmiersprachen nicht umfassend genug diese Möglichkeiten. Erst die
objektorientierte Programmierung (vor allem Smalltalk) haben hier den Ansatz zu
einer umfassenden Implementierung (Collection, Container) gezeigt.
1.2.3 Definitionsmethoden für Datenstrukturen
1.2.3.1 Der abstrakte Datentyp
Die Definition einer Datenstruktur ist bestimmt durch Daten (Datenfelder, Aufbau,
Wertebereiche) und die für die Daten gültigen Rechenvorschriften (Algorithmen,
Operationen). Datenfelder und Algorithmen bilden einen Typ, den abstrakten
Datentyp (ADT). Der ADT ist eine Kapsel, der die gemeinsame Deklaration von
Daten und Algorithmen zu einem Typ zusammenfaßt. Datenkapseln sind
elementare Bausteine, die den konventionellen Programmierstil (Programm =
Algorithmus + Daten) auf ein anspruchvolleres Niveau anheben. Die Datenkapsel
betrachtet Daten und Rechenvorschrift als eine Einheit. Das bedeutet aber auch:
Für die Ausführung einer Aufgabe ist die Datenkapsel selbst verantwortlich. Der
Anwender hat damit nichts zu tun. Er teilt dem durch die Datenkapsel bestimmten
Objekt lediglich über eine Botschaft mit, daß er eine spezielle Funktion ausgeführt
haben möchte. Das Empfangsobjekt wählt daraufhin eine ihm bekannte Methode
aus und führt die dazugehörige Prozedur aus. Das Ergebnis der Methode wird vom
Objekt an den den Sender der Botschaft wieder zurückgeschickt. Die durch die
Datenkapsel realisierte Einheit hat einen speziellen Namen: Objekt. Den
zugehörigen Programmierstil nennt man: objektorientierte Programmierung.
Die objektorientierte Sichtweise faßt Daten, Prozeduren und Funktionen zu möglichst
realistischen Modellen (Objekten) der Wirklichkeit zusammen. Zugriff auf
objektorientierte Modelle ist nur den Methoden (Prozeduren und Funktionen) erlaubt.
Eine Methode gehört zu einem Objekt mit dem Zweck die Daten des Oblekts zu
bearbeiten. Nachrichen (Botschaften) sind neben den Objekten das 2. wesentliche
Element in objektorientierten Programmiersprachen. Objekte machen nur dann
etwas, wenn sie eine Nachricht empfangen und für diese Nachricht eine Methode
haben. Andernfalls geben sie die Nachricht an die Klasse weiter, der das Objekt
angehört. Klassen (Objekttypen) sind Realisierungen abstrakter Datentypen und
umfassen: Attribute (Eigenschaften) , Methoden, Axiome. Sie beschreiben Zustand
und Verhalten gleichartiger Objekte.
Generell gilt: Ein neues Objekt (Instanz, Exemplar) einer Klasse erbt alle
Eigenschaften der Klasse. Man kann aber diesem Erbe Eigenschaften hinzufügen
bzw. Methoden streichen bzw. modifizieren. Falls der Erbe selbst Nachkommen
erhält, dann geschieht folgendes:
1. Die Instanz wird zur Klasse
2. Die Nachkommen erben die Eigenschaftten der Vorfahren
43
Algorithmen und Datenstrukturen
Jede Klasse in einem objektorientierten Programmiersystem (OOP) hat einen
Vorfahren
Eine unmittelbare Implementierung der objektorientierten Programmierung (und
damit von ADT) gibt es erst seit 1981 (Smalltalk-80)14. In der Praxis ist dieser
Programmierstil erst seit 1980 verbreitet. Aktuell ist die objektorientierte
Programmierung vor allem durch die inzwischen weit bekannte Programmiersprache C++ bzw. Java.
Daten und Algorithmen als Einheit zu sehen, war bereits schon vor 1980 bekannt.
Da damals noch keine allgemein einsetzbare Implementierung vorlag, hat man
Methoden zur Deklaration von ADT15 bereitgestellt. Damit sollte dem Programmierer
wenigstens durch die Spezifikation die Einheit von Daten und zugehörigen
Operationen vermittelt werden.
1.2.3.2 Die axiomatische Methode
Die axiomatische Methode beschreibt abstrakte (Daten-)Typen über die Angabe
einer Menge von Operationen und deren Eigenschaften, die in der Form von
Axiomen präzisiert werden. Problematisch ist jedoch: Die Axiomenmenge ist so
anzugeben, daß Widerspruchsfreiheit, Vollständigkeit und möglichst Eindeutigkeit
erzielt wird.
Eine spezielle axiomatische Methode ist die algebraische Spezifikation von Datenstrukturen. Sie soll hier stellvertretend für axiomatische Definitionsmethoden an
einem Beispiel vorgestellt werden.
1. Bsp.: Die algebraische Spezifikation des (ADT) Schlange
Konventionell würde die Datenstruktur Schlange so definiert werden: Eine Schlange
ist ein lineares Feld, bei dem nur am Anfang Knoten entfernt und nur am Ende
Knoten hinzugefügt werden können. Die Definition ist ungenau. Operationen sollten
mathematisch exakt als Funktionen und Beziehungen der Operationen als
Gleichungen angegeben sein. Erst dann ist die Prüfung auf Konsistenz und der
Nachweis der korrekten Implementierung möglich. Die algebraische Spezifikation
bestimmt den ADT Schlange deshalb folgendermaßen:
ADT Schlange
Typen
Schlange<T>, boolean
Funktionen (Protokoll)
NeueSchlange
FuegeHinzu :
Vorn
:
Entferne
:
Leer
:
 Schlange<T>
T,Schlange<T>  Schlange<T>
 T
Schlange<T>
Schlange<T>  Schlange<T>
 boolean
Schlange<T>
Axiome
Für alle t : T bzw. s : Schlange<T> gilt:
14
15
vgl. BYTE, Heft August 1981
vgl. Guttag, John: "Abstract Data Types and the Development of Data Structures", CACM, June 1977
44
Algorithmen und Datenstrukturen
1.
2.
3.
4.
5.
6.
Leer(NeueSchlange) = wahr
Leer(FuegeHinzu(t,s)) = falsch
Vorn(NeueSchlange) = Fehler
Vorn(FuegeHinzu(t,s)) = Wenn Leer(s), dann t; andernfalls Vorn(s)
Entferne(NeueSchlange) = Fehler
Entferne(FuegeHinzu(t,s)) = Wenn Leer(s), dann NeueSchlange; andernfalls
FuegeHinzu(t,Entferne(s))
Der Abschnitt Typen zeigt die Datentypen der Spezifikation. Der 1. Typ ist der
spezifizierte ADT. Von anderen Typen wird angenommen, daß sie an anderer Stelle
definiert sind. Der Typ „Schlange<T>“ wird als generischer ADT bezeichnet, da er
"übliche Schlangeneigenschaften" bereitstellt. Eigentliche Schlangentypen erhält
man durch die Vereinbarung eines Exemplars des ADT, z.B.: Schlange<integer>
Der Abschnitt Funktionen zeigt die auf Exemplare des Typs anwendbaren
Funktionen: f : D1 , D2 ,...., Dn  D . Einer der Datentypen D1 , D2 ,...., Dn oder D muß
der spezifizierte ADT sein.
Die Funktionen können eingeteilt werden in:
- Konstruktoren (constructor functions)
(Der ADT erscheint nur auf der rechten Seite des Pfeils.) Sie liefern neue Elemente (Instanzen) des
ADT.
- Zugriffsfunktionen (accessor functions)
(Der ADT erscheint nur auf der linken Seite des Pfeils.) Sie liefern Eigenschaften von existierenden
Elementen des Typs (vgl. Die Funktion: Leer)
- Umsetzungsfunktionen (transformer functions)
(Der ADT erscheint links und rechts vom Pfeil.) Sie bilden neue Elemente des ADT aus bestehenden
Elementen und (möglicherweise) anderen Argumenten (vgl. FuegeHinzu, Entferne).
Der Abschnitt Axiome beschreibt die dynamischen Eigenschaften des ADT.
2. Bsp.: Die „algebraische Spezifikation“ des ADT Stapel
ADT Stapel<T>, integer, boolean
1. Funktionen (Operationen, Protokoll)
NeuerStapel
PUSH
POP
Top
Stapeltiefe
Leer
:
:
:
:
:
T,Stapel<T>
Stapel<T>
Stapel<T>
Stapel<T>
Stapel<T>






Stapel<T>
Stapel<T>
Stapel<T>
T
integer
boolean
2. Axiome
Für alle t:T und s:Stapel<T> gilt:
(POP(PUSH(t,s)) = s
Top(PUSH(t,s)) = t
Stapeltiefe(NeuerStapel) = 0
Stapeltiefe(PUSH(i,s)) = Stapeltiefe + 1
Leer(NeuerStapel) = wahr
 Leer(PUSH(t,s) = wahr
3. Restriktionen (conditional axioms)
Wenn Stapeltiefe(s) = 0, dann führt POP(s) auf einen Fehler
Wenn Stapeltiefe(s) = 0, dann ist Top(s) undefiniert
Wenn Leer(s) = wahr, dann ist Stapeltiefe(s) Null.
Wenn Stapeltiefe(s) = 0, dann ist Leer(s) wahr.
45
Algorithmen und Datenstrukturen
Für viele Programmierer ist eine solche Spezifikationsmethode zu abstrakt. Die
Angabe von Axiomen, die widerspruchsfrei und vollständig ist, ist nicht möglich bzw.
nicht nachvollziehbar.
3. Bsp.: Die algebraische Spezifikation für einen binären Baum
ADT Binaerbaum<T>, boolean
1. Funktionen (Operationen, Protokoll)
NeuerBinaerbaum
-> Binaerbaum<T>
bin
: Binaerbaum<T>, T, Binaerbaum<T>  Binaerbaum<T>
 Binaerbaum<T>
links
: Binaerbaum<T>
 Binaerbaum<T>
rechts : Binaerbaum<T>
wert
: Binaerbaum<T>
-> T
 boolea
istLeer : Binaerbaum<T>
2. Axiome
Für alle t:T und x:Binaerbaum<T>, y:Binaerbaum<T> gilt:
links(bin(x,t,y)) = x
rechts(bin(x,t,y)) = y
wert(bin(x,t,y)) = t
istLeer(NeuerBinaerbaum) = true
istLeer(bin(x,t,y)) = false
Der direkte Weg zur Deklaration von ADT im Rahmen der objektorientierten
Programmierung ist noch nicht weit verbreitet. Der konventionelle Programmierstil,
Daten und Algorithmen getrennt zu behandeln, bevorzugt den konstruktiven Aufbau
der Daten aus elementaren Datentypen.
1.2.3.3 Die konstruktive Methode
Die Basis bilden hier die Datentypen. Jedem Objekt ist eine Typvereinbarung in der
folgenden Form zugeordnet: X : T;
X ... Bezeichner (Identifizierer) für ein Objekt
T ... Bezeichner (Identifizierer) für einen Datentyp
Einem Datentyp sind folgende Eigenschaften zugeordnet:
1. Ein Datentyp bestimmt die Wertmenge, zu der eine Konstante gehört oder die durch eine Variable
oder durch einen Ausdruck angenommen werden kann oder die durch einen Operator oder durch
eine Funktion berechnet werden kann.
2. Jeder Operator oder jede Funktion erwartet Argumente eines bestimmten Typs und liefert Resultate
eines bestimmten Typs.
Bei der konstruktiven Methode erfolgt die Definition von Datenstrukturen mit Hilfe
bereits eingeführter Datentypen. Die niedrigste Stufe bilden die einfachen
Datentypen. Basis-Datentypen werden in den meisten Programmiersprachen zur
Verfügung gestellt und sind eng mit dem physikalischen Wertevorrat einer DVAnlage verknüpft (Standard-Typen). Sie sind die „Atome“ auf der niedrigsten
Betrachtungsebene. Neue "höherwertige" Datentypen werden aus bereits definierten
„niederstufigen“ Datentypen definiert.
46
Algorithmen und Datenstrukturen
1.2.3.4 Die objektorientierte Modellierung abstrakter Datentypen
Die Spezifikation abstrakter Datentypen
Im Mittelpunkt dieser Methode steht die Definition von Struktur und Wertebereich der
Daten bzw. eine Sammlung von Operationen mit Zugriff auf die Daten. Jede Aufgabe
aus der Datenverarbeitung läßt sich auf ein solches Schema (Datenabstraktion)
zurückführen.
Zur Beschreibung des ADT dient das folgende Format:
ADT Name
Daten
Beschreibung der Datenstruktur
Operationen
Konstruktor
Intialisierungswerte: Daten zur Initialisierung des
Objekts
Verarbeitung: Initialisierung des Objekts
Operation1
Eingabe: Daten der Anwendung dieser Methode
Vorbedingung: Notwendiger Zustand des Systems vor
Ausführung einer Operation
Verarbeitung: Aktionen, die an den Daten ausgeführt
werden
Ausgabe: Daten (Rückgabewerte) an die Anwendung dieser
Methode
Nachbedingung: Zustand des Systems nach Ausführung der
Operation
Operation2
.........
Operationn
.........
Bsp.: Anwendung dieser Vorlage zur Beschreibung des ADT Stapel
ADT Stapel
Daten
Eine Liste von Datenelementen mit einer Position „top“, sie auf den
Anfang des Stapels verweist.
Operationen
Konstruktor:
Initialisierungswerte: keine
Verarbeitung: Initialisiere „top“.
Push
Eingabe: Ein Datenelement zur Aufnahme in den Stapel
Vorbedingung: keine
Verarbeitung: Speichere das Datenelement am Anfang
(„top“) des Stapel
Ausgabe: keine
Nachbedingung: Der Stapel hat ein neues Datenelement
an der Spitze („top“).
Pop
Eingabe: keine
Vorbedingung: Der Stapel ist nicht leer
Verarbeitung: Das Element an der Spitze („top“) wird entfernt.
Ausgabe: keine
Peek bzw. Top
Eingabe: keine
47
Algorithmen und Datenstrukturen
Vorbedingung: Stapel ist nicht leer
Verarbeitung: Bestimme den Wert des Datenelements an der Spitze („top“
des Stapel.
Ausgabe: Rückgabe des Datenwerts, der an der Spitze
(„top“) des Stapel steht.
Nachbedingung: Der Stapel bleibt unverändert.
Leer
Eingabe: keine
Vorbedingung: keine
Verarbeitung: Prüfe, ob der Stapel leer ist.
Ausgabe: Gib TRUE zurueck, falls der Stapel leer ist; andernfalls FALSE.
Nachbedingung: keine
bereinigeStapel
Eingabe: keine
Vorbedingung: keine
Verarbeitung: Löscht alle Elemente im Stapel und setzt die Spitze
(„top“) des Stapels zurück.
Ausgabe: keine
Klassendiagramme der Unified Modelling Language
Visualisierung und Spezifizierung objektorientierter Softwaresysteme erfolgt mit der
Unified Modelling Language (UML). Zur Beschreibung abstrakter Dytentypen dient
das wichtigste Diagramm der UML: Das Klassendiagramm.
Das Klassendiagramm beschreibt die statische Struktur der Objekte in einem
System sowie ihre Beziehungen untereinander. Die Klasse ist das zentrale Element.
Klassen werden durch Rechtecke dargestellt, die entweder den Namen der Klasse
tragen oder zusätzlich auch Attribute und Operationen. Klassenname, Attribute,
Operationen (Methoden) sind jeweils durch eine horizontale Linie getrennt.
Klassennamen beginnen mit Großbuchstaben und sind Substantive im Singular.
Ein strenge visuelle Unterscheidung zwischen Klassen und Objekten entfällt in der
UML. Objekte werden von den Klassen dadurch unterschieden, daß ihre
Bezeichnung unterstrichen ist. Haufig wird auch dem Bezeichner eines Objekts ein
Doppelpunkt vorangestellt.. Auch können Klassen und Objekte zusammen im
Klassendiagramm auftreten.
Klasse
Objekt
Wenn man die Objekt-Klassen-Beziehung (Exemplarbeziehung, Instanzbeziehung)
darstellen möchte, wird zwischen einem Objekt und seiner Klasse ein gestrichelter
Pfeil in Richtung Klasse gezeichnet:
Klasse
Objekt
Die Definition einer Klasse umfaßt die „bedeutsamen“ Eigenschaften. Das sind:
- Attribute
d.h.: die Struktur der Objekte: ihre Bestandteile und die in ihnen enthaltenen Informationen und
Daten.. Abhängig von der Detaillierung im Diagramm kann die Notation für ein Attribut den
Attributnamen, den Typ und den voreingestellten Wert zeigen:
Sichtbarkeit Name: Typ = voreingestellter Wert
- Operationen
d.h.: das Verhalten der Objekte. Manchmal wird auch von Services oder Methoden gesprochen. Das
Verhalten eines Objekts wird beschrieben durch die möglichen Nachrichten, die es verstehen kann.
48
Algorithmen und Datenstrukturen
Zu jeder Nachricht benötigt das Objekt entsprechende Operationen. Die UML-Syntax für Operationen
ist:
Sichtbarkeit Name (Parameterliste) : Rückgabetypausdruck (Eigenschaften)
Sichtbarkeit ist + (öffentlich), # (geschützt) oder – (privat)
Name ist eine Zeichenkette
Parameterliste enthält optional Argumente, deren Syntax dieselbe wie für Attribute ist
Rückgabetypausdruck ist eine optionale, sprachabhängige Spezifikation
Eigenschaften zeigt Eigenschaftswerte (über String) an, die für die Operation Anwendung finden
- Zusicherungen
Die Bedingungen, Voraussetzungen und Regeln, die die Objekte erfüllen müssen, werden
Zusicherungen genannt. UML definiert keine strikte Syntax für die Beschreibung von Bedingungen.
Sie müssen nur in geschweifte Klammern ({}) gesetzt werden.
Idealerweise sollten Regeln als Zusicherungen (engl. assertions) in der Programmiersprache
implementiert werden können.
Attribute werden mindestens mit ihrem Namen aufgeführt und können zusätzliche
Angaben zu ihrem Typ (d.h. ihrer Klasse), einen Initialwert und evtl.
Eigenschaftswerte und Zusicherungen enthalten. Attribute bilden den Datenbestand
einer Klasse.
Operationen (Methoden) werden mindestens mit ihrem Namen, zusätzlich durch ihre
möglichen Parameter, deren Klasse und Initialwerte sowie evtl. Eigenschaftswerte
und Zusicherungen notiert. Methoden sind die aus anderen Sprachen bekannten
Funktionen.
Klassenname
attribut:Typ=initialerWert
operation(argumentenliste):rückgabetyp
Abb.:
49
Algorithmen und Datenstrukturen
Bsp.: Die Klasse Object aus dem Paket java.lang
Object
+equals(obj :Object)
#finalize()
+toString()
+getClass()
#clone()
+wait()
+notify()
........
Sämtliche Java-Klassen bilden eine Hierarchie mit java.lang.Object als gemeinsame
Basisklasse.
Assoziationen repräsentieren Beziehungen zwischen Instanzen von Klassen.
Mögliche Assoziationen sind:
- einfache (benannte) Assoziationen
- Assoziation mit angefügten Attributen oder Klassen
- Qualifzierte Assoziationen
- Aggregationen
- Assoziationen zwischen drei oder mehr Elementen
- Navigationsassoziationen
- Vererbung
Attribute werden von Assoziationen unterschieden:
Assoziation: Beschreibt Beziehungen, bei denen beteiligte Klassen und Objekte von anderen Klassen
und Objekten benutzt werden können.
Attribut: Beschreibt einen privaten Bestandteil einer Klasse oder eines Objekts, welcher von außen
nicht sichtbar bzw. modifizierbar ist.
Grafisch wird eine Assoziation als durchgezogene Line wiedergegeben, die gerichtet
sein kann, manchmal eine Beschriftung besitzt und oft noch weitere Details wie z.B.
Muliplizität (Kardinalität) oder Rollenanmen enthält, z.B.:
Arbeitet für
0..1
Arbeitgeber
Arbeitnehmer
Eine Assoziation kann einen Namen zur Beschreibung der Natur der Beziehung
(„Arbeitet für“) besitzen. Damit die Bedeutung unzweideutig ist, kann man dem
Namen eine Richtung zuweisen: Ein Dreieck zeigt in die Richtung, in der der Name
gelesen werden soll.
Rollen („Arbeitgeber, Arbeitnehmer) sind Namen für Klassen in einer Relation. Eine
Rolle ist die Seite, die die Klasse an einem Ende der Assoziation der Klasse am
anderen Ende der Assoziation zukehrt. Die Navigierbarkeit kann durch einen Pfeil in
Richtung einer Rolle angezeigt werden.
50
Algorithmen und Datenstrukturen
Rolle1
Rolle2
K1
K2
1
0..*
Abb.: Binäre Relation R = C1 x C2
Rolle1
K1
K2
Rollen
...
Kn
Abb.: n-äre Relation K1 x K2 x ... x Kn
In vielen Situationen ist es wichtig anzugeben, wie viele Objekte in der Instanz einer
Assoziation miteinander zusammenhänen können. Die Frage „Wie viele?“
bezeichnet man als Multiplizität der Rolle einer Assoziation. Gibt man an einem Ende
der Assoziation eine Multiplizität an, dann spezifiziert man dadurch: Für jedes Objekt
am entgegengesetzten Ende der Assoziation muß die angegebene Anzahl von
Objekten vorhanden sein.
Ein A ist immer mit Ein A ist immer mit Ein A ist mit Ein A ist mit keieinem B assoziiert einem oder mehre- keinem oder einem nem, einem oder
ren B assoziiert
B asso-ziiert
mehreren B assoziiert
Unified
A
1 B
A
1..*
B
A
0..1 B
A
*
B
1:1
1..*
1:1..n
0..*
2..6
0..*
*
17
4
n
m
0..n:2..6
0..n:0..n
17:4
?
Abb.: Kardinalitäten für Beziehungen
Pfeile in Klassendiagrammen zeigen Navigierbarkeit an. Wenn die Navigierbarkeit
nur in einer Richtung existiert, nennt man die Assoziation eine gerichtete Assoziation
(uni-directional association). Eine ungerichtete (bidirektionale) Assoziation enthält
Navigierbarkeiten in beiden Richtungen. In UML bedeuten Assoziationen ohne
Pfeile, daß die Navigierbarbeit unbekannt oder die Assoziation ungerichtet ist.
Ungerichtete Assoziationen enthalten eine zusätzliche Bedingung: Die zu dieser
Assoziation zugehörigen zwei Rollen sind zueinander invers.
Abhängigkeit (dependency): Manchmal nutzt eine Klasse eine andere. Die UMLNotation ist dafür oft eine gestrichelte Linie mit einem Pfeil, z.B.:
51
Algorithmen und Datenstrukturen
Applet
WillkommenApplet
paint()
Graphics
Abb. : WillkommenApplet nutzt die Klasse Graphics über paint()
Reflexive Assoziation: Manchmal ist auch eine Klasse mit sich selbst assoziiert. Das
kann bspw. der fall sein, wenn eine Klasse Objekte hat, die mehrere Rollen spielen,
z.B.:
Fahrzeuginsasse
1
fahrer
fährt
0..4
beifahrer
Ein Fahrzeuginsasse kann entweder ein Fahrer oder ein Beifahrer sein. In der Rolle des Fahrers fährt
ein Fahrzeuginsasse null oder mehr Fahrzeuginsassen, die die Rolle von Beifahrern spielen.
Abb.:
Bei einer reflexiven Assoziation zieht man eine Linie von der Klasse aus zu dieser
zurück. Man kann die Rollen sowie die Namen, die Richtung und die Multiplizität der
Assoziation angeben.
Eine Aggregation ist eine Sonderform der Assoziation. Sie repräsentiert eine
(strukturelle) Ganzes/Teil-Beziehung. Zusätzlich zu einfacher Aggregation bietet
UML eine stärkere Art der Aggregation, die Komposition genannt wird. Bei der
Komposition darf ein Teil-Objekt nur zu genau einem Ganzen gehören.
Teil
Ganzes
Existenzabhängiges Teil
Abb.: Aggregation und Komposition
Eine Aggregation wird durch eine Raute dargestellt. Die Komposition wird durch eine
ausgefüllte Raute dargestellt und beschreibt ein „physikalisches Enthaltensein“.
Die Vererbung (Spezialisierung bzw. Generalisierung) stellt eine Verallgemeinerung
von Eigenschaften dar. Eine Generalisierung (generalization) ist eine Beziehung
zwischen dem Allgemeinen und dem Speziellen, in der Objekte des speziellen Typs
(der Subklasse) durch Elemente des allgemeinen Typs (der Oberklassse) ersetzt
52
Algorithmen und Datenstrukturen
werden können. Grafisch wird eine Generalisierung als durchgezogene Linle mit
einer unausgefüllten, auf die Oberklasse zeigenden Pfeilspitze wiedergegeben, z.B.:
Supertyp
Subtyp 1
Subtyp 2
Bsp.: Vererbungshierarchie und wichtige Methoden der Klasse Applet
Panel
Applet
+init()
+start()
+paint(g:Graphics) {geerbt}
+update(g:Graphics) {geerbt}
+repaint()
+stop()
+destroy()
+getParameter(name:String);
+getParameterInfo()
+getAppletInfo()
Abb.:
Schnittstellen und abstrakte Klassen: Eine Schnittstelle (Interface) ist eine
Ansammlung von Operationen, die eine Klasse ausführt. Programmiersprachen (z..
B. Java) benutzen ein einzelnes Konstrukt, die Klasse, die sowohl Schnittstelle als
auch deren Implementierung enthält. Bei der Bildung einer Unterklasse wird beides
vererbt. Eine reine Schnittstelle (wie bspw. in Java) ist eine Klasse ohne
Implementierung und besitzt daher nur Operationsdeklarationen. Schnittstellen
werden oft mit Hilfe abstrakter Klassen deklariert.
Bei abstrakten Klassen oder Methoden wird der Name des abstrakten Gegenstands
in der UML kursiv geschrieben. Ebenso kann man die Bedingung {abstract}
benutzen.
53
Algorithmen und Datenstrukturen
<<interface>>
InputStream
DataInput
OrderReader
{abstract}
Abhängigkeit
Generalisierung
Verfeinerung
DataInputStream
Irgendeine Klasse, z.B. „OrderReader“ benötigt die DataInput-Funktionalität. Die Klasse
DataInputStream implementiert DataInput und InputStream. Die Verbindung zwischen
DataInputStream und DataInput ist eine „Verfeinerung (refinement)“. Eine Verfeinerung ist in UML ein
allgemeiner Begriff zur Anzeige eines höheren Detaillierungsniveaus. Die Objektbeziehung zwischen
OrderReader und DataInput ist eine Abhängigkeit. Sie zeigt, daß „OrderReader“ die Schnittstelle
„DataInput für einige Zwecke benutzt.
Abb.: Schnittstellen und abstrakte Klassen: Ein Beispiel aus Java
Abstrakte Klassen und Schnittstellen ermöglichen die Definition einer Schnittstelle
und das Verschieben ihrer Implementierung auf später. Jedoch kann die abstrakte
Klasse schon die Implementierung einiger Methoden enthalten, während die
Schnittstelle die Verschiebung der Definition aller Methoden erzwingt.
Eine Schnittstelle modelliert man in Form einer gestrichelten Linie mit einem großen,
unausgefüllten Dreieck, das neben der Schnittstelle steht und auf diese zeigt. Eine
andere verkürzte Darstellung einer Klasse und einer Schnittstelle besteht aus einem
(kleinen Kreis), der mit der Klasse durch eine Linie verbunden ist, z.B.:
Object
Component
ImageObserver
Container
Panel
Applet
Abb.: Vererbungshierarchie von Applet
54
Algorithmen und Datenstrukturen
1.2.3.5 Die Implementierung abstrakter Datentypen in C++
Klassen (Objekttypen) sind Realisierungen abstrakter Datentypen und umfassen:
Daten und Methoden (Operationen auf den Daten). Das C++-Klassenkonzept
(definiert über struct, union, class) stellt ein universell einsetzbares Werkzeug für
die Erzeugung neuer Datentypen (, die so bequem wie eingebaute Typen eingesetzt
werden können) zur Verfügung. Zu einer Klasse gehören Daten- und
Verarbeitungselemente (d.h. Funktionen). Bestandteile einer Klasse können dem
allgemeinen Zugriff entzogen sein (information hiding). Der Programmentwickler
bestimmt die Sichtbarkeit eines jeden Elements. Einer Klasse ist ein Name (TypBezeichner) zugeordnet. Dieser Typ-Bezeichner kann zur Deklaration von Instanzen
oder Objekten des Klassentyps verwendet werden.
1.2.3.5.1. Das Konzept für benutzerdefinierte Datentypen: class bzw. struct
Definition einer Klasse
Sie besteht aus 2 Teilen
1. dem Kopf, der neben dem Schlüsselwort class (bzw. struct, union) den Bezeichner der Klasse
enthält
2. den Rumpf, der umschlossen von geschweiften Klammern, abgeschlossen durch ein Semikolon, die
Mitglieder (member, Komponenten, Elemente) der Klasse enthält.
Der Zugriff auf Elemente von Klassen wird durch 3 Schlüsselworte gesteuert:
private: Auf Elemente, die nach diesem Schlüsselwort stehen, können nur Elementfunktionen
zugreifen, die innerhalb derselben Klasse definiert sind. „class“-Komponenten sind
standardmäßig „private“.
protected: Auf Elemente, die nach diesem Schlüsselwort stehen, können nur Elementfunktionen
zugreifen, die in derselben Klasse stehen und Elementfunktionen, die von derselben Klasse
abgeleitet sind
public: Auf Elemente, die nach diesem Schlüsselwort stehen, können alle Funktionen in demselben
Gültigkeitsbereich, den die Klassendefinition hat, zugreifen.
Der Geltungsbereich von Namen der Klassenkomponenten ist klassenlokal, d.h. die
Namen können innerhalb von Elementfunktionen und im folgenden Zusammenhang
benutzt werden:
klassenobjekt.komponentenname
zeiger_auf_klassenobjekt->komponentenname
klassenname::komponentenname
- Der Punktoperator „.“ wird benutzt, falls der Programmierer Zugriff auf ein
Datenelement oder eine Elementfunktion eines speziellen Klassenobjekts wünscht.
- Der Pfeil-Operator „->“ wird benutzt, falls der Programmierer Zugriff auf ein
spezielles Klassenobjekt über einen Zeiger wünscht
Klassen besitzen einen öffentlichen (public) und einen versteckten (private) Bereich.
Versteckte Elemente (protected) sind nur den in der Klasse aufgeführten
Funktionen zugänglich. Programmteile außerhalb der Klasse dürfen nur auf den mit
public als öffentlich deklarierten Teil einer Klasse zugreifen.
55
Algorithmen und Datenstrukturen
Eine mit struct definierte Klasse ist eine Klasse, in der alle Elemente gemäß
Voreinstellung public sind. Die Elemente einer mit union definierten Klasse sind
public. Dies kann nicht geändert werden. Die Elemente einer mit class definierten
Klasse sind gemäß Voreinstellung private. Die Zugriffsebenen können verändert
werden.
Datenelemente und Elementfunktionen
Datenelemente einer Klasse werden genau wie die Elemente einer Struktur
angegeben. Eine Initialisierung der Datenelemente ist aber nicht erlaubt. Deshalb
dürfen Datenelemente auch nicht mit const deklariert sein.
Funktionen einer Klasse sind innerhalb der Klassendefinition deklariert. Wird die
Funktion auch innerhalb der Klassendefinition definiert, dann ist diese Funktion
inline. Elementfunktionen von Klassen unterscheiden sich von den gewöhnlichen
Funktionen:
- Der Gültigkeitsbereich einer Klassenfunktion ist auf die Klasse beschränkt (class scope).
Gewöhnliche Funktionen gelten in der ganzen Datei, in der sie definiert sind
- Eine Klassendefinition kann automatisch auf alle Datenelemente der Klasse zugreifen. Gewöhnliche
Funktionen können nur auf die als "public" definierten Elemente einer Klasse zugreifen.
Es
gibt
zwei
Möglichkeiten
(Schnittstellenfunktionen) in Klassen:
zur
Angabe
von
Elementfunktionen
- Definition der Funktion innerhalb von Klassen
- Deklaration der Funktion innerhalb, Definition der Funktion außerhalb der Klasse.
Mit dem Scope-Operator :: wird bei der Defintion der Funktion außerhalb der Klasse
dem Compiler mitgeteilt, wohin die Funktion gehört.
Beim Aufruf von Elementfunktionen muß der Name des Zielobjekts angegeben
werden: klassenobjektname.elementfunktionen(parameterliste)
Die Funktionen einer Klasse haben die Aufgabe, alle Manipulationen an den Daten
dieser Klasse vorzunehmen.
Der „this“-Zeiger
Jeder (Element-) Funktion wird ein Zeiger auf das Element, für das die Funktion
aufgerufen wurde, als zusätzlicher (verborgener) Parameter übergeben. Dieser
Zeiger heißt this, ist automatisch vom Compiler) deklariert und mit der Adresse der
jeweiligen Instanz (zum Zeitpunkt des Aufrufs der Elementfunktion) besetzt. "this" ist
als *const deklariert, sein Wert kann nicht verändert werden. Der Wert des Objekts
(*this), auf den this zeigt, kann allerdings verändert werden.
56
Algorithmen und Datenstrukturen
1.2.3.5.2. Generischer ADT
In den Erläuterungen zur axiomatischen Methode wurde der Typ Schlange<T> als
generischer abstrakter Datentyp bezeichnet, da er „übliche Schlangeneigenschaften“
bereitstellt. Ein Typ Stapel<T> stellt dann übliche Stapeleigenschaften bereit. Der
eigentliche benutzerdefinierte Datentyp bezeiht sich konkret auf einen speziellen
Typ, z.B. Schlange<int>, Stapel<int>. In C++ kann mit Hilfe von templates
(Schablonen) zur Übersetzungszeit eine neue Klasse bzw. auch eine neue Funktion
erzeugt werden. Schablonen (Templates) können als "Meta-Funktionen" aufgefaßt
werden, die zur Übersetzungszeit neue Klassen bzw. neue Funktionen erzeugen.
Klassenschablonen
Mit einem Klassen-Template (auch generische Klasse oder Klassengenerator) kann
ein Muster für Klassendefinitionen angelegt werden. In einer Klassenschablone steht
vor der eigentlichen Klassendefinition eine Parameterliste. Hier werden in
allgemeiner Form „Datentypen“ bezeichnet, wie sie die Elemente einer Klasse
(Daten und Funktionen) benötigen.
Bsp.: Klassenschablone für einen Stapel16
template <class T> class Stapel
{
private:
static const int stapelgroesse;
T *inhalt;
int nachf;
void init() { inhalt = new T[stapelgroesse]; nachf = 0; }
public:
Stapel()
{ init(); }
Stapel(T e) { init(); push(e); }
Stapel(const Stapel&);
~Stapel() { delete [] inhalt; }
// Destruktor
Stapel& push(T w)
{ inhalt[nachf++] = w; return *this; }
Stapel& pop()
{ nachf--; return *this; }
T top()
{ return inhalt[nachf - 1]; }
int stapeltiefe()
{ return nachf; }
int istleer()
{ return nachf == 0; }
long groesseSt() const
{ return sizeof(Stapel<T>) + stapelgroesse * sizeof(T); }
Stapel& operator = (const Stapel<T>&);
int operator == (const Stapel<T>&);
friend ostream& operator << (ostream& o, const Stapel<T>& s);
};
template <class T> const int Stapel<T> :: stapelgroesse = 100;
// Kopierkonstruktor
template <class T> Stapel<T> :: Stapel(const Stapel& s)
{
init();
// Anlegen eines neuen Felds
nachf = s.nachf;
for (int i = 0; i < nachf; i++)
inhalt[i] = s.inhalt[i];
}
// Operatorfunktion (Stapel mit eigenem Zuweisungsoperator)
template <class T>
16
PR12351.CPP
57
Algorithmen und Datenstrukturen
Stapel<T>& Stapel<T> :: operator = (const Stapel<T>& r)
{
nachf = r.nachf;
for (int i = 0; i < nachf; i++)
inhalt[i] = r.inhalt[i];
return *this;
}
// Funktionscablone zum Aufruf einer Methode, die den Platzbedarf
// fuer Struktur und gestapelte Elemente ermitteln
template <class T> long groesse(const Stapel<T>& s)
{
return s.groesseSt();
}
// Operator <<()
template <class T> ostream& operator << (ostream& o, const Stapel<T>& s)
{
o << "<";
for (int i = 0; i < s.nachf; i++)
{
if ((i <= s.nachf - 1) && (i > 0)) o << ", ";
o << s.inhalt[i];
}
return o << ">";
}
58
Algorithmen und Datenstrukturen
1.2.3.6 Die Implementierung abstrakter Datentypen in Java
1.2.3.6.1 Modellierung von Zustand und Verhalten
Die Abbildung von Zustand bzw. Verhalten in Instanzvariable bzw. Instanzmethoden
Objekte sind die Schlüssel zum Verständnis der objektorientierten Technologie.
Objekte sind Gegenstände des täglichen Lebens: der Schreibtisch, das Skript, die
Vorlesung. All diese Objekte der realen Welt haben Zustand und Verhalten. Auch
Software-Objekte haben Zustand und Verhalten. Der Zustand wird in Variablen
festgehalten, das Verhalten der Objekte beschreiben Methoden. Die Variablen
bilden den Kern der Objekte. Methoden schirmen den Objektkern von anderen
Objekten des Programms ab (Kapselung). Software-Objekte kommunizieren und
verkehren über Nachrichen (Botschaften) miteinander. Das sendende Objekt
schickt dem Zielobjekt eine Aufforderung, eine bestimmte Methode auszuführen.
Das Zielobjekt versteht (hoffentlich) die Aufforderung und reagiert mit der
zugehörigen Methode. Die genaue formale Schreibweise solcher Botschaften in
objektorientierten Sprachen ist im Detail verschieden, jedoch wird meistens folgende
Form verwendet: Empfänger.Methodenname(Argument). „Argument“ ist in dem
Botschaftsausdruck ein Übergabeparameter für die Methode.
In der realen Welt existieren häufig Objekte der gleichen Art. Sie werden über einen
Prototyp, eine Klasse, zusammengefaßt. Eine Klassendefinition in Java wird durch
das Schlüsselwort „class“ eingeleitet. Anschließend folgt innerhalb von
geschweiften
Klammern
eine
beliebige
Anzahl
an
Variablenund
Methodendefinitionen. Zum Anlegen eines Objekts einer Klasse (Instanziierung17)
muß eine Variable vom Typ der Klasse deklariert und mit Hilfe des new-Operators
ein neu erzeugtes Objekt zugewiesen werden. Das Speicher-Management in Java
erfolgt automatisch. Während das Erzeugen von Objekten immer einen expliziten
Aufruf des new-Operators erfordert18, erfolgt die Rückgabe von nicht mehr
benötigtem Speicher automatisch19.
Das Schreiben eines Programms besteht damit aus Entwurf und Zusammenstellung
von Klassen. Klassenbibliotheken (Sammlung von Klassen) stellen Lösungen für
grundlegende Programmieraufgaben bereit.
Zustand, Aussehen und andere Qualitäten eines Objekts (Attribute) werden durch
Variable definiert. Da jede Instanz einer Klasse verschiedene Werte für ihre
Variablen haben kann, spricht man von Instanzvariablen. Zusätzlich gibt es noch
Klassenvariable, die die Klasse selbst und alle ihre Instanzen betreffen. Werte von
Klassenvariablen werden direkt in der Klasse gespeichert. Der Zustand wird in
Variablen festgehalten und zeigt den momentanen Stand der Objektstruktur an, d.h.
die in den einzelnen Bestandteilen des Objekts enthaltenen Informationen und
Daten. Abhängig vom Detaillierungsgrad kann die Notation für eine Variable den
Namen, den Datentyp und den voreingestellten Wert zeigen:
Sichtbarkeit Typ Name = voreingestellter_Wert;
Sichtbarkeit: öffentlich (public), geschützt (protected) oder privat (private)
Typ: Datentyp
17
Eine Instanz einer Klasse ist ein (tatsächliches) Objekt (konkrete Darstellung)
Ausnahmen: String-, Array-Literale
19
Ein Garbage-Collector (niedrigpriorisierte Hintergrundprozeß) sucht in regelmäßigen Abständen nach nicht
mehr referenzierten Objekten und gibt den durch sie belegten Speicher an das Laufzeitsystem zurück
18
59
Algorithmen und Datenstrukturen
Name: eine nach bestimmten Regeln gebildete Zeichenkette
Nach der Initialisierung haben alle Variablen des Objekts zunächst Standardwerte
(voreingestellte Werte). Der Zugriff auf sie erfolgt mit Hilfe der Punktnotation:
Objekt.Variable.
Zur Bezugnahme auf das aktuelle Objekt dient das Schlüsselwort this. Es kann an
jeder beliebigen Stelle angegeben werden, an der das Objekt erscheinen kann, z.B.
in einer Punktnotation zum Verweis auf Instanzvariablen des Objekts oder als
Argument für eine Methode oder als Ausgabewert der aktuellen Methoden. In vielen
Fällen kann das Schlüsselwort this entfallen. Das hängt davon ab, ob es Variablen
mit gleichem Namen im lokalen Bereich gibt.
Zur Definition des Verhaltens von Objekten dienen Methoden. Methoden sind
Funktionen, die innerhalb von Klassen definiert werden und auf Klasseninstanzen
angewandt werden. Methoden wirken sich aber nicht nur auf ein Objekt aus. Objekte
kommunizieren auch miteinander durch Methoden. Eine Klasse oder ein Objekt kann
Methoden einer anderen Klasse oder eines anderen Objekts aufrufen, um
Änderungen in der Umgebung mitzuteilen oder ein Objekt aufzufordern, seinen
Zustand zu ändern. Instanzmethoden (Operationen, Services) werden auf eine
Instanz angewandt, Klassenmethoden beziehen sich auf eine Klasse.
Klassenmethoden können nur mit Klassenvariablen arbeiten.
Die Beschreibung der Operationen (Nachrichten, Methoden) erfolgt nach dem
folgenden Schema:
Sichbarkeit Rückgabetypausdruck Name(Parameterliste)
Sichtbarkeit: öffentlich (public), geschützt (protected), privat (private)
Rückgabetypausdruck: Jede Methode ist typisiert. Der Typ einer Methode bestimt den Typ des
Rückgabewerts. Dieser kann von einem beliebigen primitiven Typ, einem Objekttyp oder vom Typ void
sein. Methoden vom Typ void haben keinen Rückgabewert und dürfen nicht in Ausdrücken verwendet
werden. Hat eine Methode einen Rückgabewert, dann kann sie mit der „return“- Anweisung einen Wert
an den Aufrufer zurückgeben.
Parameterliste enthält optional Argumente und hat folgende Struktur: Datentyp
variablenname, ..... Die Anzahl der Parameter ist beliebig und kann Null sein.
In Java wird jede selbstdefinierte Klasse mit Hilfe des Operators new instanziert. Mit
Ausnahme von Zeichenketten (Strings) und Datenfeldern (Arrays), bei denen der
Compiler auch Literale zur Objekterzeugung bereitstellt, gilt dies für alle
vordefinierten Klassen der Java-Bibliothek.
Der Aufruf einer Methode erfolgt ähnlich der Verwendung einer Instanzvariablen in
„Punktnotation“. Zur Unterscheidung von einem Variablenzugriff müssen zusätzlich
die Parameter in Klammern angegeben werden, selbst wenn die Parameter-Liste
leer ist.
Konstruktoren
Eine „Constructor“-Methode bestimmt, wie ein Objekt initialisiert wird. Konstruktoren
haben immer den gleichen Namen wie die Klasse und besitzen keine „return“Anweisung. Java erledigt beim Aufruf eines Konstruktors folgende Aufgaben:
- Speicherzuweisung für das Objekt
- Initialisieung der Instanzvariablen des Objekts auf ihre Anfangswerte oder einen Default-Wert (0 bei
Zahlen, „null“ bei Objekten, „false“ bei booleschen Operatoren.
- Aufruf der Konstruktor-Methode der Klasse
60
Algorithmen und Datenstrukturen
Gewöhnlich stellt man „explizit“ einen „default“-Konstruktor zur Verfügung. Dieser
parameterlose Konstruktor überlagert den implizit bereitgestellten „default“Konstruktor. Er wird dann bei allen parameterlosen Instanzierungen verwendet.
Konstruktoren können aber auch – wie normale Dateien – Parameter übergeben
bekommen.
super() bestimmt einen Aufruf des „default“-Konstruktors der eindeutig
bestimmten „Superklasse“. „super(...)“ darf nur als erste Anweisung eines
Konstruktors auftreten. Generell bezeichnet das reservierte Wort „super“, die nach
„extends“ benannte Superklasse.
Häufig sind die Parameter Anfangswerte für Instanzvariablen und haben oft den
gleichen Namen wie die entsprechenden Instanzvariablen. In diesen Fällen löst die
Verwendung von bspw. this.ersterOperand
= ersterOperand; derartige
Namenskonflikte auf.
Bei „this“ handelt es sich um einen Zeiger, der beim Anlegen eines Objekts
automatisch generiert wird. „this“ ist eine Referenzvariable, die auf das aktuelle
Objekt zeigt und zum Ansprechen der eigenen Methoden und Instanzvariablen dient.
Der „this“-Zeiger ist auch explizit verfügbar und kann wie eine ganz normale
Objektvariable verwendet werden. Er wird als versteckter Parameter an jede nicht
statische Methode übergeben.
Konstruktoren können in Java verkettet aufgerufen werden, d.h. sie können sich
gegenseitig aufrufen. Der aufrufende Konstruktor wird dabei als normale Methode
angesehen, die über this einen weiteren Konstruktor der aktuellen Klasse aufrufen
kann.
„Freundliche“ Klassen und „freundliche“ Methoden
Der voreingestellte „Defaultstatus“ einer Klasse ist immer „freundlich“ und wird dann
verwendet, wenn keine Angaben über die Sichtbarkeit (Spezifizierer, Modifizierer)
am Beginn der Klassendefinition vorliegen.
Die freundliche Grundeinstellung aller Klassen bedeutet: Diese Klasse kann von
anderen Klassen nur innerhalb desselben Pakets benutzt werden. Das PaketKonzept von Java faßt mehrere Klassen zu einem Paket über die Anweisung
„package“ zusammen. Durch die Anweisung „import“ werden einzelne Pakete
dann in einem Programm verfügbar gemacht. Klassen, die ohne „package“Anweisung definiert werden, werden vom Compiler in ein Standardpaket gestellt. Die
„.java“- und „.class“-Dateien dieses Pakets befinden sich im aktuellen
Verzeichnis oder im darunterliegenden Verzeichnis.
Mit dem Voranstellen von „public“ vor die Klassendeklaration wird eine Klasse als
„öffentlich“ deklariert.Dies bedeutet: Alle Objekte haben Zugriff auf „public“Klassen (nicht nur die des eigenen Pakets).
Der voreingestellte Defaultstaus einer Methode ist immer freundlich und wird immer
dann verwendet, wenn keine explizite Angabe zur Sichtbarkeit am Anfang der
Methodendeklaration vorliegt. Die freundliche Grundeinstellung aller Methoden
bedeutet: Die Methoden können sowohl innerhalb der Klasse als auch innerhalb des
zugehörigen Pakets benutzt werden.
Zugriffsrechte auf Klassen, Variable und Methoden
Es gibt in Java insgesamt 4 Zugriffsrechte:
61
Algorithmen und Datenstrukturen
private
Ohne Schlüsselwort
protected
public
Zugriff nur innerhalb einer Klasse
Zugriff innerhalb eines Pakets
Zugriff innerhalb eines Pakets oder von Subklassen in einem anderen
Paket
Zugriff von überall
Mit Voranstellen des Schlüsselworts „public“ können alle Klassen, Variablen /
Konstanten und Methoden für einen beliebigen Zugriff eingerichtet werden. Eine
derartige Möglichkeit, die in etwa der Zugriffsmöglichkeit globaler Variablen in
konventionellen Programmiersprachen entspricht, ist insbesondere bei komplexen
Programm-Systemen gefährlich. Es ist nicht sichergestellt, daß zu jedem Zeitpunkt
die Werte der Instanzvariablen von Objekten bestimmte Bedingungen erfüllen.
Abhilfe verspricht hier das Prinzip der Datenkapselung (data hiding, data
encapsulation). Instanzvariable werden als „private“ erklärt, d.h.: Zugriff auf diese
Instanzvariable nur innerhalb der Klasse. Von außerhalb kann nur indirekt über das
Aufrufen von Methoden, die als „public“ erklärt sind, auf die Instanzvariablen
zugegriffen werden. Deshalb sollte auch prinzipiell für jede Instanzvariable eine
entsprechende „get“- und eine „set“-Methode („hole“ bzw. „setze“) zur Verfügung
gestellt werden, die jeweils „public“ erklärt werden.
Klassen
Instanzvariable
Instanzkonstanten
Instanzmethoden
public
private
public
public, falls ein Zugriff von außen erforderlich und sinnvoll ist.
private, falls es sich um klaseninterne Hilfsmethoden handelt.
Klassenvariable und Klassenmethoden
Das reservierte Wort static macht Variable und Methoden (wie bspw. main()) zu
Klassenvariablen bzw. Klassenmethoden.
Klassenvariable werden in der Klasse definiert und gespeichert. Deshalb wirken sich
ihre Werte auf die Klasse und all ihre Instanzen aus. Jede Instanz hat Zugang zu der
Klassenvariablen, jedoch gibt es für alle Instanzen dieser Variablen nur einen Wert.
Durch Änderung des Werts ändern sich die Werte aller Instanzen der betreffenden
Klasse.
Klassenmethoden wirken sich wie Klassenvariable auf die ganze Klasse, nicht auf
einzelne Instanzen aus. Klassenmethoden sind nützlich zum Zusammenfassen
allgemeiner Methoden an einer Stelle der Klasse. So umfaßt die Math-Klasse
zahlreiche mathematische Funktionen in der Form von Klassenmethoden. Es gibt
keine Instanzen der Klasse Math.
Auch rekurvive Programme benutzen Klassenmethoden, z.B.:
// Berechnung der Fakultaet
public static long fakultaet(int n)
{
if (n < 0)
{
return -1;
}
else if (n == 0)
{
return 1;
}
else
{
return n * fakultaet(n-1);
62
Algorithmen und Datenstrukturen
}
}
Der Aufruf einer Klassenmethode kann im Rahmen der Punktnotation aber auch
direkt erfolgen, z.B.:
long resultat = fakultaet(i);
bzw.
long resultat = Rechentafel.fakultaet(i);
unter der Voraussetzung: Die Klassenmethode „fakultaet“ befindet sich in der Klasse
„Rechentafel“.
Lokale Variable und Konstanten
Lokale Variable werden innerhalb von Methodendefinitionen deklariert und können
nur dort verwendet werden. Es gibt auch auf Blöcke beschränkte lokale Variablen.
Die Deklaration einer lokalen Variablen gilt in Java als ausführbare Anweisung. Sie
darf überall dort erfolgen, wo eine Anweisung verwendet werden darf. Die
Sichtbarkeit einer lokalen Variablen erstreckt sich von der Deklaration bis zum Ende
des umschließenden Blocks.
Lokale Variablen existieren nur solange im Speicher, wie die Methode oder der Block
existiert. Lokale Variable müssen unbedingt ein Wert zugewiesen bekommen, bevor
sie benutzt werden können. Instanz- und Klassenvariable haben einen
typspezifischen Defaultwert. Auf lokale Variable kann direkt und nicht über die
Punktnotation zugegriffen werden. Lokale Variable können auch nicht als
Konstanten20 gesetzt werden.
Beim Bezug einer Variablen in einer Methodendefinition sucht Java zuerst eine
Definition dieser Variablen im aktuellen Bereich, dann durchsucht es die äußeren
Bereiche bis zur Definition der aktuellen Methode. Ist die gesuchte Größe keine
lokale Variable, sucht Java nach einer Definition dieser Variablen als Instanzvariable
in der aktuellen Klasse und zum Schluß in der Superklasse.
Konstanten sind Speicherbereiche mit Werten, die sich nie ändern. In Java können
solche Konstanten ausschließlich für Instanz- und Klassenvariable erstellt werden.
Konstante bestehen aus einer Variablendeklaration, der das Schlüsselwort final
vorangestellt ist, und ein Anfangswert zugewiesen ist, z.B.: final int LINKS =
0;
Überladen von Methoden
In Java ist es erlaubt, Methoden zu überladen, d.h. innerhalb einer Klasse zwei
unterschiedliche Methoden mit denselben Namen zu definieren. Der Compiler
unterscheidet die verschiedenen Varianten anhand Anzahl und Typisierung der
Parameter. Es ist nicht erlaubt, zwei Methoden mit exakt demselben Namen und
identischer Parameterliste zu definieren. Es werden auch zwei Methoden, die sich
nur durch den Typ ihres Rückgabewerts unterscheiden als gleich angesehen.
Der Compiler kann die Namen in allen drei Fällen unterscheiden, denn er arbeitet mit
der Signatur der Methode. Darunter versteht man ihren internen Namen. Dieser setzt
sich aus dem nach außen sichtbaren Namen und zusätzlich kodierter Information
über Reihenfolge und die Typen der formalen Parameter zusammen.
20
Das bedeutet: das Schlüsselwort final ist bei lokalen Variablen nicht erlaubt
63
Algorithmen und Datenstrukturen
Überladen kann bedeuten, daß bei Namensgleichheit von Methoden in
„Superklasse“ und abgeleiteter Klasse die Methode der abgeleitetem Klasse, die der
Superklasse überdeckt. Es wird aber vorausgesetzt, daß sich die Parameter
signifikant unterscheiden, sonst handelt es sich bei der Konstellation Superklasse –
Subklasse um den Vorgang des Überschreibens. Soll die Originalmethode
aufgerufen werden, dann wird das Schlüsselwort „super“ benutzt. Damit wird der
Methodenaufruf in der Hierarchie nach oben weitergegeben.
Überschreiben einer Oberklassen-Methode: Zum Überschreiben einer Methode wird
eine Methode erstellt, die den gleichen Namen, Ausgabetyp und die gleiche
Parameterliste wie eine Methode der Superklasse besitzt. Da Java die erste
Methodendefinition ausführt, die es findet und die in Namen, Ausgabetyp und
Parameterliste übereinstimmt, wird die ursprüngliche Methodendefinition dadurch
verborgen.
Konstruktor-Methoden können „technisch“ nicht überschrieben werden. Da sie den
gleichen Namen wie die aktuelle Klasse haben, werden Konstruktoren nicht vererbt,
sondern immer neu erstellt. Wird ein Konstruktor einer bestimmten Klasse
aufgerufen, wird gleichzeitig auch der Konstruktor aller Superklassen aktiviert, so
daß alle Teile einer Klasse initialisiert werden. Ein spezielles Überschreiben kann
über super(arg1, arg2, ...) ermöglicht werden.
Die Methode toString() ist eine in Java häufig verwendete Methode.
„toString()“ wird als weitere Instanzmethode der Klasse Rechentafel definiert und
überschreibt die Methode gleichen Namens, die in der Oberklasse Object definiert
ist.
1.2.3.6.2 Referenzen, einfache Typen und Referenztypen
Referenzen: Referenzen sind Verweise, die auf Objekte eines bestimmten Typs
zeigen. Dieser Typ kann eine Klasse oder ein Interface sein. Referenzen treten als
lokale Variable, Instanz- oder Klassenvariable in Erscheinung.
Beim Zuweisen von Variablen für Objekte bzw. beim Weiterreichen von Objekten als
Argumente an Methoden werden Referenzen auf diese Objekte festgelegt.
Java verfügt über ein automatisches Speichermanagement. Deshalb braucht der
Java-Programmierer sich nicht um die Rückgabe von Speicher zu kümmern, der von
Referenzvariablen belegt ist. Ein mit niederer Priorität im Hintergrund arbeitender
„Garbage Collector“ sucht ständig nach Objekten, die nicht mehr referenziert werden,
um den belegten Speicher freizugeben.
Finalizer-Methoden werden aufgerufen, kurz bevor das Objekt im Papierkob landet
und sein Speicher freigegeben wird. Zum Erstellen einer Finalizer-Methode dient der
folgende Eintrag in der Klassendefinition void finalize(){ ... }. Im Körper
dieser Methode können alle möglichen Reinigungsprozeduren stehen, die das
Objekt ausführen soll. Der Aufruf der Methode finalize() bewirkt nicht
unmittelbar die Ablage im Papierkorb. Nur durch das Entfernen aller Referenzen auf
das Objekt wird das Objekt zum Löschen markiert.
Einfache Typen: Jedes Java-Programm besteht aus einer Sammlung von Klassen.
Der vollständige Code von Java wird in Klassen eingeteilt. Es gibt davon nur eine
Ausnahme: Boolesche Operatoren, Zahlen und andere einfache Typen sind in Java
erst einmal keine Objekte. Java hat für alle einfachen Typen sog. Wrapper-Klassen
implementiert. Ein Wrapper-Klasse ist eine spezielle Klasse, die eine
64
Algorithmen und Datenstrukturen
Objektschnittstelle für die in Java verschiedenen primitiven Typen darstellt. Über
Wrapper-Objekte können alle einfachen Typen wie Klassen behandelt werden. Java
besitzt acht primitive Datentypen:
- vier Ganzzahltypen mit unterschiedlichen Wertebereichen
- zwei Gleitpunktzahlentypen mit unterschiedlichen Wertebereichen nach IEEE Standard of Binary Floating
Point Arithmetic.
- einen Zeichentyp
Primitiver Typ
boolean
char
byte
short
int
long
float
double
void
Größe
1-Bit
16-Bit
8-Bit
16-Bit
32-Bit
64-Bit
32-Bit
64-Bit
-
Minimum
Unicode 0
-128
-215
-231
-263
IEEE 754
IEEE 754
-
Maximum
Unicode 216-1
+128
+215-1
+231-1
+263-1
IEEE 754
IEEE 754
-
Wrapper-Klasse
Boolean
Character
Byte
Short
Integer
Long
Float
Double
Void
Den primitiven Typen sind „Wrapper“-Klassen zugeordnet, die ein nicht primitives
Objekt auf dem „Heap“ zur Darstellung des primitiven Typs erzeugen, z.B.:
char zeichen = ‘x‘;
Character zeichenDarstellung = new Character(zeichen)
bzw.
Character zeichenDarstellung = new Character(‘x‘);
Referenztypen: Dazu gehören: Objekte der benutzerdefinierten und aus vom System
bereitgestellten Klassen, der Klassen String und Array (Datenfeld). Weiterhin gibt
es die vordefinierte Konstante „null“, die eine leere Referenz bezeichnet.
„String“ und „Array“ weisen einige Besonderheiten aus:
- Für Strings und Arrays kennt der Compiler Literale, die einen expliziten Aufruf des Operator new
überflüssig machen
- Arrays sind klassenlose Objekte. Sie können ausschließlich vom Compiler erzeugt werden, besitzen
aber keine explizite Klassendefinition. Sie werden dennoch vom Laufzeitsystem wie normale Objeklte
behandelt.
- Die Klasse String ist zwar im JDK vorhanden. Der Compiler hat aber Kenntnis über den inneren
Aufbau von Strings und generiert bei Anzeigeoperationen Code, der auf Methoden der Klassen String
und StringBuffer zugreift.
65
Algorithmen und Datenstrukturen
1.2.3.6.3 Superklassen und Subklassen, Vererbung und Klassenhierarchie
Klassen können sich auf andere Klassen beziehen. Ausgangspunkt ist eine
Superklasse, von der Subklassen (Unter-) abgeleitet sein können. Jede Subklasse
erbt den Zustand (über die Variablen-Deklarationen) und das Verhalten der
Superklasse. Darüber hinaus können Subklassen eigene Variable und Methoden
hinzufügen. Superklassen, Subklassen bilden eine mehrstufige Hierarchie. In Java
sind alle Klassen Ableitungen einer einzigen obersten Klasse – der Klasse Object.
Sie stellt die allgemeinste Klasse in der Hierarchie dar und legt die Verhaltensweisen
und Attribute, die an alle Klassen in der Java-Klassenbibliothek vererbt werden, fest.
Vererbung bedeutet, daß alle Klassen in eine strikte Hierarchie eingeordnet sind und
etwas von übergeordneten Klassen erben. Was kann von übergeordneten Klassen
vererbt werden? Beim Erstellen einer neuen Instanz erhält man ein Exemplar jeder
Variablen, die in der aktuellen Klasse definiert ist. Zusätzlich wird ein Exemplar für
jede Variable bereitgestellt, die sich in den Superklassen der aktuellen Klasse
befindet. Bei der Methodenauswahl haben neue Objekte Zugang zu allen
Methodennamen ihrer Klasse und deren Superklasse. Methodennamen werden
dynamisch beim Aufruf einer Methode gewählt. Das bedeutet: Java prüft zuerst die
Klasse des Objekts auf Definition der betreffenden Methode. Ist sie nicht in der
Klasse des Objekts definiert, sucht Java in der Superklasse dieser Klasse usw.
aufwärts in der Hierarchie bis die Definition der Methode gefunden wird.
Ist in einer Subklasse eine Methode mit gleichem Namen, gleicher Anzahl und
gleichem Typ der Argumente wie in der Superklasse definiert, dann wird die
Methodendefinition, die zuerst (von unten nach oben in der Hierarchie) gefunden
wird, ausgeführt. Methoden der abgeleiteten Klasse überdecken die Methoden der
Superklasse (Overriding beim Überschreiben / Überdefinieren).
Zum Ableiten einer Klasse aus einer bestehenden Klasse ist im Kopf der Klasse mit
Hilfe des Schlüsselworts „extends“ ein Verweis auf die Basisklasse anzugeben.
Dadurch wird bewirkt: Die abgeleitete Klasse erbt alle Eigenschaften der
Basisklasse, d.h. alle Variablen und alle Methoden. Durch Hinzufügen neuer
Elemente oder Überladen der vorhandenen kann die Funktionalität der abgeleiteten
Klasse erweitert werden.
Die Vererbung einer Klasse kann beliebig tief geschachtelt werden. Eine abgeleitete
Klasse erbt dabei jeweils die Eigenschaften der unmittelbaren „Superklasse“, die
ihrerseits die Eigenschaften ihrer unmittelbaren „Superklasse“ erbt.
Superklassen können abstrakte Klassen mit generischem Verhalten sein. Von einer
abstrakten Klasse wird nie eine direkte Instanz benötigt. Sie dient nur zu
Verweiszwecken. Abstrakte Klassen dürfen keine Implementierung einer Methode
enthalten und sind damit auch nicht instanziierbar. Java charakterisiert abstrakte
Klassen mit dem Schlüsselwort abstract. Abstrakte Klassen werden zum Aufbau
einer Klassenhierarchie verwendet, z.B. für die Zusammenfassung von zwei oder
mehr Klassen.
Auch eine abstrakte Methode ist zunächst einmal durch das reservierte Wort
"abstract" erklärt. Eine abstrakte Methode besteht nur aus dem Methodenkopf,
anstelle des Methodenrumpfs (der Methodendefinition) steht nur das "Semikolon",
z.B. public abstract String toString();.
Enthält eine Klasse mindestens eine abstrakte Methode, so wird automatisch die
gesamte Klasse zu einer abstrakten Klasse. Abstrakte Klassen enthalten keine
Konstruktoren. Sie können zwar Konstruktoren aufnehmen, allerdings führt jeder
explizite Versuch zur Erzeugung eines Objekts einer abstrakten Klasse zu einer
Fehlermeldung. Abstrakte Methoden stellen eine Schnittstellenbeschreibung dar, die
66
Algorithmen und Datenstrukturen
der Programmierer einer Subklasse zu definieren hat. Ein konkrete Subklasse einer
abstrakten Klasse muß alle abstrakten Methoden der Superklasse(n)
implementieren.
1.2.3.6.4 Schnittstellen und Pakete
Schnittstellen
Definition: Eine Schnittstelle ist in Java eine Sammlung von Methodennamen ohne
konkrete Definition. Klassen haben die Möglichkeit zur Definition eines Objekts,
Schnittstellen können lediglich ein paar abstrakte Methoden und Konstanten (finale
Daten) definieren21.
Schnittstellen bilden in Java den Ersatz für Mehrfachvererbung. Eine Klasse kann
demnach nur eine Superklasse, jedoch dafür mehrere Schnittstellen haben.
Ein Schnittstelle („Interface“) ist eine besondere Form der Klasse, die ausschließlich
abstrakte Methoden und Konstanten enthält. Anstelle von class dient zur Definition
eines „Interface“ das Schlüsselwort „interface“.
"Interfaces" werden formal wie Klassen definiert, z.B.:
public interface meineSchnittstelle
{
// Abstrakte Methoden
}
bzw.
public interface meineSpezialschnittstelle extends meineSchnittstelle
{
// Abstrakte Methoden
}
"Interfaces" können benutzt werden, indem sie in einer Klasse implementiert werden,
z.B.:
public class MeineKlasse extend Object implements meineSchnittstelle
{
/* Normale Klassendefinition + Methoden aus meineSchnittstelle */
}
Eine Klasse kann mehrere Schnittstellen implementieren, z.B.
public class MeineKlasse extends Object
implements meineSchnittstelle1, meineSchnittstelle2
{
/* Normale Klassendefinition + Metoden aus meinerSchnittstelle1
und meinerSchnittstelle2 */
}
Verwendung: Bei der Vererbung von Klassen spricht man von Ableitung, bei
Interfaces nennt man es Implementierung. Durch Implementieren einer Schnittstelle
verpflichtet sich die Klasse, alle Methoden, die im Interface definiert sind, zu
21
Angaben zur Implementierung sind nicht möglich
67
Algorithmen und Datenstrukturen
implementieren. Die Implementierung eines Interface wird durch das Schlüsselwort
„implements“ bei der Klassendefinition angezeigt.
Bsp.: Zahlreiche von Java bereitgestellte Referenztypen (Klassen) haben das
Interface Comparable implementiert. Deshalb sortiert die nachfolgenden
Sortierroutine (Sort) String- und Zahlen-Objekte, wie der Aufrufe aus SortTest
beweisen.
public class Sort
{
public void sort(Comparable x[])
{
bubbleSort(x);
}
public void bubbleSort(Comparable x[])
{
for (int i = 0; i < x.length; i++)
{
for (int j = i + 1; j < x.length; j++)
{
if (x[i].compareTo(x[j]) > 0)
{
Comparable temp = x[i];
x[i] = x[j];
x[j] = temp;
}
}
}
}
}
public class SortTest
{
public static void main(String args[])
{
String sa[] = {
"Juergen","Christian","Bernd","Werner","Uwe",
"Erich","Kurt","Karl","Emil","Ernst"
};
// Ausgabe des noch nicht sortierten x
System.out.println("Vor dem Sortieren:");
for (int i = 0; i < sa.length; i++)
{
System.out.println("sa["+i+"] = " + sa[i]);
}
// Aufruf der Sortieroutine
Sort s = new Sort();
s.sort(sa);
// Gib das sortierte Feld x aus
System.out.println();
System.out.println("Nach dem Sortieren:");
for (int i = 0; i < sa.length; i++)
{
System.out.println("sa["+i+"] = " + sa[i]);
}
System.out.println();
int za[] = { 50, 70, 80, 10, 20, 30, 1, 2, 3, 99, 12, 11, 13};
Integer o[] = new Integer[za.length];
for (int i = 0; i < za.length; i++)
{
o[i] = new Integer(za[i]);
}
// Ausgabe des noch nicht sortierten za
System.out.println("Vor dem Sortieren:");
for (int i = 0; i < za.length; i++)
{
68
Algorithmen und Datenstrukturen
System.out.println("za["+i+"] = " + za[i]);
}
// Aufruf der Sortieroutine
Sort zs = new Sort();
zs.sort(o);
// Gib das sortierte Feld x aus
for (int i = 0; i < za.length; i++)
{
za[i] = ((Integer) o[i]).intValue();;
}
System.out.println();
System.out.println("Nach dem Sortieren:");
for (int i = 0; i < za.length; i++)
{
System.out.println("za["+i+"] = " + za[i]);
}
}
}
Konstanten. Neben abstrakten Methoden können Interfaces auch Konstanten
(Variablen mit dem Attributen static und final) enthalten. Wenn eine Klasse ein
solches Interface implementiert, erbt es gleichzeitig auch alle seine Konstanten.
Ein Interface kann ausschließlich Konstanten enthalten.
Datentypen. Durch die Definition eines Interface wird auch der zugehörige
Referenztyp erzeugt, der wie andere Datentypen eingesetzt werden kann
Pakete
Definition: Pakete sind in Java Zusammenfassungen von Klassen und Schnittstellen.
Sie entsprechen in Java den Bibliotheken anderer Programmiersprachen. Pakete
ermöglichen, daß modulare Klassengruppen nur verfügbar sind, wenn sie gebraucht
werden. Potentielle Konflikte zwischen Klassenamen in unterschiedlichen
Klassengruppen können dadurch vermieden werden.
Für Methoden und Variablen innerhalb von Paketen besteht eine
Zugriffsschutzschablone. Jedes Paket ist gegenüber anderen, nicht zum Paket
zählenden Klassen abgeschirmt. Klassen, Methoden oder Variablen sind nur
sichtbar für Klassen im gleichen Paket. Klassen, die ohne „package“ Anweisung
definiert sind, werden vom Compiler in ein „Standardpaket“ gestellt. Voraussetzung
dafür ist: Die „.java“- und „.class“-Dateien dieses Pakets befinden sich im aktuellen
Verzeichnis (oder in einen darunter liegenden Klassenverzeichnis).
Die Java-Klassenbibliothek22 von Java 1.0 enthält folgende Pakete:
- java.lang: Klassen, die unmittelbar zur Sprache gehören. Das Paket umfaßt u.a.
die Klassen Object, String, System, außerdem die Sonderklassen für die
Primitivtypen (Integer, Character, Float, etc.)
Object
Class
22
Aus dieser Klasse leiten sich alle weiteren Klassen ab. Ohne explizite Angabe der
Klasse, die eine neue Klasse erweitern soll, erweitert die neue Klasse die Object-Klasse.
Die Klasse Object ist die Basis-Klasse jeder anderen Klasse in Java. Sie definiert
Methoden, die von allen Klasse in Java unterstützt werden.
Für jede in java definierte Klasse gibt es eine Instanz von Class, die diese Klasse
beschreibt
Die Klassenbibliothek des JDK befindet sich in einem Paket mit dem namen „java“.
69
Algorithmen und Datenstrukturen
String
StringBuffer
Thread
ThreadGroup
Throwable
System
Runtime
Process
Math
Number
Character
Boolean
ClassLoader
SecurityManager
Compiler
Enthält Methoden zur Manipulation von Java-Zeichenketten
Dient zum Erstellen von Java-Zeichenketten
Stellt einen Ausführungs-Thread in einem Java-Programm dar. Jedes Programm kann
mehrere Threads laufen lassen
Ermöglicht die Verknüpfung von Threads untereinander. Einige Thread-Operationen
können nur von Threads aus der gleichen ThreadGroup ausgeführt werden.
Ist die Basisklasse für Ausnahmen. Jedes Objekt, das mit der "catch"-Anweisung
gefangen oder mit der "throw"-Anweisung verworfen wird, muß eine Subklasse von
Throwable sein.
Stellt spezielle Utilities auf Systemebene zur Verfügung
Enthält eine Vielzahl gleicher Funktionen wie System, behandelt aber auch das Laufen
externer Programme
Stellt ein externes Programm dar, das von einem Runtime-Objekt gestartet wurde.
Stellt eine Reihe mathematischer Funktionen zur verfügung
Ist die Basisklasse für Double,Float, Integer und Long (Objeckt-Wrapper)
Ist ein Objekt-Wrapper für den datentyp char und enthält eine Reihe nützlicher
zeichenorientierter Operationen
Ist ein Objekt-Wrapper für den Datentyp boolean
Ermöglicht der Laufzeit-Umgebung von Java neue Klassen hinzuzufügen
Legt die Sicherheits-Restriktionen der aktuellen Laufzeitumgebung fest. Viele JavaKlassen benutzen den Security-Manager zur Sicherstellung, daß eine Operation auch
tatsächlich genehmigt ist.
Ermöglicht, falls vorhanden, den Zugriff auf den "just-in-time" Compiler
Abb.: Klassen des java.lang-Pakets
Zusätzlich enthält das java.lang-Paket noch zwei Schnittstellen:
Cloneable
Runnable
Muß von einem anderen Objekt implementiert werden, das dann geklont oder
kopiert werden kann
Wird zusammen mit der Thread-Klasse benutzt, um die aufgerufene Methode
zu definieren, wenn ein Thread gestartet wird.
Abb.: Schnittstellen im java.lang-Paket
- java.util
- java.io: Klassen zum Lesen und Schreiben von Datenströmen und zum
Handhaben von Dateien.
- java.net: Klassen zur Netzunterstützung, z.B. socket und URL (eine Klasse
zum Darstellen von Referenzen auf Dokumente im World Wide Web).
- java.awt (Abstract Windowing Toolkit): Klassen zum Implementieren einer
grafischen Benutzeroberfläche. Das Paket enthält auch eine Klasse für Grafik
(java.awt.Graphics) und Bildverarbeitung (java.awt.Image).
- java.applet: Klassen zum Implementieren von Applets, z.B. die Klasse Applet.
Die Java Version 1.1 hat die Klassenbibliothek umfassend erweitert23:
Paket
java.applet
java.awt
java.awt.datatranfer
23
Bedeutung
Applets
Abstract Window Toolkit
ClipBoard-Funktionalität (Copy / Paste)
Zusätzlich 15 weitere Packages, etwa 500 Klassen und Schnittstellen
70
Algorithmen und Datenstrukturen
java.awt.event
java.awt.image
java.beans
java.io
java.lang
java.lang.reflect
java.math
java.net
java.rmi
java.rmi.dgc
java.rmi.registry
java.rmi.server
java.security
java.security.aci
java.security.interfaces
java.sql
java.util
java.util.zip
AWT Event-handling
Bildanzeige
Java Beans
Ein- und Ausgabe, Streams
Elementare Sprachunterstützung
Introspektion, besserer Zugriff auf Klassen durch
Debugger und Inspektoren
Netzzugriffe
Remote Method Invocation, Zugriff auf Objekte in
anderen virtuellen Maschinen
RMI Distributed Garbage Collection
Verwaltet Datenbank, die RMI-Verbindungen
koordiniert
RMI-Server
Sicherheit durch digitale Signaturen, Schlüssel
Access Control Lists
Digital Signature Algorithm (DAS-Klassen)
Datenbankzugriff (JDBC)
Diverse Utilities, Datenstrukturen
JAR-Files, Kompression, Prüfsummen
Abb.: Klassenbibliothek der Java-Version 1.1
Verwendung: Jede Klasse ist Bestandteil eines Pakets. Der vollständige Name einer
Klasse besteht aus dem Namen des Pakets, danach kommt der ein Punkt, gefolgt
von dem eigentlichen Namen der Klasse.
Zur Verwendung einer Klasse muß angegeben werden, in welchem Paket sie liegt.
Hier gibt es zwei unterschiedliche Möglichkeiten:
- Die Klasse wird über ihren vollen (qualifizieren) Namen angesprochen, z.B.
java.util.Date d = new java.util.Date();
- Am Anfang des Klassenprogramms werden die gewünschten Klassen mit Hilfe der import-Anweisung
eingebunden, z.B.:
import java util.*;
......
Date d = new Date();
Die import-Anweisung gibt es in unterschiedlichen Ausprägungen:
-- Mit "import paket.Klasse" wird genau eine Klasse importiert, alle anderen Klassen des Pakets
bleiben verborgen
--Mit "import paket.*"24 können alle Klassen des angegebenen Pakets auf einmal importiert
werden.
Standardmäßig haben Java-Klassen Zugang zu den in java.lang befindlichen
Klassen. Klassen aus anderen Paketen müssen explizit über den Paketnamen
einbezogen oder in die Quelldatei importiert werden.
24
type import on demand, d.h.: Die Klasse wird erst dann in dem angegebenen Paket gesucht, wenn das
Programm sie wirklich benötigt.
71
Algorithmen und Datenstrukturen
1.2.3.6.5 Polymorphismus und Binden
Polymorphismus
Definition: Polymorphismus ist die Eigenschaft von Objekten, mehreren Klassen (und
nicht nur genau einer Klasse) anzugehören, d.h. je nach Zusammenhang in
unterschiedlicher Gestalt (polymorph) aufzutreten. Java ist polymorph.
Binden: Das Schema einer Botschaft wird aus "Empfänger Methode Argument"
gebildet. Darüber soll eine Nachricht die physikalische Adresse eines Objekts im
Speicher finden. Trotz Polymorphismus und Vererbung ist der Methodenname (oft.
Selektor genannt) nur eine Verknüpfung zum Programmcode einer Methode. Vor der
Ausführung einer Methode muß daher eine genaue Zuordnung zwischen dem
Selektor und der physikalischen Adresse des tatsächlichen Programmcodes erfolgen
(Binden oder Linken).
In der objektorientierten Programmierung unterscheidet man: frühes und spätes
Binden:
Frühes Binden: Ein Compiler ordnet schon zum Zeitpunkt der Übersetzung des Programms die
tatsächliche, physikalische Adresse der Methode dem Methodenaufruf zu.
Spätes Binden: Hier wir erst zur Laufzeit des Programms die tatsächliche Verknüpfung zwischen Selektor und
Code hergestellt. Die richtige Verbidung übernimmt das Laufzeitprogramm der Programmiersprache.
Java unterstützt das Konzept des „Late Binding“.
72
Algorithmen und Datenstrukturen
1.3 Sammeln (über Container) und Ordnen
1.3.1 Ausgangspunkt: Das Konzept zum Sammeln in Smalltalk
Gefordert ist eine allgemeines Konzept zum Sammeln und Ordnen von Objekten
(Behälter, Container, Collection). Der Entwurf solcher Konzepte bzw.
Containerklassen ist Gegenstand objektorientierter Programmiersprachen.
Ein einziger Containertyp, der allen Anforderungen gerecht wird, wäre sicherlich die
beste Lösung. Dem stehen verschiedene Schwierigkeiten entgegen:
- Gegensätzliche Ordnungskriterien (z.B. in Schlangen, Stapeln)
- Unterschiedliche Forderungen (z.B. bzgl. des Begriffs "Enthalten" in einer Menge (set) oder in einer
Sammlung (bag)
- Identifikation der Objekte über Wert oder Schlüssel oder Position (Index)
- Unterschiedliche Anforderungen der Zugriffs-Effizienz (z.B. wahlfrei-berechenbar, mengenmäßig
eingeschränkt, Zugriff über "keys" in einem "dictionary25")
Unter einem Container (bzw. Collection bzw. Ansammlung) versteht man eine
allgemeine Zusammenfassung von Objekten in irgendeiner, nicht näher
spezifizierten Organisationsstruktur. Ordnung kann in einer (An-)Sammlung viel
bedeuten, z.B.:
- in einem "array" die Ablage in irgendeiner Reihenfolge unter einem automatisch fortgeschriebenen
Index
- in einer hierarchischen Struktur (Baum-) ein Container (z.B. Liste), bei dem die enthaltenen Elemente
selbst (Listen) sein dürfen.
In Smalltalk/V realisiert die abstrakte Klasse "Collection" zusammen mit ihren
Unterklassen ein leistungsfähiges Konzept zum Sammeln und Ordnen von Objekten.
Die Klasse Collection beschreibt die gemeinsamen Eigenschaften aller ObjektAnsammlungen. Da es keine allgemeinen Ansammlungen gibt, kann man auch keine
Instanzen der Klasse Collection bilden (abstrakte Oberklasse). Objekteigenschaften,
die unabhängig von der Zugehörigkeit zu spezifischen Unterklassen sind, können in
der Oberklasse spezifiziert werden. Collection ist eine direkte Unterklasse der
allgemeinsten Klasse Object.
Object
Collection
Bag
IndexedCollection
FixedSizedCollection
Array
Bitmap
ByteArray
Interval
String
Symbol
OrderedCollection
Set
Dictionary
Identity Dictionary
System Dictionary
Abb. 1.3-1: Klassen zum Sammeln und Ordnen von Objekten in Smalltalk/V
25
Tabellen, vgl. 1.3.2
73
Algorithmen und Datenstrukturen
Viele bekannte Datenstrukturen stehen bereits in den Klassen zur Verfügung. Durch
Unterklassen und Kombination von Klassen lassen sich beliebig andere
Datenstrukturen erstellen.
„Collection“ selbst nimmt keinen Bezug auf irgendein Ordnungsprinzip, nach dem
seine Elemente abgelegt sind. Die Subklassen von Collection werden so organisiert,
daß sie häufig auftretende Ordnungsprinzipien unterstützen. Das erste
Unterscheidungsmerkmal
betrifft
die
Indizierbarkeit
der
Sammlung
(IndexedCollection). Alle anderen (nicht indizierten) Sammlungen teilen sich sich in
Bag (mehrfache Einträge sind erlaubt) und Set (mehrfache Einträge sind nicht
erlaubt). IndexedCollection wird unterteilt in Sammlungen mit einer festen Anzahl
von Elementen (FixedCollection) oder mit variabler Anzahl von Elementen
(OrderedCollection, paßt die Größe automatisch dem Bedarf an und ermöglicht den
Aufbau üblicher dynamischer Datenstrukturen: Stacks, Fifos, etc.). In der Subklasse
SortedCollection kann die Reihenfolge der Elemente durch eine Sortiervorschrift
festgelegt werden.
Bei nicht indizierten Sammlungen wird nur die Klasse Set weiter spezialisiert. Ihre
Subklasse Dictionary" kann auf die Elemente einer Sammlung über Schlüsselwörter
zugreifen.
Allen Sammlungen ist gemeinsam: Sie enthalten Nachrichten zum Hinzufügen und
Entfernen von Objekten, zum Test auf das Vorhandensein von Elementen und zum
Aufzählen der Elemente. Beschreibungen von Reaktionen auf Nachrichten eines
Objekts heißen Methoden. Jede Methode ist mit einer Nachrichtenkennung
versehen und besteht aus Smalltalk Anweisungen:
Eigenschaft der Methode:
Hinzufügen
Smalltalk-Anweisung
add:anObject
addALL:aCollection
Entfernen
remove:anObject
removeALL
Testen
includes:anObject
isEmpty
occurencesOf:anObject
Aufzählen
do:aBlock
reject:aBlock
collect:aBlock
Bedeutung:
füge ein Objekt hinzu
füge alle Elemente von
aCollection hinzu
entferne ein Objekt
entferne alle Elemente von
aCollection
gib true zurück, falls die
Anwendung leer ist, sonst false
gib true zurück, falls die
Anwendung leer ist, sonst false
gib zurück, wie oft ein
Objekt vorkommt
gib eine Ansammlung mit
den Elementen zurück,
für die die Auswertung
von aBlock true ergibt
gib eine Ansammlung mit
den Elementen zurück, für
die Auswertung von aBlock
false ergibt
führe aBlock für jedes
Element aus und gib eine
Ansammlung mit den
Ergebnisobjekten als
Element zurück
Abb. 1.3-2: Ein Auszug der Instanzmethoden zu Smalltalk-Anweisungen
Die Zuordnung von Nachrichtenkennungen zu Methoden erfolgt dynamisch beim
Senden der Nachricht. Gibt es in der Klasse des Empfängers eine Methode mit der
Nachrichtenkennung, so wird diese Methode ausgeführt. Andernfalls wird die
74
Algorithmen und Datenstrukturen
Methodensuche in der Oberklasse fortgesetzt und solange in der Klassenhierarchie
nach oben gegangen, bis eine Methode mit der gewünschten Nachrichtenkennung
gefunden wird. Gibt es keine solche Methode, dann setzt eine Ausnahmebehandlung
an.
1.3.2 Behälter-Klassen
Kollektionen
Linear
Allgemein
indexiert
DirektZugriff
Nichtlinear
Sequentieller
Zugriff
Hierarchische
Sammlung
Baum
Dictionary
HashTabelle
Liste
„array“
GruppenKollektionen
Heap
Set
Stapel Schlange
Graph
prioritätsgest. Schl.
„record“ „file“
Abb. 1.3-4: Hierarchischer Aufbau der Klasse Kollektion
Die Abbildung zeigt unterschiedliche, benutzerdefinierte Datentypen. Gemeinsam ist
diesen Klassen nur die Aufnahme und Berechnung der Daten durch ihre Instanzen.
Kollektionen können in lineare und nichlineare Kategorien eingeteilt werden. Eine
lineare Kollektion enthält eine Liste mit Elementen, die durch ihre Stellung (Position)
geordnet sind, Es gibt ein erstes, zweites, drittes Element etc.
75
Algorithmen und Datenstrukturen
1.3.2.1 Lineare Kollektionen
1. Sammlungen mit direktem Zugriff
Ein „array“ ist eine Sammlung von Komponenten desselben Typs, auf den direkt
zugegriffen werden kann.
„array“-Kollektion
Daten
Eine Kollektion von Objekten desselben (einheitlichen) Typs
Operationen
Die Daten an jeder Stelle des „array“ können über einen
ganzzahligen Index erreicht werden.
Ein statisches Feld („array“) enthält eine feste Anzahl von Elementen und ist zur
Übersetzungszeit festgelegt. Ein dynamisches Feld benutzt Techniken zur
Speicherbeschaffung und kann während der Laufzeit an die Gegebenheiten
angepaßt werden. Ein „array“ kann zur Speicherung einer Liste herangezogen
werden. Allerdings können Elemente der Liste nur effizient am Ende des „array“
eingefügt werden. Anderenfalls sind für spezielle Einfügeoperationen
Verschiebungen der bereits vorliegenden Elemente (ab Einfügeposition) nötig.
Eine „array“-Klasse sollte Bereichsgrenzenüberwachung für Indexe und dynamische
Erweiterungsmöglichkeiten
erhalten.
Implementierungen
aktueller
Programmiersprachen
umfassen
Array-Klassen
mit
nützlichen
Bearbeitungsmethoden bzw. mit dynamischer Anpassung der Bereichsgrenzen zur
Laufzeit.
Eine Zeichenkette („character string“) ist ein spezialisierter „array“, dessen
Elemente aus Zeichen bestehen:
„character string“-Kollektion
Daten
Eine Zusammenstellung von Zeichen in bekannter Länge
Operationen
Sie umfassen Bestimmen der Länge der Zeichenkette, Kopieren
bzw. Verketten einer Zeichenkette auf eine bzw. mit einer
anderen Zeichenkette, Vergleich zweier Zeichenketten (für die
Musterverarbeitung), Ein-, Ausgabe von Zeichenketten
In einem „array“ kann ein Element über einen Index direkt angesprochen werden.
In vielen Anwendungen ist ein spezifisches Datenelement, der Schlüssel (key) für
den Zugriff auf einen Datensatz vorgesehen. Behälter, die Schlüssel und übrige
Datenelemente zusammen aufnehmen, sind Tabellen.
Ein Dictionary ist eine Menge von Elementen, die über einen Schlüssel identifiziert
werden. Das Paar aus Schlüsseln und zugeordnetem Wert heißt Assoziation, man
spricht auch von „assoziativen Arrays“. Derartige Tabellen ermöglichen den
Direktzugriff über Schlüssel so, wie in einem Array der Direktzugriff über den Index
erreicht wird, z.B.: Die Klasse Hashtable in Java
Der Verbund (record) ist in der Regel eine Zusammenfassung von Datenbehältern
unterschiedlichen Typs:
„record“-Kollektion
76
Algorithmen und Datenstrukturen
Daten
Ein Element mit einer Sammlung von Datenfeldern mit
möglicherweise unterschiedlichen Typen.
Operationen
Der Operator . ist für den Direktzugriff auf den Datenbehälter vorgesehen.
Eine Datei (file) ist eine extern eingerichtete Sammlung, die mit einer Datenstruktur
(„stream“) genannt verknüpft wird.
„file“-Kollektion
Daten
Eine Folge von Bytes, die auf einem externen Gerät abgelegt
ist. Die Daten fließen wie ein Strom von und zum Gerät.
Operationen
Öffnen (open) der Datei, einlesen der Daten aus der Datei,
schreiben der Daten in die Datei, aufsuchen (seek) eines
bestimmten Punkts in der Datei (Direktzugriff) und schließen
(close) der Datei.
Bsp.: Die RandomAccessFile-Klasse in Java26 dient zum Zugriff auf RandomAccess-Dateien.
2. Sammlungen mit sequentiellem Zugriff
Darunter versteht man lineare Listen (linear list), die Daten in sequentieller
Anordnung aufnehmen:
„list“-Kollektion
Daten
Ein meist größere Objektsammlung von Daten gleichen Typs.
Operationen
Das Durchlaufen der Liste mit Zugriff auf die einzelnen
Elemente beginnt an einem Anfangspunkt, schreitet danach von
Element zu Element fort bis der gewünschte Ort erreicht ist.
Operationen zum Einfügen und Löschen verändern die Größe der
Liste.
Stapel (stack) und Schlangen (queue) sind spezielle Versionen linearer Listen, deren
Zugriffsmöglichkeiten eingeschränkt sind.
„Stapel“-Kollektion
Daten
Eine Liste mit Elementen, auf die man nur über die Spitze
(„top“) zugreifen kann.
Operationen
Unterstützt werden „push“ und „pop“. „push“ fügt ein neues
Element an der Spitze der Liste hinzu, „pop“ entfernt ein
Element von der Spitze („top“) der Liste.
„Schlange“-Kollektion
26
Implementiert das Interface DataInput und DataOutput mit eigenen Methoden.
77
Algorithmen und Datenstrukturen
Daten
Eine Sammlung von Elementen mit Zugriff am Anfang und Ende
der Liste.
Operationen
Ein Element wird am Ende der Liste hinzugefügt und am Ende
der Liste entfernt.
Eine Schlange ist besonders geeignet zur Verwaltung von „Wartelisten“ und kann zur
Simulation von Wartesystemen eingesetzt werden. Eine Schlange kann ihre
Elemente nach Prioritäten bzgl. der Verarbeitung geordnet haben (priority
queue). Entfernt wird dann zuerst das Element, das die höchste Priorität besitzt.
„prioritätsgesteuerte Schlange“-Kollektion
Daten
Eine Sammlung von Elementen, von denen jedes Element eine
Priorität besitzt.
Operationen
Hinzufügen von Elementen zur Liste. Entfernt wird immer das
Element, das die höchste (oder niedrigste) Priorität besitzt.
1.3.2.2 Nichtlineare Kollektionen
1. Hierarchische Sammlung
Eine hierarchisch angeordnete Sammlung von Datenbehältern ist gewöhlich ein
Baum mit einem Ursprungs- bzw. Ausgangsknoten, der „Wurzel“ genannt wird. Von
besonderer Bedeutung ist eine Baumstruktur, in der jeder Baumknoten zwei Zeiger
auf nachfolgende Knoten aufnehmen kann. Diese Binärbaum-Struktur kann mit Hilfe
einer speziellen angeordneten Folge der Baumknoten zu einem binären Suchbaum
erweitert werden. Binäre Suchbäume bilden die Ausgangsbasis für das Speichern
großer Datenmengen.
„Baum“-Kollektion
Daten
Eine hierarchisch angeordnete Ansammlung von Knotenelementen,
die von einem Wurzelknoten abgeleitet sind. Jeder Knoten hält
Zeiger
zu
Nachfolgeknoten,
die
wiederum
Wurzeln
von
Teilbäumen sein können.
Operationen
Die Baumstruktur erlaubt das Hinzufügen und Löschen von
Knoten. Obwohl die Baumstruktur nichtlinear ist, ermöglichen
Algorithmen zum Ansteuern der Baumknoten den Zugriff auf die
in den Knoten gespeicherten Informationen.
Ein „heap“ ist eine spezielle Version, in der das kleinste bzw. größte Element den
Wurzelknoten besetzt. Operationen zum Löschen entfernen den Wurzelknoten,
dabei wird, wie auch beim Einfügen, der Baum reorganisiert.
Basis der Heap-Darstellung ist ein „array“ (Feldbaum), dessen Komponenten eine
Binärbaumstruktur überlagert ist. In der folgenden Darstellung ist eine derartige
Interpretation durch Markierung der Knotenelemente eines Binärbaums mit
Indexpositionen sichtbar:
78
Algorithmen und Datenstrukturen
[1]
wk1
[2]
[3]
wk2
wk3
[4]
[5]
wk4
wk5
[8]
[9]
wk8
[0]
[6]
[10]
wk9
wk6
[11]
wk10
[7]
[12]
wk11
wk7
[13]
wk12
wk13
[14]
[15]
wk14
wk15
wk1
wk2
wk3
wk4
wk5
wk6
wk7
wk8
wk9
wk10 wk11 wk12 wk13 wk14 wk15
[1}
[2]
[3]
[4]
[5]
[6]
[7]
[8]
[9]
[10] [11] [12] [13] [14] [15]
Abb. 1.3-10: Darstellung eines Feldbaums
Liegen die Knoten auf den hier in Klammern angegebenen Positionen, so kann man
von jeder Position I mit
Pl = 2 * I
Pr = 2 * I + 1
Pv = I div 2
auf die Position des linken (Pl) und des rechten (Pr) Nachfolgers und des Vorgängers
(Pv) gelangen. Ein binärer Baum kann somit in einem eindimensionalen Feld (array)
gespeichert werden, falls folgende Relationen eingehalten werden:
X[I] <= X[2*I]
X[I] <= X[2*I+1]
X[1] = min(X[I] .. X[N])
Anstatt auf das kleinste kann auch auf das größte Element Bezug genommen
werden
Aufbau des Heap: Ausgangspunkt ist ein „array“ mit bspw. folgenden Daten:
X[1]
40
X[2]
10
X[3]
30
X[4]
......
, der folgendermaßen interpretiert wird:
X[1]
40
X[2]
X[3]
10
30
Abb. 1.3-11: Interpretation von Feldinhalten im Rahmen eines binären Baums
79
Algorithmen und Datenstrukturen
Durch eine neue Anordnung der Daten in den Feldkomponenten entsteht ein „heap“:
X[1]
10
X[2]
X[3]
40
30
Abb. 1.3-12:
Falls ein neues Element eingefügt wird, dann wird nach dem Ordnen gemäß der
„heap“-Bedingung erreicht:
X[1]
10
X[2]
X[3]
40
30
X[4]
15
X[1]
10
X[2]
X[3]
15
30
X[4]
40
Abb.1.3-13: Das Einbringen eines Elements in einen Heap
Beim Löschen wird das Wurzelelement an der 1. Position entfernt. Das letzte
Element im „heap“ wird dazu benutzt, das Vakuum zu füllen. Anschließend wird
reorganisiert:
80
Algorithmen und Datenstrukturen
X[1]
10
X[2]
X[3]
15
30
X[4]
40
X[1]
40
X[2]
X[3]
15
30
X[1]
15
X[2]
X[3]
40
30
Abb. 1.3-14: Das Löschen eines Elements im Heap
Implementierung des Heap in Java27.
// Erzeugung mit optionaler Angabe zur Kapazitaet (Defaultwert: 100)
//
// ******************PUBLIC OPERATIONEN**********************************
// void insert( x )
--> Einfuegen x
// Comparable loescheMin( )--> Rueckgabe und entfernen des kleinsten
//
Elements
// Comparable findMin( )
--> Rueckgabe des kleinsten Elements
// boolean isEmpty( )
--> Rueckgabe: true, falls leer; anderenfalls
//
false
// boolean isFull( )
--> Rueckgabe true, falls voll; anderenfalls
// false
// void makeEmpty( )
--> Entfernen aller Elemente
Anwendung
Der Binary Heap kann zum Sortieren herangezogen werden.
Ein Heap kann aber auch in Simulationsprogrammen und vor allem zur
Implementierung von Priority Queues verwendet werden. Hier wird vielfach der
27
Vgl. pr13228
81
Algorithmen und Datenstrukturen
einfache Heap durch komplexere Datenstrukturen ( Binomial Heap, Fibonacci Heap)
ersetzt.
2. Gruppenkollektionen
Menge (Set)
Eine Gruppe umfaßt nichtlineare Kollektionen ohne jegliche Ordnungsbeziehung.
Eine Menge (set) einheitlicher Elemente ist. bspw. eine Gruppe. Operationen auf die
Kollektion „Set“ umfassen Vereinigung, Differenz und Schnittmengenbildung.
„Set“-Kollektion
Daten
Eine ungeordnete Ansammlung von Objekten ohne Ordnung
Operationen
Die binäre Operationen über Mitgliedschaft, Vereinigung,
Schnittmenge und Differenz bearbeiten die Strukturart „Set“.
Weiter Operationen testen auf Teilmengenbeziehungen.
Graph
Ein Graph (graph) ist eine Datenstruktur, die durch eine Menge Knoten und eine
Menge Kanten, die die Knoten verbinden, definiert ist.
„graph“-Kollektion
Daten
Eine Menge von Knoten und eine Menge verbindender Kanten.
Operationen
Der Graph kann Knoten hinzufügen bzw. löschen. Bestimmte
Algorithmen starten an einem gegebenen Knoten und finden alle
von diesem Knoten aus erreichbaren Knoten. Andere Algorithmen
erreichen jeden Knoten im Graphen über „Tiefen“ bzw.
„Breiten“ - Suche.
Ein Netzwerk ist spezielle Form eines Graphen, in dem jede Kante ein bestimmtes
Gewicht trägt. Den Gewichten können Kosten, Entfernungen etc. zugeordnet sein.
2. Datenstrukturen und Algorithmen in C++ und Java
82
Algorithmen und Datenstrukturen
Datenstrukturen und Algorithmen sind eng miteinander verbunden. Die Wahl der
richtigen Datenstruktur entscheidet über effiziente Laufzeiten. Beide erfüllen nur
alleine ihren Zweck. Leider ist die Wahl der richtigen Datenstruktur nicht so einfach.
Eine Reihe von schwierigen Problenem in der Informatik wurden deshalb noch nicht
gelöst, da eine passende Datenorganisation bis heute noch nicht gefunden wurde.
Wichtige Datenstrukturen (Behälterklassen, Collection, Container) werden in C++
und Java bereitgestellt. Auch Algorithmen befinden sich in diesen „Sammlungen“.
Sammlungen (Kollektionen) sind geeignete Datenstrukturen (Behälter) zum
Aufbewahren von Daten. Durch ihre vielfältigen Ausprägungen können sie zur
Lösung unterschiedlicher Aufgaben herangezogen werden. Ein Lösungsweg umfaßt
dann das Erzeugen eines solchen Behälters, das Einfügen, Modifizieren und
Löschen der Datenelemente. Natürlich steht dabei im Mittelpunkt der Zugriff auf die
Datenelemente, das Lesen der Dateninformationen und die aus diesen
Informationen resultierenden Schlußfolgerungen. Verallgemeinert bedeutet dies: Das
Suchen nach bestimmten Datenwerten, die in den Datenelementen der
Datensammlung (Kollektion) gespeichert sind.
Suchmethoden bestehen aus einer Reihe bestimmter Operationen. Im wesentlichen
sind dies
- Das Initialisieren der Kollektion ( die Bildung einer Instanz mit der Datenstruktur, auf der die Suche
erfolgen soll
- das Suchen eines Datenelements (z.B. eines Datensatzes oder mehrerer Datensätze)in der
Datensammlung mit einem gegebenen Kriterium (z.B. einem identifizierenden Schlüssel)
- das Einfügen eines neuen Datenelements. Bevor eingefügt werden kann, muß festgestellt werden,
ob das einzufügende Element in der Kollektion schon vorliegt.
- das Löschen eines Datenelements. Ein Datenelement kann nur dann gelöscht werden, falls das
Element in der Kollektion vorliegt.
Häufig werden Suchvorgänge in bestimmten Kollektionen (Tabellen) benötigt, die
Daten über identifizierende Kriterien (Schlüssel) verwalten. Solche Tabellen können
als Wörterbücher (dictionary) oder Symboltabellen implementiert sein. In einem
Wörterbuch sind bspw. die „Schlüssel“ Wörter der deutschen Sprache und die
Datensätze, die zu den Wörtern gehörenden Erläuterungen über Definition,
Aussprache usw. Eine Symboltabelle beschreibt die in einem Programm
verwendeten Wörter (symbolische Namen). Die Datensätze sind die Deklarationen
bzw. Anweisungen des Programms. Für solche Anwendungen sind nur zwei weitere
zusätzliche Operationen interessant:
- Verbinden (Zusammenfügen) von Kollektionen, z.B. von zwei Wörterbüchern zu einem großen
Wörterbuch
- Sortieren von Sammlungen, z.B. des Wörterbuchs nach dem Schlüssel
Die Wahl einer geeigneten Datenstruktur (Behälterklasse) ist der erste Schritt. Im
zweiten Schritt müssen die Algorithmen implementiert werden. Die StandardBibliotheken der Programmiersprachen C++ und Java bieten Standardalgorithmen
an.
83
Algorithmen und Datenstrukturen
2.1 Datenstrukturen und Algorithmen in C++
2.1.1 Die C++-Standardbibliothek und die STL
Der Entwurf der Standard-C++-Bibliothek wurde relativ spät um einen Teil erweitert,
der Standard Template Librarary (STL) genannt wurde, da er u.a. viele Templates
zur Verfügung stellt. Die STL erhöht die Einsetzbarkeit von C++ enorm, da
wesentliche, immer wiederkehrende Templates dort festgelegt sind.
Die wesentlichen Komponenten der STL sind
- Algorithmen
z.B. Sortieren
- Container
, wie bspw. Listen oder Mengen
- Iteratoren
, mit deren Hilfe Container traversiert werden können
- Funktionsobjekte
, die eine Funktion einkapseln, die von anderen Komponenten benutzt werden kann.
-Adaptoren
durch die eine andere Schnittstelle von einer Komponente gebildet werden kann.
2.1.2 Container
2.1.2.1 Grundlagen
Ein Container ist ein Objekt, das der Speicherung anderer Objekte dient, z.B. eine
Liste, ein Array oder eine Schlange. Mit der Containert-Klasse wird der Typ eines
solchen Objekts definiert. Objekte, die ein Containert speichert, sind die Elemente.
Der Typ der Elemente ist der Elementtyp.
Da Container-Klassen für einen beliebigen Elementtyp definiert werden sollen, sind
in der Standard-C++-Bibliothek Templates für Container-Klassen definiert. Es wird
unterschieden zwischen
- Sequenzen, in denen jedes Element eine Position besitzt, wie z.B. Listen
- Adaptoren für Sequenzen, die einer Sequenz eine andere Schnittstelle verleihen, wie z.B. ein Stapel
- assoziative Container, die jeweils eine Menge von Elementen verwalten, auf die über ihren Inhalt
(Wert) zugegriffen wird (z.B. set und map).
Sequenzen
Adaptoren von Sequenzen
Assoziative Container
28
Template für Container-Klasse
template <class T28> class vector
template <class T> class list
template <class T> class deque
template <class T,classContainer=deque<T> > class stack
template <class T, class Container = deque<T> > class queue
template <class T,class Container=deque<T>,class Compare=
less<typname Container::value_type> > class priority_queue
template <class Key29,class Compare30=less<Key> > class set
T ist jeweils der Elementtyp
84
Algorithmen und Datenstrukturen
template <class Key,class T,class Compare = less<Key> >class map
Abb.: Templates von Container-Klassen
Die Stärke der STL ist, dass, soweit möglich, alle Container die gleiche Schnittstelle
besitzen. Die Schnittstelle wird (leider) nicht in einer abstrakten Klasse definiert
sondern durch eine Tabelle, in der die gemeinsamen Eigenschaften aller Container
festgelegt werden.
Konstruktoren
Destruktoren
Zuweisung
Traversieren
Vergleiche
Größe
Typen
X u;
X()
X(a)
Xu=a
(&a)->~X()
r=a
a.begin()
a.end()
a == b
a != b
a<b
a>b
a <= b
a >= b
a,size()
a.empty()
X::value_type
X::iterator
X::const_iterator
X::size_type
u wird leerer Container
Leerer Container
Neuer Container mit X(a) == a wird erzeugt
X u; u = a;
Der Inhalt des Containers a wird gelöscht
Dem Container mit Referenz r wird er Container a zugewiesen
liefert einen Iterator auf das erste Element des Containers zurück
liefert einen Iterator auf den gedachten Nachfolger des letzten
Elements des Containers a zurück
Prüfen, ob die Container a und b elementweise gleich sind
!(a == b)
Anzahl der Elemente in a
a.size() == 0
Elementtyp
Typ eines Iterators, der auf ein Element zeigt
Typ eines Iterators, der auf ein konstantes Element zeigt
Typ nicht negativer Werte von bspw. Iteratordifferenzen und von
size()
Abb.: Wichtige Eigenschaften eines Containers
2.1.2.2 STL-Container-Anwendungen
1. "priority_queue" container adaptor
neue Einträge
Eintrag
Eintrag
Eintrag
Eintrag
Eintrag
pop()
push()
niedrigste
...........
dritthöchste zweithöchste höchste
Priorität
Abb.: Eine Priority Queue
Die "priority_queue" nutzt die "Container"-Klassen "vector"
deque.Typische Deklarationen zu einer "priority_queue" sind:
priority_queue< vector<node> > x;
priority_queue< deque<string>,case_insensitive_compare > y;
29
30
Key ist der Typ der Schlüssel der abgespeicherten Elemente
Compare ist eine Vergleichsklasse
85
bzw.
Algorithmen und Datenstrukturen
priority_queue< vector<int>, greater<int> > z;
Die Implementierung der "priority-queue" in der STL-Version von Hewlett
Packard zeigt folgende Aussehen:
template <class Container, class Compare> class priority_queue
{
protected :
Container c;
Compare comp;
…
public :
bool empty() const { return c.empty(); }
size_type size() const { return c.size() }
value_type& top() const { return c.front(); }
void push(const value_type& x
{
c.push_back(x);
push_heap(c.begin(), c.end(), comp);
}
void pop()
{
pop_heap(c.begin(), c.end(), comp);
c.pop_back();
}
}
Die STL-Funktionen push_heap() und pop_heap() werden zur Implementierung
herangezogen. push() fügt das neue Element an das Ende des Container,
push_heap() bringt danach das Element an seinen richtigen Platz. pop() ruft
zuerst pop_heap() auf. pop_heap() bringt das Element an das Ende des
Container, pop_back() entfernt das Element und verkürzt den Container.
Man kann aus dieser Anwendung ersehen, wie leicht es wurde, eine ContainerKlasse mit Hilfe der STL einzurichten.
Die "priority_queue" der STL ist ein Adapter und stützt sich ab auf die Klassen
vector bzw. deque. Präsentiert wird ein Interface, das zusätzlich zu den üblichen
Container-Methoden fünf Member-Funktionen bereitstellt.
2. Huffman Coding31 mit "priority_queue"-Container
Gegeben ist eine Datei, z.B. mit folgendem Inhalt
AAAAAAAAAAAAAA
BBB
C
D
E
F
GGG
HHHH
Gesucht ist eine geeignete Binärcodierung (Code variabler Länge), die die Häufigkeit
des Auftretens der Zeichen berücksichtigt.
Der Huffman-Algorithmus relisiert die Binärcodierung mit Hilfe eines Binärbaums, der
folgendes Aussehen haben könnte:
31
vgl. 3.1.2.2
86
Algorithmen und Datenstrukturen
28
0
1
14
14
0
1
A
6
8
0
1
0
3
3
4
1
4
0
B
G
1
H
2
2
0
Abb.: Huffman-Codierungs-Baum
1
1
1
C
F
0
1
1
D
1
E
Jeder Knoten hat ein Gewicht, der den Platz im Huffman-Codierungs-Baum festlegt.
Die STL "priority_queue" wird zunächst zur Aufnahme der eingelesenen Zeichen
benutzt. Je nach Häufigkeit des Vorkommens der Zeichen erfolgt die Einordnung.
Danach werden die beiden Einträge (Knoten) mit der niedrigsten Priorität entfernt,
ein neuer interner Knoten (Addition der Gewichte) gebildet. Der neue Knoten wird
wiedern in die priority_queue gebracht. Das wird solange wiederholt bis nur ein
einziger Knoten in der priority_queue vorliegt.
87
Algorithmen und Datenstrukturen
2.1.3 Iteratoren
Bei allen Containern werden Iteratoren verwendet, um einen Container zu
traversieren oder evtl. auch, um auf ein bestimmtes Element zu verweisen. Die
einzelnen Container stellen bidirektionale Iteratoren oder Iteratoren zum wahlfreiem
Zugriff bereit.
vector<T>::iterator
list<T>::iterator
deque<T>::iterator
set<T,Compare>::iterator
multiset<T,Compare>::iterator32
map<Key,T,Compare33>::iterator
multimap<Key,T,Compare>::iterator
random_access_iterator
bidirectional-iterator
random_access_iterator
bidirectional_iterator
bidirectional_iterator
bidirectional_iterator
bidirectional_iterator
- Iteratoren sind Verallgemeinerungen von Zeigern
- Zeiger sind Iteratoren im Sinne der STL und können überall eingesetzt werden, wo
Iteratoren benutzt werden
- Vorwärtsiteratoren unterstützen Prä- und Postinkrement (++).
- Rückwärtsiteratoren unterstützen Prä- und Postdekrement (--). Nicht alle Container
bieten Rückwärtsiteratoren
- Random-Access-Iteratoren unterstützen Zeiger-Arithmetik
- Die Algorithmen der STL operieren auf Iteratoren.
2.1.4 Algorithmen
Algorithmen operieren auf Containern oder Iteratoren. Ihr Verhalten kann über
Template-Argumente gesteuert werden.
32
33
Iteratoren des Typs map bzw. multimap liefern Werte des Typs pair<Key,Value>
Compare definiert eine Ordnungsfunktion auf Key
88
Algorithmen und Datenstrukturen
2.2 Datenstrukturen und Algorithmen in Java
2.2.1 Durchwandern von Daten mit Iteratoren
Bei Datenstrukturen gibt es eine Möglichkeit, gespeicherte Daten unabhängig von
der Implementierung immer mit der gleichen Technik abzufragen. Bei
Datenstrukturen handelt es sich meistens um Daten in Listen, Bäumen oder
ähnlichem und oft wird nur die Frage nach der Zugehörigkeit eines Worts zum
Datenbestand gestellt (z.B. „Gehört das Wort dazu?“). Auch die Möglichkeit Daten in
irgendeiner Form aufzuzählen, ist eine häufig gestellte Aufgabe. Hierfür bieten sich
Iteratoren an. In Java umfaßt das Interface Enumeration die beiden Funktionen
hasMoreElements() und nextElement(), mit denen durch eine Datenstruktur
iteriert werden kann.
public interface Enumeration
{
public boolean hasMoreElements();
// Test, ob noch ein weiteres Element aufgezählt werden kann
public Object nextElement() throws NoSuchElementException;
/* setzt den internen Zeiger auf das nächste Element, d. h. liefert das
das nächste Element der Enumertion zurück. Diese Funktion kann eine
NoSuchException auslösen, wenn nextElement() aufgerufen wird, obwohl
hasMoreElements() unwahr ist
*/
}
Die Aufzählung erfolgt meistens über
for (Enumeration e = ds.elements(); e.hasMoreElements(); )
System.out.println(e.nextElements());
Die Datenstruktur ds besitzt eine Methode elements(), die ein Enumeration-Objekt
zurückgibt, das die Aufzählung erlaubt.
2.2.2 Die Klasse Vector
class java.util.Vector extends AbstractList
implements List, Cloneable, Serializable
Die Klasse Vector beschreibt ein Array mit variabler Länge. Objekte der Klasse
Vector sind Repräsentationen einer linearen Liste. Die Liste kann Elemente
beliebigen Typs enthalten, ihre Länge ist zur Laufzeit veränderbar (Array mit
variabler Länge). Vector erlaubt das Einfügen von Elementen an beliebiger Stelle,
bietet sequentiellen und wahlfreien Zugriff auf die Elemente. Das JDK realisiert
Vector als Array von Elementen des Typs Object. Der Zugriff auf Elemente erfolgt
über Indizes. Es wird dazu aber kein Operator [], sondern es werden Methoden
benutzt, die einen Index als Parameter annehmen.
Anlegen eines neuen Vektors (Konstruktor): public Vector()
public Vector(int initialCapacity,int capacityIncrement)
// Ein Vector vergrößert sich automatisch, falls mehr Elemente aufgenommen werden, als
// ursprünglich vorgesehen (Resizing). Dabei sollen initialCapacity und capacityIncrement
// passend gewählt werden.
89
Algorithmen und Datenstrukturen
Einfügen von Elementen: public void addElement(Object obj)
// Anhängen an des Ende der bisher vorliegenden Liste von Elementen
Eigenschaften: public final boolean isEmpty()
// Prüfen, ob der Vektor leer ist
public final int size()
// bestimmt die Anzahl der Elemente
public final int capacity()
// bestimmt die interne Größe des Arrays. Sie kann mit ensureCapacity() geändert
// werden
Einfügen an beliebiger Stelle innerhalb der Liste:
public void insertElementAt(Object obj, int index) throws
ArrayIndexOutOfBoundsException
// fügt obj an die Position index in den „Vector“ ein.
Zugriff auf Elemente: Für den sequentiellen Zugriff steht ein Iterator zur Verfügung. Wahlfreier Zugriff erfolgt
über:
public Object firstElement() throws ArrayIndexOutOfBoundException;
public Object lastElement() throws ArrayIndexOutOfBoundException;
public Object elementAt(int index) throws ArrayIndexOutOfBoundException;
firstElement() liefert das erste, lastElement() das letzte Element- Mit elementAt() wird auf das
Element an der Position index zugegriffen. Alle 3 Methoden verursachen eine Ausnahme, wenn das
gewünschte Element nicht vorhanden ist.
Arbeitsweise des internen Arrays. Der Vector vergrößert sich automatisch, falls mehr Elemente aufgenommen
werden. Die Operation heißt Resizing.
Die Größe des Felds. Mit capacity() erhält man die interne Größe des Arrays. Sie kann mit
ensureCapacity() geändert werden. ensureCapacity(int minimumCapacity) bewirkt bei einem
Vector, daß er mindestens minCapacity Elemente aufnehmen soll.
Der Vektor verkleinert nicht die aktuelle Kapazität, falls sie schon höher als minCapacity ist. Zur Veränderung
dieser Größe, dient die Methode trimToSize(). Sie reduziert die Kapazität des Vectors auf die Anzahl der
Elemente, die gerade im Vector sind.
Die Anzahl der Elemente kann über die Methode size() erfragt werden. Sie kann über setSize(int
newSize) geändert werden. Ist die neue Größe kleiner als die alte, so werden die Elemente am Ende des
Vectors abgeschnitten. Ist newSize größer als die alte Größe, werden die neu angelegten Elemente mit null
initialisiert.
Bereitstellen des Interface Enumerartion. In der Klasse Vector liefert die Methode public Enumeration
elements() einen Enumerator (Iterator) für alle Elemente, die sich in Vector befinden.
90
Algorithmen und Datenstrukturen
Vector
<< Konstruktoren >>
public Vector()
// Ein Vector in der Anfangsgröße von 10 Elementen wird angelegt
public Vector(int startKapazitaet)
// Ein Vector enthält Platz für startKapazitaet Elemente
public Vector(int startKapazitaet, int kapazitaetsSchrittweite)
<< Methoden >>
public final synchronized Object elementAt(int index)
// Das an der Stelle index befindliche Objekt wird zurückgegeben
public final int size()
public final synchronized Object firstElement()
public final synchronized Object lastElement();
public final synchronized void insertElementAt(Object obj, int index)
// fügt Object obj an index ein und verschiebt die anderen Elemente
public final synchronized void setElementAt(Object obj, int index)
public final synchronized copyInto(Object einArray[])
// kopiert die Elemente des Vektors in das Array einArray
// Falls das bereitgestellte Objektfeld nicht so groß ist wie der Vektor,
// dann tritt eine ArrayIndexOutOfBounds Exception
public final boolean contains(Object obj)
// sucht das Element, liefert true zurück wenn o im Vector vorkommt
public final int indexOf(Object obj)
// sucht im Vector nach dem Objekt obj. Falls obj nicht in der Liste ist, wird
// -1 übergeben
public final int lastIndexOf(Object obj)
public final synchronized boolean removeElement(Object obj)
// entfernt obj aus der Liste. Konnte es entfernt werden, wird true
// zurückgeliefert
public final synchronized void removeElementAt(int index)
// entfernt das Element an Stelle index
public final synchronized void removeAllElements()
// löscht alle Elemente
public final int capacity()
// gibt an, wieviel Elemente im Vektor Patz haben
// (, ohne daßautomatische Größenanpassung erfolgt)
public synchronized Object clone()
// Implementierung der clone()-methode von Object, d.h. eine Referenz
// das kopierte Feld wird zurückgegeben. Die Kopie ist flach.
public final synchronized String toString()
Abb.: Die Klasse Vector
91
Algorithmen und Datenstrukturen
2.2.3 Die Klasse Stack
class java.util.Stack extends Vector
Ein Stack ist eine nach dem LIFO-Prinzip arbeitende Datenstruktur. Elemente werden vorn (am vorderen Ende
der Liste) eingefügt und von dort auch wieder entnommen. In Java ist ein Stack eine Ableitung von Vector mit
neuen Zugriffsfunktionen für die Implementierung des typischen Verhaltens von einem Stack.
Konstruktor: public Stack();
Hinzufügen neuer Elemente: public Object push(Object item);
Zugriff auf das oberste Element:
public Object pop();
// Zugriff und Entfernen des obersten Element
public Object peek()
// Zugriff auf das oberste Element
Suche im Stack: public int search(Object o)
// Suche nach beliebigem Element,
// Rueckgabewert: Distanz zwischen gefundenem und
//
obersten Stack-Element bzw. –1,
//
falls das Element nicht da ist.
Test: public boolean empty()
// bestimmt, ob der Stack leer ist
Vector
Stack
public Stack()
public Object push(Object obj)
public synchronized Object pop()
public synchronized Object peek()
public synchronized int search(Object obj)
public boolean empty()
Abb.: Die Klasse Stack
Anwendungen:
1. Umrechnen von Dezimalzahlen in andere Basisdarstellungen
Aufgabenstellung: Defaultmäßig werden Zahlen dezimal ausgegeben. Ein Stapel, der Ganzzahlen
aufnimmt, kann dazu verwendet werden, Zahlen bezogen auf eine andere Basis als 10 darzustellen.
Die Funktionsweise der Umrechnung von Dezimalzahlen in eine Basis eines anderen Zahlensystem
zeigen die folgenden Beispiele:
92
Algorithmen und Datenstrukturen
2810  3  8  4  34 8
72 10  1  64  0  16  2  4  0  1020 4
5310  1  32  1  16  0  8  1  4  0  2  1  1101012
Mit einem Stapel läßt sich die Umrechnung folgendermaßen unterstützen:
6
leerer Stapel
n  355310
7
7
4
4
4
1
1
1
1
n%8=1
n/8=444
n%8=4
n/8=55
n  444 10
n%8=7
n/8=6
n  5510
n  610
n%8=6
n/6=0
n  010
Abb.: Umrechnung von 355310 in 67418 mit Hilfe eines Stapel
Algorithmus zur Lösung der Aufgabe:
1) Die am weitesten rechts stehende Ziffer von n ist n%b. Sie ist auf dem Stapel abzulegen.
2) Die restlichen Ziffern von n sind bestimmt durch n/b. Die Zahl n wird ersetzt durch n/b.
3) Wiederhole die Arbeitsschritte 1) und 2) bis keine signifikanten Ziffern mehr übrig bleiben.
4) Die Darstellung der Zahl in der neuen Basis ist aus dem Stapel abzulesen. Der Stapel ist zu diesem
Zweck zu entleeren.
Implementierung: Das folgende kleine Testprogramm 34 realisiert den Algorithmus und benutzt dazu
eine Instanz von Stack.
import java.util.*;
public class PR61210
{
public static void main(String[] args)
{
int zahl = 3553;
// Dezimalzahl
int b
= 8;
// Basis
Stack s = new Stack();
// Stapel
do
{
s.push(new Integer(zahl % b));
zahl /= b;
} while (zahl != 0);
while (!s.empty())
{
System.out.print(s.pop());
}
System.out.println();
}
}
34
pr61210
93
Algorithmen und Datenstrukturen
Ein Stack ist ein Vector. Die Vector-Klasse wird von der Klasse Stack erweitert. Das
ist sicherlich nicht immer besonders sinnvoll. Funktionen, die im Gegensatz zur
Leistungsfähigkeit eines Stapels stehen sind add(), addAll(), addElement(),
capacity(), clear(), clone(), contains(), copyInto(), elementsAt(), .... .
2.2.4 Die Klasse Bitset für Bitmengen
class java.util.BitSet implements Cloneable, Serializable
Die Klasse Bitset bietet komfortable Möglichkeiten zur bitweisen Manipulation von
Daten.
Bitset anlegen und füllen. Mit zwei Methoden lassen sich die Bits des Bitsets leicht
ändern: set(bitNummer) und clear(bitNummer).
Mengenorintierte Operationen. Das Bitset erlaubt mengenorientierte Operationen mit
einer weiteren Menge.
BitSet
public void and(BitSet bs)
public void or(BitSet bs)
public void xor(BitSet bs)
public void andNot(Bitset set)
// löscht alle Bits im Bitset, dessen Bit in set gesetzt sind
public void clear(int index)
// Löscht ein Bit. Ist der Index negativ, kommt es
// zur Auslösung von IndexOutOfBoundsException
public void set(int index)
// Setzt ein Bit. Ist der Index negativ, kommt es
// zur Auslösung von IndexOutOfBoundsException
public boolean get(int index)
// liefert den Wert des Felds am übergebenen Index,
// kann IndexOutOfBoundsException auslösen.
public int size()
public boolean equals(Object o)
// Vergeicht sich mit einem anderen Bitset-Objekt o.
Abb.: Die Klasse BitSet
94
Algorithmen und Datenstrukturen
2.2.5 Die Klasse Hashtable und assoziative Speicher
Eine Hashtabelle (Hashtable) ist ein assoziativer Speicher, der Schlüssel (keys) mit
Werten verknüpft. Die Datenstruktur ist mit einem Wörterbuch vergleichbar. Die
Hashtabelle arbeitet mit Schlüssel/Werte Paaren. Aus dem Schlüssel wird nach
einer Funktion – der sog. Hashfunktion – ein Hashcode berechnet. Dieser dient als
Index für ein internes Array. Dieses Array hat zu Anfang ein feste Grösse. Leider hat
dieses Technik einen entscheidenden Nachteil. Besitzen zwei Wörter denselben
Hashcode, dann kommt es zu einer Kollision. Auf ihn muß die Datenstruktur
vorbereitet sein. Hier gibt es verschiedene Lösungsansätze. Die unter Java
implementierte Variante benutzt eine verkettete Liste (separate Chaining). Falls eine
Kollision auftritt, so wird der Hashcode beibehalten und der Schlüssel bzw. Wert in
einem Listenelement an den vorhandenen Eintrag angehängt. Wenn allerdings
irgendwann einmal eine Liste durchsucht werden muß, dann wird die Datenstruktur
langsam. Ein Maß für den Füllgrad ist der Füllfaktor (Load Factor). Dieser liegt
zwischen 0 und 100 %. 0 bedeutet: kein Listenelement wird verwendet. 100 %
bedeutet: Es ist kein Platz mehr im Array und es werden nur noch Listen für alle
zukommenden Werte erweitert. Der Füllfaktor sollte für effiziente Anwendungen nicht
höher als 75% sein. Ist ein Füllfaktor nicht explizit angegeben, dann wird die
Hashtabelle „rehashed“, wenn mehr als 75% aller Plätze besetzt sind.
class java.util.Hashtable extends Dictionary implements Map,
Cloneable, Serializable
Erzeugen von einem Objekt der Klasse HashTable:
public Hashtable()
/* Die Hashtabelle enthält eine Kapazität von 11 Einträgen und einen Füllfaktor von 75 % */
public HashTable(int initialCapacity)
/* erzeugt eine Hashtabelle mit einer vorgebenen Kapazität und dem Füllfaktor 0.75 */
public HashTable(int initialCapacity, float loadFactor)
/* erzeugt eine Hashtabelle mit einer vorgebenen Kapazität und dem angegebenen Füllfaktor */
Daten einfügen: public Object put(Object key, Object value)
/* speichert den Schlüssel und den Wert in der Hashtabelle. Falls sich zu dem
Schlüssel schon ein Eintrag in der Hashtabelle befand, so wird dieser
zurückgegeben. Anderenfalls ist der Rückgabewert null. Die Methode ist
vorgegeben vom Interface Map. Es überschreibt die Methode von der Superklasse
Dictionary. */
Daten holen: public Object get(Object key)
Schlüssel entfernen. public Object remove(Object key)
Löschen der Werte. public void clear()
Test. public boolean containsKey(Object key)
// Test auf einen bestimmten Schlüssel
public boolean containsValue(Object value)
// Test auf einen bestimmten Wert
Aufzählen der Elemente. Mit keys() und elements() bietet die Hashtabelle zwei
Methoden an, die eine Aufzählung zurückgeben:
public Enumeration keys()
// liefert eine Aufzählung aller Schlüssel, überschreibt keys() in Dictionary.
public Enumeration elements()
// liefert eine Aufzählung der Werte, überschreibt elements() in Dictionary
Wie üblich liefern beide Iteratoren ein Objekt, welches das Interface Enumeration implementiert. Der Zugriff
erfolgt daher mit Hilfe der Methoden hasMoreElements() und nextElement().
95
Algorithmen und Datenstrukturen
Dictionary
{abstract}
public abstract Object put (Object key, Object value)
publicc abstract Object get(Object key)
public abstract Enumeration elements()
public abstract Enumeration keys()
public abstract int size()
public absztract boolean isEmpty()
public abstract Object remove(Object key)
Hashtable
Map
<< Konstruktor >>
public Hashtable(int initialKapazitaet)
public Hashtable(int initialKapazitaet, float Ladefaktor)
public Hashtable()
<< Methoden >>
public synchronized boolean contains(Object wert)
public synchronized boolean containsKey(Object key)
public synchronized void clear()
public synchronized Object clone()
protected void rehash()
public synchronized String toString()
Properties
<< Konstruktor >>
public Properties()
// legt einen leeren Container an
public Properties(Properties defaults)
// füllt eine Property-Liste mit den angegebenen Default-Werten
<< Methoden >>
public String getProperty(String key)
public String getProperty(String key, String defaultKey)
public synchronized void load(InputStream in) throws IOException
// Hier muß ein InputStream übergeben werden, der die daten der
// Property-Liste zur Verfügung stellt.
public synchronizred void save(OutputStream out, String header)
public void list(PrintStream out)
public void list(PrintWriter out)
public Enumeration propertyNames()
// beschafft ein Enumerations-Objekt mit denen Eigenschaften
// der Property-Liste aufgezählt werden können
Abb.: Die Klassen Hashtable und Properties
Die Klasse Hashtable ist eine Konkretisierung der abstrakten Klasse Dictionary. Diese Klasse beschreibt
einen assoziativen Speicher, der Schlüssel auf Werte abbildet und über den Schlüsselbegriff einen effizienten
Zugriff auf den Wert ermöglicht. Einfügen und der Zugriff auf Schlüssel erfolgt nicht auf der Basis des Operators
96
Algorithmen und Datenstrukturen
„==“, sondern mit Hilfe der Methode „equals“. Schlüssel müssen daher lediglich inhaltlich gleich sein, um als
identisch angesehen zu werden.
Bsp.35: Hashtabelle zum Test der von Zufallszahlen der Methode Math.random().
import java.util.*;
class Zaehler
{
int i = 1;
public String toString()
{
return Integer.toString(i);
}
}
public class Statistik
{
public static void main(String args[])
{
Hashtable h = new Hashtable();
for (int i = 0; i < 10000; i++)
{
// Erzeuge eine Zahl zwischen 0 und 20
Integer r = new Integer((int)(Math.random() * 20));
if (h.containsKey(r))
((Zaehler) h.get(r)).i++;
else h.put(r,new Zaehler());
}
System.out.println(h);
}
}
Die Klasse Hashtable benutzt das Verfahren der Schlüsseltransformation (Hash-Funktion) zur Abbildung von
Schlüsseln auf Indexpostionen eines Arrays. Die Kapazität der Hash-Tabelle gibt die Anzahl der Elemente an,
die insgesamt untergebracht werden können. Der Ladefaktor zeigt an, bei welchem Füllungsgrad die HashTabelle vergrößert werden muß. Das Vergrößern erfolgt automatisch, falls die Anzahl der Elemente innerhalb der
Tabelle größer ist als das Produkt aus Kapazität und Ladefaktor. Seit dem JDK 1.2 darf der Ladefaktor auch
größer als 1 sein. In diesem Fall wird die Hash-Tabelle erst dann vergrößert, wenn der Füllungsgrad größer als
100% ist und bereits ein Teil der Elemente in den Überlaufbereichen untergebracht wurde.
Die Klasse Hashtable ist eine besondere Klasse für Wörterbücher. Ein Wörterbuch
ist eine Datenstruktur, die Elemente miteinander assoziiert. Das Wörterbuchproblem
ist das Problem, wie aus dem Schlüssel möglichst schnell der zugehörige Wert
konstruiert wird. Die Lösung des Problems ist: Der Schlüssel wird als Zahl kodiert
(Hashcode) und dient in einem Array als Index. An einem Index hängen dann noch
die Werte mit gleichem Hashcode als Liste an.
35
vgl. pr13215
97
Algorithmen und Datenstrukturen
2.2.6 Die abstrakte Klasse Dictionary
Die Klasse Dictionary ist eine abstrakte Klasse, die Methoden anbietet, wie
Objekte (also Schlüssel und Wert) miteinander assoziiert werden:
public abstract Object put(Object key,Object value)
// fügt den Schlüssel key mit dem verbundenen Wert value in das Wörterbuch
// ein
public abstract Object get(Object key)
//
//
//
//
liefert das zu key gehörende Objekt zurück. Falls kein Wert mit dem
Schlüssel verbunden ist, so liefert get() eine null. Eine null als
Schlüssel oder Wert kann nicht eingesetz werden. In put() würde das zu
einer NullPointerException führen.
public abstract Object remove(Object key)
// entfernt ein Schlüssel/Wertepaar aus dem Wörterbuch. Zurückgegeben wird
// der assoziierte Wert.
public abstract boolean isEmpty()
// true, falls keine Werte im Wörterbuch
public int size()
gibt zurück, wie viele Elemente aktuell im Wörterbuch sind.
public abstract Enumeration keys()
// liefert eine Enumeration für alle Schlüssel
public abstract Enumeration elements()
// liefert eine Enumeration über alle Werte.
2.2.7 Die Klasse Properties
Die Properties Klasse ist eine Erweiterung von Hashtable. Ein Properties Objekt
erweitert die Hashtable um die Möglichkeit, sich unter einem wohldefinierten Format
über einen Strom zu laden und zu speichern.
Erzeugen. public Properties()
// erzeugt ein leeres Propertes Objekt ohne Worte.
public Properties(Properties p)
// erzeugt ein leeres Properties Objekt mit Standard-werten aus den
// übergebenen Properties
Die Methode getProperty(). public String getProperty(String s)
// sucht in den Properties nach der Zeichenkette
public String getProperty(String key, String default)
// sucht in den Properties nach der Zeichenkette key. Ist dieser nicht
// vorhanden, wird der String default zurückgegeben
Eigenschaften ausgeben. Die Methode list() wandert durch die Daten und gibt
sie auf einem PrintWriter aus:
public void list(PrintWriter pw)
// listet die Properties auf dem PrintWriter aus.
98
Algorithmen und Datenstrukturen
2.2.8 Collection API
Die Java 2 Plattform hat Java erweitert um das Collection API. Anstatt Collection
kann man auch Container (Behälter) sagen. Ein Container ist ein Objekt, das
wiederum Objekte aufnimmt und die Verantwortung für die Elemente übernimmt. Im
„util“-Paket befinden sich sechs Schnittstellen, die grundlegende Eigenschaften
der Containerklassen definieren.
Das in Java 1.2 enthaltene Collections Framework beinhaltet im Wesentlichen drei Grundformen: Set, List
und Map. Jede dieser Grudformen ist als Interface implementiert. Die Interfaces List und Set sind direkt aus
Collection abgeleitet. Es gibt auch noch eine abstrakte Implementierung des Interface, mit dessen Hilfe das
Erstellen eigener Collections erleichtert wird. Bei allen Collections, die das Interface Collection implementieren,
kann ein Iterator zum Durchlaufen der Elemente mit der Methode „iterator()“ beschafft werden.
Zusätzlich fordert die JDK 1.2-Spezifikation für jede Collection-Klasse zwei Konstruktoren:
- Einen parameterlosen Konstruktor zum Anlegen einer neuen Collection.
- Ein mit einem einzigen Collection-Argument ausgestatteter Konstruktor, der eine neue Collection anlegt und
mit den Elementen der als Argument übergebenen Collection auffüllt.
2.2.8.1 Die Schnittstellen Collection, Iterator, Comparator
Das Interface Collection bildet die Basis der Collection-Klasse und –Interfaces des JDK 1.2. Alle
Behälterklassen implementieren das Collection Interface und geben den Klassen damit einen äußeren Rahmen.
99
Algorithmen und Datenstrukturen
Das Interface Collection
<< interface >>
Collection
public void clear();
// Optional: Löscht alle Elemente in dem Container. Eird dies vom Container nicht unterstützt,
// kommt es zur UnSupportedOperationException
public boolean add(Object o);
// Optional: Fügt ein Objekt dem Container hinzu und gibt true zurück, falls sich das Element
// einfügen läßt. Gibt false zurück, falls schon ein Objektwert vorhanden ist und doppelte Werte
// nicht erlaubt sind.
public boolean addAll(Collection c);
// fügt alle Elemente der Collection c dem Container hinzu
public boolean remove(Object o);
// Entfernen einer einzelnen Instanz. Rückgabewert ist true, wenn das Element gefunden und
// entfernt werden konnte
public boolean removeAll(Collection c);
// Oprtional: Entfernt alle Objekte der Collection c aus dem Container
public boolean contains(Object o);
// liefert true, falls der Container das Element enthält
// Rückgabewert ist true, falls das vorgegebene Element gefunden werden konnte
public boolean containsAll(Collection c);
// liefert true, falls der Container alle Elemente der Collection c enthält.
public boolean equals(Object o);
// vergleicht das angegebene Objekt mit dem Container, ob die gleichen Elemente vorkommen.
public boolean isEmpty();
// liefert true, falls der Container keine Elemente enthält
public int size();
// gibt die Größe des Containers zurück
public boolean retainAll(Collection c);
public Iterator iterator();
publicObject [] toArray();
// gibt ein Array mit Elementen des Containers zurück
public Object [] toArray(Object [] a);
public int hashCode();
// liefert den Hashwert des Containers
public String toString()
// Rückgabewert ist die Zeichenketten-Repräsentation der Kollektion.
Abb.: Das Interface Collection
Die abstrakte Basisklasse AbstractCollection implementiert die Methoden des Interface Collection (ohne
iterator() und size()). AbstractCollection ist die Basisklasse von AbstractList und
AbstractSet.
100
Algorithmen und Datenstrukturen
Das Interface Iterator
<< interface >>
Iterator
public boolean hasNext();
// gibt true zurück, wenn der Iterator mindestens ein weiteres Element enthält.
public Object next();
// liefert das nächste Element bzw. löst eine Ausnahme des Typs NoSuchElementException
// aus, wenn es keine weiteren Elemente gibt
public void remove();
// entfernt das Element, das der Iterator bei next() geliefert hat.
Abb.:
Bei allen Collections, die das Interface Collection implementieren, kann ein Iterator zum Durchlaufen der
Elemente mit der Methode „iterator()“ beschafft werden.
Das Interface Comparator
Vergleiche zwischen Objekten werden mit speziellen Objekten vorgenommen, den Comparatoren. Ein konkreter
Comparator implementiert die folgende Schnittstelle.
<< interface >>
java.util.Comparator
public int compare(Object o1, Object o2)
// vergleicht 2 Argumente auf ihre Ordnung
public boolean equals(Object arg)
// testet, ob zwei Objekte bzgl. des Comparator-Objekts gleich sind
Abb.
101
Algorithmen und Datenstrukturen
2.2.8.2 Die Behälterklassen und Schnittstellen des Typs List
Behälterklassen des Typs List fassen eine Menge von Elementen zusammen, auf die sequentiell oder über
Index (-positionen) zugegriffen werden kann. Wie Vektoren der Klasse Vector36 hat das erste Element den
Index 0 und das letzte den Index „size() – 1“. Es ist möglich an einer beliebigen Stelle ein Element
einzufügen oder zu löschen. Die weiter hinten stehenden Elemente werden dann entsprechend weiter nach rechts
bzw. nach links verschoben.
Das Interface List37
<< interface >>
List
public void add(int index, Object element);
// Einfügen eines Elements an der durch Index spezifizierten Position
public boolean add(Object o);
// Anhängen eines Elements ans Ende der Liste
// Rückgabewert ist true, falls die Liste durch den Aufruf von add verändert wurde. Er ist false,
// wenn die Liste nicht verändert wurde. Das kann bspw. der Fall sein, wenn die Liste keine
// Duplikate erlaubt und ein bereits vorhandenes Element noch einmal eingefügt werden soll.
// Konnte das Element aus einem anderen Grund nicht eingefügt werden, wird eine Ausnahme
// des Typs UnsupportedOperationException, CallsCastException oder IllegalArgumentException
// ausgelöst
public boolean addAll(Collection c);
// Einfügen einer vollständigen Collection in die Liste. Der Rückgabewert ist true, falls die Liste
// durch den Ausfruf von add veränder wurde
public boolean addAll(int index, Collection c)
public void clear();
public boolean equals(Object object);
public boolean contains(Object element);
public boolean containsAll(Collection collection);
public Object remove(int index)
public boolean remove(Object element);
public boolean removeAll(Collection c);
// Alle Elemente werden gelöscht, die auch in der als Argument angebenen
// Collection enthalten sind.
public boolean retainAll(Collection c);
// löscht alle Elemente außer den in der Argument-Collection enthaltenen
public Object get();
public int hashCode();
public Iterator iterator();
public ListIterator listIterator():
public ListIterator listIterator(int startIndex);
public Object set(int index, Obeject element);
public List subList(int fromIndex, int toIndex);
public Object [] toArray();
public Object [] toArray(Object [] a);
Abb.:
Auf die Elemente einer Liste läßt sich mit einem Index zugreifen und nach Elementen läßt sich mit linearem
Aufwand suchen. Doppelte Elemente sind erlaubt. Die Schnittstelle List, die in ArrayList und LinkedList eine
Implementierung findet, erlaubt sequentiellen Zugriff auf die gespeicherten gespeicherten Elemente. Das
Interface List wird im JDK von verschiedenen Klassen implementiert:
36
seit Java 1.2 implementiert die Klasse Vector die Schnittstelle List
Da ebenfalls das AWT-Paket eine Klasse mit gleichen namen verwendet, muß der voll qualifizierte Name in
Anwendungen benutzt werden.
37
102
Algorithmen und Datenstrukturen
AbstractList
ist eine abstrakte Basisklasse (für eigene List-Implementierungen), bei der alle Methoden die Ausnahme
UnsupportedException auslösen und diverse Methoden abstract deklariert sind. Die direkten
Subklassen sind AbstractSequentialList, ArrayList und java.util.Vector. AbstractList
implementiert bereits viele Methoden für die beiden Listen-Klassen38:
abstract class AbstractList extends AbstractCollection implements List
<< Methoden >>
public void add(int index, Object element)
// Optional: Fügt ein Objekt an der spezifizierten stelle ein
public boolean add(Object o)
// Optional: Fügt das Element am Ende an
public boolean addAll(int index, Collection c)
// Optional: Fügt alle Elemente der Collection ein
public void clear()
// Optional: Löscht alle Elemente
public boolean equals(Object o)
// vergleicht die Liste mit dem Objekt
public abstract Object get(int index)
// liefert das Element an dieser Stelle
int hashCode()
// liefert HashCode der Liste
int indexOf(Object o)
// liefert Position des ersten Vorkommens für o oder –1,
// wenn das Element nicht existiert.
Iterator iterator()
// liefert den Iterator. Überschreibt die Methode AbstractCollection,
// obwohl es auch listIterator() für die spezielle Liste gibt. Die Methode
// ruft aber listIterator() auf und gibt ein ListIterator-Objekt zurück
Object remove(int index)
// löscht ein Element an Position index.
protected void removeRange(int fromIndex, int toIndex)
// löscht Teil der Liste von fromIndex bis toIndex. fromIndex wird mitgelöscht,
// toIndex nicht.
public Object set(int index, Object element)
// Optional. Ersetzt das Element an der Stelle index mit element.
public List subList(int fromIndex, int toIndex)
// liefert Teil einer Liste fromIndex (einschließlich) bis toIndex (nicht mehr dabei)
Abb.:
AbstractSequentialList
bereitet die Klasse LinkedList darauf vor, die Elemente in einer Liste zu verwalten und nicht wie
ArrayList in einem internen Array.
LinkedList
realisiert die doppelt verkettete, lineare Liste und implementiert List.
ArrayList
implementiert die Liste als Feld von Elementen und implementiert List. Da Arraylist ein Feld ist, ist der Zugriff
auf ein spezielles Element sehr schnell. Eine LinkedList muß aufwendiger durchsucht werden. Die verkettete
Liste ist aber deutlich im Vorteil, wenn Elemente gelöscht oder eingefügt werden.
38
Beim aufruf einer optionalen Methode, die von der Subklasse nicht implementiert wird, führt zur
UnsupportedOperationException.
103
Algorithmen und Datenstrukturen
<< interface >>
Collection
<< interface >>
List
LinkedList
<< Konstruktor >>
public LinkedList();
public LinkedList(Collection collection);
<< Methoden >>
public void addFirst(Object object);
public void addLast(Object object);
public Object getFirst();
public Object getLast();
public Object removeFirst();
public Object removeLast();
ArrayList
Vector
<< Konstruktor
public ArrayList();
public ArrayList(Collection collection);
public ArrayList(int anfangsKapazitaet);
<< Methoden >>
protected void removeRange
(int fromIndex, int toIndex)
// löscht Teil der Liste von
// fromIndex bis toIndex. fromIndex wird
// mitgelöscht, toIndex nicht.
Abb.
Das Interface ListIterator
<< interface >>
ListIterator
public boolean hasPrevious();
// bestimmt, ob es vor der aktuellen Position ein weiteres Element gibt, der Zugriff ist mit
// previous möglich
public boolean hasNext();
public Object next();
public Object previous();
public int nextIndex();
puplic int previousIndex();
public void add(Object o);
// Einfügen eines neuen Elements an der Stelle der Liste, die unmittelbar vor dem nächsten
// Element des Iterators liegt
public void set(Object o);
// erlaubt, das durch den letzten Aufruf von next() bzw. previous() beschaffene Element zu
// ersetzen
public void remove();
Abb.:
ListIterator ist eine Erweiterung von Iterator. Die Schnittstelle fügt noch
Methoden hinzu, damit an aktueller Stelle auch Elemente eingefügt werden können.
104
Algorithmen und Datenstrukturen
Mit einem ListIterator läßt sich rückwärts laufen und auf das vorgehende
Element zugreifen.
Bsp.:
1. Simulation einer Schlange39
Eine verkettete Liste hat neben den normalen Funktionen aus AbstractList noch weitere
Hilfsmethoden zur Implementierung von einem Stack oder einer Schlange. Es handelt sich dabei um
die Methoden addFirst(), addLast(), getFirst(), getLast() und removeFirst().
import java.util.*;
public class ListBsp
{
public static void main(String [] args)
{
LinkedList schlange = new LinkedList();
schlange.add("Thomas");
schlange.add("Andreas");
schlange.add("Josef");
System.out.println(schlange);
schlange.removeFirst();
schlange.removeFirst();
System.out.println(schlange);
}
}
2. Entfernen von Duplikaten40
import java.util.*;
public class EntfDupl
{
public static void main(String args[])
{
int [] a = { 1, 7, 7, 1, 5, 1, 2, 7, 2, 1, 6, 6, 3, 6, 7 };
LinkedList l = new LinkedList();
for (int i = 0; i < a.length; i++)
{ l.add(new Integer(a[i])); }
System.out.println(l);
int i = 0;
do
{
int aktWert = ((Integer) l.get(i)).intValue();
i++;
if (i == l.size()) break;
int j = i;
do
{
int wert = ((Integer) l.get(j)).intValue();
if (wert == aktWert) l.remove(j);
j++;
} while (j < l.size());
System.out.println(l);
} while (i < l.size());
}
}
39
40
Vgl. pr22220
vgl. pr22220
105
Algorithmen und Datenstrukturen
2.2.8.3 Behälterklassen des Typs Set
Ein Set ist eine Menge, in der keine doppelten Einträge vorkommen können. Set hat die gleichen Methoden wie
Collection. Standard-Implementierung für Set sind das unsortierte HashSet (Array mit veränderlicher Größe)
und das sortierte TreeSet (Binärbaum).
Bsp.41:
import java.util.*;
public class SetBeispiel
{
public static void main(String args [])
{
Set set = new HashSet();
set.add("Gerhard");
set.add("Thomas");
set.add("Michael");
set.add("Peter");
set.add("Christian");
set.add("Valentina");
System.out.println(set);
Set sortedSet = new TreeSet(set);
System.out.println(sortedSet);
}
}
41
Vgl. pr13230
106
Algorithmen und Datenstrukturen
<< interface >>
Collection
<< interface >>
Set
public boolean add(Object element);
public boolean addAll(Collection collection);
public void clear();
public boolean equals(Object object);
public boolean contains(Object element);
public boolean containsAll(Collection collection);
public int hashCode();
public Iterator iterator();
public boolean remove(Object element);
public boolean removeAll(Collection collection);
public boolean retainAll(Collection collection);
public int size();
public Object[] toArray();
public Object[] toArray(Object[] a);
HashSet
public HashSet();
public HashSet(Collection collection);
public HashSet(int anfangskaazitaet);
public HashSet(int anfangskapazitaet,
int ladeFaktor);
<< interface >>
SortedSet
public Object first();
public Object last();
public SortedSet headSet(Object toElement);
public SortedSet subSet(Object fromElement,
Object toElement);
public SortedSet tailSet(Object fromElement);
public Comparator comparator();
public Object fisrt();
public Object last();
TreeSet42
public TreeSet()
public TreeSet(Collection collection);
public TreeSet(Comparator vergleich);
public TreeSet(SortedSet collection);
Die Schnittstelle Set
Ist eine im mathematischen Sinne definierte Menge von Objekten. Die Reihenfolge
wird durch das Einfügen festgelegt. Wie von mathematischen Mengen bekannt, darf
ein Set keine doppelten Elemente enthalten. Besondere Beachtung muß Objekten
42
implementiert die sortierte Menge mit Hilfe der Klasse TreeMap, verwendet einen Red-Black-Tree als
Datenstruktur
107
Algorithmen und Datenstrukturen
geschenkt werden, die ihren Wert nachträglich ändern. Die kann ein Set nicht
kontrollieren. Eine Menge kann sich nicht selbst als Element enthalten.
Zwei Klassen ergeben sich aus Set: die abstrakte Klasse AbstractSet und die
konkrete Klasse HashSet.
Die Schnittstelle SortedSet
erweitert Set so, daß Elemente sortiert ausgelesen werden können. Das
Sortierkriterium wird durch die Hilfsklasse Comparator gesetzt.
2.2.8.4 Behälterklassen des Typs Map
Ein Map ist eine Menge von Elementen, auf die über Schlüssel zugegriffen wird. Jedem Schlüssel (key) ist
genau ein Wert (value) zugeordnet. Standard-Implementierungem sind HashMap, HashTable und
TreeMap.
108
Algorithmen und Datenstrukturen
Interface Map, SortedMap und implemetierende Klassen
<< interface >>
Collection
<< interface >>
Map
public void clear();
public boolean containskey(Object key);
public boolean containsValue(Object value);
public Set entrySet();
public Object get(Object key);
public boolean isEmpty();
public Set keySet();
public Object remove(Object key);
public int size();
public Collection values();
HashMap
public HashMap();
public Hashmap(Collection collection);
public HashMap(int anfangskapazitaet);
public HashMap(int anfangskapazitaet,
int ladeFaktor);
<< interface >>
SortedMap
public Comparator comparator();
public Object firstKey();
public Object lastKey();
public SortedMap headmap(Object toKey);
public SortedMap subMap(Object fromKey,
Object toKey);
public SortedMap tailMap(Object fromKey);
Hashtable
TreeMap
public TreeMap();
public TreeMap(Map collection);
public TreeMap(Comparator vergleich);
public TreeMap(SortedMap collection);
Abb.:
Die Schnittstelle Map
Eine Klasse, die Map implementiert, behandelt einen assoziativen Speicher. Dieser
verbindet einen Schlüssel mit einem Wert. Die Klasse Hashtable erbt von Map.
Map ist für die implementierenden Klassen AbstractMap, HashMap, Hashtable,
RenderingHints, WeakHashMap und Attributes das, was die abstrakte Klasse
Dictionary für die Klasse Hashtable ist.
Die Schnittstelle SortedMap
Eine Map kann mit Hilfe eines Kriteriums sortiert werden und nennt sich dann
SortedMap. SortedMap erweitert direkt Map. Das Sortierkriterium wird mit einem
speziellen Objekt, das sich Comparator nennt, gesetzt. Damit besitzt auch der
109
Algorithmen und Datenstrukturen
assoziative Speicher über einen Iterator eine Reihenfolge. Nur die konkrete Klasse
TreeMap implementiert bisher eine SortedMap.
Die abstrakte Klasse AbstractMap implementiert die Schnittstelle Map.
Die konkrete Klasse HashMap implementiert einen assoziativen Speicher, erweitert
die Klasse AbstractMap und implementiert die Schnittstelle Map.
Die konkrete Klasse TreeMap erweitert AbstractMap und implementiert
SortedMap. Ein Objekt von TreeMap hält Elemente in einem Baum sortiert.
Bsp.43: Aufbau und Anwendung einer Hash-Tabelle
import java.io.*;
import java.util.*;
public class HashTabTest
{
public static void main(String [ ] args)
{
Map map = new HashMap();
String eingabeZeile
= null;
BufferedReader eingabe = null;
try {
eingabe = new BufferedReader(
new FileReader("eing.txt"));
}
catch (FileNotFoundException io)
{
System.out.println("Fehler beim Einlesen!");
}
try {
while ( (eingabeZeile = eingabe.readLine() ) != null)
{
StringTokenizer str = new StringTokenizer(eingabeZeile);
if (eingabeZeile.equals("")) break;
String key
= str.nextToken();
String daten = str.nextToken();
System.out.println(key);
map.put(key,daten);
}
}
catch (IOException ioe)
{
System.out.println("Eingefangen in main()");
}
try {
eingabe.close();
}
catch(IOException e)
{
System.out.println(e);
}
System.out.println("Uebersicht zur Hash-Tabelle");
System.out.println(map);
//h.printHashTabelle();
System.out.println("Abfragen bzw. Modifikationen");
// Wiederauffinden
String eingabeKey = null;
BufferedReader ein = new BufferedReader(
new InputStreamReader(System.in));
System.out.println("Wiederauffinden von Elementen");
while (true)
{
43
vgl. pr23300
110
Algorithmen und Datenstrukturen
try {
System.out.print("Bitte Schluessel eingeben, ! bedeutet Ende: ");
eingabeKey = ein.readLine();
// System.out.println(eingabeKey);
if (eingabeKey.equals("!")) break;
String eintr = (String) map.get(eingabeKey);
if (eintr == null)
System.out.println("Kein Eintrag!");
else
{
System.out.println(eintr);
System.out.println("Soll dieser Eintrag geloescht werden? ");
String antwort = ein.readLine();
// System.out.println(antwort);
if ((antwort.equals("j")) || (antwort.equals("J")))
{
// System.out.println("Eintrag wird entfernt!");
map.remove(eingabeKey);
}
}
}
catch(IOException ioe)
{
System.out.println(eingabeKey +
" konnte nicht korrekt eingelesen werden!");
}
}
System.out.println(map);
System.out.println("Sortierte Tabelle");
Map sortedMap = new TreeMap(map);
System.out.println(sortedMap);
}
}
2.2.8.5 Implementierung von Graphen-Algorithmen mit Behälterklassen
2.2.8.5.1 Kürzeste Pfade in gerichteten, ungewichteten Graphen.
Lösungsbeschreibung. Die
ungewichteten Graphen G:
folgende
k1
k3
Abbildung
einen
gerichteten,
k2
k4
k6
zeigt
k5
k7
Abb.:
Ausgangspunkt ist ein Startknoten s (Eingabeparameter). Von diesem Knoten aus
soll der kürzeste Pfad zu allen anderen Knoten gefunden werden. Es interessiert nur
die Anzahl der Kanten, die in dem Pfad enthalten sind.
111
Algorithmen und Datenstrukturen
Falls für s der Knoten k3 gewählt wurde, kann zunächst am Knoten k3 der Wert 0
eingetragen werden. Die „0“ wird am Knoten k3 vermerkt.
k1
k2
k3
k4
k5
0
k6
k7
Abb.: Der Graph nach Markierung des Startknoten als erreichbar
Danach werden alle Knoten aufgesucht, die „eine Einheit“ von s entfernt sind. Im
vorliegenden Fall sind das k1 und k6. Dann werden die Knoten aufgesucht, die von s
zwei Einheiten entfernt sind. Das geschieht über alle Nachfolger von k 1 und k6. Im
vorliegenden Fall sind es die Knoten k2 und k4. Aus den benachbarten Knoten von k2
und k4 erkennt man, dass k5 und k7 die kürzesten Pfadlängen von drei Knoten
besitzen. Da alle Knoten nun bewertet sind ergibt sich folgenden Bild:
k1
k2
1
k3
2
k4
0
k5
2
1
k6
k7
Abb.: Graph nach Ermitteln aller Knoten mit der kürzeszen Pfadlänge 2
Die hier verwendete Strategie ist unter dem Namen „breadth-first search“ bekannt.
Die „Breitensuche zuerst“ berücksichtigt zunächst alle Knoten vom Startknoten aus,
die am weitesten entfernt liegenden Knoten werden zuerst ausgerechnet.
Übertragen der Lösungsbeschreibung in Quellcode. Zu Beginn sollte eine Tabelle
mit folgenden Einträgen vorliegen:
k
k1
k2
k3
k4
k5
k6
k7
bekannt
false
false
false
false
false
false
false
dk


0




pk
0
0
0
0
0
0
0
Die Tabelle überwacht den Fortschritt beim Ablauf des Algorithmus und führt Buch
über gewonnene Pfade. Für jeden Knoten werden 3 Angaben in der Tabelle
verwaltet:
- die Distanz dk des jeweiligen Knoten zu dem Startknoten s. Zu Beginn sind alle Knoten von s aus
unerreichbar (  ). Ausgenommen ist natürlich s, dessen Pfadlänge ist 0 (k 3).
- Der Eintrag pk ist eine Variable für die Buchführung (und gibt den Vorgänger im Pfad an).
112
Algorithmen und Datenstrukturen
- Der Eintrag unter „bekannt“ wird auf „true“ gesetzt, nachdem der zugehörige Knoten erreicht wurde.
Zu Beginn wurden noch keine Knoten erreicht.
Das führt zu der folgenden Knotenbeschreibung:
class Vertex
{
public String
public LinkedList
public boolean
public int
public Vertex
.....
}
name;
adj;
bekannt;
dist;
path;
// Name des Knoten
// Benachbarte Knoten
// Kosten
// Vorheriger Knoten auf dem kuerzesten Pfad
Die Grundlage des Algorithmus kann folgendermaßen beschrieben werden:
/*
/*
/*
/*
1
2
3
4
*/
*/
*/
*/
/* 5 */
/* 6 */
/* 7 */
/* 8 */
/* 9 */
void ungewichtet(Vertex s)
{
Vertex v, w;
s.dist = 0;
for (int aktDist = 0; aktDist < ANZAHL_KNOTEN; aktDist++)
for each v
if (!v.bekannt && v.dist == aktDist)
{
v.bekannt = true;
for each w benachbart_zu v
if (w.dist == INFINITY)
{
w.dist = aktDist + 1;
w.path = v;
}
}
}
Der Algorithmus deklariert schrittweise je nach Distanz (d = 0, d = 1, d= 2 ) die
Knoten als bekannt und setzt alle benachbarten Knoten von d w   auf die Distanz
d w  d  1.
2
Die Laufzeit des Algorithmus liegt bei O ( V ) .44 Die Ineffizienz kann beseitigt werden:
Es gibt nur zwei unbekannte Knotentypen mit
d v   . Einigen Knoten wurde dv = aktDist
zugeordnet, der Rest zeigt dv = aktDist + 1. Man braucht daher nicht die ganze Tabelle, wie es in
Zeile 3 und Zeile 4 beschrieben ist, nach geeigneten Knoten zu durchsuchen. Am einfachsten ist es,
die Knoten in zwei Schachteln einzuordnen. In die erste Schachtel kommen Knoten, für die gilt: dv =
aktDist. In die zweite Schachtel kommen Knoten, für die gilt: dv = aktDist + 1. In Zeile 3 und
Zeile 4 kann nun irgendein Knoten aus der ersten Schachtel herausgegriffen werden. In Zeile 9 kann w
der zweiten Schachtel hinzugefügt werden. Wenn die äußere for-Schleife terminiert ist die erste
Schachtel leer, und die zweite Schachtel kann nach der ersten Schachtel für den nächsten Durchgang
übertragen werden.
Durch Anwendung einer Schlange (Queue) kann das Verfahren verbessert werden.
Am Anfang enthält diese Schlange nur Knoten mit Distanz aktDist. Benachbarte
Knoten haben die Distanz aktDist + 1 und werden „hinten“ an die Schlange
angefügt. Damit wird garantiert, daß zuerst alle Knoten mit Distanz aktDist
bearbeitet werden. Der verbesserte Algorithmus kann in Pseudocode so formuliert
werden:
44
wegen der beiden verschachtelten for-Schleifen
113
Algorithmen und Datenstrukturen
/* 1 */
/* 2 */
/* 3 */
/*
/*
/*
/*
4
5
6
7
*/
*/
*/
*/
/* 8 */
/* 9 */
/*10 */
void ungewichtet(Vertex s)
{
Queue q;
Vertex v, w;
q = new Queue();
q.enqueue(s); s.dist = 0;
while (!q.isEmpty())
{
v = q.dequeue();
v.bekannt = true; // Wird eigentlich nicht mehr benoetigt
for each w benachbart_zu v
if (w.dist == INFINITY)
{
w.dist = v.dist + 1;
w.path = v;
q.enqueue(w);
}
}
}
Die folgende Tabelle zeigt, wie sich die Daten der Tabelle während der Ausführung
des Algorithmus ändern:
Anfangszustand
k
bekannt
k1
false
k2
false
k3
false
k4
false
k5
false
k6
false
k7
false
Q: k3
dk


0




k2 aus der Schlange
k
bekannt dk
k1
true
1
k2
true
2
k3
true
0
k4
false
2
k5
false
3
k6
true
1

k7
false
Q: k4, k5
pk
0
0
0
0
0
0
0
k3 aus der Schlange
bekannt dk
pk
false
1
k3

false
0
true
0
0

false
0

false
0
false
1
k3

false
0
Q: k1, k6
k1 aus der Schlange
bekannt dk
pk
true
1
k3
false
2
k1
true
0
0
false
2
k1

false
0
false
1
k3

false
0
Q: k6, k2, k4
k6 aus der Schlange
bekannt dk
pk
true
1
k3
false
2
k1
true
0
0
false
2
k1
false
0

true
1
k3

false
0
Q: k2, k4
pk
k3
k1
0
k1
k2
k3
0
k4 aus der Schlange
bekannt dk
pk
true
1
k3
true
2
k1
true
0
0
true
2
k1
false
3
k2
true
1
k3
false
3
k4
Q: k5, k7
k5 aus der Schlange
bekannt dk
pk
true
1
k3
true
2
k1
true
0
0
true
2
k1
true
3
k2
true
1
k3
false
3
k4
Q: k7
K7 aus der Schlange
bekannt dk
pk
true
1
k3
true
2
k1
true
0
0
true
2
k1
true
3
k2
true
1
k3
true
3
k4
Q: leer
Abb.:Veränderung der Daten während der Ausführung des Algorithmus zum kürzesten Pfad
Implementierung45.
class Vertex
{
String
name;
// Name des Knoten
LinkedList adj;
// Benachbarte Knoten
int
dist;
// Kosten
Vertex
path;
// Vorheriger Knoten auf dem kuerzesten Pfad
// Konstruktor
public Vertex( String nm )
{ name = nm; adj = new LinkedList( ); reset( ); }
// Methode
public void reset( )
{ dist = Graph.INFINITY; path = null; }
45
vgl.: pr22850
114
Algorithmen und Datenstrukturen
}
Über die Instanzvariable adj wird die Liste der benachbarten Knoten geführt, dist
enthält die Kosten, path den Vorgängerknoten vom kürzsten Pfad. Identifiziert wird
der Knoten durch einen Namen (Typ: String).
Die Klasse Graph implementiert die Methode ungewichtet(). Die Schlange in
dieser Liste wird über eine LinkedList mit den Methoden removeFirst() und
addLast() simuliert. Zum Aufbau des Graphen (Adjazenzliste) dient die Methode
addEdge(). Die Kanten werden aus einer Textdatei, die je Zeile ein Knotenpaar
(source, destination) umfaßt. Über die Hashmap vertexMap werden die
Referenzen zu den Knoten hergestellt.
public class Graph
{
public static final int INFINITY = Integer.MAX_VALUE;
private HashMap vertexMap = new HashMap( ); // Abbildung der Knoten
// Methode Hinzufuegen Kante
public void addEdge(String sourceName,String destName )
{
Vertex v = getVertex(sourceName);
Vertex w = getVertex(destName);
v.adj.add( w );
}
// Ausgabe des Pfads
public void printPath(String destName) throws NoSuchElementException
{
Vertex w = (Vertex) vertexMap.get(destName);
if( w == null )
throw new NoSuchElementException( "Destination vertex not found" );
else if( w.dist == INFINITY )
System.out.println( destName + " is unreachable" );
else
{
printPath( w );
System.out.println( );
}
}
// Falls vertexName nicht da ist, fuege den Knoten
// mit diesem Namen in die vertexMap (Adjazenzliste).
// In jedem Fall: Rueckgabe des Knoten.
private Vertex getVertex(String vertexName)
{
Vertex v = (Vertex) vertexMap.get(vertexName);
if( v == null )
{
v = new Vertex(vertexName);
vertexMap.put(vertexName, v);
}
return v;
}
private void printPath(Vertex dest)
{
if( dest.path != null )
{
printPath( dest.path );
System.out.print( " to " );
}
System.out.print( dest.name );
}
private void clearAll( )
{
for( Iterator itr = vertexMap.values( ).iterator( ); itr.hasNext( ); )
( (Vertex)itr.next( ) ).reset( );
}
115
Algorithmen und Datenstrukturen
public void ungewichtet( String startName ) throws NoSuchElementException
{
clearAll( );
Vertex start = (Vertex) vertexMap.get(startName);
if( start == null )
throw new NoSuchElementException( "Startknoten wurde nicht gefunden" );
LinkedList q = new LinkedList( ); // Schlange fuer breadth search first
q.addLast(start); start.dist = 0;
while( !q.isEmpty( ) )
{
Vertex v = (Vertex) q.removeFirst( );
for( Iterator itr = v.adj.iterator( ); itr.hasNext( ); )
{
Vertex w = (Vertex) itr.next( );
if( w.dist == INFINITY )
{
w.dist = v.dist + 1;
w.path = v;
q.addLast( w );
}
}
}
}
/*
* Verarbeitung einer Anforderung;
* Rueckgabe false, falls Dateiende.
*/
public static boolean processRequest(BufferedReader in, Graph g)
{
String startName = null;
String destName = null;
try
{
System.out.println( "Starknoten:" );
if( (startName = in.readLine( ) ) == null )
return false;
System.out.println( "Zielknoten:" );
if( ( destName = in.readLine( ) ) == null )
return false;
g.ungewichtet(startName);
g.printPath(destName);
}
catch( Exception e )
{ System.err.println( e ); }
return true;
}
/*
* Eine main()-Routine, die
* 1. Eine Datei liest, die Kanten enthaelt
*
(Der Dateiname wird als Parameter ueber die
*
Kommandozeile eingegeben);
* 2. den Graphen aufbaut;
* 3. wiederholt 2 Knoten anfordert und
*
den Algorithmus zur Berechnung der kuerzesten Pfade
*
in Gang setzt.
* Die Datei besteht aus Zeilen mit dem Format
*
Quelle (source) Ziel (destination).
*/
public static void main( String [ ] args )
{
Graph g = new Graph( );
try
{
FileReader din = new FileReader(args[0]);
BufferedReader graphFile = new BufferedReader(din);
// Lies die Kanten und fuege ein
116
Algorithmen und Datenstrukturen
String zeile;
while( ( zeile = graphFile.readLine( ) ) != null )
{
StringTokenizer st = new StringTokenizer(zeile);
try
{
if( st.countTokens( ) != 2 )
throw new Exception( );
String source = st.nextToken( );
String dest
= st.nextToken( );
g.addEdge(source, dest);
}
catch( Exception e )
{ System.err.println( e + " " + zeile ); }
}
}
catch( Exception e )
{ System.err.println( e ); }
System.out.println( "File read" );
BufferedReader in = new BufferedReader(
new InputStreamReader( System.in ) );
while( processRequest( in, g ) )
;
}
}
2.2.8.5.2 Berechnung der kürzesten Pfadlängen in gewichteten Graphen
(Algorithmus von Dijkstra)
1. Dijkstras Algorithmus zur Berechnung der kürzesten Wege
Gegeben ist ein gerichteter Graph G mit Knotenmenge V und Kantenmenge E. Jede
Kante e kat eine nichtnegative Länge, Außerdem ist ein Knoten s (Standort)
gegeben.
Gesucht ist der kürzeste Weg von s nach v für jeden Knoten v  V des Graphen.
Vorausgesetzt ist, dass jeder Knoten v  V durch wenigstens einen Weg von s aus
erreichbar ist. Für den kürzesten Weg soll die Länge ermittelt werden.
Lösungsbeschreibung. Die Lösung stützt sich auf die Berechnung der kürzesten
Pfadlängen in ungewichteten Graphen46 ab. Im Algorithmus von Dijkstra werden
auch die Daten über „bekannt“, dv (kürzeste Pfadlänge) und pv (letzter Knoten, der
eine Veränderung von dv verursacht hat) verwaltet.
Es wird eine Menge S von Knoten betrachtet und schrittweise vergrößert, für die der
kürzeste Weg von s aus bereits bekannt ist. Jedem Knoten v  V wird ein Distanz
d(v) zugeordnet. Anfangs ist d(s) = 0 und für alle von s verschiedenen Knoten v  V
ist d v   , und S ist leer. Dann wird S nach dem Prinzip "Knoten mit kürzester
Distanz von s zuerst" schrittweise folgendermaßen vergrößert, bis S alle Knoten V
des Graphen enthält:
1. Wähle Knoten v  V
2. Nimm v zu S hinzu
46
S mit minimaler Distanz
vgl. 1.
117
Algorithmen und Datenstrukturen
3. Für jede Kante vw von einem Knoten v zu einem Knoten
min({ d (v), d (v)  c(v, w)})
w S , ersetze d(w) durch
Der folgende Graph
2
k1
4
k2
1
3
10
2
2
k3
k4
5
8
k5
4
k6
6
k7
1
Abb.: Graph nach Ermitteln aller Knoten mit der kürzeszen Pfadlänge 2
mit der Knotenbeschreibung
class Vertex
{
....
public LinkedList adj;
// Benachbarte Knoten
public boolean
bekannt; //
public DistType47 dist;
// Kosten
public Vertex
path;
// Vorheriger Knoten auf dem kuerzesten Pfad
...
}
führt zu der folgende Initialisierung:
k
k1
k2
k3
k4
k5
k6
k7
bekannt
false
false
false
false
false
false
false
dk
0






pk
null
null
null
null
null
null
null
Abb.: Anfangszustand der Tabelle mit den Daten für den Algorithmus von Dijkstra
Der erste Knoten (Start) ist der Knoten k1 mit Pfadlänge 0. Nachdem k1 bekannt ist,
ergibt sich folgendes Bild:
47
DistType ist wahrscheinlich int
118
Algorithmen und Datenstrukturen
k
k1
k2
k3
k4
k5
k6
k7
bekannt
true
false
false
false
false
false
false
dk
0
2

1



pk
null
k1
null
k1
null
null
null
Abb.: Zustand der Tabelle nach „k1 ist bekannt“
„k1“ beitzt die Nachbarknoten: k2 und k4. „k4“ wird gewählt und als bekannt markiert.
Die Knoten k3, k5, k6 und k7 sind jetzt die benachbarten Knoten.
k
k1
k2
k3
k4
k5
k6
k7
bekannt
true
false
false
true
false
false
false
dk
0
2
3
1
3
9
5
pk
null
k1
k4
k1
k4
k4
k4
Abb.: Zustand der Tabelle nach „k4 ist bekannt“
„k2“ wird gewählt. „k4“ ist benachbart, aber schon bekannt. „k5“ ist ebenfalls
benachbart, wir aber nicht ausgerichtet, da die Kosten von „k 2“ aus 2 +10 = 12 sind
und ein Pfad der Länge 3 schon bekannt ist
k
k1
k2
k3
k4
k5
k6
k7
bekannt
true
true
false
true
false
false
false
dk
0
2
3
1
3
9
5
pk
null
k1
k4
k1
k4
k4
k4
Abb.: Zustand der Tabelle nach „k2 ist bekannt“
Der nächste ausgewählte Knoten ist „k5“ (ohne Ausrichtungen), danach wird k3
gewählt. Die Wahl von „k3“ bewirkt die Ausrichtung von „k6“
k
k1
k2
k3
k4
k5
k6
k7
bekannt
true
true
true
true
true
false
false
dk
0
2
3
1
3
8
5
pk
null
k1
k4
k1
k4
k3
k4
Abb.: Zustand der Tabelle „k5 ist bekannt“ und (anschließend) „k 3 ist bekannt“.
„k7“ wird gewählt. Daraus resultiert folgende Tabelle:
119
Algorithmen und Datenstrukturen
k
k1
k2
k3
k4
k5
k6
k7
bekannt
true
true
true
true
true
false
true
dk
0
2
3
1
3
6
5
pk
null
k1
k4
k1
k4
k7
k4
Abb.: Zustand der Tabelle „k7 ist bekannt“.
Schließlich bleibt nur noch k6 übrig. Das ergibt dann die folgende Abschlußtabelle:
k
k1
k2
k3
k4
k5
k6
k7
bekannt
true
true
true
true
true
true
true
dk
0
2
3
1
3
6
5
pk
null
k1
k4
k1
k4
k7
k4
Abb.: Zustand der Tabelle nach „k6 ist bekannt“.
Der Algorithmus, der diese Tabellen
folgendermaßen beschrieben werden:
berechnet,
kann
(in
Pseudocode)
void dijkstra(Vertex s)
{
Vertex v, w;
/* 1 */
s.dist = 0;
/* 2 */
for(; ;)
{
/* 3 */
v = kleinster_unbekannter_Distanzknoten;
/* 4 */
if (v == null)
/* 5 */
break;
/* 6 */
v.bekannt = true;
/* 7 */
for each w benachbart_zu v
/* 8 */
if (!w.bekannt)
/* 9 */
if (v.dist + cvw < w.dist)
{
/* 10 */
w.dist = v.dist + cvw;
/* 11 */
w.pfad = v;
}
}
}
Die Laufzeit des Algorithmus resultiert aus dem Aufsuchen aller Knoten (in den
beiden for-Schleifen) und im Aufsuchen der Kanten (c(vw)) (in der inneren forSchleife): O(|E| + |V|2) = O(|V|2).
Implementierung48.
import java.util.*;
import java.io.*;
class Vertex
{
String
name;
48
// Name des Knoten
vgl.pr22851
120
Algorithmen und Datenstrukturen
LinkedList adj;
// Benachbarte Knoten
boolean
bekannt;
int
dist;
// Kosten
Vertex
path;
// Vorheriger Knoten auf dem kuerzesten Pfad
// Konstruktor
public Vertex(String nm)
{ name = nm; adj = new LinkedList( ); reset( ); }
// Methode
public void reset( )
{ dist = Graph.INFINITY; path = null; bekannt = false; }
}
public class Graph
{
public static final int INFINITY = Integer.MAX_VALUE;
private HashMap vertexMap = new HashMap(); // Abbildung der Knoten
private HashMap edgeMap
= new HashMap(); // Abbildung der Kanten
// Methode Hinzufuegen Kante
public void addEdge(String sourceName,String destName,String distanz)
{
Vertex v = getVertex(sourceName);
Vertex w = getVertex(destName);
String key = sourceName + destName;
v.adj.add(w);
edgeMap.put(key,distanz);
}
// Ausgabe des Pfads
public void printPath(String destName) throws NoSuchElementException
{
Vertex w = (Vertex) vertexMap.get(destName);
if( w == null )
throw new NoSuchElementException( "Destination vertex not found" );
else if( w.dist == INFINITY )
System.out.println( destName + " is unreachable" );
else
{
System.out.println(w.dist);
printPath( w );
System.out.println( );
}
}
// Falls vertexName nicht da ist, fuege den Knoten
// mit diesem Namen in die vertexMap.
// In jedem Fall: Rueckgabe des Knoten.
private Vertex getVertex(String vertexName)
{
Vertex v = (Vertex) vertexMap.get(vertexName);
if( v == null )
{
v = new Vertex(vertexName);
vertexMap.put(vertexName, v);
}
return v;
}
private void printPath(Vertex dest)
{
if( dest.path != null )
{
printPath( dest.path );
System.out.print( " to " );
}
System.out.print( dest.name );
}
private void clearAll( )
{
for( Iterator itr = vertexMap.values( ).iterator( ); itr.hasNext( ); )
( (Vertex)itr.next( ) ).reset( );
121
Algorithmen und Datenstrukturen
}
public void dijkstra(String startName) throws NoSuchElementException
{
clearAll( );
Vertex v, w, u;
Vertex start = (Vertex) vertexMap.get(startName);
if( start == null )
throw new NoSuchElementException( "Startknoten wurde nicht gefunden" );
start.dist = 0;
v = start; w = start; u = start;
for (; ;)
{
if (v == null) break;
v.bekannt = true;
int kleinstW = INFINITY;
for( Iterator itr = v.adj.iterator( ); itr.hasNext( ); )
{
w = (Vertex) itr.next( );
if (w == null) break;
if (!w.bekannt)
{
String key = v.name + w.name;
int cvw
= Integer.parseInt((String) edgeMap.get(key));
if (v.dist + cvw < w.dist)
{
w.dist = v.dist + cvw;
w.path = v;
}
}
}
for( Iterator itr = vertexMap.values( ).iterator( ); itr.hasNext( ); )
{
Vertex x = (Vertex) itr.next( );
if (!x.bekannt && x.dist != INFINITY)
{
if (kleinstW > x.dist)
{
kleinstW = x.dist;
u = x;
}
}
}
if (!u.bekannt) v = u;
else v = null;
}
}
/*
* Verarbeitung einer Anforderung;
* Rueckgabe false, falls Dateiende.
*/
public static boolean processRequest(BufferedReader in, Graph g)
{
String startName = null;
String destName = null;
try
{
System.out.print("Starknoten: ");
if( (startName = in.readLine( ) ) == null )
return false;
System.out.print("Zielknoten: " );
if( (destName = in.readLine( ) ) == null )
return false;
// System.out.println(startName);
g.dijkstra(startName);
System.out.print("Kuerzester Weg: ");
g.printPath(destName);
}
122
Algorithmen und Datenstrukturen
catch( Exception e )
{ System.err.println( e ); }
return true;
}
/*
* Eine main()-Routine, die
* 1. Eine Datei liest, die Kanten enthaelt
*
(Der Dateiname wird als Parameter ueber die
*
Kommandozeile eingegeben);
* 2. den Graphen aufbaut;
* 3. wiederholt 2 Knoten anfordert und
*
den Algorithmus zur Berechnung der kuerzesten Pfade
*
in Gang setzt.
* Die Datei besteht aus Zeilen mit dem Format
*
Quelle (source) Ziel (destination).
*/
public static void main(String [] args)
{
Graph g = new Graph( );
try
{
FileReader din = new FileReader(args[0]);
BufferedReader graphFile = new BufferedReader(din);
// Lies die Kanten und fuege ein
String zeile;
while( ( zeile = graphFile.readLine() ) != null )
{
StringTokenizer st = new StringTokenizer(zeile);
try
{
if( st.countTokens( ) != 3 )
throw new Exception( );
String source = st.nextToken( );
String dest
= st.nextToken( );
String distanz = st.nextToken();
g.addEdge(source, dest, distanz);
}
catch( Exception e )
{ System.err.println( e + " " + zeile ); }
}
}
catch( Exception e )
{ System.err.println( e ); }
System.out.println( "File read" );
BufferedReader in = new BufferedReader(
new InputStreamReader( System.in ) );
while( processRequest( in, g ) )
;
}
}
Ein Problem des vorstehenden Agorithmus ist das Durchsuchen der Knotenmenge
nach der kleinsten Distanz49. Man kann das wiederholte Bestimmen der kleinsten
Distanz
einer
prioritätsgesteuerten
Warteschlange
übertragen.
Der
Leistungsaufwand beträgt dann O(|E| log(|V)+|V| log(|V|)). Der Algorithmus (in
Pseudocode) könnte so aussehen:
v  V do d (v)  
/* 2 */ d ( s )  0 ; S  0
/* 1 */ for_all
/* 3 */ pq = new PriorityQueue(); // Vorrangwarteschlange für Knoten in V
/* 4 */ while pq  0 do /* pq  V S */
49
v = kleinster_unbekannter_Distanzknoten
123
Algorithmen und Datenstrukturen
/* 5 */ pq.delete _ min()
S  S  v
/* 7 */ for _ all (v, w)  E do
/* 8 */ if d (v)  c(v, w)  d ( w) pq.decrease _ key( w, d (v)  c(v, w))
/* 9 */ Entferne (v, w) aus E
/* 6 */
/*10*/ end while
2. Implementierung mit Hilfe einer Schlange
Negativ bewertete Kanten. Falls der Graph negativ bewertete Kanten hat,
funktioniert der Algorithmus von Dijkstra nicht. Implementiert man den DijkstraAlgorithmus mit einer Schlange, dann dürfen Kantenbewertungen auch negativ sein:
void negativGewichtet(Vertex s)
{
Queue q;
Vertex v, w;
q = new Queue();
q = enqueue(s);
while(!q.isEmpty())
{
v = dequeue();
for each w benachbart_zu w
if (v.dist + cvw < w.dist)
{
w.dist = v.dist + cvw;
w.path = v;
if (w ist_nicht_in q)
q.enqueue(w);
}
}
}
Implementierung.50
import java.util.*;
import java.io.*;
class Vertex
{
String
name;
// Name des Knoten
LinkedList adj;
// Benachbarte Knoten
boolean
bekannt;
int
dist;
// Kosten
Vertex
path;
// Vorheriger Knoten auf dem kuerzesten Pfad
// Konstruktor
public Vertex(String nm)
{ name = nm; adj = new LinkedList( ); reset( ); }
// Methode
public void reset( )
{ dist = Graph.INFINITY; path = null; bekannt = false; }
}
public class Graph
{
public static final int INFINITY = Integer.MAX_VALUE;
private HashMap vertexMap = new HashMap(); // Abbildung der Knoten
private HashMap edgeMap
= new HashMap(); // Abbildung der Kanten
50
vgl. pr22852
124
Algorithmen und Datenstrukturen
// Methode Hinzufuegen Kante
public void addEdge(String sourceName,String destName,String distanz)
{
Vertex v = getVertex(sourceName);
Vertex w = getVertex(destName);
v.adj.add(w);
String key = sourceName + destName;
edgeMap.put(key,distanz);
}
// Ausgabe des Pfads
public void printPath(String destName) throws NoSuchElementException
{
Vertex w = (Vertex) vertexMap.get(destName);
if( w == null )
throw new NoSuchElementException( "Zielknoten nicht gefunden" );
else if( w.dist == INFINITY )
System.out.println( destName + " ist nicht erreichbar" );
else
{
printPath( w );
System.out.println(" = " + w.dist);
}
}
// Falls vertexName nicht da ist, fuege den Knoten
// mit diesem Namen in die vertexMap.
// In jedem Fall: Rueckgabe des Knoten.
private Vertex getVertex(String vertexName)
{
Vertex v = (Vertex) vertexMap.get(vertexName);
if( v == null )
{
v = new Vertex(vertexName);
vertexMap.put(vertexName, v);
}
return v;
}
private void printPath(Vertex dest)
{
if( dest.path != null )
{
printPath( dest.path );
System.out.print( " to " );
}
System.out.print( dest.name );
}
private void clearAll( )
{
for( Iterator itr = vertexMap.values( ).iterator( ); itr.hasNext( ); )
( (Vertex)itr.next( ) ).reset( );
}
public void negGew(String startName) throws NoSuchElementException
{
clearAll();
Vertex v, w;
LinkedList q = new LinkedList();
Vertex start = (Vertex) vertexMap.get(startName);
if( start == null )
throw new NoSuchElementException( "Startknoten wurde nicht gefunden" );
q.addLast(start); start.dist = 0;
while(!q.isEmpty())
{
v = (Vertex) q.removeFirst();
for( Iterator itr = v.adj.iterator(); itr.hasNext( ); )
{
w = (Vertex) itr.next();
String key = v.name + w.name;
int cvw
= Integer.parseInt((String) edgeMap.get(key));
125
Algorithmen und Datenstrukturen
if ( (v.dist + cvw) < w.dist)
{
w.dist = v.dist + cvw;
w.path = v;
if (!q.contains(w))
q.addLast(w);
}
}
}
}
/*
* Verarbeitung einer Anforderung;
* Rueckgabe false, falls Dateiende.
*/
public static boolean processRequest(BufferedReader in, Graph g)
{
String startName = null;
String destName = null;
try
{
System.out.println( "Starknoten:" );
if( (startName = in.readLine( ) ) == null )
return false;
System.out.println( "Zielknoten:" );
if( (destName = in.readLine( ) ) == null )
return false;
g.negGew(startName);
g.printPath(destName);
}
catch( Exception e )
{ System.err.println( e ); }
return true;
}
/*
* Eine main()-Routine, die
* 1. Eine Datei liest, die Kanten enthaelt
*
(Der Dateiname wird als Parameter ueber die
*
Kommandozeile eingegeben);
* 2. den Graphen aufbaut;
* 3. wiederholt 2 Knoten anfordert und
*
den Algorithmus zur Berechnung der kuerzesten Pfade
*
in Gang setzt.
* Die Datei besteht aus Zeilen mit dem Format
*
Quelle (source) Ziel (destination).
*/
public static void main(String [] args)
{
Graph g = new Graph( );
try
{
FileReader din = new FileReader(args[0]);
BufferedReader graphFile = new BufferedReader(din);
// Lies die Kanten und fuege ein
String zeile;
while( ( zeile = graphFile.readLine() ) != null )
{
StringTokenizer st = new StringTokenizer(zeile);
try
{
if( st.countTokens( ) != 3 )
throw new Exception( );
String source = st.nextToken( );
String dest
= st.nextToken( );
String distanz = st.nextToken();
g.addEdge(source, dest, distanz);
}
catch( Exception e )
126
Algorithmen und Datenstrukturen
{ System.err.println( e + " " + zeile ); }
}
}
catch( Exception e )
{ System.err.println( e ); }
System.out.println( "File read" );
BufferedReader in = new BufferedReader(
new InputStreamReader( System.in ) );
while( processRequest( in, g ) )
;
}
}
3. Implementierung des Algorithmus von Dijkstra nach dem Verfahen von
Bellman/Ford
Negative Kantengewichte können auch im Rahmen des Optimalitätsprinzips von
Bellman zur Lösung des Problems kürzester Wege benutzt werden. Eine
Anwendung des Optimalitätsprinzip auf das "Shortest Path"-Problem zeigt der
Bellman-Ford Algorithmus:
void bellmanford(Vertex s)
{
Vertex v, w;
s.dist = 0;
for I = 1 to E  1
{
for_each (v, w)  E
{
if ((v.dist + cvw) < w.dist)
{
w.dist = v.dist + cvw;
}
}
}
}
127
Algorithmen und Datenstrukturen
2.2.8.5.3 Berechnung eines minimal spannenden Baums in einem
zusammenhängenden, ungerichteten, gewichteten Graphen
(Algorithmus von Prim)
Gegeben ist ein endlicher, zusammenhängender Graph G = (V,E) mit
Gewichtsfunktion g : E    (Gewicht oder Länge der Kante).
Gesucht ist ein spannender Baum für G.
Defintion: Ein spannender Baum ist ein zusammenhängender, azyklischer Teilgraph
von G, der alle Knoten miteinander verbindet.
Die Kosten von T sind die Summe der Gewichte aller Kanten in T.
Aufgabe. Berechne einen spannenden Baum mit minimalen Kosten (minimum
spanning tree).
Lösungsbeschreibung. Der folgende Graph
2
k1
k2
4
1
3
10
2
7
k3
k4
5
8
k5
4
k6
6
k7
1
besitzt folgenden minimale Spannbaum:
2
k1
k2
1
2
k3
k4
k5
4
k6
6
k7
1
Abb.:
Die Anzahl der Kanten in einem minimal spannenden Baum ist |V| - 1 (Anzahl der
Knoten – 1). Der minimal spannende Baum ist
- ein Baum, der keine Zyklen besitzt.
- spannend, da er jeden Knoten abdeckt.
- ein Minimum.
Der Algorithmus von Prim arbeitet stufenweise. Auf jeder Stufe wird ein Knoten
ausgewählt. Die Kanten auf seine nachfolgenden Knoten werden dann untersucht.
Die Untersuchung folgt nach den Vorschriften des Dijkstra-Algorithmus. Es gibt nur
eine Ausnahme hinsichtlich der Ermittlung der Distanz: d w  min( d v , cvw )
Die Ausgangssituation zeigt folgende Tabelle:
128
Algorithmen und Datenstrukturen
k
k1
k2
k3
k4
k5
k6
k7
bekannt
false
false
false
false
false
false
false
dv
0






pv
null
null
null
null
null
null
null
Abb.: Ausgangssituation
„k1“ wird ausgewählt, „k2, k3, k4 sind zu k1 benachbart“. Das führt zur folgenden
Tabelle:
k
k1
k2
k3
k4
k5
k6
k7
bekannt
true
false
false
false
false
false
false
dv
0
2
4
1



pv
null
k1
k1
k1
null
null
null
Abb.: Die Tabelle im Zustand „k1 ist bekannt“
Der nächste Knoten, der ausgewählt wird ist k4. Jeder Knoten ist zu k4 benachbart.
Ausgenommen ist k1, da dieser Knoten „bekannt“ ist. k2 bleibt unverändert, denn die
„Kosten“ von k4 nach k2 sind 3, bei k2 ist 2 eingetragen. Der Rest wird, wie die
folgende Tabelle zeigt, verändert:
k
k1
k2
k3
k4
k5
k6
k7
bekannt
true
false
false
true
false
false
false
dv
0
2
2
1
7
8
4
pv
null
k1
k4
k1
k4
k4
k4
Abb.: Die Tabelle im Zustand „k4 ist bekannt“
Der nächste Knoten, der ausgewählt wird, ist k2. Das zeigt keine Auswirkungen.
Dann wird k3 gewählt. Das bewirkt eine Veränderung der Distanz zu k6.
k
k1
k2
k3
k4
k5
k6
k7
bekannt
true
true
true
true
false
false
false
dv
0
2
2
1
7
5
4
pv
null
k1
k4
k1
k4
k3
k4
Abb.: Tabelle mit Zustand „k2 ist bekannt“ und (anschließend) mit dem Zustand „k 3 ist bekannt“
Es folgt die Wahl des Knoten k7, was die Ausrichtung von k6 und k5 bewirkt:
k
Bekannt
dv
pv
129
Algorithmen und Datenstrukturen
k1
k2
k3
k4
k5
k6
k7
true
true
true
true
false
false
true
0
2
2
1
6
1
4
null
k1
k4
k1
k7
k7
k4
Abb.: Tabelle mit Zustand „k7 ist bekannt“
Jetzt werden noch k6 und dann k5 bestimmt. Die Tabelle nimmt danach folgende
Gestalt an:
k
k1
k2
k3
k4
k5
k6
k7
Bekannt
true
true
true
true
true
true
true
dv
0
2
2
1
6
1
4
pv
null
k1
k4
k1
k7
k7
k4
Abb.: Tabelle mit Zustand „k6 ist bekannt“ und (anschließend) „k5 ist bekannt“
Die Tabelle zeigt, daß folgende Kanten den minimal spannenden Baum bilden:
(k2,k1),k3,k4)(k4,k1),(k5,k7),(k6,k7),(k7,k4)
Der Algorithmus von Prim zeigt weitgehende Übereinstimmung mit dem Algorithmus
von Dijkstra. Er besitzt aber auch Gültigkeit für ungerichtete Graphen. Jede Kante ist
daher in zwei Adjazenzlisten zu führen. Ohne Heaps ist die Laufzeit O(|V|2).
130
Algorithmen und Datenstrukturen
2.2.8.5.4 Topologisches Sortieren
Sortieren bedeutet Herstellung einer totalen (vollständigen) Ordnung. Es gibt auch
Prozesse zur Herstellung von teilweisen Ordnungen51, d.h.: Es gibt eine Ordnung für
einige Paare dieser Elemente, aber nicht für alle. In Graphen für die Netzplantechnik
ist die Feststellung partieller Ordnungen zur Berechnung der kürzesten (und
längsten) Wege erforderlich.
Bsp.: Die folgende Darstellung zeigt einen Netzplan zur Ermittlung des kritischen
Wegs. Die einzelnen Knoten des Graphen sind Anfangs- und Endereignispunkte der
Tätigkeiten, die an den Kanten angegeben sind. Die Kanten (Pfeile) beschreiben die
Vorgangsdauer und sind Abbildungen binärer Relationen. Zwischen den Knoten liegt
eine partielle Ordnungsrelation.
Bestelle A
50 Tage
Baue B
1
Teste B
4
20 Tage
Korrigiere Fehler
2
25 Tage
3
15 Tage
Handbucherstellung
60 Tage
Abb. : Ein Graph der Netzplantechnik
Zur Berechnung des kürzesten Wegs sind folgende Teilfolgen, die partiell geordnet
sind, nötig:
1 -> 3:
50 Tage
1->4->2->3: 60 Tage
1->4->3:
80 Tage (kürzester Weg)
Eindeutig ist das Bestimmen der topologischen Folgen nicht. Zu dem folgenden
Graphen
2
1
4
3
kann es mehrere topologische Folgen geben.Zwei dieser topologischen Folgen sind
51
vgl. 1.2.2.2
131
Algorithmen und Datenstrukturen
1
2
1
3
3
4
2
4
Abb.:
Bezugspunkt zur Ableitung eines Algorithmus für den topologischen Sort ist ein
gerichteter, azyklischer Graph, z.B.
0
1
2
3
1
2
3
4
1
5
3
6
2
7
Über der Knotenidentifikationen ist zusätzlich die Anzahl der Vorgänger vermerkt. Dieser Zähler wird in
die Knotenbeschreibung aufgenommen. Der Zähler soll festhalten, wie viele unmittelbare Vorgänger
der Knoten hat. Hat ein Knoten keine Vorgänger, dann wird der Zähler auf 0 gesetzt.
Damit kann der Algorithmus durch folgende Pseudocode-Darstellung beschrieben
werden.
void topsort()
{
Queue q;
int zaehler = 0;
Vertex v, w;
q = new Queue();
for each vertex v
if (v.indegree52 == 0)
q = new Queue();
while (!q.isEmpty())
{
v = q.dequeue();
zaehler++;
for each w adjacent to v
if (--w.indegree == 0)
q.enqueue(w);
}
if (zaehler != anzahlKnoten)
System.out.println(“Fehler: Zyklus gefunden”);
}
52
indegree ist der Zähler für die jeweilige Anzahl von Vorgängerknoten
132
Algorithmen und Datenstrukturen
Zur Bestimmung der gewünschten topologischen Folge wird mit den
Knotenpunktnummern begonnen, deren Zähler den Wert 0 enthalten. Sie verfügen
über keinen Vorgänger und erscheinen in der topologischen Folge an erster Stelle.
Implementierung53:
import java.util.*;
import java.io.*;
class Vertex
{
String
name;
// Name des Knoten
LinkedList adj;
// Benachbarte Knoten
int
indegree = 0; // Ingrad des Knoten
// int
dist;
// Kosten
// Vertex
path;
// Vorheriger Knoten auf dem kuerzesten Pfad
// Konstruktor
public Vertex( String nm )
{ name = nm; adj = new LinkedList(); }
}
public class Graph
{
// Abbildung der Knoten
private HashMap vertexMap = new HashMap();
// Methode Hinzufuegen Kante
public void addEdge(String sourceName,String destName)
{
Vertex v = getVertex(sourceName);
Vertex w = getVertex(destName);
w.indegree++;
v.adj.add(w);
}
// Falls vertexName nicht da ist, fuege den Knoten
// mit diesem Namen in die vertexMap.
// In jedem Fall: Rueckgabe des Knoten.
private Vertex getVertex(String vertexName)
{
Vertex v = (Vertex) vertexMap.get(vertexName);
if( v == null )
{
v = new Vertex(vertexName);
vertexMap.put(vertexName, v);
}
return v;
}
/*
* Herstellung der Ordnung von Knoten in einem
* gerichteten, azyklischen Graphen
*/
public void topsort()
{
Vertex v = null, w;
int zaehler = 0;
// Schlange fuer breadth search first
LinkedList q = new LinkedList( );
for (Iterator itr = vertexMap.values().iterator(); itr.hasNext();)
{
v = ((Vertex) itr.next());
if (v.indegree == 0)
{
// enqueue: Einreihen in die Schlange
q.addLast(v);
// System.out.println(v.name);
53
vgl. pr22854
133
Algorithmen und Datenstrukturen
}
}
if (v == null)
{
System.out.println("Fehler: Kein vorgaengerloser Knoten");
System.exit(0);
}
while( !q.isEmpty( ) )
{
// dequeue: Entnehmen aus der Schlange
v = (Vertex) q.removeFirst( );
zaehler++;
System.out.print(v.name + " ");
for( Iterator itr = v.adj.iterator( ); itr.hasNext( ); )
{
w = (Vertex) itr.next( );
if(--w.indegree == 0)
{
q.addLast( w );
}
}
}
// System.out.println(vertexMap.size());
// System.out.println(zaehler);
if (zaehler != vertexMap.size())
{
System.out.println("Fehler: Zyklus gefunden");
System.exit(0);
}
}
/*
* Eine main()-Routine, die
* 1. Eine Datei liest, die Kanten enthaelt
*
(Der Dateiname wird als Parameter ueber die
*
Kommandozeile eingegeben);
* 2. den Graphen aufbaut;
* 3. wiederholt 2 Knoten anfordert und
*
den Algorithmus zur Berechnung des topologischen Sort
*
in Gang setzt.
* Die Datei besteht aus Zeilen mit dem Format
*
Quelle (source) Ziel (destination).
*/
public static void main(String [] args)
{
Graph g = new Graph( );
try
{
FileReader din = new FileReader(args[0]);
BufferedReader graphFile = new BufferedReader(din);
// Lies die Kanten und fuege ein
String zeile;
while( ( zeile = graphFile.readLine() ) != null )
{
StringTokenizer st = new StringTokenizer(zeile);
try
{
if( st.countTokens( ) != 2 )
throw new Exception( );
String source = st.nextToken( );
String dest
= st.nextToken( );
g.addEdge(source, dest);
}
catch( Exception e )
{ System.err.println( e + " " + zeile ); }
}
}
catch( Exception e )
134
Algorithmen und Datenstrukturen
{ System.err.println( e ); }
System.out.println( "File read" );
// System.out.print(g.vertexMap);
// System.out.println(g.vertexMap.size());
g.topsort();
System.out.println();
}
}
Schreibtischtest. Die folgende Tabelle soll die Veränderung des Zählers für
unmittelbare Vorgänger zeigen und über die Knotenidentifikationen das Ein- bzw.
Ausgliedern aus der Schlange (Queue) q.
Vertex
1
2
3
4
5
6
7
Enqueue
Dequeue
1
0
1
2
3
1
3
2
1
1
2
0
0
1
2
1
3
2
2
2
3
0
0
1
1
0
3
2
5
5
4
0
0
1
0
0
3
1
4
4
5
0
0
0
0
0
2
0
3,7
3
6
0
0
0
0
0
1
0
7
7
0
0
0
0
0
0
0
6
6
Die Tabelle wurde mit den Daten des folgenden, azyklischen Graphen erstellt:
0
1
2
3
1
2
3
4
1
5
3
6
2
7
135
Algorithmen und Datenstrukturen
2.2.9 Algorithmen
Die Wahl einer geeigneten Datenstruktur ist der erste Schritt. Im zweiten Schritt
müssen die Algorithmen implementiert werden. Die Java Bibliothek hilft mit einigen
Standardalgorithmen weiter. Dazu zählen Funktionen zum Sortieren und Suchen in
Containern und das Füllen von Containern. Zum flexiblen Einsatz dieser Funktionen
haben die Java-Entwickler die Klasse Collections bereitgestellt. Collections bietet
Algorithmen statischer Funktionen an, die als Parameter ein Collection Objekt
erwarten. Leider sind viele Algorithmen nur auf List Objekte definiert 54, z.B.
public static void shuffle(List list)
// würfelt die Werte einer Liste durcheinander
Bsp.55:
import java.util.*;
public class VectorShuffle
{
public static void main(String args[])
{
Vector v = new Vector();
for (int i = 0; i < 10; i++)
v.add(new Integer(i));
Collections.shuffle(v);
System.out.println(v);
}
}
public static void shuffle(List list, random rnd)
// würfelt die werte der Liste durcheinander und benutzt dabei den Random Generator rnd.
Nur die Methoden min() und max() arbeiten auf allgemeinen Collection-Objekten.
2.2.9.1 Datenmanipulation
Daten umdrehen. Die Methode reverse() dreht die Werte einer Liste um. Die
Laufzeit ist linear zu der Anzahl der Elemente.
public static void reverse(List l)
// dreht die Elemente in der Liste um
Listen füllen. Mit der fill()-Methode läßt sich eine Liste in linearer Zeit belegen.
Nützlich ist dies, wenn eine Liste mit Werten initialisiert werden muß.
public static void fill (List l, Object o)
// füllt eine Liste mit dem Element o
Daten zwischen Listen kopieren. Die Methode copy(List quelle, List ziel)
kopiert alle Elemente von quelle in die Liste ziel und überschreibt dabei
Elemente, die evtl. an dieser Stelle liegen.
public static void copy(List quelle, List ziel)
// kopiert Elemente von quelle nach ziel. Ist ziel zu klein, gibt es eine IndexOutOfBoundsException
54
55
Nutzt die Collection Klasse keine List Objekte, arbeitet sie mit Iterator Objekten, um allgemein zu bleiben
vgl. pr22122
136
Algorithmen und Datenstrukturen
2.2.9.2 Größter und kleinster Wert einer Collection
Die Methoden min() und max() suchen das größte und kleinste Element einer
Collection. Die Laufzeit ist linear zur Größe der Collection. Die Methoden machen
keinen Unterschied, ob die Liste schon sortiert ist oder nicht.
public static Object min(Collection c)
//
public static Object max(Collection c)
/* Falls min() bzw. max() auf ein Collection-Objekt angewendet wird, erfolgt die Bestimmung des
Minimums bzw. Maximums nach der Methode compareTo() der Comparable Schnittstelle. Byte,
Character, Double, File, Float, Long, Short, String, Integer, BigInteger, ObjectStreamField, Date und
Calendar haben diese Schnittstelle implementiert. Lassen sich die Daten nicht vergleichen, dann gibt
es eine ClassCastException.
*/
public static Object min(Collection c, Comparator vergl)
//
public static Object max(Collection c, Comparator vergl)
2.2.9.3 Sortieren
Die Collection Klasse bietet zwei sort() Methoden an, die die Elemente einer Liste
stabil56 sortieren. Die Methode sort() sortiert die Elemente in ihrer natürlichen
Ordnung, z.B.:
- Zahlen nach der Größe (13 < 40)
- Zeichenketten alphanumerisch (Juergen < Robert < Ulli)
Eine zweite überladene Form von sort() arbeitet mit einem speziellen Comparator
Objekt, das zwei Objekte mit der Methode compare() vergleicht.
public static void sort(List liste)
// sortiert die Liste
public static void sort(List liste,Comparator c)
// sortiert die Liste mit dem Comparator c
Die Sortierfunktion arbeitet nur mit List-Objekten. „sort()“ gibt es aber auch in der
Klasse Arrays.
Bsp.: Das folgende Programm sortiert eine Reihe von Zeichenketten in aufsteigender
Folge. Es nutzt die Methode Arrays.asList() zur Konstruktion einer Liste aus
einem Array57.
import java.util.*;
public class CollectionsSortDemo
{
public static void main(String args[])
{
String feld[] =
{ "Regina","Angela","Michaela","Maria","Josepha",
"Amalia","Vera","Valentina","Daniela","Saida",
56
Stabile Sortieralgorithmen beachten die Reihenfolge von gleichen Elementen, z.B. beim Sortieren von
Nachrichten in einem Email-Programm, zuerst nach dem Datum und anschließend nach dem Sender, soll die
Liste innerhalb des Datum sortiert bleiben.
57
Leider gibt es keinen Konstruktor für ArrayList, der einen Array mit Zeichenketten zuläßt.
137
Algorithmen und Datenstrukturen
"Linda","Elisa"
};
List l = Arrays.asList(feld);
Collections.sort(l);
System.out.println(l);
}
}
Die Java Bibliothek bietet nicht viel zur Umwandlung von Feldern („Array“) in
dynamische Datenstrukturen. Eine Ausnahme bildet die Hilfsklasse Arrays, die die
Methode asList() anbietet. Die Behälterklassen ArrayList und LinkedList
werden über asList() nicht unterstützt, d.h. Über asList() wird zwar eine interne
Klasse ArrayList benutzt, die eine Erweiterung von AbstractList ist, aber nur
das notwendigste implementiert.
Sortieralgorithmus. Es handelt sich um einen optimierten „Merge-Sort“. Seine
Laufzeit beträgt N  log( N ) .
Die sort() Methode arbeitet mit der toArray() Funktion der Klasse List. Damit
werden die Elemente der Liste in einem Feld (Array) abgelegt. Schließlich wird die
sort() Methode der Klasse Arrays genutzt und mit einem ListIterator wieder
in die Liste eingefügt.
Daten in umgekehrter Reihenfolge sortieren. Das wird über ein spezielles
Comparator-Objekt geregelt, das von Collections über die Methode
reverseOrder() angefordert werden kann.
Bsp.58:
import java.util.*;
public class CollectionsReverseSortDemo
{
public static void main(String args[])
{
Vector v = new Vector();
for (int i = 0; i < 10; i++)
{
v.add(new Double(Math.random()));
}
Comparator comparator = Collections.reverseOrder();
Collections.sort(v,comparator);
System.out.println(v);
}
}
Eine andere Möglichkeit für umgekehrt sortierte Listen besteht darin, erst die Liste
mit sort() zu sortieren und anschließend mit reverse() umzudrehen.
58
Vgl. pr22122
138
Algorithmen und Datenstrukturen
2.2.9.4 Suchen von Elementen
Die Behälterklassen enthalten die Methode contains(), mit der sich Elemente
suchen lassen. Für sortierte Listen gibt es eine wesentlich schnellere Suchmethode:
binarySearch():
public static int binarySearch(List liste, Object key)
// sucht ein Element in der Liste. Gibt die Position zurück oder ein Wert kleiner 0,
// falls key nicht in der Liste ist.
public static int binarySearch(List liste, Object key, Comparator c)
// Sucht ein Element mit Hilfe des Comparator Objekts in der Liste. Gibt die Position zurück oder
// einen Wert kleiner als 0, falls der key nicht in der Liste ist.
Bsp.59: Das folgende Programm sortiert (zufällig ermittelte) Daten und bestimmt
Daten in Listen mit Hilfe der binären Suche.
import java.util.*;
public class ListSort
{
public static void main(String [] args)
{
final int GR = 20;
// Verwenden einer natuerliche Ordnung
List a = new ArrayList();
for (int i = 0; i < GR; i++)
a.add(new VglClass((int)(Math.random() * 100)));
Collections.sort(a);
Object finde = a.get(GR / 2);
int ort = Collections.binarySearch(a,finde);
System.out.println("Ort von " + finde + " = " + ort);
// Verwenden eines Comparator
List b = new ArrayList();
// Bestimmt zufaellig Zeichenketten der Laenge 4
for (int i = 0; i < GR; i++)
b.add(Felder.randString(4));
// Instanz fuer den Comparator
AlphaVgl av = new AlphaVgl();
// Sortieren
Collections.sort(b,av);
// Binaere Suche
finde = b.get(GR / 2);
ort = Collections.binarySearch(b,finde,av);
System.out.println(b);
System.out.println("Ort von " + finde + " = " + ort);
}
}
2.2.9.5 Typsichere Datenstrukturen
Die Datenstrukturen des Pakets java.util haben einen großen Nachteil: Die
eingesetzten typen sind immer vom Typ Object. Typsicherheit über Templates, wie
C++ es bietet, ist bisher nicht vorgesehen. Es spricht einiges dafür, daß in den
nächsten Javagenerationen generische Typen Berücksichtigung finden.
3. Algorithmen
59
Vgl. pr22122
139
Algorithmen und Datenstrukturen
3.1 Ausgesuchte algorithmische Probleme
3.1.1 Spezielle Sortieralgorithmen
Sortieren bedeutet: Anordnen einer gegebenen Menge von Datenelementen in einer
bestimmten Ordnung60. Danach sind Suchvorgänge
nach diesen Elementen
wesentlich vereinfacht. Da es nur wenige Programmierprobleme gibt, die ohne
Sortieren auskommen, ist die Vielfalt der dafür vorhandenen Algorithmen fast
unüberschaubar. Alle verfolgen den gleichen Zweck, viele sind in gewisser
Hinsicht optimal, und die meisten Algorithmen haben unter gewissen Bedingungen
auch Vorteile gegenüber anderen. Eine Leistungsanalyse der Algorithmen kann
diese Vorteile herausstellen.
Selbstverständlich hängt auch beim Sortieren die Wahl des Algorithmus von der
Struktur der zu bearbeitenden Daten ab. Die Sortiermethoden teilen sich hier
grundsätzlich bereits in zwei Gruppen:
- Sortieren von Feldern (internes Sortieren)
Felder befinden sich auf direkt zugreifbaren, internen Speicherbereichen.
- Sortieren von (sequentiellen) Dateien (externes Sortieren).
Dateien sind auf externen Speichern (Bänder, Platten) untergebracht. Daten liegen hier im
Format eines sequentiellen File vor. Dadurch ist zu jeder Zeit nur eine Komponente im
direkten Zugriff. Diese Einschränkungen gegenüber Feldstrukturen bedeutet, daß andere
Techniken zum Sortieren herangezogen werden müssen.
3.1.1.1 Interne Sortierverfahren
Quicksort
Beschreibung. Beim Quicksort-Verfahren wird in jedem Schritt ein Element x der zu
sortierenden Folge als Pivot-Element ausgewählt. Dann wird die zu sortierende
Folge so umgeordnet, dass eine Teilfolge links von x entsteht, in die alle Werte der
Elemente kommen, die nicht größer als x sind. Rechts von x entsteht eine Teilfolge,
in der alle Werte der Elemente kommen, die größer sind als das Pivot-Element x.
Diese Teilfolgen werden dann selbst wieder nach dem gleichen Verfahren rekursiv
zerlegt und umsortiert. Dies geschieht so lange, bis die Teilfolgen die Länge 1
besitzen und damit bereits sortiert sind, so dass man am Ende eine vollständig
sortierte Folge enthält.
60
1.2.2.2
140
Algorithmen und Datenstrukturen
Abb.:
Implementierung.
void quicksort(char array[], int min, int max)
{
int left = min, right = max;
// reads the value of the cell in the middle
char middle = array[(left + right) / 2];
do
{
// find first element >= element in the middle
while (array[left] < middle)
left++;
// find last element <= element in the middle
while (array[right] > middle)
right--;
// swap these elements
if (left < right)
swap(array, left, right);
if (left <= right)
}
left++;
// go to the
right--;
// next elements
}
// continue as long as we meet in the middle
} while (left <= right);
if (min < right)
// separate the array
quicksort(array, min, right);
// into two parts and continue sorting
if (left < max)
// as long there is a part which
quicksort(array, left, max);
// has more than one cell
}
141
Algorithmen und Datenstrukturen
N
Aufwand. Maximal werden zum Sotieren des Felds (Array) der Länge N  
2
Vergleiche benötigt. Besonders ungünstig ist eine bereits sortierte Liste. Wird der
Quicksort auf eine solche Liste angesetzt und ist die Wahl des Pivot-Elements auf
das erste bzw. letze Element gefallen, dann läuft in diesem Fall das „Divide and
Conquer“-Verfahren komplett ins Leere61. In diesem Fall benötigt der Quicksort
N
N
n  i =   Vergleiche.

i 1
2
Durchschnittlich benötigt der Quicksort zum Sortieren eines Felds der Länge N
2  ln( 2) N  log( N )  O( N ) Vergleiche.
Entscheidend für die Laufzeit vom Quicksort ist hierbei die gute Wahl des
Pivotelements:
- Fällt die Wahl auf das letzte Element, dann ist das schlecht bei vorsortierten Arrays.
- Bei einer zufälligen Wahl liegt besseres Verhalten vor bei vorsortierten Arrays. Nachteilig ist der
zusätzliche Aufwand für die Randomisierung.
- Meistens entscheidet man sich für die Wahl des Median: Das mittlere Element des ersten, mittleren
und letzten Elements des Array.
61
Eine der entstehenden Teilfoge ist leer, die andere enthält alle restlichen Elemente.
142
Algorithmen und Datenstrukturen
Heap-Sort
Beschreibung. Der Algorithmus zum Heap-Sort untergliedert sich in zwei Phasen:
- In der ersten Phase wird aus der unsortierten Folge von N Elementen ein Heap aufgebaut.
- In der zweiten Phase wird der Heap ausgegeben, d.h. ihm wird jeweils das größte
Element entnommen (das ja an der Wurzel steht). Dieses Element wird in die zu
sortierende Folge aufgenommen und die Heap-Eigenschaften werden anschließend
wieder hergestellt.
Implementierung62.
// import java.io.*;
public class HeapSort
{
private static void durchdringeRunter(Comparable [] a, int i, int n)
{
int kind ;
Comparable tmp;
for (tmp = a[i]; (2* i + 1) < n; i = kind)
{
kind = 2 * i + 1;
if (kind != n - 1 && a[kind].compareTo(a[kind+1]) < 0)
kind++;
System.out.println(i + ", " + kind + ", " + a[kind]);
if (tmp.compareTo(a[kind]) < 0)
a[i] = a[kind];
else
break;
}
a[i] = tmp;
}
public static void heapsort(Comparable [] a)
{
for (int i = a.length / 2; i >= 0; i--)
durchdringeRunter(a, i, a.length);
for (int i = a.length - 1; i > 0; i--)
{
Comparable tmp = a[0];
a[0] = a[i];
a[i] = tmp;
durchdringeRunter(a,0,i);
}
}
public static void main(String[ ] args)
{
// InputStreamReader isr = new InputStreamReader(System.in);
// BufferedReader
ein = new BufferedReader(isr);
int [] x = { 150, 80, 40, 30, 10, 70, 110, 100, 20, 90,
60, 50, 120, 140, 130 };
Comparable [] a = new Comparable[x.length];
for (int i = 0; i < x.length; i++)
{
a[i] = new Integer(x[i]);
}
HeapSort.heapsort(a);
System.out.println("Sortierte Ausgabe: ");
for (int i = 0; i < a.length; i++)
System.out.print(a[i].toString() + ", " );
System.out.println();
}
62
pr13228
143
Algorithmen und Datenstrukturen
}
Leistungsaufwand. Mit dem Heap-Sort kann ein Feld der Länge N mit höchstens
2  N  log( N )  O( N ) vielen Vergleichen sortiert werden.
Ein Heap mit l Stufen (Level) verfügt höchstens über 2l-1 Knoten.
Beim Heap-Sort ist die Anzahl der Vergleiche kleiner als die Anzahl der Vergleiche
zum Erzeugen eines Heap für N beliebige Elemente addiert mit der Summe der
Vergleiche bei allen „Löschungen des Größtwerts“.
Anzahl der Vergleiche zum Erzeugen eines Heap für N beliebige Elemente:
l 1
 2 l 1  0  2 l  2  2  1  ...  2  2  (l  2)  1  2  (l  1)   2 i  2  (l  1  i )  2  2 l  2  l  2
i 0
Anzahl der Vergleiche zum Löschen des Maximums: Spätestens nach dem Löschen
von 2l-1 Elementen nimmt die Anzahl der Levels des Heap um 1 ab, nach weiteren 2 l2 Elementen wieder um 1, usw. Damit gilt für die Anzahl der Vergleiche
l 1
 2 l 1  2  (l  1)  2 l  2  2  (l  2)  ...  2  (2  1)  2   i  2 i  2  ((l  2)  2 l  2) .
i 1
Für die Anzahl der Vergleiche beim Heap-Sort ergibt sich damit:
 2  2 l  2  l  2  2  ((l  2)  2 l  2)  2  l  2 l  2  l  6
Da der Heap-Sort auf einem N Elemente umfassenden Array ausgeführt wird, ergibt
sich die Höhe l zu log 2 ( N  1) . die Anzahl der Vergleiche ist dann beim Heap-Sort
bestimmt durch: 2  N  log 2 ( N )  O( N )
144
Algorithmen und Datenstrukturen
Sortieren durch Mischen
1. Einführung
Aus 2 (2-Weg-Mischen) oder mehr (n-Weg-Mischen) bereits sortiert vorliegenden
Teillisten ist durch geeignetes Zusammenfügen eine einzige sortierte Teilliste zu
erzeugen. Auf diese Weise sollen aus kleinen Teillisten (zu Beginn: Länge = 1)
immer größere produziert werden, bis schließlich nur noch eine einzige sortierte Liste
übrig bleibt.
2. Verschmelzen von Feldern
Kern dieses Mischverfahrens ist das wiederholte Verschmelzen sortierter Teillisten.
Bsp.:
17
11
Vergleiche: 17 - 11
23
37
68
45
78
67
17 - 37
23 - 37
37 - 68
45 - 68
67 - 68
Abb.:
Der soeben beschriebene Mischungsvorgang findet häufig auch bei Dateien
Anwendung63.
3. 2-Wege-Mischsortieren
Eine Folge von Schlüsseln wird sortiert, indem bereits sortiert vorliegende Teilfolgen
zu immer längeren Teilfolgen verschmolzen werden. Zu Beginn ist jeder Schlüssel
eine sortierte Teilfolge. In einem Durchgang werden jeweils zwei benachbarte
Teilfolgen zu einer Folge verschmolzen. Ist die Anzahl der Schlüssel eine Potenz von
2, dann ist das paarweise Zusammenmischen, ohne daß eine Teilfolge übrig bleibt,
immer gewährleistet, z.B.:
27
18
33
55
68
12
16
08
87
95
63
37
45
52
11
63
18
27
33
55
12
68
08
16
87
95
37
63
45
52
11
18
27
33
55
08
12
16
68
37
63
87
95
11
16
45
08
12
16
18
27
33
55
68
11
19
37
45
52
63
87
08
11
12
16
18
19
27
33
37
45
52
55
63
68
87
vgl. Sequential Update Problem
145
Algorithmen und Datenstrukturen
19
19
52
95
95
Bei jedem Durchgang verdoppelt sich die Länge der Teilfolgen. Falls die Anzahl der
Schlüssel keine Zweierpotenz ist, bleibt am Ende eines Durchgangs eine Teilfolge
übrig, z.B.:
27
18
33
55
68
12
16
8
87
95
63
18
27
33
55
12
68
8
16
87
95
63
18
27
33
55
8
12
16
68
63
87
95
8
12
16
18
27
33
55
68
63
87
95
8
12
16
18
27
33
55
63
68
87
95
Vollständig ist die Schlüsselfolge sortiert, falls in einem Durchgang nur noch zwei
Teilfolgen verschmelzen.
Leistungsanalyse. Durch das Umspeichern geht jeder Schlüssel in jedem Durchlauf
in eine Elementaroperation ein. Neben dem Transport findet auch ein Vergleich statt
(mit Ausnahme der Restliste). Da es bei N = 2n Schlüsseln n = ldN Durchläufe gibt,
ist der Gesamtaufwand: Z = NldN
Der Speicheraufwand ist: S = 2N
4. Rekursives Mischsortieren
Das Prinzip des 2-Wege-Mischsortierverfahrens beruht in der Aufteilung. Eine
Teilfolge ist einfacher zu sortieren als die vollständige Folge. Diese Folge wird
deshalb zunächst einmal geteilt, da die beiden Hälften einfacher durch das
Mischsortieren zu behandeln sind. Die sortierte Folge ergibt sich dann durch
Verschmelzen der beiden sortierten Teilfolgen. Nutzt man dieses Prinzip vollständig
aus, dann ist das Teilen schließlich so weit durchzuführen bis Teilfolgen vorliegen,
die bereits sortiert sind. Eine Folge, die nur aus einem Schlüssel besteht ist immer
sortiert und besimmt damit eindeutig das Ende des Teilungsprozesses. Das
Mischsortieren ist damit eindeutig durch ein rekursives Verfahren lösbar.
// Rekursives Mischsortieren in C++
template <class T>
void mische(const T* a, int na, const T* b, int nb, T* c)
{
int ia = 0, ib = 0, ic = 0;
while (ia < na && ib < nb)
c[ic++] = (a[ia] < b[ib] ? a[ia++] : b[ib++]);
while(ia < na) c[ic++] = a[ia++];
while(ib < nb) c[ic++] = b[ib++];
}
// Die vorliegende Funktion dient als Basis fuer ein einfaches und
// schnelles Sortierverfahren. Nachteilig: Ein zusaetzliches "Array"
// ist noetig
template <class T>
void mischSort(T* a, int n)
{
if (n < 2) return;
int nLinks = n / 2, nRechts = n - nLinks;
mischSort(a,nLinks); mischSort(a+nLinks,nRechts);
T* z = new T[n];
mische(a,nLinks,a + nLinks,nRechts,z);
146
Algorithmen und Datenstrukturen
for (int i = 0; i < n; i++) a[i] = z[i];
delete [] z;
}
5. Natürliches 2-Wege-Mischen
Das Verschmelzen von nur aus einem Element bestehenden Teilfolgen kann häufig
durch längere, bereits sortiert vorliegende Teilfolgen verbessert werden. Man
versucht, eine natürliche, in der gegebenen Folge bereits enthaltene Vorsortierung
auszunutzen. So zeigt bspw. das folgende Feld
27
18
37
55
68
12
16
8
87
95
63
sechs bereits sortiert vorliegende Teilfolgen:
27
18
37
12
16
8
87
55
68
95
63
Abb.:
Die Teilfolgen können ermittelt und anschließend zusammengemischt werden:
18
27
37
55
68
8
12
16
87
95
63
95
63
Der Vorgang kann wiederholt werden. Das führt zur Folge
8
12
16
18
27
37
55
68
87
, die schließlich zu einer vollständig sortierten Folge umgestellt werden kann:
8
12
16
18
27
37
55
147
63
68
87
95
Algorithmen und Datenstrukturen
3.1.1.2 Externe Sortierverfahren
Generell ist hier die zu sortierende Datenmenge so groß, daß sie nicht mehr
vollständig im Arbeitsspeicher Platz findet. Die Daten sind in einem peripheren und
sequentiellen Speichermedium (Band, Platte) enthalten. Die Daten sind grundsätzlich sequentielle Dateien (Files) mit der Eigenschaft, daß zu jeder Zeit genau eine
Komponente zugreifbar ist. Diese Einschränkung verlangt die Verwendung anderer
Techniken zum Sortieren. Am bedeutendsten ist hier: Sortieren durch Mischen 64.
3.1.1.2.1 Direktes Mischsortieren
1. 2-Wege-Mischsortierverfahren
Grundlagen. Das direkte Mischsortieren kann auf sequentielle „Files“
folgendermaßen angewandt werden:
1. Zerlegung einer gegebenen Sequenz (z.B. A) in 2 Hälften (z.B. B und C).
2. Mischen von B und C durch Kombination einzelner Elemente zu geordneten Paaren
3. Die gemischte Sequenz ist A.
Wiederholung der Schritte 1 und 2, wobei die geordneten Paare nun zu Quadrupeln
zusammenzufassen sind.
4. Wiederholung der vorhandenen Schritte, in der jedes Mal die Länge der gemischten Sequenzen
verdoppelt werden, bis die ganze Sequenz geordnet ist.
Bsp.: Gegeben ist die Sequenz A: 44
1. Schritt
2. Schritt
3. Schritt
4. Schritt
5. Schritt
6. Schritt
B:
C:
44
94
55
18
12
06
42
67
A:
44
94
18
55
B:
C:
44
06
94
12
18
42
55
67
A:
06
12
44
94
55
06
18
B:
C:
06
18
12
42
44
55
94
67
A:
06
12
18
42
12
12
42
44
42
42
55
55
94
18
06
67
67
67
67
94
Begriffe:
- Phase: Jede Operation, die die ganze Menge der Daten einmal behandelt.
- Durchlauf, Arbeitsgang: Der kleinste Teilprozess, dessen Wiederholung den Sortierprozess ergibt.
Im Bsp. umfaßt das Sortieren 3 Durchläufe. Jeder Durchlauf besteht aus einer
Zerlegungs- und Mischphase. Zum Sortieren werden 3 Bänder (sequentielle Files)
benötigt, der Prozeß heißt 3-Band-Mischen.
64
vgl. 3.1.1.1, Sortieren durch Mischen
148
Algorithmen und Datenstrukturen
Das direkte Mischsortierverfahren verwendet Teillisten fester Länge. Das Verfahren
besteht aus einer Reihe von (Durch-) Läufen, die mit nur ein Datenelement
umfassenden Teillisten beginnen. Jeder Lauf verdoppelt die Größe der Teillisten.
Sortiert ist dann, wenn nur eine Teilliste mit allen Datenelementen in sortierter Folge
vorliegt.
Verfahrensaufwand. Sortiert ist dann, wenn nur eine Teilliste mit allen
Datenelementen in sortierter Folge vorliegt. Erforderlich sind bei N Datenelementen
ldN verschiedene Läufe, wobei alle N Datenelemente auf temporären Dateien und
anschließend wieder zurück auf das Original kopiert werden. Das führt zu 2  N  ldN
Zugriffe.
Algorithmus. Umfaßt das sequentielle „File“ N zu sortierende Datensätze, dann teilt
man diese Datensätze in N/I Teilfolgen. Die Teilfolgen enthalten demnach
höchstens I Datensätze. I ist die Anzahl der Datensätze, die (höchstens) in den
Hauptspeicher passen. Man liest eine derartige Teilfolge (ein Intervall mit I
Datensätzen) in den Internspeicher ein, sortiert sie mit einem der bekannten
Arbeitsspeicher-Sortierverfahren und schreibt die sortierte Teilfolge zurück auf den
Externspeicher, d.h. auf diverse sequentielle „Files“. Zu Beginn muß man diverse
Datensätze auf dem „Eingabe-File“ auf mehrere „Files“ aufteilen (z.B. 2), dann
verschmilzt man die inzwischen sortierten Teilfolgen. Die so entstandene Folge muß
wieder aufgeteilt werden, bis man schließlich eine vollständig sortierte Folge erzeugt
hat. Der Wechsel für Verteilungs- und Mischphase ist charakteristisch.
Ausgeglichenes direktes Mischsortieren65.
Der bisher beschriebene und implementierte Verfahrensablauf wurde mit Hilfe von
drei sequentiellen „Files“ realisiert. Nimmt man noch ein viertes File hinzu, dann kann
die Verteilungs- und Mischphase zusammengefaßt werden (ausgeglichenes direktes
Mischsortieren).
Verfahrensbeschreibung: 4 Dateien d1, d2, d3, d4 sind gegeben. Eingabedatei ist d1.
Es werden wiederholt eine bestimmte Anzahl (A) Datensätze eingelesen, intern
sortiert und abwechselnd solange auf d3 und d4 geschrieben bis d1 erschöpft ist.
Sortierte Teilfolgen (sog. Läufe, Runs) der Länge I stehen dann auf d3 und d4. Diese
Läufe werden anschließend verschmolzen. Dabei entstehen Läufe mit 2*I langen
Teilfolgen, die abwechselnd auf d1 und d2 verteilt werden. Nach jeder Aufteilungsund Verschmelzungsphase hat sich die Run-Länge verdoppelt und die Anzahl der
Läufe etwa halbiert. Das Verfahren besteht also aus
- dem Verschmelzen der Läufe von zwei Dateien und abwechselndem Verteilen aus den beiden
anderen Dateien
- dem (logischen) Vertauschen der Dateien
bis schließlich nur ein Lauf auf einer der Dateien übrig bleibt.
Bsp.: Der Hauptspeicher des Rechner faßt 3 Datensätze (I = 3). Die Dateien
enthalten folgende Schlüssel:
d1: 12, 2, 5, 15, 13, 6, 14, 1, 4, 9, 10, 3, 11, 7, 8
d2:
A Datensätze werden jeweils von d1 gelesen, intern sortiert und auf d3 und d4 aufgeteilt:
d3: 2, 5, 12, 1, 4, 14, 7, 8, 11
d4: 6, 13, 15, 3, 9, 10
65
vgl. PR33116.CPP
149
Algorithmen und Datenstrukturen
d3 und d4 werden gelesen, d1 und d2 beschrieben:
d1: 2, 5, 6, 12, 13, 15, 7, 8, 11
d2: 1, 3, 4, 9, 10, 14
Es folgt:
d3: 1, 2, 3, 4, 5, 6, 9, 10, 12, 13, 14, 15
d4: 7, 8, 11
und schließlich
d1: 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15
d1 enthält zufälligerweise die sortierte Folge. Generell kann sie auf d1 oder d3 entstehen, wenn von den
beiden Dateien d1 und d2 (bzw. d3 und d4) entstehende Läufe zuerst auf d1 und danach auf d2 (bzw.
zuerst auf d3 und danach auf d4) geschrieben werden66.
Verfahrensaufwand: Nach jeder Verschmelzungs- und Verteilungsphase hat sich die
Anzahl der Läufe etwa halbiert. Zu Beginn wurde aus N Datensätzen über ein
internes Arbeitsspeichersortierverfahren N/I Läufe hergestellt. Damit ergibt sich nach
log(N/I) Durchgängen ein einziger sortierter Lauf.
Implementierung in C++:
const int I = 10;
// Anzahl Datensaetze, die im Arbeitssp. Platz haben
// Klasse Bal2WegSort zum Sortieren externer Daten
class Bal2WegSort
{
private:
// Datenelemente
int*
A;
// Array fuer Arbeitsspeichersortieren
int
l1, l2, aus;
fstream* Datei; // Array zur Aufnahme der Dateien
int ind1l, ind2l, ind1s, ind2s, inds;
// Methoden
void init();
void OeffneDat(int);
public:
Bal2WegSort();
// Konstruktor
~Bal2WegSort();
// Destruktor
void Sort();
};
// Schnittstellenfunktionen
void Bal2WegSort::init()
{
Datei[0].open("t1.dat", ios::in);
Datei[2].open("t3.dat", ios::out | ios::trunc);
Datei[3].open("t4.dat", ios::out | ios::trunc);
int
i = 0, index = 2;
// Herleiten der Anfangsverteilung
while(!Datei[0].eof())
{
if(i == I)
{
// Sortieren von Teilfolgen der Laenge I
BubbleSort(A,I);
// Sortierte Teilfolgen werden abwechselnd nach Datei[2]
66
vgl. Ottman, T. / Wittmayer, P.: Algorithmen und Datenstrukturen, Mannheim ... 1990, S. 142
150
Algorithmen und Datenstrukturen
// bzw. Datei[3] gespeichert, begonnen wird mit Datei[2]
for(int j=0; j < I; j++)
Datei[index] << A[j] << " " << flush;
index = (index == 2) ? 3 : 2;
i = 0;
}
Datei[0] >> A[i++];
}
BubbleSort(A,i);
for(int j=0; j < i-1; j++)
Datei[index] << A[j] << " " << flush;
// Dateien schliessen
Datei[0].close(); Datei[2].close(); Datei[3].close();
}
// oeffnet die 4 Dateien entsprechend
void Bal2WegSort::OeffneDat(int flag)
{
if(flag)
{
Datei[0].open("t1.dat", ios::out | ios::trunc);
Datei[1].open("t2.dat", ios::out | ios::trunc);
Datei[2].open("t3.dat", ios::in);
Datei[3].open("t4.dat", ios::in);
ind1l = 2; ind2l = 3;
ind1s = 0; ind2s = 1;
}
else
{
Datei[0].open("t1.dat", ios::in);
Datei[1].open("t2.dat", ios::in);
Datei[2].open("t3.dat", ios::out | ios::trunc);
Datei[3].open("t4.dat", ios::out | ios::trunc);
ind1l = 0; ind2l = 1;
ind1s = 2; ind2s = 3;
}
inds = ind1s;
}
// public:
// Konstruktor
Bal2WegSort::Bal2WegSort()
{
Datei = new fstream[4]; // Container fuer Dateien
A
= new int[I];
// Container zum Herstellen der Laeufe
init();
}
// Destruktor
Bal2WegSort::~Bal2WegSort()
{
delete [] Datei;
delete [] A;
}
// eigentliches Sortieren
void Bal2WegSort::Sort()
{
int flag = 1;
// Flag bestimmt, welche Dateien lesbar, schreibbar
OeffneDat(flag);
Datei[ind2l] >> l2;
//l2 ist aktuelle gelesene Zahl der 2. Datei
Datei[ind1l] >> l1;
//l1 ist aktuelle gelesene Zahl der 1. Datei
if (l1 <= l2)
aus = l1;
//aus ist letzte Zahl der geschriebenen Datei
else aus = l2;
while(!Datei[ind2l].eof())
// Schleife, bis sortiert
{
while(!Datei[ind1l].eof() && !Datei[ind2l].eof())
// Schleife eines Durchgangs
{
if(l1 <= l2)
151
Algorithmen und Datenstrukturen
if(l1 >= aus)
{
Datei[inds] << l1 << " ";
aus = l1;
Datei[ind1l] >> l1;
}
else
if(l2 >= aus)
{
Datei[inds] << l2 << " ";
aus = l2;
Datei[ind2l] >> l2;
}
else
{
// inds wechseln
inds = (inds == ind1s) ? ind2s : ind1s;
Datei[inds] << l1 << " ";
aus = l1;
Datei[ind1l] >> l1;
}
else
if(l2 >= aus)
{
Datei[inds] << l2 << " ";
aus = l2;
Datei[ind2l] >> l2;
}
else
if(l1 >= aus)
{
Datei[inds] << l1 << " ";
aus = l1;
Datei[ind1l] >> l1;
}
else
{
// inds wechseln
inds = (inds == ind1s) ? ind2s : ind1s;
Datei[inds] << l2 << " ";
aus
= l2;
Datei[ind2l] >> l2;
}
}
// Kopiere Rest, waehle richtige Datei aus
ind1l = (Datei[ind1l].eof()) ? ind2l : ind1l;
l1 = (ind1l==ind2l) ? l2 : l1;
while(!Datei[ind1l].eof())
{
if(l1 >= aus)
{
Datei[inds] << l1 << " ";
aus
= l1;
}
else
{
// inds wechseln
inds = (inds == ind1s) ? ind2s : ind1s;
Datei[inds] << l1 << " ";
aus
= l1;
}
Datei[ind1l] >> l1;
}
// Alle Dateien schließen
Datei[0].close(); Datei[1].close();
Datei[2].close(); Datei[3].close();
// Wechsle das Lesen und das Schreiben
152
Algorithmen und Datenstrukturen
OeffneDat(flag=(++flag) % 2);
Datei[ind1l] >> l1;
Datei[ind2l] >> l2;
if (l1 <= l2) aus=l1;
else aus =l2;
}
cout << "\nDie sortierte Datei ist auf Band " << ind1l << endl;
}
2. Mehrwege-Mischsortierverfahren
Verfahrensbeschreibung: Ausgangspunkt sind 2k sequentielle Files (Bänder): d 1,
d2,...,d2k. Es werden wiederholt I Datensätze von d1 gelesen und abwechselnd aud
dk+1,dk+2,...,d2k geschrieben bis d1 erschöpft ist. Dann stehen N/(k-I) Läufe der Länge
I auf di (k + 1 <= i <= 2k) Die k Files (Bänder) dk+1,...,d2k sind jetzt Eingabebänder für
ein k-Wege-Mischen, die k Files d1,...,dk sind die Ausgabebänder. Die ersten Läufe
der Eingabebänder werden zu einem Lauf der Länge k - l verschmolzen und auf das
Ausgabeband d1 geschrieben. Danach werden die nächsten k Läufe der
Eingabebänder verschmolzen und nach d 2 geschrieben. So werden der Reihe nach
Läufe der Länge k – l auf die Ausgabedateien geschrieben, bis die Eingabedateien
erschöpft sind. Nach dieser Verschmelzungs- und Aufteilungsphase tauschen die
Eingabe- und Ausgabedateien ihre Rollen. Das k-Wege-Verschmelzen und k-WegeVerteilen wird solange fortgesetzt, bis die gesamte Folge der Datensätze als ein Lauf
auf einer der Dateien steht.
Verfahrensaufwand67: Zu Beginn werden mindestens ein Lauf, höchstens (N/I) Läufe
und im Mittel (N/(2*I)) Läufe hergestellt. Nach jeder Verschmelzungs- und
Verteilungsphase hat sich die Anzahl der Läufe um das 1/k-Fache vermindert.
Implementierung68in C++:
class balMWegSort
{
private:
int K;
int I;
fstream* datei;
int* A;
int merkmal;
//
//
//
//
//
//
int endeKZ;
//
int bandNr;
//
// Private Methoden
void oeffnen();
void schliessen();
void verteile(); //
void mische();
public:
balMWegSort (int K,
~balMWegSort ();
Anzahl der Dateien (Baender)
Max. Anzahl der Elemente im Speicher
Liste ("Array") der Streams
Internspeicher ("Array")
1 => [0, K] Eingabe- [K+1, 2*K] Ausgabebaender
0 => [0, K] Ausgabe- [K+1, 2*K] Eingabebaender
TRUE (=1) sobald nur noch auf ein Ausgabeband
Nummer des aktuellen Ausgabebandes
Anfangsverteilung erzeugen
int I);
// Konstruktor
// Destruktor
int ergebnisBand();
void mischSort();
};
// Schnittstellenfunktionen
// private Methoden
67
68
vgl. Ottman, T. / Wittmayer, P.: Algorithmen und Datenstrukturen, Mannheim ... 1990, S. 147
vgl. PR33118.CPP
153
Algorithmen und Datenstrukturen
void balMWegSort::oeffnen()
{
char Name1[13];
char Name2[13];
for (int index = 0; index < K; index++)
{
sprintf (Name1, "%d", index);
sprintf (Name2, "%d", index + K);
strcat (Name1, ".dat");
strcat (Name2, ".dat");
remove ((merkmal ? Name2 : Name1));
// loescht die durch Dateinamen spezififizierte Datei
datei[index].open (Name1, merkmal ? ios::in : ios::out);
datei[index + K].open (Name2, merkmal ? ios::out : ios::in);
}
merkmal = !merkmal;
}
void balMWegSort::schliessen ()
{
for (int index = 0; index < K; index++)
{
datei[index].close ();
datei[index + K].close ();
}
}
void balMWegSort::verteile()
{
int elem, letztesElem, normal, index = 0;
bandNr = K + 1;
datei[0] >> elem;
while (!datei[0].eof ())
{
for (index = 0; index < I && !datei[0].eof (); index++)
{
A[index] = elem;
datei[0] >> elem;
}
BubbleSort(A,index);
if (normal && A[0] < letztesElem)
bandNr++;
if (bandNr > 2 * K)
bandNr = K + 1;
letztesElem = A[index - 1];
normal = 1;
for (int index2 = 0; index2 < index; index2++)
datei[bandNr - 1] << A[index2] << endl;
}
}
void balMWegSort::mische()
{
int index, gefunden, smInd, letztesElem, normal = 0;
endeKZ = 1;
bandNr = (merkmal ? 0 : K);
for (index = 0; index < K; index++)
datei[(merkmal ? index + K : index)] >> A[index];
do
{
gefunden = 0;
for (index = 0; index < K; index++)
if (!datei[(merkmal ? index + K : index)].eof () &&
154
Algorithmen und Datenstrukturen
(!gefunden || A [index] < A[smInd]))
{
smInd = index;
gefunden = 1;
}
if (gefunden)
{
if (normal && A[smInd] < letztesElem)
{
bandNr++;
endeKZ = 0;
}
if (bandNr >= (merkmal ? K : 2 * K))
bandNr = (merkmal ? 0 : K);
letztesElem = A[smInd];
normal = 1;
datei[bandNr] << A[smInd] << endl;
if (!datei[(merkmal ? smInd + K : smInd)].eof())
datei[(merkmal ? smInd + K : smInd)] >> A[smInd];
}
} while (gefunden);
}
// Konstruktoren - Destruktoren
balMWegSort::balMWegSort (int K, int I): K(K), I(I), merkmal(1), endeKZ(0)
{
datei = new fstream[2 * K];
A = new int[I];
}
balMWegSort::~balMWegSort ()
{
delete []datei;
delete []A;
}
// Oeffentliche Methoden
void balMWegSort::mischSort ()
{
oeffnen();
verteile();
schliessen();
while (!endeKZ)
{
oeffnen();
mische();
schliessen();
}
}
int balMWegSort::ergebnisBand ()
{
return (bandNr);
}
3. Mehrphasen-Mischsortieren
Beim ausgeglichenen Mehrwege-Mischsortieren werden alle Eingabedateien
benutzt, aber nur auf eine der Ausgabedateien geschrieben. Die anderen
Ausgabedateien sind temporär nutzlos. Da normalerweise die Zahl der Dateien viel
kleiner ist als die Anzahl der Elemente (I) in einem Lauf der Anfangsverteilung wäre
155
Algorithmen und Datenstrukturen
es wünschenswert, diese Dateien zu Eingabedateidateien heranzuziehen. Man will
aber nicht alle Läufe auf ein einzige Ausgabedatei speichern, weil man sonst vor der
nachfolgenden Verschmelzungsphase noch eine zusätzliche Verteilungsphase
einschieben müßte.
Beim Mehrphasen-Mischsortieren69 (polyphase mergesort)70 arbeitet man mit (k+1)
Dateien, von denen zu jedem Zeitpunkt k Eingabedateien sind und eine
Ausgabedatei ist. Man schreibt solange alle entstehenden Läufe auf die
Ausgabedatei bis eine der Dateien erschöpft ist71. Dann wird die leere Eingabedatei
zur Ausgabedatei, die bisher vorliegende Ausgabedatei dient als Eingabedatei.
Damit besitzt man wieder k Eingabedateien und eine (andere) Ausgabedatei. Der
Sortiervorgang ist beendet, falls die Eingabedateien erschöpft sind. Das Verfahren
funktioniert nur dann, wenn zu jedem Zeitpunkt (außer am Schluß) nur ein
Eingabeband leer wird.
Bsp.: In einer Anfangsverteilung sind 13 Läufe l1 l2 l3 l4 l5 l6 l7 l8 l9 l10 l11 l12 l13 auf die
Beiden dateien d1 und d2 folgendermaßen verteilt (d3 ist leer):
d1
l1 l2 l3 l4 l5 l6 l7 l8
d2
l9 l10 l11 l12 l13
d3
leer
Danach werden jeweils die nächsten Läufe von d 1 und d2 verschmolzen und auf das
Ausgabeband d3 geschrieben, bis d2 erschöpft ist.
d1
l6 l7 l8
d2
leer
d3
l1,9 l2,10 l3,11 l4,12 l5,13
Die erste Phase ist damit abgeschlossen. Die jeweils nächsten Läufe von d 1 und d3
werden verschmolzen, bis d1 leer ist
d1
d2
l6,1,9 l7,2,10 l8,3,11
d3
l4,12 l5,13
In den folgenden Phasen resultieren folgende Verteilungen:
d1
l6,1,9,4,12 l7,2,10,5,13
l7,2,10,5,13
leer
d2
l8,3,11
leer
l7,2,10,5,13,6,1,9,4,12,8,3,11
d3
leer
l6,1,9,4,12,8,3,11
leer
Am Ende muß genau ein Lauf auf einer Datei stehen, der aus 2 Läufen resultiert, die
jeweils auf zwei Dateien stehen.
69
vgl. Ottman, T. / Wittmayer, P.: Algorithmen und Datenstrukturen, Mannheim ... 1990, S. 148
vgl. Wirth, N.: Algorithmen und Datenstrukturen, Stuttgart 1979, S. 153
71
Das ist eine Phase.
70
156
Algorithmen und Datenstrukturen
3.1.1.2.2 Natürliches Mischen
Zwei-Wege Mischsortieren
Einführung. Beim direkten Mischen wird aus einer anfangs vorhandenen, teilweisen
Ordnung kein Nutzen gezogen. Tatsächlich können 2 geordnete Teilsequenzen mit
den Längen M und N direkt in eine Sequenz mit M + N Elementen eingemischt
werden. Ein Mischsortieren, das immer die längsten möglichen Teilfolgen mischt, ist
das natürliche Mischen.
Vereinbarung: Eine Teilsequenz X[I] ..
X[J] heißt (maximaler) Lauf, wenn
folgende Bedingungen erfüllt sind:
1. X[K] <= X[K + 1]
2. X[I - 1] > X[I]
3. X[J] > X[J + 1]
(für K = I .. J - 1)
Die natürliche Mischsortierung mischt (maximale) Läufe statt fester Sequenzen mit
vorbestimmter Länge. Jede Folge von natürlichen Zahlen zerfällt in eine solche
Folge, so z.B.:
5
3
2
7 10
4
1 7
3
6
8
2 geordnete Sequenzen sind dann jeweils zu einer einzigen, geordneten Sequenz zu
vereinigen.
Verfahrensaufwand. Er wird danach gemessen, wie oft Läufe in die Betrachtung
eingehen. Läufe haben die Eigenschaft, daß beim Mischen von 2 Sequenzen mit L
Läufen eine einzige Sequenz mit genau L Läufen entsteht.
So ergibt sich für die folgenden beiden Sequenzen
8
7
6
5
4
3
2
1
mit jeweils 4 Läufen die Sequenz
7
8
5
6
3
4
1
2
, die genau 4 Läufe besitzt. Die Zahl der Läufe wird in jedem Durchlauf halbiert. Im
schlimmsten Fall ergibt sich dann die Anzahl der Bewegungen zu: L ld(L).
Der Algorithmus.
Ablauf: Die zu sortierenden Daten liegen im File F vor und sollen am Schluß in
sortierter Form unter demselben Namen zurückgegeben werden. Die beiden
Hilfsfiles sind G1 und G2. Jeder Durchlauf umfaßt eine Verteilungsphase
(distribution), die Läufe gleichmäßig von F auf G1 und G2 verteilt, und eine
Mischphase, die Läufe von G1 und G2 auf F mischt.
157
Algorithmen und Datenstrukturen
G1
F
G1
G1
F
F
F
F
.......
G2
G2
G2
Mischphase
Verteilungsphase
Abb.:
Das Sortieren ist beendet sobald F nur noch ein Lauf ist.
Für die Definition des momentanen Zustands eines Files stellt man sich am besten
einen Positionszeiger vor. Er wird beim Schreiben um je eine Einheit vorwärts
geschoben.
Beschreibung: Sie erfolgt nach der Methode „stepwise refinement“.
Grobstruktur des Prozesses:
Wiederhole
Setze die Zeiger aller 3 Files auf den Anfang;
Verteile;
Setze die Zeiger aller 3 Files auf den Anfang;
Mische;
bis L = 1;
(* L ist die Anzahl der Läufe auf dem File F *)
Verfeinerungsschritt:
Verteile
Wiederhole
Kopiere ein Lauf F auf G1
Falls
noch nicht eof(F),
kopiere einen Lauf von F auf G2;
bis Ende von F erreicht;
Mische
158
Algorithmen und Datenstrukturen
Setze L = 0;
Solange weder eof(G1) noch eof(G2) fuehre aus:
Mische je einen Lauf von G1 und G2 auf F;
Erhoehe L um 1;
Solange eof(G1) noch nicht erreicht, fuehre aus:
Kopiere einen Lauf von G1 auf F;
Erhöhe L um 1;
Solange eof(G2) noch nicht erreicht, fuehre aus:
Kopiere einen Lauf von G2 auf F;
Erhöhe L um 1;
159
Algorithmen und Datenstrukturen
3.1.2 Suche in Texten
Textverarbeitung. Texte sind nicht weiter strukturierte Folgen beliebiger Zeichen aus
einem Alphabet. Das Alphabet enthält Buchstaben, Ziffern, zahlreiche
Sonderzeichen. Der dazu grundlegende abstrakte Datentyp ist der „String“
(Zeichenkette). Bereitgestellt wird dieser Datentyp auf unterschiedliche Art, z.B. als
Grundtyp (Pascal) bzw. Klasse (Java, C++) oder „file of characters“ oder
„array of characters“ (C) oder einfach als verkettete Liste von Zeichen.
Unabhängig davon besitzt jede Zeichenkette eine bestimmte Länge und einen Zugriff
über einen Index. So soll der Zugriff auf das i-te Zeichen für jedes i >= 1 bzw. 0
möglich sein.
Algorithmen zur Verarbeitung von Zeichenketten („string processing“)
umfassen ein weites Spektrum. Dazu gehören die Suche nach Texten, das
Erkennen bestimmter Muster („pattern matching“), das Verschlüsseln,
Komprimieren und Analysieren (parsing) von Texten.
Das Suchproblem (Erkennen bestimmter Muster im Text). Es kann folgendermaßen
formuliert werden:
Gegeben ist eine Zeichenkette (Text) mit Zeichen z1...zN aus einem Alphabet und ein Muster (pattern)
{m1 , m2 ,..., m M } mit (1 <= i <= M). Gesucht sind die Vorkommen von m 1...mM in z1...zN, d.h.
Indexe i mit i <= (N-M+1) und zi=m1, zi+1=m2, ... , zi+M=mM. In der Regel ist N sehr viel größer als M.
Zu den Algorithmen für die Verarbeitung von Zeichenketten (string processing)
gehören das Suchen bzw. das Erkennen bestimmter Muster im Text (pattern
matching), das Verschlüsseln und Komprimieren von Texten, das Analysieren
(parsing) und das Übersetzen von Texten. Unter string matching versteht man
den Vergleich von Zeichenketten, ein mismatch kennzeichnet eine nicht
übereinstimmende Position.
Statischer und dynamischer Text. Man unterscheidet:
Statischer Text wird nur wenig geändert im Verhältnis zum Umfang des Werks (Lexika,
Nachschlagewerke). Hier lohnt sich durch Ergänzen mit geeigneter Information (z.B. Index) ein
Aufbereiten des Textes zur Unterstützung der Suche nach verschiedenen Mustern.
Dynamischer Text unterliegt häufig umfangreichen Änderungen (z.B. Bearbeitungen mit Texteditoren).
Die folgenden Darstellungen von Algorithmen umfassen die Bearbeitung von dynamischen Text.
Implementierung und Test der Suchalgorithmen. Zur Kontrolle der Wirkungsweise
bzw. Implementierung der verschiedenen Verfahren für die Suche in Texten wird in
C++ die Klasse „suche“ bereitgestellt.
#include <string.h>
class suche
{
private:
const char *muster;
int m;
// Musterlaenge
public:
suche(const char *pat);
// Konstruktor
~suche(){}
// Destruktor
char *finden(char *text);
};
160
Algorithmen und Datenstrukturen
Die verschiedenen Suchalgorithmen der Textverarbeitung sind jeweils in der
Methode finden() angegeben. Auch der Konstruktor wird an die spezifische
Suchmethode angepaßt.
Das folgende Programmschema72 zeigt, wie die Klasse „suche“ in die
Textverarbeitung einbezogen wird:
// Durchsuchen einer Textdatei nach einem Zeichenmuster
#include
#include
#include
#include
#include
<fstream.h>
<iomanip.h>
<stdlib.h>
<string.h>
"suche.h"
void main(void)
{
char dName[50];
cout << "Eingabe-Datei: ";
cin >> setw(50) >> dName;
char musterWort[50];
cout << "Muster: ";
cin >> musterWort;
ifstream eingabe(dName, ios::in);
if (!eingabe)
{
cout << "Kann " << dName << " nicht oeffnen" << endl;
exit(1);
}
// Bestimme Laenge der Textdatei:
int n = 0, i;
char zch;
while (eingabe.get(zch), !eingabe.fail()) n++;
eingabe.clear(); eingabe.seekg(0);
char *text = new char[n + 1];
char *p = text, *pattern = musterWort;
// Einlesen der TextDatei:
for (i = 0; i < n; i++) eingabe.get(text[i]);
text[n] = '\0';
// Spezfikation Muster:
suche x(pattern);
// Ermittle alle Vorkommen von Muster im Text:
cout << "Text vom gefundenen Muster an bis Zeilenende:\n";
while ((p = x.finden(p)) != 0)
{
for (i = 0; p[i] != '\0' && p[i] != '\n'; i++) cout << p[i];
cout << endl;
p++;
}
// cin >> zch;
}
Der zu durchsuchende Text wird aus einer Textdatei in die Zeichenkette „text“
eingelesen. Nachdem eine Instanz der Klasse „suche“ mit dem Suchmuster
(pattern) gebildet wurde, erfolgt der Aufruf über die Methode finden() die Suche
nach dem Muster. Bei erfolgreicher Suche wird der Text vom gefundenen Muster an
bis zum Zeilenende ausgegeben.
72
PR24140.CPP
161
Algorithmen und Datenstrukturen
3.1.2.1 Ein einfacher Algorithmus zum Suchen in Zeichenfolgen
1. Das naive Verfahren zur Textsuche (Brute-Force Algorithmus)
Beschreibung. Am einfachsten läßt sich ein Vorkommen des Musters m 1, ... , mM im
Text mit den Zeichen z1, ... , zN folgendermaßen bestimmen: Man legt das Muster,
das erste Zeichen im Text ist der Startpunkt, der Reihe nach an jeden Teilstring vom
text mit der Länge M an und vergleicht zeichenweise von links nach rechts, ob eine
Übereinstimmung zwischen Muster und Text vorliegt oder nicht. Das geschieht
solange, bis man das Vorkommen des Musters im Text gefunden oder das Ende des
Texts erreicht hat.
Implementierung.
char *suche::finden(char* text)
{
// Brute Force Algorithmus, Das naive Verfahren zur Textsuche
int i = 0, j = 0;
int letzterInd = strlen(text)- m;
while ((j < m) && (i < letzterInd))
{
if (text[i] == muster[j]) { i++; j++; }
else { i -= j - 1; j = 0; }
}
if (j == m) return &text[i - m];
else return 0;
}
Analyse. Bei diesem Verfahren muß das Muster (N-M+1)-mal an den Text angelegt
werden und dann jeweils ganz durchlaufen werden. Das bedeutet: Es müssen
( N  M  1)  M
Vergleiche
ausgeführt
werden.
Da
( N  M  1)  M  ( N  M  M )  M  N  M ist, liegt im schlimmsten Fall der Aufwand
in der Größenordnung von O( N  M ) .
2. Mustererkennung über den Vergleich des ersten und des letzten Zeichens
Beschreibunng. Der Algorithmus vergleicht das erste und letzte Zeichen des Musters
in einem Textbereich mit der Länge des Musters. Der Index, der zu einem
übereinstimmenden Zeichen in der Zeichenkette verweist, wird in trefferAnf
gespeichert. „trefferEnde“ ist der Index des letzten Zeichens im Textbereich, das
mit dem letzten Zeichen im Muster übereinstimmen muß. Sind trefferAnf und
trefferEnde bestimmt, dann werden die Zeichen des so definierten Textbereichs
ohne das erste und letzte Zeichen mit dem entsprechenden Teilstring des Musters
verglichen. Falls auch sie übereinstimmen ist das Muster gefunden, andernfalls muß
der Text weiter (bis zum Ende) durchsucht werden.
Implementierung.
char* suche::finden(char* text)
{
int i = 0, j = 1, k;
int letzterInd = strlen(text) - m;
while (i < letzterInd)
162
Algorithmen und Datenstrukturen
{
while (text[i] != muster[0]) i++;
int trefferAnf = i;
if (trefferAnf > letzterInd) return 0;
int trefferEnde = trefferAnf + m - 1;
if (trefferEnde > letzterInd) return 0;
if (text[trefferEnde] == muster[m - 1])
{
if (m <= 2) return &text[trefferAnf];
for (k = trefferAnf + 1; k <= trefferAnf + m - 2; k++)
{
if (text[k] != muster[j++]) { i = k; break; }
i = k;
}
if (i == trefferAnf + m - 2) return &text[trefferAnf];
}
i++; j = 1;
}
return 0;
}
Analyse. Falls die ersten m Zeichen in der Zeichenkette text zum Muster passen,
wird das über M Vergleiche ermittelt. Im besten Fall ist der vorliegende Algorithmus
bzgl. der rechnerischen Komplexität von der Ordnung O(M). Im schlimmsten Fall
müssen die Zeichen des String bis zum Ende der Zeichenkette durchsucht und
verglichen werden. U.U. kann dabei auftreten, daß jeweils die ersten Zeichen beim
Vergleich Muster mit denen in der Zeichenkette übereinstimmen, das Muster selbst
jedoch mit keinem Teilbereich von Zeichen aus text zusammenpaßt.
Bsp.: Das Muster „abc“ und der Text „aaaaaaaa“.
Die 3 Zeichen des Muster „abc“ müssen sechsmal, d.h. N-M+1mal verglichen
werden.
Generell müssen M Zeichen (N-M+1)mal verglichen werden, das führt zu
M  ( N  M  1) Vergleichen. Da M  ( N  M  1)  M  ( N  M  M )  N  M ist, liegt
im schlimmsten Fall der Aufwand in der Größenordnung von O( N  M ) . Der
vorliegende Algorithmus zeigt keine wesentliche Verbesserung gegenüber dem unter
1. angegebenen naiven Algorithmus zur Textsuche.
Die naiven Verfahren behalten nicht, welche Zeichen im Text mit einem
Anfangsstück des Musters übereinstimmen, bis eine Fehlanzeige (mismatch)
auftrat. In diesem Sinnen sind die naiven Verfahren gedächtnislos.
163
Algorithmen und Datenstrukturen
3.1.2.2 Das Verfahren von Knuth-Morris-Pratt
Beschreibung des Algorithmus. Der Algorithmus beruht auf folgender Überlegung:
Wenn an einer bestimmten Position eine Nichtübereinstimmung festgestellt wird,
dann sind die bis zu dieser Fehlposition bearbeiteten Zeichen bekannt. Diese
Information soll genutzt werden, und der Zeiger „i“, der die Zeichen durchläuft, sollte
nicht so einfach über all diese bereits bekannten Zeichen hinweggesetzt werden. In
der folgenden Darstellung kommt es erst zur Fehlanzeige an der 6. Zeigerposition
„j“. Das Erkennen einer Fehlanzeige erfolgt nach „j-1“ Zeichen.
i
Index
text[]
[0]
A
muster[] A
skip
-1
[1]
B
[2]
C
[3]
A
[4]
B
[5]
C
B
C
A
B
D
1
j
2
0
0
0
[6]
A
A
[7]
B
B
[8]
D
C
A
[9]
A
B
D
Abb.: Darstellung des Verfahrensablaufs
Würde kein einziges Zeichen sich in dem Muster wiederholen, könnte der Textzeiger
i um eine Einheit erhöht werden und der Musteranfang auf diese Textstelle nach
einer Fehlanzeige verschoben werden. Wiederholen sich Zeichen im Muster, d.h.
stimmen Zeichenbereiche am Anfang des Musters mit Zeichenbereichen am Ende
des Musters überein, dann braucht für den Vergleich der Zeiger im Muster lediglich
um einige Positionen zurückgesetzt werden und zwar genau bis zu der Stelle, an der
Übereinstimmung festgestellt wurde.
Die Sprungtabelle. Die Sprungtabelle skip[0..M-1] wird zum Zurücksetzen bei
Nichtübereinstimmung benutzt. Zur Konstuktion dieser Sprungtabelle schreibt man
das Musterwort in zwei Zeilen untereinander, das erste Zeichen liegt anfangs unter
dem zweiten, z.B.:
A
B
A
C
B
A
A
C
B
A
B
A
C
B
D
B
A
C
D
B
A
D
B
D
Dann wird die untere Kopie soweit nach rechts verschoben, bis alle sich
überlappenden Zeichen übereinstimmen (bzw. keine Zeichen sich mehr überlappen).
Die übereinander liegenden Zeichen definieren die nächste Stelle, wo das Muster
passen kann, wenn eine Nichtübereinstummung an der Stelle j des Musters
festgestellt wurde.
164
Algorithmen und Datenstrukturen
Das Feld skip bestimmt das Rücksetzen des Textzeigers i. Zeigen i und j auf
nicht übereinstimmende Zeichen in Text und Muster, so beginnt die nächste
mögliche Position für eine Übereinstimmung mit dem Muster bei Position i skip[j]. Da aber gemäß Definition von skip die ersten skip[j] Zeichen in
dieser Position mit den ersten skip[j] Zeichen des Musters übereinstimmen, kann
i unverändert bleiben und j auf skip[j] gesetzt werden:
suche::suche(const char *must)
{ muster = must;
m = strlen(muster);
int i, j;
skip[0] = -1;
for (i = 0, j = -1; i < m; i++, j++, skip[i] = j)
while (( j >= 0) && (muster[i] != muster[j])) j = skip[j];
}
char *suche::finden(char* text)
{
int i, j;
int letzterInd = strlen(text);
for (i = 0, j = 0; j < m && i < letzterInd; i++, j++)
while ((j >= 0) && (text[i] != muster[j])) j = skip[j];
if (j == m) return &text[i-m];
else return 0;
}
Falls j == 0 und text[i] mit dem Muster nicht übereinstimmt, gibt es keine
übereinstimmende Zeichen. In diesem Fall wird i erhöht und j auf den Anfangswert
des Musters zurückgesetzt. Dies wird erreicht, indem skip[0] durch Definition aud
-1 gesetzt wird, danach wird i erhöht und j beim nächsten Schleifendurchlauf auf
0 gesetzt.
Berechnung der Sprungtabelle. Das Muster wird auf Übereinstimmung mit sich selbst
geprüft:
skip[0] = -1;
for (i = 0, j = -1; i < m; i++, j++, skip[i] = j)
while (( j >= 0) && (muster[i] != muster[j])) j = skip[j];
Unmittelbar nachdem i und j erhöht wurden, steht fest: Die ersten j Zeichen des
Musters stimmen mit den Zeichen an den Positionen muster[i - j + 1] bis
muster[i - 1] überein, d.h. die letzten j Zeichen mit den ersten i Zeichen. Dies
ist das größte j mit dieser Eigenschaft, da andernfalls eine mögliche
Übereinstimmung des Musters mit sich selbst verpaßt worden wäre. Folglich ist j
genau der Wert, der skip[j] zugewiesen werden muß.
165
Algorithmen und Datenstrukturen
3.1.2.3 Das Verfahren von Boyer-Moore
Beschreibung. Die Zeichen im Muster werden nicht von links nach rechts, sondern
von rechts nach links mit den Zeichen im Text verglichen. Man legt das Muster zwar
der Reihe nach an von links nach rechts wachsende Textpositionen, beginnt aber
einen Vergleich zwischen Zeichen im Text und Zeichen im Muster immer beim
letzten Zeichen im Muster. Tritt dabei kein Fehler (d.h. keine Übereinstimmung) auf,
hat man ein Vorkommen des Musters im Text gefunden. Tritt ein Fehler auf, so wird
eine Verschiebung des Musters berechnet, d.h. die Anzahl von Positionen, um die
man das Muster nach rechts verschieben kann, bevor ein erneuter Vergleich
zwischen Muster und Text (Beginn wieder mit dem letzten Zeichen im Muster)
durchgeführt wird.
Soll bspw. ein 4 Zeichen umfassendes Wort in einem Textfeld (text[]) gesucht
werden, dann liegt zu Beginn folgende Situation vor:
Index
text[]
wort
[0]
A
A
[1]
A
B
[2]
B
C
[3]
B
D
[4]
A
[5]
B
[6]
C
[7]
D
[8]
E
[9]
F
....
...
aktuelle Position
Nach dem Boyer-Moore-Verfahren findet der erste Vergleich an der „aktuellen
Position“ statt: text[3] und muster[3] werden miteinander verglichen. Falls die
zugehörigen Zeichen gleich sind, setzen sich die Vergleiche (nach links) so lange
fort, bis das Wort gefunden ist oder unterschiedliche Buchstaben zwischen Text und
Wort (muster) erkannt wurden. Hier liegt gleich zu Beginn ein Unterschied vor. In
dieser Situation kann man zwei Fälle unterscheiden:
1. Der Buchstabe an der aktuellen Position im „text“ (hier liegt ein Leerzeichen vor) kommt im
Musterwort nicht vor. Dann kann ein Wort im Bereich von 0 bis „aktuelle Position“ an keiner Stelle
im Text beginnen, da sonst mindestens ein Buchstabe aus dem Muster gleich dem Textzeichen in
„aktuelle Position“ sein müßte. Der Eingabezeiger, der im Moment auf aktuelle Position steht, läßt
sich dann ohne weiteren Vergleich um die Länge des Musterworts zur Ausrichtung des Suchworts
für einen neuen Vergleich erhöhen.
2. Das Zeichen, das an der aktuellen Position von „text“ steht, kommt im Musterwort vor. Dann muß
dieses Wort so ausgerichtet werden, daß das rechteste alle Vorkommen im „muster“ unter der
Stelle „aktuelle Position“ im „text“ steht.
Die folgende Darstellung zeigt eine solche Situation:
Index
text[]
wort
[0]
A
A
[1]
A
B
[2]
B
C
A
[3]
B
D
B
[4]
A
[5]
B
[6]
C
[7]
D
C
A
D
B
C
D
[8]
E
[9]
F
....
...
Der Wert, um den der Zeiger dann zu erhöhen ist, steht in einer Tabelle, die
„Delta1 (bzw. skip1)“ genannt wird. Delta1 ist eine Funktion des jewiligen
Zeichens in „text“ an der Stelle der aktuellen Position. Falls das an der aktuelle
Position vorkommende Textzeichen im Suchwort nicht vorkommt, ist Delta1 gleich
der Länge des Suchworts, sonst ist Delta1 die Differenz zwischen Musterwortlänge
und der rechtesten Position von Zeichen an der (aktuellen (Text-) Position im
„muster“. Vor der eigentlichen Suche muß man Delta1 für jedes Zeichen des
166
Algorithmen und Datenstrukturen
zugrundeliegenden Alphabets (in der Regel ASCII-Code)
Berechnung von Delta1 kann folgendermaßen realisiert sein:
berechnen.
Die
// Berechnung von Delta1
for (zch = 0; zch < 256; zch++)
{ p = strrchr(muster, zch);
h = int(p ? p - muster : -1);
// Falls h != -1, muster[h] == zch
skip1[zch] = m - 1 - h;
}
Natürlich kann es auch eine Übereinstimmung zwischen dem Textzeichen an
„aktueller Position“ und dem Zeichen im Muster (muster[m-1]) geben. Dann
werden die anderen Zeichen von „Muster“ solange sukzessive mit den
entsprechenden Zeichen im „text“ verglichen bis entweder „muster“ im „text“
vorkommt oder nach „m“ Vergleichen ein Buchstabe im Text auftaucht, der nicht zu
jenen in „muster“ paßt. In der folgenden Darstellung ist für das 8 Zeichen
umfassende Musterwort eine Übereinstimmungh zwischen dem Musterwort an der
Stelle m - 1 (muster[m-1]) und einem Textzeichen gegeben:
Index
text[]
wort
[0]
...
D
[1]
...
A
[2]
...
B
[3]
...
C
[4]
D
E
[5]
A
A
[6]
B
B
[7]
C
C
[8]
E
[9]
A
[10]
B
[11]
C
Nach insgesamt 2 Übereinstimmungen paßt das Textzeichen „D“ nicht zum Muster
„E“. Das Suchwort soll nun möglichst weit nach rechts verschoben werden, so daß
das Teilwort „ABC“ mit dem Textzeichen übereinstimmt und zum anderen ein
Zeichen ungleich „E“ dem Teilwort im Muster vorausgeht. Die Position im Wort, an
der das am weitesten rechts stehende Teilwort („ABC“) beginnt, das die Bedingungen
erfüllt, ist die „right most plausible occurrence“.
Wie weit man nach rechts verschieben darf, hängt von der Position im Muster ab, an
der das dort vorliegende Zeichen ungleich dem zur Untersuchnug anstehenden
Textzeichen ist. Zunächst kann das Muster auf das am weitesten rechts gelegene
Teilwort ausgerichtet werden (im vorliegenden Beispiel umfaßt das Teilwort 3 mit
„text“ übereinstimmende Zeichen), die Ausrichtung erfolgt durch Verschieben um
(4 + 1 - 1) Positionen. Anschließend muß man noch auf das rechte Wortende
erhöhen (im Bsp. um 7 - 3). Die Summe dieser beiden Werte für das Verschieben
soll „Delta2 (bzw. skip2)“ genannt werden und muß für jede Stelle im Muster
berechnet werden. Die Berechnung von Delta2 kann folgendermaßen realisiert
werden:
// Berechnung von Delta2
for (i = m - 2; i >= 0; i--)
{ p = muster + i + 1; // Guter Nachspann, p folgt Position i.
suflen = m - 1 - i; // Teilwortlaenge
for (h = i; h >= 0; h--)
if (strncmp(muster + h, p, suflen) == 0) break;
skip2[i] = i + 1 - h; // h = -1 falls nicht gefunden.
}
Die Klasse „suche“ für das Verfahren von Boyer-Moore
#include <string.h>
inline int max(int x, int y){return x > y ? x : y;}
167
Algorithmen und Datenstrukturen
class suche
{
private:
const char *muster;
int skip1[256],
// bezogen auf nicht passende Zeichen: Delta1
*skip2,
// bezogen auf guten Nachspann: Delta2
m;
// Musterlaenge
public:
suche(const char *pat);
~suche(){delete[] skip2;}
char *finden(char *text);
};
suche::suche(const char *must)
{ muster = must;
m = strlen(muster);
skip2 = new int[m-1];
const char *p;
int zch, i, h, suflen;
// Berechnung von Delta1
for (zch = 0; zch < 256; zch++)
{ p = strrchr(muster, zch);
h = int(p ? p - muster : -1);
// Falls h != -1, muster[h] == zch
skip1[zch] = m - 1 - h;
}
// Berechnung von Delta2
for (i = m - 2; i >= 0; i--)
{ p = muster + i + 1; // Guter Nachspann, p folgt Position i.
suflen = m - 1 - i; // Teilwortlaenge
for (h = i; h >= 0; h--)
if (strncmp(muster + h, p, suflen) == 0) break;
skip2[i] = i + 1 - h; // h = -1 falls nicht gefunden.
}
// Abhaengig von muster[i] != text[j] = zch, wird das Muster
// entweder um skip1[zch] = m-1-i oder skip2[i] Positionen
// nach rechts verschoben, je nachdem welcher Sprungtabellen// wert groesser ist.
}
char *suche::finden(char* text)
{
int letztes = m - 1, k = letztes, j, i;
// Das letzte Zeichen im Muster wird
// mit text[k] verglichen:
char zch;
int n = strlen(text);
while (k < n)
{
zch = text[k];
if (muster[letztes] != zch) k += skip1[zch];
else
{ i = letztes; j = k;
do
{ if (--i < 0) return text + k - letztes;
// Passendes wurde gefunden!
} while (muster[i] == text[--j]);
k += max(skip1[text[j]] - (letztes - i), skip2[i]);
}
}
return 0;
}
168
Algorithmen und Datenstrukturen
3.2 Entwurfstechniken für Algorithmen
(Einsatz von Algorithmen-Mustern)
Idee. Anpassung von generischen
Problemklassen an eine konkrete Aufgabe
Algorithmenmustern
für
bestimmte
3.2.1 Greedy Algorithmen
Ein einführendes Beispiel. Auf Geldbeträge unter 1 DM soll Wechselgeld
herausgegeben werden. Zur Verfügung stehen ausreichend Münzen mit den Werten
50, 10, 5, 2, 1 Pfennig. Das Wechselgeld soll aus so wenig Münzen wie möglich
bestehen.
Also: 50 + 2 * 10 + 5 + 2 + 1. Der Greedy-Algorithmus bestimmt: Nimm
jeweils die größte Münze unter Zielwert, und ziehe sie von diesem ab. Verfahre
derart bis Zielwert gleich Null.
Greedy-Algorithmen berechnen lokales Optimum, z.B.:
Münzen 11, 5 und 1; Zielwert 15.
Greedy: 11 + 1 + 1 + 1 + 1
Optimum: 5 + 5 + 5
Aber in vielen Fällen entsprechen lokale Optima den globalen bzw. reicht ein lokales
Optimum aus.
Eigenschaften von Greedy-Algorithmen:
1. Gegebene Menge Werte von Eingabewerten
2. Menge von Lösungen, die aus Eingabewerten aufgebaut sind
3. Lösungen lassen sich schrittweise aus partiellen Lösungen, beginnend bei
der leeren Lösung, durch Hinzunahme von Eingabewerten aufbauen
4. Bewertungsfunktion für partielle und vollständige Lösungen
5. Gesucht wird die / eine optimale Lösung
3.2.1.1 Ein einfaches Planungs-Problem
169
Algorithmen und Datenstrukturen
3.2.1.2 Huffman Codes
Definition. Der Huffman-Algorithmus erzeugt einen Binärcode für eine gegebene
Zeichenmenge gemäß der Häufigkeit jedes einzelnen Zeichens in einem Text. Je
öfter ein Zeichen auftritt, desto kürzer ist die ihm entsprechende Bitfolge. In diesem
Sinne liefert der Algorithmus einen optimalen Code.
Bsp.: Codeworte fester Länge bei der Binärcodierung von Zeichen.
In einer Textdatei befinden sich bspw. die Zeichen „a“, „e“, „i“, „s“, „t“ und die Zeichen
„space“ bzw. „newline“. Die folgende Tabelle zeigt, in welcher Häufigkeit diese
Zeichen in der Textdatei vorkommen:
Zeichen (Character)
a
e
i
s
t
space
newline
Häufigkeit
10
15
12
3
4
13
1
Würde man diese Zeichen mit einem Binärcode fester Länge verschlüsseln, dann
könnte dies auf folgende Weise bspw. geschehen:
Zeichen (Character)
a
e
i
s
t
space
newline
Summe
Code
000
001
010
011
100
101
110
Häufigkeit
10
15
12
3
4
13
Anzahl Bits
30
45
36
9
12
39
3
174
Abb. Standard zur Binärcodierung von Zeichen
Bei größeren Dateien führt diese Art der Binärcodierung zur erheblichem
Platzbedarf. Besser wäre eine Codierung, die angepasst an die Häufigkeit von
Vorkommen der Zeichen, für die einzelnen Zeichen Codeworte variabler Länge
vorsieht. Ein besonders häufig vorkommendes Zeichen, erhält ein kurzes Codewort.
Ein weniger häufig vorkommendes Zeichen erhält ein längeres Codewort
zugeordnet.
Der Algorithmus von Huffman. Zu Beginn liegen die einzelnen Zeichen mit ihren
Häufigkeiten in der folgenden Form vor:
10
a
15
e
12
i
3
s
4
t
13
sp
1
nl
Die beiden Knoten(Bäume) mit den niedigsten Gewichtswerten (Häufigkeiten)
werden zusammengefasst:
170
Algorithmen und Datenstrukturen
10
a
15
12
e
i
4
13
t
4
sp
T1
s
nl
Abb. Huffman Algorithmus nach dem ersten Mischen
Die beiden Bäume mit dem kleinsten Gewicht wurden zusammengemischt. Das
Gewicht des neuen Baums entspricht der Summe der Gewichte der alten Bäume.
10
a
15
12
e
i
13
8
sp
T2
T1
s
t
nl
Abb. Huffman Algorithmus nach dem zweiten Mischen
15
e
12
i
13
18
sp
T3
T2
T1
s
a
t
nl
Abb. Huffman Algorithmus nach dem dritten Mischen
Es gibt jetzt vier Bäume, aus denen die beiden Bäume mit dem kleinsten Gewicht
gewählt werden.
171
Algorithmen und Datenstrukturen
15
25
e
18
T4
i
T3
sp
T2
T1
s
a
t
nl
Abb. Huffman Algorithmus nach dem vierten Mischen
Im fünften Schritt werden die Bäume mit den Wurzeln „e“ und „T 3“ gemischt, da sie
jetzt die kleinsten Gewichte haben.
25
33
T4
i
T5
sp
T3
T2
T1
s
Abb. Huffman Algorithmus nach dem fünften Mischen
172
a
t
nl
e
Algorithmen und Datenstrukturen
58
T6
T5
T4
T3
e
T2
sp
a
T1
s
i
t
nl
Abb. Huffman Algorithmus nach Erreichen des optimalen Baums
Für die Codierung der Zeichen steht der folgende optimale Präfix-Code bereit:
0
0
1
1
0
e
0
i
1
sp
1
a
0
1
t
0
s
1
nl
Abb. Optimaler Präfix-Code
Die Tabelle für die Codierung der Zeichen im vorliegenden Beispiel hat folgende
Gestalt:
173
Algorithmen und Datenstrukturen
Zeichen
A
E
I
S
T
Space
Newline
Summe
Code
001
01
10
00000
0001
11
00001
Häufigkeit
10
15
12
3
4
13
1
Anzahl Bits
30
30
24
15
16
26
5
146
Abb. Optimaler Präfix-Code für das vorliegende Beispiel
Die Codierung benutzt nur 146 Bits.
Der Huffman-Algorithmus ist ein Greedy-Algorithmus. Auf jeder Ebene wird ein
Mischvorgang ausgeführt ohne Rücksicht auf globale Betrachtungen. Gemischt
werden lokal die Bäume mit den kleinsten Gewichtswerten. Das Gewicht eines
Baums ist gleich der Summe der Häufigkeiten von seinen Blättern bzw. Teilbäumen.
Damit kann der Algorithmus folgendermaßen formuliert werden:
- Wähle die beiden Bäume T1 und T2 mit dem kleinsten Gewicht aus und bilde daraus
einen neuen Baum mit den Teilbäumen T1 und T2.
- Zu Beginn gibt es nur Bäume, die aus einzelnen Knoten bestehen. Jeder Knoten
besteht steht für ein einzelnes Zeichen.
- Am Ende gibt es nur einen einzigen Baum und das ist der optimale Baum mit dem Huffman-Code.
Aufwand. Werden die Bäume in einer Priority-Queue verwaltet, dann beträgt die
Laufzeit O(C73logC). Wird die Priority-Queue einfach in einer verketteten Liste
implementiert, dann beträgt die Laufzeit O(C2)
Zu dieser Aufwandsgröße muß noch der Aufwand zur Ermittlung der Häufigkeit der
Zeichen addiert werden.
73
C ist die Anzahl der Zeichen.
174
Algorithmen und Datenstrukturen
3.2.2 Divide and Conquer
Typisches Beispiel. Quicksort
Prinzip. Rekursive Rückführung auf identisches Problem mit kleiner Eingabemenge.
Divide-and-Conquer-Algorithmen arbeiten grundsätzlich so:
Teile das gegebene Problem in mehrere getrennte Teilprobleme auf, löse diese einzeln und setze die
Lösungen des ursprünglichen Problems aus den Teillösungen zusammen.
Wende dieselbe Technik auf jedes der Teilprobleme an, dann auf deren Teilprobleme usw. bis die
Teilprobleme klein genug sind, dass man eine Lösung explizit angeben kann.
Trachte danach, dass jedes Teilproblem derselben Art ist wie das ursprüngliche Problem, so dass es
mit demselben Algorithmus gelöst werden kann.
Weitere Beispiele für Divide and Conquer.
1. Merge-Sort
2. Türme von Hanoi
3.2.3 Dynamisches Programmieren
Jede rekursive mathematische Formel kann direkt in einen rekursiven Algorithmus
überführt werden. Häufig führt das zu ineffizienten Programmen. Es ist daher
zweckmäßig, den Computer bei der Abwicklung rekursiver Algorithmen zu
unterstützen. Eine Technik, die das ausführt, ist bekannt unter dem Namen
„dynamisches Programmieren“. Antworten, die bei der Lösung von Teilproblemen
anfallen, werden in einer Tabelle im Rahmen eines nicht rekursiven Programms
zwischengespeichert.
Beispiele.
Fibonacci-Zahlen
Ineffizienter rekursiver Algorithmus74
public static int fibrek(int n)
{
if (n <= 1) return 1;
else return fibrek(n - 1) + fibrek(n -2);
}
Diese so elegant aussehende Lösung ist furchtbar schlecht. Sie benötigt eine
Laufzeit T(N) >= T(N-1) + T(N-2). Da T(N) die gleiche rekurive Beziehung
besitzt wie Fibonacci-Zahlen, wächst T(N) genauso wie Fibonacci-Zahlen wachsen,
d.h. exponentiell. Eine Protokollierung der rekursiven Aufrufe für fibrek(6) zeigt
dies:
74
pr32301
175
Algorithmen und Datenstrukturen
fibrek(6)
fibrek(5)
fibrek(4)
fibrek(3)
fibrek(4)
fibrek(3)
fibrek(2)
fibrek(3)
fibrek(2) fibrek(1) fibrek(2)
fibrek(2)
fibrek(1)
fibrek(1) fibrek(0)
fibrek(2) fibrek(1) fibrek(1) fibrek(0) fibrek(1) fibrek(0) fibrek(1) fibrek(0)
fibrek(1) fibrek(0)
Bei einer genaueren Betrachtung stellt man fest: F N-1 wird nur einmal aufgerufen,
aber FN-2 wird zweimal, FN-3 wird dreimal, FN-4 wird viermal, FN-5 wird fünfmal
berechnet, usw.
Zur Berechnung von FN wird aber lediglich die Berechnung von FN-1 und FN-2 benötigt.
Man braucht sich dabei nur auf die aktuell errechneten Werte zu beziehen. Das führt
zu dem folgenden Algorithmus75 mit dem Aufwand O(N).
public static int fibit(int n)
{
if (n <= 1) return 1;
int letzterWert = 1;
int vorletzterWert = 1;
int resultat = 1;
for (int i = 2;i <= n; i++)
{
resultat = letzterWert + vorletzterWert;
vorletzterWert = letzterWert;
letzterWert
= resultat;
}
return resultat;
}
75
pr32301
176
Algorithmen und Datenstrukturen
3.3 Rekursive Algorithmen
Merkmal. Rekursives Anwenden einer Problemlösungsstrategie auf Teilproblem.
3.3.1 Prinzip der Rekursion an Beispielen erläutert
Prinzipiell gilt: Alle Aufgaben, die mit einer Iteration lösbar sind, sind auch ohne
Schleifen durch Rekursion lösbar. Rekursionen kann man meistens mit Hilfe von
Iterationen simulieren. Oft sind rekursive Lösungen einfacher zu verstehen,
verbrauchen aber für die Abarbeitung mehr Speicherplatz.
Beispiele.
1. Fibonacci-Zahlen
2. Die Ackermann-Funktion
Die Rekursion eignet sich für die Definition von Funktionen, die sehr schnell
wachsen. So ist die Ackermann-Funktion76 ein Beispiel für eine Rekursion, die nicht
(oder zumindest nicht direkt) durch Iteration ersetzt werden kann.
Die Werte der Ackermann-Funktion (mit zwei Argumenten n und m) sind durch die
folgenden Formeln definiert:
a m0  m  1
a 0n  a1n 1
a mn  a ann1
m 1
Die Ackermann-Funktion wächst stärker als jede primitiv rekursive Funktion.
/*
* Acker.java
*/
public class Acker
{
static int a(int x, int
{
if (x == 0) return y +
if (y == 0) return a(x
return a(x - 1,a(x,y }
y)
1;
- 1,1);
1));
public static void main(String args[])
{
int x = Integer.parseInt(args[0]);
int y = Integer.parseInt(args[1]);
System.out.println(a(x,y));
}
}
76
Benannt nach dem Mathematiker F.W. Ackermann, 1896 - 1962
177
Algorithmen und Datenstrukturen
3.3.2 Rekursive Kurven
Das Wesen der Rekursion – insbesondere der Selbstähnlichkeit – kann mit Hilfe von
Monsterkurven demonstriert werden. Sie sind eindimensionale geometrische Gebilde
unendlicher Länge, die eine Fläche abdecken. Monsterkurven werden nach einem
regelmäßigen Muster schrittweise verfeinert. Sie errinnern an Formen, die mit Hilfe
von Fraktalen erstellt werden können. Die Rekursion ist geeignet, die unendlich
lange Monsterkurve mit endlich langen Kurven anzunähern. Solche Annäherungen
einer Monsterkurve kann man als rekursive Kurve bezeichnen.
Anweisungen für eine Monsterkurve können folgendermaßen formuliert werden:
1) Man nehme einen Initiator. Das kann eine einzelne Strecke sein, kann aber auch aus mehreren
Liniensegmenten bestehen.
2) Man nehme einen Generator, der den Wachstumsprozeß des Monsters festlegt. Der Generator
besteht aus mehreren Teilstrecken sowie einem Start- und einem Endpunkt.
Im ersten Konstruktionsschritt (1. Rekursionsstufe) werden die Initiator-Segmente
durch den Generator ersetzt. Der Generator wird dabei so gedreht und skaliert, daß
seine Begrenzungspunkte mit der Initiator-Strecke zusammenfallen.
Gibt es mehrere Initiator-Strecken, dann entstehen auch mehrere Generatoren. In
der zweiten Rekursionsstufe wird jedes einzelne Segment wiederum durch den
Generator ersetzt. Diesen Prozeß wiederhölt man so oft, bis das Fraktal (die
Monsterkurve) die gewünschte Einheit zeigt.
Zum Zeichnen von rekursiven Kurven wird die Technik der Turtle-Grafik77
herangezogen. Die Turtle (Grafik-Cursor) bewegt sich folgendermaßen über die
Ebene:
a) geradeaus in einem Schritt.
Nach dem Arbeitsgang werden die neu berechneten Kordinaten der Turtle bereitgestellt.
b) Wende über eine Drehung um einen bestimmten Winkel. Der Drehwinkel wird als
Parameter im Aufruf übergeben.
Beispiele.
1. Die Schneefockenkurve (Kochsche Kurve)
Ausgangspunkt ist eine Gerade (Initiator).
77
Am MIT wurde die Turtle-Grafik erfunden
178
Algorithmen und Datenstrukturen
Abb.: Schneeflockenkurve auf der Stufe 0
Diese Strecke wird in drei gleiche Teile unterteilt, und das Mittelstück durch 2 Seiten
eines gleichseitigen Dreiecks ersetzt.
Abb.: Schneeflockenkurve auf der Stufe 1
Im nächsten Schritt wird jede der vier Strecken des Generators, allerdings auf ein
Drittel seiner Größe reduziert, ersetzt.
179
Algorithmen und Datenstrukturen
Abb.: Schneeflockenkurve auf der Stufe 2
Die Prozedur für den Initiator umfaßt hie nur das Zeichnen der geraden Strecke.
Entscheidend ist der Generator. Hier wandert der Grafik-Cursor (die Turtle) zunächst
geradeaus (ein Drittel der vorhergehenden Stufe), wendet sich dann um 60 Grad
nach links, dann 120 Grad nach rechts, usw. (vgl. Abb.). Das wird auf den weiteren
Stufen fortgesetzt und führt zu einem allgemeinen Bildungsgesetz einer rekursiven
Prozedur mit zwei Parametern im rekursiven Aufruf: „stufe – 1“, „laenge/3“.
public void schneeflocke(int stufe,int laenge)
{
double xAlt, yAlt;
if (stufe == 0)
{
xAlt = turtle.holeTurtleX(); // System.out.println(xAlt);
yAlt = turtle.holeTurtleY(); // System.out.println(yAlt);
turtle.setzeTurtleR(laenge);
turtle.schritt();
grafKontext.drawLine((int) xAlt, (int) yAlt,
(int) turtle.holeTurtleX(),
(int) turtle.holeTurtleY());
}
else {
schneeflocke(stufe-1,laenge/3);
turtle.wende(-60);
schneeflocke(stufe-1,laenge/3);
turtle.wende(120);
schneeflocke(stufe-1,laenge/3);
turtle.wende(-60);
schneeflocke(stufe-1,laenge/3);
}
}
2. Die Drachenkurve
Faltet man einen Papierstreifen doppelt in der Mitte und öffnet ihn dann so, daß sich
rechte Winkel bilden, dann erhält man – von der Seite gesehen – die zweite Stufe
der Drachenkurve.
180
Algorithmen und Datenstrukturen
Abb.: Stufe 2 der Drachenkurve
Der Initiator ist wieder die Einheitsstrecke, der Generator besteht aus zwei rechten
Winkeln.
Abb.: Stufe 3 der Drachenkurve
Es ist zwischen Links- und Rechtsorientierung zu unterscheiden (Parameter
Orientierung). Im weiteren Verlauf (Stufe 15) führt das zu folgendem Bild:
181
Algorithmen und Datenstrukturen
Abb.: Stufe 15 der Drachenkurve
Die folgende rekursive Prozedur78 ermöglicht das Zeichnen der vorliegenden
Drachenkurven:
public void drache(int stufe,double laenge,boolean richtung)
{
double wurzelZwei = Math.sqrt(2);
double xAlt, yAlt;
if (stufe == 0)
{
xAlt = turtle.holeTurtleX(); // System.out.println(xAlt);
yAlt = turtle.holeTurtleY(); // System.out.println(yAlt);
turtle.setzeTurtleR(laenge);
turtle.schritt();
grafKontext.drawLine((int) xAlt, (int) yAlt,
(int) turtle.holeTurtleX(),
(int) turtle.holeTurtleY());
}
else {
final int gradLinks = 45 * (richtung ? 1 : -1);
final int gradRechts = 90 * (richtung ? -1 : 1);
turtle.wende(gradLinks);
drache(stufe-1,laenge/wurzelZwei,true);
turtle.wende(gradRechts);
drache(stufe-1,laenge/wurzelZwei,false);
turtle.wende(gradLinks);
}
}
78
pr33201
182
Algorithmen und Datenstrukturen
3.4 Backtracking-Algorithmen
Backtracking ist eine systematische Art der Suche in einem vorgebenen Suchraum.
Wenn eine Teillösung in eine Sackgasse führt, dann wird jeweils der letzte Schritt
rückgängig gemacht. Das Rückgängigmachen eines Schritts nennt man Backtracking, daher der Name Backtracking.
Allgemeiner Backtracking Algorithmus
boolean findeLoesung(int index, Lsg loesung, ...)
{
// index ist die aktuelle Schrittzahl
// Teilloesungen werden als Referenz uebergeben
1. Solange es noch neue Teil-Loesungsschritte gibt:
a) Waehle einen neuen Teil-Lösungsschritt
b) Falls schritt gueltig ist
I) Erweitere loesung um schritt
II) Falls loesung vollstaendig ist, return true, sonst:
if (findeLoesung(index+1,loesung)
{
return true; // Loesung gefunden
}
else
{
// Sackgasse
Mache schritt rueckgaengig; // Backtracking
}
2. Gibt es keinen neuen Teil-Loesungsschritt mehr, so: return false;
}
In Java-Pseudocode lässt sich der allgemeine Backtracking Algorithmus so
formulieren:
boolean findeLoesung(int index, Lsg loesung, ...)
{
// index ... Schrittzahl
// loesung . Referenz auf Teilloesung
while (es_gibt_noch_neue_Teil-Loesungsschritte)
{
waehle_einen_neuen_Teilloesungsschritt;
if (schritt_ist_gueltig)
{
erweitere_loesung_um_schritt
if (loesung_noch_nicht_vollstaendig)
{
// rekursiver Aufruf von findeLoesung
if (findeLoesung(index + 1,loesung,...)
return true; // Loesung gefunden
else { // Sackgasse
mache_Schritt_rueckgaengig; // Backtracking
}
} else return true; // Loesung gefunden -> fertig
}
}
return false;
} // falls true Rueckgabewert steht die Loesung in loesung
Backtracking ist Tiefensuche, z.B. in einem Labyrinth.
183
Algorithmen und Datenstrukturen
Die folgende Darstellung zeigt ein Labyrinth. In diesem Labyrinth soll ein Weg vom
Start- zum Zielpunkt gefunden werden. Die Lösung dieses Problems ist eine
Anwendung der vorstehenden Prozedur in Java-Pseudocode
boolean findeLoesung(int index,Lsg loesung, int aktX, int aktY)
mit [aktX][aktY] zur Angabe der aktuellen Feldposition. „findeLoesung() 79“ findet
in einem Labyrinth mit K x L Feldern eine Weg vom Start zum Ziel und benutzt
dabei die folgende Lösungsstrategie:
- Systematische Suche vom aktuellen Feld im Labyrinth 1. oben, 2. rechts, 3. unten, 4. links
- Markierung besuchter Felder
- Zurücknahme der Züge in Sackgassen (Backtracking)
Das Resultat soll in einem zweidimensionalen Array loesung.feld[][] stehen.
Den Feldwert loesung.feld[x][y] an der Position [x][y] definiert man als
-1, wenn das Feld als Sackgasse erkannt wurde.
0, wenn das Feld besucht wurde
> 0, wenn das Feld zum Lösungsweg gehört.
79
vgl. pr32410, Labyrinth
184
Algorithmen und Datenstrukturen
Abb.
Die Feldwerte des Lösungsweges geben die Besuchsreihenfolge wieder.
[6][2]
[2][5]
[6][3]
[5][1]
[5][3]
[6][4]
[6][1]
[5][4]
[6][5]
[6][0]
[5][5]
[6][6]
[5][0]
[5][6]
[4][0]
[4][6]
[4][1]
[3][6]
[2][6]
[1][6]
[0][6]
[1][5]
[0][5]
[2][5]
[0][4]
[2][4]
[0][3]
[1][4]
[0][2]
[1][3]
[0][1]
[1][2]
[2][2]
[2][1]
[1][1]
[0][1]
[0][0]
Abb. Wege im Labyrinth (Durchlauf durch das Labyrinth)
Laufzeit. Bei der Tiefensuche werden bei
-max k möglichen Verzweigungen von jeder Teillösung aus und
- einem Lösungsbaum mit maximaler Tiefe von n
im schlechtesten Fall O(kn) Knoten im Lösungsbaum erwartet.
Tiefensuche und somit auch Backtracking haben im schlechtesten Fall mit O(k n) eine
exponentielle Laufzeit. Aus der Komplexitätstheorie ist bekannt: Algorithmen mit
nicht polynomialer Laufzeit sind zu langsam. Bei Problemen mit großer Suchtiefe
wird Backtracking deshalb zu lange brauchen.
185
Algorithmen und Datenstrukturen
Backtracking
- ist eine systematische Suchstrategie und findet deshalb immer eine optimale Lösung, sofern
vorhanden, und sucht höchstens einmal in der gleichen Sackgasse.
- ist einfach zu implementieren mit Rekursion.
- macht Tiefensuche im Lösungsraum.
- hat im schlechtesten Fall eine exponentielle Laufzeit O(k n) und ist deswegen primär nur für kleine
Probleme geeignet.
- erlaubt Wissen über ein Problem in Form einer Heuristik80 zu nutzen, um den Suchraum einzuschränken und
die Suche dadurch zu beschleunigen.
Typische Einsatzfelder des Backtracking:
- Spielprogramme (Schach, Dame)
- Erfüllbarkeit von logischen Aussagen (logische Programmierspachen)
- Planungsprobleme, Konfigurationen.
4. Bäume
80
Damit nicht alle Lösungswege ausprobiert werden müssen, werden Heuristiken verwendet. Das sind Strategien,
die mit hoher Wahrscheinlichkeit (jedoch ohne Garantie) das auffinden einer Lösung beschleunigen sollen.
186
Algorithmen und Datenstrukturen
4.1 Grundlagen
4.1.1 Grundbegriffe und Definitionen
Bäume sind eine Struktur zur Speicherung von (meist ganzahligen) Schlüsseln. Die
Schlüssel werden so gespeichert, daß sie sich in einem einfachen und effizienten
Verfahren wiederfinden lassen. Neben dem Suchen sind üblicherweise das Einfügen
eines neuen Knoten (mit gegebenem Schlüssel), das Entfernen eines Knoten (mit
gegebenem Schlüssel), das Durchlaufen aller Knoten eines Baums in bestimmter
Reihenfolge erklärte Operationen. Weitere wichtige Verfahren sind:
- Das Konstruieren eines Baums mit bestimmten Eigenschaften
- Das Aufspalten eines Baums in mehrere Teilbäme
- Das Zusammenfügen mehrere Bäume zu einem Baum
Definitionen
Eine Datenstruktur heißt "t-ärer" Baum, wenn zu jedem Element höchstens t
Nachfolger (t = 2,3,.... ) festgelegt sind. "t" bestimmt die Ordnung des Baumes (z.B.
"t = 2": Binärbaum, "t = 3": Ternärbaum).
Die Elemente eines Baumes sind die Knoten (K), die Verbindungen zwischen den
Knoten sind die Kanten (R). Sie geben die Beziehung (Relation) zwischen den
Knotenelementen an.
Eine Datenstruktur D = (K,R) ist ein Baum, wenn R aus einer Beziehung besteht, die
die folgenden 3 Bedingungen erfült:
1. Es gibt genau einen Ausgangsknoten (, das ist die Wurzel des Baums).
2. Jeder Knoten (mit Ausnahme der Wurzel) hat genau einen Vorgänger
3. Der Weg von der Wurzel zu jedem Knoten ist ein Pfad, d.h.: Für jeden von der Wurzel
verschiedenen Knoten gibt es genau eine Folge von Knoten k 1, k2, ... , kn (n >= 2), bei der k i der
Nachfolger von
ki-1 ist. Die größte vorkommende Pfadlänge ist die Höhe eines Baums.
Knoten, die keinen Nachfolger haben, sind die Blätter.
Knoten mit weniger als t Nachfolger sind die Randknoten. Blätter gehören deshalb
mit zum Rand.
Haben alle Blattknoten eines vollständigen Baums die gleiche Pfadlänge, so heißt
der Baum voll.
Quasivoller Baum: Die Pfadlängen der Randknoten unterscheiden sich höchstens
um 1. Bei ihm ist nur die unterste Schicht nicht voll besetzt.
Linksvoller Baum: Blätter auf der untersten Schicht sind linksbündig dicht.
Geordneter Baum: Ein Baum der Ordnung t ist geordnet, wenn für jeden Nachfolger
k' von k festgelegt ist, ob k' der 1., 2., ... , t. Nachfolger von k ist. Dabei handelt es
sich um eine Teilordnung, die jeweils die Söhne eines Knoten vollständig ordnet.
Speicherung von Bäumen
187
Algorithmen und Datenstrukturen
Im allg. wird die der Baumstruktur zugrundeliegende Relation gekettet gespeichert,
d.h.: Jeder Knoten zeigt über einen sog. Relationenteil (mit t Komponenten) auf
seine Nachfolger. Die Verwendung eines Anfangszeigers (, der auf die Wurzel des
Baums zeigt,) ist zweckmäßig.
4.1.2 Darstellung von Bäumen
In der Regel erfolgt eine grafische Darstellung: Ein Nachfolgerknoten k' von k wird
unterhalb des Knoten k gezeichnet. Bei der Verbindung der Knotenelemente reicht
deshalb eine ungerichte Linie (Kante).
Abb.:
Bei geordneten Bäumen werden die Nachfolger eines Knoten k in der Reihenfolge
"1. Nachfolger", "2. Nachfolger", .... von links nach rechts angeordnet.
Ein Baum ist demnach ein gerichteter Graf mit der speziellen Eigenschaft: Jeder
Knoten (Sohnknoten) hat bis auf einen (Wurzelknoten) genau einen Vorgänger
(Vaterknoten).
Ebene 1
linker Teilbaum
von k
Ebene 2
k
Ebene 3
Weg, Pfad
Ebene 4
Randknoten oder Blätter
188
Algorithmen und Datenstrukturen
Abb. 4.1-2:
4.1.3 Berechnungsgrundlagen
Die Zahl der Knoten in einem Baum ist N. Ein voller Baum der Höhe h enthält:
h
(1) N   t i 1 
i 1
th 1
t 1
Bspw. enthält ein Binärbaum N  2 h  1 Knoten. Das ergibt für den Binärbaum der
Höhe 3 sieben Knoten.
Unter der Pfadsumme Z versteht man die Anzahl der Knoten, die auf den
unterschiedlichen Pfaden im vollen t-ären Baum auftreten können:
h
(2) Z   i  t i 1
i 1
Die Summe kann durch folgende Formel ersetzt werden:
h th
th 1
Z

t  1 ( t  1) 2
t und h können aus (1) bestimmt werden:
t h  N  ( t  1)  1
h  log t ( N  ( t  1)  1)
Mit Gleichung (1) ergibt sich somit für Z
Z
log t ( N  ( t  1)  1)  ( N  ( t  1)  1) N  ( t  1) ( N  ( t  1)  1)  log t ( N  ( t  1)  1)  N


t 1
t 1
( t  1) 2
Für t = 2 ergibt sich damit:
Z  h  2 h  (2 h  1) bzw. Z  ( N  1)  ld ( N  1)  N
Die mittlere Pfadlänge ist dann:
189
Algorithmen und Datenstrukturen
(3)
Z mit 
Z ( N  ( t  1)  1)  log t ( N  ( t  1)  1)  N ( N  ( t  1)  1)  log t ( N  ( t  1)  1)
1



N
N  ( t  1)
N  ( t  1)
t 1
Für t = 2 ist
(4) Z mit 
Z N 1

 ld ( N  1)  1
N
N
Die Formeln unter (3) und (4) ergeben den mittleren Suchaufwand bei
gleichhäufigem Aufsuchen der Elemente.
Ist dies nicht der Fall, so ordnet man den Elementen die relativen Gewichte gi (i = 1,
2, 3, ... , N) bzw. die Aufsuchwahrscheinlichkeiten zu:
pi 
N
gi
, G   gi
G
i 1
N
Man kann eine gewichtete Pfadsumme
Zg   gi  hi
i 1
Suchaufwand ( Z g ) mit 
Zg
G
N
  p i  h i berechnen.
i 1
190
bzw. einen mitlleren
Algorithmen und Datenstrukturen
4.1.4 Klassifizierung von Bäumen
Wegen der großen Bedeutung, die binäre Bäume besitzen, ist es zweckmäßig in
Binär- und t-äre Bäume zu unterteilen. Bäume werden oft verwendet, um eine
Menge von Daten festzulegen, deren Elemente nach einem Schlüssel
wiederzufinden sind (Suchbäume). Die Art, nach der beim Suchen in den
Baumknoten eine Auswahl unter den Nachfolgern getroffen wird, ergibt ein weiteres
Unterscheidungsmerkmal für Bäume.
Intervallbäume
In den Knoten eines Baumes befinden sich Daten, mit denen immer feinere
Teilintervalle ausgesondert werden.
Bsp.: Binärer Suchbaum
Die Schlüssel sind nach folgendem System angeordnet. Neu ankommende
Elemente werden nach der Regel "falls kleiner" nach links bzw. "falls größer" nach
rechts abgelegt.
40
30
50
20
11
39
24
37
60
44
40
41
45
62
65
Es kann dann leicht festgestellt werden, in welchem Teilbereich ein Schlüsselwort
vorkommt.
Selektorbäume (Entscheidungsbäume)
Der Suchweg ist hier durch eine Reihe von Eigenschaften bestimmt. Beim
Binärbaum ist das bspw. eine Folge von 2 Entscheidungsmöglichkeiten. Solche
Entscheidungsmöglichkeiten können folgendermaßen codiert sein:
- 0 : Entscheidung für den linken Nachfolger
- 1 : Entscheidung für den rechten Nachfolger
Die Folge von Entscheidungen gibt dann ein binäres Codewort an. Dieses Codewort
kann mit einem Schlüssel bzw. mit einem Schlüsselteil übereinstimmen.
Bsp.: "Knotenorientierter binärer Selektorbaum"
Folgende Schlüsselfolge wird zum Erstellen des Baums herangezogen:
191
Algorithmen und Datenstrukturen
1710
3810
6310
1910
3210
2910
4410
2610
5310
=
=
=
=
=
=
=
=
=
0
1
1
0
1
0
1
0
1
1
0
1
1
0
1
0
1
1
0
0
1
0
0
1
1
1
0
0
1
1
0
0
1
1
0
1
0
1
1
1
0
0
0
1
1
12
02
12
12
02
12
02
02
12
Der zugehörige Binärbaum besitzt dann folgende Gestalt:
17
19
0_
38
39
01_
32
1_
63
10_
11_
101_
40 011_
44
53
110_
In den Knoten dient demnach der Wertebereich einer Teileigenschaft zur Auswahl
der nächsten Untergruppe.
Knotenorientierte und blattorientierte Bäume
Zur Unterscheidung von Bäumen kann auf die Aufbewahrungsstelle der Daten
zurückgegriffen werden:
1. Knotenorientierte Bäume
Daten befinden sich hier in allen Baumknoten
2. Blattorientierte Bäume
Daten befinden sich nur in den Blattknoten
Optimierte Bäume
Man unterscheidet statisch und dynamisch optimierte Bäume. In beiden Fällen sollen
entartete Bäume (schiefe Bäume, Äste werden zu linearen Listen) vermieden
werden.
Statische Optimierung bedeutet: Der Baum wird neu (oder wieder neu) aufgebaut.
Optimalität ist auf die Suchoperation bezogen. Es interessiert dabei das Aussehen
des Baums, wenn dieser vor Gebrauch optimiert werden kann.
Bei der dynamischen Optimierung wird der Baum während des Betriebs (bei jedem
Ein- und Ausfügen) optimiert. Ziel ist also hier: Eine günstige Speicherstruktur zu
erhalten. Diese Aufgabe kann im allg. nicht vollständig gelöst werden, eine
Teiloptimierung (lokale Optimierung) reicht häufig aus.
192
Algorithmen und Datenstrukturen
Werden die Operationen "Einfügen", "Löschen" und "Suchen" ohne besondere
Einschränkungen oder Zusätze angewendet, so spricht man von freien Bäumen.
Strukturbäume
Sie dienen zur Darstellung und Speicherung hierarchischer Zusammenhänge.
Bsp.: "Darstellung eines arithmetischen Ausdrucks"
Operationen in einem arithmetischen Ausdruck sind zweiwertig (, die einwertige Operation "Minus"
kann als Vorzeichen dem Operanden direkt zugeordnet werden). Zu jeder Operation gehören
demnach 2 Operanden. Somit bietet sich die Verwendung eines binären Baumes an. Für den
arithmetischen Ausdruck (A  B / C) ( D  E F) ergibt sich dann folgende Baumdarstellung:
*
+
-
A
/
B
D
*
E
C
Abb.:
193
F
Algorithmen und Datenstrukturen
4.2 Freie Binäre Intervallbäume
4.2.1 Ordnungsrelation und Darstellung
Freie Bäume sind durch folgende Ordnungsrelation bestimmt:
In jedem Knoten eines knotenorientierten, geordneten Binärbaums gilt: Alle Schlüssel im rechten
(linken) Unterbaum sind größer (kleiner) als der Schlüssel im Knoten selbst.
Mit Hilfe dieser Ordnungsrelation erstellte Bäume dienen zum Zugriff auf Datenbestände (Aufsuchen
eines Datenelements). Die Daten sind die Knoten (Datensätze, -segmente, -elemente). Die Kanten des
Zugriffsbaums sind Zeiger auf weitere Datenknoten (Nachfolger).
Dateninformation
Schluessel
Datenteil
Knotenzeiger
LINKS
RECHTS
Zeiger zum linken Sohn
Zeiger zum rechten Sohn
Abb.:
Das Aufsuchen eines Elements im Zugriffsbaum geht vom Wurzelknoten über einen Kantenzug (d.i. eine Reihe
von Zwischenknoten) zum gesuchten Datenelement. Bei jedem Zwischenknoten auf diesem Kantenzug findet ein
Entscheidungsprozeß über die folgenden Vergleiche statt:
1. Die beiden Schlüssel sind gleich: Das Element ist damit gefunden
2. Der gesuchte Schlüssel ist kleiner: Das gesuchte Element kann sich dann nur im linken Unterbaum
befinden
3. Der gesuchte Schlüssel ist größer: Das gesuchte Element kann sich nur im rechten Unterbaum
befinden.
Das Verfahren wird solange wiederholt, bis das gesuchte (Schlüssel-) Element
gefunden ist bzw. feststeht, daß es in dem vorliegenden Datenbestand nicht
vorhanden ist.
Struktur und Wachstum binärer Bäume sind durch die Ordnungsrelation bestimmt:
Aufgabe: Betrachte die 3 Schlüssel 1, 2, 3. Diese 3 Schlüssel können durch verschieden angeordnete
Folgen bei der Eingabe unterschiedliche binäre Bäume erzeugen.
Stellen Sie alle Bäume, die aus unterschiedlichen Eingaben der 3 Schlüssel resultieren, dar!
194
Algorithmen und Datenstrukturen
1, 2, 3
1, 3, 2
2, 1, 3
1
1
2
3
2
3
1
2
2, 3, 1
3, 1, 2
3, 2, 1
2
3
3
1
3
2
1
1
Es gibt also: Sechs unterschiedliche Eingabefolgen und somit 6 unterschiedliche Bäume.
Allgemein können n Elemente zu n! verschiedenen Anordnungen zusammengestellt
werden.
Suchaufwand
Der mittlere Suchaufwand für einen vollen Baum beträgt Z mit 
N 1
 ld ( N  1)  1
N
Zur Bestimmung des Suchaufwands stellt man sich vor, daß ein Baum aus dem
leeren Baum durch sukzessives Einfuegen der N Schlüssel entstanden ist. Daraus
resultieren N! Permutationen der N Schlüssel. Über all diese Baumstrukturen ist der
Mittelwert zu bilden, um schließlich den Suchaufwand festlegen zu können.
Aus den Schlüsselwerten 1, 2, ... , N interessiert zunächst das Element k, das die
Wurzel bildet.
k
Hier gibt es:
(k-1)! Unterbäume
Schlüsseln 1..N
Hier gibt es:
(N-k)! Unterbäume mit den
Schlüsseln k+1, ... , N
195
Algorithmen und Datenstrukturen
Der mittlere Suchaufwand im gesamten Baum ist:
ZN 
1
( N  Z k 1  ( k  1)  Z N  k  ( N  k ))
N
Zk-1: mittlerer Suchaufwand im linken Unterbaum
ZN-k: mittlerer Suchaufwand im rechten Unterbaum
Zusätzlich zu diesen Aufwendungen entsteht ein Aufwand für das Einfügen der
beiden Teilbäume an die Wurzel. Das geschieht (N-1)-mal. Zusammen mit dem dem
Suchschritt selbst ergibt das N-mal.
Der angegebene Suchaufwand gilt nur für die Wurzel mit dem Schlüssel k. Da alle
Werte für k gleichwahrscheinlich sind, gilt allgemein:
ZN
(k)

1 N
2 N

(
N

Z

(
k

1
)

Z

(
N

k
))
Z

1

  (Z k 1  ( k  1)
bzw.

k

1
N

k
N
N 2 k 1
N 2 k 1
N 1
2

 (Z k 1  ( k  1)
( N  1) 2 k 1
N 1
2 N
2
 2   Z k 1  ( k  1) 
  Z k 1  ( k  1)
N k 1
( N  1) 2 k 1
bzw. für N - 1: Z N 1  1 
Z N  Z N 1
Es läßt sich daraus ableiten:
Mit
der
YN  YN 1 
Ersatzfunktion
N
N 1
2N 1
 ZN 
 Z N 1 
N 1
N
N  ( N  1)
YN 
N
 ZN
N 1
folgt
die
Rekursionsformel:
N
N
2i 1
1
N
2N 1
 2    3
bzw. nach Auflösung81 YN  
N 1
N  ( N  1)
i 1 i  ( i  1)
i 1 i
Einsetzen ergibt:
ZN  2 
N 1
N 1
 HS N  3  2 
 ( HS N 1  1)  1
N
N
N
"HS" ist die harmonische Summe: HS N  
i 1
1
N
Sie läßt sich näherungsweise mit ln( N )  0.577  (ln( N ))  0.693  ld ( N ) . Damit ergibt
.  ld ( N  1)  2
sich schließlich: Z mit  14
Darstellung
Jeder geordnete binäre Baum ist eindeutig durch folgende Angaben bestimmt:
1. Angabe der Wurzel
81
vgl. Wettstein, H.: Systemprogrammierung, 2. Auflage, S.291
196
Algorithmen und Datenstrukturen
2. Für jede Kante Angabe des linken Teilbaums ( falls vorhanden) sowie des rechten Teilbaums (falls
vorhanden)
Die Angabe für die Verzweigungen befinden sich in den Baumknoten, die die
zentrale Konstruktionseinheit für den Aufbau binärerer Bäume sind.
1. Die Klassenschablone „Baumknoten“ in C++82
// Schnittstellenbeschreibung
// Die Klasse binaerer Suchbaum binSBaum benutzt die Klasse baumKnoten
template <class T> class binSBaum;
// Deklaration eines Binaerbaumknotens fuer einen binaeren Baum
template <class T>
class baumKnoten
{
protected:
// zeigt auf die linken und rechten Nachfolger des Knoten
baumKnoten<T> *links;
baumKnoten<T> *rechts;
public:
// Das oeffentlich zugaenglich Datenelement "daten"
T daten;
// Konstruktor
baumKnoten (const T& merkmal, baumKnoten<T> *lzgr = NULL,
baumKnoten<T> *rzgr = NULL);
// virtueller Destruktor
virtual ~baumKnoten(void);
// Zugriffsmethoden auf Zeigerfelder
baumKnoten<T>* holeLinks(void) const;
baumKnoten<T>* holeRechts(void) const;
// Die Klasse binSBaum benoetigt den Zugriff auf
// "links" und "rechts"
friend class binSBaum<T>;
};
// Schnittstellenfunktionen
// Konstruktor: Initialisiert "daten" und die Zeigerfelder
// Der Zeiger NULL verweist auf einen leeren Baum
template <class T>
baumKnoten<T>::baumKnoten (const T& merkmal, baumKnoten<T> *lzgr,
baumKnoten<T> *rzgr): daten(merkmal), links(lzgr), rechts(rzgr)
{}
// Die Methode holeLinks ermoeglicht den Zugriff auf den linken
// Nachfolger
template <class T>
baumKnoten<T>* baumKnoten<T>::holeLinks(void) const
{
// Rueckgabe des Werts vom privaten Datenelement links
return links;
}
// Die Methode "holeRechts" erlaubt dem Benutzer den Zugriff auf den
// rechten Nachfoger
template <class T>
baumKnoten<T>* baumKnoten<T>::holeRechts(void) const
{
// Rueckgabe des Werts vom privaten Datenelement rechts
return rechts;
}
// Destruktor: tut eigentlich nichts
template <class T>
82
vgl. baumkno.h
197
Algorithmen und Datenstrukturen
baumKnoten<T>::~baumKnoten(void)
{}
2. Baumknotendarstellung in Java
import java.util.*;
// Elementarer Knoten eines binaeren Baums, der nicht ausgeglichen ist
// Der Zugriff auf diese Klasse ist nur innerhalb eines Verzeichnisses
// bzw. Pakets moeglich
class BinaerBaumknoten
{
// Instanzvariable
protected BinaerBaumknoten links;
protected BinaerBaumknoten rechts;
public Comparable daten;
// linker Teilbaum
// rechter Teilbaum
// Dateninhalt der Knoten
// Konstruktor
public BinaerBaumknoten(Comparable datenElement)
{
this(datenElement, null, null );
}
public BinaerBaumknoten(Comparable datenElement,
BinaerBaumknoten l,
BinaerBaumknoten r)
{
daten
= datenElement;
links
= l;
rechts
= r;
}
public BinaerBaumknoten getLinks()
{
return links;
}
public BinaerBaumknoten getRechts()
{
return rechts;
}
}
4.2.2 Operationen
1. Generieren eines Suchbaums
Bsp.: Gestalt des durch die Prozedur ERSTBAUM erstellten Binärbaums nach der
Eingabe der Schlüsselfolge (12, 7, 15, 5, 8, 13, 2, 6, 14).
198
Algorithmen und Datenstrukturen
Schlüssel
12
LINKS
RECHTS
7
5
2
15
8
13
6
14
Abb.:
a) Erzeugen eines Binärbaumknotens bzw. eines binären Baums in C++
Zum Erzeugen eines Binärbaumknotens kann folgende Funktionsschablone
herangezogen werden:
template <class T>
baumKnoten<T>* erzeugebaumKnoten(T merkmal,
baumKnoten<T>* lzgr = NULL,
baumKnoten<T>* rzgr = NULL)
{
baumKnoten<T> *z;
// Erzeugen eines neuen Knoten
z = new baumKnoten<T>(merkmal,lzgr,rzgr);
if (z == NULL)
{
cerr << "Kein Speicherplatz!\n";
exit(1);
}
return z; // Rueckgabe des Zeigers
}
Der durch den Baumknoten belegte
Funktionsschablone freigegeben werden:
Speicherplatz
kann
über
folgende
template <class T>
void gibKnotenFrei(baumKnoten<T>* z)
{
delete z;
}
Der folgende Hauptprogrammabschnitt erzeugt einen binären Baum folgende
Gestalt:
199
Algorithmen und Datenstrukturen
‘A’
‘B’
‘C’
‘D’
‘E’
Abb::
void main(void)
{
baumKnoten<char> *a, *b, *c, *d, *e;
d = new baumKnoten<char>('D');
e = new baumKnoten<char>('E');
b = new baumKnoten<char>('B',NULL,d);
c = new baumKnoten<char>('C',e);
a = new baumKnoten<char>('A',b,c);
}
200
Algorithmen und Datenstrukturen
b) Erzeugen eines Binärbaumknotens bzw. eines binären Baums in Java
class StringBinaerBaumknoten
{
private String sK;
protected StringBinaerBaumknoten links, rechts;
public StringBinaerBaumknoten (String s)
{
links = rechts = null;
sK = s;
}
public void insert (String s) // Fuege s korrekt ein.
{
if (s.compareTo(sK) > 0) // dann rechts
{
if (rechts==null) rechts = new StringBinaerBaumknoten(s);
else rechts.insert(s);
}
else // sonst links
{
if (links==null) links=new StringBinaerBaumknoten(s);
else links.insert(s);
}
}
public String getString ()
{
return sK;
}
public StringBinaerBaumknoten getLinks ()
{
return links;
}
public StringBinaerBaumknoten getRechts ()
{
return rechts;
}
}
public class TestStringBinaerBaumKnoten
{
public static void main (String args[])
{
StringBinaerBaumknoten baum=null;
for (int i = 0; i < 20; i++) // 20 Zusfallsstrings speichern
{
String s = "Zufallszahl " + (int)(Math.random() * 100);
if (baum == null) baum = new StringBinaerBaumknoten(s);
else baum.insert(s);
}
print(baum); // Sortiert wieder ausdrucken
}
public static void print (StringBinaerBaumknoten baum)
// Rekursive Druckfunktion
{
if (baum == null) return;
print(baum.getLinks());
System.out.println(baum.getString());
print(baum.getRechts());
}
}
201
Algorithmen und Datenstrukturen
2. Suchen und Einfügen
Vorstellung zur Lösung:
1. Suche nach dem Schlüsselwert
2. Falls vorhanden kein Einfügen
3. Bei erfolgloser Suche Einfügen als Sohn des erreichten Blatts
a) Implementierung in C++
Das „Einfügen“83 ist eine Methode in der Klassenschablone für einen binären
Suchbaum binSBaum. Zweckmäßigerweise stellt diese Klasse Datenelemente unter
protected zur Verfügung.
#include "baumkno.h"
// Schnittstellenbeschreibung
template <class T>
class binSBaum
{
protected:
// Zeiger auf den Wurzelknoten und den Knoten, auf den am
// haeufigsten zugegriffen wird
baumKnoten<T> *wurzel;
baumKnoten<T> *aktuell;
// Anzahl Knoten im Baum
int groesse;
// Speicherzuweisung / Speicherfreigabe
// Zuweisen eines neuen Baumknoten mit Rueckgabe
// des zugehoerigen Zeigerwerts
baumKnoten<T> *holeBaumKnoten(const T& merkmal,
baumKnoten<T> *lzgr,baumKnoten<T> *rzgr)
{
baumKnoten<T> *z;
// Datenfeld ubd die beiden Zeiger werden initialisiert
z = new baumKnoten<T> (merkmal, lzgr, rzgr);
if (z == NULL)
{
cerr << "Speicherbelegungsfehler!\n";
exit(1);
}
return z;
}
// gib den Speicherplatz frei, der von einem Baumknoten belegt wird
void freigabeKnoten(baumKnoten<T> *z)
// wird vom Kopierkonstruktor und Zuweisungsoperator benutzt
{ delete z; }
// Kopiere Baum b und speichere ihn im aktuellen Objekt ab
baumKnoten<T> *kopiereBaum(baumKnoten<T> *b)
// wird vom Destruktor, Zuweisungsoperator und bereinigeListe benutzt
{
baumKnoten<T> *neulzgr, *neurzgr, *neuerKnoten;
// Falls der Baum leer ist, Rueckgabe von NULL
if (b == NULL) return NULL;
// Kopiere den linken Zweig von der Baumwurzel b und weise seine
// Wurzel neulzgr zu
if (b->links != NULL) neulzgr = kopiereBaum(b->links);
else neulzgr = NULL;
// Kopiere den rechten Zweig von der Baumwurzel b und weise seine
// Wurzel neurzgr zu
if (b->rechts != NULL) neurzgr = kopiereBaum(b->rechts);
83
vgl. bsbaum.h
202
Algorithmen und Datenstrukturen
else neurzgr = NULL;
// Weise Speicherplatz fuer den aktuellen Knoten zu und weise seinen
// Datenelementen Wert und Zeiger seiner Teilbaeume zu
neuerKnoten = holeBaumKnoten(b->daten, neulzgr, neurzgr);
return neuerKnoten;
}
// Loesche den Baum, der durch im aktuellen Objekt gespeichert ist
void loescheBaum(baumKnoten<T> *b)
// Lokalisiere einen Knoten mit dem Datenelementwert von merkmal
// und seinen Vorgaenger (eltern) im Baum
{
// falls der aktuelle Wurzelknoten nicht NULL ist, loesche seinen
// linken Teilbaum, seinen rechten Teilbaum und dann den Knoten selbst
if (b != NULL)
{
loescheBaum(b->links);
loescheBaum(b->rechts);
freigabeKnoten(b);
}
}
// Suche nach dem Datum "merkmal" im Baum. Falls gefunden, Rueckgabe
// der zugehoerigen Knotenadresse; andernfalls NULL
baumKnoten<T> *findeKnoten(const T& merkmal,
baumKnoten<T>* & eltern) const
{ // Durchlaufe b. Startpunkt ist die Wurzel
baumKnoten<T> *b = wurzel;
// Die "eltern" der Wurzel sind NULL
eltern = NULL;
// Terminiere bei einen leeren Teilbaum
while(b != NULL)
{
// Halt, wenn es passt
if (merkmal == b->daten) break;
else
{ // aktualisiere den "eltern"-Zeiger und gehe nach rechts
// bzw. nach links
eltern = b;
if (merkmal < b->daten) b = b->links;
else b = b->rechts;
}
}
// Rueckgabe des Zeigers auf den Knoten; NULL, falls nicht gefunden
return b;
}
public:
// Konstruktoren, Destruktoren
binSBaum(void);
binSBaum(const binSBaum<T>& baum);
~binSBaum(void);
// Zuweisungsoperator
binSBaum<T>& operator= (const binSBaum<T>& rs);
// Bearbeitungsmethoden
int finden(T& merkmal);
void einfuegen(const T& merkmal);
void loeschen(const T& merkmal);
void bereinigeListe(void);
int leererBaum(void) const;
int baumGroesse(void) const;
// baumspezifische Methoden
void aktualisieren(const T& merkmal);
baumKnoten<T> *holeWurzel(void) const;
};
203
Algorithmen und Datenstrukturen
Die Schnittstellenfunktion void einfuegen(const T& merkmal); besitzt
folgende Definition84:
// Einfuegen "merkmal" in den Suchbaum
template <class T>
void binSBaum<T>::einfuegen(const T& merkmal)
{ // b ist der aktuelle Knoten beim Durchlaufen des Baums
baumKnoten<T> *b = wurzel, *eltern = NULL, *neuerKnoten;
// Terminiere beim leeren Teilbaum
while(b != NULL)
{ // Aktualisiere den zeiger "eltern",
// dann verzweige nach links oder rechts
eltern = b;
if (merkmal < b->daten) b = b->links;
else b = b->rechts;
}
// Erzeuge den neuen Blattknoten
neuerKnoten = holeBaumKnoten(merkmal,NULL,NULL);
// Falls "eltern" auf NULL zeigt, einfuegen eines Wurzelknoten
if (eltern == NULL) wurzel = neuerKnoten;
// Falls merkmal < eltern->daten, einfuegen als linker Nachfolger
else if (merkmal < eltern->daten) eltern->links = neuerKnoten;
else
// Falls merkmal >= eltern->daten, einfuegen als rechter Nachf.
eltern->rechts = neuerKnoten;
// Zuweisen "aktuell": "aktuell" ist die Adresse des neuen Knoten
aktuell = neuerKnoten;
groesse++;
}
b) Eine generische Klasse für den binären Suchbaum in Java 85
Der binäre Suchbaum setzt voraus, dass alle Datenelemente in eine
Ordnungsbeziehung86 gebracht werden können. Eine generische Klasse für einen
binären Suchbaum erfordert daher ein Interface, das Ordnungsbeziehungen
zwischen Daten eines Datenbestands festlegt. Diese Eigenschaft besitzt das
Interface Comparable:
public interface Comparable
{
int compareTo(Comparable rs)
}
Das Interface87 zeigt, dass zwei Datenelemente über die Methode „compareTo“
verglichen werden können. Über die Defintion eines Interface wird auch der
zugehörige Referenztyp erzeugt, der wie andere Datentypen eingesetzt werden
kann.
// Freier binaerer Intervallbaum
// Generische Klasse fuer einen unausgeglichenen binaeren Suchbaum
//
84
vgl. bsbaum.h
vgl. pr42110
86
vgl. Kapitel 1, 1.2.2.2
87
Java-Interfaces werden in der Regel als eine Form abstrakter Klassen beschrieben, durch die einzelne Klassen
der Hierarchie zusätzliche Funktionalität erhalten können. Sollen die Klassen diese Funktionalität durch
Vererbung erhalten, müssten sie in einer gemeinsamen Superklasse angesiedelt werden. Weil ein Interface keine
Implementierung enthält, kann auch keine Instanz davon erzeugt werden. Eine Klasse, die nicht alle Methoden
eines Interface implementiert, wird zu einer abstrakten Klasse.
85
204
Algorithmen und Datenstrukturen
//
//
//
//
//
//
//
//
//
//
//
//
//
//
Konstruktor: Initialisierung der Wurzel mit dem Wert null
************ oeffentlich zugaengliche Methoden********************
void insert( x )
--> Fuege x ein
void remove( x )
--> Entferne x
Comparable find( x )
--> Gib das Element zurueck, das zu x passt
Comparable findMin( ) --> Rueckgabe des kleinsten Elements
Comparable findMax( ) --> Rueckgabe des groessten Elements
boolean isEmpty( )
--> Return true if empty; else false
void makeEmpty( )
--> Entferne alles
void printTree( )
--> Ausgabe der Binaerbaum-Elemente in sortierter
Folge
void ausgBinaerBaum() --> Ausgabe der Binaerbaum-Elemente um 90 Grad
vers.
/*
* Implementiert einen unausgeglichenen binaeren Suchbaum.
* Das Einordnen in den Suchbaum basiert auf der Methode compareTo
*/
public class BinaererSuchbaum
{
// Private Datenelemente
/* Die Wurzel des Baums */
private BinaerBaumknoten root = null;
/* Container mit daten zum Erstellen eines Baums */
Vector einVector;
// Oeffentlich zugaengliche Methoden
/*
* Konstruktoren
*/
public BinaererSuchbaum()
{
root = null;
}
public BinaererSuchbaum(Vector einVector)
{
this.einVector = einVector;
for (int i = 0; i < einVector.size();i++)
{
insert((Comparable) einVector.elementAt(i));
}
}
/*
* Einfuegen eines Elements in den binaeren Suchbaum
* Duplikate werden ignoriert
*/
public BinaerBaumknoten getRoot()
{
return root;
}
public void insert( Comparable x )
{
root = insert(x, root);
}
/*
* Entfernen aus dem Baum. Falls x nicht da ist, geschieht nichts
*/
public void remove( Comparable x )
{
root = remove(x, root);
}
/*
* finde das kleinste Element im Baum
*/
public Comparable findMin()
{
205
Algorithmen und Datenstrukturen
return elementAt(findMin(root));
}
/*
* finde das groesste Element im Baum
*/
public Comparable findMax()
{
return elementAt(findMax(root));
}
/*
* finde ein Datenelement im Baum
*/
public Comparable find(Comparable x)
{
return elementAt(find(x, root));
}
/*
* Mache den Baum leer
*/
public void makeEmpty()
{
root = null;
}
/*
* Test, ob der Baum leer ist
*/
public boolean isEmpty()
{
return root == null;
}
/*
* Ausgabe der Datenelemente in sortierter Reihenfolge
*/
public void printTree()
{
if( isEmpty( ) )
System.out.println( "Baum ist leer" );
else printTree( root );
}
/*
* Ausgabe der Elemente des binaeren Baums um 90 Grad versetzt
*/
public void ausgBinaerBaum()
{
if( isEmpty() )
System.out.println( "Leerer baum" );
else
ausgBinaerBaum( root,0 );
}
// Private Methoden
/*
* Methode fuer den Zugriff auf ein Datenelement
*/
private Comparable elementAt( BinaerBaumknoten b )
{
return b == null ? null : b.daten;
}
/*
* Interne Methode fuer das Einfuegen in einen Teilbaum
*/
private BinaerBaumknoten insert(Comparable x, BinaerBaumknoten b)
{
/* 1*/
if( b == null )
/* 2*/
b = new BinaerBaumknoten( x, null, null );
/* 3*/
else if( x.compareTo( b.daten ) < 0 )
/* 4*/
b.links = insert( x, b.links );
/* 5*/
else if( x.compareTo( b.daten ) > 0 )
206
Algorithmen und Datenstrukturen
/*
/*
/*
/*
6*/
7*/
8*/
9*/
b.rechts = insert( x, b.rechts );
else
; // Duplikat; tue nichts
return b;
}
/*
* Interne Methode fuer das Entfernen eines Knoten in einem Teilbaum
*/
private BinaerBaumknoten remove(Comparable x, BinaerBaumknoten b)
{
if( b == null )
return b;
// nichts gefunden; tue nichts
if( x.compareTo(b.daten) < 0 )
b.links = remove(x, b.links );
else if( x.compareTo(b.daten) > 0 )
b.rechts = remove( x, b.rechts );
else if( b.links != null && b.rechts != null ) // Zwei Kinder
{
b.daten = findMin(b.rechts).daten;
b.rechts = remove(b.daten, b.rechts);
}
else
b = ( b.links != null ) ? b.links : b.rechts;
return b;
}
/*
* Interne Methode zum Bestimmen des kleinsten Datenelements im Teilbaum
*/
private BinaerBaumknoten findMin(BinaerBaumknoten b)
{
if (b == null)
return null;
else if( b.links == null)
return b;
return findMin(b.links );
}
/*
* Interne Methode zum Bestimmen des groessten Datenelements im Teilbaum
*/
private BinaerBaumknoten findMax( BinaerBaumknoten b)
{
if( b != null )
while( b.rechts != null )
b = b.rechts;
return b;
}
/*
* Interne Methode zum Bestimmen eines Datenelements im Teilbaum.
*/
private BinaerBaumknoten find(Comparable x, BinaerBaumknoten b)
{
if(b == null)
return null;
if( x.compareTo(b.daten ) < 0)
return find(x, b.links);
else if( x.compareTo(b.daten) > 0)
return find(x, b.rechts);
else
return b;
// Gefunden!
}
/*
* Internae Methode zur Ausgabe eines Teilbaums in sortierter Reihenfolge
*/
private void printTree(BinaerBaumknoten b)
{
if(b != null)
{
207
Algorithmen und Datenstrukturen
printTree(b.links);
System.out.print(b.daten);
System.out.print(' ');
printTree( b.rechts );
}
}
/*
* Ausgabe des Binaerbaums um 90 Grad versetzt
*/
private void ausgBinaerBaum(BinaerBaumknoten b, int stufe)
{
if (b != null)
{
ausgBinaerBaum(b.links, stufe + 1);
for (int i = 0; i < stufe; i++)
{ System.out.print(' '); }
System.out.println(b.daten);
ausgBinaerBaum(b.rechts, stufe + 1);
}
}
3. Löschen eines Knoten
Es soll ein Knoten mit einem bestimmten Schlüsselwert entfernt werden.
Fälle
A) Der zu löschende Knoten ist ein Blatt
Bsp.:
vorher
nachher
Abb.:
Das Entfernen kann leicht durchgeführt werden
B) Der zu löschende Knoten hat genau einen Sohn
208
Algorithmen und Datenstrukturen
nachher
vorher
Abb.:
C) Der zu löschende Knoten hat zwei Söhne
nachher
vorher
Abb.:
Der Knoten k wird durch den linken Sohn ersetzt.
Der rechte Sohn von k wird rechter Sohn der rechtesten Ecke des linken Teilbaums.
Der resultierende Teilbaum T' ist ein Suchbaum, häufig allerdings mit erheblich
vergrößerter Höhe.
Aufgaben:
1. Gegeben ist ein binärer Baum folgender Gestalt:
k
k1
k2
k3
Die Wurzel wird gelöscht. Welche Gestalt nimmt der Baum dann an:
209
Algorithmen und Datenstrukturen
k1
vv
k3
v
k2
Abb.:
Es ergibt sich eine Höhendifferenz H , die durch folgende Beziehung eingegrenzt
ist: 1  H  H (TL )
H ( TL ) ist die Höhe des linken Teilbaums.
2. Gegeben ist die folgende Gestalt eines binären Baums
12
7
15
5
13
2
6
14
Welche Gestalt nimmt dieser Baum nach dem Entfernen der Schlüssel mit den unter
a) bis f) angegebenen Werten an?
a) 2 b) 6
12
7
5
15
13
14
210
Algorithmen und Datenstrukturen
c) 13
12
7
15
5
14
d) 15
12
7
14
5
e) 5
12
7
14
f) 12
7
14
Schlüsseltransfer
Der angegebene Algorithmus zum Löschen von Knoten kann zu einer beträchtlichen
Vergrößerung der Baumhöhe führen. Das bedeutet auch eine beträchtliche
Steigerung des mittleren Suchaufwands. Man ersetzt häufig die angegebene
Verfahrensweise durch ein anderes Verfahren, das unter dem Namen
Schlüsseltransfer bekannt ist.
Der zu löschende Schlüssel (Knoten) wird ersetzt durch den kleinsten Schlüssel des
rechten oder den größten Schlüssel des linken Teilbaums. Dieser ist dann nach Fall
A) bzw. B) aus dem Baum herauszunehmen.
Bsp.:
211
Algorithmen und Datenstrukturen
Abb.:
Test der Verfahrensweise "Schlüsseltransfer":
1) Der zu löschende Baumknoten besteht nur aus einem Wurzelknoten, z.B.:
Schlüssel
LINKS
12
RECHTS
Ergebnis: Der Wurzelknoten wird gelöscht.
2) Vorgegeben ist
Schlüssel
LINKS
12
RECHTS
7
5
8
Abb.:
Der Wurzelknoten wird gelöscht.
Ergebnis:
212
Algorithmen und Datenstrukturen
7
5
8
Abb.:
3) Vorgegeben ist
Schlüssel
12
LINKS
RECHTS
7
5
15
8
13
14
Abb.:
Der Wurzelknoten wird gelöscht.
Ergebnis:
213
Algorithmen und Datenstrukturen
Schlüssel
LINKS
13
RECHTS
7
5
15
8
14
Abb.:
a) Implementierung der Verfahrenweise Schlüsseltransfer
Baumknoten in einem binären Suchbaum in C++
zum Löschen von
// Falls "merkmal" im Baum vorkommt, dann loesche es
template <class T>
void binSBaum<T>::loeschen(const T& merkmal)
{
// LKnoZgr: Zeiger auf Knoten L, der geloescht werden soll
// EKnoZgr: Zeiger auf die "eltern" E des Knoten L
// ErsKnoZgr: Zeiger auf den rechten Knoten R, der L ersetzt
baumKnoten<T> *LKnoZgr, *EKnoZgr, *ErsKnoZgr;
// Suche nach einem Knoten, der einen Knoten enthaelt mit dem
// Datenwert von "merkmal". Bestimme die aktuelle Adresse diese Knotens
// und die seiner "eltern"
if ((LKnoZgr = findeKnoten (merkmal, EKnoZgr)) == NULL) return;
// Falls LKnoZgr einen NULL-Zeiger hat, ist der Ersatzknoten
// auf der anderen Seite des Zweigs
if (LKnoZgr->rechts == NULL)
ErsKnoZgr = LKnoZgr->links;
else if (LKnoZgr->links == NULL) ErsKnoZgr = LKnoZgr->rechts;
// Beide Zeiger von LKnoZgr sind nicht NULL
else
{ // Finde und kette den Ersatzknoten fuer LKnoZgr aus.
// Beginne am linkten Zweig des Knoten LKnoZgr,
// bestimme den Knoten, dessen Datenwert am groessten
// im linken Zweig von LKnoZgr ist. Kette diesen Knoten aus.
// EvonErsKnoZgr: Zeiger auf die "eltern" des zu ersetzenden Knoten
baumKnoten<T> *EvonErsKnoZgr = LKnoZgr;
// erstes moegliches Ersatzstueck: linker Nachfolger von L
ErsKnoZgr = LKnoZgr->links;
// steige den rechten Teilbaum des linken Nachfolgers von LKnoZgr hinab,
// sichere den Satz des aktuellen Knoten und den seiner "Eltern"
// Beim Halt, wurde der zu ersetzende Knoten gefunden
while(ErsKnoZgr->rechts != NULL)
{
EvonErsKnoZgr = ErsKnoZgr;
ErsKnoZgr = ErsKnoZgr->rechts;
}
if (EvonErsKnoZgr == LKnoZgr)
// Der linke Nachfolger des zu loeschenden Knoten ist das
// Ersatzstueck
// Zuweisung des rechten Teilbaums
ErsKnoZgr->rechts = LKnoZgr->rechts;
214
Algorithmen und Datenstrukturen
else
{ // es wurde sich um mindestens einen Knoten nach unten bewegt
// der zu ersetzende Knoten wird durch Zuweisung seines
// linken Nachfolgers zu "Eltern" geloescht
EvonErsKnoZgr->rechts = ErsKnoZgr->links;
// plaziere den Ersatzknoten an die Stelle von LKnoZgr
ErsKnoZgr->links = LKnoZgr->links;
ErsKnoZgr->rechts = LKnoZgr->rechts;
}
}
// Vervollstaendige die Verkettung mit den "Eltern"-Knoten
// Loesche den Wurzelknoten, bestimme eine neue Wurzel
if (EKnoZgr == NULL) wurzel = ErsKnoZgr;
// Zuweisen Ers zum korrekten Zweig von E
else if (LKnoZgr->daten < EKnoZgr->daten)
EKnoZgr->links = ErsKnoZgr;
Else EKnoZgr->rechts = ErsKnoZgr;
// Loesche den Knoten aus dem Speicher und erniedrige "groesse"
freigabeKnoten(LKnoZgr);
groesse--;
}
b) Implementierung der Verfahrenweise Schlüsseltransfer
Baumknoten in einem binären Suchbaum in Java
zum Löschen von
/*
* Interne Methode fuer das Entfernen eines Knoten in einem Teilbaum
*/
private BinaerBaumknoten remove(Comparable x, BinaerBaumknoten b)
{
if( b == null )
return b;
// nichts gefunden; tue nichts
if( x.compareTo(b.daten) < 0 )
b.links = remove(x, b.links );
else if( x.compareTo(b.daten) > 0 )
b.rechts = remove( x, b.rechts );
else if( b.links != null && b.rechts != null ) // Zwei Kinder
{
b.daten = findMin(b.rechts).daten;
b.rechts = remove(b.daten, b.rechts);
}
else
b = ( b.links != null ) ? b.links : b.rechts;
return b;
}
215
Algorithmen und Datenstrukturen
4.2.3 Ordnungen und Durchlaufprinzipien
Das Prinzip, wie ein geordneter Baum durchlaufen wird, legt eine Ordnung auf der
Menge der Knoten fest. Es gibt 3 Möglichkeiten (Prinzipien), die Knoten eines
binären Baums zu durchlaufen:
1. Inordnungen
LWR-Ordnung
(1) Durchlaufen (Aufsuchen) des linken Teilbaums in INORDER
(2) Aufsuchen der BAUMWURZEL
(3) Durchlaufen (Aufsuchen) des rechten Teilbaums in INORDER
RWL-Ordnung
(1) Durchlaufen (Aufsuchen) des rechten Teilbaums in INORDER
(2) Aufsuchen der BAUMWURZEL
(3) Durchlaufen (Aufsuchen) des Teilbaums in INORDER
Der LWR-Ordnung und die RWL-Ordnung sind zueinander invers. Die LWR Ordnung heißt auch symmetrische Ordnung.
2. Präordnungen
WLR-Ordnung
(1) Aufsuchen der BAUMWURZEL
(2) Durchlaufen (Aufsuchen) des linken Teilbaums in PREORDER
(3) Durchlaufen (Aufsuchen) des rechten Teilbaums in PREORDER
WRL-Ordnung
(1) Aufsuchen der BAUMWURZEL
(2) Durchlaufen (Aufsuchen) des rechten Teilbaums in PREORDER
(3) Durchlaufen (Aufsuchen) des linken Teilbaums in PREORDER
Es wird hier grundsätzlich die Wurzel vor den (beiden) Teilbäumen durchlaufen.
3. Postordnungen
LRW-Ordnung
(1) Durchlaufen (Aufsuchen) des linken Teilbaums in POSTORDER
(2) Durchlaufen (Aufsuchen) des rechten Teilbaums in POSTORDER
(3) Aufsuchen der BAUMWURZEL
Zunächst werden die beiden Teilbäume und dann die Wurzel durchlaufen.
RLW-Ordnung
(1) Durchlauden (Aufsuchen) des rechten Teilbaums in POSTORDER
(2) Durchlaufen (Aufsuchen) des linken Teilbaums in POSTORDER
(3) Aufsuchen der BAUMWURZEL
Zunächst werden die beiden Teilbäume und dann die Wurzel durchlaufen.
216
Algorithmen und Datenstrukturen
a) Funktionsschablonen für das Durchlaufen binärer Bäume in C++
// Funktionsschablonen fuer Baumdurchlaeufe
template <class T> void inorder(baumKnoten<T>* b,
void aufsuchen(T& merkmal))
{
if (b != NULL)
{
inorder(b->holeLinks(),aufsuchen);
aufsuchen(b->daten);
inorder(b->holeRechts(),aufsuchen);
}
}
template <class T> void postorder(baumKnoten<T>* b,
void aufsuchen(T& merkmal))
{
if (b != NULL)
{
postorder(b->holeLinks(),aufsuchen);
// linker Abstieg
postorder(b->holeRechts(),aufsuchen);
// rechter Abstieg
aufsuchen(b->daten);
}
}
b) Rekursive Ausgabefunktion in Java88
public static void print (StringBinaerBaumknoten baum)
// Rekursive Druckfunktion
{
if (baum == null) return;
print(baum.getLinks());
System.out.println(baum.getString());
print(baum.getRechts());
}
Aufgaben: Gegeben sind eine Reihe binärer Bäume. Welche Folgen entstehen beim
Durchlaufen der Knoten nach den Prinzipien "Inorder (LWR)", "Praeorder WLR" und
"Postorder (LRW)".
1.
A
B
C
E
D
F
I
G
J
H
K
L
"Praeorder": A B C E I F J D G H K L
"Inorder":
EICFJBGDKHLA
88
vgl. pr42100
217
Algorithmen und Datenstrukturen
"Postorder": I E J F C G K L H D B A
2.
+
*
A
+
B
*
C
E
D
"Praeorder": + * A B + * C D E
"Inorder":
A*B+C*D+E
"Postorder": A B * C D * E + +
Diese Aufgabe zeigt einen Strukturbaum (Darstellung der hierarchischen Struktur eines arithmetischen
Ausdrucks). Diese Baumdarstellung ist besonders günstig für die Übersetzung eines Ausdrucks in
Maschinensprache. Aus der vorliegenden Struktur lassen sich leicht die unterschiedlichen
Schreibweisen eines arithmetischen Ausdrucks herleiten. So liefert das Durchwandern des Baums in
"Postorder" die Postfixnotation, in "Praeorder" die Praefixnotation".
3.
+
A
*
B
C
"Praeorder": + A * B C
"Inorder":
A+B*C
"Postorder": A B C * +
4.
*
+
A
C
B
"Praeorder": * + A B C
"Inorder":
A+B*C
"Postorder": A B + C *
218
Algorithmen und Datenstrukturen
Anwendungen der Durchlaufprinzipien
Mit Hilfe der angegebenen Ordnungen bzw. Durchlaufprinzipien lassen sich weitere
Operationen auf geordneten Wurzelbäumen bestimmen:
a) C++-Anwendungen
1. Bestimmen der Anzahl Blätter im Baum
// Anzahl Blätter
template <class T>
void anzBlaetter(baumKnoten<T>* b, int& zaehler)
{
// benutze den Postorder-Durchlauf
if (b != NULL)
{
anzBlaetter(b->holeLinks(), zaehler);
anzBlaetter(b->holeRechts(), zaehler);
// Pruefe, ob der erreichte Knoten ein Blatt ist
if (b->holeLinks() == NULL && b->holeRechts() == NULL)
zaehler++;
}
}
2. Ermitteln der Höhe des Baums
// Hoehe des Baums
template <class T>
int hoehe(baumKnoten<T>* b)
{
int hoeheLinks, hoeheRechts, hoeheWert;
if (b == NULL)
hoeheWert = -1;
else
{
hoeheLinks = hoehe(b->holeLinks());
hoeheRechts = hoehe(b->holeRechts());
hoeheWert = 1 +
(hoeheLinks > hoeheRechts ? hoeheLinks : hoeheRechts);
}
return hoeheWert;
}
3. Kopieren des Baums
// Kopieren eines Baums
template <class T>
baumKnoten<T>* kopiereBaum(baumKnoten<T>* b)
{
baumKnoten<T> *neuerLzgr, *neuerRzgr, *neuerKnoten;
// Rekursionsendebedingung
if (b == NULL)
return NULL;
if (b->holeLinks() != NULL)
neuerLzgr = kopiereBaum(b->holeLinks());
else
neuerLzgr = NULL;
if (b->holeRechts() != NULL)
neuerRzgr = kopiereBaum(b->holeRechts());
else neuerRzgr = NULL;
// Der neue Baum wird von unten her aufgebaut,
// zuerst werden die Nachfolger bearbeitet und
// dann erst der Vaterknoten
neuerKnoten = erzeugebaumKnoten(b->daten, neuerLzgr, neuerRzgr);
// Rueckgabe des Zeigers auf den zuletzt erzeugten Baumknoten
219
Algorithmen und Datenstrukturen
return neuerKnoten;
}
4. Löschen des Baums
// Loeschen des Baums
template <class T>
void loescheBaum(baumKnoten<T>* b)
{
if (b != NULL)
{
loescheBaum(b->holeLinks());
loescheBaum(b->holeRechts());
gibKnotenFrei(b);
}
}
b) Java-Anwendungen
Grafische Darstellung eines binaeren Suchbaums89
private void zeichneBaum(BinaerBaumknoten b, int x, int y,
int m, int s)
{
Graphics g = meinCanvas.getGraphics();
if (b != null)
{
if (b.links != null)
{
g.drawLine(x,y,x - m / 2,y + s);
zeichneBaum(b.links,x - m / 2,y + s,m / 2,s);
}
if (b.rechts != null)
{
g.drawLine(x,y,x + m / 2,y+s);
zeichneBaum(b.rechts,x + m / 2,y + s,m / 2,s);
}
}
}
}
89
vgl. pr42110
220
Algorithmen und Datenstrukturen
4.3 Balancierte Bäume
Hier geht es darum, entartete Bäume (schiefe Bäume, Äste werden zu linearen
Listen, etc.) zu vermeiden. Statische Optimierung heißt: Der ganze Baum wird neu
(bzw. wieder neu) aufgebaut. Bei der dynamischen Optimierung wird der Baum
während des Betriebs (bei jedem Ein- und Ausfügen) optimiert.
Perfekt ausgeglichener, binärer Suchbaum
Ein binärer Suchbaum sollte immer ausgeglichen sein. Der folgende Baum
1
2
3
4
5
ist zu einer linearen Liste degeneriert und läßt sich daher auch nicht viel schneller als eine lineare Liste
durchsuchen. Ein derartiger binärer Suchbaum entsteht zwangsläufig, wenn die bei der Eingabe angegebene
Schlüsselfolge in aufsteigend sortierter Reihenfolge vorliegt. Der vorliegende binäre Suchbaum ist
selbstverständlich nicht ausgeglichen. Es gibt allerdings auch Unterschiede bei der Beurteilung der
Ausgeglichenheit, z.B.:
Die vorliegenden Bäume sind beide ausgeglichen. Der linke Baum ist perfekt ausbalanciert. Jeder Binärbaum ist
perfekt ausbalanciert, falls jeder Knoten über einen linken und rechten Teilbaum verfügt, dessen Knotenzahl
sich höchstens um den Wert 1 unterscheidet.
Der rechte Teilbaum ist ein in der Höhe ausgeglichener (AVL90-)Baum. Die Höhe der
Knoten zusammengehöriger linker und rechter Teilbäume unterscheiden sich
höchstens um den Wert 1. Jeder perfekt ausgeglichene Baum ist gleichzeitig auch
ein in der Höhe ausgeglichener Binärbaum. Der umgekehrte Fall trifft allerdings nicht
zu.
Es gibt einen einfachen Algorithmus zum Erstellen eines pefekt ausgeglichenen
Binärbaums, falls
(1) die einzulesenden Schlüsselwerte sortiert in aufsteigender Reihenfolge angegeben werden
(2) bekannt ist, wieviel Objekte (Schlüssel) werden müssen.
90
nach den Afangsbuchstaben der Namen seiner Entdecker: Adelson, Velskii u. Landes
221
Algorithmen und Datenstrukturen
import java.io.*;
class PBBknoten
{
// Instanzvariable
protected PBBknoten links;
// linker Teilbaum
protected PBBknoten rechts;
// rechter Teilbaum
public int daten;
// Dateninhalt der Knoten
// Konstruktoren
public PBBknoten()
{
this(0,null,null);
}
public PBBknoten(int datenElement)
{
this(datenElement, null, null );
}
public PBBknoten(int datenElement,
PBBknoten l,
PBBknoten r)
{
daten
= datenElement;
links
= l;
rechts
= r;
}
public PBBknoten getLinks()
{
return links;
}
public PBBknoten getRechts()
{
return rechts;
}
}
public class PBB
{
static BufferedReader ein = new BufferedReader(new InputStreamReader(
System.in));
// Instanzvariable
PBBknoten wurzel;
// Konstruktor
public PBB(int n) throws IOException
{
if (n == 0) wurzel = null;
else
{
int nLinks = (n - 1) / 2;
int nRechts = n - nLinks - 1;
wurzel = new PBBknoten();
wurzel.links = new PBB(nLinks).wurzel;
wurzel.daten = Integer.parseInt(ein.readLine());
wurzel.rechts = new PBB(nRechts).wurzel;
}
}
public void ausgPBB()
{
ausg(wurzel,0);
}
private void ausg(PBBknoten b, int nSpace)
{
if (b != null)
{
ausg(b.rechts,nSpace += 6);
for (int i = 0; i < nSpace; i++)
222
Algorithmen und Datenstrukturen
System.out.print(" ");
System.out.println(b.daten);
ausg(b.links, nSpace);
}
}
// Test
public static void main(String args[]) throws IOException
{
int n;
System.out.print("Gib eine ganze Zahl n an, ");
System.out.print("gefolgt von n ganzen Zahlen in ");
System.out.println("aufsteigender Folge");
n = Integer.parseInt(ein.readLine());
PBB b = new PBB(n);
System.out.print(
"Hier ist das Resultat: ein perfekt balancierter Baum, ");
System.out.println("die Darstellung erfogt um 90 Grad versetzt");
b.ausgPBB();
}
}
Schreibtischtest: Wird mit n = 10 aufgerufen, dann wird anschließend die Anzahl der Knoten
berechnet, die sowohl in den linken als auch in den rechten Teilbaum eingefügt werden. Da der
Wurzelknoten keinem Teilbaum zugeordnet werden kann, ergeben sich für die beiden Teilbäume (10 –
1) Knoten. Das ergibt nLinks = 4, nRechts = 5. Anschließend wird der Wurzelknoten erzeugt. Es
folgt der rekursive Aufruf wurzel.links = new PBB(nLinks).wurzel; mit nLinks = 4. Die
Folge davon ist: Einlesen von 4 Zahlen und Ablage dieser Zahlen im linken Teilbaum. Die danach
folgende Zahl wird im Wurzelknoten abgelegt. Der rekursive Aufruf wurzel.rechts = new
PBB(nRechts).wurzel; mit nRechts = 5 verarbeitet die restlichen 5 Zahlen und erstellt damit
den rechten Teilbaum.
Durch jedem rekursiven Aufruf wird ein Baum mit zwei ungefähr gleich großen Teilbäumen erzeugt. Da die im
Wurzelknoten enthaltene Zahl direkt nach dem erstellen des linken Teilbaum gelesen wird, ergibt sich bei
aufsteigender Reihenfolge der Eingabedaten ein binärer Suchbaum, der außerdem noch perfekt balanciert ist.
223
Algorithmen und Datenstrukturen
4.3.1 Statisch optimierte Bäume
Der Algorithmus zum Erstellen eines perfekt ausgeglichenen Baums kann zur
statischen Optimierung binärer Suchbäume verwendet werden. Das Erstellen des
binären Suchbaums erfolgt dabei nach der bekannten Verfahrensweise. Wird ein
solcher Baum in Inorder-Folge durchlaufen, so werden die Informationen in den
Baumknoten aufsteigend sortiert. Diese sortierte Folge ist Eingangsgröße für die
statische Optimierung. Es wird mit der sortiert vorliegende Folge der Schlüssel ein
perfekt ausgeglichener Baum erstellt.
Bsp.: Ein Java-Applet zur Demonstration der statischen Optimierung.91
Zufallszahlen werden generiert und in einen freien binären Intervallbaum aufgenommen, der im oberen
Bereich des Fensters gezeigt werden. Über Sortieren erfolgt ein Inorder-Durchlauf des binären
Suchbaums, über „Perfekter BinaerBaum“ Erzeugen und Darstellen des perfekt ausgeglichen binären
Suchbaums.
Abb.:
91
vgl. pr43205, ZPBBApplet.java und ZPBBApplet.html
224
Algorithmen und Datenstrukturen
4.3.2 AVL-Baum
Der wohl bekannteste dynamisch ausgeglichene Binärbaum ist der AVL-Baum,
genannt nach dem Anfangsbuchstaben seiner Entdecker (Adelson, Velskii und
Landis). Ein Baum hat die AVL-Eigenschaft, wenn in jedem Knoten sich die Höhen
der beiden Unterbäume höchstens um 1 (|HR - HL| <= 1) unterscheiden.
Die Last ("Balance") muß in einem Knoten mitgespeichert sein. Es genügt aber als
Maß für die Unsymmetrie die Höhendifferenz H festzuhalten, die nur die Werte -1
(linkslastig), 0 (gleichlastig) und +1 (rechtslastig) annehmen kann.
1. Einfügen
Beim Einfügen eines Knoten können sich die Lastverhältnisse nur auf dem Wege,
den der Suchvorgang in Anspruch nimmt, ändern. Der tatsächliche Ort der Änderung
ist aber zunächst unbekannt. Der Knoten ist deshalb einzufügen und auf notwendige
Berichtigungen zu überprüfen.
Bsp.: Gegeben ist der folgende binäre Baum
8
4
2
10
6
Abb.:
1) In diesen Baum sind die Knoten mit den Schlüsseln 9 und 11 einzufügen. Die Gestalt des Baums ist
danach:
8
4
2
10
6
9
Abb.:
Die Schlüsel 9 und 11 können ohne zusätzliches Ausgleichen eingefügt werden.
225
11
Algorithmen und Datenstrukturen
2) In den gegebenen Binärbaum sind die Knoten mit den Schlüsseln 1, 3, 5 und 7 einzufügen. Wie ist
die daraus resultierende Gestalt des Baums beschaffen?
8
4
2
-1
-2
10
6
1
Abb.:
Schon nach dem Einfügen des Schlüsselwerts „1“ ist anschließendes Ausgleichen unbedingt
erforderlich.
3) Wie könnte das Ausgleichen vollzogen werden?
Eine Lösungsmöglichkeit ist hier bspw. eine einfache bzw. eine doppelte Rotation.
4
2
8
1
6
10
Abb.: Gestalt des baums nach „Rotation“
b) Beschreibe den Ausgleichsvorgang, nachdem die Schlüssel 3, 5 und 7 eingefügt wurden!
4
2
1
8
3
6
5
10
7
Abb.: Das Einfügen der Schlüssel mit den Werten „3“, „5“ und „7“ verletzt die AVL-Eigenschaft nicht
Nachdem ein Knoten eingefügt ist, ist der Pfad, den der Suchvorgang für das
Einfügen durchlaufen hat, aufwärts auf notwendige Berichtigungen zu überprüfen.
Bei dieser Prüfung wird die Höhendifferenz des linken und rechten Teilbaums
bestimmt. Es können generell folgende Fälle eintreten:
226
Algorithmen und Datenstrukturen
(1)
H = +1 bzw. -1
Eine Verlängerung des Baums auf der Unterlastseite gleicht die Last aus, die
Verlängerung wirkt sich nicht weiter nach oben aus. Die Prüfung kann abgebrochen
werden.
(2)
H = 0
Das bedeutet: Verlängerung eines Teilbaums
Hier ist der Knoten dann ungleichlastig ( H = +1 bzw. -1), die AVL-Eigenschaft bleibt jedoch
insgesamt erhalten. Der Baum wurde länger.
(3) H = +1 bzw. -1
Das bedeutet: Verlängerung des Baums auf der Überlastseite.
Die AVL-Eigenschaft ist verletzt, wenn H = +2 bzw. -2. Sie wird durch Rotationen
berichtigt.
Die Information über die Ausgeglichenheit steht im AVL-Baumknoten, z.B.:
struct knoten
{ int num,
// Schluessel
bal;
// Ausgleichsfaktor
struct knoten *zLinks, *zRechts;
};
Abb.: AVL-Baumknoten mit Ausgleichfaktor in C++
In der Regel gibt es folgende Faktoren für die "Ausgeglichenheit" je Knoten im AVL-Baum:
"-1": Höhe des linken Teilbaums ist um eine Einheit (ein Knoten) größer als die Höhe im
rechten Teilbaum.
"0": Die Höhen des linken und rechten Teilbaums sind gleich.
"1": Die Höhe des linken Teilbaums ist um eine Einheit (ein Knoten) kleiner als die Höhe des rechten
Teilbaums.
Bsp.:
Die folgende Darstellung zeigt den Binärbaum unmittelbar nach dem Einfügen eines Baumknoten.
Daher kann hier der Faktor für Ausgeglichenheit -2 bis 2 betragen.
12
+1
7
17
+1
+2
5
0
9
14
-1
0
8
24
+2
25
0
+1
30
0
227
Algorithmen und Datenstrukturen
Nach dem Algorithmus für das Einfügen ergibt sich folgender AVL-Baum:
12
0
7
17
+1
+1
5
9
14
25
-1
0
8
24
0
30
0
0
Es gibt 4 Möglichkeiten die Ausgeglichenheit, falls sie durch Einfügen eines
Baumknoten gestört wurde, wieder herzustellen.
A
A
B
b
a
A
a
B
c
c
1a
B
b
a
b
1b
A
a
B
c
2a
c
b
2b
Abb.: Die vier Ausgangssituationen bei der Wiederherstellung der AVL-Eigenschaft
Von den 4 Fällen sind jeweils die Fälle 1a, 1b und die Fälle 2a, 2b zueinander
symmetrisch.
Für den Fall 1a kann durch einfache Rotation eine Ausgeglichenheit erreicht werden.
B
0
b
A
0
c
a
Im Fall 1b muß die Rotation nach links erfolgen.
228
Algorithmen und Datenstrukturen
Für die Behandlung von Fall 2a der Abb. 1 wird der Teilbaum c aufgeschlüsselt in
dessen Teilbäume c1 und c2:
A
-2
B
a
2
b
C
-1
c1
c2
Abb.:
Durch zwei einfache Rotationen kann der Baum ausgeglichen werden:
1. Rotation
2. Rotation
A
C
-2
C
0
a
B
-2
B
A
+1
c2
b
+1
c1
c2
+1
b
c1
Abb.:
a) Implementierung in C++
Mit der unter 1. festgestellten Verfahrensweise soll eine Klasse AVLBaum bestimmt
werden, die Knoten der zuvor angegebenen Struktur so in einen Binärbaum einfügt,
daß die AVL-Eigenschaft gewährleistet bleibt.
// avl: Demonstrationsprogramm fuer AVL-Baeume
//
Einfuegen und Loeschen von Knoten
#include <iostream.h>
#include <iomanip.h>
#include <ctype.h>
struct knoten
229
a
Algorithmen und Datenstrukturen
{ int num, bal;
struct knoten *zLinks, *zRechts;
};
class AVLbaum
{
private:
knoten *wurzel;
void LinksRotation(knoten* &z);
void RechtsRotation(knoten* &z);
int einf(knoten* &z, int x);
void aus(const knoten *z, int nLeerZeichen)
const;
public:
AVLbaum():wurzel(NULL){}
void einfuegen(int x){einf(wurzel, x);}
void ausgabe()const{aus(wurzel, 0);}
};
Methoden.
void AVLbaum::LinksRotation(knoten* &z)
{ knoten *hz = z;
z = z->zRechts;
hz->zRechts = z->zLinks;
z->zLinks = hz;
hz->bal--;
if (z->bal > 0) hz->bal -= z->bal;
z->bal--;
if (hz->bal < 0) z->bal += hz->bal;
}
void AVLbaum::RechtsRotation(knoten* &z)
{ knoten *hz = z;
z = z->zLinks;
hz->zLinks = z->zRechts;
z->zRechts = hz; hz->bal++;
if (z->bal < 0) hz->bal -= z->bal;
z->bal++;
if (hz->bal > 0) z->bal += hz->bal;
}
int AVLbaum::einf(knoten* &z, int x)
{ // Rueckgabewert: Anstieg in der Hoehe
// (0 or 1) nach Einfuegen von x in den
// Teilbaum mit Wurzel z
int deltaH = 0;
if (z == NULL)
{ z = new knoten;
z->num = x; z->bal = 0;
z->zLinks = z->zRechts = NULL;
deltaH = 1;
// Die Hoehe des Baums waechst um 1
}
else if (x > z->num)
{ if (einf(z->zRechts, x))
{ z->bal++;
// Die Hoehe des rechten Teilbaums waechst
if (z->bal == 1) deltaH = 1;
else if (z->bal == 2)
{
if (z->zRechts->bal == -1)
RechtsRotation(z->zRechts);
LinksRotation(z);
}
}
}
230
Algorithmen und Datenstrukturen
else if (x < z->num)
{ if (einf(z->zLinks, x))
{ z->bal--;
// Hoehe vom linken Teilbaum waechst
if (z->bal == -1) deltaH = 1;
else if (z->bal == -2)
{
if (z->zLinks->bal == 1)
LinksRotation(z->zLinks);
RechtsRotation(z);
}
}
}
return deltaH;
}
b) Die generische Klasse „AvlBaum“ in Java
Grundlagen: Die AVL-Eigenschaft ist verletzt, wenn diese Höhendifferenz +2 bzw. –2 ist. Der Knoten, der
diesen Wert erhalten hat, ist der Knoten „alpha“, dessen Unausgeglichenheit auf einen der folgenden 4 Fälle
zurückzuführen ist:
1. Einfügen in den linken Teilbaum, der vom linken Nachkommen des Knoten „alpha“ bestimmt ist.
2. Einfügen in den rechten Teilbaum, der vom linken Nachkommen des Knoten „alpha“ bestimmt ist.
3. Einfügen in den linken Teilbaum, der vom rechten Nachkommen des Knoten „alpha“ bestimmt ist.
4. Einfügen in den rechten Teilbaum, der vom rechten Nachkommen des Knoten „alpha“ bestimmt ist
Fall 1 und Fall 4 bzw. Fall 2 und Fall 3 sind Spiegelbilder, zeigen also das gleiche
Verhalten.
Fall 1 kann durch einfache Rotation behandelt werden und ist leicht zu bestimmen,
daß das Einfügen „außerhalb“ (links – links bzw. rechts – rechts im Fall 4 stattfindet.
Fall 2 kann durch doppelte Rotation behandelt werden und ist ebenfalls leicht zu
bestimmen, da das Einfügen „innerhalb“ (links –rechts bzw. rechts – links) erfolgt.
Die einfache Rotation: Die folgende Darstellung beschreibt den Fall 1 vor und nach der Rotation:
231
Algorithmen und Datenstrukturen
k2
k1
k1
k2
Z
Y
X
Y
Z
X
Abb.:
Die folgende Darstellung beschreibt Fall 4 vor und nach der Rotation:
k1
k2
k2
k1
X
Y
X
Y
Z
Z
Abb.:
Doppelrotation: Die einfache Rotation führt in den Fällen 2 und 3 nicht zum Erfolg.
Fall 2 muß durch eine Doppelrotation (links – rechts) behandelt werden.
k3
k2
k1
k1
k3
D
k2
B
A
A
B
C
Abb.:
232
C
D
Algorithmen und Datenstrukturen
Auch Fall 3 muß durch Doppelrotation behandelt werden
k1
k2
k2
A
k1
k3
k3
D
B
A
B
C
D
C
Abb.:
Implementierung: Zum Einfügen eines Knoten mit dem Datenwert „x“ in einen AVLBaum, wird „x“ rekursiv in den betoffenen Teilbaum eingesetzt. Falls die Höhe dieses
Teilbaums sich nicht verändert, ist das Einfügen beendet. Liegt Unausgeglichenheit
vor, dann ist einfache oder doppelte Rotation (abhängig von „x“ und den Daten des
betroffenen Teilbaums) nötig.
Avl-Baumknoten92
Er enthält für jeden Knoten eine Angabe zur Höhe(ndifferenz) seiner Teilbäume.
// Baumknoten fuer AVL-Baeume
class AvlKnoten
{
// Instanzvariable
protected AvlKnoten links;
// Linkes Kind
protected AvlKnoten rechts;
// Rechtes Kind
protected int
hoehe;
// Hoehe
public Comparable daten;
// Das Datenelement
// Konstruktoren
public AvlKnoten(Comparable datenElement)
{
this(datenElement, null, null );
}
public AvlKnoten( Comparable datenElement,
AvlKnoten lb,
AvlKnoten rb )
{
daten = datenElement;
links = lb;
rechts = rb;
hoehe = 0;
}
}
92
vgl. pr43210
233
Algorithmen und Datenstrukturen
Der Avl-Baum93
Bei jedem Schritt ist festzustellen, ob die Höhe des Teilbaums, in dem ein Element eingefügt wurde,
zugenommen hat.
/*
* Rueckgabe: Hoehe des Knotens, oder -1, falls null.
*/
private static int hoehe(AvlKnoten b)
{
return b == null ? -1 : b.hoehe;
}
Die Methode „insert“ führt das Einfügen eines Baumknoten in den Avl-Baum aus:
/*
* Interne Methode zum Einfuegen eines Baumknoten in einen Teilbaum.
* x ist das einzufuegende Datenelement.
* b ist der jeweilige Wurzelknoten.
* Rueckgabe der neuen Wurzel des jeweiligen Teilbaums.
*/
private AvlKnoten insert(Comparable x, AvlKnoten b)
{
if( b == null )
b = new AvlKnoten(x, null, null);
else if (x.compareTo( b.daten) < 0 )
{
b.links = insert(x, b.links );
if (hoehe( b.links ) - hoehe( b.rechts ) == 2 )
if (x.compareTo( b.links.daten ) < 0 )
b = rotationMitLinksNachf(b);
else
b = doppelrotationMitLinksNachf(b);
}
else if (x.compareTo( b.daten ) > 0 )
{
b.rechts = insert(x, b.rechts);
if( hoehe(b.rechts) - hoehe(b.links) == 2)
if( x.compareTo(b.rechts.daten) > 0 )
b = rotationMitRechtsNachf(b);
else
b = doppelrotationMitRechtsNachf( b );
}
else
; // Duplikat; tue nichts
b.hoehe = max( hoehe( b.links ), hoehe( b.rechts ) ) + 1;
return b;
}
Rotationen
/*
* Rotation Binaerbaumknoten mit linkem Nachfolger.
* Fuer AVL-Baeume ist dies eine einfache Rotation (Fall 1).
* Aktualisiert Angaben zur Hoehe, dann Rueckgabe der neuen Wurzel.
*/
private static AvlKnoten rotationMitLinksNachf(AvlKnoten k2)
{
AvlKnoten k1 = k2.links;
k2.links = k1.rechts;
k1.rechts = k2;
k2.hoehe = max( hoehe( k2.links ), hoehe( k2.rechts ) ) + 1;
k1.hoehe = max( hoehe( k1.links ), k2.hoehe ) + 1;
return k1;
93
vgl. pr43210
234
Algorithmen und Datenstrukturen
}
/*
* Rotation Binaerbaumknoten mit rechtem Nachfolger.
* Fuer AVL-Bäume ist dies eine einfache Rotation (Fall 4).
* Aktualisiert Angaben zur Hoehe,, danach Rueckgabe der neuen Wurzel.
*/
private static AvlKnoten rotationMitRechtsNachf(AvlKnoten k1)
{
AvlKnoten k2 = k1.rechts;
k1.rechts = k2.links;
k2.links = k1;
k1.hoehe = max( hoehe( k1.links ), hoehe( k1.rechts ) ) + 1;
k2.hoehe = max( hoehe( k2.rechts ), k1.hoehe ) + 1;
return k2;
}
/*
* Doppelrotation der Binaerbaumknoten: : erster linker Nachfolgeknoten
* mit seinem rechten Nachfolger; danach Knoten k3 mit neuem linken
* Nachfolgerknoten.
* Fuer AVL-Baeume ist dies eine doppelte Rotation (Fall 2)
* Aktualisiert Angaben zur Hoehe,, danach Rueckgabe der neuen Wurzel.
*/
private static AvlKnoten doppelrotationMitLinksNachf(AvlKnoten k3)
{
k3.links = rotationMitRechtsNachf( k3.links );
return rotationMitLinksNachf( k3 );
}
/*
* Doppelrotation der Binaerbaumknoten: erster rechter Nachfolgeknoten
* mit seinem linken Nachfolger;danach Knoten k1 mit neuem rechten
* Nachfolgerknoten
* Fuer AVL-Baeume ist dies eine doppelte Rotation (Fall 3)
* Aktualisiert Angaben zur Hoehe,, danach Rueckgabe der neuen Wurzel.
*/
private static AvlKnoten doppelrotationMitRechtsNachf(AvlKnoten k1)
{
k1.rechts = rotationMitLinksNachf(k1.rechts);
return rotationMitRechtsNachf(k1);
}
}
235
Algorithmen und Datenstrukturen
2. Löschen
Man kann folgende Fälle unterscheiden:
(1) H = +1 bzw. -1
(Verkürzung des Teilbaums auf der Überlastseite)
(2) H = 0
(Verkürzung eines Unterbaums)
Der Knoten ist jetzt ungleichlastig ( H = +1 bzw. -1), bleibt jedoch im Rahmen der AVL-Eigenschaft.
Der Baum hat seine Höhe nicht verändert, die Berichtigung kann abgebrochen werden.
(3) H = +1 bzw. -1
(Verkürzung eines Baums auf der Unterlastseite)
Die AVL-Eigenschaft ist verletzt, falls H = +2 bzw. -2. Sie wird durch eine Einfachbzw. Doppelrotation wieder hergestellt. Dadurch kann sich der Baum verkürzen, so
daß Lastreduzierungen an den Vorgänger weiterzugeben sind. Es können aber auch
Lastsituationen mit dem Lastfaktor 0 auftreten.
Bsp.: Spezialfälle zum Lastausgleich in einem AVL-Baum
k
k'
H+2
H+1
k'
a
k
H+1
H+1
b
c
c
H
H+1
a
b
k
k''
H+1
H+2
k'
a
k''
k
H
H
a
b
d
H
H
Abb.:
Löschen kann in der Regel nicht mit einer einzigen Rotation abgeschlossen werden.
Im schlimmsten Fall erfordern alle Knoten im Pfad der Löschstelle eine
Rekonfiguration. Experimente zeigen allerdings, daß beim Einfügen je Operation
mehr Rotationen durchzuführen sind als beim Löschen. Offenbar existieren beim
Löschen durch den inneren Bereich des Baums mehr Knoten, die ohne weiteres
eliminiert werden können.
236
Algorithmen und Datenstrukturen
Aufgabe
1. Gegeben ist die Schlüsselfolge 7, 6, 8, 5, 9, 4. Ermittle, wie sich mit dieser Schlüsselfolge einen
AVL-Baum aufbaut.
Schlüssel
7
BALANCE
0
LINKS, RECHTS
5
8
0
-1
4
6
0
0
9
0
Abb.:
2. Aus dem nun vorliegenden AVL-Baum sind die Knoten mit den Schlüsselwerten 9 und 8 zu löschen.
Gib an, welche Gestalt der AVL-Baum jeweils annimmt.
Schlüssel
5
BALANCE
1
LINKS, RECHTS
7
4
0
-1
6
0
Abb.:
237
Algorithmen und Datenstrukturen
4.3.3 Splay-Bäume
Zugrundeliegende Idee
Nachdem auf einen Baumknoten zugegriffen wurde, wird dieser Knoten über eine
Reihe von AVL-Rotationen zur Wurzel. Bis zu einem gewissen Grade führt das zur
Ausbalancierung.
Bsp.:
1.
y
x
x
C
A
A
y
B
B
2.
e
C
e
d
F
e
d
F
d
F
c
E
c
E
b
A
a
E
a
a
D
b
A
b
c
C
D
B
B
C
C
D
A
E
a
a
F
c
c
e
d
d
A
B
F
b
A
B
b
B
E
E
C
C
D
238
D
Algorithmen und Datenstrukturen
Splaying-Operationen
Der Knoten „x“ im Splay-Baum bewegt sich über einfache und doppelte Rotationen
zur Wurzel. Man unerscheidet folgende Fälle:
1. (zig): x ist ein Kind der Wurzel von einem Splay-Baum, einfache Rotation
2. (zig-zig): x hat den Großvater g(x) und den Vater p(x), x und p(x) sind jeweils linke (bzw. rechte)
Kinder ihres Vaters.
g(x)=p(y)
g(x)
bzw.
y = p(x)
p(x)
D
A
x
x
C
A
B
B
C
D
x
y
A
z
B
C
D
3. (zig-zag): x hat Großvater g(x) und Vater p(x), x ist linkes (rechtes) Kind von p(x), p(x) ist rechtes
(linkes) Kind von g(x)
z = g(x)
x
y=p(x)
y
z
D
x
A
B
A
B
C
239
C
D
Algorithmen und Datenstrukturen
Implementierung94
BinaerBaumKnoten
// Elementarer Knoten eines binaeren Baums, der nicht ausgeglichen ist
// Der Zugriff auf diese Klasse ist nur innerhalb eines Verzeichnisses
// bzw. Pakets moeglich
class BinaerBaumknoten
{
// Instanzvariable
protected BinaerBaumknoten links;
// linker Teilbaum
protected BinaerBaumknoten rechts;
// rechter Teilbaum
public Comparable daten;
// Dateninhalt der Knoten
// Konstruktor
public BinaerBaumknoten(Comparable datenElement)
{
this(datenElement, null, null );
}
public BinaerBaumknoten(Comparable datenElement,
BinaerBaumknoten l,
BinaerBaumknoten r)
{
daten
= datenElement;
links
= l;
rechts
= r;
}
public void insert (Comparable x)
{
if (x.compareTo(daten) > 0)
// dann rechts
{
if (rechts == null) rechts = new BinaerBaumknoten(x);
else rechts.insert(x);
}
else // sonst links
{
if (links == null) links = new BinaerBaumknoten(x);
else links.insert(x);
}
}
public BinaerBaumknoten getLinks()
{
return links;
}
public BinaerBaumknoten getRechts()
{
return rechts;
}
}
SplayBaum
//
//
//
//
//
//
//
//
//
//
//
94
SplayBaum class
***************** PUBLIC OPERATIONen ********************
void insert( x )
--> Insert x
void remove( x )
--> Remove x
Comparable find( x )
--> Gib das Merkmal zurück, das x zugeordnet ist
Comparable findMin( ) --> Rueckgabe des kleinsten elements
Comparable findMax( ) --> Rueckgabe des groessten Elements
boolean isEmpty( )
--> Rueckgabe true, falls leer; sonst false
void makeEmpty( )
--> Entferne alle Elemente
void printTree( )
--> Gib den Baum sortiert aus
pr43215
240
Algorithmen und Datenstrukturen
/*
* Implementiere einen top-down Splay Baum.
* Vergleiche beziehen sich auf die Methode compareTo.
*/
public class SplayBaum
{
private BinaerBaumknoten root;
private static BinaerBaumknoten nullNode;
static
// Static initializer for nullNode
{
nullNode = new BinaerBaumknoten( null );
nullNode.links = nullNode.rechts = nullNode;
}
private static BinaerBaumknoten newNode = null;
// wird in diversen Einfuegevorgaengen benutzt
private static BinaerBaumknoten header = new BinaerBaumknoten(null);
/*
* Konstruktor.
*/
public SplayBaum( )
{
root = nullNode;
}
/*
* Zugriff auf die Wurzel
*/
public BinaerBaumknoten holeWurzel()
{
return root;
}
/*
* Insert.
* Parameter x ist das einzufuegende Element.
*/
public void insert( Comparable x )
{
if( newNode == null )
newNode = new BinaerBaumknoten( null );
newNode.daten = x;
if( root == nullNode )
{
newNode.links = newNode.rechts = nullNode;
root = newNode;
}
else
{
root = splay( x, root );
if( x.compareTo( root.daten ) < 0 )
{
newNode.links = root.links;
newNode.rechts = root;
root.links = nullNode;
root = newNode;
}
else if( x.compareTo( root.daten ) > 0 )
{
newNode.rechts = root.rechts;
newNode.links = root;
root.rechts = nullNode;
root = newNode;
}
else return;
}
newNode = null;
}
/*
241
Algorithmen und Datenstrukturen
* Remove.
* Parameter x ist das zu entfernende Element.
*/
public void remove( Comparable x )
{
BinaerBaumknoten neuerBaum;
// Falls x gefunden wird, liegt x in der Wurzel
root = splay( x, root );
if( root.daten.compareTo( x ) != 0 )
return;
// Element nicht gefunden; tue nichts
if( root.links == nullNode )
neuerBaum = root.rechts;
else
{
// Finde das Maximum im linken Teilbaum
// Splay es zur Wurzel; dann haenge das rechte Kind dran
neuerBaum = root.links;
neuerBaum = splay( x, neuerBaum );
neuerBaum.rechts = root.rechts;
}
root = neuerBaum;
}
/*
* Bestimme das kleinste Daten-Element im Baum.
* Rueckgabe: kleinstes Datenelement bzw. null, falls leer.
*/
public Comparable findMin( )
{
if( isEmpty( ) ) return null;
BinaerBaumknoten ptr = root;
while( ptr.links != nullNode ) ptr = ptr.links;
root = splay( ptr.daten, root );
return ptr.daten;
}
/*
* Bestimme das groesste Datenelement im Baum.
* Rueckgabe: das groesste Datenelement bzw. null, falls leer
*/
public Comparable findMax( )
{
if (isEmpty( )) return null;
BinaerBaumknoten ptr = root;
while( ptr.rechts != nullNode ) ptr = ptr.rechts;
root = splay( ptr.daten, root );
return ptr.daten;
}
/*
* Bestimme ein Datenelement im Baum.
* Parameter x entfält das zu suchende Element.
* Rueckgabe: Das passende Datenelement oder null, falls leer
*/
public Comparable find( Comparable x )
{
root = splay( x, root );
if (root.daten.compareTo( x ) != 0) return null;
return root.daten;
}
/*
* Mache den Baum logisch leer.
*/
public void makeEmpty( )
{
root = nullNode;
}
/*
* Ueberpruefe, ob der Baum logisch leer ist
* Rueckgabe true, falls leer, anderenfalls false.
242
Algorithmen und Datenstrukturen
*/
public boolean isEmpty( )
{
return root == nullNode;
}
/*
* Gib den Inhalt des baums in sortierter Folge aus.
*/
public void printTree( )
{
if (isEmpty( ))
System.out.println( "Empty tree" );
else
printTree( root );
}
/*
* Ausgabe des Binaerbaums um 90 Grad versetzt
*/
public void ausgBinaerbaum(BinaerBaumknoten b, int stufe)
{
if (b != b.links)
{
ausgBinaerbaum(b.links,stufe + 3);
for (int i = 0; i < stufe; i++)
{
System.out.print(' ');
}
System.out.println(b.daten.toString());
ausgBinaerbaum(b.rechts,stufe + 3);
}
}
/*
* Interne Methode zur Ausfuehrung eines "top down" splay.
* Der zuletzt im Zugriff befindliche Knoten
* wird die neue Wurzel.
* Parameter x Ist das Zielelement, die Umgebung fuer das Splaying.
* Parameter b ist die Wurzel des Teilbaums,
* um den das Splaying stattfindet.
* Rueckgabe des Teilbaums.
*/
private BinaerBaumknoten splay( Comparable x, BinaerBaumknoten t )
{
BinaerBaumknoten leftTreeMax, rightTreeMin;
header.links = header.rechts = nullNode;
leftTreeMax = rightTreeMin = header;
nullNode.daten = x;
// Guarantee a match
for( ; ; )
if( x.compareTo( t.daten ) < 0 )
{
if( x.compareTo( t.links.daten ) < 0 )
t = rotateWithLeftChild( t );
if( t.links == nullNode ) break;
// Kette Rechts
rightTreeMin.links = t;
rightTreeMin = t;
t = t.links;
}
else if( x.compareTo( t.daten ) > 0 )
{
if( x.compareTo( t.rechts.daten ) > 0 )
t = rotateWithRightChild( t );
if( t.rechts == nullNode ) break;
// Kette Links
leftTreeMax.rechts = t;
leftTreeMax = t;
t = t.rechts;
}
243
Algorithmen und Datenstrukturen
else break;
leftTreeMax.rechts = t.links;
rightTreeMin.links = t.rechts;
t.links = header.rechts;
t.rechts = header.links;
return t;
}
/*
* Rotation BinaerBaumknoten mit linkem Nachfolger.
*/
static BinaerBaumknoten rotateWithLeftChild(BinaerBaumknoten k2)
{
BinaerBaumknoten k1 = k2.links;
k2.links = k1.rechts;
k1.rechts = k2;
return k1;
}
/*
* Rotation BinaerBaumknoten mit rechtem Nachfolger.
*/
static BinaerBaumknoten rotateWithRightChild(BinaerBaumknoten k1)
{
BinaerBaumknoten k2 = k1.rechts;
k1.rechts = k2.links;
k2.links = k1;
return k2;
}
/*
* Interne Methode zur Ausgabe eines Teilbaums in sortierter Folge.
* Parameter b ist der jweilige Wurzelknoten.
*/
private void printTree( BinaerBaumknoten b )
{
if( b != b.links )
{
printTree(b.links);
System.out.println(b.daten.toString( ));
printTree(b.rechts);
}
}
}
SplaybaumTest
import java.io.*;
public class SplayBaumTest
{
public static void main( String [ ] args )
{
SplayBaum b = new SplayBaum();
String eingabeZeile
= null;
System.out.println("Einfuegen");
BufferedReader eingabe = null;
eingabe = new BufferedReader(
new InputStreamReader(System.in));
try {
int zahl;
do
{
System.out.println("Zahl? ");
eingabeZeile = eingabe.readLine();
try {
zahl = Integer.parseInt(eingabeZeile);
b.insert(new Integer(zahl));
b.ausgBinaerbaum(b.holeWurzel(),2);
244
Algorithmen und Datenstrukturen
}
catch (NumberFormatException ne)
{ break; }
} while (eingabeZeile != "");
}
catch (IOException ioe)
{
System.out.println("Eingefangen in main()");
}
System.out.println("Loeschen");
try {
int zahl;
do
{
System.out.println("Zahl? ");
eingabeZeile = eingabe.readLine();
try {
zahl = Integer.parseInt(eingabeZeile);
b.remove(new Integer(zahl));
b.ausgBinaerbaum(b.holeWurzel(),2);
}
catch (NumberFormatException ne)
{ break; }
} while (eingabeZeile != "");
}
catch (IOException ioe)
{
System.out.println("Eingefangen in main()");
}
System.out.println("Zugriff auf das kleinste Element");
b.findMin();
b.ausgBinaerbaum(b.holeWurzel(),2);
System.out.println("Zugriff auf das groesste Element");
b.findMax();
b.ausgBinaerbaum(b.holeWurzel(),2);
}
}
245
Algorithmen und Datenstrukturen
4.3.4 Rot-Schwarz-Bäume
Zum Ausschluß des ungünstigsten Falls bei binären Suchbäumen ist eine gewisse
Flexibilität in den verwendeten Datenstrukturen nötig. Das kann bspw. durch
Aufnahme von mehr als einem Schlüssel in Baumknoten erreicht werden. So soll es
3-Knoten bzw. 4-Knoten geben, die 2 bzw. 3 Schlüssel enthalten können:
- Ein 3-Knoten besitzt 3 von ihm ausgehende Verkettungen
-- eine für alle Datensätze mit Schlüsseln, die kleiner sind als seine beiden Schlüssel
-- eine für alle Datensätze, die zwischen den beiden Schlüsseln liegen
-- eine für alle Datensätze mit Schlüsseln, die größer sind als seine beiden Schlüssel.
- Ein 4-Knoten besitzt vier von ihm ausgehende Verkettungen, nämlich eine Verkettung für jedes der
Intervalle, die durch seine drei Schlüssel definiert werden.
Es ist möglich 2-3-4-Bäume als gewöhnliche binäre Bäume (mit nur zwei Knoten)
darzustellen, wobei nur ein zusätzliches Bit je Knoten verwendet wird. Die Idee
besteht darin, 3-Knoten und 4-Knoten als kleine binäre Bäume darzustellen, die
durch „rote“ Verkettungen miteinander verbunden sind, im Gegensatz zu den
schwarzen Verkettungen, die den 2-3-4-Baum zusammenhalten:
oder
4-Knoten werden als 2-Knoten dargestellt, die mittels einer roten Verkettung verbunden sind.
3-Knoten werden als 2–Knoten dargestellt, die mit einer roten Markierung verbunden sind.
Abb.: Rot-schwarze Darstellung von Bäumen
Zu jedem 2-3-4-Baum gibt es viele ihm entsprechende Rot-Schwarz-Bäume. Diese
Bäume haben eine Reihe von Eigenschaften, die sich unmittelbar aus ihrer Definition
ergeben, z.B.:
- Alle Pfade von der Wurzel zu einem Blatt haben dieselbe Anzahl von schwarzen Kanten. Dabei
werden nur die Kanten zwischen inneren Knoten gezählt.
- Längs eines beliebigen Pfads treten niemals zwei rote Verkettungen nacheinander auf.-
Rot-Schwarz-Bäume erlauben es, AVL-Bäume, perfekt ausgeglichene Bäume und
viele andere Klassen binärer Bäume einheitlich zu repräsentieren und zu
implementieren.
246
Algorithmen und Datenstrukturen
Eine Variante zu Rot-Schwarz-Bäumen
Definition. Ein Rot-Schwarz-Baum ist ein Binärbaum mit folgenden
Farbeigenschaften:
1. Jeder Knoten ist entweder rot oder schwarz gefärbt.
2. der Wurzelknoten ist schwarz gefärbt.
3. Falls ein Knoten rot gefärbt ist, müssen seine Nachfolger schwarz gefärbt sein.
4. Jeder Pfad von einem Knoten zu einer „Null-Referenz“ muß die gleiche Anzahl von schwarzen
Knoten enthalten.
Höhe. Eine Folgerung dieser Farbregeln ist: Die Höhe eines Rot-Schwarz-Baums ist
etwa 2  log( N  1) . Suchen ist garantiert unter logN erfolgreich.
Aufgabe: Ermittle, welche Gestalt jeweils ein nach den vorliegenden Farbregeln
erstellte Rot-Schwarz-Baum beim einfügen folgenden Schlüssel „10 85 15 70 20 60
30 50 65 80 90 40 5 55“ annimmt
10
10
85
10
85
15
15
10
85
70
15
10
85
70
20
15
10
70
20
247
85
Algorithmen und Datenstrukturen
60
15
10
70
20
85
60
30
15
10
70
30
85
20
60
50
30
15
10
70
20
60
85
50
65
30
15
10
70
20
60
85
50
65
80
30
15
10
70
20
60
50
85
65
248
80
Algorithmen und Datenstrukturen
90
30
15
10
70
20
60
85
50
65
80
90
40
30
15
10
70
20
60
85
50
65
80
90
40
5
30
15
10
70
20
60
5
85
50
65
80
90
40
55
30
15
10
70
20
60
5
50
40
85
65
55
9
85
80
90
Die Abbildungen zeigen, daß im durchschnitt der Rot-Schwarz-Baum ungefähr die Tiefe eines AVLBaums besitzt.
Der Vorteil von Rot-Schwarz-Bäumen ist der geringere Overhead zur Ausführung von
Einfügevorgängen und die geringere Anzahl von Rotationen.
249
Algorithmen und Datenstrukturen
„Top-Down“ Rot-Schwarz-Bäume. Kann auf dem Weg nach unten festgestellt
werden, daß ein Knoten X zwei rote Nachfolgeknoten hat, dann wird X rot und die
beiden „Kinder“ schwarz:
X
c1
X
c2
c1
c2
Das führt zu einem Verstoß gegen Bedingung 3, falls der Vorgänger von X auch rot
ist. In diesem Fall können aber geeignete Rotationen herangezogen werden:
G
P
P
S
X
C
X
A
G
A
B
B
S
C
G
P
X
S
X
P
C
A
A
B1
G
B1
B2
B2
S
C
Der folgende Fall kann nicht eintreten: Der Nachbar vom Elternknoten ist rot gefärbt.
Auf dem Weg nach unten muß der Knoten mit zwei roten Nachfolgern einen
schwarzen Großvaterknoten haben.
Methoden zur Ausführung der Rotation.
/*
* Interne Routine, die eine einfache oder doppelte Rotation veranlasst.
* Da das Ergebnis an "parent" geheftet wird, gibt es vier Faelle.
* Aufruf durch reOrientierung.
* "item" ist das Datenelement reOrientierung.
* "parent" ist "parent" von der wurzel des rotierenden Teilbaums.
* Rueckgabe: Wurzel des rotierenden Teilbaums.
*/
private RotSchwarzKnoten rotation(Comparable item,
RotSchwarzKnoten parent)
{
if (item.compareTo(parent.daten) < 0)
return parent.links = item.compareTo( parent.links.daten) < 0 ?
rotationMitLinksNachf(parent.links) : // LL
rotationMitRechtsNachf(parent.links) ; // LR
else
return parent.rechts = item.compareTo(parent.rechts.daten) < 0 ?
rotationMitLinksNachf(parent.rechts) : // RL
rotationMitRechtsNachf(parent.rechts); // RR
}
/*
* Rotation Binaerbaumknoten mit linkem Nachfolger.
250
Algorithmen und Datenstrukturen
*/
static RotSchwarzKnoten rotationMitLinksNachf( RotSchwarzKnoten k2 )
{
RotSchwarzKnoten k1 = k2.links;
k2.links = k1.rechts;
k1.rechts = k2;
return k1;
}
/*
* Rotate Binaerbaumknoten mit rechtem Nachfolger.
*/
static RotSchwarzKnoten rotationMitRechtsNachf(RotSchwarzKnoten k1)
{
RotSchwarzKnoten k2 = k1.rechts;
k1.rechts = k2.links;
k2.links = k1;
return k2;
}
Implementierung in Java95
Sie wird dadurch erschwert, daß einige Teilbäume (z.B. der rechte Teilbaum des
Knoten 10 im vorliegenden Bsp.) leer sein können, und die Wurzel des Baums (, da
ohne Vorgänger, ) einer speziellen Behandlung bedarf. Aus diesem Grund werden
hier „Sentinel“-Knoten verwendet:
- eine für die Wurzel. Dieser Knoten speichert den Schlüssel   und einen rechten Link zu dem
realen Knoten
- einen Nullknoten (nullNode), der eine Null-Referenz anzeigt.
Der Inorder-Durchlauf nimmt aus diesem Grund folgende Gestalt an:
/*
Ausgabe der im Baum gespeicherten Datenelemente in sortierter
Reihenfolge.
*/
public void printTree( )
{
if( isEmpty() )
System.out.println("Leerer Baum");
else
printTree( header.rechts );
}
/*
* Interne Methode fuer die Ausgabe des Baum in sortierter Reihenfolge.
* b ist der Wurzelknoten.
*/
private void printTree(RotSchwarzKnoten b)
{
if (b != nullNode )
{
printTree(b.links);
System.out.println(b.daten );
printTree(b.rechts );
}
}
Die Klasse RotSchwarzknoten
// BaumKnoten fuer RotSchwarzBaeume
95
vgl. pr43220
251
Algorithmen und Datenstrukturen
class RotSchwarzKnoten
{
// Instanzvariable
public Comparable daten;
// Dateninformation der Knoten
protected RotSchwarzKnoten links; // linkes Kind
protected RotSchwarzKnoten rechts; // rechtes Kind
protected int farbe;
// Farbe
// Konstruktoren
RotSchwarzKnoten(Comparable datenElement)
{
this(datenElement, null, null );
}
RotSchwarzKnoten(Comparable datenElement,
RotSchwarzKnoten l, RotSchwarzKnoten r)
{
daten = datenElement;
links
= l;
rechts
= r;
farbe
= RotSchwarzBaum.BLACK;
}
}
Das Gerüst der Klasse RotSchwarzBaum und Initialisierungsroutinen
//
//
//
//
//
//
//
//
//
//
//
//
//
//
Die Klasse RotSchwarzBaum
Konstruktion: mit einem "negative infinity sentinel"
******************PUBLIC OPERATIONEN*********************
void insert(x)
--> Insert x
void remove(x)
--> Entferne x (nicht implementiert)
Comparable find(x)
--> Ruecggabe des Datenelements, das x enthaelt
Comparable findMin() --> Rueckgabe des kleinsten Datenelements
Comparable findMax() --> Rueckgabe des groessten Datenelements
boolean isEmpty()
--> Rueckgabe true, falls leer; anderenfalls false
void makeEmpty()
--> Entferne alles
void printTree()
--> Ausgabe in aufsteigend sortierter Folge
void ausgRotSchwarzBaum() --> Ausgabe des Baums um 90 Grad versetzt
/*
* Implementierung eines RotSchwarzBaum.
* Vergleiche basieren auf der Methode compareTo.
*/
public class RotSchwarzBaum
{
private RotSchwarzKnoten header;
private static RotSchwarzKnoten nullNode;
static
// Static Initialisierer for nullNode
{
nullNode = new RotSchwarzKnoten(null);
nullNode.links = nullNode.rechts = nullNode;
}
static final int BLACK = 1;
// Black must be 1
static final int RED
= 0;
…..
/*
* Baumkonstruktion.
* negInf ist ein Wert, der kleiner oder gleich zu allen anderen Werten
ist.
*/
public RotSchwarzBaum(Comparable negInf)
{
header = new RotSchwarzKnoten(negInf);
header.links = header.rechts = nullNode;
}
252
Algorithmen und Datenstrukturen
…..
}
Die Methode „insert“
// Fuer "insert routine" und zugehoerige unterstuetzende Routinen
private static RotSchwarzKnoten current;
private static RotSchwarzKnoten parent;
private static RotSchwarzKnoten grand;
private static RotSchwarzKnoten great;
/*
* Einfügen in den Baum. Duplikate werden ueberlesen.
* "item" ist das einzufuegende Datenelement.
*/
public void insert(Comparable item)
{
current = parent = grand = header;
nullNode.daten = item;
while( current.daten.compareTo( item ) != 0 )
{
great = grand; grand = parent; parent = current;
current = item.compareTo(current.daten ) < 0 ?
current.links : current.rechts;
// Pruefe, ob zwei rote Kinder; falls es so ist, fixiere es
if( current.links.farbe == RED && current.rechts.farbe == RED )
reOrientierung( item );
}
// Fehlanzeige fuer Einfuegen, falls schon da
if( current != nullNode )
return;
current = new RotSchwarzKnoten( item, nullNode, nullNode );
// Anhaengen an die Eltern
if( item.compareTo( parent.daten ) < 0 )
parent.links = current;
else
parent.rechts = current;
reOrientierung( item );
}
/*
* Interne Routine, die waehrend eines Einfuegevorgangs aufgerufen wird
* Falls ein Knoten zwei rote Kinder hat, fuehre Tausch der Farben aus
* und rotiere.
* item enthaelt das einzufuegende Datenelement.
*/
private void reOrientierung(Comparable item)
{
// Tausch der Farben
current.farbe = RED;
current.links.farbe = BLACK;
current.rechts.farbe = BLACK;
if (parent.farbe == RED)
// Rotation ist noetig
{
grand.farbe = RED;
if ( (item.compareTo( grand.daten) < 0 ) !=
(item.compareTo( parent.daten) < 0 ) )
parent = rotation(item, grand); // Start Doppelrotation
current = rotation(item, great);
current.farbe = BLACK;
}
header.rechts.farbe = BLACK; // Mache die Wurzel schwarz
}
Bsp.: Java-Applet96 zur Darstellung eines Rot-Schwarz-Baums
96
vgl. pr43222
253
Algorithmen und Datenstrukturen
Abb.:
254
Algorithmen und Datenstrukturen
4.3.5 AA-Bäume
Die Implementierung eines Rot-Schwarz-Baums ist sehr trickreich (insbesondere das
Löschen von Baumknoten). Bäume, die diese trickreiche Programmierung
einschränken, sind der binäre B-Baum und der AA-Baum.
Definitionen
Ein BB-Baum ist ein Rot-Schwarz-Baum mit einer besonderen Bedingung: Ein
Knoten hat, falls es sich ergibt, eine „roten“ Nachfolger“.
Zur Vereinfachung der Implementierung kann man noch einige Regeln hinzufügen
und darüber einen AA-Baum definieren:
1. Nur ein Nachfolgeknoten kann rot sein. Das bedeutet: Falls ein interner Knoten nur einen Nachfolger
hat, dann muß er rot sein. Ein schwarzer Nachfolgeknoten verletzt die 4. Bedingung von Rot-SchwarzBäumen. Damit kann ein innerer Knoten immer durch den kleinsten Knoten seines rechten Teilbaums
ersetzt werden.
2. Anstatt ein Bit zur Kennzeichnung der Färbung wird die Stufe zum jeweiligen Knoten bestimmt und
gespeichert. Die Stufe eines Knoten ist
- Eins, falls der Knoten ein Blatt ist.
- Die Stufe eines Vorgängerknoten, falls der Knoten rot gefärbt ist.
- Die Stufe ist um 1 niedriger als die seines Vorgängers, falls der Knoten schwarz ist.
Eine horizontale Verkettung zeigt die Verbindung eines Knoten und eines
Nachfolgeknotens mit gleicher Stufenzahl an. Der Aufbau verlangt, daß horizontale
Verkettungen rechts liegen und daß es keine zwei aufeinander folgende horizontale
Verkettungen gibt.
Darstellung
In einen zunächst leeren AA-Baum sollen Schlüssel in folgender Reihenfolge
eingefügt werden: 10, 85 15, 70, 20, 60, 30, 50, 65, 80, 90, 40, 5, 55, 35.
10
10
85
10
85
15
10
15
85
„skew“
15
„slip“
10
85
255
Algorithmen und Datenstrukturen
70
15
10
70
20
85
15
10
70
20
85
60
15
10
70
20
60
85
30
15
10
70
20
60
85
30
15
10
30
20
15
20
256
70
60
85
30
70
60
85
Algorithmen und Datenstrukturen
30
15
10
70
20
60
85
50
30
15
10
70
20
50
60
85
65
30
15
10
60
20
70
50
65
85
80
30
15
10
60
20
70
50
65
80
90
30
70
15
10
60
20
257
50
85
65
80
90
85
Algorithmen und Datenstrukturen
40
30
70
15
60
10
20
40
85
50
65
80
90
5
30
70
15
5
60
10
20
40
85
50
65
80
90
55
30
70
15
5
10
50
20
40
60
55
85
65
80
90
35
30
15
5
10
70
20
50
35
40
Abb.:
258
60
55
85
65
-
80
90
Algorithmen und Datenstrukturen
Implementierung
Der Knoten des AA-Baums:
// Baumknoten fuer AA-Baeume
class AAKnoten
{
public Comparable daten; // Datenelement im Knoten
protected AAKnoten links; // linkes Kind
protected AAKnoten rechts; // rechtes Kind
protected int
level; // Level
// Konstruktoren
AAKnoten(Comparable datenElement)
{
this(datenElement, null, null);
}
AAKnoten(Comparable datenElement, AAKnoten l, AAKnoten r)
{
daten = datenElement;
links = l;
rechts = r;
level
= 1;
}
}
Operationen an AA-Bäumen:
Ein „Sentinel“ repräsentiert „null“.
Suchen. Es erfolgt nach die in binären Suchbäumen üblichen Weise.
Einfügen von Knoten. Es erfolgt auf der untersten Stufe (bottom level)
/*
* Interne Methode zum Einfuegen in einen Teilbaum.
* "x" enthaelt das einzufuegende Element.
* "b" ist die wurzel des baums.
* Rueckgabe: die neue Wurzel.
*/
private AAKnoten insert(Comparable x, AAKnoten b)
{
if (b == nullNode)
b = new AAKnoten(x, nullNode, nullNode);
else if (x.compareTo(b.daten) < 0 )
b.links = insert(x, b.links);
else if (x.compareTo(b.daten) > 0 )
b.rechts = insert(x, b.rechts);
else
return b;
b = skew(b);
b = split(b);
return b;
}
/*
* Skew Primitive fuer AA-Baeume.
* "b" ist die Wurzel.
* Rueckgabe: die neue wurzel nach der Rotation.
*/
private AAKnoten skew(AAKnoten b)
{
if (b.links.level == b.level )
259
Algorithmen und Datenstrukturen
b = rotationMitLinksNachf(b);
return b;
}
/*
* Split fuer AABaeume.
* "b" ist die Wurzel.
* Rueckgabe: die neue wurzel nach der Rotation.
*/
private AAKnoten split(AAKnoten b)
{
if (b.rechts.rechts.level == b.level )
{
b = rotationMitRechtsNachf(b);
b.level++;
}
return b;
}
Bsp.:
30
70
15
5
50
10
20
35
40
60
55
85
65
80
90
1. Einfügen des Schlüssels 2
Erzeugt wird ein linke horizontale Verknüpfung
2. Einfügen des Schlüssels 45
Erzeugt werden zwei rechte horizontale Verbindungen
30
70
15
2
5
50
10
20
35
40
45
60
55
In beiden Fällen können einfache Rotationen ausgleichen:
260
85
65
80
90
Algorithmen und Datenstrukturen
- Linke horizontale Verknüpfungen werden durch ein einfache rechte Rotation
behoben („skew“)
X
P
X
P
C
A
C
B
A
B
Abb.: Rechtsrotation
z.B.:
30
5
70
15
2
10
50
20
35
40
60
45
55
85
65
80
90
Abb.:
- Aufeinanderfolgende rechte horizontale Verknüpfungen werden durch einfach linke Rotation behoben (split)
X
R
G
R
C
X
A
G
B
C
A
B
Abb.: Linksrotation
z.B. „Einfügen des Schlüssels 45“
30
70
15
5
10
50
20
35
40
Abb.:
261
45
60
55
85
65
80
90
Algorithmen und Datenstrukturen
„split“ am Knoten mit dem Schlüssel 35
30
15
5
70
40
10
20
35
50
60
45
55
85
65
80
90
„skew“ am Knoten mit dem Schlüssel 50
30
15
5
70
40
10
20
50
35
60
45
55
85
65
80
90
„split“ am Knoten mit dem Schlüssel 40
30
70
15
5
50
10
20
85
40
35
60
45
55
80
90
65
Endgültige Baumgestalt nach „skew“ am Knoten 70 und „split“ am Knoten 30
50
30
70
15
5
10
40
20
35
60
45
55
262
85
65
80
90
Algorithmen und Datenstrukturen
Löschen von Knoten.
/*
* Interne Methode fuer das Entfernen aus einem Teilbaum.
* Parameter x ist das zu entfernende Merkmal.
* Parameter b ist die Wurzel des Baums.
* Rueckgabe: die neue Wurzel.
*/
private AAKnoten remove(Comparable x, AAKnoten b)
{
if( b != nullNode )
{
// Schritt 1: Suche bis zum Grund des Baums, setze lastNode und
// deletedNode
lastNode = b;
if( x.compareTo( b.daten ) < 0 )
b.links = remove( x, b.links );
else
{
deletedNode = b;
b.rechts = remove( x, b.rechts );
}
// Schritt 2: Falls am Grund des Baums und
//
x ist gegenwaertig, wird es entfernt
if( b == lastNode )
{
if( deletedNode == nullNode || x.compareTo( deletedNode.daten ) != 0 )
return b;
// Merkmal nicht gefunden; es geschieht nichts
deletedNode.daten = b.daten;
b = b.rechts;
}
// Schritt 3: Anderenfalls, Boden wurde nicht erreicht; Rebalancieren
else
if( b.links.level < b.level - 1 || b.rechts.level < b.level - 1 )
{
if( b.rechts.level > --b.level )
b.rechts.level = b.level;
b = skew( b );
b.rechts = skew( b.rechts );
b.rechts.rechts = skew( b.rechts.rechts );
b = split( b );
b.rechts = split( b.rechts );
}
}
return b;
}
263
Algorithmen und Datenstrukturen
4.4 Bayer-Bäume
4.4.1 Grundlagen und Definitionen
4.4.1.1 Ausgeglichene T-äre Suchbäume (Bayer-Bäume)
Bayer-Bäume sind für die Verwaltung der Schlüssel zu Datensätzen in
umfangreichen Dateien vorgesehen. Der binäre Baum ist für die Verwaltung solcher
Schlüssel nicht geeignet, da er nur jeweils einen Knoten mit einem einzigen
Datensatz adressiert. Die Daten (Datensätze) stehen blockweise zusammengefaßt
auf Massenspeichern, der Binärbaum müßte Knoten für Knoten auf einen solchen
Block abgebildet werden. Jeder Zugriff auf den Knoten des Baums würde ein Zugriff
auf den Massenspeicher bewirken. Da ein Plattenzugriff relativ zeitaufwendig ist,
hätte man die Vorteile der Suchbäume wieder verloren. In einen Knoten ist daher
nicht nur ein Datum aufzunehmen, sondern maximal (T - 1) Daten. Ein solcher
Knoten hat T Nachfolger. Die Eigenschaften der knotenorientierten "T-ären"
Intervallbäume sind :
- Jeder Knoten enthält max. (T - 1) Daten
- Die Daten in einem Knoten sind aufsteigend sortiert
- Ein Knoten enthält maximal T Teilbäume
- Die Daten (Schlüssel) der linken Teilbäume sind kleiner als das Datum der Wurzel.
- Die Daten der rechten Teilbäume sind größer als das Datum der Wurzel.
- Alle Teilbäume sind T-äre Suchbäume.
Durch Zusammenfassen mehrerer Knoten kommt man so vom binären zum "T-ären"
Suchbaum, z.B.:
8
4
12
2
1
3
5
2
3
11
9
7
4
1
14
10
6
5
6
8
Abb.:
264
15
12
9
7
13
10
11
13
14
15
Algorithmen und Datenstrukturen
T-äre Bäume haben aber noch einen schwerwiegenden Nachteil. Sie können leicht
zu entarteten Bäumen degenerieren.
Bsp.: Ein entarteter 5-ärer Baum enthält durch Eingabe (Einfügen) der Elemente 1
bis 16 in aufsteigender Folge folgende Gestalt:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Abb.:
Durch Einfügen und Löschen können sich beliebig unsymetrische Strukturen
herausbilden. Algorithmen zum Ausgleichen, vegleichbar mit den AVL-Rotationen
sind jedoch nicht bekannt.
Zunehmend an Bedeutung gewinnt ein höhengleicher Baum (B-Baum), der von R.
Bayer eingeführt wurde. Höhengleichheit kann erreicht werden, wenn folgende
Eigenschaften eingehalten werden:
- Ein Knoten enthält höchstens 2  M Schlüssel ( 2  M  1 -ärer Baum). Jeder Bayer-Baum (B-Baum)
besitzt eine Klasse, im vorliegenden Fall die Klasse M. M heißt Ordnung des Baums97.
- Jeder Knoten (Ausname: Wurzel) enthält mindestens M Schlüssel, höchstens 2  M Schlüssel.
- Jeder Nichtblattknoten hat daher zwischen ( M  1 ) und( 2  M  1 ) Nachfolger.
- Alle Blattknoten liegen auf der gleichen Stufe.
- B-Bäume sind von (a,b)-Bäumen abgeleitet.
97
In einigen Büchern wird als Ordnung des Baums der Verzweigungsgrad bezeichnet, hier also 2M+1
265
Algorithmen und Datenstrukturen
4.4.1.2 (a,b)-Bäume
Ein (a,b)-Baum ist ein (externer) Suchbaum, für den gilt:
- Alle Blätter haben die gleiche Tiefe
- Schlüssel sind nur in den Blättern gespeichert98.
- Für alle Knoten k (außer Wurzeln und Blättern) gilt a  Anzahl _ der _ Kinder (k )  b
- b  2  a 1
- Für alle inneren Knoten gilt: Hat
k l Kinder, so sind in k l-1 Werte k1, ..., ki-1 gespeichert und es gilt:
k i 1  key( w)  k i für alle Knoten w im i-ten Unterraum von k .
n
- Falls B ein (a.b)-Baum mit n Blättern ist, dann gilt log b ( n)  Höhe ( B )  1  log a ( ) . Der rechte
2
Teile der Ungleichung resultiert daraus, daß bei Bäumen mit Tiefe größer als 1 die Wurzel
wenigstens zwei Kinder hat, eines der Kinder hat maximal n/2 Blätter und minimalen
Verzweigungsgrad a.
- B-Bäume sind Spezialfälle von (a.b)-Bäumen mit b  2  a  1
Bsp.:
21, 39
7,15
32
1,4
1
9
4
7
9
15
17
17
21
52, 62
24
24
32
35
35
39
43,47
43
47
52
53,56
53
Abb.: (2,3) - Baum
2
1
1
3.4.5
2
3
4
5
Abb.: (2,4) - Baum
98
In dieser Sicht unterscheiden sich (a,b)-Bäume von den hier angesprochenen B-Bäumen.
266
56
62
67
67
71
Algorithmen und Datenstrukturen
4.4.2 Darstellung von Bayer-Bäumen
+-----------------------------------+
|
|
| Z0S1Z1S2Z2S3 ........... ZN-1SNZN |
|
|
+-----------------------------------+
Zl ... Zeiger
Sl ... Schluessel
Alle Schlüssel in einem Teilbaum, auf den durch Z l-1 verwiesen wird, sind kleiner als
Sl. Alle Schlüssel in einem Unterbaum, auf den durch Z l verwiesen wird, sind größer
als Sl.
In einem B-Baum der Höhe H befinden sich daher zwischen N min  2  ( M  1) H 1  1
und N max  2  ( M  1) H  1 Schlüssel. Neue Schlüssel werden stets in den Blättern
zugefügt.
Aufgabe: Gegeben ist die folgende Schlüsselfolge: „1, 7, 6, 2, 11, 4, 8, 13, 10, 5, 19,
9, 18, 24, 3, 12, 14, 20, 21, 17“.
Bestimme die zugehörigen Strukturen eines 5-ären Baumes.
1) 1, 7, 6, 2
1
6
2
7
2) 11
6
1
2
7
11
7
8
3) 4, 8, 13
6
1
2
4
11
13
11
13
4) 10
6
1
2
4
10
7
8
5) 5, 19, 9, 18
267
Algorithmen und Datenstrukturen
6
1
2
4
5
10
7
8
9
11
13
18
19
11
13
19
24
13
14
19
20
6) 24
6
1
2
4
5
10
7
8
18
9
7) 3, 12, 14, 20, 21
1
2
4
5
3
6
7
8
10
9
18
11
12
21
24
Abb.:
a) C++-Darstellung
Zeiger ZI und Schlüssel SI eines jeden Knoten sind folgendermaßen angeordnet:
+----------------------------- ------+
|
|
| Z0S0Z1S1Z2S2 ........... ZN-1SN-1ZN |
|
|
+-------------------------------------+
Das führt zu der folgenden Beschreibung des B-Baums der Ordnung 2 (mit 5
Kettengliedern je Knoten).
//
//
B-Baum mit bis zu MAERER Verkettungen
(mit Knoten die bis zu MAERER Verkettungen enthalten)
#include <iostream.h>
#include <iomanip.h>
#include <ctype.h>
#define MAERER 5
// Anzahl Verkettungen im B-Baum:
// MAERER Verkettungsfelder je Knoten
typedef int dtype;
enum status {unvollstaendigesEinfuegen, erfolgreich, doppelterSchluessel,
Unterlauf, nichtGefunden};
struct knoten {
int n;
// Anzahl der Elemente, die in einem Knoten
// gespeichert sind (n < MAERER)
dtype s[MAERER-1]; // Datenelemente (aktuell sind n)
knoten *z[MAERER]; // Zeiger auf andere Knoten (aktuell sind n+1)
};
268
Algorithmen und Datenstrukturen
// Logische Ordnung:
//
z[0], s[0], z[1], s[1], ..., z[n-1], s[n-1], z[n]
class Bbaum
{
private:
knoten *wurzel;
status einf(knoten *w, dtype x, dtype &y, knoten* &q);
void ausg(const knoten* w, int nLeer)const;
int knotenSuche(dtype x, const dtype *a, int n)const;
status loe(knoten *w, dtype x);
public:
Bbaum(): wurzel(NULL){}
void einfuegen(dtype x);
void gibAus()const{cout << "Dateninhalt:\n"; ausg(wurzel, 0);}
void loeschen(dtype x);
void zeigeSuche(dtype x)const;
};
b) Java-Darstellung
Die Klasse CBNode zeigt die Implementierung eines Bayer-Baumknotens99. Der
Konstruktor dieser Klasse leistet die Arbeit und bekommt dazu einen
Übergabeparameter (M) geliefert, der die Ordnung des (M-ären) Bayer-Baums
beschreibt.
import java.util.*;
/*
* The CBNode class (Repraesentation eines Bayer-Baum-Knoten)
*/
public class CBNode
{
// Instanzvariable
protected Vector key, nodeptr, initkey, initvec;
int count;
// Konstruktor
/*
* constructs a single Bayer Tree node with M references to subnodes.
*/
CBNode(int M)
{
// System.out.println("CBNode(): constructor invoked!");
nodeptr = new Vector();
initvec = new Vector(); initvec = null;
key = new Vector();
initkey = new Vector(); initkey = null;
for(int i = 0; i <= M; i++)
{
nodeptr.addElement((Object)initvec);
/* System.out.println(i +
"ter Nodepointer erzeugt. Wert: " + nodeptr.elementAt(i)); */
}
for(int j = 0; j <= M - 1; j++)
{
key.addElement((Object)initkey);
// System.out.println(j + "ter Key erzeugt. Wert: " + key.elementAt(j));
}
count = 0;
// System.out.println("count Wert: " + count);
99
Vgl. pr44200, CBNode.java
269
Algorithmen und Datenstrukturen
}
}
4.4.3 Suchen eines Schlüssels
Gegeben ist der folgende Ausschnitt eines B-Baums mit N Schlüsseln:
+----------------------------- ------+
|
|
| Z0S0Z1S1Z2S2 ........... ZN-1SN-1ZN |
|
|
+-------------------------------------+
S0<S1<S2<.....<SN-1
Handelt es sich beim Knoten um ein Blatt, dann ist der Wert eines jeden Zeigers ZI
im Knoten NULL. Falls der Knoten kein Blatt ist, verweisen einige der (N+1) Zeiger
auf andere Knoten (Kinder des aktuellen Knoten).
I > 0: Alle Schlüssel im betreffenden Kind-Knoten, auf den ZI zeigt, sind größer als
SI-1
I < N: Alle Schlüssel im betreffenden Kind, auf das ZI zeigt, sind kleiner als SN.
Hat einer der angegebenen Zeiger den Wert „null“, dann existiert in dem
vorliegenden Baum kein Teilbaum, der diesen Schlüssel enthält (d.h. die Suche ist
beendet).
a) C++-Implementierung
int Bbaum::knotenSuche(dtype x, const dtype *a, int n)const
{ int i=0;
while (i < n && x > a[i]) i++;
return i;
}
void Bbaum::zeigeSuche(dtype x)const
{ cout << "Suchpfad:\n";
int i, j, n;
knoten *w = wurzel;
while (w)
{ n = w->n;
for (j = 0; j < w->n; j++) cout <<
cout << endl;
i = knotenSuche(x, w->s, n);
if (i < n && x == w->s[i])
{ cout << "Schluessel " << x << "
<< " vom zuletzt angegebenen
return;
}
w = w->z[i];
}
cout << "Schluessel " << x << " wurde
}
" " << w->s[j];
wurde in Position " << i
Knoten gefunden.\n";
nicht gefunden.\n";
b) Java-Implementierung
/*
*Rueckgabe ist true, falls der Schlüssel gefunden wurde;
* anderenfalls false.
*/
270
Algorithmen und Datenstrukturen
public boolean searchkey(int key)
{
boolean found = false;
int i = 0, n;
CBNode node = root;
while(node != null)
{
i = 0;
n = node.count;
// search key in actual node
while(i < n && key > ((Integer)node.key.elementAt(i)).intValue())
{
i++;
}
// end while
if(i < n && key == ((Integer)node.key.elementAt(i)).intValue())
{
found = true;
}
if(node.nodeptr.elementAt(i) != null)
{
mpCVisualizeTree.MoveLevelDown(i);
}
node = (CBNode) node.nodeptr.elementAt(i);
}
// end while (node != null)
return found;
}
// end searchkey Methode
4.4.4 Einfügen
Bsp.: Der Einfügevorgang in einem Bayer-Baum der Klasse 2
1) Aufnahme der Schlüssel 1, 2, 3, 4 in den Wurzelknoten
1
3
2
4
2) Zusätzlich wird der Schlüssel mit dem Wert 5 eingefügt
1
3
2
4
5
Normalerweise würde jetzt rechts von "4" ein neuer Knotem erzeugt. Das würde aber zu einem
Knotenüberlauf führen. Nach dieser Erweiterung enthält der Knoten eine ungerade Zahl von
Elementen ( 2  M  1 ). Dieser große Knoten kann in 2 Söhne zerlegt werden, nur das mittlere Element
verbleibt im Vaterknoten. Die neuen Knoten genügen wieder den B-Baum-Eigenschaften und könnem
weitere Daten aufnehmen.
3
1
2
4
5
Abb.:
Beschreibung des Algorithmus für das Einfügen
271
Algorithmen und Datenstrukturen
Ein neues Element wird grundsätzlich in einen Blattknoten eingefügt. Ist der Knoten
mit 2  M Schlüsseln voll, so läuft bei der Aufnahme eines weiteren Schlüssels der
Knoten über.
+---------------------------------------------+
|
|
| ...........
SX-1ZX-1SXZX
.....
|
|
|
+---------------------------------------------+
+----------------------------------------------+
|
|
| Z0S1Z1 .... ZM-1SMZMSM+1 ....... Z2MS2M+1
|
|
Überlauf
|
+----------------------------------------------+
Abb.:
Der Knoten wird geteilt:
Die vorderen Schlüssel verbleiben im alten Knoten, der Schlüssel mit der Nummer
M+1 gelangt als Trennschlüssel in den Vorgängerknoten. Die M Schlüssel mit den
Nummern M+2 bis 2  M  1 kommen in den neuen Knoten.
+-----------------------------+
| ....SX-1ZX-1SM+1ZYSXZX .... |
+-----------------------------+
+------------------+
|Z0S1 .... ZM-1SMZM|
+------------------+
+-----------------------------+
|ZM+1SM+2ZM+2 ..... S2M+1Z2M+1|
+-----------------------------+
Abb.:
Die geteilten Knoten enthalten genau M Elemente. Das Einfügen eines Elements in
der vorangehenden Seite kann diese ebenfalls zum Überlaufen bringen und somit
die Aufteilung fortsetzen. Der B-Baum wächst demnach von den Blättern bis zur
Wurzel.
a) Methoden zum Einfügen der Klasse Bbaum (in C++)
Zwei Funktionen (Methoden der Klasse Bbaum) „einfuegen“ und „einf“ teilen sich
die Arbeit. Der Aufruf dieser Funktionen erfolgt bspw. über b.einfuegen(x)100;
bzw. in der Methode einfuegen() über status code = einf(wurzel, x,
xNeu, zNeu);. xNeu, zNeu sind lokale Variable in einfuegen(). Die private
Methode einf() fiefert einen „code“ zurück:
unvollstaendigesEinfuegen , falls (insgesamt gesehen) der Einfügevorgang noch nicht
vollständig abgeschlossen wurde.
erfolgreich , falls das Einfügen des Schlüssels x erfolgreich war
doppelterSchluessel , falls x bereits im Bayer-Baum ist.
In den „code“-Fällen „erfolgreich“ bzw. „doppelterSchluessel“ haben xNeu
und zNeu keine Bedeutung. Im Fall „unvollstaendigesEinfuegen“ sind noch
weitere Vorkehrungen zu treffen:
100
„b“ ist eine Instanz von Bbaum
272
Algorithmen und Datenstrukturen
- Falls überhaupt noch kein Bayer-Baum vorliegt (wurzel == NULL) ist ein neuer Knoten zu erzeugen
und der Wurzel zuzuordnen. In diesem Fall enthält der Wurzelknoten dann einen Schlüssel.
- In der Regel tritt unvollständiges Einfügen auf, wenn der Knoten, in den der Schlüssel x eingefügt
werden soll, keinen Platz mehr besitzt. Ein Teil der Schlüssel kann im alten Knoten verbleiben, der
andere Teil muß in einen neuen Knoten untergebracht werden.
void Bbaum::einfuegen(dtype x)
{ knoten *zNeu;
dtype xNeu;
status code = einf(wurzel, x, xNeu, zNeu);
if (code == doppelterSchluessel)
cout << "Doppelte Schluessel werden ignoriert.\n";
if (code == unvollstaendigesEinfuegen)
{ knoten *wurzel0 = wurzel;
wurzel = new knoten;
wurzel->n = 1; wurzel->s[0] = xNeu;
wurzel->z[0] = wurzel0; wurzel->z[1] = zNeu;
}
}
status Bbaum::einf(knoten *w, dtype x, dtype &y, knoten* &q)
{ // Fuege x in den aktuellen Knoten, adressiert durch *this ein.
// Falls nicht voll erfolgreich, sind noch Ganzzahl y und Zeiger q
// einzufuegen.
// Rueckgabewert:
//
erfolgreich, doppelterSchluessel oder unvollstaendigesEinfuegen.
knoten *zNeu, *zFinal;
int i, j, n;
dtype xNeu, sFinal;
status code;
if (w == NULL){q = NULL; y = x; return unvollstaendigesEinfuegen;}
n = w->n;
i = knotenSuche(x, w->s, n);
if (i < n && x == w->s[i]) return doppelterSchluessel;
code = einf(w->z[i], x, xNeu, zNeu);
if (code != unvollstaendigesEinfuegen) return code;
// Einfuegen im untergeordneten Baum war nicht voll erfolgreich;
// Versuch zum Einfuegen in xNeu und zNeu vom aktuellem Knoten:
if (n < MAERER - 1)
{ i = knotenSuche(xNeu, w->s, n);
for (j=n; j>i; j--)
{ w->s[j] = w->s[j-1]; w->z[j+1] = w->z[j];
}
w->s[i] = xNeu; w->z[i+1] = zNeu; ++w->n;
return erfolgreich;
}
// Der aktuelle Knoten ist voll (n == MAERER - 1) und muss gesplittet
// werden.
// Reiche das Element s[h] in der Mitte der betrachteten Folge zurueck
// ueber Parameter y, damit es aufwaerts im Baum plaziert werden kann.
// Reiche auch einen Zeiger zum neu erzeugten Knoten zurueck (als
// Verweis) ueber den Parameter q:
if (i == MAERER - 1) {sFinal = xNeu; zFinal = zNeu;} else
{ sFinal = w->s[MAERER-2]; zFinal = w->z[MAERER-1];
for (j=MAERER-2; j>i; j--)
{ w->s[j] = w->s[j-1]; w->z[j+1] = w->z[j];
}
w->s[i] = xNeu; w->z[i+1] = zNeu;
}
int h = (MAERER - 1)/2;
y = w->s[h];
// y und q werden zur naechst hoeheren Stufe
q = new knoten; // im Baum weitergereicht
// Die Werte z[0],s[0],z[1],...,s[h-1],z[h] gehoeren zum linken Teil von
// s[h] and werden gehalten in *w:
w->n = h;
// z[h+1],s[h+1],z[h+2],...,s[MAERER-2],z[MAERER-1],sFinal,zFinal
273
Algorithmen und Datenstrukturen
// gehoeren zu dem rechten Teil von s[h] und werden nach *q gebracht:
q->n = MAERER - 1 - h;
for (j=0; j < q->n; j++)
{ q->z[j] = w->z[j + h + 1];
q->s[j] = (j < q->n - 1 ? w->s[j + h + 1] : sFinal);
}
q->z[q->n] = zFinal;
return unvollstaendigesEinfuegen;
}
b) Methoden zum Einfügen in Java
/*
*Einfügen eines neuen Schlüssels. Rückgabe ist –1, falls
* es misslingt, anderenfalls 0
* Parameter: value
einzufuegender Wert
*/
public int Insert(Integer value)
{
if(root == null)
{
root = new CBNode(MAERER);
root.key.setElementAt((Object)value, 0);
root.count = 1;
} // end if
else
{
if(searchkey(((Integer)value).intValue()) == true)
{
//System.out.println("double key found and will be ignored!");
return -1;
} // end if
CBNode result;
result = insrekurs(root, value);
if(result != null)
{
CBNode node = new CBNode(MAERER);
node.key.setElementAt(newValue, 0);
node.nodeptr.setElementAt(root, 0);
node.nodeptr.setElementAt(result, 1);
node.count = 1;
root = node;
}
// end if(result)
}
// end else
mpCVisualizeTree.DeleteRootKnot();
mpCExtendedCanvas.repaint();
rootflag = 0;
this.drawTree(root);
mpCExtendedCanvas.repaint();
return 0;
}
// end Insert() Methode
Die Methode „Insert“ ruft „insrekurs()“ auf. Diese rekursive Methode leistet die eigentliche Arbeit
/*
* der neue Wert wird rekursiv in den Baum eingebracht
*/
protected CBNode insrekurs(CBNode tempnode, Integer insValue)
{
CBNode result;
result = null;
newValue = insValue;
if(tempnode.nodeptr.elementAt(0) != null) // kein Blatt -> Rekursion
{
int pos = 0;
while(pos < tempnode.count && newValue.intValue() >
274
Algorithmen und Datenstrukturen
((Integer)tempnode.key.elementAt(pos)).intValue())
{
pos++;
} // end while
result = insrekurs((CBNode)tempnode.nodeptr.elementAt(pos), newValue);
if(result == null)
{
return null; // if result = null: nothing has to be inserted into
// node-> finished!
} // end if(resul == null)
} // end if(tempnode.nodeptr.elementAt(0) != null)
// insert a element
CBNode node = null;
int flag = 0;
int s = tempnode.count;
if(s >= MAERER - 1)
// split the knot
{
tempnode.count = (MAERER - 1) / 2;
node = new CBNode(MAERER);
node.count = (MAERER - 1) / 2;
for(int d = ((MAERER - 1) / 2); d > 0;)
{
if(flag != 0 || ((Integer)tempnode.key.elementAt(s - 1)).intValue() >
newValue.intValue())
{
node.nodeptr.setElementAt(tempnode.nodeptr.elementAt(s), d);
node.key.setElementAt(tempnode.key.elementAt(--s), --d);
} // end if(flag != 0 ...
else
{
node.nodeptr.setElementAt(result, d);
node.key.setElementAt(newValue, --d);
flag = 1;
} // end else
}
// end if(s >= MAERER - 1)
if(flag != 0 || ((Integer)tempnode.key.elementAt(s - 1)).intValue() >
newValue.intValue())
{
node.nodeptr.setElementAt(tempnode.nodeptr.elementAt(s), 0);
} // end if
else
{
node.nodeptr.setElementAt(result, 0);
} // end else
} // end if(s >= MAERER - 1)
else
{
tempnode.count++;
} // end else
// shift
for(; s > 0 && ((Integer)tempnode.key.elementAt(s - 1)).intValue() >
newValue.intValue(); s--)
{
tempnode.nodeptr.setElementAt(tempnode.nodeptr.elementAt(s), s + 1);
tempnode.key.setElementAt(tempnode.key.elementAt(s - 1), s);
} // end for
tempnode.key.setElementAt(newValue, s);
tempnode.nodeptr.setElementAt(result, s + 1);
newValue = (Integer) tempnode.key.elementAt((MAERER - 1) / 2);
return node;
} // end insrekurs() methode
275
Algorithmen und Datenstrukturen
4.4.5 Löschen
Grundsätzlich ist zu unterscheiden:
1. Das zu löschende Element ist in einem Blattknoten
2. Das Element ist nicht in einem Blattknoten enthalten.
In diesem Fall ist es durch eines der benachbarten Elemente zu ersetzen. Entlang des rechts
stehenden Zeigers Z ist hier zum Blattknoten hinabzusteigen und das zu löschende Element durch das
äußere linke Element von Z zu ersetzen.
Auf jeden Fall darf die Anzahl der Schlüssel im Knoten nicht kleiner als M werden.
Ausgleichen
Die Unterlauf-Gegebenheit (Anzahl der Schlüssel ist kleiner als M) ist durch
Ausleihen oder "Angliedern" eines Elements von einem der benachbarten Knoten
abzustellen.
Zusammenlegen
Ist kein Element zum Angliedern übrig (, der benachbarte Knoten hat bereits die
minimale Größe erreicht), dann enthalten die beiden Knoten je 2  M  1 Elemente.
Beide Knoten können daher zusammengelegt werden. Das mittlere Element ist dazu
aus den dem Knoten vorausgehenden Knoten zu entnehmen und der NachbarKnoten ist ganz zu entfernen. Das Herausnehmen des mittleren Schlüssels in der
vorausgehenden Seite kann nochmals die Größe unter die erlaubte Grenze fallen
lassen und gegebenenfalls auf der nächsten Stufe eine weitere Aktion hervorrufen.
Bsp.: Gegeben ist ein 5-ärer B-Baum (der Ordnung 2) in folgender Gestalt:
50
30
10
20
35
40
38
60
42
44
46
56
Abb.:
1) Löschen der Schlüssel 44, 80
276
58
65
70
80
90
95
96
Algorithmen und Datenstrukturen
50
30
10
20
35
40
38
42
56
46
58
65
Abb.:
2) Einfügen des Schlüssels 99, Löschen des Schlüssels 70 mit Ausgleichen
50
30 40
10 20
35 38
60
42 46
56 58
95
65 90
96
99
65 90
96
99
65 90
96
99
Abb.:
3) Löschen des Schlüssels 35 mit Zusammenlegen
50
40
10 20 30
38
60
42 46
56 58
95
Abb.:
40
10 20 30
38
90
60
42 46
50
60
56 58
Abb.:
a) Implementierung in C++
Löschen eines Schlüssels im Blattknoten
277
95
70
95
96
Algorithmen und Datenstrukturen
1. Fall: Im Blattknoten befinden sich mehr als die kleinste zulässige Anzahl von Schlüsselelementen.
Der Schlüssel kann einfach entfernt werden, die rechts davon befindlichen Elemente werden
einfach eine Position nach links verschoben.
2. Fall: Das Blatt enthält genau nur noch die kleinste zulässige Anzahl von Schlüsselelementen,
Nachbachknoten auf der Ebene der Blattknoten enthalten mehr als die kleinste zulässige Anzahl von
Schlüsselelementen. Der Schlüssel wird gelöscht, im Blatt liegt dann ein „Unterlauf“ vor. Man versucht
aus den linken oder rechten Nachbarknoten ein Element zu besorgen, z.B.: Es liegt im Anschluß an einen
(rekursiven) Aufruf, der in einem Blattknoten einen Schlüssel entfernt hat, folgende Situation vor:
20
10
12
30
15
40
25
33
34
36
46
48
Abb.:
Die Entnahme geeigneter Schlüssel kann hier aus dem linken bzw. aus dem rechten Nachbarn vom
betroffenen Knoten erfolgen:
20
10
12
33
15
40
25
30
34 36
46
48
Abb.:
Falls vorhanden, soll immer der rechte Nachbarknoten gewählt werden. Im vorliegenden Beispiel ist das nicht
möglich beim Löschen der Schlüsselwerte „46“ bzw. „48“. In solchen Fällen wird dem Linken Knoten ein
Element entnommen.
3. Fall: Das Blatt enthält genau die kleinste mögliche Anzahl an Elementen, Nachbarknoten auf der Ebene
der Blattknoten enthalten auch nur genau die kleinste mögliche Anzahl an Elementen.
In diesem Fall müssen die betroffenen Knoten miteinander verbunden werden, z.B. liegt im Anschluß an einen
Aufruf, der in einem Blattknoten einen Schlüssel entfernt hat, folgende Situation vor:
20
10
12 15
30
40
25
34
36
46
48
Die Verbindung zu einem Knoten mit zulässiger anzahl von Schlüsselelementen kann so vollzogen
werden:
278
Algorithmen und Datenstrukturen
20
10
12
40
15
25
30
34
36
46
48
Abb.:
Löschen eines Schlüssels in einem inneren Knoten
Solches Löschen kann aus einem Löschvorgang in einem Blattknoten resultieren.
Bsp.:
15
3
1 2
4
6
5
20
10 12
18 19
60
22 30
70
80
22
70 80
Abb.:
Das Löschen vom Schlüssel mit dem Wert 1 ergibt:
6
2
3
4
5
10
15
12
20
60
18 19
30
Abb.:
Im übergeordneten Knoten kann es erneut zu einer Unterlauf-Gegebenheit kommen. Es ist wieder ein Verbinden
bzw. Borgen mit / von Nachbarknoten erforderlich, bis man schließlich an der Wurzel angelangt ist. Im
Wurzelknoten kann nur ein Element sein. Wird dieses Element in den Verknüpfungsvorgang der beiden
unmittelbaren Nachfolger einbezogen, dann wird der Wurzelknoten gelöscht, die Höhe des Baums nimmt ab.
Eine Löschoperation kann auch direkt in einem internen Knoten beginnen, z.B. wird im folgenden
Bayer-Baum im Wurzelknoten der Schlüssel mit dem Wert „15“ gelöscht.
279
Algorithmen und Datenstrukturen
15
3
1 2
4
6
5
20
10 12
18 19
60
22 30
70
80
Abb.:
Zuerst wird zum linken Nachfolger gewechselt, anschließend wird der Baum bis zum Blatt nach rechts
durchlaufen. In diesem Blatt wird dann das am weitesten rechts stehenden Datum aufgesucht und mit dem zu
löschenden Element im Ausgangsknoten getauscht.
12
3
1 2
4
6
5
20
10 15
18 19
60
22 30
70
80
Abb.:
Der Schlüssel mit dem Wert „15“ kann jetzt nach einer bereits beschriebenen Methode gelöscht werden.
280
Algorithmen und Datenstrukturen
4.4.6 Auf Platte/ Diskette gespeicherte Datensätze
Der Ausgangspunkt zu B-Bäumen war die Verwaltung der Schlüssel zu Datensätzen
in umfangreichen Dateien. In der Regel will man ja nicht nur einfache Zahlen (d.h.
einzelne Daten), sondern ganze Datensätze speichern. Eine größere Anzahl von
Datensätzen einer solchen Datei ist aber im Arbeitsspeicher (, der ja noch Teile des
Betriebssystems, das Programm etc. enthalten muß,) nicht unterzubringen.
Notwendig ist die Auslagerung von einem beträchtlichen Teil der Datensätze auf
einen externen Speicher. Dort sind die Datensätze in einer Datei gespeichert und in
"Seiten" zusammengefaßt. Eine Seite umfaßt die im Arbeitsspeicher adressierbare
Menge von Datensätzen (Umfang entspricht einem Bayer-Baumknoten). Aus
Vergleichsgründen soll hier die Anzahl der aufgenommenen bzw. aufzunehmenden
Datensätze die Zahl M = 2 nicht überschreiten. Es werden also mindestens 2, im
Höchstfall 4 Datensätze in eine Seite aufgenommen. Allgemein gilt: Je größer M
gewählt wird, umso mehr Arbeitsspeicherplatz wird benötigt, um so größer ist aber
auch die Verarbeitungsleistung des Programms infolge der geringeren Anzahl der
(relativ langsamen) Zugriffsoperationen auf externe Speicher.
Die 1. Seite der Datei (Datenbank) enthält Informationen für die Verwaltung.
Implementierung in C++
Die Verwaltung eines auf einer Datei hinterlegten Bayer-Baums übernimmt die
folgende Klasse BBaum:
#define MAERER 5
// Anzahl Verkettungen im Bayer-Baum Knoten
enum status {unvollstaendigesEinfuegen, erfolgreich, doppelterSchluessel,
Unterlauf, nichtGefunden};
typedef int dtype;
// Knoten eines auf einer Datei hinterlegten Bayer-Baums.
struct knoten {
int n;
// Anzahl der Elemente, die in einem Knoten
// Knoten gespeichert sind (n < MAERER)
dtype s[MAERER-1]; // Datenelemente (aktuell sind n)
long z[MAERER];
// 'Zeiger' auf andere Knoten (aktuell sind n+1)
};
// Logische Ordnung:
//
z[0], s[0], z[1], s[1], ..., z[n-1], s[n-1], z[n]
// Die Klasse zum auf einer Datei hinterlegten Bayer-Baum
class BBaum {
private:
enum {NIL=-1};
long wurzel, freieListe;
knoten wurzelKnoten;
fstream datei;
status einf(long w, dtype x, dtype &y, long &u);
void ausg(long w, int nLeer);
int knotenSuche(dtype x, const dtype *a, int n)const;
status loe(long w, dtype x);
void leseKnoten(long w, knoten &Knoten);
void schreibeKnoten(long w, const knoten &Knoten);
void leseStart();
281
Algorithmen und Datenstrukturen
long holeKnoten();
void freierKnoten(long w);
public:
BBaum(const char *BaumDateiname);
~BBaum();
void einfuegen(dtype x);
void einfuegen(const char *eingabeDateiname);
void gibAus(){cout << "Dateninhalt:\n"; ausg(wurzel, 0);}
void loeschen(dtype x);
void zeigeSuche(dtype x);
};
Konstruktoren
Zur Verwaltung des Bayer-Baums in einer Datei ist besonders wichtig:
- Die Wurzel wurzel (d. h. die Position des Wurzelknotens
- eine Liste mit Informationen über den freien Speicherplatz in der Datei (adressiert über
freieListe).
Zu solchen freien Speicherplätzen kann es beim Löschen von Schlüsselelementen
kommen. Zweckmäßigerweise wird dann dieser freie Speicherbereich nicht
aufgefüllt, sondern in einer Liste freier Speicherbereiche eingekettet. freieListe
zeigt auf das erste Element in dieser Liste. Solange das Programm läuft sind
„wurzel“ und „freieListe“ Datenelemente der Klasse BBaum (in einer Datei
abgelegter Bayer-Baum). Für die Belegung dieser Dateielemente wird am
Dateianfang (1.Seite) Speicherplatz reserviert. Am Ende der Programmausführung
werden die Werte zu „wurzel“ bzw. „freieListe“ in der Datei abgespeichert.
Bsp.: Der folgende Bayer-Baum
20
10
15
60
80
10
wurzel
15
60
80
20
freieListe
Abb.:
Zeiger haben hier ganzzahlige Werte des Typs long (mit -1L als NIL), die für die
Positionen und Bytenummern stehen.
282
Herunterladen