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
If you can say exactly what your variables mean after every iteration, the code almost writes itself, and most off-by-one bugs disappear.
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 inputint[] arr · length 9
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].
1public static int maxSubarray(int[] arr) {2int current = arr[0], best = arr[0]; // current = best sum of a run ending at i3for (int i = 1; i < arr.length; i++) {4current = Math.max(arr[i], current + arr[i]); // extend the run or restart at i5best = Math.max(best, current);6}7return best;8}
Live state
current
-2
best
-2
Execution trace (dry run table)Show 14 stepsHide
| Step | Line | State | What happens |
|---|---|---|---|
| 1 | L2 | current=-2 best=-2 | 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]. |
| 2 | L4 | i=1 arr[i]=1 current=1 best=-2 | Restart at 1: the run before it had a negative sum and would only drag 1 down. |
| 3 | L5 | i=1 current=1 best=1 | New best 1, subarray [1..1]. |
| 4 | L4 | i=2 arr[i]=-3 current=-2 best=1 | Extend the run [1..2] to sum -2. |
| 5 | L4 | i=3 arr[i]=4 current=4 best=1 | Restart at 3: the run before it had a negative sum and would only drag 4 down. |
| 6 | L5 | i=3 current=4 best=4 | New best 4, subarray [3..3]. |
| 7 | L4 | i=4 arr[i]=-1 current=3 best=4 | Extend the run [3..4] to sum 3. |
| 8 | L4 | i=5 arr[i]=2 current=5 best=4 | Extend the run [3..5] to sum 5. |
| 9 | L5 | i=5 current=5 best=5 | New best 5, subarray [3..5]. |
| 10 | L4 | i=6 arr[i]=1 current=6 best=5 | Extend the run [3..6] to sum 6. |
| 11 | L5 | i=6 current=6 best=6 | New best 6, subarray [3..6]. |
| 12 | L4 | i=7 arr[i]=-5 current=1 best=6 | Extend the run [3..7] to sum 1. |
| 13 | L4 | i=8 arr[i]=4 current=5 best=6 | Extend the run [3..8] to sum 5. |
| 14 | L7 | return=6 | return 6 from [3..6]. The invariant held at every step, which is the whole correctness proof. O(n), O(1) space. |
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.
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++) {5count[s.charAt(right)]++; // right edge moved: update the summary6while (count[s.charAt(right)] > 1) {7count[s.charAt(left)]--; // left edge moves: update it too8left++;9}10// here: s[left..right] has no repeated characters11}
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 inputint[] arr · length 6
Positive values make the window sum grow when right moves and shrink when left moves. That monotonicity is what lets a window work.
1public static int minSubArrayLen(int target, int[] arr) { // all values positive2int left = 0, sum = 0, best = Integer.MAX_VALUE;3for (int right = 0; right < arr.length; right++) {4sum += arr[right]; // grow the window5while (sum >= target) { // valid: try to shrink it6best = Math.min(best, right - left + 1);7sum -= arr[left++];8}9}10return best == Integer.MAX_VALUE ? 0 : best;11}
Live state
target
7
left
0
sum
0
best
∞
Execution trace (dry run table)Show 18 stepsHide
| Step | Line | State | What happens |
|---|---|---|---|
| 1 | L2 | target=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. |
| 2 | L4 | target=7 left=0 right=0 sum=2 best=∞ | Add arr[0] = 2; window sum is 2. |
| 3 | L4 | target=7 left=0 right=1 sum=5 best=∞ | Add arr[1] = 3; window sum is 5. |
| 4 | L4 | target=7 left=0 right=2 sum=6 best=∞ | Add arr[2] = 1; window sum is 6. |
| 5 | L4 | target=7 left=0 right=3 sum=8 best=∞ | Add arr[3] = 2; window sum is 8. |
| 6 | L6 | target=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. |
| 7 | L7 | target=7 left=1 right=3 sum=6 best=4 | Drop arr[0]; sum is 6. |
| 8 | L4 | target=7 left=1 right=4 sum=10 best=4 | Add arr[4] = 4; window sum is 10. |
| 9 | L6 | target=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. |
| 10 | L7 | target=7 left=2 right=4 sum=7 best=4 | Drop arr[1]; sum is 7. |
| 11 | L6 | target=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. |
| 12 | L7 | target=7 left=3 right=4 sum=6 best=3 | Drop arr[2]; sum is 6. |
| 13 | L4 | target=7 left=3 right=5 sum=9 best=3 | Add arr[5] = 3; window sum is 9. |
| 14 | L6 | target=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. |
| 15 | L7 | target=7 left=4 right=5 sum=7 best=3 | Drop arr[3]; sum is 7. |
| 16 | L6 | target=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. |
| 17 | L7 | target=7 left=5 right=5 sum=3 best=2 | Drop arr[4]; sum is 3. |
| 18 | L10 | return=2 | return 2. Each index entered and left the window at most once: 11 pointer moves, O(n). |
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 inputint[] arr · length 8
Start with the smallest and largest values. Every step rules out one value for good.
1public static int[] twoSum(int[] arr, int target) { // arr is sorted2int lo = 0, hi = arr.length - 1;3while (lo < hi) {4int sum = arr[lo] + arr[hi];5if (sum == target) return new int[] { lo, hi };6if (sum < target) lo++; // need a bigger sum7else hi--; // need a smaller sum8}9return new int[] { -1, -1 };10}
Live state
target
19
lo
0
hi
7
Execution trace (dry run table)Show 15 stepsHide
| Step | Line | State | What happens |
|---|---|---|---|
| 1 | L2 | target=19 lo=0 hi=7 | Start with the smallest and largest values. Every step rules out one value for good. |
| 2 | L5 | target=19 lo=0 hi=7 sum=18 | 18 is too small. arr[lo] plus even the largest remaining value is too small, so arr[lo] can never be part of an answer. |
| 3 | L6 | target=19 lo=1 hi=7 | lo = 1. |
| 4 | L5 | target=19 lo=1 hi=7 sum=20 | 20 is too large. arr[hi] plus even the smallest remaining value is too large, so arr[hi] is ruled out. |
| 5 | L7 | target=19 lo=1 hi=6 | hi = 6. |
| 6 | L5 | target=19 lo=1 hi=6 sum=17 | 17 is too small. arr[lo] plus even the largest remaining value is too small, so arr[lo] can never be part of an answer. |
| 7 | L6 | target=19 lo=2 hi=6 | lo = 2. |
| 8 | L5 | target=19 lo=2 hi=6 sum=18 | 18 is too small. arr[lo] plus even the largest remaining value is too small, so arr[lo] can never be part of an answer. |
| 9 | L6 | target=19 lo=3 hi=6 | lo = 3. |
| 10 | L5 | target=19 lo=3 hi=6 sum=20 | 20 is too large. arr[hi] plus even the smallest remaining value is too large, so arr[hi] is ruled out. |
| 11 | L7 | target=19 lo=3 hi=5 | hi = 5. |
| 12 | L5 | target=19 lo=3 hi=5 sum=17 | 17 is too small. arr[lo] plus even the largest remaining value is too small, so arr[lo] can never be part of an answer. |
| 13 | L6 | target=19 lo=4 hi=5 | lo = 4. |
| 14 | L5 | target=19 lo=4 hi=5 sum=19 | Found the pair. |
| 15 | L5 | target=19 return=[4, 5] | return [4, 5] after 7 checks. O(n) time, O(1) space. |
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.
1Random rnd = new Random(42);2for (int trial = 0; trial < 10_000; trial++) {3int[] arr = rnd.ints(rnd.nextInt(8) + 1, -5, 6).toArray();4int fast = maxSubarray(arr); // the O(n) solution under test5int slow = Integer.MIN_VALUE; // brute force: every subarray6for (int i = 0; i < arr.length; i++) {7int sum = 0;8for (int j = i; j < arr.length; j++) { sum += arr[j]; slow = Math.max(slow, sum); }9}10if (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.