【探索数据结构与算法】快速排序超详解:hoare版本、挖坑法、前后指针法、优化版、非递归实现

 

💓 博客主页:C-SDN花园GGbond

⏩ 文章专栏:快速排序

一、引言 

快速排序是计算机科学中最著名的排序算法之一,与归并排序、堆排序等算法齐名。它以其简洁的算法逻辑和高效的性能表现,成为了排序算法中的佼佼者。
2. 广泛使用

快速排序在实际应用中具有广泛的适用性。无论是编程语言的标准库(如Python的sorted函数、Java的Arrays.sort方法等)还是各种数据处理系统(如数据库管理系统、数据分析工具等),都广泛采用了快速排序算法或其变种。
3. 面试与竞赛常见

由于快速排序算法的重要性,它经常出现在各种编程面试和算法竞赛中。掌握快速排序算法的原理和实现方式,对于提高编程能力和应对面试、竞赛等挑战具有重要意义。

二、快速排序的基本原理和实现 

2.1 hoare版本 
  1. 递归终止条件:首先检查left(左边界)是否大于等于right(右边界),如果是,则说明当前子数组的长度小于或等于1,此时不需要排序,直接返回。

  2. 选择基准值:这里选择子数组的第一个元素a[left]作为基准值(keyi),并将keyi的索引保存在keyi变量中。

  3. 分区操作:   

    1.使用两个指针begin和end,分别从数组的左右两端开始扫描。
    2.end指针从右向左移动,直到找到一个小于或等于基准值的元素。
    3.同时,begin指针从左向右移动,直到找到一个大于基准值的元素。
    4.如果begin和end没有相遇(即begin < end),则交换a[begin]和a[end]的值,然后继续移动指针。
    5.这个过程会一直进行,直到begin和end相遇或交错。
    6.当begin和end相遇时,将基准值(即a[keyi])与a[begin](此时begin == end)交换,这样基准值就被放到了它最终应该在的位置。

  4. 递归排序

    • 更新keyi的值为begin(即基准值最终所在的位置)。
    • 然后,对基准值左边的子数组(leftkeyi-1)和右边的子数组(keyi+1right)分别递归调用Quicksort1函数进行排序。

 

C语言实现代码 

void Swap(int* a, int* b)//交换函数
{
	int tmp = *a;
	*a = *b;
	*b = tmp;
}
// 快速排序hoare版本
void  Quicksort1(int* a, int left, int right)
{
	if (left >= right)//只有一个元素或不存在,停止递归
		return ;
 
	int keyi = left;
	int begin = left, end = right;
 
	while (begin < end)//begin与end未相遇时,继续循环
	{
		while (begin < end && a[end] >= a[keyi])//end先移动,找到小于keyi位置的值停止
			end--;
		while (begin < end && a[begin] <= a[keyi])//begin再移动,找到大于keyi位置的值停止
			begin++;
		Swap(&a[begin], &a[end]);
	}
	Swap(&a[keyi], &a[begin]);//出循环后,begin与end相遇,交换此位置与keyi位置的值
 
	keyi = begin;//更新keyi的位置
	//[left][keyi-1]keyi[keyi+1][right]  左右区间划分
	Quicksort1(a, left, keyi - 1);//左区间递归
	Quicksort1(a, keyi + 1, right);//右区间递归
}

思考:为什么最后相遇位置一定小于或等于**key**值? 

endbegin相遇无非两种情况:

情况一:end停住,begin移动与end相遇·。因为endt一直再找比key小的值,所以end停下位置一定比key小,相遇位置也一定比key小。
情况二:begin停住,end移动与begin相遇·。此时又分为两种情况:
begin从未移动,右侧数据都比可以大,相遇位置就是key,交换不变。
begin移动过至少一次,也就是至少交换过一次,此时begin停留位置的值是上一轮end所对应的值,又因为end一直再找比key小的值,所以相遇位置也一定比key小。

2.2挖坑法 

实现思路:

  1. 终止条件:首先检查left(左边界)是否大于等于right(右边界),如果是,则当前子数组的长度小于或等于1,不需要排序,直接返回。

  2. 选择基准值:选择子数组的第一个元素a[left]作为基准值,并将其存储在临时变量tmp中。这里的tmp用于后续将基准值放回到其在排序后数组中的正确位置。

  3. 分区操作(挖坑法):1.使用两个指针beginend,分别初始化为leftright。这两个指针用于从数组的两端开始向中间扫描。    

    2.end指针从右向左移动,寻找第一个小于基准值tmp的元素。找到后,将该元素的值放到begin指针所指的“坑”中(如果begin和end没有相遇)。
    3.然后,begin指针从左向右移动,寻找第一个大于基准值tmp的元素。找到后,将该元素的值放到end指针所指的“坑”中(注意,此时end已经向左移动过,所以不会覆盖之前放置的值)。   4. 这个过程会一直进行,直到beginend相遇或交错。此时,begin(或end,因为它们相遇了)所在的位置就是基准值tmp应该放置的位置。

  4. 放置基准值:将保存在tmp中的基准值放到begin(或end,它们现在相同)所指的位置。这个位置就是基准值在排序后数组中的正确位置。

  5. 递归排序

    • 现在,基准值已经处于其最终位置,我们可以将数组分为左右两个子数组进行递归排序。
    • 左子数组的范围是从leftkeyi - 1(因为keyi现在是基准值的正确位置)。
    • 右子数组的范围是从keyi + 1right

 

代码实现 

void QuickSort2(int* a, int left, int right)
{
	if (left >= right)
	{
		return;
	}
	int begin = left;
	int end = right;
	int tmp = a[begin];
	while (begin < end)
	{
		while (begin<end && a[end]>tmp)
		{
			end--;
		}
		if (begin < end)//防止是自己跟自己交换
		{
			a[begin] = a[end];
			
		}
		while (begin<end && a[begin]<tmp)
		{
			begin++;
		}
		if (begin < end)
		{
			a[end] = a[begin];
		}
	}
	a[begin] = tmp;
	int keyi = begin;
	QuickSort2(a, left, keyi - 1);
	QuickSort2(a, keyi + 1, right);
}
2.3 前后指针法

 

实现思路:

  1. 终止条件:首先检查left(左边界)是否大于等于right(右边界),如果是,则当前子数组的长度小于或等于1,不需要排序,直接返回。

  2. 选择基准值:选择子数组的第一个元素a[left]作为基准值,并将该位置的索引存储在keyi变量中。虽然这个索引在后续操作中没有被直接用于交换,但它用于在分区结束后确定基准值的最终位置。

  3. 初始化指针

    • prev(前指针)初始化为left,它用于记录小于基准值的最后一个元素的位置。
    • cur(后指针)初始化为left + 1,它用于遍历数组中的元素,寻找小于基准值的元素。
  4. 分区操作

    使用while循环,当cur小于等于right时执行循环体。  在循环体内,如果a[cur]小于基准值a[keyi],并且prev++ != cur,这是为了避免自身与自身的无用交换),则将a[prev]a[cur]的值交换。  cur始终向前移动,直到遍历完整个子数组。
  5. 放置基准值:当cur遍历完整个子数组后,循环结束。此时,prev指向的是最后一个小于基准值的元素的位置(注意,如果所有元素都大于等于基准值,则prev将停留在left位置)。  将基准值a[keyi]a[prev]交换,这样基准值就被放到了它最终应该在的位置。
  6. 更新基准值位置  由于基准值已经与a[prev]交换,所以将keyi更新为prev,以反映基准值的新位置。
  7. 递归排序:对基准值左边的子数组(leftkeyi - 1)和右边的子数组(keyi + 1right)分别递归调用Quicksort3函数进行排序。

代码实现

 
void Swap(int* a, int* b)//交换函数
{
	int tmp = *a;
	*a = *b;
	*b = tmp;
}
void Quicksort3(int* a, int left, int right)
{
	if (left >= right)//只有一个元素或不存在,停止递归
		return;
	int keyi = left;
 
	int prev = left;//前指针
	int cur = left + 1;//后指针
	while (cur <= right)//cur越界结束循环
	{
		//cur一直向后走,遇到小的值就停下,让prev向前走一步并交换
		if (a[cur] < a[keyi] && prev++ != cur)//prev++ != cur是为了避免无用的原地交换
			Swap(&a[prev], &a[cur]);
		cur++;
	}
	Swap(&a[keyi], &a[prev]);//出循环,将prev停留位置与keyi位置进行交换
 
	keyi = prev;
	Quicksort3(a, left, keyi - 1);
	Quicksort3(a, keyi + 1, right);
}
2.4快速排序非递归实现 

并使用了一个栈(Stack)来模拟递归调用的栈行为。这种做法的主要目的是避免直接使用递归,从而在某些栈空间有限的环境下减少栈溢出的风险。

由于递归实现中,每次递归调用都会将数组的一个子区间(left, right)作为新的排序区间,因此迭代实现中需要使用一个栈来模拟这一过程。具体步骤如下:

初始化栈:使用一个栈来存储待排序的区间,初始时将整个数组区间(left, right)压入栈中。
循环处理栈:只要栈不为空,就不断从栈中取出区间(left, right),对这个区间进行快速排序处理(即选择基准值,进行分区),然后可能得到两个新的子区间(如果子区间长度大于1)。
子区间入栈:将得到的两个子区间(如果有的话)重新压入栈中,以便后续继续处理。
重复处理:重复上述过程,直到栈为空,此时所有区间都已处理完成,整个数组排序也就完成了。

代码实现 

void Swap(int* a, int* b)//交换函数
{
	int tmp = *a;
	*a = *b;
	*b = tmp;
}
int  Quicksort(int* a, int left, int right)//快排hoare版子函数
{
	int midi = GetMid(a, left, right);//三个数取中间值的下标
	Swap(&a[left], &a[midi]);
	int keyi = left;
	int begin = left, end = right;
 
	while (begin < end)//begin与end未相遇时,继续循环
	{
		while (begin < end && a[end] >= a[keyi])//end先移动,找到小于keyi位置的值停止
			end--;
		while (begin < end && a[begin] <= a[keyi])//begin再移动,找到大于keyi位置的值停止
			begin++;
		Swap(&a[begin], &a[end]);
	}
	Swap(&a[keyi], &a[begin]);//出循环后,begin与end相遇,交换此位置与keyi位置的值
	keyi = begin;
	return keyi;
}
void QuickSortNonR(int* a, int left, int right)
{
	//创建栈并初始化
	Stack SK;
	StackInit(&SK);
 
	//右左区间入栈
	StackPush(&SK, right);
	StackPush(&SK, left);
 
	while (!StackEmpty(&SK))//栈不为空,继续排序
	{
		int begin = StackTop(&SK);//取一组左右区间并出栈
		StackPop(&SK);
		int end = StackTop(&SK);
		StackPop(&SK);
 
		int keyi = Quicksort(a, begin, end);//调用快排hoare子函数
 
		if ((keyi + 1) < end)//如果右区间>1,入栈
		{
			StackPush(&SK, end);
			StackPush(&SK, keyi + 1);
		}
		if (begin < (keyi - 1))//如果左区间>1,入栈
		{
			StackPush(&SK, keyi - 1);
			StackPush(&SK, begin);
		}
	}
	StackDestroy(&SK);//排序完成,销毁栈
}

 三、快速排序的优化 

3.1三数取中

上述快速排序实现代码在最坏情况下(如数组已经有序或逆序)会退化到O(n^2)。为了改善最坏情况的表现,可以采取三数取中法来优化。

快速排序三数取中法(也称为三数中值分割法)是快速排序算法的一种优化策略,它旨在通过更合理地选择分区点来减少算法在极端情况下的性能退化。

 

三数取中优化的主要目的是减少在数组已经部分有序或几乎有序时,由于分区点(pivot)选择不当而导致的性能退化。在极端情况下,如果分区点总是选择到数组的最小值或最大值,那么每次分区都将导致一个空子数组和一个几乎不变的子数组,从而使得算法的时间复杂度退化为O(n^2)。 

实现
通过比较数组左端点(left)、中间点(mid)和右端点(right)的值,选择这三个数中的中间值作为分区点。将中间值交换为数组的左端点(left),原代码的逻辑依然不变 

3.2小区间优化 

目的:
小区间优化是为了处理快速排序在处理小规模数组时可能不如其他排序算法(如插入排序)高效的问题。当数组规模较小时,快速排序的递归调用和分区操作可能会带来较大的开销,比如区间只剩几个元素时仍然需要递归调用,建立栈帧,而使用插入排序在这些情况下通常能提供更好的性能。

实现:
在QuickSort1函数中,通过检查区间长度(right - left + 1),如果它小于某个阈值(比如10),则停止递归,转而调用插入函数对该小区间进行插入排序。这是一种常见的混合排序策略,旨在结合不同排序算法的优势。

四、加入三数取中和小区间优化后的版本

用hoare版本 为例

//快速排序的优化
//三数取中
int GetMid(int* a, int left, int right)
{
	int mid = (left + right) / 2;
	if (a[left] > a[mid])
	{
		if (a[mid] > a[right])
		{
			return mid;
		}
		//a[mid]<a[right]
		else if (a[left] > a[right])
		{
			return right;
		}
		else
			return left;
	}
	else//a[left]<a[right]
	{
		if (a[left] > a[mid])
		{
			return left;
		}
		else if (a[right] > a[mid])//a[left]<a[mid]
		{
			return mid;
		}
		else
		{
			return right;
		}
	}
}
//插入排序小区间优化
void InsertSort(int* a, int n)
{

	for (int i = 0; i < n; i++)
	{
		int end = i;//[0,end]有序,a[end+1]插入
		int tmp = a[end + 1];//防止后面覆盖要插入数据
		while (end >= 0)
		{
			if (tmp > a[end])
			{
				a[end + 1] = tmp;
				end--;
			}
			else
				break;
		}
		a[end + 1] = tmp;
	}
}
void OptimizeQuickSort1(int* a, int left, int right)
{
	if (left >= right)
	{
		return;
	}
	if ((right - left + 1) < 10)
	{
		InsertSort(a, right - left + 1);
	}
	else
	{
		int midi = GetMid(a, left, right);
		swap(&a[left], &a[midi]);
		int keyi = left;
		int begin = left;
		int end = right;
		while (begin < end)
		{
			while (begin < end && a[end] >= a[keyi])
			{
				end--;
			}
			while (begin < end && a[begin] <= a[keyi])
			{
				begin++;
			}
			swap(&a[begin], &a[end]);
		}
		swap(&a[keyi], &a[begin]);
		keyi = begin;
		QuickSort1(a, left, keyi - 1);
		QuickSort1(a, keyi + 1, right);
	}
	
}

 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值