2. Elm
2.6 Verzweigende Rekursion
Rekursion ist, wenn im Funktionskörper einer Funktion
die mit
fib : Int -> Int
in Elm,
die diese Funktion berechnet, bei Eingabe von Meine Lösung
fib : Int -> Int
fib n =
if n == 0 then
0
else if n == 1 then
1
else
fib (n - 1) + fib (n - 2)
Die Fibonacci-Zahlen spielen bei der Analyse gewissen Algorithmen eine Rolle. Daher werden Sie
Ihnen
im dritten Semester in Algorithmen und Komplexität (AuK) wohl
wieder begegnen. Uns im Moment dienen sie dazu, gewisse Programmierprinzipine
zu erklären und zu üben.
Obwohl wir in diesem Semester nur am Rande auf Laufzeitkomplexität blicken (auch
hierfür siehe
AuK), so sollten wir sie auch nicht ganz aus dem Blick
verlieren.
Rufen Sie mal meinen (oder Ihren) Code fib n
auf für verschiedene Werte von
Der Rekursionsbaum
Hier nochmal mein Code für fib n
:
fib : Int -> Int
fib n =
if n == 0 then
0
else if n == 1 then
1
else
fib (n - 1) + fib (n - 2)
In einer rekursiven Funktion kann ein Aufruf f n
weitere Unteraufrufe versachen.
In unserem
Konkreten Beispiel verursacht fib n
zwei weitere Aufrufe: fib (n-1)
und fib (n-2)
, beides in Zeile 87. Diese verursachen wiederum weitere Aufrufe,
bis
In der Informatik wachsen Bäume grundsätzlich von oben nach unten. Beachten Sie, dass die
Knoten, die mit fib 1
oder fib 0
beschriftet sind, selbst keine
Kinder haben; das ist ja auch richtig, denn sie erzeugen auch keine weiteren rekursiven Aufrufe,
da Sie in Zeile 81 oder 84 enden und die Semantik der if
-Konstruktion
verhindert, dass Zeile 87 ausgewertet wird.
fib 5
.
fib 1
beschriftete Blätter hat der Rekursionsbaum
von fib n
?
Wir betrachten eine weitere Funktion, die ich mir extra für diese Vorlesung
ausgedacht habe, und die ich mangels eines besseren Namens weird
(Englisch für
eigenartig) genannt habe. In der mathematischen Notation kürze ich sie mit
weird
.
Meine Lösung
weird : Int -> Int
weird n =
if n == 0 then
0
else if modBy 2 (weird (n - 1)) == 1 then
n + weird (n - 1)
else
weird (n - 1) // 2 + 1
weird n
für
Handelt es sich bei der Rekursion um verzweigende Rekursion? Strenggenommen ja, denn
der Aufruf von weird 10
erzeugt einen Aufruf (von weird 9
) in Zeile
99,
und dann nochmal einen, entweder in Zeile 100 oder in Zeile 103.
weird 3
. Beachten Sie, dass jeder
Aufruf für
Ist das vertretbar? Man kann ja argumentieren, dass sich die Rekursion gar nicht
verzweigen müsste, weil man das Ergebnis
public class Recursion {
public static int weirdJava (int n) {
if (n == 0) {
return 0;
}
else {
int wNminus1 = weirdJava(n-1);
if (wNminus1 % 2 == 1) {
return wNminus1 + n;
}
else {
return wNminus1 / 2 + 1;
}
}
...style='counter-set: listing 23;'}
In Zeile 7 berechnen wir wNminus1
. Somit
brauchen wir nur einen rekursiven Aufruf. Die Auswertung von
weirdJava(n)
in Java erzeugt also insgesamt n weitere Aufrufe (für weird n
zwei Aufrufe von weird (n-1)
erzeugt, vier von weird (n-2)
und schließlich weird 0
.
Um also beispielsweise
Java ist unbestreitbar schneller als Elm. Allerdings ist der Unterschied zwischen
weird
und weirdJava
nicht nur der Unterschied zwischen dem schnellen (weil
hardware-näheren) Java
und dem langsameren (weil hardware-ferneren und eher für Web-Frontends gedachten) Elm; sondern
es ist
ein Unterschied zwischen einem effizienten und einem ineffizienten Algorithmus.
Können wir also unseren Elm-Code effizienter machen und irgendwie das Zwischenergebnis
wiederverwenden?
Zwischenergebnisse wiederverwenden mit Helfer-Funktionen
Versuchen wir, den Code von weird
effizienter zu machen, und zwar allein mit den
Techniken,
die wir bereits gelernt haben. Die erste Beobachtung ist, dass der Java-Code in Zeile 8-14
keinen
rekursiven Aufruf enthält, sondern einfach wNminus1
weiterverarbeitet. Wir können
also in Elm auch eine Funktion weirdHelper
schreiben, die diesen Teil übernimmt:
weirdHelper : Int -> Int -> Int
weirdHelper wNminus1 n =
if modBy 2 wNminus1 == 1 then
wNminus1 + n
else
wNminus1 // 2 + 1
Nun schreiben wir einfach eine rekursive Funktion weirdWithHelper
, die
rekursiv weirdHelper
aufruft; die also
im Prinzip die Java-Zeilen 2-7 in Elm implementiert:
weirdWithHelper : Int -> Int
weirdWithHelper n =
if n == 0 then
0
else
weirdHelper (weirdWithHelper (n - 1)) n
Der rekursive Aufruf weirdWithHelper (n-1)
wird also nur einmal ausgewertet; das
Ergebnis
wird dann als Argument der Funktion weirdHelper
übergeben (dort als Parameter
mit Namne wNminus1
bekannt); dass wNminus1
im Code von
weirdHelper
mehrfach verwendet wird, spielt keine Rolle; der Ausdruck ist bereits
ausgewertet worden, zu einer Zahl, und kann "kostenlos" wiederverwendet werden.
weird n
und weirdWithHelper n
für
Selbst weirdWithHelper 100
ist kein Problem, während weird 100
mindestens
Zwischenergebnisse mit let
Wir haben also gesehen, dass wir Zwischenergebnisse "speichern" können, indem wir die
Weiterverarbeitung
in einer Helferfunktion auslagern. In der Praxis ist das aber meist unbequem, und daher kann man
in Elm
Zwischenergebnisse speichern (und einem Bezeichner zuweisen), ähnlich wie wir es in Java
in Zeile 7 mit int wNminus1 = weirdJava(n-1);
getan haben.
Dies geht mit der let ... in ...
-Konstruktion:
weirdWithLet : Int -> Int
weirdWithLet n =
if n == 0 then
0
else
let
y : Int -- Bezeichner für ein Zwischenergebnis deklarieren
y =
weirdWithLet (n - 1) -- Zwischenergebnis berechnen und im Bezeichner speichern
in
if modBy 2 y == 1 then
y + n
else
y // 2 + 1
Im allgemeinen sieht eine let
-Konstruktion wie folgt aus:
let identifier1 : Type1 identifier1 = expression1 ... identifierN : TypeN identifierN = expressionN in finalExpression
In finalExpression
dürfen dann die Bezeichner
identifier1, ..., identifierN
vorkommen. Alles, was Sie im Top-Level in Ihrer Programmdatei definieren, also auch Funktionen
etc.,
dürfen Sie innerhalb eines let
-Blocks definieren.
Finbonacci-Zahlen ohne Verzweigung
Können wir einen ähnlichen Trick bei den Fibonacci-Zahlen anwenden, um die sich verzweigende
Rekursion in eine "lineare" Rekursion umzuwandeln? Ein tatsächliches Problem ist hierbei,
dass wir tatsächlich zwei Vorergebnisse benötigen, um
public static int fib (int n) {
int a = 0;
int b = 1;
for (int i = 0; i < n; i++) {
int temp = a+b;
a = b;
b = temp;
}
return a;
}
Wir führen zwei lokale Variable, a
und b
. In jedem Schritt
wird das Paar
(1.0, 2.0)
(1.0, 2.0) : (Float, Float)
Wir können ganz einfach mit Klammern-Notation Paare definieren. Das geht mit beliebigen Typen:
n : Int
n = 1
(n, "Montag")
(1, "Montag") : (Int, String)
Wir können an das erste und zweite Element ran mit Tuple.first
und
Tuple.second
:
pair = ("Montag", "Monday")
Tuple.first pair
"Montag" : String
Etwas zu dem Wort Tuple, auf Deutsch Tupel.
Das Wort ist aus der lateinischen Endung -tuplus abgeleitet,
zum Beispiel quintuplus (fünffach), septuplus (siebenfach).
Ein Tupel bezeichnet eine endliche Reihe von Zahlen, wobei die Reihenfolge wichtig ist.
Das unterscheidet beispielsweise das 4-Tupel fibPair
liest eine ganze Zahl n
ein und
gibt das Paar
fibPair : Int -> ( Int, Int )
fibPair n =
if n == 0 then
( 0, 1 )
else
let
previousPair : ( Int, Int )
previousPair =
fibPair (n - 1)
a : Int
a =
Tuple.first previousPair
b : Int
b =
Tuple.second previousPair
in
( b, a + b )
Sehen Sie nun: ein Aufruf von fibPair n
zieht einen rekursiven Aufruf
nach sich, nämlich fibPair (n-1)
. Insgesamt werden also nur
fibFast : Int -> Int
fibFast n =
Tuple.first (fibPair n)
fibFast
und
fib
. Testen Sie für Anmerkung: auf der Elm-Repl-Konsole ist es tatsächlich nicht möglich, automatisch die Ausführungszeit zu messen. Schätzen Sie sie also ab bzw. messen Sie sie per Hand mithilfe einer Stoppuhr.