首页 > 其他分享 >交换排序----快速排序

交换排序----快速排序

时间:2024-12-10 14:04:12浏览次数:9  
标签:right 递归 int 交换 ---- key 排序 left

快速排序

快速排序是一种高效的排序算法,它采用分治法策略,将数组分为较小和较大的两个子数组,然后递归排序两个子数组。

快速排序是Hoare于1962年提出的一种二叉树结构的交换排序方法,其基本思想为:任取待排序元素序列中的某元素作为基准值,按照该排序码将待排序集合分割成两子序列,左子序列中所有元素均小于基准值,右子序列中所有元素均大于基准值,然后最左右子序列重复该过程,直到所有元素都排列在相应位置上为止。

快速排序递归实现的主框架与二叉树前序遍历规则非常像,我们在写递归框架时可想想二叉树前序遍历规则即可快速写出来,后序只需分析如何按照基准值来对区间中数据进行划分的方式即可。而将区间按照基准值划分为左右两半部分的常见方式有:hoare版本、挖坑法、前后指针版本。

hoare版本

讲解

接下来我们一起看一下hoare版本的快速排序是如何实现的:

7f6ad391d10a4fab95054474bf136041.png

 选数组左端点为基准值(key)初始位置。

06cb58dec5de45d0a48f1512fa0c5eaf.png

用双指针从两端向中间移动(这里需要注意,如果选数组左端的值为key,则右指针先走;如果选数组右端的值为key,则左指针先走。)。左指针遇到大于或等于key的值就停,右指针遇到小于或等于key的值就停。停后交换两指针所指元素,继续移动,直到相遇:

3404ea37a9aa42948dc3cefe2147538b.png

 这里补充一个结论:left和right相遇的值一定是小于等于key的。为什么呢?这里有三种情况:①当right向左移动找小于或等于key的值,left向右移动找大于或等于key的值却没找到,而这时left和right相遇了,此时相遇的位置肯定比key小或相等;②当right向左移动找小于或等于key的值,但是没找到就直接遇到left,那这里的left肯定就在key值的位置上,那它们就在key值相遇,这就是等于key值的情况;③right向左移动时找到了小于或等于key的值,left向右移动占到了大于或等于key的值,left和right位置上的值交换,然后right继续向左移动找小于或等于key的值,但是right已经找不到小于或等于key的值了,那它就会与left相遇,此时left上的值是刚刚从right位置上交换过来的小于或等于key的值,所以他们还是在小于或等于key上的值相遇。

6f3e74e2bfe8446cbc36f2f8fd8d3a7b.png

 指针相遇后,交换key与相遇位置值。

f108e2b554534098bdd5b49e60b582cb.png

 key的索引要更新到相遇点,接下来准备对key左右子数组分别进行重复的操作。

04017eafa09140b7b5c77b78b707ac2d.png 我们发现,整体看每次对key左右子数组的排序很像二叉树结构,所以我们可以通过递归完成对子区间数据的排序。

代码
void swap(int* x, int* y) {
	int tmp = *x;
	*x = *y;
	*y = tmp;
}

int hoare(int* a, int left, int right)
{
    int keyIndex = left;
    while (left < right)
    {
        while (left < right && a[keyIndex] <= a[right])
        {
            --right;
        }
        while (left < right && a[keyIndex] >= a[left])
        {
            ++left;
        }
        swap(&a[left], &a[right]);
    }
    swap(&a[keyIndex], &a[right]);
    keyIndex = right;

    return keyIndex;
}

void quickSort(int* a, int left, int right)
{
    if (left >= right)
        return;
    int begin = left, end = right;

    int keyIndex = hoare(a, left, right);

    
    quickSort(a, begin, keyIndex - 1);
    quickSort(a, keyIndex + 1, end);
    
}

在代码中有两个点要注意:hoare函数里的最内层的两个while循环中必须要有left<right这个条件,不然会越界;while (left < right && a[keyIndex] <= a[right])不能写成while (left < right && a[keyIndex] < a[right]),while (left < right && a[keyIndex] >= a[left])不能写成 while (left < right && a[keyIndex] > a[left]),不然出现与key一样的值会进入死循环。

挖坑法

接下来,我们再看一下挖坑法的快速排序是如何实现的:

讲解

10084beb21eb4b0db112635bf1848147.png

选数组左端点为key,用双指针准备从两端向中间移动。(选数组左端的值为key,则还是右指针先走。)

66d7b455694d4cf890861655898aacfb.png

双指针移动,右指针遇小于或等于key值的填左坑,左指针遇大于或等于key值的填右坑,直到两个指针相遇。

19d6af71263a42ff9743b32d10df987d.png

指针相遇,key值填入相遇的位置。(key填入相遇的位置后别忘了更新key的索引位置)

接下来准备对key左右子数组分别进行重复的操作,与hoare的做法是一样的,进行递归排序。

代码
void swap(int* x, int* y) {
	int tmp = *x;
	*x = *y;
	*y = tmp;
}

int hole(int* a, int left, int right)
{
	int keyIndex = left;
	int key = a[keyIndex];
	while (left < right)
	{
		while (left < right && a[right] >= key) {
			--right;
		}
		a[left] = a[right];
		while (left < right && a[left] <= key) {
			++left;
		}
		a[right] = a[left];
	}
	a[right] = key;
    keyIndex = right;

	return keyIndex;
}

void quickSort(int* a, int left, int right)
{
    if (left >= right)
        return;
    int begin = left, end = right;

    int keyIndex = hoare(a, left, right);

    
    quickSort(a, begin, keyIndex - 1);
    quickSort(a, keyIndex + 1, end);
    
}

快慢指针法

讲解

接下来,我们再看一下快慢指针法的快速排序是如何实现的:

fae60825deb547a9a8eb24a722355176.png

初始化数组左端元素为key,pre(慢指针)和cur(快指针)都向右移动。

1b9dace2bb384559be7a83b56e22cc18.png

遍历数组,当 cur 指向元素小于key时,pre向前移动一个位置,交换 cur 和 pre 位置的值(pre和cur所在的位置如果相同,就不用交换);当cur指向元素大于或等于key,pre保持不动,cur继续往前走。

873c79ea1b5541a4aa9c7d7e8264ef97.png

遍历结束后,交换 pre 位置的元素和key元素,然后更新key索引。

接下来准备对key左右子数组分别进行重复的操作,与hoare和挖坑法的做法是一样的,进行递归排序。

代码
void swap(int* x, int* y) {
	int tmp = *x;
	*x = *y;
	*y = tmp;
}

int pre_cur(int* a, int left, int right)
{
	int keyIndex = left, pre = left, cur = left + 1;
	while (cur <= right)
	{
		while (a[cur] < a[keyIndex] && ++pre != cur) {
			swap(&a[cur], &a[pre]);
		}
		++cur;
	}
	swap(&a[pre], &a[keyIndex]);
	keyIndex = pre;

	return keyIndex;
}

void quickSort(int* a, int left, int right)
{
    if (left >= right)
        return;
    int begin = left, end = right;

    int keyIndex = pre_cur(a, left, right);

    
    quickSort(a, begin, keyIndex - 1);
    quickSort(a, keyIndex + 1, end);
    
}

快排的时间复杂度与优化

我们前面讲了快速排序的三种写法,但其实三种写法的时间复杂度都差不多,只是第二种写法更好理解,第三种写法代码量少且不容易写错(因为在hoare和挖坑法版本的写法中,最内层的两个while循环有些细节问题需要注意)。所以总体来说更推荐第三中写法。

时间复杂度

在探讨快速排序算法时,我们注意到其递归展开图与二叉树结构有相似之处。为了深入理解其时间复杂度和最坏情况,我们可以从以下几个方面进行阐述:

1.时间复杂度分析

快速排序通过递归将数组不断划分,其递归深度与二叉树的深度相似。在理想情况下,递归深度最多为 logn,其中 n 是数组的长度。这是因为每次递归都将数组大致均匀地划分为两个子数组。

2.每层递归的工作量

在快速排序的每一层递归中,需要遍历当前子数组以定位key的最终位置。尽管随着递归的深入,之前的key元素不再需要考虑,但这对整体工作量的影响是有限的:

54e1bb8409d24c5992879c8a2f4fb5b2.png

为了简化分析,我们可以假设每层递归都需要处理 n 个元素(尽管实际上这个数字会逐层减小,但减小的数量相对于 n 来说是可以忽略的)。

3.总体时间复杂度

结合上述两点,快速排序的总体时间复杂度为 O(n log n)。这是因为每层递归需要处理 n 个元素,而递归深度最多为 logn。

最坏情况分析

1.最坏情况的发生:

快速排序的最坏情况发生在数组已经接近排序完成(即顺序或逆序)时,或者key选择不当(如总是选择数组的第一个元素,而该元素恰好是最小值或最大值)时。

2.时间复杂度与栈溢出:

在最坏情况下,快速排序的时间复杂度可能达到 O(n²)。这是因为每次划分都极不均匀,导致递归深度接近 n,从而引发大量的递归调用和栈空间消耗,容易发生栈溢出。

3.key位置的影响:

key的位置对快速排序的性能至关重要。如果key总是选择接近数组的一端(如最小值或最大值),则划分将极不均匀,导致递归深度增加。
相反,如果key能够接近数组的中间位置,则划分将更加均匀,递归深度将更接近 logn,从而提高算法的效率。

对最坏情况的优化

当枢纽每次都接近数组的中间位置时,快速排序的递归展开图将更接近满二叉树。满二叉树的深度均匀,且每个节点都有两个子节点,这有助于减少递归调用的次数和栈空间的使用。因此,选择好的key策略(如随机选择、三数取中法等)对于提高快速排序的性能至关重要。

1.随机选择法

我们不一定让区间内第一个值作为key,我们每次在区间内随机选一个值做key。虽然随机选择的情况下也有可能选到当前区间内的最小值或最大值,但是那是一个低概率事件,大多数情况下还是不会取到区间内的最值。

代码:

void quickSort(int* a, int left, int right)
{
	if (left >= right)
		return;
	int begin = left, end = right;
	//随机选key
	int randIndex = left + (rand() % (right - left));
	swap(&a[left], &a[randIndex]);


	int keyIndex = pre_cur(a, left, right);

	if ((right - left + 1) > 10) {
		quickSort(a, begin, keyIndex - 1);
		quickSort(a, keyIndex + 1, end);
	}
	else {
		insertSort(a, right);
	}
}

2.三数取中

三数取中,将区间内的left、right和mid位置的值比较大小,取中间值。这种方法是一定不会取到区间内的最值,所以更推荐这种写法。

代码:

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

void quickSort(int* a, int left, int right)
{
	if (left >= right)
		return;
	int begin = left, end = right;

	//三数取中
	int midIndex = getMidIndex(a, left, right);
	swap(&a[left], &a[midIndex]);

	int keyIndex = pre_cur(a, left, right);

	if ((right - left + 1) > 10) {
		quickSort(a, begin, keyIndex - 1);
		quickSort(a, keyIndex + 1, end);
	}
	else {
		insertSort(a, right);
	}
}

小区间优化

在快速排序算法中,当处理小规模数据时,递归调用可能会变得低效。为了优化这种情况,我们可以引入小区间优化策略。

背景

1.递归调用的低效性:在快速排序中,当数组被划分成较小的子数组时,递归调用可能会变得频繁且低效。特别是当子数组的长度非常小(如只有几个元素)时,递归调用的开销可能会超过直接排序这些元素所需的开销。

c0a7048cc1ce4ea2acea856c8f3bff70.png

2.插入排序的优势:插入排序在处理小规模数据时通常表现较好。当数据已经部分有序或数据量很小时,插入排序的效率往往高于快速排序的递归调用。

原理

1.设定阈值:为了避免在小规模数据上进行不必要的递归调用,我们可以设定一个阈值(通常取10左右)。当子数组的长度小于这个阈值时,我们不再使用快速排序的递归调用,而是改用插入排序来排序这些元素。

2.优化二叉树结构:在快速排序的递归过程中,二叉树的深度和形状对算法的效率有很大影响。当子数组长度较小时,递归调用会生成大量的浅层节点,这些节点的处理开销较大。通过设定阈值并使用插入排序,我们可以减少这些浅层节点的数量,从而优化二叉树的结构,提高算法的整体效率。

3.利用插入排序的优势:插入排序在处理小规模或部分有序数据时非常高效。当子数组长度小于阈值时,这些子数组很可能已经部分有序(特别是在快速排序的后期阶段),因此插入排序能够更快地完成排序任务。

为什么阈值取10左右?

经验值:通过大量的实验和观察,人们发现当子数组长度小于10时,使用插入排序通常比继续递归调用快速排序更高效。这个阈值是一个经验值,但在大多数情况下都能取得良好的效果。
平衡效率与开销:选择10作为阈值是为了在效率(即排序速度)和开销(即递归调用的额外负担)之间找到一个平衡点。当子数组长度小于10时,递归调用的开销开始变得显著,而插入排序则能够更高效地处理这些小规模数据。
优化二叉树深层节点:通过设定阈值并使用插入排序,我们可以优化二叉树中深层节点的处理。这些节点通常对应着较小的子数组,使用插入排序能够减少不必要的递归调用,从而提高算法的整体效率。

代码
void quickSort(int* a, int left, int right)
{
	if (left >= right)
		return;
	int begin = left, end = right;
	//随机选key
	/*int randIndex = left + (rand() % (right - left));
	swap(&a[left], &a[randIndex]);*/

	//三路取中
	int midIndex = getMidIndex(a, left, right);
	swap(&a[left], &a[midIndex]);

	int keyIndex = pre_cur(a, left, right);

	if ((right - left + 1) > 10) {
		quickSort(a, begin, keyIndex - 1);
		quickSort(a, keyIndex + 1, end);
	}
	else {
        //小区间优化
		insertSort(a, right);
	}
}

非递归版本

递归版本的快速排序有一个问题:递归的深度太深容易栈溢出。

思路

非递归版本的快速排序需要借助栈来实现。其实在递归版本的快速排序中,每一次递归中变化的主要是区间的大小,所以我们可以在每次单趟排序完后,让对应区间的子区间入栈。

具体步骤

1.初始化栈并推入初始索引

创建栈,将数组的起始和结束索引入栈:

2.开始循环处理栈顶元素

弹出栈顶的左右索引,确定当前子数组的范围。

这里需要注意,在入栈的时候,如果先入栈的是end,然后再入栈begin,那先出栈的就是begin,后出栈的就是end。(先进后出)

调用划分函数(即前面讲hoare版本的hoare函数、挖坑法的hole函数、快慢指针的pre_cur函数),获取key元素的位置:

根据key元素位置,将未排序的子数组索引范围入栈:

(如果子区间只有一个值或者没有值就不入栈)

重复2的步骤,直至栈为空,则排序完成。

3.最后销毁栈。

代码

void quickSortNonOrder(int* a, int n)
{
	st s;
	initStack(&s);
	int begin = 0, end = n - 1;
	pushStack(&s, end);
	pushStack(&s, begin);

	while (!isEmpty(&s))
	{
		int left = stackTop(&s);
		popStack(&s);
		int right = stackTop(&s);
		popStack(&s);
		int keyIndex = pre_cur(a, left, right);
		if (keyIndex - 1 > left) //左子区间的右端索引大于左端索引说明这个子区间不止一个值,就可以入栈
        {
			pushStack(&s, keyIndex - 1);
			pushStack(&s, left);
		}
		if (keyIndex + 1 < right) //右子区间的右端索引大于左端索引说明这个子区间不止一个值,就可以入栈
        {
			pushStack(&s, right);
			pushStack(&s, keyIndex + 1);
		}
		
	}

	destroyStack(&s);
}

这里的栈会用到前面讲解栈的代码:数据结构--栈和队列-CSDN博客

标签:right,递归,int,交换,----,key,排序,left
From: https://blog.csdn.net/2403_82706967/article/details/144274428

相关文章

  • JQuery 实现简易记事簿
    这里运用到的技术:1、localStorage保存数据到浏览器,和从浏览器中localStorage取到数据2、JSON.stringify()方法和JSON.parse()方法的运用3、setSelectionRange()方法4、数组的prop添加内容,splice(i,1)删除和替换splice(i,0,'aa')5、attr()和prop()6、on绑定事件html:<ht......
  • 【探商宝】如何一键导出上千公司名单
    销售在工作中,需要不停的开发新客户。传统的销售模式下,电话的重复率和线下面谈的机会不多,因此也很难做出业绩。现在新兴的销售手段,大多都是采用线索平台提供的线索,或是AI外呼机器人来进行,以上这些方式无论哪一种,都是使用的公开的工商信息,如企某查、天某查都是如此。今天我要推......
  • 论文写完还没完!如何在ChatGPT的帮助下高效完成审稿
    审稿是论文发表或提交前的重要环节,涉及对内容完整性、逻辑性、创新性、语言表达等多个方面的评估。合理使用ChatGPT,可以使审稿过程更系统、更高效。今天分享的内容是使用ChatGPT完成论文审稿的详细步骤和操作示例,助您轻松完成高质量的审稿任务。一键完成论文初稿,请关注底部微......
  • 颜色的基本处理
        数码相机能够获取彩色图像,但相机的色彩处理是一个非常复杂的过程,是非常重要的。    此过程生产制造商在细节方面都是不公布的,但是基本的概念是相同的。当相机捕捉一个真实场景时,是怎么还原成人眼所看到的图像呢?1.RAW----相机获取到的原始图像    ......
  • C++天使的灵动心跳代码:类和对象(中下)
    文章目录4.拷贝构造函数4.1默认拷贝构造函数4.2显式调用拷贝构造函数5.运算符重载函数5.1赋值运算符重载函数5.1.1默认赋值运算符重载函数5.1.2显式调用赋值运算符重载函数5.2const取地址运算符重载函数希望读者们多多三连支持小编会继续更新你们的鼓励就是我......
  • linux基础
    一、文件、目录篇●文件类型(-):普通文件(文本、可执行程序)​(d):目录(p):管道文件●常见系统文件目录/bin/:存放系统命令/boot/:系统启动目录/home/:存放普通用户的根目录(每个用户都有根目录)/dev/:设备文件保存位置/etc/:配置文件保存位置/root/:只有root用户才有权......
  • Linux虚拟机网络配置
    本章将和大家分享VMware虚拟机安装Linux系统时如何进行网络配置。一、设置VMware 虚拟网络选择虚拟网络编辑器:选择更改设置:此处选择VMnet0、桥接模式、自动,然后应用并确定。二、编辑Linux虚拟机网络点击【网络适配器】,选择【自定义(U):特定虚拟网络】,选择【......
  • 新浪导航栏
    运用VisualStudioCode软件编写新浪导航栏,如下图图1新浪导航、手机星浪网、移动客户端、微博都是超链接,当鼠标经过其中一个标签,该标签背景和文字颜色要改变,如图2图2代码如下:<!DOCTYPEhtml><htmllang="en"><head><metacharset="UTF-8"><metaname="viewpo......
  • 有没有学网络空间安全的学长,想知道学长们毕业以后都去干嘛了?
    我作为一个零基础小白到白帽黑客,也认识到了很多零基础小白的,有一些网络空间安全的学员,但是大多数还是非计算机相关专业的学员。他们通过系统学习网络安全,掌握黑客技术之后,都找到了自己满意的工作。同学A:大学霸一枚,励志在化学行业里面发挥光芒。没想到化学行业就是天坑专业......
  • 深入源码:Spring Boot 内置 Tomcat 的实现机制分析
     在本文章中,我们将从源码层面深入分析SpringBoot如何实现内置Tomcat的功能。通过对相关代码的剖析,我们将揭示内置Tomcat的工作原理以及其在SpringBoot应用中的集成方式。这一过程不仅有助于理解SpringBoot的设计思路,还能为开发者在实际应用中提供更深入的见解。......