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.