Skip to content

Collections Interview Questions & Answers

15 questions Updated 2026-06-23 Share:

C# collections interview questions — List vs Array, Dictionary internals, HashSet, ConcurrentDictionary, Span<T>, and choosing the right type.

Read the in-depth guideChoosing the Right C# Collection(opens in new tab)
15 of 15

.NET's collection library has types optimised for different access patterns. Picking the right one affects both performance and API clarity.

// List<T> — dynamic array; O(1) index, O(n) insert/remove in middle
var list = new List<int> { 1, 2, 3 };
list.Add(4);           // O(1) amortised
list.Insert(0, 0);     // O(n) — shifts elements

// Dictionary<K,V> — hash map; O(1) average lookup, insert, remove
var dict = new Dictionary<string, int> { ["a"] = 1, ["b"] = 2 };
dict["c"] = 3;
dict.TryGetValue("a", out int val); // val = 1

// HashSet<T> — unordered unique values; O(1) add, remove, Contains
var set = new HashSet<string> { "alpha", "beta" };
set.Add("alpha"); // no-op, already present

// Queue<T> — FIFO; Enqueue/Dequeue both O(1)
var queue = new Queue<int>();
queue.Enqueue(1); queue.Enqueue(2);
int first = queue.Dequeue(); // 1

// Stack<T> — LIFO; Push/Pop both O(1)
var stack = new Stack<int>();
stack.Push(1); stack.Push(2);
int top = stack.Pop(); // 2

// LinkedList<T> — O(1) insert/remove anywhere, O(n) lookup
var ll = new LinkedList<int>(new[] { 1, 2, 3 });
ll.AddFirst(0); // O(1)

Rule of thumb: Start with List<T> for ordered mutable sequences. Use Dictionary<K,V> for key-based lookup. Use HashSet<T> for membership tests. Only switch to specialised types (LinkedList, SortedDictionary) when profiling shows a concrete bottleneck.

Arrays are fixed-size and slightly faster for indexed access. List<T> wraps an array and automatically resizes when needed, at the cost of a small overhead.

// Array — fixed length set at creation:
int[] arr = new int[5];
arr[0] = 1; arr[4] = 5;
// arr.Add(6); — does not exist; arrays cannot grow

// List<T> — dynamic; backed by an internal array:
var list = new List<int>(capacity: 5); // hint initial capacity
list.Add(1); list.Add(2); // grows automatically when needed

// Span<T> interop — both support span (zero-copy slice):
ReadOnlySpan<int> span1 = arr;           // direct, no allocation
ReadOnlySpan<int> span2 = list.AsSpan(); // also zero allocation (since .NET 5)

// Performance:
// Array:  fastest indexed access, lowest memory overhead
// List<T>: ~same indexed speed; stores Count + Capacity + array ref

// API difference:
arr.Length;       // int, fixed
list.Count;       // int, current element count
list.Capacity;    // backing array size (>= Count)

// Returning from methods:
// Return T[] when size is fixed and caller should not modify it
// Return List<T> when caller needs to add/remove
// Return IReadOnlyList<T> to expose indexed access but prevent mutation

Rule of thumb: Use arrays (T[]) when the size is known at creation time and performance is critical. Use List<T> when the collection needs to grow. Return IReadOnlyList<T> or IReadOnlyCollection<T> from public APIs to prevent callers from modifying your internal state.

Dictionary<TKey, TValue> is a hash table. It calls key.GetHashCode() to find a bucket, then uses key.Equals() to resolve collisions within the bucket. Average time complexity is O(1) for Get, Add, ContainsKey; worst case O(n) with many hash collisions.

// Good key: string, int, Guid, record types (structural equality)
var d1 = new Dictionary<string, int> { ["a"] = 1 };
var d2 = new Dictionary<int, string> { [1] = "one" };

// BAD key: mutable reference type with default GetHashCode (pointer-based)
class MutableKey { public string Name { get; set; } = ""; }
var bad = new Dictionary<MutableKey, int>();
var key = new MutableKey { Name = "a" };
bad[key] = 1;
key.Name = "b"; // mutate after insertion — GetHashCode changes!
bad.ContainsKey(key); // may return false — key is in the wrong bucket!

// Custom equality:
var ci = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
ci["Hello"] = 1;
Console.WriteLine(ci["hello"]); // 1 — case-insensitive

// Capacity hint avoids rehashing:
var d3 = new Dictionary<string, int>(capacity: 1000); // pre-allocate buckets

// Safe access patterns:
if (d1.TryGetValue("a", out int val))   // preferred — single lookup
    Console.WriteLine(val);
// d1["missing"] — throws KeyNotFoundException!
int val2 = d1.GetValueOrDefault("missing", 0); // 0 — safe

Rule of thumb: Keys must have stable, consistent GetHashCode values. Never mutate an object while it is used as a dictionary key. For custom key types, implement GetHashCode and Equals correctly, or prefer record types which do this automatically.

HashSet<T> is ideal when you need fast membership tests or need to ensure uniqueness. Contains is O(1) for HashSet vs O(n) for List.

var list = new List<int> { 1, 2, 3, 4, 5 };
var set  = new HashSet<int> { 1, 2, 3, 4, 5 };

// Membership test performance:
list.Contains(5); // O(n) — scans every element
set.Contains(5);  // O(1) — hash lookup

// Uniqueness: HashSet silently ignores duplicates
set.Add(3); // returns false, set unchanged
list.Add(3); // always adds — now has two 3s

// Set operations (no equivalent in List<T>):
var a = new HashSet<int> { 1, 2, 3, 4 };
var b = new HashSet<int> { 3, 4, 5, 6 };
a.IntersectWith(b);  // a = { 3, 4 }
a.UnionWith(b);      // a = { 3, 4, 5, 6 }
a.ExceptWith(b);     // a = {} (b is a superset here)

// Remove duplicates from a list efficiently:
var withDupes = new List<int> { 1, 1, 2, 2, 3 };
var unique = new HashSet<int>(withDupes).ToList(); // [1, 2, 3]

// Sorted variant:
var sortedSet = new SortedSet<int> { 5, 1, 3, 2 };
Console.WriteLine(string.Join(", ", sortedSet)); // 1, 2, 3, 5 — always ordered

Rule of thumb: Use HashSet<T> for any "is X in the set?" question at scale. Use SortedSet<T> when you additionally need the elements in order. Use List<T> when order and duplicates both matter.

ConcurrentDictionary<K,V> is a thread-safe dictionary. Unlike Dictionary<K,V> (which is not thread-safe), it allows concurrent reads and writes without external locking for most operations.

var cache = new ConcurrentDictionary<string, int>();

// AddOrUpdate — atomic: adds if missing, or updates with a function
cache.AddOrUpdate("hits", 1, (key, old) => old + 1);
cache.AddOrUpdate("hits", 1, (key, old) => old + 1); // now 2

// GetOrAdd — atomic: returns existing value or adds new one
int val = cache.GetOrAdd("pageSize", 25); // adds 25 if not present

// WARNING: GetOrAdd with factory is NOT atomic — factory may run multiple times
// Use GetOrAdd(key, value) when value is cheap/idempotent
// Use Lazy<T> factory pattern for expensive initialisation:
var lazyCache = new ConcurrentDictionary<string, Lazy<ExpensiveObject>>();
var lazy = lazyCache.GetOrAdd("key", _ => new Lazy<ExpensiveObject>(() => new ExpensiveObject()));
ExpensiveObject obj = lazy.Value; // Lazy ensures only one instance created

// TryAdd, TryRemove, TryUpdate — safe, return bool:
cache.TryAdd("x", 42);
cache.TryRemove("x", out int removed);
cache.TryUpdate("hits", 99, currentValue: 2); // only updates if current == 2

// Avoid: foreach + mutation (enumerator takes a snapshot — safe but stale)
foreach (var (k, v) in cache)
    Console.WriteLine($"{k}={v}");

Rule of thumb: Use ConcurrentDictionary for shared caches, counters, or lookup tables accessed from multiple threads. Prefer the atomic methods (AddOrUpdate, GetOrAdd) over read-then-write patterns which reintroduce race conditions.

These three represent different levels of immutability guarantee.

// IReadOnlyCollection<T> — Count + enumeration, no index access
IReadOnlyCollection<int> roc = new List<int> { 1, 2, 3 };
Console.WriteLine(roc.Count); // 3
// roc[0] — no indexer

// IReadOnlyList<T> — adds indexer (IReadOnlyCollection + indexer)
IReadOnlyList<int> rol = new List<int> { 1, 2, 3 };
Console.WriteLine(rol[0]); // 1
// rol.Add(4); — no Add

// BUT: the underlying list is still mutable!
var mutable = new List<int> { 1, 2, 3 };
IReadOnlyList<int> view = mutable;
mutable.Add(4);
Console.WriteLine(view.Count); // 4 — view reflects change!

// ImmutableList<T> — truly immutable; returns a new list on each "mutation"
var immutable = ImmutableList.Create(1, 2, 3);
var updated   = immutable.Add(4); // new list; original unchanged
Console.WriteLine(immutable.Count); // 3
Console.WriteLine(updated.Count);   // 4

// ImmutableList is thread-safe (no locking needed) but slower than List<T>
// Use ImmutableArray<T> for value-type semantics + performance:
ImmutableArray<int> arr = ImmutableArray.Create(1, 2, 3);
ImmutableArray<int> arr2 = arr.Add(4); // arr still [1,2,3]

Rule of thumb: Return IReadOnlyList<T> from public APIs to prevent callers from adding/removing. Use ImmutableList<T> (from System.Collections.Immutable) when you need a truly immutable, shareable collection — e.g., in functional code, state management, or thread-safe contexts.

Span<T> is a stack-only struct that represents a contiguous region of memory — array, stack memory, or unmanaged memory — without copying it. Memory<T> is the heap-compatible version (can be stored in fields or used across await).

int[] array = { 1, 2, 3, 4, 5, 6, 7, 8 };

// Span — zero-copy slice of the array:
Span<int> slice = array.AsSpan(2, 4); // elements 2–5: [3,4,5,6]
slice[0] = 99; // modifies the ORIGINAL array!
Console.WriteLine(array[2]); // 99

// Stack allocation — no heap allocation at all:
Span<byte> stackBuffer = stackalloc byte[256];
stackBuffer.Fill(0);

// String slicing without allocation:
ReadOnlySpan<char> str = "Hello, World!".AsSpan();
ReadOnlySpan<char> greeting = str[..5];             // "Hello" — no allocation
Console.WriteLine(greeting.ToString());              // "Hello"

// Memory<T> — async-compatible (Span cannot cross await boundaries):
async Task ProcessAsync(Memory<byte> buffer)
{
    await stream.ReadAsync(buffer);        // Memory<T> works across await
    ReadOnlySpan<byte> span = buffer.Span; // access as Span inside sync code
    // do something with span
}

// High-performance parsing without allocating substrings:
ReadOnlySpan<char> csv = "Alice,30,London";
int comma1 = csv.IndexOf(',');
ReadOnlySpan<char> name = csv[..comma1]; // "Alice" — no allocation

Rule of thumb: Use Span<T> to slice and process buffers without allocation in synchronous hot paths. Use Memory<T> when you need to store the slice or cross an await. Prefer ReadOnlySpan<char> over string.Substring for zero-allocation parsing.

Queue<T> implements FIFO (first-in, first-out). Stack<T> implements LIFO (last-in, first-out). Both are O(1) for their primary operations.

// Queue<T> — FIFO:
var queue = new Queue<string>();
queue.Enqueue("first");
queue.Enqueue("second");
queue.Enqueue("third");

Console.WriteLine(queue.Peek());    // "first" — peek without removing
Console.WriteLine(queue.Dequeue()); // "first" — removes and returns
Console.WriteLine(queue.Count);     // 2

// Stack<T> — LIFO:
var stack = new Stack<string>();
stack.Push("bottom");
stack.Push("middle");
stack.Push("top");

Console.WriteLine(stack.Peek()); // "top" — peek without removing
Console.WriteLine(stack.Pop());  // "top" — removes and returns
Console.WriteLine(stack.Count);  // 2

// Real-world use cases:
// Queue: task queues, BFS graph traversal, request buffering
var bfsQueue = new Queue<TreeNode>();
bfsQueue.Enqueue(root);
while (bfsQueue.Count > 0)
{
    var node = bfsQueue.Dequeue();
    foreach (var child in node.Children)
        bfsQueue.Enqueue(child);
}

// Stack: undo history, DFS traversal, expression parsing, call-stack simulation
var history = new Stack<string>();
history.Push("open file");
history.Push("type text");
string undone = history.Pop(); // "type text" — last action undone first

Rule of thumb: Use Queue<T> for work-item processing where order must be preserved (FIFO). Use Stack<T> for undo, backtracking, or DFS. Use System.Threading.Channels or BlockingCollection<T> for producer-consumer queues across threads.

Both maintain keys in sorted order. The difference is their internal data structure: SortedDictionary uses a red-black tree; SortedList uses two parallel arrays.

// SortedDictionary<K,V> — red-black tree; O(log n) all operations
var sd = new SortedDictionary<string, int>();
sd["banana"] = 2;
sd["apple"]  = 1;
sd["cherry"] = 3;
foreach (var kv in sd)
    Console.Write(kv.Key + " "); // apple banana cherry — sorted!

// SortedList<K,V> — parallel arrays; O(log n) lookup, O(n) insert/delete
var sl = new SortedList<string, int>();
sl["banana"] = 2;
sl["apple"]  = 1;

// SortedList: supports index-based access to keys and values:
Console.WriteLine(sl.Keys[0]);   // "apple"
Console.WriteLine(sl.Values[0]); // 1

// When to choose which:
// SortedDictionary: frequent inserts/deletes on a large set
// SortedList: mostly reads after initial load; memory-efficient for dense data

// Custom comparer (e.g., case-insensitive keys):
var ci = new SortedDictionary<string, int>(StringComparer.OrdinalIgnoreCase);
ci["Zebra"] = 1;
ci["apple"] = 2;
foreach (var k in ci.Keys) Console.Write(k + " "); // apple Zebra (case-insensitive order)

Rule of thumb: Use SortedDictionary when you have frequent inserts and deletes with a need for sorted iteration. Use SortedList when the data is loaded once and read many times — it uses less memory and supports index-based access to keys/values.

The collection interfaces form a hierarchy, each adding more capabilities.

// IEnumerable<T> — only: iterate with foreach
IEnumerable<int> en = new[] { 1, 2, 3 };
foreach (var n in en) Console.Write(n);

// ICollection<T> — adds: Count, Add, Remove, Contains, Clear
ICollection<int> col = new List<int> { 1, 2, 3 };
Console.WriteLine(col.Count); // 3
col.Add(4);
col.Remove(1);
col.Contains(2); // true

// IList<T> — adds: indexer [], IndexOf, Insert, RemoveAt
IList<int> lst = new List<int> { 1, 2, 3 };
Console.WriteLine(lst[0]); // 1
lst.Insert(0, 0);          // insert at position
lst.RemoveAt(0);           // remove by index

// Read-only counterparts:
// IReadOnlyCollection<T> = IEnumerable<T> + Count
// IReadOnlyList<T>       = IReadOnlyCollection<T> + indexer
// IReadOnlyDictionary<K,V> = read-only key-value lookup

// Accept the most general interface:
void Print(IEnumerable<int> items) // accept anything iterable
{
    foreach (var i in items) Console.Write(i);
}
Print(new[] { 1, 2 });       // array — ok
Print(new List<int> { 1 });  // list — ok
Print(Enumerable.Range(1, 5)); // lazy sequence — ok

Rule of thumb: Accept the most general interface your method needs (IEnumerable<T> for read-only iteration). Return the most specific interface the caller needs (IReadOnlyList<T> for indexed access, IReadOnlyCollection<T> just for counting).

LinkedList<T> is a doubly-linked list. It is O(1) for insert and remove at any known node, but O(n) for index-based access (no indexer). List<T> is O(1) for indexed access but O(n) for insert/remove in the middle (shifts elements).

var linked = new LinkedList<int>(new[] { 1, 2, 3, 4, 5 });

// O(1) insert before/after a known node:
LinkedListNode<int>? node3 = linked.Find(3);
linked.AddBefore(node3!, 99); // [1,2,99,3,4,5]
linked.AddAfter(node3!, 88);  // [1,2,99,3,88,4,5]

// O(1) add at head/tail:
linked.AddFirst(0);  // [0,1,2,99,3,88,4,5]
linked.AddLast(999); // [0,1,2,99,3,88,4,5,999]

// O(1) remove any node if you have the reference:
linked.Remove(node3!); // remove '3' — O(1)

// No indexer — O(n) to reach element by position:
// linked[3] — doesn't exist
var val = linked.Skip(3).First(); // O(n)

// Real use cases:
// - Implement LRU cache (remove from middle + add to end = O(1))
// - Undo history where you insert/remove at specific positions
// - Queue/Deque where you add/remove both ends

LinkedList<T> vs List<T> summary:

Operation List<T> LinkedList<T>
Index by position O(1) O(n)
Insert at known node O(n) O(1)
Remove at known node O(n) O(1)
Memory Contiguous (cache-friendly) Scattered (pointer overhead)

Rule of thumb: Start with List<T>. Switch to LinkedList<T> only when you profile and confirm that frequent mid-list insertion/removal is the bottleneck and you can efficiently maintain LinkedListNode<T> references.

Several common mistakes make collection code slower than it needs to be.

// 1. Pre-size collections when count is known:
var list = new List<int>(capacity: 10_000); // avoids 13+ resize+copy cycles
var dict = new Dictionary<string, int>(capacity: 500);

// 2. Use TryGetValue instead of ContainsKey + indexer (two lookups vs one):
// double lookup:
if (dict.ContainsKey("key")) { var v = dict["key"]; }
// single lookup:
if (dict.TryGetValue("key", out int val)) { /* use val */ }

// 3. CollectionsMarshal for zero-copy dict value update (.NET 6+):
ref int count = ref CollectionsMarshal.GetValueRefOrAddDefault(dict, "hits", out _);
count++;  // in-place increment — no re-hash, no boxing

// 4. Prefer Span<T> over LINQ for hot-path slicing:
int[] data = Enumerable.Range(0, 100_000).ToArray();
// LINQ allocates enumerator + closure:
var sumLinq = data.Skip(100).Take(900).Sum();
// Span — stack-only, no allocation:
int sumSpan = 0;
foreach (var n in data.AsSpan(100, 900)) sumSpan += n;

// 5. For string-keyed dictionaries, use the right comparer:
// OrdinalIgnoreCase is faster than CurrentCultureIgnoreCase for most use cases
var ci = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);

// 6. Avoid List<T> when a fixed array suffices:
static int[] ComputeSquares(int n)
{
    var result = new int[n]; // array — exact size, no overhead
    for (int i = 0; i < n; i++) result[i] = i * i;
    return result;
}

Rule of thumb: Pre-size collections, use TryGetValue over ContainsKey + indexer, prefer Span<T> for hot buffer operations, and profile before optimising — most code is not collection-bound.

PriorityQueue<TElement, TPriority> (.NET 6) is a min-heap where each element has an associated priority. Dequeue always returns the element with the lowest priority value. It replaces sorted-list workarounds for scheduling and graph algorithms.

// Elements are (value, priority) pairs; lowest priority is dequeued first
var pq = new PriorityQueue<string, int>();
pq.Enqueue("low task",    10);
pq.Enqueue("high task",    1);  // priority 1 = highest
pq.Enqueue("medium task",  5);

Console.WriteLine(pq.Dequeue()); // "high task"   (priority 1)
Console.WriteLine(pq.Dequeue()); // "medium task" (priority 5)
Console.WriteLine(pq.Dequeue()); // "low task"    (priority 10)

// Peek without removing:
pq.Enqueue("urgent", 0);
pq.TryPeek(out string? element, out int priority);
Console.WriteLine($"{element} at priority {priority}"); // urgent at priority 0

// Dijkstra shortest-path skeleton:
var distances = new Dictionary<string, int>();
var frontier  = new PriorityQueue<string, int>();
frontier.Enqueue(startNode, 0);

while (frontier.Count > 0)
{
    var (node, dist) = (frontier.Dequeue(), frontier.TryPeek(out _, out int p) ? p : 0);
    // Note: TryDequeue returns element and priority together:
    // frontier.TryDequeue(out string node, out int dist)
    foreach (var (neighbor, weight) in Graph[node])
    {
        int newDist = dist + weight;
        if (!distances.TryGetValue(neighbor, out int old) || newDist < old)
        {
            distances[neighbor] = newDist;
            frontier.Enqueue(neighbor, newDist);
        }
    }
}

Rule of thumb: Use PriorityQueue for Dijkstra, A*, task schedulers, and any scenario where you need the cheapest/most-urgent item repeatedly. Remember it is a min-heap — assign smaller numbers to higher-priority items, or negate the priority if you need max-heap behaviour.

FrozenDictionary<K,V> and FrozenSet<T> (.NET 8) are read-only, highly optimised collections built from existing data. They pay a higher one-time construction cost to produce a data structure tuned for the specific key set, then deliver faster lookups than Dictionary or HashSet for read-heavy workloads.

// Build once at startup — pay construction cost once:
var lookup = new Dictionary<string, int>
{
    ["apple"]  = 1,
    ["banana"] = 2,
    ["cherry"] = 3,
}.ToFrozenDictionary(); // FrozenDictionary<string, int>

// Lookups are typically faster than Dictionary<K,V> for the same data:
bool found = lookup.TryGetValue("banana", out int value); // true, value = 2
Console.WriteLine(lookup["cherry"]); // 3

// FrozenSet<T> for fast membership tests:
FrozenSet<string> allowedRoles =
    new HashSet<string> { "Admin", "Moderator", "Editor" }
    .ToFrozenSet(StringComparer.OrdinalIgnoreCase);

Console.WriteLine(allowedRoles.Contains("admin")); // true

// Typical use cases:
// - Permission sets loaded at startup and queried per request
// - Static lookup tables (HTTP status descriptions, currency codes, config keys)
// - Feature flag dictionaries

// Note: FrozenDictionary is immutable — no Add, Remove, or set-indexer.
// Rebuild the frozen collection if the source data changes.
var updated = sourceDict
    .Concat(new[] { KeyValuePair.Create("durian", 4) })
    .ToFrozenDictionary();

Rule of thumb: Use FrozenDictionary / FrozenSet for lookup tables that are populated once (startup, DI registration, config load) and read millions of times at runtime. They beat Dictionary on lookup speed at the cost of being immutable and slightly slower to construct.

System.Threading.Channels provides async-friendly producer-consumer pipelines. Unlike ConcurrentQueue<T> (which requires polling or blocking), channels let consumers await new items — no spin-loop, no blocking threads.

// Unbounded channel — producer never blocks:
Channel<int> channel = Channel.CreateUnbounded<int>();

ChannelWriter<int>  writer = channel.Writer;
ChannelReader<int>  reader = channel.Reader;

// Producer — runs on one task:
_ = Task.Run(async () =>
{
    for (int i = 0; i < 10; i++)
    {
        await writer.WriteAsync(i);
        await Task.Delay(50);
    }
    writer.Complete(); // signal no more items
});

// Consumer — awaits items as they arrive (no polling):
await foreach (int item in reader.ReadAllAsync())
    Console.WriteLine($"Received: {item}");

// Bounded channel — backpressure: producer is throttled when full
var bounded = Channel.CreateBounded<int>(new BoundedChannelOptions(capacity: 100)
{
    FullMode = BoundedChannelFullMode.Wait,        // await until space is available
    // Other modes: DropOldest, DropNewest, DropWrite
});

// Multiple producers / consumers are safe — channel is thread-safe:
var tasks = Enumerable.Range(0, 4)
    .Select(id => Task.Run(async () =>
    {
        while (await reader.WaitToReadAsync())
            if (reader.TryRead(out int item))
                Console.WriteLine($"Consumer {id}: {item}");
    }))
    .ToArray();
await Task.WhenAll(tasks);

Rule of thumb: Use Channel<T> for async producer-consumer pipelines where consumers should await work rather than poll or block. Use a bounded channel to apply backpressure and prevent producers from overwhelming consumers. Only use ConcurrentQueue<T> when you already have a polling/spinning loop or need the raw enqueue throughput without async overhead.

More ways to practice

The self-quiz is live. Get notified when mock interviews and new question packs drop.

or
Join our WhatsApp Channel