Algorithmen und Datenstrukturen 4. Bäume 4.1 Grundlagen 4.1.1 Grundbegriffe und Definitionen Bäume sind eine Struktur zur Speicherung von (meist ganzzahligen) Schlüsseln. Die Schlüssel werden so gespeichert, daß sie sich in einem einfachen und effizienten Verfahren wiederfinden lassen. Neben dem Suchen sind üblicherweise das Einfügen eines neuen Knoten (mit gegebenem Schlüssel), das Entfernen eines Knoten (mit gegebenem Schlüssel), das Durchlaufen aller Knoten eines Baums in bestimmter Reihenfolge erklärte Operationen. Weitere wichtige Verfahren sind: - Das Konstruieren eines Baums mit bestimmten Eigenschaften - Das Aufspalten eines Baums in mehrere Teilbäume - Das Zusammenfügen mehrere Bäume zu einem Baum Definitionen Eine Datenstruktur heißt "t-ärer" Baum, wenn zu jedem Element höchstens t Nachfolger (t = 2,3,.... ) festgelegt sind. "t" bestimmt die Ordnung des Baumes (z.B. "t = 2": Binärbaum, "t = 3": Ternärbaum). Die Elemente eines Baumes sind die Knoten (K), die Verbindungen zwischen den Knoten sind die Kanten (R). Sie geben die Beziehung (Relation) zwischen den Knotenelementen an. Eine Datenstruktur D = (K,R) ist ein Baum, wenn R aus einer Beziehung besteht, die die folgenden 3 Bedingungen erfüllt: 1. Es gibt genau einen Ausgangsknoten (, das ist die Wurzel des Baums). 2. Jeder Knoten (mit Ausnahme der Wurzel) hat genau einen Vorgänger 3. Der Weg von der Wurzel zu jedem Knoten ist ein Pfad, d.h.: Für jeden von der Wurzel verschiedenen Knoten gibt es genau eine Folge von Knoten k 1, k2, ... , kn (n >= 2), bei der k i der Nachfolger von ki-1 ist. Die größte vorkommende Pfadlänge ist die Höhe eines Baums. Knoten, die keinen Nachfolger haben, sind die Blätter. Knoten mit weniger als t Nachfolger sind die Randknoten. Blätter gehören deshalb mit zum Rand. Haben alle Blattknoten eines vollständigen Baums die gleiche Pfadlänge, so heißt der Baum voll. Quasivoller Baum: Die Pfadlängen der Randknoten unterscheiden sich höchstens um 1. Bei ihm ist nur die unterste Schicht nicht voll besetzt. Linksvoller Baum: Blätter auf der untersten Schicht sind linksbündig dicht. Geordneter Baum: Ein Baum der Ordnung t ist geordnet, wenn für jeden Nachfolger k' von k festgelegt ist, ob k' der 1., 2., ... , t. Nachfolger von k ist. Dabei handelt es sich um eine Teilordnung, die jeweils die Söhne eines Knoten vollständig ordnet. Speicherung von Bäumen 1 Algorithmen und Datenstrukturen Im allg. wird die der Baumstruktur zugrunde liegende Relation gekettet gespeichert, d.h.: Jeder Knoten zeigt über einen sog. Relationenteil (mit t Komponenten) auf seine Nachfolger. Die Verwendung eines Anfangszeigers (, der auf die Wurzel des Baums zeigt,) ist zweckmäßig. 4.1.2 Darstellung von Bäumen In der Regel erfolgt eine grafische Darstellung: Ein Nachfolgerknoten k' von k wird unterhalb des Knoten k gezeichnet. Bei der Verbindung der Knotenelemente reicht deshalb eine ungerichtete Linie (Kante). Abb.: Bei geordneten Bäumen werden die Nachfolger eines Knoten k in der Reihenfolge "1. Nachfolger", "2. Nachfolger", .... von links nach rechts angeordnet. Ein Baum ist demnach ein gerichteter Graf mit der speziellen Eigenschaft: Jeder Knoten (Sohnknoten) hat bis auf einen (Wurzelknoten) genau einen Vorgänger (Vaterknoten). 2 Algorithmen und Datenstrukturen Ebene 1 linker Teilbaum von k Ebene 2 k Ebene 3 Weg, Pfad Ebene 4 Randknoten oder Blätter Abb. 4.1-2: 4.1.3 Berechnungsgrundlagen Die Zahl der Knoten in einem Baum ist N. Ein voller Baum der Höhe h enthält: h (1) N t i 1 i 1 th 1 t 1 Bspw. enthält ein Binärbaum N 2 h 1 Knoten. Das ergibt für den Binärbaum der Höhe 3 sieben Knoten. Unter der Pfadsumme Z versteht man die Anzahl der Knoten, die auf den unterschiedlichen Pfaden im vollen t-ären Baum auftreten können: h (2) Z i t i 1 i 1 3 Algorithmen und Datenstrukturen Die Summe kann durch folgende Formel ersetzt werden: h th th 1 Z t 1 ( t 1) 2 t und h können aus (1) bestimmt werden: t h N ( t 1) 1 h log t ( N ( t 1) 1) Mit Gleichung (1) ergibt sich somit für Z Z log t ( N ( t 1) 1) ( N ( t 1) 1) N ( t 1) ( N ( t 1) 1) log t ( N ( t 1) 1) N t 1 t 1 ( t 1) 2 Für t = 2 ergibt sich damit: Z h 2 h (2 h 1) bzw. Z ( N 1) ld ( N 1) N Die mittlere Pfadlänge ist dann: (3) Z mit Z ( N ( t 1) 1) log t ( N ( t 1) 1) N ( N ( t 1) 1) log t ( N ( t 1) 1) 1 N N ( t 1) N ( t 1) t 1 Für t = 2 ist (4) Z mit Z N 1 ld ( N 1) 1 N N Die Formeln unter (3) und (4) ergeben den mittleren Suchaufwand bei gleichhäufigem Aufsuchen der Elemente. Ist dies nicht der Fall, so ordnet man den Elementen die relativen Gewichte g i (i = 1, 2, 3, ... , N) bzw. die Aufsuchwahrscheinlichkeiten zu: pi N gi , G gi G i 1 N Man kann eine gewichtete Pfadsumme Zg gi hi i 1 Suchaufwand ( Z g ) mit Zg G N p i h i berechnen. i 1 4 bzw. einen mittleren Algorithmen und Datenstrukturen 4.1.4 Klassifizierung von Bäumen Wegen der großen Bedeutung, die binäre Bäume besitzen, ist es zweckmäßig in Binär- und t-äre Bäume zu unterteilen. Bäume werden oft verwendet, um eine Menge von Daten festzulegen, deren Elemente nach einem Schlüssel wiederzufinden sind (Suchbäume). Die Art, nach der beim Suchen in den Baumknoten eine Auswahl unter den Nachfolgern getroffen wird, ergibt ein weiteres Unterscheidungsmerkmal für Bäume. Intervallbäume In den Knoten eines Baumes befinden sich Daten, mit denen immer feinere Teilintervalle ausgesondert werden. Bsp.: Binärer Suchbaum Die Schlüssel sind nach folgendem System angeordnet. Neu ankommende Elemente werden nach der Regel "falls kleiner" nach links bzw. "falls größer" nach rechts abgelegt. 40 30 50 20 11 39 24 37 60 44 40 41 45 62 65 Es kann dann leicht festgestellt werden, in welchem Teilbereich ein Schlüsselwort vorkommt. Selektorbäume (Entscheidungsbäume) Der Suchweg ist hier durch eine Reihe von Eigenschaften bestimmt. Beim Binärbaum ist das bspw. eine Folge von 2 Entscheidungsmöglichkeiten. Solche Entscheidungsmöglichkeiten können folgendermaßen codiert sein: - 0 : Entscheidung für den linken Nachfolger - 1 : Entscheidung für den rechten Nachfolger Die Folge von Entscheidungen gibt dann ein binäres Codewort an. Dieses Codewort kann mit einem Schlüssel bzw. mit einem Schlüsselteil übereinstimmen. Bsp.: "Knotenorientierter binärer Selektorbaum" Folgende Schlüsselfolge wird zum Erstellen des Baums herangezogen: 5 Algorithmen und Datenstrukturen 1710 3810 6310 1910 3210 2910 4410 2610 5310 = = = = = = = = = 0 1 1 0 1 0 1 0 1 1 0 1 1 0 1 0 1 1 0 0 1 0 0 1 1 1 0 0 1 1 0 0 1 1 0 1 0 1 1 1 0 0 0 1 1 12 02 12 12 02 12 02 02 12 Der zugehörige Binärbaum besitzt dann folgende Gestalt: 17 19 0_ 38 39 01_ 32 1_ 63 10_ 11_ 101_ 40 011_ 44 53 110_ In den Knoten dient demnach der Wertebereich einer Teileigenschaft zur Auswahl der nächsten Untergruppe. Knotenorientierte und blattorientierte Bäume Zur Unterscheidung von Bäumen kann auf die Aufbewahrungsstelle der Daten zurückgegriffen werden: 1. Knotenorientierte Bäume Daten befinden sich hier in allen Baumknoten 2. Blattorientierte Bäume Daten befinden sich nur in den Blattknoten Optimierte Bäume Man unterscheidet statisch und dynamisch optimierte Bäume. In beiden Fällen sollen entartete Bäume (schiefe Bäume, Äste werden zu linearen Listen) vermieden werden. Statische Optimierung bedeutet: Der Baum wird neu (oder wieder neu) aufgebaut. Optimalität ist auf die Suchoperation bezogen. Es interessiert dabei das Aussehen des Baums, wenn dieser vor Gebrauch optimiert werden kann. Bei der dynamischen Optimierung wird der Baum während des Betriebs (bei jedem Ein- und Ausfügen) optimiert. Ziel ist also hier: Eine günstige Speicherstruktur zu erhalten. Diese Aufgabe kann im allg. nicht vollständig gelöst werden, eine Teiloptimierung (lokale Optimierung) reicht häufig aus. 6 Algorithmen und Datenstrukturen Werden die Operationen "Einfügen", "Löschen" und "Suchen" ohne besondere Einschränkungen oder Zusätze angewendet, so spricht man von freien Bäumen. Strukturbäume Sie dienen zur Darstellung und Speicherung hierarchischer Zusammenhänge. Bsp.: "Darstellung eines arithmetischen Ausdrucks" Operationen in einem arithmetischen Ausdruck sind zweiwertig (, die einwertige Operation "Minus" kann als Vorzeichen dem Operanden direkt zugeordnet werden). Zu jeder Operation gehören demnach 2 Operanden. Somit bietet sich die Verwendung eines binären Baumes an. Für den arithmetischen Ausdruck (A B / C) ( D E F) ergibt sich dann folgende Baumdarstellung: * + - A / B D * E C Abb.: 7 F Algorithmen und Datenstrukturen 4.2 Freie Binäre Intervallbäume 4.2.1 Ordnungsrelation und Darstellung Freie Bäume sind durch folgende Ordnungsrelation bestimmt: In jedem Knoten eines knotenorientierten, geordneten Binärbaums gilt: Alle Schlüssel im rechten (linken) Unterbaum sind größer (kleiner) als der Schlüssel im Knoten selbst. Mit Hilfe dieser Ordnungsrelation erstellte Bäume dienen zum Zugriff auf Datenbestände (Aufsuchen eines Datenelements). Die Daten sind die Knoten (Datensätze, -segmente, -elemente). Die Kanten des Zugriffsbaums sind Zeiger auf weitere Datenknoten (Nachfolger). Dateninformation Schluessel Datenteil Knotenzeiger LINKS RECHTS Zeiger zum linken Sohn Zeiger zum rechten Sohn Abb.: Das Aufsuchen eines Elements im Zugriffsbaum geht vom Wurzelknoten über einen Kantenzug (d.h. eine Reihe von Zwischenknoten) zum gesuchten Datenelement. Bei jedem Zwischenknoten auf diesem Kantenzug findet ein Entscheidungsprozeß über die folgenden Vergleiche statt: 1. Die beiden Schlüssel sind gleich: Das Element ist damit gefunden 2. Der gesuchte Schlüssel ist kleiner: Das gesuchte Element kann sich dann nur im linken Unterbaum befinden 3. Der gesuchte Schlüssel ist größer: Das gesuchte Element kann sich nur im rechten Unterbaum befinden. Das Verfahren wird solange wiederholt, bis das gesuchte (Schlüssel-) Element gefunden ist bzw. feststeht, daß es in dem vorliegenden Datenbestand nicht vorhanden ist. Struktur und Wachstum binärer Bäume sind durch die Ordnungsrelation bestimmt: Aufgabe: Betrachte die 3 Schlüssel 1, 2, 3. Diese 3 Schlüssel können durch verschieden angeordnete Folgen bei der Eingabe unterschiedliche binäre Bäume erzeugen. Stellen Sie alle Bäume, die aus unterschiedlichen Eingaben der 3 Schlüssel resultieren, dar! 8 Algorithmen und Datenstrukturen 1, 2, 3 1, 3, 2 2, 1, 3 1 1 2 3 2 3 1 2 2, 3, 1 3, 1, 2 3, 2, 1 2 3 3 1 3 2 1 1 Es gibt also: Sechs unterschiedliche Eingabefolgen und somit 6 unterschiedliche Bäume. Allgemein können n Elemente zu n! verschiedenen Anordnungen zusammengestellt werden. Suchaufwand Der mittlere Suchaufwand für einen vollen Baum beträgt Z mit N 1 ld ( N 1) 1 N Zur Bestimmung des Suchaufwands stellt man sich vor, daß ein Baum aus dem leeren Baum durch sukzessives Einfügen der N Schlüssel entstanden ist. Daraus resultieren N! Permutationen der N Schlüssel. Über all diese Baumstrukturen ist der Mittelwert zu bilden, um schließlich den Suchaufwand festlegen zu können. Aus den Schlüsselwerten 1, 2, ... , N interessiert zunächst das Element k, das die Wurzel bildet. k Hier gibt es: (k-1)! Unterbäume Schlüsseln 1..N Hier gibt es: (N-k)! Unterbäume mit den Schlüsseln k+1, ... , N 9 Algorithmen und Datenstrukturen Der mittlere Suchaufwand im gesamten Baum ist: ZN 1 ( N Z k 1 ( k 1) Z N k ( N k )) N Zk-1: mittlerer Suchaufwand im linken Unterbaum ZN-k: mittlerer Suchaufwand im rechten Unterbaum Zusätzlich zu diesen Aufwendungen entsteht ein Aufwand für das Einfügen der beiden Teilbäume an die Wurzel. Das geschieht (N-1)-mal. Zusammen mit dem Suchschritt selbst ergibt das N-mal. Der angegebene Suchaufwand gilt nur für die Wurzel mit dem Schlüssel k. Da alle Werte für k gleichwahrscheinlich sind, gilt allgemein: ZN (k) 1 N 2 N ( N Z ( k 1 ) Z ( N k )) Z 1 (Z k 1 ( k 1) bzw. k 1 Nk N N 2 k 1 N 2 k 1 N 1 2 (Z k 1 ( k 1) bzw. für N - 1: Z N 1 1 ( N 1) 2 k 1 N 1 2 N 2 Z N Z N 1 2 Z k 1 ( k 1) Z k 1 ( k 1) N k 1 ( N 1) 2 k 1 Es läßt sich daraus ableiten: Mit der YN YN 1 Ersatzfunktion N N 1 2N 1 ZN Z N 1 N 1 N N ( N 1) YN N ZN N 1 folgt die Rekursionsformel: N N 2i 1 1 N 2N 1 2 3 bzw. nach Auflösung1 YN N 1 N ( N 1) i 1 i ( i 1) i 1 i Einsetzen ergibt: ZN 2 N 1 N 1 HS N 3 2 ( HS N 1 1) 1 N N N "HS" ist die harmonische Summe: HS N i 1 1 N Sie läßt sich näherungsweise mit ln( N) 0.577 (ln( N )) 0.693 ld ( N) . Damit ergibt . ld ( N 1) 2 sich schließlich: Z mit 14 Darstellung 1 vgl. Wettstein, H.: Systemprogrammierung, 2. Auflage, S.291 10 Algorithmen und Datenstrukturen Jeder geordnete binäre Baum ist eindeutig durch folgende Angaben bestimmt: 1. Angabe der Wurzel 2. Für jede Kante Angabe des linken Teilbaums ( falls vorhanden) sowie des rechten Teilbaums (falls vorhanden) Die Angabe für die Verzweigungen befindet sich in den Baumknoten, die die zentrale Konstruktionseinheit für den Aufbau binärerer Bäume sind. 1. Die Klassenschablone „Baumknoten“ in C++2 // Schnittstellenbeschreibung // Die Klasse binaerer Suchbaum binSBaum benutzt die Klasse baumKnoten template <class T> class binSBaum; // Deklaration eines Binaerbaumknotens fuer einen binaeren Baum template <class T> class baumKnoten { protected: // zeigt auf die linken und rechten Nachfolger des Knoten baumKnoten<T> *links; baumKnoten<T> *rechts; public: // Das oeffentlich zugaenglich Datenelement "daten" T daten; // Konstruktor baumKnoten (const T& merkmal, baumKnoten<T> *lzgr = NULL, baumKnoten<T> *rzgr = NULL); // virtueller Destruktor virtual ~baumKnoten(void); // Zugriffsmethoden auf Zeigerfelder baumKnoten<T>* holeLinks(void) const; baumKnoten<T>* holeRechts(void) const; // Die Klasse binSBaum benoetigt den Zugriff auf // "links" und "rechts" friend class binSBaum<T>; }; // Schnittstellenfunktionen // Konstruktor: Initialisiert "daten" und die Zeigerfelder // Der Zeiger NULL verweist auf einen leeren Baum template <class T> baumKnoten<T>::baumKnoten (const T& merkmal, baumKnoten<T> *lzgr, baumKnoten<T> *rzgr): daten(merkmal), links(lzgr), rechts(rzgr) {} // Die Methode holeLinks ermoeglicht den Zugriff auf den linken // Nachfolger template <class T> baumKnoten<T>* baumKnoten<T>::holeLinks(void) const { // Rueckgabe des Werts vom privaten Datenelement links return links; } // Die Methode "holeRechts" erlaubt dem Benutzer den Zugriff auf den // rechten Nachfolger 2 vgl. baumkno.h 11 Algorithmen und Datenstrukturen template <class T> baumKnoten<T>* baumKnoten<T>::holeRechts(void) const { // Rueckgabe des Werts vom privaten Datenelement rechts return rechts; } // Destruktor: tut eigentlich nichts template <class T> baumKnoten<T>::~baumKnoten(void) {} 2. Baumknotendarstellung in Java import java.util.*; // Elementarer Knoten eines binaeren Baums, der nicht ausgeglichen ist // Der Zugriff auf diese Klasse ist nur innerhalb eines Verzeichnisses // bzw. Pakets moeglich class BinaerBaumknoten { // Instanzvariable protected BinaerBaumknoten links; protected BinaerBaumknoten rechts; public Comparable daten; // linker Teilbaum // rechter Teilbaum // Dateninhalt der Knoten // Konstruktor public BinaerBaumknoten(Comparable datenElement) { this(datenElement, null, null ); } public BinaerBaumknoten(Comparable datenElement, BinaerBaumknoten l, BinaerBaumknoten r) { daten = datenElement; links = l; rechts = r; } public BinaerBaumknoten getLinks() { return links; } public BinaerBaumknoten getRechts() { return rechts; } } 4.2.2 Operationen 1. Generieren eines Suchbaums Bsp.: Gestalt des durch die Prozedur ERSTBAUM erstellten Binärbaums nach der Eingabe der Schlüsselfolge (12, 7, 15, 5, 8, 13, 2, 6, 14). 12 Algorithmen und Datenstrukturen Schlüssel LINKS 12 RECHTS 7 5 2 15 8 13 6 14 Abb.: a) Erzeugen eines Binärbaumknotens bzw. eines binären Baums in C++ Zum Erzeugen eines Binärbaumknotens kann folgende Funktionsschablone herangezogen werden: template <class T> baumKnoten<T>* erzeugebaumKnoten(T merkmal, baumKnoten<T>* lzgr = NULL, baumKnoten<T>* rzgr = NULL) { baumKnoten<T> *z; // Erzeugen eines neuen Knoten z = new baumKnoten<T>(merkmal,lzgr,rzgr); if (z == NULL) { cerr << "Kein Speicherplatz!\n"; exit(1); } return z; // Rueckgabe des Zeigers } Der durch den Baumknoten belegte Funktionsschablone freigegeben werden: Speicherplatz kann über folgende template <class T> void gibKnotenFrei(baumKnoten<T>* z) { delete z; } Der folgende Hauptprogrammabschnitt erzeugt einen binären Baum folgender Gestalt: 13 Algorithmen und Datenstrukturen ‘A’ ‘C’ ‘B’ ‘D’ ‘E’ Abb: void main(void) { baumKnoten<char> *a, *b, *c, *d, *e; d = new baumKnoten<char>('D'); e = new baumKnoten<char>('E'); b = new baumKnoten<char>('B',NULL,d); c = new baumKnoten<char>('C',e); a = new baumKnoten<char>('A',b,c); } 14 Algorithmen und Datenstrukturen b) Erzeugen eines Binärbaumknotens bzw. eines binären Baums in Java class StringBinaerBaumknoten { private String sK; protected StringBinaerBaumknoten links, rechts; public StringBinaerBaumknoten (String s) { links = rechts = null; sK = s; } public void insert (String s) // Fuege s korrekt ein. { if (s.compareTo(sK) > 0) // dann rechts { if (rechts==null) rechts = new StringBinaerBaumknoten(s); else rechts.insert(s); } else // sonst links { if (links==null) links=new StringBinaerBaumknoten(s); else links.insert(s); } } public String getString () { return sK; } public StringBinaerBaumknoten getLinks () { return links; } public StringBinaerBaumknoten getRechts () { return rechts; } } public class TestStringBinaerBaumKnoten { public static void main (String args[]) { StringBinaerBaumknoten baum=null; for (int i = 0; i < 20; i++) // 20 Zusfallsstrings speichern { String s = "Zufallszahl " + (int)(Math.random() * 100); if (baum == null) baum = new StringBinaerBaumknoten(s); else baum.insert(s); } print(baum); // Sortiert wieder ausdrucken } public static void print (StringBinaerBaumknoten baum) // Rekursive Druckfunktion { if (baum == null) return; print(baum.getLinks()); System.out.println(baum.getString()); print(baum.getRechts()); } } 15 Algorithmen und Datenstrukturen 2. Suchen und Einfügen Vorstellung zur Lösung: 1. Suche nach dem Schlüsselwert 2. Falls vorhanden kein Einfügen 3. Bei erfolgloser Suche Einfügen als Sohn des erreichten Blatts a) Implementierung in C++ Das „Einfügen“3 ist eine Methode in der Klassenschablone für einen binären Suchbaum binSBaum. Zweckmäßigerweise stellt diese Klasse Datenelemente unter protected zur Verfügung. #include "baumkno.h" // Schnittstellenbeschreibung template <class T> class binSBaum { protected: // Zeiger auf den Wurzelknoten und den Knoten, auf den am // haeufigsten zugegriffen wird baumKnoten<T> *wurzel; baumKnoten<T> *aktuell; // Anzahl Knoten im Baum int groesse; // Speicherzuweisung / Speicherfreigabe // Zuweisen eines neuen Baumknoten mit Rueckgabe // des zugehoerigen Zeigerwerts baumKnoten<T> *holeBaumKnoten(const T& merkmal, baumKnoten<T> *lzgr,baumKnoten<T> *rzgr) { baumKnoten<T> *z; // Datenfeld und die beiden Zeiger werden initialisiert z = new baumKnoten<T> (merkmal, lzgr, rzgr); if (z == NULL) { cerr << "Speicherbelegungsfehler!\n"; exit(1); } return z; } // gib den Speicherplatz frei, der von einem Baumknoten belegt wird void freigabeKnoten(baumKnoten<T> *z) // wird vom Kopierkonstruktor und Zuweisungsoperator benutzt { delete z; } // Kopiere Baum b und speichere ihn im aktuellen Objekt ab baumKnoten<T> *kopiereBaum(baumKnoten<T> *b) // wird vom Destruktor, Zuweisungsoperator und bereinigeListe benutzt { baumKnoten<T> *neulzgr, *neurzgr, *neuerKnoten; // Falls der Baum leer ist, Rueckgabe von NULL if (b == NULL) return NULL; // Kopiere den linken Zweig von der Baumwurzel b und weise seine // Wurzel neulzgr zu if (b->links != NULL) neulzgr = kopiereBaum(b->links); else neulzgr = NULL; // Kopiere den rechten Zweig von der Baumwurzel b und weise seine // Wurzel neurzgr zu if (b->rechts != NULL) neurzgr = kopiereBaum(b->rechts); 3 vgl. bsbaum.h 16 Algorithmen und Datenstrukturen else neurzgr = NULL; // Weise Speicherplatz fuer den aktuellen Knoten zu und weise seinen // Datenelementen Wert und Zeiger seiner Teilbaeume zu neuerKnoten = holeBaumKnoten(b->daten, neulzgr, neurzgr); return neuerKnoten; } // Loesche den Baum, der durch im aktuellen Objekt gespeichert ist void loescheBaum(baumKnoten<T> *b) // Lokalisiere einen Knoten mit dem Datenelementwert von merkmal // und seinen Vorgaenger (eltern) im Baum { // falls der aktuelle Wurzelknoten nicht NULL ist, loesche seinen // linken Teilbaum, seinen rechten Teilbaum und dann den Knoten selbst if (b != NULL) { loescheBaum(b->links); loescheBaum(b->rechts); freigabeKnoten(b); } } // Suche nach dem Datum "merkmal" im Baum. Falls gefunden, Rueckgabe // der zugehoerigen Knotenadresse; andernfalls NULL baumKnoten<T> *findeKnoten(const T& merkmal, baumKnoten<T>* & eltern) const { // Durchlaufe b. Startpunkt ist die Wurzel baumKnoten<T> *b = wurzel; // Die "eltern" der Wurzel sind NULL eltern = NULL; // Terminiere bei einen leeren Teilbaum while(b != NULL) { // Halt, wenn es passt if (merkmal == b->daten) break; else { // aktualisiere den "eltern"-Zeiger und gehe nach rechts // bzw. nach links eltern = b; if (merkmal < b->daten) b = b->links; else b = b->rechts; } } // Rueckgabe des Zeigers auf den Knoten; NULL, falls nicht gefunden return b; } public: // Konstruktoren, Destruktoren binSBaum(void); binSBaum(const binSBaum<T>& baum); ~binSBaum(void); // Zuweisungsoperator binSBaum<T>& operator= (const binSBaum<T>& rs); // Bearbeitungsmethoden int finden(T& merkmal); void einfuegen(const T& merkmal); void loeschen(const T& merkmal); void bereinigeListe(void); int leererBaum(void) const; int baumGroesse(void) const; // baumspezifische Methoden void aktualisieren(const T& merkmal); baumKnoten<T> *holeWurzel(void) const; }; 17 Algorithmen und Datenstrukturen Die Schnittstellenfunktion void einfuegen(const T& merkmal); besitzt folgende Definition4: // Einfuegen "merkmal" in den Suchbaum template <class T> void binSBaum<T>::einfuegen(const T& merkmal) { // b ist der aktuelle Knoten beim Durchlaufen des Baums baumKnoten<T> *b = wurzel, *eltern = NULL, *neuerKnoten; // Terminiere beim leeren Teilbaum while(b != NULL) { // Aktualisiere den Zeiger "eltern", // dann verzweige nach links oder rechts eltern = b; if (merkmal < b->daten) b = b->links; else b = b->rechts; } // Erzeuge den neuen Blattknoten neuerKnoten = holeBaumKnoten(merkmal,NULL,NULL); // Falls "eltern" auf NULL zeigt, einfuegen eines Wurzelknoten if (eltern == NULL) wurzel = neuerKnoten; // Falls merkmal < eltern->daten, einfuegen als linker Nachfolger else if (merkmal < eltern->daten) eltern->links = neuerKnoten; else // Falls merkmal >= eltern->daten, einfuegen als rechter Nachf. eltern->rechts = neuerKnoten; // Zuweisen "aktuell": "aktuell" ist die Adresse des neuen Knoten aktuell = neuerKnoten; groesse++; } b) Eine generische Klasse für den binären Suchbaum in Java 5 Der binäre Suchbaum setzt voraus, dass alle Datenelemente in eine Ordnungsbeziehung6 gebracht werden können. Eine generische Klasse für einen binären Suchbaum erfordert daher ein Interface, das Ordnungsbeziehungen zwischen Daten eines Datenbestands festlegt. Diese Eigenschaft besitzt das Interface Comparable: public interface Comparable { int compareTo(Comparable rs) } Das Interface7 zeigt, dass zwei Datenelemente über die Methode „compareTo“ verglichen werden können. Über die Defintion eines Interface wird auch der zugehörige Referenztyp erzeugt, der wie andere Datentypen eingesetzt werden kann. // Freier binaerer Intervallbaum 4 vgl. bsbaum.h vgl. pr42110 6 vgl. Kapitel 1, 1.2.2.2 7 Java-Interfaces werden in der Regel als eine Form abstrakter Klassen beschrieben, durch die einzelne Klassen der Hierarchie zusätzliche Funktionalität erhalten können. Sollen die Klassen diese Funktionalität durch Vererbung erhalten, müssten sie in einer gemeinsamen Superklasse angesiedelt werden. Weil ein Interface keine Implementierung enthält, kann auch keine Instanz davon erzeugt werden. Eine Klasse, die nicht alle Methoden eines Interface implementiert, wird zu einer abstrakten Klasse. 5 18 Algorithmen und Datenstrukturen // // // // // // // // // // // // // // // Generische Klasse fuer einen unausgeglichenen binaeren Suchbaum Konstruktor: Initialisierung der Wurzel mit dem Wert null ************ oeffentlich zugaengliche Methoden******************** void insert( x ) --> Fuege x ein void remove( x ) --> Entferne x Comparable find( x ) --> Gib das Element zurueck, das zu x passt Comparable findMin( ) --> Rueckgabe des kleinsten Elements Comparable findMax( ) --> Rueckgabe des groessten Elements boolean isEmpty( ) --> Return true if empty; else false void makeEmpty( ) --> Entferne alles void printTree( ) --> Ausgabe der Binaerbaum-Elemente in sortierter Folge void ausgBinaerBaum() --> Ausgabe der Binaerbaum-Elemente um 90 Grad vers. /* * Implementiert einen unausgeglichenen binaeren Suchbaum. * Das Einordnen in den Suchbaum basiert auf der Methode compareTo */ public class BinaererSuchbaum { // Private Datenelemente /* Die Wurzel des Baums */ private BinaerBaumknoten root = null; /* Container mit daten zum Erstellen eines Baums */ Vector einVector; // Oeffentlich zugaengliche Methoden /* * Konstruktoren */ public BinaererSuchbaum() { root = null; } public BinaererSuchbaum(Vector einVector) { this.einVector = einVector; for (int i = 0; i < einVector.size();i++) { insert((Comparable) einVector.elementAt(i)); } } /* * Einfuegen eines Elements in den binaeren Suchbaum * Duplikate werden ignoriert */ public BinaerBaumknoten getRoot() { return root; } public void insert( Comparable x ) { root = insert(x, root); } /* * Entfernen aus dem Baum. Falls x nicht da ist, geschieht nichts */ public void remove( Comparable x ) { root = remove(x, root); } /* * finde das kleinste Element im Baum */ 19 Algorithmen und Datenstrukturen public Comparable findMin() { return elementAt(findMin(root)); } /* * finde das groesste Element im Baum */ public Comparable findMax() { return elementAt(findMax(root)); } /* * finde ein Datenelement im Baum */ public Comparable find(Comparable x) { return elementAt(find(x, root)); } /* * Mache den Baum leer */ public void makeEmpty() { root = null; } /* * Test, ob der Baum leer ist */ public boolean isEmpty() { return root == null; } /* * Ausgabe der Datenelemente in sortierter Reihenfolge */ public void printTree() { if( isEmpty( ) ) System.out.println( "Baum ist leer" ); else printTree( root ); } /* * Ausgabe der Elemente des binaeren Baums um 90 Grad versetzt */ public void ausgBinaerBaum() { if( isEmpty() ) System.out.println( "Leerer baum" ); else ausgBinaerBaum( root,0 ); } // Private Methoden /* * Methode fuer den Zugriff auf ein Datenelement */ private Comparable elementAt( BinaerBaumknoten b ) { return b == null ? null : b.daten; } /* * Interne Methode fuer das Einfuegen in einen Teilbaum */ private BinaerBaumknoten insert(Comparable x, BinaerBaumknoten b) 20 Algorithmen und Datenstrukturen { /* /* /* /* /* /* /* /* /* 1*/ 2*/ 3*/ 4*/ 5*/ 6*/ 7*/ 8*/ 9*/ if( b == null ) b = new BinaerBaumknoten( x, null, null ); else if( x.compareTo( b.daten ) < 0 ) b.links = insert( x, b.links ); else if( x.compareTo( b.daten ) > 0 ) b.rechts = insert( x, b.rechts ); else ; // Duplikat; tue nichts return b; } /* * Interne Methode fuer das Entfernen eines Knoten in einem Teilbaum */ private BinaerBaumknoten remove(Comparable x, BinaerBaumknoten b) { if( b == null ) return b; // nichts gefunden; tue nichts if( x.compareTo(b.daten) < 0 ) b.links = remove(x, b.links ); else if( x.compareTo(b.daten) > 0 ) b.rechts = remove( x, b.rechts ); else if( b.links != null && b.rechts != null ) // Zwei Kinder { b.daten = findMin(b.rechts).daten; b.rechts = remove(b.daten, b.rechts); } else b = ( b.links != null ) ? b.links : b.rechts; return b; } /* * Interne Methode zum Bestimmen des kleinsten Datenelements im Teilbaum */ private BinaerBaumknoten findMin(BinaerBaumknoten b) { if (b == null) return null; else if( b.links == null) return b; return findMin(b.links ); } /* * Interne Methode zum Bestimmen des groessten Datenelements im Teilbaum */ private BinaerBaumknoten findMax( BinaerBaumknoten b) { if( b != null ) while( b.rechts != null ) b = b.rechts; return b; } /* * Interne Methode zum Bestimmen eines Datenelements im Teilbaum. */ private BinaerBaumknoten find(Comparable x, BinaerBaumknoten b) { if(b == null) return null; if( x.compareTo(b.daten ) < 0) return find(x, b.links); else if( x.compareTo(b.daten) > 0) return find(x, b.rechts); else return b; // Gefunden! } /* 21 Algorithmen und Datenstrukturen * Internae Methode zur Ausgabe eines Teilbaums in sortierter Reihenfolge */ private void printTree(BinaerBaumknoten b) { if(b != null) { printTree(b.links); System.out.print(b.daten); System.out.print(' '); printTree( b.rechts ); } } /* * Ausgabe des Binaerbaums um 90 Grad versetzt */ private void ausgBinaerBaum(BinaerBaumknoten b, int stufe) { if (b != null) { ausgBinaerBaum(b.links, stufe + 1); for (int i = 0; i < stufe; i++) { System.out.print(' '); } System.out.println(b.daten); ausgBinaerBaum(b.rechts, stufe + 1); } } 3. Löschen eines Knoten Es soll ein Knoten mit einem bestimmten Schlüsselwert entfernt werden. Fälle A) Der zu löschende Knoten ist ein Blatt Bsp.: vorher nachher Abb.: Das Entfernen kann leicht durchgeführt werden B) Der zu löschende Knoten hat genau einen Sohn 22 Algorithmen und Datenstrukturen nachher vorher Abb.: C) Der zu löschende Knoten hat zwei Söhne nachher vorher Abb.: Der Knoten k wird durch den linken Sohn ersetzt. Der rechte Sohn von k wird rechter Sohn der rechtesten Ecke des linken Teilbaums. Der resultierende Teilbaum T' ist ein Suchbaum, häufig allerdings mit erheblich vergrößerter Höhe. Aufgaben: 1. Gegeben ist ein binärer Baum folgender Gestalt: k k1 k2 k3 Die Wurzel wird gelöscht. Welche Gestalt nimmt der Baum dann an: 23 Algorithmen und Datenstrukturen k1 k3 k2 Abb.: Es ergibt sich eine Höhendifferenz H , die durch folgende Beziehung eingegrenzt ist: 1 H H(TL ) H ( TL ) ist die Höhe des linken Teilbaums. 2. Gegeben ist die folgende Gestalt eines binären Baums 12 15 7 5 13 2 6 14 Welche Gestalt nimmt dieser Baum nach dem Entfernen der Schlüssel mit den unter a) bis f) angegebenen Werten an? a) 2 b) 6 12 7 5 15 13 14 24 Algorithmen und Datenstrukturen 12 c) 13 7 15 14 5 d) 15 12 14 7 5 12 e) 5 14 7 f) 12 7 14 Schlüsseltransfer Der angegebene Algorithmus zum Löschen von Knoten kann zu einer beträchtlichen Vergrößerung der Baumhöhe führen. Das bedeutet auch eine beträchtliche Steigerung des mittleren Suchaufwands. Man ersetzt häufig die angegebene Verfahrensweise durch ein anderes Verfahren, das unter dem Namen Schlüsseltransfer bekannt ist. Der zu löschende Schlüssel (Knoten) wird ersetzt durch den kleinsten Schlüssel des rechten oder den größten Schlüssel des linken Teilbaums. Dieser ist dann nach Fall A) bzw. B) aus dem Baum herauszunehmen. Bsp.: Abb.: 25 Algorithmen und Datenstrukturen Test der Verfahrensweise "Schluesseltransfer": 1) Der zu löschende Baumknoten besteht nur aus einem Wurzelknoten, z.B.: Schlüssel 12 LINKS RECHTS Ergebnis: Der Wurzelknoten wird gelöscht. 2) Vorgegeben ist Schlüssel 12 LINKS RECHTS 7 5 8 Abb.: Der Wurzelknoten wird gelöscht. Ergebnis: 7 5 8 Abb.: 26 Algorithmen und Datenstrukturen 3) Vorgegeben ist 12 Schlüssel LINKS RECHTS 15 7 13 8 5 14 Abb.: Der Wurzelknoten wird gelöscht. Ergebnis: Schlüssel 13 LINKS RECHTS 7 5 15 8 14 Abb.: 27 Algorithmen und Datenstrukturen a) Implementierung der Verfahrenweise Schlüsseltransfer Baumknoten in einem binären Suchbaum in C++ zum Löschen von // Falls "merkmal" im Baum vorkommt, dann loesche es template <class T> void binSBaum<T>::loeschen(const T& merkmal) { // LKnoZgr: Zeiger auf Knoten L, der geloescht werden soll // EKnoZgr: Zeiger auf die "eltern" E des Knoten L // ErsKnoZgr: Zeiger auf den rechten Knoten R, der L ersetzt baumKnoten<T> *LKnoZgr, *EKnoZgr, *ErsKnoZgr; // Suche nach einem Knoten, der einen Knoten enthaelt mit dem // Datenwert von "merkmal". Bestimme die aktuelle Adresse dieses Knotens // und die seiner "eltern" if ((LKnoZgr = findeKnoten (merkmal, EKnoZgr)) == NULL) return; // Falls LKnoZgr einen NULL-Zeiger hat, ist der Ersatzknoten // auf der anderen Seite des Zweigs if (LKnoZgr->rechts == NULL) ErsKnoZgr = LKnoZgr->links; else if (LKnoZgr->links == NULL) ErsKnoZgr = LKnoZgr->rechts; // Beide Zeiger von LKnoZgr sind nicht NULL else { // Finde und kette den Ersatzknoten fuer LKnoZgr aus. // Beginne am linkten Zweig des Knoten LKnoZgr, // bestimme den Knoten, dessen Datenwert am groessten // im linken Zweig von LKnoZgr ist. Kette diesen Knoten aus. // EvonErsKnoZgr: Zeiger auf die "eltern" des zu ersetzenden Knoten baumKnoten<T> *EvonErsKnoZgr = LKnoZgr; // erstes moegliches Ersatzstueck: linker Nachfolger von L ErsKnoZgr = LKnoZgr->links; // steige den rechten Teilbaum des linken Nachfolgers von LKnoZgr hinab, // sichere den Satz des aktuellen Knoten und den seiner "Eltern" // Beim Halt, wurde der zu ersetzende Knoten gefunden while(ErsKnoZgr->rechts != NULL) { EvonErsKnoZgr = ErsKnoZgr; ErsKnoZgr = ErsKnoZgr->rechts; } if (EvonErsKnoZgr == LKnoZgr) // Der linke Nachfolger des zu loeschenden Knoten ist das // Ersatzstueck // Zuweisung des rechten Teilbaums ErsKnoZgr->rechts = LKnoZgr->rechts; else { // es wurde sich um mindestens einen Knoten nach unten bewegt // der zu ersetzende Knoten wird durch Zuweisung seines // linken Nachfolgers zu "Eltern" geloescht EvonErsKnoZgr->rechts = ErsKnoZgr->links; // platziere den Ersatzknoten an die Stelle von LKnoZgr ErsKnoZgr->links = LKnoZgr->links; ErsKnoZgr->rechts = LKnoZgr->rechts; } } // Vervollstaendige die Verkettung mit den "Eltern"-Knoten // Loesche den Wurzelknoten, bestimme eine neue Wurzel if (EKnoZgr == NULL) wurzel = ErsKnoZgr; // Zuweisen Ers zum korrekten Zweig von E else if (LKnoZgr->daten < EKnoZgr->daten) EKnoZgr->links = ErsKnoZgr; Else EKnoZgr->rechts = ErsKnoZgr; // Loesche den Knoten aus dem Speicher und erniedrige "groesse" freigabeKnoten(LKnoZgr); groesse--; } 28 Algorithmen und Datenstrukturen b) Implementierung der Verfahrenweise Schlüsseltransfer Baumknoten in einem binären Suchbaum in Java zum Löschen von /* * Interne Methode fuer das Entfernen eines Knoten in einem Teilbaum */ private BinaerBaumknoten remove(Comparable x, BinaerBaumknoten b) { if( b == null ) return b; // nichts gefunden; tue nichts if( x.compareTo(b.daten) < 0 ) b.links = remove(x, b.links ); else if( x.compareTo(b.daten) > 0 ) b.rechts = remove( x, b.rechts ); else if( b.links != null && b.rechts != null ) // Zwei Kinder { b.daten = findMin(b.rechts).daten; b.rechts = remove(b.daten, b.rechts); } else b = ( b.links != null ) ? b.links : b.rechts; return b; } 29 Algorithmen und Datenstrukturen 4.2.3 Ordnungen und Durchlaufprinzipien Das Prinzip, wie ein geordneter Baum durchlaufen wird, legt eine Ordnung auf der Menge der Knoten fest. Es gibt 3 Möglichkeiten (Prinzipien), die Knoten eines binären Baums zu durchlaufen: 1. Inordnungen LWR-Ordnung (1) Durchlaufen (Aufsuchen) des linken Teilbaums in INORDER (2) Aufsuchen der BAUMWURZEL (3) Durchlaufen (Aufsuchen) des rechten Teilbaums in INORDER RWL-Ordnung (1) Durchlaufen (Aufsuchen) des rechten Teilbaums in INORDER (2) Aufsuchen der BAUMWURZEL (3) Durchlaufen (Aufsuchen) des linken Teilbaums in INORDER Der LWR-Ordnung und die RWL-Ordnung sind zueinander invers. Die LWR Ordnung heißt auch symmetrische Ordnung. 2. Präordnungen WLR-Ordnung (1) Aufsuchen der BAUMWURZEL (2) Durchlaufen (Aufsuchen) des linken Teilbaums in PREORDER (3) Durchlaufen (Aufsuchen) des rechten Teilbaums in PREORDER WRL-Ordnung (1) Aufsuchen der BAUMWURZEL (2) Durchlaufen (Aufsuchen) des rechten Teilbaums in PREORDER (3) Durchlaufen (Aufsuchen) des linken Teilbaums in PREORDER Es wird hier grundsätzlich die Wurzel vor den (beiden) Teilbäumen durchlaufen. 3. Postordnungen LRW-Ordnung (1) Durchlaufen (Aufsuchen) des linken Teilbaums in POSTORDER (2) Durchlaufen (Aufsuchen) des rechten Teilbaums in POSTORDER (3) Aufsuchen der BAUMWURZEL Zunächst werden die beiden Teilbäume und dann die Wurzel durchlaufen. RLW-Ordnung (1) Durchlaufen (Aufsuchen) des rechten Teilbaums in POSTORDER (2) Durchlaufen (Aufsuchen) des linken Teilbaums in POSTORDER (3) Aufsuchen der BAUMWURZEL Zunächst werden die beiden Teilbäume und dann die Wurzel durchlaufen. 30 Algorithmen und Datenstrukturen a) Funktionsschablonen für das Durchlaufen binärer Bäume in C++ // Funktionsschablonen fuer Baumdurchlaeufe template <class T> void inorder(baumKnoten<T>* b, void aufsuchen(T& merkmal)) { if (b != NULL) { inorder(b->holeLinks(),aufsuchen); aufsuchen(b->daten); inorder(b->holeRechts(),aufsuchen); } } template <class T> void postorder(baumKnoten<T>* b, void aufsuchen(T& merkmal)) { if (b != NULL) { postorder(b->holeLinks(),aufsuchen); // linker Abstieg postorder(b->holeRechts(),aufsuchen); // rechter Abstieg aufsuchen(b->daten); } } b) Rekursive Ausgabefunktion in Java8 public static void print (StringBinaerBaumknoten baum) // Rekursive Druckfunktion { if (baum == null) return; print(baum.getLinks()); System.out.println(baum.getString()); print(baum.getRechts()); } Aufgaben: Gegeben sind eine Reihe binärer Bäume. Welche Folgen entstehen beim Durchlaufen der Knoten nach den Prinzipien "Inorder (LWR)", "Präorder WLR" und "Postorder (LRW)". 1. A B D C E G F I J H K L "Praeorder": A B C E I F J D G H K L "Inorder": EICFJBGDKHLA 8 vgl. pr42100 31 Algorithmen und Datenstrukturen "Postorder": IEJFCGKLHDBA 2. + + * A * B E D C "Praeorder": + * A B + * C D E "Inorder": A*B+C*D+E "Postorder": A B * C D * E + + Diese Aufgabe zeigt einen Strukturbaum (Darstellung der hierarchischen Struktur eines arithmetischen Ausdrucks). Diese Baumdarstellung ist besonders günstig für die Übersetzung eines Ausdrucks in Maschinensprache. Aus der vorliegenden Struktur lassen sich leicht die unterschiedlichen Schreibweisen eines arithmetischen Ausdrucks herleiten. So liefert das Durchwandern des Baums in "Postorder" die Postfixnotation, in "Präorder" die Präfixnotation". 3. + * A B "Präorder": "Inorder": "Postorder": C +A*BC A+B*C ABC*+ 4. * C + A B "Präorder": "Inorder": "Postorder": *+ABC A+B*C AB+C* 32 Algorithmen und Datenstrukturen Anwendungen der Durchlaufprinzipien Mit Hilfe der angegebenen Ordnungen bzw. Durchlaufprinzipien lassen sich weitere Operationen auf geordneten Wurzelbäumen bestimmen: a) C++-Anwendungen 1. Bestimmen der Anzahl Blätter im Baum // Anzahl Blätter template <class T> void anzBlaetter(baumKnoten<T>* b, int& zaehler) { // benutze den Postorder-Durchlauf if (b != NULL) { anzBlaetter(b->holeLinks(), zaehler); anzBlaetter(b->holeRechts(), zaehler); // Pruefe, ob der erreichte Knoten ein Blatt ist if (b->holeLinks() == NULL && b->holeRechts() == NULL) zaehler++; } } 2. Ermitteln der Höhe des Baums // Hoehe des Baums template <class T> int hoehe(baumKnoten<T>* b) { int hoeheLinks, hoeheRechts, hoeheWert; if (b == NULL) hoeheWert = -1; else { hoeheLinks = hoehe(b->holeLinks()); hoeheRechts = hoehe(b->holeRechts()); hoeheWert = 1 + (hoeheLinks > hoeheRechts ? hoeheLinks : hoeheRechts); } return hoeheWert; } 3. Kopieren des Baums // Kopieren eines Baums template <class T> baumKnoten<T>* kopiereBaum(baumKnoten<T>* b) { baumKnoten<T> *neuerLzgr, *neuerRzgr, *neuerKnoten; // Rekursionsendebedingung if (b == NULL) return NULL; if (b->holeLinks() != NULL) neuerLzgr = kopiereBaum(b->holeLinks()); else neuerLzgr = NULL; if (b->holeRechts() != NULL) neuerRzgr = kopiereBaum(b->holeRechts()); else neuerRzgr = NULL; // Der neue Baum wird von unten her aufgebaut, 33 Algorithmen und Datenstrukturen // zuerst werden die Nachfolger bearbeitet und // dann erst der Vaterknoten neuerKnoten = erzeugebaumKnoten(b->daten, neuerLzgr, neuerRzgr); // Rueckgabe des Zeigers auf den zuletzt erzeugten Baumknoten return neuerKnoten; } 4. Löschen des Baums // Loeschen des Baums template <class T> void loescheBaum(baumKnoten<T>* b) { if (b != NULL) { loescheBaum(b->holeLinks()); loescheBaum(b->holeRechts()); gibKnotenFrei(b); } } b) Java-Anwendungen Grafische Darstellung eines binären Suchbaums9 private void zeichneBaum(BinaerBaumknoten b, int x, int y, int m, int s) { Graphics g = meinCanvas.getGraphics(); if (b != null) { if (b.links != null) { g.drawLine(x,y,x - m / 2,y + s); zeichneBaum(b.links,x - m / 2,y + s,m / 2,s); } if (b.rechts != null) { g.drawLine(x,y,x + m / 2,y+s); zeichneBaum(b.rechts,x + m / 2,y + s,m / 2,s); } } } } 9 vgl. pr42110 34 Algorithmen und Datenstrukturen 4.3 Balancierte Bäume Hier geht es darum, entartete Bäume (schiefe Bäume, Äste werden zu linearen Listen, etc.) zu vermeiden. Statische Optimierung heißt: Der ganze Baum wird neu (bzw. wieder neu) aufgebaut. Bei der dynamischen Optimierung wird der Baum während des Betriebs (bei jedem Ein- und Ausfügen) optimiert. Perfekt ausgeglichener, binärer Suchbaum Ein binärer Suchbaum sollte immer ausgeglichen sein. Der folgende Baum 1 2 3 4 5 ist zu einer linearen Liste degeneriert und läßt sich daher auch nicht viel schneller als eine lineare Liste durchsuchen. Ein derartiger binärer Suchbaum entsteht zwangsläufig, wenn die bei der Eingabe angegebene Schlüsselfolge in aufsteigend sortierter Reihenfolge vorliegt. Der vorliegende binäre Suchbaum ist selbstverständlich nicht ausgeglichen. Es gibt allerdings auch Unterschiede bei der Beurteilung der Ausgeglichenheit, z.B.: Die vorliegenden Bäume sind beide ausgeglichen. Der linke Baum ist perfekt ausbalanciert. Jeder Binärbaum ist perfekt ausbalanciert, falls jeder Knoten über einen linken und rechten Teilbaum verfügt, dessen Knotenzahl sich höchstens um den Wert 1 unterscheidet. Der rechte Teilbaum ist ein in der Höhe ausgeglichener (AVL 10-)Baum. Die Höhe der Knoten zusammengehöriger linker und rechter Teilbäume unterscheidet sich höchstens um den Wert 1. Jeder perfekt ausgeglichene Baum ist gleichzeitig auch ein in der Höhe ausgeglichener Binärbaum. Der umgekehrte Fall trifft allerdings nicht zu. Es gibt einen einfachen Algorithmus zum Erstellen eines perfekt ausgeglichenen Binärbaums, falls 10 nach den Afangsbuchstaben der Namen seiner Entdecker: Adelson, Velskii u. Landes 35 Algorithmen und Datenstrukturen (1) die einzulesenden Schlüsselwerte sortiert in aufsteigender Reihenfolge angegeben werden (2) bekannt ist, wieviel Objekte (Schlüssel) werden müssen. import java.io.*; class PBBknoten { // Instanzvariable protected PBBknoten links; // linker Teilbaum protected PBBknoten rechts; // rechter Teilbaum public int daten; // Dateninhalt der Knoten // Konstruktoren public PBBknoten() { this(0,null,null); } public PBBknoten(int datenElement) { this(datenElement, null, null ); } public PBBknoten(int datenElement, PBBknoten l, PBBknoten r) { daten = datenElement; links = l; rechts = r; } public PBBknoten getLinks() { return links; } public PBBknoten getRechts() { return rechts; } } public class PBB { static BufferedReader ein = new BufferedReader(new InputStreamReader( System.in)); // Instanzvariable PBBknoten wurzel; // Konstruktor public PBB(int n) throws IOException { if (n == 0) wurzel = null; else { int nLinks = (n - 1) / 2; int nRechts = n - nLinks - 1; wurzel = new PBBknoten(); wurzel.links = new PBB(nLinks).wurzel; wurzel.daten = Integer.parseInt(ein.readLine()); wurzel.rechts = new PBB(nRechts).wurzel; } } public void ausgPBB() { ausg(wurzel,0); } 36 Algorithmen und Datenstrukturen private void ausg(PBBknoten b, int nSpace) { if (b != null) { ausg(b.rechts,nSpace += 6); for (int i = 0; i < nSpace; i++) System.out.print(" "); System.out.println(b.daten); ausg(b.links, nSpace); } } // Test public static void main(String args[]) throws IOException { int n; System.out.print("Gib eine ganze Zahl n an, "); System.out.print("gefolgt von n ganzen Zahlen in "); System.out.println("aufsteigender Folge"); n = Integer.parseInt(ein.readLine()); PBB b = new PBB(n); System.out.print( "Hier ist das Resultat: ein perfekt balancierter Baum, "); System.out.println("die Darstellung erfogt um 90 Grad versetzt"); b.ausgPBB(); } } Schreibtischtest: Wird mit n = 10 aufgerufen, dann wird anschließend die Anzahl der Knoten berechnet, die sowohl in den linken als auch in den rechten Teilbaum eingefügt werden. Da der Wurzelknoten keinem Teilbaum zugeordnet werden kann, ergeben sich für die beiden Teilbäume (10 – 1) Knoten. Das ergibt nLinks = 4, nRechts = 5. Anschließend wird der Wurzelknoten erzeugt. Es folgt der rekursive Aufruf wurzel.links = new PBB(nLinks).wurzel; mit nLinks = 4. Die Folge davon ist: Einlesen von 4 Zahlen und Ablage dieser Zahlen im linken Teilbaum. Die danach folgende Zahl wird im Wurzelknoten abgelegt. Der rekursive Aufruf wurzel.rechts = new PBB(nRechts).wurzel; mit nRechts = 5 verarbeitet die restlichen 5 Zahlen und erstellt damit den rechten Teilbaum. Durch jeden rekursiven Aufruf wird ein Baum mit zwei ungefähr gleich großen Teilbäumen erzeugt. Da die im Wurzelknoten enthaltene Zahl direkt nach dem erstellen des linken Teilbaum gelesen wird, ergibt sich bei aufsteigender Reihenfolge der Eingabedaten ein binärer Suchbaum, der außerdem noch perfekt balanciert ist. 37 Algorithmen und Datenstrukturen 4.3.1 Statisch optimierte Bäume Der Algorithmus zum Erstellen eines perfekt ausgeglichenen Baums kann zur statischen Optimierung binärer Suchbäume verwendet werden. Das Erstellen des binären Suchbaums erfolgt dabei nach der bekannten Verfahrensweise. Wird ein solcher Baum in Inorder-Folge durchlaufen, so werden die Informationen in den Baumknoten aufsteigend sortiert. Diese sortierte Folge ist Eingangsgröße für die statische Optimierung. Es wird mit der sortiert vorliegende Folge der Schlüssel ein perfekt ausgeglichener Baum erstellt. Bsp.: Ein Java-Applet zur Demonstration der statischen Optimierung.11 Zufallszahlen werden generiert und in einen freien binären Intervallbaum aufgenommen, der im oberen Bereich des Fensters gezeigt werden. Über Sortieren erfolgt ein Inorder-Durchlauf des binären Suchbaums, über „Perfekter BinaerBaum“ Erzeugen und Darstellen des perfekt ausgeglichen binären Suchbaums. Abb.: 11 vgl. pr43205, ZPBBApplet.java und ZPBBApplet.html 38 Algorithmen und Datenstrukturen 4.3.2 AVL-Baum Der wohl bekannteste dynamisch ausgeglichene Binärbaum ist der AVL-Baum, genannt nach dem Anfangsbuchstaben seiner Entdecker (Adelson, Velskii und Landis). Ein Baum hat die AVL-Eigenschaft, wenn in jedem Knoten sich die Höhen der beiden Unterbäume höchstens um 1 (|HR - HL| <= 1) unterscheiden. Die Last ("Balance") muß in einem Knoten mitgespeichert sein. Es genügt aber als Maß für die Unsymmetrie die Höhendifferenz H festzuhalten, die nur die Werte -1 (linkslastig), 0 (gleichlastig) und +1 (rechtslastig) annehmen kann. 1. Einfügen Beim Einfügen eines Knoten können sich die Lastverhältnisse nur auf dem Wege, den der Suchvorgang in Anspruch nimmt, ändern. Der tatsächliche Ort der Änderung ist aber zunächst unbekannt. Der Knoten ist deshalb einzufügen und auf notwendige Berichtigungen zu überprüfen. Bsp.: Gegeben ist der folgende binäre Baum 8 4 2 10 6 Abb.: 1) In diesen Baum sind die Knoten mit den Schlüsseln 9 und 11 einzufügen. Die Gestalt des Baums ist danach: 8 4 2 10 9 6 Abb.: Die Schlüssel 9 und 11 können ohne zusätzliches Ausgleichen eingefügt werden. 39 11 Algorithmen und Datenstrukturen 2) In den gegebenen Binärbaum sind die Knoten mit den Schlüsseln 1, 3, 5 und 7 einzufügen. Wie ist die daraus resultierende Gestalt des Baums beschaffen? 8 -2 4 -1 2 10 6 1 Abb.: Schon nach dem Einfügen des Schlüsselwerts „1“ ist anschließendes Ausgleichen unbedingt erforderlich. 3) Wie könnte das Ausgleichen vollzogen werden? Eine Lösungsmöglichkeit ist hier bspw. eine einfache bzw. eine doppelte Rotation. 4 2 8 1 6 10 Abb.: Gestalt des Baums nach „Rotation“ b) Beschreibe den Ausgleichsvorgang, nachdem die Schlüssel 3, 5 und 7 eingefügt wurden! 4 8 2 1 3 6 5 10 7 Abb.: Das Einfügen der Schlüssel mit den Werten „3“, „5“ und „7“ verletzt die AVL-Eigenschaft nicht Nachdem ein Knoten eingefügt ist, ist der Pfad, den der Suchvorgang für das Einfügen durchlaufen hat, aufwärts auf notwendige Berichtigungen zu überprüfen. Bei dieser Prüfung wird die Höhendifferenz des linken und rechten Teilbaums bestimmt. Es können generell folgende Fälle eintreten: (1) H = +1 bzw. -1 40 Algorithmen und Datenstrukturen Eine Verlängerung des Baums auf der Unterlastseite gleicht die Last aus, die Verlängerung wirkt sich nicht weiter nach oben aus. Die Prüfung kann abgebrochen werden. (2) H = 0 Das bedeutet: Verlängerung eines Teilbaums Hier ist der Knoten dann ungleichlastig ( H = +1 bzw. -1), die AVL-Eigenschaft bleibt jedoch insgesamt erhalten. Der Baum wurde länger. (3) H = +1 bzw. -1 Das bedeutet: Verlängerung des Baums auf der Überlastseite. Die AVL-Eigenschaft ist verletzt, wenn H = +2 bzw. -2. Sie wird durch Rotationen berichtigt. Die Information über die Ausgeglichenheit steht im AVL-Baumknoten, z.B.: struct knoten { int num, // Schluessel bal; // Ausgleichsfaktor struct knoten *zLinks, *zRechts; }; Abb.: AVL-Baumknoten mit Ausgleichfaktor in C++ In der Regel gibt es folgende Faktoren für die "Ausgeglichenheit" je Knoten im AVLBaum: "-1": Höhe des linken Teilbaums ist um eine Einheit (ein Knoten) größer als die Höhe im rechten Teilbaum. "0": Die Höhen des linken und rechten Teilbaums sind gleich. "1": Die Höhe des linken Teilbaums ist um eine Einheit (ein Knoten) kleiner als die Höhe des rechten Teilbaums. Bsp.: Die folgende Darstellung zeigt den Binärbaum unmittelbar nach dem Einfügen eines Baumknoten. Daher kann hier der Faktor für Ausgeglichenheit -2 bis 2 betragen. 12 +1 7 17 +1 +2 5 0 9 14 24 -1 0 +2 8 25 0 +1 30 0 Nach dem Algorithmus für das Einfügen ergibt sich folgender AVL-Baum: 12 0 7 17 41 Algorithmen und Datenstrukturen +1 +1 9 5 25 14 -1 0 8 24 30 0 0 0 Es gibt 4 Möglichkeiten die Ausgeglichenheit, falls sie durch Einfügen eines Baumknoten gestört wurde, wieder herzustellen. A A b a a B A B c c 1a a B b b 1b A c a B c 2a b 2b Abb.: Die vier Ausgangssituationen bei der Wiederherstellung der AVL-Eigenschaft Von den 4 Fällen sind jeweils die Fälle 1a, 1b und die Fälle 2a, 2b zueinander symmetrisch. Für den Fall 1a kann durch einfache Rotation eine Ausgeglichenheit erreicht werden. B 0 b A 0 c a Im Fall 1b muß die Rotation nach links erfolgen. 42 Algorithmen und Datenstrukturen Für die Behandlung von Fall 2a der Abb. 1 wird der Teilbaum c aufgeschlüsselt in dessen Teilbäume c1 und c2: A -2 a B 2 C b -1 c2 c1 Abb.: Durch zwei einfache Rotationen kann der Baum ausgeglichen werden: 1. Rotation 2. Rotation C A 0 -2 a C -2 B B A +1 c2 b +1 c1 c2 +1 b c1 Abb.: a) Implementierung in C++ Mit der unter 1. festgestellten Verfahrensweise soll eine Klasse AVLBaum bestimmt werden, die Knoten der zuvor angegebenen Struktur so in einen Binärbaum einfügt, daß die AVL-Eigenschaft gewährleistet bleibt. // avl: Demonstrationsprogramm fuer AVL-Baeume// Loeschen von Knoten#include <iostream.h> 43 Einfuegen und a Algorithmen und Datenstrukturen #include <iomanip.h> #include <ctype.h> struct knoten { int num, bal; struct knoten *zLinks, *zRechts; }; class AVLbaum { private: knoten *wurzel; void LinksRotation(knoten* &z); void RechtsRotation(knoten* &z); int einf(knoten* &z, int x); void aus(const knoten *z, int nLeerZeichen) const; public: AVLbaum():wurzel(NULL){} void einfuegen(int x){einf(wurzel, x);} void ausgabe()const{aus(wurzel, 0);} }; Methoden. void AVLbaum::LinksRotation(knoten* &z) { knoten *hz = z; z = z->zRechts; hz->zRechts = z->zLinks; z->zLinks = hz; hz->bal--; if (z->bal > 0) hz->bal -= z->bal; z->bal--; if (hz->bal < 0) z->bal += hz->bal; } void AVLbaum::RechtsRotation(knoten* &z) { knoten *hz = z; z = z->zLinks; hz->zLinks = z->zRechts; z->zRechts = hz; hz->bal++; if (z->bal < 0) hz->bal -= z->bal; z->bal++; if (hz->bal > 0) z->bal += hz->bal; } int AVLbaum::einf(knoten* &z, int x) { // Rueckgabewert: Anstieg in der Hoehe // (0 or 1) nach Einfuegen von x in den // Teilbaum mit Wurzel z int deltaH = 0; if (z == NULL) { z = new knoten; z->num = x; z->bal = 0; z->zLinks = z->zRechts = NULL; deltaH = 1; // Die Hoehe des Baums waechst um 1 } else if (x > z->num) { if (einf(z->zRechts, x)) { z->bal++; // Die Hoehe des rechten Teilbaums waechst if (z->bal == 1) deltaH = 1; else if (z->bal == 2) { if (z->zRechts->bal == -1) RechtsRotation(z->zRechts); LinksRotation(z); 44 Algorithmen und Datenstrukturen } } } else if (x < z->num) { if (einf(z->zLinks, x)) { z->bal--; // Hoehe vom linken Teilbaum waechst if (z->bal == -1) deltaH = 1; else if (z->bal == -2) { if (z->zLinks->bal == 1) LinksRotation(z->zLinks); RechtsRotation(z); } } } return deltaH; } b) Die generische Klasse „AvlBaum“ in Java Grundlagen: Die AVL-Eigenschaft ist verletzt, wenn diese Höhendifferenz +2 bzw. –2 ist. Der Knoten, der diesen Wert erhalten hat, ist der Knoten „alpha“, dessen Unausgeglichenheit auf einen der folgenden 4 Fälle zurückzuführen ist: 1. Einfügen in den linken Teilbaum, der vom linken Nachkommen des Knoten „alpha“ bestimmt ist. 2. Einfügen in den rechten Teilbaum, der vom linken Nachkommen des Knoten „alpha“ bestimmt ist. 3. Einfügen in den linken Teilbaum, der vom rechten Nachkommen des Knoten „alpha“ bestimmt ist. 4. Einfügen in den rechten Teilbaum, der vom rechten Nachkommen des Knoten „alpha“ bestimmt ist. Fall 1 und Fall 4 bzw. Fall 2 und Fall 3 sind Spiegelbilder, zeigen also das gleiche Verhalten. Fall 1 kann durch einfache Rotation behandelt werden und ist leicht zu bestimmen, daß das Einfügen „außerhalb“ (links – links bzw. rechts – rechts im Fall 4 stattfindet. Fall 2 kann durch doppelte Rotation behandelt werden und ist ebenfalls leicht zu bestimmen, da das Einfügen „innerhalb“ (links –rechts bzw. rechts – links) erfolgt. Die einfache Rotation: Die folgende Darstellung beschreibt den Fall 1 vor und nach der Rotation: 45 Algorithmen und Datenstrukturen k2 k1 k1 k2 Z X Y Y Z X Abb.: Die folgende Darstellung beschreibt Fall 4 vor und nach der Rotation: k2 k1 k2 k1 X Z X Y Y Z Abb.: Doppelrotation: Die einfache Rotation führt in den Fällen 2 und 3 nicht zum Erfolg. Fall 2 muß durch eine Doppelrotation (links – rechts) behandelt werden. k3 k2 k1 k1 k3 D k2 B A A B C Abb.: 46 C D Algorithmen und Datenstrukturen Auch Fall 3 muß durch Doppelrotation behandelt werden k1 k2 k2 A k3 k1 k3 D A B B C D C Abb.: Implementierung: Zum Einfügen eines Knoten mit dem Datenwert „x“ in einen AVLBaum, wird „x“ rekursiv in den betroffenen Teilbaum eingesetzt. Falls die Höhe dieses Teilbaums sich nicht verändert, ist das Einfügen beendet. Liegt Unausgeglichenheit vor, dann ist einfache oder doppelte Rotation (abhängig von „x“ und den Daten des betroffenen Teilbaums) nötig. Avl-Baumknoten12 Er enthält für jeden Knoten eine Angabe zur Höhe(ndifferenz) seiner Teilbäume. // Baumknoten fuer AVL-Baeume class AvlKnoten { // Instanzvariable protected AvlKnoten links; // Linkes Kind protected AvlKnoten rechts; // Rechtes Kind protected int hoehe; // Hoehe public Comparable daten; // Das Datenelement // Konstruktoren public AvlKnoten(Comparable datenElement) { this(datenElement, null, null ); } public AvlKnoten( Comparable datenElement, AvlKnoten lb, AvlKnoten rb ) { daten = datenElement; links = lb; rechts = rb; hoehe = 0; } } 12 vgl. pr43210 47 Algorithmen und Datenstrukturen Der Avl-Baum13 Bei jedem Schritt ist festzustellen, ob die Höhe des Teilbaums, in dem ein Element eingefügt wurde, zugenommen hat. /* * Rueckgabe: Hoehe des Knotens, oder -1, falls null. */ private static int hoehe(AvlKnoten b) { return b == null ? -1 : b.hoehe; } Die Methode „insert“ führt das Einfügen eines Baumknoten in den Avl-Baum aus: /* * Interne Methode zum Einfuegen eines Baumknoten in einen Teilbaum. * x ist das einzufuegende Datenelement. * b ist der jeweilige Wurzelknoten. * Rueckgabe der neuen Wurzel des jeweiligen Teilbaums. */ private AvlKnoten insert(Comparable x, AvlKnoten b) { if( b == null ) b = new AvlKnoten(x, null, null); else if (x.compareTo( b.daten) < 0 ) { b.links = insert(x, b.links ); if (hoehe( b.links ) - hoehe( b.rechts ) == 2 ) if (x.compareTo( b.links.daten ) < 0 ) b = rotationMitLinksNachf(b); else b = doppelrotationMitLinksNachf(b); } else if (x.compareTo( b.daten ) > 0 ) { b.rechts = insert(x, b.rechts); if( hoehe(b.rechts) - hoehe(b.links) == 2) if( x.compareTo(b.rechts.daten) > 0 ) b = rotationMitRechtsNachf(b); else b = doppelrotationMitRechtsNachf( b ); } else ; // Duplikat; tue nichts b.hoehe = max( hoehe( b.links ), hoehe( b.rechts ) ) + 1; return b; } Rotationen 13 vgl. pr43210 48 Algorithmen und Datenstrukturen /* * Rotation Binaerbaumknoten mit linkem Nachfolger. * Fuer AVL-Baeume ist dies eine einfache Rotation (Fall 1). * Aktualisiert Angaben zur Hoehe, dann Rueckgabe der neuen Wurzel. */ private static AvlKnoten rotationMitLinksNachf(AvlKnoten k2) { AvlKnoten k1 = k2.links; k2.links = k1.rechts; k1.rechts = k2; k2.hoehe = max( hoehe( k2.links ), hoehe( k2.rechts ) ) + 1; k1.hoehe = max( hoehe( k1.links ), k2.hoehe ) + 1; return k1; } /* * Rotation Binaerbaumknoten mit rechtem Nachfolger. * Fuer AVL-Bäume ist dies eine einfache Rotation (Fall 4). * Aktualisiert Angaben zur Hoehe,, danach Rueckgabe der neuen Wurzel. */ private static AvlKnoten rotationMitRechtsNachf(AvlKnoten k1) { AvlKnoten k2 = k1.rechts; k1.rechts = k2.links; k2.links = k1; k1.hoehe = max( hoehe( k1.links ), hoehe( k1.rechts ) ) + 1; k2.hoehe = max( hoehe( k2.rechts ), k1.hoehe ) + 1; return k2; } /* * Doppelrotation der Binaerbaumknoten: : erster linker Nachfolgeknoten * mit seinem rechten Nachfolger; danach Knoten k3 mit neuem linken * Nachfolgerknoten. * Fuer AVL-Baeume ist dies eine doppelte Rotation (Fall 2) * Aktualisiert Angaben zur Hoehe,, danach Rueckgabe der neuen Wurzel. */ private static AvlKnoten doppelrotationMitLinksNachf(AvlKnoten k3) { k3.links = rotationMitRechtsNachf( k3.links ); return rotationMitLinksNachf( k3 ); } /* * Doppelrotation der Binaerbaumknoten: erster rechter Nachfolgeknoten * mit seinem linken Nachfolger;danach Knoten k1 mit neuem rechten * Nachfolgerknoten * Fuer AVL-Baeume ist dies eine doppelte Rotation (Fall 3) * Aktualisiert Angaben zur Hoehe,, danach Rueckgabe der neuen Wurzel. */ private static AvlKnoten doppelrotationMitRechtsNachf(AvlKnoten k1) { k1.rechts = rotationMitLinksNachf(k1.rechts); return rotationMitRechtsNachf(k1); } } 49 Algorithmen und Datenstrukturen 2. Löschen Man kann folgende Fälle unterscheiden: (1) H = +1 bzw. -1 (Verkürzung des Teilbaums auf der Überlastseite) (2) H = 0 (Verkürzung eines Unterbaums) Der Knoten ist jetzt ungleichlastig ( H = +1 bzw. -1), bleibt jedoch im Rahmen der AVL-Eigenschaft. Der Baum hat seine Höhe nicht verändert, die Berichtigung kann abgebrochen werden. (3) H = +1 bzw. -1 (Verkürzung eines Baums auf der Unterlastseite) Die AVL-Eigenschaft ist verletzt, falls H = +2 bzw. -2. Sie wird durch eine Einfachbzw. Doppelrotation wieder hergestellt. Dadurch kann sich der Baum verkürzen, so daß Lastreduzierungen an den Vorgänger weiterzugeben sind. Es können aber auch Lastsituationen mit dem Lastfaktor 0 auftreten. Bsp.: Spezialfälle zum Lastausgleich in einem AVL-Baum k k' H+2 H+1 k' a k c H+1 H+1 H H+1 b c a b k k'' H+1 H+2 k' a k'' k H H a b d H H Abb.: Löschen kann in der Regel nicht mit einer einzigen Rotation abgeschlossen werden. Im schlimmsten Fall erfordern alle Knoten im Pfad der Löschstelle eine Rekonfiguration. Experimente zeigen allerdings, daß beim Einfügen je Operation mehr Rotationen durchzuführen sind als beim Löschen. Offenbar existieren beim Löschen durch den inneren Bereich des Baums mehr Knoten, die ohne weiteres eliminiert werden können. 50 Algorithmen und Datenstrukturen Aufgabe 1. Gegeben ist die Schlüsselfolge 7, 6, 8, 5, 9, 4. Ermittle, wie sich mit dieser Schlüsselfolge einen AVL-Baum aufbaut. Schlüssel 7 BALANCE 0 LINKS, RECHTS 5 8 0 -1 4 6 0 0 9 0 Abb.: 2. Aus dem nun vorliegenden AVL-Baum sind die Knoten mit den Schlüsselwerten 9 und 8 zu löschen. Gib an, welche Gestalt der AVL-Baum jeweils annimmt. Schlüssel 5 BALANCE 1 LINKS, RECHTS 7 4 0 -1 6 0 Abb.: 51 Algorithmen und Datenstrukturen 4.3.3 Splay-Bäume Zugrunde liegende Idee Nachdem auf einen Baumknoten zugegriffen wurde, wird dieser Knoten über eine Reihe von AVL-Rotationen zur Wurzel. Bis zu einem gewissen Grade führt das zur Ausbalancierung. Bsp.: 1. y x x A C A y B B 2. e e d F b a a D A c b C A D E B a a e c F c C B C d d A B F b b B E E C E a E b A d F c E B e d F c A C C D 52 D D Algorithmen und Datenstrukturen Splaying-Operationen Der Knoten „x“ im Splay-Baum bewegt sich über einfache und doppelte Rotationen zur Wurzel. Man unterscheidet folgende Fälle: 1. (zig): x ist ein Kind der Wurzel von einem Splay-Baum, einfache Rotation 2. (zig-zig): x hat den Großvater g(x) und den Vater p(x), x und p(x) sind jeweils linke (bzw. rechte) Kinder ihres Vaters. g(x) g(x)=p(y) bzw. p(x) y = p(x) D A x x C A B B C D x y A z B C D 3. (zig-zag): x hat Großvater g(x) und Vater p(x), x ist linkes (rechtes) Kind von p(x), p(x) ist rechtes (linkes) Kind von g(x) z = g(x) x y=p(x) y z D x A A B B C 53 C D Algorithmen und Datenstrukturen Implementierung14 BinaerBaumKnoten // Elementarer Knoten eines binaeren Baums, der nicht ausgeglichen ist // Der Zugriff auf diese Klasse ist nur innerhalb eines Verzeichnisses // bzw. Pakets moeglich class BinaerBaumknoten { // Instanzvariable protected BinaerBaumknoten links; // linker Teilbaum protected BinaerBaumknoten rechts; // rechter Teilbaum public Comparable daten; // Dateninhalt der Knoten // Konstruktor public BinaerBaumknoten(Comparable datenElement) { this(datenElement, null, null ); } public BinaerBaumknoten(Comparable datenElement, BinaerBaumknoten l, BinaerBaumknoten r) { daten = datenElement; links = l; rechts = r; } public void insert (Comparable x) { if (x.compareTo(daten) > 0) // dann rechts { if (rechts == null) rechts = new BinaerBaumknoten(x); else rechts.insert(x); } else // sonst links { if (links == null) links = new BinaerBaumknoten(x); else links.insert(x); } } public BinaerBaumknoten getLinks() { return links; } public BinaerBaumknoten getRechts() { return rechts; } } SplayBaum // // // // // // // // // // // /* 14 SplayBaum class ***************** PUBLIC OPERATIONen ******************** void insert( x ) --> Insert x void remove( x ) --> Remove x Comparable find( x ) --> Gib das Merkmal zurück, das x zugeordnet ist Comparable findMin( ) --> Rueckgabe des kleinsten Elements Comparable findMax( ) --> Rueckgabe des groessten Elements boolean isEmpty( ) --> Rueckgabe true, falls leer; sonst false void makeEmpty( ) --> Entferne alle Elemente void printTree( ) --> Gib den Baum sortiert aus pr43215 54 Algorithmen und Datenstrukturen * Implementiere einen top-down Splay Baum. * Vergleiche beziehen sich auf die Methode compareTo. */ public class SplayBaum { private BinaerBaumknoten root; private static BinaerBaumknoten nullNode; static // Static initializer for nullNode { nullNode = new BinaerBaumknoten( null ); nullNode.links = nullNode.rechts = nullNode; } private static BinaerBaumknoten newNode = null; // wird in diversen Einfuegevorgaengen benutzt private static BinaerBaumknoten header = new BinaerBaumknoten(null); /* * Konstruktor. */ public SplayBaum( ) { root = nullNode; } /* * Zugriff auf die Wurzel */ public BinaerBaumknoten holeWurzel() { return root; } /* * Insert. * Parameter x ist das einzufuegende Element. */ public void insert( Comparable x ) { if( newNode == null ) newNode = new BinaerBaumknoten( null ); newNode.daten = x; if( root == nullNode ) { newNode.links = newNode.rechts = nullNode; root = newNode; } else { root = splay( x, root ); if( x.compareTo( root.daten ) < 0 ) { newNode.links = root.links; newNode.rechts = root; root.links = nullNode; root = newNode; } else if( x.compareTo( root.daten ) > 0 ) { newNode.rechts = root.rechts; newNode.links = root; root.rechts = nullNode; root = newNode; } else return; } newNode = null; } /* * Remove. 55 Algorithmen und Datenstrukturen * Parameter x ist das zu entfernende Element. */ public void remove( Comparable x ) { BinaerBaumknoten neuerBaum; // Falls x gefunden wird, liegt x in der Wurzel root = splay( x, root ); if( root.daten.compareTo( x ) != 0 ) return; // Element nicht gefunden; tue nichts if( root.links == nullNode ) neuerBaum = root.rechts; else { // Finde das Maximum im linken Teilbaum // Splay es zur Wurzel; dann haenge das rechte Kind dran neuerBaum = root.links; neuerBaum = splay( x, neuerBaum ); neuerBaum.rechts = root.rechts; } root = neuerBaum; } /* * Bestimme das kleinste Daten-Element im Baum. * Rueckgabe: kleinstes Datenelement bzw. null, falls leer. */ public Comparable findMin( ) { if( isEmpty( ) ) return null; BinaerBaumknoten ptr = root; while( ptr.links != nullNode ) ptr = ptr.links; root = splay( ptr.daten, root ); return ptr.daten; } /* * Bestimme das groesste Datenelement im Baum. * Rueckgabe: das groesste Datenelement bzw. null, falls leer */ public Comparable findMax( ) { if (isEmpty( )) return null; BinaerBaumknoten ptr = root; while( ptr.rechts != nullNode ) ptr = ptr.rechts; root = splay( ptr.daten, root ); return ptr.daten; } /* * Bestimme ein Datenelement im Baum. * Parameter x entfält das zu suchende Element. * Rueckgabe: Das passende Datenelement oder null, falls leer */ public Comparable find( Comparable x ) { root = splay( x, root ); if (root.daten.compareTo( x ) != 0) return null; return root.daten; } /* * Mache den Baum logisch leer. */ public void makeEmpty( ) { root = nullNode; } /* * Ueberpruefe, ob der Baum logisch leer ist * Rueckgabe true, falls leer, anderenfalls false. 56 Algorithmen und Datenstrukturen */ public boolean isEmpty( ) { return root == nullNode; } /* * Gib den Inhalt des baums in sortierter Folge aus. */ public void printTree( ) { if (isEmpty( )) System.out.println( "Empty tree" ); else printTree( root ); } /* * Ausgabe des Binaerbaums um 90 Grad versetzt */ public void ausgBinaerbaum(BinaerBaumknoten b, int stufe) { if (b != b.links) { ausgBinaerbaum(b.links,stufe + 3); for (int i = 0; i < stufe; i++) { System.out.print(' '); } System.out.println(b.daten.toString()); ausgBinaerbaum(b.rechts,stufe + 3); } } /* * Interne Methode zur Ausfuehrung eines "top down" splay. * Der zuletzt im Zugriff befindliche Knoten * wird die neue Wurzel. * Parameter x Ist das Zielelement, die Umgebung fuer das Splaying. * Parameter b ist die Wurzel des Teilbaums, * um den das Splaying stattfindet. * Rueckgabe des Teilbaums. */ private BinaerBaumknoten splay( Comparable x, BinaerBaumknoten t ) { BinaerBaumknoten leftTreeMax, rightTreeMin; header.links = header.rechts = nullNode; leftTreeMax = rightTreeMin = header; nullNode.daten = x; // Guarantee a match for( ; ; ) if( x.compareTo( t.daten ) < 0 ) { if( x.compareTo( t.links.daten ) < 0 ) t = rotateWithLeftChild( t ); if( t.links == nullNode ) break; // Kette Rechts rightTreeMin.links = t; rightTreeMin = t; t = t.links; } else if( x.compareTo( t.daten ) > 0 ) { if( x.compareTo( t.rechts.daten ) > 0 ) t = rotateWithRightChild( t ); if( t.rechts == nullNode ) break; // Kette Links leftTreeMax.rechts = t; leftTreeMax = t; t = t.rechts; 57 Algorithmen und Datenstrukturen } else break; leftTreeMax.rechts = t.links; rightTreeMin.links = t.rechts; t.links = header.rechts; t.rechts = header.links; return t; } /* * Rotation BinaerBaumknoten mit linkem Nachfolger. */ static BinaerBaumknoten rotateWithLeftChild(BinaerBaumknoten k2) { BinaerBaumknoten k1 = k2.links; k2.links = k1.rechts; k1.rechts = k2; return k1; } /* * Rotation BinaerBaumknoten mit rechtem Nachfolger. */ static BinaerBaumknoten rotateWithRightChild(BinaerBaumknoten k1) { BinaerBaumknoten k2 = k1.rechts; k1.rechts = k2.links; k2.links = k1; return k2; } /* * Interne Methode zur Ausgabe eines Teilbaums in sortierter Folge. * Parameter b ist der jweilige Wurzelknoten. */ private void printTree( BinaerBaumknoten b ) { if( b != b.links ) { printTree(b.links); System.out.println(b.daten.toString( )); printTree(b.rechts); } } } SplaybaumTest import java.io.*; public class SplayBaumTest { public static void main( String [ ] args ) { SplayBaum b = new SplayBaum(); String eingabeZeile = null; System.out.println("Einfuegen"); BufferedReader eingabe = null; eingabe = new BufferedReader( new InputStreamReader(System.in)); try { int zahl; do { System.out.println("Zahl? "); 58 Algorithmen und Datenstrukturen eingabeZeile = eingabe.readLine(); try { zahl = Integer.parseInt(eingabeZeile); b.insert(new Integer(zahl)); b.ausgBinaerbaum(b.holeWurzel(),2); } catch (NumberFormatException ne) { break; } } while (eingabeZeile != ""); } catch (IOException ioe) { System.out.println("Eingefangen in main()"); } System.out.println("Loeschen"); try { int zahl; do { System.out.println("Zahl? "); eingabeZeile = eingabe.readLine(); try { zahl = Integer.parseInt(eingabeZeile); b.remove(new Integer(zahl)); b.ausgBinaerbaum(b.holeWurzel(),2); } catch (NumberFormatException ne) { break; } } while (eingabeZeile != ""); } catch (IOException ioe) { System.out.println("Eingefangen in main()"); } System.out.println("Zugriff auf das kleinste Element"); b.findMin(); b.ausgBinaerbaum(b.holeWurzel(),2); System.out.println("Zugriff auf das groesste Element"); b.findMax(); b.ausgBinaerbaum(b.holeWurzel(),2); } } 59 Algorithmen und Datenstrukturen 4.3.4 Rot-Schwarz-Bäume Zum Ausschluß des ungünstigsten Falls bei binären Suchbäumen ist eine gewisse Flexibilität in den verwendeten Datenstrukturen nötig. Das kann bspw. durch Aufnahme von mehr als einem Schlüssel in Baumknoten erreicht werden. So soll es 3-Knoten bzw. 4-Knoten geben, die 2 bzw. 3 Schlüssel enthalten können: - Ein 3-Knoten besitzt 3 von ihm ausgehende Verkettungen -- eine für alle Datensätze mit Schlüsseln, die kleiner sind als seine beiden Schlüssel -- eine für alle Datensätze, die zwischen den beiden Schlüsseln liegen -- eine für alle Datensätze mit Schlüsseln, die größer sind als seine beiden Schlüssel. - Ein 4-Knoten besitzt vier von ihm ausgehende Verkettungen, nämlich eine Verkettung für jedes der Intervalle, die durch seine drei Schlüssel definiert werden. Es ist möglich 2-3-4-Bäume als gewöhnliche binäre Bäume (mit nur zwei Knoten) darzustellen, wobei nur ein zusätzliches Bit je Knoten verwendet wird. Die Idee besteht darin, 3-Knoten und 4-Knoten als kleine binäre Bäume darzustellen, die durch „rote“ Verkettungen miteinander verbunden sind, im Gegensatz zu den schwarzen Verkettungen, die den 2-3-4-Baum zusammenhalten: oder 4-Knoten werden als 2-Knoten dargestellt, die mittels einer roten Verkettung verbunden sind. 3-Knoten werden als 2–Knoten dargestellt, die mit einer roten Markierung verbunden sind. Abb.: Rot-schwarze Darstellung von Bäumen Zu jedem 2-3-4-Baum gibt es viele ihm entsprechende Rot-Schwarz-Bäume. Diese Bäume haben eine Reihe von Eigenschaften, die sich unmittelbar aus ihrer Definition ergeben, z.B.: - Alle Pfade von der Wurzel zu einem Blatt haben dieselbe Anzahl von schwarzen Kanten. Dabei werden nur die Kanten zwischen inneren Knoten gezählt. - Längs eines beliebigen Pfads treten niemals zwei rote Verkettungen nacheinander auf. Rot-Schwarz-Bäume erlauben es, AVL-Bäume, perfekt ausgeglichene Bäume und viele andere Klassen binärer Bäume einheitlich zu repräsentieren und zu implementieren. 60 Algorithmen und Datenstrukturen Eine Variante zu Rot-Schwarz-Bäumen Definition. Ein Rot-Schwarz-Baum ist ein Binärbaum mit folgenden Farbeigenschaften: 1. Jeder Knoten ist entweder rot oder schwarz gefärbt. 2. der Wurzelknoten ist schwarz gefärbt. 3. Falls ein Knoten rot gefärbt ist, müssen seine Nachfolger schwarz gefärbt sein. 4. Jeder Pfad von einem Knoten zu einer „Null-Referenz“ muß die gleiche Anzahl von schwarzen Knoten enthalten. Höhe. Eine Folgerung dieser Farbregeln ist: Die Höhe eines Rot-Schwarz-Baums ist etwa 2 log( N 1) . Suchen ist garantiert unter logN erfolgreich. Aufgabe: Ermittle, welche Gestalt jeweils ein nach den vorliegenden Farbregeln erstellte Rot-Schwarz-Baum beim einfügen folgenden Schlüssel „10 85 15 70 20 60 30 50 65 80 90 40 5 55“ annimmt 10 10 85 10 85 15 15 10 85 70 15 10 85 70 20 15 10 70 20 85 61 Algorithmen und Datenstrukturen 60 15 10 70 20 85 60 30 15 10 70 30 20 85 60 50 30 15 10 70 20 60 85 50 65 30 15 10 70 20 60 50 85 65 80 30 15 10 70 20 60 50 85 65 62 80 Algorithmen und Datenstrukturen 90 30 15 10 70 20 60 50 85 65 80 90 40 30 15 10 70 20 60 50 85 65 80 90 40 5 30 15 10 70 20 60 5 50 85 65 80 90 40 55 30 15 10 70 20 60 5 50 40 85 65 55 9 85 80 90 Die Abbildungen zeigen, daß im durchschnitt der Rot-Schwarz-Baum ungefähr die Tiefe eines AVLBaums besitzt. Der Vorteil von Rot-Schwarz-Bäumen ist der geringere Overhead zur Ausführung von Einfügevorgängen und die geringere Anzahl von Rotationen. 63 Algorithmen und Datenstrukturen „Top-Down“ Rot-Schwarz-Bäume. Kann auf dem Weg nach unten festgestellt werden, daß ein Knoten X zwei rote Nachfolgeknoten hat, dann wird X rot und die beiden „Kinder“ schwarz: X c1 X c2 c1 c2 Das führt zu einem Verstoß gegen Bedingung 3, falls der Vorgänger von X auch rot ist. In diesem Fall können aber geeignete Rotationen herangezogen werden: G P X A P S X C A G B B S C G P X S X P C A A B1 G B1 B2 B2 S C Der folgende Fall kann nicht eintreten: Der Nachbar vom Elternknoten ist rot gefärbt. Auf dem Weg nach unten muß der Knoten mit zwei roten Nachfolgern einen schwarzen Großvaterknoten haben. Methoden zur Ausführung der Rotation. /* * Interne Routine, die eine einfache oder doppelte Rotation veranlasst. * Da das Ergebnis an "parent" geheftet wird, gibt es vier Faelle. * Aufruf durch reOrientierung. * "item" ist das Datenelement reOrientierung. * "parent" ist "parent" von der Wurzel des rotierenden Teilbaums. * Rueckgabe: Wurzel des rotierenden Teilbaums. */ private RotSchwarzKnoten rotation(Comparable item, RotSchwarzKnoten parent) { if (item.compareTo(parent.daten) < 0) return parent.links = item.compareTo( parent.links.daten) < 0 ? rotationMitLinksNachf(parent.links) : // LL rotationMitRechtsNachf(parent.links) ; // LR else return parent.rechts = item.compareTo(parent.rechts.daten) < 0 ? rotationMitLinksNachf(parent.rechts) : // RL rotationMitRechtsNachf(parent.rechts); // RR } /* 64 Algorithmen und Datenstrukturen * Rotation Binaerbaumknoten mit linkem Nachfolger. */ static RotSchwarzKnoten rotationMitLinksNachf( RotSchwarzKnoten k2 ) { RotSchwarzKnoten k1 = k2.links; k2.links = k1.rechts; k1.rechts = k2; return k1; } /* * Rotate Binaerbaumknoten mit rechtem Nachfolger. */ static RotSchwarzKnoten rotationMitRechtsNachf(RotSchwarzKnoten k1) { RotSchwarzKnoten k2 = k1.rechts; k1.rechts = k2.links; k2.links = k1; return k2; } Implementierung in Java15 Sie wird dadurch erschwert, daß einige Teilbäume (z.B. der rechte Teilbaum des Knoten 10 im vorliegenden Bsp.) leer sein können, und die Wurzel des Baums (, da ohne Vorgänger, ) einer speziellen Behandlung bedarf. Aus diesem Grund werden hier „Sentinel“-Knoten verwendet: - eine für die Wurzel. Dieser Knoten speichert den Schlüssel und einen rechten Link zu dem realen Knoten - einen Nullknoten (nullNode), der eine Null-Referenz anzeigt. Der Inorder-Durchlauf nimmt aus diesem Grund folgende Gestalt an: /* Ausgabe der im Baum gespeicherten Datenelemente in sortierter Reihenfolge. */ public void printTree( ) { if( isEmpty() ) System.out.println("Leerer Baum"); else printTree( header.rechts ); } /* * Interne Methode fuer die Ausgabe des Baum in sortierter Reihenfolge. * b ist der Wurzelknoten. */ private void printTree(RotSchwarzKnoten b) { if (b != nullNode ) { printTree(b.links); System.out.println(b.daten ); printTree(b.rechts ); } } Die Klasse RotSchwarzknoten 15 vgl. pr43220 65 Algorithmen und Datenstrukturen // BaumKnoten fuer RotSchwarzBaeume class RotSchwarzKnoten { // Instanzvariable public Comparable daten; // Dateninformation der Knoten protected RotSchwarzKnoten links; // linkes Kind protected RotSchwarzKnoten rechts; // rechtes Kind protected int farbe; // Farbe // Konstruktoren RotSchwarzKnoten(Comparable datenElement) { this(datenElement, null, null ); } RotSchwarzKnoten(Comparable datenElement, RotSchwarzKnoten l, RotSchwarzKnoten r) { daten = datenElement; links = l; rechts = r; farbe = RotSchwarzBaum.BLACK; } } Das Gerüst der Klasse RotSchwarzBaum und Initialisierungsroutinen // // // // // // // // // // // // // // Die Klasse RotSchwarzBaum Konstruktion: mit einem "negative infinity sentinel" ******************PUBLIC OPERATIONEN********************* void insert(x) --> Insert x void remove(x) --> Entferne x (nicht implementiert) Comparable find(x) --> Ruecggabe des Datenelements, das x enthaelt Comparable findMin() --> Rueckgabe des kleinsten Datenelements Comparable findMax() --> Rueckgabe des groessten Datenelements boolean isEmpty() --> Rueckgabe true, falls leer; anderenfalls false void makeEmpty() --> Entferne alles void printTree() --> Ausgabe in aufsteigend sortierter Folge void ausgRotSchwarzBaum() --> Ausgabe des Baums um 90 Grad versetzt /* * Implementierung eines RotSchwarzBaum. * Vergleiche basieren auf der Methode compareTo. */ public class RotSchwarzBaum { private RotSchwarzKnoten header; private static RotSchwarzKnoten nullNode; static // Static Initialisierer for nullNode { nullNode = new RotSchwarzKnoten(null); nullNode.links = nullNode.rechts = nullNode; } static final int BLACK = 1; // Black must be 1 static final int RED = 0; /* * Baumkonstruktion. * negInf ist ein Wert, der kleiner oder gleich zu allen anderen Werten * ist. */ public RotSchwarzBaum(Comparable negInf) { header = new RotSchwarzKnoten(negInf); header.links = header.rechts = nullNode; 66 Algorithmen und Datenstrukturen } ….. } Die Methode „insert“ // Fuer "insert routine" und zugehoerige unterstuetzende Routinen private static RotSchwarzKnoten current; private static RotSchwarzKnoten parent; private static RotSchwarzKnoten grand; private static RotSchwarzKnoten great; /* Einfügen in den Baum. Duplikate werden ueberlesen. * "item" ist das einzufuegende Datenelement. */ public void insert(Comparable item) { current = parent = grand = header; nullNode.daten = item; while( current.daten.compareTo( item ) != 0 ) { great = grand; grand = parent; parent = current; current = item.compareTo(current.daten ) < 0 ? current.links : current.rechts; // Pruefe, ob zwei rote Kinder; falls es so ist, fixiere es if( current.links.farbe == RED && current.rechts.farbe == RED ) reOrientierung( item ); } // Fehlanzeige fuer Einfuegen, falls schon da if( current != nullNode ) return; current = new RotSchwarzKnoten( item, nullNode, nullNode ); // Anhaengen an die Eltern if( item.compareTo( parent.daten ) < 0 ) parent.links = current; else parent.rechts = current; reOrientierung( item ); } /* * Interne Routine, die waehrend eines Einfuegevorgangs aufgerufen wird * Falls ein Knoten zwei rote Kinder hat, fuehre Tausch der Farben aus * und rotiere. * item enthaelt das einzufuegende Datenelement. */ private void reOrientierung(Comparable item) { // Tausch der Farben current.farbe = RED; current.links.farbe = BLACK; current.rechts.farbe = BLACK; if (parent.farbe == RED) // Rotation ist noetig { grand.farbe = RED; if ( (item.compareTo( grand.daten) < 0 ) != (item.compareTo( parent.daten) < 0 ) ) parent = rotation(item, grand); // Start Doppelrotation current = rotation(item, great); current.farbe = BLACK; } header.rechts.farbe = BLACK; // Mache die Wurzel schwarz } Bsp.: Java-Applet16 zur Darstellung eines Rot-Schwarz-Baums 16 vgl. pr43222 67 Algorithmen und Datenstrukturen Abb.: 68 Algorithmen und Datenstrukturen 4.3.5 AA-Bäume Die Implementierung eines Rot-Schwarz-Baums ist sehr trickreich (insbesondere das Löschen von Baumknoten). Bäume, die diese trickreiche Programmierung einschränken, sind der binäre B-Baum und der AA-Baum. Definitionen Ein BB-Baum ist ein Rot-Schwarz-Baum mit einer besonderen Bedingung: Ein Knoten hat, falls es sich ergibt, eine „roten“ Nachfolger“. Zur Vereinfachung der Implementierung kann man noch einige Regeln hinzufügen und darüber einen AA-Baum definieren: 1. Nur ein Nachfolgeknoten kann rot sein. Das bedeutet: Falls ein interner Knoten nur einen Nachfolger hat, dann muß er rot sein. Ein schwarzer Nachfolgeknoten verletzt die 4. Bedingung von Rot-SchwarzBäumen. Damit kann ein innerer Knoten immer durch den kleinsten Knoten seines rechten Teilbaums ersetzt werden. 2. Anstatt ein Bit zur Kennzeichnung der Färbung wird die Stufe zum jeweiligen Knoten bestimmt und gespeichert. Die Stufe eines Knoten ist - Eins, falls der Knoten ein Blatt ist. - Die Stufe eines Vorgängerknoten, falls der Knoten rot gefärbt ist. - Die Stufe ist um 1 niedriger als die seines Vorgängers, falls der Knoten schwarz ist. Eine horizontale Verkettung zeigt die Verbindung eines Knoten und eines Nachfolgeknotens mit gleicher Stufenzahl an. Der Aufbau verlangt, daß horizontale Verkettungen rechts liegen und daß es keine zwei aufeinander folgende horizontale Verkettungen gibt. Darstellung In einen zunächst leeren AA-Baum sollen Schlüssel in folgender Reihenfolge eingefügt werden: 10, 85 15, 70, 20, 60, 30, 50, 65, 80, 90, 40, 5, 55, 35. 10 10 85 10 85 15 10 15 85 „skew“ 15 „split“ 10 85 69 Algorithmen und Datenstrukturen 70 15 10 70 20 85 15 10 70 20 85 60 15 10 70 20 60 85 30 15 10 70 20 60 85 30 15 10 30 20 60 15 85 30 20 70 70 70 60 85 Algorithmen und Datenstrukturen 30 15 10 70 20 60 85 50 30 15 10 70 20 50 60 85 65 30 15 10 60 20 70 50 65 85 80 30 15 10 60 20 70 50 65 80 90 30 15 10 70 60 20 50 71 85 65 80 90 85 Algorithmen und Datenstrukturen 40 30 70 15 60 10 20 40 50 85 65 80 90 5 30 70 15 5 60 10 20 40 50 85 65 80 90 55 30 70 15 5 10 50 20 40 60 55 85 65 80 90 35 30 15 5 10 70 20 50 35 40 Abb.: 72 60 55 85 65 80 90 Algorithmen und Datenstrukturen Implementierung Der Knoten des AA-Baums: // Baumknoten fuer AA-Baeume class AAKnoten { public Comparable daten; // Datenelement im Knoten protected AAKnoten links; // linkes Kind protected AAKnoten rechts; // rechtes Kind protected int level; // Level // Konstruktoren AAKnoten(Comparable datenElement) { this(datenElement, null, null); } AAKnoten(Comparable datenElement, AAKnoten l, AAKnoten r) { daten = datenElement; links = l; rechts = r; level = 1; } } Operationen an AA-Bäumen: Ein „Sentinel“ repräsentiert „null“. Suchen. Es erfolgt nach der in binären Suchbäumen üblichen Weise. Einfügen von Knoten. Es erfolgt auf der untersten Stufe (bottom level) /* * Interne Methode zum Einfuegen in einen Teilbaum. * "x" enthaelt das einzufuegende Element. * "b" ist die Wurzel des Baums. * Rueckgabe: die neue Wurzel. */ private AAKnoten insert(Comparable x, AAKnoten b) { if (b == nullNode) b = new AAKnoten(x, nullNode, nullNode); else if (x.compareTo(b.daten) < 0 ) b.links = insert(x, b.links); else if (x.compareTo(b.daten) > 0 ) b.rechts = insert(x, b.rechts); else return b; b = skew(b); b = split(b); return b; } /* * Skew Primitive fuer AA-Baeume. * "b" ist die Wurzel. * Rueckgabe: die neue wurzel nach der Rotation. */ private AAKnoten skew(AAKnoten b) { 73 Algorithmen und Datenstrukturen if (b.links.level == b.level ) b = rotationMitLinksNachf(b); return b; } /* * Split fuer AABaeume. * "b" ist die Wurzel. * Rueckgabe: die neue wurzel nach der Rotation. */ private AAKnoten split(AAKnoten b) { if (b.rechts.rechts.level == b.level ) { b = rotationMitRechtsNachf(b); b.level++; } return b; } Bsp.: 30 70 15 5 50 10 20 35 60 40 55 85 65 80 90 1. Einfügen des Schlüssels 2 Erzeugt wird eine linke horizontale Verknüpfung 2. Einfügen des Schlüssels 45 Erzeugt werden zwei rechte horizontale Verbindungen 30 70 15 2 5 50 10 20 35 40 60 45 55 In beiden Fällen können einfache Rotationen ausgleichen: 74 85 65 80 90 Algorithmen und Datenstrukturen - Linke horizontale Verknüpfungen werden durch ein einfache rechte Rotation behoben („skew“) X P X P C A C B A B Abb.: Rechtsrotation z.B.: 30 5 2 70 15 10 50 20 35 40 45 60 55 85 65 80 90 Abb.: - Aufeinanderfolgende rechte horizontale Verknüpfungen werden durch einfach linke Rotation behoben (split) X R G R C X A G B C A B Abb.: Linksrotation z.B. „Einfügen des Schlüssels 45“ 30 70 15 5 10 50 20 35 40 Abb.: 75 45 60 55 85 65 80 90 Algorithmen und Datenstrukturen „split“ am Knoten mit dem Schlüssel 35 30 15 5 70 40 10 20 35 50 60 45 55 85 65 80 90 „skew“ am Knoten mit dem Schlüssel 50 30 15 5 70 40 10 20 35 50 60 45 55 85 65 80 90 „split“ am Knoten mit dem Schlüssel 40 30 70 15 5 50 10 20 85 40 35 60 45 55 80 90 65 Endgültige Baumgestalt nach „skew“ am Knoten 70 und „split“ am Knoten 30 50 30 70 15 5 10 40 20 35 60 45 55 76 85 65 80 90 Algorithmen und Datenstrukturen Löschen von Knoten. /* * Interne Methode fuer das Entfernen aus einem Teilbaum. * Parameter x ist das zu entfernende Merkmal. * Parameter b ist die Wurzel des Baums. * Rueckgabe: die neue Wurzel. */ private AAKnoten remove(Comparable x, AAKnoten b) { if( b != nullNode ) { // Schritt 1: Suche bis zum Grund des Baums, setze lastNode und // deletedNode lastNode = b; if( x.compareTo( b.daten ) < 0 ) b.links = remove( x, b.links ); else { deletedNode = b; b.rechts = remove( x, b.rechts ); } // Schritt 2: Falls am Grund des Baums und // x ist gegenwaertig, wird es entfernt if( b == lastNode ) { if( deletedNode == nullNode || x.compareTo( deletedNode.daten ) != 0 ) return b; // Merkmal nicht gefunden; es geschieht nichts deletedNode.daten = b.daten; b = b.rechts; } // Schritt 3: Anderenfalls, Boden wurde nicht erreicht; Rebalancieren else if( b.links.level < b.level - 1 || b.rechts.level < b.level - 1 ) { if( b.rechts.level > --b.level ) b.rechts.level = b.level; b = skew( b ); b.rechts = skew( b.rechts ); b.rechts.rechts = skew( b.rechts.rechts ); b = split( b ); b.rechts = split( b.rechts ); } } return b; } 77 Algorithmen und Datenstrukturen 4.4 Bayer-Bäume 4.4.1 Grundlagen und Definitionen 4.4.1.1 Ausgeglichene T-äre Suchbäume (Bayer-Bäume) Bayer-Bäume sind für die Verwaltung der Schlüssel zu Datensätzen in umfangreichen Dateien vorgesehen. Der binäre Baum ist für die Verwaltung solcher Schlüssel nicht geeignet, da er nur jeweils einen Knoten mit einem einzigen Datensatz adressiert. Die Daten (Datensätze) stehen blockweise zusammengefaßt auf Massenspeichern, der Binärbaum müßte Knoten für Knoten auf einen solchen Block abgebildet werden. Jeder Zugriff auf den Knoten des Baums würde ein Zugriff auf den Massenspeicher bewirken. Da ein Plattenzugriff relativ zeitaufwendig ist, hätte man die Vorteile der Suchbäume wieder verloren. In einen Knoten ist daher nicht nur ein Datum aufzunehmen, sondern maximal (T - 1) Daten. Ein solcher Knoten hat T Nachfolger. Die Eigenschaften der knotenorientierten "T-ären" Intervallbäume sind: - Jeder Knoten enthält max. (T - 1) Daten - Die Daten in einem Knoten sind aufsteigend sortiert - Ein Knoten enthält maximal T Teilbäume - Die Daten (Schlüssel) der linken Teilbäume sind kleiner als das Datum der Wurzel. - Die Daten der rechten Teilbäume sind größer als das Datum der Wurzel. - Alle Teilbäume sind T-äre Suchbäume. Durch Zusammenfassen mehrerer Knoten kommt man so vom binären zum "T-ären" Suchbaum, z.B.: 8 4 12 2 1 3 5 2 3 11 9 7 4 1 14 10 6 5 6 8 Abb.: 78 15 12 9 7 13 10 11 13 14 15 Algorithmen und Datenstrukturen T-äre Bäume haben aber noch einen schwerwiegenden Nachteil. Sie können leicht zu entarteten Bäumen degenerieren. Bsp.: Ein entarteter 5-ärer Baum enthält durch Eingabe (Einfügen) der Elemente 1 bis 16 in aufsteigender Folge folgende Gestalt: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 Abb.: Durch Einfügen und Löschen können sich beliebig unsymmetrische Strukturen herausbilden. Algorithmen zum Ausgleichen, vergleichbar mit den AVL-Rotationen sind jedoch nicht bekannt. Zunehmend an Bedeutung gewinnt ein höhengleicher Baum (B-Baum), der von R. Bayer eingeführt wurde. Höhengleichheit kann erreicht werden, wenn folgende Eigenschaften eingehalten werden: - Ein Knoten enthält höchstens 2 M Schlüssel ( 2 M 1 -ärer Baum). Jeder Bayer-Baum (B-Baum) besitzt eine Klasse, im vorliegenden Fall die Klasse M. M heißt Ordnung des Baums17. - Jeder Knoten (Ausname: Wurzel) enthält mindestens M Schlüssel, höchstens 2 M Schlüssel. - Jeder Nichtblattknoten hat daher zwischen ( M 1 ) und( 2 M 1 ) Nachfolger. - Alle Blattknoten liegen auf der gleichen Stufe. - B-Bäume sind von (a,b)-Bäumen abgeleitet. 17 In einigen Büchern wird als Ordnung des Baums der Verzweigungsgrad bezeichnet, hier also 2M+1 79 Algorithmen und Datenstrukturen 4.4.1.2 (a,b)-Bäume Ein (a,b)-Baum ist ein (externer) Suchbaum, für den gilt: - Alle Blätter haben die gleiche Tiefe - Schlüssel sind nur in den Blättern gespeichert18. - Für alle Knoten k (außer Wurzeln und Blättern) gilt a Anzahl _ der _ Kinder (k ) b - b 2 a 1 - Für alle inneren Knoten gilt: Hat k l Kinder, so sind in k l-1 Werte k1, ..., ki-1 gespeichert und es gilt: k i 1 key( w) k i für alle Knoten w im i-ten Unterraum von k . n - Falls B ein (a.b)-Baum mit n Blättern ist, dann gilt log b (n) Höhe ( B) 1 log a ( ) . Der rechte 2 Teile der Ungleichung resultiert daraus, daß bei Bäumen mit Tiefe größer als 1 die Wurzel wenigstens zwei Kinder hat, eines der Kinder hat maximal n/2 Blätter und minimalen Verzweigungsgrad a. - B-Bäume sind Spezialfälle von (a.b)-Bäumen mit b 2 a 1 Bsp.: 21, 39 7,15 1,4 1 32 9 4 7 9 15 17 17 21 52, 62 24 24 35 32 35 39 43 43,47 47 52 53,56 53 56 Abb.: (2,3) - Baum 2 1 1 3.4.5 2 3 4 5 Abb.: (2,4) - Baum 18 In dieser Sicht unterscheiden sich (a,b)-Bäume von den hier angesprochenen B-Bäumen. 80 62 67 67 71 Algorithmen und Datenstrukturen 4.4.2 Darstellung von Bayer-Bäumen +-----------------------------------+ | | | Z0S1Z1S2Z2S3 ........... ZN-1SNZN | | | +-----------------------------------+ Zl ... Zeiger Sl ... Schluessel Alle Schlüssel in einem Teilbaum, auf den durch Z l-1 verwiesen wird, sind kleiner als Sl. Alle Schlüssel in einem Unterbaum, auf den durch Z l verwiesen wird, sind größer als Sl. In einem B-Baum der Höhe H befinden sich daher zwischen N min 2 ( M 1) H 1 1 und N max 2 ( M 1) H 1 Schlüssel. Neue Schlüssel werden stets in den Blättern zugefügt. Aufgabe: Gegeben ist die folgende Schlüsselfolge: „1, 7, 6, 2, 11, 4, 8, 13, 10, 5, 19, 9, 18, 24, 3, 12, 14, 20, 21, 17“. Bestimme die zugehörigen Strukturen eines 5-ären Baumes. 1) 1, 7, 6, 2 1 6 2 7 2) 11 6 1 2 7 11 7 8 3) 4, 8, 13 6 1 2 4 11 13 11 13 4) 10 6 1 2 4 10 7 8 5) 5, 19, 9, 18 81 Algorithmen und Datenstrukturen 6 1 2 4 5 10 7 8 9 11 13 18 19 11 13 19 24 13 14 19 20 6) 24 6 1 2 4 5 10 7 8 18 9 7) 3, 12, 14, 20, 21 1 2 4 5 3 6 7 8 10 9 18 11 12 21 24 Abb.: a) C++-Darstellung Zeiger ZI und Schlüssel SI eines jeden Knoten sind folgendermaßen angeordnet: +----------------------------- ------+ | | | Z0S0Z1S1Z2S2 ........... ZN-1SN-1ZN | | | +-------------------------------------+ Das führt zu der folgenden Beschreibung des B-Baums der Ordnung 2 (mit 5 Kettengliedern je Knoten). // // B-Baum mit bis zu MAERER Verkettungen (mit Knoten die bis zu MAERER Verkettungen enthalten) #include <iostream.h> #include <iomanip.h> #include <ctype.h> #define MAERER 5 // Anzahl Verkettungen im B-Baum: // MAERER Verkettungsfelder je Knoten typedef int dtype; enum status {unvollstaendigesEinfuegen, erfolgreich, doppelterSchluessel, Unterlauf, nichtGefunden}; struct knoten { int n; // Anzahl der Elemente, die in einem Knoten // gespeichert sind (n < MAERER) dtype s[MAERER-1]; // Datenelemente (aktuell sind n) knoten *z[MAERER]; // Zeiger auf andere Knoten (aktuell sind n+1) }; 82 Algorithmen und Datenstrukturen // Logische Ordnung: // z[0], s[0], z[1], s[1], ..., z[n-1], s[n-1], z[n] class Bbaum { private: knoten *wurzel; status einf(knoten *w, dtype x, dtype &y, knoten* &q); void ausg(const knoten* w, int nLeer)const; int knotenSuche(dtype x, const dtype *a, int n)const; status loe(knoten *w, dtype x); public: Bbaum(): wurzel(NULL){} void einfuegen(dtype x); void gibAus()const{cout << "Dateninhalt:\n"; ausg(wurzel, 0);} void loeschen(dtype x); void zeigeSuche(dtype x)const; }; b) Java-Darstellung Die Klasse CBNode zeigt die Implementierung eines Bayer-Baumknotens19. Der Konstruktor dieser Klasse leistet die Arbeit und bekommt dazu einen Übergabeparameter (M) geliefert, der die Ordnung des (M-ären) Bayer-Baums beschreibt. import java.util.*; /* * The CBNode class (Repraesentation eines Bayer-Baum-Knoten)*/ public class CBNode{ // Instanzvariable protected Vector key, nodeptr, initkey, initvec; int count; // Konstruktor /* * constructs a single Bayer Tree node with M references to subnodes. */ CBNode(int M) { // System.out.println("CBNode(): constructor invoked!"); nodeptr = new Vector(); initvec = new Vector(); initvec = null; key = new Vector(); initkey = new Vector(); initkey = null; for(int i = 0; i <= M; i++) { nodeptr.addElement((Object)initvec); /* System.out.println(i + "ter Nodepointer erzeugt. Wert: " + nodeptr.elementAt(i)); */ } for(int j = 0; j <= M - 1; j++) { key.addElement((Object)initkey); // System.out.println(j + "ter Key erzeugt. Wert: " + key.elementAt(j)); } count = 0; // System.out.println("count Wert: " + count); }} 4.4.3 Suchen eines Schlüssels Gegeben ist der folgende Ausschnitt eines B-Baums mit N Schlüsseln: +----------------------------- ------+ | | | Z0S0Z1S1Z2S2 ........... ZN-1SN-1ZN | | | +-------------------------------------+ S0<S1<S2<.....<SN-1 19 Vgl. pr44200, CBNode.java 83 Algorithmen und Datenstrukturen Handelt es sich beim Knoten um ein Blatt, dann ist der Wert eines jeden Zeigers Z I im Knoten NULL. Falls der Knoten kein Blatt ist, verweisen einige der (N+1) Zeiger auf andere Knoten (Kinder des aktuellen Knoten). I > 0: Alle Schlüssel im betreffenden Kind-Knoten, auf den ZI zeigt, sind größer als SI-1 I < N: Alle Schlüssel im betreffenden Kind, auf das Z I zeigt, sind kleiner als SN. Hat einer der angegebenen Zeiger den Wert „null“, dann existiert in dem vorliegenden Baum kein Teilbaum, der diesen Schlüssel enthält (d.h. die Suche ist beendet). a) C++-Implementierung int Bbaum::knotenSuche(dtype x, const dtype *a, int n)const { int i=0; while (i < n && x > a[i]) i++; return i; } void Bbaum::zeigeSuche(dtype x)const { cout << "Suchpfad:\n"; int i, j, n; knoten *w = wurzel; while (w) { n = w->n; for (j = 0; j < w->n; j++) cout << cout << endl; i = knotenSuche(x, w->s, n); if (i < n && x == w->s[i]) { cout << "Schluessel " << x << " << " vom zuletzt angegebenen return; } w = w->z[i]; } cout << "Schluessel " << x << " wurde } " " << w->s[j]; wurde in Position " << i Knoten gefunden.\n"; nicht gefunden.\n"; b) Java-Implementierung /* *Rueckgabe ist true, falls der Schlüssel gefunden wurde; * anderenfalls false. */ public boolean searchkey(int key) { boolean found = false; int i = 0, n; CBNode node = root; while(node != null) { i = 0; n = node.count; // search key in actual node while(i < n && key > ((Integer)node.key.elementAt(i)).intValue()) { i++; } // end while if(i < n && key == ((Integer)node.key.elementAt(i)).intValue()) { found = true; } if(node.nodeptr.elementAt(i) != null) { mpCVisualizeTree.MoveLevelDown(i); } node = (CBNode) node.nodeptr.elementAt(i); } // end while (node != null) return found; } // end searchkey Methode 4.4.4 Einfügen Bsp.: Der Einfügevorgang in einem Bayer-Baum der Klasse 2 1) Aufnahme der Schlüssel 1, 2, 3, 4 in den Wurzelknoten 1 2 3 4 84 Algorithmen und Datenstrukturen 2) Zusätzlich wird der Schlüssel mit dem Wert 5 eingefügt 1 3 2 4 5 Normalerweise würde jetzt rechts von "4" ein neuer Knoten erzeugt. Das würde aber zu einem Knotenüberlauf führen. Nach dieser Erweiterung enthält der Knoten eine ungerade Zahl von Elementen ( 2 M 1 ). Dieser große Knoten kann in 2 Söhne zerlegt werden, nur das mittlere Element verbleibt im Vaterknoten. Die neuen Knoten genügen wieder den B-Baum-Eigenschaften und können weitere Daten aufnehmen. 3 1 2 4 5 Abb.: Beschreibung des Algorithmus für das Einfügen Ein neues Element wird grundsätzlich in einen Blattknoten eingefügt. Ist der Knoten mit 2 M Schlüsseln voll, so läuft bei der Aufnahme eines weiteren Schlüssels der Knoten über. +---------------------------------------------+ | | | ........... SX-1ZX-1SXZX ..... | | | +---------------------------------------------+ +----------------------------------------------+ | | | Z0S1Z1 .... ZM-1SMZMSM+1 ....... Z2MS2M+1 | | Überlauf | +----------------------------------------------+ Abb.: Der Knoten wird geteilt: Die vorderen Schlüssel verbleiben im alten Knoten, der Schlüssel mit der Nummer M+1 gelangt als Trennschlüssel in den Vorgängerknoten. Die M Schlüssel mit den Nummern M+2 bis 2 M 1 kommen in den neuen Knoten. +-----------------------------+ | ....SX-1ZX-1SM+1ZYSXZX .... | +-----------------------------+ +------------------+ |Z0S1 .... ZM-1SMZM| +------------------+ +-----------------------------+ |ZM+1SM+2ZM+2 ..... S2M+1Z2M+1| +-----------------------------+ Abb.: Die geteilten Knoten enthalten genau M Elemente. Das Einfügen eines Elements in der vorangehenden Seite kann diese ebenfalls zum Überlaufen bringen und somit 85 Algorithmen und Datenstrukturen die Aufteilung fortsetzen. Der B-Baum wächst demnach von den Blättern bis zur Wurzel. a) Methoden zum Einfügen der Klasse Bbaum (in C++) Zwei Funktionen (Methoden der Klasse Bbaum) „einfuegen“ und „einf“ teilen sich die Arbeit. Der Aufruf dieser Funktionen erfolgt bspw. über b.einfuegen(x)20; bzw. in der Methode einfuegen() über status code = einf(wurzel, x, xNeu, zNeu);. xNeu, zNeu sind lokale Variable in einfuegen(). Die private Methode einf() fiefert einen „code“ zurück: unvollstaendigesEinfuegen , falls (insgesamt gesehen) der Einfügevorgang noch nicht vollständig abgeschlossen wurde. erfolgreich , falls das Einfügen des Schlüssels x erfolgreich war doppelterSchluessel , falls x bereits im Bayer-Baum ist. In den „code“-Fällen „erfolgreich“ bzw. „doppelterSchluessel“ haben xNeu und zNeu keine Bedeutung. Im Fall „unvollstaendigesEinfuegen“ sind noch weitere Vorkehrungen zu treffen: - Falls überhaupt noch kein Bayer-Baum vorliegt (wurzel == NULL) ist ein neuer Knoten zu erzeugen und der Wurzel zuzuordnen. In diesem Fall enthält der Wurzelknoten dann einen Schlüssel. - In der Regel tritt unvollständiges Einfügen auf, wenn der Knoten, in den der Schlüssel x eingefügt werden soll, keinen Platz mehr besitzt. Ein Teil der Schlüssel kann im alten Knoten verbleiben, der andere Teil muß in einen neuen Knoten untergebracht werden. void Bbaum::einfuegen(dtype x) { knoten *zNeu; dtype xNeu; status code = einf(wurzel, x, xNeu, zNeu); if (code == doppelterSchluessel) cout << "Doppelte Schluessel werden ignoriert.\n"; if (code == unvollstaendigesEinfuegen) { knoten *wurzel0 = wurzel; wurzel = new knoten; wurzel->n = 1; wurzel->s[0] = xNeu; wurzel->z[0] = wurzel0; wurzel->z[1] = zNeu; } } status Bbaum::einf(knoten *w, dtype x, dtype &y, knoten* &q) { // Fuege x in den aktuellen Knoten, adressiert durch *this ein. // Falls nicht voll erfolgreich, sind noch Ganzzahl y und Zeiger q // einzufuegen. // Rueckgabewert: // erfolgreich, doppelterSchluessel oder unvollstaendigesEinfuegen. knoten *zNeu, *zFinal; int i, j, n; dtype xNeu, sFinal; status code; if (w == NULL){q = NULL; y = x; return unvollstaendigesEinfuegen;} n = w->n; i = knotenSuche(x, w->s, n); if (i < n && x == w->s[i]) return doppelterSchluessel; code = einf(w->z[i], x, xNeu, zNeu); if (code != unvollstaendigesEinfuegen) return code; // Einfuegen im untergeordneten Baum war nicht voll erfolgreich; // Versuch zum Einfuegen in xNeu und zNeu vom aktuellem Knoten: if (n < MAERER - 1) 20 „b“ ist eine Instanz von Bbaum 86 Algorithmen und Datenstrukturen { } // // // // // // if { i = knotenSuche(xNeu, w->s, n); for (j=n; j>i; j--) { w->s[j] = w->s[j-1]; w->z[j+1] = w->z[j]; } w->s[i] = xNeu; w->z[i+1] = zNeu; ++w->n; return erfolgreich; Der aktuelle Knoten ist voll (n == MAERER - 1) und muss gesplittet werden. Reiche das Element s[h] in der Mitte der betrachteten Folge zurueck ueber Parameter y, damit es aufwaerts im Baum plaziert werden kann. Reiche auch einen Zeiger zum neu erzeugten Knoten zurueck (als Verweis) ueber den Parameter q: (i == MAERER - 1) {sFinal = xNeu; zFinal = zNeu;} else sFinal = w->s[MAERER-2]; zFinal = w->z[MAERER-1]; for (j=MAERER-2; j>i; j--) { w->s[j] = w->s[j-1]; w->z[j+1] = w->z[j]; } w->s[i] = xNeu; w->z[i+1] = zNeu; } int h = (MAERER - 1)/2; y = w->s[h]; // y und q werden zur naechst hoeheren Stufe q = new knoten; // im Baum weitergereicht // Die Werte z[0],s[0],z[1],...,s[h-1],z[h] gehoeren zum linken Teil von // s[h] and werden gehalten in *w: w->n = h; // z[h+1],s[h+1],z[h+2],...,s[MAERER-2],z[MAERER-1],sFinal,zFinal // gehoeren zu dem rechten Teil von s[h] und werden nach *q gebracht: q->n = MAERER - 1 - h; for (j=0; j < q->n; j++) { q->z[j] = w->z[j + h + 1]; q->s[j] = (j < q->n - 1 ? w->s[j + h + 1] : sFinal); } q->z[q->n] = zFinal; return unvollstaendigesEinfuegen; } b) Methoden zum Einfügen in Java /* *Einfügen eines neuen Schlüssels. Rückgabe ist –1, falls * es misslingt, anderenfalls 0 * Parameter: value einzufuegender Wert */ public int Insert(Integer value) { if(root == null) { root = new CBNode(MAERER); root.key.setElementAt((Object)value, 0); root.count = 1; } // end if else { if(searchkey(((Integer)value).intValue()) == true) { //System.out.println("double key found and will be ignored!"); return 1; } // end if CBNode result; result = insrekurs(root, value); if(result != null) { CBNode node = new CBNode(MAERER); node.key.setElementAt(newValue, 0); node.nodeptr.setElementAt(root, 0); node.nodeptr.setElementAt(result, 1); node.count = 1; root = node; } // end if(result) } // end else mpCVisualizeTree.DeleteRootKnot(); mpCExtendedCanvas.repaint(); rootflag = 0; this.drawTree(root); mpCExtendedCanvas.repaint(); return 0; } // end Insert() Methode Die Methode „Insert“ ruft „insrekurs()“ auf. Diese rekursive Methode leistet die eigentliche Arbeit /* * der neue Wert wird rekursiv in den Baum eingebracht */ protected CBNode insrekurs(CBNode tempnode, Integer insValue) { CBNode result; result = null; newValue = insValue; if(tempnode.nodeptr.elementAt(0) != null) // kein Blatt -> Rekursion { int pos = 0; while(pos < tempnode.count && newValue.intValue() > ((Integer)tempnode.key.elementAt(pos)).intValue()) { pos++; } // end while result = insrekurs((CBNode)tempnode.nodeptr.elementAt(pos), newValue); if(result == null) { return null; // if result = null: nothing has to be inserted into 87 Algorithmen und Datenstrukturen // node-> finished! } // end if(resul == null) } // end if(tempnode.nodeptr.elementAt(0) != null) // insert a element CBNode node = null; int flag = 0; int s = tempnode.count; if(s >= MAERER - 1) // split the knot { tempnode.count = (MAERER - 1) / 2; node = new CBNode(MAERER); node.count = (MAERER - 1) / 2; for(int d = ((MAERER 1) / 2); d > 0;) { if(flag != 0 || ((Integer)tempnode.key.elementAt(s - 1)).intValue() > newValue.intValue()) { node.nodeptr.setElementAt(tempnode.nodeptr.elementAt(s), d); node.key.setElementAt(tempnode.key.elementAt(--s), --d); } // end if(flag != 0 ... else { node.nodeptr.setElementAt(result, d); node.key.setElementAt(newValue, --d); flag = 1; } // end else } // end if(s >= MAERER - 1) if(flag != 0 || ((Integer)tempnode.key.elementAt(s - 1)).intValue() > newValue.intValue()) { node.nodeptr.setElementAt(tempnode.nodeptr.elementAt(s), 0); } // end if else { node.nodeptr.setElementAt(result, 0); } // end else } // end if(s >= MAERER - 1) else { tempnode.count++; } // end else // shift for(; s > 0 && ((Integer)tempnode.key.elementAt(s - 1)).intValue() > newValue.intValue(); s--) { tempnode.nodeptr.setElementAt(tempnode.nodeptr.elementAt(s), s + 1); tempnode.key.setElementAt(tempnode.key.elementAt(s - 1), s); } // end for tempnode.key.setElementAt(newValue, s); tempnode.nodeptr.setElementAt(result, s + 1); newValue = (Integer) tempnode.key.elementAt((MAERER - 1) / 2); return node; } // end insrekurs() methode 4.4.5 Löschen Grundsätzlich ist zu unterscheiden: 1. Das zu löschende Element ist in einem Blattknoten 2. Das Element ist nicht in einem Blattknoten enthalten. In diesem Fall ist es durch eines der benachbarten Elemente zu ersetzen. Entlang des rechts stehenden Zeigers Z ist hier zum Blattknoten hinabzusteigen und das zu löschende Element durch das äußere linke Element von Z zu ersetzen. Auf jeden Fall darf die Anzahl der Schlüssel im Knoten nicht kleiner als M werden. Ausgleichen Die Unterlauf-Gegebenheit (Anzahl der Schlüssel ist kleiner als M) ist durch Ausleihen oder "Angliedern" eines Elements von einem der benachbarten Knoten abzustellen. Zusammenlegen Ist kein Element zum Angliedern übrig (, der benachbarte Knoten hat bereits die minimale Größe erreicht), dann enthalten die beiden Knoten je 2 M 1 Elemente. Beide Knoten können daher zusammengelegt werden. Das mittlere Element ist dazu aus den dem Knoten vorausgehenden Knoten zu entnehmen und der NachbarKnoten ist ganz zu entfernen. Das Herausnehmen des mittleren Schlüssels in der vorausgehenden Seite kann nochmals die Größe unter die erlaubte Grenze fallen lassen und gegebenenfalls auf der nächsten Stufe eine weitere Aktion hervorrufen. Bsp.: Gegeben ist ein 5-ärer B-Baum (der Ordnung 2) in folgender Gestalt: 88 Algorithmen und Datenstrukturen 50 30 10 20 40 42 38 35 60 44 46 56 58 65 80 70 90 95 Abb.: 1) Löschen der Schlüssel 44, 80 50 30 10 20 35 40 38 42 56 46 58 65 Abb.: 2) Einfügen des Schlüssels 99, Löschen des Schlüssels 70 mit Ausgleichen 50 30 40 10 20 35 38 60 42 46 56 58 95 65 90 96 99 96 99 Abb.: 3) Löschen des Schlüssels 35 mit Zusammenlegen 50 40 10 20 30 38 60 42 46 90 60 56 58 89 95 65 90 70 95 96 96 Algorithmen und Datenstrukturen Abb.: 40 10 20 30 38 50 42 46 60 95 56 58 65 90 96 99 Abb.: a) Implementierung in C++ Löschen eines Schlüssels im Blattknoten 1. Fall: Im Blattknoten befinden sich mehr als die kleinste zulässige Anzahl von Schlüsselelementen. Der Schlüssel kann einfach entfernt werden, die rechts davon befindlichen Elemente werden einfach eine Position nach links verschoben. 2. Fall: Das Blatt enthält genau nur noch die kleinste zulässige Anzahl von Schlüsselelementen, Nachbachknoten auf der Ebene der Blattknoten enthalten mehr als die kleinste zulässige Anzahl von Schlüsselelementen. Der Schlüssel wird gelöscht, im Blatt liegt dann ein „Unterlauf“ vor. Man versucht aus den linken oder rechten Nachbarknoten ein Element zu besorgen, z.B.: Es liegt im Anschluß an einen (rekursiven) Aufruf, der in einem Blattknoten einen Schlüssel entfernt hat, folgende Situation vor: 20 10 12 30 15 40 25 33 34 36 46 48 Abb.: Die Entnahme geeigneter Schlüssel kann hier aus dem linken bzw. aus dem rechten Nachbarn vom betroffenen Knoten erfolgen: 20 10 12 15 33 40 25 30 34 36 46 48 Abb.: Falls vorhanden, soll immer der rechte Nachbarknoten gewählt werden. Im vorliegenden Beispiel ist das nicht möglich beim Löschen der Schlüsselwerte „46“ bzw. „48“. In solchen Fällen wird dem Linken Knoten ein Element entnommen. 3. Fall: Das Blatt enthält genau die kleinste mögliche Anzahl an Elementen, Nachbarknoten auf der Ebene der Blattknoten enthalten auch nur genau die kleinste mögliche Anzahl an Elementen. In diesem Fall müssen die betroffenen Knoten miteinander verbunden werden, z.B. liegt im Anschluß an einen Aufruf, der in einem Blattknoten einen Schlüssel entfernt hat, folgende Situation vor: 90 Algorithmen und Datenstrukturen 20 10 12 15 30 40 25 34 36 46 48 Die Verbindung zu einem Knoten mit zulässiger Anzahl von Schlüsselelementen kann so vollzogen werden: 91 Algorithmen und Datenstrukturen 20 10 12 40 15 25 30 34 36 46 48 Abb.: Löschen eines Schlüssels in einem inneren Knoten Solches Löschen kann aus einem Löschvorgang in einem Blattknoten resultieren. Bsp.: 15 3 1 2 4 6 5 20 10 12 18 19 60 22 30 70 80 22 70 80 Abb.: Das Löschen vom Schlüssel mit dem Wert 1 ergibt: 6 2 3 4 5 10 15 12 20 60 18 19 30 Abb.: Im übergeordneten Knoten kann es erneut zu einer Unterlauf-Gegebenheit kommen. Es ist wieder ein Verbinden bzw. Borgen mit / von Nachbarknoten erforderlich, bis man schließlich an der Wurzel angelangt ist. Im Wurzelknoten kann nur ein Element sein. Wird dieses Element in den Verknüpfungsvorgang der beiden unmittelbaren Nachfolger einbezogen, dann wird der Wurzelknoten gelöscht, die Höhe des Baums nimmt ab. Eine Löschoperation kann auch direkt in einem internen Knoten beginnen, z.B. wird im folgenden Bayer-Baum im Wurzelknoten der Schlüssel mit dem Wert „15“ gelöscht. 92 Algorithmen und Datenstrukturen 15 3 1 2 4 6 5 20 10 12 18 19 60 22 30 70 80 Abb.: Zuerst wird zum linken Nachfolger gewechselt, anschließend wird der Baum bis zum Blatt nach rechts durchlaufen. In diesem Blatt wird dann das am weitesten rechts stehenden Datum aufgesucht und mit dem zu löschenden Element im Ausgangsknoten getauscht. 12 3 1 2 4 6 5 20 10 15 18 19 60 22 30 70 80 Abb.: Der Schlüssel mit dem Wert „15“ kann jetzt nach einer bereits beschriebenen Methode gelöscht werden. 93 Algorithmen und Datenstrukturen 4.4.6 Auf Platte/ Diskette gespeicherte Datensätze Der Ausgangspunkt zu B-Bäumen war die Verwaltung der Schlüssel zu Datensätzen in umfangreichen Dateien. In der Regel will man ja nicht nur einfache Zahlen (d.h. einzelne Daten), sondern ganze Datensätze speichern. Eine größere Anzahl von Datensätzen einer solchen Datei ist aber im Arbeitsspeicher (, der ja noch Teile des Betriebssystems, das Programm etc. enthalten muß,) nicht unterzubringen. Notwendig ist die Auslagerung von einem beträchtlichen Teil der Datensätze auf einen externen Speicher. Dort sind die Datensätze in einer Datei gespeichert und in "Seiten" zusammengefaßt. Eine Seite umfaßt die im Arbeitsspeicher adressierbare Menge von Datensätzen (Umfang entspricht einem Bayer-Baumknoten). Aus Vergleichsgründen soll hier die Anzahl der aufgenommenen bzw. aufzunehmenden Datensätze die Zahl M = 2 nicht überschreiten. Es werden also mindestens 2, im Höchstfall 4 Datensätze in eine Seite aufgenommen. Allgemein gilt: Je größer M gewählt wird, um so mehr Arbeitsspeicherplatz wird benötigt, um so größer ist aber auch die Verarbeitungsleistung des Programms infolge der geringeren Anzahl der (relativ langsamen) Zugriffsoperationen auf externe Speicher. Die 1. Seite der Datei (Datenbank) enthält Informationen für die Verwaltung. Implementierung in C++ Die Verwaltung eines auf einer Datei hinterlegten Bayer-Baums übernimmt die folgende Klasse BBaum: #define MAERER 5 // Anzahl Verkettungen im Bayer-Baum Knoten enum status {unvollstaendigesEinfuegen, erfolgreich, doppelterSchluessel, Unterlauf, nichtGefunden}; typedef int dtype; // Knoten eines auf einer Datei hinterlegten Bayer-Baums. struct knoten { int n; // Anzahl der Elemente, die in einem Knoten // Knoten gespeichert sind (n < MAERER) dtype s[MAERER-1]; // Datenelemente (aktuell sind n) long z[MAERER]; // 'Zeiger' auf andere Knoten (aktuell sind n+1) }; // Logische Ordnung: // z[0], s[0], z[1], s[1], ..., z[n-1], s[n-1], z[n] // Die Klasse zum auf einer Datei hinterlegten Bayer-Baum class BBaum { private: enum {NIL=-1}; long wurzel, freieListe; knoten wurzelKnoten; fstream datei; status einf(long w, dtype x, dtype &y, long &u); void ausg(long w, int nLeer); int knotenSuche(dtype x, const dtype *a, int n)const; status loe(long w, dtype x); void leseKnoten(long w, knoten &Knoten); void schreibeKnoten(long w, const knoten &Knoten); void leseStart(); 94 Algorithmen und Datenstrukturen long holeKnoten(); void freierKnoten(long w); public: BBaum(const char *BaumDateiname); ~BBaum(); void einfuegen(dtype x); void einfuegen(const char *eingabeDateiname); void gibAus(){cout << "Dateninhalt:\n"; ausg(wurzel, 0);} void loeschen(dtype x); void zeigeSuche(dtype x); }; Konstruktoren Zur Verwaltung des Bayer-Baums in einer Datei ist besonders wichtig: - Die Wurzel wurzel (d. h. die Position des Wurzelknotens - eine Liste mit Informationen über den freien Speicherplatz in der Datei (adressiert über freieListe). Zu solchen freien Speicherplätzen kann es beim Löschen von Schlüsselelementen kommen. Zweckmäßigerweise wird dann dieser freie Speicherbereich nicht aufgefüllt, sondern in einer Liste freier Speicherbereiche eingekettet. freieListe zeigt auf das erste Element in dieser Liste. Solange das Programm läuft sind „wurzel“ und „freieListe“ Datenelemente der Klasse BBaum (in einer Datei abgelegter Bayer-Baum). Für die Belegung dieser Dateielemente wird am Dateianfang (1.Seite) Speicherplatz reserviert. Am Ende der Programmausführung werden die Werte zu „wurzel“ bzw. „freieListe“ in der Datei abgespeichert. Bsp.: Der folgende Bayer-Baum 20 10 15 60 80 10 wurzel 15 60 80 20 freieListe Abb.: Zeiger haben hier ganzzahlige Werte des Typs long (mit -1L als NIL), die für die Positionen und Bytenummern stehen. 95