📁 Sangam Dhakal - Portfolio v2.0 - Netscape Navigator 4.0
_
□
×

πŸ“š Interview question

How does a hash table work? Explain how Dictionary<TKey,TValue> in .NET stores and finds entries, handles collisions and resizes, and what GetHashCode and Equals must guarantee.

Hash table fundamentals and .NET Dictionary internals: buckets and entries, chaining, load and resizing, the GetHashCode/Equals contract, average vs worst-case cost, and hash flooding protection.

Mid-Level Theory Intermediate collections data-structures dictionary gethashcode hash-table performance

🎯 Key points

  • A hash table hashes the key, maps the hash to a bucket, then compares candidate keys with Equals; average O(1), worst case O(n) when keys collide
  • Dictionary<TKey, TValue> keeps an int[] buckets array and an Entry[] entries array; colliding entries form a chain through a next index inside the entries array (separate chaining without node objects)
  • The load factor (entries ÷ buckets) reaches 1.0 before Dictionary resizes to the next prime of about double and rehashes; open-addressing tables resize much earlier (around 0.7) because probing degrades sharply
  • Contract: equal objects must have equal hash codes, the hash must not change while the key is stored, and a good hash spreads keys evenly to keep chains short
  • Use comparers such as StringComparer.Ordinal or OrdinalIgnoreCase to define key semantics; the comparer supplies both hashing and equality
  • Avoid double lookups and allocations: TryGetValue, TryAdd, CollectionsMarshal.GetValueRefOrAddDefault, and GetAlternateLookup for span-based string lookups
  • String hashing is randomised per process to resist hash flooding; enumeration order is not guaranteed
  • Dictionary is not thread-safe for writes; concurrent writes can corrupt chains (even causing infinite loops), so use ConcurrentDictionary, a lock, or FrozenDictionary for read-only data

⏱ 30-second answer

A hash table turns a key into an integer hash, maps it to a bucket (hash % bucketCount), and stores the entry there. Lookups compute the same hash, go straight to the bucket, and compare keys with Equals. Average cost is O(1); worst case (everything in one bucket) is O(n).

.NET's Dictionary<TKey,TValue> keeps an int[] buckets and an Entry[] entries array; colliding entries form a chain via a next index inside the entries array. When full, it resizes to roughly double (a prime size) and rehashes.

Contract: if a.Equals(b) then a.GetHashCode() == b.GetHashCode(), and a key's hash must not change while it is in the dictionary. Good hashes spread keys evenly.

πŸ—£ Full answer (senior)

The idea

key "SYD" ──GetHashCode()──► 0x5A3F91C2 ──% 7──► bucket 3 ──► compare keys ──► value

Inside Dictionary<TKey, TValue>

buckets (int[], 1-based index into entries, 0 = empty)
  [0]  [1]  [2]  [3]  [4]  [5]  [6]
   0    3    0    4    0    1    0
        β”‚         β”‚         β”‚
        β–Ό         β–Ό         β–Ό
      #2 PER    #3 BNE    #0 MEL
                  β”‚ next
                  β–Ό
                #1 SYD

entries (Entry[])
  #0 { hashCode, next: -1, key: "MEL", value: .. }   ← bucket 5
  #1 { hashCode, next: -1, key: "SYD", value: .. }   ← end of bucket 3's chain
  #2 { hashCode, next: -1, key: "PER", value: .. }   ← bucket 1
  #3 { hashCode, next:  1, key: "BNE", value: .. }   ← head of bucket 3's chain
  • Add: compute the hash, find the bucket, walk the chain to check the key does not exist, write the entry into the next free slot of entries, and link it at the head of the bucket's chain.
  • Lookup: compute the hash, walk the bucket's chain comparing the stored hash code first (cheap) and only then calling Equals.
  • Remove: unlink the entry from the chain; its slot goes on a free list for reuse.
  • Resize: when entries is full (load factor 1.0), allocate the next prime of roughly double the size and rebuild the buckets from the stored hash codes.

Load factor and collisions

The load factor is entries Γ· buckets. With a good hash, chains average about one entry even at a load factor of 1.0, so lookups stay O(1). Clustering happens when the hash is poor (for example GetHashCode returning only an ID's low bits, or the same value for many keys): chains grow and lookups drift towards O(n). Open-addressing tables, which store entries in the bucket array itself and probe neighbouring slots, must resize earlier (around 0.7) because probe sequences lengthen rapidly as the table fills.

Costs

Operation Average Worst case
Add / lookup / remove O(1) O(n) (all keys collide)
Resize O(n), amortised away
Enumerate O(n), order not guaranteed

Production example

A pricing service matched 200,000 incoming quotes against 50,000 products using products.First(p => p.Sku == quote.Sku), which is O(n) per quote: billions of comparisons in the worst case and a 40-second job. Building products.ToDictionary(p => p.Sku, StringComparer.OrdinalIgnoreCase) once and using TryGetValue made each match O(1). The job ran in 150 ms.

πŸ” What the interviewer is checking

Checks one of the most important data structures and the .NET equality contract.

Green flags

  • Explains hashing to buckets, collisions and resizing.
  • States average O(1) and worst-case O(n).
  • Knows the Equals/GetHashCode rules and the mutable-key trap.
  • Mentions comparers, TryGetValue and pre-sizing.

Red flags

  • Thinks lookups are always O(1) with no caveats.
  • Overrides only Equals.

πŸ“š Learn in depth

1. The contract

Rule Why
Equal objects must have equal hash codes Otherwise lookup searches the wrong bucket and misses
Unequal objects may share a hash (collision) Allowed; Equals resolves it
Hash must not change while the key is in the dictionary The entry would be in the old bucket and become unfindable
Hash should be fast and well distributed Clustering causes long chains

Records and anonymous types generate value-based Equals/GetHashCode. For classes, override both together or use HashCode.Combine(a, b, c).

2. Comparers

  • new Dictionary<string, T>(StringComparer.OrdinalIgnoreCase) makes keys case-insensitive; the comparer supplies both hash and equality.
  • Prefer ordinal comparers for identifiers; culture-aware comparers are slower and can surprise.
  • Implement IEqualityComparer<T> for custom key semantics without changing the type.

3. Hash flooding protection

If attackers can choose keys (e.g. JSON property names, query strings), they can craft many strings with the same hash and turn O(1) into O(n), causing CPU denial of service. .NET randomises string hashing per process (Marvin hash). Dictionary<string, T> starts with a faster non-randomised comparer and switches to the randomised one if it detects too many collisions.

4. Performance tips

  • Pre-size: new Dictionary<K,V>(expectedCount) avoids rehashing.
  • Avoid double lookups: TryGetValue instead of ContainsKey + indexer; TryAdd; CollectionsMarshal.GetValueRefOrAddDefault for update-in-place counters.
  • .NET 9 GetAlternateLookup<ReadOnlySpan<char>>() looks up string keys from spans without allocating strings.
  • For read-only lookups built once at startup, FrozenDictionary optimises for reads.

5. Open addressing vs chaining

Separate chaining (.NET Dictionary) Open addressing (linear/quadratic probing)
Collisions Chain via next index Probe next slots
Deletion Easy Needs tombstones
Locality Good (entries array) Very good
Load factor Can approach 1 Must stay lower (~0.7)

6. Try it yourself

  1. Create a key class whose GetHashCode returns 1 and insert 50,000 keys; time lookups vs a proper hash.
  2. Use a mutable key, add it, change a field, and try ContainsKey.
  3. Benchmark ContainsKey + indexer vs TryGetValue with BenchmarkDotNet.

πŸ’» Code

// Build the index once, then O(1) lookups
var bySku = products.ToDictionary(p => p.Sku, StringComparer.OrdinalIgnoreCase);

foreach (var quote in quotes)
{
    if (bySku.TryGetValue(quote.Sku, out var product))   // one lookup, not ContainsKey + indexer
        Match(quote, product);
}

// Counting without double lookups
var counts = new Dictionary<string, int>(StringComparer.Ordinal);
foreach (var word in words)
{
    ref int count = ref CollectionsMarshal.GetValueRefOrAddDefault(counts, word, out _);
    count++;
}
// Correct custom key: immutable, Equals and GetHashCode consistent
public readonly record struct RouteKey(string Origin, string Destination, DateOnly Date);
// records generate value equality and a combined hash

// Hand-written equivalent for a class
public sealed class FlightKey(string origin, string destination) : IEquatable<FlightKey>
{
    public string Origin { get; } = origin;
    public string Destination { get; } = destination;

    public bool Equals(FlightKey? other) =>
        other is not null &&
        string.Equals(Origin, other.Origin, StringComparison.Ordinal) &&
        string.Equals(Destination, other.Destination, StringComparison.Ordinal);

    public override bool Equals(object? obj) => Equals(obj as FlightKey);
    public override int GetHashCode() => HashCode.Combine(Origin, Destination);
}

⚠ Common mistakes

  • Mistake: Searching a list with First or Where inside a loop when a dictionary would do. Why it is wrong: Each search is O(n), so the loop becomes O(n Γ— m). Instead: Build a dictionary (or ToLookup for many values per key) once and use TryGetValue.

  • Mistake: Overriding Equals without GetHashCode (or the reverse). Why it is wrong: Two equal keys produce different hashes, land in different buckets, and lookups silently miss or duplicates are added. No exception tells you. Instead: Override both consistently, or use records and HashCode.Combine.

  • Mistake: Using mutable objects as keys and changing them after insertion. Why it is wrong: The entry stays in the bucket for its old hash, so it becomes unreachable but still uses memory. Instead: Use immutable keys (records, readonly structs, strings, IDs).

  • Mistake: Calling ContainsKey then the indexer. Why it is wrong: It hashes and searches twice. Instead: Use TryGetValue, TryAdd or GetValueRefOrAddDefault.

  • Mistake: Writing to a shared Dictionary from several threads. Why it is wrong: A concurrent resize or insert can corrupt the chains, causing lost entries, exceptions or a thread spinning forever in a cyclic chain. Instead: Use ConcurrentDictionary<TKey, TValue>, a lock, or build once and publish a FrozenDictionary.

  • Mistake: Relying on dictionary enumeration order. Why it is wrong: It is unspecified and changes after removals reuse slots. Instead: Sort explicitly, or use SortedDictionary or OrderedDictionary<TKey, TValue> when order matters.

πŸ› Senior insight: trade-offs

A hash table trades memory for time: it gives O(1) average lookups as long as the hash function and the key contract hold. Most production bugs come from the contract (mutable keys, inconsistent Equals/GetHashCode, culture-sensitive string comparison) or from unsafe sharing between threads, not from the structure itself.

Situation Choice Reason
Single-threaded or request-scoped Dictionary<TKey, TValue> Fastest general-purpose map
Shared, many writers ConcurrentDictionary<TKey, TValue> Fine-grained locking, lock-free reads
Built once at startup, read constantly FrozenDictionary<TKey, TValue> Slower to build, faster lookups, safe to share
Snapshots that change occasionally ImmutableDictionary swapped with Interlocked.Exchange Readers never see partial updates
Attacker-controlled string keys Default string comparer (randomised) Resists hash flooding

I also think about who controls the keys. With attacker-controlled keys, worst-case behaviour becomes a denial-of-service risk, which is why .NET randomises string hashes. For hot paths I avoid double lookups, pre-size, choose ordinal comparers, and measure. When an O(n) scan shows up in a profile inside a loop, a dictionary is usually the cheapest big win available.

πŸ”— Follow-ups the interviewer may ask

  1. When: Candidate mentions the GetHashCode/Equals contract Why: Probes debugging of broken keys and concurrency
    How to answer

    Likely causes, in order:

    1. Poor or constant GetHashCode (e.g. returns 0, or hashes only a low-variety field): all keys collide, chains grow, lookups become O(n), so it gets slower as it grows.
    2. Mutable keys: a field used in the hash changes after insertion, so the entry sits in the old bucket and lookups miss.
    3. Equals/GetHashCode inconsistency: equal keys produce different hashes (e.g. case-insensitive Equals with case-sensitive hash).
    4. Concurrent writes to Dictionary from multiple threads, which can corrupt the internal arrays, lose entries, or even cause infinite loops.

    Debug by measuring hash distribution, checking the key type's Equals/GetHashCode and mutability, and looking for shared dictionaries without locks. Fix with immutable keys (records), consistent comparers, and ConcurrentDictionary or proper locking.

  2. When: Candidate mentions other dictionary types Why: Checks map variants and choice
    How to answer

    A map (associative array, dictionary) stores key β†’ value pairs with unique keys. Choose the implementation by access pattern:

    • Dictionary<K,V>: hash table, O(1) average, unordered. The default.
    • SortedDictionary<K,V>: red-black tree, O(log n), sorted by key, fast inserts/removes.
    • SortedList<K,V>: two sorted arrays, O(log n) lookup but O(n) insert/remove; compact, indexable. Good for small or build-once sorted maps.
    • OrderedDictionary<K,V> (.NET 9+): keeps insertion order with index access.
    • ConcurrentDictionary<K,V>: thread-safe, lock-free reads.
    • FrozenDictionary<K,V> (.NET 8+): built once, fastest reads.
    • ImmutableDictionary<K,V>: immutable snapshots with cheap modified copies.
    • ILookup<K,V> / Dictionary<K, List<V>>: multi-map (one key, many values).
  3. When: Candidate mentions HashSet Why: Checks set operations
    How to answer

    A set stores unique items and answers "is X in it?" quickly.

    • HashSet<T>: hash table without values. Add, Remove, Contains are O(1) average. No ordering.
    • SortedSet<T>: red-black tree. O(log n) operations, items kept sorted, supports Min, Max and GetViewBetween ranges.
    • FrozenSet<T> (.NET 8+): built once, optimised for fast reads; immutable.
    • ImmutableHashSet<T>: immutable with cheap "modified copies", for snapshot-style sharing.

    Set algebra: UnionWith, IntersectWith, ExceptWith, SymmetricExceptWith, IsSubsetOf, Overlaps, SetEquals. The biggest everyday win is replacing List.Contains (O(n)) in loops with HashSet.Contains (O(1)).

πŸ›  Real-world problems: how you handle them

40-second quote matching job reduced to 150 ms
Situation
[Pricing batch] on .NET [10] matching [200,000] supplier quotes to [50,000] products every [15 minutes].
Signals
Job took [40 s] and CPU pegged a core; it occasionally overlapped the next run.
Investigation
Profiler showed most time in `Enumerable.First` with a SKU comparison lambda inside the quote loop: O(n × m).
Resolution
Built a `Dictionary<string, Product>` with `StringComparer.OrdinalIgnoreCase` once per run and used `TryGetValue`.
Prevention
Code review checklist item for lookups inside loops; performance test with production-sized data in CI.

How to tell it (STAR)

**S:** Our [pricing batch] took [40 seconds] and sometimes overlapped the next run. **T:** I was asked to make it reliable. **A:** The profiler showed a linear search for each quote across [50,000] products. I built a case-insensitive dictionary keyed by SKU once per run and used `TryGetValue`. **R:** The job dropped to [150 ms], CPU usage became negligible, and we added production-sized performance tests to catch similar regressions.

↩ Usually asked after

More in Data Structures

Last updated 23 Oct 2026.

🌐 Sangam's Webring

βͺ Previous 🏠 Main Next ⏩
πŸ’¬ Chat with me