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

📚 Interview question

What is space complexity? How do you analyse the memory an algorithm uses in .NET, including auxiliary space, recursion depth, allocations and the GC, and how do you trade time for space?

Space complexity in practice: auxiliary versus input space, stack depth from recursion, allocation cost in .NET, and time-space trade-offs.

Intermediate Theory Intermediate algorithms big-o gc memory space-complexity

🎯 Key points

  • Space complexity is how memory grows with input size; distinguish input space (the data you were given), auxiliary space (what the algorithm adds) and total space, and say which you mean
  • Recursion depth counts as space: each live call holds a stack frame, so depth n is O(n) stack, and a .NET StackOverflowException cannot be caught and terminates the process
  • C# and RyuJIT do not guarantee tail-call elimination; convert deep recursion to a loop or an explicit Stack<T> on the heap
  • In .NET, allocations cost time later: they trigger GCs, objects of 85,000 bytes or more go on the Large Object Heap and are only collected in gen 2, and gen 2 pauses hurt p99 latency
  • Streaming (StreamReader.ReadLineAsync, EF Core AsAsyncEnumerable, IAsyncEnumerable<T> responses) keeps auxiliary space O(1) and prevents container OOMKills on large inputs
  • Data layout changes the constant factor: List<int> of 1M items is about 4 MB, while 1M boxed ints in a List<object> is about 32 MB
  • Time-space trade-offs are deliberate choices: hash indexes, memoisation and prefix sums spend memory to save time; streaming and rolling DP rows spend time or simplicity to save memory
  • Reduce allocation with Span<T>, stackalloc for small buffers, ArrayPool<T>, StringBuilder, and by not materialising sequences that are only iterated once

⏱ 30-second answer

Space complexity is how the extra memory an algorithm needs grows with input size. Usually we quote auxiliary space, not counting the input.

  • Reversing an array in place: O(1).
  • Copying to a new list, or a HashSet of seen items: O(n).
  • Recursion uses stack: depth d costs O(d) (merge sort O(log n) stack plus O(n) buffer).

In .NET, space also means allocations and GC pressure: an O(n) algorithm that allocates per item can be slower than one that reuses a buffer. Common trade: spend O(n) memory (a dictionary, memo table) to save time.

🗣 Full answer (senior)

Space complexity measures memory growth the same way time complexity measures work. The key distinction:

  • Input space: memory holding the input. Usually not counted.
  • Auxiliary space: extra memory the algorithm allocates: temporary arrays, hash sets, recursion stack, buffers.

Examples

Algorithm Time Auxiliary space Why
Sum an array O(n) O(1) one accumulator
Reverse array in place O(n) O(1) swap two indexes
HashSet duplicate check O(n) O(n) stores seen items
Merge sort O(n log n) O(n) merge buffer
Array.Sort (introsort) O(n log n) O(log n) recursion stack only
Recursive DFS on a path-shaped graph O(V + E) O(V) stack depth equals path length
Memoised Fibonacci O(n) O(n) memo table plus stack
Iterative Fibonacci O(n) O(1) two variables

Space in .NET is more than Big O

  1. Stack: about 1 MB per thread by default on Windows (8 MB main thread on many Linux distros, smaller for thread-pool threads in some configurations). Deep recursion throws StackOverflowException, which cannot be caught and kills the process.
  2. Heap allocations: every new, ToList(), closure, boxing and string costs allocation time and later GC time. Objects of 85,000 bytes or more go on the Large Object Heap and are only collected in Gen 2.
  3. Memory limits: in a container with a 512 MB limit, an O(n) algorithm on a 2 GB file gets OOMKilled. Streaming (O(1) or O(buffer) space) is the fix.

Time-space trade-offs

  • Spend space to save time: hash index (O(n) space) turns O(n²) lookups into O(n); memoisation turns exponential recursion into polynomial.
  • Spend time to save space: stream a file and process line by line instead of File.ReadAllLines; recompute values instead of caching them.

Production example

A CSV validator used File.ReadAllLines then lines.Select(Parse).ToList(): O(n) space, roughly 3× the file size in UTF-16 strings and objects. A 1.5 GB file crashed the pod at its 2 GB limit. Rewriting it with StreamReader.ReadLineAsync and validating row by row made it O(1) auxiliary space (one line plus a fixed buffer), and peak memory dropped to about 40 MB.

🔍 What the interviewer is checking

Checks whether the candidate understands memory as a resource that scales with input, including stack and GC effects.

Green flags

  • Distinguishes auxiliary from total space.
  • Counts recursion depth as space and knows stack overflow is fatal in .NET.
  • Mentions streaming for large inputs, container memory limits, and the LOH.
  • Gives a concrete time-space trade-off (hash index, memoisation).

Red flags

  • Only thinks about time complexity.
  • Says "memory is cheap" without considering limits.
  • Believes C# optimises tail recursion.
  • Uses ReadAllLines/ToList on unbounded input by default.

📚 Learn in depth

1. Counting space correctly

Total memory = input + auxiliary. Interviews usually ask for auxiliary space; say which one you mean.

+---------------------------+
| input (n items)           |  not counted in auxiliary space
+---------------------------+
| extra arrays / sets       |  O(n) if proportional to input
| recursion stack frames    |  O(depth)
| fixed variables           |  O(1)
+---------------------------+

2. Recursion depth is space

Each call pushes a frame (return address, locals, saved registers), often 50–200 bytes or more in .NET:

Factorial(5)
  Factorial(4)
    Factorial(3)
      Factorial(2)
        Factorial(1)     <- 5 frames live at once: O(n) stack
Recursion pattern Depth Stack space
Linear (factorial, list walk) n O(n)
Halving (binary search, balanced tree) log n O(log n)
Quicksort worst case n O(n), introsort avoids this
DFS on a long chain V O(V)

The C# compiler and RyuJIT do not guarantee tail-call elimination, so do not rely on tail recursion to save stack. Convert deep recursion to a loop with an explicit Stack<T>, which lives on the heap.

3. Allocation is a cost even when Big O is fine

// O(n) space, n small allocations
var names = orders.Select(o => o.Name.ToUpperInvariant()).ToList();

// O(1) auxiliary: compare without allocating
int matches = 0;
foreach (var o in orders)
    if (o.Name.Equals("ACME", StringComparison.OrdinalIgnoreCase)) matches++;

Tools to reduce allocations:

Tool What it avoids
Span<T> / ReadOnlySpan<char> substring copies (text.AsSpan(0, 5))
stackalloc small temporary buffers on the stack
ArrayPool<T>.Shared repeated large array allocations
StringBuilder intermediate strings
struct enumerators / foreach on List<T> iterator allocations
IAsyncEnumerable<T> / streaming materialising whole result sets

4. Managed object overhead

On 64-bit .NET each object has a header and method table pointer (16 bytes) before its fields, and a reference costs 8 bytes. So List<int> with 1M items is about 4 MB, but List<object> with 1M boxed ints is about 8 MB of references plus 24 MB of boxes. Data layout changes the constant factor a lot even when Big O is the same.

5. Streaming: the most important space optimisation

ReadAllLines         Streaming
[ whole file ]       [ buffer ] -> process -> discard
[ all strings]       [ one line ]
[ all objects]       [ one row object ]
O(n) space           O(1) auxiliary space

In ASP.NET Core, returning IAsyncEnumerable<T> from an endpoint streams JSON, and EF Core's AsAsyncEnumerable() streams rows instead of buffering with ToListAsync().

6. Space-time trade-off catalogue

Technique Extra space Time saved
Hash index O(n) O(n) → O(1) per lookup
Memoisation / DP table O(states) exponential → polynomial
Prefix sums O(n) range sum O(n) → O(1)
Precomputed FrozenDictionary O(n) + build time faster reads
Bloom filter O(m bits) avoids disk lookups, with false positives
Rolling DP (keep last row) O(n) → O(1) or O(row) none lost

7. Try it yourself

// Compare peak memory of buffering vs streaming
using System.Diagnostics;
var path = Path.GetTempFileName();
await File.WriteAllLinesAsync(path, Enumerable.Range(0, 2_000_000).Select(i => $"{i},name{i},{i % 97}"));

long before = GC.GetTotalAllocatedBytes(true);
var all = File.ReadAllLines(path);
Console.WriteLine($"ReadAllLines allocated: {(GC.GetTotalAllocatedBytes(true) - before) / 1_048_576} MB");

before = GC.GetTotalAllocatedBytes(true);
long sum = 0;
foreach (var line in File.ReadLines(path)) sum += line.Length; // streaming
Console.WriteLine($"ReadLines allocated: {(GC.GetTotalAllocatedBytes(true) - before) / 1_048_576} MB (but never held at once)");
Console.WriteLine($"Peak working set: {Process.GetCurrentProcess().PeakWorkingSet64 / 1_048_576} MB");

Both allocate similar totals, but the streaming version only keeps one line alive at a time, so it can be collected in Gen 0 and peak memory stays flat.

💻 Code

// O(1) auxiliary space: in-place reverse
static void Reverse(int[] a)
{
    for (int i = 0, j = a.Length - 1; i < j; i++, j--)
        (a[i], a[j]) = (a[j], a[i]);
}

// O(n) auxiliary space: new array
static int[] ReversedCopy(int[] a) => a.Reverse().ToArray();

// Recursion: O(n) stack, can overflow for large n
static long SumRecursive(ReadOnlySpan<int> a) => a.IsEmpty ? 0 : a[0] + SumRecursive(a[1..]);

// Same result, O(1) auxiliary space
static long SumIterative(ReadOnlySpan<int> a) { long s = 0; foreach (var x in a) s += x; return s; }

// Reuse a pooled buffer instead of allocating per call
static async Task<long> CountBytesAsync(Stream s)
{
    var buffer = ArrayPool<byte>.Shared.Rent(81_920);
    try
    {
        long total = 0; int read;
        while ((read = await s.ReadAsync(buffer)) > 0) total += read;
        return total;
    }
    finally { ArrayPool<byte>.Shared.Return(buffer); }
}

// Stream rows from EF Core instead of ToListAsync()
await foreach (var order in db.Orders.AsNoTracking().AsAsyncEnumerable())
    await writer.WriteLineAsync($"{order.Id},{order.Total}");

⚠ Common mistakes

  • Mistake: Forgetting that recursion uses memory. Why it is wrong: Each call adds a stack frame, so a recursive walk over a 200,000-node chain needs 200,000 frames; the thread's stack (about 1–8 MB) overflows and the whole process dies. Instead: Count depth as space, and use an explicit Stack<T> or a loop when depth can reach thousands.

  • Mistake: Counting the input in auxiliary space (or not saying which you mean). Why it is wrong: Calling an in-place reverse "O(n) space" confuses the analysis and hides the real cost of algorithms that copy. Instead: Say "O(1) auxiliary, O(n) total including the input".

  • Mistake: Treating O(n) space as free because memory is cheap. Why it is wrong: In containers the memory limit is hard: File.ReadAllLines on a 1.5 GB file in a 2 GB pod gets it OOMKilled, and every replica pays the same cost. Instead: Stream large inputs, cap request sizes, and size buffers explicitly.

  • Mistake: Assuming tail recursion is optimised. Why it is wrong: C# does not guarantee tail-call elimination, so tail-recursive code still grows the stack. Instead: Rewrite as a loop.

  • Mistake: Ignoring allocation churn because Big O is unchanged. Why it is wrong: Substring, ToList, LINQ closures and boxing in a hot path allocate per item; a pipeline like orders.Select(o => o.Name.ToUpperInvariant()).ToList() creates n strings plus a list just to compare names. Instead: Compare with StringComparison.OrdinalIgnoreCase, slice with Span<T>, pool buffers, and avoid materialising sequences you only iterate once.

  • Mistake: Caching everything to save time. Why it is wrong: An unbounded cache is O(n) memory forever, effectively a leak that ends in an OOMKill. Instead: Bound caches with size limits and expiration, and measure hit rate to prove the memory is worth it.

🏛 Senior insight: trade-offs

Seniors treat memory as a budget with a hard ceiling. In Kubernetes the ceiling is the container limit, and crossing it is an OOMKill, not a slowdown. So they ask: does this algorithm's memory depend on input size, and what is the largest input we accept? If it is unbounded, they stream, page or cap it.

Production scenario Memory pattern Typical symptom Fix
Export endpoint buffers all rows with ToListAsync O(rows) per request OOMKills under concurrent exports AsAsyncEnumerable and stream the response
Large JSON request bodies parsed into objects O(body) per request Memory spikes, LOH growth Request size limits, Utf8JsonReader or streaming deserialisation
Per-request new byte[1_000_000] buffers LOH allocation each call Frequent gen 2 GCs, p99 spikes ArrayPool<byte>.Shared
Unbounded in-process cache Grows with distinct keys Slow memory climb, weekly restarts Size-limited MemoryCache or HybridCache
Deep recursion on user-supplied trees O(depth) stack Process crash with no catchable exception Iterative traversal

They also understand that in .NET, space cost shows up as time: allocations trigger GCs, LOH allocations trigger gen 2 collections, and gen 2 pauses hurt p99 latency. Reducing allocation (spans, pooling, streaming) is often a latency fix, not just a memory fix. Finally, they make trade-offs explicit and documented: "this index costs 200 MB per instance and saves 30 ms per request" is a decision others can revisit.

🔗 Follow-ups the interviewer may ask

  1. When: Candidate mentions recursion depth as space Why: Checks they can convert recursion to iteration and know stack overflow is fatal
    How to answer

    Recursion is a function solving a problem by calling itself on a smaller version of it. Every recursive function needs:

    1. A base case that stops.
    2. A recursive case that moves towards the base case.

    Each call pushes a frame on the thread's call stack (about 1 MB by default on Windows). Too deep and you get a StackOverflowException, which cannot be caught and kills the process. Recursion fits naturally recursive data (trees, nested JSON, folders), but use a loop with an explicit Stack<T> or Queue<T> when depth can be large or is controlled by input.

  2. When: Candidate talks about stack frames and heap allocations Why: Checks the underlying memory model
    How to answer

    The stack is a per-thread region that holds method frames: locals, arguments, return addresses and saved registers. Allocating is just moving the stack pointer, and everything is freed when the method returns. The heap is shared memory for objects whose lifetime is not tied to one method; the generational garbage collector frees them once they are unreachable.

    The common line "value types live on the stack, reference types on the heap" is only half true. Storage follows the declaration: a value-type local usually lives on the stack (or in a register), but a value-type field lives inside its object on the heap, and boxing, lambdas that capture locals, iterators and async methods move data to the heap. Reference-type objects are conceptually heap objects (only the reference sits on the stack), although the current JIT's escape analysis can place a small object that provably never leaves the method on the stack as an optimisation. The language guarantees lifetime and copy semantics, not a location.

  3. When: Candidate mentions the Large Object Heap or GC pressure Why: Probes how the managed heap and LOH affect latency
    How to answer

    Each thread allocates from its own allocation context, a small chunk of gen 0 where new is just a pointer bump plus zeroing; when it runs out, the thread asks the GC for another chunk, and when the gen 0 budget is used up, a gen 0 GC runs. The GC is generational because most objects die young: gen 0 collections are cheap, survivors are promoted to gen 1 and then gen 2, which is collected rarely (and mostly in the background). Objects of 85,000 bytes or more go to the large object heap (LOH), which is collected only with gen 2 and isn't compacted by default, so it can fragment. Pinned objects can't be moved, which blocks compaction; the pinned object heap (.NET 5+) keeps long-lived pinned buffers separate. In hot paths I reduce the allocation rate rather than tuning the GC.

↩ Usually asked after

More in Algorithms

Last updated 23 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me