☰
LeetCode热题100:颜色分类三指针原地排序与荷兰国旗算法
2026/10/15 4:42:31 网站建设 项目流程

面试刷题列表里,`LeetCode热题100(颜色分类)》这道题几乎是绕不开的存在。第一眼看上去就是个“把 0、1、2 排好序”的简单数组题,很多人三分钟就能写出计数排序,然后觉得已经结束了。但真正被面试官追问时才意识到,这道题考察的是你能不能在不借助额外数组、只扫描一遍的情况下,用几个指针把数组归位。别看它题目短,里面埋着 partition 的思想、指针不变量的管理、边界条件的处理,几乎是高频题里的“性价比之王”。这篇就把我复习这道题的完整思路、代码细节和踩坑记录原原本本理一遍,给准备算法面试的朋友和刷题遇到瓶颈的同学做个参考。

1. 题目拆解与面试考察点

1.1 原题描述与输入输出

题目本身非常简短:给定一个包含红色、白色、蓝色,一共 n 个元素的数组,对它们进行原地排序,使得相同颜色的元素相邻,并按照红色、白色、蓝色顺序排列。这里用整数 0、1、2 分别表示红色、白色、蓝色。

一个标准的示例是这样:

  • 输入:nums = [2,0,2,1,1,0]
  • 输出:[0,0,1,1,2,2]

要求里明确说了两件事:不能使用库函数sort,并且只能在原数组上操作。也就是说,我们不能图省事调一个Arrays.sort,也不能新建一个数组把结果装进去再复制回来。

这里有个容易忽略的地方:数组元素虽然只有 0、1、2 三种取值,但数组长度可能很大,比如 10 的 5 次方甚至更多。这意味着算法必须在线性时间内完成,空间上最好是常量级。这个约束看着简单,实际已经把很多“常规排序思路”卡掉了。

1.2 面试官真正想考什么

我在准备这道题的时候,一度以为“能写出正确答案”就够了。后来和一位朋友做模拟面试,他在五分钟内写出了计数排序,我问他“如果面试官要求你只遍历一遍数组呢?”他才意识到自己漏掉了一层。

这道题放在热题 100 里,并不是为了考你会不会“排序”,而是为了考你对数组指针的控制能力。具体来说,面试官想看这几件事:

第一,你能不能识别出“只有三种取值”这个特殊条件,从而放弃通用排序算法,想到用计数、分区这类线性做法。第二,你能不能在不新建数组的前提下,通过交换元素完成原地排序。第三,你能不能设计出只扫描一遍的算法,并且把每个指针移动的时机说清楚。最后,边界条件会不会漏,比如数组全为 0、全是 2、或者长度为 1。

这些能力在真实项目中同样重要。比如处理日志级别、状态机流转、将一组任务按优先级重新分组,都和这种“用一个指针维护边界”的思想相关。所以这道题不是在整花活,它背后是很实用的 partition 技巧。

2. 从暴力到优雅:三种解法的演进

2.1 最直观的计数排序解法

提到只有 0、1、2 三种值,很多人的第一反应是:遍历一遍统计三个数字出现的次数,再按顺序把数组重写一遍。这确实是最容易想到的解法,代码也短。

def sort_colors(nums): counts = [0] * 3 for num in nums: counts[num] += 1 idx = 0 for color in range(3): for _ in range(counts[color]): nums[idx] = color idx += 1

这个方法的时间复杂度是 O(n),因为遍历数组统计需要 O(n),重写数组也需要 O(n),合在一起还是 O(n)。额外空间是counts这个长度为 3 的数组,严格说是 O(1),因为不管数组多大,计数器只有三个。

所以在“空间复杂度 O(1)、时间复杂度 O(n)”这两个硬性指标上,计数排序其实是合规的。但它有一个特性:它对数组进行了两次遍历。第一次用于统计,第二次用于重写。

如果你够敏锐,会发现一个可以继续优化的点:既然元素只有三类,我们是否能在一次遍历的过程中,同时完成“把 0 挪到最左边、把 2 挪到最右边”这件事?这就是双指针和三指针解法的出发点。我在实际面试场景中通常建议先把计数排序讲出来,因为它容易被验证,代码风险低,可以作为保底方案。如果面试官追问“能不能只遍历一次”,再引出下面的解法。

2.2 双指针交换法(两趟版)

先聊一个过渡方案:用两个指针,一趟把 0 全部放到前面,另一趟把 2 全部放到后面。因为数组只有三种值,把 0 和 2 都处理好之后,中间剩下来的自然就是 1。

def sort_colors(nums): left = 0 for i in range(len(nums)): if nums[i] == 0: nums[i], nums[left] = nums[left], nums[i] left += 1 right = len(nums) - 1 for i in range(len(nums) - 1, -1, -1): if nums[i] == 2: nums[i], nums[right] = nums[right], nums[i] right -= 1

这个解法的正确性很好理解:第一趟从左往右扫,只要碰到 0,就和 left 位置交换,并把 left 往后移。这保证 left 左边的元素全部是 0。第二趟从右往左扫,碰到 2 就和 right 位置交换,保证 right 右边的元素全部是 2。中间剩下的必然是 1。

它仍然是 O(n) 时间、O(1) 额外空间,但比计数排序更进一步,因为它是通过交换来原地修改的,没有再“重写数组”。不过它的问题也很明显:需要扫描两遍。在某些场景下,两趟并不是大问题,因为常数倍的时间复杂度依然是 O(n),但是很多面试官会继续追问:能不能一趟完成?

这个追问其实就是在导向接下来要说的三指针解法。

2.3 三指针荷兰国旗算法

颜色分类这道题之所以被很多人记住,是因为它是经典的“荷兰国旗问题”。荷兰国旗由三条色带组成,而我们的数组恰好也只有三种颜色,所以可以把数组分成三段:左边是 0,中间是 1,右边是 2。

三指针解法的核心是维护三个区域:

  • 区间[0, p0):全部是 0
  • 区间[p0, curr):全部是 1
  • 区间(p2, n-1]:全部是 2
  • 区间[curr, p2]:待处理区域

我们用一个当前指针curr从左往右扫描,用p0维护“0 区域的右边界”,用p2维护“2 区域的左边界”。初始时p0 = 0,curr = 0,p2 = n - 1。

循环不变量是:只要curr <= p2,就继续处理。遇到nums[curr]分三种情况:

  • 等于 0:和nums[p0]交换,然后p0++,curr++
  • 等于 1:不做交换,直接curr++
  • 等于 2:和nums[p2]交换,然后p2--,但curr不动

这种做法的精妙之处在于,所有元素最多被访问一次,交换只发生在数组内部,所以时间和空间都极其高效。

3. 为什么荷兰国旗算法是本题的最优解

3.1 复杂度分析的一个误区

很多人看到“排序”两个字,第一反应是快排或归并,默认时间复杂度 O(n log n)。但这道题的约束是元素只有 0、1、2 三种值,普通比较排序的“信息论下界”并不适用。我们可以利用取值种类有限这个特性,把复杂度压到 O(n)。

荷兰国旗算法一次扫描完成排序,每个元素最多参与一次交换。curr只会从 0 走到p2,而p2只可能向左移动,所以整体操作次数是线性的。空间上只有三个变量,是真正的 O(1) 额外空间。

这里要强调一个容易被忽视的误区:有些同学认为“计数排序已经 O(n) 了,为什么还要费劲写三指针”。答案是:从复杂度的数量级上看,两者确实都是 O(n),但常数项和“是否原地”不同。计数排序虽然也是常量级辅助空间,但它的第二次遍历是在“重写”数组,不是“交换”。如果你把一个数组元素理解成不可覆盖的对象,那么计数排序就需要额外考虑“能否安全覆写”的问题;而交换法不会丢掉任何信息。

另外,在一些面试场景中,“只扫描一遍”不仅仅是为了炫技,它代表一种思维习惯。真实项目里数据可能来自实时流,我们无法把数据全部缓存下来统计再重写,只能边读边调整。这时候一次遍历能做多少事,直接决定了方案的可行性。

3.2 原地算法与稳定性

原地算法的意思是,除了输入数组本身,我们只使用常数级别的额外空间。荷兰国旗算法的交换操作完全在原数组上进行,符合“原地”要求。

有一个细节值得想清楚:这个算法是不是稳定的?答案是否定的。因为在进行连续多次交换后,相同值的元素相对顺序可能被打乱。比如两个 1 分布在不同的位置,它们可能永远不会相互交换,但 1 与 0、2 交换时,1 的原始次序可能会变化。

那为什么题目不要求稳定?因为题目元素本身只是整数 0、1、2,相等的数字无法区分,稳定性没有意义。但如果把题目改造成“按优先级排序对象”,稳定性就可能变得重要了。我见过一个改造:数组里存储的是对象,每个对象有一个 color 字段,希望排序后相同 color 的对象还能保持原来的相对顺序。这个时候荷兰国旗算法就不适用,得换用归并排序或稳定计数排序的思路。

所以在面试时,如果面试官问“这个算法稳定吗”,不要只说“不稳定”,还要补充一句“本题中相等的数字没有区分价值,所以不稳定不影响结果;但如果对象还有其他属性,需要重新评估”。

4. 手写代码的边界处理与细节

4.1 核心代码实现(Python 与 C++)

先给出最经典的 Python 实现:

def sort_colors(nums): p0, curr, p2 = 0, 0, len(nums) - 1 while curr <= p2: if nums[curr] == 0: nums[p0], nums[curr] = nums[curr], nums[p0] p0 += 1 curr += 1 elif nums[curr] == 2: nums[p2], nums[curr] = nums[curr], nums[p2] p2 -= 1 else: curr += 1

这个写法里最容易记错的地方是:遇到 2 交换之后curr不要自增。原因很简单:从p2位置换过来的元素我们还没看过,它可能是 0、1、2 中的任意一个。如果直接curr++,就可能跳过一个还没处理的 0 或 2,导致排序结果错误。

同理,遇到 0 交换之后curr可以自增,这背后的理由需要多花一点时间理解。因为在循环进行过程中,p0和curr之间的区域全部是 1,所以p0位置的值也一定是 1。当nums[curr] == 0时,把nums[p0]这个 1 换过来,curr新位置上是 1,不需要再处理,所以可以直接前进。

C++ 版本也不复杂:

void sortColors(vector<int>& nums) { int p0 = 0, curr = 0, p2 = nums.size() - 1; while (curr <= p2) { if (nums[curr] == 0) { swap(nums[p0], nums[curr]); p0++; curr++; } else if (nums[curr] == 2) { swap(nums[p2], nums[curr]); p2--; } else { curr++; } } }

注意在 C++ 里,我故意把swap和指针自增分成了两行。有些简洁写法会写成swap(nums[p0++], nums[curr++]),虽然很多编译器能正常处理,但在参数求值顺序上容易引起争议,也降低可读性。面试手写代码时,清晰比“一行写完”更重要。

4.2 常见的指针移动陷阱

三指针算法的代码量很少,但指针移动的逻辑很容易错。我总结了一个口诀:遇到 0 交换后大步走,遇到 1 原地走,遇到 2 交换后回头等。

这里的“回头等”就是指curr不前进,因为要把换过来的元素重新判断一遍。很多人死记硬背“遇到 2 交换后不移动 curr”,却没有理解为什么,结果一到数组长度只有 2 时就开始乱。

举个例子:nums = [2, 0, 1]。

  • 初始:p0=0,curr=0,p2=2
  • 第 1 步:nums[0] = 2,交换 nums[0] 和 nums[2],数组变为 [1, 0, 2],p2=1,curr 仍为 0
  • 第 2 步:nums[0] = 1,curr 变为 1
  • 第 3 步:nums[1] = 0,交换 nums[0] 和 nums[1],数组变为 [0, 1, 2],p0=1,curr=2
  • 循环结束,排序正确

如果第二步“遇到 2 后让 curr 前进”,数组会变成什么样?交换后 curr=1,然后 nums[1] = 0,交换 nums[1] 和 nums[0],结果还是 [0, 1, 2]。看起来碰巧也能对,但换一个例子[2, 0, 2, 1, 1, 0],错误就会暴露出来。因此最好还是严格按照不变量来分析。

另一个陷阱是循环终止条件。有些同学会写while (curr < nums.length),这会导致访问越过p2,把已经排到右边的 2 又拿回来处理,出现死循环或错误交换。必须写成while (curr <= p2),因为当curr超过p2时,待处理区间已经空了。

5. 常见错误与调试实录

5.1 死循环与跳过元素的现场

有一次我帮模拟面试的 A 同学调试这道题,他写的代码在遇到一连串 2 的时候会直接爆掉。他的版本是这样的:

def sort_colors(nums): p0, curr, p2 = 0, 0, len(nums) - 1 while curr <= p2: if nums[curr] == 0: nums[p0], nums[curr] = nums[curr], nums[p0] p0 += 1 curr += 1 elif nums[curr] == 2: nums[p2], nums[curr] = nums[curr], nums[p2] p2 -= 1 curr += 1 # 错误点 else: curr += 1

他加了curr += 1,然后对[2, 0, 2, 1, 1, 0]手动模拟到第 4 步,发现结果变成[0, 1, 1, 0, 2, 2],中间混进了一个 0。问题就出在:当nums[curr]是 2 时,从右边换过来的元素可能是 0,需要继续检查,如果直接跳过它,就会有 0 留在中间区域。

调试这类问题最好的方式不是打印日志,而是在纸上模拟一个长度 6 的数组,把p0、curr、p2三个指针标出来,每执行一次交换就更新一次。模拟两遍之后,你会对“为什么curr不动”产生直觉。

5.2 边界条件排查速查表

我把容易出错的测试场景整理成了一个速查表,每次写完代码都会对着过一遍:

测试用例预期结果容易出错的地方
[][]空数组时 p2 为 -1,while 条件要正确处理
[0][0]单个元素时不能发生越界交换
[2][2]单个元素且等于 2 时,交换后 p2 小于 curr
[0,0,0][0,0,0]全 0 时 p0 和 curr 同步前进
[1,1,1][1,1,1]全 1 时只做 curr 自增
[2,2,2][2,2,2]全 2 时每次交换后 curr 不动,p2 不断左移
[2,0,1][0,1,2]连续交换时是否跳过了中间的 0
[1,0,2][0,1,2]交换 0 后 curr 是否能继续前进
[2,1,0][0,1,2]最坏情况,三个值都出现在逆序位置

其中[2,1,0]这个用例特别能检验代码。初始时 curr 指向 0,数字是 2,需要不断把 2 换到末尾,然后等下一个位置的数字。如果curr过早前进,整个数组会变得很混乱。

5.3 使用计数排序时的隐蔽问题

很多人在写计数排序时反而会踩一个隐蔽的坑:重写数组时的索引更新方式。常见写法如下:

def sort_colors(nums): c0 = c1 = c2 = 0 for num in nums: if num == 0: c0 += 1 elif num == 1: c1 += 1 else: c2 += 1 nums[:c0] = [0] * c0 nums[c0:c0 + c1] = [1] * c1 nums[c0 + c1:] = [2] * c2

利用 Python 的切片赋值,看起来简洁,但它会创建新的临时列表,和“原地操作”的精神有所偏离。如果面试环境允许切片,这当然没问题,但如果面试官强调“就地”,最好用循环赋值,或直接展示三指针写法。另外,切片赋值的边界容易算错,比如在nums[c0:c0 + c1]里,很多人会把 c1 误写成 c2,导致结果错误。

其实计数排序还有一种原地双指针写法:先扫描一遍统计个数,然后用idx顺序填充。这个写法不需要创建临时列表,也更符合“原地”的精神。但不管怎么写,计数排序始终需要两趟扫描,这是它的结构性特征。

6. 面试现场指南与后续扩展

6.1 如何在面试中展示思路

这道题在面试中的最佳展开节奏,我认为是“分层递进”。

拿到题之后,不要急着写代码。先和面试官确认题目的边界:元素只有 0、1、2 三种吗?必须原地吗?数组可以为空吗?这些确认过程本身就在展示你的工程思维。然后给出第一个可行方案:计数排序,说明复杂度 O(n) + O(1)。接着主动提出“这个方案扫描了两遍,能不能扫描一遍完成”,顺势引出三指针思路。

在写代码时,边写边解释指针的含义。不要只是默写,最好说出每一行的作用,尤其是遇到 2 交换后curr不前进的理由。写完之后,主动说“我拿一个例子手动跑一遍”,然后选一个不太对称的用例,比如[2, 0, 2, 1, 1, 0]。手动模拟时,把三个指针的位置标清楚,让面试官看到你对不变量有把握。

最后做复杂度分析:时间 O(n),空间 O(1),一趟遍历。如果面试官追问稳定性和扩展性,再展开聊三向切分和快排的关系。这套流程走下来,基本上能把“会做”变成“会讲”。

6.2 同类题型的扩展

颜色分类学完之后最大的收获,其实是“用多个指针切分数组”的思维方式。很多高频题都长在同一棵树上。

最经典的是“移动零”。给定数组[0,1,0,3,12],把 0 移到末尾,同时保持非零元素相对顺序。这就是颜色分类的二元版本,只需要维护一个指针,把所有非零元素按顺序放到前面,剩余位置填 0。

另一个例子是“按奇偶排序数组”,要求把偶数放在前面,奇数放在后面。这也是一次双指针扫描就可以解决的问题。还有快速排序中的三向切分,专门处理大量重复元素。标准快排遇到元素全部相同的时候会退化成 O(n^2),但三向切分可以把等于基准值的元素集中放到中间,只对小于和大于的部分递归,复杂度能恢复到接近 O(n)。

如果你能把颜色分类吃透,再去看这些题目会特别快。因为它们都在做同一件事:用有限的指针维护若干个有序分区。

以我个人的经验来说,颜色分类这道题最好的学习方式不是背代码,而是把循环不变量写在注释里,然后对照着手动模拟三遍。第一遍用正序用例,第二遍用全相同的用例,第三遍用逆序用例。三遍下来,你就不会再把“遇到 2 后 curr 不动”记错了。面试前如果时间有限,我甚至只复习这一题作为数组双指针的代表,因为它已经涵盖了移动零、三向切分和边界处理的核心套路。最后再给一个小建议:遇到网络上的各种“简单写法”时,先确认它是否清楚地把 p0、curr、p2 三个指针的语义讲明白。能讲明白的写法才是好写法,讲不明白的简写往往是坑。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询