Programovanie (1) v C/C++
1-INF-127, ZS 2024/25

Úvod · Pravidlá · Prednášky · Softvér · Testovač
· Kontaktujte nás 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).
· Prosíme študentov, aby si pravidelne čítali e-maily na @uniba.sk adrese alebo aby si tieto emaily preposielali na adresu, ktorú pravidelne čítajú.


Prednáška 20

Z Programovanie
Skočit na navigaci Skočit na vyhledávání

Oznamy

  • Tretiu domácu úlohu treba odovzdať do 4. decembra, 22:00.
  • Semestrálna písomka bude v stredu 9. decembra o 18:10.

Aritmetické stromy

Strom pre výraz (65 – 3 * 5)/(2 + 3)

Aritmetické výrazy možno reprezentovať aj vo forme stromu nazývaného aj aritmetickým stromom:

  • Operátory a čísla tvoria tzv. uzly stromu.
  • Operátory tvoria tzv. vnútorné uzly stromu – každý z nich má dvoch synov zodpovedajúcich podvýrazom pre jednotlivé operandy.
  • Čísla tvoria tzv. listy aritmetického stromu – tie už nemajú žiadnych synov.
  • Strom obsahuje jediný uzol, ktorý nie je synom žiadneho iného uzla – to je tzv. koreň stromu a reprezentuje celý aritmetický výraz.
  • Informatici stromy väčšinou kreslia „hore nohami”, s koreňom na vrchu.

Uzol aritmetického stromu tak môžeme reprezentovať napríklad nasledujúcou štruktúrou:

struct treeNode {
    double val;      // ciselna hodnota (zmysluplna len v listoch)
    char op;         // operator (zmysluplny len vo vnutornych uzloch, pre listy vzdy rovny medzere)
    treeNode *left;  // smernik na koren podstromu reprezentujuceho lavy podvyraz
    treeNode *right; // smernik na koren podstromu reprezentujuceho pravy podvyraz
};

Pre vnútorné uzly stromu (zodpovedajúce operátorom) pritom:

  • Smerníky left a right budú ukazovať na korene podstromov reprezentujúcich ľavý resp. pravý podvýraz.
  • Znak op bude zodpovedať danému operátoru (napríklad '+').
  • Hodnota val ostane nevyužitá.

Pre listy (zodpovedajúce číselným hodnotám) naopak:

  • Smerníky left a right budú mať hodnotu NULL.
  • Znak op bude medzera ' ' (podľa op teda môžeme rozlišovať, či ide o číslo alebo o operátor).
  • Vo val bude uložená hodnota daného čísla.

Celý strom pritom budeme reprezentovať jeho koreňom.

Ide tu o jednoduchú, no nie veľmi elegantnú reprezentáciu aritmetických stromov, keďže viaceré položky štruktúry treeNode môžu byť nevyužité. S využitím objektového programovania (letný semester) možno aritmetické stromy reprezentovať omnoho krajšie.

Vytvorenie uzlu aritmetického stromu

Nasledujúce funkcie vytvoria nový vnútorný uzol (pre operátor) resp. nový list (pre číslo):

treeNode *createOp(char op, treeNode *left, treeNode *right) {
    treeNode *v = new treeNode;
    v->left = left;
    v->right = right;
    v->op = op;
    return v; 
}

treeNode *createNum(double val) {
    treeNode *v = new treeNode;
    v->left = NULL;
    v->right = NULL;
    v->op = ' ';
    v->val = val;
    return v;
}

„Ručne” teraz môžeme vytvoriť strom pre výraz (65 – 3 * 5)/(2 + 3):

treeNode *root = createOp('/', 
                   createOp('-', 
                     createNum(65),
                     createOp('*', createNum(3), createNum(5))),
                   createOp('+', createNum(2), createNum(3)));

Alebo po častiach:

treeNode *v65 = createNum(65);
treeNode *v3 = createNum(3);
treeNode *v5 = createNum(5);
treeNode *v2 = createNum(2);
treeNode *v3b = createNum(3);
treeNode *vKrat = createOp('*', v3, v5);
treeNode *vMinus = createOp('-', v65, vKrat);
treeNode *vPlus = createOp('+', v2, v3b);
treeNode *vDeleno = createOp('/', vMinus, vPlus);

Uvoľnenie pamäte

Nasledujúca funkcia uvoľní z pamäte celý strom s koreňom root:

void destroyTree(treeNode *root) {
    if (root != NULL) {
        destroyTree(root->left);
        destroyTree(root->right);
        delete root;
    }
}

Vyhodnotenie výrazu reprezentovaného stromom

Nasledujúca rekurzívna funkcia vypočíta hodnotu aritmetického výrazu reprezentovaného stromom s koreňom root:

double evaluateTree(treeNode *root) {
    assert(root != NULL);
    if (root->op == ' ') {
        return root->val;
    } else {
        double valLeft = evaluateTree(root->left);
        double valRight = evaluateTree(root->right);
        switch (root->op) {
            case '+':
                return valLeft + valRight;
                break;
            case '-':
                return valLeft - valRight;
                break;
            case '*':
                return valLeft * valRight;
                break;
            case '/':
                return valLeft / valRight;
                break;
            default:
                assert(false);
                break;
        }
    }
    return 0; // realne nedosiahnutelny prikaz
}

Vypísanie výrazu reprezentovaného stromom v rôznych notáciách

Infixovú, prefixovú, resp. postfixovú reprezentáciu aritmetického výrazu reprezentovaného stromom s koreňom root možno získať pomocou nasledujúcich funkcií:

void printInorder(FILE *fw, treeNode *root) {
    if (root->op == ' ') {
        fprintf(fw, "%.2f", root->val);
    } else {
        fprintf(fw, "(");
        printInorder(fw, root->left);
        fprintf(fw, " %c ", root->op);
        printInorder(fw, root->right);
        fprintf(fw, ")");
    }
}
void printPreorder(FILE *fw, treeNode *root) {
    if (root->op == ' ') {
        fprintf(fw, "%.2f ", root->val);
    } else {
        fprintf(fw, "%c ", root->op);
        printPreorder(fw, root->left);
        printPreorder(fw, root->right);
    }
}
void printPostorder(FILE *fw, treeNode *root) {
    if (root->op == ' ') {
        fprintf(fw, "%.2f ", root->val);
    } else {
        printPostorder(fw, root->left);
        printPostorder(fw, root->right);
        fprintf(fw, "%c ", root->op);
    }
}

Vytvorenie stromu z postfixového výrazu

Pripomeňme si z minulej prednášky funkciu na vyhodnocovanie postfixového výrazu:

typedef double dataType;


/* Sem pride definicia struktury pre zasobnik a vsetkych funkcii poskytovanych zasobnikom. */


double evaluatePostfix(char *expr) {
    stack s;
    init(s);
    
    int i = 0;
    while (true) {
        while (expr[i] != 0 && expr[i] != '\n' && isspace(expr[i])) {
            i++;
        }
        if (expr[i] == 0 || expr[i] == '\n') {
            break;
        } else if (isdigit(expr[i])) {
            double num;
            sscanf(&expr[i], "%lf", &num);
            push(s, num);
            while (isdigit(expr[i]) || expr[i] == '.') {
                i++;
            } 
        } else {
            double num2 = pop(s);         
            double num1 = pop(s);         
            switch (expr[i]) {
                case '+':
                    push(s, num1 + num2);
                    break;
                case '-':
                    push(s, num1 - num2);
                    break;
                case '*':
                    push(s, num1 * num2);
                    break;    
                case '/':
                    push(s, num1 / num2);
                    break;    
            }
            i++;
        }
    }
    double result = pop(s);
    assert(isEmpty(s));
    destroy(s);
    
    return result;
}

Túto funkciu možno jednoducho prepísať tak, aby namiesto vyhodnocovania výrazu konštruovala zodpovedajúci aritmetický strom. Namiesto hodnôt jednotlivých podvýrazov stačí na zásobníku uchovávať korene stromov, ktoré tieto podvýrazy reprezentujú. Aplikácii aritmetickej operácie potom bude zodpovedať „spojenie” dvoch podstromov do jedného stromu:

typedef treeNode *dataType;


/* Sem pride definicia struktury pre zasobnik a vsetkych funkcii poskytovanych zasobnikom. */


treeNode *parsePostfix(char *expr) {
    stack s;
    init(s);
    
    int i = 0;
    while (true) {
        while (expr[i] != 0 && expr[i] != '\n' && isspace(expr[i])) {
            i++;
        }
        if (expr[i] == 0 || expr[i] == '\n') {
            break;
        } else if (isdigit(expr[i])) {
            double num;
            sscanf(&expr[i], "%lf", &num);
            push(s, createNum(num));
            while (isdigit(expr[i]) || expr[i] == '.') {
                i++;
            } 
        } else {
            treeNode *right = pop(s);         
            treeNode *left = pop(s);         
            push(s, createOp(expr[i], left, right));
            i++;
        }
    }
    treeNode *result = pop(s);
    assert(isEmpty(s));
    destroy(s);
    
    return result;
}

Ukážkový program pracujúci s aritmetickými stromami

Nasledujúci program prečíta z konzoly aritmetický výraz v postfixovom tvare, skonštruuje jeho aritmetický strom a následne preň zavolá funkcie na výpočet hodnoty výrazu a jeho výpis v rôznych notáciách:

int main(void) {           
    char expr[100];    
    fgets(expr, 100, stdin);
    treeNode *root = parsePostfix(expr); 

    printf("Hodnota vyrazu je: %.2f\n", evaluateTree(root));
    printf("Vyraz v infixovej notacii: ");
    printInorder(stdout, root);
    printf("\n");
    printf("Vyraz v prefixovej notacii: ");
    printPreorder(stdout, root);
    printf("\n");
    printf("Vyraz v postfixovej notacii: ");
    printPostorder(stdout, root);
    printf("\n");
      
    destroyTree(root);  
      
    return 0;
}

Binárne stromy

Aritmetické stromy sú špeciálnym prípadom binárnych stromov. V informatike nachádzajú binárne stromy množstvo rozličných uplatnení – zameriame sa preto na všeobecnú dátovú štruktúru binárneho stromu. V tomto všeobecnejšom kontexte sa pritom znova objavia niektoré techniky, ktoré sme používali v špeciálnom prípade aritmetických stromov.

Terminológia stromov

Pod stromom budeme rozumieť množinu vrcholov pospájaných hranami tak, aby každá dvojica vrcholov bola spojená práve jednou postupnosťou hrán (vrcholy teda nemôžu byť pospájané cyklicky). Navyše nás na tomto predmete budú zaujímať iba zakorenené stromy, ktoré obsahujú práve jeden špeciálny vrchol nazývaný koreňom. Vždy, keď budeme hovoriť o stromoch, budeme mať na mysli zakorenené stromy. V súvislosti s nimi budeme o vrcholoch väčšinou hovoriť ako o uzloch.

Poznámka. Takáto definícia stromov nie je úplne matematicky presná (ide o „popularizáciu” ozajstnej definície). Poriadne definície pojmov súvisiacich so stromami patria do náplne predmetu Úvod do kombinatoriky a teórie grafov (letný semester).

Každý uzol u zakoreneného stromu okrem koreňa je spojený hranou s práve jedným otcom (alebo rodičom; angl. parent), ktorým je nejaký uzol v (ide o jediný vrchol stromu spojený hranou s uzlom u, ktorý je bližšie ku koreňu, než uzol u). Naopak potom hovoríme, že uzol u je synom (alebo dieťaťom; angl. child resp. son) uzla v. Vo všeobecnom zakorenenom strome pritom môže mať každý uzol ľubovoľný prirodzený (a teda aj nulový) počet synov. Na tomto predmete budeme navyše predpokladať, že pre každý vrchol je dané nejaké úplné usporiadanie jeho synov (možno teda rozlišovať medzi „najľavejším” synom, synom druhým zľava, atď.). Strom je binárny, ak má každý uzol najviac dvoch synov. Budeme pritom rozlišovať medzi pravým a ľavým synom – každý uzol má najviac jedného ľavého a najviac jedného pravého syna.

Na tomto predmete budeme odteraz pod stromom vždy rozumieť zakorenený binárny strom s rozlišovaním medzi pravými a ľavými synmi.

Predkom uzla u nazveme ľubovoľný uzol v rôzny od u ležiaci na nejakej ceste z u do koreňa stromu. Naopak potom hovoríme, že u je potomkom uzla v. Uzly zakoreneného stromu, ktoré nemajú žiadneho syna, nazývame listami; zvyšné uzly potom nazývame vnútornými uzlami.

Podstromom stromu T zakoreneným v nejakom uzle v stromu T budeme rozumieť strom s koreňom v pozostávajúci z uzla v, všetkých jeho potomkov a všetkých hrán stromu T vedúcich medzi týmito uzlami.

Každý binárny strom je teda buď prázdny, alebo je tvorený jeho koreňom a dvoma podstromami – ľavým a pravým.

Štruktúra pre uzol binárneho stromu

V nasledujúcom budeme pracovať výhradne s binárnymi stromami. Štruktúra pre uzol všeobecného binárneho stromu je podobná, ako pri aritmetických stromoch – namiesto operátora alebo hodnoty si však v každom uzle môžeme pamätať hodnotu ľubovoľného typu dataType.

Keďže neskôr budeme binárne stromy vypisovať, budeme predpokladať, že pre hodnoty typu dataType máme k dispozícii funkciu printDataType, ktorá ich v nejakom vhodnom formáte vypisuje. Nasledujúci kus kódu zodpovedá situácii, keď dataType je int.

#include <cstdio>
using namespace std;


// ...


/* Typ prvkov ukladanych v uzloch binarneho stromu -- moze byt prakticky lubovolny */
typedef int dataType;          

/* Funkcia pre vypis hodnoty typu dataType */
void printDataType(dataType d) {
    printf("%d ", d);  // pre int
}


// ...


/* Uzol binarneho stromu */
struct node {
    dataType data;  // hodnota ulozena v danom uzle
    node *left;     // smernik na laveho syna (NULL, ak tento syn neexistuje)
    node *right;    // smernik na praveho syna (NULL, ak tento syn neexistuje)
};

Vytvorenie binárneho stromu

Nasledujúca funkcia vytvorí uzol binárneho stromu s dátami data, ľavým podstromom zakoreneným v uzle *left a pravým podstromom zakoreneným v uzle *right (parametre left a right sú teda smerníkmi na uzly). Ako výstup funkcia vráti smerník na novovytvorený uzol.

/* Vytvori uzol binarneho stromu */
node *createNode(dataType data, node *left, node *right) {
    node *v = new node;
    v->data = data;
    v->left = left;
    v->right = right;
    return v;
}

Nasledujúca volanie tak napríklad vytvorí binárny strom so šiestimi uzlami zakorenený v uzle *root.

node *root = createNode(1,
                   createNode(2, 
                     createNode(3, NULL, NULL),
                     createNode(4,NULL,NULL)),
                   createNode(5,
                     NULL, 
                     createNode(6, NULL, NULL)));

Cvičenie: nakreslite binárny strom vytvorený predchádzajúcim volaním.

Likvidácia binárneho stromu

Nasledujúca rekurzívna funkcia zlikviduje celý podstrom zakorenený v uzle *root (t. j. pouvoľňuje pamäť pre všetky jeho uzly).

/* Zlikviduje podstrom s korenom *root (pouvolnuje pamat) */
void destroyTree(node *root) {
    if (root != NULL) {
        destroyTree(root->left);
        destroyTree(root->right);
        delete root;
    }
}

Výška binárneho stromu

Hĺbkou uzla binárneho stromu nazveme jeho vzdialenosť od koreňa. Koreň má teda hĺbku 0, jeho synovia majú hĺbku 1, atď. Výškou binárneho stromu (niekedy nazývanou aj jeho hĺbkou) potom nazveme maximálnu hĺbku niektorého z jeho vrcholov. Strom s jediným vrcholom má teda hĺbku 0; pre ostatné stromy je ich výška daná ako 1 plus maximum z výšok ľavého a pravého podstromu.

Nasledujúca funkcia počíta výšku stromu (kvôli elegancii zápisu pritom pracuje s rozšírením definície výšky stromu na prázdne stromy, za ktorých výšku sa považuje číslo -1).

/* Spocita vysku podstromu s korenom *root. Pre root == NULL vrati -1. */
int height(node *root) {
    if (root == NULL) {
        return -1;
    }
    int hLeft = height(root->left);    // rekurzivne spocita vysku laveho podstromu
    int hRight = height(root->right);  // rekurzivne spocita vysku praveho podstromu
    if (hLeft >= hRight) {             // vrati max(hLeft, hRight) + 1
        return hLeft + 1;
    } else {
        return hRight + 1;
    }
}

Pre výšku h stromu s n uzlami pritom platia nasledujúce vzťahy:

  • Určite h ≤ n-1. Tento prípad nastáva, ak sú všetky uzly „navešané jeden pod druhý”.
  • Strom s výškou h má najviac
Formula.png
uzlov (ako možno ľahko dokázať indukciou vzhľadom na h).
  • Z toho h ≥ log2(n+1)-1.
  • Dostávame teda log2(n+1)-1 ≤ h ≤ n-1.
  • Napríklad strom s milión vrcholmi má teda hĺbku medzi 19 a 999999.

Prehľadávanie stromov a vypisovanie ich uzlov

Často je potrebné prejsť celý strom a spracovať (napríklad vypísať) hodnoty vo všetkých uzloch. Toto prehľadávanie možno, podobne ako pri aritmetických stromoch, realizovať v troch základných poradiach: preorder, inorder a postorder.

/* Vypise podstrom s korenom *root v poradi preorder */
void printPreorder(node *root) {
    if (root == NULL) {
        return;
    }
    printDataType(root->data);
    printPreorder(root->left);
    printPreorder(root->right);
}

/* Vypise podstrom s korenom *root v poradi inorder */
void printInorder(node *root) {
    if (root == NULL) {
        return;
    }
    printInorder(root->left);
    printDataType(root->data);
    printInorder(root->right);
}

/* Vypise podstrom s korenom *root v poradi postorder */
void printPostorder(node *root) {
    if (root == NULL) {
        return;
    }
    printPostorder(root->left);
    printPostorder(root->right);
    printDataType(root->data);
}

Príklad: plné binárne stromy

Binárny strom výšky h s maximálnym počtom vrcholov 2h+1-1 sa nazýva plný binárny strom. Nasledujúca funkcia createFullTree vytvorí takýto strom a vráti smerník na jeho koreň. Jeho uzlom pritom priradí po dvoch rôzne hodnoty typu int (predpokladáme teda, že dataType je int) podľa globálnej premennej count.

// ...

int count;

/* Vytvori plny binarny strom vysky height s datami uzlov count, count + 1, ... */ 
node *createFullTree(int height) {    
    if (height == -1) {
        return NULL;
    }
    node *v = createNode(count++, NULL, NULL);
    v->left = createFullTree(height - 1);
    v->right = createFullTree(height - 1);
    return v;
}

int main(void) {
    count = 1;
    node *root = createFullTree(3);
                     
    printf("Vyska: %d\n", height(root));                 
    printf("Inorder: ");
    printInorder(root);
    printf("\n");
    printf("Preorder: ");
    printPreorder(root);
    printf("\n");
    printf("Postorder: ");
    printPostorder(root);
    printf("\n");
                     
    destroyTree(root);                 
    return 0;
}

Cvičenie: opíšte poradie, v ktorom sa v uvedenom programe jednotlivým uzlom priraďujú ich hodnoty.