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.