数据结构——快速排序

目录

引言

快速排序

1.算法思想

2.算法步骤

(1)Hoare法

(2)挖坑法

(3)前后指针法

3.复杂度分析

4.算法优化

(1)改变基准值

(2)三指针分划区间

(3)区间优化

5.非递归实现

结束语


引言

数据结构------冒泡、选择、插入和希尔排序 中我们已经学习了一部分排序的方法,接下来我们接着学习:快速排序

求点赞收藏关注!!!

快速排序

1.算法思想

快速排序(Quick Sort)是一种高效的排序算法,由C. A. R. Hoare在1960年提出。它的基本思想是:通过一趟排序将要排序的数据分割成独立的两部分,其中一部分的所有数据都比另一部分的所有数据都要小,然后再按此方法对这两部分数据分别进行快速排序,整个排序过程可以递归进行,以此达到整个数据变成有序序列。

2.算法步骤

由于快速排序的实现方式有很多,下面我们主要介绍三种方法:

(1)Hoare法

1.设定left和right指针分别指向数组首尾,选择数组首元素为基准key。

2.right先从右往左依次遍历找到比key小的数,left从左往右依次遍历找到比key大的数。然后交换left与right下标对应的值。重复步骤2直至right>=left。

3.之后交换key与left或者right对应的值,并且把该位置记为mid。划分数组为两部分。

4.对[left, mid-1]和[mid+1, right]子数组重复上述过程,直至整个数组排序完成。

下面我们来看个例子:

1)起始状态,key为数组的起始位置,left在起始位置,right在末尾。

2)right先出发,寻找比key小的数字。找到则停下。

3)left出发,寻找比key大的数字。找到则停下。

4)交换left和right的数字。

5)接着走,right找比key大的数,left找比key小的数,交换。

6)right接着走,遇到left,此时指向同一位置。

7)将left与right指向的数与key进行交换,则单趟排序就完成了,最后将基准值的下标返回给函数调用者

动图演示:

如何保证相遇位置比key小:因为right先走

right 停下时, left 与 right 相遇,由于 right 找比 key 小的值,所以此时 right 的位置一定比key小。

left 停下时,right 与 left 进行交换,交换后 left 指向的值比 key 小,此时 right 遇到 left 的位置一定比 key 小。

代码实现

// 交换函数
void swap(int* p1, int* p2)
{
	int tmp = *p1;
	*p1 = *p2;
	*p2 = tmp;
}

int PartSort1(int* arr, int begin, int end)
{
	int left = begin;
	int right = end;
	int keyi = begin;

	// 左指针小于右指针,循环继续
	while (left < right)
	{
		while (left < right && arr[right] >= arr[keyi])
			//寻找比key小的值
		{
			right--;
		}
		while (left < right && arr[left] <= arr[keyi])
			//寻找比key大的值
		{
			left++;
		}
		swap(&arr[left], &arr[right]);
	}
	int mid = left;
	swap(&arr[keyi], &arr[mid]);
	return mid;
}

void QuickSort(int* arr, int left, int right)
{
	if (left >= right)
	{
		return;
	}
	// 获取中间值
	int mid = PartSort1(arr, left, right);
	// 递归地对基准值左边的子数组进行快速排序  
	QuickSort(arr, left, mid - 1);

	// 递归地对基准值右边的子数组进行快速排序  
	QuickSort(arr, mid + 1, right);
}
(2)挖坑法

这种方法通过选择一个基准值(key),通常是数组的第一个元素,然后将数组分为两部分:一部分包含所有小于基准值的元素,另一部分包含所有大于基准值的元素。基准值最终位于这两部分的中间。

1.设置两个指针left和right,分别指向数组的最左端和最右端。

2.将起始位置 key值 设为坑,之后right从右往左找比key值小的值,找到之后放入坑位,此时right就形成新的坑。

3.然后left从左往右找比key大的值, 找到之后放入坑位,此时left就又形成新的坑。

4.最后left与right相遇,将key放入最后一个坑,并将该位置记为mid。·

5.划分区间[left,mid-1]与[mid+1,right]继续重复 1, 2,3,4步骤。直至不能划分。

下面我们来看个例子:

1)先定义变量key,存储数组第一个数作为key,此时left指向的位置就是坑。

2)right开始找小于key的数, 找到后停止,将right位置的数放进坑里,此时right位置作为新的坑。

3)left行动寻找比key大的数,找到后停止,并将值放进坑里,此时left位置作为新坑。

4)以此循环,直到二者相遇。

5)将key值放到坑中,排序完毕,将key值下标返回。

动图演示

代码实现

int PartSort2(int* arr, int begin, int end)
{
	int left = begin, right = end;
	int hole = begin;		// 记录当前需要填充的坑位(hole)
	int key = arr[left];	// 选取最左边的元素作为基准(key)
	while (left < right)
	{
		// 从右向左扫描,找到第一个小于等于key的元素
		while (left < right && arr[right] >= key)
		{
			right--;
		}
		// 将这个小于等于key的元素放到hole位置  
		arr[hole] = arr[right];
		hole = right; // 更新hole的位置
		// 从左向右扫描,找到第一个大于key的元素
		while (left < right && arr[left] <= key)
		{
			left++;
		}
		// 将这个大于key的元素放到hole位置  
		arr[hole] = arr[left];
		hole = left; // 更新hole的位置  
	}
	// 循环结束时,left和right相遇,将基准元素放到正确的位置  
	arr[hole] = key;
	return hole; // 返回基准元素的位置
}
void QuickSort(int* arr, int left, int right)
{
	if (left >= right)
	{
		return;
	}
	int mid = PartSort2(arr, left, right);//单趟排序
	// 递归地对基准元素左边的子数组进行快速排序  
	QuickSort(arr, left, mid - 1);

	// 递归地对基准元素右边的子数组进行快速排序  
	QuickSort(arr, mid + 1, right);
}
(3)前后指针法

​1.将数组第一个元素作为key基准值,定义前指针prev指向第一个数,后指针cur指向第二个数。

2.cur从左往右依次遍历找key小的值,找到之后++prev,然后交换prev与cur指向的值。

3.之后cur++继续遍历。(key为起始位置的值) 当cur遍历完之后,此时交换prev指向的值与key。

4.将此时位置记为mid。

5.最后划分区间[left,mid-1]与[mid+1,right]继续重复1, 2, 3, 4步骤。直至不能划分。

来看个例子:

1)将数组第一个元素作为key基准值,定义前指针prev指向第一个数,后指针cur指向第二个数。cur从左往右依次遍历找key小的值。此时cur位置的数比key基准值小,所以prev加一后与cur位置的数交换,由于此时prev+1 == cur,所以交换后没有变化

2)cur继续走,找到比key小的数。找到后prev加一,交换二者的数。

3)重复以上步骤。

4)cur遍历完数组,将prev与key交换数值,完成排序,并将key下标返回

动图演示

代码实现

void Swap(int* p1, int* p2)
{
	int tmp = *p1;
	*p1 = *p2;
	*p2 = tmp;
}

int PartSort3(int* arr, int begin, int end)
{
	int prev = begin;
	int cur = begin + 1;
	int keyi = begin;		// 基准元素的索引
	while (cur <= end)		// 遍历整个区间  
	{
		if (arr[cur] < arr[keyi])//小于则交换
		{
			// 将小于基准的元素与prev+1位置的元素交换,并递增prev
			Swap(&arr[++prev], &arr[cur]);
		}
		cur++;
	}
	Swap(&arr[prev], &arr[keyi]);
	// 返回基准元素的最终位置
	return prev;
}
void QuickSort(int* arr, int left, int right)
{
	if (left >= right)//不能划分
	{
		return;
	}
	int mid = PartSort3(arr, left, right);//单趟排序

	// 递归排序基准元素左边的子数组 
	QuickSort(arr, left, mid - 1);
	// 递归排序基准元素右边的子数组 
	QuickSort(arr, mid + 1, right);
}

3.复杂度分析

时间复杂度:通常情况下,需要递归logN层,每层都需要遍历,因此时间复杂度为O(NlogN)。

空间复杂度:通常情况下,需要递归logN层,因此空间复杂度为O(logN)。

4.算法优化

(1)改变基准值

​在我们选择基准值时,都是以数组中第一个数作为基准值进行排序,这样写的好处是非常方便且易懂,但是也有个大问题。

如果基准值是数组中的最大或最小值时,会导致快速排序的递归深度会非常深,排序效率会很低。

若是一个有序数组使用快速排序,则递归深度为n,单趟排序也为n,时间复杂度为O(n^2)。

为了防止出现这种情况,我们需要改变基准值:

1)随机数

在数组中随机选择一个数作为基数,每次都选到最大或最小的概率很小,但是有概率会选到最大或最小值。

#include <stdlib.h>
#include <time.h>
int GetRandIndex(int* arr, int left, int right)
{
    srand((size_t)time(NULL));	
    return rand() % (right-left+1) +left;
}

2)三数取中

int GetMid(int* arr, int left, int right)
{
	int mid = (left + right) / 2;
	if (arr[left] < arr[mid])
	{
		if (arr[mid] < arr[right])
		{
			return mid;
		}
		else if (arr[left] > arr[right])
		{
			return left;
		}
		else
		{
			return right;
		}
	}
	else   //arr[left] > arr[mid]
	{
		if (arr[mid] > arr[right])
		{
			return mid;
		}
		else if (arr[mid] > arr[left])
		{
			return left;
		}
		else
		{
			return right;
		}
	}
}
(2)三指针分划区间

除了数组有序的情况外,当数组的重复元素较多时,也会导致快排的效率降低。这时我们需要用上三指针分划区间。

三指针分划区间是快速排序算法中的一种分区策略,它主要用于处理包含多个相同基准值元素的数组。

1)步骤

1.分别定义三个指针left,cur,right分别指向数组首元素,第二个元素,最后一个元素

2.cur从左往右遍历数组:

·当arr[cur]<key,交换arr[cur]与arr[left],再让cur++``left++。

·当arr[cur]>key,交换arr[cur]与arr[right],再让right--。

·当arr[cur]==key,直接让cur++。

3.重复步骤2直至cur>right,成功划分区间。小于key:[begin,left-1],等于key:[left, right] 大于key:[right + 1, end]。

2)代码实现

void Swap(int* p1, int* p2)
{
	int tmp = *p1;
	*p1 = *p2;
	*p2 = tmp;
}

int GetMid(int* arr, int left, int right)
{
	int mid = (left + right) / 2;
	if (arr[left] < arr[mid])
	{
		if (arr[mid] < arr[right])
		{
			return mid;
		}
		else if (arr[left] > arr[right])
		{
			return left;
		}
		else
		{
			return right;
		}
	}
	else   //arr[left] > arr[mid]
	{
		if (arr[mid] > arr[right])
		{
			return mid;
		}
		else if (arr[mid] > arr[left])
		{
			return left;
		}
		else
		{
			return right;
		}
	}
}

void ThreeDivision(int* arr, int* left, int* right)
{
	int key = arr[*left];		// 以数组的第一个元素作为基准值
	int cur = *left + 1;		// 从数组的第二个元素开始遍历

	while (cur <= *right)
	{
		if (arr[cur] < key)		// 如果当前元素小于基准值
		{
			// 与left指向的元素交换,并移动left和cur
			Swap(&arr[(*left)++], &arr[cur++]);
		}
		else if (arr[cur] > key)
		{
			// 与right指向的元素交换,并移动right  
			// 注意:这里不移动cur
			// 因为新交换到cur位置的值可能需要再次比较
			Swap(&arr[cur], &arr[(*right)--]);
		}
		else
		{
			cur++;
		}
	}
}
void QuickSort(int* arr, int begin, int end)
{
	if (begin >= end)
	{
		return;
	}	
	int mid = GetMid(arr, begin, end);
	Swap(&arr[begin], &arr[mid]);
	int left = begin;
	int right = end;
	ThreeDivision(arr, &left, &right);//三指针划分区间
	//[begin, left - 1][left, right][right + 1, end]
	// 递归排序基准元素左边的子数组 
	QuickSort(arr, left, mid - 1);
	// 递归排序基准元素右边的子数组 
	QuickSort(arr, mid + 1, right);
}
(3)区间优化

在快速排序算法中,当递归到较深层级且处理的子数组长度较短时,继续使用递归快速排序可能会导致效率降低。

为什么效率会降低:

快速排序的递归类似于二叉树的形式,每次递归调用都会涉及函数调用和返回的开销,包括保存和恢复调用栈的上下文。当递归层级很深且处理的子问题规模很小时,这些开销会变得更加明显。

为了优化这种情况,可以设定一个数组长度的下限阈值。当子数组长度小于这个阈值时,不再采用递归快速排序,而是改用效率更高的插入排序算法。

void QuickSort(int* arr, int left, int right)
{
	if (left >= right)
	{
		return;
	}

	//数组长度小于10时,调用插入排序
	if (right - left + 1 < 10)
	{
		InsertSort(arr + left, right - left + 1);
		return;
	}
	int mid = PartSort3(arr, left, right);//单趟排序

	// 递归排序基准元素左边的子数组 
	QuickSort(arr, left, mid - 1);
	// 递归排序基准元素右边的子数组 
	QuickSort(arr, mid + 1, right);
}

5.非递归实现

当递归太深时会存在栈溢出的风险,因此,为了避免这种风险我们除了采用尾递归优化空间外,我们还可以采用非递归的形式实现快速排序。

非递归实现的方法需要使用数据结构------,利用其后进先出的形式模拟实现递归。

也可以来看看我的文章:

数据结构------顺序栈和链式栈

具体步骤如下:

1.将左右边界压入栈中。

2.如果栈不为空,则先出栈左边界 left ,再出栈右边界 right ,然后将[left,right]进行单趟排序得到基准点 keyi 。

3.接着判断 [left,keyi-1] , [keyi+1,right] 区间是否合法,合法就继续入栈。

4.重复2,3,直到栈空。

void QuickSortNonR(int* arr, int left, int right)
{
	ST st;
	STInit(&st);

	// 将初始的排序范围(整个数组)的左右边界压入栈中
	STPush(&st, right);
	STPush(&st, left);
	while (!STEmpty(&st))
	{
		// 先从栈中弹出的为左边界
		int begin = STTop(&st);
		STPop(&st);
		// 后从栈中弹出的为右边界
		int end = STTop(&st);
		STPop(&st);

		// 对当前子数组进行分区,并返回基准值的最终位置
		int keyi = PartSort(arr, begin, end);
		// 区间划分为[begin, keyi-1] keyi [keyi+1, end]

		// 如果基准值右侧还有元素需要排序
		// 则将右边界和基准值右侧第一个元素的索引压入栈
		if (keyi + 1 < end)
		{
			STPush(&st, end);
			STPush(&st, keyi + 1);
		}

		// 如果基准值右侧还有元素需要排序
		// 则将右边界和基准值右侧第一个元素的索引压入栈
		if (begin < keyi - 1)
		{
			STPush(&st, keyi - 1);
			STPush(&st, begin);
		}
	}
	STDestroy(&st);
}

结束语

本篇博客大致说明了有关快速排序的一些知识。

感谢各位大佬的支持!!!

求点赞收藏关注!!!

相关推荐
MengYiKeNan8 分钟前
C++二分函数lower_bound和upper_bound的用法
开发语言·c++·算法
戊子仲秋27 分钟前
【LeetCode】每日一题 2024_9_19 最长的字母序连续子字符串的长度(字符串,双指针)
算法·leetcode·职场和发展
小林熬夜学编程44 分钟前
C++第五十一弹---IO流实战:高效文件读写与格式化输出
c语言·开发语言·c++·算法
蠢蠢的打码1 小时前
8584 循环队列的基本操作
数据结构·c++·算法·链表·图论
无问8172 小时前
数据结构-排序(冒泡,选择,插入,希尔,快排,归并,堆排)
java·数据结构·排序算法
Lenyiin3 小时前
《 C++ 修炼全景指南:十 》自平衡的艺术:深入了解 AVL 树的核心原理与实现
数据结构·c++·stl
程序猿进阶3 小时前
如何在 Visual Studio Code 中反编译具有正确行号的 Java 类?
java·ide·vscode·算法·面试·职场和发展·架构
Eloudy3 小时前
一个编写最快,运行很慢的 cuda gemm kernel, 占位 kernel
算法
slandarer3 小时前
MATLAB | R2024b更新了哪些好玩的东西?
java·数据结构·matlab
king_machine design4 小时前
matlab中如何进行强制类型转换
数据结构·算法·matlab