PI 1

Werbung
Algorithmen
und
Datenstrukturen
Skriptum zur Vorlesung WS2004/2005
gehalten von Prof. Dr. Dietmar Seipel
MH
-1-
Inhaltsverzeichnis
1 Grundlagen
3
1.1 Algorithmen und ihre formalen Eigenschaften, Datenstrukturen
1.2 Polynomprodukt
1.3 Ein Multiplikationsverfahren
1.4 Aktienkurs-Analyse
1.5 Komplexität von Algorithmen
3
3
5
7
10
2 Sortieren
14
2.1 Mergesort
2.2 Quicksort
2.3 Untere Schranken für das Sortierproblem
2.4 Heap und Heapsort
2.5 Elementare Sortierverfahren
14
16
19
22
26
3 Dynamische Datenstrukturen
28
3.1 Lineare Liste
3.2 Schlangen
3.3 Stapel
3.4 Geordnete binäre Wurzelbäume
28
33
33
35
4 Suchen in Folgen
40
4.1 Sequentielle Suche
4.2 Binäre Suche
4.3 Mediansuche
40
40
42
5 Bäume
46
5.1 Suchbäume
5.2 AVL-Bäume
5.3 B-Bäume
46
54
64
6 Hashverfahren
73
6.1 Grundbegriffe
6.2 Gebräuchliche Hashfunktionen
6.3 Kollisionsstrategien
73
75
77
-2-
Kapitel 1: Grundlagen
1.1 Algorithmen und ihre formalen Eigenschaften, Datenstrukturen
Ein Algorithmus ist ein mit formalen Mitteln beschreibbares, mechanisch nachvollziehbares Verfahren
zur Lösung einer Klasse von Problemen.
Er kann in Form eines Programms von einem Computer bearbeitet werden.
Korrektheit von Algorithmen:
E. Dijkstra: Man kann durch Testen zwar die Anwesenheit, nicht aber die Abwesenheit von Fehlern
zeigen.
Formale Korrektheitsbeweise werden häufig mit Hilfe von Schleifen- und Prozedurinvarianten geführt.
Effizienz von Algorithmen:
Die wichtigsten Maße für Effizienz sind der zur Ausführung des Algorithmus benötigte
Speicherplatz und die benötigte Rechenzeit.
- experimentelle Messung mit Hilfe von repräsentativ gewählten Eingaben,
- formale Abschätzung der Kosten im besten Fall (best case), im schlechtesten Fall (worst case)
und im Mittel (average case). Die Analyse des Verhaltens im Mittel ist häufig mathematisch
schwierig durchzuführen.
Datenstruktur
Aufbau von komplexen Wertebereichen aus elementaren Wertebereichen mit Hilfe von Konstruktoren.
1.2 Polynomprodukt
Ein ganzzahliges Polynom vom Grad N-1 ist gegeben durch N ganzzahlige Koeffizienten
a0, ..., aN-1 ∈ Z:
p(x) = a0 + a1x1 + … + aN-1xN-1.
Beispiel:
p(x) = 4 + 2x - x3
Æ
a0 = 4, a1 = 2, a2 = 0, a3 = -1, N = 4
Sei
q(x) = b0 + b1x1 + … + bN-1xN-1.
ein weiteres ganzzahliges Polynom vom Grade N.
Frage: Wie kann man das Produkt der Polynome r(x) = p(x) * q(x) (effizient) berechnen?
Beispiel:
(siehe Skript)
distributives Ausmultiplizieren
Aufsammeln der Terme mit gleichen Exponenten.
-3-
Implementierung:
Polynome als Arrays
Deklaration (Java):
int p[N - 1], q[N - 1], r[2 * N - 2];
doppelt geschachtelte for-Schleife :
for (int i = 0; i <= N - 1; i++)
for (int j = 0; j <= N - 1; j++)
r[i + j] = r[i + j] + p[i] * q[j];
Es werden N2 Koeffizientenprodukte berechnet.
Divide-and-Conquer-Verfahren
zur Lösung eines Problems der Größe N
1. Divide: Teile das Problem der Größe N in (wenigstens) zwei annähernd gleich große
Teilprobleme wenn N>1 ist, sonst löse das Problem der Größe 1 direkt.
2. Conquer: Löse die Teilprobleme auf dieselbe Art (rekursiv).
3. Merge: Füge die Teillösungen zur Gesamtlösung zusammen.
Wir nehmen nun an, dass N eine Zweierpotenz ist: N = 2k, k ∈ N.
p(x) = pl(x) + xN/2 * pr(x)
mit
pl(x) = a0 + a1x1 + … + aN/2-1xN/2-1,
pr(x) = aN/2 + aN/2+1x1 + … + aN-1xN/2-1,
Ebenso kann man schreiben
q(x) = ql(x) + xN/2 * qr(x)
mit geeigneten Polynomen ql()x) und qr(x) vom Grad n/2 - 1.
Dann ist r(x) = p(x) * q(x) =
= pl(x) * ql(x) + ( pl(x) * qr(x) + pr(x) * ql(x) ) * xN/2 + pr(x) * qr(x) * xN
Beispiel: (siehe Skript)
Berechnung von 4 Produkten von Polynomen vom Grad N/2 - 1
M(N) = Anzahl der Multiplikationen von Koeffizienten, die ausgeführt werden, wenn man zwei
Polynome vom Grad N-1 mit N Koeffizienten miteinander multipliziert.
Rekursionsformel:
M(N) = 4M(N/2)
M(1) = 1
für N = 2k, k ∈ N0:
M(N) = M(2k) = 4M(2k-1) = ... = 4k * M(20) = 4k = (22)k = 22k = (2k)2 = N2.
Man kann aber mit weniger Koeffizientenmultiplikationen auskommen:
zl(x) = pl(x) * ql(x)
zr(x) = pr(x) * qr(x)
zm(x) = ( pl(x) + pr(x) ) * (ql(x) + qr(x) )
Dann ist
r(x) = p(x) * q(x) = zl(x) + (zm(x) - zl(x) - zr(x) ) * xN/2 + zr(x) * xN
-4-
Divide-and-Conquer-Verfahren (nach KARATSUBA):
zur Multiplikation zweier Polynome p(x) und q(x) vom Grad N-1.
1. Divide: Falls N=1 ist, so berechne das Produkt der beiden Koeffizienten direkt, sonst: Zerlege
die Polynome p(x) und q(x): p(x) = pl(x) + pr(x) * xN/2, q(x) = ql(x) + qr(x) * xN/2 und setzte
pm(x) = pl(x) + pr(x), qm(x) = ql(x) + qr(x),
2. Conquer: Wende das Verfahren (rekursiv) an, um die folgenden Polynomprodukte zu berechnen: zl(x) = pl(x) * ql(x), zr(x) = pr(x) * qr(x), zm(x) = pm(x) * qm(x).
3. Merge: Setze p(x) * q(x) = zl(x) + (zm(x) - zl(x) - zr(x) ) * xN/2 + zr(x) * xN.
Rekursionsformel:
M(N) = 3 * M(N/2),
M(1) = 1.
für N = 2k, k ∈ N0:
M(N) = M(2k) = 3M(2k-1) = ... = 3k * M(20) = 3k = (2log 3)k = (2k)log 3 = Nlog 3 = N1,58....
(log x = Logarithmus zur Basis 2 von x)
Verbesserung gegenüber dem naiven Verfahren mit N2 Multiplikationen.
1.3 Ein Multiplikationsverfahren
Multiplikation von zwei Dualzahlen a, b
Beispiel:
a = 1001 ( =13)
b = 101 ( = 5)
Multiplikationsschema:
1101 * 101
1101
0000
1101__
1000001
( = 65)
Implementierung:
int x = a;
int y = b;
int z = 0;
while ( y > 0 )
if ((y % 2)
y = y /
x = x +
} else {
y = y z = z +
}
}
{
== 0) {
2;
x;
// (*)
1;
x;
-5-
Wert der Variablen zu Beginn eines jeden Durchlaufs durch die while-Schleife:
x
y
1101
1101
11010
110100
110100
z
101
100
10
1
0
0
1101
1101
1101
1000001
Anzahl der Schleifeniterationen
0
1
2
3
4
Beweis für die Korrektheit des Verfahren. Wir verwenden eine Schleifeninvariante
P: y ≥ 0 und z + xy = ab
und zeigen folgende 3 Behauptungen:
1. Behauptung: P ist vor Ausführung der while-Schleife richtig, d.h. vor erstmaliger Ausführung
der if-Anweisung (*).
2. Behauptung: P bleibt bei einmaliger Ausführung der while-Schleife richtig, d.h. gilt die kontrollierende Bedingung (y > 0) der while-Schleife und die Bedingung P vor der Ausführung der
Anweisung (*), so gilt nach der Ausführung von (*) ebenfalls P.
3. Behauptung: Die while-Schleife terminiert, d.h. die zu iterierende if-Anweisung wird nur endlich oft ausgeführt.
Beweise:
1. Vor der while-Schleife gilt x = a, y = b ≥ 0, z = 0 und folglich z + xy = 0+ab = ab
2. Wir nehmen an, es gelte vor der Ausführung von (*): ( y ≥ 0 und z+xy = ab) und y > 0.
Ist y gerade, so wird y halbiert und x verdoppelt. Aber es bleibt bei Ausführung von (*) das
Produkt xy unverändert, und es gilt nach der Ausführung von (*) immer noch
(y ≥ 0 und z+xy = ab).
Ist y ungerade, so wird y um 1 verringert und z um x erhöht. Also gilt nach der Ausführung von
(*): z’+x’y’ = (z+x) + x(y-1) = z+x+xy-x = z+xy = ab, d.h. die Schleifeninvariante P ist nach
wie vor gültig.
3. Jede Ausführung der if-Anweisung (*) in der while-Schleife verringert den Wert von y um
mindestens 1 (denn für y > 0 gilt auch y/2 ≤ y-1). Nach höchstens y Iterationen muss also y ≤ 0
werden, und damit die Schleife terminieren.
Bei Terminierung des Verfahrens gilt y ≤ 0 und es gilt immer noch die Schleifeninvariante
P: y ≥ 0 und z+xy = ab
Folglich gilt y = 0 und
z = z + xy = ab,
d.h. das Produkt von a und b wird korrekt berechnet.
-6-
1.4 Aktienkurs-Analyse
1, 4, -3, -3, 1, -2, 3, -1, 4, -1, -4, -3, 0, 1
Eingabefolge X[1..14], S ∈ N0
Kurs zur Zeit i: S + X[1] + ... + X[i-1]
Das Maximum-Subarray-Problem:
Gegeben sei eine Folge X von N ganzen Zahlen in einem Array. Gesucht ist die maximale Summe
aller Elemente in einer zusammenhängenden Teilfolge.
Sie wird als maximale Teilsumme bezeichnet, jede solche Folge als maximale Teilfolge.
im Beispiel: maximale Teilfolge X[7..9], Summe: 6 (Zum Vergleich: X[5..10]: Summe 4)
Naives Verfahren:
Berechne für jedes i ∈ <1, N> sukzessive die Teilsummen X[i] + X[i+1] + ... + X[j]
für j = i, ..., N, und aktualisiere jeweils das Maximum.
Aufwand für jedes i:
- N-i Additionen,
- N-i+1 Vergleiche
Gesamtaufwand:
- Additionen: A(N) = (N-1) + (N-2) + ... + (N-N) = 0+1+2+...+N-1 = N(N-1)/2
- Vergleiche: V(N) = A(N) + N
- Summe: T(N) = A(N) + V(N) = 2A(N) + N = N(N-1)+N = N2
-7-
Divide-and-Conquer-Ansatz
1. Divide:
Teile die Folge X[1], ..., X[N] in zwei Teilfolgen X[1], ..., X[M] und X[M+1], ..., X[N]
Dann liegt die maximale Teilfolge entweder ganz in einem der beiden Teile oder sie umfasst die
Trennstelle.
Als linkes (rechtes) Randmaximum bezeichnen wir die Summe der Teilfolge X[l], ... X[M]
( X[M+1], ..., X[r] ), für die die Summe maximal wird unter allen Teilfolgen, die mit l ∈ <0,M>
beginnen (mit r ∈ <M+1,N> enden).
Die Randmaxima können mit folgendem Aufwand bestimmt werden:
- links:
M-1+M = 2M-1
- rechts: (N-M-1) + N-M = 2(N-M) -1
Summe: 2N - 2 Schritte
Die Randmaxima seien rl und rr.
Falls die maximale Teilfolge den Rand X[M] umfasst, so ist X[l], ..., X[M], X[M+1], ..., X[r] eine
maximale Teilfolge mit Teilsumme rl + rr.
2. Conquer:
Löse Teilprobleme X[1], ..., X[M] und X[M+1], ..., X[N] rekursiv. Die Maxima seien sl und sr.
3. Merge:
Die maximale Teilsumme ergibt sich nun als
max_t_sum = max{sl, sr, rl+rr}
Beispiel (siehe Skript)
Sei nun T(N) die Anzahl der Schritte, die erforderlich ist, um das Divide-and-Conquer-Verfahren für
eine Folge X[1], ..., X[N] auszuführen.
Sei N = 2k, k ∈ N0, und es werde jeweils in der Mitte geteilt: M = N/2.
Rekursionsformel:
T(N) = 2 * T(N/2) + 2N - 2 + 3 ( = ... + 2N + 1)
T(1) = 1
Folglich gilt:
T(2k) = 21 * T(2k-1) + 2*2k + 1
= 22 * T(2k-2) + 22*2k-1 + 21 + 21*2k + 1
= ...
= 2k * T(1) + k*2k+1 + (2k-1 + ... + 1)
= 2k + k*2k+1 + 2k - 1
= (k+1) * 2k+1 - 1
Wegen k = log N erhalten wir weiter:
T(N) = 2 * ( (log N) + 1 ) * N - 1
≤ 4 * N * log N - 1 für N ≥ 2
-8-
Ein weiteres algorithmisches Prinzip ist das
Scan-Line-Prinzip:
- Wir durchlaufen die Eingabe in der durch eine aufsteigend sortierte, lineare Folge von Implikationsstellen vorgegebenen Reihenfolge. (In unserem Falle die Positionen 1, ..., N der
Eingabefolge.)
- Wir führen zugleich eine vom jeweiligen Problem abhängige, dynamische veränderliche, d.h.
an jeder Implikationsstelle gegebenenfalls zu korrigierende Information mit. In unserem Falle
sind dies
o die maximale Summe bisMax einer Teilfolge im gesamten bisher inspizierten
Anfangsstück und
o das an der Implikationsstelle endende rechte Randmaximum scanMax des bisher
inspizierten Anfangsstücks.
Das rechte Randmaximum der neuen Folge mit M+1 Elementen enthält man aus dem rechten
Randmaximum der Folge mit M Elementen durch Hinzunahme von a, falls
ScanMaxalt + a > 0, ansonsten ist die leere Folge das neue rechte Randmaximum.
Algorithmus in JAVA:
public class Scanline {
public static void main(String[] args) {
int X[] = {1, 4, -3, -3, 1, -2, 3, -1, 4, -1, -4, -3, 0, 1};
System.out.println(scanLine(X));
}
public static int scanLine(int[] X) {
// Initialisierung
int N = X.length;
int scanMax = 0;
int bisMax = 0;
// Iteration
for (int i = 0; i < N; i++) {
// 2 Schritte
int a = X[i];
// 2 Schritte
// Update ScanMax und bisMax
if (scanMax + a > 0)
// 2 Schritte
scanMax += a;
// 1 Schritt
else
scanMax = 0;
bisMax = java.lang.Math.max(bisMax, scanMax);
}
return bisMax;
// 2 Schritte
}
}
Dieser Algorithmus benötigt nur linear (N) viele Durchläufe der for-Schleife und bei jedem Durchlauf,
d.h. an jeder Implikationsstelle, ist der Aufwandbeschränkt durch eine Konstante
c ∈ N0 (in unserem Fall z.B. c = 9)
Gesamtaufwand ist also T(N) ≤ 9N + 3.
-9-
1.5 Komplexität von Algorithmen
Der Aufwand oder die Komplexität eines Algorithmus gibt die Anzahl der problemrelevanten Elementaroperationen bei seiner Ausführung an. Dazu gibt man eine Funktion T: N Æ R an, die von der Problemgröße n abhängt.
Wie sind in der Regel zufrieden, wenn wir die Größenordnung oder die Wachstumsklasse des
Algorithmus bestimmen können. Wachstumsklassen sind Mengen von Funktionen, die für große n
etwa gleichen Verlauf aufweisen.
Definition (Größenordnungsmaße O, Ω, Θ)
Sei f: : N Æ R eine Funktion.
i.
O(f) = { g: N Æ R | ∃n0 ∈ N, ∃c>0 : ∀n≥n0: g(n) ≤ c * f(n) }
ii.
Ω(f) = { g: N Æ R | ∃n0 ∈ N, ∃c>0 : ∀n≥n0: g(n) ≥ c * f(n) }
iii.
Θ(f) = O(f) ∩ Ω(f)
Sprechweisen:
g ist höchstens|mindestens|genau von der Größenordnung f, falls g∈O(f) | g∈Ω(f) | g∈Θ(f) ist.
Offensichtlich gilt:
g ∈ O(f) Ù f ∈ Ω(g) und f ∈ O(f), f ∈ Ω(f), f ∈ Θ(f)
Wir schreiben oft auch g(n) ∈ O( f(n) ), u.s.w. ...
Verhalten wichtiger Funktionen:
2
log2 n
n
n log2 n
n2
2n
4
1
2
2
4
4
8
2
4
8
16
16
16
3
8
24
64
256
- 10 -
4
16
64
256
65.536
1024
10
1.024
10.240
1.048.537
> 10341
Es gilt: O(log2n) ⊂ O(n) ⊂ O(n*log2n) ⊂ O(n2) ⊂ O(2n)
Folgerung: Für f, g: N Æ R+ gilt
i.
Falls es ein c > 0 gibt mit limn->∞ ( g(n) / f(n) ) < c, so gilt: g ∈ O(f) ( und f ∈ Ω(g) ).
ii.
Falls es ein c > 0 gibt mit limn->∞ ( g(n) / f(n) ) = c, so gilt: g ∈ Θ(f) ( und f ∈ Θ(g) ).
Für Polynome ist nur der Grad ausschlaggebend:
Falls grad(f) = k, grad(g) = l, dann gilt:
f(n) ∈ O(g(n))
Ùk ≤ l
O(f(n)) = O(g(n))
Ùk = l
Beispiel (siehe Skript)
Weitere Rechenregeln:
i.
Sei g(n) ∈ O(f(n)) und a, b ∈ R und es existiere ein n’ ∈ N0 mit f(n) ≥ 1, für alle n ≥ n’.
Dann gilt a * g(n) + b ∈ O(f(n)). Für a > 0 gilt: O(a*g(n)+b) = O(g(n)).
Wegen log2n = log2b * logbn gilt für b > 1 also O(log2n) = O(logbn).
ii.
Seien f(n) ∈ O(h1(n)) und g(n) ∈ O(h2(n)) und hi(n) ≥ 0 ∀n ≥ n’. Dann gilt
f(n) + g(n) ∈ O(h1(n) + h2(n))
iii.
Falls ein n’ ∈ N0 existiert mit f(n) ≥ 0 für alle n ≥ n’, so gilt auch
f(n) * g(n) ∈ O(h1(n) * h2(n))
Beweis:
i.
ii.
iii.
Wegen g(n) ∈ O(f(n)) gibt es n0 ∈ N, c > 0 mit g(n) ≤ c * f(n) für alle n ≥ n0.
Wegen f(n) ≥ 1 für alle n ≥ n’, gilt:
a * g(n) + b ≤ a * f(n) + |b| * f(n), für alle n ≥ max{no, n’}
Wegen f(n) ∈ O(h1(n)) und g(n) ∈ O(h2(n)) gibt es n1 ∈ N und c1 > 0 sowie
n2 ∈ N und c2 > 0 mit f(n) ≤ c1 * h1(n) für alle n ≥ n1, g(n) ≤ c2 * h2(n) für alle n ≥ n2.
Somit gilt: f(n) + g(n) ≤ max {c1, c2} * (h1(n) + h2(n)) für alle n ≥ max {n1, n2, n1’, n2’}
Für alle n ≥ n’ gilt f(n) ≥ 0 und somit auch h1(n) ≥ 1/c1 * f(n) ≥ 0 für alle n≥max{n1,n’}
Somit gilt: f(n) * g(n) ≤ c1 * c2 * h1(n) * h2(n) für alle n ≥ max {n1, n2, n’}
- 11 -
Rekursionsgleichungen:
Satz:
Es seien a, b ≥ 0, c > 1, n = ck, k ∈ N+ und f(n) beliebig. Dann hat die Gleichung
T(1) = b,
T(n) = a * T(n/c) + f(n)
die Lösung
T(n) = b * ak +
k −1
∑
aj * f(ck-j)
j =0
Beweis: Vollständige Induktion über k
Induktionsanfang, k = 0:
T(n0) = T(1) = b = b * a0 +
−1
∑
aj * f(ck-j)
j =0
Induktionsschluss, k Æ k+1:
T(ck+1) = a * T(ck) + f(ck+1)
=(IA) a * ( b * ak +
k −1
∑
aj * f(ck-j)) + f(ck+1)
( f(ck+1) = a0 * f(ck+1) )
j =0
= b * ak+1 +
k
∑
aj * f(ck+1-j)
j =0
Wir interessieren uns für zwei spezielle Funktionen f(n):
f(n) = d * n und f(n) = d
Satz:
Es seien a, b ≥ 0, c > 1, n = ck, k ∈ N+ und d > 0. Dann gilt für die Lösung der Gleichung
T(1) = b,
T(n) = a * T(n/c) + d*n
die Abschätzung
{ O(n),
falls a < c
T(n) ∈ { O(n logcn) falls a = c
{ O(nlog c a)
falls a > c
(d.h. falls logca > 1)
Beweis:
Wir wenden den obigen Satz für f(n) = d*n an:
k −1
T(n) = b*ak + d*ck * ∑ (a/c)j
j =0
k −1
Es gilt:
∑
j =0
k −1
(a/c)j = ∑ qj = (1-qk) / (1 - q) für q = a/c ≠ 1
j =0
Wir unterscheiden nun 3 Fälle:
k −1
1. a < c: Dann gilt q = a/c < 1 und
∑
(a/c)j ≤ 1 / (1-q) unabhängig von k,
j =0
d.h. unabhängig von n. Dann gilt:
T(n) ≤ b*ak + d*ck * 1/(1-q) ≤ b*ck + d*ck * 1/(1-q)
= ck * (b+d/(1-q)) = n * (b+d/(1-q)) ∈ O(n)
- 12 -
k −1
2. a = c: Dann gilt
∑
j =0
(a/c)j =
k −1
∑
1=k
und somit
j =0
T(n) = b*ck + d*ck*k = ck*(b+d*k) = n*(b+d*logcn) ∈ O(n * logcn)
k −1
3. a > c: Dann gilt: q = a/c > 1 und
∑
(a/c)j = (qk-1) / (q-1) ≤ qk * 1/(q-1) und somit:
j =0
T(n) ≤ b*ak + d*ck + ak/ck * 1/(q-1) ≤ b*ak + d*ak * 1/(1-q) = ak * (b+d/(q-1))
=1 nlog c a * (b + d/(1-q) ∈ O(nlog c a)
Satz:
Es seien a, b ≥ 0, c > 1, n = ck, k ∈ N+ und d > 0. Dann gilt für die Lösung der Gleichung
T(1) = b,
T(n) = a * T(n/c) + d
die Abschätzung
{ O(logcn)
falls a = 1
log c a
T(n) ∈ { O(n
)
falls a ≠ 1
(= O(n) für a=c > 1)
Beweis:
siehe Skript (teilweise unleserlich)
1
log c (nlog c a) = logca * logcn = logcn * logca = logc(alog c n) => nlog c a = alog c n
- 13 -
Kapitel 2: Sortieren
Untersuchungen haben gezeigt, dass mehr als ein Viertel der kommerziell verbrauchten Rechenzeit auf
Sortiervorgänge entfällt.
Sortierproblem:
Gegeben ist eine Folge F von Datensätzen (engl.: items) s1, ..., sN. Jeder Satz si hat den Schlüssel ki.
Man finde eine Permutation π der Zahlen von 1 bis N derart, dass die Umordnung der Datensätze gemäß π die Schlüssel in aufsteigende Reihenfolge bringt:
kπ(1) ≤ kπ(2) ≤ ... ≤ kπ(N).
Die Ordnungsrelation auf den Schlüsseln kann sein:
- natürliche Ordnung auf (reellen) Zahlen z.B. bei Artikelnummern, Personalnummern ...
- alphabetische und lexikographische Ordnung auf Buchstaben, Strings z.B. bei Namen.
Größen zur Messung der Laufzeit von Sortierverfahren:
- Anzahl der ausgeführten Schlüsselwertvergleiche (engl.: comparisons)
Cmin(N), Cmax(N), Cmit(N)
- Anzahl der ausgeführten Bewegungen von Datensätzen (engl.: movements)
Mmin(N), Mmax(N), Mmit(N)
Für beide Parameter interessieren uns die im
- günstigsten Fall (engl.: best case, Index: min)
- schlechtesten Fall (engl.: worst case, Index: max)
- Mittel (engl.: average case, Index: mit)
erforderlichen Anzahlen.
Die Mittelwerte werden üblicherweise auf die Menge aller N! möglichen Ausgangsordnungen von N
zu sortierenden Datensätzen bezogen.
Interne Sortierverfahren: zu sortierende Datensätze sind vollständig im Hauptspeicher
Externe Sortierverfahren: Datensätze sind auf einem externen Speichermedium(Diskette, Platte, Band).
2.1 Mergesort
Mergesort (Sortieren durch Mischen) ist eines der ältesten und bestuntersuchtesten Verfahren zum
Sortieren mit Hilfe von Computern. John von Neumann hat es bereits 1945 vorgeschlagen.
Mergesort eignet sich auch besonders für das Sortieren von Daten auf Sekundärspeichern, das externe
Sortieren.
Algorithmus Mergesort
{sortiert die Folge F durch rekursives Teilen nach aufsteigenden Werten}
Falls F die Länge N=0 oder N=1 hat, so ist F bereits sortiert und bleibt unverändert. Sonst:
Divide: teile F in zwei etwa gleich große Teilfolgen F1 und F2.
- 14 -
Conquer: Sortiere F1 und F2 rekursiv mit Mergesort
Mergesort(F1) Æ F1’ sortiert
Mergesort(F2) Æ F2’ sortiert
Merge: Bilde die Resultatfolge durch Verschmelzen von F1’ und F2’. Lasse dazu je einen Positionszeiger (Index) durch die Teilfolgen F1’,F2’ so wandern: Bewege jeweils in einem Schritt denjenigen der
beiden Zeiger um eine Position weiter, der auf den kleineren Schlüssel zeigt.
Implementierung in Java:
public static void mergesort(int[] a, int l, int r) {
int i, j, k, m;
int[] b = new int[a.length];
if (r > l) {
/* Divide */
m = (l + r) / 2;
/* Conquer */
mergesort(a, l, m);
mergesort(a, m+1, r);
/* Merge */
// zuerst alle Werte ins Feld b kopieren:
// b = a[l],...,a[m],a[r],...a[m+1]
for (i = m; i >= l; i--)
b[i] = a[i];
for (j = m+1; j <= r; j++)
b[r + m + 1 - j] = a[j];
// dann der Größe nach die Werte zurückschreiben
i = l; j = r;
for (k = l; k <= r; k++) {
if (b[i] < b[j]) {
a[k] = b[i++];
} else {
a[k] = b[j--];
}
}
}
}
Beispiel (siehe Skript)
Aufwandsabschätzung:
Es gilt Cmin(N) = Cmax(N) = Cmit(N) := C(N), denn die Komplexität ist in diesem Fall unabhängig von
der Eingabe. Ebenso gilt Mmin(N) = Mmax(N) = Mmit(N) := M (N).
Rekursionsgleichung:
C(1) = 0
C(N) = 2 * C(N/2) + N
Folglich gilt: Cmin(N) = Cmax(N) = Cmit(N) ∈ Θ(N * log N).
M(1) = 0
M(N) = 2 * M(N/2) + 2 * N
Folglich gilt: Mmin(N) = Mmax(N) = Mmit(N) ∈ Θ(N * log N).
- 15 -
2.2 Quicksort
(entwickelt von C.A.R. Hoare (1962))
Quicksort ist erfahrungsgemäß (im Mittel) eines der schnellsten, wenn nicht das schnellste interne Sortierverfahren.
In-situ-Sortierverfahren: d.h. es wird zur (Zwischen-) Speicherung für die Datensätze kein zusätzlicher
Speicher benötigt, außer einer konstanten Anzahl von Hilfsspeicherplätzen für Tauschoperationen.
Algorithmus Quicksort
{ sortiert die Folge F durch rekursives Teilen nach aufsteigenden Werten }
Falls F die Länge N=0 oder N=1 hat, so ist F bereits sortiert und bleibt unverändert. Sonst:
Divide: Wähle ein Pivotelement k von F (z.B. das letzte) und teile F ohne k in Teilfolgen F1 und F2
bezüglich k: F1 enthält nur die Elemente von F ohne k, die ≤ k sind, F2 enthält die, die ≥ k sind.
Conquer: Sortiere F1 und F2 rekursiv mit Quicksort
Quicksort (F1) Æ F1’ sortiert
Quicksort (F2) Æ F2’ sortiert
Merge: Bilde die Ergebnisfolge F’ durch Hintereinanderhängen von F1’,k, F2’ in dieser Reihenfolge.
Beispiel:
- 16 -
Implementierung in Java:
public static void quicksort(int[] a, int links, int rechts) {
int l, r, pivot;
if (links < rechts) {
/* Divide */
pivot = a[rechts];
l = links;
r = rechts-1;
//Pivotelement v ist rechtestes Element
do {
while (a[l] < pivot && l < rechts) l++;
while (a[r] > pivot && r > links) r--;
if (l < r)
vertausche(a, l, r);
} while (l < r);
vertausche(a, l, rechts);
//Pivotelement an die richtige Position
/* Conquer */
quicksort(a, links, l-1);
quicksort(a, l+1, rechts);
}
}
/** Vertauschen von Feldelementen */
public static void vertausche(int[] a, int i, int j) {
int v = a[i];
a[i] = a[j];
a[j] = v;
}
Analyse des Aufwandes im besten bzw. schlechtesten Fall:
i.
Im günstigsten Fall haben die durch Aufteilung entstehenden Teilfolgen stets etwa die gleiche Länge. Dann ist die Rekursionstiefe genau von der Größenordnung log2N. Da auf jeder
Rekursionsstufe zur Aufteilung Θ(N) Schlüsselvergleiche durchgeführt werden, gilt:
Cmin(N) ∈ Θ(N * log N)
ii.
Ist das Pivotelemet das Element mit kleinstem oder größten Schlüssel, so ist eine der beiden
durch Aufteilung entstehenden Teilfolgen jeweils leer und die andere hat jeweils genau ein
Element (nämlich das Pivotelement) weniger als die Ausgangsfolge.
=> Tiefe des Aufrufbaums: N-1.
Damit ist klar, dass gilt: Cmax(N) ≥
N −1
∑
k , d.h. Cmax(N) ∈ Ω(N2)
k =1
Ebenso kann man sich überlegen: . Mmax(N) ∈ Ω(N2)
(Beispiele siehe Skript)
Analyse der mittleren Laufzeit:
Annahmen:
1. Alle N Schlüssel k1, ..., kN sind paarweise verschieden, o.B.d.A. seien die Schlüssel die Zahlen
1, ..., N (in beliebiger Reihenfolge)
2. Wir betrachten alle N! möglichen Anordnungen der N Schlüssel als gleichwahrscheinlich.
- 17 -
Folgerung:
Jede der Zahlen k∈<1,N> tritt mit gleicher Wahrscheinlichkeit 1/N als Pivotelement an Position N auf.
Es werden bei Pivotelement k durch Aufteilung zwei Folgen mit den Längen k-1 und N-k erzeugt.
Auch diese sind wieder „zufällig“.
Rekursionsformel für den Erwartungswert:
T(1) = 0
T(N) ≤ 1/N *
N
∑ ( T(k-1) + T(N-k) ) + b * N
k=1
für eine geeignete Konstante b. (b * N: Aufteilungsaufwand)
Wir wollen nun zeigen, dass dann für c = 2 * b gilt:
T(N) ≤ c * N * logeN, für alle N ≥ 1
Beweis:
T(N) ≤ 2/N *
N−1
∑T(k) + b * N
k=0
N−1
= 2/N *
∑T(k) + b * N , wegen T(0) = 0
k=1
vollständige Induktion nach N:
sei c = 2 * b
Induktionsanfang N=1
T(1) = 0 = 2 * b * 1 * loge1 (loge1 = 0)
Induktionsschritt N-1 Æ N (N ≥ 2)
T(N) ≤ 2/N *
N−1
∑T(k) + b * N
k=1
≤(IA) 2/N *
N−1
∑(c * k * logek) + b * N
k=1
Sei nun f(x) = x * logex
Die Funktion f hat folgende Eigenschaften:
a) f(x) ist monoton wachsend in [1,∞)
b) f(x) ≥ 0 für x ∈ [1,∞)
Damit gilt:
N−1
N
∑(k * logek) ≤ ∫
k=1
N
2
x*logex dx = [x /2 *
logex]N2
2
-
∫
2
= N2/2 * logeN - 2 * loge2 - [x2/4]N2
≤ N2/2 * logeN - N2/4 + 1 - 2*loge2
≤ N2/2 * logeN - N2/4
- 18 -
x2/2 * 1/x dx
Folglich gilt:
T(N) ≤ 2c/N * [ N2/2 * logeN - N2/4 ] + b * N
= c*N*logeN + N*(b - c/2)
= c*N*logeN
Analog gilt auch für die mittlere Anzahl von Schlüsselvergleichen Cmit(N) ∈ O(N * log N)
2.3 Untere Schranken für das Sortierproblem
Satz (Worst-Case)
Jedes Sortierverfahren, das ausschließlich die vollständige Ordnung auf dem Wertebereich W ausnutzt,
benötigt im Worst-Case größenordnungsmäßig mindestens N * log2N Vergleiche
( d.h. Cmax(N) ∈ Ω(N * log2N) ) zum Sortieren von N Werten k1, ..., kN ∈ W.
Zum Beweis beschränken wir uns auf die Folgen F =( k1, ..., kN) ∈ WN. mit ki ≠ kj für alle i ≠ j, d.h.
paarweise verschiedenen Werten ki. D.h. wir zeigen obige Behauptung bereits für diese Teilmenge
aller möglichen Eingabefolgen.
Definition Entscheidungsbaum:
Ein Entscheidungsbaum (engl.: decision tree) ist ein bewerteter binärer Wurzelbaum B=(T, b)
mit T=(V, R):
- V: Knotenmenge von T,
- R ⊆ V x V: Kantenmenge von T,
- b: R Æ {ja, nein}: Kantenbewertung.
Jeder Knoten v ∈ V, der nicht Blatt ist, repräsentiert eine Entscheidung (hier einen Vergleich „w<w’?“
zweier Werte w, w’ ∈ W).
Jeder Pfeil r=(v,v’) ∈ R repräsentiert das Ergebnis der Entscheidung (ja,nein) an seiner Anfangsecke v.
Jedes Blatt v~ repräsentiert das Ergebnis des Entscheidungsprozesses längs des Weges von der Wurzel
v0 bis zu v~, d.h. eine Partialordnung.
Beispiel:
a, b, c ∈ W paarweise verschieden
v0: Knoten, Wurzel des Baumes
(v0,v1): Kante/Pfeil, b((v0,v1)) = ja
(v0,v1,v2,v~): Pfad von der Wurzel v0 zu v~
v~: Knoten, Blatt des Baumes
- 19 -
In einem binären Wurzelbaum hat jeder Knoten, der nicht Blatt ist, zwei Nachfolger; jedes Blatt hat
keinen Nachfolger.
Jeder Sortieralgorithmus, der ausschließlich die vollständige Ordnung auf der Wertemenge W ausnutzt
und sequentiell Wertepaare vergleicht, induziert einen solchen Entscheidungsbaum. Die maximale
Anzahl der Vergleiche Cmax(N) entspricht dann der Höhe hT des induzierten Entscheidungsbaumes.
Für eine Eingabefolge F=( k1, ..., kN) aus paarweise verschiedenen Werten sind N! Permutationen
(ki1, ..., kiN) als Ergebnisse möglich, d.h. der induzierte Entscheidungsbaum muss mindestens N! Blätter haben.
Ein binärer Wurzelbaum der Höhe hT hat maximal 2hT Blätter.
Deshalb muss gelten: 2hT ≥ N!, d.h. hT ≥ log2(N!).
Da N! ≥ N * (N-1) * ... * N/2 ≥ (N/2)N/2 gilt weiter:
hT ≥ log2(N!) ≥ log2(N/2)N/2 = N/2 * log2(N/2) = N/2 * (log2N - 1)
Also gilt: Cmax(N) ∈ Ω(N * log2N)
Satz (Average-Case)
Jedes Sortierverfahren, das ausschließlich die vollständige Ordnung auf dem Wertebereich W ausnutzt,
benötigt im Mittel bei Gleichverteilungsannahme größenordnungsmäßig mindestens N*log2N Vergleiche (d.h. Cmit(N)∈Ω(N*log2N)) zum Sortieren von N paarweise verschiedenen Werten k1, ..., kN∈ W.
Zum Beweis benutzen wir wieder den induzierten Entscheidungsbaum T, welcher bekanntlich mindestens N! Blätter haben muss.
Der Erwartungswert Cmit(N) für die Anzahl der Vergleiche lässt sich nun wie folgt ausdrücken:
Cmit(N) = 1/N! *
∑
h(v)
v . Blatt . von .T
Für einen binären Wurzelbaum T heißt
H(T) =
∑
h(v)
v . Blatt . von .T
die Blätterhöhensumme von T.
(Im Beispiel war H(T) = 16)
Sei nun H(n) = min{H(T) | T ist binärer Wurzelbaum mit n Blättern }.
dann wollen wir H(n) nach unten abschätzen, und zeigen:
H(n) ≥ n * log2n für alle n ∈ N+.
- 20 -
Induktion nach n:
n=1: H(1) = 0 ≥ 1 * log21
n=2: H(2) = 2 ≥ 2 * log22
Sei nun n>2 und die Behauptung bereits bewiesen für alle i ≤ n-1. Wir betrachten einen binären Wurzelbaum T0 mit n Blättern und mit minimaler Blätterhöhensumme H(T0) = H(n).
Sei v0 die Wurzel von T0, Tl(v0) der linke Teilbaum und Tr(v0) der rechte Teilbaum von v0:
Wegen der Minimalität von T0 sind auch Tl(v0) und Tr(v0) minimal zur Blätterzahl i bzw. n-i.
Folglich haben wir:
H(n) = min(i∈<1,n-1>) ( n + H(i) + H(n-i) )
≥(IV) n + min(i∈<1,n-1>) ( i*log2i + (n-i) * log2(n-i))
Wir betrachten nun die Funktion
f(x) = x * log2x + (n-x) * log2(n-x), für x ∈ [1,n).
f’(x) = 1 * log2x + x * 1/x + (log2(n-x) + 1) * (-1)
= log2x - log2(n-x).
f’(x) = 0 Ù log2x = log2(n-x) Ù x = n-x Ù x = n/2
Für x < n/2 gilt f’(x) < 0, für x > n/2 gilt f’(x) > 0.
Folglich hat f(x) ein globales Minimum bei x=n/2
(Grafik im Script Seite 48)
Daraus ergibt sich für den Erwartungswert Cmit(n) folgendes:
Cmit(N)= 1/N! *
∑
h(v)
v . Blatt . von .T
= 1/N! * H(T)
≥ 1/N! * H(N!) ≥ 1/N! * N! * log2(N!)
≥ N/2 * (log2N -1)
Also gilt: Cmit(N) ∈ Ω(N * log2N).
- 21 -
2.4 Heaps und Heapsort
Benötigt man aus einer vorgegebenen Menge wiederholt das minimale Element, z.B. bei einer Prioritätswartschlange, so bietet sich folgende Datenstruktur zur Speicherung der dynamisch veränderlichen
Menge an:
Definition (Heap, Haufe, Halde)
Ein Feld a[1..N] mit Komponentenwerten a[i], 1 ≤ i ≤ N, aus einem vollständig geordneten Wertebereich W heißt Heap, falls gilt:
1) a[i] ≤ a[2i], für 1 ≤ i ≤ ⎣N/2⎦
2) a[i] ≤ a[2i+1], für 1 ≤ i ≤ ⎡N/2⎤
Beispiel: N=10
a:
4 10 6
i
20
2i
15 7
2i+1
12
25
26
18
Ein Heap realisiert implizit eine partielle Ordnung, nämlich einen vollständigen binären Wurzelbaum:
ai = a[i], ein Pfeil von ai nach aj bedeutet ai ≤ aj
N=10:
Man erkennt, dass a[1] das minimale Element des Heaps ist:
a[1] ≤ a[i] für 1 ≤ i ≤ N.
Im folgenden werden Java-Programme angegeben für
- das Einfügen eines Elementes in einen Heap,
- das Löschen eines Elementes in einem Heap,
- den Aufbau eines Heaps,
- das Sortieren mit Hilfe von Heaps.
- 22 -
Implementierung in Java:
public class Heapsort {
/** Array mit Heap */
int[] a;
/** Größe des Heaps */
int N;
/**
* Tauscht die Elemente a[i] und a[j].
*/
void exchange (int i, int j) {
int temp = a[i];
a[i] = a[j];
a[j] = temp;
}
/**
* Lässt a[k] im Feld aufsteigen.
*/
void upheap (int k) {
while ( (k > 1) && (a[k] < a[k/2]) ) {
exchange(k, k/2);
k = k / 2;
}
}
/**
* Fügt das neue Element v in den Heap der Größe N
* ein und erhöht N um 1
*/
void insert (int v) {
N++;
a[N] = v;
upheap(v);
}
/**
* Lässt a[k] im Feld versickern
*/
void downheap(int k) {
int j = 2 * k;
if (j <= N) {
if (j + 1 <= N)
if (a[j] > a[j + 1])
j++;
if (a[k] > a[j]) {
exchange(k, j);
downheap(j);
}
}
}
// a[k] hat linken Sohn a[j]
// a[k] hat auch rechten Sohn a[j + 1]
// jetzt ist a[j] der kleinere Sohn von a[k]
- 23 -
/**
* Liefert als Resultat das Heapelement a[k], entfernt a[k]
* aus dem Heap und stellt die Heap-Eigenschaft wieder her.
*/
int remove(int k) {
int v = a[k];
a[k] = a[N];
N--;
if ( (k > 1) && a[k] < a[k / 2] )
upheap(k);
else
downheap(k);
return v;
}
/**
* Aufbau des Heaps durch downheap
*/
void heapaufbau() {
for (int k = N/2; k >= 1; k--)
downheap(k);
}
/**
* Aufbau des Heaps durch insert
*/
void heapaufbau2() {
int m = N;
N = 0;
for (int k = 1; k <= m; k++)
insert(a[k]);
}
/**
* Sortiert das gegebene Feld b mit dem oben angegebenen
* Heap-Methoden und gibt das sortierte Feld zurück.
*/
int[] heapsort(int[] b) {
a = b;
N = b.length;
//Globale Variablen für die Heap-Methoden setzen
int m = N;
heapaufbau();
int[] c = new int[m];
for (int k = 1; k <= m; k++)
c[k] = remove(1);
return c;
}
}
- 24 -
Beispiel: N=10
Einfügen von v = 5 liefert:
Löschen an der Stelle k = 3, d.h. a[k] = 6 liefert:
Aus einem Heap erhält man eine Sortierung:
Heap:
Æ löschen von 4:
Æ löschen von 6:
Æ löschen von 10:
Æ löschen von 15:
Æ löschen von 20: LEERER HEAP
- 25 -
Komplexität des Heap-Aufbaus:
(„heapaufbau1“ mit downheap)
Ein Heap mit j Stufen speichert zwischen 2j-1 und 2j-1 Schlüssel:
2j-1 ≤ N ≤ 2j-1
Deshalb gilt j = ⎡log2(N+1)⎤.
Auf Stufe k gibt es höchstens 2k-1 Schlüssel.
Die Anzahl der Bewege- und Vergleichsoperationen zum Versickern (downheap) eines Elements der
Stufe k ist proportional zu j-k.
j−1
∑ 2k-1 * (j-k) = 2j-1 *
k=1
j−1
∑ 2k-j * (j-k) = 2j-1 *
k=1
j−1
∑ k/2k
≤ N * 2 ∈ O(N)
k=1
Der Aufbau eines Heaps aus einer unsortierten Folge ist also in linearer Zeit möglich. (mittels der Routine „heapaufbau“).
(Die Routine „heapaufbau2“ benötigt dagegen im schlechtsten Fall größenordnungsmäßig mindestens
N * log2N Operationen.)
Komplexität von Heapsort:
Die Anzahl der Bewege- und Vergleichsoperationen zum Löschen (remove) der Wurzel in einem Heap
der Größe N ist proportional zu log2N.
Daraus ergibt sich für das N-malige Löschen bei Heapsort ein Aufwand der proportional ist zu
N
∑ log2k
= log2(N!) ∈ O(N * log2N)
k=1
Dies dominiert den Aufwand zum Aufbau des Heaps.
Die Komplexitäten von Heapsort sind also im Worst-Case:
Cmax(N) ∈ O(N * log2N),
Mmax(N) ∈ O(N * log2N).
2.5 Elementare Sortierverfahren
Sortieren durch Auswahl (Selection-Sort)
Man bestimme sukzessive für alle i ∈ <1, N> diejenige Position j ∈ <1, N>, an der das Element mit
minimalem Schlüssel unter den Elementen a[i], ..., a[N] auftritt und vertausche a[i] mit a[j].
void selectionSort(int[] a) {
int i, j, min;
for (i = 0; i < a.length - 1; i++) {
min = i;
for (j = i + 1; j < a.length; j++)
if (a[j] < a[min])
min = j;
exchange(a, i, min);
}
}
Komplexitäten:
Cmin(N), Cmax(N), Cmit(N) ∈ O(N2)
Mmin(N) = Mmax(N) = Mmit(N) = 3*(N-1)
- 26 -
Sortieren durch Einfügen (Insertion-Sort)
Man füge sukzessive für alle i ∈ <1, N> das i-te Feldelement a[i] an der richtigen Stelle in die bereits
sortierte Folge der Elemente a[1], ..., a[i-1] ein. Das verlangt das verschieben von j ∈ <0, i-1> größeren Elementen um jeweils eine Position nach rechts.
void insertionSort(int[] a) {
int i, j, v;
for (i = 1; i < a.length; i++) {
v = a[i]; j = i;
while ( (j > 0) && (a[j-1] > v) ) {
a[j] = a[j-1];
j--;
}
a[j] = v;
}
}
Komplexitäten:
Cmin(N) = N-1,
Cmax(N) ∈ Θ(N2)
Mmin(N) = 2*(N-1), Mmax(N) ∈ Θ(N2)
Sortieren durch (direktes) Austauschen (Bubble-Sort)
Man durchläuft wiederholt die Liste der Datensätze a[1], ..., a[i] und betrachtet dabei je zwei benachbarte Elemente a[j-1] und a[j], mit 2 ≤ j ≤ i. Ist a[j-1] > a[j], so vertauscht man a[j-1] und a[j].
void bubbleSort(int[] a) {
for (int i = a.length-1; i >= 0; i--)
for (int j = 1; j <= i; j++)
if (a[j-1] > a[j])
exchange(a, j-1, j);
}
Große Elemente haben also die Tendenz, wie Luftblasen im Wasser langsam nach oben aufzusteigen.
Komplexitäten:
Cmin(N), Cmax(N), Cmit(N) ∈ Θ(N2)
Mmin(N) = 0, Mmax(N), Mmit(N) ∈ Θ(N2)
Testet man nach jedem Durchlauf der äußeren for-Schleife, ob sich noch eine Veränderung ergeben
hat, so kann man, falls dies nicht der Fall ist, schon früher stoppen.
(Beispiel siehe Skript Seite 65)
Dann erhält man Cmin(N) = N-1
Die restlichen Komplexitäten ändern sich nicht.
- 27 -
Kapitel 3: Dynamische Datenstrukturen
3.1 Lineare Listen
Eine Folge L =(a1, ..., an) kann implementiert werden als eine lineare Liste von Knoten von folgendem
Typ:
public class Node {
int key;
Node next;
Node(int k) {
key = k;
next = null;
}
}
Diese Klassendefinition zeigt an, dass ein Knoten aus zwei Komponenten besteht:
- die key - Komponente ist eine ganze Zahl
- die next - Komponente ist ein Verweis auf einen (anderen) Knoten.
Veranschaulichung:
Bereitstellen von Speicherplatz für ein Listenelement durch die Konstruktorfunktion:
Node x = new Node(v);
Die Konstruktorfunktion liefert als Resultat einen Verweis auf einen freien Speicherplatz für
ein Listenelement.
Implementierung für lineare Listen:
- Kopfzeiger head,
- Anzahl count der gespeicherten Elemente.
Die leere Liste ist gegeben durch den Verweis head = null und count = 0.
Sie wird durch folgende Konstruktorfunktion erzeugt:
List() {
head = null;
count = 0;
}
Zum Einfügen eines neuen Elements „v“ hinter dem Knoten „t“ einer linearen Liste verwenden wir die
Funktion „insert“.
- 28 -
Zum Entfernen eines Knotens „t“ aus einer linearen Liste verwenden wir die Funktion „remove“.
Falls „t“ nicht der Kopf der Liste ist, so bestimmen wir den Vorgängerknoten „x“ von „t“ und setzen
dessen Verweis auf den Nachfolgerknoten „t’“ von „t“.
Zum Verketten zweier linearer Listen L1 und L2 gegeben durch head1, count1 und head2, count2 verwenden wird die Funktion „concat“.
Um das Entfernen von Listenelementen bei gegebener Position möglichst einfach ausführen zu
können, kann man zu jedem Listenelement nicht nur einen Verweis „next“ auf das nächstfolgende,
sondern auch einen Verweis „prior“ auf das vorhergehende Listenelement abspeichern.
( Æ doppelt verkette Speicherung)
public class DoubleNode {
int key;
DoubleNode next, prior;
DoubleNode(int k) {
key = k;
next = prior = null;
}
}
Bei doppelt verketteter Speicherung können das Entfernen und das Verketten einfacher
realisiert werden.
Wir haben die nach dem Entfernen nicht mehr benötigten Knoten nicht zur neuen - und eventuell
anderen - Verwendung explizit freigegeben, sondern sie nur aus der Liste durch Umlegen von Verweisen entfernt.
Man muss in C diese Knoten explizit freigeben. In JAVA werden Elemente, auf die keine Verweise
mehr existieren vom Garbage-Kollektor automatisch aus dem Speicher entfernt.
- 29 -
/** Ein Listen- bzw. Stackelement. */
public class Node {
/** Schlüsselwert */
int key;
/** Das nächste Element */
Node next;
/**
* Ein einfachern Konstruktor mit Schlüssel.
*/
Node(int k) {
key = k;
next = null;
}
/**
* Liefert eine Textausgabe des Objektes, siehe
* {@link java.lang.Object#toString}
*/
public String toString() {
return "[" + key + "]";
}
}
/** Ein Listenelement */
public class DoubleNode {
/** Schlüsselwert */
int key;
/** Das nächste Element */
DoubleNode next;
/** Das vorhergehende Element */
DoubleNode prior;
/**
* Ein einfacher Konstruktor mit Schlüssel.
*/
DoubleNode(int k) {
key = k;
next = prior = null;
}
}
/**
* Eine einfach verkettete lineare Liste.
*/
public class List {
/** Das erste Element der Liste */
Node head;
/** Die Anzahl der gespeicherten Elemente */
int count;
/**
* Konstruktor für eine leere Liste
*/
List() {
head = null;
count = 0;
}
- 30 -
/**
* Fügt einen neuen Knoten mit Schlüssel v nach dem
* Knoten t in die Liste ein.
*/
void insert(int v, Node t) {
Node x = new Node(v);
count++;
if (t == null) {
//Einfügen am Anfang
x.next = head;
head = x;
} else {
x.next = t.next;
t.next = x;
}
}
/**
* Hängt einen neuen Knoten mit Schlüssel v an das Ende
* der Liste an (unter Benutzung von insert).
*/
void add(int v) {
Node last = head;
if (last == null) {
insert(v, null);
} else {
while (last.next != null) //Letztes Listenelement suchen
last = last.next;
insert(v, last);
}
}
/**
* Löscht den Knoten aus der Liste.
*/
void remove(Node t) {
count--;
if (t == head) {
head = t.next;
} else {
Node x = head;
while (x.next != t)
x = x.next;
x.next = t.next;
}
}
/**
* Hängt die Listen von head1 und head2 aneinander und
* speichert sie in dieser Liste.
*/
void concat(Node head1, int count1, Node head2, int count2) {
count = count1 + count2;
if (head1 == null) {
head = head2;
} else {
head = head1;
Node x = head1;
while (x.next != null)
x = x.next;
x.next = head2;
}
}
- 31 -
/** Liefert eine Textausgabe des Objektes */
public String toString() {
String s = getClass().getName() + "[count=" + count + ",{";
for (Node i = head; i != null; i = i.next) {
s += i.toString() + "->";
}
s += "null}]";
return s;
}
/** Kleines Testprogramm. */
public static void main(String[] args) {
List l = new List();
for (int i = 0; i < args.length; i++) {
l.add(Integer.parseInt(args[i]));
}
System.out.println(l);
}
}
/** Eine doppelt verkettete lineare Liste.*/
public class DoubleList {
/** Das erste Element der Liste */
DoubleNode head;
/** Die Anzahl der gespeicherten Elemente */
int count;
/** Konstruktor für eine leere Liste */
DoubleList() {
head = null;
count = 0;
}
void insert(int v, DoubleNode t) {
DoubleNode x = new DoubleNode(v);
count++;
if (t == null) { //Einfügen am Anfang
x.next = head;
x.prior = null;
head.prior = x;
head = x;
} else {
x.next = t.next;
x.prior = t;
t.next = x;
x.next.prior = x;
}
}
void remove (DoubleNode t) {
count--;
if (t.prior != null)
t.prior.next = t.next;
if (t.next != null)
t.next.prior = t.prior;
if (t == head)
head = t.next;
}
}
- 32 -
3.2 Schlangen
Eine Schlange (engl.: queue) ist eine lineare Liste, bei der das Einfügen und Entfernen von Listenelementen auf die beiden extremalen Listenelemente (d.h. Listenanfang und Listenende) beschränkt ist.
Bezeichnungen:
pushhead(v), pushtail(v): fügt das Element v am Anfang bzw. Ende der Schlange ein.
v = pophead(), v = poptail(): entfernt das erste bzw. letzte Element der Schlange und gibt seinen Wert
als Resultat zurück.
undefiniert, falls die Schlange leer ist.
v = top(): gibt das erste Element der Schlange als Resultat zurück.
ebenfalls undefiniert, falls die Schlange leer ist.
empty(): testet ob die Schlange leer ist.
init(): erzeugt eine Leere Schlange
3.3 Stapel
Ein Stapel (oder Keller; engl.: stack) ist eine lineare Liste, bei der das Einfügen und Entfernen eines
Elements auf den Listenkopf beschränkt ist.
Realisierung mit verketteten Listen.
/**
* Stack-Klasse
*/
public class Stack {
/** Oberster Knoten des Stacks */
Node top;
/**
* Konstruktor. Initialisiert den Stack (leer).
*/
Stack() {
top = null;
}
/**
* Legt ein neues Element auf den Stack
*/
void push(int v) {
Node t = new Node(v);
t.next = top;
top = t;
}
- 33 -
/**
* Liefert das oberste Element des Stacks
*/
int top() {
return top.key;
}
/**
* Liefert das oberste Element des Stacks und
* löscht dieses vom Stack
*/
int pop() {
int v = top.key;
top = top.next;
return v;
}
/**
* Liefert true, falls der Stack leer ist.
*/
boolean empty() {
return (top == null);
}
/**
* Liefert eine Textausgabe des Objektes, siehe
* {@link java.lang.Object#toString}
*/
public String toString() {
String s = getClass().getName() + "[";
for (Node i = top; i != null; i = i.next) {
s += i.toString() + "<-";
}
s += "null]";
return s;
}
/**
* Kleines Testprogramm.
*/
public static void main (String[] args) {
Stack s = new Stack();
for (int i = 0; i < args.length; i++) {
s.push(Integer.parseInt(args[i]));
}
System.out.println(s);
}
}
Anwendung:
Speicherung der Rücksprungadressen bei geschachtelten Unterprogrammaufrufen
Beim Sprung zum Unterprogramm wird die aktuelle Adresse A auf den Stapel gelegt ( push(A) ).
Beim Rücksprung wird die ehemals aktuelle Adresse des aufrufenden Programms vom Stapel geholt
( A = pop() )
(Beispiel siehe Skript Seite 77)
- 34 -
3.4 Geordnete binäre Wurzelbäume
Beispiel:
Knoten: 4
Kante: (4,5) => 5 ist Nachfolger von 4
Ein Wurzelbaum T = (V,E) besteht aus
- einer Menge V von Knoten, und
- einer Menge E von Kanten, E ⊆ V x V
so dass es einen eindeutigen Knoten, genannt Wurzel gibt, von
dem alle Knoten über jeweils eindeutige Wege erreichbar sind.
Ein Weg von v1 nach vn ist dabei eine Folge (v1, v2, ..., vn) von Knoten,
so dass für je zwei aufeinanderfolgende Knoten vi und vi+1 eine
Kante (vi, vi+1) ∈ E existiert.
(Merke: (v) kann als Weg von v nach v aufgefasst werden!)
Für einen Knoten v ∈ V bezeichnet
V(v) = {v’ ∈ V | (v’, v) ∈ E}
N(v) = {v’ ∈ V | (v, v’) ∈ E}
die Menge der Vorgänger bzw. der Nachfolger von v in T.
Offensichtlich gilt in einem Wurzelbaum:
∀v ∈ V: |V(v)| =
{ 1, falls v nicht die Wurzel ist,
{ 0, falls v die Wurzel ist.
Ein Wurzelbaum heißt binär, falls jeder Knoten höchstens zwei Nachfolger hat: ∀v ∈ V: |N(v)| ≤ 2.
Ein binärer Wurzelbaum heißt geordnet, falls für jeden Knoten v ∈ V eine injektive Abbildung
Ov: N(v) Æ {L, R}
auf seiner Nachfolgermenge gegeben ist.
- v’ ∈ V mit Ov(v’) = L heißt linker Sohn
- v’’ ∈ V mit Ov(v’’) = R heißt rechter Sohn
von v.
Baumdarstellung mittels einer Zeigerstruktur:
Ein Baum kann also durch die Angabe eines Zeigers „Wurzel“ auf den Wurzelknoten angegeben werden. Die Zeiger lSon bzw. rSon werden in einem Knoten auf „null“ gesetzt, wenn die entsprechenden
Söhne nicht existieren.
Für die Klasse BinTree wird ein Stack benötigt, der BinTreeNodes oder allgemein Objects speichern
kann (im Gegensatz zu dem oben erstellten Stack für ints.)
Falls eine Stack-Implementierung mit Objects gewählt wird, muss in der Methode wlr_durchlauf die
Zeile t = s.pop() durch t = (BinTreeNode)s.pop() ersetzt werden.
- 35 -
/**
* Knoten eines binären Baumes
*/
public class BinTreeNode {
/** Schlüsselwert */
int key;
/** Linker Sohnknoten */
BinTreeNode lson;
/** Rechter Sohnknoten */
BinTreeNode rson;
/**
* Konstruktor.
*/
BinTreeNode(int v) {
key = v;
lson = rson = null;
}
/**
* Gibt die Anzahl der Söhne des Knotens zurück (0, 1 oder 2)
*/
int countSons() {
if (lson == null)
return (rson == null)?0:1;
else
return (rson == null)?1:2;
}
/**
* Liefert true, falls der Knoten ein Blatt ist, d.h. keine Kinder hat
*/
boolean isLeaf() {
return (lson == null) && (rson == null);
}
/**
* Liefert true, falls der Knoten keinen Sohn auf der
* Seite hat, in der der Schlüssel s zu suchen wäre
*/
boolean isLeaf(int s) {
return ( (key > s) && (lson == null) )
|| ( (key < s) && (rson == null) );
}
/**
* Liefert eine Textausgabe des Objektes, siehe
* {@link java.lang.Object#toString}
*/
public String toString() {
return "[" + key + "]";
}
}
- 36 -
/**
* Binärer Baum
*/
public class BinTree {
/**
* Baumdurchlauf in LWR-Ordnung
*/
void lwr_durchlauf(BinTreeNode t) {
if (t != null) {
lwr_durchlauf(t.lson);
System.out.println(t);
lwr_durchlauf(t.rson);
}
}
/**
* Generische Methode zum Baumdurchlauf entsprechend String id
* (ist von der Form "LWR", "WLR" oder "LRW")
*/
void durchlauf(String id, BinTreeNode t) {
if (t != null) {
for (int i = 0; i < id.length(); i++) {
switch (id.charAt(i)) {
case 'L': durchlauf(id, t.lson); break;
case 'R': durchlauf(id, t.rson); break;
case 'W': System.out.println(t); break;
}
}
}
}
/**
* Nicht-rekursiver Baumdurchlauf in WLR-Ordnung
*/
void wlr_durchlauf(BinTreeNode t) {
Stack s = new Stack();
while (t != null) {
System.out.println(t);
if (t.rson != null) s.push(t.rson);
if (t.lson != null) {
t = t.lson;
} else {
if (!s.empty())
t = s.pop();
else
t = null;
}
}
}
}
- 37 -
Ordnungen und Durchlaufprinzipien
1) Inordnungen LWR, RWL
Beim Durchlaufen der Knoten in LWR- bzw. RWL-Ordnung wird zunächst
- der linke bzw. rechte Teilbaum, dann
- die Wurzel, und dann
- der rechte, bzw. linke Teilbaum
durchlaufen.
Im Beispiel erhalten wir:
LWR = (7, 5, 6, 8, 11, 9, 4, 3, 10, 2, 1)
RWL = (1, 2, 10, 3, 4, 9, 11, 8, 6, 5, 7)
Die Ordnungen LWR und RWL sind stets invers zueinander, d.h. ist LWR = (v1, ..., vn), so ist
RWL = (vn, ..., v1).
2) Randordnungen
a) Präordnungen WLR, WRL
Hier wird die Wurzel vor den beiden Teilbäumen durchlaufen.
Im Beispiel:
WLR = (4, 5, 7, 8, 6, 11, 9, 3, 2, 10, 1)
WRL = (4, 3, 2, 1, 10, 5, 8, 11, 9, 6, 7)
b) Postordnungen LRW, RLW
Hier werden die beiden Teilbäumen vor der Wurzel durchlaufen.
Im Beispiel:
LRW = (7, 6, 9, 11, 8, 5, 10, 1, 2, 3, 4)
RLW = (1, 10, 2, 3, 9, 11, 6, 8, 7, 5, 4)
Offenbar sind WLR und RLW bzw. WRL und LRW jeweils zueinander invers.
Lemma: (Durchlaufordnungen)
i)
Ein geordneter binärer Wurzelbaum ist eindeutig bestimmt durch die Angabe einer
Inordnung zusammen mit einer Randordnung. z.B. durch LWR und WLR.
ii)
Die Angabe zweier Inordnungen bzw. zweier Randordnungen reicht für die eindeutige
Charakterisierung eines geordneten Wurzelbaumes i.a. nicht aus. z.B. reichen LWR und
RWL nicht aus, und ebenso reichen WLR und LRW nicht aus.
Beweis:
i)
Wegen der Äquivalenz von LWR und RWL bzw. von WLR und RLW reicht es aus den Fall zu
betrachten, dass die In-Ordnung LWR ist und die Rand-Ordnung WLR.
Wir zeigen die Behauptung durch vollständige Induktion über die Eckenzahl n des Baums.
n = 1: trivial.
n Æ n+1:
Sei die Behauptung für alle m-eckigen Bäume mit 1 ≤ m ≤ n bereits gezeigt.
- 38 -
Wir betrachten nun einen (n+1) -eckigen Baum T. Die Wurzel von T ist offenbar das erste Element w
in der WLR-Ordnung.
Für die beiden Teilbäume der Wurzel - welche eventuell auch leer sein können - kann nun wie folgt
die LWR-Ordnung und die WLR-Ordnung aus den Ordnungen des gesamten Baumes eindeutig
bestimmt werden:
a) Die LWR-Folge kann mittels w zerlegt werden in (f1 w f2), so dass f1 bzw. f2 die LWR-Folge
für den linken bzw. den rechten Teilbaum von w ist.
b) Da man jetzt weiß, welche Elemente im linken bzw. rechten Teilbaum von w liegen, kann man
nun auch die WLR-Folge zerlegen in (w g1 g2), so dass g1 bzw. g2 die WLR-Folge für den linken bzw. rechten Teilbaum von w ist.
Dann können wir die Induktionsannahme auf die beiden Teilbäume von w bzw. deren Folgen f1 und g1
bzw. f2 und g2 anwenden (denn die beiden Teilbäume haben jeweils maximal n Knoten).
Also kann der gesamte Baum eindeutig rekonstruiert werden.
(Beispiel siehe Skript Seite 87)
ii)
Folgende Bäume haben sowohl die gleiche WLR - als auch die gleiche LRW-Ordnung:
WLR = (1, 2, 3, 4)
LRW = (3, 2, 4, 1)
- 39 -
Kapitel 4: Suchen in Folgen
4.1 Sequentielle Suche
Das Verfahren zur sequentiellen Suche sucht in einer Liste L = (a1, ..., aN) von Schlüsseln nach einem
Element am mit dem Suchschlüssel v, d.h. am = v bei erfolgreicher Suche.
Man vergleicht der Reihe nach die Elemente aN, aN-1, ..., a1 mit dem Suchschlüssel.
Um nicht immer prüfen zu müssen, ob bereits alle Listenelemente inspiziert wurden, verwendet man v
selbst als Stopper an Position 0. Bei erfolgloser Suche ist das Resultat 0.
int Sequentielle_Suche(int[] a, int v) {
int m = a.length;
a[0] = v;
while (a[m] != v)
m--;
return m;
}
Worst-Case-Komplexität: Cmax(N) = N+1 ∈ Θ(N)
Average-Case-Komplexität:
Wir nehmen an, dass die Schlüssel in L paarweise verschieden sind und dass jeder Schlüssel in L mit
der gleichen Wahrscheinlichkeit p/N der gesuchte Schlüssel ist. D.h. die Wahrscheinlichkeit für die
erfolgreiche Suche ist p ∈ [0,1], und 1-p ist die Wahrscheinlichkeit für erfolglose Suche.
Dann erhalten wir:
Cmit(N) =
N
N
i=1
i=1
∑ p/N * i + (1-p) * (N+1) = p/N * ∑ i + (1-p) * (N+1) = p/N * N*(N+1)/2 + (1-p)*(N+1)
= (N+1) * (p/2 + 1 - p) = (N+1) * (1 - p/2) ∈ Θ(N)
Bei erfolgreicher Suche (d.h. p=1) werden also im Mittel Cmit(N) = (N+1)/2, d.h. nur halb so viele
Vergleiche benötigt wie im schlechtesten Fall.
4.2 Binäre Suche
Das Verfahren zur binären Suche sucht in einer Liste L=(a1, ..., aN) von aufsteigend sortierten
Schlüsseln nach einem Element am mit dem Suchschlüssel v: d.h. am = v bei erfolgreicher Suche.
Falls L leer ist, so endet die Suche erfolglos. Sonst betrachte das Element am an der mittleren Position
m in L (m = (N+1) div 2).
- Falls v < am, so durchsuche die linke Teilliste Ll = (a1, ..., am-1) mit binärer Suche.
- Falls v > am, so durchsuche die rechte Teilliste Lr = (am+1, ..., aN) mit binärer Suche.
Sonst ist am = v und die Suche war erfolgreich.
- 40 -
Beispiel: N=7, Suchschlüssel v1 = 6, v2 = 11
Implementierung in Java:
public static int Binaere_Suche(int[] a, int l, int r, int v) {
int k, m;
if (l > r) {
k = 0;
} else {
m = (l + r) / 2;
if (a[m] == v) {
k = m;
} else {
if (a[m] > v) {
k = Binaere_Suche(a, l, m-1, v);
} else { //a[m] < v
k = Binaere_Suche(a, m+1, r, v);
}
}
}
return k;
}
Ein Aufruf von Binaere_Suche(a, 1, N, v) im Hauptprogramm liefert das gewünschte Resultat als
Funktionswert. Bei erfolgloser Suche wird 0 zurückgegeben. Bei erfolgreicher Suche wird der Index m
mit am = v zurückgegeben.
Die Worst-Case-Komplexität für die Anzahl der Schlüsselvergleiche wird offensichtlich bei erfolgloser Suche erreicht: Cmax(N) = 2 * ⎡log2(N + 1)⎤.
Um den mittleren Suchaufwand abschätzen zu können, nehmen wir an, dass N = 2q - 1 ist.
q−1
q
N = 2 -1 =
∑2
q
i
i=0
=
∑2
i-1
i=1
Das Wiederfinden des Suchschlüssels v auf der Stufe i erfordert offenbar 2(i-1)+1 = 2i - 1 Vergleiche.
- 41 -
Wir nehmen an, dass jedes Element am der Liste mit der gleichen Wahrscheinlichkeit p/N das gesuchte
Element v ist, und wir nehmen an, dass v mit der Wahrscheinlichkeit 1-p nicht gefunden wird.
Dann erhalten wir:
q
Cmit(N) = p/N *
∑(2i-1) * 2
q
i-1
+ (1-p)*2*q = 2p/N *
i=1
∑i * 2
i-1
- p/N*(2q-1) + (1-p)*2*q
i=1
= 2p/N * ( (q-1)*2q + 1) - p + (1-p)*2q = 2p/N * ( q*2q - (2q - 1)) - p + (1-p)*2q
= 2p/N * (log2(N+1) * (N+1) - N) - p + (1-p)*2*log2(N+1)
= log2(N+1)*(2p/N + 2) - 3p ∈ Θ(log2N)
Im Mittel verursacht binäres Suchen für große N also etwa
Cmit ≈ 2*log2(N+1) - 3p
und erfordert bei erfolgreicher Suche (d.h. p=1) 3 Vergleiche weniger als im schlechtesten Fall.
___________
Wir zeigen noch:
q
∑i * 2
i-1
= (q-1)*2q + 1
i=1
Vollständige Induktion nach q:
q
q = 1:
∑i * 2
i-1
= 1*20 = 1 = (q-1) * 2q + 1
i=1
q+1
q Æ q+1:
∑i * 2
i-1
i=1
q
q
= (q+1)*2 +
∑i * 2
i-1
i=1
=(IA) (q+1)*2q + (q-1) * 2q + 1
= 2*q*2q + 1 = q*2q+1 + 1
4.3 Mediansuche
Wir betrachten für eine Liste L = (a1, ..., aN) von N Elementen das folgende Auswahlverfahren:
Zu einem i ∈ <1, N> ist das i-kleinste Element am der Liste L gesucht.
- Für i = 1, bzw. i = N ist am das Minimum, bzw. das Maximum von L. Diese können natürlich in
linearer Zeit Θ(N) mit einmaligem Durchlauf von L bestimmt werden.
- Durch i-malige Bestimmung des kleinsten Elements (und nachfolgendes Entfernen desselben
aus der Liste, so dass bei der nächsten Minimumsuche eine um 1 kleinere Liste durchsucht
wird) kann man in Θ(i*N) Schritten das i-kleinste Element finden.
- Für ungerades N und i = ⎡N/2⎤ ist das i-kleinste Element von L der Median. Der Median kann
also in O(N2) Schritten bestimmt werden.
Durch Verwendung von Sortierverfahren kann man allerdings das i-kleinste Element sogar schon in
O(N * log N) bestimmen, indem man L sortiert und dann einfach das i-te Element der Sortierung abliest.
Folgendes Auswahlverfahren erlaubt sogar eine noch schnellere Bestimmung des i-kleinsten Elements
in einer Folge von N paarweise verschiedenen Elementen:
- 42 -
0. Rekursionsabbruch
Falls N ≤ 91, so berechne das i-kleinste Element direkt, sonst:
Schritt 1 bis 3: Bestimmung eines geeigneten Pivotelements v.
1. Aufteilung in Gruppen:
Teile die N Elemente in ⎣N/5⎦ Gruppen zu je 5 Elementen und höchstens eine Gruppe mit den
restlichen (höchstens 4) Elementen auf.
2. Bestimmung von Medianen:
Sortiere jede dieser höchstens ⎡N/5⎤ Gruppen aus maximal 4 oder 5 Elementen und greife das mittlere
Element einer jeden 5er-Gruppe heraus. Für die letzte, kleinere Gruppe wähle man ebenfalls das mittlere Element, falls diese Gruppe eine ungerade Zahl von Elementen hat, sonst das größere der beiden
mittleren Elemente. Man erhält somit ⎡N/5⎤ Mediane in der Zeit O(N).
3. Rekursive Bestimmung des Medians der Mediane:
Wende das Auswahlverfahren für i=⎡ ⎡N/5⎤ /2 ⎤ rekursiv auf die ⎡N/5⎤ Mediane an um das mittlere
Element v dieser Mediane zu finden.
v heißt Median der Mediane.
In der mittleren Zeile stehen die Mediane von jeweils 5 Elementen, sortiert von links nach rechts. Die
zugehörigen 5 Elemente stehen in der jeweiligen Spalte, sortiert von oben nach unten. In der Mitte des
Feldes steht der Median der Mediane v. Die Elemente links oben sind ≤ v, die rechts unten ≥ v.
4. Aufteilung der Liste mit v als Pivotelement:
Teile die N Elemente bezüglich dem Pivotelement v auf in zwei Gruppen:
Gruppe 1 enthält die k Elemente, die kleiner als v sind, und Gruppe 2 enthält die N-k-1 Elemente, die
größer als v sind.
Diese Aufteilung kann in linearer Zeit O(N) durchgeführt werden.
Unten werden wir zeigen, dass beide Gruppen mindestens einen festen Anteil der Elemente der Gesamtfolge L enthalten, nämlich mindestens 3/10*N - 6 Elemente.
5. Rekursive Anwendung des Auswahlverfahrens:
Falls i ≤ k ist, so liegt das i-kleinste Element von L in Gruppe 1 und wir suchen rekursiv das i-kleinste
Element aus Gruppe 1.
Falls i > k+1 ist, so liegt das i-kleinste Element von L in Gruppe 2, und es kann rekursiv als das
(i-(k+1)-kleinste Element aus Gruppe 2 bestimmt werden.
- 43 -
Beispiel: N = 15, i = 11
Aufteilung von L bezüglich v = 17 liefert zwei Gruppen:
Gruppe 1:
2, 7, 11, 12, 5, 13
k = 6 Elemente
Gruppe 2:
200, 20, 26, 18, 19, 22, 40, 100
N-k-1 = 8 Elemente
Das 11-kleinste Element von L ist das 4-kleinste Element „22“ aus Gruppe 2: i-(k+1) = 11-7 = 4
Komplexität des Verfahrens:
Vorraussetzung: a1, ..., aN paarweise verschieden.
Für das Pivotelement v gilt:
jede Fünfergruppe mit mittlerem Element größer als v enthält wenigstens drei Elemente, die größer als
v sind.
Es gibt g = ⎣N/5⎦ „volle“ Fünfergruppen. Darunter gibt es
g’ = ⎣1/2 * (⎣N/5⎦ -1) ⎦
volle Fünfergruppen, deren mittleres Element größer als v ist.
Es gilt:
g’ > ½ * (N/5 - 2) -1 = N/10 - 2
Also gibt es wenigstens 3 * g’ = 3*N/10 - 6 Elemente in L, die größer als v sind.
Analog gibt es wenigstens 3 * g’ Elemente in L, die kleiner als v sind.
Daraus folgt, dass die Aufteilung in Schritt 4 des Algorithmus mit v als Pivotelement zwei Gruppen
liefert, welche jeweils höchstens
N - (3*N/10 - 6) = 7N/10 + 6
Elemente enthalten.
- 44 -
Nur für diese Elemente wird der Algorithmus im Schritt 5 rekursiv aufgerufen. Man muss also nur
noch einen festen Bruchteil (etwa maximal 70%) der Elemente betrachten.
Sei T(N) nun die Anzahl der Schritte, die erforderlich sind, um das i-kleinste Element unter
den N Elementen zu finden.
Rekursionsformel:
T(N) ≤ T(⎡N/5⎤) + T(⎡7N/10 + 6⎤) + a*N
Sei c eine Konstante mit
- T(N) ≤ c * N, für alle N ≤ 91, und
- c ≥ 80 * a
Dann können wir mit vollständiger Induktion nach N zeigen, dass gilt:
T(N) ≤ c*N für alle N ∈ N+.
Induktionsanfang: T(N) ≤ c * N für alle N ≤ 91.
Induktionsschritt: für N > 91 gilt:
T(N) ≤(IA) c*⎡N/5⎤ + c*⎡7N/10 + 6⎤ + a*N
≤ c * (N/5 + 1 + 7N/10 + 6 + 1) + c/80*N
= c * (73N/80 + 8)
≤ c * N, für N > 91
( 73N + 640 ≤ 80N
Ù 640 ≤ 7N
Ù 91,43 ≤ N
Î N > 91)
Satz:
Das i-kleinste Element in einer Folge von N paarweise verschiedenen Elementen kann in O(N) Schritten gefunden werden.
- 45 -
Kapitel 5: Bäume
5.1 Suchbäume
Sei T = (V, E) ein geordneter binärer Wurzelbaum.
Sei s:V Æ S eine Abbildung der Knotenmenge V in eine vollständig geordnete Schlüsselwertmenge S.
T heißt (schwach) sortiert, g.d.w. gilt:
∀v ∈ V: ( v’ ∈ Tl(v) => s(v’) <(=) s(v) ∧ v’’ ∈ Tr(v) => s(v’’) >(=) s(v) )
Falls T sortiert ist, so wird T auch kurz Suchbaum genannt.
Beispiel: S = {2, 5, 6, 7, 8, 12, 13, 14, 15}
Durchläuft man die Knoten eines Suchbaumes T = (V, E) in LWR-Ordnung, so durchläuft man die
Schlüsselwerte monoton steigend:
T ist (schwach) sortiert Ù ( ∀v,v’ ∈ V: ( s(v) <(=) s(v’) Ù v <LWR v’ ) )
Offensichtlich sind in einem Suchbaum alle repräsentierten Schlüssel paarweise verschieden:
∀v,v’ ∈ V: v ≠ v’ => s(v) ≠ s(v’).
Wir wollen nun folgende Operationen auf Suchbäume betrachten:
- Suchen,
- Einfügen und
- Löschen eines Schlüsselwerts s,
- Generierung eines Suchbaumes zur Repräsentation einer Menge von Schlüsselwerten.
- 46 -
Hier nochmals die Klasse BinTreeNode aus dem letzten Kapitel:
/**
* Knoten eines binären Baumes
*/
public class BinTreeNode {
/** Schlüsselwert */
int key;
/** Linker Sohnknoten */
BinTreeNode lson;
/** Rechter Sohnknoten */
BinTreeNode rson;
/**
* Konstruktor.
*/
BinTreeNode(int v) {
key = v;
lson = rson = null;
}
/**
* Gibt die Anzahl der Söhne des Knotens zurück (0, 1 oder 2)
*/
int countSons() {
if (lson == null)
return (rson == null)?0:1;
else
return (rson == null)?1:2;
}
/**
* Liefert true, falls der Knoten ein Blatt ist, d.h. keine Kinder hat
*/
boolean isLeaf() {
return (lson == null) && (rson == null);
}
/**
* Liefert true, falls der Knoten keinen Sohn auf der
* Seite hat, in der der Schlüssel s zu suchen wäre
*/
boolean isLeaf(int s) {
return ( (key > s) && (lson == null) )
|| ( (key < s) && (rson == null) );
}
/**
* Liefert eine Textausgabe des Objektes, siehe
* {@link java.lang.Object#toString}
*/
public String toString() {
return "[" + key + "]";
}
}
- 47 -
/**
* Binärer Suchbaum
*/
public class BinSearchTree {
/** der Wurzelknoten */
BinTreeNode root = null;
/** Leerer Konstruktor - erzeugt einen leeren Suchbaum */
public BinSearchTree() {
root = null;
}
/** Generiert einen neuen binären Suchbaum aus dem Array s */
public BinSearchTree(int[] s) {
for (int i = 0; i < s.length; i++)
insert(s[i]);
}
/**
* Sucht den Knoten mit Schlüssel s im Teilbaum von p
*/
BinTreeNode search(BinTreeNode p, int s) {
BinTreeNode q = null;
if (p != null) {
if (p.key == s) {
q = p;
} else {
if (p.key > s) {
q = search(p.lson, s);
} else {
q = search(p.rson, s);
}
}
}
return q;
}
/**
* Sucht den Knoten mit Schlüssel s im Baum.
*/
BinTreeNode search(int s) {
return search(root, s);
}
/**
* Einfügen eines neuen Blattes mit Schlüssel s in den Teilbaum von p
*/
void insert(BinTreeNode p, int s) {
BinTreeNode q = p, r = null;
while ( (q != null) && (q.key != s) ) {
r = q;
if (q.key > s)
q = r.lson;
else
q = r.rson;
}
if (q != null) return; //Abbruch bei vorhandenem Schlüssel
if (r.key > s)
r.lson = new BinTreeNode(s);
else
r.rson = new BinTreeNode(s);
}
- 48 -
/**
* Fügt ein neues Blatt mit Schlüssel s in den Baum ein
*/
void insert(int s) {
if (root != null) {
insert(root, s);
} else {
root = new BinTreeNode(s);
}
}
/**
* Löscht den Knoten mit Schlüssel s aus dem Baum
*/
void remove(int s) {
BinTreeNode v = root, w = null, t;
//Suche Knoten v mit Schlüssel s (w ist Vater von v)
while ( (v != null) && (v.key != s) ) {
w = v;
if (v.key > s) v = v.lson; else v = v.rson;
}
//Abbruch, falls Schlüssel nicht gefunden
if (v == null) return;
switch (v.countSons()) {
case 0:
changeSon(w, v, null);
break;
case 1:
t = (v.lson == null)?v.rson:v.lson; //Der einzige Sohn von v ist t
changeSon(w, v, t);
// v durch einzigen Sohn t ersetzen
break;
case 2:
changeSon(w, v, v.lson);
// v durch v.lson ersetzen
t = findRight(v.lson);
// rechtesten Knoten suchen
t.rson = v.rson;
// rechten Sohn von v an t hängen
break;
}
}
/**
* Tauscht den Sohn v von w gegen den Knoten x.
*/
void changeSon(BinTreeNode w, BinTreeNode v, BinTreeNode x) {
if (w == null) {
if (v != root) return;
root = x;
} else {
if (w.lson == v)
w.lson = x;
else if (w.rson == v)
w.rson = x;
}
}
/**
* Sucht den Knoten der am weitesten rechts im Teilbaum von p steht
*/
BinTreeNode findRight(BinTreeNode p) {
BinTreeNode q = p;
while (q.rson != null) q = q.rson;
return q;
}
}
- 49 -
Einfügen:
Um einen Schlüsselwert s in einen Suchbaum einzufügen, „suchen“ wir s in dem Baum.
Falls s schon vorhanden ist, so wird s nicht eingefügt. Anderenfalls bricht die „Suche erfolglos“ in
einem Knoten ab.
Wir können s nun als Sohn des erreichten Knotens einfügen.
Löschen:
Zuerst muss der zu löschende Schlüsselwert s wieder in dem Baum gesucht werden. Dabei merken wir
uns nicht nur den Knoten v mit s(v) = s, sondern auch den Vater „Vater(v)“ von v im Baum.
Wir unterscheiden nun drei Fälle:
a) v ist ein Blatt:
Dann kann v ohne Probleme gelöscht werden.
b) v hat genau einen Sohn:
Dann wird v im Baum gelöscht, und der Sohn von v wird zum (entsprechenden) Sohn von
Vater(v).
c) v hat zwei Söhne:
Seien vl und vr der linke bzw. rechte Sohn von v,
und sei vt der rechteste Knoten des linken
Teilbaums von v.
Dann wird v im Baum gelöscht, und vl wird
zum (entsprechenden) Sohn von Vater(v).
Außerdem wird vr zum rechten Sohn von vt,
d.h. der rechte Teilbaum Tr(v) von v in T wird
in den linken Teilbaum Tl(v) von v in T
„eingehängt“. Dies ergibt folgenden Baum:
- 50 -
Beispiel: Löschen nach Version 1
i) Wenn wir in T den Schlüsselwert von v1 löschen, so erhalten wir folgenden neuen Baum:
ii) Wenn wir in T den Schlüsselwert von v2 löschen, so erhalten wir folgenden neuen Baum:
Man beachte, dass der rechteste Knoten v4 von Tl(v2) in diesem Fall kein Blatt ist.
- 51 -
Löschen - Version 2
Der rechteste Knoten vt hat (natürlich)
keinen rechten Sohn.
Außerdem ist s(vt) sicher ein (geeigneter)
Separator für Tl(v) und Tr(v).
Deshalb können wir v aus T löschen und
durch vt ersetzen. Danach müssen wir noch
vt in Tl(v) löschen.
Da vt maximal einen Sohn hat, ist dies
unproblematisch, vgl a), b).
Es gilt: h(T’) ≤ h(T)
Beispiel: Löschen nach Version 2:
i) Löschen von v1 in T:
ii) Löschen von v2 in T:
- 52 -
Der resultierende Baum T’ ist wieder ein Suchbaum, und zwar zur Knotenmenge V’ = V \ {v}.
Bei Version 1 ist die Höhe h(T’) häufig viel größer als die Höhe von T:
-1
≤ h(T’) - h(T) ≤ h(Tl(v))
Fall b)
Fall a)
Fall c)
Der Aufwand für das Löschen beträgt wie beim Suchen O(h) Schritte.
Die Operation Löschen nach Version 1 ist kommutativ, d.h. löscht man in einem Suchbaum zuerst
einen Knoten v1 und dann einen anderen Knoten v2, so erhält man dasselbe Produkt wie wenn man
zuerst v1 und dann v2 löscht.
Bei Löschen nach Version 2 gilt für die Höhen:
h(T’) ≤ h(T).
Generieren:
Der Konstruktor
BinSearchTree(int[] s)
erstellt einen Suchbaum für die Folge s.
Beispiel:
a) s = (12, 15, 7, 13, 5, 8)
(8 Schritte)
b) s = (5, 7, 8, 12, 13, 15)
(15 Schritte)
Der Aufwand C(N) (gemessen in der Anzahl der Schlüsselvergleiche „s <, =, > s’? “ ) und der resultierende Suchbaum T hängen stark von der Reihenfolge der Schlüsselwerte s1, ..., sN ab:
- Ist die Eingabefolge s monoton, so degeneriert T zu einer linearen Liste (siehe Beispiel b).
Dann gilt:
C(N) = Σi=1N-1 (i ) = N(N-1) / 2 ∈ Θ(N2)
und hat die maximale Höhe N-1.
-
Im günstigsten Fall erhält man einen Suchbaum T der Höhe hT = ⎣log2N⎦
Dann gilt:
C(N) = ( Σi=0 ⎣log2N⎦-1 (i * 2i) ) + ⎣log2N⎦ * (N - 2⎣log 2 N⎦ + 1)
= (⎣log2N⎦-2) * 2⎣log2N⎦ + 2 + ⎣log2N⎦ * (N - 2⎣log 2 N⎦ + 1)
= (N+1) * ⎣log2N⎦ - 2⎣log 2 N⎦+1 + 2 ∈ Θ(N * log2N)
(im Beispiel a): N = 6 Æ C(N) = 7*2 - 8 + 2 = 8 )
Insgesamt gilt: Cmin(N) ∈ Θ(N * log2N), Cmax(N) ∈ Θ(N2)
Für den Erwartungswert Cmit(N) unter der Annahme, dass alle N! Permutationen von s gleich
wahrscheinlich sind, gilt:
Cmit(N) ∈ Θ(N * log2N).
- 53 -
5.2 AVL-Bäume
Definition (von Adelson-Velski und Landis (1962)):
Sei T = (V, R) ein geordneter binärer Wurzelbaum.
i) Balance:
Die Balance eines Knotens v ∈ V ist
β(v) = h(Tr(v)) - h(Tl(v)),
d.h. die Höhendifferenz zwischen dem rechten und dem linken Teilbaum von v,
wobei h(T’) = -1 ist, falls T’ leer ist.
Falls v ein Blatt ist, so ist β(v) = 0.
Falls v kein Blatt ist und
- Tl(v) leer ist, so ist β(v) = h(Tr(v)) + 1,
- Tr(v) leer ist, so ist β(v) = -1 - h(Tl(v)).
ii) AVL-Baum:
T heißt AVL-Baum, falls gilt: ∀v ∈ V: | β(v)| ≤ 1.
Beispiele: AVL-Bäume
i) Für die Folge s = (7, 4, 11, 8, 15, 12) erzeugt der
Konstruktor folgenden Baum T:
Dieser Baum ist kein AVL-Baum,
denn er ist rechtslastig: β(7) = 2 - 0 = 2
4 7 8 11 12 15
v
β(v) 0 2 0 1 0 -1
ii) Für die Folge (11, 7, 4, 8, 12, 15) zur selben
Schlüsselwertmenge wird dagegen folgender
Baum T’ generiert:
T’ ist ein AVL-Baum. Hier gilt:
4
v
β(v) 0
7 8 11 12 15
0 0 0 0 -1
iii) T und T’ unterscheiden sich dadurch, dass T um den Knoten 11 nach „links rotiert“ ist.
- 54 -
Linksrotation
eines Suchbaumes T bezüglich zweier Knoten v, v’ mit v = Vater(v’)
Der neue Baum T’ = Rotlinks(v, v’)(T) ist ebenfalls ein Suchbaum.
Für die Höhen von T und T’ gilt:
- Falls h(Tl(v)) < h(Tr(v)) : h(T’) ≤ h(T).
-
Falls h(Tl(v)) < h(Tr(v)) und h(Tr,1) < h(Tr,2): h(T’) = h(T) - 1,
denn h(T’) = h(Tr,2) + 1 und h(T) = h(Tr,2) + 2.
-
Falls h(Tl(v)) ≥ h(Tr(v)) : h(T’) = h(T) + 1,
denn h(T’) = h(Tl) + 2 und h(T) = h(Tl) + 1.
Rechtsrotation
von T’ bezüglich v’, v (in T’ gilt: v’ = Vater(v)) liefert wieder T.
Rotrechts(v’, v) ° Rotlinks(v, v’)(T) = T
Rotlinks(v, v’) ° Rotrechts(v’, v) (T’) = T’
Durch Links-, bzw. Rechtsrotation wird die LWR-Ordnung eines Baumes nicht verändert.
Satz:
Sei S = {s1, ..., sN} mit (o.B.d.A) s1 < s2 < ... < sN eine Schlüsselwertmenge und
T = (V, R) ein beliebiger Suchbaum für S.
Dann sind alle Suchbäume T’ für S durch endlich viele Anwendungen von
Rotlinks und Rotrechts aus T erzeugbar!
- 55 -
Beweis:
Wir können jeden Suchbaum T’ für S per Induktion aus der „linken linearen Liste
erzeugen und umgekehrt:
Um nun T’ aus T zu erzeugen, gehen wir wie folgt vor:
T Æ Tl Æ T’
Offenbar kann man mit einer der beiden Rotationen alleine nicht alle Transformationen auf Suchbäumen realisieren. Z.B. ist Rotlinks auf die linke lineare Liste Tl überhaupt nicht anwendbar. Deshalb
kann man Tl in keinen anderen Suchbaum transformieren, falls nur Rotlinks zugelassen ist.
- 56 -
Sei T = (V, R) ein Wurzelbaum.
i) Der Außengrad g+(v) und der Innengrad g-(v) eines Knotens v ∈ V geben die Anzahl der Nachfolger
bzw. der Vorgänger von v in T an:
g+(v) = | { w ∈ V | (v, w) ∈ R} |
g-(v) = | { w ∈ V | (w, v) ∈ R} |
ii) T heißt vollständig, falls jeder innere Knoten (= Nicht-Blatt) genau zwei Söhne hat:
∀v ∈ V: (g+(v) ≠ 0 => g+(v) = 2).
iii) T heißt voll, falls alle Knoten auf allen Stufen außer den beiden untersten genau zwei Söhne haben:
∀v ∈ V: (h(v) ≤ h(T) - 2 => g+(v) = 2).
Hier gilt: 2h(T) ≤ |V| < 2h(T)+1 Î h(t) ∈ O( log2|V| )
Für geordnete binäre Wurzelbäume T gilt:
- Ist T voll, so ist T ein AVL-Baum, denn alle Blätter haben die Höhe h(T) oder h(T) -1.
- T4 ist ein AVL-Baum, welcher nicht voll ist:
-
T1 und T2 sind vollständig, aber keine AVL-Bäume.
T4 ist ein AVL-Baum, welcher nicht vollständig ist.
- 57 -
Fibonacci-Bäume: Abschätzung der Höhe von AVL-Bäumen
Wir betrachten im folgenden extremale AVL-Bäume, welche zu einer vorgegebenen Höhe h eine minimale Anzahl von Knoten aufweisen:
nh = min { n | T ist ein AVL-Baum mit n Knoten und Höhe h}.
Es gilt n0 = 1 und n1 = 2. Beispielbäume sind:
Sei T ein beliebiger knotenminimaler AVL-Baum der Höhe h ≥ 2.
Sei v die Wurzel von T und seien Tl(v) und Tr(v) der linke bzw. rechte Teilbaum von v
Wegen h ≥ 2 hat (mindestens) einer der beiden Teilbäume die Höhe 1 und ist somit nicht leer, und wegen |β(v)|≤ 1, ist auch der andere Teilbaum nicht leer.
Aufgrund der Knotenminimalität von T gilt:
- h(Tl(v)) = h - 1 und h(Tr(v)) = h-2, oder
- h(Tl(v)) = h - 2 und h(Tr(v)) = h-1.
Außerdem müssen Tl(v) und Tr(v) auch wieder knotenminimale AVL-Bäume sein,
zu ihrer jeweiligen Höhe.
Rekursionsformel:
n0 = 1
n1 = 2
nh = 1 + nh-1 + nh-2
Diese Rekursionsformel erinnert an die Formel der Fibonacci-Zahlen:
f0 = 0
f1 = 1
fh = fh-1 + fh-2
Die Wertetabelle zeigt den Zusammenhang:
h 0 1 2 3 4 5 6 ...
nh 1 2 4 7 12 20 33 ...
fh 0 1 1 2 3 5 8 ...
fh = nh-3 + 1, für h ≥ 3
- 58 -
Definition (Fibonacci-Bäume, FB-Bäume)
Die Menge Fh der Fibonacci-Bäume der Höhe h ist rekursiv definiert:
i)
F0 enthält alle Wurzelbäume T0 = ({v}, ∅) mit genau einem Knoten und der Höhe h = 0.
ii)
Fh, h ≥ 1, enthält alle geordneten binären Wurzelbäume
Th = <Th-1, v, Th-2> bzw. Th = <Th-2, v, Th-1>, wobei Th-1 ∈ Fh-1 und Th-2 ∈ Fh-2 .
Hierzu sei F-1 = { (∅,∅) }, d.h. T-1 ist der leere Baum, und <T, v, T’> bezeichne einen
geordneten binären Wurzelbaum mit der Wurzel v und dem linken bzw. rechten Teilbaum
T bzw. T’ von v.
ebenfalls in F3:
Satz:
Jeder FB-Baum ist auch ein AVL-Baum:
Beweis: Th = <Th-1, v, Th-2>
Dann haben - per Induktion - alle Knoten v’ in den kleineren Teilbäumen Th-1 und Th-2 die Balance
|β(v’)| ≤ 1. Für den Knoten v gilt: |β(v)| = | h(Th-1) - h(Th-2)| = 1
Dies zeigt ebenso gleich mit, dass alle Knoten v eines FB-Baumes die Balance β(v) = +/- 1 haben.
Die FB-Bäume Th ∈ Fh sind genau die knotenminimalen AVL-Bäume der Höhe h.
- 59 -
Satz:
Ein knotenminimaler AVL-Baum T zu einer vorgegebenen Höhe h mit n Knoten ist gleichzeitig auch
ein höhenmaximaler AVL-Baum zur vorgegebenen Knotenanzahl n:
Beweis:
Angenommen, es gibt einen anderen AVL-Baum T’ mit n Knoten und
h(T’) > h(T) = h.
Dann besitzt T’ einen Teilbaum T’’ der Höhe h(T’’) = h.
T’’ ist ebenfalls ein AVL-Baum.
Wegen der Knotenminimalität von T zur Höhe h gilt somit
n(T) ≤ n(T’’)
Da T’’ aber ein echter Teilbaum von T’ ist, muss gelten
n(T’’) < n(T’) = n(T),
ein Widerspruch.
Also sind die FB-Bäume genau die höhenmaximalen AVL-Bäume zu einer vorgegebenen Knotenzahl.
Satz: (Höhenabschätzung für AVL-Bäume)
Sei T ein AVL-Baum mit n Knoten. Dann gilt:
h(T) ≤ 2*log2n.
Beweis:
Für die höhenmaximalen AVL-Bäume, d.h. die FB-Bäume, mit n Knoten gilt die Rekursionsformel:
n0 = 1,
n1 = 2,
nh = 1 + nh-1 + nh-2.
Per Induktion kann man zeigen:
i)
nh = fh+3 - 1
ii)
fh = 1/√5 * ( ((1 + √5)/2)h - ((1 - √5)/2)h )
Der negative Term ((1 - √5)/2) ist dem Betrag nach kleiner als 1 und seine Potenzen konvergieren
daher mit wachsendem h gegen 0.
Daher gilt:
≈ 1/√5 * ((1 + √5)/2)h
fh
≈ 0,45 * 1,62h,
d.h. nh ≈ 0,45 * 1,62h+3 -1
Für alle h gilt:
fh ≥ 1/√5 * ((1 + √5)/2)h - 1
Wegen nh = fh+3 - 1 gilt nun:
log2(nh + 2) ≥ log2(1/√5) + (h+3)*log2((1 + √5)/2)
Daraus folgt die Abschätzung h ≤ 2 * log2(nh)
- 60 -
Doppelrotationen
Der resultierende Wurzelbaum T’ ist wiederum ein Suchbaum (falls T ein Suchbaum war), und es gilt:
DoppelRotlinks(v, v’, v’’) = Rotlinks(v, v’’) ° Rotrechts(v’, v’’),
DoppelRotrechts(v, v’, v’’) = Rotrechts(v, v’’) ° Rotlinks(v’, v’’),
Satz: (Rebalancierung)
Sei T = <Tl, v, Tr> ein geordneter binärer Wurzelbaum, mit der Wurzel v und Teilbäumen Tl, Tr,
welche beide AVL-Bäume sind.
Ist |β(v)| = 2, so kann T mittels einer Rotation oder Doppelrotation in einen AVL-Baum T’ überführt
werden, mit h(T) - 1 ≤ h(T’) ≤ h(T)
Beweis:
Sei T o.B.d.A. „linkslastig“, d.h. β(v) = -2
- 61 -
Wir betrachten nur den Fall, dass Tr nicht-leer ist. (Der andere Fall ist leicht, da dann wegen β(v) = 2
die Teilbäume Tl,1 und Tl,2 jeweils maximal einen Knoten haben können.)
Es gilt: | h(Tl,2) - h(Tl,1) | = |β(vl)| ≤ 1.
i)
β(vl) ∈ {-1, 0}, d.h. h(Tl,1) ≥ h(Tl,2):
Dann betrachten wir den Wurzelbaum
T’ = Rotrechts(v, vl)(T)
Alle Knoten in den AVL-Bäumen Tl,1, Tl,2 und Tr
haben dieselbe Balance (0, +/- 1) wie in T.
Für den Knoten v gilt:
β’(v) = h(Tr) - h(Tl,2)
= { hr - (hl-1), falls β(vl) = 0
{ hr - (hl-2), falls β(vl) = -1
= { -1
{0
Für den Knoten vl gilt:
β’(vl) =(*) h(Tl,2) + 1 - h(Tl,1) = β(vl) + 1 ∈ {-1, 0}
(*): h(Tl,2) ≥ h(Tr)
Also ist T’ ein AVL-Baum, und es gilt:
h(T’) = { h(T),
falls β(vl) = 0
{ h(T) - 1,
falls β(vl) = -1
ii)
β(vl) = +1, d.h. h(Tl,1) = h(Tl,2) - 1
Dann betrachten wir den Wurzelbaum
T’ = DoppelRotrechts(v, vl,2, vl,2)(T).
Alle Knoten in den AVL-Bäumen Tl,1, T1, T2 und Tr haben dieselbe Balance (0, +/- 1) wie in T.
- 62 -
Für den Knoten v gilt:
β’(v) = h(Tr) - h(T2)
= { h(Tr) - (h(Tl,2) - 1),
{ h(Tr) - (h(Tl,2) - 2),
= { β(v) + 2 = 0
{ β(v) + 3 = 1
da β(v) = h(Tr) - (h(Tl,2) + 1).
Für den Knoten vl gilt:
β’(vl) = h(T1) - h(Tl,1)
= { (h(Tl,2) - 1) - h(Tl,1),
{ (h(Tl,2) - 2) - h(Tl,1),
= { β(vl) -1 = 0
{ β(vl) -2 = -1
falls β(vl,2) ≥ 0
falls β(vl,2) = -1
falls β(vl,2) ≤ 0
falls β(vl,2) = 1
Für den Knoten vl,2 gilt:
β’(vl,2) = (h(Tr) + 2) - (h(Tl,1) + 2)
= h(Tr) - h(Tl,1)
= β(v) + 2
= 0,
da β(v) = h(Tr) - (h(Tl,1) + 2).
Also ist T’ ein AVL-Baum, und es gilt:
h(T’) = h(T) -1
Definition (Klassen balancierter Bäume)
Sei f: N Æ N eine Funktion.
Dann heißt eine Klasse J von Wurzelbäumen balanciert zur Höhe f(n), g.d.w. gilt:
i)
Jede n-elementige Schlüsselwertmenge S kann durch einen Baum T ∈ J der Höhe hT ≤ f(n)
repräsentiert werden.
ii)
Die Wörterbuchoperationen Suchen, Einfügen und Löschen eines Elements liefern für
jeden Baum T ∈ J einen (neuen) Baum T’ ∈ J, und sie können in O(hT) Schritten ausgeführt werden.
Satz (Adelson)
Die Klasse der AVL-Bäume ist zur Höhe
f(n) = 2 * log2n
balanciert.
Die notwendigen Rebalancierungen können nach dem Adelson-Verfahren entlang des Suchpfades mit
Hilfe von Rotationen und Doppelrotationen erfolgen.
- 63 -
5.3 B-Bäume
(Rudolf Bayer, 1972)
Bei Suchproblemen in außerordentlich großen Schlüsselwertmengen S müssen Sekundärspeicher
verwendet werden.
Dann ist für den Suchaufwand neben den Schlüsselwertvergleichen vor allen Dingen der Aufwand
zum Aufsuchen und Laden von Seiten des Sekundärspeichers entscheidend.
Um die Anzahl der Seitenwechsel von Seiten des Sekundärspeichers zu minimieren, versucht man
möglichst viele Schlüsselwerte entlang der Zugriffswege beim binären Suchen in jeweils einer Seite zu
speichern.
Definition (B-Baum vom Typ m)
Sei T = (V, R) ein geordneter Wurzelbaum mit einer Knotenmarkierung
s: V Æ 2s, welche jedem Knoten v ∈ V eine Teilmenge s(v) ⊆ S einer
Schlüsselwertmenge S zuordnet.
(im folgenden gilt: innerer Knoten: nicht die Wurzel und kein Blatt)
Dann ist T ein B-Baum vom Typ m, welcher S repräsentiert, falls gilt:
i)
Struktureigenschaften:
a) Alle Blätter haben dieselbe Höhe hT,
b) Alle Knoten haben höchstens 2m + 1 Söhne.
Die Wurzel hat mindestens 2 Söhne, falls sie kein Blatt ist.
Alle inneren Knoten haben mindestens m+1 Söhne.
c) Alle Knoten enthalten höchstens 2m Schlüssel.
Die Wurzel enthält mindestens einen Schlüssel,
alle anderen Knoten enthalten mindestens m Schlüssel.
d) Hat ein Knoten k+1 Söhne, k ≥ 0, so enthält er genau k Schlüssel.
D.h. g+(v) = k + 1 impliziert |s(v)| = k.
- 64 -
ii)
Suchbaumeigenschaft:
Die Schlüsselwertmenge S ist disjunkt auf die Knoten verteilt, und für
jeden Knoten v ∈ V gilt:
sei s(v) = {s1, ..., sk}, mit s1 < s2 < ... < sk, und
sei (v1, ..., vk+1) die Folge der Söhne von v im Baum.
Dann gilt:
a) Alle Schlüsselwerte s im ersten Teilbaum T(v1) sind kleiner als s1: s < s1.
b) Alle Schlüsselwerte s im i-ten Teilbaum T(vi), mit 2 ≤ i ≤ k, liegen zwischen
si-1 und si: si-1 < s < si.
c) Alle Schlüsselwerte s im (k+1)-ten Teilbaum T(vk+1) sind größer als sk: sk < s.
Die Struktureigenschaft (i,c) besagt:
- Für die Wurzel des B-Baumes gilt:
1 ≤ k ≤ 2m.
- Für alle anderen Knoten des B-Baumes gilt: m ≤ k ≤ 2m.
Folgerung:
Alle Knoten außer der Wurzel sind garantiert zu mindestens 50% mit Schlüsselwerten gefüllt.
Beispiel: B-Baum vom Typ m = 2, S = <1, 25>
Abschätzungen:
für einen B-Baum T = (V, R) vom Typ m.
Sei n = |V| die Knotenanzahl und h = hT die Höhe von T.
i)
Knotenzahl:
Jeder innere Knoten hat mindestens m+1 Söhne. Deshalb gilt:
n ≥ 1 + 2 * ( 1 + (m+1) + (m+1)2 + ... + (m+1)h-1 )
= 1 + 2/m * ( (m+1)h - 1 )
Jeder Knoten hat maximal 2m+1 Söhne. Deshalb gilt:
n ≤ 1 + (2m+1) + (2m+1)2 + ...+ (2m+1)h
= 1/2m * ( (2m+1)h+1 - 1)
- 65 -
ii)
iii)
iv)
Höhe:
Gemäß i) gilt:
h ≤ 1/( log2(m+1) ) * log2( (n-1)*m/2 + 1 )
h ≥ 1/( log2(2m+1) ) * log2( 2*m*n + 1 ) - 1
Deshalb gilt h ∈ Θ(log2n), für festes m.
Schlüsselanzahl:
Für alle Knoten gilt |s(v)| ≤ 2*m. Deshalb gilt mit i):
|s| ≤ n * 2m ≤ (2m+1)h+1 - 1
Für die Wurzel w des Baumes gilt |s(w)| ≥ 1, und für alle anderen Knoten v gilt |s(v)| ≥ m.
Deshalb gilt mit i):
|s| ≥ 1+m*(n-1) ≥ 1 + 2*( (m+1)h - 1 )
Deshalb gilt auch h ∈ Θ(log2|s|), für festes m.
Blätterzahl b:
Analog zu i) erhält man:
2 * (m+1)h-1 ≤ b ≤ (2m+1)h
Satz (B-Bäume)
Die Klasse der B-Bäume vom Typ m ist zur Höhe
(*)
f(n) = 1 / ( log2(m+1) ) * log2n
balanciert.
(*): h ≤ 1 / ( log2(m+1) ) * log2( (|s|+1)/2 ) und |s|+1)/2 ≤ |s| für |s| ≥ 1
Zum Beweis dieses Satzes untersuchen wir im folgenden die Wörterbuchoperationen und zeigen, dass
diese in O(h) Schritten ausgeführt werden können.
Die Höhenbeschränkung erhalten wir mit iii) von oben.
Realisierung der Wörterbuchoperationen
search(s,T)
i) Suchen nach einem Schlüsselwert s:
Innerhalb einer Schlüsselwertmenge s(v) eines jeden Knotens, welche in einer Seite des Sekundärspeichers ist, kann binär gesucht werden, mit konstantem Aufwand ⎡log2(2m)⎤
Die Suche wird - wie bei AVL-Bäumen - von den Schlüsselwerten dirigiert: s1 < ... < sk
- s < s1: Fortsetzung der Suche im ersten Teilbaum
- si-1 < s < si, 2 ≤ i ≤ k: Fortsetzung der Suche im i-ten Teilbaum
- sk < s: Fortsetzung der Suche im (k+1)-ten Teilbaum
Nach maximal (hT + 1)-maligem Durchsuchen eines Knotens findet man s, oder stellt fest, dass s nicht
im Baum enthalten ist.
Der Aufwand für die Suche ist also O(hT).
- 66 -
ii) Einfügen eines Schlüsselwertes s:
insert(s, T)
Zunächst wird s im Baum „gesucht“. Ist s schon im Baum, so ist das Einfügen unnötig.
Anderenfalls bricht die Suche „erfolglos“ in einem Blatt v ab, und der Pfad zu diesem Blatt wird gespeichert.
Dann wird s in das Blatt v eingefügt.
Hat v nun immer noch höchstens 2m Schlüssel, so kann man das Einfügen terminieren.
Anderenfalls hat man einen Überlauf mit 2m+1 Schlüsseln in v.
Dann muss der so entstandene Baum T’ rekursiv entlang des Pfades von der Wurzel zu v „rebalanciert“ werden, beginnend mit v.
Wir „rebalancieren“ einen übergelaufenen Knoten v* wie folgt:
a) Die Schlüsselwerte in v* werden in drei Gruppen aufgeteilt (Splitting):
die Menge S1 der m kleinsten Schlüssel,
das mittlere Element sm+1,
die Menge S2 der m größten Schlüssel.
b) Anstelle von v* werden zwei neue Knoten v1 bzw. v2 mit den Schlüsselwerten S1 bzw. S2 erzeugt. Der Schlüssel sm+1 wird in den Vater von v* eingefügt, falls v* nicht die Wurzel des
Baumes war.
In diesem Falle muss jetzt (rekursiv) der
Vater von v* rebalanciert werden.
Falls v* schon die Wurzel des Baumes
war, so wird eine neue Wurzel mit
genau einem Schlüssel sm+1 erzeugt.
Aufwand für das Einfügen:
Beim Rebalancieren kann höchstens hT+1 mal gesplittet werden. Deshalb ist der Aufwand für das Einfügen in O(hT).
Der beim Einfügen entstehende Baum T’ kann eine um 1 größere Höhe haben, falls die Wurzel
gesplittet wurde:
„B-Bäume wachsen ( und schrumpfen ) an der Wurzel.“
Es gilt: hT ≤ hT’ ≤ hT + 1
Beispiel: Einfügen
i)
Das Einfügen von s = 26 in T stellt kein Problem dar, da das zugehörige Blatt nur 3 Schlüssel hat. Sei T’ der erzeugte Baum.
ii)
Beim Einfügen von s’ = 27 in T’ muss ein Blatt gesplittet werden:
S1 = {23, 24},
s3 = 25,
S2 = {26, 27},
und wir erhalten folgenden
neuen Baum T’’:
- 67 -
Alternativ kann man beim Einfügen von s’ = 27 in T’ auch nach links Verschieben:
Dann erhält man folgenden Baum:
iii) Löschen eines Schlüsselwertes s:
delete(s, T)
Zunächst wird der Pfad von der Wurzel von T zu dem Knoten v von T bestimmt, der s enthält. Sei s
der i-te Schlüssel in v.
Ist v ein Blatt, so wird s aus v gelöscht, und man setzt v’ = v.
Ist v kein Blatt, wo wird s auch aus v gelöscht. Aber jetzt benötigt man einen neuen Separator s’ anstelle von s in v. Dazu wählen wir den größten Schlüssel s’ im rechtesten Blatt v’ des i-ten Teilbaums
T(vi) von v.
Wir löschen s’ aus v’ und ziehen s’ nach v hoch.
Der so entstehende neue Baum T’ erfüllt die Suchbaumeigenschaft für S’ = S\{s}.
Um auch die Struktureigenschaft wieder herzustellen, muss t’ „rebalanciert“ werden. Dies erfolgt
rekursiv entlang des Pfades von der Wurzel von T zu v’, beginnend mit v’.
Wir „rebalancieren“ einen Knoten v* wie folgt:
a)
Falls v* mindestens m Schlüssel enthält, oder die Wurzel des Baumes ist, so ist nichts zu tun.
b)
Falls v* nur m-1 Schlüssel enthält (Unterlauf), so betrachten wir den linken und
rechten Bruder von v*.
- 68 -
Enthält einer der beiden Brüder v0 mindestens m+1 Schlüssel, so kann man einen dieser Schlüssel verschieben:
Danach kann man die gesamte Rebalancierung terminieren.
Enthalten beide Brüder nur m Schlüssel, so kann man v* mit einem Bruder v0 verschmelzen:
Danach müssen wir (eventuell) den Knoten Vater(v*) rebalancieren (Rekursion).
- 69 -
Aufwand für das Löschen:
Das Hochziehen eines neuen Separators wird höchstens einmal ausgeführt.
Das Verschieben wird bei der Rebalancierung ebenfalls höchstens einmal ausgeführt.
Das Verketten wird bei der Rebalancierung höchstens hT mal ausgeführt.
Deshalb ist der Gesamtaufwand für das Löschen in O(hT)
Falls beim Rebalancieren Söhne der Wurzel verschoben werden, so verliert die Wurzel
einen Schlüssel.
Falls die Wurzel dann nur genau einen Schlüssel hätte, so schrumpft die Höhe des Baumes um 1.
Im allgemeinen gilt: hT - 1 ≤ hT’ ≤ hT.
Beispiel: Löschen
i)
Das Löschen von s = 11 stellt kein Problem dar, da das zugehörige Blatt 4 Schlüssel
enthält.
ii)
Beim Löschen von s = 5 kann man verschieben und erhält
iii)
Beim Löschen von s = 13 kann man verschmelzen und erhält:
iv)
Beim Löschen von s = 18 wird der rechte Schlüssel s’ = 17 des „linken Teilbaums“ von 18
hochgezogen. Danach wird der Baum rebalanciert.
- 70 -
Seitengrößen bei B-Bäumen:
Seitengröße 1KB = 210 Bytes
für einen Schlüsselwert + einen Zeiger: 10 Bytes
Î pro Seite maximal k = 100 Schlüsselwerte, d.h. m = 50. 50 ≤ k ≤ 100.
Beispiel: Typ m = 50
h = 0:
h = 1:
h = 2:
h = 3:
2 * 51h - 1 ≤ |S| ≤ 101h+1 - 1
1
≤ |S| ≤ 100
101 ≤ |S| ≤ 10.200
5.201 ≤ |S| ≤ 1.030.300
265.301 ≤ |S| ≤ 104.060.400
Bei praktischen Problemstellungen sind die Höhen der B-Bäume gewöhnlich höchstens 3.
Bei jeder Basisoperation sind dann höchstens 8 Seitenwechsel nötig.
Höhenvergleich:
B-Bäume:
n = |S|
1 / (log2(2m+1) * log2(n+1) - 1 ≤ h ≤ 1 / (log2(m+1) * (log2(n+1) - 1)
AVL-Bäume:
log2(n+1) - 1 ≤ h ≤ 1,44 * ( log2(n+1) + ½ * log2(5)) - 3
Beispiel: m = 50, |S| = 50.000.000
25 ≤ hAVL ≤ 35
3 ≤ hB
≤ 4
Bearbeitungszeit für eine Seite
(beim Suchen im B-Baum)
Seien k Schlüsselwerte in der Seite gespeichert:
t = α + β + (log2k) * γ
α: Aufwand zum Positionieren des Schreiblesekopfes der Platte auf die gewünschte Seite
(mittlere Zugriffszeit)
β: Aufwand für das Laden der Seite
γ: Aufwand für einen Schlüsselwertvergleich im Hauptspeicher
(beim binären Suchen innerhalb einer Seite)
Realistische Größenordnungen sind etwa (Seagate Cheetah: 10.000 U/min, UW-SCSI)
mittlere Zugriffszeit: 7,5 ms
Durchsatz:
16, 8 MB/s
α = 7,5 ms
β = 60 µs
( Faktor 116 langsamer als α )
γ=0
( vernachlässigbar gegenüber α und β )
- 71 -
B*-Bäume (blätterorientierte Version der B-Bäume)
Relation: Datensatz i Æ Schlüssel si + beschreibende Attribute -bi
-
-
-
Die Nicht-Blatt-Knoten dienen - wie üblich - als Wegweiser zu den Schlüsselwerten. Sie bilden einen B-Baum vom Typ m für eine Menge S* von Separatoren.
Die Datensätze werden entsprechend ihrer Schlüsselwerte aus S disjunkt auf die Blätter verteilt, welche zwischen m’ und 2m’ Datensätze enthalten.
Die Separatoren separieren alle Knoten des Baumes - auch die Blätter - entsprechend der Suchbaumeigenschaften (a), (b), (c) mit „≤“ ( anstelle von „<“)
Beispiel: B*-Baum vom Typ m=2, m’=1
n Blätter
n-1 Separatoren
( n = 14 )
( n - 1 = 13 )
S = <1, 25>
s* = {3, 5, 7, 9, 11, 12, 14, 16, 18, 19, 21, 23, 25}
Auf der Blätterebene des B*-Baums ist bereits die gesamte Information mit den vollständigen Datensätzen (si, -bi) repräsentiert.
Anwendung:
B*-Bäume werden benutzt, wenn zu den zu den Schlüsseln si auch beschreibende Attribute mit viel
Speicherverbrauch gespeichert werden sollen. So müssen nicht bei jeder Suche auch alle vorhergehenden Daten, sondern nur die gesuchten eingelesen werden.
- 72 -
Kapitel 6: Hashverfahren
hash: engl. für „zerhacken“ Å getrennte Speicherung
6.1 Grundbegriffe
Wir unterstellen ein direkt adressierbares Speichermedium mit einer Menge A ⊆ N von Adressen, dem
Adressraum.
Die Datensätze werden durch Schlüsselwerte s ∈ N repräsentiert.
Es gibt eine universelle Schlüsselwertmenge S ⊆ N.
Zu jedem Zeitpunkt ist immer eine Teilmenge S ⊆ S gespeichert.
Die Zuordnung eines Schlüsselwerts s ∈ S auf einen Speicherplatz a ∈ A erfolgt mittels einer arithmetischen Funktion f: S Æ A, genannt Hashfunktion: a = f(s) heißt Hausadresse von s.
Beispiel:
4 10 18 19
s
f(s) 3 6 2 7
Bei B-Bäumen (bzw. B*-Bäumen) erfolgte dagegen die Zuordnung der Schlüsselwerte s auf die Speicherplätze - in diesem Fall die Datenseiten - durch Traversieren des Baumes gepaart mit Schlüsselwertvergleichen „s < s’ ?“: Æ datenorientierte Strukturierung.
Die Strukturierung mittels Hashverfahren nennt man dagegen speicherorientiert.
Die Zuordnung eines Schlüsselwerts s mittels einer Hashfunktion f auf einen Speicherplatz erfolgt
unabhängig von der aktuell gespeicherten Menge S von Schlüsselwerten.
Standardannahmen für Hashfunktionen f: S Æ A
S = <1, N> ⊆ N
A = <1, M> ⊆ N
Ist f injektiv, so heißt f auch direkt, anderenfalls heißt f eigentliche Hashfunktion.
Meist gilt M < N und f ist eine eigentliche Hashfunktion.
- 73 -
Beispiele:
i) N = 30, M = 70:
ii) N = 30, M = 11:
1
s
f1(s) 8
f2(s) 1
2
10
1
f1(s) = 6 + 2 * s
f2(s) = 1 + ⎣s/3⎦
3
12
2
4
14
2
5
16
2
direkt
eigentlich
6
18
3
...
...
...
27
60
10
28
62
10
29
64
10
30
66
11
Synonyme
Zwei unterschiedliche Schlüsselwerte s, s’ ∈ S heißen Synonyme, wenn sie
dieselbe Hausadresse f(s) = f(s’) haben.
Die Äquivalenzklassen Si ⊆ S mit
∀s ∈ Si : f(s) = i ∈ A
heißen Synonymklassen, und sie bilden eine disjunkte Zerlegung von S.
Warum „eigentliche Hashfunktionen“?
Direkte Speicherfunktionen führen häufig zu einer Speicherplatzverschwendung. Denn es gilt dann:
|S| < |A|, wegen der Injektivität von f, und
|S| << |S|, da meist nur ein kleiner Prozentsatz aller möglichen Schlüsselwerte gespeichert
wird.
Dies führt zu einem sehr geringen Belegungsfaktor α = |S| / |A| << 1.
Deshalb wählt man eigentliche Hashfunktionen mit |S| > |A|.
Dabei nimmt man in Kauf, dass zwei Schlüsselwerte s, s’ ∈ S zu speichern sind, welche auf dieselbe
Hausadresse abgebildet werden (Synonyme).
Bei solchen Kollisionen sind dann weitere Maßnahmen erforderlich um die Wörterbuchoperationen
(Suchen, Einfügen und Löschen) durchführen zu können.
Ein Hashverfahren besteht somit aus zwei Komponenten:
einer (eigentlichen) Hashfunktion und
[einfach zu berechnen]
einer Kollisionsstrategie
[aufwändiger]
- 74 -
Kollisionswahrscheinlichkeit
Man sollte die Hashfunktion so wählen, dass die Kollisionswahrscheinlichkeit möglichst gering wird.
Allerdings ist die Wahrscheinlichkeit für eine Kollision i.a. recht groß.
(Das Auftreten mehrerer Kollisionen ist unwahrscheinlicher)
Geburtstags-Paradoxon:
Auf einer Party treffen sich zufällig n Personen:
S = {p1, ..., pn}.
Ihre Geburtstage f(pi) = gi seien gleichverteilt über das Jahr und stochastisch unabhängig:
A = <1, 365>, M = 365
Frage: Wie groß ist die Wahrscheinlichkeit P(n, M), dass keine zwei Personen am gleichen Tag
Geburtstag haben? (keine Kollision)
Zahl der möglichen Fälle: Mn („Ziehen mit Zurücklegen“)
Zahl der günstigen Fälle: M * (M-1) * (M-n+1)
(„Ziehen ohne Zurücklegen“)
P(n, M) = M * (M-1) * ... * (M-n+1) / Mn
Î Schon für n ≥ 23 gilt P(n, M) < ½ .
Î Für n = 23 ist der Belegungsfaktor α = n / M = 23 / 365 < 1/15
dagegen immer noch sehr niedrig.
6.2 Gebräuchliche Hashfunktionen
i) Division mit Rest
Sei der Adressraum A = <1, M> = {1, 2, 3, ..., M-1, M}.
Hashfunktion f: S Æ A mit
f(s) = b + s mod M
mit einer Basisadresse b ∈ N+, meist b = 1.
Beispiel:
Sei N = |S| = 1.000.000 und n = |S| = 10.000, und sei M = 15.000, d.h. A = <1, 15.0000>.
Dann erhalten wir
f(s) = 1 + s mod 15.000
(für b = 1)
5
s
f(s) 6
15.000 17.000 107.000
1
2.001 2.001
nicht injektiv!
Belegungsfaktor α = |S| / |A| = 2/3
Ist |A| = 10k , k∈ N+, eine Zehnerpotenz, so entspricht die Modulo-Berechnung dem Abschneiden der
letzten k Stellen von s bei Dezimaldarstellung („Sectioning“).
- 75 -
Aufgrund der häufig benutzten Dezimalklassifizierung und der Konkatenation von Teilschlüssel zu
Gesamtschlüsseln ist allerdings die Kollisionsgefahr beim Sectioning recht hoch.
Ist die Zahl n = |S| der zu speichernden Schlüsselwerte zeitlich relativ konstant so hat es sich praktisch
bewährt für die Größe M des Adressraums eine Primzahl
M ≥ 1,3 * |S|
zu wählen. (Dann gilt α <= 0,8)
ii) Basistransformation
Jeder Schlüsselwert s ∈ S lässt sich schreiben als
s = s0 + s1*10 + s2*102 + ... + sk*10k = Σi=0k (si*10i), si ∈ <0, 9>
(Dezimaldarstellung)
Wir wählen nun eine neue Basis b N+:
f: S Æ A
f(s) = Σi=0k (si * bi)
Beispiele:
b = 11: f(512) = 5*112 + 1*11 + 2*1 = 618
b = 9: f(512) = 5*92 + 1*9 + 2*1 = 416
b = 5: f(512) = 5*52 + 1*5 + 2*1 = 29 = 5*5 + 4*1 = f(54)
Die Basistransformation ist für b > 10 injektiv, d.h. man erhält keine Kollisionen.
Man kann die Basistransformation mit der Division mit Rest kombinieren:
f(s) = 1 + ( Σi=0k (si * bi) ) mod M.
iii) Multiplikationsmethode
Für den Adressraum A=<1,M> und eine reelle Zahl b>0 betrachten wir die Hashfunktion f: SÆA mit:
f(s) = ⎣(s*b - ⎣s*b⎦) * M ⎦ + 1
Dabei ist (s*b - ⎣s*b⎦) ∈ [0, 1] jeweils der „gebrochene Anteil“ von s*b.
Nach einem Satz von Vera Turan Sos sind diese gebrochenen Anteile gleichmäßig im Intervall [0, 1]
verstreut, falls b als irrationale Zahl gewählt wird.
Von allem Zahlen b, 0 < b < 1, führt der goldene Schnitt
b = (√5 - 1)/2 ≈ 0,618
zur gleichmäßigsten Verteilung (Æ Fibonacci-Hashing).
Beispiel: Fibonacci-Hashing für M=10
1 2 3 4 5 6 7 8 9 10
s
f(s) 7 3 9 5 1 8 4 10 6 2
f(s) ist Permutation von <1, 10>
- 76 -
6.3 Kollisionsstrategien
i) Überlaufhash
Hier wird ein Überlaufbereich für alle Hausadressen eingerichtet.
Dieser wird i.a. durch eine lineare Liste realisiert.
Sei Sa = {s ∈ S| f(s) = a} die Menge der gespeicherten Synonyme zur Hausadresse a.
Ist Sa = ∅, so ist keine Schlüssel in a gespeichert. Ansonsten ist genau ein Synonym s ∈ Sa in a gespeichert, und alle anderen Synonyme s’ ∈ Sa , s’ ≠ s, im Überlaufbereich.
Aufwand für die Wörterbuchoperationen:
Im schlechtesten Fall sind alle gespeicherten Schlüsselwerte Synonyme - d.h. sie haben dieselbe Hausadresse. Dann liegen |S| - 1 Schlüssel im Überlaufbereich.
(n = |S|)
Cmax(n) = Θ(n)
Speicherbelegung:
b: A Æ S
Für alle Schlüssel s, welche auf ihrer Hausadresse a = f(s) gespeichert sind, gilt b(a) = s.
Für alle Hausadressen a, zu denen keine Schlüssel gespeichert sind, gilt b(a) = ⊥.
Suchen nach s ∈ S
Berechne die Hausadresse a = f(s).
Ist b(a) = ⊥, so ist s ∉ S (erfolglose Suche)
Ansonsten suche s zuerst in a und dann im Überlaufbereich
Einfügen von s ∈ S
Ist b(f(s)) = ⊥, so speichere s auf seiner Hausadresse.
Ansonsten suche s zuerst in f(s) und dann im Überlaufbereich.
Falls s nicht gefunden wurde, so speichere s im Überlaufbereich.
(Falls s gefunden wurde, so ist nichts zu tun.)
Löschen von s ∈ S
Suche den Schlüssel s. Falls s gefunden wird, d.h. s ∈ S, so entferne s wie folgt:
Ist s auf seiner Hausadresse gespeichert, d.h. b(f(s)) = s, so ersetze s auf f(s) durch einen synonymen Schlüssel s’ - d.h. mit f(s’) = f(s) aus dem Überlaufbereich.
Das Löschen im Überlaufbereich erfordert keine Folgeaktionen.
Falls s nicht gefunden wird, d.h. s ∉ S, so ist nichts zu tun.
- 77 -
Average-Case: für Suche
Wir setzen Gleichverteilung auf A voraus, d.h. jeder Schlüssel s hat mit Wahrscheinlichkeit 1/M die
Hausadresse f(s) = a, für alle a ∈ A.
Sei n’ die Anzahl der Schlüssel aus S im Überlaufbereich.
Dann sind n’’ = n - n’ Schlüssel auf ihrer Hausadresse gespeichert.
Für erfolgreiche Suche ist der Aufwand
n''
n'
i=1
i=1
∑1/n * 1 + ∑ 1/n * (i+1) =
= 1/n * (n’’ + (n’+1)*(n’+2)/2 - 1) =
= 1/n * (n-n’ + (n’2 + 3n’ + 2)/2 - 1) =
= 1 + (n’2 + n’)/2n
Man kann zeigen: E[n’] ≤ α/2 * n und E[n’2] ≤ n * E[n’].
Deshalb gilt:
CmitSuchen(n) ≤ 1 + 1/(2n) * (n * α/2 * n + α/2 * n)
= 1 + α/4*(n+1).
Für die erfolglose Suche ist der Aufwand
M−n''
n''
i=1
i=1
∑1/M * 1 + ∑1/M * (n’+1) =
= 1/M * (M - n’’ + n’’(n’+1) ) =
= 1 + n’/M * (n - n’)
Hier kann man zeigen
CmitSuchen(n) ≤ 1 + α + α2/2
ii) Überlauf mit Verkettung
Hier werden für jede Hausadresse ein spezieller Überlaufbereich eingerichtet, i.a. als lineare Liste.
- 78 -
Prinzip der Speicherung:
Ist Sa = ∅, so ist kein Schlüssel in a gespeichert.
Ansonsten ist genau ein Synonym s ∈ Sa in a gespeichert und alle anderen
Synonyme s’ ∈ Sa, s’ ≠ s, sind im Überlaufbereich zu a gespeichert.
Aufwand für die Wörterbuchoperationen:
(n = |S|)
Cmax(n) = Θ(n)
Average-Case Suchaufwand:
Gleichverteilung auf A: Jede Adresse a ∈ A wird durch die Hashfunktion f: S Æ A mit der gleichen
Belegungswahrscheinlichkeit p(a) belegt:
p(a) = 1/|A| = 1/M, für alle a ∈ A.
Entscheidend ist der Erwartungswert für die Länge der Überlaufbereiche, d.h. die Größe ξa = |Sa| der
Synonymklassen.
Die Anzahlen ξa, a ∈ A, sind identisch verteilte Zufallszahlen mit
ξ1 + ξ2 + ... + ξM = |S| = n.
Für der Erwartungswert ξ für die Größe der Synonymklassen gilt dann:
M
ξ=
M
∑ ξ * p(i) = 1/M * ∑ ξ
i=1
i
i=1
i
= n/M = α.
Wir unterstellen nun noch gleiche Suchhäufigkeiten
p’(s) = 1 / |S| = 1/n, für alle s ∈ S.
p’’(s) = 1 / |S\S| = 1/(N-m), für alle s ∈ S\S.
p’: erfolgreiche Suche, p’’: erfolglose Suche
Dann erhalten wir für erfolgreiche Suche:
CmitSuchen(n) = 1 + (ξ - 1)/2 = (1 + α)/2,
denn es muss die Hausadresse durchsucht werden und im Mittel dann noch der halbe Überlaufbereich.
Unter der Voraussetzung |S| ≤ |A|, d.h. α ≤ 1, folgt:
CmitSuchen(n) ∈ O(1).
Für erfolglose Suche erhalten wir:
CmitSuchen(n) = ξ = α,
denn es muss die komplette Synonymklasse (Hausadresse und Überlaufbereich) durchsucht werden.
Auch hier gilt für |S| ≤ |A|
CmitSuchen(n) ∈ O(1).
Fazit:
Im Durchschnitt ist das Hashing mit „Überlauf mit Verkettung“ für die Wörterbuchoperationen recht
gut geeignet („konstanter Aufwand“).
Im schlechtesten Fall ist es dagegen viel schlechter als zu logarithmischer Höhe balancierte Bäume
(„linearer Aufwand“ vs. „logarithmischer Aufwand“).
- 79 -
iii) Offener Hash (Open Adressing)
Für jeden Schlüsselwert s ∈ S wird eine Permutation
π(s) = (ai1, ai2, ..., aiM)
der Menge A = {a1, ..., aM} aller Hausadressen angegeben.
Ein einzufügender Schlüsselwert s ∈ S wird unter der ersten freien Adresse aij abgelegt.
Bezeichnung:
ai1 = f(s) ist die Hausadresse von s.
ai2, ..., aiM heißen Ausweichadressen.
a) Lineares Suchen (Linear Probing)
π+(s) = ( f(s), f(s)+1, ..., M-1, M, 0, 1, ..., f(s)-1 )
π-(s) = ( f(s), f(s)-1, ..., 1, 0, M, M-1, ..., f(s)+1 )
(Beispiel siehe Skript Seite 171)
Primäres Clustering
Lineares Sondieren ist zwar einfach, es gibt aber auch Nachteile:
Lange, schon besetzte Teile, haben durch die lineare Sondierungsfolge, eine größere Tendenz zu
wachsen als kurz besetzte. Außerdem werden die Lücken zwischen längeren Teilstücken geschlossen,
so dass noch größere Teilstücke entstehen (to coalesce). Dieses Ereignis der primären Häufung (primary clustering), verschlechtert die Effizienz sehr stark, wenn der Belegungsfaktor α gegen 1 geht.
Nach kontinuierlicher Belegung eines Teilraumes { a, a+1, ..., a+k} ⊆ A des Adressraumes müssen
beim Einfügen mit der Ausweichadresse a auch die Ausweichadressen a+i, 1 ≤ i ≤ k, erfolglos aufgesucht werden.
Average-Case-Suchaufwand
(α < 1 geeignet)
erfolgreiche Suche
(n) ≈ ½ * (1+ 1/(1-α) ) < 3/2, für α < ½,
Cmit
Cmiterfolglose Suche(n) ≈ ½ * (1+ 1/(1-α)2 ) < 5/2, für α < ½.
b) Doppel-Hash (Double Hashing)
Der Nachteil des primären Clustering kann vermieden werden, wenn man die Inkremente aij+1 - aij in
der Folge π(s) der Folge der Ausweichadressen vom Schlüssel s abhängig macht.
Dazu benutzt man eine zweite Hashfunktion
f’: S Æ A = <1, M>
welche Inkrementfunktion genannt wird.
Die Permutation der Ausweichadressen für s ist dann
π(s) = ( ai1, ai2, ..., aiM)
mit
aij = 1 + ( f(s)-1 + (j-1)*f’(s) ) mod M, 1 ≤ j ≤ M.
- 80 -
Sei ggT(a,b) der größte gemeinsame Teiler zweier natürlicher Zahlen a, b ∈ N+.
z.B. ggT(17, 4) = 1, ggT(16, 4) = 4, ggT(16, 24) = 8.
Lemma:
π(s) ist eine Permutation von A = <1, M>
Ù ggT( f’(s), M ) = 1
(d.h. f’(s) und M sind teilerfremd).
Beweis:
Seien j, k ∈ <1, M>:
aij = aik Ù f(s) + (j-1)*f’(s) ≡mod M f(s) + (k-1)*f’(s) Ù (j-k)*f’(s) ≡mod M 0.
Falls ggT(f’(s), M) = 1, so gilt:
aij = aik => j-k ≡mod M 0 => j = k
Also ist π(s) in diesem Fall eine Permutation von <1, M>.
Falls ggT(f’(s), M) = g > 1, so gilt für j = (k + M/g) mod M: k ≠ j, aber
(j-k) * f’(s) ≡mod M M/g * f’(s) ≡mod M 0 => aij = aik.
Also ist π(s) in diesem Fall keine Permutation von <1, M>.
Beim Doppel-Hashing fordert man deshalb für alle s ∈ S:
ggT(f’(s), M) = 1
Die Doppel-Hash-Verfahren erwiesen sich in der Praxis als ausgezeichnete Verfahren.
Bei allen offenen Hashverfahren hängt die Suchzeit von der Reihenfolge des Ladens der Schlüsselwerte ab. (Genau wie bei Suchbäumen auch.)
Beispiele:
1.) Sei M eine Primzahl. Dann ist ggT(i, M) = 1, für alle 1 ≤ i ≤ M-1, d.h. f’(s) ∈ <1, M-1> kann
(fast) beliebig gewählt werden.
2.) Sei M=2k, k ∈ N+, eine Zweierpotenz. Dann gilt ggT(i, M) = 1 Ù i ungerade,
d.h. f(s) ∈ <1, M> \ 2*N kann gewählt werden.
3.) Seien M-2 und M Primzahlen-Zwillinge. Dann kann man wählen:
f’(s) = 1 + (s mod (M-2) )
Es gilt dann ggT(f’(s), M) = 1, für alle s ∈ S.
- 81 -
Herunterladen