Wstęp do programowania/Rekursja: Różnice pomiędzy wersjami
Nie podano opisu zmian |
mNie podano opisu zmian |
||
(Nie pokazano 32 wersji utworzonych przez 6 użytkowników) | |||
Linia 1: | Linia 1: | ||
Częstokroć stajemy przed problemem | Częstokroć stajemy przed problemem, który łatwo byłoby | ||
rozwiązać, gdybyśmy mieli w ręku odpowiedź dla mniejszych | rozwiązać, gdybyśmy mieli w ręku odpowiedź dla mniejszych | ||
danych. Dla przykładu obliczenie silni liczby <math>n</math> wymaga | danych. Dla przykładu, obliczenie silni liczby <math>n</math> wymaga | ||
przemnożenia silni <math>(n-1)</math> przez <math>n</math>. Wiemy zatem, że: | przemnożenia silni <math>(n-1)</math> przez <math>n</math>. Wiemy zatem, że: | ||
<span id=""/> | <span id=""/> | ||
<center><math> | <center><math>0!=1; n!=n*(n-1)!</math> dla <math>n>0</math></center> | ||
Możemy tę parę wzorów przyjąć za definicję funkcji silnia. | Możemy tę parę wzorów przyjąć za definicję funkcji silnia. | ||
Takie definiowanie nazywamy | Takie definiowanie nazywamy ''rekurencyjnym'' lub | ||
''indukcyjnym''. My jednak słowo ''indukcja'' | |||
wolimy zachować do określenia sposobu dowodzenia. Będziemy | wolimy zachować do określenia sposobu dowodzenia. Będziemy | ||
więc mówili o rekurencyjnych definicjach i indukcyjnych | więc mówili o rekurencyjnych definicjach i indukcyjnych | ||
dowodach. | dowodach. | ||
Dla przykładu, jeżeli ciąg <math>T_0,T_1,\ldots | Dla przykładu, jeżeli ciąg <math>T_0,T_1,\ldots</math> zdefiniujemy rekurencyjnie | ||
następująco: | następująco: | ||
<span id=""/> | <span id=""/> | ||
<center><math> T_0 = 0; T_n = 2T_{n-1}+1 | <center><math>T_0=0; T_n = 2T_{n-1}+1</math> dla <math>n>0</math></center>, | ||
</math></center> | |||
to używając metody indukcji matematycznej | to używając metody indukcji matematycznej, możemy łatwo | ||
udowodnić, że <math>T_n = 2^n-1</math>. | udowodnić, że <math>T_n = 2^n-1</math>. | ||
Napotykamy jednak często na problemy, które w odróżnieniu | Napotykamy jednak często na problemy, które w odróżnieniu od | ||
funkcji | funkcji ''silnia'', czy ciągu <math>T_n</math>, nie mają prostej, | ||
nierekurencyjnej postaci, albo wręcz nie jest ona nam | nierekurencyjnej postaci, albo wręcz nie jest ona nam | ||
znana. | znana. Dla przykładu liczby Fibonacciego znane są co najmniej od 1202 roku, | ||
przykładu liczby Fibonacciego znane są co najmniej od 1202 roku, | |||
i generowanie kolejnych liczb <math>F_n</math> bezpośrednio ze wzoru | i generowanie kolejnych liczb <math>F_n</math> bezpośrednio ze wzoru | ||
rekurencyjnego nie stanowi żadnego problemu, jednak | rekurencyjnego nie stanowi żadnego problemu, jednak | ||
dopiero w roku 1843 francuski matematyk J.Binet udowodnił wzór | dopiero Euler, a niezależnie od niego w 100 lat później w roku 1843 francuski matematyk J.Binet udowodnił wzór bezpośrednio wyliczający n-tą liczbę Fibonacciego: | ||
bezpośrednio wyliczający n-tą liczbę Fibonacciego: | |||
<span id=""/> <center><math>F_n=\frac{1}{\sqrt{5}}\left(\left(\frac{\sqrt{5}+1}{2} | |||
\right)^n -\left( \frac{1-\sqrt{5}}{2}\right)^n \right) | |||
</math></center> | |||
Znajomość tego wzoru wcale nie rozwiązuje nam wszystkich | |||
problemów związanych z wyliczeniem n-tej | problemów związanych z wyliczeniem n-tej | ||
liczby Fibonacciego. Przede wszystkim komputer mógłby mieć | liczby Fibonacciego. Przede wszystkim komputer mógłby mieć | ||
trudności ze stwierdzeniem, że wynik jest liczbą naturalną, | trudności ze stwierdzeniem, że wynik jest liczbą naturalną, | ||
choć w istocie powyższy wzór Bineta generuje jedynie liczby | choć w istocie powyższy wzór Eulera-Bineta generuje jedynie liczby | ||
naturalne. | naturalne. | ||
Linia 51: | Linia 45: | ||
naturalnej, która byłaby spodziewanym wynikiem obliczeń. Poza | naturalnej, która byłaby spodziewanym wynikiem obliczeń. Poza | ||
tym lekką przesadą możnaby nazwać używanie tak | tym lekką przesadą możnaby nazwać używanie tak | ||
skomplikowanych operacji | skomplikowanych operacji jak potęgowanie liczb niewymiernych w | ||
celu uzyskania w miarę prostej liczby naturalnej. | celu uzyskania w miarę prostej liczby naturalnej. | ||
Linia 60: | Linia 54: | ||
W Pascalu użycie rekurencji jest niezwykle naturalne: | W Pascalu użycie rekurencji jest niezwykle naturalne: | ||
{{kotwica|kod_zrodlowy|}} | |||
'''function''' silnia(n:Integer):Integer; | |||
'''begin''' | |||
'''if''' n=0 '''then''' silnia := 1 | |||
'''else''' silnia := n*silnia(n-1) | |||
'''end'''; | |||
Wywołanie tej funkcji od pewnego naturalnego argumentu <math>n\ge 0</math> | Wywołanie tej funkcji od pewnego naturalnego argumentu <math>n\ge 0</math> | ||
spowoduje, że identyfikatorowi | spowoduje, że identyfikatorowi ''silnia'' zostanie nadana | ||
wartość <math>n!</math>. Stanie się to w następujący sposób. Najpierw | wartość <math>n!</math>. Stanie się to w następujący sposób. Najpierw | ||
sprawdzimy, czy <math>n</math> równa się zero. Jeżeli tak, to wynik | sprawdzimy, czy <math>n</math> równa się zero. Jeżeli tak, to wynik | ||
wyniesie 1. Jeżeli nie, to wywołana zostanie funkcja | wyniesie 1. Jeżeli nie, to wywołana zostanie funkcja ''silnia'' od parametru <math>n-1</math>, a jej wynik przemnożony zostanie | ||
silnia | |||
przez <math>n</math> dając ostateczną wartość funkcji. Wykonanie | przez <math>n</math> dając ostateczną wartość funkcji. Wykonanie | ||
instrukcji mnożenia przez <math>n</math> zostanie więc zawieszone na czas | instrukcji mnożenia przez <math>n</math> zostanie więc zawieszone na czas | ||
obliczenia wartości <math>{ | obliczenia wartości <math>\mathit{ silnia}(n-1)</math>. Zawartość sumatora i | ||
wszystkich rejestrów używanych przez komputer do obliczeń | wszystkich rejestrów używanych przez komputer do obliczeń | ||
zostaną więc automatycznie zapamiętane przez system wykonujący | zostaną więc automatycznie zapamiętane przez system wykonujący | ||
program, | program tak, że <math>\mathit{ silnia}(n-1)</math> wykona się w czystym | ||
środowisku. Po obliczeniu silni z <math>n-1</math>, zawartość stanu | środowisku. Po obliczeniu silni z <math>n-1</math>, zawartość stanu | ||
komputera zostanie odtworzona i mnożenie przez <math>n</math> będzie mogło | komputera zostanie odtworzona i mnożenie przez <math>n</math> będzie mogło | ||
zostać zakończone. | zostać zakończone. | ||
Rzecz jasna <math>{ | Rzecz jasna <math>\mathit{ silnia}(n-1)</math> zostanie obliczona w analogiczny | ||
sposób. Rolę wartości <math>n</math> przejmie <math>n-1</math>, zatem wywoła się w | sposób. Rolę wartości <math>n</math> przejmie <math>n-1</math>, zatem wywoła się w miarę potrzeby <math>\mathit{ silnia}(n-2)</math> itd. Widać więc, że do | ||
miarę potrzeby <math>{ | |||
wywołania funkcji silnia od jakiegoś dużego parametru wymaga się | wywołania funkcji silnia od jakiegoś dużego parametru wymaga się | ||
zawieszenia wykonywania mnożeń na wielu poziomach wywołań | zawieszenia wykonywania mnożeń na wielu poziomach wywołań | ||
rekurencyjnych (po jednym dla każdego <math>1 \le k \le n</math>). | rekurencyjnych (po jednym dla każdego <math>1 \le k \le n</math>). | ||
Zauważmy, że podobnie jak w przypadku źle skonstruowanych | Zauważmy, że podobnie jak w przypadku źle skonstruowanych | ||
pętli możemy nabawić | pętli, możemy nabawić się kłopotów wywołując funkcję ''silnia'' od argumentu ujemnego. System zacznie wtedy wywoływać kaskadę silni wołanych dla parametrów ujemnych coraz bardziej oddalonych od zera. Gdyby pamięć komputera była nieskończona, spowodowałoby to nieskończone zapętlenie się programu. | ||
silnia | Każde wywołanie funkcji wymaga jednak zapamiętania aktualnego stanu komputera. Zużywa to dostępną | ||
kaskadę silni wołanych dla parametrów ujemnych coraz bardziej | |||
oddalonych od zera. Gdyby pamięć komputera była nieskończona, | |||
spowodowałoby to nieskończone zapętlenie się programu. | |||
Każde wywołanie funkcji wymaga jednak | |||
zapamiętania aktualnego stanu komputera. Zużywa to dostępną | |||
pamięć blokując potrzebny jej fragment do końca | pamięć blokując potrzebny jej fragment do końca | ||
wywołania funkcji; w naszym przypadku ten koniec nigdy nie | wywołania funkcji; w naszym przypadku ten koniec nigdy nie | ||
Linia 110: | Linia 97: | ||
Spróbujmy zastosować technikę rekurencyjną do napisania | Spróbujmy zastosować technikę rekurencyjną do napisania | ||
funkcji obliczającej n-tą liczbę Fibonacciego <math>F_n</math>. | funkcji obliczającej n-tą liczbę Fibonacciego <math>F_n</math>. | ||
{{kotwica|kod_zrodlowy|}} | |||
'''function''' Fibo(n:integer):Integer; | |||
{funkcja liczy n-ta liczbe Fibonacciego dla n>=0} | |||
'''begin''' | |||
'''if''' n <= 1 '''then''' Fibo := n | |||
'''else''' Fibo := Fibo(n-2) + Fibo(n-1) | |||
'''end'''; {Fibo} | |||
Linia 130: | Linia 110: | ||
liczb Fibonacciego. Spróbujmy zatem prześledzić, jak będzie | liczb Fibonacciego. Spróbujmy zatem prześledzić, jak będzie | ||
się wykonywać dla <math>n=4</math>. | się wykonywać dla <math>n=4</math>. | ||
Aby obliczyć wartość <math>F_4</math>, będziemy musieli | Aby obliczyć wartość <math>F_4</math>, będziemy musieli wywołać ''Fibo'' dla <math>n=2</math> i dla <math>n=3</math>, a następnie dodać te wartości. | ||
Fibo | Zauważmy jednak, że obliczywszy <math>\mathit{ Fibo}(2)</math> weźmiemy się | ||
Zauważmy jednak, że obliczywszy <math>{ | za liczenie <math>\mathit{ Fibo}(3)</math> od nowa. Do policzenia <math>\mathit{ Fibo}(3)</math> | ||
za liczenie <math>{ | będziemy jednak potrzebowali wartości <math>\mathit{ Fibo}(1)</math> oraz <math>\mathit{ | ||
będziemy jednak potrzebowali wartości <math>{ | |||
Fibo}(2)</math>. Ponieważ komputer nie otrzymał od nas żadnych | Fibo}(2)</math>. Ponieważ komputer nie otrzymał od nas żadnych | ||
wskazówek dotyczących wykorzystania raz już obliczonych | wskazówek dotyczących wykorzystania raz już obliczonych | ||
wartości, więc zacznie od nowa obliczać <math>{ | wartości, więc zacznie od nowa obliczać <math>\mathit{ Fibo}(2)</math>. | ||
Ta drobna niegospodarność będzie nas dużo kosztować. Liczba | Ta drobna niegospodarność będzie nas dużo kosztować. Liczba | ||
wywołań funkcji | wywołań funkcji ''Fibo'' będzie bowiem proporcjonalna do | ||
wartości wykładniczej ze względu na <math>n</math>. Oznacza to, jak już | wartości wykładniczej ze względu na <math>n</math>. Oznacza to, jak już | ||
wiemy, że nawet dla niewielkich stosunkowo danych, rzędu 100, | wiemy, że nawet dla niewielkich stosunkowo danych, rzędu 100, | ||
Linia 154: | Linia 133: | ||
już wartości <math>F_n</math>. Aby zaznaczyć, że dana wartość nie | już wartości <math>F_n</math>. Aby zaznaczyć, że dana wartość nie | ||
została jeszcze obliczona, wypełnimy tablicę minus jedynkami. | została jeszcze obliczona, wypełnimy tablicę minus jedynkami. | ||
Funkcję | Funkcję ''Fibo'' będziemy zatem liczyli rekurencyjnie jedynie | ||
w miarę potrzeby, czyli wtedy, gdy dla danego argumentu liczymy | w miarę potrzeby, czyli wtedy, gdy dla danego argumentu liczymy | ||
ją po raz pierwszy. Zmodyfikujmy zatem naszą funkcję. | ją po raz pierwszy. Zmodyfikujmy zatem naszą funkcję. | ||
{{kotwica|kod_zrodlowy|}} | |||
'''var''' F:array[0..m] '''of''' Integer; | |||
'''function''' Fibo1(n:integer): Integer; | |||
{ | |||
funkcja liczy n-ta liczbe Fibonacciego dla n>=0, korzystając | |||
przy tym z globalnej tablicy F, przy czym | |||
F[i] = F_i, jezeli F_i jest juz obliczone | |||
F[i] = -1, jezeli nie jest jeszcze obliczone | |||
Założenie: n>=0 | |||
} | |||
'''begin''' | |||
zaczniemy od | '''if''' F[n]<0 '''then''' {Wartosc F[n] nie jest jeszcze obliczona, więc | ||
zaczniemy od wypełnienia tablicy właściwą | |||
wartością} | |||
'''if''' n <= 1 '''then''' F[n]:=n | |||
'''else''' F[n] := Fibo1(n-2) + Fibo1(n-1); | |||
{i teraz dopiero nadamy odpowiednią wartośc identyfikatorowi Fibo. | |||
W tablicy F[n] znajduje się zawsze własciwa wartość} | |||
Fibo1 := F[n] | |||
'''end'''; {Fibo1} | |||
Wykonanie tego programu dla paru danych szybko przekona nas o | Wykonanie tego programu dla paru danych szybko przekona nas o | ||
skuteczności tego ulepszenia. Tym razem powinniśmy | skuteczności tego ulepszenia. Tym razem powinniśmy także uważać | ||
na stosunkowo nieduży zakres typu integer i jeżeli | na stosunkowo nieduży zakres typu integer, i jeżeli chcemy obliczać | ||
większe liczby Fibonacciego, | większe liczby Fibonacciego, musimy użyć innego typu (np. | ||
longint). | longint). | ||
Rzecz jasna istnieją znacznie prostsze metody pozwalające nam na | Rzecz jasna istnieją znacznie prostsze metody pozwalające nam na | ||
obliczenie n-tej liczby Fibonacciego. Podobnie | obliczenie n-tej liczby Fibonacciego. Podobnie jak w przypadku | ||
silni, można to zrobić jedną prostą pętlą z czterema | silni, można to zrobić jedną prostą pętlą z czterema | ||
przypisaniami. Są jednak problemy, dla których znalezienie | przypisaniami. Są jednak problemy, dla których znalezienie | ||
nierekurencyjnego rozwiązania ani | nierekurencyjnego rozwiązania ani nie jest proste, ani warte | ||
zachodu ze względu na elegancję i efektywność rekurencji w | zachodu ze względu na elegancję i efektywność rekurencji w | ||
tych przypadkach. | tych przypadkach. | ||
{{kotwica|wieze Hanoi|}} | |||
Wielu znane jest zapewne zadanie o wieżach z Hanoi. Legenda | Wielu znane jest zapewne zadanie o wieżach z Hanoi. Legenda | ||
głosi że w pewnej | głosi, że w pewnej świątyni buddyjskiej w Hanoi znajdują się | ||
trzy wbite w ziemię pręty. Na jednym z nich początkowo | trzy wbite w ziemię pręty. Na jednym z nich początkowo | ||
umieszczono 64 koła o malejących średnicach. Należy te koła | umieszczono 64 koła o malejących średnicach. Należy te koła | ||
przenieść z pierwszego pręta na drugi przy pomocy trzeciego, | przenieść z pierwszego pręta na drugi przy pomocy trzeciego, | ||
respektując następujące zasady: | respektując następujące zasady: | ||
#jednorazowo | #jednorazowo można przenieść jeden krążek | ||
#krążek można nałożyć na dowolny z prętów pod | #krążek można nałożyć na dowolny z prętów pod | ||
warunkiem, że kładzie się go na pusty pręt lub na krążek o | warunkiem, że kładzie się go na pusty pręt lub na krążek o | ||
większej średnicy. | większej średnicy. | ||
Ponoć mnisi przekładają od jakiegoś czasu te krążki, | Ponoć mnisi przekładają od jakiegoś czasu te krążki, a gdy | ||
skończą, nastąpi koniec świata. | skończą, nastąpi koniec świata. | ||
Jak można wyobrazić sobie najprostszy algorytm przekładający w | Jak można wyobrazić sobie najprostszy algorytm przekładający w | ||
możliwie szybki sposób krążki? | możliwie szybki sposób krążki? Przenieść jeden krążek to żadna sztuka. Jak jednak poradzić sobie z ich | ||
większą liczbą? Załóżmy indukcyjnie, | |||
większą liczbą | |||
że umiemy tego dokonać dla <math>n-1</math> krążków. Rekurencyjnie nasz | że umiemy tego dokonać dla <math>n-1</math> krążków. Rekurencyjnie nasz | ||
algorytm formułuje się bardzo prosto. Aby przełożyć wieżę | algorytm formułuje się bardzo prosto. Aby przełożyć wieżę | ||
Linia 229: | Linia 196: | ||
trzeciego, należy <math>n-1</math> krążków przełożyć na pręt trzeci, | trzeciego, należy <math>n-1</math> krążków przełożyć na pręt trzeci, | ||
następnie przełożyć krążek <math>n</math> na pręt drugi, a następnie | następnie przełożyć krążek <math>n</math> na pręt drugi, a następnie | ||
<math>(n-1)</math>-elementową wieżę przełożyć z pręta trzeciego na drugi | <math> (n-1) </math>-elementową wieżę przełożyć z pręta trzeciego na drugi | ||
przy pomocy pierwszego. | przy pomocy pierwszego. | ||
Zauważmy przy okazji, że liczba przenosin <math>n</math> krążków, <math>T_n</math>, | Zauważmy przy okazji, że liczba przenosin <math> n</math> krążków, <math> T_n</math>, | ||
wymaga dwukrotnego przeniesienia wieży złożonej z <math>n-1</math> | wymaga dwukrotnego przeniesienia wieży złożonej z <math> n-1 </math> | ||
krążków oraz jednokrotnego przeniesienia jednego krążka. | krążków oraz jednokrotnego przeniesienia jednego krążka. | ||
Mamy więc wzór: | Mamy więc wzór: | ||
<span id=""/> <center><math> T_1 = 1;\ T_n=2T_{n-1} + 1</math></center> | |||
zatem | |||
<center><math> T_n = 2(2^{n-1}-1)+1 = 2^n-1</math>.</center> | |||
Liczba przenosin jest więc wykładnicza ze względu na <math>n</math>. | Liczba przenosin jest więc wykładnicza ze względu na <math>n</math>. | ||
Możemy spać spokojnie. Nawet jeżeli mnisi będą bardzo | Możemy spać spokojnie. Nawet jeżeli mnisi będą bardzo | ||
Linia 249: | Linia 217: | ||
Procedura, która dokona wygenerowania kolejnych ruchów | Procedura, która dokona wygenerowania kolejnych ruchów | ||
przenoszących n-elementową | przenoszących n-elementową wieżę może więc wyglądać | ||
następująco. Zakładamy, że zamiast dźwigania krążków | następująco. Zakładamy, że zamiast dźwigania krążków | ||
komputer będzie wypisywał kolejne ruchy w postaci przenieś | komputer będzie wypisywał kolejne ruchy w postaci przenieś | ||
krążek z pręta <math>i</math> na pręt <math>j</math>. | krążek z pręta <math>i</math> na pręt <math>j</math>. | ||
{{kotwica|kod_zrodlowy|}} | |||
'''procedure''' Hanoi (n, skad, dokad:integer); | |||
{ procedura przenosi n krążkow z wieży skąd na wieżę dokąd } | |||
'''procedure''' przenies (co, skad, dokad:integer); | |||
{ Tu rzecz jasna można wpisać dowolnie inteligentną procedurę} | |||
'''begin''' | |||
writeln ('PRZENIES ', CO, ' Z ', skad, ' NA ', dokad) | |||
'''end'''; | |||
'''begin''' | |||
'''if''' n<=1 then | |||
przenies (1, skad, dokad) | |||
'''else''' | |||
'''begin''' | |||
{zauważmy, że jeżeli jeden pręt ma numer i, a drugi j, to trzeci | |||
z nich ma numer 6-i-j} | |||
hanoi (n-1, skad, 6-skad-dokad); | |||
przenies (n, skad, dokad ); | |||
hanoi (n-1, 6-skad-dokad, dokad) | |||
'''end''' | |||
'''end'''; | |||
Linia 313: | Linia 248: | ||
żadnych finezji dokonujemy we wnętrzu procedury więcej niż | żadnych finezji dokonujemy we wnętrzu procedury więcej niż | ||
jednokrotnego wywołania rekurencyjnego tej procedury. Tym razem | jednokrotnego wywołania rekurencyjnego tej procedury. Tym razem | ||
jednak nie będzie to stanowiło wady rozwiązania - | jednak nie będzie to stanowiło wady rozwiązania - taka jest | ||
natura problemu: odpowiedź w postaci ciągu ruchów do wykonania | natura problemu: odpowiedź w postaci ciągu ruchów do wykonania | ||
ma długość wykładniczą ze względu na <math>n</math>, więc trudno tu | ma długość wykładniczą ze względu na <math>n</math>, więc trudno tu | ||
cokolwiek poprawić. Rzecz jasna procedurę należy wywoływać | cokolwiek poprawić. Rzecz jasna procedurę należy wywoływać | ||
dla danych znacznie mniejszych niż 64. | dla danych znacznie mniejszych niż 64. |
Aktualna wersja na dzień 14:36, 24 lip 2024
Częstokroć stajemy przed problemem, który łatwo byłoby rozwiązać, gdybyśmy mieli w ręku odpowiedź dla mniejszych danych. Dla przykładu, obliczenie silni liczby wymaga przemnożenia silni przez . Wiemy zatem, że:
Możemy tę parę wzorów przyjąć za definicję funkcji silnia. Takie definiowanie nazywamy rekurencyjnym lub indukcyjnym. My jednak słowo indukcja wolimy zachować do określenia sposobu dowodzenia. Będziemy więc mówili o rekurencyjnych definicjach i indukcyjnych dowodach.
Dla przykładu, jeżeli ciąg zdefiniujemy rekurencyjnie następująco:
,
to używając metody indukcji matematycznej, możemy łatwo udowodnić, że .
Napotykamy jednak często na problemy, które w odróżnieniu od funkcji silnia, czy ciągu , nie mają prostej, nierekurencyjnej postaci, albo wręcz nie jest ona nam znana. Dla przykładu liczby Fibonacciego znane są co najmniej od 1202 roku, i generowanie kolejnych liczb bezpośrednio ze wzoru rekurencyjnego nie stanowi żadnego problemu, jednak dopiero Euler, a niezależnie od niego w 100 lat później w roku 1843 francuski matematyk J.Binet udowodnił wzór bezpośrednio wyliczający n-tą liczbę Fibonacciego:
Znajomość tego wzoru wcale nie rozwiązuje nam wszystkich problemów związanych z wyliczeniem n-tej liczby Fibonacciego. Przede wszystkim komputer mógłby mieć trudności ze stwierdzeniem, że wynik jest liczbą naturalną, choć w istocie powyższy wzór Eulera-Bineta generuje jedynie liczby naturalne.
Gdyby bowiem próbował wyciągać pierwiastek z pięciu, to nieuniknione stałoby się zaokrąglenie wyniku i w rezultacie moglibyśmy otrzymać wartość lekko różniącą się od liczby naturalnej, która byłaby spodziewanym wynikiem obliczeń. Poza tym lekką przesadą możnaby nazwać używanie tak skomplikowanych operacji jak potęgowanie liczb niewymiernych w celu uzyskania w miarę prostej liczby naturalnej.
Ponieważ stosujemy zasadę, że to komputer powinien dostosowywać się w miarę możliwości do potrzeb człowieka, więc mechanizmy pozwalające na korzystanie z rekurencji istnieją w większości współczesnych języków programowania. W Pascalu użycie rekurencji jest niezwykle naturalne:
function silnia(n:Integer):Integer; begin if n=0 then silnia := 1 else silnia := n*silnia(n-1) end;
Wywołanie tej funkcji od pewnego naturalnego argumentu
spowoduje, że identyfikatorowi silnia zostanie nadana
wartość . Stanie się to w następujący sposób. Najpierw
sprawdzimy, czy równa się zero. Jeżeli tak, to wynik
wyniesie 1. Jeżeli nie, to wywołana zostanie funkcja silnia od parametru , a jej wynik przemnożony zostanie
przez dając ostateczną wartość funkcji. Wykonanie
instrukcji mnożenia przez zostanie więc zawieszone na czas
obliczenia wartości . Zawartość sumatora i
wszystkich rejestrów używanych przez komputer do obliczeń
zostaną więc automatycznie zapamiętane przez system wykonujący
program tak, że wykona się w czystym
środowisku. Po obliczeniu silni z , zawartość stanu
komputera zostanie odtworzona i mnożenie przez będzie mogło
zostać zakończone.
Rzecz jasna zostanie obliczona w analogiczny sposób. Rolę wartości przejmie , zatem wywoła się w miarę potrzeby itd. Widać więc, że do wywołania funkcji silnia od jakiegoś dużego parametru wymaga się zawieszenia wykonywania mnożeń na wielu poziomach wywołań rekurencyjnych (po jednym dla każdego ).
Zauważmy, że podobnie jak w przypadku źle skonstruowanych pętli, możemy nabawić się kłopotów wywołując funkcję silnia od argumentu ujemnego. System zacznie wtedy wywoływać kaskadę silni wołanych dla parametrów ujemnych coraz bardziej oddalonych od zera. Gdyby pamięć komputera była nieskończona, spowodowałoby to nieskończone zapętlenie się programu. Każde wywołanie funkcji wymaga jednak zapamiętania aktualnego stanu komputera. Zużywa to dostępną pamięć blokując potrzebny jej fragment do końca wywołania funkcji; w naszym przypadku ten koniec nigdy nie nastąpi. Program zostanie zatem zerwany przez system wykonujący z powodu braku pamięci. Nie jest to jednak jedyna groźba, którą napotykamy stosując rekurencję. Znacznie poważniejszym problemem może okazać się nieprawidłowa organizacja rekurencji powodująca brzemienne w skutkach zużycie czasu wykonywanego programu.
Spróbujmy zastosować technikę rekurencyjną do napisania funkcji obliczającej n-tą liczbę Fibonacciego .
function Fibo(n:integer):Integer; {funkcja liczy n-ta liczbe Fibonacciego dla n>=0} begin if n <= 1 then Fibo := n else Fibo := Fibo(n-2) + Fibo(n-1) end; {Fibo}
Na pierwszy rzut oka widać, że funkcja powinna dobrze
zadziałać. Została napisana zgodnie z podaną wyżej definicją
liczb Fibonacciego. Spróbujmy zatem prześledzić, jak będzie
się wykonywać dla .
Aby obliczyć wartość , będziemy musieli wywołać Fibo dla i dla , a następnie dodać te wartości.
Zauważmy jednak, że obliczywszy weźmiemy się
za liczenie od nowa. Do policzenia
będziemy jednak potrzebowali wartości oraz . Ponieważ komputer nie otrzymał od nas żadnych
wskazówek dotyczących wykorzystania raz już obliczonych
wartości, więc zacznie od nowa obliczać .
Ta drobna niegospodarność będzie nas dużo kosztować. Liczba wywołań funkcji Fibo będzie bowiem proporcjonalna do wartości wykładniczej ze względu na . Oznacza to, jak już wiemy, że nawet dla niewielkich stosunkowo danych, rzędu 100, żaden komputer na świecie nie poradzi sobie z zakończeniem obliczeń, niezależnie od tego jak jest szybki i jak wiele czasu mu damy.
Cały problem będzie rozwiązany bezboleśnie, jeżeli tylko nie dopuścimy do więcej niż jednokrotnego wywołania funkcji dla tych samych danych. Wystarczy zatem stworzyć bank danych o obliczonych już wartościach . W tym celu zadeklarujemy sobie tablicę , w której będziemy przechowywali obliczone już wartości . Aby zaznaczyć, że dana wartość nie została jeszcze obliczona, wypełnimy tablicę minus jedynkami. Funkcję Fibo będziemy zatem liczyli rekurencyjnie jedynie w miarę potrzeby, czyli wtedy, gdy dla danego argumentu liczymy ją po raz pierwszy. Zmodyfikujmy zatem naszą funkcję.
var F:array[0..m] of Integer; function Fibo1(n:integer): Integer; { funkcja liczy n-ta liczbe Fibonacciego dla n>=0, korzystając przy tym z globalnej tablicy F, przy czym F[i] = F_i, jezeli F_i jest juz obliczone F[i] = -1, jezeli nie jest jeszcze obliczone Założenie: n>=0 } begin if F[n]<0 then {Wartosc F[n] nie jest jeszcze obliczona, więc zaczniemy od wypełnienia tablicy właściwą wartością} if n <= 1 then F[n]:=n else F[n] := Fibo1(n-2) + Fibo1(n-1); {i teraz dopiero nadamy odpowiednią wartośc identyfikatorowi Fibo. W tablicy F[n] znajduje się zawsze własciwa wartość} Fibo1 := F[n] end; {Fibo1}
Wykonanie tego programu dla paru danych szybko przekona nas o
skuteczności tego ulepszenia. Tym razem powinniśmy także uważać
na stosunkowo nieduży zakres typu integer, i jeżeli chcemy obliczać
większe liczby Fibonacciego, musimy użyć innego typu (np.
longint).
Rzecz jasna istnieją znacznie prostsze metody pozwalające nam na obliczenie n-tej liczby Fibonacciego. Podobnie jak w przypadku silni, można to zrobić jedną prostą pętlą z czterema przypisaniami. Są jednak problemy, dla których znalezienie nierekurencyjnego rozwiązania ani nie jest proste, ani warte zachodu ze względu na elegancję i efektywność rekurencji w tych przypadkach.
Wielu znane jest zapewne zadanie o wieżach z Hanoi. Legenda głosi, że w pewnej świątyni buddyjskiej w Hanoi znajdują się trzy wbite w ziemię pręty. Na jednym z nich początkowo umieszczono 64 koła o malejących średnicach. Należy te koła przenieść z pierwszego pręta na drugi przy pomocy trzeciego, respektując następujące zasady:
- jednorazowo można przenieść jeden krążek
- krążek można nałożyć na dowolny z prętów pod
warunkiem, że kładzie się go na pusty pręt lub na krążek o większej średnicy. Ponoć mnisi przekładają od jakiegoś czasu te krążki, a gdy skończą, nastąpi koniec świata.
Jak można wyobrazić sobie najprostszy algorytm przekładający w możliwie szybki sposób krążki? Przenieść jeden krążek to żadna sztuka. Jak jednak poradzić sobie z ich większą liczbą? Załóżmy indukcyjnie, że umiemy tego dokonać dla krążków. Rekurencyjnie nasz algorytm formułuje się bardzo prosto. Aby przełożyć wieżę złożoną z n krążków z jednego pręta na drugi przy pomocy trzeciego, należy krążków przełożyć na pręt trzeci, następnie przełożyć krążek na pręt drugi, a następnie -elementową wieżę przełożyć z pręta trzeciego na drugi przy pomocy pierwszego.
Zauważmy przy okazji, że liczba przenosin krążków, , wymaga dwukrotnego przeniesienia wieży złożonej z krążków oraz jednokrotnego przeniesienia jednego krążka. Mamy więc wzór:
zatem
Liczba przenosin jest więc wykładnicza ze względu na . Możemy spać spokojnie. Nawet jeżeli mnisi będą bardzo wydajni, to prędzej nastąpi koniec Układu Słonecznego, aniżeli przełożenie tych 64 krążków (zakładając, że mnisi zaczęli w czasach historycznych).
Procedura, która dokona wygenerowania kolejnych ruchów przenoszących n-elementową wieżę może więc wyglądać następująco. Zakładamy, że zamiast dźwigania krążków komputer będzie wypisywał kolejne ruchy w postaci przenieś krążek z pręta na pręt .
procedure Hanoi (n, skad, dokad:integer); { procedura przenosi n krążkow z wieży skąd na wieżę dokąd } procedure przenies (co, skad, dokad:integer); { Tu rzecz jasna można wpisać dowolnie inteligentną procedurę} begin writeln ('PRZENIES ', CO, ' Z ', skad, ' NA ', dokad) end; begin if n<=1 then przenies (1, skad, dokad) else begin {zauważmy, że jeżeli jeden pręt ma numer i, a drugi j, to trzeci z nich ma numer 6-i-j} hanoi (n-1, skad, 6-skad-dokad); przenies (n, skad, dokad ); hanoi (n-1, 6-skad-dokad, dokad) end end;
Nasza procedura będzie teraz działała w czasie wykładniczym ze
względu na . Dzieje się tak w zasadzie zawsze, gdy bez
żadnych finezji dokonujemy we wnętrzu procedury więcej niż
jednokrotnego wywołania rekurencyjnego tej procedury. Tym razem
jednak nie będzie to stanowiło wady rozwiązania - taka jest
natura problemu: odpowiedź w postaci ciągu ruchów do wykonania
ma długość wykładniczą ze względu na , więc trudno tu
cokolwiek poprawić. Rzecz jasna procedurę należy wywoływać
dla danych znacznie mniejszych niż 64.