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ú.


2024/25 Programovanie (1) v C/C++

Z Programovanie
Skočit na navigaci Skočit na vyhledávání
Týždeň 23.-29.9. Úvod, premenné, podmienky, výrazy, cyklus for
#Prednáška 1 · #Prednáška 2 · #Cvičenia 1
Týždeň 30.9-5.10. Ďalšie príklady na cykly, Euklidov algoritmus, cyklus while, funkcie
#Prednáška 3 · #Prednáška 4 · Cvičenia 2
Týždeň 6.-13.10. Dokončenie funkcií, teoretické cvičenie, test, polia, struct
#Prednáška 4b · #Prednáška 5 · Cvičenia 3
Týždeň 14.-20.10. Eratostenovo sito, jednoduché triedenia, binárne vyhľadávanie, zložitosť, znaky, switch
#Prednáška 6 · #Prednáška 7 · Cvičenia 4
Týždeň 21.-27.10. Reťazce, úvod do rekurzie
#Prednáška 8 · #Prednáška 9 · Cvičenia 5
Týždeň 28.10-3.11. Prehľadávanie s návratom, Mergesort, Quicksort
#Prednáška 10 · #Prednáška 11 · Cvičenia 6
Týždeň 4.11.-10.11. Smerníky, dynamické polia, práca s dvojrozmernými údajmi
#Prednáška 12 · #Prednáška 13 · Cvičenia 7
Týždeň 11.-17.11. Spájaný zoznam, hašovanie
#Prednáška 14 · Cvičenia 8
Týždeň 18.-24.11. Práca s konzolou na spôsob jazyka C, textové súbory
#Prednáška 15 · #Prednáška 16 · Cvičenia 9
Týždeň 25.11.-1.12. Zásobník, rad, vyfarbovanie
#Prednáška 17 · #Prednáška 18 · Cvičenia 10
Týždeň 2.-8.12. Aritmetické výrazy, aritmetické stromy, binárne stromy vo všeobecnosti
#Prednáška 19 · #Prednáška 20 · Cvičenia 11
Týždeň 9.-15.12. Binárne vyhľadávacie stromy, informácie ku skúške, prefixové stromy, zhrnutie
#Prednáška 21 · #Prednáška 22 · Cvičenia 12
Týždeň 16.-22.12. Nepreberané črty C a C++, rezerva
#Prednáška 23 · Cvičenia 13

Obsah

Zimný semester, úvodné informácie

Základné údaje

Rozvrh

  • Prednášky: pondelok 9:50 F1 a streda 9:50 F1
  • Hlavné cvičenia:
    • utorok 9:50 I-H6 ALEBO
    • utorok 14:50 I-H6
    • rozdelenie do skupín bolo rozposlané emailom
  • Doplnkové cvičenia: piatok 13:10 I-H3, I-H6 (stačí, ak prídete 13:25, ale nie neskôr)

Vyučujú

Konzultácie po dohode e-mailom.

Ak nemáte otázku na konkrétnu osobu, odporúčame vyučujúcich kontaktovať pomocou spoločnej adresy e-mailovej adresy E-prg.png. Odpovie vám ten z nás, kto má na starosti príslušnú otázku alebo kto má práve čas.

Ciele predmetu

  • Naučiť sa algoritmicky uvažovať, písať kratšie programy a hľadať v nich chyby, porozumieť existujúcemu kódu
  • Oboznámiť sa so základnými programovými a dátovými štruktúrami jazyka C resp. C++, nie je však nutne so všetkými črtami týchto jazykov
    • Cykly, podmienky, premenné a ich typy, funkcie a odovzdávanie parametrov, polia, smerníky, reťazce, súbory
  • Oboznámiť sa s niektorými základnými algoritmami a dátovými štruktúrami
    • Triedenia, spájané zoznamy, hašovacie tabuľky, stromy, aritmetické výrazy, rad a zásobník, rekurzia, prehľadávanie, vyfarbovanie
  • Aj štruktúry, ktoré sú hotové v C++ knižniciach, si budeme programovať sami, aby sme videli, čo sa za nimi skrýva

Literatúra

  • Predmet sa nebude striktne riadiť žiadnou učebnicou. Prehľad preberaných tém a stručné poznámky nájdete na stránke predmetu, odporúčame vám si na prednáškach a cvičeniach robiť vlastné poznámky.
  • Pri štúdiu vám môžu pomôcť knihy o jazykoch C a C++, o programovaní všeobecne a o algoritmoch preberaných na prednáške. Tu je výber z vhodných titulov, ktoré sú k dispozícii na prezenčné štúdium vo fakultnej knižnici:
    • Prokop: Algoritmy v jazyku C a C++ praktický průvodce, Grada 2008, I-INF-P-26
    • Sedgewick: Algorithms in C. Parts 1-4 I-INF-S-43/I-IV
    • Kochan: Programming in C, 2005 D-INF-K-7a
  • Referenčnú príručku k jazyku C++ nájdete napríklad na tejto webstránke: http://cplusplus.com/
  • Môže vás zaujímať aj video prednášok z iných škôl v angličtine

Priebeh semestra

  • Na prednáškach budeme preberať obsah predmetu. Prednášky budú štyri vyučovacie hodiny do týždňa.
  • Hlavné cvičenia budú dve vyučovacie hodiny do týždňa v počítačovej učebni a ich cieľom je aktívne si precvičiť učivo. Hlavnou náplňou cvičenia je riešenie zadaných príkladov. Cvičiaci vám podľa potreby pomôžu a poradia.
  • Príklady z hlavných cvičení, ktoré nestihnete vyriešiť, môžete dokončiť doma alebo na cvičeniach v piatok.
  • Okrem toho sa každý týždeň konajú doplnkové cvičenia (tiež dve vyučovacie hodiny). Sú povinné pre študentov, ktorí mali problémy na cvičeniach v utorok ale radi privítame aj ďalších. Na tomto cvičení s pomocou cvičiacich môžete dokončovať príklady z predchádzajúcich cvičení, pýtať sa otázky k učivu, prípadne pracovať na domácej úlohe.
  • Domáce úlohy budú cca 3 cez semester. Pracujte na nich samostatne doma, prípadne na doplnkových cvičeniach. Nechajte si na ne dosť času, nezačnite tesne pred termínom.
  • Príklady na cvičenia a domáce úlohy navrhujeme tak, aby vám ich riešenie pomohlo precvičiť si učivo, čím sa okrem iného pripravujete aj na záverečnú skúšku. Okrem tohto sú za tieto príklady body do záverečného hodnotenia. Najviac sa naučíte, ak sa vám príklad podarí samostatne vyriešiť, ale ak sa vám to napriek vášmu úsiliu nedarí, neváhajte sa spýtať o pomoc vyučujúcich. Možno s malou radou od nás sa Vám podarí úlohu spraviť.
  • Cieľom vyučujúcich tohto predmetu je vás čo najviac naučiť, ale musíte aj vy byť aktívni partneri. Ak vám na prednáške alebo cvičení nie je niečo jasné, spýtajte sa. V prípade problémov odporúčame navštíviť doplnkové cvičenia, alebo si dohodnúť konzultáciu. Môžete nám klásť tiež otázky emailom. Ak sa dostanete do väčších problémov s plnením študijných povinností, poraďte sa s vyučujúcimi alebo s tútorom, ako tieto problémy riešiť.


Celkové odporúčania

Prichádzajúci študenti v prvom ročníku majú veľmi rôznu úroveň znalosti programovania, v závislosti od toho, koľko sa mu venovali na strednej škole. Preto pre niektorých môže byť tento predmet veľmi ľahký, pre iných veľmi ťažký. Môže sa to zdať nespravodlivé, ale pokročilí študenti už nad programovaním strávili dlhé hodiny a začiatočníci ich bez určitej námahy nedobehnú. Veľmi radi vám však pomôžeme prekonať nástrahy tohto predmetu. Tu sú naše odporúčania podľa toho, aké znalosti už máte na začiatku semestra. Učebnú látku možno zhruba rozdeliť na základné programovacie konštrukty jazyka C resp. C++ a základné algoritmy, ktoré sa budú počas semestra striedať.

Úroveň znalostí Náročnosť látky: základy programovania v C Náročnosť látky: algoritmy, rekurzia Odporúčanie
Programovať viem len málo alebo vôbec ťažké ťažké Dôležité je začať usilovne pracovať už od začiatku semestra. Odporúčame chodiť aj na doplnkové cvičenia, ďalšie príklady riešiť doma. Neváhajte sa nás spýtať, ak vám niečo nie je jasné.
Som skúsený programátor, ale neovládam C ani C++ ľahké ťažké Aj keď prvé prednášky sa vám môžu zdať ľahké, sledujte učebnú látku, aby sa nestalo, že ste sa niektorými dôležitými vecami ešte nestretli. Nezabudnite riešiť príklady z cvičení a domáce úlohy. Hlavne ale nezaspite na vavrínoch: už po pár týždňoch začneme preberať algoritmy a rekurziu, čo môžu byť pre vás ťažšie témy. Treba preto zamakať aj na tomto predmete a v prípade, že vám učivo robí problémy, neváhajte prísť na doplnkové cvičenia.
Som skúsený programátor a ovládam C alebo C++ viem ťažké Podobne ako predchádzajúci riadok. Môžete si prípadne skúsiť napísať test pre pokročilých, môže sa vám podariť preskočiť zopár cvičení.
Som skúsený programátor a ovládam aj rekurziu a základné algoritmy (napr. z programátorských súťaží alebo rozšírenej výučby programovania na strednej škole) ľahké/viem ľahké/viem Aby ste sa nenudili riešením ľahkých príkladov, odporúčame test pre pokročilých. Aj tak však treba odovzdať domáce úlohy a absolvovať skúšku, prípadne aj semestrálny test. Priebežne sledujte učivo a v prípade nejasností sa pýtajte.

Zimný semester, pravidlá

Známkovanie

  • 25% známky je na základe príkladov z cvičení
  • 15% známky je za domáce úlohy
  • 30% známky je za semestrálny test
  • 30% známky je za praktickú skúšku

Pozor, body získavané za jednotlivé príklady nezodpovedajú priamo percentám záverečnej známky. Body za každú formu známkovania sa preváhujú tak, aby maximálny získateľný počet zodpovedal váham uvedených vyššie. Úlohy označené ako bonusové sa nerátajú do maximálneho počtu získateľných bodov v danej aktivite.

Pravidlá pre mimoriadne situácie: Ak by sa semestrálny test aj skúška konali online, váha testu sa zníži na 20% a 10% známky bude za ústnu skúšku.

  • Ústna skúška nie je potrebná, ak študent úspešne absolvuje v prezenčnej forme semestrálny test alebo praktickú skúšku (na úspešné absolvovanie testu treba aspoň 50% bodov, na úspešnú praktickú skúšku treba úspešne odovzdať aspoň jeden z dvoch príkladov).

Stupnica

  • Na úspešné absolvovanie predmetu je potrebné splniť všetky nasledovné podmienky:
    • Získať aspoň 50% bodov v celkovom hodnotení
    • Získať aspoň 50% z písomky
    • Ak sa vás týka ústna skúška, získať aspoň 50% z ústnej skúšky
    • Na praktickej skúške úspešne odovzdať aspoň jeden z dvoch príkladov
  • Ak niektorú z týchto podmienok nesplníte, dostávate známku Fx.
  • V prípade úspešného absolvovania predmetu získate známku podľa bodov v celkovom hodnotení takto:
A: 90% a viac, B:80...89%, C: 70...79%, D: 60...69%, E: 50...59%

Príklady z cvičení

  • Na hlavnom cvičení bude zverejnených niekoľko príkladov. Príklady odovzdávate do automatického testovača. Ak úspešne prejdú všetkými testami, môžete za ne dostať body (podmienkou však je dodržať aj ďalšie pokyny v zadaní úlohy).
  • Jeden príklad, označený ako rozcvička, bude mať termín odovzdania počas hlavného cvičenia, neskôr teda zaňho body nedostanete.
    • Ak chcete získať body za rozcvičku, je potrebné byť počas príslušného cvičenia fyzicky na cvičení v počítačovej učebni.
    • Počíta sa vám iba rozcvička určená pre vašu skupinu.
  • Ďalšie príklady z cvičení môžete odovzdávať až do ďalšieho pondelka 22:00 v ľubovoľnom čase a na ľubovoľnom mieste (do termínu odovzdania), odporúčame vám však využiť cvičenia, kde vám môžeme poradiť v prípade problémov.
  • Na doplnkovom cvičení bude výnimočne zadaná ešte jedna rozcvička za malý počet bonusových bodov (oznámime vopred).
  • Vyučujúci každý týždeň určia odporúčaný počet bodov pre hlavné cvičenie. Ak sa vám počas hlavného cvičenia nepodarí vyriešiť príklady za tento počet bodov, je pre vás povinná účasť na doplnkovom cvičení v danom týždni. Ak sa príslušného doplnkového cvičenia s povinnou účasťou nezúčastníte, odpočítame vám 1 bod z bodov za cvičenia.
  • Na niektorých prednáškach alebo cvičeniach budú krátke písomky, kde budete riešiť príklady na papieri. Body za tieto príklady sa tiež rátajú do bodov z cvičení.
  • Počas doplnkových cvičení môžete príklady z cvičení riešiť aj vo dvojiciach. Príklad potom odovzdáva jeden člen dvojice a uvedie používateľské meno druhého autora/autorky. Body dostanú obaja.
    • Na riešení pracujte spolu, obaja mu musia do detailov rozumieť. Ideálne je byť v dvojici s niekým na podobnej úrovni programátorských skúseností.
    • Táto možnosť platí len počas doplnkových cvičení ak ste v príslušnej učebni a len na príklady z cvičení. V ostatných časoch riešte príklady samostatne, samostatne robte aj domáce úlohy.

Domáce úlohy

  • Domáce úlohy sa budú tiež odovzdávať na testovači, budú však opravované ručne.
  • Plný počet bodov môžu dosť iba programy, ktoré prejdú všetkými testami, čiastočné body však môžete dosť aj za nedokončený program.
  • Budeme kontrolovať správnosť celkovej myšlienky, správnosť implementácie, ale body môžete stratiť aj za neprehľadný štýl.

Semestrálny test

  • Semestrálny test bude v stredu 11.12. 18:10 v posluchárňach F1 a F2.
  • Opravný test bude cez skúškové obdobie (v januári).

Praktická skúška

  • Na skúške budete riešiť 2 úlohy pri počítači v celkovom trvaní 2 hodiny.
  • Na skúške nebude k dispozícii internet. Budete používať rovnaké programátorské prostredie ako na cvičeniach.
  • Na skúške budú úlohy automaticky testované podobne ako domáce úlohy. Aspoň jedna úloha musí správne prejsť cez všetky testy, inak má študent z daného termínu skúšky známku Fx.
  • Bližšie informácie o skúške poskytneme koncom semestra.

Neprítomnosť a opravné termíny

  • V prípade, že máte príznaky respiračného ochorenia (horúčka, kašeľ a pod.), nechoďte na fakultu, aby ste chorobu nešírili. Kontaktujte vyučujúcich, ktorí vám dajú pokyny.
  • Účasť na hlavných cvičeniach veľmi silne odporúčame a v prípade neprítomnosti stratíte body z rozcvičky. Väčšiu časť bodov môžete získať aj riešením príkladov doma.
  • Ak sú pre vás doplnkové cvičenia v danom týždni povinné, neúčasťou stratíte 1 bod.
  • Neprítomnosť na prednáškach a nepovinných doplnkových cvičeniach nemusíte ospravedlňovať.
  • Domáce úlohy a príklady z cvičení je potrebné odovzdať do určeného termínu. Neskoršie odovzdané riešenia nebudú braté do úvahy, ak nezískate výnimočné predĺženie termínu od vyučujúcich.
  • Ak zo závažných dôvodov (napr. zdravotných) nemôžete prísť na cvičenia, písomku, skúšku resp. načas odovzdať domácu úlohu či príklady z cvičení, kontaktujte vyučujúcich emailom. Treba tak spraviť čím skôr, nie až spätne cez skúškové.
  • Semestrálny test má jeden opravný termín.
    • Ak sa zúčastníte opravného termínu, strácate body z predchádzajúceho termínu, aj keby ste na opravnom získali menej bodov.
  • Opakovanie skúšky sa riadi študijným poriadkom fakulty. Máte nárok na dva opravné termíny (ale len v rámci termínov, ktoré sme určili).

Odpisovanie

  • Máte povolené sa so spolužiakmi a ďalšími osobami rozprávať o zadaných domácich úlohách a príkladoch z cvičení a o stratégiách na ich riešenie. Kód, ktorý odovzdáte, musí však byť vaša samostatná práca. Je zakázané ukazovať svoj kód spolužiakom resp. im ho diktovať. Pri diskusii o úlohe nemajte otvorené vaše programy a ani si nerobte detailné poznámky.
  • Tiež je zakázané odpisovať kód z literatúry alebo z internetu (s výnimkou webstránky predmetu). Pri práci môžete používať webstránky s popisom programovacieho jazyka, nesnažte sa však nájsť priamo riešenie zadaného príkladu.
  • Pri riešení príkladov z cvičení a domácich úloh nepoužívajte ani nástroje umelej inteligencie (AI). Sú dobrým pomocníkom pre pokročilých programátorov, avšak teraz potrebujete zvládnuť základy programovania vy sami. Ak sú takéto nástroje súčasťou vášho editora, vypnite ich pri práci na našich zadaniach.
  • Počas testov a skúšok môžete používať iba povolené pomôcky a nesmiete komunikovať so žiadnymi osobami okrem vyučujúcich.


  • Po termíne odovzdania príslušnej úlohy je povolené a môže byť aj poučné porovnať váš program s programami, ktoré napísali spolužiaci alebo systémy AI.


  • Odovzdané programy môžu byť kontrolované softvérom na detekciu plagiarizmu.
  • Ak nájdeme prípady odpisovania, všetci zúčastnení študenti získajú za príslušnú domácu úlohu 0 bodov (aj študenti, ktorí dali spolužiakom odpísať).
  • Za závažné porušenie pravidiel budeme považovať aj akýkoľvek pokus narušiť činnosť testovača riešení.
  • Nájdené prípady odpisovania alebo porušovania pravidiel predmetu budú podstúpené aj na riešenie disciplinárnej komisii fakulty.
  • Ak na predmete podvádzate a my na to neprídeme, stále ste podviedli hlavne sami seba, lebo ste nevyužili príležitosť trénovať dôležité zručnosti, ktoré vám budú neskôr chýbať.

Osobné stretnutia

  • Vyučujúci vás môžu vyzvať emailom, aby ste prišli na stretnutie ohľadom príkladov, ktoré odovzdali (domáce úlohy, príklady z cvičení).
  • Na tomto stretnutí im vysvetlíte, ako ste príklad riešili.
  • Stretnutia sa budú konať počas doplnkových cvičení alebo po dohode v inom čase.
  • Ak na stretnutie neprídete alebo nebudete vedieť svoj program vysvetliť, stratíte zaňho body.

Test pre pokročilých

  • V prvom týždni semestra sa bude konať nepovinný test pre pokročilých, určený pre študentov, ktorí už ovládajú väčšiu časť učiva. Jeho úspešným absolvovaním si môžu ušetriť časť povinností na predmete.
  • Za každých celých získaných 9% z testu získavate 100% bodov z jedných cvičení (bez bonusov). Na tieto uznané cvičenia nemusíte prísť ani príklady riešiť doma. Napr. ak ste získali aspoň 63% z testu, dostanete plný počet bodov z prvých 7 bodovaných cvičení po opravení testu. Tieto body nie je možné presúvať na iné termíny cvičení. Ak riešite úlohy z takéhoto uznaného cvičenia, započíta sa vám maximum z bodov, ktoré získate riešením a z bodov, ktoré sú vám uznané.
  • Ak získate aspoň 50% z testu pre pokročilých, body z testu vám budú uznané aj ako body zo semestrálnej písomky. Ak však chcete, môžete písomku znovu písať so spolužiakmi.
  • Testom pre pokročilých nie je možné nahradiť domáce úlohy ani skúšku.

Nepreberané črty jazykov C a C++

  • Z jazykov C a C++ uvidíme len malú časť.
  • Preberané črty týchto jazykov je potrebné ovládať, pre vlastnú potrebu si však môžete v literatúre doštudovať aj ďalšie užitočné príkazy, knižnice a konštrukty.
  • Ak je v zadaní uvedené, aké prostriedky máte použiť, držte sa týchto pokynov.
  • V opačnom prípade môžete použiť aj nepreberané črty. Aby ste sa vyhli problémom pri opravovaní, je vhodné ich doplniť vysvetľujúcim komentárom.
  • Vždy používajte len štandardné súčasti jazykov C a C++ , nie špeciálne knižnice. (Výnimkou sú samozrejme knižnice poskytnuté vyučujúcimi.)
  • Vaše programy by mali fungovať na testovači bez zvláštnych nastavení kompilátora a pod.

Zimný semester, semestrálny test

  • Bude sa konať v stredu 11.12. o 18:10 v posluchárňach F1 a F2 v trvaní 90 minút.
  • Treba z neho získať aspoň 50% bodov, inak známka Fx.
  • Opravný termín bude 10.1.2025 9:00 (iba jeden).
  • Prineste si písacie potreby a preukaz študenta.
  • Môžete si priniesť ťahák v rozsahu 1 listu A4.
  • Ak máte problémy so slovenčinou, môžeme vám na teste a skúške poskytnúť strojový preklad zadaní. Prípadný záujem nahláste do piatka 6.12. pomocou formulára.
  • Počas testu nebude možné odchádzať z miestnosti. Kto odíde, musí test odovzdať a ukončiť.

Pokročilí, ktorí získali na úvodnom teste pre pokročilých 50% bodov, majú body z testu uznané aj ako body zo semestrálneho testu. Ak však chcete, môžete test znovu písať so spolužiakmi. Odovzdaním testu sa vám budú počítať body z tohto testu bez ohľadu na to, či si polepšíte alebo zhoršíte výsledok.

Na semestrálnom teste budú podobné typy príkladov, aké poznáte z teoretických cvičení, napríklad

  • napíšte funkciu, ktorá robí zadanú činnosť
  • doplňte chýbajúce časti funkcie
  • zistite, čo funkcia robí (pre daný vstup alebo všeobecne)

Vyskytne sa však aj nový typ príkladov, kde je úlohou napísať, ako bude na nejakom vstupe fungovať algoritmus alebo dátová štruktúra z prednášky. Nižšie sú ukážky takýchto príkladov. Svoje odpovede si môžete skontrolovať na spodku stránky.

Môžete si pozrieť aj ukážkový test pre pokročilých, ktorý má podobné typy príkladov ako semestrálny test.

Príklady o binárnych vyhľadávacích stromoch a lexikografických stromoch (príklady 7 a 8 nižšie) na riadnom termíne testu nebudú, môžu sa však vyskytnúť na opravnom termíne.

Ukážkové príklady z algoritmov na semestrálny test

  • Príklad 1: Prepíšte výraz 8 3 4 * + 2 3 + / z postfixovej notácie do bežnej infixovej notácie. Aká je jeho hodnota? Nakreslite ho aj ako strom.
  • Príklad 2: Prepíšte výraz ((2+4)/(3*5))/(1-2) do postfixovej a prefixovej notácie.
  • Príklad 3: Vyhodnocujeme výraz 8 3 4 * + 2 3 - / v postfixovej notácii algoritmom z prednášky. Aký bude obsah zásobníka v čase, keď začneme spracovávať znamienko +?
  • Príklad 4: Máme zásobník s a rad q, pričom obidve štruktúry uchovávajú dáta typu char. Aký bude ich obsah po nasledujúcej postupnosti príkazov?
init(s);
init(q);
push(s, 'A');
push(s, 'B');
push(s, 'C');
enqueue(q, pop(s));
enqueue(q, pop(s));
push(s, 'D');
push(s, dequeue(q));
  • Príklad 5: Strom nižšie má v každom uzle uložené jedno písmeno (dáta typu char). V akom poradí budú vypísané jednotlivé písmená, ak použijeme inorder, preorder a postorder prehľadávanie?
         A
       /  \
      /    \
     B      C  
    / \    / \
   D   E  F   G
      / \
     H   I
  • Príklad 6: Máme binárny strom, v ktorom má každý vrchol buď dve deti a v dátovom poli uložený znak '#' alebo nemá žiadne deti a v dátovom poli má uložený znak '*'. Keď tento strom vypíšeme v preorder poradí, dostaneme postupnosť ##*#*** Nakreslite, ako vyzerá tento strom.
  • Príklad 7: Nakreslite binárny vyhľadávací strom, ktorý dostaneme, ak do prázdneho stromu postupne vkladáme záznamy s kľúčmi 3, 4, 1, 2, 5, 6 (v tomto poradí).
  • Príklad 8: Nakreslite prefixový strom s abecedou {a,b}, do ktorého sme vložili reťazce aba, aaab, baa, bab, ba. Vrcholy, ktoré zodpovedajú niektorému reťazcu zo vstupu, zvýraznite dvojitým krúžkom.
  • Príklad 9 Ako bude vyzerať hašovacia tabuľka pri riešení kolízií pomocou spájaných zoznamov, ak hašovacia funkcia je |k| mod 5 a vkladáme prvky 13, -2, 0, 8, 10, 17?

Vzorové riešenia ukážkových príkladov na semestrálny test

  • Príklad 1: (8+3*4)/(2+3), hodnota 4, strom:
         /
       /  \
      /    \
     +      +  
    / \    / \
   8   *  2   3
      / \
     3   4
  • Príklad 2: postfix 2 4 + 3 5 * / 1 2 - / prefix: / / + 2 4 * 3 5 - 1 2
  • Príklad 3: na zásobníku budú čísla 8 a 12 (8 je na spodku zásobníka). Číslo 12 vzniklo vynásobením 3 a 4.
  • Príklad 4: na zásobníku budú znaky A, D, C (A na spodku zásobníka), v rade bude písmeno B
  • Príklad 5:
Preorder:  ABDEHICFD
Postorder: DHIEBFGCA
Inorder:   DBHEIAFCG
  • Príklad 6:
        #
       / \
      #   *
     /\
    *  #
      /\
     *  *
  • Príklad 7:
        3
       / \
      1   4
      \    \
       2    5
             \
              6
  • Príklad 8: (namiesto dvojitého krúžku používame *)
          .
         / \
       a/   \b
       /     \ 
      .       .
    a/ \b    a/
    .   .   *
  a/  a/  a/ \b
  .   *   *   *
b/
*
  • Príklad 9:

Pre každý index tabuľky 0,...,4 uvádzame zoznam prvkov, ktoré sa do neho zahašujú. Tieto budú pospájané v zozname v uvedenom poradí.

0: 10, 0
1:
2: 17, -2
3: 8, 13
4:

Zimný semester, skúška

Odporúčame tiež si preštudovať pravidlá predmetu.

Termíny, prihlasovanie

Termíny skúšok

  • Piatok 20.12. 13:10 (predtermín)
  • Piatok 17.1. 9:00
  • Piatok 24.1. 9:00
  • Piatok 31.1. 9:00, hlavne 1. opravný termín
  • Štvrtok 13.2. 9:00, hlavne 2. opravný termín

Ak zistíte konflikt našej skúšky s hromadnou skúškou alebo písomkou iného predmetu, dajte nám vedieť čím skôr. Na poslednú chvíľu už nedokážeme nájsť riešenie. Trvanie skúšky je cca 2,5 hodiny.

Prihlasovanie

  • Prihlasovanie je v AIS2/Votr otvorené od pondelka 2.12. 19:00
  • Na termín sa prihlasujte / odhlasujte najneskôr 24 hodín pred začiatkom termínu.
  • Na skúšku môžete ísť iba ak ste úsešne absolvovali semestrálny test aspoň na 50% bodov.
  • Celkovo budú iba uvedených päť termínov. Každý sa môže zúčastniť na najviac troch z nich.

Špeciálne pravidlá pre predtermín

  • Ak sa zúčastňujete predtermínu, body za tréningové príklady môžete dostať iba ak ich vyriešite do 20.12. 11:30 (toto neplatí, ak po predtermíne ešte idete na opravný termín skúšky).
  • Ak máte problémy so slovenčinou, môžeme vám na teste a skúške poskytnúť strojový preklad zadaní.
    • Pre riadny termín testu bolo treba nahlásiť záujem do 6.12.
    • Pre ostatné termíny najneskôr do utorka 17.12.
    • Použite formulár.

Praktická skúška

  • 2 hodiny práca pri počítači v halách H3, H6
  • Za určitých okolností môže byť čas predĺžený o 30 minút, viď pravidlá nižšie
  • Počas skúšky vám nebudeme pomáhať hľadať chyby vo vašom programe. Môžete sa však spýtať na nejasnosti v zadaní. Dajte nám tiež vedieť v prípade technických problémov alebo ak si myslíte, že v zadaní / kostre / vstupoch je chyba.

Ďalšie detaily

  • Nebudeme používať SVGdraw
  • Môžete používať aj črty C/C++, ktoré sme nebrali. Používajte len štandardné súčasti jazyka. Vaše programy by mali fungovať na testovači bez zvláštnych nastavení kompilátora a pod.
  • Odporúčame používať iba tie časti jazyka, s ktorými máte dostatočné skúsenosti.
  • Skúška bude v Linuxe, rovnaké prostredie ako na cvičeniach.
  • Odovzdávanie prostredníctvom špeciálnej verzie testovača.
  • Okrem testovača nebude k dispozícii internet.
  • Bude k dispozícii kópia poznámok z predmetu, ktoré vidíte na tejto stránke.
  • Budete používať špeciálne skúškové konto, takže nebudete mať k dispozícii žiadne svoje súbory alebo nastavenia.
  • Môžete použiť Kate, valgrind, ale aj iné nástroje, ktoré bežia v Linuxe v učebniach a nepotrebujú internet. Prípadné problémy s použitím iného softvéru vám však nebudeme pomáhať riešiť.
    • Nemôžete použiť online prostredia.
    • VS Code nemá pluginy
  • Môžete použiť pero alebo ceruzku na robenie poznámok (odporúčame).
  • Prineste si aj preukaz študenta.
  • Zakázané sú akékoľvek ďalšie pomôcky (mobily a elektronické zariadenia, poznámky a iné papierové materiály atď), komunikácia s inými osobami než s vyučujúcimi predmetu ako aj pokusy o narušenie riadneho chodu testovača.
  • Pri reštarte počítača sa stratia všetky súbory, používajte testovač ako zálohu (odovzdajte aj nedokončený program).
  • Každých cca 20 minút vás vyučujúci vyzvú, aby ste v najbližších minútach odovzdali svoj program na testovač. Ak práve nerobíte na žiadnom programe, odovzdajte krátku správu, napr. "rozmyslam"

Príklady

Na skúške budete riešiť dva príklady za rovnaký počet bodov

Prvý príklad

  • V prvom príklade budete mať za úlohu samostatne napísať celý program, ktorý rieši zadanú úlohu. Typicky bude treba načítať dáta, spracovať ich a vypísať výsledok.
  • V tomto príklade môžete použiť ľubovoľný postup.
  • Budú však zakázané polia pevných veľkostí. Polia alokujte dynamicky cez new, alebo použite štruktúry, ktoré menia veľkosť podľa potreby (napr. string, vector). Alokovanú pamäť odalokujte.
  • Predtým ako začnete programovať, si poriadne rozmyslite, aké dátové štruktúry (polia, matice, struct-y a pod.) chcete v programe použiť.

Druhý príklad

  • V druhom príklade dostanete kostru programu, pričom vašou úlohou bude doprogramovať niektoré funkcie.
  • V tomto príklade môžete mať v zadaní predpísaný spôsob, ako máte niektoré časti naprogramovať.
  • Budú sa vyžadovať aj zložitejšie časti učiva, ako napríklad zoznamy, stromy a rekurzia.


Ukážkové príklady

Niektoré ukážkové príklady na skúšku budú k dispozícii na testovači, môžete si ich v rámci tréningu vyriešiť a odovzdať. Pre realistickejší tréning si vždy prečítajte zadanie tesne predtým, ako príklad začnete riešiť, aby ste odhadli, koľko času vám príklad zaberie vrátane čítania zadania a rozmýšľania nad riešením.

Techniky hľadania chýb

Pre niektorých študentov nie je ani tak problém napísať takmer kompletný program, ale skôr nájsť v ňom chyby, kvôli ktorým nefunguje.

Hodnotenie

Aby ste mali šancu úspešne ukončiť predmet, aspoň jeden z príkladov vám musí prejsť všetky testy na testovači

  • Túto podmienku nebudeme považovať za splnenú, ak váš program nerieši zadanú úlohu (t.j. jeho myšlienka nie je v zásade správna).
  • Podmienku však považujeme za splnenú, ak váš program prejde všetky vstupy, má v zásade správnu myšlienku, ale nedostane plný počet bodov napríklad kvôli chýbajúcemu uvoľneniu pamäte, statickým poliam, menšej chybe, ktorá sa neprejavila na daných vstupoch a pod.
  • Dobre si rozmyslite, s ktorým príkladom chcete začať a snažte sa ho dokončiť, kým nedostanete OK na testovači. Potom ho môžete ešte vylepšovať alebo sa snažiť vyriešiť aspoň časť príkladu, ktorý ste ešte neriešili.

Bodové hodnotenie

  • V prvom rade budeme hodnotiť správnosť myšlienky vášho programu. Predtým, ako začnete programovať, si dobre prečítajte zadanie a rozmyslite, ako budete úlohu riešiť.
  • Ďalej je veľmi dôležité, aby sa program dal skompilovať (v štandardnom prostredí) a aby správne fungoval na všetkých vstupoch spĺňajúcich podmienky v zadaní.
  • V druhej úlohe budeme jednotlivé funkcie hodnotiť zvlášť, takže môžete získať čiastočné body, ak ste niekoľko funkcií napísali správne.
  • Na hodnotenie môže mať menší vplyv aj úprava a štýl programu (komentáre, mená premenných, odsadzovanie, členenie dlhšieho programu na funkcie,...)
  • Na tejto skúške nezáleží na rýchlosti vášho programu (pokiaľ zbehne v časovom limite, ktorý však nie je prísny). Radšej napíšte jednoduchý, prehľadný a hlavne správny pomalší program, než rýchlejší, ale zbytočne zložitý, či nesprávny.

Predĺženie času

  • Ak v riadnom čase 2 hodiny nemáte na testovači OK ani z jedného príkladu, zo skúšky by ste mali dostať Fx.
  • Dovolíme vám však predĺžiť čas skúšky o najviac 30 minút.
  • Ak zostanete na predĺženie, budeme vám rátať do výsledku body iba z jedného príkladu. Konkrétne z toho, za ktorý ste dostali OK na testovači (ak z oboch, tak z toho, za ktorý ste mali OK skôr)

Opravné termíny

  • Opakovanie praktickej skúšky sa riadi študijným poriadkom fakulty. Máte nárok na dva opravné termíny (ale len v rámci termínov, ktoré sme určili).
  • Ak po skúške máte nárok na známku E alebo lepšiu, ale chceli by ste si známku ešte opraviť, musíte sa dohodnúť so skúšajúcimi.
  • Ak sa zo závažných dôvodov (napr. zdravotných, alebo konflikt s inou skúškou) nemôžete zúčastniť termínu skúšky alebo písomky, dajte o tom vyučujúcim vedieť čím skôr.

Zimný semester, softvér

V tomto dokumente popisujeme niektoré nástroje, ktoré môžete použiť na programovanie v tomto semestri. Na cvičeniach a skúške odporúčame používať editor Kate popísaný nižšie.

Môžete používať aj iné nástroje, ale

  • k iným prostrediam vám nemusíme vedieť poradiť, ako ich používať
  • na skúške budete môcť používať iba tie programy, ktoré sú k dispozícii v učebniach na fakulte v Linuxe, pričom nebude k dispozícii internet

Prenášanie programov a odovzdávanie domácich úloh

  • Pri odovzdávaní domácich úloh odovzdávajte súbor .cpp s vašim programom (prípadne ďalšie súbory, ak to vyžaduje zadanie).
  • Ak pracujete na rôznych počítačoch v rámci FMFI učební, svoje projekty si ukladajte na sieťovom disku net
  • Dáta zo sieťového disku si môžete stiahnuť v učebni na USB kľúčik, alebo aj cez sieť z domu prihlásením sa na študentský Linuxový klaster daVinci (davinci.fmph.uniba.sk). Na prenos dát môžete použiť napríklad windowsovský program winscp
  • Odovzdané programy si môžete počas semestra stiahnuť z testovača, po začiatku ďalšieho semestra k ním stratíte prístup

Kate

Kate je základný textový editor, ponúka však dostatok nastavení, aby sa s v ňom pohodlne písali jednoduché programy (nie je však úplne vhodný na väčšie projekty).

Ako spustiť Kate v učebni

  • Prihláste sa do Linuxu rovnakým menom a heslom, aké používate v AISe.
  • Atlačte ALT+F2 a napíšte kate (alebo Kate nájdite v menu s ponukou programov v časti Utilities).
  • Odporúčame sedieť vždy pri tom istom počítači, máte uložené nastavenia.

Vytvorenie nového programu

  • File -> New (Ctrl+N) vytvorí nový textový súbor
  • Uložte ho pomocou File -> Save (Ctrl+S), bude od vás žiadať nejaké meno a môžete si zvoliť, kam bude daný súbor umiestnený. Nazvať si ho môžeme napr. prog.cpp
  • Dôležité je pridať koncovku .cpp , vďaka nej Kate vie, že chcete programovať v C++ a mal by vám automaticky zapnúť C++ zvýrazňovanie (ktoré je veľmi praktické)

Nastavenia editora

Na programovanie odporúčame spraviť / skontrolovať nasledujúce nastavenia (keď máte otvorený .cpp súbor)

  • zobrazovanie terminálu
    • Settings -> Configure Kate -> Plugins a tam zaškrtnite Plugin s terminálom
    • View -> Tool Views a zaškrtnite Show Terminal
    • Tools -> Synchronize Terminal with Current Document
  • automatické zvýrazňovanie Tools -> Highlighting -> Sources, malo by byť zaškrtnuté C++
  • automatické C++ odsádzanie Tools -> Indentation malo by byť zaškrtnuté C Style
  • Tools -> align vám preformátuje vybranú časť programu

Kompilovanie a spustenie programu

  • Kate nemá vstavané kompilovanie ani spúšťanie, preto na to treba používať terminál (textové príkazy)
  • V Kate viete mať priamo otvorenú lištu s terminálom, čo je veľmi praktické. Mala by sa nachádzať dole pod textovým oknom (prípade kliknite na malú ikonku Terminal)
  • Kliknite do okna s terminálom, aby sa stalo aktívnym
  • V termináli sa treba dostať do priečinku s vaším súborom
    • buď sa to stane automaticky vďaka nastaveniu Tools -> Synchronize Terminal with Current Document
    • alebo použite príkazy nižšie
  • Ak sa nachádzate v priečinku, v ktorom sa nachádza váš program napr. v súbore prog.cpp, môžete ho pomocou konzoly kompilovať a spúšťať
  • Príkaz make prog (slovo prog je meno súboru, ale bez koncovky), v tom istom priečinku sa vytvorí súbor prog, čo bude spustiteľný program (linuxový ekvivalent .exe)
  • Príkaz g++ prog.cpp -o prog - vytvorí to isté ako príkaz pred tým, akurát vieme nastavovať parametre kompilátora g++
  • Príkaz ./prog - spustí daný program v priečinku, ak mal niečo vypísať, vypíše to do konzoly, ak mal niečo čítať, načítava to tiež z konzoly (ak nie je povedané inak)
  • Text prog v príkazoch vyššie nahraďte menom súboru, s ktorým pracujete.

Ďalšia práca s teminálom

  • V termináli by ste mali vidieť svoje meno, nejaké ďalšie veci a potom :~$ za ktorým kliká kurzor.
  • Časť za : vám hovorí, v ktorom priečinku sa nachádzate, ~ je domový priečinok.
  • Príkaz ls vypíše zoznam súborov a priečinkov v priečinku, v ktorom ste (skratka z list).
  • Príkaz cd meno_priečinka presuniete sa do priečinka s daným menom, ak sa taký priečinok nachádza v aktuálnom priečinku (skratka z change directory).
  • Príkaz cd .. presuniete sa o jeden priečinok vyššie.
  • Ak budete pri písaní mena priečinka/súboru stláčať klávesu Tab, bude sa vám snažiť automaticky doplniť hľadaný súbor. Ak je možností viac, doplní čo najviac znakov, ktoré sú rovnaké.
  • Šípkou hore a dole listujete v histórii príkazov a stlačením Enter ho môžete spustiť znovu.

Ak si v aktuálnom priečinku vytvoríte textový súbor so vstupom, môžete ho poslať na vstup vášho programu pomocou presmerovania:

  • Namiesto ./prog spustíte ./prog < my_input.txt
  • Výsledok bude rovnaký ako keby ste obsah súboru my_input.txt ručne písali na konzolu.
  • Ak by ste si výsledok programu tiež chceli uložiť do súboru, použite opačné presmerovanie: ./prog < my_input.txt > my_output.txt

Práca v Kate s grafickou knižnicou SVGdraw

  • Knižnicu začneme používať od piatej prednášky
  • Stiahnite si knižnicu
    • Stiahnuté súbory SVDdraw.cpp a SVGdraw.h si uložte do priečinku, v ktorom máte svoje programy
  • do vlastného programu potom musíte na začiatok pridať riadok #include "SVGdraw.h"

Kompilácia s grafickou knižnicou SVGdraw

  • Kompilátor potrebuje vedieť, že váš program chce používať funkcie z iného súboru (SVGdraw.cpp) a preto mu musíte povedať, aby ich nalinkoval. Obyčajný make fungovať nebude.
  • Použite príkaz g++ prog.cpp SVGdraw.cpp -o prog
  • Vytvorí sa vám súbor prog, ktorý môžete normálne spustiť pomocou ./prog
  • V priečinku s programom sa vytvorí súbor s príponou .svg, ktorý si môžete pozrieť napr. v internetových prehliadačoch firefox alebo chromium.

Iné programátorské prostredia

Okrem jednoduchých editorov ako Kate existujú aj zložitejšie prostredia, ktoré podporujú prácu programátora, najmä na väčších projektoch. Zvyknú sa nazývať IDE (integrated development environment). Výhodu je napríklad zabudovaný debugger, ktorý umožňuje krokovať program pri hľadaní chýb.

Viacplatformové prostredia

Nasledovné prostredia by mali fungovať na Linuxových aj Windowsových počítačoch, aj keď nie vždy je ľahké ich nainštalovať a môžu byť tiež pomerne pomalé

Z nich v učebniach v Linuxe fungujú VS Code, Netbeans, Code::Blocks, Eclipse

  • Tieto prostredia teda môžete použiť aj na skúške (vo VS Code ale nebudú pluginy pre C++)
  • Návod na použitie prostredia Netbeans z minulých semestrov nájdete tu

Windows

Pod Windows existuje pomerne veľké množstvo programátorských prostredí podporujúcich C/C++. Možnosti sú napríklad nasledovné:

1. Kompilátor GCC + textový editor

  • Ide o najjednoduchšiu možnosť podobnú práci s Kate pod Linuxom.
  • GCC je pôvodom Linuxový kompilátor pre C/C++. Existuje však aj verzia pre Windows, ktorá je súčasťou prostredia MinGW.
  • Programy možno písať v ľubovoľnom textovom editore, ideálne však v takom, ktorý podporuje zvýrazňovanie syntaxe pre C a C++, napríklad Notepad++, PSPad a mnohé ďalšie.
  • Programy potom kompilujeme z príkazového riadku: napríklad súbor program.cpp skompilujeme tak, že sa v príkazovom riadku nastavíme do adresára, ktorý ho obsahuje a následne zadáme príkaz ako
g++ -o prog prog.cpp

Tým sa vytvorí spustiteľný súbor prog.exe, ktorý možno spustiť z príkazového riadku príkazom prog.

  • Viaceré textové editory podporujú aj integráciu s príkazovým riadkom, takže kompilovanie a spúštanie programov možno realizovať priamo z nich.

Inštalácia MinGW:

  • Stiahnite si inštálator zo stránky projektu.
  • Zapamätajte si adresár na disku, do ktorého MinGW inštalujete.
  • Spustite inštaláciu, počas ktorej zvoľte obidva jazyky C a C++.
  • Po ukončení inštalácie pridajte cestu do adresára obsahujúceho spustiteľné súbory gcc.exe a g++.exe (typicky <cesta do koreňového adresára MinGW>\bin) do systémovej premennej PATH, aby bolo možné g++ v príkazovom riadku volať z ľubovoľného adresára. Na internete je dostupných množstvo návodov na editovanie systémových premenných (kľúčové slová pre vyhľadávanie môžu byť napríklad edit PATH environment variable v kombinácii s vašou verziou Windows).

2. NetBeans

  • NetBeans je IDE určené najmä pre jazyk Java. Možno v ňom však vyvíjať aj aplikácie pre C a C++.
  • Je potrebné mať nainštalované MinGW (viď vyššie) a súčasne aj utilitu MSYS. Je možné nainštalovať aj obidva tieto programy naraz. Po ukončení inštalácie je potrebné okrem cesty na adresár obsahujúci gcc.exe a g++.exe do premennej PATH pridať aj adresár obsahujúci make.exe.
  • Podrobný návod na inštaláciu je k dispozícii tu, pričom odporúčame variant s MinGW.

3. Dev-C++

  • Iné IDE pre Windows s pomerne bezproblémovou inštaláciou (nepotrebuje MinGW).
  • Dostupné je tu.

4. Code::Blocks

  • Multiplatformové IDE.
  • Dostupné tu.

5. Visual Studio 2015

  • Profesionálne komerčné prostredie od firmy Microsoft, ktoré si ako študenti môžete na študijné účely nainštalovať podľa pokynov na fakultnej stránke

Ak máte na počítači operačný systém Windows, ale chceli by ste si vyskúšať aj prácu v Linuxe:

  • Môžete si nainštalovať Linux do virtuálneho počítača, napr. pomocou programu VirtualBox.
  • Alebo môžete štartovať Linux nainštalovaný na USB kľúči.

On-line prostredia

Ak máte problémy nainštalovať na svoj počítač vhodné prostredie na programovanie, môžete skúsiť webstránky, ktoré vám umožňujú písať, kompilovať a spúšťať jednoduché programy.

Tieto stránky nebudete môcť používať na skúške.


Kontrola práce s pamäťou programom valgrind

V druhej polovici semestra budeme pracovať aj so smerníkmi a alokáciou pamäte. Na odhalenie chýb, ktoré pri práci s pamäťou vznikajú, môžete použiť program valgrind.

Valgrind

  • C-čko pri použití polí a smerníkov nekontroluje, či ich používame správne
  • Chybou v programe sa nám teda ľahko môže stať, že čítame alebo píšeme mimo alokovanej pamäte
  • Takéto chyby sa niekedy ťažko hľadajú
  • V Linuxe nám na hľadanie takýchto chýb pomôže nástroj valgrind, ktorý môžete použiť aj na skúške
  • Vo Windows môžete použiť Dr. Memory

Spustenie programu v nástroji valgrind pri použití Kate

  • Na príkazovom riadku v editore Kate spúšťate váš program príkazom typu ./prog, kde prog.cpp je meno vášho súboru
  • Namiesto toho napíšete valgrind ./prog
  • Nástroj valgrind bude náš program pozorne sledovať a keď robí divné veci v pamäti, vypíše nám o tom správu
  • Aby boli tieto správy zrozumiteľnejšie (obsahovali čísla riadkov), lepšie je skompilovať program s prepínačom -g
  • Namiesto make prog teda napíšete g++ -g prog.cpp -o prog alebo ešte lepšie je zapnúť si aj varovania kompilátora
g++ -g -Wall prog.cpp -o prog


Spustenie programu v nástroji valgrind pri použití Netbeans

  • Keď v Netbeans spustíme nástroj Build (ikonka kladivka; spúšťa sa tiež automaticky pred spustením programu), Netbeans zavolá kompilátor a vytvorí spustiteľný súbor
  • Tento spustiteľný súbor nájdeme v adresári typu NetBeansProjects/meno_projektu/dist/Debug/GNU-Linux-x86/, volá sa rovnako ako projekt
  • V Linuxe si ho môžeme na príkazovom riadku spustiť aj mimo prostredia Netbeans, stačí napísať NetBeansProjects/meno_projektu/dist/Debug/GNU-Linux-x86/meno_projektu
  • Namiesto toho ho môžeme spustiť valgrind NetBeansProjects/meno_projektu/dist/Debug/GNU-Linux-x86/meno_projektu
  • Nástroj valgrind bude náš program pozorne sledovať a keď robí divné veci v pamäti, vypíše nám o tom správu


Ukážky chýb a výsledok z valgrind

Neinicializovaná premenná

Nasledujúci program vypisuje neinicializovanú premennú i

#include <iostream>
using namespace std;
int main(void) {
    int i; cout << i << endl;
}

Valgrind vypíše okrem iného

==25895== Conditional jump or move depends on uninitialised value(s)
==25895==    at 0x4F3CCAE: std::ostreambuf_iterator<char, std::char_traits<char> > std::num_put<char, std::ostreambuf_iterator<char, std::char_traits<char> > >::_M_insert_int<long>(std::ostreambuf_iterator<char, std::char_traits<char> >, std::ios_base&, char, long) const (in /usr/lib/x86_64-linux-gnu/libstdc++.so.6.0.21)
==25895==    by 0x4F3CEDC: std::num_put<char, std::ostreambuf_iterator<char, std::char_traits<char> > >::do_put(std::ostreambuf_iterator<char, std::char_traits<char> >, std::ios_base&, char, long) const (in /usr/lib/x86_64-linux-gnu/libstdc++.so.6.0.21)
==25895==    by 0x4F493F9: std::ostream& std::ostream::_M_insert<long>(long) (in /usr/lib/x86_64-linux-gnu/libstdc++.so.6.0.21)
==25895==    by 0x40082C: main (prog.cpp:4)
==25895== 
==25895== Use of uninitialised value of size 8
==25895==    at 0x4F3BB13: ??? (in /usr/lib/x86_64-linux-gnu/libstdc++.so.6.0.21)
==25895==    by 0x4F3CCD9: std::ostreambuf_iterator<char, std::char_traits<char> > std::num_put<char, std::ostreambuf_iterator<char, std::char_traits<char> > >::_M_insert_int<long>(std::ostreambuf_iterator<char, std::char_traits<char> >, std::ios_base&, char, long) const (in /usr/lib/x86_64-linux-gnu/libstdc++.so.6.0.21)
==25895==    by 0x4F3CEDC: std::num_put<char, std::ostreambuf_iterator<char, std::char_traits<char> > >::do_put(std::ostreambuf_iterator<char, std::char_traits<char> >, std::ios_base&, char, long) const (in /usr/lib/x86_64-linux-gnu/libstdc++.so.6.0.21)
==25895==    by 0x4F493F9: std::ostream& std::ostream::_M_insert<long>(long) (in /usr/lib/x86_64-linux-gnu/libstdc++.so.6.0.21)
==25895==    by 0x40082C: main (prog.cpp:4)

Dôležitá informácia je, že chyba nastala na riadku 4 v programe prog.cpp

Neinicializovaný smerník

Nasledujúci program zapisuje do pamäte, na ktorú ukazuje smerník s neinicializovanou hodnotou

#include <iostream>
using namespace std;
int main(void) {
    int *p; 
    *p = 7; 
    cout << *p << endl;
}
==25923== Use of uninitialised value of size 8
==25923==    at 0x400822: main (prog.cpp:5)
==25923== 
==25923== Invalid write of size 4
==25923==    at 0x400822: main (prog.cpp:5)
==25923==  Address 0x0 is not stack'd, malloc'd or (recently) free'd
==25923== 
==25923== 
==25923== Process terminating with default action of signal 11 (SIGSEGV)
==25923==  Access not within mapped region at address 0x0
==25923==    at 0x400822: main (prog.cpp:5)
==25923==  If you believe this happened as a result of a stack
==25923==  overflow in your program's main thread (unlikely but
==25923==  possible), you can try to increase the size of the
==25923==  main thread stack using the --main-stacksize= flag.
==25923==  The main thread stack size used in this run was 8388608.

Chybné odalokovanie

Tento program sa pokúša odalokovať pamäť, ktorá nebola alokovaná

#include <iostream>
using namespace std;
int main(void) {
    int i = 7; 
    int *p = &i; 
    delete p;
}
==25952== Invalid free() / delete / delete[] / realloc()
==25952==    at 0x4C2F24B: operator delete(void*) (in /usr/lib/valgrind/vgpreload_memcheck-amd64-linux.so)
==25952==    by 0x4007A7: main (prog.cpp:6)
==25952==  Address 0xfff0002bc is on thread 1's stack
==25952==  in frame #1, created by main (prog.cpp:3)

Písanie za koniec alokovaného poľa

#include <iostream>
using namespace std;
int main(void) {
    int *p = new int[4];
    for(int i = 0; i <= 4; i++) {
        p[i] = 100;
    }
    delete[] p;
}

Priraďujeme na index 4 v poli, ktoré má iba indexy 0 až 3. Valgrind vypíše:

==149928== Invalid write of size 4
==149928==    at 0x1091C4: main (prog.cpp:6)
==149928==  Address 0x4db3c90 is 0 bytes after a block of size 16 alloc'd
==149928==    at 0x483C583: operator new[](unsigned long) (in /usr/lib/x86_64-linux-gnu/valgrind/vgpreload_memcheck-amd64-linux.so)
==149928==    by 0x10919E: main (prog.cpp:4)

Písanie za koniec lokálneho poľa

Ani valgrind nemusí nájsť všetky chyby, napr. píšeme za koniec poľa, ktoré je lokálne vo funkcii, ale valgrind si to nevšimne:

#include <iostream>
using namespace std;
int main(void) {
    int a[4] = {10, 20, 30, 40};
    int *p = a;
    for(int i = 0; i <= 4; i++) {
        p[i] = 100;
    }
}

Ak index 4 nahradíme 200, program padne, ale valgrind nevie presne určiť kde...

  • Celkovo valgrind lepšie deteguje chyby týkajúce sa dynamicky alokovanej pamäte (pomocou new)

Hľadanie neodalokovanej pamäte

Valgrind nám tiež môže pomôcť nájsť pamäť, ktorú sme alokovali cez new, ale zabudli odalokovať cez delete alebo delete[].

  • V programe nižšie máme dve volania new, ku ktorým chýba odalokovanie
#include <iostream>
using namespace std;
int main(void) {
  int n = 100;
  int *a = new int[n];
  double *b = new double;
  for(int i = 0; i<n; i++) {
    a[i] = i;
  }
}

Valgrind vypíše

==149385== Memcheck, a memory error detector
==149385== Copyright (C) 2002-2017, and GNU GPL'd, by Julian Seward et al.
==149385== Using Valgrind-3.15.0 and LibVEX; rerun with -h for copyright info
==149385== Command: ./prog
==149385== 
==149385== 
==149385== HEAP SUMMARY:
==149385==     in use at exit: 408 bytes in 2 blocks
==149385==   total heap usage: 3 allocs, 1 frees, 73,112 bytes allocated
==149385== 
==149385== LEAK SUMMARY:
==149385==    definitely lost: 408 bytes in 2 blocks
==149385==    indirectly lost: 0 bytes in 0 blocks
==149385==      possibly lost: 0 bytes in 0 blocks
==149385==    still reachable: 0 bytes in 0 blocks
==149385==         suppressed: 0 bytes in 0 blocks
==149385== Rerun with --leak-check=full to see details of leaked memory
==149385== 
==149385== For lists of detected and suppressed errors, rerun with: -s
==149385== ERROR SUMMARY: 0 errors from 0 contexts (suppressed: 0 from 0)
  • Vidíme teda, že počas beho programu sa alokovali 3 kusy pamäte a iba jeden sa odalokoval
  • Z toho jedno alokovanie bolo v štandardnej knižnici, ale tie ďalšie dve sú naše a bolo by pekné ich odalokovať
  • Podľa pokynov programu spustíme valgrind --leak-check=full ./prog
  • Pribudne podrobnejší rozbor neodalokovanej pamäte
==149568== HEAP SUMMARY:
==149568==     in use at exit: 408 bytes in 2 blocks
==149568==   total heap usage: 3 allocs, 1 frees, 73,112 bytes allocated
==149568== 
==149568== 8 bytes in 1 blocks are definitely lost in loss record 1 of 2
==149568==    at 0x483BE63: operator new(unsigned long) (in /usr/lib/x86_64-linux-gnu/valgrind/vgpreload_memcheck-amd64-linux.so)
==149568==    by 0x109209: main (prog.cpp:6)
==149568== 
==149568== 400 bytes in 1 blocks are definitely lost in loss record 2 of 2
==149568==    at 0x483C583: operator new[](unsigned long) (in /usr/lib/x86_64-linux-gnu/valgrind/vgpreload_memcheck-amd64-linux.so)
==149568==    by 0x1091FB: main (prog.cpp:5)
  • valgrind nám teraz vypísal, na ktorom riadku je new, ku ktorému nebol volaný delete (riadky 5 a 6 v súbore prog.cpp)
  • tu to vidíme ľahko aj bez valgrind, ale vo väčšom programe vám táto informácia môže pomôcť

Cvičenie

Nasledujúci program by mal správne vypísať text "AhojAhojAhojAhoj", ale je v ňom zopár chýb. Skúste nájsť a opraviť chyby čítaním programu, použitím debugera, programu valgrind, prípadne si pridajte nejaké pomocné výpisy premenných.

  • v programe valgrind je vždy dobré začať od prvej vypísanej chyby, opraviť ju a spustiť valgrind znovu
#include <iostream>
using namespace std;

void opakuj(char kam[], char co[], char kolko) {
    /* Funkcia dostane na vstupe retazec co a cislo kolko a nakopiruje ho tolkokrat
     * za sebou do retazca kam. */

    int i=0; // pozicia v kam
    for(int opakovanie=0; opakovanie<kolko; opakovanie++) {  // opakuj kopirovanie
        for(int j=0; co[j]!=0; j++) {  // prechod cez znaky retazca co
            kam[i] = co[j];
            i++;
        }        
    }
}

int main(void) {
    char ahoj[4] = {'A', 'h', 'o', 'j'};
    char vysledok[16];  
    opakuj(vysledok, ahoj, 4);
    cout << vysledok << endl;
}

Testovač

Príklady z cvičení, domácich úloh, ale aj na záverečnej skúške budete odovzdávať na stránke http://prog.dcs.fmph.uniba.sk/ , ktorá bude súčasne mnohé z vašich riešení aj testovať.

  • Na stránku sa prihláste heslom, ktoré dostanete na prvých cvičeniach, po prihlásení si ho zmeňte.
  • Na testovači uvidíte zoznam príkladov, spolu s ich zadaniami v pdf formáte a s ďalšími potrebnými súbormi (napr. kostra programu, príklady vstupu)
  • Vaše programy môžete odovzdať buď zvolením súboru s programom (.cpp) z disku alebo nakopírovaním programu do textového poľa.
  • Váš program sa uloží na testovači a môžete si neskôr skontrolovať, či ste odovzdali správnu verziu.
  • Testovač váš program skompiluje a spustí na niekoľkých vstupoch. Výsledok testovania vám zobrazí.
  • V závislosti od zaťaženia servera zobrazenie výsledku môže nejaký čas trvať.
  • V ideálnom prípade dostanete výsledok OK, ak váš program vypísal na všetkých vstupoch správnu odpoveď.
  • Môže však dôjsť k rôznym chybám:
    • CE (compile error): chyba pri kompilácii, testovač vypíše výstup kompilátora. Odporúčame pred odovzdaním program skompilovať na vašom počítači.
    • WA (wrong answer): na niektorom vstupe váš program vypísal zlú odpoveď. Môže ísť o závažnú chybu v programe, ale aj len o malý problém vo formáte výstupu. Testovač väčšinou porovnáva váš výsledok znak po znaku so správnym výsledkom a každý rozdiel, ako napríklad medzera navyše, považuje za chybu. Pozrite si detaily testovania, či nezbadáte rozdiely vo formáte.
    • TO (time limit exceeded): program na príslušnom vstupe bežal príliš dlho. Nakoľko dávame pomerne veľké časové limity, pravdepodobne váš program sa "zacyklil", t.j. beží do nekonečna.
    • SG (segmentation fault): váš program spadol na chybu pri behu, napr. delenie nulou, prístup mimo hraníc poľa, prípadne priveľká spotreba pamäte.
    • Kód WAITING znamená, že váš program ešte čaká na spustenie a RUNNING znamená, že prebieha jeho testovanie.
  • Pri týchto problémoch skúste program spúšťať na viacerých vstupoch na vašom počítači a chybu objaviť. Niekedy vám poskytneme aj vstupy a výstupy použité na testovači, ktoré vám pomôžu pri hľadaní chyby. V opačnom prípade si skúste nejaké vstupy vymyslieť sami.
  • Program opravujte až kým nedostanete OK na všetkých vstupoch.
  • Testovač vám dovolí odovzdať program aj po termíne, ale takéto pokusy už nebudú brané do úvahy pri bodovaní, pokiaľ nemáte dohodnuté individuálne predĺženie termínu s vyučujúcimi.
  • Ak testovač nefunguje alebo ak nájdete chybu v zadaní, dajte nám vedieť na adrese E-prg.png Takisto na túto adresu posielajte otázky k zadaniam alebo prosby o pomoc s konkrétnou úlohou.

Poznámka o vstupe a výstupe

  • Ak v zadaní nie je povedané inak, všetok vstup načítavajte z konzoly príkazmi cin (prípadne scanf a pod.) a vypisujte na konzolu príkazmi cout (prípadne printf a pod.)
  • Vstup uvedený v zadaní má testovač v súbore a presmeruje ho vášmu programu na konzolu, t.j. správa sa podobne, ako keby niekto zadával príslušné hodnoty ručne počas behu vášho programu
  • Naopak testovač zachytí do súboru všetko, čo váš program vypíše na konzolu. Tento súbor potom porovnáva so správnou odpoveďou.
  • Napr. program na sčítanie čísel z prvej prednášky by mohol mať nasledovný vstup a výstup na testovači:
10
3
Please enter the first number: Please enter the second number: 10+3=13
  • V príkladoch na testovači väčšinou vynecháme interaktívne časti, program si teda nebude pýtať čísla od užívateľa, predpokladá, že ten ich sám od seba zadá.

Nastavenia kompilátora

  • Vaše programy kompilujeme pomocou gcc 9.4.0 s nastaveniami g++ -static -std=gnu++17 -O2 -Wall -Wextra

SVGdraw

Knižnica SVGdraw umožňuje vytvoriť obrázok v SVG formáte a vykresľovať do neho rôzne geometrické útvary, animovať ich a používať korytnačiu grafiku.

Vykresľovanie v SVG formáte

  • Ako prvé musíme vytvoriť súbor s obrázkom v SVG formáte s určitými rozmermi príkazom typu SVGdraw drawing(150, 100, "hello.svg");
  • Do obrázku môžeme kresliť príkazmi drawRectangle, drawEllipse, drawLine, drawText.
  • Ak chceme vykresľovať mnohouholníky, použijeme skupinu príkazov startPolygon, addPolygonPoint a drawPolygon. Pomocou startPolygon a addPolygonPoint postupne vymenujeme vrcholy a pomocou drawPolygon mnohouholník uzavrieme a vykreslíme.
  • Pomocou príkazov setLineColor, setFillColor, setNoFill nastavujeme farbu čiar a vyfarbovania. Farby zadávame buď troma číslami od 0 do 255 určujúcimi intenzitu červenej, zelenej a modrej, alebo názvom, napr. "red" (zoznam mien farieb). Príkaz setFontSize nastavuje veľkosť písma a setLineWidth nastavuje hrúbku čiary.
  • Po vykreslení všetkých útvarov ukončíme vykresľovanie príkazom drawing.finish();
  • Po spustení programu by mal vzniknúť súbor hello.svg, ktorý si môžete prezrieť napríklad v internetovom prehliadači.

Animácie

  • Príkaz wait umožní pozastaviť vykresľovanie SVG súboru o zadaný čas v sekundách, takže jednotlivé útvary sa objavujú postupne.
  • Príkaz clear schová všetky vykreslené útvary, takže môžeme kresliť znova na prázdnu plochu. Pred príkazom clear je vhodné použiť wait.
  • Príkaz hideItem schová objekt (napr. čiaru) so zadaným číslom. Každý kresliaci príkaz vráti číslo práve vykresleného objektu, takže si ho stačí uložiť v nejakej premennej pre neskoršie mazanie.

Príkazy pre korytnačiu grafiku

Namiesto vykresľovania obdĺžnikov, čiar a pod na zadané súradnice môžeme obrázok vytvoriť aj korytnačou grafikou. Na obrázku bude pohyb korytnačky znázornený ako červený trojuholník a za sebou bude nechávať čiernu čiaru.

  • Príkazom typu Turtle turtle(200, 300, "domcek2.svg", 50, 250, 0); vytvoríme SVG obrázok určitej veľkosti a s určitým menom súboru. Posledné tri čísla udávajú počiatočnú polohu korytnačky a jej natočenie.
  • Korytnačka si pamätá svoju polohu a natočenie na ploche. Príkaz forward posunie korytnačku dopredu, príkazy turnLeft a turnRight ju otočia.
  • Príkaz setSpeed umožňuje zmeniť rýchlosť korytnačky, aby sme lepšie videli, ako sa postupne hýbe.

Prednáška 1

Pozrite si úvod k predmetu a pravidlá.

Oznamy

  • Ak nemáte predmet zapísaný v AIS, zapíšte si ho pokiaľ možno už dnes do 14:00, aby sme pre vás pripravili konto na cvičenia.
  • Zajtra budú hlavné cvičenia s prvými bodovanými príkladmi.
    • Dve skupiny 9:50 a 14:50, zaradenie do skupiny ste dostali emailom. Treba prísť na určenú skupinu.
    • Oboznámenie sa s prostredím, s testovačom, riešenie jednoduchých príkladov k prvej prednáške.
    • Budete potrebovať prihlasovacie meno a heslo do AIS2 na prihlásenie na počítač v učebni.
    • Budete potrebovať aj heslo na náš testovač, ktoré vám pošleme dnes večer.
    • Aspoň na úvod cvičenia odporúčame prísť aj pokročilým.
    • Môžete si priniesť a používať aj vlastný počítač.
    • Ak z vážnych príčin nemôžete prísť, kontaktujte nás a dohodneme náhradné riešenie.
  • Na cvičení alebo aj skôr prosím vyplňte anonymnú anketu.
  • V stredu druhá prednáška.
  • Tento piatok sú doplnkové cvičenia pre všetkých nepovinné.
  • Ak si neviete C++ nainštalovať na notebook, prineste ho na doplnkové cvičenia niektorý piatok, skúsime pomôcť.
  • Do stredy obeda sa v prípade záujmu prihláste na test pre pokročilých.
  • Test pre pokročilých bude v piatok počas doplnkových cvičení v I-H3.

Programátorské prostredie

  • Na tomto predmete budeme programovať v jazyku C++, budeme však z neho používať len malú časť.
  • Na cvičeniach odporúčame vyskúšať ako v operačnom systéme Linux používať editor Kate.
  • Môžete používať aj iné programátorské prostredia, ale
    • odovzdané programy musia správne pracovať na testovači,
    • počas skúšky budete mať k dispozícii len to, čo beží v učebniach v Linuxe bez pripojenia na internet,
    • v iných prostrediach vám nemusíme vedieť pomôcť s ich používaním.
  • Ak na vašom počítači máte Windows, namiesto Kate si môžete nainštalovať napr. Visual Studio Code.
  • Vypnite si v programátorských prostrediach AI pomocné nástroje na písanie alebo opravovanie kódu.
  • Potrebné nástroje si môžete nainštalovať zadarmo aj na vašom počítači alebo môžete mimo rozvrhu používať školské učebne.

Prvý program

  • Tradične sa v učebniciach programovania ako prvý uvádza program, ktorý iba vypíše na obrazovku text "Hello world!". Tu je v jazyku C++:
#include <iostream>
using namespace std;

int main() {
    cout << "Hello world!" << endl;
}
  • Samotný text je vypísaný príkazom cout << "Hello world!" << endl;
  • Všimnite si, že text Hello world! sme dali do úvodzoviek, čím poukazujeme na to, že to nie sú príkazy programovacieho jazyka, ale text, s ktorým treba niečo robiť.
  • Za príkaz sme dali bodkočiarku, ktorá ho ukončuje.
  • O vypisovaní si povieme viac neskôr, ale už teraz môžete vypisovať rôzne texty tým, že zmeníte text medzi úvodzovkami.
  • Riadok int main() { označuje začiatok programu, program ide až po ukončovaciu zloženú zátvorku }
  • Jazyk C++ sám o sebe neobsahuje príkazy na vypisovanie (cout <<...). Na to potrebujeme použiť knižnicu: súbor príkazov, ktoré niekto už naprogramoval a my ich len používame. Prvé dva riadky programu nám umožnia používať štandardnú knižnicu iostream, ktorá je súčasťou C++ a ktorá obsahuje príkazy na vypisovanie.


Spúšťanie programu

  • Na to, aby sme náš program mohli spustiť na počítači, potrebujeme ho najskôr skompilovať, t.j. preložiť do spustiteľného strojového kódu.
  • Ako na to, nájdete v návode na prácu s editorom Kate.

Ďalší jednoduchý program

  • Podobným spôsobom môžeme vypísať aj iný text. Napríklad dnešný dátum:
#include <iostream>
using namespace std;

int main() {
    cout << "Dnes je 23.9.2024." << endl;
}
  • Cvičenie: Rozšírte program tak, aby na druhý riadok vypísal dátum v americkom formáte mesiac/deň/rok.

Premenné

Príklad z cvičenia by mohol vyzerať napríklad takto:

#include <iostream>
using namespace std;
int main() {
    cout << "23.9.2024" << endl;
    cout << "9/23/2024" << endl;
}

Ak by sme v ňom chceli zmeniť dátum na prvú prednášku o rok, museli by sme pomeniť vhodné čísla na dvoch miestach. Navyše keď vidíme v programe nejaké číslo, nemusí byť úplne jasné, čo znamená.

Program teraz prepíšeme tak, aby sme deň, mesiac a rok mali zapísané symbolicky a mohli ich meniť na jednom mieste.

#include <iostream>
using namespace std;
int main() {
    int den = 23;
    int mesiac = 9;
    int rok = 2024;

    cout << den << "." << mesiac << "." << rok << endl;
    cout << mesiac << "/" << den << "/" << rok << endl;
}

Symbolickým hodnotám den, mesiac, rok sa hovorí premenné.

  • Premenná je vyhradené miesto v pamäti počítača, ku ktorému v programe pristupujeme pod určitým názvom.
  • Do tejto pamäti si môžeme zapísať hodnotu a neskôr ju použiť.
  • Príkaz int x = 100; vytvorí novú premennú a uloží do nej hodnotu 100.
  • Každá premenná má určitý typ, ktorý určuje, aké hodnoty do nej môžeme ukladať.
  • Tieto premenné majú typ int, čo je skratka zo slova integer, celé číslo.

Ak v programe premenným priradíme iné čísla, môžeme vypísať iný dátum.

Príkaz int x = 100; vieme rozpísať aj na dva príkazy:

int x; 
x = 100;

Prvý z nich vytvorí premennú x, ktorá bude mať nejakú ľubovoľnú hodnotu a druhý túto počiatočnú hodnotu zmení na 100.

Zhrnutie

  • Programy, ktoré sme doteraz videli, vyzerali takto:
    • Najprv sme zapli používanie niekoľkých knižníc
    • Samotný program začínal int main() { a končil zloženou zátvorkou }
    • Program mohol mať niekoľko príkazov ukončených bodkočiarkami, ktoré sa vykonávajú jeden po druhom.
  • Logiku za tým, prečo jednotlivé príkazy píšu tak, ako sa píšu, sme zatiaľ ešte nevysvetľovali, mali by ste však byť schopní modifikovať príklady uvedené v prednáške menením čísel, textov v úvodzovkách, pridávaním ďalších príkazov a podobne.
  • Upozornenia:
    • Je rozdiel medzi malými a veľkými písmenami
    • Všetky čiarky, bodkočiarky, zátvorky a podobne sú dôležité
    • Na väčšine miest v programe môžeme voľne pridávať medzery a konce riadku, snažíme sa tým program spraviť prehľadný
  • Programy, ktoré sme videli doteraz, nie sú veľmi zaujímavé, lebo vždy robia to isté a robia pevný počet krokov, ktoré sme museli ručne všetky vypísať. Ďalej uvidíme
    • načítanie, ktoré nám umožní získať dáta od používateľa
    • podmienky, ktoré nám umožnia vykonávať príkazy podľa okolností
    • cykly, ktoré nám umožnia opakovať tie isté príkazy veľa krát

Textový výpis a načítanie

Vieme už vypísať niečo na obrazovku (výstup, output) a podobne môžeme aj čítať, čo nám používateľ napíše na klávesnici (vstup, input). Takéto zadané hodnoty tiež uložíme do premenných, aby sme s nimi mohli ďalej pracovať.

Sčítanie čísel

Nasledujúci program od používateľa vypýta dve čísla a vypíše ich súčet.

#include <iostream>
using namespace std;

int main() {
    int x, y;

    cout << "Please enter the first number: ";
    cin >> x;
    cout << "Please enter the second number: ";
    cin >> y;

    int result = x + y;
    cout << x << "+" << y << "=" << result << endl;
}

Tu je príklad behu programu, keď používateľ zadal čísla 10 a 3:

Please enter the first number: 10
Please enter the second number: 3
10+3=13
  • Tento program používa na vstup a výstup príkazy z knižnice iostream a teda do hlavičky programu dáme #include <iostream> a using namespace std;
  • Program najskôr vytvorí dve premenné x a y typu int (a nepriradzuje im zatiaľ žiadne hodnoty).
  • Potom príkazom cout vypíše text "Please enter the first number: " aby používateľ vedel, čo má robiť.
  • Potom pomocou príkazu cin načíta číslo od používateľa do premennej x.
  • To isté opakuje pre premennú y.
  • Potom vytvorí novú premennú result a uloží do nej súčet x a y.
  • Nakoniec vypíše výsledok aj s výrazom, ktorý sme počítali, pomocou príkazu cout.

Viac o príkaze cout

  • Pomocou cout vypisujeme na konzolu, t.j. textovú obrazovku.
  • To, čo chceme vypísať, pošleme na cout pomocou šípky <<
  • cout << endl; vypíše koniec riadku.
  • Môžeme naraz vypísať aj viac vecí oddelených šípkami <<
    • Napr. cout << x << "+" << y << "=" << result << endl; vypíše najskôr obsah premennej x (napr. hodnotu 10), potom znamienko plus (ktoré máme v úvodzovkách), potom obsah premennej y, potom znamienko rovnosti, potom obsah premennej result a nakoniec koniec riadku.

Viac o príkaze cin

  • Pomocu cin načítavame z konzoly údaje od používateľa.
  • Tieto údaje pošleme do premenných pomocou šípky >>
  • Opäť môžeme načítať aj viac vecí naraz, napr. nasledovný úryvok si vypýta obe čísla naraz a uloží ich do premenných x a y
cout << "Please enter two numbers separated by a space: ";
cin >> x >> y;
  • Pozor, cin nekontroluje, že používateľ zadáva rozumné hodnoty. Čo sa stane, ak namiesto čísla zadá nejaké písmená a podobne?

Prednáška 2

Oznamy

Opakovanie ako fungujú cvičenia:

  • V utorok 9:50 sa na testovači objaví nová sada úloh.
  • Prvá úloha je rozcvička, treba ju odovzdať do konca cvičenia, začnite teda touto úlohou.
  • Zvyšok cvičenia riešte ďalšie úlohy.
    • Rozcvičku pre druhú skupinu riešiť nemusíte, nedostanete za ňu body.
  • Čo nestihnete na cvičení, môžete dokončiť doma alebo na doplnkových cvičeniach v piatok, termín odovzdania je ďalší pondelok večer 22:00.
  • Úlohy sa dajú odovzdávať aj po termíne, ale nedostanete za ne body, ak sme vám neudelili výnimku.
  • Ak vám testovač k príkladu vypíše zelené OK, prešiel testami. Ak vypíše oranžový kód chyby, niečo je zle, treba opraviť a odovzdať znovu.
  • Body za prvé cvičenia sa objavia na testovači koncom budúceho týždňa, ale väčšinou dostanete body za všetky príklady, kde testovač dal OK.

Najbližšie dni na programovaní

  • Dnes prednáška, ďalšie dve prednášky budúci týždeň
  • Tento piatok doplnkové cvičenia nepovinné. Môžete využiť na konzultácie ohľadom inštalácie softvéru na váš notebook, ak potrebujete pomôcť s riešením úloh alebo máte otázky k učivu.
  • Počas doplnkových cvičení nepovinný test pre pokročilých v I-H3 (prihlasovanie do dnešného obeda cez testovač).
  • Budúci utorok ďalšie cvičenia s ďalšou sadou úloh (k prednáške dnes a v pondelok).

Technické záležitosti

  • Na stránke Softvér nájdete príklady ďalších programov, ktoré môžete použiť namiesto Kate.
  • Ak sa vám páči Kate, prečítajte si časť o Kate a práci na príkazovom riadku. Vo Windows si môžete nainštalovať podobný editor, napr. Notepad++.
  • Ak by ste chceli viac klikacie prostredie, môžete skúsiť napríklad programy Netbeans alebo VS Code, dajú sa použiť aj na skúške a dajú sa nainštalovať aj vo Windows.
  • Na svojom počítači máte širokú paletu možností, v čom programovať, aspoň občas ale skúste použiť Linux na cvičeniach, aby ste vedeli robiť skúšku (Kate alebo iné nástroje, nie však internetové prostredia).

Ďalšie upozornenia k štúdiu:

  • V rozvrhu http://candle.fmph.uniba.sk/ vidíte všetky predmety, ktoré sú povinné, povinne voliteľné alebo výberové pre váš ročník, ale nie všetky si musíte zapísať a naopak, môžete si zapísať aj iné predmety.
  • Dôležité je skontrolovať si, či to máte zapísané v AIS, sedí s tým, na čo aj reálne chodíte.
  • Prvé dva týždne (do 4.10.) si môžete v AIS pridávať a uberať predmety, potom treba kontaktovať študijné emailom, aby vám uzatvorili zápisný list.
  • Prvácke povinné predmety by ste si mali zapísať, z výberových si môžete vybrať.
  • Pozor na to, aby ste získali aspoň 15 kreditov za zimný semester.

Opakovanie

Doteraz sme videli:

  • Načítavanie pomocou cin, výpis pomocou cout.
  • Celočíselné premenné typu int.
#include <iostream>
using namespace std;

int main() {
    int x, y;

    cout << "Please enter the first number: ";
    cin >> x;
    cout << "Please enter the second number: ";
    cin >> y;

    int result = x + y;
    cout << x << "+" << y << "=" << result << endl;
}

Komentáre

Do zdrojových kódov programov v jazykoch C a C++ je možné pridávať komentáre, čo sú časti kódu ignorované kompilátorom.

  • Komentár je časť programu začínajúca /* a končiaca */ (aj cez viac riadkov)
  • Komentár je aj text od // až po koniec riadku. To je užitočné na písanie krátkych komentárov.

Do komentárov sa zvyknú písať poznámky k významu okolitých príkazov, čo zlepšuje orientáciu v kóde a jeho pochopenie inými programátormi.


Príklad:

#include <iostream>
using namespace std;

/* Tento program od pouzivatela nacita dve cele cisla
 * a vypise ich sucet. */

int main() {
    // vytvorime premenne x a y
    int x, y;

    // do premennych od pouzivatela nacitame cisla
    cout << "Please enter the first number: ";
    cin >> x;
    cout << "Please enter the second number: ";
    cin >> y;

    // do novej premennej result spocitame vysledok
    int result = x + y;
    // vysledok vypiseme
    cout << x << "+" << y << "=" << result << endl;
}

Podmienka (if)

Niekedy chceme vykonať určité príkazy len ak sú splnené nejaké podmienky. To nám umožňuje príkaz if.

  • Nasledujúci program si vypýta od používateľa číslo a vypíše, či je toto číslo záporné alebo nezáporné.
#include <iostream>
using namespace std;

int main() {
    int x;
    cout << "Zadajte cislo: ";
    cin >> x;

    if (x < 0) {
        cout << "Cislo " << x << " je zaporne." << endl;
    } else {
        cout << "Cislo " << x << " je nezaporne." << endl;
    }
}
  • Tu je príklad dvoch behov programu:
Zadajte cislo: 10
Cislo 10 je nezaporne.
Zadajte cislo: -3
Cislo -3 je zaporne.
  • Ako vidíme, za príkazom if je zátvorka s podmienkou. V našom príklade podmienka je x < 0.
  • Ak je podmienka v zátvorke splnená (t.j. ak x je menšie ako nula), vykonáme príkazy v zloženej zátvorke za príkazom if.
  • Ak podmienka nie je splnená (t.j. ak je x väčšie alebo rovné nule), vykonáme príkazy v zloženej zátvorke za slovom else
  • Časť else {...} je možné vynechať, ak nechceme vykonávať žiadne príkazy.
  • Ak za if alebo else nasleduje iba jeden príkaz, zátvorky { a } môžeme vynechať. To však ľahko vedie k chybám, preto je lepšie ich vždy použiť.

Cvičenie:

  • Pomocou podmienky vypíšte absolútnu hodnotu načítaného čísla.
  • Namiesto vypísania uložte túto hodnotu do premennej y, ktorá by sa dala ďalej v programe použiť.

Dátové typy int, double a bool

Na začiatok budeme pracovať s troma dátovými typmi:

  • typ int pre celé čísla – príkladmi konštánt typu int1, 42, -2, alebo 0.
  • typ double pre reálne čísla – príkladmi konštánt typu double4.2, -3.0, 3.14159, alebo 1.5e3 (1.5e3 je tzv. semilogaritmický zápis znamenajúci 1,5 ⋅ 103, t.j. 1500).
  • typ bool pre logické hodnoty – jedinými konštantami sú true a false.

Premenné typu int a double zaberajú pevne daný počet bitov, preto do nich nie je možné uložiť ľubovoľné celé alebo reálne číslo. Presný rozsah možných hodnôt môže závisieť od kompilátora, v súčasnosti však väčšinou platí:

  • Typ int zvyčajne zaberá 4 bajty (32 bitov) a dajú sa ním reprezentovať celé čísla z intervalu <-2 147 483 648, +2 147 483 647>.
  • Typ double zvyčajne zaberá 8 bajtov. Ním reprezentované reálne čísla sú v pamäti uložené vo forme z ⋅ a ⋅ 2b, kde z je znamienko, a je reálne číslo z intervalu <1,2) (mantisa) a b je celé číslo (exponent). Na uloženie mantisy sa používa 52 bitov a na uloženie exponentu 11 bitov. Typ double tak možno použiť na prácu s reálnymi číslami približne v rozsahu od 10-300 po 10300 s presnosťou na 15 až 16 platných číslic. Pri tejto reprezentácii sa nevyhradzujú pevné počty bitov na reprezentáciu celej a desatinnej časti; počet cifier pred a za rádovou čiarkou je určený exponentom. Hovoríme preto o pohyblivej rádovej čiarke.

Pretypovanie

Hodnotu niektorého z typov bool, int, double je možné skonvertovať na „zodpovedajúcu” hodnotu iného z týchto typov, tejto operácii hovoríme pretypovanie.

  • Pretypovanie int na double: hodnota čísla zostáva tá istá
  • Pretypovanie double na int: zaokrúhleniu smerom k nule (čiže nadol pri kladných číslach, nahor pri záporných), t.j. "urezanie" desatinných cifier
  • Pretypovanie bool na int alebo double: true sa konvertuje na 1 alebo 1.0 a false na 0 alebo 0.0
  • Pretypovanie z int alebo double na bool: nula sa skonvertuje na false, ľubovoľná nenulová hodnota na true

Pretypovanie je možné realizovať dvoma spôsobmi:

  • Implicitne, napríklad priradením premennej jedného typu do premennej iného typu, alebo použitím premennej jedného typu v kontexte, kde sa očakáva premenná druhého typu.
  • Explicitne, použitím pretypovacieho operátora: (nazov_noveho_typu) vyraz_stareho_typu.

Možnosti pretypovania sú ilustrované nasledujúcim ukážkovým programom.

#include <iostream>
using namespace std;

int main() {
  bool b1 = true;
  int n1 = 4;
  double x1 = 1.234;

  bool b2;
  int n2;
  
  b2 = n1; 
  n2 = x1; 
  cout << b2 << " " << n2 << endl; // Vypise 1 1
  
  x1 = n2; 
  cout << x1 << endl; // Vypise 1
  
  cout << (bool) 7 << " " << (bool) 0 << endl; // Vypise 1 0 
  cout << (int) 4.2 << " " << (int) -4.2 << endl; // Vypise 4 -4
  
}

Aritmetické operátory a výrazy

Na typoch int aj double sa dajú robiť základné aritmetické operácie

  • sčítanie +
  • odčítanie -
  • násobenie *
  • delenie /

Operátor / sa na argumentoch typu int správa ako celočíselné delenie – hodnota podielu sa zaokrúhli smerom k nule.

  • Napríklad výraz 5/3 má hodnotu 1.
  • Akonáhle je však aspoň jeden operand typu double, interpretuje sa / ako delenie reálnych čísel.
  • Výrazy 5.0 / 3.0, 5.0 / 3, 5 / 3.0 a 5 / (double)3 teda majú všetky hodnotu 1.66667.
#include <iostream>
using namespace std;

int main() {
    int a = 4;
    int b = 3;

    // Automaticky pretypuje cele cislo 3 na realne cislo 3.0
    double d = 3; 

    // Celociselne delenie: 4 / 3 = 1
    cout << a / b << endl; 
    // Necelociselne delenie: 4 / 3.0 = 1.33333
    cout << a / d << endl; 
    // Necelociselne delenie: (1.0 * 4) / 3 = 4.0 / 3 = 1.33333
    cout << (1.0 * a) / b << endl; 
    // Necelociselne delenie: 3.0 / 4 = 1.33333 
    cout << ((double)a) / b << endl; 
    
    // Do e je priradeny vysledok celociselneho delenia 4 / 3 = 1; 
    // po pretypovani je to rovne 1.0
    double e = a / b; 
    // Vypise 1
    cout << e << endl; 
    // Vypise 0.5, lebo 1.0 / 2 je necelociselne delenie  
    cout << e / 2 << endl; 
}
  • Na celých číslach je definovaný operátor %, ktorého výstupom je zvyšok po celočíselnom delení (modulo). Napríklad výraz 5 % 3 má hodnotu 2.
  • Ďalšie matematické operácie a funkcie vyžadujú #include <cmath> (pre jazyk C++) v hlavičke programu:
    • Napríklad cos(x), sin(x), tan(x) (tangens), acos(x) (arkus kosínus), exp(x) (ex), log(x) (prirodzený logaritmus), pow(x,y) (xy), sqrt(x) (odmocnina), abs(x) (absolútna hodnota), floor(x) (dolná celá časť), ...
    • Viac detailov možno nájsť v dokumentácii.

Relačné operátory

Hodnoty typov int, double a bool možno porovnávať nasledujúcimi relačnými operátormi:

  • == pre rovnosť;
  • != pre nerovnosť;
  • < pre reláciu „menší”;
  • > pre reláciu „väčší”;
  • <= pre reláciu „menší alebo rovný”;
  • >= pre reláciu „väčší alebo rovný”.

Výstupom relačného operátora je logická hodnota true alebo false.

Logické operátory a výrazy

Na výrazoch typu bool sú definované logické operátory, ktoré sa správajú rovnako ako logické spojky známe z výrokovej logiky:

  • || pre disjunkciu (or, alebo);
  • && pre konjunkciu (and, a súčasne);
  • ! pre negáciu (not, opak)

Logickým výrazom je napríklad !((x >= 2) && (x <= 4)) alebo !true.

Operátory priradenia, zvýšenie a zníženie hodnoty o 1

Operátor priradenia premenna = hodnota už poznáme. Často realizovanou operáciou na číslach je zvýšenie hodnoty o 1. To možno urobiť napríklad nasledujúcimi spôsobmi:

  • x = x + 1;
  • x += 1;
  • x++;

Analogicky sú definované operátory ako --, -=, *=, atď.

Priorita a asociativita operátorov

Výrazy sa vyhodnocujú v nasledujúcom poradí preferencie jednotlivých operátorov. Operátory v jednom riadku majú rovnakú prioritu a operátory vo vyššom riadku majú vyššiu prioritu, než operátory v nižších riadkoch.

  • ++ (inkrement), -- (dekrement), ! (logická negácia)
  • *, /, %
  • +, -
  • <, >, <=, >=
  • ==, !=
  • && (logická konjunkcia)
  • || (logická disjunkcia)
  • = (priradenie)

Poradie vyhodnocovania je možné meniť zátvorkami, ako napríklad vo výraze 4*(5-3).

Uvedené operátory sa väčšinou vyhodnocujú zľava doprava (hovoríme, že sú zľava asociatívne) – napríklad 1 - 2 - 3 sa teda vyhodnotí ako (1 - 2) - 3, t.j. -4 a nie ako 1 - (2 - 3), t.j. 2. Výnimkou sú operátory !, ++, -- a =, ktoré sú sprava asociatívne. To umožňuje napríklad viacnásobné priradenie a = b = c, ktoré najprv priradí hodnotu c do b a následne hodnotu výrazu b = c – tou je nová hodnota premennej b, čiže hodnota premennej c –, do a.

Viac sa o operátoroch v C++ možno dočítať napríklad tu.

Viac o podmienkach

Vnorené podmienky

Príkazy if môžeme navzájom vnárať.

Príklad: načítaj číslo a zisti, či je kladné, záporné alebo nula.

#include <iostream>
using namespace std;

int main() {
    int x;
    cout << "Please enter some number: ";
    cin >> x;

    if (x == 0) {
        cout << "Zero" << endl;
    } else {
        if (x > 0) {
            cout << "Positive" << endl;
        } else {
            cout << "Negative" << endl;
        }
    }

}

Logické výrazy môžu byť efektívnym nástrojom na elimináciu množstva vnorených podmienok: napríklad konštrukcia typu

if (a == 0) {
    if (b == 0) {
        čokoľvek
    }
}

je ekvivalentná konštrukcii

if (a == 0 && b == 0) {
    čokoľvek
}

Upozornenie

Častou chybou je použitie priradenia namiesto porovnania. Nasledujúci kúsok programu do premennej x priradí nulu, ktorá sa premení na false pre účely vyhodnotenia podmienky.

if (x=0) cout << zero << endl;


Ďalšia bežná chyba je zabudnutie zložených zátvoriek

if (x==0) cout << zero; cout << endl;

Tento program vykoná cout << endl vždy, nezávisle od podmienky. V prípade, že chceme vykonať v podmienke viacero príkazov, nesmieme zabudnúť ich uzátvorkovať:

if (x==0) { cout << zero; cout << endl; }

Cvičenia

  • Napíšte program, ktorý načíta čísla a,b,c a vypíše, či sú usporiadané vzostupne, t.j. či platí a<b<c
    • Pozor, výraz a<b<c treba rozpísať na dve porovnania spojené logickou spojkou

Cyklus for

Dôležitou časťou programovania je schopnosť opakovanie vykonávať tie isté príkazy. Prvou možnosťou ako to robiť, je cyklus for.

Príklad 1: vypisovanie čísel od 1 po n

Nasledujúci program načíta zo vstupu číslo n a postupne vypíše prirodzené čísla od 1 po n (pred každé dá medzeru).

#include <iostream>
using namespace std;

int main() {
    int n;
    cin >> n;
    for (int i = 1; i <= n; i++) {
        cout << " " << i;
    }
    cout << endl;
}

Tu je výstup programu pre n = 9:

 1 2 3 4 5 6 7 8 9

Cyklus for vyzeral v programe takto:

for (int i = 1; i <= n; i++) {
    telo cyklu
}

Táto konštrukcia pozostáva z kľúčového slova for nasledovaným zátvorkou s troma časťami oddelenými bodkočiarkami:

  • Príkaz int i = 1 vytvorí novú celočíselnú premennú i a priradí jej hodnotu 1.
  • Podmienka i <= n určuje dokedy sa má cyklus opakovať. V tomto prípade to má byť kým je hodnota premennej i menšia alebo rovná n.
  • Príkaz i++ hovorí, že po každom zopakovaní cyklu (t.j. po každej jeho iterácii) sa má hodnota premennej i zvýšiť o jedna.
  • Medzi zloženými zátvorkami { a } je potom tzv. telo cyklu – čiže jeden alebo viac príkazov, ktoré sa budú opakovať postupne pre rôzne hodnoty premennej i.

V príklade 1 je telom cyklu iba príkaz cout << " " << i;, ktorý vypíše medzeru a hodnotu premennej i.

Príklad 2: vypisovanie čísel od 0 po n-1

Drobnou zmenou predchádzajúceho programu môžeme napríklad vypísať všetky čísla od 0 po n-1:

#include <iostream>
using namespace std;

int main() {
    int n;
    cin >> n;
    for (int i = 0; i < n; i++) {
        cout << " " << i;
    }
    cout << endl;
}

Tu je výstup programu pre n = 9:

 0 1 2 3 4 5 6 7 8

Príklad 3: výpočet faktoriálu

Nasledujúci program si od používateľa vypýta číslo n a vypočíta n!, t.j. súčin celých čísel od 1 po n.

#include <iostream>
using namespace std;

int main() {
    int n;
    
    cout << "Zadajte n: ";
    cin >> n;
    
    int vysledok = 1;
    for (int i = 1; i <= n; i++) {
       vysledok = vysledok * i;
    }
    
    cout << n << "! = " << vysledok << endl;
}

Príklad behu programu pre n=4 (1*2*3*4=24):

Zadajte n: 4
4! = 24
  • Program používa premennú vysledok, ktorú na začiatku inicializuje hodnotou 1 a postupne ju násobí číslami 1, 2, ..., n.
  • Riadok vysledok = vysledok * i; zoberie pôvodnú hodnotu premennej vysledok, vynásobí ju hodnotou premennej i (t.j. jedným z čísel 1, 2, ..., n) a výsledok uloží naspäť do premennej vysledok (prepíše pôvodnú hodnotu). To isté sa dá napísať ako vysledok *= i;

Funkcia n! však veľmi rýchlo rastie a už pre n=13 sa výsledok nezmestí do premennej typu int. Dostávame nezmyselné hodnoty:

12! = 479001600
13! = 1932053504
14! = 1278945280
15! = 2004310016
16! = 2004189184
17! = -288522240

Správne hodnoty (ktoré možno získať zmenou typu premennej vysledok na long long int) sú:

12! = 479001600
13! = 6227020800
14! = 87178291200
15! = 1307674368000
16! = 20922789888000
17! = 355687428096000


Cvičenie: rozšírte program tak, aby okrem výpisu výsledku aj rozpísal faktoriál ako súčin:

Zadajte n: 4
4! = 1*2*3*4 = 24

Zhrnutie

Poznáme základné stavebné prvky, z ktorých sa dajú spraviť aj pomerne zložité programy:

  • premenné typu int, double, bool a operátory, ktoré s nimi vedia počítať
  • podmienku if, ktorá umožňuje vykonávať určité časti programu len za nejakých okolností
  • cyklus for, ktorý umožnuje opakovať určité časti programu veľa krát

Cieľom najbližšej prednášky a cvičení je hlavne precvičiť si tieto stavebné prvky na veľa ďalších príkladoch.

Cvičenia 1

Cieľom prvých cvičení je:

  1. vyskúšať si prihlásenie na počítače v učebni a prácu v prostredí Kate, prípadne v iných prostrediach,
  2. precvičiť si písanie jednoduchých programov,
  3. vyskúšať si prácu s testovačom.

Príklad 0: Sčítanie čísel

Príklad 1: odovzdávanie na testovači domácich úloh

  • Prihláste sa na testovač, zmeňte si heslo.
  • Pozrite si Návod na prácu s testovačom.
  • Nájdite si zadanie príkladu 1 na testovači, je to len malá modifikácia sčítania dvoch čísel.
  • Zmodifikujte program podľa požiadaviek zadania a odovzdajte ho na testovači.
    • Pozrite si výsledky testovania. Ak nedostanete odpoveď OK, skúste chybu opraviť.
  • Tento príklad je rozcvička, body zaňho dostanete, len ak ho dokončíte počas cvičenia pre vašu skupinu.

Ďalšie bodované príklady

  • Ďalšie zadania na tento týždeň nájdete priamo na testovači. Prvé tri príklady sa týkajú pondelkovej prednášky, ďalšie tri už potrebujú učivo zo stredy.
  • Môžete ich riešiť aj doma, až do budúceho pondelka 22:00.
  • Ak ste všetky príklady nevyriešili, odporúčame vám prísť na doplnkové cvičenia v piatok a pokračovať v ich riešení.

Prednáška 3

Oznamy

Budúci pondelok 7. októbra bude na časti prednášky teoretické cvičenie.

  • Budete písať krátky test zameraný na učivo z prvých troch prednášok.
  • Tento test sa počíta do druhých cvičení. Ak ich máte uznané z testu pre pokročilých, na test nemusíte ísť.
  • Na teste bude povolené používať pero a ťahák vo forme jedného obojstranne popísaného listu A4.

Ciele teoretického cvičenia

  • Precvičiť si poriadnejšie základné konštrukty.
  • Vynechaním časti prednášky vznikne väčší priestor precvičiť si základy.
  • Vyskúšať si prácu na papieri (bude treba na aj semestrálnom teste, ktorý má oveľa väčšiu váhu).

Prečo robíme toto cvičenie a písomné testy na papieri?

  • Dobré na precvičenie porozumenia hotovým programom (treba na ďalších predmetoch aj pri štúdiu odbornej literatúry).
  • Núti vás dobre si premyslieť každý detail, nie len náhodne skúšať meniť rôzne časti programu, kým nezačne fungovať.
  • Pri zložitejších programoch sa už nedá postupovať náhodne.
  • Na papieri miernejšie hodnotíme drobné chyby ako chýbajúca bodkočiarka, to vám na počítači pomôže nájsť kompilátor. Dôležitejšie je sa rozhodnúť, či má byť niekde napríklad i < n alebo i <= n.

Piatkové cvičenia

  • V piatok 4.10. budú cvičenia povinné pre študentov, ktorí zajtra (v utorok 1.10.) na cvičeniach nevyriešia aspoň dva príklady.

Opakovanie: výpočet faktoriálu

#include <iostream>
using namespace std;

int main() {
    int n;
    
    cout << "Zadajte n: ";
    cin >> n;
    
    int vysledok = 1;
    for (int i = 1; i <= n; i++) {
       vysledok = vysledok * i;
    }
    
    cout << n << "! = " << vysledok << endl;
}

Príklad behu programu pre n=4:

Zadajte n: 4
4! = 24

Cvičenie: rozšírte program tak, aby okrem výpisu výsledku aj rozpísal faktoriál ako súčin:

Zadajte n: 4
4! = 1*2*3*4 = 24


Ďalšie príklady na cyklus for

Simulovanie hodov kocky

Nasledujúci program od používateľa načíta číslo n a vypíše n simulovaných hodov kocky (každý na samostatný riadok).

#include <iostream>
#include <cstdlib>
#include <ctime>

using namespace std;

int main() {
    // Inicializacia generatora pseudonahodnych cisel
    srand(time(NULL)); 
 
    int n;
    cout << "Zadajte pocet hodov: ";
    cin >> n;
    
    for (int i = 1; i <= n; i++) {
        // Vygenerovanie a vypisanie hodu kockou
        cout << rand() % 6 + 1 << endl; 
    }
}

Príklad behu programu:

Zadajte pocet hodov: 6
2
6
1
2
1
4
  • Program využíva funkciu rand(), ktorá generuje pseudonáhodné celé čísla.
    • Nie sú v skutočnosti náhodné, lebo ide o pevne definovanú matematickú postupnosť, ktorá však má mnohé vlastnosti náhodných čísel.
    • Aby bolo možné použiť túto funkciu, treba do hlavičky pridať #include <cstdlib>.
    • Výstupom funkcie rand() je nezáporné celé číslo medzi nulou a nejakou veľkou konštantou.
    • Zvyšok po delení tohto čísla šiestimi, t.j. rand() % 6, je potom číslo medzi 0 a 5. Ak k tomu pripočítame 1, dostaneme číslo od 1 po 6.
  • Funkcia srand inicializuje generátor pseudonáhodných čísel na základe parametra určujúceho počiatočný bod pseudonáhodnej postupnosti.
    • My ako tento parameter používame aktuálny čas (v sekundách od začiatku roku 1970), čo zaručuje dostatočný efekt náhodnosti.
    • Aby bolo možné použiť funkciu time, je treba do hlavičky pridať #include <ctime>.

Vypisovanie deliteľov (podmienka v cykle)

Nasledujúci program načíta od používateľa prirodzené číslo n a vypíše zoznam jeho deliteľov.

  • číslo i delí číslo n práve vtedy, keď zvyšok po delení čísla n číslom i je 0
#include <iostream>
using namespace std;

int main() {
    int n;
    cout << "Zadajte cislo: ";
    cin >> n;
    
    cout << "Delitele cisla " << n << ":";
    for (int i = 1; i <= n; i++) {
        if (n % i == 0) {
            cout << " " << i;
        }
    }    
    cout << endl;
}

Príklad behu programu:

Zadajte cislo: 30
Delitele cisla 30: 1 2 3 5 6 10 15 30

Úprava a čitateľnosť programov

Pri písaní programov myslite na to, že ich typicky budú okrem počítača čítať aj ľudia (napríklad vy po dlhšom čase, učitelia, alebo kolegovia na väčšom projekte). Je preto zvykom dodržiavať určité zásady, ktoré čitateľnosť zdrojového kódu zlepšujú:

  • Odsadzovanie: príkazy vykonávané v cykle, či v podmienke (alebo vo všeobecnosti v ľubovoľnom bloku medzi { a }) odsadzujeme o niekoľko pozícií doprava (napríklad o 4 medzery).
    • Pri vnorených cykloch, podmienkach a podobne odsadzujeme o ďalšie štyri pozície, atď.
    • Väčšina editorov a IDE pre C/C++ nejakým spôsobom odsadzovanie podporujú
  • Voľné riadky: ucelené časti programu je kvôli prehľadnosti často dobré oddeliť prázdnym riadkom.
  • Medzery: odporúča sa písať okolo operátorov medzery.
  • Napríklad zápis
for (int i = 1; i <= n; i++) {
    a += i;
}

je o dosť prehľadnejší ako

for(int i=1;i<=n;i++){a+=i;}
  • Dĺžka riadku: odporúča sa vyhýbať sa riadkom dlhším ako 80 znakov. S dlhými riadkami sú problémy pri tlači alebo zobrazovaní v menších oknách; aj na veľkom monitore čitateľa zbytočne namáhajú. V prípade potreby je možné dlhšiu podmienku alebo iný výraz rozdeliť na viac riadkov.
  • Názvy premenných: najmä pri rozsiahlejších programoch je vhodné používať názvy premenných, ktoré vyjadrujú ich obsah (napríklad userCount alebo pocet_pouzivatelov namiesto h). Premenné v kratších programoch, alebo premenné používané iba lokálne v kratšom kuse programu možno označovať aj krátkymi zaužívanými názvami, ako napríklad i a j pre premenné v cykloch, n pre počet, alebo a pre pole.
  • Komentáre: význam jednotlivých úsekov kódu je najmä pri rozsiahlejších programoch dobré popísať v komentároch.

Pri známkovaní budeme brať do úvahy aj prehľadnosť vašich programov.

Cyklus while

Okrem cyklu for možno v jazykoch C/C++ používať aj cyklus while s nasledujúcou schémou:

while (podmienka) { 
    telo cyklu 
}

Telo takéhoto cyklu sa vykonáva, kým je podmienka cyklu splnená. Presnejšie sa pri vykonávaní cyklu while typicky deje nasledovné:

  1. Overí sa, či je podmienka cyklu splnená.
  2. Ak áno, vykoná sa telo cyklu a celý proces sa opakuje (čiže sa opätovne vykoná overenie podmienky z bodu 1, atď.).
  3. Ak nie, vykonávanie programu pokračuje prvým príkazom nasledujúcim za cyklom while.

Úroky v banke

  • Predpokladajme, že na začiatku každého roku uložíme na účet nejakú pevnú sumu (napríklad 1000 EUR).
  • Na konci každého roku sa vklad zúročí ročným úrokom (napríklad 5%).
  • Za koľko rokov úspory dosiahnu danú cieľovú čiastku (napríklad 10000 EUR)?

Túto úlohu budeme riešiť programom pracujúcim nasledovne:

  • V premennej ciastka budeme uchovávať aktuálny stav účtu
  • V každom roku túto premennú zvýšime o vklad a úrok
  • Toto opakujeme, kým suma uložená v premennej nie je rovná aspoň cieľovej čiastke.

To môžeme vyjadriť pomocou cyklu while.

#include <iostream>
using namespace std;

int main(void) {
    double vklad, ciel, urok;
        
    cout << "Zadaj kazdorocny vklad: ";
    cin >> vklad;
    cout << "Zadaj cielovu ciastku: ";
    cin >> ciel;
    cout << "Zadaj rocny urok (v %): ";
    cin >> urok;
    
    int rok = 0;
    double ciastka = 0;
    
    while (ciastka < ciel) {
        rok++;                   
        ciastka = (ciastka + vklad) * (1 + urok / 100);
        cout << "Na konci roku " << rok << " je na ucte " << ciastka << " EUR." << endl;  
    }
}

Ukážkový vstup a výstup:

Zadaj kazdorocny vklad: 1000
Zadaj cielovu ciastku: 10000
Zadaj rocny urok (v %): 5
Na konci roku 1 je na ucte 1050 EUR
Na konci roku 2 je na ucte 2152.5 EUR
Na konci roku 3 je na ucte 3310.12 EUR
Na konci roku 4 je na ucte 4525.63 EUR
Na konci roku 5 je na ucte 5801.91 EUR
Na konci roku 6 je na ucte 7142.01 EUR
Na konci roku 7 je na ucte 8549.11 EUR
Na konci roku 8 je na ucte 10026.6 EUR

Euklidov algoritmus

Euklidov algoritmus slúži na hľadanie najväčšieho spoločného deliteľa dvojice kladných celých čísel a, b.

  • To znamená: hľadáme najväčšie kladné prirodzené číslo d, ktoré delí súčasne a aj b.
  • Najväčší spoločný deliteľ čísel a, b označujeme gcd(a,b) (z angl. greatest common divisor). V slovenčine sa používa aj s označenie nsd(a,b).
  • Ide o jeden z najstarších známych algoritmov vôbec. Jeho variant popísal už Euklides v diele Základy okolo roku 300 pred Kr.

Príklad:

  • Delitele čísla 12 sú 1, 2, 3, 4, 6, 12.
  • Delitele čísla 8 sú 1, 2, 4, 8.
  • Spoločné delitele 8 a 12 teda sú 1, 2, 4.
  • Najväčší spoločný deliteľ čísel 8 a 12 je teda gcd(8,12) = 4.


Euklidov algoritmus je založený na platnosti nasledujúcej lemy.

Lema. Pre všetky kladné celé čísla a, b platí:

gcd(a,b) = gcd(b, a mod b).

Dôkaz.

  • Nech X je množina spoločných deliteľov a a b, nech Y je množina spoločných deliteľov b a a mod b. Dokážeme rovnosť X = Y.
  • Ak označíme r := a mod b, tak existuje celé číslo q také, že a = q b + r.
  • Ak x ∈ Y, číslo x delí b aj r a z rovnosti a = q b + r vyplýva, že delí aj a. Teda x ∈ X.
  • Ak naopak x ∈ X, číslo x delí a aj b a z rovnosti r = a - qb vyplýva, že delí aj r. Preto x ∈ Y.
  • Dokázali sme teda, že platí X ⊆ Y a súčasne X ⊆ Y. Preto X = Y. ◻

Poznámka: môže sa stať, že a mod b je 0. Nakoľko ale každé celé číslo delí nulu, gcd(b, 0) = b pre každé kladné b.

  • Euklidov algoritmus opakovane používa lemu: gcd(12,8) = gcd(8, 4) = gcd(4, 0) = 4
  • Dostáva sa k stále menším číslam, až kým b neklesne na nulu

Implementácia tohto algoritmu teda môže vyzerať nasledovne:

#include <iostream>
using namespace std;

int main() {
    int a, b;    
    cout << "Zadaj dvojicu kladnych celych cisel: ";
    cin >> a >> b;
    
    while (b != 0) {
        int r = a % b;
        a = b;
        b = r;
    }
    
    cout << "Najvacsi spolocny delitel je " << a << "." << endl;
}

Príklad behu programu:

Zadaj dvojicu kladnych celych cisel: 30 8
Najvacsi spolocny delitel je 2.

Tento výpočet prešiel cez dvojice:

30 8
8 6
6 2
2 0

Cvičenie: Ako bude fungovať Euklidov algoritmus pre vstupné čísla 8, 30?

Nekonečný cyklus

V cykle while typu

while (true) {
    telo cyklu
}

je podmienka stále splnená; telo cyklu sa teda opakuje donekonečna resp. kým program nezastavíme (v prípade, že cyklus neukončíme umelo príkazom break, ktorý uvidíme o chvíľu).

Napríklad môžeme donekonečna niečo vypisovať:

#include <iostream>
using namespace std;

int main() {
    while (true) {
        cout << "Hello, World!" << endl;
    }
}

Hra „hádaj číslo”

V nasledujúcom programe si počítač „myslí” číslo od 1 do 100 a užívateľ háda, o ktoré číslo ide (až kým nakoniec neuhádne).

#include <iostream>
#include <cstdlib>
#include <ctime>
using namespace std;

int main() {
    srand(time(NULL));

    int num = rand() % 100 + 1;
    cout << "Myslim si cislo od 1 po 100. Tvoj tip: ";
    
    bool correct = false;

    while (!correct) {
        int guess;
        cin >> guess;
        if (guess < num) {
            cout << "Prilis nizko. Iny tip: ";
        } else if (guess > num) {
            cout << "Prilis vysoko. Iny tip: ";        
        } else {
            correct = true;
            cout << "Spravne!" << endl;
        }
    }
}

Príklad behu programu:

Myslim si cislo od 1 po 100. Tvoj tip: 50
Prilis nizko. Iny tip: 70
Prilis nizko. Iny tip: 90
Prilis vysoko. Iny tip: 80
Spravne!

Príkazy break a continue

V C/C++ existuje dvojica príkazov umožňujúcich umelo prerušiť vykonávanie cyklu resp. jednej jeho iterácie:

  • Príkaz break ukončí cyklus, v ktorom sa program práve nachádza; vykonávanie programu pokračuje prvým príkazom za cyklom.
  • Príkaz continue „skočí” na ďalšiu iteráciu cyklu, pričom nevykoná zvyšok tela cyklu.

Tieto príkazy treba používať s mierou, keďže robia program menej prehľadným a sú častým zdrojom chýb.

Hra „hádaj číslo” s príkazom break

Program uvedený vyššie môžeme prepísať bez boolovskej premennej s použitím nekonečného cyklu, z ktorého ale „vyskočíme” príkazom break, keď používateľ uhádne.

#include <iostream>
#include <cstdlib>
#include <ctime>
using namespace std;

int main() {
    srand(time(NULL));

    int num = rand() % 100 + 1;
    cout << "Myslim si cislo od 1 po 100. Tvoj tip: ";
    
    while (true) {
        int guess;
        cin >> guess;
        if (guess < num) {
            cout << "Prilis nizko. Iny tip: ";
        } else if (guess > num) {
            cout << "Prilis vysoko. Iny tip: ";        
        } else {
            cout << "Spravne!" << endl;
            break;
        }
    }
}

Viac o cykle for

Schéma cyklu for

Cyklus for sme doposiaľ používali iba veľmi základným spôsobom; jeho možnosti sú omnoho väčšia. Vo všeobecnosti možno schému cyklu for popísať nasledovne:

for (prikaz1; podmienka; prikaz2) {
    postupnost_prikazov
}

Takýto cyklus potom pracuje nasledovne:

  • Vykoná sa príkaz prikaz1.
  • Kým platí podmienka podmienka, vykonáva sa telo cyklu postupnost_prikazov zakaždým nasledované príkazom prikaz2.

Uvedený cyklus for je teda typicky ekvivalentný nasledujúcemu cyklu while:

prikaz1;
while (podmienka) {
    postupnost_prikazov
    prikaz2;
}

(Drobnou výnimkou je prípad, keď telo cyklu for obsahuje príkaz continue. V takom prípade sa príkaz prikaz2 aj tak vykoná.)

Nasledujúce kúsky kódu napríklad obidva vypisujú čísla 1 až 9:

for (int i = 1; i <= 9; i++) {
    cout << " " << i;
}
int i = 1;
while (i <= 9) {
    cout << " " << i;
    i++;
}

Cyklus for spravidla používame, ak má náš cyklus krátky a jednoduchý iterátor (prikaz2) a jednoduchú podmienku. V opačnom prípade väčšinou používame cyklus while.

Vypisovanie deliteľov od najväčších

Vráťme sa k programu na vypisovanie všetkých deliteľov čísla n z úvodu tejto prednášky. Tam sme deliteľov vypisovali v poradí od najmenšieho po najväčší, a to použitím cyklu

for (int i = 1; i <= n; i++)

Nahradením tohto cyklu cyklom

for (int i = n; i >= 1; i--)

získame program vypisujúci delitele v poradí od najväčšieho po najmenší.

#include <iostream>
using namespace std;

int main() {
    int n;
    cout << "Zadajte cislo: ";
    cin >> n;
    
    cout << "Delitele cisla " << n << ":";
    for (int i = n; i >= 1; i--) {
        if (n % i == 0) {
            cout << " " << i;
        }
    }
    cout << endl;
}

Príklad behu programu:

Zadajte cislo: 30
Delitele cisla 30: 30 15 10 6 5 3 2 1

Rýchlejšie hľadanie deliteľov

Program na vypisovanie deliteľov môžeme o niečo urýchliť:

  • Stačí si všimnúť, že i je deliteľ čísla n práve vtedy, keď n/i je deliteľom čísla n.
  • Číslo n/i teda môžeme rovno vypísať spoločne s i.
  • Aspoň jedno z tejto dvojice čísel je navyše menšie alebo rovné odmocnine z n, čo znamená, že stačí prehľadať iba čísla i spĺňajúce túto podmienku.
#include <iostream>
using namespace std;

int main() {
    int n;
    cout << "Zadajte cislo: ";
    cin >> n;
    
    cout << "Delitele cisla " << n << ":";
    for (int i = 1; i*i <= n; i++) {
        if (n % i == 0) {
            cout << " " << i << " " << n / i;
        }
    }
    cout << endl;
}

Cvičenie 1: Občas sa môže stať, že program vypíše niektorého deliteľa dvakrát. Kedy? Modifikujte telo cyklu for tak, aby program každého deliteľa vypísal práve raz.

Cvičenie 2: Vyskúšajte si rýchlosť rôznych variantov programu na vypisovanie deliteľov na veľkom vstupe, napríklad pre n = 1234567890.

Nekonečný cyklus for

V cykle

for (prikaz1; podmienka; prikaz2) {
    postupnost_prikazov
}

môžu byť príkazy prikaz1 a prikaz2 aj prázdne – v takom prípade sa na ich mieste nič nevykoná. Podobne môže byť prázdna aj podmienka podmienka, ktorá sa v takom prípade interpretuje ako true.

Nekonečný cyklus možno teda napísať aj ako

for ( ; true; ) {
    cout << "Hello, World!" << endl;
}

prípadne ako

for ( ; ; ) {
    cout << "Hello, World!" << endl;
}

Vnorené cykly

Vypíšeme tabuľku násobilky, v ktorej bude v riadku i a stĺpci j súčin i ⋅ j.

  • Použijeme dva vnorené cykly: jeden pôjde cez riadky, druhý cez stĺpce.
#include <iostream>
using namespace std;

int main() {
    int n;  // pokial ma ist nasobilka
    cin >> n;
    for (int riadok = 1; riadok <= n; riadok++) {
        for (int stlpec = 1; stlpec <= n; stlpec++) {
            cout << " " << riadok * stlpec;
        }
        cout << endl;
    }
}

Ukážka výstupu pre n=5:

 1 2 3 4 5
 2 4 6 8 10
 3 6 9 12 15
 4 8 12 16 20
 5 10 15 20 25

Ak pred každé číslo vypíšeme namiesto medzery " " tabulátor "\t", dostaneme krajší výstup

	1	2	3	4	5
	2	4	6	8	10
	3	6	9	12	15
	4	8	12	16	20
	5	10	15	20	25

Cvičenie: pridajme jednotlivým riadkom a stĺpcom hlavičku

Cykly: zhrnutie

  • Videli sme niekoľko príkladov využitia cyklov for a while.
  • Cyklus for je možné zapísať ako while (a naopak).
  • Z cyklu vieme vyskočiť príkazom break, prejsť na ďalšiu iteráciu príkazom continue. Používať s mierou.
  • Euklidov algoritmus rýchlo nájde najväčšieho spoločného deliteľa dvoch čísel.

Prednáška 4

Oznamy

Čo nás čaká v najbližších dňoch

  • V piatok budú doplnkové cvičenia.
    • Povinné budú pre tých, ktorí v utorok na cvičeniach nevyriešili úspešne aspoň dva príklady.
    • Môžete prísť aj ostatní, ak potrebujete pomôcť s riešením úloh alebo máte iné otázky k predmetu.
    • Na doplnkových cvičeniach máte povolené pracovať v dvojiciach a odovzdávať príklady z cvičení spolu.
    • Vzhľadom na to, že v piatok nemáte v rozvrhu prestávku na obed, budeme uznávať účasť aj keď prídete trochu neskôr, najneskôr však 13:25.
  • V pondelok 7.10. bude časť prednášky teoretické cvičenie, na ktorom bude krátky test.
    • Test bude z prvých troch prednášok.
    • Ak máte aspoň jedny cvičenia uznané z testu pre pokročilých, na test nemusíte ísť.
    • Na teste bude povolené používať pero a ťahák vo forme jedného obojstranne popísaného listu A4.

Funkcie

Funkcia je samostatný kus kódu (postupnosť príkazov) s určitým menom. Po zavolaní funkcie jej menom sa daná postupnosť príkazov vykoná.

  • V iných programovacích jazykoch sa používajú aj podobné termíny ako procedúra, metóda, či podprogram.
  • Funkcia vo všeobecnosti dostane niekoľko (aj nula) vstupných argumentov, ktoré môže pri svojom behu používať.
  • Funkcia tiež môže vrátiť výstupnú hodnotu.
  • Ide teda o veľmi podobný koncept ako funkcie v matematike (ako napríklad sin(x)):
    • Oboje si možno predstaviť ako „krabičku”, ktorá na základe niekoľkých vstupných hodnôt vráti nejakú výstupnú hodnotu.
    • Funkcie v C/C++ ale môžu okrem vracania výstupných hodnôt vykonávať ľubovoľný kód, teda napríklad aj niečo vypisovať na konzolu a podobne.

Obvod trojuholníka bez použitia funkcií

Užitočnosť funkcií ilustrujeme na príklade. Chceme napísať program, ktorý od používateľa načíta súradnice vrcholov trojuholníka ABC a na výstup vypíše obvod tohto trojuholníka. Beh takéhoto programu teda môže vyzerať napríklad takto:

Zadaj suradnice vrcholu A: 0 0
Zadaj suradnice vrcholu B: 3 0
Zadaj suradnice vrcholu C: 0 4
Obvod trojuholnika ABC: 12

Obvod spočítame ako súčet dĺžok jednotlivých strán, v programe teda trikrát opakujeme výpočet dĺžky strany:

#include <iostream>
#include <cmath>
using namespace std;

int main() {
    double Ax, Ay, Bx, By, Cx, Cy;
    
    cout << "Zadaj suradnice vrcholu A: "; 
    cin >> Ax >> Ay;
    cout << "Zadaj suradnice vrcholu B: ";
    cin >> Bx >> By;
    cout << "Zadaj suradnice vrcholu C: ";
    cin >> Cx >> Cy;
    
    /* Spocitaj dlzky jednotlivych stran: */
    double a = sqrt((Bx - Cx) * (Bx - Cx) 
                    + (By - Cy) * (By - Cy));
    double b = sqrt((Ax - Cx) * (Ax - Cx) 
                    + (Ay - Cy) * (Ay - Cy));
    double c = sqrt((Ax - Bx) * (Ax - Bx) 
                    + (Ay - By) * (Ay - By));
    
    cout << "Obvod trojuholnika ABC: " << a + b + c;
}

Opakované písanie toho istého vzorca na výpočet dĺžky strany je prácne; navyše pri ňom ľahko spravíme chybu. V nasledujúcom programe ho teda nahradíme funkciou.

Obvod trojuholníka s použitím funkcie

Videli sme teda, že na výpočet obvodu trojuholníka sa nám môže zísť funkcia počítajúca vzdialenosť medzi dvoma bodmi v rovine, t.j. dĺžku úsečky. Tá môže v C/C++ vyzerať napríklad takto:

double dlzka(double x1, double y1, double x2, double y2) {
    return sqrt((x1 - x2) * (x1 - x2) + (y1 - y2) * (y1 - y2));
}

Týmto sme zadefinovali funkciu s názvom dlzka so štyrmi vstupnými argumentmi x1, y1, x2, y2 typu double, reprezentujúcimi súradnice dvojice bodov v rovine. Pred samotný názov funkcie sme zadali návratový typ funkcie, ktorým je double – táto funkcia teda bude vracať na výstupe reálne čísla. Nakoniec sme zadefinovali samotné telo funkcie, tentokrát pozostávajúce z jediného špeciálneho príkazu return, ktorým funkcia vracia svoju výstupnú hodnotu.

Kompletný program na výpočet obvodu trojuholníka môže s použitím funkcie dlzka vyzerať napríklad takto:

#include <iostream>
#include <cmath>
using namespace std;

/* Definicia funkcie dlzka: */
double dlzka(double x1, double y1, double x2, double y2) {
    return sqrt((x1 - x2) * (x1 - x2) + (y1 - y2) * (y1 - y2));
}

int main() {
    double Ax, Ay, Bx, By, Cx, Cy;
    
    cout << "Zadaj suradnice vrcholu A: "; 
    cin >> Ax >> Ay;
    cout << "Zadaj suradnice vrcholu B: ";
    cin >> Bx >> By;
    cout << "Zadaj suradnice vrcholu C: ";
    cin >> Cx >> Cy;
    
    // Spocitaj dlzky jednotlivych stran:

    // Volanie funkcie dlzka s argumentmi Bx, By, Cx, Cy
    double a = dlzka(Bx, By, Cx, Cy);
    // Volanie funkcie dlzka s argumentmi Ax, Ay, Cx, Cy
    double b = dlzka(Ax, Ay, Cx, Cy);
    // Volanie funkcie dlzky s argumentmi Ax, Ay, Bx, By
    double c = dlzka(Ax, Ay, Bx, By);
    
    cout << "Obvod trojuholnika ABC: " << a + b + c;
}

Telo funkcie nemusí pozostávať iba z jediného príkazu. Napríklad ešte jednoduchšie by sme funkciu dlzka mohli napísať takto:

double dlzka(double x1, double y1, 
            double x2, double y2) {
    double dx = x1 - x2;
    double dy = y1 - y2; 
    return sqrt(dx * dx + dy * dy);
}

Cvičenie:

  • V Pascale existuje funkcia sqr, ktorá na vstupe berie reálne číslo x a výstupom je toto číslo umocnené na druhú. Naprogramujte túto funkciu v C/C++ a použite ju na zjednodušenie funkcie dlzka.
  • Mohli by sme použiť aj pow(x,2) (treba #include <cmath>), ale môže byť jednoduchšie zrátať x*x.

Výhody funkcií

Funkcie sú užitočné z viacerých dôvodov:

  • Umožňujú vytvoriť „skratku” pre často používané časti kódu, ktoré tak nie je nutné zakaždým písať nanovo.
  • Umožňujú používanie kusov kódu vytvorených iným programátorom. Napríklad sme použili funkciu sqrt, ktorá vráti druhú odmocninu svojho vstupu.
  • Funkcie umožňujú rozdeliť písanie programu na menšie časti. Zvlášť sa môžeme sústrediť na telo programu, ktoré používa funkciu a zvlášť na implementáciu samotnej funkcie. Tieto dve časti môžu robiť aj rôzni programátori.

Definícia funkcie

Definícia funkcie pozostáva z nasledujúcich častí:

  • Typ návratovej hodnoty funkcie. Funkcia z úvodného príkladu napríklad vracia vzdialenosť bodov v rovine, teda hodnotu typu double. Podobne môžeme písať funkcie vracajúce iné návratové typy, napríklad int. Funkcie, ktoré nemajú vracať žiadnu hodnotu majú špeciálny návratový typ void – ide typicky o funkcie, ktoré len vykonajú určitú činnosť, napríklad vypísanie textu na konzolu a podobne.
  • Identifikátor funkcie. Funkciu môžeme (v rámci určitých medzí) pomenovať prakticky ľubovoľne, rovnako ako pri premenných. Vhodné je však použiť názov, ktorý vystihuje úlohu danej funkcie (napríklad dlzka).
  • Zoznam vstupných parametrov funkcie. V zátvorkách za názvom funkcie je zoznam typov a identifikátorov vstupných argumentov, ktoré funkcia očakáva. V úvodnom príklade funkcia očakáva súradnice dvoch bodov v rovine, teda štyri hodnoty typu double. Ak funkcia neočakáva žiaden vstupný argument, môžeme do zátvoriek napísať void alebo nechať zoznam argumentov prázdny.
  • Telo funkcie. Do zložených zátvoriek za definíciou funkcie píšeme postupnosť príkazov, ktoré má funkcia vykonať.
  • Príkaz return. Vracia návratovú hodnotu funkcie.
typ_navratovej_hodnoty identifikator_funkcie(zoznam_vstupnych_argumentov) {
    telo_funkcie // Môže obsahovať príkazy return.
}

Ďalšie príklady funkcií

Súčet čísel od a po b

Ukážeme si tri verzie funkcie, ktorá dostane dvojicu celých čísel a, b a spočíta súčet všetkých celých čísel od a po b vrátane.

  • Prvá verzia tento súčet vráti ako návratovú hodnotu.
  • Druhá verzia súčet vypíše, vrátane sčitovaných čísel, jej návratový typ bude void.
  • Tretia verzia súčet aj vypíše aj vráti.
/* Funkcia, ktora vrati sucet a + (a+1) + ... + (b-1) + b */
int sum(int a, int b) {
    int result = 0;
    for (int i = a; i <= b; i++) {
        result += i;
    }    
    return result;
}

/* Funkcia, ktora vypise cisla a, (a+1), ..., b  a ich sucet */
void printNumbers(int a, int b) {
    int result = 0;
    for (int i = a; i <= b; i++) {
        if (i > a) {
            cout << " + ";
        }
        cout << i;
        result += i;
    }    
    cout << " = " << result << endl;
}

/* Funkcia, ktora vypise cisla a, (a+1), ..., b  a ich sucet
 * a tento sucet aj vrati. */
int sumAndPrint(int a, int b) {
    int result = 0;
    for (int i = a; i <= b; i++) {
        if (i > a) {
            cout << " + ";
        }
        cout << i;
        result += i;
    }    
    cout << " = " << result << endl;
    return result;
}

Program využívajúci tieto funkcie môže vyzerať napríklad takto:

#include <iostream>
using namespace std;

// sem pridu definicie funkcii sum, printNUmbers, sumAndPrint 
// uvedene vyssie

int main() {
    int a, b;
    
    cout << "Zadaj dvojicu celych cisel: ";
    cin >> a >> b;

    cout << "Test funkcie sum" << endl;
    cout << "Sucet celych cisel od " << a << " po " << b << ": ";
    // zavolame funkciu a vysledok priamo vypiseme
    cout << sum(a, b) << endl;
    // vysledok funkcie mozeme ulozit do premennej 
    // a pouzit neskor:
    int sucet = sum(a, b); 
    cout << "Druha mocnina suctu cisel je: " << sucet * sucet << endl;

    cout << endl << "Test funkcie printNumbers" << endl;
    // tato funkcia nema vysledok, nic neukladame
    printNumbers(a, b);
    
    cout << endl << "Test funkcie sumAndPrint" << endl;
    // dalsia funkcia vypise aj vrati hodnotu
    int sucet2 = sumAndPrint(a, b);
    cout << "Druha mocnina suctu cisel je: " << sucet2 * sucet2 << endl;
    
    // vystupnu hodnotu funkcie mozeme aj odignorovat
    sumAndPrint(a,b); 
}

Príkaz return

Pozrime sa teraz bližšie na špeciálny príkaz return:

  • Po vykonaní príkazu return je funkcia okamžite zastavená a jej výstupná hodnota je výraz za slovom return.
  • Funkcia môže obsahovať aj viacero volaní return. Akonáhle sa však jedno z nich vykoná, funkcia končí s danou návratovou hodnotou. Napríklad:
#include <iostream>
using namespace std;

int f(void) {
    return 1;
    return 2; // Nikdy sa nevykona.
    return 3; // Nikdy sa nevykona.
}

int main() {
    cout << f() << endl; // Vypise 1.
}
  • Funkcia s návratovým typom void môže tiež obsahovať príkaz return, avšak bez návratovej hodnoty.
    • V takom prípade return slúži iba na ukončenie vykonávania funkcie, väčšinou je lepšie prepísať inak
    • Tu je ukážka, kde namiesto return by bolo lepšie použiť else:
#include <iostream>
using namespace std;

void akeCislo(int n) {
    if (n >= 0) {
        cout << "Cislo je nezaporne." << endl;
        return;
    }

    // Vykona sa len v pripade n < 0:
    cout << "Cislo je zaporne." << endl; 
}

int main() {
    int n;
    cin >> n;
    akeCislo(n);
}
  • Napríklad minimum z dvoch čísel môžeme vypočítať dvoma rôznymi spôsobmi:
int minimum1(int a, int b) {
    int minval;
    if (a < b) {
        minval = a;
    } else {
        minval = b;
    }
    return minval;
}

int minimum2(int a, int b) {
    if (a < b) {
        return a;
    } else {
        return b;
    }
}
  • Funkcie s návratovým typom iným ako void je žiadúce písať tak, aby na ľubovoľnom vstupe ich vykonávanie vždy skončilo príkazom return.
    • Ak totiž takáto funkcia skončí inak, než príkazom return, jej výstupná hodnota je nedefinovaná (použije sa „hocijaký nezmysel”), čo môže viesť k zákerným chybám.

Cvičenie: Nájdite hodnotu nasledujúcej funkcie pre n=6 a n=7. Viete stručne popísať, čo funkcia robí pre všeobecné n?

int zahada(int n) {
    for (int i = 2; i * i <= n; i++) {
        if (n % i == 0) {
            return i; 
        }
    }
    return n;
}

Lokálne a globálne premenné

Premenné v C/C++ možno rozdeliť na globálne a lokálne:

  • Globálne premenné možno používať vo všetkých funkciách, ktoré sú v programe definované za touto premennou.
  • Lokálne premenné sú definované vo vnútri funkcie a môžu sa používať iba v rámci nej (alebo iba v rámci niektorej časti funkcie, ak je premenná definovaná napríklad v tele cyklu a podobne).

Platí navyše, že:

  • Viaceré funkcie môžu mať lokálne premenné s tým istým názvom – každá funkcia potom používa tú svoju. Toto sa bežne používa.
  • Ak má lokálna premenná rovnaký názov ako nejaká globálna premenná, lokálna premenná prekryje globálnu – funkcia teda používa svoju lokálnu premennú (bližšia košeľa ako kabát). Toto radšej nerobte, môže vzniknúť chaos.

Je silno odporúčané používať predovšetkým lokálne premenné. Väčšie programy s globálnymi premennými môžu byť veľmi neprehľadné.

Nasledujúci program obsahuje globálnu premennú x a v každej funkcii lokálnu premennú s názvom y:

#include <iostream>
using namespace std;

int x;

void f(void) {
    int y = 10;
    cout << x << " " << y << endl;      
}

int main() {
    x = 10;
    int y = 20;
        
    f();                            // Vypise 10 10.  
    cout << x << " " << y << endl;  // Vypise 10 20.
}

Parametre funkcií

Odovzdávanie parametrov hodnotou

Vstupné parametre funkcií sa správajú ako lokálne premenné danej funkcie. Pri volaní funkcie sa každému parametru priradí určitá hodnota. Uvažujme napríklad funkciu

void f(int a, int b) {
    // ...
}

Pri volaní

f(1,2);

sa parametru a priradí hodnota 1 a parametru b sa priradí hodnota 2. Tieto sa ďalej správajú ako lokálne premenné funkcie f. Možno ich teda meniť, ale táto zmena sa neprejaví na mieste, odkiaľ funkciu voláme. Tento mechanizmus nazývame odovzdávaním parametrov hodnotou.

Príklad:

#include <iostream>
using namespace std;

void f(int n) {
    n++;
    cout << n << endl;
}

int main() {
    int n = 1;
    f(n);               // Vypise 2
    cout << n << endl;  // Vypise 1
}

Odovzdávanie parametrov referenciou

V prípade, že pred názov niektorého parametra v hlavičke funkcie napíšeme &, parameter sa bude odovzdávať referenciou.

  • Za takýto parameter možno pri volaní funkcie dosadiť iba premennú (kým pri odovzdávaní hodnotou môžeme použiť napríklad aj konštanty alebo iný výraz).
  • Namiesto hodnoty sa funkcii pošle adresa premennej v pamäti (referencia).
  • Funkcia potom bude túto premennú používať pod novým názvom; jej prípadné zmeny sa prejavia aj na mieste, odkiaľ bola funkcia volaná.

Príklad:

#include <iostream>
using namespace std;

void f(int &n) {
    n++;
    cout << n << endl;
}

int main() {
    int n = 1;
    f(n);               // Vypise 2.
    cout << n << endl;  // Vypise 2.
}

Ďalej si ukážeme niekoľko použití odovzdávania parametrov referenciou.

Viac ako jedna návratová hodnota

Odovzdávanie parametra referenciou používame napríklad vtedy, keď potrebujeme vrátiť viac ako jednu výstupnú hodnotu.

Napríklad nasledujúca funkcia stred dostane súradnice dvoch bodov [x1,y1] a [x2,y2] a do parametrov [xm,ym], ktoré sú odovzdané referenciou, uloží súradnice stredu úsečky spájajúcej body [x1,y1] a [x2,y2].

#include <iostream>
using namespace std;

void stred(double x1, double y1, double x2, double y2, 
           double &xm, double &ym) {
    xm = (x1 + x2) / 2;
    ym = (y1 + y2) / 2;
}

int main() {
    double Ax, Ay, Bx, By;
    
    cout << "Zadaj suradnice bodu A: ";
    cin >> Ax >> Ay;
    cout << "Zadaj suradnice bodu B: ";
    cin >> Bx >> By;
    
    double Mx, My;
    stred(Ax, Ay, Bx, By, Mx, My);
    cout << "Stred usecky AB je [" 
         << Mx << ", " << My << "]." << endl; 
}

Funkcia swap

Typickým príkladom použitia odovzdávania parametrov referenciou je funkcia swap, ktorá vymení hodnoty dvoch premenných, ktoré dostane ako parametre.

#include <iostream>
using namespace std;

void swap(int &a, int &b) {
    int tmp = a;
    a = b;
    b = tmp;
}

int main() {
    int x, y;
    cin >> x >> y;
    
    swap(x, y);
    
    cout << "x = " << x << ", y = " << y << endl; 
}

Keby sme funkcii odovzdali parameter hodnotou – čiže by sme funkciu swap definovali s hlavičkou void swap(int a, int b); – vo funkcii main by sa premenné nevymenili.

Ošetrovanie chybných vstupov

Občas môže nastať potreba, aby parametre funkcie spĺňali určité podmienky. V takom prípade môže byť potrebné korektne sa vysporiadať aj so vstupmi, ktoré tieto podmienky nespĺňajú. Ukážeme si teraz zopár možných prístupov k tomuto problému.

Funkcia neošetrujúca chybné vstupy

Uvažujme napríklad nasledujúcu funkciu, ktorá počíta súčet všetkých deliteľov čísla n. Jej základným predpokladom je, že n je kladné celé číslo.

#include <iostream>
using namespace std;

int sumOfDivisors(int n) {
    int sum = 0;
    for (int i = 1; i <= n; i++) {
        if (n % i == 0) {
            sum += i;    
        }        
    }   
    return sum;  
}

int main() {
    int n;
    cout << "Zadaj kladne cele cislo: ";
    cin >> n;
        
    cout << "Sucet delitelov " << n << ": " 
         << sumOfDivisors(n) << "." << endl;
}

Ak však používateľ zadá na vstupe nejaké záporné číslo, funkcia vždy vráti nulu, čo nie je úplne v súlade s očakávaním. Jednou možnosťou by samozrejme bolo prerobiť funkciu tak, aby pracovala správne aj na záporných vstupoch. Sú však aj situácie, keď podobné riešenie nie je možné. Ukážeme si preto ďalšie spôsoby, ako sa s nekorektným vstupom vysporiadať.


Použitie funkcie assert

V prípade použitia nekorektného vstupu napríklad môžeme celý program ihneď ukončiť. Pohodlný spôsob, ako to spraviť, je použitie funkcie assert (treba #include <cassert>). Táto funkcia kontroluje platnosť nejakej podmienky. Ak je táto podmienka splnená, program normálne pokračuje; v opačnom prípade sa program zastaví s chybovou hláškou. Argumentom funkcie assert môže byť ľubovoľná booleovská hodnota.

#include <iostream>
#include <cassert>
using namespace std;

int sumOfDivisors(int n) {
    assert(n > 0);
    int sum = 0;
    for (int i = 1; i <= n; i++) {
        if (n % i == 0) {
            sum += i;    
        }        
    }   
    return sum;  
}

int main() {
    int n;
    cout << "Zadaj kladne cele cislo: ";
    cin >> n;
        
    cout << "Sucet delitelov " << n << ": " 
         << sumOfDivisors(n) << "." << endl;
}

Úspech výpočtu ako výstupná hodnota

Často je ale neprípustné v prípade jediného volania funkcie s nekorektnými vstupmi ukončiť celý program. Chceli by sme teda vrátiť dve hodnoty: samotný súčet deliteľov a indikátor, či boli argumenty zadané správne.

  • Napríklad súčet deliteľov môžeme ukladať do parametra odovzdávaného referenciou.
  • Výstupom funkcie bude booleovská hodnota, ktorá bude true práve vtedy, keď výpočet funkcie prebehol správne (t.j. keď boli zadané správne argumenty). Túto výstupnú hodnotu je potom možné použiť pri volaní funkcie ako známku toho, že hodnota v parametre predanom referenciou je zmysluplná.
#include <iostream>
using namespace std;

bool sumOfDivisors(int n, int &sum) {
    if (n <= 0) {
        return false;
    } else {
        sum = 0;
        for (int i = 1; i <= n; i++) {
            if (n % i == 0) {
                sum += i;    
            }        
        }   
        return true;
    }  
}

int main() {
    int n, sum;
    cout << "Zadaj kladne cele cislo: ";
    cin >> n;
    
    if (sumOfDivisors(n, sum)) { 
        cout << "Sucet delitelov " << n << ": " 
             << sum << "." << endl;
    } else {
        cout << "Zly vstup" << endl;
    }
}

Programy s viacerými funkciami

V programe možno volať iba funkcie, ktoré už predtým boli niekde definované.

Tento program typicky neskompiluje (vo vnútri f1 ešte nepozná f2)

void f1(void) {
    f2();
}

void f2(void) {
    cout << "Hello, World!" << endl;
}

Tento program je v poriadku:

void f2(void) {
    cout << "Hello, World!" << endl;
}

void f1(void) {
    f2();
}

Funkcia main

Špeciálnou funkciou je v C/C++ funkcia main.

  • V programe ju nemožno volať – jediné jej volanie je automatické (začína sa ním beh programu).
  • Výstupná hodnota funkcie main sa interpretuje ako „spôsob ukončenia” programu (napríklad 0 pre korektné ukončenie, iné hodnoty pre chyby).
    • Funkciu main môžeme teda ukončit napríklad príkazom return 0.
  • Funkcia main môže mať aj určité presne špecifikované parametre (viac o tom neskôr).

Funkcie: zhrnutie

  • Funkcie nám umožňujú rozbiť väčší program na menšie logické časti a tým ho sprehľadniť. Tiež nám umožňujú vyhnúť sa opakovaniu podobných kusov kódu.
  • Hlavička funkcie obsahuje návratový typ (môže byť void), meno funkcie, typy a mená parametrov.
  • V tele funkcie sú samotné príkazy. Vypočítanú hodnotu vrátime príkazom return.
  • Lokálne premenné sú viditeľné len vo funkcii, ktorá ich definuje, globálne vo všetkých funkciách.
  • Parametre odovzdávané hodnotou sú lokálne premenné inicializované hodnotami, ktoré sa zadajú pri volaní funkcie.
  • Parametre odovzdávané referenciou (&) sú len novým menom pre inú premennú.

Prednáška 4b

Oznamy

Program na dnes:

  • oznamy
  • teoretické cvičenie (krátky test)
  • vzorové riešenia testu
  • dokončenie minulej prednášky

Čo nás čaká v najbližších dňoch

  • zajtra hlavné cvičenia, rozcvička na funkcie (pozrite si pred cvičením)
  • v stredu prednáška o poliach
  • v piatok doplnkové cvičenia: povinné pre tých, čo v utorok na cvičení nezískajú aspoň 5 bodov

Dnešné teoretické cvičenie

  • krátky bodovaný testík, počíta sa do cvičení (spolu 4 body, cca 1% známky)
  • ciele: zopakovať si učivo, rozmýšľať nad hotovými programami bez kompilovania a spúšťania programu, tréning na semestrálny test
  • môžete používať pero a ťahák vo forme jedného obojstranne popísaného listu A4

Opakovanie

Funkcia je samostatný kus kódu (postupnosť príkazov) s určitým menom. Po zavolaní funkcie jej menom sa daná postupnosť príkazov vykoná.

  • Umožňujú vytvoriť „skratku” pre často používané časti kódu, ktoré tak nie je nutné zakaždým písať nanovo.

Definícia funkcie pozostáva z nasledujúcich častí:

  • Typ návratovej hodnoty funkcie (napríklad double, int, void)
  • Identifikátor funkcie (použite výstižný názov)
  • Zoznam vstupných parametrov funkcie a ich typov
  • Telo funkcie (príkazy v { })
  • Príkaz return vracia návratovú hodnotu funkcie a ukončí jej beh

Platnosť lokálnych premenných je obmedzená na funkciu (alebo blok) v ktorej boli zadefinované. Globálne premenné je možné používať vo všetkých funkciách, zvyčajne sa chceme ich použitiu vyhnúť.

Odovzdávanie parametrov

  • Hodnotou: parametre sa nakopírujú, ďalej fungujú ako lokálne premenná funkcie (napr. double x)
  • Referenciou: parameter je nové meno pre premennú z volajúcej funkcie (napr. double &x)

Odovzdávanie parametrov hodnotou

Vstupné parametre funkcií sa správajú ako lokálne premenné danej funkcie. Pri volaní funkcie sa každému parametru priradí určitá hodnota. Uvažujme napríklad funkciu

void f(int a, int b) {
    // ...
}

Pri volaní

f(1, 2);

sa parametru a priradí hodnota 1 a parametru b sa priradí hodnota 2. Tieto sa ďalej správajú ako lokálne premenné funkcie f. Možno ich teda meniť, ale táto zmena sa neprejaví na mieste, odkiaľ funkciu voláme. Tento mechanizmus nazývame odovzdávaním parametrov hodnotou.

Príklad:

#include <iostream>
using namespace std;

void f(int n) {
    n++;
    cout << n << endl;
}

int main() {
    int n = 1;
    f(n);               // Vypise 2
    cout << n << endl;  // Vypise 1
}

Odovzdávanie parametrov referenciou

V prípade, že pred názov niektorého parametra v hlavičke funkcie napíšeme &, parameter sa bude odovzdávať referenciou.

  • Za takýto parameter možno pri volaní funkcie dosadiť iba premennú (kým pri odovzdávaní hodnotou môžeme použiť napríklad aj konštanty alebo iný výraz).
  • Namiesto hodnoty sa funkcii pošle adresa premennej v pamäti (referencia).
  • Funkcia potom bude túto premennú používať pod novým názvom; jej prípadné zmeny sa prejavia aj na mieste, odkiaľ bola funkcia volaná.

Príklad:

#include <iostream>
using namespace std;

void f(int &n) {
    n++;
    cout << n << endl;
}

int main() {
    int n = 1;
    f(n);               // Vypise 2.
    cout << n << endl;  // Vypise 2.
}

Ďalej si ukážeme niekoľko použití odovzdávania parametrov referenciou.

Viac ako jedna návratová hodnota

Odovzdávanie parametra referenciou používame napríklad vtedy, keď potrebujeme vrátiť viac ako jednu výstupnú hodnotu.

Napríklad nasledujúca funkcia mid dostane súradnice dvoch bodov [x1,y1] a [x2,y2] a do parametrov [xm,ym], ktoré sú odovzdané referenciou, uloží súradnice stredu úsečky spájajúcej body [x1,y1] a [x2,y2].

#include <iostream>
using namespace std;

void mid(double x1, double y1, double x2, double y2, 
         double &xm, double &ym) {
    xm = (x1 + x2) / 2;
    ym = (y1 + y2) / 2;
}

int main() {
    double Ax, Ay, Bx, By;
    
    cout << "Zadaj suradnice bodu A: ";
    cin >> Ax >> Ay;
    cout << "Zadaj suradnice bodu B: ";
    cin >> Bx >> By;
    
    double Mx, My;
    mid(Ax, Ay, Bx, By, Mx, My);
    cout << "Stred usecky AB je [" 
         << Mx << ", " << My << "]." << endl; 
}

Funkcia swap

Typickým príkladom použitia odovzdávania parametrov referenciou je funkcia swap, ktorá vymení hodnoty dvoch premenných, ktoré dostane ako parametre.

#include <iostream>
using namespace std;

void swap(int &a, int &b) {
    int tmp = a;
    a = b;
    b = tmp;
}

int main() {
    int x, y;
    cin >> x >> y;
    
    swap(x, y);
    
    cout << "x = " << x << ", y = " << y << endl; 
}

Keby sme funkcii odovzdali parameter hodnotou – čiže by sme funkciu swap definovali s hlavičkou void swap(int a, int b); – vo funkcii main by sa premenné nevymenili.

Ošetrovanie chybných vstupov

Občas môže nastať potreba, aby parametre funkcie spĺňali určité podmienky. V takom prípade môže byť potrebné korektne sa vysporiadať aj so vstupmi, ktoré tieto podmienky nespĺňajú. Ukážeme si teraz zopár možných prístupov k tomuto problému.

Funkcia neošetrujúca chybné vstupy

Uvažujme napríklad nasledujúcu funkciu, ktorá počíta súčet všetkých deliteľov čísla n. Jej základným predpokladom je, že n je kladné celé číslo.

#include <iostream>
using namespace std;

int sumOfDivisors(int n) {
    int sum = 0;
    for (int i = 1; i <= n; i++) {
        if (n % i == 0) {
            sum += i;    
        }        
    }   
    return sum;  
}

int main() {
    int n;
    cout << "Zadaj kladne cele cislo: ";
    cin >> n;
        
    cout << "Sucet delitelov " << n << ": " 
         << sumOfDivisors(n) << "." << endl;
}

Ak však používateľ zadá na vstupe nejaké záporné číslo, funkcia vždy vráti nulu, čo nie je úplne v súlade s očakávaním. Jednou možnosťou by samozrejme bolo prerobiť funkciu tak, aby pracovala správne aj na záporných vstupoch. Sú však aj situácie, keď podobné riešenie nie je možné. Ukážeme si preto ďalšie spôsoby, ako sa s nekorektným vstupom vysporiadať.

Použitie funkcie assert

V prípade použitia nekorektného vstupu napríklad môžeme celý program ihneď ukončiť. Pohodlný spôsob, ako to spraviť, je použitie funkcie assert (treba #include <cassert>). Táto funkcia kontroluje platnosť nejakej podmienky. Ak je táto podmienka splnená, program normálne pokračuje; v opačnom prípade sa program zastaví s chybovou hláškou. Argumentom funkcie assert môže byť ľubovoľná booleovská hodnota.

#include <iostream>
#include <cassert>
using namespace std;

int sumOfDivisors(int n) {
    assert(n > 0);
    int sum = 0;
    for (int i = 1; i <= n; i++) {
        if (n % i == 0) {
            sum += i;    
        }        
    }   
    return sum;  
}

int main() {
    int n;
    cout << "Zadaj kladne cele cislo: ";
    cin >> n;
        
    cout << "Sucet delitelov " << n << ": " 
         << sumOfDivisors(n) << "." << endl;
}

Príklady dvoch behov programu:

Zadaj kladne cele cislo: 10
Sucet delitelov 10: 18.
Zadaj kladne cele cislo: -2
a.out: prog.cpp:6: int sumOfDivisors(int): Assertion `n > 0' failed.
Aborted (core dumped)

Úspech výpočtu ako výstupná hodnota

Často je ale neprípustné v prípade jediného volania funkcie s nekorektnými vstupmi ukončiť celý program. Chceli by sme teda vrátiť dve hodnoty: samotný súčet deliteľov a indikátor, či boli argumenty zadané správne.

  • Napríklad súčet deliteľov môžeme ukladať do parametra odovzdávaného referenciou.
  • Výstupom funkcie bude booleovská hodnota, ktorá bude true práve vtedy, keď výpočet funkcie prebehol správne (t.j. keď boli zadané správne argumenty). Túto výstupnú hodnotu je potom možné použiť pri volaní funkcie ako známku toho, že hodnota v parametre predanom referenciou je zmysluplná.
#include <iostream>
using namespace std;

bool sumOfDivisors(int n, int &sum) {
    if (n <= 0) {
        return false;
    } else {
        sum = 0;
        for (int i = 1; i <= n; i++) {
            if (n % i == 0) {
                sum += i;    
            }        
        }   
        return true;
    }  
}

int main() {
    int n, sum;
    cout << "Zadaj kladne cele cislo: ";
    cin >> n;
    
    if (sumOfDivisors(n, sum)) { 
        cout << "Sucet delitelov " << n << ": " 
             << sum << "." << endl;
    } else {
        cout << "Zly vstup" << endl;
    }
}

Programy s viacerými funkciami

V programe možno volať iba funkcie, ktoré už predtým boli niekde definované.

Tento program typicky neskompiluje (vo vnútri f1 ešte nepozná f2)

void f1() {
    f2();
}

void f2() {
    cout << "Hello, world!" << endl;
}

Tento program je v poriadku:

void f2() {
    cout << "Hello, world!" << endl;
}

void f1() {
    f2();
}

Funkcia main

Špeciálnou funkciou je v C/C++ funkcia main. Od ostatných funkcií sa odlišuje v nasledujúcom:

  • V programe ju nemožno volať – jediné jej volanie je automatické (začína sa ním beh programu).
  • Výstupná hodnota funkcie main sa interpretuje ako „spôsob ukončenia” programu (napríklad 0 pre korektné ukončenie, iné hodnoty pre chyby).
    • Funkciu main môžeme teda ukončit napríklad príkazom return 0.
  • Funkcia main môže mať aj určité presne špecifikované parametre (viac o tom neskôr).

Prednáška 5

Oznamy

  • Na piatkové cvičenia treba prísť, ak ste v utorok na cvičení nezískali aspoň 5 bodov.
    • Na testovači máte v záložke Body, položke Piatkove_cvicenie uvedené predbežné body z utorkového cvičenia a napísané, či je alebo nie je pre vás piatkové cvičenie povinné.
    • Ak je pre vás cvičenie povinné a neprídete, dostanete -1 bod.
    • Môžete prísť aj ak je pre vás cvičenie nepovinné, ale máte nejaké otázky.
  • Ak by ste chceli zmeniť skupinu na utorkové cvičenia, vyplňte formulár
  • Dnes polia, budúci pondelok hlavne ďalšie príklady a algoritmy s použitím tých častí Cčka, ktoré už poznáte

Hľadanie chýb v programe

  • Väčšina programov nefunguje na prvý krát, hľadanie chýb patrí medzi základné činnosti programátora.
  • Podobné chyby sa často opakujú, tréningom sa ich naučíte nájsť rýchlejšie.

Ak program ani neskompiluje

  • Kompilátor vypíše číslo riadku s chybou, čo vám ju môže pomôcť nájsť.
    • Občas je však chyba trochu inde, napr. niečo chýba o riadok vyššie.
  • Pokročilejšie prostredia vám vedia ukázať polohu chyby.
  • Ak kompilátor vypíše veľa chýb, opravte najskôr prvú, potom skompilujte znovu, ďalšie chyby môžu byť len dôsledkom prvej.
  • Ak neviete nájsť chybu pri kompilácii, skúste zakomentovať nejaké časti programu pomocou /* */, aby ste zúžili priestor, kde chyba môže byť.
  • Aj varovania kompilátora môžu poukazovať na chybu v programe.

Ak program dáva zlé výsledky, "cyklí sa" alebo "padá"

  • Môžete skúsiť program znovu prečítať, či nezbadáte chybu.
  • Alebo experimentami zistiť, kde sa jeho správanie prvýkrát začne odlišovať od toho, čo očakávate.
  • To sa dá robiť spúšťaním programu po krokoch v nástroji nazvanom debugger (nachádza sa napr. v Netbeans).
  • Alternatíva k debuggeru je do programu pridať pomocné výpisy, ktoré vám prezradia, ktorá časť programu sa práve vykonáva a aké sú hodnoty dôležitých premenných.
    • Po nájdení chyby treba tieto pomocné výpisy odstrániť. Pozor, aby ste tým nespravili ďalšiu chybu.
  • Debugger alebo výpisy vám pomôžu nájsť chybu iba vtedy, ak máte predstavu o tom, ako by program mal fungovať a hľadáte, kde sa od nej skutočné správanie líši.
  • Pomáha vyrobiť si čo najmenší vstup, kde je zlý výsledok.

Záznam typu struct

V príkladoch s obvodom trojuholníka a stredom úsečky sme funkciám posielali veľa parametrov (súradnice x a y)

void stred(double x1, double y1, double x2, double y2, double &xm, double &ym) {
    xm = (x1 + x2) / 2;
    ym = (y1 + y2) / 2;
}

Program bude krajší, ak si údaje o jednom bode spojíme do jedného záznamu

struct bod {
    double x, y;
};
  • Pomocou struct vytvoríme nový dátový typ bod, ktorý má zložky x a y
  • V jednom struct-e môžu byť aj položky rôznych typov, napr.
struct bod { 
   double x,y; 
   int id; 
   bool visible; 
};
  • Môžeme vytvárať premenné typu bod, napr. bod a, b;
  • K položkám bodu pristupujeme pomocou bodky, napr. a.x = 4.0;
  • Do funkcií body posielame radšej referenciou, aby sa zbytočne nekopírovalo veľa hodnôt

Nasledujúci program načíta súradnice troch bodov, spočíta obvod trojuholníka a stredy všetkých troch strán.

#include <iostream>
#include <cmath>
using namespace std;

struct bod {
    double x, y;  // suradnice bodu v rovine
};

double dlzka(bod &bod1, bod &bod2) {
    // funkcia vrati dlzku usecky z bodu 1 do bodu 2
    double dx = bod1.x - bod2.x;
    double dy = bod1.y - bod2.y;
    return sqrt(dx * dx + dy * dy);
}

void stred(bod &bod1, bod &bod2, bod &stred) {
    // funkcia do bodu stred spocita stred usecky z bodu 1 do bodu 2
    stred.x = (bod1.x + bod2.x) / 2;
    stred.y = (bod1.y + bod2.y) / 2;
}

void vypisBod(bod &b) {
    // funkcia vypise suradnice bodu v zatvorke a koniec riadku
    cout << "(" << b.x << "," << b.y << ")" << endl;
}

int main() {
    // nacitame suradnice vrcholov trojuholnika
    bod A, B, C;
    cout << "Zadaj suradnice vrcholu A oddelene medzerou: ";
    cin >> A.x >> A.y;
    cout << "Zadaj suradnice vrcholu B oddelene medzerou: ";
    cin >> B.x >> B.y;
    cout << "Zadaj suradnice vrcholu C oddelene medzerou: ";
    cin >> C.x >> C.y;
    // spocitame dlzky stran
    double da = dlzka(B, C);
    double db = dlzka(A, C);
    double dc = dlzka(A, B);
    // vypiseme obvod
    cout << "Obvod trojuholnika ABC: " << da + db + dc << endl;

    // spocitame stredy stran
    bod stredAB;
    stred(A, B, stredAB);
    bod stredAC;
    stred(A, C, stredAC);
    bod stredBC;
    stred(B, C, stredBC);

    // vypiseme stredy stran
    cout << "Stred strany AB: ";
    vypisBod(stredAB);
    cout << "Stred strany AC: ";
    vypisBod(stredAC);
    cout << "Stred strany BC: ";
    vypisBod(stredBC);
}

Príklad behu programu:

Zadaj suradnice vrcholu A oddelene medzerou: 0 0
Zadaj suradnice vrcholu B oddelene medzerou: 0 3
Zadaj suradnice vrcholu C oddelene medzerou: 4 0
Obvod trojuholnika ABC: 12
Stred strany AB: (0,1.5)
Stred strany AC: (2,0)
Stred strany BC: (2,1.5)

Spracovanie väčšieho množstva dát

Naše programy doteraz spracovávali len malý počet vstupných dát načítaných od užívateľa (napr. súradnice troch bodov). Často však chceme pracovať s väčším množstvom dát

  • Dnes si ukážeme, ako uložiť väčšie množstvo dát do poľa
  • Na niektoré úlohy však pole nepotrebujeme - údaje môžeme spracovávať rovno ako ich užívateľ zadáva (takéto príklady sme už videli na cvičeniach)

V nasledujúcich príkladoch užívateľ zadá číslo N a potom N celých čísel

  • Predstavme si napríklad, že učiteľ zadá body, ktoré študenti dostali na písomke (napr. celé čísla v rozsahu 0..10)
  • Z týchto bodov chceme spočítať nejaké štatistiky

Priemer

#include <iostream>
using namespace std;

int main() {
    int N;
    cout << "Zadaj pocet cisel: ";
    cin >> N;

    int sucet = 0;
    cout << "Zadavaj cisla: ";
    for (int i = 0; i < N; i++) {
        int x;
        cin >> x;
        sucet += x;
    }

    double priemer = sucet / (double) N;
    cout << "Priemer je " << priemer << "." << endl;
}
  • Čo by sa stalo, keby sme vo výpočte priemeru vynechali (double)?

Maximum

#include <iostream>
using namespace std;

int main() {
    int max, x, N;

    cout << "Zadaj pocet cisel: ";
    cin >> N;

    cout << "Zadavajte cisla: ";

    max = ?
    for (int i = 0; i < N; i++) {
        cin >> x;
        if (x > max) {
            max = x;
        }
    }

    cout << endl << "Maximum je " << max << endl;
}

Ako ale začať? Ako nastaviť maximum na začiatok?

  • Jedna možnosť je nastaviť ho na nejakú veľmi malú hodnotu, aby sa iste neskôr zmenila. Ale čo ak používateľ dá všetky čísla ešte menšie?
  • Riešením je použiť najmenšie možné číslo. Ale to je príliš viazané na konkrétny rozsah, nebude fungovať po zmene typu premenných.
  • Ďalšia možnosť je si pamätať, že ešte nemáme správne nastavené maximum a po načítaní prvého čísla ho nastaviť alebo spracovať prvé číslo zvlášť (mimo cyklu).
#include <iostream>
using namespace std;

int main() {
    int max, x, N;

    cout << "Zadaj pocet cisel: ";
    cin >> N;

    cout << "Zadavajte cisla: ";
    cin >> x;   // načítanie prvého čísla
    max = x;

    for (int i = 1; i < N; i++) {  // cyklus cez N-1 ďalších čísel 
        cin >> x;
        if (x > max) {
            max = x;
        }
    }

    cout << endl << "Maximum je " << max << endl;
}

Cvičenie:

  • Ako by sme program rozšírili tak, aby vedel vypísať aj koľké číslo v poradí bolo najväčšie?
  • Čo treba v programe zmeniť, ak chceme hľadať minimum namiesto maxima?

Prvé použitie poľa: podpriemer / nadpriemer

Chceme spočítať priemer a o každom vstupnom čísle vypísať, či je nadpriemerné alebo podpriemerné.

  • Priemer vieme až keď načítame všetky čísla, musíme si ich teda zapamätať
  • Na to používame tabuľky, ktoré sa volajú polia.
  • Na začiatok pre jednoduchosť predpokladajme, že vopred vieme, že počet údajov N je napr. 20 (N teda nenačítavame)
  • Príkaz int a[20]; vytvorí pole s 20 premennými typu int, ku ktorým pristupujeme a[0], a[1], ..., a[19]
    • pozor, a[20] neexistuje
#include <iostream>
using namespace std;

int main() {
    const int N = 20; // premennu N uz nebude mozne menit, ma konstantnu hodnotu 20
    int a[N];
    double sucet = 0;

    cout << "Zadavajte " << N << " cisel: ";
    for (int i = 0; i < N; i++) {
        cin >> a[i];
        sucet += a[i];
    }

    double priemer = sucet / N;
    cout << "Priemer je " << priemer << "." << endl;
    for (int i = 0; i < N; i++) {
        if (a[i] > priemer) { 
             cout << a[i] << ": vacsie ako priemer." << endl;
        } else if (a[i] < priemer) {
             cout << a[i] << ": mensie ako priemer." << endl;
        } else {
             cout << a[i] << ": priemer." << endl;
        }
    }
}

Na zamyslenie:

  • Pozor, chyby pri zaokrúhľovaní môžu spôsobiť, že niekedy priemerné číslo bude považované za nad/podpriemerné
    • Vedeli by ste program prerobiť tak, aby používal iba premenné typu int a nerobil žiadnu chybu v zaokrúhľovaní?
    • Môže aj po takejto zmene niekedy dať zlú odpoveď?

Polia

Rozsah poľa je konštantný výraz väčší ako 0. Prvky sa indexujú od 0 po počet - 1

int a[10];
const int N=20;
double b[N];

Ak nepoznáme vopred počet prvkov, ktoré chceme dať do poľa, môžeme odhadnúť, že ich nebude viac ako NMax, ktoré definujeme ako konštantu v programe.

  • Vytvoríme pole veľkosti NMax, použijeme z neho len prých N hodnôt
#include <iostream>
using namespace std;

int main() {
    const int NMax = 1000;
    int p[NMax];
    int N;
    cout << "Zadaj pocet cisel: ";
    cin >> N;
    if (N > NMax) {
        cout << "Prilis velke N" << endl;
        return 1;  // ukončíme funkciu main a tým aj program
    }
   cout << "Zadavajte " << N << " cisel: ";
   ...


Čo ak ako veľkosť poľa použijeme premennú N, ktorú si prečítame od používateľa alebo inak spočítame za behu?

    int N;
    cout << "Zadaj pocet cisel: ";
    cin >> N;
    int p[N];
  • V starších verziách C resp. C++ to nefunguje, aj keď niektoré novšie kompilátory to zvládajú
  • Na prednáškach tento spôsob nebudeme používať
  • Pole veľkosti N, ktorá nie je konštanta, sa naučíme vytvárať inak v druhej polovici semestra

Vytvorenie a inicializácia poľa

V definícii môžeme pole inicializovať zoznamom prvkov.

// spravne: inicializacia zoznamom
int A[4] = {3, 6, 8, 10}; 
// spravne: definicia bez inicializacie
int B[4]; 
// nespravne, pole tu nedefinujeme:
// B[4] = {3, 6, 8, 10};  
// spravne - menime prvky existujuceho pola:
B[0] = 3; B[1] = 6; B[2] = 8; B[3] = 10;

Indexovanie hodnotou mimo intervalu

Pozor, kompilátor nekontroluje indexy prvkov

  int a[10];
  a[10] = 1234;
  • Skompiluje, ale hodnota 1234 sa zapíše do pamäti na zlé miesto
  • Môže to mať nepredvídateľné následky: prepísanie obsahu iných premenných (chybný výpočet alebo „nevysvetliteľné“ správanie sa programu) alebo dokonca prepísanie časti kódu vášho programu

Kopírovanie a testovanie rovnosti

V prípade, že chceme vytvoriť pole, ktoré je kópiou už existujúceho poľa, ponúka sa možnosť príkazu priradenia b=a;. Takýto príkaz však neskompiluje – nedá sa takto priraďovať, treba kopírovať prvok po prvku.

for (int i=0; i < 10; i++) {
   b[i] = a[i];
}

Polia sa tiež nedajú porovnávať pomocou operátora ==. Podmienku if (a==b) { cout << "rovnake"; }. síce skompilujete, ale nikdy to nebude pravda – neporovná sa obsah poľa, ale niečo úplne iné (adresy polí v pamäti). Treba opäť porovnávať prvok po prvku.

bool rovne = true;
for (int i = 0; i < 10; i++) { 
    rovne = rovne && a[i] == b[i]; 
}
if (rovne) {
    cout << "Rovnaju sa" << endl; 
}

Ten istý kúsok programu môžeme napísať napr. aj takto:

bool rovne = true;
for (int i = 0; i < 10; i++) { 
    if(a[i] != b[i]) {
        rovne = false;
        break;
    }
}
if (rovne) {
    cout << "Rovnaju sa" << endl; 
}

Výskyty čísel 0...9

Na vstupe je číslo N a N celých čísel od 0 do 9 a chceme vedieť, koľkokrát sa jednotlivé čísla na vstupe vyskytli.

Prvý prístup:

  • vstupné čísla uložíme do poľa
  • pre každú hodnotu i od 0 po 9 prejdeme pole a spočítame počet výskytov i

Druhý prístup:

  • samotné vstupné čísla neukladáme do poľa, spracovávame ich po jednom
  • vytvoríme si pole počítadiel dĺžky 10, v ktorom p[i] bude počet výskytov čísla i
#include <iostream>
using namespace std;

int main() {
    int p[10];  // pole dlzky 10
    int N;
    cout << "Zadajte pocet cisel: ";
    cin >> N;

    for (int i = 0; i < 10; i++) {
       p[i] = 0; // inicializácia poľa p[0]=0; p[1]=0; ... p[9]=0;
    }

    cout << "Zadavajte " << N << "cisel z intervalu 0-9: ";
    for (int i = 0; i < N; i++) {
        int x;
        cin >> x;
        if (x >= 0 && x < 10) { // test, či je číslo z požadovaného rozsahu
            p[x]++;             // zvýšime počítadlo pre hodnotu x
        }
    }

    cout << endl;
    for (int i = 0; i < 10; i++) {
        cout << "Pocet vyskytov " << i << " je " << p[i] << endl; // výpis
    }
}

Odbočka: grafická knižnica SVGdraw

Výsledný obrázok
  • Aplikácie s grafickým rozhraním budeme programovať až budúci semester
  • V tomto semestri, ale budeme kresliť obrázky v SVG formáte pomocou jednoduchej knižnice #SVGdraw
  • Knižnicu si stiahnite a nainštalujte podľa návodu
  • Na začiatku programu zapnite knižnicu pomocou #include "SVGdraw.h"
  • Tu je malý ukážkový program, ktorý vykreslí zelený štvorec a v ňom červený kruh:
#include "SVGdraw.h"

int main() {
  /* Vytvor obrázok s šírkou 150 a výškou 100 a 
   * ulož ho do súboru stvorec.svg*/
  SVGdraw drawing(150, 100, "stvorec.svg");

  /* Nastav farbu vyfarbovania na zelenú. */
  drawing.setFillColor("green");
  /* Vykresli štvorec s ľavým horným 
   * rohom v bode (20,10)
   * a s dĺžkou strany 80, 
   * t.j. pravým dolným rohom 100,90 */
  drawing.drawRectangle(20,10,80,80);
  
  /* Nastav farbu vyfarbovania na červenú */
  drawing.setFillColor("red");
  /* Vykresli kruh so stredom 
   * v bode (60,50) a polomerom 40. */
  drawing.drawEllipse(60,50,40,40);

  /* Ukonči vypisovanie obrázka. */
  drawing.finish();
}

Kreslíme kruhy

P5-kruhy.png

Nasledujúci program náhodne vygeneruje súradnice niekoľkých kruhov a vykreslí ich pomocou knižnice SVGdraw.

  • Údaje o jednom kruhu si uloží do záznamu struct kruh, v programe používame pole takýchto kruhov.
  • Navyše o každom kruhu zistí, či sa pretína s iným kruhom, a ak áno, pri vykresľovaní ho orámuje červenou farbou.
#include "SVGdraw.h"
#include <cstdlib>
#include <ctime>
#include <cmath>

struct kruh {
    int x, y; /* suradnice stredu */
    int polomer; /* polomer kruhu */
    bool pretinaSa; /* pretína sa s iným? */
};

void generujKruh(kruh &k, int velkost, int polomer) {
    /* inicializuj kruh s nahodnou polohou 
     * a danym polomerom */
    k.x = rand() % (velkost - 2 * polomer) 
          + polomer;
    k.y = rand() % (velkost - 2 * polomer) 
          + polomer;
    k.polomer = polomer;
}

bool pretinajuSa(kruh &k1, kruh &k2) {
    /* zisti, ci sa dva kruhy pretinaju */
    int dx = k1.x - k2.x;
    int dy = k1.y - k2.y;
    double d2 = sqrt(dx * dx + dy * dy);
    return d2 <= k1.polomer + k2.polomer;
}

int main() {
    const int pocet = 10; /* počet kruhov */
    const int velkost = 300; /* veľkosť obrázku */
    const int polomer = 15; /* polomer kruhu */

    /* inicializácia generátora 
     * pseudonáhodných čísel */
    srand(time(NULL));

    /* inicializácia obrázku */
    SVGdraw drawing(velkost, velkost, "kruhy.svg");

    /* pole kruhov */
    kruh kruhy[pocet];

    for (int i = 0; i < pocet; i++) {
        /* kazdemu kruhu vygeneruj nahodnu polohu */
        generujKruh(kruhy[i], velkost, polomer);
    }

    /* zisti, ktoré kruhy pretínajú iné kruhy */
    for (int i = 0; i < pocet; i++) {
        kruhy[i].pretinaSa = false;
        for (int j = 0; j < pocet; j++) {
            if (i != j && pretinajuSa(kruhy[i],
                                      kruhy[j])) {
                kruhy[i].pretinaSa = true;
            }
        }
    }

    /* vykresluj kruhy */
    drawing.setFillColor("gray");
    for (int i = 0; i < pocet; i++) {
        if (kruhy[i].pretinaSa) {
            drawing.setLineColor("red");
        } else {
            drawing.setLineColor("black");
        }
        drawing.drawEllipse(kruhy[i].x, 
                            kruhy[i].y,
                            kruhy[i].polomer,
                            kruhy[i].polomer);
    }

    /* ukoncenie vykreslovania */
    drawing.finish();
}

Polia: zhrnutie

  • Pole je tabuľka hodnôt. V poli dĺžky n máme hodnoty p[0], p[1], ..., p[n-1]
  • Kopírovanie a porovnávanie polí si musíme naprogramovať
  • C resp. C++ nekontrolujú, či index nie je mimo rozsahu poľa

Ďalšie príklady na prácu s poľom

  • Načítajte pole čísel a vypíšte ho v opačnom poradí.
  • Skúste poradie povymieňať priamo v poli a nie iba pri výpise.
  • Načítajte pole čísel, náhodne ich premiešajte a zase vypíšte.

Prednáška 6

Oznamy

Prebrali sme premenné, polia, podmienky, cykly a funkcie.

  • Z týchto stavebných prvkov sa dajú vystavať pomerne komplikované programy.
  • Menej skúsení programátori si potrebujú prácu s týmito pojmami čo najviac precvičiť. Skúste vyriešiť všetky príklady z cvičení, pracujte na domácej úlohe.
    • Neodpisujte, nič sa tým nenaučíte.
    • Kontaktujte nás, ak potrebujete pomôcť.
    • Účasť na piatkových cvičeniach nie je "za trest", ale spôsob, ako vám môžeme pomôcť zvládnuť predmet.

Čo nás čaká v najbližšom čase

  • DÚ1 je zverejnená, riešte do utorka 29.10. 22:00. Každá DÚ má váhu 5% známky, teda približne ako 2 týždne cvičení.
  • Dnes algoritmy s poliami.
  • Rozcvička zajtra sa tiež bude týkať polí.
  • Účasť v piatok povinná pre tých, ktorí v utorok nezískajú aspoň 4 body.

Parametre funkcií: prehľad, opakovanie

Jednoduché typy, napr. int, double, bool

  • Bez & sa skopíruje hodnota
  • S & premenná dostane vo funkcii nové meno
void f1(int x) {
    x++; // zmena x sa neprenesie do main (y zostane rovnaká)
    cout << x << endl;
}

void f2(int &x) {
    x++; // zmena x sa prenesie ako zmena y v main
    cout << x << endl;
}

int main() {
    int y = 0;
    f1(y);
    f2(y);
    f1(y + 1);
    //zle: f2(y+1);
}

Polia odovzdávame bez &

  • väčšinou potrebujeme poslať aj veľkosť poľa, ak nie je globálne známa
  • zmeny v poli zostanú aj po skončení funkcie
void f(int a[], int n) {
    for (int i = 0; i < n; i++) {
        cout << a[i] << endl;
    }
}

int main() {
    int b[3] = {1, 2, 3};
    f(b, 3);
}

SVGdraw obrázky sú v skutočnosti objekty, väčšinou ich chcete posielať s &

  • všetky zmeny na nich spravené pretrvávajú aj po skončení funkcie
#include "SVGdraw.h"
void kresli(SVGdraw &drawing, int n, int y, 
            int rozostup, int velkost) {
   for(int i=0; i<n; i++) {
       drawing.drawRectangle(i*rozostup+velkost, y, 
                             velkost, velkost);
   }
}
int main() {
    SVGdraw drawing(320, 100, "stvorce.svg");
    kresli(drawing, 10, 50, 30, 10);
    drawing.finish();
}

Štruktúry (struct) väčšinou tiež posielame pomocou &


Návratové hodnoty:

  • ak je výsledkom funkcie jedno číslo alebo pravdivostná hodnota, vrátime ju príkazom return
  • ak je výsledkom viac hodnôt, alebo niečo zložitejšie (pole, struct,...), odovzdáme ho ako parameter pomocou &, návratová hodnota môže zostať void (pri poli & nedávame)

Tieto pravidlá súvisia so smerníkmi a správou pamäti, povieme si viac o pár týždňov


Funkcie a polia: načítanie a vypísanie poľa

Dve základné funkcie, ktoré sa nám môžu zísť v programoch

  • precvičíme si tiež ako sa pracuje s poliami vo funkciách
#include <cassert>
#include <iostream>
using namespace std;

void readArray(int a[], int &n, int maxN) {
    /* Od užívateľa načíta počet vstupných čísel,
     * tento počet uloží do premennej n. 
     * Potom načíta n celých čísel a uloží ich do poľa a,
     * Hodnota maxN je veľkosť poľa,
     * ktorú nemožno prekročiť. */

    cin >> n;
    assert(n <= maxN);

    for (int i = 0; i < n; i++) {
        cin >> a[i];
    }
}

void printArray(int a[], int n) {
   for (int i = 0; i < n; i++) {
       cout << " " << a[i];
   }
   cout << endl;
}

int main() {
    const int maxN = 100;
    int n;
    int a[maxN];

    readArray(a, n, maxN);

    // tu môžeme pole nejako upraviť

    printArray(a, n);
}

Triedenia

Máme pole čísel, chceme ich usporiadať od najmenšieho po najväčšie.

  • Napr. pre pole 9 3 7 4 5 2 chceme dostať 2 3 4 5 7 9
  • Jeden z najštudovanejších problémov v informatike.
  • Súčasť mnohých zložitejších algoritmov.
  • Veľa spôsobov, ako triediť, dnes si ukážeme zopár najjednoduchších.

Bublinkové triedenie (Bubble Sort)

Idea: Kontrolujeme všetky dvojice susedných prvkov a keď vidíme menšie číslo za väčším, vymeníme ich

for (int i = 1; i < n; i++) {
     if (a[i] < a[i - 1]) {
         swap(a[i - 1], a[i]);
     }
}
  • Ak sme nenašli v poli žiadnu dvojicu, ktorú treba vymeniť, skončili sme.
  • Inak celý proces opakujeme znova.

Celé triedenie:

void swap(int &x, int &y) {
    /* Vymeň hodnoty premenných x a y. */
    int tmp = x;
    x = y;
    y = tmp;
}

void sort(int a[], int n) {
    /* usporiadaj prvky v poli a od najmenšieho po najväčší */

    bool hotovo = false;
    while (!hotovo) {
        bool vymenil = false;
        /* porovnávaj všetky dvojice susedov, 
           vymeň ak menší za väčším */
        for (int i = 1; i < n; i++) {
            if (a[i] < a[i - 1]) {
                swap(a[i - 1], a[i]);
                vymenil = true;
            }
        }
        /* ak sme žiadnu dvojicu nevymenili, 
           môžeme skončiť. */
        if (!vymenil) {
            hotovo = true;
        }
    }
}
  • Čo ak vo for cykle dáme for (int i = 0; i < n; i++) { ?
  • Ako nahradíme premennú hotovo príkazom break?

Príklad behu:

prvá iterácia while cyklu
 9 3 7 4 5 2
 3 9 7 4 5 2
 3 7 9 4 5 2
 3 7 4 9 5 2
 3 7 4 5 9 2
 3 7 4 5 2 9

druhá iterácia while cyklu
 3 7 4 5 2 9
 3 4 7 5 2 9
 3 4 5 7 2 9
 3 4 5 2 7 9

tretia iterácia while cyklu
 3 4 5 2 7 9
 3 4 2 5 7 9

štvrtá iterácia while cyklu
 3 4 2 5 7 9
 3 2 4 5 7 9

piata iterácia while cyklu
 3 2 4 5 7 9
 2 3 4 5 7 9

Cvičenie: Ako sa bude správať algoritmus na nasledujúcich vstupoch, koľkokrát zopakuje vonkajší while cyklus?

  • Utriedené pole 1,2,...,n
  • Pole n,1,2,...,n-1
  • Pole 2,3,...,n,1
  • Pole n,n-1,...,1

Triedenie výberom (selection sort, max sort)

Idea: nájdime najväčší prvok a uložme ho na koniec. Potom nájdime najväčší medzi zvyšnými a uložme ho na druhé miesto odzadu atď.

  • V cykle uvádzame ako komentár invariant, podmienku, ktorá na tom mieste vždy platí. Takýto invariant nám pomôže si uvedomiť, že je náš program správny.
int maxIndex(int a[], int n) {
    /* vráť index, na ktorom je najväčší prvok 
       z prvkov a[0]...a[n-1] */
    int index = 0;
    for(int i=1; i<n; i++) {
        if(a[i]>a[index]) {
            index = i;
        }
        /* invariant: a[j]<=a[index] pre vsetky j=0,...,i*/
    }
    return index;
}

void sort(int a[], int n) {
    /* usporiadaj prvky v poli a od najmenšieho po najväčší */

    for(int kam = n - 1; kam >= 1; kam--) {
        /* invariant: a[kam+1]...a[n-1] sú utriedené
         * a pre každé i,j také že 0<=i<=kam, kam<j<n 
         * platí a[i]<=a[j] */
        int index = maxIndex(a, kam + 1);
        swap(a[index], a[kam]);
    }
}

Príklad behu programu

Vstup           9 3 7 4 5 2 
Po výmene (9,2) 2 3 7 4 5 9
Po výmene (7,5) 2 3 5 4 7 9
Po výmene (5,4) 2 3 4 5 7 9
Po výmene (4,4) 2 3 4 5 7 9
Po výmene (3,3) 2 3 4 5 7 9

Cvičenie: Bude čas behu algoritmu výrazne odlišný pre pole utriedené správne a pole utriedené v opačnom poradí?

Triedenie vkladaním (Insertion Sort)

Idea:

  • v prvej časti poľa prvky v utriedenom poradí
  • zober prvý prvok z druhej časti a vlož ho na správne miesto v utriedenom poradí

Príklad behu algoritmu:

 9 3 7 4 5 2
 3 9 7 4 5 2
 3 7 9 4 5 2
 3 4 7 9 5 2
 3 4 5 7 9 2
 2 3 4 5 7 9

Ako spraviť vkladanie:

  • Vkladaný prvok si zapamätáme v pomocnej premennej
  • Utriedené prvky posúvame o jedno doprava, kým nenájdeme správne miesto pre odložený prvok
void sort(int a[], int n) {
    /* usporiadaj prvky v poli a od najmenšieho po najväčší */

    for (int i = 1; i < n; i++) {
        int prvok = a[i];
        int kam = i;
        while (kam > 0 && a[kam - 1] > prvok) {
            a[kam] = a[kam - 1];
            kam--;
        }
        a[kam] = prvok;
    }
}
  • Všimnime si podmienku (kam > 0 && a[kam - 1] > prvok)
    • Ak kam==0, prvá časť je false, druhá časť sa už nevyhodnocuje
    • Ak by sme prehodili časti, program by mohol spadnúť (a[kam - 1] > prvok && kam > 0)

Cvičenie: Ako sa bude správať algoritmus na nasledujúcich vstupoch, koľkokrát zopakuje priradenie a[kam]=a[kam-1]?

  • Utriedené pole 1,2,...,n
  • Pole n,1,2,...,n-1
  • Pole 2,3,...,n,1
  • Pole n,n-1,...,1

Triedenia: zhrnutie

  • Videli sme tri jednoduché algoritmy na triedenie
  • Neskôr sa naučíme rýchlejšie algoritmy na triedenie, ktoré používajú rekurziu
  • Precvičili sme si funkcie, parametre a polia
  • K funkciám je dobré napísať, čo robia
  • Do cyklov si môžeme písať invarianty
    • Používajú sa pri formálnom dokazovaní správnosti
    • Pomáhajú pochopeniu kódu
    • Môžeme ich použiť na ručnú alebo automatickú kontrolu správnosti hodnôt premenných
    • Príkaz assert v knižnici cassert kontroluje podmienku, napr. assert(i>=0 && i<n); ukončí program s chybovou hláškou ak podmienka neplatí

Cvičenie:

  • Na vstupe sú dve n-prvkové množiny (čísla v množine sa neopakujú, ale môžu byť zadané v ľubovoľnom poradí)
  • Zistite, či tieto množiny obsahujú rovnaké prvky
    • Viete pri tom použiť triedenie?

Eratostenovo sito

  • Prvočíslo je prirodzené číslo väčšie ako 1, ktoré je deliteľné len samo sebou a číslom 1.
  • Chceli by sme nájsť všetky prvočísla medzi 2 a n.
  • Napríklad ak n=30, výsledok má byť 2, 3, 5, 7, 11, 13, 17, 19, 23, 29.

Mohli by sme ísť cez všetky čísla a pre každé testovať, koľko má deliteľov (deliteľov sme už hľadali predtým), ale vieme to spraviť aj rýchlejšie. Použijeme algoritmus zvaný Eratostenovo sito.

  • Vytvoríme pole a pravdivostných hodnôt, kde a[i] nám hovorí, či je i ešte potenciálne prvočíslo.
  • Na začiatku budú všetky hodnoty true, lebo sme ešte žiadne číslo nevylúčili.
  • Začneme číslom 2. Toto je iste prvočíslo, tak ho vypíšeme. O jeho násobkoch však vieme, že iste nemôžu byť prvočísla. Nastavíme preto pre každý násobok j=2*k pravdivostnú hodnotu a[j] na false.
  • Potom prechádzame v poli, kým nenájdeme najbližšiu ďalšiu hodnotu true. Toto číslo je prvočíslo, teda ho vypíšeme a vyškrtáme jeho násobky.
#include <iostream>
using namespace std;

int main() {
    const int n = 30;
    bool a[n + 1];

    for (int i = 2; i <= n; i++) {
        a[i] = true;
    }
    for (int i = 2; i <= n; i++) {
        if (a[i]) {
            cout << i << " ";
            for (int j = 2 * i; j <= n; j = j + i) {
                a[j] = false;
            }
        }
    }
    cout << endl;
}

Výstup programu

2 3 5 7 11 13 17 19 23 29

Priebeh programu:

0  1  2  3  4  5  6  7  8  9 10 11 12 ...
?  ?  T  T  T  T  T  T  T  T  T  T  T ... na zaciatku
?  ?  T  T  F  T  F  T  F  T  F  T  F ... po vyskrtani i=2
?  ?  T  T  F  T  F  T  F  F  F  T  F ... po vyskrtani i=3
?  ?  T  T  F  T  F  T  F  F  F  T  F ... dalej sa uz skrtaju len vacsie cisla

Cvičenie: Napíšte funkciu, ktorá uloží prvočísla medzi 2 a n do poľa (ak by sme ich chceli použiť na ďalšie výpočty).


Zdrojový kód programu s triedeniami

/* Program s triedeniami z prednášky 6.
   http://compbio.fmph.uniba.sk/vyuka/prog/index.php/Predn%C3%A1%C5%A1ka_6 */
#include <iostream>
#include <cassert>
using namespace std;

void swap(int &x, int &y) {
    /* Vymeň hodnoty premenných x a y. */
    int tmp = x;
    x = y;
    y = tmp;
}

void readArray(int a[], int &n, int maxN) {
    /* Od užívateľa načíta počet vstupných čísel,
     * tento počet uloží do premennej n. 
     * Potom načíta n celých čísel a uloží ich do poľa a,
     * Hodnota maxN je veľkosť poľa,
     * ktorú nemožno prekročiť. */

    cin >> n;
    assert(n <= maxN);
    for (int i = 0; i < n; i++) {
        cin >> a[i];
    }
}

void printArray(int a[], int n) {
   for (int i = 0; i < n; i++) {
       cout << " " << a[i];
   }
   cout << endl;
}

void bubbleSort(int a[], int n) {
    /* usporiadaj prvky v poli a od najmenšieho po najväčší */

    bool hotovo = false;
    while (!hotovo) {
        bool vymenil = false;
        /* porovnávaj všetky dvojice susedov, vymeň ak menší za väčším */
        for (int i = 1; i < n; i++) {
            if (a[i] < a[i - 1]) {
                swap(a[i - 1], a[i]);
                vymenil = true;
            }
        }
        /* ak sme žiadnu dvojicu nevymenili, môžeme skončiť. */
        if (!vymenil) {
            hotovo = true;
        }
    }
}
int maxIndex(int a[], int n) {
    /* vráť index, na ktorom je najväčší prvok z prvkov a[0]...a[n-1] */
    int index = 0;
    for(int i=1; i<n; i++) {
        if(a[i]>a[index]) {
            index = i;
        }
        /* invariant: a[j]<=a[index] pre všetky j=0,...,i*/
    }
    return index;
}

void selectionSort(int a[], int n) {
    /* usporiadaj prvky v poli a od najmenšieho po najväčší */

    for(int kam=n-1; kam>=1; kam--) {
        /* invariant: a[kam+1]...a[n-1] sú utriedené
         * a pre každé i,j také že 0<=i<=kam, kam<j<n platí a[i]<=a[j] */
        int index = maxIndex(a, kam+1);
        swap(a[index], a[kam]);
    }
}

void insertionSort(int a[], int n) {
    /* usporiadaj prvky v poli a od najmenšieho po najväčší */

    for (int i = 1; i < n; i++) {
        // inv1
        int prvok = a[i];
        int kam = i;
        while (kam > 0 && a[kam - 1] > prvok) {
            // inv2
            a[kam] = a[kam - 1];
            kam--;
        }
        a[kam] = prvok;
    }
}

int main() {
    const int maxN = 100;
    int n;
    int a[maxN];

    readArray(a, n, maxN);

    printArray(a, n);
    insertionSort(a, n);
    printArray(a,n);
}

Prednáška 7

Oznamy

  • Začiatok semestra je pre začiatočníkov ťažký, ale programovať sa naučíte len riešením čo najväčšieho počtu príkladov.
  • Bez dobrej znalosti cyklov, podmienok, premenných, polí a funkcií nebudete rozumieť ďalšiemu učivu a nespravíte skúšku.
  • Snažte sa každý týždeň vyriešiť čo najviac príkladov.
  • Pred cvičením si pozrite poznámky z prednášok.
  • Dobre si prečítajte zadanie, vrátane príkladu vstupu a výstupu.
    • Cieľom príkladu vstupu je aj lepšie ilustrovať zadanie. Skúste si skontrolovať, ako sa asi výstup vypočítal zo vstupu.
  • Najskôr si vymyslite postup, ako idete úlohu riešiť. Vytiahnite si papier na poznámky a náčrtky.

Ďalšie prednášky a cvičenia

  • Ak ste v utorok na cvičení získali menej ako 4 body, piatkové cvičenia sú pre vás povinné.
  • Dnes ešte algoritmy, znaky, v pondelok reťazce (ďalšie precvičenie polí a funkcií, trochu nových pojmov z C).
  • Budúcu stredu začneme rekurziu, potenciálne ťažké učivo.

Budúci týždeň cvičenia v špeciálnom režime

  • Budúci utorok rozcvička a zopár príkladov na znaky a reťazce.
  • Budúcu stredu po prednáške pribudne menší príklad na rekurziu, v piatok počas cvičení bude bonusová rozcvička na rekurziu.
  • Odporúčame budúci týždeň na doplnkové cvičenia prísť aj stredne pokročilým programátorom, ak ste ešte nerobili s rekurziou.

Vyhľadávanie prvkov poľa

Chceme zistiť, či pole obsahuje prvok zadanej hodnoty.

  • Musíme prejsť celé pole, lebo nevieme, kde sa prvok môže nachádzať.
  • Tento algoritmus sa nazýva lineárne vyhľadávanie
/* Funkcia find vráti index výskytu prvku x
 * v poli a. Ak sa x v poli nevyskytuje, vráti -1.
 * Hodnota n určuje počet prvkov poľa. */
int find(int a[], int n, int x) {
    for (int i = 0; i < n; i++) {
        if (a[i] == x) return i;
    }
    return -1;
}
  • Toto môžeme urobiť aj v prípade, že máme pole usporiadané.
  • Ale neexistuje lepšie riešenie?

Binárne vyhľadávanie v utriedenom poli

Ak porovnáme hľadanú hodnotu x s nejakým prvkom utriedeného poľa a[i], môžu nastať tri možnosti:

  • ak sme trafili pozíciu i takú, že x==a[i], máme odpoveď a môžeme skončiť s vyhľadávaním
  • ak x < a[i], tak všetky prvky a[j] napravo od pozície i budú určite tiež väčšie ako x a teda ich nemusíte ďalej uvažovať, stačí hľadať vľavo od i
  • ak x > a[i], tak všetky prvky a[j] naľavo od pozície i budú určite tiež menšie ako x a teda ich nemusíte ďalej uvažovať, stačí hľadať vpravo od i

Binárne vyhľadávanie v utriedenom poli teda pracuje nasledovne:

  • pamätáme si ľavý a pravý okraj intervalu, kde ešte môže byť hľadaný prvok x
  • vyberieme prvok a[index] v strede tohoto intervalu, teda index=(left+right)/2
  • na základe porovnania s hľadaným prvkom x interval skrátime na polovicu
  • ak už je interval zlý (t.j. pravý a ľavý kraj sú naopak), tak skončíme
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;
}

Ukážka práce algoritmu pre dva vstupy

int a[7]={2,5,21,32,38,45,50}
x=21
   left=0 right=6: index=3; A[index]>x (32>21)
   left=0 right=2; index=1; A[index]<x (5<21)
   left=2 right=2; index=2; A[index]=x -> return 2
x=11
   left=0 right=6: index=3; A[index]>x (32>11)
   left=1 right=2; index=1; A[index]<x (5<11)
   left=2 right=2; index=2; A[index]>x (21>11)
   left=2 right=1; left>right           -> koniec while cyklu -> return -1

Intuitívne máme pocit, že binárne vyhľadávanie je lepšie, ako lineárne.

Zložitosť algoritmu

Ako sme videli napríklad pri triedení a vyhľadávaní, jednu úlohu môžeme často riešiť viacerými spôsobmi. Intuitívne máme o niektorých pocit, že sú lepšie ako iné. Pozrime sa, prečo sú niektoré riešenia lepšie a ako môžeme niečo také odhadovať.

Časová zložitosť lineárneho vyhľadávania

Často nás zaujíma, ako rýchlo nám program bude bežať. Väčšinou táto rýchlosť nejako závisí od vstupných dát. Iste bude kratšie trvať triedenie trojprvkového poľa ako poľa s milión prvkami. Časovú zložitosť teda budeme odhadovať v závislosti od veľkosti vstupu.

Pre niektoré programy môžeme spočítať počet operácií, ktoré program vykoná na vstupoch veľkosti n.

  • Ukážme si túto metódu pre lineárne vyhľadávanie v neutriedenom poli
int find(int a[], int n, int x) {
    for (int i = 0; i < n; i++) {
        if (a[i] == x) return i;
    }
    return -1;
}
  • Najmenej operácií spravíme, ak x je v a[0]
  • Vtedy spravíme 4 kroky: i=0, i<n, a[i]==x, return i (závisí aj od toho, čo presne považujeme za "jeden krok")
  • Najviac operácií spravíme, ak sa x v poli nenachádza
  • Vtedy spravíme raz i=0, (n+1) krát i<n, n krát i++, n krát a[i]==x, raz return -1, spolu 3n+3 krokov
  • Celkový počet krokov bude teda niečo medzi 4 a 3n+3

Vo väčších programoch však toto rozmedzie nie je jednoduché vypočítať.

  • Preto uvažujeme väčšinou iba najhorší prípad, ktorý môže nastať
  • Okrem toho nás nezaujímajú presné čísla, ale iba akýsi odhad (približná funkcia) závislá od vstupu.
  • Pri lineárnom vyhľadávaní je v najhoršom prípade počet krokov lineárna funkcia od n, vravíme teda, že tento algoritmus má lineárnu zložitosť, značíme O(n)
  • Presnú definíciu O uvidíte budúci rok na predmete Algoritmy a dátové štruktúry

Časová zložitosť binárneho vyhľadávania

  • Najhorší možný scenár pre danú veľkosť vstupu n nastane, keď prvok nájdeme až v poslednom kroku alebo v poli nebude vôbec.
  • V prvom kroku máme celé pole a v ňom sa pozrieme na stredný prvok a podľa jeho hodnoty zoberieme buď ľavú alebo pravú (zhruba) polovicu poľa.
  • Tým pádom v druhom kroku máme pole polovičnej veľkosti a robíme na ňom zase to isté.
  • V každom kroku teda máme pole o polovicu menšie, až kým nemáme pole veľkosti 1. Potom už prvok nájdeme alebo v ďalšom kroku povieme, že tam nie je.
  • Akú zložitosť bude mať tento algoritmus?
    • Zapíšeme si číslo n (počet prvkov) v dvojkovej sústave.
    • Pri delení poľa na polovicu bude ďalšia veľkosť poľa toto číslo bez poslednej cifry.
    • Počet krokov, ktoré potrebujeme, je teda zhruba počet cifier n v dvojkovej sústave, čo je log2 n.
  • Vravíme teda, že zložitosť binárneho vyhľadávania je logaritmická, značíme O(log n)
  • Logaritmus rastie pre veľké n oveľa pomalšie ako lineárna funkcia, čiže binárne vyhľadávanie považujeme za efektívnejší algoritmus ako lineárne vyhľadávanie
    • pozor, neplatí to však pre každý vstup, iba pre porovnanie najhorších prípadov pre dosť veľké n

Pre ilustráciu som na mojom počítači namerala, koľko sekúnd v priemere trvá lineárne a binárne vyhľadávanie v poli s n číslami:

n       lineárne binárne
10      3.1e-8   3.7e-8
100     2.0e-7   6.9e-8
1000    1.8e-6   1.0e-7
10000   1.8e-5   1.4e-7
100000  1.8e-4   1.8e-7
1000000 1.8e-3   2.4e-7

Pripomíname, že napr. 1.8e-3 je 1.8 ⋅ 10-3, t.j. 0.0018. Pri hľadaní v poli dĺžky 10 je teda lineárne vyhľadávanie rýchlejšie, ale v poli dĺžky milión je už vyše 7000 krát pomalšie...

Časová zložitosť triedenia vkladaním

Triedenie vkladaním (Insertion sort) z minulej prednášky

  • Pripomíname ideu: prvých i prvkov máme utriedených, prvok a[i] sa snažíme vložiť na správne miesto
  • Na to musíme posunúť všetky väčšie prvky o jedna doprava, aby sme mu spravili miesto
void sort(int a[], int n) {
    /* usporiadaj prvky v poli a od najmenšieho po najväčší */

    for (int i = 1; i < n; i++) {
        int prvok = a[i];
        int kam = i;
        while (kam > 0 && a[kam - 1] > prvok) {
            a[kam] = a[kam - 1];
            kam--;
        }
        a[kam] = prvok;
    }
}
  • V najhoršom prípade pre dané i bude a[i] menšie ako všetky doteraz utriedené prvky a teda while cyklus bude bežať i krát
  • Ak je pole na začiatku usporiadané naopak, t.j. od najväčšieho prvku po najmenší, tento najhorší prípad nastane pri každej hodnote i
  • Teraz si už iba spočítame: pre i=1 posúvame 1 prvok, pre i=2 dva prvky, ..., pre i=n-1 posúvame n-1 prvkov
  • Teda čas, ktorý na to potrebujeme je 1+2+...+(n-1) = n(n-1)/2 = n2/2-n/2.
  • Zložitosť tohto triedenia bude teda kvadratická, čiže O(n2)
  • Bude sa však správať rovnako (kvadraticky) na všetkých vstupoch? Čo ak dostaneme na vstupe pole už správne utriedené?

Ostatné triedenia z prednášky (výberom a bublinkové) majú tiež v najhoršom prípade kvadratickú zložitosť

  • Premyslite si prečo
  • Ako dlho im to potrvá v najlepšom prípade?

Existujú však aj triedenia s časovou zložitosťou O(n log n), ako uvidíme neskôr v semestri

Cvičenie:

  • Na vstupe máme n čísel usporiadaných od najmenšieho po najväčšie a číslo x, chceme zistiť, či sa x nachádza medzi n číslami
  • Načítame čísla do poľa a spustíme lineárne alebo binárne vyhľadávanie
  • Aká bude časová zložitosť týchto dvoch verzií programu?
  • Čo ak nechceme vyhľadávať jednu hodnotu x, ale m rôznych hodnôt?
  • Čo ak nie sú čísla na vstupe utriedené a pred binárnym vyhľadávaním musíme najskôr triediť?

Znaky

Doteraz sme pracovali iba s číselnými dátami, ale pri programovaní často pracujeme z reťazcami (textami).

  • Reťazce budú na ďalšej prednáške, dnes si ukážeme, ako pracovať s ich jednotlivými súčasťami, znakmi (písmená, čísla, medzery,...)
  • Znakové konštanty sa zapisujú v apostrofoch, napr. 'A', '1', ' ' a pod.
  • Znakové premenné sú typu char, z anglického character. Ich veľkosť je spravidla 1 bajt, t.j. 8 bitov.

Znaky majú svoje kódy uvedené v tabuľke ASCII. Najbežnejšie sa budeme stretávať s týmito:

  • 48...57: '0'...'9'
  • 65...90: 'A'...'Z'
  • 97...122: 'a'...'z'
  • 32: medzera ' '
  • 9: tabulátor '\t'
  • 10: koniec riadku '\n'
  • 0: špeciálny nulový znak (uvidíme nabudúce) '\0'

Poznámky

  • bežné znaky z US klávesnice sú v rozsahu 0..127 (7 bitov)
  • nakoľko char je 8-bitový, môže ešte nadobúdať hodnoty -1 ... -128 alebo 128..255 podľa kompilátora
  • moderný softvér väčšinou namiesto klasických 8-bitových znakov používa Unicode, aby sa dali reprezentovať aj rôzne špeciálne symboly, znaky s diakritikou, jazyky nepoužívajúce latinku a pod.
  • na tomto predmete si vystačíme s klasickými znakmi v rozsahu 0..127

Do premennej typu char môžeme priraďovať, jej obsah zapísať alebo prečítať:

  char c = 'A';
  char z;
  z = c;
  cout << c;
  cin >> z;  // prečíta jeden znak (pozor, preskakujú sa biele znaky)

Znaky môžeme porovnávať. Na konci programu vyššie platí nasledovné:

  • c=='A' ... je pravda,
  • c=='a' ... nie je pravda – rozlišujú sa malé a veľké písmená,
  • c<='Z' ... je pravda – písmená sú usporiadané: A<B< ... <Z, a<b< ... <z, aj cifry sú usporiadané: 0<1< ... <9.

Pri čítaní zo vstupu pomocou cin do premennej typu char sa preskakujú tzv. biele znaky (napr. medzera, tabulátor, koniec riadku).

  • Toto nie je vždy žiadúce a preto môžeme použiť modifikátor noskipws, ktorý zruší preskakovanie takýchto znakov. Do premennej teda budeme vedieť prečítať aj medzeru.
#include <iostream>
using namespace std;

int main(void) {
  char a,b,c; 
  cin >> noskipws >> a >> b >> c;
  cout << a << b << c;
}

Pre referenciu uvádzame ASCII kódy niekoľko dôležitých znakov z rozsahu 0 až 32 a všetky znaky z rozsahu 33 až 126.

0 ukončovací znak
9 tabulátor '\t'
10 koniec riadku '\n'
32 medzera ' '

33 !     52 4     71 G      90 Z     109 m
34 "     53 5     72 H      91 [     110 n
35 #     54 6     73 I      92 \     111 o
36 $     55 7     74 J      93 ]     112 p
37 %     56 8     75 K      94 ^     113 q
38 &     57 9     76 L      95 _     114 r
39 '     58 :     77 M      96 `     115 s
40 (     59 ;     78 N      97 a     116 t
41 )     60 <     79 O      98 b     117 u
42 *     61 =     80 P      99 c     118 v
43 +     62 >     81 Q     100 d     119 w
44 ,     63 ?     82 R     101 e     120 x
45 -     64 @     83 S     102 f     121 y
46 .     65 A     84 T     103 g     122 z
47 /     66 B     85 U     104 h     123 {
48 0     67 C     86 V     105 i     124 |
49 1     68 D     87 W     106 j     125 }
50 2     69 E     88 X     107 k     126 ~
51 3     70 F     89 Y     108 l

Hodnota jednoduchého výrazu

Nasledujúci program spočíta hodnotu jednoduchého výrazu, ktorý pozostáva z dvoch čísel spojených znamienkom +, -, * alebo /. Okolo sú medzery (kvôli jednoduchšiemu načítaniu).

Napr. pre vstup 1 / 2 program vypíše 0.5.

#include <iostream>
using namespace std;

int main(void) {
    double a, b;
    char znamienko;
    cin >> a >> znamienko >> b;
    double vysledok;
    if (znamienko == '+') {
        vysledok = a + b;
    }
    else if (znamienko == '-') {
        vysledok = a - b;
    }
    else if (znamienko == '*') {
        vysledok = a * b;
    }
    else if (znamienko == '/') {
        vysledok = a / b;
    } else {
        cout << "zle znamienko " << znamienko << endl;
        return 1;
    }
    cout << vysledok << endl;
}

Switch

  • V predchádzajúcom programe bola pomerne dlhá a komplikovaná séria príkazov if, else
  • Namiesto toho sa dá použiť príkaz switch, ktorý podľa hodnoty výrazu pokračuje jednou z viacerých vetiev.

V našom jednoduchom príklade by mohol switch vyzerať nasledovne:

    switch (znamienko) {
    case '+' :
        vysledok = a + b;
        break;
    case '-' :
        vysledok = a - b;
        break;
    case '*' :
        vysledok = a * b;
        break;
    case '/' :
        vysledok = a / b;
        break;
    default:
        cout << "zle znamienko " << znamienko << endl;
        return 1;
    }

Vo všeobecnosti obsahuje príkaz switch viacero rôznych prípadov vyhodnotenia výrazu v podmienke.

switch (výraz)
{
case k_1: príkazy_1
case k_2: príkazy_2
default: príkazy_d
}

Takýto príkaz funguje nasledovne:

  • Vyhodnotí výraz.
    • Ak sa hodnota zhoduje s konštantným výrazom k_i v niektorom z prípadov, pokračuje časťou príkazy_i
    • Ak sa nezhoduje a máme vetvu default, pokračuje sa časťou prikazy_d
    • Ak nie je vetva default, pokračuje sa za koncom switch bloku.
  • Pozor: vykonávanie nekončí vykonaním posledného príkazu v prikazy_i, ale pokračuje ďalej, kým nie je prerušené príkazom break.
    • Toto je častá chyba pri použití príkazu switch
#include <iostream.h>

void main () {
  int n;
  cout << "Zadaj n (1,2,3,4): ";
  cin >> n;
  switch (n) {
    case 1: cout << "Jeden" << endl;
    case 2: cout << "Dva" << endl;
    case 3:
    case 4: cout << "Tri alebo styri" << endl;
    default: cout << "Chyba!" << endl;
  }
  cout << "Koniec." << endl;
}

Pre n=2 sa začnú vykonávať príkazy uvedené za vetvou case 2. Vypíše sa:

    Dva
    Tri alebo styri
    Chyba!
    Koniec.

Výhodou je, že môžeme zlúčiť viacero prípadov do jednej vetvy tým, ze príkazy napíšeme až za posledný prípad (tu vidíme napr. v situácii n=3 a n=4).

Dôležité upozornenie: break switch while

Predstavme si, že v programe sa pýtame užívateľa, či chce pokračovať s ďalším vstupom, pričom odpoveď má byť znak 'A' alebo 'N'. Ak by sme program napísali takto, nefungoval by:

    while (true) {
        // nejaky vypocet
        cout << "Chcete pokracovat? (Zadajte odpoved A alebo N)" << endl;
        char odpoved;
        cin >> odpoved;
        switch (odpoved) {
        case 'N':
            break;
        // spracovanie inych pripadov...
        }
    }
  • Príkaz break nevyskočí zo všetkých cyklov, ale iba z najvnútornejšieho - a tým je v tomto prípade switch. Program teda pokračuje aj keď užívateľ zadá 'N'.
  • Ak by break bol použitý s podmienkou, všetko by fungovalo: while (true) { ... if (odpoved=='N') { break; } }

Ešte znaky

Pretypovanie

Znakové premenné teda ukladajú kódy jednotlivých znakov, čo sú celé čísla.

  • Preto medzi znakmi a celými číslami môžeme prechádzať jednoducho (char môžeme priradiť do int a naopak, s char môžeme tiež robiť aritmetické operácie, pozor jedine na malý rozsah char-u)
  • Ale pri výpise sa výraz typu char vypisuje ako znak podľa ASCII tabuľky, výraz typu int ako číslo, niekedy teda treba pretypovať
  • Podobne vstup sa inak spracúva, ak ide do premennej char vs int.
#include<iostream>
using namespace std;

int main(void) {
  int i;
  char c;

  c = 'A';             // to iste ako c = 65
  cout << c << endl;   // vypise A
  cout << c+1 << endl; // vypise 66 (c+1 je typu int)
  cout << (char)(c+1) << endl; // pretypujeme, vypise B

  cout << "Napiste cifru 0-9: ";
  cin >> c;  
  // ak pouzivatel zada 0, do c sa ulozi '0', t.j. 48

  cout << "Napiste cifru 0-9: ";
  cin >> i;  
  // ak pouzivatel zada 0, do i sa ulozi hodnota 0

  cout << c << " " << (int)c << endl;
  cout << i << " " << (char)i << endl;
}

Prednáška 8

Oznamy

Prednášky a cvičenia tento týždeň

  • Dnešná prednáška reťazce
  • Zajtra na cvičeniach rozcvička na reťazce plus ďalšie príklady na reťazce (a znaky, polia, funkcie)
  • Prednáška v stredu rekurzia
  • V stredu po prednáške pribudne ďalší príklad na rekurziu a v piatok bonusová rozcvička
    • Bonusovú rozcvičku môžete riešiť od hocikade, ale len v čase piatkových cvičení
    • Ak ste ešte nerobili s rekurziou, veľmi silne odporúčame prísť na doplnkové cvičenia
  • V rekurzii pokračujeme aj budúci týždeň

Domáca úloha do budúceho utorka 29.10. 22:00

  • Preštudujte si zadanie, pýtajte sa otázky.
  • K úlohe sa vám môže hodiť pozrieť si video z konca prednášky 5 o knižnici #SVGdraw a o ukladaní struct-ov do poľa.
  • Časť bodov môžete dostať aj za neúplný program. Začnite od jednoduchších častí, napr. načítanie vstupu a uloženie do poľa, vykreslenie pôšt a domov jednotnou farbou.
  • Potom môžete prejsť na hľadanie najbližšej pošty a správne nastavovanie farieb v obrázku.
  • Každá domáca úloha má váhu 5% známky, teda zhruba ako dva týždne cvičení

Z minulej prednášky

Znaky

  • Znakové premenné sú typu char, z anglického character.
  • Znaky majú svoje kódy uvedené v tabuľke ASCII.
  • Do premennej typu char môžeme priraďovať, jej obsah zapísať alebo prečítať. Znaky môžeme porovnávať pomocou ==, <= atď
  • Znakové premenné ukladajú kódy jednotlivých znakov, čo sú celé čísla. Preto medzi znakmi a celými číslami môžeme prechádzať úplne jednoducho.
    • Keď chceme aby výsledok bol konkrétneho typu, použijeme pretypovanie.
  • Znakové konštanty sa píšu v apostrofoch, napr. 'A'

Switch

  • Namiesto niekoľkých vnorených if s tou istou premennou v podmienke môžeme použiť switch.
  • Vo všeobecnosti obsahuje príkaz switch viacero rôznych prípadov vyhodnotenia výrazu v podmienke.
switch (výraz) {
    case k1: príkazy1; break;
    case k2: príkazy2; break;
    default: príkazyd;
}

Pozor: vykonávanie nekončí vykonaním posledného príkazu v prikazyi, ale pokračuje ďalej, kým nie je prerušené príkazom break.

  • Výhodou je, že môžeme zlúčiť viacero prípadov do jednej vetvy.

Využitie znakov

Vďaka znakom môžeme spraviť kontrolu toho, čo vlastne používateľ napísal na vstup. Napríklad, či zadal správne celé číslo a nenamiešal medzi cifry nejaký iný znak.

#include <iostream>
using namespace std;

int main(void) {
    int N = 0;
    char c;

    cout << "Zadajte cele kladne cislo: ";

    cin >> noskipws >> c;

    // kým je načítaný znak číslo (t.j. jedna cifra)
    while ((c >= '0') && (c <= '9')) { 

        // prevod z kódu znaku na cifru 0..9 
        // ('0' má kod 48)
        int cifra = c - '0';
        // upravíme číslo N 
        N = N * 10 + cifra; 
        // a načítame ďalší znak
        cin >> noskipws >> c; 
    }

    if ((c == ' ') || (c == '\n')) { 
        // ak sme skončili medzerou alebo koncom riadku, 
        // tak je to pekné číslo
        cout << "Zadali ste " << N << endl;
    } else {
        // ak sme skončili niečim iným, asi to nebude ok
        cout << "Toto je cele cislo?" << endl; 
    }
}

Reťazce v jazyku C

Textový reťazec je v jazyku C štandardne uložený v poli ako postupnosť znakov (char) ukončená znakom s kódom 0.

  • Napr. reťazec "ABC" je uložený ako pole dĺžky 4 obsahujúci kódy 65, 66, 67, 0 (resp. znaky 'A', 'B', 'C', '\0')
  • Pozor, rozdiel medzi znakom s kódom 0 (píše sa aj '\0') a znakom pre cifru nula '0' s kódom 48
  • Reťazce teda nemôžu obsahovať vo vnútri znak s kódom 0, ten je rezervovaný na ukončovanie
  • Na reťazec s n znakmi potrebujeme pole dĺžky aspoň n+1, lebo jeden znak sa minie na ukončovací symbol

Keď sme robili funkcie na prácu s poľom čísel, museli sme poslať pole aj počet prvkov. Pri reťazcoch nemusíme zvlášť udržiavať dĺžku, tá je daná pozíciou nuly v poli.

Inicializácia reťazcov

  • Chceme vytvoriť premennú str obsahujúce reťazec Ahoj spolu s koncom riadku
  • Prvý spôsob je zdĺhavý:
    char str[6];
    str[0] = 'A';
    str[1] = 'h';
    str[2] = 'o';
    str[3] = 'j';
    str[4] = '\n'; // znak pre koniec riadku
    str[5] = 0;
  • Alebo ako inicializácia poľa: char str[10]={'A','h','o','j','\n',0};
  • Namiesto toho sa používa špeciálna skratka: char str[6]="Ahoj\n";
  • Ako vytvoríme prázdny reťazec?

Reťazec je naozaj pole

Znaky reťazca môžeme meniť

char a[100] = "vlk";
char ch = a[0]; // ch obsahuje hodnotu 'v'
char b[100] = "pes";

b[0] = ch;     // Výsledkom je 'ves'.
b[0] = 'd';    // Výsledkom je 'des'. 
b[0] = a[1];   // Výsledkom je 'les'.

Reťazec sa nedá kopírovať jednoduchým priradením, nemôžeme teda spraviť

char a[100];
a = "Ahoj";           // chyba
char b[100] = "Ahoj"; // ok - inicializacia
a = b;                // chyba

Reťazce sa nedajú ani porovnávať pomocou ==, !=, < atď

Kopírovanie a porovnávanie si musíme naprogramovať cez cykly, alebo použiť hotové funkcie z knižníc.

Knižnica cstring

Obsahuje mnohé funkcie na prácu s reťazcami, napríklad tieto:

  • strlen(retazec): vráti dĺžku reťazca
  • strcpy(kam, co): skopíruje reťazec co do reťazca kam (pole kam musí byť dosť dlhé)
  • strcat(kam, co): za koniec reťazca kam pridá reťazec co (pole kam musí byť dosť dlhé)
  • strcmp(retazec1, retazec2): vráti nulu ak sa reťazce rovnajú, kladné číslo keď je prvý neskôr v abecednom poradí, záporné číslo, ak je skôr. Pozor, to či je skôr alebo neskôr sa berie podľa kódov znakov, takže napr. 'Z' je skôr ako 'a'.

Všetky tieto funkcie by sme si však vedeli naprogramovať aj sami. Tu je napríklad výpočet dĺžky:

int myStrLen(char a[]) {
    int n = 0; 
    while(a[n] != 0) {  n++; }
    return n;
}
  • čo bude funkcia robiť ak reťazcu chýba na konci 0?

Dve verzie kopírovania:

void myStrCpy(char a[], char b[]) {
    /* Skopiruj obsah retazca b do retazca a.
     * Pole a musi mat dost miesta. */
    int n = 0;
    while (b[n] != 0) {
        a[n] = b[n];
        n++;
    }
    a[n] = 0; // reťazec musí končiť 0
}

void myStrCpy2(char a[], char b[]) {
    /* Skopiruj obsah retazca b do retazca a.
     * Pole a musi mat dost miesta. */
    for (int i = 0; i <= strlen(b); i++) {
        a[i] = b[i];
    }
}
  • Ktorá je rýchlejšia pre dlhé reťazce?
  • Aká je ich zložitosť ako funkcia dĺžky reťazca n?

Namiesto strcmp naprogramujeme len test na rovnosť:

bool rovnostRetazcov(char a[], char b[]) {
    /* vrati true ak su retazce a, b rovnake, inak vrati false */

    for (int i = 0; a[i] != 0 || b[i] != 0; i++) {
        if (a[i] != b[i]) return false;
    }
    return true;
}
  • Ako bude prebiehať funkcia, ak jeden reťazec je začiatkom druhého?

Načítavanie a vypisovanie reťazcov

  • Bežné načítanie z konzoly do reťazca (cin >> str) načíta jedno slovo
    • Preskočí biele znaky (medzery, konce riadkov, tabulátory), potom prečíta všetko po ďalší biely znak (alebo koniec vstupu) a uloží do premennej.
    • Pri čítaní je vhodné nastaviť maximálny počet znakov na načítanie, aby sme nevyšli z poľa
  • Na načítanie jedného riadku je možné použiť funkciu getline. Načíta až po koniec riadku, ten zahodí.
  • Vypisovanie funguje normálne pomocou cout << str
#include <iostream>
using namespace std;

int main(void) {
    const int maxN = 100;
    char str[maxN], str2[maxN], str3[maxN];

    // načíta celý riadok, ale najviac maxN-1 znakov
    cin.getline(str, maxN); 

    // najbližšie načíta najviac maxN-1 znakov 
    cin.width(maxN); 
    // načíta jedno slovo
    cin >> str2;
     
    // načítanie ďalšieho slova, width treba opakovať
    cin.width(maxN); 
    cin >> str3;     

    cout << "str: \"" << str << "\"" << endl;
    cout << "str2: \"" << str2 << "\"" << endl;
    cout << "str3: \"" << str3 << "\"" << endl;
}

Príklad behu programu (prvé dva riadky zadal užívateľ, na začiatku a konci každého je medzera)

 a b c 
 g h i 
str: " a b c "
str2: "g"
str3: "h"

Algoritmy s reťazcami

Prácu s reťazcami si precvičíme na niekoľkých menších príkladoch.

Vyhľadávanie podreťazca

Chceme zistiť, či a kde sa v reťazci nachádza určité slovo alebo iná vzorka.

#include <iostream>
#include <cstring>
using namespace std;

int find(char text[], char pattern[]) {
    /* Vráti polohu prvého výskytu reťazca pattern 
     * v reťazci text, alebo -1 ak sa nevyskytuje. */

    int n = strlen(text);
    int m = strlen(pattern);
    for (int i = 0; i < n - m + 1; i++) {
        bool zhoda = true;
        for (int j = 0; j < m; j++) {
            if (text[i + j] != pattern[j]) {
               zhoda = false;
               break;
            }
        }
        if (zhoda) {
            return i;
        }
    }
    return -1;
}

int main(void) {
    const int maxN = 2000;
    char A[maxN], B[maxN];

    cout << "Zadaj text: ";
    cin.getline(A,maxN);
    cout << "Zadaj vzorku: ";
    cin.getline(B,maxN);
    cout << find(A,B) << endl;
}
  • Predpočítame si dĺžky a uložíme do premenných, aby sa zbytočne nerátali znova a znova
  • Ako by sme zmenili program aby hľadal posledný výskyt namiesto prvého?
  • Vedeli by sme do poľa uložiť polohy všetkých výskytov?

Prevod čísla na reťazec

Máme danú premennú x typu int, chceme ju uložiť v desiatkovej sústave do reťazca.

  • Zvyšok po delení 10 je posledná cifra, uložíme si ju do reťazca, vydelíme x desiatimi
  • Opakujeme, kým nespracujeme celé číslo.
  • Prevod z čísla c (0..9) na cifru: '0'+c
  • Nezabudneme na ukončovací znak 0
  • Dostaneme číslo v opačnom poradí, napr pre x=12 budeme mať reťazec {'2', '1', 0}
  • Preto ešte celé číslo otočíme naopak.
void reverse(char a[]) {
    int n = strlen(a);
    int i = 0;
    int j = n - 1;
    while (i < j) {
        char tmp = a[i];
        a[i] = a[j];
        a[j] = tmp;
        i++; j--;
    }
}

void int2str(int x, char a[]) {
    /* prevedie kladne cele cislo x na retazec,
     * vysledok ulozi do retazca a, ktory musi mat dost miesta. */
    assert(x > 0);

    int n = 0;
    while(x > 0) {
        a[n] = '0' + x % 10;
        x /= 10;
        n++;
    }
    a[n] = 0;

    /* teraz je cislo naopak, treba otocit */
    reverse(a);
}
  • Ako upravíme funkciu, aby fungovala aj pre x=0, prípadne záporné x?
  • Pozor na rozdiel medzi znakom 0 a '0' (a medzi reťazcom "0")

Formátovanie čísla

  • Chceme číslo zapísať do reťazca a doplniť naľavo medzerami na šírku width.
const int maxN = 100;

void formatInt(int x, char A[], int width) {
    /* číslo x konvertujeme na reťazec
     * a uložíme do poľa A zarované doprava na šírku width */

    /* najprv x uložíme do pomocného reťazca B  a zrátame jeho dĺžku n */
    char B[maxN];
    int2str(x, B);
    int n = strlen(B);

    /* do A dáme width-n medzier a ukončovaciu 0 */
    assert(n <= width);
    int i;
    for (i = 0; i < width - n; i++) {
        A[i] = ' ';
    }
    A[i] = 0;

    /* za A prikopírujeme B */
    strcat(A, B);
}
  • Čo by sa stalo, ak by sme nedali do A ukončovaciu 0?
  • Vedeli by ste prepísať program, aby pracoval priamo v poli A (bez poľa B)?


Využijeme na vypísanie pekne zarovnanej tabuľky faktoriálov:

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

int main(void) {
    char A[maxN];
    int n = 12;
    for (int i = 1; i <= n; i++) {
        int x = factorial(i);
        formatInt(i, A, 2);
        cout << A << "! = ";
        formatInt(x, A, 10);
        cout << A << endl;
    }
}
 1! =          1
 2! =          2
 3! =          6
 4! =         24
 5! =        120
 6! =        720
 7! =       5040
 8! =      40320
 9! =     362880
10! =    3628800
11! =   39916800
12! =  479001600

Dalo by sa aj jednoduchšie pomocou nastavenia width v cin:

int main(void) {
    int n = 12;
    for (int i = 1; i <= n; i++) {
        int x = factorial(i);
        cout.width(2);
        cout << i << "! = ";
        cout.width(10);
        cout << x << endl;
    }
}

Zalamovanie riadkov

Pre zaujímavosť: ukážka trochu dlhšieho programu na prácu s textom

  • Máme reťazec s nejakým textom, v ktorom sa vyskytujú rôzne biele znaky, napríklad medzery a konce riadkov. Máme danú šírku riadku W, napr. 80 znakov. Úlohou je ho upraviť tak:
    • aby na každom riadku bolo najviac W znakov, pričom nový riadok začína tam, kde by už ďalšie slovo presahovalo cez W
    • medzi dvoma slovami má byť vždy buď jedna medzera alebo jeden koniec riadku
    • predpokladáme, že žiadne slovo nemá viac ako W znakov
Zadavaj text ukonceny prazdnym riadkom.
A AA A AAA 
A  A AA AAA
AAA A AA AA  A

Zadaj sirku riadku:
5
Sformatovany odstavec:
A AA
A AAA
A A
AA
AAA
AAA A
AA AA
A
Zadavaj text ukonceny prazdnym riadkom.
Martin Kukucin: Do skoly.    Vakacie sa koncia. Ondrej Rybar sa vse zamysli nad marnostou sveta i vsetkeho, co je v nom. 
Predstupuje mu tu i tu pred oci profesor, ako stoji pred ciernou tabulou, drziac kruzidlo v ruke a demonstruje pamatnu poucku Pytagorovu. 
A zimomriavky naskakuju na chrbat, lebo s geometriou stoji od pociatku na nohe valecnej. 
Ani matematika nenie lepsia, menovite odvtedy, co sa do nej vplichtili miesto cisel vsakove litery. 
Neraz hutal, naco ich ucenci vpustili do matematiky - ved i bez nich je dost strapata: ci sa im malilo cisel a tak preto vsantrocili medzi ne a a b a ci fantazia sa im tak rozihrala, 
ze prekrocila hranice cisel celych, zlomkov obycajnych i desatinnych i bohvieakych, a zabludila na nivy, kde rastu nestastne litery? 
 ,Uz akokolvek,' huta Ondro, ,litery tam nemaju co hladat. Tazko je uverit, ze a/b = c, lebo nevies, co je a, alebo b.' 

Zadaj sirku riadku:
50
Sformatovany odstavec:
Martin Kukucin: Do skoly. Vakacie sa koncia.
Ondrej Rybar sa vse zamysli nad marnostou sveta i
vsetkeho, co je v nom. Predstupuje mu tu i tu pred
oci profesor, ako stoji pred ciernou tabulou,
drziac kruzidlo v ruke a demonstruje pamatnu
poucku Pytagorovu. A zimomriavky naskakuju na
chrbat, lebo s geometriou stoji od pociatku na
nohe valecnej. Ani matematika nenie lepsia,
menovite odvtedy, co sa do nej vplichtili miesto
cisel vsakove litery. Neraz hutal, naco ich ucenci
vpustili do matematiky - ved i bez nich je dost
strapata: ci sa im malilo cisel a tak preto
vsantrocili medzi ne a a b a ci fantazia sa im tak
rozihrala, ze prekrocila hranice cisel celych,
zlomkov obycajnych i desatinnych i bohvieakych, a
zabludila na nivy, kde rastu nestastne litery? ,Uz
akokolvek,' huta Ondro, ,litery tam nemaju co
hladat. Tazko je uverit, ze a/b = c, lebo nevies,
co je a, alebo b.'

Plán: úlohu si rozdelíme na viac častí

  • Prerobíme reťazec tak, aby sme všetky biele znaky nahradili medzerami. Na rozpoznanie bielych znakov použijeme funkciu isspace z knižnice cctype.
  • Každý súvislý úsek medzier nahradíme práve jednou medzerou, zmažeme medzery na začiatku a na konci.
    • Viac možností na riešenie, napríklad znaky presýpame do nového poľa. My ale použijeme len jedno pole
  • Niektoré medzery nahradíme koncom riadku, aby každý riadok mal šírku najviac W.
  • Spravíme načítanie a vypísanie.
#include <iostream>
#include <cstring>
#include <cctype>
#include <cassert>
using namespace std;

void simplify(char A[]) {
    /* V retazci A nahradi kazdy suvisly usek bielych znakov prave jednou medzerou.
     * Na zaciatku a konci retazca nebudu medzery. */

    /* prepis hocijake biele znaky na medzeru */
    for (int i = 0; A[i] != 0; i++) {
        if (isspace(A[i])) {
            A[i] = ' ';
        }
    }

    int kam = 0; /* prve este neobsadene miesto */
    char prev = ' '; /* predchadzajuci znak */

    for (int i = 0; A[i] != 0; i++) {
        /* ak nemame viac medzier po sebe, skopirujeme znak */
        if (A[i] != ' ' || prev != ' ') {
            A[kam] = A[i];
            kam++;
        }
        /* zapamatame si posledny znak */
        prev = A[i];
    }

    /* zrusime pripadnu medzeru na konci */
    if (kam > 0 && A[kam - 1] == ' ') {
        kam--;
    }

    /* retazec ukoncime nulou */
    A[kam] = 0;
}

bool breakLines(char A[], int width) {
    /* Preformatuje odstavec na sirku riadku width, vyhodi zbytocne medzery.
     * Dlzka kazdeho slova musi byt najviac width, inak funkcia vrati false */

    simplify(A);
    int n = strlen(A);

    int zac = 0;  /* index prveho pismena v riadku */
    while (zac < n) {
        int kon = zac + width;  /* potencialny koniec riadku */
        /* ak uz nemame dost pismen na cely riadok */
        if (kon > n) {
            kon = n;
        }
        /* ak sme na konci, pridame koniec riadku za koniec retazca */
        if (kon == n) {
            A[kon] = '\n';
            A[kon + 1] = 0;
            n++;
        } else {
            /* ideme späť, kým nenájdeme medzeru */
            while (kon > zac && A[kon] != ' ') {
                kon--;
            }
            /* nenašli sme medzeru: slovo bolo príliš dlhé. */
            if (kon == zac) {
                return false;
            }
            /* medzeru prepíšeme na koniec riadku */
            assert(A[kon]==' ');
            A[kon] = '\n';
        }
        /* za koncom riadku bude novy zaciatok */
        zac = kon + 1;
    }
    return true;
}

int main(void) {
    const int maxN = 2000;
    char A[maxN];
    A[0] = 0;

    cout << "Zadavaj text ukonceny prazdnym riadkom." << endl;
    while (true) {
        /* nacitame jeden riadok */
        char tmp[maxN];
        cin.getline(tmp, maxN);
        /* ak je prazdny, koncime nacitavanie */
        if (strcmp(tmp, "") == 0) {
            break;
        }
        /* ak je miesto v poli A, pridame do neho novy riadok */
        if (strlen(A) + strlen(tmp) + 2 < maxN) {
            strcat(A, tmp);
            strcat(A, "\n");
        } else {
            cout << "Text je prilis dlhy." << endl;
            return 1;
        }
    }

    cout << "Zadaj sirku riadku:" << endl;
    int width;
    cin >> width;

    breakLines(A, width);
    cout << "Sformatovany odstavec:" << endl;
    cout << A;
}
  • Akú zložitosť má načítanie vzhľadom na celkový počet načítaných písmen? Dalo by sa zlepšiť?

Zhrnutie

  • Reťazec je pole znakov, za posledným znakom reťazca dáme špeciálny znak s kódom 0
  • V knižnici cstring sú funkcie na porovnávanie a kopírovanie reťazcov atď. a pomocou cin a cout môžeme reťazce načítavať a vypisovať.
  • Ďalšie funkcie si vieme naprogramovať aj sami, zvyčajne jednoduchá práca s poľom

Prednáška 9

Oznamy

Domáca úloha do utorka

  • Časť bodov môžete dostať aj za neúplný program
  • Na piatkových cvičeniach vám môžeme poradiť, ak máte otázky
  • Nenechávajte všetku prácu na poslednú chvíľu

Cvičenia

  • Dnes pribudne do cvičení ďalší príklad na rekurziu, v piatok bonusová rozcvička za jeden bod
  • Bonusovú rozcvičku môžete riešiť od hocikade, ale len v čase piatkových cvičení
  • Študentom, ktorí ešte nepracovali s rekurziou, odporúčame prísť na cvičenia v piatok
  • V rekurzii pokračujeme aj budúci týždeň. Pondelkovú prednášku ľahšie pochopíte, ak si dovtedy vyriešite aspoň tieto dva ľahké rekurzívne príklady

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! = 1 ak n≤1
  • n! = n ⋅ (n-1)! inak

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

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

Aby sa 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 je počítanie najväčšieho spoločného deliteľa.

int gcd(int a, int b) {
    while (b != 0) {
        int r = a % b;
        a = b;
        b = r;
    }
    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);
}

Binárne vyhľadávanie

Aj binárne vyhľadávanie prvku v utriedenom poli z prednášky 7 sa dá pekne zapísať rekurzívne.

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 okraje aktuálneho úseku poľa 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í?

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)
  • 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, kde definovaná funkcia používa seba samú priamo.
  • V nepriamej rekurzii 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).
  • Ilustračný príklad nižšie (veľmi neefektívne) testuje párnosť a nepárnosť čísla (párnosť je samozrejme lepšie testovať pomocou n % 2):
  • Nakoľko vo funkcii vieme použiť len funkcie definované v programe nad ňou, máme tu problém. Vyriešime ho tzv, deklaráciou, kde napíšeme len hlavičku funkcie bez tela a telo dáme nižšie.
// deklaracia funkcie odd, aby sa dala pouzit v even
bool odd(int n);  

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 s funkciami
  • 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 sa 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, ktorý sme rozpísali na viac riadkov, aby sa nám simulácia lepšie robila.

// Pôvodná verzia
int factorial(int n) {
    if (n <= 1) return 1;
    else return n * factorial(n - 1);
}

// Rozpísaná verzia
int factorial(int n) {
    int result;
    if (n < 2) result = 1;
    else { 
       int rest = factorial(n - 1);  // rekurzia
       result = n * rest;
    }
    return result;
}

int main() {
   int f = factorial(3);
   cout << f << endl;
}

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). 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.

Odbočka: korytnačia grafika v SVGdraw

Náš prvý dnešný príklad rekurzie budú rekurzívne obrázky, fraktály. Aby sa nám lepšie vykresľovali, v knižnici #SVGdraw je možnosť kresliť pomocou korytnačej grafiky.

  • Vytvoríme si virtuálnu korytnačku, ktorá má určitú polohu a natočenie.
  • Môžeme jej povedať, aby sa otočila doľava alebo doprava o určitý počet stupňov (turtle.turnLeft(uhol) a turtle.turnRight(uhol)).
  • Môžeme jej povedať, aby išla o určitú dĺžku dopredu (turtle.forward(dlzka))
  • Keď ide korytnačka dopredu, zanecháva v piesku chvostom čiarku (vykreslí teda čiaru do nášho obrázku).

Napríklad na vykreslenie štvorca s dĺžkou strany 100 môžeme korytnačke striedavo prikazovať ísť o 100 dopredu a otáčať sa o 90 stupňov doľava.

  • Na obrázku sa animuje pohyb korytnačky (pozri tu)
  • Program by sa dal ľahko rozšíriť na vykresľovanie pravidelného n-uholníka (stačí zmeniť uhol otočenia a počet opakovaní cyklu)
#include "SVGdraw.h"

int main(void) {
    /* Vytvor korytnačku na súradniciach (25,175)
     * otočenú doprava na obrázku s rozmermi 200x200,
     * ktorý bude uložený v súbore stvorec.svg. */
    Turtle turtle(200, 200, "stvorec.svg", 25, 175, 0);

    for (int i = 0; i < 4; i++) {
        // vykresli čiaru dĺžky 150
        turtle.forward(150);  
        // otoč sa doľava o 90 stupňov 
        turtle.turnLeft(90);  
    }
    /* strany boli vykreslené v poradí 
     * dolná, pravá, horná, ľavá */

    /* Ukonči vypisovanie obrázka. */
    turtle.finish();
}

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

Kochove krivky stupňov 0-5


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 4 úsečiek s dĺžkou d/3.
  • Tú istú transformáciu môžeme teraz spraviť na každej zo 4 nových úsečiek, t.j. dostávame 16 úsečiek dĺžky d/9.
  • Takéto transformácie môžeme robiť do nekoneč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 použijeme 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();
}

Rekurzívny strom

A ešte jeden príklad fraktálu: strom definovaný nasledovne:

  • strom má dva parametre: veľkosť d a stupeň n
  • strom stupňa 0 je prázdna množina
  • strom stupňa n pozostáva z kmeňa, ktorý tvorí úsečka dĺžky d a z dvoch podstromov, ktoré sú stromy stupňa n-1, veľkosti d/2 a otočené o 30 stupňov doľava a doprava od hlavnej osi stromu (pozri obrázky nižšie)

Rekurzívnu funkciu na vykresľovanie stromu napíšeme tak, aby sa po skončení vrátila na miesto a otočenie, kde začala

  • Bez toho by sme nevedeli, kde korytnačka je po vykreslení ľavého podstromu a nemohli by sme teda kresliť pravý
  • Korytnačka teda prejde po každej vetve dvakrát, raz smerom dopredu a raz naspäť (animácia)
#include "SVGdraw.h"

void drawTree(double d, int n, Turtle& turtle) {
    if (n == 0) {
        /* stupen 0 - nerob nic */
        return;  
    } else {
        /* kmen stromu */
        turtle.forward(d);              
        turtle.turnLeft(30);
        /* lava cast koruny */
        drawTree(d / 2, n - 1, turtle); 
        turtle.turnRight(60);
        /* prava cast koruny */
        drawTree(d / 2, n - 1, turtle); 
        turtle.turnLeft(30);
        /* navrat na spodok kmena */
        turtle.forward(-d);             
    }
}

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

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

    /* vytvor korytnačku otočenú hore */
    Turtle turtle(width, height, "fraktal.svg", 
                  width / 2, height - 10, 90);

    /* nakresli strom rekurzívne */
    drawTree(d, n, turtle);

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

Fibonacciho čísla

Fibonacciho postupnosť je postupnosť čísel taká, že:

  • Nultý člen je 0.
  • Prvý člen je 1.
  • Každý ďalší člen postupnosti je daný súčtom dvoch predchádzajúcich.

Prvých niekoľko členov Fibonacciho postupnosti je

0, 1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144, 233, 377, 610, 987, 1597, 2584, 4181, 6765, 10946, 17711, 28657, 46368, 75025, 121393, ...

Formálnejšiu definíciu n-tého Fibonacciho čísla F(n) môžeme zapísať ako rekurzívny vzťah:

  • F(0) = 0,
  • F(1) = 1,
  • Pre všetky prirodzené čísla n ≥ 2: F(n) = F(n - 1) + F(n - 2).

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

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

Fibonacciho čísla nerekurzívne

Jedna možnosť je ukladať F[0], F[1], ..., F[n] do poľa

  • int fibonacci(int n) {
        int F[MAXN + 1];
        F[0] = 0;
        F[1] = 1;
        for(int i = 2; i <= n; i++) {
            F[i] = F[i-1] + F[i-2];
        }
        return F[n];
    }
    

Všimnite si ale, že z poľa vždy potrebujeme len posledné dve vyplnené čísla, takže stačili by nám dve premenné typu int.

int fibonacci(int n) {
    if (n == 0) {
        return 0;
    } else if (n == 1) {
        return 1;
    } else {
        int F_posledne = 1;
        int F_predposledne = 0;
        for (int i = 2; i <= n; i++) {
            int F_n = F_posledne + F_predposledne;
            F_predposledne = F_posledne;
            F_posledne = F_n;                     
        }
        return F_posledne;
    }
}

Cvičenie

Ako by sme bez počítača zistili, čo robí rekurzívna funkcia, napríklad takáto obmena Fibonacciho postupnosti?

int f(int n) {
    if (n <= 2) return 1;
    else return f(n-1) + f(n-3);
}

Ako pracuje rekurzívna verzia Fibonacciho čísel

  • Rekurzívna verzia výpočtu Fibonacciho čísel je krátka a elegantná, podobá sa na matematickú definíciu
  • Je ale neefektívna
  • Skúsme napríklad odsimulovať, čo sa deje, ak chceme rekurzívne spočítať F[3]
  • Kvôli prehľadnosti si rekurzívnu funkciu fib rozpíšeme na viac riadkov:
#include <iostream>
using namespace std;

int fib(int n) {
    if (n == 0) {
        return 0;
    } else if (n == 1) {
        return 1;
    } else {
        int a = fib(n - 1); // riadok (A)
        int b = fib(n - 2); // riadok (B)
        return a+b;
    }
}

int main() {
    int x = fib(3);       // riadok (C)
    cout << x << endl; 
}

Tu je priebeh programu (obsah zásobníka)

(1)          (2)                      (3)

                                       fib n=2
              fib n=3                  fib n=3, a=?, b=?, riadok A
main, x=?     main, x=?, riadok C      main, x=?, riadok C

(4)                             (5)
 
fib n=1                         
fib n=2, a=?, b=?, riadok A     fib n=2, a=1, b=?, riadok A
fib n=3, a=?, b=?, riadok A     fib n=3, a=?, b=?, riadok A
main, x=?, riadok C             main, x=?, riadok C             


(6)                             (7)
 
fib n=0                         
fib n=2, a=1, b=?, riadok B     fib n=2, a=1, b=0, riadok B
fib n=3, a=?, b=?, riadok A     fib n=3, a=?, b=?, riadok A
main, x=?, riadok C             main, x=?, riadok C             


(8)                             (9)

                                fib n=1
fib n=3, a=1, b=?, riadok A     fib n=3, a=1, b=?, riadok B 
main, x=?, riadok C             main, x=?, riadok C                 


(10)                            (11)

fib n=3, a=1, b=1, riadok B     
main, x=?, riadok C             main, x=2, riadok C 

Postupnosť volaní počas výpočtu vieme znázorniť aj stromovým diagramom:

Fib.png

Priamočiary rekurzívny zápis výpočtu Fibonacciho čísel je neefektívny, lebo výpočet Fibonacciho čísel sa opakuje

  • Napr. pre n=5 počítame fib(2) trikrát, pre n=6 päťkrát a pre n=20 až 4181-krát

Prednáška 10

Oznamy

  • Zajtra 22:00 termín odovzdania DÚ1
  • V stredu zverejníme DÚ2
  • Zajtrajšia rozcvička bude z dnešnej prednášky (a teda je fajn si prednášku pozrieť pred cvičením)
  • V piatok je sviatok, cvičenia nebudú
  • V utorok 5.11. na cvičeniach bude krátky test podobne ako na prednáške 7.10.
    • Bude zahŕňať učivo po dnešnú prednášku.
    • Môžete si priniesť ťahák 1 list A4. Používanie počítača nebude povolené.
    • Ide o súčasť cvičení 7, takže študenti, ktorí majú cvičenia 7 uznané na základe testu pre pokročilých, nemusia prísť.
  • Všímajte si varovania kompilátora, môžu vás upozorniť na chybu
main.cpp:15:22: warning: 
  array subscript 1 is above array bounds of 'char [1]' 
  [-Warray-bounds]
        char str[0];
        str [0] = '\n';

main.cpp:16:1: warning: 
  no return statement in function 
  returning non-void [-Wreturn-type]

main.cpp:12:31: warning: 
  comparison of integer expressions 
  of different signedness: 'int' and 'size_t' 
  {aka 'long unsigned int'} [-Wsign-compare]
 for (int i = 0; i < strlen(retazec); i ++) {

Opakovanie rekurzie

  • Rekurzívna definícia: určitý objekt definujeme pomocou menších objektov toho istého typu
  • Napríklad rekurzívna definícia faktoriálu:
    • n! = 1 ak n≤1 (triviálny prípad)
    • n! = n ⋅ (n-1)! inak
  • Rekurzívne definície vieme často priamočiaro zapísať do rekurzívnych funkcií
int factorial(int n) {
    if (n <= 1) return 1;
    else return n * factorial(n-1);
}
  • V rekurzívnej funkcii riešime problém pomocou menších podproblémov toho istého typu
    • Napríklad aby sme našli číslo x v utriedenom poli medzi indexami left a right, potrebujeme ho porovnať so stredným prvkom tohoto intervalu a potom riešiť tú istú úlohu pre menší interval.
    • Aj keď sme pôvodne chceli hľadať prvok v celom poli, úlohu rozšírime o parametre left a right, aby sa dala spraviť rekurzia.
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);
    }
}

Zásobník volaní

Druhý pohľad na rekurziu je dynamický: môžeme simulovať, čo sa v programe deje so zásobníkom volaní (call stack).

  • Skúsme napríklad odsimulovať, čo sa deje ak vo funkcii main zavoláme fib(3)
  • Kvôli prehľadnosti si fib rozpíšeme na viac riadkov:
#include <iostream>
using namespace std;

int fib(int n) {
    if (n == 0) {
        return 0;
    } else if (n == 1) {
        return 1;
    } else {
        int a = fib(n - 1); // riadok (A)
        int b = fib(n - 2); // riadok (B)
        return a+b;
    }
}

int main() {
    int x = fib(3);    // riadok (C)
    cout << x << endl; 
}

Tu je priebeh programu (obsah zásobníka)

(1)          (2)                      (3)

                                       fib n=2
              fib n=3                  fib n=3, a=?, b=?, riadok A
main, x=?     main, x=?, riadok C      main, x=?, riadok C

(4)                             (5)
 
fib n=1                         
fib n=2, a=?, b=?, riadok A     fib n=2, a=1, b=?, riadok A
fib n=3, a=?, b=?, riadok A     fib n=3, a=?, b=?, riadok A
main, x=?, riadok C             main, x=?, riadok C             


(6)                             (7)
 
fib n=0                         
fib n=2, a=1, b=?, riadok B     fib n=2, a=1, b=0, riadok B
fib n=3, a=?, b=?, riadok A     fib n=3, a=?, b=?, riadok A
main, x=?, riadok C             main, x=?, riadok C             


(8)                             (9)

                                fib n=1
fib n=3, a=1, b=?, riadok A     fib n=3, a=1, b=?, riadok B 
main, x=?, riadok C             main, x=?, riadok C                 


(10)                            (11)

fib n=3, a=1, b=1, riadok B     
main, x=?, riadok C             main, x=2, riadok C 

Postupnosť volaní počas výpočtu vieme znázorniť aj stromovým diagramom:

Fib.png

Pozor, priamočiary rekurzívny zápis výpočtu Fibonacciho čísel je neefektívny, lebo výpočet Fibonacciho čísel sa opakuje, čas výpočtu rastie exponenciálne od n

  • Napr. pre n=5 počítame fib(2) trikrát, pre n=6 päťkrát a pre n=20 až 4181-krát
  • Fibonacciho čísla je teda lešie počítať nerekurzívnymi metódami z minulej prednášky, ktorých čas je lineárny od n
  • Iné ukážky z minulej prednášky (faktoriál, gcd, binárne vyhľadávanie) nevedú v rekurzívnej forme takémuto extrémnemu spomaleniu a teda väčšinou nie je problém v nich rekurziu použiť

Vypisovanie variácií s opakovaním

Vypíšte všetky trojice cifier, pričom každá cifra je z množiny {0..n-1} a cifry sa môžu opakovať (variácie 3-tej triedy z n prvkov). Napr. pre n=2:

000
001
010
011
100
101
110
111

Veľmi jednoduchý program s troma cyklami:

#include <iostream>
using namespace std;

int main() {
    int n;
    cin >> n;
    for(int i = 0; i < n; i++) {
        for(int j = 0; j < n; j++) {
            for(int k = 0; k < n; k++) {
                cout << i << j << k << endl;
            }
        }
    }
}

Rekurzívne riešenie pre všeobecné k

Čo ak chceme k-tice pre všeobecné k? Využijeme rekurziu.

  • Variácie k-tej triedy vieme rozdeliť na n skupín podľa prvého prvku:
    • tie čo začínajú na 0, tie čo začínajú na 1, ..., tie čo začínajú na n-1.
  • V každej skupine ak odoberieme prvý prvok, dostaneme variácie triedy k-1
#include <iostream>
using namespace std;

void vypis(int a[], int k) {
    for (int i = 0; i < k; i++) {
        cout << a[i];
    }
    cout << endl;
}

void generuj(int a[], int i, int k, int n) {
    /* v poli a dlzky k mame prvych i cifier,
     * chceme vygenerovat vsetky moznosti
     * poslednych k-i cifier */
    if (i == k) {
        vypis(a, k);
    } else {
        for (int x = 0; x < n; x++) {
            a[i] = x;
            generuj(a, i + 1, k, n);
        }
    }
}

int main() {
    const int maxK = 100;
    int a[maxK];
    int k, n;
    cout << "Zadajte k a n: ";
    cin >> k >> n;
    generuj(a, 0, k, n);
}


Strom rekurzívnych volaní pre k=3, n=2 (generuj je skrátené na gen, červenou je zobrazený obsah poľa a): Generuj.png

Ďalšie rozšírenia

  • Čo ak chceme všetky k-tice písmen A-Z?
  • Čo ak chceme všetky DNA reťazce dĺžky k (DNA pozostáva z "písmen" A,C,G,T)?
// pouzi n=26
void vypis(int a[], int k) {
    for (int i = 0; i < k; i++) {
        char c = 'A'+a[i];
        cout << c;
    }
    cout << endl;
}

// pouzi n=4
void vypis(int a[], int k) {
    char abeceda[5] = "ACGT";
    for (int i = 0; i < k; i++) {
        cout << abeceda[a[i]];
    }
    cout << endl;
}

Cvičenia

  • Ako by sme vypisovali všetky k-ciferné hexadecimálne čísla (šestnástková sústava), kde používame cifry 0-9 a písmená A-F?
  • Ako by sme vypisovali všetky k-tice písmen v opačnom poradí, od ZZZ po AAA?

Variácie bez opakovania

Teraz chceme vypísať všetky k-tice cifier z množiny {0, ..., n-1}, v ktorých sa žiaden prvok neopakuje (pre k=n dostávame permutácie)

Príklad pre k=3, n=3

012
021
102
120
201
210

Skúšanie všetkých možností

  • Jednoduchá možnosť: použijeme predchádzajúci program a pred výpisom skontrolujeme, či je riešenie správne

Prvý pokus:

bool spravne(int a[], int k, int n) {
    /* je v poli a dlzky k kazde cislo 
     * od 0 po n-1 najviac raz? */
    bool bolo[maxN];
    for (int i = 0; i < n; i++) {
        bolo[i] = false;
    }
    for (int i = 0; i < k; i++) {
        if (bolo[a[i]]) return false;
        bolo[a[i]] = true;
    }
    return true;
}

void generuj(int a[], int i, int k, int n) {
    /* v poli a dlzky k mame prvych i cifier,
     * chceme vygenerovat vsetky moznosti
     * poslednych k-i cifier */
    if (i == k) {
        if (spravne(a, k, n)) {
            vypis(a, k);
        }
    } else {
        for (int x = 0; x < n; x++) {
            a[i] = x;
            generuj(a, i + 1, k, n);
        }
    }
}

Cvičenie: ako by sme napísali funkciu spravne, ak by nedostala ako parameter hodnotu n?

Prehľadávanie s návratom, backtracking

  • Predchádzajúce riešenie je neefektívne, lebo prechádza cez všetky variácie s opakovaním a veľa z nich zahodí.
    • Napríklad pre k=7 a n=10 pozeráme 107 variácií s opakovaním, ale iba 604800 z nich je správnych, čo je asi 6%
  • Len čo sa v poli a vyskytne opakujúca sa cifra, chceme túto vetvu prehľadávania ukončiť, lebo doplnením ďalších cifier problém neodstránime
  • Spravíme funkciu moze(a,i,x), ktorá určí, či je možné na miesto i v poli a dať cifru x
  • Testovanie správnosti vo funkcii generuj sa dá vynechať
bool moze(int a[], int i, int x) {
    /* Mozeme dat hodnotu x na poziciu i v poli a?
     * Mozeme, ak sa nevyskytuje v a[0..i-1] */
    for (int j = 0; j < i; j++) {
        if (a[j] == x) return false;
    }
    return true;
}

void generuj(int a[], int i, int k, int n) {
    /* v poli a dlzky k mame prvych i cifier,
     * chceme vygenerovat vsetky moznosti
     * poslednych k-i cifier */
    if (i == k) {
        vypis(a, k);
    } else {
        for (int x = 0; x < n; x++) {
            if (moze(a, i, x)) {
                a[i] = x;
                generuj(a, i + 1, k, n);
            }
        }
    }
}

Možné zrýchlenie: vytvoríme trvalé pole bolo, v ktorom bude zaznamené, ktoré cifry sa už vyskytli a to použijeme namiesto funkcie moze.

  • Po návrate z rekurzie nesmieme zabudúť príslušnú hodnotu odznačiť!
void generuj(int a[], bool bolo[], int i, int k, int n) {
    /* v poli a dlzky k mame prvych i cifier,
     * v poli bolo su zaznamenane pouzite cifry,
     * chceme vygenerovat vsetky moznosti
     * poslednych k-i cifier */
    if (i == k) {
        vypis(a, k);
    } else {
        for (int x = 0; x < n; x++) {
            if (!bolo[x]) {
                a[i] = x;
                bolo[x] = true;
                generuj(a, bolo, i + 1, k, n);
                bolo[x] = false;
            }
        }
    }
}

int main() {
    const int maxK = 100;
    const int maxN = 100;
    int a[maxK];
    bool bolo[maxN];
    int k, n;
    cout << "Zadajte k a n (k<=n): ";
    cin >> k >> n;
    for (int i = 0; i < n; i++) {
        bolo[i] = false;
    }
    generuj(a, bolo, 0, k, n);
}

Cvičenie: ako potrebujeme zmeniť program, aby sme generovali všetky postupnosti k cifier z množiny {0,..,n-1} také, že z každej cifry sú v postupnosti najviac 2 výskyty?

Technika rekurzívneho prehľadávania všetkých možností s orezávaním beznádejných vetiev sa nazýva prehľadávanie s návratom, po anglicky backtracking.

  • Hľadáme všetky postupnosti, ktoré spĺňajú nejaké podmienky.
    • Vo všeobecnosti nemusia byť rovnako dlhé.
  • Ak máme celú postupnosť, vieme otestovať, či spĺňa podmienku (funkcia spravne).
  • Ak máme časť postupnosti a nový prvok, vieme otestovať, či po pridaní tohto prvku má ešte šancu tvoriť časť riešenia (funkcia moze).
    • Funkcia moze nesmie vrátiť false, ak ešte je možné riešenie.
    • Môže vrátiť true, ak už nie je možné riešenie, ale nevie to ešte odhaliť.
    • Snažíme sa však odhaliť problém čím skôr.
  • Prehľadávanie s návratom môže byť vo všeobecnosti veľmi pomalé, čas výpočtu exponenciálne rastie.

Všeobecná schéma prehľadávania s návratom

void generuj(int a[], int i) {
    /* v poli a dlzky k mame prvych i cisel z riesenia */
    if (spravne(a, i)) { 
        /* ak uz mame cele riesenie, vypiseme ho */
        vypis(a, i);
    } else {
        pre vsetky hodnoty x {
            if (moze(a, i, x)) {
                a[i] = x;
                generuj(a, i + 1);
            }
        }
    }
}

Problém 8 dám

Prehľadávanie s návratom sa dá využiť aj na riešenie rôznych hlavolamov. Tu si ukážeme jeden z nich.


Cieľom je rozmiestniť n dám na šachovnici nxn tak, aby sa žiadne dve navzájom neohrozovali, tj. aby žiadne dve neboli v rovnakom riadku, stĺpci, ani na rovnakej uhlopriečke.

Príklad pre n=4:

 . * . .
 . . . *
 * . . .
 . . * .
  • V každom riadku bude práve jedna dáma, teda riešenie môžeme reprezentovať ako pole damy dĺžky n, kde damy[i] je stĺpec, v ktorom je dáma na riadku i
    • Príklad vyššie by v poli damy mal čísla 1,3,0,2
  • Podobne ako pri generovaní variácií bez opakovania chceme do poľa dať čísla od 1 po n, aby spĺňali ďalšie podmienky (v každom stĺpci a na každej uhlopriečke najviac 1 dáma)
  • Vytvoríme polia, kde si pre každý stĺpec a uhlopriečku pamätáme, či už je obsadená
  • Uhlopriečky v oboch smeroch očíslujeme číslami od 0 po 2n-2
    • V jednom smere majú miesta na uhlopriečke rovnaký súčet, ten teda bude číslom uhlopriečky
    • V druhom smere majú rovnaký rozdiel, ten však môže byť aj záporný, pričítame n-1
  • Pre jednoduchosť použijeme niekoľko globálnych premenných, aby si rekurzívne funkcie nemuseli posielať veľa argumentov
    • Krajšie by bolo dať tieto premenné do struct-u a posielať ten ako argument

Damy-uh1.png Damy-uh2.png

#include <iostream>
using namespace std;

/* globalne premenne */
const int maxN = 100;
int n;                /* velkost sachovnice */
int damy[maxN];       /* damy[i] je stlpec s damou v riadku i*/
bool bolStlpec[maxN]; /* bolStlpec[i] je true ak stlpec i obsadeny */

/* polia, ktore obsahuju true, ak uhlopriecky obsadene */
bool bolaUhl1[2 * maxN - 1];  
bool bolaUhl2[2 * maxN - 1];
int pocet;       /* pocet najdenych rieseni */

int uhl1(int i, int j) {
    /* na ktorej uhlopriecke je riadok i, stlpec j v smere 1? */
    return i + j;
}

int uhl2(int i, int j) {
    /* na ktorej uhlopriecke je riadok i, stlpec j v smere 2? */
    return n - 1 + i - j;
}

void vypis() {
    /* vypis sachovnicu textovo a zvys pocitadlo rieseni */
    pocet++;
    for (int i = 0; i < n; i++) {
        for (int j = 0; j < n; j++) {
            if (damy[i] == j) cout << " *";
            else cout << " .";
        }
        cout << endl;
    }
    cout << endl;
}

void generuj(int i) {
    /* v poli damy mame prvych i dam, dopln dalsie */
    if (i == n) {
        vypis();
    } else {
        for (int j = 0; j < n; j++) {
            /* skus dat damu na riadok i, stlpec j */
            if (!bolStlpec[j]
                && !bolaUhl1[uhl1(i, j)] 
                && !bolaUhl2[uhl2(i, j)]) {
                damy[i] = j;
                bolStlpec[j] = true;
                bolaUhl1[uhl1(i, j)] = true;
                bolaUhl2[uhl2(i, j)] = true;
                generuj(i + 1);
                bolStlpec[j] = false;
                bolaUhl1[uhl1(i, j)] = false;
                bolaUhl2[uhl2(i, j)] = false;
            }
        }
    }
}

int main() {
    cout << "Zadajte velkost sachovnice: ";
    cin >> n;
    for (int i = 0; i < n; i++) {
        bolStlpec[i] = false;
    }
    for (int i = 0; i < 2 * n + 1; i++) {
        bolaUhl1[i] = false;
        bolaUhl2[i] = false;
    }

    /* rekuzia */
    pocet = 0;
    generuj(0);
    cout << "Pocet rieseni: " << pocet << endl;
}

Generovanie všetkých podmnožín

Chceme vypísať všetky podmnožiny množiny {0,..,m-1}. Na rozdiel od variácií nám v podmnožine nezáleží na poradí (napr. {0,1} = {1,0}), prvky teda budeme vždy vypisovať od najmenšieho po najväčší. Napr. pre m=2 máme podmnožiny

{}
{0}
{1}
{0,1}

Podmnožinu vieme vyjadriť ako binárne pole dĺžky m,

  • a[i]=0 znamená, že i nepatrí do množiny a a[i]=1 znamená, že patrí.
  • Napríklad podmnožina {0,2,3} množiny {0,1,2,3,4} sa zapíše ako pole 1,0,1,1,0.

Teda môžeme použiť program variácie s opakovaním pre n=2, k=m a zmeniť iba výpis:

void vypis(int a[], int m) {
    cout << "{";
    bool prve = true;
    for (int i = 0; i < m; i++) {
        if (a[i] == 1) {
            if (prve) {
                cout << i;
                prve = false;
            } else {
                cout << "," << i;
            }
        }
    }
    cout << "}" << endl;
}
  • V premennej prve si pamätáme, či máme oddeliť ďalšie vypisované číslo od predchádzajúceho.
    • Ak ešte žiadne nebolo, oddeľovač je prázdny reťazec.
    • Ak už sme niečo vypísali, oddeľovač je čiarka.

Namiesto poľa intov môžeme použiť pole boolovských hodnôt a celý program trochu prispôsobiť tomu, že generujeme podmnožiny:

#include <iostream>
#include <cstring>
using namespace std;

void vypis(bool a[], int m) {
    cout << "{";
    bool prve = true;
    for (int i = 0; i < m; i++) {
        if (a[i]) {
            if (prve) {
                cout << i;
                prve = false;
            } else {
                cout << "," << i;
            }
        }
    }
    cout << "}" << endl;
}

void generuj(bool a[], int i, int m) {
    /* v poli a dlzky k mame rozhodnutie o prvych i
     * prvkoch, chceme vygenerovat vsetky podmnoziny
     * prvkov {i..m-1} */
    if (i == m) {
        vypis(a, m);
    } else {
        a[i] = true;
        generuj(a, i + 1, m);
        a[i] = false;
        generuj(a, i + 1, m);
    }
}

int main() {
    const int maxM = 100;
    int m;
    cin >> m;
    bool a[maxM];
    generuj(a, 0, m);
}

Pre n=3 program vypíše:

{0,1,2}
{0,1}
{0,2}
{0}
{1,2}
{1}
{2}
{}

Cvičenie: Čo by program vypísal, ak by sme prehodili true a false v rekurzii?

Generovanie podmnožín využijeme na budúcej prednáške na riešenie problému batoha, čo je jeden z dôležitých praktických problémov, pre ktoré nepoznáme efektívne algoritmy.

Zhrnutie

  • Videli sme ako rekurzívne generovať všetky postupnosti spĺňajúce určité požiadavky.
  • Ak máme špeciálne požiadavky, napr. že žiadne číslo sa neopakuje, môžeme buď generovať všetky postupnosti a testovať to pred výpisom, alebo už počas generovania urezávať neperspektívne vetvy výpočtu, čo je rýchlejšie.
  • Táto technika sa volá prehľadávanie s návratom (backtracking).
  • Pozor, čas výpočtu prudko (exponenciálne) rastie s dĺžkou postupností, takže vhodné len pre malé vstupy.
  • Dve ukážky: problém 8 dám, problém batoha (budúca prednáška).

Prednáška 11

Oznamy

  • DÚ2 bude zverejnená po prednáške, odovzdávajte do utorka 19.11. 22:00
  • V utorok 5.11. na cvičeniach bude krátky test podobne ako na prednáške 7.10.
    • Bude zahŕňať učivo po minulú prednášku.
    • Môžete si priniesť ťahák 1 list A4. Používanie počítača nebude počas testu povolené.
    • Ide o súčasť cvičení 7, takže študenti, ktorí majú cvičenia 7 uznané na základe testu pre pokročilých, nemusia prísť.
    • Po teste pokračujete s riešením príkladov z cvičení na počítači.
  • Plán na dnes: najskôr si ukážeme dva rekurzívne algoritmy na triedenie, potom sa vrátime k minulej téme prehľadávania s návratom.

Rýchle triedenia prostredníctvom paradigmy „rozdeľuj a panuj”

Doposiaľ sme prebrali tri triediace algoritmy: Bubble Sort, Insertion Sort a Max Sort. Všetky sú jednoduché, ale pomalé: majú kvadratickú zložitosť O(n2).

Dnes pridáme ďalšie dve triedenia, ktoré budú pre veľké polia omnoho rýchlejšie: Merge Sort a Quick Sort. Obe sú založené na paradigme rozdeľuj a panuj (angl. divide and conquer, lat. divide et impera).

Rozdeľuj a panuj je paradigma rekurzívneho riešenia problémov pracujúca v troch fázach:

  • Rozdeľuj: problém rozdelíme na menšie časti (t.j. podproblémy), ktoré sa dajú riešiť samostatne.
  • Vyrieš podproblémy: rekurzívne vyriešime úlohu pre každý podproblém.
  • Panuj: riešenia podproblémov spojíme do riešenia pôvodného problému.

Triedenie zlučovaním (Merge Sort)

Triedenie zlučovaním (angl. Merge Sort) pracuje nasledovne:

  • Pole rozdelíme na dve približne rovnaké časti
  • Každú časť zvlášť rekurzívne utriedime
  • Vo fáze „panuj” sa tieto dve utriedené postupnosti zlúčia (angl. merge) do výsledného utriedeného poľa.

Príklad:

Vstupné pole rozdelené na polovicu:
|6 1 5 7 2|4 8 9 3 0|
Po rekurzívnom utriedení ľavej časti
|1 2 5 6 7|4 8 9 3 0|
Po rekurzívnom utriedení pravej časti
|1 2 5 6 7|0 3 4 8 9|
Po zlúčení
|0 1 2 3 4 5 6 7 8 9|

Triedenie zlučovaním tak bude vyzerať nasledovne (pričom zostáva implementovať funkciu merge):

void mergesort(int a[], int left, int right) {
/* Funkcia utriedi prvky pola a[] od indexu left po index right (vratane). */
    
    /* Trivialne pripady: */
    if (left >= right) {
        return;           
    }
    
    /* Rozdeluj -- spocitaj priblizny stred triedeneho useku: */
    int middle = (left + right) / 2;
          
    /* Rekurzivne vyries podproblemy: */
    mergesort(a, left, middle);
    mergesort(a, middle + 1, right);
       
    /* Panuj -- zluc obe utriedene casti do jednej: */ 
    merge(a, left, middle, right);
}

Zlúčenie dvoch utriedených podpostupností

Zostáva naprogramovať zlúčenie dvoch utriedených postupností a[left..middle], a[middle+1..right] do jednej utriedenej postupnosti.

Príklad:

|1 2 5 6 7|0 3 4 8 9| -> |0 1 2 3 4 5 6 7 8 9|

Zlúčenú postupnosť budeme postupne ukladať do pomocného poľa aux, pričom postupovať budeme nasledovne:

  • Prvým prvkom poľa aux bude menší z prvkov a[left] a a[middle+1].
    • Ostanú nám postupnosti a[left+1..middle] a a[middle+1..right] alebo a[left..middle] a a[middle+2..right].
  • Vo všeobecnosti máme postupnosti a[i..middle] a a[j..right].
    • Ďalším prvkom poľa aux bude menší z prvkov a[i] a a[j]
    • Ostanú nám postupnosti a[i+1..middle] a a[j..right] alebo a[i..middle] a a[j+1..right].
  • Toto robíme dovtedy, kým niektorú z postupností nevyčerpáme celú. Potom už len na koniec poľa aux dokopírujeme zvyšok druhej postupnosti.

Po spojení oboch postupností do utriedeného poľa aux toto pole prekopírujeme naspäť do poľa a.

void merge(int a[], int left, int middle, int right) {
    int aux[maxN];
    int i = left;       // index v prvej postupnosti
    int j = middle + 1; // index v druhej postupnosti
    int k = 0;          // index v poli aux
    
    while (i <= middle && j <= right) { 
        // Kym su obe postupnosti a[i..middle], a[j..right] neprazdne,
        // mensi z prvkov a[i], a[j] uloz do aux[k] a posun indexy
        if (a[i] <= a[j]) {                         
            aux[k] = a[i];
            i++;
            k++;
        } else {
            aux[k] = a[j];
            j++;
            k++;
        }
    }
    
    while (i <= middle) {  
        // Ak nieco ostalo v prvej postupnosti, dokopiruj ju na koniec
        aux[k] = a[i];
        i++;
        k++;
    }
    
    while (j <= right) { 
        // Ak nieco ostalo v druhej postupnosti, dokopiruj ju na koniec
        aux[k] = a[j];
        j++;
        k++;
    }
    
    for (int t = left; t <= right; t++) { 
        // Prekopiruj pole aux naspat do pola a 
        // pozicia 0 v aux pojde na poziciu left v a
        a[t] = aux[t - left];
    }
}

Ukážka na príklade

Uvažujme pole a = {6, 1, 5, 7, 2, 4, 8, 9, 3, 0}.

Volanie mergesort(a,0,9) potom utriedi pole a pomocou nasledujúcich rekurzívnych volaní (mergesort(a,l,h) je na obrázku skrátené na sort(l,h) a merge(a,l,m,h) na merge(l,m,h), žltou sú vyznačené pozície v poli, ktoré sa v danom volaní triedia):

Mergesort.png

Pole a sa počas týchto volaní mení nasledovne:

merge(a,0,0,1): |6|1|5 7 2 4 8 9 3 0  -> |1 6|5 7 2 4 8 9 3 0
merge(a,0,1,2): |1 6|5|7 2 4 8 9 3 0  -> |1 5 6|7 2 4 8 9 3 0
merge(a,3,3,4):  1 5 6|7|2|4 8 9 3 0  ->  1 5 6|2 7|4 8 9 3 0
merge(a,0,2,4): |1 5 6|2 7|4 8 9 3 0  -> |1 2 5 6 7|4 8 9 3 0
merge(a,5,5,6):  1 2 5 6 7|4|8|9 3 0  ->  1 2 5 6 7|4 8|9 3 0
merge(a,5,6,7):  1 2 5 6 7|4 8|9|3 0  ->  1 2 5 6 7|4 8 9|3 0
merge(a,8,8,9):  1 2 5 6 7 4 8 9|3|0| ->  1 2 5 6 7 4 8 9|0 3|
merge(a,5,7,9):  1 2 5 6 7|4 8 9|0 3| ->  1 2 5 6 7|0 3 4 8 9|
merge(a,0,4,9): |1 2 5 6 7|0 3 4 8 9| -> |0 1 2 3 4 5 6 7 8 9|

Odhad zložitosti

Zlučovanie dvoch postupností, ktoré spolu obsahujú N prvkov, trvá čas O(N). Prečo?

V algoritme máme log2 N úrovní rekurzie:

  • na prvej spracovávame úseky dĺžky N,
  • na druhej N/2, na tretej N/4 atď,
  • po log2 N úrovniach dostaneme úseky dĺžky 1.

Na každej úrovni je každý prvok najviac v jednom zlučovaní, teda celkový čas zlučovaní na každej úrovni je O(N). Celkový čas výpočtu je O(N log N).

Výsledný program

#include <iostream>
using namespace std;

const int maxN = 1000;

void merge(int a[], int left, int middle, int right) {
    int aux[maxN];
    int i = left;
    int j = middle + 1;
    int k = 0;
    
    while (i <= middle && j <= right) {
        if (a[i] <= a[j]) {
            aux[k] = a[i];
            i++;
            k++;
        } else {
            aux[k] = a[j];
            j++;
            k++;
        }
    }
    
    while (i <= middle) {
        aux[k] = a[i];
        i++;
        k++;
    }
    
    while (j <= right) {
        aux[k] = a[j];
        j++;
        k++;
    }
    
    for (int t = left; t <= right; t++) {
        a[t] = aux[t - left];
    }
}

void mergesort(int a[], int left, int right) {
    if (left >= right) {
        return;
    }
    
    int middle = (left + right) / 2;
          
    mergesort(a, left, middle);
    mergesort(a, middle + 1, right);
       
    merge(a, left, middle, right);
}

int main() {
    int N;
    int a[maxN];
    
    cout << "Zadaj pocet cisel: ";
    cin >> N;
    cout << "Zadaj " << N << " cisel: ";
    for (int i = 0; i < N; i++) {
        cin >> a[i];
    }
    
    mergesort(a,0,N-1);
    
    cout << "Utriedene cisla:";
    for (int i = 0; i < N; i++) {
        cout << " " << a[i];
    }                        
    cout << endl;
}

Quick Sort

Quick Sort je tiež založený na metóde rozdeľuj a panuj. Postupuje ale nasledovne:

  • V rámci fázy rozdeľuj vyberie niektorý prvok poľa (napríklad jeho prvý prvok), ktorý nazve pivotom. Prvky poľa následne preusporiada na tri skupiny: v ľavej časti poľa budú prvky menšie ako pivot, za nimi pivot samotný a napokon v pravej časti prvky väčšie alebo rovné ako pivot.
  • Rekurzívne utriedi prvú a tretiu skupinu (druhá skupina má iba jeden prvok)
  • Vo fáze panuj už potom nemusí robiť nič – po utriedení spomínaných dvoch skupín totiž vznikne utriedené pole.

Príklad:

Vstupné pole:
|6 1 5 7 2 4 8 9 3 0| 
Po rozdelení s pivotom 6:
|0 1 5 2 4 3|6|9 7 8|
Po rekurzívnom utriedení ľavej časti:
|0 1 2 3 4 5|6|9 7 8|
Po rekurzívnom utriedení pravej časti:
|0 1 2 3 4 5|6|7 8 9|

Základ triedenia tak bude vyzerať nasledovne (pričom zostáva implementovať funkciu partition):

void quicksort(int a[], int left, int right) {
/* Utriedi cast pola a[] od indexu left po index right (vratane) */

    /* Trivialne pripady: */ 
    if (left >= right) {
        return; 
    }
    
    /* Rozdel pole na tri podpostupnosti: */ 
    int middle = partition(a, left, right); 
    // Po vykonani funkcie: 
    // a[left..middle-1] su mensie ako pivot 
    // a[middle] je pivot
    // a[middle+1..right] su vacsie ako pivot
    
    /* Rekurzivne utried a[left..middle-1], a[middle+1..right]: */    
    quicksort(a, left, middle-1);  
    quicksort(a, middle+1, right);   
}

Funkcia partition

Funkcia partition na vstupe dostane pole a spolu s hraničnými indexami left a right. Prvok a[left] vyberie ako pivot a postupnosť a[left..right] preusporiada tak, aby pre nejakú hodnotu middle takú, že left <= middle <= right platilo nasledovné:

  • Prvky a[left],...,a[middle-1] sú menšie, než pivot.
  • Prvok a[middle] je pivot.
  • Prvky a[middle+1],...,a[right] sú väčšie alebo rovné ako pivot.

Hodnotu middle potom funkcia partition vráti ako svoj výstup.

Funkcia partition udržiava nasledujúce invarianty:

  • Prvok a[left] je pivot.
  • Prvky a[left+1],...,a[lastSmaller] sú menšie ako pivot.
  • Prvky a[lastSmaller+1],...,a[unknown-1] sú väčšie alebo rovné ako pivot.
  • Prvky a[unknown],...,a[right] sa ešte s pivotom neporovnávali.

Funkcia partition zakaždým porovnáva prvok a[unknown] s pivotom:

  • Ak je menší ako pivot, je nutné „presunúť ho doľava”; vymení ho teda s a[lastSmaller+1] a hodnotu lastSmaller zvýši o jedna.
  • Ak je väčší alebo rovný ako pivot, môže ostať na svojom mieste.

Následne zvýši index unknown o jedna a tento postup opakuje, až kým prejde cez všetky prvky danej časti poľa.

Nakoniec je ešte nutné vymeniť a[left] s a[lastSmaller], čím sa pivot dostane na svoje miesto.

void swap (int &x, int &y) {
    int tmp = x;
    x = y;
    y = tmp;
}

int partition(int a[], int left, int right) {
    // Ak za pivot chceme zvolit iny prvok, vymenime ho najprv s a[left]
    int pivot = a[left];     
    int lastSmaller = left;
    
    for (int unknown = left + 1; unknown <= right; unknown++) {
        if (a[unknown] < pivot) {
            lastSmaller++;
            swap(a[unknown], a[lastSmaller]);
        }
    }   
    swap(a[left],a[lastSmaller]); 
    return lastSmaller;
}

Ukážka na príklade

Opäť uvažujme pole a = {6, 1, 5, 7, 2, 4, 8, 9, 3, 0}.

Volanie quicksort(0,9) potom utriedi pole a pomocou nasledujúcich rekurzívnych volaní (namiesto quicksort(a,l,h) píšeme zakaždým len sort(l,h)):

sort(0,9) sort(0,5) sort(0,-1)
          .         sort(1,5) sort(1,0)
          .         .         sort(2,5) sort(2,4) sort(2,2)
          .         .         .         .         sort(4,4)
          .         .         .         sort(6,5)
          sort(7,9) sort(7,8) sort(7,7)
                    .         sort(9,8)
                    sort(10,9)

Volania funkcie partition sú počas tohto behu nasledovné:

partition(a,0,9): |6 1 5 7 2 4 8 9 3 0| -> |0 1 5 2 4 3|6|9 7 8|
partition(a,0,5): |0 1 5 2 4 3|6 9 7 8  -> |0|1 5 2 4 3|6 9 7 8
partition(a,1,5):  0|1 5 2 4 3|6 9 7 8  ->  0|1|5 2 4 3|6 9 7 8
partition(a,2,5):  0 1|5 2 4 3|6 9 7 8  ->  0 1|3 2 4|5|6 9 7 8
partition(a,2,4):  0 1|3 2 4|5 6 9 7 8  ->  0 1|2|3|4|5 6 9 7 8
partition(a,7,9):  0 1 2 3 4 5 6|9 7 8| ->  0 1 2 3 4 5 6|8 7|9|
partition(a,7,8):  0 1 2 3 4 5 6|8 7|9  ->  0 1 2 3 4 5 6|7|8|9

Cvičenie:

  • Ako sa bude Quick Sort správať, keď na vstupe dostane už utriedené pole?
  • Ako sa bude správať, keď na vstupe dostane zostupne utriedené pole?

Odhad zložitosti

V ideálnom prípade pivot rozdelí pole na dve rovnako veľké časti. Vtedy je čas výpočtu O(N log N), podobne ako pre Mergesort.

Nepríjemné je, keď pivot je vždy najmenší alebo najväčší prvok v danom úseku. Vtedy je čas O(N2).

  • Aby sme sa vyhli problémom, v praxi sa ako pivot často vyberá náhodný prvok z intervalu.

Výsledný program

#include <iostream>
using namespace std;

const int maxN = 1000;

void swap (int &x, int &y) {
    int tmp = x;
    x = y;
    y = tmp;
}

int partition(int a[], int left, int right) {
    int pivot = a[left];
    int lastSmaller = left;
    
    for (int unknown = left + 1; unknown <= right; unknown++) {
        if (a[unknown] < pivot) {
            lastSmaller++;
            swap(a[unknown], a[lastSmaller]);
        }
    }   
    swap(a[left],a[lastSmaller]); 
    return lastSmaller;
}

void quicksort(int a[], int left, int right) {
    if (left >= right) {
        return; 
    }
    
    int middle = partition(a, left, right);
        
    quicksort(a, left, middle-1);  
    quicksort(a, middle+1, right);   
}

int main() {
    int N;
    int a[maxN];
    
    cout << "Zadaj pocet cisel: ";
    cin >> N;
    cout << "Zadaj " << N << " cisel: ";
    for (int i = 0; i <= N-1; i++) {
        cin >> a[i];
    }
    
    quicksort(a,0,N-1);
    
    cout << "Utriedene cisla:";
    for (int i = 0; i <= N-1; i++) {
        cout << " " << a[i];
    }                        
    cout << endl;
}

Iná implementácia

Občas sa možno stretnúť aj s nasledujúcou implementáciou triedenia Quick Sort. Skúste samostatne odôvodniť jej správnosť.

void quicksort(int a[], int left, int right) {
    if (left >= right) {
        return;
    }
    
    /* partition */
    int pivot = a[(left + right)/2];
    int i = left;
    int j = right;
    
    while (i <= j) {
        while (a[i] < pivot) i++;
        while (a[j] > pivot) j--;
        if (i <= j) {
            swap(a[i],a[j]);
            i++; j--;
        }
    }
    
    /* rekurzia */    
    quicksort(a, left, j);
    quicksort(a, i, right);   
}

Triediace algoritmy: zhrnutie

Jednoduché triedenia: Bubble Sort, Insertion Sort, Max Sort.

  • Jednoduché, ale pomalé: zložitosť O(n2).

Rekurzívne triedenia založené na technike rozdeľuj a panuj.

  • Rýchlejšie, zložitejšie.
  • Merge Sort: zložitosť O(n log n).
  • Quick Sort: zložitosť O(n2) v najhoršom prípade, pre väčšinu vstupov O(n log n), väčšinou rýchlejší ako Merge Sort.

Reálnu rýchlosť triedení na náhodne zvolenom veľkom vstupe možno porovnať napríklad nasledujúcim programom:

#include <iostream>
#include <cstdlib>
#include <ctime>
using namespace std;

const int maxN = 100000;

void insertionsort(int a[], int n) {
    for (int i = 1; i < n; i++) {
        int prvok = a[i];
        int kam = i;
        while (kam > 0 && a[kam - 1] > prvok) {
            a[kam] = a[kam - 1];
            kam--;
        }
        a[kam] = prvok;
    }
}

void merge(int a[], int left, int mid, int right) {
    int aux[maxN];
    int i = left;
    int j = mid + 1;
    int k = 0;

    while ((i <= mid) && (j <= right)) {
        if (a[i] <= a[j]) {
            aux[k] = a[i];
            i++;
            k++;
        } else {
            aux[k] = a[j];
            j++;
            k++;
        }
    }

    while (i <= mid) {
        aux[k] = a[i];
        i++;
        k++;
    }

    while (j <= right) {
        aux[k] = a[j];
        j++;
        k++;
    }

    for (int k = left; k <= right; k++) {
        a[k] = aux[k - left];
    }
}

void mergesort(int a[], int left, int right) {
    if (left >= right) {
        return;
    }

    int mid = (left + right) / 2;

    mergesort(a, left, mid);
    mergesort(a, mid + 1, right);

    merge(a, left, mid, right);
}

void swap(int &x, int &y) {
    int tmp = x;
    x = y;
    y = tmp;
}

int partition(int a[], int left, int right) {
    int pivot = a[left];
    int lastSmaller = left;

    for (int unknown = left + 1; unknown <= right; unknown++) {
        if (a[unknown] < pivot) {
            lastSmaller++;
            swap(a[unknown], a[lastSmaller]);
        }
    }
    swap(a[left], a[lastSmaller]);
    return lastSmaller;
}

void quicksort(int a[], int left, int right) {
    if (left >= right) {
        return;
    }

    int mid = partition(a, left, right);

    quicksort(a, left, mid - 1);
    quicksort(a, mid + 1, right);
}

int main() {
    int N;
    int a1[maxN];
    int a2[maxN];
    int a3[maxN];

    cout << "Zadaj pocet nahodnych cisel v poli: ";
    cin >> N;

    srand(time(NULL));
    for (int i = 0; i <= N - 1; i++) {
        a1[i] = rand() % 1000;
        a3[i] = a2[i] = a1[i];
    }

    clock_t start1, end1, start2, end2, start3, end3;

    start1 = clock();
    insertionsort(a1, N);
    end1 = clock();

    start2 = clock();
    mergesort(a2, 0, N - 1);
    end2 = clock();

    start3 = clock();
    quicksort(a3, 0, N - 1);
    end3 = clock();

    cout << "Insertion sort: " 
	 << (end1 - start1) * 1.0 / CLOCKS_PER_SEC << " CPU sekund" << endl;
    cout << "Merge sort: " 
	 << (end2 - start2) * 1.0 / CLOCKS_PER_SEC << " CPU sekund" << endl;
    cout << "Quick sort: " 
	 << (end3 - start3) * 1.0 / CLOCKS_PER_SEC << " CPU sekund" << endl;

}

Pre N = 100000 môžeme dostať napríklad nasledujúci výstup (líši sa od počítača k počítaču a od volania k volaniu):

Insertion sort: 7.474 CPU sekund
Merge sort: 0.076 CPU sekund
Quick sort: 0.032 CPU sekund

Problém batoha (Knapsack problem)

Dnes sme videli ako použiť rekurziu v rýchlych algoritmoch, teraz sa však vráťme k prehľadávaniu s návratom z minulej prednášky, čo je pomalá metóda pre prípady, keď nepoznáme lepší algoritmus.

Metódu prehľadávania s návratom využijeme na riešenie problému batoha. Ide o dôležitý problém, s ktorým sa ešte počas štúdia stretnete. Predstaviť si ho môžeme napríklad takto:

  • Zlodej sa vlúpal do obchodu, v ktorom našiel niekoľko predmetov.
  • Pozná cenu aj hmotnosť predmetov.
  • Z obchodu dokáže odniesť iba lup nepresahujúci nosnosť svojho batoha.
  • Ktoré predmety má zlodej odcudziť, aby ich celková hmotnosť nepresahovala nosnosť batoha a aby odišiel s čo najcennejším lupom?

Vstup nášho programu bude vyzerať napríklad nejako takto:

Zadaj pocet predmetov v obchode: 3
Zadaj hmotnost a cenu predmetu 1: 5 9
Zadaj hmotnost a cenu predmetu 2: 4 6
Zadaj hmotnost a cenu predmetu 3: 4 4
Zadaj nosnost batoha: 8

Výstup programu na horeuvedenom vstupe potom bude takýto:

Zober nasledujuce predmety: 2 3
Celkova hodnota lupu: 10

Pri reálnom použití nosnosť batoha môže reprezentovať dostupné zdroje, napr. výpočtový čas na serveri, dostupných pracovníkov, veľkosť rozpočtu a pod, a predmety sú potenciálne úlohy, z ktorých si chceme vybrať podmnožinu, ktorú by sme s danými zdrojmi vedeli vykonať a dosiahnuť čo najvyšší zisk alebo iný ukazovateľ.

Prvé riešenie: preskúmanie všetkých podmnožín

  • Preskúmame všetky podmnožiny množiny predmetov v obchode, čiže všetky potenciálne lupy.
  • Na to upravíme program generujúci všetky podmnožiny danej množiny.
  • Pre každú podmnožinu namiesto výpisu spravíme nasledovné:
    • Spočítame celkovú hmotnosť a cenu nájdeného potenciálneho lupu.
    • Ak hmotnosť tohto lupu nepresahuje nosnosť batoha, porovnáme jeho cenu s najlepším doposiaľ nájdeným lupom.
    • Ak je cennejší, ako doposiaľ najlepší lup, ide o nového kandidáta na optimálny lup a zapamätáme si ho.


  • Pre jednoduchosť použijeme v programe globálne premenné, lebo potrebujeme veľa údajov
    • Globálne premenné spôsobujú problémy vo väčších programoch: mená premenných sa môžu "biť", môžeme si omylom prepísať číslo dôležité v inej časti programu
    • Mohli by sme si tiež spraviť struct obsahujúci všetky premenné potrebné v rekurzii a odovzdávať si ten


Podmnožiny budeme reprezentovať poľom typu bool, v ktorom si pre každý predmet pamätáme, či do danej podmnožiny patrí.

#include <iostream>
using namespace std;

const int maxN = 100;

/* Struktura reprezentujuca jeden predmet */
struct predmet {
    int hmotnost;
    int cena;
}; 

/* Globalne premenne pouzivane v rekurzii: */

// pocet predmetov v obchode
int N;                         
// pole s udajmi o jednotlivych predmetoch
predmet a[maxN];                 
// nosnost batoha
int nosnost;                     

// najcennejsi doposial najdeny lup
bool najlepsiLup[maxN];          
// jeho cena (kazdy lup bude urcite cennejsi ako -1)
int cenaNajlepsiehoLupu = -1;    

int spocitajHmotnostLupu(bool lup[]) {
    int hmotnost = 0;
    for (int i = 0; i < N; i++) {
        if (lup[i]) {
            hmotnost += a[i].hmotnost;
        }
    }
    return hmotnost;
}

int spocitajCenuLupu(bool lup[]) {
    int cena = 0;
    for (int i = 0; i < N; i++) {
        if (lup[i]) {
            cena += a[i].cena;
        }
    }
    return cena;
}

void vypisLup(bool lup[]) {
    cout << "Zober nasledujuce predmety:";
    for (int i = 0; i < N; i++) {
        if (lup[i]) {
             cout << " " << i + 1;
        }            
    }
    cout << endl;
}

/* Generovanie vsetkych moznych lupov (podmnozin predmetov) */
void generujLupy(bool lup[], int index) {
    /* V poli lup[] dlzky N postupne generujeme podmnoziny.
       O hodnotach lup[0],...,lup[index-1] uz je rozhodnute.
       Postupne vygenerujeme vsetky moznosti 
       pre lup[index],...,lup[N-1].
       Kazdy lup porovname s doposial najlepsim 
       a v pripade potreby optimum aktualizujeme.    
    */
    if (index == N) {                                
        // Lup je vygenerovany; zisti, ci ho batoh unesie.                               
        if (spocitajHmotnostLupu(lup) <= nosnost) {  
            // Ak ano, porovnaj cenu s doposial najlepsim.
            int cenaLupu = spocitajCenuLupu(lup);
            if (cenaLupu > cenaNajlepsiehoLupu) {    
                // Ak je najdeny lup drahsi, uloz ho
                cenaNajlepsiehoLupu = cenaLupu;
                for (int i = 0; i < N; i++) {
                    najlepsiLup[i] = lup[i];
                } 
            }
        }
    } else {                                         
        // Lup este nie je vygenerovany,
        // skus obe moznosti pre lup[index]. 
        lup[index] = false;
        generujLupy(lup, index+1);
        lup[index] = true;
        generujLupy(lup, index+1);
    }
} 

int main() {
    cout << "Zadaj pocet predmetov v obchode: ";
    cin >> N;
    for (int i = 0; i < N; i++) {
        cout << "Zadaj hmotnost a cenu predmetu " 
             << (i+1) << ": ";
        cin >> a[i].hmotnost >> a[i].cena;
    }
    cout << "Zadaj nosnost batoha: ";
    cin >> nosnost;
    
    bool lup[maxN];
    generujLupy(lup, 0);
    
    vypisLup(najlepsiLup);
    cout << "Celkova hodnota lupu: " 
         << cenaNajlepsiehoLupu << endl;
}

Cvičenie: Čo bude program robiť, keď každý predmet má hmotnosť väčšiu ako nosnosť batoha?

Optimalizácia č. 1: ukončenie prehľadávania vždy, keď je prekročená nosnosť

Keď je už po vygenerovaní nejakej podmnožiny (čiže prvých niekoľko hodnôt poľa lup) jasné, že hmotnosť lupu bude presahovať nosnosť batoha, možno túto vetvu prehľadávania ukončiť.

Okrem samotnej funkcie generujLupy je potrebné prispôsobiť aj funkciu spocitajHmotnostLupu tak, aby ju bolo možné aplikovať aj na neúplne vygenerované podmnožiny.

/* Potrebujeme vediet spocitat hmotnost len pre cast predmetov: */

int spocitajHmotnostLupu(bool lup[], int pokial) {
    int hmotnost = 0;
    for (int i = 0; i <= pokial; i++) {
        if (lup[i]) {
            hmotnost += a[i].hmotnost;
        }
    }
    return hmotnost;
}

void generujLupy(bool lup[], int index) {
    if (spocitajHmotnostLupu(lup, index-1) > nosnost) {
        // Ak dosial vygenerovana cast lupu presahuje nosnost batoha, 
        // mozno prehladavanie ukoncit
        return;  
    }
    if (index == N) {
        int cenaLupu = spocitajCenuLupu(lup);
        if (cenaLupu > cenaNajlepsiehoLupu) {
            cenaNajlepsiehoLupu = cenaLupu;
            for (int i = 0; i < N; i++) {
                najlepsiLup[i] = lup[i];
            } 
        }
    } else {
        lup[index] = false;
        generujLupy(lup, index+1);
        lup[index] = true;
        generujLupy(lup, index+1);
    }
}

Cvičenie: Čo bude program robiť, keď každý predmet má hmotnosť väčšiu ako nosnosť batoha?

Optimalizácia č. 2: hmotnosť a cenu lupu netreba zakaždým počítať odznova

Predchádzajúci program vždy znovu a znovu prepočítava hmotnosť a cenu lupu, aj keď sa zoznam vybraných predmetov zmení iba trochu. Namiesto toho môžeme cenu a hmotnosť doposiaľ vygenerovanej časti lupu predávať funkcii generuj ako parameter.

#include <iostream>
using namespace std;

const int maxN = 100;

struct predmet {
    int hmotnost;
    int cena;
}; 

int N;
predmet a[maxN];
int nosnost;

bool najlepsiLup[maxN];
int cenaNajlepsiehoLupu = -1;

void vypisLup(bool lup[]) {
    cout << "Zober nasledujuce predmety:";
    for (int i = 0; i < N; i++) {
        if (lup[i]) {
            cout << " " << i + 1;
        }    
    }
    cout << endl;
}

void generujLupy(bool lup[], int index, int hmotnostLupu, int cenaLupu) {
    if (hmotnostLupu > nosnost) {
        return;
    }
    if (index == N) {
        if (cenaLupu > cenaNajlepsiehoLupu) {
            cenaNajlepsiehoLupu = cenaLupu;
            for (int i = 0; i < N; i++) {
                najlepsiLup[i] = lup[i];
            } 
        }
    } else {
        lup[index] = false;
        generujLupy(lup, index+1, hmotnostLupu, cenaLupu);
        lup[index] = true;
        generujLupy(lup, index+1, 
                    hmotnostLupu + a[index].hmotnost, 
                    cenaLupu + a[index].cena);
    }
} 

int main() {
    cout << "Zadaj pocet predmetov v obchode: ";
    cin >> N;
    for (int i = 0; i < N; i++) {
        cout << "Zadaj hmotnost a cenu predmetu " 
             << (i+1) << ": ";
        cin >> a[i].hmotnost >> a[i].cena;
    }
    cout << "Zadaj nosnost batoha: ";
    cin >> nosnost;
    
    bool lup[maxN];
    // Doposial nie je nic vygenerovane; 
    // hmotnost aj cena lupu su zatial nulove
    generujLupy(lup, 0, 0, 0); 
    
    cout << endl;
    vypisLup(najlepsiLup);
    cout << "Celkova hodnota lupu: " 
         << cenaNajlepsiehoLupu << endl;
}

Prednáška 12

Oznamy

  • DÚ2 zverejnená, odovzdávajte do utorka 19.11. 22:00.
  • Dnes nová téma: smerníky a práca s pamäťou.
  • Zajtra na cvičeniach rozcvička na papieri a ďalšie tri príklady.
  • Piatkové cvičenia sú dobrovoľné.

Ukazovateľ, smerník, pointer

  • Pamäť v počítači je rozdelená na dieliky, napr. bajty
  • Každá premenná zaberá niekoľko takýchto dielikov
  • Každý dielik má adresu (poradové číslo)
  • Ukazovateľ (resp. smerník alebo pointer) je premenná, ktorej hodnota je adresa iného dieliku pamäte
  • Na obrázku je na adrese 24 uložená premenná x typu int, ktorej hodnota je 17.
  • Na adrese 40 je uložený smerník p, ktorého hodnota je 24.
    • Hovoríme, že smerník p ukazuje na premennú x, zjednodušene to budeme kresliť ako šípku, viď spodok obrázku.
    • Väčšinou nás nezaujíma, aké sú presné hodnoty adries, chceme si ale vedieť nakresliť podobný obrázok so šípkami.

Memory-c.png


Takúto situáciu (len s inými adresami) vyrobíme príkazmi

int x = 17;   // vytvorenie a inicializácia premennej x
int * p;      // vytvorenie premennej p, ktorá bude smerník na int
p = & x;      // & x vráti adresu premennej x, tú uložíme do premennej p

Operátor * (dereferencia, dáta na adrese)

  • Ak p je smerník, pomocou *p môžeme pristúpiť k údajom na adrese reprezentovanej smerníkom p.
  • Tieto údaje potom možno aj meniť.
  • Ak máme int x = 17; int *p = &x;, tak ďalej v programe *p aj x sú mená pre ten istý dielik pamäte.
int x = 17;
int * p = &x;        // p ukazuje na adresu premennej x
cout << *p << endl; // vypíše hodnotu z adresy p, t.j. 17
*p = 9;             // hodnota na adrese p sa zmení na 9
cout << x << endl;  // vypíše hodnotu premennej x, t.j. 9
(*p)++;             // hodnota na adrese p sa zmení na 10
cout << x << endl;  // vypíše hodnotu premennej x, t.j. 10
x = 42;
cout << *p << endl;  // vypíše hodnotu na adrese p, t.j. 42

Smerník NULL

Dôležitým špeciálnym prípadom smerníka je konštanta NULL reprezentujúca smerník, ktorý nikam neukazuje.

  • Je definovaná vo viacerých štandardných knižniciach, ako napríklad cstdlib alebo iostream.
  • Možno ju priradiť do smerníka ľubovoľného typu.

Pozor, ak do premennej typu smerník nič nepriradíme, má nedefinovanú hodnotu, ukazuje na náhodné miesto v pamäti, alebo niekde mimo.

Smerník ako parameter funkcie

Namiesto odovzdávania parametrov referenciou ich môžeme odovzdať pomocou smerníka. Tu je napríklad smerníková verzia funkcie swap, ktorá vymieňa hodnoty dvoch premenných.

#include <iostream>
using namespace std;

void swap(int * px, int * py) {  // parametre sú smerníky
    // hodnotu z adresy px uložíme do tmp:
    int tmp = *px;
    // na adresu px uložíme hodnotu z adresy py:
    *px = *py;
    // na adresu py uložíme tmp:
    *py = tmp;
}

int main() {
    int x, y;
    cout << "Zadaj x,y: ";
    cin >> x >> y;
    // ako parametre pošleme adresy premenných x,y
    swap(&x, &y);                                  
    cout << "x = " << x 
         << ", y = " << y << endl;
}

Knižničné funkcie v C často používajú odovzdávanie parametrov cez smerníky.

Smerníky a polia

Smerníky a polia v jazyku C spolu veľmi úzko súvisia.

  • Pole je vlastne smerník na nultý prvok.
  • Môžeme ho nakopírovať do premennej typu T *.
  • Na premenné typu T * môžeme použiť operátor [].
int a[4] = {10, 20, 30, 40};
int * p;
p = a;        // p ukazuje na nulty prvok pola a
cout << a[1]; // vypise 20
cout << p[1]; // vypise 20
  • Polia v C pozostávajú z políčok rovnakej veľkosti uložených v pamäti jedno za druhým, veľkosť políčka je daná typom prvku
  • Výraz p[i] zoberie adresu uloženú v p, zvýši ju o veľkosť_políčka * i a pozrie sa na príslušnú adresu
  • p[0] je teda to isté ako *p
  • Pozor, C umožní p[i] použiť aj keď p neukazuje na pole, vtedy pristupuje do pamäte s neznámym obsahom, program môže skončiť s chybou alebo sa správať "záhadne"
int x = 10;
int * p;
p = &x;
cout << p[1]; // ??? pristupuje do pamäte za premennou x
              // môže to mať nepríjemné dôsledky

Polia sú konštantné smerníky, nemožno ich zmeniť.

int a[4] = {10, 20, 30, 40};
int b[3] = {1, 2, 3};
int * p = b;  // ok
p = a;        // ok
a = b;        // nedá sa
a = p;        // nedá sa

Vo funkciách pracujúcich s poliami môžeme namiesto parametra int a[] písať aj int *a a kód ani použitie funkcie sa nemení.

#include <iostream>
using namespace std;

void vypisPole(int a[], int n) {
    for (int i = 0; i < n; i++) {
        cout << a[i] << " ";
    }
    cout << endl;
}

void vypisPole2(int * a, int n) {
    for (int i = 0; i < n; i++) {
        cout << a[i] << " ";
    }
    cout << endl;
}

int main() {
    const int N  = 4;
    int a[N] = {10, 20, 30, 40};
    int *b = a;
    
    //styrikrat vypiseme to iste
    vypisPole(a, N); 
    vypisPole(b, N);
    vypisPole2(a, N);
    vypisPole2(b, N);
}

Dynamická alokácia a dealokácia pamäte

Doteraz sme videli:

  • Globálne premenné, ktoré majú vopred známu veľkosť a vyhradenú pamäť.
  • Lokálne premenné, ktoré majú vopred známu veľkosť, ale pamäť sa im prideľuje až pri volaní funkcie na zásobníku volaní funkcií (call stack).

Program si ale počas behu môže podľa potreby vyhradiť aj ďalšiu pamäť:

  • Používa sa na to operátor new.
  • Pamäť sa vyhradí v oblasti zvanej halda (heap).
  • Keď už pamäť nepotrebujeme, uvoľníme ju príkazom delete.
  • Uvoľnená pamäť môže byť znovu použitá pri ďalších volaniach new.

Alokácia pamäte na jednu premennú

int * p;   

// new vyhradí úsek pamäte pre jednu hodnotu typu int
// adresa tohto úseku sa uloží do smerníka p
p = new int;   

// do alokovanej pamäte sa uloží hodnota 50  
*p = 50;
cout << *p << endl;  // výpis 50

delete p;      // uvoľnenie alokovanej pamäte

Alokácia pamäte pre pole

int * p; 

// new vyhradí úsek pamäte pre pole 5 hodnôt typu int
p = new int[5];

// premenná p sa dá použiť ako pole dĺžky 5
for(int i = 0; i < 5; i++) {
   p[i] = i;
}   

delete[] p;     // uvoľnenie alokovanej pamäte


  • Pozor, ak alokujeme pole, pamäť uvoľnujeme cez delete[], nie delete
  • Ak zamieňate delete[] a delete, správanie programu môže byť nedefinované
int * p;

p = new int;
// ...
delete p;

p = new int[5];
// ...
delete[] p;

Dynamickú alokáciu polí možno využiť napríklad na vytvorenie poľa, ktorého veľkosť zadá používateľ.

#include <iostream>
using namespace std;

int main(void) {
    cout << "Zadaj pocet cisel: ";
    int N;
    cin >> N;
    int * a = new int[N];
    
    cout << "Zadavaj " << N << " cisel:" << end;
    for (int i = 0; i <= N-1; i++) {
        cin >> a[i];
    }
    cout << "Tu su cisla odzadu:" << endl;
    for (int i = N-1; i >= 0; i--) {
        cout << a[i] << " ";
    }
    cout << endl;

    delete[] a;
}

Poznámka:

  • V našich programoch sme vytvárali polia, ktorých veľkosť bola konštanta const int maxN = 100; int a[maxN];
  • Niektoré kompilátory dovolia vytvoriť aj pole, ktorého veľkosť sa zistí počas behu programu int N; cin >> N; int a[N];
    • Nefunguje to však vždy, navyše môže byť problém s veľkými poliami, lebo veľkosť zásobníka volaní môže byť obmedzená.
  • Pri alokovaní poľa pomocou new vždy môžeme použiť veľkosť, ktorá sa zistila až počas behu int N; cin >> N; int *a = new int[N];
    • Alokovanie má aj ďalšie výhody, takto vytvorené pole sa napríklad dá vrátiť ako výsledok funkcie.

Aplikácia smerníkov: dynamické polia

V praxi často narazíme na nasledujúci problém: chceme zo vstupu načítať do poľa nejaké údaje, ale vopred nevieme, koľko ich bude a teda aké veľké pole potrebujeme vytvoriť.

  • Doteraz sme to riešili konštantou MaxN ohraničujúcou maximálnu povolenú veľkosť vstupu, ale to má problémy:
    • Ak je vstup väčší ako MaxN, nevieme ho spracovať, aj ak by inak kapacity počítača postačovali
    • Ak je vstup oveľa menší ako MaxN, zbytočne zaberáme pamäť veľkým poľom, ktorého veľká časť je nevyužitá
  • Pre jednoduchosť budeme uvažovať na vstupe postupnosť nezáporných čísel ukončenú -1, napr. 7 3 0 4 3 -1
    • Do poľa chceme uložiť všetko okrem poslednej -1
    • Používateľ nám vopred nezadá počet prvkov

Riešením je postupne alokovať väčšie a väčšie polia podľa potreby

  • Začneme s malým poľom (napr. veľkosti 2)
  • Vždy keď sa pole zaplní, alokujeme nové pole dvojnásobnej veľkosti, prvky do neho skopírujeme a staré pole odalokujeme
  • Presúvanie prvkov dlho trvá, preto pole vždy zdvojnásobíme, aby sme nemuseli presúvať často
  • Spolu pri načítaní n prvkov robíme najviac 2n presunov jednotlivých prvkov

Takáto verzia poľa, ktorá rastie podľa potreby, sa nazýva dynamické pole

  • V štandardných C++ knižniciach je definovaná dátová štruktúra vector, ktorá sa správa podobne.
  • My teraz implementujeme zjednodušenú verziu tejto štruktúry.
  • Pre jednoduchosť napíšeme iba verziu dynamického poľa pre typ int. Analogicky by sme postupovali pre iné typy.

Dynamické pole celých čísel budeme reprezentovať ako štruktúru typu dynArray, ktorá bude pozostávať z nasledujúcich troch zložiek:

  • Zo smerníku items ukazujúceho na nultý prvok poľa (čiže vlastne pole samotné).
  • Z celočíselnej premennej length, v ktorej bude počet prvkov, ktoré sú aktuálne v poli.
  • Z celočíselnej premennej size, v ktorej bude veľkosť alokovanej pamäte pre pole items.

Štruktúra dynArray teda v sebe združuje pole aj jeho dĺžku, stačí posielať jeden parameter.

Napíšeme niekoľko funkcií, pomocou ktorých budeme s dynamickými poľami manipulovať.

  • Funkcia void init(dynArray &a) inicializuje dynamické pole a, v ktorom je nula prvkov. Funkcia ale alokuje nejaký malý objem pamäte (u nás dva prvky).
  • Funkcia void add(dynArray &a, int x) pridá do dynamického poľa a prvok s hodnotou x, čím počet prvkov vzrastie o jedna. V prípade potreby ešte predtým realokuje pamäť.
  • Funkcia int get(dynArray a, int index) vráti prvok dynamického poľa a na pozícii index. V prípade, že index nereprezentuje korektnú pozíciu prvku poľa (teda je menší ako 0 alebo väčší, než a.length - 1), ukončí vykonávanie programu pomocou assert.
  • Funkcia void set(dynArray &a, int index, int x) nastaví prvok dynamického poľa na pozícii index na hodnotou x. Ak index nereprezentuje korektnú pozíciu prvku poľa, ukončí vykonávanie programu pomocou assert.
  • Funkcia int length(dynArray a) vráti počet prvkov aktuálne uložených v dynamickom poli a.
  • Funkcia void destroy(dynArray &a) zlikviduje dynamické pole a (uvoľní pamäť).

Bez ohľadu na implementáciu samotného dynamického poľa už teda vieme napísať kostru programu, ktorý ho využíva, napríklad na výpis vstupu odzadu.

#include <iostream>
#include <cassert>
using namespace std;

struct dynArray {
// ...
};

// definície funkcií init, add, get, set, length, destroy

int main() {
    dynArray a;
    init(a);  // inicializuje a
    
    int x;
    cin >> x;
    // pridavame prvky, kym su nezaporne
    while (x >= 0) {                 
        add(a, x);     
        cin >> x;    
    }
    // vypise prvky pola od konca
    for (int i = length(a) - 1; i >= 0; i--) {
        cout << get(a, i) << " ";     
    }
    cout << endl;

    // ukazka pouzitia get a set
    set(a, 0, 42);   
    cout << get(a, 0) << endl;  

    destroy(a);  // uvolni pamat
}

Implementácia dynamického poľa

#include <iostream>
#include <cassert>
using namespace std;

/* Dynamicke pole celych cisel */
struct dynArray {
    int * items;    // smerník na prvý prvok poľa
    int size;       // veľkosť alokovaného poľa
    int length;     // počet prvkov pridaných do poľa
};

void init(dynArray & a) {
    /* Inicializuje dynamické pole veľkosti 2 */
    a.size = 2;
    a.length = 0;
    a.items = new int[a.size];
}

void reallocate(dynArray & a, int newSize) {
    /* Pomocna funkcia, ktora sa pouziva vo funkcii add.
     * Zmeni velkost pola zo size na newSize,
     * prekopiruje vsetky prvky do noveho pola. */

    assert(a.length <= newSize);
    a.size = newSize;
    // alokujeme nove pole
    int * newItems = new int[a.size];
    // prekopirujeme stare pole do noveho
    for (int i = 0; i < a.length; i++) {
        newItems[i] = a.items[i];
    }
    // uvolnime stare pole
    delete[] a.items;
    a.items = newItems;  // a.items teraz ukazuje na nove pole
}

void add(dynArray & a, int x) {
    /* Prida na koniec dynamickeho pola prvok x
     * a v pripade potreby realokuje pole */

    // ak uz sa x do pola nevojde
    if (a.length == a.size) {
        // zdvojnasobime velkost pola items
        reallocate(a, a.size * 2);
    }
    // teraz je pole urcite dost velke
    // pridame x a zvysime pocet prvkov
    a.items[a.length] = x;
    a.length++;
}

int get(dynArray & a, int index) {
    /* Vrati prvok dynamickeho pola a na pozicii index
     * (ak ide o korektnu poziciu)*/
    assert(index >= 0 && index <= a.length - 1);
    return a.items[index];
}

void set(dynArray & a, int index, int x) {
    /* Ulozi na poziciu index dynamickeho pola a hodnotu x
     * (ak ide o korektnu poziciu)*/
    assert(index >= 0 && index <= a.length - 1);
    a.items[index] = x;
}

int length(dynArray & a) {
    /* Vrati pocet prvkov v dynamickom poli a */
    return a.length;
}

void destroy(dynArray & a) {
    /* Uvolni alokovanu pamat pre pole a */
    delete[] a.items;
}

int main() {
    dynArray a;
    init(a);  // inicializuje a

    int x;
    cin >> x;
    // pridavame prvky, kym su nezaporne
    while (x >= 0) {
        add(a, x);
        cin >> x;
    }
    // vypise prvky pola od konca
    for (int i = length(a) - 1; i >= 0; i--) {
        cout << get(a, i) << " ";
    }
    cout << endl;

    // ukazka pouzitia get a set
    set(a, 0, 42);
    cout << get(a, 0) << endl;

    destroy(a);  // uvolni pamat
}

Ďalšie detaily používania smerníkov

Typy smerníkov

Smerník ukazujúci na premennú typu T sa definuje ako T *p, napríklad:

int    * p1;   // smerník p1 na int
char   * p2;   // smerník p2 na char
double * p3;   // smerník p3 na double
// atď

Smerníky ukazujúce na premenné rôznych typov sú takisto rôznych typov. Bez pretypovania sa nedá medzi nimi priraďovať.

int * p1;
int * p2;
double * p3;

p1 = p2;   // korektné priradenie
p3 = p1;   // chyba

Operátor & (adresa)

  • Videli sme, že adresu premennej vieme zistiť operátorom &
  • Tú potom môžeme priradiť do premennej typu smerník
int x = 17;
int * p;
p = &x;

Premenná p teraz ukazuje na miesto, kde je uložená celočíselná premenná x.

  • Operátor & možno aplikovať aj na prvky poľa (alebo položky struct-u)
  • Operátor & nemožno aplikovať na konštanty ani na výrazy
int x = 0;
int a[5] = {1, 2, 3, 4, 5};
int * p;

p = &x;       // korektné priradenie
p = &a[2];    // korektné priradenie
p = &(x + 1); // chyba (výraz nemá adresu)
p = &42;      // chyba (konštanta 42 nemá adresu)


Ladenie programov so smerníkmi

  • Smerníky (a polia) môžu byť nepríjemným zdrojom chýb, keďže kompilátor nekontroluje, či sú používané správne.
  • Napríklad možno čítať aj zapisovať mimo alokovanej pamäte.
  • S odchytávaním takýchto chýb môžu pomôcť automatizované nástroje, ako napríklad Valgrind (pre Linux) alebo Dr. Memory (pre Windows aj Linux).
  • Návod na prácu s programom valgrind

Zhrnutie

  • Smerník, ukazovateľ, pointer je premenná, v ktorej je uložená adresa nejakého pamäťového miesta.
  • Typ smerníku určuje, na aký typ premennej by mal ukazovať, napr. int *p.
  • Do smerníku môžeme priradiť NULL, adresu nejakej premennej &i, novoalokovanú pamäť pomocou new, iný smerník toho istého typu.
  • Ku políčku, na ktoré ukazuje smerník p, pristupujeme pomocou *p.
  • Pole je vlastne smerník na svoj prvý (nultý) prvok.
  • Pole určitej dĺžky (ktorá je známa až počas behu) alokujeme pomocou new typ[pocet].
  • Pamäť alokovanú cez new by sme mali odalokovať pomocou delete alebo delete[] (podľa toho, či to bolo pole).
  • Pri práci so smerníkmi ľahko spravíme chybu, pomôcť nám môže napríklad valgrind.
  • Nabudúce uvidíme: dvojrozmerné polia, smerník na smerník int **.

Prednáška 13

Oznamy

  • DÚ2 zverejnená, odovzdávajte do utorka 19.11. 22:00.
    • Na úlohách a cvičeniach neodpisujte.
  • Piatkové cvičenia sú dobrovoľné.
    • Môžeme vám poradiť s riešením príkladov z cvičenia (3 príklady s termínom v pondelok na Quicksort, MergeSort a dynamické pole).
    • Budú tam vzorové riešenia utorkového testu.
    • Môžete si pozrieť aj svoj obodovaný test.

Opakovanie smerníkov

Smerníky na jednoduché premenné:

int n = 7;         // premenná typu int 
int * p = NULL;    // smerník na premennú typu int 
p = &n;            // p ukazuje na n, *p a n sú zhruba to isté 
*p = 8;            // v premennej n je teraz 8 
n = (*p)+1;        // v premennej n je teraz 9

Smerníky a polia, alokovanie poľa:

int a[3];
int * b = a; // a,b sú teraz takmer rovnocenné premenné 
*b = 3;
b[1] = 4;
a[2] = 5;   // v poli sú teraz čísla 3,4,5 
b = new int[a[1]];  // b teraz ukazuje na nové pole dĺžky 4 
delete[] b;         // uvoľníme pamäť alokovanú pre nové pole

Dvojrozmerné polia

  • Doteraz sme stále pracovali s jednorozmerným poľom, čo však ak potrebujeme dvojrozmerné pole, maticu?
    • dvojrozmerné tabuľky napr. body študentov z domácich úloh
    • matice z algebry
    • rastrové obrázky atď
  • Podobne môžeme potrebovať aj polia väčších rozmerov, pracuje sa s nimi analogicky ako s dvojrozmernými

Dvojrozmerné polia s konštantnou veľkosťou

  • Ak je veľkosť dvojrozmerného poľa vopred známa konštanta, môžeme ho vytvoriť veľmi jednoducho
    • napr. int a[2][5] vytvorí tabuľku s dvomi riadkami a piatimi stĺpcami
    • a[i][j] je potom prvok na riadku i a stĺpci j
    • Rozmery poľa musíme uviesť aj ak pole posielame do funkcie, napr. void vypis(int a[2][5])
  • Tento spôsob však nebudeme ďalej používať, lebo väčšinou chceme rozmery prispôsobiť potrebám daného vstupu
#include <iostream>
using namespace std;

int main() {
    /* Vytvorime pole s dvoma riadkami a piatimi stlpcami 
     * a rovno ho aj inicializujeme: */ 
    int a[2][5] = {{1,2,3,4,5},{6,7,8,9,10}}; 

    /* Vypiseme pole ako tabulku: */
    for (int i = 0; i < 2; i++) {
        for (int j = 0; j < 5; j++) {
            cout << a[i][j] << " ";
        }
        cout << endl;
    }
}

Dvojrozmerné pole pomocou poľa smerníkov

  • Omnoho flexibilnejšou alternatívou sú polia smerníkov.
  • Každý riadok tabuľky bude jedno dynamicky alokované pole a smerník na jeho začiatok si uložíme do poľa smerníkov.
  • Tabuľka s m riadkami a n sĺpcami bude v pamäti uložená nejako takto:

Pamat9.png

Nasledujúci program načíta rozmery dvojrozmernej tabuľky, potom jej prvky a spočíta priemer čísel v každom stĺpci.

#include <iostream>
using namespace std;

int main() {
    int m, n;
    cout << "Zadaj pocet riadkov: ";
    cin >> m;
    cout << "Zadaj pocet stlpcov: ";
    cin >> n;
    
    /* Alokuj pole smernikov na riadky: */
    int **a;
    a = new int *[m];
    
    /* Alokuj jednotlive riadky: */
    for (int i = 0; i < m; i++) {
        // a[i] je smernik na i-ty riadok 
        a[i] = new int[n];           
    }
    
    /* Nacitanie prvkov tabulky: */
    cout << "Zadaj cisla tabulky:" << endl;
    for (int i = 0; i < m; i++) {
        for (int j = 0; j < n; j++) {
            // nacitaj j-ty prvok i-teho riadku 
            cin >> a[i][j];          
        }
    }
    
    /* Spocitaj a vypis priemery jednotlivych stlpcov: */
    for (int j = 0; j < n; j++) {
        int sum = 0;
        for (int i = 0; i < m; i++) {
            sum += a[i][j];
        }
        cout << "Priemer hodnot v stlpci " << j 
             << " je " << ((double)sum) / m << endl;
    } 
    
    /* Uvolnenie pamate: */
    for (int i = 0; i < m; i++) {
        delete[] a[i];
    }
    delete[] a;
}

Cvičenie:

  • Ako by ste spočítali priemery stĺpcov vstupnej tabuľky s využitím iba jednorozmerného poľa? (Celú tabuľku teda nechceme ukladať.)
  • Ako by vyzeralo vytvorenie a použitie trojrozmernej tabuľky?
  • Ako by sme navzájom vymenili prvý a druhý riadok tabuľky? Ako prvý a druhý stĺpec?

Príklad: výšková mapa

Pokračujme ukážkou o niečo väčšieho programu využívajúceho dvojrozmerné tabuľky (matice).

  • Maticu uložíme ako dynamicky alokované pole smerníkov.
  • V programe je niekoľko funkcií, ktoré sa môžu zísť aj v iných programoch na prácu s maticami.
  • int ** vytvorMaticu(int m, int n) alokuje pamäť pre maticu s m riadkami a n stĺpcami a vráti smerník na pole smerníkov.
  • void zmazMaticu(int **a, int m) uvolní pamäť alokovanú pre maticu a s m riadkami (počet stĺpcov nepotrebujeme).
  • void nacitajMaticu(int **a, int m, int n) dostane už alokovanú maticu a s m riadkami a n stĺpcami a vyplní ju číslami načítanými zo vstupu.

Všimnite si, že funkciám potrebujeme dávať aj rozmery matice. Namiesto toho by sme si mohli spraviť štruktúru podobne ako pri dynamickom poli:

struct matica {
  int m, n;
  int **a;
}

Cieľ programu

Náš program bude v obdĺžnikovej tabuľke celých čísel uchovávať výškovú mapu.

  • Bude to obdĺžniková tabuľka s m riadkami a n stĺpcami.
  • Každé políčko obsahovať nadmorskú výšku od 0 po 2000 metrov nad morom (nadmorská výška 0 znamená more a kladná nadmorská výška znamená pevninu).
  • Program na vstupe najprv dostane rozmery tabuľky m a n a následne nadmorské výšky všetkých štvorčekov.
  • Takto zadanú mapu program vykreslí pomocou knižnice SVGdraw, pričom každý štvorček dostane určitú farbu podľa svojej nadmorskej výšky.
  • Následne zavolá funkciu najvyssiVrch, ktorá nájde najvyšší bod (resp. jeden z najvyšších bodov) vykresľovaného územia a v mape ho zvýrazní rámikom.

Príklad vstupu a výstupu:

Mapa.png
   22   11
    0    0    0    0    0    0    0    0    0    0    0
    0    0    0   40   80  100  120  140  120    0    0
    0   40   80  120  160  200  240  280  190  100    0
    0   60  120  180  240  300  360  420  260  100    0
    0   80  160  240  320  400  480  560  160    0    0
    0   80  120  300  400  500  600    0    0    0    0
    0    0   80  160  180  600    0    0    0    0    0
    0    0    0    0    0    0    0    0   70    0    0
    0    0    0    0    0  800  960  700  200    0    0
    0    0    0  540  420  900  700  500    0    0    0
    0    0  220  600  800 1000 1200 1400  680    0    0
    0   20  240  660  880 1100 1320 1540  750  100    0
    0   40  280  720  960 1200 1440 1680  820  100    0
    0   60  420  780 1040 1300 1560 1820 1200  400    0
    0   80  360  840 1120 1400 1680 1960 1500  600    0
    0   40  280  720  960 1200 1440 1680 1000  400    0
    0  100  220  600  800 1000 1200 1400  680  100    0
    0   60  120  480  640  800  960 1120  540  100    0
    0   20    0    0  480  600  720  840  400  100    0
    0    0    0  240  320  400  480  560  260  100    0
    0    0    0    0  160  200  240  280    0    0    0
    0    0    0    0    0    0    0    0    0    0    0

Program výšková mapa

#include <iostream>
#include "SVGdraw.h"
using namespace std;

/* velkost stvorceka mapy v pixeloch */
const int stvorcek = 15;

int ** vytvorMaticu(int m, int n) {
    /* Vytvori a vrati maticu (obdlznikovu tabulku)
     * s m riadkami a n stlpcami. */
    int **a;
    a = new int *[m];
    for (int i = 0; i < m; i++) {
        a[i] = new int[n];
    }
    return a;
}

void zmazMaticu(int **a, int m) {
    /* Uvolni z pamate maticu s m riadkami. */
    for (int i = 0; i < m; i++) {
        delete[] a[i];
    }
    delete[] a;
}

void nacitajMaticu(int **a, int m, int n) {
    /* Nacita hodnoty do uz vytvorenej matice 
     * velkosti m krat n. */
    for (int i = 0; i < m; i++) {
        for (int j = 0; j < n; j++) {
            cin >> a[i][j];
        }
    }
}

void nastavFarbu(int vyska, SVGdraw &drawing) {
    /* podla nadmorskej vysky nastavi farbu ciary 
     * aj vyplne
     * modra -- more (nadmorska vyska 0)
     * zelena -- niziny (nadmorska vyska 1,...,200)
     * hneda -- "pohoria" (nadmorska vyska 200,...,2000) */

    // premenne pre cervenu, zelenu a modru zlozku farby
    int r, g, b;
    // nastavenie farby podla hodnoty
    if (vyska == 0) { // modre more
        r = 0;
        g = 0;
        b = 255;
    } else if (vyska <= 200) { // zelena nizina
        double x = vyska / 200.0;
        r = x * 255;
        g = 127 + x * 127;
        b = 0;
    } else {  // zlto-hnede hory
        double x = (vyska - 200) / 1800.0;
        r = 255 - x * 150;
        g = 255 - x * 200;
        b = 0;
    }

    /* Nastavi farbu ciary aj vyplne na dane hodnoty. */
    drawing.setLineColor(r, g, b);
    drawing.setFillColor(r, g, b);
}

void vykresliStvorcek(int riadok, int stlpec, SVGdraw &drawing) {
    /* Vykresli stvorcek pre dany riadok a stlpec mapy.
     * Pouzije pri tom aktualne nastavene farby.
     * Pozor, pri vykreslovani 
     * uvadzame najskor x (stlpec), potom y (riadok) */
    drawing.drawRectangle(stlpec * stvorcek,
			  riadok * stvorcek,
			  stvorcek, stvorcek);
}

void vykresliMapu(int **a, int m, int n, SVGdraw &drawing) {
    /* Vykresli mapu rozmerov m krat n do obrazku. 
     * Jednotlive stvorceky mapy ofarbi podla ich nadmorskej vysky */
    for (int i = 0; i < m; i++) {
        for (int j = 0; j < n; j++) {
            nastavFarbu(a[i][j], drawing);
            vykresliStvorcek(i, j, drawing);
        }
    }
}

void maximumMatice(int **a, int m, int n, int &riadok, int &stlpec) {
    /* Najde v matici a o rozmeroch m krat n 
     * policko s maximalnou hodnotou 
       a jeho suradnice ulozi do premennych riadok, stlpec. */
    riadok = 0;
    stlpec = 0;
    for (int i = 0; i < m; i++) {
        for (int j = 0; j < n; j++) {
            if (a[i][j] > a[riadok][stlpec]) {
                riadok = i;
                stlpec = j;
            }
        }
    }
}

int main() {
    /* nacitaj rozmery matice */
    int m, n;
    cin >> m >> n;

    /* vytvor a nacitaj maticu */
    int **a = vytvorMaticu(m, n);
    nacitajMaticu(a, m, n);

    /* zobraz maticu, pozor vymena suradnice */
    SVGdraw drawing(n * stvorcek, m * stvorcek,
		    "mapa.svg"); 
    vykresliMapu(a, m, n, drawing);

    /* najdi najvyssi vrch a zvyrazni ho stvorcekom */
    int riadok, stlpec;
    maximumMatice(a, m, n, riadok, stlpec);

    drawing.setLineColor("black");
    drawing.setLineWidth(3);
    drawing.setNoFill();
    vykresliStvorcek(riadok, stlpec, drawing);

    /* ukonci vykreslovanie */
    drawing.finish();

    /* uvolni pamat matice */
    zmazMaticu(a, m);
}

Hra life

Hra life je jednoduchá simulácia kolónie buniek, ktorá má zaujímavé teoretické vlastnosti.

  • Máme mriežku m x n štvorčekov, v každom žije najviac 1 bunka
  • Bunky sa rodia a umierajú podľa toho, koľko majú susedov v ôsmych okolitých políčkach
    • Ak v čase t má bunka 2 alebo 3 susedov, zostane žiť aj v čase t+1, inak zomiera
    • Ak v čase t má prázdne políčko presne 3 susedov, narodí sa tam v čase t+1 nová bunka

Stav hry si môžeme pamätať v matici boolovských hodnôt.

Príklad vstupu

  • Pre jednoduchosť vstup uvádzame ako nuly a jednotky bez medzier (1=živá bunka). Výslednú animáciu nájdete tu.
20 20
00000000000000000000
00000000000000000000
00000000000000000000
00000000000000000000
00000000000000000000
00000000000000000000
00000000000000000000
00000000000000000000
00000000000000000000
00000111111111100000
00000000000000000000
00000000000000000000
00000000000000000000
00000000000000000000
00000000000000000000
00000000000000000000
00000000000000000000
00000000000000000000
00000000000000000000
00000000000000000000


Rátanie zmeny v matici

  • Stav v čase t máme v matici a, do matice b chceme dať stav v čase t+1
int zratajOkolie(int m, int n, bool **a, int riadok, int stlpec) {
    /* pocet zivych prvkov v okoli */
    int sucet = 0;
    for (int i = riadok - 1; i <= riadok + 1; i++) {
        for (int j = stlpec - 1; j <= stlpec + 1; j++) {
            /* treba osetrit okraje matice */
            if (i >= 0 && i < m && j >= 0 && j < n && a[i][j]) {
                sucet++;
            }
        }
    }
    /* samotny stvorcek nechceme zaratat */
    if (a[riadok][stlpec]) {
        sucet--;
    }
    return sucet;
}

void prepocitajMaticu(int m, int n, bool **a, bool **b) {
    for (int i = 0; i < m; i++) {
        for (int j = 0; j < n; j++) {
            int pocet = zratajOkolie(m, n, a, i, j);
            /* prirad do b[i][j] hodnotu podla okolia a[i][j] */
            b[i][j] = (pocet == 3 || (pocet == 2 && a[i][j]));
        }
    }
}

Ďalšie detaily programu

  • Celý program, ktorý načíta vstup a simuluje 10 krokov hry Life aj s vytvorením animácie: #Program Life
  • Prepočítavanie chceme opakovať v cykle pre viacero časových intervalov.
  • Môžeme prekopírovať celú maticu z b späť do a, ale rýchlejšie je len vymeniť smerníky.
    for (int i = 0; i < 10; i++) {
        /* podla a spocitaj maticu do b */
        prepocitajMaticu(m, n, a, b);
        /* vymen smerniky, aby v a bola nova matica */
        bool **tmp = b;
        b = a;
        a = tmp;
    }
  • vytvorMaticu, zmazMaticu a pod. prepíšeme tak, aby robili s maticou boolovských hodnôt namiesto intov.
  • V animácii na začiatku vykreslíme celú maticu, potom prekreslíme vždy len tie bunky, ktoré sa zmenili.

Polia reťazcov

  • Dvojrozmerné polia v C/C++ nemusia mať všetky riadky rovnako dlhé. To sa hodí napríklad na ukladanie viacerých reťazcov.
  • Spomeňte si, že v C je reťazec jednoducho pole char-ov, kde za posledným znakom ide špeciálny znak 0.
  • Pole reťazcov bude teda dvojrozmerné pole char-ov.
  • Môžeme načítavať napr. vstup po riadkoch, pričom každý riadok načítame do dlhého poľa, ktoré by malo stačiť a potom do prekopírujeme do akurát veľkého riadku v poli.
  • Vstup je ukončený prázdnym riadkom.
  • Nakoniec program riadky vypíše odzadu.
  • Ak by sme vopred alokovali maxN riadkov, každý veľkosti maxRiadok, vyšlo by potenciálne na zmar oveľa viac pamäte.
#include <iostream>
#include <cstring>
using namespace std;

const int maxN = 1000;
const int maxRiadok = 1000;

int main() {
    char *a[maxN];   // pole maxN smernikov na char
    char riadok[maxRiadok];
    int n = 0;
    while (true) {
        // nacitame jeden riadok 
        cin.getline(riadok, maxRiadok);
        // ak je prazdny alebo sa minuli polozky pola A,
        // koncime nacitavanie        
        if (strcmp(riadok, "") == 0 || n == maxN) {
            break;
        }
        // alokujeme pamat pre n-ty retazec pola a
        // pozor, je o jedna dlhsi ako dlzka retazca (kvoli 0 na konci)
        a[n] = new char[strlen(riadok)+1];
        // prekopirujeme riadok 
        strcpy(a[n], riadok);
        n++;
    }

    // vypiseme riadky odzadu
    for(int i = n-1; i >= 0; i--) {
      cout << a[i] << endl;
    }

    // uvolnime pamat
    for (int i = 0; i < n; i++) {
        delete[] a[i];
    }
}

Cvičenia:

  • Prečo nemáme na konci programu delete[] a?
  • Čo by sa stalo, ak by sme namiesto a[n] = new char[strlen(riadok)+1]; strcpy(a[n], riadok) dali a[n] = riadok?
  • Prerobte program tak, aby namiesto poľa a fixnej veľkosti maxN používal dynamické pole.
  • Vedeli by sme dynamické polia nejako použiť aj na načítavanie jednotlivých riadkov?

Vstupy do funkcie main

Často vidíte v programoch funkciu main s nasledujúcou hlavičkou:

int main(int argc, char** argv) {

Vstupné argumenty argc a argv sa používajú pri spúšťaní programu na príkazovom riadku.

  • argv je pole C-čkových reťazcov a argc je počet reťazcov v tomto poli
  • Prvý reťazec, argv[0], je meno samotného programu a ostatné sú argumenty programu
  • Videli sme napríklad spúšťanie kompilátora na príkazovom riadku:
g++ program.cpp -o program
  • Tu g++ je meno programu, ktoré je nasledované tromi argumentami "program.cpp", "-o" a "program". Tieto argumenty dávajú kompilátoru informáciu o tom, ktorý zdrojový súbor má kompilovať a kam má uložiť spustiteľný program.

Nasledujúci jednoduchý program vypíše všetky argumenty, ktoré dostal z príkazového riadku, vrátane mena programu.

#include <iostream>
using namespace std;

int main(int argc, char **argv) {
    for (int i = 0; i < argc; i++) {
        cout << argv[i] << endl;
    }
}

Deklarácie so smerníkmi a poľami

Príklady na prácu s maticami uvedené vyššie môžete modifikovať vo vlastných programoch, ale je dobré trochu lepšie rozumieť logike práce so smerníkmi v C/C++.

  • Operátor [ ] je zľava asociatívny, t.j. a[2][3] je to isté ako (a[2])[3]
  • Operátor * je sprava asociatívny, t.j. **p je to isté ako *(*p).
  • Operátor [ ] má vyššiu prioritu ako *, t.j. *a[2] je to isté ako *(a[2]).
  • Ak x je pole smerníkov na int, tak *(x[2]) znamená, že vezmeme pole x, pozrieme sa na jeho druhý prvok a následne na tento druhý prvok aplikujeme dereferenciu, čím dostaneme int.
  • Ak x je smerník na pole int-ov, (*x)[2] znamená, že vezmeme x, aplikujeme dereferenciu, ktorej výsledkom je pole int-ov a pozrieme sa na druhý prvok tohto poľa.

Cvičenie:

  • Premennú x sme vytvorili príkazom int ** x = vytvorMaticu(4,4). S ktorými prvkami tabuľky potom pracujú výrazy *(x[2]) a (*x)[2]?

Komplikácie nastávajú aj pri pochopení typov premenných. Pomôžu nasledujúce rady.

  • Deklaráciu premennej int * p môžeme čítať takto: Ak vezmeme smerník p a aplikujeme na neho operátor *, získame hodnotu typu int.
  • Deklarácia int * a[4] je to isté ako int * (a[4]) a znamená: Ak vezmeme a, pozrieme sa na niektorý zo štyroch prvkov tohto poľa a nakoniec aplikujeme dereferenciu, dostaneme hodnotu typu int. Vytvorili sme teda štvorprvkové pole smerníkov na int. Jednotlivé smerníky v poli zatiaľ nie sú inicializované.
  • Deklarácia int (* a)[4] znamená: Ak vezmeme a, aplikujeme dereferenciu, dostaneme pole a keď sa pozrieme na niektorý prvok tohto poľa, dostaneme int. Riadok teda vytvorí smerník na pole štyroch celých čísel. Tento smerník však zatiaľ ukazuje na náhodné miesto pamäte, žiadne nové pole nevzniklo. To sa nám málokedy zíde, premennú a môžeme zadefinovať radšej ako int **a.
  • Deklarácia int *(*(a[4])) vytvorí štvorprvkové pole smerníkov na smerníky na int. Dá sa zapísať aj bez zátvoriek int ** a[4]

Zhrnutie

  • Hlavnou náplňou dnešnej prednášky bolo vytvorenie a používanie dvojrozmerných polí pomocou poľa smerníkov na riadky.
  • Môžete používať a podľa potreby upravovať funkcie int ** vytvorMaticu(int m, int n), void zmazMaticu(int **a, int m) a void nacitajMaticu(int **a, int m, int n) z programu s výškovou mapou.
  • Pole reťazcov je vlastne dvojrozmerná tabuľka znakov, ktorej riadky môžu mať rôznu dĺžku (a každý je správne ukončený nulou).
  • Pozor na správnu alokáciu a dealokáciu. Pri práci so smerníkmi sa oplatí nakresliť si obrázok, čo kam ukazuje.

Program Life

/* Program Hra Life z prednášky 13. */

#include "SVGdraw.h"
#include <iostream>
#include <cassert>
using namespace std;

/* velkost stvorceka */
const int stvorcek = 15;

bool ** vytvorMaticu(int m, int n) {
    /* vytvor maticu s m riadkami a n stlpcami */
    bool **a;
    a = new bool *[m];
    for (int i = 0; i < m; i++) {
        a[i] = new bool[n];
    }
    return a;
}

void zmazMaticu(int m, bool **a) {
    /* uvolni pamat matice s m riadkami */
    for (int i = 0; i < m; i++) {
        delete[] a[i];
    }
    delete[] a;
}

void nacitajMaticu(int m, int n, bool **a) {
    /* matica je vytvorena, velkosti m krat n, vyplnime ju cislami zo vstupu */
    for (int i = 0; i < m; i++) {
        for (int j = 0; j < n; j++) {
            char c;
            cin >> c;  // nacitaj znak, preskoc biele znaky, ak nejake su
            a[i][j] = (c == '1');
        }
    }
}

void zobrazStvorcek(int i, int j, bool hodnota, SVGdraw &drawing) {
    /* zobraz stvorcek v riadku i a stlpci j */
    drawing.setLineColor("white");
    if (hodnota) {
        drawing.setFillColor("black");
    } else {
        drawing.setFillColor("white");
    }
    drawing.drawRectangle(j * stvorcek, i * stvorcek, stvorcek, stvorcek);
}

void zobrazMaticu(int m, int n, bool **a, SVGdraw &drawing) {
    /* zobraz prvky true ciernymi stvorcekmi */
    for (int i = 0; i < m; i++) {
        for (int j = 0; j < n; j++) {
            /* nastavenie farby podla hodnoty */
            if (a[i][j]) {
                zobrazStvorcek(i, j, true, drawing);
            }
        }
    }
}

void zobrazZmeny(int m, int n, bool **a, bool **b, SVGdraw &drawing) {
    /* zobraz nove stvorceky na miestach, kde bola zmena */
    for (int i = 0; i < m; i++) {
        for (int j = 0; j < n; j++) {
            if (a[i][j] != b[i][j]) {
                zobrazStvorcek(i, j, b[i][j], drawing);
            }
        }
    }
}

int zratajOkolie(int m, int n, bool **a, int riadok, int stlpec) {
    /* pocet zivych prvkov v okoli */
    int sucet = 0;
    for (int i = riadok - 1; i <= riadok + 1; i++) {
        for (int j = stlpec - 1; j <= stlpec + 1; j++) {
            /* treba osetrit okraje matice */
            if (i >= 0 && i < m && j >= 0 && j < n && a[i][j]) {
                sucet++;
            }
        }
    }
    /* samotny stvorcek nechceme zaratat */
    if (a[riadok][stlpec]) {
        sucet--;
    }
    return sucet;
}

void prepocitajMaticu(int m, int n, bool **a, bool **b) {
    for (int i = 0; i < m; i++) {
        for (int j = 0; j < n; j++) {
            int pocet = zratajOkolie(m, n, a, i, j);
            /* prirad do b[i][j] hodnotu podla okolia a[i][j] */
            b[i][j] = (pocet == 3 || (pocet == 2 && a[i][j]));
        }
    }
}

int main(void) {
    /* nacitaj rozmery matice */
    int m, n;
    cin >> m >> n;

    /* vytvor a nacitaj maticu */
    bool **a = vytvorMaticu(m, n);
    nacitajMaticu(m, n, a);

    /* zobraz maticu */
    SVGdraw drawing(m * stvorcek, n * stvorcek, "life.svg");
    zobrazMaticu(m, n, a, drawing);
    drawing.wait(1);

    /* pomocna matica na vypocty */
    bool **b = vytvorMaticu(m, n);

    /* simuluj 10 krokov hry life */
    for (int i = 0; i < 10; i++) {
        /* podla a spocitaj maticu do b */
        prepocitajMaticu(m, n, a, b);
        /* prekresli, co sa zmenilo */
        zobrazZmeny(m, n, a, b, drawing);
        drawing.wait(1);
        /* vymen smerniky, aby v a bola nova matica */
        bool **tmp = b;
        b = a;
        a = tmp;
    }

    /* uvolni pamat matic */
    zmazMaticu(m, a);
    zmazMaticu(m, b);
    drawing.finish();
}

Prednáška 14

Oznamy

  • V stredu je dekanské voľno, prednáška nebude.
  • Piatkové cvičenia sú povinné pre tých, ktorí na cvičeniach v utorok nezískajú aspoň 4 body. Informácia bude na testovači.
    • Väčšina príkladov bude na dvojrozmerné polia, vyskytnú sa aj spájané zoznamy z dnešnej prednášky.
  • DÚ2 odovzdávajte do utorka 19.11. 22:00.
  • Pripomíname, že semestrálny test bude v stredu 11.12. o 18:10. Opravný termín v januári.
    • Ak sa vyskytne konflikt s iným predmetom, hláste to vyučujúcim čo najskôr.

Dynamická množina

Motivačný príklad

  • Na fakulte sa dvere do niektorých miestností otvárajú priložením čipovej karty k čítačke.
  • Každá karta má v sebe uložené identifikačné číslo.
  • Čítačka má v pamäti zoznam identifikačných čísel oprávnených osôb (študenti, vyučujúci a pod.).
  • Po priložení karty z nej prečíta číslo a zisťuje, či ho má vo svojom zozname.
  • Administrátor tiež potrebuje vedieť pridávať a uberať oprávnené osoby.
  • Ako asi môže byť systém pracujúci so zoznamom identifikačných čísel naprogramovaný?

Dynamická množina

Chceli by sme vytvoriť dátovú štruktúru s nasledujúcou špecifikáciou.

  • Máme množinu A, ktorá sa bude postupne meniť, preto ju nazývame dynamická množina.
  • Funkcia contains dostane množinu A a hodnotu x a zistí, či x patrí do A.
  • Funkcia add dostane množinu A a hodnotu x a pridá x do A.
  • Funkcia remove dostane množinu A a prvok x a odoberie x z A.
  • Pre jednoduchosť funkciu remove nebudeme dnes uvažovať.
  • Niekedy sa môžu zísť aj iné operácie.

Problém príslušnosti k množine sa vyskytuje aj v mnohých iných situáciách.

  • Ako dnes uvidíme, dynamickú množinu môžeme implementovať rôznymi spôsobmi.
  • Hovoríme, že dynamická množina je abstraktný dátový typ, špecifikuje totiž iba rozhranie, ktoré má dátová štruktúra poskytovať používateľovi, nie jeho implementáciu.
  • Ak by sme zmenili implementáciu z jednej na inú, nemusíme nutne meniť programy, ktoré dynamickú množinu využívajú, pokiaľ k nej pristupujú iba pomocou uvedených funkcií.

Implementácie dynamických množín

  • Pre jednoduchosť budeme uvažovať iba dynamickú množinu celých čísel.
  • Dynamickú množinu budeme uchovávať v štruktúre set.
  • Navyše budeme mať implementovaných niekoľko funkcií, ktoré s dynamickými množinami pracujú.
  • Kostra programu teda bude vyzerať pre ľubovoľnú implementáciu dynamickej množiny takto:
/* Štruktúra reprezentujúca dynamickú množinu. */
struct set {
    // ...
};

/* Funkcia vytvorí prázdnu dynamickú množinu. */
void init(set &s) {
    // ...
}

/* Funkcia zistí, či prvok x patrí do množiny s. */
bool contains(set &s, int x) {
    // ...
}

/* Funkcia pridá prvok x do množiny s. */
void add(set &s, int x) {
    // ...
}

/* Funkcia uvoľní množinu s z pamäte. */
void destroy(set &s) {
    // ...
}

Bez ohľadu na implementáciu štruktúry set a uvedených funkcií už teraz môžeme napísať program, ktorý ich využíva. Z konzoly číta príkazy a postupne ich vykonáva.

#include <iostream>
#include <cstring>
using namespace std;

// ...

const int maxlength = 100;

int main(void) {
    set A;
    init(A);
    
    while (true) {
        char prikaz[maxlength];
        cin.width(maxlength);
        cin >> prikaz;
        if (strcmp(prikaz, "contains") == 0) {
            int x;
            cin >> x;
            cout << contains(A, x) << endl;        
        } else if (strcmp(prikaz, "add") == 0) {
            int x;
            cin >> x;
            add(A, x);
        } else if (strcmp(prikaz, "end") == 0) {    
            break;
        }
    }

    destroy(A);
}

Ukážeme si teraz niekoľko rôznych implementácii dynamickej množiny; začneme s dvoma, ktoré sú nám už známe.

Dynamická množina ako pole

Dynamickú množinu môžeme implementovať tak, že jej prvky budeme ukladať do poľa v ľubovoľnom poradí.

  • Funkcia contains musí zakaždým prejsť celé pole lineárnym prehľadávaním (nie je teda zrovna rýchla).
  • Funkcia add je naopak veľmi rýchla: stačí pridať prvok na koniec poľa.
  • Je ale potrebné dávať pozor na prekročenie kapacity poľa (mohli by sme použiť dynamické pole).
#include <cassert>

// ...

const int maxN = 1000;

struct set {
    int *items;  // Smerník na nultý prvok poľa
    int length;  // Počet prvkov v poli
};

void init(set &s) {
    s.items = new int[maxN];
    s.length = 0;
}

bool contains(set &s, int x) {
    for (int i = 0; i < s.length; i++) {
        if (s.items[i] == x) {
            return true;
        }
    }
    return false;
}

void add(set &s, int x) {
    assert(s.length < maxN);
    s.items[s.length] = x;
    s.length++;
}

void destroy(set &s) {
    delete[] s.items;
}

Dynamická množina ako utriedené pole

Prvky množiny môžeme v poli uchovávať aj utriedené od najmenšieho po najväčšie.

  • Funkcia contains potom môže použiť binárne vyhľadávanie. Je teda rýchlejšia, ako v predchádzajúcom prípade (v poli veľkosti n sa pozrie len na približne log2 n pozícií; napríklad pre miliónprvkové pole sa pozrieme asi na 20 prvkov poľa).
  • Funkcia add ale musí vložiť prvok na správne miesto v utriedenom poli; je teda o dosť pomalšia.
  • Tento spôsob by teda bol vhodný, ak sa množina mení zriedkavo.
#include <cassert>

// ...

const int maxN = 1000;

struct set {
    int *items;  // smerník na nultý prvok poľa
    int length;  // počet prvkov v poli
};

void init(set &s) {
    s.items = new int[maxN];
    s.length = 0;
}

bool contains(set &s, int x) {
    int left = 0;
    int right = s.length - 1;
    while (left <= right) {
        int index = (left + right) / 2;
        if (s.items[index] == x) {
            return true;
        } else if (s.items[index] > x) {
            right = index - 1;
        } else {
            left = index + 1;
        }    
    }
    return false;
}

void add(set &s, int x) {
    assert(s.length < maxN);
    int kam = s.length;
    while (kam > 0 && s.items[kam - 1] > x) {
        s.items[kam] = s.items[kam - 1];
        kam--;
    }
    s.items[kam] = x;
    s.length++;
}

void destroy(set &s) {
    delete[] s.items;
}

Ďalšie možnosti implementácie dynamickej množiny (plán na dnes)

Dnes uvidíme ďalšie dva spôsoby implementácie dynamickej množiny:

  • Množina ako spájaný zoznam:
    • Ľahko pridáme nové prvky, nepotrebujeme vopred vedieť veľkosť.
    • Nedá sa rýchlo binárne vyhľadávať.
    • Založené na smerníkoch.
  • Množina pomocou hašovania:
    • Často veľmi rýchle vyhľadávanie.
    • Použijeme polia aj spájané zoznamy.

Odbočka: smerníky a struct

Opakovanie základnej práce so smerníkmi

int n = 7;         // premenná typu int 
int * p = &n;      // smerník na int 
// n, *p teraz znamenajú to isté

int * p2 = new int; // p2 ukazuje na alokovanú pamäť
*p2 = 7;            // cez *p2 pracujeme s touto pamäťou
delete p2;          // uvoľníme pamäť
p2  = p;            // meníme samotný smerník
p = NULL;           // NULL "nikam neukazuje"

Smerníky a struct

Smerník môže ukazovať aj na struct. Operátory . (prístup k prvku štruktúry) a [] (prístup k prvku poľa) majú vyššiu prioritu ako operátory * (dereferencia smerníka) a & (adresa). Preto napríklad:

  • Zápis *s.cokolvek je to isté ako *(s.cokolvek) a vyjadruje dereferenciu smerníka s.cokolvek.
  • Zápis (*p).cokolvek vyjadruje prvok cokolvek štruktúry získanej dereferenciou smerníka p.
  • Zvyčajne je potrebnejší zápis (*p).cokolvek; existuje preň preto skratka p->cokolvek.
struct bod {
  int x, y;
};

// ...

bod b; 
b.x = 0;
b.y = 0;
bod *p = &b;       // p ukazuje na bod b

bod *p2 = new bod; // alokovanie nového bodu
(*p2).x = 20;      // bod, na ktorý ukazuje p2, bude mať x 20
p2->y = 10;        // bod, na ktorý ukazuje p2, bude mať y 10
delete p2;         // uvoľnenie pamäte


Spájané zoznamy

Spájaný zoznam (angl. linked list) je postupnosť uzlov rovnakého typu usporiadaných za sebou. Každý uzol pritom pozostáva z dvoch častí:

  • Samotné dáta; v našom prípade jedno číslo typu int.
  • Smerník next, ktorý ukazuje na nasledujúci prvok zoznamu.
    • Tieto smerníky umožňujú pohybovať sa po zozname zľava doprava.
    • Posledný uzol zoznamu nemá následníka, smerník next bude mať hodnotu NULL.

Štruktúra spájaného zoznamu je znázornená na nasledujúcom obrázku:

PROG-list.png

Uzol jednosmerne spájaného zoznamu budeme reprezentovať pomocou struct-u node:

/* Štruktúra reprezentujúca uzol jednosmerne spájaného zoznamu: */
struct node {
    int data;    // Hodnota uložená v danom uzle
    node *next;  // Smerník na nasledujúci uzol
};

Vo vnútri definície typu node teda používame smerník na samotný typ node.

Samotná množina reprezentovaná spájaným zoznamom je potom štruktúra set obsahujúca iba smerník na prvý prvok zoznamu. Ak je zoznam prázdny, bude tento smerník NULL.

/* Štruktúra implementujúca množinu ako spájaný zoznam: */
struct set {
    node *first; // Smerník na prvý uzol zoznamu
};

void init(set &s) {
    s.first = NULL;
}

Vkladanie na začiatok zoznamu

Nasledujúca funkcia na začiatok zoznamu vloží nový uzol s dátami x:

void add(set &s, int x) {
    // Vytvoríme nový uzol, uložíme doňho x
    node * p = new node; 
    p->data = x;
    // uzol p bude novým prvým prvkom zoznamu
    p->next = s.first;  
    s.first = p;         
}

Vyhľadávanie v zozname

Funkcia vyhľadávajúca číslo x v zozname bude pracovať tak, že postupne prehľadáva zoznam od jeho začiatku, s využitím smerníkov na nasledujúce prvky:

bool contains(set &s, int x) {
    node *p = s.first;
    while (p != NULL) {
        if (p->data == x) {
            return true;
        } 
        p = p->next;
    }
    return false;
}

Funkcia by sa dala napísať aj pomocou for cyklu

bool contains(set &s, int x) {
    for(node *p = s.first; p != NULL; p = p->next) {
        if (p->data == x) {
            return true;
        } 
    }
    return false;
}

V tejto forme sa viac podobá na funkciu pre polia:

bool contains(set &s, int x) {
    for (int i = 0; i < s.length; i++) {
        if (s.items[i] == x) {
            return true;
        }
    }
    return false;
}
  • Smerník p je teda v zoznamoch ekvivalentom indexu i
  • Inicializácia je p = s.first namiesto i = 0
  • Posun na ďalší prvok je p = p->next namiesto i++
  • Podmienka na pokračovanie je p != NULL namiesto i < s.length

Uvoľnenie zoznamu

Funkcia, ktorá uvoľní zoznam z pamäti, pracuje podobne: prechádza postupne zoznam od začiatku až po jeho koniec a uvoľňuje z pamäte jednotlivé uzly. Treba si však dať pozor na to, aby sme smerník na nasledujúci uzol získali ešte predtým, než z pamäti uvoľníme ten predošlý.

void destroy(set &s) {
    node *p = s.first;
    while (p != NULL) {
        node *p2 = p->next;
        delete p;
        p = p2;
    }
}

Výpis zoznamu

Môžeme napísať aj nasledujúcu funkciu, ktorá po zavolaní vypíše obsah celého zoznamu:

void print(set &s) {
    node *p = s.first;
    while (p != NULL) {
        cout << p->data << " ";
        p = p->next;
    }   
    cout << endl; 
}

Varianty spájaných zoznamov

  • V našom zozname si v každom uzle pamätáme iba smerník na následníka, hovoríme o jednosmerne spájanom zozname.
  • Často sú užitočné aj obojsmerne spájané zoznamy, kde sa v každom uzle uchováva aj smerník na predchodcu; takéto zoznamy sú ale o niečo náročnejšie na údržbu.
  • Používajú sa dokonca aj cyklické zoznamy, kde posledný prvok ukazuje späť na prvý prvok zoznamu.

Hašovanie

Implementácia množiny priamym adresovaním

Úplne odlišným spôsobom implementácie dynamickej množiny je tzv. priame adresovanie (angl. direct addressing).

  • Množinu všetkých možných hodnôt, ktoré v danej implementácii môžeme chcieť do množiny pridať, nazveme univerzum U.
  • Predchádzajúce implementácie sa ľahko dali upraviť na rôzne univerzá (celé čísla, desatinné čísla, smerníky na zložitejšie štruktúry, napr. struct, pole, reťazec).
  • Na rozdiel od toho sa priame adresovanie dá použiť iba ak univerzum je U = {0,1,...,m-1} pre nejaké rozumne malé prirodzené číslo m.
  • Podmnožinu A univerza U potom môžeme reprezentovať ako pole booleovských hodnôt dĺžky m, kde i-ty prvok poľa bude true práve vtedy, keď i patrí do A.
  • Túto reprezentáciu sme používali napríklad v poli bolo pri prehľadávaní s návratom.
  • Funkcie contains aj add sú potom veľmi jednoduché a rýchle.
  • Problémom tohto prístupu je ale vysoká pamäťová zložitosť, ak je číslo m veľké.
  • Veľmi efektívne pre malé univerzá (napr. cifry 0,...,9, všetky znaky anglickej abecedy, všetky znaky s ASCII hodnotami od 0 po 255, a pod.).
#include <cassert>

/* Maximálne povolené číslo v množine */
const int m = 1000;

/* Štruktúra implementujúca množinu priamym adresovaním: */
struct set {
    bool *isin;
};

void init(set &s) {
    s.isin = new bool[m];
    for (int i = 0; i < m; i++) {
        s.isin[i] = false;
    }
}

bool contains(set &s, int x) {
    assert(x >= 0 && x < m);
    return s.isin[x];  
}

void add(set &s, int x) {
    assert(x >= 0 && x < m);
    s.isin[x] = true;
}

void destroy(set &s) {
    delete[] s.isin;
}

Jednoduché hašovanie

Priame adresovanie sa nehodí pre veľké univerzá, lebo by vyžadovalo veľa pamäte.

Hašovanie (angl. hashing) je jednoduchá finta, ktorá funguje nasledovne:

  • Nech U je univerzum všetkých možných prvkov množiny.
  • Vytvoríme hašovaciu tabuľku (angl. hash table), čo je pole nejakej rozumnej veľkosti m.
  • Naprogramujeme hašovaciu funkciu, ktorá transformuje prvky univerza U na indexy hašovacej tabuľky; pôjde teda o funkciu h: U -> {0, 1, ... , m−1}.
  • Najjednoduchšia hašovacia funkcia pre celočíselné prvky je h(x) = |x| mod m.
    • |x| spočítame funkciou abs z knižnice cstdlib.
    • Absolútnu hodnotu používame, lebo napríklad -10 % 3 je -1, čo mimo rozsahu indexov tabuľky.
    • V praxi sa používajú zložitejšie hašovacie funkcie. Ideálne je hašovacia funkcia jednoduchá a rýchla, ale pritom hodnoty do tabuľky distribuuje rovnomerne, aby sa príliš často nestávalo, že dva prvky sa namapujú na to isté políčko.
int hash(int x, int m) {
    return abs(x) % m;
}

Napríklad pre m = 5: hash(12, 5) je 2, hash(-6, 5) je 1.

Prvý pokus o prácu hašovacou tabuľkou by teda mohol vyzerať takto:

Vkladanie prvku x:

  • Spočítame index = hash(x, m) a prvok vložíme na pozíciu table[index].

Vyhľadávanie prvku x:

  • Ak je prvok s kľúčom x v tabuľke, musí byť na indexe hash(x, m).
  • Skontrolujeme túto pozíciu a ak tam je niečo iné ako x, prvok x sa v tabuľke nenachádza.

Problémy:

  • Na akú hodnotu inicializovať prvky poľa table?
  • Čo ak budeme potrebovať vložiť prvok na miesto, kde je už niečo uložené?

Kolízie

  • Pri vkladaní prvku sme narazili na problém, ak na už obsadené miesto chceme vložiť iný prvok.
  • Ak sa dva prvky x a y sa zahašujú na rovnakú pozíciu h(x) = h(y), hovoríme, že nastala kolízia.
  • Existujú rôzne prístupy na riešenie kolízií, môžeme napríklad hľadať iné voľné miesto v tabuľke.
  • V našom programe kolízie vyriešime tak, že v každom políčku tabuľky uložíme spájaný zoznam všetkých prvkov, ktoré sa tam zahašovali.
    • Táto situácia je znázornená na nasledujúcom obrázku, v ktorom sme do hašovacej tabuľky pre m=5 pridali prvky 0,2,5.

Hash2.png

#include <cstdlib>

/* hašovacia funkcia: */
int h(int x, int m) {
    return abs(x) % m;
}

/* štruktúra reprezentujúca jeden prvok spájaného zoznamu: */
struct node {
    int data;
    node *next;
};

/* štruktúra implementujúca dynamickú množinu hašovaním: */
struct set {
    node **table;  // pole smerníkov na začiatky zoznamov
    int m;         // veľkost hašovacej tabuľky
};

void init(set &s, int m) {  
    // veľkosť tabuľky je parametrom funkcie init
    s.m = m;
    s.table = new node *[m];
    for (int i = 0; i < m; i++) {
        s.table[i] = NULL;
    }
}

bool contains(set &s, int x) {
    // spočítame políčko tabuľky
    int index = h(x, s.m);      
    node *p = s.table[index];
    // p ukazuje na začiatok zoznamu
    while (p != NULL) {         
        // prechádzame zoznam, hľadáme x
        if (p->data == x) {
            return true;
        }
        p = p->next;
    } 
    return false;    
}

void add(set &s, int x) {
    // spočítame políčko tabuľky
    int index = h(x, s.m);
    // vytvoríme nový uzol
    node *p = new node;         
    p->data = x;
    // uzol vložíme na začiatok zoznamu
    p->next = s.table[index];   
    s.table[index] = p;       
}

void destroy(set &s) {
    for (int i = 0; i < s.m; i++) {
        // uvoľníme zoznam s.table[i]
        node *p = s.table[i];   
        while (p != NULL) {
            node *p2 = p->next;
            delete p;
            p = p2;
        }
    }
    delete[] s.table;
}

Cvičenie: Ako bude vyzerať hašovacia tabuľka pri riešení kolízií pomocou spájaných zoznamov, ak hašovacia funkcia je |x| mod 5 a vkladáme prvky 13, -2, 0, 8, 10, 17?

Zložitosť

  • Rýchlosť závisí od veľkosti tabuľky m, hašovacej funkcie a počtu kolízií.
  • V najhoršom prípade sa všetky prvky zahašujú do toho istého políčka, a teda musíme pri hľadaní prejsť všetky prvky množiny.
  • Ak máme šťastie a v každom políčku máme len málo prvkov, bude aj vyhľadávanie rýchle.
    • Ak je tabuľka dosť veľká a hašovacia funkcia vhodne zvolená, tento prípad je pomerne obvyklý.
    • Hašovacie tabuľky sa často používajú v praxi.
  • Viac budúci rok na predmete Algoritmy a dátové štruktúry.

Zhrnutie

  • Videli sme abstraktný dátový typ dynamická množina.
  • Implementovali sme ho pomocou neutriedených a utriedených polí, spájaných zoznamov, priamym adresovaním (ak prvky sú napr. malé celé čísla) a hašovaním.
  • Ďalšie dve implementácie uvidíme na konci semestra.
  • Spájané zoznamy sú ďalším príkladom využitia smerníkov. Ich výhodou je možnosť rýchlo pridávať a uberať uzly na začiatku zoznamu, alebo aj na ľubovoľnom mieste zoznamu, pokiaľ máme smerník na predchodcu. Nevieme však rýchlo pristupovať k prvku na danej pozícii. Prácu so zoznamami si precvičíme na cvičeniach tento a budúci týždeň.
  • Na zamyslenie: pri jednoduchej implementácii množiny v poli sme skúsili dve možnosti: utriedenú a neutriedenú. Malo by zmysel uvažovať aj utriedený spájaný zoznam?

Prednáška 15

Oznamy

  • DÚ2 odovzdávajte do zajtra 22:00, posledná DÚ bude zverejnená v stredu.
  • Rozcvička bude z dnešnej prednášky, zvyšok cvičení hlavne spájané zoznamy z minulého pondelka.
  • Piatkové cvičenia sú povinné pre tých, ktorí nedokončia načas rozcvičku.
  • Nerobte zbytočné zmeny v poskytnutej kostre.
  • Pri práci so smerníkmi odporúčame nakresliť si obrázok.

Smerníková aritmetika

Na smerníkoch možno vykonávať určité operácie, ktoré sa zvyknú nazývať smerníková aritmetika. Uvažujme číslo n typu int a smerníky p a q na nejaký typ T.

int n;
T * p, * q;
  • p + n je smerník na n-té políčko za adresou p, pričom veľkosť políčka je daná typom T
    • p + n je teda to isté ako &(p[n]) a *(p+n) je to isté ako p[n].
    • p++ je skratkou pre p = p + 1, posunie nám teda smerník p o políčko doprava.
    • Výraz p[n] je len skratkou pre *(p+n).
    • p[n] aj p + n teda chceme používať iba ak p ukazuje na prvok poľa, za ktorým v poli ide ešte aspoň n ďalších políčok
  • Podobne p - n je smerník na n-té políčko pred adresou p.
    • p - n teda chceme používať iba ak p ukazuje na prvok poľa, pred ktorým v poli ide ešte aspoň n ďalších políčok
  • Ak p a q sú adresami prvkov v tom istom poli, p - q je celé číslo k také, že p == q + k, t.j. o koľko políčok je p ďalej vpravo od q.
  • Ak p a q sú adresami prvkov v tom istom poli, môžeme ich tiež porovnávať pomocou <, >, <=, >=
  • Ľubovoľné dva smerníky toho istého typu vieme porovnávať pomocou ==, !=.

Tu je napr. zvláštny spôsob ako vypísať pole a:

const int n = 4;
int a[n] = {4, 3, 2, 1};
for (int * smernik = a; smernik < a + n; smernik++) {
     cout << "Prvok " << smernik - a << " je " << *smernik << endl;
}

Podobný kód sa ale občas používa na prechádzanie reťazcov. Napríklad nasledujúca funkcia spočíta počet medzier v reťazci:

int zratajMedzery(char str[]) { // mohli by sme dať aj char *str
  int pocet = 0;
  while(*str != 0) {   // kým nenájdeme ukončovaciu nulu
     if(*str == ' ') { // skontroluj znak, na ktorý ukazuje str
         pocet++; 
     }
     str++;           // posuň str na ďalší znak 
  }
  return pocet;
}

Funkcie z knižnice cstring so smerníkovou aritmetikou

  • strstr(text, vzorka) vracia smerník na char
    • NULL ak sa vzorka nenachádza v texte, smerník na začiatok prvého výskytu inak
    • pozíciu výskytu zistíme smerníkovou aritmetikou:
char * text = "Hello world!";
char * vzorka = "or";
char * where = strstr(text, vzorka);
if(where != NULL) {
  int position = where - text;
}
  • Ako by ste pomocou volaní strstr spočítali počet výskytov vzorky v texte?
  • Podobne strchr hľadá prvý výskyt znaku v texte.

Práca s konzolou na spôsob jazyka C: printf a scanf

  • Doposiaľ sme s konzolou pracovali prostredníctvom knižnice iostream, ktorá patrí medzi štandardné knižnice jazyka C++ a v ktorej sú definované prúdy cin a cout.
  • Dnes si ukážeme alternatívny prístup k práci s konzolou založený na knižnici cstdio, ktorá je štandardnou knižnicou jazyka C.

Výpis formátovaných dát na konzolu: printf

  • S použitím knižnice cstdio možno na konzolu písať pomocou funkcie printf.
  • Tu sú príklady jej použitia:
#include <cstdio>
#include <cmath>
using namespace std;

int main() {
    int x = 10;
    double y = sqrt(x);
    printf("Ahoj svet, este raz!\n");
    printf("Odmocnina cisla %d je %f.\n", x, y);
}

Tento program vypíše:

Ahoj svet, este raz!
Odmocnina cisla 10 je 3.162278.

Vo všeobecnosti vyzerá volanie printf nasledovne:

printf(format, hodnota1, hodnota2, ...)
  • Prvý argument je formátovací reťazec, za ním môže nasledovať niekoľko ďalších argumentov.
  • Bežné znaky z formátovacieho reťazca sa priamo vypíšu na výstup.
  • Koniec riadku sa píše pomocou "\n".
  • Symbol % začína takzvanú špecifikáciu konverzie a má za následok vypísanie ďalšieho argumentu funkcie (prvého, ktorý sa nepoužil).
  • V jednoduchých príkladoch za znakom % nasleduje znak reprezentujúci typ vypísanej hodnoty.
  • Pozor, typy jednotlivých argumentov musia byť v súlade s formátovacím reťazcom.
  • %d: celé číslo (typ int).
  • %ld: dlhé celé číslo (typ long int).
  • %f: reálne číslo (typ double).
  • %e: reálne číslo vo vedeckej notácii, napr. 5.4e7 (typ double).
  • %c: znak (typ char).
  • %s: reťazec (typ char *).
  • %%: vypíše samotný znak %.

Formátovanie výstupu

  • Formát vypísania daného argumentu možno upraviť nepovinnými parametrami medzi symbolom % a znakom konverzie.
  • Napríklad:
    • %.2f: vypíše reálne číslo na 2 desatinné miesta,
    • %4d: ak má celé číslo menej ako 4 cifry, doplní vľavo medzery,
    • %04d: podobne, ale dopĺňa nuly.

Nasledujúci program vo vhodnom formáte vypíše hodnoty faktoriálu prirodzených čísel od 1 po 20 použitím typu long long int, ktorý garantuje aspoň 64 bitové číslo:

#include <cstdio>
using namespace std;

long long int factorial(int n) {                      
    if (n == 0) {
        return 1;
    } else {
        return n * factorial(n-1);
    }
}

int main() {
    for (int i = 1; i <= 20; i++) {                   
        printf("%2d! = %22lld\n", i, factorial(i));
    }
}
 1! =                      1
 2! =                      2
 3! =                      6
 4! =                     24
 5! =                    120
 6! =                    720
 7! =                   5040
 8! =                  40320
 9! =                 362880
10! =                3628800
11! =               39916800
12! =              479001600
13! =             6227020800
14! =            87178291200
15! =          1307674368000
16! =         20922789888000
17! =        355687428096000
18! =       6402373705728000
19! =     121645100408832000
20! =    2432902008176640000


Nasledujúci program vypíše zadaný dátum vo formáte typu 02.01.2019:

#include <cstdio>
using namespace std;

void vypisDatum(int d, int m, int r) {
    printf("%02d.%02d.%04d\n", d, m, r);
}

int main() {
    vypisDatum(2, 1, 2019);
}


Celá špecifikácia konverzie pozostáva z nasledujúcich častí:

  • Z povinného úvodného znaku %.
  • Z nepovinných príznakov, napríklad -, ktorého použitie vyústi v zarovnanie vypisovaného textu vľavo (bez jeho použitia sa text zarovná vpravo). Ďalšími príznakmi sú napríklad 0 (dopĺňanie núl naľavo), + (vypíše znamienko + pri kladných číslach), atď.
  • Z nepovinného celého čísla udávajúceho minimálnu šírku výpisu (minimálny počet „políčok”, do ktorých sa text vypíše).
  • Z nepovinnej bodky nasledovanej celým číslom udávajúcim presnosť výpisu (pri reálnych číslach napríklad počet desatinných miest; presnosť má však svoju interpretáciu aj pri iných typoch dát).
  • Z nepovinného modifikátora l, ll, alebo h pre long, long long, resp. short.
  • Z povinného symbolu konverzie (napr. d, f, s, ...).

Načítanie formátovaných dát z konzoly: scanf

Vstup z konzoly sa dá načítať funkciou scanf s typickým volaním

scanf(format, adresa1, adresa2, ...)
  • Napríklad scanf("%d", &x) načíta celočíselnú hodnotu do premennej x.
  • Zatiaľ čo argumentmi printf sú priamo hodnoty, scanf potrebuje adresy premenných, pretože ich modifikuje.

Pomocou scanf možno načítať aj viacero premenných naraz:

#include <cstdio>
using namespace std;

void vypisDatum(int d, int m, int r) {
    printf("%02d.%02d.%04d\n", d, m, r);
}

int main() {
    int d, m, r;
    printf("Zadaj den, mesiac a rok: ");  
    scanf("%d %d %d", &d, &m, &r);        
    vypisDatum(d, m, r);                    
}

Formátovací reťazec sa v scanf interpretuje nasledovne:

  • Špecifikácia typu načítavaných premenných je podobná ako pri funkcii printf.
    • %d načíta int
    • %lf načíta double (pozor, tu je rozdiel od printf, kde sa double vypisuje pomocou %f)
    • %s načíta reťazec, konkrétne jedno slovo, t.j. postupnosť nebielych znakov. Ako argument sa zadá hodnota typu char*, ktorá má ukazovať na dostatočne veľké pole.
    • %100s načíta slovo, ale najviac 100 znakov. Pole má mať veľkosť aspoň 101.
    • %c načíta jeden znak. Na rozdiel od všetkých predchádzajúcich typov, tu sa nepreskakujú biele znaky pred prvým nebielym.
  • Biele znaky (angl. whitespace, t.j. medzery, konce riadkov, tabulátory) vo formátovacom reťazci spôsobia, že funkcia scanf číta a ignoruje všetky biele znaky pred ďalším nebielym znakom. Jeden biely znak vo formátovacom reťazci tak umožní ľubovoľný počet bielych znakov na vstupe.
  • Ostatné znaky formátovacieho reťazca musia presne zodpovedať vstupu.

Nasledujúci príkaz tak napríklad načíta dátum vo formáte deň.mesiac.rok:

scanf("%d.%d.%d", &d, &m, &r);

Kontrola správnosti vstupu

Funkcia scanf vracia počet úspešne načítaných hodnôt zo vstupu.

  • V prípade chyby hneď na začiatku vstupu tak napríklad vráti 0.
  • Ak hneď na začiatku narazí na koniec vstupu, vráti hodnotu EOF (typicky -1).
  • Vstup z konzoly sa dá ukončiť pod Linuxom ako Ctrl+D resp. pod Windowsom ako Ctrl+Z a Enter

Príklad: zadávanie dátumu vo formáte deň.mesiac.rok s kontrolou vstupu:

#include <cstdio>
using namespace std;

void vypisDatum(int d, int m, int r) {
    printf("%02d.%02d.%04d\n", d, m, r);
}

int main() {
    int d, m, r;
    printf("Zadaj datum: ");
    int uspech = scanf("%d.%d.%d", &d, &m, &r);
    if (uspech == 3) {
        printf("Datum je ");
        vypisDatum(d, m, r);
    } else {
        printf("Nebol zadany korektny datum.\n");
        printf("Pocet uspesne nacitanych poloziek: %d.\n", uspech);
    }
}

Ďalší program počíta súčet postupne zadávaných čísel, až kým je zadané nekorektné číslo alebo koniec súboru:

#include <cstdio>
using namespace std;

int main() {
    double sum = 0;
    double x;
    while (scanf("%lf", &x) == 1) {
         sum += x;
    }
    printf("Sucet je %.2f\n", sum);
}

Textové súbory

Na načítavanie a vypisovanie dát sme doposiaľ používali výhradne konzolu. V praxi však často potrebujeme spracovávať dáta uložené v súboroch.

  • Zameriame sa na súbory v textovom formáte, s ktorými sa pracuje podobne ako s konzolou.
  • V C++ existujú ekvivalenty cin >> a cout << aj pre súbory, nájdete ich v knižnici fstream.

Základy: typ FILE * a funkcie fopen, fclose, fprintf, fscanf

So súbormi sa pri použití knižnice cstdio pracuje pomocou typu FILE * (veľkými písmenami).

  • Je to smerník na štruktúru typu FILE, ktorá obsahuje informácie o súbore, s ktorým sa práve pracuje.
  • Premenné pre prácu so súbormi tak možno definovať napríklad takto:
FILE *fr, *fw;

Otvorenie súboru pre čítanie

  • fr = fopen("vstup.txt", "r");
  • Otvorí súbor s názvom vstup.txt (prípadne možno zadať kompletnú cestu k súboru).
  • Ak taký súbor neexistuje alebo sa nedá otvoriť, do fr priradí NULL.
  • Z otvoreného súboru môžeme čítať napríklad pomocou fscanf, ktorá je analógiou k scanf.
  • Napríklad fscanf(fr, "%d", &x);

Otvorenie súboru pre zápis

  • fw = fopen("vystup.txt", "w");
  • Vytvorí súbor s názvom vystup.txt. Ak už existoval, zmaže jeho obsah (keby sme vo volaní fopen namiesto "w" použili "a", pridávalo by sa na koniec existujúceho súboru).
  • Ak sa nepodarí súbor otvoriť, do fw priradí NULL.
  • Do otvoreného súboru môžeme zapisovať napr. pomocou funkcie fprintf, ktorá je analógiou k printf.
  • Napr. fprintf(fw, "%d", x);

Zatvorenie súboru

  • Po ukončení práce so súborom je ho potrebné zavrieť pomocou fclose(f);
  • Počet súčasne otvorených súborov je obmedzený.

Príklad

Nasledujúci program načíta číslo n a následne n celých čísel zo súboru vstup.txt. Do súboru vystup.txt vypíše vstupné čísla v opačnom poradí.

#include <cstdio>
#include <cassert>
using namespace std;

int main() {
    // otvorime vstupny subor a skontrolujeme, ze sa podarilo
    FILE *fr = fopen("vstup.txt", "r");   
    assert(fr != NULL);
    
    // nacitame pocet cisel
    int n, uspech;
    uspech = fscanf(fr, "%d", &n);
    assert(uspech == 1 && n >= 0);

    // alokujeme pole a nacitame cisla
    int *a = new int[n];
    for (int i = 0; i < n; i++) {
        uspech = fscanf(fr, "%d", &a[i]);
        assert(uspech == 1);
    }
    fclose(fr);

    // otvorime vystupny subor a skontrolujeme, ze sa podarilo
    FILE *fw = fopen("vystup.txt", "w");
    assert(fw != NULL);

    // vypiseme cisla odzadu
    for (int i = n-1; i >= 0; i--) {
        fprintf(fw, "%d ", a[i]);
    }
    fclose(fw);
    delete[] a;    
}

Štandardný vstup a výstup ako súbor

So štandardným vstupom a výstupom sa pracuje rovnako ako so súborom. V cstdio sú definované dva konštantné smerníky

FILE *stdin, *stdout;

pre štandardný vstupný a výstupný prúd. Tie tak môžu byť použité v ľubovoľnom kontexte, v ktorom sa očakáva súbor. Napríklad volanie fscanf(stdin, "%d", &x) je ekvivalentné volaniu scanf("%d", &x).

Ten istý kód sa tak dá použiť na prácu so súbormi aj so štandardným vstupom resp. výstupom – stačí len podľa potreby nastaviť premennú typu FILE *. Typické použitie je napríklad nasledovné:

    // do retazca str nacitame z konzoly meno suboru alebo pomlcku
    char str[101];
    scanf("%100s", str);
    // do fw otvorime subor alebo pouzijeme konzolu
    FILE *fw;    
    if (strcmp(str, "-") == 0) {
        fw = stdout;
    } else {
        fw = fopen(str, "w");
    }
    // zapiseme nieco do fw
    fprintf(fw, "Hello world!\n");
    // ak treba, zatvorime subor
    if (strcmp(str, "-") != 0) {
        fclose(fw);
    }

Testovanie konca súboru

Existujú dve možnosti testovania, či sme dosiahli koniec súboru:

  • V knižnici cstdio je definovaná symbolická konštanta EOF, ktorá má väčšinou hodnotu -1. Ak sa funkcii fscanf nepodarí načítať žiadnu hodnotu, pretože načítavanie dospelo ku koncu súboru, vráti konštantu EOF ako svoj výstup.
  • Funkcia feof(subor) vráti true práve vtedy, keď sa funkcia fscanf (alebo nejaká iná funkcia) už niekedy pokúšala čítať za koncom súboru subor.

Spracovanie vstupu pozostávajúceho z postupnosti čísel

Často na vstupe očakávame postupnosť číselných hodnôt oddelených bielymi znakmi. Pozrime sa na tri obvyklé možnosti, ako môže byť takýto vstup zadaný a spracovaný pomocou funkcie fscanf.

Formát 1: N (počet čísel) a následne N ďalších čísel.

#include <cstdio>
#include <cassert>
using namespace std;

int main() {
    FILE *f;
    const int MAXN = 100;
    int a[MAXN], N, uspech;

    f = fopen("vstup.txt", "r");
    assert(f != NULL);

    uspech = fscanf(f, "%d ", &N);
    assert(uspech == 1 && N >= 0 && N <= MAXN);

    for (int i = 0; i < N; i++) {
        uspech = fscanf(f, "%d ", &a[i]);
        assert(uspech == 1);
    }
    fclose(f);

    // tu pride spracovanie dat v poli a
}

Formát 2: postupnosť čísel ukončená číslom -1 alebo inou špeciálnou hodnotu.

    // otvorime subor f ako vyssie
    N = 0;
    int x;
    uspech = fscanf(f, "%d ", &x);
    assert(uspech == 1);
    while (x != -1) {
        assert(N < MAXN);
        a[N] = x;
        N++;
        uspech = fscanf(f, "%d ", &x);
        assert(uspech == 1);
    }
    // zatvorime subor a spracujeme data

Formát 3: čísla, až kým neskončí súbor (najtypickejší prípad v praxi).

Priamočiary prístup nefunguje vždy správne:

    // otvorime subor f ako vyssie
    N = 0;
    while (!feof(f)) {
        assert(N < MAXN);
        uspech = fscanf(f, "%d", &a[N]);
        assert(uspech == 1); // bude tu padat
        N++;
    }
    // zatvorime subor a spracujeme data
  • Tento program nefunguje, ak po poslednom čísle v súbore nasleduje ešte koniec riadku (čo je obvyklé).
  • Pri načítaní tohto čísla skončíme na symbole konca riadku, fscanf sa teda nepokúsi čítať za koncom súboru.
  • Spustí sa teda ďalšie opakovanie cyklu, v ktorom sa už ale nepodarí ďalšie číslo načítať a assert ukončí program s chybovou hláškou.
  • Aj bez príkazu assert by sme mali problém, že do poľa by sa uložila nezmyselná hodnota.

Tento nedostatok môžeme napraviť napríklad tak, že vo volaní funkcie fscanf dáme vo formátovacom reťazci za %d medzeru. Tá sa bude pokúšať preskočiť všetky biele znaky až po najbližší nebiely; pritom natrafí na koniec súboru a feof(f) už bude vracať true.

#include <cstdio>
#include <cassert>
using namespace std;

int main() {
    FILE *f;
    const int MAXN = 100;
    int a[MAXN], N, uspech;

    f = fopen("vstup.txt", "r");
    assert(f != NULL);

    N = 0;
    while (!feof(f)) {
        assert(N < MAXN);
        uspech = fscanf(f, "%d ", &a[N]);
        assert(uspech == 1);
        N++;
    }
    fclose(f);

    // tu pride spracovanie dat v poli a
}

Cvičenie: upravte úryvky programu vyššie tak, aby pracoval s číslami typu double alebo so slovami.

Zhrnutie

  • Doteraz sme s konzolou pracovali pomocou cin a cout z knižnice iostream, ktorá patrí do C++.
  • V jazyku C sa na načítavanie čísel a slov používajú funkcie scanf a printf z knižnice cstdio.
    • Funkcia scanf umožňuje tiež testovať koniec vstupu a správnosť prečítaných hodnôt.
    • Funkcia printf umožňuje pohodlne vypísať viacero hodnôt aj s nastavením formátovania ako napr. počet desatinných miest alebo zarovnávanie.
  • Pri práci so súbormi súbor typu FILE * otvoríme funkciou fopen, zatvoríme fclose.
  • Namiesto scanf, printf pri súboroch použijeme fprintf, fscanf.

Prednáška 16

Oznamy

  • DÚ3 bude zverejnená dnes po prednáške, odovzdajte do piatku 6.12. 22:00. Zahŕňa dvojrozmerné polia a spracovanie vstupu a výstupu, súbory. Je to dobrý tréning na prvý príklad na skúške.

Príklad na prácu s textovými súbormi

  • Prácu s textovými súbormi si zopakujeme na nasledujúcom príklade.
  • Hlavný vstupný súbor vstup.txt má na prvom riadku názov výstupného súboru.
  • Každý z niekoľkých ďalších riadkov obsahuje názov jedného vstupného súboru nasledovaný počtom čísel, ktoré z neho chceme načítať.
  • Úlohou je prekopírovať z každého súboru zadaný počet čísel.

Napríklad vstup.txt

vystup.txt
a.txt 2
b.txt 1
a.txt 3

Súbor a.txt

1 2 3 4 5 6 7 8 9

Súbor b.txt

10 20 30 40 50 60 70 80 90

Výsledný súboru vystup.txt

1 2 10 1 2 3

Program uvedený nižšie pracuje v nasledujúcich krokoch:

  • Otvorí hlavný vstupný súbor vstup.txt a prečíta z neho názov výstupného súboru.
  • Otvorí výstupný súbor.
  • Následne opakovane prečíta názov súboru a počet čísel N.
  • Otvorí súbor s práve načítaným názvom, prekopíruje z neho N čísel do výstupného súboru a následne tento súbor zatvorí.
  • Zatvorí hlavný vstupný súbor aj výstupný súbor.

Dĺžka načítavaných reťazcov bude vo volaniach funkcie fscanf obmedzená na 19 znakov

  • to teda je maximálna dĺžka názvu súboru, s ktorou bude program vedieť pracovať
#include <cstdio>
#include <cassert>
using namespace std;

int main() {
    FILE *fr_main, *fr_part, *fw;
    int N, success, num;
    fr_main = fopen("vstup.txt", "r");
    assert(fr_main != NULL);
    
    char filename[20];
    success = fscanf(fr_main, "%19s", filename);
    assert(success == 1); 
    
    fw = fopen(filename, "w");
    assert(fw != NULL);
    while (!feof(fr_main)) {
        success = fscanf(fr_main, "%19s %d ", filename, &N);
        assert(success == 2);
        fr_part = fopen(filename, "r");
        assert(fr_part != NULL);
        for (int i = 0; i < N; i++) {
            success = fscanf(fr_part, "%d ", &num);
            assert(success == 1);
            fprintf(fw, "%d ", num);
        }
        fclose(fr_part);
    }
    fclose(fw);
    fclose(fr_main); 
}

Čítanie a zapisovanie po znakoch

Knižnica cstdio obsahuje aj funkcie na čítanie a zapisovanie súboru po znakoch.

  • Funkcia int getc(FILE *f) načíta a vráti jeden znak zo súboru
    • Ak načítanie neprebehne úspešne, výsledkom je špeciálna konštanta EOF, ktorá je vždy rôzna od ľubovoľnej hodnoty typu char
    • Neukladajte výstupnú hodnotu funkcie getc do premennej typu char, lebo nebudete vedieť rozoznať koniec súboru.
  • Funkcia int getchar() je skratka pre getc(stdin), načíta teda jeden znak z konzoly
    • Avšak rovnako ako pri scanf sa vstup začne spracovávať až potom, ako používateľ stlačí Enter, nie je takto možné reagovať priamo na stlačenie nejakej klávesy.
  • Funkcia int putc(int c, FILE *f) zapíše znak c do súboru.
  • Funkcia int putchar(int c) je skratkou pre putc(c, stdout), vypíše teda daný znak na konzolu.

Príklad: kopírovanie súboru

Nasledujúci program skopíruje obsah súboru original.txt do súboru kopia.txt.

#include <cstdio>
using namespace std;

int main() {
    FILE *fr = fopen("original.txt", "r");
    FILE *fw = fopen("kopia.txt", "w");

    int c = getc(fr);
    while (c != EOF) {
        putc(c, fw);
        c = getc(fr);
    }

    fclose(fr);
    fclose(fw);
}

Cvičenie: čo robí nasledujúci program?

  • výsledkom priradenia c = getc(fr) je hodnota priradená do premennej c
#include <cstdio>
using namespace std;

int main() {
    FILE *fr;
    int c;

    fr = fopen("vstup.txt", "r");
    while ((c = getc(fr)) != '\n') {
        putchar(c);
    }
    putchar(c);         
    fclose(fr);
}

Funkcia ungetc

  • Často sa stáva, že pri načítavaní znakov nájdeme koniec práve načítavaného úseku až po načítaní znaku, ktorý už nie je žiadúce prečítať.
  • Vtedy je užitočné posunúť sa v načítavaní o jeden krok nazad.
  • Túto úlohu realizuje funkcia int ungetc(int c, FILE * f).
  • Väčšinou ako c použijeme posledne načítaným znak zo súboru f.
  • Môžeme však použiť aj iný znak, ktorý bude virtuálne pridaný na začiatok neprečítanej časti súboru. Súbor sa reálne nemení, ale pri nasledujúcom čítaní z neho sa ako prvý prečíta znak c.
  • V prípade úspechu ungetc(c,f) vráti hodnotu c; v prípade neúspechu je výstupom EOF.
  • Takéto správanie funkcie ungetc je však garantované len ak sa táto funkcia nevolá viackrát za sebou.

Príklad č. 1: Nasledujúci kus programu skonvertuje reťazec pozostávajúci zo znakov '0''9' na zodpovedajúcu číselnú hodnotu. Keď narazí na prvý znak, ktorý nie je cifra, vráti ho, aby sa dal použiť pri ďalšom spracovávaní.

    int hodnota = 0;
    int c = getchar();
    while (c >= '0' && c <= '9') {
        hodnota = hodnota * 10 + (c - '0');
        c = getchar();
    }
    ungetc(c, stdin);

Príklad č. 2: Nasledujúci program prečíta číslo pomocou funkcie fscanf, predtým však musí prečítať neznámy počet znakov '$'.

#include <cstdio>
using namespace std;

int main() {
    FILE *fr = fopen("vstup.txt", "r");
    int c = getc(fr);
    while (c == '$') {
        c = getc(fr);
    }
    ungetc(c, fr);

    int hodnota;
    fscanf(fr, "%d", &hodnota);
    printf("%d\n", hodnota);
   
    fclose(fr);
}

Čítanie a zapisovanie po riadkoch

Riadok vieme načítať zo súboru nasledujúcou funkciou

char *fgets(char *str, int n, FILE * f);

Jej argumenty sú

  • Pole znakov str, do ktorého sa v prípade úspechu riadok načíta.
  • Číslo n je typicky dĺžka poľa str
    • Funkcia do poľa str z daného riadku súboru načíta a uloží najviac n-1 znakov a reťazec str následne ukončí znakom 0.
  • Smerník f na súbor, z ktorého sa má riadok prečítať.


  • Funkcia fgets teda načítava znaky dovtedy, kým narazí na koniec riadku (\n) alebo koniec súboru alebo kým zo súboru neprečíta n-1 znakov.
  • Prípadný znak \n na konci riadku sa nezahodí, ale pridá sa na koniec reťazca str (pokiaľ nebolo načítaných príliš veľa znakov).
  • Výstupom funkcie je v prípade načítania aspoň jedného znaku načítaný reťazec str; inak je výstupom NULL a reťazec str ostáva nezmenený.

Funkcia int fputs(const char *str, FILE *f) vypíše reťazec str do súboru

  • str môže obsahovať hocikoľko koncov riadkov (aj nula)
  • pri chybe vráti EOF, inak vráti nezáporné celé číslo


Príklad: nasledujúci program spočíta počet riadkov v súbore vstup.txt (za predpokladu, že žiaden z týchto riadkov nie je dlhší ako 100 znakov vrátane znaku \n na konci riadku):

#include <cstdio>
using namespace std;

const int maxN = 101;

int main() {
    char str[maxN];
    int num = 0;
    
    FILE *fr = fopen("vstup.txt", "r");
    while (fgets(str, maxN, fr) != NULL) {
        num++;
    }
    fclose(fr);
    
    printf("Pocet riadkov je %d\n", num);
}

Cvičenie:

  • Zistite, ako sa správa uvedený program, keď posledným znakom v súbore je resp. nie je znak \n.
  • Zistite, čo program vypíše na výstup pre súbor, ktorý obsahuje jediný riadok o 200 znakoch.

Prístupy k spracovaniu textového vstupu

Časté schémy spracovania textového súboru:

  • Pomocou fscanf načítavame jednotlivé čísla, slová a pod. (vhodné, keď všetky biele znaky považujeme za ekvivalentné oddeľovače)
  • Čítame po znakoch pomocou getc (pomerne univerzálne, ale niekedy prácne)
  • Čítame po riadkoch pomocou fgets do reťazca, potom reťazec spracovávame (vhodné, keď riadok je ucelená časť súboru, ktorá nás zaujíma)

Niekedy je užitočné tieto prístupy aj kombinovať.


Príklad: chceme nájsť dĺžku najdlhšieho riadku v súbore

  • Prvá možnosť je čítanie riadkov do reťazca a ich spracovanie (problém, ak je riadok príliš dlhý)
  • Druhá možnosť je čítať súbor po znakoch, pričom si potrebujeme udržiavať "stav": koľko písmen sme už videli v aktuálnom riadku
#include <cstdio>
#include <cstring>
using namespace std;

const int maxN = 100;

int main() {
    FILE *fr = fopen("vstup.txt", "r");
    int maxDlzka = 0;
    char str[maxN];
    while (fgets(str, maxN, fr) != NULL) {
        int dlzka = strlen(str);
        if (dlzka > maxDlzka) {
            maxDlzka = dlzka;
        }
    }
    fclose(fr);
    printf("Najdlhsi riadok ma dlzku %d\n", maxDlzka);
}
#include <cstdio>
using namespace std;

int main() {
    FILE *fr = fopen("vstup.txt", "r");
    int maxDlzka = 0;
    int dlzka = 0;
    int c = getc(fr);
    while (c != EOF) {
        dlzka++;
        if (c == '\n') {
            if (dlzka > maxDlzka) {
                maxDlzka = dlzka;
            }
            dlzka = 0;
        }
        c = getc(fr);
    }
    if (dlzka > maxDlzka) { // Posledny riadok nemusi koncit symbolom \n.
        maxDlzka = dlzka;
    }
    fclose(fr);
    printf("Najdlhsi riadok ma dlzku %d\n", maxDlzka);
}

Cvičenie: čo ak chceme zistiť, koľký riadok v súbore bol ten najdlhší?

Príklad 2: máme súbor s číslami oddelenými bielymi znakmi (medzery, tabulátory, konce riadkov,...), pričom medzi dvoma číslami môže byť aj viac ako jeden oddeľovač. Chceme spočítať súčet čísel na každom riadku.

  • Nepríjemná kombinácia rozlišovania koncov riadku od iných bielych znakov a čítania formátovaných hodnôt (čísel)
  • Môžeme prečítať riadok do reťazca a rozložiť na čísla (napr. funkciou sscanf a so špecifikáciou konverzie %n, ktorá uloží počet načítaných znakov).
  • Program nižšie však používa kombináciu getc, ungetc a fscanf
    • Biele znaky spracúva pomocou funkcie getc. Ak je niektorý z týchto znakov koncom riadku, vypíše zistený súčet čísel.
    • Po nájdení nebieleho znaku ho vráti do vstupného prúdu funkciou ungetc, prečíta číslo pomocou funkcie fscanf a aktualizuje súčet.
    • Na zistenie, či je prečítaný znak biely, využíva program funkciu isspace z knižnice cctype.
    • Program predpokladá, že aj posledný riadok vstupného súboru je ukončený symbolom \n.


#include <cstdio>
#include <cctype>
using namespace std;

int main(void) {
    FILE *fr = fopen("vstup.txt", "r");
    int sucet = 0;
    int hodnota;
    while (!feof(fr)) {
        int c = getc(fr);
        // Precitaj biele znaky po najblizsi nebiely.
        while (c != EOF && isspace(c)) { 
            if (c == '\n') {   // Na konci riadku vypis sucet.
                printf("Sucet %d\n", sucet);
                sucet = 0;
            }
            c = getc(fr);
        }
        if (c == EOF) { // Koniec suboru
            break;
        }                
        ungetc(c, fr);    
        fscanf(fr, "%d", &hodnota);
        sucet += hodnota;
    }
    fclose(fr);
}

Cvičenia:

  • Čo program spraví, ak vstup obsahuje aj prázdne riadky?
  • Upravte program, aby pracoval správne aj v prípade, že posledný riadok nie je ukončený symbolom \n.
    • Čo vlastne chceme považovať za posledný riadok?
  • Upravte program, aby na výstupe vypisoval aj čísla na riadkoch oddelené medzerami.

Jednoduché šifrovanie

Prácu so súbormi si teraz precvičíme na dvoch jednoduchých šifrách.

Caesarova šifra

Caesarova šifra je šifra, pri ktorej sa každé písmeno vstupného reťazca posunie cyklicky o K miest v abecednom poradí, kde K je zadaný parameter šifry.

  • Napríklad pre K=2 sa písmeno A zmení na C, písmeno b sa zmení na d a písmeno Z sa zmení na B.
  • Ukážeme si jej použitie pre anglickú abecedu (t. j. znaky 'a''z' a 'A''Z' bez diakritiky); je ju ale možné upraviť napríklad aj tak, aby pracovala s ASCII kódmi.

Napríklad pre K=3:

Pôvodný text:   HelloWorld
Šifrovaný text: KhoorZruog

Nasledujúci program zašifruje súbor.

#include <cstdio>
#include <cassert>
using namespace std;

void encryptCaesar(FILE *fr, FILE *fw, int shift) {
    assert(shift >= 0 && shift <= 25);
    int c;
    while ((c = getc(fr)) != EOF) {
        if ((c >= 'A') && (c <= 'Z')) {
            c = c + shift;
            if (c > 'Z') {
                c -= 26;
            }
        } else if ((c >= 'a') && (c <= 'z')) {
            c = c + shift;
            if (c > 'z') {
                c -= 26;
            }
        }
        putc(c, fw);
    }
}

int main() {
    int shift;
    scanf("%d", &shift);  // nacitame parameter o kolko posuvat pismena
    
    FILE *fr = fopen("plaintext.txt", "r");
    FILE *fw = fopen("ciphertext.txt", "w");
    
    encryptCaesar(fr, fw, shift);
    
    fclose(fr);
    fclose(fw);
}
  • Čo program spraví so znakmi, ktoré nie sú písmená?
  • Dešifrovanie by fungovalo podobne, akurát by sa posun odpočítaval a ako špeciálny prípad by sa riešilo, keď nový znak má kód menší ako 'a'. Môžeme ale tiež použiť program na šifrovanie a posun 26-K, kde K je posun použitý na šifrovanie.

Vigenèrova šifra

Vigenèrova šifra je veľmi podobná Caesarovej; posun však nie je konštantný, ale podľa kľúča.

  • Kľúč je reťazec zložený z písmen AZ, pričom tieto predstavujú posuny o 025 pozícií v abecede.
  • Prvý symbol otvoreného textu je zašifrovaný podľa prvého symbolu kľúča, druhý symbol podľa druhého symbolu kľúča, atď. Po vyčerpaní celého kľúča sa pokračuje cyklicky, opäť od jeho začiatku.

Napríklad pre heslo AHOJ:

Pôvodný text:   HelloWorld
Heslo:          AHOJAHOJAH
Šifrovaný text: HlzuoDcalk

Očíslované písmená abecedy:

 0:A,  1:B,  2:C,  3:D,  4:E,  5:F,  6:G
 7:H,  8:I,  9:J, 10:K, 11:L, 12:M, 13:N
14:O, 15:P, 16:Q, 17:R, 18:S, 19:T, 20:U
21:V, 22:W, 23:X, 24:Y, 25:Z

Nasledujúci program zašifruje súbor.

#include <cstdio>
using namespace std;

const int maxKeyLength = 100;

void encryptVigenere(FILE *fr, FILE *fw, char *key) {
    int c;
    int i = 0;
    while ((c = getc(fr)) != EOF) {
        if ((c >= 'A') && (c <= 'Z')) {
            c = c + (key[i] - 'A');
            if (c > 'Z') {
                c -= 26;
            }
            i++;
        } else if ((c >= 'a') && (c <= 'z')) {
            c = c + (key[i] - 'A');
            if (c > 'z') {
                c -= 26;
            }
            i++;
        }
        if (key[i] == 0) {
            i = 0;
        }
        putc(c, fw);
    }
}

int main() {
    // nacitame kluc z konzoly
    char key[maxKeyLength];
    scanf("%s", key);
    
    // otvorime subory
    FILE *fr = fopen("plaintext.txt", "r");
    FILE *fw = fopen("ciphertext.txt", "w");
    
    // sifrujeme 
    encryptVigenere(fr, fw, key);
    
    // zatvorime subory
    fclose(fr);
    fclose(fw);
}

Nasledujúci program potom dešifruje súbor.

#include <cstdio>
#include <cassert>
using namespace std;

const int maxKeyLength = 100;

void decryptVigenere(FILE *fr, FILE *fw, char *key) {
    int c;
    int i = 0;
    while ((c = getc(fr)) != EOF) {
        if ((c >= 'A') && (c <= 'Z')) {
            c = c - (key[i] - 'A');
            if (c < 'A') {
                c += 26;
            }
            i++;
        } else if ((c >= 'a') && (c <= 'z')) {
            c = c - (key[i] - 'A');
            if (c < 'a') {
                c += 26;
            }
            i++;
        }
        if (key[i] == 0) {
            i = 0;
        }
        putc(c, fw);
    }
}

int main() {
    char key[maxKeyLength];
    scanf("%s", key);
    
    FILE *fr = fopen("ciphertext.txt", "r");
    FILE *fw = fopen("plaintext2.txt", "w");
    
    decryptVigenere(fr, fw, key);
    
    fclose(fr);
    fclose(fw);
}


  • Caesarova aj Vigenèrova šifra s krátkym kľúčom sa dajú ľahko prelomiť.
  • O šifrách, ktoré sa v súčasnosti používajú, sa môžete dozvedieť vo vyšších ročníkoch na predmete Kryptológia.

Zhrnutie práce so súbormi

  • Ukázali sme si prácu so súbormi pomocou knižnice cstdio z jazyka C.
  • Súbor je reprezentovaný smerníkom typu FILE *.
  • Na otváranie a zatváranie súborov slúžia funkcie fopen, fclose.
  • Na vypísanie kombinácie čísel a textov slúži funkcia fprintf, čísla a slová načítavame pomocou fscanf.
  • Po znakoch pracujeme funkciami putc a getc, niekedy sa zíde aj ungetc.
  • Po riadkoch pracujeme pomocou fgets a fputs.
  • V programoch, ktoré sa majú reálne používať, je vhodné kontrolovať, či tieto operácie úspešne zbehli. Tiež pozor na to, aby pri načítaní reťazcov nenastalo pretečenie poľa.
  • K niektorým funkciám existujú aj verzie pre konzolu, napr. printf a scanf, putchar, getchar.
  • S konzolou tiež môžeme pracovať pomocou premenných stdin, stdout.


Ďalšie možnosti

  • Okrem textových súborov existujú aj binárne, kde nie je číslo zapísané ako postupnosť cifier, ale priamo ako bajty.
    • Binárne súbory majú často menšiu veľkosť a rýchlejšie sa s nimi pracuje, ťažšie však ručne skontrolujete, čo v súbore je.
    • V knižnici cstdio existujú na prácu s binárnymi súbormi funkcie fwrite a fread.
  • V C++ sa dá so súbormi pracovať podobne, ako sme používali cin a cout. Potrebné funkcie nájdete v knižnici fstream.
  • V jednom programe nekombinujte prácu s konzolou pomocou cstdio a iostream, môže dôjsť k strate dát.
  • V mnohých programovacích jazykoch existujú knižnice na načítavanie a zapisovanie často používaných formátov súborov.

Prednáška 17

Oznamy

  • Domácu úlohu 3 odovzdávajte do 6. decembra, 22:00.
  • Zajtrajšia rozcvička bude zo súborov, pozrite si prednášky z minulého týždňa.
  • Piatkové cvičenia sú povinné pre tých, ktorí nevyriešia v utorok počas cvičení úspešne rozcvičku.

Abstraktný dátový typ

Abstraktný dátový typ (ADT) je abstrakcia dátovej štruktúry nezávislá od samotnej implementácie.

  • Býva zadaný pomocou množiny operácií (hlavičiek funkcií), ktoré poskytuje
  • Jeden abstraktný dátový typ môže byť implementovaný pomocou viacerých dátových štruktúr.

Témou prednášky 14 bol napríklad abstraktný dátový typ dynamická množina, ktorý poskytuje tri základné operácie:

  • Zistenie či prvok patrí do množiny (contains).
  • Pridanie prvku do množiny (add).
  • Odobranie prvky z množiny (remove).

Videli sme implementácie pomocou neutriedeného poľa, utriedeného poľa, spájaného zoznamu, priameho adresovania a hašovania (pre jednoduchosť bez operácie remove).

Podobne za ADT môžeme považovať dynamické pole s operáciami add, length, get a set.

Výhodou abstraktných dátových typov je oddelenie implementácie dátovej štruktúry od programu, ktorý ju používa.

  • Napríklad program pracujúci s dynamickou množinou prostredníctvom funkcií contains, add a remove možno rovnako dobre použiť pri implementácii množiny pomocou neutriedených polí, ako pri jeho implementácii pomocou hašovania.

Rad a zásobník

Témou dnešnej prednášky sú dva nové abstraktné dátové typy, zásobník a rad.

  • Rad aj zásobník udržiavajú postupnosť nejakých prvkov.
  • Typicky ide o úlohy alebo dáta čakajúce na spracovanie.
  • Obidva poskytujú funkciu, ktorá vkladá nový prvok.
  • Druhou základnou funkciou je výber jedného prvku, pričom rad sa od zásobníka líši tým, ktorý prvok sa vyberá.

Prvky radu a zásobníka môžu byť ľubovoľného typu. Namiesto konkrétneho typu (ako napríklad int alebo char) dnes budeme pracovať so všeobecným typom, ktorý nazveme dataType. Za ten možno dosadiť ľubovoľný konkrétny typ. Napríklad int dosadíme za dataType takto:

typedef int dataType;
  • Pri využití tohto prístupu tak napríklad bude možné získať z radu prvkov typu int rad prvkov typu char zmenou v jedinom riadku programu.
  • Taká istá úprava by sa dala spraviť aj pri ADT množina a dynamické pole.


Rad (queue)

Niekedy sa nazýva aj front(a).

  • Z radu sa zakaždým vyberie ten jeho prvok, ktorý doň bol vložený ako prvý spomedzi jeho aktuálnych prvkov.
  • Možno ho tak pripodobniť k radu pri pokladni v obchode.
  • Takáto metóda manipulácie s dátami sa v angličtine označuje skratkou FIFO, podľa first in, first out.

Abstraktný dátový typ pre rad poskytuje tieto operácie (kde queue je názov štruktúry reprezentujúcej rad):

/* Inicializuje prázdny rad */
void init(queue &q);

/* Zistí, či je rad prázdny */
bool isEmpty(queue &q);

/* Pridá prvok item na koniec radu */
void enqueue(queue &q, dataType item);

/* Odoberie prvok zo začiatku radu a vráti jeho hodnotu */
dataType dequeue(queue &q);

/* Vráti prvok zo začiatku radu, ale nechá ho v rade */
dataType peek(queue &q);

/* Uvoľní pamäť */
void destroy(queue &q);

Zásobník (stack)

  • Zo zásobníka sa naopak zakaždým vyberie ten prvok, ktorý doň bol vložený ako posledný.
  • Môžeme si ho predstaviť ako stĺpec tanierov, kde umyté taniere dávame na vrch stĺpca a tiež z vrchu aj berieme taniere na použitie.
  • Táto metóda manipulácie s dátami sa v angličtine označuje skratkou LIFO, podľa last in, first out.

Abstraktný dátový typ pre zásobník poskytuje tieto operácie (stack je názov štruktúry reprezentujúcej zásobník):

/* Inicializuje prázdny zásobník */
void init(stack &s);

/* Zistí, či je zásobník prázdny */
bool isEmpty(stack &s);

/* Pridá prvok item na vrch zásobníka */
void push(stack &s, dataType item);

/* Odoberie prvok z vrchu zásobníka a vráti jeho hodnotu */
dataType pop(stack &s);

/* Vráti prvok na vrchu zásobníka, ale nechá ho v zásobníku */
dataType peek(stack &s);

/* Uvoľní pamäť */
void destroy(stack &s);

Programy využívajúce rad a zásobník

Bez ohľadu na samotnú implementáciu vyššie uvedených funkcií vieme písať programy, ktoré ich využívajú. Napríklad nasledujúci program pracuje s radom:

#include <iostream>
using namespace std;

typedef int dataType;


/* Sem príde definícia štruktúry queue a potrebných funkcií. */


int main() {
    queue q;
    init(q);
    enqueue(q, 1);
    enqueue(q, 2);
    enqueue(q, 3);
    cout << dequeue(q) << endl;  // Vypíše 1
    cout << dequeue(q) << endl;  // Vypíše 2
    cout << dequeue(q) << endl;  // Vypíše 3
    destroy(q);
}

Podobne nasledujúci program pracuje so zásobníkom:

#include <iostream>
using namespace std;

typedef int dataType;

/* Sem príde definícia štruktúry stack a potrebných funkcií. */

int main() {
    stack s;
    init(s);
    push(s, 1);
    push(s, 2);
    push(s, 3);
    cout << pop(s) << endl;  // Vypíše 3
    cout << pop(s) << endl;  // Vypíše 2
    cout << pop(s) << endl;  // Vypíše 1
    destroy(s);
}


Poznámka:

  • V objektovo-orientovanom programovaní (budúci semester) sa namiesto napr. push(s,10) píše niečo ako s.push(10)

Implementácia zásobníka a radu

Zásobník pomocou poľa

  • Na úvod implementujeme zásobník pomocou poľa items, ktoré budeme alokovať na fixnú dĺžku maxN (ešte lepšie by bolo použiť dynamické pole).
  • Spodok zásobníka pritom bude v tomto poli uložený na pozícii 0 a jeho vrch na pozícii top.
  • Keď je zásobník prázdny, bude v premennej top hodnota -1.
#include <cassert>

typedef int dataType;
const int maxN = 1000;

struct stack {
    // Alokované pole prvkov zásobníka
    dataType *items; 
    // Index vrchu zasobníka v poli items, -1 ak prázdny
    int top;         
};

/* Inicializuje prázdny zásobník */
void init(stack &s) {
    s.items = new dataType[maxN];
    s.top = -1; 
}

/* Zistí, či je zásobník prázdny */
bool isEmpty(stack &s) {
    return s.top == -1;
}

/* Pridá prvok item na vrch zásobníka */
void push(stack &s, dataType item) {
    assert(s.top <= maxN - 2);
    s.top++;
    s.items[s.top] = item;
} 

/* Odoberie prvok z vrchu zasobníka a vráti jeho hodnotu */
dataType pop(stack &s) {
    assert(!isEmpty(s));
    s.top--;
    return s.items[s.top + 1];
}

/* Vráti prvok na vrchu zásobníka, ale nechá ho tam */          
dataType peek(stack &s) {
    assert(!isEmpty(s));
    return s.items[s.top];
}

/* Uvoľní pamäť */
void destroy(stack &s) {
    delete[] s.items;
}

Rad pomocou poľa

  • Rad sa od zásobníka líši tým, že prvky sa z neho vyberajú z opačnej strany, než sa doň vkladajú.
  • Keby sa prvý prvok radu udržiaval na pozícii 0, museli by sa po každom výbere prvku tie zvyšné posunúť o jednu pozíciu doľava, čo je časovo neefektívne.
  • Rad teda implementujeme tak, aby jeho začiatok mohol byť na ľubovoľnej pozícii first poľa items.
  • Pole items pritom budeme chápať ako cyklické. Prvky s indexom menším ako first budeme chápať ako nasledujúce za posledným prvkom poľa.
#include <cassert>

typedef int dataType;

const int maxN = 1000;

struct queue {
    dataType *items; // Alokované pole obsahujúce prvky radu
    int first;       // Index prvého prvku radu v poli items
    int count;       // Počet prvkov v rade
};

/* Inicializuje prázdny rad */
void init(queue &q) {
    q.items = new dataType[maxN];
    q.first = 0;
    q.count = 0;
}

/* Zistí, či je rad prázdny */
bool isEmpty(queue &q) {
    return q.count == 0;
}

/* Pridá prvok item na koniec radu */
void enqueue(queue &q, dataType item) {
    assert(q.count < maxN);
    int index = (q.first + q.count) % maxN;
    q.items[index] = item;
    q.count++;
} 

/* Odoberie prvok zo začiatku radu a vráti jeho hodnotu */
dataType dequeue(queue &q) {
    assert(!isEmpty(q));
    dataType result = q.items[q.first];
    q.first = (q.first + 1) % maxN;
    q.count--;
    return result;
}

/* Vráti prvok zo začiatku radu, ale nechá ho tam */
dataType peek(queue &q) {
    assert(!isEmpty(q));
    return q.items[q.first];
}         

/* Uvoľní pamäť */
void destroy(queue &q) {
    delete[] q.items;
}

Zásobník pomocou spájaného zoznamu

  • Zásobník teraz implementujeme pomocou spájaného zoznamu.
  • Na rozdiel od implementácie pomocou poľa bude výhodnejšie uchovávať vrch zásobníka ako prvý prvok zoznamu.
  • Pri jednosmerne spájaných zoznamoch je totiž jednoduchšie vkladať a odoberať prvky na jeho začiatku.
  • Výhoda tohto prístupu oproti implementácii pomocou poľa je v tom, že maximálny počet prvkov v zásobníku nebude obmedzený konštantou maxN. Podobný efekt je možné docieliť aj pomocou dynamického poľa, tam však dochádza k realokácii.
#include <cassert>

typedef int dataType;

struct node {
    dataType data;
    node *next;
};

struct stack {
    // smerník na vrch zásobnika (začiatok zoznamu)
    // ak je zásobník prázdny, hodnota NULL.
    node *top; 
};

/* Inicializuje prázdny zásobnik */
void init(stack &s) {
    s.top = NULL;
}

/* Zistí, či je zásobník prázdny */
bool isEmpty(stack &s) {
    return s.top == NULL;
}

/* Pridá prvok item na vrch zásobníka */
void push(stack &s, dataType item) {
    node *tmp = new node;
    tmp->data = item;
    tmp->next = s.top;
    s.top = tmp;  
} 

/* Odoberie prvok z vrchu zásobníka a vráti jeho hodnotu */
dataType pop(stack &s) {
    assert(!isEmpty(s));
    dataType result = s.top->data;
    node *tmp = s.top->next;
    delete s.top;
    s.top = tmp;
    return result;
}

/* Vráti prvok na vrchu zásobníka, ale nechá ho tam */          
dataType peek(stack &s) {
    assert(!isEmpty(s));
    return s.top->data;
}

/* Uvoľní pamäť */
void destroy(stack &s) {
    while (!isEmpty(s)) {
        pop(s);
    }
}

Rad pomocou spájaného zoznamu

  • Pri implementácii radu pomocou spájaného zoznamu rozšírime spájané zoznamy zo 14. prednášky o smerník last na posledný prvok zoznamu.
  • Bude tak možné efektívne vkladať prvky na koniec zoznamu, ako aj odoberať prvky zo začiatku zoznamu.
  • Výhodou oproti implementácii radu pomocou poľa je, podobne ako pri zásobníkoch, eliminácia obmedzenia na maximálny počet prvkov v rade.
#include <cassert>

typedef int dataType;

struct node {
    dataType data;
    node *next;
};

struct queue {
    // Smerník na prvý uzol
    // Ak je rad prázdny, hodnota NULL
    node *first; 
    // Smerník na posledný uzol. 
    // Ak je rad prázdny, hodnota NULL
    node *last;  
};

/* Inicializuje prázdny rad */
void init(queue &q) {
    q.first = NULL;
    q.last = NULL;
}

/* Zistí, či je rad prázdny */
bool isEmpty(queue &q) {
    return q.first == NULL;
}

/* Pridá prvok item na koniec radu */
void enqueue(queue &q, dataType item) {
    node *tmp = new node;
    tmp->data = item;
    tmp->next = NULL;
    if (isEmpty(q)) {
        q.first = tmp;
        q.last = tmp;
    } else {
        q.last->next = tmp;
        q.last = tmp;
    }
} 

/* Odoberie prvok zo začiatku radu a vráti jeho hodnotu */
dataType dequeue(queue &q) {
    assert(!isEmpty(q));
    dataType result = q.first->data;
    node *tmp = q.first->next;
    delete q.first;
    if (tmp == NULL) {
        q.first = NULL; 
        q.last = NULL;
    } else {
        q.first = tmp;
    } 
    return result;
}

/* Vráti prvok zo začiatku radu, ale nechá ho tam */
dataType peek(queue &q) {
    assert(!isEmpty(q));
    return q.first->data;
}         

/* Uvoľní pamäť */
void destroy(queue &q) {
    while (!isEmpty(q)) {
        dequeue(q);
    }
}

Použitie zásobníka a radu

Zásobník aj rad často uchovávajú dáta určené na spracovanie, zoznamy úloh, atď. Rad sa zvyčajne používa v prípadoch, keď je žiadúce zachovať ich poradie. Typicky môže ísť o situácie, keď jeden proces generuje úlohy spracúvané iným procesom, napríklad:

  • Textový procesor pripravuje strany na tlač a vkladá ich do radu, z ktorého ich tlačiareň (resp. jej ovládač) postupne vyberá.
  • Sekvenčne vykonávané výpočtové úlohy čakajú v rade na spustenie.
  • Zákazníci čakajú na zákazníckej linke na voľného operátora.
  • Pasažieri na standby čakajú na voľné miesto v lietadle.

Zásobník sa zvyčajne používa v situáciách, keď na poradí spracúvania nezáleží, alebo keď je žiadúce vstupné poradie obrátiť. Najvýznamnejší príklad situácie druhého typu je nasledujúci:

  • Operačný systém ukladá lokálne premenné volaných funkcií na tzv. zásobníku volaní (angl. call stack), čo umožňuje používanie rekurzie.
  • Rekurzívne programy sa dajú prepísať na nerekurzívne pomocou „ručne vytvoreného” zásobníka (neskôr si ukážeme nerekurzívnu verziu triedenia Quick Sort).

Príklad: kontrola uzátvorkovania

Ako jednoduchý príklad na použitie zásobníka uvažujme nasledujúci problém: na vstupe je daný reťazec pozostávajúci (okrem prípadných ďalších znakov, ktoré budeme ignorovať) zo zátvoriek (,),[,],{,}. Úlohou je zistiť, či je tento reťazec dobre uzátvorkovaný. To znamená, že:

  • Pre každú uzatváraciu zátvorku musí byť posledná dosiaľ neuzavretá otváracia zátvorka rovnakého typu, pričom musí existovať aspoň jedna dosiaľ neuzavretá zátvorka.
  • Každá otváracia zátvorka musí byť niekedy neskôr uzavretá.

Príklady očakávaného vstupu a výstupu:

()
Retazec je dobre uzatvorkovany

nejaky text bez zatvoriek
Retazec je dobre uzatvorkovany

[((({}[])[]))]()
Retazec je dobre uzatvorkovany

[[#))
Retazec nie je dobre uzatvorkovany

())(
Retazec nie je dobre uzatvorkovany

((
Retazec nie je dobre uzatvorkovany

((cokolvek
Retazec nie je dobre uzatvorkovany
  • Nasledujúci program postupne prechádza cez vstupný reťazec, pričom pre každú otváraciu zátvorku si na zásobník pridá uzatváraciu zátvorku rovnakého typu.
  • Ak narazí na uzatváraciu zátvorku, výraz môže byť dobre uzátvorkovaný len v prípade, že je na zásobníku aspoň jedna zátvorka, pričom zátvorka na vrchu zásobníka sa zhoduje so zátvorkou na vstupe.
  • V prípade úspešného prechodu cez celý vstup je reťazec dobre uzátvorkovaný práve vtedy, keď na zásobníku nezostala žiadna zátvorka.
#include <iostream>
#include <cassert>
using namespace std;

typedef char dataType;

/* Sem pride definicia struktury stack a vsetkych potrebnych funkcii. */

int main() {
    char vyraz[100];
    cin.getline(vyraz, 100);
    
    stack s;
    init(s);
    
    bool dobre = true;
    
    for (int i = 0; vyraz[i] != 0; i++) {
        switch (vyraz[i]) {
            case '(':
                push(s, ')');
                break;
            case '[':
                push(s, ']');
                break;
            case '{':
                push(s, '}');
                break;
            case ')':
            case ']':
            case '}':
                if (isEmpty(s)) {
                    dobre = false;
                } else {
                    char c = pop(s);
                    if (c != vyraz[i]) {
                        dobre = false;
                    }
                }
                break;
        }
    }
    
    dobre = dobre && isEmpty(s);
        
    destroy(s);
    
    if (dobre) {
        cout << "Retazec je dobre uzatvorkovany." << endl;
    } else {
        cout << "Retazec nie je dobre uzatvorkovany." << endl;
    }

}

Cvičenie: Prepíšte program na kontrolu zátvoriek do rekurzívnej podoby. Použite pritom iba premenné typu char; špeciálne nepoužívajte žiadne polia. Reťazec načítavajte pomocou funkcií getc a ungetc. Môžete predpokladať, že je ukončený koncom riadku.


Zhrnutie

  • Videli sme abstraktné dátové štruktúry zásobník a rad, ktoré vedia uchovávať postupnosť prvkov a pridávať a uberať prvky podľa určitých pravidiel.
  • Implementovali sme ich pomocou polí aj spájaných zoznamov. Implementácie všetkých funkcií sú rýchle a jednoduché (okrem destroy pre neprázdnu štruktúru).
  • Implementácia radu pomocou poľa používa pekný trik s cyklickým poľom.
  • Na budúcej prednáške vďaka zásobníku a radu prerobíme niektoré rekurzívne programy na nerekurzívne.

Ak zostáva čas, ukážeme si ešte jeden rekurzívny program, viac na budúcej prednáške.

Prednáška 18

Oznamy

Budúci utorok bude na cvičeniach rozcvička na papieri.

  • Bude pokrývať učivo po prednášku 17.
  • Dôležitý tréning na semestrálny test.

Blíži sa semestrálny test.

Od pondelka 2.12. 19:00 sa môžete hlásiť na termín skúšky pri počítači.

  • Ak vidíte konflikt niektorého termínu s hromadnou skúškou alebo písomkou z iného predmetu, dajte nám vedieť čím skôr.
  • Kapacita termínov bude obmedzená, prihláste sa teda radšej skôr, neskôr to môžete zmeniť.
  • Prihlásiť a odhlásiť sa dá najviac deň vopred.
  • Skúšku 20.12. môžete robiť iba ak spravíte test 11.12. aspoň na 50% bodov.
  • Decembrový termín odporúčame hlavne študentom, ktorým programovanie nerobí problémy.
  • Viac informácií o skúške na prednáške v stredu 11.12.

Opakovanie: zásobník a rad

  • Zásobník (angl. stack) a rad alebo front (angl. queue) sú abstraktné dátové typy, ktoré udržiavajú postupnosť nejakých prvkov.
  • Obidva typy podporujú vloženie prvku a výber prvky
  • Zo zásobníka sa vyberá prvok, ktorý v ňom pobudol najkratšie, z radu prvok, ktorý v ňom bol najdlhšie
  • Zásobník tak pripomína stĺpec čistých tanierov v reštaurácii, rad pripomína rad pri pokladni

Konkrétne funkcie hlavičky funkcií

void init(stack &s);
bool isEmpty(stack &s);
void push(stack &s, dataType item); // vlozenie prvku
dataType pop(stack &s);    // vyber prvku
dataType peek(stack &s);
void destroy(stack &s);

void init(queue &q);
bool isEmpty(queue &q);
void enqueue(queue &q, dataType item); // vlozenie prvku
dataType dequeue(queue &q);  // vyber prvku
dataType peek(queue &q);
void destroy(queue &q);
  • Pre obidva typy sme videli implementáciu v poli aj v spájanom zozname
  • Hlavné funkcie vkladania a vyberania sú v obidvoch implementáciách rýchle a jednoduché

Videli sme tiež, že obidve štruktúry sa dajú použiť na ukladanie dát alebo úloh, ktoré ešte treba vyriešiť

  • Dnes uvidíme ďalšie príklady


Použitie zásobníka a radu: nerekurzívny Quick Sort

Pripomeňme si triedenie Quick Sort z 11. prednášky:

void swap (int &x, int &y) {
    int tmp = x;
    x = y;
    y = tmp;
}

int partition(int a[], int left, int right) {
    int pivot = a[left];     
    int lastSmaller = left;
    
    for (int unknown = left + 1; unknown <= right; unknown++) {
        if (a[unknown] < pivot) {
            lastSmaller++;
            swap(a[unknown], a[lastSmaller]);
        }
    }   
    swap(a[left],a[lastSmaller]); 
    return lastSmaller;
}

void quicksort(int a[], int left, int right) {
    if (left >= right) {
        return; 
    }
    
    int middle = partition(a, left, right);
        
    quicksort(a, left, middle-1);  
    quicksort(a, middle+1, right);   
}

int main() {
  // ...
  quicksort(a, 0, N-1);
  // ...
}

Namiesto rekurzie môžeme použiť aj zásobník úsekov, ktoré ešte treba dotriediť.

struct usek {
    int left;
    int right;
};

typedef usek dataType;

/* Sem pride definicia struktury stack a vsetkych potrebnych funkcii. */

/* Sem pridu funkcie swap a partition rovnake ako vyssie. */

void quicksort(int a[], int n) {
    stack s;
    init(s);
    
    usek u;
    u.left = 0;
    u.right = n-1;
    push(s,u);
    
    while (!isEmpty(s)) {
        u = pop(s);
        // vynechame useky dlzky 0 a 1
        if (u.left >= u.right) {
            continue;
        }
        
        int middle = partition(a, u.left, u.right);
        
        usek u1;
        u1.left = u.left;
        u1.right = middle - 1;
        usek u2;
        u2.left = middle + 1;
        u2.right = u.right;
        push(s,u2);
        push(s,u1);
    }
    
    destroy(s);
}

int main() {
  // ...
  quicksort(a, N);
  // ...
}
  • Tento program triedi úseky v rovnakom poradí, ako rekurzívny Quick Sort, lebo po rozdelení poľa na dve časti dá na vrch zásobníka úsek zodpovedajúci jeho ľavej časti. Až keď sa táto ľavá časť a všetky podúlohy, ktoré z nej vzniknú, spracuje, dôjde na spracovanie pravej časti poľa.
  • Pri triedení Quick Sort však na tomto poradí nezáleží, takže by sme mohli jednotlivé úseky vkladať na zásobník aj v opačnom poradí.
  • Alebo by sme namiesto zásobníka mohli použiť rad. Potom by najskôr rozdelil ľavú aj pravú časť na ďalšie podčasti a potom by delil každú z týchto podčastí atď.

Na zamyslenie: ako by mohla vyzerať nerekurzívna verzia triedenia Merge Sort? Prečo sa nedá použiť rovnaký prístup ako pri triedení Quick Sort?

Vyfarbovanie súvislých oblastí

  • Uvažujme obrázok pozostávajúci z m krát n štvorčekov (pixelov).
  • Farbu každého pixelu zapíšeme do jedného políčka dvojrozmerného poľa s m riadkami a n stĺpcami.
  • V našom jednoduchom príklade budeme pracovať iba s piatimi farbami, ktoré budeme reprezentovať číslami 0,...,4 podľa nasledujúceho poľa (napríklad číslo 0 teda reprezentuje bielu farbu):
const char *farby[5] = {"white", "blue", "black", "yellow", "red"};

Napríklad obrázok

Matica1.png

tak môže byť reprezentovaný nasledujúcim textovým súborom obsahujúcim najprv rozmery matice (čísla m a n) a za nimi samotné prvky matice:

11 17
 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
 0 0 0 1 1 1 1 1 1 1 1 1 0 0 0 0 0
 0 0 0 1 0 0 0 0 0 0 0 1 0 0 0 0 0
 0 0 0 1 0 0 0 0 0 2 2 1 2 2 2 2 0
 0 0 0 1 0 0 0 0 0 2 0 1 0 0 0 2 0
 0 2 2 2 2 2 2 2 0 2 2 1 2 2 2 2 0
 0 2 0 1 0 0 0 2 0 0 0 1 0 0 0 0 0
 0 2 0 1 0 0 0 2 0 0 0 1 0 1 1 1 1
 0 2 0 1 1 1 1 2 1 1 1 1 0 1 0 0 1
 0 2 0 0 0 0 0 2 0 0 0 0 0 1 1 1 1
 0 2 2 2 2 2 2 2 0 0 0 0 0 0 0 0 0

Zameriame sa teraz na nasledujúci problém: používateľ zvolí (zadá na konzolu) súradnice niektorého štvorčeka a cieľom je ofarbiť nejakou farbou (napríklad červenou) celú súvislú oblasť rovnakej farby obsahujúcu daný štvorček. Napríklad pre obrazec vyššie a vstupné súradnice (2,1) (teda štvorček v treťom riadku a druhom stĺpci, keďže matica sa bude indexovať od nuly) by mal byť výstupom nasledujúci obrazec:

Matica2.png

Podobný problém je napríklad často potrebné riešiť v rôznych nástrojoch na prácu s grafikou a podobne.

  • Jednoduché útvary, napr. obdĺžnik, by sa dali vyfarbiť jednoduchými cyklami, naša súvislá oblasť ale môže mať veľmi zložitý tvar, s mnohými zákrutami, vetveniami a dierami a podobne.
  • Pre každé políčko, ktoré prefarbíme, nesmieme zabudnúť skontrolovať všetkých jeho susedov a ak majú rovnakú farbu ako malo pôvodné políčko, tak prefarbiť aj ich, ich susedov, susedov ich susedov atď.
  • Toto vieme ľahko zapísať rekurzívne: zafarbíme jedno políčko a potom rekurzívne zafarbujeme celú dosiahnuteľnú oblasť pre každého suseda rovnakej farby, akú malo pôvodne prefarbené políčko.
  • Táto podúloha je menšia, lebo aspoň jedno políčko z nej ubudlo prvým prefarbením.

Vyfarbovanie pomocou rekurzívnej funkcie

Nasledujúca rekurzívna funkcia vyfarbi prefarbí políčko so súradnicami (riadok, stlpec) na cieľovú farbu farba a následne sa rekurzívne zavolá pre všetkých susedov tohto políčka, ktoré sú zafarbené pôvodnou farbou prefarbovanej oblasti.

/* Prefarbi súvislú jednofarebnú oblasť obsahujúcu 
 * pozíciu (riadok,stlpec) na farbu s číslom farba. */
void vyfarbi(int **a, int m, int n, 
             int riadok, int stlpec, int farba) {
    int staraFarba = a[riadok][stlpec];
    if (staraFarba == farba) {
        return;
    }
    a[riadok][stlpec] = farba;
    if (riadok - 1 >= 0 
        && a[riadok - 1][stlpec] == staraFarba) {
        vyfarbi(a, m, n, riadok - 1, stlpec, farba);
    }
    if (riadok + 1 < m 
        && a[riadok + 1][stlpec] == staraFarba) {
        vyfarbi(a, m, n, riadok + 1, stlpec, farba);
    }
    if (stlpec - 1 >= 0 
        && a[riadok][stlpec - 1] == staraFarba) {
        vyfarbi(a, m, n, riadok, stlpec - 1, farba);
    }
    if (stlpec + 1 < n 
        && a[riadok][stlpec + 1] == staraFarba) {
        vyfarbi(a, m, n, riadok, stlpec + 1, farba);
    }
}

Proces vyfarbovania môžeme animovať pomocou našej knižnice SVGdraw

V animácii okrem zmeny farby štvorčeka meníme aj farbu jeho rámčeka: pri zavolaní rekurzie sa zmení na hnedú a po skončení spracovania štvorčeka aj jeho susedov sa zmení na sivú. Hnedé sú teda vždy rámčeky štvorčekov uložené na zásobníku volaní.

Program s animáciou

  • Program na vyfarbovanie súvislých oblastí obsahuje funkcie na inicializáciu matice, jej načítanie zo súboru, vykresľovanie jednotlivých štvorčekov a celej matice, ako aj uvoľnenie pamäte. Všetky tieto funkcie pracujú podobne ako pri príklade s výškovou mapou z prednášky 13.
  • Funkcia main načíta maticu zo súboru vstup.txt a animáciu uloží do súboru matica.svg.
  • Do rekurzívnej funkcie pridáme volania funkcie vykresliStvorcek, ktoré aktuálny štvorček v obrázku prekreslia novou farbou a menia aj farbu rámčeka. Za ňou vždy zavoláme funkciu drawing.wait, ktorá animáciu pozdrží, aby sme zmeny stihli na obrázku sledovať.
#include "SVGdraw.h"
#include <cstdio>
#include <cassert>

const char *farby[5] = {"white", "blue", "black", "yellow", "red"}; 

const int stvorcek = 40;    // velkost stvorceka v pixeloch
const int hrubkaCiary = 2;  // hrubka ciary v pixeloch
const double pauza = 0.3;   // pauza po kazdom kroku vyfarbovania v sekundach

/* Vytvori maticu s n riadkami a m stlpcami. */
int **vytvorMaticu(int m, int n) {
    int **a;
    a = new int *[m];
    for (int i = 0; i < m; i++) {
        a[i] = new int[n];
    }
    return a;
}

/* Uvolni pamat matice a s n riadkami a m stlpcami. */
void zmazMaticu(int **a, int m) {
    for (int i = 0; i < m; i++) {
        delete[] a[i];
    }
    delete[] a;
}

/* Vykresli stvorcek v riadku i a stlpci j 
   s farbou vyplne farba a farbou ciary farbaCiary. */
void vykresliStvorcek(int i, int j, 
                      const char * farba, 
                      const char * farbaCiary, 
                      SVGdraw &drawing) {
    drawing.setLineColor(farbaCiary);
    drawing.setLineWidth(hrubkaCiary);
    drawing.setFillColor(farba);
    drawing.drawRectangle(j * stvorcek, i * stvorcek, 
                          stvorcek, stvorcek);
}

/* Vykresli maticu a s n riadkami a m stlpcami. */
void vykresliMaticu(int **a, int m, int n, SVGdraw &drawing) {
    for (int i = 0; i < m; i++) {
        for (int j = 0; j < n; j++) {
            vykresliStvorcek(i, j, farby[a[i][j]], "lightgray", drawing);
        }
    }
}

/* Nacita z textoveho suboru, na ktory ukazuje fr, 
   prvky matice a s n riadkami a m stlpcami. */
void nacitajMaticu(FILE *fr, int **a, int m, int n) {
    assert(fr != NULL);
    for (int i = 0; i < m; i++) {
        for (int j = 0; j < n; j++) {
            fscanf(fr, "%d", &a[i][j]);
        }
    }
}

/* Prefarbi suvislu jednofarebnu oblast obsahujucu 
 * poziciu (riadok,stlpec) na farbu s cislom farba. */
void vyfarbi(int **a, int m, int n, 
             int riadok, int stlpec, int farba,
             SVGdraw &drawing) {
    int staraFarba = a[riadok][stlpec];
    if (staraFarba == farba) {
        return;
    }
    a[riadok][stlpec] = farba;
    vykresliStvorcek(riadok, stlpec, farby[farba], 
                     "brown", drawing);
    drawing.wait(pauza);
    if (riadok - 1 >= 0 && a[riadok - 1][stlpec] == staraFarba) {
        vyfarbi(a, m, n, riadok - 1, stlpec, farba, drawing);
    }
    if (riadok + 1 < m && a[riadok + 1][stlpec] == staraFarba) {
        vyfarbi(a, m, n, riadok + 1, stlpec, farba, drawing);
    }
    if (stlpec - 1 >= 0 && a[riadok][stlpec - 1] == staraFarba) {
        vyfarbi(a, m, n, riadok, stlpec - 1, farba, drawing);
    }
    if (stlpec + 1 < n && a[riadok][stlpec + 1] == staraFarba) {
        vyfarbi(a, m, n, riadok, stlpec + 1, farba, drawing);
    }
    vykresliStvorcek(riadok, stlpec, farby[farba], 
                     "lightgray", drawing);
    drawing.wait(pauza);
} 

int main() {
    FILE *fr = fopen("vstup.txt", "r");
    assert(fr != NULL);

    // nacitanie rozmerov matice
    int m, n;
    fscanf(fr, "%d %d", &m, &n);  
    // vytvorenie matice a nacitanie jej prvkov  
    int **a = vytvorMaticu(m, n);
    nacitajMaticu(fr, a, m, n);         
    fclose(fr);
        
    SVGdraw drawing(n * stvorcek, m * stvorcek, "matica.svg");
    vykresliMaticu(a, m, n, drawing);

    // nacitanie suradnic pociatocneho stvorceka 
    int riadok, stlpec;
    scanf("%d", &riadok);
    scanf("%d", &stlpec);

    vyfarbi(a, m, n, riadok, stlpec, 4, drawing);

    drawing.finish();
    zmazMaticu(a, m);
}

Počítanie ostrovov

Obrázok, s ktorým sme pracovali vyššie, môže reprezentovať napríklad jednoduchú mapu súostrovia, kde more je znázornené modrou farbou a pevnina je znázornená žltou farbou. Úlohou môže byť zistiť počet ostrovov. Ten môžeme zistiť napríklad takto:

  • Prechádzame postupne všetky políčka mapy.
  • Ak narazíme na pevninu (t. j. žlté políčko), zvýšime doposiaľ nájdený počet ostrovov o 1 a ofarbíme celý ostrov (napríklad) na červeno.
  • Ak narazíme na ďalšie žlté políčko, opäť urobíme to isté.
  • Toto robíme, až kým prejdeme cez všetky políčka mapy.

Príklad mapy a jej zobrazenie pred začiatkom hľadania ostrovov, po nájdení prvých troch ostrovov a po nájdení všetkých ostrovov:

11 17
 1 1 1 1 1 1 3 1 1 1 1 1 1 1 1 1 1
 1 1 1 3 3 3 3 1 1 3 1 1 1 1 3 3 1
 1 1 1 3 3 1 1 1 1 1 1 1 1 1 3 3 1
 1 1 3 3 1 1 1 1 1 3 3 1 3 3 3 3 1
 1 1 1 3 3 1 1 1 1 3 1 1 3 3 3 3 1
 1 3 3 3 3 1 3 3 1 3 3 1 3 3 3 3 1
 1 1 3 3 1 1 1 3 1 1 1 1 1 1 1 1 1
 1 1 1 1 1 1 1 3 3 3 1 1 3 3 3 1 1
 1 1 1 1 1 1 1 3 3 3 1 1 3 1 3 1 1
 1 1 3 3 3 3 1 3 1 1 1 1 3 3 3 1 1
 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1

Do programu vyššie teda dorobíme funkciu

int najdiOstrovy(int **a, int m, int n, SVGdraw &drawing) {
    int ostrovov = 0;
    for (int i = 0; i < m; i++) {
        for (int j = 0; j < n; j++) {
            if (a[i][j] == 3) {
                ostrovov++;
                vyfarbi(a, m, n, i, j, 4, drawing);
            }
        }
    }
    return ostrovov;
}

a funkciu main môžeme zmeniť napríklad takto:

int main() {
    FILE *fr = fopen("ostrovy.txt", "r");
    assert(fr != NULL);
    int m, n;
    fscanf(fr, "%d %d", &m, &n);
    int **a = vytvorMaticu(m, n);
    nacitajMaticu(fr, a, m, n);
    fclose(fr);
        
    SVGdraw drawing(n * stvorcek, m * stvorcek, "mapa.svg");
    vykresliMaticu(a, m, n, drawing);

    int pocetOstrovov = najdiOstrovy(a, m, n, drawing);
    printf("Pocet ostrovov je %d.\n", pocetOstrovov);

    drawing.finish();
    zmazMaticu(a, m);
}

Cvičenie: upravte program tak, aby ešte navyše zistil, či má niektorý z ostrovov jazero.

Nerekurzívne vyfarbovanie

Vyfarbovanie s použitím zásobníka

S použitím niektorej implementácie zásobníka z minulej prednášky môžeme napísať aj nerekurzívnu verziu funkcie vyfarbi. Tá zakaždým vyberie zo zásobníka niektoré políčko, skontroluje všetkých jeho susedov a ak majú pôvodnú farbu, ofarbí ich a vloží ich na zásobník, aby sme ďalej skontrolovali aj ich susedov.

Súradnice jednotlivých susedov budeme počítať s použitím cyklu for a polí deltaStlpec a deltaRiadok, ktoré pre i = 0,1,2,3 obsahujú posuny jednotlivých súradníc i-teho suseda oproti práve spracúvanému políčku (dalo by sa použiť aj v rekurzívnej verzii).

struct policko {
    int riadok, stlpec;
};

typedef policko dataType;


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


const int deltaRiadok[4] = {0, 0, 1, -1};
const int deltaStlpec[4] = {1, -1, 0, 0};

/* Prefarbi suvislu jednofarebnu oblast obsahujucu 
 * poziciu (riadok,stlpec) na farbu s cislom farba. */  
void vyfarbi(int **a, int m, int n, 
             int riadok, int stlpec, int farba,
             SVGdraw &drawing) {
    int staraFarba = a[riadok][stlpec];
    if (staraFarba == farba) {
        return;
    }
    a[riadok][stlpec] = farba;
    vykresliStvorcek(riadok, stlpec, farby[farba], 
		     "lightgrey", drawing);
    stack s;
    init(s);
    
    policko p;
    p.riadok = riadok;
    p.stlpec = stlpec;
    push(s, p);
    
    while (!isEmpty(s)) {
        p = pop(s);
        for (int i = 0; i <= 3; i++) {
            policko sused;
            sused.riadok = p.riadok + deltaRiadok[i];
            sused.stlpec = p.stlpec + deltaStlpec[i];
            if (sused.riadok >= 0 && sused.riadok < m
                && sused.stlpec >= 0 && sused.stlpec < n
                && a[sused.riadok][sused.stlpec] == staraFarba) {
                a[sused.riadok][sused.stlpec] = farba;
                vykresliStvorcek(p.riadok, p.stlpec, farby[farba], 
                                "lightgrey", drawing);
                drawing.wait(pauza);
                push(s, sused);    
            }        
        }
    }
    destroy(s);
}

Vyfarbovanie s použitím radu

Namiesto zásobníka môžeme použiť aj rad. Obrazec sa potom bude vyfarbovať v poradí podľa vzdialenosti od počiatočného políčka. Tento algoritmus sa volá prehľadávanie do šírky, kým rekurzívna verzia zodpovedá prehľadávaniu do hĺbky.

struct policko {
    int riadok, stlpec;
};

typedef policko dataType;


/* Sem pride definicia struktury pre rad 
 * a funkcii poskytovanych radom. */


const int deltaRiadok[4] = {0, 0, 1, -1};
const int deltaStlpec[4] = {1, -1, 0, 0};

/* Prefarbi suvislu jednofarebnu oblast obsahujucu
 * poziciu (riadok,stlpec) na farbu s cislom farba. */  
void vyfarbi(int **a, int m, int n, 
             int riadok, int stlpec, int farba, 
             SVGdraw &drawing) {
    int staraFarba = a[riadok][stlpec];
    if (staraFarba == farba) {
        return;
    }
    a[riadok][stlpec] = farba;
    vykresliStvorcek(riadok, stlpec, farby[farba],
                     "lightgrey", drawing);
    
    queue q;
    init(q);
    
    policko p;
    p.riadok = riadok;
    p.stlpec = stlpec;
    enqueue(q, p);
    
    while (!isEmpty(q)) {
        p = dequeue(q);
        for (int i = 0; i <= 3; i++) {
            policko sused;
            sused.riadok = p.riadok + deltaRiadok[i];
            sused.stlpec = p.stlpec + deltaStlpec[i];
            if (sused.riadok >= 0 && sused.riadok < m
                && sused.stlpec >= 0 && sused.stlpec < n
                && a[sused.riadok][sused.stlpec] == staraFarba) {
                a[sused.riadok][sused.stlpec] = farba;
                vykresliStvorcek(sused.riadok, sused.stlpec, farby[farba],
                                 "lightgrey", drawing);
                drawing.wait(pauza);
                enqueue(q, sused);    
            }        
        }
    }
    destroy(q);
}

Program potom môžeme upraviť aj tak, aby do každého ofarbeného políčka vypísal jeho vzdialenosť od počiatočného políčka:

struct policko {
    int riadok, stlpec, vzd;
};

typedef policko dataType;


/* Sem pride definicia struktury pre rad 
 * a funkcii poskytovanych radom. */

void vypisVzdialenost(policko p, SVGdraw &drawing) {
    drawing.setLineColor("white");
    drawing.setFontSize(20);
    char text[15];
    sprintf(text, "%d", p.vzd);
    drawing.drawText((p.stlpec + 0.5) * stvorcek,
		     (p.riadok + 0.5) * stvorcek, text);
}

const int deltaRiadok[4] = {0, 0, 1, -1};
const int deltaStlpec[4] = {1, -1, 0, 0};

/* Prefarbi suvislu jednofarebnu oblast obsahujucu 
 * poziciu (riadok,stlpec) na farbu s cislom farba. */  
void vyfarbi(int **a, int m, int n, 
             int riadok, int stlpec, int farba, 
             SVGdraw &drawing) {
    int staraFarba = a[riadok][stlpec];
    if (staraFarba == farba) {
        return;
    }
    
    queue q;
    init(q);
    
    policko p;
    p.riadok = riadok;
    p.stlpec = stlpec;
    p.vzd = 0;
    enqueue(q, p);
    a[riadok][stlpec] = farba;
    vykresliStvorcek(riadok, stlpec, farby[farba],
                     "lightgrey", drawing);
    vypisVzdialenost(p, drawing);
    
    while (!isEmpty(q)) {
        p = dequeue(q);
        for (int i = 0; i <= 3; i++) {
            policko sused;
            sused.riadok = p.riadok + deltaRiadok[i];
            sused.stlpec = p.stlpec + deltaStlpec[i];
            if (sused.riadok >= 0 && sused.riadok < m
                && sused.stlpec >= 0 && sused.stlpec < n
                && a[sused.riadok][sused.stlpec] == staraFarba) {
	        sused.vzd = p.vzd + 1;
                a[sused.riadok][sused.stlpec] = farba;
                vykresliStvorcek(sused.riadok, sused.stlpec, farby[farba],
                                 "lightgrey", drawing);
                vypisVzdialenost(sused, drawing);
                drawing.wait(pauza);
                enqueue(q, sused);    
            }        
        }
    }
    destroy(q);
}

Zhrnutie

  • Vyfarbovanie súvislých oblastí v matici sa dá využiť v počítačovej grafike ale na iné úlohy, napríklad počítanie ostrovov na mape.
  • Dá sa jednoducho napísať rekurzívne.
  • Rekurziu vieme odstrániť použitím zásobníka alebo radu na ukladanie políčok, ktoré ešte treba spracovať.
  • Pri využití radu vieme spočítať aj vzdialenosť od počiatočného bodu
  • Budúci semester uvidíte na Programovaní (2) prehľadávanie grafov, ktoré funguje veľmi podobne, ale je trochu všeobecnejšie.

Na konci prednášky sme ešte prebrali úvodné časti prednášky 19.

Prednáška 19

Oznamy

Prednášky

  • Tento týždeň a budúci pondelok ešte prednášky v normálnom režime.
  • Budúcu stredu 11.12. v prvej polovici prednášky informácie k skúške a rady k skúškovému všeobecne, potom doberieme posledné povinné učivo.
  • Posledný týždeň semestra v pondelok 16.12. nepovinná prednáška o nepreberaných črtách jazykov C a C++, v stredu 18.12. prednáška pravdepodobne nebude.

Cvičenia a úlohy

  • Cvičenia bežia normálne každý utorok, piatkové cvičenia už iba 2x.
  • Zajtrajšia rozcvička bude na papieri, môžete si priniesť ťahák, učivo po prednášku 17.
  • Tretiu domácu úlohu treba odovzdať do tohto piatka (6.12.) 22:00.

Semestrálny test

  • Budúcu stredu, viď oznamy z minulej prednášky.
  • Bude obsahovať učivo po prednášku 20 vrátane. Na opravnom termíne môže byť aj učivo z ďalších prednášok.
  • V stredu po prednáške na testovač pridáme niektoré príklady z budúcich cvičení, aby ste ich mohli vopred použiť ako tréning na písomku.

Na termíny sa bude dať prihlasovať od dnes 19:00

  • Kapacita termínov bude obmedzená, prihláste sa teda radšej skôr, neskôr to môžete zmeniť.
  • Ak vidíte konflikt niektorého termínu s hromadnou skúškou alebo písomkou z iného predmetu, dajte mi prosím vedieť čím skôr.
  • Decembrový termín odporúčame hlavne študentom, ktorým programovanie nerobí problémy.
  • Viac informácií o skúške je na stránke #Zimný semester, skúška, spolu cez to prejdeme budúcu stredu.
  • Na testovač budúcu stredu pridáme zopár tréningových príkladov na skúšku.

Aritmetické výrazy

Nejaký čas sa teraz budeme venovať spracovaniu aritmetických výrazov pozostávajúcich z reálnych čísel, operácií +,-,*,/ a zátvoriek (,). Hlavnou úlohou je vyhodnotenie daného výrazu; napríklad pre výraz

(65 - 3 * 5) / (2 + 3)

chceme vedieť povedať, že jeho hodnota je 10. Najprv ale zavedieme dva alternatívne spôsoby zápisu aritmetických výrazov, z ktorých jeden nám vyhodnocovanie výrazov značne uľahčí.

Infixová notácia

  • Bežný spôsob zápisu aritmetických výrazov sa v matematike nazýva aj infixovou notáciou.
  • Binárne operátory (ako napríklad +,-,*,/) sa v tejto notácii píšu medzi svojimi dvoma operandmi.
  • Poradie vykonávania operácií sa riadi zátvorkami a prioritou operácií.

Napríklad

(65 – 3 * 5) / (2 + 3)

je infixový výraz s hodnotou 10.

Prefixová notácia

Pri prefixovej notácii (často podľa jej pôvodcu Jana Łukasiewicza nazývanej aj poľskou notáciou) sa každý operátor v aritmetickom výraze zapisuje pred svojimi dvoma operandmi.

Napríklad výraz (65 – 3 * 5) / (2 + 3) má prefixový zápis

/ - 65 * 3 5 + 2 3

Zaujímavosť: programovací jazyk Lisp a jeho varianty využívajú prefixovú notáciu na všetky výrazy, píšu však aj zátvorky, napríklad (+ 1 2).

Postfixová notácia

Pri postfixovej notácii (často nazývanej aj obrátenou poľskou notáciou) je situácia opačná: operátor sa zapisuje bezprostredne za svojimi dvoma operandmi.

Výraz (65 – 3 * 5) / (2 + 3) má teda postfixový zápis

65 3 5 * - 2 3 + /
  • Postfixová a prefixová notácia sú pre človeka o niečo ťažšie čitateľné, než bežná infixová notácia (čo môže byť aj otázkou zvyku).
  • Uvidíme však, že výrazy v postfixovej notácii sa dajú jednoducho vyhodnocovať.
  • Výhodou postfixovej a prefixovej notácie oproti infixovej je aj to, že nepotrebujú zátvorky.

Unárne mínus

  • Operátory +, * a / sú vždy binárne, čo znamená, že sa aplikujú sa na dva operandy.
  • Operátor - môže byť binárny aj unárny (unárny znamená, že sa aplikuje na jeden operand).
    • Príklad s binárnym mínus: 1 - 2.
    • Príklad s unárnym mínus: 2 * -(2 + 3).
  • Nevýhodou postfixovej a prefixovej notácie je, že bez zátvoriek v nich nie je možné rozpoznať binárne a unárne mínus.
    • Prefixový výraz - - 2 3 by napríklad mohol zodpovedať ako výrazu -2 - 3 aj výrazu -(2 - 3).
  • Na vyriešenie tohto problému budeme na unárne mínus používať znamienko ~ (iba interne v našom programe, nepoužíva sa tak všeobecne). V infixových výrazoch budú unárne mínus automaticky rozpoznané a nahradené ~.

Predspracovanie výrazu

Používateľ zadá výraz ako text v niektorej notácii. Aby sa nám s ním lepšie pracovalo, prevedieme ho najskôr na postupnosť symbolov (angl. token), pričom každý symbol bude buď číslo reprezentované v premennej typu double alebo znak reprezentujúci operátor alebo zátvorku.

Jednotlivé symboly budeme ukladať do záznamov typu token

struct token {
    char op;   
    double val;
};
  • Ak štruktúra obsahuje číslo, op bude medzera a samotné číslo bude v položke val.
  • Ak štruktúra reprezentuje iný symbol, tento symbol bude v položke op a položka val sa nebude používať.

Postupnosť symbolov uložíme do štruktúry tokenSequence, ktorá obsahuje pole položiek typu token a tiež koľko položiek bolo do poľa uložených.

struct tokenSequence {
    token * items;  // pole tokenov
    int size;       // veľkosť alokovaného poľa
    int length;     // počet tokenov uložených v poli
};


Napríklad pre výraz "(2.1+ 3.9) / 3" vznikne nasledujúca dátová štruktúra:

tokenSequence:
length: 7
size: nejaké číslo >= 7
items: pole s nasledovnými položkami

token items[0]: op '(', val ?
token items[1]: op ' ', val 2.1
token items[2]: op '+', val ?
token items[3]: op ' ', val 3.9
token items[4]: op ')', val ?
token items[5]: op '/', val ?
token items[6]: op ' ', val 3

Preklad výrazu na postupnosť symbolov realizuje funkcia tokenize.

  • Na načítanie čísla používame funkciu sscanf, ktorá vyzerá podobne ako scanf, ale načítava zo zadaného reťazca.
    • Ako reťazec zadáme &(str[strPos]), čo spôsobí, že sa začne načítavať od pozície strPos, mohli by sme písať aj str+strPos.
    • Formátovací znak %n nám do premennej skip uloží počet znakov, ktoré sa pri načítaní čísla použili, o tento počet potom posunieme premennú strPos.
  • Kód token newToken = {str[strPos], 0} inicializuje položku op na znak str[strPos] a položku val na nulu.
  • Pomocné funkcie init a addToken pracujú s postupnosťou symbolov, nájdete ich v programe na konci prednášky.
void tokenize(char * str, tokenSequence & tokens) {
    init(tokens, strlen(str));  // inicializujeme prázdnu postupnosť

    int strPos = 0;  // pozícia v rámci reťazca
    while (str[strPos] != 0) {  // kým nie sme na konci str
        if (isspace(str[strPos])) {  // preskakujeme biele znaky
            strPos++;
        } else if (isdigit(str[strPos]) || str[strPos] == '.') {
            // keď nájdeme cifru alebo bodku (začiatok čísla)
            double val;
            int skip;
            // načítame toto číslo pomocou sscanf, 
            // do skip uložíme počet znakov čísla
            sscanf(&(str[strPos]), "%lf%n", &val, &skip);
            // vytvoríme a uložíme token
            token newToken = {' ', val};
            addToken(tokens, newToken);
            // preskočíme všetky znaky čísla
            strPos += skip;
        } else {
            // spracovanie zátvoriek alebo operátora
            assert(strchr("+-/*()~", str[strPos]) != NULL);
            // vytvoríme a uložíme token
            token newToken = {str[strPos], 0};
            addToken(tokens, newToken);
            strPos++;
        }
    }
}

Vyhodnocovanie aritmetických výrazov v postfixovej notácii

  • Budeme používať zásobník, do ktorého budeme vkladať čísla čakajúce na spracovanie.
  • Operátor má v postfixovom zápise obidva operandy pred sebou. Keď narazíme na operátor, jeho operandy sme už prečítali a spracovali. Ide navyše o posledné dve prečítané alebo vypočítané hodnoty.

Výraz budeme postupne čítať zľava doprava:

  • Keď narazíme na číslo, vložíme ho na zásobník.
  • Keď narazíme na operátor:
    • Vyberieme zo zásobníka jeho dva operandy.
    • Prvé z týchto čísel je pritom druhým operandom a druhé z nich je prvým operandom.
    • Vykonáme s týmito operandmi danú operáciu a výsledok tejto operácie vložíme naspäť na zásobník.
  • Pri unárnom mínus vyberáme zo zásobníka iba jeden operand.
  • Tento postup opakujeme, až kým neprídeme na koniec výrazu. V tom okamihu by sme mali mať na zásobníku jediný prvok, výslednú hodnotu výrazu.

Technická poznámka:

  • Keďže vo výslednom programe budeme potrebovať zásobníky čísel aj znakov, používame v ňom zásobník položiek typu token. Ak by sme však robili čisto vyhodnocovanie postfixovej notácie, stačil by nám zásobník s prvkami typu double.

Cvičenie:

  • Odsimulujme činnosť tohto algoritmu na našom vstupe 65 3 5 * - 2 3 + /
  • Aké všelijaké prípady môžu nastať, keď na vstupe nemáme správny výraz v postfixovej notácii?
typedef token dataType;
// Nasleduje kód pre zásobník tokenov
// ...


/** Funkcia aplikuje operátor uložený v opToken na čísla
* uložené v num1 a num2, výsledok uloží ako číslo do result.
* Unárny operátor sa aplikuje iba na num1. */
void applyOp(token opToken, token num1, token num2, token & result) {
    result.op = ' ';
    switch (opToken.op) {
    case '~':
        result.val = -num1.val;
        break;
    case '+':
        result.val = num1.val + num2.val;
        break;
    case '-':
        result.val = num1.val - num2.val;
        break;
    case '*':
        result.val = num1.val * num2.val;
        break;
    case '/':
        result.val = num1.val / num2.val;
        break;
    default: // iné operátory nepovoľujeme.
        assert(false);
    }
}

/** Funkcia vyhodnotí a vráti hodnotou výrazu v postfixovom tvare. */
double evaluatePostfix(tokenSequence & tokens) {
    // zásobník, do ktorého ukladáme čísla
    stack numberStack;
    init(numberStack);

    for (int i = 0; i < tokens.length; i++) {
        // aktuálny token zo vstupu
        token curToken = tokens.items[i];
        if (curToken.op == ' ') {
            // čísla rovno ukladáme na zásobník
            push(numberStack, curToken);
        } else {
            // spracovanie operátora
            token num1, num2, result;
            // najskôr vyberieme 1 alebo 2 čísla zo zásobníka
            if (curToken.op == '~') {
                num1 = pop(numberStack);
            } else {
                num2 = pop(numberStack);
                num1 = pop(numberStack);
            }
            // na operandy aplikujeme operátor
            applyOp(curToken, num1, num2, result);
            // výsledné číslo uložíme na zásobník
            push(numberStack, result);
        }
    }
    // zo zásobníka vyberieme výsledné číslo
    token result = pop(numberStack);
    // skontrolujeme, že zásobník je prázdny a výsledok je číslo
    assert(isEmpty(numberStack) && result.op == ' ');
    // uvoľníme pamäť zásobníka
    destroy(numberStack);
    return result.val;
}

Prevod výrazu z infixovej do postfixovej notácie

  • Pre bežnú prax je dôležitejšie vyhodnocovanie výrazov v klasickej infixovej notácii.
  • Túto úlohu teraz vyriešime tak, že napíšeme funkciu, ktorá preloží aritmetický výraz z infixovej do postfixovej notácie.
  • Následne ho môžeme vyhodnotiť algoritmom popísaným vyššie.

Popis algoritmu

Uvažujme najprv výrazy bez zátvoriek a bez unárnych mínusov.

  • Pozostávajú teda z čísel a binárnych operátorov +, -, *, /, kde * a / majú vyššiu prioritu ako + a -.
  • Všetky tieto operátory sú navyše zľava asociatívne, t.j. napr. 30 / 6 / 5 je to isté ako (30 / 6) / 5.

Na prevod takéhoto výrazu do postfixového tvaru ním stačí prejsť zľava doprava a všimnúť si dve skutočnosti:

  • Poradie jednotlivých čísel v postfixovom výraze je rovnaké, ako v pôvodnom infixovom výraze.
  • Napríklad výraz 1 + 2 - 3 * 4 + 5 má postfixový tvar 1 2 + 3 4 * - 5 +.
  • Pri prechádzaní vstupným infixovým výrazom budeme teda čísla priamo pridávať do výstupného postfixového výrazu.
  • Každý operátor treba presunúť spomedzi jeho dvoch operandov za jeho druhý operand.
  • Ak teda vo vstupnom infixovom výraze narazíme na operátor, nepridáme ho hneď do výstupného výrazu, ale uložíme ho pre neskoršie pridanie na správnej pozícii.
  • Uložený operátor treba vypísať za jeho druhým operandom. Ak pritom aj samotný operand obsahuje nejaké ďalšie operátory, určite musia byť vypísané skôr. Operátory teda budeme ukladať na zásobník.
  • Keď vo vstupnom infixovom výraze narazíme na operátor, je možné, že tesne pred ním končí operand jedného alebo niekoľkých operátorov uložených na zásobníku. Vďaka ľavej asociatívnosti pritom ide o práve všetky operátory na vrchu zásobníka, ktoré majú vyššiu alebo rovnakú prioritu, ako nájdený operátor. Všetky tieto operátory teda postupne vyberieme zo zásobníka a vypíšeme ich do výstupného reťazca. Až následne na zásobník vložíme nájdený operátor.
  • Na konci vstupného reťazca vypíšeme všetky operátory, ktoré zostali na zásobníku.

Z technických dôvodov budeme na spodku zásobníka uchovávať „umelé dno” #, ktoré budeme chápať ako symbol s nižšou prioritou ako všetky operátory.

  • Situáciu, keď pri vyberaní operátorov zo zásobníka narazíme na jeho dno tak budeme môcť riešiť konzistentne so situáciou, keď narazíme na operátor s nižšou prioritou: v oboch prípadoch chceme s vyberaním prestať.

Na vstupnom výraze 1 + 2 - 3 * 4 + 5 bude práve popísaný algoritmus pracovať nasledovne:

Vstupný symbol  Výstupný výraz    Zásobník (dno vľavo) 
-------------------------------------------------------------------------------------------------------
           1                       #            vypíš 1 
                1                  #

           +    1                  #            # má nižšiu prioritu ako +, nevyberaj ho
                1                  # +          vlož + na zásobník

           2    1                  # +          vypíš 2
                1 2                # + 

           -    1 2                # +          + má rovnakú prioritu ako -, vyber + a vypíš
                1 2 +              #            # má nižšiu prioritu ako -, nevyberaj ho
                1 2 +              #            vlož - na zásobník
                1 2 +              # -   

           3    1 2 +              # -          vypíš 3
                1 2 + 3            # - 

           *    1 2 + 3            # -          - má nižšiu prioritu ako *, nevyberaj ho
                1 2 + 3            # - *        vlož * na zásobník

           4    1 2 + 3            # - *        vypíš 4
                1 2 + 3 4          # - *                     

           +    1 2 + 3 4          # - *        * má vyššiu prioritu ako +, vyber * a vypíš
                1 2 + 3 4 *        # -          - má rovnakú prioritu ako +, vyber - a vypíš
                1 2 + 3 4 * -      #            # má nižšiu prioritu ako +, nevyberaj ho
                1 2 + 3 4 * -      # +          vlož - na zásobník

           5    1 2 + 3 4 * -      # +          vypíš 5
                1 2 + 3 4 * - 5    # +                 

    [koniec]    1 2 + 3 4 * - 5    # +          vyber operátory na zásobníku a vypíš
                1 2 + 3 4 * - 5 +  #            <-- VÝSLEDNÝ POSTFIXOVÝ VÝRAZ

Pridanie zátvoriek:

  • Keď vo vstupnom infixovom reťazci narazíme na ľavú zátvorku, vložíme ju do zásobníka. Podobne ako pri # ju budeme považovať za symbol s nižšou prioritou, než všetky binárne operátory. To má dva dôsledky:
    • Pri vkladaní zátvorky teda nevyhadzujeme operátory zo zásobníka
    • Operátory vo vnútri zátvorky nespôsobia jej vyhodenie ani vyhodenie symbolov pod ňou
  • Keď narazíme na pravú zátvorku, potrebujeme vypísať všetky doposiaľ nevypísané operátory uzatvorené touto zátvorkou. Preto vyberieme a vypíšeme na výstup všetky operátory zo zásobníka až po prvú ľavú zátvorku (ktorú zo zásobníka taktiež vyberieme).

Pridanie unárneho mínus:

  • Unárne mínus má vyššiu prioritu ako binárne operátory. Predpokladáme, že ho máme zapísané ako ~.
  • Je však sprava asociatívne, t.j. --2 je to isté ako -(-2) a v postfixovom tvare to bude 2 ~ ~.
  • Preto ak je aktuálny operátor unárne mínus ~, nevyhadzujeme iné unárne mínus zo zásobníka. Toto je v programe dosiahnuté tak, že nové unárne mínus ("pravé") má vyššiu prioritu ako to, čo už je na zásobníku ("ľavé").

Implementácia

/** Pomocná funkcia, ktorá vráti prioritu operátora.
 * Argument right určuje, či ide o pravý z dvoch porovnávaných operátorov */
int priority(char op, bool right) {
    if (op == '#' || op == '(') return 0;
    if (op == '-' || op == '+') return 1;
    if (op == '*' || op == '/') return 2;
    if (op == '~' && !right) return 3;
    if (op == '~' && right) return 4;
    assert(false); // sem by sme sa nemali dostať
}

/** Skonvertuje infixový výraz infix do postfixovej formy */
void infixToPostfix(tokenSequence & infix, tokenSequence & postfix) {
    // inicializuje výslednú postupnosť tokenov
    init(postfix, infix.length);

    // vytvoríme zásobník operátorov a na spodok dáme #
    stack opStack;
    init(opStack);
    token curToken = {'#', 0};
    push(opStack, curToken);

    for (int i = 0; i < infix.length; i++) {
        // aktuálny token zo vstupu
        curToken = infix.items[i];
        if (curToken.op == ' ') {
            // čísla rovno skopírujeme do výstupu
            addToken(postfix, curToken);
        } else if (curToken.op == '(') {
            // začiatok zátvorky uložíme na zásobník
            push(opStack, curToken);
        } else if (curToken.op == ')') {
            // koniec zátvorky:
            // na výstup pridáme zo zásobníka operátory,
            // ktoré boli v zátvorke
            token popped = pop(opStack);
            while (popped.op != '(') {
                addToken(postfix, popped);
                popped = pop(opStack);
            }
        } else {
            // spracovanie operátora:
            // na výstup pridáme zo zásobníka operátory s vyššou prioritou
            int p = priority(curToken.op, true);
            while (priority(peek(opStack).op, false) >= p) {
                token popped = pop(opStack);
                addToken(postfix, popped);
            }
            // aktuálny operátor dáme na zásobník
            push(opStack, curToken);
        }
    }

    // zvyšné operátory presunieme zo zásobníka na výstup
    while (peek(opStack).op != '#') {
        token popped = pop(opStack);
        addToken(postfix, popped);
    }
    destroy(opStack);
}

Cvičenie: Rozšírte program vyššie o operáciu umocňovania ^ s prioritou vyššou ako * (dajte pozor na fakt, že umocňovanie je na rozdiel od operácií +, -, *, / sprava asociatívne).


Predspracovanie unárneho mínus

  • Pre prevodom infixového výrazu do postfixového tvaru potrebujeme v infixovom výraze nahradiť unárne mínus znakom ~.
  • Robí to funkcia fixUnaryMinus.
  • Mínus je v korektnom infixovom výraze unárne práve vtedy, keď nenasleduje za číslom, ani za pravou zátvorkou, čo testuje pomocná funkcia isOperand.
/** Pomocná funkcia, ktorá vráti, či token je koncom operandu v infixovej notácii */
bool isOperand(token curToken) {
    return curToken.op == ' ' || curToken.op == ')';
}

/** Funkcia, ktorá v infixovom výraze zmení unárne mínus na ~ */
void fixUnaryMinus(tokenSequence & infix) {
    for (int i = 0; i < infix.length; i++) {
        if(infix.items[i].op == '-' 
           && (i == 0 || !isOperand(infix.items[i - 1]))) {
            infix.items[i].op = '~';
        }
    }
}

Záverečné poznámky

  • Infixové výrazy vieme vyhodnotiť tak, že najskôr ich prevedieme na postfixové a tie potom vyhodnotíme.
    • Pri vyhodnocovaní postfixovej formy používame zásobník čísel (operandov).
    • Pri prevode z infixovej formy na postfixovú používame zásobník operátorov.
  • Obidva procesy sa dajú vykonávať aj súčasne (počas prechodu výrazom používame jeden zásobník čísel a jeden zásobník operátorov).
  • Všeobecnejšie a elegantnejšie prístupy k analýze a vyhodnocovaniu výrazov ale aj celých programov uvidíte na predmete Kompilátory (Mgr. štúdium), ktorý využíva poznatky z predmetu Formálne jazyky a automaty (semester 2Z).


Typ dát na zásobníku

V našom programe sme potrebovali zásobník čísel aj zásobník znakov.

  • Vyriešili sme to tým, že na zásobník dávame položky typu token, ktoré obsahujú znak aj číslo.
  • Nie je to však príliš elegantné a plytve pamäťou na nepotrebné údaje.
  • Druhá možnosť je dvakrát implementovať celý zásobník pre rôzne typy dát, kopírovanie a menenie kódu je však tiež niečo, čomu sa chceme vyhnúť.
  • Najlepšie by bolo použiť techniku generického programovania, ktorá je k dispozícii vo veľa jazykoch, vrátane C++, ale nie C. Touto technikou vieme zásobník implementovať raz a použiť s rôznymi typmi. Ukážku tejto techniky uvidíme na poslednej prednáške a viac sa dozviete na Programovaní (2) v letnom semestri.

Na konci prednášky sme ešte prebrali úvodné časti prednášky 20.

Program na prácu s výrazmi

#include <cstdio>
#include <cctype>
#include <cassert>
#include <cstring>
using namespace std;

/** Štruktúra pre jednu súčasť výrazu: znamienko alebo číslo */
struct token {
    char op;     // znamienko alebo medzera, ak ide o číslo
    double val;  // číselná hodnota, ak je op medzera
};

/** Štruktúra pre postupnosť tokenov */
struct tokenSequence {
    token * items;  // pole tokenov
    int size;       // veľkosť alokovaného poľa
    int length;     // počet tokenov uložených v poli
};

/** Funkcia inicializuje prázdnu postupnosť tokenov
pričom alokuje pole požadovanej veľkosti. */
void init(tokenSequence & tokens, int size) {
    tokens.items = new token[size];
    tokens.size = size;
    tokens.length = 0;
}

/** Funkcia do postupnosti tokenov pridá nový token. */
void addToken(tokenSequence & tokens, token & newToken) {
    assert(tokens.length < tokens.size);
    tokens.items[tokens.length] = newToken;
    tokens.length++;
}

/** Funkcia odalokuje pamäť alokovanú pre postupnosť tokenov */
void destroy(tokenSequence & tokens) {
    delete[] tokens.items;
}

/** Funkcia vypíše postupnosť tokenov, každý v hranatých zátvorkách. */
void printTokens(tokenSequence & tokens) {
    for (int i = 0; i < tokens.length; i++) {
        token curToken = tokens.items[i];
        if (curToken.op == ' ') {
            printf(" [val %g]", curToken.val);
        } else {
            printf(" [op %c]", curToken.op);
        }
    }
    printf("\n");
}

/** Funkcia vypíše postupnosť tokenov ako aritmetický výraz. */
void printTokenExpression(tokenSequence & tokens) {
    for (int i = 0; i < tokens.length; i++) {
        token curToken = tokens.items[i];
        if (curToken.op == ' ') {
            printf(" %g", curToken.val);
        } else {
            printf(" %c", curToken.op);
        }
    }
    printf("\n");
}

/** Funkcia konvertuje výraz z reťazca na postupnosť tokenov. */
void tokenize(char * str, tokenSequence & tokens) {
    init(tokens, strlen(str));  // inicializujeme prázdnu postupnosť

    int strPos = 0;  // pozícia v rámci reťazca
    while (str[strPos] != 0) {  // kým nie sme na konci str
        if (isspace(str[strPos])) {  // preskakujeme biele znaky
            strPos++;
        } else if (isdigit(str[strPos]) || str[strPos] == '.') {
            // keď nájdeme cifru alebo bodku (začiatok čísla)
            double val;
            int skip;
            // načítame toto číslo pomocou sscanf, do skip uložíme počet znakov čísla
            sscanf(&(str[strPos]), "%lf%n", &val, &skip);
            // vytvoríme a uložíme token
            token newToken = {' ', val};
            addToken(tokens, newToken);
            // preskočíme všetky znaky čísla
            strPos += skip;
        } else {
            // spracovanie zátvoriek alebo operátora
            assert(strchr("+-/*()~", str[strPos]) != NULL);
            // vytvoríme a uložíme token
            token newToken = {str[strPos], 0};
            addToken(tokens, newToken);
            strPos++;
        }
    }
}

// Nasleduje kód pre zásobník tokenov
typedef token dataType;

/** Uzol spájaného zoznamu pre zásobník */
struct node {
    dataType data; // dáta uložené v uzle
    node * next;   // smerník na ďalší uzol zoznamu
};

/** Štruktúra pre zásobník implementovaný pomocou zoznamu*/
struct stack {
    node * top; // Smernik na vrch zasobníka  alebo NULL
};

/** Funkcia inicializuje prázdny zásobník */
void init(stack & s) {
    s.top = NULL;
}

/** Funkcia zistí, či je zásobník prázdny */
bool isEmpty(stack & s) {
    return s.top == NULL;
}

/** Funkcia pridá prvok item na vrch zásobníka */
void push(stack & s, dataType item) {
    node * tmp = new node;
    tmp->data = item;
    tmp->next = s.top;
    s.top = tmp;
}

/** Funkcia odoberie prvok z vrchu zasobnika a vráti ho */
dataType pop(stack & s) {
    assert(!isEmpty(s));
    dataType result = s.top->data;
    node * tmp = s.top->next;
    delete s.top;
    s.top = tmp;
    return result;
}

/** Funkcia vráti prvok na vrchu zásobníka, ale nechá ho v zásobníku */
dataType peek(stack & s) {
    assert(!isEmpty(s));
    return s.top->data;
}

/** Funkcia uvoľní pamäť zásobníka */
void destroy(stack & s) {
    while (!isEmpty(s)) {
        pop(s);
    }
}

/** Funkcia aplikuje operátor uložený v opToken na čísla
* uložené v num1 a num2, výsledok uloží ako číslo do result.
* Unárny operátor sa aplikuje iba na num1. */
void applyOp(token opToken, token num1, token num2, token & result) {
    result.op = ' ';
    switch (opToken.op) {
    case '~':
        result.val = -num1.val;
        break;
    case '+':
        result.val = num1.val + num2.val;
        break;
    case '-':
        result.val = num1.val - num2.val;
        break;
    case '*':
        result.val = num1.val * num2.val;
        break;
    case '/':
        result.val = num1.val / num2.val;
        break;
    default: // iné operátory nepovoľujeme.
        assert(false);
    }
}

/** Funkcia vyhodnotí a vráti hodnotou výrazu v postfixovom tvare. */
double evaluatePostfix(tokenSequence & tokens) {
    // zásobník, do ktorého ukladáme čísla
    stack numberStack;
    init(numberStack);

    for (int i = 0; i < tokens.length; i++) {
        // aktuálny token zo vstupu
        token curToken = tokens.items[i];
        if (curToken.op == ' ') {
            // čísla rovno ukladáme na zásobník
            push(numberStack, curToken);
        } else {
            // spracovanie operátora
            token num1, num2, result;
            // najskôr vyberieme 1 alebo 2 čísla zo zásobníka
            if (curToken.op == '~') {
                num1 = pop(numberStack);
            } else {
                num2 = pop(numberStack);
                num1 = pop(numberStack);
            }
            // na operandy aplikujeme operátor
            applyOp(curToken, num1, num2, result);
            // výsledné číslo uložíme na zásobník
            push(numberStack, result);
        }
    }
    // zo zásobníka vyberieme výsledné číslo
    token result = pop(numberStack);
    // skontrolujeme, že zásobník je prázdny a výsledok je číslo
    assert(isEmpty(numberStack) && result.op == ' ');
    // uvoľníme pamäť zásobníka
    destroy(numberStack);
    return result.val;
}

/** Pomocná funkcia, ktorá vráti prioritu operátora.
* Argument right určuje, či ide o pravý z dvoch porovnávaných operátorov */
int priority(char op, bool right) {
    if (op == '#' || op == '(') return 0;
    if (op == '-' || op == '+') return 1;
    if (op == '*' || op == '/') return 2;
    if (op == '~' && !right) return 3;
    if (op == '~' && right) return 4;
    assert(false); // sem by sme sa nemali dostať
}

/** Skonvertuje infixový výraz infix do postfixovej formy */
void infixToPostfix(tokenSequence & infix, tokenSequence & postfix) {
    // inicializuje výslednú postupnosť tokenov
    init(postfix, infix.length);

    // vytvoríme zásobník operátorov a na spodok dáme #
    stack opStack;
    init(opStack);
    token curToken = {'#', 0};
    push(opStack, curToken);

    for (int i = 0; i < infix.length; i++) {
        // aktuálny token zo vstupu
        curToken = infix.items[i];
        if (curToken.op == ' ') {
            // čísla rovno skopírujeme do výstupu
            addToken(postfix, curToken);
        } else if (curToken.op == '(') {
            // začiatok zátvorky uložíme na zásobník
            push(opStack, curToken);
        } else if (curToken.op == ')') {
            // koniec zátvorky:
            // na výstup pridáme zo zásobníka operátory,
            // ktoré boli v zátvorke
            token popped = pop(opStack);
            while (popped.op != '(') {
                addToken(postfix, popped);
                popped = pop(opStack);
            }
        } else {
            // spracovanie operátora:
            // na výstup pridáme zo zásobníka operátory s vyššou prioritou
            int p = priority(curToken.op, true);
            while (priority(peek(opStack).op, false) >= p) {
                token popped = pop(opStack);
                addToken(postfix, popped);
            }
            // aktuálny operátor dáme na zásobník
            push(opStack, curToken);
        }
    }

    // zvyšné operátory presunieme zo zásobníka na výstup
    while (peek(opStack).op != '#') {
        token popped = pop(opStack);
        addToken(postfix, popped);
    }
    destroy(opStack);
}

/** Pomocná funkcia, ktorá zo zásobníka vyberie
* jedno alebo dve čísla, aplikuje na nich operátor a výsledok uloží na zásobník. */
void processOp(stack & numberStack, token curToken) {
    token num1, num2, result;
    // najskôr vyberieme 1 alebo 2 čísla zo zásobníka
    if (curToken.op == '~') {
        num1 = pop(numberStack);
    } else {
        num2 = pop(numberStack);
        num1 = pop(numberStack);
    }
    // na operandy aplikujeme operátor
    applyOp(curToken, num1, num2, result);
    // výsledné číslo uložíme na zásobník
    push(numberStack, result);
}

/** Spočíta hodnotu výrau v infixovej forme,
 * kombinuje infixToPostfix a evaluatePostfix */
double evaluateInfix(tokenSequence & infix) {
    // zásobník, do ktorého ukladáme čísla
    stack numberStack;
    init(numberStack);

    // vytvoríme zásobník operátorov a na spodok dáme #
    stack opStack;
    init(opStack);
    token curToken = {'#', 0};
    push(opStack, curToken);

    for (int i = 0; i < infix.length; i++) {
        // aktuálny token zo vstupu
        curToken = infix.items[i];
        if (curToken.op == ' ') {
            // čísla ukladáme na zásobník
            push(numberStack, curToken);
        } else if (curToken.op == '(') {
            // začiatok zátvorky uložíme na zásobník
            push(opStack, curToken);
        } else if (curToken.op == ')') {
            // koniec zátvorky:
            // spracujeme zo zásobníka operátory,
            // ktoré boli v zátvorke
            token popped = pop(opStack);
            while (popped.op != '(') {
                processOp(numberStack, popped);
                popped = pop(opStack);
            }
        } else {
            // spracovanie operátora:
            // spracujeme zo zásobníka operátory s vyššou prioritou
            int p = priority(curToken.op, true);
            while (priority(peek(opStack).op, false) >= p) {
                token popped = pop(opStack);
                processOp(numberStack, popped);
            }
            // aktuálny operátor dáme na zásobník
            push(opStack, curToken);
        }
    }

    // spracujeme zvyšné operátory zo zásobníka
    while (peek(opStack).op != '#') {
        token popped = pop(opStack);
        processOp(numberStack, popped);
    }
    destroy(opStack);

    // zo zásobníka vyberieme výsledné číslo
    token result = pop(numberStack);
    // skontrolujeme, že ásobník je prázdny a výsledok je číslo
    assert(isEmpty(numberStack) && result.op == ' ');
    // uvoľníme pamäť zásobníka čísel
    destroy(numberStack);
    return result.val;
}

/** Pomocná funkcia, ktorá vráti, či token je koncom operandu v infixovej notácii */
bool isOperand(token curToken) {
    return curToken.op == ' ' || curToken.op == ')';
}

/** Funkcia, ktorá v infixovom výraze zmení unárne mínus na ~ */
void fixUnaryMinus(tokenSequence & infix) {
    for (int i = 0; i < infix.length; i++) {
        if (infix.items[i].op == '-'
                && (i == 0 || !isOperand(infix.items[i - 1]))) {
            infix.items[i].op = '~';
        }
    }
}

/** Hlavný program načítava a vykonáva postupnosť príkazov tokenize, postfix, infix a end.
 * Príkaz tokenize tokenizuje a vypíše vstupný výraz.
 * Príkaz postfix načíta výraz v postfixovom formáte a vypíše jeho hodnotu.
 * Príkaz infix načíta výraz v infixovom formáte, upraví v ňom unárne - na ~.
 * prevedie ho do postfixového formátu, vyhodnotí postfixový výraz a naopokon
 * vyhodnotí aj priamo infixovú formu výrazu.  */
int main() {
    const int maxLine = 100;
    char command[maxLine];
    char expression[maxLine];

    while (true) {
        int ret = scanf("%s", command);
        if (ret < 1 || strcmp(command, "end") == 0) {
            break;
        } else if (strcmp(command, "tokenize") == 0) {
            fgets(expression, maxLine, stdin);
            tokenSequence tokens;
            tokenize(expression, tokens);
            printf(" tokens:");
            printTokens(tokens);
            destroy(tokens);
        } else if (strcmp(command, "postfix") == 0) {
            fgets(expression, maxLine, stdin);
            tokenSequence postfix;
            tokenize(expression, postfix);
            double value = evaluatePostfix(postfix);
            printf(" value: %g\n", value);
            destroy(postfix);
        }
        if (strcmp(command, "infix") == 0) {
            fgets(expression, maxLine, stdin);
            tokenSequence infix;
            tokenize(expression, infix);
            fixUnaryMinus(infix);
            printf(" infix:");
            printTokenExpression(infix);
            tokenSequence postfix;
            infixToPostfix(infix, postfix);
            printf(" postfix:");
            printTokenExpression(postfix);
            double value = evaluatePostfix(postfix);
            printf(" value of postfix: %g\n", value);
            double value2 = evaluateInfix(infix);
            printf(" value of infix: %g\n", value2);
            destroy(infix);
            destroy(postfix);
        }
    }
}

Prednáška 20

Oznamy

  • Na testovači dnes pribudnú nejaké úlohy z budúcich cvičení, ktoré vám pomôžu s prípravou na test.
  • V piatok sú cvičenia nepovinné, budú na nich vzorové riešenia testu. Môžeme vám tiež poradiť s riešením úloh z cvičení alebo domácej úlohy, prípadne ak máte otázky k ukážkovým príkladom na test.
  • Termín DÚ3 je v piatok večer.
  • Takisto do piatka je potrebné hlásiť záujem o prípadný preklad zadaní, viď informácie k testu

Opakovanie z minulej prednášky

Aritmetické výrazy

  • Bežná infixová notácia, napr. (65 – 3*5)/(2 + 3)
  • Postfixová notácia 65 3 5 * - 2 3 + /
  • Prefixová notácia / - 65 * 3 5 + 2 3
  • Prefixová a postfixová notácia nepotrebujú zátvorky
  • Prevod z infixovej notácie na postfixovú pomocou zásobníka
    • Čo si ukladáme do zásobníka?
  • Vyhodnocovanie postfixovej notácie pomocou zásobníka
    • Čo si ukladáme do zásobníka?

Aritmetický výraz ako strom

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

Aritmetické výrazy môžeme veľmi prirodzene reprezentovať vo forme stromu

  • Operátory a čísla tvoria tzv. uzly (alebo vrcholy) stromu.
  • Operátory tvoria tzv. vnútorné uzly stromu, každý z nich má dve deti zodpovedajúce podvýrazom pre jednotlivé operandy.
    • Pre jednoduchosť na dnešnej prednáške neuvažujeme unárne mínus, dalo by sa však ľahko dorobiť.
  • Čísla tvoria tzv. listy stromu, tie už nemajú žiadne deti.
  • Strom obsahuje jediný uzol, ktorý nemá rodiča. Tento sa nazýva koreň stromu a reprezentuje celý aritmetický výraz.
  • Informatici stromy väčšinou kreslia „hore nohami”, s koreňom na vrchu.

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

struct treeNode {
    // číselná hodnota (len v listoch)
    double val;

    // operátor vo vnútorných uzloch, pre listy medzera
    char op;

    // smerníky na podstromy
    treeNode * left, * right;
};

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.

V tejto reprezentácii sú niektoré položky štruktúry treeNode nevyužité (napr. val vo vnútorných vrcholoch). S využitím objektového programovania (letný semester) budeme vedieť stromy pre aritmetické výrazy reprezentovať elegantnejšie.

Vytvorenie uzlu

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);

Ďalší plán

Ďalej uvidíme:

  • jednoduchú rekurzívnu funkciu na vyhodnotenie výrazu uloženého ako strom,
  • ďalšie rekurzívne funkcie na uvoľnenie pamäte stromu a výpis výrazu,
  • funkciu na vytvorenie stromu z postfixového výrazu.

Stromy majú v informatike veľa využití, nielen na aritmetické výrazy. Ďalej sa teda budeme venovať práci so stromami všeobecne.

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.

  • Ak je zadaný vrchol listom, vrátime hodnotu uloženú v položke val.
  • V opačnom prípade rekurzívne spočítame hodnoty pre obidva podvýrazy a skombinujeme ich podľa typu znamienka.
  • Celkovo veľmi jednoduchý a prirodzený výpočet, nie je potrebný explicitný zásobník.
  • Funkcia nižšie nefunguje pre unárne mínus, nebolo by však ťažké ho dorobiť.

Rekurziu budeme používať vždy, keď potrebujeme prejsť všetky uzly stromu. Cyklom sa to programuje ťažko, lebo z uzla potrebujeme ísť doľava aj doprava.

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
}

Uvoľnenie pamäte

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

  • Opäť používa rekurziu na prejdenie celého stromu.
  • Pozor na poradie príkazov, treba najskôr uvoľniť podstromy až potom zavolať delete na root, inak by sme stratili prístup k deťom.
  • Všimnite si, ako sú riešené triviálne prípady, funkcia ani nezisťuje, s akým typom uzla pracuje.
void destroyTree(treeNode * root) {
    if (root != NULL) {
        destroyTree(root->left);
        destroyTree(root->right);
        delete root;
    }
}

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í.

  • Opäť používajú rekurziu na prejdenie celého stromu.
  • Líšia sa hlavne umiestnením príkazu na vypísanie operátora (pred, medzi alebo za rekurzívnym vypísaním podvýrazov).
  • Infixová notácia potrebuje aj zátvorky. Táto funkcia ich pre istotu dáva všade. Rozmyslite si, ako by sme ich vedeli vypísať iba tam, kde treba.
  • Ako by sme funkcie rozšírili pre unárne mínus?
/** Funkcia vypíše aritmetický výraz v inorder poradí */
void printInorder(treeNode * root) {
    if (root->op == ' ') {
        printf("%g", root->val);
    } else {
        printf("(");
        printInorder(root->left);
        printf(" %c ", root->op);
        printInorder(root->right);
        printf(")");
    }
}

/** Funkcia vypíše aritmetický výraz v preorder poradí */
void printPreorder(treeNode * root) {
    if (root->op == ' ') {
        printf("%g ", root->val);
    } else {
        printf("%c ", root->op);
        printPreorder(root->left);
        printPreorder(root->right);
    }
}

/** Funkcia vypíše aritmetický výraz v postorder poradí */
void printPostorder(treeNode * root) {
    if (root->op == ' ') {
        printf("%g ", root->val);
    } else {
        printPostorder(root->left);
        printPostorder(root->right);
        printf("%c ", root->op);
    }
}

Vytvorenie stromu z postfixového výrazu

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

/** Funkcia vyhodnotí a vráti hodnotou výrazu v postfixovom tvare. */
double evaluatePostfix(tokenSequence & tokens) {
    // zásobník, do ktorého ukladáme čísla
    stack numberStack;
    init(numberStack);

    for (int i = 0; i < tokens.length; i++) {
        // aktuálny token zo vstupu
        token curToken = tokens.items[i];
        if (curToken.op == ' ') {
            // čísla rovno ukladáme na zásobník
            push(numberStack, curToken);
        } else {
            // spracovanie operátora
            token num1, num2, result;
            // najskôr vyberieme 1 alebo 2 čísla zo zásobníka
            if (curToken.op == '~') {
                num1 = pop(numberStack);
            } else {
                num2 = pop(numberStack);
                num1 = pop(numberStack);
            }
            // na operandy aplikujeme operátor
            applyOp(curToken, num1, num2, result);
            // výsledné číslo uložíme na zásobník
            push(numberStack, result);
        }
    }
    // zo zásobníka vyberieme výsledné číslo
    token result = pop(numberStack);
    // skontrolujeme, že zásobník je prázdny a výsledok je číslo
    assert(isEmpty(numberStack) && result.op == ' ');
    // uvoľníme pamäť zásobníka
    destroy(numberStack);
    return result.val;
}


  • 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 bude zodpovedať spojenie dvoch podstromov do jedného stromu.
  • V tomto prípade nepoužívame postupnosť symbolov (tokenov), ale priamo spracovávame postfixový výraz vo forme reťazca.
typedef treeNode *dataType;


/* Sem príde definícia štruktúry pre zásobník a všetkých funkcií poskytovaných zásobníkom. */
treeNode * postfixToTree(char * str) {
    // zásobník, do ktorého ukladáme korene podstromov
    stack treeStack;
    init(treeStack);

    int strPos = 0;  // pozícia v rámci reťazca
    while (str[strPos] != 0) {  // kým nie sme na konci str
        if (isspace(str[strPos])) {  // preskakujeme biele znaky
            strPos++;
        } else if (isdigit(str[strPos]) || str[strPos] == '.') {
            // keď nájdeme cifru alebo bodku (začiatok čísla)
            double val;
            int skip;
            // načítame toto číslo pomocou sscanf,
            // do skip uložíme počet znakov čísla
            sscanf(&(str[strPos]), "%lf%n", &val, &skip);
            // preskočíme všetky znaky čísla
            strPos += skip;

            // vytvoríme list a uložíme na zásobník
            push(treeStack, createNum(val));
        } else {
            // spracovanie operátora
            assert(strchr("+-/*", str[strPos]) != NULL);
            treeNode * left, * right;
            // najskôr vyberieme 2 podstromy zo zásobníka
            // vytvoríme nový koreň,
            // ktorý bude ich rodičom a vložíme na zásobník
            right = pop(treeStack);
            left = pop(treeStack);
            push(treeStack, createOp(str[strPos], left, right));
            strPos++;
        }
    }
    // zo zásobníka vyberieme výsledný strom
    treeNode * result = pop(treeStack);
    // skontrolujeme, že zásobník je prázdny
    assert(isEmpty(treeStack));
    // uvoľníme pamäť zásobníka
    destroy(treeStack);
    return result;
}

Ukážkový program pracujúci so stromami pre aritmetické výrazy

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. Celý program je na konci prednášky.

int main() {
    // načítame postfixový výraz do reťazca
    const int maxLine = 100;
    char postfix[maxLine];
    fgets(postfix, maxLine, stdin);
    // výraz konvertujeme na strom
    treeNode * root = postfixToTree(postfix);
    // spočítame hodnotu výrazu
    double value = evaluateTree(root);
    printf(" value: %g\n", value);
    // vypíšeme vo všetkých troch notáciách
    printf(" inorder: ");
    printInorder(stdout, root);
    printf("\n predorder: ");
    printPreorder(stdout, root);
    printf("\n postdorder: ");
    printPostorder(stdout, root);
    printf("\n");
    // uvoľníme pamäť
    destroyTree(root);
}

Binárne stromy

Stromy pre aritmetické výrazy sú špeciálnym prípadom binárnych stromov. V informatike majú binárne stromy množstvo rozličných uplatnení. Ukážeme si teda všeobecnú dátovú štruktúru binárneho stromu.

Terminológia stromov

  • Strom obsahuje množinu uzlov alebo vrcholov prepojených hranami. (uzol angl. node, vrchol vertex, hrana edge).
  • Ak je strom neprázdny, jeden jeho vrchol nazývame koreň (angl. root)
  • Každý uzol u okrem koreňa je spojený hranou s práve jedným rodičom (angl. parent), ktorým je nejaký uzol v. Naopak uzol u je dieťaťom (angl. child) uzla v.
  • Vo všeobecnom strome môže mať každý uzol ľubovoľný počet detí (aj nula).
  • Strom je binárny, ak má každý uzol najviac dve deti. Budeme pritom rozlišovať medzi pravým a ľavým dieťaťom.
  • Uzly zakoreneného stromu, ktoré nemajú žiadne dieťa, nazývame listami; zvyšné uzly nazývame vnútornými uzlami.
  • Predkom uzla u nazveme ľubovoľný uzol v ležiaci na ceste z u do koreňa stromu (vrátane u a koreňa). Naopak potom hovoríme, že u je potomkom uzla v.
  • Podstromom stromu T zakoreneným v nejakom uzle v stromu T budeme rozumieť strom s koreňom v pozostávajúci zo 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.


Takéto stromy sa nazývajú zakorenené. Presnejšiu matematickú definíciu zakorenených aj nezakorenených stromov uvidíte na predmete Úvod do kombinatoriky a teórie grafov (letný semester).

Š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 stromoch pre aritmetické výrazy, namiesto operátora alebo hodnoty si však v každom uzle budeme pamätať hodnotu ľubovoľného typu dataType, napríklad int.

/* Typ prvkov ukladaných v uzloch binárneho stromu */
typedef int dataType;          

/* Uzol binárneho stromu */
struct node {
    // hodnota uložená v uzle
    dataType data;  

    // smerníky na podstromy
    node * left, * right;
};

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.

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 stromoch pre výrazy, realizovať v troch základných poradiach: preorder, inorder a postorder.

Pri vypisovaní predpokladáme, že pre hodnoty typu dataType máme k dispozícii funkciu printDataType, ktorá ich v nejakom vhodnom formáte vypisuje.


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

/* Vypíše podstrom s koreňom * root v poradí preorder */
void printPreorder(node * root) {
    if (root != NULL) {
        printDataType(root->data);
        printPreorder(root->left);
        printPreorder(root->right);
   } 
}

/* Vypíše podstrom s koreňom * root v poradí inorder */
void printInorder(node * root) {
    if (root != NULL) {
        printInorder(root->left);
        printDataType(root->data);
        printInorder(root->right);
    }
}

/* Vypíše podstrom s koreňom * root v poradí postorder */
void printPostorder(node * root) {
    if (root != NULL) {
        printPostorder(root->left);
        printPostorder(root->right);
        printDataType(root->data);
    }
}

Cvičenie: ako by sme spočítali súčet hodnôt uložených v uzloch stromu?

Likvidácia binárneho stromu

Nasledujúca rekurzívna funkcia zlikviduje celý podstrom zakorenený v uzle * root (t. j. uvoľní pamäť pre všetky jeho uzly). Veľmi sa podobá na funkciu pre strom reprezentujúci aritmetický výraz.

/* Zlikviduje podstrom s korenom * root (uvolni 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 deti majú hĺbku 1, atď.
  • Výškou binárneho stromu potom nazveme maximálnu hĺbku niektorého z jeho vrcholov.
    • Strom s jediným vrcholom má teda výšku 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).

/* Spočíta výšku podstromu s koreňom * root. 
 * Pre root == NULL vráti -1. */
int height(node * root) {
    if (root == NULL) {
        return -1;
    }
    // rekurzívne spočíta výšku ľavého a pravého podstromu
    int hLeft = height(root->left);    
    int hRight = height(root->right);  
    // vráti max(hLeft, hRight) + 1
    if (hLeft >= hRight) {             
        return hLeft + 1;
    } else {
        return hRight + 1;
    }
}

Cvičenie: prepíšte funkciu tak, aby triviálnym prípadom bol list, nie prázdny strom. Funkcia teda vždy dostane smerník na neprázdny strom a nebude volať rekurziu na prázdne podstromy. Ktorá verzia je jednoduchšia? Ktorá sa vám zdá jednoduchšia na pochopenie?

Aká môže byť výška binárneho stromu?

Pre výšku h binárneho stromu s n uzlami 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.

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 uzly pritom očísľuje 1, 2, 3,... (predpokladáme, že dataType je int) pomocou 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);
    count++;
    v->left = createFullTree(height - 1);
    v->right = createFullTree(height - 1);
    return v;
}

int main() {
    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);
}

Cvičenie:

  • Nakreslite strom aj s hodnotami v uzloch, ktorý vznikne pre výšku 2.
  • Vo všeobecnosti opíšte poradie, v ktorom sa v uvedenom programe jednotlivým uzlom priraďujú ich hodnoty.
  • Ako by ste v programe odstránili globálnu premennú count?


Program pre aritmetické výrazy ako stromy

#include <cstdio>
#include <cctype>
#include <cassert>
#include <cstring>
using namespace std;

struct treeNode {
    // číselná hodnota (len v listoch)
    double val;

    // operátor vo vnútorných uzloch, pre listy medzera
    char op;

    // smerníky na podstromy
    treeNode * left, * right;
};

/** Funkcia vráti nový uzol pre operátor */
treeNode * createOp(char op, treeNode * left, treeNode * right) {
    treeNode * v = new treeNode;
    v->left = left;
    v->right = right;
    v->op = op;
    return v;
}

/** Funkcia vráti nový uzol pre číslo */
treeNode * createNum(double val) {
    treeNode * v = new treeNode;
    v->left = NULL;
    v->right = NULL;
    v->op = ' ';
    v->val = val;
    return v;
}


// Nasleduje kód pre zásobník uzlov stromu
typedef treeNode * dataType;

/** Uzol spájaného zoznamu pre zásobník */
struct node {
    dataType data; // dáta uložené v uzle
    node * next;   // smerník na ďalší uzol zoznamu
};

/** Štruktúra pre zásobník implementovaný pomocou zoznamu*/
struct stack {
    node * top; // Smernik na vrch zasobníka  alebo NULL
};

/** Funkcia inicializuje prázdny zásobník */
void init(stack & s) {
    s.top = NULL;
}

/** Funkcia zistí, či je zásobník prázdny */
bool isEmpty(stack & s) {
    return s.top == NULL;
}

/** Funkcia pridá prvok item na vrch zásobníka */
void push(stack & s, dataType item) {
    node * tmp = new node;
    tmp->data = item;
    tmp->next = s.top;
    s.top = tmp;
}

/** Funkcia odoberie prvok z vrchu zasobnika a vráti ho */
dataType pop(stack & s) {
    assert(!isEmpty(s));
    dataType result = s.top->data;
    node * tmp = s.top->next;
    delete s.top;
    s.top = tmp;
    return result;
}

/** Funkcia vráti prvok na vrchu zásobníka, ale nechá ho v zásobníku */
dataType peek(stack & s) {
    assert(!isEmpty(s));
    return s.top->data;
}

/** Funkcia uvoľní pamäť zásobníka */
void destroy(stack & s) {
    while (!isEmpty(s)) {
        pop(s);
    }
}

/** Funkcia konvertuje výraz v postfixovom tvare na strom */
treeNode * postfixToTree(char * str) {
    // zásobník, do ktorého ukladáme korene podstromov
    stack treeStack;
    init(treeStack);

    int strPos = 0;  // pozícia v rámci reťazca
    while (str[strPos] != 0) {  // kým nie sme na konci str
        if (isspace(str[strPos])) {  // preskakujeme biele znaky
            strPos++;
        } else if (isdigit(str[strPos]) || str[strPos] == '.') {
            // keď nájdeme cifru alebo bodku (začiatok čísla)
            double val;
            int skip;
            // načítame toto číslo pomocou sscanf,
            // do skip uložíme počet znakov čísla
            sscanf(&(str[strPos]), "%lf%n", &val, &skip);
            // preskočíme všetky znaky čísla
            strPos += skip;

            // vytvoríme list a uložíme na zásobník
            push(treeStack, createNum(val));
        } else {
            // spracovanie operátora
            assert(strchr("+-/*", str[strPos]) != NULL);
            treeNode * left, * right;
            // najskôr vyberieme 2 podstromy zo zásobníka
            // vytvoríme nový koreň,
            // ktorý bude ich rodičom a vložíme na zásobník
            right = pop(treeStack);
            left = pop(treeStack);
            push(treeStack, createOp(str[strPos], left, right));
            strPos++;
        }
    }
    // zo zásobníka vyberieme výsledný strom
    treeNode * result = pop(treeStack);
    // skontrolujeme, že zásobník je prázdny
    assert(isEmpty(treeStack));
    // uvoľníme pamäť zásobníka
    destroy(treeStack);
    return result;
}

/** Funkcia spočíta hodnotu výrazu reprezentovaného stromom */
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
}

/** Funkcia uvoľní pamäť daného stromu */
void destroyTree(treeNode * root) {
    if (root != NULL) {
        destroyTree(root->left);
        destroyTree(root->right);
        delete root;
    }
}

/** Funkcia vypíše aritmetický výraz v inorder poradí */
void printInorder(treeNode * root) {
    if (root->op == ' ') {
        printf("%g", root->val);
    } else {
        printf("(");
        printInorder(root->left);
        printf(" %c ", root->op);
        printInorder(root->right);
        printf(")");
    }
}

/** Funkcia vypíše aritmetický výraz v preorder poradí */
void printPreorder(treeNode * root) {
    if (root->op == ' ') {
        printf("%g ", root->val);
    } else {
        printf("%c ", root->op);
        printPreorder(root->left);
        printPreorder(root->right);
    }
}

/** Funkcia vypíše aritmetický výraz v postorder poradí */
void printPostorder(treeNode * root) {
    if (root->op == ' ') {
        printf("%g ", root->val);
    } else {
        printPostorder(root->left);
        printPostorder(root->right);
        printf("%c ", root->op);
    }
}


int main() {
    // načítame postfixový výraz do reťazca
    const int maxLine = 100;
    char postfix[maxLine];
    fgets(postfix, maxLine, stdin);
    // výraz konvertujeme na strom
    treeNode * root = postfixToTree(postfix);
    // spočítame hodnotu výrazu
    double value = evaluateTree(root);
    printf(" value: %g\n", value);
    // vypíšeme vo všetkých troch notáciách
    printf(" inorder: ");
    printInorder(root);
    printf("\n predorder: ");
    printPreorder(root);
    printf("\n postdorder: ");
    printPostorder(root);
    printf("\n");
    // uvoľníme pamäť
    destroyTree(root);
}

Prednáška 21

Oznamy

Test

  • Túto stredu 11.12. o 18:10 v posluchárňach F1 a F2 v trvaní 90 minút.
  • Viac informácií na stránke #Zimný semester, semestrálny test.
  • Zajtra pošleme pokyny emailom, prosím pozrite si ich.

Prednášky

  • V stredu v prvej polovici prednášky budú informácie k skúške a rady k skúškovému všeobecne, potom doberieme posledné učivo.
  • Budúci pondelok bude nepovinná prednáška o nepreberaných črtách jazykov C a C++.
  • Budúcu stredu prednáška nebude.

Cvičenia

  • Zajtra normálne cvičenia, dva príklady už máte zverejnené.
  • Piatkové cvičenia povinné pre tých, ktorí v utorok nevyriešia rozcvičku.
  • Budúci utorok v rámci cvičení tréning na skúšku.
  • V piatok 20.12. od 13:10 predtermín skúšky, cvičenia nebudú.

Opakovanie: aritmetický výraz ako strom

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

Uzol takéhoto stromu môžeme reprezentovať nasledujúcou štruktúrou:

struct treeNode {
    // číselná hodnota (len v listoch)
    double val;

    // operátor vo vnútorných uzloch, pre listy medzera
    char op;

    // smerníky na podstromy
    treeNode * left, * right;
};
  • Vnútorné uzly stromu zodpovedajú operátorom
  • Listy zodpovedajú číselným hodnotám

Videli sme

  • Štruktúru treeNode
  • Vytvorenie stromu z postfixového výrazu (podobné na vyhodnocovanie, využíva sa zásobník hotových podstromov)
  • Vyhodnotenie výrazu, výpis v postfixovej, prefixovej a infixovej forme, uvoľnenie pamäte stromu (jednoduché rekurzívne funkcie)


Zaoberali sme sa aj všeobecnými binárnymi stromami, videli sme

  • Terminológiu
  • Štruktúru node
  • Výpis v poradí preorder, inorder, postorder, uvoľnenie pamäte stromu (jednoduchá rekurzia, podobne ako pre výrazy)
  • Hĺbka vrchola a výška stromu

Binárne vyhľadávacie stromy

Príklad binárneho vyhľadávacieho stromu.

Stromy sa v informatike používajú na veľa účelov. Ďalším príkladom sú binárne vyhľadávacie stromy, ktoré môžu slúžiť na implementovanie abstraktného dátového typu dynamická množina, ktorý sme videli na prednáške 14. Prvky množiny nebudeme ukladať do poľa, ani v spájaného zoznamu, ale do 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), tieto dáta niekedy voláme aj kľúč
  • 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 uložená vo vrchole */
    node * parent;  /* rodič vrchola, NULL v koreni */
    node * left;    /* ľavé dieťa, NULL ak neexistuje */
    node * right;   /* pravé dieťa, NULL ak neexistuje */
};

/* Samotná dynamická množina (obal pre používateľa). */
struct set {
    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(set &s) {
    s.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. Vytvoríme aj funkciu destroy, ktorá dostane množinu implementovanú ako strom a tento strom zlikviduje.

/* Uvoľní pamäť pre strom s koreňom root */
void destroy(node *root) {
    if (root != NULL) {
        destroy(root->left);
        destroy(root->right);
        delete root;
    }
}

/* Zlikviduje množinu s (uvoľní pamäť) */
void destroy(set &s) {
    destroy(s.root);
}

Hľadanie v binárnom vyhľadávacom strome

Funkcia findNode v podstrome s koreňom root hľadá uzol, ktorého položka dáta obsahuje hľadanú hodnotu x. Vráti takýto uzol alebo NULL ak neexistuje.

  • Najskôr porovná hľadaný kľúč s dátami v koreni:
    • ak sa rovnajú, končíme (našli sme, čo hľadáme),
    • ak je x menšie ako dáta v koreni, musí byť v ľavom podstrome,
    • ak je x väčšie, musí byť v pravom podstrome.
  • 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.

Funkcia contains 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 x.

/* Ak v strome s koreňom root existuje uzol s kľúčom x, 
 * vráti ho. Inak vráti NULL. */
node * findNode(node *root, int x) {
    node * v = root;
    while (v != NULL && v->data != x) {
        if (x < v->data) {
            v = v->left;
        } else {
            v = v->right;
        }
    }
    return v;
}

/* Rekurzívna verzia */
node *findNodeR(node *root, int x) {
    if (root == NULL || x == root->data) {
        return root;
    } else if (x < root->data) {
        return findNodeR(root->left, x);
    } else {  // x > root->data
        return findNodeR(root->right, x);
    }
}

/* Zistí, či strom reprezentujúci množinu s 
 * obsahuje uzol s kľúčom x. */
bool contains(set &s, int x) {
    return findNode(s.root, x) != 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ží nový uzol s kľúčom x na správne miesto podstromu zakoreneného v *root.

  • 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, ale dá sa aj cyklom, podobne ako pri hľadaní
  • Funkcia add vloží nový uzol pomocou funkcie insertNode ale zvlášť ošetrí prípad, keď ide o prvý uzol.
/* Funkcia vytvorí uzol s daným kľúčom
 * a rodičom, deti nastaví na NULL. */
node * createBSTNode(int data, node * parent) {
    node *v = new node;
    v->data = data;
    v->left = NULL;
    v->right = NULL;
    v->parent = parent;
    return v;
}

/* Vloží nový uzol s hodnotou x 
 * na správne miesto stromu s koreňom root */
void insertNode(node *root, int x) {
    assert(root != NULL);
    if (x < root->data) {
        if (root->left == NULL) {
            root->left = createBSTNode(x, root);
        } else {
            insertNode(root->left, x);
        }
    } else {
        if (root->right == NULL) {
            root->right = createBSTNode(x, root);
        } else {
            insertNode(root->right, x);
        }
    }
}

/* Vloží do stromu pre množinu s nový uzol s kľúčom x */
void add(set &s, int x) {
    if (s.root == NULL) {
        // špeciálny prípad, keď vkladáme prvý uzol
        s.root = createBSTNode(x, NULL);
    } else {
        insertNode(s.root, x);
    }
}

Čas vkladania je tiež v najhoršom prípade úmerný hĺbke stromu.

Cvičenia

  • Ako bude vyzerať strom po nasledujúcej postupnosti operácií?
    set s;
    init(s);
    add(s, 2);
    add(s, 5);
    add(s, 3);
    add(s, 10);
    add(s, 7);  
  • Napíšte nerekurzívny variant funkcie insertNode.
  • Do funkcie doplňte assert, ktorý deteguje prípad, že vkladaná hodnota sa už v strome nachádza.
  • Napíšte funkciu treeSort, ktorá z poľa celých čísel a pomocou volaní funkcie add vytvorí binárny vyhľadávací strom a následne pomocou prehľadávania tohto stromu v poradí inorder pole a utriedi.

Minimum a následník

Uvedieme teraz dve funkcie, ktoré sa zídu pri mazaní prvku zo stromu, ale môžu sa zísť aj inokedy.

Prvá funkcia minNode nájde vo vyhľadávacom strome uzol, v ktorom je uložená najmenšia hodnota.

  • Všetky prvky menšie ako koreň sú v ľavom podstrome, bude tam zrejme aj minimum.
  • Tá istá úvaha platí pre koreň ľavého podstromu.
  • Ideme teda doľava kým sa dá, posledný vrchol vrátime (list alebo vrchol, ktorý má iba pravé dieťa).
  • Nie je treba teda prechádzať celý strom a nemusíme sa ani pozerať na položku data v uzloch.
  • Dá sa napísať cyklom aj rekurzívne.
  • Obalom pre používateľa bude funkcia min, ktorá pomocou funkcie minNode nájde minimálny kľúč v danej množine.
/* Vráti uzol s minimálnym kľúčom v strome s koreňom v */
node *minNode(node *v) {
    assert(v != NULL);
    while (v->left != NULL) {
        v = v->left;
    }
    return v;
}

/* Vráti minimálnu hodnotu v množine s */
int min(set &s) {
    assert(s.root != NULL);
    return minNode(s.root)->data; 
}

Cvičenia:

  • Napíšte rekurzívny variant funkcie minNode.
  • Ako by bolo treba funkciu zmeniť, aby hľadala maximum?


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.

  • Ak má uzol v pravé dieťa, následník uzla v bude vrchol s minimálnym kľúčom v pravom podstrome.
  • V opačnom prípade môže byť následníkom uzla v jeho rodič, ak v je jeho ľavé dieťa.
  • Ak je v pravým dieťaťom svojho rodiča, môže to byť jeho prarodič (ak je rodič uzla v ľavým dieťaťom tohto prarodiča), 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 (najväčší prvok).
    • Ako presne sa bude funkcia nižšie pre tento prvok správať?
/* Vráti uzol, ktorý vo vzostupnom poradí uzlov
 * podľa kľúčov nasleduje za v. 
 * Ak taký uzol neexistuje, vráti 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 remove zmaže z binárneho vyhľadávacieho stromu uzol s kľúčom x (predpokladá, že taký je).

  • Najprv pomocou funkcie findNode nájde uzol v s kľúčom x.
  • Ak je v list, jednoducho ho zmažeme.
  • Ak má v jedno dieťa, toto dieťa prevesíme priamo pod rodiča v a v zmažeme.
  • Ak má v dve deti, nájdeme nasledovníka v, t.j. minimum v pravom podstrome v.
  • Tento nasledovník nemá ľavé dieťa, vieme ho teda zmazať.
  • Jeho údaje presunieme do vrcholu v.
  • Tiež treba dať pozor na mazanie koreňa.
/* Zmaže zo stromu pre množinu s uzol s kľúčom x */
void remove(set &s, int x) {
    // Nájde uzol s hodnotou, ktorú treba vymazať.
    node *v = findNode(s.root, x);
    // Skontrolujme, že požadovaný uzol existuje
    assert(v != NULL);
    
    // Nájde uzol *rm, ktorý sa reálne zmaže
    node *rm;                                          
    if (v->left == NULL || v->right == NULL) {         
        rm = v;
    } else  {
        rm = successorNode(v);
        // Presunie kľúč uzla *rm do uzla *v
        v->data = rm->data;
    }

    // ak má uzol rm dieťa, jeho rodičom bude rodič rm
    node *child;                                      
    if (rm->left != NULL) {
        child = rm->left;
    } else {
        child = rm->right;
    }
    if (child != NULL) {
        child->parent = rm->parent;
    }
    if (rm->parent == NULL) {
        s.root = child;
    } else if (rm == rm->parent->left) {
        rm->parent->left = child;    
    } else if (rm == rm->parent->right) {
        rm->parent->right = child;
    }
    // rm už nie je v strome, uvoľníme jeho pamäť
    delete rm;
}

Zložitosť jednotlivých operácií

  • Časová zložitosť operácií contains, add aj remove je úmerná výške stromu, ktorú označíme h.
  • Minule sme ukázali, že pre strom s n uzlami máme 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 ak sa prvky vkladajú v náhodnom poradí, výška stromu bude v priemere 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.

Ukážkový program s binárnymi vyhľadávacími stromami

#include <iostream>
#include <cassert>
using namespace std;

struct node {
    /* vrchol binárneho vyhľadávacieho stromu  */
    int data;       /* hodnota uložená vo vrchole */
    node * parent;  /* rodič vrchola, NULL v koreni */
    node * left;    /* ľavé dieťa, NULL ak neexistuje */
    node * right;   /* pravé dieťa, NULL ak neexistuje */
};

/* Samotná dynamická množina (obal pre používateľa). */
struct set {
    node *root;  /* koreň stromu, NULL pre prázdny strom */
};

/* Funkcia inicializuje prázdny binárny vyhľadávací strom */
void init(set &s) {
    s.root = NULL;
}

/* Uvoľní pamäť pre strom s koreňom root */
void destroy(node *root) {
    if (root != NULL) {
        destroy(root->left);
        destroy(root->right);
        delete root;
    }
}

/* Zlikviduje množinu s (uvoľní pamäť) */
void destroy(set &s) {
    destroy(s.root);
}

/* Ak v strome s koreňom root existuje uzol s kľúčom x, 
 * vráti ho. Inak vráti NULL. */
node * findNode(node *root, int x) {
    node * v = root;
    while (v != NULL && v->data != x) {
        if (x < v->data) {
            v = v->left;
        } else {
            v = v->right;
        }
    }
    return v;
}

/* Rekurzívna verzia */
node *findNodeR(node *root, int x) {
    if (root == NULL || x == root->data) {
        return root;
    } else if (x < root->data) {
        return findNodeR(root->left, x);
    } else {  // x > root->data
        return findNodeR(root->right, x);
    }
}

/* Zistí, či strom reprezentujúci množinu s 
 * obsahuje uzol s kľúčom x. */
bool contains(set &s, int x) {
    return findNode(s.root, x) != NULL;
}

/* Funkcia vytvorí uzol s daným kľúčom
 * a rodičom, deti nastaví na NULL. */
node * createBSTNode(int data, node * parent) {
    node *v = new node;
    v->data = data;
    v->left = NULL;
    v->right = NULL;
    v->parent = parent;
    return v;
}

/* Vloží nový uzol s hodnotou x 
 * na správne miesto podstromu zakoreneného v root */
void insertNode(node *root, int x) {
    assert(root != NULL);
    if (x < root->data) {
        if (root->left == NULL) {
            root->left = createBSTNode(x, root);
        } else {
            insertNode(root->left, x);
        }
    } else {
        if (root->right == NULL) {
            root->right = createBSTNode(x, root);
        } else {
            insertNode(root->right, x);
        }
    }
}

/* Vloží do stromu pre množinu s nový uzol s kľúčom x */
void add(set &s, int x) {
    if (s.root == NULL) {
        // špeciálny prípad, keď vkladáme prvý uzol
        s.root = createBSTNode(x, NULL);
    } else {
        insertNode(s.root, x);
    }
}

/* Vráti uzol s minimálnym kľúčom v strome s koreňom v */
node *minNode(node *v) {
    assert(v != NULL);
    while (v->left != NULL) {
        v = v->left;
    }
    return v;
}

/* Vráti minimálnu hodnotu v množine s */
int min(set &s) {
    assert(s.root != NULL);
    return minNode(s.root)->data; 
}

/* Vráti uzol, ktorý vo vzostupnom poradí uzlov
 * podľa kľúčov nasleduje za v. 
 * Ak taký uzol neexistuje, vráti 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;
}


/* Zmaže zo stromu pre množinu s uzol s kľúčom x */
void remove(set &s, int x) {
    // Nájde uzol s hodnotou, ktorú treba vymazať.
    node *v = findNode(s.root, x);
    // Skontrolujme, že požadovaný uzol existuje
    assert(v != NULL);
    
    // Nájde uzol *rm, ktorý sa reálne zmaže
    node *rm;                                          
    if (v->left == NULL || v->right == NULL) {         
        rm = v;
    } else  {
        rm = successorNode(v);
        // Presunie kľúč uzla *rm do uzla *v
        v->data = rm->data;
    }

    // ak má uzol rm dieťa, jeho rodičom bude rodic rm
    node *child;                                      
    if (rm->left != NULL) {
        child = rm->left;
    } else {
        child = rm->right;
    }
    if (child != NULL) {
        child->parent = rm->parent;
    }
    if (rm->parent == NULL) {
        s.root = child;
    } else if (rm == rm->parent->left) {
        rm->parent->left = child;    
    } else if (rm == rm->parent->right) {
        rm->parent->right = child;
    }
    // rm už nie je v strome, uvoľníme jeho pamäť
    delete rm;
}

/* Vypíše podstrom s koreňom * root v poradí preorder */
void printInorder(node * root) {
    if (root != NULL) {
        cout << " " << root->data;
        printInorder(root->left);
        printInorder(root->right);
   } 
}

int main() {

    set s;
    init(s);

    cout << "Vkladame 2,5,3,10,7 do s." << endl;
    add(s, 2);
    add(s, 5);
    add(s, 3);
    add(s, 10);
    add(s, 7);  

    cout << "Obsahuje s cisla 2 a 4?" << endl;
    cout << contains(s, 2) << endl;
    cout << contains(s, 4) << endl;

    cout << "Minimum s: " << min(s) << endl;;

    cout << "Vypis v inorder poradi" << endl;
    printInorder(s.root);
    cout << endl;

    cout << "Mazeme 2,5,10 z s." << endl;
    remove(s, 5);
    remove(s, 2);
    remove(s, 10);

    cout << "Vypis v inorder poradi" << endl;
    printInorder(s.root);
    cout << endl;
    
    destroy(s);
}

Prednáška 22

Oznamy

Plán prednášok a cvičení na zvyšok semestra:

  • Dnes informácie k skúške a posledná ukážka stromov, večer 18:10 semestrálny test.
  • V piatok cvičenia pre tých, čo nespravili v utorok rozcvičku.
  • V pondelok 16.12. 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 17.12. v rámci cvičení tréning na skúšku.
    • Na testovači pribudnú tréningové príklady na skúšku. Za niektoré budete môcť získať bonusový bod, ak ich vyriešite do 16.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 20.12. od 13:10 predtermín skúšky, piatkové cvičenia nebudú.

Odporúčame si aspoň raz vyskúšať prácu v Linuxe na školských počítačoch, budete to potrebovať na skúške (návod).

Prefixové stromy

Prefixový strom reprezentujúci množinu reťazcov a, aj, ale, aleba, alebo, cez, na, nad.

Prefixové stromy (niekde tiež lexikografické stromy; angl. trie zo slova retrieval) sú dátová štruktúra na uchovávanie množiny reťazcov. Ide o stromy, ktoré nemusia byť binárne:

  • Uzol prefixového stromu má najviac toľko detí, koľko je znakov v uvažovanej abecede. Každé dieťa je označené iným znakom abecedy. Graficky si môžeme predstaviť tento znak prislúchajúci k hrane spájajúcej rodiča a dieťa.
  • Koreň prefixového stromu zodpovedá prázdnemu reťazcu.
  • Uzol v hĺbke k zodpovedá reťazcu dĺžky k, ktorý dostaneme prečítaním písmen na ceste z koreňa do daného uzla.
  • Každý uzol prefixového stromu obsahuje logickú hodnotu vyjadrujúcu, či k nemu prislúchajúci reťazec patrí do množiny reprezentovanej týmto prefixovým stromom.
  • V korektnom prefixovom strome všetky listy zodpovedajú reťazcom z reprezentovanej množiny.
  • Vnútorné vrcholy môžu zodpovedať reťazcu z množiny alebo iba prefixu jedného alebo viacerých takých reťazcov.

Uzly prefixového stromu budeme reprezentovať štruktúrou node

  • Uzol obsahuje obsahuje booleovskú premennú isWord, v ktorej je uložená informácia o tom, či reťazec prislúchajúci k danému uzlu patrí alebo nepatrí do reprezentovanej množiny a pole children smerníkov na jednotlivé deti daného uzla.
  • Veľkosť alphSize tohto poľa je rovná veľkosti uvažovanej abecedy.
  • V ukážkovom programe uvažujeme abecedu 'a'..'z'.


const int alphSize = 'z' - 'a' + 1;

struct node {
    // pole smerníkov na deti
    node * children[alphSize]; 
    // prislúcha uzol k slovu z množiny?
    bool isWord;              
};

Samotný prefixový strom potrebuje smerník na svoj koreň:

struct trie {
    node * root;      
};

Inicializácia a likvidácia prefixového stromu

Nasledujúca funkcia inicializuje prázdny prefixový strom t:

void init(trie & t) {
    t.root = NULL; 
}

Uvoľnenie pamäte alokovanej pre podstrom zakorenený v uzle root realizujeme obdobne ako pri binárnych vyhľadávacích stromoch. Jediný rozdiel spočíva v potenciálne väčšom počte detí uzla root.

void destroySubtree(node * root) {
    if (root != NULL) {
        for (int i = 0; i < alphSize; i++) {
            destroySubtree(root->children[i]);
        }
        delete root;
    }
}

Nasledujúca funkcia potom zlikviduje celý prefixový strom t:

void destroy(trie & t) {
    destroySubtree(t.root);
}

Hľadanie v prefixovom strome

Funkcia contains pre daný prefixový strom t a reťazec word zistí, či slovo word patrí do množiny reprezentovanej stromom t.

  • Postupuje po písmenách reťazca word. Kým nedôjde na koniec slova, snaží sa ísť po hranách, ktoré zodpovedajú jednotlivým písmenám.
  • V prípade, že v niektorom bode narazí na NULL, slovo word sa v strome nenachádza.
  • V opačnom prípade toto slovo dočíta v nejakom uzle v. V takom prípade slovo word patrí do reprezentovanej množiny práve vtedy, keď v->isWord má hodnotu true.
bool contains(trie & t, const char * word) {
    node * v = t.root;
    if (v == NULL) {
        return false;
    }
    for (int i = 0; word[i] != 0; i++) {
        int c = word[i] - 'a';
        assert(c >= 0 && c < alphSize);
        v = v->children[c];
        if (v == NULL) {
            return false;
        }
    }
    return v->isWord;
}

Vkladanie do prefixového stromu

Pri vkladaní reťazca do množiny reprezentovanej prefixovým stromom potrebujeme vytvárať nové uzly. Túto podúlohu realizuje funkcia createNode, ktorá vytvorí nový uzol s hodnotou isWord danou jej argumentom a so všetkými smerníkmi na deti nastavenými na NULL.

node * createNode(bool isWord) {
    node * v = new node;
    for (int i = 0; i < alphSize; i++) {
        v->children[i] = NULL;
    }
    v->isWord = isWord;
    return v;
}

Vloženie reťazca word do prefixového stromu t vykoná funkcia add, ktorá pracuje nasledovne:

  • Začne v koreni stromu, odkiaľ postupuje nižšie smerom k listom.
  • V každom uzle sa pozrie na ďalšie písmeno slova word. Ak danému uzlu chýba dieťa pre toto písmeno, vytvorí ho pomocou funkcie createNode. Následne sa presunie do tohto dieťaťa.
  • Keď v nejakom uzle v príde na koniec slova word, nastaví hodnotu v->isWord na true.
void add(trie & t, const char * word) {
    if (t.root == NULL) {
        t.root = createNode(false);
    }
    node * v = t.root;
    for (int i = 0; word[i] != 0; i++) {
        int c = word[i] - 'a';
        assert(c >= 0 && c < alphSize);
        if (v->children[c] == NULL) {
            v->children[c] = createNode(false);
        }
        v = v->children[c];
    }
    v->isWord = true;
}

Vymazanie slova z prefixového stromu

Vymazávanie slov z množiny reprezentovanej prefixovým stromom budeme realizovať prostredníctvom pomocnej rekurzívnej funkcie removeFromSubtree.

  • Funkcia z podstromu zakorenenom v uzle root vymaže sufix reťazca word začínajúci na pozícii index.
  • Funkcia vráti booleovskú hodnotu podľa toho, či sa pri tomto vymazaní sufixu z daného podstromu vymazal jeho koreň root.
  • Ak sa slovo word v reprezentovanej množine nenachádza, funkcia removeFromSubtree vyhlási chybu pomocou funkcie assert.

Funkcia removeFromSubtree pracuje nasledovne:

  • Ak je sufix reťazca word začínajúci na indexe index prázdny, nastaví hodnotu root->isWord na false.
  • V opačnom prípade funkcia removeFromSubtree zavolá rekurzívne samú seba pre dieťa zodpovedajúce písmenu na pozícii index reťazca word. Ak toto volanie dané dieťa zmaže, prestaví smerník na toto dieťa na NULL.
  • V prípade, že po vykonaní jednej z predchádzajúcich dvoch operácií nemá uzol root žiadne dieťa a súčasne má root->isWord hodnotu false, uvoľní pamäť alokovanú pre uzol root a informáciu o jeho zmazaní vráti na výstupe.

Cvičenie: hoci mazanie neprehľadáva celý strom, iba jednu cestu z koreňa smerom dolu, naprogramovali sme ho rekurzívne. Na aký problén by sme narazili, ak by sme ju chceli naprogramovať cyklom? Pomohli by nám smerníky na rodiča v uzloch stromu?

bool removeFromSubtree(node * root, 
                       const char * word, int index) {
    assert(root != NULL);
    if (word[index] == 0) {
        assert(root->isWord);
        root->isWord = false;
    } else {
        int c = word[index] - 'a';
        bool deleted = removeFromSubtree(root->children[c], 
                                         word, index + 1);
        if (deleted) {          
            root->children[c] = NULL;
        }
    }
    int numChildren = 0;                        
    for (int i = 0; i < alphSize; i++) {
        if (root->children[i] != NULL) {
            numChildren++;
        }
    }
    if (numChildren == 0 && !root->isWord) {
        delete root;
        return true;
    } else {
        return false;
    }
}

Samotné odstránenie reťazca word z množiny reprezentovanej stromom t potom realizuje funkcia remove.

void remove(trie & t, const char * word) {
    // zavoláme rekurziu pre koreň stromu
    bool rootRemoved = removeFromSubtree(t.root, word, 0);
    // ak bol koreň odstránený, nastavíme t.root na NULL
    if (rootRemoved) {
        t.root = NULL;
    }
}

Výška prefixového stromu

Nasledujúca funkcia vypočíta výšku podstromu zakoreneného v uzle root:

int subtreeHeight(node *root) {
    if (root == NULL) {
        return -1;
    }
    int maxHeight = -1;
    for (int i = 0; i < alphSize; i++) {
        int height = subtreeHeight(root->children[i]);
        if (height > maxHeight) {
            maxHeight = height;
        }
    }
    return maxHeight + 1;
}

Výšku samotného prefixového stromu t potom spočíta nasledujúca funkcia:

int height(trie &t) {
    return subtreeHeight(t.root);
}

Vypisovanie slov reprezentovaných prefixovým stromom

Nasledujúca funkcia printSubtree prehľadáva podstrom zakorenený v uzle root a v reťazci str postupne generuje všetky slová z reprezentovanej množiny, ktoré zároveň vypisuje na konzolu. V parametri index dostane hĺbku aktuálneho vrcholu, t.j. pozíciu v reťazci, na ktorú pridáme ďalší znak.

void printSubtree(node *root, char *str, int index) {
    if (root == NULL) {
        return;
    }
    if (root->isWord) {
        // ukončíme a vypíšeme reťazec
        str[index] = 0; 
        printf("%s\n", str);
    }
    for (int i = 0; i < alphSize; i++) {
        str[index] = 'a' + i;
        printSubtree(root->children[i], str, index + 1);
    }
}

Funkcia printAll vypisujúca všetky slová v množine reprezentovanej prefixovým stromom t najprv spočíta výšku stromu t, ktorá je rovná dĺžke najdlšieho reťazca tejto množiny. Následne dynamicky alokuje reťazec dostatočnej dĺžky na uchovanie každého slova množiny a zavolá funkciu printSubtree pre koreň stromu t.

void printAll(trie &t) {
    int height = height(t);
    if (height >= 0) { 
        char *str = new char[height + 1];
        printSubtree(t.root, str, 0);
        delete[] str;
    }
}

V akom poradí budú slová vypísané?

Ukážka programu s prefixovým stromom, ADT slovník

Na vstupe máme text pozostávajúci zo slov s malými písmenami a pre každé slovo v texte chceme spočítať, koľkokrát sa tam nachádza.

  • Jednotlivé slová uložíme pomocou prefixového stromu a v každom uzle si pamätáme namiesto hodnoty isWord počítadlo count, ktoré udáva, koľkokrát sme príslušné slovo videli na vstupe.
  • Počítadlo má hodnotu nula pre prefixy vstupných slov, ktoré samé zatiaľ ako slovo na vstupe neboli.
  • Namiesto funkcie treeInsert máme funkciu treeIncrement, ktorá dostane slovo a zvýši jeho počítadlo, pričom ak slovo zatiaľ v strome nebolo, tak ho pridá.
  • Podobne by sme na tento účel vedeli upraviť aj implementáciu množiny pomocou binárneho vyhľadávacieho stromu, hašovacej tabuľky, poľa alebo zoznamu.
    • Pozor, ak sú kľúče reťazce, na ich porovnanie musíme v týchto implementáciách použiť strcmp, nie ==, < a pod.

Abstraktný dátový typ, ktorý si okrem množiny kľúčov ku každému kľúču pamätá aj ďalšie dáta, sa zvykne nazývať slovník (angl. dictionary, map).

  • Tu boli kľúče slová a ďalšie dáta počet výskytov.
  • Iný príklad je zoznam kontaktov, kde kľúčom je meno osoby a pre dané meno chceme vrátiť kontaktné údaje danej osoby (emailová adresa, telefón a pod.)


#include <cstdio>
#include <cassert>
#include <cstring>
using namespace std;

const int alphSize = 'z' - 'a' + 1;

// uzol prefixoveho stromu
struct node {
    // pole smernikov na deti
    node *children[alphSize];
    // pocet vyskytov slova prisluchajuceho uzlu
    int count;
};

// cely prefixovy strom 
struct trie {
    node *root;
};


// inicializacia prazdneho stormu
void init(trie &t) {
    t.root = NULL;
}

// mazanie podstromu s korenom root
void destroySubtree(node *root) {
    if (root != NULL) {
        for (int i = 0; i < alphSize; i++) {
            destroySubtree(root->children[i]);
        }
        delete root;
    }
}

// uvolnenie pamate celeho stromu
void destroy(trie &t) {
    destroySubtree(t.root);
}

// vytvorenie noveo uzlu bez deti a s nula vyskytmi
node *createNode() {
    node *v = new node;
    for (int i = 0; i < alphSize; i++) {
        v->children[i] = NULL;
    }
    v->count = 0;
    return v;
}

// zvysenie pocitadla pre slovo word
// ak slovo este nie je v strome, je pridane
void increment(trie &t, const char *word) {
    if (t.root == NULL) {
        t.root = createNode();
    }
    node *v = t.root;
    for (int i = 0; word[i] != 0; i++) {
        int c = word[i] - 'a';
        assert(c >= 0 && c < alphSize);
        if (v->children[c] == NULL) {
            v->children[c] = createNode();
        }
        v = v->children[c];
    }
    v->count++;
}

// vyska podstromu s korenom root
int subtreeHeight(node *root) {
    if (root == NULL) {
        return -1;
    }
    int maxHeight = -1;
    for (int i = 0; i < alphSize; i++) {
        int height = subtreeHeight(root->children[i]);
        if (height > maxHeight) {
            maxHeight = height;
        }
    }
    return maxHeight + 1;
}

// vyska stromu. t.j. dlzka najdlsieho slova
int height(trie &t) {
    return subtreeHeight(t.root);
}

// vypisanie slov v podstrome prefixoveho stromu
void printSubtree(node *root, char *str, int index) {
    if (root == NULL) {
        return;
    }
    if (root->count > 0) {
        str[index] = 0; // ukoncenie retazca pred vypisom
        printf("%s %d\n", str, root->count);
    }
    for (int i = 0; i < alphSize; i++) {
        str[index] = 'a' + i;
        printSubtree(root->children[i], str, index + 1);
    }
}

// vypisanie slov prefixoveho stromu
void printAll(trie &t) {
    int height = height(t);
    if (height >= 0) {
        char *str = new char[height + 1];
        printSubtree(t.root, str, 0);
        delete[] str;
    }
}

int main() {
    // inicializacia stromu
    trie t;
    init(t);
    // postupne nacitavanie slov
    char word[100];
    while (true) {
        int count = scanf("%99s", word);
        if (count < 1) { // koniec vstupu
            break;
        }
	// pridanie slova resp. zvysenie pocitadla
        increment(t, word);
    }
    // vypis a uvolnenie pamate
    printAll(t);
    destroy(t);
}

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 (nekombinovať)
  • 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;  // smerník na celočíselnú premennú

p = &i;         // správne
p = &(i + 3);   // zle, i+3 nie je premenná
p = &15;        // zle, konštanta nemá adresu
i = * p;        // správne ak p bol inicializovaný

int * cislo = new int;  // alokovanie jednej premennej
*cislo = 50;
..
delete cislo;

int a[4];
int *b = a;  // a,b su teraz takmer rovnocenné premenné 

int *b = new int[n]; // alokovanie 1D poľa danej dĺžky
..
delete[] b;

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 dynamická množina (set)

  • operácie init, contains, add, remove
  • implementácie pomocou
    • neutriedeného poľa
    • utriedeného poľa
    • spájaných zoznamov
    • hašovacej tabuľky
    • binárnych vyhľadávacích stromov
    • prefixové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

PROG-list.png

Spájané zoznamy

struct node {
    int data;
    node* next;
};
struct linkedList {
    node* first;
};
void insertFirst(linkedList &z, int d){
    /* do zoznamu z vlozi na zaciatok novy prvok s datami d */
    node* p = new node;   // vytvoríme nový prvok
    p->data = d;          // naplníme dáta
    p->next = z.first;    // uzol ukazuje na doterajší začiatok
    z.first = p;          // tento prvok je novým začiatkom
}
Strom pre výraz (65 – 3*5)/(2 + 3)

Binárne stromy

struct node {
    /* uzol stromu  */
    dataType data;
    node * left;  /* ľavé dieťa */
    node * right; /* pravé dieťa */
};

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
P22-BST.png

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
Prefixový strom reprezentujúci množinu reťazcov a, aj, ale, aleba, alebo, cez, na, nad.

Prefixové stromy

  • ukladajú množinu reťazcov
  • nie sú binárne: vrchol môže mať veľa detí
  • 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 { // uzol prefixoveho stromu 
    bool isWord; // je tento uzol 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 data;
    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

Prednáška 23

Nepreberané črty jazykov C a C++

Dnešná prednáška bude venovaná (pomerne plytkému) prehľadu rôznych čŕt jazykov C a C++, ktoré sa počas semestra nepreberali. Tento prehľad by mal poslúžiť ako pomôcka pri štúdiu existujúcich programov, resp. ako inšpirácia pre ďalšie samoštúdium.

Znalosť materiálu z tejto prednášky nebude vyžadovaná na skúške. Rovnako nie je odporúčané na skúške tento materiál využívať bez dôkladného samostatného oboznámenia sa s ním.

Táto prednáška nepokrýva objektovo-orientované programovanie v jazyku C++. Objektovo-orientované programovanie (v jazyku Java) bude hlavnou náplňou druhého semestra programovania.

Rozdiely medzi jazykmi C a C++

Jazyk C vznikol okolo roku 1972 na podporu vývoja operačného systému Unix; jazyk C++ okolo roku 1985. Programy v jazyku C sú väčšinou súčasne aj korektnými programami v jazyku C++ (ide ale o isté zjednodušenie). Obidva tieto jazyky existujú vo viacerých štandardoch, v ktorých sa postupne pridávali nové črty.

V priebehu semestra sme programovali v jazyku C++. Veľká časť konštrukcií, ktoré sme v programoch využívali, však pochádza už z jazyka C. Výnimkami sú však napríklad nasledujúce črty jazyka C++, ktoré v jazyku C nie sú k dispozícii:

  • V jazyku C nie je možné predávanie parametrov funkcií referenciou. Namiesto toho je potrebné používať smerníky.
  • V jazyku C nefungujú operátory new a delete. Namiesto nich treba použiť funkcie popísané nižšie.
  • Na používanie typu bool a konštánt true a false je potrebná knižnica stdbool (t. j. #include <stdbool.h>). Zabudovaný booleovský typ má názov _Bool a nadobúda hodnoty 0 a 1.
  • Štandardnými knižnicami jazyka C sú spomedzi štandardných knižníc jazyka C++ v zásade tie začínajúce písmenom c, napríklad cstdio. Namiesto #include <cstdio> potom v C píšeme #include <stdio.h>.
    • V jazyku C špeciálne nie je možné používať knižnicu iostream a v nej definované štandardné vstupno-výstupné prúdy cin a cout.
  • Ak máme napr. struct bod, v C++ sme premenné deklarovali cez bod b;, v jazyku C treba písať struct bod b;.
  • V starších verziách jazyka C nefungujú mnohé konštrukcie jazyka C++, ktoré sme bežne používali, napríklad komentáre vo forme //, deklarácie premenných inde ako na začiatku funkcie, atď.

Nepreberané črty jazyka C (použiteľné aj v C++)

Enumerované typy

Enumerované typy pozostávajú z vymenovania niekoľkých hodnôt, ktoré sa stanú celočíselnými konštantami. To je občas užitočné na sprehľadnenie zdrojového kódu.

Príklad:

#include <iostream>
using namespace std;

int main() {
    // definicia enumerovaneho typu farba
    enum farba {biela, modra, cervena, zelena, cierna};  
    // definicia premennej typu farba     
    farba f = biela;                                     
    f = zelena; 
    cout << f << endl; // vypise 3
}

Zložený typ (union)

Zložený typ umožňuje na jednom mieste pamäte uchovávať hodnotu, ktorá môže byť viacerých dátových typov (na rozdiel od štruktúr však vždy ide o práve jednu hodnotu). Definuje sa podobne ako štruktúra, avšak s použitím kľúčového slova union namiesto struct.

Príklad:

#include <iostream>
#include <cstring>
using namespace std;

union zlozenyTyp {
    int n;
    char s[8];
};

int main() {
    zlozenyTyp z;
    z.n = 10;
    cout << z.n << endl;  // vypise 10
    strcpy(z.s, "abcd");
    cout << z.s << endl;  // vypise abcd
    cout << z.n << endl;  // vypise nejaky nezmysel
}

Napríklad pri aritmetických stromoch by použitie zloženého typu umožnilo ušetriť trochu pamäte tým, že by sme si v každom uzle pamätali buď jeho hodnotu, alebo operátor, pričom typ uzla by sme rozlišovali podľa toho, či sú smerníky na oboch synov rovné NULL.

Operátory

Okrem operátorov, ktoré sme používali, existuje niekoľko ďalších, ako napríklad nasledujúce:

  • Bitové operátory pracujú s celým číslom ako s poľom bitov (vhodnejšie sú unsigned typy):
    • << a >> posúvajú bity doľava a doprava, zodpovedajú násobeniu a deleniu mocninami dvojky.
    • & (and po bitoch), | (or po bitoch), ^ (xor po bitoch), ~ (negácia po bitoch).
  • Ternárny operátor (podmienka)?(hodnota pre true):(hodnota pre false)
    • cout << x << " je " << ((x%2==0) ? "parne" : "neparne") << endl;
  • Pozor na rozdiel medzi a[i++]=0 a a[++i]=0, prehľadnejšie je použiť dva príkazy: a[i]=0; i++; alebo i++; a[i] = 0;

Cyklus do-while

Cyklus do-while je obdobou cyklu while s vyhodnocovaním podmienky na konci iterácie. Nasledujúce dva spôsoby písania cyklu sú viac-menej ekvivalentné:

do { 
    prikazy;
} while(podmienka);

while (true) {
    prikazy;
    if (!podmienka) break;
}

Makrá a konštanty

Konštantu možno zadefinovať napríklad aj takto:

#define MAXN 100

Na rozdiel od konštanty s definíciou

const int maxN = 100;

sa pri použití #define konštantná premenná MAXN reálne nevytvára (nevyhradzuje sa pre ňu pamäťové miesto); všetky výskyty MAXN sú preprocesorom kompilátora nahradené v zdrojovom kóde konštantou 100.

Okrem konštánt možno definovať aj zložitejšie makrá s parametrami:

/* Definicia makra: */
#define MIN(X,Y) ((X) < (Y) ? (X) : (Y))

/* Priklad pouzitia: */
cout << MIN(a*a, b+5);

/* Preprocesor vykona substituciu za MIN, ktorou dostane: */
cout << ((a*a) < (b+5) ? (a*a) : (b+5));
  • Bez dostatočného množstva zátvoriek by pri použití makra MIN mohlo dôjsť k „interakcii s okolím”.
  • Vo všeobecnosti je odporúčané vyvarovať sa použitia makier.

Delenie programu na súbory

  • Väčší program chceme rozdeliť na viac súborov
  • Chceme vytvárať a používať vlastné knižnice - skupiny funkcií s podobným účelom
    • Napríklad knižnica implementujúca funkcie pracujúce so zásobníkom
  • Knižnicu rozdelíme na dva súbory, napr. stack.h a stack.c resp. stack.cpp

V hlavičkovom súbore (header file) stack.h zadeklarujeme funkcie, ale neuvádzame ich kód, napr.:

typedef int dataType;
struct stack {
    int top;  /* pozicia vrchného prvku zásobníka */
    dataType *items; /* pole prvkov */
};
void init(stack &s);
bool isEmpty(stack &s);
void push(stack &s, dataType data); 
dataType pop(stack &q);

Programy, ktoré chcú použiť stack, použijú #include "stack.h"

  • Všimnite si, že v include dávame meno štandardných knižníc v <>, našich vlastných v ""

V súbore stack.cpp uvedieme kód funkcií, napr.

#include "stack.h"
const int maxN = 100;
void init(stack &s) {
    s.top = -1;
    s.items = new dataType[maxN];
}
...


Pri kompilácii potom potrebujeme kompilátoru zadať mená všetkých častí programu s príponou cpp resp. c, napríklad

g++ -o program main.cpp stack.cpp
  • Práve jeden z kompilovaných zdrojových súborov musí obsahovať funkciu main.

Zopár užitočných funkcií

Alokácia pamäte

  • V jazyku C nie sú definované operátory new a delete, resp. new[] a delete[].
  • Pamäť sa alokuje funkciou malloc, ktorá alokuje kus pamäte s daným počtom bajtov.
    • V prípade neúspechu vráti NULL.
    • V prípade úspechu vráti smerník na void, ktorý je následne nutné pretypovať.
  • Uvoľnenie pamäte realizuje funkcia free.
  • Pri výpočte veľkosti potrebnej pamäte sa zvyčajne používa operátor sizeof.
#include <cstdlib>   // resp. #include <stdlib.h> v C

/* vytvorime pole 100 int-ov */
int *a = (int *)malloc(sizeof(int) * 100);
/* odalokujeme pole a */
free(a);

Triedenie

  • Funkcia qsort z knižnice stdlib.h.
  • Dostane pole, počet jeho prvkov, veľkosť každého prvku a funkciu, ktorá porovná dva prvky.
    • Funkciu teda posielame ako parameter.
    • Táto porovnávacia funkcia dostane dva smerníky typu void * (na dva prvky poľa).
    • Vráti záporné číslo, ak prvý prvok je menší, nulu, ak sú rovnaké a kladné číslo, ak je prvý väčší.
  • Ak si napíšeme porovnávaciu funkciu, môžeme triediť prvky hocijakého typu.
int compare(const void *a, const void *b) {
    return (*(int*)a - *(int*)b);
}
int a[] = {5, 3, 2, 4, 1};
qsort(a, 5, sizeof(int), compare);
  • Existuje napríklad aj funkcia bsearch na binárne vyhľadávanie v utriedenom poli.

Odbočka: argumenty typu const

  • V príklade vyššie máme funkciu s hlavičkou int compare(const void *a, const void *b).
  • Argumentom typu const void *a programátor „sľubuje”, že nebude meniť obsah pamäte, na ktorú ukazuje smerník a.
  • Napríklad pre funkciu void f(const int *a) {*a = 5;} teda kompilátor vyhlási chybu.
  • Ak by sme naopak pri triedení chceli použiť funkciu s hlavičkou int compare(void *a, void *b), kompilátor tiež vyhlási chybu, lebo qsort očakáva const void * a nie void *.
  • Za dobrú prax sa považuje používanie const na všetky parametre typu smerník alebo referencia, ktoré funkcia nepotrebuje meniť.

Nepreberané črty jazyka C++

Generické funkcie

Občas sa zíde napísať algoritmus, ktorý by mohol pracovať na dátach rôznych typov. Napríklad triediť môžeme celé alebo desatinné čísla, reťazce, zložitejšie štruktúry s určitým kľúčom a pod.

V C++ sa dajú písať takzvané generické funkcie, ktoré možno „parametrizovať” podľa typu:

#include <iostream>
using namespace std;

template <typename T>
T myMax (T a, T b) {
  return (a > b) ? a : b;
}

int main() {
    int i = 3;
    int j = 5;
    int k = myMax(i, j);
    cout << k << endl;
}
  • O generických funkciách sa budete viac učiť budúci semester.

Preťaženie operátorov

Pre novovytvorené typy je možné štadardným operátorom jazyka C++ priradiť význam pomocou tzv. preťaženia. Napríklad v nasledujúcom príklade definujeme operátor < na menách, ktorý najprv porovnáva podľa priezviska a následne podľa krstného mena.

struct meno {
    char *krstne, *priezvisko;
};

bool operator < (const meno &x, const meno &y) {
    return strcmp(x.priezvisko, y.priezvisko) < 0
            || strcmp(x.priezvisko, y.priezvisko) == 0
            && strcmp(x.krstne, y.krstne) < 0;
}
  • Podobne môžeme zadefinovať napríklad operátory + a * pre štruktúry reprezentujúce napríklad polynómy alebo komplexné čísla.
  • cout << "Hello" používa preťažený operátor << pre výstupný prúd na ľavej strane a reťazec na pravej strane.

Reťazce typu string

  • V C++ je možné okrem klasických C-čkových reťazcov použiť aj typ string z C++.
  • Jeho použitie je pohodlnejšie, sám si určuje potrebnú veľkosť pamäte.
  • Reťazce tohto typu sú objekty, do funkcií ich odovzdávame väčšinou referenciou (cez &).
  • K jednotlivým znakom pristupujeme pomocou [] (ako u polí) alebo pomocou metódy at.
#include <string>
#include <iostream>
using namespace std;

int main() {
    char cstr[100] = "Ahoj\n";
    string str = "Ako sa mas?\n";
    string str2;

    /* Do str mozno pomocou operatora = 
     * korektne priradit konstantne retazce,
     * C-ckove retazce (polia znakov), 
     * aj ine premenne typu string. */
    str2 = "Ahoj\n";
    str2 = cstr;
    str2 = str;

    /* Meranie dlzky retazca: */
    cout << "Dlzka je: " << str.length() << endl;

    /* Pomocou operatora + mozeme spojit dva retazce do jedneho. */
    str2 = cstr + str;
    cout << str2 << endl;

    /* Funguje porovnanie pomocou ==, !=, <, ...
     * (bud dvoch C++ stringov, alebo C++ stringu a C stringu) */
    if (str < str2) {
        cout << "Prvy je mensi" << endl;
    } else if (str == str2) {
        cout << "Rovnaju sa" << endl;
    } else {
        cout << "Druhy je mensi" << endl;
    }
}
  • Pomocou metódy c_str() možno získať z reťazca typu string hodnotu typu const char*.

Dátová štruktúra vector

  • Súčasťou štandardnej knižnice jazyka C++ je viacero rôznych dátových štruktúr.
  • Ide pritom o generické štruktúry, ktoré môžu uchovávať dáta rôznych typov.
    • elegantnejšie ako naše riešenie s dataType
    • v jednom programe môžeme mať štruktúry uchovávajúce rôzne typy

Spomenieme dátovú štruktúru vector [1]:

  • Elegantnejšia verzia dynamických polí z prednášky.

Vytvorenie nového vektora

// vytvori pole celych cisel s nula cislami
vector<int> a;       
// vytvori pole 5 celych cisel, ktore nastavi na 1
vector<int> a(5,1);

Prístup k prvkom vektora:

  • Pomocou a[index] podobne ako pri poliach, nekontroluje, či je index v medziach poľa.
  • Funkciou a.at(index), ktorá v prípade indexu mimo rozsahu vyhodí výnimku (program spadne) a nenarobí chaos v pamäti, ľahšie sa hľadá chyba.
  • V obidvoch prípadoch môžeme aj priraďovať: a[index] = value; a.at(index) = value;
  • Ďalšie metódy na prácu s vektormi:
    • a.push_back(x) vloží hodnotu x ako nový prvok na koniec poľa, podľa potreby pritom pole realokuje a pod.
    • a.size() vráti počet prvkov v poli.
#include <vector>
#include <iostream>
using namespace std;

int main() {
    vector<int> a;
    
    while(true) {  
        // nacitame postupnost nezapornych cisel 
        // ukoncenu -1, ulozime do vektora
        int prvok;
        cin >> prvok;
        if (prvok < 0) {
            break;
        }
        a.push_back(prvok);
    }
    // vypiseme prvky vektora
    for (int i = 0; i < (int)a.size(); i++) {
        cout << a[i] << endl;  // alebo a.at(i)
    }
}

Algoritmus na triedenie:

  • V knižnici <algorithm>
//triedime normalne pole
int A[6] = {1, 4, 2, 8, 5, 7};
sort(A, A + 6);

//triedime vektor
vector <int> A;
sort(A.begin(), A.end());

//triedime podla nasej porovnavacej funkcie, napr. podla absolutnej hodnoty
struct cmp {
    bool operator()(int x, int y) { return abs(x) < abs(y); }
};
cmp c;
sort(A.begin(), A.end(), c);

2D matica pomocou vektora:

#include <vector>
#include <iostream>
using namespace std;

int main() {
    // nacitanie a vypis matice m x n
    int m, n;
    cin >> m >> n;

    vector<vector<int>> a(m, vector<int> (n, 0));

    for (int i = 0; i < (int)a.size(); i++) {
        for (int j = 0; j < (int)a[i].size(); j++) {
            cin >> a[i][j];
        }
    }
    for (int i = 0; i < (int)a.size(); i++) {
        for (int j = 0; j < (int)a[i].size(); j++) {
            cout << " " << a[i][j];
        }
        cout << endl;
    }
}

Range-based for loop

  • Špeciálny cyklus cez prvky vektora a pod.
    • nepotrebujeme zavádzať premennú pre index
    • podobný cyklus uvidíte podrobnejšie v Jave
#include <iostream>
#include <vector>
using namespace std; 

void vypis(const vector<int> &a) {
  // vypise vsetky prvky vektora a
  for (const int &value : a) { 
    cout << value << ' ';
  }
  cout << '\n';  
}

int main() {
  vector<int> a;

  // do vektora a vlozi prvky 0,..,5
  for(int value : {0,1,2,3,4,5}) {
      a.push_back(value);
  }

  vypis(a);  // vypise 0 1 2 3 4 5
 
  // zvysi kazdy prvok vektora o 1
  for (int &value : a) { 
      value++;
  }

  vypis(a); // vypise 1 2 3 4 5 6
}

Slovník, map

Štruktúra map implementuje slovník, pričom zadáme dva typy: kľúč a hodnota

  • väčšinou je implementovaný pomocou nejakej verzie binárnych vyhľadávacích stromov
  • existuje aj unordered_map, ktorý využíva hašovanie
  map <string, string> zoznam;
  zoznam["Jozko Mrkvicka"] = "02/12345678";
  zoznam["Janko Hrasko"] = "02/87654321";
  if (zoznam.count("Jozko Mrkvicka") > 0) {
      cout << zoznam["Jozko Mrkvicka"] << endl;
  }
  // prejdenie vsetkych zaznamov v slovniku pomocou iteratora
  cout << "Vsetky zaznamy:" << endl;
  for(map <string, string>::iterator i = zoznam.begin();
      i != zoznam.end(); i++) {
      // i->first je meno, i->second je cislo
      cout << i->first << " " << i->second << endl;
  }

Stream

  • Textové súbory môžeme načítavať alebo zapisovať podobne ako sme pracovali s konzolou cez cin a cout
  • Budeme využívať typy a funkcie zadefinované v knižnici fstream.
  • ifstream je typ súboru určený na čítanie, ofstream na zápis
#include <fstream>
#include <iomanip>
using namespace std;

int main () {
    // otvorenie súboru
    ofstream fw;
    fw.open ("test.txt");     
    // do fw zapisujeme podobne ako na cout
    fw << "10*32=" << 10*32 << endl; 
    // vypise 0.666667
    fw << 2.0/3.0 << endl; 
    // vypise 0.67
    fw << setprecision(2) << 2.0/3.0 << endl;
    // vypise 6.67e-01
    fw << scientific << 2.0/3.0 << endl; 
    fw.close();
}
  • Knižnica iomanip umožňuje formátovanie výstupu napríklad pomocou manipulátorov setprecision(), setfill(), setw(), scientific atď

Podobne môžeme otvoriť aj súbor, z ktorého budeme čítať vstup. V príklade načítame pole celých čísel.

#include <fstream>
#include <iostream>
using namespace std;
const int MAX = 100;

int main () {
    ifstream fr;
    fr.open ("vstup.txt");

    int pocet, a[MAX];
    fr >> pocet;
    for (int i = 0; i < pocet; i++) {
        fr >> a[i];
    }
    fr.close();

    for (int i = 0; i < pocet; i++) {
      cout << a[i] << endl;
    }
}

Kontrola správneho otvorenia súboru a iné chyby

  • Funkcia f.fail() vráti true, ak vznikla chyba (väčšinou pri otvorení neexistujúceho súboru)
  • Druhou možnosťou je testovať správne otvorenie pomocou testovania premennej typu ofstream alebo ifstream.
    • Technicky ide o preťaženie operátora konverzie na bool resp. na void*, robí negáciu funkcie fail
  ifstream fr1("vstup1.txt");
  if(fr1.fail()) {
    cout << "Cannot open vstup1.txt file.\n";
    return 1;
  }

  ifstream fr2("vstup2.txt"); 
  if(!fr2) {
    cout << "Cannot open vstup2.txt file.\n";
    return 1;
  }
  // tiez mozeme pouzit assert(fr2)

Testovanie konca súboru môžeme robiť pomocou funkcie eof

ifstream fin("vstup.txt");
while(!fin.eof()) {
    char c;
    fin >> c;
    cout << c;
}

Podobne ako pri konzole môžeme použiť funkciu getline, ktorá načíta celý riadok. Nasledovný príklad spočíta v jednotlivých riadkoch počet bodiek.

#include <fstream>
#include <iostream>
using namespace std;

int main(void){
    ifstream f("vstup.txt");;
    string line;
    while (getline(f,line)){
        int pocet = 0;
        for (int i = 0; i < line.length(); i++){
            if (line[i] == '.') {
                pocet++;
            }
        }
        cout << pocet <<endl;
    }
}