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.


Inhaltsverzeichnis

Kapitel 1
Einleitung, Motivation, Übersicht

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

2.1 Das Problem

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
Image /home0/guests/mansky/SemArbeit//vec.png

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 $ \in$ 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 erster Lösungsversuch

Ein naiver Algorithmus iteriert über alle Integerpaare i und j, wobei gilt 0$ \le$i$ \le$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.


\begin{lstlisting}[caption=Ein naiver Lösungsansatz,frame=lines, label=grenander...
... maxsofar = Max (maxsofar, sum);
}
}
return maxsofar;
}
\end{lstlisting}


$ \textsuperscript$

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.

2.3 Eine Lösung mit quadratischer Laufzeit

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).


\begin{lstlisting}[caption=Eine erhebliche Verbesserung, frame=lines, label=gren...
... maxsofar = Max (maxsofar, sum);
}
}
return maxsofar;
}
\end{lstlisting}


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.

2.4 Die lineare Lösung

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.


\begin{lstlisting}[caption=Eine erhebliche Verbesserung, frame=lines, label=gren...
...0);
Max (maxsofar, maxendinghere);
}
return maxsofar;
}
\end{lstlisting}

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

3.1 Aufgabenbeschreibung

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.

3.2 Brute Force

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].


\begin{lstlisting}[caption=Ein Brute Force Algorithmus, frame=lines, label=patte...
...n[j])) { j++; }
if (j>=m) pos=i;
i++;
}
return pos;
}
\end{lstlisting}

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$ \ge$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$ \ge$n, liegt keine Übereinstimmung vor und -1 wird ausgegeben. Der Algorithmus hat, allerdings nur im schlechtesten Fall, eine Zeitkomplexität von O(n2) .

3.3 Der Boyer-Moore Algorithmus

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
Image /home0/guests/mansky/SemArbeit//boym.png

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

4.1 Die Aufgabenstellung

Unter einem Binom (oder binomischen Ausdruck) [Weis99] versteht man einen Term der Form

(a + b)n        n $\displaystyle \in$ R

(4.1)

mit beliebigen Rechengrößen a und b (meist a, b $ \in$ 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

an - kbk

(4.3)

ü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.

=\begin{displaymath}\begin{cases}
{n-1 \choose k-1}+{n-1 \choose k} & 0<k<n, \\ 
 1 & \text{k=0 oder k=n}.
 \end{cases}\end{displaymath} (4.5)

= $\displaystyle {\frac{n!}{k!(n-k)!}}$        (0$\displaystyle \le$k$\displaystyle \le$n) (4.6)

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:

\begin{lstlisting}
1
1 1
1 2 1
1 3 3 1
1 4 6 4 1
\end{lstlisting}

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.

4.2 Ein rekursiver Algorithmus

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($ {\frac{2^n}{n}}$). 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.


\begin{lstlisting}[caption=Ein rekursiver Algorithmus, frame=lines, label=bcSimp...
... return SimpleBinCoeff (n-1,k-1) + SimpleBinCoeff (n-1, k);
}
\end{lstlisting}

4.3 Ein iterativer Algorithmus, basierend auf der Rekursion

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.


\begin{lstlisting}[caption=Ein Algorithmus basierend auf dem Pascal´schen Dreiec...
...bc [i,j] = bc[i-1,j-1]+bc[i-1,j];
}
}
return bc[n,k];
}
\end{lstlisting}

4.4 Ein linearer iterativer Algorithmus

Eine einfachere und schnellere Variante ergibt sich, wenn die Lösung nach 4.6 herangezogen wird. Die Zeitkomplexität beträgt immerhin O(n).


\begin{lstlisting}[caption=Die Fakultät, frame=lines]
//Invariante: bei long=2^...
... for (int i=1;i<=n;i++) { faculty *= i; }
return faculty;
}
\end{lstlisting}

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.

\begin{lstlisting}[caption=Ein linearer schneller Algorithmus, frame=lines]
pub...
...Coeff (long n, long k) {
return Fak(n)/(Fak(k)*Fak(n-k));
}
\end{lstlisting}

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.

Bibliography

[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