3.java快速排序常用的几种划分位置(pivot)选择方法

58 阅读5分钟

快速排序(QuickSort)的效率和划分位置(pivot)选择关系非常大。选得不好会退化成 O(n2)O(n^2),选得合理则能保证平均 O(nlog⁡n)O(n \log n)。


🔹 常用的几种划分位置(pivot)选择方法

1. 固定位置法

  • 取第一个元素 / 最后一个元素 / 中间元素 作为 pivot。
  • 优点:实现最简单。
  • 缺点:当输入数组本身接近有序时,极易退化成 O(n2)O(n^2)。
int pivot = nums[left];  // 取最左边
// 或
int pivot = nums[right]; // 取最右边

2. 随机选择法(Randomized QuickSort)

  • [left, right] 区间随机选一个元素作为 pivot。
  • 优点:能有效避免最坏情况(比如已经有序的数据)。
  • 缺点:有少量随机数开销,但可忽略。
Random rand = new Random();
int pivotIndex = left + rand.nextInt(right - left + 1);
swap(nums, pivotIndex, right); // 把 pivot 换到最后

3. 三数取中法(Median-of-Three)

  • 取区间的 左端、右端、中间 三个元素,取它们的中位数作为 pivot。
  • 优点:能较好避免最坏情况,减少递归深度。
  • 缺点:需要额外比较操作,但提升稳定性。
int mid = left + (right - left) / 2;
int pivot = medianOfThree(nums[left], nums[mid], nums[right]);

4. 九数取中法 / 更高阶采样

  • 类似三数取中,但取更多点(比如 9 个点),再递归找中位数。
  • 优点:分区更均衡。
  • 缺点:开销大,通常用于大规模排序。

5. Median-of-Medians(中位数的中位数)

  • 线性时间 O(n)O(n) 选择 pivot 的方法。

  • 过程:

    1. 将数组分成若干组(一般 5 个一组)。
    2. 每组找中位数。
    3. 把这些中位数组成新数组,递归找它们的中位数。
    4. 得到的中位数作为 pivot。
  • 优点:保证最坏情况也是 O(nlog⁡n)O(n \log n)。

  • 缺点:实现复杂,常用于 QuickSelect(找第 k 大/小)。


🔹 对比总结

方法平均复杂度最坏复杂度实现复杂度适用场景
固定位置(左/右/中)O(nlog⁡n)O(n \log n)O(n2)O(n^2)简单场景
随机选择O(nlog⁡n)O(n \log n)很难退化成 O(n2)O(n^2)★★常用,实际工程
三数取中O(nlog⁡n)O(n \log n)接近均衡,避免 O(n2)O(n^2)★★常用优化
九数取中O(nlog⁡n)O(n \log n)很稳定★★★大规模排序
Median-of-MediansO(nlog⁡n)O(n \log n)O(nlog⁡n)O(n \log n)★★★★特殊场景,理论保证

✅ 在 实际应用 中:

  • 随机选择三数取中 最常用;
  • Median-of-Medians 多用于 选择问题(QuickSelect) ,而不是普通排序。

好问题 👍 我来详细解释一下你写的这段随机数代码为什么要这么取随机数,以及逻辑背后的原因:

Random rand = new Random();
int pivotIndex = left + rand.nextInt(right - left + 1);
swap(nums, pivotIndex, right); // 把 pivot 换到最后

1. rand.nextInt(right - left + 1) 的含义

  • rand.nextInt(n) 会生成 [0, n) 之间的整数(包含 0,不包含 n)。
  • 所以 rand.nextInt(right - left + 1) 会生成 [0, right-left] 之间的整数。
  • 再加上 left,范围就变成了 [left, right] ,也就是当前区间里任意一个下标。

这样就能 随机选择当前区间的任意一个元素作为 pivot(基准值)


2. 为什么要随机选 pivot?

快速排序的核心步骤是 选取 pivot 并划分,不同的 pivot 会导致性能差异很大:

  • 坏情况(最差 O(n²))
    如果每次都选到最小值/最大值,划分特别不平衡,比如 [1,2,3,4,5] 已排序数组,选第一个元素当 pivot,就会退化成冒泡/选择排序。
  • 好情况(平均 O(n log n))
    如果 pivot 能把区间均匀划分(接近中位数),那么划分比较平衡,递归树深度接近 log n,总复杂度就是 O(n log n)。

👉 随机选 pivot 能 打破输入的特殊性,使得算法的期望复杂度稳定在 O(n log n),大大降低退化风险。


3. 为什么把 pivot 换到最后?

swap(nums, pivotIndex, right);
  • 在 partition 操作里,通常我们以最后一个元素作为 pivot 来写逻辑(简洁:遍历 [left, right-1],最后把 pivot 放回正确位置)。
  • 如果随机选到别的下标,就先和 right 位置交换,这样 partition 的代码不用改动,依旧是以 nums[right] 为基准。

这样做的好处是:

  1. partition 逻辑更统一(永远用末尾元素当 pivot)。
  2. 实现简洁,不用额外处理 pivotIndex。

总结

  • rand.nextInt(right - left + 1) → 生成 [left, right] 范围的随机下标。
  • 随机选 pivot → 避免退化情况,保证期望 O(n log n)。
  • swap 到最后 → 简化 partition 代码逻辑。

1. Java 里 nextInt() 的定义

在 Java 里,Random 的用法是这样的:

rand.nextInt(n)  // 生成 [0, n) 的随机整数

⚠️ 注意:参数 n 必须是一个整数(生成 0 到 n-1 之间的随机数)。

也就是说,Java 本身 没有 rand.nextInt(left, right+1) 这样的双参数形式(至少在旧版本里没有)。


2. 为什么写成 left + rand.nextInt(right - left + 1)

因为我们需要在 [left, right] 范围里随机取一个整数,

  • 区间长度是 (right - left + 1)
  • 在这个区间里取一个 偏移量(0 到区间长度-1)
  • 再加上 left 作为起点,就变成了 [left, right]

所以写法是:

int pivotIndex = left + rand.nextInt(right - left + 1);

3. Java 17 以后确实有类似功能

Java 17 开始,Random 新增了一个重载:

rand.nextInt(origin, bound)  // [origin, bound) 左闭右开

所以你可以写成:

int pivotIndex = rand.nextInt(left, right + 1);

这时候效果和 left + rand.nextInt(right - left + 1) 是一样的,因为 right+1 保证了取值范围是 [left, right]


总结

  • 旧版本 Java:只能写 left + rand.nextInt(right - left + 1)
  • 新版本 Java(17+):可以直接写 rand.nextInt(left, right+1),更直观