Der Datentyp "binärer Baum"
Die Programmiersprache PASCAL stellt uns verschiedene Datenstrukturen zu Verfügung (Arrays, Records), d.h. die dazugehörigen Operationen sind in die Sprache schon eingebaut, sie werden vom Compiler direkt in Befehlsfolgen übersetzt oder liegen als fertige Prozeduren in den Sprachbibliotheken (libraries) bereit. Es gibt allerdings noch viele andere denkbare Datenstrukturen, die für gewisse Algorithmen nützlich sind und in denen sich bestimmte algorithmische Vorgehensweisen plastisch darstellen lassen.
Wenn eine solche Datenstruktur hilfreich, in der Programmiersprache aber nicht vorgesehen ist, so lohnt es sich, sich die Mühe zu machen, diese Datenstruktur in der Programmiersprache zu beschreiben - wir sagen auch: implementieren. Bei größeren Programmieraufgaben ist es ein Teil der Aufgabe, sich zuerst die geeigneten Datenstrukturen herzustellen, bevor man mit der eigentlichen Umsetzung des Verfahrens beginnt.
Dieses Vorgehen wollen wir hier am Beispiel einer bestimmten Datenstruktur demonstrieren: dem binären Baum.
Definition Digraph:
Ist Q eine Menge (die Menge der Knoten) und K eine Teilmenge von
QxQ (die Menge der Kanten), so heißt die Struktur G = (Q,K) gerichteter
Graph oder Digraph (directed graph). Eine Folge benachbarter Kanten
heißt Pfad, genauer:
(q1,
q2) (q2, q3)........(qn, qn+1)
heißt Pfad der Länge n, wenn (qi, qi+1)
aus K (i=1,...,n).
q1 ist direkter Vorgänger von q2 , q2
ist direkter Nachfolger von q1, q1 ist Vorgänger
von qi, qi ist Nachfolger von q1
(i >= 1)
Definition Baum:
Ein Baum ist ein gerichteter Graph, der genau einen Knoten ohne direkten
Vorgänger hat (die Wurzel) und in dem jeder Knoten von der Wurzel aus
durch einen und nur einen Pfad erreicht wird. Die Tiefe eines Knotens ist
die Länge des Pfades von der Wurzel zu diesem Knoten, die Höhe des Baums
ist die Länge des längsten Pfades. Den direkten Vorgänger eines Knotens nennen
wir Vater, einen direkten Nachfolger Sohn. Knoten mit demselben
Vater sind Brüder. Knoten ohne Söhne heißen Blätter.
Ein Baum ist binär, wenn jeder Knoten höchstens 2 Söhne hat. Ein binärer
Baum der Höhe h heißt vollständig, wenn jeder Knoten einer Tiefe kleiner
h genau zwei Söhne hat. Er heißt fast vollständig, wenn alle Knoten
einer Tiefe kleiner h-1 genau 2 Söhne haben, während in der Tiefe h-1 ein
Knoten (in der Breitennummerierung s.u.) nach einem Knoten mit weniger als 2
Söhnen keine Söhne mehr hat (d.h. die letzte Ebene h ist möglicherweise ab
einer bestimmten Stelle nicht mehr gefüllt.)
Sind die Söhne eines Knotens geordnet (durchnummeriert, links-rechts, etc.)., so kann man anhand dieser Ordnung auch die Knoten des ganzen Baumes ordnen (nummerieren):
Breitennummerierung (Breadth first):
Anschaulich: Die Knoten der Tiefe i bilden die Ebene i des Baumes. Hat
der Baum die Höhe h, so hat er die h+1 Ebenen 0,...,h. In jeder Ebene
nummerieren wir die Knoten von links nach rechts durch. Dabei beginnen wir in
Ebene 0. Ein Knoten liegt dann vor einem anderen, wenn er in einer früheren
Ebene liegt oder in derselben Ebene früher.
Formal: Sind a, b Knoten, so ist a < b, wenn die Tiefe von a kleiner
ist als die von b, oder wenn bei gleicher Tiefe a und b Vorfahren a' und b'
haben, so daß a' kleinerer Bruder von b' ist.
Tiefennummerierung (Depth first):
Anschaulich: Wir nummerieren die Knoten beginnend bei der Wurzel und
folgen dabei dem Pfad, der durch den jeweils kleinsten Sohn gegeben ist, bis
zum Ende. Dann gehen wir zurück bis zu dem Vorfahren, der noch nicht besuchte
Söhne hat. Den kleinsten davon suchen wir auf und folgen nun wieder dem
linkesten Pfad bis zum Ende, usw.
Algorithmisch beschrieben ist das:
procedure num(p);
begin
nummeriere p (d.h. gib ihm die kleinste noch nicht
vergebene Nummer);
for all Söhne q von p in ihrer Reihenfolge do num(q);
end;
Lemma:
1) Ein vollständiger binärer Baum der Höhe h hat 2h+1-1 Knoten
(2i Knoten der Tiefe i).
2) Ist n die Zahl der Knoten eines binären Baums und h seine Höhe,
so gilt: 2h <= n < 2h+1, d.h. h = log n (nach
unten abgerundet)
Der Datentyp "binärer Baum"
Nun wollen wir mit Hilfe der mathematischen Struktur Baum den Datentyp Baum
definieren, so wie wir die Datenstruktur Array definieren.
Beim Array mit n Feldern, die wir mit Integern belegen konnten, hatten wir die Statements:
var
A: array[1..n] of integer;
A[i]:= 5
x := A[i]
Der Array besteht aus n Feldern, die vom Typ Integer sind. Hierbei ist A[i] der Name des i-ten Feldes des Arrays A. In das wird der Integer 5 geschrieben. Dann wird der Inhalt von A[i] – also die 5 – in ein Feld mit Namen x geschrieben.
Stellen wir uns einen vollständigen binären Baum B von Integerfeldern der Höhe h vor. Dann könnten wir analoge Statements verwenden wollen:
var B: tree[h] of integer; {stellt uns einen
vollständigen binären Baum von
Feldern vom Typ Integer der Höhe h zur
Verfügung}
B[w]:=
5
{schreibt in Feld B[w] des Baums B den Integer 5}
x :=
B[w]
{schreibt den Inhalt von Feld B[w] in ein Feld mit Namen x}
Was ist das Feldes B[w] des Baums B? In einem binären Baum, in dem es immer nur zwei Söhne gibt, können wir vom linken und vom rechten Sohn sprechen. Wenn wir die Kante zum linken Sohn mit "li" und die zum rechten Sohn mit "re" beschriften, wo führt uns ein Wort w aus den Buchstaben "li" und "re" von der Wurzel einen Pfad entlang bis zu einem Knoten. Wir wollen diesem Knoten die Adresse w geben. B[w] ist also gerade das Feld, das wir von der Wurzel aus erreichen, wenn wir dem durch w bezeichneten Pfad folgen.
Array-Repräsentation
Der Datentyp "Baum" wird uns nicht wie der Datentyp "Array"
von der Sprache Delphi fertig geliefert. Wir müssen diesen Datentyp mit den von
Delphi bereitgestellten Typen modellieren. Dabei müssen wir sagen können, wie
wir das Feld B[w] finden, d.h. wie wir den Pfad w entlanggehen, bzw. die
Operationen "gehe zum Vater, gehe zum linken (rechten) Sohn ausführen:
Sei B ein binärer, vollständiger Baum. Wir repräsentieren B
durch einen Array A, indem wir das i-te Feld des Baum in der Breitennummerierung
mit dem i-ten Feld des Arrays A identifizieren. Das Angenehme an dieser Repräsentation
ist die Tatsache, daß die Operation von einem Feld zum Vaterfeld bzw.
zu den Sohnfeldern zu gelangen so überaus einfach ist. Es gilt:
Vater (A[i]) =
A[i div 2]
linker Sohn
(A[i]) = A[2i]
rechter Sohn
(A[i]) = A[2i+1].
Die Operationen "div 2" und
"*2" (d.h. in der Binärdarstellung die letzte Stelle streichen bzw.
eine 0 anhängen) gehören zu den einfachsten Rechenoperationen.
Hat der Baum B die Höhe h, so hat der Array A hat die Länge 2h+1-1.
Ist B nicht vollständig, so repräsentieren wir ihn ebenso durch den Array A, indem wir uns B durch dummy-Knoten vervollständigt denken, und die entsprechenden Array-Felder mit einem dummy-Symbol # belegen. Wir definieren dies induktiv
A[1] = Wurzel
A[2i]
= linker Sohn(A[i]), falls es einen Sohn gibt und A[i] nicht
#, sonst = #,
A[2i+1] =
rechter Sohn(A[i]), falls es 2 Söhne gibt und A[i] nicht #, sonst = #.
Beispiel:
Baum Array
A ( A B # C # # # D # # # # # # # )
|
B
|
C
|
D
Beispiel:
Baum Array
A ( A B C D # E # # # # # F G # # )
/ \
B C
| |
D E
/ \
F G
Allerdings sieht man, der Array wird nicht kürzer, auch wenn der Baum kaum mehr Knoten hat, wenn nur seine Höhe gleich bleibt. Das bedeutet, daß die Arrayrepresentation bei sehr unvollständigen Bäumen recht speicheraufwendig ist.