¢¡¤£¦¥¤§© ¤

Werbung
http://www.mpi-sb.mpg.de/~hannah/info5-ws00
IS
UN
R
S
WS 2000/2001
E R SIT
S
Bast, Schömer
SA
IV
A
Grundlagen zu
Datenstrukturen und Algorithmen
A VIE N
Fragmente aus den Vorlesungen∗
1 Arithmetik großer Zahlen + Grundlagen
2
1.1 Literatur . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
2
1.2 Repräsentation im Rechner . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
2
1.3 Der Schuladditionsalgorithmus . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
3
1.4 Der Schulmultiplikationsalgorithmus
. . . . . . . . . . . . . . . . . . . . . . . . .
3
1.5 Die O-Notation (auch: Landausche Symbole) . . . . . . . . . . . . . . . . . . . . .
4
1.6 Ein rekursiver Multiplikationsalgorithmus . . . . . . . . . . . . . . . . . . . . . . .
5
1.7 Allgemeine Sätze für Rekursions(un)gleichungen . . . . . . . . . . . . . . . . . . .
6
1.8 Schlauere rekursive Multiplikation . . . . . . . . . . . . . . . . . . . . . . . . . . .
7
1.9 Kombination von rekursiver und Schulmethode
. . . . . . . . . . . . . . . . . . .
8
1.10 Noch schnellere Multiplikation . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
9
1.11 Ein Checker für die Multiplikation langer Zahlen . . . . . . . . . . . . . . . . . . .
9
1.12 Das RAM-Maschinenmodell . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
11
zuletzt geändert am 26. Februar 2001
∗
eine Html-Version findet sich unter http://www.mpi-sb.mpg.de/~hannah/info5-ws00/fragmente
1
Arithmetik großer Zahlen + Grundlagen
Gegeben: Arithmetik (+, −, ∗, /, etc.) mit ganzen Zahlen beschränkter Größe.
Gesucht: Arithmetik mit ganzen Zahlen beliebiger Größe.
Beispiel Mensch: kann nur das kleine Einmaleins.
Beispiel Computer: typischerweise Datentyp int mit 32 oder 64 Bit, d.h. höchstens 232 bzw. 264
verschiedene Zahlen.
Viele Anwendungen, z.B. Kryptographie, exaktes Rechnen, Computeralgebra.
1.1
Literatur
Inhaltlich findet sich das meiste, was wir in diesem Vorlesungsblock besprechen, in den Kapiteln Introduction: Integer Arithmetic“ und Foundations“ aus den Course Notes von Mehl”
”
horn/Sanders vom SS00 wieder; siehe http://www.mpi-sb.mpg.de/~mehlhorn/ftp/Info5/introduction.
ps bzw. http://www.mpi-sb.mpg.de/~mehlhorn/ftp/Info5/Foundations.ps
Bemerkung: Wir werden in der Vorlesung öfter in anderer Reihenfolge vorgehen, andere Schwerpunkte setzen, andere Beispiele geben, oder Beweise anders führen als in den Course Notes beschrieben. Profitieren Sie von den verschiedenen Blickwinkeln, und insbesondere davon, dass es
Fehler sowohl im Skript als auch in der Vorlesung gibt, aber die Schnittmenge der beiden ziemlich
klein ist!
Abschnitt 1.6 aus dem Kapitel Introduction: Integer Arithmetics“ ist für uns nicht relevant (das
”
haben wir in der Vorlesung geschickter gemacht).
Ein Kapitel über die O-Notation und die Auflösung von Rekursions(un)gleichungen, findet sich
übrigens auch in jedem Lehrbuch zum Thema Datenstrukturen und Algorithmen; siehe http:
//www.mpi-sb.mpg.de/~hannah/info5-ws00/literatur.html.
Eine Postscript-Version dieses Kapitels findet sich unter http://www.mpi-sb.mpg.de/~hannah/
info5-ws00/download/fragmente.arithmetik.ps.gz, eine Pdf-Version unter http://www.mpi-sb.
mpg.de/~hannah/info5-ws00/download/fragmente.arithmetik.pdf.
1.2
Repräsentation im Rechner
Fakt: Für eine
gegebene Basis B ≥ 2, lässt sich jede nichtnegative natürliche Zahl a eindeutig
Pn−1
schreiben als i=0 ai · B i =: (an−1 . . . a0 )B mit 0 ≤ ai < B für i = 0, . . . , n − 1.
Für die Repräsentation beliebig großer Zahlen im Rechner bietet es sich daher an, B als die
Anzahl der verschiedenen Zahlen, die sich mit dem Datentyp int darstellen lassen, zu wählen
(typisch 232 oder 264 ) und eine Zahl (an−1 . . . a0 )B in einem Feld von n int’s zu speichern.
Im Folgenden wird immer angenommen, dass wir uns für eine feste Basis B entschieden haben.
Wir wollen uns an dieser Stelle noch nicht mit den Details der Implementierung dieser (verhältnismäßig simplen) Datenstruktur beschäftigen, und nehmen einfach erstmal an, dass wir einen
Datentyp integer haben, für den wir z.B. folgendes schreiben können:
a.digits()
a[i]
für die Anzahl der Stellen von a (das n von oben);
für die ite Stelle eines integer a (0, falls i ≥ n).
Definition: Eine primitive Addition ist eine Addition dreier einstelliger Zahlen. Eine primitive
Multiplikation ist eine Multiplikation zweier einstelliger Zahlen.
Bemerkung 1: Das Ergebnis einer primitiven Operation kann also höchstens zwei stellig sein.
Bemerkung 2: Multiplikation mit B i , Division durch B i , oder modulo B i für ein gegebenes i ∈
N0 benötigt zwar Arbeit, aber keine primitiven Operationen. Intuitiv sind das ja auch einfachere
Operationen.
1.3
Der Schuladditionsalgorithmus
integer school_add(integer& a, integer& b)
{
int n = max( a.digits(), b.digits() );
integer s = 0;
// Variable für die Summe
int carry = 0;
// Variable für den Übertrag
for(int i = 0; i < n; i++)
{
integer si = primitive_add(a[i], b[i], carry);
// eine primitive Addition (mit zweistelligem Resultat)
s[i] = si[0];
carry = si[1];
}
s[n] = carry;
return s;
}
Bemerkung: Wir benutzen in dieser Vorlesung häufiger die Syntax von C bzw. C++, aber nur in
ihrer elementarsten Form. Sie lernen die Syntax und Semantik ohne weiteres durch die Beispiele
aus der Vorlesung.
Lemma: Um zwei n-stellige Zahlen zu addieren benötigt der Algorithmus school_add genau n
primitive Operationen.
Beweis: In jeder Iteration wird eine primitive Addition ausgeführt, und es gibt n Iterationen
insgesamt.
Bemerkung: Man sieht auch leicht, dass es im Allgemeinen besser nicht geht, weil man ja
zumindest jede der insgesamt 2n Ziffern einmal anschauen muss.
1.4
Der Schulmultiplikationsalgorithmus
integer school_mult(integer& a, integer& b)
{
integer p = 0;
// das wird das Produkt
for(int i = 0; i < n; i++)
{
p = p + ( (a * b[i]) << i ); // multipliziere a mit der i-ten Ziffer
(x)
}
//
von b und schiebe um i nach links
return p;
}
Bemerkung: Das kann man in C++ sogar genau so schreiben, wenn man die beiden Operatoren
integer operator*(const integer& a, digit d)
integer operator<<(const integer& a, int i)
geeignet definiert (sog. Operator Overloading). Ohne Operatoren müsste man hier so was schreiben
wie
p = school_add(p, shift_left( mult_by_digit(a, b[i]) , i ) );
Lemma: Um zwei n-stellige Zahlen miteinander zu multiplizieren benötigt der Algorithmus
school_mult zwischen 3.5 · n2 und 4 · n2 primitive Operationen.
Beweis: Die Multiplikation einer n-stelligen Zahl mit einer einstelligen Zahl lässt sich mit n
primitiven Multiplikationen und n primitiven Additionen bewerkstelligen (Übungsaufgabe!). Das
um i nach links geschobene Produkt hat n + 1 + i Stellen, also benötigt die Addition in der i-ten
Iteration n+1+i primitive Additionen und p hat danach n+i+1 Stellen, für i = 0, . . . , n−1 (das
erfordert formal einen, wenn auch sehr einfachen, Induktionsbeweis). Die i-te Iteration
benötigt
Pn
also 3n + i + 1 primitive Operationen insgesamt, und die gesamte Schleife somit i=1 (3n + i) =
3 · n2 + 1/2 · n · (n + 1) = 3.5 · n2 + n/2 ≤ 4 · n2 viele.
1.5
Die O-Notation
[Zuerst haben wir die Begriffe Problem, Instanz eines Problems, sowie Größe einer Instanz eines
Problems erklärt.]
Definition: Für g : N → R+
0 ist
def
O(g) = {f : N → R|∃c > 0 ∃n0 ∈ N ∀n ≥ n0 : f (n) ≤ c · g(n)}.
Bemerkung 1: Man sagt Oh-von-g“ oder auch Groß-Oh-von-g“ (je nachdem, ob ein Klein”
”
”
Oh“ in der Nähe ist). Intuitiv ist f ∈ O(g), falls die Größenordnung von f nicht größer ist als die
von g.
Bemerkung 2: Statt f ∈ O(g) schreibt man typischerweise f (n) = O(g(n)), wenn klar ist, dass
die Funktion von n abhängt, also z.B. n = O(n2 ) (Achtung: n und n2 sind dann nicht als Terme
zu verstehen, sondern stehen hier für die Funktionen n 7→ n und n 7→ n2 ).
Bemerkung 3: Das Gleichheitszeichen ist dann aber nicht mehr symmetrisch, genausowenig wie
≤ symmetrisch ist.
Beispiel 1: 1000 · n = O(n2 ), obwohl 1000 · n > n2 für alle n < 1000.
Beispiel 2: 1000 · n2 = O(10 · n2 ) = O(n2 ).
Definition: Für g : N → R+
0 ist
def
Ω(g) = {f : N → R|∃c > 0 ∃n0 ∈ N ∀n ≥ n0 : f (n) ≥ c · g(n)}.
Bemerkung: Intuitiv ist f ∈ Ω(g), falls die Größenordnung von f nicht kleiner ist als die von g.
Beispiel 1: n2 = Ω(n).
Beispiel 2: n/10 = Ω(100 · n).
Definition: Für g : N → R+
0 ist
def
Θ(g) = O(g) ∩ Ω(g).
Bemerkung: Intuitiv ist also f ∈ Θ(g), falls die Größenordnung von f dieselbe ist wie die von
g.
Beispiel: 7 · n2 − 8 · n + 5 = Θ(n2 ).
1.6
Ein rekursiver Multiplikationsalgorithmus
Prinzip: Teile-und-Löse (in der Literatur oft divide-and-conquer, etwa teile-und-beherrsche“,
”
aber das ist Kriegssprache).
Konkret: Gegeben zwei n-stellige Zahlen a = (an−1 . . . a0 )B und b = (bn−1 . . . b0 )B , teile a und b
jeweils in zwei Hälften a(1) = (an−1 . . . am )B und a(0) = (am−1 . . . a0 )B bzw. b(1) = (bn−1 . . . bm )B
und b(0) = (bm−1 . . . b0 )B , wobei m = dn/2e. Dann gilt a = a(1) · B m + a(0) und b = b(1) · B m + b(0)
und somit
a · b = (a(1) · B m + a(0) ) · (b(1) · B m + b(0) ) = a(1) · b(1) · B 2m + a(1) · b(0) + a(0) · b(1) · B m + a(0) · b(0) .
Es lässt sich also die Multiplikation von zwei n-stelligen Zahlen auf vier Multiplikationen zweier
höchstens dn/2e-stelligen Zahlen zurückführen (man beachte, dass dn/2e < n für alle n ≥ 2).
integer recursive_mult(integer& a, integer& b)
{
int n = a.digits();
if (n == 1)
// falls a und b einstellig ...
{
return primitive_mult(a[0],b[0]);
// ... tut es eine primitive Multiplikation
}
int m = round_up(n/2);
a1 = a >> m;
// schiebe a um m Ziffern nach rechts
a0 = a - (a1 << m);
// die rechtesten m Ziffern von a
b1 = b >> m;
// schiebe b um m Ziffern nach rechts
b0 = b - (b1 << m);
// die rechtesten m Ziffern von b
integer p1 = recursive_mult(a0,b0);
integer p2 = recursive_mult(a1,b0);
integer p3 = recursive_mult(a0,b1);
integer p4 = recursive_mult(a1,b1);
return p1 + (p2 + p3) << m + p4 << (2*m);
}
Lemma: Um zwei n-stellige Zahlen miteinander zu multiplizieren benötigt der Algorithmus
recursive_mult Θ(n2 ) primitive Operationen.
Beweis: Sei T (n) die Anzahl der primitiven Operationen, die recursive_mult für zwei n-stellige
Zahlen benötigt. Dann gilt die folgende Rekursionsgleichung:
1
falls n = 1;
T (n) =
4 · T (dn/2e) + 3 · 2n falls n > 1.
Iteratives Einsetzen führt uns zu der Vermutung, dass für alle n, die Zweierpotenzen sind, gilt,
T (n) = 7n2 − 6n,
was sich dann auch durch Induktion beweisen lässt. (Wir haben auch gesehen, dass sich die
schwächere Aussage T (n) ≤ 7n2 nicht über Induktion beweisen lässt.)
Laufzeittests: Zeitmessungen (für konkrete Zahlen siehe die Course Notes vom SS 2000) zeigen deutlich die quadratische Abhängigkeit von n. Sie legen aber auch nahe, dass der konstante
Faktor ca. 40 mal höher liegt als für school_mult. Das liegt daran, dass Rekursion mit einem
erheblichen Overhead (neudeutsch für Zusatzkosten) verbunden ist (siehe Kapitel über das Maschinenmodell), der bei obigem Algorithmus durch nichts aufgewogen wird; siehe aber unseren
nächsten Algorithmus.
Streng genommen haben wir das Lemma nur für n, die Zweierpotenzen sind, bewiesen. Wie folgern
wir daraus das allgemeine Resultat?
Tipp: Sowas ist manchmal eine rein technische Angelegenheit (im mathematischen Sinne), manchmal aber auch ein zusätzliches tiefes Problem.
Möglichkeit 1: (Aus der Theorie-Trickkiste) Wir ändern den Algorithmus so ab, dass er es
nur mit n’s zu tun hat, die Zweierpotenzen sind, indem wir die Zahlen vorne mit hinreichend
vielen Nullen auffüllen. Da sich die Eingabegröße dabei höchstens verdoppelt(!), gilt dann für die
Laufzeit T̃ des so modifizierten Algorithmus, dass für alle n ∈ N,
2 T̃ (n) = T (2dlog2 ne ) = Θ 2dlog2 ne
= Θ(n2 ).
Möglichkeit 2: Wir können hier aber auch einfach die Analyse genauer machen. Das kann
manchmal sehr kompliziert sein, aber hier reicht es zu beobachten, dass T monoton ist, und
daher für beliebiges n ∈ N gilt, T (n) ≤ T (2dlog2 ne ) = O(n2 ) und T (n) ≥ T (2blog2 nc ) = Ω(n2 ).
1.7
Allgemeine Sätze für Rekursions(un)gleichungen
Szenario: (Teile-und-Löse allgemein(er)) Ein Problem, für das sich mit Arbeit f (n) Instanzen
der Größe n in a Instanzen der Größe (höchstens) dn/be aufspalten und deren Lösungen zur
Gesamtlösung zusammensetzen lassen. Instanzen der Größe n ≤ n0 können direkt mit f (n) Arbeit
gelöst werden.
Die entsprechende Rekursionsgleichung für die Gesamtarbeit T (n):
f (n)
falls n ≤ n0 ;
T (n) =
a · T (dn/be) + f (n) falls n > n0 .
Durch wiederholtes Einsetzen leiten wir eine explizite Formel her, für n von der Form n0 · bk , für
ein k ∈ N
k−1
k
X
X
k
i
i
T (n) = a · T (n0 ) +
a · f (n/b ) =
ai · f (n/bi ).
i=0
i=0
Intuition: Der letzte Term dieser Summe (i = k) bezeichnet gerade die Kosten, um all die nicht
mehr weiter aufgespalteten Teilprobleme zu lösen (ak viele). Der i-te Term, für i < k, bezeichnet
gerade die Kosten für’s Aufspalten und Rekombinieren in der i-ten Rekursionsstufe.
Lemma: Für alle n mit logb (n/n0 ) ∈ N, d.h. von der Form n0 · bk für ein k ∈ N gilt
T (n) =
k
X
ai · f (n/bi ).
i=0
Beweis: Über vollständige Induktion (2. Übungsblatt, Aufgabe 5).
P
Oft ist f von der Form c · nd , für c, d > 0. Dann erhalten wir T (n) = c · nd ki=0 (a/bd )i , also eine
geometrische Reihe. Um einen expliziten Ausdruck zu erhalten unterscheiden wir drei Fälle:
1. Fall: a/bd < 1 ⇐⇒ a < bd ⇐⇒ logb a < d
Intuitiv: Die Gesamtkosten T (n) werden dominiert vom Aufspalten und Rekombinieren in der
ersten Rekursionsstufe. Formal: T (n) = Θ(nd ).
2. Fall: a/bd > 1 ⇐⇒ a > bd ⇐⇒ logb a > d
Intuitiv: T (n) wird dominiert durch die Kosten für das Lösen der Teilprobleme. Formal: T (n) =
Θ(nd · (a/bd )k ) = Θ(nd · nlogb a · n−d ) = Θ(nlogb a ).
3. Fall: a/bd = 1 ⇐⇒ a = bd ⇐⇒ logb a = d
Intuitiv: Jede Rekursionsstufe, sowie das letztliche Lösen der Teilprobleme ist gleich teuer. Formal:
T (n) = c · nd · (k + 1) = Θ(nd · log n).
Für allgemeine n ∈ N können wir dasselbe Argument wie bei der Analyse von recursive_mult
verwenden.
Master Theorem für Rekursions(un)gleichungen: Für T (n) wie oben, mit f von der Form
f (n) ≤ c · nd , gilt

falls logb a > d;
 O nlog b a
d
O n
T (n) =
falls logb a < d;

O nd · log n falls logb a = d.
Zusatz: Mit ≥ statt ≤ gilt die analoge Aussage mit Ω statt O (wir wollen schließlich sichergehen,
dass wir unsere Algorithmen so gut wie möglich analysieren; z.B. ist ja auch Tschool−mult (n) =
O(n10000 )).
1.8
Schlauere rekursive Multiplikation
Idee: (Karatsuba und Ofman, 1963) In unserem rekursiven Algorithmus für die Multiplikation
lässt sich eine Multiplikation sparen, zum Preis von drei zusätzlichen Additionen bzw. Subtraktionen: (a, b, n, m, a(1) , a(0) , b(1) , b(0) wie vorher)
a · b = (a(1) · B m + a(0) ) · (b(1) · B m + b(0) )
= a(1) · b(1) · B 2m + a(1) · b(0) + a(0) · b(1) · B m + a(0) · b(0)
= a(1) · b(1) · B 2m + (a(1) + a(0) ) · (b(1) + b(0) ) − a(1) · b(1) − a(0) · b(0) · B m + a(0) · b(0) .
Bemerkung: Wir müssen aufpassen; a(1) + a(0) und b(1) + b(0) haben möglicherweise (dn/2e + 1)
Stellen, und dn/2e + 1 = n für n = 2, 3. Aufspalten macht daher nur Sinn für n ≥ 4. Für n ≤ 3
benutzen wir einfach irgendeine Methode, z.B. die Schulmethode.
integer clever_mult(integer& a, integer& b)
{
int n = a.digits();
if (n <= 3)
// falls höchstens drei Stellen ...
{
return school_mult(a,b);
// ... benutzen wir die Schulmethode
}
int m = round_up(n/2);
//
a1 = a >> m;
// ganz genau
a0 = a - (a1 << m);
// wie bei
b1 = b >> m;
// recursive_mult
b0 = b - (b1 << m);
//
integer p1 = clever_mult(a0,b0);
integer p2 = clever_mult(a1,b1);
integer p3 = clever_mult(a0+a1,b0+b1);
return p1 + (p3 - p1 - p2) << m + p2 << (2*m);
}
Lemma: Um zwei n-stellige Zahlen miteinander zu multiplizieren benötigt der Algorithmus
clever_mult O(nlog2 3 ) primitive Operationen.
Beweis: Sei T (n) die Anzahl der primitiven Operationen, die clever_mult für zwei n-stellige
Zahlen benötigt. Dann gilt
O(1)
falls n ≤ 3;
T (n) ≤
3 · T (dn/2e + 1) + 6 · 2n falls n > 3.
Das ist nicht ganz das Format von unserem Master Theorem, aber für T̃ (n) = T (n + 2) ist
T (d(n + 2)/2e + 1) = T (dn/2e + 2) = T̃ (dn/2e) und daher
O(1)
falls n ≤ 3;
T̃ (n) ≤
3 · T̃ (dn/2e) + 12n + 24 falls n > 3.
Das Mastertheorem gibt uns dann T̃ (n) = O(nlog2 3 ), und damit auch T (n) = T̃ (n − 2) = O((n −
2)log2 3 ) = O(nlog2 3 ).
Laufzeittests: (Zahlen siehe Course Notes vom SS 00) In der Tat verdreifacht sich T clever−mult
etwa, wenn sich die Eingabegröße verdoppelt. Allerdings ist die Laufzeit erst ab sehr großen n
tatsächlich besser als die von Tschool−mult .
Moral: Ein größenordnungsmäßig schnellerer Algorithmus gewinnt irgendwann (d.h. für genügend
große n) immer.
1.9
Kombination von rekursiver und Schulmethode
Frage: Unterhalb welcher Größe n0 sollte man mit der Rekursion aufhören (d.h. nicht weiter
aufspalten), sondern das Produkt direkt mit der Schulmethode ausrechnen? (Oben haben wir
einfach n0 = 3 genommen.)
Theoretische Antwort: Sei T (n, n0 ) die Anzahl der primitiven Operationen für Eingabelänge n
und Rekursionsstop bei n0 , d.h. kleinster Probleminstanzgröße n0 . Der Einfachheit halber nehmen
wir an, dass n eine Zweierpotenz ist, und dass ein Rekursionsschritt die Problemgröße genau
halbiert (anstatt halbiert plus eins). Dann kommen für n0 gerade die log2 n Werte 1, 2, 4, . . . , n in
Frage.
Sei Trec−step (n0 ) = Crec · n0 die Anzahl der primitiven Operationen, die benötigt werden, um ein
Problem der Größe n0 in 3 Probleme der Größe n0 /2 aufzuteilen, und sei Tschool−mult (n0 ) = Csch ·n20
die Anzahl der primitiven Operationen, die school_mult für zwei n0 -stellige Zahlen benötigt.
Dann ist für alle n0 ∈ {2, 4, 8, . . . , n}, mit i = log2 (n/n0 ) (d.h. angefangen mit einem Problem
der Größe n hat man nach i Rekursionsstufen 3i Teilprobleme der Größe n/2i = n0 )
T (n, n0 /2) − T (n, n0 ) = 3i · Trec−step (n0 ) + 3i+1 · Tschool−mult (n0 /2) − 3i · Tschool−mult (n0 )
= 3i · Crec · n0 + 3 · Csch · (n0 /2)2 − Csch · n20
= 3i · Crec · n0 − Csch /4 · n20 ,
und daher
T (n, n0 /2) < T (n, n0 ) ⇐⇒ n0 > 4 · Crec /Csch ,
das heißt, wir sollten genau solange mit der Rekursion fortfahren, wie die Anzahl der Stellen
größer als 4 · Crec /Csch ist, und sonst aufhören.
Experimentelle Antwort: Da n0 nicht von n abhängt, können wir auch versuchen, es experimentell zu bestimmen, indem wir für festes n einfach verschiedene Werte von n0 ausprobieren,
und den nehmen, der die beste Laufzeit liefert.
Laufzeittests: (Zahlen siehe Course Notes vom SS 00) Für eine gute Wahl von n0 ist der kombinierte Algorithmus immer der schnellste (von allen, die wir bisher gesehen haben).
1.10
Noch schnellere Multiplikation
Satz: (Schönhage und Strassen, 1971) Zwei n-stellige Zahlen lassen sich mit O(n log n · log log n)
primitiven Operationen multiplizieren.
Bemerkung: Der konstante Faktor, der hier in dem O versteckt ist, ist aber so riesig, dass der
Algorithmus für alle denkbaren Werte von n sogar langsamer ist als school_mult.
Moral: Der asymptotisch bessere Algorithmus gewinnt irgendwann (d.h. für genügend große n)
immer, aber irgendwann kann ziemlich spät sein.
1.11
Ein Checker für die Multiplikation langer Zahlen
Problem: Selbst bei so relativ kleinen Programmen wie school_mult, recursive_mult, oder
clever_mult, ist die Wahrscheinlichkeit sehr groß, dass wir beim konkreten Programmieren einen
Fehler machen.
Lösungsansatz 1: Wir testen unser Programm anhand von ein paar Beispielen. Das ist besser
als nix, aber wir können ja nicht alle Möglichkeiten unter allen Umständen testen (manche Fehler
treten ja nur manchmal auf).
Lösungsansatz 2: Wir beweisen (verifizieren) formal, dass unser Programm genau das tut, was
es tun soll. Das ist aber schon für kleinste Programme extrem aufwendig.
Lösungsansatz 3: Wir fügen zu unserem Programm einen Checker hinzu, der testet, ob die
berechnete Lösung tatsächlich korrekt ist. (Das ist oft einfacher, als die Lösung zu berechnen,
z.B. Faktorisieren).
Idee: Verallgemeinerte Neunerprobe. Teste modulo einer geeigneten Zahl m, ob a·b ≡ c(mod m),
oder äquivalent dazu, ob
((a mod m) · (b mod m)) mod m = c mod m,
was genau dann der Fall ist, wenn m die Differenz c − a · b teilt.
Beobachtung 1: Der Test ist niemals falsch-negativ“ (= meldet einen Fehler wo keiner war),
”
weil a · b = c ⇒ a · b ≡ c (mod m) für alle m ∈ N.
Beobachtung 2: Der Test ist falsch-positiv“ (= entdeckt einen Fehler nicht) genau dann, wenn
”
D := c − a · b 6= 0 und m teilt D.
Also sollte m
1. wenige Stellen haben, damit mod m leicht zu berechnen ist.
2. kein Teiler von c − a · b sein, falls c 6= a · b.
Wir kennen ja aber c − a · b nicht!
Idee: Wir raten einfach ein kleines m und hoffen, dass es kein Teiler von c − a · b ist, falls c 6= a · b.
bool check_mult(integer& a, integer& b, integer& c)
{
if (c.digits() > a.digits() + b.digits()) {
return false;
}
int n = max( a.digits(), b.digits() );
int k = 2 + round( log(2*n)/log(B) );
// Teste modulo einer k-stelligen Zahl
do {
integer m = random(k);
// wähle eine zufällige k-stellige Zahl
} while !is_prime(m);
// bis eine Primzahl gefunden ist
return ( ((a % m)*(b % m)) % m == c % m );
}
Lemma: Seien Zahlen zu einer Basis B ≥ 14 dargestellt, und sei D eine höchstens 2n-stellige
Zahl (d.h. 0 ≤ D < B 2n ) und k = 2 + dlogB (2n)e. Dann sind nicht mehr als ein B-tel aller
höchstens k-stelligen Primzahlen Teiler von D.
Beweis: Laut Primzahlsatz ist die Anzahl aller Primzahlen mit bis zu k Stellen, d.h. zwischen 1
und B k , mindestens B k / ln(B k ). Wären jetzt mehr als ein B-tel davon, das heißt mehr als B k /(k ·
B · ln B), Teiler von D, müsste D größer als ihr Produkt sein. Das Produkt von l verschiedenen
Zahlen ist aber größer als l! ≥ (l/e)l , was für l = B k /(k · B · ln B) mindestens
Bk
e · B · ln B · k
B k /(k·B·ln B)
ist. Nun gilt aber für B ≥ 14, dass e · ln B ≤ B und daher e · B · ln B · k ≤ kB 2 = B 2+logB k , also
wäre
k k−logB k−2
D > B B · k·B·ln B ,
und weil für B ≥ 14 und k ≥ 3, k − logB k − 2 ≥ k · ln B/B (3. Übungsblatt, Aufgabe 3),
D > BB
k /B 2
= BB
k−2
≥ BB
log B (2n)
≥ B 2n ,
im Widerspruch zur Annahme, dass D höchstens 2n Stellen hat. Also war unsere Annahme falsch,
dass mehr als ein B-tel der bis zu k-stelligen Primzahlen Teiler von D sind.
Satz: Die Wahrscheinlichkeit, dass check_mult einen Fehler nicht entdeckt, ist kleiner als 1/B.
Beispiel: Für B = 232 (Anzahl der Zahlen die man typischerweise mit einem int darstellen
kann), ist 1/B ≈ 1 : 5 000 000 000, und selbst für n ∼ 109 , d.h. Zahlen mit einer Milliarde Stellen
(die also ein paar Gigabyte benötigen, alleine um sie hinzuschreiben), reicht k = 3.
Lemma: Die Wahrscheinlichkeit, dass die do-while Schleife öfter als k · ln2 B Mal ausgeführt
wird, ist ≤ 1/B.
Beweis: Nach Primzahlsatz gibt es mindestens
B k / ln(B k ) Primzahlen zwischen 1 und B k . Die
Wahrscheinlichkeit, dass von k · ln2 B zufällig aus diesem Intervall gewählten Zahlen keine prim
ist, ist also höchstens
k·ln2 B
1
≤ e− ln B = 1/B
1−
k · ln B
(beachte, dass 1 − x ≤ e−x für alle x).
Lemma: Es gibt eine Implementierung von is_prime, die für n-stellige Zahlen mit O(B n ) primitiven Operationen auskommt.
Satz: Mit Wahrscheinlichkeit mindestens 1 − 1/B benötigt check_mult für zwei n-stellige Faktoren O(n log n) primitive Operationen.
Beweis: Aus den beiden Lemmata folgt, dass wir mit Wahrscheinlichkeit mindestens 1 − 1/B
eine k-stellige Primzahl mit O(n · k) = O(n · log n) primitiven Operationen finden können. Die
vier Modulo-Operationen können mit dem Schuldivisionsalgorithmus (1.Übungsblatt, Aufgabe 5)
ebenfalls mit O(n · k) = O(n · log n) primitiven Operationen ausgeführt werden.
Beobachtung: Der Beweis ist relativ kompliziert, der Algorithmus überhaupt nicht.
Moral: Selbst abgefahrene Mathematik kann echt nützlich sein.
1.12
Das RAM-Maschinenmodell
Siehe den Abschnitt A Machine Model“ aus dem Kapitel Foundations“ aus den Course Notes
”
”
vom letzten Sommersemester (http://www.mpi-sb.mpg.de/~mehlhorn/ftp/Info5/Foundations.
ps).
Message: Wir können ruhig C++-Code oder auch Pseudo-Code schreiben, solange klar ist, wie
sich die benutzten Konstrukte in die Maschinensprache übersetzen lassen.
Herunterladen