Programovanie (2) v Jave
1-INF-166, LS 2016/17

Úvod · Pravidlá · Prednášky · Projekt · Netbeans · Odovzdávanie · Test a skúška
· Vyučujúcich môžete kontaktovať pomocou e-mailovej adresy E-prg.png (bude odpovedať ten z nás, kto má príslušnú otázku na starosti alebo kto má práve čas).
· Predvádzanie projektov bude v pondelok 5.6. od 9:00 do 12:00 a v utorok 6.6 od 12:00 do 13:30 (po skúške), oboje v miestnosti M217. Na termín sa prihláste v AIS. Ak robíte vo dvojici, prihlási sa iba jeden člen dvojice.
· Body zo záverečného testu sú na testovači. Poradie príkladov: P1: do šírky, P2: topologické, P3: výnimky, P4: iterátor, P5: testy, P6: strom. Bolo potrebné získať aspoň 20 bodov zo 40.
· Opravný test bude 19.6.2017 od 9:00 v miestnosti M-I. Na termín sa prihláste v AISe.
· Zapisovanie známok a osobné stretnutia ku skúške budú v utorok 13.6. 13:30-14:30 v M163 a v stredu 14.6. 14:00-14:30 v M163.


Prednáška 9

Z Programovanie
Prejsť na: navigácia, hľadanie

Organizačné poznámky

  • DÚ5 zverejnená
  • Dnes začíname rekurziu
    • Na cvičeniach začnite s príkladmi na rekurziu, ale precvičiť si treba aj reťazce
    • Neváhajte dôjsť na doplnkové cvičenia
    • Na cvičeniach (hlavných aj doplnkových) sa pýtajte na veci, ktoré vám nie sú jasné
  • Vyučujúcich môžete kontaktovať aj pomocou novej e-mailovej adresy E-prg.png (bude odpovedať ten z nás, kto má príslušnú otázku na starosti alebo kto má práve čas)

Klasické úvodné príklady na rekurziu

Rekurzia je metóda, pri ktorej definujeme objekt (funkciu, pojem, . . . ) pomocou jeho samého.

Na začiatok sa pozrieme na „klasické“ príklady algoritmov využívajúcich rekurziu.

Výpočet faktoriálu

Faktoriál prirodzeného čísla n značíme n! a je to súčin všetkých celých čísel od 1 po n. Pre úplnosť 0! definujeme ako 1.

Výpočet pomocou cyklu z prednášky 3:

int factorial(int n) {
    int result = 1;
    for (int i = 1; i <= n; i++) {
        result = result * i;
    }
    return result;
}

Rekurzívna definícia faktoriálu: n!=\left\{{\begin{array}{ll}1&{\mathrm  {ak~}}n<2\\n\cdot (n-1)!&{\mathrm  {inak}}\\\end{array}}\right.

Túto matematickú definíciu môžeme priamočiaro prepísať do rekurzívnej funkcie:

int factorial(int n) {
    if (n < 2) return 1;
    else return n * factorial(n-1);
}

Aby sa nám rekurzia nezacyklila, mali by sme dodržiavať nasledujúce zásady:

  • Rekurzívna funkcia musí obsahovať vetvu pre triviálny prípad niektorého vstupu. Táto vetva nebude obsahovať rekurzívne volanie funkcie, ktorú práve definujeme.
  • Rekurzívne volanie funkcie by malo mať vhodne redukovaný niektorý vstup, aby sme sa časom dopracovali k triviálnemu prípadu.

Najväčší spoločný deliteľ (Euklidov algoritmus)

Ďalším tradičným príkladom na rekurziu, s ktorým ste sa už stretli, je počítanie najväčšieho spoločného deliteľa.

  • Euklidov algoritmus z prednášky 3 bol založený na rovnosti gcd(a,b) = gcd(b, a mod b)
  • Tú sme použili v cykle:
int gcd(int a, int b) {
    while(b != 0) {
        int x = a % b;
        a = b;
        b = x;
    }
    return a;
}

Avšak opäť to isté môžeme ešte kratšie a elegantnejšie napísať rekurziou:

int gcd(int a, int b) {
   if (b == 0) return a;
   else return gcd(b, a % b);
}

Fibonacciho čísla

Nemôžeme vynechať obľúbený rekurzívny príklad - Fibonacciho čísla, ktoré sme videli na prednáške 4. Aj tam sa rekurzia priam pýta, keďže Fibonacciho čísla sú samé o sebe definované rekurzívne:

  • F(0)=F(1)=1
  • F(n)=F(n-1)+F(n-2) pre n>2

Z tejto definície vieme opäť urobiť rekurzívnu funkciu jednoducho:

int fib(int n){
    if (n<2) return 1;
    else return fib(n-1)+fib(n-2);
}

Toto je opäť krajšie ako nerekurzívna verzia:

int fib(int n) {
    int f = 1;     // Cislo F(i) 
    int oldF = 1;  // Cislo F(i-1)
    
    for (int i = 2; i <= n; i++) {
        int newF = f + oldF;  // spocitaj nove F(i) pre vyssie i
        oldF = f;             // poposuvaj hodnoty
        f = newF;
    }
    return f;
}

Binárne vyhľadávanie

Aj binárne vyhľadávanie prvku v utriedenom poli z prednášky 7 sa do sprehľadní v rekurzívnom zápise.

Pôvodná nerekurzívna funkcia vrátila polohu prvku x v poli a alebo hodnotou -1, ak sa tam nenachádzal:

int find(int a[], int n, int x) {
    int left = 0, right = n - 1;
    while (left <= right) {
        int index = (left + right) / 2;
        if (a[index] == x) return index;
        else if (a[index] < x) left = index + 1;
        else right = index - 1;
    }
    return -1;
}

V rekurzívnej verzii si okraje aktuálneho úseku poľa si v rekurzii posielame ako parametre:

int find(int a[], int left, int right, int x) {
    if (left > right) return -1;
    int index = (left + right) / 2;
    if (a[index] == x) return index;
    else if (a[index] < x) return find(a, index+1, right, x);
    else return find(a, left, index - 1, x);
}

Ak chceme vyhľadať x v poli a s n prvkami, voláme find(a, 0, n-1, x).

Na zamyslenie:

  • Táto funkcia má dva triviálne (nerekurzívne) prípady. Ktoré?
  • Aká veličina klesá v každom rekurzívnom volaní?

Tu je ešte trochu iná verzia binárneho vyhľadávania s niekoľkými rozdielmi:

  • vraciame iba či sa x nachádza v poli alebo nie (dalo by sa rozšíriť aj na index)
  • pri porovnávaní x a a[index] rozlišujeme iba dva prípady, nie tri
  • končíme pri intervale dĺžky 1, nie 0
bool contains (int a[], int left, int right, int x){
    if (left == right) return (a[left] == x);
    int index = (left + right) / 2;
    if (x <= a[index]) return contains(a, left, index, x);
    else return contains(a, index+1, right, x);
}

int main(void) {
  const int n = 9;
  int a[n]={1,5,7,12,45,55,72,95,103};
  cout << contains(a, 0, n-1, 467) << endl;
  cout << find(a, 0, n-1, 467) << endl;
}

Zhrnutie

Pri rekurzii vyjadríme riešenie nejakej úlohy pomocou riešenia jednej alebo viacerých úloh toho istého typu, ale s menším vstupom plus ďalšie potrebné nerekurzívne výpočty

  • výpočet n! vyjadríme pomocou výpočtu (n-1)! a násobenia
  • výpočet gcd(a, b) vyjadríme pomocou výpočtu gcd(b, a % b)
  • výpočet F[n] vyjadríme pomocou výpočtu F[n-1] a F[n-2]
  • binárne vyhľadávanie v dlhšom intervale vyjadríme pomocou binárneho vyhľadávania v kratšom intervale

Všimnite si, že občas musíme zoznam parametrov nejakej funkcie rozšíriť pre potreby rekurzie

  • napr. funkcia find by prirodzene dostávala pole a, dĺžku n a hľadaný prvok, ale kvôli rekurzii potrebuje ľavý a pravý okraj
  • pre pohodlie užívateľa môžeme pridať pomocnú funkciu (wrapper):
int find(int a[], int n, int x) {
  return find(a, 0, n-1, x);
}

Viac o rekurzii

Nepriama rekurzia

Všetky doteraz uvedené funkcie sú príkladom priamej rekurzie – definovaná funkcia používa seba samú priamo. Druhým možným prípadom je nepriama rekurzia (alebo tiež vzájomná), kedy funkcia neodkazuje vo svojej definícii priamo na seba, ale využíva inú funkciu, ktorá sa odkazuje naspäť na prvú (všeobecnejšie sa kruh môže uzavrieť na viac krokov). Ako príklad uveďme rekurzívne definície predikátov párnosti a nepárnosti:

bool even(int n) {
    if (n == 0) return true;
    else return odd(n - 1);
}

bool odd(int n) {
    if (n == 0) return false;
    else return even(n - 1);
}

Rekurzia pomocou zásobníka - ako je rekurzia implementovaná

O rekurzívne volania sa stará zásobník volaní (call stack)

  • Ide o všeobecnú štruktúru potrebnú aj v nerekurzívnych programoch, ktoré volajú funkcie
  • Po zavolaní nejakej funkcie f sa pre ňu vytvorí na zásobníku záznam, ktorý obsahuje všetky lokálne premenné a argumenty funkcie
  • Keď potom z funkcie f zavoláme nejakú funkciu g (pričom v rekurzii môže byť aj f=g), tak sa vytvorí nový záznam pre g. Navyše v zázname pre f si uložíme aj to, v ktorom kroku sme prestali s výpočtom, aby sme vedeli správne pokračovať
  • Po skončení výpočtu funkcie g sa jej záznam zruší zo zásobníka. Vrátime k záznamu pre funkciu f a pokračujeme vo výpočte so správnymi hodnotami všetkých premenných a od správneho miesta.

Záznamy v zásobníku si môžeme predstaviť uložené v stĺpci jeden nad druhým

  • vrchný záznam je aktuálny, pre funkciu, ktorá sa vykonáva
  • pod ním je záznam pre funkciu, ktorá ju volala atď
  • na spodku je záznam pre funkciu main

Teraz si môžeme jednoduchý zásobník odsimulovať napríklad na výpočte faktoriálu.

int factorial(int n) {
    if (n < 2) return 1;
    else return n * factorial(n-1);
}

Zložitejšie príklady rekurzie

Každý z predchádzajúcich príkladov sme vedeli pomerne jednoducho zapísať aj bez rekurzie, aj keď rekurzívny výpočet bol často prehľadnejší, zrozumiteľnejší, kratší a krajší.

Ukážeme si však aj príklady, ktoré by sa bez rekurzie písali obtiažne (aj keď ako si neskôr ukážeme, rekurzia sa dá vždy odstrániť, v najhoršom prípade simuláciou zásobníka). Takéto príklady, kde rekurzia veľmi pomáha, uvidíme na zvyšku dnešnej prednášky, ale aj na dvoch ďalších a k rekurzii sa vrátime aj neskôr v semestri a samozrejme na ďalších predmetoch.

Fraktály

Fraktály sú útvary, ktorých časti na rôznych úrovniach zväčšenia sa podobajú na celý útvar. Mnohé fraktály vieme definovať a vykresliť pomocou jednoduchej rekurzie.

Kochova krivka stupňa 3

Príkladom fraktálu je Kochova krivka. Ako vzniká?

  • Predstavme si úsečku, ktorá meria d centimetrov.
  • Spravíme s ňou nasledujúcu transformáciu:
    • Úsečka sa rozdelí na tretiny a nad strednou tretinou sa zostrojí rovnostranný trojuholník. Základňa trojuholníka v krivke nebude.
    • Dostávame tak útvar pozostávajúci zo štyroch úsečiek s dĺžkou d/3
  • Tú istú tranformáciu môžeme teraz spraviť na každej zo štyroch nových úsečiek, t.j. dostávame 16 úsečiek dĺžky d/9
  • Takéto transformácie môžeme robiť do nekončna

Druhá možnosť je popísať krivku pomocou dvoch parametrov: dĺžka d a stupeň n

  • Kochova krivka stupňa 0 je úsečka dĺžky d
  • Kochova krivka stupňa n pozostáva zo štyroch kriviek stupňa n-1 a dĺžky n/3
    • na presný popis umiestnenia a natočenia týchto kriviek nižšieho stupňa by sme mohli použiť korytnačiu grafiku, čo máme spravené vo funkcii nižšie.
 
#include "../SVGdraw.h"

void drawKoch(double d, int n, Turtle& turtle){
    if (n==0) turtle.forward(d);
    else {
        drawKoch(d/3, n-1, turtle);
        turtle.turnLeft(60);
        drawKoch(d/3, n-1, turtle);
        turtle.turnRight(120);
        drawKoch(d/3, n-1, turtle);
        turtle.turnLeft(60);
        drawKoch(d/3, n-1, turtle);
    }
}

int main(void) {
    int width = 310; /* rozmery obrazku */
    int height = 150;

    double d = 300; /* velkost krivky */
    int n = 5; /* stupen krivky */

    /* Vytvor korytnačku otočenú doprava. */
    Turtle turtle(width, height, "fraktal.svg", 1, height-10, 0);

    /* nakresli Kochovu krivku rekurzívne */
    drawKoch(d, n, turtle);

    /* Schovaj korytnačku. */
    turtle.finish();
}

Vyhodnocovanie výrazu

Máme zadaný reťazec, v ktorom je výraz obsahujúci celé čísla, znamienka '+', '-' a zátvorky.

  • Pre jednoduchosť predpokladajme, že zátvorky sú okolo každého podvýrazu
  • Príklady výrazov:
    • 99
    • (12+13)
    • (((12-5)+7)-(6+8))
  • Výrazy uvažované v tomto probléme teda môžeme definovať takto:
    • desiatkový zápis celého nezáporného čísla je výraz
    • ak A a B sú výrazy a Z je znamienko + alebo -, tak reťazec (AZB) je tiež výraz
    • nič iné výraz nie je

Na vyhodnotenie výrazu môžeme použiť nasledovný postup:

  • Nájdeme si znamienko Z také, že výraz je tvaru (Výraz1 Z Výraz2)
  • Zistíme, akú hodnotu majú Výraz1 a Výraz2
  • Hodnota celého výrazu sa dá vypočítať pomocou týchto hodnôt a znamienka Z

Začneme hľadaním polohy správneho znamienka Z:

int najdiZnamienko(char str[]) {
    // vo vyraze tvaru (vyrazZvyraz najde polohu znamienka Z
    int poc=0; // pocet otvorenych zatvoriek
    for (int i=1; i<strlen(str)-1; i++) { //preskocime prvu lavu zatvorku
        if (str[i]=='(') poc++;
        else if (str[i]==')') poc--;
        else if (poc==0 && (str[i] == '+' || str[i]=='-')) return i;
    }
    return -1;
}


Čo bude triviálny prípad vyhodnocovania výrazu?

  • keď je výraz číslo, t.j. nezačína zátvorkou

Potom vyhodnocovanie výrazu može pracovať nasledovne:

int vyhodnotVyraz(char str[]){
    if (str[0]!='(') return vyhodnotCislo(str);
    else {
        char s[100];
        int poz = najdiZnamienko(str);

        strCopy(str, s, 1, poz-1);      // od 1 po pozíciu pred znamienkom (pozícia 0 je '(' )
        int hodnota1 = vyhodnotVyraz(s);
        strCopy(str, s, poz+1, strlen(str)-2); // od pozície po znamienku po predposledný znak str (posledný znak - strlen()-1 je ')' )
        int hodnota2 = vyhodnotVyraz(s);

        switch (str[poz]){
            case '+': return hodnota1+hodnota2;
            case '-': return hodnota1-hodnota2;
        }
        return 0;
    }    
}

Hanojské veže

  • Problém Hanojských veží pozostáva z troch stĺpcov (tyčiek) a niekoľkých kruhov rôznej veľkosti. Začína sa postavením pyramídy z kruhov (kameňov) na prvú tyčku od najväčšieho po najmenší.
  • Úlohou je potom presunúť celú pyramídu na inú tyčku, avšak pri dodržaní nasledovných pravidiel:
    • v jednom ťahu (na jedenkrát) je možné premiestniť iba jeden hrací kameň
    • väčší kameň nesmie byť nikdy položený na menší


Ako budeme úlohu riešiť? Asi rekurzívne, nie?

  • V prípade, že máme iba jeden kameň je úloha veľmi jednoduchá - preložíme ho z pôvodného stĺpika na cieľový stĺpik.
  • Ak chceme preložiť viac kameňov (nech ich je N), tak
    • Všetky okrem posledného preložíme na pomocný stĺpik (na to použijeme taký istý postup len s N-1 kameňmi)
    • Premiestnime jeden kameň kam potrebujeme
    • Zatiaľ odložené kamene (na pomocnom stĺpiku) preložíme z pomocného na cieľový stĺpik (na to použijeme opäť taký istý postup s N-1 kameňmi)

Aby sme to popísali konkrétnejšie - preloženie N kameňov z A na C (s pomocným stĺpikom B) urobíme takto:

  • Preložíme N-1 kameňov z A na B (s použitím C)
  • Preložíme 1 kameň z A na C (s použitím B - ale reálne to potrebovať nebudeme)
  • Preložíme N-1 kameňov z B na C (s použitím A)
void presunHanoi(int odkial, int cez, int kam, int n){
    if (n == 1) {
        cout << "Prelozim kamen z " << odkial <<" na " << kam << endl;
    } else {
        presunHanoi(odkial, kam, cez, n-1); // odlozime si n-1 na pomocny-cez
        presunHanoi(odkial, cez, kam, 1);   // prelozime najvacsi na finalne miesto
        presunHanoi(cez, odkial, kam, n-1); // zvysnych n-1 prelozime z docasneho odkladiska na finalne
    }
}

int main (void){
    presunHanoi(1, 2, 3, 3); // z veze 1 na vezu 3 (pomocou veze 2)
}

Dôležité je si uvedomiť, že nasledovný postup dodržuje pravidlá. Po zavolaní funkcie presunHanoi vždy platí:

  • Funcia bude presúvať n horných kameňov z tyče odkial na tyč kam pomocou pomocnej tyče cez.
  • Ak n>1, tak na tyčiach kam a cez sú len väčšie kamene ako horných n kameňov na odkial.
  • Ak n=1, na tyči kam sú len väčšie kamene, obsah tyče cez môže byť ľubovoľný.

Drobnosť na záver: chvostová rekurzia

Chvostová rekurzia je rekurzia, kde je rekurzívne volanie ako úplne posledná akcia vo volajúcej funkcii, ako napr. pri výpočte gcd

int gcd(int a, int b) {
   if (b == 0) return a;
   else return gcd(b, a % b);
}

Ale aj v binárnom vyhľadávaní rekurzia volala až úplne na koniec:

bool contains (int a[], int left, int right, int x){
    if (left == right) return (a[left] == x);
    int index = (left + right) / 2;
    if (x <= a[index]) return contains(a, left, index, x);
    else return contains(a, index+1, right, x);
}

Dobrý kompilátor dokáže z takejto rekurzívnej funkcie vytvoriť nerekurzívnu.

Avšak napr. rekurzívny faktoriál nepoužíval chovostovú rekurziu, lebo výsledok rekurzie bolo ešte treba vynásobiť n:

int factorial(int n) {
    if (n < 2) return 1;
    else return n * factorial(n-1);
}

Dal by sa však zapísať aj chvostovou rekurziou, s použitím pomocného parametra:

/* tato funkcia spocita x * (n!) */
int xTimesFactorial(int n, int x) {
  if(n<2) return x;
  else return xTimesFactorial(n-1, n*x);
}

int factorial(int n) {
  return xTimesFactorial(n, 1);
}

Podobne môžeme upraviť na chvostovú rekurziu aj funḱciu na výpočet Fibonacci čísel, aj keď sa v bežnej verzii volá dvakrát a nemôžu byť obe volania chvostom:

int fib(int n){
    if (n<2) return 1;
    else return fib(n-1)+fib(n-2);
}
  • Pôvodný rekurzívny výpočet Fibonacciho čísel je dosť pomalý, lebo jednotlivé hodnoty Fibonacciho čísel počítame totiž viackrát (čím menšie číslo, tým viackrát je spočítané).
  • To sa dá napraviť použitím takzvaného akumulátora, čo je dodatočný parameter, v ktorom si predávame stav výpočtu.
  • Konkrétne pri efektívnejšom počítaní Fibonacciho čísel zdola nahor si v akumulátore budeme odkladať posledné dve napočítané čísla.
  • V podstate takto budeme napodobňovať to, ako by ste si počítali konkrétne Fibonacciho číslo v hlave alebo ako sme to robili bez rekurzie.
  • Začnete od dvoch jednotiek a postupne si dopočítate ďalšie až po číslo, ktoré ste potrebovali.
  • V parametroch a a b si pamätáme dve susedné čísla a v parametri n si pamätáme, koľko rekurzívnych volaní ešte chceme urobiť.
  • Takto naprogramovaná Fibonacciho funkcia je rýchla a kopíruje spôsob nášho uvažovania.
int fib1(int a, int b, int n) {
    if (n == 0) return a;
    else return fib1(b,a+b,n-1);
}

int fib(int n){
    fib1(1,1,n);
}