Foundations/Proof Techniques

Lesson 1.24,808 words

Proof Techniques

An algorithm without a proof is a conjecture. This lesson collects the handful of arguments that certify the algorithms in this course — direct proof, contrapositive, contradiction, ordinary and strong induction, construction, and disproof by counterexample — each with a small worked example and a picture.

╌╌╌╌

Every algorithm in this course comes with four deliverables, and the third — proof of correctness — is the one students skip and regret. An algorithm you cannot argue for is a conjecture: it might be right, but you have no way to know, and it passed my tests is not a proof, since one untested instance can sink it.

The arguments you need are few. A small kit of techniques proves nearly everything in algorithms, and the same shapes recur from sorting to intractability. This lesson is that kit. None of it is deep; the skill is recognizing which tool fits a given claim, and writing the argument cleanly enough that a skeptical reader is forced to agree.

Direct proof

The default. To prove if then , assume and walk a chain of valid steps to . Most everyday claims yield to it.

The whole move is to unfold the definitions (odd means ), do the algebra, then fold the definition back (an expression of the form is odd). Many direct proofs follow this shape: translate the hypothesis into symbols, manipulate, translate back.

Proof by contrapositive

The statements if then and if not then not are logically equivalent:

So when the forward direction is awkward, prove the contrapositive instead — it is the same theorem in an easier form. We used exactly this to prove a search routine sound in the previous lesson: rather than reason about the whole array (if it returns not found), we reasoned about one line of code (if it returns found then ).

Proving even even directly is clumsy (you would factor ); the contrapositive reduces it to a one-line consequence of work already done.

In algorithm design, contrapositives are most useful when they turn a claim about absence into a claim about presence. Here is the argument that lets a trial-division primality test stop at instead of scanning all the way to :

The forward statement quantifies over every candidate divisor and asserts a negative, which gives you nothing to compute with. The contrapositive supplies a concrete object (, the smaller factor) and one inequality. The algorithmic payoff is real: testing a twelve-digit number now takes about trial divisions instead of .

Proof by contradiction

To prove , assume and derive an impossibility. Since valid reasoning from a true premise cannot reach a falsehood, the premise must have been false, so holds. It is the proof of last resort, and often the shortest.

Proof by contradiction: assume the claim is false, reason without error, and arrive at something impossible; the only escape is that the assumption was wrong.

The classic is the irrationality of , the proof the Pythagoreans reputedly found unsettling.

Contradiction also proves things do not exist or cannot be improved. The lower bound for comparison sorting and the proof that no greedy coin system is always optimal are both suppose a better object existed, derive absurdity arguments. Here is the smallest specimen of that genre, a genuine lower bound proved in four sentences:

The move deserves a name, because it recurs: the contradiction is manufactured by an adversary who watches what the algorithm does and then plants the bad case precisely in the spot it never looked. Every you cannot do better than claim in this course, from sorting to searching, has this shape: assume a faster algorithm exists, then exhibit an input it must get wrong.

rational_sqrt.pypython
from math import isqrt

def integer_sqrt(number: int) -> int:
  """
    The floor of the square root of a non-negative integer.\n
  """
  if number < 0:
    raise ValueError("integer_sqrt is undefined for negative integers")
  return isqrt(number)

def is_perfect_square(number: int) -> bool:
  """
    Whether `number` is a perfect square — equivalently, whether sqrt(number)\n
    is rational for a non-negative integer.\n
  """
  if number < 0:
    return False

  # a number is a perfect square iff floor(sqrt) squared recovers it.
  root: int = isqrt(number)
  return root * root == number

def has_rational_sqrt(number: int) -> bool:
  """
    Whether the square root of a non-negative integer is rational. By the\n
    contradiction argument, this holds exactly for perfect squares; sqrt(2),\n
    not a square, is therefore irrational.\n
  """
  return is_perfect_square(number)

def square_is_even_implies_even(number: int) -> bool:
  """
    A checkable instance of the contrapositive lemma used in the proof:\n
    if number^2 is even then number is even. Returns whether the\n
    implication holds for this `number` (always True).\n
  """
  # the contrapositive: whenever the square is even, the number is too.
  square_is_even: bool = (number * number) % 2 == 0
  number_is_even: bool = number % 2 == 0
  return (not square_is_even) or number_is_even

Induction

Induction is the main tool for proving algorithm correctness, because algorithms repeat: loops and recursion both do the same thing on ever-smaller (or ever-larger) instances.1 To prove a statement for all , you show two things:

  • Base case. holds outright.
  • Inductive step. Assuming (the induction hypothesis), prove .

Together these are a falling chain of dominoes: the base case tips the first, the step guarantees each tips the next, so all of them fall.

Induction as dominoes: the base case topples the first tile, the inductive step guarantees each tile topples its neighbour, so every tile falls.

The textbook example is the sum of the first integers, the same closed form the analysis lessons lean on.

Equalities are the gentlest case, because the inductive step is forced: substitute the hypothesis, simplify, done. Inequalities require slightly more judgment: after substituting you must still bridge from what the hypothesis gives you to what the claim demands. Here is the inequality that explains why halving reaches in logarithmically many steps, the fact behind binary search and every divide-and-conquer depth bound:

Read the step closely: the hypothesis delivered , the claim wanted , and the bridge was the observation that the surplus is nonnegative. Most failed induction attempts on inequalities die exactly at that bridge, and the usual repair is to prove a stronger claim whose surplus is bigger.

The art is choosing the hypothesis. Too weak and the step cannot go through; the fix is often to prove a stronger statement, whose hypothesis gives the step more to work with (this is strengthening the induction hypothesis, and it powers the substitution method for recurrences).

gauss_sum.pypython
def gauss_sum_closed(count: int) -> int:
  """
    The closed form 1 + 2 + ... + count = count*(count+1)/2.\n
    Defined as 0 for count <= 0 (an empty sum).\n
  """
  if count <= 0:
    return 0
  return count * (count + 1) // 2

def gauss_sum_iterative(count: int) -> int:
  """
    The same total accumulated in a loop, maintaining the invariant that\n
    after visiting integer `current`, `running_total` equals the closed\n
    form for `current`. Termination at count yields the claim.\n
  """
  running_total: int = 0
  for current in range(1, count + 1):
    running_total += current

    # invariant: the partial sum equals the closed form so far.
    assert running_total == gauss_sum_closed(current)
  return running_total

Strong induction

Sometimes needs more than its immediate predecessor — it needs several smaller cases, or one you cannot predict in advance. Strong induction grants the entire history: to prove , assume for all .

Ordinary induction leans on the single previous case; strong induction leans on every smaller case at once — needed when a step splits into unpredictable parts.

It is the natural argument whenever a problem splits into smaller subproblems of sizes you do not know ahead of time — which is to say, most of divide & conquer and dynamic programming.

Ordinary induction would be stuck here: 's factorization has nothing to do with 's. Knowing that tells you nothing useful about , and knowing about tells you nothing about . We needed the freedom to reach back to and , wherever they landed. For that means and , neither of which is .

prime_factorization.pypython
def is_prime(number: int) -> bool:
  """
    Whether `number` is prime by trial division up to its square root.\n
  """
  if number < 2:
    return False

  # trial-divide by every candidate up to sqrt(number).
  divisor: int = 2
  while divisor * divisor <= number:
    if number % divisor == 0:
      return False
    divisor += 1
  return True

def smallest_factor(number: int) -> int:
  """
    The smallest divisor of `number` that is at least 2 — `number` itself\n
    when it is prime. Requires number >= 2.\n
  """
  # the first divisor found below sqrt is prime; none means number is prime.
  candidate: int = 2
  while candidate * candidate <= number:
    if number % candidate == 0:
      return candidate
    candidate += 1
  return number

def prime_factors(number: int) -> list[int]:
  """
    The non-decreasing list of prime factors of `number` (with multiplicity),\n
    whose product is `number`. Requires number >= 2.\n
    Follows the strong-induction structure: peel off the smallest prime\n
    factor, then factor the strictly smaller quotient.\n
  """
  if number < 2:
    raise ValueError("prime factorization is defined for integers >= 2")

  # peel off the smallest prime factor, then recurse on the quotient.
  factors: list[int] = []
  remaining: int = number
  while remaining > 1:
    factor: int = smallest_factor(remaining)
    factors.append(factor)
    remaining //= factor

  return factors

Strong induction proves recursion correct

The payoff of strong induction is that it certifies recursive algorithms almost mechanically: assume every recursive call returns the right answer (each call is on a smaller instance, so the strong hypothesis covers it), then check that the combining logic is sound.1 Here is the standard fast exponentiation routine, which computes in multiplications by squaring:

Algorithm 1:Power(x,n)\textsc{Power}(x, n) — compute xnx^n for integer n0n \ge 0
  1. 1
    if n=0n = 0 then
  2. 2
    return 11
  3. 3
    hPower(x,n/2)h \gets \textsc{Power}(x, \lfloor n/2 \rfloor)
    recurse on the half
  4. 4
    if nn is even then
  5. 5
    return hhh \cdot h
  6. 6
    else
  7. 7
    return hhxh \cdot h \cdot x

Notice which induction was required. The call from input is on , not on : for the proof for leans on the case , skipping the forty-nine cases between. Weak induction's hypothesis covers only the immediate predecessor and cannot reach that far back. Every recursive correctness proof in this course follows this template: base case = the non-recursive branch, hypothesis = the recursive calls are correct, step = the combine logic preserves correctness. The only obligation that varies is checking the combine.

Structural induction: trees

Induction is not confined to : any collection of objects built from smaller objects of the same kind supports it. Trees are the canonical case: a binary tree is either a single leaf or a root with two smaller binary trees hanging off it, so a claim about all binary trees can be proved by inducting on the tree's size, with the subtrees playing the role of smaller cases. This is structural induction, and it is strong induction in disguise: a root's subtrees can be any smaller sizes, so the step needs the full history, exactly as prime factorization did.

A full binary tree with internal nodes (circles) and leaves (squares). Removing the root splits it into two smaller full binary trees; the strong hypothesis applies to each.

The proof never mentions the tree's shape: balanced, degenerate, or lopsided, the count comes out the same, which is why the figure shows a deliberately skewed tree. Facts of this kind recur throughout the course: this one bounds the size of merge trees and decision trees in the sorting lower bound, and its siblings (a binary tree of height has at most leaves) are proved by the same template.2

Where inductions go wrong

Induction fails in stereotyped ways, and each failure mode has a classic specimen. Knowing them keeps your own proofs honest.

A valid step cannot rescue a false base. Consider the claim for all . The inductive step is airtight: if , then

since for . Yet the claim is false: at we would need , and at we would need . The first domino never falls. The statement only becomes true at (), so the honest theorem is for all , with base case . Checking where the base actually starts is not pedantry: an algorithm proved correct for all still needs separate handling for and , and a recursive implementation hits those inputs last and crashes on them first.

Count how far back the step reaches. A step that uses and together (the shape of every Fibonacci-flavored claim) needs two base cases, because the step for reaches down to , which one base case at never established. In general, a step that reaches back positions needs consecutive base cases, and a strong-induction step that recurses to needs the base to cover everything its smallest instances bottom out on. Erickson's advice is to write the step first, see which smaller cases it consumed, and only then decide what the base must be.1

Flawed maintenance: all horses are the same color. The most famous broken induction proves that any horses are identically colored. Base case (): one horse has one color. ✓ Inductive step: given horses, remove the first; the remaining are same-colored by hypothesis. Remove the last instead; the first are also same-colored. The two groups overlap, so all share one color. ?

The horses argument needs the two -element groups to overlap. For they share horses — but for the groups are and , disjoint, and the step collapses.

The flaw is quantifier-shaped: the overlap argument silently assumes the two groups share at least one horse, which requires . At the very first application of the step (from to ) the groups are and with empty intersection, and nothing links their colors. One broken link, at exactly one value of , and every later domino stands: the proof is valid for any horses of which some two share a color, a theorem nobody needs. The lesson is to test the inductive step at its smallest instance, where degenerate geometry (empty overlaps, single elements, zero-length ranges) lives.

Assuming what you are proving. The inductive hypothesis is ; the obligation is . Writing assume and simplifying it into a true statement proves nothing, because false premises can yield true conclusions: from , multiplying both sides by gives the perfectly true . This mistake usually appears disguised as start with the equation for and manipulate both sides until they match. The repair is directional discipline: start from the left side of the claim (or from the hypothesis), and transform it by known-valid steps until the right side appears, never touching the target equation as if it were already true.

Proof by construction

To prove something exists, the most convincing move is to build it — exhibit the object and check it works. A constructive existence proof produces a witness, not merely a guarantee.

This is the proof style closest to our subject, because an algorithm is a constructive proof: a correct algorithm for a problem is a witness that a solution exists and a recipe for producing it. When the greedy method proves its choice is safe by an exchange argument — take any optimal solution, transform it into the greedy one without making it worse — that, too, is construction: it builds the optimum it claims exists.

prime_gap_construction.pypython
from math import factorial

def composite_run(length: int) -> list[int]:
  """
    A run of `length` consecutive composite integers — the constructed\n
    witness. Term i (for offset 2..length+1) is (length+1)! + offset, which\n
    `offset` divides, so each term is composite. Requires length >= 1.\n
  """
  if length < 1:
    raise ValueError("a composite run must have length >= 1")
  base: int = factorial(length + 1)
  return [base + offset for offset in range(2, length + 2)]

def witness_divisor(length: int, position: int) -> int:
  """
    For the `position`-th term of `composite_run(length)` (0-indexed), a\n
    proper divisor that certifies it composite — namely position + 2.\n
  """
  if not 0 <= position < length:
    raise IndexError("position out of range for the constructed run")
  return position + 2

Disproof by counterexample

The arguments above confirm universal claims (for all ). To refute one, you need just a single instance where it fails. One counterexample is fatal; no amount of confirming examples can rescue a false for all.

This is why correctness must hold on every input, and why a plausible heuristic needs a proof rather than a few passing trials. Hunting for a small counterexample is also the fastest way to test a conjecture before investing in a proof3 — a conjecture that survives honest attempts to break it is worth the cost of a proof.

greedy_coin_counterexample.pypython
from typing import NamedTuple, Optional, Sequence

class Counterexample(NamedTuple):
  """
    A witness that greedy change is suboptimal: the amount, the coin count\n
    greedy uses, and the true minimum.\n
  """
  amount: int
  greedy_coins: int
  optimal_coins: int

def greedy_coin_count(denominations: Sequence[int], amount: int) -> Optional[int]:
  """
    Coins used by the greedy rule (largest coin that fits, repeatedly), or\n
    None if greedy gets stuck before reaching exactly `amount`.\n
  """
  remaining: int = amount
  coins_used: int = 0

  # repeatedly take as many of the largest fitting coin as possible.
  for coin in sorted(denominations, reverse=True):
    if coin <= 0:
      continue
    coins_used += remaining // coin
    remaining %= coin

  # success only when greedy lands exactly on the amount.
  return coins_used if remaining == 0 else None

def optimal_coin_count(denominations: Sequence[int], amount: int) -> Optional[int]:
  """
    The true minimum number of coins making exactly `amount`, by dynamic\n
    programming, or None if no combination works.\n
  """
  # best[t] is the fewest coins for amount t; unreachable marks "no way yet".
  unreachable: int = amount + 1
  best: list[int] = [0] + [unreachable for _ in range(amount)]

  # relax every target against each coin it can end with.
  for target in range(1, amount + 1):
    for coin in denominations:
      if 0 < coin <= target and best[target - coin] + 1 < best[target]:
        best[target] = best[target - coin] + 1

  return None if best[amount] == unreachable else best[amount]

def find_counterexample(
  denominations: Sequence[int], max_amount: int
) -> Optional[Counterexample]:
  """
    The smallest amount in 1..max_amount where greedy uses strictly more\n
    coins than the optimum, or None if greedy is optimal throughout that\n
    range. Hunting a small counterexample is the fast first attack on a\n
    conjectured "this always works."\n
  """
  # scan amounts upward; the first where greedy beats optimal is the witness.
  for amount in range(1, max_amount + 1):
    greedy: Optional[int] = greedy_coin_count(denominations, amount)
    optimal: Optional[int] = optimal_coin_count(denominations, amount)
    if greedy is not None and optimal is not None and greedy > optimal:
      return Counterexample(amount, greedy, optimal)

  return None

Loop invariants are induction

The reason induction matters so much here is that loop invariants are induction applied to time. A loop invariant is a statement true before and after every iteration; proving it amounts to an induction over the iteration count, and its three-part rubric4 maps one-to-one onto the parts of an inductive proof:

Loop invariantInduction
Initialization — holds before the first iterationBase case
Maintenance — if it holds before a pass, it holds afterInductive step
Termination — what the invariant gives once the loop stopsConclusion

We proved Find-Max correct exactly this way, and the pattern returns for every loop in the course. To see the rubric run end-to-end once more, here is the simplest loop that computes anything:

Algorithm 2:Array-Sum(A)\textsc{Array-Sum}(A)return A[1]+A[2]++A[n]A[1] + A[2] + \cdots + A[n]
  1. 1
    s0s \gets 0
  2. 2
    for i1i \gets 1 to nn do
  3. 3
    ss+A[i]s \gets s + A[i]
    fold in the element under the cursor
  4. 4
    return ss
The invariant photographed mid-run on at the start of the iteration with : the accumulator equals the sum of the shaded prefix , and positions are still untouched.
  • Initialization. Before the first iteration, and the invariant reads (sum of no elements) . The seed line set , and the empty sum is by convention. ✓
  • Maintenance. Assume the invariant entering the iteration with cursor : . The body executes , after which — the invariant with cursor value , the state in which the next iteration begins. ✓
  • Termination. The loop exits when the cursor passes , i.e. with the invariant holding for : . That is the specified output, and the last line returns it. ✓

Each bullet is short because the invariant was chosen well: it names the one quantity the loop maintains, at one fixed instant (the start of an iteration), and it mentions the cursor so that termination instantiates it at a known value. A vaguer statement ( holds a partial sum) passes initialization and maintenance trivially and then gives you nothing at termination. The rubric only pays off if the invariant is strong enough that its instance is the correctness claim.4

Recursion is even more direct: a recursive algorithm is correct by (usually strong) induction on the size of its input, with the base case the non-recursive branch and the inductive step the assumption that the recursive calls are themselves correct, exactly the proof above.

find_max_invariant.pypython
from typing import Sequence, TypeVar

from comparable import Comparable

Element = TypeVar("Element", bound=Comparable)

def find_max(values: Sequence[Element]) -> Element:
  """
    The largest element of a non-empty sequence, found by a single scan that\n
    checks its loop invariant after every iteration. Raises ValueError on an\n
    empty sequence.\n
  """
  if not values:
    raise ValueError("find_max is undefined for an empty sequence")

  current_max: Element = values[0]
  scanned: int = 1

  # initialization: current_max is the max of the length-1 prefix.
  assert current_max == max(values[:scanned])

  for index in range(1, len(values)):
    if values[index] > current_max:
      current_max = values[index]
    scanned = index + 1

    # maintenance: current_max is the max of the prefix values[:scanned].
    assert current_max == max(values[:scanned])

  # termination: the whole sequence was scanned, so this is the maximum.
  return current_max
comparable.pypython
from typing import Any, Protocol, TypeVar


class Comparable(Protocol):
  """
    Anything orderable with `<` (int, float, str, tuple, date, …).\n
  """

  # `other` is position-only so built-ins (int, str, …), whose dunder
  # operands are position-only, structurally satisfy the protocol.
  def __lt__(self, other: Any, /) -> bool: ...
  def __gt__(self, other: Any, /) -> bool: ...
  def __le__(self, other: Any, /) -> bool: ...
  def __ge__(self, other: Any, /) -> bool: ...

A note on yes/no algorithms

For procedures that decide rather than compute — does a path exist, is this formula satisfiable, is in the array — correctness has two halves with standard names, introduced earlier: soundness (every yes is true — no false positives) and completeness (every true case is caught — no false negatives). The two are proved separately and often with different tools: soundness frequently falls to a direct or contrapositive argument about a single accepting step, while completeness usually wants induction over the algorithm's progress. Keeping them apart keeps the proof honest.

Machine-checked proof, and its limits

Every technique here is a paper-and-pencil proof, checked by a human reader. Two developments push past that. The first is machine-checked proof: an a proof assistant like Coq, Lean, or Isabelle can check an inductive argument mechanically, invariant by invariant, with no gap left to clearly. The four-color theorem (1976) was the first famous result whose proof had to be completed by computer, and modern formalizations — Gonthier's fully machine-checked four-color and Feit–Thompson proofs — show the same induction and case analysis we do by hand, scaled to sizes no referee could audit.5 When a correctness proof matters enough that a subtle error would be costly, this is where the discipline of state the invariant precisely pays off.

The second is a caution about what a proof of correctness does and does not buy. A verified algorithm is correct relative to its specification; if the specification is wrong, the proof certifies the wrong thing. And correctness is separate from feasibility: proving that a procedure eventually halts with the right answer says nothing about whether it halts before the sun burns out — the running-time analysis of the next lessons is a distinct obligation. Skiena's running advice, to attack a conjecture with small counterexamples before attempting a proof, is the cheap filter that saves the expensive one: most false conjectures die to an instance with three or four elements.6

Takeaways

  • Match the tool to the claim. If then → direct, or contrapositive if the reverse is easier. A for all about a repeated process → induction (strong induction when a step needs many smaller cases). There exists → construction. This always works that you doubt → hunt a counterexample.
  • Induction is the spine of correctness proofs, because loops and recursion repeat. Loop invariants are induction over iterations; recursive correctness is induction over input size.
  • Strengthen the hypothesis when an inductive step won't close — proving a stronger statement can be easier, because the step gets more to work with.
  • Audit the base and the smallest step. A valid inductive step cannot rescue a false base case, a step that reaches back positions needs base cases, and flawed maintenance hides at the smallest instance (the horses proof breaks only at ).
  • One counterexample refutes a universal claim; no finite pile of examples proves one. This is why it passed the tests is evidence, not proof.
  • An algorithm is a constructive existence proof — which is why getting the proof right and getting the algorithm right are the same task, not two.

Footnotes

  1. Erickson, Algorithms, Appendix on Induction — the boilerplate inductive proof and the discipline of an explicit, strong induction hypothesis. 2 3
  2. CLRS, Appendix B.5 — trees and their basic counting properties; the decision-tree argument that uses them appears in Ch. 8 (§8.1).
  3. Skiena, The Algorithm Design Manual, §1.3 — reasoning about correctness, and counterexamples as the first line of attack on a conjecture.
  4. CLRS, Ch. 2 (§2.1) — loop invariants and the initialization / maintenance / termination rubric. 2
  5. Appel, K. & Haken, W. (1977). Every planar map is four colorable. Illinois J. Mathematics 21 — the first computer-assisted proof; Gonthier, G. (2008). Formal proof — the four-color theorem. Notices of the AMS 55(11), and the machine-checked Feit–Thompson odd-order theorem (2012).
  6. Skiena, The Algorithm Design Manual, §1.3 — hunting small counterexamples as the first, cheapest test of a conjectured algorithm or claim.
Practice

╌╌ END ╌╌