LeetCode 75. 颜色分类:荷兰国旗问题详解

一、题目描述

给定一个包含 012 的数组 nums,分别表示红色、白色和蓝色。

要求我们将数组原地排序,使得相同颜色的元素相邻,并且按照:

复制代码
0 -> 1 -> 2

的顺序排列。

注意:题目要求不能使用内置 sort 函数

示例:

复制代码
输入:nums = [2,0,2,1,1,0]
输出:[0,0,1,1,2,2]

输入:nums = [2,0,1]
输出:[0,1,2]

二、题目分析

这道题看起来像排序题,但数组里只有三种数字:

复制代码
0、1、2

所以我们没必要真的用普通排序算法。

比较容易想到两种思路:

  1. 统计 012 出现的次数,然后重新填充数组。
  2. 使用双指针,在一次遍历中完成排序。

第一种方法比较简单,但是需要遍历两次数组。

第二种方法更经典,也就是常说的荷兰国旗问题

三、方法一:计数法

因为数组中只有 012 三种元素,所以我们可以先统计每个数字出现了多少次。

然后再按照数量,把数组改成:

复制代码
若干个 0 + 若干个 1 + 若干个 2

Java 代码

复制代码
class Solution {
    public void sortColors(int[] nums) {
        int count0 = 0;
        int count1 = 0;
        int count2 = 0;

        for (int num : nums) {
            if (num == 0) {
                count0++;
            } else if (num == 1) {
                count1++;
            } else {
                count2++;
            }
        }

        int index = 0;

        while (count0-- > 0) {
            nums[index++] = 0;
        }

        while (count1-- > 0) {
            nums[index++] = 1;
        }

        while (count2-- > 0) {
            nums[index++] = 2;
        }
    }
}

复杂度分析

时间复杂度:O(n)

空间复杂度:O(1)

这个方法很好理解,适合刚开始刷题的时候使用。

不过它不是最优雅的写法,因为它需要先统计,再重新赋值。

四、方法二:双指针法

更推荐掌握的是双指针法。

我们可以维护三个区域:

复制代码
[0, left - 1]        放 0
[left, i - 1]        放 1
[i, right]           当前还没处理
[right + 1, n - 1]   放 2

定义三个指针:

复制代码
left  指向下一个应该放 0 的位置
right 指向下一个应该放 2 的位置
i     当前遍历的位置

遍历时分三种情况:

情况一:numsi == 0

说明当前元素应该放到前面。

我们交换 nums[i]nums[left],然后:

复制代码
left++;
i++;

因为换过来的元素一定已经处理过,或者就是当前位置,所以 i 可以往后走。

情况二:numsi == 1

说明当前元素位置没问题,直接:

复制代码
i++;

情况三:numsi == 2

说明当前元素应该放到后面。

交换 nums[i]nums[right],然后:

复制代码
right--;

注意:这里 i 不能马上加一。

因为从 right 换过来的元素还没有处理过,可能是 012,所以需要继续判断当前位置。

五、双指针代码实现

复制代码
class Solution {
    public void sortColors(int[] nums) {
        int left = 0;
        int right = nums.length - 1;
        int i = 0;

        while (i <= right) {
            if (nums[i] == 0) {
                swap(nums, i, left);
                left++;
                i++;
            } else if (nums[i] == 1) {
                i++;
            } else {
                swap(nums, i, right);
                right--;
            }
        }
    }

    private void swap(int[] nums, int i, int j) {
        int temp = nums[i];
        nums[i] = nums[j];
        nums[j] = temp;
    }
}

六、举个例子理解一下

以数组为例:

复制代码
nums = [2,0,2,1,1,0]

初始状态:

复制代码
left = 0
i = 0
right = 5

当前 nums[i] = 2,应该放到右边,所以交换 nums[0]nums[5]

复制代码
[0,0,2,1,1,2]

此时:

复制代码
left = 0
i = 0
right = 4

注意这里 i 不动,因为换过来的 0 还需要继续处理。

当前 nums[i] = 0,应该放到左边,交换 nums[0]nums[0]

复制代码
[0,0,2,1,1,2]

然后:

复制代码
left = 1
i = 1
right = 4

当前 nums[i] = 0,继续放到左边:

复制代码
[0,0,2,1,1,2]

然后:

复制代码
left = 2
i = 2
right = 4

当前 nums[i] = 2,交换到右边:

复制代码
[0,0,1,1,2,2]

然后:

复制代码
left = 2
i = 2
right = 3

当前 nums[i] = 1,直接跳过:

复制代码
i = 3

当前 nums[i] = 1,继续跳过:

复制代码
i = 4

这时 i > right,循环结束,数组排序完成。

最终结果:

复制代码
[0,0,1,1,2,2]

七、为什么遇到 2 的时候 i 不能加一?

这是这道题最容易出错的地方。

比如:

复制代码
nums = [1,2,0]

i = 1 时,nums[i] = 2,我们把它和右边交换:

复制代码
[1,0,2]

这时从右边换过来的 0 还没有被处理。

如果我们直接 i++,就会跳过这个 0,导致结果错误。

所以遇到 2 时,只移动 right,不移动 i

八、复杂度分析

双指针法中,每个元素最多被处理一次,所以:

时间复杂度:

复制代码
O(n)

空间复杂度:

复制代码
O(1)

满足题目要求的原地排序。

九、总结

这道题是经典的荷兰国旗问题

如果只是想快速通过,可以用计数法;

如果想掌握更经典的思路,建议重点理解双指针法。

核心思路就是维护三个区域:

复制代码
左边放 0
中间放 1
右边放 2

遍历时:

  • 遇到 0,交换到左边
  • 遇到 1,直接跳过
  • 遇到 2,交换到右边,并且当前位置继续判断

这题的关键点是:遇到 2 时,i 不要急着往后走