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