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