Lesson 9 • Intermediate Track
Collections
By the end of this lesson you'll be able to store many values in one place and pick the right tool for the job — a growable List<T> for ordered items, a Dictionary<K,V> for instant lookups by name, a HashSet<T> for unique values, and Stack/Queue for ordered processing.
What You'll Learn
- Understand why a List<T> beats a fixed-size array when the count can change
- Create, add to, search, sort and remove from a List<T>
- Store and look up key-value pairs with Dictionary<K,V> (safely, with TryGetValue)
- Keep only unique values and do set maths with HashSet<T>
- Use Stack<T> (LIFO) and Queue<T> (FIFO) for ordered processing
- Choose the right collection for any situation — and avoid the common crashes
List<T>, and walking a sequence with foreach — collections lean on all three. Every collection in this lesson lives in System.Collections.Generic, so you'll need using System.Collections.Generic; at the top of your file.💡 Real-World Analogy
Think of collections as different containers in your kitchen. A List is a numbered shelf — items sit in order and you can grab one by its position. A Dictionary is a labelled spice rack — you find "cumin" instantly by name, never by counting jars. A HashSet is a bowl of unique fridge magnets — adding a duplicate changes nothing. A Stack is a pile of plates — you always take from the top (last on, first off). A Queue is the checkout line — first person in is the first served. Picking the right container is half the job; the rest is just Add, Remove, and looking things up.
📊 Which Collection Should I Use?
| Collection | Order | Duplicates | Find an item by… | Best for |
|---|---|---|---|---|
| array (int[]) | ✅ Index | ✅ Yes | Position | Fixed, known size |
| List<T> | ✅ Index | ✅ Yes | Position | Growable ordered lists |
| Dictionary<K,V> | ❌ No | Unique keys | Key | Fast lookup by name/ID |
| HashSet<T> | ❌ No | ❌ No | Membership | Unique items, set maths |
| Stack<T> | LIFO | ✅ Yes | Top only | Undo, backtracking |
| Queue<T> | FIFO | ✅ Yes | Front only | Task queues, BFS |
Rule of thumb: reach for List<T> by default, switch to Dictionary<K,V> the moment you find yourself searching by a name or ID, and use HashSet<T> when duplicates would be a bug.
1. Arrays vs List<T>
An array (string[]) has a fixed length — you set its size once and it can never grow or shrink. That's perfect when the count is known and stable. But most of the time you don't know how many items you'll have, so you reach for a List. A List<T> is a resizable array: it starts empty and grows automatically every time you .Add(...). The <T> is the type it holds — List<string> holds strings, List<int> holds whole numbers. Read this worked example first, then you'll build one yourself.
Worked example: array vs List<T>
See a fixed array next to a growable List — read every comment and check the output.
using System;
using System.Collections.Generic; // <-- List, Dictionary, HashSet live here
class Program
{
static void Main()
{
// An ARRAY is fixed-size. You decide its length up front and it
// NEVER changes. Good when you know exactly how many items there are.
string[] weekdays = { "Mon", "Tue", "Wed", "Thu", "Fri" };
Console.WriteLine($"Array length: {weekdays.Length}"); // Array length: 5
Console.WriteLine($"Third day: {weekdays[2]}");
...2. Working with List<T>
A List<T> gives you a handful of methods you'll use constantly: Add appends an item, Remove deletes the first matching value, Contains answers a yes/no membership question, Count tells you how many items there are, Sort reorders them, and list[i] reads or writes the item at position i (remember, the first item is index 0). The cleanest way to visit every item is a foreach. Read the worked example, then finish the one below it.
Worked example: Add, Remove, Contains, Sort
Create a list of fruits, add and remove items, search it, and sort it.
using System;
using System.Collections.Generic;
class Program
{
static void Main()
{
// Create a List<string> and fill it one item at a time.
List<string> fruits = new List<string>();
fruits.Add("Apple");
fruits.Add("Banana");
fruits.Add("Cherry");
fruits.Add("Date");
// foreach walks every item, in order, no index needed.
Console.WriteLine("All fruits:");
foreach (string fruit in fruits)
Console.WriteL
...Your turn. The shopping-list program below is almost complete — fill in the three ___ blanks using the hints in the comments, then run it and check the output.
🎯 Your turn: build a shopping List<string>
Create the list, add items with .Add(...), and print them with a foreach.
using System;
using System.Collections.Generic;
class Program
{
static void Main()
{
// 🎯 YOUR TURN — fill in each ___ then press "Try it Yourself".
// Goal: build a shopping list, add three items, and print them all.
// 1) Create an empty List that holds strings
List<string> shopping = ___; // 👉 new List<string>()
// 2) Add three items with .Add(...)
shopping.Add("Milk");
shopping.___("Bread"); // 👉 the me
...3. Dictionary<TKey, TValue>
A Dictionary stores key → value pairs, and looking a value up by its key is almost instant (on average O(1) — it doesn't slow down as the dictionary grows). Use one whenever you find data by a unique identifier: a username, a product code, a setting name. Add or overwrite with dict[key] = value, and read with dict[key] — but only if you're certain the key exists. The safe way to read is TryGetValue, which returns false instead of crashing when the key is missing.
Worked example: Dictionary lookups (safely)
Store name → age pairs, look them up, and iterate every KeyValuePair.
using System;
using System.Collections.Generic;
class Program
{
static void Main()
{
// A Dictionary maps a KEY to a VALUE. Here: name (string) -> age (int).
// You can fill it inline with { key, value } pairs.
Dictionary<string, int> ages = new Dictionary<string, int>
{
{ "Alice", 30 },
{ "Bob", 25 },
{ "Charlie", 35 }
};
// Add a new entry with key indexing. dict[key] = value.
ages["Diana"] =
...Now you try. The stock-tracker below maps a product name to how many you have in stock. Fill in the three ___ blanks, then run it.
🎯 Your turn: build a Dictionary<string,int>
Create the dictionary, add products with key indexing, then look one up.
using System;
using System.Collections.Generic;
class Program
{
static void Main()
{
// 🎯 YOUR TURN — fill in each ___ then run it.
// Goal: store stock counts by product name, then look one up.
// 1) Create an empty Dictionary: string key -> int value
Dictionary<string, int> stock = ___; // 👉 new Dictionary<string, int>()
// 2) Add three products with key indexing (stock["name"] = number)
stock["Apples"] = 12;
stock["Banana
...4. HashSet<T> — Unique Values
A HashSet<T> holds only unique values — try to add a duplicate and it's quietly ignored, so the set never contains the same item twice. That makes it ideal for tags, permission lists, and stripping duplicates out of data. It also does proper set maths: UnionWith (everything in either set), IntersectWith (only what's in both), and ExceptWith (remove the overlap). Membership tests with Contains are very fast — much faster than searching a List for big collections.
Worked example: HashSet — unique items & set maths
Watch a duplicate get ignored, then combine sets with Union and Intersect.
using System;
using System.Collections.Generic;
class Program
{
static void Main()
{
// A HashSet holds UNIQUE elements only — duplicates are silently ignored.
HashSet<string> tags = new HashSet<string>();
tags.Add("csharp");
tags.Add("dotnet");
tags.Add("programming");
tags.Add("csharp"); // duplicate — not added a second time
Console.WriteLine($"Tags count: {tags.Count}"); // 3, not 4
Console.WriteLine("\nAll tags:");
...5. Stack<T> and Queue<T>
These two control the order in which you take things out. A Stack is LIFO — Last In, First Out — like a stack of plates or a browser's "back" history: Push adds to the top, Pop removes the top, Peek looks without removing. A Queue is FIFO — First In, First Out — like a checkout line or a print queue: Enqueue joins the back, Dequeue serves the front. Whenever order-of-processing matters, one of these two is exactly what you want.
Worked example: Stack (LIFO) vs Queue (FIFO)
Compare Push/Pop against Enqueue/Dequeue and watch the order each one serves.
using System;
using System.Collections.Generic;
class Program
{
static void Main()
{
// Stack = Last In, First Out (LIFO) — like a pile of plates.
Stack<string> plates = new Stack<string>();
plates.Push("Red plate");
plates.Push("Blue plate");
plates.Push("Green plate"); // <-- last one on top
Console.WriteLine("=== Stack (LIFO) ===");
Console.WriteLine($"Top plate: {plates.Peek()}"); // Peek = look, don't remove
Consol
...🔎 Deep Dive: don't change a collection while you foreach it
This is the single most common collection bug. Adding to or removing from a list inside a foreach over that same list throws InvalidOperationException: Collection was modified. The loop relies on the collection staying still; you pulled the rug out from under it.
// ❌ Throws "Collection was modified" mid-loop
foreach (int n in numbers)
if (n < 0) numbers.Remove(n);
// ✅ Fix 1 — loop over a COPY, modify the original
foreach (int n in numbers.ToList())
if (n < 0) numbers.Remove(n);
// ✅ Fix 2 — one-liner that removes all matches
numbers.RemoveAll(n => n < 0);RemoveAll (with a small condition called a lambda) is usually the cleanest fix when you just want to drop everything that matches a rule.
Putting It Together: a Word Counter
Here's a small but genuinely useful program that combines this lesson's ideas — an array of words from Split, and a Dictionary<string,int> that counts how many times each word appears. The ContainsKey check is the heart of it: seen the word before? add one; first time? start at one. You understand every line now.
Worked example: count words in a sentence
Change the sentence and watch the per-word counts update.
using System;
using System.Collections.Generic;
class Program
{
static void Main()
{
// === Word frequency counter — List + Dictionary working together ===
string sentence = "the cat sat on the mat the cat";
// Split the sentence into words on each space -> a string array.
string[] words = sentence.Split(' ');
// The dictionary maps each word to how many times we've seen it.
Dictionary<string, int> counts = new Dictionary<string, int>();
...This "count occurrences" pattern is everywhere — tallying votes, log levels, inventory, survey answers. Learn it once and you'll reuse it constantly.
Pro Tips
- 💡 Default to
List<T>: use a plain array only when the size is fixed and you want the tiny performance edge. - 💡 Always read dictionaries with
TryGetValue:dict[key]throws if the key is missing;TryGetValuenever crashes. - 💡 Use
HashSet<T>for fast membership: checkingset.Contains(x)stays fast even with millions of items;List.Containsslows right down. - 💡 Never add/remove inside a
foreachover the same collection — loop a copy with.ToList()or useRemoveAll(...). - 💡 Stack for "most recent", Queue for "fairest order": undo wants the last action first; a task queue wants the oldest task first.
Common Errors (and the fix)
- "System.Collections.Generic.KeyNotFoundException": you read
dict["Eve"]but that key doesn't exist. Check first withContainsKey("Eve"), or read safely withif (dict.TryGetValue("Eve", out int v)). - "System.ArgumentException: An item with the same key has already been added": you used
dict.Add(key, ...)for a key that's already there. Usedict[key] = valueto overwrite instead, or guard withif (!dict.ContainsKey(key)). - "System.IndexOutOfRangeException" / "ArgumentOutOfRangeException": you indexed past the end — e.g.
list[3]on a list of 3 items (valid indexes are 0, 1, 2). Remember indexes start at 0 and the last one islist.Count - 1. - "System.InvalidOperationException: Collection was modified": you added to or removed from a collection inside a
foreachover it. Loop a copy (foreach (var x in list.ToList())) or uselist.RemoveAll(...). - "CS0246: The type or namespace name 'List<>' could not be found": you forgot the import. Add
using System.Collections.Generic;at the top — it's whereList,Dictionary,HashSet,StackandQueueall live.
📋 Quick Reference
| Task | Code | Result |
|---|---|---|
| New list | new List<string>() | empty list |
| Add an item | list.Add("x") | appends "x" |
| Read by index | list[0] | first item |
| How many? | list.Count | item count |
| Contains? | list.Contains("x") | true / false |
| Add to dict | dict["k"] = 5 | sets/overwrites |
| Safe lookup | dict.TryGetValue("k", out v) | true / false |
| Key exists? | dict.ContainsKey("k") | true / false |
| Stack add / take | Push(x) / Pop() | LIFO |
| Queue add / take | Enqueue(x) / Dequeue() | FIFO |
Frequently Asked Questions
Q: When should I use an array instead of a List<T>?
Use an array only when the number of items is fixed and known up front (e.g. the 7 days of the week, or a chess board's 64 squares). For everything that grows, shrinks, or has an unknown size, use a List<T> — it does everything an array does plus Add/Remove.
Q: How is a Dictionary different from a List?
A List finds items by their numeric position (list[2]). A Dictionary finds them by a meaningful key (ages["Alice"]) and that lookup is near-instant regardless of size. If you ever loop a list just to find the item with a matching name or ID, a dictionary will be faster and clearer.
Q: Why did adding a duplicate to my HashSet do nothing?
That's the whole point of a set — it holds each value only once. Add returns false when the value is already present and the count doesn't change. If you need to keep duplicates, use a List<T> instead.
Q: What's the practical difference between a Stack and a Queue?
A Stack hands back the most recently added item (LIFO) — great for undo and backtracking. A Queue hands back the oldest item (FIFO) — great for processing things fairly in arrival order, like jobs or messages.
Mini-Challenge: Count the Words
No blanks this time — just a brief and an outline to keep you on track. Build a Dictionary<string,int> that counts how many times each word appears in a sentence, then print each word with its count. Run it and check your output against the expected lines in the comments.
🎯 Mini-Challenge: word-frequency counter
Split the sentence, tally each word in a Dictionary, and print the counts.
using System;
using System.Collections.Generic;
class Program
{
static void Main()
{
// 🎯 MINI-CHALLENGE: Count how often each word appears
// 1. Start from this sentence:
// string sentence = "red blue red green blue red";
// 2. Split it into words with sentence.Split(' ').
// 3. Make a Dictionary<string, int> called "tally".
// 4. foreach over the words. If tally already ContainsKey(word),
// add 1 to tally[word]; otherw
...🎉 Lesson Complete
- ✅ A
List<T>is a growable array —Add,Remove,Contains,Count,Sort, and index withlist[i] - ✅ A
Dictionary<K,V>maps keys to values with near-instant lookup — read safely usingTryGetValue - ✅ A
HashSet<T>keeps only unique values and does set maths (UnionWith,IntersectWith) - ✅
Stack<T>is LIFO (Push/Pop);Queue<T>is FIFO (Enqueue/Dequeue) - ✅ Indexes start at 0; reading a missing key or past the end throws — guard with
ContainsKey/TryGetValue - ✅ Never add or remove inside a
foreachover the same collection - ✅ Everything lives in
System.Collections.Generic— remember theusing - ✅ Next lesson: Exception Handling — catch and recover from errors gracefully with
try/catch
Sign up for free to track which lessons you've completed and get learning reminders.