II.2. Entwicklung von Algorithmen

1
II.2. Entwicklung von Algorithmen
Wiederholung:
Ein Algorithmus ist ein in endlicher Weise beschreibbares determiniertes
Verfahren zur Lösung einer Klasse von Problemen. Ein Algorithmus transformiert
Eingabegrößen in endlich vielen Schritten in Ausgabegrößen.
Charakteristisch sind die Eigenschaften:
• Allgemeinheit: Das Verfahren ist geeignet, eine ganze Klasse von Problemen zu lösen
• Determiniertheit: Zu jedem Zeitpunkt steht fest, welcher Teilschritt als nächster ausgeführt
wird
Damit ein Algorithmus von einem Computer ausgeführt werden kann, muß er so detailliert
dargestellt werden, daß jeder Einzelschritt ausführbar ist.
Bei komplexen Algorithmen ist das schwierig und erfordert ein methodisches Vorgehen:
2.1. Schrittweise Verfeinerung
Prinzip: Zerlege die Aufgabe in Teilaufgaben und entwickle Algorithmen zur Lösung der
Teilaufgaben!
Mit den Teilaufgaben verfahre genauso usw. bis schließlich die Teilaufgaben leicht
zu lösen sind! (Teile und herrsche!)
Beispiel 1: Für einen Roboter ist ein Algorithmus zu entwickeln, der beschreibt, wie eine
Tasse Instant-Kaffee zubereitet wird.
1. Version:
(1)
(2)
(3)
Koche Wasser
Gib Kaffepulver in die Tasse
Fülle Wasser in die Tasse
Die Schritte seien nicht detailliert genug, um vom Roboter verstanden zu werden. Daher
Verfeinerung, indem die einzelnen Schritte verfeinert werden:
2. Version:
(1.1)
(1.2)
(1.3)
(1.4)
(2.1)
(2.2)
(2.3)
(2.4)
(3.1)
Fülle Kessel
Schalte Kessel an
Warte, bis es kocht
Schalte Kessel aus
Öffne Kaffeglas
Entnimm einen Löffel Kaffee
Kippe Löffel in die Tasse
Schließe Kaffeglas
Gieße Wasser aus dem Kessel in die Tasse, bis die Tasse voll ist
Wenn jetzt alle Schritte vom Roboter ausgeführt werden können, so fertig. Falls nicht, so für
gewisse Teilschritte weitere Verfeinerung, z.B. wenn etwa für (1.1) weitere Verfeinerung
nötig:
2
3. Version:
(1.1.1) Stelle Kessel unter Wasserhahn
(1.1.2) Drehe Wasserhahn auf
(1.1.3) Warte, bis Kessel voll ist
(1.1.4) Drehe Wasserhahn zu
(1.2) Schalte Kessel an
(1.3) Warte, bis es kocht
(1.4) Schalte Kessel aus
(2.1) Öffne Kaffeglas
(2.2) Entnimm einen Löffel Kaffee
(2.3) Kippe Löffel in die Tasse
(2.4) Schließe Kaffeglas
(3.1) Gieße Wasser aus dem Kessel in die Tasse, bis die Tasse voll ist
2.2. Sequenz
Im Roboterbeispiel: Die Teilschritte werden sequentiell ausgeführt: Von oben nach unten
jeder Schritt genau einmal; nach dem letzten Teilschritt ist der Algorithmus beendet.
Dies heißt Sequenz.
Beispiel 2: Es soll der Flächeninhalt eines Dreiecks berechnet werden, dessen 3 Seiten
gegeben sind (Seiten a, b, c)
1.Version:
(1) Gib Seiten a, b, c ein
(2) Berechne Flächeninhalt
(3) Gib Flächeninhalt aus
Hier wäre noch unklar, wie der Flächeninhalt zu berechnen wäre; wir nehmen die Heronische
Formel:
1
F = s(s − a)(s − b)( s − c) , s = ( a + b + c)
2
2. Version: (1) Gib Seiten a, b, c ein
(2.1) Bestimme den halben Umfang s (Schreibweise: s := 1/2(a+b+c) )
(2.2) Berechne rad als s(s-a)(s-b)(s-c) (rad := s(s-a)(s-b) (s-c) )
(2.3) Berechne Fl.inhalt F als Wurzel aus rad (F := rad )
(3) Gib Flächeninhalt F aus
Diese Version kann als endgültig angesehen werden, weil man die entsprechenden
Anweisungen in einer Programmiersprache aufschreiben kann! (man kann z.B. die
Quadratwurzel ziehen) Anderenfalls müßte man weiter verfeinern.
Algorithmen werden üblicherweise dargestellt mittels Pseudocode oder Struktogrammen.
Für das Strukturmuster Sequenz wäre die obige Darstellung Pseudocode. Als Struktogramm
hätte man analog:
Eingabe : a, b, c
s := 1/2(a+b+c )
rad := s(s-a)(s-b)(s-c)
F := rad
Ausgabe: F
3
Die einzelnen Blöcke werden also von oben nach unten durchlaufen.
Häufig lassen sich Algorithmen nicht als eine Sequenz von Einzelschritten darstellen; z.B.
wenn für den Roboter das Kaffeeglas leer ist!
Es ist eine Auswahl zu treffen; wobei von einem Test abhängt, welche!
2.3. Alternative (Auswahl, Selektion)
Die allgemeine Form ist (Syntax):
Pseudocode
Falls Bedingung
dann Block 1
sonst Block 2
Fortsetzung
Struktogramm
Bedingung
ja
nein
Block 1
mit der Bedeutung (Semantik):
Block 2
Fortsetzung
Falls Bedingung erfüllt ist, so wird Block1 ausgeführt; anderenfalls Block 2; dann in
jedem Falle Fortsetzung (wobei diese leer sein kann)
Beispiel:
Falls Ampel rot oder gelb
dann stoppe
sonst fahre weiter
und tiefer geschachtelt:
Falls keine Ampel
dann fahre vorsichtig weiter
sonst falls Ampel rot oder gelb
dann stoppe
sonst fahre weiter
Beispiel: Bestimmung des Maximums von 3 Zahlen x, y und z
1. Version:
Falls x > y
dann wähle zwischen x und z
sonst wähle zwischen y und z
2. Version: (Verfeinerung)
Falls x > y
dann falls x > z
dann wähle x
sonst wähle z
sonst falls y > z
dann wähle y
sonst wähle z
Es gibt auch die spezielle Form:
4
Falls Bedingung
dann Block
Falls die Bedingung erfüllt ist, so wird Block ausgeführt; anderenfalls wird nichts getan
(leerer Nein-Zweig).
Es sei noch das Beispiel Dreiecksberechnung erweitert: Wenn beim obigen Algorithmus für
die Seitenlängen 1, 2 und 4 eingegeben wird, so ergibt sich ein Fehler, denn in einem Dreieck
ist die Summe zweier Seiten mindestens so groß wie die dritte; etwa:
a+b ≥ c ⇔ a+b+c ≥ 2c ⇔ 1/2(a+b+c) ≥ c ⇔ s-c ≥ 0
Unter der Voraussetzung, daß a, b, c ≥ 0 sind, kann man das also testen, indem man testet, ob
rad ≥ 0 ist:
Eingabe: a, b, c
s := 1/2(a+b+c)
rad := s(s-a)(s-b)(s-c)
Falls rad < 0
dann Ausgabe Text: “Kein Dreieck“
sonst F := rad
Ausgabe: F
Manchmal kommt es vor, daß anhand des Wertes eines Ausdruckes eine mehrfache
Fallunterscheidung vorgenommen werden soll: Je nachdem, welchen Wert der Ausdruck hat,
soll ein dazugehöriger Block ausgeführt werden:
Pseudocode
Unterscheide Ausdruck
A1: Block 1
A2: Block 2
.
.
.
An/sonst: Block n
Struktogramm
Ausdruck
A1
A2
...
An/sonst
Block 1
Block 2
...
Block n
Bedeutung: Wert von Ausdruck wird bestimmt. Wenn der Wert = A1 ist, wird Block 1 ausgeführt, ..., wenn Wert = An ist, wird Block n ausgeführt. Wenn statt An sonst steht,
wird Block n ausgeführt, falls Wert ≠ A1, ..., An-1
Als Beispiel nehmen wir die Dreiecksberechnung und unterscheiden noch den Fall, dass das
Dreieck entartet ist (nur aus einem Geradenstück besteht). Als Testausdruck ist das Signum
von rad geeignet, das mit einer Funktion sign berechnet werden möge:
5
Eingabe: a, b, c
s := 1/2(a+b+c)
rad := s(s-a)(s-b)(s-c)
sig := sign(rad)
Unterscheide sig
1: F := rad
Ausgabe: F
-1: Ausgabe Text: “Kein Dreieck“
0: Ausgabe Text: “Dreieck entartet“
2.4. Iteration (Wiederholung)
Beispiel:
Es soll eine ganze Zahl n ≥ 0 eingegeben werden. Dann sollen sukzessive n
Zahlen eingegeben und aufsummiert werden; ihre Summe ist auszugeben.
Vorbetrachtung:
Addieren einer weiteren Zahl bedeutet das Hinzuaddieren der Zahl zur
„alten“ Summe, so erhält man die neue Summe!
Im Rechner: Speicherplatz für summe; dann „Anweisung“
summe := summe + zahl
üblicherweise für: Addiere zahl zu summe
Damit es richtig wird: Zu Anfang muß summe den Wert 0 haben! Dies
heißt Initialisierung!
Lösungsversuch:
Eingabe: n
summe := 0 {Setze summe auf 0}
i := 1
{Setze Zählvariable i auf 1}
Falls i ≤ n
dann Eingabe: zahl
summe := summe + zahl
i := i + 1
Falls i ≤ n
dann Eingabe: zahl
summe := summe + zahl
i := i + 1
.
.
.
Ausgabe: summe
Wir können das nicht aufschreiben, weil wir nicht wissen, wie groß n ist! (n kann variieren)
neues Darstellungselement: abweisende Schleife
6
Pseudocode
Struktogramm
Solange Bedingung führe aus
Block
Bedingung
Block
mit der Semantik: Block (Schleifenkörper) wird solange ausgeführt wie Bedingung erfüllt ist.
wobei vor jedem Schleifendurchlauf Bedingung getestet wird.
Für unser Beispiel:
Eingabe: n
summe := 0
i := 1
Solange i ≤ n führe aus
Eingabe: zahl
summe := summe + zahl
i := i + 1
Ausgabe: summe
Eingabe: n
summe := 0
i := 1
i≤n
Eingabe: zahl
summe := summe + zahl
i := i + 1
Ausgabe: summe
„Trockentest“ für Bsp.: n = 3 und Zahlsequenz 1,2,3
n
3
summe
0
1
3
6
i
1
2
3
zahl
1
2
3
Es wird die richtige Summe ausgegeben!
Bei diesem Beispiel wird die Schleife n-mal durchlaufen wobei diese Anzahl schon vor
Durchlaufen des Schleifenkörpers feststeht. Das nennt man Zählschleife und schreibt auch:
Für i := 1(1)n führe aus
Block
i := 1(1)n
Block
mit der Bedeutung: Für i = 1 mit der Schrittweite 1 bis i = n führe Block aus.
Beispiel für Schleife, die keine Zählschleife:
Es soll die Summe nichtnegativer Zahlen berechnet werden. Die Anzahl der Zahlen ist
nicht bekannt; das Ende der Zahlen wird durch Eingabe einer Zahl < 0 gekennzeichnet.
summe := 0
Eingabe: zahl
Solange zahl ≥ 0 führe aus
summe := summe + zahl
Eingabe(zahl)
Ausgabe: summe
7
Es gibt noch die Möglichkeit einer nichtabweisenden Schleife;
Pseudocode
Wiederhole
Block
bis Bedingung
Struktogramm
Block
Bedingung
mit offensichtlicher Semantik. (Test nach Schleifendurchlauf)
Für obiges Beispiel:
summe := 0
Wiederhole
Eingabe: zahl
summe := summe + zahl
bis zahl < 0
summe := summe - zahl
Ausgabe: summe
Bei der nichtabweisenden Schleife (repeat-Schleife) wird der Schleifenkörper immer
mindestens einmal durchlaufen! Gerade wenn das so ist, kann man diesen Schleifentyp
benutzen.
2.5. Beispiel
Insgesamt erhält man nun die Darstellung eines Algorithmus durch schrittweise Verfeinerung,
indem man die angegebenen Grundelemente benutzt.
Beispiel: Algorithmus zur Bestimmung (Ausgabe) der Liste aller Primzahlen, die ≤ einer
vorgegebenen Zahl n sind (n ist einzugeben)
Eingabe: n
Falls n ≥ 2
dann Ausgabe: „2“
p := 3
Solange p ≤ n führe aus
Teste, ob p prim
Falls p prim
dann Ausgabe: p
p := p + 2
Es ist noch der Block: Teste, ob p prim zu verfeinern
8
Eingabe(n)
Falls n ≥ 2
dann Ausgabe(„2“)
p := 3
Solange p ≤ n führe aus
pot-teiler := 1
Wiederhole
pot-teiler := pot-teiler + 2
bis pot-teiler  p oder (pot-teiler)2 > p
Falls (pot-teiler)2 > p
dann Ausgabe(p)
p := p + 2
2.6. Modularität
Bei schrittweiser Verfeinerung ergeben sich oft Komponenten, die sehr unabhängig vom
Gesamtalgorithmus sind - in dem Sinne unabhängig, daß sie entworfen werden können, ohne
das genaue Umfeld zu kennen. Solche Teilalgorithmen können insbesondere von einer
anderen Person entwickelt werden und sie können von anderen Algorithmen immer wieder
benutzt werden. Große Programme werden immer durch Zusammenfügen solcher Moduln
erstellt.
Darstellung durch:
Modul Modulname(Formalparameter)
{Beschreibung der Wirkung des Moduls}
Modulkörper
Aufruf (Benutzung) eines Moduls erfolgt durch:
Modulname(Aktualparameter)
Beispiel : Modul, der zu einem gegebenen Vektor a und einer Zahl n (der Vektor besteht aus
den Komponenten a1, a2, ... an) den Index des Maximums der ai zurückgibt.
Modul indexmax(a, n, indexmaximum)
{ermittelt zu den Komponenten a1, ... ,an des Vektors a den Index des
Maximums der ai und zwar genauer den kleinsten Index und gibt diesen
im Parameter indexmaximum zurück}
j := 1
Für i := 2(1)n führe aus
Falls ai > aj
dann j := i
indexmaximum := j
Weiterer Modul zum Tausch der Werte von 2 Variablen:
9
Modul Tausch(x, y)
{tauscht die Werte der Variablen x und y}
hilf := x {Benutzung der Hilfsvariablen hilf}
x := y
y := hilf
Mit diesen Moduln schreiben wir einen Sortieralgorithmus, d.h. einen Algorithmus, der die
Komponenten a1, ... , an des Vektors a aufsteigend sortiert:
1. Version:
Für i := n(-1)2 führe aus
Tausche das Maximum von a1,...,ai mit ai
2. Version:
Für i := n(-1)2 führe aus
indexmax(a, i, j)
Tausch(ai, aj)
Bedeutung des Modularitätsprinzips
• Natürliches Einfügen in Top-Down-Methode
• getrennter Entwurf von Moduln u. rufendem Programm möglich (auch durch verschiedene
Personen)
• Zur Benutzung des Moduls nur nötig zu wissen, was der Modul macht, nicht wie es
gemacht wird
• Modularisierte Algorithmen (Programme) sind verständlicher
• Wiederholte Benutzung: Modulbibliotheken
2.7. Rekursion
Es gibt Probleme mit gewissen Eingabedaten, für die sich ein merkwürdiges Lösungsprinzip
anbietet: Man kann das Problem lösen, indem man dasselbe Problem für „einfachere“
Eingabedaten löst usw. bis das Restproblem „trivial“ lösbar ist.
Ein solches Verfahren (Algorithmus) heißt rekursiv.
Bsp.: Berechnung der Fakultät von n: n! = 1 ⋅ 2 ⋅ ... ⋅ n
Es ist nämlich gerade
n! = 1, wenn n = 0 ist
n! = n ⋅ (n-1)! sonst
Man kann also folgenden Modul schreiben:
10
Modul Fakultät(n)
{Berechnet n! für beliebiges n ≥ 0}
Falls n = 0
dann Ergebnis := 1
sonst Ergebnis := n ⋅ Fakultät(n-1)
Dabei ist von besonderer Wichtigkeit der „triviale“ Fall - die Abbruchbedingung. Wenn
diese fehlt oder nicht erreicht wird, terminiert der Algorithmus nicht! Dies wäre zum Beispiel
der Fall beim Aufruf : Fakultät(-1).
Die Berechnung von Fakultät(3) erfolgt so:
Fakultät(3)
=
3 ⋅ Fakultät(2) = 6
Fakultät(2)
=
2 ⋅ Fakultät(1) = 2
Fakultät(1)
=
1 ⋅ Fakultät(0) = 1
Fakultät(0)
=
1
Für dieses Beispiel ist allerdings auch eine iterative Variante offensichtlich. Obwohl es
prinzipiell immer möglich ist, ein rekursives Programm in ein äquivalentes iteratives zu
verwandeln, kann das aber sehr schwierig sein und zudem sind solche iterativen Programme
schwer zu verstehen.
Ein Beispiel für eine solche Aufgabe sind die „Türme von Hanoi“:
Startplatz
Zielplatz
Hilfsplatz
Ein Turm von (im allg. Fall n) Steinen soll vom Startplatz auf den Zielplatz gebracht werden,
wobei ein Stein nur einzeln bewegt werden darf und niemals ein größerer Stein auf einem
kleineren liegen darf. Nur der eine Hilfsplatz darf benutzt werden.
Rekursive Lösung:
1. Schaffe den Turm aus n-1 Steinen vom Startplatz zum Hilfsplatz (dafür ist der
ursprüngliche Zielplatz der aktuelle Hilfsplatz)
2. Lege den größten Stein vom Startplatz auf den Zielplatz
3. Schaffe den Turm aus n-1 Steinen vom Hilfsplatz zum Zielplatz (dafür ist der
ursprüngliche Startplatz der neue Hilfsplatz)
Der „triviale“ Fall ist der, daß nur ein Stein vorhanden ist; dann muß nur der Stein vom
Startplatz zum Zielplatz gebracht werden.
Formal erhalten wir:
11
Modul Hanoi(n, Start, Ziel, Hilf)
Falls n > 1
dann Hanoi(n-1, Start, Hilf, Ziel)
Stein von Start nach Ziel
Hanoi(n-1, Hilf, Ziel, Start)
sonst Stein von Start nach Ziel
Für n = 3 erhält man folgenden Ablauf (Aufrufbaum):
(3,S,Z,H)
(2,S,H,Z)
(1,S,Z,H)
S→H
S→Z
S→Z
(1,Z,H,S)
Z→H
(2,H,Z,S)
(1,H,S,Z)
H→S
H→Z
(1,S,Z,H)
S→Z
Also ist die Zugfolge:
S→Z
S→H
Z→H
S→Z
H→S
H→Z
S→Z
2.8. Komplexität
Entscheidend für die Güte eines Algorithmus sind häufig die Ressourcen (z.B. Rechenzeit),
die er benötigt. Man spricht von der Komplexität (z.B. Zeitkomplexität) des Algorithmus.
Wir betrachten hier nur kurz 3 Sortieralgorithmen: n reelle Zahlen a1,...an sind der Größe nach
(aufsteigend )zu ordnen. Der erste ist der aus 2.6:
Für i := n(-1)2 führe aus
indexmax(a, i, j)
tausch(ai, aj)
Wir sehen für diese Aufgabe als dominierend an die Anzahl der Vergleiche zwischen reellen
Zahlen. Beim Aufruf indexmax(a, i, j) sind das gerade i-1 Stück. Also ist die Gesamtzahl
(n-1) + (n-2) + ... + 2 + 1 = 1/2n(n-1) = 1/2n2 + O(n)
Vergleiche.
Der 2. Algorithmus sei so beschrieben:
Ang., a1,...,ai-1 seien bereits sortiert. Dann wird das i-te Element ai an die richtige
Stelle in die Folge a1,...,ai-1 eingefügt; und zwar wird ai mit aj , j = i-1, i-2,... ver-
12
glichen und wenn aj > ai ist, so wird aj um 1 Stelle nach rechts verschoben. Wenn
aj ≤ ai ist, so wird ai an Stelle j+1 geschrieben.
Hier ist im schlechtesten Falle der Aufwand auch 1/2n2 + O(n). Und im Mittel erhält man
offenbar 1/4n2 +O(n).
Dieser Algorithmus ist also besser (schneller) als der erste.
Der 3. Algorithmus ist rekursiv:
Man teile die Menge der Zahlen in 2 gleichgroße Teile, sortiere diese Teile (nach
dem gleichen Verfahren) und mische die sortierten Teile zusammen
Das Mischen (Verschmelzen) von 2 sortierten Mengen von 1/2n Zahlen erfordert offenbar
C ⋅ n Vergleiche (C eine Konstante). Also erhält man eine Rekursionsgleichung für den
gesamten Aufwand:
T(n) = 2 ⋅ T(n/2) + C ⋅ n
Die Lösung ist T(n) = C ⋅ n ⋅ log2(n) + k ⋅ n; T(1) = k. (Probe)
Dieses Mischsortieren ist also schneller!
Methode: Teile und herrsche!
Vergleich:
n
n2
100
103
104
106
104
106
108
1012
n ⋅ log2n
664
9966
132877
1,99 ⋅107
Bei 106 Op./sek: Um 106 Zahlen zu sortieren: 20 sek mit Mischsortieren und 106 sek =
10 Tage für Maximumsortieren.