【LeetCode 912】排序数组——随机化快速排序详解

作者:逆境不可逃

技术永无止境

希望我的内容可以帮助到你!!!!


LeetCode 912「排序数组」要求我们在不调用内置排序函数的情况下,将整数数组按升序排列。数组长度最多为 5 × 10⁴,并且可能包含大量重复元素,因此需要选择效率较高的排序算法。

这篇文章使用随机化快速排序解决问题。核心过程可以概括为:

复制代码
随机选择一个基准值 pivot
          ↓
把数组划分到 pivot 两侧
          ↓
递归排序左右两个子数组

快速排序平均时间复杂度为 O(n log n),划分过程直接在原数组上完成,不需要额外创建左右数组。

一、快速排序的基本思路

假设当前需要排序的区间为:

复制代码
[5, 2, 3, 1, 4]

从中选择一个元素作为基准值。为了方便讲解,假设选中 3:

复制代码
pivot = 3

经过一次划分后,我们希望得到:

复制代码
[小于等于 3的元素 | 3 | 大于等于3的元素]

例如:

复制代码
[1, 2 | 3 | 5, 4]

此时 3 已经处在一个正确的分界位置。接下来只需要继续排序:

复制代码
左区间:[1, 2]
右区间:[5, 4]

当左右区间也分别有序时,整个数组自然有序。

二、为什么随机选择 pivot

最简单的快速排序可以固定选择第一个元素作为基准值:

复制代码
int pivot = nums[left];

但是,如果输入数组本身已经接近有序:

复制代码
[1, 2, 3, 4, 5]

每次都选择第一个元素,就可能得到极不平衡的划分:

复制代码
[] | 1 | [2, 3, 4, 5]
[] | 2 | [3, 4, 5]
[] | 3 | [4, 5]

递归深度会接近 n,时间复杂度退化为 O(n²)。

因此,代码在当前区间中随机选择 pivot:

复制代码
int randomIndex =
        left + RAND.nextInt(right - left + 1);

其中:

复制代码
RAND.nextInt(right - left + 1)

生成:

复制代码
0 ~ right - left

再加上 left,最终范围就是:

复制代码
left ~ right

随机选择不能彻底消除最坏情况,但可以大幅降低持续选中最大值或最小值的概率,使算法在实际运行中更稳定。

三、先把 pivot 放到区间开头

选择好 pivot 后,代码将它与 nums[left] 交换:

复制代码
int pivot = nums[randomIndex];
swap(nums, randomIndex, left);

交换后,当前区间可以看成:

复制代码
[pivot | 尚未划分的元素]

这样做是为了把 pivot 暂时单独保存到左端。后面的双指针只需要扫描:

复制代码
[left + 1, right]

等划分完成后,再把 pivot 放到最终位置。

四、使用相向双指针划分数组

初始化两个指针:

复制代码
int i = left + 1;
int j = right;

数组在扫描过程中保持下面的结构:

复制代码
[pivot | <= pivot | 尚未处理 | >= pivot]
   ↑          ↑          ↑          ↑
 left        i-1         j+1       right

也可以理解为:

  • i 左边已经处理过,元素都不大于 pivot。
  • j 右边已经处理过,元素都不小于 pivot。
  • i~j 之间还没有处理。

左指针寻找大数

复制代码
while (i <= j && nums[i] < pivot) {
    i++;
}

如果 nums[i] < pivot,它本来就应该留在左边,所以 i 继续向右移动。

循环结束后,如果 i 没有越界,那么:

复制代码
nums[i] >= pivot

右指针寻找小数

复制代码
while (i <= j && nums[j] > pivot) {
    j--;
}

如果 nums[j] > pivot,它本来就应该留在右边,所以 j 继续向左移动。

循环结束后,如果指针没有交错,那么:

复制代码
nums[j] <= pivot

此时,nums[i] 在错误的一侧,nums[j] 也在错误的一侧,所以交换它们:

复制代码
swap(nums, i, j);
i++;
j--;

重复这个过程,直到两个指针相遇或交错。

五、用一个例子理解 partition

以数组为例:

复制代码
[5, 2, 3, 1, 4]

假设随机选中了 3,先将它交换到区间开头:

复制代码
[3, 2, 5, 1, 4]
 ↑
pivot

初始化:

复制代码
i = 1
j = 4

左指针开始移动:

复制代码
nums[1] = 2 < 3

所以 i 向右移动到下标 2:

复制代码
[3, 2, 5, 1, 4]
       ↑     ↑
       i     j

此时 nums[i] = 5,不应该留在左边。

右指针从右向左寻找:

复制代码
nums[4] = 4 > 3

所以 j 移到下标 3:

复制代码
[3, 2, 5, 1, 4]
       ↑  ↑
       i  j

此时:

复制代码
nums[i] = 5 >= 3
nums[j] = 1 <= 3

交换它们:

复制代码
[3, 2, 1, 5, 4]

然后:

复制代码
i = 3
j = 2

两个指针已经交错,扫描结束。

六、为什么 pivot 要和 j 交换

扫描结束后,数组结构大致为:

复制代码
[pivot | <= pivot | >= pivot]
   ↑             ↑   ↑
 left            j   i

此时 j 是左侧区域的最后一个位置,因此将 pivot 与 nums[j] 交换:

复制代码
swap(nums, left, j);

得到:

复制代码
[<= pivot | pivot | >= pivot]
                ↑
                j

在前面的例子中:

复制代码
交换前:[3, 2, 1, 5, 4]
交换后:[1, 2, 3, 5, 4]
                 ↑
               pivot

为什么不和 i 交换?

首先,i 可能已经等于 right + 1,这时访问 nums[i] 会越界。其次,即使 i 没有越界,它通常也指向右侧区域,可能满足:

复制代码
nums[i] > pivot

如果与 i 交换,就可能把一个大于 pivot 的数字放到区间最左边,破坏划分结果。

而 j 位于左侧区域,将 pivot 放到 j 的位置能够保证:

复制代码
j 左边的元素 <= pivot
j 右边的元素 >= pivot

所以 partition() 最后返回 j:

复制代码
return j;

七、递归排序左右区间

完成一次划分后,pivot 已经位于分界位置,不需要再次参与排序。

只需要递归处理它的左右两侧:

复制代码
quickSort(nums, left, pivotIndex - 1);
quickSort(nums, pivotIndex + 1, right);

整体过程就是:

复制代码
原数组
   ↓ partition
左区间 + pivot + 右区间
   ↓             ↓
递归排序       递归排序

当区间内最多只有一个元素时,区间天然有序,可以直接返回:

复制代码
if (left >= right) {
    return;
}

八、为什么扫描时使用严格比较

代码中使用的是:

复制代码
nums[i] < pivot
nums[j] > pivot

而不是:

复制代码
nums[i] <= pivot
nums[j] >= pivot

这与重复元素有关。

假设数组中所有元素都等于 pivot:

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

使用严格比较时,左右指针遇到等于 pivot 的元素都会停下来,然后交换并继续向中间移动:

复制代码
i →         ← j

最终两个指针会在区间中间附近相遇,使 pivot 也落在靠近中间的位置,左右区间相对均衡。

如果把相等元素全部跳过,它们可能集中到同一侧,递归区间会变得很不平衡,容易退化成 O(n²)。

需要说明的是,这份代码属于双路划分,不是三路快速排序。它通过严格比较,让等于 pivot 的元素分散到左右两侧。三路快速排序则会直接划分出:

复制代码
小于 pivot | 等于 pivot | 大于 pivot

当数组包含大量重复元素时,三路划分通常会更直接,因为等于 pivot 的区域不需要继续递归。

九、判断子数组是否已经有序

代码还增加了一个提前结束的优化:

复制代码
boolean ordered = true;

for (int i = left; i < right; i++) {
    if (nums[i] > nums[i + 1]) {
        ordered = false;
        break;
    }
}

if (ordered) {
    return;
}

如果当前区间已经是升序:

复制代码
[1, 2, 3, 4, 5]

就不需要继续选择 pivot 和递归划分,可以直接返回。

对于整个数组已经有序的情况,只需要扫描一次,时间复杂度可以达到 O(n)。

不过,这个优化不是快速排序正确运行的必要条件。它会让每个递归区间多一次有序性检查,如果更看重代码简洁,也可以删除。删除后仍然是完整的随机化快速排序。

十、完整 Java 代码

复制代码
import java.util.Random;

class Solution {

    private static final Random RAND = new Random();

    public int[] sortArray(int[] nums) {
        quickSort(nums, 0, nums.length - 1);
        return nums;
    }

    // 对闭区间 [left, right] 进行快速排序
    private void quickSort(int[] nums, int left, int right) {
        if (left >= right) {
            return;
        }

        // 如果当前区间已经有序,直接结束
        boolean ordered = true;

        for (int i = left; i < right; i++) {
            if (nums[i] > nums[i + 1]) {
                ordered = false;
                break;
            }
        }

        if (ordered) {
            return;
        }

        int pivotIndex = partition(nums, left, right);

        quickSort(nums, left, pivotIndex - 1);
        quickSort(nums, pivotIndex + 1, right);
    }

    // 对闭区间 [left, right] 进行划分
    private int partition(int[] nums, int left, int right) {
        // 随机选择 pivot
        int randomIndex =
                left + RAND.nextInt(right - left + 1);

        int pivot = nums[randomIndex];

        // 把 pivot 暂时放到区间开头
        swap(nums, randomIndex, left);

        int i = left + 1;
        int j = right;

        while (true) {
            // 寻找左侧第一个大于等于 pivot 的元素
            while (i <= j && nums[i] < pivot) {
                i++;
            }

            // 寻找右侧第一个小于等于 pivot 的元素
            while (i <= j && nums[j] > pivot) {
                j--;
            }

            if (i >= j) {
                break;
            }

            swap(nums, i, j);
            i++;
            j--;
        }

        // 将 pivot 放到左右区域的分界处
        swap(nums, left, j);

        return j;
    }

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

十一、为什么这段代码能够完成排序

可以从三个方面理解它的正确性。

partition 完成了有效划分

执行完 partition() 后:

复制代码
pivot 左边的元素 <= pivot
pivot 右边的元素 >= pivot

因此,pivot 已经处在一个合法的排序位置。

递归解决规模更小的问题

接下来分别排序:

复制代码
[left, pivotIndex - 1]
[pivotIndex + 1, right]

这两个区间都比原区间更小。

递归最终会结束

当区间长度小于或等于 1 时:

复制代码
left >= right

方法直接返回。

所以,左右子数组最终都会变得有序,再加上中间的 pivot,整个数组也就有序了。

十二、复杂度分析

时间复杂度

如果每次划分都比较均衡,递归树大约有 log n 层,每一层总共扫描 n 个元素:

复制代码
O(n) × O(log n) = O(n log n)

因此,随机化快速排序的期望时间复杂度为:

复制代码
O(n log n)

如果每次随机选中的 pivot 恰好都是当前区间的最大值或最小值,划分会极不平衡,最坏时间复杂度仍然是:

复制代码
O(n²)

随机选择 pivot 降低了这种情况持续发生的概率,但不能提供严格的最坏 O(n log n) 保证。如果必须保证最坏时间复杂度,可以使用堆排序或归并排序。

空间复杂度

划分过程只使用几个变量:

复制代码
partition 额外空间:O(1)

额外空间主要来自递归调用栈:

复制代码
平均空间复杂度:O(log n)
最坏空间复杂度:O(n)

稳定性

快速排序不是稳定排序。相同数值的元素可能因为交换而改变原来的相对顺序。

十三、实现时需要注意的细节

随机范围要包含 right

当前区间是闭区间 [left, right],长度为:

复制代码
right - left + 1

所以应该写成:

复制代码
left + RAND.nextInt(right - left + 1)

少写一个 +1,就永远选不到 right。

递归区间不能再次包含 pivot

partition() 返回后,pivot 已经确定位置,所以递归范围应当是:

复制代码
[left, pivotIndex - 1]
[pivotIndex + 1, right]

如果仍然把 pivot 包含进去,可能造成重复处理,甚至无法正常结束递归。

双指针移动时必须检查边界

应该先判断:

复制代码
i <= j

再访问 nums[i] 或 nums[j]:

复制代码
while (i <= j && nums[i] < pivot)

Java 的 && 具有短路特性。当 i > j 时,不会继续访问数组,因此可以避免下标越界。

重复元素不能全部推向同一侧

这里要保留严格比较:

复制代码
nums[i] < pivot
nums[j] > pivot

这样遇到等于 pivot 的元素时,两边指针都会停下并继续向中间收缩。

单独运行时需要导入 Random

LeetCode 编辑器可能已经提供常用导入,但在普通 Java 文件中应显式添加:

复制代码
import java.util.Random;

十四、总结

这道题使用了随机化双路快速排序,核心有四步:

复制代码
1. 在当前区间随机选择 pivot
2. 把 pivot 临时放到区间开头
3. 使用相向双指针完成划分
4. 递归排序 pivot 左右两侧

其中最值得理解的是 partition():

复制代码
[pivot | <= pivot | 未处理 | >= pivot]
                     ↓
[<= pivot | pivot | >= pivot]

随机 pivot 用来降低极端划分连续出现的概率;严格的 < 和 > 比较则让重复元素分散到两侧,避免全部集中在一个递归区间。

面试时可以这样回答:

我使用随机化快速排序。每次从当前区间随机选择一个 pivot,把它交换到区间开头,然后通过相向双指针寻找左侧大于等于 pivot 的元素和右侧小于等于 pivot 的元素并进行交换。指针交错后,将 pivot 与右指针位置交换,从而得到左侧不大于 pivot、右侧不小于 pivot 的划分,再递归排序左右区间。随机选择 pivot 可以降低有序输入导致退化的概率,算法期望时间复杂度为 O(n log n),平均递归空间为 O(log n)。

相关推荐
GoAI3 小时前
DeepSeek Harness Memory (一)主流记忆插件总结
后端·算法·面试
Pniubi4 小时前
力扣54.螺旋矩阵
算法·leetcode·矩阵
choumou_M6 小时前
算法_1:由浅入深学习快速排序QuickSort
学习·算法·排序算法
Coder_Shenshen7 小时前
罗克韦尔ACD工程归档离线变量树解析
算法·c#
心仪久了会沦陷8 小时前
C语言数据结构入门——树与二叉树详解:概念、堆实现与链式遍历
c语言·数据结构·算法
小此方8 小时前
[算法实战论:动态规划]背包问题概念,01不满背包问题,01满背包问题,背包问题的滚动数组空间优化
算法·动态规划
悠仁さん10 小时前
【C++】哈希表
c++·算法·散列表
码少女10 小时前
数据结构——图的基础
数据结构
地平线开发者10 小时前
点云算法整理与常用计算模块:从数据到模型,再到工程与量化
算法·自动驾驶