5. Arithmetische Algorithmen

5.3 Multiplikation

Schriftliche Multiplikation - die Schulmethode

Wie multipliziert man zwei natürliche Zahlen? Die "schriftliche Multiplikation" haben wir alle in der Grundschule gelernt. Daher ist Ihnen vielleicht gar nicht klar: die schriftliche Multiplikation ist ein Algorithmus! Und wahrscheinlich der erste "richtige", der Ihnen im Leben überhaupt begegnet ist. Man kann sogar noch weiter gehen und unsere Dezimaldarstellung als Datenstruktur zur Darstellung von natürlichen Zahlen bezeichnen, die sich gegen die römischen Zahlen) durchgesetzt hat, unter Anderem weil sie effiziente und vergleichsweise einfache Multiplikationsalgorithmen unterstützt. Rufen wir uns diesen "Uralgorithmus" ins Gedächtnis.

Der Schulalgorithmus zur schriftlichen Multiplikation benötigt eine Subroutine (bzw. eine Helfer-Funktion), die eine beliebig lange Zahl mit einer einstelligen Zahl multipliziert. Nennen wir diese Funktion integerTimesDigit

Sobald wir dies verstanden haben, können wir beliebig lange Zahlen multiplizieren:

Strenggenommen müssen wir noch zwei Subroutinen definieren: eine zum Addieren beliebig langer Zahlen (aber das überlasse ich Ihnen), und eine zur Multiplikation zweier einstelliger Zahlen. Für letzteres gibt es keinen Trick. Das müssen Sie auswendig lernen (unter dem Namen kleines Einmaleins). Machen Sie sich klar, dass der gerade beschriebene Algorithmus unabhängig von der Basis unseres Zahlensystems ist - nur das kleine Einmaleins ist für jede Basis anders. Für Basis 2 ist der obige Algorithmus besonders einfach.

Laufzeitanalyse

Lemma 5.3.1 Der Algorithmus integerTimesDigit, der eine -stellige Zahl mit einer einstelligen multipliziert (und eine höchstens -stellige Zahl zurückgibt) hat Laufzeit .

Wie sieht es jetzt nun mit der Laufzeit der Schulmethode aus? Um eine -stellige Zahl mit einer -stelligen zu multiplizieren, müssen wir die Subroutine integerTimesDigit insgesamt -mal aufrufen. Dies hat eine Laufzeit von . Das Ergebnis ist eine Liste von Zahlen

die jeweils wieviele Stellen haben? Jede höchstens ; aber sie sind ja "verschoben"; wir haben oben einfach die Nullen am Ende weggelassen. Schreiben wir sie nun der Korrektheit halber hin:

Die längste Zahl, , kann also bis zu Stellen haben. Wir müssen nun noch Additionen ausführen. Die Zahlen bleiben dabei durchweg höchstens -stellig (überprüfen Sie es: hat 5 Stellen), was insgesamt Schritt benötigt.

Die Asymmetrie zwischen und im Ausdruck sollte uns stutzig machen. Wenn wir sehr viel größer ist als , sagen wir , dann ist dies ; würden wir die Zahlen vertauschen, dann hätten wir eine Laufzeit von , also deutlich schneller. Wir können natürlich eine "Wrapper"-Funktion schreiben, die sicherstellt, dass die links stehende Zahl die längere ist (so wie im Beispiel . Es geht aber auch anders:

Übungsaufgabe 5.3.1 Zeigen Sie, wie man die Zwischenergebnisse so aufaddieren kann, dass die gesamte Addition Schritte benötigt.

Tip: Der Trick besteht darin, auszunutzen, dass von den maximal Stellen von die letzten allesamt 0 sind.

Theorem 5.3.2 Die Schulmethode zur Multiplikation natürlicher Zahlen benötigt Schritte, um eine -stellige Zahl mit einer -stelligen zu multiplizieren.

Divide-and-Conquer

Erinnern Sie sich, was Mergesort so einfach und erfolgreich gemacht hat: Wir haben das unsortierte Array in zwei (fast) gleichgroße Teilarrays zerlegt; jedes dieser zwei rekursiv sortiert; dann die beiden sortieren Teilarrays mittels der recht einfachen Funktion merge zu einem ganzen sortieren Array zusammengefügt. Dieses Prinzip Zerlegen - rekursiv lösen - zusammenfügen nennt man in der Algorithmik Divide and Conquer. Können wir für Multiplikation einen Divide-and-Conquer-Algorithmus entwickeln? Wie zerlegt man "eine natürliche Zahl in zwei gleichgroße Hälften"? Damit meine ich nicht , denn da haben die "Hälften" ja gleich viele Stellen wie die ursprüngliche Zahl; nein, ich will eine -stellige Zahl irgendwie sinnvoll in zwei -stellige zerlegen. In unserer Dezimaldarstellung ist eine natürliche Zahl ja einfach ein Array aus Ziffern, 73036417 ist also irgendwie das Array . Das zerlegen wir jetzt in und . Arithmetisch geht folgendes vor sich:

Beobachtung 5.3.3 Sei eine -stellige Zahl. Dann können wir in linearer Zeit ein -stelliges und ein -stelliges berechnen, so dass

gilt.
Das geht in jeder Basis. Wenn Sie zum Beispiel hexadezimal rechnen, also mit den sechszehn Ziffern , dann müssen Sie nur in der Gleichung () die Zahl zehn durch die Zahl sechzehn ersetzen, die im Hexadezimalsystem passenderweise wiederum als 10 geschrieben wird.

Wie verwenden wir dies nun, um zwei natürliche Zahlen zu multiplizieren? Um unser Gehirn zu schonen und damit wir uns auf die wirklich wichtigen Dinge konzentrieren können, gehe ich im folgenden davon aus, dass beide Zahlen -stellig sind und auch noch gerade ist. Um nun also zwei -stellige Zahlen zu multiplizieren, schreiben wir

und schauen dann, was bei der Multiplikation geschieht:

An einem konkreten Beispiel:

Hier ist nun unser Pseudo-Code für den naiven Divide-and-Conquer-Algorithmus:

def naiveMult(x, y):
	# x, y are two n-bit integers, and n is even 	
	a = first(x)
	b = secondHalf(x)
	c = firstHalf(y)
	d = secondHalf(y)

	ac = naiveMult (a, c)
	ad = naiveMult (a, d)
	bc = naiveMult (b, c)
	bd = naiveMult (b, d)

	result = ac * 10**n + (ad + bc) * 10**(n/2) + cd                         
  return result

Da fehlt natürlich noch die Rekursionsbasis, beispielsweise wenn ist; da können wir nichts mehr zerlegen sondern wenden einfach das kleine Einmaleins an. Um die Laufzeit zu analysieren, beachten Sie, dass die Zeieln 3,4,5,6 und 13 jeweils Schritte benötigen. Lassen Sie sich nicht von den * und 10**n in Zeile 13 einschüchtern: hier wird nicht wirklich etwas multipliziert; mit multiplizieren heißt ja einfach, hinten Nullen anzuhängen, und das geht in Zeit.

Beobachtung 5.3.4 Es gibt eine Zahl , so dass die Zeilen 3,4,5,6 und 13 von naiveMult insgesamt höchstens Schritte benötigen.

Die Zahl hängt von der genauen Implementierung ab und davon, was wir als "Schritt" verstehen. Ein Takt auf dem Computer beispielsweise. Aber genau das versuchen wir ja mit der -Notation unter den Teppich zu kehren. Um nun aber die Laufzeit von naiveMult zu analysieren, müssen wir dieses wieder unter dem Teppich hervorholen.

Sei also die Anzahl der Schritte, die naiveMult im Worst-Case macht, wenn es zwei -stellige Zahlen als Eingabe bekommt. Wir gehen von nun an auch davon aus, dass eine Zweierpotenz ist, dass also alle Zahlenarrays sich in zwei gleich große Hälften unterteilen lassen, bis sie irgendwann einstellig sind und der Basisfall (das kleine Einmaleins) erreicht ist. Also: . Was ist ? Es gilt

Warum? Der Term ist die Anzahl der Schritte, die in den Zeilen 3, 4, 5, 6, 13 getan wird. Der Term steht für die Anzahl der Schritte, die durch die rekursiven Aufrufe in Zeilen 8, 9, 10, 11 verursacht werden. Was ist ? Im Basisfall wenden wir das kleine Einmaleins an. Wenn wir beispielsweise als Basis 256 wählen, dann können wir in dem Fall einfach zwei Bytes multiplizieren. Die Logik dafür ist auf dem Prozessorchip fest implementiert; die Multiplikation braucht also nur einen Schritt. Da aber das ganze drum herum (der Test , der Funktionsaufruf etc.) auch etwas braucht, sagen wir einfach: . Das gleiche wie oben. Wenn wir richtig wählen, dann sind wir auf der sicheren Seite. Ersetzen wir nun das in () durch ein , definieren uns also eine Funktion per

Es sollte klar sein, dass gilt. Um für eine geschlossene Form zu berechnen (und somit die Laufzeit von naiveMult zu verstehen), müssen wir den Rekursionsbaum zeichnen. Ich nehme, wie oben bereits erwähnt, an, dass eine Zweierpotenz ist.

Ein Knoten auf Ebene repräsentiert also einen rekursiven Aufruf von naiveMult mit -stelligen Zahlen. Er verursacht also Schritte. Ebene trägt also insgesamt

zum Gesamtwert bei. Wir sehen: die Ebenen werden immer "teurer", im Gegensatz zu Mergesort, wo jede Ebene ungefähr gleichviel Arbeit verursacht hat. Was ist die letzte Ebene? Erinnern wir uns: wir nehmen an, dass ist. Dann haben wir Ebenen , und Ebene trägt bei. Daher: für gilt

Beobachtung 5.3.5 Wenn ist, dann gilt .

Theorem 5.3.6 Die Anzahl der Schritte, die naiveMult braucht, um zwei -stellige Zahlen zu multiplizieren, ist .

Beweis. Sei die nächstgrößere Zweierpotenz von aus gesehen, formal also . Da die Eingabezahlen höchstens Stellen haben, braucht naiveMult höchstens

Schritte. Wir erinnern uns, dass eine feste, nur von Implementierung und Rechnerarchitektur abhängige Zahl war (also nicht von der Eingabegröße abhängt). Daher ist und somit auch .

Wir haben also einen ganz gehörigen Aufwand betrieben und am Ende doch wieder nur einen -Algorithmus bekommen, auch nicht besser als die Schulmethode.

Die Karatsuba-Multiplikation

Umsonst war der Aufwand doch nicht, weil er die Bühne bereitet für einen tatsächlich viel schnelleren Algorithmus: die Karatsuba-Multiplikation. Der fast schon magische Trick ist, die vier Multiplikationen in Zeilen 8, 9, 10, 11 durch drei Multiplikationen (und mehrere Additionen) zu ersetzen. Die zentrale Beobachtung ist

und somit

Wir berechnen nun also rekursiv die Produkte , und . Mittels () erhalten wir nun durch eine einfache (und billige) Subtraktion. Der Pseudo-Code ist hier:

def karatsuba (x, y):
	# x, y are two n-bit integers, and n is even 	
	a = first(x)
	b = secondHalf(x)
	c = firstHalf(y)
	d = secondHalf(y)

	ac = karatsuba (a, c)
	bd = karatsuba (b, d)
	a_plus_b_times_c_plus_d = karatsuba (a+b, c+d)

	ad_plus_bc = a_plus_b_times_c_plus_d - ac - bd 

	result = ac * 10**n + ad_plus_bc * 10**(n/2) + cd  ac * 10**n + (ad + bc) * 10**(n/2) + cd
	return result

Die Implementierung ist nun etwas schwieriger, weil wir zusätzlich Subtraktion als Subroutine benötigen. Aber auch dies geht in . Und klar: wenn wir eine Bibliothek für Big-Integer-Arithmetik schreiben, dann wird die sowieso eine Funktion für Subtraktion enthalten. Als Rekursionsbasis legen wir nun den Fall fest, dass die Zahlen oder weniger Stellen haben (und da wenden wir dann z.B. naiveMult an); der Basisfall verursacht höchstens Schritte, für passend gewähltes (warum Faktor 2, wird weiter unten klar). Für größere haben wir die "Netto-Arbeit" aus den Zeilen 3, 4, 5, 6, 12, 14. Da wird nur zerlegt, addiert und subtrahiert, und daher benötigen wir auch hier maximal Schritte, für passend gewähltes . Unangenehm ist jetzt jedoch, dass der rekursive Aufruf in Zeile 10 es nicht notwenigerweise mit -stelligen Zahlen zu tun hat; durch die Addition kann nämlich eine Stelle hinzukommen; es sind also maximal -stellige Zahlen. Die rekursiven Aufruf ein Zeile 8 und 9 bekommen -stellige Zahlen. Um uns die Laufzeitabschätzung zu erleichert, sind wir konservativ und sagen: alle Zahlen in den rekursiven Aufrufen sind maximal -stellig. Das stimmt dann auf jeden Fall. Wir können die Laufzeit abschätzen durch

Das geht einfach zu analysieren, wenn die spezielle Form hat. Dann gilt nämlich , und die Anzahl der Stellen in den rekursive Aufrufen hat wiederum die gleiche spezielle Form. Wir zeichnen den Rekursionsbaum:

Auf Ebene haben wir also Knoten, von denen jeder einen Aufruf mit -stelligen Zahlen darstellt. Für Ebenen ist die Rekursionsbasis noch nicht erreicht, und der Beitrag von Ebene zu ist

Für wird dieser Ausdruck zu . Da haben wir aber die Rekursionsbasis bereits erreicht, müssen also anders rechnen! Auf Ebene haben wir Knoten. Jeder davon trägt zu bei. Also stimmt der Ausdruck () auch für . Wir erhalten , indem wir über alle Ebenen summieren:

Wir brauchen nun die Formel für die geometrische Summe:

Somit gilt

Bevor wir nun eine schöne Form für angeben, können wir aus dem obigen Ausdruck schon etwas hilfreiches ablesen: wenn wir ungefähr verdoppeln, also von auf , dann verdreifacht sich die Laufzeit. Das deckt sich wunderbar mit unseren empirischen Daten über die Laufzeit der Multiplikation in Python.

Theorem 5.3.7 Die Laufzeitkomplexität der Karatsuba-Multiplikation ist .

Beweis. Sei die Anzahl der Stellen der zu multiplizierenden Zahlen. Sei die nächstgrößere Zahl, die die Form hat. Formal setzen wir und . Wie oben gesehen hat verursacht Karatsuba auf -stelligen Zahlen höchstens

Schritte. Wir schätzen nun ab:

Die Anzahl der Schritte ist also höchstens

wie behauptet.
Die Karatsuba-Multiplikation hat einen recht hohen Overhead im Vergleich zur Schulmethode (das haben rekursive Implementierungen immer). Daher kann man die Laufzeit deutlich verkürzen, indem man den Basisfall nicht bei ansetzt sondern bei oder so. Wenn man Zahlen mit weniger als 20 Stelen erreicht hat, macht man nicht mehr rekursiv weiter, sondern berechnet das Produkt mit der Schulmethode. Den genauen Wert von 20 (also wo man die Rekursion abbrechen sollte), kann man empirisch ermitteln.