5 Rekurze a zásobník • Při volání metody z metody main() se do zásobníku uloží aktivační záznam obsahující - parametry - návratovou adresu, tedy adresu, kde bude program pokračovat v metodě main () po skončení volané metody • Následuje - vstup do metody - její vykonání s použitím parametrů uložených v zásobníku - návrat do metody main() na adresu uloženou v zásobníku s odstraněním aktivačního záznamu Uvedené schéma vyhovuje nejen pro volání nějaké metody v metodě main(), ale i když tato metoda opět volá nějakou metodu. Do zásobníku se uloží další aktivační záznam s adresou kde budeme pokračovat ve volající metodě a vykoná se volaná metoda. Po jejím vykonání se vrátíme do volající metody na adresu, kterou získáme z vrcholu zásobníku a odstraníme aktivační záznam volané metody. Dále se dokončí volající metoda jak bylo uvedeno. Tímto způsobem lze postupovat až do potenciálního přetečení použitého zásobníku.
Rekurzivní volání metody Rekurzivní výpočty jsou potom implementovány tak, že metoda buď přímo nebo nepřímo volá sama sebe. Uvažujme přímou rekurzi voláním rekurzivní metody z metody main(). Potom celý proces můžeme rozložit na: • • • •
volání rekurzivní metody z metody main() vstup do metody nula nebo více rekurzivních volání dosažení základního případu a následné výstupy z rekurzivních volání až po návrat do metody main()
class Faktorial { public static void main (String[] args) { int n = Integer.parseInt(args[0]); System.out.println(n+"! = "+f(n)); // volání // rekurzivní metody f, uložení do zásobníku // parametru n a adresy pro návrat } static int f(int n) { if (n > 1) // vstup, rozhodnutí nastal-li // základní případ return n * f(n - 1); // rekurzivní volání, // do zásobníku vložíme parametr // n-1 a adresu násobení else return 1;// jsme na základním případu a // vykonáme výstup, přičemž podle // adresy v záznamu na vrcholu // zásobníku pokračujeme násobením // nebo návratem do metody main() } }
Eliminace rekurze uživatelským zásobníkem Použijeme vlastní zásobník pro aktivační záznamy s položkami – par, parametr pro faktoriál – segmentKodu, indikace pokračování výpočtu s hodnotami – návrat
skončení rekurze – násobení výpočet faktoriálu při výstupu z rekurzivního volání
Aktivační záznamy budou objekty třídy AZaznam class AZaznam { int par; int segmentKodu;
}
AZaznam(int parametr, int segment) { par = parametr; segmentKodu = segment; }
Při vstupu do rekurzivní procedury potřebujeme zjistit hodnotu parametru v aktivačním záznam, avšak bez odstranění aktivačního záznamu ze zásobníku. Obecně jde o operaci přečti hodnotu na vrcholu zásobníku a obvykle má jméno top. Zásobník aktivačních záznamů potom implementuje třída AZasobnik class AZasobnik { private AZaznam[] z; private int vrchol; final int maxN=10; AZasobnik() { z = new AZaznam[maxN]; vrchol = -1; }
}
void push(AZaznam ref) { z[++vrchol] = ref;
AZaznam pop() { return z[vrchol--]; } AZaznam top() { return z[vrchol]; } } Sledujíc komentáře v programu Faktorial, můžeme implementaci rekurzivního výpočtu faktoriálu uživatelským zásobníkem vyjádřit následujícím schématem
1:volání PUSH(n,návrat)
2:vstup TOP par? 5:výstup POP segmentKodu?
3:rekurzivní volání PUSH(par-1, násobení)
4:násobení TOP výsledek:= výsledek*par 6:návrat výsledek=n!
Tomuto schématu odpovídá program ZFaktorial
class ZFaktorial { static static static static static
int n; int vysledek; AZasobnik azasob; int segment; AZaznam azaz;
public static void main(String[] args) { n=7; faktorial(); System.out.println(n + "! = " + vysledek); }
static void faktorial() { azasob = new AZasobnik(); segment = 1; while (pokracuj()) ; }
}
static boolean pokracuj() { switch(segment) { case 1: // volani azaz = new AZaznam(n,6); azasob.push(azaz); segment = 2; break; case 2: // vstup azaz = azasob.top(); if(azaz.par > 1) segment = 3; else { vysledek = 1; segment = 5; } break; case 3: // rekurzivní volání AZaznam novyZaznam = new AZaznam(azaz.par - 1, 4); azasob.push(novyZaznam); segment = 2; break; case 4: // nasobeni azaz = azasob.top(); vysledek = vysledek * azaz.par; segment = 5; break; case 5: // vystup azaz = azasob.pop(); segment = azaz.segmentKodu; break; case 6: // navrat return false; } return true; }
Získaný program je možné dále upravit.
Výpočet 0!
// hodnoty na začátku případů, větve
volani: vstup: vystup: navrat:
vysledek= vysledek= vysledek=1 vysledek=1
Z: ( ) Z: (0,navrat) Z: (0,navrat) Z: ( )
vysledek= vysledek= vysledek= vysledek= vysledek=1 vysledek=1 vysledek=2 vysledek=2
Z:( ) Z:( [2,navrat] ) Z:( [2,navrat] ) Z:( [2,navrat] [1,nasobeni] ) Z:( [2,navrat] [1,nasobeni] ) Z:( [2,navrat]) Z:( [2,navrat] ) Z:( )
Výpočet 2! volani: vstup: rekurzivni volani: vstup: vystup: nasobeni: vystup: navrat:
ADT Strom Stromy jsou matematická abstrakce organizace dynamických množin, kterou v běžném životě používáme (možná nevědomě) velice často. Příkladem může být rodokmen, tedy například struktura z druhé přednášky, omezíme-li se na rodiče. Sportovní soutěže založené na vylučovacím principu, kdy dál postupuje jeden ze soupeřů. Organizace knížky – Herout P.: Učebnice jazyka Java.
Obsah
Úvod
2 Základní pojmy
…
20 Vlákna
Literatura
Rejstřík
2.1 Trocha historie nikoho nezabije … 2.6 Co byste měli vědět, než začnete programovat
2.6.1 Jak přeložit a spustit program 2.6.2 Běžné problémy
V počítači může být ve formě stromu organizován systém souborů uložených v adresářích, které definujeme rekurzivně jako množinu souborů a adresářů. Prvky v dynamické množině organizované jako strom nazýváme vrcholy. Některé vrcholy jsou spojeny a toto spojení nazýváme hranou. Strom potom můžeme rekurzivně definovat následovně: a) Jeden vrchol je strom. Tento vrchol se nazývá kořen stromu. b) Nechť x je vrchol a jsou stromy T1, T2, ..., Tn. Strom je vrchol x spojený s kořeny stromů T1, T2, ..., Tn. V tomto stromě je x kořenem stromu a stromy T1, T2, ..., Tn se nazývají podstromy. Jejich kořeny jsou přímými následníky vrcholu x a vrchol x je jejich přímým předchůdcem. Vrchol, který nemá přímě následníky se nazývá listem. Vrchol, který není listem se nazývá vnitřním vrcholem. Někdy je vhodné zahrnout mezi stromy i prázdnou množinu vrcholů. Cesta je posloupnost vrcholů, ve které po sobě jdoucí vrcholy jsou spojeny hranou. Délka cesty je počet hran cesty. Délku cesty z vrcholu k sobě samému potom můžeme definovat jako nulovou. Ke každému vrcholu je z kořene právě jedna cesta. Hloubka vrcholu ve stromě (úroveň, na které se nachází) je definována jako délka této cesty. Úroveň (hloubka) kořene stromu je tedy nulová. Výška stromu je maximální hloubka vrcholu stromu. Množina přímých následníků může být uspořádaná, například při grafickém zobrazení zleva doprava. Důležitou třídou takových stromů jsou binární stromy.
Definice Binární strom je prázdný strom anebo vrchol, který má levý a pravý podstrom, které jsou binární stromy. Model dat, který je binárním stromem můžeme implementovat použitím pole, kterého prvky mají typ klíče. Pozice vrcholů binárního stromu lze očíslovat následujícím způsobem: Začneme kořenem, kterému přiřadíme 1, dále jeho levému následníku 2, pravému následníku 3, a v číslování pokračujeme na každé úrovni zleva až do konce, kde přejdeme na další úroveň. Tyto čísla jsou pak indexy uvedených pozic v binárním stromu. Pokud se na pozici skutečně nachází vrchol, prvek pole obsahuje klíč. V opačném případě je prvek pole označen jako nepoužit, například jsou-li hodnoty vrcholů nezáporná celá čísla, může být jeho hodnota -1. Má-li vrchol hodnotu indexu i, potom levý následník má index 2i pravý následník má index 2i + 1 předchůdce, pokud existuje, má index i/2 (celočíselně). Obecně, tato implementace není efektivní, protože nejenom musíme vytvořit pole pro předpokládanou maximální velikost stromu, ale navíc musí obsahovat i prvky pro pozice neobsazené vrcholy. Poznamenejme, že uvedené vztahy platí, má-li kořen stromu index 1. Pokud by jsme ho umístnili do prvku pole s indexem 0, tyto vztahy nutno upravit.
Vrchol binárního stromu můžeme implementovat obdobně jako prvek seznamu, jenomže namísto položky dalsi budou v implementaci vrcholu polozky levy a pravy class Vrchol { int klic; Vrchol levy; Vrchol pravy; ...
}
void tiskVrcholu() { System.out.print(klic+” “); }
V případě seznamu, když jsme potřebovali projít (navštívit) všechny jeho prvky, například pro jejich vytištění, postupovali jsme od prvního k dalšímu pomocí ukazatele dalsi, atd. V případě stromu začneme kořenem, ale pro další systematický postup máme tři možnosti Přímý průchod (preorder) navštívíme vrchol, potom levý a pravý podstrom Vnitřní průchod (inorder) navštívíme levý podstrom, vrchol a pravý podstrom Zpětný průchod (postorder) navštívíme levý a pravý podstrom a potom vrchol
Binární strom je definován rekurzivně a následně můžeme rekurzivně vyjádřit jeho průchody. Rekurzivní průchod stromem void pruchodR(Vrchol v) { if (v == null) return; v.tiskVrcholu(); pruchodR(v.levy); pruchodR(v.pravy); } Vrchol koren; pruchodR (koren); Uvedená metoda implementuje průchod preorder, posunutím řádku s tiskem mezi rekurzivní volání získáme implementaci průchodu inorder a jeho posunutím za obě rekurzivní získáme implementaci průchodu postorder.
Čas průchodu stromem je pro prázdný strom dán vyhodnocením podmínky T(0) = cpor Trvá-li tisk ctisk a má-li levý strom m vrcholů a prvý strom n-m vrcholů, potom T(n) = T(m) + T(n-m) + ctisk
pro n > 0
Její řešení je T(n) = (cpor + ctisk)n + cpor čas průchodu stromem je O(n).
Použitím abstraktního zásobníku, do kterého můžeme ukládat klíče anebo stromy můžeme vytvořit nerekurzivní implementace průchodů. 1. Do zásobníku vložíme procházený strom 2. Dokud zásobník není prázdný, v cyklu vybereme prvek ze zásobníku a je-li ním klíč vytiskneme ho, jinak do zásobníku vložíme pro preorder: pravý podstrom, levý podstrom, klíč vrcholu pro inorder: pravý podstrom, klíč vrcholu, levý podstrom pro postorder: klíč vrcholu, pravý podstrom, levý podstrom
Pro preorder průchod je při vkládání jako poslední vložen klíč a ten je tedy vybrán na začátku uvedeného cyklu a vytisknut. Nerekurzivní preorder průchod pak je void pruchod(Vrchol v) { VZasobnik z = new VZasovnik(); z.push(v); while (!z.jePrazdny()) { v = z.pop(); v.tiskVrcholu(); if (v.pravy != null) z.push(v.pravy); if (v.levy != null) z.push(v.levy); } }
Binární vyhledávací stromy (BVS) Vkládání prvků do lineárních implementací uspořádaných množin a hledání prvků v takových implementacích neuspořádaných množin je časově náročné. Pro BVS jsou v průměrném případu obě tyto operace efektivní. Prvky jsou ve vrcholech BVS uspořádány tak, že splňují následující BVS vlastnost: Nechť x je vrchol stromu. Je-li y vrchol v levém podstromu, potom y.klíč < x.klíč. Je-li y vrchol v pravém podstromu, potom y.klíč > x.klíč. Pro BVS budeme uvažovat, že klíče všech prvků jsou různé. Základní operací nad BVS, je hledání hodnoty uložené ve vrcholu podle klíče. Třída DVrchol tedy bude obsahovat navíc člen data, například typu String. class DVrchol { int klic; String data; DVrchol levy; DVrchol pravy; DVrchol (int klic, String data) { this.klic = klic; this.data = data; }
}
void tiskVrcholu() { System.out.print(data+” “); }
BVS strom je reprezentován svým kořenem private DVrchol koren; Pro prázdný strom je koren == null Signatura metody hledej je String hledej(int) Z definice BVS přímo plyne její rekurzivní implementace private String hledejR(DVrchol v, int klic) { if (v == null) return null; if (klic == v.klic) return v.data; if (klic < v.klic) return hledejR(v.levy, klic); else return hledejR(v.pravy, klic); } String hledej(int klic) { return hledejR(koren, klic); }
Hledání v BVS je stejně efektivní jako binární hledání v uspořádané množině. Pro BVS je významné, že stejně efektivně můžeme implementovat i vkládání. Signatura metody vloz je void vloz(int, String) Její rekurzivní implementaci můžeme opsat následovně. Je-li strom prázdný nový prvek stane jeho kořenem. Jinak, je-li klíč nového prvku menší než klíč kořene, prvek vložíme do levého podstromu a do pravého podstromu, je-li klíč nového prvku větší než klíč kořene. private DVrchol vlozR(DVrchol v, int klic, String data) { if (v == null) return new DVrchol(klic, data); if (klic < v.klic) v.levy = vlozR(v.levy, klic, data); else v.pravy = vlozR(v.pravy, klic, data); return v; } void vloz(int klic, String data) { koren = vlozR(koren, klic, data); } Obě metody při každém rekurzivním volání sestoupí o jednu úroveň níž a tedy složitost uvedených algoritmů je O(h), kde h je výška stromu. Průchod inorder BVS, sestrojeným pro posloupnost prvků jejich postupným vkládáním metodou vloz(), vytiskne tyto prvky uspořádané podle klíče.
Nerekurzivní verze uvedených metod se v cyklu rozhodují, budou-li pokračovat v levém nebo v pravém podstromu. V následující metodě vloz se nejprve najde pozice, kam nový vrchol vložit a potom se vloží. Protože proměnná x, pomocí které stromem sestupujeme má v tom okamžiku hodnotu null, udržujeme na proměnné predch (obdobně jako u seznamu) ukazatel na vrchol, ke kterému nový vrchol připojíme. void vloz(int klic, String data) { if (koren == null) { koren = new DVrchol(klic, data); return; } DVrchol x = koren, predch = null; while (x != null) if (klic < x.klic) { predch = x; x = x.levy; } else { predch = x; x = x.pravy; } if (klic < predch.klic) predch.levy = new DVrchol(klic, data); else predch.pravy = new DVrchol(klic, data); }
Vlastnosti BVS Uvedli jsme, že časová složitost hledání a vkládání je O(h). Jaká je tedy výška stromu po vložení náhodné permutace N různých prvků? Nejhorší případ h = n – 1 , strom degeneruje na seznam, prvky v permutaci byly uspořádané. Časová složitost uvedených operací tedy je O(N). Nejlepší případ, prvky budou vloženy tak, že kromě poslední úrovně, jsou zaplněny všechny vyšší úrovně. Pro h platí 2h ≤ N < 2h+1 a h = log2 N. Časová složitost uvedených operací tedy je O(log N). Takové stromy jsou vyvážené. Jaká je složitost pro obecný nevyvážený BVS s N vrcholy. Čas uvedených operací je dán hloubkou pozice vkládání a hledání. Zavedeme-li celkovou délku cest BVS jakou součet hloubek všech vrcholů, tj. cest z kořene, průměrná hloubka vrcholu je celková délka cest BVS / N Je-li kořenem BVS i-tý největší prvek, potom v levém podstromě je i-1 vrcholů a v pravém podstromě N-i vrcholů. Označme PN průměrnou celkovou délku cest BVS s N vrcholy, vznikl-li vkládáním posloupnosti N prvků, přičemž všechny jejich permutace jsou stejně pravděpodobné. Potom platí rovnice PN =
1 N
∑ ( Pi-1 + i – 1 + PN-i + N – i) 1≤i≤N
Jejím řešením získáme PN ≈ 2N ln N = 1,39N log2 N Průměrná časová složitost operací vkládání a hledání je tedy O(log N). V předcházejících úvahách jsme předpokládali, že všechny permutace mají stejnou pravděpodobnost. Odpovídá každé permutaci různý BVS? Uložme prvky posloupnosti do mřížky tak, že řádek odpovídá hodnotě a sloupec poloze Příklad 3,5,2,4,1 x x 3 x x
x x x x 5
x 2 x x x
x x x 4 x
1 x x x x
Získali jsme mřížkovou reprezentaci BVS. Výměnou sloupců odpovídajících vrcholům nad a pod libovolným vrcholem se vyhledávací strom nezmění, posloupnost ano. x x 3 x x
x x x x 5
3,5,2,1,4
x 2 x x x
1 x x x x
x x x 4 x
Počet všech posloupností z N prvků je N!. Lze ukázat, že počet různých binárních stromů s N vrcholy ~ 4N/√(πN), tedy veliký počet posloupností odpovídá každému BVS. Zatím jsme se nevěnovali druhé základní operaci, tj. vyjmutím prvku ze stromu, kdy nutno, pokud jde o vnitřní vrchol se dvěma přímými následníky, strom rekonfigurovat, tj. určit prvek, který vložíme na pozici odebraného prvku. Lze tak učinit několika způsoby, vedou však k tomu, že strom po odstranění nezůstává náhodným, někdy se průměrná hloubka změní na √N . Možným řešením je označit vybraný prvek jako neplatný, například použitím logické členské proměnné. Často v aplikaci nemáme mnoho vybraných prvků a dokonce můžou být užitečné pokud by jich bylo později opět potřeba. Další technikou je vytvoření nového stromu pro platné prvky, dosáhl-li počet neplatných vrcholů nějakou mez, co je také častý způsob práce s datovými strukturami.