A számítógép-tudományban a hash-tábla olyan adatszerkezet, amely hasítófüggvény segítségével állapítja meg, hogy melyik kulcshoz milyen érték tartozik – így implementál egy asszociatív tömböt. A hasítófüggvény segítségével a kulcsot leképezzük az adatokat tároló tömb egy adott indexére, ahol a keresett érték fellelhető.

Hash-tábla
TípusTömb (adatszerkezet)
Komplexitás (O jelöléssel)
TárigényO(n)
BeszúrásO(1)
KeresésO(n)
TörlésO(n)

Ideális esetben minden értelmezett kulcsra egyedi hash-t állít elő a hasítófüggvény, de ez a gyakorlatban ritkán valósítható meg – így számolnunk kell azzal, hogy két különböző kulcsra ugyanazt a hash-t kapjuk, és kezelnünk kell az ilyenkor fellépő hash-ütközést.

Sok esetben a hash-táblák teljesítménye számottevően jobb, mint a keresőfáké vagy egyéb táblás szerkezeteké, ezért széles körben használják asszociatív tömbök implementációjában, adatbázisok indexelésében, illetve a cache-memória felépítésében.

A hash-függvény

szerkesztés

A tábla algoritmusának középpontjában egy tömb áll, amelyet a hash-táblának hívunk. A táblát egy függvény segítségével indexeljük.

index = f(kulcs, méret)

Az f függvény előállít egy indexet a [0, méret] tartományon, amelyet arra használunk, hogy a tömböt indexeljük.

Egy jó hash-függvény elengedhetetlen a hatékony implementációhoz. Alapvető követelmény a hash-függvénnyel szemben, hogy a lehetséges kulcsokra egyenletes eloszlású hasheket képezzen. Amennyiben nem egyenletes az eloszlás, az ütközések száma megnő, melyeknek a kezelése költséges. Nyílt címzésű hashelés alkalmazása esetén a függvényt úgy érdemes megszerkeszteni, hogy elkerüljük a tömörüléseket. A tömörülések a nyílt címzés tulajdonságaiból adódóan akár megtöbbszörözhetik a keresési időket. A kriptográfiában alkalmazott hashelő függvények gyakran egyenletes eloszlású hasheket állítanak elő tetszőleges méretű hash-táblához, de ezek ritkán alkalmasak egyszerű hash-táblák indexelésére a hashek előállításának költségéből adódóan.

Hash-ütközések

szerkesztés

A gyakorlatban szinte elkerülhetetlen a hash-ütközés jelensége – a születésnap-paradoxon alapján egy hash-táblába már a mérethez viszonyítva alacsony számú beszúrás után magas a valószínűsége az ütközések előfordulásának. A paradoxon példáját felhasználva amennyiben egy 365 méretű hash-táblában az embereket a születésnapjuk alapján hasheljük, akkor már 23 beszúrás esetén 50%-os valószínűséggel jelentkezik hash-ütközés.

A hash-ütközéseket kezelő algoritmusok teljesítménye általában nem a táblában fellelhető elemek számától, hanem az összes rendelkezésre álló hely és az elemek által elfoglalt hely arányától függ, azaz a telítettségi tényezőtől. A telítettségi tényező adott esetben akár a 100%-ot is átlépheti, például láncolásos megoldás esetén.

A továbbiakban felsoroljuk a leggyakoribb módszereket a hash-ütközések kezelésére.

Láncolás

szerkesztés

Láncolás alkalmazása esetén a hash-tábla egyes indexein nem egy elemet tárolunk, hanem azon kulcsok és értékek láncolt listáját, amelyeket a hash-függvény oda képzett le. Ekkor a táblában való kereséskor a hash-indexén levő listát kell bejárnunk a keresett adat kinyeréséhez. Beszúráskor a lista végéhez hozzáfűzzük az új értéket. Törléskor a megfelelő listaelemet távolítjuk el a láncolt listából.

A táblában végzett műveletek költsége ekkor a megfelelő indexen levő elemek bejárása. Így egy megfelelően egyenletes eloszlású hashelés esetén a műveletek költsége kizárólag a telítettségi tényező függvénye. A láncolás során a teljesítmény lineárisan csökken a telítettségi tényező növekedésével, így hatékonyabb egy azonos elemszámú láncolt listánál, vagy akár egy kiegyensúlyozott keresőfánál is.

A láncolás alkalmazásánál legrosszabb esetben az összes beszúrt elem ugyanarra az indexre kerül, ekkor minden művelet O(n) lépést igényel.

A láncolást teljesen véletlenszerű hozzáférés esetén gyakran kulcs szerint rendezett listákkal implementáljuk, ekkor felére csökken az átlagos keresési idő, hiszen amennyiben egyértelműen tudjuk rendezni a kulcsok halmazát, reprezentatív, véletlenszerű mintavételezés során átlagosan az elemek 50%-a nagyobb és 50%-a kisebb, mint a kiválasztott elem, azaz átlagosan elegendő a lista felét bejárnunk ahhoz, hogy megtaláljuk az elemet (vagy hogy megállapítsuk, hogy nincs a táblában). Amennyiben viszont egyes kulcsok gyakrabban fordulnak elő, mint mások, ideálisabb a listákat a hozzáférések száma alapján rendezni, azaz egy előremozgató becslést alkalmazni.

Hátrányai

szerkesztés

A láncolással implementált hash-táblák a láncolt listák hátrányait is öröklik: a tárigény jelentősen megnő a listákban tárolandó O(n) mutató mérete miatt. A láncolt listáknak a cache teljesítménye is gyenge más adatszerkezetekhez képest, hiszen az egyes "láncszemek" tetszőleges helyen helyezkedhetnek el a memóriában, így egy k elemű listához O(k) lap betöltésére lehet szükség.

Egyes implementációkban a hash-táblában nem egy mutatót tárolunk a megfelelő láncolt listára, hanem annak a láncolt listának az első elemét, ezzel növelve a cache teljesítményt, illetve csökkentve a tárigényt.

Láncolás egyéb adatszerkezetekkel

szerkesztés

Az egyes indexeken tárolt adatszerkezet tetszőlegesen megválasztható, amennyiben az támogatja a megfelelő műveletek. Például alkalmazhatunk egy önkiegyensúlyozó bináris keresőfát is, mellyel a tárigény nő, ellenben az egyes műveletek lépésszáma legrosszabb esetben O(n) helyett O(log n). Alkalmazhatunk dinamikus tömböket is, melyben a beszúrás lépésszáma amortizált O(1), a keresés és törlés pedig O(n). A dinamikus tömbökkel való megoldásnak lényegesen jobb a cache teljesítménye, mint a láncolt listás megoldásnak, de az optimális beszúrási teljesítmény eléréséhez a tárigénye is nagyobb.

Nyílt címzés

szerkesztés

A nyílt címzésű hash-táblákban a láncolással ellentétben egy indexen kizárólag egy értéket tárolunk, azaz a táblában tárolt elemek száma nem haladhatja meg az indexek (egyedi hashek) számát. Beszúráskor a megadott módszerrel a kapott hashtől indulva megkeressük az első szabad indexet, ahova az elem bekerül. Kereséskor ugyanígy járjuk be az egyes indexeket, ekkor O(n) lépésben megállapíthatjuk, hogy a megadott kulcs szerepel-e a táblában. Törléskor az elem indexét nem üresnek, hanem töröltnek jelöljük, így azok a kereső algoritmusok, amelyek ellenőrzik ezt az indexet se adnak hibás eredményt, viszont egy olyan táblában, ahol gyakoriak a beszúrások és törlések a keresés teljesítménye gyorsabban romlik. Egy kulcs helyét az úgynevezett próbálás során keressük meg. Ennek több, ismert módszere létezik.

Lineáris próbálás

szerkesztés

Lineáris próbálás esetén két kipróbált hely között a távolság konstans méretű, általában 1.

Általánosítva, amennyiben adott egy H(x,n) hashelő függvény, a H(x,i,n) lineáris próbafüggvény:

 

ahol x a kulcs, i a próbálás távolsága és n a tábla mérete.

Nem egyenletes eloszlású hashelés esetén a lineáris próbálás során a beszúrt értékek a hash-tábla egyes tartományain csoportosulnak, azaz gyakran egymás utáni indexeken fognak elhelyezkedni a beszúrt elemek.

Kvadratikus próbálás

szerkesztés

Kvadratikus próbálás során a két kipróbált hely között a távolságot egy másodfokú polinom adja.

Amennyiben adott a H(x,n) hashelő függvény, az i. próbálás helyét adja a következő H(x,i,n) kvadratikus próbafüggvény:

 

ahol x a kulcs és n a tábla mérete.   és   a függvényre jellemző konstansok, amelyeknek az ideális értéke a tábla méretétől függ és egy adott táblára nem változik az értékük. Az együtthatók értéke akkor ideális, ha az összes i < n-re különböző indexet ad a függvény. Tetszőleges táblaméretre nehéz olyan kvadratikus próbát előállítani, amely garantálja, hogy minden beszúrás sikeres, amennyiben a telítettségi tényező meghaladja az 50%-ot. Ha  , az algoritmus egy lineáris próbálássá degradálódik.

Kettős hashelés

szerkesztés

A kettős hashelés során a lépésköz a bemenő adat függvénye, ezzel csökkentve a csoportulások kialakulásának lehetőségét.

Amennyiben adott a H(x,n) hashelő függvény, az i. próbálás helyét adja a következő függvény:

 

ahol x a kulcs, n a tábla mérete és a H'(x,n) a másodlagos hashelő függvény. Fontos, hogy úgy válasszuk meg a másodlagos függvényt, hogy az semmilyen kulcsra ne adjon 0 értéket, hiszen ekkor a lépésköz minden i-re 0, azaz a táblának csupán egyetlen helyére próbáljuk meg beszúrni az elemet.

Dinamikus méretezés

szerkesztés

A hash-táblák teljesítménye arányosan csökken a telítettségi tényező növekedésével. A telítettségi tényezőt fix méretű táblában nem tudjuk csökkenteni anélkül, hogy elemeket távolítanánk el belőle, így az egyetlen fennmaradó lehetőség a tábla méretének növelése. A tábla növelésével az egyes elemeknek az újrahashelésére van szükség, mivel azoknak a hash-e eltérhet az átméretezett tábla függvénye alapján.

Ha az átméretezés során a tábla mindig konstansszorosára nő, akkor az egyes táblaműveletek amortizált lépésszáma változatlan marad, illetve az átméretezés amortizált lépésszáma is O(1).

  NODES