Hash Tables Indirizzamento diretto Tabelle Hash Risoluzioni di collisioni Indirizzamento aperto Indirizzamento diretto U (Insieme di tutte le chiavi) T[0,…,8] 2 8 7 --- 0 --- 1 --- 2 0 3 --- 1 3 K 5 (chiavi effettive) 6 dati aggiuntivi key 3 4 5 5 6 6 --- 7 --- 8 L’indirizzamento diretto viene utilizzato quando l’insieme delle chiavi U è piccolo. Indirizzamento diretto •L’indirizzamento diretto viene rappresentato, generalmente, da un array, dove ogni casella corrisponde ad una chiave (key) dell’insieme U. •Ogni casella dell’array deve corrispondere ad un elemento distinto e unico dell’insieme delle chiavi U. •La casella T[key] punta all’elemento con la chiave key e le sue informazioni aggiuntive. •Se T[key]=NIL, allora non esiste nessun elemento da mappare con chiave key. •Se |U| non è grande, l’array non raggiunge grosse dimensioni, utilizzando efficacemente l’indirizzamento diretto. Indirizzamento diretto Operazioni: DIRECT-ADDRESS-SEARCH(T,key) 1. return T[key] DIRECT-ADDRESS-INSERT(T,key) 1. T[key] ← key DIRECT-ADDRESS-DELETE(T,key) 1. T[key] ← NIL Queste tre operazioni richiedono tempo O(1). Hash Tables •L’indirizzamento diretto diventa difficoltoso quando U risulta essere molto grande. •Inoltre, se |K| << |U|, ci sarebbe un notevole spreco di spazio di memoria. Esempio: Prendiamo un dizionario della lingua italiana di circa 130.000 parole con lunghezza massima di 15 lettere. Se U corrisponde all’insieme di tutte le stringhe di lettere L={a, b, c,…, z} di lunghezza massima di 15, sia ha: 15 | U | L i 1 i L 151 1 L 1 | K | 130.000 71.528.434.512.099.266.416 Parole presenti nel dizionario se |L|=21 Hash Tables T[0, 1, …, m-1] --- U (Insieme di tutte le chiavi) ----h(key0)= h(key1) --- key K (chiavi effettive) 0 h(key2) key 1 key h(key3) key 2 3 ----- Viene definita una funzione hash che calcola la casella da associare a ciascuna chiave (keyi). h: U → {0, 1, 2, …, m-1} Si dice, che h(keyi) è il valore hash della chiave (keyi). Hash Tables • Tramite le Hash Tables, la quantità di memoria necessaria può essere ridotta a Θ(|K|), mantenendo un costo della ricerca di O(1) nel caso medio. • C’è la possibile presenza di collisioni, ossia quando due chiavi distinte hanno lo stesso valore hash. keyi≠keyj e h(keyi)=h(keyj) • Se la funzione hash distribuisse in modo uniforme le chiavi nelle differenti celle, la probabilità di collisioni sarebbe minima. • Essendo |U|>m (con m la dimensione di T), evitare del tutto le collisioni è impossibile. Gestire le collisioni • Una possibile soluzione è quella di inserire tutti gli elementi con lo stesso valore hash in una lista concatenata. T[0, 1, …, m-1] U (Insieme di tutte le chiavi) ------key0 --- key K (chiavi effettive) key1 0 key2 - key 1 key key3 - key 3 2 ----- - Gestire le collisioni Operazioni: CHAINED-HASH-SEARCH(T,key) 1. ricerca un elemento con chiave ‘key’ nella lista T[h(key)] CHAINED-HASH-INSERT(T,x) 1. inserisci l’elemento x all’inizio della lista T[h(key)] CHAINED-HASH-DELETE(T,x) 1. elimina l’elemento x nella lista T[h(key)] •La cella T[h(key)] contiene un puntatore all’inizio della lista concatenata in cui vengono inseriti tutti i valori con lo stesso valore hash. •Se nessun elemento è stato ancora inserito, T[h(key)] contiene NIL. Gestire le collisioni • L’inserimento richiede un tempo O(1). • La ricerca richiede un tempo proporzionale alla lunghezza della lista nel caso peggiore. • Per quanto riguarda la rimozione, può essere compiuta in tempo O(1) quando si usa una lista bidirezionale (doubly linked list). • Se la lista concatenata è unidirezionale, per poter eseguire la rimozione di un elemento bisogna fare una ricerca per conoscere l’elemento che precede x. Il tempo quindi è proporzionale alla lunghezza della lista nel caso peggiore. Analisi di hashing con liste concatenate • Nel caso peggiore tutti gli elementi hanno lo stesso valore di hash e quindi tutto viene gestito come se fosse una lista concatenata. • Nella nostra analisi si fa l’assunzione di uniformità semplice della funzione hash. Questo vuol dire che la funzione hash distribuisce bene l’insieme delle chiavi da memorizzare sulle m celle. • Si assume anche che il calcolo della funzione hash h(key) è costante,O(1). • Si assume, inoltre, che l’accesso alle celle si fatto in tempo costante, O(1). • Si definisce il fattore di carico α per T come n/m, cioè il numero medio di elementi in ogni lista quando ne sono stati inseriti n in totale. Analisi di hashing con liste concatenate Sotto queste ipotesi, in una tabella hash che risolve le collisioni per concatenazione una ricerca senza successo richiede un tempo medio di Θ(1+ α). a. b. c. Le chiavi vengono equamente distribuite per tutte le m celle. Dunque, la lunghezza media delle liste equivale a α. Per cui il numero medio di elementi da esaminare in una ricerca senza successo è pari a α. A questo si aggiunge il tempo di calcolo di h(key), ottenendo un tempo medio di Θ(1+ α). Analisi di hashing con liste concatenate Sotto queste ipotesi, in una tabella hash che risolve le collisioni per concatenazione una ricerca con successo richiede un tempo medio di Θ(1+ α). a. Le chiavi vengono equamente distribuite per tutte le m celle. b. L’analisi è più complessa, in quanto dipende in quale punto della lista mediamente si trova l’elemento x da trovare. c. Per eseguire questo calcolo più agilmente si suppone che gli elementi vengono inseriti alla fine della lista. Questo non cambia il risultato. Analisi di hashing con liste concatenate d. Sotto questa ipotesi, quando l’elemento i-esimo è inserito, esso viene messo in una lista lunga in media (i-1)/m. e. Per trovare l’elemento i-esimo, bisogna calcolare h(keyi) ed esaminare mediamente (i-1)/m elementi. f. Dunque il tempo medio complessivo è: 1 n 1 n i 1 1 n T (n) Tempo(i ) (1 ) 1 (i 1) n i 1 n i 1 m nm i 1 1 n(n 1) 1 1 1 (1 ) nm 2 2 2m Analisi di hashing con liste concatenate Il significato di questa analisi? •Se gli elementi all’interno della tabella hash sono n e risultano proporzionali a m, si ha n=O(m) e, quindi, α = O(m)/m = O(1). •Quindi, la ricerca media richiede in media tempo costante. •Essendo la ricerca l’operazione più costosa, si ottiene un tempo medio per tutte le operazioni pari a O(1). Requisiti per una buona funzione hash L’assunzione di uniformità semplice della funzione hash equivale a supporre che ogni chiave ha uguale probabilità di essere collocata in una qualsiasi cella. Più formalmente, supponiamo che ogni chiave key è pescata indipendentemente dalle altre da U con probabilità P(key). Allora si ha: P(key) key:h ( key ) j 1 m Raramente, si è a conoscenza della distribuzione P. Per questo motivo si utilizzano in pratica delle euristiche che possono produrre una buona funzione hash. Il metodo della divisione Nel metodo della divisone si utilizza il resto di key diviso m: h(key) = key mod m La scelta di m deve essere fatto in maniera opportuna, che dipende, in genere, dalla tipologia di chiavi (numeri decimali, i byte di caratteri ASCII,…). Una buona scelta è quella di scegliere un numero primo non vicino a una potenza di 2. Per esempio, se n= 2000 e le chiavi sono equamente probabili, il numero primo 701, che è vicino a 2000/3, può essere una buona scelta. Si ha quindi: h(key) = key mod 701 Il metodo della moltiplicazione Richiede due passi: 1. Si moltiplica K per una costante A, 0 < A < 1, e si estrae la parte non intera. 2. Si moltiplica il risultato per m e si prende la parte intera. In breve: h(key) m((key A)mod1) La scelta di m non è più critica. Quindi, si può sceglie un valore che renda la moltiplicazione efficiente. Anche se la funzione hash è buona per ogni valore di A, ci sono alcune scelte migliori. Per esempio: A≈(√5 – 1)/2=0.6180339887… Indirizzamento aperto • Nell’indirizzamento aperto ogni cella della tabella contiene o un elemento dell’insieme U o NIL. • Quando si ricerca un elemento, si esaminano sistematicamente le posizioni della tabella finché o si trova l’elemento desiderato o è chiaro che non è presente nella tabella. • Non ci sono puntatori e liste concatenate, tutto è memorizzato nella tabella. • Quindi, la tabella hash può riempirsi finché nessun altro elemento può essere inserito. • Il fattore di carico α ≤ 1. Scansione nell’indirizzamento aperto Quando si ricerca o si inserisce un elemento, si esaminano in sequenza le posizioni della tabella finché o si trova l’elemento desiderato o si trova NIL. La sequenza <s0, s1 , s2 , …, sm-1,> è una permutazione degli indici <0, 1, 2, …, m-1> della tabella e dipende dalla chiave key scelta. Quindi la funzione hash è del tipo: h: U x {0,1, 2, …, m-1} → {0,1, 2, …, m-1} E la sequenza di scansione è: <h(key,0), h(key,1), …, h(key,m-1)> Dunque, si ha un problema di quante posizioni della tabella in media si vengono visitate. Inserimento nell’indirizzamento aperto HASH-INSERT(T,key) 1. i←0 2. repeat j ← h(key,i) 3. if T[j] = NIL 4. then T[j] ← key 5. return j 6. else i ← i+1 7. until i = m 8. error “overflow sulla tabella hash” La ricerca della posizione termina quando si trova una posizione vuota. Ricerca nell’indirizzamento aperto HASH-SEARCH(T,key) 1. i←0 2. repeat j ← h(key,i) 3. if T[j] = key 4. then return j 5. else i ← i+1 6. until T[j] = NIL o i = m 7. return NIL La ricerca della chiave termina quando si trova la chiave o una posizione vuota, poiché key sarebbe stata inserita proprio li. Ipotesi di uniformità della funzione hash • Nell’ipotesi di uniformità della funzione hash si considera equamente probabile una qualunque delle m! permutazioni delle posizioni data la chiave key. • Questa ipotesi generalizza quella di uniformità semplice, in quanto viene restituita dalla funzione hash non un solo valore di {0, 1, 2, …, m-1}, ma un’intera sequenza. • Per questo motivo, risulta difficile generare una buona funzione hash. • Ci sono alcune approssimazioni: la scansione lineare, quella quadratica e il doppio hash. Scansione lineare Data una funzione hash h’: U → {0, 1, …, m-1}, il metodo di scansione lineare usa la funzione hash: h(key,i) = (h’(key) + i) mod m •La scansione lineare è facile da realizzare. •Presenta un problema conosciuto come fenomeno di agglomerazione primaria. •Le posizioni occupate si accumulano in lunghi tratti, aumentando il tempio medio di ricerca. Scansione quadratica Data una funzione hash h’: U → {0, 1, …, m-1}, il metodo di scansione quadratica usa la funzione hash h(key,i) = (h’(key) + c1 i + c2 i2) mod m •Le scansioni successive dipendono in modo quadratico dal numero i di accessi. •Questo metodo funziona meglio della scansione lineare, ma i valori c1, c2 e m devono essere scelti in modo che la sequenza scansioni l’intera tabella. •Come per la scansione lineare, l’accesso iniziale determina l’intera sequenza, quindi sono usate solo m distinte sequenze di scansione. Per questo si dice che c’è un problema di agglomerazione secondaria. Hashing doppio Il metodo di hashing doppio usa una funzione hash del tipo: h(key,i) = (h1(key) + i h2(key)) mod m •Diversamente dalla scansione lineare o quadratica, la sequenza di scansione dipende dalla posizione iniziale e dalla distanza determinata dalla seconda funzione di hash. Per esempio, si può usare: h1 = key mod m h2 = 1+ (key mod m’) •Questo permette di usare Θ(m2) sequenze di scansione, avvicinandosi all’assunzione di uniformità della funzione di hash. Analisi dell’indirizzamento aperto Diamo solo i risultati: • Data una tabella hash a indirizzamento aperto con fattore di carico α=n/m <1, il numero medio di accessi di una ricerca senza successo è al più 1/(1-α), assumendo l’uniformità della funzione hash. • Data una tabella hash a indirizzamento aperto con fattore di carico α=n/m <1, il numero medio di accessi di una ricerca con successo è al più 1/α ln(1/(1-α)), assumendo l’uniformità della funzione hash e che ogni chiave sia ricercata nella tabella in modo equamente probabile.