在上一篇博文中学习了时间复杂度为 O(n*logn) 的归并算法,介绍其两种实现方式——自顶向下和自底向上,不同于O(n^2)排序算法,O(n *logn)在处理百万级数据量上有明显的性能优势。而此篇文章将介绍具有代表性O(n *logn)的另一种算法——快速排序,其性能总体还会优于归并排序,但是在最坏情况下时间复杂度会退化为O(n^2)!继而出现了对快速排序的系列优化并衍生出新的实现方式,来一探究竟。
此篇博文涉及的知识点如下:
挖掘算法中的数据结构(一):选择、插入、冒泡、希尔排序 及 O(n^2)排序算法思考
挖掘算法中的数据结构(二):O(n*logn)排序算法之 归并排序(自顶向下、自底向上) 及 算法优化
(1)整体过程
首先来回顾一下上篇博文讲解的归并排序重点思想:不论数组排序内容,直接一分为二再逐渐归并排序。而快速排序:
以下数组{4,6,2,3,1,5,7}为例,选择4为基点,将4放到合适位置,使得4之前的所以元素小于4,后面的所有元素大于4。
(2)Partition过程
对于快速排序过程而言,最重要的是将数组分成两个部分,使得基点元素在分界点。此过程为快速排序的核心,通常称为 Partition,以下动画演示了此过程:
接下来讨论 i 这个元素(即当前访问的元素 e)如何变化才能使整个数组保证 v 的左右两部分刚好代表小于、大于v的位于两侧:
i ++
,继续判断下一个元素。j++
,代表 橘黄色部分元素新增了一个,再进行i ++
,继续判断下一个元素。最终结果
经过以上部分对数组进行遍历,完成后就是上图所示,第一个元素是 v ,橘黄色部分小于 v ,紫色部分大于 v ,最后只需要将l下标和j 下标所指的元素交换位置即可。
整个数组被分成小于v 和大于 v的两部分,而v也放到了合适的位置,如下图所示:
以上就是整个Partition的过程,理解透彻后可以轻松实现快速排序的逻辑代码。
(1)quickSort函数
目的:主函数中调用此方法即可(暴露给上层调用)
在函数quickSort
中定义另一个函数__quickSort
,取名代表它其实是一个私有的函数,被quickSort
所调用,对于用户而言只需调用quickSort
即可。
(2)__quickSort函数
目的:使用递归来进行快速排序,对arr[l…r]的范围进行快速排序
__partition
对arr数组从l 到r 进行partition操作,此函数会返回一个索引值,该值就是arr数组被partition后分成左右两部分的中间分界点下标。(3)__partition函数
目的:对arr[l…r]部分进行partition操作,返回p, 使得arr[l…p-1] < arr[p] ; arr[p+1…r] > arr[p]
此函数需要进行的逻辑操作在上一点partition过程思想中已详细讲解,来查看具体实现:
l + 1
开始遍历整个数组,让整个数组在此循环之后分成两个部分,即arr[l+1…j] < v ; arr[j+1…i) > v。判断当前元素是否大于v j-l
代表小于v的元素总数,j+1
相当于小于v的元素总数新增一个。查看以下代码:
// 对arr[l...r]部分进行partition操作
// 返回p, 使得arr[l...p-1] < arr[p] ; arr[p+1...r] > arr[p]
template <typename T>
int __partition(T arr[], int l, int r){
T v = arr[l];
int j = l; // arr[l+1...j] < v ; arr[j+1...i) > v
for( int i = l + 1 ; i <= r ; i ++ )
if( arr[i] < v ){
j ++;
swap( arr[j] , arr[i] );
}
swap( arr[l] , arr[j]);
return j;
}
// 对arr[l...r]部分进行快速排序
template <typename T>
void __quickSort(T arr[], int l, int r){
if( l >= r )
return;
int p = __partition(arr, l, r);
__quickSort(arr, l, p-1 );
__quickSort(arr, p+1, r);
}
template <typename T>
void quickSort(T arr[], int n){
__quickSort(arr, 0, n-1);
}
int main() {
int n = 100000;
// 测试1 一般性测试
cout<<"Test for random array, size = "<", random range [0, "<"]"<int* arr1 = SortTestHelper::generateRandomArray(n,0,n);
int* arr2 = SortTestHelper::copyIntArray(arr1,n);
SortTestHelper::testSort("Merge Sort", mergeSort, arr1, n);
SortTestHelper::testSort("Quick Sort", quickSort, arr2, n);
delete[] arr1;
delete[] arr2;
cout<return 0;
}
总结
两种排序算法虽然都是O(nlogn)级别的, 但是快速排序(Quick Sort)算法有常数级的优势,比归并排序(Merge Sort) 快,即使已经对 * 归并排序 * 进行了优化。
熟悉套路的都知道接下来对快速排序进行代码优化,这里主要优化两个部分:
(1)优化一:
在详细学习了上篇博文归并排序讲解后,此点优化并不陌生,那就是高级的排序算法在底层时可使用插入排序(Insertion Sort)优化快速排序——递归到底优化:__quickSort
函数中的第一个判断是当只剩下一个元素时才返回,事实上当方法递归到元素较少时,可使用插入排序来提高性能,由以下两个原因:
所以优化一:函数一开始判断当递归到底只剩下一定值时(可自行修改,不要过大,我这里设定为15)时,剩下的数组采用插入算法进行排序
(2)优化二
此优化才是快速排序的重点问题,首先引出其问题再做一组测试用例,就是归并排序和快速排序对近乎有序的数组进行排序(测试代码不再粘贴,自行查看源码)
结果如下:
查看第二个测试用例结果,发现两种排序在对近乎有序的数组情况时,归并排序很快得出了结果,但是快速排序迟迟未出现结果(最后需要几十秒)!
原因分析
归并排序之所以是一个O(n*logn)的算法,在每次排序的时候都将数组一分为二,这样依次类推下去,整个层数是 logn层,每一层排序消耗O(n)时间,最后时间复杂度为O(n*logn),如下图所示:
对于快速排序而言,也是这样将整个数据一分为二,层层递进下去,只是稍有不同的是需要找到一个标志点,将此点左、右部分的数组进行分别排序。这样快速排序与归并排序产生不同:归并排序每次都是平均地将整个数组一分为二,而对于快速排序就无法保证,分出来的子数组可能是一大一小情况,进而再次递归时,情况会更严重。(如下图所示:)
快速排序最差情况,退化为O(n^2)
因此快速排序生成的递归树的平衡度比归并排序要差,并且并不难保证树的高度是logn,甚至于高过logn。最差的情况就是当整个数组近乎有序的情况,生成的递归树如下图所示,每次作为标志点的第一个元素左部分并无小于它的元素(因为是近乎有序数组),从而导致递归树层级很高,到达n层,每一层又消耗O(n),此时最终时间复杂度为O(n^2)
解决优化
以上也就是为何快速排序在面对近乎有序数组的情况下性能慢的原因,而解决方法正是对快速排序的第二个优化:在原有快速排序中,是固定使用最左侧元素作为标志元素,而希望是尽可能地使用整个数组中间的元素,也许不能准确地定位此中间元素。
优化二:其实只要随机使用一个标志元素即可,此时快速排序的时间复杂度期望值是O(n*logn),此时退化成O(n^2)的概率是很小的,因为正好选到最小值作为标志元素的概率是很小的。(第一次选中最小值作为标志点的概率是1/n,第二次是1/(n-1),依次类推,最后相乘得到结果近乎于0)
代码如下:
// 对arr[l...r]部分进行partition操作
// 返回p, 使得arr[l...p-1] < arr[p] ; arr[p+1...r] > arr[p]
template <typename T>
int _partition(T arr[], int l, int r){
// ☆☆☆☆☆ 随机在arr[l...r]的范围中, 选择一个数值作为标定点pivot
swap( arr[l] , arr[rand()%(r-l+1)+l] );
T v = arr[l];
int j = l;
for( int i = l + 1 ; i <= r ; i ++ )
if( arr[i] < v ){
j ++;
swap( arr[j] , arr[i] );
}
swap( arr[l] , arr[j]);
return j;
}
// 对arr[l...r]部分进行快速排序
template <typename T>
void _quickSort(T arr[], int l, int r){
// 对于小规模数组, 使用插入排序进行优化
if( r - l <= 15 ){
insertionSort(arr,l,r);
return;
}
int p = _partition(arr, l, r);
_quickSort(arr, l, p-1 );
_quickSort(arr, p+1, r);
}
template <typename T>
void quickSort(T arr[], int n){
//设置随机种子
srand(time(NULL));
_quickSort(arr, 0, n-1);
}
优化后的测试
最后在优化后发现快速排序的性能已经极高地提升起来了,虽然没有快过归并排序,因为归并排序中的第二个优化,在已经排好序的数组中不用再次递归调用了。但也只是在近乎有序数组的情况下,这里快速排序结合随机算法进行了优化,在大部分情况下性能还是更优的。(此时快速排序的时间复杂度在最坏情况下仍是O(n^2),但是此概率是极其极其低,近乎为0)
(1)包含大量相同元素的数组测试
经过上面的优化过程,快速排序算法已是非常稳健了,但是它仍然存在一些问题:再测试一组特殊实例情况,对存在包含大量相同元素的数组(0~10范围内50万个数)进行排序,结果如下。
此时, 对于含有大量相同元素的数组, 快速排序算法再次退化成了O(n^2)级别的算法,为何?
(2)分析
上图部分并不陌生,是快速排序的核心部分,即Partition过程,判断当前元素e是否大于v,根据结果放入橘黄色部分或紫色部分。但是这里有一个隐患,我们并没有判断等于的情况!
第一反应你可能觉得这很好解决,至于要把相等的部分放入左、右任何一部分即可,数组中含有大量重复元素,这样会把数组分成极度不平衡的两个部分,在这种情况下快速排序会退化成O(n^2),结果如下图所示:
意识到以上问题后,主要需要解决的还是Partition过程,于是我们换一个思路来实现Partition过程,查看以下动画:
之前快速排序中的Partition过程是将小于v 和大于 v 的两部分放在一起,然后从左到右逐渐遍历整个数组。现在将这两部分放到数组的两端,下标i、j分别进行扫码:
以上两个下标进行扫码时,有一种情况没有写,其实就是当下标 i扫描的元素大于v,下标 j扫描的元素小于v时,将两个下标所指的元素值交换即可!
最后,当下标i 等于下标j 时,扫描结束。将l 和 j下标所代表的元素交换位置即可。
最终结果:
以上就是Partition后的结果,查看此图你会发现怎么橘黄色部分和紫色部分都含有等于v的元素,这范围设置的是否不对?其实不然!此种双路快速排序法与之前最大的区别就是:把等于v的元素分散到左右两部分。当下标i、j指向的元素即使与v相等,也要互相交换位置。这样可避免大量等于v的元素集中在一部分,正因如此,这样的算法面临大量重复元素值的情况下,也可以很好的平衡两部分。
这里只修改partition函数即可,代码如下:
【只修改partition函数即可】
// 双路快速排序的partition
// 返回p, 使得arr[l...p-1] < arr[p] ; arr[p+1...r] > arr[p]
template
int _partition2(T arr[], int l, int r){
// 随机在arr[l...r]的范围中, 选择一个数值作为标定点pivot
swap( arr[l] , arr[rand()%(r-l+1)+l] );
T v = arr[l];
// arr[l+1...i) <= v; arr(j...r] >= v
int i = l+1, j = r;
while( true ){
// 注意这里的边界, arr[i] < v, 不能是arr[i] <= v,因为会导致两部分数量不平衡
while( i <= r && arr[i] < v )
i ++;
// 注意这里的边界, arr[j] > v, 不能是arr[j] >= v
while( j >= l+1 && arr[j] > v )
j --;
if( i > j )
break;
swap( arr[i] , arr[j] );
i ++;
j --;
}
swap( arr[l] , arr[j]);
return j;
}
注意
以上代码中的重点,也就是双路快速排序法的重点,就是判断下标i、j增减的条件边界,之前反复强调的重点就是将重复值平均分配到数组中的两个部分,所以边界判断只能是< 或 >,而不是<= 或>=。
下面举个例子来体会,数组 1,0,0, …, 0, 0:
因为连续出现相等的情况,第一种会交换i和j的值,而第二种方式则会将连续出现的这些值归为其中一方,使得两棵子树不平衡,这样会导致O(n^2)出现。
再次进行测试(测试代码见源码):
从结果得知 快速排序与归并排序比较中重新恢复到王者位置!
以上在面临数组中包含大量重复值排序时,对会沦为O(n^2)的排序算法进行优化,从而避免并更好的提高了其性能,但其实针对快速排序算法还有一个更经典的方法 —– 三路快速排序法
在之前进行快速排序时都是将整个数组分成两部分,即小于v 和大于v(两部分都含有等于v的元素值),而三路快速排序法则是多加了一部分—–等于v,将这一部分单独提出来。查看以下动画,这样划分之后,在处理等于v的元素可不管,而是处理小、大于v的元素即可。
三部分下标划分表示
arr[l+1…lt]
arr[gt…r] > v
arr[lt+1…i-1]==v
下面要处理i下标代表的元素e,分以下3种情况:
最后,当下标i 等于下标gt时,扫描结束。将l 和 lt下标所代表的元素交换位置即可。
这种方式的优点就是不需要对等于v的元素进行重复操作,可以一次性少考虑相同元素
这里只修改partition函数即可,代码如下:
【只修改partition函数即可】
// 递归的三路快速排序算法
template
void __quickSort3Ways(T arr[], int l, int r){
// 对于小规模数组, 使用插入排序进行优化
if( r - l <= 15 ){
insertionSort(arr,l,r);
return;
}
// 随机在arr[l...r]的范围中, 选择一个数值作为标定点pivot
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 ){
swap( arr[i], arr[lt+1]);
i ++;
lt ++;
}
else if( arr[i] > v ){
swap( arr[i], arr[gt-1]);
gt --;
}
else{ // arr[i] == v
i ++;
}
}
swap( arr[l] , arr[lt] );
__quickSort3Ways(arr, l, lt-1);
__quickSort3Ways(arr, gt, r);
}
下面针对以上讲解的3种快速排序方式分别对随机数组、近乎有序数组、包含大量重复元素数组进行测试,结果如下(测试代码见源码):
结论
总体而言,快速排序的性能是要优于归并排序!一般系统级别的快速排序都会选择三路快速排序,因为它在处理包含大量重复元素时,性能极高,即使不是,它的性能也得到保证,不会太差。
这两种O(n*logn)高效的排序算法本身背后隐藏着重要的算法思想:其实归并排序和快速排序都使用了分治算法的基本思想。
分治算法:分而治之,就是将原问题分割成同等结构的子问题,之后将子问题逐一解决后,原问题也得到解决。
虽然都使用了分治算法的基本思想,但是归并排序和快速排序依旧代表了不同的实现:
其实后面介绍的树形结构有关内容也使用了 分治思想,所以不要把一些经典的算法实现和算法设计思想拆开。
关于归并排序和快速排序的第一个衍生问题就是逆序对,例如下图中的数组{8,6,2,3,1,5,7,4},其中{2,3}就是一个顺序对,而{2,1}就是一个逆序对。
一个数组中逆序对的数量最有效的就是衡量这个数组的有序程度,例如{1,2,3,4,5,6,7,8,},这是完全有序数组,逆序对为0;而数组{8,7,6,5,4,3,2,1}完全逆序数组,此时逆序数量达到最大值。
(1)暴力破解
最容易解决的方式就是双重循环,考察每一个数对,判断是否逆序,实现简单,效率低,时间复杂度为O(n^2)
(2)归并排序
要解决此问题此时可以依赖于归并过程,例如以下动画,两个分别排好序的子数组{2,3,6,8,}和{1,4,5,7,}:
// 计算逆序数对的结果以long long返回
// 对于一个大小为N的数组, 其最大的逆序数对个数为 N*(N-1)/2, 非常容易产生整型溢出
// merge函数求出在arr[l...mid]和arr[mid+1...r]有序的基础上, arr[l...r]的逆序数对个数
long long __merge( int arr[], int l, int mid, int r){
int *aux = new int[r-l+1];
for( int i = l ; i <= r ; i ++ )
aux[i-l] = arr[i];
// 初始化逆序数对个数 res = 0
long long res = 0;
// 初始化,i指向左半部分的起始索引位置l;j指向右半部分起始索引位置mid+1
int j = l, k = mid + 1;
for( int i = l ; i <= r ; i ++ ){
if( j > mid ){ // 如果左半部分元素已经全部处理完毕
arr[i] = aux[k-l];
k ++;
}
else if( k > r ){ // 如果右半部分元素已经全部处理完毕
arr[i] = aux[j-l];
j ++;
}
else if( aux[j-l] <= aux[k-l] ){ // 左半部分所指元素 <= 右半部分所指元素
arr[i] = aux[j-l];
j ++;
}
else{ // 右半部分所指元素 < 左半部分所指元素
arr[i] = aux[k-l];
k ++;
// 此时, 因为右半部分k所指的元素小
// 这个元素和左半部分的所有未处理的元素都构成了逆序数对
// 左半部分此时未处理的元素个数为 mid - j + 1
res += (long long)(mid - j + 1);
}
}
delete[] aux;
return res;
}
// 求arr[l..r]范围的逆序数对个数
// 思考: 归并排序的优化可否用于求逆序数对的算法? :)
long long __inversionCount(int arr[], int l, int r){
if( l >= r )
return 0;
int mid = l + (r-l)/2;
// 求出 arr[l...mid] 范围的逆序数
long long res1 = __inversionCount( arr, l, mid);
// 求出 arr[mid+1...r] 范围的逆序数
long long res2 = __inversionCount( arr, mid+1, r);
return res1 + res2 + __merge( arr, l, mid, r);
}
// 递归求arr的逆序数对个数
long long inversionCount(int arr[], int n){
return __inversionCount(arr, 0, n-1);
}
如果这个问题是取数组中的最大值或最小值,那么时间复杂度为O(n),可是现在是取第n大的元素,例如在1000000里取第1000个元素。
此问题的解决思路很简单,就是给整个数组排序再通过下标索引取出元素即可,算法复杂度为O(n*logn),但是!在本篇博文中学习了快速排序后,可使用O(n)时间获取。
快速排序的核心过程
每次找到一个标志点,将此点挪到数组中合适的位置,注意此合适位置恰好是数组中排序好后所处的位置。
示例引导
例如下图示例中的标志点4,最后挪到的位置恰好就是数组最后有序的位置,比如此时我们要获取第6个位置上的元素,那么标志位4之前的元素无需考虑,从后部分处理,继续处理后部分的第二位是谁?
// partition 过程, 和快排的partition一样
// 思考: 双路快排和三路快排的思想能不能用在selection算法中? :)
template <typename T>
int __partition( T arr[], int l, int r ){
int p = rand()%(r-l+1) + l;
swap( arr[l] , arr[p] );
int j = l; //[l+1...j] < p ; [lt+1..i) > p
for( int i = l + 1 ; i <= r ; i ++ )
if( arr[i] < arr[l] )
swap(arr[i], arr[++j]);
swap(arr[l], arr[j]);
return j;
}
// 求出arr[l...r]范围里第k小的数
template <typename T>
int __selection( T arr[], int l, int r, int k ){
if( l == r )
return arr[l];
// partition之后, arr[p]的正确位置就在索引p上
int p = __partition( arr, l, r );
if( k == p ) // 如果 k == p, 直接返回arr[p]
return arr[p];
else if( k < p ) // 如果 k < p, 只需要在arr[l...p-1]中找第k小元素即可
return __selection( arr, l, p-1, k);
else // 如果 k > p, 则需要在arr[p+1...r]中找第k小元素
return __selection( arr, p+1, r, k );
}
// 寻找arr数组中第k小的元素
template <typename T>
int selection(T arr[], int n, int k) {
assert( k >= 0 && k < n );
srand(time(NULL));
return __selection(arr, 0, n - 1, k);
}
所有以上解决算法详细代码请查看liuyubo老师的github:
https://github.com/liuyubobobo/Play-with-Algorithms
下一篇博文将讲解另一个排序算法——堆排序,也是此系列的第一个数据结构—–堆,后续学习你会发现对于堆的使用将远超与求解排序。
tips:
以上算法我在学习过程中结合图示理解更快,画出示意图有助于我们整理思路,再多加注意边界问题即可,重点还是要亲手实践。
若有错误,虚心指教~