📁 Sangam Dhakal - Portfolio v2.0 - Netscape Navigator 4.0
_
□
×

📚 Interview question

Code review: what is the time complexity of this .NET code, and how would you make it faster? It loops over orders, calls products.FirstOrDefault(p => p.Id == line.ProductId) per line, builds a report with string +=, and checks seen.Contains(order.Id) on a List<int>.

Analysing the real time complexity of everyday .NET code with hidden loops, and rewriting it to linear time.

Mid-Level CodeReview Intermediate algorithms big-o code-review linq performance time-complexity

🎯 Key points

  • Name each input separately (O orders, L lines per order, P products, R report length) and multiply nested costs: the original is O(O² + O · L · P + R²)
  • On a List<T> or any IEnumerable<T>, FirstOrDefault, Contains, Any and IndexOf are hidden O(n) loops; on a HashSet<T> or Dictionary, Contains is O(1) average
  • string += in a loop copies the whole string each time, so total copying is O(R²) and large strings land on the Large Object Heap; use StringBuilder or stream rows to a writer
  • Build a Dictionary or HashSet index once outside the loop, then probe it in O(1): the rewrite is O(P + O · L + R), linear in input and output
  • Use the bool returned by HashSet.Add instead of Contains followed by Add
  • Decide what happens when a product is missing (TryGetValue returns false): write an empty name, log it, or fail the run, rather than silently relying on null
  • Deferred LINQ re-runs on every Count() or ElementAt; materialise once with ToList when you need several passes, but avoid materialising huge sequences you only stream once
  • Confirm with a profiler (dotnet-trace) or BenchmarkDotNet at production-sized input, and state the size threshold where the change matters

⏱ 30-second answer

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.

🗣 Full answer (senior)

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;
}

Step-by-step analysis

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.

Rewrite

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.

Production example

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.

🔍 What the interviewer is checking

Checks whether the candidate can find hidden quadratic behaviour in normal-looking code and fix it idiomatically.

Green flags

  • Names separate variables (O, L, P) and multiplies correctly.
  • Spots all three issues: FirstOrDefault scan, List.Contains, string +=.
  • Rewrites with ToDictionary, HashSet.Add return value and StringBuilder or streaming.
  • Mentions profiling or benchmarking to confirm.

Red flags

  • Says "it is O(n) because there is one loop".
  • Misses the string concatenation cost.
  • Builds the dictionary inside the loop.
  • Suggests parallelism to fix an algorithmic problem.

📚 Learn in depth

1. A method for analysing any code

  1. Name the inputs: O orders, L lines per order, P products, R output size.
  2. Walk each loop and multiply by how many times it runs.
  3. Expand every library call into its real cost (see the table below).
  4. Add sequential parts, multiply nested parts.
  5. Keep the dominant term for realistic sizes.

2. Hidden costs to expand

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

3. Why string += is quadratic

Strings 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.

4. Multiple enumeration of deferred LINQ

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.

5. Making it linear: the two standard moves

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.

6. Verifying with a profiler

  • 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.
  • BenchmarkDotNet with [Params(1_000, 10_000, 100_000)] shows whether time grows 10× or 100× per 10× input.

7. Try it yourself

// 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.

💻 Code

// 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}");

⚠ Common mistakes

  • 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.

🏛 Senior insight: trade-offs

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.

🔗 Follow-ups the interviewer may ask

  1. When: Candidate fixes the code snippet Why: Moves from a code review to diagnosing the same problem in production
    How to answer
    1. Confirm it is complexity, not load: plot latency against the customer's data size. A curve that bends upward with n (not with traffic) points to O(n²) or worse.
    2. Profile with real-sized data: dotnet-trace/dotnet-counters or Application Insights profiler; look for wide frames in List.Contains, FirstOrDefault, string.Concat, sorting, or many SQL calls per request (N+1).
    3. Fix the algorithm: index with Dictionary/HashSet, batch queries, push filtering/sorting/paging to SQL, stream instead of buffering.
    4. Prevent it: performance tests with production-sized n, latency-by-size dashboards, and limits on unbounded inputs.
  2. When: Candidate replaces the scan with a Dictionary Why: Checks they understand why a hash lookup is O(1) and when it is not
    How to answer

    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.

  3. When: Candidate uses Big O terms loosely Why: Checks the fundamentals behind the analysis
    How to answer

    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.

    • O(1): Dictionary lookup, array index.
    • O(log n): binary search, SortedDictionary lookup.
    • O(n): a single loop, List.Contains.
    • O(n log n): Array.Sort, OrderBy.
    • O(n²): nested loops over the same data.
    • O(2ⁿ): naive recursive Fibonacci, trying every subset.

    Amortised O(1) (like List.Add) means occasional expensive steps averaged over many cheap ones.

🛠 Real-world problems: how you handle them

Nightly billing report took 40 minutes after a large client onboarded
Situation
A billing job built a CSV of [20,000] orders with [5] lines each against a catalogue of [10,000] products.
Signals
The job's runtime grew from [2 minutes] to [40 minutes]; it overlapped the morning batch window; Gen 0 GC count and allocation rate were very high.
Investigation
dotnet-trace showed most CPU in Enumerable.FirstOrDefault and String.Concat. Reading the code showed a product scan per line, List.Contains for de-duplication and string += for the report.
Resolution
Built a Dictionary of products once, used HashSet.Add for de-duplication, and streamed rows to a StreamWriter. Runtime dropped to [under 1 second].
Prevention
Added a review checklist item for lookups and concatenation in loops, and a benchmark with production-sized input in the performance stage.

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].

↩ Usually asked after

More in Algorithms

Last updated 23 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me