Egy konfigurációs kulcs–érték tárolóban a gyors keresés csak akkor hasznos, ha közben egyértelmű marad, ki birtokolja a kulcsokat, mikor szűnnek meg a visszaadott pointerek érvényes hozzáférést biztosítani, és milyen állapot marad allokációs hiba után. A hash table ezért nem pusztán algoritmus: tárolási, élettartam- és hibakezelési szerződés is.
A dinamikus tömbnél megismert kapacitáskezelésre, a listák csomópont-élettartamára és a callbackek context-szerződésére építünk. Az alábbi modell ISO C17-et használ, egyetlen szálból, konkurens hozzáférés nélkül.
Legyen a kulcs hashértéke, pedig az egyenlőségi predikátum. A helyes működés szükséges feltétele:
A fordított irány nem követelmény: különböző kulcsoknak lehet azonos hashértéke. Ez ütközés, angolul collision; az azonosságot végül az egyenlőségi vizsgálat dönti el.
Az egyenlőségnek ekvivalenciarelációnak kell lennie: reflexívnek, szimmetrikusnak és tranzitívnak. A tárolt kulcs hashértéke és egyenlőségi osztálya nem változhat meg a táblabeli tagság alatt.
Konfigurációs tárolónkban a kulcs és az érték nullterminált
karakterlánc, beágyazott nullkarakter nélkül. Az alapváltozat kis- és
nagybetűérzékeny: az egyenlőség feltétele
strcmp(a, b) == 0. Ez ISO C17 könyvtári
viselkedésre épülő, választott API-szerződés, nem minden
konfigurációs formátum kötelező szemantikája.
A comparator gyakran háromirányú összehasonlító függvényt jelent: negatív, nulla vagy pozitív eredménnyel. Egy logikai equality callback viszont igaz értéket ad egyenlőségkor. A kettő közvetlen felcserélése megfordíthatja a jelentést.
Ha például az equality figyelmen kívül hagyja az ASCII-kisbetű/nagybetű különbséget, a hashnek is ugyanilyen normalizálással kell dolgoznia. Nem elegendő csak az összehasonlítást módosítani.
Callbackes API-nál a callback contextjének tulajdonosa a hívó; a contextnek minden használatkor élnie kell. A benne tárolt hashmag vagy összehasonlítási szabály megváltoztatása újraépítés nélkül megsérti az invariánst.
Nyílt címzésnél (open addressing) a bejegyzések közvetlenül egy slottömbben helyezkednek el. Ütközéskor egy keresési sorozat, a probe sequence további slotokat vizsgál. Lineáris próbálásnál a kezdőhely:
ahol a slotok száma; ezután növekvő indexek következnek, a tömb végén körbefordulással.
Láncolásnál (separate chaining) a hash egy bucketet választ, amely például külön allokált csomópontok listáját tartja.
| Tulajdonság | Nyílt címzés | Külön allokált csomópontos láncolás |
|---|---|---|
| Tároló | Folytonos slottömb | Buckettömb és csomópontok |
| Locality | Jellemzően jó a slotok között | Pointerkövetés, szétszórt csomópontok |
| Allokáció | Tömbnöveléskor; a payload külön is allokálható | Tipikusan beszúrásonként csomópont |
| Rehash | Bejegyzések más slotokba kerülnek | A csomópontok újraláncolhatók |
| Címstabilitás | Slotpointer nem stabil rehashkor | Csomópontcím megtartható |
| Törlés | Tombstone vagy külön átrendezési algoritmus | Csomópont eltávolítása |
Ezek implementációs következmények, nem univerzális garanciák. A láncolás sem tesz minden iterátort stabillá: a buckethez vagy következő kapcsolathoz kötött bejárási állapot megváltozhat.
Nyílt címzésnél három slotállapotot használunk:
EMPTY: a jelenlegi tábla felépítése óta nem
foglalt;LIVE: élő bejegyzést tartalmaz;DEAD: korábban foglalt, törölt hely, azaz
tombstone.Legyen az élő, a törölt slotok száma. Két arány fontos:
Az a szokásos terhelési tényező (load factor). A keresést azonban a tombstone-ok is hosszabbítják, ezért az effektív foglaltságot jelző sem hagyható figyelmen kívül.
Legyen
,
és egy szemléltető hashfüggvény az env, log,
port kulcsokhoz egyaránt a 2-es kezdőhelyet adja. Beszúrás
után:
| Index | 2 | 3 | 4 | 5 |
|---|---|---|---|---|
| Állapot | LIVE |
LIVE |
LIVE |
EMPTY |
| Kulcs | env |
log |
port |
— |
A log törlésekor a 3-as slot DEAD lesz. A
port keresése megvizsgálja a 2-es slotot, áthalad a 3-ason,
majd megtalálja a 4-est. Ha törléskor EMPTY állapotot
írnánk, a keresés idő előtt „nincs ilyen kulcs” eredménnyel állna
meg.
Beszúráskor megjegyezhetjük az első tombstone helyét, de tovább kell
keresnünk egy egyező kulcs vagy egy EMPTY slot eléréséig.
Különben már létező kulcsból készíthetnénk második példányt. Minden
keresési ciklus legfeljebb
slotot vizsgáljon: ez sérült kapacitáspolitika esetén is kizárja a
végtelen körbeforgást.
A példabeli politika az élő elemszámot legfeljebb a kapacitás felére engedi. Sok tombstone esetén azonos kapacitású újraépítés, sok élő elem esetén növelés szükséges. Az újraépítés eltünteti a tombstone-okat.
Növelés előtt két külön határt ellenőrzünk:
Az első a duplázást, a második az allokáció méretszorzását védi. A
méret egysége C bájt, nem feltétlenül nyolcbites
oktett. Beszúrásnál az n + 1 kiszámítása helyett például az
n < m / 2 feltétellel ellenőrizhetjük, hogy elfér-e még
egy elem a választott korláton belül.
Az owned kulcsot vagy értéket a tábla birtokolja; a borrowed hozzáférés nem jár felszabadítási felelősséggel. A kettőt kulcsra és értékre külön kell eldönteni.
| Erőforrás | Tulajdonos | Kötelezettség |
|---|---|---|
| Slottömb | Tábla | Rehashkor és megszüntetéskor felszabadítja |
| Másolt kulcs | Tábla | Törlésig él, tartalma változatlan |
| Másolt érték | Tábla | Cseréig vagy törlésig él |
| Kölcsönzött kulcs | Hívó | Tagság végéig él és szemantikailag változatlan |
| Kölcsönzött érték | Hívó | Minden táblán keresztüli használatkor él |
Egy újrahasznált parserbufferre mutató borrowed kulcs tehát akkor is hibás lehet, ha a buffer még nincs felszabadítva: tartalmának módosítása miatt a bejegyzés rossz keresési sorozatban marad.
A következő kód külön allokált, owned kulcsokat és értékeket feltételez. Minden ilyen allokációnak pontosan egy tulajdonosa van; a kulcs- és értékpointerek nem ugyanazt az allokációt birtokolják.
| Művelet | Slotra mutató borrowed pointer | Külön allokált kulcs-/értékszövegre mutató pointer |
|---|---|---|
| Sikertelen rehash | Megmarad | Megmarad |
| Sikeres rehash | Érvénytelen | Megmarad |
| Bejegyzés törlése | A bejegyzéshez tartozó hozzáférés megszűnik | A törölt bejegyzés szövegeire mutató pointerek érvénytelenek |
| Érték cseréje | Slot megmaradhat | Régi értékpointer érvénytelen |
| Tábla megszüntetése | Érvénytelen | Érvénytelen |
Owned beszúrásnál előbb mindkét szövegmásolatot létrehozzuk, majd biztosítjuk a kapacitást, és csak ezután publikáljuk a bejegyzést. Hiba esetén a részlegesen elkészült másolatokat felszabadítjuk. Értékcserekor is előbb másolunk, és csak siker után szabadítjuk fel a régi értéket.
Az alábbi cfg.c önállóan objektumfájllá fordítható
modulrész: a rehash műveletet tartalmazza, nem teljes
hashkönyvtárat.
Előfeltétel: t érvényes, inicializált táblára mutat;
live és used pontos;
live <= used <= cap. Üres kezdeti állapota
struct table t = {0};. A hash mező a teljes
hashérték, nem a korábbi kapacitással képzett bucketindex.
#include <stddef.h>
#include <stdint.h>
#include <stdlib.h>
enum slot_state { EMPTY, LIVE, DEAD };
struct slot {
enum slot_state state;
size_t hash;
char *key;
char *value;
};
struct table {
struct slot *slots;
size_t cap;
size_t live;
size_t used; /* LIVE + DEAD */
};
enum cfg_status {
CFG_OK,
CFG_LIMIT,
CFG_NOMEM
};
enum cfg_status cfg_rehash(struct table *t, size_t new_cap)
{
if (new_cap < 8 ||
t->live > new_cap / 2 ||
new_cap > SIZE_MAX / sizeof(struct slot)) {
return CFG_LIMIT;
}
struct slot *fresh = malloc(new_cap * sizeof *fresh);
if (fresh == NULL) {
return CFG_NOMEM;
}
for (size_t i = 0; i < new_cap; ++i) {
fresh[i] = (struct slot){0};
}
for (size_t i = 0; i < t->cap; ++i) {
if (t->slots[i].state != LIVE) {
continue;
}
size_t p = t->slots[i].hash % new_cap;
while (fresh[p].state == LIVE) {
++p;
if (p == new_cap) {
p = 0;
}
}
fresh[p] = t->slots[i];
}
free(t->slots); /* Nem a kulcsokat és értékeket! */
t->slots = fresh;
t->cap = new_cap;
t->used = t->live;
return CFG_OK;
}Külön elvégzendő laborprofil: GCC 14.2.0 vagy Clang 19.1.7, Linux x86-64 System V, LP64, glibc 2.39; ISO C17 mód. A példa itt nem végrehajtott fordítási vagy futási mérés.
gcc -std=c17 -Wall -Wextra -Wpedantic -Wconversion \
-c cfg.c -o cfg-gcc.o
clang -std=c17 -Wall -Wextra -Wpedantic -Wconversion \
-c cfg.c -o cfg-clang.oHelyességi bizonyításvázlat. A méretszorzást
ellenőrzés előzi meg. Allokációs hiba előtt a régi táblát nem
módosítjuk. Sikeres allokáció után legfeljebb new_cap / 2
élő bejegyzést helyezünk át, ezért minden keresés talál üres slotot. Az
áthelyezés nem allokál és nem hív hibázható callbacket. A régi tábla a
commitig marad tulajdonos; az ideiglenes pointermásolatok nem jelentenek
két önálló felszabadítási felelősséget. Végül csak a régi slottömböt
szabadítjuk fel.
A rehash várható költsége megfelelő hash-eloszlási modell mellett , rossz ütközési mintával azonban is lehet. Átmenetileg mindkét slottömb él.
Korlátos effektív foglaltság és megfelelő hash-eloszlás mellett a keresés várhatóan konstans számú slotot vizsgál; legrosszabb esetben -et. A kulcs hosszát külön számolni kell: a hashképzés egy hosszú sztringre tipikusan . Geometrikus növelésnél a növelési költség beszúrásonként amortizálható, de ez nem jelent worst-case konstans késleltetést.
A hash DoS lényege, hogy a támadó sok, azonos vagy kedvezőtlenül csoportosuló hashértékű kulcsot választ. Ettől a beszúrások összköltsége négyzetessé válhat. A nagyobb kapacitás nem javítja meg az azonos teljes hashértékeket.
Nem megbízható bemenethez támadásállóságra tervezett, titkos maggal kulcsolt hash, továbbá elemszám-, kulcshossz- és memóriahatár indokolt. A puszta „véletlen só hozzáadása” önmagában nem bizonyít támadásállóságot.
Tipikus hibák:
EMPTY állapotot írni;Magyarázó: Az equality szerint
"PORT" és "port" azonos, a hash viszont az
eredeti karaktereket dolgozza fel. Mi hibás, és milyen feltétellel
javítható?
Kódolási: Egészítsd ki a modult
cfg_erase_at függvénnyel. Kapjon indexet, utasítsa el a nem
élő slotot, szabadítsa fel a payloadot, és tartsa helyesen a
számlálókat.
Diagnosztikai: Elmentettél egy slotpointert és az abban tárolt értékre mutató pointert. Mi használható sikertelen rehash, sikeres rehash, majd az adott bejegyzés törlése után?
Sérülhet az feltétel. Mindkét műveletnek ugyanazt a stabil normalizálást kell alkalmaznia, például explicit ASCII-kisbetűsítést. Ellenőrzési feltétel: minden tesztelt, egyenlőnek minősített kulcspár hashértéke legyen azonos. Ez szükséges teszt, nem teljes bizonyítás.
Az alábbi C17-részlet ugyanabba a cfg.c fájlba
illeszthető, és az előző parancsokkal fordítható. A tábla tulajdonlási
előfeltételei változatlanok.
int cfg_erase_at(struct table *t, size_t i)
{
if (i >= t->cap || t->slots[i].state != LIVE) {
return 0;
}
free(t->slots[i].key);
free(t->slots[i].value);
t->slots[i] = (struct slot){ .state = DEAD };
--t->live;
return 1;
}A used változatlan: egy élő slotból egy tombstone lett.
Ellenőrzési feltétel: ütköző kulcsok közül a középső törlése után a
később elhelyezett kulcs továbbra is megtalálható legyen. A törölt kulcs
és érték borrowed pointerei többé nem használhatók.
Sikertelen rehash után mindkét pointer használható marad. Sikeres rehash után a régi slotpointer már nem használható hozzáférésre, az értékpointer viszont igen, mert annak allokációját nem mozgattuk.
A bejegyzés későbbi törlése az értékpointert is érvénytelenné teszi. Ellenőrzési feltétel: sikeres rehash után a slotot új kereséssel kell megszerezni; törlés után a régi payloadpointert sem szabad dereferálni.
A hash table helyessége a hash/egyenlőség konzisztenciáján, a stabil kulcsszemantikán és az explicit ownershipen alapul. Nyílt címzésnél a tombstone őrzi a keresési útvonalat, a tranzakciós rehash pedig a régi állapotot védi hiba esetén. A kapacitás, az invalidáció és a támadható bemenet kezelése ugyanannak az API-szerződésnek a része.