|
Design Fallstudien
Mansky Christian
Jänner, 2003
Abstract:
Dieser Artikel versucht anhand dreier ausgesuchter Beispiele die Schwierigkeiten
effizienten und eleganten Algorithmen-Designs zu demonstrieren. Die
Beispiele sind der gängigen Literatur entnommen, Kapitel 5
gibt noch einen Überblick über die Designtechniken, ohne Anspruch auf
Vollständigkeit zu erheben.
Für eine Person, die sich beruflich mit der Informatik, und hier im besonderen
mit der Programmierung auseinandersetzt, ist das Design guter (im Sinne
effizienter und eleganter) Algorithmen unumgänglich. Der Artikel präsentiert
drei ausgewählte Beispiele anhand dessen das Design guter Algorithmen
verdeutlicht werden soll.
Kapitel 2 zeigt ein rein akademisches
Beispiel, dass die mögliche Vorgehensweise zum Finden eines guten Algorithmus
dennoch anschaulich verdeutlicht. Kapitel 3
beschreibt zwei Algorithmen zur Suche in Zeichenketten und Kapitel 4
versucht eine Antwort auf die Frage zu geben, wie der Binomialkoeffizient
am besten implementiert wird. Zum Schluss fasst Kapitel 5
die gewonnenen Einsichten im Design von Algorithmen zusammen.
Kapitel 2
Das erste Problem und ein einfacher Algorithmus
Gegeben ist ein Eingabevektor [Bent00] x
mit n Elementen vom Typ Float. Gesucht
ist die maximale Summe, die in einem zusammenhängenden Subvektor der Eingabe
gefunden werden kann. In unserem Beispielvektor in Abbildung 2.1
befinden sich 10 Elemente.
Abbildung 2.1: Die maximale
Summe eines Subvektors
 |
Der Algorithmus soll die Summe von x[2..6],
oder 187 zurückgeben. Das Problem ist leicht lösbar, wenn alle Elemente
des Vektors positive Zahlen sind. Dann ist nämlich der maximale Subvektor,
der Eingabevektor selbst. Um die Problemdefinition abzuschließen, legen
wir noch folgendes fest. Wenn alle Zahlen des Eingabevektors negativ sind,
ist der gesuchte Subvektor der leere Vektor. Dieser besitzt die Summe
0. Das Problem erwuchs aus einem Pattern Matching Problem, welches sich
Ulf Grenander an der Brown University stellte. Ursprünglich war es in
zweidimensionaler Form. Gegeben ist also ein zweidimensionales Array
n×n mit
n R. Gesucht ist die maximale Summe die ein rechteckiges
Subarray enthält. Grenander konnte dieses Problem nicht in vernünftiger
Zeit lösen, deswegen abstrahierte er das Problem um eine Dimension, um
Einblick in die Struktur des Problems zu bekommen. Experimentierfreudige
dürfen sich am zweidimensionalen Problem versuchen, es ist nach wie vor
nicht gelöst.
Ein naiver Algorithmus iteriert über alle Integerpaare i und j, wobei
gilt
0 i j < n, wobei n die Länge des Eingabevektors
x ist. Für jedes Paar von i und j wird
zusätzlich noch die Summe von x[i..j]
berechnet, und es wird überprüft ob die soeben errechnete Summe größer
ist, als die bisher berechnete. Listing 2.2
implementiert genau dieses Verhalten. Der Algorithmus ist leicht verständlich
und kurz. Leider ist er auch ziemlich langsam. Eine Laufzeitmessung auf
einem Pentium 4 (1.7 GHz, 256MB RAM) ergab bei einer Eingabegröße von
n = 10.000 immerhin eine Laufzeit von
14,5 Minuten, bei n = 100.000 immerhin
schon ca. 10 Tage. Listing 2.1 realisiert die Funktion
Max, die in diesem Kapitel für alle Algorithmen benötigt wird.

Die äußerste, ebenso die innerste Schleife werden genau n mal ausgeführt.
Werden diese Faktoren multipliziert, lässt sich erkennen dass der Code
in der mittleren Schleife
O(n2) mal ausgeführt wird. Die innerste
for-Schleife wird nicht öfter als n mal ausgeführt, sie kostet O(n).
Beide Ergebnisse multipliziert ergeben also kubische Zeitkomplexität.
Es gibt einen ziemlich offensichtlichen Weg den Algorithmus aus Listing
2.2 schneller zu machen. Das verbesserte
Programm ist quadratisch, hat also eine Zeitkomplexität von
O(n2).
Listing 2.3 berechnet die Summe sehr schnell,
indem es berücksichtigt, dass die Summe von x[i..j]
ziemlich eng mit der Summe von x[i..j
- 1] "`verwandt"' ist. Die Anweisungen innerhalb der ersten (also
äußersten) Schleife werden n mal ausgeführt. Die innerhalb der zweiten
(also innersten) Schleife werden ebenfalls n mal ausgeführt. Dies ergibt
eine Zeitkomplexität von
O(n2). Eine zum Vergleich durchgeführte Laufzeitmessung
ergab bei einer Eingabemenge der Größe n = 10.000
eine Laufzeit von ca. 797 Millisekunden, bei n
= 100.000 immerhin schon eine Laufzeit von 1.3 Minuten.
Listing 2.4 zeigt den einfachsten Algorithmus.
In weniger als linearer Laufzeit kann das Problem nicht mehr gelöst werden,
da offensichtlich sämtliche Elemente des Eingabevektors in die Lösung
miteinbezogen werden müssen. Eine Messung bei
n = 1.000.000 Elementen ergab eine Laufzeit
von 32 ms.
Anstatt nun den maximalen Subvektor - der auf Position i endet - zu berechnen,
wird der ja bereits berechnete maximale Subvektor der auf Position i-1
endet (gespeichert in der Variable maxendinghere)
zur Lösung herangezogen. Die Zuweisung verändert maxendinghere
derart, dass sie nun den maximalen Subvektor der auf Position i endet
beinhaltet. Wird ihr Wert negativ, bekommt die Variable den Wert 0 zugewiesen,
da der maximale Subvektor nun der leere Vektor ist und dieser, wie anfangs
vereinbart, den Wert 0 besitzt.
Kapitel 3
Suche in Zeichenfolgen
Gegeben ist eine endliche Folge von Zeichen, gespeichert in einer Variablen
text. Gesucht ist die Anfangsposition des
gesuchten Musters - gespeichert in der Variable pattern
- im Text.
Die offensichtliche Methode [Rech99] für das Pattern Matching an die
man sofort denkt, besteht darin für jede mögliche Position im Text zu
überprüfen ob das Muster tatsächlich passt. Die Funktion in Listing 3.1
sucht auf diese Weise nach dem ersten Auftreten des Musters pattern[1..m]
in der Zeichenfolge text[1..n].
Dazu wird eine Laufvariable i verwendet,
die in den Text zeigt, sowie eine Laufvariable j,
welche in das Pattern verweist. Solange i
und j auf übereinstimmende Zeichen treffen,
werden sie inkrementiert. Wurde das Ende des Patterns erreicht - also
j m - wurde das Pattern gefunden. Wird keine Übereinstimmung
der Zeichen gefunden, wird j an den Anfang
des Patterns zurückgesetzt. Die Variable i
wird so gesetzt, dass es einer Verschiebung des Patterns um eine Position
nach rechts entspricht. Wird das Ende des Textes erreicht, also i n, liegt keine Übereinstimmung vor und -1 wird ausgegeben.
Der Algorithmus hat, allerdings nur im schlechtesten Fall, eine Zeitkomplexität
von
O(n2) .
Abbildung 3.1 zeigt die Suche
eines Patterns [Sedge92] in einem Text. Für die Suche wird
ein eindimensionales Array benötigt, in Listing 3.2
heißt dieses Array shift. Darin wird für
jeden Buchstaben des Alphabets die Anzahl der Stellen gespeichert, um
die das Pattern nach rechts verschoben werden kann. Alle Buchstaben, bis
auf diejenigen die im Pattern vorkommen, werden im Array mit der Länge
des Patterns - der erste Buchstabe des Patterns wird mit m
- 1, der zweite Buchstabe mit m - 2
usw. - initialisiert. Das bedeutet in unserem Fall shift[s]=4,
shift[t]=3, etc.
Abbildung 3.1: Pattern Matching
mit Boyer Moore
 |
Indem wir bei der Prüfung auf Übereinstimmung mit dem Muster von rechts
nach links vorgehen, wird der letzte Buchstabe des Musters - in diesem
Fall das g - mit dem r
verglichen. Ein Vergleich scheitert hier augenscheinlich. Für die Berechnung
der Weite des Sprunges wird der Buchstabe im Text, der für das Scheitern
verantwortlich war, herangezogen. In shift[r]
ist eine 5 gespeichert, also kann das Muster um 5 Stellen nach rechts
verschoben werden. Im nächsten Schritt kann das g
des Patterns mit dem c im Text verglichen
werden. Auch hier scheitert ein Vergleich, in shift[c]
ist wiederum eine 5 gespeichert, das Pattern kann deshalb um 5 Stellen
nach rechts verschoben werden. Auch der nachfolgende Vergleich von g
und s scheitert. In shift[s]
ist jetzt aber die Zahl 4 gespeichert. Das Pattern kann also um 4 Stellen
nach rechts verschoben werden, und der Vergleich des Textes mit dem Pattern
liefert hier ein positives Ergebnis.

Kapitel 4
Der Binomialkoeffizient
Unter einem Binom (oder binomischen Ausdruck) [Weis99] versteht man einen Term der Form
|
(a + b)n n
R
|
(4.1) |
mit beliebigen Rechengrößen a und b (meist
a, b R). Wird 4.1 in der
Form eines Produktes aus n Faktoren geschrieben
|
(a + b)(a + b)...(a
+ b)
|
(4.2) |
und darf distributiv gerechnet und kommutativ addiert werden, so lässt
sich das Produkt 4.2 durch eine Summe
von Einzelprodukten
an - kbk
bzw.
bn - kak
(k = 0, 1,..., n) darstellen.
Im Fall n = 2 geschieht dies etwa gemäß
(a + b)2 = a2 + ab
+ ba + b2.
Gilt für a und b
zusätzlich noch das Kommutativgesetz, also ab
= ba, dann bleiben n + 1
Produkte der Form
übrig, die auch mehrfach auftreten können, zum Beispiel für n
= 2
(a + b)2 = a2 + ab
+ ba + b2 = a2 + 2ab
+ b2.
Diese Vielfachen der Produkte 4.3
in der Summendarstellung des Binoms (a + b)n
heißen Binomialkoeffizienten der Ordnung n. Es gibt also für die jeweilige
Ordnung n genau n + 1 Binomialkoeffizienten. Diese werden allgemein mit
dem Symbol
|

|
(4.4) |
bezeichnet. Die Berechnung [Ped00] erfolgt Rekursiv nach 4.5
bzw. iterativ wie in 4.6 dargestellt.
|
|
= |
(4.5) |
Die rekursive Lösung 4.5
lässt sich leicht mit dem nach ihrem Entdecker benannten Pascal´schen
Dreieck zeigen. Pascal schrieb die Binoialkoeffizienten der jeweils selben
Ordnung zentriert in eine Zeile. Für
n = 0, 1, 2, 3, 4 also:
Pascal bemerkte, dass am Rand des Dreiecks immer eine 1 steht, die inneren
Zahlen des Dreiecks ergeben sich als Summe der beiden darüberliegenden
Zahlen. Die folgenden Lösungsversuche konzentrieren sich zunächst auf
die rekursive Lösung, am Schluss wird noch der iterative Algorithmus und
die dabei auftretenden Probleme behandelt.
Der durch den rekursiven Algorithmus produzierte Code in Listing 4.1
ist leicht verständlich und sehr kurz. Leider hat dieser Algorithmus die
horrende Zeitkomplexität von
O( ). Ein Grund dafür kann wohl in der Tatsache
gesehen werden, dass bereits berechnete Werte nicht abgespeichert werden.
Deshalb müssen diese bei Bedarf noch einmal berechnet werden.
Die in Listing 4.2 dargestellte Lösung bedient
sich des Pascal´schen Dreiecks wie in 4.5
angegeben. Eine Verbesserung gegenüber dem Algorithmus in Listing 4.1
ergibt sich aus der schlichten Tatsache, dass alle bereits errechneten
Werte abgespeichert werden. Die Zeitkomplexität beträgt O2),
was eine erhebliche Beschleunigung bedeutet.
Eine einfachere und schnellere Variante ergibt sich, wenn die Lösung
nach 4.6 herangezogen wird. Die Zeitkomplexität
beträgt immerhin O(n).
Vom algorithmischen Standpunkt aus betrachtet, mag diese Lösung zufriedenstellend
sein. Dennoch ergeben sich bei einer konkreten Implementierung Schwierigkeiten.
Der Typ long in C# etwa kann 263
positive Zahlen darstellen, 21! überschreitet
aber diesen Wertebereich bereits. Verwendet man den Typ ulong
hat man immerhin schon 264 darstellbare
Zahlen zur Verfügung, was die Situation aber nicht verbessert.
Ein Ausweg aus diesem Dilemma kann durch eine noch effizientere Implementierung
der in Listing 4.2 präsentierten Lösung gefunden
werden. Insofern, als ja nicht alle Werte des Pascal´schen Dreiecks berechnet
werden müssen, sondern nur der "Weg" den die Rekursion durch das Dreieck
nimmt. Auf eine Implementierung wird an dieser Stelle aber aus Platzgründen
verzichtet.
Kapitel 5
Zusammenfassung
Die hier präsentierten Algorithmen illustrieren wichtige Design Techniken
[Bent00] und stellen weder den Anspruch auf
Vollständigkeit noch den auf unbeschränkte Gültigkeit.
- Zustände speichern, um nochmalige Berechnung zu vermeiden.
-
Diese Methode wurde in den Listings 2.3,
2.4 und 4.2
verwendet. Es wird Speicherplatz geopfert, um zu vermeiden dass Ergebnisse
noch einmal berechnet werden müssen.
- Vorverarbeiten von Informationen in Datenstrukturen.
-
Im Boyer Moore Algorithmus (Listing 3.2)
wurde das Array shift angelegt, beim
Binomialkoeffizienten in Listing 4.2 konnten
bekannte Werte (etwa für k = 0 und
k = 1) sofort als Ergebnisse abgespeichert
werden.
- Rekursive Algorithmen.
-
Rekursive Algorithmen sind besser als ihr Ruf. Es gibt eine Vielzahl
sinnvoller Anwendung. Laut [Wirth00] ist Rekursion auf jeden Fall dann anzuwenden,
wenn die Problemstellung ihrem Wesen nach eher rekursiv als iterativ
ist.
- "Scanning" Algorithmen.
-
Probleme lineare Listen betreffend können oft durch eine Umformulierung
der Problemstellung in folgender Weise erzielt werden. Eine Lösung
für x[0..i-1] ist vorhanden. Wie kann
das nächste Element x[i] in die bestehende
Lösung integriert werden. Listing 2.4
ist ein Paradebeispiel für ein derartiges Vorgehen.
-
- [Wirth00]
-
Nikolaus Wirth: Algorithmen und Datenstrukturen.
B.G.Teubner Stuttgart/Leipzig/Wiesbaden, 2000
- [Sedge92]
-
Robert Sedgewick: Algorithmen. Addison
Wesley, 1997
- [Bent00]
-
Jon Bentley: Programming Pearls, 2nd edition.
Addison Wesley 2000
- [Rech99]
-
Peter Rechenberg: Algorithmen und Datenstrukturen
1. Gruppe Software, Linz 1999
- [Ped00]
-
Paul E. Dunne: Algorithm Design Paradigms.
http://www.csc.liv.ac.uk/~ped/teachadmin/algor/algor.html
- [Weis99]
-
Eric W. Weisstein: Binomial Coefficient.
Wolfram Research 1999, http://mathworld.wolfram.com/BinomialCoefficient.html
|