Phase 19 - Lesson 19.14

Computational Complexity, Feasibility Estimation, and Proving Algorithms Correct

The two questions to ask before writing any code: will it finish, and will it be right? Plus the disciplined optimization of brute force.

⏱ 65 min● Intermediate🔗 Prereqs: 19.1–19.13 (this lesson audits all of them)
↖ Phase 19 hub
Builds on: Every previous lesson in this phase offered a choice of algorithms. This one is how you choose.
Leads to: This is the habit you carry into every Euler Lab problem - and into every production research job.

Learning Objectives

Click a status chip to cycle: Not started → In progress → Studied → Practiced → Needs review → Mastered.

Key Vocabulary

Big-O
\(f=O(g)\) iff \(\exists C,n_0:\ f(n)\le Cg(n)\ \forall n\ge n_0\). An upper bound - it says nothing about tightness.
Big-Theta
\(f=\Theta(g)\) iff \(f=O(g)\) AND \(f=\Omega(g)\). This is what you usually mean when you say ‘the complexity is’.
Operations budget
A rough count of primitive operations you can afford: \(\sim10^8\) simple ops/second in C, \(\sim10^6\!-\!10^7\) in pure Python.
Loop invariant
A property true before the loop, preserved by each iteration, and strong enough at exit to give the result. The standard correctness proof.
Variant / measure
A non-negative integer that strictly decreases each iteration - the termination half of the proof (well-ordering, as in Euclid).
Pseudo-polynomial
Polynomial in the numeric VALUE of an input but exponential in its bit length (e.g. the \(O(nW)\) knapsack DP).
Crossover point
The \(n\) beyond which an asymptotically better algorithm actually beats a worse one with a smaller constant. It is often much larger than you expect.

Intuition & Motivation

Intuition
Two questions, asked before any code is written. Will it finish? - count states, count work per state, multiply, compare to a budget. Will it be right? - name the loop invariant and the decreasing measure. Nearly every failed attempt in this phase’s problem set fails one of those two questions on paper, ten minutes before the keyboard would have told you the same thing an hour later.

Asymptotics, precisely

Definition - The three symbols
\(f=O(g)\): \(\limsup f/g\lt \infty\) (upper bound). \(f=\Omega(g)\): \(\liminf f/g\gt 0\) (lower bound). \(f=\Theta(g)\): both. Note \(n=O(n^2)\) is true but useless; if you know the bound is tight, say \(\Theta\).

Constants and lower-order terms are dropped because they do not affect the growth rate - but they absolutely affect whether your program finishes today. Asymptotics rank algorithms; a budget decides feasibility. You need both, and confusing them is the classic beginner error in both directions.

The feasibility estimate

\[\text{time}\ \approx\ \frac{\text{operations}}{\text{ops per second}},\qquad \text{ops per second}\approx\begin{cases}10^8\text{–}10^9 & \text{C / NumPy vectorised}\\ 10^6\text{–}10^7 & \text{pure CPython loops.}\end{cases}\] (19.32)
\(n\)\(O(n^2)\)\(O(n^3)\)\(O(2^n)\)\(O(n!)\)
101001 0001 0243.6M
204008 0001M2.4×10^18 - hopeless
10010 0001M10^30 - hopeless -
1 0001M10^9 - borderline - -
10^610^12 - hopeless - - -

Read this table as a set of hard walls. In pure Python, \(10^6\) loop iterations is a comfortable second; \(10^8\) is a coffee break; \(10^{10}\) is never. So a \(\Theta(n^2)\) algorithm at \(n=10^5\) (\(10^{10}\) ops) is not ‘slow’ - it is infeasible, and the fix must change the exponent, not the code.

Key Idea
The one-minute triage. Given the input size, work backwards: \(n\le10\) → anything, even \(n!\). \(n\le20\) → \(2^n\) (bitmask DP, 19.8). \(n\le40\) → \(2^{n/2}\) (meet in the middle, 19.10). \(n\le500\) → \(n^3\). \(n\le5000\) → \(n^2\). \(n\le10^6\) → \(n\log n\). \(n\ge10^9\) → \(O(\log n)\) or a closed form (19.13). The input size TELLS you the intended complexity class - read it as a hint, not a constraint.

Interactive: where does the crossover actually happen?

Proving it right: invariant + variant

An algorithm is correct when you can state (i) a loop invariant - a property that holds before the loop, is preserved by every iteration, and at termination implies the goal - and (ii) a variant - a non-negative integer that strictly decreases, guaranteeing termination by well-ordering.

Worked Example - Prove binary search correct
1
Setup. Sorted array \(A[0..n-1]\), target \(t\). Maintain \(lo,hi\) with the search range \([lo,hi)\).
2
Invariant. If \(t\) occurs in \(A\) at all, then it occurs in \(A[lo..hi-1]\). True initially (\(lo=0,\ hi=n\) covers everything).
3
Preservation. Let \(mid=\lfloor(lo+hi)/2\rfloor\). If \(A[mid]\lt t\), then by sortedness no index \(\le mid\) can hold \(t\), so setting \(lo=mid+1\) preserves the invariant; symmetrically for \(A[mid]\gt t\) with \(hi=mid\).
4
Variant. \(hi-lo\) is a non-negative integer, and each iteration strictly decreases it (because \(lo\le mid\lt hi\), both branches shrink the range by at least one). By well-ordering the loop terminates.
5
Exit. The loop ends when \(lo=hi\) (empty range) or on a hit. By the invariant, an empty range means \(t\) is absent. Both outcomes are correct. □
6
Cost. The variant at least halves each iteration, so there are \(\le\lceil\log_2 n\rceil\) iterations: \(\Theta(\log n)\). Note the SAME two objects - invariant and variant - delivered correctness AND complexity. That is not a coincidence; it is why this method is worth the discipline. (Compare Euclid, 19.1: invariant = the gcd is unchanged, variant = the second argument.)

Optimizing a brute force, in order

‘Brute force is too slow’ is a diagnosis, not a plan. Apply these four moves in order, and stop as soon as you are under budget:

  1. Prune. Reject partial candidates early (19.10). Costs nothing, often buys orders of magnitude, never changes the exponent.
  2. Restructure the loops. Hoist invariant work out of the inner loop; iterate over the smaller set; break as soon as the answer is decided. Constant-factor wins - but a \(100\times\) constant is the difference between a coffee break and a second.
  3. Precompute / cache. Sieve once and query many (19.2); memoize the recursion (19.7); build a factorial table (19.5). This is usually where an \(O(n\sqrt n)\) becomes an \(O(n\log\log n)\).
  4. Reformulate. Change the algorithm class: closed form, matrix power (19.13), meet-in-the-middle (19.10), generating function (19.6), a mathematical identity that collapses the sum. This is the only move that changes the EXPONENT - and it is the move that most problems are secretly testing.

The ordering is deliberate: the first three are cheap and safe, and they buy you the time to think about the fourth. But if the feasibility estimate says you are \(10^6\times\) over budget, skip straight to (4) - no amount of pruning closes a gap of six orders of magnitude.

Coding workbench

Every problem in the Euler Lab is, at bottom, a feasibility puzzle: the naive reading of the statement is always computable in principle and always too slow in practice. The stated bound (‘below two million’, ‘the \(10\,001\)st prime’, ‘term \(10^{15}\)’) is a message about the intended complexity class. Learning to read that message is the single most transferable skill in this phase - and it is exactly the skill a quant research job tests when it asks whether your backtest will finish before the meeting.

Common Mistakes to Avoid
  • Saying ‘it’s \(O(n^2)\), that’s fine’ without knowing \(n\). Complexity without a number is not an estimate.
  • Optimizing the constant when the exponent is wrong. A \(100\times\) speedup of an \(O(2^n)\) algorithm buys you \(\log_2 100\approx6.6\) more elements. That is all.
  • Confusing \(O\) with \(\Theta\), then concluding an algorithm is slow because someone quoted a loose upper bound.
  • Ignoring memory. \(O(n^2)\) time is often fine at \(n=10^5\); \(O(n^2)\) MEMORY at \(n=10^5\) is 80 GB and will not run at all.
  • Trusting a benchmark on small inputs. Constants dominate below the crossover, so the asymptotically-worse algorithm often looks faster in your test - and then dies in production.
  • Skipping the correctness proof because the code ‘passes the examples’. An invariant that you can state in one sentence catches bugs that no test suite will.
Quant Practitioner Tips
  • Estimate on paper first. Count states, count work per state, multiply, compare to \(10^7\) (Python) or \(10^8\) (C). Ten minutes of arithmetic beats an hour of waiting.
  • The input bound in the problem statement is a hint about the intended algorithm. \(n\le20\) screams bitmask; \(n\le10^{18}\) screams \(O(\log n)\) or a formula.
  • When you must speed up pure Python by a constant: vectorize with NumPy (Phase 17), use bytearray/big-int bitsets (19.8), and hoist everything you can out of the inner loop. Expect \(10\text{–}100\times\), never \(10^6\times\).
  • Always keep the brute force. It is your oracle: run it on small inputs and diff against the fast version. Every optimization in this phase should be validated that way (this is Pragmatic Programmer’s point about testing against a reference implementation).
  • State the invariant in a comment above the loop. If you cannot state it, you do not yet understand your own algorithm - and that is the moment to stop typing.

Practice this in the Euler Lab

Computational problems that exercise exactly this technique. Each opens in the Euler Lab with a Python workbench, a progressive hint ladder, and answer checking. Tier A/B run at full scale in the browser.

Warm-up:
#39 Integer Right Triangles (2%, tier A) #11 Largest Product in a Grid (5%, tier A) #44 Pentagon Numbers (6%, tier A) #18 Maximum Path Sum I (7%, tier A)

Applied:
#485 Maximum Number of Divisors (19%, tier B) #190 Maximising a Weighted Product (21%, tier B) #183 Maximum Product of Parts (23%, tier B)

Challenge:
#147 Rectangles in Cross-hatched Grids (41%, tier C) #161 Triominoes (41%, tier C)

745 Project Euler problems in total are mapped to this lesson. Open the Euler Lab to filter them all.

Knowledge Check

Q1 Medium
Your algorithm is \(\Theta(n^2)\) and \(n=10^5\). In pure Python, roughly how long?
Under a second
About a minute
About 10 days - \(10^{10}\) ops at \(\sim10^7\) ops/s
It depends only on the constant
Q2 Medium
A loop invariant proves correctness; what proves TERMINATION?
The invariant does both
A variant: a non-negative integer measure that strictly decreases each iteration
The base case
Big-O analysis
Q3 Hard
You have an \(O(2^n)\) algorithm and you make it 1000× faster with clever constant-factor tuning. How many more elements can you handle?
1000 more
About 10 more (\(\log_2 1000\approx10\))
Twice as many
It becomes polynomial

Practical Exercise

(a) You must count pairs \((i,j)\) with a property, over \(n=2\times10^5\) items. The obvious algorithm is \(O(n^2)\). Estimate its cost, decide feasibility, and name two reformulations that could change the exponent. (b) Give a loop invariant and a variant for the Euclidean algorithm (19.1) and conclude both correctness and the \(O(\log\min(a,b))\) bound. (c) An \(O(n\log n)\) algorithm has constant 200; an \(O(n^2)\) one has constant 1. Find the crossover \(n\) and say which you would ship.

▶ Show full solution

(a) \(n^2=4\times10^{10}\) operations. At \(10^7\) ops/s (Python) that is \(4\times10^3\) seconds ≈ 67 minutes at the very best, realistically many hours - infeasible for an interactive workflow, and no amount of tuning fixes a factor of \(10^4\). Reformulations that change the exponent: (i) sort and sweep - if the property is order-related (e.g. \(a_i+a_j\le K\)), sort once and use two pointers or binary search: \(O(n\log n)\); (ii) hash/count complement - if the property is an equality (e.g. \(a_i+a_j=K\)), pass once with a hash map of complements: \(O(n)\). Both replace ‘examine every pair’ with ‘examine every element, and look up what it needs’ - the standard \(n^2\to n\log n\) move.

(b) Invariant: \(\gcd(a,b)\) is unchanged by the update \((a,b)\mapsto(b,\ a\bmod b)\) - proved in 19.1 by showing the two pairs have identical common-divisor sets. Variant: the second argument \(b\), a non-negative integer, strictly decreases (since \(a\bmod b\lt b\)). By well-ordering the loop terminates, and at termination \(b=0\), where the invariant gives \(\gcd(a,0)=a\) - the returned value is the gcd. Bound: the variant more than halves every two steps (\(a\bmod b\lt a/2\) in either case), so the number of iterations is \(O(\log\min(a,b))\). The same two objects gave correctness, termination AND complexity.

(c) Solve \(200\,n\log_2 n = n^2\), i.e. \(n=200\log_2 n\). Try \(n=2000\): \(200\log_2 2000\approx200\times11=2200\gt 2000\). Try \(n=2400\): \(200\times11.2=2240\lt 2400\). So the crossover is near \(n\approx2200\). Which to ship? If \(n\) is always below ~2000, the quadratic is genuinely faster AND simpler - ship it, and say so in a comment. If \(n\) can grow, ship the \(O(n\log n)\): at \(n=10^6\) it is \(4\times10^9\) vs \(10^{12}\), a 250× win that keeps growing. The right answer depends on the input distribution, which is why ‘what is \(n\)?’ is always the first question.

After the reveal, answer for yourself: In (c), the asymptotically worse algorithm wins on the actual data. When is it professionally right to ship the ‘worse’ algorithm - and what must you write down when you do?

Lesson Summary

Before writing code, ask two questions. Will it finish? - count states × work per state, and compare against a real budget (\(\sim10^7\) ops/s in Python, \(\sim10^8\!-\!10^9\) in C/NumPy); the input bound in a problem statement is a message about the intended complexity class. Will it be right? - state a loop invariant (correctness) and a decreasing non-negative variant (termination); the same two objects usually hand you the complexity bound too. When brute force is too slow, prune, restructure, precompute, then reformulate - and remember that only reformulation changes the exponent.

Retrieval Practice

Close the lesson and answer from memory before checking. This is deliberate, effortful recall - the single highest-yield study action.

▶ Show retrieval prompts & answers
Q: What two things must you check before implementing an algorithm, and how?
A: Feasibility: |states| × work-per-state versus an ops budget (\(\sim10^7\)/s in Python, \(10^8\)–\(10^9\) in C/NumPy). Correctness: a loop invariant (preserved each iteration, implies the goal at exit) plus a non-negative variant that strictly decreases (termination).
Q: Why can't a constant-factor speedup rescue an exponential algorithm?
A: A \(c\)-fold speedup shifts the feasible input size by only \(\log_2 c\), since \(2^{n+\Delta}=2^n2^\Delta\). A 1000× optimization buys about 10 extra elements. Only changing the algorithm class (the exponent) helps.

Completion Checklist

Confidence / mastery rating
Personal notes

Source References

This lesson synthesizes and paraphrases concepts from the sources below. No copyrighted text, problem sets, or solutions are reproduced. Return to the originals for full depth.