Hashcodes und Hashfunktionen
Kurz gesagt: Ein Hashcode ist eine Zahl, die GetHashCode() aus dem Schlüssel berechnet; Dictionary wählt damit den Bucket, in dem es sucht, und findet so einen Schlüssel auch unter einer Million schnell.
Problem
Der Platz wurde zweimal verkauft
Die Theaterkasse hält die verkauften Plätze in einer Map: Platz → Name des Zuschauers. Ein Platz ist eine Klasse mit Reihe und Nummer:
class Seat{ public int Row { get; set; } public int Number { get; set; }
public Seat(int row, int number) { Row = row; Number = number; }}Dictionary<Seat, string> bookings = new Dictionary<Seat, string>();bookings[new Seat(5, 12)] = "Maria";
Console.WriteLine(bookings.ContainsKey(new Seat(5, 12)));Console.WriteLine(bookings.Count);bookings[new Seat(5, 12)] = "Jonas";Console.WriteLine(bookings.Count);False12Maria hat Platz 5/12 gekauft, aber ContainsKey mit demselben Platz sagt False. Dann kauft Jonas denselben Platz, und die Map hat jetzt zwei Schlüssel 5/12. Warum?
Die Idee
Hashcode: eine Zahl aus dem Schlüssel
Ein Dictionary vergleicht den Schlüssel nicht mit allen anderen. Es berechnet aus dem Schlüssel eine ganze Zahl und weiß daraus, wo es suchen muss. Diese Zahl heißt Hashcode, und die Methode GetHashCode() gibt sie zurück. Jedes Objekt hat sie, denn sie steht in object.
Console.WriteLine("Maria".GetHashCode() == "Maria".GetHashCode());Console.WriteLine("Maria".GetHashCode() == "Marie".GetHashCode());Console.WriteLine("Maria".GetHashCode() == "maria".GetHashCode());Console.WriteLine(1004.GetHashCode());TrueFalseFalse1004- Innerhalb eines Laufs gibt derselbe Text dieselbe Zahl.
- Eine kleine Änderung gibt eine andere Zahl: „Maria“, „Marie“ und „maria“ sind drei verschiedene Hashcodes.
- Der Hashcode eines
intist die Zahl selbst. - Ein Hashcode ist schnell berechnet, egal wie viele Schlüssel im Dictionary sind.
Die Zahl selbst wird für Text hier absichtlich nicht ausgegeben. .NET berechnet den Hashcode eines string bei jedem Lauf des Programms anders, du würdest also eine andere bekommen. Die Vergleiche kommen jedes Mal gleich heraus.
Die Methode, die die Zahl macht, heißt Hashfunktion.
Genauer, für Neugierige
- Warum ändert sich der Hashcode von Text? Wäre er im Voraus bekannt, könnte jemand einem Server Tausende Schlüssel mit demselben Hashcode schicken und das Dictionary langsam machen. Deshalb wählt .NET bei jedem Lauf einen zufälligen Startpunkt für die Berechnung. Der Hashcode von Text wird nicht in eine Datei oder Datenbank geschrieben und nicht an ein anderes Programm geschickt: Dort ist er anders.
- Kryptografische Hashfunktionen wie SHA-256 sind etwas anderes. Sie geben ein langes Ergebnis, und es ist praktisch unmöglich, zwei Texte mit demselben Hash zu finden. Deshalb prüft man mit ihnen, ob eine Datei nicht verändert wurde, und speichert damit Passwörter.
GetHashCode()ist für schnelles Suchen da und gibt keinen solchen Schutz.
Sieh es dir an
Die Buckets
Innen ist ein Dictionary ein Array von Buckets. Die Nummer des Buckets für einen Schlüssel ist der Rest des Hashs bei der Division durch die Anzahl der Buckets. Der Hashcode von Text ändert sich bei jedem Lauf, deshalb sind die Schlüssel hier Bestellnummern: Bei ihnen ist der Hashcode die Zahl selbst:
int[] keys = { 1007, 2043, 3010, 4051 };int buckets = 8;foreach (int key in keys){ int hash = key.GetHashCode(); int index = (hash % buckets + buckets) % buckets; Console.WriteLine(key + " -> " + hash + " -> Bucket " + index);}Schritt für Schritt, in Worten
- Vier Schlüssel (Bestellnummern) und acht leere Buckets, nummeriert von 0 bis 7.
- 1007.GetHashCode() ist die Zahl selbst, 1007. Der Rest bei der Division durch 8 ist 7: Das ist der Bucket.
- 2043 ergibt Bucket 3.
- 3010 ergibt Bucket 2.
- 4051 landet auch in Bucket 3: eine Kollision. Die beiden Schlüssel sind in einem Bucket: 4051 hängt wie in einer Kette hinter 2043.
- Suche nach 4051: dieselbe Zahl, derselbe Bucket 3. Equals mit 2043: nein. Mit 4051: ja.
- Das Schreiben
bookings[key] = …berechnet den Bucket und legt das Paar dort ab. Die Suche (ContainsKey,TryGetValue,[key]) berechnet denselben Bucket und sucht nur darin. Deshalb ist sie auch mit einer Million Schlüssel schnell. %kann in C# wie in Java eine negative Zahl geben:-7 % 8ist -7. Deshalb steht da(hash % buckets + buckets) % buckets: So ist der Rest nie negativ. Ein Hashcode kann negativ sein, eine Bucket-Nummer nicht.- 2043 und 4051 sind in Bucket 3 gelandet. Das heißt Kollision. Darum geht es in der nächsten Karte.
Genauer, für Neugierige
- Das echte
Dictionarymacht aus dem Hashcode eine vorzeichenlose Zahl (uint), die nie negativ ist, und nimmt den Rest der Division durch die Anzahl der Buckets. Diese Zahl ist eine Primzahl (3, 7, 17, 37…), die Zahlen sind dort also andere, aber das Prinzip ist dasselbe.
Console.WriteLine(-7 % 8 + " " + (-7 % 8 + 8) % 8);-7 1Probier es aus
Welcher Bucket?
Der Hashcode eines int ist die Zahl selbst. Die Bestellnummern kommen in eine Map mit 4 Buckets.
Was gibt das Programm aus?
int[] orderIds = { 6, 10, 13, 21 };foreach (int id in orderIds){ Console.WriteLine(id + " -> Bucket " + id.GetHashCode() % 4);}Die Reste bei der Division durch 4: 6 und 10 geben 2, 13 und 21 geben 1. Vier Schlüssel, zwei Kollisionen, zwei leere Buckets. Mit mehr Buckets verteilen sich die Schlüssel gleichmäßiger.
Die Idee
Kollision: GetHashCode findet den Bucket, Equals findet den Schlüssel
Verschiedene Schlüssel können denselben Hashcode haben. Der Hashcode eines long ist das exklusive „Oder“ (XOR) seiner oberen und unteren Hälfte, jede mit 32 Bit. In -1 sind alle Bits 1, die beiden Hälften heben sich auf, und es bleibt 0, genau wie für 0 selbst:
Console.WriteLine(0L.GetHashCode() + " " + (-1L).GetHashCode());
Dictionary<long, string> codes = new Dictionary<long, string>();codes[0L] = "Null";codes[-1L] = "Minus eins";Console.WriteLine(codes[0L] + " " + codes[-1L]);0 0Null Minus einsDie beiden Schlüssel sind in einem Bucket, aber das Dictionary verwechselt sie nicht. Der Bucket hält eine Kette von Paaren. Die Suche geht sie entlang und vergleicht mit Equals, bis sie den Schlüssel findet.
Die zwei Schritte arbeiten also so:
GetHashCode()sagt, in welchem Bucket gesucht wird.Equals()sagt, welcher der Schlüssel im Bucket der gesuchte ist.
Jetzt ist klar, was mit dem Theaterplatz passiert ist. Seat überschreibt keine der beiden Methoden, also nutzt es die Versionen aus object. Dort vergleicht Equals Referenzen, und GetHashCode ist meist für jedes Objekt anders. Die beiden Objekte new Seat(5, 12) sind zwei verschiedene Objekte, also auch zwei verschiedene Schlüssel.
Die Idee
Der Vertrag: Equals und GetHashCode zusammen
Seat muss sagen, wann zwei Plätze derselbe Platz sind, und gleichen Plätzen denselben Hashcode geben:
class Seat{ public int Row { get; set; } public int Number { get; set; }
public Seat(int row, int number) { Row = row; Number = number; }
public override bool Equals(object other) { if (other is not Seat s) { return false; } return Row == s.Row && Number == s.Number; }
public override int GetHashCode() { return 31 * Row + Number; }}Mit demselben Code in Main wie in der ersten Karte:
Dictionary<Seat, string> bookings = new Dictionary<Seat, string>();bookings[new Seat(5, 12)] = "Maria";
Console.WriteLine(bookings.ContainsKey(new Seat(5, 12)));Console.WriteLine(bookings.Count);bookings[new Seat(5, 12)] = "Jonas";Console.WriteLine(bookings.Count);True11Die Regeln, die C# erwartet:
- Wenn
a.Equals(b), danna.GetHashCode() == b.GetHashCode(). Immer. - Wenn die Hashcodes gleich sind, können die Objekte verschieden sein. Das ist eine Kollision.
- Wenn du
Equalsüberschreibst, überschreib auchGetHashCode, mit denselben Eigenschaften.
Überschreibst du nur Equals, warnt dich der Compiler, aber das Programm kompiliert:
class Seat{ public int Row { get; set; } public int Number { get; set; }
public Seat(int row, int number) { Row = row; Number = number; }
public override bool Equals(object other) { if (other is not Seat s) { return false; } return Row == s.Row && Number == s.Number; }}warning CS0659: 'Seat' overrides Object.Equals(object o) but does not override Object.GetHashCode()Mit diesem Seat gibt das erste Programm wieder False, 1 und 2 aus: Der Platz ist zweimal verkauft. Die Warnung hält den Compiler nicht an, also lies sie.
Genauer, für Neugierige
- Warum 31? Es ist eine ungerade Primzahl, und sie mischt gut. Die Multiplikation mit 31 macht den Hash von der Reihenfolge abhängig: Platz 5/12 und Platz 12/5 haben verschiedene Hashcodes. Mit
Row + Numberwürden beide im selben Bucket landen. - Statt der Formel kannst du
HashCode.Combineschreiben. Es berechnet einen Hashcode aus den Werten, die du ihm gibst:
public override int GetHashCode(){ return HashCode.Combine(Row, Number);}- Die Zahl aus
HashCode.Combineändert sich ebenfalls bei jedem Lauf, wie der Hashcode von Text. Für das Dictionary spielt das keine Rolle: Innerhalb eines Laufs haben gleiche Plätze gleiche Hashcodes. Equalsbekommt einobject, deshalb prüft es zuerst, ob das andere überhaupt einSeatist.other is not Seat sist die Typprüfung aus dem Modul „Vererbung und Polymorphie“: Ist das andere einSeat, dann istsdieses Objekt, schon vom TypSeat. Deshalb istnew Seat(5, 12).Equals("5/12")gleichFalse.- In C# hat eine Klasse, die ein Schlüssel wird, meist auch
IEquatable<Seat>: eine MethodeEquals(Seat other), ohne Umwandlung ausobject.DictionaryundHashSetrufen sie auf, wenn sie da ist, undEquals(object)ruft sie nur auf. Die Eigenschaften sind hier nur lesbar. Warum, siehst du in der Karte „Die Buckets füllen sich“:
class Seat : IEquatable<Seat>{ public int Row { get; } public int Number { get; }
public Seat(int row, int number) { Row = row; Number = number; }
public bool Equals(Seat other) { return other is not null && Row == other.Row && Number == other.Number; }
public override bool Equals(object other) { return Equals(other as Seat); }
public override int GetHashCode() { return HashCode.Combine(Row, Number); }}- Im Modul „Modernes C#“ lernst du
recordkennen. Mit einer Zeile,record Seat(int Row, int Number);, sindEqualsundGetHashCodeaus den Eigenschaften fertig, und das erste Programm gibtTrue,1und1aus.
Probier es aus
Finde den Fehler
HashSet. Zwei Produkte mit demselben Code sind dasselbe Produkt, auch wenn sich der Preis geändert hat. Aber Contains mit dem neuen Preis gibt False zurück:HashSet<Product> catalog = new HashSet<Product>();catalog.Add(new Product("A-100", 25.0));// Der Preis ist gesunken, aber es ist dasselbe Produkt.Console.WriteLine(catalog.Contains(new Product("A-100", 19.9)));FalseIn welcher Zeile der Klasse steckt der Fehler?
Wähle die Zeile mit dem Fehler.
Equals vergleicht nur den Code, aber GetHashCode nutzt auch den Preis. Die beiden Produkte sind laut Equals gleich, haben aber verschiedene Hashcodes. Das bricht den Vertrag. Ein HashSet merkt sich den Hashcode jedes Elements und vergleicht zuerst ihn, deshalb kommt es gar nicht bis zu Equals. GetHashCode muss dieselben Eigenschaften nutzen wie Equals: return Code.GetHashCode();. Dann gibt Contains True zurück.Die Idee
Die Buckets füllen sich
Bei vielen Paaren werden die Buckets lang, und die Suche wird langsamer. Deshalb wächst ein Dictionary:
- Am Anfang gibt es keine Buckets. Das erste Hinzufügen legt 3 an.
- Wenn sich die Plätze füllen, legt es mindestens doppelt so viele Buckets an (wieder eine Primzahl) und verteilt alle Schlüssel neu. Die Bucket-Nummer hängt von der Anzahl der Buckets ab, deshalb wird jeder Schlüssel neu berechnet. Hier siehst du, wann es wächst:
Dictionary<string, int> prices = new Dictionary<string, int>();int capacity = prices.EnsureCapacity(0);for (int i = 1; i <= 20; i++){ prices["Produkt " + i] = i; if (prices.EnsureCapacity(0) != capacity) { capacity = prices.EnsureCapacity(0); Console.WriteLine("Wächst bei Paar " + i + ": " + capacity + " Plätze"); }}Wächst bei Paar 1: 3 PlätzeWächst bei Paar 4: 7 PlätzeWächst bei Paar 8: 17 PlätzeWächst bei Paar 18: 37 PlätzeEnsureCapacity(0)gibt zurück, wie viele Plätze das Dictionary hat, ohne etwas zu ändern. Hier dient es nur dazu, zu sehen, wann sich die Tabelle ändert. In einemDictionarygibt es so viele Plätze wie Buckets.- Deshalb sind Hinzufügen und Suchen im Durchschnitt schnell, aber ab und zu dauert ein Hinzufügen länger.
Es gibt auch eine Falle. Ein Schlüssel darf sich nicht ändern, während er in einer Map oder einem Set liegt:
Seat seat = new Seat(5, 12);HashSet<Seat> sold = new HashSet<Seat>();sold.Add(seat);
seat.Number = 13;Console.WriteLine(sold.Contains(seat));Console.WriteLine(sold.Contains(new Seat(5, 12)));Console.WriteLine(sold.Count);FalseFalse1Contains(seat) sucht im Bucket für 5/13 und findet ihn nicht. Contains(new Seat(5, 12)) sucht im richtigen Bucket, aber das Objekt dort ist nicht mehr gleich 5/12. Das Element ist im Set (die Größe ist 1), aber es kann nicht gefunden werden. Deshalb ist es eine gute Idee, dass die Eigenschaften eines Schlüssels nur lesbar sind: public int Number { get; }.Genauer, für Neugierige
- Wie viele Plätze es am Anfang gibt, kannst du im Konstruktor setzen:
new Dictionary<string, int>(64). Das Dictionary nimmt eine Primzahl, die nicht kleiner ist (hier 71), und muss mit 64 Schlüsseln unterwegs nicht wachsen. Wann es wächst (wenn sich die Plätze füllen), lässt sich nicht einstellen. - In .NET ist ein Bucket immer eine Kette: Einen Baum wie in Java gibt es nicht. Gegen viele absichtliche Kollisionen mit Text schützt es, dass der Hashcode von Text bei jedem Lauf anders ist.
Dictionary<string, int> prices = new Dictionary<string, int>(64);Console.WriteLine(prices.EnsureCapacity(0));for (int i = 1; i <= 64; i++){ prices["Produkt " + i] = i;}Console.WriteLine(prices.EnsureCapacity(0));7171Übung
Eigene Hashcodes
Löse die Aufgaben in deinem Editor.
1. Ein Hashcode für eine Person. Eine Klasse Person mit Vorname, Nachname und Alter. Überschreib Equals und GetHashCode. Nimm für GetHashCode das Rezept: Beginne bei 17, und für jedes Feld result = 31 * result + der Hash des Feldes. Für ein int-Feld ist der Hash die Zahl selbst. Leg Jonas Weber mit 30 zweimal an und einmal mit 31. Gib aus, ob die Hashcodes der ersten beiden gleich sind, ob die Hashcodes der ersten und der dritten gleich sind, und wie viele verschiedene Personen in einem HashSet bleiben. Gib die Hashcodes selbst nicht aus: Bei Text ändern sie sich bei jedem Lauf.
TrueFalseVerschiedene Personen: 22. Der Roboter im Lager. Der Roboter startet in Zelle (0, 0) und macht die Schritte NNEESWWN: N ist nach oben (y + 1), S nach unten, E nach rechts (x + 1), W nach links. Wie viele verschiedene Zellen hat er besucht, die Startzelle mitgezählt? Halte sie in einem HashSet<Point>. Mit dieser Klasse sagt das Programm 9, es sollte aber 7 sagen. Repariere es:
class Point{ public int X { get; set; } public int Y { get; set; }
public Point(int x, int y) { X = x; Y = y; }}Schritte: 8Verschiedene Zellen: 73. Ein Hash einer Datei. Eine Methode HashFileContent(string filePath) liest die Datei und gibt den Hash ihres Inhalts als hexadezimalen Text zurück. GetHashCode() taugt hier nicht: Beim nächsten Lauf hätte dieselbe Datei einen anderen Hashcode. Nimm SHA-256 aus System.Security.Cryptography: Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(content))) (Encoding steht in System.Text). Eine Datei schreibst du mit File.WriteAllText(path, text) und liest sie mit File.ReadAllText(path) (in System.IO). Schreib drei Berichte: a.txt und b.txt mit „Bericht für Oktober: 1250 €“ und c.txt mit „Bericht für Oktober: 1260 €“. Vergleiche ihre Hashes. Verschiedene Hashes heißen verschiedene Dateien, und gleiche SHA-256-Hashes heißen in der Praxis gleiche Dateien.
a.txt: db6cf1fbd452ee116b95c9ed5d604e3b2b93d34a0e660991e991e1d84f43898fb.txt: db6cf1fbd452ee116b95c9ed5d604e3b2b93d34a0e660991e991e1d84f43898fc.txt: 7fad428479b6c5af07d54dcdf9665ded486847b65ef67aec7681a24e450d4902a und b: gleicha und c: verschiedenWeitere Aufgaben
- Eine eigene Hashtabelle. Eine Fortsetzung der Benutzertabelle aus der letzten Lektion, diesmal ohne
Dictionary. Leg ein Array aus 8 Buckets an, jeder eineListaus Paaren von Benutzername und E-Mail. Die Hashfunktion ist die Summe der Zeichencodes im Namen. SchreibAdd,Getund eine Methode, die ausgibt, wie viele Paare in jedem Bucket sind. - Eine ID aus einer E-Mail.
GenerateUserId(string email)gibt"U"und die ersten 8 Zeichen des SHA-256 der kleingeschriebenen E-Mail zurück. Ist die ID fürJonas@Example.comundjonas@example.comdieselbe? Warum taugtGetHashCode()hier nicht? Können zwei verschiedene E-Mails dieselbe ID bekommen? Wie würdest du es herausfinden? - Kollisionen zählen. Berechne den Bucket der Schlüssel
user1…user1000mitGetHashCode()bei 16, 64 und 1024 Buckets und zähle, wie viele Schlüssel in einem Bucket landen, in dem schon ein Schlüssel ist. Starte das Programm zweimal: Die Zahl ändert sich ein wenig, weil sich der Hashcode von Text ändert. - Der Schuhkarton. Eine Klasse
Boxmit Breite, Höhe und Tiefe. Zwei Kartons sind gleich, wenn ihre Maße gleich sind, in derselben Reihenfolge. SchreibEqualsundGetHashCodemitHashCode.Combineund prüfe die Kartons 30×20×10 und 10×20×30 in einemHashSet.
Test
Teste dich
Verwandte Lektionen
Abschnitt „Verwandte Lektionen“- Baut auf: try, catch und finally · Listen · Stack, Queue und verkettete Liste · Dictionary und HashSet
- Geht weiter in: record, switch und Muster · Hashtabelle · Passwörter · Abhängigkeiten: fremder Code, den dein Code ausführt
- Aufgaben zum Thema: Collections