Hash Tables
9 min readHash Tables
TL;DR
A hash table turns a key into an array index by hashing it, which is why lookup is O(1) ā you compute the address rather than search for it. Everything interesting follows from what happens when two keys compute the same address. The interview questions cluster around three things: how collisions are resolved, why the average case degrades to O(n) and who can force that, and the contract between GetHashCode and Equals that, when broken, produces the single nastiest class of bug in .NET collections ā an object you can put in but never get back out.
How it works
The mechanism
- Compute
hash = key.GetHashCode(). - Map it into the table:
bucket = hash % capacity(real implementations use a prime modulus or a mask over a power-of-two capacity). - Handle collisions ā two distinct keys landing in the same bucket.
- Verify with
Equals, because equal hashes do not mean equal keys.
Step 4 is the one people forget: the hash narrows the search to a bucket, and Equals confirms the actual match. A hash table is therefore only as correct as the relationship between those two methods.
Collision resolution
Separate chaining ā each bucket holds a list of entries. Simple, degrades gracefully, and is what .NET's Dictionary<K,V> uses (an entries array with next-index links rather than actual linked-list nodes, which keeps it cache-friendly). Lookup cost is O(1 + chain length).
Open addressing ā on collision, probe for another free slot (linear probing, quadratic probing, or double hashing). Better locality since everything is in one array, but suffers clustering and needs tombstones on delete. Load factor matters far more here.
Load factor and resizing
The load factor is entries divided by capacity. As it approaches 1, collisions become common and lookups tend toward O(n). Implementations resize ā allocate a bigger table and rehash every entry ā when it crosses a threshold. That resize is O(n), so a dictionary built by n insertions costs O(n) total but with periodic O(n) spikes. Pre-sizing with new Dictionary<K,V>(expectedCount) removes all of them.
The GetHashCode / Equals contract
public sealed class OrderKey
{
public string Region { get; init; } = "";
public int Number { get; init; }
public override bool Equals(object? obj) =>
obj is OrderKey other &&
Region == other.Region &&
Number == other.Number;
// MUST use exactly the fields Equals uses, and no others.
public override int GetHashCode() => HashCode.Combine(Region, Number);
}
The rules, in the order they bite:
- Equal objects must have equal hash codes. Break this and the dictionary looks in the wrong bucket ā you insert an item and a lookup with an equal key reports "not found".
- Unequal objects may share a hash code (collision). Legal, just slower.
- The hash must not change while the object is a key. Mutating a field used by
GetHashCodeafter insertion leaves the entry stranded in the old bucket: it is in the dictionary, it is not findable, and it is not removable. Use immutable keys ārecordtypes orinit-only properties.
record gives you value equality and a matching GetHashCode for free, which is why records make excellent dictionary keys.
Hash flooding ā the security angle
If an attacker can choose keys and predict your hash function, they can craft thousands of keys that all collide, turning every O(1) lookup into an O(n) scan of one enormous bucket. Post a form with 10,000 colliding field names and the parser does 10āø comparisons ā a CPU denial-of-service from a single small request. This is why .NET randomises string hashing per process by default, so an attacker cannot compute colliding keys offline. It is also why GetHashCode values must never be persisted or sent across processes: they legitimately differ between runs.
Concurrency
Dictionary<K,V> is not thread-safe, and concurrent writes do not merely lose data ā they can corrupt the internal bucket links and hang a reader in an infinite loop. ConcurrentDictionary<K,V> uses fine-grained striped locking for writes and lock-free reads. Note that GetOrAdd's factory delegate may run more than once under contention while only one result wins, so the factory must be side-effect-free or idempotent.
A: It is O(1) on average because the hash function computes an array index directly, so finding the bucket is arithmetic rather than search, and if keys are well distributed each bucket holds a small constant number of entries. It becomes O(n) when all keys collide into the same bucket, because the lookup then degenerates into a linear scan of that bucket's chain. Collisions cluster like that when the hash function is poor, when the key distribution is pathological, or when an attacker deliberately crafts colliding keys ā so the average case depends on an assumption about the input that is not always safe to make.
A: Objects that are equal must return the same hash code; the reverse need not hold, since unequal objects may collide. If you override Equals without overriding GetHashCode, two equal objects can hash to different buckets, so you insert an item and a lookup with an equal key searches the wrong bucket and reports not found ā the item is in the dictionary and unreachable. The third rule is that the hash must not change while the object is in use as a key: mutating a field that GetHashCode reads strands the entry in its old bucket, where it can be neither found nor removed. That is why immutable keys, such as records or init-only types, are the safe default.
A: The load factor is the ratio of stored entries to bucket capacity, and it measures how crowded the table is. As it approaches one, collisions become common and the average chain length grows, so lookups drift from O(1) toward O(n). Implementations therefore resize when the load factor crosses a threshold ā allocating a larger bucket array and rehashing every existing entry, since bucket assignment depends on capacity. That rehash is O(n), which makes individual insertions occasionally expensive even though n insertions cost O(n) overall. Passing an expected capacity to the constructor avoids every intermediate resize.
A: It is a denial-of-service attack where the attacker submits many keys deliberately chosen to hash to the same bucket, collapsing the table's O(1) behaviour into O(n) per lookup ā so n insertions become O(n²) and a single modest request can burn enormous CPU. It historically hit web frameworks through form fields, query strings, and JSON properties, which are all parsed into dictionaries with attacker-controlled keys. .NET defends by randomising string hash codes per process, so the attacker cannot precompute a colliding set offline. A direct consequence is that GetHashCode values are not stable across processes or runs and must never be persisted or transmitted.
A: Whenever you need ordering ā sorted iteration, minimum or maximum, range queries, or "the next key after this one" ā because hashing deliberately destroys any relationship between key value and position, so those operations require a full scan and a sort. You should also avoid it when the worst case matters more than the average, such as under a hard tail-latency budget or with attacker-controlled keys, where a balanced tree's guaranteed O(log n) is worth more than a hash's average O(1). Finally it is a poor fit for very small collections, where a linear scan of an array is faster in practice and allocates nothing, and where hashing overhead exceeds the search it replaces.
A: Because its operations are not atomic ā an insert may need to write an entry, update a bucket's head index, and possibly resize and rehash the whole table, and a concurrent reader can observe that sequence half-applied. The consequences go beyond a lost update: a reader can follow a partially-updated chain of next-index links and loop forever, pinning a CPU core, and concurrent writers can produce duplicated or lost entries and an internally inconsistent table. The fix is ConcurrentDictionary<K,V>, which uses lock-free reads and striped locks for writes; note that its GetOrAdd factory can execute more than once under contention even though only one result is stored, so that factory must be idempotent.
A: With separate chaining, each bucket points at a collection of entries that hashed there, so collisions extend a chain and the table tolerates load factors above one, with deletion being a simple unlink. With open addressing, every entry lives in the main array and a collision probes for another free slot by linear, quadratic, or double hashing, which gives much better cache locality since there is no indirection, but performance collapses as the load factor approaches one, and deletion requires tombstone markers so probe sequences are not broken. .NET's Dictionary uses a chaining variant implemented over flat arrays with index links, which captures most of open addressing's locality benefit while keeping chaining's graceful degradation.
A: It must distribute keys uniformly across the table so that no bucket attracts a disproportionate share, it must use every field that participates in equality so that equal objects agree and unequal ones separate, it should be fast because it runs on every lookup, and it should avalanche ā a one-bit change in the input should change roughly half the output bits, so structured inputs like sequential IDs or common prefixes do not cluster. In .NET you get all of this from HashCode.Combine, which applies a proper mixing function; hand-rolled alternatives such as XOR-ing fields are a classic mistake, because XOR is commutative so swapping two field values yields an identical hash.
Key takeaways
- Hash narrows to a bucket;
Equalsconfirms the match. Correctness depends on both agreeing. - Equal ā equal hash codes. Break it and items become unfindable and unremovable.
- Never mutate a field that
GetHashCodereads while the object is a key ā use records or init-only types. - Load factor drives resizes, and each resize rehashes everything. Pre-size when you know the count.
- Hash flooding turns O(1) into O(n) deliberately; .NET randomises string hashing, so hash codes are never stable across processes.
- No ordering, no range queries, no worst-case guarantee ā those are what you paid for O(1).
Dictionaryunder concurrent writes does not just lose data; it can corrupt links and hang a reader.