Chapter 1

Complexity and Big-O

O/Theta/Omega notation, amortized analysis, and reading complexity off real code.

Complexity and Big-O

The measuring stick for everything else in this guide. This file covers the formal machinery (O/Theta/Omega, amortized analysis, recurrences) but spends most of its space on the two things that actually get you through an interview: mechanically deriving a bound from code you are looking at, and knowing the real complexity of the language built-ins you reach for by reflex. Every number in the benchmark tables was measured on the machine this guide was written on — CPython 3.11.15 and Node 22.22.2 / V8 12.4 on a small 2-core cloud container. Treat the absolute values as indicative and the shapes (the growth exponents) as the point.

Table of contents


1. Why asymptotics, and where they lie

The model is the RAM machine: unit-cost arithmetic on machine words, unit-cost indexed memory access, and we count operations as a function of input size. That model buys us the ability to compare algorithms without a benchmark, and it costs us three things you should be ready to name when an interviewer pushes:

  1. Memory is not unit-cost. L1 is roughly 1 ns, main memory roughly 100 ns. Two Theta(n^2) loops over the same matrix differ by an order of magnitude depending on stride. Measured here, walking a flat array('q') of n*n with stride 1 versus stride n — identical operation count:

         n       stride 1       stride n   slowdown
       512         0.0142         0.0286      2.02x
      1024         0.0519         0.1521      2.93x
      2048         0.2173         0.7567      3.48x

    And in Node, on a Float64Array, where the JIT removes the interpreter overhead and exposes the memory system directly:

         n      MiB   stride 1 s   stride n s   slowdown
       512      2.0      0.00032      0.00087      2.75x
      1024      8.0      0.00133      0.00355      2.66x
      2048     32.0      0.00894      0.03523      3.94x
      4096    128.0      0.03491      0.21943      6.29x

    The slowdown grows with n because the working set outgrows each cache level in turn. This is why an array-backed structure beats a pointer-based one with the same asymptotics: same Theta(n) walk, 4x apart at n = 4,000,000.

            n    contiguous        chased   slowdown
      100,000        0.0060        0.0104      1.72x
    1,000,000        0.0471        0.1094      2.32x
    4,000,000        0.1932        0.7609      3.94x
  2. Constants are the whole story at interview-sized n. O(log n) beats O(n) asymptotically, but where is the crossover? Measured, linear scan versus bisect on a sorted Python list:

         n   linear ns/query   bisect ns/query     winner
         2             145.9              61.9     bisect
         4             165.0             141.3     bisect
         8             426.7             165.7     bisect
        16             616.3             196.5     bisect
       512            6500.6             204.8     bisect

    Here bisect wins from n = 2, because it is C code and the Python loop is bytecode. Rewrite the linear scan in C (in on a list) and the crossover moves to a few dozen. The lesson is not “always binary search” — it is that the crossover is an empirical question about constants, and the asymptotics only tell you which side of it grows.

  3. n is often small and bounded. Insertion sort beats quicksort below ~16 elements, which is exactly why every production sort (TimSort in V8 and CPython, introsort in C++) switches to it. Saying this out loud is a strong signal.

State the bound, then state the caveat. “It’s O(n log n), but the comparator dominates so in practice I’d care more about how expensive key() is” is a better answer than the bound alone.


2. Formal definitions

For functions f, g from the naturals to the non-negative reals:

NotationMeaningLimit form
f = O(g)there exist c > 0, n0 such that f(n) <= c·g(n) for all n >= n0limsup f/g < infinity
f = Omega(g)there exist c > 0, n0 such that f(n) >= c·g(n) for all n >= n0liminf f/g > 0
f = Theta(g)both of the above0 < lim inf <= lim sup < infinity
f = o(g)for every c > 0 there is n0 with f(n) < c·g(n)lim f/g = 0
f = omega(g)for every c > 0 there is n0 with f(n) > c·g(n)lim f/g = infinity

Read = as “is in”. O is an upper bound, Omega a lower bound, Theta a tight bound.

Proving membership. To show 3n^2 + 5n + 7 = O(n^2): for n >= 1, 3n^2 + 5n + 7 <= 3n^2 + 5n^2 + 7n^2 = 15n^2, so c = 15, n0 = 1. To show it is not O(n): 3n^2/n = 3n grows without bound, so no constant c works.

The two misuses to avoid.

  • “Worst case” is not the same as “upper bound”. O describes a function; “worst case” describes which input you chose. You can legitimately say “the best case is O(n)” — those are orthogonal axes. Sloppiness here is a common interview tell.
  • O is an upper bound, so it is not tight by definition. Merge sort is O(n^100). True and useless. If you know the bound is tight, say Theta; most people say O and mean Theta, which is fine as long as you can answer “is that tight?”

Useful algebra.

  • O(f) + O(g) = O(max(f, g)); constants and lower-order terms vanish.
  • O(f)·O(g) = O(f·g) — nesting multiplies.
  • log_a n = Theta(log_b n), so the base of a logarithm is a constant factor and is dropped.
  • n^k = o(c^n) for any k and c > 1; log^k n = o(n^e) for any e > 0. Polynomials beat logs, exponentials beat polynomials.
  • Sum of the first n integers is Theta(n^2); the harmonic sum H(n) = Theta(log n).
  • Stirling: log(n!) = Theta(n log n). This is the comparison-sort lower bound in disguise.

3. The growth hierarchy and what n you can afford

graph LR
  A["O(1)<br/>hash lookup"] --> B["O(log n)<br/>binary search"]
  B --> C["O(sqrt n)<br/>trial division"]
  C --> D["O(n)<br/>single scan"]
  D --> E["O(n log n)<br/>comparison sort"]
  E --> F["O(n^2)<br/>nested loops"]
  F --> G["O(n^3)<br/>Floyd-Warshall"]
  G --> H["O(2^n)<br/>subsets"]
  H --> I["O(n!)<br/>permutations"]
Classn=10n=100n=1,000n=1,000,000Typical source
O(1)1111hash lookup, array index, arithmetic
O(log n)371020binary search, balanced tree, heap push
O(sqrt n)310321,000trial division, sqrt decomposition
O(n)101001,0001e6single scan, two pointers, counting
O(n log n)336649,9662.0e7comparison sort, divide and conquer, heap-per-element
O(n^2)1001e41e61e12nested loops, all pairs, naive DP over pairs
O(n^3)1,0001e61e91e18Floyd-Warshall, matrix multiply, interval DP
O(2^n)1,0241.3e30subsets, naive recursion without memo
O(n!)3.6e6permutations, brute-force TSP

3.1 What actually fits in one second

Measured by calibrating each shape on real code and extrapolating to a 1-second budget.

CPython 3.11.15 (pure-Python loops):

class                |      calib n | measured s |          max n in 1s
------------------------------------------------------------------------
O(log n)             |    1,048,576 |     0.2302 |             1.41e+26
O(sqrt n)            |  100,000,000 |     0.0013 |   56,049,500,102,307
O(n)                 |    3,000,000 |     0.1235 |           24,287,143
O(n log n) pure-py   |      200,000 |     0.5418 |              352,754
O(n log n) .sort()   |    1,000,000 |     0.3286 |            2,829,789
O(n^2)               |        2,000 |     0.1217 |                5,733
O(n^3)               |          250 |     0.3883 |                  342
O(2^n)               |           22 |     0.1833 |                   24
O(n!)                |           10 |     0.2487 |                   10

Node 22 / V8 (same shapes, JIT-compiled):

class                  |      calib n | measured s |          max n in 1s
--------------------------------------------------------------------------
O(log n)               |    1,048,576 |     0.0039 | effectively unbounded
O(sqrt n)              |  100,000,000 |     0.0002 |             3.97e+15
O(n)                   |   30,000,000 |     0.0367 |          818,045,450
O(n log n) hand-merge  |      500,000 |     0.3726 |            1,254,098
O(n log n) .sort()     |    2,000,000 |     1.7885 |            1,161,732
O(n^2)                 |       10,000 |     0.1552 |               25,387
O(n^3)                 |          700 |     0.5332 |                  863
O(2^n)                 |           25 |     0.0514 |                   29
O(n!)                  |           11 |     0.4593 |                   11

Rules of thumb worth memorizing, derived from the above:

  • Node’s tight numeric loop is ~30x faster than CPython’s. 8e8 vs 2.4e7 simple operations per second. That gap is the single biggest practical difference between the two languages in a coding round, and it is why the same O(n^2) solution passes in JS and TLEs in Python.
  • Pushing work into C is worth an order of magnitude in Python. list.sort() handles 2.8e6 elements per second while a hand-written pure-Python merge sort handles 3.5e5 — 8x. Same asymptotics. In Node the JIT closes that gap entirely (hand-merge actually beats .sort() here because .sort() pays a comparator call per comparison).
  • Competitive-programming heuristic: with a 1-second limit assume ~1e8 simple ops in a compiled language, ~1e7 in Node, ~1e6–1e7 in Python. From the constraint you can read off the intended complexity: n <= 20 means exponential/bitmask; n <= 500 means O(n^3); n <= 5,000 means O(n^2); n <= 1e5 means O(n log n); n <= 1e7 means O(n) and you should avoid allocating.

4. Space complexity and the recursion stack

Auxiliary space is what your algorithm allocates beyond the input; total space includes the input. “In-place” conventionally means O(1) auxiliary — which is why in-place quicksort is still O(log n) space (the recursion stack) and why heapsort is the genuinely O(1) comparison sort.

Common auxiliary costs:

StructureSpace
Recursion depth d, frame size fO(d·f)
Merge sort (array)O(n); O(log n) for linked lists
QuicksortO(log n) expected, O(n) worst without tail-recursion elimination
BFS queueO(V) in the worst case, O(b^d) in a branching search
DFS stackO(V) worst, O(h) on a tree
Memoization tableO(distinct states)
Adjacency list / matrixO(V + E) / O(V^2)

4.1 Measured recursion limits

Python guards depth with a counter, not the real stack:

sys.getrecursionlimit() default = 1000
frames reached before RecursionError (default limit) = 998
limit=2,000,000 thread stack=  1 MiB -> depth 1999996 (RecursionError)
limit=2,000,000 thread stack=  8 MiB -> depth 1999996 (RecursionError)

Two things to notice. First, the default ceiling is ~1000 frames, so any recursion over an input of size 1e5 needs to be iterative — a linked list of 100,000 nodes will blow up a recursive reversal. Second, since CPython 3.11 pure-Python frames live on a heap-allocated chunked stack, so raising sys.setrecursionlimit no longer segfaults for simple Python recursion (the thread stack size stopped mattering — all three thread-stack sizes reached the same depth). Recursion that crosses back into C (__repr__, sorted with a recursive key, copy.deepcopy) still uses the C stack and there threading.stack_size() plus a worker thread is the real fix.

Node guards with the actual stack, so frame size matters:

stack-size flag: (default)
no-arg frame        : 12554
4-arg frame         :  6974
8-local frame       :  6974

Roughly 12,500 trivial frames, halving as you add arguments or locals. node --stack-size=N (in KB) raises it, at the risk of a hard segfault instead of a catchable RangeError. There are no proper tail calls in V8, so the fix is always an explicit stack or a loop.

The interview-relevant conclusion: if the input can exceed ~1000 (Python) or ~10,000 (Node), convert recursion to iteration. Both languages give you the same three tools — an explicit stack, an explicit work queue, or bottom-up DP instead of top-down memoization.


5. Amortized analysis

Amortized cost is the average cost per operation over a worst-case sequence. It is not the average case (which averages over inputs, using probability) and it is not the best case. A sequence of n pushes on a dynamic array costs O(n) total, so O(1) amortized, even though one individual push costs O(n).

Three methods:

MethodIdea
Aggregatebound the total cost of n operations, divide by n
Accountingovercharge cheap operations, store the credit, spend it on expensive ones; show the balance never goes negative
Potentialdefine Phi(state) >= 0; amortized cost = actual cost + Phi(after) - Phi(before); telescoping gives the total

5.1 Dynamic array growth

Aggregate. With a doubling policy starting at capacity 1, resizes happen at sizes 1, 2, 4, …, and copy that many elements. Total copying for n appends is 1 + 2 + 4 + … + 2^(log n) < 2n = O(n), so O(1) amortized.

Potential. Let Phi = 2·size - capacity. A non-resizing append costs 1 and raises Phi by 2, so amortized 3. A resizing append (size = capacity = k) costs k+1 to copy and Phi goes from k to 2(k+1) - 2k = 2, a drop of k-2, so amortized 3. Constant either way.

Growth factor matters. With a factor of g, the total copy cost is n·g/(g-1). Doubling gives 2n; 1.5x gives 3n; and CPython’s ~1.125x gives 9n — nine times more copying, in exchange for far less wasted memory and a much better chance of extending the allocation in place. Measured CPython list growth (sys.getsizeof as we append):

     len  getsizeof   capacity   event
       1         88          4   grow
       5        120          8   grow
       9        184         16   grow
      17        248         24   grow
      25        312         32   grow
      33        376         40   grow
      41        472         52   grow
      53        568         64   grow

total reallocations while appending 1,000,000 items: 86
capacity sequence (first 24): [4, 8, 16, 24, 32, 40, 52, 64, 76, 92, 108, 128, 148, 172, 200, 232, ...]
growth ratio: first=2.000 median=1.12569 last=1.12501
asymptotic factor approaches 1.125006  (CPython: new = n + n>>3 + 6, rounded)

86 reallocations for a million appends, and the ratio converges on 9/8. The formula in CPython’s list_resize is new_allocated = ((size_t)newsize + (newsize >> 3) + 6) & ~(size_t)3 — grow by an eighth, add a fudge factor, round to a multiple of 4. sys.getsizeof([]) == 56 (header only) and each slot is one 8-byte pointer, which is how you read capacity out of getsizeof.

V8 grows JS arrays by roughly 1.5x plus a constant (new = old + old/2 + 16), and shrinks when the array becomes less than half full.

5.2 The two-stack queue

Keep inbox and outbox. enqueue pushes onto inbox. dequeue pops outbox, and when outbox is empty, pours all of inbox into it.

class Queue:
    def __init__(self): self._in, self._out = [], []
    def enqueue(self, x): self._in.append(x)
    def dequeue(self):
        if not self._out:
            while self._in: self._out.append(self._in.pop())
        if not self._out: raise IndexError("empty")
        return self._out.pop()

A single dequeue can cost O(n), but each element is moved from inbox to outbox exactly once in its lifetime. Accounting: charge 3 credits at enqueue (one to push, one to move, one to pop later); the balance is never negative, so every operation is O(1) amortized.

Say the caveat too: amortized O(1) is not the same as worst-case O(1), and for a real-time system (a frame budget, an audio callback) the O(n) spike is a bug. That is what a fixed-capacity ring buffer or an incremental-rehash hash table is for.

5.3 Other classic amortized results

StructureAmortized resultNote
Hash table with doubling + rehashO(1) insertworst-case single insert O(n)
Union-Find with path compression + union by rankO(alpha(n)) per opalpha <= 4 for any realistic n
Fibonacci heapO(1) insert/decrease-key, O(log n) extract-minhuge constants; rarely worth it
Splay treeO(log n) per opno per-op guarantee at all
Incrementing a binary counterO(1) per incrementthe canonical accounting example
Monotonic stack over n elementsO(n) totalevery element pushed and popped once

6. Recurrences and the Master Theorem

6.1 The three techniques

Substitution — guess and prove by induction. Guess T(n) <= c·n·log n for merge sort: T(n) = 2T(n/2) + n <= 2c(n/2)log(n/2) + n = cn log n - cn + n <= cn log n for c >= 1.

Recursion tree — draw the levels, sum the work per level, multiply by the number of levels. For T(n) = 2T(n/2) + n: each level does n work, there are log n levels, total Theta(n log n). For T(n) = 2T(n/2) + 1: level i does 2^i work, the last level dominates with n leaves, total Theta(n).

Master Theorem — for T(n) = a·T(n/b) + f(n) with a >= 1, b > 1, let c_crit = log_b(a):

CaseConditionResultReading
1f(n) = O(n^(c_crit - e)) for some e > 0Theta(n^c_crit)leaves dominate
2f(n) = Theta(n^c_crit · log^k n), k >= 0Theta(n^c_crit · log^(k+1) n)every level costs the same
3f(n) = Omega(n^(c_crit + e)) and a·f(n/b) <= c·f(n) for some c < 1 (regularity)Theta(f(n))the root dominates

The regularity condition in case 3 is what people forget. It fails for pathological f like n^2·sin^2(n); when it fails, use Akra–Bazzi.

6.2 Worked examples

Recurrencea, b, c_critCaseResultAlgorithm
T(n) = 2T(n/2) + Theta(n)2, 2, 12 (k=0)Theta(n log n)merge sort, quicksort best case
T(n) = T(n/2) + Theta(1)1, 2, 02 (k=0)Theta(log n)binary search
T(n) = 2T(n/2) + Theta(1)2, 2, 11Theta(n)tree traversal, heapify-style
T(n) = 8T(n/2) + Theta(n^2)8, 2, 31Theta(n^3)naive matrix multiply
T(n) = 7T(n/2) + Theta(n^2)7, 2, log2(7)=2.8071Theta(n^2.807)Strassen
T(n) = 3T(n/2) + Theta(n)3, 2, 1.5851Theta(n^1.585)Karatsuba multiplication
T(n) = T(n/2) + Theta(n)1, 2, 03Theta(n)quickselect best case; also the geometric-series intuition n + n/2 + n/4 + … = 2n
T(n) = 2T(n/2) + Theta(n log n)2, 2, 12 (k=1)Theta(n log^2 n)merge sort with an n log n merge
T(n) = 4T(n/2) + Theta(n^2)4, 2, 22 (k=0)Theta(n^2 log n)some closest-pair variants
T(n) = T(n-1) + Theta(n)not Master formtreeTheta(n^2)quicksort worst case, insertion sort, selection sort
T(n) = T(n-1) + Theta(1)not Master formtreeTheta(n)linear recursion
T(n) = 2T(n-1) + Theta(1)not Master formtreeTheta(2^n)naive Fibonacci, subset enumeration
T(n) = T(sqrt n) + Theta(1)substitute m = log nTheta(log log n)van Emde Boas-ish

Quickselect’s average case needs a different argument: E[T(n)] = T(3n/4) + O(n) in expectation over random pivots (the pivot lands in the middle half with probability 1/2), which telescopes to O(n) — the same geometric series as the case-3 row above. Median-of-medians makes that worst-case O(n) at the cost of a large constant.

Akra–Bazzi, in one line, for unequal splits T(n) = sum(a_i·T(b_i·n)) + f(n): find p with sum(a_i·b_i^p) = 1, then T(n) = Theta(n^p·(1 + integral of f(u)/u^(p+1) du)). You will almost never need it, but naming it when a recurrence has unequal splits (like T(n) = T(n/3) + T(2n/3) + n, which is Theta(n log n)) is a good signal.


7. Reading complexity off code, mechanically

7.1 Loops

for i in range(n):            # n
    for j in range(n):        # x n
        work()                # = Theta(n^2)

for i in range(n):            # dependent bounds: sum_{i} i = n(n-1)/2
    for j in range(i):
        work()                # = Theta(n^2), same class, half the constant

i = 1
while i < n:                  # multiplicative update
    i *= 2                    # = Theta(log n)

for i in range(n):            # harmonic sum: n/1 + n/2 + n/3 + ... = n·H(n)
    for j in range(i, n, i+1):
        work()                # = Theta(n log n)   <- this is the sieve pattern

The sieve of Eratosthenes is the famous case: the inner loop runs n/p times for each prime p, and sum over primes of n/p = Theta(n log log n) by Mertens’ theorem. Interviewers accept “n log log n, essentially linear”.

7.2 The nested loop that is not quadratic

This is the single most common place people misjudge a bound. Two rules:

Rule 1 — count total inner iterations, not the nesting depth.

# Two pointers: the inner while never resets. Each pointer advances at most n times.
l = 0
for r in range(n):
    while l < r and bad(l, r):
        l += 1                     # total work across ALL iterations <= n
# Theta(n), despite the nested while.

# Monotonic stack: each element is pushed once and popped at most once.
st = []
for x in arr:
    while st and st[-1] < x:
        st.pop()                   # total pops <= n
    st.append(x)
# Theta(n).

Rule 2 — if the inner loop’s bound is reset each time, it multiplies.

for r in range(n):
    l = 0                          # reset! now it really is quadratic
    while l < r: l += 1
# Theta(n^2).

The question to ask yourself is always: what is the total number of times this line executes over the whole run? Not how deep is it nested?

7.3 Recursion

Count nodes in the recursion tree times work per node.

def fib(n):                    # branching factor 2, depth n
    return n if n < 2 else fib(n-1) + fib(n-2)
# Theta(phi^n) ~ Theta(1.618^n), commonly stated as O(2^n).

def fib_memo(n, memo={}):      # each distinct n computed once
    ...
# Theta(n) time, Theta(n) space.

def subsets(arr, i=0, cur=[]):        # 2^n leaves, and O(n) to copy each
    ...
# Theta(n·2^n) time. The copy matters: people say 2^n and lose the n.

def permutations(arr): ...            # n! leaves, O(n) per leaf -> Theta(n·n!)

For a DP, the bound is almost always (number of states) x (work per state). Say it that way and you will never miscount: “the state is (index, remaining capacity), so O(n·W) states, O(1) transition, therefore O(n·W) time and O(W) space with the rolling array.”

7.4 Hidden linear costs

The most common source of an accidental extra factor of n:

a[1:]              # copies n-1 pointers    -> O(n)
s[i:j]             # copies characters      -> O(j-i)
list(x)            # O(n)
x in some_list     # O(n)
arr.insert(0, v)   # O(n) memmove
arr.pop(0)         # O(n) memmove
max(a)             # O(n) -- and O(n^2) when it is inside a loop
len(list(gen))     # consumes the generator, O(n), and the generator is now exhausted
[...arr]           // O(n)
arr.shift()        // O(n)
arr.unshift(v)     // O(n)
arr.splice(i,1)    // O(n)
arr.concat(b)      // O(n+m)
str.slice(i)       // O(1) in V8 (SlicedString) but flattening later is O(n)
Object.keys(o)     // O(n)

Measured, tail slicing inside a loop — 1000 slices of a list of n:

### 1000 tail slices a[i:] of a list of n   [each slice copies O(n) pointers]
         n      seconds        ns/op   t(2n)/t(n)   exponent
   100,000     0.135801     135801.1
   200,000     0.312493     312493.4         2.30       1.20
   400,000     0.907807     907806.9         2.91       1.54
   800,000     2.837960    2837960.0         3.13       1.64

The classic bug this creates is a “recursive quicksort” written with slicing, which is O(n log n) comparisons but O(n log n) copying on top, and a “sliding window” that re-slices instead of moving indices, which is O(n^2).


8. Complexity of the built-ins

8.1 JavaScript / TypeScript

OperationComplexityMutatesNotes
arr[i], arr[i] = vO(1)O(1) only while the array is not in dictionary mode
arr.push / arr.popO(1) amortizedyesgrowth ~1.5x + 16
arr.shift / arr.unshiftO(n)yesreindexes everything; use a deque or index cursor
arr.splice(i, d, ...ins)O(n)yesO(n) even for one element
arr.slice(a, b)O(b-a)nocopy
arr.concat(b)O(n+m)no
arr.indexOf / includes / find / some / everyO(n)noincludes finds NaN, indexOf does not
arr.sortO(n log n)yesTimSort, stable; default comparator is lexicographic
arr.toSorted / toReversed / with / toSplicedO(n)noES2023 copies
arr.reverseO(n)yes
arr.joinO(total length)no
arr.flat(d)O(total elements)noInfinity allowed
arr.length = k (shrink)O(1)–O(n)yestruncation; growing creates holes
delete arr[i]O(1)yescreates a hole, permanently deoptimizes
Map.get/set/has/deleteO(1) averageSameValueZero keys, insertion-ordered
Set.add/has/deleteO(1) average
Set.union/intersection/differenceO(n+m) / O(min) / O(n)noES2025
Map/Set iterationO(n)insertion order, always
obj.k (fixed shape)O(1)inline-cached to an offset load
obj[dynamicKey] on a growing objectO(1) averagedictionary mode, slower constant
Object.keys/values/entriesO(n)nointeger-like keys first, ascending, then strings in insertion order
{...obj} / Object.assignO(n)noown enumerable only, invokes getters
structuredClone(x)O(size)nohandles cycles, Map/Set/Date; not functions
str[i], str.lengthO(1)UTF-16 code units, not characters
str + str2O(1)builds a ConsString (rope); flattening later is O(n)
str.slice/substringO(1)SlicedString view
str.split('')O(n) allocationsavoid; use [...str] for code points
str.indexOf/includes/replaceO(n·m) worst
JSON.parse/stringifyO(size)stringify is a full traversal
new Array(n).fill(v)O(n)keeps the array packed
Array.from({length: n}, f)O(n)packed, no intermediate

8.2 Python

OperationAverageAmortized worstNotes
list[i], list[i] = vO(1)O(1)
list.append / pop()O(1)O(1)growth factor 9/8
list.insert(i, v) / pop(i) / del list[i]O(n)O(n)memmove
list.extend(k)O(k)O(k)
x in listO(n)O(n)
list[a:b]O(b-a)O(b-a)copy
list.sort() / sorted()O(n log n)O(n log n)Timsort, stable, O(n) on sorted input
list.reverse()O(n)O(n)
min/max/sumO(n)O(n)
dict[k], k in dict, dict[k]=v, delO(1)O(n)worst case needs adversarial hashes
dict iteration / copyO(n)O(n)insertion-ordered since 3.7 (implementation detail in 3.6)
set add/remove/inO(1)O(n)
s | tO(len(s)+len(t))
s & tO(min(len(s), len(t)))O(len(s)·len(t))iterates the smaller side
s - tO(len(s))
s ^ tO(len(s))
deque.append/appendleft/pop/popleftO(1)O(1)doubly-linked blocks of 64
deque[i]O(n)O(n)not random access; O(1) only near the ends
deque.rotate(k)O(k)O(k)
deque.remove(x) / x in dequeO(n)O(n)
heapq.heappush / heappopO(log n)O(log n)min-heap only
heapq.heapifyO(n)O(n)not n log n
heapq.nlargest(k, it)O(n log k)beats sorted(it)[:k] for small k
bisect_left/rightO(log n)O(log n)search only
bisect.insortO(n)O(n)O(log n) search + O(n) memmove
str[i], len(s)O(1)O(1)code points; Python strings are not UTF-16
s + tO(n+m)O(n+m)new object; see the += note below
s[a:b]O(b-a)O(b-a)copies
''.join(list)O(total)O(total)the canonical builder
s in tO(n·m) worstuses a Crochemore-Perrin variant, near-linear in practice
Counter(iterable)O(n)O(n)most_common(k) is O(n log k)
Counter.most_common()O(n log n)full sort when k is omitted
functools.lru_cache hitO(1)dict + doubly-linked list, thread-safe

Verified against the Python wiki TimeComplexity page.


9. The measured traps

Every table below is real output. The “exponent” column is log2(t(2n)/t(n)) — the empirical growth exponent, so 1.0 means linear and 2.0 means quadratic. This is the single most useful debugging technique in this file: double n and see what the time does.

9.1 list.insert(0, x) vs deque.appendleft

### list.insert(0, x), n times                    ### deque.appendleft(x), n times
         n      seconds   ns/op   exponent                 n      seconds   ns/op   exponent
     4,000     0.002616   653.9                      100,000     0.002883    28.8
     8,000     0.010210  1276.2       1.96            200,000     0.005779    28.9      1.00
    16,000     0.041631  2601.9       2.03            400,000     0.012648    31.6      1.13
    32,000     0.163582  5112.0       1.97            800,000     0.028289    35.4      1.16

Quadratic versus linear, and at n = 32,000 the per-operation cost is already 5.1 microseconds versus 35 nanoseconds — 145x. Same story for list.pop(0) (exponent ~2.0, 46 microseconds per op at 32,000) versus deque.popleft (exponent ~1.1, 38 ns).

If you write a BFS with queue.pop(0) on a graph with 1e5 nodes, you have written an O(V^2) BFS. This is the most common performance bug in Python interview code.

9.2 in list vs in set vs in dict

### `x in list` (200,000 probes)            ### `x in set` (200,000 probes)
     n      ns/probe   exponent                  n      ns/probe   exponent
   500        1410.5                           500          42.6
 1,000        2556.0       0.86              1,000          42.8       0.01
 2,000        5182.0       1.02              2,000          43.2       0.01
 4,000       10911.8       1.07              4,000          43.8       0.02

Exponent 1.0 versus 0.0 — the definition of O(n) versus O(1). At n = 4,000 the set is 250x faster, and the gap grows without bound. dict measures identically to set (49 ns, exponent 0).

The interview move: any time you see a membership test inside a loop, hoist the collection into a set (or a JS Set). That single change is what turns most accidental O(n^2) solutions into O(n).

9.3 String building in Python, and the subtlety everyone gets wrong

### s += 'x' (plain local)  [CPython in-place resize fires -> LINEAR]
         n      seconds        ns/op   exponent
   100,000     0.004188         41.9
   200,000     0.008397         42.0       1.00
   400,000     0.016957         42.4       1.01
   800,000     0.033730         42.2       0.99

### s += 'x' (one extra reference alive)  [in-place resize BLOCKED -> QUADRATIC]
    25,000     0.004784        191.4
    50,000     0.028328        566.6       2.57
   100,000     0.158448       1584.5       2.48
   200,000     0.760010       3800.0       2.26

### obj.s += 'x' (attribute, not a local)  [STORE_ATTR -> QUADRATIC]
    16,000     0.002362        147.6       1.30
    32,000     0.010054        314.2       2.09

### ''.join(genexpr)      -> 33.9 ns/op, exponent 1.00
### append then ''.join   -> 26.2 ns/op, exponent 1.00

This is a great thing to know because it is more nuanced than the folklore. CPython has an in-place resize optimization for s += t: when the target is a local variable holding the only reference to the string (refcount 1) and the opcode is STORE_FAST, it can realloc in place, making the loop linear. The moment anything else holds a reference — another variable, a list, an attribute on an object, a closure cell — the optimization is disabled and you are back to quadratic.

So the correct interview answer is: ''.join() is the reliable linear builder; += is linear only by accident and the accident is easy to break. Note the contrast with JavaScript, where += builds a rope and is genuinely fine (see JS core, section 8.4).

9.4 Set intersection iterates the smaller side

### set(n) & set(100), 20,000 times   [O(min(|s|,|t|)) -> exponent ~0]
    10,000     1577.0 ns
    20,000     1559.5 ns    -0.02
    40,000     1604.5 ns     0.04
    80,000     1545.2 ns    -0.05

### set(n) & set(n), 200 times        [both sides size n -> exponent ~1]
    10,000   161,440 ns
    20,000   337,346 ns      1.06
    40,000   671,539 ns      0.99
    80,000 1,217,764 ns      0.86

Intersecting a set of 80,000 with a set of 100 costs the same as intersecting 10,000 with 100 — O(min(len(s), len(t))), exactly as documented. Union and difference are O(len(s) + len(t)) and O(len(s)). Knowing which side is iterated lets you write small & big deliberately.

9.5 bisect.insort is not O(log n)

### bisect_left, n times (search only)      ### bisect.insort, n times
         n      ns/op   exponent                     n      ns/op   exponent
   100,000      500.5                             4,000      520.5
   200,000      538.8       1.11                  8,000     1867.2       2.84
   400,000      538.3       1.00                 16,000     3028.6       1.70
   800,000      550.8       1.03                 32,000     5791.9       1.94

The search is O(log n) (flat per-op cost), but the insert is an O(n) memmove, so building a sorted list with insort is O(n^2). Use a heap if you only need the extremum, sortedcontainers.SortedList if you need order-statistics, or collect-then-sort if you can (sorted() on 1e6 elements is 0.33 s).

9.6 Quick reference of accidental quadratics

Looks likeActually isFix
for x in b: if x in a_listO(n·m)a_set = set(a) first
queue.pop(0) in BFSO(V^2)collections.deque
arr.shift() in a JS queueO(n^2)index cursor or ring buffer
s += chunk in a loop (Python)O(n^2) when refcount > 1''.join(parts)
result = result + [x]O(n^2)result.append(x)
arr.splice(i, 1) inside a loopO(n^2)filter into a new array, or swap-with-last
slicing the input in recursionextra O(n log n) copyingpass (lo, hi) indices
max(window) per stepO(n·k)monotonic deque, O(n)
sorted() inside a loopO(n^2 log n)sort once, or use a heap
list.insert(0, x)O(n^2)deque.appendleft, or append and reverse at the end
recomputing len(list(gen))O(n) each and exhausts the generatormaterialize once
del arr[0] in a loopO(n^2)iterate forwards building a new list
string concatenation to build a matrix rowO(n^2)list of pieces + join
Object.keys(o).includes(k)O(n)k in o or Object.hasOwn(o, k)
dict.keys() membership in old Python 2 habitsfine now — k in d is O(1)just use k in d

10. Expected and probabilistic complexity

Average case averages over an input distribution; expected case averages over the algorithm’s own random choices. The second is the one you control.

AlgorithmGuaranteeWhy
Randomized quicksortO(n log n) expected, O(n^2) worst with probability ~0a random pivot splits 25/75 or better with probability 1/2
Quickselect (random pivot)O(n) expected, O(n^2) worstgeometric series over shrinking subproblems
Median-of-medians selectO(n) worstdeterministic, constant ~5x larger
Hash tableO(1) expected under simple uniform hashingexpected chain length = load factor alpha = n/m
Skip listO(log n) expectedcoin-flip levels; O(n) worst with probability 2^-n
TreapO(log n) expectedrandom priorities make it a random BST
Bloom filterO(k) always; false positive rate (1 - e^(-kn/m))^kno false negatives
Miller-RabinO(k log^3 n)Monte Carlo: may be wrong, bounded probability

Load factor. For separate chaining, the expected probe length is 1 + alpha. For open addressing with linear probing it is roughly 1/(1-alpha) for a successful search, which is why real implementations resize around alpha = 0.66 (CPython dicts) or 0.75 (Java HashMap). Above 0.9 the constant explodes.

Monte Carlo vs Las Vegas. Monte Carlo: fixed running time, possibly wrong answer (Miller-Rabin, Bloom filter). Las Vegas: always correct, running time is a random variable (randomized quicksort, skip list). Interviewers like this distinction because it maps directly onto “which risk can your system tolerate”.

Hash flooding. The O(n) worst case for a hash table is reachable by an adversary who knows your hash function — a real DoS vector against HTTP query parsing. The mitigations are randomized seeds (Python’s PYTHONHASHSEED, on by default since 3.3) and SipHash for string keys. Saying this shows you know that “O(1) average” has a security footnote.


11. Lower bounds and “can you do better?”

When an interviewer asks “can you do better?”, there are only four honest answers, and knowing which one applies is the skill.

1. Yes — here is the better algorithm. Usually via a better data structure (heap, hash map, prefix sums, monotonic stack) or by removing recomputation (DP, two pointers).

2. No, and here is the information-theoretic reason.

Comparison sorting is Omega(n log n). A comparison sort is a decision tree; each internal node is one comparison with two outcomes, and each of the n! permutations needs its own leaf. A binary tree with n! leaves has height >= log2(n!) = Theta(n log n) by Stirling. Therefore some input forces Theta(n log n) comparisons. Counting/radix/bucket sorts beat this only because they are not comparison sorts — they read the structure of the keys.

Searching an unsorted array is Omega(n). An adversary answers “not equal” for the first n-1 probes and then chooses the remaining slot to make you wrong.

Any correct algorithm must read its input. Hence Omega(n) for anything whose answer depends on every element — which is why “find the maximum” cannot be beaten, and why “find an element in a sorted array” can (you do not need to read it all).

3. Not asymptotically, but here is a constant-factor win. Fewer passes, better cache behaviour, early exit, in-place instead of allocating, bit tricks. See the stride benchmarks in section 1.

4. Only with different assumptions. “O(n) if the values are bounded integers — counting sort.” “O(1) if I can preprocess.” “O(log n) if I can keep it sorted as it arrives.” Naming the assumption is often the answer the interviewer wants.

Adversary arguments are the general tool for lower bounds: imagine an opponent who answers your queries to maximize your work, consistent with some valid input. The classic result: finding both the min and the max needs 3n/2 - 2 comparisons, not 2n - 2, because you can pair elements first.


12. Talking about complexity in the interview

A script that works:

  1. Name the input size(s) explicitly. “n is the number of nodes, m the number of edges” or “n rows, k the number of distinct keys”. Half of all complexity confusion is an undefined variable.
  2. State time and space. People forget space, and it is often where the follow-up lives.
  3. Say how you got it. “Each element is pushed and popped at most once, so the total work in the while loop is O(n)” is worth much more than “O(n)”.
  4. Say whether it is tight. “That’s Theta(n log n) — the sort dominates and I can’t avoid the sort because I need the order.”
  5. Volunteer the bottleneck. “The n log n comes only from sorting; if the input arrived sorted this would be linear.” That invites the follow-up you already know the answer to.
  6. Distinguish worst / average / amortized when it matters. “Hash lookups are O(1) expected; the worst case is O(n) but needs adversarial keys.”
  7. Then ask about constraints. “How large is n? If it’s under a few thousand, the O(n^2) version is simpler and I’d ship that.” This is the answer of someone who has shipped code.

Verify empirically when you can: double the input and check the ratio. In an interview you can do this verbally (“if I double n this should roughly quadruple”); in real life it is the fastest way to catch a hidden linear cost, and it is exactly how every table in section 9 was produced.

# The doubling harness. Keep this in your head; it is 15 lines.
import time, math

def growth(fn, sizes, setup=lambda n: n):
    prev = None
    print(f"{'n':>12} {'seconds':>12} {'ns/op':>12} {'ratio':>8} {'exponent':>9}")
    for n in sizes:
        arg = setup(n)
        t0 = time.perf_counter(); fn(arg); dt = time.perf_counter() - t0
        ratio = dt / prev if prev else None
        exp = math.log2(ratio) if ratio else None
        print(f"{n:>12,} {dt:>12.6f} {dt/n*1e9:>12.1f} "
              f"{(f'{ratio:.2f}' if ratio else ''):>8} {(f'{exp:.2f}' if exp else ''):>9}")
        prev = dt

growth(lambda n: [x for x in range(n)], [100_000, 200_000, 400_000, 800_000])

Exponent near 0 means constant, 1 means linear, 1.0–1.2 with slow drift usually means n log n at these sizes, 2 means quadratic.


13. Interview follow-ups

Q: What is the difference between O and Theta, and why do people say O when they mean Theta?

A: O is an upper bound, Theta is tight. Colloquially “the complexity is O(n log n)” means “tight”, and interviewers accept it — but if they ask “is that tight?”, answer precisely.

Q: Is amortized O(1) the same as average O(1)?

A: No. Amortized averages over a worst-case sequence with no probability involved; average case averages over an input distribution. A dynamic array is amortized O(1) — a guarantee. A hash table is average O(1) — a probabilistic claim.

Q: Why is heapify O(n) and not O(n log n)?

A: Sift-down from the last internal node upward: nodes at height h cost O(h), and there are at most n/2^(h+1) of them. Sum over h of h·n/2^(h+1) = n·sum(h/2^h) = 2n. Most nodes are near the leaves and cost almost nothing.

Q: Why is the base of the log irrelevant?

A: log_a n = log_b n / log_b a, and 1/log_b a is a constant. In practice binary search is log2 and a B-tree of order 100 is log100, which is the same class but a 6.6x smaller constant — worth mentioning for disk-based structures.

Q: A function is O(n) but slower than one that is O(n^2). How?

A: Constants and cache behaviour. The O(n) one may allocate, chase pointers, or call through a megamorphic site; the O(n^2) one may be a tight contiguous scan. For small n the quadratic wins. See the 4x pointer-chasing penalty in section 1.

Q: What is the space complexity of recursion over a balanced binary tree? An unbalanced one?

A: O(log n) and O(n). Interviewers use this to check that you account for the stack.

Q: How do you bound a DP?

A: States x transitions. Say it in that form and also give the space after applying a rolling array, because that is the standard follow-up.

Q: What is the complexity of building a hash map from n items?

A: O(n) expected, O(n^2) adversarial worst case (all collide). Rehashing is amortized into the O(n).

Q: Sorting a nearly-sorted array?

A: Insertion sort is O(n + d) where d is the number of inversions, so O(n) for a nearly-sorted array. TimSort detects existing runs and hits O(n) too — which is exactly why both V8 and CPython use it.

Q: What is O(alpha(n))?

A: The inverse Ackermann function, the amortized cost per Union-Find operation with path compression and union by rank. It is under 5 for any n that fits in the universe, so treat it as constant but do not call it constant.

Q: Complexity of Object.keys versus Map iteration in JS?

A: Both O(n). The difference is ordering: Map is pure insertion order; object keys put integer-like keys first in ascending numeric order, then string keys in insertion order, then symbols.

Q: Why does arr.shift() matter if it is “just” a constant factor worse?

A: It is not a constant factor — it is O(n) per call, so a loop of shifts is O(n^2). That is the difference between 1e5 and 1e10 operations.

Q: What complexity should I target for n = 1e5? n = 1e9?

A: 1e5 -> O(n log n) or O(n sqrt n). 1e9 -> you cannot even read the input in a scripting language; you need O(1) or O(log n) with preprocessing, or streaming with sublinear memory.

Q: Time-space tradeoffs — give three examples.

A: Memoization (time down, space up); a hash set for O(1) lookup instead of re-scanning; a suffix automaton/prefix-sum table built once to answer many queries in O(1).

Q: What is the complexity of sorted(d.items(), key=lambda kv: kv[1])?

A: O(n log n) comparisons, but with n calls to a Python-level key function; operator.itemgetter(1) is measurably faster because it stays in C. Same asymptotics, better constant.

Q: How do you empirically confirm a complexity claim?

A: Double n and look at the ratio: ~1 means constant, ~2 linear, ~2.1–2.3 n log n, ~4 quadratic, ~8 cubic. Take log2 of the ratio to read the exponent directly.

Q: What does “in-place” mean precisely?

A: O(1) auxiliary space. In-place quicksort is a slight abuse (O(log n) stack); heapsort and insertion sort are strictly in-place; merge sort on arrays is not.

Q: Amortized vs worst case — when does the difference actually bite?

A: Real-time systems and tail latency. An amortized-O(1) hash insert that occasionally rehashes 10 million entries is a 200 ms p99.9 spike. That is why databases use incremental rehashing and games use fixed-capacity pools.

Q: What is the complexity of the sieve of Eratosthenes, and why?

A: O(n log log n) time, O(n) space. The inner loop runs n/p times per prime p, and the sum of 1/p over primes up to n is log log n.

Q: Can you sort in O(n)?

A: Not with comparisons. With bounded integer keys, counting sort is O(n + k); radix sort is O(d(n + b)) for d digits and base b. Both trade generality and space for linearity.

Q: Why is n log n the answer to so many problems?

A: Because “sort it first” is the single most productive preprocessing step, and because divide and conquer with linear merging lands exactly there by case 2 of the Master Theorem.


Next: JavaScript and Node core or, for the language-specific complexity tables applied to real structures, Data structures in TypeScript and Data structures in Python.

Verify it yourself

complexity/bench_builtins_py.py

"""Empirically measure the complexity class of Python built-ins.

For each operation we time the whole workload at n, 2n, 4n, 8n and report the
doubling ratio r = t(2n)/t(n). exponent = log2(r) is the exponent of the TOTAL
workload: 1 => linear total, 2 => quadratic total, 0 => independent of n.
"""
import gc
import math
import random
import sys
import time
from bisect import bisect_left, insort
from collections import deque
import heapq

random.seed(11)


def measure(setup, work, sizes, reps=5, ops=None):
    out = []
    for n in sizes:
        best = float("inf")
        for _ in range(reps):
            state = setup(n)
            gc.collect()
            gc.disable()
            t0 = time.perf_counter()
            work(n, state)
            dt = time.perf_counter() - t0
            gc.enable()
            best = min(best, dt)
        out.append((n, best, ops(n) if ops else n))
    return out


def report(title, rows, note=""):
    print(f"\n### {title}" + (f"   [{note}]" if note else ""))
    print(f"{'n':>10} {'seconds':>12} {'ns/op':>12} {'t(2n)/t(n)':>12} {'exponent':>10}")
    prev = None
    for n, t, k in rows:
        if prev is None:
            ratio = expo = ""
        else:
            r = t / prev
            ratio, expo = f"{r:.2f}", f"{math.log2(r):.2f}"
        print(f"{n:>10,} {t:>12.6f} {t / k * 1e9:>12.1f} {ratio:>12} {expo:>10}")
        prev = t


Q = [4_000, 8_000, 16_000, 32_000]          # quadratic-friendly sizes
L = [100_000, 200_000, 400_000, 800_000]    # linear-friendly sizes
PROBES = 200_000


# --- 1. front insertion ----------------------------------------------------
def w_insert0(n, a):
    for i in range(n):
        a.insert(0, i)


def w_appendleft(n, d):
    for i in range(n):
        d.appendleft(i)


def w_append(n, a):
    for i in range(n):
        a.append(i)


report("list.insert(0, x), n times", measure(lambda n: [], w_insert0, Q),
       "expect exponent ~2")
report("deque.appendleft(x), n times", measure(lambda n: deque(), w_appendleft, L),
       "expect exponent ~1")
report("list.append(x), n times", measure(lambda n: [], w_append, L),
       "expect exponent ~1")


# --- 2. front removal ------------------------------------------------------
def w_pop0(n, a):
    for _ in range(n):
        a.pop(0)


def w_popleft(n, d):
    for _ in range(n):
        d.popleft()


report("list.pop(0), n times", measure(lambda n: list(range(n)), w_pop0, Q),
       "expect exponent ~2")
report("deque.popleft(), n times", measure(lambda n: deque(range(n)), w_popleft, L),
       "expect exponent ~1")


# --- 3. membership ---------------------------------------------------------
def w_probe(n, st):
    coll, qs = st
    c = 0
    for q in qs:
        if q in coll:
            c += 1
    return c


report(f"`x in list` ({PROBES:,} probes, list of n)", measure(
    lambda n: (list(range(n)), [random.randrange(n) for _ in range(PROBES)]),
    w_probe, [500, 1_000, 2_000, 4_000], reps=3, ops=lambda n: PROBES),
    "cost per probe should double with n")

report(f"`x in set` ({PROBES:,} probes, set of n)", measure(
    lambda n: (set(range(n)), [random.randrange(n) for _ in range(PROBES)]),
    w_probe, [500, 1_000, 2_000, 4_000], reps=3, ops=lambda n: PROBES),
    "expect exponent ~0")

report(f"`x in dict` ({PROBES:,} probes, dict of n)", measure(
    lambda n: ({i: i for i in range(n)}, [random.randrange(n) for _ in range(PROBES)]),
    w_probe, [500, 1_000, 2_000, 4_000], reps=3, ops=lambda n: PROBES),
    "expect exponent ~0")


# --- 4. string building ----------------------------------------------------
def w_str_local(n, _):
    s = ""
    for _i in range(n):
        s += "x"
    return len(s)


def w_str_aliased(n, _):
    """Hold exactly one extra reference so the refcount==1 in-place resize in
    CPython's ceval cannot fire. O(n) memory, O(n^2) time."""
    s = ""
    prev = None
    for _i in range(n):
        prev = s          # refcount(s) == 2 at the moment of the concat
        s += "x"
    return len(s), prev is not None


def w_str_attr(n, box):
    """`obj.s += 'x'` compiles to LOAD_ATTR/STORE_ATTR, not STORE_FAST, so the
    in-place hack also cannot fire."""
    for _i in range(n):
        box.s += "x"
    return len(box.s)


class Box:
    __slots__ = ("s",)

    def __init__(self):
        self.s = ""


def w_join_gen(n, _):
    return len("".join("x" for _ in range(n)))


def w_list_join(n, _):
    parts = []
    for _i in range(n):
        parts.append("x")
    return len("".join(parts))


report("s += 'x' (plain local)", measure(lambda n: None, w_str_local, L),
       "CPython in-place resize fires -> linear")
report("s += 'x' (one extra reference alive)", measure(lambda n: None, w_str_aliased, Q),
       "in-place resize blocked -> quadratic")
report("obj.s += 'x' (attribute, not local)", measure(lambda n: Box(), w_str_attr, Q),
       "STORE_ATTR -> quadratic")
report("''.join(genexpr)", measure(lambda n: None, w_join_gen, L), "expect ~1")
report("append to list then ''.join", measure(lambda n: None, w_list_join, L), "expect ~1")


# --- 5. slicing ------------------------------------------------------------
def w_slices(n, a):
    step = max(1, n // 1000)
    t = 0
    for i in range(0, n, step):
        t += len(a[i:])
    return t


report("1000 tail slices a[i:] of a list of n", measure(
    lambda n: list(range(n)), w_slices, L, reps=3, ops=lambda n: 1000),
    "each slice copies O(n) pointers -> exponent ~1")


# --- 6. heapq / bisect -----------------------------------------------------
def w_heappush(n, h):
    for _ in range(n):
        heapq.heappush(h, random.random())


def w_bisect(n, a):
    for i in range(n):
        bisect_left(a, i)


def w_insort(n, a):
    for _ in range(n):
        insort(a, random.random())


report("heapq.heappush, n times", measure(lambda n: [], w_heappush, L),
       "n log n -- looks linear at these sizes")
report("bisect_left, n times (no insert)", measure(lambda n: list(range(n)), w_bisect, L),
       "expect ~1")
report("bisect.insort, n times", measure(lambda n: [], w_insort, Q),
       "O(log n) search + O(n) memmove -> exponent ~2")


# --- 7. set algebra --------------------------------------------------------
def w_intersect(n, st):
    big, small = st
    for _ in range(2000):
        big & small


report("set(n) & set(100), 2000 times", measure(
    lambda n: (set(range(n)), set(range(100))), w_intersect,
    [10_000, 20_000, 40_000, 80_000], reps=3, ops=lambda n: 2000),
    "O(min(|s|,|t|)) -> exponent ~0")

print(f"\n(best of 5 unless noted, GC disabled inside the timed region, "
      f"CPython {sys.version.split()[0]})")

complexity/bench_cache.py

"""'The constants are a lie' evidence.

Three experiments, all with IDENTICAL asymptotic complexity, whose wall-clock
costs differ by an order of magnitude:

  A. row-major vs column-major traversal of an n x n matrix     -- both Theta(n^2)
  B. sequential array walk vs pointer-chasing a shuffled list   -- both Theta(n)
  C. linear scan vs bisect on a sorted list                     -- O(n) vs O(log n)
     (find the crossover: below it, the "worse" algorithm wins)
"""
import gc
import random
import sys
import time
from array import array
from bisect import bisect_left

random.seed(5)


def best(fn, reps=3, *a):
    b = float("inf")
    for _ in range(reps):
        gc.collect(); gc.disable()
        t0 = time.perf_counter()
        fn(*a)
        b = min(b, time.perf_counter() - t0)
        gc.enable()
    return b


# ---------- A: matrix traversal order ----------
def make_matrix(n):
    return [array("q", range(n)) for _ in range(n)]


def row_major(m, n):
    s = 0
    for i in range(n):
        row = m[i]
        for j in range(n):
            s += row[j]
    return s


def col_major(m, n):
    s = 0
    for j in range(n):
        for i in range(n):
            s += m[i][j]
    return s


print("## A. row-major vs column-major, both Theta(n^2) (Python lists of array('q'))")
print(f"{'n':>6} {'row-major s':>14} {'col-major s':>14} {'slowdown':>10}")
for n in (512, 1024, 2048):
    m = make_matrix(n)
    tr = best(row_major, 3, m, n)
    tc = best(col_major, 3, m, n)
    print(f"{n:>6} {tr:>14.4f} {tc:>14.4f} {tc / tr:>9.2f}x")

# Same experiment with a flat buffer, so indexing cost is identical and only
# the memory access pattern differs.
print("\n## A2. flat array('q') of n*n, stride-1 vs stride-n, both Theta(n^2)")
print(f"{'n':>6} {'stride 1':>14} {'stride n':>14} {'slowdown':>10}")
for n in (512, 1024, 2048):
    buf = array("q", bytes(8 * n * n))
    def seq(buf=buf, n=n):
        s = 0
        for k in range(n * n):
            s += buf[k]
        return s
    def strided(buf=buf, n=n):
        s = 0
        for j in range(n):
            for i in range(n):
                s += buf[i * n + j]
        return s
    ts, td = best(seq, 3), best(strided, 3)
    print(f"{n:>6} {ts:>14.4f} {td:>14.4f} {td / ts:>9.2f}x")


# ---------- B: sequential vs pointer chasing ----------
print("\n## B. array walk vs pointer chasing, both Theta(n)")
print(f"{'n':>9} {'contiguous':>13} {'chased':>13} {'slowdown':>10}")
for n in (100_000, 1_000_000, 4_000_000):
    a = array("q", range(n))

    # A permutation-as-next-index chain: same number of loads, random order.
    perm = list(range(n))
    random.shuffle(perm)
    nxt = array("q", [0]) * 0
    nxt = array("q", bytes(8 * n))
    for k in range(n - 1):
        nxt[perm[k]] = perm[k + 1]
    nxt[perm[-1]] = perm[0]

    def walk(a=a, n=n):
        s = 0
        for k in range(n):
            s += a[k]
        return s

    def chase(nxt=nxt, n=n, start=perm[0]):
        s = 0
        p = start
        for _ in range(n):
            p = nxt[p]
            s += p
        return s

    tw, tc = best(walk, 3), best(chase, 3)
    print(f"{n:>9,} {tw:>13.4f} {tc:>13.4f} {tc / tw:>9.2f}x")


# ---------- C: linear scan vs binary search crossover ----------
print("\n## C. linear scan O(n) vs bisect O(log n): where is the crossover?")
print(f"{'n':>6} {'linear ns/query':>17} {'bisect ns/query':>17} {'winner':>10}")
REPS = 200_000
for n in (2, 4, 8, 16, 32, 64, 128, 256, 512):
    a = list(range(0, 2 * n, 2))
    qs = [random.randrange(0, 2 * n) for _ in range(REPS)]

    def lin(a=a, qs=qs):
        c = 0
        for q in qs:
            for i, v in enumerate(a):
                if v >= q:
                    c += i
                    break
        return c

    def bis(a=a, qs=qs):
        c = 0
        for q in qs:
            c += bisect_left(a, q)
        return c

    tl, tb = best(lin, 3), best(bis, 3)
    win = "linear" if tl < tb else "bisect"
    print(f"{n:>6} {tl / REPS * 1e9:>17.1f} {tb / REPS * 1e9:>17.1f} {win:>10}")

print(f"\n(CPython {sys.version.split()[0]}, 2-core Xeon cloud container)")

complexity/bench_cache_node.js

// Cache-effect evidence in JIT-compiled code, where memory latency is not
// hidden behind interpreter overhead.
let SINK = 0;

function best(fn, reps = 5) {
  let b = Infinity;
  for (let i = 0; i < reps; i++) {
    const t0 = process.hrtime.bigint();
    SINK += fn() & 1;
    const dt = Number(process.hrtime.bigint() - t0) / 1e9;
    if (dt < b) b = dt;
  }
  return b;
}

console.log(`# Node ${process.version} / V8 ${process.versions.v8}`);

// --- A. stride-1 vs stride-n over the same n*n Float64Array, both Theta(n^2)
console.log('\n## A. Float64Array n*n: stride 1 vs stride n (both Theta(n^2))');
console.log(`${'n'.padStart(6)} ${'MiB'.padStart(8)} ${'stride 1 s'.padStart(12)} ${'stride n s'.padStart(12)} ${'slowdown'.padStart(10)}`);
for (const n of [256, 512, 1024, 2048, 4096]) {
  const buf = new Float64Array(n * n);
  for (let i = 0; i < n * n; i++) buf[i] = i & 7;
  const seq = () => { let s = 0; for (let k = 0; k < n * n; k++) s += buf[k]; return s; };
  const str = () => { let s = 0; for (let j = 0; j < n; j++) for (let i = 0; i < n; i++) s += buf[i * n + j]; return s; };
  const ts = best(seq), td = best(str);
  console.log(
    `${String(n).padStart(6)} ${((n * n * 8) / 1048576).toFixed(1).padStart(8)} ` +
    `${ts.toFixed(5).padStart(12)} ${td.toFixed(5).padStart(12)} ${(td / ts).toFixed(2).padStart(9)}x`);
}

// --- B. sequential walk vs pointer chasing over the same number of loads
console.log('\n## B. Int32Array: sequential index walk vs random pointer chase (both Theta(n))');
console.log(`${'n'.padStart(10)} ${'MiB'.padStart(8)} ${'sequential s'.padStart(14)} ${'chased s'.padStart(12)} ${'slowdown'.padStart(10)}`);
let rs = 88172645463325252n;
function rnd32() { rs ^= rs << 13n; rs ^= rs >> 7n; rs ^= rs << 17n; return Number(rs & 0x7fffffffn); }
for (const n of [1 << 12, 1 << 16, 1 << 20, 1 << 23]) {
  const next = new Int32Array(n);
  const perm = new Int32Array(n);
  for (let i = 0; i < n; i++) perm[i] = i;
  for (let i = n - 1; i > 0; i--) { const j = rnd32() % (i + 1); const t = perm[i]; perm[i] = perm[j]; perm[j] = t; }
  for (let k = 0; k < n - 1; k++) next[perm[k]] = perm[k + 1];
  next[perm[n - 1]] = perm[0];

  const seqArr = new Int32Array(n);
  for (let i = 0; i < n; i++) seqArr[i] = (i + 1) % n;

  const walk = () => { let s = 0; for (let k = 0; k < n; k++) s += seqArr[k]; return s; };
  const chase = () => { let s = 0, p = perm[0]; for (let k = 0; k < n; k++) { p = next[p]; s += p; } return s; };
  const tw = best(walk), tc = best(chase);
  console.log(
    `${n.toLocaleString('en-US').padStart(10)} ${((n * 4) / 1048576).toFixed(2).padStart(8)} ` +
    `${tw.toFixed(5).padStart(14)} ${tc.toFixed(5).padStart(12)} ${(tc / tw).toFixed(2).padStart(9)}x`);
}

// --- C. linear scan vs binary search crossover, both in JS
console.log('\n## C. crossover: linear scan vs binary search over a sorted Int32Array');
console.log(`${'n'.padStart(6)} ${'linear ns/q'.padStart(13)} ${'binary ns/q'.padStart(13)} ${'winner'.padStart(8)}`);
const Q = 2_000_000;
for (const n of [2, 4, 8, 16, 32, 64, 128, 256, 1024, 4096]) {
  const a = new Int32Array(n);
  for (let i = 0; i < n; i++) a[i] = 2 * i;
  const qs = new Int32Array(Q);
  for (let i = 0; i < Q; i++) qs[i] = rnd32() % (2 * n);

  const lin = () => {
    let c = 0;
    for (let t = 0; t < Q; t++) { const q = qs[t]; let i = 0; while (i < n && a[i] < q) i++; c += i; }
    return c;
  };
  const bin = () => {
    let c = 0;
    for (let t = 0; t < Q; t++) {
      const q = qs[t]; let lo = 0, hi = n;
      while (lo < hi) { const mid = (lo + hi) >> 1; if (a[mid] < q) lo = mid + 1; else hi = mid; }
      c += lo;
    }
    return c;
  };
  const tl = best(lin, 3), tb = best(bin, 3);
  console.log(
    `${String(n).padStart(6)} ${((tl / Q) * 1e9).toFixed(2).padStart(13)} ` +
    `${((tb / Q) * 1e9).toFixed(2).padStart(13)} ${(tl < tb ? 'linear' : 'binary').padStart(8)}`);
}
console.error('sink', SINK);

complexity/bench_crossover.py

"""Where does an O(log n) algorithm actually beat an O(n) one?

Three fair fights (same implementation language on both sides) plus one unfair
fight, to show that the crossover point is a property of constants, not of the
asymptotics.
"""
import gc
import random
import time
from bisect import bisect_left

random.seed(9)
REPS = 100_000


def best(fn, reps=3):
    b = float("inf")
    for _ in range(reps):
        gc.collect(); gc.disable()
        t0 = time.perf_counter()
        fn()
        b = min(b, time.perf_counter() - t0)
        gc.enable()
    return b


def py_bisect(a, x):
    lo, hi = 0, len(a)
    while lo < hi:
        mid = (lo + hi) >> 1
        if a[mid] < x:
            lo = mid + 1
        else:
            hi = mid
    return lo


print("## Fair fight 1: pure-Python linear scan vs pure-Python binary search")
print(f"{'n':>6} {'linear ns/q':>13} {'binary ns/q':>13} {'winner':>8}")
for n in (2, 4, 8, 16, 24, 32, 48, 64, 128, 512):
    a = list(range(0, 2 * n, 2))
    qs = [random.randrange(0, 2 * n) for _ in range(REPS)]

    def lin(a=a, qs=qs):
        c = 0
        for q in qs:
            for i, v in enumerate(a):
                if v >= q:
                    c += i
                    break
        return c

    def binr(a=a, qs=qs):
        c = 0
        for q in qs:
            c += py_bisect(a, q)
        return c

    tl, tb = best(lin), best(binr)
    print(f"{n:>6} {tl/REPS*1e9:>13.1f} {tb/REPS*1e9:>13.1f} "
          f"{('linear' if tl < tb else 'binary'):>8}")

print("\n## Fair fight 2: C-level linear scan (list.index) vs C-level bisect_left")
print(f"{'n':>6} {'index ns/q':>13} {'bisect ns/q':>13} {'winner':>8}")
for n in (2, 4, 8, 16, 32, 64, 128, 256, 512, 2048):
    a = list(range(n))
    qs = [random.randrange(n) for _ in range(REPS)]

    def idx(a=a, qs=qs):
        c = 0
        for q in qs:
            c += a.index(q)
        return c

    def bis(a=a, qs=qs):
        c = 0
        for q in qs:
            c += bisect_left(a, q)
        return c

    ti, tb = best(idx), best(bis)
    print(f"{n:>6} {ti/REPS*1e9:>13.1f} {tb/REPS*1e9:>13.1f} "
          f"{('index' if ti < tb else 'bisect'):>8}")

print("\n## Fair fight 3: dict build+lookup vs sort+bisect for k lookups over n items")
print(f"{'n':>8} {'k':>8} {'hash total s':>14} {'sort+bisect s':>15} {'winner':>8}")
for n, k in ((1000, 1), (1000, 10), (1000, 1000), (100_000, 1), (100_000, 100_000)):
    data = [random.randrange(1 << 30) for _ in range(n)]
    qs = [random.choice(data) for _ in range(k)]

    def hashway(data=data, qs=qs):
        s = set(data)
        return sum(1 for q in qs if q in s)

    def sortway(data=data, qs=qs):
        a = sorted(data)
        c = 0
        for q in qs:
            i = bisect_left(a, q)
            c += 1 if i < len(a) and a[i] == q else 0
        return c

    th, ts = best(hashway), best(sortway)
    print(f"{n:>8,} {k:>8,} {th:>14.6f} {ts:>15.6f} "
          f"{('hash' if th < ts else 'sort'):>8}")

print("\n## Unfair fight: bytecode linear scan vs C bisect (why 'C wins' is not an argument)")
print(f"{'n':>6} {'py-linear ns/q':>16} {'C bisect ns/q':>15} {'winner':>8}")
for n in (2, 8, 64):
    a = list(range(0, 2 * n, 2))
    qs = [random.randrange(0, 2 * n) for _ in range(REPS)]

    def lin(a=a, qs=qs):
        c = 0
        for q in qs:
            for i, v in enumerate(a):
                if v >= q:
                    c += i
                    break
        return c

    def bis(a=a, qs=qs):
        c = 0
        for q in qs:
            c += bisect_left(a, q)
        return c

    tl, tb = best(lin), best(bis)
    print(f"{n:>6} {tl/REPS*1e9:>16.1f} {tb/REPS*1e9:>15.1f} "
          f"{('py-linear' if tl < tb else 'C bisect'):>8}")

complexity/bench_fixups_py.py

import gc
import math
import random
import sys
import time

random.seed(3)


def measure(setup, work, sizes, reps=5, ops=None):
    out = []
    for n in sizes:
        best = float("inf")
        for _ in range(reps):
            state = setup(n)
            gc.collect(); gc.disable()
            t0 = time.perf_counter()
            work(n, state)
            dt = time.perf_counter() - t0
            gc.enable()
            best = min(best, dt)
        out.append((n, best, ops(n) if ops else n))
    return out


def report(title, rows, note=""):
    print(f"\n### {title}" + (f"   [{note}]" if note else ""))
    print(f"{'n':>10} {'seconds':>12} {'ns/op':>12} {'t(2n)/t(n)':>12} {'exponent':>10}")
    prev = None
    for n, t, k in rows:
        ratio = expo = ""
        if prev is not None:
            r = t / prev
            ratio, expo = f"{r:.2f}", f"{math.log2(r):.2f}"
        print(f"{n:>10,} {t:>12.6f} {t / k * 1e9:>12.1f} {ratio:>12} {expo:>10}")
        prev = t


def w_str_aliased(n, _):
    s = ""
    prev = None
    for _i in range(n):
        prev = s
        s += "x"
    return len(s), prev is not None


def w_str_local(n, _):
    s = ""
    for _i in range(n):
        s += "x"
    return len(s)


BIGQ = [25_000, 50_000, 100_000, 200_000]
report("s += 'x' (one extra reference alive)", measure(lambda n: None, w_str_aliased, BIGQ, reps=3),
       "in-place resize blocked -> quadratic")
report("s += 'x' (plain local, same n range)", measure(lambda n: None, w_str_local, BIGQ, reps=3),
       "in-place resize fires -> linear")


def w_intersect(n, st):
    big, small = st
    acc = 0
    for _ in range(20_000):
        acc += len(big & small)
    return acc


report("set(n) & set(100), 20,000 times", measure(
    lambda n: (set(range(n)), set(range(100))), w_intersect,
    [10_000, 20_000, 40_000, 80_000], reps=5, ops=lambda n: 20_000),
    "O(min(|s|,|t|)) -> exponent ~0")


def w_intersect_rev(n, st):
    big, small = st
    acc = 0
    for _ in range(200):
        acc += len(big & small)
    return acc


report("set(n) & set(n), 200 times", measure(
    lambda n: (set(range(n)), set(range(n // 2, n + n // 2))), w_intersect_rev,
    [10_000, 20_000, 40_000, 80_000], reps=5, ops=lambda n: 200),
    "both sides size n -> exponent ~1")

print(f"\n(CPython {sys.version.split()[0]})")

complexity/bench_list_growth.py

"""Measure CPython's list over-allocation pattern with sys.getsizeof, and
derive the amortized cost of append."""
import sys

EMPTY = sys.getsizeof([])
PTR = 8  # bytes per PyObject* on 64-bit

print(f"sys.getsizeof([])          = {EMPTY} bytes  (header only)")
print(f"sys.getsizeof([1])         = {sys.getsizeof([1])} bytes")
print(f"pointer size               = {PTR} bytes\n")

a = []
last = sys.getsizeof(a)
reallocs = []
print(f"{'len':>8} {'getsizeof':>10} {'capacity':>10}   event")
for i in range(0, 200):
    a.append(i)
    sz = sys.getsizeof(a)
    if sz != last:
        cap = (sz - EMPTY) // PTR
        reallocs.append((len(a), cap))
        if len(reallocs) <= 18:
            print(f"{len(a):>8} {sz:>10} {cap:>10}   grow")
        last = sz

# keep going to large n to expose the asymptotic growth factor
a = []
last = sys.getsizeof(a)
pts = []
for i in range(1_000_000):
    a.append(i)
    sz = sys.getsizeof(a)
    if sz != last:
        pts.append(((sz - EMPTY) // PTR))
        last = sz

print(f"\ntotal reallocations while appending 1,000,000 items: {len(pts)}")
print("capacity sequence (first 24):", pts[:24])
print("capacity sequence (last 8):  ", pts[-8:])
ratios = [pts[i + 1] / pts[i] for i in range(len(pts) - 1)]
print(f"growth ratio: first={ratios[0]:.3f} median={sorted(ratios)[len(ratios)//2]:.5f} "
      f"last={ratios[-1]:.5f}")
print(f"asymptotic factor approaches {pts[-1]/pts[-2]:.6f}  (CPython: new = n + n>>3 + 6, rounded)")

# Verify the documented formula: new_allocated = ((n >> 3) + (n < 9 ? 3 : 6)) + n
def predict(n):
    return (n + (n >> 3) + (3 if n < 9 else 6)) & ~3 if False else n + (n >> 3) + (3 if n < 9 else 6)

print("\ncheck CPython list_resize formula  new_alloc = n + (n>>3) + (n<9 ? 3 : 6):")
b = []
mismatch = 0
prev_cap = 0
for n in range(0, 3000):
    b.append(n)
    cap = (sys.getsizeof(b) - EMPTY) // PTR
    if cap != prev_cap:
        want = predict(len(b))
        # CPython rounds the allocation up to a multiple of 4
        want_rounded = (want + 3) & ~3
        flag = "ok" if cap in (want, want_rounded) else "MISMATCH"
        if flag == "MISMATCH":
            mismatch += 1
            if mismatch < 6:
                print(f"  len={len(b)} cap={cap} predicted={want}/{want_rounded}")
        prev_cap = cap
print(f"  mismatches in first 3000 appends: {mismatch}")

# Amortized cost: total elements copied over 1e6 appends
total_copied = 0
prev = 0
for cap in pts:
    total_copied += prev
    prev = cap
print(f"\nelements copied during 1,000,000 appends: {total_copied:,} "
      f"({total_copied/1_000_000:.3f} per append)")

complexity/bench_n_in_1s_node.js

// For each complexity class, find the largest n whose work finishes in ~1.0 s
// on Node 22 / V8. Every kernel returns a value that is accumulated into a
// global sink and printed at the end, so V8 cannot eliminate the loops.

const BUDGET = 1.0;
let SINK = 0;

function bestOf(fn, arg, reps = 5) {
  let b = Infinity;
  for (let k = 0; k < reps; k++) {
    const t0 = process.hrtime.bigint();
    SINK += Number(fn(arg)) & 1;
    const dt = Number(process.hrtime.bigint() - t0) / 1e9;
    if (dt < b) b = dt;
  }
  return b;
}

const kLinear = (n) => { let s = 0; for (let i = 0; i < n; i++) s += i; return s; };
const kSqrt = (n) => { let i = 2, s = 0; while (i * i <= n) { s += n % i; i++; } return s; };
const kLogn = (n) => {
  let total = 0;
  for (let r = 0; r < 200000; r++) { let m = n; while (m > 1) { m = Math.floor(m / 2); total++; } }
  return total;
};
const kQuad = (n) => { let s = 0; for (let i = 0; i < n; i++) for (let j = 0; j < n; j++) s += (i ^ j) & 1; return s; };
const kCubic = (n) => { let s = 0; for (let i = 0; i < n; i++) for (let j = 0; j < n; j++) for (let k = 0; k < n; k++) s += (i ^ j ^ k) & 1; return s; };
const kExp = (n) => { let s = 0; const lim = 1 << n; for (let m = 0; m < lim; m++) s += m & 1; return s; };

function permCount(n) { // O(n!) via Heap's algorithm, real work per permutation
  const a = Array.from({ length: n }, (_, i) => i);
  const c = new Array(n).fill(0);
  let s = a[0], i = 0;
  while (i < n) {
    if (c[i] < i) {
      const j = i % 2 === 0 ? 0 : c[i];
      const t = a[j]; a[j] = a[i]; a[i] = t;
      s += a[0];
      c[i]++; i = 0;
    } else { c[i] = 0; i++; }
  }
  return s;
}

let rngState = 123456789;
function rnd() { rngState ^= rngState << 13; rngState ^= rngState >>> 17; rngState ^= rngState << 5; return rngState >>> 0; }

function mergeSort(a) {
  if (a.length <= 1) return a;
  const mid = a.length >> 1;
  const L = mergeSort(a.slice(0, mid)), R = mergeSort(a.slice(mid));
  const out = []; let i = 0, j = 0;
  while (i < L.length && j < R.length) out.push(L[i] <= R[j] ? L[i++] : R[j++]);
  while (i < L.length) out.push(L[i++]);
  while (j < R.length) out.push(R[j++]);
  return out;
}

function timeMergeSort(n) {
  const a = Array.from({ length: n }, () => rnd());
  const t0 = process.hrtime.bigint();
  const r = mergeSort(a);
  const dt = Number(process.hrtime.bigint() - t0) / 1e9;
  SINK += r[0] & 1;
  return dt;
}

function timeNativeSort(n) {
  const a = Array.from({ length: n }, () => rnd());
  const t0 = process.hrtime.bigint();
  a.sort((x, y) => x - y);
  const dt = Number(process.hrtime.bigint() - t0) / 1e9;
  SINK += a[0] & 1;
  return dt;
}

function lgamma(x) { // Lanczos, good enough for factorial extrapolation
  const g = 7;
  const c = [0.99999999999980993, 676.5203681218851, -1259.1392167224028,
    771.32342877765313, -176.61502916214059, 12.507343278686905,
    -0.13857109526572012, 9.9843695780195716e-6, 1.5056327351493116e-7];
  if (x < 0.5) return Math.log(Math.PI / Math.sin(Math.PI * x)) - lgamma(1 - x);
  x -= 1;
  let a = c[0];
  const t = x + g + 0.5;
  for (let i = 1; i < g + 2; i++) a += c[i] / (x + i);
  return 0.5 * Math.log(2 * Math.PI) + (x + 0.5) * Math.log(t) - t + Math.log(a);
}

function solveN(n0, t0, kind) {
  const r = BUDGET / t0;
  switch (kind) {
    case 'logn': return Math.pow(n0, r);
    case 'sqrt': return n0 * r * r;
    case 'n': return n0 * r;
    case 'nlogn': {
      const target = r * n0 * Math.log2(n0);
      let lo = 2, hi = 1e18;
      for (let i = 0; i < 300; i++) {
        const mid = (lo + hi) / 2;
        if (mid * Math.log2(mid) < target) lo = mid; else hi = mid;
      }
      return lo;
    }
    case 'n2': return n0 * Math.sqrt(r);
    case 'n3': return n0 * Math.cbrt(r);
    case '2n': return n0 + Math.log2(r);
    case 'fact': {
      const target = lgamma(n0 + 1) + Math.log(r);
      let x = n0;
      while (lgamma(x + 2) <= target) x++;
      return x;
    }
  }
}

const fmt = (x) => (!Number.isFinite(x) || x > 1e30 ? 'effectively unbounded' : x > 1e15 ? x.toPrecision(3) : Math.floor(x).toLocaleString('en-US'));

const rows = [['O(1)', '-', '-', 'unbounded']];
for (const [label, kind, n0, fn] of [
  ['O(log n)', 'logn', 1 << 20, kLogn],
  ['O(sqrt n)', 'sqrt', 1e8, kSqrt],
  ['O(n)', 'n', 30000000, kLinear],
  ['O(n^2)', 'n2', 10000, kQuad],
  ['O(n^3)', 'n3', 700, kCubic],
  ['O(2^n)', '2n', 25, kExp],
  ['O(n!)', 'fact', 11, permCount],
]) {
  const t = bestOf(fn, n0);
  rows.push([label, n0.toLocaleString('en-US'), t.toFixed(4), fmt(solveN(n0, t, kind))]);
}

let tms = Infinity;
for (let i = 0; i < 3; i++) tms = Math.min(tms, timeMergeSort(500000));
rows.push(['O(n log n) hand-merge', '500,000', tms.toFixed(4), fmt(solveN(500000, tms, 'nlogn'))]);

let tns = Infinity;
for (let i = 0; i < 3; i++) tns = Math.min(tns, timeNativeSort(2000000));
rows.push(['O(n log n) .sort()', '2,000,000', tns.toFixed(4), fmt(solveN(2000000, tns, 'nlogn'))]);

const order = ['O(1)', 'O(log n)', 'O(sqrt n)', 'O(n)', 'O(n log n) hand-merge',
  'O(n log n) .sort()', 'O(n^2)', 'O(n^3)', 'O(2^n)', 'O(n!)'];
rows.sort((a, b) => order.indexOf(a[0]) - order.indexOf(b[0]));

console.log(`# Node ${process.version} / V8 ${process.versions.v8} -- budget ${BUDGET}s`);
console.log(`${'class'.padEnd(22)} | ${'calib n'.padStart(12)} | ${'measured s'.padStart(10)} | ${'max n in 1s'.padStart(20)}`);
console.log('-'.repeat(74));
for (const [a, b, c, d] of rows) {
  console.log(`${a.padEnd(22)} | ${b.padStart(12)} | ${c.padStart(10)} | ${d.padStart(20)}`);
}
console.error(`sink=${SINK}`);

complexity/bench_n_in_1s_py.py

"""For each complexity class, find the largest n whose work finishes in ~1.0 s.

Strategy: run a real kernel at a calibration n, time it, then solve for the n
that costs 1 s using the known growth law. The calibration point is printed so
the extrapolation is auditable.

Two flavours are reported for O(n log n): a pure-Python merge sort (bytecode
loop) and list.sort() (C loop). The gap between them is the whole point.
"""
import math
import random
import sys
import time
from itertools import permutations

random.seed(7)
BUDGET = 1.0


def best_of(fn, arg, reps=3):
    b = float("inf")
    for _ in range(reps):
        t0 = time.perf_counter()
        fn(arg)
        b = min(b, time.perf_counter() - t0)
    return b


def k_linear(n):
    s = 0
    for i in range(n):
        s += i
    return s


def k_sqrt(n):
    i, s = 2, 0
    while i * i <= n:
        s += n % i
        i += 1
    return s


def k_logn(n):
    total = 0
    for _ in range(200_000):
        m = n
        while m > 1:
            m >>= 1
            total += 1
    return total


def k_quad(n):
    s = 0
    for _i in range(n):
        for _j in range(n):
            s += 1
    return s


def k_cubic(n):
    s = 0
    for _i in range(n):
        for _j in range(n):
            for _k in range(n):
                s += 1
    return s


def k_exp(n):
    s = 0
    for mask in range(1 << n):
        s += mask & 1
    return s


def k_fact(n):
    s = 0
    for p in permutations(range(n)):
        s += p[0]
    return s


def merge_sort(a):
    if len(a) <= 1:
        return a
    mid = len(a) // 2
    left, right = merge_sort(a[:mid]), merge_sort(a[mid:])
    out, i, j = [], 0, 0
    while i < len(left) and j < len(right):
        if left[i] <= right[j]:
            out.append(left[i]); i += 1
        else:
            out.append(right[j]); j += 1
    out.extend(left[i:]); out.extend(right[j:])
    return out


def k_mergesort(n):
    a = [random.getrandbits(30) for _ in range(n)]
    t0 = time.perf_counter()
    merge_sort(a)
    return time.perf_counter() - t0


def k_timsort(n):
    a = [random.getrandbits(30) for _ in range(n)]
    t0 = time.perf_counter()
    a.sort()
    return time.perf_counter() - t0


def solve_n(n0, t0, kind):
    r = BUDGET / t0
    if kind == "logn":
        return n0 ** r
    if kind == "sqrt":
        return n0 * r * r
    if kind == "n":
        return n0 * r
    if kind == "nlogn":
        target = r * n0 * math.log2(n0)
        lo, hi = 2.0, 1e18
        for _ in range(300):
            mid = (lo + hi) / 2
            if mid * math.log2(mid) < target:
                lo = mid
            else:
                hi = mid
        return lo
    if kind == "n2":
        return n0 * math.sqrt(r)
    if kind == "n3":
        return n0 * r ** (1 / 3)
    if kind == "2n":
        return n0 + math.log2(r)
    if kind == "fact":
        target = math.lgamma(n0 + 1) + math.log(r)
        x = n0
        while math.lgamma(x + 2) <= target:
            x += 1
        return x
    raise ValueError(kind)


def fmt(x):
    if x > 1e15:
        return f"{x:.3g}"
    return f"{int(x):,}"


rows = []
rows.append(("O(1)", "-", "-", "unbounded"))

for label, kind, n0, fn in [
    ("O(log n)", "logn", 1 << 20, k_logn),
    ("O(sqrt n)", "sqrt", 10 ** 8, k_sqrt),
    ("O(n)", "n", 3_000_000, k_linear),
    ("O(n^2)", "n2", 2_000, k_quad),
    ("O(n^3)", "n3", 250, k_cubic),
    ("O(2^n)", "2n", 22, k_exp),
    ("O(n!)", "fact", 10, k_fact),
]:
    t = best_of(fn, n0)
    rows.append((label, f"{n0:,}", f"{t:.4f}", fmt(solve_n(n0, t, kind))))

t_ms = min(k_mergesort(200_000) for _ in range(3))
rows.append(("O(n log n) pure-py", "200,000", f"{t_ms:.4f}", fmt(solve_n(200_000, t_ms, "nlogn"))))
t_ts = min(k_timsort(1_000_000) for _ in range(3))
rows.append(("O(n log n) .sort()", "1,000,000", f"{t_ts:.4f}", fmt(solve_n(1_000_000, t_ts, "nlogn"))))

order = ["O(1)", "O(log n)", "O(sqrt n)", "O(n)", "O(n log n) pure-py",
         "O(n log n) .sort()", "O(n^2)", "O(n^3)", "O(2^n)", "O(n!)"]
rows.sort(key=lambda r: order.index(r[0]))

print(f"# CPython {sys.version.split()[0]} -- budget {BUDGET}s")
print(f"{'class':<20} | {'calib n':>12} | {'measured s':>10} | {'max n in 1s':>20}")
print("-" * 72)
for label, n0, t, est in rows:
    print(f"{label:<20} | {n0:>12} | {t:>10} | {est:>20}")

complexity/bench_ops_node.js

// Measure raw loop throughput in Node 22 / V8 on this machine.
const os = require('node:os');

function timeit(fn, ...args) {
  let best = Infinity;
  for (let k = 0; k < 5; k++) {
    const t0 = process.hrtime.bigint();
    const r = fn(...args);
    const dt = Number(process.hrtime.bigint() - t0) / 1e9;
    if (r === undefined) throw new Error('sink');
    if (dt < best) best = dt;
  }
  return best;
}

function emptyLoop(n) { let i = 0; while (i < n) i++; return i; }
function forAccum(n) { let s = 0; for (let i = 0; i < n; i++) s += i; return s; }
function arrIndex(n, a) { let s = 0; for (let i = 0; i < n; i++) s += a[i & 1023]; return s; }
function mapLookup(n, m) { let s = 0; for (let i = 0; i < n; i++) s += m.get(i & 1023); return s; }
function objLookup(n, o) { let s = 0; for (let i = 0; i < n; i++) s += o[i & 1023]; return s; }
function fnCall(n) { const f = (x) => x + 1; let s = 0; for (let i = 0; i < n; i++) s = f(s); return s; }
function pushArr(n) { const out = []; for (let i = 0; i < n; i++) out.push(i); return out.length; }

const N = 3_000_000;
const a = Array.from({ length: 1024 }, (_, i) => i);
const m = new Map(a.map((x) => [x, x]));
const o = Object.fromEntries(a.map((x) => [x, x]));

const rows = [
  ['while (i < n) i++', timeit(emptyLoop, N)],
  ['for (...) s += i', timeit(forAccum, N)],
  ['array index a[i]', timeit(arrIndex, N, a)],
  ['Map.get(i)', timeit(mapLookup, N, m)],
  ['object o[i]', timeit(objLookup, N, o)],
  ['arrow function call', timeit(fnCall, N)],
  ['Array.push', timeit(pushArr, N)],
];

console.log(`# Node ${process.version} / V8 ${process.versions.v8} on ${os.arch()}`);
console.log(
  'operation'.padEnd(32) + 'sec/3M'.padStart(10) + 'ns/op'.padStart(10) + 'ops/sec'.padStart(16),
);
for (const [name, dt] of rows) {
  console.log(
    name.padEnd(32) +
      dt.toFixed(4).padStart(10) +
      ((dt / N) * 1e9).toFixed(2).padStart(10) +
      Math.round(N / dt).toLocaleString('en-US').padStart(16),
  );
}

complexity/bench_ops_py.py

"""Measure raw loop throughput in CPython 3.11 on this machine."""
import sys
import time
import platform


def timeit(fn, *args):
    best = float("inf")
    for _ in range(3):
        t0 = time.perf_counter()
        fn(*args)
        dt = time.perf_counter() - t0
        best = min(best, dt)
    return best


def empty_loop(n):
    i = 0
    while i < n:
        i += 1
    return i


def for_range_accum(n):
    s = 0
    for i in range(n):
        s += i
    return s


def list_index(n, a):
    s = 0
    for i in range(n):
        s += a[i & 1023]
    return s


def dict_lookup(n, d):
    s = 0
    for i in range(n):
        s += d[i & 1023]
    return s


def fn_call(n):
    def f(x):
        return x + 1
    s = 0
    for i in range(n):
        s = f(s)
    return s


def append_list(n):
    out = []
    for i in range(n):
        out.append(i)
    return len(out)


N = 3_000_000
a = list(range(1024))
d = {i: i for i in range(1024)}

results = [
    ("while i < n: i += 1", timeit(empty_loop, N), N),
    ("for i in range(n): s += i", timeit(for_range_accum, N), N),
    ("list index a[i]", timeit(list_index, N, a), N),
    ("dict lookup d[i]", timeit(dict_lookup, N, d), N),
    ("python function call", timeit(fn_call, N), N),
    ("list.append", timeit(append_list, N), N),
]

print(f"# CPython {platform.python_version()} on {platform.machine()}")
print(f"{'operation':<32}{'sec/3M':>10}{'ns/op':>10}{'ops/sec':>16}")
for name, dt, n in results:
    print(f"{name:<32}{dt:>10.4f}{dt/n*1e9:>10.1f}{n/dt:>16,.0f}")

# builtin-level throughput (C loop, not bytecode loop)
t = timeit(lambda: sum(range(N)))
print(f"{'sum(range(n)) [C loop]':<32}{t:>10.4f}{t/N*1e9:>10.1f}{N/t:>16,.0f}")

complexity/bench_recursion_cboundary.py

"""Contrast pure-Python recursion (heap-allocated frames since 3.11) with
recursion that crosses a C boundary on every level (still uses the C stack).

Run each probe in a subprocess: a C-stack overflow is a hard crash, not an
exception, and we want to observe that safely.
"""
import subprocess
import sys
import textwrap

PROBES = {
    "pure Python f() -> f()": """
        import sys
        sys.setrecursionlimit(300000)
        d = 0
        def f():
            global d
            d += 1
            f()
        try:
            f()
        except RecursionError:
            print("RecursionError at depth", d)
    """,
    "via C slot: obj + obj (__add__)": """
        import sys
        sys.setrecursionlimit(300000)
        d = 0
        class N:
            def __add__(self, other):
                global d
                d += 1
                return self + other
        try:
            N() + N()
        except RecursionError:
            print("RecursionError at depth", d)
    """,
    "via C: repr() of nested list": """
        import sys
        sys.setrecursionlimit(300000)
        x = []
        cur = x
        for _ in range(200000):
            nxt = []
            cur.append(nxt)
            cur = nxt
        try:
            s = repr(x)
            print("repr ok, len", len(s))
        except RecursionError:
            print("RecursionError building repr")
    """,
}

for name, src in PROBES.items():
    p = subprocess.run([sys.executable, "-c", textwrap.dedent(src)],
                       capture_output=True, text=True, timeout=120)
    out = (p.stdout + p.stderr).strip().splitlines()
    tail = out[-1] if out else "(no output)"
    print(f"{name:<34} rc={p.returncode:<5} {tail}")

complexity/bench_recursion_node.js

// Measure V8 stack depth for a few frame shapes, at default and raised stack size.
function depthNoArgs() {
  let d = 0;
  function f() { d++; f(); }
  try { f(); } catch (e) { if (!(e instanceof RangeError)) throw e; }
  return d;
}

function depthWithArgs() {
  let d = 0;
  function f(a, b, c, e) { d++; f(a + 1, b, c, e); }
  try { f(1, 2, 3, 4); } catch (e) { if (!(e instanceof RangeError)) throw e; }
  return d;
}

function depthWithLocals() {
  let d = 0;
  function f() {
    const x1 = d, x2 = d + 1, x3 = d + 2, x4 = d + 3, x5 = d + 4,
      x6 = d + 5, x7 = d + 6, x8 = d + 7;
    d++;
    f();
    return x1 + x2 + x3 + x4 + x5 + x6 + x7 + x8;
  }
  try { f(); } catch (e) { if (!(e instanceof RangeError)) throw e; }
  return d;
}

console.log(`# Node ${process.version} / V8 ${process.versions.v8}`);
console.log(`stack-size flag: ${process.execArgv.filter((a) => a.includes('stack-size')).join(' ') || '(default)'}`);
console.log('no-arg frame        :', depthNoArgs());
console.log('4-arg frame         :', depthWithArgs());
console.log('8-local frame       :', depthWithLocals());

complexity/bench_recursion_py.py

"""Measure CPython recursion behaviour: default limit, and the real C-stack
ceiling when you raise the limit (in a thread with a large stack so we do not
segfault the main interpreter)."""
import sys
import threading

print("sys.getrecursionlimit() default =", sys.getrecursionlimit())


def depth_at_default():
    d = 0

    def f():
        nonlocal d
        d += 1
        f()

    try:
        f()
    except RecursionError:
        pass
    return d


print("frames reached before RecursionError (default limit) =", depth_at_default())


# How deep can we actually go if we raise the limit and give the thread stack room?
def probe(limit, stack_bytes):
    result = {}

    def run():
        sys.setrecursionlimit(limit)
        d = 0

        def f():
            nonlocal d
            d += 1
            f()

        try:
            f()
        except RecursionError:
            result["depth"] = d
            result["how"] = "RecursionError"

    threading.stack_size(stack_bytes)
    t = threading.Thread(target=run)
    t.start()
    t.join()
    return result


for stack_mb in (1, 8, 64):
    r = probe(2_000_000, stack_mb * 1024 * 1024)
    print(f"limit=2,000,000 thread stack={stack_mb:>3} MiB -> depth {r.get('depth')} ({r.get('how')})")

sys.setrecursionlimit(1000)
# Frame cost: measure bytes of C stack per Python frame indirectly.
print()
print("A single-argument Python frame in 3.11 costs roughly "
      "(stack bytes)/(depth reached) -- see the numbers above.")

complexity/out_builtins_py.txt


### list.insert(0, x), n times   [expect exponent ~2]
         n      seconds        ns/op   t(2n)/t(n)   exponent
     4,000     0.002616        653.9                        
     8,000     0.010210       1276.2         3.90       1.96
    16,000     0.041631       2601.9         4.08       2.03
    32,000     0.163582       5112.0         3.93       1.97

### deque.appendleft(x), n times   [expect exponent ~1]
         n      seconds        ns/op   t(2n)/t(n)   exponent
   100,000     0.002883         28.8                        
   200,000     0.005779         28.9         2.00       1.00
   400,000     0.012648         31.6         2.19       1.13
   800,000     0.028289         35.4         2.24       1.16

### list.append(x), n times   [expect exponent ~1]
         n      seconds        ns/op   t(2n)/t(n)   exponent
   100,000     0.001815         18.1                        
   200,000     0.004263         21.3         2.35       1.23
   400,000     0.009431         23.6         2.21       1.15
   800,000     0.021455         26.8         2.27       1.19

### list.pop(0), n times   [expect exponent ~2]
         n      seconds        ns/op   t(2n)/t(n)   exponent
     4,000     0.018987       4746.7                        
     8,000     0.077932       9741.4         4.10       2.04
    16,000     0.319882      19992.7         4.10       2.04
    32,000     1.488170      46505.3         4.65       2.22

### deque.popleft(), n times   [expect exponent ~1]
         n      seconds        ns/op   t(2n)/t(n)   exponent
   100,000     0.002731         27.3                        
   200,000     0.006029         30.1         2.21       1.14
   400,000     0.014046         35.1         2.33       1.22
   800,000     0.030686         38.4         2.18       1.13

### `x in list` (200,000 probes, list of n)   [cost per probe should double with n]
         n      seconds        ns/op   t(2n)/t(n)   exponent
       500     0.282091       1410.5                        
     1,000     0.511196       2556.0         1.81       0.86
     2,000     1.036393       5182.0         2.03       1.02
     4,000     2.182353      10911.8         2.11       1.07

### `x in set` (200,000 probes, set of n)   [expect exponent ~0]
         n      seconds        ns/op   t(2n)/t(n)   exponent
       500     0.008511         42.6                        
     1,000     0.008554         42.8         1.01       0.01
     2,000     0.008632         43.2         1.01       0.01
     4,000     0.008751         43.8         1.01       0.02

### `x in dict` (200,000 probes, dict of n)   [expect exponent ~0]
         n      seconds        ns/op   t(2n)/t(n)   exponent
       500     0.009862         49.3                        
     1,000     0.009823         49.1         1.00      -0.01
     2,000     0.009619         48.1         0.98      -0.03
     4,000     0.009917         49.6         1.03       0.04

### s += 'x' (plain local)   [CPython in-place resize fires -> linear]
         n      seconds        ns/op   t(2n)/t(n)   exponent
   100,000     0.004188         41.9                        
   200,000     0.008397         42.0         2.01       1.00
   400,000     0.016957         42.4         2.02       1.01
   800,000     0.033730         42.2         1.99       0.99

### s += 'x' (one extra reference alive)   [in-place resize blocked -> quadratic]
         n      seconds        ns/op   t(2n)/t(n)   exponent
     4,000     0.000390         97.5                        
     8,000     0.000922        115.2         2.36       1.24
    16,000     0.002272        142.0         2.46       1.30
    32,000     0.009808        306.5         4.32       2.11

### obj.s += 'x' (attribute, not local)   [STORE_ATTR -> quadratic]
         n      seconds        ns/op   t(2n)/t(n)   exponent
     4,000     0.000395         98.8                        
     8,000     0.000961        120.1         2.43       1.28
    16,000     0.002362        147.6         2.46       1.30
    32,000     0.010054        314.2         4.26       2.09

### ''.join(genexpr)   [expect ~1]
         n      seconds        ns/op   t(2n)/t(n)   exponent
   100,000     0.003388         33.9                        
   200,000     0.006778         33.9         2.00       1.00
   400,000     0.014493         36.2         2.14       1.10
   800,000     0.028124         35.2         1.94       0.96

### append to list then ''.join   [expect ~1]
         n      seconds        ns/op   t(2n)/t(n)   exponent
   100,000     0.002620         26.2                        
   200,000     0.005331         26.7         2.03       1.03
   400,000     0.011336         28.3         2.13       1.09
   800,000     0.022666         28.3         2.00       1.00

### 1000 tail slices a[i:] of a list of n   [each slice copies O(n) pointers -> exponent ~1]
         n      seconds        ns/op   t(2n)/t(n)   exponent
   100,000     0.135801     135801.1                        
   200,000     0.312493     312493.4         2.30       1.20
   400,000     0.907807     907806.9         2.91       1.54
   800,000     2.837960    2837960.0         3.13       1.64

### heapq.heappush, n times   [n log n -- looks linear at these sizes]
         n      seconds        ns/op   t(2n)/t(n)   exponent
   100,000     0.023442        234.4                        
   200,000     0.047707        238.5         2.04       1.03
   400,000     0.103838        259.6         2.18       1.12
   800,000     0.206249        257.8         1.99       0.99

### bisect_left, n times (no insert)   [expect ~1]
         n      seconds        ns/op   t(2n)/t(n)   exponent
   100,000     0.050054        500.5                        
   200,000     0.107755        538.8         2.15       1.11
   400,000     0.215339        538.3         2.00       1.00
   800,000     0.440606        550.8         2.05       1.03

### bisect.insort, n times   [O(log n) search + O(n) memmove -> exponent ~2]
         n      seconds        ns/op   t(2n)/t(n)   exponent
     4,000     0.002082        520.5                        
     8,000     0.014938       1867.2         7.17       2.84
    16,000     0.048457       3028.6         3.24       1.70
    32,000     0.185342       5791.9         3.82       1.94

### set(n) & set(100), 2000 times   [O(min(|s|,|t|)) -> exponent ~0]
         n      seconds        ns/op   t(2n)/t(n)   exponent
    10,000     0.007129       3564.7                        
    20,000     0.005086       2543.1         0.71      -0.49
    40,000     0.007200       3599.8         1.42       0.50
    80,000     0.003253       1626.3         0.45      -1.15

(best of 5 unless noted, GC disabled inside the timed region, CPython 3.11.15)

complexity/out_cache.txt

## A. row-major vs column-major, both Theta(n^2) (Python lists of array('q'))
     n    row-major s    col-major s   slowdown
   512         0.0111         0.0156      1.40x
  1024         0.0454         0.0641      1.41x
  2048         0.2039         0.3317      1.63x

## A2. flat array('q') of n*n, stride-1 vs stride-n, both Theta(n^2)
     n       stride 1       stride n   slowdown
   512         0.0142         0.0286      2.02x
  1024         0.0519         0.1521      2.93x
  2048         0.2173         0.7567      3.48x

## B. array walk vs pointer chasing, both Theta(n)
        n    contiguous        chased   slowdown
  100,000        0.0060        0.0104      1.72x
1,000,000        0.0471        0.1094      2.32x
4,000,000        0.1932        0.7609      3.94x

## C. linear scan O(n) vs bisect O(log n): where is the crossover?
     n   linear ns/query   bisect ns/query     winner
     2             145.9              61.9     bisect
     4             165.0             141.3     bisect
     8             426.7             165.7     bisect
    16             616.3             196.5     bisect
    32             904.6             182.5     bisect
    64            1024.7             132.6     bisect
   128            1409.5             153.2     bisect
   256            2610.9             175.3     bisect
   512            6500.6             204.8     bisect

(CPython 3.11.15, 2-core Xeon cloud container)

complexity/out_cache_node.txt

# Node v22.22.2 / V8 12.4.254.21-node.39

## A. Float64Array n*n: stride 1 vs stride n (both Theta(n^2))
     n      MiB   stride 1 s   stride n s   slowdown
   256      0.5      0.00013      0.00010      0.72x
   512      2.0      0.00032      0.00087      2.75x
  1024      8.0      0.00133      0.00355      2.66x
  2048     32.0      0.00894      0.03523      3.94x
  4096    128.0      0.03491      0.21943      6.29x

## B. Int32Array: sequential index walk vs random pointer chase (both Theta(n))
         n      MiB   sequential s     chased s   slowdown
     4,096     0.02        0.00000      0.00001      2.49x
    65,536     0.25        0.00008      0.00028      3.57x

complexity/out_fixups.txt


### s += 'x' (one extra reference alive)   [in-place resize blocked -> quadratic]
         n      seconds        ns/op   t(2n)/t(n)   exponent
    25,000     0.004784        191.4                        
    50,000     0.028328        566.6         5.92       2.57
   100,000     0.158448       1584.5         5.59       2.48
   200,000     0.760010       3800.0         4.80       2.26

### s += 'x' (plain local, same n range)   [in-place resize fires -> linear]
         n      seconds        ns/op   t(2n)/t(n)   exponent
    25,000     0.000999         40.0                        
    50,000     0.002009         40.2         2.01       1.01
   100,000     0.004172         41.7         2.08       1.05
   200,000     0.008067         40.3         1.93       0.95

### set(n) & set(100), 20,000 times   [O(min(|s|,|t|)) -> exponent ~0]
         n      seconds        ns/op   t(2n)/t(n)   exponent
    10,000     0.031540       1577.0                        
    20,000     0.031189       1559.5         0.99      -0.02
    40,000     0.032089       1604.5         1.03       0.04
    80,000     0.030905       1545.2         0.96      -0.05

### set(n) & set(n), 200 times   [both sides size n -> exponent ~1]
         n      seconds        ns/op   t(2n)/t(n)   exponent
    10,000     0.032288     161440.1                        
    20,000     0.067469     337345.9         2.09       1.06
    40,000     0.134308     671539.4         1.99       0.99
    80,000     0.243553    1217764.2         1.81       0.86

(CPython 3.11.15)

complexity/probe_c_depth.py

import subprocess, sys, textwrap

SRC = """
import sys
sys.setrecursionlimit({lim})
d = 0
class N:
    def __add__(self, other):
        global d
        d += 1
        return self + other
try:
    N() + N()
except RecursionError:
    print("RecursionError at depth", d)
"""

def ok(lim):
    p = subprocess.run([sys.executable, "-c", textwrap.dedent(SRC.format(lim=lim))],
                       capture_output=True, text=True, timeout=120)
    return p.returncode == 0, (p.stdout+p.stderr).strip()

lo, hi = 1000, 300000
# find first failing limit
while lo < hi:
    mid = (lo + hi) // 2
    good, msg = ok(mid)
    if good:
        lo = mid + 1
    else:
        hi = mid
print("first recursionlimit that segfaults on __add__ chain:", lo)
good, msg = ok(lo - 1)
print("at limit", lo-1, "->", msg)

complexity/probe_v8_cap.py

import re, subprocess, sys
def cap(n):
    src = f"const a = []; for (let i = 0; i < {n}; i++) a.push(i); %DebugPrint(a);"
    p = subprocess.run(["node","--allow-natives-syntax","-e",src],
                       capture_output=True, text=True, timeout=180)
    m = re.search(r"- elements: 0x[0-9a-f]+ <FixedArray\[(\d+)\]", p.stdout)
    return int(m.group(1)) if m else None

sizes = [0,1,2,16,17,18,42,43,44,81,82,83,139,140,141,226,227,356,357]
print(f"{'length':>8} {'capacity':>10}  predicted (i+1)+((i+1)>>1)+16 at last grow")
for n in sizes:
    c = cap(n)
    print(f"{n:>8} {str(c):>10}")