Sei sulla pagina 1di 54

1

UNIVERSITA DEGLI STUDI DI CATANIA




FACOLTA DI SCIENZE MATEMATICHE, FISICHE E
NATURALI



Tesi di Laurea


Due applicazioni didattiche sulle tabelle hash e
sull'algoritmo di Dijkstra



Platania Gabriele
667/002365

A. A. 2007/2008
2

Indice:


1.Introduzione 3

2. Aspetti teorici. 6
2.1 Tabelle Hash.... 6
2.1.2 Tabelle Hash a lista di concatenamento: ... 10
2.1.3 Tabelle hash a indirizzamento aperto: ... 13

2.2.1 Considerazioni sulle tabelle Hash. 17
2.2.2 Altre applicazioni delle funzioni Hash.. 18

2.3 Nozioni sui Grafi.. 19
2.3.1 Algoritmo di Dijkstra: .. 20
2.3.2 Grafo dei Cammini Minimi.. 23

3. Il Progetto. 24
3.1 Pacchetto relativo ai Grafi 24
3.2 Pacchetto relativo alle Tabelle Hash 33

3.3 Algoritmi utilizzati: 42
3.4 Alcune statistiche.... 44

4. Applicazioni delle tabelle Hash 51

5. Conclusioni.. 53

Riferimenti Bibliografici.. 54
3
Capitolo 1
Introduzione

Al giorno doggi, nella progettazione di un software, sempre pi importante
utilizzare delle strutture dati che permettano un accesso rapido ai record
memorizzati.
In base al target dellapplicazione che si vuole costruire, si decide se sia
preferibile implementare certe funzionalit tramite una struttura dati piuttosto
che un'altra.
Supponiamo, ad esempio, di voler memorizzare una lista di chiavi in una
struttura dati che ci permetta di inserirne una nuova e controllare lesistenza
(cercarne) di una gi inserita. In questo caso, possiamo spaziare la nostra
scelta tra diversi tipi di strutture.
La soluzione pi immediata quella di costruire una lista.
In una lista, le chiavi vengono inserite una dopo laltra. Quando ne inseriamo
una nuova, questa viene posta in testa a tutte le altre.
Una struttura dati di questo tipo, ha il vantaggio di consumare le risorse
strettamente necessarie in termini di memoria, infatti non viene memorizzato
alcun dato se non le chiavi stesse.
Il problema nellutilizzo di questa struttura dati, invece, che i tempi di
recupero dei dati sono molto lenti.
Supponiamo, ad esempio, di voler cercare una determinata chiave
nellelenco.
Per sapere se questa presente o no nella nostra lista, dobbiamo per forza
controllare ogni chiave. Addirittura, se la chiave cercata non presente,
dovremo cercare dallinizio alla fine, controllando singolarmente ogni elemento
presente.
In una struttura dati di questo tipo, pu aiutarci molto lordinamento.
Infatti tramite lordinamento possiamo fermare la ricerca non appena viene
trovato un elemento in qualche modo maggiore di quello che stiamo
cercando.
4
In media, questo ci permette di dimezzare il tempo necessario per la ricerca
di una chiave.
Per ottenere una lista ordinata, per, necessario utilizzare degli algoritmi, in
genere onerosi in quanto allungano di molto i tempi necessari per
linserimento, perch la chiave non pu essere inserita semplicemente
allinizio, ma bisogna trovare la sua posizione specifica.
Questo non un problema finch la lista non particolarmente lunga, ma
spesso, ci si trova ad avere a che fare con una mole significativa di dati
(nellordine di centinaia di migliaia) e questo causa enormi rallentamenti
nellapplicazione finale.

Una struttura dati dalle caratteristiche opposte alla precedente, lArray.
Un Array una suddivisione in caselle di memoria, in cui possibile leggere
e scrivere in ogni posizione con costi trascurabili in termini di tempo.
In questo caso, ad esempio, potremmo, per convenzione, stabilire che le
caselle contenenti 0 sono vuote, mentre quelle contenenti 1 sono piene.
Al momento in cui bisogna inserire una determinata chiave (ad esempio un
numero), baster porre la casella associata ad essa a 1, mentre in fase di
ricerca, sar sufficiente controllare se la casella associata alla chiave che
stiamo cercando sia 1 oppure 0.

Il vantaggio di questo approccio sicuramente la velocit, infatti in una sola
operazione siamo in grado di fornire linformazione richiesta.
Gli svantaggi, daltro canto, bilanciano la velocit.
Gli sprechi in termini di memoria utilizzata sono enormi.
Tutte le caselle con un numero che non stato inserito sono comunque
presenti. Inoltre, per poter utilizzare questo tipo di struttura, necessario
conoscere in maniera precisa, e in anticipo, il numero massimo di inserimenti
da effettuare, e spesso non possibile avere a priori questinformazione.

Le Tabelle hash permettono di accedere ai dati in maniera veloce, e con uno
spreco di risorse relativamente molto basso.
5

Si noti che finora abbiamo considerato i record da manipolare
semplicemente come chiavi. Queste possono essere numeri, stringhe o
anche oggetti pi complessi composti da un insieme di pi informazioni, come
ad esempio un intero record sui dati anagrafici di una persona.
In realt il tipo di dati presi in considerazione non importante, in quanto un
record complesso pu essere gestito tramite lutilizzo di opportuni puntatori. In
questa maniera possibile considerare il record da manipolare, qualunque
esso sia, come una generica chiave.
Spesso, per semplicit, si utilizza linsieme dei numeri naturali N anche come
universo delle chiavi. Questo concetto non sbagliato, in quanto se luniverso
U delle chiavi non dovesse corrispondere allinsieme dei numeri naturali,
sempre e comunque possibile mappare linsieme U in N.
Ad esempio, se dovessimo creare una funzione Hash che dovesse
memorizzare delle stringhe, queste si potrebbero mappare sommando i codici
ASCII dei singoli caratteri, oppure considerando la posizione delle singole
lettere nellalfabeto, eccetera.

La presente relazione cos strutturata: nel Capitolo 2 vengono presi in
rassegna gli aspetti teorici relativi alle tabelle hash e alle nozioni dei grafi,
nonch l'algoritmo di Dijkstra, quindi nel Capitolo 3 presenteremo in dettaglio il
progetto svolto, con diagramma UML, descrizione delle singole classi e
qualche schermata riassuntiva. Infine nel Capitolo 4 vedremo un esempio di
applicazione reale di alcuni argomenti trattati.
In Conclusione, verranno presentati alcuni possibili miglioramenti riguardanti
il progetto.
6
Capitolo 2
Aspetti teorici

2.1 Tabelle Hash

Immaginiamo di costruire un Array, di dimensione M (proporzionale al
numero di dati da immagazzinare), e, associare a ciascuna chiave una
posizione ben definita in questo array, tramite una funzione matematica.
Questa funzione detta funzione Hash.
Lo scopo di una funzione Hash, quindi, quello di definire una
corrispondenza tra linsieme universo U delle chiavi e linsieme delle posizioni
della tabella.
Data una certa chiave, la funzione Hash associer sempre la stessa
posizione ad essa.
In questo modo, possibile inserire e cercare elementi in pochissime
operazioni.
Infatti, calcolare il valore Hash di una chiave richiede tempo costante ( O(1) ),
cos come accedere alla locazione desiderata.
Esistono diversi tipi di funzioni Hash. In base alla funzione utilizzata si
ottengono distribuzioni dei dati pi o meno uniformi. Lideale sarebbe avere
una funzione Hash che permetta di distribuire i dati tra le varie liste in maniera
perfettamente uniforme. Purtroppo, questo tipo di Hashing, non possibile, se
non conoscendo a priori linsieme delle chiavi da inserire.
Le funzioni Hash, quindi, possono in qualche modo trasformare i dati,
attraverso delle funzioni matematiche che vedremo pi avanti, in un altro tipo
di dati, che noi considereremo come indici della nostra tabella hash.
Bisogna tener presente che dal risultato di una funzione hash impossibile
risalire al dato iniziale, in quanto potrebbero essere presenti pi chiavi con
valori hash identici.

Una buona funzione Hash deve godere di certe propriet:
sia M la dimensione della tabella Hash, e sia U luniverso delle chiavi.
7
La funzione hash deve essere definita nellinsieme universo delle chiavi, ed
avere valori in [0,M-1].

Hash(K) : U [0,M-1];

Ipotesi di Hash uniforme semplice:

Il valore Hash calcolato su una chiave estratta casualmente dalluniverso
delle chiavi U deve avere la stessa probabilit di essere uno qualsiasi dei
valori compresi tra [0, M-1]. Questa probabilit 1 /M. Non devono quindi
esistere valori pi probabili di altri.
Questa ipotesi, apparentemente banale, ma in realt, se luniverso delle
chiavi non banale, creare una funzione Hash che garantisca una
distribuzione uniforme degli elementi non per niente semplice.




Completezza dellinput:

Una buona funzione Hash deve considerare la chiave nella sua completezza,
e non in parte di essa. Questa propriet deriva dalla ricerca dellhashing
uniforme semplice: se si considera solo una parte della chiave, infatti, tutte le
chiavi simili avranno una probabilit maggiore di essere assegnate alla stessa
cella.

Ecco alcune funzioni hash molto usate:

Hash Moltiplicazione

H(K) = M*(KA mod 1)

Viene moltiplicata la chiave K per una costante A, compresa tra zero e uno, e
ne viene preso il valore decimale (KA mod 1). Questo viene poi moltiplicato per
la dimensione M della tabella. Il tutto viene poi arrotondato allintero pi basso.
Statisticamente, stato dimostrato che un buon valore di A :

8
A=(5-1)/2


Hash Divisione

H(K) = K mod M

In questo caso, per, la dimensione della tabella, influisce sul valore finale
dellhash. Diventa, quindi un dato influente nel calcolo. Se infatti, viene presa
come dimensione M della tabella Hash una potenza di due, come

M = 2
P

Il risultato di questa funzione Hash dipenderebbe esclusivamente dalle ultime
P cifre del valore K considerato. Questo perch i computer effettuano calcoli in
base 2.
Per capire meglio, proviamo a immaginare la stessa situazione in base
decimale.

Sia M la dimensione della nostra tabella Hash:

M = 10P

Con P = 2, M sarebbe uguale a 100.
Sia K la chiave sulla quale si intende calcolare lhash.

K = 12345

In questo caso, lHash risulterebbe:

Hash_Divisione(12345) = 45
Hash_Divisione(987645) = 45

Questo violerebbe il criterio di completezza dellinput, e la funzione Hash
risultante non sarebbe buona in quanto porterebbe a collisione dei dati simili.
9
Per avere buoni risultati con questo tipo di Hash, consigliabile scegliere
valori di M distanti da potenze di 2. Meglio ancora se numeri primi. In questo
caso, infatti, il risultato della divisione varia tenendo in considerazione linput
nella sua completezza.

Collisioni:

Da quanto abbiamo detto, evidente che esiste la probabilit che il valore di
una funzione Hash calcolata su due chiavi distinte sia identico.
Questo comportamento, inoltre, molto pi probabile di quello che ci si
aspetta, a causa del Problema del compleanno:

Problema del Compleanno:
Il Problema del compleanno afferma che in un gruppo, la probabilit che due
persone compiano gli anni lo stesso giorno largamente superiore di quanto ci
si aspetti. Infatti, ad esempio, in un gruppo di 23 persone, la probabilit che
due di loro abbiano il compleanno lo stesso giorno, addirittura superiore al
50%.
Questo deriva dalla probabilit composta che tutti compiano gli anni in giorni
diversi. Questa probabilit si abbassa man mano che si aggiungono persone al
gruppo, e di conseguenza, la probabilit inversa, cio quella che due o pi
persone compiano gli anni lo stesso giorno, si alza. Il problema del
compleanno, nelle tabelle hash, indica che la probabilit che in una tabella
Hash due elementi collidano nella stessa casella molto pi alta di quanto ci si
aspetti, e inoltre aumenta con laumentare degli elementi inseriti.

Il rapporto tra elementi inseriti e numero di caselle, ci d un indice di quanto
la tabella sfruttata. Questindice detto Coefficiente di carico, e si indica con
la lettera greca .
Se indichiamo con N il numero di chiavi presenti nella tabella Hash e con M il
numero totale di caselle, allora:

= N/M

10
In base al tipo di tabella Hash, questo coefficiente sar utilizzato in diversi
modi.


Gestione delle collisioni:

Dato che la funzione Hash non mai perfetta, e abbiamo visto tramite il
problema del compleanno che la probabilit che si verifichino collisioni molto
pi alta di quanto ci si aspetti, succede spesso che a due o pi chiavi,
corrisponda lo stesso valore hash. In particolare, questo inevitabile nel caso
in cui la cardinalit dellinsieme delle chiavi da inserire sia maggiore del
numero di caselle a disposizione.
In questo caso, necessario operare una gestione delle collisioni.

In base alla gestione delle collisioni, le tabelle hash si dividono in due grandi
gruppi:

1)Le tabelle hash a lista di concatenamento.

2)Le tabelle hash a indirizzamento aperto.



2.1.2 :Tabelle Hash a lista di concatenamento:

Le Tabelle Hash a lista di concatenamento, gestiscono le collisioni
semplicemente associando ad ogni casella dellarray una lista, che conterr
tutte le chiavi che andranno a collidere in quella posizione.

Questa soluzione, permette alla tabella di non avere un limite massimo di
elementi da inserire.
Se ad ogni elemento corrisponde una lista, durante la ricerca, una volta
calcolato lhash, bisogner controllare tutti gli elementi della lista
corrispondente.

11
Supponendo di avere una funzione hash abbastanza buona, cio che
distribuisca i dati in maniera abbastanza uniforme tra le caselle disponibili,
rispettando il principio di Hashing uniforme semplice, in media ogni lista
conterr un numero di elementi pari al rapporto tra gli elementi inseriti (N) e il
numero di caselle disponibili(M). Questo rapporto lo abbiamo gi definito come
Coefficiente di carico

= N/M

Questo rapporto, ci indica quanto sfruttata la tabella hash. Un coefficiente
di carico molto vicino allo 0 indica una tabella hash molto veloce, parzialmente
vuota, mentre un coefficiente di carico che si avvicina all1, indica una tabella
Hash quasi piena, in cui la probabilit che avvenga una collisione molto alta.

Una conseguenza dellipotesi di Hashing uniforme semplice, che la
lunghezza media delle liste pari al coefficiente di carico.
Questo ci aiuta nel calcolo della complessit computazionale.
Questa infatti, escludendo il tempo necessario al calcolo della funzione hash,
data solamente dal tempo di ricerca dellelemento in una lista, e questo
dipende fortemente dalla dimensione della lista stessa.
Se si sceglie M proporzionale alla cardinalit dellUniverso U delle chiavi K,
allora:

M = c* |U|

= N/M = |U| / (c*|U|) = 1/c

quindi la complessit computazionale della ricerca di una chiave O(1/c)
La dimensione della tabella Hash, quindi, influisce direttamente sia sulla
quantit di memoria occupata, sia sulla velocit di esecuzione delle operazioni
della tabella.

In questa prospettiva, possiamo considerare le liste e gli Array come speciali
tipi di tabelle Hash, con dimensioni diverse:
12


M = 1 -> c= 1/N
Lista: Occupazione memoria bassa e tempi di ricerca
O(1/c) = O(1/1/N) = O(N)

M circa uguale a |U| -> c circa uguale a 1
Tabella Hash: Occupazione memoria bassa e tempi di ricerca
O(1/c) = O(1/1) = O(1)

M >> |U| -> c molto grande
Array: Occupazione memoria alta, tempi di ricerda:
O(1/c) = O(1)
13
2.1.3:Tabelle hash a indirizzamento aperto:

Le tabelle Hash a indirizzamento aperto funzionano seguendo un principio
molto diverso da quelle a lista di concatenamento.
Per risolvere una collisione, se una casella occupata, ne viene cercata
unaltra libera, se questa ancora una casella occupata ne viene cercata
unaltra ancora, e cos via, finch o non viene trovata una casella libera, o non
vengono terminate le caselle a disposizione.

Naturalmente, una funzione Hash che indica una singola posizione (come
quelle viste finora) non pi sufficiente per indirizzare un elemento, perch in
questa maniera verrebbe considerata una singola casella.
Piuttosto dovremo utilizzare una sequenza di scansione della tabella, cio
dovremmo scorrere tutte ed una sola volta le caselle disponibili, creando quindi
una permutazione delle celle della tabella.
Una funzione Hash di questo tipo, prende in input non solo la chiave K su cui
calcolare lHash, ma anche literazione (cio il numero di tentativi gi
effettuati).

Limplementazione pi semplice di questo, la Scansione Lineare.
Se la casella Hash(K) occupata, semplicemente si guarda la casella
successiva, cio Hash(K)+1.
Se anche questa occupata si passa a quella ancora successiva, e cos via.

Hash_Lineare(K,i) = (Hash(K) + i) mod M

Il Mod M finale serve ad assicurarsi che quando la somma di Hash(K) e i
supera il valore di M, riprende dallinizio.

Una scansione di questo tipo, per, soffre del problema dellagglomerazione
primaria.
14
Cio vengono a crearsi degli agglomerati di caselle piene consecutive, che si
ingrandiscono sempre pi, aumentando il numero di tentativi da effettuare
prima di trovare una casella libera.
Un esempio:

In una situazione come questa a fianco, la probabilit che un elemento
vada a finire nella casella 7 1/10. Ma va a finire nella casella 7 anche se
lhash 3, 4, 5 o 6.
Quindi la probabilit totale (5/10).
Lagglomerato tender ad ingrandirsi sempre di pi, aumentando i
tentativi di inserimento finch non sar necessario un tempo lineare O(n)
per un singolo inserimento.







Per risolvere questo problema, si utilizza un altro tipo di funzione hash,
chiamato Hashing Quadratico.

Hash_Quadratico(K,i) = (Hash(K)+c1*i + c2*i2) mod M

In questo caso, per bisogna fare attenzione alla scelta di C1 e C2 rispetto a
M, perch potrebbero risultare in una scansione parziale delle caselle.

Questo tipo di hash soffre del fenomeno meno grave dellagglomerazione
secondaria, perch comunque a due chiavi che hanno inizialmente lo stesso
Hash, sar assegnata la stessa sequenza di scansione, dal primo allultimo
elemento.

La soluzione a questo problema lhashing doppio.
15
Per questo tipo di hash sono necessarie due diverse funzioni Hash,
Hash1(K) e Hash2(K), e viene calcolato in questo modo:

Hash_Doppio(K,i) = (Hash1(K) + i*Hash2(K)) mod M

Questo tipo di hash permette di assegnare scansioni diverse anche agli
elementi il cui hash iniziale era identico.
Unaccortezza di cui bisogna tener conto in questo tipo di hash, per, che
Hash2(K) devessere sempre primo con M, cio non devono avere divisori
comuni. Se questo accadesse, infatti, posto d = M|Hash2(K) il massimo
comune divisore tra M e Hash2(K), il calcolo finale:

(Hash1(K)+i*Hash2(K)) mod M

Non potrebbe mai dare come risultato un numero non divisibile per d,
rendendo impossibile la scansione di tutte le caselle della tabella Hash.

Per fare un esempio:
K = 2
M = 10
Hash1(K) = 0
Hash2(K) = 2

In questo caso, le caselle verranno controllate in questordine:

0-2-4-6-8-10-12-14-16-18

Ma ai numeri maggiori di 10 viene applicato il modulo M, quindi la sequenza
finale sar:

0-2-4-6-8-0-2-4-6-8

16
Le caselle dispari non verranno mai controllate!
Questo perch 2 e 10 hanno come massimo comune divisore

d = 2|10 = 2

Se invece M fosse stato uguale a 11, la sequenza di scansione sarebbe
stata:

0-2-4-6-8-10-12-14-16-18-20

Ma ai numeri maggiori di 11 viene applicato il modulo M, quindi:

0-2-4-6-8-10-1-3-5-7-9

In questo caso possibile effettuare la scansione in tutte le caselle della
tabella hash.


Una soluzione rapida a questo problema quella di imporre che M sia un
numero primo (nellesempio, infatti, ho considerato M = 11).


Una funzione Hash che fornisce una permutazione degli elementi di una
tabella Hash, dovrebbe teoricamente rispettare lipotesi di Hashing uniforme.

Hashing Uniforme:
La probabilit che ad un certo elemento K venga assegnata una sequenza T
in una tabella Hash a M caselle, devessere

P = 1/M!

17
Con lhashing lineare e quadratico, le possibili permutazioni sono solo M,
quindi la probabilit che ad una certa chiave venga assegnata una particolare
permutazione degli elementi

P = 1/M

e ci molto distante dallipotesi di Hashing uniforme.

Con lhashing doppio, invece, le permutazioni possibili sono M2, perch
anche se il risultato del primo Hash identico, il secondo Hash comunque
diverso perch stato calcolato utilizzando una funzione Hash diversa.
In questo caso, quindi, viene fornita una qualsiasi tra le M2 combinazioni.
Questo ancora distante dallipotesi di Hashing uniforme, ma comunque un
grosso miglioramento.

Purtroppo, non sono ancora state create funzioni Hash che rispettino
completamente lipotesi di Hashing Uniforme.



2.2.1 Considerazioni sulle tabelle Hash.

Nelle tabelle hash a lista di concatenamento, utilizziamo una lista per
memorizzare tutte le chiavi che collidono nella stessa casella. Ma adesso che
conosciamo questa nuova struttura dati, che ci permette di ottenere i dati con
una velocit molto maggiore di una lista, viene spontaneo chiedersi perch
non utilizzare al posto di una lista una tabella Hash per memorizzare le chiavi
che collidono.
Il motivo, in realt, molto semplice.
Quando creiamo una tabella Hash, dobbiamo cercare di prevedere il numero
di elementi che verranno inseriti al suo interno.
Sostituendo le liste con delle tabelle Hash, dobbiamo cercare di prevedere il
numero di elementi per ogni lista, sia questo C.
18
Quindi prevediamo di inserire un numero di elementi C*M.
Per avere un effettivo guadagno da questa soluzione, ci si aspetta che C sia
un intero positivo maggiore di 1 (piuttosto grande).
Ma se ci fosse vero, significa che abbiamo dimensionato male la nostra
tabella Hash primaria, infatti nonostante prevediamo di inserire C*M elementi,
abbiamo dimensionato la nostra tabella con solo M caselle.
In sostanza, quindi, una tabella Hash di tabelle Hash, non avrebbe senso, in
quanto una tabella Hash contenente C*M caselle sarebbe pi veloce e
occuperebbe meno spazio di questa.

2.2.2 Altre applicazioni delle funzioni Hash:

Come abbiamo gi accennato, dato un valore Hash, impossibile calcolare
quale input alla funzione abbia generato tale valore. Appunto per questo le
funzioni Hash non sono invertibili.
Una funzione Hash che sia anche particolarmente sicura e che abbia un
dominio molto vasto (come ad esempio [010
200
] ) trova molte altre
applicazioni come ad esempio negli algoritmi di sicurezza informatica o di
controllo degli errori.
Variando minimamente linformazione su cui viene calcolato lhash, questo
cambia completamente.
Questo molto utile nel caso in cui sia necessario confermare lintegrit di un
messaggio.

Controllo degli errori:
Ad ogni messaggio, viene associato un digest: cio un hash calcolato su tutto
il resto del messaggio.
In questo modo, una modifica anche minima del messaggio, dovuta ad un
errore in fase di trasmissione o in ricezione, produrrebbe un digest
completamente diverso, permettendo di verificare con pochi bit lintegrit di
tutto il messaggio.
Questo molto utile nel caso in cui viene trasmessa una quantit
considerevole di dati.
19
Lhash di una qualsiasi quantit di dati, infatti, risulta in pochi bit, solitamente
128.

Un altro utilizzo molto diffuso in ambiente di sicurezza informatica, ad
esempio la memorizzazione delle password. Un servizio di login, infatti, non
deve assolutamente memorizzare la password fornita. Se lo facesse, infatti, un
eventuale attaccante che riuscisse ad ottenere la tabella delle password,
potrebbe tranquillamente effettuare qualsiasi login, e ci renderebbe il servizio
poco sicuro.
Se il servizio, invece di memorizzare la password ne memorizzasse lhash,
potrebbe verificare i dati inseriti ricalcolandone lhash e confrontandolo con
quello in memoria.
Se il confronto d esito positivo, allora i dati inseriti sono corretti.
In questo modo, un eventuale attacco alla tabella delle password, non
potrebbe fornire alcuna informazione utile, infatti dal solo hash, non possibile
risalire al dato iniziale.
A questo punto, per, sorge spontaneo chiedersi se una password diversa
potrebbe fornire lo stesso hash, provocando un falso login.
In effetti, questo possibile, ma la probabilit che questo accada talmente
remota, che molto pi semplice trovare la password reale inserendo caratteri
casuali.
I servizi che alla conferma di registrazione inviano per email la password
fornita, possono farlo perch hanno memorizzato la password invece
dellhash. Questi servizi sono poco sicuri.

2.3 Nozioni sui Grafi

I Grafi sono la struttura dati pi versatile di tutte. Particolari tipi di grafi sono
gli alberi o le liste.
Un grafo formalmente definito come
G = <V, E>
20
dove V linsieme dei nodi, ed E linsieme degli archi. Un grafo pu essere
immaginato come un insieme di nodi, contenenti delle informazioni, connessi
tra di loro tramite gli archi appartenenti allinsieme E.

In base al tipo di archi, i grafi possono essere orientati oppure non orientati.
Un grafo orientato un grafo in cui un arco <v1, v2> rappresenta una
connessione tra v1 e v2, ma non una connessione tra v2 e v2. In un grafo non
orientato, un arco <v1, v2> rappresenta una connessione sia tra v1 e v2 che
tra v2 e v1.

Un Grafo si dice pesato, quando esiste un costo C per passare da un arco.
Un grafo non pesato pu essere rappresentato come un grafo pesato, in cui
tutti gli archi hanno costo 1. Il costo rappresentato tramite una funzione
w(v1,v2) che indica il peso dellarco tra v1 e v2. Se non esiste un arco tra v1 e
v2, allora w(v1,v2) sar uguale a +infinito.
Il costo degli archi verr indicato tramite una funzione: w(v1,v2)

I Grafi sono solitamente rappresentati in due modi: Tramite le liste di
adiacenza oppure tramite le matrici di adiacenza. Una matrice di adiacenza
una matrice in cui in ogni posizione [i,j] indicato il peso per passare dal nodo
i al nodo j. Le liste di adiacenza, invece, indicano per li-esima lista, tutti i nodi
adiacenti al nodo i (e il relativo costo).

Un Percorso una sequenza di nodi <v0,v1,vn> in cui per ogni 0<i<n,
esiste un arco dal nodo vi al nodo vi+1.
Il Peso di un cammino definito come la sommatoria dei pesi di tutti gli archi
che fanno parte del cammino:



21
Un Cammino minimo un percorso P il cui peso minimo. Non esiste cio,
un altro cammino da v0 a vN con peso minore.

2.3.1 Algoritmo di Dijkstra:

Lalgoritmo di Dijkstra attualmente il miglior algoritmo per calcolare i
cammini minimi da sorgente singola in un Grafo G che contenga solamente
archi di peso non negativo.
Lo pseudocodice relativo allalgoritmo

While size(Query) > 0
Nodo EstraiMin(Query)
For each (u,v) Adj(Nodo)
Relax(u,v,w);
Come si pu vedere, lalgoritmo estrae il nodo con distanza minima, per poi
rilassare gli archi ad esso adiacenti. La procedura di relax :

if(d[V]>d[u]+w(u,v))
d[v] = d[u] + w(u,v);


Cio viene aggiornata la distanza solamente se il percorso trovato ha peso
minore del percorso gi memorizzato.
Durante lesecuzione dellalgoritmo di Dijkstra, tutti i nodi vengono presi in
considerazione una ed una volta sola. Prima di iniziare la procedura, tutti i nodi
vengono impostati con distanza uguale a +infinito, mentre alla sorgente viene
impostata distanza uguale a zero (logicamente, la sorgente non pu avere una
distanza da se stessa maggiore di zero).
Ad ogni iterazione, vengono prima rilassati tutti gli archi adiacenti al nodo
selezionato, poi viene estratto da una coda di priorit il nodo con distanza
minore. La procedura si ripete finch tutti i nodi non sono stati selezionati.
La dimostrazione della correttezza dellalgoritmo pu essere fatta in maniera
molto semplice con il metodo dellinvariante.

Invariante:
Sia S linsieme di tutti i nodi giunti a convergenza. Cio tutti i nodi per cui
valga che
D =
22
Con = costo di un cammino minimo dalla sorgente s.
Durante ogni iterazione dellalgoritmo, viene pescato il nodo con distanza
minima. Nel momento in cui viene pescato, quel nodo sar gi giunto a
convergenza.

Dimostrazione:
Durante la prima iterazione, infatti, il nodo sorgente viene posto con distanza
D = 0.
La distanza del nodo sorgente da se stesso, infatti, effettivamente = 0.
A questo punto vengono rilassati gli archi adiacenti alla sorgente.

Dimostriamo per assurdo che lalgoritmo corretto.
Durante lesecuzione dellalgoritmo sia X il nodo appena pescato.
Supponiamo che per X valga

D >

Se non esistesse alcun cammino dalla sorgente s al nodo P, allora la
distanza di X da s sarebbe uguale a infinito, quindi D = inf. Ma per ipotesi D >
, quindi deve esistere un cammino (minimo) da s a X. Sia questo P: tale che

P = S~>Y->X

Cio P che parte dalla sorgente, dopo un certo numero di passi arriva a Y, ed
esiste un arco Y->X. Nota Bene: S pu anche coincidere con Y.
Per ipotesi, abbiamo che i pesi degli archi sono tutti > 0.
Dato che Y ha distanza D minore di X, Y un nodo gi giunto a
convergenza, altrimenti avremmo preso Y.
Ma se Y giunto a convergenza, allora abbiamo effettuato il relax su tutti i
suoi archi, in particolare sullarco (Y->X), quindi la distanza D di X sar

D = W(P)
23


Ma P era un cammino minimo, quindi
D =

Assurdo, perch per ipotesi D > .
Quindi lalgoritmo corretto.



2.3.2 Grafo dei Cammini Minimi
Un Grafo dei Cammini Minimi un particolare tipo di grafo, in cui partendo da
una sorgente S, qualsiasi cammino effettuato per raggiungere un altro nodo,
un cammino minimo.
Solitamente, un grafo dei cammini minimi G viene estratto da un grafo G,
prendendo in considerazione tutti i nodi, e tutti e soli gli archi che fanno parte
di un qualche cammino minimo da sorgente S.

Propriet:
Linsieme dei cammini possibili nel grafo dei cammini minimi G uguale
allinsieme dei cammini minimi del grafo G.
24
Capitolo 3
Il Progetto:

Il progetto su cui ho lavorato un programma didattico per mostrare
graficamente come funzionano le tabelle hash e i grafi e per calcolare varie
statistiche sul rendimento delle tabelle hash in base a diverse impostazioni.

E stato realizzato in Java, nellambiente di sviluppo Eclipse.
Il progetto si compone di due diversi pacchetti, uno riguardante i Grafi e gli
algoritmi di visita, laltro orientato verso una visualizzazione grafica e statistica
sulle tabelle Hash.


3.1 Pacchetto relativo alle Tabelle Hash:
Il pacchetto relativo alle tabelle Hash permette di effettuare alcune operazioni
grafiche, come visualizzazione di una tabella Hash, inserimento passo-passo
di elementi, e creazione di grafici statistici.
Il Diagramma che stiamo per vedere mostra le classi utilizzate per il
pacchetto in questione nonch le loro relazioni. In questo modo possibile
visualizzare graficamente la struttura delle classi utilizzate.













25


UML:


Classe Tabella Hash(90 LOC):
Attributi:
int M (Dimensione)
double A (Costante utilizzata per il calcolo dellhash a moltiplicazione)
costanti:
HASH_LINEARE = 1
HASH_QUADRATICO = -1
HASH_DOPPIO = 0
INDIRIZZAMENTO_APERTO = 1
LISTA_DI_CONCATENAMENTO = 0
Classe astratta.
Implementa alcuni metodi comuni, come il calcolo di diversi tipi di Hash, e
definisce i metodi che saranno poi implementati dalle figlie TabellaHashLista e
TabellaHashIndirizzamentoAperto.
26
Definisce anche alcuni metodi utilizzati per la visualizzazione grafica della
tabella.

TabellaHashLista(69 LOC):
Implementa una tabella Hash a lista di concatenamento
Il costruttore prende in input il tipo di Hash, e la dimensione della tabella.
Metodi:
Int Inserisci(K)
Inserisce la chiave K ritornando il numero di operazioni effettuate (in questo
caso ritorna sempre 1. Implementazione dellinserimento gi definito nella
classe Astratta Tabella Hash.
Int Has(K)
Ricerca la chiave K, ritornando il numero di iterazioni effettuate per la ricerca.

TabellaHashIndirizzamentoAperto(83 LOC)
Implementa una tabella Hash a indirizzamento aperto.
Il costruttore prende in input il tipo di Hash e la dimensione della tabella.
Metodi:
int prova Inserimento(K,i)
Prova ad inserire la chiave K senza inserirla effettivamente. Ritorna un valore
utilizzato per controllare se linserimento alliterazione i andrebbe a buon fine o
no, o se il numero di caselle disponibili terminato.
Questo metodo viene utilizzato poi per implementare linserimento passo-
passo.

Int inserisci(K)
Inserisce la chiave K ritornando il numero di iterazioni necessarie a trovare
una locazione libera.

Int Cerca(K)
Effettua una ricerca della chiave K, ritornando il numero di iterazioni
necessarie a trovare lelemento K o una locazione null.

27
InserimentoPassoPasso(73 LOC):
Crea un nuovo Frame in cui viene visualizzata una tabella Hash del tipo
specificato nelle impostazioni. Viene solamente chiamato il metodo draw della
tabella che definito per il tipo Tabella Hash ma poi implementato
diversamente per i due diversi tipi di tabella
Ad ogni click viene effettuato un nuovo passo dellinserimento aumentando il
valore status ed inviando un impulso al metodo actionPerformed, che
provveder in base al valore di status ad effettuare le operazioni adatte.

Attributi:
Status
Int i (iterazione)
Metodi:
costruttore: prende in input diversi parametri dati dalle diverse impostazioni
specificate dallutente, per creare ed inizializzare la tabella adatta.
actionPerformed
Al click del mouse aggiorna lo stato della tabella e lancia il repaint() che a
sua volta disegna la tabella.

Animazione Grafico(93 LOC):
Inizializza un nuovo frame contenente un Pannello Grafico e un Pannello
Misure, per poi lanciare un timer che richiama una funzione per aggiornare e
visualizzare il nuovo stato.

Attributi:
Timer T
Variabili necessarie per calcoli statistici (tra cui array con la media dei valori,
numero di prove eseguite eccetera)
Metodi:
Costruttore
Il costruttore prende in input un oggetto Preferenze che contiene tutte le
impostazioni definite dallutente. In questo modo possibile definire ed
allocare le tabelle cos come scelto dallutente.
28

Void start()
Fa partire il timer per lanimazione, iniziando a disegnare i risultati trovati.

actionPerformed()
Questo metodo viene richiamato ogni volta che scade il timer. Aggiunge un
nuovo elemento alla tabella.
Se questa una tabella ad indirizzamento aperto aggiorna i dati con il
numero di operazioni necessarie allinserimento, altrimenti se la tabella una
lista, effettua una ricerca su un elemento casuale, calcolando il numero di
passi necessari a trovare lelemento cercato (o una locazione vuota).

Pannello Grafico(40 LOC)
Il Pannello Grafico disegna un grafico basandosi su un array di dati che gli
vengono passati in input.
Per ogni elemento dellarray viene calcolata laltezza della colonna che
dovrebbe rappresentarlo. In questo modo vengono disegnate tutte le colonne
una dopo laltra, creando un grafico di tipo istogramma.

Pannello Misure(36 LOC)
Il Pannello Misure disegna la scala a tacche accanto al grafico, in modo da
delineare visivamente la dimensione di ogni singola colonna.
Prende in input il valore massimo del grafico, e viene aggiornato ad ogni
cambio di tabella.

Preferenze(26 LOC)
Questa classe utilizzata solamente per contenere le impostazioni
dellutente riguardo le tabelle.

Frame Preferenze(254 LOC)
Il Frame Preferenze gestisce graficamente il frame adibito alle impostazioni e
ne estrae i dati dopo aver effettuato gli opportuni controlli.
La grossa dimensione data dal grande numero di controlli in input (ad
esempio il codice che controlla il valore di Alpha: pu essere maggiore di 1
29
solo se la tabella scelta a lista di concatenamento), e anche dalle
impostazioni grafiche per rendere il frame usabile.

Ecco alcune immagini:

Inserimento Passo-Passo (Tabella ad indirizzamento Aperto):
Qui viene mostrato il frame in cui possibile visualizzare graficamente la
procedura di inserimento di un elemento allinterno di una tabella Hash ad
indirizzamento aperto.
Una label indica lo stato in cui ci si trova, i valori delle funzioni Hash e le
prossime operazioni che verranno effettuate.
Sulla sinistra una rappresentazione grafica della tabella, in cui le caselle
vuote sono rappresentate tramite rettangoli bianchi, mentre quelle occupate
vengono visualizzate come rettangoli neri con la chiave al loro interno.



30




Inserimento Passo-passo (Tabella a Lista di concatenamento)
In questo caso invece vengono visualizzate le liste come concatenamento di
pi elementi.

Il pulsante Avanti permette di passare da uno stato allaltro, aggiornando la
scritta in alto e procedendo con tutti i vari passaggi dellinserimento:

-Estrazione casuale del numero da inserire
-Calcolo dellHash
-Inserimento in testa alla lista




31

Questa schermata mostra la finestra di modifica delle impostazioni sulle quali
effettuare inserimenti o statistiche





Da qui possibile modificare queste impostazioni, che verranno poi
utilizzate sia per la visualizzazione dellinserimento passo-passo, che per la
parte statistica.


Tipo di Tabella Hash:
Questa opzione permette di scegliere se linserimento passo passo o le
statistiche devono essere effettuati utilizzando una tabella Hash ad
indirizzamento Aperto oppure una a Lista di concatenamento.

Tipo di Hash:
In caso venga selezionata una tabella Hash a indirizzamento Aperto, questa
opzione permette di scegliere la sequenza di scansione utilizzata.
32

Numero di tabelle Hash da riempire:
Quando viene effettuata una statistica, possibile visualizzare i risultati
ottenuti da inserimenti (o ricerche) effettuando una media sui singoli risultati
ottenuti da N tabelle Hash. Questo parametro indica quante tabelle Hash
devono essere prese in considerazione. Pi alto, quindi, pi la media risulta
stabile.

Numero di caselle per ogni tabella Hash:
Questo parametro indica il numero di caselle che devono possedere le
tabelle Hash. In caso di inserimenti statistici, consigliato inserire un numero
di caselle abbastanza alto, mentre per le visualizzazioni passo passo
naturalmente il programma pi usabile nel caso in cui le tabelle abbiano
poche caselle.

Numero di Millisecondi tra un inserimento e laltro:
Quando viene effettuata una statistica, il Grafico risultante viene aggiornato
ad ogni inserimento. Questo parametro permette di decidere la velocit di
aggiornamento del grafico. Se si vuole visualizzare un grafico in maniera pi
lenta allora basta inserire un numero abbastanza alto.

Massimo Coefficiente di carico:
Quando vengono effettuate le statistiche, pu essere necessario voler
prendere in considerazione una tabella Hash solo finch il suo coefficiente di
carico non raggiunge una certa soglia. Con questo parametro possibile
specificare quale soglia si deve raggiungere. Se la tabella Hash ad
indirizzamento aperto, allora questo parametro devessere massimo uguale ad
1. Se viene inserito un numero maggiore di 1, naturalmente viene considerato
linserimento solo finch la tabella non piena.



33








Grafici:



3.2 Pacchetto relativo ai Grafi:
La parte del programma che si occupa delle tabelle hash permette di
modificare diverse impostazioni sul tipo di grafico da effettuare, e sul tipo di
tabella hash da utilizzare.
La parte relativa ai Grafi, invece, permette di creare nuovi grafi, definendo
Nodi e archi, oppure lasciare al programma il compito di creare un grafo
connettendo in maniera casuale i nodi, e assegnando agli archi un peso
positivo anchesso casuale.
34
I Nodi vengono visualizzati come dei cerchi pieni con dentro la loro Label
(univoca) e la distanza dal nodo centrale (inizializzata a +infinito), e gli archi
come delle frecce, orientati.

In questo progetto, ho implementato i Grafi tramite Liste di Adiacenza.
Pi precisamente, questo lUML:



Elenco delle classi:
Interfaccia_Utente(103 LOC):
Attributi: -
Metodi: Costruttore, Main
Questa classe si occupa solo dellinterfaccia utente, e di gestire gli eventi
richiamati tramite il menu.

Pannello Grafo(120 LOC):
Attributi: Grafo G
Metodi: Metodi di Mouse Listener e MouseMotionListener, paintComponent.
35
Questa classe si occupa di visualizzare graficamente il grafo che contiene.
Inoltre, capace di ascoltare gli eventi del mouse per rendere possibile il
trascinamento dei nodi (tramite Drag) e la selezione dei nodi con un click del
mouse.

Grafo(182 LOC):
Attributi:
Alfabeto : Elenco di caratteri che possono essere utilizzati per le label dei
nodi.
Metodi:
Aggiungi Nodo, Aggiungi Arco, draw, getNodeOn(x,y), set Source, Dijkstra,
EstraiGrafodeiCamminiMinimi.
Questa classe rappresenta un grafo vero e proprio. Oltre ai metodi di base
(aggiungi nodo o arco) sono presenti il metodo draw, che disegna tutti i nodi e
tutti gli archi presenti nel grafo, il metodo getNodeOn(x,y) che restituisce (se
presente) il nodo che contiene le coordinate x,y, set Source, che imposta la
sorgente da utilizzare per lalgoritmo di Dijkstra, e infine un metodo
EstraiGrafodeiCamminiMinimi, che crea un nuovo Grafo contenente solo gli
archi che fanno parte di un qualche cammino minimo.

Nodo(138 LOC):
Attributi:
Label La label visualizzata quando viene disegnato il nodo
Distanza La distanza trovata tramite lalgoritmo di Dijkstra dal nodo
sorgente
Selected Indica se il nodo selezionato (per effettuare qualche
operazione)
X,Y Le coordiate X e Y del nodo.
Adiacenti Vector di tutti i nodi adiacenti.
Metodi:
Costruttore, getDistance, setDistance, isAdj(Nodo), getArcTo(Nodo),
contains(x,y), compareTo(Nodo)
36
Questa classe rappresenta un singolo nodo. I Metodi degni di nota sono
isAdj(Nodo), che indica se il nodo passato adiacente, getArcTo(Nodo) che
restituisce(se presente) larco al nodo passato in input, contains(x,y) che indica
se le coordinate (x,y) sono presenti allinterno del nodo, e compareTo(Nodo)
che indica se il nodo passato per input pi o meno vicino alla sorgente.

Cenni sulla programmazione:
Il Nodo implementa linterfaccia Comparable, in questo modo, possibile
utilizzare strumenti gi presenti in Java per ordinarli. Ho utilizzato le code di
priorit per implementare efficacemente lalgoritmo di Dijkstra, in modo da non
dover implementare altre classi.
Quando viene cliccato un punto del grafo, il Pannello Grafo chiede al grafo
quale dei suoi nodi contiene le coordinate del punto cliccato, il Grafo chiede a
sua volta ai suoi nodi, se uno dei nodi risponde true allora questo viene
selezionato.
La selezione permette di impostare un nodo come sorgente (se effettuata
tenendo premuto Shift) oppure di aggiungere archi ai nodi (se cliccati tenendo
premuto Ctrl).

Oltre a questo, il Grafo permette di visualizzare lesecuzione passo-passo
dellalgoritmo di Dijkstra.

Immagini del programma:

Il programma, allinizio crea un nuovo grafo in maniera casuale, con 5 nodi e
un numero di archi variabile, (Circa 10).










37
Qui viene visualizzata una schermata di esempio contenente un grafo con 5
nodi e 8 archi. Il grafo stato creato in maniera casuale dal programma.


I pesi degli archi sono scritti a 1/3 del loro percorso. Tra due nodi connessi
tra loro (in questo caso ad esempio C e D), possibile distinguere il peso
dellarco
C->D = 60
D->C = 71

Dalla loro posizione.
Il menu Grafi permette di aggiungere nuovi nodi e archi.

38


Il nuovo nodo verr inserito in una posizione casuale della finestra, e gli viene
assegnata la label successiva, seguendo lordine delle lettere dellalfabeto. A,
B, C e cos via.
Finite le lettere dellalfabeto, verranno generate label a due lettere AA, AB,
AC, e cos via. Dopo queste vengono generate label a 3 lettere e cos via.

39


Dal menu possibile cliccare su Inserisci Arco. E possibile inserire gli archi
tenendo premuto Ctrl e cliccando sul primo e poi sul secondo arco da
inserire.
Non necessario cliccare sulla voce del menu per inserire gli archi.
Selezionando il primo nodo, questo diventa rosso. Una volta cliccato sul
secondo nodo comparir un pop up che chieder il peso dellarco da inserire.
40


Una volta che il grafo stato creato o modificato come si desidera,
possibile applicare lalgoritmo di Dijkstra, dopo aver selezionato (tenendo
premuto shift) un nodo da utilizzare come sorgente.
Il nodo Sorgente verr colorato di rosso, poi sar possibile applicare
lalgoritmo di Dijkstra.
Ad ogni click sulla voce, verr eseguito un unico passo dellalgoritmo, in
modo tale da mostrare il calcolo delle distanze progressivamente.
Terminata lesecuzione dellalgoritmo, sar possibile estrarre il cammino dei
cammini minimi, cliccando sullapposita voce del menu.
Verr creato un nuovo grafo, identico a quello precedente, in cui saranno
presenti tutti e soli gli archi facenti parte di un qualche cammino minimo.

41


Il programma, oltre a permettere le operazioni sui grafi, permette anche di
effettuare operazioni sulle tabelle Hash.


42
3.3 Algoritmi utilizzati:

Tabelle Hash a indirizzamento aperto:

-Inserimento(K)
while((K > 0) && (i<M)){
if(Data[Hash(K,i)] < 0){
Data[Hash(K,i)] = K;
K = -1; //cos il while si "chiude"
}
i++;
}

Considerazioni:
Tutti gli elementi di Data sono allinizio impostati a -1.
Quindi se Data[Hash(K,i)] < 0 significa se vuoto.
Una volta inserito il dato, possiamo impostare K a -1 in modo da uscire dal
while.
Complessit nel caso peggiore: O(M) (Vengono provate tutte le caselle)
Complessit nel caso medio: Dipende fortemente da e dal tipo di Hash
utilizzato.





-Ricerca(K)
while((Data[Hash(K,i)]!=K)&&(i<M))
i++;
if(i < M)
return i;
return -1; //Se l'elemento non viene trovato torno -1

}

Considerazioni:
Questo algoritmo continua a ricercare lelemento seguendo la sequenza di
scansione finch non lo trova.
Uscito dal while, se i < M allora labbiamo trovato in i passi, altrimenti
lelemento non presente.

Complessit nel caso peggiore: O(N)
43
Complessit nel caso medio: Dipende fortemente da e dal tipo di Hash
utilizzato.


Tabelle Hash a lista di concatenamento:

Inserimento:

int QuickHash = Hash(Info);
if (Data[QuickHash] == null)
Data[QuickHash] = new Lista();
Data[QuickHash].Inserisci(new Nodo(Info));

Considerazioni:
Calcoliamo lhash del dato da inserire, e controlliamo se nella posizione
specificata gi presente una lista. Se non presente la creiamo, altrimenti
semplicemente lelemento viene inserito in cima alla lista.
Linserimento in testa richiede tempo O(1), quindi la complessit totale
dellalgoritmo :
Nel caso peggiore: O(1)
Nel caso medio: O(1)


Ricerca:


int QuickHash = Hash(Info);
return Data[QuickHash].Search(Info);


Considerazioni:
Come si pu vedere dallalgoritmo, la ricerca in una tabella Hash, non altro
che una ricerca nella lista specifica. Il calcolo dellHash richiede tempo O(1),
mentre la ricerca nella lista richiede tempo O(n) con n = dimensione della lista.

Per la propriet dellhash uniforme semplice, se ogni elemento ha probabilit
1/M di essere inserito in una specifica posizione, allora la dimensione di ogni
lista sar circa N/M con N = Numero di elementi inseriti.

Ma = N/M.
44

Scegliendo M = cN, si trova che = c.
La complessit finale quindi O() = O(c) = O(1).

Complessit nel caso peggiore:
Nel caso peggiore, cio in cui la nostra funzione Hash Pessima, tutti gli
elementi saranno inseriti nella stessa lista, e si avr che ununica lista avr
dimensione N, mentre tutte le altre avranno lunghezza 0.
In questo caso, i tempi di esecuzione degradano a O(N), perch si dovr
cercare lelemento in una lista di dimensione N.
Nel caso medio, comunque, la ricerca avr costo O() cio O(1).

3.4 Alcune statistiche

Ecco alcuni grafici statistici come output del progetto.
I grafici sono stati ottenuti mediando i risultati di 50 diverse tabelle hash ad
indirizzamento aperto, da 500 elementi ciascuna. Alli-esima colonna vediamo
il numero di collisioni trovate per linserimento delli-esimo elemento. Gli
inserimenti si fermano quando la tabella hash raggiunge un coefficiente di
carico del 100%.
Il tipo di funzione Hash utilizzata lHash Moltiplicativo.

Grafico numero 1:
Hashing Lineare
45

Questo grafico mostra come aumenta il numero di operazioni necessarie per
ogni inserimento allaumentare del fattore di carico.
Il tipo di Hash utilizzato lHash lineare.
E da notare che in questo caso, su una tabella di 500 caselle, quando il
fattore di carico abbastanza alto si arriva addirittura a 207 inserimenti, cio
vengono scandite pi del 40% delle caselle prima di trovarne una libera
(41.4% precisamente)
46
Grafico numero 2:
Lhashing Quadratico

Qui il tipo di Hash utilizzato invece lhash di tipo quadratico. E possibile
notare un miglioramento in due direzioni:
Da una parte landamento della curva pi piatto. Ci significa che
linserimento meno soggetto al coefficiente di carico rispetto allhashing
lineare.
Un altro miglioramento, molto pi significativo, sta nel massimo numero di
inserimenti.
In questo caso, in media, quando la tabella hash molto piena, vengono
scansionate il 89 caselle, e rispetto alle 207 che si ottenevano con lhashing
lineare questo un ottimo miglioramento.
In percentuale, stavolta, le caselle scandite prima dellinserimento sono solo
l1,7%
47
Grafico numero 3:
-Hashing doppio



Questo tipo di Hash, come possibile notare dal grafico, offre prestazioni
ancora migliori rispetto agli altri due. La curva ancora pi appiattita, ma
addirittura in questo caso, il valore massimo 29. Cio in media, con
coefficiente di carico oltre il 90%, vengono comunque scansionate circa lo
0.58% delle caselle prima di trovarne una libera, e questo risultato
sorprendente.

Questi grafici, dimostrano quale peso ha il tipo di funzione Hash sulle
prestazioni finali dellapplicazione. Una funzione Hash perfetta permetterebbe
di avere i dati sempre e comunque con una sola operazione, e questo
porterebbe ad avere applicazioni molto pi veloci di come siamo abituati a
vedere.


Tabelle Hash a lista di concatenamento:
48

Una tabella Hash a lista di concatenamento, statisticamente si comporta in
maniera molto migliore.
Questo grafico stato ottenuto effettuando degli inserimenti e ricercando
degli elementi casuali allinterno della tabella Hash. Le ricerche, quindi,
possono avere successo o meno. Nellasse delle X, indicato il coefficiente di
carico, che va aumentando pian piano da 0 a 5.
Un coefficiente di carico pari a 5, significa che la tabella Hash stata forzata
a contenere cinque volte il numero di elementi per la quale stata creata.


Come si pu vedere dal grafico, landatura piuttosto oscillatoria, ma
comunque, in media, vengono ispezionati due elementi prima di trovare
lelemento cercato (o prima di essere in grado di dire che lelemento non
stato trovato).
Questo risultato sorprendente in diversi modi.

Analisi sui dati:
Indirizzamento
Aperto
Lista di
concatenamento
Spazio Occupato Fisso Variabile
49
Tempo di risposta (
basso)
Molto veloce O(1) Molto veloce O(1)
Tempo di risposta(
alto)
Molto lento
(Dipende
dallhash)
Molto veloce
Massimo numero di
elementi
M Infiniti


Spazio occupato:
Una tabella Hash ad indirizzamento aperto, occupa sempre la stessa
quantit di memoria, indipendentemente da quanto carica. Anche se nella
tabella sono presenti pochissimi elementi, questa occuper memoria come se
fosse completamente piena.
Una tabella Hash a lista di concatenamento, invece, occupa una quantit di
memoria proporzionale al numero di elementi contenuti. Quando vuota
occupa pochissimo spazio, man mano che si riempie la memoria viene
allocata.



Velocit nelle operazioni:
Inserimento:
Durante linserimento, una tabella Hash a indirizzamento aperto, deve
cercare la prima casella libera. Questa operazione, nel peggiore dei casi,
degenera in un tempo computazionale lineare nel numero di caselle disponibili
O(M).
Statisticamente, abbiamo visto dai grafici, che tale operazione pu essere
effettuata in un numero di operazioni che si avvicina, nel peggiore dei casi, al
15% del numero di caselle. Per quanto basso, per, sempre un tempo che
dipende fortemente dalla dimensione della tabella.
Una tabella Hash a lista di concatenamento, invece, effettua un numero di
operazioni costante: Calcolo dellhash e inserimento in testa alla lista.
Questo richiede un tempo costante O(1).

Ricerca di un elemento:
50
La ricerca di un elemento, pu essere considerata, in una tabella Hash ad
indirizzamento aperto, come linserimento di quellelemento, considerando il
coefficiente di carico al momento della chiamata. La ricerca, quindi, cos come
linserimento, richiede una quantit di tempo lineare nel numero di caselle
disponibili.

O(M)

In una tabella Hash a lista di concatenamento, come si pu vedere
direttamente dal grafico, il numero di operazioni necessarie alla ricerca di un
elemento dipende fortemente dal fattore di carico. In media, il tempo di ricerca
di un inserimento

O(/2)

Considerando, per, che si cerca di dimensionare la tabella in modo che
sia circa uguale ad 1, questo tempo pu essere approssimato a

O(1)

Altre considerazioni:
In caso di una cattiva stima del numero di elementi da inserire, abbiamo visto
che una tabella Hash a lista di concatenamento in grado non solo di
contenere qualsiasi numero di elementi, ma anche di evitare la degenerazione
dei tempi di risposta, mantenendo la velocit molto molto alta. Inoltre, per
quanto le tabelle hash a indirizzamento aperto possano essere ottimizzate
cambiando tipo di hash, il numero minimo di collisioni, quando il coefficiente di
carico supera l80%, i loro tempi di risposta degenerano molto.
51
Capitolo 4
Applicazioni delle tabelle Hash.

Durante il periodo in cui ho lavorato come programmatore presso lazienda
CISE, ho avuto modo di sperimentare quanto possono migliorare le prestazioni
di unapplicazione utilizzando le tabelle hash al posto delle liste.

Mi stato presentato il seguente problema:
Dato un file di testo contenente dati su tutte le visite effettuate da un
ospedale, dare una stima abbastanza precisa di quanti erano i pazienti visitati
nellospedale.

In ogni riga del file di testo erano presenti, tra gli altri campi, i codici fiscali dei
pazienti visitati.
Il problema era reale, quindi non in tutte le righe erano presenti tutti i dati, in
alcune mancava il codice fiscale, in altre il nome o il cognome, o addirittura
alcune righe non presentavano alcun campo di identificazione compilato
(questo il motivo per cui si richiede una stima abbastanza precisa piuttosto
che il numero esatto).

Naturalmente, contare le righe del file di testo, non risolveva il problema, dato
che nel file erano presenti pi di una riga per ogni paziente.
In particolare, erano presenti una riga per ogni medicinale prescritto o visita
effettuata.

Per risolvere il problema sono stati tentati diversi approcci. Si consideri che il
numero di medicinali prescritti e visite effettuate in un ospedale in un mese
una quantit di dati molto grande nellordine di centinaia di migliaia di righe.
Primo tentativo:
Inserire in un database temporaneo tutte le righe e i codici fiscali per
poi effettuare una query di tipo Select Distinct(Codice_Fiscale) From
Tabella.
52
Questo tipo di approccio, per, richiedeva circa una ventina di minuti per
ottenere il risultato esatto, tra il tempo per effettuare gli inserimenti e il tempo di
esecuzione della query. Il programma non abbastanza veloce da essere
considerato usabile.
Secondo tentativo:
Leggere tutte le righe una dopo laltra, ed inserire in una lista i codici
fiscali dei pazienti, solo se non presenti.
Questa soluzione, allinizio sembrava essere pi veloce della prima, in
quanto fino a circa il 25% della scansione del file procede speditamente.
Purtroppo, per, con lincremento delle dimensioni della lista, ad ogni rigo
bisogna controllare una lista che diventa via via sempre pi lunga. Per quanto
migliore della soluzione precedente, per, lapplicazione non ancora
abbastanza usabile, in quanto i tempi di calcolo si aggirano intorno ai 15
minuti.
Terzo tentativo:
Leggere tutte le righe una dopo laltra, ed inserire in una Tabella Hash a
lista di concatenamento (con circa 10.000 caselle) tutti i codici fiscali gi
letti. Prima dellinserimento di ogni codice fiscale, viene richiesto alla
tabella Hash se esso presente. Se non presente, viene inserito, e un
contatore viene aumentato.
In questo caso, la soluzione viene fornita in un tempo di esecuzione pari a
circa 15-20 secondi. Il programma usabile.Lincremento di prestazioni ha
reso lutente finale davvero molto soddisfatto.
53

Capitolo 5
Conclusioni:
I programmi potrebbero essere migliorati in diversi modi:
Nella parte relativa ai grafi, ad esempio, potrebbe essere previsto
linserimento di archi di peso negativo, ed implementare gli algoritmi relativi
alla ricerca di cammini minimi nel caso di archi di peso negativo, ad esempio
Bellman-Ford.

La parte relativa alle Tabelle Hash potrebbe prevedere linserimento di un
elemento deciso dallutente, e la possibilit di creare nuove funzioni Hash
definite dallutente a runtime.
54

Capitolo 6
Riferimenti Bibliografici:

T.H. Cormen, C.E. Leiserson, R.L. Rivest, C. Stein, Introduzione
agli Algoritmi, McGrawHill, seconda edizione, 2004

Appunti e slide del corso di Algoritmi 2 Professor Domenico Cantone

Wikipedia.
http://it.wikipedia.org/wiki/Hash_table

Potrebbero piacerti anche