0. hét: Összetett feladatok

Czirkos Zoltán · 2022.06.21.

A tárgy anyagához szorosan nem kapcsolódó, nehezebb programozási feladatok.

1. Morzekódot dekódoló tömb

A listákat és a fákat általában dinamikus adatszerkezeteknek nevezzük, mert dinamikus foglalással hozzuk létre az egyes csomópontjaikat. A dinamikus memóriakezelés azonban nem szükségszerű. A csomópontokból építhetünk tömböt is, hiszen az egyes csomópontokban tárolt pointerek nem feltétlenül kell dinamikus memóriaterületre mutassanak, mutathatnak egy tömb elemeire is. (Sőt ilyenkor akár a pointerek helyett indexeket is használhatnánk: -1 jelentené a lista végét vagy az üres fát, mert az nem lehet tömbindex.)

Például egy láncolt lista, amely az 5, 6, 7 számokat tartalmazza, és a tömb 0, 2, 1. elemeiből épül fel:

struct Listaelem {
  int adat;
  ListaElem *kov;
};

ListaElem elemek[4] = {
  { 5, &elemek[2] },
  { 7, NULL },
  { 6, &elemek[1] },
};
ListaElem *eleje = &elemek[0];

A tömb 3. eleme üres, ha a listát bővíteni szeretnénk, ott még van egy hely.

Mindez adhat egy ötletet a morzekód dekódoláshoz. Tudjuk, hogy a morzekódot egy dekódoló fával lehet leggyorsabban dekódolni. És azt is, hogy a morzekód előre adott, nem változik - ezért a dekódoló fát legjobb lenne már a forráskódba beépíteni, nem pedig futási időben létrehozni. A forráskód a fentihez hasonló lehetne.

Ezt azonban kézzel megírni elég időrabló dolog, jobb lenne egy másik programmal generálni. A feladat tehát a következő: ki kell indulnod egy olyan forráskódból, amelyik tartalmazza a morzeábécét:

Morse morse_data[] = {
    { 'A', ".-" },
    { 'B', "-..." },
    { 'C', "-.-." },
    /* ... */
    { 'Z', "--.." },
    { '0', "-----" },
    { '1', ".----" },
    /* ... */
    { '9', "----." },
    {0}
};

És ezt kell kiegészítened egy olyan teljes programmá, amelyik a fenti tömb felhasználásával előállítja a morzekód dekódolására használható bináris fát a szabványos kimenetén:

MorseTree root[] = {
    { 0, root + 1, root + 3 },
    { 'E', root + 9, root + 2 },
    { 'A', root + 18, root + 16 },
    { 'T', root + 4, root + 12 },
    /* ... */
};

Ennek a fának a gyökere a tömb 0. eleme. Onnan eggyel balra, az 1. indexen az 'E' betű van (ti), eggyel jobbra, a 3. indexen pedig a 'T' betű (tá). (Választásod szerint a tömbbön belüli pointerek helyett lehetnek akár tömbindexek is.)

Mutasd is meg, hogy működik az így megépített fa, dekódold a morzekóddal leírt nevedet a segítségével!

2. Rekurzió – stílusgyakorlat

Tetszőlegesen hosszú szöveget beolvasni: ez nem könnyű feladat. Amíg nem láttuk az egész szöveget, nem tudjuk, mennyi memóriát kell foglalni. Amíg nem foglaltuk le a memóriaterületet a sztring számára, addig viszont nem olvashatjuk a szöveget, mert nem tudjuk eltárolni a karaktereket.

Vagy mégis? Tudjuk, hogy a függvények lokális változóiból minden függvényhíváskor létrejön egy új példány. Így ha egy rekurzív függvénynek egy karakter a lokális változója, akkor egy rekurzív hívással létrehozunk egy újabb karaktert, meg még egyet és még egyet, amíg a szöveg végére nem értünk. Ha ez megtörtént, akkor pedig lefoglalhatjuk a memóriaterületet, mert addigra látjuk, hány karakter volt.

A feladatod egy ilyen függvényt írni:

/* A függvény beolvas egy teljes sort (enterig) a szabványos bemenetről,
 * és visszaadja egy dinamikusan foglalt sztringben. A sztring
 * nullával van lezárva, az entert nem tartalmazza. A hívó felelőssége
 * a free()-t meghívni a kapott pointerre. */
char* sort_beolvas(void);

A szöveg beolvasását a fenti módszerrel kell megvalósítanod. Amit használhatsz:

  • tetszőleges saját segédfüggvény,
  • egyetlen egy malloc hívás.

Amit pedig nem:

  • statikus élettartamú változó (azaz globális és függvénybeli statikus),
  • tömb átméretezés, bármilyen egyéb tároló.

Figyelem: ennek a feladatnak elméleti jelentősége van csak! A gyakorlatban az okos átméretezés (pl. a méret duplázása) célravezetőbb, hatékonyabb.

3. Szótár, trie fa

A trie fa (trie tree, radix tree, prefix tree) egy olyan adatszerkezet, amelyben sztringek tárolhatók, és azok prefix alapján könnyen kereshetők.

Jobb oldalt látható egy ilyen fa, amely a tea, ten, in és inn szavakat tartalmazza. A t, te és i sztringek nincsenek a fában; csak az előbbi szavakhoz vezető út miatt kellett létrehozni őket.

Írj programot, amely ilyen szótárat épít az STL eszközeivel. Lehessen egy egyszerű felületen hozzáadni a szavakat, és ellenőrizni, benne vannak-e a szótárban!

> add tea
added tea.
> add ten
added ten.
> find tea
tea in dict.
> find te
te not in dict.

4. abs() – rejtvény

Az alábbi program mást ír ki using namespace std-vel és anélkül. Miért? Mi lehet az oka, hogy ezt így találták ki?

#include <cmath>
#include <cstdlib>
#include <iostream>

using namespace std;

int main() {
    std::cout << abs(sin(1.2));
}

5. Keresőfa-e

Írj függvényt, amely paraméterként egy egész számot tartozó fát kap, visszatérési értékében pedig azt jelzi, hogy ez rendelkezik-e keresőfa tulajdonsággal, vagy nem! Vagyis balra mindig a gyökértől kisebb, jobbra mindig a gyökértől nagyobb számok vannak, mindenhol.

Vizsgáld meg az algoritmusod futási idejét! Hogyan függ az a fa csúcspontjainak számától!

Ha nem O(n) időben fut az algoritmusod, oldd meg azt O(n) időben is!

Megoldás

Nem elég azt vizsgálni, hogy a teljes fa gyökeréhez képest csak balra kisebbek, jobbra nagyobbak vannak-e. Az alábbi fában ez teljesül (a 2 és a 3 kisebbek a gyökérben lévő 5-nél, illetve a 6, 9 és 8 nagyobbak nála), mégsem keresőfa; a jelölt elemek elrontják azt.

    5
   / \
  3   9
 /   / \
2   6   8

Azt sem elég vizsgálni, hogy minden csomópont gyerekei balra kisebbek, jobbra nagyobbak-e. Az alábbi fában ez minden közvetlen kapcsolatban lévő elemre teljesül, mégsem keresőfáról van szó: a 6-os elem rossz helyen van, bár a 3-nál nagyobb, de az egész fát tekintve a gyökérben lévő 5-től jobbra keresnénk.

    5
   / \
  3   8
 / \   \
2   6   9

A két tulajdonságnak együtt kell teljesülnie.

struct BinFa {
    int szam;
    BinFa *bal, *jobb;
};

/* maximumot keres a fában. a teljes fát vizsgálja, nem feltételez keresőfa
 * tulajdonságot. nem hívható üres fára. */
int max_fa(BinFa *gy) {
    int max = gy->szam;
    if (gy->bal != NULL) {
        int bal_max = max_fa(gy->bal);
        max = bal_max > max ? bal_max : max;
    }
    if (gy->jobb != NULL) {
        int jobb_max = max_fa(gy->jobb);
        max = jobb_max > max ? jobb_max : max;
    }
    return max;
}

int min_fa(BinFa *gy) {
    return /* ... mint fent ... */;
}

bool keresofa_e(BinFa *gy) {
    if (gy == NULL)     /* üres fa = keresőfa, mert nem hibás */
        return true;
    
    /* ha balra bárhol túl nagy elem van, vagy jobbra bárhol túl kicsi */
    if (max_fa(gy->bal) > gy->szam)
        return false;
    if (min_fa(gy->jobb) < gy->szam)
        return false;
    
    /* ha balra vagy jobbra nem keresőfa van */
    if (!keresofa_e(gy->bal) || !keresofa_e(gy->jobb))
        return false;
    
    /* egyébként az */
    return true;
}

Ez nem hatékony; minden részfát annyiszor bejár (sőt duplán annyiszor), amilyen magas részfában van.

Hatékonyabb megoldáshoz érdemes egy bejárásból kiindulni. Tudjuk, hogy a keresőfát inorder bejárva növekvő számsort kapunk. Tehát nem kell mást tenni, mint elvégezni a bejárást, és ha bárhol olyan számot találunk, amelyik egyenlő az előbb látottal vagy kisebb annál, akkor a fát hibásnak jelölhetjük. Figyelni kell arra, hogy az első elemet még nincs mivel összehasonlítani a keresésben. Erre jó lehet egy std::optional objektum. Érdemes megállítani a keresést, ha hibát találunk a fában; ez pedig úgy oldható meg legyegyszerűbben, ha kivételt dobunk a rekurzió mélyéről.

#include <iostream>
#include <optional>

struct BinFa {
    int szam;
    BinFa *bal, *jobb;
};

class NemKeresofa : public std::exception {};

void keresofa_hibat_keres(BinFa *gy, std::optional<int> &elozo) {
    if (gy == NULL)
        return;
    keresofa_hibat_keres(gy->bal, elozo);
    if (elozo && *elozo >= gy->szam)
        throw NemKeresofa();
    elozo = gy->szam;
    keresofa_hibat_keres(gy->jobb, elozo);
}

bool keresofa_e(BinFa *gy) {
    try {
        std::optional<int> max;
        keresofa_hibat_keres(gy, max);
    } catch (NemKeresofa nk) {
        return false;
    }
    return true;
}

int main() {
    BinFa *fa =
        new BinFa{5,
            new BinFa{3,
                new BinFa{2, nullptr, nullptr},
                new BinFa{4, nullptr, nullptr}},
            new BinFa{7, nullptr, nullptr}};
    std::cout << keresofa_e(fa);
}

Másik megoldási lehetőség: figyelembe vehetjük azt is, hogy a hierarchia milyen intervallumba szorítja be a részfák csomópontjaiban található értékeket. Ehhez vegyük példának az alábbi fát:

    5
   / \
  3   9
     / \
    x   10

Ebben a fában az x-szel jelölt helyen (akár egy csomópont van ott, akár egy nagyobb részfa) csak 5-nél nagyobb, és 9-nél kisebb szám lehet. 5-nél nagyobb azért, mert a gyökérben lévő 5-től jobbra van (ha kisebb lenne, akkor az 5-től balra kellene legyen). 9-nél kisebb pedig azért, mert egy attól balra lévő részfáról van szó.

A megoldás így:

bool keresofa_e_belso(BinFa *gy, int *min, int *max) {
    if (gy == nullptr)
        return true;
    
    if ((min != nullptr && gy->szam <= *min)
        || (max != nullptr && gy->szam >= *max))
        return false;
    
    return keresofa_e_belso(gy->bal, min, &gy->szam)
        && keresofa_e_belso(gy->jobb, &gy->szam, max);
}

bool keresofa_e(BinFa *gy) {
    return keresofa_e_belso(gy, nullptr, nullptr);
}

A függvény a fa gyökere mellett átvesz egy minimumot és egy maximumot is – ezek között kellene legyen az adott részfa összes eleme. Ha ez nem teljesül az adott csomópontnál, hamissal tér vissza, amúgy pedig vizsgálja a részfákat. Amikor balra halad, akkor a gyökér határozza meg a maximumot (annál nagyobb elem a gyökértől balra nem lehet), amikor jobbra, akkor pedig a gyökér a minimumot adja (annál jobbra kisebb elem nem lehet). A keresést úgy kell elindítani, hogy mindkét pointer nullptr értékű; a gyökér esetében még nincs sem alsó, sem felső határ.

6. Sakktábla objektum, figura objektum

A sakk tisztjei az alábbi módokon tudnak mozogni:

  • A bástya azonos soron vagy oszlopon léphet.
  • A futó azonos átlón léphet.
  • A vezér bástyaként és futóként is tud lépni.
  • Végül pedig a huszár az egyetlen, amely el tud menekülni a vezér elől, mert L alakban (2-t erre, 1-et arra) lép.

Olyan programot kell írnod, amelyben feladat megmondani egy kiválasztott mezőn álló figuráról, hogy támadja-e valamelyik másik figurát a táblán. A színekkel (világos, sötét) most nem kell foglalkoznod.

Procedurálisan

Készíts ehhez procedurális modellt! Ebben egy mezőn álló figura típusát egy felsorolt típusú értékkel enum-mal tárolhatod, amelynek része kell legyen az üres mező is. A tábla ilyen enum-ok tömbje. Írd meg azt a függvényt, amely egy pozíciót kap, és igaz/hamis értékkel tér vissza attól függően, hogy van-e ott figura ÉS támad-e az egy másik figurát!

Objektumorientáltan

Az objektumorientált modellben a tábla olyan objektumokból áll, amelyek mind egy figura ősosztály leszármazottjai. A tábla így egy heterogén kollekció, amely 8×8 pointerből áll; az üres mező lehet null pointer is. A lépést pedig valamilyen virtuális függvény adja meg. Írd meg így is a programot!

Végül hasonlítsd össze a két programot olyan szempontból, hogy mennyire különülnek el a programban az egyes önálló tevékenységek függvényei!

Megoldás
#include <iostream>

class Table;


class Piece {
  public:
    /* igazzal tér vissza, ha a megadott mezőre tud mozogni a figura */
    virtual bool can_move(int x, int y) const = 0;
    /* igazzal tér vissza, ha bármit épp támad */
    bool attacks_something() const;
    /* táblán elhelyezés */
    void set_pos(Table *table, int x, int y) {
        table_ = table;
        x_ = x;
        y_ = y;
    }
  protected:
    int x_, y_;
    Table *table_;
};


class Table {
  public:
    Table() {
        for (int y = 0; y < 8; ++y)
            for (int x = 0; x < 8; ++x)
                table_[y][x] = nullptr;
    }
    void set(int x, int y, Piece *p) {
        table_[y][x] = p;
        p->set_pos(this, x, y);
    }
    Piece *get(int x, int y) const {
        return table_[y][x];
    }
  private:
    Piece *table_[8][8];
};


bool Piece::attacks_something() const {
    /* mit támad? */
    for (int y = 0; y < 8; ++y) {
        for (int x = 0; x < 8; ++x) {
            /* saját magát nem */
            if (x == x_ && y == y_)
                continue;
            /* ha van ott valami, és én léphetek oda, akkor támadom. */
            /* az algoritmus független a típustól! */
            if (table_->get(x, y) && this->can_move(x, y))
                return true;
        }
    }
    return false;
}


class Rook : public Piece {
  public:
    virtual bool can_move(int x, int y) const {
        return x == x_ || y == y_;
    }
};

class Bishop : public Piece {
  public:
    virtual bool can_move(int x, int y) const {
        return abs(x - x_) == abs(y - y_);
    }
};

class Queen : public Piece {
  public:
    virtual bool can_move(int x, int y) const {
        return x == x_ || y == y_ || abs(x - x_) == abs(y - y_);
    }
};

class Knight : public Piece {
  public:
    virtual bool can_move(int x, int y) const {
        int dx = abs(x - x_);
        int dy = abs(y - y_);
        return (dx == 2 && dy == 1) || (dx == 1 && dy == 2);
    }
};


int main() {
    Table t;
    Rook r1;
    Rook r2;
    t.set(5, 5, &r1);
    t.set(6, 6, &r2);
    std::cout << r1.attacks_something();
}

7. Szótár bináris kereséssel

Első rész

Adott a következő szótárprogram: szotar.c. Ez C-ben íródott; egy max. 200 szópárból álló angol–magyar szótárat képes tárolni, fájlba írni és onnan visszaolvasni. A szavak maximum 50 betűsek lehetnek.

Írd át ezt a programot C++-ba! Használd az STL eszközeit a szótár tárolásához, szüntesd meg a max. 50 betű, illetve max. 200 szópár korlátozásokat! OOP-sítsd a szótárat, szünetsd meg a C stílusú hibakezelést, és végül írd át C++-osra a fájlkezelést is!

Második rész

Végezd el a következő módosításokat!

  • Hozz létre indexelő tömböket, amelyek segítségével magyar és angol nyelven egyszerre is rendezett lehet a szótár. Pl. ha egy indexelő tömb tartalma {3, 4, 2, 0, 1}, az azt jelentheti, hogy a szótár elemeit ilyen sorrendben olvasva magyar ábécérendben kapjuk a szavakat. A másik indexelő tömb pl. {2, 4, 3, 1, 0} lehet, az meg az angol sorrendre.
  • Az indexelő tömböt fájlba ne mentsd, ugyanakkor figyelj arra, hogy betöltéskor újra kell azt generálni.
  • Új szó felvételekor szintén az indexelő tömböket kezelni kell.
  • Lehessen listázni kétféleképpen: magyar nyelvű sorrend szerint és angol nyelvű sorrend szerint is!
  • A keresést írd át úgy, hogy bináris keresést alkalmazz benne. Ügyelj arra, hogy a funkcionalitás megmaradjon: magyar és angol kifejezés alapján is kell tudni keresni.

Próbáld minél inkább elkerülni a kódduplikációt!

Ha végül már csak nyelv szerinti esetszétválasztásaid maradnak a programban (if nyelv == magyar ... else ... jellegű kódrészletek), akkor azokat is próbáld megszüntetni! Ez nehezebb feladat, komolyabb átalakítást igényelhet.

Megoldás

A probléma oda vezethető vissza, hogy egy osztály két adattagja közül kell választani mindig. Hol eszerint keresünk, hol eszerint rendezünk stb. Valahogy így:

if (nyelv == Magyar)
    return tomb[i].magyar;
else
    return tomb[i].angol;

Több megoldás is kínálkozik:

  • Át kell alakítani az adatszerkezetet. A nyelvek nem osztály adattagjaiként jelennek meg, hanem egymástól független tömbökként, valahogy így:
    class Szotar {
        std::vector<std::string> magyar_szavak;
        std::vector<int> magyar_indexek;
        std::vector<std::string> angol_szavak;
        std::vector<int> angol_indexek;
    };
    A párhuzamos tömbök nem szépek, de az indexelő tömbök eleve párhuzamosan kell fussanak (pl. megegyező méretben) a szavak tömbjeivel, így ez elfogadható ebben a feladatban. Esetleg egy nyelv szavaihoz és indexeihez osztályt lehet létrehozni:
    class Szotar {
        struct Nyelv {
            std::vector<std::string> szavak;
            std::vector<int> indexek;
        };
        
        Nyelv magyar, angol;
    };
    A lényeg így is az lesz, hogy egy összetartozó szótömb–indextömb páros már adatként kezelhető; pl. a kereső függvény referenciaként kaphatja, hogy melyik szótömbben, melyik indextömbbel kell keresni.
  • Lehet C++ adattag mutatókat (member pointer) is használni:
    struct Szo {
        std::string magyar, angol;
    };
    
    std::string Szo::* p;
    
    p = &Szo::magyar;
    std::cout << szavak[i].*p;  // a magyar szó
                               
    p = &Szo::angol;           
    std::cout << szavak[i].*p;  // az angol szó
    Így is elérhető, hogy ez az információ paraméterként adható át a függvényeknek.
  • Lehet függvényeket is írni is, amelyek egy szópár angol vagy magyar szavát adják vissza. Ezek kompatibilis függvények lesznek (szópár → sztring fejléccel), és átadhatók paraméterként. Így a keresés és a rendezés is generikus lehet.