Hash Tables Indirizzamento diretto Tabelle Hash Risoluzioni di collisioni Indirizzamento aperto
Indirizzamento ? Problema dell’indirizzamento: Si hanno una serie di chiavi (key) con una serie di dati associati: come gestire la memorizzazione e la ricerca (lookup) di questi dati? (es. dati: insieme di record) dati aggiuntivi key ? Davide Bianchi +39 339 269577 Venezia Gianni Lamberti +39 328 456721 Torino … … … Valeria Rossi +39 0577 234567 Siena Marco Rei +39 347 277577 Napoli Insieme di RECORD
Indirizzamento O(1) Scopo: Avere tempi di ricerca (lookup) rapidi, constante O(1). Ottimizzare l’uso della memoria. dati aggiuntivi Lookup O(1) key Davide Bianchi +39 339 269577 Venezia Gianni Lamberti +39 328 456721 Torino … … … Valeria Rossi +39 0577 234567 Siena Marco Rei +39 347 277577 Napoli
Indirizzamento diretto Soluzione semplice: Crea uno spazio di memoria per ogni possibile chiave. Ci sarà un associazione diretta tra la chiave e lo spazio assegnato al relativo record. CHIAVI INDICI RECORD Gianni Lamberti 1 Davide Bianchi +39 339 269577 Venezia Valeria Rossi 2 Gianni Lamberti +39 328 456721 Torino … … … … Marco Rei 998 Valeria Rossi +39 0577 234567 Siena 999 Marco Rei +39 347 277577 Napoli Davide Bianchi
Indirizzamento diretto U (Insieme di tutte le chiavi) T[0,…,8] --- dati aggiuntivi --- 1 key 2 --- 2 8 3 3 7 --- 4 1 5 5 K (chiavi effettive) 3 6 6 5 --- 7 6 --- 8 Per semplificare prendiamo come chiavi (key) i numeri interi. 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) return T[key] DIRECT-ADDRESS-INSERT(T,key) T[key] ← key DIRECT-ADDRESS-DELETE(T,key) T[key] ← NIL Queste tre operazioni richiedono tempo O(1).
Hash Tables Problema: L’indirizzamento diretto diventa inefficiente quando U risulta essere molto grande. Si utilizza molta memoria! 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: se |L|=21 Parole presenti nel dizionario
(Insieme di tutte le chiavi) Hash Tables T[0, 1, …, m-1] --- U (Insieme di tutte le chiavi) --- --- h(key0)= h(key1) --- key0 h(key2) K (chiavi effettive) key1 h(key3) key2 --- key3 --- 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.
(Insieme di tutte le chiavi) 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 key1 - --- key0 K (chiavi effettive) key2 - key1 key3 - key2 --- key3 ---
Gestire le collisioni Operazioni: CHAINED-HASH-SEARCH(T,key) ricerca un elemento con chiave ‘key’ nella lista T[h(key)] CHAINED-HASH-INSERT(T,x) inserisci l’elemento x all’inizio della lista T[h(key)] CHAINED-HASH-DELETE(T,x) 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 sia fatto in tempo costante, O(1).
Ricerca senza successo Definizione: 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. Ipotesi: uniformità semplice della funzione hash + calcolo funzione hash e accesso alle celle in tempo O(1) → Si ha che in una tabella hash che risolve le collisioni per concatenazione una ricerca senza successo richiede un tempo medio pari a: Θ(1+ α)
Ricerca senza successo CALCOLO FUNZ. HASH + ACCESSO ALLA CELLA RICERCA NELLA LISTA CONCATENATA Θ(1+ α) 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+ α).
Ricerca con successo Con le stesse ipotesi, in una tabella hash che risolve le collisioni per concatenazione una ricerca con successo richiede un tempo medio di Θ(1+ α). Le chiavi vengono equamente distribuite per tutte le m celle. L’analisi è più complessa, in quanto dipende in quale punto della lista mediamente si trova l’elemento x da trovare. 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 Sotto questa ipotesi, quando l’elemento i-esimo è inserito, esso viene messo in una lista lunga in media (i-1)/m. Per trovare l’elemento i-esimo, bisogna calcolare h(keyi) ed esaminare mediamente (i-1)/m elementi. Dunque il tempo medio complessivo è:
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: 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 divisione Nota: ricordatevi che ogni chiave ha una sua rappresentazione binaria! Cattiva scelta di m: m=2r 0 ≤ Key < 2w = (bw2w+… ) + … + (b727+b626 +b525+b424) + (b323+b222 +b121+b020) Key = 1011 1001 0001 11002 0 ≤ h(key) < 2r Se w=16 (16-bit) ed r=6 (m=64), per determinare il valore di h(key) viene utilizzata solo una piccola frazione dei bit che compongono la chiave. Molta informazione viene buttata via!
Il metodo della moltiplicazione Richiede due passi: Si moltiplica K per una costante A, 0 < A < 1, e si estrae la parte non intera. Si moltiplica il risultato per m e si prende la parte intera. In breve: 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.
Esempio inserimento Inserimento chiave key=321: Scansione numero 0 T[0,…,m-1] 0. Prova h(key,0) --- --- 1 --- 201 --- 367 COLLISIONE ! 546 --- --- m-1
Esempio inserimento Inserimento chiave key=321: Scansione numero 1 T[0,…,m-1] 0. Prova h(key,0) --- 1. Prova h(key,1) --- 1 --- COLLISIONE ! 201 --- 367 546 --- --- m-1
Esempio inserimento Inserimento chiave key=321: Scelta numero 2… OK! T[0,…,m-1] 0. Prova h(key,0) --- 1. Prova h(key,1) --- 1 --- 2. Prova h(key,2) 201 --- 367 546 --- VUOTO! --- m-1
Esempio inserimento Chiave inserita! 0. Prova h(key,0) T[0,…,m-1] 0. Prova h(key,0) --- 1. Prova h(key,1) --- 1 --- 2. Prova h(key,2) 201 --- 367 546 Viene effettuata una scansione su tutte le celle fino a trovare uno spazio disponibile. 321 INSERITO! --- m-1
Inserimento nell’indirizzamento aperto HASH-INSERT(T,key) i ← 0 repeat j ← h(key,i) if T[j] = NIL then T[j] ← key return j else i ← i+1 until i = m error “overflow sulla tabella hash” La ricerca della posizione termina quando si trova una posizione vuota.
Ricerca nell’indirizzamento aperto HASH-SEARCH(T,key) i ← 0 repeat j ← h(key,i) if T[j] = key then return j else i ← i+1 until T[j] = NIL o i = m 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.