🔥 荷兰国旗算法:三指针一次遍历搞定三色排序,看懂直呼“太优雅了”!
一句话总结:给定一个包含
0、1、2的数组,如何只遍历一次就完成排序?答案就是——荷兰国旗算法。它用三个指针协作,像交通警察一样指挥每个元素“各回各家”,效率拉满,堪称“原地排序”的教科书级操作。
如果你刷过 LeetCode,一定见过这道经典题:75. 颜色分类。题目要求对只含 0、1、2 的数组进行排序,但不能使用库函数或额外空间。
看似简单,实则暗藏玄机。今天我们就来深入剖析这个问题背后的“魔法”——荷兰国旗算法(Dutch National Flag Algorithm),并对比几种常见解法,看看为什么它是最优选择。
🎈 把问题想象成一场“气球整队派对”
假设你正在组织一场主题派对,现场有红、黄、蓝三种气球(分别代表数字 0、1、2),现在你要让它们按顺序站好队:
- 所有 红色气球(0) 排在最左边
- 所有 黄色气球(1) 居中站位
- 所有 蓝色气球(2) 站在最右边
而且你只能在原地调整位置(不能搬走再放回来),也不能数完再排(只能扫一遍)。怎么最快完成?
普通人可能会先清点数量再重填,但程序员追求的是——一次遍历,精准到位。
❌ 方法一:计数排序 —— “老实人干活法”
思路
先遍历一遍数组,统计出 0、1、2 各出现了多少次;
然后再从头开始,按照统计结果依次填入对应个数的 0 → 1 → 2。
代码实现
function sortColors(nums) {
const count = [0, 0, 0];
for (let num of nums) count[num]++;
let i = 0;
for (let val = 0; val <= 2; val++) {
while (count[val]-- > 0) {
nums[i++] = val;
}
}
}
优缺点分析
| ✅ 优点 | ❌ 缺点 |
|---|---|
| 实现简单,逻辑清晰 | 需要遍历两次数组 |
| 时间复杂度 O(n),空间 O(1) | 若元素是复杂对象(如带属性的对象),复制值成本高 |
⚠️ 注意:如果数组里存的是
{color: 0}这样的对象而不是原始数字,这种方法就得重新构造对象,性能大打折扣。
❌ 方法二:两步快排分区 —— “分而治之”的妥协版
思路
利用快速排序的思想做两次分区:
- 第一次:将所有
0移到左边,其余为1和2 - 第二次:在剩下的部分中,将
1移到左边,2自然归位
实现伪代码
partition(nums, 0); // 把0都放到前面
partition(nums, 1); // 在非0区域把1放到前面
优缺点
| ✅ 优点 | ❌ 缺点 |
|---|---|
| 原地操作,无需额外空间 | 仍需遍历近似两次 |
| 利用了快排思想,易于理解 | 不够“一步到位”,略显啰嗦 |
就像喝奶茶先舔盖再插吸管——能喝到,但总觉得多此一举 😅
✅ 正确答案:荷兰国旗算法 —— 三指针协同作战,一次搞定!
这个算法由计算机科学大师 Edsger Dijkstra 提出,名字来源于荷兰国旗的三横条设计(红白蓝三色水平排列),完美契合本题的三类元素分布。
核心思想:三个指针当“保安队长”,分工明确
我们定义三个指针,像小区门口的安保团队一样协同工作:
| 指针 | 名称 | 职责 |
|---|---|---|
left | 左护法 | 指向下一个 0 应该存放的位置,其左侧全是 0 |
right | 右护法 | 指向下一个 2 应该存放的位置,其右侧全是 2 |
cur | 巡逻兵 | 当前正在检查的元素位置,从左往右扫描 |
🎯 目标:让 cur 从左到右扫描整个数组,遇到不同数字就采取相应动作,直到越过 right。
🧩 四步走策略(关键逻辑)
while (cur <= right) {
switch(nums[cur]) {
case 0: /* 处理0 */
case 1: /* 处理1 */
case 2: /* 处理2 */
}
}
✅ 情况一:当前是 0
👉 应该放在最左边!
✔️ 操作:与 left 交换,然后 left++、cur++
💡 解释:left 地盘扩大,且换过来的一定是已处理过的(因为是从左往右来的),所以 cur 可以前进。
✅ 情况二:当前是 1
👉 中间派,无需移动!
✔️ 操作:cur++ 即可
💡 解释:1 就应该待在中间区域,放行即可。
✅ 情况三:当前是 2
👉 必须扔到最右边!
✔️ 操作:与 right 交换,right--,但 cur 不动
⚠️ 关键点:换过来的元素还没检查过!可能是 0 或 1,必须让 cur 再判断一次。
💡 动画式理解(建议脑补)
初始状态:
[2, 0, 2, 1, 1, 0]
↑ ↑ ↑
cur ... right
left
过程示意(简化):
cur=0是2→ 与right交换 →[0, 0, 2, 1, 1, 2],right--- 新
cur=0是0→ 与left交换 →[0, 0, 2, 1, 1, 2],left++,cur++ cur=1是0→ 与left交换 →[0, 0, 2, 1, 1, 2],继续前进cur=2是2→ 与right交换 →[0, 0, 1, 1, 2, 2],right--- ……持续下去,直到
cur > right
最终结果:
[0, 0, 1, 1, 2, 2] ✅ 完美排序!
✅ 最终代码实现(JavaScript)
/**
* @param {number[]} nums
* @return {void} Do not return anything, modify nums in-place instead.
*/
var sortColors = function(nums) {
let left = 0, // 0 区域的右边界
right = nums.length - 1, // 2 区域的左边界
cur = 0; // 当前遍历位置
while (cur <= right) {
if (nums[cur] === 0) {
[nums[cur], nums[left]] = [nums[left], nums[cur]];
left++;
cur++; // 可以前进,因为 left 来的方向都是处理过的
} else if (nums[cur] === 2) {
[nums[cur], nums[right]] = [nums[right], nums[cur]];
right--; // 不能 ++cur,因为换过来的数未被检查
} else {
cur++; // 是 1,直接跳过
}
}
};
📊 三种方法横向对比
| 方法 | 时间复杂度 | 遍历次数 | 是否原地 | 适用场景 |
|---|---|---|---|---|
| 计数排序 | O(n) | 2次 | 是 | 元素范围小、允许重复赋值 |
| 两步分区 | O(n) | ~2次 | 是 | 理解快排思想的过渡方案 |
| 荷兰国旗算法 | O(n) | 仅1次 | 是 | 推荐解法,高效且通用性强 |
✅ 特别优势:即使数组中存储的是复杂对象(如
ColorBall{ type: 0 }),也只需交换引用,无需深拷贝!
🧠 算法背后的哲学:抓主要矛盾,让元素“自我归位”
荷兰国旗算法之所以优雅,在于它抓住了问题的本质:
我们知道只有三种固定值,且大小关系明确(0 < 1 < 2)
因此不需要通过比较来排序,而是可以直接根据值决定去向——这就是“桶排序思维”与“双指针技巧”的完美结合。
这种思想在很多地方都有体现:
- 状态机处理(比如订单状态流转)
- 分类问题(成绩等级 A/B/C)
- 快速排序中的三路快排(Quicksort 3-way partitioning)
💬 编程启示录:当你面对有限种类、有序关系的问题时,不妨想想:“能不能用几个‘哨兵’指针,让每个元素自己找到组织?”
🏁 总结:为什么你应该掌握这个算法?
| 亮点 | 说明 |
|---|---|
| ⏱️ 极致效率 | 仅一次遍历,O(n) 时间,常数级空间 |
| 🧩 思维巧妙 | 三指针协同,边界控制精准 |
| 🔄 原地操作 | 不依赖额外数组,适合大数据场景 |
| 🛠️ 可拓展性强 | 是三路快排的基础,也是分布式排序的重要组件 |
📚 延伸阅读 & 推荐练习
- LeetCode 经典题:
- 75. 颜色分类
- 88. 合并两个有序数组(类似双指针思想)
- 215. 数组中的第K个最大元素(三路快排应用)
🎯 最后送大家一句话:
编程之美,不在代码长短,而在思路是否清晰。
有时候,解决问题的关键不是更强的算法,而是更聪明的分工。
就像荷兰国旗算法里的三个指针——各司其职,默契配合,一次遍历,天下太平。