Skip to content

Latest commit

 

History

History
168 lines (95 loc) · 8.2 KB

a4. Tabelle Hash.md

File metadata and controls

168 lines (95 loc) · 8.2 KB

Molte applicazioni richiedono un insieme dinamico che supporta le operazioni dizionario INSERT, SEARCH, DELETE.

Una struttura affidabile per implementare i dizionari sono le tabelle hash, che anche se nel caso pessimo performano come le linked list, nella pratica sono molto veloci, impiegando un tempo medio di $O(1)$.

Tabelle Ad Accesso Diretto

è una tecnica semplice che funziona quando l'insieme totale delle chiavi possibili ($U = {0,1,...,m-1}$) è piccolo. Per rappresentare l'insieme dinamico si usa un'array (tabella ad accesso diretto) denotata da $T[0:m-1]$, ogni posizione corrisponde ad una possibile chiave di $U$

![[direct-access.png|center]]

Operazioni

Le operazioni disponibili impiegano tutte $O(1)$:

DIRECT-ACCESS-SEARCH(T, k):
	return T[k]

DIRECT-ACCESS-INSERT(T, x):
	T[x.key] = x

DIRECT-ACCESS-DELETE(T, x):
	T[x.key] = NIL

Tabelle Hash

Il problema delle [[#Tabelle Ad Accesso Diretto]] è che dipendono dalla dimensione di $U$, che se supera la dimensione massima della memoria diventa impossibile da rappresentare. Un secondo problema è che l'insieme di chiavi effettivamente usate potrebbe essere molto più piccolo di $U$, e quindi la maggior parte dello spazio utilizzato sarebbe inutile.

Una tabella hash richiede molto meno spazio di una tabella ad accesso diretto, con un'utilizzo di memoria di $\Theta (|K|)$ mantenendo il tempo di ricerca di $O(1)$.


La "fregatura" consiste nel tempo di ricerca che rappresenta il caso __medio__, e non il __peggiore__

Funzione Di Hash

Nell'accesso diretto l'elemento con chiave k viene memorizzato nello slot k, mentre nelle tabelle hash si usa una funzione di hash ($h$) per calcolare il numero di slot di $k$, così che l'elemento vada nello slot $h(k)$.

In questo modo si riduce la dimensione dell'array, che invece di avere dimensione $|U|$ avrà dimensione $m$.


Un esempio semplice, ma non molto buono, è la funzione: $$h(k) = k \mod m$$
C'è però un problema: due chiavi potrebbero puntare allo stesso slot, chiameremo questa situazione __collisione__

Collisioni

La soluzione ideale sarebbe di evitare le collisioni, scegliendo magari una funzione di hash adatta.

Un'altra idea è quella di far sembrare $h$ "casuale", ma ricordandosi che la funzione deve essere deterministica, ovvero che dato lo stesso $k$ la funzione deve avere sempre lo stesso risultato.

Dato che $|U| > m$ ci saranno sempre almeno due chiavi che avranno lo stesso valore hash, rendendo impossibile evitare le collisioni.


Una funzione ideale $h$, avrebbe, per ogni possibile $k$, un output che sia un elemento che sia scelto in modo casuale e indipendente nel range $\{0,1,...,m-1\}$, una volta scelto il valore ogni chiamata  $h(k)$ ritorna sempre quel valore

Concatenamento

Consiste nell'usare delle linked list per ogni slot dell'array, ogni volta che la funzione di hashing genera lo stesso risultato, l'elemento viene aggiunto alla linked list di quella chiave.

![[Pasted image 20230615165741.png|center]]

Quando le collisioni vengono risolte attraverso il concatenamento, le operazioni di dizionario sono semplici da implementare, e hanno un tempo di esecuzione di $O(1)$:

  • L'inserimento presuppone che l'elemento da inserire non sia nella lista, se lo è bisogna cercare a scapito di un costo aggiuntivo
  • La ricerca nel tempo peggiore è proporzionale alla lunghezza della lista
  • L'eliminazione impiega $O(1)$ se prende come input l'elemento $x$ e non la sua chiave, rendendo non necessaria la ricerca.
title: Quanto impiega l'hashing con concatenamento?

Il tempo di esecuzione __peggiore__ è nell'ordine di $\Theta(n)$, e richiede che tutti gli $n$ elementi vadano sullo stesso slot creando una lista lunga $n$.

Il tempo di esecuzione __medio__ dipende da quanto bene la funzione $h$ distribuisce le chiavi tra gli slot, arrivando ad una possibilità che due chiavi collidano di $1/m$ 

Dato il fattore di caricamento $\alpha = n/m$

In una tabella hash le cui collisioni sono risolte tramite concatenamento, la ricerca in media impiega $\Theta(1 + \alpha)$, assumendo una funzione di hashing uniforme e indipendente

Indirizzamento Aperto

Descriviamo l'indirizzamento aperto come un metodo per risolvere le collisioni che non utilizza spazio al di fuori della tabella hash.

  • Tutti gli elementi occupano la tabella stessa
  • Ogni valore della tabella contiene un elemento dell'insieme dinamico oppure NIL
  • Nessuna lista o altri elementi sono memorizzati fuori la tabella
  • La tabella può essere riempita così che non possono essere inseriti altri elementi
  • Una conseguenza è che il fattore di caricamento $\alpha$ non può mai superare 1

Le collisioni sono gestite in questo modo: quando un elemento deve essere inserito, viene posizionato nella sua "prima-scelta" (first-choice) se possibile. Se non possibile (la posizione è occupata), il nuovo elemento viene inserito nella sua "second-choice", e così via fino a che non si trova uno spazio vuoto dove poter piazzare il nuovo elemento.

Ricerca

Per cercare un elemento bisogna esaminare il suo slot preferito per diminuire le preferenze fino a che non trovi l'elemento desiderato, oppure uno slot vuoto.

La procedura HASH-SEARCH richiede in input una tabella e una chiave, ritornando la posizione $q$ della chiave, oppure NIL se la chiave non è presente.

Inserimento

Per inserire un elemento bisogna esaminare successivamente (probe) la tabella fino a che non troviamo uno slot vuoto in cui inserire la chiave.

Invece di utilizzare un ordine finito $0,1,...,m-1$ (che impiega $\Theta (n)$), la sequenza di posizioni esaminate dipende dalla chiave inserita, che in aggiunta ad un probe number che indica quale slot controllare, rendendo la funzione:$$h: U \times {0,1,...,m-1} \to 0,1,...,m-1$$


L'indirizzamento aperto richiede che per ogni chiave $k$, la sequenza di probe $< h(k, 0), h(k, 1),..., h(k, m-1) >$ sia una permutazione di $\{0,1,...,m-1\}$, così che ogni posizione possa essere considerata come slot per una nuova chiave

HASH-INSERT(T, k):
	i = 0
	do:
		q = h(k, i)
		if T[q] == NIL:
			T[q] = k
			return q
		else:
			i++
	while i != m
	
	error "hash table overflow"

La procedura di inserimento assume che tutti gli elementi della tabella hash siano chiavi senza informazioni satellite, e da per scontato che l'elemento da inserire non sia già presente nella tabella.

Eliminazione

Eliminare un elemento dalla tabella può essere complicato, perché se si imposta semplicemente come vuoto allora la ricerca potrebbe fermarsi prima di trovare un qualche elemento che è stato inserito quando lo slot era ancora occupato.

Per risolvere questo problema si segna lo slot come DELETED invece che NIL, così che HASH-INSERT lo possa trattare come slot vuoto in cui poter inserire un nuovo elemento, mentre HASH-SEARCH lo tratta come slot da saltare per controllare le posizioni successive.

Doppio Hashing

Assumendo un hashing indipendente e uniforme, la sequenza di ogni chiave può essere una delle $m!$ permutazioni di ${0,1,...,m-1}$, ma implementare un hashing del genere è molto difficile, infatti nella pratica vengono usate delle approssimazioni, come il doppio hashing.

Il doppio hashing offre uno dei migliori metodi per l'indirizzamento aperto, dato che le permutazioni prodotte hanno molte delle caratteristiche delle permutazioni scelte casualmente.

Il doppio hashing usa una funzione hash nella forma: $$h(k, i) = (h_1(k) + i \cdot h_2(k)) \mod m$$

Dove $h_1$ e $h_2$ sono funzioni hash ausiliari.

Dati una tabella di $m = 13$, $h_1(k) = k \mod 13$ e $h_2(k) = 1 +  (k \mod 11)$

![[Pasted image 20230616123119.png|center]]

Linear Probing

Linear probing è un caso speciale di doppio hashing, è il modo più semplice per risolvere le collisioni in indirizzamento aperto.

Se lo slot $T[h_1(k)]$ è pieno, allora si controlla $T[h_1(k) + 1]$ e così via fino ad arrivare alla fine, si ricomincia poi da $T[0]$ e si controlla fino a $T[h_1(k) -1]$, che se è pieno implica che la tabella è piena.

è un caso speciale di doppio hashing in quanto si può scrivere che $h_2(k) = 1$, rendendo la funzione finale $$h(k) = (h_1(k)+i) \mod m$$