C# List.Contains O(n²) Trap — Checkout Died at 500 Items
List.
- Arrays: fixed-size contiguous storage (int[]), O(1) index access, cannot grow, stored inline on stack or heap depending on context
- List
: dynamic array that grows automatically (default capacity 4, doubles on resize), O(1) amortised Add, O(1) index access - Dictionary
: hash table for O(1) key-based lookups, requires GetHashCode() and Equals() implementation for custom keys - Performance: Dictionary lookup ~100ns, List.Contains O(n) ~1,000ns for 10K items — 10x slower
- Production trap: Using List where O(n) Contains is called in loop (O(n²) performance) instead of Dictionary (O(n))
- Biggest mistake: Modifying collection while iterating with foreach (InvalidOperationException) — use for loop backward or ToList() copy
Imagine you're organising a birthday party. A single variable is like holding one balloon in your hand — great for one thing. An array is like a balloon rack with fixed, numbered slots — you decide upfront you need exactly 10 balloons, and each slot has a number starting at zero. A List is like a stretchy gift bag — you can keep stuffing more presents in without deciding the size in advance. A Dictionary is like a labelled coat-check counter — instead of a number, each item has a unique name tag (like 'Alice's coat') so you can grab exactly the right one instantly.
Every real app — from a shopping cart to a leaderboard to a contact book — needs to store more than one piece of data at a time. The moment you move beyond a single variable, you need a structure that can hold a group of values and let you work with them together. That's exactly what arrays and collections do, and they are arguably the most-used tools in any C# developer's daily work. Knowing them well separates a developer who struggles with basic data wrangling from one who writes clean, confident code.
Before collections existed, developers had to manually juggle groups of related data — declaring ten separate variables for ten students, copying data into new arrays whenever the size changed, writing fragile code that broke if you added one extra item. Arrays gave us numbered slots in memory. The Collections namespace in .NET went further, giving us dynamic, purpose-built containers like List, Dictionary, Queue, and Stack that handle growth, lookup, and ordering automatically.
By the end you'll be able to declare and use arrays, create and manipulate List<T> and Dictionary<TKey, TValue>, iterate over any collection with a foreach loop, choose the right container for a given problem, and avoid the three classic mistakes that trip up almost every beginner.
C# Arrays — Fixed-Size Storage With Numbered Slots
An array is the simplest way to store multiple values of the same type. Think of it as a row of labelled lockers in a school corridor. You decide how many lockers you need when you build the corridor — that number never changes. Each locker has a number starting at zero (not one — this trips people up constantly). Locker zero is the first one.
You declare an array by writing the type, square brackets, a name, and then using new to create it with a fixed size. Once created, every slot is automatically filled with the default value for that type — zero for numbers, null for strings.
Arrays are the right choice when you know exactly how many items you'll have and that number won't change — days of the week, months of the year, RGB colour channels. They are blazing fast because the computer lays them out in a straight line in memory. But if the size might change — use a List instead, which we'll cover next.
The .Length property tells you how many slots exist. Accessing a slot that doesn't exist (like index 10 in a 5-slot array) throws an IndexOutOfRangeException — one of the most common crashes beginners see.
array.Length - 1 if you need the last item, or just use array[^1] in modern C# (version 8+).arr[i] is a CPU-level address calculation — base + (i * element_size) — O(1) constant time.List — The Dynamic Array That Grows With You
The moment you need a collection whose size isn't fixed — a shopping cart that can have any number of items, a list of players that join and leave — an array becomes a headache. You'd have to create a new, bigger array and copy everything over every time you wanted to add an item. That's exactly the problem List<T> solves.
List<T> is a generic collection. The T is a placeholder for the type you want to store — List<string> stores strings, List<int> stores integers. Think of it as an array with superpowers: it resizes itself automatically, gives you ., Add()., Remove()., Contains().Count, and dozens of other useful methods out of the box.
Under the hood, a List actually is backed by an array — it just handles all the resizing work for you. When it runs out of room, it silently creates a new internal array twice the size and copies everything across. You never have to think about this, but it's good to know so you understand why arrays are marginally faster for fixed data.
Use List<T> as your default collection whenever the size might change or you don't know it upfront. It's the workhorse of C# development — you'll use it in virtually every project you write.
.Length. Lists use .Count. They do the same thing — tell you how many items are stored — but using the wrong one is a compiler error that catches out almost every beginner on their first day with collections. The rule is simple: if it ends in [] it's an array, use .Length. If it's a List<T>, use .Count.new List<T>(10000) — eliminates resizes and copies.Dictionary — Lightning-Fast Lookup by Name
Sometimes a numbered index isn't the right way to identify data. You don't want player stats at index 3 — you want them by player name. You don't want a country's capital at index 47 — you want it by the country's name. That's what Dictionary<TKey, TValue> is for.
A dictionary stores key-value pairs. Every item has a unique key (like a name, an ID, a code) and a value (the data attached to that key). Think of a real physical dictionary: you look up a word (the key) and get the definition (the value) instantly — you don't have to read every page to find it.
Lookup in a dictionary is extremely fast — essentially instant regardless of how many items are stored — because .NET uses a technique called hashing internally. Compare this to searching through a List where, in the worst case, you have to check every single item.
Keys must be unique. You can't have two entries with the key "Alice" — the second add will throw an exception. Values can be duplicated — two players can have the same score. Use ContainsKey() before adding if you're not sure whether the key exists yet.
Use a Dictionary whenever you need to look things up by a meaningful name or ID rather than a position number.
SortedDictionary<TKey, TValue> instead.GetHashCode() distribution. Poor hash codes degrade to O(n).GetHashCode() and Equals(). Use immutable keys (string, int, Guid) where possible.TryGetValue() instead of the indexer to avoid crashes on missing keys.The O(n²) List.Contains That Took Down Checkout
if (shoppingCart.Contains(productId)) for each new item being added to the cart. shoppingCart was a List<int>. List.Contains calls Array.IndexOf internally — a linear scan O(n). For a cart with N items, the total complexity of adding N items with duplicate check was Σ O(i) from i=1 to N = O(n²). For N=500, that's ~125,000 operations. For N=1000, that's ~500,000 operations — a quadratic blowup. The team refactored to use HashSet<int> for the duplicate check, which is O(1) average, dropping total complexity to O(n). After the fix, 500-item checkout took 100ms.List<int> to HashSet<int> for the validity pass.
2. Kept the main cart as List<int> because order matters for display, but the duplicate check used cartSet = new HashSet<int>(cartList) for O(n) build once, O(1) checks.
3. Added performance tests with 1, 10, 100, 500, 1000 items to catch regressions.
4. Added an ICollection<T> abstraction that selects HashSet for membership tests, List for ordering.
5. Documented the performance characteristics of each collection at the call site: // O(n) lookup, use HashSet if called repeatedly.- List.Contains is O(n) — linear scan. Dictionary.ContainsKey and HashSet.Contains are O(1) average.
- O(n²) algorithms are invisible in small tests (10 items → 100 operations) but catastrophic at scale (1000 items → 1,000,000 operations).
- Always check the complexity of collection operations. List<T> is not a magic bullet — it's just an array wrapper.
- When you need fast lookups by key, use Dictionary<TKey,TValue>. When you need fast membership tests without values, use HashSet<T> (O(1) Add, Remove, Contains).
Console.WriteLine($'Index: {idx}, Count: {list.Count}');. Use TryGetValue for dictionaries. For lists, check if (idx < list.Count && idx >= 0) before access.dict[key] without checking existence. Replace with dict.TryGetValue(key, out var value). Or check if (dict.ContainsKey(key)). Never assume the key exists.foreach nested inside another foreach over same collection..Remove() or .Add() inside a foreach loop. Fix: iterate over .ToList() copy: foreach (var item in list.ToList()) { list.Remove(item); }. Or use for loop backward: for (int i = list.Count - 1; i >= 0; i--) { list.RemoveAt(i); }dict.Add(key, value) without checking if key exists. Use indexer dict[key] = value (overwrites) or check if (!dict.ContainsKey(key)) dict.Add(...);if (idx >= 0 && idx < list.Count) { var item = list[idx]; } else { / handle error / }Key takeaways
TryGetValue() instead of the indexer to avoid crashes on missing keys.Common mistakes to avoid
5 patternsOff-by-one index errors on arrays and lists
array[^1] for last element (C# 8+), or array[array.Length - 1]. In loops: for (int i = 0; i < list.Count; i++) (not i <= list.Count).Adding a duplicate key to a Dictionary
.Add() call.if (!dict.ContainsKey(key)) dict.Add(key, value); or use the indexer dict[key] = value which safely overwrites instead of throwing. Choose based on desired behavior (add vs update).Modifying a List while iterating over it with foreach
foreach (var item in list.ToList()) { list.Remove(item); }. Or iterate backward with for loop: for (int i = list.Count - 1; i >= 0; i--) { list.RemoveAt(i); }. Or use RemoveAll(predicate).Using List.Contains in a loop (O(n²) performance)
var hashSet = new HashSet<T>(list); if (hashSet.Contains(item)) { ... }. HashSet.Contains is O(1) average. For key-value lookups, use Dictionary<TKey,TValue>.Assuming Dictionary preserves insertion order
SortedDictionary<TKey,TValue> for ordered by key, or List<KeyValuePair<TKey,TValue>> for insertion-order preservation. If you need both lookup and order, maintain both structures.Interview Questions on This Topic
Explain the internal behavior of List
new List<T>(10000). Capacity is always at least the number of elements stored, accessed via .Capacity. After adding, you can call .TrimExcess() to release unused capacity, but this causes another O(N) copy.Frequently Asked Questions
That's C# Basics. Mark it forged?
4 min read · try the examples if you haven't