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.
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
DATABASE Dal modello concettuale ER all’applicativo finale in Access, Visual Basic, Pascal, Html e Php: All'interno esempi di applicativi realizzati con Access, Visual Studio, Lazarus e Wamp