快排的原理
快排是综合性能最好的算法,也凭借优秀的性能成为常用的排序算法。快排基于分治的策略,每次选择一个基准值,将数组分为小于基准值和大于基准值两部分,然后对基准左右两侧的数组递归调用这个过程,直至找到所有元素的位置。
上面的图片展示了快排的基本过程,但问题是如何找到基准值的位置?最简单的想法是遍历所有元素,但这样就已经排好序了,失去了快排的意义。快排使用两端逼近的思路来找到基准值的位置,相关代码可以这样组织:
vector<int> nums//待排序数组
int pivot; //基准值
int left; //左边的位置
int right; //右边的位置
while (left < right){
while (left<right && nums[left]<=pivot) ++left;
//找到基准值左边比基准值大的元素,交换到右边
if (left<right) swap(nums[left], nums[right]);
while (left<right && nums[right]>=pivot) --right;
//找到基准值右边比基准值大的元素,交换到左边
if (left<right) swap(nums[left], nums[right]);
}
这样就保证基准值的左边都比基准值小,右边都比基准值大了,完整的快排及测试代码如下:
#include <bits/stdc++.h>
using namespace std;
void quicksort(vector<int>& nums, int start, int end){
if(nums.empty()) return;
int pivot = nums[start];
int i = start;
int j = end;
while (i<j){
while (i<j && nums[j]>=pivot)--j;
if (i<j)swap(nums[i], nums[j]);
while (i<j && nums[i]<=pivot)++i;
if (i<j)swap(nums[i],nums[j]);
}
if (start < i-1)quicksort(nums, start, i-1);
if (end > j+1)quicksort(nums, j+1, end);
}
int main(){
vector<int> nums = {2, 5, 9, 3, 4, 1};
quicksort(nums, 0, nums.size()-1);
for(int num:nums){
cout << num << endl;
}
return 0;
}
执行结果如下:
快排复杂度
利用递归树的方式来求解快排的复杂度非常直观,下面是一次快排可能的递归树:
利用递归树求解,时间复杂度 = 递归树的深度 x 每层的遍历次数,显然递归树的深度为logN,每层遍历的次数总数都为n,那么快排的时间复杂度为nlogN。空间复杂度 = 递归树的深度 x 每层额外的空间大小,快排没有额外使用的空间,因此空间复杂度为logN。
快排最差复杂度
在极端情况下,快排的递归树可能不是上面的类似满二叉树的形状。在上面的讨论中,数组每次都被分为两部分,但如果基准的一侧正好没有数字,那么数据就只被分为一部分。
上图就是一种特殊情况,此时类似于二叉树没有左孩子,则二叉树会退化为链表,深度变为n,按照上面介绍的公式可以求到,此时时间复杂度为o(n*n),空间复杂度o(n)。
快排的优化
在快排的复杂度讨论中我们知道,基准如果选择不恰当,会导致二叉树的深度增加,这是不希望看到的,因此快排的优化策略中,通常采用随机的方式选择基准值,或者使用数组开头、正中、结尾三个元素中中间大小的元素作为基准,这样保证基准的两侧都有元素。
如果阅读过STL源码就会发现,sort()
函数中,开始使用快排处理,当分组的元素个数小于某个阈值时,就改用插入排序处理。这样做的原因是当划分的区间在5~20之间时,快排效率不高,很容易出现一侧没有值的情况,而插入排序对于已经近似排好序的数组分组效果很好,因此可以采用插入排序来优化快排。在STL中,如果快排递归深度归多,还会换成堆排序来完成排序。