P nP nP → − → − nf − nf Ν → Ν :f ≥ + ≥ = = = ungerade n

Werbung
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
Herunterladen