Programovanie (1) v C/C++
1-INF-127, ZS 2024/25
Prednáška 21
Obsah
- 1 Oznamy
- 2 Sylaby predmetu
- 3 Binárne vyhľadávacie stromy
- 3.1 Definícia dátových štruktúr
- 3.2 Likvidácia binárneho vyhľadávacieho stromu
- 3.3 Hľadanie v binárnom vyhľadávacom strome
- 3.4 Vkladanie do binárneho vyhľadávacieho stromu
- 3.5 Minimálny uzol
- 3.6 Následník uzla
- 3.7 Mazanie z binárneho vyhľadávacieho stromu
- 3.8 Zložitosť jednotlivých operácií
- 3.9 Príklad programu pracujúceho s binárnymi vyhľadávacími stromami
Oznamy
Plán prednášok a cvičení na zvyšok semestra:
- Dnes informácia o skúškach, detaily skúšky z programovania, pokračujeme v učive o stromoch
- Tento piatok 10.12. cez cvičenia semestrálny test.
- V pondelok 13.12. bude bežná prednáška, pokračujú stromy.
- V stredu 15.12. dokončíme stromy, potom nepovinná prednáška o nepreberaných črtách jazykov C a C++ (táto nepovinná časť učiva nebude vyžadovaná na skúške, ale môžete ju použiť).
- V utorok 14.12. v rámci cvičení tréning na skúšku.
- Už dnes po prednáške sa na testovači objavia tréningové príklady na skúšku. Za niektoré budete môcť získať bonusový bod, ak ich vyriešite do 12.1. (ako tréning sa dajú riešiť aj neskôr). V utorok na cvičeniach pribudne ešte jeden tréningový príklad za 4 body. Ak prídete na cvičenia a odovzdáte na konci aspoň rozumne rozrobenú verziu programu, získate jeden bonusový bod, aj keď ho nestihnete dokončiť.
- V piatok 17.12. od 12:00 predtermín skúšky, doplnkové cvičenia nebudú
Sylaby predmetu
Základy
Konštrukcie jazyka C
- premenné typov int, double, char, bool, konverzie medzi nimi
- podmienky (if, else, switch), cykly (for, while)
- funkcie (a parametre funkcií - odovzdávanie hodnotou, referenciou, smerníkom)
void f1(int x){} //hodnotou
void f2(int &x){} //referenciou
void f3(int* x){} //smerníkom
void f(int a[], int n){} //polia bez & (ostanú zmeny)
void kresli(Turtle &t){} //korytnačky, SVGdraw a pod. s &
Polia, reťazce (char[])
int A[4]={3, 6, 8, 10};
int B[4];
B[0]=3; B[1]=6; B[2]=8; B[3]=10;
char C[100] = "pes";
char D[100] = {'p', 'e', 's', 0};
- funkcie strlen, strcpy, strcmp, strcat
Súbory, spracovanie vstupu
- cin, cout alebo printf, scanf
- fopen, fclose, feof
- fprintf, fscanf
- getc, putc, ungetc, fgets, fputs
- spracovanie súboru po znakoch, po riadkoch, po číslach alebo slovách
Smerníky, dynamicky alokovaná pamäť, dvojrozmerné polia
int i; // „klasická“ celočíselná premenná
int *p; // ukazovateľ na celočíselnú premennú
p = &i; // spravne
p = &(i + 3); // zle i+3 nie je premenna
p = &15; // zle konstanta nema adresu
i = *p; // spravne ak p bol inicializovany
int * cislo = new int; // alokovanie jednej premennej
*cislo = 50;
..
delete cislo;
int a[4];
int *b = a; // a,b su teraz takmer rovnocenne premenne
int *A = new int[n]; // alokovanie 1D pola danej dlzky
..
delete[] A;
int **a; // alokovanie 2D matice
a = new int *[n];
for (int i = 0; i < n; i++) a[i] = new int[m];
..
for (int i = 0; i < n; i++) delete[] a[i];
delete[] a;
Abstraktné dátové typy
Abstraktný dátový typ dynamické pole (rastúce pole)
- operácie init, add, get, set, length
Abstraktný dátový typ množina (set)
- operácie init, find, insert, remove
- implementácie pomocou
- neutriedeného poľa
- utriedeného poľa
- spájaných zoznamov
- binárnych vyhľadávacích stromov
- hašovacej tabuľky
- lexikografického stromu (ak kľúč je reťazec)
Abstraktné dátové typy rad a zásobník
- operácie pre rad (frontu, queue): init, isEmpty, enqueue, dequeue, peek
- operácie pre zásobník (stack): init, isEmpty, push, pop
- implementácie: v poli alebo v spájanom zozname
- využitie: ukladanie dát na spracovanie, odstránenie rekurzie
- kontrola zátvoriek a vyhodnocovanie výrazov pomocou zásobníka
Dátové štruktúry
Spájané zoznamy
struct node {
int data;
item* next;
};
struct linkedList {
item* first;
};
void insertFirst(linkedList &z, int d){
/* do zoznamu z vlozi na zaciatok novy prvok s datami d */
item* p = new item; // vytvoríme nový prvok
p->data = d; // naplníme dáta
p->next = z.first; // prvok bude prvým prvkom zoznamu (ukazuje na doterajší začiatok)
z.first = p; // tento prvok je novým začiatkom
}
Binárne stromy
struct node {
/* vrchol stromu */
dataType data;
node * left; /* lavy syn */
node * right; /* pravy syn */
};
node * createNode(dataType data, node *left, node *right) {
node *v = new node;
v->data = data;
v->left = left;
v->right = right;
return v;
}
- prehľadávanie inorder, preorder, postorder
- použitie na uloženie aritmetických výrazov
Binárne vyhľadávacie stromy
- vrcholy vľavo od koreňa menší kľúč, vpravo od koreňa väčší
- insert, find, remove v čase závisiacom od hĺbky stromu
Lexikografické stromy
- ukladajú množinu reťazcov
- nie sú binárne: vrchol môže mať veľa synov
- insert, find, remove v čase závisiacom od dĺžky kľúča, ale nie od počtu kľúčov, ktoré už sú v strome
struct node {
/* vrchol lexikografickeho stromu */
char data; // pismeno ulozene v tomto vrchole
bool isWord; // je tento vrchol koncom slova?
node* next[Abeceda]; // pole smernikov na deti
};
Hašovanie
- hašovacia tabuľka veľkosti m
- kľúč k premietneme nejakou funkciou na index v poli (0,...,m-1}
- každé políčko hašovacej tabuľky spájaný zoznam prvkov, ktoré sa tam zahašovali
- v ideálnom prípade sa prvky rozhodia pomerne rovnomerne, zoznamy krátke, rýchle hľadanie, vkladenie, mazanie
- v najhoršom prípade všetky prvky v jednom zozname, pomalé hľadanie a mazanie
int hash(int k, int m){ // veľmi jednoduchá hašovacia funkcia, v praxi väčšinou zložitejšie
return abs(k) % m;
}
struct node {
int item;
node* next;
};
struct set {
node** data;
int m;
};
Algoritmy
Rekurzia
- Rekurzívne funkcie
- Vykresľovanie fraktálov
- Prehľadávanie s návratom (backtracking)
- Vyfarbovanie
- Prehľadávanie stromov
Triedenia
- nerekurzívne: Bubblesort, Selectionsort, Insertsort
- rekurzívne: Mergesort, Quicksort
- súvisiace algoritmy: binárne vyhľadávanie
Matematické úlohy
- Euklidov algoritmus, Eratostenovo sito
- Práca s aritmetickými výrazmi: vyhodnocovanie postfixovej formy, prevod z infixovej do postfixovej, reprezentácia vo forme stromu
Binárne vyhľadávacie stromy
Stromy sa v informatike často používajú. Ďalším príkladom sú binárne vyhľadávacie stromy, ktoré slúžia na ukladanie množiny prvkov. Prvky množiny teda nemáme v poli, ani v spájanom zozname, ale vo vrcholoch binárneho stromu.
- V binárnom vyhľadávacom strome má každý vrchol 0,1 alebo 2 deti
- V každom vrchole máme položku s dátami (pre jednoduchosť typu int)
- Pre každý vrchol v stromu platí:
- Každý vrchol v ľavom podstrome v má hodnotu data menšiu ako vrchol v
- Každý vrchol v pravom podstrome v má hodnotu data väčšiu ako vrchol v
- Z toho vyplýva, že ak vypíšeme strom v inorder poradí, dostaneme prvky usporiadané
- Pre danú množinu kľúčov existuje veľa vyhľadávacích stromov
Cvičenie: nájdite všetky binárne vyhľadávacie stromy pozostávajúce z troch uzlov s kľúčmi 1, 2, 3.
Definícia dátových štruktúr
Vrchol typu node
- Položka data typu int
- Smerník na ľavé a pravé dieťa
- Na niektoré úlohy (napr. mazanie vrcholu) sa hodí aj smerník na rodiča (ten má hodnotu NULL v koreni)
Celý strom je štruktúra obsahujúca iba smerník na koreň
- Pre prázdny strom je to NULL.
struct node {
/* vrchol binárneho vyhľadávacieho stromu */
int data; /* hodnota */
node * parent; /* rodič vrchola, NULL v koreni */
node * left; /* ľavé dieťa, NULL ak neexistuje */
node * right; /* pravé dieťa, NULL ak neexistuje */
};
/* Samotná štruktúra binárneho vyhľadávacieho stromu (obal pre používateľa). */
struct binarySearchTree {
node *root; /* koreň stromu, NULL pre prázdny strom */
};
Inicializácia prázdneho binárneho vyhľadávacieho stromu
/** Funkcia inicializuje prázdny binárny vyhľadávací strom */
void init(binarySearchTree &t) {
t.root = NULL;
}
Likvidácia binárneho vyhľadávacieho stromu
Likvidáciu podstromu zakoreneného v danom uzle root realizujeme funkciou destroy, obdobne ako pri všeobecných binárnych stromoch. Používateľovi navyše dáme k dispozícii aj funkciu bstDestroy, ktorá zlikviduje binárny vyhľadávací strom t tak, že zavolá funkciu destroy na jeho koreň.
/* Uvolni pamat pre podstrom s korenom *root. */
void destroy(node *root) {
if (root != NULL) {
destroy(root->left);
destroy(root->right);
delete root;
}
}
/* Zlikviduje strom t (uvolni pamat). */
void bstDestroy(binarySearchTree &t) {
destroy(t.root);
}
Hľadanie v binárnom vyhľadávacom strome
Funkcia findNode v podstrome s koreňom root hľadá uzol, ktorého kľúč je rovný key. Vráti takýto uzol alebo NULL ak neexistuje
- Najskôr porovná hľadané dáta s dátami v koreni
- Ak sa rovnajú, končíme (našli sme, čo hľadáme)
- Ak je hľadaná hodnota menšia ako dáta v koreni, musí byť v ľavom podstrome, ak je väčšia v pravom
- V príslušnom podstrome sa rozhodujeme podľa tých istých pravidiel
- Keď narazíme na prázdny podstrom, dáta sa v strome nenachádzajú
- Dá sa zapísať rekurzívne alebo cyklom, lebo vždy ideme iba do jedného podstromu
Pomocná funkcia bstFind zavolá funkciu findNode pre koreň daného binárneho vyhľadávacieho stromu t a pomocou nej zistí, či tento strom obsahuje uzol s kľúčom key.
/* Ak v strome s korenom root existuje uzol s klucom key,
* vrati ho na vystupe. Inak vrati NULL. */
node * findNode(node *root, dataType item) {
node * v = root;
while (v != NULL && v->data != item) {
if (item < v->data) {
v = v->left;
} else {
v = v->right;
}
}
return v;
}
/** Rekurzivna verzia */
node *findNodeR(node *root, int key) {
if (root == NULL || root->key == key) {
return root;
} else if (key < root->key) {
return findNodeR(root->left, key);
} else {
return findNodeR(root->right, key);
}
}
/* Zisti, ci strom t obsahuje uzol s klucom key. */
bool bstFind(binarySearchTree &t, int key) {
return findNode(t.root, key) != NULL;
}
Čas výpočtu je v najhoršom prípade úmerný výške stromu.
Vkladanie do binárneho vyhľadávacieho stromu
Nasledujúca funkcia insertNode vloží uzol *v na správne miesto podstromu zakoreneného v *root ako jeho list.
- Predpokladáme, že prvok v strome nie je.
- Putujeme po strome podobne ako pri vyhľadávaní prvku, až kým nenarazíme na nulový smerník.
- Na tomto mieste by mal byť nový prvok, takže ho tam pridáme ako nový list
- Uvádzame rekurzívnu verziu, dá sa aj cyklom, podobne ako pri hľadaní
- Funkcia bstInsert vytvorí uzol s daným kľúčom key a pomocou funkcie insertNode ho vloží do binárneho vyhľadávacieho stromu t.
/* Vloží uzol v na správne miesto podstromu zakoreneného v root */
void insertNode(node *root, node *v) {
assert(root != NULL && v != NULL);
if (v->key < root->key) {
if (root->left == NULL) {
root->left = v;
v->parent = root;
} else {
insertNode(root->left, v);
}
} else {
if (root->right == NULL) {
root->right = v;
v->parent = root;
} else {
insertNode(root->right, v);
}
}
}
/* Vloží do stromu t nový uzol s kľúčom key. */
void bstInsert(binarySearchTree &t, int key) {
node *v = new node;
v->key = key;
v->left = NULL;
v->right = NULL;
v->parent = NULL;
if (t.root == NULL) {
t.root = v;
} else {
insertNode(t.root, v);
}
}
Čas vkladania je tiež v najhoršom prípade úmerný hĺbke stromu.
Cvičenia
- Napíšte nerekurzívny variant funkcie insertNode.
- Napíšte funkciu treeSort, ktorá z poľa celých čísel a pomocou volaní funkcie bstInsert vytvorí binárny vyhľadávací strom a následne pomocou prehľadávania tohto stromu v poradí inorder pole a utriedi.
Minimálny uzol
Nasledujúca funkcia minNode nájde v podstrome zakorenenom v *root uzol s minimálnym kľúčom. Je pritom založená na skutočnosti, že všetky uzly tohto podstromu s kľúčom menším ako root->key sa musia nachádzať v ľavom podstrome uzla *root.
„Obalom” pre používateľa bude funkcia bstMin, ktorá pomocou funkcie minNode nájde minimálny kľúč v danom binárnom vyhľadávacom strome t.
/* Vrati (niektory) uzol s minimalnou hodnotou key v podstrome s korenom *root. */
node *minNode(node *root) {
assert(root != NULL);
if (root->left != NULL) {
return minNode(root->left);
} else {
return root;
}
}
// ...
/* Vrati minimalny kluc uzla v strome t. */
int bstMin(binarySearchTree &t) {
assert(t.root != NULL);
return minNode(t.root)->key;
}
Cvičenie: napíšte nerekurzívny variant funkcie minNode.
Následník uzla
Funkcia successorNode nájde pre daný uzol *v jeho následníka (angl. successor) v binárnom vyhľadávacom strome – čiže uzol, ktorý vo vzostupnom poradí podľa kľúčov nasleduje bezprostredne za uzlom *v. Je pritom založená na nasledujúcich pozorovaniach:
- Ak má uzol *v pravého syna, následník uzla *v musí byť v jeho pravom podstrome – konkrétne pôjde o minimálny uzol z tohto podstromu.
- V opačnom prípade môže byť následníkom uzla *v jeho otec (ak *v je jeho ľavý syn). Ak je *v pravým synom svojho otca, môže to byť aj jeho starý otec (ak je otec uzla *v ľavým synom tohto starého otca), atď. Vo všeobecnosti teda ide o najbližšieho predka uzla *v takého, že *v patrí do jeho ľavého podstromu.
- V strome existuje práve jeden uzol bez následníka (jeden spomedzi najväčších prvkov).
/* Vrati uzol, ktory vo vzostupnom poradi uzlov podla klucov nasleduje za *v. Ak taky uzol neexistuje, vrati NULL. */
node *successorNode(node *v) {
assert(v != NULL);
if (v->right != NULL) {
return minNode(v->right);
}
while (v->parent != NULL && v == v->parent->right) {
v = v->parent;
}
return v->parent;
}
Mazanie z binárneho vyhľadávacieho stromu
Nasledujúca funkcia bstRemove zmaže z binárneho vyhľadávacieho stromu t práve jeden uzol s kľúčom key (ak sa taký uzol v strome vyskytuje). Pracuje tak, že najprv pomocou funkcie findNode nájde uzol *v s kľúčom key. V prípade úspechu zistí počet synov uzla *v. Ak totiž *v nemá žiadneho syna alebo má len jedného syna, možno ho zo stromu t zmazať jednoducho tak, že sa prípadný syn uzla *v stane synom otca uzla *v. V prípade, že má *v dvoch synov je však zrejmé, že jeho následník sa musí nachádzať v jeho neprázdnom pravom podstrome. Tento následník *rm navyše nemôže mať ľavého syna. Odstránenie kľúča key je teda možné realizovať tak, že sa kľúč uzla *rm presunie do uzla *v a následne sa odstráni uzol *rm tak, ako je popísané vyššie.
/* Zmaze zo stromu t prave jeden uzol s klucom key (ak tam taky je). */
void bstRemove(binarySearchTree &t, int key) {
node *v = findNode(t.root, key); // Najde uzol v s hodnotou, ktoru treba vymazat.
if (v == NULL) {
return;
}
node *rm; // Najde uzol *rm stromu t, ktory sa napokon realne zmaze.
if (v->left == NULL || v->right == NULL) {
rm = v;
} else {
rm = successorNode(v);
}
if (rm != v) { // Ak rm != v, presunie kluc uzla *rm do uzla *v.
v->key = rm->key;
}
node *child; // Zmaze uzol *rm a uvolni pamat alokovanu pre tento uzol.
if (rm->left != NULL) {
child = rm->left;
} else {
child = rm->right;
}
if (child != NULL) {
child->parent = rm->parent;
}
if (rm->parent == NULL) {
t.root = child;
} else if (rm == rm->parent->left) {
rm->parent->left = child;
} else if (rm == rm->parent->right) {
rm->parent->right = child;
}
delete rm;
}
Zložitosť jednotlivých operácií
- Časová zložitosť operácií bstFind(t), bstInsert(t) aj bstRemove(t) je úmerná hodnote height(t), čo je výška stromu t.
- Minule sme ukázali, že pre výšku h stromu s n vrcholmi je log2(n+1)-1 ≤ h ≤ n-1.
- Zložitosť uvedených operácií je teda v najhoršom prípade lineárna od počtu uzlov stromu (tento prípad nastane, ak prvky vkladáme od najmenšieho po najväčší alebo naopak).
- Dá sa však ukázať, že v priemernom prípade je ich zložitosť rádovo logaritmická od počtu uzlov.
- Na predmete Algoritmy a dátové štruktúry (druhý ročník) sa tieto tvrdenia dokazujú poriadne a preberajú sa tam aj varianty vyhľadávacích stromov, pre ktoré je zložitosť uvedených operácií logaritmická aj v najhoršom prípade.
Príklad programu pracujúceho s binárnymi vyhľadávacími stromami
Nasledujúci program realizuje základné operácie s binárnymi vyhľadávacími stromami podľa príkazov zadávaných používateľom na konzolu.
#include <cstdio>
#include <cstring>
#include <cassert>
using namespace std;
// ...
int main(void) {
binarySearchTree t;
bstInit(t);
char command[20];
int key;
while (true) {
scanf("%19s", command);
if (strcmp(command, "insert") == 0) {
scanf("%d", &key);
bstInsert(t, key);
}
if (strcmp(command, "remove") == 0) {
scanf("%d", &key);
bstRemove(t, key);
}
if (strcmp(command, "find") == 0) {
scanf("%d", &key);
bool b = bstFind(t, key);
if (b) {
printf("YES\n");
} else {
printf("NO\n");
}
}
if (strcmp(command, "min") == 0) {
printf("%d\n", bstMin(t));
}
if (strcmp(command, "exit") == 0) {
break;
}
}
bstDestroy(t);
return 0;
}