⚙️ BI-PA2 – Programování a Algoritmizace 2

Státnicové poznámky · Bakalářské státní zkoušky · FIT ČVUT

C++ OOP Abstraktní datové typy Šablony & Výjimky Dědičnost & Polymorfismus
Okruh 1 · BI-SPOL.21-22 / BI-PA2.21

Objektově orientované programování v C++

Zapouzdření, dědičnost, statické atributy a metody, virtuální metody, polymorfismus, abstraktní třídy, výjimky, šablony, přetěžování operátorů, mělká a hluboká kopie.

🏗️ Třídy, objekty a základní pojmy OOP

Třída je popis datového typu: definuje atributy (datové položky) a metody (funkce). Objekt je instance třídy – konkrétní proměnná vzniklá z daného popisu.

OOP styl vychází z analýzy problému: identifikujeme entity reálného světa, jejich vlastnosti (stav) a interakce (interface). Každá třída tak zastupuje jednu entitu.

Tři programovací styly

  • Naivní: vše v main, žádná struktura, copy-paste, nevhodný pro větší projekty.
  • Procedurální: data v strukturách (struct), operace jsou separátní funkce s explicitním parametrem struktury (např. a_push(Array& a, int x)). Snazší testování, ale názvy funkcí musejí nést prefix pro odlišení typů.
  • OOP: data a funkce sloučeny v objektu. Stav objektu je soukromý, public interface diktuje, co lze s objektem dělat.

Syntaxe třídy

struct Array {
    Array();            // konstruktor
    ~Array();           // destruktor
    size_t size() const;
    void push(int x);
private:
    size_t size_, cap_;
    int *data_;
};
💡 class vs struct
Jediný rozdíl: class má implicitní přístup private, structpublic. Jinak jsou oba mechanismy totožné.

Klíčové slovo this

Uvnitř metody je this ukazatel na aktuální instanci. Slouží k rozlišení lokálních proměnných a atributů třídy (zejm. při kolizích jmen). Dobrou praxí je dávat atributům prefix m_ nebo suffix _.

🔒 Zapouzdření (Encapsulation)

Zapouzdření je prostředek, ne cíl. Cílem je jasné, kontrolovatelné a vysokoúrovňové veřejné rozhraní, které umožňuje nezávisle upravovat implementaci uvnitř třídy.
  • public – přístupné komukoli.
  • protected – přístupné ve třídě a jejích podtřídách.
  • private – přístupné pouze ve třídě samé.

Proč ne "getter pro každou proměnnou"?

Pokud má proměnná veřejný getter i setter, lze ji rovnou zveřejnit. Getter+setter navíc zavazují třídu k určité implementaci. Lepší je nabídnout smysluplné operace (např. push(x), sort()) místo surového přístupu k datům.

⚠️ Častá chyba
Mechanické přidávání gettrů a settrů pro každou atribut bez rozmyslu je anti-pattern – narušuje smysl zapouzdření a komplikuje budoucí změny třídy.

Konstantní objekty a metody

Metoda označená const zaručuje, že nemodifikuje atributy. Na konstantním objektu lze volat pouze konstantní metody. Typický vzor – dvě přetížené varianty přístupu k prvku:

int& at(size_t i);        // nekonstantní – vrací referenci pro zápis
int  at(size_t i) const;  // konstantní   – vrací hodnotu

🔧 Konstruktory, destruktory, statické členy

Konstruktor a destruktor

  • Konstruktor je volán automaticky při vytvoření objektu (T x(a,b) nebo new T(a,b)).
  • Destruktor je volán při zániku objektu – automaticky pro lokální proměnné, explicitně přes delete pro dynamicky alokované.
  • Atributy jsou inicializovány v initializer listu (za :), před vstupem do těla konstruktoru. Pořadí inicializace odpovídá pořadí deklarace atributů ve třídě.
Array::Array() : size_(0), cap_(0), data_(nullptr) {}
// Delegující konstruktor:
Array(int v) : Array() { push(v); }
📌 Implicitní konstruktor
Překladač generuje implicitní konstruktor (bez parametrů) automaticky, pokud třída nemá žádný. Lze ho explicitně vynutit pomocí = default nebo zakázat pomocí = delete.

Statické (třídní) atributy a metody

Statické atributy jsou sdíleny všemi instancemi třídy – existuje jen jedna kopie v paměti, bez ohledu na počet objektů. Statické metody nemají přístup k this ani k instančním atributům.

class T {
    static inline int cnt = 0;   // od C++17: inline v hlavičce
public:
    T() { cnt++; }
    ~T() { cnt--; }
    static int getCount() { return cnt; }
};
// Volání statické metody:
cout << T::getCount();
📖 Proč static?
Příklad: čítač instancí třídy. Nebo globální konfigurace sdílená všemi objekty. Statické metody slouží jako "volné funkce" s přístupem do privátní části třídy (bez potřeby instance).

📋 Mělká a hluboká kopie

Mělká kopie (shallow copy): zkopíruje se pouze objekt sám – pointery se sdílí. Hluboká kopie (deep copy): kopírují se i odkazované objekty; kopie je zcela nezávislá.

Proč je mělká kopie problém s pointery?

Generovaný kopírovací operátor = kopíruje pointer, nikoli data. Oba objekty pak sdílejí jeden blok paměti – modifikace jednoho změní druhý a při destrukci dojde k double free.

Pravidlo tří (Rule of Three)

🔴 Kritické pravidlo
Pokud třída definuje destruktor, kopírovací konstruktor nebo kopírovací operátor =, měla by definovat všechny tři.
// Hluboká kopie – kopírovací konstruktor:
Array::Array(const Array& o)
    : size_(o.size_), cap_(o.cap_),
      data_(cap_ ? new int[cap_] : nullptr) {
    for (size_t i = 0; i < size_; i++)
        data_[i] = o.data_[i];
}

// Kopírovací operator= s kontrolou samopřiřazení:
Array& Array::operator=(const Array& o) {
    if (&o == this) return *this;  // ochrana před a = a
    delete[] data_;
    size_ = o.size_; cap_ = o.cap_;
    data_ = cap_ ? new int[cap_] : nullptr;
    for (size_t i = 0; i < size_; i++)
        data_[i] = o.data_[i];
    return *this;
}

Idiom copy-and-swap

Elegantní implementace operátoru přiřazení: parametr se přebírá hodnotou (volá se kopírovací konstruktor), pak se prohodí vnitřní data. Tím se automaticky ohlídá bezpečnost a samopřiřazení.

Array& Array::operator=(Array o) {  // o je kopie
    using std::swap;
    swap(size_, o.size_);
    swap(cap_, o.cap_);
    swap(data_, o.data_);
    return *this;
}  // destruktor o uvolní starou paměť

Pravidlo pěti (C++11) – rvalue reference a přesouvání

  • rvalue reference (&&) označuje dočasný objekt, jehož obsah lze "ukrást".
  • std::move převede lvalue na rvalue – upozorní překladač, že zdroj lze přesunout.
  • Přesouvací konstruktor převezme data ze zdroje a nastaví zdroj do validního prázdného stavu.
Array::Array(Array&& o)
    : size_(o.size_), cap_(o.cap_), data_(o.data_) {
    o.size_ = o.cap_ = 0;
    o.data_ = nullptr;
}
📖 Pravidlo nuly
Nejlepší je neimplementovat žádný z pěti speciálních členů a použít unique_ptr / shared_ptr místo holých pointerů – přesouvání a kopírování pak funguje správně automaticky.

🌲 Dědičnost (Inheritance)

Dědičnost umožňuje vytvořit novou třídu (podtřídu) rozšířením nebo modifikací existující (nadtřídy). Podtřída dědí atributy i metody předka a může přidávat nové nebo přepisovat existující.
class CCounter {           // základní třída
protected:
    int m_Val, m_InitVal;
public:
    virtual void increment() { m_Val++; }
    virtual void decrement() { m_Val--; }
    void reset()  { m_Val = m_InitVal; }
    int get() const { return m_Val; }
    CCounter(int val) { m_InitVal = val; reset(); }
};

class CCounterMod : public CCounter {
    int m_Modulus;
public:
    void increment() override {
        CCounter::increment();
        m_Val %= m_Modulus;
    }
    CCounterMod(int val, int mod)
        : CCounter(val % mod), m_Modulus(mod) {}
};

Paměťová reprezentace

Objekt potomka obsahuje v paměti celý objekt předka jako svoji první část, za ním následují atributy potomka. Proto lze ukazatel na potomka přiřadit ukazateli na předka.

Klíčová slova dědičnosti

Klíčové slovoViditelnost zděděných členůPolymorfismus
publicZachována (public→public, protected→protected)Ano
protectedVše max. protectedOmezený
privateVše private, nepřístupné zvnějškuNe
⚠️ Slicing (krájení)
Při předání potomka hodnotou (ne přes pointer/referenci) dojde k vytvoření kopie jako typ předka – ztratí se "část potomka". Proto se polymorfismus vždy realizuje přes pointer nebo referenci.

Klíčové slovo override (C++11)

Označení override u metody říká překladači: "tato metoda přepisuje virtuální metodu předka." Překladač pak hlásí chybu, pokud předek žádnou takovou virtuální metodu nemá – chrání před překlepovými chybami (jiná signatura, chybějící const apod.).

🎭 Virtuální metody a polymorfismus

Polymorfismus (run-time) umožňuje volat metody přes ukazatel/referenci na bázovou třídu, přičemž se vždy zavolá metoda skutečného typu objektu – bez znalosti konkrétního potomka.

Statická vs. dynamická vazba

VlastnostStatická vazbaDynamická vazba
Kdy se rozhodujePři kompilaciZa běhu
Závisí naTyp proměnné/ukazateleSkutečný typ objektu
Klíčové slovo(bez) – výchozívirtual
VýkonRychlejšíMírně pomalejší (2× dereference)
CCounter* p = new CCounterMod(0, 5);
p->increment();  // bez virtual: CCounter::increment
                 //  s virtual: CCounterMod::increment ✓

Virtuální tabulka metod (VMT / vtable)

Každá třída s virtuálními metodami má VMT – pole ukazatelů na implementace metod. Každý objekt nese ukazatel na VMT své třídy (inicializovaný konstruktorem). Volání virtuální metody = přečtení adresy z VMT + nepřímé volání → O(1), ale 2 dereference paměti navíc.

💡 Kdy použít virtual?
Použij virtual vždy, když chceš, aby potomci mohli přepsat metodu a aby se při volání přes bázový pointer/referenci zavolala správná varianta. Destruktor bázové třídy musí být virtual, jinak nedojde ke správnému uvolnění paměti potomka!
class Base {
public:
    virtual ~Base() {}  // ← POVINNÉ!
    virtual void foo();
};

📐 Abstraktní třídy a čisté virtuální metody

Abstraktní metoda (pure virtual) je deklarovaná, ale bez implementace: virtual void foo() = 0;. Třída s alespoň jednou abstraktní metodou je abstraktní třída – nelze z ní vytvářet instance, ale ukazatele a reference na ni ano.

Abstraktní třídy definují interface: zavazují všechny potomky, aby implementovaly dané metody, ale samy o tom, jak to udělají, nerozhodují.

struct CCounter {
    virtual ~CCounter() {}
    virtual void increment() = 0;
    virtual void decrement() = 0;
    virtual void reset()     = 0;
    virtual std::string get() const = 0;
};

// Potomek musí implementovat všechny čisté virtuální metody:
class CCounterInt : public CCounter {
    int m_Val, m_InitVal;
public:
    void increment() override { m_Val++; }
    // ...
};

Heterogenní (polymorfní) datové struktury

Pomocí ukazatelů na abstraktní bázovou třídu lze do jednoho kontejneru ukládat objekty různých typů (potomci), s nimiž pracujeme jednotným rozhraním:

vector<CCounter*> counters;
counters.push_back(new CCounterInt(0));
counters.push_back(new CCounterChar('A'));
for (auto* c : counters) c->increment(); // polymorfní volání

RTTI a dynamic_cast

Pokud potřebujeme zjistit skutečný typ objektu za běhu, použijeme dynamic_cast (vrátí nullptr při neshodě) nebo typeid. Dobré použití RTTI je vzácné – přílišné spoléhání na dynamic_cast signalizuje špatný návrh (mělo by stačit polymorfní volání).

Výjimky (Exceptions)

Výjimka je mechanismus pro hlášení chyb, který přenese řízení z místa detekce chyby až k volajícímu, který ji umí zpracovat – bez nutnosti explicitně předávat chybové kódy přes celý zásobník volání.

Mechanismus výjimek

  • throw výraz – vygeneruje výjimku. Typ výrazu odlišuje druhy chyb.
  • try { … } catch(Type id) { … } – blok try hlídá výjimky, catch je zpracovává.
  • Výjimka se šíří přes všechna volání, dokud nenarazí na odpovídající catch. Přitom se volají destruktory lokálních objektů (RAII!) – proto jsou výjimky bezpečné.
int bar(int x) {
    if (x == 0) throw std::invalid_argument("x nesmí být 0");
    return 100 / x;
}

int main() {
    try {
        cout << bar(0) << endl;
    } catch (const std::invalid_argument& e) {
        cout << "Chyba: " << e.what() << endl;
    } catch (...) {
        cout << "Neznámá výjimka" << endl;
    }
}

Standardní výjimky (#include <stdexcept>)

TřídaPopis
std::exceptionZákladní třída pro všechny std výjimky
std::invalid_argumentNeplatný argument funkce
std::out_of_rangeIndex/hodnota mimo povolený rozsah
std::runtime_errorChyba zjistitelná pouze za běhu
std::logic_errorLogická chyba v programu
💡 RAII a výjimky
Dynamicky alokované objekty (holé pointery) nejsou automaticky uvolněny při výjimce. Používej unique_ptr nebo shared_ptr – jejich destruktor se zavolá vždy, i při výjimce.

noexcept

Metoda označená noexcept nesmí vyhazovat výjimky. Překladač může díky tomu generovat efektivnější kód. Zejména destruktory a přesouvací konstruktory by měly být noexcept.

Přetěžování operátorů

Přetěžování operátorů přiřazuje novým datovým typům nové chování existujících operátorů C++. Priorita a asociativita operátoru se nemění, nelze zavádět nové operátory.

Kdy přetěžovat?

  • Matematické typy (zlomky, komplexní čísla, vektory, matice).
  • Kontejnery – [] pro přístup k prvkům.
  • I/O proudy – << a >>.
  • Porovnávání – ==, < atd.

Operátor jako metoda vs. funkce

OperátorDoporučeníKonverze na levý operand?
=, [], (), ->Pouze jako metodaN/A
+=, -=, *=Jako metodaNe
+, -, *, /Jako funkce (nebo friend)Ano
<<, >> (stream)Pouze jako funkce / friend
// Operátor << pro výstup (musí být funkce, ne metoda):
std::ostream& operator<<(std::ostream& out, const CRat& r) {
    return out << '(' << r.num_ << '/' << r.den_ << ')';
}

// Binární + jako friend (přístup k privátním členům):
friend CRat operator+(CRat x, CRat y) {
    return { x.num_*y.den_ + y.num_*x.den_,
             x.den_*y.den_ };
}

Spřátelené funkce (friend)

Funkce označená friend uvnitř třídy má přístup k privátním členům. De facto se stává součástí implementace třídy. Používat střídmě – narušuje zapouzdření, ale je nepostradatelná pro operátory streamů.

Uživatelské konverze

Konstruktor s jedním parametrem slouží jako konverzní konstruktor – umožňuje implicitní konverzi jiného typu na danou třídu. Slovo explicit zabraňuje implicitní konverzi (vyžaduje explicitní přetypování).

explicit CRat(int num = 0, int den = 1);
// Bez explicit: CRat r = 5; // OK
// S explicit:   CRat r = 5; // chyba, musí být CRat r(5);

Spaceship operátor <=> (C++20)

Trojcestné porovnání: vrací negativní, nulovou nebo pozitivní hodnotu. Lze ho nechat vygenerovat (= default), což automaticky generuje i ==. Dříve bylo nutné ručně implementovat všech 6 relačních operátorů.

🧩 Šablony (Templates)

Šablony jsou mechanismus pro generické programování v C++: umožňují psát kód nezávislý na konkrétním datovém typu. Až při instanciaci (použití s konkrétním typem) překladač vygeneruje konkrétní kód.

Šablony funkcí

template <typename T>
T max(const T& a, const T& b) {
    return a < b ? b : a;
}
// Instanciace je automatická:
max(1, 15);      // T = int
max(1.5, 2.3);   // T = double

Šablony tříd

template <typename T>
struct Array {
    void push(T x);
    T& operator[](size_t i);
private:
    std::unique_ptr<T[]> data_;
    size_t size_ = 0, cap_ = 0;
};

Array<int>    intArr;    // pole celých čísel
Array<string> strArr;   // pole řetězců
💡 Implementace šablon v hlavičkových souborech
Překladač potřebuje při instanciaci přístup k definici šablony (ne jen deklaraci). Proto se implementace šablon (těla metod) píší do hlavičkových souborů (.h), nikoli do .cpp.

constexpr – vyhodnocování při překladu

constexpr unsigned factorial(unsigned n) {
    return n > 1 ? n * factorial(n-1) : 1;
}
char buf[factorial(5)];  // velikost pole vypočtena při překladu

Specializace šablon

Pro konkrétní typ parametru lze poskytnout odlišnou implementaci šablony. Příklad: std::vector<bool> má specializaci ukládající bity paměťově efektivně.

📝 Shrnutí okruhu 1

  • OOP sjednocuje data a operace do objektů s jasně definovaným veřejným rozhraním (zapouzdření).
  • Konstruktor inicializuje, destruktor uklízí; pravidlo tří/pěti zajišťuje správnou správu paměti u tříd s pointery.
  • Dědičnost rozšiřuje třídy; virtual + override zajišťují run-time polymorfismus přes VMT.
  • Abstraktní třídy definují interface (= 0); nelze z nich vytvářet instance, ale ukazatele ano.
  • Výjimky přenášejí chybový signál přes zásobník volání; RAII zaručuje úklid i při výjimce.
  • Šablony umožňují generický kód bez duplikace; implementace musí být v hlavičkovém souboru.

❓ Kontrolní otázky pro státnice

Co je zapouzdření a proč nestačí mít pro každý atribut getter a setter?
Zapouzdření je prostředek k oddělení rozhraní od implementace – umožňuje měnit implementaci bez rozbití zbytku programu. Getter+setter pro každý atribut fakticky zveřejňuje implementaci a zavazuje třídu k dané vnitřní struktuře, čímž zbytečně omezuje budoucí změny.
Co je mělká kopie a kdy způsobí problém? Jak se tento problém řeší?
Mělká kopie zkopíruje hodnotu pointeru, nikoli data na která ukazuje. Problém nastane, pokud oba objekty sdílejí blok paměti – modifikace v jednom se projeví v druhém, a při destrukci dojde k dvojímu uvolnění (double free). Řeší se hlubokou kopií (vlastní kopírovací konstruktor a operator=, jež alokují nový blok a zkopírují data) nebo sdíleným vlastnictvím přes shared_ptr.
Jak funguje VMT? Proč je destruktor bázové třídy virtual?
VMT (virtual method table) je pole adres virtuálních metod, generované překladačem pro každou třídu. Každý objekt nese ukazatel na VMT své třídy. Volání virtuální metody znamená čtení adresy z VMT + nepřímé volání. Destruktor musí být virtual, protože při volání delete p (kde p je bázový pointer) musíme zavolat destruktor skutečného typu objektu – bez virtual by se zavolal jen destruktor bázové třídy a paměť potomka by unikla.
Co jsou abstraktní třídy a k čemu slouží?
Abstraktní třída obsahuje alespoň jednu čistě virtuální metodu (= 0). Nelze z ní vytvořit instanci, ale lze deklarovat ukazatele/reference. Slouží k definici rozhraní (interface) – všichni potomci musejí implementovat dané metody. Umožňuje heterogenní datové struktury (různé typy v jednom kontejneru, přístup přes bázový ukazatel).
Jaký je rozdíl mezi statickou a dynamickou vazbou? Kdy se volá která varianta?
Statická vazba: metoda se vybere při kompilaci podle deklarovaného typu proměnné. Dynamická vazba: metoda se vybere za běhu podle skutečného typu objektu (přes VMT). Dynamická vazba nastane, pokud je metoda virtual a volání probíhá přes pointer nebo referenci. Při volání přes hodnotu (kopii) vždy nastane statická vazba – proto polymorfismus vyžaduje pointer/referenci.
Vysvětlete mechanismus výjimek. Co se stane s lokálními objekty při propagaci výjimky?
Výjimka (throw výraz) přeruší normální tok programu a šíří se přes zásobník volání, dokud nenarazí na odpovídající catch blok. Při propagaci jsou destruktory všech lokálních objektů zavolány automaticky (stack unwinding) – proto jsou RAII objekty (jako unique_ptr) bezpečné i při výjimkách. Holé pointery toto nezaručují.
Proč musí být implementace šablon v hlavičkovém souboru?
Šablona sama o sobě negeneruje žádný kód – kód se generuje až při instanciaci (použití) s konkrétním typem. Při instanciaci potřebuje překladač vidět definici šablony (tělo funkcí/metod), nikoli jen deklaraci. Protože různé překladové jednotky (.cpp soubory) mohou instanciovat stejnou šablonu, musí mít definici k dispozici – proto je v hlavičkovém souboru.
Okruh 2 · BI-SPOL.21-23 / BI-PA2.21

Abstraktní datové typy – specifikace, implementace a datové struktury

Zásobník, fronta, pole, seznam, tabulka (map), množina (set). Implementace pomocí spojových struktur, stromů a pole (vč. hašovacích tabulek).

📦 Abstraktní datový typ (ADT) – definice a specifikace

ADT definuje množinu hodnot a množinu operací, nezávisle na konkrétní implementaci a paměťové reprezentaci.

Formální specifikace ADT

ADT lze formálně popsat pomocí signatur (syntaxe operací) a axiomů (sémantika – ekvivalence výrazů). Příklad pro zásobník:

Signatury:
  init:          → stack
  push(_,_):     stack, elem → stack
  pop(_):        stack → stack
  top(_):        stack → elem
  empty(_):      stack → bool

Axiomy:
  empty(init)         = true
  empty(push(s,x))    = false
  top(push(s,x))      = x
  pop(push(s,x))      = s
💡 ADT vs. datová struktura
ADT popisuje co struktura dělá (rozhraní + sémantika). Datová struktura navíc specifikuje jak to dělá (paměťová reprezentace + algoritmy). V C++ ADT typicky implementujeme jako generickou třídu (šablonu).

📚 Zásobník (Stack)

Zásobník je LIFO struktura (Last In, First Out). Prvek vložený jako poslední je odebrán jako první.

Operace zásobníku

  • push(x) – vloží prvek na vrchol.
  • pop() – odebere a vrátí prvek z vrcholu.
  • top() / peek() – přečte vrchol bez odebrání.
  • empty() – test prázdnosti.

Implementace 1: Spojový seznam (jednosměrný)

Každý uzel drží hodnotu a ukazatel na následníka. Vrchol zásobníku = hlava seznamu. Push i pop jsou O(1), žádné realokace.

template <typename T>
struct Stack {
    void push(T t) {
        head_ = make_unique<Node>(move(t), move(head_));
    }
    T pop() {
        auto tmp = move(head_);
        head_ = move(tmp->next);
        return move(tmp->value);
    }
private:
    struct Node {
        unique_ptr<Node> next;
        T value;
    };
    unique_ptr<Node> head_;
};

Implementace 2: Nafukovací pole

Pole s dynamicky rostoucí kapacitou. Push na konec pole = O(1) amortizovaně (při plném poli alokujeme dvojnásobek). Pop = O(1). Výhoda: lepší cache lokalita než spojový seznam.

ImplementacepushpopPaměť
Spojový seznamO(1)O(1)Větší overhead (ukazatele)
Nafukovací poleO(1) amort.O(1)Efektivnější, lepší cache

Použití v praxi

Zásobník volání funkcí, undo/redo, vyhodnocování výrazů, DFS průchod grafem, validace závorek.

🚃 Fronta (Queue)

Fronta je FIFO struktura (First In, First Out). Prvek vložený jako první je odebrán jako první.

Operace fronty

  • enqueue(x) – vloží prvek na konec.
  • dequeue() – odebere prvek ze začátku.
  • empty() – test prázdnosti.

Implementace 1: Spojový seznam (obousměrný ukazatel)

Hlava = začátek fronty (odebíráme), konec = přidáváme. Potřebujeme ukazatel na konec pro O(1) enqueue.

Implementace 2: Kruhové pole (circular buffer)

Pole pevné nebo dynamické velikosti, indexované modulo. Enqueue i dequeue jsou O(1) worst-case. Při plném poli se alokuje nové (lineární čas, amortizovaně konstantní).

// Klíčový vzorec pro kruhový buffer:
at(i) = data_[(i + first_) % cap_]
// Enqueue: at(size_++) = t
// Dequeue: first_ = (first_ + 1) % cap_; size_--

Implementace 3: Lineární pole s posunem

Nevybíráme fyzicky, jen zvyšujeme offset začátku. Pokud je volný prostor před prvky větší než počet prvků, posuneme je na začátek. Amortizovaně O(1) pro obě operace.

Použití v praxi

BFS průchod grafem, plánování procesů (OS scheduler), buffery pro I/O, tiskové fronty.

📊 Pole (Array ADT)

Pole je kontejner s náhodným přístupem (random access) – k prvku identifikovanému n-ticí indexů se přistoupí v O(1) díky výpočtu offsetu.

Mapovací funkce (přístupová funkce)

Mapuje indexy na offset v paměti. Pro 1D pole: map(i) = i - l1 (pro l1 = 0 jednoduše i). Pro 2D pole (n×m, row-major uložení):

map(i, j) = i * m + j   // řádkové uložení (C/C++)
map(i, j) = j * n + i   // sloupcové uložení (Fortran)

Reprezentace multidimenzionálního pole

TypPopisPřístup
Row-majorJeden blok, řádky za sebou (C/C++)O(1)
Column-majorJeden blok, sloupce za sebou (Fortran)O(1)
Iliffe (pole polí)Pole ukazatelů na řádky; umožňuje nepravidelné tvaryO(1) (2× dereference)

Operace s polem – složitosti

  • Přístup k prvku: O(1) – výpočet offsetu.
  • Přidání/odebrání na konci: O(1) amortizovaně (nafukovací pole).
  • Přidání/odebrání uprostřed: O(n) – nutno posouvat prvky.
  • Mazání bez zachování pořadí: O(1) – zaměnit mazaný prvek posledním.

Nafukovací pole (dynamic array)

Při naplnění kapacity alokujeme nové pole o dvojnásobné velikosti a zkopírujeme prvky. Amortizovaná složitost push_back je O(1) – geometrický růst zajistí, že celkové kopírování pro n vložení je O(n).

📖 Proč dvojnásobek?
Pokud bychom zvyšovali o konstantu k, celkové kopírování pro n vložení by bylo O(n²). Geometrický růst (2×, 1.5× apod.) zajišťuje O(n) celkové kopírování.

🔗 Spojový seznam (Linked List)

Spojový seznam je dynamická datová struktura, kde každý uzel obsahuje data a ukazatel(e) na sousední uzly. Nevyžaduje souvislý blok paměti.

Typy spojových seznamů

TypNavigaceTypické využití
Jednosměrný (singly linked)Jen vpředZásobník, jednoduchá fronta
Obousměrný (doubly linked)Vpřed i vzadDeque, LRU cache
CyklickýPoslední → prvníČasové sdílení (OS)

Operace s obousměrným seznamem

  • Vložení/odebrání na začátek/konec: O(1).
  • Vložení/odebrání na zadané pozici (máme iterátor): O(1).
  • Hledání prvku: O(n) – lineární průchod.
  • Přístup k i-tému prvku: O(n) – nelze indexovat jako pole.
struct Node {
    unique_ptr<Node> next;
    T value;
    Node(T v, unique_ptr<Node>&& n)
        : value(move(v)), next(move(n)) {}
};

Srovnání s polem

OperacePoleSpojový seznam
Přístup k i-tému prvkuO(1)O(n)
Vložení na začátekO(n)O(1)
Vložení na konecO(1) amort.O(1)
Vložení uprostředO(n)O(1) (máme iterátor)
Cache lokalitaVýbornáŠpatná
⚠️ Paměťová režie
Každý uzel spojového seznamu nese navíc ukazatel (8 B na 64-bit systému). Pro malé datové typy (int = 4 B) to znamená 67% overhead. V praxi bývá pole efektivnější díky cache lokalitě i pro operace, kde má seznam asymptoticky lepší složitost.

🗂️ Množina (Set) a Tabulka / Slovník (Map)

Množina je kontejner bez duplicit s operacemi insert, erase, contains. Mapa (slovník, tabulka) ukládá dvojice klíč-hodnota; klíče jsou unikátní.

Operace množiny

  • insert(x), erase(x), contains(x)
  • Množinové operace: sjednocení (∪), průnik (∩), podmnožina, rovnost

Přehled implementací s časovými složitostmi

Implementaceinserterasecontains∪/∩
Charakteristický vektorO(1)O(1)O(1)O(|U|)
Neseřazené poleO(n)O(n)O(n)O(nm)
Seřazené poleO(n)O(n)O(log n)O(n+m)
Neseřazený sez.O(n)O(n)O(n)O(nm)
Seřazený sez.O(n)O(n)O(n)O(n+m)
Vyvážený BSTO(log n)O(log n)O(log n)O(n+m)
Hašovací tabulkaO(1) prům.O(1) prům.O(1) prům.O(n+m)

V STL: std::set / std::map – vyvážený BST (červeno-černý strom). std::unordered_set / std::unordered_map – hašovací tabulka.

🌳 Binární vyhledávací strom (BST)

BST je binární strom, kde pro každý uzel s klíčem x platí: všechny klíče v levém podstromu jsou menší než x a všechny klíče v pravém podstromu jsou větší než x.

Základní operace BST

Vyhledávání: od kořene, porovnáváme x s hodnotou uzlu, jdeme doleva nebo doprava. O(h) kde h = hloubka stromu.

Vkládání: vyhledáme místo, kde by x byl, a přidáme nový uzel jako list. O(h).

Mazání: tři případy podle počtu synů mazaného uzlu:

  • Žádný syn: uzel prostě smažeme.
  • Jeden syn: nahradíme uzel jeho synem.
  • Dva synové: nahradíme hodnotu uzlu maximem z levého podstromu (nebo minimem z pravého), a pak smažeme tento uzel rekurzivně.
// Implementace mazání (schematicky):
void erase_(Node::Ptr& n, T x) {
    if (!n) return;
    if (n->value != x) return erase_(n->son(x > n->value), x);
    if (!n->left || !n->right)
        n = move(n->left ? n->left : n->right);
    else  // dva synové: najdi max levého podstromu
        n->value = erase_max_(n->left);
}

In-order průchod

Levý podstrom → uzel → pravý podstrom. Prvky jsou navštíveny v seřazeném pořadí od nejmenšího. Časová složitost: O(n).

Vyváženost BST

🔴 Problém: nevyvážený strom
Při vkládání seřazené posloupnosti (1, 2, 3, …) vznikne degenerovaný strom = spojový seznam. Hloubka je O(n) a všechny operace jsou O(n)!

Řešení: vyvažování – po vložení/mazání se kontroluje hloubka a stromem se provádějí rotace:

  • AVL strom: rozdíl výšek levého a pravého podstromu každého uzlu je max. 1. Garantuje O(log n).
  • Červeno-černý strom: barvy uzlů + invarianty zajišťují log n hloubku. Implementace v STL (std::set, std::map).
  • Líné vyvažování (scapegoat): pokud syn > 70 % otce, celý podstrom přebudujeme na dokonale vyvážený. O(log n) amortizovaně.

🔑 Hašovací tabulky (Hash Tables)

Hašovací tabulka mapuje klíče na indexy pole pomocí hašovací funkce. Ideálně umožňuje insert, erase, contains v O(1) průměrném čase.

Hašovací funkce

Funkce h(k, m) přiřadí klíči k číslo z intervalu 0..m-1, kde m je velikost pole. Požadavky: deterministická, rychlá, rovnoměrné rozložení hodnot.

// Celá čísla:
unsigned int hash(int k, int m) {
    return ((unsigned int)k) % m;
}

// Řetězce (DJB2 – Bernstein):
unsigned int hash(const char* s, int m) {
    unsigned int h = 5381;
    while (*s) h += (h << 5) + *s++;  // 33*h + c
    return h % m;
}

Kolize a jejich řešení

Kolize nastane, když h(k1, m) = h(k2, m) pro k1 ≠ k2. Vždy existuje, pokud počet klíčů > m (holubníkový princip).

Otevřené hašování (řetězení / separate chaining):

  • Každá pozice tabulky odkazuje na spojový seznam klíčů se stejným hašem.
  • Průměrná délka seznamu = n/m (load factor).
  • Pro load factor ≤ 1 jsou operace O(1) průměrně.
// Struktura hašovací tabulky:
template <class Key>
class HashSet {
    int m;                    // počet slotů
    LinkList<Key>* keys;     // pole spojových seznamů
public:
    void ins(const Key& k) { keys[hash(k,m)].add(k); }
    bool incl(const Key& k) const {
        return keys[hash(k,m)].incl(k);
    }
};

Uzavřené hašování (open addressing): při kolizi hledáme jiný slot v tabulce (lineární probingem, kvadratickým, double hashing). Všechny prvky jsou v samotné tabulce.

💡 Volba velikosti m
Optimálně volíme m jako prvočíslo a load factor < 0,7. Při překročení load factoru tabulku rehašujeme – přealokujeme na dvojnásobek a přepočítáme hašovací hodnoty všech prvků (O(n), amortizovaně O(1) na operaci).

Vztah map a set implementovaných hašováním

Mapa je množina párů (klíč, hodnota) kde porovnání ignoruje hodnotu. Implementace je identická, jen uzly nesou navíc asociovanou hodnotu.

⬆️ Prioritní fronta a binární halda

Prioritní fronta je ADT, kde se prvky odebírají podle priority, ne pořadí vložení. Binární halda je typická implementace: binární strom splňující haldové uspořádání.

Vlastnosti binární haldy (min-heap)

  • Úplný binární strom (vyplněn po hladinách zleva).
  • Haldové uspořádání: každý uzel má prioritu ≤ priorita synů → minimum je vždy v kořeni.
  • Hloubka = O(log n).

Uložení v poli (indexování od 1)

// Synové uzlu i jsou na indexech 2i a 2i+1
// Otec uzlu i je na indexu i/2
// Přidáváme/odebíráme vždy na konci pole

Operace haldy

  • insert(x): přidej na konec, bubble_up – porovnej s otcem, pokud je menší, prohoď; opakuj. O(log n).
  • extract_min(): odeber kořen, přesuň poslední prvek do kořene, bubble_down – porovnej se syny, prohoď s menším; opakuj. O(log n).
  • decrease_key: sniž prioritu, pak bubble_up. O(log n).
void bubble_up(size_t i) {
    if (i == 0 || data_[i] >= data_[(i-1)/2]) return;
    swap(data_[i], data_[(i-1)/2]);
    bubble_up((i-1)/2);
}
void bubble_down(size_t i) {
    size_t l = 2*i+1;
    if (l >= size) return;
    size_t s = (l+1 < size && data_[l+1] < data_[l]) ? l+1 : l;
    if (data_[i] <= data_[s]) return;
    swap(data_[i], data_[s]);
    bubble_down(s);
}

STL implementace: std::priority_queue (max-heap), funkce std::make_heap, std::push_heap, std::pop_heap.

📚 STL kontejnery a jejich volba

C++ standardní knihovna nabízí hotové implementace všech základních ADT jako generické třídy (šablony).

KontejnerInterní strukturaPřístup O(1)?Seřazeno?Hlavní použití
vector<T>Nafukovací poleAno (index)NeObecné pole, zásobník
deque<T>Chunky poleAnoNeFronta (push/pop oba konce)
list<T>Obousměrný sp. seznamNeNeČasté vkládání uprostřed
set<T>Červeno-černý stromNe (log n)AnoMnožina seřazených prvků
map<K,V>Červeno-černý stromNe (log n)Dle klíčeSeřazený slovník
unordered_set<T>Hašovací tabulkaO(1) prům.NeRychlá množina
unordered_map<K,V>Hašovací tabulkaO(1) prům.NeRychlý slovník
stack<T>Wrapper na dequeLIFO zásobník
queue<T>Wrapper na dequeFIFO fronta
priority_queue<T>Halda (max-heap)Prioritní fronta

Iterátory

Iterátory jsou obecné ukazatele na prvky kontejnerů – umožňují jednotné procházení bez znalosti vnitřní struktury. Typy iterátorů (od nejslabšího):

  • InputIterator: čtení, posun vpřed (istream_iterator)
  • OutputIterator: zápis, posun vpřed (ostream_iterator, back_inserter)
  • ForwardIterator: čtení i zápis, více průchodů (forward_list)
  • BidirectionalIterator: + posun vzad (list, set, map)
  • RandomAccessIterator: + skok o n pozic, indexace (vector, deque)

📝 Shrnutí okruhu 2

  • ADT definuje operace a jejich sémantiku nezávisle na implementaci; datová struktura navíc specifikuje reprezentaci a algoritmy.
  • Zásobník (LIFO) a fronta (FIFO): implementace polem (amortizovaně O(1)) nebo spojovým seznamem (O(1) worst-case).
  • BST umožňuje seřazený přístup, hledání, insert, delete v O(log n) při vyváženosti; nevyvážený BST degraduje na O(n).
  • Hašovací tabulka: průměrně O(1) pro základní operace, nutná dobrá hašovací funkce a řešení kolizí (řetězení nebo open addressing).
  • Binární halda: implementace prioritní fronty v poli, insert a extract_min v O(log n).
  • Volba správné datové struktury zásadně ovlivňuje výkon; vždy zvažuj požadované operace a jejich frekvenci.

❓ Kontrolní otázky pro státnice

Co je abstraktní datový typ? Čím se liší od datové struktury?
ADT definuje množinu hodnot a operací nezávisle na implementaci – říká co se dělá (sémantika přes axiomy), nikoli jak. Datová struktura navíc specifikuje paměťovou reprezentaci a konkrétní algoritmy – tedy i jak efektivně se operace provedou.
Jaké jsou způsoby implementace zásobníku a fronty? Porovnejte jejich vlastnosti.
Oba lze implementovat: (1) spojovým seznamem – push/pop/enqueue/dequeue jsou O(1) worst-case, ale větší paměťová režie; (2) nafukovacím polem – push/enqueue jsou O(1) amortizovaně (realokace při plném poli), lepší cache lokalita. Pro frontu je nutné kruhové pole (nebo lineárně s posunem) aby dequeue bylo O(1).
Proč může být nevyvážený BST problematický? Jak se vyvažování řeší?
Při seřazeném vkládání vznikne degenerovaný strom (hloubka O(n)), kde všechny operace mají O(n) složitost namísto O(log n). Řeší se vyvažováním: AVL strom udržuje rozdíl výšek podstromů ≤ 1 (rotace po vložení/mazání), červeno-černý strom má slabší invarianty ale prakticky rychlejší rotace. V STL se používá červeno-černý strom.
Co je hašovací funkce a co jsou kolize? Jak se kolize řeší?
Hašovací funkce mapuje klíč na index 0..m-1 v tabulce. Kolize nastane, když dva různé klíče mají stejný index. Řeší se: (1) otevrené hašování – na každé pozici je seznam kolidujících klíčů; (2) uzavřené hašování – při kolizi hledáme jiný slot (lineární probing, kvadratický probing, double hashing). Klíčová je volba dobré hašovací funkce pro rovnoměrné rozložení klíčů.
Vysvětlete princip binární haldy. Jak probíhá bubble_up a bubble_down?
Binární halda je úplný binární strom uložený v poli (synové uzlu i jsou na indexech 2i a 2i+1). Haldové uspořádání: priorita otce ≤ priorita synů. Bubble_up: po vložení nového prvku (na konec) ho porovnáváme s otcem; pokud je menší, prohazujeme – opakujeme směrem ke kořeni. Bubble_down: po odebrání minima (kořen) dosadíme poslední prvek do kořene a ho porovnáváme s menším ze synů; pokud je větší, prohazujeme – opakujeme směrem dolů. Obě operace jsou O(log n).
Kdy zvolíte BST (std::set) a kdy hašovací tabulku (std::unordered_set)?
BST (std::set): potřebujeme seřazené procházení prvků, operace lower_bound/upper_bound, nebo garantované O(log n) worst-case. Hašovací tabulka (std::unordered_set): potřebujeme co nejrychlejší insert/contains v průměrném případě O(1) a nevadí nám, že prvky nejsou seřazeny. Pozor: hašovací tabulka může mít O(n) worst-case při špatné hašovací funkci nebo mnoha kolizích.
Co je nafukovací pole a jak dosahuje amortizované O(1) složitosti pro push_back?
Nafukovací pole při naplnění kapacity alokuje nové pole o dvojnásobné velikosti a přesune prvky. Jednotlivé operace push_back jsou buď O(1) (dost místa) nebo O(n) (realokace). Amortizovaná analýza: pro n operací push_back je celkový počet kopírování 1 + 2 + 4 + … = O(n), tedy průměr je O(1) na operaci. Geometrický růst (2×) je klíčový – lineární přírůstek by dal O(n) amortizovaně.