Datenstrukturen

Werbung
Algorithmus
Korrekt, einfach zu verstehen, zu implementieren, geringstmöglicher Platz- und
Laufzeitbedarf.
Laufzeitkomplexität
Anzahl der Elementaroperationen in Abhängigkeit der Eingabekomplexität.
Abstraktion: Keine Unterscheidung der Elementaroperationen, Menge aller Eingaben in Komplexitätsklassen zerlegen, und in diesen nur noch den best-,
average-, worst-case betrachten, Faktoren und Summanden ignorieren. Es wird
also nur noch das Wachstum betrachtet.
O-Notation
f, g : N → R+ . f = O(g) :⇔ ∃n0 ∈ N, c ∈ R+ : ∀n ≥ n0 : f (n) ≤ cg(n) ⇐
monotonie ⇒ ∃ lim fg(n)
(n) (d.h. f wächst höchstens so schnell wie g).
n→∞
f = O(g) : f ≤ g, f = o(g) : f < g, f = Θ(g) : f = g, f = ω(g) : f > g, f =
Ω(g) : f ≥ g. Sei Ω(g) die (mindest)Komplexität eines Problems P und sei
f = O(g) die Laufzeitkomplexität des Algorithmuses A der P löst, dann ist A
asymptotisch optimal.
externer Algorithmus
Wir sprechen von einem externen Algorithmus, wenn zur Verarbeitung einer Objektmenge der Größe n nur O(1) interner Speicher benötigt wird und Ω(n) externer
Hintergrundspeicherplatz (z.B. Festplatte). Eine externe Datenstruktur (=Speicherstruktur) ist vollständig auf Hintergrundspeicher dargestellt.
Das Lesen/Schreiben von k Bytes im Hauptspeicher ist in etwa k-mal so teuer wie
das Lesen/Schreiben von einem Byte, deshalb ist eine Kostenrechnung pro Elementaroperation hier sinnvoll. Ein Byte von einem Plattenspeicher lesen/schreiben ist
unwesentlich billiger als 1 Kilobyte zu lesen/schreiben, da die Hauptzeit für das Positionieren des Lesekopfs verwendet wird und für das Warten darauf, dass das richtige
Segment unter selbigen ist. Deshalb werden Daten immer in größeren Blöcken von
der Platte gelesen die man auch als (Speicher)Seiten bezeichnet.
Daher betrachtet man i.d.R. als Kostenmaß für die Laufzeit eines externen Algorithmus die Anzahl der Seitenzugriffe und für den Platzbedarf die Anzahl der
belegten Seiten. Ein weiteres interessantes Maß für die Speicherplatzausnutzung
ist die Anzahl der benutzten Bytes pro verfügbarer Bytes in den benutzten Seiten(in Prozent).
Listen
Es gibt eine Anordnung also auch Vorgänger und Nachfolger, Elementduplikate
sind möglich.
Algebren
list1 sorts l, e, b ops f irst : l → e, rest : l → l, append : l × e → l, concate :
l×l →l
Implem. list1
Implem. list2
list2 sorts l, e, b, p ops f ront|last : l → p, next|previous : l → p∪ ⊥, bol|eol :
l × p → b, insert : l × p × e → l, delete : l × p → l, concat : l × l → l, f ind :
l × (e → b) → p ∪ null, retrive : l × p → e.
Stack (LIFO) top=first, push = append, pop=rest, stack=list. Wird i.d.R. im
Array implementiert.
Queues (FIFO) front = first, enqueue = rappend1 , dequeue = rest, queue=list.
Wird häufig in zyklischen Arrays implementiert.
Doppelt verkettete Liste alle Operationen werden effizient unterstützt.
Einfach verkettete Liste pos zeigt immer auf den Vorgänger damit kann delete
effizient implementiert werden und durch einen Zeiger auf das erste und
1 append
wird hier durch rappend = rare append ersetzt
1
letzte Listenelement kann Einfügen am Anfang und concat effizient implementiert werden. Delete für das letzte Element und previous können
nicht effizient implementiert werden, Positionen sind instabil bei Änderungsoperationen.
Im Array Nachteile sind der höhere Implementierungsaufwand, die Größe des
Arrays ist festgelegt. Vorteile sind, dass die Struktur gespeichert werden
kann und der Hauptspeicher für jedes Element nicht vom Betriebssystem
angefordert werden muss.
Abbildungen
Binäre Bäume
(mapping) sorts mapping =: m, domain =: d, range =: r ops assign: m × d ×
r → m, apply: m × d → r ∪ ⊥. Entspricht einem Array auf Implementierungseben.
(i) Der leere Baum (3, ) ist ein binärer Baum.
(ii) Sei x ein Knoten, T1 und T2 binäre Bäume, dann ist (T1 , x, T2 ) auch ein
binärer Baum.
Bezeichnungen
x heißt Wurzel, T1 linker T2 rechter Teilbaum. Seien t1 , t2 die Wurzeln von T1
und T2 , dann heißen sie Söhne von x oder auch Geschwister/Brüder und x
heißt Vater von t1 und t2 . Ein Knoten ohne Söhne heißt Blatt. Eine Folge von
Knoten p0 , p1 , . . . , pn mit pi ist Vater von pi+1 heißt Pfad. Sei p0 , . . . , pn ein
Pfad, dann heißen die Knoten p0 , . . . , pn−1 Vorfahren von pn . Alle Knoten die
von einem Knoten p aus erreichbar sind heißen Nachfahren von p. Die Anzahl
der Knoten weniger eins in einem Pfad ist die Länge des Pfades. Die Höhe
eines Baumes ist die Länge des längsten Pfades im Baum. Die Pfadlänge von
der Wurzel bis zu einem Knoten p ist die Tiefe von p.
max./min. Höhe
Die maximale Höhe eines Baumes mit n Knoten ist n-12 , die minimale Höhe
ist O(log2 n) (exakt blogn c.
Haben alle inneren Knoten zwei Söhne, dann hat ein binärer Baum mit n + 1
Blättern n Knoten.
Algebra
Implementierung
allg. Bäume
sorts tree =: t, e, l ops maketree : t×e×t → t, key : t → e, lef t/right : t → t,
in−, pre−, postorder : t → l.
Mit Zeiger Jeder Knoten enthält z.B. einen Record mit einem Zeiger auf den
linken Sohn und eine auf den Rechten (natürlich auch noch Datenfelder
für Informationen).
Im Array Die Knoten eines vollständigen Baumes werden von links nach rechts
aufsteigend durchnummeriert, dann wird der i-te Knoten in das i-te Arrayelement geschrieben. Damit ergibt sich, dass der linke Sohn des iten Knoten im Array A an Position A[2i] steht, der rechte an Position
A[2i + 1].
(i) Ein einzelner Knoten ist ein Baum.
(ii) Sei x ein Knoten und seien T1 , . . . , Tk Bäume, dann ist (x, T1 , . . . , Tk ) ein
Baum.
Bezeichnungen
Der Grad eines Knoten ist die Anzahl seiner Söhne. Der Grad eines Baumes
ist der höchste Grad den einer seiner Knoten hat. Ein Wald ist eine Menge von
Bäumen.
min./max. Höhe
Die maximale Höhe eines Baumes vom Grad d mit n Knoten ist n-1, die mini2 Entartung
zur Liste
2
Implementierung
male Höhe ist O(logd n).
Mit Arrays Der Grad d des Baumes wird festgelegt; ein Knoten wird dann
durch ein Array mit d Elementen repräsentiert. Vorteil: Zugriff auf einen
Sohn in konstanter Zeit (O(1)). Nachteil: Wird der Grad nur selten erreicht wird Platz verschwendet; die max. Anzahl Söhne pro Knoten ist
durch den Grad beschränkt.
Im Binärbaum Die Wurzel enthält nur den linken Sohn von diesem zweigt links
der erste eigene Sohn ab während rechts ein Bruder abzweigt. Man kann
also allgemein sagen, dass von einem Knoten3 links der erste eigen Sohn
abzweigt und rechts ein weiterer Sohn des Vaterknoten (also ein Bruder).
Vorteil: der Platzbedarf ist O(n) die Anzahl der Söhne ist nur durch die
vorhandenen Ressourcen beschränkt. Nachteil: Zugriff auf einen Sohn ist
nicht mehr in konstanter Zeit möglich.
Mengen
∩∪\
Implementierung
set1 sorts set =: s, e, l ops insert : s × e → s, union : s × s → s, diff erence :
s × s → s, enumerate : s → l.
Bitvektor insert O(1); empty, enumerate O(N ); ∩ ∪ \ O(N ). N ist die Größe
des Universums.
ungeordnete Liste ∩ ∪ \ O(n · m) denn bei ∩ und \ muss jedes Element mit
jedem verglichen werden; bei ∪ muss auf Dubletten geprüft werden.
geordnete Liste Ist die beste Implementierung. insert und enumerate können
in O(n) realisiert werden. Durch die Technik des parallelen Durchlaufs
benötigen ∩ ∪ \ nur O(n + m) Zeit.
paralleler Durchlauf
Hierbei wird das erste Element der ersten Liste gemerkt und die zweite Liste
durchsucht bis ein Element größer oder gleich dem gemerkten Element gefunden wird. Anschließend kann die, der Operation entsprechende, Verarbeitung
durchgeführt werden. Sind die Elemente gleich gehören sie in den Durchschnitt
bzw. entfernt wenn es sich um die Differenz handelt. Bei der Vereinigung kann
an dieser Stelle eine die Ordnung erhaltende Verkettungsoperation durchgeführt
werden. Ist die Verarbeitung beendet merkt man sich das aktuelle Element der
zweite Listen und durchwandert die Erste nach dem selben Prinzip.
Dictionaries
Sind Mengen mit INSERT, DELETE und MEMBER.
set2 sorts elemset =: s, e, b ops insert : s × e → e, delete : s × e → s,
member : s × e → b.
sequentiell geordnete Liste im Array insert und delete benötigen O(n), member
O(log n) und Platz O(N ) mit N ist Größe des Arrays.
ungeordnete Liste insert benötigt O(1) mit Duplikatsprüfung O(n) Zeit, delete
und member O(n), Platzbedarf ist O(n).
geordnete Liste insert, delete, member und Platz benötigen O(n).
Bitvektor insert, delete, member benötigen nur konstante Zeit der Platzbedarf
ist O(U ) mit U ist Größe des Universums. Dies ist nur für kleine Mengen
(Universen) praktikabel.
einfache Implementierungen
Hash
Wert/Schlüssel eines Mengenelements wird durch die Hashfunktion in die Speicheradresse umgerechnet unter der sie in einem sog. Bucket (Behälter) abgelegt
wird.
3 mit
Ausnahme der Wurzel
3
Hashfunktion
Die Hashfunktion h : D → {0, 1, . . . , m − 1} sollte surjektiv und effizient zu
|
{z
}
m−Behälter
berechnen sein und die Elemente gleichmäßig verteilen.
Kollision
Wird ein Schlüssel auf einen schon besetzten Behälter abgebildet erfolgt eine
Kollision mit der Wahrscheinlichkeit Pk = 1 − P¬k = 1 − P¬k (1) · P¬k (2) · . . . ·
m m−1
· m · . . . · m−(n−1)
.
P¬k (n) = 1 − m
m
Es kommt also bei m=365 Behältern mit dem Einfügen des n=50sten Wertes
mit einer Wahrscheinlichkeit von 97% zu einer Kollision4 .
offenes
Hashing
Jeder Behälter wird als Liste implementiert. Im Mittel gilt n Elemente pro m
n
); bei einer gleichmäßigen Verteilung ergibt sich für die Operationen
Buckets ( m
n
Suchen, Einfügen, Entfernen eine Laufzeitkomplexität von O(1 + m
) = O(1).
Im Worst-Case (Entartung zur Liste) O(n) und ein Platzbedarf von O(n + m).
Eine Kollision stellt kein Problem dar, da das kollidierende Element an der
entsprechenden Liste angefügt wird.
geschlossenes Hashing
Hier ist die Zahl der Einträge auf eine Tabelle mit m · b Einträge beschränkt.
(Sei b = 1).
Kollisionsstrategien
Hashfunktionen
Zusammenfassung
Kommt es beim geschlossenen Hashing zu Kollision muss eine rehashing Operation durchgeführt werden. Hierzu werden rehashing Funktionen h1 , h2 , . . . , hm−1
definiert, welche für den kollidierten Schlüssel x die Buckets h1 (x), . . . , hm−1 (x)
inspizieren. In die erste freie/gelöschte Stelle wird der Schlüssel abgelegt. Beim
Suchen nach x wird die gleiche Folge betrachtet.
lineares Sondieren hi = (h(s)+c·i) mod m, c /m,
|
damit alle Behälter getroffen
5
werden. Nachteil Kettenbildung im Abstand c.
quadratisches Sondieren h2i−1 (x) = (h(x) + i2 ) mod m, h2i (x) = (h(x) − i2 +
und m = 4 · j + 3, m ist Prim, damit
m2 ) mod m mit 1 ≤ i ≤ m−1
2
alle Buckets getroffen werden. Der Vorteil ist keine Clusterbildung beim
rehashing; für die Primärkollision ändert sich nichts.
Doppel-Hashing h und h0 sind voneinander unabhängige Hashfunktionen. Damit folgt für die Kollisionswahrscheinlichkeit Pk (h(x) = h(y))∩Pk (h0 (x) =
h0 (y)) = m12 . Die Hashfunktion ist dann hi (x) = (h(x)+h0 (x)·i2 ) mod m.
Experimente zeigen, dass diese Methode von idealem Hashing praktisch
nicht unterscheidbar ist. Der Pferdefuß ist das finden (und beweisen)
zweier unabhängiger Hashfunktionen.
Divisionsmethode Sei der Schlüsselbereich ⊂ N: h(k) = k mod m.
Mittel-Quadrat-Methode Sei der Schlüsselbereich ⊂ N. Man bildet von dem
Schlüssel x das Quadrat und wählt von der resultierenden Ziffernfolge
xr , xr−1 , . . . , x1 = x2 einen Block von mittleren Ziffern aus. Da diese von
allen Ziffern in x abhängen erreicht man eine bessere Streuung aufeinanderfolgender Werte.
Schlechtes Worst-Case verhalten, keine sortierte Ausgabe der Schlüssel möglich6 .
Das Duchschnittsverhalten ist mit O(1) perfekt, alle Hashverfahren sind relativ
einfach zu implementieren.
4 Geburtstagsparadoxon
5 man
spricht auch von Clusterbildung
für binäre Suchbäume
6 Motivation
4
Bei dynamischen Anwendungen7 ist offenes Hashing die erste Wahl. Wird die
Tabellengröße um ein vielfaches überschritten sollte die Tabelle reorganisiert
werden. Bei einer beschränkten Anzahl der Elemente und wenig Löschoperationen wählt man i.d.R geschlossenes Hashing wobei man darauf achten muss,
dass die Auslastung unter 80% bleibt.
binäre Suchbäume
Sei T ein Baum, Tk seine Knotenmenge und D eine Menge auf der sich eine
Ordnung definieren lässt. Dann heißt µ : Tk → D eine Knotenmakierung von
T.
Ein knotenmarkierter binärer Baum T heißt binärer Suchbaum, gdw für jeden
Teilbaum T 0 von T mit T 0 = (Tl0 , y, Tr0 ) gilt
∀x ∈ Tl0 : µ(x) < µ(y), ∀z ∈ Tr0 : µ(z) > µ(y)8 .
Damit liefert ein inorder-Durchlauf die Knoten des Baumes als aufsteigen sortierte Liste entsprechend der auf D definierten Ordnung.
Alg. insert
insert(t,x) suche den Knoten x im Baum t. Wird x gefunden verlasse insert
ansonsten sei p der Zeiger an dem die Suche endete mit p=nil. Setze p auf x.
Alg. delete
delete(t,x) ist der Knoten x in dem Baum t enthalten dann unterscheiden die
folgenden Fälle:
x ist ein Blatt dann setze den entsprechenden Zeiger im Vaterknoten auf nil.
x hat genau einen Sohn dann ersetze den Zeiger des Vaterknotens, der auf x
zeigt, durch den Zeiger auf den Sohn von x.
x hat zwei Söhne dann bestimme den kleinsten Knoten, der größer x ist in dem
Teilbaum, der von x abzweigt und ersetze x durch diesen Knoten.
Damit der Baum nicht linkslastig wird durch häufiges löschen, sollte
zufällig bestimmt werden ob x durch den kleinsten Knoten der größer
als x ist ersetzt wird oder ob x durch den größten Knoten der kleiner als
x ist ersetzt wird.
Analyse
Der Aufwand von insert, delete und member ist proportional zum durchwanderten Pfad im worst-case also zur Höhe des Baumes, die schlechtesten falls n − 1
beträgt und zu einer linearen Laufzeitkomplexität9 führt (Aufbau O(n2 )).
Ist der binäre Suchbaum ausgeglichen beträgt die Laufzeitkomplexität O(log n).
AVL-Bäume
Ein binärer Suchbaum mit der Strukturinvarianten: “die Höhe der zwei Teilbäume
eines jeden Knoten unterscheidet sich höchstens um 1” heißt AVL-Baum 10 .
Nach einer Aktualisierung des Baumes
muss geprüft werden ob die Strukturinvariante noch erfüllt ist indem man vom aktuellen Knoten nach der Lösch- oder Einfügeoperation zurück zur Wurzel wandert und
für jedem Knoten auf dem Pfad auf Verletzung der Strukturinvariante prüft. Ist die
Strukturinvariante durch verletzt muss rebalanciert werden. Es gilt:
7 häufiges
Löschen und Einfügen
spricht hier von einer Strukturinvarianten
9 Motivation für balancierte Bäume
10 nach den Erfindern Adelson-Velskii und Landis
8 man
5
- Nach dem Einfügen genügt eine
Einfach- oder Doppelrotation um den
Baum wieder zu balancieren.
- Nach dem wird auch durch Einfachoder Doppelrotation balanciert, allerdings kann es vorkommen, dass durch
das Balancieren der Vaterknoten des
balancierten Teilbaums die Strukturinvariante verletzt und auch balanciert werden muss. Dies kann sich schlechtestenfalls bis zur Wurzel fortsetzen.
Zur Einfach- und Doppelrotation kann man sich die Faustregel merken: Bringt
ein “äußerer” Teilbaum (wie z.B. C in der Graphik) einen Baum aus der Balance
muss einfach rotiert werden, verletzt ein “innerer” Teilbaum die Strukturinvariante muss eine Doppelrotation durchgeführt werden.
Analyse
Man kann zeigen, dass die Höhe eines AVL-Baumes 1, 440(log n) beträgt. Da
auch hier wieder das Laufzeitverhalten für die drei Operationen proportional
zur Höhe des Baumes ist ergibt sich eine Laufzeitkomplexität von O(log n) für
alle drei Operationen. Der Platzbedarf ist O(n).
Um eine externe Implementierung des Dictionary-Datentyps zu realisieren fast man
Speicherseiten als Knoten eines Suchbaums auf. Um die Kosten für eine Suche, die
der Pfadlänge entsprechen, gering zu halten, wählt man Bäume mit hohem Verzweigungsgrad.
Vielweg-Suchbäume
-Der leere Baum ist ein Vielweg-Suchbaum mit der Schlüsselmenge ∅.
-Seien T0 , . . . , Ts Vielweg Suchbäume mit Schlüsselmengen T 0 , . . . , T s und sei
k1 , . . . , ks eine Folge von Schlüsseln, sodass gilt: k1 < k2 < . . . < ks . Dann ist
die Folge T0 k1 T1 k2 T2 k3 . . . ks Ts ein Vielweg-Suchbaum genau dann, wenn
gilt:
∀x ∈ T 0 : x < k1 ,
1, . . . , s − 1
∀x ∈ T s : ks < x,
Seine Schlüsselmenge ist {k1 , . . . , ks } ∪
∀x ∈ T i : ki < x < ki+1
Ss
i=0
mit
i=
T i.
B-Bäume
Ein B-Baum der Ordnung m ist ein Vielweg-Suchbaum mit folgenden Eigenschaften:
- Die Anzahl der Schlüssel in jedem Knoten mit Ausnahme der Wurzel liegt
zwischen m und 2m. Die Wurzel enthält mindestens einen und maximal
2m Schlüssel.
- Alle Pfadlängen von der Wurzel zu einem Blatt sind gleich.
- Jeder innere Knoten mit s Schlüsseln hat genau s+1 Söhne (d.h., es gibt
keine leeren Teilbäume).
Höhe
Betrachtet man einen minimal gefüllten11 B-Baum der Ordnung m mit n Schlüsseln,
kann man mit Hilfe der Strukturdefinition zeigen, dass für dessen Höhe h gilt:
h = O(log(m+1) n).
Einfügealgorithmus
insert (root, x) suche nach x im Baum mit Wurzel root;
wenn x nicht gefunden wird, dann sei p das Blatt, in dem die Suche endete;
11 jeder
Knoten außer der Wurzel hat m Schlüssel und somit m+1 Söhne
6
füge x an der richtigen Position in p ein; hat p nun 2m + 1 Schlüssel, dann
behandle den Overflow von p.
Overflow
Ein Overflow wird behandelt indem der Knoten p mit 2m+1 Schlüssel am
mittleren Schlüssel km+1 geteilt wird und dieser dann in den Vaterknoten wandert12 . Damit wird p zu zwei Knoten mit je m Schlüssel, die entweder alle
größer als m+1 oder kleiner sind und somit problemlos links bzw. rechts, von
dem in den Vaterknoten gewanderten Schlüssel m+1, angefügt werden können.
Läuft nun der Vaterknoten über, dann wird dieses Prozedere wiederholt bis der
Vaterknoten entweder weniger als 2m+1 Schlüssel enthält oder die Wurzel ist
(siehe Fußnote).
Löschalgorithmus
delete (root, x) suche nach x im Baum mit der Wurzel root; //Abbruch bei
erfolgloser Suche
Liegt x in einem inneren Knoten dann suche x’, den Nachfolger von x13 im
Baum14 ; vertausche x mit x’;
Lösche x nun aus dem Blatt p, das x enthält;
Ist p nicht die Wurzel und p hat nun m-1 Schlüssel dann behandle den Underflow von p.
Underflow
Der Underflow von p wird behandelt indem der direkte Nachbar15 p’ betrachtet
wird. Enthält dieser mehr als m Schlüssel, dann ermittle den mittleren Schlüssel
kr der gesamten Schlüsselfolge über p und p’, und ersetze den Vaterschlüssel y
von dem p und p’ abzweigen durch kr und schreibe alle Schlüssel < kr mit y
nach p (Baum ist balanciert).
Enthält p’ weniger als m Schlüssel dann verschmelze p und p’ indem der
Schlüssel y des Vaterknotens von dem p und p’ abzweigen in die Schlüsselmenge aller Schlüssel aus p und p’ einsortiert wird und verschmelze p und p’
zu einem Knoten p”. Durch das Entfernen von y aus dem Vaterknoten kann
dieser nun einen Underflow haben der ebenso behandelt wird bis hoch zur Wurzel falls nötig. Tritt an der Wurzel der Fall ein, dass diese keinen Knoten mehr
hat, dann entferne die Wurzel und mache den letzten Verschmolzenen Knoten
zur Wurzel.
Analyse
Priority Queus
Implementierung
Die die Kosten für eine Einfüge-, Such- oder Lösch-Operation sind proportional zur Höhe des Baumes. Der B-Baum unterstützt also alle DictionaryOperationen in O(log(m+1) n) Zeit mit einem Platzbedarf von O(n). Die Speicherplatzausnutzung ist mit Ausnahme der Wurzel immer besser als 50sorts
pqueue16 =: p, e ops insert : p × e → p, deletemin : p → p × e.
Ein Heap kann mit einem leicht modifizierten AVL-Baum implementiert werden
was zu einer Laufzeitkomplexität von O(log n) und einem Platzbedarf von O(n)
führt.
Eine, an das Problem angepasste Alternative, ist ein partiell geordneter Baum,
wobei ein knotenmarkierter Binärbaum T , in dem für jeden Teilbaum T 0 mit
12 Ist
p die Wurzel, dann wird m+1 zur neuen Wurzel
nächstgrößeren gespeicherten Schlüssel
14 x’ liegt in einem Blatt
15 Dieser existiert, da p nicht die Wurzel ist und der Vater von p wenigstens eine Schlüssel
hat (Wurzel) und somit wenigstens zwei Söhne
16 auch Heap genannt
13 den
7
Wurzel x gilt ∀y ∈ T 0 : µ(x) ≤ µ(y) partiell geordneter Baum heißt.
alg. insert
insert(h,e Füge das Element e auf der ersten freien Position der untersten
Ebene des Heaps h ein. Sei v der Vater von e. Solange es einen Vaterknoten
gibt und dieser größer ist als e tausche mit diesem den Platz.
alg. deletemin
deletemin(h) Entferne den letzte besetzten Knoten k aus dem Heap h und ersetze die Wurzel w durch k. Solange k einen rechten oder linken kleineren Sohn
hat tausche mit dem kleineren der Beiden den Platz. Liefere schließlich w (das
kleinste Element) zurück.
Sn
Sei M eine Menge und seien M1 , . . . , Mn Teilmengen von M für die gilt i=1 Mi =
M, Mi ∩ Mj = ∅ mit i 6= j, i, j ∈ {1, . . . , n} dann heißt P = {M1 , . . . , Mn }
Partition von M . Eine Teilmenge Mi wird auch mit Komponente bezeichnet.
Partition
Implementierung
sorts partition =: p, compname =: c, e ops addcomp : p × c × e → p, merge :
p × c × c → p, f ind : p × e → c.
In Arrays Bei dieser Implementierung definiert man ein Komponentenarray
das durch die Komponentennamen indiziert ist und zweidimensionale Elemente hat. Die erste Dimension enthält die Mächtigkeit der Komponente
die zweite den Namen des “ersten” Elements der Komponente (oder 0).
Außerdem benötigt man noch ein Elementarray, dass mit den Elementnamen indiziert ist und dessen Elemente ebenfalls zweidimensional sind. Die
erste Dimension enthält den Komponentennamen, die Zweite den Namen
des “nächsten” Elements der Komponente (oder 0). Beide Arrays müssen
in ihrer Größe der Mächtigkeit von M entsprechen(, damit auch der Fall,
dass P aus |M | Komponenten besteht abgedeckt ist). f ind kann offensichtlich in konstanter Zeit realisiert werden, das Verschmelzen ist proportional zur Anzahl der Elemente der durchlaufenen Liste, also O(n).
Durch den Trick, dass beim Verschmelzen immer die kleinere Komponente der größeren hinzugefügt wird kann man n − 1 merge-Operationen in
O(n log n) Zeit17 realisieren.
Mit Bäumen Hierbei wird jede Komponente durch einen Baumrepräsentiert
dessen Knoten einen Verweis auf den Vater enthalten. Als Komponentenname wird ein Verweis auf den Wurzelknoten des entsprechenden Baumes benutzt. Des weiteren benötigt man noch eine Array, dass mit den
Elementnamen indiziert ist und je einen Zeiger auf das entsprechende
Element enthält.
merge wird realisiert indem die eine Komponente zum Sohn der Anderen
gemacht wird. Hierzu sind stets zwei Operationen möglich woraus sich
eine konstante Laufzeitkomplexität ergibt.
f ind wird realisiert indem man über das Elementarray auf den Knoten des
Elements zugreift und sich bis zur Wurzel hocharbeitet, somit ist die Laufzeitkomplexität proportional zur Höhe des Baumes, also O(h). Während
des Hochlaufens zur Wurzel können alle auf dem Pfad liegenden Komponenten “mitgenommen” werden und dem Wurzelknoten hinzugefügt werden. Diese Technik nennt sich Pfadkompression. Ohne Pfadkompression
können n f ind-Operationen in O(n log n) Zeit ausgeführt werden. Mit
17 man
spricht bei einer solchen Verbesserung von einer amortisierten worst-case Laufzeit
8
Pfadkompression kann man zeigen, dass nur noch O(n · G(n)) Operationen benötigt werden, wobei G eine extrem langsam wachsende Funktion
ist für die gilt G(n) ≤ 5 für alle n ≤ 265536 .
Sortieren
Gegeben sei eine Folge S = s1 . . . sn von Records, die eine key-Komponente eines
linear geordneten Datentyps besitzen. Man berechne die Folge S 0 = si1 . . . sin als
Permutation der Folge S, sodaß si1 .key ≤ si2 .key ≤ . . . ≤ sin .key.
Klassifizierung
-nach Speichernutzung: intern alles im Hauptspeicher extern datensatzweise
-nach Methode: Sortieren durch Einfügen, durch Auswählen, Divide-and-Conquer,
Fachverteilen;
-nach Effizienz: Einfache Verfahren O(n2 ), gute Verfahren mit O(n log n)
-in nur einem Array (in situ oder nicht (halber Hauptspeicher).
-Allgemeines Verfahren oder auf Folgen mit bestimmten Eigenschaften spezialisiert.
Auswählen und Einfügen
Zur Beschreibung der beiden Strategien werden die Folgen SORTED und UNSORTED in zwei einfachen Verfahren verwendet mit O(n2 ) Laufzeitkomplexität.
algorithm SelectionSort (S) UNSORTED := S; SORTED := ∅;
while UNSORTED 6= ∅ do
entnimm UNSORTED das Minimum und hänge es an SORTED an. end while.
algorithm InsertionSort (S) UNSORTED := S; SORTED := ∅;
while UNSORTED 6= ∅ do
entnimm UNSORTED das erste Element und füge es an der richtigen
Position in SORTED ein. end while.
Analyse
Heapsort und Baumsortiern
Wir sprechen von direktem Auswählen und Einfügen, wenn sie ohne weiteres
im Array realisiert werden.
SelectionSort 1. Schleifendurchlauf n Operationen, 2. Durchlauf n-1 OperatioPn
nen,. . . , n. Durchlauf 1 Operation. =⇒ n+(n−1)+. . .+1 = i=1 i = n(n+1)
=
2
2
2
18
O(n ); C(n) = O(n ), X(n) = O(n) .
Pn
InsertionSort 1 + 2 + . . . + n = i=1 i = O(n2 ) = Cw (n) = Mw (n); 21 · (1 + 2 +
Pn
. . . + n) = 12 i=1 i = O(n2 ) = Ca (n) = M 19 a (n). Ist S fallend geordnet muß
die innere Schleife bis zu S[0] durchlaufen.
Die kritischen Operationen von SelectionSort und InsertionSort, Auswahl des Minimums bzw. das Einfügen in eine geordnete Folge, wurde in O(n) Zeit realisiert. Durch
geeignete Datenstrukturen werden diese Operationen von Heapsort bzw. Baumsortieren in O(log n) Zeit durchgeführt was zu einer verbesserten Laufzeitkomplexität
von O(n log n) für den Sortiervorgang führt. Da die Datenstruktur von balancierten
Suchbäumen recht komplex ist, ist Baumsortieren in der Praxis nicht so interessant.
Wegen der effizienten Implementierbarkeit eines partiell geordneten Baumes (Heap)
im Array ist Heapsort auch in der Praxis interessant. Grundprinzip:
-Die n zu sortierenden Elemente werden in einen Heap eingefügt: Komplexität
O(n log n).
-Dann wird n-mal das Minimum aus dem Heap entnommen: Komplexität O(n log n).
18 Comparisons,
Exchanges
19 Move-Operationen
9
Um den Aufbau zu beschleunigen und in situ sortieren zu können definieren wir
den Begriff des Teilheaps für eine im Array implementierte Folge von Records
S[i..k], die eine key-Komponente eines linear geordneten Datentyps besitzen.
Teilheap
Ein Teilarray S[i..k], 1 ≤ i ≤ k ≤ n, heißt Teilheap :⇐⇒
∀j ∈ [i, . . . , k] : S[j].key ≤ S[2j].key falls 2j ≤ k und S[j].key ≤ S[2j +
1].key falls 2j + 1 ≤ k
Algorithmus
Aufbau des Heap: S[bn/2c + 1..n] ist bereits ein Teilheap (⇐ Blätter)
for i := b n/2 cdownto 1 do
erweitere den Teilheap S[(i+1)..n] zu einem Teilheap S[i..n] durch
Einsinken lassen von S[i] end for
Sortieren der Folge: Da S nun ein Heap ist, ist S[1] das kleinste Element der
Folge. Wir tauschen S[1] mit S[n] und lassen das neue S[1] in den Teilheap
S[1..n-1] einsinken, womit S[1] wieder das kleinste Element des Teilheaps S[1..n1]. Nun tauschen wir wieder S[1] mit S[n-1] usw. und erhalten schließlich eine
absteigend20 sortierte Folge.
Das Einsinken von Element E im Teilheap wird realisiert indem der kleiner
der beiden Söhne von E ermittelt wird, falls diese existieren, ist dieser kleiner
als E dann wird Platz getauscht und das Prozedere so lange wiederholt bis der
kleinere Sohn größer als E ist oder E keine Söhne mehr hat, also ein Blatt ist.
Analyse
Man kann zeigen, dass der Aufbau des Heap O(n) Zeit benötigt damit kann
man Heapsort benutzen, um in O(n + k log n) die k kleinsten Elemente einer
Menge in Sortierreihenfolge zu erhalten. Wenn k ≤ n/ log n ist, kann dieses
Problem also in O(n) Zeit gelöst werden.
Vergleich mit Quicksort
Da das Einsinken auf jeder Ebene des Heap-Baumes zwei Vergleiche benötigt,
ist die Gesamtzahl der Vergleiche Cw (n) = 2 · n · log n + O(n). Da auf der untersten Ebene schon 50% der Knoten liegen sieht es für das Durchschnittsverhalten
nicht viel besser aus im Gegensatz zu Quicksort mit ≈ 1, 386 · n · log n + O(n).
Durch Trennung der beiden Entscheidungen welcher Sohn ist zu wählen und
muss das bearbeitete Element noch tiefer sinken kann man den Faktor des
Durchschnittsverhaltens in Richtung 1 drücken. Man verfolgt hier zuerst den
Pfad (Entscheidung 1) und wandert dann aufwärz zur Einfügeposition (bottom
up Heapsort).
Praktische Untersuchungen haben gezeigt, dass Heapsort für n ≥ 400 besser als
Standard-Quicksort und für n ≥ 16000 besser als Clever Quicksort ist. Hinzu
kommt noch das gute O(n log n) worst-case Verhalten im Gegensatz zu O(n2 )
vom Quicksort.
DAC-Verfahren
Das Divide-and-Conquer -Paradigma lässt sich allgemein so formulieren:
Ist die Objektmenge klein genug löse das Problem direkt ansonsten
Divide: Zerlege die Menge in mehrere möglichst gleichgroße Teilmengen.
Conquer: Löse das Problem rekursiv für jede Teilmenge.
Merge: Berechne aus den Teillösungen die Gesamtlösung des Problems.
Mergesort
benutzt einen trivialen Divide-Schritt und leistet die eigentliche Arbeit im
Merge-Schritt wo zwei sortierte Folgen zu einer verschmolzen werden was bei
20 für
ein aufsteigende Ordnung einen Maximumheap verwenden
10
Algorithmus
Implementierung
parallelem Durchlauf in O(k + m) Zeit realisierbar ist.
MergeSort(S) Wenn |S| = 1 ist die Rekursionsabbruchbedingung erreicht ansonsten halbiere S (Divide), dann rufe MergeSort je mit den beiden Hälften
auf (Conquer ) und schließlich verschmelze die Rückgabewerte dieser Aufrufe
(Merge).
Die Implementierung mit verketteten Listen braucht O(n) Zeit für das Zerlegen
einer List der Länge n, da stets halbiert wird, wird der Divide-Schritt log n mal
durchgeführt damit wird auch der Merge-Schritt log n mal ausgeführt, der für
das Verschmelzen zweier Listen mit insgesamt n Elementen O(n) Zeit benötigt.
Insgesamt ergibt sich also O(n log n).
Eine Implementierung im Array mit zwei21 Arrays ist möglich. Der DivideSchritt benötigt hier nur konstante Zeit, der Conquer-Schritt wird wieder log n
mal durchgeführt und somit auch der Mergeschritt mit linearer Laufzeitkomplexität was wieder zu einem Gesamtaufwand von O(n log n) führt.
Also kann man festhalten, dass Mergesort O(n log n) Zeit benötigt. Dies lässt sich
auch formal zeigen indem man die entsprechende Rekursionsgleichung aufstellt, die
ein Spezialfall einer allgemeinen Rekursionsgleichung für DAC-Algorithmen ist, von
der man zeigen kann, dass Sie bei linearen Divide- und Merge-Schritten eine Laufzeitkomplexität von O(n log n) hat.
Quicksort
Algorithmus
arbeitet im Divide-Schritt während der Mergeschritt in konstanter Zeit durchgeführt werden kann (concatenieren von Listen) oder ganz überflüssig ist (Sortieren im Array).
QuickSort(S) wenn |S| = 1 oder alle Schlüssel in S gleich sind, dann ist die
Rekursionsabbruchbedingung erfüllt ansonsten
Divide: Wähle den Schlüssel (=Splittelement) so das dieser nicht minimal22 ist
in S. Berechne eine Teilfolge S1 aus S mit den Elementen, deren Schlüsselwert kleiner als der Schüssel ist und eine Teilfolge S2 mit Elementen die
größer sind.
Conquer : Rufe Quicksort mit den beiden Teilfolgen auf.
Merge: Concatiniere die Rückgabewerte des Conquer-Schrittes.
Der Divide Schritt hat ein lineares Laufzeitverhalten, die Häufigkeit des ConquerSchrittes ist von der Wahl des Schlüssels abhängig. Wird als Schlüssel immer
der zweitgrößte Wert gewählt, dann entartet der Rekursionsstack23 und der
Divide-Schritt wird n mal vom Conquer-Schritt aufgerufen, was zu einer Laufzeitkomplexität von O(n2 ) im Worst-Case führt. Dies passiert z.B. wenn man
das erste nicht minimale Element der Folge als Schlüssel ermittelt wird und die
Folge schon sortiert ist. Weitestgehend Vermeiden lässt sich dies indem man
den Schlüsselwert per Zufall ermitteln lässt oder noch besser: man ermittelt
drei Werte per Zufall und wählt den Median aus (Clever Quicksort). Bei dieser Variante liegt die Anzahl der Vergleiche im Average-Case nur um 18,8%
über dem optimalen Wert während die Standardvariante 38,6% darüber liegt.
21 Es
soll auch trickreiche in situ Implementierungen geben
er minimal terminiert Quicksort nicht
23 dies kann verhindert werden indem die rekursive in eine iterative Implementierung umgewandelt wird. Die kleinere Partition wird gleich weiterverarbeitet die größere auf einem
Stack gemerkt. Dieser Stack kann höchstens auf O(log n) anwachsen.
22 ist
11
Allgemein kann man zeigen, dass das Durchschnittsverhalten bei O(n log n)
liegt.
Trotz des besseren Worst-Case Verhalten und besseren Average-Case Verhalten
ab einer bestimmten Eingabekomplexität von Heapsort wird Quicksort häufiger verwendet da die Entartung in der Praxis kaum vorkommt und weil die
Verarbeitungschritte weniger komplex sind. Das Splittelement kann in einem
schnellen Register gehalten werden.
Fachverteilen
Bucketsort
Radixsort
Unterliegen die Schlüsselwerte bestimmten Einschränkungen und können auch
andere Operationen als Vergleiche angewandt werden, so ist es möglich in O(n)
Zeit zu sortieren. Sei der keytype = 0..m-1 und seien Duplikate erlaubt. Seien
B0 , . . . , Bm−1 Behälter die durch Listen implementiert und in einem Array organisiert sind und S eine n-Elementige Folge von Records mit oben genannten
keytype.
-Dann werden die Behälter initialisiert mit leeren Listen (O(m));
-Die n Elemente werden auf die entsprechenden Buckets verteilt Bsi .key = si
(O(n));
-Schließlich werden die Buckets ausgegeben (O(n)).
Also wird in O(m + n + n) Zeit sortiert mit m = O(n) folgt eine Laufzeitkomplexität von O(n). Dieses verfahren lässt sich noch verallgemeinern indem man
keytype = 0..nk − 1 setzt und Bucketsort mit m=n Buckets in mehreren Phasen
anwendet.
1. Phase: Bucketsort mit Bj = si und j = div n0 mod n.
2. Phase: Bucketsort mit Bj = si und j = div n1 mod n.
..
.
k. Phase: Bucketsort mit Bj = si und j = div nk−1 mod n.
Wobei die i-te Phase immer mit der Ergebnisliste der (i-1)-ten Phase arbeitet
und im k-ten Schritt kann man auf “mod n” verzichten. Bei diesem Verfahren
werden die Schlüsselwerte als Ziffernfolgen zur Basis m=n aufgefasst und in
jeder Phase wird nach einer Ziffer der Ziffernfolge sortiert, in der ersten nach
der ersten Ziffer in der zweiten nach der zweiten und so weiter. Jede Phase
hat eine Laufzeitkomplexität von O(n) betrachtet man k nicht als Konstante
erhältman O(kn).
Externes Sortieren
Mittels externem Sortieren können groß externe Datenbestände sortiert werden.
Gegeben sei also eine gespeicherte Menge von n Datensätzen mit Schlüsseln
aus einem geordneten Wertebereich. Die Sätze stehen in einem vom Betriebssystem verwalteten File, dessen Darstellung wir uns als Seitenfolge vorstellen
können. Wir nehmen der Einfachheit halber an , dass alle Datensätze feste
Länge haben und dass b Sätze auf eine Seite passen. Seite 1: d1 . . . db , Seite
2: db+1 . . . d2b , Seite k: d(k−1)·b+1 . . . dn . Sequentielles Lesen aller n Datensätze
erfordert k = dn/be Seitenzugriffe. Ein Lesen aller Datensätze in irgendeiner
anderen Reihenfolge wird im allgemeinen etwa n Seitenzugriffe erfordern. Da b
gewöhnlich erheblich größer ist als 1, ist man auf Sortierverfahren angewiesen,
die eine Menge von Sätzen sequentiell24 verarbeiten. Hier ist Mergesort die erste Wahl wobei im externen Fall die Divide- und Conquer-Schritte wegfallen,
24 d.h.
in Speicherungsreihenfolge
12
man beginnt direkt mit dem bottom-up Merge-Vorgang.
Lauf
Eine aufsteigende geordnete Teilfolge von Sätzen innerhalb eines Files heißt Lauf. Als Lauftrennsymbol
wird in der nebenstehenden Graphik ein senkrechter
Strich benutzt.
Algorithmus
Seien seien g1 , g2 , f1 , f2 Dateien wobei g1 die zu sortierenden n Datensätze enthält und die übrigen leer
sind. Im initialen Lauf (ir) wird die Datensätze aus
g1 eingelesen und in Läufen der Länge eins25 abwechselnd26 in die Dateien f1 ,
f2 geschrieben. Dann werden in Phase 1 (p1) die Läufe von f1 nach g1 geschrieben wobei immer zwei aufeinanderfolgende Läufe verschmolzen werden. Ebenso
wird mit f2 nach g2 verfahren. Damit hat sich die Anzahl der Läufe halbiert.
Nun werden die Läufe aus g1 bzw. g2 wieder unter Verschmelzung zweier aufeinanderfolgender Läufe nach f1 bzw. f2 geschrieben. Damit wird nun so lange
fortgefahren bis die Anzahl der Läufe 1 ist (p3 in der Graphik).
Analyse
Im initialen Lauf und in jeder Phase werden sämtliche Datensätze jeweils einmal
sequentiell gelesen und geschrieben, mit Kosten O(n/b). Beginnt man mit n
Läufen der Länge 1 gibt es dlog2 ne Phasen. Der Gesamtaufwand beträgt also
O(n/b log n) Seitenzugriffe, der interne Zeitbedarf ist auch O(n log n).
direktes Mischen
Verwendet man im initialen Lauf Läufe fester Länge, wie oben, spricht man
auch von direktem Mischen. Das obige Verfahren lässt sich noch verbessern
indem man interne und externe Sortierverfahren kombiniert. Anstatt nur 4/8
Seiten im Puffer zu halten sortiert man beim direkten Mischen im initialen
Lauf jeweils so viele Datensätze wie in den Hauptspeicher passen mit einem
internen Sortierverfahren. So werden Anfangsläufe der Länge m (also fester
Länge) erzeugt. Es werden im initialen Lauf also k = dn/me Läufe erzeugt,
damit kann die Anzahl der Phasen (dlog2 ke) stark reduziert werden.
natürliches Mischen
Um vorsortierte Teile von Dateien auszunutzen und um initiale Läufe erzeugen zu
können, die die Größe des Hauptspeichers
übersteigen verwendet man Läufe variabler
Länge. Die Idee ist einen Puffer im Hauptspeicher zu halten der n Datensätze aufnehmen kann. Man entnimmt den Datensatz
mit minimalem Schlüssel und schreibt ihn
in den Lauf. Dann füllt man den Puffer wieder auf. Ist der hinzugekommene Datensatz größer als der zuletzt geschrieben
kann dieser auch noch dem aktuellen Lauf hinzugefügt werden, anderenfalls
muss er auf den nächsten Lauf warten.
Implementieren lässt sich dieses Verfahren mit einem Heap der in einem Array
der Größe m liegt. Der Heap wird gefüllt und das minimale Element emin in die
Ausgabedatei geschrieben. Dann wird wieder ein Satz der Eingabedatei gelesen,
25 die
somit trivialerweise sortiert sind
ist sichergestellt, dass die Anzahl der Läufe pro Datei sich höchstens um eins
unterscheiden
26 somit
13
ist dieser größer als emin dann lasse den Satz einsinken ansonsten verkleinere
den Heapbereich auf m-1 und schreibe den neuen Satz ans Ende des Arrays.
Fahre so lange fort bis der Heapbereich die Länge Null hat. Rekonstruiere den
Heapbereich und beginne einen neuen Lauf.
Vielweg Mischen
Hierbei werden statt je zwei Eingabe- und Ausgabedateien k Ausgabe- und Eingabedateien verwendet. In einer Phase werden wiederholt k Läufe zu einem Lauf
verschmolzen; die entstehenden Läufe werden zyklisch auf die Ausgabedateien
verteilt. Intern muss man dabei in jedem Schritt aus den k ersten Schlüsseln
der Eingabedateien den minimalen Schlüssel bestimmen; bei großem k kann
das wiederum effizient mithilfe eines Heaps geschehen.
Analyse: In jeder Phase wird nun die Anzahl vorhandener Läufe durch k dividiert; wenn zu Anfang r Läufe vorhanden sind, ergeben sich dlogk re Phasen,
gegenüber dlog2 re beim binären Mischen. Die Anzahl externer Seitenzugriffe
wird also noch einmal erheblich reduziert. Der interne Zeitbedarf ist O(n·log2 k)
pro Phase, also insgesamt
O(n · log2 k · logk r) = O(n · log2 r)
|
{z
}
log2 r
gerichtete Graphen
Ein gerichteter Graph 27 ist ein Paar G = (V, E) mit V 6= ∅ als endliche Menge
von Knoten und E ⊆ V × V einer Menge von Kanten.
Bezeichnungen
Sei e := (v1 , v2 ) dann sagt man e ist inzident mit v1 und v2 bzw. v1 und v2
sind benachbart bzw. adjazent.
Sei v einbestimmter Knoten und v 0 beliebig. Grad(v) := |{v, v 0 ), (v 0 , v) ∈ E}|,
Eingangsgrad(v) := |{(v 0 , v) ∈ E}|, Ausgangsgrad(v) := |{(v, v 0 ) ∈ E}.
Eine Folge von Knoten v1 , . . . , vn mit (vi , vi+1 ) ∈ E mit i ∈ {1, . . . , n − 1} heißt
Pfad mit der Länge := n − 1 = |{(xi , xi+1 ) ∈ E}| mit i ∈ {1, . . . , n − 1}. Ein
Pfad heißt einfach, wenn alle Knoten, mit Ausnahmen von v1 = vn paarweise
verschieden sind. Ein Einfacher Pfad mit der Länge ≥ 1 und v1 = vn heißt
einfacher Zyklus.
Ein Teilgraph eines Graphen G = (V, E), ist ein Graph G0 = (V 0 , E 0 ) mit
V 0 ⊆ V und E 0 ⊆ E.
Die Knoten v1 , v2 eines gerichteten Graphen heißen stark verbunden, wenn es
einen Pfad von v1 nach v2 und von v2 nach v1 gibt. Eine starke Komponente
ist ein Teilgraph mit maximaler Knotenanzahl in dem alle Knoten paarweise
stark verbunden sind.
Speicherdarstellung
Sind alle v ∈ V von einem Wurzelknoten aus erreichbar heißt G Wurzelgraph.
Adjazenzmatrix Der Graph mit |V | = n wird durch eine boolsche n × n-Matrix
Aij := (true falls (i, j) ∈ E, f alse sonst) dargestellt. Bei Kantenmarkierten Graphen kann statt true/false die Kantenmarkierung ∪∞ eingetragen
werden.
Der Vorteil dieser Darstellung ist, dass die Existenz einer Verbindung
zwischen zwei Knoten in konstanter Zeit abgefragt werden kann.
27 auch
Digraph = directed graph
14
Nachteile sind ein Platzbedarf von O(n2 ), das Auffinden aller Nachfolger
benötigt O(n) Zeit und die Initialisierung Θ(n2 ).
Adjazenzlisten Man verwaltet für jeden Knoten eine Liste seiner Nachfolger
womit der Platzbedarf auf O(|V | + |E|) sinkt, alle k Nachfolger in O(k)
Zeit ermittelbar sind, allerdings ist ein Test auf Nachbarschaft nicht mehr
in konstanter Zeit realisierbar. Um die Vorgänger eines Knoten verfügbar
zu machen kann eine inverse Adjazenzliste verwaltet werden.
Expansion
Die Expansion X(v) eines Graphen G in einem Knoten v ist wie folgt definierter
Baum:
(i) Falls v keinen Nachfolger hat, ist X(v) ein Baum der nur aus dem Knoten
v besteht.
(ii) Falls v1 , . . . , vk Nachfolger von v sind, ist X(v) = (v, X(v1 ), . . . , X(vk ))
Dieser Baum wird unendlich falls er Zyklen enthält. Ein Tiefendurchlauf entspricht einem Preorder-Durchlauf von X(v), der jeweils bei einem schon besuchten Knoten abgebrochen wird.
Ein Breitendurchlauf wird realisiert indem die Knoten des Expansionsbaums
aufsteigend ebenenweiße von links nach rechts durchlaufen werden. Auch wird
bei schon besuchten Knoten abgebrochen.
Die resultierenden Bäume des Tiefen- bzw. Breitendurchlaufs heißen depthbzw. breadth-first Spannbaum. Verbleiben nach einem Durchlauf unbesuchte
Knoten vu , beginnt mit vu ein neuer Durchlauf. Man erhält dann sog. spannende
Wälder.
Einen Tiefendurchauf erhält man mit folgendem Algorithmus:
algorithm dfs(v)
if besucht[v]=false then
verarbeite v;
besucht[v] = true;
for each Nachfolger v i von v do
dfs(v i)
end for
end if
Ein Breitendurchlauf kann wie folgt realisiert werden:
algorithm bfs(v)
Queue:q; enqueue(q,v); besucht[v] := true;
while not isempty(q) do
verarbeite v’ := front(q); dequeue(q);
for each Nachfolger v 0 i von v’ do
if not besucht [v 0 i] then
enqueue(q,v 0 i);
besucht[v 0 i] := true;
end if
15
end for
end while
Die Laufzeitkomplexität einer Implementierung mit Adjazenzliste beträgt O(|V |+
|E|)
Sei G ein kantenmarkierter, gerichteter Graph wobei die Knotenmarkierung
Kosten sind, dann ist die Länge eines Pfades die Summe der besuchten Kantenkosten.
Dijkstra
Algorithmus der kürzesten Wege von einem bestimmten Knoten zu allen anderen erreichbaren Knoten.
Hierbei man von v ausgehend einen Teilgraphen wachsen, der aus Knoten besteht deren ausgehende Kanten schon betrachtet wurden (innere Knoten) und
bei denen dies noch nicht der Fall ist Peripherieknoten. Die Menge der Kanten
des Teilgraphen setzt sich zusammen aus den Kanten des Baumes der kürzesten
Wege und den übrigen. Der Teilgraph wächst indem in jedem Schritt aus der
Menge der Peripherieknoten, der Knoten mit dem kürzesten Abstand vk zu v
in die Menge der inneren Knoten übergeht. Dabei werden alle Nachfolger von
vk betrachtet und zwei Fälle unterschieden.
1. Fall Der betrachtete Nachfolger gehört schon zur Knotenmenge des Teilgraphen, dann wird überprüft ob der neue Pfad über vk kürzer ist als
der bisherige, falls ja wird die Pfadlänge zu diesem Knoten aktualisiert
und die bisherige Kante aus der Menge der Kanten, die zum Baum der
kürzesten Wege gehören durch die neue Kante mit vk ersetzt.
2. Fall Der betrachteten Nachfolger gehört noch nicht zur Knotenmenge des
Teilgraphen, dann wird die Information der Pfadlänge gespeichert und
die entsprechende Kante in die Menge der Kanten, die zum Baum der
kürzesten Wege gehören hinzugefügt.
Implementierung
Der Algorithmus startet indem der Peripherieknotenmenge v hinzugefügt wird
und endet wenn diese leer ist.
mit Adjazenzmatrix Es wird ein Array benötigt in dem die Pfadlänge zu den
jeweiligen Knoten hinterlegt wird (dist). dist entspricht der Menge der
Peripherieknoten.
Es wird ein Array benötigt, dass den unmittelbaren Vorgänger zu einem
Knoten enthält (f ather). f ather entspricht dem Baum der kürzesten
Wege.
Außerdem wird noch ein Array für die inneren Knoten (inner) benötigt.
Schließlich wird noch eine Kosten-Adjazenzmatrix (cost(i, j) benötigt.
- ermittle aus dist den Knoten w mit minimalen Abstand zu v. (O(n))
- durchlaufe die Zeile cost(w,-) um für alle Nachfolger von w ggf. den
Abstand und den Vater zu korrigieren (O(n))
Die Laufzeitkomplexität dieser Implementierung ist O(n2 ).
mit Adjazenzliste und Heap dist enthält wieder alle Abstände der Knoten des
Teilgraphen zu v, und f ahter wird in der selben Bedeutung wie oben
verwendet.
Dann benötigen wir noch einen Heap (mittels Array implementiert) der
alle Peripherieknoten enthält und mit einem weiteren Array (heapadress),
16
das zu jedem Knoten seine Heapposition enthält, doppelt verkettet ist.
- Entnimm den Knoten mit dem kürzesten Abstand zu v aus dem
Heap (O(log n)).
- Finde in dessen Adjazenzliste die mi Nachfolger von wi (O(mi )).
a) füge jeden Nachfolger der noch nicht in dem Heap ist in diesen
ein (O(log n))
b) Schon vorhandene Nachfolger der müssen evtl. aktualisiert werden und im Heap nach oben wandern.
Aufwand für a) und b) ist O(mi log n). Über alle Schritte des AlgoP
rithmus summiert gilt
mi = e = |E|
Es ergibt sich ein Gesamtaufwand von O(e · log n) mit einem Platzbedarf
von O(|V | + |E|).
Floyd
Bestimmung kürzerster Wege zwischen allen Knoten im Graphen auch als “all
pairs shortest path”-Problem bekannt.
Sei G = (V, E) mit |V | = n. Der Algorithmus berechnet eine Folge von Graphen
G0 , . . . , Gn . Graph Gi entsteht jeweils durch Modifikation des Graphen Gi−1 .
Jeder Graph Gi ist wie folgt definiert:
(i) Gi hat die gleiche Knotenmenge wie G.
α
(ii) Es existiert in Gi eine Kante v −→ w ⇔ es existiert in G ein Pfad
von v nach w, in dem als Zwischenknoten nur Knoten aus der Menge
{1, . . . , i} verwendet werden. Der kürzeste derartige Pfad hat Koste α.
α
Dabei bezeichne die Notation v −→ w eine Kante (v, w) mit υ(v, w) = α.
i-te Schritt des Algorithmus von Floyd berechnet sich wie folgt: Betrachte alle
Paare (vj , wk ) der Vorgänger vj und Nachfolger wk mit j = 1, . . . , r und k =
1, . . . , s von vi des Graphen Gi−1 .
(a) Falls noch keine Kante von vj nach wk existiert, so erzeuge die Kante
α+β
vj −→ wk .
γ
(b) Falls schon eine Kante vj → wk existiert, ersetze die Markierung γ dieser
Kante durch α + β, falls α + β < γ.
Implementierung
Sei Cij die Kostenmatrix von G, für i = j setze Cij = 0 und außerdem Cij = ∞
gdw (vi , vj ) 6∈ E.
Initialisiere eine weitere Matrix A mit C und eine Matrix P mit -1.
for (int i=0; i<n; i++)
for (int j=0; j<n; j++)
for (int k=0; k<n; k++)
if (A[j][i] + A[i][k] < A[j][k]) {
A[j][k] = A[j][i] + A[i][k]; //k ist über i von j günstiger zu erreiche
P[j][k] = i;} // O(nˆ3) //j erreicht k über i
Warshalls Alg.
Berechne einen Graphen G = (V, E) vom dem Graphen G = (V, E) mit E :=
{(v, w) | es gibt in G einen Pfad von v nach w}
Sei A ein boolsches Array mit A[i][j] = true, gdw es gibt eine Pfad von i nach
j.
//A initialisieren anhand von C
17
for ... for ... for //siehe Floyd Algorithmus
if (A[j][i] && A[i][k] && !A[j][k] {// j erreicht k über i
A[j][k] = true;}
G heißt transitive Hülle von G.
Starke Komponenten
Alle alle starken Komponenten eines Graphen berechnen.
- Alle Depth-First-Spannbäume von G ermitteln und die Knoten in der
Reihenfolge der Beendigung ihrer rekursiven Aufrufe indizieren.
- Inversen Graphen Gr konstruieren durch Richtungsumkehrung aller Kanten von G.
- Depth-First-Spannbäume von Gr erzeugen, angefangen mit den Knoten
der höchsten Nummer aus dem ersten Schritt. Ist ein Spannbaum komplett fahre mit dem höchsten der verblieben Knoten fort.
- Die Knotenmenge jedes im vorhergehenden Schritt entstanden Spannbaums bildet die Knotenmenge einer starken Komponente.
ungerichtete Graphen
Ein ungerichteter Graph ist ein Graph, in dem die Relation E symmetrisch
ist. Verbundene Komponenten lassen sich ermitteln durch einen Tiefen- bzw.
Breitendurchlauf. Die Spannbäume sind Knotenmengen der Zyklen.
Ein verbundener, azyklischer Graph heißt freier Baum. Es gilt
n ≥ 1 ⇒ |E| = n − 1 und durch hinzufügen einer Kante entsteht ein Zyklus.
Ein freier Baum von einem verbundenen Graphen mit Kantenbewertung, der
alle Knoten von G enthält und dessen Kanten eine Teilmenge der Kanten von
G ist heißt Spannbaum.
Kruskal Alg.
Berechne berechne einen Spannbaum mit minimalen Kosten zu G = (V, E).
Es wird ein Graph T entwickelt, der zunächst nur einen Knoten enthält. sukzessive werden die Kanten in aufsteigender Kostenfolge betrachtet, verbindet
eine Kante zwei getrennte Komponenten von T so wird sie in den Graphen eingefügt. Sobald T nur noch eine einzige Komponente besitzt, ist T ein minimaler
Spannbaum für G.
18
Geom. Probleme
(i)Alle Punkte p ∈ P innerhalb eines Rechtecks bestimmen; (ii)alle sich schneidenden
Paare von Rechtecken einer Ebene bestimmen; (iii)entscheiden welche Plyederkanten
im Raum sichtbar sind; (iv)den naheliegensten Punkt zu einem anderen Punkt im
k-dimensionalekn Raum ermitteln.
Anwendung
(i) Datenbank, (ii) VLIS-Entwurf, (iii) 3D-Szenen, (iv) Spracherkennung.
Klassifikation
Es werden im Weiteren Mengen- und Suchprobleme mit orthogonalen Objekten
betrachtet.
Mengenprb.
Interessanten Eigenschaften einer Menge berechnen.
Suchproblem
Ermittle alle Objekte s ∈ S die mit dem Queryobjekt q in interessanter Beziehung stehen.
orthogonal
heißt ein geometrisches Objekt28 wenn es als kartesisches Produkt von k Intervallen beschrieben werden kann. (Intervall darf zum Punkt entarten.)
beliebig orientiert
heißt ein geometrisches Objekt wenn seine Kanten bzw. Flächen beliebige Richtungen haben dürfen.
Plane-Sweep
Ist eine Methode um Mengenprobleme zu lösen. Die Idee ist, eine horizontale/vertikale
Gerade/Hyperebene genannt Sweepline durch die Ebene/den Raum zu schieben und
dabei den Schnitt mit der Objektmenge zu betrachten. Zu ein Plane-Sweep-Algorithmus
gehören immer zwei Datenstrukturen, die Sweepline-Status-Struktur und die SweepEvent-Struktur. Die Sweepline-Status-Struktur muss dynamisch sein, die Event-Struktur
ist von Fall zu Fall statisch oder dynamisch.
Notation
h = (x1 , x2 , y) ist ein horizontales Segment, v = (x, y1 , y2 ) ein Vertikales und r =
(x1 , x2 , y1 , y2 ) ist ein Rechteck. Die i-te Komponente eines der Tupel wird mit dem
mit i indizierten Namen des Tupels bezeichnet (Bsp. v2 = y1 von v). Für eine Tupelmenge S bezeichnet πi (S) die Menge aller i-ter Komponenten der Tupel der Menge
S. Wird von einer Menge von x-Koordinaten gesprochen, dann ist damit gemeint
eine Menge von Paaren, die aus der x-Koordinate (Schlüssel) und z.B. zugehörigem
vertikalem Segment bestehen.
Segment-Schnitt-Problem
Finde in einer Menge horizontaler und Vertikaler Liniensegmente die sich schneidenden Paare.
Es gilt, dass horizontale Segmente die Sweepline während eines bestimmten Zeitintervalls
schneiden, die vertikalen zu einem bestimmten Zeitpunkt. Das horizontal schneidende Segment gehört zur Statusstruktur und liegt im y-Intervall des einen oder der
mehreren vertikalen Elemente der Statusstruktur. Die Statusstruktur sollte also die
y-Koordinaten der die Sweepline schneidenden Segmente enthalten und wann immer
eines od. mehrere vertikalen Elemente getroffen werden, werden alle vorhandenen
y-Koordinaten ihrer y-Intervalle ermittelt. Die Sweep-Eventstruktur sind alle linken
und rechten Enden der horizontalen Segmente und die x-Koordinaten der vertikalen
Segmente:
Algorithmus
SegmentIngersectionPS(H,V)
S = {(h1 , h)|h ∈ H} ∪ {(h2 , h) | h ∈ H} ∪ {(v1 , v)|v ∈ V }; //Eventstruktur
Sortiert nach x
Y = {(h3 , h) | h ∈ H, h ist unter der Sweepline }; //Statusstruktur
Y := ∅; //Initialisierung (Sweeplien am Start)
durchlaufe S: das gerade erreichte Objekt ist
28 eine
zusammenhängende Teilmenge des Rn
19
-h1 ∈ π1 (H) =⇒ Y := Y ∪ {(h3 , h)}; //Sweepline über neuem horizontalem
Segment
-h2 ∈ π2 (H) =⇒ Y := Y \{(h3 , h)}; //Sweepline hat horizontales Segment hinter sich gelassen
-v1 ∈ π1 (V ) =⇒ A := π2 ({w ∈ Y | w1 ∈ [y1 , y2 ]}; //Ermittle v schneidende
hrz. Segmente.
gib alle Paare in A × {v} aus. end
Datenstruktur
die das Einfügen und Entfernen einer Koordinate und das Auffinden aller gespeicherten Koordinaten, die in einem Intervall liegen realisiert. Dies kann mit
einem AVL-Baum gelöst werden, der nur in den Blättern Koordinaten enthält
welche zu einer Liste verkettet sind.
Laufzeitkomplexität
Sei n = |H| + |V |. Das Sortieren benötigt O(n log n); beim Durchlaufen von
S werden O(n) Einfüge-, Entferne- und Query-Operationen durchgeführt, jede
kostet O(log n) Zeit. Ist nun k die Gesamtzahl der sich schneidenden Paare,
so folgt eine Laufzeitkomplexität von O(n log n + k), der Speicherbedarf ist
offensichtlich O(n). Die Problemkomplexität ist Ω(n log n + k), Ω(n) für Zeitund Platzbedarf also ist der Algorithmus asymptotisch optimal.
DAC-Lösung
Bei dieser Lösungsvariante des Problems wird auch zunächst die Menge S entwickelt wie bei der Plane-Sweep Variante. Dann wird der rekursive Algorithmus
linesect(S,L,R,V) aufgerufen mit
L = {(h1 , h) ∈ S | (h2 , h) 6∈ S}, R = {(h2 , h) ∈ S | (h1 , h) 6∈ S}, V =
{([v2 , v3 ], v) | v ∈ S}
algorithm linesect(S,L,R,V)
Enthält S nur ein Element s:
L := (h3 , h); R := ∅; V := ∅; //s ist linker Endpunkt (h1 , h)
R := (h3 , h); L := ∅; V := ∅; //s ist rechter Endpunkt (h2 , h)
V := ([v2 , v3 ], v); L := ∅; R := ∅; //s ist vertikales Segment(v1 , v)
Enthält S mehr als ein Element:
Divide: Wähle eine x-Koordinate xm , die S in zwei etwa gleich große Teilmengen S1 und S2 teilt, wobei S1 = {s ∈ S | s1 < xm } und S2 = S\S1
Conquer: linsect(S1 , L1 , R1 , V1 ); linsec(S2 , L2 , R2 , V2 );
Merge: LR := L1 ∩ R2 ;//horiz. Segmente, die sich im Mergeschritt “treffen”
L := (L1 \LR) ∪ L2 ; R := R1 ∪ (R2 \LR); V := V1 ∪ V2 ;
output((L1 \LR) ⊗ V2 ); output((R2 \LR) ⊗ V1 )
end linsect.
Rechteckschnitt-Problem
Finde alle achsenparallele Rechtecke einer Eben, die sich schneiden29 .
Rechtecke deren Kanten sich schneiden, lassen sich mit dem Segmentschnitt-Algorithmus
finden. Das Problem von Rechtecke die sich einander enthalten, lässt sich auf ein
Punkteischluß-Problem zurückführen. Liegt ein Rechteck in einem anderen so liegen
auch alle Punkte dieses Rechtecks in dem anderen also auch insbesondere ein beliebig
ausgewählter.
Algorithmus
Punkteinschluss(R)
29 Zwei
geometrische Objekte schneiden sich, wenn sie gemeinsame Punkte haben
20
S = {(r1 , lef t, (r3 , r4 ), r) | r ∈ R}∪{(r2 , right, (r3 , r4 ), r) | r ∈ R}∪{(p1 , point, p2 ) | p ∈
P unkte}
Y = {([r3 , r4 ], r) | r ∈ R, r ist unter der Sweepline }
Y := ∅
durchlaufe S: Das gerade erreichte Objekt s ist
-s2 == lef t =⇒ Y = Y ∪ {([r3 , r4 ], r)};
-s2 == right =⇒ Y = Y \{([r3 , r4 ], r) | r = s4 };
-s2 == point =⇒ A := π2 ({[r3 , r4 ], r0 ) ∈ I|s3 ∈ [r3 , r4 ]});
gib alle Paare in A × {r} aus. end
Segmentbaum
covered nodes
Es wird also eine Datenstruktur benötigt, die das Einfügen und Entfernen eines
Intervalls und das Auffinden aller Intervalle, die eine Query-Koordinate enthalten,
erlaubt.
Eine geordnete Menge von Koordinaten
{x0 , . . . , xN } teilt den eindimensionalen Raum
in eine Sequenz “atomarer” Intervalle [x0 , x1 [, [x1 , x2 [, . . . , [xN −1 , xN ]
auf. Jeder Knoten des Segment-Baums hat
eine zugeordnete Liste (die Knotenliste) von
“Inervall-Namen”. Intervallnamen werden immer so hoch wie möglich im Baum eingetragen: wann immer zwei Söhne eines
Knotens von einem Intervall “markiert” würden, markiere stattdessen den Vater. Alle Intervalle die einen Punkt x enthalten findet man indem der Segmentbaum durchlaufen wird bis zu dem Intervall [xi , xi+1 [ das x enthält. Alle auf
dem Pfad liegende Intervall-Namen der Knotenlisten sind x enthaltende Intervalle.
CN (i) := {p ∈ S | interval(p) ⊆ i ∧ ¬(interval(f ather(p)) ⊆ i)} Menge der
von einem, Intervall i in einem Segment-Baum S kanonisch bedeckten Knoten.
Analyse Segement-Baum
Der leere Baum hat N Blätter und N − 1 innere Knoten benötigt also O(N ) Speicherplatz und hat die Höhe O(log N ). Eine Menge von n Intervallen lässt sich mit
O(N +n log N ) Platz abspeichern. Das Einfügen und Entfernen eines Intervalls kostet
O(log N ), das Auffinden der t Intervalle, die x0 enthalten, erfolgt in O(log N + t) Zeit.
Algorithmus Analyse
Sei r = |Rechtecke|, p = |P unkte|, k = |P unkteinschlüsse|, n = r + p. Aufbauen und Sortieren der Sweep-Struktur benötigt O(n log n) Zeit, Konstruktion des
Segmentbaums O(n) also erfordert die Initialisierung O(n log n) Zeit. Während des
Sweeps werden r linke und rechte Rechteckkanten und p Punkte angetroffen. Jede
der zugeordneten Operationen kann in O(log r) = O(log n) Zeit durchgeführt werden, ausgenommen die Ausgabe der Punkteinschlüsse, die O(k0 ) Zeit benötigt also
insgesamt O(n log n + k0 ) Zeit- und O(n + r log r) Platzbedarf. Hinzu kommen noch
(O(n log n + k00 ) Zeit- und O(n) Platzbedarf für den Segmentschitt-Algorithmus. Da
O(k0 ) = O(k00 ) = O(k) gilt zusammengefasst für das Rechteckschnitt-Problem mit
Plane-Sweep die Laufzeitkomplexität von O(n log n + k) mit einem Platzbedarf von
O(n log n) dieser kann verbessert werden auf O(n) durch andere Datenstrukturen oder
DAC-Algorithmen.
Maßproblem
Bestimme die, von einer Menge Rechtecken bedeckte, Fläche.
Zu jedem Zeitpunkt schneidet die Sweepline die Rechteckmenge in
einer Menge von y-Intervallen. Zwischen zwei aufeinanderfolgenden
Sweep-Positionen xi und xi+1 gibt es eine wohldefinierte Menge
Y aktuell vorhandener y-Intervalle. Die von Rechtecken bedeckte
Fläche innerhalb des Streifens zwischen xi und xi+1 ist gegeben durch
(xi+1 − xi ) · measure(Y ). measure(Y) ist die Länge der Vereinigung disjunkter Y-
21
Intervalle der linken Rechteckkanten der Rechtecke die gerade unter der Sweepline
sind. Also ist eine Datenstruktur gesucht, die Intervalle zu speichern, zu löschen und
das Maß einer gespeicherten Intervall-Menge abzufragen erlaubt.
Der Segmentbaum von oben erlaubt schon das löschen und hinzufügen von Intervallen
um das Abfragen des Maßes zu realisieren wird der Knotentyp modifiziert. Statt der
Liste erhält jeder Knoten eine Zähler der inkrementiert wird beim hinzufügen des
entsprechenden Intervalls, dekrementiert beim Entfernen außerdem wird das Maß
des Teilbaumes hinterlegt dessen Wurzel der knoten ist. Sei p nun ein Knoten dann
errechnet sich das Maß
p.measure = if p.count > 0 then p.top - p.bottom
else if p ist ein Blatt then 0 else p.left.measure + p.right.measure
Der modifizierte Segment-Baum stellt eine Intervallmenge über einem Raster
der Größe N in O(N ) Speicherplatz dar. Das Einfügen oder Entfernen eines
Intervalls unter gleichzeitiger Korrektur aller Knotenmaße ist in O(log N ) Zeit
möglich. Die Abfrage des Maßes der dargestellten Intervallmenge erfordert nur
O(1) Zeit.
Analyse
Mit der Laufzeitkomplexität von O(n log n) für den Aufbau der Event- und
Sweep-Struktur, den n Einfüge- und Löschoperationen von Intervallen in O(log n)
Zeit während des Sweeps und dem n-maligen Ablesen ind Aufaddieren des Maße
folgt für das Maß-Problem gelöst mittels Plane-Sweep ein Zeit- und Platzbedarf
von O(n log n) und O(n).
22
Herunterladen