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

📚 Interview question

How do you use HashSet<T> in C#? Explain Add returning bool, the set operations (UnionWith, IntersectWith, ExceptWith, SetEquals), custom comparers, and why HashSet.Contains beats List.Contains. When would you use SortedSet<T> or FrozenSet<T> instead?

The `HashSet<T>` API in practice: uniqueness, set algebra, comparers, and the sorted and frozen alternatives.

Junior Theory Beginner collections frozenset hashset sets sortedset

🎯 Key points

  • `HashSet<T>` stores unique values with O(1) average `Add`, `Remove` and `Contains`
  • `Add` returns `false` for duplicates, so no `Contains` check is needed first
  • `UnionWith`, `IntersectWith`, `ExceptWith`, `SymmetricExceptWith` modify the set in place
  • `SetEquals`, `IsSubsetOf` and `Overlaps` test relationships and ignore order
  • Pass a comparer such as `StringComparer.OrdinalIgnoreCase` to define equality
  • Order is not guaranteed; `SortedSet<T>` gives sorted order and `GetViewBetween` ranges
  • `FrozenSet<T>` (.NET 8+) is best for sets built once and read often
  • Not thread-safe for writes; there is no built-in concurrent set

⏱ 30-second answer

HashSet<T> is an unordered collection of unique values backed by a hash table, so Add, Remove and Contains are O(1) on average, versus O(n) for List<T>.Contains. Add returns false if the value is already there, which makes de-duplication simple. It has in-place set operations: UnionWith, IntersectWith, ExceptWith, SymmetricExceptWith, plus tests such as SetEquals, IsSubsetOf and Overlaps. Pass a comparer (for example StringComparer.OrdinalIgnoreCase) to define what "equal" means. Use SortedSet<T> when you need sorted order or range queries (O(log n)), and FrozenSet<T> (.NET 8+) for a set built once and read many times.

🗣 Full answer (senior)

How I would answer this in an interview

HashSet<T> is an unordered collection of unique elements backed by a hash table, so Add, Remove and Contains are average O(1). It's the right tool whenever the question is "have I seen this?" or "what do these sets have in common?". I cover Add returning bool, the set operations, comparers, why it beats List.Contains, and when SortedSet<T> or FrozenSet<T> fit better.

1. Add returns bool

var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var email in emails)
{
    if (!seen.Add(email))                 // false = already present
        logger.LogWarning("Duplicate email {Email}", Mask(email));
}
  • Add returns true if the item was added, false if it was already present — one lookup for "check and insert". Writing if (!set.Contains(x)) set.Add(x); does two.
  • Remove also returns bool. TryGetValue(equalValue, out actual) returns the stored instance (useful for de-duplicating string instances or canonicalising objects).

2. Set operations

All of these modify the set in place (except the Is.../Overlaps/SetEquals queries):

Method Result Math
UnionWith(other) Add all elements of other A ∪ B
IntersectWith(other) Keep only elements also in other A ∩ B
ExceptWith(other) Remove elements in other A − B
SymmetricExceptWith(other) Keep elements in exactly one A △ B
IsSubsetOf, IsSupersetOf, IsProperSubsetOf bool ⊆, ⊇, ⊂
Overlaps(other) bool: any common element A ∩ B ≠ ∅
SetEquals(other) bool: same elements, ignoring order and duplicates A = B
var required = new HashSet<string>(["orders:read", "orders:write"], StringComparer.Ordinal);
bool allowed = required.IsSubsetOf(user.Permissions);

var removedSkus = new HashSet<string>(oldCatalogue);
removedSkus.ExceptWith(newCatalogue);   // SKUs that disappeared

These are optimised when other is a HashSet<T> with the same comparer (no rehashing). LINQ equivalents (Union, Intersect, Except) return new lazy sequences and build internal sets each time.

3. Custom comparers

Pass an IEqualityComparer<T> at construction:

  • Strings: StringComparer.Ordinal or OrdinalIgnoreCase — never culture-sensitive comparers for identifiers.
  • Custom types: implement IEquatable<T>/GetHashCode (or use records), or supply a comparer that compares by a key (e.g. product SKU).
  • Elements must not change in ways that affect equality while in the set.
  • Set operations between sets with different comparers fall back to slower paths and can give surprising results — keep comparers consistent.

4. Why HashSet.Contains beats List.Contains

  • List<T>.Contains is a linear scan: O(n), comparing every element (vectorised for primitives, but still proportional to size).
  • HashSet<T>.Contains computes a hash, jumps to a bucket and compares a few entries: O(1) average.

In a loop, that is the difference between O(n × m) and O(n + m). With 50,000 IDs checked against 50,000 IDs: ~2.5 billion comparisons vs ~100,000 operations. For very small collections (a handful of items), a list or array scan can be just as fast because hashing has a fixed cost — measure if it's on a hot path.

5. SortedSet<T> and FrozenSet<T>

Type Implementation Lookup Ordered? Mutable? Use for
HashSet<T> Hash table O(1) avg No Yes General membership, de-duplication
SortedSet<T> Red-black tree O(log n) Yes, by comparer Yes Ordered unique items, Min/Max, GetViewBetween range queries
FrozenSet<T> (.NET 8) Specialised hash strategies chosen at creation O(1), faster than HashSet No No Read-only lookups built once (allow-lists, config, reserved words)
ImmutableHashSet<T> Tree of buckets O(log n) No Persistent (returns new sets) Sharing snapshots with occasional updates
private static readonly FrozenSet<string> AllowedExtensions =
    new[] { ".pdf", ".png", ".jpg" }.ToFrozenSet(StringComparer.OrdinalIgnoreCase);

FrozenSet is ideal for static allow-lists checked on every request: creation is slower, but lookups are optimised and it's safe to share across threads.

6. A production example

A nightly sync compared 200,000 customer IDs from a CRM with 200,000 from the billing database using crmIds.Where(id => !billingIds.Contains(id)) where billingIds was a List<string>. It took over 40 minutes. Converting billingIds to a HashSet<string>(StringComparer.Ordinal) and using ExceptWith made it run in under a second. The fix was one line; the lesson was to look for List.Contains inside loops during code review.

🔍 What the interviewer is checking

Checks that the candidate reaches for a set when uniqueness or membership matters, and knows the API.

  • Green flags: knows O(1) Contains, uses Add's return value, names in-place set operations, mentions comparers, SortedSet and FrozenSet.
  • Red flags: uses List.Contains in loops, assumes ordering, or thinks HashSet is thread-safe.

📚 Learn in depth

1. Internal structure

HashSet<T> uses the same design as Dictionary<TKey,TValue>, just without values:

private int[] _buckets;      // 1-based index into _entries
private Entry[] _entries;    // { int HashCode; int Next; T Value; }
private int _count, _freeList, _freeCount, _version;
private IEqualityComparer<T>? _comparer;
  • Bucket count is a prime; index = hash mod buckets (fast modulo on 64-bit).
  • Collisions are chained through Next indexes inside the entries array.
  • Removed slots go onto a free list and are reused.
  • Resizing doubles to the next prime and rehashes from stored hash codes.

So Add/Contains/Remove are O(1) average, O(n) worst case with pathological hashing.

2. How set operations are implemented

  • UnionWith(other): Add each element of other — O(m).
  • ExceptWith(other): Remove each element of other — O(m). If other is the same set, clears.
  • IntersectWith(other): if other is a HashSet<T> with an equal comparer, iterate this set and remove elements not in other (O(n)); otherwise, build a bit array marking elements found in other, then remove unmarked ones.
  • SetEquals(other): with a compatible HashSet, compare counts then check containment; otherwise, may need to build a temporary set.
  • IsSubsetOf with compatible sets short-circuits when Count > other.Count.

The "same comparer" fast paths are why it's important to construct sets consistently.

3. SortedSet<T> internals

SortedSet<T> is a red-black tree (a self-balancing binary search tree):

  • Each node is a separate heap object (item, left, right, colour) — more memory and less cache-friendly than hash sets.
  • Operations are O(log n) with at most a constant number of rotations per insert/delete.
  • In-order traversal gives sorted enumeration; Min/Max are O(log n) (walk left/right).
  • GetViewBetween(lower, upper) returns a live view over a range, e.g. all timestamps within a window.
  • Duplicates (by comparer) are rejected, so a comparer that returns 0 for distinct items (e.g. comparing only by date) silently drops items.

4. FrozenSet<T> internals

ToFrozenSet() analyses the elements and picks an implementation:

  • For small sets, a simple array scan may be fastest.
  • For integers, a specialised hash or even a bitmap for dense ranges.
  • For strings, it can examine lengths and specific character positions to find a cheap discriminating hash (only hashing a few characters instead of the whole string), and may bucket by length so lookups for impossible lengths fail immediately.

Construction can cost much more than building a HashSet, so FrozenSet pays off only when the set is long-lived and looked up frequently. It's immutable and therefore thread-safe.

5. Memory considerations

A HashSet<int> stores, per element, a 12-byte entry (hash, next, value) plus 4 bytes of bucket — roughly 4× the memory of an int[]. A SortedSet<int> uses a ~40-byte node object per element. For tens of millions of integers, consider sorted arrays with BinarySearch, or BitArray for dense ranges.

6. LINQ vs in-place set operations

a.Intersect(b) builds a HashSet from b internally each time you enumerate, and yields distinct elements from a found in it. It doesn't modify anything and is lazy. Use LINQ for one-off expressions on sequences; use HashSet<T> methods when you already have sets or need repeated operations.

Try it yourself

  1. Benchmark List.Contains vs HashSet.Contains vs FrozenSet.Contains for sizes 5, 100 and 100,000.
  2. Create a SortedSet<Order> with a comparer that compares only CreatedAt and add two orders with the same timestamp.
  3. Use GetViewBetween to query a range of dates from a SortedSet<DateTime>.
  4. Compare IntersectWith performance with a HashSet argument vs a List argument.

💻 Code

using System.Collections.Frozen;

// De-duplicate and detect duplicates
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var email in incomingEmails)
{
    if (!seen.Add(email))
        Console.WriteLine($"Duplicate: {email}");
}

// Set algebra (in place)
var required = new HashSet<string> { "read", "write", "admin" };
var granted  = new HashSet<string> { "read", "write" };
var missing = new HashSet<string>(required);
missing.ExceptWith(granted);                 // { "admin" }
bool exact = granted.SetEquals(["write", "read", "read"]);  // true

// Get the stored instance back
seen.TryGetValue("[email protected]", out string? original);

// Sorted set with range query
var prices = new SortedSet<decimal> { 9.5m, 20m, 14m, 31m };
var midRange = prices.GetViewBetween(10m, 25m);   // 14, 20

// Built once, read many times
FrozenSet<string> blockedCountries = new[] { "XX", "YY" }.ToFrozenSet();
bool blocked = blockedCountries.Contains(countryCode);

⚠ Common mistakes

1. Contains then Add

What happens: if (!set.Contains(x)) set.Add(x);. Why it is wrong: two lookups where one suffices. Do instead: use the bool returned by Add.

2. Using List.Contains inside loops

What happens: membership checks against large lists in nested loops or LINQ Where. Why it is wrong: O(n × m) time; becomes minutes for large data. Do instead: build a HashSet<T> once and check against it.

3. Forgetting the comparer

What happens: new HashSet<string>() for emails or codes that should be case-insensitive. Why it is wrong: "[email protected]" and "[email protected]" are treated as different; duplicates slip through. Do instead: pass StringComparer.OrdinalIgnoreCase (or the correct comparer) at construction.

4. Expecting order from HashSet<T>

What happens: output generated by enumerating a HashSet. Why it is wrong: order is undefined and can change after removals or between runs. Do instead: sort explicitly or use SortedSet<T> when order matters.

5. A SortedSet comparer that ignores identity

What happens: sorting orders by date in a SortedSet<Order> with a date-only comparer. Why it is wrong: the set treats equal-comparing items as duplicates and drops them silently. Do instead: add tie-breakers (e.g. ID) to the comparer, or use a list and sort.

6. Mutating elements inside a set

What happens: changing a property used by GetHashCode after adding the object. Why it is wrong: the element can no longer be found or removed. Do instead: use immutable elements or compare by an immutable key.

7. Building FrozenSet frequently

What happens: ToFrozenSet() per request. Why it is wrong: construction is relatively expensive; the benefit needs many lookups. Do instead: build frozen sets once (static or singleton) for long-lived, read-only data.

8. Sharing a mutable HashSet across threads

What happens: a static HashSet<string> updated by multiple requests. Why it is wrong: not thread-safe; corrupts internal state. Do instead: use locks, ConcurrentDictionary<T, byte> as a set, or immutable/frozen sets.

9. Using Distinct() repeatedly instead of maintaining a set

What happens: calling .Distinct().ToList() every time an item is added. Why it is wrong: rebuilds a set each time — O(n) per addition. Do instead: maintain a HashSet<T> and add incrementally.

10. Mismatched comparers in set operations

What happens: caseInsensitiveSet.IntersectWith(caseSensitiveSet). Why it is wrong: falls back to slower paths and the semantics depend on which set's comparer is used. Do instead: keep comparers consistent across sets you combine.

🏛 Senior insight: trade-offs

A strong answer connects the set type to the access pattern and the lifetime of the data.

Pattern Best type Reason
Many lookups, data changes HashSet<T> O(1) add and lookup
Static allow/deny list FrozenSet<T> Fastest reads, immutable
Need min/max or ranges SortedSet<T> Ordered tree
Shared snapshot across threads ImmutableHashSet<T> Safe to share, cheap copies
Concurrent adds ConcurrentDictionary<T, byte> No built-in concurrent set

Seniors also copy the comparer when cloning a set (new HashSet<T>(set, set.Comparer)), because losing a case-insensitive comparer silently changes behaviour.

🔗 Follow-ups the interviewer may ask

  1. When: Candidate is asked how sets work underneath Why: Checks set data structure theory
    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)).

  2. When: Candidate stores custom objects in a set Why: Checks Equals and GetHashCode understanding
    How to answer

    System.Object gives every type Equals, GetHashCode, ToString, GetType, the protected MemberwiseClone and Finalize, plus static ReferenceEquals. The review problem is the Equals/GetHashCode contract: equal objects must return equal hash codes. If Equals compares Email but GetHashCode is still identity-based, two "equal" customers land in different hash buckets, so Dictionary, HashSet, Distinct and GroupBy treat them as different. I'd implement IEquatable<Customer>, override both methods using the same immutable fields, compare emails case-insensitively in both, overload ==/!=, and seal the class. For value-like data a record generates all of this.

↩ Usually asked after

More in Collections

Last updated 30 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me