5. Rekurencja Uwaga! W tym rozdziale nie są omówione żadne nowe konstrukcje języka C++. Omówiona jest za to technika wykorzystująca funkcje, która pozwala na rozwiązanie pewnych nowych rodzajów zadań. Przed rozpoczęciem pracy nad tym rozdziałem upewnij się, że rozumiesz funkcje i instrukcje warunkowe. Bez tej wiedzy będziesz mieć problemy ze zrozumieniem materiału zawartego w tym rozdziale. Jeżeli potrzebujesz, rozwiąż więcej zadań z poprzedniego rozdziału. Przykłady 5.1. Napisz program, który będzie w nieskończoność wypisywać kolejne liczby naturalne, począwszy od 0, po jednej liczbie na linię. 1 #include <iostream> 2 using namespace std; 3 4 void wypisznaturalneod(int n) { 5 cout << n << endl; 6 wypisznaturalneod(n+1); 7 } 8 9 int main() { 10 wypisznaturalneod(0); 11 12 return 0; 13 } Uwaga! Program ten nigdy sam nie zakończy swojego działania. Aby zakończyć jego działanie wciśnij kombinację klawiszy Ctrl+C. Rozwiązanie tego zadania może na pierwszy rzut oka wyglądać bardzo nietypowo. Wykorzystuje ono bardzo popularną technikę zwaną rekurencja. W celu wypisania wszystkich kolejnych liczb naturalnych tworzymy funkcję, która pozwoli wypisać nam wszystkie liczby naturalne począwszy od pewnej 31
danej liczby. Funkcja ta, wypisznaturalneod nie zwraca żadnej wartości (wypisuje jedynie liczby), a jako argument przyjmuje początkową liczbę, od której chcemy zacząć wypisywanie. W ciele tej funkcji znajdują się jedynie 2 linie kodu. Pierwsza z nich odpowiada za wypisanie pierwszej z liczb (czyli n, od którego mieliśmy zacząć). Druga linia wywołuje zaś tę samą funkcję, jednak z argumentem o jeden większym, co wypisze na ekran wszystkie pozostałe liczby naturalne, które mamy do wypisania. Rekurencja polega na wywoływaniu funkcji wewnątrz jej własnej definicji. Pozwala to na wielokrotne wykonanie tego samego fragmentu kodu. Funkcję, która wywołuje sama siebie nazywamy rekurencyjna. Funkcje rekurencyjne z reguły podążają za utartym schematem: jeżeli potrafię znaleźć rozwiązanie dla n, to jakie będzie rozwiązanie dla n + 1? 5.2. Napisz program, który będzie odliczać w dół do 0 od pewnej zadanej liczby. Program ma pobrać od użytkownika liczbę n, a następnie wypisać na ekran liczby od n do 0 włącznie, w kolejności malejącej, po jednej liczbie na linię. 1 #include <iostream> 2 using namespace std; 3 4 void odliczod(int n) { 5 if (n < 0) { 6 return; 7 } 8 9 cout << n << endl; 10 odliczod(n-1); 11 } 12 13 int main() { 14 int n; 15 cin >> n; 16 17 odliczod(n); 18 19 return 0; 20 } Program z poprzedniego przykładu nie był zbyt przydatny, jako że nigdy nie kończył swojego działania. Nie posiadał on tzw. podstawy rekurencji, a jedynie krok rekurencyjny. 32
W tym przykładzie chcemy wypisywać kolejno coraz mniejsze liczby, stąd wywołanie w linii 10 zawiera n-1, a nie n+1 jak w poprzednim przykładzie. Tym razem jednak chcemy zakończyć wypisywanie liczb, jeżeli n przekroczy 0. Dlatego oprócz zmiany plusa na minus, do funkcji dodane zostały jeszcze dodatkowe linie kodu (5-7), które sprawdzają czy argument funkcji nie jest ujemny. Jeżeli jest, działanie funkcji jest przerywane, a co za tym idzie, kolejne liczby nie są wypisywane na ekran. Taki warunek nazywamy podstawą rekurencji i z reguły umieszczamy go na samym początku funkcji rekurencyjnej. Uwaga! Warunek w linii 5 moglibyśmy zastąpić przez n == -1 i program działałby wciąż poprawnie. Jednak gdyby ktoś poprosił cię o zmianę tego kodu tak, aby wypisywał co drugą liczbę, konieczna byłaby modyfikacja tego warunku na n == -1 n == -2. Unikaj używania warunków z == lub!= w podstawie rekurencji (a później także w pętlach). Czasem jest to niezbędne, jednak w większości przypadków lepiej jest taki warunek zastąpić przez jedną z nierówności. Całą resztę kodu funkcji rekurencyjnej nazywamy krokiem rekurencji. 5.3. Napisz program, który wypisze kolejne liczby naturalne od a do b włącznie, w kolejności rosnącej, gdzie a i b są dane przez użytkownika. 1 #include <iostream> 2 using namespace std; 3 4 void wypisznaturalne(int start, int koniec) { 5 if (start > koniec) { 6 return; 7 } 8 9 cout << start << endl; 10 wypisznaturalne(start+1, koniec); 11 } 12 13 int main() { 14 int start, koniec; 15 cin >> start >> koniec; 16 17 wypisznaturalne(start, koniec); 18 19 return 0; 20 } 33
Tym razem nasza funkcja rekurencyjna przyjmuje dwa argumenty: granice przedziału, którego elementy mamy wypisać. Zacznijmy od omówienia kroku rekurencji (linie 9-10). Przypomina on rozwiązanie przykładu 5.1. Wypisujemy pierwszą liczbę przedziału, a potem rekurencyjnie wypisujemy całą resztę. Tym razem musimy jednak pamiętać o przekazaniu również drugiego argumentu koniec, który jednak nie zmienia się. Wypisywanie powinno zakończyć się w momencie, kiedy kolejna liczba do wypisania (tj. start) jest większa niż ostatnia liczba, która miała być wypisana (czyli koniec). Stąd podstawa rekurencji (linie 5-7) sprawdza czy start jest większe od koniec i jeżeli tak, to kończy działanie funkcji bez wypisywania żadnej wartości na ekran. Głowna funkcja programu sprowadza się do pobrania od użytkownika dwóch liczb, a następnie przekazania ich do zdefiniowanej już funkcji wypisznaturalne. 5.4. Dana jest plansza o wymiarach 2 n 2 n, gdzie n > 0, z wyciętym jednym z pól, oraz dowolna liczba klocków w kształcie litery L złożonej z 3 kwadratów. W jaki sposób dokładnie przykryć planszę klockami tak, aby żaden klocek nie zakrywał wyciętego pola ani nie wystawał poza planszę? Klocki nie mogą na siebie nachodzić, ale można dowolnie je obracać. Uwaga! Ten przykład nie zawiera kodu, jako że zadanie nie prosi o jego napisanie. Jeżeli czujesz się na siłach, możesz spróbować napisać program, który będzie konstruował i prezentował odpowiedni układ klocków dla danej planszy. Wymaga to jednak nieco wiedzy wykraczającej poza dotychczasowy materiał. Zaczniemy od rozwiązania najprostszego przypadku, gdzie n = 1, czyli plansza ma rozmiar 2 2. Następnie na podstawie prostszych rozwiązań będziemy konstruować rozwiązania dla coraz większych plansz. Przypadek bazowy jest trywialnie prosty. Mamy planszę 2 2 z jednym z pól wyciętym. Cała reszta planszy ma więc dokładnie taki sam kształt jak nasz klocek, a więc wystarczy go tam położyć. Następnie załóżmy, że mamy rozwiązanie dla n i spróbujmy znaleźć rozwiązanie dla n+1. Dzielimy planszę na 4 części: na pół w pionie i w poziomie, otrzymując cztery ćwiartki. 34
Dostajemy więc 4 plansze, każda o rozmiarach 2 n 2 n, a taką potrafimy zapełnić klockami tak, aby dowolne pole pozostało wolne. W jednej z ćwiartek znajduje się pole, które musi pozostać puste, więc zapełniamy ją odpowiednio. Na pozostałych 3 ćwiartkach nie mamy żadnych pól, które powinny pozostać puste. Możemy je sobie wybrać tak, aby w pozostałą lukę dało się umieścić jeszcze jeden klocek. W każdej z tych 3 ćwiartek zostawiamy wolne pole przy samym środku planszy, co da nam lukę w kształcie klocka, którą możemy w prosty sposób zapełnić. Wiemy, że dla dowolnego n da się wypełnić planszę, jeżeli da się to zrobić dla n 1. Wiemy także, że jest to możliwe dla n = 1. Stąd wiemy, że da się to zrobić dla n = 2 (bo da się dla 2 1 = 1), a więc także dla n = 3 (bo da się dla 3 1 = 2) itd. W ten sposób nie tylko udowodniliśmy (przez indukcję), że żądany układ istnieje dla każdego n > 0, ale także znaleźliśmy rekurencyjny sposób na jego skonstruowanie. 35
5.5. Napisz program, który obliczy wartość silni z danej liczby. 1 #include <iostream> 2 using namespace std; 3 4 int silnia(int n) { 5 if (n == 0) { 6 return 1; 7 } 8 9 return n * silnia(n-1); 10 } 11 12 int main() { 13 int n; 14 cin >> n; 15 cout << silnia(n); 16 17 return 0; 18 } Rekurencję spotykamy nie tylko w programowaniu, ale i w matematyce. Niektóre funkcje, jak silnia, zdefiniowane są rekurencyjnie. To znaczy że definicja takiej funkcji odwołuje się do tej właśnie funkcji. Uwaga! Silnia z n (zapisujemy n!) to iloczyn wszystkich kolejnych liczb naturalnych od 1 do n włącznie. Silnia zdefiniowana jest następująco: { 1 gdy n = 0 n! = n (n 1)! gdy n 0 Definicję tę możemy zapisać w C++ w niemal niezmienionej formie, jak widać na przykładzie powyżej. Sprawdzamy najpierw czy n == 0. Jeżeli tak, zwracamy 1, jeżeli nie, zwracamy iloczyn n i silni z n-1. Uwaga! Drugie z wyrażeń return moglibyśmy umieścić w bloku else, jednak nie jest to konieczne. Jeżeli warunek podstawy rekurencji jest spełniony, to i tak program nie dotrze nigdy do kodu kroku rekurencji. 36
5.6. Ile jest liczb naturalnych nie większych od n, które podzielne są przez 5, ale nie są podzielne przez 3? Napisz program, który obliczy wynik dla n danego przez użytkownika. 1 #include <iostream> 2 using namespace std; 3 4 int ileniewiekszych(int n) { 5 if (n < 0) { 6 return 0; 7 } 8 9 if (n % 5 == 0 && n % 3!= 0) { 10 return 1 + ileniewiekszych(n-1); 11 } else { 12 return ileniewiekszych(n-1); 13 } 14 } 15 16 int main() { 17 int n; 18 cin >> n; 19 20 cout << ileniewiekszych(n) << endl; 21 22 return 0; 23 } Przy użyciu rekurencji możemy w łatwy sposób policzyć ile liczb w danym przedziale spełnia pewien warunek. Wystarczy rekurencyjnie przejść kolejno po wszystkich liczbach w przedziale (jak w przykładach 5.1, 5.2 i 5.3) i dla każdej z nich sprawdzić, czy warunek jest spełniony. Jeżeli tak, do ostatecznego wyniku dodajemy 1. Jeżeli nie, nie dodajemy nic. Dokładnie w ten sposób działa krok rekurencyjny funkcji w tym rozwiązaniu. Sprawdzamy czy obecnie rozpatrywana liczba spełnia warunek (linia 9) i dodajemy 1 do wyniku dla liczby o jeden mniejszej (linia 10) lub po prostu zwracamy wynik dla n 1 (linia 12). W podstawie rekurencji sprawdzamy, czy dana liczba jest mniejsza zero. Jeżeli tak, to nie musimy szukać już dalej, jako że interesują nas wyłącznie liczby naturalne, a poniżej 0 ich nie ma, stąd wynik wynosi 0. 37
Pytania 5.1. Co stanie się, jeżeli wywołamy odliczod(-1)? 5.2. Co stanie się, jeżeli wywołamy silnia(-1)? 5.3. Z czego wynika różnica w odpowiedziach na poprzednie dwa pytania? 5.4. Co wypisze na ekran wywołanie silnia(20)? Czy jest to prawidłowy wynik? Jaka jest największa wartość n dla której funkcja z przykładu 5.5 zwróci prawidłowy wynik? Dlaczego dla wartości większych otrzymujemy błędny wynik? 5.5. Czy zmiana typu zwracanego z int na double w funkcji silnia jest dobrym rozwiązaniem problemu z poprzedniego pytania? 5.6. Przeczytaj w internecie o wieżach Hanoi, na przykład na Wikipedii. Opisz własnymi słowami, ale zwięźle, w jaki sposób rekurencyjnie rozwiązać ten problem dla n krążków. Narysuj kolejne kroki dla n = 3. Zadania 5.1. (+50) Napisz program, który wypisze liczby od danego n do 1 włącznie, przy czym każda liczba podzielna przez 3 powinna zostać zastąpiona słowem fizz. 5.2. (+60) Napisz program, który rekurencyjnie obliczy sumę liczb naturalnych od 1 do danego n. Nie używaj wzoru Gaussa (tj. n(n+1) 2 ). 5.3. (+60) Napisz program, który obliczy wartość k-tej potęgi 2, gdzie k jest liczbą naturalną daną jako argument funkcji. Nie używaj cmath. 5.4. (+70) Napisz program, który obliczy wartość k-tej potęgi n, gdzie k i n są danymi liczbami naturalnymi. Nie używaj cmath. 5.5. (+75) Napisz program, który obliczy wartość funkcji Ackermanna A(m, n) zdefiniowanej następująco: n + 1 gdy m = 0 A(m, n) = A(m 1, 1) gdy m > 0 i n = 0 A(m 1, A(m, n 1)) gdy m > 0 i n > 0 5.6. (+70) Napisz program, który pobierze od użytkownika liczbę całkowitą, a następnie wypisze jej cyfry, zaczynając od jedności, po jednej cyfrze na linię. 38
5.7. (+70) Algorytm Euklidesa. Największy wspólny dzielnik (NWD) dwóch liczb to największa taka liczba naturalna, która dzieli bez reszty obie te liczby. Wiemy, że NWD(a, 0) = a oraz że NWD(a, b) = NWD(b, a mod b), gdzie a mod b to reszta z dzielenia a przez b. Napisz program, który znajdzie NWD dwóch danych liczb. 5.8. (+80) Ciąg Collatza zdefiniowany jest następująco: pierwsza liczba ciągu jest dowolną liczbą naturalną. Następnie każda kolejna wartość ciągu obliczana jest na podstawie poprzedniej wedle następującej reguły: jeżeli poprzednia wartość jest parzysta, nowa wartość stanowi jej połowę, w przeciwnym razie jej trzykrotność powiększoną o 1. Przykład. Jeżeli rozpoczniemy od 6 uzyskamy: 6, 3, 10, 5, 16, 8, 4, 2, 1. Napisz program, który dla danego pierwszego elementu ciągu wypisze kolejne elementy tego ciągu aż do napotkania pierwszej jedynki. 5.9. (+60) Napisz funkcję, która policzy wartość n-tej liczby Fibonacciego (F n ). Dla uściślenia: F 0 = 0, F 1 = 1, F n = F n 1 + F n 2. 5.10. (+60) Napisz program, który wypisze kolejne liczby naturalne od a do b włącznie, w kolejności malejącej, gdzie a i b to dane liczby naturalne. 5.11. (+80) Napisz program, który wypisze wszystkie dzielniki danej liczby naturalnej. Podpowiedź: konieczne może być napisanie dwóch funkcji. Jedna z nich przyjmuje dwa argumenty: wartość dzielnej i potencjalnego dzielnika, która rekurencyjnie sprawdza kolejne dzielniki. Druga funkcja przyjmuje tylko jeden argument i wywołuje pierwszą funkcję z danym argumentem i wartością pierwszego potencjalnego dzielnika. 5.12. (+100) Potęgę o wykładniku naturalnym możemy zdefiniować następująco: 1 gdy k = 0 n k = n k 2 n k 2 gdy 2 k n k 1 2 n k 1 2 n gdy 2 k Napisz funkcję, która obliczać będzie potęgi o wykładnikach naturalnych używając tej definicji. Upewnij się, że w każdym przypadku wykonujesz tylko jedno wywołanie rekurencyjne (np. przez zapisanie wyniku w zmiennej). Nie używaj cmath. 5.13. (+60) Ile jest liczb naturalnych mniejszych od n, które podzielne są przez 5, ale nie są podzielne przez 3? Napisz program, który obliczy wynik dla n danego przez użytkownika. 39
5.14. (+80) Jeżeli wypiszemy wszystkie liczby naturalne mniejsze od 10, które są wielokrotnościami 3 lub 5, otrzymamy 3, 5, 6 i 9. Suma tych liczb wynosi 23. Napisz program, który znajdzie sumę wszystkich wielokrotności 3 i 5 poniżej 1000. (Project Euler, zadanie 1) 5.15. (+100) Suma kwadratów pierwszych 10 liczb naturalnych wynosi 1 2 + 2 2 +... + 10 2 = 385 Natomiast kwadrat sumy pierwszych 10 liczb naturalnych wynosi (1 + 2 +... + 10) 2 = 3025 Stąd różnica między sumą kwadratów pierwszych 10 liczb naturalnych a kwadratem ich sumy wynosi 3025 385 = 2640. Znajdź różnicę między sumą kwadratów a kwadratem sumy dla pierwszych 100 liczb naturalnych. Podpowiedź: przydatne będzie tu napisanie więcej niż jednej funkcji. (Project Euler, zadanie 6) 5.16. (+120) Mamy do dyspozycji monety o nominałach 5zł i 2zł. Napisz program, który obliczy minimalną liczbę monet jaka jest niezbędna do wydania reszty w kwocie danej przez użytkownika. Program powinien wypisać słowa nie da się, jeżeli danej kwoty nie da się wydać przy użyciu danych nominałów. Upewnij się, że twój program zwraca prawidłowy wynik dla kwot takich jak 6zł (3 monety) czy 21zł (6 monet). 5.17. (+120) Napisz program, który wypisze na ekran tabliczkę mnożenia do 100. Rozszerzenie 5.1. (+210) Napisz funkcję, która przybliży wartość liczby π ze wzoru Leibniza: π 4 = ( 1) n n=0 2n+1. 5.2. (+340) Napisz funkcję, która obliczy przybliżoną wartość logarytmu naturalnego z danej liczby zmiennoprzecinkowej (double). 5.3. (+550) Zaimplementuj algorytm sortowania przez scalanie. 40