Foundations/What Is an Algorithm?

Lesson 1.14,250 words

What Is an Algorithm?

An algorithm is a finite, mechanical recipe that transforms inputs into outputs. We define what counts as an algorithm, how we write one down, and the three things we always ask of it: is it correct, is it fast, and can we prove it.

╌╌╌╌

You have already met dozens of algorithms without being told that's what they were. Merge sort, quicksort, binary search, linear search, breadth-first search; even the rote procedures for multiplying () or adding () two integers by hand; even tidying a room by a fixed set of rules. A simple working definition unites them:

fib.pypython
def fib(n: int) -> int:
  if n <= 1:
    return n
  return fib(n-1) + fib(n-2)

def fib2(n: int) -> int:
  M = [0, 1]
  for i in range(1, n):
    M.append(M[-1] + M[-2])
  return M[-1]
fib.hshaskell
fib :: Int -> Int
fib 0 = 0
fib 1 = 1
fib n = fib (n - 1) + fib (n - 2)

fib2 :: Int -> Int
fib2 n
  | n <= 1    = n
  | otherwise = fib2 (n - 1) + fib2 (n - 2)

That definition is deliberately blunt. Erickson sharpens it the same way: an algorithm is a procedure a rock could follow, where every step is so mechanical that no intelligence, intuition, or luck is required to carry it out.1 CLRS frames it operationally: a well-defined computational procedure that takes a value (or set of values) as input and produces a value as output.2 Skiena adds the engineer's caveat: it must work correctly on every instance, not merely on the examples we happen to test.3

Pull those framings apart and you get a checklist. A procedure earns the name algorithm only if it is:

  • Finite. It halts after finitely many steps on every valid input. Repeat until it looks sorted is not a step count you can bound.
  • Definite. Every step is unambiguous: two executors starting from the same state and reading the same step do the same thing. Pick a good pivot fails this test until you say which element.
  • Effective. Each step is basic enough to actually carry out: compare two numbers, copy a cell, add. Factor the integer is a legal step only if you already hold an algorithm for factoring.
  • Anchored to a problem. It consumes specified inputs and produces specified outputs. A procedure that produces nothing computes nothing.

The multiplication procedure you learned as a child passes all four; season to taste fails definiteness, and a program that loops forever on one unlucky input fails finiteness. Three demands run through everything that does pass, and through this entire course:

  • Correctness. The algorithm produces the right output on every valid input. One counterexample is enough to sink it.
  • Efficiency. It uses few resources (time, space) as the input grows.
  • Provability. We can argue, rather than assert, that the first two hold.

Communicating an algorithm

Having an algorithm in your head is not enough; you must convey it so that someone else can run it, trust it, and predict its cost. In this course every algorithm comes with four deliverables:

  1. High-level idea. One or two sentences of plain English.
  2. Pseudocode. The steps, precise enough to analyze.
  3. Proof of correctness. An argument that it always returns the right answer.
  4. Complexity analysis. How its running time (and sometimes space) grows with the input.

These four form a pipeline. The problem spec fixes what counts as a correct answer; the idea and pseudocode are two resolutions of the same method; the proof certifies the pseudocode against the spec; and the analysis measures that same pseudocode. Each deliverable feeds the next.

The four deliverables as a pipeline: the problem spec anchors everything; idea and pseudocode refine the method; proof checks the pseudocode against the spec; analysis measures the same pseudocode.

We will build a complete example of all four below. First, two preliminaries: what exactly the algorithm is for, and how we write it down.

Specifying the problem

Before writing an algorithm we must agree on the problem it solves: the set of legal inputs and, for each, the required output. A problem is a relation between inputs and outputs; an instance is one particular input. For sorting:

Notice what the specification hides: it says nothing about how to reorder the numbers, only what the result must satisfy. Algorithm design rests on that separation of what from how.

Our running example will be a smaller, more concrete problem:

It is simple enough that you can see the whole algorithm at once, yet rich enough to demand all four deliverables, including a proof that is more subtle than it first looks.

Deliverable 1 — the high-level idea

Before any pseudocode, say what you intend to do in plain words. For :

That sentence is the whole algorithm. Everything that follows makes it precise and proves it works.

Deliverable 2 — the pseudocode

We describe algorithms in pseudocode: precise enough to analyze, free of the syntactic noise of any real language. The high-level idea translates directly:

Algorithm 1:Find-Max(A)\textsc{Find-Max}(A)return the largest element of A[1..n]A[1..n]
  1. 1
    xA[1]x \gets A[1]
    largest value seen so far
  2. 2
    for i2i \gets 2 to nn do
  3. 3
    if A[i]>xA[i] > x then
  4. 4
    xA[i]x \gets A[i]
  5. 5
    return xx

Two small but real design choices deserve attention. We seed with rather than with or . This is why the specification insisted , so that exists and the maximum is well defined. The loop starts at , since has already been accounted for by the seed.

find_max.pypython
from typing import Sequence, TypeVar

from comparable import Comparable

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

def find_max(array: Sequence[Element]) -> Element:
  """
    The maximum element of a non-empty `array`.\n
    Raises ValueError when `array` is empty, since the specification\n
    requires at least one element for "the maximum" to be defined.\n
  """
  if len(array) == 0:
    raise ValueError("find_max requires a non-empty array")

  # seed with the first element so the answer is always one of the inputs.
  largest: Element = array[0]

  # sweep the rest, keeping the larger of the running best and each element.
  for index in range(1, len(array)):
    largest = max(largest, array[index])

  return largest
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: ...

As a second specimen of pseudocode, here is the classic insertion sort, which sorts in place by growing a sorted prefix one element at a time. We will return to it when we study sorting; for now it shows what nested loops and an in-place rearrangement look like on the page.

Algorithm 2:Insertion-Sort(A)\textsc{Insertion-Sort}(A) — sort A[1..n]A[1..n] in increasing order
  1. 1
    for j2j \gets 2 to nn do
  2. 2
    keyA[j]key \gets A[j]
  3. 3
    ij1i \gets j - 1
    insert into sorted prefix
  4. 4
    while i>0i > 0 and A[i]>keyA[i] > key do
  5. 5
    A[i+1]A[i]A[i + 1] \gets A[i]
  6. 6
    ii1i \gets i - 1
  7. 7
    A[i+1]keyA[i + 1] \gets key
  8. 8
    return AA

The outer loop walks a marker from left to right; everything before is already sorted. Each pass lifts out as , slides the larger sorted-prefix elements one slot right, and drops into the gap that opens up — exactly how you would tidy a hand of playing cards. The trace below shows the sorted prefix (shaded) absorbing one new element per row.

Insertion-Sort on : each row is the array after one pass of ; the shaded prefix is sorted, and the outlined cell is the just inserted.

A picture of the idea

Here is mid-sweep on . The cursor has just reached ; everything to its left has been scanned, and holds the largest value among . Since , the if fires and is updated to .

Find-Max mid-sweep over a five-cell array, updating at the cursor .

The shaded cell is the element under the cursor; the region to its left (positions through ) is the part already summarized by .

insertion_sort.pypython
from typing import TypeVar

from comparable import Comparable

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

def insertion_sort(array: list[Element]) -> list[Element]:
  """
    Sort `array` into non-decreasing order in place, returning it.\n
    Stable: equal elements keep their original relative order, since the\n
    inner loop stops shifting as soon as it meets an element <= the key.\n
  """
  for marker in range(1, len(array)):
    key: Element = array[marker]

    # slide every sorted-prefix element greater than `key` one slot right.
    position: int = marker - 1
    while position >= 0 and array[position] > key:
      array[position + 1] = array[position]
      position -= 1

    # the gap that opened up is exactly where `key` belongs.
    array[position + 1] = key

  return array
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: ...

Deliverable 3 — proof of correctness

How do we know works? Erickson's a rock could run it intuition tells us the steps are mechanical, but it does not tell us the answer is right. Correctness needs an argument.

It is tempting to argue by contradiction (suppose is the true maximum but returns something else), but the clean way is to name what the loop preserves and induct on it: is only ever overwritten by a larger value, so never decreases and never holds anything that wasn't actually in the array. Made precise, that is a loop invariant: a statement true before and after every iteration.

We verify it with the three-part rubric that will recur throughout the course:

  • Initialization. Before the first iteration , so we must check . The seed line set , and the maximum of a one-element set is that element. ✓
  • Maintenance. Assume the invariant holds entering iteration , i.e. . The body sets (it overwrites exactly when , and leaves it otherwise). Hence after the body , which is precisely the invariant for the next cursor value . ✓
  • Termination. The loop ends once the cursor would exceed , i.e. with the invariant established for . So , and returns exactly the value the specification demands. ✓

Initialization, maintenance, termination: that triple drives correctness proofs, and we will use it constantly.

The rubric on a harder loop: insertion sort

's invariant fit in one clause. Insertion sort (Algorithm 2) needs two, and the second is the one beginners drop:

The phrase "exactly the elements originally in :q is doing real work.The prefix is sorted:q alone would not pin the algorithm down: a procedure could make the prefix sorted by destroying it (we will meet such asorter" shortly), so the invariant must also record that elements are only ever rearranged, never invented or lost. CLRS states the insertion-sort invariant in exactly this two-clause form.4

Before the proof, look at what one pass actually does to the array. Lifting out of its cell (line 2) leaves a hole; each pass of the while loop (lines 4–6) slides one prefix element right into the hole, moving the hole one step left; line 7 fills the final hole with .

Mid-pass snapshot of Insertion-Sort at , , on . The while loop has slid , then , one slot right, leaving the hole at position 3. Left of the hole the prefix is intact and sorted; from the hole to position , every element is ; has not been looked at. The next test finds , so the hole moves once more before line 7 drops into it.

Now the three parts, argued against the line numbers of Algorithm 2.

  • Initialization. Before the first iteration, , so the prefix is the single cell . Nothing has executed yet, so it still holds its original element, and a one-element array is trivially sorted. ✓
  • Maintenance. Assume the invariant entering the iteration for some : the prefix is a sorted arrangement of the original first elements. Line 2 copies into , so we may treat cell as a hole. Each pass of the while loop (lines 4–6) fires only when ; line 5 copies that element one slot right into the hole, and line 6 makes cell the new hole. Ignoring the hole, every shift leaves the prefix's elements intact and in the same relative order, so the region right of the hole (through cell ) stays sorted and consists entirely of elements . The loop exits in one of two ways: , so every prefix element was and the hole is cell ; or , so the hole sits just right of the rightmost element . Either way, everything left of the hole is and sorted, everything right of it through cell is and sorted, and line 7 drops into the hole. The result is sorted, holding exactly the original elements of . That is the invariant with in place of . ✓
  • Termination. The for loop exits when reaches . Plugging into the invariant: consists of exactly the elements originally in , in increasing order. That is word for word the sorting specification (a sorted permutation of the input), and line 8 returns it. ✓

The maintenance step above quietly ran a second induction: the claim about holes and shifted elements is itself an invariant of the inner while loop, checked once per shift. For nested loops that layering is the normal shape of a correctness proof — an outer invariant whose maintenance step leans on an inner one. Here the inner argument is short enough to inline; when it is not, state the inner invariant explicitly and give it the same three-part treatment.

Aside: soundness and completeness

For algorithms that answer yes/no rather than compute a value, the same rigor takes a slightly different shape. Consider , which reports whether key occurs in . Its correctness has two independent halves, and they carry standard names worth adopting now — they recur across the whole course, in search, decision procedures, verifiers, and the reductions of intractability:

  • Soundnessevery found answer is true. The procedure never lies in the affirmative: when it says found, genuinely . Soundness rules out false positives.
  • Completenessevery true case is caught. The procedure never misses: when , it really does say found. Completeness rules out false negatives.

The two are genuinely separate. An algorithm that always answered not found would be vacuously sound — it never makes a false claim of membership — yet hopelessly incomplete; one that always answered found would be complete but unsound. Correctness requires both guarantees at once.

Proving soundness by contrapositive: the hard claim about the whole array (top) is logically identical to an easy one about a single line of code (bottom).

Invariants are not only about prefixes growing left to right. When the array is sorted, we can search it by repeatedly halving a window, and the invariant describes where the answer can still hide rather than what has been built so far.

Algorithm 3:Binary-Search(A,k)\textsc{Binary-Search}(A, k) — search sorted A[1..n]A[1..n] for kk
  1. 1
    lo1lo \gets 1
  2. 2
    hinhi \gets n
  3. 3
    while lohilo \le hi do
  4. 4
    mid(lo+hi)/2mid \gets \floor{(lo + hi) / 2}
  5. 5
    if A[mid]=kA[mid] = k then return found
  6. 6
    else if A[mid]<kA[mid] < k then lomid+1lo \gets mid + 1
    discard left half
  7. 7
    else himid1hi \gets mid - 1
    discard right half
  8. 8
    return not found

The invariant is deliberately conditional. It does not claim is in the window — may not be in the array at all — only that the cells outside the window have been legitimately ruled out.

  • Initialization. Lines 1–2 set and , so the window is the whole array and the claim is vacuous: if is in , it is in . ✓
  • Maintenance. Suppose the invariant holds entering an iteration and line 5 does not return, so . If (line 6), sortedness gives , so no cell at index can hold . If is in the array at all, the invariant places it in , and we just excluded , so it lies in — exactly the new window after . The case (line 7) is symmetric. ✓
  • Termination. Two duties here, and the first is easy to forget: the loop must actually end, and the exit state must imply the postcondition. For progress: inside the loop forces , so line 6 raises by at least one and line 7 lowers by at least one; the window length strictly shrinks every iteration and the loop runs at most times. For the exit itself there are two doors. Through line 5, the algorithm just witnessed , so found is sound. Through line 3 failing, and the window is empty; the invariant says that if were in the array it would be in that empty window, which is absurd, so and not found is correct — the algorithm is complete. ✓

The same vocabulary as , but here completeness is not a one-line observation: it rests entirely on the invariant. Every discarded cell was ruled out for a reason, and the invariant records those reasons.

Binary-Search for in . Each row is one probe: the shaded window is , the thick cell is , and grayed cells have been ruled out by the invariant. Probes: , then , then — found.

How invariant proofs go wrong

An invariant proof has exactly three joints, and each one has a characteristic failure. All three failures look like proofs until you press on the right spot.5

False at initialization. The seed in looks fussier than the neutral seed , and the neutral seed is a genuine bug. With , the invariant is already false entering whenever ; and on an all-negative array the if never fires, so the algorithm returns — a value that is not even in the array. The failed initialization check is not pedantry; it points at a real input that breaks the program.

A broken seed. Initializing falsifies the invariant before the loop even starts: on no element beats , the if never fires, and Find-Max returns , which does not occur in . The correct seed makes initialization checkable — and true.

Too weak to imply the postcondition. Take insertion sort and drop the permutation clause, keeping only " is sorted." Now consider this impostor, which replaces lines 2–7 of Algorithm 2 with a single assignment:

Algorithm 4:Copy-Left(A)\textsc{Copy-Left}(A) — a "sorter" with a perfect (weak) invariant
  1. 1
    for j2j \gets 2 to nn do
  2. 2
    A[j]A[j1]A[j] \gets A[j - 1]
  3. 3
    return AA

The weak invariant sails through all three checks: a one-cell prefix is sorted (initialization); appending a copy of the last element keeps a sorted prefix sorted (maintenance); at the whole array is sorted (termination). Every step is airtight, and the program is garbage — on it sorts to . Nothing in the proof was wrong; the invariant proved a true statement that fails to imply the specification, which demanded a sorted permutation of the input. When the termination step ends with anything short of the postcondition, word for word, the invariant needs strengthening.

Off-by-one at the exit. The termination step must use the exact negation of the loop guard, evaluated at the actual exit value of the counter. 's loop ends with , not ; plugging the wrong value into the invariant proves only , which leaves unaccounted for. The same slip in is a live bug rather than a weak conclusion: change line 3 to while and, on a one-element array with , the loop body never runs and line 8 answers not found. The invariant itself survives untouched — what breaks is the exit analysis, because failing gives , a window that may still hold one unexamined cell, and the window is empty step of the termination argument is simply false.

Deliverable 4 — complexity, in brief

The fourth deliverable asks how many steps the algorithm takes as a function of the input size . For , the seed and the final return cost a constant; the loop runs times, doing a comparison and at most one assignment each pass. If each line costs some machine-dependent constant , the total is

a linear running time: double the array and you roughly double the work. The point of the is that it throws away the machine-specific constants and keeps only the growth rate.

That insertion sort, by contrast, is not always so cheap is why fast needs care. On an array already in reverse order, every new element sifts past all its predecessors, costing comparisons, which is quadratic in . On an already-sorted array it does only comparisons, which is linear: the same algorithm, wildly different costs.

Why the same Insertion-Sort costs so differently: a reverse-sorted input forces every new element past its whole prefix, while a sorted input shifts nothing.

Defining , , and precisely, and measuring this growth independently of the machine, is the subject of asymptotic analysis in the next lesson.

Further frontiers

The four deliverables are a working discipline, but each has grown into a field of its own. The precise, mechanical procedure we relied on informally has an exact meaning: a function is computable if some Turing machine computes it, and the Church–Turing thesis holds that every reasonable model of computation — Turing machines, the lambda calculus, the RAM of the next lesson — computes exactly the same class of functions. Turing's 1936 construction also produced the first problem that no algorithm can solve, the halting problem: there is no procedure that decides, for an arbitrary program and input, whether the program eventually stops.6 So write an algorithm for it is not always a request that can be met, a boundary worth knowing before spending a week on a problem that is provably undecidable.

The proof-of-correctness deliverable, done here by hand, can be machine-checked. Interactive proof assistants such as Coq and Isabelle let one state an algorithm's specification and its invariants formally and have the computer verify every step; the CompCert C compiler and the seL4 operating-system kernel are large systems proved correct this way, insertion sort's loop invariant scaled up to tens of thousands of lines.7 And the high-level-idea deliverable is where the standard design paradigms live — divide-and-conquer, greedy, dynamic programming, and the rest — each a reusable pattern for the idea step, and each the subject of a later module. Skiena frames the whole design manual around recognizing which paradigm a new problem fits.8

Takeaways

  • An algorithm is a precise recipe of steps for solving a computational problem: a finite, mechanical, input-to-output procedure that must be correct on every instance.
  • Every algorithm comes with four deliverables: high-level idea, pseudocode, proof of correctness, and complexity analysis. shows all four at full size.
  • Specify the problem (legal inputs → required outputs) before the method. That is also what tells you to seed and require .
  • Pseudocode is for humans to reason about; a loop invariant ( only ever grows, to a value actually in the array) turns it looks right into a proof by initialization, maintenance, termination. State which moment your invariant describes.
  • Invariants must be strong enough to imply the postcondition: insertion sort's invariant needs the permutation clause, not just the prefix is sorted satisfies the weak version while destroying the input.
  • The termination step has two duties: show the loop ends (a quantity that strictly shrinks), then combine the invariant with the exact negation of the guard at the counter's real exit value. Off-by-ones live here.
  • For yes/no algorithms, prove each direction separately, and use the contrapositive to pick the easier statement. needs both: found is witnessed directly; not found is forced by the invariant.
  • Correctness and efficiency are separate questions: an algorithm can win one and lose the other.

Footnotes

  1. Erickson, Algorithms, Ch. 0 — Introduction: an algorithm as a procedure mechanical enough that a rock could follow it.
  2. CLRS, Ch. 1 — The Role of Algorithms (§1.1): the operational well-defined computational procedure framing.
  3. Skiena, The Algorithm Design Manual, §1.1–1.3 — Introduction to Algorithm Design: an algorithm must be correct on every instance.
  4. CLRS, §2.1 — Insertion sort: the loop invariant is stated with both clauses, "the elements originally in :q andin sorted order."
  5. Skiena, §1.3 — Reasoning about Correctness: on how plausible-looking correctness arguments fail, and why counterexamples and induction are the tools that expose them.
  6. Turing, A. M. (1936). On computable numbers, with an application to the Entscheidungsproblem. Proc. London Mathematical Society s2-42 — Turing machines, the Church–Turing thesis, and the undecidability of the halting problem.
  7. Leroy, X. (2009). Formal verification of a realistic compiler. Communications of the ACM 52(7) — the CompCert verified C compiler; Klein, G. et al. (2009). seL4: formal verification of an OS kernel. Proc. SOSP.
  8. Skiena, The Algorithm Design Manual, §1.1 and Part I — organizing algorithm design around a small set of reusable paradigms (divide-and-conquer, greedy, dynamic programming, and others).
Practice

╌╌ END ╌╌