Chapter 7: Foundational Problem-Solving Patterns
7.08Beginner5 topics16 min3 interactive animations

State and invariants

An invariant is a statement that stays true at every step of a loop. Writing it down tells you how to update your variables, why the loop terminates, and why the final answer is right.

Learning goals

  • State a loop invariant and check initialisation, maintenance and termination
  • Design counters and windows around the invariant they maintain
  • Prove that a pointer move never skips an answer
  • Test a solution against a brute-force reference

Prerequisites

LoopsTwo pointersSliding windows

If you can say exactly what your variables mean after every iteration, the code almost writes itself, and most off-by-one bugs disappear.

7.08.1

Loop invariants

A loop invariant is checked in three steps. Initialisation: it is true before the first iteration. Maintenance: if it is true before an iteration, it is still true after. Termination: when the loop ends, the invariant plus the exit condition give the result.

Kadane's algorithm is a clean example. Invariant: after index i, current is the best sum of a subarray ending exactly at i, and best is the best sum anywhere in arr[0..i]. Step through the animation and check that both statements hold at every frame.

Worst

O(n)

Space

O(1)

Maximum subarray (Kadane's invariant)

Interactive · edit the input

Your input

Try

int[] arr · length 9

-2
1
-3
4
-1
2
1
-5
4
012345678
Being comparedBest run ending hereBest subarray so far
Setup

Invariant: after processing index i, current is the best sum of a subarray ending exactly at i, and best is the best sum anywhere in arr[0..i].

Step 1 / 14
Java
1public static int maxSubarray(int[] arr) {
2 int current = arr[0], best = arr[0]; // current = best sum of a run ending at i
3 for (int i = 1; i < arr.length; i++) {
4 current = Math.max(arr[i], current + arr[i]); // extend the run or restart at i
5 best = Math.max(best, current);
6 }
7 return best;
8}

Live state

0 steps

current

-2

best

-2

No condition evaluated on this step
Execution trace (dry run table)Show 14 steps
StepLineStateWhat happens
1L2current=-2 best=-2Invariant: after processing index i, current is the best sum of a subarray ending exactly at i, and best is the best sum anywhere in arr[0..i].
2L4i=1 arr[i]=1 current=1 best=-2Restart at 1: the run before it had a negative sum and would only drag 1 down.
3L5i=1 current=1 best=1New best 1, subarray [1..1].
4L4i=2 arr[i]=-3 current=-2 best=1Extend the run [1..2] to sum -2.
5L4i=3 arr[i]=4 current=4 best=1Restart at 3: the run before it had a negative sum and would only drag 4 down.
6L5i=3 current=4 best=4New best 4, subarray [3..3].
7L4i=4 arr[i]=-1 current=3 best=4Extend the run [3..4] to sum 3.
8L4i=5 arr[i]=2 current=5 best=4Extend the run [3..5] to sum 5.
9L5i=5 current=5 best=5New best 5, subarray [3..5].
10L4i=6 arr[i]=1 current=6 best=5Extend the run [3..6] to sum 6.
11L5i=6 current=6 best=6New best 6, subarray [3..6].
12L4i=7 arr[i]=-5 current=1 best=6Extend the run [3..7] to sum 1.
13L4i=8 arr[i]=4 current=5 best=6Extend the run [3..8] to sum 5.
14L7return=6return 6 from [3..6]. The invariant held at every step, which is the whole correctness proof. O(n), O(1) space.
7.08.2

Maintaining counts

Many algorithms keep a summary of what has been seen instead of re-scanning: a running sum, a frequency map, or the number of satisfied requirements. The invariant states exactly what the summary means, for example “count[v] is the number of times v occurs in the current window”.

Every operation that changes the underlying data must update the summary in the same step. Forgetting the update when the left edge of a window moves is the most common sliding-window bug.

Java
1// invariant: count[c] = occurrences of c in s[left..right]
2int[] count = new int[128];
3int left = 0;
4for (int right = 0; right < s.length(); right++) {
5 count[s.charAt(right)]++; // right edge moved: update the summary
6 while (count[s.charAt(right)] > 1) {
7 count[s.charAt(left)]--; // left edge moves: update it too
8 left++;
9 }
10 // here: s[left..right] has no repeated characters
11}
7.08.3

Window invariant

For a sliding window, write down what holds after each outer iteration. In “minimum size subarray sum”: after shrinking, the window [left..right] has sum below the target, and every valid window ending at right has already been recorded.

That invariant justifies the while loop: it shrinks exactly until the window is no longer valid, so it never misses a shorter valid window and never stops early.

Variable window: shortest subarray with sum ≥ target

Interactive · edit the input

Your input

Try

int[] arr · length 6

2
3
1
2
4
3
012345
EnteringLeavingBest window
Setup

Positive values make the window sum grow when right moves and shrink when left moves. That monotonicity is what lets a window work.

Step 1 / 18
Java
1public static int minSubArrayLen(int target, int[] arr) { // all values positive
2 int left = 0, sum = 0, best = Integer.MAX_VALUE;
3 for (int right = 0; right < arr.length; right++) {
4 sum += arr[right]; // grow the window
5 while (sum >= target) { // valid: try to shrink it
6 best = Math.min(best, right - left + 1);
7 sum -= arr[left++];
8 }
9 }
10 return best == Integer.MAX_VALUE ? 0 : best;
11}

Live state

0 pointer moves

target

7

left

0

sum

0

best

∞

No condition evaluated on this step
Execution trace (dry run table)Show 18 steps
StepLineStateWhat happens
1L2target=7 left=0 sum=0 best=∞Positive values make the window sum grow when right moves and shrink when left moves. That monotonicity is what lets a window work.
2L4target=7 left=0 right=0 sum=2 best=∞Add arr[0] = 2; window sum is 2.
3L4target=7 left=0 right=1 sum=5 best=∞Add arr[1] = 3; window sum is 5.
4L4target=7 left=0 right=2 sum=6 best=∞Add arr[2] = 1; window sum is 6.
5L4target=7 left=0 right=3 sum=8 best=∞Add arr[3] = 2; window sum is 8.
6L6target=7 left=0 right=3 sum=8 best=4[0..3] reaches the target with length 4. Best so far: 4. Now try a shorter window.
7L7target=7 left=1 right=3 sum=6 best=4Drop arr[0]; sum is 6.
8L4target=7 left=1 right=4 sum=10 best=4Add arr[4] = 4; window sum is 10.
9L6target=7 left=1 right=4 sum=10 best=4[1..4] reaches the target with length 4. Best so far: 4. Now try a shorter window.
10L7target=7 left=2 right=4 sum=7 best=4Drop arr[1]; sum is 7.
11L6target=7 left=2 right=4 sum=7 best=3[2..4] reaches the target with length 3. Best so far: 3. Now try a shorter window.
12L7target=7 left=3 right=4 sum=6 best=3Drop arr[2]; sum is 6.
13L4target=7 left=3 right=5 sum=9 best=3Add arr[5] = 3; window sum is 9.
14L6target=7 left=3 right=5 sum=9 best=3[3..5] reaches the target with length 3. Best so far: 3. Now try a shorter window.
15L7target=7 left=4 right=5 sum=7 best=3Drop arr[3]; sum is 7.
16L6target=7 left=4 right=5 sum=7 best=2[4..5] reaches the target with length 2. Best so far: 2. Now try a shorter window.
17L7target=7 left=5 right=5 sum=3 best=2Drop arr[4]; sum is 3.
18L10return=2return 2. Each index entered and left the window at most once: 11 pointer moves, O(n).
7.08.4

Pointer movement proof

Two-pointer algorithms are correct when every move discards only candidates that cannot be part of an answer. In two-sum on sorted data, if arr[lo] + arr[hi] < target, then arr[lo] plus any remaining value is also too small, so lo can be discarded permanently.

Write that argument for every pointer move. If you cannot, the algorithm may skip a valid answer, and a brute-force test will usually find the counterexample.

Two-sum on a sorted array (opposite ends)

Interactive · edit the input

Your input

Try

int[] arr · length 8

1
3
4
6
8
11
14
17
01234567
Being comparedRuled outMatch / answer
Setup

Start with the smallest and largest values. Every step rules out one value for good.

Step 1 / 15
Java
1public static int[] twoSum(int[] arr, int target) { // arr is sorted
2 int lo = 0, hi = arr.length - 1;
3 while (lo < hi) {
4 int sum = arr[lo] + arr[hi];
5 if (sum == target) return new int[] { lo, hi };
6 if (sum < target) lo++; // need a bigger sum
7 else hi--; // need a smaller sum
8 }
9 return new int[] { -1, -1 };
10}

Live state

0 checks

target

19

lo

0

hi

7

No condition evaluated on this step
Execution trace (dry run table)Show 15 steps
StepLineStateWhat happens
1L2target=19 lo=0 hi=7Start with the smallest and largest values. Every step rules out one value for good.
2L5target=19 lo=0 hi=7 sum=1818 is too small. arr[lo] plus even the largest remaining value is too small, so arr[lo] can never be part of an answer.
3L6target=19 lo=1 hi=7lo = 1.
4L5target=19 lo=1 hi=7 sum=2020 is too large. arr[hi] plus even the smallest remaining value is too large, so arr[hi] is ruled out.
5L7target=19 lo=1 hi=6hi = 6.
6L5target=19 lo=1 hi=6 sum=1717 is too small. arr[lo] plus even the largest remaining value is too small, so arr[lo] can never be part of an answer.
7L6target=19 lo=2 hi=6lo = 2.
8L5target=19 lo=2 hi=6 sum=1818 is too small. arr[lo] plus even the largest remaining value is too small, so arr[lo] can never be part of an answer.
9L6target=19 lo=3 hi=6lo = 3.
10L5target=19 lo=3 hi=6 sum=2020 is too large. arr[hi] plus even the smallest remaining value is too large, so arr[hi] is ruled out.
11L7target=19 lo=3 hi=5hi = 5.
12L5target=19 lo=3 hi=5 sum=1717 is too small. arr[lo] plus even the largest remaining value is too small, so arr[lo] can never be part of an answer.
13L6target=19 lo=4 hi=5lo = 4.
14L5target=19 lo=4 hi=5 sum=19Found the pair.
15L5target=19 return=[4, 5]return [4, 5] after 7 checks. O(n) time, O(1) space.
7.08.5

Correctness check

Test pattern-based solutions against a brute-force reference. Generate many small random inputs, run both, and compare. Small inputs (n up to 8 or so) find most boundary bugs: empty windows, single elements, all-equal values and negative numbers.

Every animation on this site was checked this way. A useful habit is to deliberately break your solution once and confirm the test fails; a test that cannot fail checks nothing.

Java
1Random rnd = new Random(42);
2for (int trial = 0; trial < 10_000; trial++) {
3 int[] arr = rnd.ints(rnd.nextInt(8) + 1, -5, 6).toArray();
4 int fast = maxSubarray(arr); // the O(n) solution under test
5 int slow = Integer.MIN_VALUE; // brute force: every subarray
6 for (int i = 0; i < arr.length; i++) {
7 int sum = 0;
8 for (int j = i; j < arr.length; j++) { sum += arr[j]; slow = Math.max(slow, sum); }
9 }
10 if (fast != slow) throw new AssertionError(Arrays.toString(arr) + " " + fast + " vs " + slow);
11}

Practice ladder

Now try it yourself

Easy

  • Write the invariant for linear search and for finding the maximum
  • Maximum subarray (Kadane)

Medium

  • Longest substring without repeating characters: state the window invariant
  • Write a brute-force tester for two-sum on sorted input

Advanced

  • Prove Boyer-Moore majority vote with an invariant
  • Prove the correctness of the Dutch national flag partition

Key takeaways

  • An invariant needs initialisation, maintenance and termination.
  • Every change to the data must update the summary you maintain.
  • Justify every pointer move, then confirm with a brute-force random test that can fail.