TL; DR: le tabelle hash garantiscono il tempo O(1)
previsto nel caso peggiore se scegli la tua funzione hash in modo uniforme e casuale da una famiglia universale di funzioni hash. Il caso peggiore previsto non è lo stesso del caso medio.
Disclaimer: non provo formalmente che le tabelle hash lo siano O(1)
, per questo dai un'occhiata a questo video di coursera [ 1 ]. Inoltre non discuto gli aspetti ammortizzati delle tabelle hash. Ciò è ortogonale alla discussione su hashing e collisioni.
Vedo una quantità sorprendentemente grande di confusione su questo argomento in altre risposte e commenti, e cercherò di correggerne alcuni in questa lunga risposta.
Ragionando sul caso peggiore
Esistono diversi tipi di analisi del caso peggiore. L'analisi che la maggior parte delle risposte ha fatto finora non è il caso peggiore, ma piuttosto il caso medio [ 2 ]. L' analisi media dei casi tende ad essere più pratica. Forse il tuo algoritmo ha un input nel caso peggiore, ma in realtà funziona bene per tutti gli altri input possibili. Il risultato finale è che il tuo runtime dipende dal set di dati su cui stai eseguendo.
Considera il seguente pseudocodice del get
metodo di una tabella hash. Qui presumo che gestiamo la collisione concatenando, quindi ogni voce della tabella è un elenco collegato di (key,value)
coppie. Assumiamo anche che il numero di bucket m
sia fisso ma è O(n)
, dove n
è il numero di elementi nell'input.
function get(a: Table with m buckets, k: Key being looked up)
bucket <- compute hash(k) modulo m
for each (key,value) in a[bucket]
return value if k == key
return not_found
Come altre risposte hanno sottolineato, questo avviene nella media O(1)
e nel peggiore dei casi O(n)
. Possiamo fare un piccolo schizzo di una dimostrazione per sfida qui. La sfida è la seguente:
(1) Dai l'algoritmo della tua tabella hash a un avversario.
(2) L'avversario può studiarlo e prepararsi per tutto il tempo che vuole.
(3) Infine l'avversario ti dà un input di dimensione n
da inserire nella tua tabella.
La domanda è: quanto è veloce la tua tabella hash sull'input dell'avversario?
Dal passaggio (1) l'avversario conosce la tua funzione hash; durante la fase (2) l'avversario può creare un elenco di n
elementi con lo stesso hash modulo m
, ad esempio calcolando in modo casuale l'hash di un gruppo di elementi; e poi in (3) possono darti quella lista. Ma ecco, dal momento che tutti gli n
elementi vengono inseriti nello stesso bucket, il tuo algoritmo impiegherà O(n)
tempo per attraversare l'elenco collegato in quel bucket. Non importa quante volte ripetiamo la sfida, l'avversario vince sempre, e questo è quanto sia cattivo il tuo algoritmo, nel peggiore dei casi O(n)
.
Come mai l'hashing è O (1)?
Ciò che ci ha sconcertati nella sfida precedente è stato il fatto che l'avversario conosceva molto bene la nostra funzione hash e poteva utilizzare quella conoscenza per creare il peggior input possibile. E se invece di usare sempre una funzione hash fissa, avessimo effettivamente un set di funzioni hash H
, che l'algoritmo può scegliere casualmente in fase di esecuzione? Nel caso foste curiosi, H
si chiama famiglia universale di funzioni hash [ 3 ]. Va bene, proviamo ad aggiungere un po 'di casualità a questo.
Per prima cosa supponiamo che la nostra tabella hash includa anche un seme r
e r
sia assegnata a un numero casuale al momento della costruzione. Lo assegniamo una volta e poi viene corretto per quell'istanza di tabella hash. Ora rivisitiamo il nostro pseudocodice.
function get(a: Table with m buckets and seed r, k: Key being looked up)
rHash <- H[r]
bucket <- compute rHash(k) modulo m
for each (key,value) in a[bucket]
return value if k == key
return not_found
Se proviamo ancora una volta la sfida: dal punto (1) l'avversario può conoscere tutte le funzioni hash in cui ci troviamo H
, ma ora dipende dalla funzione hash specifica che usiamo r
. Il valore di r
è privato per la nostra struttura, l'avversario non può ispezionarlo in fase di esecuzione, né prevederlo in anticipo, quindi non può inventare un elenco che è sempre negativo per noi. Supponiamo che nello stadio (2) l'avversario sceglie una funzione hash
in H
a caso, poi artigianato un elenco di n
collisioni sotto hash modulo m
, e invia che per la fase (3), attraversando le dita che in fase di esecuzione H[r]
saranno gli stessi hash
hanno scelto.
Questa è una scommessa seria per l'avversario, l'elenco che ha creato si scontra hash
, ma sarà solo un input casuale sotto qualsiasi altra funzione hash in H
. Se vince questa scommessa, il nostro tempo di esecuzione sarà il peggiore O(n)
come prima, ma se perde, beh, ci viene solo dato un input casuale che richiede il O(1)
tempo medio . E infatti la maggior parte delle volte l'avversario perderà, vince solo una volta ogni |H|
sfida, e possiamo fare di |H|
essere molto grandi.
Confronta questo risultato con l'algoritmo precedente in cui l'avversario ha sempre vinto la sfida. Mano che ondeggia un po 'qui, ma poiché la maggior parte delle volte l'avversario fallirà, e questo è vero per tutte le possibili strategie che l'avversario può provare, ne consegue che sebbene il caso peggiore sia O(n)
, il caso peggiore previsto lo è in realtà O(1)
.
Ancora una volta, questa non è una prova formale. La garanzia che otteniamo da questa prevista analisi del caso peggiore è che il nostro tempo di esecuzione è ora indipendente da qualsiasi input specifico . Questa è una garanzia veramente casuale, al contrario dell'analisi del caso medio in cui abbiamo mostrato che un avversario motivato potrebbe facilmente creare input errati.