Sei sulla pagina 1di 18

Hash table

Con array/liste è facile implementare tante operazioni da fare.


Con ciascuna di queste strutture certe operazioni hanno complessità O(N)

Le tabelle hash hanno solo operazioni di base, ma ciascuna di queste ha costo


costante!

Tavola a indirizzamento diretto


è una idea preliminare rispetto alla tabella hash.

Immaginiamo di avere U come universo delle chiavi U = {0, 1, ..., m − 1}

💡 se le chiavi non sono degli interi consecutivi allora bisogna


trasformarle con qualche funzione

L’insieme dinamico viene rappresentato con un array T di dimensione m in cui


ogni posizione corrisponde ad una chiave.

Per questo motivo viene chiamata tavola a indirizzamento diretto, accedere a


ciascun elemento è una operazione infatti che costa O(1)

Esempio:

Siccome l’universo delle chiavi va da 0 a 9, abbiamo 10 elementi nell’array T e


ciascuno di questi può puntare ad un dato che ha una chiave corrispondente oppure

Hash table 1
ad un NIL (elemento nullo).
Le operazioni in una tavola a indirizzamento diretto sono semplicissime!

💡 Ovviamente tutte le operazioni hanno tempo costante O(1)

💡 La ricerca di un elemento con chiave k restituisce semplicemente


l’elemento T[k] che può essere nil oppure contiene l’elemento desiderato

Sembra una struttura molto efficiente, ma dal punto di vista dello spazio può
non risultare efficiente!

Infatti la quantità di memoria occupata dalla struttura dati dipende dai contesti in
cui viene utilizzata, se la tabella è molto grande rispetto alla quantità di dati
memorizzati realmente, la struttura non sarà efficiente.

Ad esempio, ipotizziamo di usare una tabella a indirizzamento diretto per


rappresentare studenti identificati da una matricola di 6 cifre, quindi abbiamo 106
possibili chiavi.

Quindi, ipotizzando che un puntatore occupi 8 byte, T occuperà 8 ∗ 106 byte di


memoria

Di ogni studente inoltre si memorizza 105 byte di dati, ossia 100kb

Immaginiamo di avere 20000 studenti

Lo spazio occupato ma non usato (ossia i nil) :

8 ∗ (106 − 20000) = 7840000B = 7.84MB


La percentuale di spazio sprecato rispetto allo spazio totale sarebbe:

Hash table 2
Ossia circa lo 0.4%

💡 Quindi in questo esempio specifico lo spazio sprecato è accettabile, e la


struttura può essere implementata.

Ma se memorizzassi soltanto 1kb di dati per ciascuno studente:

Quindi il 28% della memoria è occupata inutilmente!

E se abbasso il numero di studenti da 20000 a 200

Ossia il 95.6% della memoria è occupata inutilmente!

💡 In questi due ultimi casi , la struttura dati non è ragionevole come scelta, è
troppo costosa dal punto di vista dello spazio per implementarla!

Tavole hash
Quindi, l’indirizzamento diretto non è realizzabile se l’universo delle chiavi è troppo
grande

in ogni caso non è efficiente dal punto di vista della memoria utilizzata, può
capitare che troppa memoria sia usata per i puntatori nil.

L’idea è quella di utilizzare una tabella T con dimensione m con dimensione


molto più piccola di ∣U∣

Hash table 3
Non c’è più una corrispondenza diretta tra la posizione della tabella T e una
chiave di U , ma la posizione della chiave k è determinata usando una funzione:

Che viene chiamata funzione hash.

Esempio di tavola hash

💡 La funzione hash è definita come h(k) = k mod 5 ossia ogni posizione


nella tabella è determinata dal resto della chiave k con 5, perchè l’array ha
5 posizioni

Quindi nel caso delle tavole hash l’indirizzamento non è più diretto.

L’elemento con chiave k si trova nella posizione h(k)

Come conseguenze della tabella hash abbiamo:

riduciamo lo spazio utilizzato.

ma si perde la diretta corrispondenza fra chiave e posizioni, e l’universo ha un


numero di elementi maggiore della lunghezza di T, di conseguenza due chiavi
possono corrispondere alla stessa posizione nell’array T.

quindi possono esserci delle collisioni!

Hash table 4
💡 Nell’esempio precedente, le coppie (0, 5), (1, 6), (2, 7), (3, 8) sono in
collisione! infatti il resto della divisone per 5 è uguale per i due elementi
della coppia!

Una buona funzione di hash cerca di tenere al minimo il numero di collisioni,


posiziona le chiavi in 0, 1, ..., m − 1 in modo uniforme, per ridurre al minimo le
collisioni!
L’hash perfetto sarebbe una funzione che non crei mai collisioni, ossia una funzione
iniettiva:

= k2 ⟹ h(k1 ) 
k1  = h(k2 )

Ma se ∣U∣ > m allora l’hash perfetto si può realizzare solo se l’insieme da


rappresentare non è dinamico! In tutti gli altri casi non esiste l’hash perfetto.

Gestione delle collisioni


Come si gestiscono le collisioni?
Una possibile soluzione è concatenare gli elementi in collisione in una lista!

Esempio:

💡 Sicuramente le chiavi 2 e 7 andranno in collisione tra loro, perchè il resto


di questi due numeri per 5 è 2!

Ogni volta che un elemento va in collisione, si inserisce nella lista puntata dall’hash
dell’elemento, ma l’inserimento viene sempre effettuato in testa! In questo modo non
devo scorrere tutta la lista e ho O(1)

Hash table 5
Operazioni nelle tavole hash con concatenamento:
Inserimento:

💡 La funzione ListInsert fa riferimento ad un inserimento in testa


dell’elemento x nella lista L!

💡 Con la funzione di hash h andiamo ad individuare la posizione nella


tabella T della lista che dovrà contenere l’elemento da inserire.

Ricerca:

Cancellazione:

💡 Le tre operazioni di base sono molto semplici , ma cosa si può dire


relativamente alla loro complessità?

Complessità delle operazioni in una tabella hash


Per individuare la lista, bisogna calcolare il valore hash di una chiave, e assumiamo
che il tempo di calcolo di hash sia costante, O(1)

Hash table 6
La ricerca di un elemento di chiave k richiede del tempo proporzionale alla
lunghezza della lista ∣T (h(k))∣

Il costo della ricerca dipende quindi dal numero di elementi e dalle caratteristiche
della funzione hash.
Mentre per quanto riguarda la cancellazione, essa richiede O(1) perchè assumiamo
di aver già individuato l’elemento.

Costo di una ricerca:


Notazione:

m indica il numero di celle in T


N indica il numero di elementi memorizzati
N
α= m indica il fattore di carico della tabella hash.
Qual’è il caso peggiore per una ricerca in una tabella hash?
Immaginiamo di avere come universo delle chiavi tutte le matricole di 6 cifre, m =
200 e come funzione hash h(k) = k mod 200
Un possibile elenco di inserimento che rende successivamente pesante la ricerca è:

000123, 100323, 123723, 343123, 333123, ...


Infatti tutte queste chiavi sono associate alla stessa cella di T

Nel caso peggiore quindi la ricerca costa θ(N)

Mentre il caso migliore?

Quando la lista T (h(k)) è vuota oppure contiene un solo elemento, la ricerca in


questo caso ha costo O(1)

Mentre quale sarebbe il caso medio?

In realtà non possiamo dire nulla senza sapere come è fatta la funzione hash.

Assumiamo di avere una funzione che è facile da calcolare e gode della


proprietà di uniformità semplice, ossia la funzione hash distribuisce in modo
uniforme le chiavi tra le celle, tutte le posizioni della tabella hash sono
equiprobabili!

Ad esempio, questa funzione di hash è uniformemente semplice?

Hash table 7
Il resto di una divisione per 10 corrisponde sempre all’ultima cifra della chiave.

l’ultima cifra appartiene a {0, 1, 2..., 9}

Ognuno di questi numeri appare 10 volte come ultima cifra, quindi in questo caso la
funzione è uniforme!
In questo caso invece:

La funzione hash è uniforme semplice?

La funzione di hash in questo caso calcola la somma delle cifre della chiave

h(k) = 0 per k = 0
h(k) = 1 per k = 1, k = 10
h(k) = 2 per k = 2, k = 11, k = 20
Quindi la funzione non è uniforme semplice! Ci sono infatti delle posizioni della
tabella T con più elementi rispetto ad altre!

💡 Se la funzione di hash fosse uniforme semplice, la successione


rappresentata nel grafico sarebbe piatta.

Caso medio con hashing uniforme semplice


In media, quanti elementi ci sono in una lista?

Hash table 8
chiamiamo ni il numero di elementi nella lista T [i] con i = 0, 1, .., m − 1
Il numero medio di elementi in una lista è

Tempo medio per cercare un elemento che non c’è:

Il tempo per individuare la lista è θ(1)

Ogni lista ha la stessa probabilità di essere associata con la chiave

la lista ha in media α elementi, quindi per percorrere tutta la lista θ(α)

Il tempo richiesto mediamente da una operazione di ricerca di un elemento assente


nella tabella è θ(1) + θ(α) = θ(α)

💡 Attenzione! il valore di α non è costante! infatti dipende dal numero di


elementi presenti nella tabella, questo valore è variabile! (siccome le
nostre strutture dati sono dinamiche)

💡 Però possiamo immaginare che m sia scelto in modo che α sia quasi
costante

Tempo medio per cercare un elemento presente nella lista:

In questo caso, a differenza di prima, non necessariamente dobbiamo percorrere


completamente la lista, il numero di elementi da esaminare dipende da dove si trova
l’elemento cercato, ad esempio se l’elemento cercato è in prima posizione dobbiamo
valutare solo un elemento, se invece si trova in ultima posizione dobbiamo scorrere
l’intera lista!

Assumiamo che la ricerca riguardi un elemento scelto a caso:

Il tempo per individuare la lista è sempre θ(1)

Assumiamo che la ricerca riguarda l’i-esimo elemento inserito, xi

per trovare xi dobbiamo esaminare xi stesso e tutti gli elementi che sono stati
inseriti dopo xi e hanno una chiave con stesso valore di hash di xi !

Hash table 9
Quanti elementi tali ci sono?

Dopo xi si inseriscono N − i elementi


ma quanti di questi finiscono nella stessa lista di xi ?

Siccome la funzione è uniforme semplice, ogni elemento viene inserito nella


1
lista xi con probabiltà m

In media abbiamo Nm−i elementi nella stessa lista di xi

Quindi il tempo di ricerca per xi è proporzionale a

💡 il valore 1 indica il tempo per individuare la lista esatta.

Se generalizziamo il nostro ragionamento e al posto di xi prendiamo un elemento a


caso, il tempo per ricercare un elemento scelto a caso è:

💡 1
La sommatoria è moltiplicata per N per far si che ciascun elemento a
caso sia equiprobabile nella somma totale!

Se elaboriamo la quantità precedente, in particolare scomponendo le somme nella


parentesi:

La prima e la seconda sommatoria contiene termini che non dipendono da i, quindi


sommiamo lo stesso termine N volte.
1

Hash table 10
1
La prima sommatoria vale quindi N ∗N =1
1 N N
La seconda vale N ∗N∗ m = M
Mentre la terza , essendo una serie aritmetica diventa

In definitiva otteniamo:

E dividendo l’ultima frazione otteniamo:

α α
1+ −
2 2N
Il tempo di calcolo può essere scritto come
α α
θ(1) + θ(1 + 2
− 2N
) = θ(1 + α)
Quindi esattamente come nel caso in cui l’elemento non è presente, il tempo di
calcolo della ricerca nel caso migliore è proporzionale al valore α

Ma cosa vuol dire nella pratica θ(1 + α)?

Se il numero di celle in T è proporzionale a N , allora N = O(m) e quindi


α = O(1), la ricerca in questo caso richiede tempo costante!
E, facendo l’ipotesi di prima, tutte e tre le operazioni costano O(1)

Come trovare una buona funzione hash


Il significato della parola hash, per quanto riguarda gli algoritmi, è combinare /
utilizzare tutte le informazioni della chiave!
Inanzitutto una buona funzione hash è uniforme semplice.

Ma questa proprietà è difficile da verificare, perchè dipende da quali chiavi saranno


usate.

Hash table 11
Finora abbiamo interpretato le chiavi sempre come numeri naturali ed effettivamente
ogni chiave è una sequenza di bit!
E nella funzione hash si cerca di far si che ogni bit della chiave entri “in gioco”

Una buona funzione hash sceglie le posizioni da assegnare in modo da eliminare


eventuale regolarità nei dati

Metodo della divisione


Il metodo della divisione assegna alla chiave k la posizione:

h(k) = k mod m
è molto veloce, ma bisogna fare attenzione a m!
Esempio:

immaginiamo di assegnare la posizione tramite il metodo della divisione a delle


stringhe, rappresentato con il codice ascii, ad esempio possiamo trasformare la
parola oca in un numero nel seguente modo:

In questa maniera traduciamo ogni stringa in un numero


Ma, da questa tabella si evince che il valore di m conta molto:

💡 Tutte le parole scelte terminano con “le” , e avendo m


parole sono associate alla posizione 1637
= 2048 tutte le

💡 Mentre con m = 1583 le 4 parole vanno tutte in posizioni diverse!

Hash table 12
Infatti, se si usa m= 2p , entrano in gioco solo le due ultime lettere della parola,
quindi si può scegliere m in questo modo soltanto se si ha la certezza che gli ultimi
bit hanno una distribuzione uniforme , per le parole italiane non va certamente bene!

Invece un numero primo non vicino ad una potenza di 2 è spesso una buona scelta (
m = 1583)

Metodo della moltiplicazione


Avendo 0 < A < 1

h(k) = [m(Ak mod 1)]

Con x mod 1 che indica la parte frazionaria di x.


Con questa tecnica il valore di m non è critico e di solito viene scelta una potenza di
2.

La scelta ottimale di A dipende dai dati, ma secondo alcune stime e ricerche A =


5 −1
2
Esempio con 4 parole:

Avendo 4 parole piuttosto simili tra loro abbiamo 4 valori differenti di hash!

Indirizzamento aperto
Rispetto a prima, con l’indirizzamento aperto tutti gli elementi sono memorizzati nella
tavola T
In generale, l’elemento con chiave k viene inserito nella posizione h(k) se essa è
libera
Se invece la posizione h(k) non è libera, allora si cerca una posizione libera in base
ad uno schema di ispezione, che esamina altre posizioni fino a quando non si trova
una cella libera.

Hash table 13
Lo schema di ispezione più semplice è la cosiddetta ispezione lineare: a partire
dalla posizione h(k) l’elemento con chiave k viene inserito nella prima cella
libera!

Esempio

💡 Ad esempio, la chiave 2 va in
collisione con la chiave 12,
quindi il valore 2 viene
inserito tramite l’ispezione
lineare, e viene scelta la
posizione 3 visto che è libera!
Anche 22 va in collisione
siccome la posizione 2 è
occupata, e siccome anche la
3 è occupata la chiave 22
viene inserita in posizione 4.

Si osserva che con questa tecnica anche chiavi con hash diversi possono andare in
collisione, non è detto che una chiave si trovi nella posizione indicata dalla tabella
hash

Estensione della funzione hash


In generale l’indirizzamento aperto può essere descritto tramite una funzione hash
estesa con l’ordine di ispezione:

h : U ∗ {0, 1, 2, ..., m − 1} → {0, 1, 2, ..., m − 1}


Infatti un elemento al massimo può subire m − 1 collisioni

Hash table 14
In generale, un elemento con chiave k viene inserito:

Nella posizione h(k, 0) se questa è libera

altrimenti nella posizione h(k, 1) se questa è libera

e cosi via…

L’ispezione è lineare se

h(k, i) = (h′ (k) + i) mod m

Dove h′ (k) è la funzione di hash “normale”

💡 il mod m serve per rendere la lista “circolare”, ossia per riuscire a


ricomincare a ispezionare la lista dal primo elemento quando gli elementi
sono finiti.

Inserimento in tabella hash con indirizzamento aperto

💡 l’indice i ci serve per tenere conto delle collisioni “subite”

💡 L’algoritmo, oltre a effettuare l’inserimento, ritorna anche la posizione in


cui è stato inserito l’elemento di chiave k

Hash table 15
Ricerca in tabella hash con indirizzamento aperto

💡 Come prima, l’indice i ci serve per tenere conto delle collisioni

💡 La ricerca da esito nil quando l’elemento con chiave k non è presente


nella tabella, questo accade quando troviamo nella posizione cercata una
casella vuota, oppure quando i supera m
Se invece viene trovato l’elemento con chiave k in posizione j, si ritorna
T[j]

💡 Per questo motivo, dobbiamo assumere che non ci siano buchi nella
tabella e che non sia possibile la cancellazione di un elemento!

Problema della cancellazione in una tabella hash a


indirizzamento aperto
Nel caso in cui permettessimo la cancellazione in una tabella hash a indirizzamento
aperto, andremo a rovinare l’operazione di ricerca, quindi per cancellare un
elemento non possiamo marcare la posizione con un nil.
L’idea per risolvere questo problema è implementare una costante deleted per
indicare che un elemento è stato cancellato cancellato.

Ma per fare questo bisognerebbe cambiare la funzione di inserimento.

Hash table 16
💡 Nella pratica, l’indirizzamento aperto si usa quando non c’è necessità di
cancellare.

Possibili schemi di ispezione


Il problema dell’ispezione lineare è che si creano delle file di celle occupate, questo
fenomeno viene chiamato addensamento primario, ed è un problema per gli
inserimenti!
Un altro schema di ispezione è l’ispezione quadratica:

h(k, i) = (h′ (k) + c1 i + c2 i2 ) mod m

Come prima, i è il numero di collisioni già subite, ma in questo caso l’ordine di


ispezione delle celle dipende soltanto dalla chiave k , se due elementi hanno h′ (k)
uguale , allora verranno restituiti due valori uguali! questo è il cosidetto
addensamento secondario
Per evitare questo problema , si introduce il cosidetto doppio hashing:

h(k, i) = (h1 (k) + ih2 (k)) mod m

In questo caso, due chiavi con stesso valore di h1 non avranno in generale stesso
valore di h(k, i)!

Complessità di ricerca con indirizzamento aperto


Consideriamo il caso ottimale dal punto di vista della funzione hash e lo schema di
ispezione:

Assumiamo che la posizione di una chiave scelta a caso abbia distribuzione


uniforme.

Inoltre, qualunque sequenza di ispezione ha la stessa probabilità (nella pratica si


può ottenere questo solo con il doppio hashing)

Consideriamo, dal punto di vista della ricerca, il caso in cui l’elemento sia assente ,
che corrisponde al caso peggiore!

Denotiamo quindi con X il numero di celle esaminate durante una ricerca senza
successo
Assumendo che la chiave assente sia scelta a caso, è ovvio che X sia casuale
(variabile aleatoria), e ovviamente vale almeno 1 , quindi P (X ≥ 1) = 1.

Hash table 17
Qual’è la probabilità di esaminare almeno due celle? questo accade quando la
prima cella non è un nil, ma questo è N/m , infatti nella tabella sono occupate N
celle su m totali.
N
quindi P (X ≥ 2) = m
E la probabilità di esaminare almeno tre celle?
N N −1
P (X ≥ 3) = m
∗ m−1
In generale, la probabilità di esaminare almeno i celle è :

N N −1 N −i+2
P (X ≥ i) = ∗ ... ∗
m m−1 m−i+2
Siccome a noi interessa porre un limite superiore e ciascun elemento del prodotto è
minore di α, possiamo dire che

P (X ≥ i) ≤ ai−1
Ora, ci interessa il valor medio della variabile aleatoria X che indico con E[X]

💡 infatti la sommatoria infinita di ai−1 corrisponde ad una serie geometrica,


che converge a 1−α
1

Ad esempio, se α = 0.5 ossia metà dell’array è pieno, in media servono 2


confronti!

se invece α = 0.75, allora in media serviranno 4 confronti.


Ovviamente, nel caso di una ricerca con successo vengono esaminate meno celle.

💡 più α assume valori vicini ad 1 e più confronti dovrò fare.

Anche l’inserimento si analizza con lo stesso approccio.

Hash table 18

Potrebbero piacerti anche