快速排序的深入优化探讨
一. 快速排序
快速排序(quick sort)是一种基于分治策略的排序算法,运行高效,应用广泛。
快速排序的核心操作是“哨兵划分”,其目标是:选择数组中的某个元素作为“基准数”,将所有小于基准数的元素移到其左侧,而大于基准数的元素移到其右侧。
- 选取数组最左端元素作为基准数,初始化两个指针 i 和 j 分别指向数组的两端。
- 设置一个循环,在每轮中使用 i(j)分别寻找第一个比基准数大(小)的元素,然后交换这两个元素。
- 循环执行步骤 2. ,直到 i 和 j 相遇时停止,最后将基准数交换至两个子数组的分界线。
哨兵划分完成后,原数组被划分成三部分:左子数组、基准数、右子数组,且满足“左子数组任意元素 < 基准数 < 右子数组任意元素”。因此,我们接下来只需对这两个子数组进行排序。
/* 哨兵划分 */
int partition(vector<int> &nums, int left, int right) {// 以 nums[left] 为基准数int i = left, j = right;while (i < j) {while (i < j && nums[j] >= nums[left])j--; // 从右向左找首个小于基准数的元素while (i < j && nums[i] <= nums[left])i++; // 从左向右找首个大于基准数的元素swap(nums[i], nums[j]); // 交换这两个元素}swap(nums[i], nums[left]); // 将基准数交换至两子数组的分界线return i; // 返回基准数的索引
}
这是一次划分,然后怎么将整个数组进行排序?
解决方法:持续递归,直至子数组长度为 1 时终止,从而完成整个数组的排序
/* 快速排序 */
void quickSort(vector<int> &nums, int left, int right) {// 子数组长度为 1 时终止递归if (left >= right)return;// 哨兵划分int keyi = partition(nums, left, right);// 递归左子数组、右子数组quickSort(nums, left, keyi - 1);quickSort(nums, keyi + 1, right);
}
二. 快排性能的关键点分析:
决定快排性能的关键点是每次单趟排序后,key对数组的分割,如果每次选key基本⼆分居中,那么快排的递归树就是颗均匀的满⼆叉树,性能最佳。但是实践中虽然不可能每次都是⼆分居中,但是性能也还是可控的。但是如果出现每次选到最⼩值/最⼤值,划分为0个和N-1的⼦问题时,时间复杂度为O(N^2),数组序列有序时就会出现这样的问题,我们前⾯已经⽤三数取中或者随机选key解决了这个问题,也就是说我们解决了绝⼤多数的问题,但是现在还是有⼀些场景没解决(数组中有⼤量重复数据时),类似⼀下代码。
1 // 数组中有多个跟key相等的值
2 int a[] = { 6,1,7,6,6,6,4,9 };
3 int a[] = { 3,2,3,3,3,3,2,3 };
4
5 // 数组中全是相同的值
6 int a[] = { 2,2,2,2,2,2,2,2 };
以下是《算法导论》书籍中给出的hoare和lomuto给出的快排的单趟排序的伪代码
三路划分算法思想讲解:
当⾯对有⼤量跟key相同的值时,三路划分的核⼼思想有点类似hoare的左右指针和lomuto的前后指针的结合。核⼼思想是把数组中的数据分为三段【⽐key⼩的值】 【跟key相等的值】【⽐key⼤的值】,所以叫做三路划分算法。结合下图,理解⼀下实现思想:
- key默认取left位置的值。
- left指向区间最左边,right指向区间最后边,cur指向left+1位置。
- cur遇到⽐key⼩的值后跟left位置交换,换到左边,left++,cur++。
- cur遇到⽐key⼤的值后跟right位置交换,换到右边,right–。
- cur遇到跟key相等的值后,cur++。
- 直到cur > right结束
hoare和lomuto和三路划分单趟排序代码分析:
数组中有⼤量重复数据时,快排单趟选key划分效果对象:
#include<stdio.h>#include<stdlib.h>#include<time.h>#include<string.h>void PrintArray(int* a, int n)
{
for (int i = 0; i < n; ++i)
{printf("%d ", a[i]);}printf("\n");}void Swap(int* p1, int* p2){int tmp = *p1;*p1 = *p2;*p2 = tmp;}// hoare// [left, right]int PartSort1(int* a, int left, int right){int keyi = left;++left;while (left <= right)//left和right相遇的位置的值⽐基准值要⼤{//right找到⽐基准值⼩或等while (left <= right && a[right] > a[keyi]){right--;}//left找到⽐基准值⼤或等while (left <= right && a[left] < a[keyi]){left++;}//right leftif (left <= right){Swap(&a[left++], &a[right--]);}}//right keyi交换Swap(&a[keyi], &a[right]);return right;}// 前后指针int PartSort2(int* a, int left, int right)
{int prev = left;int cur = left + 1;int keyi = left;while (cur <= right){if (a[cur] < a[keyi] && ++prev != cur){Swap(&a[prev], &a[cur]);}++cur;}Swap(&a[prev], &a[keyi]);keyi = prev;return keyi;}typedef struct{int leftKeyi;int rightKeyi;}KeyWayIndex;// 三路划分KeyWayIndex PartSort3Way(int* a, int left, int right){int key = a[left];// left和right指向就是跟key相等的区间// [开始, left-1][left, right][right+1, 结束]int cur = left + 1;while (cur <= right){// 1、cur遇到⽐key⼩,⼩的换到左边,同时把key换到中间位置// 2、cur遇到⽐key⼤,⼤的换到右边if (a[cur] < key){Swap(&a[cur], &a[left]);++cur;++left;}else if (a[cur] > key)
{Swap(&a[cur], &a[right]);--right;}else{++cur;}}KeyWayIndex kwi;kwi.leftKeyi = left;kwi.rightKeyi = right;
return kwi;}void TestPartSort1(){int a1[] = { 6,1,7,6,6,6,4,9 };int a2[] = { 3,2,3,3,3,3,2,3 };int a3[] = { 2,2,2,2,2,2,2,2 };PrintArray(a1, sizeof(a1) / sizeof(int));int keyi1 = PartSort1(a1, 0, sizeof(a1) / sizeof(int) - 1);PrintArray(a1, sizeof(a1) / sizeof(int));printf("hoare keyi:%d\n\n", keyi1);PrintArray(a2, sizeof(a2) / sizeof(int));int keyi2 = PartSort1(a2, 0, sizeof(a2) / sizeof(int) - 1);PrintArray(a2, sizeof(a2) / sizeof(int));printf("hoare keyi:%d\n\n", keyi2);PrintArray(a3, sizeof(a3) / sizeof(int));int keyi3 = PartSort1(a3, 0, sizeof(a3) / sizeof(int) - 1);PrintArray(a3, sizeof(a3) / sizeof(int));printf("hoare keyi:%d\n\n", keyi3);}void TestPartSort2(){int a1[] = { 6,1,7,6,6,6,4,9 };int a2[] = { 3,2,3,3,3,3,2,3 };int a3[] = { 2,2,2,2,2,2,2,2 };PrintArray(a1, sizeof(a1) / sizeof(int));int keyi1 = PartSort2(a1, 0, sizeof(a1) / sizeof(int) - 1);PrintArray(a1, sizeof(a1) / sizeof(int));printf("前后指针 keyi:%d\n\n", keyi1);PrintArray(a2, sizeof(a2) / sizeof(int));int keyi2 = PartSort2(a2, 0, sizeof(a2) / sizeof(int) - 1);PrintArray(a2, sizeof(a2) / sizeof(int));printf("前后指针 keyi:%d\n\n", keyi2);PrintArray(a3, sizeof(a3) / sizeof(int));int keyi3 = PartSort2(a3, 0, sizeof(a3) / sizeof(int) - 1);PrintArray(a3, sizeof(a3) / sizeof(int));printf("前后指针 keyi:%d\n\n", keyi3);}void TestPartSort3(){//int a0[] = { 6,1,2,7,9,3,4,5,10,4 };int a1[] = { 6,1,7,6,6,6,4,9 };int a2[] = { 3,2,3,3,3,3,2,3 };int a3[] = { 2,2,2,2,2,2,2,2 };PrintArray(a1, sizeof(a1) / sizeof(int));KeyWayIndex kwi1 = PartSort3Way(a1, 0, sizeof(a1) / sizeof(int) - 1);PrintArray(a1, sizeof(a1) / sizeof(int));printf("3Way keyi:%d,%d\n\n", kwi1.leftKeyi, kwi1.rightKeyi);PrintArray(a2, sizeof(a2) / sizeof(int));KeyWayIndex kwi2 = PartSort3Way(a2, 0, sizeof(a2) / sizeof(int) - 1);PrintArray(a2, sizeof(a2) / sizeof(int));printf("3Way keyi:%d,%d\n\n", kwi2.leftKeyi, kwi2.rightKeyi);PrintArray(a3, sizeof(a3) / sizeof(int));
KeyWayIndex kwi3 = PartSort3Way(a3, 0, sizeof(a3) / sizeof(int) - 1);
PrintArray(a3, sizeof(a3) / sizeof(int));printf("3Way keyi:%d,%d\n\n", kwi3.leftKeyi, kwi3.rightKeyi);}int main(){TestPartSort1();TestPartSort2();TestPartSort3();return 0;}
三种快排单趟排序运⾏结果分析:
从下⾯的运⾏结果分析,lomuto的前后指针法,⾯对key有⼤量重复时,lomuto划分不是很理想,性能退化,hoare相对还不错,但是⼤量重复时,没有三路划分快。三路划分算法,把跟key相等的值都划分到了中间,可以很好的解决这⾥的问题
6 1 7 6 6 6 4 96 1 4 6 6 6 7 9hoare keyi:33 2 3 3 3 3 2 33 2 3 2 3 3 3 3hoare keyi:42 2 2 2 2 2 2 22 2 2 2 2 2 2 2hoare keyi:36 1 7 6 6 6 4 94 1 6 6 6 6 7 9前后指针 keyi:23 2 3 3 3 3 2 32 2 3 3 3 3 3 3前后指针 keyi:22 2 2 2 2 2 2 22 2 2 2 2 2 2 2前后指针 keyi:06 1 7 6 6 6 4 91 4 6 6 6 6 9 73Way keyi:2,53 2 3 3 3 3 2 32 2 3 3 3 3 3 33Way keyi:2,72 2 2 2 2 2 2 2
2 2 2 2 2 2 2 2
3Way keyi:0,7
三. 排序OJ
912. 排序数组 - ⼒扣(LeetCode)
题目链接
下⾯我们再来看看这个OJ题,这个OJ,当我们⽤快排的时候,lomuto的⽅法,过不了这个题⽬,hoare版本可以过这个题⽬。堆排序和归并和希尔是可以过的,其他⼏个O(N^2)也不过了,因为这个题的测试⽤例中不仅仅有数据很多的⼤数组,也有⼀些特殊数据的数组,如⼤量重复数据的数组。堆排序和归并和希尔不是很受数据样本的分布和形态的影响,但是快排会,因为快排要选key,每次key都当趟分割都很偏,就会出现效率退化问题。
• 前⾯我们分析了lomuto的前后指针⾯对⼤量重复数据时,效率会退化,hoare版本会好很多,所以hoare是可以过这个OJ的,但是OJ还是⼀个相对局限的测试,就像leetcode官⽅为啥开始写的答案是lomuto,说明那会lomuto是可以过的,后⾯加了⼤量重复数值的测试⽤例,所以就过不了,但是答案忘记改了,说明写答案讲解和测试⽤例补充的不是⼀个团队,协作出问题(当然后⾯看这个视频课,可能官⽅答案就修正)。那么hoare现在可以过,leetcode哪天增加⼀个特殊测试⽤例以后,就过不了,三路划分也类似,因为他们的思想还是在特殊场景下效率会退化,⽐如⼤多数选key都是接近最⼩或者最⼤的值,导致划分不均衡,效率退化。
-
introsort是由David Musser在1997年设计的排序算法,C++ sgi STL sort中就是⽤的introspectivesort(内省排序)思想实现的。内省排序可以认为不受数据分布的影响,⽆论什么原因划分不均匀,导致递归深度太深,他就是转换堆排了,堆排不受数据分布影响,具体看下⾯代码细节。
-
其次三路划分针对有⼤量重复数据时,效率很好,其他场景就⼀般,但是三路划分思路还是很有价值的,有些快排思想变形体,要⽤划分去选数,他能保证跟key相等的数都排到中间去,三路划分的价值就体现出来了。
下⾯我们分别展⽰⼀下这⼏种思想去跑leetcode oj的思路和代码。
lomuto的快排跑排序OJ代码
void Swap(int* x, int* y)
{int tmp = *x;*x = *y;*y = tmp;
}
void QuickSort(int* a, int left, int right)
{if (left >= right)return;int begin = left;int end = right;// 随机选keyint randi = left + (rand() % (right-left + 1));// printf("%d\n", randi);Swap(&a[left], &a[randi]);int prev = left;int cur = prev + 1;int keyi = left;while (cur <= right){if (a[cur] < a[keyi] && ++prev != cur){Swap(&a[prev], &a[cur]);}++cur;}Swap(&a[prev], &a[keyi]);keyi = prev;// [begin, keyi-1] keyi [keyi+1, end]QuickSort(a, begin, keyi - 1);QuickSort(a, keyi+1, end);}int* sortArray(int* nums, int numsSize, int* returnSize){srand(time(0));QuickSort(nums, 0, numsSize-1);*returnSize = numsSize;return nums;}
运⾏结果:
hoare的快排跑排序OJ代码
void Swap(int* x, int* y){int tmp = *x;*x = *y;*y = tmp;}void QuickSort(int* a, int left, int right)
{if (left >= right)return;int begin = left, end = right;int randi = left + (rand() % (right-left+1));Swap(&a[left], &a[randi]);int keyi = left;++left;while (left <= right)//left和right相遇的位置的值⽐基准值要⼤{//right找到⽐基准值⼩或等while (left <= right && a[right] > a[keyi]){right--;}//left找到⽐基准值⼤或等while (left <= right && a[left] < a[keyi]){left++;}if (left <= right){Swap(&a[left++], &a[right--]);}}//right keyi交换Swap(&a[keyi], &a[right]);keyi = right;// [begin, keyi-1] keyi [keyi+1, end]QuickSort(a, begin, keyi - 1);QuickSort(a, keyi+1, end);}int* sortArray(int* nums, int numsSize, int* returnSize){srand(time(0));QuickSort(nums, 0, numsSize-1);*returnSize = numsSize;return nums;}
三路划分的快排跑排序OJ代码
void Swap(int* x, int* y){int tmp = *x;*x = *y;*y = tmp;}void QuickSort(int* a, int left, int right){if (left >= right)return;int begin = left;int end = right;// 随机选keyint randi = left + (rand() % (right-left + 1));Swap(&a[left], &a[randi]);// 三路划分// left和right指向就是跟key相等的区间
// [begin, left-1] [left, right] right+1, end]int key = a[left];int cur = left+1;while(cur <= right){// 1、cur遇到⽐key⼩,⼩的换到左边,同时把key换到中间位置// 2、cur遇到⽐key⼤,⼤的换到右边if(a[cur] < key)
{Swap(&a[cur], &a[left]);++left;++cur;}else if(a[cur] > key){Swap(&a[cur], &a[right]);--right;}else{++cur;}}// [begin, left-1] [left, right] right+1, end]QuickSort(a, begin, left - 1);QuickSort(a, right+1, end);}int* sortArray(int* nums, int numsSize, int* returnSize){srand(time(0));QuickSort(nums, 0, numsSize-1);*returnSize = numsSize;return nums;}
introsort的快排跑排序OJ代码
introsort是introspective sort采⽤了缩写,他的名字其实表达了他的实现思路,他的思路就是进⾏⾃我侦测和反省,快排递归深度太深(sgi stl中使⽤的是深度为2倍排序元素数量的对数值)那就说明在这种数据序列下,选key出现了问题,性能在快速退化,那么就不要再进⾏快排分割递归了,改换为堆排序进⾏排序。
/*** Note: The returned array must be malloced, assume caller calls free().*/void Swap(int* x, int* y){int tmp = *x;*x = *y;*y = tmp;}void AdjustDown(int* a, int n, int parent){int child = parent * 2 + 1;while (child < n){// 选出左右孩⼦中⼤的那⼀个if (child + 1 < n && a[child + 1] > a[child]){++child;}if (a[child] > a[parent]){Swap(&a[child], &a[parent]);parent = child;child = parent * 2 + 1;}else{break;}}}void HeapSort(int* a, int n){// 建堆 -- 向下调整建堆 -- O(N)for (int i = (n - 1 - 1) / 2; i >= 0; --i){AdjustDown(a, n, i);}// ⾃⼰先实现 -- O(N*logN)int end = n - 1;while (end > 0){Swap(&a[end], &a[0]);AdjustDown(a, end, 0);--end;}}void InsertSort(int* a, int n){for (int i = 1; i < n; i++){int end = i-1;int tmp = a[i];// 将tmp插⼊到[0,end]区间中,保持有序while (end >= 0){if (tmp < a[end]){a[end + 1] = a[end];--end;}else{break;}}a[end + 1] = tmp;}}void IntroSort(int* a, int left, int right, int depth, int defaultDepth){if (left >= right)return;// 数组⻓度⼩于16的⼩数组,换为插⼊排序,简单递归次数if(right - left + 1 < 16){InsertSort(a+left, right-left+1);return; }// 当深度超过2*logN时改⽤堆排序
if(depth > defaultDepth){HeapSort(a+left, right-left+1);return;}depth++;int begin = left;int end = right;// 随机选keyint randi = left + (rand() % (right-left + 1));Swap(&a[left], &a[randi]);int prev = left;int cur = prev + 1;int keyi = left;while (cur <= right){if (a[cur] < a[keyi] && ++prev != cur){Swap(&a[prev], &a[cur]);}++cur;}Swap(&a[prev], &a[keyi]);keyi = prev;// [begin, keyi-1] keyi [keyi+1, end]IntroSort(a, begin, keyi - 1, depth, defaultDepth);IntroSort(a, keyi+1, end, depth, defaultDepth);}void QuickSort(int* a, int left, int right){int depth = 0;int logn = 0;int N = right-left+1;for(int i = 1; i < N; i *= 2){logn++;}// introspective sort -- ⾃省排序IntroSort(a, left, right, depth, logn*2);}int* sortArray(int* nums, int numsSize, int* returnSize){srand(time(0));QuickSort(nums, 0, numsSize-1);*returnSize = numsSize;return nums;}
四. 竞赛中快速排序的写法
竞赛时我们需要快速的写出快排的代码,所以下面是快排的模板,当然思路是一样的。
#include <iostream>
using namespace std;
const int N = 100010;
int q[N];
void quick_sort(int q[], int l, int r)
{
if (l >= r) return;
int i = l - 1, j = r + 1, x = q[l + r >> 1];
while (i < j)
{
do i ++ ; while (q[i] < x);
do j -- ; while (q[j] > x);
if (i < j) swap(q[i], q[j]);
}
quick_sort(q, l, j);
quick_sort(q, j + 1, r);
}
int main()
{
int n;
scanf("%d", &n);for (int i = 0; i < n; i ++ ) scanf("%d", &q[i]);
quick_sort(q, 0, n - 1);
for (int i = 0; i < n; i ++ ) printf("%d ", q[i]);
return 0;
}