A pipeline emits 500K event IDs and must drop repeats. A hash set answers one question directly: is this element already present? IEqualityComparer<T>.GetHashCode selects a home bucket or cell. The collision strategy finds the candidates, and IEqualityComparer<T>.Equals identifies the matching member.

Elements are unique according to the set’s comparer. The structure is effectively a hash map without value slots, using the same hashing and collision mechanics. A second Add is rejected when it compares equal to an existing member. For example, HashSet<string>(StringComparer.OrdinalIgnoreCase) treats "dotnet" and "DOTNET" as one member. The set retains comparer-distinct values and discards insertion order, occurrence counts, and associated data.

Core shape: element → comparer.GetHashCode → home bucket/cell → bucket chain or probe sequence → comparer.Equals candidate already there? reject : store

Visualization

Representation and the Uniqueness Contract

The physical layout is a hash table, identical to a hash map with the value slot removed: a bucket or cell array whose length is a prime (or a power of two, depending on the runtime), a comparer whose GetHashCode maps each element to a home position, and a collision-resolution scheme — separate chaining (a linked list or slot chain per bucket) or open addressing (probing to the next free slot). A load factor (elements ÷ buckets or cells) helps keep the expected collision path short; crossing its threshold triggers a resize, allocating a larger array and rehashing every element into new home positions.

Those hashing mechanics live in HashMap and are not re-derived here. What is specific to a set is a single decision made on every Add: after comparer.GetHashCode selects a home bucket or cell, the operation follows the bucket chain or probe sequence and calls comparer.Equals on each candidate. If a comparer-equal element is found, the add is a no-op and the collection is unchanged; only a collision-path miss inserts. This is the whole uniqueness invariant — no two members compare equal under the set’s comparer.

The membership contract is exact while the comparer contract holds and comparer-observed state stays stable. Contains(x) uses comparer.GetHashCode(x) to choose a home bucket or cell, follows its bucket chain or probe sequence, and returns true only if comparer.Equals(member, x) succeeds for a candidate. Comparer-equal values must produce the same hash code; otherwise they can start different collision paths, letting duplicates enter and lookups miss stored members. Under that contract there are no false positives or false negatives, unlike a probabilistic Bloom filter, which can report a member that was never added.

Two properties are deliberately not retained. Iteration order reflects bucket layout and rehash history, not insertion sequence, and can change after any Add/Remove or across runtime versions. And because the set stores presence rather than occurrence, it cannot answer “how many times” — an element is either in or out.

Complexity
n
number of elements currently stored in the set

When the Structure Stops Fitting

The lookup path exposes the main limits.

  • Ordered and range queries. A bucket index says nothing about rank, so finding the smallest element above k or every element in [a, b] requires a full scan.
  • Current .NET implementations can switch built-in HashSet<string> hashing to a randomized comparer after detecting excessive collisions. Custom types and comparers remain responsible for their own distribution.
  • The comparer contract. Values for which comparer.Equals(x, y) is true must return the same comparer.GetHashCode. Breaking that rule starts lookups from different positions and can admit a duplicate or hide an existing member. Mutating state used by the comparer after insertion has the same effect because the member remains on its old collision path.

Pre-sizing the set to the expected count avoids the intermediate resizes.

Diagram and C# Implementation

References