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.
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
Lengthsafely. - Traverse arrays and collections with
forandforeach, and describe the enumerator behindforeach. - Swap elements, sort with
Array.Sort, and trace a bubble sort. - Use
varwith 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>, andHashSet<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
intanddoubleelements. - 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 ofdouble;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 asdoublesArray[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 holding0until 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
sumstarts at 0 and accumulates each element.- The loop condition uses
doublesArray.Length, not the literal10. If the array changes size, a hard-coded 10 gives a wrong average or throwsSystem.IndexOutOfRangeException. Always get the size fromLength.
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.TryParsestores 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:
myStringssupplies the sequence. - Iteration variable:
thisStringreceives one element per pass. - No index: the body never touches a length or position.
- Numeric cast:
(double)sumprevents 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.Currentexposes 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[]
varreplaces the explicit type on the left, andintis 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. varis still strongly typed; the inferredint[]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 useCount.
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
Addrequires both a key and a value. Keys must be unique; adding an existing key throws an exception.ContainsKey(andContainsValue) 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> { … };. Adynamicvariable skips compile-time type checking and resolves members at run time, whereasvaris inferred at compile time. Keepdynamicas a vocabulary preview; the course uses explicit types orvar.
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:
- Store five fixed quiz weights in an array and total them with
foreach. - Keep a
List<string>of enrolled students; add, insert, remove, and search by name, printingCountafter each change. - Map student IDs to names in a
Dictionary<int, string>, checkingContainsKeybefore every lookup. - 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. - 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, notLength. - A runtime-sized array is sized once; it still cannot grow.
- Arrays use
Length; collections useCount. - 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.Sortsorts in place and destroys the original order.varwith arrays requiresnew[]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;Addreturnsfalse. UnionWith,IntersectWith,ExceptWith, andSymmetricExceptWithchange 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.
Course Notes