Files

347 lines
12 KiB
Markdown
Raw Permalink Normal View History

2026-05-17 09:38:35 +08:00
---
tags: [技巧, 双指针, 三向切分, 荷兰国旗, 原地排序, 数组]
create time: 2026-05-16 18:30
---
# 98-颜色分类
## 题面
> **LeetCode 75. Sort Colors**
给定一个包含红色、白色和蓝色、共 `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]
```
**提示:**
- `n == nums.length`
- `1 <= n <= 300`
- `nums[i]` 为 `0`、`1` 或 `2`
---
## 思路
### 先思考一个问题 🤔
这个有一个经典的名字——**荷兰国旗问题(Dutch National Flag Problem)**,由 Edsger Dijkstra 提出:如何用最少交换次数把红白蓝三色球排成"红-白-蓝"的顺序?
> [!tip] 直觉方案
> 统计 0、1、2 各自的个数,然后再按个数写回去——这需要两遍扫描。题目要求**一趟扫描**,怎么做?
>
> 排序算法如快排也能做到 $O(n \log n)$,但我们想要更好的——$O(n)$ 时间 + $O(1)$ 空间。
### 核心洞察:三路分区 ⭐
既然只有三种值 `{0, 1, 2}`,我们可以用**三个指针**把数组划分成三个区域:
| 区域 | 位置 | 值 | 含义 |
|------|------|-----|------|
| **左区 `[0, low)`** | 数组左侧 | `0` | 已经排好位的红色 |
| **中区 `[low, mid)`** | 数组中间 | `1` | 已经排好位的白色 |
| **未处理 `[mid, high]`** | 数组右侧 | 待确定 | 还没见过的元素 |
| **右区 `(high, n-1]`** | 数组最右侧 | `2` | 已经排好位的蓝色 |
> [!question] 启发式提问
> 如果只有一个指针从左走到右,遇到 `0` 只能放左边,遇到 `2` 只能放右边。但只靠一个指针做不到同时维护两个边界——**为什么需要三个指针?**
>
> 因为你需要同时知道"下一个该填 0 的位置在哪"和"下一个该填 2 的位置在哪"。这两个位置分别从两端向中间逼近。
### 关键性质:中区的 invariant(不变量)
> [!info] 不变的真理
> 在每一步操作中,`[low, mid)` 区间内的所有元素都是 `1`。这就是说:**凡是已经被确认是 1 的元素,永远不会再被触碰**。这让算法在一趟扫描中就能完成排序。
### 算法流程
```
初始化:
low = 0 → 指向下一个 0 应该放置的位置
mid = 0 → 当前正在检查的元素
high = n - 1 → 指向下一个 2 应该放置的位置
当 mid <= high 时循环:
情况 A: nums[mid] == 0
将 nums[mid] 与 nums[low] 交换
low++(左区扩大)
mid++(当前位置已确定为 0,继续向前)
情况 B: nums[mid] == 1
mid++(1 就在中区合适的位置,不动它,mid 直接前进)
情况 C: nums[mid] == 2
将 nums[mid] 与 nums[high] 交换
high--(右区扩大)
mid 不移动! ← 关键点
因为从 high 换过来的元素还没有被检查过
```
> [!warning] ⚠️ 最容易出错的地方
>
> 当 `nums[mid] == 2` 执行交换后,**mid 不能自增**。因为从 `high` 位置换过来的元素从未被检查过,需要在下一轮迭代中重新判断。
>
> 反之,当 `nums[mid] == 0` 执行交换后,`mid` **可以**自增——因为在交换之前 `low <= mid`,而 `nums[low]` 位置的元素要么是 `1`(在中区内),要么 `low == mid` 本身就是同一个位置。无论哪种情况,交换到 `mid` 位置的都是 `1` 或者自身,这两种都已经确认正确。
### 逐步跟踪演示
以 `nums = [2, 0, 2, 1, 1, 0]` 为例:
```
初始状态:
索引: [0] [1] [2] [3] [4] [5]
nums: [2, 0, 2, 1, 1, 0]
↑ ↑ ↑
low mid high
第1步: nums[mid]=2 → 与 nums[high] 交换
索引: [0] [1] [2] [3] [4] [5]
nums: [0, 0, 2, 1, 1, 2]
↑ ↑ ↑
low mid high
(mid 不移动,重新检查 nums[mid]=0)
第2步: nums[mid]=0 → 与 nums[low] 交换
索引: [0] [1] [2] [3] [4] [5]
nums: [0, 0, 2, 1, 1, 2]
↑ ↑ ↑
low mid high
(low++, mid++)
第3步: nums[mid]=2 → 与 nums[high] 交换
索引: [0] [1] [2] [3] [4] [5]
nums: [0, 0, 1, 1, 2, 2]
↑ ↑ ↑
low mid high
(mid 不移动)
第4步: nums[mid]=1 → mid++
索引: [0] [1] [2] [3] [4] [5]
nums: [0, 0, 1, 1, 2, 2]
↑ ↑ ↑
low mid high
第5步: nums[mid]=1 → mid++,此时 mid > high,退出循环 ✅
```
结果:`[0, 0, 1, 1, 2, 2]` —— 排序完成!
### 流程图
```mermaid
flowchart TD
Start(["开始<br/>low=0 mid=0 high=n-1"]) --> Loop{"mid <= high?"}
Loop -- 否 --> Done(["返回排序结果"])
Loop -- 是 --> Check{"值等于多少?"}
Check -- "0" --> Swap0["交换 nums[low] 与 nums[mid]"]
Swap0 --> IncBoth["low++ mid++"]
IncBoth --> Loop
Check -- "1" --> IncMid["mid++"]
IncMid --> Loop
Check -- "2" --> Swap2["交换 nums[mid] 与 nums[high]"]
Swap2 --> DecHigh["high--<br/>mid 不变"]
DecHigh --> Loop
style Start fill:#e3f2fd
style Done fill:#c8e6c9
style Swap0 fill:#fff3e0
style Swap2 fill:#fff3e0
```
### 备选方案对比
| 方法 | 时间复杂度 | 空间复杂度 | 说明 |
|------|----------|----------|------|
| **三指针分区(荷兰国旗)** ⭐ | $O(n)$ | $O(1)$ | 最优解,一趟扫描 |
| 两趟计数排序 | $O(n)$ | $O(1)$ | 统计 0/1/2 个数后回填,简单但不符合进阶要求 |
| 快速排序的三向分区 | $O(n \log n)$ 平均 | $O(\log n)$ | 通用排序,对本题来说杀鸡用牛刀 |
---
## 代码提示
> [!abstract] 📝 Go 伪代码框架
```
low, mid, high := 0, 0, len(nums)-1
for mid <= high:
如果 nums[mid] == 0:
交换 nums[low], nums[mid]
low++
mid++
否则如果 nums[mid] == 1:
mid++
否则 (nums[mid] == 2):
交换 nums[mid], nums[high]
high--
// mid 不变
```
> [!step] 实现要点
1. **循环条件是 `mid <= high`**,不是 `<`,因为等于时还需要处理最后一个元素
2. **mid 不移动的分支最容易忘**——交换完 `high` 之后必须留在原地检查新换过来的元素
3. **Go 中没有原地 swap 运算符**,需要用临时变量:`nums[low], nums[mid] = nums[mid], nums[low]`
---
## 技巧
> [!summary] 🔑 核心模式记忆
```go
// 三指针三路分区模板
low, mid, high := 0, 0, len(nums)-1
for mid <= high {
switch nums[mid] {
case 0:
nums[low], nums[mid] = nums[mid], nums[low]
low++
mid++
case 1:
mid++
case 2:
nums[mid], nums[high] = nums[high], nums[mid]
high--
}
}
```
> 看到「只有 k 种离散值的原地排序」→ 优先考虑**三路/多路分区**思想。k=3 时就是经典的荷兰国旗问题。
### 1. 荷兰国旗的思想本质
> [!quote] 💬 一句话总结
>
> **"小的放左边,大的放右边,中的自己站好"** ——用三个指针同时维护三个有序区域,中间的指针负责"侦察"。
这是一种**分区而非比较**的思维:不需要拿每个元素去跟其他元素比较大小,而是根据值域直接定位目标区域。
### 2. 为什么 mid 在情况 C 中不能移动?
> [!note] 深入理解
>
> 这是一个面试中几乎必问的问题。原因如下:
>
> `mid` 指向的是当前尚未检查的元素。当它与 `high` 交换后,`mid` 位置上得到的是来自 `high` 位置的元素——这个元素可能是 `0`、`1` 或 `2`,**从未被检查过**。如果此时 mid 自增就会漏掉这个元素,导致错误。
>
> 反过来看,在情况 A 中,`low <= mid`。交换前 `nums[low]` 是中区的起始位置(值为 `1`)或者 `low == mid`(自身交换)。所以交换到 `mid` 上的一定是 `1` 或自身,两种都已确认正确,mid 可以放心前进。
### 3. 与快速排序三向分区的联系
> [!info] 知识串联
>
> 三路分区也是快速排序优化版本的核心——当数组中存在大量重复元素时,标准快排会退化到 $O(n^2)$,而**三向快速排序**将数组分为 `< pivot`、`= pivot`、`> pivot` 三个区间,天然处理重复元素。
>
> 本题相当于三向分区的一个特例:pivot 的范围已知是 `{0, 1, 2}`,所以一轮扫描即可全部分清。
### 4. 关联变体题
| 题目 | 变化点 | 核心思路 |
|------|--------|---------|
| **75. Sort Colors** | 三种离散值排序 | 三指针荷兰国旗 ✅ |
| **912. Sort an Array** | 通用数组排序 | 归并排序 / 堆排序 / 三向快排 |
| **三向快排(快排优化)** | 含大量重复元素 | Dutch Partitioning 作为 partition 步骤 |
### 5. Go 运行细节
> [!example] 🐹 Go 特有注意事项
- Go 原生支持**并行赋值**,一行即可完成 swap:`a, b = b, a`,无需临时变量
- 当数组长度为 1 时,`low == mid == high`,循环进入一次后退出,逻辑正确
- `switch` 语句在 Go 中比多个 `if-else` 更清晰,也稍快(编译器生成跳转表)
---
## 代码
```go
// sortColors sorts an array of 0s, 1s, and 2s in-place using
// the Dutch National Flag three-pointer approach.
// All 0s come first, then all 1s, then all 2s.
func sortColors(nums []int) {
// ── Step 1: 初始化三个指针 ──
// low: 下一个 0 应该放置的位置(左区右边界)
// mid: 当前正在检查的元素(侦察指针)
// high: 下一个 2 应该放置的位置(右区左边界)
low, mid, high := 0, 0, len(nums)-1
// ── Step 2: 一趟扫描分区 ──
for mid <= high {
switch nums[mid] {
case 0:
// 遇到 0:交换到左区
// 交换后 mid 位置的元素已确认为 1 或自身,mid 可以前进
nums[low], nums[mid] = nums[mid], nums[low]
low++
mid++
case 1:
// 遇到 1:留在中区,mid 直接前进
mid++
case 2:
// 遇到 2:交换到右区
// 注意 mid 不能前进!因为从 high 换过来的元素还未被检查
nums[mid], nums[high] = nums[high], nums[mid]
high--
}
}
// ── Step 3: 隐式结束 ──
// 当 mid > high 时,整个数组已被划分为 [0]*[1]*[2]* 三段
}
```
### 代码走读
> [!success] ✅ 复杂度总结
| 指标 | 结果 | 说明 |
|------|------|------|
| **时间复杂度** | $O(n)$ | 每个元素最多被访问常数次(`mid++` 走 n 步;每次交换至少消耗一个 `low` 或 `high`,最多 n 次交换) |
| **空间复杂度** | $O(1)$ | 仅使用了三个整型变量 `low`、`mid`、`high` |
完美满足一趟扫描 + 常数空间的进阶要求!
> [!quote] 💬 面试建议
这道题是考察**双指针/分区思想**的经典题。面试中被问到后:
1. **先确认约束条件**——只有三种值、原地排序、一趟扫描最优
2. **引出荷兰国旗问题**——说出名字会给面试官留下好印象
3. **画示意图解释三路分区**——标出 low/mid/high 三个指针的职责和不变量
4. **解释 mid 在不同情况下的行为差异**——这是展示你思考深度的地方
5. **主动分析时间复杂度**——证明每个元素只被常数次访问
> [!warning] ⚠️ 常见陷阱
> 1. **mid 在交换 `high` 后忘记停留**——最常见的 bug,会导致漏检元素
> 2. **循环条件写成 `mid < high`**——等于时仍需处理最后一个元素
> 3. **误以为 `low++` 放在 `case 2` 中也正确**——`low` 只对应 `0` 的位置,与 `2` 无关
> 4. **忽略空数组边界**——虽然题目保证了 `n >= 1`,但在实际工程中应加防御性检查