Analysing the real time complexity of everyday .NET code with hidden loops, and rewriting it to linear time.
Count the work per line, including hidden loops in library calls. With O orders, L lines per order, P products:
products.FirstOrDefault per line: O(O · L · P).seen.Contains on a List: O(O²).report += copies the whole string each time: O(total chars²).Fix: build Dictionary<int, Product> once (O(P)), use HashSet<int> for seen, and use StringBuilder. Total becomes O(P + O · L + output size), linear in the input.
The code under review:
public string BuildReport(List<Order> orders, List<Product> products)
{
var seen = new List<int>();
string report = "";
foreach (var order in orders) // O times
{
if (seen.Contains(order.Id)) continue; // O(O) scan
seen.Add(order.Id);
foreach (var line in order.Lines) // L times
{
var p = products.FirstOrDefault(p => p.Id == line.ProductId); // O(P) scan
report += $"{order.Id},{p?.Name},{line.Qty}\n"; // copies whole string
}
}
return report;
}
| Line | Cost per call | Times called | Total |
|---|---|---|---|
seen.Contains |
O(O) | O | O(O²) |
products.FirstOrDefault |
O(P) | O · L | O(O · L · P) |
report += |
O(current length) | O · L | O(R²) where R is report length |
With 20,000 orders, 5 lines each and 10,000 products, the product lookup alone is 20,000 × 5 × 10,000 = 10⁹ comparisons. The string concatenation on a 3 MB report copies roughly R²/2 characters, terabytes of memory traffic and a lot of Gen 0 and LOH garbage.
public string BuildReport(IReadOnlyList<Order> orders, IReadOnlyList<Product> products)
{
var byId = products.ToDictionary(p => p.Id); // O(P)
var seen = new HashSet<int>(orders.Count); // O(1) Add/Contains
var sb = new StringBuilder(orders.Count * 64);
foreach (var order in orders) // O
{
if (!seen.Add(order.Id)) continue; // one lookup, not two
foreach (var line in order.Lines) // L
{
byId.TryGetValue(line.ProductId, out var p); // O(1)
sb.Append(order.Id).Append(',').Append(p?.Name).Append(',').Append(line.Qty).Append('\n');
}
}
return sb.ToString();
}
Total: O(P + O · L + R), which is linear in the input and output. In a real case like this, a nightly report went from 40 minutes to under a second.
A billing job ran fine for a year, then started timing out after a large client onboarded. Profiling with dotnet-trace showed 90% of CPU in Enumerable.FirstOrDefault and String.Concat. Nobody had written a nested loop on purpose; the quadratic behaviour came from library calls inside a loop.
Checks whether the candidate can find hidden quadratic behaviour in normal-looking code and fix it idiomatically.
Green flags
FirstOrDefault scan, List.Contains, string +=.ToDictionary, HashSet.Add return value and StringBuilder or streaming.Red flags
| Call | Real cost |
|---|---|
List.Contains/IndexOf/Remove |
O(n) |
Enumerable.First/FirstOrDefault/Any/Where(...).Count() |
O(n) |
List.Insert(0, x) / RemoveAt(0) |
O(n) shift |
string + string |
O(length of both), new string every time |
ToList() / ToArray() |
O(n) copy plus allocation |
OrderBy |
O(n log n), and deferred, so it reruns on every enumeration |
Count() on a lazy IEnumerable |
O(n), re-enumerates |
Dictionary/HashSet operations |
O(1) average |
string += is quadraticStrings are immutable, so each += allocates a new string and copies everything so far:
append 1: copy 1 chunk
append 2: copy 2 chunks
append 3: copy 3 chunks
...
append k: copy k chunks
total : 1 + 2 + ... + k = k(k+1)/2 -> O(k^2)
StringBuilder keeps a linked list of chunks and appends in amortised O(1), so the total is O(R). Once a string exceeds 85,000 bytes, every += also allocates on the Large Object Heap, which adds Gen 2 GC pressure.
var active = orders.Where(o => o.IsActive); // nothing runs yet
for (int i = 0; i < active.Count(); i++) // Count() enumerates: O(n) each iteration
Process(active.ElementAt(i)); // ElementAt(i): O(i) each
This is O(n²) from two different sources. Materialise once with ToList() and iterate that.
Before: for each A, scan all of B O(|A| * |B|)
A ----> [ scan B linearly ]
After : build index over B once, then probe O(|A| + |B|)
B ----> Dictionary (one pass)
A ----> Dictionary lookup O(1)
The second move is sorting both sides and merging (O(n log n)), which is what a database merge join does. Hash join versus nested-loop join in SQL execution plans is exactly the same trade-off.
dotnet-counters monitor -n MyApp System.Runtime shows allocation rate and GC counts climbing with string +=.dotnet-trace collect plus a flame graph shows wide bars under FirstOrDefault or Contains.[Params(1_000, 10_000, 100_000)] shows whether time grows 10× or 100× per 10× input.// Benchmark: dotnet add package BenchmarkDotNet; run with -c Release
[MemoryDiagnoser]
public class ReportBench
{
[Params(1_000, 10_000)] public int N;
private List<int> _ids = null!;
[GlobalSetup] public void Setup() => _ids = Enumerable.Range(0, N).ToList();
[Benchmark(Baseline = true)]
public string Concat() { var s = ""; foreach (var i in _ids) s += i + ","; return s; }
[Benchmark]
public string Builder() { var sb = new StringBuilder(); foreach (var i in _ids) sb.Append(i).Append(','); return sb.ToString(); }
}
Compare the ratio between N = 1,000 and N = 10,000 for each method: about 100× for Concat, about 10× for Builder.
// BEFORE: O(O^2 + O*L*P + R^2)
var seen = new List<int>();
string report = "";
foreach (var order in orders)
{
if (seen.Contains(order.Id)) continue;
seen.Add(order.Id);
foreach (var line in order.Lines)
{
var p = products.FirstOrDefault(p => p.Id == line.ProductId);
report += $"{order.Id},{p?.Name},{line.Qty}\n";
}
}
// AFTER: O(P + O*L + R)
var byId = products.ToDictionary(p => p.Id);
var seenIds = new HashSet<int>(orders.Count);
var sb = new StringBuilder();
foreach (var order in orders)
{
if (!seenIds.Add(order.Id)) continue;
foreach (var line in order.Lines)
{
byId.TryGetValue(line.ProductId, out var p);
sb.Append(order.Id).Append(',').Append(p?.Name).Append(',').Append(line.Qty).Append('\n');
}
}
var result = sb.ToString();
// Even better for big exports: stream rows instead of building one huge string
await using var writer = new StreamWriter(path);
foreach (var order in orders)
foreach (var line in order.Lines)
await writer.WriteLineAsync($"{order.Id},{byId.GetValueOrDefault(line.ProductId)?.Name},{line.Qty}");
Mistake: Only looking for nested for loops.
Why it is wrong: FirstOrDefault, Contains, Any and IndexOf on a list are loops too; a single visible loop around them is already quadratic.
Instead: Expand every call in a loop body into its real cost for the actual collection type before writing down the total.
Mistake: Fixing the lookup but leaving string +=.
Why it is wrong: Concatenation in a loop is O(R²) in copying and floods the GC, including the Large Object Heap once strings pass about 85 KB.
Instead: Use StringBuilder, or stream rows to a StreamWriter so the whole report is never in memory.
Mistake: Calling Contains then Add on a HashSet.
Why it is wrong: That is two hash lookups; still O(1) but double the work and easy to get wrong under concurrency.
Instead: Use the bool returned by HashSet.Add (or Dictionary.TryAdd).
Mistake: Building the dictionary inside the loop.
Why it is wrong: products.ToDictionary() per order is O(O · P), as slow as before plus allocations.
Instead: Build indexes once, outside the hot loop.
Mistake: Ignoring missing keys after switching to a dictionary.
Why it is wrong: The indexer throws KeyNotFoundException for an unknown product, and TryGetValue with no check silently writes blank names.
Instead: Use TryGetValue and decide explicitly: default value, a logged warning with the order ID, or a validation failure.
Mistake: Re-enumerating a deferred LINQ query (Count(), ElementAt) in a loop.
Why it is wrong: Each call reruns the whole pipeline, turning one pass into n passes.
Instead: Iterate once with foreach, or materialise once with ToList() when you genuinely need several passes.
Mistake: Claiming a speed-up without measuring. Why it is wrong: For tiny inputs the dictionary build can cost more than it saves, and the real bottleneck may be I/O. Instead: Benchmark at production-sized n and confirm with a profiler.
The senior skill here is reading library calls as loops. Almost no production O(n²) bug is a deliberate nested for; it is FirstOrDefault in a foreach, Contains on a List, string +=, or EF Core lazy loading issuing a query per row (the I/O version of the same problem).
| Smell | Cost in a loop of n | Fix | Memory trade-off |
|---|---|---|---|
list.FirstOrDefault(x => x.Id == id) |
O(n · m) | ToDictionary once |
O(m) extra for the index |
list.Contains(id) for dedup |
O(n²) | HashSet.Add |
O(n) extra for the set |
string += |
O(R²) copying | StringBuilder or streaming |
Streaming keeps memory O(1) |
query.Count() / ElementAt(i) |
O(n²) re-enumeration | One foreach or one ToList() |
ToList holds all items |
| Lazy-loaded navigation per row | n database round trips | Include or a projection |
One larger query |
Seniors fix the class of bug, not just the instance: add an analyser or review checklist item for List.Contains in loops, accept IReadOnlyDictionary<TKey, TValue> in method signatures so callers pass an index, and add a performance test with realistic n so the quadratic curve shows up in CI rather than after a big client onboards. They also state the threshold: for 20 products a linear scan is fine and clearer; for 10,000 it is not.
List.Contains, FirstOrDefault, string.Concat, sorting, or many SQL calls per request (N+1).Dictionary/HashSet, batch queries, push filtering/sorting/paging to SQL, stream instead of buffering.A hash table turns a key into an integer hash, maps it to a bucket (hash % bucketCount), and stores the entry there. Lookups compute the same hash, go straight to the bucket, and compare keys with Equals. Average cost is O(1); worst case (everything in one bucket) is O(n).
.NET's Dictionary<TKey,TValue> keeps an int[] buckets and an Entry[] entries array; colliding entries form a chain via a next index inside the entries array. When full, it resizes to roughly double (a prime size) and rehashes.
Contract: if a.Equals(b) then a.GetHashCode() == b.GetHashCode(), and a key's hash must not change while it is in the dictionary. Good hashes spread keys evenly.
Big O describes how an algorithm's cost grows as input size n grows, ignoring constants and lower-order terms. It is an upper bound on growth, usually quoted for the worst case.
Dictionary lookup, array index.SortedDictionary lookup.List.Contains.Array.Sort, OrderBy.Amortised O(1) (like List.Add) means occasional expensive steps averaged over many cheap ones.
How to tell it (STAR)
At [company] our nightly billing report suddenly took [40 minutes] after a large client joined. I profiled it and saw the time was in FirstOrDefault and string concatenation. The code did a product scan per order line and built the report with +=, so it was effectively quadratic. I indexed products in a Dictionary, used a HashSet and streamed the output, and it now runs in [under a second].
Last updated 23 Oct 2026.