Inhaltsverzeichnis

Transkrypt

Inhaltsverzeichnis
Inhaltsverzeichnis
1 Spezifikation von Datenstrukturen
2
2 Felder
2.1 Spezifikation von Feldern . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
2.2 Spezifikation von unbeschränkten Feldern . . . . . . . . . . . . . . . . . . . . . . . . . . .
3
4
5
3 Arithmetik langer Zahlen
3.1 Spezifikation . . . . . . . . . . . .
3.2 Rekursive Multiplikation . . . . . .
3.3 Schlauere rekursive Multiplikation
3.4 Checker für die Multiplikation . . .
.
.
.
.
9
9
13
15
17
4 Listen
4.1 Spezifikation von doppelt verketteten Listen . . . . . . . . . . . . . . . . . . . . . . . . . .
4.2 Vergleich mit Feldern . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
19
19
23
5 Hashing
5.1 Hashing mit Verkettung . . . . .
5.2 Average-Case Analyse . . . . . .
5.3 Universelles Hashing . . . . . . .
5.4 Perfektes Hashing . . . . . . . . .
5.5 Hashing mit offener Adressierung
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
24
24
27
28
29
32
6 Bäume
6.1 Binäre Suchbäume . . . . . . . . . .
6.2 2-5-Bäume . . . . . . . . . . . . . . .
6.3 Amortisierte Analyse für 2-5-Bäume
6.3.1 Gesamtheitsmethode . . . . .
6.3.2 Bankkontomethode . . . . . .
6.3.3 Potentialmethode . . . . . . .
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
36
36
41
49
49
49
51
7 Prioritätswarteschlangen
7.1 Spezifikation von Prioritätswarteschlangen . . . . . . . . . . . . . . .
7.2 Implementierung von Prioritätswarteschlangen mit binären Heaps . .
7.3 Implementierung von Prioritätswarteschlangen mit Fibonacci Heaps
7.4 Amortisierte Analyse von Fibonacci Heaps . . . . . . . . . . . . . . .
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
52
52
53
55
66
8 Union-Find
8.1 Spezifikation von Partitionen . . . . . . . . . . . . .
8.2 Einfache Implementierungen von Partitionen . . . .
8.3 Implementierung von Partitionen mit Bäumen . . .
8.4 Analyse von Partitionen mit Pfadkomprimierung . .
8.4.1 Die Ackermannfunktionen und ihre Inversen .
8.4.2 Die Analyse . . . . . . . . . . . . . . . . . . .
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
70
70
70
74
78
78
81
.
.
.
.
.
1
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
1
Spezifikation von Datenstrukturen
Ein Ziel dieser Vorlesung ist es, verschiedene Datenstrukturen und Algorithmen kennenzulernen. Um eine
Datenstruktur einzuführen, werden wir immer die folgende Methode zur Spezifikation verwenden.
1. Definition: Im ersten Abschnitt der Spezifikation werden die Instanzen eines Datentyps (z.B. konkrete Variablen des entsprechenden Typs) definiert. Wichtige Besonderheiten für diesen Datentyp
werden hier auch beschrieben.
2. Instanziierung: Hier wird beschrieben, wie man eine Instanz des Datentyps erzeugt. Man kann
hier auch festlegen, mit welchem Wert diese Instanz initialisiert wird.
3. Operationen: Hier werden die einzelnen Operationen, die auf Objekte des Datentyps angewendet
werden können, beschrieben. Man beschreibt dabei die Schnittstelle der Operation, d.h. man gibt
genau an, wie ein Benutzer die Operation aufrufen muß und welches Ergebnis er dann erhält. Oft
braucht man Vorbedingungen an Parameter, die hier auch beschrieben werden sollten.
Ein Benutzer, der die ersten drei Abschnitte der Spezifikation eines Datentyps kennt, sollte in seinem
Programm diesen Datentyp korrekt anwenden können.
4. Implementierung: In diesem Abschnitt steht die Implementierung des Datentyps (mit den unter
Abschnitt 3 angegebenen Operationen).
5. Laufzeit: Hier werden Laufzeitangaben für die Operationen gemacht. Dabei wird die Implementierung aus Abschnitt 4 zugrunde gelegt.
2
2
Felder
Felder sind eine grundlegende Datenstruktur, die in fast allen Programmiersprachen eingebaut ist.
Vorteile der eingebauten C++-Felder:
• Sie sind sehr einfach zu verwenden.
• Sie sind zeiteffizient. Ein Zugriff auf ein Array-Element kostet konstante Zeit.
• Sie sind sehr platzeffizient. Man benötigt im allgemeinen nur soviel Speicherplatz, wie man Elemente
hat.
Beispiel:
/* Hier muss die Groesse des Arrays mit angegeben
werden, sonst funktioniert es nicht.
*/
void initialize(int* A, int d, int groesse)
{
int i;
for(i=0; i<groesse; i++) A[i]=d;
}
void ausgabe(int* A, int groesse)
{
int i;
for(i=0; i<groesse; i++) cout << A[i] << " ";
cout << endl;
}
void main()
{
int A[5];
/* Initialisierung der Elemente */
initialize(A,3,5);
/* Ausgabe */
ausgabe(A,5);
/* Zugriff auf Elemente */
A[3] = A[1] + 2;
ausgabe(A,5);
/* keine Fehlermeldung bei falschen Grenzen */
A[7] = 5;
ausgabe(A,10);
}
Nachteile der eingebauten C++-Felder:
• Die Felder kennen ihre Größe nicht.
• Wir können keine generischen Prozeduren schreiben, z.B. initialisieren mit Nullelementen.
Aus diesen Gründen werden wir eine neue Datenstruktur array entwickeln.
3
2.1
Spezifikation von Feldern
1. Definition: Eine Instanz des parametrisierten Datentyps array<T> ist eine indizierte Menge von
Variablen vom Typ T. Die Anzahl n der Variablen in dieser Menge heißt die Größe der Instanz. Die
Indizes sind die Zahlen 0, . . . , n − 1.
2. Instanziierung:
array<T> A(int n);
erzeugt eine Instanz A von array<T> der Größe n.
3. Operationen:
T& A[int i];
int A.size();
void A.init(T x);
gibt die Variable mit Index i zurück.
Vorbedingung: 0 ≤ i ≤ n − 1.
gibt die Größe von A zurück.
initialisiert alle Variablen von A mit dem Wert x.
Das Beispiel von eben können wir jetzt folgendermaßen implementieren.
void ausgabe(array<int> A)
{
int i;
for(i=0; i<A.size(); i++) cout << A[i] << " ";
cout << endl;
}
void main()
{
array<int> A(5);
/* Initialisierung der Elemente */
A.init(3);
/* Ausgabe */
ausgabe(A);
/* Zugriff auf Elemente */
A[3] = A[1] + 2;
ausgabe(A);
/* hier Fehlermeldung bei falschen Grenzen */
//A[7] = 5;
}
4. Implementierung:
template <class T>
class array
{
T* feld;
// das Feld
int groesse; // die Groesse des Feldes
public:
/* Konstruktor */
array(int n)
{
groesse = n;
4
feld = new T[n];
}
/* Destruktor */
~array()
{
delete[] feld;
}
// Feld wurde mit new deklariert
// und muss deshalb mit delete[]
// geloescht werden.
/* Zugriffsoperator */
T& operator[](int i)
{
assert(0<=i && i<groesse); // Fehlermeldung, falls die
// Arraygrenzen nicht stimmen.
return feld[i];
}
/* die Groesse */
int size()
{
return groesse;
}
/* initialisiert alle array-Elemente mit x */
void init(const T& x)
{
int i;
for(i=0; i<groesse; i++) feld[i] = x;
}
};
5. Laufzeit: Die Erzeugung einer Instanz der Größe n kostet Zeit O(1+Talloc (n)). Dabei ist Talloc (n)
die Zeit, die ein Maschinenmodell braucht, um n Speicherzellen anzulegen. Falls n zur Kompilierzeit
bekannt ist, braucht man zur Erzeugung nur O(1).
In C++ wird bei der Erzeugung einer Instanz von array<T> der Default-Konstruktor von T aufgerufen (falls T nicht bereits ein eingebauter Typ ist, der keinen Default-Konstruktor besitzt). Dann
ergibt sich die Zeit für die Erzeugung als O(n + Talloc (n)) bzw. O(n).
Zugriff auf die Variable mit Index i, 0 ≤ i < n, kostet Zeit Θ(1).
Initialisierung benötigt Zeit Θ(n).
Man kann diese Felder nur verwenden, wenn man von vorneherein weiß, wie groß die Datenmenge, die
man verwalten will, maximal wird. Vor Benutzen eines Feldes muß also die Größe der Instanz bekannt
sein.
Bei vielen Anwendungen ist aber diese Größe nicht unbedingt am Anfang bekannt. Wir könnten zum
Beispiel den Anwender eine Reihe von Zahlen eingeben lassen, diese nacheinander in einem Feld speichern
und das dann sortieren.
2.2
Spezifikation von unbeschränkten Feldern
1. Definition: Eine Instanz des parametrisierten Datentyps u_array<T> ist eine indizierte Menge von
Variablen vom Typ T . Die aktuelle Größe einer Instanz ist der größte seit der Erzeugung benutzte
Index +1. Am Anfang ist die aktuelle Größe der Instanz gleich 0. Die Indizes sind die Zahlen aus
N0 . Bei der Erzeugung des Arrays wird ein Default-Wert angegeben, der jeder noch nicht anders
spezifizierten Variablen zugewiesen wird.
5
2. Instanziierung:
erzeugt eine Instanz A von u_array<T> der Größe 0
und Default-Element def.
u_array<T> A(T def);
3. Operationen:
T A[int i];
void A.put(int i, T e);
int A.size();
gibt den Wert der Variable mit Index i zurück.
Vorbedingung: 0 ≤ i
speichert e in der Variablen mit Index i.
Vorbedingung: 0 ≤ i
gibt die aktuelle Größe von A zurück.
4. Implementierung:
template <class T>
class u_array
{
T* feld;
int groesse;
int max_index;
T default_value;
//
//
//
//
das Feld
die Groesse des Feldes
groesster verwendeter Index + 1
der Default-Wert
/* vergroessert das Feld auf ein Feld der Groesse > i */
void make_room(int i)
{
do
{
groesse = naechste_groesse(groesse);
} while(i>=groesse);
T* neues_feld = new T[groesse];
assert(neues_feld != 0); // falls etwas schief geht
for(int j=0; j<max_index; j++) neues_feld[j] = feld[j];
for(int j=max_index; j<groesse; j++) neues_feld[j] = default_value;
delete[] feld;
feld = neues_feld;
}
Wenn wir das Feld vergrößern, wird zunächst also ein größeres Feld allokiert und dann das kleiner
Feld in das größere Feld umkopiert. Die leeren Positionen des neuen Feldes werden mit dem Default
Element besetzt.
1
0
2
1
1
0
2
1
0
0
0
0
public:
/* Konstruktor */
u_array(T d)
{
groesse = 1;
max_index = 0;
6
feld = new T[1];
feld[0] = d;
default_value = d;
}
/* Destruktor */
~u_array()
{
delete[] feld;
}
/* Gibt die Groesse zurueck */
int size()
{
return max_index;
}
/* Gibt den Wert der i-ten Variablen zurueck */
T operator[](int i)
{
assert(i>=0);
if(i>=groesse) make_room(i); // evtl. array vergroessern
if(i>=max_index) max_index = i+1;
return feld[i];
}
/* Setzt den Wert der i-ten Variablen */
void put(int i, const T& e)
{
assert(i>=0);
if(i>=groesse) make_room(i); // evtl. array vergroessern
if(i>=max_index) max_index = i+1;
feld[i] = e;
}
};
5. Laufzeit: Die Erzeugung eines u_array geht in Zeit O(1).
Die Laufzeit eines Zugriffs auf die Variable mit Index i (mit [] oder put) kann beliebig hoch sein,
da man je nach Index das Array vergrößern muß. Um dennoch eine Laufzeitaussage machen zu
können, sehen wir uns zunächst die Funktion make_room an. Wir haben hier noch nicht genauer
gesagt, wie das Feld vergrößert wird.
Zu Beginn hat das Feld die Größe s0 = 1. Wird es vergrößert, so erhalten wir die Größen s1 , . . . , sn
mit si =naechste_groesse(si−1) für i = 1, . . . , n. Es sei imax der maximal verwendete Index. Dann
ist auf jeden Fall sn > imax . Damit nicht zuviel Platz verwendet wird, sollte außerdem sn = O(imax )
sein.
Als Ansatz verdoppeln wir immer die Größe:
si = 2si−1 ,
d.h.naechste_groesse(si−1) = 2si−1 . Es ist dann si = 2i s0 = 2i für i = 1, . . . , n.
7
Da imax ≥ sn−1 ist, folgt
sn = 2sn−1 ≤ 2imax
und damit sn = O(imax ).
Wir nehmen jetzt an, wir hätten m Zugriffsoperationen, die jeweils Laufzeit Tj benötigen. Die
Gesamtlaufzeit für diese m Operationen ist dann
m
X
Tj .
j=1
Bei der j-ten Operation können 2 Fälle auftreten. Entweder muß das Feld nicht vergrößert werden,
dann ist Tj = Θ(1), oder das Feld muß vergrößert werden. In diesem Fall sei die neue Größe des
Feldes si . Dann ist Tj = Θ(1 + si ), da das Vergrößern des Feldes auf die Größe si durch das
Umkopieren Zeit Θ(si ) kostet.
Die Gesamtlaufzeit ist damit
m
X
Tj = Θ m +
j=1
n
X
sj
i=0
!
.
Die Summe können wir jetzt abschätzen.
n
X
sj
=
i=0
n
X
2i
i=0
= 2n+1 − 1
= 2sn − 1
= Θ(sn ).
Damit erhalten wir für die Gesamtlaufzeit
m
X
Tj = Θ(m + sn ) = O(m + imax ).
j=1
Ist imax = O(m), d.h. imax ≤ cm, so wird wirklich ein konstanter Bruchteil (nämlich ≥ imax
c ) der
array Elemente benutzt. Dann ist die Gesamtlaufzeit für m Zugriffe gerade O(m). Damit sieht man,
daß die Laufzeit amortisiert konstant ist.
8
3
Arithmetik langer Zahlen
Als Anwendung der u_arrays betrachten wir die Arithmetik langer Zahlen.
Problem: Der eingebaute Datentyp int kann nur eine beschränkte Anzahl von Zahlen (meist 232
oder 264 ) darstellen.
Idee: Sei B ≥ 2 eine natürlich Zahl (Basis), die man mit int darstellen kann. Sei a ∈ N. Dann gibt
es ein n ∈ N mit
B n−1 ≤ a < B n .
Dann läßt sich a eindeutig darstellen als
a=
n−1
X
ai B i = (an−1 , . . . , a0 )B
i=0
mit 0 ≤ ai < B
(B-adische Darstellung, z.B. B = 2, B = 10). Dabei ist n die Länge der Darstellung. Die Zahlen aus
{0, 1, . . . , B − 1} heißen digits.
Wir berechnen also für eine beliebige natürliche Zahl a ihre B-adische Darstellung und speichern diese
in einem Feld. Da wir mit beliebig großen Zahlen rechnen wollen, können wir die Größe des Feldes nicht
von vorneherein festlegen, wir müssen also die Datenstruktur u_array verwenden.
3.1
Spezifikation
1. Definition: Eine Instanz des Datentyps integer realisiert eine nichtnegative ganze Zahl.
2. Instanziierung:
erzeugt eine Instanz A mit Wert 0
erzeugt eine Instanz A mit Wert n
integer A;
integer A(int n);
3. Operationen:
gibt den Wert des i-ten Koeffizienten in der
Basisdarstellung zurück.
Vorbedingung: 0 ≤ i
speichert d als i-ten Koeffizienten der Zahl, die
durch A dargestellt ist.
Vorbedingung: 0 ≤ i
gibt die Größe der Basisdarstellung zurück.
gibt den Wert von A + B zurück
gibt den Wert von AB zurück.
digit A[int i];
void A.put(int i, digit d);
int A.size();
integer add(integer A, integer B);
integer mult(integer A, integer B);
4. Implementierung: Wir nehmen an, daß wir einen Datenty digit zur Verfügung haben, der die
Zahlen {0, . . . , B −1} realisiert. Dabei werden die üblichen Rechenoperationen auf diesem Datentyp
modulo B durchgeführt.
class integer
{
u_array<digit>* A;
// Zeiger auf den Integer
public:
// Konstruktoren
integer()
{
A = new u_array<digit>((digit) 0);
}
integer(int n)
9
{
// hier wird ein int in ein Integer uebersetzt.
A = new u_array<digit>((digit) 0);
int i=0;
do
{
int d = n % BASIS;
A->put(i,(digit) d);
n = n - d;
n = n / BASIS;
i++;
} while(n!=0);
}
// gibt den i-ten Koeffizienten zurueck
digit operator[](int i)
{
return (*A)[i];
}
/* genau wie bei u_array braucht man put, um den Wert zu setzen. */
void put(int i, const digit& d)
{
A->put(i,d);
}
/* gibt die aktuelle Groesse zurueck */
int size()
{
return A->size();
}
};
/* Addiert zwei integer */
integer add(integer a, integer b)
{
return school_add(a,b);
}
/* Multipliziert zwei integer */
integer mult(integer a, integer b)
{
return school_mult(a,b);
}
/* Addiert drei digits, Rueckgabewert: integer */
/* Bei der Berechnung wird geschummelt! */
integer add_three_digits(digit a, digit b, digit c)
{
int ai = a.get_digit();
int bi = b.get_digit();
int ci = c.get_digit();
integer s(ai+bi+ci);
10
return s;
}
/* Addiert zwei integer mit der Schulmethode */
integer school_add(integer a, integer b)
{
int n = max(a.size(), b.size());
integer s = 0;
digit carry = (digit)0;
int i;
for(i=0; i<n; i++)
{
integer si = add_three_digits(a[i], b[i], carry);
s.put(i, si[0]);
carry = si[1];
}
if(carry.get_digit()!=0) s.put(n, carry);
return s;
}
/* Multipliziert ein integer mit B^i */
integer shift_left(integer a, int i)
{
integer b(0); // Hier wird automatisch alles auf 0 gesetzt.
int n=a.size();
for(int j=0; j<n; j++) b.put(i+j, a[j]);
return b;
}
/* Multipliziert zwei digits, Rueckgabewert ist integer */
/* Bei der Berechnung wird geschummelt! */
integer mult_two_digits(digit a, digit b)
{
int ai = a.get_digit();
int bi = b.get_digit();
integer p(ai*bi);
return p;
}
/* Multipliziert ein integer mit einem digit */
integer mult_by_digit(integer a, digit d)
{
integer p(0);
digit p_carry(0);
digit carry(0);
int n=a.size();
int i;
for(i=0; i<n; i++)
{
integer mi = mult_two_digits(a[i],d);
integer pi = add_three_digits(mi[0], p_carry, carry);
11
p.put(i, pi[0]);
carry = pi[1];
p_carry = mi[1];
}
// Nach Konstruktion kann hier nur etwas einstelliges rauskommen:
// a*d < B^n*B = B^{n+1}, also ist das Ergebnis eine hoechstens
// n+1 - stellige Zahl.
integer pi = add_three_digits((digit)0, p_carry, carry);
if((pi[0]).get_digit()!=0) p.put(n, pi[0]);
return p;
}
/* Multipliziert zwei integer mit der Schulmethode */
integer school_mult(integer a, integer b)
{
integer p(0);
int n = b.size();
for(int i=0; i<n; i++)
p=school_add(p, shift_left(mult_by_digit(a,b[i]),i));
return p;
}
5. Laufzeit: Primitive Operationen seien hier die Addition von drei digits (add_three_digits)
bzw. die Multiplikation von zwei digits (mult_two_digits).
Lemma 3.1 Um zwei n-stellige Zahlen zu addieren, benötigt school_add Θ(n) primitive Operationen.
Beweis: Die for-Schleife in school_add wird n mal durchlaufen, jedes Mal wird add_three_digits
aufgerufen.
Lemma 3.2 Um zwei n-stellige Zahlen zu multiplizieren, benötigt school_mult O(n2 ) primitive
Operationen.
Beweis: Es sei Ai :=shift_left(mult_by_digit(a,b[i]),i) die im i-ten Durchlauf der forSchleife von school_mult berechnete Zahl.
Wir zeigen zunächst: Ai < (B − 1)B n+i .
Die Zahl Ai ist durch Multiplikation einer n-stelligen Zahl mit einem digit (und späterem Shiften)
zustandegekommen. Die Zahl a war < B n , die Zahl b[i] ≤ B − 1. Damit ist
mult_by_digit(a,b[i])< (B − 1)B n und Ai < (B − 1)B n+i .
Jetzt zeigen wir durch Induktion: Im i-ten Durchlauf der for-Schleife von school_mult hat p vor
der Addition school_add höchstens n + i Stellen.
Induktionsanfang: i = 0: Vor dem ersten Schleifendurchlauf ist p = 0, hat also 0 ≤ n + 0 Stellen.
Induktionsschritt: i → i+1: Nach Induktionsvoraussetzung wurde im i-ten Schritt p durch Addition
einer höchstens n+i-stelligen Zahl (d.h. einer Zahl < B n+i ) zu der Zahl Ai < (B−1)B n+i bestimmt.
Damit ist p zu Beginn des i + 1-ten Schritts eine Zahl, die
< B n+i + (B − 1)B n+i = B n+i+1
12
ist, also eine höchstens n + i + 1-stellige Zahl.
Es werden also n Additionen von Zahlen mit höchstens n + i bzw. höchstens n + i + 1 Stellen
durchgeführt. Dafür benötigt man
n−1
X
2
(n + i + 1) = n +
i=0
n
X
i = n2 +
i=1
n(n + 1)
= O(n2 )
2
primitive Additionen.
Jetzt müssen wir noch die primitiven Additionen und Multiplikationen zählen, die bei der Multiplikation einer höchstens n-stelligen Zahl mit einem digit auftreten. In mult_by_digit wird in
jedem Schleifendurchlauf eine primitive Multiplikation und eine primitive Addition durchgeführt,
so daß man O(n) primitive Operationen für einen Aufruf dieser Funktion braucht.
Damit erhält man insgesamt nO(n) + O(n2 ) = O(n2 ) primitive Operationen für die Multiplikation
zweier höchstens n-stelliger Zahlen mit school_mult.
3.2
Rekursive Multiplikation
Die Multiplikation nach der Schulmethode benötigt Laufzeit O(n2 ). Wir versuchen jetzt, die Multiplikation rekursiv zu berechnen und damit die Laufzeit zu verbessern (Prinzip: divide-and-conquer).
Konkret: Wir haben zwei n-stellige Zahlen a = (an−1 . . . a0 )B und b = (bn−1 . . . b0 )B gegeben. Diese
Zahlen teilen wir jetzt in zwei Hälften: a(1) = (an−1 . . . am )B und a(0) = (am−1 . . . a0 )B bzw. b(1) =
(bn−1 . . . bm )B und b(0) = (bm−1 . . . b0 )B , wobei m = dn/2e.
Es gilt dann a = a(1) · B m + a(0) und b = b(1) · B m + b(0) und somit
a · b = (a(1) · B m + a(0) ) · (b(1) · B m + b(0) )
= a(1) · b(1) · B 2m + a(1) · b(0) + a(0) · b(1) · B m + a(0) · b(0) .
Eine Multiplikation von zwei n-stelligen Zahlen läßt sich also auf vier Multiplikationen zweier höchstens dn/2e-stelligen Zahlen zurückführen (man beachte, daß dn/2e < n für alle n ≥ 2).
integer recursive_mult(integer a, integer b)
{
make_equal_size(a,b); // a und b sollten die gleiche Groesse haben
int n = a.size();
// Sind beide Zahlen einstellig -> primitive Multiplikation
if(n==1) return mult_two_digits(a[0],b[0]);
// m=ceil{n/2}
int m = n/2;
if((n%2)==1) m++;
// Erzeuge
integer a1
integer a0
integer b1
integer b0
a0,a1,b0,b1
= shift_right(a,m);
= school_sub(a,shift_left(a1,m));
= shift_right(b,m);
= school_sub(b,shift_left(b1,m));
// die 4 Multiplikationen von n/2 stelligen Zahlen
integer p1 = recursive_mult(a0,b0);
integer p2 = recursive_mult(a0,b1);
integer p3 = recursive_mult(a1,b0);
integer p4 = recursive_mult(a1,b1);
13
//
p2
p2
p4
p1
p1
die Additionen fuer das Ergebnis
= school_add(p2,p3);
= shift_left(p2,m);
= shift_left(p4,2*m);
= school_add(p1,p2);
= school_add(p1,p4);
return p1;
}
Lemma 3.3 Um zwei n-stellige Zahlen miteinander zu multiplizieren benötigt recursive_mult Θ(n2 )
primitive Operationen.
Beweis: Sei T (n) die Anzahl der primitiven Operationen, die recursive_mult für zwei n-stellige
Zahlen benötigt. Ist n = 1, so führen wir nur eine primitive Operation durch. Ist n > 1, dann teilen wir
die Zahlen in je zwei (etwa) gleichgroße Teile und führen vier recursive_mult’s für je zwei dn/2e-stellige
Zahlen durch. Anschließend werden diese Zahlen mit school_add addiert. Wir addieren hier höchstens
2n-stellige Zahlen, so daß wir die Kosten für die drei Additionen mit 3 · 2n angeben können.
(Die beiden Zahlen, die zu multiplizieren sind, sind < B n , also ist das Produkt < B 2n . Das Gesamtergebnis ist also eine höchstens 2n-stellige Zahl, d.h. es werden höchstens 2n-stellige Zahlen addiert.)
Es gilt also die folgende Rekursionsgleichung:
1
falls n = 1;
T (n) =
4 · T (dn/2e) + 3 · 2n falls n > 1.
Zur Lösung dieser Rekursionsgleichung können wir das Mastertheorem anwenden. Sei
c1
falls n = 1;
T (n) =
aT (dn/be) + cnk sonst.
Dann ist

 Θ(nk )
Θ(nk log n)
T (n) =

Θ(nlogb a )
falls k > logb a,
falls k = logb a,
falls k < logb a.
Für unsere Rekursionsgleichung erhalten wir
k = 1,
logb a = log2 4 = 2
und somit
T (n) = Θ(n2 ).
Fazit: Wir haben uns hier ziemlich angestrengt, um eine rekursive Multiplikation zu erhalten, sind
aber größenordnungsmäßig genauso schlecht wie vorher. Bei Laufzeittests zeigt sich sogar, daß der konstante Faktor für recursive_mult ca. 40 mal höher liegt als für school_mult. Das liegt daran, daß
Rekursion mit einem erheblichen Overhead verbunden ist, der bei diesem Algorithmus durch nichts aufgewogen wird.
In der Tabelle stehen die Laufzeiten (in Sekunden) für die Multiplikation von n-stelligen integers mit
school_mult (Ts ) und recursive_mult (Tr ). (Die Laufzeiten sind aus dem Skript vom Sommersemester
2000).
n
8000
16000
32000
64000
128000
Ts
0.01
0.05
0.19
0.75
3.1
Tr
0.44
1.76
7.1
28.75
114.4
14
3.3
Schlauere rekursive Multiplikation
Idee: (Karatsuba und Ofman, 1963) In unserem rekursiven Algorithmus für die Multiplikation läßt sich
eine Multiplikation sparen, zum Preis von drei zusätzlichen Additionen bzw. Subtraktionen: (a, b, n, m,
a(1) , a(0) , b(1) , b(0) wie vorher)
a · b = (a(1) · B m + a(0) ) · (b(1) · B m + b(0) )
= a(1) · b(1) · B 2m + a(1) · b(0) + a(0) · b(1) · B m + a(0) · b(0)
= a(1) · b(1) · B 2m + (a(1) + a(0) ) · (b(1) + b(0) ) − a(1) · b(1) − a(0) · b(0) · B m + a(0) · b(0) .
Bemerkung: Wir müssen aufpassen; a(1) + a(0) und b(1) + b(0) haben möglicherweise (dn/2e + 1)
Stellen, und dn/2e + 1 = n für n = 2, 3. Aufspalten macht daher nur Sinn für n ≥ 4. Für n ≤ 3 benutzen
wir einfach irgendeine Methode, z.B. die Schulmethode.
integer clever_mult(integer a, integer b)
{
make_equal_size(a,b); // a und b sollten die gleiche Groesse haben
int n = a.size();
// Sind beide Zahlen <= dreistellig -> primitive Multiplikation
if(n<=3) return school_mult(a,b);
int m = n/2;
if((n%2)==1) m++;
// Erzeuge
integer a1
integer a0
integer b1
integer b0
integer c1
integer c2
a0,a1,b0,b1 und a0+a1, b0+b1
= shift_right(a,m);
= school_sub(a,shift_left(a1,m));
= shift_right(b,m);
= school_sub(b,shift_left(b1,m));
= school_add(a1,a0);
= school_add(b1,b0);
// die 3 Multiplikationen von n/2 + 1 stelligen Zahlen
integer p1 = clever_mult(a0,b0);
integer p2 = clever_mult(a1,b1);
integer p3 = clever_mult(c1,c2);
//
p3
p3
p3
p2
p1
p1
die Additionen fuer das Ergebnis
= school_sub(p3,p1);
= school_sub(p3,p2);
= shift_left(p3,m);
= shift_left(p2,2*m);
= school_add(p1,p2);
= school_add(p1,p3);
return p1;
}
Lemma 3.4 Um zwei n-stellige Zahlen miteinander zu multiplizieren benötigt clever_mult O(nlog2 3 ) =
O(n1.58 ) primitive Operationen.
Beweis: Sei T (n) die Anzahl der primitiven Operationen, die clever_mult für zwei n-stellige Zahlen
benötigt. Dann gilt
O(1)
falls n ≤ 3;
T (n) ≤
3 · T (dn/2e + 1) + O(n) falls n > 3.
15
Das ist nicht unbedingt ein brauchbares Format. Wir definieren daher eine etwas geänderte Funktion
T̃ (n) := T (n + 2). Es ist T (d(n + 2)/2e + 1) = T (dn/2e + 2) = T̃ (dn/2e) und daher
O(1)
falls n ≤ 1;
T̃ (n) ≤
3 · T̃ (dn/2e) + O(n) falls n > 1.
Das Mastertheorem gibt uns dann T̃ (n) = O(nlog2 3 ), und damit auch T (n) = T̃ (n−2) = O((n−2)log2 3 ) =
O(nlog2 3 ).
In der Tabelle stehen die Laufzeiten (in Sekunden) für die Multiplikation von n-stelligen integers
mit school_mult (Ts ) und clever_mult (Tc ). (Die Laufzeiten sind aus dem Skript vom Sommersemester
2000). Am Anfang ist school_mult besser, obwohl die asymptotische Schranke schlechter ist. Verdoppelt
man die Länge der Zahlen, dann vervierfacht sich die Laufzeit von school_mult, während sich die Laufzeit
von clever_mult nur (etwa) verdreifacht.
n
80000
160000
320000
640000
1280000
2560000
5120000
Ts
1.19
4.73
19.77
99.97
469.6
1907
7803
Tc
5.85
17.51
52.54
161
494
1457
4310
Da die Laufzeit von school_mult für kleinere Eingaben besser ist als die Laufzeit von clever_mult,
macht es Sinn, unterhalb einer gewissen Größe n0 nicht mehr aufzuspalten, sondern das Ergebnis mit
school_mult zu berechnen.
Frage: Welche Größe n0 ist sinnvoll?
Theoretische Antwort: Wir bestimmen die Arbeit T als Funktion von n, n0 :
C1 · n2 falls n ≤ n0
T (n, n0 ) =
3T n2 , n0 + C2 · n sonst.
Dabei sind C1 , C2 > 0 Konstanten. Gesucht ist ein optimales n0 , d.h. wir lösen die Extremwertaufgabe
minn0 ∈N T (n, n0 ).
Der Einfachheit halber nehmen wir an, daß n = n0 · 2k ist. Es folgt
T (n, n0 ) = 3k C1 n20 +
k−1
X
(3/2)i C2 n
i=0
=
=
k
3 C1 n20
3k C1 n20
+ 2nC2 ((3/2)k − 1)
+ 2nC2 (3/2)k − 2nC2 .
Mit 2k = n/n0 und (n/n0 )log2 3 = 2k log2 3 = 3k folgt
2−log2 3
1−log2 3
− 2nC2 .
T (n, n0 ) = nlog2 3 C1 n0
+ 2C2 n0
Um die Extremwertaufgabe zu lösen, müssen wir diese Funktion nach n0 ableiten:
∂
1−log2 3
− log 3
T (n, n0 ) = nlog2 3 (2 − log2 3)C1 n0
+ 2(1 − log2 3)C2 n0 2 .
∂n0
16
Danach müssen wir die Gleichung
∂
∂n0 T (n, n0 )
= 0 lösen. Wir erhalten:
∂
1−log2 3
− log 3
T (n, n0 ) = 0 ⇔ (2 − log2 3)C1 n0
+ 2(1 − log2 3)C2 n0 2 = 0
∂n0
1−log2 3
⇔ (2 − log2 3)C1 n0
− log2 3
= −2(1 − log2 3)C2 n0
⇔ (2 − log2 3)C1 n0 = 2(log2 3 − 1)C2
2(log2 3 − 1) C2
C2
⇔ n0 =
·
≈ 2.8 ·
.
(2 − log2 3) C1
C1
Beachte insbesondere, daß n0 nicht von n abhängt (was erstmal nicht so klar ist).
Experimentelle Antwort: Da n0 nicht von n abhängt, können wir auch versuchen, es experimentell
zu bestimmen, indem wir für festes n einfach verschiedene Werte von n0 ausprobieren, und den nehmen,
der die beste Laufzeit liefert.
Bemerkung: Es gibt eine noch schnellere Multiplikation (Schönhage und Strassen, 1971). Damit
lassen sich zwei n-stellige Zahlen mit O(n log n · log log n) primitiven Operationen multiplizieren. Der
konstante Faktor, der hier in dem O versteckt ist, ist aber so riesig, daß der Algorithmus für alle denkbaren
Werte von n sogar langsamer als school_mult ist.
3.4
Checker für die Multiplikation
Ein Checker ist ein Programm, welches testet, ob das Ergebnis richtig sein kann. Dabei soll die Laufzeit
für den Checker minimal im Vergleich zum ganzen Algorithmus sein.
Ein Checker für die Multiplikation zweier Zahlen macht also folgendes. Wenn man drei Zahlen a, b, c
eingibt, so testet der Checker, ob a · b = c sein kann.
Es macht natürlich keinen Sinn, im Checker noch einmal die Multiplikation zu wiederholen. Statt
dessen kann man einen Test machen, wie er z.B. auf dem 6. Übungsblatt erarbeitet wird.
Der Checker gibt keine 100 prozentige Garantie, daß das Ergebnis stimmt, aber man kann i.a. eine
Wahrscheinlichkeit ausrechnen, mit der das Ergebnis stimmt, wenn der Checker ohne Probleme durchläuft.
Beispiel: Ein Beispiel für einen Checker für die Multiplikation ist die sogenannte Neunerprobe.
Dabei bestimmt man die Quersumme der beiden Zahlen a und b, multipliziert diese und bestimmt die
Quersumme des Ergebnisses. Diese muß gleich der Quersumme von c sein.
Betrachten wir zum Beispiel die Multiplikation
5247 · 4678 = 24545466.
hier ist a = 5247, b = 4678, und c = 24545466. Die Quersummen sind
Q(a) = Q(5 + 2 + 4 + 7) = Q(18) = Q(1 + 8) = 9,
Q(b) = Q(4 + 6 + 7 + 8) = Q(25) = Q(2 + 5) = 7,
Q(Q(a) · Q(b)) = Q(9 · 7) = Q(63) = Q(6 + 3) = 9,
Q(c) = Q(2 + 4 + 5 + 4 + 5 + 4 + 6 + 6) = Q(36) = Q(3 + 6) = 9.
Damit wissen wir allerdings noch nicht, ob das Ergebnis stimmt.
Man kann mit dieser Methode Fehler entdecken: Für
27 · 6 = 83
erhalten wir die Quersummen
Q(27) = 9,
Q(6) = 6,
Q(Q(27) · Q(6)) = Q(54) = 9,
Q(83) = Q(11) = 2,
17
also stimmt die Rechnung nicht. Leider findet man nicht alle Fehler, denn die Rechnung
27 · 6 = 153
erfüllt diesen Checker, obwohl das Ergebnis nicht stimmt (27 · 6 = 162).
Wieso funktioniert diese Methode? Dazu sehen wir uns die Dezimaldarstellung näher an. Eine Zahl a
in Dezimaldarstellung wird geschrieben als
a=
n−1
X
ai 10i
i=0
mit 0 ≤ ai ≤ 9. In der Übung zeigen wir
a·b=c
⇒
a · b ≡ c mod 9
(und allgemeiner). Betrachten wir die Zahl a mod 9, so erhalten wir wegen 10 ≡ 1 mod 9 und damit
10i ≡ 1 mod 9 für alle i:
n−1
X
a≡
ai mod 9.
i=0
Die Quersumme einer Zahl ist also nichts anderes als diese Zahl modulo 9.
18
4
Listen
Neben Feldern sind auch Listen wichtige grundlegende Datenstrukturen. Wir betrachten hier nur doppelt
verkettete Listen.
4.1
Spezifikation von doppelt verketteten Listen
1. Definition: Eine Instanz L des Datentyps List<T> stellt eine Folge von Elementen vom Typ T
dar. Jedes Listenelement kennt seinen Nachfolger und seinen Vorgänger (doppelt verkettete Liste).
Einfügen und Löschen von Elementen können in konstanter Zeit durchgeführt werden.
Wir schreiben zur Abkürzung handle für einen Zeiger auf ein Listenelement. Das ist nicht ganz
korrekt, da das Listenelement auch vom Typ T abhängt und wir somit handle<T> schreiben müßten.
2. Instanziierung:
konstruiert eine leere Liste vom Typ List<T>.
List<T> L;
3. Operationen:
bool empty();
handle first();
handle last();
handle insert(handle pos, T& x);
handle erase(handle pos);
void clear();
void splice(handle pos,
List<T>& L2,
handle first,
handle last);
handle find(T& x);
gibt true zurück, wenn die Liste leer ist, sonst false.
liefert einen Zeiger auf das erste Element der Liste
zurück.
liefert einen Zeiger auf das letzte Element der Liste
zurück.
fügt das Element x vor Position pos ein und liefert
einen Zeiger auf das Listenelement x.
löscht das Element auf Position pos und liefert einen
Zeiger auf das auf pos folgende Element zurück.
löscht die komplette Liste (ohne den Destruktor aufzurufen)
bewegt die Elemente aus dem Bereich
[first, last] aus der Liste L2 in die Liste L und
fügt sie vor der Position pos ein.
Vorbedingungen: pos ist ein gültiger Zeiger in L und
first, last sind gültige Zeiger in L2
findet erstes Vorkommen von x in L und liefert einen
Zeiger auf dieses Element zurück.
4. Implementierung: Für die Implementierung definieren wir uns zunächst einen Datentyp, der ein
Listenelement darstellt.
template <class T>
class list_node
{
public:
list_node* prev;
list_node* next;
T inf;
// der Vorgaenger
// der Nachfolger
// das Element selbst
};
next
prev
inf
Für einen Zeiger auf ein solches Listenelement schreiben wir kurz handle.
19
template <class T>
class list
{
typedef list_node<T>* handle;
handle head;
// der Kopf der Liste
Idee: Eine Liste besitzt immer einen Listenkopf. Dieser zeigt auf das erste Element der Liste. Das
letzte Element der Liste zeigt auf den Listenkopf. Eine leere Liste besteht dann nur aus dem Kopf.
H
H
public:
// Loescht alle Listenelemente bis auf den Kopf
void clear()
{
handle tmp = head->next;
while(tmp!=head)
{
handle tmpnext = tmp->next;
delete tmp;
tmp = tmpnext;
}
head->next = head;
head->prev = head;
}
// Konstruktor, erzeugt leere Liste
list()
{
head = new list_node<T>;
head->next = head;
head->prev = head;
}
// Destruktor
~list()
{
clear();
delete head;
}
20
// Testet, ob die Liste leer ist
bool empty()
{
if(head->next==head) return true;
return false;
}
// gibt das erste Listenelement zurueck
handle first()
{
return head->next;
}
// gibt das letzte Listenelement zurueck
handle last()
{
return head->prev;
}
// fuegt ein Element in die Liste vor pos ein
handle insert(handle pos, T x)
{
handle tmp = new list_node<T>;
tmp->inf = x;
tmp->next = pos;
tmp->prev = pos->prev;
pos->prev ->next = tmp;
pos->prev = tmp;
return tmp;
}
// loescht das Element pos
handle erase(handle pos)
{
assert(pos!=head);
handle next_node = pos->next;
handle prev_node = pos->prev;
prev_node->next = next_node;
next_node->prev = prev_node;
delete pos;
return next_node;
}
21
/* Bewegt die Elemente aus dem Bereich [first, last] aus
der Liste L2 in die Liste L und fuegt sie vor pos ein. */
void splice(handle pos, list<T>& L2, handle first, handle last)
{
// Hier sollten noch irgendwelche Abfragen rein, um die
// Vorbedingungen zu ueberpruefen.
// Hier passiert nichts
if(first==last->next) return;
if(pos==last->next) return;
if(pos==first) return;
// die Teilliste aus L2 loeschen
first->prev->next = last->next;
last->next->prev = first->prev;
// die Teilliste in L einfuegen
pos->prev->next = first;
first->prev = pos->prev;
last->next = pos;
pos->prev = last;
}
// Sucht das Element x in der Liste
handle find(T x)
{
handle tmp = head->next;
while(tmp!=head)
{
if(tmp->inf == x) return tmp;
tmp = tmp->next;
}
assert(tmp!=head);
}
22
};
5. Laufzeit: Die Operationen clear und find laufen i.a. die ganze Liste durch und brauchen deshalb
O(n) Zeit (bei n Listenelementen). Alle anderen Operationen brauchen nur konstante Zeit O(1).
4.2
Vergleich mit Feldern
Zunächst verhalten sich Felder und Listen ziemlich gleich. Um n Elemente zu speichern braucht man bei
beiden Varianten O(n) Speicherplatz. Einfügen und Löschen geht jeweils in konstanter Zeit O(1). Unterschiede zwischen diesen Datenstrukturen gibt es nur, wenn man die Elemente als geordnet betrachtet.
In einer Liste wie in einem Feld sind die Elemente in einer bestimmten Weise angeordnet. In einem
Feld ist diese Ordnung durch den Index gegeben, in einer Liste wird sie durch Zeiger bestimmt.
Hat man n geordnete Elemente gespeichert und will ein neues Element an seiner richtigen Stelle
einfügen, so kann man das bei Listen in konstanter Zeit tun, da hier nur Zeiger umgehängt werden. Bei
Feldern braucht man Zeit O(n), da man dann alle Elemente ab dieser Stelle umkopieren muß. Im schlechtesten Fall, wenn man das neue Element an die erste Stelle einfügt, muß man n Elemente umkopieren.
Analog sieht es aus, wenn man ein bestimmtes Element löschen will.
Will man dagegen bei n angeordneten Elementen das k-te Element (z.B. das k-t kleinste Element)
finden, so eignen sich dafür Felder besser. In einem Feld steht das k-te Element an der k − 1-ten Stelle
(falls es nur verschiedene Elemente gibt). Man kann in konstanter Zeit darauf zugreifen. Bei einer Liste
muß man die Liste bis zum k-ten Element durchlaufen, was Zeit O(k) kostet.
Je nach der Anwendung sollte man sich also genau überlegen, ob man Felder oder Listen verwendet.
23
5
Hashing
Problem: Wir wollen eine Menge von Daten verwalten. Es soll leicht sein, Daten einzugeben und zu
löschen, sowie Daten nach einem bestimmten Schlüssel zu suchen.
Beispiele:
1. Wir wollen die Anwohner einer Straße nach Hausnummern geordnet auflisten. Später wollen wir
die Anwohner der Hausnummer k finden. (Wir gehen hier davon aus, daß nicht mehrere Familien
in einem Haus wohnen.)
Für dieses Problem eignen sich Felder als Datenstruktur. In A[i] speichern wir die Anwohner der
Hausnummer i. Einfügen, Löschen, und Suche (nach Hausnummern geordnet) benötigen Zeit O(1).
Das Array benötigt gerade soviel Speicherplatz, wie es Daten gibt.
2. Wir wollen die Daten von 100 Studenten nach Matrikelnummern geordnet verwalten.
Idee: Matrikelnummern sind auch Zahlen, also speichern wir die Daten in einem Feld an der
entsprechenden Stelle, genau wie oben.
Problem: Matrikelnummern sind zu groß (7-stellige Zahlen). Wir bräuchten ein Feld der Größe
108 (oder mindestens 3 · 107 , wenn man davon ausgeht, daß die höchste Matrikelnummer mit 2
anfängt), um nur 100 Daten zu speichern. Der Speicherplatzverbrauch ist also unangemessen hoch.
2. Idee: Ändere die Datenstruktur array so, daß eine untere und eine obere Schranke angegeben
werden kann. Dann braucht man deutlich weniger Speicher, wenn man als untere Schranke die
kleinste Matrikelnummer und als obere Schranke die größte verwendete Matrikelnummer angibt.
Problem: Die Spannbreite der Matrikelnummern kann trotzdem ziemlich groß sein. Man braucht
eventuell trotzdem ein Array der Größe 106 , um die 100 Daten zu speichern. Wenn man z.B. die
Daten aus der Anmeldung zur Vorlesung Info 5 nimmt, braucht man immer noch ein Array der
Größe 1.5 · 106 .
3. Idee: Speichere die Daten in einem Array der Größe 100, wobei nur die letzten beiden Stellen
der Matrikelnummer betrachtet werden (d.h. die Matrikelnummer modulo 100).
Es kann dabei natürlich passieren, daß mehrere Matrikelnummern die gleichen Ziffern haben. Dann
werden sie in einer Liste an der entsprechenden Stelle gespeichert. Wir hoffen dabei, daß nicht zu
viele Matrikelnummern an die gleiche Stelle geschrieben werden.
Sprechweise: Es sei U das Universum, aus dem die Elemente stammen (oBdA U = {0, . . . , N − 1}
für ein N ∈ N). In unserem Beispiel ist N = 108 . Unser Ziel ist es, eine Teilmenge S ⊂ U geeignet zu
verwalten. Dabei sollen die Operationen Einfügen, Löschen und Suchen möglichst schnell realisiert sein.
Eine Abbildung
h : U → {0, . . . , m − 1}
heißt Hashfunktion. Die Zahl m ist die Größe der Hashtabelle. die Hashtabelle selbst ist eine
Datenstruktur, in der die Elemente aus U mit Hilfe der Hashfunktion verwaltet werden.
Wie bei dem Beispiel oben mit den Datensätzen der 100 Studierenden macht es Sinn zwischen dem
Datensatz zu unterscheiden (HashItem) und der Schlüssel-Information (Key), die einfach eine Komponente von einem HashItem ist und anhand derer sich das HashItem eindeutig identifizieren läßt; im
Beispiel oben ist das die Matrikelnummer.
5.1
Hashing mit Verkettung
Bei Hashing mit Verkettung ist die Hashtabelle einfach ein Feld von (einfach oder doppelt verketteten)
Listen.
24
0
10 inf_10
30 inf_30
34 inf_34
14 inf_14
1
2
11 inf_11
20 inf_20
3
4
21 inf_21
37 inf_37
24 inf_24
5
6
7
10 inf_10
24 inf_24
27 inf_27
8
17 inf_17
9
34 inf_34
30 inf_30
14 inf_14
Hashtabelle: Hashing mit Verkettung
27 inf_27
31 inf_31
U
S
1. Definition: Eine Instanz H des Datentyps HashTable stellt eine Hashtabelle mit Einträgen vom
Typ HashItem dar. Die Hashtabelle realisiert Hashing mit Verkettung. Einfügen eines Elementes
und Löschen eines Elementes (bei gegebener Position) geht in konstanter Zeit. Die Hashfunktion
hash() zu dieser Tabelle wird extra angegeben.
2. Instanziierung:
Konstruiert eine leere Hashtabelle der Größe
size mit Einträgen vom Typ HashItem.
HashTable H(int size);
3. Operationen:
void H.insert(HashItem i);
void H.erase(HashItem i);
void H.erase(HashItem i, handle pos);
handle H.find(Key k);
Fügt das Element i in die Hashtabelle ein.
Löscht das Element i.
Löscht das Element i in Position pos.
Findet die Position in der Hashtabelle, an der
das HashItem mit Schlüssel k steht und gibt
diese zurück.
4. Implementierung: Ich betrachte hier HashItems, deren Schlüssel vom Typ key und deren Information ein string ist.
class HashTable
{
array<list<HashItem> >* A;
// fuer die Listen
typedef list_node<HashItem>* handle;
public:
// Konstruktor
HashTable(int size)
{
A = new array<list<HashItem> >(size);
}
// Destruktor
25
~HashTable()
{
delete A;
}
// ein HashItem einfuegen
void insert(HashItem i)
{
key x = i.getKey();
// i wird an die Liste in (*A)[h(x)] angehaengt
handle last = ((*A)[hash(x)]).last();
((*A)[hash(x)]).insert(last, i);
}
// ein HashItem loeschen
void erase(HashItem i)
{
key x = i.getKey();
handle pos = ((*A)[hash(x)]).find(i);
((*A)[hash(x)]).erase(pos);
}
// ein HashItem in einer bestimmten Position loeschen
void erase(HashItem i, handle pos)
{
key x = i.getKey();
((*A)[hash(x)]).erase(pos);
}
// ein HashItem finden
handle find(key x)
{
// Erzeuge ein HashItem mit Schluessel x.
// Bei Vergleich von HashItems wird nur der Schluessel
// verglichen, deshalb findet man das HashItem (inf_x, x),
// wenn man nach tmp sucht.
HashItem tmp("", x);
handle pos = ((*A)[hash(x)]).find(tmp);
return pos;
}
};
5. Laufzeit: Einfügen eines Elementes und Löschen eines Elementes bei gegebener Position gehen in
konstanter Zeit O(1).
Schwieriger wird es bei find. Dabei ist klar, daß Löschen eines Elementes ohne Angabe der Position die gleiche Laufzeit benötigt wie find. Wir nehmen jetzt an, daß die Anzahl der aktuell
gespeicherten Elemente n ist. Diese Elemente bilden eine Teilmenge S von U .
Im schlechtesten Fall entartet Hashing mit Verkettung zu einer einzigen Liste. Der Zugriff auf ein
Element kann dann bis zu Θ(n) Zeit benötigen.
Genauer: Für eine feste Hashfunktion h und für x ∈ U sei
Cx,h (S) := |{y ∈ S | x 6= y, h(x) = h(y)}|
26
die Größe der Konfliktmenge von x bzgl. S. Ein Zugriff auf das Element x benötigt dann Zeit
O(1 + Cx,h (S)). Dabei kann Cx,h (S) jeden Wert zwischen 0 und n − 1 annehmen.
5.2
Average-Case Analyse
Um die mittlere Laufzeit der Zugriffe beim Hashing mit Verkettung zu bestimmen, muß man den Erwartungswert der Zahl Cx,h (S) berechnen. Wir gehen dabei von einem Universum U der Größe N und einer
Hashtabelle der Größe m aus. Die Hashfunktion h : U → {0, . . . , m − 1} sei fest gewählt. Wir wollen eine
Teilmenge S ⊂ U der Größe n mit der Hashtabelle verwalten.
Dazu nehmen wir an, daß alle n-elementigen Teilmengen S von U gleichwahrscheinlich sind. Der
Ereignisraum ist dann
Ω = {S ⊂ U | |S| = n}.
Die Wahrscheinlichkeit für die Auswahl einer Menge S ist
P (S) =
1
1
.
=
|Ω|
N
n
Für festes x ∈ U ist dann Cx,h (S) eine Zufallsvariable, die S auf die Anzahl der Elemente in S, die
mit x kollidieren, abbildet.
Lemma 5.1 Sei h : U → {0, . . . , m − 1} eine Funktion, die U gleichmäßig über {0, . . . , m − 1} verteilt,
d.h. für alle i ∈ {0, . . . , m − 1} ist |{x ∈ U : h(x) = i}| ≤ dN/me, und sei, für ein n ∈ N, S eine zufällige
n-elementige Teilmenge aus dem Universum U . Dann gilt für ein beliebiges x ∈ U , daß
n
E(Cx,h (S)) ≤ .
m
Beweis: Definiere die Indikatorvariablen
1 , falls x 6= y, h(x) = h(y);
δh (x, y) :=
0 , sonst.
P
Dann ist Cx,h (S) =
δh (x, y) und es gilt
y∈S
X
X
Cx,h (S) =
X
δh (x, y)
S⊆U,|S|=n y∈S
S⊆U,|S|=n
=
X
X
δh (x, y)
y∈U S⊆U,|S|=n,y∈S
=
=
X N −1 δh (x, y)
n−1
y∈U
N −1 X
δh (x, y).
n−1
y∈U
Die letzte Summe ist genau eins weniger wie die Anzahl der Elemente aus dem Universum, die auf h(x)
abgebildet werden (eins weniger, weil δh (x, x) = 0), also nach der Annahme aus dem Lemma über h
höchstens dN/me − 1 ≤ N/m. Mit
X
X
1
E(Cx,h (S)) =
Cx,h (S)P (S) = Cx,h (S)
N
S⊆U,|S|=n
S⊆U,|S|=n
n
folgt
E(Cx,h (S)) ≤
N −1
·
n−1
N
n
N
m
=
(N −1)!·N
(n−1)!·(N −n)!·m
N!
n!·(N −n)!
27
=
n
N ! · n! · (N − n)!
= .
N ! · (n − 1)! · (N − n)! · m
m
Definition 5.1 Für eine Hashtabelle der Größe m, in der n Elemente gespeichert sind, heißt β = n/m
der Belegungsfaktor.
Korollar 5.1 Unter den Annahmen des Lemmas ist die erwartete Laufzeit für einen Zugriff auf ein
beliebiges Element stets O(1 + β), wenn β der Belegungsfaktor der Tabelle vor der Operation war (O(β)
für die Lokalisierung des Elementes und O(1) für den eigentlichen Zugriff ).
Problem: Das Lemma macht Annahmen über die Eingabe, die natürlich nicht immer realistisch sind.
Es sind nicht immer alle n-elementigen Teilmengen gleich wahrscheinlich.
5.3
Universelles Hashing
Idee: Wir machen gar keine (Zufälligkeits-)Annahmen über die Menge S der in der Hashtabelle zu
speichernden Elemente und wählen stattdessen die Hashfunktion zufällig.
Definition 5.2 Es sei c ∈ R. Eine Klasse H von Funktionen von U nach {0, . . . , m − 1} heißt cuniversell, falls für alle x, y ∈ U mit x 6= y gilt:
|{h ∈ H : h(x) = h(y)}| ≤ c · |H|/m.
Bei der Average-Case Analyse waren die Hashfunktion h und x fest, während wir eine Wahrscheinlichkeitsannahme über S gemacht haben. Jetzt wählen wir S und x fest, d.h. S ist die Menge der aktuell
gespeicherten Elemente und x ∈ U ist fest gewählt. Dann definieren wir die Zufallsvariable
Cx,S (h) := |{y ∈ S | x 6= y, h(x) = h(y)}|.
Lemma 5.2 Sei h zufällig aus einer c-universellen Klasse von Funktionen von U → {0, . . . , m − 1}
gewählt und S eine beliebige n-elementige Teilmenge von U . Dann gilt für ein beliebiges x ∈ U , daß
E(Cx,S (h)) ≤ c · n/m.
Beweis: Der Ereignisraum Ω ist jetzt einfach H; das Wahrscheinlichkeitsmaß ist wieder das uniforme:
1
.
P (h) = |H|
Der Beweis geht analog zu dem vorherigen, nur einfacher:
X
XX
Cx,S (h) =
δh (x, y)
h∈H y∈S
h∈H
=
XX
δh (x, y)
y∈S h∈H
=
X
y∈S,y6=x
≤
≤
X
y∈S,y6=x
|{h ∈ H : h(x) = h(y)}|
c|H|
m
c|H|n
,
m
da |S| = n ist. Dann ist
E(Cx,S (h)) ≤
c|H|n
= c · n/m.
m|H|
Korollar 5.2 Unter den Annahmen des Lemmas ist die erwartete Laufzeit für einen Zugriff auf ein
beliebiges Element stets O(1 + cβ), wenn β der Belegungsfaktor der Tabelle vor der Operation war (O(cβ)
für die Lokalisierung des Elementes und O(1) für den eigentlichen Zugriff ).
28
Wir zeigen jetzt, daß es solche c-universellen Klassen von Hashfunktionen überhaupt gibt.
Beispiel: Sei m eine Primzahl und U = {0, . . . , mr − 1} (d.h. N = mr ). Für a, x ∈ U seien
a = (ar−1 . . . a0 )m und x = (xr−1 . . . x0 )m die Darstellungen zur Basis m. Man definiert
a × x :=
r−1
X
a i xi .
i=0
Für jedes a ∈ U betrachten wir jetzt die Funktion
ha : U → {0, . . . , m − 1},
ha : x 7→ a × x
mod m.
Die Klasse dieser Funktionen hat also N Elemente. Sie ist 1-universell.
Zum Beweis seien x, y ∈ U mit x 6= y. In der Darstellung zur Basis m ist x = (xr−1 . . . x0 )m ,
y = (yr−1 , . . . , y0 )m . Da diese beiden Zahlen verschieden sind, unterscheidet sich ihre Basisdarstellung an
mindestens einer Stelle. Sei also xj 6= yj . Die Frage ist, für wieviele a ∈ U die Gleichheit ha (x) = ha (y)
bzw.
r−1
r−1
X
X
a i xi ≡
ai yi mod m
i=0
i=0
gilt. Diese Gleichheit ist gleichbedeutend mit
aj (xj − yj ) ≡
X
0≤i<r,i6=j
ai (yi − xi ) mod m.
Dabei ist, wegen xj 6= yj auf der linken Seite xj − yj 6≡ 0 mod m.
Wieviele a ∈ U kann man wählen, so daß diese Gleichheit erfüllt ist? Für gegebene x und y kann man
r − 1 Zahlen ai ∈ {0, . . . , m − 1} beliebig wählen und erhält dann eine Zahl A mod m auf der rechten
Seite. Dann ist aj eindeutig bestimmt durch
aj ≡ A/(xj − yj )
mod m.
(Da m eine Primzahl ist, ist aj auch wirklich eindeutig.)
Wir haben also ≤ mr−1 Möglichkeiten für die Wahl von a und damit
|{ha | a ∈ U, ha (x) = ha (y)}| ≤ mr−1 =
mr
N
= .
m
m
Ein Beispiel für eine 2-universelle Klasse von Hashfunktionen findet man auf dem Übungsblatt.
5.4
Perfektes Hashing
Bei dem oben beschriebenen Hashverfahren können immer noch Kollisionen auftreten. Diese werden dann
dadurch gelöst, daß man die Daten in Listen verwaltet. In diesem Fall kann der Zugriff auf ein Element
relativ teuer sein, da man erst eine Liste durchsuchen muß.
Besser wäre es, wenn gar keine Kollisionen auftreten würden, d.h. wenn die Hashfunktion auf der
Menge S der zu verwaltenden Elemente injektiv wäre. In diesem Fall würde jeder Elementzugriff in
konstanter Zeit realisierbar sein. Eine solche Hashfunktion heißt perfekte Hashfunktion.
Wie oben sei m die Größe der Hashtabelle und n = |S|. Eine perfekte Hashfunktion existiert natürlich
immer, wenn m ≥ n ist. Damit diese Funktion sinnvoll ist, muß sie allerdings in konstanter Zeit berechenbar sein. Wir werden jetzt eine solche Funktion suchen. Dabei betrachten wir eine 1-universelle Klasse H
von Hashfunktionen.
Satz 5.1 Es sei H eine 1-universelle Klasse von Hashfunktionen, m die Größe der Hashtabelle und
n = |S|.
n
• Ist m >
, dann enthält H eine Funktion h, die auf S injektiv ist. Diese Funktion kann in
2
Zeit O(|H|n) gefunden werden.
29
n
, dann sind mindestens die Hälfte der Funktionen aus H injektiv auf S. Eine solche
2
Funktion kann durch einen randomisierten Algorithmus in erwarteter Zeit O(n) gefunden werden.
• Ist m > 2
• Ist m > n−1, dann ist für mindestens die Hälfte aller Funktionen h ∈ H die Anzahl der Kollisionen
< n.
Beweis: Wir betrachten die Zufallsvariable
CS (h) := |{{x, y}| x, y ∈ S, x 6= y, h(x) = h(y)}|.
Diese gibt die Zahl der Kollisionen von h in S an. Wir können sie auch als Summe
X
δh (x, y),
CS (h) =
{x,y}⊂S,x6=y
mit δh wie oben, schreiben. Da H 1-universell ist, folgt für den Erwartungswert von δh (x, y) für feste x, y:
E(δh (x, y)) = 0 · P (δh (x, y) = 0) + 1 · P (δh (x, y) = 1) = P (δh (x, y) = 1) ≤
|H|/m
1
= .
|H|
m
Für den Erwartungswert von CS (h) folgt dann
E(CS (h)) =
X
E(δh (x, y)) =
{x,y}⊂S,x6=y
1
m
X
1=
{x,y}⊂S,x6=y
n
2
·
1
.
m
n
, dann ist E(CS (h)) < 1. Das bedeutet, daß im Durchschnitt CS (h) < 1 ist, also
2
muß es ein h ∈ H geben, für das CS (h) < 1 gilt. Nach Konstruktion ist dann für diese Funktion
CS (h) = 0, d.h. die Funktion h ist injektiv auf S. Man kann jetzt alle Funktionen aus H testen und
für jede Funktion in O(n) Zeit feststellen, ob diese injektiv auf S ist oder nicht (s. Übung).
n
, dann ist E(CS (h)) < 12 . In diesem Fall gilt für mindestens die Hälfte aller
• Ist m > 2
2
Funktionen aus H, daß CS (h) = 0 ist. Es gilt nämlich wegen der Markow Ungleichung (s. Übung):
• Ist m >
P (CS (h) ≥ 1) ≤ E(CS (h)) <
1
.
2
Wäre für mindestens die Hälfte aller h ∈ H der Wert CS (h) ≥ 1, so wäre auch die Wahrscheinlichkeit
≥ 12 . Da dies nicht der Fall ist, muß für mindestens die Hälfte aller Funktionen CS (h) = 0 sein, d.h.
diese Funktionen sind injektiv auf S.
Wählt man sich eine solche Funktion zufällig, so kann man in Zeit O(n) testen, ob diese injektiv auf S
ist. Dies ist mit Wahrscheinlichkeit ≥ 21 der Fall, so daß die erwartete Zahl der Wahlwiederholungen,
bis man auf eine injektive Funktion stößt, höchstens 2 ist.
• Ist m > n − 1, so folgt E(CS (h)) <
n
2
und mit der Markow Ungleichung sieht man
P (CS (h) ≥ n) ≤
1
E(CS (h))
< .
n
2
Die Argumentation geht genauso wie eben.
n
=
2
n(n − 1)), dann können wir in vernünftiger Zeit (erwarteter Zeit O(n)) eine Hashfunktion finden, die auf
der Menge S injektiv ist. Die Zugriffe auf die einzelnen Elemente laufen dann in konstanter Zeit.
Was wissen wir jetzt? Wenn wir die Größe der Hashtabelle groß genug wählen (m ≥ 2
30
Einen Nachteil hat dieses Verfahren allerdings: die Hashtabelle ist unnötig groß. Durch zweistufiges
perfektes Hashing soll die Hashtabelle auf eine Größe O(n) reduziert werden.
Idee: Genau wie bei Hashing mit Verkettung werden verschiedene Elemente auf eine Position gehasht.
Allerdings werden die Elemente dann nicht in einer Liste gespeichert, sondern jeweils in einem Array,
wobei wieder eine Hashfunktion (diesmal eine andere) angewendet wird. Diese zweite Hashfunktion muß
injektiv sein. Dabei muß man natürlich darauf achten, daß nur eine beschränkte Anzahl von Elementen
auf die gleiche Position gehasht wird, damit die Größen der einzelnen Hashtabellen nicht zu groß werden.
Dazu beschreiben wir die Zufallsvariable CS (h) ein wenig anders. Es sei für 0 ≤ i < m
BS,i (h) := |{x ∈ S | h(x) = i}|.
Das ist also die Anzahl der Elemente aus S, die auf das Feld i abgebildet werden. Wieviele Kollisionen
gibt es aufdem Feld i?
Die Zahl der Kollisionen auf diesem Feld ist gerade die Zahl der Paare auf diesem
BS,i (h)
. Damit folgt
Feld, also
2
CS (h) =
m−1
X
i=0
BS,i (h)
2
.
Zweistufiges Perfektes Hashing: Wir wählen m = n und erhalten somit aus dem Satz, daß für
mindestens die Hälfte aller h ∈ H gilt
m−1
X
i=0
BS,i (h)
2
= CS (h) < n.
Ein solches h findet man in erwarteter Zeit O(n). Für jedes i sei
BS,i (h)
+1
mi = 2
2
und Hi eine Menge von universellen Hashfunktionen U → {0, 1, . . . , mi − 1}. Für jedes i sucht man ein
hi ∈ Hi , welches injektiv auf der Menge {x ∈ S | h(x) = i} ist. Diese Suche geht nach dem Satz in
erwarteter Zeit O(BS,i (h)). Insgesamt hat man für diesen Schritt die erwartete Laufzeit
!
m−1
X
O
BS,i (h) = O(n).
i=0
Für jedes i baut man sich jetzt eine Hashtabelle der Größe mi , in der mit der Hashfunktion hi die
Elemente, die durch h auf i abgebildet werden, injektiv gespeichert werden.
Die Gesamtgröße der Tabelle ist dann
m+
m−1
X
i=0
mi
m−1
X
BS,i (h)
= n+
2
+1
2
i=0
m−1
X BS,i (h) = n+2
+m
2
i=0
m−1
X BS,i (h) = 2n + 2
2
i=0
< 2n + 2n
= 4n.
Damit haben wir folgenden Satz bewiesen:
Satz 5.2 Mit Hilfe des zweistufigen Verfahrens kann eine perfekte Hashtabelle der Größe O(n) in erwarteter Zeit O(n) erzeugt werden.
31
0
10
25
1
16
31
1
2
37
7
12
3
3
23
18
4
19
24
34
26
8
Hashfunktion:
h(x) = x mod 5
0
25
1
26
2
7
10
1
h_0(x) = floor(x/2) mod 3
16
37
31
h_1(x) = x mod 13
12
h_2(x) = x mod 7
3
3
18
4
24
19
8
23
34
h_3(x) = x mod 13
h_4(x) = x mod 7
Hashfunktion:
h(x) = x mod 5
5.5
Hashing mit offener Adressierung
Eine andere Möglichkeit, Speicherplatz zu sparen, ist Hashing mit offener Adressierung.
Idee: Für jedes Element werden solange verschiedene Hashwerte ausprobiert, bis eine freie Position
gefunden ist. Auf diese Weise können bis zu m Elemente direkt in der Tabelle gespeichert werden, für
Tabellengröße m.
Formal: Unsere Hashfunktion ist nicht mehr von der Form h : U → {0, . . . , m − 1}, sondern eine
Funktion
h : U × {0, . . . , m − 1} → {0, . . . , m − 1}.
Für ein x ∈ U probiert man der Reihe nach die Positionen h(x, 0), h(x, 1), . . . , h(x, m − 1) aus.
Variante 1: (engl. linear probing)
h(x, i) = (h(x) + i) mod m,
wobei h : U → {0, . . . , m − 1}. Hier werden für jedes x alle Tabellenpositionen einmal ausprobiert.
Variante 2: (engl. double hashing)
h(x, i) = (h1 (x) + i · h2 (x))
mod m,
wobei h1 : U → {0, . . . , m − 1} und h2 : U → {1, . . . , m}. Hier werden für ein x genau dann alle
Tabellenpositionen ausprobiert, wenn m und h2 (x) teilerfremd sind (was nahe legt, m als eine Primzahl
zu wählen).
Es gibt allerdings auch einige Probleme bei dieser Variante des Hashing.
• Anders als bei Hashing mit Verkettung darf der Belegungsfaktor nie > 1 werden (wenn die Tabelle
voll ist, ist sie voll).
• Löschen kann nicht so ohne weiteres unterstützt werden. Wenn man ein Element, das z.B. an Stelle
h(x, i) steht, löscht, entsteht unter Umständen eine Lücke. Darauf muß man achten, wenn man
später ein Element sucht oder ein neues Element einfügen will.
32
• Die Position eines Elementes hängt nicht nur von dem Element selbst ab, sondern auch von der
Reihenfolge der Eingabe.
Hashing mit offener Adressierung, h(x,i) = (x+i) mod 12
15, 47, 33, 26, 83, 58, 87, 34, 22, 46, 73, 24
83
26
15
33
47
87
83
26
15
33
58
47
34
83
26
15
87
26
15
87
33
58
47
33
58
47
.........
83
34
22
46
73
24
Zur Average-Case Analyse nehmen wir an, daß die Positionen in einer zufälligen Reihefolge ausprobiert
werden, und daß die Anzahl der gespeicherten Elemente n kleiner ist als die Größe der Hashtabelle m
(also Belegungsfaktor β < 1). Weiter sei h : U × {0, . . . , m − 1} → {0, . . . , m − 1} und für x ∈ U sei die
Folge h(x, 0), . . . , h(x, m − 1) eine zufällige Permutation der Folge 0, . . . , m − 1 ist (insbesondere werden
also alle Positionen ausprobiert).
Lemma 5.3 Für festes x ∈ U betrachten wir die Zufallsvariable
X := min{i ∈ N0 : h(x, i) nicht belegt}.
Dann ist
m+1
=O
E(1 + X) ≤
m−n+1
1
1−β
Beweis: Es ist X ≥ i genau dann, wenn h(x, 0), . . . , h(x, i − 1) alle belegt sind, also wenn
{h(x, 0), . . . , h(x, i−1)} eine Teilmenge von {j : j-te Stelle belegt} ist. Die Menge {j : j-te Stelle belegt}
hat nun n Elemente, und {h(x, 0), . . . , h(x, i−1)} ist nach Annahme eine zufällige i-elementige Teilmenge
von {0, . . . , m − 1}. Somit ist
n
n!
i
n! (m − i)!
= i!·(n−i)!
P (X ≥ i) = =
·
.
m!
m! (n − i)!
m
i!·(m−i)!
i
Wegen P (X ≥ n + 1) = 0 folgt
E(X) =
∞
X
i=1
P (X ≥ i) =
n
X
i=1
n
P (X ≥ i) =
33
n! X (m − i)!
.
m! i=1 (n − i)!
Es ist für i = 1, . . . , n:
n! (m − i)!
·
m! (n − i)!
=
=
=
=
Man erhält also die Summe
n!(m − n)!
m!
n!(m − n)!
m!
(m − i)!
(n − i)!(m − n)!
(m − i)!
·
(n − i)!((m − i) − (n − i))!
·
m−i
(m−i)−(n−i)
m
n
m−i
m−n
.
m
n
n
n m−1 n! X (m − i)!
1 X
1 X m−i
i
= m
.
= m
m! i=1 (n − i)!
m−n
m−n
n i=1
n i=m−n
Jetzt kann man sich überlegen (s. Übung), daß für alle 0 ≤ k ≤ m − 1 gilt:
m−1
X
i=k
i
k
=
m
.
k+1
Damit folgt dann:
n
n! X (m − i)!
m! i=1 (n − i)!
=
1
m
n
=
=
=
m
m−n+1
n!(m − n)!
m!
·
m!
(m − n + 1)!(n − 1)!
n!(m − n)!
(m − n + 1)!(n − 1)!
n
.
m−n+1
Es folgt, daß E(1 + X) = (m + 1)/(m − n + 1) = O(1/(1 − n/m)) ist.
Korollar 5.3 Unter unseren Annahmen geht Einfügen eines Elementes in die Hashtabelle in Zeit
O(1/(1 − n/m)) = O(1/(1 − β)).
Beweis: Wenn man ein neues Element einfügt, muß man solange suchen, bis ein freier Platz gefunden
wird. Das geht nach dem Lemma in Zeit O(1/(1 − n/m)). Dann wird das Element an die entsprechende
Stelle geschrieben.
Für die Laufzeit ist auch wichtig, wie lange man nach einem bestimmten Element sucht.
Satz
5.3 Unter unseren
Annahmen
geht Suchen
eines Elementes in die Hashtabelle in erwarteter Zeit
m
1
1
1
1
O m
ln
+
=
O
ln
+
n
1−n/m
n
β
1−β
β .
Beweis: Wir suchen nach dem Element x. Nehmen wir an,
wir hätten x als i-tes Element eingefügt.
1
Nach dem Lemma haben wir beim Einfügen von x dann O 1−i/m
Stellen durchsuchen müssen, bis wir
1
eine freie Stelle für x gefunden haben. Um x zu finden, müssen wir also O 1−i/m
Stellen durchsuchen.
Leider wissen wir nicht, als wievieltes Element x eingefügt wurde. Um dennoch eine Analyse machen
34
zu können, bilden wir den Mittelwert über alle Elemente.
n−1
1
1X
n i=0 1 − i/m
n−1
1X m
n i=0 m − i
=
n−1
mX 1
n i=0 m − i
=
m
m X 1
n j=m−n+1 j
=
m
(Hm − Hm−n )
n
=
mit
Hk =
k
X
1
j=1
j
.
Für diese Zahl (k-te harmonische Zahl) hat man die Abschätzung
ln(k) ≤ Hk ≤ ln(k) + 1.
Damit erhält man
n−1
1
1X
n i=0 1 − i/m
≤
=
m
(ln(m) + 1 − ln(m − n))
n
m
m
m
+ .
ln
n
m−n
n
35
6
Bäume
Ziel: Entwerfe Datenstruktur zur Verwaltung einer Menge von Elementen mit Ordnung, die folgende
Operationen effizient unterstützt: insert, erase, find, minimum, maximum, succ, pred.
6.1
Binäre Suchbäume
Bei der Suche nach einem Element in einem Feld von geordneten Elementen kann man binäre Suche
anwenden. Dabei betrachtet man immer das mittlere Element. Je nachdem, ob das gesuchte Element
größer oder kleiner als das mittlere Element ist, sucht man in der linken oder in der rechten Hälfte weiter.
Beispiel: Wir betrachten binäre Suche für ein sortiertes Feld ganzer Zahlen. Gleichzeitig bauen wir
uns einen binären Baum, der die betrachtenden Zahlen enthält.
0
1
2
5
2
7
3
4
5
6
7
8
9
10
11
10
17
24
31
39
42
44
53
67
0
1
2
2
5
7
l
3
4
5
6
7
8
9
10
11
10
17
24
31
39
42
44
53
67
m
0
1
2
5
r
m
l
2
7
r
24
l
24
7
r
m
3
4
5
6
7
8
9
10
11
10
17
24
31
39
42
44
53
67
42
24
7
42
5
2
17
10
39
31
53
44
67
Jetzt betrachten wir nur den binären Baum. Hier können wir genauso wie bei der binären Suche nach
einem bestimmten Element suchen. Dabei fangen wir an der Wurzel des Baumes an. Ist das gesuchte
Element kleiner als das Element im betrachteten Knoten, so gehen wir nach links weiter. Ist es größer,
dann gehen wir nach rechts weiter. (Falls es gleich ist, haben wir es natürlich gefunden.) Enden wir an
36
einem Blatt, dann ist das Element nicht im Baum enthalten.
Man kann sich leicht davon überzeugen, daß die binäre Suche in einem sortierten Feld genau das
gleiche ist wie die Suche in einem entsprechenden binären Suchbaum.
1. Definition: Eine Instanz B des parametrisierten Datentyps bin_tree<T> stellt einen binären
Baum mit Elementen vom Typ T dar. Dieser Typ muß eine Ordnung besitzen. Alle Elemente im
linken Unterbaum eines Knotens sind kleiner, alle Elemente im rechten Unterbaum eines Knotens
sind größer als das Element im Knoten selbst. (Wir gehen stillschweigend davon aus, daß wir nur
verschiedene Elemente betrachten.)
Wir schreiben zur Abkürzung handle für einen Zeiger auf einen Knoten des Baumes. Das ist nicht
ganz korrekt, da der Knoten auch vom Typ T abhängt und wir somit handle<T> schreiben müßten.
2. Instanziierung:
Konstruiert einen leeren binären Suchbaum vom Typ
bin_tree<T>.
bin_tree<T> B;
3. Operationen:
Gibt das Minimum des Teilbaumes mit Wurzel v
zurück.
Gibt das Maximum des Teilbaumes mit Wurzel v
zurück.
Sucht das Element x im Baum und gibt es zurück.
Findet das kleinste Element das größer v ist.
Findet das größte Element das kleiner v ist.
Löscht den Teilbaum mit Wurzel v.
Fügt das Element x in die richtige Stelle im Baum
ein.
Löscht das Element v aus dem Baum.
handle minimum(handle v)
handle maximum(handle v)
handle find(T x)
handle succ(handle v)
handle pred(handle v)
void clear(handle v)
handle insert(T x)
void erase(handle v)
4. Implementierung: Für die Implementierung definieren wir uns zunächst einen Datentyp, der
einen Knoten des binären Baums darstellt.
/* Realisiert ein Element des binaeren Suchbaums */
template <class T>
class bin_tree_node
{
public:
bin_tree_node* parent;
bin_tree_node* left;
bin_tree_node* right;
T inf;
//
//
//
//
der
das
das
das
Vorgaenger
linke Kind
rechte Kind
Element selbst
};
Für einen Zeiger auf einen solchen Knoten schreiben wir kurz handle.
template <class T>
class bin_tree
{
typedef bin_tree_node<T>* handle;
handle root;
// die Wurzel des Baums
public:
37
// Konstruktor
bin_tree()
{
root = 0;
}
// loescht den Teilbaum mit Wurzel v
void clear(handle v)
{
if(v->left) clear(v->left);
if(v->right) clear(v->right);
delete v;
}
// Destruktor
~bin_tree()
{
clear(root);
}
// findet ein Element
handle find(T x)
{
handle v=root;
while(v!=0 && v->inf!=x)
{
if(x<v->inf) v = v->left;
else v = v->right;
}
return v;
}
handle succ(handle v) bestimmt das kleinste Element, das größer v->inf ist. Es gibt zwei Fälle,
wo dieses Element stehen kann. Falls v ein rechtes Kind hat, dann sind in diesem Teilbaum alle
Elemente größer v->inf. Das Minimum des rechten Teilbaums ist dann das gesuchte Element.
v
v
1. Fall
2. Fall
Falls v kein rechtes Kind hat, findet man das nächstgrößte Element folgendermaßen. Man geht von
v aus solange nach oben, wie man das rechte Kind des Elternknotens ist. Ist man das linke Kind
des Elternknotens, dann ist der Elternknoten der gesuchte Knoten.
38
Analog findet man das größte Element welches kleiner als v->inf ist.
// findet das naechstgroesste Element
handle succ(handle v)
{
if(v->right!=0) return minimum(v->right);
handle p = v->parent;
while(p!=0 && v==p->right)
{
v = p;
p = v->parent;
}
return p;
}
// fuegt ein Element ein
handle insert(T x)
{
// neuen Knoten erzeugen
handle u = new bin_tree_node<T>;
u->inf = x;
u->left = 0;
u->right = 0;
u->parent = 0;
// finde die Stelle, an die u eingefuegt wird
handle v = root;
handle p = 0;
while(v!=0)
{
p = v;
if(x < v->inf) v = v->left;
else v = v->right;
}
// fuege u ein
u->parent = p;
if(p==0) root = u; // falls u die Wurzel ist
else
// sonst
{
if(x < p->inf) p->left = u;
else p->right = u;
}
return u;
}
Beim Löschen eines Elementes gibt es drei verschiedene Fälle. Dabei sind die ersten beiden Fälle
ziemlich einfach. Wenn der Knoten v keine Kinder hat, kann man ihn einfach löschen. Hat er nur
ein Kind, so wird er gelöscht und das Kind an den Elternknoten von v angehängt. Beispiele für
diese Fälle sind:
39
v
1. Fall
v
2. Fall
Der dritte Fall ist komplizierter. Wenn der Knoten v zwei Kinder hat, können wir ihn nicht einfach
löschen, da wir die zwei Kinder nicht beide an den Elternknoten anhängen können. Aber wir kennen
in diesem Fall einen Knoten, der höchstens ein Kind hat: succ(v). Wir sind jetzt nämlich im ersten
Fall von succ(v). Dieser Knoten ist das Minimum des Teilbaums, dessen Wurzel das rechte Kind
von v ist. Wie wir bei minimum gesehen haben, hat succ(v) in diesem Fall höchstens ein Kind.
Der Knoten succ(v) könnte also einfach wie in den ersten beiden Fällen gelöscht werden. Und
genau das tun wir auch. Dazu kann man zunächst feststellen, daß man succ(v) an die Stelle von
v schreiben kann. Dann wird der alte Knoten succ(v) gelöscht.
// loescht ein Element aus dem Baum
void erase(handle v)
{
handle u,w;
if(v->left == 0 || v->right == 0) u = v; // Fall 1, Fall 2
else u = succ(v);
// Fall 3
// Suche einen Nachfolger, der nicht leer ist.
// Es existiert hoechstens einer.
if(u->left != 0) w = u->left;
else w = u->right;
// Loesche den Knoten.
if(w!=0) w->parent = u->parent;
if(u->parent==0) root = w; // die Wurzel wird geloescht
else
{
if(u == u->parent->left) u->parent->left = w;
else u->parent->right = w;
}
if(u != v)
{
u->left = v->left;
v->left->parent = u;
u->right = v->right;
v->right->parent = u;
40
u->parent = v->parent;
if(v->parent == 0) root = u;
else
{
if(v == v->parent->left) v->parent->left = u;
else v->parent->right = u;
}
}
delete v;
}
};
5. Laufzeit: Es seien n die Anzahl der Elemente und t die Tiefe des Baumes. Im günstigsten Fall ist
t = O(log n). Allerdings kann es auch passieren, daß t = n ist.
Bei allen Operationen wird der Baum höchstens einmal von oben nach unten oder von unten nach
oben durchlaufen. Damit haben alle Operationen eine Laufzeit von O(t).
Da die Laufzeit der Operationen von der Tiefe des Baumes abhängt, ist es wichtig, die Tiefe möglichst
gering zu halten. Bei n Elementen ist die minimale Tiefe eines binären Baumes log n. Das Ziel ist also,
einen binären Suchbaum der Tiefe O(log n) zu verwalten.
Eine Möglichkeit dazu erhält man durch Anwendung von Rotationen (s. 9. Übung). Eine zweite
Möglichkeit sind Rot-Schwarz Bäume, die wir in der Vorlesung nicht betrachten.
6.2
2-5-Bäume
In diesem Abschnitt betrachten wir Bäume, die anders strukturiert sind als binäre Suchbäume. Der
wesentliche Unterschied ist dabei, daß das Speichern der Daten blattorientiert erfolgt, d.h. alle Daten
stehen in den Blättern. In den Knoten, die keine Blätter sind, stehen Hinweise, mit denen man in einfacher
Weise auf bestimmte Daten zugreifen kann.
1. Definition: Eine Instanz B des parametrisierten Datentyps two_five_tree<T> stellt einen Baum
über einer Menge von Elementen vom Typ T dar. Auf dieser Menge existiert eine Ordnung. Die
Elemente werden blattorientiert, in geordneter Reihenfolge gespeichert.
Jeder Knoten, der kein Blatt ist, hat mindestens 2 aber höchstens 5 Kinder. Ein solcher Knoten
enthält als Information den Wert des Maximums des Teilbaums mit ihm selbst als Wurzel.
Wir schreiben zur Abkürzung handle für einen Zeiger auf einen Knoten des Baumes. Das ist nicht
ganz korrekt, da der Knoten auch vom Typ T abhängt und wir somit handle<T> schreiben müßten.
Beispiel:
47
15
3
6
10
31
15
17
22
47
23
41
25
31
39
47
Lemma 6.1 Für einen 2-5-Baum der Tiefe t mit n Blättern gilt
log5 n ≤ t ≤ log2 n.
Beweis: Da jeder Knoten höchstens 5 Kinder hat, gilt n ≤ 5t . Da jeder Knoten aber mindestens 2 Kinder
hat, gilt n ≥ 2t .
2. Instanziierung:
two_five_tree<T> B;
Konstruiert einen leeren 2-5-Baum vom Typ
two_five_tree<T>.
3. Operationen:
handle minimum(handle v)
handle maximum(handle v)
handle find(T x)
void clear(handle v)
handle insert(T x)
void erase(handle v)
Gibt das Minimum des Teilbaumes mit Wurzel v
zurück.
Gibt das Maximum des Teilbaumes mit Wurzel v
zurück.
Sucht das Element x im Baum und gibt es zurück.
Löscht den Teilbaum mit Wurzel v.
Fügt das Element x in die richtige Stelle im Baum
ein.
Löscht das Element v aus dem Baum.
4. Implementierung: Für die Implementierung definieren wir uns zunächst einen Datentyp, der
einen Knoten des 2-5-Baums darstellt. Die Kinder eines Knotens sind in einer Liste gespeichert.
Gleichzeitig weiß jeder Knoten, wieviele Kinder er hat. Der Zugriff auf ein bestimmtes Kind bleibt
trotz Liste konstant, da die Anzahl der Kinder (und damit die Liste) beschränkt ist. Man hätte
die Kinder auch in einem Feld der Länge 5 speichern können. Trotzdem müßte man sich dann die
Anzahl der Kinder merken (damit diese nicht unter 2 geht) und außerdem immer die Elemente
umkopieren, sobald ein Kind eingefügt oder gelöscht wird.
/* Realisiert ein Element des 2-5-Baums */
template <class T>
class two_five_tree_node
{
public:
two_five_tree_node* parent;
int anzahl_kinder;
list<two_five_tree_node*> kinder;
T inf;
//
//
//
//
der
die
die
das
Vorgaenger
Anzahl der Kinder
Kinder
Element selbst
};
Für einen Zeiger auf einen solchen Knoten schreiben wir kurz handle.
template <class T>
class two_five_tree
{
typedef two_five_tree_node<T>* handle;
handle root;
// die Wurzel des Baums
public:
// Konstruktor
two_five_tree()
42
{
root = 0;
}
// loescht den Teilbaum mit Wurzel v
void clear(handle v)
{
while(v && v->anzahl_kinder)
{
clear(((v->kinder).last())->inf);
v->anzahl_kinder--;
}
delete v;
}
// Destruktor
~two_five_tree()
{
clear(root);
}
// findet das Minimum
handle minimum(handle v)
{
while(v->anzahl_kinder) v = ((v->kinder).first())->inf;
return v;
}
// findet das Maximum
handle maximum(handle v)
{
while(v->anzahl_kinder) v = ((v->kinder).last())->inf;
return v;
}
// findet ein Element
handle find(T x)
{
handle v=root;
if(x>v->inf) return 0;
list_node<two_five_tree_node<T>* > * kind;
while(v->anzahl_kinder)
{
kind = (v->kinder).first();
while((kind->inf)->inf < x) kind = kind->next;
v = kind->inf;
}
if(v->inf != x) return 0;
return v;
}
Diese Operationen sind einfach zu implementieren, wenn man den Überblick über die pointer
behält...
43
Schwieriger wird es, wenn man an der Baumstruktur etwas ändert, d.h. einen Knoten einfügt oder
löscht. Das Problem dabei ist, die Invariante (jeder Knoten hat mindestens 2 und höchstens 5
Kinder) aufrechtzuerhalten.
Zuerst betrachten wir das Einfügen. Dabei wird erst ein neuer Knoten erzeugt, dann die Stelle
gefunden, an der der neue Knoten angehängt wird. Der neue Knoten kommt dann an die richtige
Stelle. Der Elternknoten des neuen Knotens erhält also ein Kind mehr. Sind es insgesamt noch ≤ 5
Kinder, ist alles in Ordnung. Sind es 6 Kinder, müssen wir den Baum noch verändern, damit die
Invariante aufrechterhalten bleibt.
In diesem Fall werden wir die 6 Kinder aufspalten. Dazu erzeugen wir einen neuen Elternknoten,
der auf der gleichen Ebene wie der Elternknoten mit 6 Kindern steht. Dieser erhält 3 der 6 Kinder.
// fuegt ein Element ein
handle insert(T x)
{
// neuen Knoten erzeugen
handle u = new two_five_tree_node<T>;
u->inf = x;
u->anzahl_kinder = 0;
// finde die Stelle, an die u eingefuegt wird
handle v=root;
if(!v) // u ist der erste Knoten des Baumes
{
v = new two_five_tree_node<T>;
v->inf = x;
v->anzahl_kinder = 1;
(v->kinder).insert((v->kinder).last(), u);
v->parent = 0;
u->parent = v;
root = v;
return u;
}
// es gibt bereits Knoten im Baum
list_node<two_five_tree_node<T>* > * kind;
if(x>v->inf) // x ist groesser als das Maximum
{
while(v->anzahl_kinder)
{
kind = (v->kinder).last();
v = kind->inf;
}
kind = kind->next;
}
else
{
while(v->anzahl_kinder)
{
kind = (v->kinder).first();
while((kind->inf)->inf < x) kind = kind->next;
v = kind->inf;
}
}
44
// u wird in die Liste von v->parent nach kind eingefuegt
handle p = v->parent;
(p->kinder).insert(kind, u);
p->anzahl_kinder++;
u->parent = p;
while(p && p->inf < x)
{
p->inf = x;
p = p->parent;
}
// aufspalten bei zuvielen Kindern
if((v->parent)->anzahl_kinder > 5) spalten(v->parent);
return u;
}
Spalten
A
B
C
D
E
F
A
B
C
D
E
F
Spaltet man die Kinder eines Knotens v auf, so erhält der Elternknoten von v ein Kind mehr. Es
kann dabei passieren, daß der Elternknoten dann 6 Kinder hat. Dann wird wieder aufgespalten.
Dies zieht sich solange durch den Baum nach oben, bis die Invariante wieder erfüllt ist.
Aufpassen muß man, wenn die Kinder der Wurzel aufgespalten werden. Dann wird eine neue Wurzel
mit 2 Kindern erzeugt. Dadurch erhöht sich die Tiefe des Baumes dann um 1.
// der Knoten v hat 6 Kinder -> aufspalten
void spalten(handle v)
{
// erzeuge neuen Knoten
handle w = new two_five_tree_node<T>;
w->inf = v->inf;
w->anzahl_kinder = 3;
v->anzahl_kinder = 3;
// und die Listen spalten
list_node<two_five_tree_node<T>* > * kind;
kind = (v->kinder).first();
kind = kind->next;
kind = kind->next; // das 3. Listenelement
v->inf = (kind->inf)->inf;
(w->kinder).splice((w->kinder).last(), v->kinder,
kind->next, (v->kinder).last());
kind = (w->kinder).first();
(kind->inf)->parent = w;
kind = kind->next;
(kind->inf)->parent = w;
kind = kind->next;
45
(kind->inf)->parent = w;
// was passiert, wenn v die Wurzel ist? -> neue Wurzel
if(v==root)
{
// neue Wurzel erzeugen
root = new two_five_tree_node<T>;
root->anzahl_kinder = 2;
root->inf = w->inf;
v->parent = root;
w->parent = root;
(root->kinder).insert((root->kinder).last(), v);
(root->kinder).insert(((root->kinder).last())->next, w);
}
else
{
// wird an den Elternknoten von v angehaengt
handle p = v->parent;
w->parent = p;
p->anzahl_kinder++;
(p->kinder).insert(((p->kinder).last())->next, w);
// evtl. weiter aufspalten
if(p->anzahl_kinder == 6) spalten(p);
}
}
Beim Löschen wird zunächst das entsprechende Blatt gelöscht. Man darf dann nicht vergessen, evtl.
die Knoteneinträge auf dem Weg von der Wurzel zu dem gelöschten Blatt zu ändern.
Ein Problem tritt hier auf, wenn der Elternknoten des zu löschenden Knotens jetzt nur noch ein
Kind hat. In diesem Fall hat man zwei Möglichkeiten. Der Elternknoten hat auf jeden Fall einen
Geschwisterknoten (da jeder Knoten mindestens 2 Kinder hat). Hat der Knoten neben dem Elternknoten mindestens 3 Kinder, so kann man eines dieser Kinder verschieben (stehlen). Hat der
Knoten neben dem Elternknoten nur 2 Kinder, dann wird einer der beiden Elternknoten gelöscht
und der andere erhält die 3 Kinder.
// loescht ein Element aus dem Baum
void erase(handle v)
{
// Blatt v loeschen
handle p = v->parent;
p->anzahl_kinder--;
list_node<two_five_tree_node<T>* > * pos;
pos = (p->kinder).find(v);
(p->kinder).erase(pos);
// evtl. Knoteneintraege aendern
if(v->inf == p->inf)
{
p->inf = (((p->kinder).last())->inf)->inf;
handle u = p->parent;
while(u && v->inf == u->inf)
{
u->inf = p->inf;
46
u = u->parent;
}
}
// v loeschen
delete v;
// evtl. Baum umbauen
if(p->anzahl_kinder == 1)
{
// Nachbar von p suchen
pos = ((p->parent)->kinder).find(p);
handle n = (pos->next)->inf;
if(n->anzahl_kinder == 2) verschmelzen(p,n);
else stehlen(p,n); // hier: >2 Kinder
}
}
Achtung: Mir fällt gerade auf, daß in der Implementierung ein Fehler ist. Wenn der Knoten, der
nur noch ein Kind hat, am Ende der Kinderliste steht, geht das Programm vermutlich schief. Man
sollte dann den Knoten, der in der Kinderliste vornedran steht, nehmen, statt den nächsten Knoten
(der der Listenkopf ist). Beim Stehlen/Verschmelzen muß man dann auch darauf achten, daß die
richtige Seite genommen wird (damit die Daten hinterher immer noch geordnet sind). Momentan
habe ich aber keine Lust/Zeit, diesen Fehler zu verbessern...
Wenn ein Knoten v ein Kind gestohlen hat, dann hat sich an der Anzahl der Kinder des Elternknotens von v nichts geändert. In diesem Fall ist die Invariante wiederhergestellt und man muß den
Baum nicht weiter rebalancieren.
Stehlen
A
B
C
D
A
B
C
D
// Knoten v hat 1 Kind, Nachbarknoten w >= 3 -> stehlen
void stehlen(handle v, handle w)
{
//Knoten stehlen
(v->kinder).splice(((v->kinder).last())->next, w->kinder,
(w->kinder).first(), (w->kinder).first());
// der gestohlene Knoten
handle k = ((v->kinder).last())->inf;
k->parent = v;
v->inf = k->inf;
v->anzahl_kinder++;
w->anzahl_kinder--;
}
Verschmelzt man einen Knoten v mit einem Nachbarknoten, dann wird dem Elternknoten von v
ein Kind abgezogen. Hier muß man aufpassen, ob der Elternknten dann auch nur noch ein Kind
47
hat. Falls ja, muß man eine Ebene höher stehlen oder verschmelzen.
Verschmelzen
A
B
C
A
B
C
Ich glaube, ich habe hier auch wieder einen Fehler gemacht. Was passiert, wenn die Wurzel plötzlich
nur noch ein Kind hat? Die Programme behandeln es jedenfalls nicht, wie es aussieht. Meine einzige
Entschuldigung dafür: Wir sind kein Programmierkurs...
// Knoten v hat 1 Kind, Nachbarknoten w 2 Kinder -> verschmelzen
void verschmelzen(handle v, handle w)
{
// Liste verschmelzen
(w->kinder).splice((w->kinder).first(), v->kinder,
(v->kinder).first(), (v->kinder).first());
(((w->kinder).first())->inf)->parent = w;
w->anzahl_kinder++;
// Knoten v loeschen
handle p = v->parent;
p->anzahl_kinder--;
list_node<two_five_tree_node<T>* > * pos;
pos = (p->kinder).find(v);
(p->kinder).erase(pos);
delete v;
// evtl. noch umbauen
if(p->anzahl_kinder == 1)
{
// Nachbar von p suchen
pos = ((p->parent)->kinder).find(p);
handle n = (pos->next)->inf;
if(n->anzahl_kinder == 2) verschmelzen(p,n);
else stehlen(p,n); // hier: >2 Kinder
}
}
};
5. Laufzeit: Zuerst zu den einfachen Laufzeitabschätzungen. Es seien n Elemente im Baum gespeichert. Nach dem Lemma hat der Baum dann eine Tiefe von t = Θ(log n). Die Operationen
minimum, maximum und find benötigen Zeit Θ(log n), da sie nur einmal von der Wurzel aus durch
den Baum durchgehen. Dabei ist zu beachten, daß der Zugriff auf ein beliebiges Kind eines Knotens
in konstanter Zeit erfolgt, da man höchstens 5 Listenelemente durchprobieren muß.
Bei insert wird auch zunächst der Baum durchlaufen (Zeit Θ(log n)), dann das Element eingefügt
(Zeit Θ(1)). Evtl. muß man die Einträge auf dem Weg noch mal ändern (Zeit O(log n)). Danach muß
der Baum rebalanciert werden. Dabei wird evtl. die Methode spalten aufgerufen. Ein Aufspalten
alleine geht in konstanter Zeit, allerdings kann es passieren, daß wir das Aufspalten bis zur Wurzel
fortführen müssen. Damit brauchen wir insgesamt für ein insert im schlechtesten Fall Zeit O(log n).
48
Bei erase wird der Knoten in konstanter Zeit gelöscht und dann evtl. der Baum einmal von unten nach oben durchlaufen (Zeit O(log n)). Danach werden die Rebalancierungsmaßnahmen durchgeführt. stehlen geht in konstanter Zeit, ebenso wie ein einzelner Aufruf von verschmelzen. Da
verschmelzen allerdings im schlechtesten Fall den ganzen Baum durchlaufen kann, hat man auch
bei erase eine Laufzeit von O(log n).
6.3
Amortisierte Analyse für 2-5-Bäume
Die 2-5-Bäume haben die schöne Eigenschaft, daß die Tiefe immer in Θ(log n) bleibt, wenn n die Anzahl
der Blätter ist. Dabei stören allerdings die Rebalancierungsmaßnahmen etwas, die doch im schlechtesten
Fall ziemlich viel Zeit verbrauchen.
Wir werden jetzt zeigen, daß die Rebalancierung von 2-5-Bäumen in amortisiert konstanter Zeit läuft.
Genauer gesagt:
Satz 6.1 Startet man mit einem leeren 2-5-Baum und macht n insert- und erase-Operationen, so ist
die Gesamtzahl der Rebalancierungsoperationen höchstens 2n.
Um das zu beweisen, beschäftigen wir uns erst einmal näher mit amortisierter Analyse.
Was ist amortisierte Analyse? Mit Hilfe von amortisierter Analyse kann man berechnen, wieviel Zeit
eine Folge von Operationen, die im Einzelfall unterschiedlich viel Zeit kosten, als Gesamtfolge kosten. Im
Gegensatz zur Average-Case Analyse wird hier keine Wahrscheinlichkeit berechnet, sondern ein genauer
Wert.
6.3.1
Gesamtheitsmethode
Es gibt verschiedene Möglichkeiten, amortisierte Analyse durchzuführen. Als erstes betrachten wir die
Gesamtheitsmethode, die wir bereits angewendet haben. Dabei wird für n Operationen die Gesamtlaufzeit T (n) bestimmt. Man sagt dann, daß die Laufzeit für eine Operation amortisiert T (n)/n ist.
Es gibt keine generelle Methode, um die Gesamtlaufzeit zu bestimmen. Man muß sich dabei meistens
irgendwelche Tricks einfallen lassen. Eine solche Analyse haben wir bereits durchgeführt.
Bei unbeschränkten Feldern (u_arrays) gab es Feldzugriffe mit unterschiedlichen Ausführungszeiten
Tj . Unter der Annahme, daß auf einen konstanten Bruchteil der Feldelemente wirklich zugegriffen wird,
n
P
Tj = O(n) ist. Daher ist die
konnten wir zeigen, daß bei n Zugriffen die Gesamtausführungszeit T =
j=1
Laufzeit amortisiert konstant.
Um das zu zeigen, haben wir die Feldzugriffe, die mehr Zeit kosten, genauer angesehen. Wir konnten
diese addieren (ohne zu wissen, wann sie auftreten) und damit die Gesamtsumme ausrechnen.
6.3.2
Bankkontomethode
Eine andere Möglichkeit, amortisierte Analyse durchzuführen, ist die Bankkontomethode: Man zahlt
bei gewissen Operationen (z.B. Eingabe, Löschen) eine gewisse Anzahl von RE (Recheneinheiten) ein.
Dabei bezahlt man mehr, als die einzelne Operation eigentlich kostet. Wenn dann noch andere Operationen ausgeführt werden, bei denen man nichts einzahlt (oder nichts einzahlen kann), werden deren Kosten
durch die überschüssigen RE’s gedeckt. (Bei dieser Methode ist auch der Name ’amortisiert’ sinnvoll.)
Als einfaches Beispiel kann man die Realisierung einer Warteschlange (queue) mit Hilfe von zwei
stacks auf dem 5. Übungsblatt sehen. Dabei werden die Elemente beim Einfügen zunächst auf den
ersten Keller getan. Will man Elemente löschen, so werden sie aus dem zweiten Keller entnommen. Falls
der zweite Keller leer ist, werden die Elemente aus dem ersten Keller in den zweiten Keller umkopiert.
Nehmen wir an, ein Anfassen eines Elementes kostet 1RE. Ein Element wird in dieser Warteschlange
viermal angefaßt: Einfügen in den ersten Keller, rausholen aus dem ersten Keller, einfügen in den zweiten
Keller, rausholen aus dem zweiten Keller. Damit hatten wir damals argumentiert, daß wir für solche
Warteschlangen amortisiert konstante Laufzeit haben. Jetzt wollen wir die Bankkontomethode anwenden.
Beim Einfügen und beim Löschen eines Elementes bräuchte man jeweils nur 1RE. Allerdings muß
auch noch das Umkopieren vom ersten in den zweiten Keller mit 2RE bezahlt werden. Es wäre sinnvoll,
wenn jedes Element, das im ersten Keller ist, 2RE besitzt.
49
Beim Einfügen eines Elementes könnte man jedem Element auch 3RE mitgeben. Davon wird eine für
das Einfügen verbraucht und zwei für das Umkopieren behalten. Das Löschen des Elementes bezahlen
wir mit 1RE. Damit sieht man sofort, daß man (amortisiert) konstante Laufzeit hat: Für jede Operation
werden nur konstant viele RE’s verbraucht.
Jetzt wollen wir die Bankkontomethode für die amortisierte Analyse bei 2-5-Bäumen anwenden. Dazu müssen wir uns erst einmal überlegen, wieviel die einzelnen Rebalancierungsoperationen tatsächlich
kosten. Da eine einzelne Operation spalten, stehlen oder verschmelzen jeweils nur konstante Laufzeit
braucht, setzen wir die Kosten auf eine einzelne Rebalancierungsoperation auf 1RE.
Wieviele RE sollten den verschiedenen Knoten zur Verfügung stehen? Dazu überlegen wir uns, wie
sich die einzelnen Knoten unterscheiden. Die Blätter werden nur eingefügt oder gelöscht. Rebalancierungsmaßnahmen betreffen sie nicht, sondern nur die Knoten im Innern des Baumes. Die Knoten im
Innern haben 1 bis 6 Kinder (vor der Rebalancierung). Hat ein solcher Knoten 1 oder 6 Kinder, so muß
eine Rebalancierungsmaßnahme durchgeführt werden. Hat er 2 oder 5 Kinder, dann ist es ein gefährdeter
Knoten, bei dem vermutlich demnächst eine Rebalancierungsmaßnahme durchgeführt werden muß. Ein
Knoten mit 3 oder 4 Kindern wird in der nächsten Zeit nicht weiter betrachtet.
Mit dieser Überlegung können wir die folgende Invariante formulieren: Den inneren Knoten des 2-5Baumes stehen unbenutzte RE gemäß der folgenden Tabelle zur Verfügung.
Anzahl Kinder 1 2 3 4 5 6
RE
3 1 0 0 1 3
Um zu sehen, daß diese Invariante sinnvoll ist, sehen wir uns die einzelnen Rebalancierungsmaßnahmen
genauer an.
spalten: Ein Knoten v hat 6 Kinder, die aufgespalten werden. Nach der Invariante hat der Knoten
3RE zur Verfügung. Sein Vaterknoten p hat xRE zur Verfügung. Durch das Aufspalten wird 1RE
verbraucht, v hat danach noch 2RE. Die gibt er an seinen Vater weiter, da er keine mehr braucht (er
hat ja jetzt nur noch 3 Kinder). Der Vaterknoten p bekommt ein Kind mehr und hat jetzt x + 2RE.
Hatte p vorher 2, 3 oder 4 Kinder, so hat er mehr RE als notwendig (was ja nicht weiter schlimm
ist). Weiteres Aufspalten ist dann nicht nötig. Hatte er vorher 5 Kinder, so hat er jetzt 6 Kinder
und 3RE, d.h. er ist in der gleichen Ausgangssituation wie v.
stehlen: Ein Knoten v hat nur 1 Kind. Der Nachbarknoten w von v hat mindestens 3 Kinder. v
hat 3RE und w hat xRE (x = 0 oder x = 1). Das Stehlen verbraucht 1RE von v. Nach dem Stehlen
hat v zwei Kinder, sollte also nach der Invariante mindestens 1RE übrigbehalten. Die dritte RE
wird von v an w übergeben. Falls w nämlich nur 3 Kinder hatte, hat es jetzt nur noch zwei Kinder
und braucht dann diese RE. Ansonsten hat w eine RE zu viel.
verschmelzen: Ein Knoten v hat nur ein Kind, der Nachbarknoten w hat nur zwei Kinder. Nach
der Invariante hat v 3RE und w 1RE. Verschmelzen benötigt 1RE. Der Knoten, zu dem v und
w verschmolzen wird, hat dann noch 3RE übrig, braucht aber keine (da er drei Kinder hat). Er
übergibt die 3RE an seinen Vaterknoten, der ja ein Kind verloren hat. Hatte der Vaterknoten vorher
xRE, so hat er jetzt x+3RE. Insbesondere hat er mindestens 4RE, falls er vorher nur zwei und jetzt
nur noch ein Kind hat, die Rebalancierungsmaßnahmen können also weiter fortgeführt werden.
Wir sehen also, daß wir mit der obigen Invariante die Rebalancierungsmaßnahmen immer bezahlen
können. Jetzt zeigen wir, daß es genügt, jedem insert oder erase eines Blattes 2RE mitzugeben, um
die Invariante aufrechtzuerhalten. Dabei nehmen wir an, daß Hinzufügen und Löschen eines Blattes
jeweils 1RE kosten. Die andere RE, die wir mitgegeben haben, wird dabei dem Vaterknoten des Blattes
übergeben. Man überlegt sich sofort, daß man diesem Knoten damit mindestens so viele RE gibt, wie in
der Invariante gefordert. (Dabei lassen wir den trivialen Fall, daß wir einen Baum mit nur einem Blatt
haben, einfach weg...)
Damit können wir den Satz beweisen. Bei n insert- und erase-Operationen werden insgesamt 2nRE
eingezahlt. Wir haben uns davon überzeugt, daß die Invariante damit erfüllt ist. Weiter haben wir gesehen,
daß bei erfüllter Invariante soviele Rebalancierungsoperationen durchgeführt werden können, wie notwendig sind. Da jede Rebalancierungsmaßnahme 1RE verbraucht, können höchstens 2n solche Operationen
durchgeführt werden.
50
6.3.3
Potentialmethode
Eine dritte Methode, amortisierte Analyse durchzuführen, ist die Potentialmethode. Bei der Bankkontomethode wurde jedem Element bei bestimmten Operationen eine gewisse Anzahl an RE gegeben, die
dieses Element dann für Operationen, die mit ihm ausgeführt wurden, verbrauchen konnte. Im Unterschied
dazu wird bei der Potentialmethode jede unverbrauchte RE der gesamten Datenstruktur gutgeschrieben.
Diese können dann bei beliebigen Operationen verbraucht werden.
Dazu sei D0 die Datenstruktur vor Beginn der Operationen. Für i = 1, 2, . . . , n seien ci die wirklichen
Kosten der i-ten Operation und Di sei die Datenstruktur, die entsteht, wenn man auf Di−1 die i-te
Operation anwendet. Man definiert eine Potentialfunktion Φ : {D0 , . . . , Dn } → R≥0 mit Φ(D0 ) = 0
und Φ(Di ) ≥ 0 für alle i. Dann kann man die amortisierten Kosten der i-ten Operation (bzgl. der
definierten Potentialfunktion) definieren als
ĉi = ci + Φ(Di ) − Φ(Di−1 ).
Die amortisierten Kosten sind höher als die tatsächlichen Kosten, falls die Differenz Φ(Di ) − Φ(Di−1 ) > 0
ist. Ist die Differenz < 0, so wird ein Teil der anfallenden Kosten aus dem Potential bezahlt.
Bei der Definition der Potentialfunktion sollte man darauf achten, daß die amortisierten Kosten niemals < 0 werden, da das keinen Sinn macht.
Die amortisierten Gesamtkosten für alle n Operationen sind dann
n
X
i=1
ĉi =
n
X
i=0
(ci + Φ(Di ) − Φ(Di−1 ) =
n
X
ci + Φ(Dn ).
i=0
Als Beispiel betrachten wir wieder die amortisierte Analyse der queue. Wir definieren
Φ(Di ) = 2· aktuelle Anzahl der Elemente im ersten Keller.
Da diese nie negativ wird, sind die Voraussetzungen an die Potentialfunktion erfüllt. Dann können wir
die amortisierten Kosten der einzelnen Operationen berechnen.
Für eine push-Operation erhalten wir die amortisierten Kosten
ĉi = ci + Φ(Di ) − Φ(Di−1 ) = 1 + 2 = 3,
da nach dieser Operation die queue ein Element mehr als vorher (und zwar im ersten Keller) besitzt. Für
eine pop-Operation erhalten wir, falls der zweite Keller nicht leer ist, die amortisierten Kosten
ĉi = ci + Φ(Di ) − Φ(Di−1 ) = 1 − 0 = 1.
Es ist zwar ein Element weniger in der queue als vorher, aber dieses Element wurde aus dem zweiten
Keller entnommen. Im ersten Keller ändert sich die Elementanzahl nicht.
Falls der zweite Keller leer ist, müssen wir alle Elemente aus dem ersten Keller in den zweiten Keller
umkopieren. Seien dies k Elemente. Die Differenz der Potentialfunktion ist dann Φ(Di )−Φ(Di−1 ) = −2k.
Als tatsächliche Kosten erhalten wir 2k + 1, denn wir haben 2k Kosten, um die k Elemente umzukopieren
und müssen dann noch ein Element aus der queue entfernen. Damit erhalten wir die amortisierten Kosten
ĉi = ci + Φ(Di ) − Φ(Di−1 ) = 2k + 1 − 2k = 1.
Man sieht, daß die amortisierten Kosten konstant sind.
51
7
Prioritätswarteschlangen
7.1
Spezifikation von Prioritätswarteschlangen
1. Definition: Eine Instanz Q des parametrisierten Datentyps pri_queue<T> stellt eine Menge von
Elementen vom Typ T dar. Die Elemente vom Typ T besitzen jeweils einen Schlüssel (key), mit
dem man eine Ordnung auf T definieren kann. In die Prioritätswarteschlange können die Elemente
beliebig eingefügt werden. Man kann den Schlüssel eines Elementes runtersetzen. Man kann aus der
Prioritätswarteschlange das Element mit dem kleinsten Schlüssel entfernen.
2. Instanziierung:
pri_queue<T> Q;
Konstruiert eine leere Prioritätswarteschlange vom
Typ pri_queue<T>.
3. Operationen:
handle minimum()
handle erase_min()
void decrease_key(T x,
key k_old,
key k_new)
handle insert(T x)
Gibt das Minimum der Prioritätswarteschlange
zurück.
Löscht das Minimum der Prioritätswarteschlange
und gibt es zurück.
Gibt dem Element x mit Schlüssel k_old den neuen
Schlüssel k_new.
Fügt das Element x in die Prioritätswarteschlange
ein.
Man kann Prioritätswarteschlangen natürlich auch umgekehrt definieren, so daß sie das Maximum (statt
dem Minimum) zurückliefern und entsprechend den Schlüssel erhöhen.
4. Implementierung: Zuerst definieren wir eine Klasse, die die Elemente mit ihrem Schlüssel verwaltet. Der Einfachheit halber sind Schlüssel hier vom Typ int.
template <class T>
class pri_queue_element
{
T inf;
// das Element selbst
int key; // der Schluessel
public:
// Default-Konstruktor macht nichts
pri_queue_element<T>()
{
}
// Konstruktor
pri_queue_element<T>(T x, int k)
{
inf = x;
key = k;
}
// decrease_key
void decrease_key(int k)
{
key = k;
}
52
// Ein pri_queue_element ist durch seinen Schluessel eindeutig bestimmt.
bool operator==(pri_queue_element x)
{
if(x.key == key) return true;
return false;
}
bool operator!=(pri_queue_element x)
{
if(x.key != key) return true;
return false;
}
bool operator>(pri_queue_element x)
{
if(key > x.key) return true;
return false;
}
bool operator<(pri_queue_element x)
{
if(key < x.key) return true;
return false;
}
bool operator>=(pri_queue_element x)
{
if(key >= x.key) return true;
return false;
}
bool operator<=(pri_queue_element x)
{
if(key <= x.key) return true;
return false;
}
};
Mit Hilfe dieser Klasse können wir jetzt die Prioritätswarteschlange implementieren. In der Vorlesung betrachten wir dazu zwei verschiedene Möglichkeiten.
7.2
Implementierung von Prioritätswarteschlangen mit binären Heaps
Zuerst betrachten wir binäre Heaps. Diese haben wir bereits früher bei Heapsort kennengelernt.
Ein Heap wird dabei in ein Feld eingebettet, wobei man die Eigenschaft
A[i] ≤ A[2i + 1],
A[i] ≤ A[2i + 2]
hat, für alle i, für die A[i], A[2i+1], A[2i+2] definiert sind. Ein solcher Heap stellt natürlich einen binären
Baum dar, wobei die Kinderknoten eines Knotens immer größer als der Knoten selbst sind (oder gleich).
Die Kinderknoten des Knotens A[i] stehen dann in A[2i + 1] und A[2i + 2].
53
3
7
5
11
15
9
21
3
7
13
5
11
9
6
10
6
17
19
17
15
21
13
10
19
Wir betrachten jetzt kurz die Realisierung eines Heaps. Die Elemente werden wie oben beschrieben
in einem Feld gespeichert. Dadurch ist der entsprechende binäre Baum immer balanciert, d.h. er hat die
Tiefe t = blog nc bei n Elementen. Durch die Baumstruktur kann man ein Element in O(t) = O(log n)
Zeit finden.
Mit einer Operation heapify kann man den Heap wiederherstellen, wenn er an einer Stelle verletzt
ist, d.h. wenn ein Element größer als eines seiner Kinder ist. Dabei wird das Element mit einem der
Kinder vertauscht. Das wird solange gemacht, bis die Heapeigenschaft wiederhergestellt ist. Die Laufzeit
dafür ist also wieder O(t) = O(log n).
Für insert setzen wir das neue Element erst an die letzte Stelle des Arrays. Dann gehen wir im Baum
solange nach oben, wie der Elternknoten größer als der neue Knoten ist und vertauschen diese. Die Zeit
dafür ist ebenfalls O(t) = O(log n).
Genauso implementieren wir decrease_key. Dabei wird der Schlüssel eines Elementes (wonach die
Elemente ja geordnet sind) erniedrigt und die Heapeigenschaft ist nicht mehr erfüllt. Wie eben gehen wir
dann im Baum nach oben und vertauschen solange die Elemente, bis die Eigenschaft wieder erfüllt ist.
Zeit: O(t) = O(log n).
Das Minimum des Heaps findet man in Zeit O(1), es steht in der Wurzel (d.h. an der 0. Stelle im
Feld).
Die Funktion erase_min löscht das Minimum, indem einfach das letzte Element im Array an die Stelle
der Wurzel gesetzt wird. Damit die Heapeigenschaft erhalten bleibt, wird danach heapify aufgerufen.
Zeit: O(t) = O(log n).
Mit Hilfe dieses binären Heaps lassen sich Prioritätswarteschlangen sehr einfach realisieren. Wir verwenden dazu die Klasse pri_queue_element.
/* Realisiert Priorit"atswarteschlange mit binaeren Heaps */
template <class T>
class pri_queue_heap
{
bin_heap<pri_queue_element<T> >* schlange;
public:
// Konstruktor
pri_queue_heap(int n)
{
schlange = new bin_heap<pri_queue_element<T> >(n);
54
}
// findet das Minimum
pri_queue_element<T> minimum()
{
return schlange->minimum();
}
// loescht das Minimum und gibt es zurueck
pri_queue_element<T> erase_min()
{
return schlange->erase_min();
}
// Schluessel wird erniedrigt
void decrease_key(T x, int k_old, int k_new)
{
pri_queue_element<T> el(x,k_old);
pri_queue_element<T> el_old(x,k_old);
el.decrease_key(k_new);
schlange->decrease_key(el_old,el);
}
// fuegt ein Element ein
void insert(T x, int k)
{
pri_queue_element<T> el(x,k);
schlange->insert(el);
}
};
Die Laufzeiten für die Operationen der Prioritätswarteschlange sind die gleichen wie für den binären
Heap, d.h. minimum hat Laufzeit O(1), die anderen Operationen haben Laufzeit O(log n), wobei n die
Anzahl der Elemente in der Warteschlange ist.
7.3
Implementierung von Prioritätswarteschlangen mit Fibonacci Heaps
Ein Fibonacci Heap ist eine Liste von Wurzeln von Fibonacci Bäumen. Diese Bäume sind keine binären
Bäume, jeder Knoten kann beliebig viele Kinder haben. Die Kinder eines Knotens müssen immer größer
als der Knoten selbst sein.
Da die Struktur von Fibonacci Heaps relativ kompliziert ist, schauen wir uns die Implementierung
genauer an. Ein Knoten eines Fibonacci Baums ist ein normaler Baumknoten, der eine Kinderliste verwaltet.
template <class T>
class fib_node
{
public:
fib_node* parent;
int anzahl_kinder;
list<fib_node*> kinder;
T inf;
bool mark;
//
//
//
//
//
//
der Vorgaenger
die Anzahl der Kinder
die Kinder
das Element selbst
= true, falls der Knoten seit dem letzten
Mal, als er ein Kind geworden ist, ein Kind
55
// verloren hat.
...
};
Die Markierung brauchen wir später.
Ein Fibonacci Baum ist einfach ein Baum, dessen Knoten Fibonacci Knoten sind. In der Wurzel des
Baumes steht das Minimum. Die Kinder eines Knotens sind immer größer als der Knoten selbst.
3
10
5
13
17
15
8
9
19
/* Realisiert Fibonacci Baum */
template <class T>
class fib_tree
{
typedef fib_node<T>* handle;
handle root;
// die Wurzel des Baums
public:
// Konstruktor
fib_tree()
{
root = 0;
}
fib_tree(handle v)
{
root = v;
}
// loescht den Teilbaum mit Wurzel v
void clear(handle v)
{
while(v && v->anzahl_kinder)
{
clear(((v->kinder).last())->inf);
v->anzahl_kinder--;
}
delete v;
56
}
// Destruktor
~fib_tree()
{
clear(root);
}
// gibt die Wurzel zurueck
handle wurzel()
{
return root;
}
// findet das Minimum
handle minimum(handle v)
{
// das Minimum ist der Wurzelknoten
return v;
}
Um das Minimum des Fibonacci Baums zu bestimmen, braucht man Zeit O(1).
Wenn wir nach einem Element suchen, sehen wir nach, ob es kleiner als die Wurzel ist (und somit
nicht in diesem Teilbaum). Falls nicht, kann es die Wurzel selbst sein. Ansonsten suchen wir das Element
rekursiv in allen Teilbäumen der Kinder der Wurzel. Bei n Elementen braucht man hierfür Zeit O(n), da
im schlechtesten Fall alle Elemente angesehen werden müssen.
// findet ein Element im Teilbaum mit Wurzel v
handle find(T x, handle v)
{
if(!v || x<v->inf) return 0;
if(x==v->inf) return v;
if(!v->anzahl_kinder) return 0;
list_node<fib_node<T>* > * kind = (v->kinder).first();
handle pos = 0;
for(int i=1; i <= v->anzahl_kinder; i++)
{
pos = find(x, kind->inf);
if(pos) return pos;
kind = kind->next;
}
return 0;
}
handle find(T x)
{
return find(x, root);
}
Wir betrachten zwei verschiedene Einfügeoperationen. Bei der ersten Operation wird ein Knoten einfach
als neues Kind der Wurzel eingefügt. Dabei gehen wir davon aus, daß der Knoten dort an der richtigen
Stelle steht, d.h. daß er größer als der Wurzelknoten ist. Weiter kann der Knoten, der eingefügt wird,
auch wieder Kinder haben, die dann mit eingefügt werden, so daß hier ein ganzer Teilbaum an die Wurzel
gehängt wird.
57
Bei der zweiten Einfügeoperation wird ein neues Element in den Baum eingefügt. Dabei machen wir
es uns einfach: Wir hängen das neue Element einfach an die Kinderliste der Wurzel an. Das geht gut,
falls schon mindestens ein Element im Baum ist und falls das neue Element größer als die Wurzel ist.
Ist das neue Element kleiner als die Wurzel, dann ist es das neue Element das neue Minimum und
sollte dann in der Wurzel stehen. In diesem Fall wird die alte Wurzel das einzige Kind der neuen Wurzel.
Die Tiefe des Baumes erhöht sich um 1.
Für beide insert braucht nur konstante Laufzeit O(1).
// fuegt einen Knoten als Kind der Wurzel ein
handle insert(handle v)
{
v->parent = root;
v->mark = false; // v ist gerade Kind geworden
root->anzahl_kinder++;
(root->kinder).insert((root->kinder).last(), v);
return v;
}
// fuegt ein Element ein
handle insert(T x)
{
// neuen Knoten erzeugen
handle u = new fib_node<T>;
u->inf = x;
u->anzahl_kinder = 0;
u->mark = false; // u wird Wurzel oder Kind
// finde die Stelle, an die u eingefuegt wird
handle v=root;
if(!v) // u ist der erste Knoten des Baumes
{
u->parent = 0;
root = u;
return u;
}
// es gibt bereits Knoten im Baum
if(v->inf > x) // u wird die neue Wurzel
{
u->parent = 0;
root = u;
// v wird das einzige Kind von u
u->anzahl_kinder = 1;
(u->kinder).insert((u->kinder).last(), v);
v->parent = u;
return u;
}
// u wird ein Kind von der Wurzel
u->parent = v;
v->anzahl_kinder++;
(v->kinder).insert((v->kinder).last(), u);
return u;
}
58
Die nächste Operation erase_tree brauchen wir für später. Hier wird ein Unterbaum aus einem Baum
herausgeschnitten. Dabei wird kein Knoten gelöscht, nur Zeiger verbogen. Die Laufzeit dafür ist O(n),
da man den Knoten in der Kinderliste erst noch suchen muß.
// schneidet einen Knoten samt Unterbaum aus dem Baum
// ohne den Unterbaum selbst zu loeschen
void erase_tree(handle v)
{
// v loeschen
handle p = v->parent;
p->anzahl_kinder--;
p->mark = true; // p verliert ein Kind, evtl. noch mehr dazu.
list_node<fib_node<T>* > * pos;
pos = (p->kinder).find(v);
(p->kinder).erase(pos);
}
Wenn wir tatsächlich ein Element löschen wollen, löschen wir diesen Knoten aus der Kinderliste des
Elternknotens und fügen die Kinder des gelöschten Knotens (mitsamt Unterbäumen) in die Kinderliste
des Elternknotens ein.
// loescht ein Element aus dem Baum
// das Element darf nicht die Wurzel sein
void erase(handle v)
{
// v loeschen
handle p = v->parent;
p->anzahl_kinder--;
p->mark = true; // p verliert ein Kind, evtl. noch mehr dazu.
list_node<fib_node<T>* > * pos;
pos = (p->kinder).find(v);
(p->kinder).erase(pos);
// Kinder von v in die Kinderliste von p eintragen
if(v->anzahl_kinder)
{
list_node<fib_node<T>* > * kind = (v->kinder).first();
while(v->anzahl_kinder)
{
(kind->inf)->mark = false; // wird gerade Kind
(p->kinder).insert((p->kinder).last(), kind->inf);
p->anzahl_kinder++;
v->anzahl_kinder--;
kind = kind->next;
}
}
// v loeschen
delete v;
}
};
Jetzt kommen wir zu den Fibonacci Heaps, mit denen dann die Prioritätswarteschlangen implementiert
werden können. Ein Fibonacci Heap besteht aus einer doppelt verketteten Liste, in der die Wurzeln
von Fibonacci Bäumen verwaltet werden. Außerdem gibt es noch einen Zeiger auf das Minimum. Das
Minimum aller Elemente muß in einer Wurzel stehen, d.h. der Zeiger zeigt auf ein Element der Wurzelliste.
59
Minimum
10
Fib_tree
A
27
3
Fib_tree
B
7
42
Fib_tree
C
Fib_tree
D
template <class T>
class fib_heap
{
typedef fib_node<T>* handle;
list<fib_tree<T>* > * wurzelliste; // die Wurzelliste
list_node<fib_tree<T>* >* Minimum_node; // das Minimum
fib_tree<T>* Minimum;
// das Minimum
int max_anzahl_kinder;
// maximal auftretende Kinderzahl
// der einzelnen Wurzeln
// Macht aus v einen Baum mit Wurzel v
fib_tree<T>* make_baum(fib_node<T>* v)
{
fib_tree<T>* baum = new fib_tree<T>(v);
return baum;
}
// bestimmt die maximal auftretende Kinderzahl
int max_zahl()
{
int m = 0;
handle wurzel;
list_node<fib_tree<T>* >* baum_node = wurzelliste->first();
list_node<fib_tree<T>* >* kopf_node = baum_node->prev;
fib_tree<T>* baum = baum_node->inf;
while(baum_node!=kopf_node)
{
wurzel = baum->wurzel();
if(m < wurzel->anzahl_kinder) m = wurzel->anzahl_kinder;
baum_node = baum_node->next;
baum = baum_node->inf;
}
return m;
}
Falls das Minimum sich ändert, muß man es aktualisieren. Dazu geht man die Wurzelliste durch und
sucht das Minimum der Elemente in der Wurzelliste.
60
// aktualisiert das Minimum
void aktual_min()
{
list_node<fib_tree<T>* >* baum_node = wurzelliste->first();
list_node<fib_tree<T>* >* kopf_node = baum_node->prev;
fib_tree<T>* baum = baum_node->inf;
handle wurzel = baum->wurzel();
Minimum = baum;
Minimum_node = baum_node;
T min = wurzel->inf;
baum_node = baum_node->next;
baum = baum_node->inf;
while(baum_node!=kopf_node)
{
wurzel = baum->wurzel();
if(min > wurzel->inf)
{
min = wurzel->inf;
Minimum = baum;
Minimum_node = baum_node;
}
baum_node = baum_node->next;
baum = baum_node->inf;
}
}
Sucht man ein Element, so sucht man es in jedem einzelnen Baum in der Wurzelliste. Die Laufzeit
dafür ist also O(n) bei n Elementen im Heap. Wir brauchen zwei verschiedene find-Funktionen. Eine,
die einen Zeiger auf das gefundene Element zurückgibt, und eine, die einen Zeiger auf den Baum, in dem
das gefundene Element liegt, zurückgibt.
handle find(T x)
{
handle pos;
list_node<fib_tree<T>* >* baum_node = wurzelliste->first();
list_node<fib_tree<T>* >* kopf_node = baum_node->prev;
fib_tree<T>* baum = baum_node->inf;
while(baum_node!=kopf_node)
{
pos = baum->find(x);
if(pos) return pos;
baum_node = baum_node->next;
baum = baum_node->inf;
}
assert(baum_node!=kopf_node);
}
// Gibt Zeiger auf den Baum, in dem x ist, zurueck
fib_tree<T>* find_tree(T x)
{
handle pos;
list_node<fib_tree<T>* >* baum_node = wurzelliste->first();
list_node<fib_tree<T>* >* kopf_node = baum_node->prev;
fib_tree<T>* baum = baum_node->inf;
while(baum_node!=kopf_node)
61
{
pos = baum->find(x);
if(pos) return baum;
baum_node = baum_node->next;
baum = baum_node->inf;
}
assert(baum_node!=kopf_node);
}
public:
fib_heap()
{
wurzelliste = new list<fib_tree<T>* >;
Minimum = 0;
Minimum_node = 0;
max_anzahl_kinder = 0;
}
~fib_heap()
{
delete wurzelliste;
delete Minimum;
delete Minimum_node;
}
Das Minimum wird in konstanter Laufzeit O(1) gefunden, da ein Zeiger in der Wurzelliste direkt auf das
Minimum zeigt.
T minimum()
{
handle min = Minimum->wurzel();
return min->inf;
}
Bei insert machen wir aus dem neuen Element zuerst einen Fibonacci Baum, der nur aus diesem
Element besteht. Dieser Baum wird in die Wurzelliste eingetragen. Dann wird getestet, ob das neue
Element kleiner als das Minimum ist, und ggf. das Minimum aktualisiert. Dabei muß dann aber nicht
die Liste durchgegangen werden (wie oben), sondern nur der Zeiger auf den neuen Baum gesetzt werden.
Dadurch kann man in Zeit O(1) ein neues Element einfügen.
void insert(T x)
{
//erzeuge neuen Baum
fib_tree<T>* neu = new fib_tree<T>;
neu->insert(x);
// fuege ihn in die wurzelliste ein
list_node<fib_tree<T>* >* tmp;
tmp = wurzelliste->insert(wurzelliste->last(), neu);
// aktualisiere Minimum
if(Minimum==0 || x < (Minimum->wurzel())->inf)
{
Minimum = neu;
Minimum_node = tmp;
62
}
}
Jetzt betrachten wir die Operation decrease_key. Dabei haben wir drei verschiedene Schritte. Der erste
Schritt hat eigentlich nichts mit decrease_key zu tun: Hier wird das Element, das geändert werden soll,
gesucht. Dieser Schritt braucht Laufzeit O(n), wie wir oben bereits festgestellt haben. Wir hätten aber
auch eine andere Spezifikation nehmen können, bei der man das Element mitgeben muß, so daß dieser
Schritt wegfällt. Da wir nur an der Laufzeit von decrease_key interessiert sind, kümmern wir uns um
diesen ersten Schritt nicht mehr.
Im zweiten Schritt haben wir jetzt den Knoten v. Dieser wird aktualisiert (mit dem neuen Schlüssel).
Dadurch ist er eventuell größer als sein Vaterknoten. Wir nehmen den Teilbaum mit Wurzel v, schneiden
ihn aus dem Fibonacci Baum, in dem er drin liegt, raus, und setzen ihn als neuen Baum in die Wurzelliste
(natürlich nur, falls v nicht bereits Wurzel eines Fibonacci Baums ist und schon in der Wurzelliste steht).
Dann wird evtl. das Minimum aktualisiert, wie bei insert. Dieser Schritt, das eigentliche decrease_key,
braucht nur konstante Zeit O(1). (Wer genau in die Implementierung sieht, bemerkt, daß hier ein Fehler
vorliegt. In diesem Schritt wird der Baum gesucht, der v als Wurzelknoten hat. Das benötigt bei meiner
Implementierung O(n) Zeit. Man hätte es aber auch irgendwie anders machen können, daß jeder Knoten
z.B. weiß, zu welchem Baum er gehört, so daß man diesen Schritt wirklich in konstanter Zeit durchführen
kann.)
Dann kommt noch ein dritter Schritt, der den Rebalancierungsmaßnahmen bei 2-5-Bäumen entspricht.
Für diesen Schritt brauchen wir auch die Markierung der Knoten. Zur Erinnerung: Die Markierung war
true, wenn ein Knoten seit dem letzten Mal, als er ein Kind geworden ist, ein Kind verloren hat. Wir
wollen damit erreichen, daß ein Knoten nicht zu viele Kinder (genauer: höchstens 1 Kind) verliert, seit
er Kind eines anderen Knotens geworden ist.
In diesem dritten Schritt gehen wir von dem Vaterknoten von v (der ja gerade ein Kind verloren
hat) solange nach oben, wie die Markierung true ist (evtl. bis zur Wurzel). Wir entfernen jeweils den
aktuellen Knoten aus der Kinderliste seines Vaterknotens und tragen ihn (als neuen Baum, der nur aus
diesem Knoten besteht) in die Wurzelliste ein. Der Vaterknoten des aktuellen Knotens verliert dadurch
natürlich wieder ein Kind, so daß sich diese Ablöseoperation bis zur Wurzel fortsetzen kann. Eine einzelne
Ablöseoperation benötigt konstante Zeit O(1). Wir werden später zeigen, daß die Zahl der Ablöseoperationen amortisiert konstant ist, so daß man insgesamt für decrease_key amortisiert konstante Laufzeit
angeben kann.
// verlange, dass x_new.key < x_old.keY
void decrease_key(T x_old, T x_new)
{
// finde Element x_old
handle v = find(x_old);
v->inf = x_new;
// v ist in der Wurzelliste
if(v->parent==0)
{
//aktualisiere Minimum
if(x_new < (Minimum->wurzel())->inf) aktual_min();
}
else if(x_new < (v->parent)->inf)
{
// entfernte v aus der Kindliste von v->parent und
// fuege den Baum mit Wurzel v in die Wurzelliste ein und
// aktualisiere Minimum
handle p = v->parent;
fib_tree<T>* baum = find_tree(p->inf);
baum->erase_tree(v);
wurzelliste->insert(wurzelliste->last(), make_baum(v));
63
v->mark = false;
v->parent = 0;
if(x_new < (Minimum->wurzel())->inf) aktual_min();
// Markierung von v->parent: abarbeiten
handle u = p->parent;
while(u && p->mark)
{
// entfernte p aus der Kindliste von u und
// fuege den Baum mit Wurzel p in die Wurzelliste ein
baum->erase_tree(p);
wurzelliste->insert(wurzelliste->last(), make_baum(p));
p->mark = false;
p->parent = 0;
p = u;
u = p->parent;
}
if(u) p->mark = true;
// aktualisiere maximale Kinderzahl
max_anzahl_kinder = max_zahl();
}
}
Bei erase_min() wird zunächst das Minimum gelöscht (das man ja direkt findet). Dieses Minimum
war die Wurzel eines Fibonacci Baumes. Die Kinder des Minimums werden mitsamt ihren Unterbäumen in
die Wurzelliste eingetragen. Jetzt wäre man im Prinzip schon fast fertig: Man könnte einfach das Minimum
aktualisieren und aufhören. Aber zum Aktualisieren des Minimums muß man hier die gesamte Wurzelliste
durchgehen. Die Laufzeit für erase_min() wäre in diesem Fall O(Zahl der Kinder des Minimums + Zahl
der Knoten in der Wurzelliste).
Da die Laufzeit hier sowieso nicht mehr konstant ist, können wir auch noch einige Umbauaktionen
durchführen. Wenn man diese wegläßt, hätte man praktisch nur eine Liste von Elementen, was nicht
sonderlich effektiv ist. In diesem Schritt bauen wir also jetzt die Bäume zusammen.
Das Ziel ist, alle Wurzelknoten mit gleicher Kinderzahl zu verschmelzen, so daß alle verbleibenden
Knoten in der Wurzelliste unterschiedliche Kinderzahl haben. ’Verschmelzen’ bedeutet hier, daß die Wurzel, die größer ist, als neues Kind der kleineren Wurzel eingebaut wird.
6
17
k Kinder
k Kinder
6
verschmelzen
k+1 Kinder
17
Implementiert wird dieses Verschmelzen aller Knoten mit Hilfe eines Arrays. Man geht die Wurzelliste
durch und schaut sich an, wieviele Kinder die aktuelle Wurzel hat. Hat man vorher bereits Wurzeln mit
der entsprechenden Kinderzahl (d) gefunden, dann stehen diese im Array an der Stelle A[d]. Falls dort
eine Wurzel steht, verschmilzt man diese mit der aktuellen Wurzel (das Minimum der beiden wird die
aktuelle Wurzel, die andere wird ein Kind). Die aktuelle Wurzel hat jetzt d + 1 Kinder, man sieht bei
A[d + 1] nach, ob bereits eine solche Wurzel existiert. Dies wird immer weitergeführt, bis man keine
Wurzel im Array mehr findet. Dann wird die aktuelle Wurzel an die der Kinderzahl entsprechenden Stelle
im Array eingetragen.
64
In der Wurzelliste stehen am Ende nur noch Wurzeln mit unterschiedlicher Kinderzahl.
Ein Verschmelzen kosten konstante Zeit O(1). Nehmen wir an, m sei die maximal auftretende Kinderzahl. Dann brauchen wir für erase_min im worst case Laufzeit O(m+Zahl der Kinder des Minimums
+ Zahl der Knoten in der Wurzelliste).
void erase_min()
{
// Minimum loeschen und Kinder in die Wurzelliste einfuegen
handle min = Minimum->wurzel();
if(min->anzahl_kinder)
{
list_node<fib_node<T>* > * kind = (min->kinder).first();
while(min->anzahl_kinder)
{
(kind->inf)->mark = false; // wird in Wurzelliste eingetragen
wurzelliste->insert(wurzelliste->last(), make_baum(kind->inf));
kind = kind->next;
min->anzahl_kinder--;
}
max_anzahl_kinder = max_zahl();
}
wurzelliste->erase(Minimum_node);
// das komische Array A
int m = max_anzahl_kinder;
u_array<list_node<fib_tree<T>*>*> A(0);
for(int d=0; d<= m; d++) A.put(d, 0);
// forall v in der Wurzelliste
list_node<fib_tree<T>* >* baum_node = wurzelliste->first();
list_node<fib_tree<T>* >* kopf_node = baum_node->prev;
while(baum_node!=kopf_node)
{
list_node<fib_tree<T>* >* wurzel_node = baum_node;
baum_node = baum_node->next;
fib_tree<T>* wurzel_baum = wurzel_node->inf;
handle wurzel = wurzel_baum->wurzel();
int d = wurzel->anzahl_kinder;
list_node<fib_tree<T>* >* u_node = A[d];
while(u_node!=0)
{
fib_tree<T>* u_baum = u_node->inf;
handle u_wurzel = u_baum->wurzel();
A.put(d, 0);
if(u_wurzel->inf < wurzel->inf)
{
// loesche wurzel_baum aus der Wurzelliste
wurzelliste->erase(wurzel_node);
//fuege wurzel in die Kinderliste von u_wurzel ein
u_baum->insert(wurzel);
wurzel_node = u_node;
wurzel_baum = u_baum;
wurzel = u_wurzel;
65
}
else
{
// loesche u_baum aus der Wurzelliste
wurzelliste->erase(u_node);
//fuege u_wurzel in die Kinderliste von wurzel ein
wurzel_baum->insert(u_wurzel);
}
d++;
u_node = A[d];
}
A.put(d, wurzel_node);
}
// aktualisiere Minimum
aktual_min();
}
};
7.4
Amortisierte Analyse von Fibonacci Heaps
Die Fibonacci Folge ist folgendermaßen definiert:
f0 = 0,
f1 = 1,
, fn = fn−1 + fn−2 für n ≥ 2.
Wir haben gezeigt, daß für alle n ∈ N0 gilt
1
fn = √ (φn − φ̂n )
5
mit den Zahlen
√
1− 5
φ̂ =
.
2
√
1+ 5
,
φ=
2
Lemma 7.1 Sei v ein Knoten eines Fibonacci Heaps und seien u1 , u2 , . . . , uk die Kinder von v in der
Reihenfolge, in der sie (zuletzt) Kinder von v geworden sind. Dann hat u i mindestens i − 2 Kinder.
Beweis: Der Knoten ui ist Kind von v geworden, als v mindestens i − 1 Kinder hatte. Die Knoten
u0 , . . . , ui−1 sind vorher Kinder geworden, es könnten aber auch noch andere Kinder dagewesen sein, die
zwischendurch gelöscht wurden.
Zu diesem Einfügezeitpunkt hatte ui nach Konstruktion genauso viele Kinder wie v, also mindestens i−
1 Kinder. Danach kann der Knoten ui keine weiteren Kinder mehr bekommen haben (nach Konstruktion
bekommen nur Wurzelknoten Kinder). Er kann evtl. ein Kind verloren haben, aber höchstens eines. Also
ist die Anzahl der Kinder von ui mindestens i − 2.
Lemma 7.2 Sei sk die Anzahl der Knoten in den Unterbäumen eines Knotens v mit k Kindern (einschließlich v selbst). Dann gilt für k ≥ 2:
sk ≥ φk−2 .
Daher auch der Name Fibonacci. :-)
Beweis: Es ist s0 = 1 und s1 ≥ 2. Dabei ist s0 = 1 klar. Hat ein Knoten 1 Kind, so hat dieses Kind
keine negative Kinderanzahl. Also ist s1 ≥ 2.
Sei jetzt k ≥ 2. Wir betrachten einen Knoten v mit k Kindern. Die Kinder u1 , . . . , uk seien in der
gleichen Reihenfolge gegeben wie im letzten Lemma. Nach diesem Lemma wissen wir, daß u k mindestens
k − 2 Kinder hat. Also ist die Anzahl der Knoten im Unterbaum mit Wurzel uk mindestens sk−2 .
66
Die Anzahl der restlichen Knoten im Teilbaum mit v als Wurzel können wir auch abschätzen. Diese
ist nämlich mindestens sk−1 . Insgesamt folgt dann:
sk ≥ sk−1 + sk−2 .
Jetzt können wir mit Induktion die Abschätzung
sk ≥ f k
für alle k ∈ N0 beweisen.
k = 0: s0 = 1 ≥ f0 = 0.
k = 1: s1 ≥ 2 ≥ f1 = 1.
k > 1: sk ≥ sk−1 + sk−2 ≥ fk−1 + fk−2 = fk .
Dann schätzen wir wegen fk ≥ φk−2 (s. Übung) für k ≥ 2 ab:
sk ≥ fk ≥ φk−2 .
Korollar 7.1 Bei einem Fibonacci Heap mit n Elementen ist die maximale Kinderzahl der Wurzeln
m = O(log n).
Beweis: Nach dem Lemma ist die Anzahl der Elemente im Baum, dessen Wurzel m Kinder hat, sm ≥
φm−2 (für m ≥ 2). Andererseits ist n ≥ sm . Damit erhält man
n ≥ φm−2
⇔
m ≤ 2 + logφ n = O(log n).
Jetzt können wir die amortisierte Analyse für Fibonacci Heaps durchführen.
Satz 7.1 Für einen Fibonacci Heap mit n Elementen erhalten wir die folgenden amortisierten Kosten:
minimum:
O(1)
insert:
O(1)
decrease_key: O(1)
erase_min:
O(log n)
Beweis: Zur amortisierten Analyse verwenden wir die Potentialmethode. Dazu definieren wir die folgende
Potentialfunktion.
Φ(H) = w(H) + 2a(H),
wobei w(H) die Anzahl der Wurzeln in der Wurzelliste und a(H) die Anzahl der markierten Knoten im
Heap ist. Es ist klar, daß bei einem leeren Heap Φ(H) = 0 ist. Weiter ist Φ(H) ≥ 0 auch immer erfüllt.
Wir berechnen jetzt die amortisierten Kosten der einzelnen Operationen. Dazu benötigen wir die
genauen Kosten.
minimum hat die genauen Kosten ci = 1RE (konstante Kosten). Damit ergeben sich die amortisieren
Kosten
cˆi = ci + Φ(Hi ) − Φ(Hi−1 ) = 1RE,
da sich an der Struktur und somit auch an der Potentialfunktion nichts ändert.
insert hat die genauen Kosten ci = 1RE. Es ändert sich aber etwas an der Struktur: Die Wurzelliste
erhält ein Element mehr. Damit ist Φ(Hi ) − Φ(Hi−1 ) = 1RE. Wir erhalten als amortisierte Kosten
cˆi = ci + Φ(Hi ) − Φ(Hi−1 ) = 2RE.
67
decrease_key: Das eigentliche decrease_key hat konstante Kosten 1RE (s. Bemerkung bei der
Implementierung). Danach werden verschiedene Ablöseoperationen durchgeführt. Vom Vaterknoten
des betrachteten Knotens aus geht man solange nach oben, wie man auf markierte Knoten trifft,
schneidet diese aus und setzt sie in die Wurzelliste. Wir nehmen an, daß man auf diese Weise
k Knoten ablöst. Dann sind die genauen Kosten für diesen Schritt ci = (1 + k)RE, da jeder
Ablöseschritt konstante Kosten hat.
Die Anzahl der Wurzelelemente hat sich um k +1 erhöht (der betrachtete Knoten und die k Knoten,
die abgeschnitten wurden). Es ist also w(Hi ) − w(Hi−1 ) = k + 1.
Die Zahl der markierten Knoten hat sich auch geändert. Eventuell war der betrachtete Knoten
markiert, jetzt ist er es nicht mehr. Die k abgelösten Knoten sind nicht mehr markiert. Im letzten
Ablöseschritt wird aber eventuell ein Knoten markiert (falls er nicht eine Wurzel ist). Damit haben
wir a(Hi ) − a(Hi−1 ) ≤ −k + 1. Insgesamt folgt
Φ(Hi ) − Φ(Hi−1 ) ≤ k + 1 + 2(−k + 1) = (−k + 3)RE.
Für die amortisierten Kosten eines decrease_key erhält man dann
cˆi = ci + Φ(Hi ) − Φ(Hi−1 ) ≤ (k + 1) + (−k + 3) = 4RE.
erase_min: Bei erase_min wird zunächst das Minimum gelöscht und die Kinder des Minimums
als neue Wurzeln in die Wurzelliste gesetzt. Es sei m die maximale Kinderzahl der Wurzeln. Wir
haben gesehen, daß m = O(log n) ist. Damit sind die genauen Kosten für diesen Schritt O(1 + m) =
O(log n).
Danach werden Knoten mit gleicher Kinderzahl verschmolzen. Ein solches Verschmelzen kostet
konstante Zeit O(1). Wir nehmen an, wir würden k dieser Verschmelzeoperationen durchführen.
Zuletzt wird das aktuelle Minimum gesucht, wobei man einmal durch die Wurzelliste durchgehen muß. Die Wurzelliste hat am Ende w(Hi ) Elemente. Für diese Zahl können wir auch eine
Abschätzung angeben. Am Ende gibt es in der Wurzelliste nur Wurzeln mit unterschiedlicher Kinderzahl. Da m die maximale Kinderzahl ist, kann es höchstens m + 1 solche Wurzeln geben.
Insgesamt sind die genauen Kosten für ein erase_min also ci ≤ 1 + m + k + m + 1 = 2m + k + 2.
Es ist w(Hi−1 ) die Anzahl der Elemte in der Wurzelliste vor dem erase_min. Ein Element wird aus
der Wurzelliste entfernt, dafür kommen höchstens m Elemente dazu. Eine Verschmelzeoperation
entfernt ein Element aus der Wurzelliste. Insgesamt erhalten wir damit
w(Hi ) ≤ w(Hi−1 ) − 1 + m − k.
Es folgt
w(Hi ) − w(Hi−1 ) ≤ m − k − 1.
Es werden keine Knoten neu markiert. Im ersten Schritt, wenn die Kinder der gelöschten Wurzel
selbst zu Wurzeln werden, können aber bis zu m markierte Knoten wegfallen. Es folgt
a(Hi ) − a(Hi−1 ) ≤ 0.
Für die Potentialfunktion folgt damit
Φ(Hi ) − Φ(Hi−1 ) ≤ m − k − 1
und für die amortisierten Kosten
cˆi = ci + Φ(Hi ) − Φ(Hi−1 ) ≤ 2m + k + 2 + m − k − 1 = 3m + 1 = O(log n).
Wir vergleichen nochmal die Laufzeiten der beiden Implementierungen von Prioritätswarteschlangen,
die wir kennengelernt haben.
68
Kosten binäre Heaps
minimum
O(1)
insert
O(log n)
decrease_key O(log n)
erase_min
O(log n)
Dennoch sind Fibonacci Heaps meistens
binäre Heaps verwendet. Das liegt an
amortisierte Kosten Fibonacci Heaps
O(1)
O(1)
O(1)
O(log n)
nur von theoretischem Interesse. In der Praxis werden eher
• der Schwierigkeit, Fibonacci Heaps richtig zu implementieren (unter anderem sollten die Laufzeitabschätzungen auch stimmen),
• der großen Konstante, die in der O-Notation versteckt ist und für kleinere Zahlen schlechtere Ergebnisse liefert.
69
8
Union-Find
8.1
Spezifikation von Partitionen
Definition 8.1 Eine Partition einer Menge S ist eine Menge von paarweise disjunkten Teilmengen von
S, deren Vereinigung gerade S ist.
Beispiel: Es sei S = {1, 2, 3, 4, 5, 6, 7, 8, 9}. Dann ist z.B. die Menge
{{3, 6, 9}, {1}, {2, 4, 8}, {5, 7}}
eine Partition von S.
Mengen werden durch einen eindeutigen Namen repräsentiert. Dabei muß für je zwei Elemente aus
der gleichen Menge gelten: find(i)==find(j).
Wir werden verschiedene Implementierungen für Partition kennenlernen. Zur Vereinfachung nehmen
wir an, daß sowohl die Elemente als auch die Mengennamen vom Typ int sind.
1. Definition: Eine Instanz P des Datentyps Partition stellt eine Partition der Menge {1, 2, . . . , n}
von Elementen vom Typ int dar. Man kann Mengen vereinigen (d.h. die Partition vergröbern) und
zu einem gegebenen Element die Menge finden, in der dieses Element liegt.
2. Instanziierung:
Partition P(int n);
Konstruiert die Partition {{1}, {2}, . . . , {n}} der
Menge {1, 2, . . . , n}.
3. Operationen:
int union(int A, int B);
int find(int x);
8.2
Vereinigt die Mengen A und B und gibt den Namen
der neuen Menge zurück.
Gibt den Namen der Menge zurück, in der das Element x liegt.
Einfache Implementierungen von Partitionen
Eine einfache Implementierung erhält man zum Beispiel dadurch, daß man für jedes Element explizit den
Namen der Menge speichert. Dabei nehmen wir einfach als Mengennamen ein Element aus der Menge.
class PartitionSimple
{
int *name; // name[i] = Name der Menge, in der Element i liegt
int groesse;
public:
/* Konstruktor */
/* Erzeugt die Partition {{1},{2},...,{n}} */
PartitionSimple(int n)
{
groesse = n;
name = new int[groesse+1];
for(int i=1; i<=groesse; i++) name[i]=i;
}
/* Destruktor */
~PartitionSimple()
{
delete[] name;
70
}
/* gibt den Namen der Menge, in der i liegt, zurueck */
int find(int i)
{
assert(i>=1 && i<=groesse);
return name[i];
}
/* Vereinigt die Mengen A und B, neuer Name der Menge: A */
int Union(int A, int B)
{
for(int i=1; i<=groesse; i++)
{
if(name[i]==B) name[i]=A;
}
return A;
}
};
Laufzeiten (bei n Elementen):
Initialisierung Θ(n)
union
Θ(n)
find
Θ(1)
Führt man auf einer Partition einer Menge mit n Elementen n − 1 union-Operationen und m findOperationen durch, so hat man eine Laufzeit von Θ(m + n2 ).
Die schlechte Laufzeit bei union kommt daher, daß man bei jedem Aufruf alle Elemente der Menge
durchgehen muß. Eigentlich würde es aber reichen, die Elemente der Teilmenge B zu durchlaufen. Dazu
sollte es möglich sein, alle Elemente einer Teilmenge aufzulisten. Um das zu ermöglichen, erzeugen wir
noch zusätzlich eine Listenstruktur. Dabei zeigt ein Element einer Teilmenge immer auf ein anderes
Element der Teilmenge. Das letzte Element der Liste zeigt auf sich selbst. Als Mengennamen wählen wir
das erste Element der Liste.
class PartitionWithLists
{
int *name; // name[i] = Name der Menge, in der Element i liegt
int *next; // next[i] = Nachfolgeelement von i, falls i letztest
// Element der Liste ist: next[i]=i.
int groesse;
public:
/* Konstruktor */
/* Erzeugt die Partition {{1},{2},...,{n}} */
PartitionWithLists(int n)
{
groesse = n;
name = new int[groesse+1];
next = new int[groesse+1];
for(int i=1; i<=groesse; i++)
{
name[i]=i;
next[i]=i;
}
}
71
/* Destruktor */
~PartitionWithLists()
{
delete[] name;
delete[] next;
}
/* gibt den Namen der Menge, in der i liegt, zurueck */
int find(int i)
{
assert(i>=1 && i<=groesse);
return name[i];
}
/* Vereinigt die Mengen A und B, neuer Name der Menge: A */
int Union(int A, int B)
{
// Umbenennung aller Elemente in der Menge B
int anfangB=B;
name[B] = A;
while(next[B]!=B)
{
B = next[B];
name[B] = A;
}
// Fuege die 2. Liste hinter den Anfang der ersten Liste ein
if(next[A]!=A) next[B] = next[A];
next[A] = anfangB;
return A;
}
};
Laufzeiten (bei n Elementen):
Initialisierung Θ(n)
union
Θ(Größe beider Mengen)
find
Θ(1)
Führt man auf einer Partition einer Menge mit n Elementen n − 1 union-Operationen und m findOperationen durch, so hat man hier auch eine Laufzeit von Θ(m + n2 ).
Man hat hier zwar i.a. bei union eine kleinere Laufzeit als bei der ersten Implementierung, allerdings
kann die Laufzeit im schlechtesten Fall genauso schlecht sein.
Beispiel:
PartitionWithLists P(n);
P.union(2,1);
P.union(3,2);
...
P.union(n,n-1);
Hier wird im i-ten union die Menge i + 1 mit der Menge i vereinigt. Die Menge i + 1 hat dabei nur ein
Element, die Menge i hat i Elemente. Die Laufzeit für dieses Beispiel ist dann
n+
n−1
X
i=1
i=n+
(n − 1)n
= Θ(n2 ).
2
72
An dem Beispiel sieht man, daß es nicht zeiteffizient ist, wenn man immer die zweite Menge in die
erste Menge integriert. Besser wäre es, die kleinere der beiden Mengen umzubenennen. Die Idee für die
nächste Verbesserung ist also, bei der Vereinigung zweier Mengen die Elemente der kleineren Menge
umzubenennen.
Dazu müssen wir uns die Länge der Listen (die Größe der Mengen) speichern. Dies machen wir in
dem Array size. Dabei steht in size[i] die Größe der Menge mit Namen i. Hier kann es zu (kleineren)
Verwirrungen kommen: name und next haben als Indizes die Elemente selbst (also die Zahlen von 1 bis
n). Das Array size hat als Indizes die Namen der Mengen in der Partition. Zufällig haben wir uns darauf
geeinigt, daß diese Namen vom Typ int ist. Ebenso zufällig können in der angegebenen Implementierung
nur Namen von 1 bis n auftreten, so daß wir size genauso wie name und next als Feld der Zahlen von 1
bis n betrachten. Dennoch darf man nicht vergessen, daß diese Zahlen bei size eine andere Bedeutung
als bei name und next haben.
/* Realisiert Union-Find: Partition der Menge {1,...,n} */
class PartitionWithLists_2
{
int *name; // name[i] = Name der Menge, in der Element i liegt
int *next; // next[i] = Nachfolgeelement von i, falls i letztest
// Element der Liste ist: next[i]=i.
int *size; // size[i] = Laenge der Liste mit Namen i
int groesse;
public:
/* Konstruktor */
/* Erzeugt die Partition {{1},{2},...,{n}} */
PartitionWithLists_2(int n)
{
groesse = n;
name = new int[groesse+1];
next = new int[groesse+1];
size = new int[groesse+1];
for(int i=1; i<=groesse; i++)
{
name[i]=i;
next[i]=i;
size[i]=1;
}
}
/* Destruktor */
~PartitionWithLists_2()
{
delete[] name;
delete[] next;
delete[] size;
}
/* gibt den Namen der Menge, in der i liegt, zurueck */
int find(int i)
{
assert(i>=1 && i<=groesse);
return name[i];
}
73
/* Vereinigt die Mengen A und B,
neuer Name der Menge: der Name der groesseren Menge */
int Union(int A, int B)
{
// welche Menge ist groesser?
if(size[A]<size[B])
{
int h = A;
A = B;
B = h;
} // Jetzt ist Menge B kleiner als Menge A
// Umbenennung aller Elemente in der Menge B
int anfangB=B;
name[B] = A;
while(next[B]!=B)
{
B = next[B];
name[B] = A;
}
// Fuege die 2. Liste hinter den Anfang der ersten Liste ein
if(next[A]!=A) next[B] = next[A];
next[A] = anfangB;
// Groesse der Menge A aktualisieren
size[A]+=size[B];
return A;
}
};
Laufzeiten (bei n Elementen):
Initialisierung Θ(n)
union
Θ(Größe der kleineren Menge)
find
Θ(1)
Jetzt berechnen wir die Gesamtlaufzeit für eine Folge von union-Operationen. Die Laufzeit entspricht
der Zahl der Änderungen im Feld name. Wie oft kann der Eintrag in name[i] geändert werden? Wird
er geändert, dann wird die Menge, in der sich i vorher befand, in eine andere Menge, die mindestens
genauso groß ist, integriert. Die Größe der Menge hat sich also mindestens verdoppelt.
Fängt man mit der kleinstmöglichen Menge (Größe 1) an und macht die kleinstmöglichsten unionOperationen (sprich: Verdoppeln der Größe), so kann man höchstens blog nc mal verdoppeln. Der Eintrag
in name[i] kann also höchstens blog nc mal geändert werden. Betrachtet man jetzt alle Elemente, so
erhält man die folgende Laufzeitabschätzung.
Führt man auf einer Partition einer Menge mit n Elementen n − 1 union-Operationen und m findOperationen durch, so hat man hier eine Laufzeit von Θ(m + n log n).
8.3
Implementierung von Partitionen mit Bäumen
Man kann die Datenstruktur zur Verwaltung der Partitionen ein wenig umstellen, so daß man ein billiges union aber ein teureres find hat. Dazu verwendet man Bäume, in denen jeder Knoten nur seinen
Elternknoten kennen muß. Jeder Knoten kann beliebig viele Kinder haben. Der Elternknoten der Wurzel
ist die Wurzel selbst.
Ein solcher Baum entspricht gerade einer Teilmenge. Der Name der Menge ist das Element der Wurzel.
Bei union setzen wir den Baum mit weniger Elementen als neuen Teilbaum unter die Wurzel des anderen
74
Baumes. Bei find gehen wir in dem Baum bis zur Wurzel hoch und geben die Wurzel zurück.
class Partition
{
int *parent; // parent[i] = der von i aus naechste Knoten auf dem
// Weg zur Wurzel. Ist i die Wurzel, dann parent[i]=i
int *size;
// size[i] = Groesse der Menge mit Namen i
int groesse;
public:
/* Konstruktor */
/* Erzeugt die Partition {{1},{2},...,{n}} */
Partition(int n)
{
groesse = n;
parent = new int[groesse+1];
size = new int[groesse+1];
for(int i=1; i<=groesse; i++)
{
parent[i]=i;
size[i]=1;
}
}
/* Destruktor */
~Partition()
{
delete[] parent;
delete[] size;
}
/* gibt den Namen der Menge, in der i liegt, zurueck */
int find(int i)
{
assert(i>=1 && i<=groesse);
// suche die Wurzel des Baumes mit dem Element i
while(parent[i]!=i) i = parent[i];
return i;
}
/* Vereinigt die Mengen A und B,
neuer Name der Menge: der Name der groesseren Menge */
int Union(int A, int B)
{
// die kleinere Menge wird an die Wurzel gehaengt
// welche Menge ist groesser?
if(size[A]<=size[B])
{
parent[A]=B;
size[B]+=size[A];
return B;
}
else
75
{
parent[B]=A;
size[A]+=size[B];
return A;
}
}
};
Laufzeiten (bei n Elementen):
Initialisierung Θ(n)
union
Θ(1)
find
Θ(Länge des Pfades vom Element zur Wurzel)
Die Länge des Pfades von einem beliebigen Element bis zur Wurzel des entsprechenden Baumes
können wir mit O(log n) abschätzen, so daß ein find Laufzeit O(log n) hat. Wir bauen nämlich bei jedem
union den kleineren Baum als Teilbaum unter die Wurzel des größeren Baumes an. Ein Element i (in der
kleineren Menge) wird dabei um 1 nach unten verschoben, d.h. der Weg bis zur Wurzel verlängert sich
um 1. Die kleinere Menge verdoppelt sich dabei mindestens, so daß man ein Element höchstens blog nc
mal um 1 nach unten verschieben kann. Also hat der Weg eines beliebigen Elementes bis zur Wurzel
höchstens die Länge blog nc.
Führt man auf einer Partition einer Menge mit n Elementen n − 1 union-Operationen und m findOperationen durch, so hat man hier eine Laufzeit von Θ(n + m log n).
Man kann diese Implementierung noch ein wenig verbessern. Bei der find-Operation gehen wir von
einem Element bis zur Wurzel hoch. Die beste Laufzeit erhalten wir, wenn das Element direkt unter der
Wurzel steht. Die Idee ist jetzt, auf dem Weg zur Wurzel alle Elemente mitzunehmen und direkt unter
die Wurzel zu schreiben. Bei zukünftigen find-Operationen für diese Elemente (und für alle Elemente
in den Unterbäumen dieser Elemente) ist man dann näher an der Wurzel dran, so daß die zukünftigen
Operationen billiger werden.
In der Implementierung von Partition muß dabei nur die find-Operation folgendermaßen geändert
werden:
/* gibt den Namen der Menge, in der i liegt, zurueck */
int find(int i)
{
assert(i>=1 && i<=groesse);
// suche die Wurzel des Baumes mit dem Element i
// haenge dabei alle betrachteten Elemente direkt unter die Wurzel
if(parent[i]==i) return i;
parent[i]=find(parent[i]);
return parent[i];
}
Laufzeiten (bei n Elementen, worst case):
Initialisierung Θ(n)
union
Θ(1)
find
O(log n)
Dabei erhält man die Abschätzung O(log n) für die Laufzeit von find wie oben.
Man kann aber folgende amortisierte Analyse machen: Führt man auf einer Partition einer Menge
mit n Elementen n − 1 union-Operationen und m find-Operationen durch, so hat man hier eine Laufzeit
von Θ(n + mα(n, m/n)).
Die Funktion α wird im nächsten Abschnitt behandelt.
76
9
2
3
10
5
1
7
14
15
8
11
4
17
find(4)
9
2
3
10
5
1
7
4
8
14
11
15
17
77
8.4
Analyse von Partitionen mit Pfadkomprimierung
Diese Analyse ist sehr schwierig und wird hier auch nicht vollständig durchgeführt werden können. Wir
benötigen zunächst einige Definitionen.
8.4.1
Die Ackermannfunktionen und ihre Inversen
Notation: Sei f : N0 → N0 eine Funktion. Für k ∈ N0 bedeutet f (k) (n) das k-malige Hintereinanderausführen von f auf n:
f (k) (n) = (f ◦ · ◦ f )(n).
| {z }
k mal
Für k = 0 erhält man f (0) (n) = n.
Definition 8.2 Die Ackermannfunktionen sind die Funktionen Ak : N0 → N0 für k ∈ N0 mit
A0 (n)
= 2n,
Ak (n)
= Ak−1 (1), k ≥ 1,
= Ak−1 (Ak (n − 1)),
(n)
k ≥ 1, n > 0.
Um zu sehen, wie sich die Ackermannfunktionen verhalten, betrachten wir die folgende Tabelle.
A0 (n)
A1 (n)
A2 (n)
A3 (n)
n=0 n=1
0
2
1
2
1
1
2
2
n=2
4
4 = 22
n=3
6
8 = 23
n=4
8
16 = 24
4 = 22
4 = 22
16 = 24
65536 = 216
65536 = 216
2n
2n
22
..
.2
Die ersten Ackermannfunktionen sind noch relativ einfach zu beschreiben. A0 (n) = 2n ist nach Defi(n)
nition klar. Weiter ist A1 (n) = A0 (1). Man wendet also A0 n mal auf die 1 an und erhält die Funktion
n
A1 (n) = 2 .
Um A2 (n) zu bestimmen, müssen wir n mal A1 auf 1 anwenden. Wir erhalten die Funktion A2 (n) =
22
..
.2
, wobei hier n mal die 2 übereinandersteht.
Bei A3 (n) wenden wir n mal A2 auf 1 an. Für den Wert von A3 (4) müßten wir 65536 mal die 2
übereinanderschreiben.
Man sieht, daß diese Funktionen extrem schnell anwachsen. Die ersten drei Funktionswerte bleiben
immer gleich.
Lemma 8.1 Für alle k ∈ N0 ist Ak (0) = 1 (falls k > 0, A0 (0) = 0), Ak (1) = 2 und Ak (2) = 4.
Beweis: Induktion über k.
k = 0: s. Tabelle. k = 1: s. Tabelle.
k → k + 1: Es ist
(0)
Ak+1 (0) = Ak (1) = 1,
Ak+1 (1) = Ak (1) = 2,
Ak+1 (2) = Ak (Ak (1)) = Ak (2) = 4,
wobei die letzten beiden Zeilen mit Induktionsvoraussetzung folgen.
Lemma 8.2 Für alle k ist Ak (n) streng monoton wachsend.
Beweis: Es sei m > n. Zu zeigen ist, daß Ak (m) > Ak (n) ist.
k = 0: ist klar.
k → k + 1: Nach Induktionsvoraussetzung ist Ak streng monoton wachsend. Man kann dann leicht mit
(i)
(m−n)
Induktion nach i zeigen, daß für alle i ∈ N gilt Ak (1) > 1. Damit folgt insbesondere Ak
(1) > 1.
(m)
(n)
Wendet man n mal Ak auf beide Seiten an, so folgt Ak (1) > Ak (1) und damit die Behauptung.
78
Lemma 8.3 Für alle k, n ∈ N0 (außer für k = n = 0) gilt Ak (n) > n.
Beweis: Induktion nach k.
k = 0, k = 1: klar nach Definition.
(n)
k → k + 1: Es ist Ak+1 (n) = Ak (1). Nach Induktionsvoraussetzung ist Ak (1) > 1 und damit Ak (1) ≥ 2.
(2)
Wendet man darauf Ak an, so erhält man, da die Funktion Ak streng monoton wachsend ist, Ak (1) ≥
Ak (2) > 2 nach Induktionsvoraussetzung. Wir wenden weiter Ak auf diese Ungleichung an und zeigen für
(i)
alle i: Ak (1) > i. Mit i = n folgt die Behauptung.
Lemma 8.4 Für alle k ∈ N0 gilt Ak+1 (n) ≥ Ak (n) für alle n ∈ N0 .
(n)
(n−1)
Beweis: Es ist Ak+1 (n) = Ak (1). Nach dem letzten Lemma wissen wir, daß Ak
(1) > n − 1 ist
(n)
(wurde im Beweis auch gezeigt). Da Ak streng monoton wachsend ist, folgt Ak (1) ≥ Ak (n) und damit
die Behauptung.
Definition 8.3 Die Inversen der Ackermannfunktionen sind die Funktionen Ik : N → N für k ∈ N0
mit
I0 (n)
Ik (n)
= dn/2e ,
(i)
= min{i ∈ N | Ik−1 (n) = 1},
k ≥ 1.
Lemma 8.5 Für alle k ∈ N0 gilt Ik (n) < n für alle n ≥ 2. Damit ist gezeigt, daß die Menge, aus der
das Minimum genommen wird, nie leer ist.
Beweis: Induktion über k.
k = 0: Zeige für alle n ∈ N, n ≥ 2: n/2 ≤ n − 1. (⇔ n/2 ≥ 1 ⇔ n ≥ 2) Damit folgt I0 (n) ≤ n − 1 < n.
(i−1)
(i)
k → k + 1: Nach Induktionsvoraussetzung ist für alle i ∈ N entweder Ik−1 (n) = 1 oder Ik−1 (n) =
(i−1)
(i−1)
Ik−1 (Ik−1 (n)) < Ik−1 (n).
Wegen Ik−1 (n) < n muß man Ik−1 also höchstens n − 1 mal anwenden, bis man auf 1 stößt. Es folgt
also Ik (n) < n.
Beispiel: Wir berechnen zunächst I1 (14). Es ist
I0 (14) = 7,
I0 (7) = 4,
I0 (4) = 2,
I0 (2) = 1.
Damit folgt I1 (14) = 4.
Jetzt versuchen wir uns an der Berechnung von I2 (5). Dazu berechnen wir:
I1 (5) = 3
I1 (3) = 2
(I0 (5) = 3, I0 (3) = 2, I0 (2) = 1)
(I0 (3) = 2, I0 (2) = 1)
I1 (2) = 1
(I0 (2) = 1)
Wir erhalten also: I2 (5) = 3.
Beispiel: Wir zeigen noch: Für alle k ∈ N0 ist Ik (3) > 1.
k = 0: Es ist I0 (3) = d3/2e = 2 > 1.
k → k + 1: Nach Induktionsvoraussetzung ist Ik (3) > 1. Um durch Anwendung von Ik von 3 auf 1 zu
kommen, muß man also mindestens 2 Schritte machen, d.h. Ik+1 (3) > 1.
Analog kann man auch zeigen, daß für alle k ∈ N0 gilt: Ik (4) > 1.
Lemma 8.6 Für alle k ∈ N0 ist Ik (Ak (n)) = n für alle n ∈ N.
Beweis: Induktion über k.
k = 0: I0 (A0 (n)) = d(2n)/2e = n.
k − 1 → k:
Ik (Ak (n))
(i)
= min{i ∈ N | Ik−1 (Ak (n)) = 1}
(i)
(n)
= min{i ∈ N | Ik−1 (Ak−1 (1)) = 1}
(n−i)
= min{i ∈ N, i ≤ n | Ak−1 (1)) = 1}.
79
(n−i)
Der letzte Schritt folgt dabei aus der Induktionsvoraussetzung. Ist i < n, dann ist A k−1 (1) > 1. Für
(n−i)
i = n ist Ak−1 (1)) = 1, so daß wir uns im letzten Schritt auf i ≤ n beschränken konnten. Es folgt
Ik (Ak (n)) = n.
Lemma 8.7 Für alle k ∈ N0 , n ∈ N gilt Ik+1 (n) ≤ Ik (n).
Beweis: Wir betrachten Ik (n). Entweder ist Ik (n) = 1 und damit Ik+1 (n) = 1 = Ik (n), oder es ist
Ik (n) ≥ 2. In diesem Fall folgt mit Lemma 8.5, daß Ik (Ik (n)) < Ik (n) ist. Man braucht damit höchstens
Ik (n) − 1 Schritte, um von Ik (n) auf 1 zu kommen. Es folgt Ik+1 (n) ≤ Ik (n) − 1 + 1 = Ik (n).
Lemma 8.8 Für alle k ∈ N0 ist Ik (n) monoton wachsend.
Beweis: Übung.
Definition 8.4 Die inverse Ackermann Funktion α : N × N → N0 ist definiert als
α(n, n0 ) = min{k ∈ N0 | Ik (n) ≤ 2 + n0 }. = min{k ∈ N0 | Ak (2 + n0 ) ≥ n}.
Lemma 8.9 Die beiden Definitionen sind gleich.
Beweis: Es ist wegen Ik (Ak (2 + n0 )) = 2 + n0
Ak (2 + n0 ) ≥ n
⇔
2 + n0 ≥ Ik (n).
Lemma 8.10 Für alle n ≥ 5 gilt: Ik (n) > 2 für alle k ∈ N0 . Die Funktion Ik kann für alle n ≥ 5 also
nie kleiner als 3 werden, d.h. wir brauchen in der Definition 2 + n0 , damit die Menge, aus der wir das
Minimum nehmen, nicht leer ist.
Beweis: Induktion über k.
k = 0: I0 (n) = dn/2e ≥ d5/2e = 3 > 2.
(i)
k → k + 1: Es ist Ik+1 (n) = min{i ∈ N | Ik (n) = 1}. Sei n ≥ 5. Nach Induktionsvoraussetzung wissen
wir, daß Ik (n) > 2 ist. Also folgt Ik+1 (n) ≥ 2. Wir nehmen jetzt an, daß Ik+1 (n) = 2 ist, also daß
Ik (Ik (n)) = 1 ist, und führen diese Aussage zu einem Widerspruch.
Zunächst muß in diesem Fall Ik (n) < 5 sein, da sonst nach Induktionsvoraussetzung Ik (Ik (n)) > 2 > 1
ist. Also kann Ik (n) ∈ {3, 4} sein, wegen Ik (n) > 2. Ist Ik (n) = 3, so haben wir gezeigt, daß Ik (Ik (n)) =
Ik (3) > 1 ist. Genauso für Ik (n) = 4. Wir haben also den Widerspruch, d.h. der Fall Ik+1 (n) = 2 kann
nicht eintreten. Daher ist Ik+1 (n) > 2.
Beispiel: n0 = 1:
α(n, 1) = min{k ∈ N0 | Ak (3) ≥ n}
Da A4 (3) extrem riesig ist, folgt, daß für alle Zahlen, die man so verwendet, α(n, 1) ≤ 3 ist. Diese
Funktion verhält sich also so gut wie konstant. Allerdings ist sie nicht konstant, man kann im Gegenteil
sogar zeigen, daß lim α(n, 1) = ∞ ist.
n→∞
Das zeigen wir allgemeiner:
Lemma 8.11 Für alle n0 ∈ N gilt lim α(n, n0 ) = ∞.
n→∞
Beweis: Übung.
80
8.4.2
Die Analyse
Wir haben die Funktion α eingeführt, um folgende Analyse für Partitionen mit Pfadkomprimierung
durchzuführen:
Führt man auf einer Partition einer Menge mit n Elementen n − 1 union-Operationen und m findOperationen durch, so hat man bei Verwendung von Bäumen mit Pfadkomprimierung eine Laufzeit von
Θ(n + mα(n, m/n)).
Diese Aussage können wir leider nicht beweisen. Wir werden eine ähnliche, etwas schwächere Aussage
zeigen:
Satz 8.1 Führt man auf einer Partition einer Menge mit n Elementen n − 1 union-Operationen und m
find-Operationen durch, so hat man bei Verwendung von Bäumen mit Pfadkomprimierung eine Laufzeit
von O((n + m)α(n, 1)).
Beweis: Seien P1 , . . . , Pm die Pfade entlang denen die find-Operationen laufen. Diese laufen immer von
einem Element zur Wurzel des entsprechenden Baumes.
Wir wollen die Situation jetzt vereinfachen und nur einen Baum betrachen. Dazu nehmen wir an, daß
wir zuerst alle union-Operationen durchführen, so daß wir einen einzigen Baum übrigbehalten. Dann erst
werden alle find-Operationen durchgeführt. Dabei gehen wir in diesem Baum aber nicht bis zur Wurzel,
sondern wir gehen wirklich die Pfade P1 , . . . , Pm entlang. Bei einem Pfad werden die durchlaufenen
Knoten auch nicht an die Wurzel des Baumes, sondern an den Endknoten des Pfades gehängt.
E
E
Pfadkomprimierung auf dem
Pfad von A nach E
2
Die Knoten A und 1 werden
an E gehaengt.
2
A
1
1
A
Wir betrachten also jetzt m Pfadkomprimierungen P1 , . . . , Pm auf dem einen Baum mit n Knoten.
Für einen Knoten u aus diesem Baum sei h(u) die Höhe von u im Baum. Dabei ist die Höhe von
Blättern gleich 0. Die Höhe eines Knotens u mit mehreren Kindern ist das Maximum der Höhen seiner
Kinder +1.
81
In jedem Knoten steht seine Hoehe.
4
0
3
0
1
1
2
0
0
0
0
0
Wir haben uns bereits vorher überlegt, daß die Pfade eines jeden Knotens zur Wurzel des Baumes
höchstens blog nc lang sind. Das liegt daran, daß wir bei einem union immer den Baum mit weniger
Elementen an die Wurzel des Baumes mit mehr Elementen hängen. Es folgt, daß die Höhe jedes Knotens
≤ log n ist.
Definition 8.5 Gegeben sei ein Knoten u mit Elternknoten v in dem Baum. Der Höhenunterschied von
u zu seinem Elternknoten v ist vom Typ k, falls
h(v) + 3 ≥ Ak (h(u) + 3),
h(v) + 3 < Ak+1 (h(u) + 3).
(Die ’+3’ wird aus technischen Gründen benötigt, da wir sonst mit kleinen Höhen Probleme bekommen.)
Ein Knoten u heißt vom Typ k, wenn er zu seinem Elternknoten einen Höhenunterschied vom Typ
k hat.
Lemma 8.12 Es kommen nur Höhenunterschiede der Typen 0, 1, . . . , α(n, 1) − 1 vor.
Beweis: Die Höhe jedes Knotens ist nach obiger Überlegung ≤ log n. Wir interessieren uns für Laufzeitabschätzungen bei großen n, d.h. wir können n so groß wählen, daß log n < n − 3 ist. Dann kommen nur
Höhen 0, 1, . . . , n − 4 vor.
Nach Definition der Funktion α ist
α(n, 1) = min{k ∈ N0 | Ak (3) ≥ n}.
Damit folgt insbesondere, daß Aα(n,1) (3) ≥ n ist. Da die Ak monoton wachsen, ist damit Aα(n,1) (h(u) +
3) ≥ n > (n − 4) + 3 für alle Knoten u. (Hier würden wir Probleme bekommen, wenn wir die ’+3’
weglassen würden.)
Damit folgt, daß die Höhenunterschiede nicht vom Typ α(n, 1) (oder größer) sein können, also folgt
die Behauptung.
Zu Beginn der Analyse führen wir also alle union-Operationen durch und erhalten einen Baum.
Zu jedem Knoten dieses Baumes merken wir uns die Höhe. Bei den Pfadkompressionen, die danach
durchgeführt werden, bleiben die Höhen der Knoten enthalten, die Knoten ändern nur ihre Lage. Dabei
werden immer Knoten mit kleinerer Höhe an Knoten mit größerer Höhe gehängt. Mit dem Lemma haben
wir gezeigt, daß die Höhenunterschiede nicht beliebig groß werden können.
Definition 8.6 Eine Umhängung eines Knotens vom Typ k heißt effektiv, falls es weiter oben im Pfad
noch einen Knoten gibt, der mindestens vom Typ k ist.
82
Lemma 8.13 Es gibt insgesamt O(mα(n, 1)) nichteffektive Umhängungen.
Beweis: Auf jedem Pfad gibt es höchstens so viele nichteffektive Umhängungen, wie es verschiedene
Typen gibt.
Lemma 8.14 Für einen Knoten u gibt es höchstens (h(u) + 3)α(n, 1) effektive Umhängungen.
Beweis: Der Knoten u sei vom Typ Ak . Die Höhe des Elternknotens v von u (+3) ist also kleiner als
(i)
Ak+1 (h(u) + 3). Es sei h(v) + 3 ≥ Ak (h(u) + 3). Nach einer effektiven Umhängung von u hat u einen
neuen Elternknoten w, für dessen Höhe nach Definition der effektiven Umhängung gilt:
(i+1)
h(w) + 3 ≥ Ak
(h(u) + 3).
Nach h(u) + 3 effektiven Umhängungen hat u dann einen Elternknoten, dessen Höhe mindestens
(h(u)+3)
≥ Ak
(h(u)+3)
(h(u) + 3) ≥ Ak
(1) = Ak+1 (h(u) + 3)
ist. (Dabei haben wir verwendet, daß Ak monoton wächst.) Der Typ von u hat sich also noch h(u)
effektiven Umhängungen mindestens um 1 erhöht.
Da der Typ nicht beliebig groß werden kann, kann es höchstens (h(u) + 3)α(n, 1) effektive Umhängungen von u geben.
Die Gesamtzahl aller Umhängungen
ergibt
effektiven Umhängungen
P sich also aus der Gesamtzahl aller
P
h(u)α(n,
1)
+
3nα(n,
1)
plus der Gesamtzahl aller
(h(u)
+
3)α(n,
1)
=
O
O
Knoten u
Knoten u
nichteffektiven Umhängungen O(mα(n, 1)):



X
Gesamtzahl aller Umhängungen = O 
h(u) + 3n + m α(n, 1) .
Knoten u
Dies ist auch gerade die Gesamtlänge der Pfade P1 , . . . , Pm , entlang derer die Komprimierung stattfindet.
Der Rest wird in der Übung bewiesen.
83

Podobne dokumenty