前言
前面J桑讲了快排的思想(感兴趣的朋友可以去看看~)
今天我们来讲快排的优化。
一、为什么要优化快排
快速排序性能的关键点分析
决定快排性能的关键点是每次单趟排序后,key 对数组的分割。如果每次选 key 基本二分居中,那么快排的递归树就是一棵均匀的满二叉树,性能最佳。然而,实践中虽然不可能每次都是二分居中,但性能依然可控。
但如果出现每次选到最小值或最大值,划分为 0 个和 N-1 的子问题时,时间复杂度为 O(N²)。在数组序列有序时,就会出现这样的问题。
仍需解决的场景
-
数组中有多个与 key 相等的值:
- 例如:
int a[] = { 6,1,7,6,6,6,4,9 };
- 例如:
int a[] = { 3,2,3,3,3,3,2,3 };
- 例如:
-
数组中全是相同的值:
- 例如:
int a[] = { 2,2,2,2,2,2,2,2 };
- 例如:
这些场景会影响快排的性能,可能导致最坏情况的出现。
二、随机数Key
在快速排序中,基准元素的选择至关重要。相比将最左边元素作为基准,随机选择基准有几个明显优势:
-
避免最坏情况:
- 如果数组有序或近乎有序,选择最左边元素作为基准会导致每次分割的子数组大小不均匀,造成递归深度达到最大,从而使时间复杂度退化到 O(n²)。
-
提升分割效果:
- 随机选择的基准元素更有可能让数组均匀分割,这样递归深度会更小,效率更高。
-
更强的适应性:
- 在处理重复元素较多的情况下,最左边的基准可能导致很多冗余操作。随机选择可以更灵活地处理这些情况,减少不必要的比较。
总的来说,使用随机数作为基准选择能让快速排序在各种情况下更稳定、更高效。
它的实现是:(随机选到key与最左边元素交换,还是让key = 最左边元素)
// 随机选key
int randi = left + (rand() % (right - left + 1));
Swap(&arr[left], &arr[randi]);
int key = arr[left];
三、三值取中
三值取中与随机选择基准
概念
-
三值取中:
- 三值取中是一种优化快速排序基准选择的方法。它通过选取数组中的第一个元素、最后一个元素和中间元素,计算这三个数的中位数作为基准。这种方法能够有效减少极端情况的发生,从而提高排序性能。
-
随机选择基准:
- 随机选择基准是另一种优化策略,它通过在数组中随机选取一个元素作为基准。这样可以打破数组的有序性规律,降低最坏情况的概率,保持良好的平均时间复杂度。
特性 | 三值取中 | 随机选择基准 |
---|---|---|
选择方式 | 选取第一个、最后一个和中间元素,取中位数作为基准。 | 随机选取数组中的一个元素作为基准。 |
性能稳定性 | 能有效减少极端情况,特别适合近乎有序的数组。 | 随机性可能导致偶尔选择极端值,风险较大。 |
处理重复元素的能力 | 对重复元素的处理较好,避免了不必要的比较和交换。 | 在有大量重复元素时,可能导致不均匀分割。 |
实现复杂度 | 需要额外的比较步骤来找到中位数,略显复杂。 | 实现相对简洁,随机性引入不确定性。 |
适用场景 | 适合特定情况,如近乎有序或重复数据较多的数组。 | 灵活性高,适用于各种输入情况。 |
总结
三值取中和随机选择基准各有优劣。三值取中通过选择中位数能够提高稳定性,特别是在处理特定情况时表现出色;而随机选择基准则在实现上更为灵活,适合于更广泛的数据集。在实际应用中,可以根据数据特征和具体需求选择合适的方法,以获得最佳的快速排序效果。
三值取中的代码实现是:
//三值取中选key
int key = MedianOfThree(arr, left, right);
int cur = left + 1;
while (cur <= right)
{
if (arr[cur] < key)
{
Swap(&arr[left], &arr[cur]);
left++;
cur++;
}
else if (arr[cur] > key)
{
Swap(&arr[right], &arr[cur]);
right--;
}
else if (arr[cur] == key)
{
cur++;
}
}
// 三值取中函数
int MedianOfThree(int* arr, int left, int right)
{
int mid = left + (right - left) / 2;
// 比较并交换,确保 arr[left] <= arr[mid] <= arr[right]
if (arr[left] > arr[mid])
Swap(&arr[left], &arr[mid]);
if (arr[left] > arr[right])
Swap(&arr[left], &arr[right]);
if (arr[mid] > arr[right])
Swap(&arr[mid], &arr[right]);
// 使用中间值作为基准值,并将其移到 left 位置
Swap(&arr[left], &arr[mid]);
return arr[left]; // 返回基准值
}
四、三路划分
前面两个方式是解决如何找key的问题,三路划分主要是用于处理有大量与Key值相同的元素时的情况。
为什么要用三路划分?
传统的快速排序在选择基准时,遇到大量与基准相等的元素时,容易导致不平衡的分割,进而影响算法的效率。特别是在这些情况下,算法的时间复杂度可能退化为 O(n²),因为每次的划分几乎没有将数组缩小。因此,三路划分应运而生,它将数组划分为三段:小于基准的部分、等于基准的部分和大于基准的部分。
三路划分的基本思想
三路划分的核心思想可以简单地总结为将数组分为三段:
- 小于基准值(key)的部分
- 等于基准值(key)的部分
- 大于基准值(key)的部分
这种划分方法结合了两种常见的划分策略:Hoare 的左右指针法和 Lomuto 的前后指针法。通过使用三个指针来管理这三个区域,使得算法在处理重复元素时能够高效进行。
算法步骤
假设我们有这样一组数据
-
选择基准:
- 默认情况下,选择最左边的元素作为基准(key)。
-
初始化指针:
left
指针指向数组的开始位置。right
指针指向数组的结束位置。cur
指针从left + 1
开始,遍历整个数组。
像这样:
-
遍历和划分:
-
当
cur
指针遇到:- 小于基准值的元素:与
left
指针指向的元素交换,同时left
和cur
都向右移动一位。
- 大于基准值的元素:与
right
指针指向的元素交换,并将right
向左移动一位(此时,cur
不动,因为交换后,当前指向的元素还需要判断)。
- 等于基准值的元素:直接移动
cur
向右。
- 小于基准值的元素:与
-
继续这个过程,直到
cur
超过right
。
-
-
结束条件:
- 当
cur
超过right
时,划分结束,所有小于基准的元素都在左侧,等于基准的元素在中间,大于基准的元素在右侧。
- 当
总结
三路划分算法通过将数组分为小于、等于和大于基准的三部分,有效地解决了快速排序在处理重复元素时的性能问题。它的核心在于利用三个指针来高效管理这三部分,从而提高了算法的效率和稳定性。在面对大量相同元素时,三路划分算法可以显著减少不必要的比较和交换操作,使得排序过程更加高效。
三路划分具体实现代码是:
while (cur <= right)
{
if (arr[cur] < key)
{
Swap(&arr[left], &arr[cur]);
left++;
cur++;
}
else if (arr[cur] > key)
{
Swap(&arr[right], &arr[cur]);
right--;
}
else if (arr[cur] == key)
{
cur++;
}
}
// [begin, left-1] [left, right] right+1, end]
QuickSort(arr, begin, left - 1);
QuickSort(arr, right + 1, end);
五、优化快排代码总结
综合上述三值取中及三路划分,我们就得到了一个近乎完美的排序方法,它可以解决99%排序问题~
#include"QuickSort.h"
void Swap(int* x, int* y)
{
int tmp = *x;
*x = *y;
*y = tmp;
}
// 三值取中函数
int MedianOfThree(int* arr, int left, int right)
{
int mid = left + (right - left) / 2;
// 比较并交换,确保 arr[left] <= arr[mid] <= arr[right]
if (arr[left] > arr[mid])
Swap(&arr[left], &arr[mid]);
if (arr[left] > arr[right])
Swap(&arr[left], &arr[right]);
if (arr[mid] > arr[right])
Swap(&arr[mid], &arr[right]);
// 使用中间值作为基准值,并将其移到 left 位置
Swap(&arr[left], &arr[mid]);
return arr[left]; // 返回基准值
}
void QuickSort(int* arr, int left, int right)
{
if (left >= right)
{
return;
}
int begin = left, end = right;
// 随机选key
//int randi = left + (rand() % (right - left + 1));
//Swap(&arr[left], &arr[randi]);
//int key = arr[left];
//三值取中选key
int key = MedianOfThree(arr, left, right);
int cur = left + 1;
while (cur <= right)
{
if (arr[cur] < key)
{
Swap(&arr[left], &arr[cur]);
left++;
cur++;
}
else if (arr[cur] > key)
{
Swap(&arr[right], &arr[cur]);
right--;
}
else if (arr[cur] == key)
{
cur++;
}
}
// [begin, left-1] [left, right] right+1, end]
QuickSort(arr, begin, left - 1);
QuickSort(arr, right + 1, end);
}
int* sortArray(int* nums, int numsSize, int* returnSize)
{
srand(time(0));
QuickSort(nums, 0, numsSize - 1);
*returnSize = numsSize;
return nums;
}
这里给大家一个测试代码,大家下去可以测试快排的效率
#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;
}
六、自省排序
introsort的快排,introsort是introspective sort采⽤了缩写,他的名字其实表达了他的实现思路,他的思路就是进行自我侦测和反省,快排递归深度太深(sgi stl中使⽤的是深度为2倍排序元素数量的对数值)那就说明在这种数据序列下,选key出现了问题,性能在快速退化,那么就不要再进⾏快排分割递归了,改换为堆排序进⾏排序。
自省排序说白了就是数据小时用直接插入排序,数据多时用快排,深度大时改为堆排序,将我们前面学到的排序都结合起来了,我们直接来看代码~
#include"introsort.h"
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;
}
}
// 三值取中函数
int MedianOfThree(int* arr, int left, int right)
{
int mid = left + (right - left) / 2;
// 比较并交换,确保 arr[left] <= arr[mid] <= arr[right]
if (arr[left] > arr[mid])
Swap(&arr[left], &arr[mid]);
if (arr[left] > arr[right])
Swap(&arr[left], &arr[right]);
if (arr[mid] > arr[right])
Swap(&arr[mid], &arr[right]);
// 使用中间值作为基准值,并将其移到 left 位置
Swap(&arr[left], &arr[mid]);
return arr[left]; // 返回基准值
}
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]);
//也可以三值取中选key
//int key = MedianOfThree(arr, left, right);
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;
}
总结
到这里我们快排的优化就讲完了,当然往后还有其他的优秀排序,但是我们这里的快排已经能打败90%的排序了,是一个近乎完美的排序,大家要和J桑一起多看多练哦~
谢谢大家~