Files

347 lines
12 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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`,但在实际工程中应加防御性检查