Module 1 · Foundations · Lesson 2 of 17
The C# Angle: Collection Costs, LINQ Costs, Strings and Span in Coding Interviews
Learning outcome
You can spot the C# calls that silently change a solution's complexity (List.Contains, List.Insert(0), RemoveAt(0), ElementAt on a sequence, string +=), choose the collection that matches the access pattern, and use Span<T> and StringBuilder to avoid allocations. You can also show that you verify LINQ costs instead of guessing.
Intuition
Interviewers grade the algorithm, but in C# the algorithm hides inside library calls. One Contains on a List<T> inside a loop turns O(n) into O(n²). The fix is rarely a new algorithm; it is the right collection. For the full table of collection costs, keep the .NET Collections Big-O Cheat Sheet open; this lesson shows the costs that matter most in coding rounds, measured.
flowchart TD
Q{"What does the loop do?"} -->|"membership test"| H["HashSet / Dictionary: O(1) avg"]
Q -->|"take from the front"| QQ["Queue<T>: O(1)"]
Q -->|"take smallest/largest"| P["PriorityQueue: O(log n)"]
Q -->|"index access"| L["List<T> / T[]: O(1); List.Add: amortized O(1)"]
Q -->|"build a string"| S["StringBuilder"]
Q -->|"slice without copying"| SP["Span<T> / ReadOnlySpan<char>"]Deep dive
- Collections used in this course:
T[]andList<T>: O(1) indexing.List.Addis amortized O(1); arrays have fixed length, so growing one requires allocating and copying. Inserting or removing at the front of a list shifts elements: O(n) per call.Dictionary<TKey,TValue>andHashSet<T>: O(1) average lookup, add and remove. Enumeration order is not guaranteed by the documentation.Stack<T>andQueue<T>: O(1) push/pop and enqueue/dequeue (amortized when they grow).Queue<T>is a circular buffer, so dequeuing does not shift elements.PriorityQueue<TElement,TPriority>(.NET 6+): a min-heap; O(log n) enqueue and dequeue, O(1) peek (Lesson 14).SortedSet<T>andSortedDictionary<TKey,TValue>: balanced trees, O(log n) per operation with ordering and ranges (Lesson 11).LinkedList<T>: O(1) insert/remove when you hold the node, but one heap allocation per node (Lesson 8 measures it).
- LINQ in algorithms:
- LINQ is fine for building inputs and for one-pass work outside hot loops. Inside a loop, each call is a full pass unless the operator has a fast path.
ElementAt(i)on a plainIEnumerable<T>(for example ayieldmethod) walks from the start every time; on anIList<T>it indexes directly.Count()enumerates when neither a collection count nor an internal LINQ counting fast path applies;Counton aList<T>is O(1).- Some combinations are optimized internally.
OrderBy(x).First()does not sort in current .NET: it scans once for the minimum. Materializing the sort (ToArray(),ToList(), or enumerating it) does sort. Do not memorize which combinations are special: measure, or write the loop. The Time and Space Complexity in LINQ course covers each operator.
- Strings:
stringis immutable.s += cin a loop allocates a new string each time and copies the old one: O(n²) time and allocation in total.StringBuilderappends into a growing buffer: amortized O(1) per append.Substringallocates a copy.AsSpan(start, length)and range slicing on aReadOnlySpan<char>do not allocate.
Span<T>in coding rounds:Span<T>/ReadOnlySpan<T>give a view over an array, a string orstackallocmemory. Slicing (a[..k]) is O(1) and allocation-free.- Useful helpers:
span.Reverse(),span.Sort(),span.IndexOf,int.Parse(ReadOnlySpan<char>), and in .NET 9+span.Split(',')which yieldsRangevalues. stackalloc int[26]gives a fixed-size counting array with no heap allocation (Lessons 3 and 6).- Limits: a
Span<T>cannot be stored in a field of a class, captured in a lambda, or used acrossawait.
Code example: measured costs
// L02: the C# costs that change a solution's complexity: collection choice, LINQ in loops,
// string building and Span slicing. Allocations are measured with GC.GetAllocatedBytesForCurrentThread.
static long Alloc(Action a) { a(); long b = GC.GetAllocatedBytesForCurrentThread(); a(); return GC.GetAllocatedBytesForCurrentThread() - b; }
// 1. Membership test inside a loop: List.Contains is O(n) per call, HashSet.Contains O(1) average.
int n = 20_000;
var list = Enumerable.Range(0, n).ToList();
var set = new HashSet<int>(list);
Big.Time("List.Contains x n (O(n^2) total)", [10_000, 20_000, 40_000], m => { var l = Enumerable.Range(0, m).ToList(); return () => { int c = 0; for (int i = 0; i < m; i++) if (l.Contains(m - 1 - i)) c++; }; });
Big.Time("HashSet.Contains x n (O(n) total)", [10_000, 20_000, 40_000], m => { var s = new HashSet<int>(Enumerable.Range(0, m)); return () => { int c = 0; for (int i = 0; i < m; i++) if (s.Contains(m - 1 - i)) c++; }; });
// 2. List.Insert(0, x) shifts every element: n inserts cost O(n^2). Add is amortized O(1).
Big.Time("List.Insert(0) x n", [10_000, 20_000, 40_000], m => () => { var l = new List<int>(); for (int i = 0; i < m; i++) l.Insert(0, i); });
Big.Time("List.Add x n", [10_000, 20_000, 40_000], m => () => { var l = new List<int>(); for (int i = 0; i < m; i++) l.Add(i); });
// 3. LINQ inside a loop. ElementAt on this yield sequence walks from the start each time.
IEnumerable<int> Seq(int m) { for (int i = 0; i < m; i++) yield return i; }
static long SumByElementAt(IEnumerable<int> s, int m) { long t = 0; for (int i = 0; i < m; i++) t += s.ElementAt(i); return t; }
Check.Eq("ElementAt loop sum n=1000", SumByElementAt(Seq(1000), 1000), 499_500L);
Big.Time("ElementAt(i) in a loop over a yield sequence", [2_000, 4_000, 8_000], m => { var s = Seq(m); return () => SumByElementAt(s, m); });
// Verify LINQ costs instead of assuming. OrderBy(x).First() is special-cased by .NET (a single
// O(n) scan, no sort), but materializing the sort (ToArray()[0]) buffers and sorts: O(n log n), O(n) space.
var data = Big.RandomArray(100_000, 1_000_000);
Check.Eq("OrderBy().First() == Min()", data.OrderBy(x => x).First(), data.Min());
Console.WriteLine($"alloc OrderBy(x).First(): {Alloc(() => data.OrderBy(x => x).First()):N0} bytes");
Console.WriteLine($"alloc OrderBy(x).ToArray()[0]: {Alloc(() => _ = data.OrderBy(x => x).ToArray()[0]):N0} bytes");
Console.WriteLine($"alloc Min(): {Alloc(() => data.Min()):N0} bytes");
Big.Time("OrderBy(x).First()", [200_000, 400_000, 800_000], m => { var d = Big.RandomArray(m, 1_000_000); return () => d.OrderBy(x => x).First(); });
Big.Time("OrderBy(x).ToArray()[0]", [200_000, 400_000, 800_000], m => { var d = Big.RandomArray(m, 1_000_000); return () => _ = d.OrderBy(x => x).ToArray()[0]; });
// 4. Strings are immutable: += in a loop copies the whole string every time.
static string Concat(int m) { string s = ""; for (int i = 0; i < m; i++) s += 'x'; return s; }
static string Builder(int m) { var sb = new System.Text.StringBuilder(); for (int i = 0; i < m; i++) sb.Append('x'); return sb.ToString(); }
Check.Eq("same result", Concat(2000) == Builder(2000), true);
foreach (int m in new[] { 2_000, 4_000 })
Console.WriteLine($"m={m:N0}: += allocates {Alloc(() => Concat(m)):N0} bytes, StringBuilder {Alloc(() => Builder(m)):N0} bytes");
// 5. Substring allocates a new string; AsSpan slices without copying.
string csv = string.Join(",", Enumerable.Range(0, 1000));
static int SumSubstring(string s) { int t = 0, start = 0; for (int i = 0; i <= s.Length; i++) if (i == s.Length || s[i] == ',') { t += int.Parse(s.Substring(start, i - start)); start = i + 1; } return t; }
static int SumSpan(string s) { int t = 0; var r = s.AsSpan(); foreach (var part in r.Split(',')) t += int.Parse(r[part]); return t; }
Check.Eq("Substring sum", SumSubstring(csv), 499_500);
Check.Eq("Span sum", SumSpan(csv), 499_500);
Console.WriteLine($"alloc Substring parse: {Alloc(() => SumSubstring(csv)):N0} bytes; Span parse: {Alloc(() => SumSpan(csv)):N0} bytes");
Check.Summary();Output (.NET 10, Release, tiered compilation off)
time growth (median of 5, varies per run): List.Contains x n (O(n^2) total) n= 10,000 7.550 ms n= 20,000 32.192 ms x4.3 n= 40,000 131.125 ms x4.1 time growth (median of 5, varies per run): HashSet.Contains x n (O(n) total) n= 10,000 0.035 ms n= 20,000 0.070 ms x2.0 n= 40,000 0.137 ms x2.0 time growth (median of 5, varies per run): List.Insert(0) x n n= 10,000 1.376 ms n= 20,000 11.395 ms x8.3 n= 40,000 55.524 ms x4.9 time growth (median of 5, varies per run): List.Add x n n= 10,000 0.042 ms n= 20,000 0.096 ms x2.3 n= 40,000 0.102 ms x1.1 PASS ElementAt loop sum n=1000 -> 499500 time growth (median of 5, varies per run): ElementAt(i) in a loop over a yield sequence n= 2,000 4.640 ms n= 4,000 18.399 ms x4.0 n= 8,000 74.300 ms x4.0 PASS OrderBy().First() == Min() -> 2 alloc OrderBy(x).First(): 120 bytes alloc OrderBy(x).ToArray()[0]: 1,600,304 bytes alloc Min(): 0 bytes time growth (median of 5, varies per run): OrderBy(x).First() n= 200,000 1.516 ms n= 400,000 3.234 ms x2.1 n= 800,000 6.517 ms x2.0 time growth (median of 5, varies per run): OrderBy(x).ToArray()[0] n= 200,000 43.583 ms n= 400,000 91.696 ms x2.1 n= 800,000 203.078 ms x2.2 PASS same result -> True m=2,000: += allocates 4,051,976 bytes, StringBuilder 8,696 bytes m=4,000: += allocates 16,103,976 bytes, StringBuilder 16,864 bytes PASS Substring sum -> 499500 PASS Span sum -> 499500 alloc Substring parse: 31,920 bytes; Span parse: 0 bytes tests: 5 passed, 0 failed
Code walkthrough
List.Containsinside a loop of n: going from 10,000 to 40,000 the time went x4.3 then x4.1, the O(n²) signature. TheHashSet.Containsloop went x2.0 and x2.0: O(n).- n calls to
List.Insert(0, i)grew x8.3 then x4.9; every insert shifts all existing elements. n calls toList.Addtook well under a millisecond; at that scale the ratios (x2.3, x1.1) are measurement noise, which is why tiny timings prove nothing. ElementAt(i)over ayieldsequence went x4.0 and x4.0 for 2,000 → 8,000: O(n²), because each call restarts the iterator. The test confirms the sum is still correct (499500): the bug is cost, not correctness.OrderBy(x).First()allocated 120 bytes and grew x2.1 then x2.0: .NET runs it as a single scan.OrderBy(x).ToArray()[0]allocated 1,600,304 bytes for 100,000 ints (the sort buffers) and was far slower at every size.Min()allocated 0 bytes.- String building: for 2,000 characters
+=allocated 4,051,976 bytes versus 8,696 forStringBuilder. Doubling to 4,000 characters made+=allocate 16,103,976 bytes (about x4, quadratic) whileStringBuilderroughly doubled to 16,864. - Parsing 1,000 comma-separated numbers:
Substringallocated 31,920 bytes; theSpanversion withSplitandint.Parse(ReadOnlySpan<char>)allocated 0 bytes.
Complexity
| Call | Cost per call | Inside a loop of n |
|---|---|---|
List<T>.Contains, IndexOf, Remove(x) | O(n) | O(n²) |
HashSet<T>.Contains, Dictionary.TryGetValue | O(1) average | O(n) |
List<T>.Insert(0, x), RemoveAt(0) | O(n) | O(n²) |
Queue<T>.Dequeue, Stack<T>.Pop | O(1) | O(n) |
ElementAt(i) without an indexing fast path (such as a yield sequence) | O(i + 1) | O(n²) |
string += c | O(length) | O(n²) |
StringBuilder.Append(c) | O(1) amortized | O(n) |
span[a..b], AsSpan | O(1), no allocation | O(n) |
Failure modes
if (!list.Contains(x)) list.Add(x)to build a distinct list: O(n²). Use aHashSet<T>.list.RemoveAt(0)as a queue: O(n) per dequeue.- Re-enumerating a lazy LINQ query inside a loop (
query.Count()each iteration). string +=in a loop to build output.- Assuming a LINQ chain sorts or buffers (or does not) without checking. Some chains have fast paths; most do not.
- Storing a
Span<T>in a class field or capturing it in a lambda: the compiler rejects it. UseMemory<T>or indices.
Interview angle
"Your solution is O(n), but it calls result.Contains(x) in the loop." Answer: "That is a List<T>, so Contains is a linear scan and the loop is O(n²). I will keep a HashSet<int> alongside it for membership checks; that makes the loop O(n) average with O(n) extra space. If order does not matter, the HashSet alone is enough."
Practice questions
- Why is
OrderBy(x => x).ToArray()[0]worse thanMin()?
Answer: It sorts the whole sequence, O(n log n) time and O(n) buffers; Min() is one O(n) pass with O(1) space.
- What does
s += ccost in a loop of n characters?
Answer: O(n²) time and allocation; each step copies the whole string.
- Which collection replaces
List<T>used as a FIFO queue?
Answer: Queue<T>: O(1) enqueue and dequeue.
- Can you keep a
Span<char>in a class field?
Answer: No. Span<T> is a ref struct; use Memory<T> or store start/length indices.
Revision checklist
- Membership in a loop:
HashSet/Dictionary, neverList.Contains. - Front removal:
Queue<T>, neverRemoveAt(0). - Build strings with
StringBuilder; slice with spans. - LINQ inside loops: each call is a pass unless proven otherwise.
- Small timings are noise; compare growth.
Related on TechInterviewNotes
Analogy
Looking up a word
Finding a word by reading a book from page one is List.Contains. Using the index at the back is HashSet.Contains. Both work; only one is fast enough when you need to look up every word in the book.
Quick reference
Swap these in coding rounds
List.Containsin a loop →HashSet<T>.RemoveAt(0)→Queue<T>.Insert(0, x)repeatedly → add to the end, reverse once.string +=→StringBuilder.Substring→AsSpan/ranges.OrderByfor min/max →Min()/Max()or one loop.