快速排序在时间复杂度为为O(N*logN)的几种排序方法中效率较高,主要采用分治法的排序思想。
快速排序的最坏运行情况是 O(n²),比如说顺序数列的快排。但它的平摊期望时间是 O(nlogn),且 O(nlogn) 记号中隐含的常数因子很小,比复杂度稳定等于 O(nlogn) 的归并排序要小很多。所以,对绝大多数顺序性较弱的随机数列而言,快速排序总是优于归并排序。
由于关键词的比较和交换是跳跃的,所以快速排序是一种不稳定的排序。
1、算法步骤
1)先从待排数列中找到一个数作为基准数(主元);
2)分区过程,将大于基准数的数全部放在它的右边,将小于基准数的数全部放在它的左边;
3)再对左右区间继续执行上述1)、2)步骤,直到区间只有一个数。
2、动图演示
3、时间、空间复杂度分析
时间复杂度:最优情况下为(分区每次都是均匀的一分为二)
最差情况下,待排数列是一个正序或逆序,时间复杂度为
空间复杂度:主要是递归造成栈空间的使用,最好情况下为(递归树深度为);最差情况下进行N-1次递归,空间复杂度为O(N)。平均情况,空间复杂度也为。
4、C++代码实现
快排算法的核心是分区,分区函数partition()的实现是快排算法理解和实现的关键,也是难点。
看到分区过程中比较、移动的方法很多,当时理解了也不容易记忆,现在我打算只记忆一种方法,就是挖坑填数法。
初始待排序列:数组下标0-8依次存这些数
0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
50 | 10 | 90 | 30 | 70 | 40 | 80 | 60 | 20 |
初始i = 0,j = 8,取第一个数字50作为基准数(pivot),pivot = Arr[0],将Arr[0]存在pivot中,相当于在Arr[0]上挖个坑,其它的数可以填充过来;
从j开始依次向左找到小于或等于50的数,j = 8满足,将Arr[8]的数取出填到Arr[0]的坑处,Arr[0] = Arr[8] , i++(可以省略);Arr[8]留下一个坑,找其他的数再填;从i开始向右找大于50的数,i = 2满足,将Arr[2]的数取出填到Arr[8]处,Arr[8] = Arr[2] , j--(可省略),Arr[2]处留下一个坑,后面的数继续填。
现在数组变为:(红色位置代表坑的位置,紫色为填过的数)
0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
20 | 10 | 90 | 30 | 70 | 40 | 80 | 60 | 90 |
从j = 8向左找小于或等于50的数,j = 5满足,将Arr[5]的数取出填到Arr[2]处,Arr[5]为坑;从i = 2向右找大于50的数,i = 4满足,将Arr[4]填到Arr[5]的坑里,Arr[4]为坑。
此时数组为:i = 4 , j = 5
0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
20 | 10 | 40 | 30 | 70 | 70 | 80 | 60 | 90 |
从j = 5再向左找,j--后为4,i = j = 4,循环退出;
Arr[4]为上次留下的坑,将pivot填入,Arr[4] = pivot;
数组变为:
0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
20 | 10 | 40 | 30 | 50 | 70 | 80 | 60 | 90 |
对挖坑填数总结:(适用于取第一个数作为基准数,若取别的位置上的数作为基准数,取出基准数保存,可以先将第一个数先填到基准数的坑位,接下来同样可用以下方法分区)
(1)i = left , j = right,取基准数取出保存起来,形成第一个坑Arr[i];
(2)从j开始向左找到小于等于基准数的数字,挖出此数填入上述形成的坑Arr[i];
(3)从i开始向右找到大于基准数的数字,挖出此数填入(2)中形成的坑中;
(4)重复(2)、(3)步骤,直到i = j,将基准数填入Arr[i]中。
分区函数实现后,再实现分治代码,算法完成。
#include<iostream>
#include<time.h>
using namespace std;
template<typename T>
int Partition(T Arr[],int left, int right)
{
int pivot = Arr[left];
while(left<right)
{
while(left<right&&Arr[right]>=pivot)//从右向左找到小于等于pivot的数
right--;
Arr[left] = Arr[right];
while(left<right&&Arr[left]<= pivot)//从左往右找到大于等于pivot的数
left++;
Arr[right] = Arr[left];
}
Arr[left] = pivot;
return left;
}
template<typename T>
void Qsort(T Arr[],int left, int right)
{
int pivot;
if(left<right)
{
pivot = Partition(Arr,left,right);
Qsort(Arr,left,pivot-1);
Qsort(Arr,pivot+1,right);
}
}
int main()
{
int arr[]={50,10,90,30,70,40,80,60,20};
int len = sizeof(arr)/sizeof(arr[0]);
Qsort(arr,0,len-1);
for(int i=0;i<len;i++)
cout<<arr[i]<<" ";
return 0;
}
5、快排算法的优化
(1)优化选取枢轴
显然取第一个数字作为基准数是不合理的,如果序列本身接近于正序或逆序,时间复杂度将很高,为;
优化:1)取序列中随机位置的一个数作为基准数,可改善上述情况,但仍未概率事件,且随机函数开销较大,增加程序的运行时间。
template<typename T>
int Partition(T Arr[],int left, int right)
{
int index = (rand()%(right - left + 1))+left;
int pivot = Arr[index];
Arr[index] = Arr[left];
while(left<right)
{
while(left<right&&Arr[right]>=pivot)
right--;
Arr[left] = Arr[right];
while(left<right&&Arr[left]<= pivot)
left++;
Arr[right] = Arr[left];
}
Arr[left] = pivot;
return left;
}
2)取中位数法(可3、5或者7个数中取),可以较为合理的取到序列的中间值作为基准值,每次接近等分的分割区间,达到最优的时间复杂度。
三数法:取左端、中间、右端三个数中的中位数。
template<typename T>
int Partition(T Arr[],int left, int right)
{
int mid = (left+right)/2;
if(Arr[left] > Arr[mid])
swap(Arr[left],Arr[mid]);
if(Arr[left]>Arr[right])
swap(Arr[left],Arr[right]);
if(Arr[mid]>Arr[right])
swap(Arr[mid],Arr[right]);
int pivot = Arr[mid];
Arr[mid] = Arr[left];
while(left<right)
{
while(left<right&&Arr[right]>=pivot)
right--;
Arr[left] = Arr[right];
while(left<right&&Arr[left]<= pivot)
left++;
Arr[right] = Arr[left];
}
Arr[left] = pivot;
return left;
}
(2)优化小数组时的排序方案
当数组规模很小的时候,使用快速排序不如简单插入排序效率高(插入排序是简单排序中效率最高的),因为快速排序的递归调用,耗时、消耗额外的空间。
可以增加一个判断,当right-left不大于某个常数时(有资料认为7比较合适,也有认为50的,实际情况中可以调整),就使用插入排序。这样能最大化利用两种排序的优势来完成排序工作。
(3)优化递归操作
对QSort函数实施尾递归优化
void Qsort1(T Arr[],int left, int right)
{
int pivot;
while(left<right)
{
pivot = Partition(Arr,left,right);
Qsort1(Arr,left,pivot-1);
left = pivot+1;//尾递归
}
}
第一次递归以后,left就没用处了,可以pivot+1直接赋值给low。
采用迭代而不是递归的方法,可以缩减堆栈深度,从而提升整体性能。