Salta al contenuto
Note per Studenti Tabelle hash

Tabelle hash

In questa pagina 8

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).

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);
  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à.
  • 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.

Compression function

  • 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.

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.

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).

Caso pessimo. 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.

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.

La condizione N′≥2NN' \ge 2N ammortizza il costo del rehashing sugli inserimenti che lo hanno preceduto. 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.

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

  • Componenti: funzione hash hh = hash code + compression function →[0,N−1]\to [0, N-1]; bucket array AA di capacità NN; gestione delle collisioni (k1≠k2k_1 \ne k_2 con h(k1)=h(k2)h(k_1) = h(k_2)). Realizza la 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 →).
  • Obiettivi di hh: uniform hashing (Pr⁡[h(k)=i]=1/N\Pr[h(k) = i] = 1/N, indipendenza) e velocità.
  • Hash code: numeri per cast o somma di metà alte e basse; stringhe: somma (cattiva: stop, spot, tops collidono), polinomiale ∑siak−1−i\sum s_i a^{k-1-i} (a=31a = 31 in Java), cyclic shift di 55.
  • Compression: divisione i mod Ni \bmod N (NN primo, lontano da potenze di 22); MAD [(ai+b) mod p] mod N[(ai + b) \bmod p] \bmod N, p>Np > N primo.
  • Separate chaining: bucket = lista; λ=n/N\lambda = n/N. get/put/remove: caso pessimo Θ(n)\Theta(n) (tutte nello stesso bucket); caso medio O(1+λ)O(1 + \lambda) sotto uniform hashing; in pratica λ<0,9\lambda < 0{,}9.
  • Rehashing se λ\lambda supera la soglia (es. 0,750{,}75): nuovo array N′≥2NN' \ge 2N, nuova hh, reinserimento di tutte le entry in Θ(n)\Theta(n); ammortizzato.
  • Esempio: N=11N = 11, h=(3k+5) mod 11h = (3k + 5) \bmod 11, chiavi 12,44,13,88,23,94,11,39,2012, 44, 13, 88, 23, 94, 11, 39, 20 →\to 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); λ=9/11\lambda = 9/11.
  • Pro/contro: semplice, veloce in media, nessun ordine richiesto / caso pessimo lineare, dipende da hh, spazio per λ\lambda basso.
  • Errori: O(1)O(1) al caso pessimo; N=2pN = 2^p; copiare invece di reinserire dopo il rehashing; somma dei caratteri come hash di stringa.

Esercizi su questo argomento

Teoria collegata