Data Structures & Algorithms (DSA) Module 1 – Foundations: problems, correctness and complexity
Correctness: specifications and loop invariants
Write preconditions and postconditions, prove a loop right with an invariant (start, every pass, exit) and catch a broken invariant with assert.
What you will learn
- Write preconditions and postconditions for a function
- State a loop invariant and check its initialisation, maintenance and termination
- Use assertions to catch a broken invariant while testing
- Distinguish partial correctness from total correctness
Before you start
On this page
The last lesson asked for one sentence explaining why each faster solution is right. This lesson makes that sentence precise. It shows how to say exactly what a function promises, and how to argue that a loop keeps the promise for every input, not only for the inputs you happened to test.
Say what the function promises
A specification has two parts:
- the precondition: what must be true of the input when the function is called;
- the postcondition: what will be true of the result when it returns, provided the precondition held.
For a function largest(items), a careful specification reads:
- Precondition:
itemsis a list of numbers with at least one item. - Postcondition: the result is one of the items, and no item is larger than it.
Both halves of the postcondition matter. “No item is larger” alone would let the function return a huge number that
is not in the list at all. And the precondition is not decoration: Python’s own max() has the same one, and called
on an empty list without a default it raises ValueError rather than inventing an answer. A caller who breaks the
precondition gets no promise.
Loop invariants
Most algorithms are loops, and a loop is where a promise is easiest to break. A loop invariant is a statement
about the loop’s variables that is true every time the loop test is checked: before the first pass, and again after
each pass. For the loop inside largest, a good invariant is:
Invariant: best is the largest of items[0:i].
To show that a loop is correct with an invariant, check three things:
- Initialisation. It is true before the first pass. With
best = items[0]andi = 1, it says thatbestis the largest of the first item alone, which is true. - Maintenance. If it is true before a pass, it is true after it. The body compares
items[i]withbestand keeps the larger, then adds 1 toi, sobestis again the largest of the firstiitems. - Termination. The loop stops, and when it does, the invariant together with the reason it stopped gives the
postcondition. It stops when
i == len(items), and then “the largest of items[0:i]” is the largest of all of them.
The three checks of a loop invariant
Text description of the diagram
The diagram follows the loop of largest(), from top to bottom. Its invariant is "best is the largest of items[0:i]".
- Initialisation: the precondition says items has at least one item, so best = items[0] and i = 1 make the invariant true before the first pass.
- The loop test asks whether i < len(items). While it does, the body runs: it looks at items[i], updates best if needed and adds 1 to i. Maintenance means that if the invariant was true before a pass, it is true again after it. The arrow then leads back to the test.
- Termination: when the test fails, the invariant still holds and i == len(items). Together these say that best is the largest of all the items, which is the postcondition.
This is an argument about every possible input at once. Tests can only ever try some of them.
C. A. R. Hoare made the method formal in “An axiomatic basis for computer programming” (Communications of the ACM,
volume 12, number 10, pages 576–580), building on Robert Floyd’s earlier work with flowcharts. He wrote a
precondition, a program and a postcondition together, and gave a rule for while loops built on an invariant P. In
the notation used today:
In words: if every run of the body S that starts with P true and the loop condition B true ends with P true again, then the whole loop, started with P true, can only end with P still true and B false. The three checks above are this rule applied by hand, plus the reason the loop ends at all, which the rule leaves out (more on that below).
Assertions put the invariant to work
An assert statement checks a condition while the program runs and raises AssertionError when it is false. Writing
the invariant as an assertion turns the argument into something the computer checks on every pass:
def largest(items):
"""Precondition: items is a list of numbers with at least one item.
Postcondition: the result is one of the items, and no item is larger."""
best = items[0]
i = 1
while i < len(items):
# Invariant: best is the largest of items[0:i].
assert best == max(items[:i]), f"invariant broken at i={i}"
print(f"i={i}: best={best} is the largest of {items[:i]}")
if items[i] > best:
best = items[i]
i += 1
# The invariant still holds, and the loop stopped because i == len(items).
assert best == max(items[:i]) and i == len(items)
return best
print("result:", largest([3, 8, 2, 9, 4])) Output
i=1: best=3 is the largest of [3] i=2: best=8 is the largest of [3, 8] i=3: best=8 is the largest of [3, 8, 2] i=4: best=9 is the largest of [3, 8, 2, 9] result: 9
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 largest.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
Each line of the output comes from one pass, just after its invariant check, and the invariant held every time; the
assertion after the loop checks the exit as well. max(items[:i]) makes each check cost as much as a whole pass over
the list, which is fine while testing and the reason such checks do not belong in production code.
A broken loop that still passes a test
Here is the same function with an easy mistake: the loop starts at index 2 instead of 1, so the second item is never looked at.
def largest(items, check=False):
"""Meant to return the largest item, but the loop starts one place too late."""
best = items[0]
for i in range(2, len(items)): # the bug: index 1 is never looked at
if check:
assert best == max(items[:i]), f"invariant broken at i={i}: best={best}, max(items[:{i}])={max(items[:i])}"
if items[i] > best:
best = items[i]
return best
print("largest([3, 8, 2, 9]) =", largest([3, 8, 2, 9])) # right, by luck
print("largest([3, 8, 2]) =", largest([3, 8, 2])) # wrong: 8 is larger
largest([3, 8, 2, 9], check=True) # the invariant check stops at the first bad step Output (exit status 1)
largest([3, 8, 2, 9]) = 9 largest([3, 8, 2]) = 3
Printed as an error (standard error)
Traceback (most recent call last):
File "largest_bug.py", line 14, in <module>
largest([3, 8, 2, 9], check=True) # the invariant check stops at the first bad step
~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^
File "largest_bug.py", line 6, in largest
assert best == max(items[:i]), f"invariant broken at i={i}: best={best}, max(items[:{i}])={max(items[:i])}"
^^^^^^^^^^^^^^^^^^^^^^
AssertionError: invariant broken at i=2: best=3, max(items[:2])=8
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 largest_bug.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
The first call returns 9, the right answer, because the largest item happens to come late in the list. A test on
[3, 8, 2, 9] alone would pass. The second call shows the bug: 3 instead of 8. With the invariant checked, the very
first pass fails, at i = 2: best is still 3 while the first two items include 8. The check catches the mistake on
the very input whose final answer happened to be right, because it tests every step, not only the result.
Termination: something must shrink
A loop that never stops never returns a wrong answer, and it is still not correct. To show that a loop stops, find a
variant: a whole number that is never negative and gets smaller on every pass, so the loop cannot run forever. In
binary search over the positions lo up to hi - 1, the variant is hi - lo, the number of positions left.
def search(items, target, buggy=False):
"""Index of target in the sorted list items, or -1. Positions lo up to hi - 1 are still to be searched."""
lo, hi = 0, len(items)
sizes = []
while lo < hi:
sizes.append(hi - lo) # the variant: it must shrink on every pass
if len(sizes) > 8:
return f"no answer: hi - lo went {sizes}, the loop never ends"
mid = (lo + hi) // 2
if items[mid] == target:
return f"found at {mid}: hi - lo went {sizes}"
if items[mid] < target:
lo = mid if buggy else mid + 1 # the bug keeps mid in the range
else:
hi = mid
return f"not found: hi - lo went {sizes + [hi - lo]}"
prices = [10, 20, 30, 40]
print("correct, 35:", search(prices, 35))
print("correct, 20:", search(prices, 20))
print("buggy, 20:", search(prices, 20, buggy=True))
print("buggy, 35:", search(prices, 35, buggy=True)) Output
correct, 35: not found: hi - lo went [4, 1, 0] correct, 20: found at 1: hi - lo went [4, 2] buggy, 20: found at 1: hi - lo went [4, 2] buggy, 35: no answer: hi - lo went [4, 2, 1, 1, 1, 1, 1, 1, 1], the loop never ends
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 stuck_search.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
In the correct version hi - lo shrinks on every pass until the target is found or nothing is left. The buggy version
sets lo = mid instead of lo = mid + 1. Once a single position is left and its value is below the target, mid is
lo itself, lo = mid changes nothing, and hi - lo stays 1 forever; the example gives up after eight passes.
Notice that the buggy version never returns a wrong position. When it returns at all, its answer is right.
That difference has names:
- A loop is partially correct if, whenever it stops, its result meets the postcondition.
- It is totally correct if it is partially correct and also stops on every input that meets the precondition.
Hoare’s rules on their own prove only the first: his paper says a proved result holds provided the program terminates, and calls this “conditional” correctness. The variant supplies the second.
Half-open ranges prevent off-by-one errors
Many off-by-one bugs are invariant bugs: the code means “positions lo to hi, both included” in one place and “lo up
to, but not including, hi” in another. Python’s range(lo, hi) and slices such as items[lo:hi] are half-open:
they include lo and exclude hi. That convention has useful properties: the length is hi - lo, an empty range is
simply lo == hi, and two ranges [a, b) and [b, c) join with no gap and no overlap. Choose one convention for each
loop, write it in the invariant, and keep it everywhere in that loop. The binary search above uses the half-open
interval of positions from lo up to hi.
Assertions check invariants, never input
Python runs assertions only while __debug__ is true. Started with the -O option, it removes every assert
statement before running the program, so a check written as an assertion silently disappears:
import subprocess
import sys
program = """
def withdraw(balance, amount):
assert 0 < amount <= balance, "amount out of range"
return balance - amount
print(withdraw(500, 800))
"""
for command in (["python3"], ["python3", "-O"]):
run = subprocess.run([sys.executable, *command[1:], "-c", program], capture_output=True, text=True)
shown = run.stdout.strip() or run.stderr.strip().splitlines()[-1]
print(" ".join(command) + ":", shown) Output
python3: AssertionError: amount out of range python3 -O: -300
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 assert_skipped.py
Without -O, the assertion stops a withdrawal of 800 from a balance of 500. With -O, the check is gone and the
balance goes to −300.
Security
Never use assert to check input from a user, a file or the network, or anything else a program must refuse when it
is wrong. Raise an exception such as ValueError instead. Use assertions for what should be true if your own code is
correct, such as invariants, so that a failure points at a bug.
Key takeaways
- A specification is a precondition on the input and a postcondition on the result; the postcondition must pin the answer down completely.
- A loop invariant is true every time the loop test is checked. Prove it true at the start, show that each pass keeps it, and check that at the exit it gives the postcondition.
- Asserting the invariant while testing finds a broken loop on the first pass that breaks it, even when the final answer happens to be right.
- Partial correctness means “right whenever it stops”; total correctness adds “always stops”, shown with a variant that shrinks on every pass.
- Assertions vanish under
python -O: use them for invariants, never to validate input.
Exercise
Exercise · Easy · Python, JavaScript
Find the first negative number, with its invariant
Write two functions in first_negative.py (JavaScript: first_negative.mjs).
no_negative_before(nums, i)(JavaScript:noNegativeBefore(nums, i)) states the invariant of your loop. It returnsTruewhen none ofnums[0], …,nums[i - 1]is negative, andFalseotherwise. Fori = 0there is nothing before position 0, so the answer isTrue.first_negative(nums)(JavaScript:firstNegative(nums)) returns the position of the first negative number innums, or-1when there is none. Zero is not negative.
Use a loop over a position i that starts at 0, and keep no_negative_before(nums, i) true every time the loop test is checked. When the loop stops, the invariant and the reason it stopped must be enough to give the answer.
The sample tests check both functions on small cases. Then, on 300 random lists, they check that your invariant is true at every position your loop passes, and false just after the negative number your function returns.
Python · Starter code · first_negative.py
def no_negative_before(nums, i):
"""True when none of nums[0], ..., nums[i - 1] is negative: the invariant of the loop below."""
# Replace this line with your code.
return True
def first_negative(nums):
"""Position of the first negative number in nums, or -1 when there is none."""
i = 0
# Write your loop here. Every time its test is checked, no_negative_before(nums, i) must be true.
return -1 The sample tests · test_first_negative.py
import random
from first_negative import first_negative, no_negative_before
def test_first_negative_cases():
"""returns the first negative position, or -1"""
assert first_negative([]) == -1
assert first_negative([3, 1, 4]) == -1
assert first_negative([-1]) == 0
assert first_negative([5, 0, -2, -7]) == 2
assert first_negative([0, 0]) == -1
def test_invariant_cases():
"""the invariant is true for an empty prefix and false once a negative number is inside"""
assert no_negative_before([5, 0, -2], 0) is True
assert no_negative_before([5, 0, -2], 2) is True
assert no_negative_before([5, 0, -2], 3) is False
assert no_negative_before([-4, 6], 1) is False
def test_invariant_explains_the_answer():
"""on 300 random lists, the invariant holds up to the answer and fails just after it"""
rng = random.Random(3)
for _ in range(300):
nums = [rng.randint(-3, 9) for _ in range(rng.randint(0, 10))]
answer = first_negative(nums)
stop = len(nums) if answer == -1 else answer
for i in range(stop + 1):
assert no_negative_before(nums, i) is True, (nums, i)
if answer != -1:
assert nums[answer] < 0, nums
assert no_negative_before(nums, answer + 1) is False, nums JavaScript · Starter code · first_negative.mjs
/** True when none of nums[0], ..., nums[i - 1] is negative: the invariant of the loop below. */
export function noNegativeBefore(nums, i) {
// Replace this line with your code.
return true;
}
/** Position of the first negative number in nums, or -1 when there is none. */
export function firstNegative(nums) {
let i = 0;
// Write your loop here. Every time its test is checked, noNegativeBefore(nums, i) must be true.
return -1;
} The sample tests · first_negative.test.mjs
import { test, assert } from 'toolverse:test';
import { firstNegative, noNegativeBefore } from './first_negative.mjs';
test('returns the first negative position, or -1', () => {
assert.equal(firstNegative([]), -1);
assert.equal(firstNegative([3, 1, 4]), -1);
assert.equal(firstNegative([-1]), 0);
assert.equal(firstNegative([5, 0, -2, -7]), 2);
assert.equal(firstNegative([0, 0]), -1);
});
test('the invariant is true for an empty prefix and false once a negative number is inside', () => {
assert.equal(noNegativeBefore([5, 0, -2], 0), true);
assert.equal(noNegativeBefore([5, 0, -2], 2), true);
assert.equal(noNegativeBefore([5, 0, -2], 3), false);
assert.equal(noNegativeBefore([-4, 6], 1), false);
});
test('on 300 random lists, the invariant holds up to the answer and fails just after it', () => {
let seed = 3;
const random = () => (seed = (seed * 48271) % 2147483647) / 2147483647;
for (let k = 0; k < 300; k++) {
const nums = Array.from({ length: Math.floor(random() * 11) }, () => Math.floor(random() * 13) - 3);
const answer = firstNegative(nums);
const stop = answer === -1 ? nums.length : answer;
for (let i = 0; i <= stop; i++) assert.equal(noNegativeBefore(nums, i), true, JSON.stringify([nums, i]));
if (answer !== -1) {
assert.ok(nums[answer] < 0, JSON.stringify(nums));
assert.equal(noNegativeBefore(nums, answer + 1), false, JSON.stringify(nums));
}
}
}); A hint
A loop such as while i < len(nums) and nums[i] >= 0: i += 1 keeps the invariant: it only moves past a number after checking that it is not negative. It stops for one of two reasons. Either i == len(nums), and no number at all is negative, or nums[i] is the first negative number.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (Pyodide, QuickJS): nothing is sent to mysmartcopilot.com. The first run of each language downloads it: Python (about 13.5 MB) or JavaScript (about 0.6 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Check yourself
6 questions about this lesson. Every answer and why it is right is on the page, behind “Show the answer”. Your score stays in this browser.
References
- An axiomatic basis for computer programming (C. A. R. Hoare, Communications of the ACM, volume 12, number 10, pages 576–580) (Association for Computing Machinery (this copy is hosted by Carnegie Mellon University))
- Computer Science Curricula 2023: Algorithmic Foundations (AL) (ACM, IEEE Computer Society and AAAI)
- The assert statement (The Python Language Reference) (Python Software Foundation)
- Command line and environment: the -O option (Python Software Foundation)
- Built-in functions: max() (Python Software Foundation)
- Built-in types: ranges (Python Software Foundation)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress