一直都想写一篇关于快速排序的博客,但迟迟没有动笔,今天趁着这个机会,将我对于快速排序的认识进行一个总结,希望对阅读到这篇博客的你有所帮助。
说起快速排序,我头脑中的第一反应就是partition操作,因为partition操作是整个快速排序的核心所在。那到底什么是partition操作呢?先来看一张图:
partition操作简单地说,就是找到一个元素作为标定点(如图中的5),经过操作后,使得该标定点左边的元素是<5,标定点右边的元素是>=5,经过这次操作,5这个元素已经放置在它应该放的位置上。从上图中不难看出,如果将5左右两边的子数组都分别排好序后,那么整个数组就已经有序了,即,我们所要做的工作就是对5左右两边的子数组分别递归调用快速排序进行排序即可。
那么partition操作具体应该怎么做呢?再来看两张图:
为了方便,我们取第一个元素作为标定点,j指针指向小于v的最后一个元素,指针i指向当前考察的元素e,当e>=v时,直接将i指针右移,而当e小于v时,将i指向的元素和j的下一个位置的元素(即j+1位置的元素)进行一次交换,然后i指针和j指针都分别右移一个单位。以此类推,直到遍历完整个数组,最后只要将j所指向的元素和v元素进行一次交换,就完成了partition操作。
上述是基本的快速排序的方法,下面来探讨几个优化的方法。
优化1:当子数组的元素个数比较少时,采用插入排序替换快速排序。虽然快速排序是O(nlogn)的排序算法,而插入排序是O(n^2)的算法,但是插入排序前面的系数要小于快速排序,即插入排序:O(a*n^2),快速排序:O(b*nlogn),有a < b,则当排序的个数,即n较小时,插入排序反而比快速排序要快。
优化2:当遇到基本有序的数组时,使用上述的快排会使时间复杂度退化为O(n^2),原因如下图所示:
由于我们只是选取最左边的元素作为标定点,使得当数组基本有序时,递归的层数从原来的logn层退化为n层,而每层的partition操作又需要O(n)的时间复杂度,所以总的时间复杂度退化为O(n^n),解决方法是,随机化选取标定点,这样即使遇到基本有序的数组也不太可能退化为O(n^2)。
下面是增加了两个优化方案后的快速排序的代码:
//返回位置p,使得数组中在p之前的数都<arr[p],在P之后的数都>arr[p]
template<typename T>
int __partition(T arr[], int l, int r)
{
//随机选取标定点
swap(arr[rand()%(r-l+1)+l], arr[l]);
T v = arr[l];
int j = l;
for(int i = l+1; i <= r; i++)
{
if(arr[i] < v)
{
swap(arr[j+1], arr[i]);
j++;
}
}
//最后交换v和指针j所指向的元素
swap(arr[j], arr[l]);
//将j的位置返回
return j;
}
template<typename T>
void __quickSort(T arr[], int l, int r)
{
//当元素格式小于等于15时,使用插入排序替换快速排序
if( r - l <= 15 ){
insertionSort(arr,l,r);
return;
}
//设置时间种子,为随机化选取标定点做准备
srand(time(NULL));
//得到标定点的位置
int p = __partition(arr, l, r);
//对标定点左边的子数组进行排序
__quickSort(arr, l, p-1);
//对标定点右边的子数组进行排序
__quickSort(arr, p+1, r);
}
上面的第二种优化方法虽然在数组基本有序时能够摆脱退化为O(n^2)的命运,但是还有一种常见的情况会让我们上面的快排退化为O(n^2),那就是在数组中包含大量重复元素时。接下来介绍一种十分高大上的优化方法,能够有效解决大量重复元素的问题,这种方法被广泛地应用,例如java标准库中的sort()方法就是使用了这种优化后的快排。这种方法称为三路快速排序,先来看图:
使用我们之前的快排,在包含大量重复元素时,partition操作会将数组分为两个大小极度不平衡的两部分,对右边那个大块头进行递归调用的话,调用的层数接近与n层,这便会使得时间复杂度退化为O(n^2),解决方法是下面的三路快排,将数组分为小于v,等于v和大于v三部分,对于中间等于v的部分,因为它们已经在正确的位置上,下一次递归将不对这部分进行操作,这就使得待处理的数据大大减少,如下图所示:
指针lt指向小于v的最后一个元素,指针i指向当前正在考察的元素e,指针gt指向大于v的最后一个元素,大于v部分从右向左扩展。当e==v时,指针i直接右移,当e小于v时,i指向的元素和lt+1指向的元素进行交换,并且i指针和lt指针都向右移动一个单位。当e大于v时,将i指向的元素和gt-1指向的元素进行交换,交换后,gt指针向左移动一个单位,i指针不需要动。上述就是整个三路快排的基本操作,经过这个操作后,只需要对小于v部分和大于v部分再递归调用快排即可。下面是三路快排的c++代码实现:
template<typename T>
void __quickSort3Ways( T arr[], int l, int r ){
//当元素个数小于等于15时,使用插入排序替代快排
if ( r - l <= 15 ){
insertionSort( arr, l , r );
return;
}
//随机化选取标定点
swap(arr[l],arr[rand()%(r-l+1)+l]);
T v = arr[l];
int lt = l;//arr[l+1,lt] < v
int gt = r + 1;//arr[gt,r] > v
int i = l + 1;//arr[lt+1,i) == v
while( i < gt ){
if( arr[i] == v ){
i++;
}
else if( arr[i] > v ){
swap( arr[i] , arr[gt-1] );
gt--;
}
else{
swap( arr[lt+1] , arr[i] );
i++;
lt++;
}
}
swap( arr[lt], arr[l] );
//对小于v部分进行排序
__quickSort3Ways( arr , l , lt - 1 );
//对大于v部分进行排序
__quickSort3Ways( arr , gt , r );
}
template<typename T>
void quickSort3Ways( T arr[] , int n ){
//设置时间种子
srand(time(NULL));
__quickSort3Ways( arr, 0 , n - 1 );
}
三路快排是一种比较优秀的优化快速排序,很多系统级别的排序都是使用三路快排,掌握了三路快排,相信你已经对快速排序算法有了比较深入的理解。