题目描述

✅ 补充题 4. 手撕快速排序

image-20260928184726234

给定整数数组 nums,请将其按非递减顺序排列并返回,所有元素及其重复次数都要保留。

本补充题沿用力扣「排序数组」的输入输出,要求手动实现快速排序,不能直接调用内置排序函数。

示例 1:

输入:nums = [5,2,3,1]
输出:[1,2,3,5]

示例 2:

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

提示:

  • 1 <= nums.length <= 5 * 10^4。
  • -5 * 10^4 <= nums[i] <= 5 * 10^4。
  • 力扣原题要求 O(n log n) 时间,并尽量减少额外空间。
  • 随机快速排序的 O(n log n) 为期望时间,最坏仍可能退化为 O(n²),下文会区分这两种情况。

题意分析

手写快速排序,把整数数组按从小到大的顺序排列并返回。排序只改变元素的位置,所有元素及其重复次数都要保留,不能直接调用库中的排序函数。

下面的实现直接在原数组中交换元素,不另外创建数组保存分区;递归调用仍然需要栈空间。重点是如何把当前区间分好,再对尚未有序的部分继续排序。

解法:随机基准 + 三路分区

核心思路

[!blue]

从当前区间随机选一个元素,将它的值保存为基准 pivot。把区间分成小于、等于、大于基准的三段后,中间一段的值完全相同,已经不需要排序;只要再把左右两段各自排好,整个区间就有序了。这就是快速排序的分治过程。

用 lt 标记等值段的起点,i 扫描尚未分类的元素,gt 标记未知区间的末尾。在当前处理的 [left, right] 内,lt 左侧都小于基准,lt 到 i - 1 都等于基准,i 到 gt 尚未分类,gt 右侧都大于基准。

当前值小于基准,就与 lt 位置交换,并同时右移 lt 和 i:换回来的值属于等值段,或者只是与自身交换,不需要再检查。当前值大于基准,就与 gt 位置交换并左移 gt,但不能移动 i,因为从右侧换来的值还没有分类。当前值等于基准,只需右移 i。

当 i > gt 时,未知区间清空,[lt, gt] 就是完整的等值段。递归时跳过它,可以避免反复处理重复值;随机选择基准则减少划分表现对原始排列的依赖,但不保证每次都能均匀划分。

解题步骤

  1. 若 left >= right,区间最多只有一个元素,直接返回。
  2. 在 [left, right] 中随机选取并保存基准值,初始化 lt = i = left、gt = right。
  3. 在 i <= gt 时分类:小于基准就与 lt 交换并右移 lt、i;大于基准就与 gt 交换并只左移 gt;等于基准就只右移 i。
  4. 分区结束后,递归排序 [left, lt - 1] 和 [gt + 1, right],跳过中间等值段。
  5. 初始调用覆盖整个数组,所有递归结束后返回原数组。

代码实现

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

        return nums;
    }

    private void quickSort(int[] nums, int left, int right) {
        if (left >= right) {
            return;
        }

        // 基准保存为值,后续交换不会改变本轮比较基准。
        int pivot = nums[java.util.concurrent.ThreadLocalRandom.current().nextInt(left, right + 1)];
        // 四段依次为小于、等于、未知、大于基准,未知区间是 i 到 gt。
        int lt = left;
        int i = left;
        int gt = right;

        while (i <= gt) {
            if (nums[i] < pivot) {
                swap(nums, lt++, i++);
            } else if (nums[i] > pivot) {
                // 右端换回的元素尚未分类,因此本轮不增加 i。
                swap(nums, i, gt--);
            } else {
                i++;
            }
        }

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

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

        nums[i] = nums[j];
        nums[j] = temp;
    }
}
import "math/rand"

func sortArray(nums []int) []int {
    var quickSort func(int, int)
    quickSort = func(left, right int) {
        if left >= right {
            return
        }
        // 基准保存为值,后续交换不会改变本轮比较基准。
        pivot := nums[left+rand.Intn(right-left+1)]
        // 四段依次为小于、等于、未知、大于基准,未知区间是 i 到 gt。
        lt, i, gt := left, left, right
        for i <= gt {
            if nums[i] < pivot {
                nums[lt], nums[i] = nums[i], nums[lt]
                lt++
                i++
            } else if nums[i] > pivot {
                // 右端换回的元素尚未分类,因此本轮不增加 i。
                nums[i], nums[gt] = nums[gt], nums[i]
                gt--
            } else {
                i++
            }
        }
        quickSort(left, lt-1)
        quickSort(gt+1, right)
    }
    quickSort(0, len(nums)-1)
    return nums
}

复杂度分析

  • 时间复杂度:期望 $O(n \log n)$。每次分区线性扫描当前区间,随机基准使划分在期望意义下较均衡,递归各层的总扫描量不超过 $O(n)$,期望层数为 $O(\log n)$。若连续出现极不均衡划分,最坏仍为 $O(n^2)$;全相等数组只需一轮 $O(n)$ 分区。
  • 空间复杂度:递归栈期望 $O(\log n)$,最坏 $O(n)$;分区本身只用常数空间。

关键点总结

[!green]

  • 基准保存为值:交换会改变原来的位置,比较对象必须保持不变。
  • 等值段无需递归:重复元素越集中,跳过等值段省去的重复比较越多。
  • 右侧交换后继续检查当前位置:换回的值尚未分类,不能直接跳过。

易错点总结

[!yellow]

  • 与 gt 交换后不能递增 i:否则会漏掉换入元素。
  • 递归区间排除等值段:边界应为 lt - 1 和 gt + 1,避免重复处理甚至不收敛。
  • 随机基准不保证最坏情况消失:仍可能连续产生不均衡划分,不能把最坏时间写成 $O(n \log n)$。
  • 先判断区间长度再抽取基准:空数组或单元素区间应直接返回。

相似题目

题目 难度 关联与区别
215. 数组中的第K个最大元素 中等 快速选择复用分区,但只递归包含第k项的一侧,本题要排序两边。
转载与许可
作者
链接 https://hgnulb.github.io/blog/2026/33242831
许可 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议,转载请注明出处!