Hot 100 --- 颜色分类
本文概览:本文讲解颜色分类(荷兰国旗问题):只有 0、1、2 三个值,0 要排到最前面、2 要排到最后面,中间剩下的自然是 1;方法一是统计个数后重写,方法二用左右两个指针各守一端,一边遍历一边把 0 往左扔、2 往右扔,只需一遍扫描
一、题目

二、题目分析
1. 题目要求
给定一个包含红色、白色和蓝色、共 n 个元素的数组 nums,原地对它们进行排序,使得相同颜色的元素相邻,并按照红色、白色、蓝色顺序排列。
用整数 0、1 和 2 分别表示红色、白色和蓝色。必须在不使用库内置的 sort 函数的情况下解决这个问题。
示例 1:nums = [2, 0, 2, 1, 1, 0] → [0, 0, 1, 1, 2, 2]
示例 2:nums = [2, 0, 1] → [0, 1, 2]
2. 怎么想这题?
题目说得很死:数组里只有 0、1、2 三种值,最后要排成"所有 0 在前面、所有 1 在中间、所有 2 在后面"。
最直接的做法是先数再填:扫一遍统计出 0、1、2 各有多少个,然后照着个数把数组从头到尾重写一遍。思路没问题,但要扫两遍数组。
还能不能再省?注意这里只有三个值,而且规矩特别简单:0 的归宿是最前面,2 的归宿是最后面,1 夹在中间——也就是说,只要把 0 和 2 都送对了地方,剩下的位置自然全是 1,压根不用管。
那就可以在遍历的过程中"边看边归位":遇到 0 就往数组头部扔,遇到 2 就往数组尾部扔。既然要往两端扔,就得有两个指针分别记住"头部扔到哪了、尾部扔到哪了"。这就是双指针的思路,一遍扫描就能排好。
3. 需要解决哪几个问题?
问题一:两个指针各自指向哪里、分别代表什么含义?
问题二:遇到 0 或 2 时需要交换,交换之后两个指针和遍历下标分别怎么移动?
问题三(最容易错的细节):为什么遇到 0 时遍历下标要往后走一格,遇到 2 时却要停在原地再看一次?
三、方法一:统计个数后重写,两遍遍历
1. 思路概览
1 | public void sortColors(int[] nums) { |
思路简要说明:
- 第一遍统计:数出 0、1、2 各有多少个
- 第二遍填充:按 0、1、2 的个数依次覆盖回数组
- **时间复杂度 O(n)**,空间 O(1)(只用三个计数器)
- 缺点:要扫两遍数组
2. 思路详解
这个方法的逻辑和"桶排序"是一回事:既然知道了每种值该占多少格,直接按格子填就行了。
以 [2, 0, 2, 1, 1, 0] 为例:
1 | 第一遍数完:count0 = 2, count1 = 2, count2 = 2 |
简单可靠,代价是要遍历两遍。既然一趟就能解决,就值得往下想。
3. 复杂度分析
**时间复杂度 O(n)**:两遍线性扫描。
**空间复杂度 O(1)**:三个计数器。
四、能不能一遍扫完?
回顾一下刚才那个观察:0 的归宿是数组最左边,2 的归宿是最右边,而 1 只要站在中间就行。
既然两个"极端值"各有明确的目的地,那就可以一边遍历一边送它们回家:
- 遇到
0,把它和当前最左边还没确定的位置交换——这样 0 就落到该去的地方,那个位置可以划进"0 区"了; - 遇到
2,把它和当前最右边还没确定的位置交换——2 就落到尾部,那个位置划进"2 区"; - 遇到
1,先别动它,让它在原地等着(等 0 和 2 都归位,它自然就落在中间了)。
于是需要两个指针,一个盯左、一个盯右,分别记录"0 区"和"2 区"已经推进到哪儿。下面是具体怎么落地。
五、方法二:双指针三路分区,一遍遍历
1. 思路概览
1 | public void sortColors(int[] nums) { |
思路简要说明:
- **
left**:[0, left)这一段已经全是 0,left指向 0 区的下一个空位 - **
right**:(right, n-1]这一段已经全是 2,right指向 2 区的上一个空位 - **
i**:当前正在考察的位置,它前面的[left, i)全是 1 - **循环条件
i <= right**:[i, right]才是还没处理的区间,i越过right就说明处理完了 - **时间复杂度 O(n)**,空间 O(1)
2. 思路详解
第一步:两个指针 + 一个遍历下标,各自管什么?
把整个数组看成四段:
1 | [ 0 区 ) [ 1 区 ) [ 待处理区 ] ( 2 区 ] |
[0, left):已排好的 0,全是 0;[left, i):已排好的 1;[i, right]:还没看过的部分;(right, n-1]:已排好的 2。
每处理完一个元素,就把它塞进对应的段里,待处理区随之缩小。等 i > right,待处理区空了,整段数组就排好了。
第二步:三种情况分别怎么处理?
情况一:nums[i] == 0。 它该去最前面。把它和 nums[left] 交换——left 那个位置正是 0 区的下一个空位,换过去正好落位。然后 left++(0 区往前扩一格)、i++(这一位已经处理完了,往后看下一个)。
情况二:nums[i] == 2。 它该去最后面。把它和 nums[right] 交换——right 是 2 区的上一个空位,换过去落位。然后 right--(2 区往前扩一格)。
情况三:nums[i] == 1。 1 不用挪,让它待在原地即可,直接 i++ 往后看。等 0 和 2 各自归位,这些留在中间的 1 自然就聚成了中间的 1 区。
第三步:为什么 0 要 i++,2 却不 i++?
这是这个方法唯一值得琢磨的地方,关键看换过来的那个数是谁。
遇到 0 时,和 nums[left] 交换。而 left 永远落在 i 的左边(left ≤ i),它左边那段 [0, left) 全是 0、[left, i) 全是 1——所以 nums[left] 位置上待着的一定是一个 1(当 left < i 时),而这个 1 早就被考察过了(它属于已排好的 1 区)。换过来之后 nums[i] 变成了 1,这个 1 本来就该待在中间,不用再管,所以 i++ 可以直接往后走。
遇到 2 时,和 nums[right] 交换。而 right 永远在 i 的右边,它属于还没考察过的待处理区。也就是说,nums[right] 可能是 2、可能是 1、也可能是 0——换到 nums[i] 上的这个新值谁也不知道是什么。所以这一位必须留在原地重新考察一遍,此时 i 不能动。
一句话概括:0 换过来的是"已经看过的 1",放心往前走;2 换过来的是"从没见过的数",必须停下再看一眼。
第四步:完整执行过程
以示例 1,nums = [2, 0, 2, 1, 1, 0] 为例。初始 left = 0, right = 5, i = 0:
1 | 初始: [2, 0, 2, 1, 1, 0] left=0 right=5 i=0 |
再看示例 2,nums = [2, 0, 1]:
1 | 初始: [2, 0, 1] left=0 right=2 i=0 |
可以看到 i=0 那一步:换过来的 1 还没被考察过,所以停在原地看了一下才发现它是 1,才往后走——这正是"2 不 i++"的必要性。
3. 复杂度分析
**时间复杂度 O(n)**:每个元素最多被处理常数次,i 一路向右、right 一路向左,合起来推进 O(n) 步。
**空间复杂度 O(1)**:只有三个下标变量,原地交换。
六、总结
| 方法 | 遍历次数 | 时间 | 空间 | 关键点 |
|---|---|---|---|---|
| 统计个数后重写 | 两遍 | O(n) | O(1) | 先数再填,简单但要多扫一遍 |
| 双指针三路分区 | 一遍 | O(n) | O(1) | 0 往左扔、2 往右扔,1 留在中间 |
这题的灵魂在于发现"只有三个值,而且两个极端值各有明确归宿":
- 0 该在最左、2 该在最右,所以用左右两个指针各守住一端;
- 剩下的 1 不用管,它会被自然地夹在中间。
真正容易写错的是移动下标的时机:遇到 0 交换后 i++,因为换回来的一定是已经看过的 1;遇到 2 交换后 i 不动,因为从右边换回来的是一个从没考察过的数,必须重新判断。把这一点想通了,这个双指针就是标准的"荷兰国旗问题"解法(三路分区),只扫一遍、只用常数空间。






