简要说明
(1) 题目来源:LeetCode(75. 颜色分类)
(2) 代码仅供参考,尚可优化。如有改进建议,欢迎评论分享。
题目简介
-
给定一个包含红色、白色和蓝色,一共 n 个元素的数组,原地对它们进行排序,使得相同颜色的元素相邻,并按照红色、白色、蓝色顺序排列。
-
此题中,我们使用整数 0、 1 和 2 分别表示红色、白色和蓝色。
输入输出样例:
输入:nums = [2, 0, 2, 1, 1, 0]
输出:nums = [0, 0, 1, 1, 2, 2]
热身:数组整理(奇数和偶数)
对于本题需要作出以下有用的假设:我们希望在O(n)时间复杂度内解决这个问题。事实上,一个简单的排序就可以解决这个问题,但除去一些良好组织的排序外都以O(nlogn)为下界,对我们来说仍然不太理想。
另一个有意义的假设(本文也基于此假设)是,我们希望在一遍扫描内解决这一问题。即便有上一个假设,也有取巧的办法:分别数0、1(和2)的个数,然后还原它(计数排序)。但这实则上有两遍“扫描”:第一次扫描获取个数,第二次扫描填充结果。
因此,如果直接对这个问题有思路的话就可以跳过本部分。不然,我们可以从这道题目入手,来为颜色分类问题构思:
- 给定一个整型正数数组,由若干奇数和偶数组成。要求使用一遍扫描整理数组,使得所有奇数在所有偶数的左侧。例如,原数组为[5, 2, 4, 7, 9],那么结果就应是[5, 7, 9, 2, 4]。为简化这一问题,不追求奇数和偶数在原数组中的顺序。
读者可以在此稍作停留,自行思考本题。接下去是本题的思路。
本题的简要思路就是交换奇数和偶数,我们可以只由奇数或偶数发起交换操作。如果我们主动交换偶数,那就需要放在尾部;如果我们主动交换奇数,那就需要放在头部。接下去我们进一步思考后者的思路。
其实最头疼的问题就是如何找到“头部”,因为“头部”可能已经有整理好(或本身就在)的奇数,它们不需要被挪动。因此自然而然地,我们就引入一个“指针”,去指向整理完的奇数位置。
// 测试样例
int array[] = {5,6,7,2,4,8,7};
int length = 7;
void swap(int *array, int i, int j) {
int temp = array[i];
array[i] = array[j];
array[j] = temp;
}
void organize(int *array, int length) {
int p = 0;
for (int i=0; i<length; ++i) {
if (array[i] & 0x1) {
// n是奇数
swap(array, p++, i);
}
}
}
int main() {
organize(array, length);
for (int i=0; i<length; ++i)
cout << array[i] << ' ';
}
可以看到,organize()函数内的p就是用于这一个“指针”的作用,它负责记录整理完的奇数的尾部。在我们的测试样例中,第一个数就是奇数,实际上就是自己与自己交换。之后遇到偶数就先置之不理,直到遇到下一个奇数,再和第二个位置交换,以此类推。
如果我们主动整理偶数,也就是更换organize()内的if的逻辑(即!array[i] & 0x1),那么指针应该从尾部依次前挪。读者可类似地写出代码。
更有趣的问题是,如果我们要求保证偶数的顺序,应该怎么做?这个问题留给读者思考。显然,并不要求偶数顺序的话,上述代码就足够了。
回到本题:思路分析与代码
注:仅代表个人思路
和上面的数组整理问题进行比较的话,实际上这道题就多出了一个颜色:奇数偶数的组织问题就是对两个颜色进行整理,它需要使用一个指针去标记整理的头部(主动整理奇数的话)。
如果仅要求O(n)而不限制扫描次数的话,上述整理的思路便是两次整理,先把0整理到最前面,对剩下部分再针对1和2进行整理。
回到本题。本题只有三个颜色,因此我们可以分开整理:把0整理到最前面,把2整理到最后面,这就需要一头一尾两个指针。事情解决了一半,但仍然有许多的代码细节的问题。先贴上代码。
void swap(vector<int>& v, int i, int j) {
if (i==j) return; //这行无关紧要
int temp = v[i];
v[i] = v[j];
v[j] = temp;
}
void sortColors(vector<int>& nums) {
int size = nums.size();
int number_red, number_blue;
number_red = number_blue = 0;
for (int i=0; i<size-number_blue;) {
switch (nums[i]) {
case 0:
swap(nums, number_red++, i++);
break;
case 1:
++i;
break;
case 2:
swap(nums, size-1-number_blue, i);
number_blue++;
break;
}
}
}
在这里,number_red和number_blue就负责我们上面两个指针的概念,分别表示对应颜色的数量。在一次扫描中:
- 如果遇到0,需要把这个表项放到最前面,这就需要将nums[i]和nums[number_red]进行交换。
我们考虑新的nums[i](即原先的nums[number_red])会是什么,它可能是0(当且仅当0在头时的第一次扫描会出现这个情况),或者是1和2。由于number_red ≤ i,不可能是2(已经会被放到尾部),因此只可能是1。我们不对1进行操作,于是下一次的扫描对象是nums[i+1],即需要自增i。 - 如果遇到1,我们不动,扫描下一项,即需要自增i。
- 如果遇到2,需要把这个表项放到最后面,这就需要将nums[i]和nums[size-1-number_blue]进行交换。
我们考虑新的nums[i](即原先的nums[number_red])会是什么。因为新的nums[i]我们从未处理过,因此它完全有可能是0、1、2,这就需要再次处理。因此在这种情况下,i不需要自增。
最后我们考虑边界情况。如果我们依然像之前的热身问题一样一直整理到尾部,就会出现问题:此时number_blue > 0,也就是我们现在扫描的对象已经是整理好的2了,那么再发生上述交换的话就可能把1(或0)换回尾部,我们之前的操作就变成徒劳了!因此扫描停止的情况是扫描到整理好的尾部2就停止,对应代码中的i < size - number_blue。
在这里我们留下另一个问题:如果出现4种(甚至更多)颜色,我们应当如何整理。事实上,当颜色数趋近于无限时,这就变成了排序问题,因此我们只考虑颜色数在比较少的情况。(对于4种颜色的情况,有时间再添这个坑)
补充部分
- 事实上并不是对这个问题的补充……之所以会在做颜色分类本题时联想到本博客中的热身题,除了它们思路一致之外,还有就是这道题给我留下了一些印象——据说是之前某高校保研的算法上机题,要求并没有那么高——只是O(n)解决。本问题实际上就是对这个问题的延伸应用了。