Unser Pointer-Jumping-Algorithmus für Listenpräfix (eigentlich: Listensuffix)
aus Kapitel 2.4 läuft in
Zeit und mit Arbeit , weil in jedem Schritt jeder
der Prozessoren beschäftigt ist. Der Zeitaufwand ist so gut wie bei unserem
Algorithmus für Präfixsummen (der auf Arrays arbeitet, nicht auf verketteten Listen).
Der Arbeitsaufwand ist allerdings enttäuschend; es wäre wünschenswert, einen Arbeitsaufwand
von zu erreichen, wie es ja mit einem sequentiellen Algorithmus leicht möglich ist.
Übungsaufgabe 2.6.1
Das Problem List Ranking besteht darin, bei einer verketteten Liste für jedes Glied
den Abstand zum Ende der Liste zu berechnen, also
Zeigen Sie, dass List Ranking vollständig für List Prefix ist. In anderen Worten,
wenn ich Ihnen einen parallelen List-Ranking-Algorithmus mit Zeit und Arbeit
als Subroutine zur Verfügung stelle, dann können Sie auch List Prefix
in Zeit und Arbeit lösen.
Tip
Verwenden Sie das Ergebnis von List Ranking, um die verkettete Liste in ein Array
umzuwandeln.
Warum ist Pointer Jumping ineffizienter als der Algorithmus (ursprünglich: Schaltkreis) für
Präfixsumme?
Schauen wir uns nochmal das Beispiel für Pointer Jumping an, und zwar zu dem Zeitpunkt, zu dem
Zeitschritt
1 abgeschlossen ist:
Der Prozessor, der für das erste Listenelement (mit Wert 19) zuständig ist,
zeigt nun auf das dritte Element und wird auch im weiteren Verlauf
in jedem Schritt Arbeit leisten. Alternativ könnte er sich doch verabschieden und warten, bis
das zweite Listenelement "fertig" ist (also auf das Ende deutet), und dann den Wert übernehmen. In
etwa
wie hier:
Nur gerade Indizes nehmen am Pointer Jumping teil. Die anderen verabschieden sich, bis die geraden
fertiggeworden sind. Dann machen wir rekursiv weiter (sprich: teilen jeden geraden Index durch
zwei). Das ist im Prinzip genau das, was wir auf Arrays bei Präfixsummen getan haben.
Warum geht das bei Listen nicht? Um das auf Listen zu implementieren, müsste jedes Listenelement
wissen, ob es geraden oder ungeraden Index hat. Wofür es seinen Rang, also seinen
Abstand zum Listenende wissen müsste - genau jenes Problem, das wir im Moment lösen wollen.
Randomisierung
Verallgemeinern wir das Prinzip "gerade Werte machen Pointer Jumping, ungerade Werte verabschieden
sich vorerst" zu
"Listenelemente in der Menge verabschieden sich; Elemente in überspringen sie."
Diesen Schritt kann man in einem Zeitschritt ausführen, falls keine zwei aufeinanderfolgenden
Listenglieder in liegen. Falls dies so wäre, könnten wir den neuen Nachfolger nicht in
konstanter Zeit berechnen, wie zum Beispiel Knoten in dieser Liste:
Um die Elemente in in konstanter Zeit überspringen zu können, um also die "kontrahierte" Liste
auf den Elementen in konstanter Zeit bauen zu können, müssen wir (in konstanter
Zeit)
eine unabhängige Menge finden; und zwar soll möglichst groß sein - im Idealfall , aber
wäre auch in Ordnung; dann würden wir in jeder Runde
einen konstanten Anteil von Elementen
eliminieren (überspringen) und wären nach Runden fertig.
Wir unterhalten einen Prozessor pro Listenelement . In jedem Schritt wirft jeder Prozessor
eine Münze - wählt also ein Zufallsbit in . Wenn nun
und , dann fügen wir in ein.
Wir wiederholen diesen Schritt mal.
Elemente , die zu einem Zeitpunkt eliminiert werden (also grau markiert werden),
merken sich den Zeitpunkt: . Vorgänger von grauen
Elementen, die also Pointer Jumping durchführen, merken sich, wie lange ihr Pointer ist -
wie weit voraus in der ursprünglichen Liste also ihr Nachfolger liegt.
Hierfür initialisieren wir für alle bis auf das letzte Glied;
dann setzen wir, falls eliminiert wird:
Wenn wir zum Ende gekommen sind, also alle Elemente bis auf das letzte (mit der Selbstschleife)
eliminiert
worden sind, dann starten wir die Rekonstruktionsphase, in der wir für jedes
Element berechnen wollen (also den Abstand von Element zum Ende der Liste, gemessen in der
ursprünglichen Liste). Sei das letzte Listenelement.
Wir setzen und zählen dann die Zeitschritte zurück: . Sei der aktuelle Zeitschritt dieser Phase. Wenn ist,
dann setzen wir
Per Induktion können wir nun zeigen, dass jedes Element zum Zeitpunkt
seinen richtigen Rang zugewiesen bekommt:
Sei . Das Element wird später als eliminiert (oder gar nicht, wenn es das
Ende der Liste ist),
also hat bereits den korrekten Wert.
Den Abstand von zu in der ursprünglichen Liste haben wir uns in Phase 1 in
gemerkt.
Analyse
Die Gesamtzahl der Zeitschritte in Phase 2 ist gleich der in Phase 1. Wir müssen also
überlegen, wie lange Phase 1 dauert. Dies steht nicht a priori fest sondern ist eine
Zufallsvariable! Rein theoretisch könnten wir ja Pech haben und in den ersten
Schritten für jedes Element wählen und somit gar keine Elemente eliminieren.
Wir müssen nun mit Wahrscheinlichkeiten rechnen.
Was ist die Wahrscheinlichkeit, dass ein (bis jetzt noch nicht eliminiertes Element) in
der nächsten Runde eliminiert wird?
Im Schnitt sinkt also die Länge der Liste in jedem Schritt um einen Faktor . Machen wir das
präzise.
Die Wahrscheinlichkeit, dass ein Element nach Schritten noch nicht eliminiert worden ist,
ist
Sei die Anzahl der Elemente, die nach Schritten noch nicht eliminiert worden
sind, also die Länge der Restliste. Es gilt und
Für ist dies
Falls wir nach Schritten also noch nicht zu Ende sind, dann gilt
. Nach Markovs Ungleichung ist
Wir können also nach Schritten eine "Abstimmung" durchführen, in der
wir schauen, ob es überhaupt noch überlebende Listenelemente gibt.
Hierfür könnte jeder Prozessor für "sein" Element
einen Indikator setzen.
Wir berechnen dann parallel in Schritten die Summe
Falls diese größer ist als 1, dann führen wir noch einmal Schleifendurchläufe durch, und
so weiter. Im Erwartungswert brauchen wir also Schritte, bis Phase 1 zu Ende ist.
Implementierung
Übungsaufgabe 2.6.2
Schreiben Sie den Code für die einzelnen Prozessoren! Merken Sie sich in einer Variable
, "wie lange" der von ausgehende Pointer ist. Merken Sie
sich mit den Zeitpunkt, zu dem eliminiert (d.h. übersprungen)
worden ist.
Tip: um unnötige if-Abfragen zu vermeiden, schreiben Sie einen
Code zur Initialisierung, zur Dekonstruktionsphase und zur Rekonstruktionsphase.
Übungsaufgabe 2.6.3
Wie hoch ist der Arbeitsaufwand? Um den zu bestimmen, versuchen Sie, zumindest für
Phase 1 den Code so präzise wie möglich hinzuschreiben. Schreiben Sie auch
den Scheduler. Wie sehen die Mengen der Prozessoren, die in Schritt aufgerufen werden,
aus?
Der Algorithmus von Anderson und Miller
Der obige Algorithmus braucht immer noch Arbeit, was daran liegt, dass
wir auch für bereits eliminierte Elemente Arbeit verrichten müssen - und sei es nur, zu
überprüfen, ob dieses Element noch in der Restliste oder bereits eliminiert worden ist.
Die Idee des nächsten Algorithmus ist nun, die Elemente so auf eine beschränkte Menge
von Prozessoren aufzuteilen, dass jeder Prozessor sich in jedem Schritt auf ein
noch existierendes Listenelement konzentrieren kann. Der Algorithmus
stammt aus dem Paper A simple randomized parallel algorithm for list-ranking
von Richard J. Anderson und Gary L. Miller aus dem Jahre 1988.
Wir gehen von Prozessoren aus.
Unsere Liste liegt in aufeinanderfolgenden Speicherzellen , wobei
wir eben nicht davon ausgehen können, dass der Listennachfolger von die Zelle ist.
Wir teilen die Elemente gleichmäßig auf die Prozessoren auf, so dass jeder Prozessor einen
Block von zusammenhängend im Speicher liegenden Zellen (also Elementen) bekommt.
Prozessor ist also für die Speicherzellen
zuständig. Wir betonen,
dass die Zellen einen zusammenhängenden Block im Speicher bilden, nicht notwendigerweise
in der Liste. Wir gehen nun ähnlich vor wie im vorherigen Algorithmus, allerdings vorsichtiger.
Jeder Prozessor merkt sich einen Index , nämlich die linkeste Speicherzelle,
die einem noch nicht eliminierten Listenelement entspricht. Anfangs ist also
. Wir nennen die aktive Zelle von Prozessor .
Der Prozessor wählt ein Bit . Falls selbst
Bit gewählt hat (oder gar nicht aktive Zelle eines Prozessors ist),
fügen wir in die Menge der zu eliminierenden Elemente ein.
Falls eliminiert werden soll, so merkt sich der Prozessor den
Zeitpunkt der Eliminierung: .
Wir bestimmen den Vorgänger und führen für ihn
Pointer Jumping durch:
Wir können die Vorgänger anfangs in einem Schritt parallel
berechnen (bzw. mit Prozessoren mit Schritten).
Laufzeit-Analyse
Wir können Phase 1 beenden, wenn alle Prozessoren ihren Teil abgearbeitet haben,
jeder Prozessor also seine Listenelemente eliminiert hat. Im Worst-Case zeigt
die aktive Zelle eines Prozessors auf die aktive eines anderen, und dann ist die
Wahrscheinlichkeit,
dass wir eliminieren können, wieder . Im Schnit würde es also Runden
dauern, bis Prozessor seine Elemente eliminiert hat. (Mit etwas Zusatzarbeit könnten wir
bestimmt zeigen,
dass ein aktives Element eben nicht immer auf ein anderes aktives deuten wird und dass daher die
Wahrscheinlichkeit
deutlich höher als liegen wird; auf mehr als werden wir allerdings sicherlich
nicht kommen,
und da uns konstante Faktoren nicht interessieren, werden wir auch nicht weiter groß darüber
nachdenken).
Wenn nun also jeder Prozessor im individuellen Erwartungswert nach höchstens Schritten
fertig ist, wie lange wird es dauern, bis alle Prozessoren fertig sind?
Wir wollen nun zeigen, dass nach Schritten mit hoher Wahrscheinlichkeit alle
Prozessoren fertig sind.
Wir definieren die Indikatorvariablen wie folgt:
Beobachtung 2.6.1 Die folgenden zwei Aussagen sind äquivalent:
Nach Schritten sind alle Prozessoren fertig - und somit Phase 1 des Algorithmus.
Für alle gilt .
Sei das Ereignis, dass ist; dass also Prozessor nach
Schritten noch nicht fertig ist. Die Ereignisse sind nicht unabhängig, da die
-Pointer im Allgemeinen zwischen den Blöcken, die den einzelnen Prozessoren zugeordnet sind,
hin- und herzeigen.
Wir wenden daher eine Union-Bound-Schranke an:
Wir konzentrieren uns nun darauf, zu zeigen, dass klein ist. Auch für einen Prozessor
sind die unterschiedlichen Variablen im Allgemeinen nicht
unabhängig.
Allerdings gilt: wenn , wenn Prozessor also nach
Schritten
noch nicht alle
Listenelemente abgearbeitet hat, dann gilt : zu jedem Zeitpunkt
wird
das aktive Element mit Wahrscheinlich mindestens eliminiert, egal, was bisher geschehen ist.
Formal:
Beobachtung 2.6.2. Seien Werte mit
. Dann gilt
Wir definieren nun unabhängige Zufallsvariable , die Werte in annehmen
und für jedes erfüllen. Die sind also "mindestens so
gut"
wie die , und daher gilt
Behauptung 2.6.3
Es gilt
und das gilt insbesondere auch für .
Formal müsste man dies mit einem Coupling Argument zeigen. Hier vertrauen wir aber
auf unsere Intuition, dass die ja immer "besser" sind als die .
Die Tschebyschow-Ungleichung reicht nicht aus
Sei . Was ist die Wahrscheinlichkeit, dass Prozessor nach Schritten noch
nicht
fertig ist? Wie wir gesehen haben ist das höchstens die Wahrscheinlichkeit,
dass ist. Sei .
Im Erwartungswert ist . Das "schlechte" Ereignis tritt also dann ein,
wenn der tatsächliche Wert von weniger als ein Viertel seines Erwartungswertes ist.
So eine Wahrscheinlichkeit können wir mit der Tschebyschow-Ungleichung abschätzen.
Lemma 2.6.4(Tschebyschow-Ungleichung). Sei
eine Zufallsvariable, die reelle Werte annimmt und sei . Dann gilt
In unserem Fall gilt . Wenn also , dann ist
und somit
Da ist und die einzelnen unabhängig sind, gilt
da alle die gleiche Verteilung haben. Was ist nun ? Für eine
Variable, die nur die Werte und annimmt und mit Wahrscheinlichkeit
gilt ; in unserem Fall ist und somit .
Zusammen also
Kombinieren wir das nun mit unserem Union Bound oben:
weil wir ja viele Prozessoren verwenden. Was haben wir bewiesen? Die
Wahrscheinlichkeit
zum Scheitern ist höchstens . Das ist aber eine nutzlose Schranke, weil
sie immer größer ist als . Und in der Tat ist die Tschebyschow-Ungleichung bei der
Analyse von Algorithmen oft nicht stark genug.
Sei die Summe unabhängiger -Zufallsvariablen mit . Die
Tschebyschow-Ungleichung
besagt in Worten, dass große Abweichungen vom Erwartungswert bei unwahrscheinlich sind, und
insbesondere hat diese Wahrscheinlichkeit die Form . Der genaue Wert von hängt nun
ab und davon, was wir unter einer "großen" Abweichung verstehen.
Die Chernoff-Schranke, die wir im nächsten Teilkapitel kennenlernen werden, besagt, dass
die Wahrscheinlichkeit einer großen Abweichung noch viel kleiner ist, nämlich
. Da ist, ist das höchstens , wenn wir die
Konstante richtig hinkriegen. Dann könnten wir wie folgt rechnen:
Und somit ist die Wahrscheinlichkeit, dass wir nach Schritten nicht fertig
sind,
sehr klein. Wir können nun nach Schritten eine parallele "Umfrage" durchführen,
um herauszufinden, ob wir fertig sind. Ansonsten würden wir wiederum Schritte
fortfahren. Die erwartete Laufzeit von Phase 1 - und des gesamten Algorithmus - ist somit . Da wir Prozessoren beschäftigen, ist die Anzahl der Arbeitsschritte
.
Theorem 2.6.5 Der Algorithmus von Anderson und Miller löst
List Ranking im Erwartungswert in Zeitschritten und Arbeitsschritten.
Übungsaufgabe 2.6.4
Idealerweise wünschen wir uns bei randomisierten Algorithmen Aussagen der Form
die Wahrscheinlichkeit, dass der Algorithmus nach Schritten nicht zu Ende ist,
ist exponentiell klein.
Zeigen Sie, dass das bei dem obigen Algorithmus nicht der Fall ist;
beispielsweise also, dass
für eine Konstant Ihrer Wahl.
Forschungsaufgabe 2.6.5
Entwerfen Sie einen abgewandelten Algorithmus für List Ranking, für den in der Tat