Kapitola 1
Státnice - Hašování

1.1 Hashování

Základní motivací pro hashování je slovníkový problém, kdy máme za úkol reprezentovat množinu prvků z nějakého univerza a provádět na ní následující operace:

- MEMBER (je třeba, aby tato operace probíhala velmi rychle)

- INSERT

- DELETE

Aby byl MEMBER rychlý, bylo by nejlepší mít v paměti pole bitů o velikosti . V případě, že (a navíc může být neúnosně velké), použiji hashovací funkci a množinu reprezentuji polem s políčky tak, že je uložen na indexu . Předpokládejme, že funkce se dá spočítat v čase – jiné funkce vlastně nemají smysl, protože nepřináší dostatečné zrychlení.

Problém je, když nastane kolize: , . Jednotlivé druhy hashování, které následují, se liší strategiemi předcházení a řešení kolizí.

Pro následující analýzy si označíme:

- Load factor (faktor zaplnění) – .

Hashování se separovanými řetězci

V tomto typu hashování se kolize řeší řetězením ve spojácích: pro každé políčko založíme zvlášť spoják všech prvků, které se do něj hashují. Všechny algoritmy je musí projít. Předpokládejme, že řetězce jsou prosté – nic se v nich neopakuje. V nejhorším případě mají všechny prvky stejný hash a máme jen jeden seznam.

Paměťová náročnost je pro každý seznam , kde je délka toho seznamu.

Existují dvě varianty – neuspořádaná a s s uspořádanými prvky v řetězcích. Liší se jedině v očekávaném počtu testů pro neúspěšné hledání (když dojdu v řetězci za místo, kde by byl hledaný prvek, můžu skončit).

Pro odhad složitosti alogritmů předpokládáme, že:

- Hashovací funkce rozděluje data rovnoměrně

- Sama reprezentovaná množina je náhodný výběr z s rovnoměrným rozdělením

Tyto předpoklady v praxi ale splněny být nemusí.

Očekávaná průměrná délka řetězců

Pro odhad složitosti se počítá očekávaná délka řetězců. Označme délku -tého řetězce jako . Potom pravděpodobnost, že tento řetězec má délku , odpovídá binomickému rozdělení:

Toto je jen aproximace (pro nekonečnou velikost univerza i seznamů), pro případ, že , ale lze použít. Očekávaná délka řetězce pak vychází jako (rozepíšu faktoriál a vytknu , pak změním rozsah sumace (protože násobení mi nic nedá), pak můžu z udělat a sumovat ):

Vlastně tu ale objevujeme Ameriku tím, že počítáme střední hodnotu binomicky rozdělené veličiny s parametrem – ze vzorce nám vyjde to samé. Stejně tak rozptyl ze vzorce vyjde .

Očekávaná délka nejdelšího řetězce

Tento údaj však sám o sobě nestačí, počítá se i očekávaný nejhorší případ (očekávaná délka nejdelšího řetězce). Ta se definuje následovně:

Z toho (pravděpodobnost disjunkce jevů je součet jednotlivých pravděpodobností; vyčíslení: počet podmnožin správné velikosti a pravděpodobnost, že mají stejný hash):

Najdeme mezní hodnotu , pro které . Označme . Potom . Ze Stirlingovy formule plyne, že . Z toho odvodíme (hodně neformálně, asymptoticky):

A . Pro platí, že :

A tedy očekávaná délka nejdelšího řetězce je .

Očekávaný počet testů

Testy jsou porovnání toho, co hledáme, s nějakým prvkem, nebo zjištění, že řetězec je prázdný. Jejich očekávaný počet je další odhad efektivity struktury. Rozlišujeme úspěšné a neúspěšné hledání.

Neúspěšné hledání (Je-li délka řetězce , jeden test stejně provedu, jinak otestuji celý řetězec):

S uspořádanými řetězci končím dřív ().

Počet testů pro úspěšné vyhledávání je roven průměru počtu testů provedených při vložení každého z prvků, tj. 1 + očekávaná délka řetězce při každém vkládání: .

Hashování s přemísťováním

Nevýhodou separovaných řetězců je nutnost alokovat další paměť, to je neefektivní. Proto zavedeme do hashovací tabulky pomocné ukazatele a celé řetězce nacpeme přímo do ní (a zřetězené prvky prostě rozházíme na jiné adresy). Pro hashování s přemísťováním se v tabulce uchovává navíc jednoduše odkaz na předchozí a následující prvek řetězce. Pokud vkládáme na místo, kde už je nějaký prvek z jiného řetězce, přehodíme tento cizí prvek jinam.

Algoritmy jsou téměř stejné jako pro separované řetězce, jen při DELETE prvního prvku řetězce je nutné na jeho místo přesunout druhý (pokud existuje).

Očekáváný počet testů je stejný jako pro hashování se separovanými řetězci. Přemísťování v tabulce je ale náročnější než 1 test, proto jsou INSERT a DELETE pomalejší.

Hashování se dvěma ukazateli

Od předchozího se liší tím, že místo ukazatele na předchozí prvek používá odkaz na začátek řetězce BEGIN. Řetězec tak už nemusí začínat na indexu svého hashe.

Místo přesouvání prvků algoritmy mění BEGIN (ten je na -tém políčku vyplněn, právě když existuje řetězec prvků s hashem ).

- INSERT všechno vkládá na konec řetězce, zakládá-li nový, do BEGIN (na místě určené hashem) píše, kde se ve skutečnosti nachází

- DELETE jen upravuje odkazy na následující, nebo BEGIN (pokud maže poslední prvek řetězce).

Kvůli tomu, že řetězce začínají jinde než na svém místě, je počet testů o něco větší:

- Úspěšné hedání:

- Neúspěšné hledání přibližně .

Srůstající (coalesced) hashování

Srůstající hashování používá jen jeden ukazatel v hashovací tabulce navíc – odkaz na další prvek NEXT. Řetězce tak obsahují hodnoty s různými hashi. Prvek vkládáme vždy do řetězce, obsahujícího -té políčko v tabulce.

Existují různé varianty:

- Standardní (bez pomocné paměti, “late” a “early” insertion) – LISCH, EISCH

- Bezpřívlastkové (s pomocnou pamětí, “late”, “early” a “varied” insertion) — LICH, VICH, EICH.

Bez pomocné paměti – LISCH a EISCH

LISCH je “late insertion”, tedy přidává se za poslední prvek řetězce. EISCH (“early insertion”) přidává za první prvek řetězce.

- Algoritmus MEMBER je stejný pro oba (jen projití řetězce po odkazech NEXT).

- Alg. INSERT:

- U LISCH projití celého řetězce (v případě že není prázdný, jinak jednoduše vložím na správné políčko) s testy na přítomnost prvku, potom vložení na libovolné volné místo v tabulce a připojení na konec řetězce.

- Pro EISCH vložení na nějaké volné místo v tabulce a jen přepojení ukazatelů NEXT – připojení do řetězce za první prvek (pokud je řetězec neprázdný).

Algoritmy DELETE nejsou známy, kromě primitivních. Problémem je u nich zachování náhodného uspořádání prvků v řetězcích, které se předpokládá pro dodržení očekávaných časů operací. Je ale možné také prvky jen označit jako odstraněné a jejich místa použít při vkládání dalších (to ale zpomaluje hledání).

EISCH je kupodivu o něco rychlejší na úspěšné vyhledání (je větší pravděpodobnost práce s novým prvkem), očekávaný počet testů je stejný.

Počet testů v neúspěšném případě: Spočteme průměr přes všechny posloupnosti délky (kde hledáme . prvek v množině ostatních ). Označme počet řetězců délky , které přispívají celkem porovnáními k sumě:

Tady představuje počet prázdných řádků, tedy , je součet délek všech řetězců v reprezentacích -prvk. množin, tedy .

Poslední člen označíme . Při INSERTu do -prvkové množiny jsou 2 možnosti vzniku řetězce délky : buď přidávám do řetězce (délky ), nebo řetězec (pův. délky ) nezměním. Z toho vyjádříme rekurentní vztah pro (úpravy: rozepsání rozdílů, vykrácení, rozpis jako ):

Pak pomocí vztahu spočítaného z získáme nerekurentní vztah (, obrácení sumace a vytknutí ):

A tedy očekávaný počet testů vyjde:

Počet testů pro úspěšný případ spočteme pro LISCH jako počet testů při vkládání prvku. Metoda EISCH pro tento postup nesplňuje předpoklady. Porovnání klíčů při neúspěšném vyhledávání je stejně při přístupu na obsazené políčko, neporovnávám ale nic při přístupu na neobsazené políčko, takže dostávám:

Průměr pro postupné vkládání všech prvků pak dává:

Pro metodu EISCH vychází (bez důkazu):

Všechny odhady mají odchylku .

S pomocnou pamětí – LICH, VICH, EICH

V této variantě rozdělíme paměť na dvě části:

- (hash-funkcí) přímo adresovatelná

- pomocná část (bez přístupu hash-funkcí)

Při kolizích nejdříve ukládáme do řádků z pomocné části, pak teprve do přímo adresovatelné, tedy oddalujeme srůstání řetězců. Chování se tak až do určitého okamžiku podobá separovaným.

Existují tři varianty podle chování algoritmu INSERT:

- LICH vždy přidává na konec řetězce

- EICH v případě neprázdného řetězce vždy za 1. prvek

- VICH vždy za poslední prvek v pomocné paměti nebo (pokud žádné v pomocné paměti nejsou) za 1. prvek řetězce (tj. chová se na pomocné paměti jako LICH a v přímo adresovatelné části jako EICH).

Algoritmy až na VICH se chovají stejně jako ve standardním srůstajícím hashování, rozhodující je výběr volného řádku pro vložení: např. “vždy vyber z nejvyšší adresy” může zaručit používání pomocné paměti. Také tu není přirozené efektivní DELETE.

Odhad složitosti: definujeme si následující hodnoty:

- – počet uložených prvků

- – velikost přímo adresovatelné paměti

- – celková velikost paměti

- – faktor zaplnění

- – adresovací faktor

- – jediné nezáp. řešení rovnice

Pokud je , pak pro všechny verze vychází očekávaný počet testů v neúspěšném případě a v úspěšném (chyba: .

V případě, že (začínají srůstat řetězce), se metody liší a vychází divnosti. V neúspěšném případě je VICH a LICH lepší než EICH, v úspěšném vede VICH před EICH a LICH (vždy o jednotky procent). Doporučená hodnota . Na hledání volného řádku se v praxi hodí např. spojový seznam volných řádků.

Lineární přidávání

Tato a následující metoda nepoužívá žádné dodatečné položky v hashovací tabulce a zároveň řetězce kolidujích prvků ukládá přímo do ní. Nalezení dalšího prvku z řetězce je přímo v algoritmu.

Lineární přidávání je nejjednoduším řešením takové situace: v případě kolize při INSERTu nalezne nejbližší vyšší volné políčko a vloží nový prvek tam. Předpokládáme “cyklickou” paměť, tj. když dojdeme na konec, vkládáme od začátku.

Problémem je tvoření shluků – při velkém zaplnění se operace dost zpomalují. Také nepodporuje efektivní DELETE (a ani primitivní způsoby nejsou moc richlé). V praxi je dobré uchovávát počet uložených prvků nebo mít zarážku (nikdy neobsazované pole), abychom věděli, kdy dojde k přeplnění.

Očekávaný počet testů je v neúspěšném a v úspěšném případě (bez důkazu).

Dvojité hashování

Dvojité hashování je vylepšení předchozí metody tak, aby nevznikaly shluky. Výběr následujícího řádku bude závislý na předchozím, ale s rovnoměrným rozložením. Na to použiji druhou hashovací funkci .

Při operacích INSERT pak hledám nejmenší od , že je volné políčko, tj. postupně přičítám a modulím. Stejný postup je i pro operaci MEMBER.

Je nutné, aby , tj. abych měl prosté posloupnosti (a z každého políčka tak mohl vést řetězec po celé tabulce). Idea, že iterace tvoří pro každé náhodnou permutaci paměťových míst, není úplně přesná, ale v praxi stačí, aby z plynulo, že a budou odlišné.

Funkce navíc musíme volit “chytře” (i lineární přidávání je spec. příp. dvojitého hashování, kdy ). Pak tato metoda je znatelně rychlejší než lin. přidávání. Předpoklad náhodnosti použitý v teoretické analýze sice splnit nelze, ale přiblížit se mu ano.

Očekávaný počet testů

Předpokládáme, že iterování funkce tvoří náhodné permutace (což, jak bylo řečeno, není úplně přesné).

Pro neúspěšný případ: označme pravděpodobnost, že při zaplnění je pro nějaké prvních políček, kam bych ho mohl vložit, plných. Potom a tedy .

Očekávaný počet testů je (předposlední rovnost plyne z rekurentního vztahu pro , poslední krok dokázat indukcí):

Počet testů v úspěšném případě – stejná metoda jako u dřívějších analýz, takže vychází:

Srovnání

Podle počtu testů:

neúspěšné

úspěšné

1.

separované uspořádané řetězce

separované (usp. i neusp.) řetězce, přemísťování

2.

separované řetězce, přemísťování

dva ukazatele

3.

dva ukazatele

VICH

4.

VICH, LICH

LICH

5.

EICH

EICH

6.

LISCH, EISCH

EISCH

7.

dvojité hashování

LISCH

8.

lineární přidávání

dvojité hashování

9.

lineární přidávání

- VICH je při vhodném lepší než hashování se dvěma ukazateli.

- Lineární přidávání se nedá použít pro , dvojité hashování pro .

- Separované řetězce a obecné srůstající hashování používají víc paměti, přemísťování a dvojité hashování zas víc času, tj. nelze říct, které je jednoznačně lepší.

Implementační dodatky

- Pro hledání volných řádků se většinou používá seznam (zásobník).

- Přeplnění se většinou řeší držením v rozumném intervalu () a přehashováním do jinak velké tabulky () při pře- nebo podtečení

- V praxi se doporučuje přehashování odkládat (např. pomocnými tabulkami) a provádět při nečinnosti systému.

DELETE se ve strukturách, které ho nepodporují, řeší označením políčka jako smazaného s možností využití při vkládání. V případě, že polovina polí je blokovaná tímto způsobem, se vše přehashuje. Pro srůstající hashování se toto používat nemusí, máme metody na zachování náhodnosti rozdělení dat.

V praxi je výhodné, známe-li něco o rozdělení vstupních dat, aby ho hashovací funkce kopírovala (většinou to ale nejde), jinak musíme předpokládat rovnoměrnost, což zaručeno zdaleka není. Nutnost rovnoměrného rozdělení vstupních dat lze obejít (viz níže).

1.2 Univerzální hashování

Abychom nemuseli předpokládat rovnoměrné rozložení vstupních hashovaných dat (což zdaleka není vždy zaručeno), budeme mít místo pevné hash-funkce nějakou množinu , z níž funkci náhodně rovnoměrně vyberu. Analýza složitostí se pak dělá přes všechny a platí pro všechny – i nerovnoměrné ( je daná pevně a se k ní volí; ).

Pro formalizaci analýz je nutné mít analytické zadání funkcí a znát přesnou velikost množiny . To obejdeme očíslováním funkcí a počítáním s indexovou množinou (očekávaná hodnota je průměr přes ). Při použití skutečné velikosti v odhadech bychom dostali horší výsledky, protože některé funkce s různými indexy se můžou ve skutečnosti ukázat jako identické, a to tu zanedbáváme.

Definice: Systém funkcí je -univerzální, pokud:

.

Tj. zaručuje se, že pro každé dva různé prvky má maximálně funkcí kolizi.

Existence c-univerzálních systémů

Předpokládejme, že universum má tvar kde je nějaké prvočíslo a vezmeme funkce typu

Jsou dobře použitelné, protože se dají počítat rychle. Protože je prvočíslo, můžeme pracovat v , coz je těleso. Rovnice je ekvivalentní s:

Z Frobeniovy věty o jednoznačnosti řešení lineárních rovnic plyne, že pro každé existuje jen jedna dvojice , které vyhovuje. Počet řešení soustavy je tedy omezený číslem ( – hodnot, – hodnot pro daná ).

Pak je systém -univerzální pro a jeho velikost opdovídá .

Vlastnosti

Vyrobíme si pomocnou funkci

Chceme potom spočítat součet . Z výsledku vidíme očekávanou délku řetězce pro libovolnou (jednu) množinu dat. Tohle pak sečtu přes všechny mé hash-funkce a z -univerzality dostanu

Z toho dopočítám (podělením ) horní odhad očekávaného .

Výsledek: očekávaný čas operací MEMBER, INSERT a DELETE v -univerzálním hashování je (kde faktor naplnění ). Čas po sobě jdoucích operací na původně prázdné tabulce je . To není lepší hodnota než mají separované řetězce (), ale u nich předpokládám rovnoměrné rozdělení dat.

Výběr vhodné funkce není úplně jednoduchý, protože funkcí může celkem být např až , tj. nelze ho provést jednoduchým zavoláním generátoru náhodných čísel, nýbrž např. náhodným vybráním každého bitu indexu funkce. Proto je výhodné najít co nejmenší -univerzální systémy (viz dále).

Dolní odhady velikosti univ. systémů

Očíslujme hash-funkce z a induktivně definujme množiny jako největší podmnožiny takové, že je jednoprvková. Platí , tedy – velikost těchto množin klesá s logaritmem a . Takže velikost univ. systému roste alespoň úměrně logaritmu velikosti univerza.

Dolní odhad c

5-univerzální systém: Zvolme a k němu vezměme -té prvočíslo tak, že . Definujme systém funkcí kde . Zřejmě .

Odhadem , když si množinu rozdělíme na a , se dá dokázat, že systém je 5-univerzální, za dalších podmínek i 3.25-univerzální.

Dolní odhad : Platí: . Spočítáme – pro pevnou máme (z Cauchy-Schwarzovy nerovnosti, ):

Tedy . Zároveň platí , což mi dává výsledek.

1.3 Perfektní hashování

Základní ideou perfektního hashování je nalézt hash-funkci, která pro danou množinu nedělá žádné kolize, takže operace MEMBER bude velice rychlá. Potom je nevýhoda, že nelze přirozeným způsobem realizovat operaci INSERT, tj. v praxi se nesmí moc často vyskytovat.

Tabulka by neměla být o mnoho větší než množina a funkce rychle spočitatelná a její realizace nezabírat moc paměti (tedy žádná zadávání tabulkou).

Definice

- Hashovací funkce je perfektní pro množinu , pokud pro

- Soubor funkcí je -perfektní, pokud takové, že existuje perfektní pro .

Odhady velikosti

Každá funkce je perfektní pro množin (sčítáme přes všechny množiny hashů a pro každou z nich uvažujeme všechny možnosti, jak mohla vzniknout). Z Cauchy-Schwarzovy nerovnosti plyne, že tento výraz nabývá maxima, když . Každá funkce je tedy perfektní pro max. množin. Z toho plyne:

Jiný odhad lze provést jako u -univ. systémů s očíslovanými funkcemi . Používám induktivně definované množiny , kde a je největší podmnožina , kde je zrovna funkce konstantní. Dostáváme , tj. , ale z perfektnosti plyne . Dostáváme .

Existence

Reprezentujme soubor funkcí na univerzu velikosti pomocí matice typu , takže , tj. v jednom sloupci jsou výsledky jedné hashovací funkce pro všechny prvky univerza.

Pak žádná funkce z není perfektní pro množinu , když podmatice tvořená řádky příslušejícími prvkům nemá prostý sloupec. Takových matic je maximálně (počet všech funkcí minus počet prostých, to celé krát libovolné doplnění na řádek):

Podmnožin velikosti je pak , čímž vynásobeno mám počet matic neodpovídajících -perfektnímu systému. Všech matic je . Potom existuje -perfektní systém, když:

Příšernými kejklemi dostaneme podmínku existence .

Konstrukce funkce

Chceme splnit rychlou spočitatelnost a paměťovou nenáročnost. Předpokládáme univerzum prvočíselné velikosti a funkce typu:

Označme . Potom pokud není perfektní, pak nějaké a mám .

Odhadnu výraz . Z vlastností modula mám takových pro daná nejvýše . Dostávám tedy odhad a z něj vidím, že existuje takové , že , tedy pro tabulku velikosti mám perfektní funkci.

Dá se dokázat trochu slabší předpoklad, že , který je základem pravděpodobnostního algoritmu.

Pak mám deterministický algoritmus, který pro nalezne perfektní v čase a pravděpodobnostní, který pro najde perfektní v čase . Mám tedy konstrukci perfektní hash-funkce, ta ale nesplňuje požadavek na malou tabulku ().

Menší tabulka

Zmenšíme-li velikost tabulky na , bude výše uvedený algoritmus schopný nalézt funkci, pro kterou platí ( v pravděpodobnostní variantě). Každou kolizi pak můžeme “rozstrkat” perfektní funkcí nad miniaturní tabulkou a celková velikost všech tabulek bude mnohem menší:

- Vezmeme nalezenou funkci a najdeme všechny neprázdné množiny

- Pro jim odpovídající (dvojnásobek v pravděp. metodě) najdeme takové, že je perfektní funkce pro do tabulky velikosti .

- Definujme , potom pokud , pak je perfektní, její hodnota spočitatelná v čase a hashuje do tabulky velikosti ( s pravděp. případě), je naleznutelná v čase () a pro její uložení do paměti jsou potřeba hodnoty a , vyžadující paměti.

Pro výpočet potřebuji 2 násobení, 2 modulo a 1 sčítání (pro v paměti), tabulka má velikost . Taková funkce ale stále nesplňuje požadavek na málo paměti pro uložení.

“Malá” funkce

Víme, že pro je počet prvočísel, která ho dělí . Z toho úvahou o dělitelích čísla na -prvkové a vzorce hustoty prvočísel dostanu, že existuje o velikosti takové, že je perfektní pro .

Deterministické nalezení trvá (test perfektnosti každého systému je ). Proto použijeme pravděpodobnostní algoritmus (mezi přir. čísly je aspoň prvočísel, která vyhovují): nejdřív najde prvočíslo a pak testuje perfektnost. Očekávaný počet testů je , celková složitost algoritmu je pak . Najde zhruba až větší prvočíslo než deterministický.

Tuto funkci použijeme ke konstrukci výsledné hash-funkce:

1. Nalezneme prvočíslo , aby byla perfektní pro

2. Položíme , pak najdeme prvočíslo

3. K němu existuje takové, že je perfektní pro

4. Položíme a najdeme perfektní pro do tabulky s méně než řádky (viz výše, počítá se ale do univerza o velikosti ).

5. Pak je perfektní.

Funkce je určena 1 číslem o velikosti , 2 čísly o velikosti a , je určená čísly o , tj. celkem zadání vyžaduje paměti.