荷兰国旗算法:三指针一次遍历搞定三色排序,看懂直呼“太优雅了”!

109 阅读6分钟

🔥 荷兰国旗算法:三指针一次遍历搞定三色排序,看懂直呼“太优雅了”!

一句话总结:给定一个包含 012 的数组,如何只遍历一次就完成排序?答案就是——荷兰国旗算法。它用三个指针协作,像交通警察一样指挥每个元素“各回各家”,效率拉满,堪称“原地排序”的教科书级操作。

如果你刷过 LeetCode,一定见过这道经典题:75. 颜色分类。题目要求对只含 012 的数组进行排序,但不能使用库函数或额外空间。

看似简单,实则暗藏玄机。今天我们就来深入剖析这个问题背后的“魔法”——荷兰国旗算法(Dutch National Flag Algorithm),并对比几种常见解法,看看为什么它是最优选择。


🎈 把问题想象成一场“气球整队派对”

假设你正在组织一场主题派对,现场有红、黄、蓝三种气球(分别代表数字 012),现在你要让它们按顺序站好队:

  • 所有 红色气球(0) 排在最左边
  • 所有 黄色气球(1) 居中站位
  • 所有 蓝色气球(2) 站在最右边

而且你只能在原地调整位置(不能搬走再放回来),也不能数完再排(只能扫一遍)。怎么最快完成?

普通人可能会先清点数量再重填,但程序员追求的是——一次遍历,精准到位


❌ 方法一:计数排序 —— “老实人干活法”

思路

先遍历一遍数组,统计出 012 各出现了多少次;
然后再从头开始,按照统计结果依次填入对应个数的 012

代码实现

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} 这样的对象而不是原始数字,这种方法就得重新构造对象,性能大打折扣。


❌ 方法二:两步快排分区 —— “分而治之”的妥协版

思路

利用快速排序的思想做两次分区:

  1. 第一次:将所有 0 移到左边,其余为 12
  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 不动
⚠️ 关键点:换过来的元素还没检查过!可能是 01,必须让 cur 再判断一次。


💡 动画式理解(建议脑补)

初始状态:

[2, 0, 2, 1, 1, 0]
 ↑        ↑        ↑
cur      ...      right
         left

过程示意(简化):

  1. cur=02 → 与 right 交换 → [0, 0, 2, 1, 1, 2]right--
  2. cur=00 → 与 left 交换 → [0, 0, 2, 1, 1, 2]left++, cur++
  3. cur=10 → 与 left 交换 → [0, 0, 2, 1, 1, 2],继续前进
  4. cur=22 → 与 right 交换 → [0, 0, 1, 1, 2, 2]right--
  5. ……持续下去,直到 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) 时间,常数级空间
🧩 思维巧妙三指针协同,边界控制精准
🔄 原地操作不依赖额外数组,适合大数据场景
🛠️ 可拓展性强是三路快排的基础,也是分布式排序的重要组件

📚 延伸阅读 & 推荐练习


🎯 最后送大家一句话
编程之美,不在代码长短,而在思路是否清晰。
有时候,解决问题的关键不是更强的算法,而是更聪明的分工。
就像荷兰国旗算法里的三个指针——各司其职,默契配合,一次遍历,天下太平