2. Suchverfahren - oth

Werbung
Algorithmen und Datenstrukturen
2. Suchverfahren
2.1 Informationsbeschaffung in Datensammlungen
2.1.1 Suchmethoden
Sammlungen (Kollektionen, Collections) sind geeignete 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
Suchverfahren teilen sich1 in interne (Bezugsobjekt: Hauptspeicher) und externe
Suchverfahren (Bezugsobjekt: externer Datenträger).
1
vgl. Kapitel 3: Sortierverfahren
1
Algorithmen und Datenstrukturen
2.1.2
Datensammlungen
Suchmethoden
(Collections)
zur
Implementierung
von
In aktuell vorliegenden Programmiersprachen bezeichnet man Datenstrukturen zur
Aufnahme und Verarbeitung von Datenmengen als Collections. Collections legen
Daten in geeigneter Form ab, der Zugriff ist nur mit Hilfe vorgegebener Funktionen
erlaubt.
Collections
gibt
es
in
großer
Anzahl
und
diversen
Implementierungsvarianten2.
Collection-Klassen in C++
Collections in Java
Collection-Klassen in Java
In Java existieren seit der Version 1.0 die Collections: Vector, Stack, Hashtable
und BitSet. Das Collection-Framework des JDK 1.2 hat 20 weitere Klassen und
Interfaces bereitgestellt. Im wesentlichen lassen sich daraus drei Grundformen
ableiten:
Eine List ist eine beliebig große Liste von Elementen beliebigen Typs, auf die sowohl wahlfrei als
auch sequentiell zugegriffen werden kann.
Ein Set ist eine Menge von Elementen, auf die mit typischen Mengenoperationen zugegriffen werden
kann.
Eine Map ist eine Abbildung auf Elemente eines anderen Typs, also eine Menge zusammengehöriger
Paare von Objekten.
Interfaces des JDK 1.2
Sie deklarieren die Methoden. Das Basisinterface Collection fasst die
wesentlichen Methoden einer großen Menge unterschiedlicher Kollektionen (List,
Set) zusammen.
2
vgl. 1.3.2
2
Algorithmen und Datenstrukturen
<< interface >>
Collection
public void clear();
public boolean add(Object o);
public boolean addAll(Collection c);
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);
public boolean contains(Object o);
// Rückgabewert ist true, falls das vorgegebene Element gefunden werden konnte
public boolean containsAll(Collection c);
public boolean equals(Object o);
public boolean isEmpty();
public int size();
public boolean retainAll(Collection c);
public Iterator iterator();
publicObject [] toArray();
public Object [] toArray(Object [] a);
public int hashCode();
public String toString()
// Rückgabewert ist die Zeichenketten-Repräsentation der Kollektion.
Abb.: Das Interface Collection
Bei allen Collections, die das Interface Collection implementieren, kann ein
Iterator zum Durchlaufen aller Elemente mit der Methode iterator() beschafft
werden.
<< 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();
// das zuletzt gelöschte Element wird anschließend mit remove() gelöscht
Abb.: Das Interface Iterator
Zusätzlich fordert das JDK 1.2 Spezifikation für jede Collection-Klasse zwei
Konstruktoren:
- Ein parameterloser Konstruktor dient zur Anlage einer leeren Collection.
- Ein Konstruktor mit einem Collection-Argument dient zum Anlegen einer neuen Collection und zum
Füllen mit allen Elementen aus der als Argument übergegebenen Collection.
3
Algorithmen und Datenstrukturen
2.2 Suchverfahren in linearen Listen
C++- Implementierung
Java-Implementierung
Die Datenstruktur „lineare Liste“ wird in Java über das Interface List bereitgestellt.
Auf jedes Element kann mit einem Index zugegriffen weden. Das Interface List ist
direkt aus Collection abgeleitet und erbt somit dessen Methoden.
<< 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. :Das Interface List
4
Algorithmen und Datenstrukturen
<< 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.: Das Interface ListIterator
Das Interface List wird im JDK von verschiedenen Klassen implementiert:
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.
LinkedList
realisiert die doppelt verkettete, lineare Liste
ArrayList
implementiert die Liste als Array von Elementen.
<< 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
<< Konstruktor
public ArrayList();
public ArrayList(Collection collection);
public ArrayList(int anfangsKapazitaet);
<< Methoden >>
5
Vector
Algorithmen und Datenstrukturen
2.2.1 Suchen in sequentiell gespeicherten, linearen Listen
2.2.1.1 Sequentielle, lineare Suche
Verfahrensweise
Ein vorgegebener Schlüssel wird nacheinander mit den Schlüsseln einer Tabelle
verglichen.
Bsp.: Die Schlüssel in der Tabelle sind:
13
19
26
35
47
53
72
81
87
Abb.
Durch die aufeinanderfolgende Pfeile in der Tabelle ist der Suchweg angedeutet.
Aufwand
Der gesuchte Schlüssel kann nach dem 1., 2., ... , im schlimmsten Fall nach N
Schritten gefunden werden. Eine erfolgreiche Suche ist im Mittel nach
Z mit =
1 N
N ⋅ ( N + 1) N + 1
⋅ ∑ i = (1 + 2 + 3 + ... + N ) =
=
N i =1
2⋅ N
2
Schritten abgeschlossen. Dabei wurde vorausgestzt: Jeder Schlüssel wird gleich
häufig gesucht.
2.2.1.2 Suche in geordneten Listen
1. Sprungsuche
Verfahrensweise
Eine sortierte Tabelle der Länge N läßt sich in N/T Tabellenteile der Länge T
unterteilen. Man stellt fest, in welchem Tabellenteil sich der Schüssel befinden muß.
Dies geschieht mit dem jeweils größten Schlüssel eines Tabellenteils. Ist so ein
Tabellenteil ausgesondert, kann innerhalb dieses Teils nach dem sequentiellen
Prinzip gesucht werden.
6
Algorithmen und Datenstrukturen
Bsp.: Die Schlüssel in der Tabelle sind:
13
19
26
35
47
53
72
81
87
Abb.:
Vorgegebener Schlüsselwert: 72.
Die Pfeile in der Tabelle beschreiben, wie der vorgegebene Schlüssel in der Tabelle
ermittelt wird.
Aufwand
Infolge der herrschenden Ordnung kann die Suche innerhalb einer Teilliste
abgebrochen werden. Zur Berechnung des mittleren Suchaufwands müssen die
Beiträge zu den einzelnen Positionen zusammen gezählt werden.
...
...
1+2
1+(T-1)
1+1 1
...
2+(T-1)
2+1
2
N/T+(T-1)
N/T+1 N/T
Abb.:
N /T
(1) Z mit = 1 / 2 ⋅ N ⋅ (T ∑ i +
i =1
(2)
N T −1
N
⋅ ∑ i) = 1 / 2 ⋅ ( + T )
T i =1
T
Z max = N / T + T − 1
(1) enthält einen mit T fallenden und steigenden Anteil. Damit läßt sich eine optimale
Teillistenlänge folgendermaßen ermitteln:
N
+ T)
T
dZ mit
1⋅ N 1
=−
+ =0
dT
2⋅T 2 2
Z mit = 1 / 2 ⋅ (
T=
N
Z mitopt =
1⋅ N
N 1
+
= ⋅( N + N ) = N
2
2
2⋅ N
Aus (2) folgt:
7
Algorithmen und Datenstrukturen
dZ max
N
= − 2 +1 = 0
dT
T
T= N
Z maxopt =
N
+ N = N + N +1 = 2⋅ N +1
N
2. Binäre Suche
Beschreibung des Verfahrens
In einem Feld, dessen Elemente bereits sortiert sind, kann ein spezielles Element
am schnellsten über die binäre Suche gefunden werden. Durch fortschreitendes
Teilen des vorgegebenen Suchbereichs und Bestimmen, in welchem Teil weiter
gesucht werden soll, kann schließlich das gesuchte Element isoliert und festgelegt
werden. Das binäre Suchen kann nach der "Teile und Herrsche"-Strategie rekursiv
implementiert werden. Die Suche wird ständig wiederholt, der zu durchsuchende
Bereich wird dabei kleiner bis schließlich nur noch ein einziges Element übrig bleibt.
Immer wird bei dem fortgesetzten Teilen ein Element in der Mitte der Teilbereiche
überprüft.
Aufwand
Am besten schneidet die binäre Suche ab, falls das zu suchende Element sich
inmitten der Liste befindet. Die Komplexität ist dann vom Rang O(1), nur ein einziger
Vergleich ist nötig. Die rechnerische Komplexität im schlechtesten Fall ist O(log 2 N )
Dieser Fall tritt dann auf, wenn das gesuchte Element nicht in der Liste ist oder erst
beim zuletzt möglichen Vergleich gefunden wird. Die Teillisten des binären
Suchverfahrens sind dann auf die Größe eines Listenelements gesunken. Jede
Iteration während der Suche vermindert bekanntlich die Größe der zu
durchsuchenden Liste um die Hälfte:
N
N 21
N 22
1 = N 2M
....
Das Aufsplitten in Teillisten erfordert M Iterationen. M liegt näherungsweise bei
log 2 N :
N
2M
< 2 bzw. N < 2 M +1
Da M die erste ganze Zahl für N
2M
< 2 ist, muß N
2 M −1
≥ 2 bzw. 2 M ≤ N sein. Es
gilt: 2 M ≤ N < 2 M +1 .
M ist die größte ganze Zahl, die kleiner oder gleich Z mit = ldN ist.
8
Algorithmen und Datenstrukturen
Wegen dieser Beziehung wird das binäre Suchen häufig auch logarithmisches
Suchen genannt.
3. Suchen und Sortieren in Java mit der Klasse Collections
Im Paket java.util gibt es die Klasse Collections mit einer großen Anzahl
statischer Methoden zur Manipulation und Verarbeitung von Collections, z.B. zum
Durchsuchen, Sortieren, Kopieren und Synchronisieren von Collections.
static void sort(List list);
static void sort(List list, Comparator c);
Mit Hilfe von sort() können beliebige Listen sortiert werden. Argumente sind vom
Typ List und optional vom Typ Comparator. Fehlt die Angabe von Comparator,
dann wird die Liste in ihrer natürlichen Ordnung sortiert. Alle Elemente müssen dann
das Comparable-Interface implementiert haben und ohne Typfehler paarweise
vergleichbar sein. Gemäß JDK-Dokumentation verwendet diese Methode ein
modifiziertes Mischsortieren (MergeSort), das auch im „Worst.Case“ eine Laufzeit
von N ⋅ log(N ) hat.
static int binarySearch(List list, Object key);
static int binarySearch(List list, Object key, Comparator c);
9
Algorithmen und Datenstrukturen
2.2.1.3 Suchen durch Adreßrechnen
Grundlagen
Sprungsuche bzw. binäre Suche brachten wesentliche Verbesserungen gegenüber
dem sequentiellen Suchen. Der Aufwand für die beiden Verfahren ist aber immer
noch relativ hoch. Gewünscht wird im Rahmen eines Suchvorgangs ein Zugriff. Das
kann nur über eine direkte Adressierung der gewünschten Arbeitsspeicherposition
erreicht werden.
Einfache Abbildungsmöglichkeiten
1. Äquidistante Schlüssel
Verfahrensweise
Im einfachsten Fall sind die Werte der Schlüssel äquidistant. Der Index eines
Schlüssels berechnet sich hier aus:
Index =
SCHLx − SCHLmin
+1
D
SCHL min : kleinster vorkommender Schlüssel
D : Distanz zwischen 2 Schlüsseln (In vielen Fällen gilt D =1, SCHL min =0
(Nullbasisindizierung)
Aufwand: Z mit = Z max = 1 (Idealwert)
2. Einstichverfahren
Verfahrensweise
Die Schlüssel sind nicht mehr äquidistant, jedoch einigermaßen gleichmäßig über
ihren Wertebereich verteilt. Der folgende Ansatz führt in die Nähe des gesuchten
Schlüssels:
Index =
SCHLx − SCHLmin
⋅ ( N − 1)
SCHLmax − SCHLmin
Dieser Ansatz liefert i. allg. einen reellen Wert. Durch Aufrunden auf die nächste
ganze Zahl ergibt sich der Wert für den Index. In vielen Fällen wird man den
gesuchten Schlüssel an der berechneten Stelle vorfinden. Liegen jedoch 2 Schlüssel
sehr eng zusammen, dann bilden sie sich auf den gleichen Indexwert ab (Kollision).
solche Kollisionen müssen durch zusätzliche Suchschritte aufgelöst werden. Die
Zahl der Kollisionen kann man verringern, wenn man die Tabelle etwas größer
macht als zur Aufnahme der vorhandenen Schlüssel notwendig ist. Typisch ist ein
Füllfaktor von 0.7 bis 0.8.
Beispiel:
10
Algorithmen und Datenstrukturen
Position 1 2
Schlüssel 13 18
3
25
4
5
30
6
7
46
8
9
59
10
62
11
71
12
79
62 (vorgegebener Schlüssel)
Abb.:
Der Füllfaktor dieser Tabelle beträgt 0.75. Die Schlüssel sind hier außerdem
geordnet gehalten, so daß man am Ziel feststellen kann, in welcher Richtung man
(sequentiell) weiterzusuchen hat.
2.2.1.4 Hash-Codingverfahren
Allgemeine Abbildungen
Beim Einstichverfahren beruht die Berechnung des Index auf dem
Proportionalitätsansatz. Die Tabelle, in der der Schlüssel lokalisiert werden soll,
muß demnach sortiert sein. Außerdem geht der größte und kleinste Schlüssel in die
Rechnung ein.
Viel einfacher wäre natürlich die Möglichkeit, den Index direkt aus dem
Schlüsselwert auszurechnen. Gesucht ist: Eine Abbildung der Schlüsselwerte auf
die Indexmenge. Eine perfekte Abbildung ist allerdings der Ausnahmefall. Die
Forderung nach Eindeutigkeit ist gleichbedeutend mit: Die Tabelle ist so lang
auszulegen, daß jeder mögliche Name genau einen Platz zugeordnet bekommt.
Schlüsselwerte sind intern durch ein Bitmuster, z.B. je Zeichen ein Byte, dargestellt.
Dieses Bitmuster kann als Dualzahl interpretiert werden. Jedem Schlüsselwert
entspricht dann eine Zahl. Namen mit bspw. 4 Zeichen umfassen so Dualzahlen
zwischen 0 und 231-1, gefordert ist aber eine Abbildung auf die Indexmenge von
Null bis zur Länge der Tabellen (- 1). Suchschlüssel bestehen in den meisten Fällen
aus Zeichen, die auf ganzzahlige Indexe transformiert werden sollen. Neben der
Schlüsseltransformation sollten geeignete Funktionen für die Abbildung noch zwei
weitere Funktionen erfüllen (, falls es möglich ist):
- einfache Berechnung der Adresse
- Möglichst gleichmäßige Streung der Schlüsselwerte über den Indexbereich
Die Verwendung einer Schlüsseltransformation führt auf ein entscheidendes
Problem: Da die Menge der theoretisch möglichen Schlüsselwerte wesentlich größer
ist als die Menge der zur Verfügung stehenden Adressen, kann die Abbildung der
Schlüsselwerte auf Adressen nicht eindeutig sein. Auch eine kompaktere
Darstellung, in der man ein Zahlensystem mit einer passenden Basis aufstellt, führt
zu keiner Lösung.
Bsp.: Die Namen (Schlüssel) werden in der Regel aus 26 Buchstaben und 10 Ziffern gebildet. Man
kann folgende Zuordnung vereinbaren: A = 1, ... , Z = 26, 0 = 27, 1 = 28, ... , 9 = 36. Jedem Namen
entspricht dann eine Zahl im Zahlensystem zur Basis 37. Mit den Annahmen
11
Algorithmen und Datenstrukturen
- nur Namen aus 4 Zeichen sind zugelassen, die mit einem Buchstaben beginnen. Der größte Name
3
2
ist dann: Z 999 = 26 ⋅ 37 + 36 ⋅ 37 + 36 ⋅ 37 + 36
- Der Speicherbereich betrage 1024 Bytes
- Es können 100 verschiedene Namen vorkommen
Da die Abbildung von 1367630 Namen auf 1024 Adressen nicht eindeutig sein kann, wird man
fordern: Möglichst gleichmäßige Abbildung der 100 wirklich vorkommenden Adressen. Wird ein
Name (Schlüsselwert) auf eine Adresse abgebildet, unter der schon ein anderer Name eingetragen
ist, ist ein Ausweichplatz zu bestimmen.
Das vorliegende Beispiel führt zu der folgenden Überlegung:
- Direkte Abbildungen führen zur Speicherplatzverschwendung. Der Namensraum muß weniger (oder
höchstens gleich viele) Werte umfassen als der Adreßraum.
- Auf der anderen Seite sind Schlüsselwerte nur eine sehr eingeschränkte Teilmenge des durch den
Schlüssel angesprochenen Wertebereichs:
Der Füllfaktor (Belegungsfaktor) N/M (N: Anzahl der belegten Speicherstellen, M:
Anzahl der zur Verfügung stehenden Speicherplätze) ist dann gering.
Man wählt deshalb im allg. nicht eindeutige Abbildungen (Hash-Funktionen) und
nimmt in Kauf, daß 2 Elemente (oder mehr) einer gegebenen Schlüsselwertmenge
auf die gleiche Adresse (den gleichen Index) abgebildet werden. Es ist dann ein
Algorithmus zu bestimmen, der im Kollisionsfall (gleiche Hausadresse) eine
Ausweichadresse zuweist (Überlauf).
Überläufe können folgendermaßen behandelt werden:
1) Alle auftretenden Überläufe werden als lineares Feld außerhalb der unmittelbar zugeordneten
Hash-Tabelle gespeichert.
2) Die Überläufe jeder Speicherzelle (von jedem Tabellenelement) werden als lineares Überlauffeld
außerhalb der unmittelbar zugeordneten Hash-Tabelle gespeichert. Jede Zelle der Tabelle
benötigt einen Zeiger auf den Anfangsknoten des Überlauffelds. Zusätzlich werden noch weitere
Speicherzellen für Überläufer benötigt.
3) Überläufer werden in noch nicht verwendeten Speicherzellen der direkt zugeordneten HashTabelle gespeichert (offene Adressierung, Open Adressing)
Wahl der Hashfunktion
Eine gute Hash-Funktion sollte möglichst einfach und schnell berechenbar sein und
die zu speichernden Datensätze möglicht gleichmäßig auf den Speicherbereich zur
Vermeidung von Adreßkollisionen verteilen:
1. Die Divisions-Rest-Methode (Restklassenbildung)
Ein naheliegendes Verfahren zum Erzeugen einer Hashadresse H (S) ,
0 ≤ H ( S ) ≤ M − 1 , zu gegebenem Schlüssel S aus dem Bereich der natürlichen
Zahlen, ist, den Rest von S bei ganzzahliger Division durch M (Länge der Tabelle)
zu nehmen:
H ( S ) = S mod M
12
Algorithmen und Datenstrukturen
Entscheidend ist die gute Wahl von M 3. Damit die Adressen möglichst gleichmäßig
verteilt sind, empfiehlt es sich für den rechten Operanden des „Modulo-Operators“
eine Primzahl einzusetzen.
2. Faltung
Der Schlüssel wird in Komponenten zerlegt, z.B. Zahlen in Ziffern, Textworte in
Buchstaben. Diese Komponenten werden dann addiert bzw. multipliziert bzw.
logisch verknüpft. Häufig findet man: Multiplikation (meistens mit 2, Shift-Operation)
und anschließendes XOR (exklusives Oder). Das Hashing geschieht, indem man
sich bspw. auf den Typ "word" beschränkt und die Überläufe ignoriert und über ein
nachgeschaltetes Divisions-Restverfahren.
3. „Midsquare“-Verfahren
Aus dem Schlüsselwert muß beim „Midsqare“-Verfahren ein ganzzahliger Wert
gebildet werden. Im Anschluß erfolgt die Bildung des Quadrats von diesem
ganzzahligen Wert.
Auflösen von Kollisionen
Eine Hash-Funktion, die keine Kollisionen verursacht, heißt „perfekt“. Praktisch kann
perfektes Hashing nur für eine fest vorgebene Anzahl von Schlüsseln erreicht
werden. Das ist bspw. beim Aufbau einer Symboltabelle mit C++-Schlüsselwörtern
(z.B. while, template) der Fall. Mit einem Zugriff ist ein gesuchtes Schlüsselwort
gefunden. In anders gearteten Fällen ist die Suche nach einer perfekten HashFunktion nahezu unmöglich. Jeder neu hinzukommende Schlüssel zerstört den evtl.
erreichten perfekten Zustand.
Generell führen Hash-Funktionen auf Kollisionen. Zur Auflösung gibt es hier zwei
unterschiedliche Vorgehensweisen:
- Die kollidierenden Schlüssel werden aneinander verkettet (chaining)
- Von einer Anfangsadresse (Anfangsindex) wird eine Folge weiterer Adressen
(Indizes) durchlaufen (open adressing)
3
vgl. Ottman, T. und Widmayer, P.: Algorithmen und Datenstrukturen, B I Wissenschaftsverlag, Mannheim,
1990
13
Algorithmen und Datenstrukturen
2.2.1.4.1 Kettungstechniken „Seperate chaining“
Alle Schlüssel, die sich auf denselben Ort abbilden, werden mit Hilfe einer
geketteten Liste verwaltet. Die Hash-Tabelle enthält dann nur noch Zeiger auf
Listenketten.
Walter
........
Richard
........
Karl
.........
Werner
Kurt
Dieter
......
.......
Ernst
........
Josef
~
Liesel
Gerold
........
.......
.......
Hans
.........
Peter
........
~
Gerd
.........
Abb.: Aufbau der Hash-Tabelle für „seperate chaining“
Die eigentliche Hash-Tabelle ist ein Array, desen Komponenten Referenzen auf
Listenketten enthalten. Die Listenketten bestehen aus Knoten mit Einträgen, die aus
einem Schlüssel und Datenelementen bestehen.
Implementierung in C++
typedef int BOOL;
const int FALSE = 0;
const int TRUE = 1;
struct eintrag
{
// Datenelemente
char* name;
unsigned nummer;
eintrag* nachfolger;
// Methoden
eintrag(char*,unsigned);
// Konstruktor
friend char* holeSchl(eintrag&);
friend BOOL gleich(eintrag&, eintrag&);
friend ostream& operator <<(ostream&, eintrag&);
};
eintrag :: eintrag(char* n, unsigned nr)
{
int laenge = strlen(n);
name = new char[laenge + 1];
strcpy(name,n);
nummer = nr;
nachfolger = 0;
}
char* holeSchl(eintrag& e)
{
return e.name;
14
Algorithmen und Datenstrukturen
}
BOOL gleich(eintrag& e1, eintrag& e2)
{
return gleich(e1.name, e2.name);
}
ostream& operator <<(ostream& strm, eintrag& e)
{
return strm << e.name;
}
Einträge, die nach dem vorliegenden Schema gebildet werden, werden in eine Hash-Tabelle
eingehangen, die über den folgenden „abstrakten Datentyp“ beschrieben wird:
// Hash-Tabelle fuer seperate chaining
#ifndef HASH_H
#define HASH_H
template <class elt, class schl> class hashTabelle
{
// Darstellung
private:
int belegung;
// Groesse der Tabelle
int eltAnz;
// zeigt an, wie die Tabelle gefuellt ist
elt* elemente; // umfasst die Hash-tabelle
// Initialisieren
void init();
public:
hashTabelle(int = 17);
// Konstruktor (Initialisieren)
~hashTabelle();
// Destruktor
// Zugriff / Modifikation
private:
int suchePosition(schl);
public:
elt suche(schl);
void hinzufuegen(elt /*, aktion aktBearb = IGNORIERT*/);
void loeschen(schl);
// Attribute
BOOL leer();
int groesse();
double fuellung();
// Verarbeitung
BOOL enthaelt(elt);
// Ausgabe
friend ostream& operator<<(ostream&, const hashTabelle<elt, schl>&);
};
// Schnittstellenfunktionen
// Initialisieren /Beenden
template <class elt, class schl>
void hashTabelle<elt, schl> :: init()
{
elemente = new elt[belegung];
if (0 == elemente)
fehler("[hashTabelle::init] Speicherbeschaffung");
for (int i = 0; i < belegung; i++) elemente[i] = 0;
}
template <class elt, class schl>
hashTabelle<elt, schl> :: hashTabelle(int gr) :
eltAnz(0), belegung(gr) // , aktpos(-2)
{
15
Algorithmen und Datenstrukturen
init();
}
template <class elt, class schl>
hashTabelle<elt, schl> :: ~hashTabelle()
{
delete [] elemente;
}
// Zugriff / Modifikation
// private:
template <class elt, class schl>
int hashTabelle<elt, schl> :: suchePosition(schl s)
{
// Aufruf Hash-Funktion
int i = hash(s) % belegung;
return i;
}
template <class elt, class schl>
elt hashTabelle<elt, schl> :: suche(schl s)
{
elt h = elemente[suchePosition(s)];
if (h == 0) return 0;
while (h)
{
if (gleich(holeSchl(*h),s)) break;
h = h->nachfolger;
}
if (h) return h;
else
return 0;
}
template <class elt, class schl>
void hashTabelle<elt, schl> ::
hinzufuegen(elt e )
{
assert(0 != e);
int pos = suchePosition(holeSchl(*e));
if (0 == elemente[pos])
{
elemente[pos] = e;
}
else {
e->nachfolger = elemente[pos];
elemente[pos] = e;
}
eltAnz++;
}
template <class elt, class schl>
void hashTabelle<elt, schl> :: loeschen(schl s)
{
elt h = elemente[suchePosition(s)];
// Bei Duplikaten wird immer der erste Eintrag geloescht
if (h->nachfolger == 0) elemente[suchePosition(s)] = 0;
else {
elt hz = h;
h = h->nachfolger;
while (h)
{
if (gleich(holeSchl(*h),s))
{
hz->nachfolger = h->nachfolger;
delete h;
16
Algorithmen und Datenstrukturen
break;
}
hz = h;
h = h->nachfolger;
}
}
}
/* Attribute */
template <class elt, class schl>
BOOL hashTabelle<elt, schl> :: leer()
{
for (int i = 0; i < belegung; i++)
if (0 != elemente[i]) return FALSE; // FALSE
return TRUE; // TRUE
}
template <class elt, class schl>
double hashTabelle<elt, schl> :: fuellung()
{
return double(eltAnz)/belegung;
}
template <class elt, class schl>
int hashTabelle<elt, schl> :: groesse()
{
return eltAnz;
}
/* Verarbeitung */
template <class elt, class schl>
BOOL hashTabelle<elt, schl> :: enthaelt(elt e)
{
elt h = elemente[suchePosition(holeSchl(*e))];
while (h && !gleich(holeSchl(*e),holeSchl(*h)))
h = h->nachfolger;
if (h == 0) return FALSE;
else return TRUE;
}
// Ausgabe der Hash-Tabelle
template <class elt, class schl>
ostream& operator<<(ostream& strm, const hashTabelle<elt, schl>& t)
{
elt h;
for (int i = 0; i < t.belegung; i++)
{
h = t.elemente[i];
while (h)
{
strm << holeSchl(*h) << ' ';
h = h->nachfolger;
}
// cout << endl;
strm << endl;
}
return strm;
}
17
Algorithmen und Datenstrukturen
Die Klassenschablone umfaßt den Schlüssel (Datentyp: schl) und den Datensatz mit den Einträgen
(Datentyp: elt). Die folgende Anwedung4 zeigt einen Test der hash-Tabelle für das „seperate
chaining“. Nach jeder Veränderung wird die Hash-Tabelle ausgegeben.
#include "hash.h"
void main()
{
hashTabelle<eintrag*, char*> tbl;
char NamenPuffer[25], antwort;
unsigned nummer;
eintrag* e;
ifstream telefonDatei("telefon.txt",ios::in);
cout << "\nDaten aus der Datei telefon.txt:\n";
while (telefonDatei >> NamenPuffer >> nummer)
{ cout << setw(30) << setiosflags(ios::left)
<< NamenPuffer << nummer << ' ' << endl;
e = new eintrag(NamenPuffer,nummer);
tbl.hinzufuegen(e);
}
cout << "\nUebersicht zur Hash-Tabelle" << endl;
cout << tbl;
cout << "\nAbfragen bzw. Modifikationen" << endl;
for (;;)
{
cout << "Gib einen Namen ein oder ! fuer das Ende ";
cin >> NamenPuffer;
if (*NamenPuffer == '!') break;
e = tbl.suche(NamenPuffer);
if (e)
{
cout << "Name: " << e->name << " Nummer: "
<< e->nummer << endl;
cout << "Soll dieser Eintrag geloescht werden? (J/N): "
<< flush;
cin >> antwort;
if (antwort == 'J' || antwort == 'j')
{
tbl.loeschen(NamenPuffer);
cout << "Neue Hash-Tabelle: " << endl;
cout << tbl;
}
}
else {
cout << "Fuer " << NamenPuffer
<< " kein Eintrag in der Hash-Tabelle." << endl;
}
}
}
Implementierung in Java5
4
5
PR22145.CPP
pr22141
18
Algorithmen und Datenstrukturen
2.2.1.4.2 Überlaufverfahren ohne Kettung
Diese Verfahren suchen bei Adreßkollisionen nach einem freien Tabellenplatz. Bei
der Bestimmung solcher Folgen vom Adressen (Indexfolgen) sollen möglichst wenig
Überlappungen (Häufungen) entstehen. Man unterscheidet:
1) Primäre Häufungen
Sie entstehen, falls sich 2 treffende Folgen (von Adressen bzw. Indizes) gemeinsam weiterlaufen
2) Sekundäre Häufungen
Sie liegen vor, falls auf die gleiche Adresse (den gleichen Index) abgebildete Elemente gleiche
Auflösungsfolgen haben. Zwei Synonyme „s“ und „s‘“ durchlaufen stets dieselbe Sondierungsfolge,
behindern sich also auf den Ausweichplätzen.
Eine gute Hash-Funktion lokalisiert die Position i in einer Tabelle mit der
Wahrscheinlichkeit 1/N. Ist die Position i besetzt, dann ist die Wahrscheinlichkeit
auf Position i + 1 zu landen: ½*N. Auf Position i + 3 gelangt mit der
Wahrscheinlichkeit:
1/3*N.
Häufungen
treten
dann
auf,
wenn
die
Wahrscheinlichkeit, freie Stellen in einer Gruppe aufeinanderfolgender Positionen
zu finden, größer ist, als irgendeine freie Stelle in der Tabelle.
Zur Bildung von Folgen (für Adressen, Indexe) können beliebige Verfahrensweisen
zur Anwendung kommen. In der Praxis haben sich jedoch einige typische Methoden
durchgesetzt.
1. Lineare Fortschaltung (Lineares Sondieren)
Verfahrensweise: Liefert die Namens-Adreß-Transformation eine Adresse „as“,
unter der schon ein Name eingetragen ist, dann wird durch lineares Fortschalten as
+ 1, as + 2, ... die nächste freie Adresse gesucht. Dort wird dann das
Schlüsselwort eingetragen. Ist as + i > M , so wird die Tabelle zyklisch von vorn
durchlaufen. Die Suche (nach Schlüsseln oder freiem Platz) bricht spätestens dann
ab, wenn die komplette Tabelle durchlaufen wurde, ohne das gesuchte Element
bzw. freien Speicherplatz zu finden.
Bsp.: Der folgende Datensatz ist in einer 11 Elemente umfassenden Tabelle
gespeichert:
struct datenSatz
{
int schl;
int daten;
} merkmal;
Die Hash-Funktion nutzt den Divisions-Rest-Algorithmus:
H(merkmal) = merkmal.schl % 11
Folgende Daten sollen in die Hash-Tabelle aufgenommen werden:
{ 54, 77, 94, 89, 14, 45, 76 }
19
Algorithmen und Datenstrukturen
Das führt zu folgenden Einträgen:
[0]
77
[1]
89
[2]
45
[3]
14
[4]
76
[5]
[6]
94
[7]
[8]
[9]
[10]
54
Kollision: errechnete Position 0
Kollision: errechnete Position 1
Abb.:
Beurteilung:
- Das lineare Suchen (linear probing) einer Ersatzadresse ist eines der ältesten und zugleich
uneffektivsten Verfahren
- Häufige Fehlerkollisionen
- Bildung klumpenförmiger, primärer Häufungen (Clustering)
Die Klassenschablone der Hash-Tabelle in C++ weist den gleichen Aufbau auf wie
die Hash-Tabellen für „double hashing“ und „quadratisches Sondieren“. Ein
Unterschied besteht lediglich in der Methode „suchePostion()“:
template <class elt, class schl>
int hashTabelle<elt, schl> :: suchePosition(schl s)
{
int i = hash(s) % belegung;
int ursprung = i;
//
int schrittw = 1;
// lineares Sondieren
do
{
if (!elemente[i] || gleich(s, holeSchl(*elemente[i])))
return i;
i += schrittw;
// schrittw += 2;
if (i >= belegung) i -= belegung;
//
}
while (i != ursprung);
// Die Tabelle wurde einmal durchlaufen, eine passende Luecke
// wurde nicht entdeckt; es ist an der Zeit, die Tabelle zu
// erweitern
ausdehnen();
return suchePosition(s);
// sieht nach Rekursion aus,, tritt aber nur einmal auf
}
Über suchePosition() werden alle Positionen in der Hash-Tabelle ermittelt. Falls
keine freie Position ermittelt werden kann, wird die Hash-Tabelle über
„ausdehnen()“ erweitert (mindestens auf die doppelte Größe).
20
Algorithmen und Datenstrukturen
Kurt
~
.........
Hans
........
Werner
.......
Josef
.......
Fritz
...........
Uwe
...........
Dieter
..........
~
Abb.: Hash-Tabellen-Aufbau nach dem linearen Sondieren
2. Zufälliges Suchen
Verfahrensweise: Bei diesem Verfahren wird im Kollisionsfall mit Hilfe einer
Zufallszahl ein Ersatzplatz gesucht:
(1) Berechnung einer Tabellenadresse as (nach einer der beschriebenen Transformationen)
(2) Vergleich des vorliegenden Namens mit dem unter as eingetragenen Namen. Im Kollisionsfall
weiter bei (3), andernfalls STOP
(3) Berechnung einer Zufallszahl xi und Bildung der Zuordnung as := as + xi mod M
(4) Weiter bei (2)
Anforderungen an den Zufallszahlengenerator:
- Die Zahlenfolge muß reproduzierbar sein, d.h.: Jede erstmalige Kollision eines Namens bewirkt die
Generierung derselben Folge von Zufallszahlen
- Es sollen möglichst viele Zahlen zwischen 1 und M-1 genau einmal erzeugt werden
Beurteilung: Alle Schlüssel, die auf diesselbe Adresse abgebildet werden,
generieren diesselbe Zufallsfolge (sekundäres Clustering)
3. Double Hashing
Verfahrensweise: Hier werden zwei voneinander unabhängige Hash-Funtionen
benutzt. Die erste Funktion dient zur Ermittlung der Position. Die zweite bestimmt,
falls die Position belegt ist, die nächste freie Position im Rahmen des „open
adressing“. Die Sondierungsfolge ist statt einer zufälligen Permutation eine zweite
Hash-Funktion und führt für den Schlüssel S auf
H(S), H(S)-H‘(S),H(S)–2*H‘(S), ... ,H(S)–(M–1)*H‘(S)
(jeweils modulo M, H‘(S) berechnet die zweite Hash-Funktion). H‘(S) muß so
gewählt werden, daß für alle Schlüssel S die Sondierungsfolge eine Permutation der
Hashadresse bildet, d.h. H‘(S) ist ungleich Null und darf M nicht teilen. Man sagt:
H‘(S) muß relativ prim sein zu M. Falls M eine Primzahl ist, dann ist sicher jedes
H‘(S) für alle „S“ relativ prim zu M. Wählt man H‘(S) abhängig von H(S), so werden
21
Algorithmen und Datenstrukturen
manche (oder sogar alle) Synonyme die gleiche Sondierungsfolge haben, eine
gewisse sekundäre Häufung ist die Folge.
Ist M eine Primzahl und H(S) = S mod M, dann erfüllt H‘(S)=1 + S mod (M-2)6
die Anforderungen. Andere geeignete Hash-Funktionen für „double hashing“ sind
i+H‘(S)modM
bzw.
M – 2 – S * mod(M-2)
In der Praxis reicht häufig eine einfachere Hash-Funktion, z.B.: H‘(S) = 8 – (S
mod 8)7.
Kurt
Werner
Josef
Bernd
~
.........
........
.......
.......
~
Fritz
...........
Dieter
...........
Herbert
..........
Abb.: Hash-Tabellen-Aufbau nach „double hashing“
Die Klassenschablone der Hash-Tabelle in C++ weist den gleichen Aufbau auf wie
die Hash-Tabellen für „quadratisches Sondieren“. Ein Unterschied besteht lediglich
in der Methode „suchePostion()“:
template <class elt, class schl>
int hashTabelle<elt, schl> :: suchePosition(schl s)
{
int i = hash(s) % belegung;
int ursprung = i;
//
int schrittw = // 8 - (hash1(s) % 8);
belegung - 2 - (hash1(s) % belegung - 2);
do
{
if (!elemente[i] || gleich(s, holeSchl(*elemente[i])))
return i;
i += schrittw;
// schrittw += 2;
if (i >= belegung) i -= belegung;
//
}
while (i != ursprung);
// Die Tabelle wurde einmal durchlaufen, eine passende Luecke
// wurde nicht entdeckt; es ist an der Zeit, die Tabelle zu
// erweitern
ausdehnen();
return suchePosition(s);
// sieht nach Rekursion aus,, tritt aber nur einmal auf
6
7
das ist besser als 1-Smod(M-1), da M-1 gerade ist
vgl. Sedgewick, Robert: Algorithmen, Addison-Wesley, München, 1. Auflage 1991, S. 282
22
Algorithmen und Datenstrukturen
}
4. quadratisches Sondierem
Verfahrensweise: Zuerst wird im Kollisionsfall die unmittelbar folgende Position in
der Hashtabelle untersucht (as + 1). Danach, falls die Position besetzt ist, wird der
Index nicht um 2 Einheiten sondern um 4 Einheiten heraufgesetzt (as + 4). Führt
das auch nicht zum Erfolg, dann wird der Index um 9 Einheiten erhöht (as + 9). Die
Quadratzahlen kann man sogar über eine einfache Addition bestimmen, falls man
folgende Berechnungsmöglichkeit der Quadratzahl nutzt:
0
1
1
4
3
9
5
16
25
7
9
36
11
49
13
64
15
Man braucht also nur der Zahl, die zur jeweils vorletzten Quadratzahl zur Ermittlung
der letzten aktuellen Quadratzahl addiert wurde, eine Zwei hinzuzufügen. Das
Resultat wird zur letzten aktuellen Quadratzahl addiert und man erhält die neue
Quadratzahl.
Mit dem quadratischen Sondieren erreicht man so mindestens die Hälfte aller
möglichen Positionen, falls die Tabellengröße eine Primzahl ist. Das ist in der Regel
ausreichend.
Die Datenstruktur der „hashTabelle“
Ernst
Karl
........
Hans
Liesel
~
.........
.......
.......
~
Fritz
...........
Walter
...........
Werner
..........
Abb.:
Die „hash-Tabelle“ enthält einen Verweis auf den Datensatz. Die Position für diesen
Verweis ist bestimmt durch eine im Datensatz vereinbarte Feldgrösse, den
Schlüssel.
Schluessel
weitere Feldgrößen ......
23
Algorithmen und Datenstrukturen
Implementierung in C++
Zu den Schlüsseln müssen die boolschen Funktionen gleich() und vergleiche() definiert sein.
Außerdem muß für diese Schlüssel (unabhängige Veränderliche) die Hash-Funktion vereinbart sein.
Zur Datenstruktur (abstrakter Datentyp) der „hash-Tabelle“ liegt folgende Klassenschablone vor:
// Schnittstellenbeschreibung „hashTabelle“
// Hash-Tabelle fuer quadratisches Sondieren
#ifndef HASH_H
#define HASH_H
template <class elt, class schl> class hashTabelle
{
// Darstellung
private:
int belegung;
// max. moegl. Belegung der Tabelle
int eltAnz;
// zeigt an, wie die Tabelle gefuellt ist
elt* elemente; // umfasst die Datenstruktur
// Initialisieren / Abschliessen
void init();
void ausdehnen();
public:
hashTabelle(int = 17);
// Konstruktor (Initialisieren)
~hashTabelle();
// Destruktor
// Zugriff / Modifikation
private:
int suchePosition(schl);
public:
elt suche(schl);
elt operator[](schl& k);
void hinzufuegen(elt, aktion aktBearb = IGNORIERT);
// Durchlauf
private:
int aktpos;
int zaehler;
public:
void ruecksetzen();
BOOL beendet();
BOOL naechstes();
// bestimmt die jeweils naechste Position
elt aktuell();
// Zugriff auf das aktuelle Verarbeitungselement
int index();
// Attribute
BOOL leer();
BOOL voll();
int groesse();
int belegteGroesse();
double fuellung();
// Verarbeitung
BOOL enthaelt(elt);
// Ausgabe
friend ostream& operator<<(ostream&, const hashTabelle<elt, schl>&);
};
// Schnittstellenfunktionen
#include <math.h>
// lehnt sich leicht an den Algorithmus A an, Knuth, vol. 2, 1st. ed., p.
// 340
BOOL istPrimzahl(int n)
{
assert(n > 0);
24
Algorithmen und Datenstrukturen
if (n % 3 == 0) return 0;
if (n % 5 == 0) return 0;
int grenze = int(sqrt(n));
for (int d = 5; d <= grenze; d += 6)
if ((n % (d + 2)) == 0 || (n % (d + 6)) == 0)
return FALSE;
// FALSE
return TRUE;
// TRUE
}
int naechstePrimzahl(int n)
{
if (n % 2 == 0) n++;
while (!istPrimzahl(n)) n += 2;
return n;
}
// Initialisieren
template <class elt, class schl>
void hashTabelle<elt, schl> :: init()
{
elemente = new elt[belegung];
if (0 == elemente)
fehler("[hashTabelle::init] Speicherbeschaffung");
for (int i = 0; i < belegung; i++) elemente[i] = 0;
}
// Konstruktor
template <class elt, class schl>
hashTabelle<elt, schl> :: hashTabelle(int gr) :
eltAnz(0), belegung(naechstePrimzahl(gr)), aktpos(-2)
{
init();
}
// Destruktor
template <class elt, class schl>
hashTabelle<elt, schl> :: ~hashTabelle()
{
delete [] elemente;
}
// Zugriff / Modifikation
// private:
template <class elt, class schl>
int hashTabelle<elt, schl> :: suchePosition(schl s)
{
int i = hash(s) % belegung;
int ursprung = i;
// zur Erzeugung einer Folge
int schrittw = 1;
// von Quadraten
do
{
if (!elemente[i] || gleich(s, holeSchl(*elemente[i])))
return i;
i += schrittw;
schrittw += 2;
if (i >= belegung) i -= belegung;
//
}
while (i != ursprung);
// Die Tabelle wurde einmal durchlaufen, eine passende Luecke
// wurde nicht entdeckt; es ist an der Zeit, die Tabelle zu
// erweitern
ausdehnen();
25
Algorithmen und Datenstrukturen
return suchePosition(s);
// sieht nach Rekursion aus,, tritt aber nur einmal auf
}
template <class elt, class schl>
elt hashTabelle<elt, schl> :: suche(schl s)
{
return elemente[suchePosition(s)];
}
template <class elt, class schl> elt
hashTabelle<elt, schl>::operator[](schl& s)
{
return suche(s);
}
Hash-Tabellen können, ohne den internen Aufbau der Tabelle zu zerstören, expandieren. Das der
Hash-Tabelle zugrundeliegende Feld wird in eine neue, größere Tabelle übertragen und anschließend
gelöscht. Es ist zweckmäßig zu expandieren, wenn die Tabelle noch nicht vollständig gefüllt ist.
Kollisionen wachsen in der Regel stark, wenn die Tabelle nahezu gefüllt ist. Es hat sich
herausgestellt, daß Tabelle expandieren sollen, wenn sie zu 70 bis 80% gefüllt sind.
template <class elt, class schl>
void hashTabelle<elt, schl> :: ausdehnen()
{
int alteGroesse = belegung;
elt* alteElemente = elemente;
belegung = naechstePrimzahl(2 * belegung);
init();
// Verdoppeln der Tabelle
for (int i = 0; i < alteGroesse; i++)
if (alteElemente[i])
elemente[suchePosition(holeSchl(*alteElemente[i]))] =
alteElemente[i];
delete [] alteElemente;
}
template <class elt, class schl>
void hashTabelle<elt, schl> ::
hinzufuegen(elt e, aktion aktBearb)
{
assert(0 != e);
int pos = suchePosition(holeSchl(*e));
if (0 == elemente[pos])
{
elemente[pos] = e;
eltAnz++;
if (voll()) ausdehnen();
}
else
switch (aktBearb)
{
case FEHLER:
fehler("[hashTabelle::hinzufuegen] "
" Die hashTabelle enthaelt schon "
" einen Eintrag mit diesem Schluessel");
/* kein break, das Progran
terminiert in fehler() */
case WARNUNG:
warnen("[hashTabelle::hinzufuegen] "
" Die hashTabelle enthaelt schon "
" einen Eintrag mit diesem Schluessel");
/* kein break */
26
Algorithmen und Datenstrukturen
case IGNORIERT:
case ERSETZT:
elemente[pos] = e;
}
}
/* Durchlauf */
template <class elt, class schl>
void hashTabelle<elt, schl>::ruecksetzen()
{
aktpos = -1;
zaehler = 0;
}
template <class elt, class schl>
BOOL hashTabelle<elt, schl> :: beendet()
{
return aktpos >= belegung;
}
template <class elt, class schl>
BOOL hashTabelle<elt, schl> :: naechstes()
{
if (-2 == aktpos)
fehler("[hashTabelle::naechstes] Ruecksetzen steht noch aus");
do
aktpos++;
while (!beendet() && (0 == elemente[aktpos]));
if (beendet())
return FALSE; // FALSE
else
{
zaehler++;
return TRUE; // TRUE
}
}
template <class elt, class schl>
elt hashTabelle<elt, schl> :: aktuell()
{
if (-2 == aktpos)
fehler("[hashTabelle::aktuell] Ruecksetzen steht noch aus");
if (-1 == aktpos)
fehler("[hashTabelle::aktuell] zum Durchlauf nicht eingerichtet ");
if (beendet())
fehler("[hashTabelle::aktuell] Duchlauf schon beendet");
return elemente[aktpos];
}
template <class elt, class schl>
int hashTabelle<elt, schl>::index()
{
return zaehler;
}
/* Attribute */
template <class elt, class schl>
BOOL hashTabelle<elt, schl> :: leer()
{
for (int i = 0; i < belegung; i++)
27
Algorithmen und Datenstrukturen
if (0 != elemente[i]) return FALSE; // FALSE
return TRUE; // TRUE
}
template <class elt, class schl>
double hashTabelle<elt, schl> :: fuellung()
{
return double(eltAnz)/belegung;
}
template <class elt, class schl>
BOOL hashTabelle<elt, schl> :: voll()
{
return 5 * eltAnz > 4 * belegung;
}
template <class elt, class schl>
int hashTabelle<elt, schl> :: groesse()
{
return eltAnz;
}
template <class elt, class schl>
int hashTabelle<elt, schl> :: belegteGroesse()
{
return belegung;
}
/* Verarbeitung */
template <class elt, class schl>
BOOL hashTabelle<elt, schl> :: enthaelt(elt e)
{
return e == elemente[suchePosition(holeSchl(*e))];
}
template <class elt, class schl>
ostream& operator<<(ostream& strm, const hashTabelle<elt, schl>& t)
{
hashTabelle<elt, schl>& tbl = (hashTabelle<elt, schl>&)t;
tbl.ruecksetzen();
while (tbl.naechstes())
{
strm << tbl.index() << '\t';
strm << *tbl.aktuell() << '\n';
}
return strm;
}
#endif /* HASH_H */
#ifndef HASH_H
#define HASH_H
Ausgabe von Warnungen: Neben der zentralen Fehlerbehandlungsroutine sind zur Ausgabe von
Warnungen folgende Kodierungen
enum aktion {IGNORIERT,WARNUNG, FEHLER, ERSETZT};
und folgende Ausgabefunktion vorgesehen:
static void warnen(char* nachricht)
{
28
Algorithmen und Datenstrukturen
cerr << "Warnung: " << nachricht << "!\n";
}
Hash-Funktion: Eine gute Hash-Funktion verteilt Indexpositionen relativ gleichmäßig über den
Indexbereich. Weiterhin muß die Hash-Funktion einfach berechnet werden können, denn sie wird
häufig aufgerufen. Zunächst wird aus dem Schlüssel ein ganzzahliger Wert ermittelt. Schüssel
bestehen häufig aus Zeichenketten. Ein einfache Funktion zur Berechnung eines ganzzahligen Werts
aus einer Zeichenkette ist:
long hash0(char* s)
{
int l = strlen(s);
switch (l)
{
case 0:
return
case 1:
return
case 2:
return
case 3:
case 4:
case 5:
return
default:
return
}
0;
s[1];
s[0] * s[1];
s[0] * s[1] + s[l-1];
s[0] * s[1] + s[l-4];
}
long hash(char* s)
{
long n = hash0(s);
cerr << "hash(" << s << ") = " << n << '\n';
return n;
}
Zu hash0 alternative Hashfunktionen sind:
unsigned long hash1(char* s)
{
const char* schlZgr
= s;
unsigned long hashWert = 0;
// while (*schlZgr) hashWert = (hashWert << 5) + *schlZgr++;
while (*schlZgr) hashWert += *schlZgr++;
return hashWert;
}
unsigned long hash2(char* s)
{
return (s[0] + 27 * s[1] + 729 * s[2]);
}
unsigned long hash3(char* s)
{
// Midsquare-Verfahren
unsigned long hashwert = (int) s;
hashwert *= hashwert;
hashwert >>= 11;
return hashwert;
}
unsigned long hash4(char* s)
{
unsigned long hashwert = 0;
29
Algorithmen und Datenstrukturen
for (int i = 0;i <= strlen(s); i++)
hashwert = (hashwert << 3) + s[i];
return hashwert;
}
unsigned long hash5(char* s)
{
int laenge = strlen(s);
int hashWert = 0;
if (laenge <= 1) hashWert = s[0];
else hashWert = s[0] + s[laenge - 1];
return hashWert;
}
Die folgende Hash-Funktion kann für einen ganzzahligen Schlüssel herangezogen werden.
// Hash-Funktion fuer einen ganzzahligen Schluessel
unsigned long hash6(int s)
{
if (s < 0) s = -s;
return s;
}
Über das folgende Testprogramm8 soll die Funktionsweise der Hash-Tabelle mit den Einträgen über
quadratisches Sondieren gezeigt werden.
#include "hash.h"
void main()
{
hashTabelle<eintrag*, char*> tbl;
char NamenPuffer[25], antwort;
unsigned nummer;
eintrag* e;
ifstream telefonDatei("telefon.txt",ios::in);
cout << "\nDaten aus der Datei telefon.txt:\n";
while (telefonDatei >> NamenPuffer >> nummer)
{ cout << setw(30) << setiosflags(ios::left)
<< NamenPuffer << nummer << ' ' << endl;
e = new eintrag(NamenPuffer,nummer);
tbl.hinzufuegen(e);
}
cout << "\nUebersicht zur Hash-Tabelle" << endl;
cout << tbl;
cout << "\nAbfragen bzw. Modifikationen" << endl;
for (;;)
{
cout << "Gib einen Namen ein oder ! fuer das Ende ";
cin >> NamenPuffer;
if (*NamenPuffer == '!') break;
e = tbl.suche(NamenPuffer);
if (e)
{
cout << "Name: " << e->name << " Nummer: "
<< e->nummer << endl;
}
else {
cout << "Fuer " << NamenPuffer
<< " kein Eintrag in der Hash-Tabelle." << endl;
cout << "Soll dieser Name in die Tabelle aufgenommen werden? (J/N): "
<< flush;
cin >> antwort;
if (antwort == 'J' || antwort == 'j')
8
PR22145.CPP
30
Algorithmen und Datenstrukturen
{
cout << "Zugehoerige Telefonnummer: ";
cin >> nummer;
e = new eintrag(NamenPuffer,nummer);
tbl.hinzufuegen(e);
cout << "Neue Hash-Tabelle: " << endl;
cout << tbl;
}
}
}
}
31
Algorithmen und Datenstrukturen
Implementierung in Java9
9
pr22142
32
Algorithmen und Datenstrukturen
2.2.1.4.3 Aufwendungen in Hash-Codingverfahren
Eine gute Hash-Funktion sorgt für gleichförmige Verteilung der Hash-Werte. In
Kombination mit einer größeren Tabelle ist die Zahl der Kollisionen niedrig. Falls
eine Hash-Tabelle M Einträge besitzt und N Einträge möglich sind, dann ist der
Ladefaktor der Tabelle: λ = M N . Für die leere Tabelle ist λ Null. Falls mehrere
Elemente hinzugefügt werden, wächst λ und die Kollisionen nehmen zu. Bei offener
Adressierung erhält λ den Wert 1, wenn die Tabelle voll ist. Im Fall des „seperate
chaining“ kann λ auch größere Werte als 1 annehmen. Im schlimmsten Fall werden
alle Datenelemente beim „seperate chaining“ auf diesselbe Tabellenposition
abgebildet. Falls die verkettete Liste N Datenelemente umfaßt ist die Suchzeit in der
Liste O(N). Durchschnittlich wird eine Suchzeit von O( λ ) = O( M N ) erwartet. Falls
die Anzahl der Elemente fest ist, kann man im Fall des „seperate chaining“ von
einem O(1)-Aufwand ausgehen. Man hat zur Ermittlung rechnerischer Komplexität
folgende Formeln angegeben:
offene Adressierung
„separate chaining“
erfolgreiche Suche
nicht erfolgreiche Suche
1
1
+ ,λ ≠ 1
2 ⋅ (1 − λ ) 2
λ
1+
2
1
1
+ ,λ ≠ 1
2
2 ⋅ (1 − λ ) 2
e−λ + λ
Falls λ = 1 ist, erwartet man bzgl. erfolgreicher Suche N/2 Versuche. Erfolglose
Suche bedeutet N Versuche. Die Verfahren der offenen Adressierung arbeiten für
kleine λ gut. Die angegebene Formel zum „open adressing“ gilt für „linear probing“.
Für „open adressing“ mit „double hashing“ hat Knuth10 zu nicht vollen, keine
Löschungen enthaltende Hash-Tabellen für erfolgreiche Suche die Formel11
− ln(1 − λ)
λ
angegeben.
Ladefaktor
0.5
0.6
0.7
0.8
0.9
1.0
2.0
4.0
„open adressing“
mit „linear probing“
„open adressing“ m.
„double hashing“
Chained hashing
1.50
1.75
2.17
3.00
5.50
-
1.39
1.53
1.72
2.01
2.56
-
1.25
1.30
1.35
1.40
1.45
1.50
2.00
3.00
Abb.: Durchschnittliche Anzahl überprüfter Tabellenelemente bis zur erfolgreiche Suche
10
11
vgl. Knuth: The Art of Prgramming, Volume 3
− ln(0.2) 1.6
=
=2
0,8
0.8
(Ladefaktor ist hier 0.8)
33
Algorithmen und Datenstrukturen
2.2.2 Sequentielles Suchen in verkettet gespeicherten, linearen Listen
Beschreibung des Knotentyps
Eine Liste ist verkettet gespeichert, falls jeder Knoten die Adresse der
Arbeitsspeicherzelle enthält, in der sein Nachfolger gespeichert ist.
Knoten k
Knoten k’
Zeiger auf den
nachfolgenden Knoten
Abb.:
Ein Knoten einer verkettet gespeicherten Liste besteht aus einem Datenfeld und
einem Zeigerfeld. Das Zeigerfeld ist das Bindeglied zum Aufbau der linearen Liste.
Methoden für einen Knotentyp
Jeder Knotentyp ist mit Methoden ausgerüstet, die das Einfügen bzw. das Löschen
eines Knoten nach einem gegebenen Listenknoten ermöglichen. Das Einfügen eines
Knoten läßt sich so darstellen:
vor dem Einfügen
nach dem Einfügen
Abb.:
Das Löschen beschreibt die folgende Darstellung:
vor dem Löschen
34
Algorithmen und Datenstrukturen
nach dem Löschen
Abb.:
Abstrakter Datentyp Listenknoten
ADT Knoten
Daten
Ein Datenfeld (daten) enthält die zu speichernden Information
(Datenteil). Das anschließende Feld von Listenknoten (nachf) enthält
einen Zeiger auf die folgenden Knoten (Relationenteil). Enthält es den
Wert NULL, dann gibt es keinen nachfolgenden Knoten.
Operationen
Konstruktor:
Initialisierungswerte: Ein Wert für das Datenfeld und einen Zeiger
auf die folgenden Knoten.
Verarbeitung: Initialisierung der beiden Komponenten des Listenknoten.
nachfKnoten
Eingabe: keine
Vorbedingung: keine
Verarbeitung: keine
Ausgabe: Rückgabe des Zeigerwerts auf den nachfolgenden Knoten.
Nachbedingung: keine
einfuegenDanach
Eingabe: Ein Zeiger auf einen neuen Knoten
Vorbedingung: keine
Verarbeitung: Setze im aktuellen Knoten den Zeiger auf den neuen Knoten
und im neuen Knoten den Zeiger auf den bisher dem
aktuellen Knoten folgenden Knoten.
Ausgabe: keine
Nachbedingung: Der Knoten zeigt nun auf einen neuen Knoten.
loeschenDanach
Eingabe: keine
Vorbedingung: keine
Verarbeitung: Kette den Zeiger auf den folgenden Knoten aus im aktuellen
Knoten aus und trage in das Zeigerfeld des aktuellen Knoten
den Zeiger ein, der dem Nachfolgerknoten des aktuellen
Knoten folgt
Ausgabe: Zeiger auf den gelöschten Knoten.
Nachbedingung: Der Knoten hat einen neuen Zeiger.
Spezifikation über C++-Klassenschablone
Der ADT Knoten kann mit Hilfe der folgenden C++-Klassenschablone12 beschrieben
werden:
#include <iostream.h>
#include <stdlib.h>
// Zentrale Fehlerbearbeitungsroutine
static void fehler(char* nachricht)
{
cerr << "Fehler: " << nachricht << "!\n";
12
PR22201.CPP, knoten.h
35
Algorithmen und Datenstrukturen
exit(1);
}
// Deklaration Listenknoten
template <class T> class Knoten
{
private:
// nachf ist die Adresse des folgenden Knoten
Knoten<T> *nachf;
public:
// oeffentlich zugaengliches Datenelement
T daten;
// Konstruktor
Knoten (const T& item, Knoten<T>* zgrNachf = NULL);
// Modifikationsmethoden fuer Listen
void einfuegenDanach(Knoten<T> *p);
Knoten<T> *loeschenDanach(void);
// Ermitteln der Adresse des naechsten Knoten
Knoten<T> *nachfKnoten(void) const;
};
Methoden für den Listenknoten
// Konstruktor. Initialisieren der Daten und Zeigerelemente
template <class T>
Knoten<T>::Knoten(const T& item, Knoten<T>* zgrNachf) :
daten(item), nachf(zgrNachf)
{}
Die Methode nachfKnoten() versorgt den Anwender mit einem Zeiger auf den
folgenden Knoten:
// Rueckgabe der Adresse auf den nachfolgenden Knoten
template <class T>
Knoten<T> *Knoten<T>::nachfKnoten(void) const
{
return nachf;
}
Die beiden Methoden einfuegenDanach() und loeschenDanach() dienen zum
Aufbau linear verketteter Listen:
// Einfuegen eines Knoten nach dem aktuellen Knoten
template <class T>
void Knoten<T>::einfuegenDanach(Knoten<T> *z)
{
// z zeigt auf den Nachfolger des aktuellen Knoten, der
// aktzuelle Knoten zeigt auf z
z->nachf = nachf;
nachf = z;
}
loeschenDanach entfernt den Knoten, der dem aktuellen Objekt folgt. Dessen
Zeiger wird auf den nächsten Knoten der Liste eingestellt. Gibt es keinen Knoten
nach dem aktuellen Objekt, dann ist der Zeiger auf NULL zu bringen. In einem
temporären Bereich wird der Zeiger des zu löschenden Knoten gesichert.
36
Algorithmen und Datenstrukturen
vorher
vorher
Abb.:
// Loeschen des Knoten, der dem aktuellen Knoten folgt,
// Rueckgabe der Adresse des geloeschten Knoten
template <class T>
Knoten<T> *Knoten<T>::loeschenDanach(void)
{
// Sichern der Adresse des zu loeschenden Knoten
Knoten<T> *tempZgr = nachf;
// Wenn es keinen Folgeknoten gibt, ist die Rueckgabe NULL
if (nachf == NULL) return NULL;
// Der aktuelle Knoten zeigt auf den Nachfolger des zu
// loeschenden Knoten
nachf = tempZgr->nachf;
// Rueckgabe des Zeigers auf den ausgeketteten Knoten
return tempZgr;
}
Aufbau linear verketteter Listen
Die einfach gekettete Liste ist durch einen Zeiger bestimmt, der auf den ersten
Listenknoten hinweist. Falls dieser Zeiger den Wert NULL besitzt, ist die Liste leer.
Die Funktionsschablone erzeugeKnoten() baut einen Listenknoten mit Datenwert
(Datenteil) und Zeiger (Relationenteil) auf:
// Erzeugen eines Knoten zum Aufbau linear geketteter Listen
template <class T> Knoten<T>*
erzeugeKnoten(const T& merkmal, Knoten<T>* nachfZgr = NULL)
{
Knoten<T>* neuerKnoten;
neuerKnoten = new Knoten<T>(merkmal,nachfZgr);
if (neuerKnoten == NULL)
fehler("[Knoten::erzeugeKnoten] Speicherbelegungsfehler ");
return neuerKnoten;
}
Die Funktionsschablonen zum Einfügen eines Listenknoten am Anfang bzw. zum
Löschen von Listenknoten ermöglichen die Verwaltung von Elementen in einer linear
verketteten Liste:
// Funktionsschablone zum Einfuegen am Listenanfang
template <class T>
void einfuegenVorn(T merkmal, Knoten<T>* & kopf)
{
kopf = erzeugeKnoten(merkmal,kopf);
}
// Loeschen aller Knoten in der Liste
template <class T>
void bereinigeListe(Knoten<T>* &anfang)
{
Knoten<T> *aktZgr, *nachfZgr;
aktZgr = anfang;
while (aktZgr != NULL)
{
nachfZgr = aktZgr->nachfKnoten();
37
Algorithmen und Datenstrukturen
delete aktZgr;
aktZgr = nachfZgr;
}
// Markiere die Liste zu leer
anfang = NULL;
}
Einfügen eines Knoten in eine geordnete Folge von Listenknoten
Häufig besteht der Wunsch, daß die Daten der durch die verkettete Liste
verbundenen Knoten eine geordnete Folge abgeben. Falls eine derartige geordnete
Folge von Listenknoten gefordert ist, wird zweckmäßigerweise jeder neu
hinzukommende Listenknoten direkt an seine Position gebracht und eingefügt:
template <class T>
void geordnEinfuegen(Knoten<T>* &anfang, T merkmal)
{
// aktZgr laeuft durch die Liste
Knoten<T> *aktZgr, *vorZgr, *neuerKnoten;
// vorZgr == NULL signalisiert: Listenende am Anfang
vorZgr = NULL;
aktZgr = anfang;
while (aktZgr != NULL)
{
// bestimme den einzufuegenden Punkt
if (merkmal < aktZgr->daten)
break;
vorZgr = aktZgr;
aktZgr = aktZgr->nachfKnoten();
}
if (vorZgr == NULL)
anfang = erzeugeKnoten(merkmal, anfang);
else
{
//
neuerKnoten = erzeugeKnoten(merkmal);
vorZgr->einfuegenDanach(neuerKnoten);
}
}
Anwendung: Sortieren durch Einfügen in linear geketteten Listen
Die Elemente eines gegebenen Arbeitsspeicherfelds werden in eine geordnete,
verkettete Liste eingefügt:
template <class T>
void verkSortieren(T x[], int n)
{
Knoten<T> *geordnListe = NULL, *aktZgr;
int i;
// Fuege die Elemente aus dem array geordnet in die Liste ein
for (i = 0; i < n; i++)
geordnEinfuegen(geordnListe,x[i]);
// Kopiere die Knoten der Liste in den array
aktZgr = geordnListe;
i = 0;
while (aktZgr != NULL)
{
x[i++] = aktZgr->daten;
38
Algorithmen und Datenstrukturen
aktZgr = aktZgr->nachfKnoten();
}
bereinigeListe(geordnListe);
}
Der folgende Hauptprogrammabschnitt ruft die vorliegende Funktionsschablone zum
Sortieren eines Arbeitsspeicherfelds auf:
void main(void)
{
// Sortiere die Komponeneten des Felds
int x[10] = {22, 11, 33, 55, 44, 77, 66, 99, 88, 13};
verkSortieren(x,10);
cout << "Sortiert: ";
for (int i = 0; i < 10; i++)
cout << x[i] << " ";
cout << endl;
}
2.2.2.1 Einfach gekettete Listen
1. Die Klasse „einfach verkettete Liste“ in C++
Die Klasse „einfach verkettete Liste“ besitzt in C++ folgende Datenstruktur:
39
Algorithmen und Datenstrukturen
vorn
hinten
vorgZgr
aktZgr
position=3
groesse=5
position
Abb.: Die Datenstruktur zur Klasse „verketteteListe“
Zu dieser Struktur gehört die folgende Schnittstellenbeschreibung13:
// Deklaration Verkettete Liste
#include "knoten.h"
template <class T>
class seqListenIterator;
template <class T>
class verketteteListe
{
private:
// Zeiger auf Anfang und Ende der Liste
Knoten<T> *vorn, *hinten;
// fuer Datenzugriff, Einfuegen und Loeschen
Knoten<T> *vorgZgr, *aktZgr;
// Anzahl Listenelemente
int groesse;
// Listenposition, wird benutzt von der Methode "ruecksetzen"
int position;
// Private Methoden zum Zuweisen und Freigeben von Knoten
Knoten<T> *beschaffeKnoten(const T& merkmal,Knoten<T> *nachfZgr=NULL);
void freigabeKnoten(Knoten<T> *z);
// kopiert die Liste L auf die aktuelle Liste
void kopiereListe(const verketteteListe<T>& L);
public:
// Konstruktoren
verketteteListe(void);
verketteteListe(const verketteteListe<T>& L);
// Destruktor
~verketteteListe(void);
// Zuweisungsoperator
verketteteListe<T>& operator= (const verketteteListe<T>& L);
// Methoden zum Ueberpruefen des Listen-Status
int listenGroesse(void) const;
int leereListe(void) const;
// Durchlaufmethoden
void ruecksetzen(int pos = 0);
void naechstes(void);
int endeListe(void) const;
int aktPosition(void) const;
// Methoden zum Einfuegen
13
vgl. PR22211.CPP, gkliste.h
40
Algorithmen und Datenstrukturen
void einfuegenVorn(const T& merkmal);
void einfuegenHinten(const T& merkmal);
void einfuegenAn(const T& merkmal);
void einfuegenDanach(const T& merkmal);
// Methoden zum Loeschen
T loeschenVorn(void);
void loeschenAn(void);
// Datenwiedergewinnung/ Modifikation
T& Daten(void);
// Methode zur Bereinigung der Liste
void bereinigeListe(void);
// Die folgende Klasse benoetigt den Zugriff auf den Listenanfang
friend class seqListenIterator<T>;
};
Methoden zur verketteten Liste
a) private Methoden
Die privaten Methoden beschaffeKnoten()und freigabeKnoten() verwalten
die Speicherbelegung durch Instanzen der Klasse. Falls ein Fehler dabei auftritt,
terminiert das Programm. kopiereListe() kopiert den Inhalt einer Liste in eine
leere Liste.
template <class T>
Knoten<T> *verketteteListe<T>::beschaffeKnoten(const T& merkmal,
Knoten<T>* nachfZgr)
{
Knoten<T> *z;
z = new Knoten<T>(merkmal,nachfZgr);
if (z == NULL) fehler("[gekettete Liste]: Speichebelegungsfehler");
return z;
}
template <class T>
void verketteteListe<T>::freigabeKnoten(Knoten<T> *z)
{
delete z;
}
// kopiere L auf die aktuelle Liste, die leer vorausgestzt wird
template <class T>
void verketteteListe<T>::kopiereListe(const verketteteListe<T>& L)
{
// benutze z fuer den Durchlauf von L
Knoten<T> *z = L.vorn;
int pos;
// Jedes Element von L soll am Ende des aktuellen Objekts eingefuegt
// werden
while (z != NULL)
{
einfuegenHinten(z->daten);
z = z->nachfKnoten();
}
// Rueckkehr, falls die Liste leer ist
if (position == -1) return;
// Ruecksetzen vorgZgr und aktZgr in der neuen Liste
vorgZgr = NULL;
aktZgr = vorn;
for (pos = 0; pos != position; pos++)
{
vorgZgr = aktZgr;
41
Algorithmen und Datenstrukturen
aktZgr = aktZgr->nachfKnoten();
}
}
b) öffentliche Methoden zur verketteten Liste
Konstruktor, Destruktor
// Erzeugen einer leere Liste: Zeiger und Groesse werden auf 0
// gesetzt, die Listenposition auf -1
template <class T>
verketteteListe<T>::verketteteListe(void): vorn(NULL), hinten(NULL),
vorgZgr(NULL),aktZgr(NULL), groesse(0), position(-1)
{}
template <class T>
verketteteListe<T>::verketteteListe(const verketteteListe<T>& L)
{
vorn = hinten = NULL;
vorgZgr = aktZgr = NULL;
groesse = 0;
position = -1;
kopiereListe(L);
}
// Destruktor
template <class T>
verketteteListe<T>::~verketteteListe(void)
{
bereinigeListe();
}
Methoden zum Ruecksetzen / Test / Durchlaufen der Liste
// Ruecksetzen der Position
template <class T>
void verketteteListe<T>::ruecksetzen(int pos)
{
int startPos;
// Falls die Liste leer ist, Rueckkehr
if (vorn == NULL) return;
// Falls die Position ungueltig ist, terminiere das Programm
if (pos < 0 || pos > groesse-1)
fehler("ruecksetzen: Ungueltige Listenposition: ");
// Bewege den Listendurchlaufmechanismus zu pos
if(pos == 0)
{
// Ruecksetzen auf Listenanfang
vorgZgr = NULL;
aktZgr = vorn;
position = 0;
}
else
// Ruecksetzeb aktZgr, vorgZgr und position
{
aktZgr = vorn->nachfKnoten();
vorgZgr = vorn;
startPos = 1;
// Bewegung nach rechts bis position == pos
for(position=startPos; position != pos; position++)
{
// Bewegen der beiden Durchlaufzeiger nach vorn
vorgZgr = aktZgr;
42
Algorithmen und Datenstrukturen
aktZgr
}
}
}
= aktZgr->nachfKnoten();
template <class T>
int verketteteListe<T>::listenGroesse(void) const
{
return groesse;
}
template <class T>
int verketteteListe<T>::leereListe(void) const
{
return groesse == 0;
}
// Bewege vorgZgr und aktZgr vorwaerts um einen Knoten
template <class T>
void verketteteListe<T>::naechstes(void)
{
// Falls der Durchlauf das Ende der Liste erreicht hat oder
// die Liste leer ist, kehre zurueck
if (aktZgr != NULL)
{
// Bewege die beiden Zeiger um einen Knoten vorwaerts
vorgZgr = aktZgr;
aktZgr = aktZgr->nachfKnoten();
position++;
}
}
// Wahr (True), falls der aktZgr auf NULL steht
template <class T>
int verketteteListe<T>::endeListe(void) const
{
return aktZgr == NULL;
}
// Rueckgabe der Position des aktuellen Knoten
template <class T>
int verketteteListe<T>::aktPosition(void) const
{
return position;
}
Methoden zum Einfügen
vorn=NULL
hinten=NULL
vorgZgr=NULL
aktZgr=NULL
position=-1
groesse=0
vorn
hinten
vorgZgr=NULL
aktZgr
position=0
groesse=1
Abb.: Einfügen an einer leeren Liste
43
Algorithmen und Datenstrukturen
vorn
hinten
vorgZgr
aktZgr
position=3
groesse=4
vorn
hinten
vorgZgr
aktZgr
position=4
groesse=5
Abb.: Einfügen „hinten“
Mehrere Methoden sind für das Einfügen vorgesehen. einfuegenHinten() fügt
einen Listenknoten hinten am Ende der Liste ein. einfuegenAn() benutzt dazu die
aktuelle Position in der Liste, einfuegenDanach() fügt einen Listenknoten nach
der aktuellen Position ein. Falls die aktuelle Position das Ende der Liste erreicht hat,
wird der Knoten über einfuegenAn() bzw. einfuegenDanach() nach dem
letzten Knoten in die Liste eingebracht.
// Einfuegen "merkmal" am Listenanfang
template <class T>
void verketteteListe<T>::einfuegenVorn(const T& merkmal)
{
// Aufruf "ruecksetzen", falls die Liste nicht leer ist
if (vorn != NULL) ruecksetzen();
einfuegenAn(merkmal);
// einfuegen am Listenanfang
}
// Einfuegen am Listenende
template <class T>
void verketteteListe<T>::einfuegenHinten(const T& merkmal)
{
Knoten<T> *neuerKnoten;
vorgZgr = hinten;
neuerKnoten = beschaffeKnoten(merkmal); // Erzeuge den neuen Knoten
if (hinten == NULL)
// falls die Liste leer ist, einfuegen vorn
vorn = hinten = neuerKnoten;
else
{
hinten->einfuegenDanach(neuerKnoten);
hinten = neuerKnoten;
}
aktZgr = hinten;
position = groesse;
groesse++;
}
44
Algorithmen und Datenstrukturen
Gegeben ist die aktuelle Position durch den Zeiger aktZgr. „einfuegenAn()“ fügt
an dieser Stelle einen neuen Listenknoten mit der Information ein, die im Parameter
von einfuegenAn() übergeben wird. Zur Erzeugung des neuen Knoten dient die
private Methode beschaffeKnoten().
// Einfuegen "merkmal" an die aktuelle Listenposition
template <class T>
void verketteteListe<T>::einfuegenAn(const T& merkmal)
{
Knoten<T> *neuerKnoten;
// 2 Faelle: Einfuegen am anfang oder innerhalb der Liste
if (vorgZgr == NULL)
{
// Einfuegen am Listenanfang. Plaziert auch einen
// Knoten in eine leere Liste
neuerKnoten = beschaffeKnoten(merkmal,vorn);
vorn = neuerKnoten;
}
else
{
// Einfuegen innerhalb der Liste. Plaziert den Knoten nach vorgZgr
neuerKnoten = beschaffeKnoten(merkmal);
vorgZgr->einfuegenDanach(neuerKnoten);
}
// Falls vorgZgr == hinten, wird in eine leere Liste eingefuegt
// oder an das Ende einer nicht leeren Liste. Aktualisiere "hinten"
// und "position"
if (vorgZgr == hinten)
{
hinten = neuerKnoten;
position = groesse;
}
// aktualisiere aktZgr und erhoehe die Listengroesse
aktZgr = neuerKnoten;
groesse++;
// erhoehe die Listengroesse
}
// Einfuegen "merkmal" nach der aktuellen Listenposition
template <class T>
void verketteteListe<T>::einfuegenDanach(const T& merkmal)
{
Knoten<T> *z;
z = beschaffeKnoten(merkmal);
if (vorn == NULL)
// Einfuegen in eine leere Liste
{
vorn = aktZgr = hinten = z;
position = 0;
}
else
{
// Einfuegen nach dem letzten Knoten der Liste
if (aktZgr == NULL) aktZgr = vorgZgr;
aktZgr->einfuegenDanach(z);
if (aktZgr == hinten)
{
hinten = z;
position = groesse;
}
else position++;
vorgZgr = aktZgr;
aktZgr = z;
}
groesse++;
// Erhoehe Listengroesse
45
Algorithmen und Datenstrukturen
}
Methoden zum Löschen
vorn
hinten
vorgZgr=NULL
aktZgr
position=0
groesse=1
vorn=NULL
hinten=NULL
vorgZgr=NULL
aktZgr=NULL
position=-1
groesse=0
Abb.: Löschen des letzten Listenelements
46
Algorithmen und Datenstrukturen
vorn
hinten
vorgZgr
aktZgr
position=4
groesse=5
vorn
hinten
vorgZgr
aktZgr
position=3
groesse=4
Abb.: Löschen „hinten“
loeschenVorn() loescht den ersten Listenknoten der Liste, loeschenAn()
entfernt den Knoten nach der aktuellen Position. Der Versuch, einen Listenknoten
aus einer leeren Liste zu löschen, terminiert das Programm.
// Loesche den Knoten am Listenanfang
template <class T>
T verketteteListe<T>::loeschenVorn(void)
{
T merkmal;
ruecksetzen();
if (vorn == NULL) fehler("Ungueltiges Loeschen");
merkmal = aktZgr->daten;
loeschenAn();
return merkmal;
}
// Loeschen des Knoten an der aktuellen Listenposition
template <class T>
void verketteteListe<T>::loeschenAn(void)
{
Knoten<T> *z;
// Fehler Falls leere Liste oder Listenende
if (aktZgr == NULL) fehler("Ungueltiges Loeschen");
// Loeschen entweder am Anfangsknoten oder innerhalb der Liste
if (vorgZgr == NULL)
{
// Sichere die Adresse am Listenanfang und kette aus. Falls dies
// der letzte Knoten ist, wird "vorn" NULL
z = vorn;
vorn = vorn->nachfKnoten();
}
else
// kette einen inneren Knoten nach vorgZgr aus. Sichere die Adresse
z = vorgZgr->loeschenDanach();
// Falls das Ende geloescht wird, ist das neue Ende vorgZgr und die
// die Position wird erniedrigt; im anderen Fall bleibt die Position
// gleich. Wenn z auf den letzten Knoten zeigt, ist hinten = NULL und
47
Algorithmen und Datenstrukturen
// position ist -1
if (z == hinten)
{
hinten = vorgZgr;
position--;
}
// bewege aktZgr hinter den geloeschten Knoten.
// Falls z der letzte Knoten in der Liste ist, wird aktZgr NULL
aktZgr = z->nachfKnoten();
// Gib den Knoten frei und erniedrige die Listenlaenge
freigabeKnoten(z);
groesse--;
}
loeschenAn() entfernt den Knoten nach der aktuellen Position.
template <class T>
void verketteteListe<T> :: loeschenAn(void)
{
// Loeschen des Knoten an der aktuellen Listenposition
Knoten<T>* k;
// Fehler bei leerer Liste oder Position am Ende der Liste
if (aktZgr == NULL)
fehler("[verketteteListe] Ungueltiges Loeschen");
// Loeschen vorn oder innerhalb der Liste
if (vorgZgr == NULL)
{
// Sichern der Adresse von vorn
k = vorn;
// Ausketten von vorn
vorn = vorn->nachfKnoten();
// vorn wird NULL, falls es der letzte Knoten war
}
else k = vorgZgr->loeschenDanach();
//
if (k == hinten)
{
// hinten wird geloescht, vorgZgr ist neues Listenende
hinten = vorgZgr;
position--;
}
// aktZgr hinter den geloeschten Knoten setzen
aktZgr = k->nachfKnoten();
// gib den Knoten frei und dekrementiere die Listengroesse
freigabeKnoten(k);
groesse--;
}
Datenzugriff
Mit der Methode Daten() wird der Datenzugriff an der aktuellen Position der Liste
möglich. Daten() gibt eine Referenz auf die Datenelemente der Listenknoetn
zurück.
// Rueckgabe einer Referenz auf den Datenwert im aktuellen Knoten
template <class T> T& verketteteListe<T>::Daten(void)
{
// Fehler, falls Liste leer oder vollstaendig durchlaufen
if (groesse == 0 || aktZgr == NULL)
fehler("Daten: ungueltige Referenz");
return aktZgr->daten;
}
48
Algorithmen und Datenstrukturen
Die Methode bereinigeListe() steuert alle Listenknoten an und gibt den durch
die Knoten belegten Speicherplatz frei.
template <class T>
void verketteteListe<T>::bereinigeListe(void)
{
Knoten<T> *aktPosition, *nachfPosition;
aktPosition = vorn;
while(aktPosition != NULL)
{
// Hole die Adresse des naechsten Knoten und loesche
// den aktuellen Knoten
nachfPosition = aktPosition->nachfKnoten();
freigabeKnoten(aktPosition);
aktPosition = nachfPosition; // Postionieren auf den nachf. Knoten
}
vorn = hinten = NULL;
vorgZgr = aktZgr = NULL;
groesse = 0;
position = -1;
}
Anwendungen
Eine häufig gestellte Aufgabe ist das Entfernen evtl. vorliegender Duplikate. Von
einer aktuellen Position aus, wird untersucht, ob das im Listenknoten gespeicherte
Element nochmal vorkommt.
void entferneDuplikate(verketteteListe<int>& L)
{
// aktuelle Listenposition und Datenwert
int aktPos, aktWert;
// Positioniere auf den Listenanfang
L.ruecksetzen();
// Schleife durch die Liste
while(!L.endeListe())
{
// Bearbeite den aktuellen Listenelementwert
aktWert = L.Daten();
aktPos = L.aktPosition();
// Positioniere um einen Knoten nach rechts
L.naechstes();
// Positioniere vorwaerts bis das Ende der Liste erreicht ist
// Loesche alle Erscheinungsbilder von aktWert
while(!L.endeListe())
// Falls ein Knoten geloescht wurde, ist die aktuelle Position die
// des naechsten Knoten
if (L.Daten() == aktWert) L.loeschenAn();
else L.naechstes();
// Positioniere auf den naechsten Knoten
// Posizioniere auf den ersten Knoten mit dem Wert aktWert. Mache weiter
L.ruecksetzen(aktPos);
L.naechstes();
}
}
Ausgabe der Listenelemente
// Ausgabe der Liste L
template <class T>
49
Algorithmen und Datenstrukturen
void ausgListe(verketteteListe<T>& L)
{
// Positioniere auf den Anfang der Liste.
// Durchlaufe die Liste und gib jedes Element aus
for(L.ruecksetzen(); !L.endeListe(); L.naechstes())
cout << L.Daten() << " ";
}
Hauptprogrammabschnitt14
void main(void)
{
verketteteListe<int> L;
int i;
// Einfuegen von 15 zufaellig ausgewaehlten Ganzzahlen im Bereich
// von 0 bis 15
for(i=0; i < 15; i++)
L.einfuegenHinten(1 + random(7));
cout << "Original-Liste: ";
ausgListe(L); cout << endl;
// Entferne alle Duplikate und gib die neue Liste aus
entferneDuplikate(L);
cout << "Bereinigte Liste:
";
ausgListe(L); cout << endl;
}
/*
<Ablauf des Programms >
Original-Liste: 1
Bereinigte Liste:
*/
14
7
7 1 5 1 2 7 2
1 7 5 2 6 3 4
PR22212.CPP
50
1
6
6
3
6
4
Algorithmen und Datenstrukturen
2. Eine Klassenhierarchie für sequentielle bzw. geordnete Listen
Liste
seqListe
geordneteListe
abstrakte Basisklasse
sequentielle Liste
geordnete Liste (Datenelemente der Listenknoten
sind, vom Anfang zum Ende der Liste betrachtet, sortiert)
Abb.: Klassenhierarchie: Liste, seqListe, geordneteListe
Die abstrakte Basisklasse „Liste“
Es gibt Klassen mit rein virtuellen Funktionen (abstrakte Klassen15). Eine solche
Funktion ist nur deklariert, nicht definiert. Klassen mit rein virtuellen Funktionen
dienen zur Definition von Ableitungen. So bildet eine abstrakte Klasse eine Art
Schablone für die abgeleiteten Klassen. In den abgeleiteten Klassen erfolgt dann die
Konkretisierung. Da für die abstrakte Klasse keine Objekte erzeugt werden können,
darf sie nicht als Funktionsergebnis oder Funktionsargument auftreten.
Ebensowenig darf sie das Ergebnis einer expliziten Typkonversion sein. Referenzen
oder Zeiger auf abstrakte Klassen sind jedoch zulässig.
In der abstrakten Basisklasse „Liste“16 existiert ein Datenelement „groesse“, das
zur Definition der Methoden „listenGroesse()“ und „leereListe()“ dient.
Diese Methoden werden von den abgeleiteten Klassen geerbt und können dort evtl.
überlagert werden. Die anderen Methoden der Klasse Liste sind rein virtuelle
Funktionen. Sie müssen in der abgeleiteten Klasse konkretisiert, d.h. überschrieben
werden. Eine Funktion für das Einfügen von Listenelementen hängt von der
speziellen Ausprägung des Listenbehälters ab und kann damit erst in der
abgeleiteten Klasse festgelegt werden.
// Schnittstellenbeschreibung der abstrakten Klasse „Liste“
template <class T> class Liste
{
protected:
// Anzahl Listenelemente. Wird durch die abgeleiteten Klassen
// aktualisiert
int groesse;
public:
// Konstruktor
Liste(void);
// (virtueller Destruktor)
virtual ~Liste(void);
// Zugriffsmethoden
virtual int listenGroesse(void) const;
virtual int leereListe(void) const;
15
16
Eine Klasse, die mindestens eine rein virtuelle Funktion besitzt, heißt abstrakte Klasse.
liste.h
51
Algorithmen und Datenstrukturen
virtual int finden (T& item) = 0;
// Methoden zur Modifikation
virtual void einfuegen(const T& item) = 0;
virtual void loeschen(const T& item) = 0;
virtual void bereinigeListe(void) = 0;
};
// Methoden zu abstrakten Basisklasse Liste
Die Methoden zur Modifikation der Listen in den abgeleiteten Klassen müssen das
Datenelement groesse() verwalten. Der Konstruktor zur Klasse Liste setzt
„groesse“ auf 0. Da die Methoden listenGroesse() und leereListe() nur
vom Wert des Datenelementes groesse abhängen, werden sie in der Basisklasse
Liste implementiert und so von der abgeleiteten Klasse genutzt.
// Konstruktor (setzt die Listengroesse auf 0
template <class T>
Liste<T>::Liste(void): groesse(0)
{}
// Virtueller Destruktor, tut nichts
template <class T>
Liste<T>::~Liste(void)
{}
// Rueckgabe der Listengroesse
template <class T>
int Liste<T>::listenGroesse(void) const
{
return groesse;
}
// Test auf eine leere Liste
template <class T>
int Liste<T>::leereListe(void) const
{
return groesse == 0;
}
Die Ableitung der Klasse „seqListe“ (sequentielle Liste)17 aus der abstrakten
Basisklasse Liste
// Schnittstellenbeschreibung seqListe
Die Methoden einfuegen(), loeschen(), bereinigeListe() verwalten das
Datenelement groesse() der Basisklasse. Zwei spezielle Methoden
loescheVorn() und beschaffeDaten() ergänzen die Methoden der
Basisklasse.
template <class T>
class seqListe: public Liste<T>
{
protected:
// verkettetes Listenobjekt, verfuegbar in der abgeleiteten Klasse
verketteteListe<T> verkListe;
public:
// Konstruktor
seqListe(void);
// Zugriffsmethoden
17
vgl. seqliste.h
52
Algorithmen und Datenstrukturen
virtual int finden (T& merkmal);
T beschaffeDaten(int pos);
// Methoden zur Modifikation
virtual void einfuegen(const T& merkmal);
virtual void loeschen(const T& merkmal);
T loeschenVorn(void);
virtual void bereinigeListe(void);
};
// Methoden zur Klasse seqListe
Die Schnittstellenbeschreibung bestimmt: Die zugrunde liegende Datenstruktur ist
die verkettet gespeicherte Liste. Das verkettet vorliegende Listenobjekt beeinflußt
die Methoden der sequentiellen Liste: einfuegen(), loeschen(), finden().
Der Konstruktor der abgeleiteten Klasse ruft den Konstruktor der abstrakten Klasse
Liste auf. Dort wird groesse der Liste auf 0 gesetzt.
// Default-Konstructor. Initialisiert die Basisklasse
template <class T>
seqListe<T>::seqListe(void): Liste<T>()
{}
// Mit der Methode bereinigeListe wird die verkettete Liste zurueckgesetzt
template <class T>
void seqListe<T>::bereinigeListe(void)
{
verkListe.bereinigeListe();
groesse = 0;
}
// Benutze die Methode "einfuegenHinten" von "verketteteListe
template <class T>
void seqListe<T>::einfuegen(const T& merkmal)
{
verkListe.einfuegenHinten(merkmal);
groesse++;
// aktualisiere groesse in Liste
}
// Benutze die Methode "loeschenVorn" zum Entfernen des ersten Elements
// aus der Liste
template <class T>
T seqListe<T>::loeschenVorn(void)
{
groesse--;
return verkListe.loeschenVorn();
}
// Loesche Knoten mit dem Datenwert von "merkmal"
template <class T>
void seqListe<T>::loeschen(const T& merkmal)
{
int resultat = 0;
// Suchen nach "merkmal" in der Liste. Falls gefunden,
// setze resultat auf wahr
for(verkListe.ruecksetzen();!verkListe.endeListe();verkListe.naechstes())
if (merkmal == verkListe.Daten())
{
resultat++;
break;
}
// Falls "merkmal" gefunden wird, loesche den zugehoerigen Knoten
// erniedrige groesse
if (resultat)
{
verkListe.loeschenAn();
groesse--;
}
53
Algorithmen und Datenstrukturen
}
// gib den Datenwert von "merkmal" an der Position "pos" zurueck
template <class T>
T seqListe<T>::beschaffeDaten(int pos)
{
// Pruefen auf eine gueltige Position
if (pos < 0 || pos >= verkListe.listenGroesse())
{
cerr << "pos is out of range!" << endl;
exit(1);
}
// Setze die aktuelle Listenposition auf "pos" und gib "daten" zurueck
verkListe.ruecksetzen(pos);
return verkListe.Daten();
}
// "merkmal" ist Schluessel fuer das Durchsuchen der Liste. Falls
// "merkmal" in der Liste ist, ist der Rueckgabewert wahr, andernfalls
// falsch. Falls gefunden, weise das Listenelement dem Referenzparameter
// "merkmal" zu
template <class T>
int seqListe<T>::finden(T& merkmal)
{
int resultat = 0;
// Suche nach "merkmal" in der Liste. Falls gefunden, setze
// "resultat auf "wahr"
for(verkListe.ruecksetzen();!verkListe.endeListe();verkListe.naechstes())
if (merkmal == verkListe.Daten())
{
resultat++;
break;
}
// Falls wahr, aktualisiere "merkmal", gib wahr zurueck;
// andernfalls falsche
if (resultat) merkmal = verkListe.Daten();
return resultat;
}
Die geordnete Liste18
Die Klasse „seqListe“ erzeugt Listen, bei denen Listenknoten am Ende der Liste
eingefügt werden. Die Datenelemente der Listenknoten sind, vom Anfang zum Ende
der Liste betrachtet, ohne Ordnung. In vielen Anwendungen ist eine derartige
Ordnungsbeziehung gefordert. Die Klasse seqListe kann für eine Klasse, die eine
derartige Ordnung aufweist, Basisklasse sein. Zur Herstellung der
Ordnungsbeziehung wird hier nur der relationale Operator „<“ verwendet. Zur
Herstellung der Ordnungsbeziehung muß lediglich die Methode „einfuegen()“
überlagert werden.
// Schnittstellenbeschreibung geordneteListe
template <class T>
class geordneteListe: public seqListe<T>
{
public:
// Konstruktor
geordneteListe(void);
// ueberschreiben "einfuegen" zur Bildung einer geordneten Liste
virtual void einfuegen(const T& merkmal);
18
ordlist.h
54
Algorithmen und Datenstrukturen
// Ausgabe der Datenelemente der geordneten Liste
void ausgeordnListe();
};
// Methoden
Konstruktoren der Basisklasse werden nicht automatisch vererbt. Der Konstruktor
der Klasse geordneteListe ruft den Konstruktor der Klasse seqListe auf. Der
Konstruktor zu seqListe ruft seinerseits den Konstruktor der abstrakten
Basisklasse Liste auf.
// Konstuktor, initialisiert die Basisklasse
template <class T>
geordneteListe<T>::geordneteListe(void): seqListe<T>()
{}
Die Klasse geordneteListe definiert eine Funktion zum Einfügen der
Listenknoten an geeignete Positionen. Die Methoden zum Einfügen nutzt den von
der Klasse verketteteListe eingebauten Mechanismus der Suche nach dem
ersten Datenwert, der größer ist als der Wert des neu eingefügten Datums. Mit der
Methode einfuegenAn() wird dieser Datenwert, eingebettet in einen Listenknoten,
an die aktuelle Position eingekettet. Ist der neue Wert größer als alle bisher
vorliegenden Werte, dann wird dieser Datenwert an das Ende der Liste angehängt.
// Einfuegen´von "merkmal" in die Liste in aufsteigender Folge
template <class T>
void geordneteListe<T>::einfuegen(const T& merkmal)
{
// Benutze den Durchlaufmechanismus der verketteten Liste zum
// Lokalisieren des Einfuegepunkts
for(verkListe.ruecksetzen();!verkListe.endeListe();verkListe.naechstes())
if (merkmal < verkListe.Daten()) break;
// Einfuegen "merkmal" an der aktuellen Listenposition
verkListe.einfuegenAn(merkmal);
groesse++;
}
// Ausgabe der Daten der geordneten Liste
template <class T>
void geordneteListe<T>::ausgeordnListe()
{
for(verkListe.ruecksetzen();!verkListe.endeListe();verkListe.naechstes())
cout << verkListe.Daten() << " ";
}
Test19
void main(void)
{
geordneteListe<int> L;
int x[15] = {2, 7, 9, 13, 6, 11, 15, 17, 21, 22, 45, 5, 3, 51, 4};
int i;
for (i = 0; i < 15; i++)
L.einfuegen(x[i]);
cout << "geordnete Liste: ";
L.ausgeordnListe();
cout << endl;
19
PR22216.CPP
55
Algorithmen und Datenstrukturen
}
3. Listeniteratoren (Werkzeuge zum Durchlaufen von Listen)
Die abstrakte Basisklasse Iterator
In einem Feld (array) bzw. einer sequentiellen Liste) wird ein Listenobjekt häufig mit
folgender Schleife zum Listendurchlauf bearbeitet:
for (pos = 0; pos < L.listenGroesse(); pos++)
cout << L.beschaffeDaten() << “ “;
Methoden, die Datenstrukturen (z.B. Listen, Bäume, Tabellen) vom Ausgangspunkt
der Struktur bis zum Ende durchlaufen, können in Klassen, sog. Iterator-Klassen,
zusammengefaßt werden. Ein Objekt der Klasse Iterator hat die Aufgabe,
Datenstrukturen, z.B. binäre Bäume, verkettete Listen zu durchlaufen. Der Iterator
benutzt die Methoden naechstes() bzw. endeListe() zum Durchlaufen von
Knoten der Struktur. Der Anwender des Iterators braucht sich um die Verwaltung der
Indexpositionen bzw. Zeiger, die für das Durchlaufen der Struktur benötigt werden,
nicht zu kümmern. Meistens wird die Iterator-Klasse als Freund in die Listenklasse
zur Lösung des Zugriffsproblems auf private Elemente einbezogen.
// Schnittstellenbeschreibung einer allgemeinen abstrakten Iterator// Basisklasse
template <class T> class Iterator
{
protected:
// zeigt an, ob der Iterator das Ende der Liste erreicht hat.
// muss in der abgeleiteten Klasse verwaltet werden
int iterationAbschluss;
public:
// Konstruktor
Iterator(void);
// Iterator Methoden
virtual void naechstes(void) = 0;
virtual void ruecksetzen(void) = 0;
// Datenwiedergewinnungs-/Modifikations-Methoden
virtual T& Daten(void) = 0;
// test auf das Ende der Liste
virtual int endeListe(void) const;
};
Die abstrakte Klasse besitzt ein einziges Datenelement: iterationAbschluss.
Dieses Datenelement muß über die Methoden ruecksetzen() bzw.
naechstes() in jeder abgeleiteten Klasse verwaltet werden. Nur der Konstruktor
der abstrakten Basisklasse muß konkretisiert werden.
// Konstruktor, setzt iterationAbschluss auf 0 (Falsch)
template <class T>
Iterator<T>::Iterator(void): iterationAbschluss(0)
{}
// gibt den Wert von iterationAbschluss zurueck.
template <class T>
int Iterator<T>::endeListe(void) const
{
56
Algorithmen und Datenstrukturen
return iterationAbschluss;
}
Listeniterator für eine sequentielle Liste20
Er wird abgeleitet aus der vorliegenden abstrakten Basisklasse und enthält einen
Zeiger (listenZgr), der auf die aktuell angelaufene Listenposition verweist.
// Schnittstellenbeschreibung
// seqListenIterator abgeleitet aus der abstrakten Klasse Iterator
template <class T>
class
: public Iterator<T>
{
private:
// Verwalten eines lokalen Zeigers bezogen auf seqListe,
// die hier durchlaufen wird
seqListe<T> *listenZgr;
// Vorgaenger- und aktuelle Position muss beim Listendurchlauf
// verwaltet werden
Knoten<T> *vorgZgr, *aktZgr;
public:
// Konstruktor
seqListenIterator(seqListe<T>& lst);
// Durchlaufmethoden
virtual void naechstes(void);
virtual void ruecksetzen(void);
// Datenwiedergewinnungs-/modifikationsmethoden
virtual T& Daten(void);
// Ruecksetzen des Iterator zum Durchlaufen einer neuen Liste
void setzeListe(seqListe<T>& lst);
};
// Methoden
// Konstruktor. Initialisieren Basisklasse and Zeiger auf seqListe
Der Iterator wird durch die Konstruktion der sequentiellen Listeniteratorklasse in
eine bestimmte sequentielle Liste gebunden. Die folgenden Operationen arbeiten
mit dieser Liste. Der Iterator verwaltet den Zeiger listenZgr, der zum
sequentiellen Listenobjekt die Verbindung herstellt. Die aktuelle Position wird auf
den Anfang der sequentiellen Liste gesetzt.
seqListenIterator:listenZgr
verkListe
vorgZgr
20
aktZgr
seqliste.h
57
Algorithmen und Datenstrukturen
Abb.: Die Datenstruktur zur Klasse seqListenIterator
template <class T>
seqListenIterator<T>::seqListenIterator(seqListe<T>& lst):
Iterator<T>(), listenZgr(&lst)
{
// Rechne mit der Tatsache, dass die Liste leer ist
iterationAbschluss = listenZgr->verkListe.leereListe();
// Positioniere den Iterator auf den Listenanfang
ruecksetzen();
}
// Naechstes Listenelement
Von Element zu Element wird sich durch die Liste bewegt, bis schließlich das Ende
der Liste erreicht wird. Diese Bedingung wird über iterationAbschluss
angezeigt. Die Methode naechstes() verwaltet dieses Datenelement.
template <class T>
void seqListenIterator<T>::naechstes(void)
{
// Falls aktZgr NULL ist, wurde das Ende der Liste erreicht
if (aktZgr == NULL) return;
// Bewege vorgZgr/aktZgr vorwaerts um einen Knoten
vorgZgr = aktZgr;
aktZgr = aktZgr->NextNode();
// Falls das Ende der geketteten Liste erreicht ist, signalisiere
// das Ende der Iteration
if (aktZgr == NULL) iterationAbschluss = 1;
}
// Gehe zum Listenanfang
Die Methode ruecksetzen() initialisiert iterationAbschluss und setzt die
Zeiger vorgZgr bzw. aktZgr auf die Listenanfangsposition. Die Klasse
seqListenIterator ist „Freund“ der Klasse verketteteListe und besitzt
Zugriff auf die Datenelemente dieser Klasse
template <class T>
void seqListenIterator<T>::ruecksetzen(void)
{
// stellt den Anfangszustand fuer den Listendurchlauf her
iterationAbschluss = listenZgr->verkListe.leereListe();
// Falls die Liste leer ist Rueckkehr
if (listenZgr->verkListe.vorn == NULL) return;
// Bewege den Listendurchlaufmechanismus auf den ersten Knoten
vorgZgr = NULL;
aktZgr = listenZgr->verkListe.vorn;
}
// Rueckgabe des Datenwerts vom aktuellen Listenknoten
Mit dem Iterator kann Datenzugriff auf die Listenknoten über die Methode Daten()
erzielt werden.
template <class T>
T& seqListenIterator<T>::Daten(void)
58
Algorithmen und Datenstrukturen
{
// Fehler, falls die Liste leer ist oder durchlaufen ist
if (listenZgr->verkListe.leereListe() || aktZgr == NULL)
fehler("Daten: ungueltige Referenz!");
return aktZgr->daten;
}
// Setzen des Iterator zum Durchlaufen einer neuen Liste
Die Methode setzeListe() übernimmt ein „sequentielles Listenobjekt“, der
Iterator sorgt für das Durchlaufen dieser Liste.
template <class T>
void seqListenIterator<T>::setzeListe(seqListe<T>& lst)
{
listenZgr = &lst;
// Positionieren zum Durchlauf auf das erste Listenelement
ruecksetzen();
}
59
Algorithmen und Datenstrukturen
2.2.2.2 Doppelt gekettete Listen
Bei Listenelementen, die ein uneingeschränktes Durchlaufen einer linearen Liste in
beiden Richtungen erforderlich machen, ist die doppelt verkettete Liste die
angemessene Datenstruktur:
Abb.: Aufbau „doppelt gekettete Listen“
60
Algorithmen und Datenstrukturen
2.2.2.3 Einfach und doppelt gekettete Ringe
1. Einfach gekette Ringstruktur
Aufbau einfach geketteter Ringstrukturen
BASIS
"Leerer Ring"
BASIS
Abb.:
Eine leere ringförmig verkettete Liste enthält eine Listenknoten und ein nicht
initialisiertes Datenfeld. Der Zeiger auf diesen Listenknoten zeigt auf sich selbst. Ein
„Null“-Zeiger existiert in ringförmig verketteten Listen nicht.
Gegeben ist folgende Listenstruktur
ZGR
ZGR1
Abb.:
Eine ringförmige Datenstruktur kann durch die Anweisung
ZGR1->Nachf := ZGR;
erreicht werden. In der Regel zeigt der letze Knoten in der verkettet gespeicherten
Liste auf den Listenanfang. Ringe können auch folgenden Aufbau besitzen:
Abb.:
61
Algorithmen und Datenstrukturen
Die Klasse „einfach verketteter Ringknoten“ in C++21
// Deklaration Listenknoten
template <class T>
class ringKnoten
{
private:
// ringfoermige Verkettung auf den naechsten Knoten
ringKnoten<T> *nachf;
public:
// "daten" im oeffentlichen Zugriffsbereich
T daten;
// Konstruktoren
ringKnoten(void);
ringKnoten (const T& merkmal);
// Listen-Modifikationsmethoden
void einfuegenDanach(ringKnoten<T> *z);
ringKnoten<T> *loeschenDanach(void);
// beschafft die Adresse des (im Ring) folgenden Knoten
ringKnoten<T> *nachfKnoten(void) const;
};
Die Struktur einer einfach veketteten, ringförmig geschlossenen Liste kann so
dargestellt werden:
daten:
nachf:
Abb.: leere Liste
daten:
nachf:
Abb.: Liste mit Knoten
// Schnittstellenfunktionen
Der Konstruktor initialisiert einen Knoten, der einen Zeiger enthält, der auf diesen
Knoten zurück verweist. So kann jeder Knoten den Anfang einer leeren Liste
repräsentieren. Das Datenfeld des Knoten bleibt in diesem Fall unbesetzt.
// Konstruktor der eine Liste deklariert und "daten"
// uninitialisiert laesst.
template <class T>
ringKnoten<T>::ringKnoten(void)
{
// initialisiere den Knoten, so dass er auf sich selbst zeigt
nachf = this;
}
21
vgl. ringkno.h
62
Algorithmen und Datenstrukturen
// Konstruktor der eine leere Liste erzeugt und "daten"
// initialisiert
template <class T>
ringKnoten<T>::ringKnoten(const T& merkmal)
{
// setze den Knoten so, dass er auf sich selbst zeigt
// und initialisiere "daten"
nachf = this;
daten = merkmal;
}
Die Methode nachfKnoten() ermittelt einen Verweis auf den nächsten in der
einfach verketteten, ringförmig geschlossenen Liste. Die Methode soll das
Durchlaufen der Liste erleichtern.
// Rueckgabe des Zeiger auf den naechsten Knoten
template <class T>
ringKnoten<T> *ringKnoten<T>::nachfKnoten(void) const
{
return nachf;
}
Die Methoden zur Modifikation der Liste einfuegenDanach(ringKnoten<T>
*z); fügt die Listenknoten unmittelbar nach dem Anfangsknoten (, der die leere
Liste definiert,) ein.
vor dem Einfügen:
nach dem Einfügen:
daten:
nachf:
Z
Abb.: Einfügen des Knoten „z“ in eine leere Liste
vor dem Einfügen:
nach dem Einfügen:
daten:
nachf:
Z
Abb.: Einfügen des Knoten „z“ in ein einfach gekettete, ringförmig geschlossene Liste mit Listenknoten
// Einfuegen eines Knoten z nach dem aktuellen Knoten
template <class T>
void ringKnoten<T>::einfuegenDanach(ringKnoten<T> *z)
{
// z zeigt auf den Nachfolger des aktuellen Knoten,
// der aktuellen Knoten zeigt auf z
z->nachf = nachf;
nachf = z;
}
Die Methode loeschenDanach() löscht den Listenknoten unmittelbar nach dem
aktuellen Knoten.
63
Algorithmen und Datenstrukturen
// Loesche den Knoten, der dem aktuellen Knoten folgt und gib seine
// Adresse zurueck
template <class T>
ringKnoten<T> *ringKnoten<T>::loeschenDanach(void)
{
// Sichere die Adresse des Knoten, der geloescht werden soll
ringKnoten<T> *tempZgr = nachf;
// Falls "nachf" mit der Adresse des aktuellen Objekts (this) ueberein// stimmt, wird auf sich selbst gezeigt. Hier darf nicht geloescht werden
// (Rueckgabewert NULL)
if (nachf == this) return NULL;
// Der aktuelle Knoten zeigt auf denNachfolger von tempZgr.
nachf = tempZgr->nachf;
// Gib den Zeiger auf den ausgeketteten Knoten zurueck
return tempZgr;
}
Anwendung: Das „Josephus-Problem“
Aufgabenstellung: Ein Reisebüro verlost eine Weltreise unter „N“ Kunden. Dazu werden die Kunden
von 1 bis N durchnummeriert, eine Bediensteter des Reisebüros hat in einem Hut N Lose
untergebracht. Ein Los wird aus dem Hut gezogen, es hat die Nummer M (1 <= M <= N). Zur
Auswahl des glücklichen Kunden stellt man sich dann folgendes vor: Die Kunden (identifiziert durch
die Nummern 1 bis N) werden in einem Kreis angeordnet und mit Hilfe der gezogenen Losnummer
aus diesem Kreis entfernt. Bei bspw. 8 Kunden und der gezogenen Losnummer 3 werden, da das
Abzählen bzw. Entfernen im Uhrzeigersinn erfolgt, folgende Nummern aus dem Kreis entfernt: 3, 6,
1, 5, 2, 8, 4. Die Person 7 gewinnt die Reise.
Lösung22:
#include <iostream.h>
#include <stdlib.h>
#include "ringkno.h"
// Erzeuge eine ringfoermig verkettete Liste mit gegebenem Anfang
void erzeugeListe(ringKnoten<int> *anfang, int n)
{
// Beginn des Einfuegevorgangs
ringKnoten<int> *aktZgr = anfang, *neuerKnotenWert;
int i;
// Erzeuge die n Elemente umfassende ringfoermige Liste
for(i=1;i <= n;i++)
{
// Belege den Knoten mit Datenwert
neuerKnotenWert = new ringKnoten<int>(i);
// Einfuegen am Listenende
aktZgr->einfuegenDanach(neuerKnotenWert);
aktZgr = neuerKnotenWert;
}
}
// Gegeben ist eine n Elemente umfassende, ringfoermige Liste; loese das
// Josephus-Problem durch Loeschen jeder m. Person bis nur
// eine Person uebrig bleibt
void Josephus(ringKnoten<int> *liste, int n, int m)
{
// vorgZgr bewegt aktZgr durch die Liste
ringKnoten<int> *vorgZgr = liste, *aktZgr = liste->nachfKnoten();
ringKnoten<int> *geloeschterKnotenZgr;
22
PR22221.CPP
64
Algorithmen und Datenstrukturen
// Loesche alle bis auf eine Person aus der Liste
for(int i=0;i < n-1;i++)
{
// Zaehle die Personen jeweils an der aktuelle Stelle
// Suche m Personen auf
for(int j=0;j < m-1;j++)
{
// Ausrichten der Zeiger
vorgZgr = aktZgr;
aktZgr = aktZgr->nachfKnoten();
// Falls "aktZgr am Anfang steht, bewege die Zeiger weiter
if (aktZgr == liste)
{
vorgZgr = liste;
aktZgr = aktZgr->nachfKnoten();
}
}
cout << "Loesche Person " << aktZgr->daten << endl;
// Ermittle den zu loeschenden Knoten und aktualisiere aktZgr
geloeschterKnotenZgr = aktZgr;
aktZgr = aktZgr->nachfKnoten();
// loesche den Knoten aus der Liste
vorgZgr->loeschenDanach();
delete geloeschterKnotenZgr;
// Falls aktZgr am Anfang steht, bewege Zeiger weiter
if (aktZgr == liste)
{
vorgZgr = liste;
aktZgr = aktZgr->nachfKnoten();
}
}
cout << endl << "Ausgezaehlt wurde " << aktZgr->daten << endl;
// Loesche den uebrig gebliebenen Knoten
geloeschterKnotenZgr = liste->loeschenDanach();
delete geloeschterKnotenZgr;
}
void main(void)
{
// Liste mit Personen
ringKnoten<int> liste;
// n ist die Anzahl der Personen, m ist die Abzaehlgroesse
int n, m;
cout << "Anzahl Bewerber? ";
cin >> n;
// Erzeuge eine ringfoermig gekettete Liste mit Personen 1, 2, ... n
erzeugeListe(&liste,n);
// Zufallswert: 1 <= m <= n
randomize();
m = 1 + random(n);
cout << "Erzeugte Zufallszahl " << m << endl;
// loese das Josephus Problem und gib den Gewinner aus
Josephus(&liste,n,m);
}
/*
<Ablauf des Programms>
Anzahl der Bewerber? 10
Erzeugte Zufallszahl 5
Loesche Person 5
Loesche Person 10
Loesche Person 6
Loesche Person 2
Loesche Person 9
Loesche Person 8
Loesche Person 1
Loesche Person 4
Loesche Person 7
65
Algorithmen und Datenstrukturen
Person 3 gewinnt.
*/
2. Doppelt gekettete Ringstruktur
Basis
Abb.: Doppelt gekettete Ringstruktur
Leerer Ring
66
Algorithmen und Datenstrukturen
Basis
Abb.: Der leere Ring in einer doppelt geketteten Ringstruktur
Implementierung in C++
Doppelt verkettete Listen erweitern den durch ringförmig verkettete Listen
bereitgestellten Leistungsumfang beträchtlich. Sie erleichtern das Einfügen und das
Löschen durch Zugriffsmöglichkeinten in zwei Richtungen:
links
daten
rechts
......
.....
4
1
2
3
Abb.:
Klassenschablone „doppelt verketteter RingKnoten“23
template <class T> class dkringKnoten
{
private:
// ringfoermig angeornete Verweise nach links und rechts
dkringKnoten<T> *links;
dkringKnoten<T> *rechts;
public:
// daten steht unter oeffentlichem Zugriff
T daten;
// Konstruktoren:
dkringKnoten(void);
dkringKnoten (const T& merkmal);
// Modifikation der Listen
void einfuegenRechts(dkringKnoten<T> *z);
void einfuegenLinks(dkringKnoten<T> *z);
dkringKnoten<T> *loescheKnoten(void);
// Beschaffen der Adressen der nachfolgenden Knoten auf der
// linken und rechten Seite
dkringKnoten<T> *nachfKnotenRechts(void) const;
dkringKnoten<T> *nachfKnotenLinks(void) const;
};
Methoden für doppelt verketteten Listenknoten einer ringförmig geschlossenen Liste
23
dringkn.h
67
Algorithmen und Datenstrukturen
Konstruktoren
// Konstruktor: erzeugt eine leere Liste, das Datenfeld bleibt
// ohne Initialisierung; wird zur Definition des Listenanfangs benutzt
template <class T>
dkringKnoten<T>::dkringKnoten(void)
{ // ínitialisiert den Knoten mit einem Zeiger, der auf den
// Knoten zeigt
links = rechts = this;
}
// Konstruktor: erzeugt eine leere Liste und intialisierte das Feld daten
template <class T>
dkringKnoten<T>::dkringKnoten(const T& merkmal)
{ // initialisiert den Knoten mit einem Zeiger der
// auf den Knoten zeigt und initialisiert das Datenfeld
links = rechts = this;
daten = merkmal;
}
Einfügen eines Knoten
// Fuege einen Knoten z rechts zum aktuellen Knoten ein
template <class T>
void dkringKnoten<T>::einfuegenRechts(dkringKnoten<T> *z)
{ // kette z zu seinem Nachfolger auf der rechten Seite ein
z->rechts = rechts;
rechts->links = z;
// verkette z mit dem aktuellen Knoten auf seiner linkten Seite
z->links = this;
rechts = z;
}
// Fuege einen Knoten z links zum aktuellen Knoten ein
template <class T>
void dkringKnoten<T>::einfuegenLinks(dkringKnoten<T> *z)
{ // kette z zu seinem Nachfolger auf der linken Seite ein
z->links = links;
links->rechts = z;
// verkette z mit dem aktuellen Knoten auf seiner rechten Seite
z->rechts = this;
links = z;
}
Löschen
// Ausketten des aktuellen Knoten aus der Liste
template <class T>
dkringKnoten<T> *dkringKnoten<T>::loescheKnoten(void)
{
// Knotenverweis "links" muss verkettet werden mit dem
// Verweis des aktuellen Knoten nach rechts
links->rechts = rechts;
// Knotenverweis "rechts" muss verkettetet werden mit dem
// Verweis des aktuellen Knoten nach links
rechts->links = links;
// Rueckgabe der Adresse vom aktuellen Knoten
return this;
}
Bestimmen der nachfolgenden Knoten
68
Algorithmen und Datenstrukturen
// Rueckgabe Zeiger zum naechsten Knoten auf der rechten Seite
template <class T>
dkringKnoten<T> *dkringKnoten<T>::nachfKnotenRechts(void) const
{
return rechts;
}
// Rueckgabe Zeiger zum naechsten Knoten auf der linken Seite
// return pointer to the next node on the left
template <class T>
dkringKnoten<T> *dkringKnoten<T>::nachfKnotenLinks(void) const
{
return links;
}
Anwendung: Einfügen eines doppelt verketteten Listenknoten in eine geordnete Fole
von Listenknoten24
Falls der Aufbau einer geordneten Folge von doppelt verketteten Listenknoten im
Rahmen einer ringförmig verketteten Liste gelingt, kann die Liste in Vorwärtsrichtung
(links) durchlaufen bzgl. der in den Listenknoten gespeicherten Daten eine
aufsteigende Sortierung zeigen und ,in Rückwärtsrichtung (rechts) durchwandert,
eine absteigende Sortierung aufweisen. Mit zwei Funktionsschablonen
einfuegenKleiner() und einfuegenGroesser() soll dies erreicht werden.
Zum Aufbau der ringförmig, doppelt verketteten Liste wird die Funktionsschablone
DverkSort()
herangezogen,
die
zum
geordneten
Einfügen
die
Funktionsschablone einfuegenKleiner() und einfuegenGroesser() benutzt
und den Anfangszeiger „dkAnfang“ verwaltet.
template <class T>
void einfuegenKleiner(dkringKnoten<T> *dkAnfang,
dkringKnoten<T>* &aktZgr, T merkmal)
{
dkringKnoten<T> *neuerKnoten= new dkringKnoten<T>(merkmal), *z;
// Bestimme den Einfuegepunkt
z = aktZgr;
while (z != dkAnfang && merkmal < z->daten) z = z->nachfKnotenLinks();
// Einfuegen des Knotens mit dem Datenelement
z->einfuegenRechts(neuerKnoten);
// Ruecksetzen aktZgr auf den neuen Knoten
aktZgr = neuerKnoten;
}
template <class T>
void einfuegenGroesser(dkringKnoten<T>* dkAnfang,
dkringKnoten<T>* & aktZgr, T merkmal)
{
dkringKnoten<T> *neuerKnoten= new dkringKnoten<T>(merkmal), *z;
// Bestimmen des Einfuegepunkts
z = aktZgr;
while (z != dkAnfang && z->daten < merkmal) z = z->nachfKnotenRechts();
// Einfuegen des Datenelements
z->einfuegenLinks(neuerKnoten);
// Ruecksetzen des aktuellen Zeigers auf neuerKnoten
aktZgr = neuerKnoten;
}
template <class T>
24
PR22225.CPP
69
Algorithmen und Datenstrukturen
void DverkSort(T a[], int n)
{ // Die doppelt verkettete Liste soll Feld-Komponenten aufnehmen
dkringKnoten<T> dkAnfang, *aktZgr;
int i;
// Einfuegen des ersten Elements in die doppelt verkettete Liste
dkringKnoten<T> *neuerKnoten = new dkringKnoten<T>(a[0]);
dkAnfang.einfuegenRechts(neuerKnoten);
aktZgr = neuerKnoten;
// Einbrigen weiterer Elemente in die doppelt verkettete Liste
for (i = 1; i < n; i++)
if (a[i] < aktZgr->daten) einfuegenKleiner(&dkAnfang,aktZgr,a[i]);
else einfuegenGroesser(&dkAnfang,aktZgr,a[i]);
// Durchlaufe die Liste und kopiere die Datenwerte zurueck in den "array"
aktZgr = dkAnfang.nachfKnotenRechts();
i = 0;
while(aktZgr != &dkAnfang)
{
a[i++] = aktZgr->daten;
aktZgr = aktZgr->nachfKnotenRechts();
}
// Loesche alle Knoten in der Liste
while(dkAnfang.nachfKnotenRechts() != &dkAnfang)
{
aktZgr = (dkAnfang.nachfKnotenRechts())->loescheKnoten();
delete aktZgr;
}
}
Der folgende Hauptprogrammabschnitt ruft die vorliegende Funktionsschablone zum
Sortieren eines Arbeitsspeicherfelds auf
void main(void)
{ // Ein initialisierter "array" mit 10 Ganzzahlen
int A[10] = {82,65,74,95,60,28,5,3,33,55};
DverkSort(A,10);
// sortiere "array"
cout << "Sortiertes Feld:
";
for(int i=0;i < 10;i++) cout << A[i] << " "; cout << endl;
}
70
Algorithmen und Datenstrukturen
2.3 Tabellen (dictionaries)
Tabellen (mehr unter dem englischen Namen „dictionary“ bekannt) sind assoziative
Strukturen, in denen über einen Schlüssel auf Datenwerte zugegriffen werden kann.
Aus dem Schlüssel kann direkt eine Listenposition ermittelt werden, unter der die
Datenwerte eingetragen sind („associative array“). Solche paarweise auftretenden
Daten können in einer verketteten Liste, einem Baum oder in einer Hash-Tabelle
gespeichert werden. Ist die Speicherung geordnet nach Schlüsselwerten erfolgt,
dann liegt eine geordnete Tabelle vor.
Implementierung in Java
Die Implementierung erfolgt über das Interface 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.
71
Algorithmen und Datenstrukturen
<< 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.: Interface Map, SortedMap und implemetierend Klassen
72
Algorithmen und Datenstrukturen
2.3.1 Einfache Tabellen
Eine einfache Tabelle speichert ihre Elemente in einer sequentiellen Liste. Der
Zugriff erfolgt über einen Schlüssel.
2.3.2 Sortierte Tabellen
Die sortierte Tabelle ist eine einfache Tabelle, die beim Einfügen von
Tabellenelementen dafür sorgt, daß zwischen den Schlüsseln der Tabelle eine
Ordnung (aufsteigend oder absteigend) erhalten bleibt.
Implementierung in C++
Die folgenden Datentypen, Datenstrukturen bzw. abstrakten Datentypen werden zum
Aufbau einer nach einem Schlüsselwert „sortierten Tabelle“ benötigt:
typedef int BOOL;
const int FALSE = 0;
const int TRUE = 1;
enum ordnung {VOR = -1, GLEICH = 0, NACH = 1, OHNE = -10};
Der Aufzählungstyp „ordnung“ hält fest, ob ein Element „e1“ VOR einem Element
„e2“ bzw. NACH einem Element „e2“ kommt bzw. das Element „e1“ GLEICH einem
Element „e2“ ist. Eine Vergleichsfunktion, die nach dem Vergleich von zwei
Zeichenketten die jeweilige Ordnungszahl (VOR, GLEICH, NACH) zurückgibt, umfaßt:
int gleich(char* e1, char* e2)
{
int i = strcmp(e1,e2);
if (i < 0) return VOR;
else if (i == 0) return GLEICH;
else if (i > 0) return NACH;
return OHNE;
}
Zur zentralen Behandlung der Fehler bzw. Warnungen sind zwei Funktionen
vorgegeben, die die später zu definierenden Methoden der sortierten Tabelle
verwenden sollen.
enum aktion {IGNORIERT,WARNUNG, FEHLER, ERSETZT};
static void fehler(char* nachricht)
{
cerr << "Fehler: " << nachricht << "!\n";
exit(1);
}
static void warnen(char* nachricht)
{
cerr << "Warnung: " << nachricht << "!\n";
}
73
Algorithmen und Datenstrukturen
Zur Aufnahme in der sortierten Tabelle sind Zeiger auf die folgende Struktur
vorgesehen:
struct eintrag
{
private:
char* name;
unsigned nummer;
public:
eintrag(char*,unsigned);
friend char* holeSchl(eintrag&);
friend BOOL gleich(eintrag&, eintrag&);
friend ostream& operator <<(ostream&, eintrag&);
};
Die Struktur „eintrag“ erhält Deklarationen zu einem Konstruktor und drei „friend“Funktionen:
Konstruktor
eintrag :: eintrag(char* nm, unsigned nr)
{
int laenge = strlen(nm);
name = new char[laenge + 1];
strcpy(name,nm);
nummer = nr;
}
„char* holeSchl(eintrag& e)“ beschafft den Schlüsselwert im „eintrag“. Der
Schlüsselwert ist in der Struktur „eintrag“ unter der Komponente „name“ enthalten.
char* holeSchl(eintrag& e)
{
return e.name;
}
„int gleich(eintrag& e1, eintrag& e2)“ prüft, ob 2 Schlüsselwerte der Struktur
„eintrag“ „gleich“ sind.
int gleich(eintrag& e1, eintrag& e2)
{
return gleich(e1.name, e2.name);
}
Ausgabeoperation für die Struktur „eintrag“
ostream& operator <<(ostream& strm, eintrag& e)
{
return strm << e.name << ' ' << e.nummer;
}
Die Schnittstellenbeschreibung der sortierten Tabelle umfaßt die folgenden
Klassenschablone:
template <class elt, class schl> class sortTabelle
{
// Darstellung
74
Algorithmen und Datenstrukturen
private:
int belegung;
// maximal moegliche Belegung der Tabelle
int eltAnz;
// zeigt an, wie die Tabelle gefuellt ist
elt* elemente; // umfasst die Datenstruktur
// Initialisieren / Abschliessen
void init();
void ausdehnen();
public:
sortTabelle(int = 10);
// Konstruktor (Initialisieren)
~sortTabelle();
// Destruktor
// Zugriff / Modifikation
private:
int suchePosition(schl);
void einfuegenvorn(elt);
void einfuegen(elt,int);
public:
elt suche(schl);
// elt operator[](schl& k);
void hinzufuegen(elt, aktion aktBearb = IGNORIERT);
sortTabelle<elt, schl>& operator+=(elt);
private:
int aktpos;
// Aktuelle Position zur Verarbeitung
// von Tabellenelementen
public:
void ruecksetzen();
BOOL beendet();
BOOL naechstes(); // bestimmt die jeweils naechste Position
elt aktuell();
// Zugriff auf das aktuelle Verarbeitungselement
// int index();
// Attribute
BOOL leer();
BOOL voll();
int groesse();
int belegteGroesse();
double fuellung();
// Vergleiche
friend BOOL gleich(const sortTabelle<elt, schl>&,
const sortTabelle<elt, schl>&);
friend BOOL subtabelle(const sortTabelle<elt, schl>&,
const sortTabelle<elt, schl>&);
// Kopieren
private:
void kopiere(const sortTabelle<elt, schl>& tbl);
public:
sortTabelle(const sortTabelle<elt, schl>& tbl);
sortTabelle<elt, schl>&
operator=(const sortTabelle<elt, schl>& tbl);
// Verarbeitung
BOOL enthaelt(elt);
BOOL enthaeltGleich(elt);
// Ausgabe
friend ostream& operator<<(ostream&,
const sortTabelle<elt, schl>&);
};
Es ist vorgesehen, daß Zeiger auf Elemente in die sortierte Tabelle aufgenommen
werden, die aus einem Datensatz (vom Typ „elt“) bestehen. Der Datensatz enthält
eine Schlüsselkomponente (vom Datentyp „schl“). Ein derartiger Datensatz könnte
dann bspw. vom Typ „eintrag“ sein. Zugriffs- und Vergleichsfunktionen zu diesem
Datensatz sind im vorliegenden Fall zu „eintrag“ bekannt und können mit den dort
vereinbarten Funktionsnamen aufgerufen werden. Man kann sich bei der folgenden
75
Algorithmen und Datenstrukturen
Aufgabe direkt auf „eintrag“ beziehen. Zeiger auf diese Einträge werden in einem
„array elemente“ aufgenommen.
Eine Instanz zur sortierten Tabelle erhält man über den Aufruf des Konstruktors, der
zur Initialisierung die private Member-Funktion init() nutzt.
template <class elt, class schl>
void sortTabelle<elt, schl> :: init()
{
elemente = new elt[belegung];
if (0 == elemente)
fehler("[sortTabelle::init] Speicherbeschaffung");
for (int i = 0; i < belegung; i++) elemente[i] = 0;
}
template <class elt, class schl>
sortTabelle<elt, schl> :: sortTabelle(int gr) :
belegung(gr), eltAnz(0)
{
init();
}
template <class elt, class schl>
sortTabelle<elt, schl> :: ~sortTabelle()
{
delete [] elemente;
}
Der Destruktor zur „sortTabelle“ hat folgende Gestalt:
template <class elt, class schl>
sortTabelle<elt, schl> :: ~sortTabelle()
{
delete [] elemente;
}
Zur Aufname von „Einträgen“ in die sortierte Tabelle dient die Member-Funktion
hinzufuegen(). Die „Einträge“ sollen so in „elemente“ eingefügt werden, daß
dieser „array“ bzgl. seiner Komponenten, adressiert vom Index 0 bis zu „eltAnz“,
so auf „Einträge“ verweist, daß die Schlüsselworte dieser „Einträge“ eine
aufsteigende Sortierreihenfolge ergeben. Das „Einfügen“ ist nach dem Prinzip
„Sortieren durch Einfügen“ organisiert, d.h.: Es ist die Position der Komponenten zu
ermitteln, unter der der neue Eintrag eingefügt werden muß. Ist dieser Platz bereits
belegt, dann sind die Komponenten von dieser Position an „nach hinten“ zu
verschieben.
Zum „einfuegen()“ ist demnach zunächst die Position zu bestimmen. Das
geschieht über die Funktion „suchePosition()“, die nach dem Prinzip der binären
Suche vorgeht.
// private:
template <class elt, class schl>
int sortTabelle<elt, schl> :: suchePosition(schl s)
{
if (eltAnz == 0) return 0;
int mitte, unten = 0, oben = eltAnz - 1;
ordnung ord;
while (unten < oben)
{
76
Algorithmen und Datenstrukturen
mitte = (unten + oben) / 2;
// cout << holeSchl(*elemente[mitte]) << endl;
ord
= (ordnung) gleich(s, holeSchl(*elemente[mitte]));
if (ord == GLEICH) return mitte;
if (ord == VOR) oben = mitte -1;
else unten = mitte + 1;
}
return unten;
}
Das eigentliche „einfuegen()“ kann, nachdem die Position, unter der eingefügt
werden soll, bekannt ist, implementiert werden.
// private:
template <class elt, class schl>
void sortTabelle<elt, schl> :: einfuegen(elt e, int pos)
{
if (eltAnz == belegung) ausdehnen();
for (int i = eltAnz; i >= pos; i--)
{
elemente[i+1] = elemente[i];
}
elemente[pos] = e;
eltAnz++;
}
Nachdem suchePosition() und einfuegen() für die Implementierung definiert
sind, kann die Funktion hinzufuegen() formuliert werden. Sie legt anhand des
Vergleichs „Schlüsselwert des einzufügenden Eintrags“ zu „Schlüsselwert des an
der gefundenen Position bisher vorliegenden Eintrags“ fest, ob VOR bzw. NACH der
gefundenen Position der Eintrag erfolgen muß. Im Fehlerfall erfolgt der Aufruf von
„fehler()“. Falls der Schlüssel schon in der Tabelle vorkommt, kommt es zur
Ausgabe einer „warnung()“. Bei GLEICH (Schlüssel ist in der sortierten Tabelle
schon vorhanden) kommt es in Abhängigkeit vom aktuellen Bearbeitungszustand
„aktBearb“ zu einer Verzweigung. Hat „aktBearb“ den Wert
- FEHLER, erfolgt der Aufruf von fehler()
- WARNUNG, erfolgt der Aufruf von warnen()
- ERSETZT, IGNORIERT erfolgt die Aufnahme der Schlüssel in die Tabelle.
template <class elt, class schl>
void sortTabelle<elt, schl> :: einfuegenvorn(elt e)
{
elemente[0] = e;
eltAnz++;
}
template <class elt, class schl>
void sortTabelle<elt, schl> :: hinzufuegen(elt e, aktion aktBearb)
{
if (eltAnz == 0)
{
einfuegenvorn(e);
return;
}
// int stop;
int pos = suchePosition(holeSchl(*e));
switch (gleich(holeSchl(*e),holeSchl(*elemente[pos])))
77
Algorithmen und Datenstrukturen
{
case VOR:
{
einfuegen(e,pos);
break;
}
case NACH:
{
einfuegen(e,pos+1);
break;
}
case OHNE:
fehler("[sortTabelle::hinzufuegen] "
" Vergleich fuehrt auf keine Ordnung ");
case GLEICH:
switch(aktBearb)
{
case FEHLER:
fehler("[sortTabelle::hinzufuegen] "
" Die sortTabelle enthaelt schon "
" einen Eintrag mit diesem Schluessel");
/* error exits, so no break */
case WARNUNG:
warnen("[sortTabelle::hinzufuegen] "
" Die sortTabelle enthaelt schon "
" einen Eintrag mit diesem Schluessel");
/* no break */
case ERSETZT:
case IGNORIERT:
elemente[pos] = e;
}
}
}
Beim einfuegen() ist noch zu berücksichtigen, ob die Kapazitaät der „array“
Komponenten ausreicht. Ist die Kapazität der „array“-Komponenten nicht
ausreichend, dann soll die Kapazität (selbstverständlich ohne Datenverlust) über die
Funktion ausdehnen() verdoppelt werden.
//private:
template <class elt, class schl>
void sortTabelle<elt, schl> :: ausdehnen()
{
int alteGroesse = belegung;
elt* alteElemente = elemente;
belegung = 2 * belegung; // Verdopple die Tabelle
init();
for (int i = 0; i < alteGroesse; i++)
if (alteElemente[i])
elemente[i] = alteElemente[i];
delete [] alteElemente;
}
Die Ausgabe von hinzufuegen() kann auch
Operatorfunktion operator+=() übernommen werden.
template <class elt, class schl>
sortTabelle<elt, schl>& sortTabelle<elt, schl>
:: operator +=(elt e)
{
78
durch
Überladen
der
Algorithmen und Datenstrukturen
hinzufuegen(e);
return *this;
}
Die Methode suche() sucht nach einem Eintrag in der sortierten Tabelle. Der
Eintrag ist durch den Schlüsselwert spezifiziert, der als Argument in der Methode
suche() angegeben wird. Ist die Suche erfolglos, wird „0“ zurückgegeben werden.
Im Erfolgsfall wird auf den Eintrag verwiesen werden, der den Schlüssel enthält.
template <class elt, class schl>
elt sortTabelle<elt, schl> :: suche(schl s)
{
int pos = suchePosition(s);
// if (0 == pos) return 0;
if (gleich(s, holeSchl(*elemente[pos]))==GLEICH)
return(elemente[pos]);
return 0;
}
Die zum hinzufuegen() inverse Funktion ist das „Entfernen“ eines Eintrags aus
der Tabelle.
Zur Bearbeitung der Tabellenelemente der sortierten Tabelle gibt es noch einige
nützliche Attributfunktionen zu bestimmen. Generell wird die Verarbeitung der
Elemente über das Datenelement
int aktpos;
// Aktuelle Position zur Verarbeitung
gesteuert. „aktpos“ regelt, was aktuelles Element ist und wird über ruecksetzen()
auf den Tabellenanfang gesetzt.
template <class elt, class schl>
void sortTabelle<elt, schl>::ruecksetzen()
{
aktpos = -1;
// zaehler = 0;
}
Die Methode beendet() bestimmt, ob aktpos das aktuelle Tabellenende erreicht
hat.
template <class elt, class schl>
BOOL sortTabelle<elt, schl> :: beendet()
{
return aktpos >= eltAnz - 1;
}
Die Methode naechstes() an, die aktpos auf das jeweils nächste Tabellenelement setzt.
template <class elt, class schl>
BOOL sortTabelle<elt, schl> :: naechstes()
{
if (beendet())
return FALSE;
else
{
79
Algorithmen und Datenstrukturen
aktpos++;
return TRUE;
}
}
Die Methode aktuell() ermittelt den aktuellen Eintrag.
template <class elt, class schl>
elt sortTabelle<elt, schl> :: aktuell()
{
return elemente[aktpos];
}
Die Methode leer() bestimmt, ob die sortierte Tabelle „Einträge“ enthält.
template <class elt, class schl>
BOOL sortTabelle<elt, schl> :: leer()
{
for (int i = 0; i < belegung; i++)
if (0 != elemente[i]) return FALSE;
return TRUE;
}
Die Füllung (das Verhältnis von aktuell belegten Einträgen zu den maximal
moeglichen Einträgen) ist:
template <class elt, class schl>
double sortTabelle<elt, schl> :: fuellung()
{
return double(eltAnz)/belegung;
}
Die Größe der Tabelle (Anzahl der aktuellen Tabelleneinträge) wird bestimmt durch:
template <class elt, class schl>
int sortTabelle<elt, schl> :: groesse()
{
return eltAnz;
}
Die Operatorfunktion operator<<() dient zur Ausgabe aller Tabellenelemente.
template <class elt, class schl> ostream& operator<<(ostream& strm,
const sortTabelle<elt, schl>& tbl)
template <class elt, class schl> ostream&
operator<<(ostream& strm, const sortTabelle<elt, schl>& t)
{
sortTabelle<elt, schl>& tbl = (sortTabelle<elt, schl>&)t;
tbl.ruecksetzen();
while (tbl.naechstes())
{
strm << holeSchl(*tbl.aktuell()) << '\n';
}
return strm;
}
Zum Kopieren der sortierten Tabelle dient die private Funtion kopiere().
80
Algorithmen und Datenstrukturen
// private:
template <class elt, class schl>
void sortTabelle<elt, schl>
:: kopiere(const sortTabelle<elt, schl>& tbl)
{
eltAnz = tbl.eltAnz;
for (int i = 0; i < eltAnz; i++)
elemente[i] = tbl.elemente[i];
}
Der Kopierkonstrukor, der eine Kopie der sortierten Tabelle anlegt, umfaßt:
template <class elt, class schl>
sortTabelle<elt, schl> ::
sortTabelle(const sortTabelle<elt, schl>& tbl) :
belegung(tbl.belegung),
elemente(new elt[tbl.belegung])
{
if (0 == elemente)
fehler("[sortTabelle::Kopierkonstruktor] Fehlanzeige Belegung");
kopiere(tbl);
}
Zum Zuweisen einer sortierten Tabelle an eine Variable vom Typ „sortTabelle“ ist
ein Überladen der Operatorfunktion operator=() nötig.
template <class elt, class schl> sortTabelle<elt, schl>&
sortTabelle<elt, schl> ::
operator=(const sortTabelle<elt, schl>& tbl)
{
if (this == &tbl) return *this;
// assignment to self!
if (belegung != tbl.belegung)
{
delete [] elemente;
belegung = tbl.belegung;
elemente = new elt[belegung];
if (0 == elemente)
fehler("[sortTabelle::operator=] fehlerhafte Belegung");
}
kopiere(tbl);
return *this;
}
2.3.3 Hash-Tabellen
Implementierung in Java25
Die folgende Anwendung26 zeigt einen Test der in Java implementierten Klasse
HashMap:
import java.io.*;
import java.util.*;
25
26
pr23300
vgl. pr22142
81
Algorithmen und Datenstrukturen
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)
{
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")))
{
82
Algorithmen und Datenstrukturen
// 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);
}
}
83
Algorithmen und Datenstrukturen
2.4 Suchen in Texten
2.4.1 Suchen in Zeichenfolgen
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) 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 m1...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.
Statischer und dynamischer Text
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
84
Algorithmen und Datenstrukturen
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);
};
Die verschiedenen Suchalgorithmen der Textverarbeitung sind jeweils in der
Methode finden() angegeben. Auch der Konstruktor wird an die spezifische
Suchmethode angepaßt.
Das folgende Programmschema27 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)
{
27
PR24140.CPP
85
Algorithmen und Datenstrukturen
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.
2.4.2 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 m1, ... , 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 ) .
86
Algorithmen und Datenstrukturen
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)
{
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.
87
Algorithmen und Datenstrukturen
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.
2.4.3 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
C
[8]
D
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.
88
Algorithmen und Datenstrukturen
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.
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
89
Algorithmen und Datenstrukturen
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ß.
2.4.4 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.
90
Algorithmen und Datenstrukturen
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
zugrundeliegenden Alphabets (in der Regel ASCII-Code) berechnen. Die
Berechnung von Delta1 kann folgendermaßen realisiert sein:
// 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
91
Algorithmen und Datenstrukturen
„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;}
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.
}
92
Algorithmen und Datenstrukturen
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;
}
93
Algorithmen und Datenstrukturen
2.5 Problemlösungverfahren
2.5.1 Grundlagen
Übersicht
Im Mittelpunkt der Informatik steht das Lösen von Problemen. Generell sind daher
nicht Schlüsselsuchprogramme, sondern Problemlösungssuchprogramme gefragt.
Derartige Programme sind natürlich weitaus komplexer als die Verfahren zur
Schlüsselsuche. Dennoch stellt sich die Frage, ob nicht Struktur bzw. Gestalt vieler
Probleme zu Gemeinsamkeiten führt, die die Programmierung von
Problemlösungsverfahren auf eine breite Grundlage stellt.
Die Gemeinsamkeiten aller Problemlösungszustände, Operationen bzw. Operatoren
(, die einen Zustand in einen anderen Zustand umwandeln), Ziele bzw.
Lösungszustände lassen sich mit Hilfe gerichteter Graphen so beschreiben:
- die Knoten entsprechen einem bestimmten Zustand der Problemwelt
- die Pfeile stehen für eine bestimmte Operation
- ein Ziel wird durch einen bestimmten Zielknoten bzw. Zielzustand dargestellt
Unter den gerichteten Grafen hat sich wegen der "hohen Geschwindigkeit beim
Durchsuchen eine Spezialform als besonders geeignet herausgestellt: Bäume und
hier insbesondere binäre Bäume. Die Lösung für ein beliebiges Problem kann daher
immer als Weg durch einen Graphen oder Baum beschrieben werden. Anfangspunkt
dieses Wegs ist dann die Wurzel des Baums. Der zu erreichende Zielzustand wird
durch mehrere Blätter dargestellt. Die Lösungspfad führt von der Wurzel zu einem
Blatt des Baums.
Bsp.: Das Labyrinth-Problem
Zur Erforschung des Labyrinth dient eine interne Baumdarstellung: Befindet man
sich an einem beliebigen Punkt im Labyrinth, so stehen theoretisch 4
Bewegungsrichtungen 28 zur Verfügung: oben (o), unten (u), links (l) und rechts (r).
Das führt zu 4 Regeln, die in einer bestimmten Reihenfolge auf den jeweiligen
Knoten (Verzweigungspunkt im Labyrinth) anzuwenden sind:
1. Nach rechts gehen
2. Falls das nicht geht, nach links gehen
3. Sollte das auch mißlingen, nach unten gehen
4. Ist das auch nicht möglich, nach unten gehen
Nicht immer führt der Weg direkt vom Start zum Zielpunkt, z.B. wird in dem
folgenden Labyrinth bei der Suche nach dem Zielpunkt (2b) ein Zyklus durchlaufen:
28
realistisch gibt es nur 3 Richtungen: die Richtung, aus der man gekommen ist, ist kein sinnvoller Weg
94
Algorithmen und Datenstrukturen
1
2
3
b
a
c
1a
1b
1c
2a
2b
3a
3b
2c
3c
3c
3b
2c
1c
3a
1b
2a
1a
1a
Abb.:
In diesem Fall ist es notwendig sich zu merken, daß man an einer bestimmten Stelle
(1a) schon einmal war. Der Zyklus ist eine Konsequenz unglücklich ausgewählter
Regeln. Eine andere Regelauswahlstrategie kann zum Erfolg führen.
95
Algorithmen und Datenstrukturen
In dem folgenden Labyrinth
1
2
a
b
c
d
führt das zu folgendem Weg:
1a
Startknoten
1b
1c
2c
1d
2d
Zielknoten
Abb.:
Ist man in einer Sackgasse, dann kann man nur noch in die Richtung bis zu letzten
Verzweigung gehen, aus der man gekommen ist. An der Abzweigung führt man die
nächste mögliche Regel aus und überprüft, ob diese erfolgreich werden kann
(Backtracking).
Suchverfahren zum Auffinden eines Lösungpfades
Grundsätzlich bestehen bei der Suche nach Lösungspfaden nur 2 verschiedene
Möglichkeiten:
- Suchen in die Breite
- Suchen in die Tiefe
Beim Suchen in die Breite werden, angefangen bei der Baumwurzel ( = Stufe 0),
nacheinander alle Knoten des Problembaums untersucht. Erst danach wird in die
nächste Stufe gesprungen, falls kein Zielzustand gefunden wurde.
96
Algorithmen und Datenstrukturen
Beim Suchen in die Tiefe werden, angefangen bei der Baumwurzel, einzelne
Lösungsvarianten immer weiter verfolgt, d.h.: Es werden nur die Nachfolgeknoten
des unmittelbar bearbeiteten Knotens als nächste bearbeitet.
Die Tiefensuche verfolgt von der Wurzel aus eine bestimmte Lösungsvariante. Im
ungünstigsten Fall führt die bearbeitete Variante in eine Sackgasse. In diesem Fall
muß der Variantenpfad so weit zurückverfolgt werden, bis man zu einem Knoten mit
einem noch nicht bearbeiteten Nachfolger kommt. Von dort aus geht es dann wieder
normal weiter. Diese Vorgehensweise wird als Backtracking bezeichnet.
Das Finden einer Lösung geschieht durch einen Versuch (trial), dessen Auswirkung
auf die Lösung geprüft wird (error?). Gewöhnlich wird der Prozess des Versuchens
und Nachprüfens in einzelne Teilschritte zerlegt, die sich häufig in rekursiver Form
ausdrücken lassen.
2.5.2 Das Backtracking-Problem und seine Lösungen
1. Anwendungsgebiet
Typische Backtracking-Probleme sind:
- das 8-Damen-Problem
- das Auffinden kürzester Wege in Grafen
- das Auffinden des besten Zuges bei der Programmierung von Spielen
- das Travelling Salesman Problem
Das Anwendungsgebiet der "Backtracking-Programmierung" sind kombinatorische
Suchprobleme der folgenden Art:
Suchen der Werte einer Variablen xi aus einem Wertevorrat. Die Werte der Variablen xi müssen eine
bestimmte Bedingung Bi erfüllen.
Das Verfahren kann graphisch als Baum dargestellt werden, dessen Knoten die
Lösungsvektoren sind und dessen Kanten den Weg des Algorithmus von einem
Lösungsvektor der Laenge i - 1 zu einem der Länge i angeben.
Bsp.: Das 4-Damen-Problem
Es handelt sich um eine Vereinfachung des sog. 8-Damen-Problems:
8 Damen sollen so auf einem Schachbrett postiert werden, daß sie sich nicht schlagen können, d.h.: 2
Damen stehen nie in derselben Zeile oder Spalte oder Diagonale.
Auf eine Lösung dieses Problems führt die folgende Baumstruktur:
97
Algorithmen und Datenstrukturen
(_,_,_,_)
1
2
3
2
(2,_,_,_)
(1,_,_,_)
4
1
S
S
(1,3,_,_)
2
3
S
(1,4,_,_)
4
2
4
4
(2,4,_,_)4
3
1
S
S
(2,4,1,_)
(1,4,2,_)
(2,4,1,3)
Die Lösung des Problems läßt sich anschaulich darstellen:
0
3
-3
y
x
x
x
x
x
2
8
5
Abb.:
Da Damen sich auch in der Diagonale bedrohen, sind Haupt- und Nebendiagonalenplätze zu speichern
98
Algorithmen und Datenstrukturen
2. Allgemeiner Backtrack-Algorithmus
procedure Versuche;
begin
Initialisiere_Parameter;
repeat
Treffe_naechste_Wahl;
if Wahl_annehmbar then
begin
Nimm_Wahl_auf;
if not Ende then Versuche
else Aufnahme_Loesung;
(* Bei Bedarf Ausgabe der Loesung *)
Mache_Wahl_rueckgaengig;
end;
until Alle_Kandidaten_versucht;
end;
In vielen Fällen kann der vorliegende Algorithmus noch günstiger gestaltet werden,
wenn man mit einem Stufenparameter für die Verschachtelungstiefe der Rekursion
arbeiten kann und darüber eine einfache Abbruchbedingung bekommt. Für N
Kandidaten und maximaler Verschachtelunstiefe M ergibt sich dann:
procedure Versuche(I : integer);
var J : integer;
begin
Initialisiere_Parameter;
for J := 1 to N do
begin
Waehle_J-ten_Kandidaten;
if Wahl_annehmbar then
99
Algorithmen und Datenstrukturen
begin
Nimm_Wahl_auf;
if I < M then Versuche(I + 1) else Aufnahme_Loesung;
Mache_Wahl_rueckgaengig;
end;
end;
end;
3. Anwendungen
Das 8-Dame-Problem
Das 8-Damen-Problems kann folgendermaßen formuliert werden kann: Es sollen 8
ganze Zahlen (x1,....,x8) mit 1 <= xi <= 8 gefunden werden (xi ist die Spaltennummer
der Dame in Zeile i).
Falls i <> j ist, ist auch xi <> xj (, da Damen sich nicht in der gleichen Spalte
befinden duerfen). Damen dürfen sich aber auch nicht auf Diagonalen treffen.
Außerdem ist in den "/"-Diagonalen die Summe der Koordinaten (I - J) und in
den "\"-Diagonalen die Differenz der Koordinaten (I + J) für alle Felder konstant.
Das ergibt die Ungleichungen:
xi + i <> xj + j
xi - i <> xj - j
Lösungsvorschlag in C++
/*
Das Problem der acht Damen
-------------------------Kurzbeschreibung: Durch Backtracking werden alle Moeglichkeiten
gefunden, acht Damen so auf einem Schachbrett
zu positionieren, dass keine Dame eine andere
bedroht. */
#include <iostream.h>
enum bool{FALSE, TRUE};
class damen
{
private:
unsigned short pos[8];
bool Zeile[8];
bool HD[15];
// Hauptdiagonale /
bool ND[15];
// Nebendiagonale \
public:
damen(void);
// Konstruktor
void versuche(int);
100
Algorithmen und Datenstrukturen
friend ostream& operator <<(ostream &, damen &);
};
//-------------------- Element-Funktionen ----------------------damen :: damen(void)
{
int i;
// Im Konstruktor werden die einzelnen Felder initialisiert.
for(i = 0; i < 8; i++)
Zeile[i] = TRUE;
for(i = 0; i < 15; i++)
HD[i] = ND[i] = TRUE;
}
void damen :: versuche(int j)
{
for(int i = 0; i < 8; i++)
{
if(Zeile[i] && HD[j+i] && ND[7+i-j])
{
// Position ist moeglich, also wird Dame gesetzt:
pos[i] = j;
// Dadurch werden alle oben ueberpr•ften Zeilen und Diagonalen bedroht:
Zeile[i] = HD[j+i] = ND[7+i-j] = FALSE;
// Falls noch nicht alle Damen gesetzt wurden, erfolgt ein weiterer,
// rekursiver Aufruf, der die naechste Spalte ueberprueft:
if(j < 8)
versuche(j+1);
else // Sonst wurde moegliche Loesung gefunden
cout << (*this);
// Nun muss der Versuch noch zurueckgenommen werden,
// da er am nach Verlassen dieser Spalte wieder moeglich wird:
Zeile[i] = HD[j+i] = ND[7+i-j] = TRUE;
}
// Ende von if( ...
}
// Ende von for( ...
}
// Ende von Versuche()
//----------- keine Schnittstellen-Funktionen --------------------ostream& operator<<(ostream &s, damen &d)
{
s << "\n\t";
for(short i = 0; i < 8; i++)
s << d.pos[i] << "
";
return(s);
}
//----------------------- main-Programm ----------------------void main()
{
damen queens;
queens.versuche(1);
} // Ende von main()
// Rekursionsanfang
Es gibt insgesamt 92 Lösungen, wobei jedoch nur 12 tatsächlich verschieden sind.
die übrigen entstehen durch Permutation der Zeilen, d.h.: Die Dame in der 1. Spalte
wird in diesselbe Zeile der 2. Spalte, die Dame in der zweiten in dieselbe Zeile der
101
Algorithmen und Datenstrukturen
der 3. Spalte gesetzt, bis schließlich die Dame in der 8 Spalte in diesselbe Zeile der
1. Spalte gesetzt wird.
2.5.3 Problemlösungsstrategien
Wie Tiefen- und Breitensuche zur Lösung von Problemem beitragen können, zeigt
das folgende Beispiel:
Das 15er Spiel (Schiebe-Puzzle)
Beschreibung
Ziel des „15er-Spiel“ bzw. des Schiebe-Puzzle ist: Herstellung einer sinnvollen
Ordnung für die 15 durchnummerierten Plättchen in einer 4 x 4 Matrix durch
horizontales bzw. vertikales Verschieben der nicht nummerierten Stelle:
6 7 15 1
8 9
4
13 12 5 11
3 4 10 2
1 2 3 4
5 6 7 8
9 10 11 12
13 14 15
Start
Ziel
Abb.:
Ein Zustand, d.i. eine Konfiguration der Plättchen, und die Operatoren zur
Überführung der Zustände setzen sich aus den Verschiebungen der Plättchen
zusammen. Zu einem beliebigen Zeitpunkt gibt es mindesetns 2 höchstens 4
Operationen. Folgende Operationen sind möglich:
- Stein nach rechts schieben
- Stein nach links schieben
- Stein nach oben schieben
- Stein nach unten schieben
Jeweils ein Nachfolgeknoten kann gestrichen werden, da er den vorangegangenen
Zug wieder rückgängig macht.
Darstellung und Manipulation der Plättchen-Positionen im Puzzle
Es bietet sich eine 4 x 4 - Matrix an, in der jedes Element ein Plättchen symbolisiert
und somit eine Nummer (im Bereich von 1 bis 15) trägt. Verfolgt wird die Bewegung
des blanken, nummernfreien Elements über die Datenelemente leereZeile,
leereSpalte der Klasse Position:
102
Herunterladen