Dinamikus adatszerkezetek II. – Bináris fák

1 Ismétlés – láncolt lista

Duplán láncolt lista

A láncolt listák előnye: nagyon gyors és egyszerű a méretüket megváltoztatni (beszúrás/törlés), így alkalmasak dinamikusan változó számú adat kezelésére.

Hátrányuk: csak lineáris keresés valósítható meg bennük.


Az ideális az lenne, ha olyan adatszerkezetünk lenne, ami

A bináris fák és algoritmusaik

3 A bináris keresőfa: rendezett tárolás

Minden elemnek két gyereke lehet:

Figyeljük meg, hogy a fenti szabály nem csak a közvetlen gyermekekre igaz! Például az 5-től balra található elemek a 3, 4, 1, 2 mind kisebbek nála. Az a tény, hogy ha egy állítás igaz egy elem bal/jobboldali gyermekére, akkor az igaz az összes az elemtől balra/jobbra elhelyezkedő elemre, egy nagyon fontos tulajdonsága a bináris fáknak.

A fa elemeinek megnevezéséhez a családfa analógiáját szoktuk használni. Minden csomópontból maximum két nyíl indul ki, a csomópont két gyereke felé. A csomópont maga ilyenkor a szülő csomópont. Leszármazottaknak nevezzük egy csomópont összes gyerekét, azok gyerekeit stb. (Vagyis a gyerekek a közvetlen leszármazottak.)

A fa gyökerének a szerkezet kezdőpontját nevezzük. Ez egy olyan elem, amelyiknek nincsen már szülője. A fa levelei azok a csomópontok, amelyeknek nincsen gyerekük.

A fa rekurzív adatszerkezet: egy elem bal oldali szomszédja is egy részfa, jobb oldali is egy részfa. Ezeknek természetesen további részfáik lehetnek.

A fa csomópontjainak megnevezéséhez használjuk a gráfelméletből vett szavakat is. A fa egy gráf: csúcsokból áll, amelyeket élek kötnek össze:

  • az élek irányítottak, a nyíl egy elemtől a másikra mutat, visszafelé nem,
  • két elem között csak egy út létezik – nincs kör a fában.

Egy csomópont szomszédai azok, amelyek egy éllel össze vannak kötve (vagyis az adott csomópont szülője és két gyereke). A bal oldali gyereket így nevezhetjük bal oldali szomszédnak, a jobb oldali gyereket pedig jobb oldali szomszédnak. Ilyen értelemben a szülő is szomszéd.

4 A fa reprezentációja C nyelven

typedef struct BinFa {
  …    // tetszőleges adat
  struct BinFa *bal, *jobb;
} BinFa;

A fa egy csúcsát egy struktúra írja le, amelyben az adatokon túl van két ugyanilyen strukúrára mutató pointer: a bal, illetve jobb gyermek címei.

Érdekesség: nyelvi szinten nem látszik a különbség egy duplán láncolt lista és egy bináris fa között. A különbség abban áll, hogy másra használjuk az adatok mellé tett két pointert.

5 Keresés a fában: O(log n)

Az algoritmus:

  1. a gyökér elemtől indulunk,
  2. ha az aktuális elem nem létezik, akkor nincs a fában a keresett,
  3. összehasonlítjuk a keresett elemmel:
    • ha pont az, akkor végeztünk,
    • ha nagyobb, akkor balra megyünk tovább,
    • ha kisebb, akkor jobbra megyünk tovább;
  4. folytatjuk a 2. ponttól.

Elvileg minden lépésben feleződik a még megvizsgálandó elemek száma, tehát a keresés O(log2n) időben fut le.

Tudjuk, merre
kell menni!
BinFa *keres(BinFa *gyoker, int adat) {
   BinFa *mozgo=gyoker;
   while (mozgo!=NULL && mozgo->adat!=adat) {
      if (adat < mozgo->adat) mozgo=mozgo->bal;
      else mozgo=mozgo->jobb;
   }
   return mozgo;
}

A while ciklussal addig megyünk, amíg a „mozgo” NULL nem lesz, vagy meg nem találjuk a keresett elemet. A „mozgo” NULL lehet, mert:

  • a fa még üres és egy NULL pointert kaptunk argumentumként,
  • a legutóbbi összehasonlítást egy levélben végeztük és továbbindultunk egy nemlétező gyermek felé.

A logikai rövidzár miatt az while ciklus fejében az ÉS kapcsolat második fele már nem értékelődik ki, ha „mozgo” értéke NULL. Ez nagyon fontos, hiszen egy NULL pointeren a mozgo->adat kifejezés futási idejű hibát okozna. A logikai rövidzárat pontosan az ilyen jellegű kifejezések miatt vezették be a nyelvbe.

A lehetséges visszatérési értékek:

  • NULL, mert a fa üres volt és rögtön az első iteráció kilépett a while ciklusból,
  • NULL, mert az elemet nem találta meg és egy levél nemlétező gyermekén állt meg a ciklus,
  • a megtalált elem címe.

a. megvan

c. üres fa

b. nincs benne

A keresés lehetséges eredményei:

  1. a fa üres, a visszatérési érték: NULL,
  2. nincs a fában a keresett elem (pl. 10), visszatérési érték: NULL,
  3. megtaláljuk a keresett elemet, visszatérés az elem címével.

6 A fa bejárása I. – rekurzió

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

A fa bejárása rekurzió nélkül csak nehézkesen oldható meg.
Ha kihasználjuk, hogy a fa egy rekurzív adatszerkezet, akkor egyszerű feladat!


7 A fa bejárása II. – algoritmus

bal–gyökér–jobb

Feladat: írjuk ki a fában tárolt számokat!

  1. Járjuk be az aktuális elem bal részfáját,
  2. Írjuk ki az aktuális csúcsban tárolt számot,
  3. Járjuk be az aktuális elem jobb részfáját.
 

A rekurzióban az a szép, hogy a leírás C-ben is egyszerű:

void sorban_kiir(BinFa *gyoker) {
   if (gyoker==NULL)   // leállási feltétel
      return;

   sorban_kiir(gyoker->bal);     // 1
   printf("%d ", gyoker->adat);     // 2
   sorban_kiir(gyoker->jobb);    // 3
}

Leállási feltétel: üres részfához értünk:

  • Vagy az egész fa üres,
  • Vagy az adott részfa üres!

A függvény meghívódik az üres részfákra is!

Megtehetnénk, hogy a rekurzív hívások előtt ellenőrizzük, hogy van-e valami az adott részfában, pl.:

if (gyoker->bal!=NULL)
   sorban_kiir(gyoker->bal);

Ettől azonban rövidebb nem lesz a kód, hiszen a függvény első sorában mindenképpen kell ellenőrizni, hogy a gyökér pointer NULL vagy nem NULL. (Hiszen az egész fa is lehet üres, és az üres fa is fa, amelyre a függvény hívható.)

8 A fa bejárása III. – inorder bejárás fordítva

A megismert algoritmust inorder bejárásnak nevezik, ugyanis a fa elemein növekvő sorrendben hajtja végre a művelet.

Megcserélve a bal és a jobb részfa bejárását, csökkenő a sorrend:

jobb–gyökér–bal
  1. Járjuk be a elem jobb részfát (nagyobb elemek),
  2. Dolgozzuk fel az aktuális elemet,
  3. Járjuk be a bal részfát (kisebb elemek).
 

9 A postorder bejárás – fa felszabadítása

Ha egy teljes fát szeretnénk felszabadítani, vigyázni kell: nehogy magunk alatt vágjuk a fát, azaz nehogy elveszítsük a hozzáférést elemekhez. Felszabadításkor mindig leveleket szabad csak törölni. Olyan bejárásra van szükség, amely először a leveleket járja be, majd azokat az elemeket, amik a korábbi levelek felszabadítása után levéllé válnak és így tovább.

Csak leveleket szabad felszabadítani!

  1. Járjuk be az aktuális elem bal részfáját,
  2. Járjuk be az aktuális elem jobb részfáját,
  3. Szabadítsuk fel az aktuális elemet.
bal–jobb–gyökér

Előbb a részfákat felszabadítjuk, utána mehet az aktuális elem is:

void felszabadit(BinFa *gyoker) {
   if (gyoker==NULL)   // leállási feltétel
      return;

   felszabadit(gyoker->bal);     // 1
   felszabadit(gyoker->jobb);       // 2
   free(gyoker);                 // 3
}

10 A preorder bejárás I.

Tegyük fel, hogy van egy fa a memóriában, amit szeretnénk fájlba menteni, majd onnan visszaállítani!


Ha ezt az inorder bejárással tennénk:

1, 2, 3, 4, …

1, 2, 3, 4, …

A fájlban sorrendben lesznek az elemek, az újra felépített fába rendezetten, a legkisebb elemtől kezdve szúrjuk be az elemeket. Ez azt jelenti, hogy az „1” lesz a gyökér, és minden további elem nagyobb, mint az előző, tehát a fa egy láncolt listává degradálódik. Így a keresés már
nem logaritmikus időben fut!

11 A preorder bejárás II.

gyökér–bal–jobb
  1. Vegyük sorra az aktuális elemet,
  2. Járjuk be az aktuális elem bal részfáját,
  3. Járjuk be az aktuális elem jobb részfáját.
 

A fájl pointerét, amelybe írjuk az adatokat, természetesen át kell adni paraméterként mindenhol:

void fajlba_kiir(BinFa *gyoker, FILE *fajl) {
   if (gyoker==NULL)   // leállási feltétel
      return;

   fprintf(fajl, "%d ", gyoker->adat); // 1
   fajlba_kiir(gyoker->bal, fajl);         // 2
   fajlba_kiir(gyoker->jobb, fajl);    // 3
}

12 Műveletek fákon – általában

Sokféle kérdés feltehető egy fával kapcsolatban:

A fenti feladatokat rekurzív algoritmusokkal lehet könnyen megoldani.


A megoldás sémája

  1. A feladatot megoldjuk a bal részfára (rekurzív hívás)
  2. A feladatot megoldjuk a jobb részfára (rekurzív hívás)
  3. Számbavesszük az aktuális elemet

13 Műveletek – fák elemszáma

Feladat: számoljuk meg, hogy hány eleme van egy fának!

  1. Ha üres a fa (NULL pointert kaptunk), térjünk vissza 0-val!
  2. Különben vegyük az aktuális elem bal részfájának az elemszámát!
  3. Adjuk hozzá a jobb részfa elemszámát!
  4. Adjunk hozzá 1-et (aktuális elem)! Térjünk vissza így.

Akkor is (1) fut le,
ha levél gyerekeire
(üres részfára)
hívjuk meg!
int elemszam(BinFa *gyoker) {
   if (gyoker==NULL) return 0;   // 1

   return elemszam(gyoker->bal)  // 2
        + elemszam(gyoker->jobb)    // 3
        + 1;                     // 4
}

Valójában az történik, hogy a return utáni kifejezésben bejárjuk a fát.

  • Meghívjuk az elemek balgyermekére sorozatban, amíg NULL pointerig nem jutunk (legbaloldalibb gyermek balgyermeke NULL).
    • Ekkor a legutolsó függvény 0-val visszatér.
    • Ehhez hozzáadjuk a legbaloldalibb gyermek jobbgyermekeinek a számát.
    • Ha ez a gyermek egy levél volt, akkor onnan is 0-val térünk vissza, majd ehhez még egyet adunk, tehát megszámoltuk őt.
  • Ezután eggyel feljebb lépünk, hiszen annak az elemnek a balrészfájára elvégeztük a műveletet és 1-et kaptunk.
    • Ekkor annak is megszámláljuk a jobbrészfáját, majd hozzáadunk 1-et és újból feljebb lépünk.
  • Akkor fejeződik be a függvényhívási sorozat, amikor a legjobboldalabbi elemet is megszámláltuk és ezzel visszajutunk a legelső függvényhívásba. Ott hozzáadunk 1-et az eredményhez – ezzel megszámlálva a gyökér elemet –, majd visszatérünk az elemszámmal.

14 Műveletek – levelek száma

Levelek száma: hasonló, de feltételhez kell kötni a számlálást:

  1. Ha üres a fa (NULL pointert kaptunk), térjünk vissza 0-val!
  2. Ha az aktuális elem levél, térjünk vissza 1-gyel!
  3. Különben vegyük az aktuális elem bal részfájában a levelek számát, adjuk hozzá a jobb részfa leveleinek számát, térjünk ezzel vissza!

int levelszam(BinFa *gyoker) {
   if (gyoker==NULL) return 0;          // 1

   if (gyoker->bal==NULL && gyoker->jobb==NULL) // 2
      return 1;

   return levelszam(gyoker->bal)          // 3
        + levelszam(gyoker->jobb);
}

Itt is bejárjuk a fát.

  • Ha egy levélhez jutunk, akkor egyet adunk vissza, hiszen neki már nem lehetnek gyermekei, ahonnan egyéb érték érkezhetne. Ilyenkor függvényhívásra sincs már szükség (hiszen nincsenek részfák, amelyekben számolni kellene bármit is).
  • Ha nem levélen állunk, akkor megszámláljuk a bal részfában a leveleket (rekurzívan meghívjuk a függvényt a bal gyermekre, majd ugyanezt megtesszük a jobb részfában és a kettő összegével térünk vissza.
  • Üres fa, vagy nemlétező gyermek esetén 0-val térünk vissza.

Figyeljük meg, hogy csak akkor nem hívjuk meg a gyermekekre a függvényt, ha levélben vagyunk. Olyankor ha csak az egyik gyermek NULL, meghívjuk rá, tehát ilyenkor is az (1) feltétel fut le.

15 Műveletek – elemek adott szinten

Feladat: adott szinten hány elem van?

  1. Üres fa esetén a visszatérési érték 0.
  2. Ha az átvett szint értéke 0, akkor azt a szintet kell megszámolni: visszatér 1-gyel.
  3. Különben megszámolja a bal és jobb részfában a megfelelő elemeket. Ehhez a szintet eggyel csökkentve hívja magát.

int szint_elemei(BinFa *gyoker, int szint) {
  if (gyoker==NULL) return 0;   // 1
  if (szint==0) return 1;   // 2

  return szint_elemei(gyoker->bal,  szint-1)  // 3
       + szint_elemei(gyoker->jobb, szint-1);
}

A fontos mozzanat itt az, hogy ennek a függvénynek nem csak, hogy van egy paramétere, de azt a paramétert a rekurzióban változtatja is. Hogy hány csúcs van az ötödik szinten, ahhoz azt kell összeadni, hogy hány csúcs van a bal- és a jobboldali részfában a negyedik szinten. Az ő gyökerükhöz képest negyedik szinten!

16 Keresőfa építése

Rekurzívan könnyű az új elem hozzáadása a keresőfához:


Csakhogy közben változhat a fa gyökere pointer!

BinFa *gyoker = NULL;

gyoker = beszur(gyoker, 5);
gyoker = beszur(gyoker, 2);
gyoker = beszur(gyoker, 7);

Megoldás: térjünk vissza vele, mint a listáknál.

BinFa *beszur(BinFa *gyoker, int adat) {
    if (gyoker==NULL) {                          // üres?
        BinFa *uj = (BinFa*) malloc(sizeof(BinFa));
        uj->bal = uj->jobb = NULL;    /* levél lesz */
        uj->adat = adat;
        return uj;     /* vissza kell térni vele! */
    }
    
    if (adat < gyoker->adat)                // kisebb?
        gyoker->bal = beszur(gyoker->bal, adat);
    else if (adat > gyoker->adat)                // nagyobb?
        gyoker->jobb = beszur(gyoker->jobb, adat);
    else
        ; /* benne van */

    return gyoker;
}

Fontos végiggondolni, mi történik az egyes mutatókkal. Tegyük fel, hogy a függvényt a következő formában hívták meg:

gyoker = beszur(gyoker, 5);
  • Ha a fa üres, akkor gyoker=NULL. Ilyenkor az 1-es feltétel igaz lesz, és keletkezik egy új elem. A paraméterként kapott gyökér pointert, amely egy lokális változó, felülírjuk az új elem címével. Végülis pedig majd visszatérünk ezzel, és az eredeti gyökér mutatót a hívás után az értékadás fogja átállítani.
  • Ha a fa nem üres, akkor a gyökerében van egy elem. Ennek értékétől függ, hogy az új elemet a bal- vagy a jobboldali részfába kell szúrni. Ha a gyökérnél kisebb a beszúrandó, oda kell kerülnie (2).
  • Ha a jobb oldali részfába kerül az elem, akkor ugyanez a helyzet.
  • Ha se nem kisebb, se nem nagyobb, akkor a gyökérelemben azt látjuk, amit amúgy is be kell szúrni. Ilyenkor simán visszatérünk, nincs teendő, hiszen az elem már szerepel a fában. A gyökér pointer változatlan.

Fontos mozzanat az utolsó: hogy visszatérünk a változatlan gyökér pointerrel. A hívó ugyanis az értékadást mindenképpen elvégzi, és ilyenkor azt kell biztosítani, hogy a teljes fa gyökér pointere ne változzon – ehhez pedig egyszerűen visszaadjuk ugyanazt a gyökér pointert, amit kaptunk.

Ha a gyökér létezik, és annak a bal oldali részfájába szúrunk be, akkor hasonlóan megy minden. Ha ott NULL pointer van, akkor az felülíródik. Ha nem NULL, akkor az ott meghívott függvény változatlan gyoker pointerrel tér vissza, azaz az értékadásból gyoker->bal=gyoker->bal lesz végül. Minden helyen ez lesz a helyzet, kivétel ott, ahol valamelyik részfa üres fa!

Ebből következik a függvény hívásának módja a függvény belsejében is. Ha azt mondtuk, hogy a függvényt így kell használni:

gyoker = beszur(gyoker, 5);

Akkor a bal oldali részfába beszúrásnál ezt kell írnunk:

gyoker->bal = beszur(gyoker->bal, 5);

17 Példa – szavak statisztikája

kutya 2
cica 6
mérési 4
hiba 1

Feladat: készítsünk beolvasott szavakról statisztikát! Melyik, hányszor szerepel?


Letölthető:
szostat.c

Megoldás: sorban haladunk a fájl szavain; ha az aktuális szó még nincs benne a fában, akkor betesszük és a darabszámot 1-re állítjuk; Ha már benne van, akkor megnöveljük a darabszámot.

A fák ideálisak erre a feladatra, hiszen gyors a beszúrás és az „eleme-e” művelet. Bónusz: ábécé rendben kapjuk meg az eredményt. A feladathoz szükséges adatstruktúra:

typedef struct SzoStat {
   char szo[51];
   int db;
   struct SzoStat *bal, *jobb;
} SzoStat;

A szabványos bemeneten érkező szöveg statisztikájának az elkészítése:

Kihasználjuk, hogy
a scanf %s
whitespace-ig olvas
int main() {
   SzoStat *fa = NULL; // üres fa
   char szo[51];

   while (scanf("%s", szo) == 1)
      fa = beszur(fa, szo);

   kiir(fa);
   felszabadit(fa);

   return 0;
}

A szükséges módosítás a beszúró algoritmuson: le kell kezelni azt az esetet, amikor az elem már benne van a fában, és strcmp()-vel kell végezni a sztringek összehasonlítását.

SzoStat *beszur(SzoStat *gyoker, char *szo) {
    int er;

    if (gyoker==NULL) {
        SzoStat *uj = (SzoStat*) malloc(sizeof(SzoStat));
        strcpy(uj->szo, szo);
        uj->db = 1;
        uj->bal = uj->jobb = NULL;
        return uj;     /* vissza kell térni vele! */
    }
    er = strcmp(szo, gyoker->szo);
    if (er < 0)
        gyoker->bal = beszur(gyoker->bal, szo);
    else if (er > 0)
        gyoker->jobb = beszur(gyoker->jobb, szo);
    else
        gyoker->db++;

    return gyoker;
}

18 Fák alkalmazásai – hierarchia

Hierarchia tárolása.
Műveletek, pl. (2+3)*5.
Nincs szükség zárójelezésre!

Dekódoló fa.
Morze: ti = balra, tá = jobbra.

19 Fák alkalmazásai – további alakok

struct TriFa {
   …
   struct TriFa *bal,
                *kozep,
                *jobb;
};

háromágú fa

Az elvek ugyanazok, mint a bináris fánál. Pl. elemek száma:

  1. Ha NULL pointer, akkor 0.
  2. Egyébként be kell járni az összes részfát.
  3. Az elemek száma azok elemszámának összege + 1.

struct BinFa {
   int adat;

   struct BinFa *szulo;
   struct BinFa *bal, *jobb;
};

szülőkre mutató pointerrel

Így egyszerűbb:

  • Iteratív bejárás „jobbra tapogatózva”
  • Törlés

Érdemes megfigyelni: nyelvileg nem különbözik a két struktúra egymástól. Csak máshogyan használjuk a pointereket!

A bináris fából törlés sem triviális művelet – főleg, ha a törlendő csúcsnak bal- és jobboldali szomszédja is van. Ilyenkor a fát át kell rendezni, ha a keresőfa tulajdonságot meg szeretnénk tartani. Ezzel is az Algoritmuselmélet tárgy fog foglalkozni.

20 Fák alkalmazásai – hatékonyság

Kiegyensúlyozott fa: bármely csúcspont részfáinak magassága közötti különbség legfeljebb egy. (A bal oldali nem ilyen.)

B-fák, AVL-fák, …
„Algoritmuselmélet” tárgy

Ha a fa nem kiegyensúlyozott, akkor a keresés lassabb.

Az ebben az előadásban bemutatott faépítő algoritmusok nem kiegyensúlyozott fát építenek. Az általuk épített fa kiegyensúlyozottsága attól függ, mennyire érkeznek szerencsésen a beszúrandó adatok. A kiegyensúlyozott építéshez összetettebb algoritmusok szükségesek – ezeket majd az Algoritmuselmélet nevű tárgy fogja bemutatni.

21 A tanult adatszerkezetek


Eddig az alábbi adatszerkezetekkel ismerkedtünk meg:

  • tömbök,
  • láncolt listák,
  • bináris fák.

Mindnek megvannak az előnyei és a hátrányai. Alaposan ismerni kell a felépítésüket, működésüket, algoritmusaikat ahhoz, hogy képesek legyünk eldönteni, egy adott feladathoz melyik illik a legjobban.

 ElérésKeresésBeszúrásTörlés
Tömb1nnn
Listann11
Falog nlog nlog nlog n

Tömb

Előnye:

  • gyors, tetszőleges sorrendű adatelérés (a leggyorsabb),
  • csak annyi helyet foglalnak el a memóriában, amennyi a hasznos adat,
  • hatékony rendező algoritmusok léteznek tömbökre.

Hátránya:

  • az átméretezésük költséges – gyakran változó mennyiségű adathoz nem alkalmasak,
  • a feltöltés közbeni rendezésük költséges.

Mikor használjuk: ha az adatok száma keveset változik és kritikus a rendkívül gyors adatelérés. A rendezett tömbök esetén lehetséges bináris keresés (O(log n) lépésszámmal), de a rendezés költséges művelet.

Lista

Előnye:

  • egyszerű bővíthetőség (rendezetlennél a leggyorsabb),
  • egyszerű törlés (nem kell mozgatni az elemeket),
  • egyszerű (bár nem a leghatékonyabb) rendezve építés.

Hátránya:

  • az elemek csak lineárisan kereshetőek.

Mikor használjuk: ha az adatok száma gyakran változik és a keresés nem időkritikus (pl. ha mindig a feltöltés (fordított) sorrendjében van szükség az elemekre: vermek, sorok).

Fa

Előnye:

  • egyszerű bővíthetőség (a rendezettnél a leggyorsabb),
  • egyszerű és hatékony rendezve építés,
  • nagyon gyors keresés (kb. a tömbök sebességével megegyező).

Hátránya:

  • egy elem törlése bonyolult feladat (át kell rendezni a fát).

Mikor használjuk: ha az adatok száma gyakran változik és a fontos rendezettség, illetve a gyors keresés. Előnyös halmazok, asszociatív tömbök megvalósítására.

Kettős indirekcó

23 Indirekció – cím szerinti paraméterátadás

C-ben csak érték szerinti paraméterátadás van. A függvények a paraméterek másolatát kapják.

A cím szerinti paraméterátadás mégis megoldható pointerrel:


void novel(int *pi) {
    (*pi)++; // a mutatott integert
}

int x=5;

novel(&x);  // x címe
printf("%d", x);

A listás, fás algoritmusaink megváltoztatják a lista eleje, fa gyökere mutatót. Ötlet: azt ugyanígy kellene átadni!

Ez azért lehetséges, mert a pointer ugyanolyan változó, mnint a többi. Vegyük példának a lenti függvényt. Ez két VALAMI-t cserél meg. Hogy a cseréket el tudja végezni, a függvény nem érték szerint várja a paramétereit (azaz a változók másolatát), hanem cím szerint (pointereket az eredeti változókra). A kódban VALAMI helyére bármilyen típust beírhatunk: kapunk egy függvényt, amely két adott típusú dolgot kell megcserélni.

p1: mutató egy VALAMI-re
*p1: egy VALAMI
void csere(VALAMI *p1, VALAMI *p2) {
   VALAMI temp=*p1;
   *p1=*p2;
   *p2=temp;
}

Ha két int-et szeretnénk cserélni, akkor a VALAMI helyre int-et írunk. Ha két int*-ot, akkor a VALAMI helyére int* kerül.

void int_csere(int *p1, int *p2) {
   int temp=*p1;
   *p1=*p2;
   *p2=temp;
}

int x=29; int y=17;
int_csere(&x, &y); /* x=17, y=29 */
void ptr_csere(int **p1, int **p2) {
   int *temp=*p1;
   *p1=*p2;
   *p2=temp;
}

int *px=&x; int *py=&y;
ptr_csere(&px, &py); /* px=&y, py=&x */

24 Kettős indirekció: lista építése

A lista elejére beszúró függvény a múltkori módon:

ListaElem *elore_beszur(ListaElem *eleje, int adat) {
   ListaElem *uj = (ListaElem*) malloc(sizeof(ListaElem));
   uj->adat = adat;
   uj->kovetkezo = eleje;
   return uj;
}

A függvénynek meg kell tudnia változtatni a lista eleje mutatót:



A másik változata, ami az „eleje” pointer címét veszi át:

void elore_beszur_ptr(ListaElem **peleje, int adat) { // !
   ListaElem *uj = (ListaElem*) malloc(sizeof(ListaElem));
   uj->adat = adat;
   uj->kovetkezo = *peleje; // *peleje – az első elem címe
   *peleje = uj;
}

Kettős indirekció. A függvény belsejében:

peleje
Annak a pointernek a címe, amely a lista elejét tárolja. Ez képződik a hívás helyén, amikor ott azt írjuk, hogy &eleje. Ez a main() lokális váltózójának címe.
*peleje
A lista elejének (első elemének, vagyis az első struktúrának) címe. Ez a hívó változójának értéke.
**peleje
Ez lenne a lista első eleme maga (a struktúra), de most nem használjuk.

25 Kettős indirekció: lista építése – használat

A múltkorinál a visszaadott mutatót be kellett másolni a változóba:

ListaElem *eleje=NULL;

eleje=elore_beszur(eleje, 2);
eleje=elore_beszur(eleje, 3);

A mostani függvény cím szerint kapja azt, ezért meg tudja tenni azt is:

Sokkal jobb
megoldás!
ListaElem *eleje=NULL;

elore_beszur_ptr(&eleje, 2);
elore_beszur_ptr(&eleje, 3);

A nagy előny: így nem lehet kifelejteni az értékadást!

Figyeljük meg a használatok közötti különbséget:

visszaadott elejecímként átadott eleje
előnyegyszerűnem lehet elfelejteni a megváltozó eleje pointert (mivel szól a fordító*, hogy rossz az átadott paraméter típusa, ListaElem** helyett ListaElem*)
hátránya visszaadott pointer elfelejthetőkényelmetlen, mindenhova pluszba kell az & operátor

* A rendes fordítók szólnak.

26 Kettős indirekció – lista végéhez fűzés

Beszúrás lista végére, és az „eleje” mutató címének átvétele:

void vegere(ListaElem **peleje, int adat) {
   ListaElem *uj=(ListaElem*) malloc(sizeof(ListaElem));
   uj->adat=adat;
   uj->kov=NULL;

   if (*peleje==NULL)
      *peleje=uj;     // eleje ptr változik
   else {
      ListaElem *mozog;
      for (mozog=*peleje; mozog->kov!=NULL; mozog=mozog->kov)
         ; /* üres ciklus */
      mozog->kov=uj;  // az utolsó „következő”-je változik
   }
}

Vegyük észre: van valahol egy ListaElem* mutató, amit be kell állítani. Vagy az utolsó elemben, vagy kívül, a hívónál!

27 Kettős indirekció – lista végéhez fűzés 2.0

void vegere(ListaElem **peleje, int adat) {
   ListaElem **pmozgo, *uj;

   pmozgo=peleje;        // pointer megkeresése
   while (*pmozgo!=NULL)
      pmozgo=&(*pmozgo)->kov; // !

   uj=(ListaElem*) malloc(sizeof(ListaElem)); // új elem
   uj->adat=adat;
   uj->kov=NULL;
   *pmozgo=uj;           // a megtalált NULL pointer felülírása
}

Egy lehetséges módosított algoritmushoz az előző felismerés adja az ötletet – nevezetesen az, hogy a lista végére szúrás azt jelenti, hogy meg kell keresni a lista végén azt a NULL pointert, amelyik lezárja azt. Mindegy, hogy ez a lista végi elemben van, vagy a lista eleje pointer a NULL (üres lista esetén) – a feladat az, hogy felülírjuk azt a mutatót az új elem címével.

A függvény elején a ciklus ezért ezt teszi: megkeresi azt a NULL pointert. A pmozgo pointer ennek a pointernek a címét tárolja. Először a lista eleje pointerre mutat, utána pedig minden lépésben a mutatott listaelem következő pointerére állítjuk át. Fontos az indirekciók számában a különbség: a pmozgo pointer itt nem a listaelemre mutató pointer, hanem a listaelemre mutató pointerre mutató pointer. Vagyis a listaelem címét tároló változó címe. Így először a hívó eleje pointerének címét tárolja, utána az első listaelem kov pointerének címét stb. Ezért van szükség a címképző operátorra is a ciklustörzsben: *pmozgo a listaelemre mutató pointer, (*pmozgo)->kov az abban tárolt „következő” pointer, &(*pmozgo)->kov pedig annak a címe.

28 Kettős indirekció – keresőfa építése

A keresőfába beszúráshoz meg kell keresni azt, hogy hol kell legyen a beszúrandó elemre mutató pointer. Ha a fa üres lenne, akkor a legfelső, azaz a gyökérpointert kellene úgy módosítani, hogy az az új elemre mutasson. Ha a 0-t szeretnénk beszúrni, akkor az 1-es csomópont bal pointerét (amely jelenleg NULL pointer) kell úgy módosítani, hogy az az új elemre mutasson. Ha a 4-est keressük, az benne van a fában – a 3-as csomópont jobb pointere az, amely rá mutat, és ilyenkor a beszúráshoz nem kell tenni semmit.

Azt keressük, hogy hol az a pointer, amely majd mutat rá.
Ez valamelyik csomópont bal/jobb pointere, vagy a gyökér pointere.

Ha van egy ilyen keresőalgoritmusunk. samely nem a keresett elemre mutató pointert adja vissza, hanem a keresett elemre mutató pointer címét, akkor könnyű a beszúrás. Ha nincs meg a keresett elem, ez akkor is értékes választ ad: a visszaadott pointer egy olyan pointerre mutat, amely értéke NULL, de ott kellene legyen a keresett elemre mutató pointer. A beszúrás:

/* új csomópontot szúr a keresőfába.
 * A gyökerpointert cím szerint veszi át. */
void beszur(BinFa **pgyoker, int adat) {
  /* megkeresi a helyét */
  BinFa **ptr=beszurashoz_keres(pgyoker, adat);

  if (*ptr==NULL) {                   // ha még nincs
     BinFa *uj=(BinFa*) malloc(sizeof(BinFa));
     uj->adat=adat;

     uj->bal=uj->jobb=NULL;
     *ptr=uj;                         // beszúrás
  }
}

A *ptr=uj; kifejezés

  • üres fa esetén a függvényen kívüli, a fa gyökerére mutató pointert változtatja meg,
  • nem üres fában valamelyik csomópont bal vagy jobb mutatóját változtatja meg, amely eddig NULL értékű volt, és ahová új levélként bekerül a beszúrandó elem.

Fontos, hogy ez a beszúró függvény cím szerint vegye át a gyökér címét is: BinFa **pgyoker. Ha egy teljesen üres fába szúrunk be elemet, akkor az meg kell változzon:

BinFa *gyoker = NULL;
beszur(&gyoker, 5);

Ugyanezért kell a kereső algoritmusnak is cím szerint átadni a pointert: ha a fa üres, a kereső algoritmus a gyökérelem pointer címével kell visszatérjen (annak címével amely egyelőre még NULL, és az ebben a példában az egész, üres fa gyökere).

A kettős indirekció a keresésben:

/* Megkeresi az adott elem (leendő) helyét a fában.
 * Egy olyan címet ad vissza, ahol a leendő elem címének
 * kell tárolódnia, vagy ahol a megtalált elem címe tárolódik. */
BinFa **beszurashoz_keres(BinFa **pgyoker, int adat) {
   BinFa **pmozgo=pgyoker;

   while (*pmozgo!=NULL && (*pmozgo)->adat!=adat) {
      if (adat < (*pmozgo)->adat)
         pmozgo=&(*pmozgo)->bal;
      else
         pmozgo=&(*pmozgo)->jobb;
   }

   return pmozgo;
}

Mivel mutatók címével tér vissza, azok értékét felül tudjuk írni.

Emlékeztetőül itt a kétszeres indirekció nélküli keresés. Érdemes összehasonlítani – lényegében annyi a különbség, hogy egyikben mindenhol mozgo van, másikban mindenhol *pmozgo. Ahol a lentiben konkrét érték van (mozgo), ott a fentiben cím, amelyet dereferálni kell (*pmozgo). Ahova a lentiben konkrét érték kerül (mozgo=), oda a fentiben cím kell (pmozgo=), ezért címet kell képezni (pmozgo=&...).

BinFa *keres(BinFa *gyoker, int adat) {
   BinFa *mozgo=gyoker;

   while (mozgo!=NULL && mozgo->adat!=adat) {
      if (adat < mozgo->adat)
         mozgo=mozgo->bal;
      else
         mozgo=mozgo->jobb;
   }

   return mozgo;
}

Hash táblák

30 Keresés O(log n)-nél gyorsabban

Elem megtalálása

Vajon lehet ennél jobbat?


O(1) lépésben

Hash-tábla:
„Algoritmuselmélet” tárgy

Honnan tudjuk, hogy hol keressük?
Onnan, hogy tudjuk, hova raktuk!

31 A hasító táblázatok röviden

Kulcs → érték párok tárolása

  • Viszonylag nagy tömböt foglalunk
  • hash() függvény: kulcs→tömbindex (to hash = összekever, összezagyvál)

Jó hasító függvény?

  • gyors,
  • egyenletesen helyezi el az adatokat

A használat elve:

Adat hash_tabla[MERET]; // a hash tábla: tömb

printf("Telefon: %s\n", hash_tabla[hash("Taz")].telszam);

32 A hasító függvény

A kulcsot le kell képezni a 0 … MÉRET-1 tartományra. Példa hash függvények (nem túl jók):

int egesz_hash(int i) {
   return i % MERET;
}
int sztring_hash(char *nev) {
   return ((nev[0]-'A')*26+(nev[1]-'a')) % MERET;
}

Ütközések: ha két kulcs ugyanoda „hashelődik”:

kulcs % MÉRET == (kulcs + MÉRET) % MÉRET

Pl. a fenti függvénnyel Taz és Tapsi Hapsi.

33 Ütközések kezelése – egymás után téve

Az ütköző adatokat egymás után helyezzük el a táblában.

34 Ütközések kezelése – láncolt listával

Az ütköző elemeket láncolt listába tesszük:

Kevés ütköző elem → néhány elem a listákban. Gyors keresés!

typedef struct ListaElem {
    char nev[51];
    char telefonszam[20];
    struct ListaElem *kovetkezo;
} ListaElem;
ListaElem *hashtabla[MERET];  /* listák tömbje */

char keresett[]="Tapsi Hapsi";
index=hash(keresett);
talalt=listakeres(hashtabla[index], keresett);
if (talalt!=NULL)
    printf("Telefonszáma: %s\n", talalt->telefonszam);
else
    printf("Nincs ilyen név!\n");

35 Hash tábla – további felhasználások

Google keresés?


Fájlcserélő: elosztott tábla