2.6 Listenpräfix in O(logn) Zeit und O(n) Arbeit

Unser Pointer-Jumping-Algorithmus für Listenpräfix (eigentlich: Listensuffix) aus Kapitel 2.4 läuft in Zeit O(logn) und mit Arbeit O(nlogn), weil in jedem Schritt jeder der n 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 O(n) 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 O(logn) und Arbeit O(n) als Subroutine zur Verfügung stelle, dann können Sie auch List Prefix in Zeit O(logn) und Arbeit O(n) 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 X verabschieden sich; Elemente in LX überspringen sie."

Diesen Schritt kann man in einem Zeitschritt ausführen, falls keine zwei aufeinanderfolgenden Listenglieder in X liegen. Falls dies so wäre, könnten wir den neuen Nachfolger nicht in konstanter Zeit berechnen, wie zum Beispiel Knoten u in dieser Liste:

Um die Elemente in X in konstanter Zeit überspringen zu können, um also die "kontrahierte" Liste auf den Elementen LX in konstanter Zeit bauen zu können, müssen wir (in konstanter Zeit) eine unabhängige Menge X finden; und zwar soll X möglichst groß sein - im Idealfall n/2, aber Ω(n) wäre auch in Ordnung; dann würden wir in jeder Runde einen konstanten Anteil von Elementen eliminieren (überspringen) und wären nach O(logn) Runden fertig.

Wir unterhalten einen Prozessor pro Listenelement u. In jedem Schritt wirft jeder Prozessor eine Münze - wählt also ein Zufallsbit b(u) in {0,1}. Wenn nun b(u)=1 und b(succ[u])=0, dann fügen wir u in X ein.

Wir wiederholen diesen Schritt T mal. Elemente u, die zu einem Zeitpunkt t eliminiert werden (also grau markiert werden), merken sich den Zeitpunkt: elim-time[u]:=t. 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 dist[u]:=1 für alle u bis auf das letzte Glied; dann setzen wir, falls succ[u] eliminiert wird:

(falls succ[u] eliminiert wird)dist[u]:=dist[u]+dist[succ[u]]

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 rank[u] für jedes Element berechnen wollen (also den Abstand von Element u zum Ende der Liste, gemessen in der ursprünglichen Liste). Sei z das letzte Listenelement. Wir setzen rank[z]:=0 und zählen dann die Zeitschritte zurück: t=T,T1,,1. Sei t der aktuelle Zeitschritt dieser Phase. Wenn elim-time[u]=t ist, dann setzen wir

rank[u]:=dist[u]+rank[succ[u]] .

Per Induktion können wir nun zeigen, dass jedes Element u zum Zeitpunkt t=elim-time[u] seinen richtigen Rang zugewiesen bekommt:

  1. Sei v:=succ[u]. Das Element v wird später als u eliminiert (oder gar nicht, wenn es das Ende der Liste ist), also hat \rank[v] bereits den korrekten Wert.
  2. Den Abstand von u zu v in der ursprünglichen Liste haben wir uns in Phase 1 in dist[u] 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 n Schritten für jedes Element b(u)=0 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) u in der nächsten Runde eliminiert wird?

Pr[u wird eliminiert]=Pr[b(u)=1b(succ[u])=0]=14 .

Im Schnitt sinkt also die Länge der Liste in jedem Schritt um einen Faktor 3/4. Machen wir das präzise. Die Wahrscheinlichkeit, dass ein Element u nach t Schritten noch nicht eliminiert worden ist, ist

(34)t

Sei Yt die Anzahl der Elemente, die nach t Schritten noch nicht eliminiert worden sind, also die Länge der Restliste. Es gilt Y0=n und

E[Yt]=ElementuPr[u noch nicht eliminiert nach t Schritten]=n(34)t .

Für t:=6logn ist dies

E[Y6logn]=n(34)6logn=n(2764)2logn<n(12)2logn=n1n2=1n .

Falls wir nach 6logn Schritten also noch nicht zu Ende sind, dann gilt Y6logn1. Nach Markovs Ungleichung ist

Pr[Y6logn1]1n .

Wir können also nach t=6logn 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 u einen Indikator survives[u]{0,1} setzen. Wir berechnen dann parallel in logn Schritten die Summe

survives[1]+survives[2]++survives[n] .

Falls diese größer ist als 1, dann führen wir noch einmal 6logn Schleifendurchläufe durch, und so weiter. Im Erwartungswert brauchen wir also O(logn) 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 dist[u], "wie lange" der von u ausgehende Pointer ist. Merken Sie sich mit elim-time[u] den Zeitpunkt, zu dem u 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 St der Prozessoren, die in Schritt t aufgerufen werden, aus?

Der Algorithmus von Anderson und Miller

Der obige Algorithmus braucht immer noch O(nlogn) 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 n/logn Prozessoren aus. Unsere Liste liegt in n aufeinanderfolgenden Speicherzellen 1,,n, wobei wir eben nicht davon ausgehen können, dass der Listennachfolger von i die Zelle i+1 ist. Wir teilen die n Elemente gleichmäßig auf die Prozessoren auf, so dass jeder Prozessor einen Block von logn zusammenhängend im Speicher liegenden Zellen (also Elementen) bekommt. Prozessor i ist also für die Speicherzellen Zi:={ilog(n),ilog(n)+1,,ilog(n)+log(n)1} zuständig. Wir betonen, dass die Zellen Zi einen zusammenhängenden Block im Speicher bilden, nicht notwendigerweise in der Liste. Wir gehen nun ähnlich vor wie im vorherigen Algorithmus, allerdings vorsichtiger.

  1. Jeder Prozessor merkt sich einen Index vZi, nämlich die linkeste Speicherzelle, die einem noch nicht eliminierten Listenelement entspricht. Anfangs ist also v=ilog(n). Wir nennen v die aktive Zelle von Prozessor i.
  2. Der Prozessor wählt ein Bit b[v]{0,1}. Falls succ[v] selbst Bit 0 gewählt hat (oder gar nicht aktive Zelle eines Prozessors ist), fügen wir v in die Menge X der zu eliminierenden Elemente ein.
  3. Falls j eliminiert werden soll, so merkt sich der Prozessor den Zeitpunkt der Eliminierung: elim-time[v]=t. Wir bestimmen den Vorgänger u:=pred[v] und führen für ihn Pointer Jumping durch: dist[u]:=dist[u]+dist[v]succ[u]:=succ[v]

Wir können die Vorgänger pred[v] anfangs in einem Schritt parallel berechnen (bzw. mit n/logn Prozessoren mit logn Schritten).

Laufzeit-Analyse

Wir können Phase 1 beenden, wenn alle Prozessoren ihren Teil abgearbeitet haben, jeder Prozessor also seine logn Listenelemente eliminiert hat. Im Worst-Case zeigt die aktive Zelle v eines Prozessors auf die aktive eines anderen, und dann ist die Wahrscheinlichkeit, dass wir v eliminieren können, wieder 14. Im Schnit würde es also 4logn Runden dauern, bis Prozessor i 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 14 liegen wird; auf mehr als 1/2 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 4logn Schritten fertig ist, wie lange wird es dauern, bis alle Prozessoren fertig sind? Wir wollen nun zeigen, dass nach 16logn Schritten mit hoher Wahrscheinlichkeit alle Prozessoren fertig sind.

Wir definieren die Indikatorvariablen Xt(i) wie folgt:

Xt(i):={1 wenn Prozessor i zum Zeitpunkt t ein Listenelement eliminieren konnte0 sonst.

Beobachtung 2.6.1 Die folgenden zwei Aussagen sind äquivalent:

  1. Nach T Schritten sind alle Prozessoren fertig - und somit Phase 1 des Algorithmus.
  2. Für alle i gilt t=1TXt(i)=logn.

Sei Ei das Ereignis, dass t=1TXt(i)<logn ist; dass also Prozessor i nach T Schritten noch nicht fertig ist. Die Ereignisse E1,E2,,Ep sind nicht unabhängig, da die succ-Pointer im Allgemeinen zwischen den Blöcken, die den einzelnen Prozessoren zugeordnet sind, hin- und herzeigen. Wir wenden daher eine Union-Bound-Schranke an:

Pr[Nach T Schritten sind nicht alle Prozessoren fertig]=Pr[i=1pEi]i=1pPr[Ei] .

Wir konzentrieren uns nun darauf, zu zeigen, dass Pr[Ei] klein ist. Auch für einen Prozessor i sind die unterschiedlichen Variablen X1(i),X2(i), im Allgemeinen nicht unabhängig. Allerdings gilt: wenn t=1sXs(i)<logn, wenn Prozessor i also nach s Schritten noch nicht alle Listenelemente abgearbeitet hat, dann gilt Pr[Xs+1(i)]14: zu jedem Zeitpunkt wird das aktive Element mit Wahrscheinlich mindestens 1/4 eliminiert, egal, was bisher geschehen ist. Formal:

Beobachtung 2.6.2. Seien x1,,xs{0,1} Werte mit x1++xs<logn. Dann gilt

Pr[Xs+1(i)=1 | X1(i)=x1,,Xs(i)=xs]14 .

Wir definieren nun unabhängige Zufallsvariable Y1,,YT, die Werte in {0,1} annehmen und Pr[Yt=1]=14 für jedes t erfüllen. Die Xt(i) sind also "mindestens so gut" wie die Yt, und daher gilt

Behauptung 2.6.3 Es gilt

Pr[X1(i)++XT(i)k]Pr[Y1++YTk] ,

und das gilt insbesondere auch für k=log(n)1.

Formal müsste man dies mit einem Coupling Argument zeigen. Hier vertrauen wir aber auf unsere Intuition, dass die Xt(i) ja immer "besser" sind als die Yt.

Die Tschebyschow-Ungleichung reicht nicht aus

Sei T=16logn. Was ist die Wahrscheinlichkeit, dass Prozessor i nach T Schritten noch nicht fertig ist? Wie wir gesehen haben ist das höchstens die Wahrscheinlichkeit, dass Y1+YT<logn=T/16 ist. Sei Y:=Y1+YT. Im Erwartungswert ist E[Y]=T/4. Das "schlechte" Ereignis tritt also dann ein, wenn der tatsächliche Wert von Y 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 Y eine Zufallsvariable, die reelle Werte annimmt und sei θR. Dann gilt

Pr[|YE[Y]|θ]Var(Y)θ2 .

In unserem Fall gilt E[Y]=T/4. Wenn also Y<T/8, dann ist |YE[Y]|>|T/8T/4|=T/8 und somit

Pr[Y<T/8]Var(Y)(T/8)2 .

Da Y=Y1++YT ist und die einzelnen Yt unabhängig sind, gilt

Var(Y)=Var(Y1)++Var(VT)=TVar(Y1) ,

da alle Yt die gleiche Verteilung haben. Was ist nun Var(Y1)? Für eine Variable, die nur die Werte 0 und 1 annimmt und 1 mit Wahrscheinlichkeit p gilt Var(Y1)=p(1p); in unserem Fall ist p=1/4 und somit Var(Y1)=1434. Zusammen also

Pr[Y<T/8]Var(Y)(T/8)2=T316(T/8)2=T364T216=12T=128logn=321logn .

Kombinieren wir das nun mit unserem Union Bound oben:

Pr[Nach T Schritten sind nicht alle Prozessoren fertig]=Pr[i=1pEi]i=1pPr[Ei]=i=1pPr[t=1TXt(i)<logn]i=1pPr[t=1TYt<logn]i=1p321logn=3p2logn=3n2(logn)2 ,

weil wir ja p=n/logn viele Prozessoren verwenden. Was haben wir bewiesen? Die Wahrscheinlichkeit zum Scheitern ist höchstens 3n2(logn)2. Das ist aber eine nutzlose Schranke, weil sie immer größer ist als 1. Und in der Tat ist die Tschebyschow-Ungleichung bei der Analyse von Algorithmen oft nicht stark genug.

Sei Y=Y1++YT die Summe unabhängiger 0/1-Zufallsvariablen mit Pr[Yt=1]=p. Die Tschebyschow-Ungleichung besagt in Worten, dass große Abweichungen vom Erwartungswert bei Y unwahrscheinlich sind, und insbesondere hat diese Wahrscheinlichkeit die Form c/T. Der genaue Wert von c hängt nun p 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 exp(cT). Da T=logn ist, ist das höchstens 1/n2, wenn wir die Konstante c richtig hinkriegen. Dann könnten wir wie folgt rechnen:

Pr[Nach T Schritten sind nicht alle Prozessoren fertig](siehe Rechnung oben)i=1pPr[t=1TYt<logn]i=1p1n2=nlogn1n2<1n .

Und somit ist die Wahrscheinlichkeit, dass wir nach 16logn Schritten nicht fertig sind, sehr klein. Wir können nun nach 16logn Schritten eine parallele "Umfrage" durchführen, um herauszufinden, ob wir fertig sind. Ansonsten würden wir wiederum 16logn Schritte fortfahren. Die erwartete Laufzeit von Phase 1 - und des gesamten Algorithmus - ist somit O(logn). Da wir n/logn Prozessoren beschäftigen, ist die Anzahl der Arbeitsschritte O(n).

Theorem 2.6.5 Der Algorithmus von Anderson und Miller löst List Ranking im Erwartungswert in O(logn) Zeitschritten und O(n) Arbeitsschritten.

Übungsaufgabe 2.6.4 Idealerweise wünschen wir uns bei randomisierten Algorithmen Aussagen der Form die Wahrscheinlichkeit, dass der Algorithmus nach T Schritten nicht zu Ende ist, ist exponentiell klein. Zeigen Sie, dass das bei dem obigen Algorithmus nicht der Fall ist; beispielsweise also, dass

Pr[nach 16logn Schritten noch nicht fertig]1nc

für eine Konstant cR Ihrer Wahl.

Forschungsaufgabe 2.6.5 Entwerfen Sie einen abgewandelten Algorithmus für List Ranking, für den in der Tat

Pr[nach clogn Schritten noch nicht fertig]exp(n) gilt.