Complexity says nothing about how fast your code runs today. It says what happens to that
number when the input grows. Drag n and watch the same nine curves separate.
Ready for a four-minute review?
Timings assume a billion operations a second — change
the assumption in How much input fits?
Log–log paper: every gridline is ten times the last, on both axes. A power law plots
as a straight line and its exponent is the slope — which is why O(n²)
climbs at twice the angle of O(n), and why the exponentials leave the page almost
immediately. Each curve carries its own dash pattern too, so the nine stay distinct
without relying on colour. Tap a curve or a ladder row to pin its class.
/
Highlight
Type to filter the sheet. Enter jumps to the first result; the up and down arrows move
through matches.
Pick a class to reveal every operation with that growth rate — in the cards, the tables, the
ladder and the plot.
Your review
Five focused questions: due work first, then a recent weak spot and a new transfer question. Miss one and it returns in this session; get it right and it comes back in 3, 7, 21, then 60 days.
How to read this sheet
Sixty seconds, three ideas. Everything below is one of them applied to a particular structure.
1
What is growing?
n is the size of the input — elements in the list, keys in the map, vertices in the graph. A complexity is a promise about what happens to the work when n gets bigger, not about how long anything takes today.
2
Which case?
Best, average and worst describe different inputs, not different machines. Average is what you will feel; worst is what wakes you at 3am. Amortised is a third thing: expensive rarely, cheap the rest of the time, averaged over a run of operations.
3
Read the shape, not the stopwatch.
Constants and lower terms are dropped. 3n² + 2n + 7 and n² share a colour here, because past some n the square is all that is left.
✓
One worked example
Double the array. An O(n) pass does about 2× the work it did before. An O(n²) pass does about 4×. An O(log n) lookup does one extra step. That ratio — not the millisecond count — is what every row on this page is telling you.
Append is amortised: one insertion in every n triggers a resize that copies everything. Contiguity is also why iteration beats a linked list by an order of magnitude in practice — the numbers here do not show cache lines.
In practice Equal complexity to a linked list for traversal, and usually 5–10× faster: the prefetcher can see where you are going.
Appending writes one slot — constant. When the array is full it allocates a bigger one and copies everything, which is n. Doubling the capacity means that copy happens on the 1st, 2nd, 4th, 8th… append, so n appends copy 1 + 2 + 4 + … + n < 2n elements in total. Spread over n appends that is a constant each: amortised O(1).
append(a, x): if a.len == a.cap: bigger = allocate(2 * a.cap) copy(a.items → bigger) # n, but rarely a.items = bigger a.items[a.len] = x # 1, almost always a.len += 1
The marked lines are where the cost lives.
Growing by a fixed amount instead of a factor turns the same loop quadratic: n/c copies of average size n/2. The doubling is not an implementation detail, it is the bound.
The same array, kept in order — search collapses to binary
worst casecache-friendly
Operation
Avg
Worst
Access by index
1
1
Search
log n
log n
Insert
n
n
Delete
n
n
Min / max
1
1
Predecessor / successor
log n
log n
Range scan (k results)
log n + k
log n + k
Space
n
Why?
Cheap to read, expensive to change. Ideal for data built once and queried often; a poor fit for anything written to at runtime.
In practice Binary search is O(log n) but jumps around memory; for n under a few hundred a linear scan of a packed array often wins.
Each comparison throws away half of what is left, so the number of steps is how many times you can halve n before reaching one — log₂ n. A million elements is twenty comparisons; a billion is thirty.
search(a, x): lo, hi = 0, a.len - 1 while lo <= hi: mid = (lo + hi) / 2 if a[mid] == x: return mid if a[mid] < x: lo = mid + 1 # half gone else: hi = mid - 1 # half gone return not_found
The marked lines are where the cost lives.
The halving depends on reaching a[mid] in constant time. On a linked list, or on a sorted file you must seek through, the trick evaporates.
Nodes and forward pointers, with a head and (usually) a tail
worst case
Operation
Avg
Worst
Access by index
n
n
Search
n
n
Insert at front
1
1
Insert at back (with tail)
1
1
Insert after a known node
1
1
Delete a known node
n
n
Delete from front
1
1
Space
n
Why?
Deleting a node you already hold is linear here, not constant: you need its predecessor, and finding that means walking from the head. This is the one asymmetry a doubly-linked list removes.
In practice The O(1) insert assumes you already hold the node. Finding it is the O(n) you actually pay.
There is no arithmetic that turns an index into an address, so reaching element k means following k pointers. Splicing, by contrast, rewrites two fields — but only once you are already standing at the right node.
insert_after(node, x): fresh = Node(x) fresh.next = node.next # 1 node.next = fresh # 1get(head, k): for i in 0..k: head = head.next # k pointer hops
The marked lines are where the cost lives.
The famous O(1) insert is priced without the search. Insert "after the value 42" costs O(n) — the walk, not the write.
Pointers both ways, at the cost of one extra word per node
worst case
Operation
Avg
Worst
Access by index
n
n
Search
n
n
Insert at either end
1
1
Insert beside a known node
1
1
Delete a known node
1
1
Delete from either end
1
1
Iterate in reverse
n
n
Space
n
Why?
Constant-time removal of a node you already hold is what makes it the list inside an LRU cache: the hash table hands you the node, the list splices it out.
In practice Two pointers per element is real memory. It earns its keep when something else — a hash map, in an LRU cache — hands you the node.
A back pointer means a node knows both of its neighbours, so it can unlink itself without anyone finding its predecessor first. That is the whole difference, and it is what makes O(1) removal possible.
unlink(node): node.prev.next = node.next # 1 node.next.prev = node.prev # 1 # no search: the node knew both sides
The marked lines are where the cost lives.
It only pays when something else hands you the node. An LRU cache works because a hash map maps key → node; without that map you are back to scanning.
Worst case is constant on a linked implementation and amortised on an array one, where a push can trigger a resize.
Everything happens at one end of an array: write at len, read at len − 1. No element ever moves, so every operation is a couple of instructions — apart from the occasional resize, which amortises away exactly as it does for an array list.
In practice On a ring buffer every operation is a couple of index updates; on a linked list it is an allocation.
Two indices — head and tail — chase each other around a fixed-size ring. Enqueue writes at tail, dequeue reads at head, and neither shifts anything, so both are constant.
enqueue(q, x): q.items[q.tail] = x q.tail = (q.tail + 1) % q.cap # wrap, not shiftdequeue(q): x = q.items[q.head] q.head = (q.head + 1) % q.cap return x
The marked lines are where the cost lives.
Implementing dequeue as "remove the first element of an array" shifts every remaining element and makes it O(n). The modulo is what buys the constant.
Both ends cheap; a stack and a queue in one structure
amortisedcache-friendly
Operation
Avg
Worst
Push / pop front
1
n
Push / pop back
1
n
Access by index
1
1
Insert in the middle
n
n
Search
n
n
Space
n
Why?
Indexed access is constant on the usual block-array implementation (C++ deque; Python's collections.deque is linked and gives up on it). The linear worst case is the push that doubles the ring buffer — amortised away across a run of pushes, and pops never pay it.
In practice Usually a list of fixed blocks, so indexing costs an extra indirection compared with a plain array.
The same ring trick, but both ends are live: push and pop at head or tail are index updates. Indexing into the middle is still constant when the deque is one array, and one extra indirection when it is a list of blocks.
Buckets of linked entries; collisions extend a chain
averageexpected
Operation
Avg
Worst
Lookup by key
1
n
Insert
1
n
Delete
1
n
Iterate (unordered)
n
n
Iterate in key order
n log n
n log n
Resize / rehash
n
n
Min / max key
n
n
Space
n
Why?
The linear worst case is every key hashing to one bucket. Against untrusted input that is a denial-of-service vector, which is why runtimes seed their hashes randomly at startup.
In practice Constant *average*. With adversarial keys and no randomised hash, every key can land in one bucket and lookup becomes linear.
The hash turns a key into a bucket index in constant time, so lookup is one array access plus a walk of that bucket. With n keys in m buckets and a hash that spreads well, the average chain is n/m — kept under a small constant by resizing, which is why the average is O(1). The worst case is every key in one bucket: a linear scan.
lookup(t, key): i = hash(key) % t.buckets.len # 1 for entry in t.buckets[i]: # chain length if entry.key == key: return entry.value return missing
The marked lines are where the cost lives.
Iteration order is not insertion order and may differ between runs. Depending on it is a bug that survives every test until it does not.
One entry per slot; collisions probe onward to the next free one
averagecache-friendly
Operation
Avg
Worst
Lookup by key
1
n
Insert
1
n
Delete
1
n
Iterate (unordered)
n
n
Resize / rehash
n
n
Space
n
Why?
Faster than chaining while the table is under about 70% full, and it degrades sharply past that. Deletion needs tombstones, which accumulate until a rehash clears them.
In practice Faster than chaining while the load factor stays under ~0.7, because a probe sequence stays inside a cache line or two. Past that it degrades sharply.
No chains: a collision probes the next slot, and the next, until it finds an empty one. While the table is under about 70% full the expected probe count is a small constant, and every probe is the next cache line rather than a pointer dereference. As the load factor approaches 1 the probe runs merge and the constant explodes.
lookup(t, key): i = hash(key) % t.cap while t.slots[i] is not empty: # probe run if t.slots[i].key == key: return t.slots[i].value i = (i + 1) % t.cap return missing
The marked lines are where the cost lives.
Deleting cannot just blank a slot — that would cut a probe run in half and hide later keys. Tombstones accumulate and are cleaned only by a rehash.
Unbalanced. Fine on random input, a list on sorted input
average
Operation
Avg
Worst
Search
log n
n
Insert
log n
n
Delete
log n
n
Min / max
log n
n
Predecessor / successor
log n
n
In-order traversal
n
n
Height
log n
n
Space
n
Why?
Every worst case in this card is the same event: keys arriving in sorted order, which builds one long spine.
In practice Unbalanced. Insert sorted data and it degenerates into a linked list — which is why nobody ships a plain BST.
Every comparison sends you down one side, so a search costs one step per level. On a balanced tree there are log n levels. Nothing in a plain BST enforces balance, though: the height is whatever the insertion order made it, up to n.
search(node, x): while node: if x == node.key: return node node = x < node.key ? node.left : node.right # cost = the height of the tree, not log n
The marked lines are where the cost lives.
Inserting already-sorted data — a common case, since data often arrives sorted — builds a path, not a tree. Every operation becomes O(n).
Stricter balance than red-black, so lookups are shallower and writes rotate more often. Prefer it for read-heavy workloads.
In practice Stricter balance than red-black: faster lookups, more rotations on write. Good for read-heavy indexes.
The heights of any node's two subtrees differ by at most one. That invariant forces the height to at most about 1.44 log n, so every operation is logarithmic in the worst case, not merely on average. A rotation is a constant-time pointer rewrite that restores the invariant after a write.
Looser balance, fewer rotations — the usual ordered map
worst case
Operation
Avg
Worst
Search
log n
log n
Insert
log n
log n
Delete
log n
log n
Min / max
log n
log n
Predecessor / successor
log n
log n
In-order traversal
n
n
Space
n
Why?
What std::map, TreeMap and most kernel schedulers are built from: at most two rotations to rebalance an insert, three for a delete.
In practice Looser balance, fewer rotations. This is what most standard libraries' ordered maps are.
Colour rules keep every root-to-leaf path within a factor of two of every other, so the height is at most 2 log n. That is a weaker guarantee than AVL, bought back as fewer rotations per update — at most three on insert, and O(1) amortised overall.
insert(t, x): n = bst_insert(t, x); n.colour = RED while n.parent is RED: recolour or rotate(n) # ≤ 3 rotations, ever n = n.grandparent t.root.colour = BLACK
The marked lines are where the cost lives.
Slightly taller than an AVL tree, so lookup-dominated workloads do measurably more comparisons. This is the trade the standard libraries chose.
Same asymptotics as a balanced binary tree, but a node holds hundreds of keys, so the depth is three or four instead of thirty. Every relational index you have ever used is one of these.
In practice The log is base B, not base 2. With B ≈ 100 a billion keys sit four levels deep — four disk reads.
A node holds B keys rather than one, so the tree is log_B n deep instead of log₂ n. Comparisons inside a node are free relative to the read that fetched it, and that read brought back a whole block — which is the entire point on a device where a random read costs a millisecond.
search(node, key): while node is internal: i = binary_search(node.keys, key) # in RAM, free node = read_block(node.child[i]) # one disk read return node.find(key) # depth = log_B n ≈ 4 for a billion keys
The marked lines are where the cost lives.
In memory it loses to a binary tree: the wide nodes exist to amortise a block read that is not happening.
Layered linked lists; balance comes from coin flips
expected
Operation
Avg
Worst
Search
log n
n
Insert
log n
n
Delete
log n
n
Range scan (k results)
log n + k
n
Min / max
1
1
Space
n
Why?
Balanced-tree behaviour with no rotations, which makes lock-free concurrent versions far easier to write. The worst case is improbable rather than impossible.
In practice Randomised, not guaranteed. Much easier to make lock-free than a balanced tree, which is why concurrent maps like it.
Each node is promoted to the next level with probability ½, so level k holds about n/2^k nodes and there are about log n levels. A search walks forward on a sparse level until it would overshoot, then drops — an expected constant number of steps per level.
search(list, key): node = list.head for level from top down to 0: while node.next[level].key < key: node = node.next[level] # ~2 steps expected return node.next[0]
The marked lines are where the cost lives.
Expected, not guaranteed: a run of unlucky coin flips can build a tall thin list. In exchange, updates touch a few pointers with no global rebalancing — the reason concurrent maps favour it.
Keyed by prefix, one node per character. m is key length
worst case
Operation
Avg
Worst
Search
m
m
Insert
m
m
Delete
m
m
All keys with a prefix
m + k
m + k
Longest prefix match
m
m
Space
nk
Why?
Independent of how many keys are stored — only the key length matters. Space is the catch: a node per character per branch, unless you compress runs into a radix tree.
In practice Independent of n, but the constant is a pointer array per node. Compressed variants trade code complexity for a lot of memory.
Lookup follows one child pointer per character of the key, so the cost is m — the key length — and does not depend on how many keys are stored. A prefix query walks m characters and then enumerates whatever hangs below.
search(root, word): node = root for ch in word: # m steps, whatever n is node = node.children[ch] if not node: return false return node.is_word
The marked lines are where the cost lives.
An array of child pointers per node is the alphabet size times a word, per character stored. Memory, not time, is what kills naive tries.
Range aggregates over an array that keeps changing
worst case
Operation
Avg
Worst
Build
n
n
Range query
log n
log n
Point update
log n
log n
Range update (lazy)
log n
log n
Space
n
Why?
Answers "sum/min/max over [i, j]" in log time while updates keep arriving. A prefix-sum array does the query in constant time but cannot be updated.
In practice Handles any associative combine — min, max, gcd, matrices. That generality is why it costs about 4n memory.
The array is covered by a binary tree of ranges. Any query range decomposes into at most two nodes per level — about 2 log n nodes — whose stored answers combine into the result. An update rewrites one leaf and the log n ancestors above it.
Prefix sums, in one array and a dozen lines of code
worst casecache-friendly
Operation
Avg
Worst
Build
n
n
Prefix sum
log n
log n
Range sum
log n
log n
Point update
log n
log n
Space
n
Why?
Half the memory and a fraction of the code of a segment tree, at the cost of only supporting invertible operations — sums yes, minimums no.
In practice Same log n as a segment tree with a tiny constant and exactly n words, but only for invertible operations like sums.
Index i stores the sum of a block whose length is the lowest set bit of i. A prefix sum therefore adds one value per set bit of i — at most log n of them — and an update walks the same bits upward. That is the whole structure: no nodes, no pointers, one array.
prefix_sum(t, i): s = 0 while i > 0: s += t[i] i -= i & -i # clear lowest set bit return s # ≤ log n iterations
The marked lines are where the cost lives.
Range sums work because subtraction undoes a prefix. There is no such trick for min or max — that is what a segment tree is for.
Degrades to a full scan as dimensions rise: past roughly twenty, brute force is faster. High-dimensional search uses approximate methods instead.
In practice Degrades to a linear scan past about ten dimensions — the curse of dimensionality, visible right in the bound.
Each level splits space on a different axis, so a nearest-neighbour search descends to a leaf and then only revisits branches whose bounding region is closer than the best match so far. In low dimensions that prunes almost everything; the cost is logarithmic on average.
nearest(node, target, best): best = closer_of(best, node.point) near, far = sides_of(node, target) best = nearest(near, target, best) if distance_to_plane(node, target) < best.dist: best = nearest(far, target, best) # pruned, usually return best
The marked lines are where the cost lives.
Past roughly ten dimensions almost every branch survives pruning and the search degenerates to a scan of all n points. The curse of dimensionality, visible in the bound.
Building from a whole array is linear, not n log n — the standard heapify does most of its work near the leaves, where subtrees are shallow.
In practice An array, no pointers, no allocation. Beats theoretically better heaps up to surprisingly large n.
The array is an implicit complete tree: children of i live at 2i+1 and 2i+2. Insert appends and swims the value up at most log n levels; extract-min moves the last element to the root and sinks it down at most log n levels. Building from n items is O(n), not O(n log n), because most nodes are near the leaves and sink almost nowhere.
extract_min(h): min = h[0] h[0] = h.pop() # move last to root i = 0 while child(i) < h[i]: # sink: ≤ log n swaps swap(i, smaller_child(i)) i = smaller_child(i) return min
The marked lines are where the cost lives.
Finding an arbitrary element is O(n) — the heap only orders parents against children. decrease-key needs an external index from key to position.
In practice Mergeable in log n, which a binary heap cannot do — but every operation carries pointer chasing.
A forest of trees whose sizes are the powers of two in n. Merging two heaps is binary addition on that forest, carrying like bits — log n work. Everything else falls out of merge.
merge(a, b): # like adding two binary numbers for each order k in 0..log n: combine trees of order k, carry order k+1insert(h, x): merge(h, single_node_heap(x))
The marked lines are where the cost lives.
Its reason to exist is meldability. If you never merge two heaps, a binary heap does the same work with a fraction of the pointer chasing.
Amortised bounds; the theoretical best for Dijkstra
amortised
Operation
Avg
Worst
Find min
1
1
Insert
1
1
Extract min
log n
n
Decrease key
1
log n
Merge two heaps
1
1
Space
n
Why?
Constant-time decrease-key is what drops Dijkstra to O(E + V log V). The constants are bad enough that a binary heap usually wins in practice — a good reminder that this whole table hides them.
In practice The famous O(1) decrease-key is amortised and the constants are large. Dijkstra with a binary heap usually beats Dijkstra with a Fibonacci heap on real graphs.
Insert and decrease-key do the minimum possible work — splice a node into a root list and move on — and defer the tidying to the next extract-min, which consolidates trees of equal degree. Amortised over a sequence, that is O(1) for the lazy operations and O(log n) for extract.
decrease_key(h, node, k): node.key = k if node.key < node.parent.key: cut(node) → root list # O(1), no rebalancing cascading_cut(node.parent) # the tidy-up is deferred to extract_min
The marked lines are where the cost lives.
Amortised, not per-operation: one extract-min pays for all the deferred work, and the per-node bookkeeping is heavy enough that binary heaps usually win in practice.
α is the inverse Ackermann function — below five for any n that fits in memory. Constant for every practical purpose, and provably not quite constant.
In practice Path compression plus union by rank. Either one alone is log n; together they are effectively constant.
Each set is a tree; find walks to the root. Union by rank keeps trees shallow, and path compression flattens every node it walks past straight onto the root, so the same walk never happens twice. Together they give α(n) amortised — under five for any n that fits in a computer.
find(x): if parent[x] != x: parent[x] = find(parent[x]) # compress on the way out return parent[x]union(a, b): ra, rb = find(a), find(b) attach the shorter tree under the taller # by rank
The marked lines are where the cost lives.
Either optimisation alone gives O(log n). People implement one, measure, and conclude the structure is slow.
Space is the extra memory, not the array. The badges are what people forget: stable keeps equal elements in their original order, in place needs no second array, adaptive gets faster on data that is already nearly sorted.
The quadratic case needs a pathological run of pivots; random or median-of-three pivots make it vanishingly unlikely. Fastest in practice on general data.In practice Its constant factor is the smallest of the O(n log n) sorts, which is why the quadratic worst case is tolerated everywhere.
Partitioning is one linear pass. If the pivot lands near the middle, the problem halves each time and there are log n levels of linear work — n log n. A pivot that always lands at an end gives n levels instead, and n² total.
sort(a, lo, hi): p = partition(a, lo, hi) # n work at this level sort(a, lo, p - 1) sort(a, p + 1, hi) # balanced pivots → log n levels → n log n # worst pivots → n levels → n²
The marked lines are where the cost lives.
Sorted input plus a first-element pivot is the classic quadratic case — and sorted input is exactly what you get when someone re-sorts an already sorted list.
No bad case. You pay for it in the scratch buffer, which is also what makes it the sort of choice for linked lists and for data too large to fit in memory.In practice The extra array is the cost people forget; on large data the allocation and the copy dominate the comparison count.
Splitting is free, merging two sorted halves is one linear pass, and the recursion is log n levels deep regardless of the data. Linear work per level times log n levels: n log n, always.
sort(a): if a.len <= 1: return a l, r = sort(a[:mid]), sort(a[mid:]) # log n levels return merge(l, r) # n per level
The marked lines are where the cost lives.
The merge needs somewhere to write. That n extra array — and the copy back — is what you trade for the guarantee.
Finds runs that are already ordered and merges them. Real data is full of such runs, which is why this is the default sort in Python, Java and Rust.In practice Real input is often already partly sorted, so the "best case" is not a curiosity — it is Tuesday.
The only common sort that is both in place and n log n in the worst case. Poor cache locality keeps it off the podium anyway.In practice Optimal bounds, poor locality: it jumps around the array by powers of two and loses to quicksort in practice.
Quicksort that watches its own recursion depth and switches to heapsort when it gets too deep, and to insertion sort near the leaves. This is what your standard library actually runs.
The gap sequence is the whole algorithm. These figures are for Pratt's gaps — every 2^i·3^j below n — which are provably n log² n. Ciura's empirically-tuned gaps are faster on real input and have no proven bound at all, which is why they are not what is printed here.
k is the size of the key range. Linear for small integer keys, hopeless for 64-bit ones.In practice Linear in n + k, and k is the *key range*. Sorting 32-bit integers this way asks for a four-billion-entry table.
Beats n log n only because it never compares — k is the number of digits. It sorts keys, not arbitrary orderings.In practice Beats comparison sorts on fixed-width keys, but the d passes each rewrite the whole array.
Shortest path when every edge costs the same. Weighted edges need Dijkstra.In practice Shortest path only when every edge costs the same. With weights it is confidently wrong, not merely slow.
The queue holds a frontier of equal hop count, so vertices come out in rings: everything one edge away, then everything two edges away. Each vertex is enqueued once and each edge inspected once — V + E.
bfs(g, s): queue = [s]; seen = {s} while queue: v = queue.pop_front() # each vertex once for w in g.neighbours(v): # each edge once if w not in seen: seen.add(w); queue.push_back(w)
The marked lines are where the cost lives.
The ring structure *is* the shortest-path proof, and it assumes every edge costs one. Weighted edges break the argument, not just the speed.
Negative edge weights break it silently — it returns a path, just not the shortest one.In practice Fails outright with negative edge weights — it commits to a vertex the moment it pops it.
Same sweep as BFS, but the frontier is ordered by accumulated cost instead of hop count. Each vertex is popped once (log V) and each edge can push one improved distance (log V), giving (V + E) log V.
dijkstra(g, s): pq = [(0, s)]; dist[s] = 0 while pq: d, v = pq.pop_min() # log V if d > dist[v]: continue # stale entry for (w, cost) in g.edges(v): if d + cost < dist[w]: dist[w] = d + cost; pq.push((dist[w], w)) # log V
The marked lines are where the cost lives.
Popping a vertex declares its distance final. One negative edge makes that declaration false, and no amount of extra running time fixes it.
Slower than Dijkstra, but handles negative weights and reports negative cycles.In practice Slower, but it tolerates negative weights and detects negative cycles. That is what you are buying.
A shortest path uses at most V − 1 edges, so relaxing every edge V − 1 times is enough for the true distances to propagate outwards one hop per pass. A V-th pass that still improves something proves a negative cycle.
for i in 1..V-1: # V-1 passes for (u, v, w) in edges: # E each dist[v] = min(dist[v], dist[u] + w)one more pass improves something → negative cycle
The marked lines are where the cost lives.
The V × E is unconditional in the textbook form. Stopping early when a pass changes nothing is the one-line optimisation everyone forgets.
All pairs at once, in three nested loops. Fine to a few thousand vertices, hopeless past that.In practice Three nested loops over a dense matrix: terrible asymptotics, excellent constants. Under a few hundred vertices it beats running Dijkstra V times.
Dijkstra with a heuristic pulling it toward the goal. Same bound, and the constant is what you came for — a good heuristic visits a fraction of the vertices. A heuristic that overestimates costs correctness, not just speed.In practice Only as good as the heuristic. With h = 0 it is Dijkstra; with an inadmissible h it is fast and wrong.
Ford-Fulkerson with breadth-first search picking the augmenting paths, which is what bounds it independently of the capacities. The number of augmenting paths depends on the graph's flow structure, not on V and E alone.
Never re-reads a character of the text. The failure table records where to resume after a mismatch.
The prefix table says, for every position in the pattern, how much of what you just matched is also a prefix of the pattern. On a mismatch you slide by that amount instead of restarting, so the text pointer never moves backwards: n steps for the scan, m to build the table.
build failure table for pattern # mi = 0for each character of the text: # n, never backtracks while mismatch: i = table[i - 1] if match: i += 1 if i == m: report a match
The marked lines are where the cost lives.
Brute force is O(nm) only on contrived input; on English text it is close to linear. KMP buys you the guarantee, not usually the speed.
Scans the pattern backwards and can skip m characters at a time, so it often reads only a fraction of the text. Two tables do it: the bad-character rule, one entry per alphabet symbol, and the good-suffix rule, one per pattern position. The bad-character rule alone is Boyer-Moore-Horspool, whose worst case is nm.In practice Sublinear in practice — it skips forward by whole pattern lengths — while the good-suffix rule is what keeps the worst case linear. The reason grep is fast.
A rolling hash makes the average linear; hash collisions push the worst case to nm. The natural choice for searching many patterns at once.In practice The hash makes it linear in expectation; a collision-heavy input degrades it to the brute-force product.
All patterns at once, in one pass, in time independent of how many there are. m here is the total length of every pattern.In practice One pass finds every pattern at once, which is why intrusion detection and spam filters use it.
The naive recursion is 2ⁿ. Memoising it is the whole idea of dynamic programming in one example.In practice The naive recursion is exponential only because it recomputes; one array turns it linear. This is the whole idea of DP in one row.
The naive recursion recomputes fib(n−2) twice, fib(n−3) three times, and so on — a call tree with about 2ⁿ nodes for n distinct answers. Store each answer the first time and the tree collapses to a line: n subproblems, O(1) work each.
# exponential: the same subtree, over and overfib(n) = fib(n-1) + fib(n-2)# linear: n subproblems, each solved oncetable[0], table[1] = 0, 1for i in 2..n: table[i] = table[i-1] + table[i-2]
The marked lines are where the cost lives.
The speed-up is not the loop, it is the memory. Every DP bound is "number of distinct subproblems × cost of combining them" — count those two and you have the complexity.
Pseudo-polynomial: W is a value, not an input length, so this is exponential in the number of bits of W. The problem is still NP-hard.In practice O(nW) is pseudo-polynomial: W is a value, so the cost is exponential in the number of bits used to write it down.
The table has n × W cells — one per (item, remaining capacity) — and each is filled by comparing two already-known cells. n × W subproblems, constant work each.
for i in 1..n: for w in 0..W: # W is a VALUE, not a length best[i][w] = max(best[i-1][w], best[i-1][w - wt[i]] + val[i])
The marked lines are where the cost lives.
W is written in log W digits, so this is exponential in the input length — pseudo-polynomial. Knapsack is still NP-hard; nothing here says otherwise.
Pseudo-polynomial, like knapsack, and NP-complete for the same reason.In practice Same trap as knapsack — polynomial in the target, exponential in its encoding.
The plot asks how long a given n takes. This asks the question you usually have instead: given a time budget, how large can n be?
“Operation” is an illustrative unit, not a benchmark — a comparison, a pointer hop, a hash. What matters is the distance between the rows, and no machine changes that.
Describe the workload
Seven switches, two ranked answers, and the reasons the runners-up lost. A slower, more honest version of the need list.
Learn mode
The same material, in the order that makes sense the first time. Seven lessons, five to ten minutes each, every one ending in three questions.
Growth and notation
Lesson 1 of 7
What a complexity actually claims, and what it deliberately refuses to say.
A bound is a shape
A complexity describes how the work responds to a bigger input. It is a claim about a ratio: double n, and an O(n) routine does about twice the work while an O(n²) routine does about four times. Wall-clock time never enters into it, which is why the same bound holds on a phone and on a server.
Why constants are dropped
Write out the real count — say 3n² + 2n + 7 — and ask what happens at n = 1,000. The square term is 3,000,000; the rest is 2,007. Past some input size the largest term is the answer and everything else is rounding, so the notation keeps only that term and drops its coefficient too.
O, Ω and Θ
O is an upper bound: no worse than this. Ω is a lower bound: no better than this. Θ is both at once — the growth *is* this. Casual usage says "big-O" for all three, which is harmless right up until someone says "quicksort is O(n²)" and means it as a criticism.
Best, average, worst, amortised
These pick which input you are talking about, not which notation. Worst case is over all inputs; average is over a distribution of them; amortised is over a *sequence* of operations, and is a guarantee rather than a statistical hope.
Three questions
An algorithm does 5n + 200 operations. Its complexity is:
Coefficients and lower-order terms are dropped. Only the shape survives.
You double n and the running time goes up about eightfold. The likely class is:
2³ = 8. The exponent is exactly what the ratio tells you.
Which describes a guarantee over a sequence of operations rather than over inputs?
Amortised: any n operations cost this in total, whatever the data does.
Contiguity versus pointers — the oldest trade in the subject.
Address arithmetic
An array element lives at base + k × size, which is one multiply and one add regardless of k. That single fact gives O(1) indexing and, with sorted data, binary search. Everything an array is bad at follows from the same fact: keeping elements adjacent means an insertion in the middle has to move the tail.
Amortised append
Doubling the capacity when full means the copies over n appends sum to under 2n, so each append is constant on average — and this is a guarantee, not a hope. Growing by a constant amount instead makes the same loop quadratic.
What pointers buy
A linked list splices in constant time because nothing needs to move. But it can only splice where it already stands, and it has no way to reach position k except by walking. The O(1) insert is real; the search that precedes it is the cost you actually pay.
The part the table cannot show
Iterating an array and iterating a linked list are both O(n), and the array is routinely five to ten times faster: it reads memory in order, so the prefetcher stays ahead. Two identical bounds, an order of magnitude apart.
Three questions
Inserting at the front of an array list of n elements costs:
Contiguity means the whole tail moves up one slot.
A deque is preferable to an array list when:
The ring buffer makes both ends constant; an array list is only cheap at the back.
Why is a doubly-linked list the right structure inside an LRU cache?
The map turns "find the node" into a lookup; the back pointer then makes unlinking free.
Constant time, and the assumptions hiding inside it.
Bucket, then scan
A hash turns a key into an index in constant time. With n keys spread over m buckets the average bucket holds n/m entries, and resizing keeps that ratio near a small constant — which is the entire argument for O(1). Nothing about it is guaranteed: it is an average over a good hash and ordinary keys.
Chaining versus probing
Chaining puts colliding keys in a list hanging off the bucket; open addressing walks forward to the next free slot. Probing is faster while the table is under about 70% full because the probe stays in cache, and degrades sharply after that. Chaining degrades gently and tolerates a fuller table.
When you need order
A hash table cannot answer "the smallest key above 40" or "iterate in order" at all. That is what balanced trees are for: log n per operation, in exchange for successors, ranges and sorted iteration. Choosing between them is choosing whether you need ordering, not which is faster.
On disk, the shape changes
When a lookup means a block read, you want each read to do as much work as possible. A B-tree node holds hundreds of keys, so the depth is log_B n — four reads for a billion keys. That is why databases are built on B-trees and not on red-black trees.
Three questions
Your keys come from users and the hash is not seeded. The realistic worst case is:
Collision flooding is a real denial-of-service technique; seeded hashes are the fix.
You need "all keys between 100 and 200". Which structure?
Ranges need ordering. A hash table has none; a heap orders only against the extreme.
A B-tree beats a red-black tree mainly because:
The wide node exists to amortise an expensive read. In RAM the advantage disappears.
Why height is the whole story, and what keeps it low.
Cost equals height
Every operation on a search tree walks from the root to a leaf, so the cost is the height. A balanced tree has height log n; a degenerate one has height n. Every balancing scheme in this section exists to prevent the second case.
Rotations
A rotation rewrites three pointers to change which node is on top, preserving the ordering and reducing the height on one side. It is O(1). AVL applies more of them and keeps the tree shorter; red-black applies fewer and tolerates a taller tree. That is the whole difference between them.
Heaps order less
A heap only promises that a parent beats its children, which is far weaker than a search tree — and much cheaper. Peek is free, insert and extract are log n, and it lives in a flat array with no pointers. Searching one is O(n), because the ordering you need was never established.
Build is linear
Heapifying n items is O(n), not O(n log n): half the nodes are leaves and sink nowhere, a quarter sink one level, and the sum converges. It is the standard example of a bound that looks wrong until you count where the nodes actually are.
Three questions
Inserting 1, 2, 3, …, n into a plain BST gives what height?
Every key is larger than the last, so every insert goes right. This is why plain BSTs are not shipped.
Finding an arbitrary value in a binary heap costs:
The heap property says nothing about siblings, so there is nothing to guide a search.
Building a heap from n items costs:
Most nodes are near the leaves and barely sink; the total telescopes to linear.
One bound, several very different sets of promises.
The n log n floor
Any sort that only compares elements needs at least n log n comparisons in the worst case — there are n! possible orders and each comparison halves the possibilities. Counting and radix sort get under it by not comparing at all, at the cost of assumptions about the keys.
Levels times work per level
Mergesort splits to log n levels and does linear work merging each — n log n, no matter the input. Quicksort does the same when pivots land near the middle, and n levels when they do not. Same recursion, different guarantee.
What the bound does not say
Stability (do equal elements keep their order?), memory (does it need a second array?) and adaptivity (is it fast on nearly-sorted data?) are all invisible in n log n, and all of them decide which sort you actually want.
Why Timsort won
Real data arrives partly ordered — appended, merged, re-sorted. Timsort finds existing runs and merges them, so the best case is genuinely common. It is stable, adaptive, and n log n in the worst case; the price is a complicated implementation.
Three questions
You need a stable sort with a worst-case guarantee and can spare memory:
Quicksort is unstable with a quadratic worst case; heapsort is unstable.
Counting sort is O(n + k). The trap is:
Sorting 32-bit keys this way wants a four-billion-entry table.
Heapsort has optimal bounds but loses to quicksort in practice because:
Sinking a value touches indices 2i and 2i+1 — further apart at every level.
V + E, and the enormous range hiding inside that plus sign.
Reading V + E
Visiting every vertex once and every edge once is O(V + E). But E runs from V − 1 in a tree to about V² in a dense graph, so the same bound is linear or quadratic depending on the graph you have. Always ask which regime you are in before comparing two graph algorithms.
Rings versus cost
BFS expands in rings of equal hop count; Dijkstra expands in order of accumulated cost. When every edge costs the same these are the same order, which is exactly why BFS finds shortest paths on unweighted graphs — and why it silently stops being correct the moment weights differ.
What negative edges break
Dijkstra settles a vertex when it pops it and never looks again. A negative edge found later could have improved that vertex, so the result is wrong rather than slow. Bellman-Ford relaxes every edge V − 1 times instead: slower by a factor of V, correct with negatives, and able to report a negative cycle.
Dense graphs change the answer
Floyd-Warshall is V³ with three tight loops over a matrix. Running Dijkstra from every vertex is V·(V + E)·log V, which on a dense graph is V³ log V with far worse constants. Below a few hundred vertices, the "worse" algorithm wins.
Three questions
Unweighted graph, shortest path. The right tool is:
With equal weights BFS is correct and has no priority-queue overhead.
One edge has weight −3. Dijkstra will:
It commits to a vertex on pop; a later improvement cannot be applied.
Count the subproblems, count the work per subproblem. That is the bound.
The formula
Every DP bound is the number of distinct subproblems times the cost of combining them. Fibonacci: n subproblems, O(1) each — O(n). Edit distance: n × m cells, O(1) each — O(nm). Matrix chain: n² cells, O(n) each — O(n³). Once you can count both factors you do not need to memorise the table.
Why the naive version explodes
The recursion is not slow because recursion is slow; it is slow because it solves the same subproblem thousands of times. Memoisation does not change the algorithm, it changes how many times each answer is computed — from exponential to once.
Pseudo-polynomial
Knapsack is O(nW) and knapsack is NP-hard, which sounds contradictory until you notice W is a *value*. Writing it takes log W digits, so the table is exponential in the length of the input. Same trap in subset-sum and coin change.
Space is often cheaper than time
Most DP tables are filled row by row and only ever read the previous row, so O(nm) time frequently comes with O(min(n, m)) space. Reconstructing the actual path is what forces you to keep the whole table.
Three questions
A DP has n² subproblems and does O(n) work in each. Its complexity is:
Subproblems × work per subproblem. That is the whole method.
Memoising the naive Fibonacci changes it from exponential to linear because:
The call tree collapses to n distinct nodes; the loop is a convenience, not the cause.
O(nW) for knapsack is called pseudo-polynomial because:
Add three digits to the capacity and the work grows a thousandfold.
Curves are pleasant to look at and easy to forget. Commit to an answer first — the gap between what you expected and what happened is the part that sticks.
n log n versus n². Which wins, and by how much at a million?
Predict:
n
n log n
n²
Ratio
At n = 10 they are within a factor of three — you would not notice. At a million, n log n is about twenty million steps and n² is a trillion: fifty thousand times more. This gap is why sorting algorithms stopped being interesting after 1960.
A linear algorithm with a constant of 1,000 against a quadratic one with a constant of 1. Where do they cross?
Predict:
n
1000·n
n²
Ratio
They cross at n = 1,000. Below that the quadratic is faster — often by a lot — which is why real sort implementations switch to insertion sort under about 16 elements. Asymptotics tell you who wins eventually, never where eventually starts.
A logarithmic lookup: how much slower at a trillion than at a thousand?
Predict:
n
log n
n
Ratio
Ten steps becomes forty. The input grew by a factor of a billion and the work grew by four — this is why B-tree depth is a punchline, and why "just add an index" so often ends a performance conversation.
Two O(n) passes over the same array. Can one be ten times slower?
Predict:
n
sequential n
pointer-chasing n
Ratio
Yes, and routinely. Both curves are straight lines on this plot — the same class — but one reads memory in order and the other follows pointers into cache misses. Complexity classes say nothing about the constant, and the constant is what you feel today.
O(2ⁿ) at n = 40, 50 and 60. How bad does it get?
Predict:
n
2ⁿ
n³
Ratio
A trillion, then a quadrillion, then a quintillion — each ten more elements multiplies the work by a thousand. Exponential algorithms do not get slower gradually; they hit a wall, and no hardware moves it more than a few n to the right.
Trial division to n versus to √n, for primality at n = 1,000,000.
Predict:
n
√n
n
Ratio
A thousand divisions instead of a million. Stopping at the square root is not a micro-optimisation, it changes the class — and it is the cheapest example of doing so.
Derive the Big-O
A few lines of code and one question: how many times does the work happen? Reading a table is recognition; this is the skill the table stands in for.
One loop
total = 0for i in 0..n-1: total += a[i]
How many times does the addition run, and what is the class?
The body runs once per element: exactly n times. One loop over the input is the definition of linear.
Nested loops
count = 0for i in 0..n-1: for j in 0..n-1: count += 1
How many times does count += 1 run?
The inner loop runs n times for each of the n outer iterations: n × n.
The triangular loop
count = 0for i in 0..n-1: for j in i+1..n-1: count += 1
The inner loop shrinks each pass. What is the class?
The count is n(n−1)/2 = ½n² − ½n. The constant ½ is dropped, so it is quadratic — the same class as the full nested loop, at half the work.
Halving the input
i = nwhile i > 1: i = i / 2 step()
How many times does step() run?
The question "how many halvings reach 1?" is the definition of log₂ n. A million becomes twenty steps.
Two loops in a row
for i in 0..n-1: step()for j in 0..n-1: step()
Sequential loops, not nested. The class is:
Sequential work adds, nested work multiplies. n + n = 2n, and the constant is dropped.
Divide and conquer with a linear merge
sort(a): if a.len <= 1: return a l = sort(first half) r = sort(second half) return merge(l, r) # linear in a.len
Linear work at each level, halving each time. Total?
log n levels of recursion, and every level does n work in total across all its calls. That product is the n log n of mergesort.
A log inside a loop
for i in 0..n-1: binary_search(sorted, a[i]) # log n
A linear loop whose body is logarithmic:
Nested costs multiply: n iterations × log n per iteration. This is the shape of "sort, then look each item up".
The early exit
for i in 0..n-1: if a[i] == target: return i # maybe immediatelyreturn not_found
The return can fire on the first element. What is the worst case?
An early exit improves the best case, Ω(1), and leaves the worst case untouched. Missing values always cost the full scan.
Simplify the expression
# counted by hand:# 3n² + 2n + 7 operations
What is this in Big-O?
Drop the coefficient, drop the lower terms. At n = 1,000 the square term is 3,000,000 against 2,007 for the rest — the remainder is rounding error.
The hidden copy
s = ""for i in 0..n-1: s = s + "x" # builds a NEW string
With immutable strings, what does this cost?
Iteration i copies i characters, so the total is 1 + 2 + … + n ≈ n²/2. The quadratic is invisible in the source and obvious in the profiler.
Flashcards, both directions
Most practice runs name → complexity. The direction engineering asks in is the reverse: here is my requirement, what fits? Both are here.
Your progress
Kept in this browser and nowhere else — no account, no server, nothing anyone has to delete later. Export it if you want it elsewhere.
Reading the sheet
O upper bound
It will not do worse than this. The one people mean when they say “big-O”.
Ω lower bound
It will not do better than this — the best case, on the input it likes most.
Θ tight bound
Upper and lower agree. The growth is this, not merely bounded by it.
n and its friends
n is the number of elements; m a pattern or key length; k an output size or key range; V
and E a graph's vertices and edges. Focus or tap any chip to see what its symbols mean
in that particular row.
Every symbol
n
The number of elements — items in the structure, keys in the map.
m
A second size: the length of a pattern or key, or the edge count in some texts.
k
An output size — how many results a query actually returns.
V
A graph's vertices.
E
A graph's edges.
W
The largest edge weight, or the width of a numeric range.
α(n)
The inverse Ackermann function. Below 5 for any n you can store, so it is 'constant' in every practical sense.
B
A disk block or cache line — how many keys travel together in one read.
d
The number of dimensions, or the alphabet size, depending on the section.
Guarantee badges
worst case
The bound holds for every input, with no assumptions.
expected
Holds on average over the algorithm's own randomness, not over your data.
average
Holds on typical data. Adversarial input can be worse.
amortised
Any run of operations averages to this, though one of them may be far worse.
cache-friendly
Touches memory in order. Often several times faster than the same bound with pointer chasing.
disk-friendly
Designed so one block read does a lot of work — the reason B-trees run databases.
stable
Equal elements keep their original order.
in place
Constant extra memory.
adaptive
Faster on nearly-sorted input.
Inside the cards the O( ) is dropped: the column
heading already says which bound you are reading, and two hundred repetitions of the same
three characters is ink you have to look past. Constants are dropped too, so a linear pass
that touches every byte twice and one that touches it once share a colour. At small n the
constant is usually what you feel; this sheet is about what you feel later.