Salta al contenuto
Note per Studenti Tabelle hash

Tabelle hash

In questa pagina 8

Definizione (tabella hash). La tabella hash è un'implementazione efficiente della mappa (vedi Mappe e dizionariADT mappa (chiavi distinte) con get, put, remove, keySet/values/entrySet; famiglia mappa/mappa ordinata/dizionario (multimappa); applicazioni; implementazioni semplici ma poco efficienti (lista, array indicizzato dalle chiavi); dizionario realizzato con una mappa di liste.Mappe e dizionari →) con tempo medio costante. Ha tre componenti:

  1. una funzione hash h:U→[0,N−1]h : U \to [0, N-1] (UU universo delle chiavi), composta da hash code k↦Zk \mapsto \mathbb{Z} e compression function Z→[0,N−1]\mathbb{Z} \to [0, N-1];
  2. un bucket array AA di capacità NN: A[i]A[i] contiene le entry ⟨k,v⟩\langle k, v \rangle con h(k)=ih(k) = i;
  3. un metodo di gestione delle collisioni: ⟨k1,v1⟩,⟨k2,v2⟩\langle k_1, v_1 \rangle, \langle k_2, v_2 \rangle con k1≠k2k_1 \ne k_2 e h(k1)=h(k2)h(k_1) = h(k_2).

Esempio. Con N=11N=11 e h(k)=(3k+5) mod 11h(k)=(3k+5)\bmod11 la chiave 1212 va nel bucket 41 mod 11=841\bmod11=8: 3⋅12+5=413\cdot12+5=41 e 41=3⋅11+841=3\cdot11+8, quindi il resto è 88.

L'idea: invece di usare la chiave come indice di un array enorme (Mappe e dizionariADT mappa (chiavi distinte) con get, put, remove, keySet/values/entrySet; famiglia mappa/mappa ordinata/dizionario (multimappa); applicazioni; implementazioni semplici ma poco efficienti (lista, array indicizzato dalle chiavi); dizionario realizzato con una mappa di liste.Mappe e dizionari →), la si trasforma in un indice piccolo. Visto che ∣U∣≫N|U| \gg N, la funzione hh non può essere iniettiva (principio dei cassetti: più chiavi di quante siano le celle), quindi le collisioni sono inevitabili e vanno gestite. Sono anche più frequenti di quanto si pensi, come nel paradosso del compleanno (Calcolo combinatorio per la probabilitàNegli spazi uniformi bisogna contare: principio di moltiplicazione, disposizioni con ripetizione n^k, disposizioni semplici n!/(n−k)!, permutazioni n!, combinazioni (n su k); estrazioni con e senza reinserimento e distribuzione ipergeometrica.Calcolo combinatorio per la probabilità →): con soli 55 chiavi in N=11N=11 bucket, la probabilità che nessuna collida è 11⋅10⋅9⋅8⋅7115=55440161051≈0,34\frac{11\cdot10\cdot9\cdot8\cdot7}{11^5}=\frac{55440}{161051}\approx0{,}34, cioè c'è una collisione con probabilità ≈0,66\approx 0{,}66.

Cosa si chiede a una buona funzione hash

  1. hh deve imitare il più possibile un processo casuale (uniform hashing): per ogni k≠k′k \ne k' e ogni i,ji, j vale Pr⁡[h(k)=i]=1/N\Pr[h(k) = i] = 1/N e Pr⁡[h(k)=i∣h(k′)=j]=1/N\Pr[h(k) = i \mid h(k') = j] = 1/N (le chiavi finiscono in indici statisticamente indipendenti: è la nozione di Indipendenza di eventiA e B sono indipendenti se P(A ∩ B) = P(A) P(B), cioè se sapere che uno si è verificato non cambia la probabilità dell'altro; l'indipendenza passa ai complementari, non va confusa con l'incompatibilità, e per più eventi va richiesta su ogni sottofamiglia.Indipendenza di eventi →, e il condizionamento è quello di Probabilità condizionataLa probabilità di A sapendo che si è verificato B è P(A ∣ B) = P(A ∩ B) / P(B), con P(B) > 0; è una nuova misura di probabilità, e da essa seguono la regola del prodotto e la regola della catena.Probabilità condizionata →; in pratica ogni chiave è assegnata a un bucket scelto a caso con probabilità uniforme 1/N1/N);
  2. hh deve essere veloce da calcolare.

Hash code

  • Numeri: byte, short, char, int →\to int per cast; float →\to Float.floatToIntBits(k); long →\to (int)((k >> 32) + (int)k) (somma dei 32 bit alti e bassi: un semplice cast a int perderebbe metà dei bit); double →\to cast a int di Double.doubleToLongBits(k) oppure, come per long, somma delle due metà.

    Definizione (hash code per stringhe)

    • Stringhe S=s0s1…sk−1S = s_0 s_1 \dots s_{k-1}:
      • somma dei caratteri ∑si\sum s_i: non è buona: stop, spot, tops hanno lo stesso hash code (le permutazioni collidono);
      • polinomiale: ∑i=0k−1si ak−1−i\sum_{i=0}^{k-1} s_i \, a^{k-1-i} (con la regola di Horner); per parole inglesi vanno bene a=31,33,37,39,41a = 31, 33, 37, 39, 41 (a=31a = 31 nella classe String di Java);
      • cyclic shift: si somma ogni carattere dopo uno shift ciclico di 55 posizioni della somma parziale: h <- s0; for i <- 1 to k-1: h <- (h << 5) | (h >> 27); h <- h + s_i.

Perché la somma dei caratteri è debole: l'addizione è commutativa, quindi qualsiasi permutazione delle stesse lettere dà lo stesso hash code (stop, spot, tops hanno gli stessi caratteri, il cui codice numerico dipende dalla codifica scelta: Codifiche binarie e informazione non numericaBit, byte e multipli (potenze di 2 e di 10); codici BCD e Gray; caratteri ASCII, Unicode e UTF-8; ordine dei byte (little e big endian); bit di parità e codice di Hamming per rilevare e correggere errori.Codifiche binarie e informazione non numerica →). Il polinomio usa invece potenze diverse di aa per le diverse posizioni, quindi lo scambio di due lettere cambia il risultato. La regola di Horner calcola il polinomio con k−1k-1 moltiplicazioni e addizioni, senza elevare a potenza: h <- s0; for i <- 1 to k-1: h <- h*a + s_i. Per "abc" con a=31a=31: h=97h=97; 97⋅31+98=310597\cdot31+98=3105; 3105⋅31+99=963543105\cdot31+99=96354; lo stesso valore di 97⋅312+98⋅31+9997\cdot31^2+98\cdot31+99. Il costo è Θ(k)\Theta(k) per una stringa di kk caratteri, quindi "h(k)h(k) costa O(1)O(1)" vale se la chiave ha lunghezza limitata. Gli operatori di shift <<, >> sono quelli sui bit (Aritmetica binariaSomma e sottrazione in binario, overflow per senza segno (riporto) e per complemento a 2 (segni), flag del processore, moltiplicazione per somme e scorrimenti, algoritmo di Booth, divisione, shift logici e aritmetici.Aritmetica binaria →, Sistemi di numerazione posizionaliNotazione posizionale in base b; conversioni tra base 10, 2, 8 e 16 per interi (divisioni successive) e per parti frazionarie (moltiplicazioni successive); numeri periodici in binario.Sistemi di numerazione posizionali →).

Esempio. Con a=31a=31, "ab" ha hash code polinomiale 97⋅31+98=310597\cdot31+98=3105 e "ba" ha 98⋅31+97=313598\cdot31+97=3135, mentre con la somma dei caratteri entrambe valgono 195195.

Compression function

Definizione (compression function: divisione e MAD)

  • Metodo della divisione: i↦i mod Ni \mapsto i \bmod N. Se NN non è primo le correlazioni tra gli hash code tendono a conservarsi; ad esempio con N=2pN = 2^p conta solo gli ultimi pp bit e con N=10pN = 10^p solo le ultime pp cifre decimali. In pratica si sceglie NN primo e lontano da potenze di 22.
  • MAD (multiply-add-divide): i↦[(ai+b) mod p] mod Ni \mapsto [(a i + b) \bmod p] \bmod N con p>Np > N primo e a,b∈[0,p−1]a, b \in [0, p-1] scelti a caso, a>0a > 0.

Perché NN potenza di 22 è una cattiva scelta. Scrivere un numero in binario (Sistemi di numerazione posizionaliNotazione posizionale in base b; conversioni tra base 10, 2, 8 e 16 per interi (divisioni successive) e per parti frazionarie (moltiplicazioni successive); numeri periodici in binario.Sistemi di numerazione posizionali →): i mod 2pi \bmod 2^p sono esattamente gli ultimi pp bit di ii, quindi tutti i codici che hanno gli stessi ultimi pp bit collidono. Con N=8N=8 i codici 8,16,248, 16, 24 vanno tutti nel bucket 00; con N=11N=11 vanno nei bucket 8,5,28, 5, 2. Lo stesso accade con N=10pN=10^p e le ultime pp cifre decimali. Un NN primo non ha divisori in comune con la maggior parte degli schemi regolari dei dati, e li "mescola".

Esempio. Con N=11N=11 l'hash code 137137 dà 137 mod 11=5137\bmod11=5; con MAD (a=3a=3, b=5b=5, p=13p=13) l'hash code 77 dà [(3⋅7+5) mod 13] mod 11=0[(3\cdot7+5)\bmod13]\bmod11=0.

Separate chaining

Definizione (load factor e separate chaining). Ogni bucket è una mappa più piccola, realizzata con una lista. Il load factor è λ=n/N\lambda = n/N (nn entry, NN bucket): la lunghezza media di un bucket.

Esempio. Con n=9n=9 entry e N=11N=11 bucket: λ=9/11≈0,82\lambda=9/11\approx0{,}82. Perché λ\lambda è la lunghezza media: con nn entry distribuite sui NN bucket in modo uniforme, il numero di entry nel bucket ii ha Valore attesoIl valore atteso E[X] = Σ x p_X(x) è la media dei valori di X pesata con le loro probabilità (esiste se la serie converge assolutamente); per una funzione g vale E[g(X)] = Σ g(x) p_X(x) senza trovare la legge di g(X), ed E è lineare: E[aX + bY + c] = aE[X] + bE[Y] + c.Valore atteso → n⋅1N=λn\cdot\frac1N=\lambda (ogni entry cade nel bucket ii con probabilità 1/N1/N, e il valore atteso di una somma è la somma dei valori attesi).

get(k):    se esiste (k, x) in A[h(k)] restituisci x, altrimenti null
put(k, v): se esiste (k, x) in A[h(k)] sostituisci x con v e restituisci x
           altrimenti inserisci (k, v) in coda ad A[h(k)], n <- n + 1, restituisci null
remove(k): se esiste (k, x) in A[h(k)] toglila, n <- n - 1, restituisci x; altrimenti null

Si assume che il calcolo di h(k)h(k) costi O(1)O(1).

Proprietà (costo di una tabella hash: caso pessimo e medio). Per una tabella con nn entry get, put, remove costano Θ(n)\Theta(n), dominate dalla ricerca della chiave nel bucket: O(n)O(n) banale; Ω(n)\Omega(n) con tutte le entry nello stesso bucket e chiave cercata assente.

Caso medio. Sotto l'ipotesi di uniform hashing la complessità media è O(1+λ)O(1 + \lambda), nel senso: ricerca senza successo, media su tutti i valori di h(k)h(k) equiprobabili; ricerca con successo, media su tutte le chiavi presenti equiprobabili inserite una dopo l'altra senza cancellazioni. In pratica si impone λ<0,9\lambda < 0{,}9. Il load factor è il compromesso tra spazio e velocità: λ\lambda piccolo = tempo basso e spazio elevato; λ\lambda grande = tempo elevato e spazio ridotto. La qualità di hh determina quanto ci si avvicina all'ipotesi ideale.

Esempio. Con λ≈0,82\lambda\approx0{,}82 una ricerca senza successo costa in media circa 1+λ≈1,81+\lambda\approx1{,}8 confronti, mentre nel caso pessimo, con tutte le entry in un bucket, costa nn.

Da dove viene 1+λ1+\lambda. Ricerca senza successo di kk: si calcola h(k)h(k) (costo costante, il "11") e si confronta kk con tutte le entry del bucket h(k)h(k), in media λ\lambda: totale 1+λ1+\lambda. Ricerca con successo: la chiave cercata è stata inserita come jj-esima; quando è stata inserita nel suo bucket c'erano in media j−1N\frac{j-1}{N} altre entry davanti a lei (inserimento in coda), quindi ci vogliono in media 1+j−1N1+\frac{j-1}{N} confronti. Facendo la media sulle j=1,…,nj=1,\dots,n chiavi equiprobabili: 1n∑j=1n(1+j−1N)=1+n−12N<1+λ2\frac1n\sum_{j=1}^{n}\Big(1+\frac{j-1}{N}\Big)=1+\frac{n-1}{2N}<1+\frac{\lambda}{2} (si usa la somma dei primi interi, SommatorieIl simbolo di sommatoria, le sue proprietà (linearità, additività, cambio di indice) e le somme notevoli di Gauss e geometrica.Sommatorie →). Il grafico mostra che finché λ\lambda resta costante (per esempio sotto la soglia 0,750{,}75) il costo medio è una costante piccola, indipendente da nn.

Grafico interattivo: Confronti medi per una ricerca in una tabella hash con separate chaining in funzione del load factor: senza successo 1 + λ, con successo circa 1 + λ/2; a λ = 0,75 (soglia di rehashing) sono 1,75 e 1,375

Rehashing

Definizione (rehashing). Quando λ\lambda supera una soglia (ad esempio λ>0,75\lambda > 0{,}75):

  1. si crea un nuovo bucket array di capacità N′≥2NN' \ge 2N (se serve, si cerca un primo ≥2N\ge 2N);
  2. si sceglie una nuova funzione hash;
  3. si reinseriscono tutte le entry.

Esempio. Con N=11N=11 e soglia 0,750{,}75 il nono inserimento (9/11>0,759/11>0{,}75) causa il rehashing verso N′≥22N'\ge22, per esempio il primo 2323.

La condizione N′≥2NN' \ge 2N ammortizza il costo del rehashing sugli inserimenti che lo hanno preceduto: è lo stesso argomento dell'array estendibile che raddoppia (Liste, pile e codeRipasso degli ADT elementari: lista index-based (array) e position-based (lista doppiamente concatenata con sentinelle), pila LIFO, coda FIFO, deque, iteratori; interfacce, implementazioni e costi; array estendibile e coda circolare.Liste, pile e code →). Dopo il rehashing con N′=23N'=23 e n=9n=9 si ha λ=9/23≈0,39\lambda=9/23\approx0{,}39, e si può arrivare a n=17n=17 entry senza nuovo rehashing (17/23≈0,7417/23\approx0{,}74), mentre con n=18n=18 si supera la soglia (18/23≈0,7818/23\approx0{,}78): servono 99 inserimenti prima del prossimo rehashing, quindi il costo Θ(n)\Theta(n) del trasferimento è diviso su molti inserimenti. Il trasferimento di nn entry costa Θ(n)\Theta(n) perché le chiavi sono già distinte e non occorre cercarle prima di inserirle.

Esempio svolto

N=11N = 11, h(k)=(3k+5) mod 11h(k) = (3k + 5) \bmod 11, separate chaining, chiavi inserite nell'ordine 12,44,13,88,23,94,11,39,2012, 44, 13, 88, 23, 94, 11, 39, 20:

kk 12 44 13 88 23 94 11 39 20
3k+53k + 5 41 137 44 269 74 287 38 122 65
h(k)h(k) 8 5 0 5 8 1 5 1 10

Contenuto finale (bucket vuoti omessi): A[0]=(13)A[0] = (13); A[1]=(94,39)A[1] = (94, 39); A[5]=(44,88,11)A[5] = (44, 88, 11); A[8]=(12,23)A[8] = (12, 23); A[10]=(20)A[10] = (20). Le collisioni sono nei bucket 1, 5 e 8. Load factor λ=9/11≈0,82\lambda = 9/11 \approx 0{,}82. La ricerca di 1111 confronta 4444, 8888, 1111: tre confronti. Passaggi per k=94k=94: 3⋅94+5=2873\cdot94+5=287 e 287=26⋅11+1287=26\cdot11+1, quindi il resto è 11 e 9494 va in A[1]A[1]; per k=39k=39: 3⋅39+5=122=11⋅11+13\cdot39+5=122=11\cdot11+1, anche in A[1]A[1], dietro a 9494 (inserimento in coda). Lunghezze dei bucket: 1,2,3,2,11,2,3,2,1 (somma 99, uguale a nn); gli altri 66 bucket sono vuoti.

Pro e contro

Pro: implementazione facile; ottime prestazioni al caso medio; non richiede un universo ordinato. Contro: complessità elevata al caso pessimo; esito dipendente dalla bontà della funzione hash; spreco di spazio per mantenere λ\lambda basso.

Errori comuni

  • Dire che get è O(1)O(1) al caso pessimo: lo è in media, con λ\lambda costante.
  • Scegliere NN potenza di 22 o di 1010 con la divisione.
  • Dimenticare che dopo il rehashing la funzione hash cambia: ogni entry va reinserita, non copiata nella stessa posizione.
  • Usare la somma dei caratteri come hash code di stringhe: gli anagrammi collidono.

Versione ripasso

  • Definizione: implementazione della mappa (vedi Mappe e dizionariADT mappa (chiavi distinte) con get, put, remove, keySet/values/entrySet; famiglia mappa/mappa ordinata/dizionario (multimappa); applicazioni; implementazioni semplici ma poco efficienti (lista, array indicizzato dalle chiavi); dizionario realizzato con una mappa di liste.Mappe e dizionari →) con tempo medio costante. Tre componenti: funzione hash h:U→[0,N−1]h: U\to[0,N-1] (hash code k↦Zk\mapsto\mathbb{Z} seguito da una compression function); bucket array AA di capacità NN, con A[i]A[i] che contiene le entry con h(k)=ih(k)=i; gestione delle collisioni (k1≠k2k_1\ne k_2 con h(k1)=h(k2)h(k_1)=h(k_2)).
  • Esempio: con N=11N=11 e h(k)=(3k+5) mod 11h(k)=(3k+5)\bmod11 la chiave 1212 dà 41=3⋅11+841=3\cdot11+8, quindi bucket 88.
  • Collisioni inevitabili: ∣U∣≫N|U|\gg N, quindi hh non è iniettiva (principio dei cassetti). Con 55 chiavi in 1111 bucket, la probabilità che nessuna collida è 11⋅10⋅9⋅8⋅7115≈0,34\frac{11\cdot10\cdot9\cdot8\cdot7}{11^5}\approx0{,}34 (vedi Calcolo combinatorio per la probabilitàNegli spazi uniformi bisogna contare: principio di moltiplicazione, disposizioni con ripetizione n^k, disposizioni semplici n!/(n−k)!, permutazioni n!, combinazioni (n su k); estrazioni con e senza reinserimento e distribuzione ipergeometrica.Calcolo combinatorio per la probabilità →).
  • Buona funzione hash: (1) uniform hashing, cioè Pr⁡[h(k)=i]=1/N\Pr[h(k)=i]=1/N e indipendenza tra chiavi diverse (vedi Indipendenza di eventiA e B sono indipendenti se P(A ∩ B) = P(A) P(B), cioè se sapere che uno si è verificato non cambia la probabilità dell'altro; l'indipendenza passa ai complementari, non va confusa con l'incompatibilità, e per più eventi va richiesta su ogni sottofamiglia.Indipendenza di eventi →); (2) velocità di calcolo.
  • Hash code per i numeri: int così com'è; long sommando le due metà a 3232 bit (un cast diretto perderebbe metà dei bit); float e double tramite la rappresentazione in bit.
  • Hash code per stringhe:
    • la somma dei caratteri è cattiva: stop, spot, tops collidono;
    • polinomiale ∑isi ak−1−i\sum_i s_i\,a^{k-1-i} con a=31a=31, calcolato con Horner (h <- h*a + s_i): per "abc" si ottiene 9635496354;
    • cyclic shift di 55 bit sulla somma parziale, poi somma del carattere successivo.
    • Esempio: "ab" dà 97⋅31+98=310597\cdot31+98=3105, "ba" dà 31353135; con la somma entrambe valgono 195195.
  • Compression, divisione: i mod Ni\bmod N con NN primo e lontano da potenze di 22: con N=8N=8 i codici 8,16,248, 16, 24 finiscono tutti nel bucket 00. Esempio: con N=11N=11, 137 mod 11=5137\bmod11=5.
  • Compression, MAD: [(ai+b) mod p] mod N[(ai+b)\bmod p]\bmod N, con p>Np>N primo, a,b∈[0,p−1]a,b\in[0,p-1] scelti a caso e a>0a>0. Esempio: a=3a=3, b=5b=5, p=13p=13, N=11N=11 con hash code 77 dà bucket 00.
  • Separate chaining: ogni bucket è una lista. Load factor λ=n/N\lambda=n/N, cioè la lunghezza media di un bucket; con n=9n=9 e N=11N=11 è λ≈0,82\lambda\approx0{,}82. get, put e remove scorrono la lista del bucket A[h(k)]A[h(k)]; put aggiunge in coda.
  • Costo: caso pessimo Θ(n)\Theta(n), con tutte le entry nello stesso bucket. Caso medio sotto uniform hashing O(1+λ)O(1+\lambda); in pratica si tiene λ<0,9\lambda<0{,}9.
    • Ricerca senza successo: in media 1+λ1+\lambda confronti.
    • Ricerca con successo: in media 1+n−12N<1+λ21+\frac{n-1}{2N}<1+\frac{\lambda}{2}, mediando sulle nn chiavi inserite in coda.
  • Rehashing: se λ\lambda supera una soglia (ad esempio 0,750{,}75) si crea un array di capacità N′≥2NN'\ge 2N (dopo N=11N=11, ad esempio 2323), si sceglie una nuova hh e si reinseriscono tutte le entry: Θ(n)\Theta(n), ammortizzato sugli inserimenti precedenti.
  • Esempio svolto: N=11N=11, h=(3k+5) mod 11h=(3k+5)\bmod11, chiavi 12,44,13,88,23,94,11,39,2012,44,13,88,23,94,11,39,20 danno bucket 8,5,0,5,8,1,5,1,108,5,0,5,8,1,5,1,10. Contenuto: A[0]=(13)A[0]=(13), A[1]=(94,39)A[1]=(94,39), A[5]=(44,88,11)A[5]=(44,88,11), A[8]=(12,23)A[8]=(12,23), A[10]=(20)A[10]=(20); la ricerca di 1111 fa 33 confronti.
  • Pro/contro: semplice e veloce in media, non richiede un universo ordinato; caso pessimo lineare, dipende dalla qualità di hh, spreco di spazio per tenere λ\lambda basso.
  • Errori tipici: dire che get è O(1)O(1) nel caso pessimo; scegliere NN potenza di 22 o di 1010; copiare le entry invece di reinserirle dopo il rehashing; usare la somma dei caratteri come hash di una stringa.

Esercizi su questo argomento

Teoria collegata