Rekurze Jan Hnilica Počítačové modelování 12 1
Rekurzivní charakter úlohy Výpočet faktoriálu faktoriál : n! = n (n - 1) (n - 2)... 2 1 (0! je definován jako 1) můžeme si všimnout, že výpočet n! obsahuje vnořené výpočty dalších faktoriálů: 6! = 6 5 4 3 2 1 5! = 5 4 3 2 1 4! =... pro n 1 můžeme napsat: n! = n (n - 1)! Rekurzivní úloha řešení úlohy obsahuje řešení dílčích podúloh stejného charakteru podobný rekurzivní chrakter bychom nalezli v řadě úloh - ciferný součet: CS(n) = n % 10 + CS(n / 10) // dokud n 0 - celočíselná mocnina Mocnina(n, exp) = n Mocnina(n, exp - 1) // dokud exp > 0 - průchod lineárním seznamem atd... Jan Hnilica Počítačové modelování 12 2
Rekurzivní funkce jazyk C (a řada dalších jazyků) umožňuje vytvářet tzv. rekurzivní funkce rekurzivní funkce = funkce, která ve svém těle volá sebe samu Rekurzivní funkce pro výpočet faktoriálu: vyjdeme z rekurzivní definice: n! = 1 // pro n == 0 = n (n - 1)! // pro n > 0 kterou ve funkci opíšeme : int Faktorial(int n) if (n < 2) return 1; return n * Faktorial(n - 1); Důležité: funkce má definovanou ukončovací podmínku, při jejímž splnění se další rekurzivní volání už neprovede při rekurzivním volání se mění parametry volané funkce, vždy dochází ke zjednodušení (zmenšení) úlohy Jan Hnilica Počítačové modelování 12 3
Co se děje při rekurzivním volání funkce totéž co u normálního volání funkcí, tzn. při volání funkce je na systémovém zásobníku vytvořen záznam, obsahující lokální proměnné volané funkce, návratovou adresu výsledku atd... volající funkce zastaví provádění kódu a čeká, až volaná funkce dokončí svou činnost poté co volaná funkce skončí, pokračuje volající funkce ve svém kódu při rekurzivním volání volající funkce zavolá svou rekurzivní odnož a čeká, až ta dokončí svoji práci volaná rekurzivní odnož postupuje úplně stejně, také volá další odnož a čeká, takto se postupuje dál, dokud čerstvě volaná funkce nenarazí na ukončovací podmínku najednou je rozpracováno více úloh, které všechny postupují podle téhož kódu při návratu nejdříve skončí funkce, volaná jako poslední, ta vrací řízení předposledně volané funkci... nejpozději skončí ta funkce, která byla volána jako první každá funkce má na zásobníku vlastní záznam (vlastní lokální proměnné atd.) jednotlivé funkce nemají přístup k lokálním proměnným jiných funkcí, můžou ale sdílet proměnné předávané odkazem (nebo globální proměnné) Jan Hnilica Počítačové modelování 12 4
F (4) čeká F (3) čeká F (2) čeká Co se děje při rekurzivním volání funkce Faktorial(4) čas = 4 Faktorial(3) = 3 Faktorial(2) = 2 Faktorial(1) 1 24 F(3) končí 6 F(2) končí 2 F(1) končí F(4) končí Poznámka: Nákres ilustruje fakt, že funkce, která byla volána jako první, dokončí svou práci jako poslední a naopak! (systém zásobníku) Jan Hnilica Počítačové modelování 12 5
Ilustrace rekurzivních volání void Pozpatku() char znak = getchar(); if (znak!= '\n') Pozpatku(); putchar(znak); 1. funkce načte znak na vstupu 2. načtený znak je - konec řádku => funkce končí - něco jiného => funkce volá sebe samu a až poté, co je vráceno řízení, vypíše načtený znak (později volané funkce mezitím načetly a vypsaly vstupní znaky až do konce řádku) výsledkem je otočení řetězce zadaného na vstupu: vstup: abcd výstup: bcda Jan Hnilica Počítačové modelování 12 6
Příklady jednoduchých rekurzivních funkcí Euklidův algoritmus pro výpočet největšího společného dělitele (NSD) jsou zadána dvě celá čísla A a B, úkolem je nalézt jejich NSD - největší číslo, které beze zbytku dělí jak A, tak B pro výpočet NSD se nabízí několik postupů 1. postupné zkoušení všech možných dělitelů 2. vypočítat prvočíselné rozklady A a B, NSD je jejich největší společná část např. 126 = 2 3 3 7 78 = 2 3 13 NSD (126, 78) = 6 3. Euklidův algoritmus: pokud A == B NSD(A, B) = A pokud A > B NSD(A, B) = NSD(A - B, B) pokud B > A NSD(A, B) = NSD(A, B - A) NSD(126, 78) = NSD(48, 78) = NSD(48, 30) = NSD(18, 30) = NSD(18, 12) = = NSD(6, 12) = NSD (6, 6) = 6 Jan Hnilica Počítačové modelování 12 7
Příklady jednoduchých rekurzivních funkcí Euklidův algoritmus pro výpočet největšího společného dělitele (NSD) rekurzivní předpis algoritmu: pokud A == B NSD(A, B) = A pokud A > B NSD(A, B) = NSD(A - B, B) pokud B > A NSD(A, B) = NSD(A, B - A) snadno přepíšeme na rekurzivní funkci: int NSD(int a, int b) if (a == b) return a; if (a > b) return NSD(a - b, b); else return NSD(a, b - a); Jan Hnilica Počítačové modelování 12 8
Příklady jednoduchých rekurzivních funkcí Průchod lineárním seznamem uvažujme jednoduchý LS sestavený z prvků: typedef struct prvek char znak; struct prvek * dalsi; Prvek; průchod seznamem (spojený např. s výpisem prvků) lze napsat takto: void VypisSeznam(Prvek * S) if (S!= NULL) printf("%c ", S->znak); VypisSeznam(S->dalsi); Jan Hnilica Počítačové modelování 12 9
Použití rekurze rekurze se obvykle nepoužívá k řešení úloh, které jdou snadno vyřešit cyklem všechny dosud uvedené funkce jdou snadno napsat i bez rekurze Euklidův NSD: int NSD(int a, int b) while (a!= b) if (a > b) a = a - b; else b = b - a; return a; Faktoriál: int Faktorial(int n) int f = 1; for (int i = n; i > 1; i--) f = f * i; return f; Jan Hnilica Počítačové modelování 12 10
Použití rekurze je nutné si uvědomit, že volání rekurzivních funkcí sebou nese určité náklady na čas a paměť (vytvoření proměnných na zásobníku, návratová adresa, skok do funkce, návrat z funkce...) lze-li úlohu jednoduše řešit bez použití rekurze, je takové řešení efektivnější v ne-rekurzivních algoritmech se většinou také snáze hledají chyby rekurze se obvykle používá v těchto případech: úlohy typu "vygeneruj všechny možnosti" prohledávání do hloubky (backtracking) algoritmy "rozděl a panuj" Jan Hnilica Počítačové modelování 12 11
Kombinatorické úlohy (generování všech možností) pro nácvik rekurze se dobře hodí úlohy na generování základních kombinatorických struktur, zároveň jde také o úlohy, kde je rekurze přirozeným a jednoduchým řešením problému, který by šel iterací řešit jen velmi těžko nejprve připomenutí základních pojmů: - máme n-prvkovou množinu, v našem případě čísla 1, 2,.., n - z této množiny budeme provádět k-prvkové výběry (k n), přičemž rozlišujeme: variace - ve výběru záleží na pořadí prvků, tzn. (1, 2, 3) je jiná variace než (1, 3, 2) kombinace - ve výběru na pořadí prvků nezáleží, tzn. (1, 2, 3) a (1, 3, 2) jsou tytéž kombinace u variací i kombinací dále rozlišujeme varianty a) s opakováním - prvky ve výběru se mohou opakovat (např. 1, 1, 2, 3) b) bez opakování - prvky se opakovat nemohou permutace - jsou variace bez opakování pro n = = k - jinak řečeno jde o záměny pořadí původní množiny prvků Jan Hnilica Počítačové modelování 12 12
Kombinatorické úlohy (generování všech možností) Příklad: n = 4, k = 2, provádíme tedy 2-prvkové výběry z množiny 1, 2, 3, 4 variace s opakováním (1,1) (1,2) (1,3) (1,4) (2,1) (2,2) (2,3) (2,4) (3,1) (3,2) (3,3) (3,4) (4,1) (4,2) (4,3) (4,4) variace bez opakování (1,2) (1,3) (1,4) (2,1) (2,3) (2,4) (3,1) (3,2) (3,4) (4,1) (4,2) (4,3) kombinace s opakováním (1,1) (1,2) (1,3) (1,4) (2,2) (2,3) (2,4) (3,3) (3,4) (4,4) kombinace bez opakování (1,2) (1,3) (1,4) (2,3) (2,4) (3, 4) Jan Hnilica Počítačové modelování 12 13
Kombinatorické úlohy (generování všech možností) Variace s opakováním úloha: vygenerovat všechny k-prvkové variace z množiny čísel 1, 2,..., n postup: na každou z k pozic postupně umístíme všechny hodnoty 1... n úloha řešitelná pomocí vnoření k cyklů, ale pouze za předpokladu, že k a n jsou předem pevně zadány: for (int c1 = 1; c1 <= n; c1++) for (int c2 = 1; c2 <= n; c2++) for (int c3 = 1; c3 <= n; c3++)... for (int ck = 1; ck <= n; ck++) printf("%i %i %i...%i", c1, c2, c3,..., ck); pokud jsou k a n vstupními parametry programu, nelze vnořené cykly použít (není předem známo, kolik jich do kódu napsat a do jaké meze mají probíhat) Jan Hnilica Počítačové modelování 12 14
Kombinatorické úlohy (generování všech možností) Variace s opakováním rekurzivní funkce řeší problém jednoduše parametry funkce: n, k... parametry variace variace...pole délky k, do kterého ukládáme výsledek index... pozice v poli variace, na kterou právě zapisujeme void VariaceSO(int n, int k, int variace[], int index) if (index == k) // hotová variace výpis a konec VypisPole(variace, k); else for (int i = 1; i <= n; i++) // postupně použijeme všechna čísla variace[index] = i; // umístíme číslo na danou pozici VariaceSO(n, k, variace, index + 1); // necháme vygenerovat zbytek rekurzivně volaná funkce stejným způsobem zpracuje další políčko variace analýza funkce je na další straně Jan Hnilica Počítačové modelování 12 15
Kombinatorické úlohy (generování všech možností) Variace s opakováním analýza funkce funkce obhospodařuje vždy jedno políčko vytvářené variace (s indexem index) o o o o v cyklu od 1 do n (tedy přes všechny přípustné hodnoty) do tohoto políčka zapíše aktuální hodnotu a zavolá rekurzivní funkci (svou vlastní kopii) kopie tak dostane ke zpracování pole, kde je políčko index už vyplněné a sama bude stejným způsobem vyplňovat další políčko (proto ji voláme s parametrem index + 1) a sama bude také volat své rekurzivní kopie rekurzivní volání končí ve chvíli, kdy volaná kopie dostane ke zpracování vyplněnou variaci (index == k), kterou jenom vypíše a vrátí řízení řízení se tak vrací k čekajícím funkcím, které pokračují ve svých cyklech a do příslušných polí vyplňují další čísla v pořadí funkci v programu voláme s parametrem index = 0 (začínáme s prázdnou variací) VariaceSO(n, k, variace, 0); Technická poznámka: pole variace je do funkcí předáváno odkazem (jako vždy v jazyce C, u polí si funkce nevyrábí lokální kopii, ale pracují přímo s předaným polem, změny v poli se tak projeví i mimo funkci) všechny rekurzivní exempláře tedy pracují nad tím samým polem v paměti. Jan Hnilica Počítačové modelování 12 16
Kombinatorické úlohy (generování všech možností) Variace s opakováním analýza funkce průběh volání pro n = 3, k = 2, tvoříme tedy 2-prvkové variace z množiny 1, 2, 3 v závorkách funkce je uveden pouze parametr index (červeně), zelené číslo v kroužku udává pořadí, v jakém byla funkce volána, zároveň je znázorněn stav pole variace 1 VO(0) 1 3 2 VO(1) 1 1 1 3 1 2 3 4 5 VO(2) VO(2) VO(2) výpis výpis výpis 2 6 VO(1) 11 3 1 VO(2) výpis 10 12 3 2 VO(1) VO(2) výpis 13 3 3 VO(2) výpis 2 1 2 2 2 3 7 VO(2) 8 VO(2) 9 výpis výpis VO(2) výpis Jan Hnilica Počítačové modelování 12 17
Kombinatorické úlohy (generování všech možností) Variace bez opakování řešíme obdobně jako variace s opakováním, ale před zapsáním čísla i na j-tou pozici je potřeba zjistit, jestli číslo i už není zapsáno na jedné z předchozích pozic [0]..[j - 1] to lze provést několika způsoby a) před zapsáním čísla prohledat doposud vygenerovanou část (od 0 do index - 1) b) jako parametr funkce přidat pomocné pole příznaků (0/1) signalizujících, jestli dané číslo ve variaci je či není, zápis čísla do variace pak může vypadat takto: // zápis čísel do variace for (int i = 1; i <= n; i++) if (!pouzite[i]) // pokud číslo ve variaci zatím není variace[index] = i; // zapíšeme ho pouzite[i] = 1; // oznacime ho jako použité VariaceBO(n, k, index + 1, pouzite, variace); // vygenerujeme zbytek pouzite[i] = 0; // číslo před návratem nahoru uvolníme pro další variace Jan Hnilica Počítačové modelování 12 18
Kombinatorické úlohy (generování všech možností) Kombinace s opakováním řešíme podobně jako variace, ale aby nedošlo k vygenerování stejných kombinací (lišících se pouze pořadím prvků), generujeme pouze neklesající (či nerostoucí) posloupnosti při zápisu čísla do kombinace tedy musíme zjistit minimální (či maximální) hodnotu, kterou na danou pozici můžeme zapsat (prohledat dosud zapsanou část kombinace, předávat minimum jako parametr funkce...) Kombinace bez opakování stejné jako kombinace s opakováním, ale generujeme pouze ostře rostoucí (či klesající) posloupnosti Jan Hnilica Počítačové modelování 12 19
Generování všech možností Palindromy palindrom = slovo, které se čte z obou stran stejně (např. madam) úloha: máme zadaný řetězec, cílem je vygenerovat všechny palindromy, které lze získat vyškrtáním některých znaků řetězce telepatie => epe telepatie => tat telepatie => telet... Řešení rekurzivní generování všech pod-řetězců, testování na palindromicitu, výpis palindromů pro řetězec délky n je 2 n možných pod-řetězců (každý ze znaků v pod-řetězci buďto je a nebo není) generování pod-řetězců: - použijeme pole příznaků, udávajících jestli je znak v aktuálním pod-řetězci obsažen - každý znak postupně použijeme (příznak = 1) nebo nepoužijeme (příznak = 0) a zavoláme tutéž funkci, která takto ošetří další znak v pořadí - vždy po nastavení celé n-tice příznaků pod-řetězec otestujeme Jan Hnilica Počítačové modelování 12 20
Kdy rekurzi nepoužívat Fibonacciho posloupnost je nekonečná posloupnost přirozených čísel F i (pro i = 0, 1, ), kde platí F i = i pro i = 0, 1 F i = F i 1 + F i 2 pro i > 1 prvními dvěma členy jsou 0 a 1, každý další člen pak získáme jako součet dvou předchozích: 0, 1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144... Úloha: napišme funkci, která spočítá n-tý člen Fibonacciho posloupnosti rekurzivní definice F i = F i 1 + F i 2 přímo svádí k napsání rekurzivní funkce, která by mohla vypadat takto: int Fibonacci(int n) if (n <= 1) return n; return Fibonacci(n - 1) + Fibonacci(n - 2); Jan Hnilica Počítačové modelování 12 21
Kdy rekurzi nepoužívat Fibonacciho posloupnost funkce z předchozí strany počítá správně, ale pomalu, zkuste n > 40 (pozor, do 4-bytového int se vejde maximálně F 46, do unsigned int pak F 47 ) důvodem je opakované počítání stejných funkčních hodnot F 5 F 4 F 3 F 3 F 2 F 2 F 1 F 2 F 1 F 1 F 0 F 1 F 0 Obrázek ukazuje větvení rekurzivních volání při výpočtu F 5. Funkce F 2 je během výpočtu volána 3-krát, funkce F 1 dokonce 5-krát. Při výpočtu vyšších členů posloupnosti bude počet opakovaných volání stejných funkcí dramaticky narůstat. F 1 F 0 Jan Hnilica Počítačové modelování 12 22
Kdy rekurzi nepoužívat Fibonacciho posloupnost časová složitost rekurzivního algoritmu je O(2 n ) - při volání F i (i > 1) se výpočet vždy rozdělí na dvě větve - větvení skončí když jsou volány funkce F0 a F1, což při výpočtu F n nastane v n-tém zanoření řešení 1: použít pomocné pole a zaznamenávat si do něj již spočtené hodnoty, při výpočtu F i se nejprve podívat, jestli už hodnota nebyla spočtena => O(n) rešení 2: počítat od spoda a úlohu tak vyřešit prostým cyklem => O(n) int Fibonacci(int n) if (n <= 1) return n; int f0 = 0, f1 = 1, fn; for (int i = 2; i <= n; i++) fn = f0 + f1; f0 = f1; f1 = fn; return fn; (a ušetříme rekurzivní volání) Jan Hnilica Počítačové modelování 12 23
Poznámky Kdy rekurzi používat? pokud nevede k extrémním časovým nárokům algoritmu pokud rekurzivní funkce významně zjednoduší zápis algoritmu pokud ne-rekurzivní řešení úlohy neznáme Rekurzivní algoritmus bez rekurzivní funkce rekurzivní algoritmy využívají systémového zásobníku, ale datovou strukturu zásobník si můžeme naprogramovat sami program pracuje pomocí běžného cyklu, ze zásobníku vždy odebere ke zpracování dílčí úlohu a uloží do něj nové úlohy zniklé rekurzivním dělením odebrané úlohy, algoritmus končí vyprázdněním zásobníku (pracnější na naprogramování, ale efektivnější vyhneme se volání funkcí) Nepřímá rekurze rekurzivní volání může být skryto - funkce A volá ve svém těle funkci B, která zase volá funkci A Jan Hnilica Počítačové modelování 12 24