Big O notation as a growth-rate language: the common complexity classes, real .NET examples of each, and best/average/worst/amortised analysis.
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.
Big O is a way to talk about how cost scales, not how fast code is today. If doubling the input doubles the time, it is O(n). If doubling the input quadruples the time, it is O(n²).
| Class | Name | n = 1,000 → 1,000,000 | .NET example |
|---|---|---|---|
| O(1) | constant | same | array[i], Dictionary.TryGetValue, HashSet.Contains |
| O(log n) | logarithmic | ~10 → ~20 steps | Array.BinarySearch, SortedSet.Contains |
| O(n) | linear | 1,000× more | List.Contains, Sum(), one foreach |
| O(n log n) | linearithmic | ~2,000× more | Array.Sort, List.Sort, OrderBy |
| O(n²) | quadratic | 1,000,000× more | nested loops, list.Contains inside a loop |
| O(2ⁿ) | exponential | impossible | brute-force subsets, naive Fibonacci |
Dictionary lookup is O(1) average, O(n) worst if every key collides.List<T>.Add is usually O(1) but sometimes copies the whole array when it doubles; across n adds the total is O(n), so each add is amortised O(1).An order export took 2 seconds for 1,000 orders in test. In production with 50,000 orders it took over an hour. The code called customers.First(c => c.Id == order.CustomerId) inside a loop over orders: O(n · m). Building a Dictionary<int, Customer> once (O(m)) and looking up inside the loop (O(1)) made it O(n + m) and the export finished in under a second. Big O is how you predict that before production does it for you.
Checks that the candidate can classify code by growth rate and connect it to real consequences.
Green flags
List.Contains.List.Add or Dictionary collisions).Red flags
f(n) = O(g(n)) means: there exist constants c and n₀ such that for all n ≥ n₀, f(n) ≤ c · g(n).
In plain words, beyond some input size, f never grows faster than a constant multiple of g. The "doubling test" is a practical consequence: if cost is proportional to n², doubling n multiplies the cost by about four once n is large enough for the dominant term to win.
| Notation | Meaning | Analogy |
|---|---|---|
| O (Big O) | upper bound | ≤ |
| Ω (Big Omega) | lower bound | ≥ |
| Θ (Big Theta) | tight bound (both) | = |
In interviews people say "Big O" when they usually mean the tight bound of the worst case. Saying "binary search is Θ(log n) in the worst case" is the precise version.
Concrete numbers for n = 1,000,000 at roughly 1 ns per step:
| Class | Steps | Time |
|---|---|---|
| O(log n) | 20 | 20 ns |
| O(n) | 10⁶ | 1 ms |
| O(n log n) | 2 × 10⁷ | 20 ms |
| O(n²) | 10¹² | ~17 minutes |
| O(2ⁿ) | astronomical | never |
The difference between O(n log n) and O(n²) at a million rows is milliseconds versus a timeout.
Any time you halve the problem each step, you get log₂ n steps:
1,000,000 -> 500,000 -> 250,000 -> ... -> 1 (about 20 halvings)
Binary search, balanced tree height and the depth of merge sort's recursion are all log n for this reason. The base of the log does not matter in Big O, because log₂ n and log₁₀ n differ only by a constant factor.
List<T>.Addcapacity: 4 8 16
[####] [########] [################]
^ add 5th item: allocate 8, copy 4
^ add 9th: allocate 16, copy 8
Total copies after n adds ≈ 4 + 8 + 16 + ... + n < 2n. So n adds cost O(n) in total, which is amortised O(1) per add. Individual adds can still be O(n), which matters for latency-sensitive code: pre-size with new List<T>(capacity) when you know the count.
| Algorithm | Average | Worst | What triggers the worst case |
|---|---|---|---|
Dictionary lookup |
O(1) | O(n) | Poor GetHashCode or hash flooding |
| Quicksort (plain) | O(n log n) | O(n²) | Bad pivots on sorted or adversarial input (introsort caps it) |
| Insertion sort | O(n²) | O(n²) | Reverse order; but O(n) on nearly sorted data |
| Unbalanced BST | O(log n) | O(n) | Inserting sorted keys |
| Looks like | Actually |
|---|---|
list.Contains(x) |
O(n) scan |
list.Remove(x) / list.Insert(0, x) |
O(n) shift |
list.RemoveAt(0) in a loop |
O(n²) total |
string += in a loop |
O(n²) copying; use StringBuilder |
enumerable.Count() on a non-collection |
O(n) enumeration |
ElementAt(i) on IEnumerable<T> |
O(i) |
OrderBy(...).First() |
Sorts or partially sorts; use MinBy for a single O(n) pass |
ImmutableList<T> index |
O(log n), it is a tree |
// dotnet new console; paste into Program.cs; dotnet run -c Release
using System.Diagnostics;
foreach (var n in new[] { 1_000, 10_000, 50_000 })
{
var list = Enumerable.Range(0, n).ToList();
var set = list.ToHashSet();
var sw = Stopwatch.StartNew();
int hits = 0;
foreach (var x in list) if (list.Contains(x)) hits++; // O(n²)
var quadratic = sw.Elapsed;
sw.Restart();
foreach (var x in list) if (set.Contains(x)) hits++; // O(n)
Console.WriteLine($"n={n,6}: List {quadratic.TotalMilliseconds,8:F1} ms, HashSet {sw.Elapsed.TotalMilliseconds,6:F2} ms");
}
Watch the List column grow about 100× when n grows 10×, while the HashSet column grows about 10×.
```csharp
// O(1): constant, independent of n
int First(int[] a) => a[0];
// O(log n): halve the search space each step
int idx = Array.BinarySearch(sortedIds, 42);
// O(n): one pass
long Sum(int[] a)
{
long s = 0;
foreach (var x in a) s += x;
return s;
}
// O(n log n): comparison sort
Array.Sort(prices);
// O(n²): nested loop over the same data
bool HasDuplicateSlow(int[] a)
{
for (int i = 0; i < a.Length; i++)
for (int j = i + 1; j < a.Length; j++)
if (a[i] == a[j]) return true;
return false;
}
// O(n): same answer with a HashSet (expected O(1) per Add)
bool HasDuplicateFast(int[] a)
{
var seen = new HashSet
Mistake: Saying Big O measures speed ("O(1) is fast"). Why it is wrong: Big O measures growth. An O(1) hash lookup that computes an expensive hash can be slower than an O(n) scan of five items. Instead: Say "the cost does not grow with n" and measure real speed with BenchmarkDotNet.
Mistake: Calling two nested loops over different collections O(n²). Why it is wrong: If orders has n items and products has m, the cost is O(n · m). With 1,000,000 orders and 10 products it behaves like O(n). Instead: Use a separate letter for each independent input.
Mistake: Ignoring hidden loops such as List.Contains, IndexOf, Any, First or string += inside a loop.
Why it is wrong: Each one is O(n), so the outer loop becomes O(n²) without any visible nested for.
Instead: Read the cost of every call in a hot loop, and build a Dictionary or HashSet once outside it.
Mistake: Quoting only the average case, for example "Dictionary is always O(1)".
Why it is wrong: With a bad GetHashCode or a hash-flooding attack every key collides and lookups become O(n).
Instead: State average and worst case, and what makes the worst case happen.
Mistake: Confusing amortised with average.
Why it is wrong: Average is over possible inputs; amortised is a guaranteed bound over a sequence of operations. List.Add is amortised O(1) but a single add can be O(n).
Instead: Use "amortised" for sequences, and pre-size collections in latency-sensitive paths.
Mistake: Optimising Big O while ignoring I/O. Why it is wrong: One extra database or HTTP call per item dwarfs CPU work. Instead: Count round trips first, then in-memory complexity.
Seniors use Big O as a prediction tool for scale, not an exam topic. Before shipping, they ask "what is n in production, and how will it grow in two years?" O(n²) on 50 config entries is fine forever; O(n²) on customer orders is a future incident.
They also know when Big O misleads: for small n, contiguous arrays and linear scans win because of CPU caches; for I/O-bound systems, round trips dominate everything. The senior move is: reason with Big O, confirm with a benchmark at realistic n, and watch production metrics (p99 latency versus input size) for curves that bend upward.
Finally, they look for complexity in the library calls, not just the loops they wrote: Contains, Count(), ElementAt, OrderBy().First(), immutable collections and EF queries that materialise too early.
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.
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.
IndexOf, Contains, First.Array.BinarySearch, List<T>.BinarySearch, span BinarySearch. Returns the bitwise complement of the insertion point when not found.HashSet/Dictionary built first, only for exact matches.Use binary search for sorted data, range queries ("first item ≥ x") and when you cannot afford a hash index.
Last updated 23 Oct 2026.