一. 快速排序
快速排序(quick sort)是一种基于分治策略的排序算法,运行高效,应用广泛。
快速排序的核心操作是"哨兵划分",其目标是:选择数组中的某个元素作为"基准数",将所有小于基准数的元素移到其左侧,而大于基准数的元素移到其右侧。
- 选取数组最左端元素作为基准数,初始化两个指针 i 和 j 分别指向数组的两端。
- 设置一个循环,在每轮中使用 i(j)分别寻找第一个比基准数大(小)的元素,然后交换这两个元素。
- 循环执行步骤 2. ,直到 i 和 j 相遇时停止,最后将基准数交换至两个子数组的分界线。
哨兵划分完成后,原数组被划分成三部分:左子数组、基准数、右子数组,且满足"左子数组任意元素 < 基准数 < 右子数组任意元素"。因此,我们接下来只需对这两个子数组进行排序。
cpp
/* 哨兵划分 */
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 时终止,从而完成整个数组的排序
cpp
/* 快速排序 */
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解决了这个问题,也就是说我们解决了绝⼤多数的问题,但是现在还是有⼀些场景没解决(数组中有⼤量重复数据时),类似⼀下代码。
cpp
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划分效果对象:
cpp
#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 left
if (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相等的值都划分到了中间,可以很好的解决这⾥的问题
cpp
6 1 7 6 6 6 4 9
6 1 4 6 6 6 7 9
hoare keyi:3
3 2 3 3 3 3 2 3
3 2 3 2 3 3 3 3
hoare keyi:4
2 2 2 2 2 2 2 2
2 2 2 2 2 2 2 2
hoare keyi:3
6 1 7 6 6 6 4 9
4 1 6 6 6 6 7 9
前后指针 keyi:2
3 2 3 3 3 3 2 3
2 2 3 3 3 3 3 3
前后指针 keyi:2
2 2 2 2 2 2 2 2
2 2 2 2 2 2 2 2
前后指针 keyi:0
6 1 7 6 6 6 4 9
1 4 6 6 6 6 9 7
3Way keyi:2,5
3 2 3 3 3 3 2 3
2 2 3 3 3 3 3 3
3Way keyi:2,7
2 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代码
cpp
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;
// 随机选key
int 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代码
cpp
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代码
cpp
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;
// 随机选key
int 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出现了问题,性能在快速退化,那么就不要再进⾏快排分割递归了,改换为堆排序进⾏排序。
cpp
/**
* 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;
// 随机选key
int 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;
}
四. 竞赛中快速排序的写法
竞赛时我们需要快速的写出快排的代码,所以下面是快排的模板,当然思路是一样的。
cpp
#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;
}