How hash tables work
See how Dictionary and HashSet find a key in O(1): hash codes, buckets, collisions, load factor and resizing, and the Equals/GetHashCode rules your own keys must follow.
Dictionary<TKey, TValue> and HashSet<T> since the C# course, and you know lookups are “instant”. Searching a list is O(n), binary search is O(log n), so how can a lookup take the same time for 10 keys and 10 million? The answer is the hash table, probably the most useful data structure in everyday programming.
The idea: compute where the key lives
Compute the key’s hash code: a number made from the key’s contents ( key.GetHashCode()). Equal keys always give the same number.Turn it into a slot number: bucket = hash % bucketCount.Store the entry in that bucket. To find the key later, compute the same bucket and look only there.
ContainsKey does not slow down as the dictionary grows.
Collisions are normal
Chaining: each bucket holds a small list of entries. A lookup goes to the bucket and compares the key with each entry in that list. .NET’s DictionaryandHashSetwork this way (the chains are stored inside one array of entries for speed).Open addressing: if the slot is taken, try the next slot (or another computed one) until a free one is found. Python’s dict and many C++ tables use this.
Load factor and resizing
count / bucketCount, the average chain length. When it gets too high, the table allocates a bigger bucket array (.NET roughly doubles it, to a prime number) and re-inserts every entry, because hash % bucketCount changes when the bucket count changes. A resize is O(n), but it happens rarely, so like List<T> growth the average cost per insert stays O(1).
| Operation | Average | Worst case |
|---|---|---|
Add, Remove | ||
ContainsKey, TryGetValue, Contains | ||
Keys must follow the equality rules
GetHashCode() to find the bucket and Equals() to compare entries inside it. The rule: if two keys are Equal, they must have the same hash code. By default a class uses reference equality: two separate objects with the same data are different keys. A record (or a struct) compares by value, and generates both methods for you.
var byClass = new Dictionary<GridPointClass, string>();byClass[new GridPointClass(2, 3)] = "Column C1";Console.WriteLine(byClass.ContainsKey(new GridPointClass(2, 3))); // a different object var byRecord = new Dictionary<GridPoint, string>();byRecord[new GridPoint(2, 3)] = "Column C1";Console.WriteLine(byRecord.ContainsKey(new GridPoint(2, 3))); // equal by value class GridPointClass{ public int X { get; } public int Y { get; } public GridPointClass(int x, int y) { X = x; Y = y; }} record GridPoint(int X, int Y);False True
HashCode.Combine mixes several fields into one good hash code:
class GridPointClass : IEquatable<GridPointClass>{ public int X { get; } public int Y { get; } public GridPointClass(int x, int y) { X = x; Y = y; } public bool Equals(GridPointClass? other) => other is not null && X == other.X && Y == other.Y; public override bool Equals(object? obj) => Equals(obj as GridPointClass); public override int GetHashCode() => HashCode.Combine(X, Y);}ints with chaining: SimpleIntSet(int bucketCount) with Add(int x) (returns false if already present), Contains(int x) and Count. Use an array of List<int> as the buckets. Adding 5, 9, 13, 2, 9 to a set with 4 buckets should give Count 4, Contains(13) true, Contains(6) false. (No resizing needed.)
Show a hint
(x.GetHashCode() & int.MaxValue) % buckets.Length. The & int.MaxValue clears the sign bit so negative numbers do not give a negative index.
Show the solution
var set = new SimpleIntSet(4);foreach (int x in new[] { 5, 9, 13, 2, 9 }) set.Add(x);Console.WriteLine(set.Count);Console.WriteLine(set.Contains(13));Console.WriteLine(set.Contains(6)); class SimpleIntSet{ private readonly List<int>[] _buckets; public int Count { get; private set; } public SimpleIntSet(int bucketCount) { _buckets = new List<int>[bucketCount]; for (int i = 0; i < bucketCount; i++) _buckets[i] = new List<int>(); } private List<int> BucketFor(int x) => _buckets[(x.GetHashCode() & int.MaxValue) % _buckets.Length]; public bool Contains(int x) => BucketFor(x).Contains(x); public bool Add(int x) { if (Contains(x)) return false; // a set holds each value once BucketFor(x).Add(x); Count++; return true; }}4 True False
Remember
A hash table turns a key into a bucket index with hash % bucketCount, so lookups do not scan.Collisions are normal; .NET chains entries that share a bucket. Average O(1) needs a good hash and a low load factor; resizing keeps the average cost O(1). Equal keys must have equal hash codes. Use records or override EqualsandGetHashCode, and never mutate a key.