08 – AVL-Bäume (selbstbalancierte Binärbäume)

 

Wiederholung: Rechenzeit von Binärbäumen

Wie im Artikel Nr. 7 beschrieben, verhält sich die Rechenzeit für die Operationen im Binärbaum wie folgt:

  • Einfügen
    • best case: O(log n)
    • worst case: O(n)
  • Aufbau des gesamten Baumes
    • best case: O(n log n)
    • worst case: O(n2)

Ziel: möglichst gut “balancierter” Baum, um best case beim Rechenaufwand zu erreichen!

Balancekriterium

Der Unterschied der Höhen hlinks und hrechts der beiden Nachfolger-Teilbäume eines Knotens soll minimiert werden.

Hier ist hrechts-hlinks = -2. Dies muss verbessert werden.

Selbstbalancierte Binärbäume (AVL-Bäume)

Die Idee hinter selbstbalancierten Binärbäumen (AVL-Bäume nach Adelson-Velsky-Landis) ist es, in jedem Knoten ein Balancekriterium zu speichern. Dieser Wert wird berechnet mit der Formel

bal = hrechts-hlinks

Zulässige Werte für bal sind nur {-1, 0, 1}, das heißt der linke und der rechte Teilbaum eines Knotens dürfen sich höchstens um 1 in der Höhe unterscheiden.
Es kann nachgewiesen werden, dass in diesem Fall der Aufwand zum Einfügen eines neuen Knotens nur O(log n) ist.

Beispiel:

1. Knoten einfügen:

2. Knoten einfügen:

3. Knoten einfügen:

4. Knoten einfügen:

5. Knoten einfügen:

Operationen in AVL-Bäumen

Einfügen neuer Knoten mit insert
Wenn wir einen neuen Knoten einfügen, gehen wir wie beim normalen Binärbaum vor. Jedoch wird rückwärts, vom neuen Knoten aus, dem vorausgehenden Knoten mitgeteilt, dass der entsprechende Teilbaum gewachsen ist.
bal erhöht oder verringert sich dadurch um 1. Wenn sich bal nicht mehr im zulässigen Wertebereich befindet, muss der Baum lokal umgebaut werden.

Die Funktion heißt: insert(Comparable e, AvlNode t)

Der Funktion wird wie beim Binärbaum der Schlüssel des neuen Elementes übergeben, sowie die Stelle, an der das Element eingefügt werden soll. Beim Aufruf durch den Benutzer ist dies der Wurzelknoten. Programmintern wird die Funktion dann im weiteren Verlauf rekursiv von sich selbst mit einem anderen Parameter für t aufgerufen.

Im ersten Schritt wird geprüft, ob an der Stelle t schon ein Knoten existiert. Wenn nicht, wird an genau dieser Stelle der neue Knoten erzeugt:

if(t == null)
	t = new AvlNode(e, null, null);

Wenn der Knoten t schon existiert, muss geprüft werden, ob der neue Knoten als linker oder als rechter Nachfolger in Frage kommt. Hierfür wird der übergebene Schlüssel mit dem Schlüssel des Knotens t verglichen:

else if(e.compareTo(t.element) < 0)
	...

An dieser Stelle wollen wir nun vier verschiedene Fälle untersuchen, die beim Einfügen des Knotens auf der linken oder der rechten Seite auftreten können.

Fall 1: Durch das Einfügen des neuen Knotens wird der linke Arm zu lang:

Diese Situation ist der “Left-Left-Case”. Das Problem wird behoben durch eine einfache Rotation nach rechts:

Der linke Teilarm (Element 2) wird dabei einfach mitgezogen. Da auf dieses linke “Kind” geachtet werden muss, wird die Rotation “rotateWithLeftChild” genannt.

Fall 2: Durch das Einfügen des neuen Knotens wird der rechte Arm zu lang:

Dieser Fall (Right-Right-Case) ist analog zu Fall 1. Hier wird also auch das Problem mit der gleichen Herangehensweise gelöst: Der Baum wird nach links rotiert mit der Funktion “rotateWithRightChild”:

Fall 3: Durch das Einfügen des neuen Knotens wird der linke Arm zu lang, allerdings ist der neue Knoten nun nicht linker Nachfolger, sondern rechter Nachfolger:

Hier kann der Baum nicht einfach nach rechts rotiert werden, denn dann wäre der Knoten 3 an der Stelle der Wurzel. Sowohl Knoten 4 als auch Knoten 5 haben aber einen größeren Schlüsselwert als Knoten 3, deshalb hätte Knoten 3 zwei rechte Nachfolger. Es muss jedoch immer einen linken und einen rechten Nachfolger geben.

Die Lösung des Problems ist eine doppelte Rotation. Zuerst wird eine lokale Rotation nach links durchgeführt, anschließend folgt eine Rotation nach rechts, wobei das linke “Kind” mitgezogen wird:

Knoten 4 ist nun die neue Wurzel, er hat einen linken und einen rechten Nachfolger und der Baum ist wieder ausbalanciert. Die Funktion, die diesen beiden Rotationen kombiniert, ist “doubleWithLeftChild”.

Fall 4: Durch das Einfügen des neuen Knotens wird der rechte Arm zu lang, allerdings ist der neue Knoten nun nicht rechter Nachfolger, sondern linker Nachfolger:

Hier verfährt man wie bei Fall 3 und kombiniert nun eine Rotation nach rechts mit einer Rotation nach links, wobei das rechte Kind beachtet wird:

Die zugehörige Funktion heißt “doubleWithRightChild”.

Nun wieder zu unserem Algorithmus. Die Funktion war bisher so weit fortgeschritten:

	if(t == null)
		t = new AvlNode(e, null, null);

	else if(e.compareTo(t.element) < 0)

Wenn der übergebene Schlüssel nun also kleiner ist als der des Knotens t, dann muss der insert-Befehl an den linken Nachfolger des Knotens t weitergegeben werden (denn links sind ja immer die kleineren Elemente angehängt):

	t.left = insert(e, t.left);

Nun muss die Balancierung angepasst werden. Es wird links angefügt, es könnte also der linke Teilbaum nun 2 länger sein als der rechte (Höhe rechts-Höhe links ist dann -2). Dies wird in der nächsten if-Abfrage geprüft:

	if(height(t.right)-height(t.left) == -2)
		...

Um den “Rotationstyp” festzustellen, muss nun noch getestet werden, ob der neue Knoten links (Fall 1) oder rechts (Fall 3) an den linken Nachfolger angefügt wurde. Dementsprechend werden nach der Abfrage die Rotationsfunktionen aufgerufen:

	if(e.compareTo(t.left.element) < 0)
		t = rotateWithLeftChild(t);
	else
		t = doubleWithRightChild(t);

Damit ist der Baum fertig balanciert.

Gilt hingegen e.compareTo(t.element) > 0, dann findet das Einfügen im rechten Teilbaum statt. Die Funktion verfährt hier analog zum linken Teilbaum, nur eben auf Fall 2 und 4 angepasst:

else if(e.compareTo(t.element) > 0)
{
	t.right = insert(e, t.right);
	if( height(t.right)-height(t.left) == 2)
		if(e.compareTo(t.right.element) > 0)
			t = rotateWithRightChild(t);
		else
			t = doubleWithLeftChild(t);
}

Ganz zum Abschluss müssen noch die Werte für die Höhe der Teilbäume neu bestimmt werden. Hierfür wird rekursiv jedem Knoten das Maximum der Höhen der Nachfolgerbäume+1 zugewiesen (ein Knoten hat die Höhe seines größeren Nachfolgerbaumes+1):

t.height = max height(t.left), height(t.right))+1;

Für einen Überblick über die gesamte Funktion, auch im Zusammenhang mit den Rotationsfunktionen, die hier nicht weiter besprochen werden sollen, siehe den Programmtext unten.

Fazit

Die Links- und Rechtsrotationen sind alle lokale Operationen mit dem Aufwand O(1). Als obere Abschätzung kann man sagen, dass schlimmstenfalls bei jedem Einfügen eine Rotation durchgeführt werden muss. Dieser schlimmste Fall kann aber mathematisch nachweislich nicht auftreten, der Rechenaufwand zum Einfügen eines Knotens geht daher nicht über O(log n) hinaus.
Der Rechenaufwand, um den gesamten Baum aufzubauen ist somit O(n log n), das Optimum für einen Binärbaum.

Der fertig aufgebaute AVL-Baum ist ein perfekter Binärbaum, in dem mit minimalem Aufwand eine sortierte Traversierung durchgeführt werden kann. Zusätzlich ist der AVL-Baum eine dynamische Datenstruktur (Vorteile gegenüber statischen Feldern). Somit ist der AVL-Baum auch ein optimales Sortierverfahren.

Implementierung in Java

public class AvlTree
{ // Klasse für die Verwaltung der Knoten des AvlTrees
	public AvlNode root; // Der Wurzelknoten

	public AvlTree()
	{ // Konstruktor setzt Wurzel auf null
		root = null;
	}

	public void makeEmpty()
	{ // setzt die Wurzel wieder auf null, somit wird der Baum gelöscht
		root = null;
	}

	public boolean isEmpty()
	{ // prüft, ob der Baum leer ist
		return (root == null);
	}

	public AvlNode insert(Comparable e, AvlNode t)
	{ // Fügt einen neuen Knoten als Knoten t ein
		if(t == null)
			// Wenn der Knoten t noch nicht existiert, wird t der neue Knoten
			t = new AvlNode(e, null, null);
		// Wenn t schon existiert...
		else if(e.compareTo(t.element) < 0)
		{ // ... und der Schlüssel e des neuen Elementes < dem von t ist,
		  // geht der Befehl weiter an den linken Nachfolger:
			t.left = insert(e, t.left); 

			// Nun muss die Balancierung angepasst werden. Es wird links ange-
			// fügt, es könnte also der linke Teilbaum nun 2 länger sein als
			// der rechte (Höhe rechts-Höhe links ist dann -2)
			if(height(t.right)-height(t.left) == -2)
				// wenn der übergebene Schlüssel kleiner ist als der des linken
				// Nachfolgers, hängt am linken Nachfolger noch ein linker N.
				// es muss dann nach rechts rotiert werden, wobei der linke
				// Nachfolger berücksichtigt wird (with left child)
				if(e.compareTo(t.left.element) < 0)
					t = rotateWithLeftChild(t);
				else
					// ansonsten muss erst links rotiert werden, um den oben
					// genannten Fall zu erreichen, dann wird rechts rotiert:
					t = doubleWithRightChild(t);
		} // end else if
		else if(e.compareTo(t.element) > 0)
		{ // ... und der Schlüssel e des neuen Elementes > dem von t ist
		  // Verfahren ist analog zu dem oben genannten
			t.right = insert(e, t.right);
			if( height(t.right)-height(t.left) == 2)
				if(e.compareTo(t.right.element) > 0)
					t = rotateWithRightChild(t);
				else
					t = doubleWithLeftChild(t);
		} // end else if
		// Zum Schluss wird noch der Höhe des Knotens das neue Maximum zugew.
		t.height = max height(t.left), height(t.right))+1;
		return t;
	} // end insert

	public static int height(AvlNode t)
	{ // Wenn t == null -> gib -1 zurück, Wenn t != null -> gib t.height zurück
		return t == null ? -1 : t.height;
	}

	public static int max(int lhs, int rhs)
	{ // Wenn lhs > rhs -> gib lhs zurück, Wenn lhs <= rhs -> gib rhs zurück
		return lhs > rhs ? lhs : rhs;
	}

	public static AvlNode rotateWithLeftChild(AvlNode k2)
	{
		// Nachfolger werden vertauscht:
		AvlNode k1 = k2.left;
		k2.left = k1.right;
		k1.right = k2;
		// Neue Höhen werden angepasst
		k2.height = max(height(k2.left), height(k2.right))+1;
		k1.height = max(height(k1.left), k2.height)+1;
		return k1;
	} // end rotateWithLeftChild

	public static AvlNode rotateWithRightChild(AvlNode k1)
	{
		// Nachfolger werden vertauscht:
		AvlNode k2 = k1.right;
		k1.right = k2.left;
		k2.left = k1;
		// Neue Höhen werden angepasst
		k1.height = max(height(k1.left), height(k1.right))+1;
		k2.height = max(height(k2.right), k1.height)+1;
		return k2;
	} // end rotateWithRightChild

	public static AvlNode doubleWithRightChild(AvlNode k3)
	{ // Kombination aus Links- und Rechtsrotation
		k3.left = rotateWithRightChild(k3.left);
		return rotateWithLeftChild(k3);
	} // end doubleWithLeftChild

	public static AvlNode doubleWithLeftChild(AvlNode k1)
	{ // Kombination aus Rechts- und Linksrotation
		k1.right = rotateWithLeftChild(k1.right);
		return rotateWithRightChild(k1);
	} // end doubleWithRightChild

	private class AvlNode
    { // private Klasse zur Speicherung der Knotendaten
        AvlNode(Comparable theElement)
        { // Konstruktor 1, ruft Konstruktor 2 auf mit Standardeinstellungen
            this(theElement, null, null);
        } // end AvlNode

        AvlNode(Comparable theElement, AvlNode lt, AvlNode rt)
        { // Konstruktor 2, weist Parameter den Eigenschaften zu
            element  = theElement;
            left     = lt;
            right    = rt;
            height   = 0;
        } // end AvlNode

        Comparable element;      // Die Daten im Knoten
        AvlNode    left;         // Der linke Nachfolger
        AvlNode    right;        // Der rechte Nachfolger
        int        height;       // Die Höhe des längeren der beiden
								 // an dem Knoten hängenden Teilbäume
    } // end private class AvlNode
} // end class AvlTree

Ähnliche Artikel

1 Kommentar zu “08 – AVL-Bäume (selbstbalancierte Binärbäume)”

Danke für diese tolle Seite! Hat mir echt geholfen. Seh ich mir jetzt immer vor dem Einschlafen noch mal an!

MfG Nikolai

Kommentar verfassen