C# Programming · Lesson 6

Lining Up Your Ducks with Collections

Store, traverse, sort, and combine data with C# arrays, List<T>, dictionaries, initializers, and HashSet<T> set operations.

  • csharp
  • arrays
  • collections
  • list
  • dictionary
  • hashset
  • foreach

Lesson purpose

Lesson 6 moves from single-value variables to variables that hold many values. Students learn the two species of collection: the array, a fixed-length typed sequence, and the more flexible collection classes such as List<T>, Dictionary<TKey, TValue>, and HashSet<T>. The lesson covers creating, filling, traversing, sorting, searching, converting, and initializing these structures, and it shows how sets combine and compare groups of values. The organizing idea: the right collection makes the program’s access pattern visible in its type.

Learning objectives

  • Explain why arrays replace long lists of individually named variables.
  • Declare fixed-size and runtime-sized arrays, index them from zero, and use Length safely.
  • Traverse arrays and collections with for and foreach, and describe the enumerator behind foreach.
  • Swap elements, sort with Array.Sort, and trace a bubble sort.
  • Use var with array initializers.
  • State the two main limitations of arrays and the traits arrays share with collections.
  • Choose among List<T>, LinkedList<T>, Queue<T>, Stack<T>, Dictionary<TKey, TValue>, and HashSet<T>.
  • Use common List<T> and dictionary members, and convert between lists and arrays.
  • Write array, list, dictionary, and set initializers.
  • Use HashSet<T> for union, intersection, difference, and symmetric difference.

1. Two kinds of collections

Simple variables fall short when a program must track many items of the same kind: ten ducks instead of one. C# provides two kinds of variables that store multiple items:

  • An array holds a list of objects of the same type. One array cannot mix int and double elements.
  • A collection class (flexible lists, queues, stacks, dictionaries, sets) adds behavior such as growing, shrinking, and lookup by key.

Terminology used in the course: “array” means an array, “collection class” means one of the classes, and “collection” or “list” can mean either. A few older collection classes can hold mixed types, but modern generic collections are the classroom standard. Mastering arrays and List<T> covers most of the course.

2. Why arrays exist

Averaging six numbers with six variables is manageable:

double d0 = 5, d1 = 2, d2 = 7, d3 = 3.5, d4 = 6.5, d5 = 8;
double sum = d0 + d1 + d2 + d3 + d4 + d5;
double average = sum / 6;

Averaging 600 or 6 million values that way is impossible. An array stores the whole sequence in one variable and lets an index, which can itself be a variable, select an element.

double[] doublesArray = { 5, 2, 7, 3.5, 6.5, 8, 1, 9, 1, 3 };
double[] blank = new double[6];   // six slots, each starting at 0

Talking points:

  • Brackets: double[] declares an array of double; doublesArray[0] reads one element.
  • Zero-based index: the first element is index 0 and the last is Length - 1. The element numbers are the index; the book calls a single element such as doublesArray[0] a subarray.
  • Fixed length: the number of slots is chosen when the array is created.
  • Default values: new double[6] allocates six slots, each holding 0 until assigned.

Separate three ideas that students blur together: the array object, its elements, and the indexes.

Quick check: what is the last valid index of a six-element array?

Preview: ranges and from-end indexes

Newer C# supports doublesArray[0..3] (elements at indexes 0, 1, and 2; the end index is excluded) and doublesArray[^1] (the last element). [^0] is past the end and throws an index-out-of-range exception. The book describes project-file edits and a downloaded file to enable this on older frameworks; current .NET supports ranges and indexes directly.

3. Looping through an array

The FixedArrayAverage program:

double[] doublesArray = { 5, 2, 7, 3.5, 6.5, 8, 1, 9, 1, 3 };

double sum = 0;
for (int i = 0; i < doublesArray.Length; i++)
{
    sum = sum + doublesArray[i];
}

double average = sum / doublesArray.Length;
Console.WriteLine(average);   // 4.6
  • sum starts at 0 and accumulates each element.
  • The loop condition uses doublesArray.Length, not the literal 10. If the array changes size, a hard-coded 10 gives a wrong average or throws System.IndexOutOfRangeException. Always get the size from Length.

4. Runtime-sized arrays

FixedArrayAverage has two problems: its size is fixed at ten, and its values are written into the program. VariableArrayAverage lets the user choose how many values to enter:

Console.Write("Enter the number of values to average: ");
int numElements = Convert.ToInt32(Console.ReadLine());

double[] doublesArray = new double[numElements];

for (int i = 0; i < numElements; i++)
{
    Console.Write("enter double #" + (i + 1) + ": ");
    string val = Console.ReadLine() ?? "";

    if (double.TryParse(val, out double value))
    {
        doublesArray[i] = value;
    }
    else
    {
        Console.WriteLine($"Attempted to input {val}, using 0 instead.");
        doublesArray[i] = 0;
    }
}

double sum = 0;
for (int i = 0; i < doublesArray.Length; i++)
{
    sum += doublesArray[i];
}

Console.Write(sum / numElements + " is the average of (" + doublesArray[0]);
for (int i = 1; i < doublesArray.Length; i++)
{
    Console.Write(" + " + doublesArray[i]);
}
Console.WriteLine(") / " + doublesArray.Length);

Talking points:

  • Choose length: input decides how many slots new double[numElements] creates.
  • Still fixed afterward: “variable-length” means the size is chosen at run time, not that the array can grow later.
  • Fill safely: double.TryParse stores a valid number or substitutes 0. Substituting a default is appropriate when the program cannot ask for a retry, such as when reading from a database. Always tell the user a substitution happened.
  • Formatting output: the first element prints before the loop, then each later element prints with a leading “ + “. Trace it character by character.

Sample: entering X, 5, 2, !, 9 prints 3.2 is the average of (0 + 5 + 2 + 0 + 9) / 5. The invalid entries are not ignored; they are stored as 0 and still count toward the divisor.

Two ways to give an array values:

double[] initializedArray = { 5, 2, 7, 3.5, 6.5, 8, 1, 9, 1, 3 };
double[] blankArray = new double[10];   // must still assign each value

Quick check: with X, 5, 2, !, 9, why is the average 3.2 rather than 5.33?

5. Processing arrays with foreach

A for loop over an array has several places to go wrong: the start index, the comparison, and the bound. The foreach loop removes that guesswork by visiting every element in order:

string[] myStrings = { "Hello", "Goodbye", "Today", "Tomorrow" };
int sum = 0;

foreach (string thisString in myStrings)
{
    Console.WriteLine($"The current string is: {thisString}.");
    Console.WriteLine($"It's {thisString.Length} characters long.");
    sum += thisString.Length;
}

double average = (double)sum / myStrings.Length;
Console.WriteLine($"The average character length is: {average}.");   // 6.25
  • Source: myStrings supplies the sequence.
  • Iteration variable: thisString receives one element per pass.
  • No index: the body never touches a length or position.
  • Numeric cast: (double)sum prevents integer division.

foreach works on every collection type, not only arrays, and also handles multidimensional arrays. Prefer it when every element is visited and position does not matter; keep for when the index itself is needed, such as when writing into the array or comparing neighbors.

The enumerator behind foreach

A collection supplies an enumerator through GetEnumerator(). The enumerator is a stateful cursor:

  • MoveNext() advances to the next element and reports whether one exists.
  • Current exposes the element at the cursor.

An enumerator lets code iterate a collection without knowing how the collection stores items, can iterate part of a collection by starting partway through, and supports late (deferred) execution. The book’s advanced UsingEnumeratedForeach example moves a list enumerator to “Hello” with MoveNext() and then runs foreach on the enumerator itself, so processing begins with “Goodbye” and the average becomes 6.67. Use this example only to expose the protocol; ordinary code uses foreach directly. Lesson 7 covers enumerators and iterators fully.

Quick check: which enumerator member answers “is there another item?”

6. Swapping and sorting

An array cannot change length, but its elements can be rearranged. Swapping two elements needs a temporary variable so neither value is lost:

string temp = strings[i];
strings[i] = strings[k];
strings[k] = temp;

For strings and other objects, the array slots hold references, so a swap exchanges references.

Array.Sort sorts an array in place. To keep the original order, sort a real copy:

string[] planets = { "Mercury", "Venus", "Earth", "Mars", "Jupiter" };

string[] sortedNames = (string[])planets.Clone();
Array.Sort(sortedNames);

foreach (string planet in sortedNames)
{
    Console.WriteLine("\t" + planet);   // Earth, Jupiter, Mars, Mercury, Venus
}

Correction to the book: its BubbleSortArray example writes string[] sortedNames = planets; and claims this copies the array. Assignment copies only the reference, so both variables name the same array and sorting one “sorts” both. Clone() creates a distinct array.

Bubble sort

Array.Sort sorts strings alphabetically, not by length. The book’s custom bubble sort orders the names by length:

for (int outer = planets.Length - 1; outer >= 0; outer--)
{
    for (int inner = 1; inner <= outer; inner++)
    {
        if (planets[inner - 1].Length > planets[inner].Length)
        {
            string temp = planets[inner - 1];
            planets[inner - 1] = planets[inner];
            planets[inner] = temp;
        }
    }
}
  • Adjacent comparison: compare neighboring name lengths.
  • Swap: exchange out-of-order neighbors with temp.
  • Nested passes: each outer pass moves the next-longest name to the end of the unsorted region, so shorter names “bubble” toward the front.
  • Tradeoff: bubble sort is simple and fine for tiny arrays but performs poorly on large ones. Use the built-in sort unless there is a real reason not to.

Result by length: Mars, Earth, Venus, Jupiter, Mercury. The \t escape inserts a tab and \n a blank line in the output.

Quick check: why does bubble sort need nested loops?

7. Using var for arrays

The traditional array forms:

Form Code Notes
Size only new int[3] Three default zeros; size can come from a variable.
Values only new int[] { 1, 2, 3 } The compiler counts the initializer.
Size and values new int[3] { 1, 2, 3 } Size must match the values.
Short form int[] numbers = { 1, 2, 3 }; Concise; only in a declaration.

var works with arrays when an initializer lets the compiler infer the element type:

var myArray = new[] { 2, 3, 5, 7, 11, 13 };        // int[]
var names = new[] { "John", "Paul", "George", "Ringo" };   // string[]
var averages = new[] { 3.0, 3.34, 4.0, 2.0, 1.8 };  // double[]
  • var replaces the explicit type on the left, and int is omitted before [] on the right.
  • The initializer is required, and all values must share or convert to one best type.
  • The short form cannot follow var: var names = { "John", "Paul" }; does not compile.
  • var is still strongly typed; the inferred int[] can never hold strings.

Quick check: what type is inferred for new[] { 3.0, 4.5 }?

8. Why collection classes

Arrays are simple, support multiple dimensions, and appear throughout the .NET class library, but they have two important limitations:

  • The program must declare the size of an array when it is created, and C# cannot resize it afterward.
  • Inserting or removing an element in the middle is inefficient because the other elements must move.

Most collections make adding, inserting, and removing easy, and they resize automatically. Use an array when a multidimensional structure is needed; no collection class provides multiple dimensions.

Arrays and collections share these traits:

  • Each holds elements of one type, fixed at compile time.
  • Most can be accessed with square-bracket index syntax, such as myList[3] = "Joe".
  • Both have methods and properties. Arrays report size with Length; collections use Count.
var smallPrimeNumbers = new[] { 2, 3, 5, 7, 11, 13 };
int arrayCount = smallPrimeNumbers.Length;   // 6

List<int> smallPrimes = new List<int> { 2, 3, 5, 7, 11, 13 };
int listCount = smallPrimes.Count;           // 6

9. Collection shapes and <T>

Class Shape
List<T> A dynamic array of T objects.
LinkedList<T> A linked list of T objects.
Queue<T> First in, first out: join at the back, leave from the front.
Stack<T> Add and remove at the top, like cafeteria trays.
Dictionary<TKey, TValue> Look up a key and retrieve its value.
HashSet<T> A mathematical set with no duplicates and set operations.

<T> is a placeholder for a type. Instantiate (create an instance) by supplying a real type:

List<int> intList = new List<int>();

T is only a convention; T, U, and V are common. Dictionary<TKey, TValue> needs two types. Because a type is filled into a template, these are generic collections; Lesson 8 explains generics in depth.

Choose by access pattern, not habit:

Need Choose
Fixed number of meaningful positions Array
Ordered items that grow and shrink List<T>
Direct lookup by a unique key Dictionary<TKey, TValue>
Uniqueness and membership questions HashSet<T>

10. Using lists

An array of 50 MP3 objects runs out of room at the 51st and leaves a gap when one is removed. A list grows and heals itself:

List<MP3> myMP3s = new List<MP3>();
myMP3s.Add(new MP3("Avril Lavigne"));

(MP3 is a class like those in Part 2.) If a list or array can contain null items, check for null inside the loop before calling a method on an element.

Type safety

List<string> nameList = new List<string>();
nameList.Add("one");
// nameList.Add(3);      // compiler error
// nameList.Add(true);   // compiler error

The list was instantiated for string, so the compiler rejects other types at build time. T is the type parameter; List<string> is a parameterized type.

List<int> intList = new List<int>();
intList.Add(3);
intList.Add(4);

foreach (int i in intList)
{
    Console.WriteLine("int i = " + i);
}

Converting between lists and arrays

string[] myStrings = { "Hello", "Goodbye", "Today", "Tomorrow" };
List<string> myStringList = new List<string>();
myStringList.AddRange(myStrings);                // array into list
string[] newStringArray = myStringList.ToArray(); // list into a new array

Searching and other list tasks

List<string> words = new List<string> { "Hello", "Goodbye", "Today", "Tomorrow" };

Console.WriteLine(words[3]);                 // Tomorrow
if (words.Contains("Goodbye"))
{
    Console.WriteLine("Goodbye appears in the list.");
}
int where = words.IndexOf("Today");          // 2, or -1 if absent

words.Sort();
words.Insert(3, "Yellow");
words.RemoveAt(3);
Task Member Caution
Append Add, AddRange Count increases.
Find Contains, IndexOf, BinarySearch IndexOf can return -1; BinarySearch needs a sorted list.
Place Insert Later items shift.
Remove RemoveAt Validate the index.
Convert ToArray Creates a separate copy.

Quick check: at what stage is nameList.Add(3) rejected? Which property reports how many items a list holds?

11. Using dictionaries

A dictionary pairs each key with a value, like a word and its definition. TKey is the key type and TValue the value type; both can be any type.

Dictionary<string, string> dict = new Dictionary<string, string>();
dict.Add("C#", "cool");
dict.Add("VB", "a simple but wordy language");
dict.Add("Fortran", "ancient");

Console.WriteLine(dict.ContainsKey("VB"));     // True
Console.WriteLine(dict.ContainsKey("Ruby"));   // False
Console.WriteLine(dict["Fortran"]);            // ancient
  • Add requires both a key and a value. Keys must be unique; adding an existing key throws an exception.
  • ContainsKey (and ContainsValue) checks membership before a lookup, preventing a missing-key failure.
  • Dictionary pairs are in no particular order and a dictionary cannot be sorted. Choose a dictionary for direct lookup by key, not for ordering.

Iterating yields KeyValuePair<TKey, TValue> objects:

foreach (KeyValuePair<string, string> pair in dict)
{
    Console.WriteLine("Key: " + pair.Key.PadRight(8) + "Value: " + pair.Value);
}

foreach (string key in dict.Keys)
{
    Console.WriteLine("Key: " + key);
}

foreach (string value in dict.Values)
{
    Console.WriteLine("Value: " + value);
}

Console.WriteLine("Number of items: " + dict.Count);

Keys and Values return their own collections (KeyCollection and ValueCollection) in matching order. Because the keys are strings, string methods such as PadRight work on them.

Quick check: which structure fits “student ID → student record”? What happens if Add repeats a key?

12. Array and collection initializers

int[] numbers = { 1, 2, 3 };                 // short form; cannot use var
var numbers2 = new[] { 1, 2, 3 };            // var requires new[]

The older way to fill a collection:

List<int> numList = new List<int>();
numList.Add(1);
numList.Add(2);
numList.Add(3);

List<int> copyList = new List<int>(numbers);  // from an array
List<int> copyList2 = new List<int>(numList); // from another collection
numList.AddRange(numbers);

Since C# 3.0, a collection initializer constructs and fills in one expression. Unlike an array initializer, it must name the collection type after new:

List<int> numList = new List<int> { 1, 2, 3 };
var list = new List<string> { "Head", "Heart", "Hands", "Health" };

Dictionary<int, string> ids =
    new Dictionary<int, string> { { 1, "Sam" }, { 2, "Joe" } };

HashSet<int> biggerPrimes = new HashSet<int> { 19, 23, 29, 31, 37, 41 };
  • Dictionary initializers use an inner pair of braces per entry: key, comma, value.
  • In a set initializer, duplicate values collapse into one member.
  • The book also shows dynamic list = new List<string> { … };. A dynamic variable skips compile-time type checking and resolves members at run time, whereas var is inferred at compile time. Keep dynamic as a vocabulary preview; the course uses explicit types or var.

13. Using sets

A set is an unordered collection with no duplicate items, borrowed from mathematics: the odd numbers {1, 3, 5, 7, 9}, the days of the week. C# sets are finite and limited by memory. Beyond add, delete, and find, sets support:

  • Union: join the members of two sets into one.
  • Intersection: keep only the members found in both.
  • Difference: keep the members of one set that do not appear in the other.

Use HashSet<T> whenever two or more collections must be combined, compared for overlap, or used to exclude a group of items.

HashSet<int> smallPrimes = new HashSet<int> { 2, 3, 5, 7, 11, 13 };

List<int> intList = new List<int> { 0, 1, 2, 3, 4, 5, 6, 7 };
HashSet<int> numbers = new HashSet<int>(intList);   // from any list-like collection

bool added = smallPrimes.Add(2);   // false: already present, set unchanged

Adding a duplicate is not an error; Add returns false and the set does not change.

Union: combine without duplicates

List<string> colors = new List<string> { "red", "orange", "yellow" };
string[] moreColors = { "orange", "yellow", "green", "blue", "violet" };

HashSet<string> combined = new HashSet<string>(colors);
combined.UnionWith(moreColors);
// red, orange, yellow, green, blue, violet

List<string> spectrum = new List<string>(combined);   // back to a list

colors.AddRange(moreColors) would keep the duplicate orange and yellow; UnionWith screens them out.

Intersection: find the overlap

List<string> candidates = new List<string>
    { "Clinton", "Edwards", "Giuliani", "McCain", "Obama", "Romney" };
List<string> senators = new List<string>
    { "Alexander", "Boxer", "Clinton", "McCain", "Obama", "Snowe" };

HashSet<string> senatorsRunning = new HashSet<string>(candidates);
senatorsRunning.IntersectWith(senators);   // Clinton, McCain, Obama

Symmetric difference: only the non-overlapping items

Stack<int> stackOne = new Stack<int>(new[] { 1, 2, 3, 4, 5, 6, 7, 8 });
Stack<int> stackTwo = new Stack<int>(new[] { 2, 4, 6, 7, 8, 10, 12 });

HashSet<int> nonoverlapping = new HashSet<int>(stackOne);
nonoverlapping.SymmetricExceptWith(stackTwo);   // 1, 3, 5, 10, 12

The book fills these stacks all at once for convenience; normal stack use pushes and pops one item at a time.

Difference: exclude items

Queue<int> queue = new Queue<int>(new[] { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 17 });
HashSet<int> unique = new HashSet<int> { 1, 3, 5, 7, 9, 11, 13, 15 };

unique.ExceptWith(queue);   // 11, 13, 15

ExceptWith removes from unique every value that also appears in queue (1, 3, 5, 7, 9). Values that were only in queue were never in unique, so they are not added.

Important: these …With methods are void; they change the set they are called on. Copy first when the original must survive:

HashSet<string> warm = new() { "red", "orange", "yellow" };
HashSet<string> overlap = new(warm);
overlap.IntersectWith(new[] { "orange", "yellow", "green" });   // warm unchanged

Quick check: which operation answers “in both”? Why create a copy before calling UnionWith?

Classroom application

Build a small course-roster tool:

  1. Store five fixed quiz weights in an array and total them with foreach.
  2. Keep a List<string> of enrolled students; add, insert, remove, and search by name, printing Count after each change.
  3. Map student IDs to names in a Dictionary<int, string>, checking ContainsKey before every lookup.
  4. Build HashSet<string> rosters for two sections and print students in both (IntersectWith), in either (UnionWith), and only in section A (ExceptWith), copying the sets first.
  5. Sort the list alphabetically, then write a bubble sort that orders names by length.

Choose the structure for each scenario and explain the access pattern:

  • A seven-day forecast always has seven positions. (Array.)
  • A playlist gains and loses tracks while the program runs. (List<T>.)
  • A student record must be found directly from a unique ID. (Dictionary.)
  • Two rosters must reveal students enrolled in both sections. (HashSet<T> intersection.)

Common misconceptions

  • The first array index is 0, and the last is Length - 1, not Length.
  • A runtime-sized array is sized once; it still cannot grow.
  • Arrays use Length; collections use Count.
  • Hard-coding an array size in a loop invites wrong results and IndexOutOfRangeException.
  • Assigning one array variable to another shares the same array; it does not copy it.
  • Array.Sort sorts in place and destroys the original order.
  • var with arrays requires new[] and an initializer, and it is still strongly typed.
  • A List<string> rejects non-string values at compile time, not at run time.
  • Dictionaries have no guaranteed order and cannot be sorted; keys must be unique.
  • Adding a duplicate to a HashSet<T> does not throw; Add returns false.
  • UnionWith, IntersectWith, ExceptWith, and SymmetricExceptWith change the set they are called on.

Lesson summary

Arrays give a fixed, typed, zero-based shape that for and foreach can traverse, sort, and aggregate. Collection classes add behavior: List<T> grows and shrinks, Dictionary<TKey, TValue> maps unique keys to values, and HashSet<T> enforces uniqueness and answers union, intersection, and difference questions directly. Initializers make starting data readable, and the choice of structure should follow how the program will use the data. Lesson 7 examines the enumerator protocol that lets foreach visit any sequence.