Space complexity in practice: auxiliary versus input space, stack depth from recursion, allocation cost in .NET, and time-space trade-offs.
Space complexity is how the extra memory an algorithm needs grows with input size. Usually we quote auxiliary space, not counting the input.
HashSet of seen items: O(n).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.
Space complexity measures memory growth the same way time complexity measures work. The key distinction:
| 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 |
StackOverflowException, which cannot be caught and kills the process.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.File.ReadAllLines; recompute values instead of caching them.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.
Checks whether the candidate understands memory as a resource that scales with input, including stack and GC effects.
Green flags
Red flags
ReadAllLines/ToList on unbounded input by default.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)
+---------------------------+
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.
// 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 |
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.
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().
| 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 |
// 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.
// 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}");
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.
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.
Recursion is a function solving a problem by calling itself on a smaller version of it. Every recursive function needs:
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.
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.
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.
Last updated 23 Oct 2026.