{{TIN066 Skripta}} {{ TODO|Lepe zpracovano na: Státnice - Hašování }}

Slovníkový problém

Mějme universum prvků U a jeho podmnožinu S. Tu chceme reprezentovat a umět na ní provádět operace MEMBER, INSERT a DELETE. Nechci mít v paměti prostor pro celé U, proto si zavedu hashovací funkci h ⁣:U{0..m1}h\colon U \to \{0..m-1\}, pak prvky ukládám do pole délky m na index podle h(x)h(x).

  • Problémem jsou kolize: x,yS:xy, h(x)=h(y)x, y \in S: x \ne y,\ h(x) = h(y).

  • Ještě nás bude zajímat load factor α=S/m\alpha = |S|/m.

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

{{TODO|Důkazy složitostí (ale bez zamlžení toho, co už tu je; možná lemmata a detaily na zvláštní stránce?)}}

Každá položka hashe vede na spoják s kolidujícími prvky (řetězce jsou prosté, nic v nich není dvakrát). V nejhorším případě budu mít jediný spoják se vším.

Paměti žere každá položka O(1+l(i))O(1+l(i)) (l(i) je délka spojáku), očekávané délky spojáků se <b>za předpokladu Umn2{|U|\over m}\gg n^2</b> (předpoklad tohoto typu je nezbytný, fanoušsci pravděpodobnostní metody si za ním mohou představit narozeninový paradox) řídí binomickým pravděpodobnostním rozdělením (s parametrem 1/m). Z toho vypadne, že:

  • E l=α\mathbb{E}\ l = \alpha

  • var l=n1m(11m)\hbox{var}\ l = n{1 \over m}\left(1 - {1 \over m}\right)

Zběsilými a děsivými sumacemi na tři stránky se dobereme k očekávané délce nejdelší položky O(lognloglogn)O({\log n \over \log \log n}) za předpokladu, že α1\alpha \le 1.

{{TODO|Vysvětlit tu souvislost s narozeninovým paradoxem pořádně, nejlépe obecně jako samplování s opakováním vs. bez opakování; jednou větou jde v podstatě jen o to, že náhodné zobrazení z nmn \to m je pro nmn \ll \sqrt m skoro jistě prosté, a proto vybíráme-li bez opakování (neboli prostě), dopadne to skoro jistě stejně, jako když za předpokladu nmn \ll \sqrt m vybíráme s opakovaním (což se mnohem lépe analyzuje, neboť se tím odstraní závislosti).}}

Neuspořádané řetězce

Dobře, jak dlouho bude trvat něco najít? Očekávaný počet testů při neúspěšném hledání bude: (11/m)n+n/m(1 - 1/m)^n + n/m tzn. cca eα+αe^{-\alpha} + \alpha

(pravděpodobnost prázdného řetězce plus pro každou délku ta délka krát pravděpodobnost, že to bude ona).

Očekávaný počet testů při úspěšném vyhledání 1+α/21 + \alpha/2. To je vidět celkem intuitivně, dokázat to formálně je trošku pracnější, ale už jsme tu přeskočili i horší věci. ;-)

Uspořádané řetězce

Očekávaný počet testů při úspěšném vyhledávání je stejný jako neuspořádaných řetězců. Při neúspěšném vyhledávání to můžeme utnout dříve, konkr. se lze dopracovat k počtu testů eα+1+α/2(1eα)/αe^{-\alpha} + 1 + \alpha/2 - (1 - e^{-\alpha})/\alpha.

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

Jde o hešování se separovanými řetězci, kde pro řetězce nevytváříme spojové seznamy s uzly někde na haldě, ale přidáváme tyto uzly do volných řádků naší tabulky (hrozí zde proto přeplnění). Uzly řetězců jsou v jednom úseku paměti - může být vstřícnější ke cache oproti separovaným řetězcům.

Stále zde existují řetězce. Řetězec s X vždy začíná na místě, kam se nahešovalo X. V případě, že do položky budu chtít uložit něco, co tam vážně patří, přendám prvek jinam. Očekávané hodnoty všeho budou stejné jako u separovaných řetězců, ale INSERT/DELETE budou samozřejmě náročnější.

Sloupce: (key, next, previous)

MEMBER x : i = h(x)

  • if(i.previous != null || i.key == null) return false; - je zde cizí řetězec nebo prázdný řádek

  • while (i.next!=null && i.key != x) i = i.next; - doskáčeme v řetězci na x nebo na konec

  • return i.key==x;

INSERT x : i = h(x)

  • pokud (i.key prázdný) vložím zde, konec;

  • pokud (i.previous prázdný) je zde začátek správného řetězce. Doskáču na konec řetězce, vložím na nový řádek a napojím k řetězci

  • pokud (i.previous neprázdný) je zde meziuzel cizího řetězce. Překopíruju položku i na jiný prázdný řádek (a přepojím pointery předchůdce/následníka), na i začnu nový řetězec.

DELETE x : i = h(x)

  • nejdříve doskáču na správné i (jako v MEMBER(x))

  • vymažu položku, "spoják style" (přepojení pointerů, rozlišení zda jsem první / vnitřní / koncový prvek spojáku ...)

Poznámky: na pointery next - previous stačí jen pár bitů (indexy jsou jen v rámci tabulky). V INSERT(x) je potřeba umět elegantně najít volný řádek / zahlásit přeplnění.

S dvěma ukazateli

Hashování s přemísťováním, ale řetězec (spoják) je pouze jednosměrný, ukazatel na předchozí prvek nahradíme ukazatelem begin na začátek řetězce. Řetězec obsahující X nemusí začínat na řádku h(x), ale začíná na h(x).begin (rovněž hodnota h(x).key nemusí patřit do řetězce obsahujícího X). Odpadá nutnost přemísťování (jen se mění begin), zase je maličko větší očekávaný počet testů.

Sloupce: (key, next, begin).

INSERT x : i = h(x)

  • pokud (i.begin prázdné)

    • pokud (i.key prázdné) vložíme položku na i

    • jinak vezmeme prázdný řádek j, j.key = x, i.begin = j;

  • pokud (i.begin neprázdné) doskáčeme na konec řetězce, vložíme x na nový řádek a napojíme za konec řetězce

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

Sloupce: (key, next)

Máme jen jeden pomocný ukazatel next, prvek pak prostě přidáme do řetězce, který zrovna prochází jeho položkou; v řetězci se tak časem mohou ocitnout prvky s různými hashi. Neumíme efektivně mazat. Srůstající řetězce jsou jakýmsi protikladem separovaných řetězců.

{{TODO|Počty testů}}

Bez pomocné paměti (standardní)

LISCH (late) přidá prvek na konec řetězce, EISCH (early) přidá prvek do řetězce za prvek na kolidující položce (volnou položku pro přidávaný prvek seženu jedno jak). EISCH je trochu rychlejší (neformálně - pravděpodobnost práce s nově přidaným prvek je vyšší).

S pomocnou pamětí

Přidáme oblast přetečení, což bude přímo neadresovatelný seznam, kam házíme kolidující nové prvky - až když je oblast plná, začneme je vkládat přímo na volná místa tabulky. (Tedy ukazatel next může procházet klidně skrz oblast přetečení.) EICH, LICH fungují stejně, VICH (varied) vloží nový prvek za poslední, který je ještě v pomocné části, nebo jako EICH (pokud řetězec přes pomocnou část nevede).

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

Sloupce: (key)

Bez pomocných položek pro práci s tabulkou, prostě nový prvek přidám na nejbližší následující volné místo (předpokládám cyklickou paměť). Hodně triviální implementace, ale složité a pomalé DELETE. Nevhodné pro netriviální α\alpha, začnou se vytvářet shluky.

Očekávaný počet testů:

  • Neúspěšný případ: 12(1+(11α)2){1 \over 2}\left(1 + \left({1 \over 1 - \alpha}\right)^2\right)

  • Úspěšný případ: 12(1+11α){1 \over 2}\left(1 + {1 \over 1 - \alpha}\right)

Dvojité hashování

Jako lineární přidávání, další volnou pozici ale hledám chytřeji než lineárně - zavedu si druhou hashovací funkci, pak postupně pro rostoucí i testuji (h1(x)+ih2(x))modm(h_1(x) + ih_2(x)) \mod m. Samozřejmě je vhodné, aby h2(x)∤mh_2(x) \not| m. DELETE je zase otrava. Když druhý hash zvolíme chytře, bude fungovat o dost lépe než lineární.

Lineární přidávání je specifický případ dvojitého hešování, kde h2(x)h_2(x) je konstantní jednička.

Lineární funguje dobře pro tabulku zaplněnou do 70%, dvojité až do 90%.

{{TODO|Odhady testů}}

Externí hashování

Chceme minimalizovat počet přístupů do stránkované externí paměti. V OZD se následující učilo pod jménem "Faginovo hashování".

Data rozhazujeme do stránek, každá stránka má atribut i - prvních i bitů hashe všech položek ve stránce je stejných. Dále si držíme adresář - mapování hashů na stránky, ve kterém indexujeme pomocí prvních j bitů hashe (iji \le j); několik položek adresáře může ukazovat na tu samou stránku (pak i<ji <j). Pokud se stránka naplní, zvýšíme i a stránku rozštěpíme, pokud tím porušíme invariant, zvýšíme i j.

Adresář by se měl vejít do jedné stránky (nebo ho prostě držíme v paměti), potom MEMBER jsou max. 3 externí operace (nalezení 2 operace, třetí příp. update a zápis zpět na disk), INSERT/DELETE max. 6 operací (lookup 2 operace, pak buď uložení jako třetí, nebo např. pro INSERT rozštěpení: 3.,4. zápis dvou nových stránek, 5. znovunačtení adresáře (proč si jej nedržíme v paměti?), 6. uložení nového adresáře).

Nechť b je počet prvků na stránku a n počet všech prvků:

  • Očekávaný počet použitých stránek bude nbln2n \over b\ln 2; tedy je zaplněno zhruba 69% možných položek v paměti.

  • Očekávaná velikost adresáře bude ebln2n1+1/b{e \over b\ln 2}n^{1+1/b}; tedy superlineární! Mez použitelnosti (nárůst adresáře kolem 5%) pro b=100b=100 je zhruba n=1010n=10^{10}, pro větší n je potřeba i větší b.