3.3 Valiants O(loglogn)-Merge (und warum es nicht ganz korrekt ist)

Leslie Valiant fand 1975 eine trickreiche Methode, zwei Arrays der Länge n mit in O(loglogn) zu mergen. Der Algorithmus ist rekursiv und verwendet folgendes Lemma als Grundbaustein:

Lemma 3.3.1 Sei B ein sortiertes Arrays mit |B|=n und x gegeben. Dann können wir rank(x,B) mit n Prozessoren in O(1) Schritten berechnen.

Beweis von Lemma 3.3.1. Wir nehmen an, dass alle Listenelemente verschieden sind, und auch dass xB. Wir haben n+1 Prozessoren P0,P1,,Pn. Prozessor Pj überprüft, ob

(1)B[j]<xi<B[j+1]

gilt, wobei wir B[0]= und B[n+1]= setzen. Falls es gilt, weiß Pj, dass rank(x,B)=j ist und schreibt diesen Wert in die Ergebniszelle. Da es immer genau ein j gibt, das (1) erfüllt, entstehen keine Schreibkonflikte.

Um nun merge(A,B) zu berechnen, wählen wir jedes m-te Element aus A und jedes n-te Element aus B aus

und speichern sie in einem Array:

A:=[A[im] | 1im1]B:=[B[jn] | 1jn1] .

Mittels Lemma 3.3.1 bestimmen wir mit |A||B|=mn Prozessoren in O(1) Zeit den Rang rank(A[i],B) für jedes 1im1. Für bestimmtes i bezeichnen wir diesen Rang mit j. Es gilt also

B[jn]<A[i]<B[(j+1)n] .

Bildlich gesprochen heißt dass, wir wissen, in welches "Teilstück" von B das Element x gehört.

In einem nächsten Schritt bestimmen wir, wieder mit Hilfe von Lemma 3.3.1, den Rang von x=A[i] in "seinem Interval" B[jn+1,,(j+1)n1]. Wir tun dies parallel für jedes 1im1 und brauchen dafür wieder mn Prozessoren und O(1) Zeit. Mit O(mn) Prozessoren und O(1) Zeit haben wir also folgendes berechnet:

Wir kennen also rank(x,B) für jedes xA. So können wir B in m Teilarrays B1,B2,,Bm zerlegen und A ebenso in A1,A2,,Am, so dass

Ai,BiA[im]<Ai+1,Bi+1

gilt. Mit der Schreibweise U<x für eine Liste U meinen wir, dass z<x für jedes zU gilt. Wir können nun also

(2)merge(A,B)=merge(A1,B1)merge(A2,B2)merge(Am1,Bm1)merge(Am,Bm)

wobei jedes merge(Ai,Bi) rekursiv gelöst wird.

Zeitkomplexität

Anfangs haben unsere Listen A und B die Längen m und n. Nach einem Rekursionsschritt haben wir m Teilprobleme (Ai,Bi) mit |Ai|=m. Die Länge der "linken" Liste schrumpft also in O(1) Zeitschritten von m auf m. Nach einem weiteren Rekursionsschritt haben die linken Listen Länge m und ganz allgemein nach t Rekursionsebenen Länge m2t. Für jedes Teilproblem merge(A,B) gehen wir davon aus, dass uns |A||B| Prozessoren zur Verfügung stehen. Nach T:=loglogm Ebenen haben wir Listen der Länge m2T=m2loglogm=m1logm=(2logm)1logm=2

erreicht. Das verbleibende Problem merge(A,B) für |A|2 können wir mit |A||B| Prozessoren in O(1) Schritten berechnen. Insgesamt benötigen wir also O(T)=O(loglogm) Schritte, um merge(A,B) zu berechnen.

Anzahl der Prozessoren

Um die rechte Seite von (2) zu berechnen, müssen wir merge(Ai,Bi) für jedes 1im1 berechnen. Dies tun wir rekursiv. Dafür statten wir das Teilproblem merge(Ai,Bi) mit |Ai||Bi| Prozessoren aus. Sei mi:=Ai und ni:=Bi. Wir wissen, dass mi=m (bis eventuell auf das letzte Array); ni allerdings kann bis zu n Elemente erhalten; wir wissen nur, dass ni=n ist. Wir können daher die benötigte Anzahl an Prozessoren mit der Cauchy-Schwarz-Ungleichung abschätzen:

(3)i=1m1miniimiini=mn .

Wenn A und B anfangs also jeweiles n Elemente haben, so können wir mit n Prozessoren verfahren und diese dann auch auf die Teilprobleme aufteilen - wir haben für jedes Teilproblem ausreichend viele Prozessoren.

Was wir unterschlagen haben

Die obige Laufzeitanalyse unterschlägt ein wichtiges Detail: die Ungleichung (3) sagt uns zwar, dass wir unsere mn hinreichend viele sind, um jedes Teilproblem adäquat auszustatten, sagt uns aber nicht, welcher Prozessor auf welchem Teilproblem arbeiten muss. Betrachten wir den l-ten unserer mn Prozessoren für merge(A,B). Soll er zugeordnet werden? Für die ersten k Teilprobleme benötigen wir

(4)pk:=i=1kmini

Prozessoren. Prozessoren 1,,p1 arbeiten also an merge(A1,B1); Prozessoren p1+1,,p2 an merge(A2,B2) und so weiter. Der Haken hierbei: bei (4) handelt es sich um eine Präfixsumme. Und um die zu berechnen bräuchten wir selbst wieder Zeit O(logn), wie wir in Kapitel 2.3 gelernt haben.

Valiant erkennt dieses Problem und unterscheidet zwischen Schritten, in denen tatsächlich Elemente aus der zu sortierenden Menge verglichen werden und "Overhead" und erwähnt, dass wachsender Overhead die Effizienzgewinne wieder zunichte macht. Auf der einen Seite ist das unbefriedigend, weil wir ja alle Berechnungsschritte zählen wollen, nicht nur die, in denen ein Vergleich ausgeführt wird; andererseits ist es vielleicht ganz interessant, zu untersuchen, was man alles machen kann, wenn man nur Vergleichsoperationen zählt.

Maximum in O(loglogn) parallelel Vergleichsschritten

Lassen wir uns also auf Valiants Gedankenexperiment ein, nur Schritte zu zählen, in denen wirklich Vergleichsoperationen durchgeführt werden. Wir nennen solche Schritte parallele Vergleichsschritte. Alle anderen Operationen, also Ergebnisse addieren, kopieren betrachten wir als kostenlos.

Theorem 3.3.2 Mit n Prozessoren kann man in einem Array von n Elementen in O(loglogn) parallelen Vergleichsschritten das Maximum bestimmen.

Dies ist also deutlich schneller als der traditionelle binärbaumartige Algorithmus, der so vorgeht wie in der K.o.-Runde einer Weltmeisterschaft und O(logn) Schritte braucht.

Beweis. Verallgemeinern wir kurz und nehmen an, wir haben n Prozessoren und n/k Elemente. Im Falle des Theorems wäre k=1, da wir ja n Elemente haben. Wenn wir die n/k Elemente in nkl Blöcke von je l Elementen unterteilen und in jedem Block alle (l2) paarweisen Vergleiche ausführen, dann sind das

(l2)nkl=l12kn

Vergleichsoperationen. Für l:=2k+1 sind das n Operationen, und wir können sie alle parallel mit unseren n Prozessoren ausführen. Wir haben also nach einem Vergleichsschritt genug Information, um in jedem Block das Maximum zu bestimmen.

Achtung. Es braucht dann immer noch O(logl) Schritte, um pro Block das Maximum zu ermitteln. Nur müssen wir hier nur die Ergebnisse bereits erfolgte Vergleichsoperationen betrachten und keine weiteren Vergleiche tätigen. Daher zählen wir diese Schritte nicht als Vergleichsschritte mit, sondern betrachten sie in diesem Zusammenhang als kostenlos.

Mit einem Vergleichsschritt haben wir also pro Block das Maximum ermittelt. Das globale Maximum aller n/k Elemente muss nun eines der nk(2k+1) verbleibenden Kandidaten, der Blockmaxima sein. Wir fahren rekursiv fort, nun mit k:=k(2k+1). Wie groß ist also die Menge der verbleibenden Kandidaten nach i Vergleichsschritten? Wir definieren

k0:=1ki+1:=ki(2ki+1)

Dann verbleiben nach i Schritten nur noch n/ki Kandidaten. Wenn n/ki=O(1) erreicht ist (z.B. n/k_i = 1 oder 2), dann beenden wir die Rekursion und ermitteln das Maximum in O(1) verbleibenden Vergleichsschritten. Um uns die Rechnung zu erleichtern, definieren wir

l0:=2li+1:=li2

Per Induktion sieht man, das kili für alle i2 gilt und außerdem li=22i. Es verbleiben nach i2 Schritten also

nkinli=n22i

Kandidaten. Nach T=loglogn Schritten verbleibt noch n22T=1 Kandidat und wir sind fertig (bzw. wir sind schon vor T fertig, weil die Zahl der Kandidaten schon zuvor 1 erreicht haben wird).

Übungsaufgabe 3.3.1 Wir sind stillschweigend davon ausgegangen, dass man die n/k Elemente in nkl Blöcke der Größe l aufteilen kann. Das geht natürlich nur, wenn nk durch l teilbar ist (und selbst eine natürliche Zahl ist). Passen Sie die Analyse so an, dass Sie diesen Teilbarkeitsschwierigkeiten Rechnung tragen.