1. 简介
快速排序是一种二叉树结构的交换排序方法,采用分治策略来对数据进行排序。
基本步骤如下:
- 选择基准值:通常选择序列的第一个或最后一个元素作为基准值。
- 分区操作:重新排列序列,使得所有小于或等于基准值的元素都移到基准的左边,而所有大于基准值的元素都移到基准的右边。这一步完成后,基准值所在的位置就是其最终位置。
- 递归排序:递归地将小于基准值的子序列和大于基准值的子序列再次进行快速排序。
简而言之,就是将待排序集合分割成两个子序列,左子序列中所有元素均小于基准值,右子序列中所有元素均大于基准值。再对左右两子序列分别递归排序。
2. 图解步骤
2.1 通过基准值分区
2.1.1 方法一
目的:将区域分成两部分,小于等于基准值区域和大于基准值区域。
基准值 : base
当前值 : curNum
比基准值小或等的分区(小分区): minPartition
小分区的下一个值 : nextNum
步骤:
① 当前值 <= 基准值,当前值 与 小分区的下一个值 交换,
小分区的范围 向右扩,
当前值 向右移。
② 当前值 > 基准值,当前值向右移。
void partitioning(int[] arr) {
int nextNum = 0;
int curNum = 0;
int len = arr.length;
int base = len - 1;
while(nextNum < len){
if(arr[curNum] <= arr[base]){
// 通过交换,放入小分区,其他的默认大分区
swap(arr, nextNum, curNum);
nextNum++;
curNum++;
}else{
curNum++;
}
}
}
void swap(int[] arr, int l, int r){
int tmp = arr[l];
arr[l] = arr[r];
arr[r] = tmp;
}
2.1.2 方法二(优化)
由于方法一并不是严格意义上的小于基准值的分区,如果基准值有多个,效率会低一点。
将数组分成三部分,小于基准值区域,等于基准值区域和大于基准值区域。这样多个等于基准值的将不参与后续排序。
基准值 : base
当前值 : curNum
比基准值小的分区(小分区) : minPartition
比基准值大的分区(大分区) : maxPartition
小分区的下一个值 : nextMinNum
大分区的下一个值 : nextMaxNum
步骤:
① 当前值 < 基准值,当前值 与 小分区的下一个值 交换,
小分区的范围 向右扩,
当前值 向右移。
② 当前值 > 基准值,当前值 与 大分区的下一个值 交换,
大分区的范围 向左扩,
当前值 不动。
③ 当前值 = 基准值, 当前值向右移。
void partitioning(int[] arr) {
int len = arr.length;
int nextMinNum = 0;
int base = len - 1;
int nextMaxNum = len - 2;
int curNum = 0;
while(curNum < nextMaxNum){
if(arr[curNum] < arr[base]){
// 通过交换,放入小分区
swap(arr, nextMinNum, curNum);
nextNum++;
curNum++;
}else if(arr[curNum] > arr[base]){
// 通过交换,放入大分区
swap(arr, nextMaxNum, curNum);
nextMaxNum--;
}else{
curNum++;
}
}
// 达到终止条件,curNum > nextMaxNum, 即当前值进入大分区
// nextMaxNum和base交换
swap(arr, curNum, base);
}
void swap(int[] arr, int l, int r){
int tmp = arr[l];
arr[l] = arr[r];
arr[r] = tmp;
}
2.2 递归排序
通过方法二对数组快速排序,将数组分成小分区和大分区,再对两部分分别进行快速排序,一直递归到不能再分,结果就是排好序的数组。
public void quickSort(int[] arr){
if (arr == null || arr.length == 0){
return;
}
process(arr, 0, arr.length);
}
// 对数组按方案二递归排序
void process(int[] arr, int l, int r){
if (l >= r){
return;
}
int[] midPartition = partition(arr, l, r);
// 递归小分区
process(arr, l, midPartition[0] - 1);
// 递归大分区
process(arr, midPartition[1] + 1, r);
}
/*
* 通过方法二排序,分组
*
* @Param int[] arr 需排序数组
* @Param int l 左边界
* @Param int r 右边界
* @Return int[2] 返回中间分区的边界
*/
int[] partition(int[] arr, int l, int r){
int nextMinNum = l;
int nextMaxNum = r;
int curNum = l;
while(curNum < nextMaxNum){
if(arr[curNum] < arr[r]){
// 通过交换,放入小分区
swap(arr, nextMinNum, curNum);
nextNum++;
curNum++;
}else if(arr[curNum] > arr[r]){
// 通过交换,放入大分区
swap(arr, nextMaxNum, curNum);
nextMaxNum--;
}else{
curNum++;
}
}
// 达到终止条件,curNum > nextMaxNum, 即当前值进入大分区
// nextMaxNum和base交换
swap(arr, nextMaxNum, r);
return new int[]{nextMinNum, nextMaxNum};
}
void swap(int[] arr, int l, int r){
int tmp = arr[l];
arr[l] = arr[r];
arr[r] = tmp;
}
3. 复杂度分析
快速排序是一种效率较高的排序算法,其时间复杂度在最优情况下是O(nlogn),而在最坏情况下是O(n^2)。以下是具体分析:
- 最优情况:当快速排序的每一次分区操作都能将数组平分为两个几乎相等的部分时,这时的时间复杂度为O(nlogn)。这是因为每一层递归大约处理一半的元素,而递归树的深度为logn,因此总的操作次数是T(n) = 2T(n/2) + O(n),其中T(n/2)是递归调用的时间复杂度,O(n)是划分操作的时间复杂度。
- 平均情况:在随机选择基准值的情况下,快速排序的平均时间复杂度也是O(nlogn)。这是因为虽然每次划分不一定能够完美平分数组,但通过随机化可以减少出现最坏情况的概率,使得算法的性能接近最优情况。
- 最坏情况:当每次分区操作都将数组划分为两个极端不平衡的部分时,例如一个部分包含n-1个元素,另一个部分为空,这时快速排序的时间复杂度会退化成类似于冒泡排序的性能,即为O(n^2)。这种情况通常发生在待排序序列已经是有序或者逆序的情况下。
4. 优缺点
尽管快速排序在很多场景下表现出色,但它也有一些不可避免的缺点。
- 优点
- 效率高:快速排序的平均时间复杂度为O(nlogn),在大多数情况下表现优异。
- 原地排序:不需要额外的存储空间,只需少量的栈空间用于递归。
- 适用于大数据:在大规模数据集上表现良好,处理大数组时高效。
- 并行化能力:可以并行处理子数组,进一步优化性能。
- 缺点
- 最坏情况性能差:当输入数组已经部分有序或完全有序时,时间复杂度会退化到O(n^2),并且性能显著下降。
- 不稳定排序:不能保证相等元素的相对顺序,这可能会影响某些特定应用。
- 递归实现可能导致栈溢出:若递归层次太深,可能会导致栈溢出,特别是在处理非常大的数据集时。
- 小数组效率低:对于小数组,快速排序的性能可能不如其他简单排序算法,如插入排序。
- 依赖数据分布:若数据分布不均,对算法性能有一定影响。
快排的优点有很多,但缺点也是需要注意的,则主要体现在最坏情况下性能较差、不稳定排序、可能导致栈溢出等问题。所以选用算法的时候需要考虑到是否合适于所要应对的场景,不能被其优点一叶障目。
5. 总结
快速排序算法是一种高效的排序算法,其基本思想是采用“分治”策略。
快速排序算法通过选择一个基准元素,将待排序序列划分成小于和大于基准元素的两部分,并递归地对这两部分进行排序。该算法在平均情况下具有O(nlogn)的时间复杂度,但在最坏情况下会退化到O(n^2)。可以尝试采用随机选择基准元素抑或使用插入排序处理小规模数据等方法来优化算法性能。