每日一算:颜色分类
题目描述
给定一个包含红色(用 0 表示)、白色(用 1 表示)和蓝色(用 2 表示)共 n
个元素的数组 nums
,要求原地排序,使相同颜色的元素相邻,且按 “红→白→蓝” 的顺序排列。需在不使用库内置 sort
函数的前提下解决,进阶要求是 “仅用常数空间的一趟扫描算法”。
示例 1:
- 输入:
nums = [2,0,2,1,1,0]
- 输出:
[0,0,1,1,2,2]
示例 2:
- 输入:
nums = [2,0,1]
- 输出:
[0,1,2]
核心约束:
- 元素仅为 0、1、2 三种值;
- 需原地排序(空间复杂度尽可能低);
- 进阶要求:时间复杂度 O (n)(一趟扫描),空间复杂度 O (1)。
解法一:计数排序(基础思路,两趟扫描)
思路解析
计数排序的核心是 “统计每种元素的出现次数,再按顺序填充回原数组”,适合元素值范围有限的场景(本题仅 0、1、2)。
步骤:
- 统计次数:遍历数组,用三个变量分别记录 0、1、2 的出现次数;
- 原地填充:按 “0→1→2” 的顺序,根据统计次数将对应值填充回原数组。
代码实现(C++)
#include <vector>
using namespace std;class Solution {
public:void sortColors(vector<int>& nums) {int count0 = 0, count1 = 0, count2 = 0;// 第一趟:统计 0、1、2 的出现次数for (int num : nums) {if (num == 0) count0++;else if (num == 1) count1++;else count2++;}// 第二趟:按顺序填充回原数组int idx = 0;// 填充 0while (count0--) {nums[idx++] = 0;}// 填充 1while (count1--) {nums[idx++] = 1;}// 填充 2while (count2--) {nums[idx++] = 2;}}
};
复杂度分析
- 时间复杂度:O (n),两趟遍历数组(统计 + 填充),每趟均为 O (n);
- 空间复杂度:O (1),仅用三个计数变量,无额外空间开销。
优缺点
- 优点:逻辑简单,易实现,空间复杂度最优,适合元素值范围固定的场景;
- 缺点:需两趟扫描,不满足进阶要求的 “一趟扫描”,且仅适用于有限值的排序(通用性弱)。
解法二:双指针(一趟扫描,进阶思路)
思路解析
双指针的核心是 “用两个指针划分区域,一趟遍历中完成元素交换”,将数组分为三个区间:
- [0, left-1]:已排序的 0;
- [left, right]:待排序的元素(1 或未处理的 0/2);
- [right+1, n-1]:已排序的 2。
步骤:
- 初始化
left = 0
(0 的右边界)、right = nums.size() - 1
(2 的左边界)、i = 0
(当前遍历指针); - 遍历数组,根据
nums[i]
的值调整区域:- 若
nums[i] == 0
:交换nums[i]
和nums[left]
,left++
(0 的区域扩大),i++
(当前元素已处理); - 若
nums[i] == 2
:交换nums[i]
和nums[right]
,right--
(2 的区域扩大),不移动 i(交换后nums[i]
是未处理的元素,需重新判断); - 若
nums[i] == 1
:直接i++
(1 无需交换,留在待排序区域);
- 若
- 当
i > right
时,所有元素处理完毕(待排序区域为空)。
代码实现(C++)
#include <vector>
using namespace std;class Solution {
public:void sortColors(vector<int>& nums) {int n = nums.size();int left = 0; // 0 的右边界([0, left-1] 是 0)int right = n-1; // 2 的左边界([right+1, n-1] 是 2)int i = 0; // 当前遍历指针while (i <= right) {if (nums[i] == 0) {// 交换到 0 的区域,left 右移,i 右移(当前元素已处理)swap(nums[i], nums[left]);left++;i++;} else if (nums[i] == 2) {// 交换到 2 的区域,right 左移,i 不移动(新元素需重新判断)swap(nums[i], nums[right]);right--;} else {// 遇到 1,直接跳过i++;}}}
};
关键细节
- 交换
nums[i]
和nums[right]
后,i
不移动:因为交换过来的元素可能是 0 或 2(如原nums[right]
是 0),需重新判断是否需要进一步交换; - 循环终止条件
i <= right
:当i > right
时,待排序区域[left, right]
为空,所有元素已归位。
复杂度分析
- 时间复杂度:O (n),每个元素仅被遍历一次(
i
和right
均单向移动,总步数为 n); - 空间复杂度:O (1),仅用三个指针变量,满足进阶要求的 “常数空间”。
优缺点
- 优点:一趟扫描完成排序,时间和空间复杂度均最优,是本题的标准进阶解法;
- 缺点:指针移动逻辑需仔细理解(尤其是
i
不移动的场景),对初学者有一定思维门槛。
解法三:单指针(一趟预处理 + 一趟填充,过渡思路)
思路解析
单指针的核心是 “先将 0 归位,再将 1 归位”,本质是 “两次局部的一趟扫描”,介于计数排序和双指针之间。
步骤:
- 第一阶段:用指针
p
记录 0 的右边界,遍历数组,将所有 0 交换到[0, p-1]
区域,p
随交换右移; - 第二阶段:从
p
开始遍历(此时[0, p-1]
已全为 0),用p
记录 1 的右边界,将所有 1 交换到[p, q-1]
区域(q
为新指针),剩余区域自然为 2。
代码实现(C++)
#include <vector>
using namespace std;class Solution {
public:void sortColors(vector<int>& nums) {int n = nums.size();int p = 0; // 记录当前 0 的右边界(第一阶段)/ 1 的右边界(第二阶段)// 第一阶段:将所有 0 移到数组左侧for (int i = 0; i < n; i++) {if (nums[i] == 0) {swap(nums[i], nums[p]);p++;}}// 第二阶段:将所有 1 移到 0 的右侧(从 p 开始,因为 [0,p-1] 已全为 0)for (int i = p; i < n; i++) {if (nums[i] == 1) {swap(nums[i], nums[p]);p++;}}// 剩余 [p, n-1] 区域自然为 2,无需处理}
};
复杂度分析
- 时间复杂度:O (n),两趟局部遍历(第一趟全数组,第二趟从
p
开始,总步数仍为 O (n)); - 空间复杂度:O (1),仅用一个指针变量。
优缺点
- 优点:逻辑比双指针更直观,分阶段处理降低理解难度,适合过渡学习;
- 缺点:需两趟扫描(虽总步数为 O (n),但非严格意义上的 “一趟”),不满足进阶的 “一趟扫描” 要求。
三种解法对比
解法 | 时间复杂度 | 空间复杂度 | 扫描次数 | 核心优势 | 适用场景 |
---|---|---|---|---|---|
计数排序 | O(n) | O(1) | 2 | 逻辑最简单,易实现 | 元素值范围固定,无需一趟扫描 |
双指针 | O(n) | O(1) | 1 | 一趟扫描,效率最优 | 进阶要求,追求极致性能 |
单指针 | O(n) | O(1) | 2 | 逻辑直观,过渡学习 | 理解双指针前的铺垫 |
进阶总结:双指针解法的核心思想
双指针解法之所以能实现 “一趟扫描 + 常数空间”,关键在于:
- 区域划分:用
left
和right
提前划分出 “已排序的 0 区域” 和 “已排序的 2 区域”,将待处理元素限制在中间,避免重复处理; - 指针单向移动:
left
只右移(0 区域只扩大),right
只左移(2 区域只扩大),i
只右移(或因交换 2 时暂停),确保每个元素仅被遍历一次; - 原地交换:通过交换元素实现区域调整,无需额外空间存储,满足 “原地排序” 要求。
这种 “划分区域 + 指针协同” 的思路,在排序(如快速排序的分区)、数组分区(如分隔正数和负数)等问题中均有广泛应用,是解决 “线性时间 + 常数空间” 问题的重要技巧。
实际应用场景
- 图像处理:对像素值进行分类(如黑白灰三色图像的像素排序);
- 数据过滤:将数据按 “有效 / 待审核 / 无效” 三类划分,且需原地调整;
- 嵌入式开发:内存受限场景下的轻量级排序(无额外内存可用)。
根据场景需求选择解法:追求简单选计数排序,追求性能选双指针,学习过渡选单指针。