荷兰国旗问题三指针法详解:一次遍历原地排序的核心原理与C++实现

📅 2026/7/20 22:09:55
荷兰国旗问题三指针法详解:一次遍历原地排序的核心原理与C++实现
1. 项目概述从一道经典OJ题说起最近在带新人刷算法题发现“荷兰国旗问题”这道题出现的频率相当高无论是在各大高校的OJ平台还是在求职面试的算法轮它都是一个绕不开的经典。题目本身描述很简单给你一个只包含0、1、2三种元素的整数数组要求你在不使用库函数排序的情况下原地将它们按0、1、2的顺序排列好。这听起来不就是排序吗但它的魅力在于它要求你用一种特定的、高效且一次遍历的方法来解决这就是“三指针法”。很多朋友第一次接触时要么是暴力排序后交差要么是写个计数排序虽然能过但总感觉没抓住题目的精髓面试官追问“能不能一次遍历完成”时就卡壳了。今天我就结合自己当年踩过的坑和后来反复琢磨的心得用C来彻底拆解这道题不仅告诉你代码怎么写更要讲清楚指针每一步移动背后的逻辑以及那些容易写错的边界条件。2. 问题本质与核心思路拆解2.1 问题重述与约束分析荷兰国旗问题Dutch National Flag Problem是由计算机科学家Edsger Dijkstra提出的。它的经典描述是有一个数组里面只有红、白、蓝三种颜色的球分别对应数值0 1 2我们的任务是将这些球排列成红、白、蓝的顺序。这里的核心约束有几个第一必须原地排序不能使用额外的数组空间复杂度要求是O(1)第二我们希望尽可能只遍历数组一次时间复杂度达到O(n)第三对于每个数值排序后它们的相对顺序不一定需要保持稳定即非稳定排序。理解这些约束很重要它直接决定了我们算法的设计方向。如果允许使用额外空间最简单的办法就是计数排序遍历一遍数出0、1、2各有几个然后第二遍遍历直接按顺序写回数组。这确实简单也满足O(n)时间但它遍历了两次并且没有挑战性。面试官出这道题显然是想考察你对指针操作和区间划分的理解深度。2.2 三指针法思想精髓三指针法是解决这个问题的标准答案它的核心思想是将数组划分为四个区间并通过三个指针来维护这些区间的边界。这四个区间分别是[0, zero)这个区间内的所有元素都是0红色。[zero, i)这个区间内的所有元素都是1白色。[i, two)这个区间是待处理的、未分类的区间。[two, n)这个区间内的所有元素都是2蓝色。初始化时我们设zero 0i 0two n数组长度。这意味着0区间为空zero指向0区间的下一个待插入位置。1区间为空i也指向1区间的下一个待插入位置且与zero重合。待处理区间是整个数组[0, n)。2区间为空two指向2区间的第一个位置从末尾开始。整个算法的过程就是指针i从左向右扫描待处理区间根据nums[i]的值将其交换到正确的区间并相应地移动zero或two指针同时缩小待处理区间i和two之间的范围直到i与two相遇表示所有元素都已归位。注意这里i指针的移动需要特别小心。只有当nums[i]被换过来的是1或者它本来就是1时i才向前移动。如果换过来的是0或2i不能动因为新换到i位置的元素还没有被检查和处理。3. 核心细节解析与实操要点3.1 指针的初始位置与循环条件指针的初始位置设定是算法的基石理解错了后面全乱。zero指针指向的是下一个0应该被放置的位置初始时数组前面没有0所以它从0开始。i指针是扫描指针它指向当前正在检查的元素同时也标志着1区间的末尾开区间所以它也从头开始。two指针指向的是下一个2应该被放置的位置的前一个位置更直白地说two指向的是2区间的左边界闭区间初始时数组末尾没有2所以它从n开始即数组末尾的下一个位置。这样一来待处理区间就是[i, two)是一个左闭右开区间。循环的条件是while (i two)。为什么不是i two或者i n因为当i two时意味着待处理区间已经为空[i, two)是一个无效区间所有元素都已经处理完毕。如果继续循环就会访问到已经归到2区间的元素导致错误交换。3.2 三种情况的处理逻辑与交换操作在每一轮循环中我们只关注nums[i]的值Case 1:nums[i] 1这是最简单的情况。1就应该待在1区间而i指针本身正好就是1区间的右边界。所以我们不需要交换直接让i指针向右移动一位i相当于把当前这个1纳入1区间同时待处理区间头部向后缩进一位。Case 2:nums[i] 00应该被放到0区间。0区间由zero指针维护。此时我们将nums[i]与nums[zero]交换。交换前zero位置是什么它要么是1区间的第一个元素值为1要么就是i自己当zero i时。交换后nums[zero]位置变成了0这个0就归位了。因此0区间需要向右扩大一位所以zero。关键来了交换后i位置的新元素是什么它是从zero位置换过来的而这个位置原本是已经处理好的1区间的元素或者是i本身即值0。如果是1那么它属于Case 1我们下一轮循环会处理它如果是0即zero和i相等时交换那么它自己就是0但此时zero已经这个0实际上已经被归入0区间了i指针需要移动吗仔细看我们的逻辑我们交换后i位置的新元素是“从已处理区间换过来的已知值”。为了保证逻辑统一和代码简洁一种常见的写法是在nums[i]0时交换后直接i。但这是有风险的。更严谨的做法是只有在确定i位置的新元素是1或者是0且zero移动后i已经指向0区间之后时才移动i。实际上由于交换后zero位置原来的值只可能是1或0且zero i所以交换后i位置的值不可能是未处理的2。因此在交换并zero之后我们可以安全地i。这是很多参考代码的做法也是正确的。Case 3:nums[i] 22应该被放到2区间。2区间由two指针维护从右向左。此时我们将nums[i]与nums[--two]交换。注意这里是--two先让two指针向左移动一位指向2区间左边的第一个空位或者说待处理区间的末尾然后交换。交换后nums[two]位置变成了2这个2就归位了。但是i指针绝对不能移动因为从two位置原待处理区间末尾换到i位置的元素是一个尚未被检查过的元素它的值可能是0、1、2中的任何一种。我们必须在下一次循环中检查这个新换过来的元素。因此在nums[i]2的情况下只进行two--不进行i。3.3 边界条件与循环不变式理解“循环不变式”是写出正确算法的关键。在本算法中循环不变式就是在while (i two)循环开始前和每一轮迭代结束后以下三个条件始终成立子数组[0, zero)全是0。子数组[zero, i)全是1。子数组[two, n)全是2。子数组[i, two)是待处理的、包含混合元素的区域。我们的所有操作都必须维护这个不变式。最容易出错的地方就是在处理nums[i]2时错误地移动了i。如果你在交换后也i那么从末尾换过来的那个未处理元素就被跳过了如果它恰好是0那么这个0就可能被遗留在最终数组的中间位置导致排序错误。你可以用一个极端的例子[2, 0, 1]来测试如果交换2后i就会出错。4. 完整C实现与逐行解读下面给出完整的C实现代码并附上详细的注释。#include vector #include utility // for std::swap class Solution { public: void sortColors(std::vectorint nums) { int n nums.size(); // 初始化三个指针 // zero: 指向下一个0应该存放的位置[0, zero)区间全是0 // i: 当前检查的元素位置也是1区间的右边界开区间[zero, i)区间全是1 // two: 指向2区间的左边界闭区间[two, n)区间全是2 int zero 0, i 0, two n; // 当待处理区间[i, two)不为空时继续循环 while (i two) { if (nums[i] 0) { // 情况1当前元素是0应该放到0区间 // 交换nums[i]和nums[zero] std::swap(nums[i], nums[zero]); // 0区间向右扩大一位 zero; // 交换后i位置的新元素来自原zero位置。 // 原zero位置只可能是1来自1区间或0i与zero重合。 // 无论是哪种这个新元素都已经在正确的逻辑位置或将在下一轮被正确处理。 // 因此i可以安全地向右移动。 i; } else if (nums[i] 1) { // 情况2当前元素是1应该放到1区间 // 1就在它应该在的位置i本身就在1区间的边界直接纳入1区间即可 // i向右移动1区间扩大 i; } else { // nums[i] 2 // 情况3当前元素是2应该放到2区间 // 先将two指针左移指向2区间的前一个空位 two--; // 交换nums[i]和nums[two] std::swap(nums[i], nums[two]); // 关键交换后i位置的新元素是原nums[two]这是一个未被检查的元素。 // 因此i指针在此轮不能移动需要在下一轮循环中检查这个新元素。 // 所以这里没有 i } } // 循环结束[i, two)区间为空所有元素已归位 } };逐行解读与心路历程int zero 0, i 0, two n;这是算法的初始化状态。想象整个数组是一个待处理的流水线i是扫描头zero在开头准备收0two在末尾准备收2。一开始0区和1区为空2区也为空整个流水线都是待处理品。while (i two)循环的驱动力。只要扫描头i还没有碰到2区的左边界two就说明中间还有没处理完的东西。这个条件保证了不会把已经归到右边的2又换到左边来。if (nums[i] 0)分支当我看到当前物品是红色0时我的动作是把它和zero位置的物品交换。zero位置可能是白色1或者就是它自己如果zero和i指向同一个说明0区间和待处理区间头挨着头。交换后红色球放到了最前面红色区域的末尾完美。然后zero和i都往前挪一格。这里i是安全的因为换过来的球我知道它是白色1或者红色0白色球下一轮会被直接放过红色球自己换自己现在的位置已经在zero-1属于红色区域了i指向它的下一个也没问题。else if (nums[i] 1)分支最简单看到白色球1不用动直接让它通过i把它划入白色区域就行。else分支最需要小心的部分。看到蓝色球2我的目标是把扔到末尾的蓝色区域。所以我先让two往左挪一格给蓝色球腾个位置two--然后把当前这个蓝色球和那个腾出来的位置上的球交换。交换完后我当前i位置上的球是刚从队伍末尾拿过来的一个“未知球”。我根本不知道它是红是白是蓝所以我绝对不能移动i我必须停在原地在下一轮循环里重新检查这个新来的“未知球”。这就是整个算法最精髓也最容易出错的地方。很多新手在这里顺手写个i程序在某些测试用例下就悄无声息地错了。循环结束当i和two相遇意味着“待处理流水线”已经清空。所有红色球都在左边白色球在中间蓝色球在右边任务完成。5. 测试用例设计与调试技巧5.1 必须覆盖的测试用例写完代码不能盲目提交一定要用多种情况测试。以下是我总结的必测用例覆盖了各种边界和特殊情况// 测试函数 void test() { Solution sol; vectorvectorint testCases { {}, // 空数组 {0}, // 单元素0 {1}, // 单元素1 {2}, // 单元素2 {2, 0, 1}, // 容易出错的顺序测试交换2后i不动的逻辑 {2, 2, 2, 2}, // 全2 {0, 0, 0, 0}, // 全0 {1, 1, 1, 1}, // 全1 {0, 1, 2}, // 标准升序 {2, 1, 0}, // 标准降序 {0, 2, 2, 1, 0, 1, 2, 0}, // 随机混合 {1, 0, 2, 1, 0, 2, 1, 0}, // 另一种随机混合 }; for (auto nums : testCases) { vectorint original nums; // 备份原数组 sol.sortColors(nums); // 简单验证检查是否非递减对于0,1,2排序后就是0全部在前接着1最后2 bool passed true; int state 0; // 期望状态0-1-2 for (int num : nums) { if (num 0) { if (state 0) passed false; } else if (num 1) { if (state 1) passed false; if (state 1) state 1; } else { // num 2 if (state 2) state 2; } } if (!passed) { cout Failed on input: ; for (int n : original) cout n ; cout \nOutput: ; for (int n : nums) cout n ; cout endl; } else { cout Passed. endl; } } }特别要关注{2, 0, 1}这个用例。如果你的代码在处理nums[i]2后错误地增加了i那么这个用例的执行过程会是初始:[2,0,1],zero0, i0, two3nums[0]2: 交换nums[0]和nums[2](two先减为2)数组变为[1,0,2],two2。如果此时错误地执行了i则i1。下一轮循环i1,nums[1]0: 交换nums[1]和nums[zero](即nums[0])数组变为[0,1,2],zero1,i2。循环条件i(2) two(2)为假退出。最终结果[0,1,2]看起来对了但这是巧合。让我们跟踪i的移动那个从末尾换过来的1原nums[2]在交换到位置0后因为i已经所以它被跳过了检查。在这个例子中它恰好是1所以没问题。但如果把数组改成{2, 0, 0}错误就会暴露初始:[2,0,0]第一步交换2后错误地i[0,0,2],i1,two2处理nums[1]0交换nums[1]和nums[0][0,0,2],zero1,i2循环结束结果[0,0,2]正确。等等这个也对了再换一个{2, 1, 0}初始:[2,1,0]第一步交换2后错误地i[0,1,2],i1,two2处理nums[1]1ii2循环结束结果[0,1,2]正确。看来这个错误很隐蔽我们需要一个更特异的例子{2, 0, 2, 1, 0}。初始:[2,0,2,1,0],zero0, i0, two5i0, nums[0]2: 交换nums[0]和nums[4][0,0,2,1,2],two4。错误地ii1。i1, nums[1]0: 交换nums[1]和nums[zero](即nums[0]) [0,0,2,1,2],zero1,i2。i2, nums[2]2: 交换nums[2]和nums[3](two先减为3) [0,0,1,2,2],two3。错误地ii3。循环条件i(3) two(3)为假退出。最终结果[0,0,1,2,2]正确天哪这个bug太难抓了。实际上只有在交换2时从后面换过来的元素是0并且这个0在后续没有被正确处理时才会出错。一个能暴露错误的例子是{2, 0, 1, 0}你可以自己模拟一下错误代码的过程会发现最终的0没有全部到前面。所以严格遵循“交换2后i不动”的规则是绝对必要的这是算法正确性的保证。5.2 调试与可视化技巧对于算法新手我强烈建议使用“纸上模拟”或“打印日志”的方法来理解指针变化。你可以在循环里加入打印语句while (i two) { cout i i , zero zero , two two | ; for (int num : nums) cout num ; cout endl; // ... 原有的if-else逻辑 ... }对于输入{2,0,2,1,0}正确的执行日志应该是i0, zero0, two5 | 2 0 2 1 0 i0, zero0, two4 | 0 0 2 1 2 (交换nums[0]和nums[4]two--i不动) i0, zero0, two4 | 0 0 2 1 2 (nums[i]0交换nums[0]和nums[0]zero, i) i1, zero1, two4 | 0 0 2 1 2 (nums[i]0交换nums[1]和nums[1]zero, i) i2, zero2, two4 | 0 0 2 1 2 (nums[i]2交换nums[2]和nums[3]two--i不动) i2, zero2, two3 | 0 0 1 2 2 (nums[i]1i)通过日志你可以清晰地看到每一步指针和数组状态的变化尤其是i在交换2后暂停的那一步这对于理解算法至关重要。6. 常见问题与排查技巧实录在实际编码和面试中围绕荷兰国旗问题三指针法有几个高频出现的疑问和易错点。6.1 为什么是while (i two)而不是while (i two)这是一个区间表示的理解问题。我们定义待处理区间是[i, two)左闭右开。当i two时区间[i, i)是空的没有元素需要处理循环应该终止。如果写成i two那么当i two时循环还会进入此时访问nums[i]可能会越界如果two初始为n那么i n时访问nums[n]是越界的或者更糟糕的是如果two在过程中被减小i two可能指向一个已经被归到2区间的元素导致错误的交换。所以严格的小于号是维护区间定义和避免越界的关键。6.2 处理nums[i] 0时zero有可能大于i吗在整个算法执行过程中zero指针始终满足zero i。为什么呢zero指针只有在遇到0时才可能增加而i指针是扫描指针它一步步向右移动。考虑两种情况当nums[i]是0时我们交换后zero且i两者保持同步或zero可能因为连续的0而暂时领先但i会立刻追上因为也i了。当nums[i]是1时只有izero不动所以i会大于zero。当nums[i]是2时i不动zero也不动。因此zero永远不可能跑到i的右边去。这个不变量保证了我们交换nums[i]和nums[zero]时zero索引总是有效的并且它指向的位置要么是1区间的开头值为1要么就是i自己值为0或1。6.3 算法的时间复杂度和空间复杂度是多少时间复杂度O(n)。尽管代码里有一个循环但指针i和two共同遍历了整个数组的每个元素恰好一次。每个元素最多被交换两次例如一个0可能先从后面被换到前面再被换到更前面。所以总体操作次数与数组长度 n 成线性关系。空间复杂度O(1)。我们只使用了固定的几个整型变量zero,i,two没有使用任何与输入规模 n 相关的额外数据结构。这完美符合了“原地排序”的要求。6.4 这个算法是稳定排序吗不是稳定排序。稳定排序要求相等元素的相对位置在排序后保持不变。考虑数组[0₁, 1, 0₂]其中下标只是为了区分两个0。三指针法的一种可能过程是遇到第一个0自己和自己交换或无操作zero和i后移。遇到1i后移。遇到第二个0此时zero指向1的位置交换nums[2](0₂)和nums[1](1)数组变为[0₁, 0₂, 1]。可以看到0₂ 被移动到了 0₁ 的后面但它们的相对顺序改变了如果严格按原始下标0₁ 应该在 0₂ 前面。所以这个算法不保证稳定性。题目通常没有稳定性要求如果有则需要采用其他方法。6.5 如果数组元素不是0,1,2而是其他三个值算法如何修改算法本身不关心具体的值它只关心有三种类别。你可以把if (nums[i] 0) 1 2的条件判断换成对应类别的判断即可。例如如果是要将数组按“负数”、“零”、“正数”分类你可以定义if (nums[i] 0)对应0的处理if (nums[i] 0)对应1的处理else对应2的处理。算法的框架和指针移动逻辑完全不变。6.6 一个容易忽略的“坑”数组访问越界在交换操作时特别是std::swap(nums[i], nums[--two])我们必须确保--two后的索引是有效的即two 0。在我们的循环条件while (i two)和操作顺序下这是有保证的。因为当i接近two时如果nums[i]是2我们会先执行two--。由于i two所以two至少比i大1two--之后的新two至少等于i因此nums[two]是有效的可能是i本身或者是i右边的元素。绝对不会出现two被减到小于0的情况。但如果你在实现时不小心把--two写在了循环末尾或其他地方就需要警惕索引有效性。7. 举一反三三指针法的变体与应用掌握了经典的荷兰国旗问题你会发现三指针或双指针划分的思想在解决一类“数组分区”问题时非常有用。它本质是一种快速排序中的三路划分算法。在快速排序中我们通常选择一个基准pivot将数组划分为小于、等于、大于基准的三部分然后递归排序小于和大于的部分。这里的荷兰国旗排序就是快速排序三路划分的一次特例基准值有多个0, 1, 2但实际上是三个固定的值。你可以尝试用这个思想解决LeetCode上的这类问题75. 颜色分类这就是本文讨论的荷兰国旗问题。215. 数组中的第K个最大元素在快速选择算法中可以利用三路划分来提高效率尤其是当数组中有大量重复元素时。283. 移动零可以看作是荷兰国旗问题的简化版只有0和非0两类用双指针一个扫描指针一个非零元素存放指针就能解决思路同源。905. 按奇偶排序数组将数组按奇偶性分成两部分也是双指针/分区思想的体现。解决这类问题的核心就是定义清楚每个指针的含义和它们维护的区间然后在扫描过程中根据当前元素的值通过交换将其归位到正确的区间并谨慎地移动指针。多练习几次你就能对这类问题形成肌肉记忆。最后我个人的一点体会是算法学习不能只停留在“AC”Accept通过的层面。像荷兰国旗问题死记硬背代码也能过但遇到变体或者面试官深入追问时就露怯了。一定要把指针移动的每一个“为什么”都想明白自己构造几个边缘用例在纸上或者调试器里走一遍流程直到你能像讲故事一样把算法的执行过程讲清楚。这才是真正内化了一个算法以后遇到类似的划分问题你就能很快地抽象出模型写出bug-free的代码。