Kapitel 2 B¨aume und Priority Queues

Werbung
14
Kapitel 2
Bäume und Priority Queues
2.1
Bäume
Bisher haben wir als dynamische Datenstrukturen Listen kennengelernt. Da der Zugriff in Listen in
der Regel nur sequentiell erfolgen kann, ergibt sich für das Einfügen bzw. Suchen in einer (sortierten)
Liste bei Länge n ein linearer Aufwand. Das heißt:
O(n) im worst case und
O(n/2) im average case.
Dies ist für viele Anwendungen, in denen ein sich dynamisch ändernder Datenbestand verwaltet werden muss, zu langsam (z.B. Verwaltung von Identifiern in einem Programm durch den Compiler,
Autorenkatalog einer Bibliothek, Konten einer Bank, usw.). Bessere Methoden bietet unter anderem
die Datenstruktur der Bäume, die in diesem Kapitel erläutert wird.
2.1.1
Grundbegriffe
Gerichtete Bäume (kurz Bäume) kann man auf zwei Arten erklären. Eine graphentheoretische Definition 1 wurde bereits in der Coma I im Zusammenhang mit Graphen behandelt. Etwas abstrakter ist
die rekursive Definition, die in der Coma I in Zusammenhang mit der Rekursion erläutert wurde. Sie
wird hier noch einmal erklärt und in Abbildung 2.1 visualisiert:
1 Ein
gerichteter Baum ist ein Digraph T = (V, E) mit folgenden Eigenschaften:
– Es gibt genau einen Knoten r, in dem keine Kante endet (die Wurzel von T ).
– Zu jedem Knoten i 6= r gibt es genau einen Weg von der Wurzel r zu i.
Dies bedeutet, dass keine zwei Wege in den gleichen Knoten einmünden. Der Graph kann sich ausgehend von der Wurzel
also nur verzweigen. Daher kommt auch der Name Baum.
15
16
KAPITEL 2. BÄUME UND PRIORITY QUEUES
Ein Baum T
• ist entweder leer
• oder er entsteht aus endlich vielen, voneinander verschiedenen Bäumen T1 , . . . , Tn mit Wurzeln
w1 , . . . , wn , die in T als Teilbäume unter der Wurzel w von T (einem neuen Knoten) hängen.
w1
wn
A
A
T1 A
Tn A
... A
A
A
A
A
A
w r
@
@
=⇒
w1
...
A
T1 A
A
A
A
@
@ wn
A
Tn A
A
A
A
Abbildung 2.1: Baum, rekursiv aufgebaut
Beispiele für die Verwendung von Bäumen sind:
• Darstellung von Hierarchien
• Auswertung arithmetischer Ausdrücke
z.B.: ((a + b) ∗ (c + d))/e + f /g (siehe Abb. 2.6, Seite 24)
• Rekursionsbaum
Im Zusammenhang mit Bäumen ist die folgenden Terminologie üblich: Blätter, innere Knoten, Wurzel, Kinder / Söhne / Brüder, Vater / Eltern, Nachfolger, Vorgänger und Teilbäume. Ein Knoten v kann
einen Vater und Söhne haben. Die Söhne eines Vaters sind Brüder. Hat ein Knoten keinen Vater, ist
er die Wurzel des Baumes. Hat er keine Söhne, ist er ein Blatt. Wenn ein Knoten verschieden von der
Wurzel ist und mindestens einen Sohn hat, ist er ein innerer Knoten.
Eine besondere Rolle spielen die binären Bäume. Sie sind entweder leer oder bestehen aus der Wurzel
und einem linken und einem rechten binärem Baum (den Teilbäumen). Jeder Knoten hat maximal
zwei Söhne, man spricht vom linken und vom rechten Sohn. In den folgenden Abschnitten werden
wir ausschließlich binäre Bäume behandeln und deshalb das Wort Baum in der Bedeutung binärer
Baum verwenden. Bekannte Beispiele binärer Bäume sind der Stammbaum mit Vater, Mutter und
einer Person als deren Nachfolger (!) oder die Aufzeichnung eines Tennisturniers, in der jedes Spiel
durch einen Knoten mit dem Namen des Gewinners charakterisiert ist und die beiden vorausgehenden
Spiele als dessen Nachfolger aufgeführt sind.
Die rekursive Struktur von Bäumen ist von großer Bedeutung für viele Algorithmen auf Bäümen.
Auch viele charakteristische Größen von Bäumen lassen sich rekursiv beschreiben oder definieren.
17
2.1. BÄUME
Ein Beispiel dafür ist die Höhe von Bäumen. Die Höhe gibt den längsten Weg von der Wurzel bis zum
Blatt gemessen in Anzahl der Kanten an. Sie ergibt sich wie folgt:
h(T ) =
n
−1
falls T leer
max{h(T1 ), h(T2 )} + 1
sonst
(2.1)
Besteht T beispielsweise nur aus einem Knoten, ergibt sich aus Gleichung (2.1) die Höhe von T zu
h(T ) = max{−1, −1} + 1 = 0.
2.1.2
Implementation von binären Bäumen
Im Folgenden wird gezeigt, wie sich binäre Bäume als abstrakte Datenstruktur implementieren lassen.
Ein Baum besteht aus Knoten und Kanten zwischen den Knoten. Die Knoten sind hier Objekte der
inneren Klasse BinTreeNode. Für die Kanten nutzt man die Zeigereigenschaft von Referenzobjekten.
So kennt ein BinTreeNode das Objekt, das im Knoten steht, seinen linken und seinen rechten Sohn und
in manchen Implementationen auch seinen Vater. Das wird in Abbildung 2.2 deutlich. Zusätzlich sind
get und set Methoden sinnvoll sowie Methoden, die testen, ob der linke bzw. rechte Sohn vorhanden
sind.
class BinTreeNode {
Object
BinTreeNode
BinTreeNode
data;
lson;
rson;
// saved object
// left son
// right son
// sometimes also usefull
BinTreeNode
parent; // parent
...
// constructors, get methods,
// set methods ...
}
Objekt
r
Ref. auf
linken Sohn
r
A
AAU
Ref. auf
rechten Sohn
Abbildung 2.2: Struktur eines Knotens
Wie in Abb. 2.3 dargestellt, ist ein Baum eine Verzeigerung“ von Knoten. Jeder BinTreeNode zeigt
”
auf seine Söhne und, wie oben schon erwähnt, in manchen Implementationen auch auf seinen Vater.
18
KAPITEL 2. BÄUME UND PRIORITY QUEUES
Es gibt einen BinTreeNode, hier root“ genannt, dessen rechter (oder linker) Sohn immer auf die
”
eigentliche Wurzel des Baumes zeigt. Zusätzlich gibt es eine Referenz curr“ (lies: karr), die auf
”
einen beliebigen Knoten im Baum zeigt und die auf jeden Knoten umgesetzt werden kann.
root
qH
H
HH
j
Objekt
q
q
Q
Q
q
Q
+
Q
s
Q
Objekt
q
q
Objekt
q
@
@
R
@
Objekt
q
q
@
R
@
q
@
@
R
@
Objekt
q
@
curr
q
@
...
@
R
@
...
Abbildung 2.3: Baum, dargestellt als verkettete Struktur
class BinTree {
BinTreeNode dummy;
BinTreeNode curr;
// dummy node whose left son is the root
// points at the current node
...
}
Das folgende Programm 2.1 stellt ein Beispiel einer abstrakten Klasse dar, von der binäre Bäume
abgeleitet werden können. Einige Methoden werden im Folgenden genauer erklärt.
Programm 2.1 BinTree
/**
* abstract base class for all sorts of binary trees
*
* @author N.N.
*/
abstract class BinTree {
/**
* class for tree nodes
*/
protected class BinTreeNode {
19
2.1. BÄUME
public BinTreeNode() {
}
// default constructor
public BinTreeNode(Object obj) { // init constructor
}
public boolean isLeaf() {
}
// is node a leaf in tree?
public boolean isRoot() {
}
// is node root of tree?
public boolean isLeftChild() {
}
// is node left child
// of parent?
public BinTreeNode getLeftChild() {
}
// get left child
public BinTreeNode getRightChild() { // get right child
}
public BinTreeNode getParent() {
}
public String toString() {
}
}
// get parent
// conversion to string
// class BinTreeNode
/***
data
******************************************************/
/***
constructors
**********************************************/
// default constructor, initializes empty tree
public BinTree() {
}
/***
get methods
***********************************************/
public boolean isEmpty() {
}
// is tree empty?
20
KAPITEL 2. BÄUME UND PRIORITY QUEUES
// root node of tree
// -> what should be returned if tree is empty??
protected BinTreeNode _getRoot() {
}
// current number of tree nodes
public int getSize() {
}
// height of tree
public int getHeight() {
}
/***
set methods
***********************************************/
// switch debugging mode
public static void setCheck(boolean mode) {
}
/***
methods for current node
**********************************/
// reset current node to first node in inorder sequence
public void reset() {
}
// does current node stand at end of inorder sequence?
public boolean isAtEnd() {
}
// reset current node to successor in inorder sequence
public void increment() {
}
// object referenced by current node
public Object currentData() {
}
// ist current node a leaf?
public boolean isLeaf() {
}
21
2.1. BÄUME
/***
conversion methods
****************************************/
// convert tree to string
// use getClass() somewhere so that class name of "this" shows
public String toString() {
}
/***
debugging methods
*****************************************/
// check consistency of links in entire tree
protected boolean _checkLinks() {
}
}
Es gibt viele Methoden, die man an oder mit Bäumen durchführen kann. Dazu gehören beispielsweise Methoden zum Einfügen und Löschen von Knoten, zum Durchlaufen des Baumes (vgl. Abschnitt 2.1.3 usw. Wir wollen uns eine mögliche Methode zum Berechnen der Höhe eines Baumes
genauer anschauen. Diese benutzt die Gleichung 2.1 zur Berechnung der Höhe und nutzt die rekursive Struktur von Bäumen.
Programm 2.2 getHeight()
int getHeight() {
if (isEmpty()){
// empty tree
return -1;
} else {
int lheight = _getRoot().getLeftSon().getHeight();
int rheight = _getRoot().getRightSon().getHeight();
return Math.max(rheight,lheight)+1;
}
}
Implementation im Array
Bäume können auch mit Hilfe von Arrays implementiert werden. Hierbei handelt es sich zwar nicht
um eine dynamische Datenstruktur, diese Umsetzung ist allerdings für manche Programmiersprachen
(z.B. FORTRAN) erforderlich. Die Idee hierbei ist, die Indizes als Zeiger auf die Söhne zu nutzen. Das
lässt sich explizit (durch Abspeicherung) oder implizit (durch Berechnung) lösen. Bei der expliziten
Variante sehen die Knoten so aus:
class ArrayBinTreeNode {
Object data;
int
lson;
22
KAPITEL 2. BÄUME UND PRIORITY QUEUES
int
rson;
}
Der Baum wird dann, wie auch in Abbildung 2.4 veranschaulicht, als Array umgesetzt:
ArrayBinTreeNode[] tree = new ArrayBinTreeNode[n];
0
1
i
s
...
n−2 n−1
j
...
?
Objekt
i
...
j
Abbildung 2.4: Baum als Array
Dazu gehören natürlich noch die oben schon dargestellten Zugriffsfunktionen. Die Höhe wird ebenfalls auf die schon erklärte Weise rekursiv berechnet.
Bei der impliziten Variante werden die beiden Söhne nicht im Knoten gespeichert, sondern in getMethoden berechnet. Die Indizes der Söhne des Knoten i ergeben sich bei binären Bäumen immer zu
2i + 1 für den linken Sohn und 2i + 2 für den rechten Sohn.
Der Nachteil an einer Implementation mit Arrays ist leider, dass man bei nicht vollen Bäumen im
Vergleich zur üblichen Implementation mehr Speicherplatz benötigt.
2.1.3
Traversierung von Bäumen
Mit Traversierung eines Baumes bezeichnet man den Durchlauf von Knoten zu Knoten, um in jedem
Knoten etwas zu tun. In den Knoten sind Daten, ähnlich wie in einer Liste, und um mit diesen arbeiten
zu können, müssen sie nacheinander erreicht werden. Jedoch ist die Reihenfolge des Durchlaufens
eines Baumes nicht mehr eindeutig wie bei einer Liste. Standardmäßig benutzt man die folgenden
drei Traversierungen:
WLR: Der Preorder-Durchlauf. Hier wird zuerst die Wurzel betrachtet, dann der linke Teilbaum mit
derselben Regel und dann der rechte Teilbaum wieder mit der selben Regel.
LWR: Der Inorder-Durchlauf. Hier wird zuerst der linke Teilbaum, dann die Wurzel und dann der
rechte Teilbaum besucht, wobei die Teilbäume wieder mit derselben Regel durchlaufen werden.
LRW: Der Post-Durchlauf. Die Wurzel wird erst erreicht, nachdem zuerst der linke und dann der
rechte Teilbaum jeweils mit derselben Regel durchlaufen wurden.
Die Kürzel WLR, LWR und LRW zeigen vereinfacht jeweils die Reihenfolge des Durchlaufens an.
Die Vorsilben Pre-, In- und Post- beziehen sich jeweils auf die Rolle der Wurzel.
23
2.1. BÄUME
A
B
D
C
E
F
Abbildung 2.5: Beispielbaum für die Traversierung
Beispiel 2.1 Dieses Beispiel zeigt die drei Traversierungsmöglichkeiten für den Baum in Abbildung 2.5.
WLR: A, B, D, E, C, F
LWR: D, B, E, A, C, F
LRW: D, E, B, F, C, A
Ist es einfach nur wichtig, unabhängig von der Reihenfolge alle Knoten zu erreichen, spielt es keine
Rolle, welche Traversierung gewählt wird. Allerdings gibt es verschiedene Anwendungen, die jeweils
unterschiedliche Reihenfolgen benutzen. Beim Aufrufbaum oder beim Rekursionsbaum beispielsweise, die in Coma I behandelt wurden, werden die Methoden in Postorder Reihenfolge abgearbeitet. Im
folgenden Beispiel wird verdeutlicht, welchen Einfluss die verschiedenen Reihenfolgen auf arithmetische Ausdrücke haben.
Beispiel 2.2 Der arithmetische Ausdruck
((a + b) ∗ (c + d))/e + f /g
wird vom Compiler in einen Baum, wie in Abb. 2.6, umgewandelt. In diesem Baum stehen die Identifier in den Blättern. In den inneren Knoten und der Wurzel stehen Operatoren. Diese verknüpfen jeweils ihren linken Teilbaum als arithmetischen Ausdruck mit dem Ausdruck ihres rechten Teilbaums.
Durchläuft man den Baum in Inorder, ergibt sich der arithmetische Ausdruck in Infix-Notation:
((a + b) ∗ (c + d))/e + f /g
Durchläuft man den Baum aber in Postorder, erhält man den Ausdruck in Postfix-Notation beziehungsweise umgekehrter polnischer Notation (UPN):
ab + cd + ∗e/ f g/+
Dieser wird dann vom Computer, wie in Coma I behandelt, mit Hilfe eines Stacks berechnet.
Im Gegensatz zur Infix-Notation ist der Baum aus der Postfix-Notation arithmetischer Ausdrücke ohne Hilfe von Klammern (re)konstruierbar. Indem man den Ausdruck in Postfix-Notation von hinten
durchläuft, kann man den Baum über die Postorder-Reihenfolge von hinten nach vorne (wieder) aufbauen.
24
KAPITEL 2. BÄUME UND PRIORITY QUEUES
+
/
/
e
∗
+
a
f
g
+
b c
d
Abbildung 2.6: Ein arithmetischer Ausdruck als Baum dargestellt
Implementation
Um einen Baum in den verschiedenen Reihenfolgen zu durchlaufen, kann man sich in den JavaMethoden die rekursive Struktur der Bäume nützlich machen. Die Umsetzung zeigt die folgenden
Methoden, die sinnvollerweise zur Klasse BinTree gehören.
Programm 2.3 Traversierung eines Baumes
void preOrderTraversal() {
if (isEmpty()) {
return;
}
// work on root
getLeftSon().preOrderTraversal();
getRightSon().preOrderTraversal();
}
void inOrderTraversal(){
if (isEmpty()) {
return;
}
getLeftSon().inOrderTraversal();
// work on root
getRightSon().inOrderTraversal();
}
void postOrderTraversal() {
2.2. PRIORITY QUEUES
25
if (isEmpty()) {
return;
}
getLeftSon().postOrderTraversal();
getRightSon().postOrderTraversal();
// work on root
}
}
Neben den rekursiven Methoden gibt es auch die Möglichkeit den Baum iterativ zu durchlaufen. Exemplarisch wird hier nur die Inorder Traversierung angesprochen. Die Umsetzung wird in der Übung
behandelt. Zur iterativen Traversierung werden drei Methoden benötigt:
1. public void reset()
2. public void increment()
3. public boolean isAtEnd()
Die Methode reset() sucht sich den am weitesten links stehenden Knoten des Baumes und setzt den
curr-Zeiger auf diesen Knoten. Die Methode increment() setzt den curr-Zeiger auf den Nachfolger,
also auf den nächsten Knoten entsprechend der Inorder-Reihenfolge. Die Methode isAtEnd() prüft,
ob der Inorder-Durchlauf das Ende erreicht hat. Objekte mit solchen Methoden bezeichnet man als
Iterator und die Methoden werden dementsprechend Iteratormethoden genannt.
2.2
Priority Queues
Bei einer Priority Queue handelt es sich um eine Datenstruktur mit folgenden Kennzeichen:
• Sie hat einen homogenen Komponententyp, wobei jede Komponente einen Schlüssel (Wert)
besitzt.
• Die folgenden Operationen sind möglich:
1. Einfügen einer Komponente
2. Zugriff auf die Komponente mit dem kleinsten Wert
3. Entfernen der Komponente mit dem kleinsten Wert
4. Änderung des Wertes einer Komponente
Die Priority Queue wurde schon in Coma I im Zusammenhang mit Heapsort behandelt. Jedoch lag
dort die Aufmerksamkeit auf der Komponente mit dem größten Wert, nicht auf der mit dem kleinsten
Wert.
26
2.2.1
KAPITEL 2. BÄUME UND PRIORITY QUEUES
Mögliche Implementationen einer Priority Queue
a) Als sortiertes Array
Wenn die Anzahl n der zu speichernden Elemente bekannt ist, können die Elemente in einem Array,
Abb. 2.7, gespeichert werden, wobei das kleinste Element in der ersten Komponente des Arrays steht
und die übrigen aufwärts sortiert folgen. Damit ist ein sehr schneller Zugriff auf das kleinste Element
gewährleistet, jedoch dauern die übrigen Operationen lange, wie in der folgenden Auflistung zu sehen
ist.
1. Einfügen:
O(n) (binäre Suche + Verschieben)
2. Zugriff:
O(1)
3. Entfernen:
O(n)
4. Wert ändern: O(n)
0
1
2
3
4
5
6
7
12 18 24 35 44 53 63 72
6
kleinstes
Element
Abbildung 2.7: Priority Queue als sortiertes Array
Eine bessere Variante ist die folgende:
b) Als Heap
Wie bei Heapsort wird das Array als Baum mit Heap-Eigenschaft aufgefasst. Die Heapeigenschaft ist
dann erfüllt, wenn die Wege von der Wurzel zu jedem Blatt jeweils aufsteigend sortiert sind. Zur Herstellung der Heapeigenschaft wird die Methode heapify()“ verwendet. Ihre genauere Funktionsweise
”
wurde bereits in Coma I erläutert.
0
12
1
18
35
4
HH
H
2
53
63
6
@
@
3
7
24
@
@
5
72
44
Abbildung 2.8: Priority Queue als Heap
Für die Operationen im Heap ergibt sich dann dieser Aufwand im worst case:
27
2.3. LITERATURHINWEISE
1. Einfügen:
O(log n)
2. Zugriff:
3. Entfernen:
4. Wert ändern:
O(1)
O(log n)
O(log n)
als Blatt in die letzte Arraykomponente einfügen
und nach oben wandern lassen
letzte Komp. an die 0-te Stelle tauschen und absinken lassen
aufsteigen oder absinken lassen
Also sind neben dem sehr schnellen Zugriff auf das kleinste Element auch die anderen Operationen
schneller als im sortierten Array.
Es gibt aber noch andere Implementationen, die die Operationen noch schneller, allerdings nur amortisiert, schaffen. Dazu gehören zum Beispiel die Fibonacci Heaps.
2.3
Literaturhinweise
Bäume und Priority Queues werden in jedem Buch über Datenstrukturen behandelt, vgl. etwa [CLRS01, Knu98,
OW02, SS02]
Herunterladen