Dizionari

Note

Un dizionario è una struttura dati astratta che contiene elementi accessibili direttamente, data la loro chiave. Nei nostri esempi assumiamo che le chiavi siano numeri naturali. Le operazioni supportate da un dizionario sono Insert, Delete e Search.

Nel caso in cui le possibili chiavi siano un numero limitato, un'implementazione di un dizionario è un vettore di puntatori. Le chiavi vengono usate come indice del vettore, e le operazioni sul dizionario sono implementate come:

  • Insert(D,e): D[e.key] <- e
  • Delete(D,e): D[e.key] <- NIL
  • Search(D,e.key): return D[e.key]

Per ciascuna di queste operazioni si ha complessità computazionale e complessità spaziale , con dominio delle chiavi.

Tabella hash

Note

Una tabella hash implementa un dizionario con una complessità in memoria pari al numero di chiavi per cui è effettivamente presente un valore. Tipicamente si prealloca uno spazio per chiavi, e si usa come indice della tabella il risultato del calcolo di una funzione della chiave detta funzione di hash .

Idealmente mappa ogni chiave su un distinto elemento del suo codominio, tuttavia ciò è impossibile chiamiamo quindi collisione i casi in cui, date due chiavi con abbiamo che .

Metodo dell'indirizzamento chiuso

Nel metodo dell'indirizzamento chiuso (anche chiamato open hashing o chaining) ogni riga della tabella (bucket) contiene la testa di una lista al posto del puntatore ad un singolo elemento. Nel caso di collisione l'elemento nuovo viene aggiunto in testa alla lista (). Per cercare/cancellare un elemento di chiave è necessario cercare nell'intera lista di quelli del bucket .

Nel caso pessimo tutti gli elementi collidono, dando origine ad una lista lunga elementi, e quindi Insert/Delete/Search costeranno . Definiamo quindi il fattore di carico: Se assumiamo che una scelta di fa si che ogni chiave abbia la stessa probabilità di finire in una qualsiasi delle celle (Ipotesi di Hashing Uniforme Semplice, IHUS), allora la lunghezza media di una lista è il fattore di carico , il tempo medio per cercare una chiave (presente o non presente) è . Se il fattore di carico non è eccessivo tutte le operazioni sono in media.

Si ha che il numero medio di tentativi prima di trovare un elemento desiderato è:

Metodo dell'indirizzamento aperto

Nel metodo dell'indirizzamento aperto (anche chiamato open addressing o closed hashing), in caso di collisione si seleziona secondo una regola deterministica l'indirizzo di un altro bucket di destinazione (procedimento di ispezione). Nel caso non si trovino bucket vuoti l'inserimento può fallire (), si rialloca una tabella più grande, vuota, e si reinseriscono tutti gli elementi della vecchia nella nuova (ricalcolando la loro hash), incluso il nuovo ().

Si modifica inoltre la procedura di ricerca, affinché, se l'elemento non viene trovato nel suo bucket, essa effettui la stessa ispezione. La cancellazione è effettuata inserendo un opportuno valore (tombstone) che non corrisponde a nessuna chiave.

Procedure di ispezione

Ispezione lineare e clustering

Il metodo di ispezione più semplice è l'ispezione lineare. Dato il bucket dove avviene la collisione al primo () tentativo di inserimento, si sceglie come bucket candidato per l'-esimo inserimento.

Tuttavia se ci sono molte collisioni su un dato bucket, peggiorerà la probabilità di collisione in tutte le vicinanze. Questo fenomeno è detto clustering primario delle collisioni. Per alcune scelte di , il peggiorare delle prestazioni dovuto al clustering dell'ispezione lineare è molto forte. È possibile avere clustering di dimensione logaritmica nella dimensione della tabella, effettuando rehashing molto prima che sia piena.

Ispezione quadratica

Per mitigare il fenomeno del clustering è possibile utilizzare il criterio di ispezione quadratica, dove . Questa formula rimuove il clustering primario, tuttavia chiavi con la stessa posizione iniziale generano ancora più clustering: hanno la stessa sequenza di ispezione.

Dimostrazione

Si ha che genera tutti i valori in . Supponiamo per assurdo che esistono tali che: Fattorizzando abbiamo: Se si ha , che è assurdo. Se dati i range possibili la somma è compresa tra , che è assurdo. Abbiamo quindi che , ma e , e quindi almeno uno tra e è dispari. Essendo solo il fattore pari può essere multiplo di , ma e , che è assurdo.

Doppio hashing

Note

Definiamo . Allora il passo di ispezione dipende dalla chiave. Per essere sicuro di ispezionare tutti i bucket deve essere coprimo con . Per basta fare si che generi solo numeri dispari, mentre se è primo basta fare sì che generi un numero minore di .

Funzioni di hash

Metodo della divisione

È un metodo semplice: Normalmente ha una distribuzione non uniforme e va evitato se , tuttavia ha distribuzione quasi uniforme se è primo vicino ad una potenza di due.

Metodo della moltiplicazione

Un altro metodo semplice è: In questo caso, la dimensione della tabella non è critica. Una scelta possibile per è , rappresentato a virgola fissa. Un modo pratico di calcolare in C, nota la larghezza di parola del calcolatore è calcolare , moltiplicare per e troncare. Per esempio per parole di :

(uint32_t)(k * (double)ALPHA * ((uint64_t)1 << 32)))
Hashing universale

Si può dimostrare che con primo, per qualunque distribuisce uniformemente le chiavi nella tabella.

Hashing crittografico

Consideriamo il caso in cui il codominio di sia enorme. Con una buona che rispetti l'IHUS servono moltissime chiavi prima di vedere una collisione, Non potrò mai materializzare la tabella di hash, ma l'hash di un valore funge da etichetta unica del valore stesso. È importante che non si possano trovare collisioni o preimmagini in tempo utile. Esempio possono essere SHA-2-256 con .