it-swarm.com.de

Einfache Hashfunktionen

Ich versuche, ein C -Programm zu schreiben, das eine Hash-Tabelle zum Speichern verschiedener Wörter verwendet, und ich könnte Hilfe gebrauchen.

Zuerst erstelle ich eine Hash-Tabelle mit der Größe einer Primzahl, die der Anzahl der Wörter am nächsten kommt, die ich speichern muss, und dann benutze ich eine Hash-Funktion, um eine Adresse für jedes Wort zu finden. Ich begann mit der einfachsten Funktion und addierte die Buchstaben, was zu einer Kollision von 88% führte. Dann fing ich an, mit der Funktion zu experimentieren und fand heraus, dass die Kollisionen nicht unter 35% liegen, egal auf was ich sie ändere. Im Moment benutze ich

unsigned int stringToHash(char *Word, unsigned int hashTableSize){
  unsigned int counter, hashAddress =0;
  for (counter =0; Word[counter]!='\0'; counter++){
    hashAddress = hashAddress*Word[counter] + Word[counter] + counter;
  }
  return (hashAddress%hashTableSize);
}

das ist nur eine zufällige Funktion, die ich mir ausgedacht habe, aber sie liefert mir die besten Ergebnisse - etwa 35% Kollision.

Ich habe in den letzten Stunden Artikel über Hash-Funktionen gelesen und versucht, ein paar einfache Funktionen wie djb2 zu verwenden, aber alle haben zu noch schlechteren Ergebnissen geführt. Es ist nicht viel schlimmer, aber ich habe etwas Besseres erwartet als Schlimmeres. Ich weiß auch nicht, wie ich einige der anderen, komplexeren, wie z , Samen) nehmen sie auf.

Ist es normal, dass es trotz djb2 zu mehr als 35% Kollisionen kommt, oder mache ich etwas falsch? Was sind die Werte für key, len und seed?

33
Hardell

Versuchen Sie sdbm:

hashAddress = 0;
for (counter = 0; Word[counter]!='\0'; counter++){
    hashAddress = Word[counter] + (hashAddress << 6) + (hashAddress << 16) - hashAddress;
}

Oder djb2:

hashAddress = 5381;
for (counter = 0; Word[counter]!='\0'; counter++){
    hashAddress = ((hashAddress << 5) + hashAddress) + Word[counter];
}

Oder Adler32:

uint32_t adler32(const void *buf, size_t buflength) {
     const uint8_t *buffer = (const uint8_t*)buf;

     uint32_t s1 = 1;
     uint32_t s2 = 0;

     for (size_t n = 0; n < buflength; n++) {
        s1 = (s1 + buffer[n]) % 65521;
        s2 = (s2 + s1) % 65521;
     }     
     return (s2 << 16) | s1;
}

// ...

hashAddress = adler32(Word, strlen(Word));

Nichts davon ist wirklich großartig. Wenn Sie wirklich gute Hashes wollen, brauchen Sie etwas Komplexeres wie lookup zum Beispiel.

Beachten Sie, dass bei einer Hash-Tabelle eine Menge Kollisionen erwartet werden, sobald sie gefüllt ist um mehr als 70-80%. Dies ist völlig normal und tritt auch dann auf, wenn Sie einen sehr guten Hash-Algorithmus verwenden. Aus diesem Grund erhöhen die meisten Hashtable-Implementierungen die Kapazität der Hashtable (z. B. capacity * 1.5 Oder sogar capacity * 2), Sobald Sie der Hashtable etwas hinzufügen und das Verhältnis size / capacity Bereits übersteigt 0,7 bis 0,8. Wenn Sie die Kapazität erhöhen, wird eine neue Hashtabelle mit einer höheren Kapazität erstellt. Alle Werte der aktuellen Hashtabelle werden zur neuen Hashtabelle hinzugefügt (daher müssen sie alle erneut verarbeitet werden, da ihr neuer Index in den meisten Fällen anders ist). Dies ist das neue hastable Array ersetzt die alte und die alte wird freigegeben. Wenn Sie 1000 Wörter haschen möchten, wird eine Hash-Kapazität von mindestens 1250 empfohlen, besser 1400 oder sogar 1500.

Hashtables sollten nicht "randvoll" gefüllt werden, zumindest nicht, wenn sie schnell und effizient sein sollen (daher sollten sie immer über Kapazitätsreserven verfügen). Das ist die Verkleinerung von Hashtabellen, sie sind schnell (O(1)), verschwenden jedoch normalerweise mehr Speicherplatz, als zum Speichern derselben Daten in einer anderen Struktur erforderlich wäre (wenn Sie sie als sortiertes Array speichern, werden Sie nur eine Kapazität von 1000 für 1000 Wörter benötigen; die Verkleinerung besteht darin, dass die Suche in diesem Fall nicht schneller als O(log n) sein kann). Eine kollisionsfreie Hashtabelle ist in den meisten Fällen auch nicht möglich. Nahezu alle Hash-Tabellen-Implementierungen erwarten Kollisionen und haben normalerweise eine Möglichkeit, mit ihnen umzugehen (normalerweise verlangsamen Kollisionen die Suche, aber die Hash-Tabelle funktioniert immer noch und schlägt in vielen Fällen andere Datenstrukturen).

Beachten Sie auch, dass es bei Verwendung einer ziemlich guten Hash-Funktion keine Anforderung gibt, aber auch keinen Vorteil, wenn die Hash-Tabelle eine Potenz von 2 hat, wenn Sie Hash-Werte mit modulo (%) In beschneiden das Ende. Der Grund, warum viele Hashtable-Implementierungen immer Potenzen mit 2 Kapazitäten verwenden, ist, dass sie kein Modulo verwenden, sondern stattdessen AND (&). zum Beschneiden, da eine UND-Verknüpfung zu den schnellsten Verknüpfungen gehört, die Sie auf den meisten CPUs finden (Modulo ist niemals schneller als AND, im besten Fall ist es gleich schnell, in den meisten Fällen ist es viel langsamer). Wenn Ihre Hashtabelle eine Potenz von 2 Größen verwendet, können Sie jedes Modul durch eine UND-Verknüpfung ersetzen:

x % 4  == x & 3
x % 8  == x & 7
x % 16 == x & 15
x % 32 == x & 31
...

Dies funktioniert jedoch nur für Potenzen von 2 Größen. Wenn Sie Modulo verwenden, können Potenzen von 2 Größen nur dann etwas kaufen, wenn der Hash ein sehr schlechter Hash mit einer sehr schlechten "Bitverteilung" ist. Eine schlechte Bitverteilung wird normalerweise durch Hashes verursacht, die keine Bitverschiebung (>> Oder <<) Oder andere Operationen verwenden, die einen ähnlichen Effekt wie die Bitverschiebung haben würden.

Ich habe eine abgespeckte Lookup3-Implementierung für Sie erstellt:

#include <stdint.h>
#include <stdlib.h>

#define rot(x,k) (((x)<<(k)) | ((x)>>(32-(k))))

#define mix(a,b,c) \
{ \
  a -= c;  a ^= rot(c, 4);  c += b; \
  b -= a;  b ^= rot(a, 6);  a += c; \
  c -= b;  c ^= rot(b, 8);  b += a; \
  a -= c;  a ^= rot(c,16);  c += b; \
  b -= a;  b ^= rot(a,19);  a += c; \
  c -= b;  c ^= rot(b, 4);  b += a; \
}

#define final(a,b,c) \
{ \
  c ^= b; c -= rot(b,14); \
  a ^= c; a -= rot(c,11); \
  b ^= a; b -= rot(a,25); \
  c ^= b; c -= rot(b,16); \
  a ^= c; a -= rot(c,4);  \
  b ^= a; b -= rot(a,14); \
  c ^= b; c -= rot(b,24); \
}

uint32_t lookup3 (
  const void *key,
  size_t      length,
  uint32_t    initval
) {
  uint32_t  a,b,c;
  const uint8_t  *k;
  const uint32_t *data32Bit;

  data32Bit = key;
  a = b = c = 0xdeadbeef + (((uint32_t)length)<<2) + initval;

  while (length > 12) {
    a += *(data32Bit++);
    b += *(data32Bit++);
    c += *(data32Bit++);
    mix(a,b,c);
    length -= 12;
  }

  k = (const uint8_t *)data32Bit;
  switch (length) {
    case 12: c += ((uint32_t)k[11])<<24;
    case 11: c += ((uint32_t)k[10])<<16;
    case 10: c += ((uint32_t)k[9])<<8;
    case 9 : c += k[8];
    case 8 : b += ((uint32_t)k[7])<<24;
    case 7 : b += ((uint32_t)k[6])<<16;
    case 6 : b += ((uint32_t)k[5])<<8;
    case 5 : b += k[4];
    case 4 : a += ((uint32_t)k[3])<<24;
    case 3 : a += ((uint32_t)k[2])<<16;
    case 2 : a += ((uint32_t)k[1])<<8;
    case 1 : a += k[0];
             break;
    case 0 : return c;
  }
  final(a,b,c);
  return c;
}

Dieser Code ist nicht so leistungsoptimiert wie der Originalcode, daher ist er viel einfacher. Es ist auch nicht so portabel wie der ursprüngliche Code, aber es ist portabel für alle gängigen Verbraucherplattformen, die heute verwendet werden. Es ignoriert auch den CPU-Endian vollständig, aber das ist nicht wirklich ein Problem, es wird auf großen und kleinen Endian-CPUs funktionieren. Beachten Sie jedoch, dass auf Big- und Little-Endian-CPUs nicht derselbe Hash für dieselben Daten berechnet wird, dies ist jedoch nicht erforderlich. Es wird ein guter Hash für beide Arten von CPUs berechnet, und es ist nur wichtig, dass auf einer einzelnen Maschine immer derselbe Hash für dieselben Eingabedaten berechnet wird.

Sie würden diese Funktion wie folgt verwenden:

unsigned int stringToHash(char *Word, unsigned int hashTableSize){
  unsigned int initval;
  unsigned int hashAddress;

  initval = 12345;
  hashAddress = lookup3(Word, strlen(Word), initval);
  return (hashAddress%hashTableSize);
  // If hashtable is guaranteed to always have a size that is a power of 2,
  // replace the line above with the following more effective line:
  //     return (hashAddress & (hashTableSize - 1));
}

Sie fragen sich, was initval ist. Nun, es ist was immer du willst. Man könnte es ein Salz nennen. Dies beeinflusst die Hash-Werte, jedoch wird die Qualität der Hash-Werte dadurch nicht verbessert oder verschlechtert (zumindest nicht im Durchschnitt kann es jedoch zu mehr oder weniger Kollisionen bei sehr spezifischen Daten kommen). Z.B. Sie können verschiedene initval - Werte verwenden, wenn Sie dieselben Daten zweimal hashen möchten, aber jedes Mal einen anderen Hash-Wert erzeugen möchten (es gibt keine Garantie dafür, dass dies der Fall ist, aber es ist eher wahrscheinlich, wenn initval ist anders; wenn es den gleichen Wert schafft, wäre dies ein sehr unglücklicher Zufall, dass Sie das als eine Art Kollision behandeln müssen). Es ist nicht ratsam, unterschiedliche initval - Werte zu verwenden, wenn Daten für dieselbe Hashtabelle gehasht werden (dies führt im Durchschnitt eher zu mehr Kollisionen). Eine andere Verwendung für initval ist, wenn Sie einen Hash mit anderen Daten kombinieren möchten. In diesem Fall wird der bereits vorhandene Hash zu initval, wenn die anderen Daten gehasht werden (also sowohl die anderen Daten als auch der vorherige Hash-Einfluss) das Ergebnis der Hash-Funktion). Sie können initval sogar auf 0 Setzen, wenn Sie beim Erstellen der Hashtabelle einen zufälligen Wert möchten oder auswählen (und diesen zufälligen Wert immer für diese Instanz der Hashtabelle verwenden, aber jede Hashtabelle hat ihren eigenen zufälligen Wert Wert).

Ein Hinweis zu Kollisionen:

Kollisionen sind in der Praxis normalerweise kein so großes Problem, dass es sich in der Regel nicht auszahlt, Tonnen von Speicher zu verschwenden, nur um sie zu vermeiden. Die Frage ist vielmehr, wie Sie effizient mit ihnen umgehen.

Sie sagten, Sie beschäftigen sich derzeit mit 9000 Wörtern. Wenn Sie ein unsortiertes Array verwendet haben, sind durchschnittlich 4500 Vergleiche erforderlich, um ein Wort im Array zu finden. Auf meinem System benötigen 4500 Zeichenfolgenvergleiche (unter der Annahme, dass Wörter zwischen 3 und 20 Zeichen lang sind) 38 Mikrosekunden (0,000038 Sekunden). Selbst ein so einfacher, ineffektiver Algorithmus ist für die meisten Zwecke schnell genug. Angenommen, Sie sortieren die Wortliste und verwenden eine binäre Suche. Für die Suche nach einem Wort im Array sind durchschnittlich nur 13 Vergleiche erforderlich. 13 Vergleiche sind zeitlich nahezu nichts, es ist zu wenig, um überhaupt einen verlässlichen Benchmark zu erstellen. Wenn also das Finden eines Wortes in einer Hash-Tabelle 2 bis 4 Vergleiche erfordert, würde ich nicht einmal eine Sekunde mit der Frage verschwenden, ob dies ein großes Leistungsproblem sein könnte.

In Ihrem Fall kann eine sortierte Liste mit binärer Suche sogar eine Hash-Tabelle bei weitem übertreffen. Sicherlich benötigen 13 Vergleiche mehr Zeit als 2-4 Vergleiche. Im Fall einer Hash-Tabelle müssen Sie jedoch zuerst die Eingabedaten hashen, um eine Suche durchzuführen. Das Hashing alleine kann schon länger als 13 Vergleiche dauern! Je besser der Hash, desto länger desto mehr Daten werden gehasht. Eine Hash-Tabelle zahlt sich also nur dann aus, wenn Sie über eine sehr große Datenmenge verfügen oder wenn Sie die Daten häufig aktualisieren müssen (z. B. ständig Wörter zur Tabelle hinzufügen/daraus entfernen), da diese Vorgänge für eine Hash-Tabelle weniger kostspielig sind als für sie sind für eine sortierte Liste). Die Tatsache, dass ein Hash O(1) ist, bedeutet nur, dass unabhängig von der Größe ein Lookup ca. brauchen immer die gleiche zeit O(log n) bedeutet nur, dass die Suche logarithmisch mit der Anzahl der Wörter wächst, dh mehr Wörter, langsamere Suche. Die Big-O-Notation sagt jedoch nichts über die absolute Geschwindigkeit aus! Das ist ein großes Missverständnis. Es wird nicht gesagt, dass ein O(1) - Algorithmus immer schneller arbeitet als ein O(log n) - Algorithmus. Die Big-O-Notation gibt nur an, dass der O(log n) - Algorithmus den O(1) - Algorithmus mit Sicherheit überholt, wenn der O(log n) -Algorithmus zu einem bestimmten Zeitpunkt, aber Ihre aktuelle Wortanzahl liegt möglicherweise weit darunter. Ohne Benchmarking beider Ansätze kann man nicht sagen, welcher schneller ist, wenn man sich nur die Big-O-Notation ansieht.

Zurück zu den Kollisionen. Was tun, wenn Sie auf eine Kollision stoßen? Wenn die Anzahl der Kollisionen klein ist, meine ich hier nicht die Gesamtzahl der Kollisionen (die Anzahl der Wörter, die in der Hashtabelle kollidieren), sondern die pro Index (die Anzahl der Wörter, die im selben Hashtabellenindex gespeichert sind) in Ihrem Fall vielleicht 2-4), ist der einfachste Ansatz, sie als verknüpfte Liste zu speichern. Wenn für diesen Tabellenindex bisher keine Kollision aufgetreten ist, gibt es nur ein einziges Schlüssel/Wert-Paar. Wenn eine Kollision aufgetreten ist, gibt es eine verknüpfte Liste von Schlüssel/Wert-Paaren. In diesem Fall muss Ihr Code die verknüpfte Liste durchlaufen und jeden der Schlüssel überprüfen und den Wert zurückgeben, wenn er übereinstimmt. Gemessen an Ihren Zahlen enthält diese verknüpfte Liste nicht mehr als 4 Einträge, und 4 Vergleiche sind in Bezug auf die Leistung unbedeutend. Das Auffinden des Index ist also O(1), das Auffinden des Werts (oder das Erkennen, dass dieser Schlüssel nicht in der Tabelle enthalten ist) ist O(n), aber hier ist n nur die Nummer der verknüpften Liste Einträge (so ist es höchstens 4).

Wenn die Anzahl der Kollisionen zunimmt, kann eine verknüpfte Liste zu langsam werden, und Sie können auch ein dynamisch großes, sortiertes Array von Schlüssel/Wert-Paaren speichern, das das Nachschlagen von O(log n) und erneut n ermöglicht. ist nur die Anzahl der Schlüssel in diesem Array, nicht aller Schlüssel in der Hastable. Selbst wenn ein Index 100 Kollisionen enthält, werden höchstens 7 Vergleiche durchgeführt, um das richtige Schlüssel/Wert-Paar zu finden. Das ist immer noch fast nichts. Obwohl Sie tatsächlich 100 Kollisionen bei einem Index haben, ist entweder Ihr Hash-Algorithmus für Ihre Schlüsseldaten ungeeignet, oder die Hash-Tabelle hat eine viel zu geringe Kapazität. Der Nachteil eines dynamisch sortierten Arrays ist, dass das Hinzufügen/Entfernen von Schlüsseln etwas aufwändiger ist als bei einer verknüpften Liste (in Bezug auf den Code, nicht unbedingt in Bezug auf die Leistung). Die Verwendung einer verknüpften Liste ist in der Regel ausreichend, wenn Sie die Anzahl der Kollisionen niedrig genug halten, und es ist fast trivial, eine solche verknüpfte Liste selbst in C zu implementieren und zu einer vorhandenen Hashtabellenimplementierung hinzuzufügen.

Die meisten Hashtable-Implementierungen, die ich verwendet habe, scheinen einen solchen "Fallback auf eine alternative Datenstruktur" zu verwenden, um mit Kollisionen umzugehen. Der Nachteil besteht darin, dass diese ein wenig zusätzlichen Speicher benötigen, um die alternative Datenstruktur zu speichern, und ein bisschen mehr Code, um auch nach Schlüsseln in dieser Struktur zu suchen. Es gibt auch Lösungen, die Kollisionen in der Hash-Tabelle selbst speichern und keinen zusätzlichen Speicher benötigen. Diese Lösungen haben jedoch einige Nachteile. Der erste Nachteil ist, dass jede Kollision die Wahrscheinlichkeit für noch mehr Kollisionen erhöht, wenn mehr Daten hinzugefügt werden. Der zweite Nachteil besteht darin, dass die Nachschlagezeiten für Schlüssel mit der Anzahl der bisherigen Kollisionen linear abnehmen (und wie ich bereits sagte, führt jede Kollision zu noch mehr Kollisionen, wenn Daten hinzugefügt werden), während die Nachschlagezeiten für Schlüssel, die nicht in der Hash-Tabelle enthalten sind, noch schlechter abnehmen und wenn Sie am Ende eine Suche nach einem Schlüssel durchführen, der nicht in der Hash-Tabelle enthalten ist (Sie können es jedoch nicht wissen, ohne die Suche durchzuführen), kann die Suche so lange dauern wie eine lineare Suche über die gesamte Hash-Tabelle (YUCK !!!). . Wenn Sie also zusätzlichen Speicherplatz sparen können, wählen Sie eine alternative Struktur, um Kollisionen zu verarbeiten.

73
Mecki

Zuerst erstelle ich eine Hash-Tabelle mit der Größe einer Primzahl, die der Anzahl der Wörter nahe kommt, die ich speichern muss, und dann benutze ich eine Hash-Funktion, um eine Adresse für jedes Wort zu finden.

...

return (hashAddress% hashTableSize);

Da die Anzahl der verschiedenen Hashes mit der Anzahl der Wörter vergleichbar ist, können Sie nicht mit viel geringeren Kollisionen rechnen.

Ich habe einen einfachen statistischen Test mit einem zufälligen Hash durchgeführt (was das Beste ist, was Sie erreichen können) und festgestellt, dass 26% die begrenzende Kollisionsrate ist, wenn Sie #words == #different hashes haben.

2