【不看会后悔系列】排序之——快速排序优化(随机数key,三值取中,三路划分,自省排序)【最全三路划分讲解】


前言

前面J桑讲了快排的思想(感兴趣的朋友可以去看看~)

戳我戳我戳我~~~

今天我们来讲快排的优化。

在这里插入图片描述


一、为什么要优化快排

快速排序性能的关键点分析

决定快排性能的关键点是每次单趟排序后,key 对数组的分割。如果每次选 key 基本二分居中,那么快排的递归树就是一棵均匀的满二叉树,性能最佳。然而,实践中虽然不可能每次都是二分居中,但性能依然可控。

但如果出现每次选到最小值或最大值,划分为 0 个和 N-1 的子问题时,时间复杂度为 O(N²)。在数组序列有序时,就会出现这样的问题。

仍需解决的场景

  1. 数组中有多个与 key 相等的值

    • 例如:int a[] = { 6,1,7,6,6,6,4,9 };
    • 例如:int a[] = { 3,2,3,3,3,3,2,3 };
  2. 数组中全是相同的值

    • 例如:int a[] = { 2,2,2,2,2,2,2,2 };

这些场景会影响快排的性能,可能导致最坏情况的出现。


二、随机数Key

在快速排序中,基准元素的选择至关重要。相比将最左边元素作为基准,随机选择基准有几个明显优势:

  1. 避免最坏情况

    • 如果数组有序或近乎有序,选择最左边元素作为基准会导致每次分割的子数组大小不均匀,造成递归深度达到最大,从而使时间复杂度退化到 O(n²)。
  2. 提升分割效果

    • 随机选择的基准元素更有可能让数组均匀分割,这样递归深度会更小,效率更高。
  3. 更强的适应性

    • 在处理重复元素较多的情况下,最左边的基准可能导致很多冗余操作。随机选择可以更灵活地处理这些情况,减少不必要的比较。

总的来说,使用随机数作为基准选择能让快速排序在各种情况下更稳定、更高效。

它的实现是:(随机选到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 的前后指针法。通过使用三个指针来管理这三个区域,使得算法在处理重复元素时能够高效进行。

算法步骤

假设我们有这样一组数据
在这里插入图片描述

  1. 选择基准

    • 默认情况下,选择最左边的元素作为基准(key)。
  2. 初始化指针

    • left 指针指向数组的开始位置。
    • right 指针指向数组的结束位置。
    • cur 指针从 left + 1 开始,遍历整个数组。

像这样:
在这里插入图片描述

  1. 遍历和划分

    • cur 指针遇到:

      • 小于基准值的元素:与 left 指针指向的元素交换,同时 leftcur 都向右移动一位。

      在这里插入图片描述

      • 大于基准值的元素:与 right 指针指向的元素交换,并将 right 向左移动一位(此时,cur 不动,因为交换后,当前指向的元素还需要判断)。

      在这里插入图片描述

      • 等于基准值的元素:直接移动 cur 向右。
    • 继续这个过程,直到 cur 超过 right

    在这里插入图片描述

  2. 结束条件

    • 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桑一起多看多练哦~

谢谢大家~
在这里插入图片描述

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值