Hochschule Regensburg Rekursion 123 Übung 12_1 Spezielle Algorithmen (SAL) Name: ________________________ Lehrbeauftragter: Prof. Sauer Vorname: _____________________ Rekursion bedeutet: Rückführung auf sich selbst. Rekursion ist eine mächtige Problemlösungstechnik. Läßt sich nämlich ein Problem P (n) der Größe n auf dasselbe Problem der Größe n-1, also P (n − 1) reduzieren, dann ist damit das Problem bereits gelöst. Vorausgesetzt ist hierbei: P (1) ist ein einfach zu lösendes Problem: P (n) → P ( n − 1)... → P (1) 4 In der Programmierung wird die rekursive Problemreduktion durch rekursive Funktionen erzielt. Das sind Funktionen, die sich selbst aufrufen, z.B. f (n) führt zu f ( n − 1) , was wiederum zu f ( n − 2) führt. Die Anzahl der geschachtelten Aufrufe wird Rekursionstiefe genannt. Rekursive Definitionen Die Funktion f : Ν → Ν wird durch 1 ⎧ ⎪ 1 ⎪ f ( n) = ⎨ n ⎪ f ( 2 ) n ≥ 2, ⎪ f (3n + 1) n ≥ 2, ⎩ n=0 n =1 n gerade n ungerade rekursiv definiert. Damit eine rekursive Funktion definiert werden kann, muß mindestens eine Alternative eine Abbruchbedingung enthalten. Funktionsdefininitionen können als Ersetzungssysteme gesehen werden. Funktionswerte lassen sich aus dieser Sicht durch wiederholtes Einsetzen berechnen, z.B. die Auswertung von f (3) : f (3) → f (10) → f (5) → f (16) → f (8) → f (4) → f (2) → f (1) → 1 Formen der Rekursion - Lineare Rekursion - Endrekursion, z.B. größter gemeinsamer Teiler: ggT ( a, b) = ⎧ ⎨ a ⎩ ggT (b, a mod b) falls b = 0 falls b > 0 1 x≤0 ⎩ x ⋅ ( x − 1)! sonst. Fakultät: x! = ⎧ ⎨ - Verzweigende Rekursion oder Baumrekursion, z.B. 1 Programmieren in Java, Skriptum zur Vorlesung 2005/2006, 2.6.7 Programmieren in C++, Skriptum zur Vorlesung im SS 2006, 1.7.4 3 Algorithmen und Datenstrukturen, Skriptum zur Vorlesung im SS 2008, 3.3 4 eine enge Verwandtschaft zu dem in der Mathematik bekannten Prinzip der vollständigen Induktion ist nicht zu verkennen 2 1 ⎧1 ⎛n⎞ ⎪ ⎜⎜ ⎟⎟ = ⎨ ⎝k ⎠ ⎪ ⎩ k = 0, k = n ⎛ n − 1⎞ ⎛ n − 1⎞ ⎜⎜ ⎟⎟ + ⎜⎜ ⎟⎟ ⎝ k − 1⎠ ⎝ k ⎠ sonst - Geschachtelte Rekursion (bzw. nicht primitive Rekursion) 5 - Verschränkte oder wechselseitige Rekursion Der rekursive Aufruf erfolgt indirekt, z.B.: n = 0, ⎧true even(n) = ⎨ ⎩ odd (n − 1) n > 0 n = 0, ⎧ false odd (n) = ⎨ ⎩ even(n − 1) n > 0 Rekursion und Iteration Man kann zeigen: Jeder rekursive Algorithmus lässt sich in einen iterativen umwandeln. Da die Iteration wesentlich effektiver als die Rekursion ist, stellt sich die Frage: Warum verwendet man überhaupt die Rekursion? Für die Rekursion spricht: 1. Es gibt bestimmte rekursiv formulierte Algorithmen, die schneller oder wenigstens gleich schnell arbeiten als vergleichbar iterative. 2. Es lassen sich viele Probleme rekursiv „sehr einfach“ lösen. Bsp.: Die folgende rekursive Definition der Fakultätsdefinition fak(0) = 1 fak(n) = n * fak(n-1), falls n > 0 Die rekursive Definition der Fakultät lässt sich direkt in Java umsetzen: public static int fak(int n) { if (n == 0) return 1; else return n*fak(n-1); // der rekursive Aufruf ist endrekursiv, // damit ist auch die Funktion endrekursiv } Aufrufstruktur für fak(3) fak(3) fak(2) fak(1 fak(0) Rekursionstiefe 0 1 2 3 Hinsichtlich der Effizienz ist die iterative, nichtrekursive Funktion vorzuziehen, da - rekursive Funktionen einen Stapelüberlauf bewirken können - neben Rückgabeadresse auch lokale Variable gespeichert werden müssen Beseitigung endrekursiver Aufrufe (Eliminieren der Endrekursion 6) 5 6 Algorithmen und Datenstrukturen, Skriptum zur Vorlesung im SS 2008, 3.3.4 tail recursion elimination 2 n Bsp.: Die Funktion sum( x, n) berechnet die folgende Summe: sum( x, n) = x + ∑ i i =1 public static int sum(int x, int n) { if (n == 0) return x; else return sum(x+n,n-1); } Endrekursive Funktionen können also folgende Gestaltungsformen annehmen: (1) public static void f() { A // Folge von Anweisungen f(); } Der endrekursive Aufruf sorgt für wiederholtes Durchführen von A. Damit kann die rekursive Funktion f gleichwertig durch eine iterative Version ersetzt werden. public static void f() { while (true) { A // Folge von Anweisungen } } Auch wenn sich der endrekursive Aufruf innerhalb einer Kontrollstruktur befindet, erfolgt die Überführung in eine iterative Version ganz analog. Bspw. kann (2) public static void f() { if (B) A1 else { A2 f(); } } gleichwertig ersetzt werden durch public static void f() { while(true) { if (B) A1 else { A2 } } } (3) Besitzt die endrekursive Funktion außerdem Parameter, so muß die Parameterübergabe durch Zuweisungen nachgebaut werden. ReturnType f(Type1 x1, Type2 x2,…,Typen xn) { while(true) { 3 A7 x1=a1; x2=a2; … xn=an; } } Damit läßt sich die Endrekursion in der Funktion sum( x, n) beseitigen: public static int sumohneRek(int x, int n) { while (true) { if (n == 0) return x; else { x = x + n; n = n -1 ; } } } Durch Umbau der Kontrollstrukturen erhält man eine einfacher strukturierte Lösung: public static int sumIt(int x, int n) { while (n != 0) { x = x + n; n = n - 1; } return x; } } Generell sollte man auf die Verwendung der Rekursion immer dann verzichten, falls es eine offensichtliche Lösung mit der Iteration gibt. Solche Lösungen existieren bspw. nicht, wenn die rekursive Funktion mehr als einen rekursiven Aufruf enthält. Dynamisches Programmieren Das größte Problem bei rekursiven Lösungen ist die Gedächtnislosigkeit, die insbesondere bei induktivem Entwurf von Algorithmen beobachtet werden kann 8. Hier kann insbesondere der Lösungsansatz „Dynamisches Programmieren“ (Zwischenspeichern der Lösungen der kleineren Probleme) 9. Teile-und-Herrsche-Verfahren Bei den bisherigen rekursiven Funktionen wurde in der Regel ein Problem der Größe n auf ein Problem der Größe n-1 reduziert. Bei den Teile-und-Herrsche-Verfahren (divide ans conquer) 10 wird versucht ein Problem in wenigstens zwei möglichst gleichgroße Teilprobleme derselben Art wie die Ausgangsprozedur zu zerlegen. Die Teilprobleme werden dann auf dieselbe Art (d.h. rekursiv) gelöst. Schematisch lassen sich die Teile-und-Herrsche Verfahren folgendermaßen formulieren: if Problem ist einfach zu lösen ()z.B. Problemgröße n = 1) then löse Problem direkt; else // Teileschritt Teile Problem in wenigstens 2 möglichst gleichgroße Teilprobleme, 7 Die Rückgabe eines konkreten Funktionswertes muss wie bei der rekursiven Version im Basisfall des Programmteils A erfolgen 8 vgl. Algorithmen und Datenstrukturen, Skriptum 2008, 3.2.3 9 vgl. Algorithmen und Datenstrukturen, Skriptum 2008, 3.2.3 10 vgl. Algorithmen und Datenstrukturen, Skriptum 2008, 3.2.2 4 wobei Teilprobleme derselben Art sind wie Ausgangsproblem: // Herrscheschritt Löse Teileproblem rekursiv Setze Teillösungen zur Gesamtlösung zusammen; Bsp.: Potenzfunktion pot ( x, n) = x n ; n ∈ Ν n x n wird auf x 2 zurückgeführt: n 2 n 2 x = x ⋅x , n n 2 falls n gerade und n ≥ 2 n 2 xn = x ⋅ x ⋅ x , x1 = x falls n ungerade und n ≥ 3 11 public static double pot(double x,int n) { if (n == 1) return x; else { double p = pot(x,n/2); if (n % 2 == 0) // n ist gerade return p*p; else return x * p * p; } } Bekannte Beispiele für „Teile-und-Herrsche“ Verfahren: Binäre Suche 121314 MergeSort 15 Quicksort 16 Es gibt auch Prozeduren mit rekursiven Aufrufen, bei denen das Teile-und-Herrsche Prinzip nicht anwendbar ist, z.B. das bekannte Problem der Türme von Hanoi. 11 ganzzahlige Division vorausgesetzt Algorithmen und Datenstrukturen, Skriptum 2008, 1.2.7.2 13 Programmieren in Java, Skriptum WS 2005/2006, 2.2.3.3 14 Programmieren in C++, SS 2006, 1.7.4 15 Algorithmen und Datenstrukturen, Skriptum 2008, 3.1.1.1.3 16 Algorithmen und Datenstrukturen, Skriptum 2008, 3.1.1.1.1 12 5 Türme von Hanoi public static void bewegeTurm(int n, int quellplatz, int zielplatz, int hilfsplatz) { if (n > 0) { bewegeTurm(n-1,quellplatz,hilfsplatz,zielplatz); System.out.println("Bewege Scheibe von " + quellplatz + " nach " + zielplatz); // zaehler++; bewegeTurm(n-1,hilfsplatz,zielplatz,quellplatz); } } Beseitigung der Rekursion mit Hilfe eines Kellers Zur Laufzeit benutzt der Rechner bei jedem Funktionsaufruf den Systemkeller zum Abspeichern der Parameter, Rücksprungadressen und lokalen Variablen. Prinzipiell kann dieser Mechanismus auf die Programmierebene hochgezogen werden. Auf das Einkellern der Rücksprungadressen kann man verzichten. Statt eines rekursiven Aufrufs wird die Aufgabe, die durch den rekursiven Aufruf erledigt werden soll, in einen Keller eingefügt. In einer Schleife werden solange Aufgaben aus dem Keller geholt und bearbeitet, bis der Keller leer ist. Ein Keller steht in Java im Rahmen des Collection Framework zur Verfügung 17. Für die Implementierung der Datenstruktur wird hier ein generischer Typ 18 benutzt. Parameter sind in der Datenstruktur bzw. der Klasse Knoten zusammengefasst: import java.util.*; class Knoten { // Instanzvariable int anz; int quelle; int ziel; int hilfe; // Konstruktor public Knoten(int n, int q, int z, int h) { anz = n; quelle = q; ziel = z; hilfe = h; }; } public class Hanoi { // Klassenvariable static long zaehler = 0; // Bewegung der Scheiben, rekursive Prozedur public static void bewegeTurm(int n, int quellplatz, int zielplatz, int hilfsplatz) { if (n > 0) { bewegeTurm(n-1,quellplatz,hilfsplatz,zielplatz); System.out.println("Bewege Scheibe von " + quellplatz + " nach " + zielplatz); 17 18 vgl. Algorithmen und Datenstrukturen, Skriptum SS 2008, 2.1.3 vgl. Algorithmen und Datenstrukturen, Skriptum SS 2008, 1.3.5.6.2 6 zaehler++; bewegeTurm(n-1,hilfsplatz,zielplatz,quellplatz); } } // Bewegung der Scheiben ohne rekursive Funktionsaufrufe // Unterstützung eines Stapels public static void bewegeTurmIt(int n, int quellplatz, int zielplatz, int hilfsplatz) { Stack<Knoten> s = new Stack<Knoten>(); s.push(new Knoten(n,quellplatz,zielplatz,hilfsplatz)); while (!s.empty()) { Knoten k = s.pop(); if (k.anz == 1) System.out.println("Bewege Scheibe von " + k.quelle + " nach " + k.ziel); else { s.push(new Knoten(k.anz-1,k.hilfe,k.ziel,k.quelle)); s.push(new Knoten(1,k.quelle,k.ziel,k.hilfe)); s.push(new Knoten(k.anz-1,k.quelle,k.hilfe,k.ziel)); } } } public static void main(String args[]) { int anzahl = Integer.valueOf(args[0]).intValue(); // Aufruf der rekursiven Prozedur bewegeTurm // bewegeTurm(anzahl,1,2,3); // System.out.println(zaehler + " Scheibenbewegungen"); // Aufruf der Prozedur mach Beseitigung der Rekursion bewegeTurmIt(anzahl,1,2,3); } } mit // (1) // (2) // (3) a) Vergleiche die mit (1) und (3) markierten Zeilen mit den entsprechenden rekursiven Aufrufen. Warum ist die Reihenfolge umgekehrt? b) Warum erfolgt in der Zeile (2) nicht wie in der rekursiven Version direkt die Ausgabe: System.out.println("Bewege Scheibe von " + quellplatz + " nach " + zielplatz); c) Vergleiche die Aufrufstruktur der rekursiven Version mit den Kellerzuständen der iterativen Version beim Aufruf von bewegeTurm 7 Rekursive Datentypen 19 Rekursiv formulierte Algorithmen bieten sich insbesondere an, wenn das zugrunde liegende Problem oder die zu behandelnde Datenstruktur rekursiv definiert sind. Werte rekursiver Datentypen können eine oder mehrere Komponenten enthalten, die zum gleichen Typ gehören wie sie selbst, z.B. „Binärbaumknoten“. Baumknoten<T> protected T daten; protected Baumknoten<T> links; protected Baumknoten<T> rechts; // Konstruktoren public Baumknoten(T daten); public Baumknoten(T daten, Baumknoten<T> links, Baumknoten<T> rechts); // getter- und setter-Methoden public T getDaten() public Baumknoten<T> getLinks() public Baumknoten<T> getRechts() ... Das steht in Analogie zu einer Prozedur, die einen oder mehrere Aufrufe von sich selbst enthält. Wie Prozeduren können solche Typen-Definitionen direkt oder indirekt rekursiv sein. Die wesentlichen Eigenschaften rekursiver Strukturen, die sie deutlich von fundamentalen Strukturen z.B. Arrays) unterscheidet, ist ihre Fähigkeit, ihre Größe zu verändern. Daher ist es unmöglich, einer rekursiv definierten Struktur einen festen Speicherbereich zuzuweisen (, und folglich kann ein Compiler den Komponenten solcher Variablen keine spezifische Adressen zuordnen). Das Problem wird verbreitet gelöst über dynamische Speicherzuordnung, d.h. Zuweisung von Speicher zu einzelnen Komponenten erst dann, wenn sie während der Programmausführung entstehen (, und nicht schon während der Übersetzung). Zu dem vorliegenden Binärbaumknoten ist folgende Klasse gegeben: class Baumknoten<T> { private T daten; private Baumknoten<T> links, rechts; // Konstruktor public Baumknoten(T daten) { this(daten,null,null); } public Baumknoten(T daten, Baumknoten<T> links, Baumknoten<T> rechts) { this.links = links; this.rechts = rechts; this.daten = daten; } // getter-Methoden public T getDaten() { return daten; } public Baumknoten<T> getLinks() 19 N. Wirth, Algorithmen und Datenstrukturen, 2. Auflage, Stuttgart 1979, S. 222 8 { return links; } public Baumknoten<T> getRechts() { return rechts; } } Traversierungen in binären Bäumen Eine Traversierung eines binären Baums besteht aus dem systematischen Besuchen aller Knoten in einer bestimmten Reihenfolge. Zum Nachweis verschiedener Traversierungen wird mit Hilfe von Objekten der Klasse Baumknoten<T> ein Baum folgender Gestalt erzeugt: + * A + B * C E D Diese Struktur wird in der main()-Routine der Klasse GenBaumknoten aufgebaut 20: public class GenBaumknoten { private static Baumknoten wurzel; .. public static void main(String { // Aufbau eines Baumknoten<Character> h = new Baumknoten<Character> i = new Baumknoten<Character> d = new Baumknoten<Character> e = new Baumknoten<Character> b = new Baumknoten<Character> f = new Baumknoten<Character> g = new Baumknoten<Character> c = new Baumknoten<Character> a = new wurzel = a; .. } } args[]) Baumknoten<Character>('C'); Baumknoten<Character>('D'); Baumknoten<Character>('A'); Baumknoten<Character>('B'); Baumknoten<Character>('*',d,e); Baumknoten<Character>('*',h,i); Baumknoten<Character>('E'); Baumknoten<Character>('+',f,g); Baumknoten<Character>('+',b,c); Baumdurchläufe 21: Bäume können auf verschiedene Art durchlaufen werden. Die bekanntesten Verfahren sind Tiefensuche (depth-first-search, DFS) und Breitensuche (breadth-first-search), BFS). Tiefensuche kann unterschieden werden in die drei Typen: präorder, postorder und inorder, abhängig von der Reihenfolge der rekursiven Aufrufe. 20 21 vgl. GenBaumknoten.java vgl. Datenstrukturen und Algorithmen, Skriptum SS 2008, 4.2.3 9 Tiefensuche Präorder - Betrachte zuerst den Knoten (die Wurzel des Teilbaums) - Durchsuche dann den linken Teilbaum - Durchsuche zuletzt den rechten Teilbaum Inorder - Durchsuche zuerst den linken Teilbaum - Betrachte dann den Knoten - Durchsuche dann den rechten Teilbaum Postorder - Durchsuche zuerst den linken Teilbaum - Durchsuche dann den rechten Teilbaum - Betrachte zuletzt den Knoten Abb.: Preorder-, Inorder-, Postorder- und Preorder-Traversen Die Implementierung des Preorder zeigt zwei rekursive Aufrufe: private static void praeorderAusg(Baumknoten b) { // Rekursiver Durchlauf if (b == null) return; { System.out.print(b.getDaten() + " "); praeorderAusg(b.getLinks()); praeorderAusg(b.getRechts()); } } Zur Transparenz ist es zweckmäßig eine iterative Lösung mit Stack 22 zu erstellen: private static void wlrnr(Baumknoten b) { Stack<Baumknoten> s = new Stack<Baumknoten>(); s.push(null); while (b != null) { System.out.print(b.getDaten() + " "); if (b.getRechts() != null) s.push(b.getRechts()); if (b.getLinks() != null) b = b.getLinks(); else b = (Baumknoten) s.pop(); } 22 vgl. Algorithmen und Datenstrukturen, Skriptum 2008, 2.1.3 10 } Wozu braucht man Präfix- und Postfixnotationen? Gegeben ist z.B. der arithmetische Ausdruck (a + b) * c + d. Diese Notation heißt InfixNotation, weil jeder Operator immer zwischen zwei arithmetischen (Teil-) Ausdrücken steht. Ohne Klammern ist der Ausdruck nicht eindeutig. Prä- und Postfix-Notation sind bei der Erstellung von Compilern (z.B. einem Java-Compiler) populär, weil man damit syntaktisch korrekte algebraische Ausdrücke klammerfrei darstellen kann. Der Compiler erzeugt einen Syntaxbaum für einen Ausdruck: - Die Wurzel enthält immer einen Operator Jeder Teilbaum stellt entweder den Namen einer Variablen dar oder einen arithmetischen Teilausdruck Ein derartiger Baum heißt Kantorowitsch-Baum, z.B.: + * + a d c b Abb.: Kantorowitsch-Baum des algebraischen Ausdrucks (a + b) * c + d Die Preorder-Traversierung eines Kantorowitsch-Baums ergibt die klammerfreie Präfix-Notation des entsprechenden arithmetischen Ausdrucks. Die Notation heißt Präfix, weil alle Operatoren am Anfang stehen: + * + a b c d Die Postorder-Traversierung eines Kantorowitsch-Baums ergibt die klammerfreie Postfix-Notation (auch polnische Notation genannt) des entsprechenden arithmetischen Ausdrucks. Die Notation heißt Postfix, weil der Operator immer hinter den Operanden steht, z.B. a b + c * d + Bsp. zu Baumtraversen für Strukturbäume 23 Breitensuche (levelorder) Bei der Breitensuche besucht man jeweils nacheinander die Knoten der gleichen Ebene: - Starte bei der Wurzel (Ebene 0) - Bis die Höhe des Baums erreicht ist, setze den Level um eins höher und gehe von links nach rechts durch alle Knoten dieser Ebene Um mittels Breitensuche durch einen Baum zu wandern, müssen alle Baumknoten einer Ebene gespeichert werden. Die Knoten werden in einer Schlange 24 gespeichert. Im folgenden Bsp. wird die Breitensuche iterativ auf einem Baum mit Hilfe einer Schlange 25 durchgeführt. 23 vgl. Datenstrukturen und Algorithmen, Skriptum 2008, 4.2.3 vgl. Datenstrukturen und Algorithmen, Skriptum 2008, 2.2.3 25 vgl. Datenstrukturen und Algorithmen, Skriptum 2008, 2.2.3 24 11 private static void breitenSuche(Baumknoten wurzel) { Baumknoten b; // Hilfsbaum // Schlange LinkedList<Baumknoten> s = new LinkedList<Baumknoten>(); if (wurzel != null) s.add(wurzel); // lege uebergebenen Baum in Schlange while (!s.isEmpty()) { b = s.poll(); // besorge Baum aus Schlange und entferne // vordersten Eintrag System.out.print(b.getDaten()+ " "); // Ausgabe Wert der Baumwurzel if (b.getLinks() != null) // falls linker Sohn s.add(b.getLinks()); // haenge ihn an Schalnge if (b.getRechts() != null) // falls rechter Sohn s.add(b.getRechts()); // haenge ihn an Schlange } } 12