Come funziona una tabella hash?


494

Sto cercando una spiegazione di come funziona una tabella hash - in un inglese semplice per un semplice come me!

Ad esempio, so che prende la chiave, calcola l'hash (sto cercando una spiegazione come) e quindi esegue un qualche tipo di modulo per capire dove si trova l'array in cui è memorizzato il valore, ma è lì che la mia conoscenza si ferma .

Qualcuno potrebbe chiarire il processo?

Modifica: non sto chiedendo specificamente come vengono calcolati i codici hash, ma una panoramica generale su come funziona una tabella hash.


4
Di recente, ho scritto questo articolo ( en.algoritmy.net/article/50101/Hash-table ) descrivendo diversi modi, come archiviare e cercare i dati, con accento sulle tabelle hash e le loro strategie (concatenamento separato, sondaggio lineare, doppio hash )
malejpavouk,

1
Si potrebbe pensare a una tabella hash come a una versione estesa di un array, che non si limita solo alle chiavi intere consecutive.
user253751

Risposte:


913

Ecco una spiegazione in termini di profani.

Supponiamo che tu voglia riempire una biblioteca di libri e non solo inserirli lì dentro, ma vuoi essere in grado di ritrovarli facilmente quando ne hai bisogno.

Quindi, decidi che se la persona che vuole leggere un libro conosce il titolo del libro e il titolo esatto per l'avvio, allora è tutto ciò che dovrebbe prendere. Con il titolo, la persona, con l'aiuto del bibliotecario, dovrebbe essere in grado di trovare il libro facilmente e rapidamente.

Quindi, come puoi farlo? Bene, ovviamente puoi tenere una specie di elenco di dove metti ogni libro, ma poi hai lo stesso problema della ricerca in biblioteca, devi cercare l'elenco. Certo, l'elenco sarebbe più piccolo e più facile da cercare, ma non si desidera cercare in sequenza da un'estremità della libreria (o dell'elenco) all'altra.

Vuoi qualcosa che, con il titolo del libro, possa darti il ​​posto giusto in una volta, quindi tutto ciò che devi fare è semplicemente passare allo scaffale giusto e prendere il libro.

Ma come si può fare? Bene, con un po 'di premura quando riempi la biblioteca e molto lavoro quando riempi la biblioteca.

Invece di iniziare a riempire la libreria da un'estremità all'altra, escogiti un metodo intelligente. Prendi il titolo del libro, lo esegui attraverso un piccolo programma per computer, che sputa un numero di scaffale e un numero di slot su quello scaffale. Qui è dove metti il ​​libro.

La bellezza di questo programma è che in seguito, quando una persona torna a leggere il libro, dai nuovamente il titolo al programma e ricevi lo stesso numero di scaffale e numero di slot che ti è stato originariamente dato, e questo è dove si trova il libro.

Il programma, come altri hanno già detto, si chiama algoritmo di hash o calcolo di hash e di solito funziona prendendo i dati inseriti (il titolo del libro in questo caso) e calcola un numero da esso.

Per semplicità, diciamo che converte ogni lettera e simbolo in un numero e li somma tutti. In realtà, è molto più complicato di così, ma lasciamo questo per ora.

Il bello di un tale algoritmo è che se si inserisce ripetutamente lo stesso input, continuerà a sputare lo stesso numero ogni volta.

Ok, in pratica è così che funziona una tabella hash.

Segue roba tecnica.

Innanzitutto, c'è la dimensione del numero. Di solito, l'output di un tale algoritmo hash è all'interno di un intervallo di un numero elevato, in genere molto più grande dello spazio disponibile nella tabella. Ad esempio, supponiamo che nella biblioteca ci sia spazio per esattamente un milione di libri. L'output del calcolo dell'hash potrebbe essere compreso tra 0 e 1 miliardo, che è molto più alto.

Quindi cosa facciamo? Usiamo qualcosa chiamato calcolo del modulo, che sostanzialmente dice che se contavi per il numero che volevi (cioè il miliardo di numeri) ma volevi rimanere all'interno di un intervallo molto più piccolo, ogni volta che raggiungi il limite di quell'intervallo più piccolo hai iniziato da 0, ma devi tenere traccia di quanto lontano sei arrivato nella grande sequenza.

Supponi che l'output dell'algoritmo hash sia compreso tra 0 e 20 e ottieni il valore 17 da un determinato titolo. Se la dimensione della biblioteca è di soli 7 libri, conti 1, 2, 3, 4, 5, 6 e quando arrivi a 7, ricomincia da 0. Dato che dobbiamo contare 17 volte, abbiamo 1, 2, 3, 4, 5, 6, 0, 1, 2, 3, 4, 5, 6, 0, 1, 2, 3 e il numero finale è 3.

Ovviamente il calcolo del modulo non viene eseguito in questo modo, ma con divisione e resto. Il resto della divisione di 17 per 7 è 3 (7 va 2 volte in 17 a 14 e la differenza tra 17 e 14 è 3).

Quindi, metti il ​​libro nello slot numero 3.

Questo porta al prossimo problema. Collisioni. Dal momento che l'algoritmo non ha modo di distanziare i libri in modo da riempire esattamente la libreria (o la tabella hash se vuoi), finirà inevitabilmente per calcolare un numero che è stato usato in precedenza. Nel senso della biblioteca, quando arrivi allo scaffale e al numero di slot in cui desideri inserire un libro, c'è già un libro lì.

Esistono vari metodi di gestione delle collisioni, tra cui l'esecuzione dei dati in un altro calcolo per ottenere un altro posto nella tabella ( doppio hashing ) o semplicemente per trovare uno spazio vicino a quello che ti è stato dato (cioè proprio accanto al libro precedente assumendo lo slot era disponibile anche noto come sondaggio lineare ). Ciò significherebbe che hai qualche scavo da fare quando provi a trovare il libro più tardi, ma è ancora meglio che semplicemente iniziare da un'estremità della biblioteca.

Infine, ad un certo punto, potresti voler inserire più libri nella biblioteca di quelli consentiti dalla biblioteca. In altre parole, è necessario creare una libreria più grande. Poiché il punto esatto nella biblioteca è stato calcolato usando la dimensione esatta e attuale della biblioteca, ne consegue che se ridimensionate la biblioteca potreste finire per trovare nuovi punti per tutti i libri poiché il calcolo fatto per trovare i loro punti è cambiato.

Spero che questa spiegazione fosse un po 'più concreta rispetto ai secchi e alle funzioni :)


Grazie per un'ottima spiegazione. Sai dove posso trovare maggiori dettagli tecnici su come è implementato nel framework 4.x .Net?
Johnny_D,

No, è solo un numero. Dovresti semplicemente numerare ogni scaffale e slot iniziando da 0 o 1 e aumentando di 1 per ogni slot su quello scaffale, quindi continua la numerazione sullo scaffale successivo.
Lasse V. Karlsen,

2
"Esistono vari metodi di gestione delle collisioni, incluso l'esecuzione dei dati in un altro calcolo per ottenere un altro punto nella tabella" - cosa intendi con un altro calcolo? È solo un altro algoritmo? OK, quindi supponiamo di usare un altro algoritmo che genera un numero diverso in base al nome del libro. Poi, se dovessi trovare quel libro, come farei a sapere quale algoritmo usare? Userei il primo algoritmo, il secondo algoritmo e così via fino a trovare il libro il cui titolo è quello che sto cercando?
user107986

1
@KyleDelaney: No per l' hashing chiuso (dove le collisioni vengono gestite trovando un bucket alternativo, il che significa che l'utilizzo della memoria è fisso ma si impiega più tempo a cercare tra i bucket). Per l'hashing aperto ovvero il concatenamento in un caso patologico (terribile funzione hash o input deliberatamente creati per scontrarsi con qualche avversario / hacker) potresti finire con la maggior parte dei secchi hash vuoti, ma l'utilizzo totale della memoria non è peggiore - solo più puntatori NULL invece di indicizzazione utilmente nei dati.
Tony Delroy,

3
@KyleDelaney: hai bisogno della cosa "@Tony" per essere avvisato dei tuoi commenti. Sembra che ti stia chiedendo del concatenamento: supponiamo che abbiamo tre nodi valore A{ptrA, valueA}, B{ptrB, valueB}, C{ptrC, valueC}e una tabella hash con tre bucket [ptr1, ptr2, ptr3]. Indipendentemente dal fatto che ci siano collisioni durante l'inserimento, l'utilizzo della memoria è fisso. Potresti non avere collisioni: A{NULL, valueA} B{NULL, valueB} C{NULL, valueC}e [&A, &B, &C], o tutte le collisioni A{&B, valueA} B{&C, valueB}, C{NULL, valueC}e [NULL, &A, NULL]: i secchi NULL sono "sprecati"? Kinda, un po 'no. Stessa memoria totale utilizzata.
Tony Delroy,

104

Uso e Lingo:

  1. Le tabelle hash vengono utilizzate per archiviare e recuperare rapidamente dati (o record).
  2. I record vengono archiviati in bucket tramite i tasti hash
  3. Le chiavi di hash vengono calcolate applicando un algoritmo di hashing a un valore scelto (il valore chiave ) contenuto nel record. Questo valore scelto deve essere un valore comune a tutti i record.
  4. Ogni bucket può avere più record organizzati in un ordine particolare.

Esempio del mondo reale:

Hash & Co. , fondata nel 1803 e priva di qualsiasi tecnologia informatica, aveva un totale di 300 schedari per conservare le informazioni dettagliate (i registri) per i loro circa 30.000 clienti. Ogni cartella di file è stata chiaramente identificata con il suo numero client, un numero univoco compreso tra 0 e 29.999.

I commessi di quel tempo dovevano recuperare e archiviare rapidamente i record dei clienti per il personale di lavoro. Lo staff aveva deciso che sarebbe stato più efficiente utilizzare una metodologia di hashing per archiviare e recuperare i propri record.

Per archiviare un record client, gli impiegati di archiviazione utilizzerebbero il numero client univoco scritto nella cartella. Usando questo numero client, modulerebbero la chiave hash di 300 per identificare il casellario in cui è contenuto. Quando aprivano il casellario, avrebbero scoperto che conteneva molte cartelle ordinate per numero client. Dopo aver identificato la posizione corretta, l'avrebbero semplicemente inserita.

Per recuperare un record cliente, agli impiegati di archiviazione verrà assegnato un numero cliente su un foglietto. Usando questo numero client univoco (la chiave hash ), lo modulerebbero di 300 per determinare quale schedario avesse la cartella client. Quando hanno aperto il casellario avrebbero scoperto che conteneva molte cartelle ordinate per numero di cliente. Effettuando una ricerca tra i record, troverebbero rapidamente la cartella client e la recupererebbero.

Nel nostro esempio reale, i nostri secchi sono schedari e i nostri archivi sono cartelle di file .


Una cosa importante da ricordare è che i computer (e i loro algoritmi) gestiscono i numeri meglio delle stringhe. Quindi accedere a un array di grandi dimensioni utilizzando un indice è significativamente molto più veloce dell'accesso in sequenza.

Come ha detto Simon, che ritengo molto importante è che la parte di hashing consiste nel trasformare un grande spazio (di lunghezza arbitraria, di solito stringhe, ecc.) E mapparlo in un piccolo spazio (di dimensioni note, di solito numeri) per l'indicizzazione. Questo se molto importante da ricordare!

Quindi nell'esempio sopra, i 30.000 possibili client sono mappati su uno spazio più piccolo.


L'idea principale in questo è quella di dividere l'intero set di dati in segmenti per accelerare la ricerca effettiva che di solito richiede tempo. Nel nostro esempio sopra, ciascuno dei 300 schedari conterebbe (statisticamente) circa 100 registrazioni. La ricerca (indipendentemente dall'ordine) attraverso 100 record è molto più rapida della necessità di gestirne 30.000.

Forse avrai notato che alcuni lo fanno già. Ma invece di escogitare una metodologia di hashing per generare una chiave hash, nella maggior parte dei casi useranno semplicemente la prima lettera del cognome. Quindi, se hai 26 casellari contenenti ciascuno una lettera dalla A alla Z, in teoria hai appena segmentato i tuoi dati e migliorato il processo di archiviazione e recupero.

Spero che sia di aiuto,

Jeach!


2
Descrivi un tipo specifico di strategia di prevenzione delle collisioni della tabella hash, chiamata in modo variabile "indirizzamento aperto" o "indirizzamento chiuso" (sì, triste ma vero) o "concatenamento". C'è un altro tipo che non utilizza i bucket di elenco ma memorizza invece gli elementi "in linea".
Konrad Rudolph,

2
descrizione eccellente. eccetto che ogni casellario conterrebbe, in media, informazioni sui 100record (record 30k / 300 armadi = 100). Potrebbe valere una modifica.
Ryan Tuck,

@TonyD, vai su questo sito sha-1 online e genera un hash SHA-1 per TonyDquello che scrivi nel campo di testo. Ti ritroverai con un valore generato di qualcosa che sembra e5dc41578f88877b333c8b31634cf77e4911ed8c. Questo non è altro che un grande numero esadecimale di 160 bit (20 byte). È quindi possibile utilizzare questo per determinare quale bucket (una quantità limitata) verrà utilizzato per archiviare il record.
Jeach,

@TonyD, non sono sicuro di dove si riferisca il termine "chiave hash" in una controversia? In tal caso, indica le due o più località. O stai dicendo che "noi" usiamo il termine "chiave hash" mentre altri siti come Wikipedia usano "valori hash, codici hash, somme hash o semplicemente hash"? In tal caso, chi se ne frega finché il termine utilizzato è coerente all'interno di un gruppo o un'organizzazione. I programmatori usano spesso il termine "chiave". Personalmente direi che un'altra buona opzione sarebbe "hash value". Ma escluderei usando "codice hash, somma hash o semplicemente hash". Concentrati sull'algoritmo e non sulle parole!
Jeach,

2
@TonyD, ho cambiato il testo in "avrebbero modellato la chiave hash di 300", sperando che fosse più pulito e più chiaro per tutti. Grazie!
Jeach,

64

Questa risulta essere un'area della teoria piuttosto profonda, ma il profilo di base è semplice.

In sostanza, una funzione hash è solo una funzione che prende le cose da uno spazio (diciamo stringhe di lunghezza arbitraria) e le mappa su uno spazio utile per l'indicizzazione (interi senza segno, diciamo).

Se hai solo un piccolo spazio di cose da hash, potresti cavartela semplicemente interpretando quelle cose come numeri interi e il gioco è fatto (ad esempio stringhe di 4 byte)

Di solito, però, hai uno spazio molto più grande. Se lo spazio delle cose che permetti come chiavi è più grande dello spazio delle cose che stai usando per indicizzare (il tuo uint32 o altro), non puoi avere un valore univoco per ognuno. Quando due o più cose hanno lo stesso risultato, dovrai gestire la ridondanza in modo appropriato (questo di solito viene definito una collisione e il modo in cui la gestisci o meno dipenderà un po 'da ciò che sei usando l'hash per).

Ciò implica che è improbabile che abbia lo stesso risultato e probabilmente anche tu vorresti che la funzione hash fosse veloce.

Bilanciare queste due proprietà (e alcune altre) ha tenuto occupate molte persone!

In pratica di solito dovresti essere in grado di trovare una funzione nota per funzionare bene per la tua applicazione e usarla.

Ora per far funzionare questo come una tabella hash: immagina che non ti interessi all'utilizzo della memoria. Quindi puoi creare un array purché il tuo set di indicizzazione (tutti gli uint32, ad esempio). Quando aggiungi qualcosa alla tabella, ottieni la sua chiave e guardi l'array in quell'indice. Se non c'è niente lì, metti il ​​tuo valore lì. Se c'è già qualcosa lì, aggiungi questa nuova voce a un elenco di cose a quell'indirizzo, insieme a sufficienti informazioni (la tua chiave originale o qualcosa di intelligente) per trovare quale voce appartiene effettivamente a quale chiave.

Quindi, se vai avanti a lungo, ogni voce nella tua tabella hash (l'array) è vuota o contiene una voce o un elenco di voci. Il recupero è semplice come indicizzare nell'array e restituire il valore o percorrere l'elenco dei valori e restituire quello giusto.

Naturalmente, in pratica, in genere non puoi farlo, spreca troppa memoria. Quindi fai tutto sulla base di una matrice sparsa (dove le uniche voci sono quelle che usi effettivamente, tutto il resto è implicitamente nullo).

Ci sono molti schemi e trucchi per far funzionare meglio questo, ma questa è la base.


1
Scusa, so che questa è una vecchia domanda / risposta, ma ho cercato di capire quest'ultimo punto che hai fatto. Una tabella hash ha una complessità temporale O (1). Tuttavia, una volta che si utilizza un array sparse, non è necessario eseguire una ricerca binaria per trovare il proprio valore? A quel punto la complessità temporale non diventa O (log n)?
Herbrandson,

@herbrandson: no ... un array sparse significa semplicemente che sono stati popolati relativamente pochi indici con valori - puoi comunque indicizzare direttamente l'elemento dell'array specifico per il valore di hash che hai calcolato dalla tua chiave; tuttavia, l'implementazione di array sparsi che Simon descrive è sana solo in circostanze molto limitate: quando le dimensioni del bucket sono dell'ordine delle dimensioni delle pagine di memoria (rispetto intai tasti diciamo con scarsità 1 su 1000 e pagine 4k = la maggior parte delle pagine toccate), e quando il sistema operativo tratta in modo efficiente tutte le pagine 0 (quindi le pagine completamente inutilizzate non necessitano di memoria di backup), quando lo spazio degli indirizzi è abbondante ....
Tony Delroy,

@TonyDelroy - è vero che è una semplificazione eccessiva, ma l'idea era quella di fornire una panoramica di ciò che sono e perché, non un'implementazione pratica. I dettagli di quest'ultimo sono più sfumati, mentre annuisci nella tua espansione.
simon,

48

Molte risposte, ma nessuna di esse è molto visiva e le tabelle hash possono facilmente "fare clic" quando vengono visualizzate.

Le tabelle hash sono spesso implementate come matrici di elenchi collegati. Se immaginiamo una tabella che memorizza i nomi delle persone, dopo alcuni inserimenti potrebbe essere disposta in memoria come di seguito, dove i ()numeri racchiusi sono valori hash del testo / nome.

bucket#  bucket content / linked list

[0]      --> "sue"(780) --> null
[1]      null
[2]      --> "fred"(42) --> "bill"(9282) --> "jane"(42) --> null
[3]      --> "mary"(73) --> null
[4]      null
[5]      --> "masayuki"(75) --> "sarwar"(105) --> null
[6]      --> "margaret"(2626) --> null
[7]      null
[8]      --> "bob"(308) --> null
[9]      null

Alcuni punti:

  • ciascuna delle voci dell'array (indici [0], [1]...) è conosciuta come un bucket e inizia un elenco di valori - possibilmente vuoto - collegato (alias elementi , in questo esempio - nomi di persone )
  • ogni valore (ad es. "fred"con hash 42) è collegato dal bucket [hash % number_of_buckets]ad es 42 % 10 == [2].; %è l' operatore modulo : il resto è diviso per il numero di bucket
  • più valori di dati possono scontrarsi e essere collegati dallo stesso bucket, molto spesso perché i loro valori di hash si scontrano dopo l'operazione del modulo (ad esempio 42 % 10 == [2], e 9282 % 10 == [2]), ma occasionalmente perché i valori di hash sono gli stessi (ad esempio "fred"ed "jane"entrambi mostrati con l'hash 42sopra)
    • la maggior parte delle tabelle hash gestisce le collisioni - con prestazioni leggermente ridotte ma nessuna confusione funzionale - confrontando il valore completo (qui il testo) di un valore ricercato o inserito con ciascun valore già nell'elenco collegato nel bucket con hash

Le lunghezze dell'elenco collegato si riferiscono al fattore di carico, non al numero di valori

Se le dimensioni della tabella aumentano, le tabelle hash implementate come sopra tendono a ridimensionarsi (ovvero creare un array più grande di bucket, creare elenchi collegati nuovi / aggiornati da lì, eliminare il vecchio array) per mantenere il rapporto tra valori e bucket (aka load fattore ) da qualche parte nell'intervallo da 0,5 a 1,0.

Hans fornisce la formula effettiva per altri fattori di carico in un commento di seguito, ma per valori indicativi: con il fattore di carico 1 e una funzione hash di forza crittografica, 1 / e (~ 36,8%) di secchi tenderà a essere vuoto, un altro 1 / e (~ 36,8%) hanno un elemento, 1 / (2e) o ~ 18,4% due elementi, 1 / (3! E) circa il 6,1% tre elementi, 1 / (4! E) o ~ 1,5% quattro elementi, 1 / (5! E) ~ .3% ne ha cinque ecc. - la lunghezza media della catena da secchi non vuoti è ~ 1,58, indipendentemente dal numero di elementi nella tabella (ovvero se ci sono 100 elementi e 100 secchi, o 100 milioni elementi e 100 milioni di bucket), motivo per cui diciamo che la ricerca / inserimento / cancellazione sono operazioni a tempo costante O (1) .

Come una tabella hash può associare le chiavi ai valori

Data un'implementazione della tabella hash come descritto sopra, possiamo immaginare di creare un tipo di valore come struct Value { string name; int age; };, e confronto di uguaglianza e funzioni hash che guardano solo il namecampo (ignorando l'età), e quindi succede qualcosa di meraviglioso: possiamo archiviare i Valuerecord come {"sue", 63}nella tabella , poi cerca "fai causa" senza conoscere la sua età, trova il valore memorizzato e recupera o addirittura aggiorna la sua età
- buon compleanno Sue - che in modo interessante non cambia il valore dell'hash, quindi non richiede che spostiamo il record di Sue su un altro secchio.

Quando lo facciamo, stiamo usando la tabella hash come un contenitore associativo aka mappa e i valori che memorizza possono essere considerati costituiti da una chiave (il nome) e uno o più altri campi ancora definiti - in modo confuso - il valore ( nel mio esempio, solo l'età). Un'implementazione della tabella hash utilizzata come mappa è nota come mappa hash .

Questo contrasta con l'esempio precedente in questa risposta in cui abbiamo archiviato valori discreti come "sue", che potresti pensare come la sua chiave: quel tipo di utilizzo è noto come un set di hash .

Esistono altri modi per implementare una tabella hash

Non tutte le tabelle hash utilizzano elenchi collegati (noti come concatenamento separato ), ma la maggior parte di quelli generici lo fanno, poiché l'alternativa principale hash chiuso (ovvero indirizzamento aperto ) - in particolare con operazioni di cancellazione supportate - ha proprietà di prestazione meno stabili con chiavi soggette a collisioni / funzioni hash.


Alcune parole sulle funzioni hash

Forte hashing ...

Uno scopo generale, il compito della funzione hash di minimizzare le collisioni nel caso peggiore è quello di spruzzare le chiavi attorno ai secchi della tabella hash in modo efficace in modo casuale, generando sempre lo stesso valore hash per la stessa chiave. Anche un bit che cambia in qualsiasi punto della chiave idealmente - casualmente - capovolge circa la metà dei bit nel valore di hash risultante.

Questo di solito è orchestrato da matematica troppo complicata per me da far breccia. Citerò un modo di facile comprensione - non il più scalabile o compatibile con la cache ma intrinsecamente elegante (come la crittografia con un solo pad!) - poiché penso che aiuti a portare a casa le qualità desiderabili sopra menzionate. Supponiamo che tu abbia hashing a 64 bit double- potresti creare 8 tabelle ognuna di 256 numeri casuali (codice sotto), quindi utilizzare ogni sezione a 8 bit / 1 byte della doublerappresentazione della memoria della memoria per indicizzare in una tabella diversa, XOR numeri casuali che cerchi. Con questo approccio, è facile vedere che un po '(nel senso della cifra binaria) cambia ovunque nei doublerisultati in un diverso numero casuale che viene cercato in una delle tabelle e un valore finale totalmente non correlato.

// note caveats above: cache unfriendly (SLOW) but strong hashing...
size_t random[8][256] = { ...random data... };
const char* p = (const char*)&my_double;
size_t hash = random[0][p[0]] ^ random[1][p[1]] ^ ... ^ random[7][p[7]];

Hash debole ma spesso veloce ...

Molte funzioni di hashing delle librerie passano interi invariati (noto come funzione di hash banale o di identità ); è l'altro estremo dal forte hashing descritto sopra. Un hash di identità è estremamentecollisione soggetta nei casi peggiori, ma la speranza è che nel caso abbastanza comune di chiavi intere che tendono ad aumentare (forse con alcune lacune), si mapperanno in secchi successivi lasciando meno vuote delle foglie di hashing casuali (le nostre ~ 36,8 % al fattore di carico 1 menzionato in precedenza), con un numero di collisioni inferiore e un numero di elenchi di elementi di collisione più lunghi rispetto a quello ottenuto dalle mappature casuali. È anche ottimo per risparmiare il tempo necessario per generare un hash forte e se le chiavi vengono cercate in ordine, verranno trovate nei secchi nelle vicinanze della memoria, migliorando gli accessi alla cache. Quando i tasti non aumentano bene, la speranza è che siano abbastanza casuali da non aver bisogno di una forte funzione di hash per randomizzare totalmente il loro posizionamento in bucket.


6
Mi permetta di dire solo: risposta fantastica.
CRThaze

@Tony Delroy Grazie per la straordinaria risposta. Ho ancora un punto in sospeso nella mia mente. Dici che anche se ci sono 100 milioni di bucket, il tempo di ricerca sarebbe O (1) con il fattore di carico 1 e una funzione di hash della forza crittografica. Ma che dire di trovare il secchio giusto in 100 milioni? Anche se abbiamo tutti i secchi ordinati, non è O (log100.000.000)? Come può trovare il secchio essere O (1)?
selman,

@selman: la tua domanda non fornisce molti dettagli per spiegare perché pensi che potrebbe essere O (log100.000.000), ma dici "anche se abbiamo tutti i bucket ordinati" - tieni presente che i valori nei bucket della tabella hash non vengono mai "ordinati" nel solito senso: quale valore appare in quale bucket viene determinato applicando la funzione hash alla chiave. Pensare che la complessità sia O (log100.000.000) implica che si immagini di fare una ricerca binaria attraverso secchi ordinati, ma non è così che funziona l'hash. Forse leggi alcune delle altre risposte e vedi se inizia a dare più senso.
Tony Delroy,

@TonyDelroy In effetti, i "secchi ordinati" sono lo scenario migliore che immagino. Quindi O (log100.000.000). Ma se così non fosse, come può l'applicazione trovare un bucket relativo tra milioni? La funzione hash genera in qualche modo una posizione di memoria?
selman,

1
@selman: poiché la memoria del computer consente un "accesso casuale" a tempo costante: se è possibile calcolare un indirizzo di memoria, è possibile recuperare il contenuto della memoria senza dover accedere alla memoria in altre parti dell'array. Quindi, indipendentemente dal fatto che accediate al primo bucket, all'ultimo bucket o a un bucket in qualunque punto intermedio, avrà le stesse caratteristiche prestazionali (liberamente, impiegando lo stesso tempo, anche se soggetto alla memoria cache L1 / L2 / L3 della CPU impatta ma funzionano solo per aiutarti a riaccedere rapidamente a secchi di recente accesso o casualmente nelle vicinanze e possono essere ignorati per l'analisi big-O).
Tony Delroy,

24

Ragazzi, siete molto vicini a spiegarlo completamente, ma mancano un paio di cose. La tabella hash è solo un array. L'array stesso conterrà qualcosa in ogni slot. Come minimo memorizzerai l'hashvalue o il valore stesso in questo slot. Inoltre, è possibile memorizzare un elenco di valori collegati / concatenati che si sono scontrati su questo slot o utilizzare il metodo di indirizzamento aperto. È inoltre possibile memorizzare un puntatore o puntatori ad altri dati che si desidera recuperare da questo slot.

È importante notare che l'hashvalue stesso generalmente non indica lo slot in cui posizionare il valore. Ad esempio, un hashvalue potrebbe essere un valore intero negativo. Ovviamente un numero negativo non può indicare una posizione dell'array. Inoltre, i valori di hash tenderanno a essere più volte numeri maggiori rispetto agli slot disponibili. Quindi un altro calcolo deve essere eseguito dalla stessa hashtable per capire in quale slot dovrebbe andare il valore. Questo viene fatto con un'operazione matematica di un modulo come:

uint slotIndex = hashValue % hashTableSize;

Questo valore è lo slot in cui andrà il valore. Nell'indirizzamento aperto, se lo slot è già pieno di un altro hashvalue e / o altri dati, l'operazione del modulo verrà nuovamente eseguita per trovare lo slot successivo:

slotIndex = (remainder + 1) % hashTableSize;

Suppongo che potrebbero esserci altri metodi più avanzati per determinare l'indice di slot, ma questo è quello comune che ho visto ... sarebbe interessato a qualsiasi altro che funzioni meglio.

Con il metodo del modulo, se si dispone di una tabella di dimensioni pari a 1000, qualsiasi valore di hash compreso tra 1 e 1000 verrà inserito nello slot corrispondente. Qualsiasi valore negativo e qualsiasi valore maggiore di 1000 saranno potenzialmente in collisione tra i valori degli slot. Le probabilità che ciò accada dipendono sia dal metodo di hashing sia dal numero di elementi totali aggiunti alla tabella hash. In genere, è consigliabile aumentare le dimensioni dell'hashtable in modo tale che il numero totale di valori aggiunti sia pari a circa il 70% delle sue dimensioni. Se la tua funzione hash fa un buon lavoro di distribuzione uniforme, generalmente incontrerai pochissime o nessuna collisione bucket / slot e si comporterà molto rapidamente sia per la ricerca che per le operazioni di scrittura. Se il numero totale di valori da aggiungere non è noto in anticipo, fare una buona stima usando qualunque mezzo,

Spero che questo abbia aiutato.

PS: in C # il GetHashCode()metodo è piuttosto lento e provoca collisioni di valori reali in molte condizioni che ho testato. Per divertirti davvero, crea la tua funzione hash e cerca di farla collidere MAI sui dati specifici che stai eseguendo, eseguire più velocemente di GetHashCode e avere una distribuzione abbastanza uniforme. L'ho fatto usando valori hashcode lunghi anziché di dimensioni int e ha funzionato abbastanza bene su fino a 32 milioni di valori hash nella tabella hash con 0 collisioni. Purtroppo non riesco a condividere il codice in quanto appartiene al mio datore di lavoro ... ma posso rivelare che è possibile per determinati domini di dati. Quando riesci a raggiungere questo obiettivo, l'hashtable è MOLTO veloce. :)


so che il post è piuttosto vecchio, ma qualcuno può spiegare cosa significa (resto + 1) qui
Hari,

3
@Hari si remainderriferisce al risultato del calcolo del modulo originale e ne aggiungiamo 1 per trovare il prossimo slot disponibile.
x4nd3r

"L'array stesso conterrà qualcosa in ogni slot. Come minimo memorizzerai l'hashvalue o il valore stesso in questo slot." - è comune che "slot" (bucket) non memorizzino alcun valore; le implementazioni di indirizzamento aperto spesso archiviano NULL o un puntatore al primo nodo in un elenco collegato, senza alcun valore direttamente nello slot / bucket. "sarebbe interessato a qualsiasi altro" - il "+1" che illustra è chiamato sondaggio lineare , spesso con prestazioni migliori: sondaggio quadratico . "generalmente incontrano pochissime o nessuna collisione di bucket / slot" - capacità del 70%, ~ 12% di slot con 2 valori, ~ 3% 3 ....
Tony Delroy

"L'ho fatto usando valori hashcode lunghi anziché di dimensioni int e ha funzionato abbastanza bene su fino a 32 milioni di valori hash nella tabella hash con 0 collisioni." - questo semplicemente non è possibile nel caso generale in cui i valori delle chiavi sono effettivamente casuali in un intervallo molto più ampio rispetto al numero di bucket. Nota che avere valori di hash distinti è spesso abbastanza semplice (e il tuo parlare di longvalori di hash implica che è quello che hai ottenuto), ma assicurarti che non si scontrino nella tabella di hash dopo che l'operazione mod /% non è (nel caso generale ).
Tony Delroy

(Evitare tutte le collisioni è noto come hash perfetto . In generale è pratico per alcune centinaia o migliaia di chiavi che sono conosciute in anticipo - gperf è un esempio di uno strumento per calcolare tale funzione hash. Puoi anche scrivere il tuo in un numero molto limitato circostanze - ad es. se le tue chiavi sono puntatori a oggetti del tuo pool di memoria che è tenuto abbastanza pieno, con ogni puntatore a una distanza fissa, puoi dividere i puntatori per quella distanza e avere effettivamente un indice in un array leggermente scarso, evitando collisioni).
Tony Delroy,

17

Ecco come funziona nella mia comprensione:

Ecco un esempio: immagina l'intero tavolo come una serie di secchi. Supponiamo di avere un'implementazione con codici hash alfanumerici e di avere un bucket per ogni lettera dell'alfabeto. Questa implementazione mette ogni elemento il cui codice hash inizia con una lettera particolare nel bucket corrispondente.

Supponiamo che tu abbia 200 oggetti, ma solo 15 hanno codici hash che iniziano con la lettera "B." La tabella hash dovrebbe solo cercare e cercare tra i 15 oggetti nel bucket "B", anziché tutti i 200 oggetti.

Per quanto riguarda il calcolo del codice hash, non c'è nulla di magico in questo. L'obiettivo è solo che oggetti diversi restituiscano codici diversi e che oggetti uguali restituiscano codici uguali. Potresti scrivere una classe che restituisce sempre lo stesso numero intero di un codice hash per tutte le istanze, ma essenzialmente distruggeresti l'utilità di una tabella hash, poiché diventerebbe solo un secchio gigante.


13

Breve e dolce:

Una tabella hash racchiude un array, consente di chiamarlo internalArray. Gli elementi vengono inseriti nell'array in questo modo:

let insert key value =
    internalArray[hash(key) % internalArray.Length] <- (key, value)
    //oversimplified for educational purposes

A volte due chiavi eseguono l'hash sullo stesso indice dell'array e si desidera mantenere entrambi i valori. Mi piace memorizzare entrambi i valori nello stesso indice, che è semplice da codificare creando internalArrayuna matrice di elenchi collegati:

let insert key value =
    internalArray[hash(key) % internalArray.Length].AddLast(key, value)

Quindi, se volessi recuperare un oggetto dalla mia tabella hash, potrei scrivere:

let get key =
    let linkedList = internalArray[hash(key) % internalArray.Length]
    for (testKey, value) in linkedList
        if (testKey = key) then return value
    return null

Le operazioni di eliminazione sono altrettanto semplici da scrivere. Come puoi dire, inserimenti, ricerche e rimozione dal nostro array di elenchi collegati sono quasi O (1).

Quando il nostro array interno diventa troppo pieno, forse con una capacità dell'85% circa, possiamo ridimensionare l'array interno e spostare tutti gli elementi dal vecchio array nel nuovo array.


11

È ancora più semplice di così.

Una tabella hash non è altro che un array (di solito scarso uno ) di vettori che contengono coppie chiave / valore. La dimensione massima di questo array è in genere inferiore al numero di elementi nell'insieme di valori possibili per il tipo di dati archiviati nella tabella hash.

L'algoritmo hash viene utilizzato per generare un indice in quell'array in base ai valori dell'elemento che verrà archiviato nell'array.

Qui è dove archiviano i vettori delle coppie chiave / valore nella matrice. Poiché l'insieme di valori che possono essere indici nella matrice è in genere inferiore al numero di tutti i possibili valori che il tipo può avere, è possibile che il tuo hash l'algoritmo genererà lo stesso valore per due chiavi separate. Un buon algoritmo di hash lo impedirà il più possibile (motivo per cui è in genere relegato al tipo perché ha informazioni specifiche che un algoritmo di hash generale non può conoscere), ma è impossibile prevenirlo.

Per questo motivo, puoi avere più chiavi che genereranno lo stesso codice hash. Quando ciò accade, gli elementi nel vettore vengono ripetuti e viene effettuato un confronto diretto tra la chiave nel vettore e la chiave che viene cercata. Se viene trovato, ottimo e viene restituito il valore associato alla chiave, altrimenti non viene restituito nulla.


10

Prendi un sacco di cose e un array.

Per ogni cosa, crei un indice per esso, chiamato hash. La cosa importante dell'hash è che "si disperde" molto; non vuoi che due cose simili abbiano hash simili.

Metti le tue cose nell'array nella posizione indicata dall'hash. Più di una cosa può finire in un determinato hash, quindi memorizzi le cose in array o qualcos'altro appropriato, che generalmente chiamiamo un secchio.

Quando guardi le cose nell'hash, segui gli stessi passaggi, capendo il valore dell'hash, quindi vedendo cosa c'è nel secchio in quella posizione e controllando se è quello che stai cercando.

Quando il tuo hashing funziona bene e il tuo array è abbastanza grande, ci saranno solo poche cose al massimo in un particolare indice dell'array, quindi non dovrai guardare molto.

Per i punti bonus, fai in modo che quando si accede alla tua tabella hash, sposta l'oggetto trovato (se presente) all'inizio del bucket, quindi la prossima volta è la prima cosa controllata.


1
grazie per l'ultimo punto che tutti gli altri hanno mancato di menzionare
Sandeep Raju Prabhakar,

4

Tutte le risposte finora sono buone e arrivano a diversi aspetti di come funziona una tabella hash. Ecco un semplice esempio che potrebbe essere utile. Diciamo che vogliamo memorizzare alcuni elementi con stringhe alfabetiche minuscole come chiavi.

Come spiegato da Simon, la funzione hash viene utilizzata per mappare da un grande spazio a un piccolo spazio. Un'implementazione semplice e ingenua di una funzione hash per il nostro esempio potrebbe prendere la prima lettera della stringa e mapparla su un numero intero, quindi "alligatore" ha un codice hash di 0, "ape" ha un codice hash di 1 ", zebra "sarebbe 25, ecc.

Successivamente abbiamo un array di 26 bucket (potrebbe essere ArrayLists in Java) e inseriamo l'elemento nel bucket che corrisponde al codice hash della nostra chiave. Se abbiamo più di un elemento che ha una chiave che inizia con la stessa lettera, avranno lo stesso codice hash, quindi andrebbero tutti nel bucket per quel codice hash, quindi una ricerca lineare dovrebbe essere effettuata nel bucket per trova un oggetto particolare.

Nel nostro esempio, se avessimo avuto solo poche dozzine di elementi con i tasti che attraversavano l'alfabeto, avrebbe funzionato molto bene. Tuttavia, se avessimo un milione di elementi o tutte le chiavi iniziassero tutte con 'a' o 'b', la nostra tabella hash non sarebbe l'ideale. Per ottenere prestazioni migliori, avremmo bisogno di una diversa funzione hash e / o più bucket.


3

Ecco un altro modo di vederlo.

Presumo che tu capisca il concetto di un array A. Questo è qualcosa che supporta l'operazione di indicizzazione, in cui puoi arrivare all'elemento Ith, A [I], in un solo passaggio, non importa quanto A sia grande.

Quindi, ad esempio, se si desidera archiviare informazioni su un gruppo di persone che hanno tutte un'età diversa, un modo semplice sarebbe quello di avere un array sufficientemente grande e utilizzare l'età di ogni persona come indice nell'array. In tal modo, potresti avere accesso in un solo passaggio alle informazioni di qualsiasi persona.

Ma ovviamente potrebbe esserci più di una persona con la stessa età, quindi quello che metti nella matrice ad ogni voce è un elenco di tutte le persone che hanno quell'età. Quindi puoi ottenere le informazioni di una persona in un solo passaggio più un po 'di ricerca in quell'elenco (chiamato "secchio"). Rallenta solo se ci sono così tante persone che i secchi diventano grandi. Quindi hai bisogno di un array più grande e di un altro modo per ottenere più informazioni identificative sulla persona, come le prime lettere del suo cognome, invece di usare l'età.

Questa è l'idea di base. Invece di usare l'età, può essere usata qualsiasi funzione della persona che produce una buona diffusione di valori. Questa è la funzione hash. Come se potessi prendere ogni terzo bit della rappresentazione ASCII del nome della persona, mescolata in un certo ordine. Tutto ciò che conta è che non vuoi che troppe persone abbiano hash sullo stesso bucket, perché la velocità dipende dal fatto che i bucket rimangano piccoli.


2

Il modo in cui viene calcolato l'hash non dipende in genere dall'hashtable, ma dagli elementi aggiunti ad esso. Nelle librerie framework / base come .net e Java, ogni oggetto ha un metodo GetHashCode () (o simile) che restituisce un codice hash per questo oggetto. L'algoritmo del codice hash ideale e l'implementazione esatta dipendono dai dati rappresentati nell'oggetto.


2

Una tabella di hash funziona totalmente sul fatto che il calcolo pratico segue il modello di macchina ad accesso casuale, ovvero è possibile accedere al valore a qualsiasi indirizzo in memoria in tempo O (1) o tempo costante.

Quindi, se ho un universo di chiavi (set di tutte le possibili chiavi che posso usare in un'applicazione, ad es. Numero di roll per studente, se è composto da 4 cifre, questo universo è un insieme di numeri da 1 a 9999) e un modo di mapparli su un insieme finito di numeri di dimensioni che posso allocare memoria nel mio sistema, teoricamente la mia tabella hash è pronta.

In generale, nelle applicazioni la dimensione dell'universo delle chiavi è molto grande rispetto al numero di elementi che voglio aggiungere alla tabella hash (non voglio sprecare una memoria da 1 GB in hash, diciamo, valori 10000 o 100000 interi perché sono 32 un po 'lungo in rappresaglia binaria). Quindi, usiamo questo hashing. È una specie di operazione "matematica" che unisce il mio grande universo a un piccolo insieme di valori che posso accogliere in memoria. In casi pratici, spesso lo spazio di una tabella hash è dello stesso "ordine" (big-O) del (numero di elementi * dimensioni di ciascun elemento), quindi non perdiamo molta memoria.

Ora, un set grande mappato su un set piccolo, il mapping deve essere molti-a-uno. Quindi, chiavi diverse verranno assegnate nello stesso spazio (?? non giusto). Ci sono alcuni modi per gestirlo, ne conosco solo due popolari:

  • Utilizzare lo spazio che doveva essere allocato al valore come riferimento a un elenco collegato. Questo elenco collegato memorizzerà uno o più valori, che risiedono nello stesso slot in molti mapping. L'elenco collegato contiene anche le chiavi per aiutare qualcuno che viene alla ricerca. È come molte persone nello stesso appartamento, quando arriva un fattorino, va nella stanza e chiede specificamente il ragazzo.
  • Utilizzare una doppia funzione di hash in un array che fornisce ogni volta la stessa sequenza di valori anziché un singolo valore. Quando vado a memorizzare un valore, vedo se la posizione di memoria richiesta è libera o occupata. Se è gratuito, posso archiviare il mio valore lì, se è occupato prendo il prossimo valore dalla sequenza e così via fino a quando non trovo una posizione libera e memorizzo il mio valore lì. Durante la ricerca o il recupero del valore, torno sullo stesso percorso indicato dalla sequenza e in ogni posizione chiedo il valore se è presente fino a quando non lo trovo o cerco tutte le possibili posizioni nell'array.

L'introduzione agli algoritmi di CLRS fornisce una visione molto approfondita dell'argomento.


0

Per tutti coloro che cercano un linguaggio di programmazione, ecco come funziona. L'implementazione interna di hashtable avanzati presenta molte complessità e ottimizzazioni per l'allocazione / deallocazione e la ricerca dello storage, ma l'idea di livello superiore sarà più o meno la stessa.

(void) addValue : (object) value
{
   int bucket = calculate_bucket_from_val(value);
   if (bucket) 
   {
       //do nothing, just overwrite
   }
   else   //create bucket
   {
      create_extra_space_for_bucket();
   }
   put_value_into_bucket(bucket,value);
}

(bool) exists : (object) value
{
   int bucket = calculate_bucket_from_val(value);
   return bucket;
}

dov'è calculate_bucket_from_val()la funzione di hashing in cui deve avvenire tutta la magia dell'unicità.

La regola empirica è: affinché un determinato valore sia inserito, il bucket deve essere UNICO E DERIVABILE DAL VALORE che deve memorizzare.

Bucket è qualsiasi spazio in cui sono archiviati i valori - perché qui l'ho tenuto int come indice di array, ma forse anche un percorso di memoria.


1
"La regola empirica è: affinché un determinato valore sia inserito, il bucket deve essere UNICO E DERIVABILE DAL VALORE che dovrebbe memorizzare." - descrive una funzione hash perfetta , che di solito è possibile solo per alcune centinaia o migliaia di valori noti al momento della compilazione. La maggior parte delle tabelle hash deve gestire le collisioni . Inoltre, le tabelle hash tendono ad allocare spazio per tutti i bucket, siano essi vuoti o meno, mentre il tuo pseudo-codice documenta un create_extra_space_for_bucket()passaggio durante l'inserimento di nuove chiavi. I secchi possono tuttavia essere dei suggerimenti.
Tony Delroy,
Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.