Können Hash-Tabellen wirklich O (1) sein?

113

Es scheint allgemein bekannt zu sein, dass Hash-Tabellen O (1) erreichen können, aber das hat für mich nie Sinn gemacht. Kann es bitte jemand erklären? Hier sind zwei Situationen, die mir in den Sinn kommen:

A. Der Wert ist ein int kleiner als die Größe der Hash-Tabelle. Daher ist der Wert ein eigener Hash, daher gibt es keine Hash-Tabelle. Aber wenn es so wäre, wäre es O (1) und immer noch ineffizient.

B. Sie müssen einen Hash des Werts berechnen. In dieser Situation ist die Reihenfolge O (n) für die Größe der Daten, die nachgeschlagen werden. Die Suche könnte O (1) sein, nachdem Sie O (n) gearbeitet haben, aber das kommt in meinen Augen immer noch zu O (n).

Und wenn Sie keinen perfekten Hash oder eine große Hash-Tabelle haben, gibt es wahrscheinlich mehrere Artikel pro Eimer. Es entwickelt sich also sowieso irgendwann zu einer kleinen linearen Suche.

Ich finde Hash-Tabellen großartig, aber ich bekomme die Bezeichnung O (1) nur, wenn sie nur theoretisch sein soll.

Der Wikipedia- Artikel für Hash-Tabellen verweist konsistent auf die konstante Suchzeit und ignoriert die Kosten der Hash-Funktion vollständig. Ist das wirklich eine faire Maßnahme?


Bearbeiten: Um zusammenzufassen, was ich gelernt habe:

  • Dies ist technisch richtig, da die Hash-Funktion nicht alle Informationen im Schlüssel verwenden muss und daher eine konstante Zeit sein kann, und weil eine ausreichend große Tabelle Kollisionen auf eine nahezu konstante Zeit reduzieren kann.

  • In der Praxis ist dies der Fall, da es im Laufe der Zeit nur funktioniert, solange die Hash-Funktion und die Tabellengröße so gewählt werden, dass Kollisionen minimiert werden, obwohl dies häufig bedeutet, dass keine Hash-Funktion mit konstanter Zeit verwendet wird.

nach vorne gezogen
quelle
31
Es wird O (1) amortisiert, nicht O (1).
Kennytm
Denken Sie daran, dass O () die Grenze für eine große Anzahl von Operationen ist. Im Durchschnitt haben Sie nicht viele Kollisionen - es ist nicht erforderlich, dass eine einzelne Operation keine Kollision aufweist.
Martin Beckett
Abhängig von der String-Implementierung können Strings ihren Hash-Wert mit sich herumtragen, sodass dies konstant ist. Der Punkt ist, dass es für die Komplexität der Hash-Suche irrelevant ist.
Rich Remer
@kennytm Sicher, die Suche, sobald Sie die Eingabe gehasht haben, wird amortisiert O (1). Aber sind die Kosten für die Berechnung des Hash wirklich vernachlässigbar? Angenommen, wir haben eine Zeichenfolge - ein Zeichenarray. Um den Hash zu generieren, wird jedes Zeichen durchlaufen, sodass das Hashing einer Zeichenfolge O (N) ist, wobei N die Länge der Zeichenfolge ist. So ist es für C # dokumentiert und so wird Javas hashCode()Methode für a implementiert String. grepcode.com/file/repository.grepcode.com/java/root/jdk/openjdk/…
spaaarky21
1
@ spaaarky21 Das N in O (N), von dem Sie sprechen, ist die Länge der Zeichenfolge, die sich von der Größe der Hash-Tabelle unterscheidet. Mark Byers Antwort ging bereits darauf ein.
Kennytm

Antworten:

65

Sie haben hier zwei Variablen, m und n, wobei m die Länge der Eingabe und n die Anzahl der Elemente im Hash ist.

Der O (1) Lookup-Leistungsanspruch geht von mindestens zwei Annahmen aus:

  • Ihre Objekte können in O (1) Zeit gleich sein.
  • Es wird nur wenige Hash-Kollisionen geben.

Wenn Ihre Objekte eine variable Größe haben und eine Gleichheitsprüfung das Betrachten aller Bits erfordert, wird die Leistung zu O (m). Die Hash-Funktion muss jedoch nicht O (m) sein - sie kann O (1) sein. Im Gegensatz zu einem kryptografischen Hash muss eine Hash-Funktion zur Verwendung in einem Wörterbuch nicht jedes Bit in der Eingabe betrachten, um den Hash zu berechnen. Implementierungen können nur eine feste Anzahl von Bits betrachten.

Bei ausreichend vielen Elementen wird die Anzahl der Elemente größer als die Anzahl der möglichen Hashes, und dann erhalten Sie Kollisionen, die zu einem Leistungsanstieg über O (1) führen, z. B. O (n) für eine einfache Durchquerung verknüpfter Listen (oder O (n) * m) wenn beide Annahmen falsch sind).

In der Praxis gilt die O (1) -Angabe zwar technisch falsch, ist jedoch für viele Situationen in der realen Welt ungefähr zutreffend, insbesondere für Situationen, in denen die obigen Annahmen gelten.

Mark Byers
quelle
4
Wenn Sie unveränderliche Objekte als Schlüssel verwenden, z. B. Java-Zeichenfolgen, nachdem Sie den Hash einmal berechnet haben, können Sie sich daran erinnern und müssen ihn nicht erneut berechnen. Auf der anderen Seite können Sie sich normalerweise nicht auf den Hash verlassen, um festzustellen, ob zwei Schlüssel gleich sind, sobald Sie den richtigen Bucket gefunden haben. Für Zeichenfolgen müssen Sie also eine O (m) -Überquerung durchführen, um herauszufinden, ob sie gleich sind.
JeremyP
1
@JeremyP: Guter Punkt beim Vergleich der O (m) -Gleichheit. Ich habe das verpasst - aktualisierter Beitrag. Vielen Dank!
Mark Byers
2
Die O(1)Behauptung ist wahr, wenn Sie Hashing intoder etwas anderes haben, das in ein Maschinenwort passt. Das ist es, was die meisten Hashing-Theorien annehmen.
Thomas Ahle
Ich mag diese Erklärung von dir Mark, ich habe sie in meinem Artikel über Hash-Tabellen auf meshfields.de/hash-tables zitiert
Steve K
3
In "m ist die Länge der Eingabe" - Eingabe ist zu vage - bedeutet dies möglicherweise, dass alle Schlüssel und Werte eingefügt werden, aber später wird klar (zumindest für diejenigen, die das Thema bereits verstehen), dass Sie den Schlüssel meinen . Ich schlage nur vor, aus Gründen der Klarheit "Schlüssel" in der Antwort zu verwenden. Übrigens - konkretes Beispiel - Visual C ++ - std::hashTextschlüssel kombinieren 10 Zeichen, die gleichmäßig entlang des Textes verteilt sind, zum Hash-Wert, sodass es unabhängig von der Textlänge O (1) ist (aber massiv kollisionsanfälliger als GCC!). Unabhängig davon haben Ansprüche von O (1) eine andere Annahme (normalerweise korrekt), dass m viel kleiner als n ist .
Tony Delroy
22

Sie müssen den Hash berechnen, daher ist die Reihenfolge O (n) für die Größe der Daten, die nachgeschlagen werden. Die Suche könnte O (1) sein, nachdem Sie O (n) gearbeitet haben, aber das kommt in meinen Augen immer noch zu O (n).

Was? Das Hashing eines einzelnen Elements benötigt konstante Zeit. Warum sollte es etwas anderes sein? Wenn Sie nElemente einfügen , müssen Sie nHashes berechnen , und das dauert linear. Um ein Element nachzuschlagen, berechnen Sie einen einzelnen Hash von dem, wonach Sie suchen, und finden damit den entsprechenden Bucket . Sie berechnen nicht die Hashes von allem, was bereits in der Hash-Tabelle enthalten ist.

Und wenn Sie keinen perfekten Hash oder keine große Hash-Tabelle haben, gibt es wahrscheinlich mehrere Elemente pro Bucket, sodass es sowieso irgendwann zu einer kleinen linearen Suche kommt.

Nicht unbedingt. Die Buckets müssen nicht unbedingt Listen oder Arrays sein, sie können beliebige Containertypen sein, z. B. eine ausgeglichene BST. Das bedeutet O(log n)schlimmsten Fall. Aus diesem Grund ist es wichtig, eine gute Hashing-Funktion zu wählen, um zu vermeiden, dass zu viele Elemente in einen Bucket eingefügt werden. Wie KennyTM betonte, haben Sie im Durchschnitt immer noch O(1)Zeit, auch wenn Sie gelegentlich durch einen Eimer graben müssen.

Der Kompromiss zwischen Hash-Tabellen ist natürlich die Platzkomplexität. Sie tauschen Raum gegen Zeit, was in der Informatik der übliche Fall zu sein scheint.


Sie erwähnen die Verwendung von Zeichenfolgen als Schlüssel in einem Ihrer anderen Kommentare. Sie sind besorgt darüber, wie lange es dauert, den Hash eines Strings zu berechnen, da er aus mehreren Zeichen besteht? Wie jemand anderes noch einmal betont hat, müssen Sie nicht unbedingt alle Zeichen betrachten, um den Hash zu berechnen, obwohl dies möglicherweise zu einem besseren Hash führt, wenn Sie dies tun. In diesem Fall, wenn mIhr Schlüssel durchschnittlich Zeichen enthält und Sie alle verwendet haben, um Ihren Hash zu berechnen, haben Sie wahrscheinlich Recht, dass Suchvorgänge erforderlich sind O(m). Wenn m >> ndann könnten Sie ein Problem haben. In diesem Fall wären Sie mit einem BST wahrscheinlich besser dran. Oder wählen Sie eine günstigere Hashing-Funktion.

mpen
quelle
Hash-Tabellen verwenden keine BSTs. BSTs erfordern keine Hashwerte. Karten und Sets können jedoch als BSTs implementiert werden.
Nick Dandoulakis
3
@ Nick: Eh? Nein ... BSTs benötigen keine Hashwerte ... das ist der Punkt. Wir gehen davon aus, dass wir zu diesem Zeitpunkt bereits eine Kollision haben (gleicher Hash ... oder zumindest der gleiche Bucket), also müssen wir uns etwas anderes ansehen, um das richtige Element zu finden, dh den tatsächlichen Wert.
Mpen
Oh, ich verstehe deinen Standpunkt. Aber ich bin mir nicht sicher, ob das Mischen von BSTs und Hashes die Mühe wert ist. Warum nicht einfach BSTs verwenden?
Nick Dandoulakis
2
Ich sage nur, dass Sie das für Kollisionen loswerden könntenO(n) . Wenn Sie sind viele Kollisionen erwarten, dann sind Sie richtig, wahrscheinlich besser dran , in erster Linie mit einem BST gehen.
Mpen
1
@ spaaarky21 Richtig, aber Nin diesem Fall ist die Länge der Zeichenfolge. Wir müssen nur einen String hashen, um zu bestimmen, in welchen 'Bucket' er gehen soll - er wächst nicht mit der Länge der Hashmap.
Mpen
5

Der Hash hat eine feste Größe - das Nachschlagen des entsprechenden Hash-Buckets ist eine Fixkostenoperation. Dies bedeutet, dass es O (1) ist.

Das Berechnen des Hashs muss keine besonders teure Operation sein - wir sprechen hier nicht von kryptografischen Hash-Funktionen. Aber das ist vorbei. Die Berechnung der Hash-Funktion selbst hängt nicht von der Anzahl n der Elemente ab. Dies hängt möglicherweise von der Größe der Daten in einem Element ab. Dies ist jedoch nicht das, worauf sich n bezieht. Die Berechnung des Hashs hängt also nicht von n ab und ist auch O (1).

David M.
quelle
3
Nachschlagen des Hash-Eimers ist O (1). Das Auffinden des richtigen Schlüssels ist jedoch eine O (n) -Prozedur, bei der n von der Anzahl der Hash-Kollisionen abhängt.
Nick Dandoulakis
1
Also aus 3 Schritten den Hash berechnen, den Bucket finden, den Bucket durchsuchen, der mittlere Schritt ist konstant? Das Durchsuchen des Buckets ist normalerweise konstant. Die Berechnung des Hashs ist normalerweise mehrere Größenordnungen billiger als andere Methoden zum Auffinden des Eimers. Aber summiert sich das wirklich zu konstanter Zeit? Bei einer naiven Teilstringsuche würden Sie O (n * m) für die beiden Längen sagen. Warum wird die Länge des Schlüssels hier nicht berücksichtigt?
gezogen
Das Finden eines Schlüssels mit fester Länge ist nur dann O (n), wenn seine Liste gesichert ist. Eine Hash-Tabelle mit ausgeglichenem Baum wird O (log (n))
jk sein.
@Jk Für gute Hash-Funktionen ist der schlimmste Fall immer logn, siehe meine Antwort unter stackoverflow.com/questions/4553624/hashmap-get-put-complexity/…
Thomas Ahle
Im schlimmsten Fall wird die Komplexität im Falle einer Kollision o (n) sein
Saurabh Chandra Patel
3

Hashing ist nur dann O (1), wenn die Tabelle nur eine konstante Anzahl von Schlüsseln enthält und einige andere Annahmen getroffen werden. Aber in solchen Fällen hat es einen Vorteil.

Wenn Ihr Schlüssel eine n-Bit-Darstellung hat, kann Ihre Hash-Funktion 1, 2, ... n dieser Bits verwenden. Denken Sie an eine Hash-Funktion, die 1 Bit verwendet. Die Bewertung ist mit Sicherheit O (1). Sie partitionieren den Schlüsselraum jedoch nur in 2. Sie ordnen also bis zu 2 ^ (n-1) Schlüssel demselben Bin zu. Bei Verwendung der BST-Suche sind bis zu n-1 Schritte erforderlich, um einen bestimmten Schlüssel zu finden, wenn dieser fast voll ist.

Sie können dies erweitern, um zu sehen, dass Ihre Bin-Größe 2 ^ (nk) beträgt, wenn Ihre Hash-Funktion K-Bits verwendet.

also K-Bit-Hash-Funktion ==> nicht mehr als 2 ^ K effektive Bins ==> bis zu 2 ^ (nK) n-Bit-Schlüssel pro Bin ==> (nK) Schritte (BST) zum Auflösen von Kollisionen. Tatsächlich sind die meisten Hash-Funktionen viel weniger "effektiv" und benötigen / verwenden mehr als K Bits, um 2 ^ k Bins zu erzeugen. Auch das ist optimistisch.

Sie können es so anzeigen - Sie benötigen ~ n Schritte, um im schlimmsten Fall ein Schlüsselpaar mit n Bits eindeutig unterscheiden zu können. Es gibt wirklich keine Möglichkeit, diese Grenze der Informationstheorie, die Hash-Tabelle oder nicht zu umgehen.

Dies ist jedoch NICHT, wie / wann Sie die Hash-Tabelle verwenden!

Bei der Komplexitätsanalyse wird davon ausgegangen, dass für n-Bit-Schlüssel O (2 ^ n) -Schlüssel in der Tabelle enthalten sein können (z. B. 1/4 aller möglichen Schlüssel). Aber die meiste, wenn nicht die ganze Zeit, in der wir die Hash-Tabelle verwenden, haben wir nur eine konstante Anzahl der n-Bit-Schlüssel in der Tabelle. Wenn Sie nur eine konstante Anzahl von Schlüsseln in der Tabelle möchten, beispielsweise C ist Ihre maximale Anzahl, können Sie eine Hash-Tabelle mit O (C) -Bins bilden, die eine erwartete konstante Kollision garantiert (mit einer guten Hash-Funktion). und eine Hash-Funktion unter Verwendung von ~ logC der n Bits im Schlüssel. Dann ist jede Abfrage O (logC) = O (1). Auf diese Weise behaupten die Leute, dass der Zugriff auf die Hash-Tabelle O (1) ist.

Hier gibt es ein paar Fänge: Erstens ist es möglicherweise nur ein Abrechnungstrick, zu sagen, dass Sie nicht alle Bits benötigen. Erstens können Sie den Schlüsselwert nicht wirklich an die Hash-Funktion übergeben, da dies n Bits im Speicher verschieben würde, der O (n) ist. Sie müssen also zB eine Referenzübergabe durchführen. Sie müssen es jedoch noch irgendwo speichern, was eine O (n) -Operation war. Sie rechnen es einfach nicht mit dem Hashing ab. Ihre gesamte Rechenaufgabe kann dies nicht vermeiden. Zweitens führen Sie das Hashing durch, suchen den Papierkorb und finden mehr als 1 Schlüssel. Ihre Kosten hängen von Ihrer Auflösungsmethode ab. Wenn Sie einen Vergleich durchführen (BST oder Liste), haben Sie eine O (n) -Operation (Rückrufschlüssel ist n-Bit). Wenn Sie den 2. Hash ausführen, haben Sie das gleiche Problem, wenn der 2. Hash kollidiert.

Betrachten Sie in diesem Fall die Alternative, z. B. BST. Es gibt C-Tasten, daher ist eine ausgeglichene BST in der Tiefe O (logC), sodass eine Suche O (logC) -Schritte ausführt. Der Vergleich in diesem Fall wäre jedoch eine O (n) -Operation ... daher scheint Hashing in diesem Fall die bessere Wahl zu sein.

Eugene D.
quelle
1

TL; DR: Hash-Tabellen garantieren die O(1)erwartete Worst-Case-Zeit, wenn Sie Ihre Hash-Funktion gleichmäßig zufällig aus einer universellen Familie von Hash-Funktionen auswählen. Der erwartete schlimmste Fall ist nicht der gleiche wie der durchschnittliche Fall.

Haftungsausschluss: Ich beweise formal nicht, dass Hash-Tabellen vorhanden sind. O(1)Schauen Sie sich dazu dieses Video von coursera [ 1 ] an. Ich diskutiere auch nicht die amortisierten Aspekte von Hash-Tabellen. Das ist orthogonal zur Diskussion über Hashing und Kollisionen.

Ich sehe in anderen Antworten und Kommentaren überraschend viel Verwirrung um dieses Thema und werde versuchen, einige davon in dieser langen Antwort zu korrigieren.

Argumentation über den schlimmsten Fall

Es gibt verschiedene Arten der Worst-Case-Analyse. Die Analyse, die die meisten Antworten hier bisher gemacht haben, ist kein Worst-Case, sondern ein Durchschnittsfall [ 2 ]. Eine durchschnittliche Fallanalyse ist in der Regel praktischer. Möglicherweise hat Ihr Algorithmus eine schlechte Worst-Case-Eingabe, funktioniert aber tatsächlich gut für alle anderen möglichen Eingaben. Unterm Strich hängt Ihre Laufzeit von dem Datensatz ab, auf dem Sie ausgeführt werden.

Betrachten Sie den folgenden Pseudocode der getMethode einer Hash-Tabelle. Hier gehe ich davon aus, dass wir Kollisionen durch Verketten behandeln, sodass jeder Eintrag in der Tabelle eine verknüpfte Liste von (key,value)Paaren ist. Wir gehen auch davon aus, dass die Anzahl der Buckets mfest ist O(n), aber wo nist die Anzahl der Elemente in der Eingabe.

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

Wie andere Antworten gezeigt haben, läuft dies im Durchschnitt O(1)und im schlimmsten Fall O(n). Wir können hier eine kleine Skizze eines Beweises durch Herausforderung machen. Die Herausforderung lautet wie folgt:

(1) Sie geben Ihren Hash-Tabellen-Algorithmus an einen Gegner weiter.

(2) Der Gegner kann es studieren und vorbereiten, solange er will.

(3) Schließlich gibt Ihnen der Gegner eine Eingabe der Größe, die nSie in Ihre Tabelle einfügen können.

Die Frage ist: Wie schnell ist Ihre Hash-Tabelle in der Eingabe des Gegners?

Ab Schritt (1) kennt der Gegner Ihre Hash-Funktion; während Schritt (2) kann der Gegner eine Liste von nElementen mit denselben hash modulo merstellen, indem er beispielsweise den Hash einer Reihe von Elementen zufällig berechnet; und dann in (3) können sie Ihnen diese Liste geben. Aber siehe da, da alle nElemente denselben Bucket haben, wird Ihr Algorithmus einige O(n)Zeit brauchen, um die verknüpfte Liste in diesem Bucket zu durchlaufen. Egal wie oft wir die Herausforderung wiederholen, der Gegner gewinnt immer, und so schlecht ist Ihr Algorithmus im schlimmsten Fall O(n).

Wie kommt es, dass Hashing O (1) ist?

Was uns bei der vorherigen Herausforderung abschreckte, war, dass der Gegner unsere Hash-Funktion sehr gut kannte und dieses Wissen nutzen konnte, um den schlechtesten Input zu erstellen. Was wäre, wenn wir nicht immer eine feste Hash-Funktion verwenden würden, sondern tatsächlich eine Reihe von Hash-Funktionen hätten H, aus denen der Algorithmus zur Laufzeit zufällig auswählen kann? Wenn Sie neugierig sind, Hspricht man von einer universellen Familie von Hash-Funktionen [ 3 ]. Okay, lassen Sie uns versuchen, etwas Zufälligkeit hinzuzufügen .

Angenommen, unsere Hash-Tabelle enthält auch einen Startwert rund rwird zur Erstellungszeit einer Zufallszahl zugewiesen. Wir weisen es einmal zu und dann ist es für diese Hash-Tabelleninstanz behoben. Lassen Sie uns nun unseren Pseudocode erneut betrachten.

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

Wenn wir die Herausforderung noch einmal versuchen: Ab Schritt (1) kann der Gegner alle Hash-Funktionen kennen, in denen wir uns befinden H, aber jetzt hängt die spezifische Hash-Funktion, die wir verwenden, davon ab r. Der Wert von rist für unsere Struktur privat. Der Gegner kann ihn weder zur Laufzeit überprüfen noch im Voraus vorhersagen, sodass er keine Liste erstellen kann, die für uns immer schlecht ist. Nehmen wir an , dass in Schritt (2) der Gegner eine Funktion wählt hashin Hzufällig, er Handwerk dann eine Liste von nKollisionen unter hash modulo m, und sendet die für Schritt (3), Kreuzung Finger , die zur Laufzeit H[r]die gleiche sein wird hashsie gewählt haben.

Dies ist eine ernsthafte Wette für den Gegner, die Liste, unter der er erstellt wurde, kollidiert hash, ist jedoch nur eine zufällige Eingabe unter einer anderen Hash-Funktion in H. Wenn er diese Wette gewinnt, ist unsere Laufzeit der schlechteste Fall O(n)wie zuvor, aber wenn er verliert, erhalten wir nur eine zufällige Eingabe, die die durchschnittliche O(1)Zeit in Anspruch nimmt . Und in der Tat wird der Gegner meistens verlieren, er gewinnt nur einmal bei jeder |H|Herausforderung, und wir können |H|sehr groß werden.

Vergleichen Sie dieses Ergebnis mit dem vorherigen Algorithmus, bei dem der Gegner immer die Herausforderung gewonnen hat. Handwinken hier ein bisschen, aber da der Gegner meistens scheitert und dies für alle möglichen Strategien gilt, die der Gegner versuchen kann, folgt daraus, dass, obwohl der schlimmste Fall ist O(n), der erwartete schlimmste Fall tatsächlich ist O(1).


Auch dies ist kein formaler Beweis. Die Garantie, die wir aus dieser erwarteten Worst-Case-Analyse erhalten, besteht darin, dass unsere Laufzeit jetzt unabhängig von bestimmten Eingaben ist . Dies ist eine wirklich zufällige Garantie, im Gegensatz zu der durchschnittlichen Fallanalyse, bei der wir gezeigt haben, dass ein motivierter Gegner leicht schlechte Eingaben machen kann.

Edman
quelle
0

Es gibt zwei Einstellungen, unter denen Sie O (1) Worst-Case-Zeiten erhalten können.

  1. Wenn Ihr Setup statisch ist, erhalten Sie durch FKS-Hashing O (1) -Garantien im ungünstigsten Fall . Aber wie Sie angegeben haben, ist Ihre Einstellung nicht statisch.
  2. Wenn Sie Cuckoo-Hashing verwenden, sind Abfragen und Löschungen O (1) im schlimmsten Fall, aber das Einfügen wird nur mit O (1) erwartet. Kuckuck-Hashing funktioniert recht gut, wenn Sie eine Obergrenze für die Gesamtzahl der Einsätze haben und die Tabellengröße auf ungefähr 25% größer einstellen.

Von hier kopiert

ChaosPredictor
quelle
0

Es scheint hier auf der Diskussion zu beruhen, dass wenn X die Obergrenze von (Anzahl der Elemente in der Tabelle / Anzahl der Bins) ist, eine bessere Antwort O (log (X)) ist, vorausgesetzt, dass die Bin-Suche effizient implementiert wird.

nak
quelle
0

A. Der Wert ist ein int kleiner als die Größe der Hash-Tabelle. Daher ist der Wert ein eigener Hash, daher gibt es keine Hash-Tabelle. Aber wenn es so wäre, wäre es O (1) und immer noch ineffizient.

Dies ist ein Fall, in dem Sie die Schlüssel trivial unterschiedlichen Buckets zuordnen können, sodass ein Array eine bessere Wahl für die Datenstruktur zu sein scheint als eine Hash-Tabelle. Die Ineffizienzen wachsen jedoch nicht mit der Tabellengröße.

(Möglicherweise verwenden Sie weiterhin eine Hash-Tabelle, da Sie nicht darauf vertrauen, dass die Ints während der Entwicklung des Programms kleiner als die Tabellengröße bleiben. Sie möchten den Code möglicherweise wiederverwendbar machen, wenn diese Beziehung nicht besteht oder Sie dies einfach nicht tun möchten, dass Menschen, die den Code lesen / pflegen, geistige Anstrengungen verschwenden müssen, um die Beziehung zu verstehen und aufrechtzuerhalten).

B. Sie müssen einen Hash des Werts berechnen. In dieser Situation ist die Reihenfolge O (n) für die Größe der Daten, die nachgeschlagen werden. Die Suche könnte O (1) sein, nachdem Sie O (n) gearbeitet haben, aber das kommt in meinen Augen immer noch zu O (n).

Wir müssen zwischen der Größe des Schlüssels (z. B. in Bytes) und der Größe der Anzahl der Schlüssel unterscheiden, die in der Hash-Tabelle gespeichert sind. Behauptungen, dass Hash-Tabellen O (1) -Operationen bereitstellen, bedeuten, dass Operationen (Einfügen / Löschen / Suchen) nicht weiter verlangsamt werden, da die Anzahl der Schlüssel von Hunderten auf Tausende auf Millionen auf Milliarden steigt (zumindest nicht, wenn alle Daten vorliegen Der Zugriff / die Aktualisierung erfolgt in einem ebenso schnellen Speicher, sei es, dass RAM- oder Festplatten-Cache-Effekte ins Spiel kommen können, aber selbst die Kosten eines Cache-Fehlschlags im ungünstigsten Fall sind in der Regel ein konstantes Vielfaches der Best-Case-Treffer.

Stellen Sie sich ein Telefonbuch vor: Sie haben vielleicht Namen, die ziemlich lang sind, aber ob das Buch 100 Namen oder 10 Millionen hat, die durchschnittliche Namenslänge wird ziemlich konsistent sein und der schlimmste Fall in der Geschichte ...

Der Guinness-Weltrekord für den längsten Namen, den jemals jemand verwendet hat, wurde von Adolph Blaine Charles David Earl Frederick Gerald Hubert Irvin John Kenneth Lloyd Martin Nero Oliver Paul Quincy Randolph Sherman Thomas Uncas Victor William Xerxes Yancy Wolfeschlegelsteinhausenbergerdorff, Senior, aufgestellt

... wcsagt mir, dass das 215 Zeichen sind - das ist keine harte Obergrenze für die Schlüssellänge, aber wir müssen uns keine Sorgen machen, dass es massiv mehr gibt.

Dies gilt für die meisten realen Hash-Tabellen: Die durchschnittliche Schlüssellänge wächst nicht mit der Anzahl der verwendeten Schlüssel. Es gibt Ausnahmen, zum Beispiel kann eine Schlüsselerstellungsroutine Zeichenfolgen zurückgeben, in die inkrementierende Ganzzahlen eingebettet sind, aber selbst dann erhöhen Sie jedes Mal, wenn Sie die Anzahl der Schlüssel um eine Größenordnung erhöhen, die Schlüssellänge nur um 1 Zeichen: Dies ist nicht signifikant.

Es ist auch möglich, einen Hash aus einer Menge von Schlüsseldaten fester Größe zu erstellen. Zum Beispiel wird Microsoft Visual C ++ mit einer Standardbibliotheksimplementierung ausgeliefert std::hash<std::string>, die einen Hash erstellt, der nur zehn Bytes enthält, die gleichmäßig entlang der Zeichenfolge verteilt sind. Wenn die Zeichenfolgen also nur bei anderen Indizes variieren, kommt es zu Kollisionen (und damit in der Praxis zu Nicht-O (1) -Verhalten auf der Suchseite nach der Kollision), aber die Zeit zum Erstellen des Hashs hat eine harte Obergrenze.

Und wenn Sie keinen perfekten Hash oder eine große Hash-Tabelle haben, gibt es wahrscheinlich mehrere Artikel pro Eimer. Es entwickelt sich also sowieso irgendwann zu einer kleinen linearen Suche.

Im Allgemeinen stimmt das, aber das Tolle an Hash-Tabellen ist, dass die Anzahl der Schlüssel, die während dieser "kleinen linearen Suche" besucht wurden, - für den separaten Verkettungsansatz bei Kollisionen - eine Funktion des Lastfaktors der Hash-Tabelle (Verhältnis von Schlüsseln zu Buckets) ist.

Bei einem Auslastungsfaktor von 1,0 beträgt die durchschnittliche Länge dieser linearen Suchvorgänge unabhängig von der Anzahl der Schlüssel durchschnittlich ~ 1,58 (siehe meine Antwort hier ). Für geschlossenes Hashing ist es etwas komplizierter, aber nicht viel schlimmer, wenn der Lastfaktor nicht zu hoch ist.

Dies ist technisch richtig, da die Hash-Funktion nicht alle Informationen im Schlüssel verwenden muss und daher eine konstante Zeit sein kann, und weil eine ausreichend große Tabelle Kollisionen auf eine nahezu konstante Zeit reduzieren kann.

Diese Art verfehlt den Punkt. Jede Art von assoziativer Datenstruktur muss letztendlich manchmal Operationen über jeden Teil des Schlüssels ausführen (Ungleichheit kann manchmal nur aus einem Teil des Schlüssels bestimmt werden, aber Gleichheit erfordert im Allgemeinen, dass jedes Bit berücksichtigt wird). Zumindest kann es den Schlüssel einmal hashen und den Hash-Wert speichern. Wenn es eine ausreichend starke Hash-Funktion verwendet - z. B. 64-Bit-MD5 -, wird möglicherweise sogar die Möglichkeit ignoriert, dass zwei Schlüssel denselben Wert haben (ein Unternehmen) Ich habe genau das für die verteilte Datenbank getan: Die Zeit für die Hash-Generierung war im Vergleich zu WAN-weiten Netzwerkübertragungen immer noch unbedeutend. Es macht also nicht allzu viel Sinn, sich über die Kosten für die Verarbeitung des Schlüssels Gedanken zu machen: Dies ist mit dem Speichern von Schlüsseln unabhängig von der Datenstruktur verbunden, und wie oben erwähnt - nicht.

Bei Hash-Tabellen, die groß genug sind, um Kollisionen zu reduzieren, fehlt auch der Punkt. Für eine separate Verkettung haben Sie bei jedem Lastfaktor immer noch eine konstante durchschnittliche Kollisionskettenlänge - sie ist nur höher, wenn der Lastfaktor höher ist, und diese Beziehung ist nicht linear. Der SO-Benutzer Hans kommentiert meine Antwort auch wie oben verlinkt :

Die durchschnittliche Schaufellänge, die von nicht leeren Schaufeln abhängig ist, ist ein besseres Maß für die Effizienz. Es ist a / (1-e ^ {- a}) [wobei a der Lastfaktor ist, e 2,71828 ist ...]

Der Ladefaktor allein bestimmt also die durchschnittliche Anzahl kollidierender Schlüssel, die Sie beim Einfügen / Löschen / Suchen durchsuchen müssen. Bei einer getrennten Verkettung ist es nicht nur konstant, wenn der Lastfaktor niedrig ist, sondern immer konstant. Für die offene Adressierung hat Ihr Anspruch jedoch eine gewisse Gültigkeit: Einige kollidierende Elemente werden in alternative Buckets umgeleitet und können dann die Operationen auf anderen Schlüsseln stören, sodass bei höheren Lastfaktoren (insbesondere> .8 oder .9) die Kollisionskettenlänge dramatischer schlechter wird.

In der Praxis ist dies der Fall, da es im Laufe der Zeit nur funktioniert, solange die Hash-Funktion und die Tabellengröße so gewählt werden, dass Kollisionen minimiert werden, obwohl dies häufig bedeutet, dass keine Hash-Funktion mit konstanter Zeit verwendet wird.

Nun, die Tabellengröße sollte zu einem vernünftigen Auslastungsfaktor führen, wenn man zwischen engem Hashing oder getrennter Verkettung wählt. Aber auch wenn die Hash-Funktion etwas schwach ist und die Schlüssel nicht sehr zufällig sind, hilft eine Primzahl von Buckets oft, sie zu reduzieren Kollisionen ebenfalls (wird hash-value % table-sizedann so umbrochen, dass Änderungen nur zu einem oder zwei höherwertigen Bits im Hash-Wert immer noch in Eimer aufgelöst werden, die pseudozufällig über verschiedene Teile der Hash-Tabelle verteilt sind).

Tony Delroy
quelle