排序数组 超级通俗易懂、全面的快速排序教程(优化重复元素、实例有序问题)
🎯 本文档详细介绍了快速排序算法及其优化方案,包括Lomuto分区与Hoare分区方法。快速排序是一种高效的排序算法,其核心在于通过选择一个“基准”来将数组划分为两个子数组,递归地对这些子数组进行排序。文档中不仅解释了这两种分区的基本思想和实现细节,还讨论了如何通过随机选择基准值来避免最坏情况的发生,并对比了它们的效率差异。此外,文中提到了JDK中采用的双轴快速排序及对小数组使用插入排序的优化策略,展示了高效排序算法在实际应用中的重要性。
快速排序
Lomuto分区方案:传统快速排序
**基本思想:**选一个基准数,把小的扔到基准的左边,大的扔到基准的右边,递归重复这个过程。
- 选基准,可以选择数组的任意一个数,例如第一个数
- 分区操作
- 左指针向右找比基准大的
- 右指针向左找比基准小的
- 交换这两个数
- 重复步骤 a、b、c,直到左、右指针相遇
- 最后把基准和左右指针所在位置的元素交换
- 递归 对基准的左右两边的子数组重复上述操作。
private void quickSort(int[] nums, int left, int right) {
if (left >= right) return;
// 基准
int pivot = nums[left];
int i = left, j = right;
while (i < j) {
// 找到比基准小的数字
while (i < j && nums[j] >= pivot) {
j--;
}
// 找到比基准大的数字
while (i < j && nums[i] <= pivot) {
i++;
}
// 交换元素
if (i < j) swap(nums, i, j);
}
// 最后把基准和左右指针所在位置的元素交换
swap(nums, left, i);
// 对基准的左右两边重复上述操作
quickSort(nums, left, i - 1);
quickSort(nums, i + 1, right);
}
private static void swap(int[] arr, int i, int j) {
int temp = arr[i];
arr[i] = arr[j];
arr[j] = temp;
}
使用方式
quickSort(nums, 0, nums.length - 1);
注意点
如果选择是第一个数为基准,需要先移动右指针,再移动左指针。
如果先移动左指针,再移动右指针会怎么样?
例如数组是[3, 1, 2, 4]
- 基准为3
- 先移动 left,找到比 3 大的数,即 4,此时 left=3
- 此时 left==right,交换基准和left的位置,交换后为 [4, 1, 2, 3]
此时,基准左边的部分元素大于3,违反了规则。原因是如果先找到比基准大的元素,再和基准交换,就会出现基准左边的数比基准大的情况
如果先移动右指针,再移动左指针呢?
- 基准为3
- 先移动 right,找到比 3 小的数,即 2,此时 right=2
- 移动 left,知道 left==right,交换基准和 left 的位置,交换后为 [2, 1, 3, 4]
满足基准左边的元素都小于基准,基准右边的元素都大于基准。先移动右指针能保证当左右指针相遇时,相遇点的元素一定 ≤ 基准
时间复杂度分析
- 最好情况
O
(
n
log
n
)
:每次划分都能将数组均匀分成两部分(即分区点接近中位数) - 最坏情况
O(n²)
:每次划分极度不平衡(如分区点是最小或最大值,且输入已有序或逆序)
缺点
传统的快速排序是有明显缺点的
- 出现最坏时间复杂度,当输入数组已经有序或近乎有序时,每次选择的基准(pivot)都是最小或最大值,导致分区极度不平衡。此时时间复杂度退化为
O(n²)
。
- 总是选择最左边的元素作为基准(
pivot = nums[left]
),在特定输入(如已排序数组)下性能较差。 - 重复元素处理:当数组中存在大量重复元素时,当前的分区方式可能导致不平衡的分区,因为
nums[j] >= pivot
和nums[i] <= pivot
会让重复元素分布在两侧,可能退化为O(n²)。
- 递归深度:最坏情况下递归深度为
O(n)
,可能导致栈溢出。
Hoare分区方案:优化大量元素
Hoare分区方案的关键特点
- 基准值选择:如选择数组的第一个元素作为基准值(pivot)
- 双指针法:
- 左指针
i
从数组起始位置向右移动 - 右指针
j
从数组末尾向左移动 - 当
i
找到≥pivot的元素,j
找到≤pivot的元素时,交换它们
- 左指针
- 终止条件:当两个指针相遇或交叉时停止
- 分区结果:
- 返回的
j
是分区点的位置 - 分区后,左侧子数组所有元素≤pivot
- 右侧子数组所有元素≥pivot
- 返回的
- 递归排序:分区后对左右两个子数组分别递归调用快速排序
do-while
循环确保i
和j
至少移动一次,防止死循环
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[left];
// 初始化左右指针
int i = left - 1, j = right + 1;
while (true) {
// 从左向右找第一个大于等于pivot的元素
do {
i++;
} while (i < right && nums[i] < pivot);
// 从右向左找第一个小于等于pivot的元素
do {
j--;
} while (j > left && nums[j] > pivot);
// 如果指针相遇或交叉,退出循环
if (i >= j) {
break;
}
swap(nums, i, j);
}
// 递归处理左、右分区
quickSort(nums, left, j);
quickSort(nums, j + 1, right);
}
private void swap(int[] nums, int i, int j) {
int temp = nums[i];
nums[i] = nums[j];
nums[j] = temp;
}
}
与 Lomuto 分区方案的比较
- 效率更高:Hoare分区通常比Lomuto分区少做3倍的交换操作
- 分区更均衡:在处理包含多个相同元素的数组时表现更好,如下图所示:Lomuto 分出来的右数组很大,左数组大小为0。Lomuto 分出来的左数组和右数组一样大
- 分区点:分区后基准值的位置是
j
(不是i
),且左右子数组是[low, j]
和[j+1, high]
随机 pivot 优化
快速排序的性能高度依赖于 分区(partition) 的效果。如果每次分区都能将数组分成大致相等的两部分,那么递归深度就是 O(log n),总时间复杂度是 O(n log n)。但如果分区不平衡(比如每次只能分成一个元素和剩余部分),递归深度会变成 O(n),导致时间复杂度退化到 O(n²)。
Hoare分区可以优化大量重复元素的案例,但是对于已经升序或降序排序的案例,效率还是很低
最坏情况分析:固定选择第一个元素作为 pivot(如
pivot = nums[low]
)
如果输入数组已经有序(升序或降序),每次分区都会产生 极不平衡 的分区,效率极低,可能导致栈溢出
- 左边子数组长度为
0
(或1
),右边子数组长度为n-1
(或n-2
)。 - 递归树退化为 链表,递归深度为 O(n),时间复杂度 O(n²)。
[1, 2, 3, 4, 5] → pivot=1 → 左边[],右边[2,3,4,5]
[2, 3, 4, 5] → pivot=2 → 左边[],右边[3,4,5]
...
随机选择一个元素作为 pivot
- 降低 最坏情况(已排序数组)出现的概率,即使输入是有序的,由于 pivot 随机选择,相当于输入无序,分区仍然较均衡。
class Solution {
Random r = new Random();
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;
// 随机位置获取一个pivot
int pivot = nums[left + r.nextInt(right - left)];
// 初始化左右指针
int i = left - 1, j = right + 1;
while (true) {
// 从左向右找第一个大于等于pivot的元素
do {
i++;
} while (i < right && nums[i] < pivot);
// 从右向左找第一个小于等于pivot的元素
do {
j--;
} while (j > left && nums[j] > pivot);
// 如果指针相遇或交叉,退出循环
if (i >= j) {
break;
}
swap(nums, i, j);
}
// 递归处理左、右分区
quickSort(nums, left, j);
quickSort(nums, j + 1, right);
}
private void swap(int[] nums, int i, int j) {
int temp = nums[i];
nums[i] = nums[j];
nums[j] = temp;
}
}
JDK 排序算法
如果用 JDK 默认排序,会发现它的排序效率极高
class Solution {
public int[] sortArray(int[] nums) {
Arrays.sort(nums);
return nums;
}
}
JDK 也使用了快排,但是做了极致的优化,通过源码可知,JDK 使用了双轴快速排序,有兴趣的朋友可以去学习一下源码
除此之外,还针对小数组使用插入排序