Dutch National Flag Problem
Origin: Dijkstra's Problem
In 1976, Edsger W. Dijkstra posed a deceptively simple question: given an array of elements that each belong to one of three categories, can you sort them in a single pass using constant extra memory?
He named it after the Dutch national flag, which has three horizontal bands of red, white, and blue. The task is to rearrange an array of 0s, 1s, and 2s so that all 0s (red) come first, then all 1s (white), then all 2s (blue).
This became one of the most elegant examples of 3-way partitioning. The technique underpins quicksort optimizations (when many duplicate keys exist), database query processing, and appears in coding interviews as LeetCode 75: Sort Colors.
The Problem Statement
Given an array nums containing only values 0, 1, and 2, sort it in-place so that all 0s are at the front, all 1s in the middle, and all 2s at the end. You must do this in a single pass with O(1) extra space.
Output: [0, 0, 1, 1, 2, 2]
This is equivalent to partitioning around a pivot value of 1: elements less than 1 go left, equal to 1 stay in the middle, greater than 1 go right.
The Three-Pointer Approach
Dijkstra's solution uses three pointers to maintain four regions of the array as we scan through it:
low(green): the boundary between the 0-region and the 1-region. Everything beforelowis a confirmed 0.mid(orange): the current element being examined. Everything betweenlowandmidis a confirmed 1.high(purple): the boundary between the 1-region and the 2-region. Everything afterhighis a confirmed 2.
The algorithm processes elements at mid and decides what to do based on the value:
If nums[mid] == 0: swap(nums[low], nums[mid]), low++, mid++
If nums[mid] == 1: mid++
If nums[mid] == 2: swap(nums[mid], nums[high]), high−1
Notice: when we swap with high, we do not advance mid. The element swapped from high is unexamined, so mid must inspect it in the next iteration.
The Loop Invariant
At every iteration, the array satisfies these four conditions simultaneously:
nums[0..low-1] contains only 0s (red)Region 2:
nums[low..mid-1] contains only 1s (white)Region 3:
nums[mid..high] is the unknown region (not yet classified)Region 4:
nums[high+1..n-1] contains only 2s (blue)
When mid crosses high, the unknown region is empty and the entire array is sorted. This invariant is what makes the algorithm correct: each operation shrinks the unknown region by at least one element while preserving the other regions.
Step-Through Animation
Walk through the algorithm on the array [2, 0, 2, 1, 1, 0]. Press Step to advance one operation at a time, or Reset to start over. Watch the three pointers and colored cells update as elements are swapped into position.
Detailed Walkthrough
Let us trace through [2, 0, 2, 1, 1, 0] step by step. Initial state: low = 0, mid = 0, high = 5.
nums[mid] = nums[0] = 2. Since it is 2, swap nums[0] with nums[5] and decrement high.Array becomes
[0, 0, 2, 1, 1, 2]. Pointers: low=0, mid=0, high=4.We did not advance
mid because the swapped-in element (0) still needs inspection.
nums[mid] = nums[0] = 0. Since it is 0, swap nums[0] with nums[0] (self-swap), increment both low and mid.Array stays
[0, 0, 2, 1, 1, 2]. Pointers: low=1, mid=1, high=4.
nums[mid] = nums[1] = 0. It is 0 again, so swap nums[1] with nums[1] (self-swap), increment both.Array stays
[0, 0, 2, 1, 1, 2]. Pointers: low=2, mid=2, high=4.
nums[mid] = nums[2] = 2. It is 2, swap nums[2] with nums[4], decrement high.Array becomes
[0, 0, 1, 1, 2, 2]. Pointers: low=2, mid=2, high=3.
nums[mid] = nums[2] = 1. It is 1, just advance mid.Array stays
[0, 0, 1, 1, 2, 2]. Pointers: low=2, mid=3, high=3.
nums[mid] = nums[3] = 1. It is 1, advance mid.Array stays
[0, 0, 1, 1, 2, 2]. Pointers: low=2, mid=4, high=3.Now
mid > high, so the loop terminates. The array is sorted.
C++ Implementation
The implementation is remarkably compact. The entire logic fits in a single while loop with three branches:
#include <vector>
#include <algorithm> // std::swap
void sortColors(std::vector<int>& nums) {
int low = 0, mid = 0, high = nums.size() - 1;
while (mid <= high) {
if (nums[mid] == 0) {
std::swap(nums[low], nums[mid]);
++low;
++mid;
} else if (nums[mid] == 1) {
++mid;
} else { // nums[mid] == 2
std::swap(nums[mid], nums[high]);
--high;
}
}
}
// Example usage
int main() {
std::vector<int> nums = {2, 0, 2, 1, 1, 0};
sortColors(nums);
// nums is now {0, 0, 1, 1, 2, 2}
return 0;
}
Complexity Analysis
Each iteration of the loop either advances mid forward or moves high backward (or both via the 0-case). The unknown region [mid..high] shrinks by at least 1 each time. Since this region starts at size n and ends at 0, the total number of iterations is at most n.
| Metric | Value | Reasoning |
|---|---|---|
| Time | O(n) | Single pass through the array |
| Space | O(1) | Only three integer pointers |
| Swaps | ≤ n | At most one swap per iteration |
| Comparisons | ≤ n | One comparison per iteration |
Compared to a counting sort (two passes, O(n) time, O(1) space), Dijkstra's approach matches the complexity but does it in a single pass. More importantly, it generalizes to objects with satellite data where counting and overwriting is not possible.
Why Not Advance Mid on a 2-Swap?
This is the most common source of bugs. When nums[mid] == 0 and we swap with low, the value that arrives at mid from the low position has already been examined (it was in the confirmed 1-region [low..mid-1]). So it is safe to advance mid.
But when nums[mid] == 2 and we swap with high, the value that arrives at mid from the high position is from the unknown region. It could be 0, 1, or 2. We must examine it before moving on. Forgetting this causes elements to land in the wrong region.
Correctness Proof Sketch
We prove correctness by showing the loop invariant holds at initialization, is preserved by each branch, and implies the postcondition at termination.
- Initialization:
low = mid = 0,high = n-1. The 0-region and 1-region are empty (vacuously correct). The 2-region is empty. The entire array is the unknown region. - Case nums[mid] == 0: After swapping
nums[low]andnums[mid], element atlowis 0 (extends the 0-region). The element previously atlowwas in[low..mid-1](a confirmed 1), so it is safe at the newmid-1position. Both pointers advance, shrinking the unknown region. - Case nums[mid] == 1: The element is already in the correct region. Advance
midto grow the 1-region. - Case nums[mid] == 2: After swapping with
nums[high], the element athighis 2 (extends the 2-region). Decrementhighto shrink the unknown region.midstays because the newly arrived element is unexamined. - Termination:
mid > highmeans the unknown region[mid..high]is empty. The invariant guarantees all 0s are at the front, 1s in the middle, and 2s at the end.
Common Pitfalls
- Advancing mid after a 2-swap: As explained above, the swapped-in element from
highis unexamined. Skipping it corrupts the sort. - Off-by-one in the loop condition: The condition must be
mid <= high, notmid < high. Whenmid == high, there is still one unexamined element. - Forgetting to handle empty arrays: If
numsis empty,high = -1and the loop does not execute. This is correct, but be careful with unsigned types. - Using the wrong initial values:
lowandmidmust both start at 0, andhighatn-1. Startinghighatnorlowat-1shifts all regions by one.
Extension: 4-Way Partition
The 3-way partition generalizes naturally. Suppose you have four categories (0, 1, 2, 3). You can use four pointers: a, b, c, and d. The idea is the same: maintain sorted regions at the boundaries and an unsorted middle that shrinks each step.
The approach chains two 3-way partitions, or uses a direct 4-pointer scheme:
void fourWayPartition(std::vector<int>& nums) {
int a = 0, b = 0, c = 0;
int d = nums.size() - 1;
while (c <= d) {
if (nums[c] == 0) {
std::swap(nums[c], nums[b]);
std::swap(nums[b], nums[a]);
++a; ++b; ++c;
} else if (nums[c] == 1) {
std::swap(nums[c], nums[b]);
++b; ++c;
} else if (nums[c] == 2) {
++c;
} else { // nums[c] == 3
std::swap(nums[c], nums[d]);
--d;
}
}
}
The invariant now maintains five regions instead of four. The complexity remains O(n) time and O(1) space. This pattern is useful when partitioning data into more than three buckets, for example in multi-pivot quicksort or radix-based sorting.
| Partition Type | Pointers | Categories | Passes |
|---|---|---|---|
| 2-way (standard quicksort) | 2 | 2 | 1 |
| 3-way (Dutch Flag) | 3 | 3 | 1 |
| 4-way | 4 | 4 | 1 |
| k-way (general) | k | k | 1 (with care) |
Related Problems
The Dutch National Flag pattern appears in many forms across competitive programming and interviews:
- LeetCode 75 (Sort Colors): The direct application. Given an array of 0, 1, 2 values, sort in-place.
- Partition around a pivot: Given a pivot value, rearrange so elements less than pivot come first, equal in the middle, greater at the end. This is the core of 3-way quicksort.
- Segregate 0s and 1s: A simplified 2-way version. Use just two pointers.
- Move all negatives to the front: Treat negatives as 0 and non-negatives as 1. Same 2-pointer pattern.
- Three-way string partition: Partition strings by their first character into three groups. Same algorithm, different comparator.
Key Takeaways
- The Dutch National Flag is a 3-way partition solved with three pointers in a single O(n) pass.
- The loop invariant is the heart of the algorithm: four regions with clear boundaries.
- Do not advance
midafter swapping withhigh; the swapped-in element is unexamined. - Generalizes to k-way partitions by adding more pointers.
- Appears in quicksort optimization, database query processing, and many interview problems.