Just added: Algorithms you can run and practice
Updated Jun 22, 2026 Edit on GitHub

Collections - Arrays, Lists, Dictionaries & Sets

Up to now you've held one value in one variable. Real programs deal in many: items in a cart, scores in a game, users by their IDs. C# gives you four shapes you pick between by asking one question: how do I need to get my data back out? Get that question right and the choice makes itself.

The mental model: a collection is a box, and the boxes differ by what they're good at. An array is rigid, with a fixed number of slots. A List<T> grows. A Dictionary<K,V> you reach into by label instead of position. A HashSet<T> quietly refuses duplicates. Same idea - hold many things - different trade-offs.

Arrays - the fixed-size box

What it actually is. An array is a fixed-length sequence of values, all the same type, laid out back-to-back in memory. "Fixed-length" is the whole personality: the size is set at creation and never changes. Want a fifth slot in a four-slot array? Make a new array.

int[] nums = { 1, 2, 3 };
Console.WriteLine(nums[0]);      // first element - C# counts from 0
Console.WriteLine(nums[2]);      // third element
Console.WriteLine(nums.Length);  // how many slots
1
3
3

What just happened: int[] nums = { 1, 2, 3 } declared an array of three integers and filled it in one go. You read an element by its index in square brackets, and like most languages C# counts from zero - nums[0] is the first item, nums[2] the third. nums.Length tells you the size. Reaching past the end (nums[3]) throws an IndexOutOfRangeException at runtime - no slot to read.

⚠️ Length is a property, not a method. It's nums.Length with no parentheses. (Confusingly, other collections below use .Count instead, and strings use .Length too - the naming isn't consistent.)

💡 When do you actually use an array? When the size is genuinely fixed and known up front - the seven days of a week, the RGB channels of a pixel, a lookup table that never grows. Want to add items? You've outgrown arrays.

The generic collections mental model

Before the growable containers, one idea that unlocks all of them: generics.

📝 Generics. The <T> in List<T> is a type parameter - a blank you fill in. List<int> is "a list of ints"; List<string> is "a list of strings." Collections in System.Collections.Generic are type-safe: a List<int> only ever holds ints, enforced by the compiler. Stuff a string in and your code won't compile.

This matters because of what came before generics. The old System.Collections namespace had types like ArrayList that held object - "anything." That sounds flexible, but it was a trap: everything you pulled out came back as a vague object you had to cast, value types got silently boxed (an extra allocation), and a string accidentally added to your list-of-numbers blew up only at runtime.

⚠️ Avoid the old non-generic collections. ArrayList, Hashtable, and friends still exist for backward compatibility, but in modern C# they're a code smell. Always reach for List<T>, Dictionary<K,V>, HashSet<T>. If you see ArrayList in a tutorial, that tutorial is old.

💡 Program to interfaces where it helps. The generic collections all implement a layered set of interfaces - names that describe behavior rather than a concrete type:

  • IEnumerable<T> - "you can foreach over me." The most general; promises nothing but iteration.
  • ICollection<T> - IEnumerable<T> plus a Count and the ability to Add/Remove.
  • IList<T> - ICollection<T> plus indexed access ([i]) and ordering.

You don't need these yet to use a list. But accepting the narrowest interface that does the job in a method makes it flexible: one that only loops should take IEnumerable<T>, so a caller can hand it a list, array, or anything iterable. We'll lean on this heavily once we hit LINQ.

List<T> - the growable box

What it actually is. A List<T> is an ordered, indexable, growable sequence - an array that handles its own resizing: Add items and it makes room; read and write by index just like an array. It's the collection you'll use more than all the others combined.

var fruits = new List<string> { "apple", "banana" };

fruits.Add("cherry");          // grows by one
fruits.Add("date");

Console.WriteLine(fruits.Count);   // how many - note Count, not Length
Console.WriteLine(fruits[1]);      // index like an array

foreach (var fruit in fruits)
{
    Console.WriteLine(fruit);
}
4
banana
apple
banana
cherry
date

What just happened: new List<string> { "apple", "banana" } used collection initializer syntax - the { ... } seeds the list with starting items, the same convenience the array got. Add appends to the end and the list grows itself, no size declared anywhere. Ask how many it holds with .Count (lists use Count; arrays used Length - the inconsistency is annoying). Read by index with fruits[1] like an array, and foreach walks every element in order - the iteration IEnumerable<T> promises, for free.

💡 foreach vs indexing. Use foreach to just visit every item (cleaner, no off-by-one risk). Use the indexer list[i] when you need the position - to modify a specific slot, or walk two lists in lockstep.

Dictionary<K,V> - the box you reach into by label

A list is perfect when you care about order and position. But often you want "the item labeled bob," not "the third item." That's a dictionary.

What it actually is. A Dictionary<K,V> stores key → value pairs and looks up a value instantly by its key, no scanning. Dictionary<string, int> reads as "keys are strings, values are ints" - usernames to scores, say. (Other languages call this a hash map, hash, or associative array.)

var scores = new Dictionary<string, int>
{
    ["alice"] = 50,
    ["bob"] = 30,
};

scores.Add("carol", 90);        // add a new pair
scores["alice"] = 75;           // indexer overwrites an existing key

Console.WriteLine(scores["bob"]);   // look up by key

// Safe lookup for a key that might not exist:
if (scores.TryGetValue("dave", out int daveScore))
    Console.WriteLine($"dave: {daveScore}");
else
    Console.WriteLine("dave not found");

// Iterate the pairs:
foreach (KeyValuePair<string, int> pair in scores)
{
    Console.WriteLine($"{pair.Key} = {pair.Value}");
}
30
dave not found
alice = 75
bob = 30
carol = 90

What just happened: We built a dictionary with initializer syntax (["alice"] = 50), then grew it two ways: Add("carol", 90) for a brand-new key, and scores["alice"] = 75 where the indexer overwrote the existing value. scores["bob"] returned its value instantly. TryGetValue("dave", ...) returned false with no crash, so we printed the fallback. foreach over a dictionary hands you each entry as a KeyValuePair<K,V>, with .Key and .Value on it.

⚠️ The indexer throws on a missing key. Reading scores["dave"] when dave isn't there does not return zero or null - it throws a KeyNotFoundException and stops your program. When a key might not exist, use TryGetValue (or ContainsKey first): the indexer is for keys you're certain are present, TryGetValue for keys you're hoping are present.

// ContainsKey is the other safe check - handy when you don't need the value yet:
if (scores.ContainsKey("alice"))
    Console.WriteLine("alice is on the board");
alice is on the board

What just happened: ContainsKey answers a plain yes/no without fetching the value. Prefer TryGetValue when you'll use the value right after (it does the lookup once); reach for ContainsKey when you only need the boolean. Both spare you the exception.

HashSet<T> - the box that refuses duplicates

The last container is the specialist. A HashSet<T> holds unique elements - add the same value twice and the second add is silently ignored - and answers "do you contain this?" very fast.

var seen = new HashSet<string>();

Console.WriteLine(seen.Add("apple"));   // true - newly added
Console.WriteLine(seen.Add("apple"));   // false - already present, ignored
seen.Add("banana");

Console.WriteLine(seen.Count);          // 2, not 3
Console.WriteLine(seen.Contains("banana"));  // fast membership test
True
False
2
True

What just happened: Add returns a bool telling you whether the value was actually new - true the first time "apple" went in, false the second time since the set already had it, so the count stayed at 2. Contains checks membership quickly, without scanning every element the way a List's Contains would.

💡 Pick the right box. The takeaway that makes everything above click:

  • List<T> - you need order and access by position; duplicates are fine. The default workhorse.
  • Dictionary<K,V> - you need fast lookup by a key rather than by position.
  • HashSet<T> - you need uniqueness and fast "is it in here?" checks; order and position don't matter.
  • T[] (array) - the size is genuinely fixed and known up front.

Underneath all four sits IEnumerable<T> - every one is iterable, why a single foreach works on all of them. That same interface is the foundation of LINQ, C#'s query toolkit, where you'll filter and transform any collection with the same handful of operators. More in Phase 12.

Recap

  1. An array (int[]) is fixed-size and indexed from 0; ask its size with .Length. Use it only when the count is genuinely fixed.
  2. Generics (List<T>, the <T>) make collections type-safe - a List<int> holds only ints, checked at compile time. Avoid the old non-generic ArrayList/Hashtable.
  3. List<T> is the growable, ordered, indexable workhorse: Add, [i], .Count, and foreach. It's what you reach for most.
  4. Dictionary<K,V> maps keys to values for instant lookup; ⚠️ the indexer throws on a missing key, so use TryGetValue or ContainsKey when a key might be absent. Iterate it as KeyValuePair<K,V>.
  5. HashSet<T> keeps elements unique and tests membership fast; Add returns false when the value was already present.
  6. Pick by how you read data back out - position (List), key (Dictionary), uniqueness (HashSet), fixed count (array) - and remember all of them are IEnumerable<T>, the foundation LINQ builds on.

Next, we put these collections to work: control flow and methods - the if, switch, and loops that make decisions, and how to package logic into reusable methods.

Quick check

Test yourself on the choices that matter - which box to pick, and the dictionary trap:

[
  {
    "q": "You need to store users keyed by their unique ID and look one up instantly by that ID. Which collection fits best?",
    "choices": [
      "An array (T[])",
      "A List<T>",
      "A Dictionary<K,V>",
      "A HashSet<T>"
    ],
    "answer": 2,
    "explain": "A Dictionary<K,V> maps keys to values and looks up a value instantly by its key. A List would force you to scan every element to find the matching ID; a dictionary jumps straight to it."
  },
  {
    "q": "What happens when you read `scores[\"dave\"]` from a Dictionary<string,int> and the key \"dave\" doesn't exist?",
    "choices": [
      "It returns 0, the default for int",
      "It returns null",
      "It throws a KeyNotFoundException and stops the program",
      "It silently adds \"dave\" with value 0"
    ],
    "answer": 2,
    "explain": "The dictionary indexer throws KeyNotFoundException on a missing key - it does not return a default or add the key. Use TryGetValue or ContainsKey when a key might be absent."
  },
  {
    "q": "Why prefer the generic `List<T>` over the old non-generic `ArrayList`?",
    "choices": [
      "List<T> is type-safe - the compiler guarantees it holds only one type, catching mistakes before runtime",
      "ArrayList cannot grow, but List<T> can",
      "List<T> is the only one you can foreach over",
      "There is no real difference; they are interchangeable"
    ],
    "answer": 0,
    "explain": "ArrayList holds object (anything), so type errors surface only at runtime and value types get boxed. List<T> is type-safe: a List<int> holds only ints, enforced by the compiler. Both can grow and both are iterable."
  }
]

Before the quiz: without looking back, say (or jot down) the core idea of this phase in your own words.

Check your understanding 3 questions

1. You need to store users keyed by their unique ID and look one up instantly by that ID. Which collection fits best?

2. What happens when you read `scores["dave"]` from a Dictionary<string,int> and the key "dave" doesn't exist?

3. Why prefer the generic `List<T>` over the old non-generic `ArrayList`?