目录
冒泡排序
1.冒泡排序的核心思想
2.冒泡排序的思路步骤
3.冒泡排序代码
4.代码分析
5.对冒泡排序的时间复杂度是O(N^2)进行解析
6.冒泡排序的特性总结
递归实现快速排序(二路划分版本)
1.快速排序基本思路
2.代码思路步骤
3.代码实现
4.代码分析
(1)递归终止条件
(2)小区间优化
(3)单趟排序
(4)递归排序左右子区间
(5)合并结果
(6)总结
5.小区间优化
快速排序的问题
对于快速排序采用小区间优化的代码解析
小区间优化的价值
总结
快速排序单趟的作用
Hoare版本单趟
1.Hoare版本单趟的思路步骤
2.对选左边做key则右边right先走可以保证right和left相遇位置的值一定比key要小进行解析
3.Hoare版本单趟的作用
4.Hoare版本单趟的代码
5.代码解析
5.1.解析Hoare版本单趟PartSort1函数的形参包括begin和end的原因
5.2.Hoare版本单趟排序过程
(1)R指针从右向左移动
(2)L指针从左向右移动
(3)交换R和L指针指向的元素
(4)循环结束,交换基准值到正确位置
5.3.对三数取中函数GetMidIndex进行解析
5.3.1.三数取中的背景
5.3.2.对三数取中选key进行解析
5.3.3.三数取中函数GetMidIndex的代码解析
5.3.4在Hoare版本代码如何使用GetMidIndex三数取中函数进行解析
6.快速排序采用Hoare版本单趟的代码递归过程
7.快速排序函数的时间复杂度
7.1.未采用三数取中策略的时间复杂度
7.2.解析在理想状态下快速排序的时间复杂度是O(N*logN)。
理想状态下的快速排序
分析递归树
递归树的深度
每层的操作数量
总操作数量
总结
7.3.采用三数取中策略后的时间复杂度
7.4.总结
挖坑法版本单趟
1.挖坑法版本单趟的思路步骤
2.挖坑法版本单趟的代码
前后指针版本单趟
1.前后指针版本单趟的思路步骤
2.详细说明前后指针prev、cur的作用
2.1.prev 指针的作用
2.2.cur 指针的作用
2.3.总结
3.对于前后指针版本单趟排序的本质理解
3.1.前后指针版本单趟排序的本质
3.2.总结
4.前后指针版本单趟的代码
递归实现快速排序(三路划分版本)
1.三路划分版本的快速排序思路步骤
2.三路划分版本的快速排序代码
3.三路划分快速排序的作用
3.1.处理重复元素
3.2.优化性能:
3.3.减少递归深度:
3.4.适用于实际数据:
3.5.总结
冒泡排序
1.冒泡排序的核心思想
冒泡排序是一种简单的排序算法,其核心思想是通过相邻元素的比较和交换,逐步将较大的元素“浮”到数组的末尾。
2.冒泡排序的思路步骤
图形解析:
冒泡排序的思路步骤如下:
(1)开始比较:从数组的第一个元素开始,比较相邻的两个元素。
(2)交换元素:如果第一个元素比第二个元素大(对于升序排序),则交换这两个元素的位置。
(3)遍历数组:对数组中的每一对相邻元素执行相同的比较和交换操作,直到数组的最后一个元素。在这一步结束时,最大的元素会被“冒泡”到数组的最后一个位置。
(4)重复过程:除了最后一个已经排好序的元素外,对数组中剩下的元素重复步骤1到3。每完成一次完整的遍历,都会将一个最大元素放到正确的位置。
(5)减少比较范围:由于每一轮比较都会将一个最大元素放到正确的位置,因此在下一轮比较时,可以减少一个元素的比较范围。
(6)结束条件:当整个数组都已经排序好,即不再需要交换任何元素时,算法结束。
(7)最终结果:通过上述步骤,数组中的元素将按照升序排列,冒泡排序完成。
3.冒泡排序代码
//交换函数
void Swap(int* p1, int* p2)
{
int tmp = *p1;
*p1 = *p2;
*p2 = tmp;
}
//冒泡排序
// 1.冒泡排序的时间复杂度:O(N^2)
// 2.冒泡排序适用的场景:序列本身有序(注意:冒泡排序对趋近于有序序列进行排序的效率也不是很高;冒泡排序对乱序序列进行排序是不能起到优化作用,使得排序的时间复杂度依然是O(N^2))
void BubbleSort(int* a, int n)
{
//1.冒泡排序单趟次数:n - 1趟
for (int j = 0; j < n - 1; ++j)
{
//2.定义临时变量exchange来标记冒泡排序的一趟过程中是否发生交换。若没有交换则exchange = 0;发生交换则exchange = 1。
int exchange = 0;
//3.冒泡排序一趟的过程。而每趟冒泡排序要比较的对数n-1-j。(注意:若排成升序的话,则冒泡排序的一趟就决定了n-j个元素中最大元素在数组中的最终位置)
for (int i = 1; i < n-j; ++i)
{
//4.若前一个元素a[i - 1]比后一个元素a[i]要大,则交换两个元素的值。
if (a[i - 1] > a[i])
{
Swap(&a[i - 1], &a[i]);
exchange = 1;
}
}
//5.每跑完一趟冒泡就要判断一趟的冒泡排序的过程中是否发生元素交换,若没有发生交换则说明序列已经有序了则此时不需要在进行冒泡排序。
if (exchange == 0)
{
break;
}
}
}
4.代码分析
-
外层循环控制排序趟数:
for (int j = 0; j < n - 1; ++j)//
外层循环控制排序的趟数。因为如果有n个元素,那么排序完成后最大的n-1个元素将会被放置在其最终位置上,所以只需要进行n-1趟排序。
-
标记是否发生交换:
int exchange = 0;//
在每趟排序开始前,设置一个标记exchange
,用来记录该趟排序过程中是否发生了元素交换。如果一趟排序中没有发生任何交换,说明所有元素已经处于有序状态,可以提前结束排序。
-
内层循环执行一趟排序:
for (int i = 1; i < n-j; ++i)//
内层循环执行一趟冒泡排序。随着排序的进行,每趟排序后最大元素会被放置在正确的位置,因此每趟排序后可以减少一次比较,即n-j
。
-
比较和交换元素:
if (a[i - 1] > a[i])//
在内层循环中,逐对比较相邻元素。Swap(&a[i - 1], &a[i])//
如果发现前面的元素大于后面的元素(在升序排序的情况下),则交换这两个元素的位置。exchange = 1;//
每次交换后,将exchange
标记为1,表示本趟排序发生了交换。
-
检查是否可以提前结束排序:
if (exchange == 0)//
一趟排序完成后,检查exchange
标记。如果exchange
仍为0,说明这趟排序中没有发生任何交换,数组已经是有序的,因此可以跳出外层循环,结束排序。
总结:冒泡排序通过两重循环实现,外层循环控制排序的趟数,内层循环进行实际的元素比较和交换。通过设置一个交换标记,可以在数组已经有序的情况下提前终止排序,提高效率。
5.对冒泡排序的时间复杂度是O(N^2)进行解析
冒泡排序的时间复杂度是O(n^2),其中n是待排序元素的数量。这个时间复杂度是在最坏和平均情况下得出的,具体如下:
-
最坏情况(Worst Case):当对逆序序列进行排序时,冒泡排序需要进行最大次数的比较和交换。在这种情况下,每一趟排序都需要比较n-1次,总共需要进行n-1趟排序,因此比较次数是(n-1) + (n-2) + … + 1,这等价于(n-1)(n)/2,即O(n^2)。
-
平均情况(Average Case):在平均情况下,冒泡排序的时间复杂度也是O(n^2),因为平均情况下仍然需要比较接近n^2/2次。
-
最好情况(Best Case):当对有序序列进行排序时,冒泡排序可以在第一趟排序后立即结束,因为不需要进行任何交换。在这种情况下,冒泡排序的时间复杂度是O(n),因为它只需要进行一次遍历来确认序列已经是有序的。
需要注意的是,尽管最好情况下的时间复杂度是O(n),但由于冒泡排序通常不适用于大数据集,因此它的效率通常低于其他更高效的排序算法,如快速排序、归并排序或堆排序。
6.冒泡排序的特性总结
(1)冒泡排序是一种非常容易理解的排序
(2)时间复杂度:O(N^2)
(3)空间复杂度:O(1)
(4)稳定性:稳定
递归实现快速排序(二路划分版本)
1.快速排序基本思路
快速排序是Hoare于1962年提出的一种二叉树结构的交换排序方法,其基本思想为:任取待排序元素序列中的某元素作为基准值key,按照该排序码将待排序集合分割成两子序列,左子序列中所有元素均小于基准值key,右子序列中所有元素均大于基准值key,然后最左右子序列重复该过程,直到所有元素都排列在相应位置上为止。
注意:递归实现快速排序的思路和与二叉树前序遍历规则非常像,所以我们在写递归框架时可想想二叉树前序遍历规则即可快速写出来。
2.代码思路步骤
-
选择基准值key:在待排序的区间中选择一个元素作为基准值key(通常可以选择区间的第一个元素、最后一个元素或者是随机选择的一个元素),这里我选择待排序的区间的第一个元素作为基准值key。
-
分区:将待排序的区间分成两个子区间,使得左子区间中的所有元素都不大于基准值key,而右子区间中的所有元素都不小于基准值key。这一步称为快速排序的“单趟排序”。
-
递归排序子区间:递归地对左子区间和右子区间重复执行快速排序的过程。
-
合并结果:由于快速排序是在原地进行排序的,所以不需要额外的合并操作,当所有递归调用完成后,整个数组就已经有序了。
3.代码实现
注意:递归实现的快速排序一共有3种单趟排序的写法,这3种单趟分别叫做:Hoare、挖坑法、前后指针,下面会逐一介绍,而单趟排序函数的函数名是PartSort。
//直接插入排序
void InsertSort(int* a, int n)
{
//for循环的作用是:进行n - 1次直接插入排序单趟把数组第2个元素开始往后的n - 1个数据插入
到有序序列进而把数组中n个数据排序成升序。
for (int i = 0; i < n-1; ++i)
{
//注意:我们一开始不认为整个要排序的数组是个有序序列,而是一开始我们认为数组的第一
个元素就是个有序序列而数组第二个元素就是要插入到有序序列中的数据,等到数组第二个元
素插入到有序序列之后会使得数组前2个元素组成一个有序序列,进而使得数组第三个元素就是
插入到有序序列中的据,以此类推下去。
//在一个有序序列中插入一个数据进行排序的单趟过程:
//1.用end指向数组中的有序序列最后一个元素
//一开始end表示的数组中的有序序列最后一个元素的下标,而i是数组元素的下标。
int end = i;
//2.用变量tmp临时存储插入到有序序列的数据。而且这个插入数据是在有序序列最后一个元素
的后面。
//一开始下标end+1就是插入到有序序列中插入数据的下标。由于插入数据a[end + 1]在插入
的过程中有可能被有序序列中的其他数据覆盖掉所以我们要用临时变量tmp把插入
数据存储起来。
int tmp = a[end + 1];
//3.插入数据tmp在有序序列中插入的过程
//3.1.在有序序列中找到比插入数据tmp要小的数
while (end >= 0)
{
//注意:即使一开始end表示的是有序序列最后一个元素的下标,但是我们是用end从后往
前遍历整个有序序列
//若插入数据tmp比有序序列元素a[end]要小,则有序序列元素a[end]要往后挪动一步。
if (tmp < a[end])
{
//挪动数据
a[end + 1] = a[end];
--end;
}
//若插入数据tmp大于或等于有序序列的元素a[end],则停止比较有序序列元素和插入数据
tmp,然后在此时下标end+1的位置插入数据。
else
{
break;
}
}
//3.2.插入数据tmp插入到有序序列中比自己要小的数据的后面一个位置。
//插入数据tmp永远是在数组下标end的后一个位置插入的,即插入数据在数组下标end+1的位
置插入数据。
a[end + 1] = tmp;
}
}
void QuickSort(int* a, int begin, int end)
{
//1.当begin >= end时说明当前要排序的区间[begin,end]中只有1个或者0个元素则此时可以认为当前排序区间[begin,end]是个有序区间则此时不需要对该区间进行快速排序。
if (begin >= end)
{
return;
}
//2.把当前区间[begin,end]排序成有序的过程:
//2.1.若区间[begin,end]中的元素个数 < 15的话,则直接利用直接插入排序对区间[begin,end]
中的所有元素排序成有序,排完后就直接结束本次快速排序。
//(注意:元素数量的计算方式end - begin + 1)
if ((end - begin + 1) < 15)
{
//(小区间优化)小区间用直接插入替代,减少递归调用次数。(注意:这里不利用希尔排序把小
区间排成有序的原因是当数据量少时体现不出希尔排序的效率而且也没必要使用希尔排序)
//注意:直接插入排序一定是在区间 [begin,end]在数组中的起始位置a + begin开始把区间
[begin,end]排序成有序。
InsertSort(a + begin, end - begin + 1);
}
else//2.2.若区间[begin,end]中的元素数量 >= 15的话,则选择递归思路的快速排序来对区间
[begin,end]中的所有元素排序成有序。
{
//2.2.1.模拟二叉树前序遍历的递归思路来让快速排序函数QuickSort把区间[begin,end]排
序成有序。
//(1)利用快速排序单趟排序函数PartSort1或者PartSort2或者PartSort3确定当前区间
[begin,end]选的基准值key(即相等于根结点)在快速排序后的最终的位置。
int keyi = PartSort3(a, begin, end);
//由于单趟结束后确定了基准值key在区间[begin,end]最终的位置,进而把当前要待排序区
间[begin,ene]分割成左子区间[begin, keyi-1]、基准值下标keyi、右子区间[keyi+1, end],
类似于二叉树前序遍历在一次递归调用后当前二叉树被分割成左子树、根结点、右子树。
//(2)递归利用快速排序函数QuickSort把当前区间[begin,end]的左右子区间排成有序后,则
当前区间[begin,end]就会变得有序。
//递归把左子区间[begin, keyi-1]排成有序
QuickSort(a, begin, keyi - 1);
//递归把右子区间[keyi+1, end]排成有序
QuickSort(a, keyi + 1, end);
}
}
4.代码分析
(1)递归终止条件
当begin
大于或等于end
时,表示当前处理的区间已经没有元素或者只有一个元素,可以认为该区间已经是有序的,因此不需要进行排序,直接返回。
(2)小区间优化
当区间[begin, end]
中的元素个数小于15时,快速排序的性能优势不明显,因此可以使用直接插入排序来对区间内的元素进行排序。直接插入排序在处理小规模数据时效率较高,且实现简单。
(3)单趟排序
当区间[begin, end]
中的元素个数大于或等于15时,执行快速排序的单趟排序操作。单趟排序的目的是确定一个基准值key在数组中的最终位置,使得基准值key左侧的所有元素都不大于基准值,右侧的所有元素都不小于基准值。在代码中,这一步是通过调用单趟排序函数PartSort1或者PartSort2或者PartSort3来
实现的,该函数返回基准值的最终位置keyi
。
(4)递归排序左右子区间
在确定了基准值key的最终位置后,数组被分割成两个子区间:[begin, keyi-1]
和[keyi+1, end]
。接下来,递归地对这两个子区间进行快速排序。递归的过程类似于二叉树的前序遍历,先处理左子树,再处理右子树。
(5)合并结果
由于快速排序是原地排序,在递归过程中不需要合并操作。当所有递归调用完成后,整个数组就已经被排序好了。
(6)总结
快速排序通过递归和分治的策略,将大区间分割成小区间,并在小区间上使用直接插入排序来优化性能。每趟排序都会确定一个基准值key的最终位置,然后将数组分割成两部分,递归地对这两部分进行排序,直到整个数组变得有序。
5.小区间优化
图形解析:
-
快速排序的问题
-
问题背景:
- 当排序大型数组时,如果递归层数很高,最底层的区间将包含很少的元素。
- 对于这些小区间,使用快速排序的递归方法可能会产生过多的函数调用栈帧,从而造成不必要的开销。
-
小区间优化法的解决方式:
- 当区间元素数量减少到一定程度(例如10到20个元素)时,不再使用快速排序的递归方法,而是改用直接插入排序。
- 直接插入排序在小数据量时效率较高,且实现简单。
-
为什么选择直接插入排序:
- 对于小数据量的序列,直接插入排序和希尔排序的性能差异不大。
- 希尔排序需要进行预排序,对于小区间来说这是不必要的。
-
对于快速排序采用小区间优化的代码解析
-
if ((end - begin + 1) < 15) { //(小区间优化)小区间用直接插入替代,减少递归调用次数。(注意:这里不利用希尔排序把小 区间排成有序的原因是当数据量少时体现不出希尔排序的效率而且也没必要使用希尔排序) //注意:直接插入排序一定是在区间 [begin,end]在数组中的起始位置a + begin开始把区间 [begin,end]排序成有序。 InsertSort(a + begin, end - begin + 1); }
-
直接插入排序函数
InsertSort
:- 该函数对数组的一个区间进行直接插入排序。
- 通过一个
for
循环,将每个元素插入到其左侧已排序的序列中。
-
快速排序函数
QuickSort
:- 在递归过程中,如果区间大小小于15,则调用
InsertSort
进行排序。 - 否则,继续使用快速排序的递归方法。
- 在递归过程中,如果区间大小小于15,则调用
-
递归调用:
- 快速排序的单趟函数(如
PartSort1
)用于确定基准值的最终位置,并将数组分为左右两个子区间。 - 然后递归地对左右子区间进行快速排序。
- 快速排序的单趟函数(如
-
小区间优化的价值
-
减少递归次数:
- 在最理想的状态下,快速排序的递归过程形成满二叉树。
- 小区间优化可以显著减少快速排序的递归次数,尤其是对于最底层的区间。
-
广泛应用:
- 官方库和
qsort
等标准排序函数常采用小区间优化。
- 官方库和
-
总结
-
Hoare版本的快速排序通过两种优化方式提高效率:
- 三数取中:避免最坏情况下的性能下降。(注意:三数取中下面会提到)
- 小区间优化:减少递归调用的次数,提高排序效率。
-
在面试中,建议先写出基础的Hoare版本快速排序,然后再引入这两种优化策略。
快速排序单趟的作用
快速排序的单趟排序(即一次分区操作)的作用是:
-
选择基准值:在数组中选择一个元素作为基准值(pivot)。
-
调整元素位置:通过比较和交换元素,将数组中的元素重新排列,使得所有小于基准值的元素都移到基准值的左边,所有大于基准值的元素都移到基准值的右边。
-
确定基准值位置:在完成上述调整后,基准值会被放置在其最终排序位置上,这个位置是数组排序后该基准值应该所在的位置。
具体来说,单趟排序的作用可以概括为以下几点:
-
划分操作:将大问题分解为小问题,通过一次分区操作,将数组分为两个子数组,一个子数组的所有元素都不大于基准值,另一个子数组的所有元素都不小于基准值。
-
局部有序:尽管整个数组还未完全有序,但经过一次分区操作后,基准值已经处于最终位置,且其左侧和右侧的子数组元素相对位置关系已经确定。
-
递归基础:单趟排序为后续的递归排序提供了基础。因为基准值的位置已经确定,所以接下来只需要递归地对左侧和右侧的子数组进行同样的快速排序操作。
-
减少比较次数:通过单趟排序,减少了后续排序过程中需要比较的元素对数,从而提高了整体排序的效率。
总的来说,快速排序的单趟排序是整个快速排序算法中非常关键的一步,它不仅确定了基准值的最终位置,也为递归排序提供了子问题,是快速排序高效性的核心所在。
Hoare版本单趟
1.Hoare版本单趟的思路步骤
图形解析:
注意:下面是以排升序为例来阐述Hoare版本单趟的思路。
-
步骤1->选择基准值key:从待排序的数组中选择一个元素作为基准值key。通常可以选择待排序的数组最左边元素、最右边元素或者中间元素等。这里我选择待排序的数组最左边元素为基准值key。注意:若是选待排序的数组最左边做key则一定是让右边的指针right先走;若是选数组待排序的数组最右边元素做key则一定是让左边的指针left先走。(原因:选左边做key则右边 right先走可以保证right和left相遇位置的值一定比key要小;选右边做key则左边left先走可以保证right和left相遇位置的值一定比key要大)
-
步骤2->初始化两个指针:设置两个指针,分别称为left和right,初始时left指向数组的第一个元素,right指向数组的最后一个元素。
-
步骤3->指针移动和交换:(注意:由于是选待排序的数组的第一个元素作为基准值key则必须让指针right先走)
- right指针从右向左一直移动,直到找到比基准值key要小的元素就停下来,然后等left在左边找到比基准值key要大的数。
- left指针从左向右一直移动,直到找到比基准值key要大的元素就停下来。
- 当right、left两个指针都停止移动时,交换这两个指针所指向的元素。
-
步骤4->分区:重复步骤3,直到left和right指针相遇。在指针相遇的位置,将基准值key与left或right指针所指向的元素交换(注意:由于是选待排序的数组的第一个元素作为基准值key而且单趟排序开始时让指针right先走,使得left和right相遇点位置的元素一定比基准值key要小),这样基准值key就被放置在了正确的位置上,即所有小于基准值key的元素都在其左侧,所有大于基准值key的元素都在其右侧。
-
步骤5->返回基准值位置:返回基准值key最终所在的位置keyi,这个位置将用于下一步的递归调用。
2.对选左边做key则右边right先走可以保证right和left相遇位置的值一定比key要小进行解析
注意:这里把right简称R,而left简称L。
(1) R和L相遇的两种情况及图形解析
注意:由于Hoare版本单趟排序的思路导致R和L是不可能同时移动的,而且只能是R移动而L停来或者L移动而R停下来。
①R和L相遇的类型1:一种是R先停下来,L在一走直到L与R相遇,而相遇位置就是R停住的位置。
图形解析:类型1的相遇点位置是R停下来的位置。而R只有遇到比key要小的值才会停下来,进而使得此时R停住位置的值一定比key要小,而L是一直走过来和停住的R进行相遇,最终相遇时会发现相遇位置的值都比key要小。
②R和L相遇的类型2:一种是L先停下来,R在一走直到R与L相遇,而相遇位置就是L停住的位置。
图形解析:类型2是L找到比key要大的值后才停下来但是L下来后必须先让R和L位置的元素发生交换完后才会让R走,由于R和L位置的元素交换完后此时L停住位置的值一定比key要小,而R是一直走过来和停住的L进行相遇,最终相遇时会发现相遇位置的值都比key要小。
总的来说,这两种相遇情况使得R和L相遇位置的值都比key要小。
3.Hoare版本单趟的作用
①把当前待排序区间分割出左右区间,左区间的元素比基准值key小或者相等,右区间的元素比基准值key大或者相等。
②单趟结束后,key已经落到他的正确的位置(注:正确的位置指的是一次单趟完了之后基准值key会落到快速排序完后的最终位置,即一次单趟就确定一个key的值在快速排序完后最终的位置)
注意:一次单趟就确定一个key的值在快速排序完后最终的位置的原因:由于一次单趟就分割出左右区间,而且左区间的所有值比key要小而右区间比key要大,最终导致key的值在一次单趟后就找到了快速排序完后最终的位置。图形解析,如下图所示:
4.Hoare版本单趟的代码
//交换函数
void Swap(int* p1, int* p2)
{
int tmp = *p1;
*p1 = *p2;
*p2 = tmp;
}
// 三数取中函数->作用:选出下标begin、(begin + end) / 2、 end的中间值并返回。
// begin mid end
int GetMidIndex(int* a, int begin, int end)
{
int mid = (begin + end) / 2;
if (a[begin] < a[mid])
{
if (a[mid] < a[end])
{
return mid;
}
else if (a[begin] > a[end])
{
return begin;
}
else
{
return end;
}
}
else //a[begin] >= a[mid]
{
if (a[mid] > a[end])
{
return mid;
}
else if (a[begin] < a[end])
{
return begin;
}
else
{
return end;
}
}
}
//Hoare版本单趟
//时间复杂度:O(N)
//小结:
//注意:快速排序最坏情况是对逆序序列进行排序;最好情况是对有序、接近有序的序列进行排序。
//(1)快速排序的Hoare版本的单趟内采用三数取中以后,即使是在把逆序排序成升序的这种最坏情况下,Hoare版本的快速排序瞬间也可以利用三数取中把最坏情况变成最好情况。
//(2)由于三数取中的关系导致Hoare版本的快速排序几乎不会出现最坏情况,使得Hoare版本的快速排序的时间复杂度可以认为是O(N*logN)。
//(3)若快速排序的Hoare版本单趟没有三数取中,则最坏情况下的时间复杂度是O(N^2);最好情况下的时间复杂度是O(N*logN)。
//注意:下面说的区间[begin,end]就是数组中待排序数据对应的下标访问范围。
int PartSort1(int* a, int begin, int end)
{
//1.利用三数取中函数GetMidIndex选出区间[begin,end]中下标begin、(begin + end) / 2、
end位置的3个元素的中间值作为Hoare版本单趟的基准值key。
//注意:三数取中的目的是防止选的key是区间[begin,end]中最大的或者是最小的数进而导致快
速排序的时间复杂度是最坏的情况O(N^2)
int mid = GetMidIndex(a, begin, end);
//2.让中间值a[mid]存放在区间[begin,end]最左边begin位置,以此方便取位于区间[begin,
end]最左边begin位 置的区间中间值作为基准值key。
Swap(&a[begin], &a[mid]);
//3.让L指向区间[begin,end]最左边的位置,让R指向区间[begin,end]最右边的位置。
int left = begin, right = end;
//4.选区间[begin,end]最左边begin位置的元素作为基准值key,并且用keyi标记基准值key在数
组中的位置。
int keyi = left;
//5.Hoare版本单趟的过程
while (left < right)
{
//注意:由于我们选的是区间[begin,end]最左边begin位置的元素作为基准值key,所以在开
始进行Hoare版本单趟时一定让R从右边先走。
//5.1.R从右往左一直走直到找到比key要小的值就停下来去等L在左边找到比key要大的数。
//R在右边找小的数。
while (left < right && a[right] >= a[keyi])
{
--right;
}
//5.2.L从左往右一直走直到找到比key要大的值就停下来
//L在左边找的的数。
while (left < right && a[left] <= a[keyi])
{
++left;
}
//5.3.由于此时R找到比key要小的值而L找到比key要大的值所以此时交换R和L之间的值
Swap(&a[left], &a[right]);
}
//5.4.当while (left < right)循环结束后,由于此时R和L相遇,则相遇之后就把基准值key和相
遇点的元素a[left]发生交换,当交换完后就结束了单趟的过程。
Swap(&a[left], &a[keyi]);
//6.由于Hoare版本的单趟结束后,基准值key来到了相遇点的位置,则要更新keyi的值使得下标
keyi始终指向基准值key。
keyi = left;
//7.由于一次Hoare版本的单趟结束后,基准值key已经落到它快速排序后最终位置,所以这里返回
基准值keyi的下标来告诉主调函数基准值key在整个数组中的位置,以防止主调函数继续对基准值
key进行排序。
return keyi;
}
5.代码解析
5.1.解析Hoare版本单趟PartSort1
函数的形参包括begin
和end
的原因
在快速排序的Hoare版本中,PartSort1
函数的形参包括begin
和end
的原因是为了定义待排序数组的子区间的起始和结束位置。以下是对为什么需要这两个参数的分析:
-
定义子区间:
begin
:表示子区间的起始索引。end
:表示子区间的结束索引(这是个闭区间,即包括end
位置的元素)。
-
递归分治: 快速排序的核心是分治策略,它将大问题分解为小问题。通过
begin
和end
,我们可以指定当前递归调用需要处理的子数组部分。在每一轮递归中,我们只关注这个子区间内的元素排序。 -
确定操作范围:
- 在单趟排序中,我们需要在指定的子区间内进行元素比较和交换,以确定基准值的位置。
begin
和end
限定了比较和交换操作的范围,防止对数组其他部分的元素进行不必要的操作。
-
处理不同大小的子数组:
- 在快速排序的每一步中,子数组的大小都在变化。通过传递
begin
和end
,函数可以适应不同大小的子数组。 - 这也使得快速排序可以处理整个数组,也可以处理数组的一部分。
- 在快速排序的每一步中,子数组的大小都在变化。通过传递
-
递归调用:
- 在单趟排序完成后,基准值的位置被确定,数组被分成两部分。此时,我们需要对这两部分分别进行递归排序。
- 使用
begin
和end
参数,我们可以为左子区间和右子区间分别调用PartSort1
函数,并传递新的起始和结束索引。
5.2.Hoare版本单趟排序过程
(1)R指针从右向左移动
- 初始时,right = end使得R指针指向子区间[begin,end]的最后一个元素。
- R指针从右向左移动,寻找小于基准值
a[keyi]
(位于begin
位置)的元素。 while (left < right && a[right] >= a[keyi])
:这个循环确保R指针在找到小于基准值的元素之前一直向左移动。left < right
条件防止指针越界,a[right] >= a[keyi]
确保R指针在找到小于基准值的元素时停止。
(2)L指针从左向右移动
- 初始时,left = begin使得L指针指向子区间[begin,end]的第一个元素。
- L指针从左向右移动,寻找大于基准值
a[keyi]
的元素。 while (left < right && a[left] <= a[keyi])
:这个循环确保L指针在找到大于基准值的元素之前一直向右移动。left < right
条件防止指针越界,a[left] <= a[keyi]
确保L指针在找到大于基准值的元素时停止。
(3)交换R和L指针指向的元素
- 当R和L指针都停止移动时,它们分别指向了小于和大于基准值的元素。此时交换
a[left]
和a[right]
的值,这样较小的元素就被移到了左边,较大的元素被移到了右边。
(4)循环结束,交换基准值到正确位置
- 当
while (left < right)
循环结束时,R和L指针相遇,这意味着所有小于基准值的元素都已经被移到了左边,所有大于基准值的元素都已经被移到了右边。 - 此时,需要将基准值
a[keyi]
交换到R和L指针相遇的位置,即a[left]
。这是通过Swap(&a[left], &a[keyi])
实现的。 - 注意,这里的
keyi
是一个变量,它保存了基准值的初始位置(即begin
)。我们不能使用局部变量key
来存储基准值,因为这样不会改变数组中begin
位置元素的值。正确的做法是交换a[left]
和a[keyi]
,这样基准值就被放置在了正确的位置上。
通过以上步骤,单趟排序完成了对子区间的划分,基准值被放置在其最终位置,左边的所有元素都不大于基准值,右边的所有元素都不小于基准值。接下来,可以对左右子区间递归地进行快速排序。
5.3.对三数取中函数GetMidIndex进行解析
5.3.1.三数取中的背景
(1)快速排序最糟糕情况的原因:
- 快速排序如果仅选择区间最左边或最右边的元素作为基准值(key),在处理有序或接近有序的序列时,每次分区操作只能将数组分成一个极小的子区间和一个较大的子区间。这导致递归树的深度达到数组的长度N,每次操作处理的元素数量递减,最终操作次数形成等差数列,总操作次数接近N^2,时间复杂度为O(N^2)。
(2) 三数取中防止最糟糕情况:
- 由于无法预知数组序列的有序程度,使用三数取中可以避免在有序或接近有序数组上执行快速排序时遇到最糟糕情况。三数取中通过选择区间最左边、中间和最右边三个元素中的中值作为基准值,确保了基准值的选择更加合理,从而避免了递归树极度不平衡的情况。
(3) 三数取中不改变Hoare版本快速排序的写法:
- 三数取中方法不需要改变Hoare版本快速排序的算法结构。通过
GetMidIndex
函数确定中值后,与区间最左边的元素交换,保证了每次分区操作选择的基准值都是三个数的中值,从而优化了快速排序的性能。
5.3.2.对三数取中选key进行解析
(1) 三数取中的思路:
- 三数取中考虑区间[begin, end]中的三个元素:最左边的元素a[begin],中间的元素a[mid],和最右边的元素a[end]。通过比较这三个元素,选择它们的中值作为基准值key。
(2) 三数取中函数GetMidIndex
的作用:
GetMidIndex
函数负责选出三个数中的中值,并返回其下标。通过这种方式,可以确保基准值不会是区间中的最大或最小值,从而避免了快速排序在最糟糕情况下的时间复杂度。
(3) 三数取中在快速排序单趟的作用:
- 在Hoare版本的单趟排序中,通过三数取中选出的基准值key,可以使得分区操作更加均匀,避免了递归树深度过深的问题。这保证了快速排序在大多数情况下都能保持O(N*logN)的时间复杂度。
5.3.3.三数取中函数GetMidIndex的代码解析
// 三数取中
// begin mid end
int GetMidIndex(int* a, int begin, int end)
{
int mid = (begin + end) / 2;
if (a[begin] < a[mid])
{
if (a[mid] < a[end])
{
return mid;
}
else if (a[begin] > a[end])
{
return begin;
}
else
{
return end;
}
}
else // a[begin] >= a[mid]
{
if (a[mid] > a[end])
{
return mid;
}
else if (a[begin] < a[end])
{
return begin;
}
else
{
return end;
}
}
}
- 三数取中函数
GetMidIndex
的目的是在给定的数组区间[begin, end]内,选择三个特定位置的元素(begin位置、中间位置、end位置)的中间值,并返回这个中间值的下标。以下是函数的详细分析: -
计算中间位置的下标:
int mid = (begin + end) / 2;
这里计算了begin和end之间位置的下标,即数组的中间位置。 -
比较begin、mid和end位置的元素: 接下来,函数通过一系列的条件判断来确定这三个位置的中间值。
-
第一种情况:如果
a[begin] < a[mid]
,这意味着begin位置的元素小于中间位置的元素。- 如果
a[mid] < a[end]
,这意味着中间位置的元素小于end位置的元素,所以中间位置的元素就是这三个数中的中间值。 - 如果
a[mid] >= a[end]
,这意味着中间位置的元素大于或等于end位置的元素。- 如果
a[begin] > a[end]
,这意味着begin位置的元素大于end位置的元素,所以begin位置的元素是中间值。 - 否则(
a[begin] <= a[end]
),end位置的元素是中间值。
- 如果
- 如果
-
第二种情况:如果
a[begin] >= a[mid]
,这意味着begin位置的元素大于或等于中间位置的元素。- 如果
a[mid] > a[end]
,这意味着中间位置的元素大于end位置的元素,所以中间位置的元素是中间值。 - 如果
a[mid] <= a[end]
,这意味着中间位置的元素小于或等于end位置的元素。- 如果
a[begin] < a[end]
,这意味着begin位置的元素小于end位置的元素,所以begin位置的元素是中间值。 - 否则(
a[begin] >= a[end]
),end位置的元素是中间值。
- 如果
- 如果
-
-
返回中间值的下标: 函数最终返回的是中间值的下标,而不是中间值本身。
- 通过这种方式,
GetMidIndex
函数能够有效地在begin、mid和end这三个位置的元素中选出中间值,从而避免了在快速排序中选择极值作为基准值可能导致的性能问题。
5.3.4在Hoare版本代码如何使用GetMidIndex三数取中
函数进行解析
Swap
函数用于交换两个元素的值。GetMidIndex
函数通过比较三个特定位置的元素来确定中值,并返回其下标。- 在
PartSort1
函数中,首先使用GetMidIndex
确定基准值的下标,并将其与区间最左边的元素交换,以确保基准值位于数组的最左边。 - 接下来,通过两个指针
left
和right
分别从区间两端向中间移动,比较并交换元素,直到两个指针相遇。 - 最后,将基准值交换到相遇点的位置,完成单趟排序,并返回基准值的最终位置。
通过这种方式,三数取中确保了快速排序算法在面对各种不同类型的数组时都能保持较高的效率。
6.快速排序采用Hoare版本单趟的代码递归过程
注意:快速排序的递归实现思路有点类似于二叉树的前序遍历,进而导致快速排序的递归过程就像一个完全二叉树。
7.快速排序函数的时间复杂度
7.1.未采用三数取中策略的时间复杂度
未采用三数取中且遇到最坏情况的快速排序递归调用展开图的图形解析:
平均情况的快速排序递归调用展开图的图形解析:
- 最坏情况:如果每次分区操作都是最不平衡的(例如,基准值key总是最小或最大的元素),那么快速排序的时间复杂度会退化到O(N^2)。这种情况可能发生在数组已经有序或者接近有序时。
- 平均情况:在没有采用三数取中策略的情况下,快速排序的平均时间复杂度通常是O(N*logN),但由于存在最坏情况,这个平均复杂度是基于随机输入的。
7.2.解析在理想状态下快速排序的时间复杂度是O(N*logN)。
图形解析:
快速排序在理想状态下的时间复杂度是O(N*logN),这个结论是基于以下分析和计算得出的:
理想状态下的快速排序
在理想状态下,快速排序的每一次分区操作都能将数组分成两个大小相等的部分。这意味着每层递归处理的元素数量大约减半。
分析递归树
快速排序的递归过程可以表示为一棵递归树。在理想状态下,这棵树是完全平衡的即快速排序的递归展开图是个满二叉树。
递归树的深度
在理想状态下,每次分区都将数组分成两半,所以递归树(满二叉树)的深度是logN(以2为底),其中N是数组的总元素数量。以下是计算过程:
- 在第一层递归中,快速排序处理N个元素。
- 在第二层递归中,快速排序处理N/2个元素(每个子数组)。
- 在第三层递归中,快速排序处理N/4个元素(每个子数组)。
- 以此类推,直到最后一层递归,处理的元素数量为1。
因此,递归树的深度是logN(因为N/(2^k) = 1,解得k = logN)。
每层的操作数量
在理想状态下,每一层递归需要处理N个元素,因为每一层都在处理不同部分的N个元素,只是分区的数量在增加。
总操作数量
由于递归树有logN层,且每一层都处理N个元素,我们可以将每一层的操作数量相加来得到总的操作数量。每一层的操作数量是N,所以总操作数量是:N + N + N + … + N (共logN项)
这可以简化为:N * logN。因此,快速排序在理想状态下的时间复杂度是O(N*logN)。
总结
快速排序在理想状态下的时间复杂度是O(N*logN),因为递归树是平衡的,每层递归处理大约N个元素,而递归树的深度是logN。这个分析假设每次分区操作都能完美地将数组分成两个大小相等的部分。
7.3.采用三数取中策略后的时间复杂度
图形解析:
- 三数取中策略:通过从区间的开始、中间和结束位置选取三个元素,然后取它们的中间值作为基准值,可以有效地避免在数组有序或接近有序时选取极端的基准值,从而减少分区不平衡的情况。
- 避免最坏情况:采用三数取中策略后,快速排序在最坏情况下的发生概率大大降低。这是因为三数取中使得基准值更有可能接近数组的中值,从而使得分区操作更加平衡。
7.4.总结
(1)三数取中策略通过减少分区不平衡的情况,有效地避免了快速排序在最坏情况下的性能退化,从而使得快速排序的平均时间复杂度更稳定地保持在O(N*logN)。
(2)若快速排序中加入三数取中以后,快速排序瞬间从最糟糕的情况变成最理想的情况,使得快速排序不会出现最糟糕的情况,从而使得快速排序的时间复杂度可以认为O(N*logN)
(3)“最糟糕的情况”指的是每次选区间基准值key选的都是区间中最小或最大的元素;而“最理想的情况”指的是每次选区间基准值key都是区间所有元素的中间值,这样在把当前待排序区间分割成左右子区间时两个子区间的长度差不多相等,进而每次分区都能尽可能平衡,使得递归树的深度最小,即O(logN)。
挖坑法版本单趟
1.挖坑法版本单趟的思路步骤
图形解析:
-
初始化坑位:
- 选择待排序区间[begin,end]最左边位置begin的元素作为基准值(key),并将其值保存到一个临时变量变量key中,这样待排序区间最左边元素的位置begin就变成了第一个坑位hole,即第一个坑位是hole = begin。
-
开始挖坑与填坑:
- 初始化两个指针,left和right,分别指向区间的起始begin和结束位置end。
- 由于一开始选最左边是坑位,则首先让指针right从右向左一直移动直到找到比基准值key要小的元素就停下来。当找到这样的元素时,将其值填入左边的坑位,这时right指针所在的位置变成了新的坑位。(注意:若一开始选区间[begin,edn]最左边位置begin的元素作为基准值key进而使得一开始区间[begin,end]最左边位置begin是坑位hole则必须让right先走;若一开始选区间[begin,edn]最右边位置end的元素作为基准值key进而使得一开始区间[begin,end]最右边位置end是坑位hole则必须让left先走)
- 填完坑后就接着让指针left指针从左向右一直移动直到找到比基准值key要大的元素就停下来。当找到这样的元素时,将其值填入右边的坑位,这时left指针所在的位置变成了新的坑位。
-
交替挖坑与填坑:
- 重复上述过程,即right挖坑left填坑,left挖坑right填坑,直到left和right指针相遇。
-
填入基准值:
- 当left和right指针相遇时,它们一定是在一个坑位上相遇的。这时,将之前保存的基准值key填入这个坑位。
-
完成单趟排序:
- 经过这一过程,基准值key的左侧所有元素都不大于key,右侧所有元素都不小于key。
2.挖坑法版本单趟的代码
//交换函数
void Swap(int* p1, int* p2)
{
int tmp = *p1;
*p1 = *p2;
*p2 = tmp;
}
// 三数取中
// begin mid end
int GetMidIndex(int* a, int begin, int end)
{
int mid = (begin + end) / 2;
if (a[begin] < a[mid])
{
if (a[mid] < a[end])
{
return mid;
}
else if (a[begin] > a[end])
{
return begin;
}
else
{
return end;
}
}
else // a[begin] >= a[mid]
{
if (a[mid] > a[end])
{
return mid;
}
else if (a[begin] < a[end])
{
return begin;
}
else
{
return end;
}
}
}
int PartSort2(int* a, int begin, int end)
{
//1.利用三数取中函数选出这3个下标begin、(begin+end)/2 、end的中间值。目的是把快速排序
最坏情况变成最好情况。
int mid = GetMidIndex(a, begin, end);
//2.把中间值下标mid位置的元素放到区间[begin,end]最左边begin位置。
Swap(&a[begin], &a[mid]);
//3.让L指向区间[begin,end]最左边的位置,让R指向区间[begin,end]最右边的位置。
// (注意:L和R的作用是从区间[begin,end]的两边遍历整个区间)
int left = begin, right = end;
//4.选区间最左边位置begin的元素作为基准值key
int key = a[left];
//5.选区间[begin,end]最左边位置left作为坑位hole。
//(注意:由于一开始选左边位坑位则一定要让R从右边先走)
int hole = left;
//6.挖坑法版本的单趟过程
while (left < right)
{
//6.1.由于一开始选区间最左边元素作为基准值key=所以一开始是在左边挖坑,所以R从右往
左找比key要小的值,找到后就把R停下来,并把R停下来位置的元素填到左边的坑位中。
//总的来说,右边找小,填到左边坑里面。
//注意:left < right的作用是防止R发生越界。若没有写left < right,当区间[begin,end]
的所有元素都比key要大时导致R一直在走,最终使得R从左边滑出造成发生越界。
//(1)R从右往左找小的数
while (left < right && a[right] >= key)
{
--right;
}
//(2)R找到小的数后就停下来,并把R位置比key要小的数填到左边坑位中
a[hole] = a[right];
//(3)更新此时坑位的新位置
hole = right;//R填完坑后,此时R停下来的位置形成新坑位
//6.2.R填完坑后,L从左往右找比key要大的值,找到后就把L停下来,并把L停下来位置的元素
填到右边的坑位中。
//总的来说,左边找大,填到右边坑里面。
//注意:left < right的作用是防止L发生越界。若没有写left < right,当区间
[begin,end]的所有元素都比key要小时导致L一直在走,最终使得L从右边滑出造成发生越界。
//(1)L从左往右找大的数
while (left < right && a[left] <= key)
{
++left;
}
//(2)//L找到大的数就停下来,并把L位置比key要大的数填到右边坑位中
a[hole] = a[left];
//(3)更新此时坑位的新位置
hole = left;//填完坑后,此时L停下来的位置形成新坑位
}
//7.由于此时R和L在坑位相遇,所以此时把基准值key放到相遇点的坑位中。把基准值key放到相遇
点的坑位后,则此时基准值在区间[begin,end]的下标Hoare位置。
//注意:相遇点的坑位元素一定比基准值key要小。
a[hole] = key;
//8.由于一次挖坑法版本的单趟结束后,基准值key已经落到它排序后最终位置,所以这里返回基
准值key的下标hole来告诉主调函数基准值key在整个数组中的位置,以防止主调函数继续对基准值
key进行排序。
return hole;
}
前后指针版本单趟
1.前后指针版本单趟的思路步骤
图形解析:
-
初始化:
- 选择区间 [begin, end] 的最左边位置 begin 作为基准值 key 的下标 keyi。这意味着基准值是 a[begin],并且 keyi 被设置为 begin。
- 初始化指针 prev,使其指向区间 [begin, end] 的起始位置 begin。prev 指针将用于追踪小于基准值 key 的元素区间的末尾。
- 初始化指针 cur,使其指向区间 [begin, end] 的起始位置的后一个位置 begin + 1。cur 指针将用于遍历数组,寻找小于基准值 key 的元素。
-
遍历区间:
cur
指针从左到右遍历区间[begin, end]
,cur一直移动直到找到比基准值key要小的元素才停下来。
-
交换元素:
- 当
cur
指针找到一个小于 基准值key
的元素时,执行以下操作:- 先将
prev
指针右移一位(++prev
),表示找到了一个新的小于key
的元素,并准备将其交换到小于基准值key 的元素区间内。 - 如果指针prev右移一位后prev指针和
cur
指针不相等,则交换a[prev]
和a[cur]
的值。这一步是为了将小于key
的元素移动到数组的左侧,即移动到数组左侧中所有小于基准值key元素的后面一个位置。 cur
指针右移一位(++cur
),继续寻找下一个小于key
的元素。
- 先将
- 当
-
继续遍历:
- 如果
cur
指针找到的元素大于或等于基准值key
,则只将cur
指针右移一位(++cur
),继续向后遍历。
- 如果
-
结束条件:
- 当
cur
指针超过end
时,停止遍历。
- 当
-
放置基准值:
- 将
prev
指针所指向的元素与基准值key
交换,这样基准值就被放置到了正确的位置,即所有小于key
的元素都在其左侧,所有大于或等于key
的元素都在其右侧。
- 将
2.详细说明前后指针prev、cur的作用
- 在前后指针版本的快速排序单趟排序过程中,prev 和 cur 这两个指针扮演着关键的角色,它们的各自作用如下:
2.1.prev 指针的作用
-
指向小于基准值的元素区间的末尾:
- prev 指针始终位于小于基准值 key 的元素区间的最后一个元素的位置。这意味着在 prev 指针左侧(包括 prev 指针所在位置)的所有元素都是小于 key 的。
-
确定交换位置:
- 当 cur 指针找到一个小于 key 的元素时,prev 指针首先右移一位(++prev),这表示我们找到了一个新的小于 key 的元素,并准备将其交换到 prev 指针的位置,从而将其加入到小于 key 的元素区间。
-
交换元素:
- 如果在右移 prev 指针之后,prev 和 cur 指针指向的不是同一个位置,那么我们就交换 a[prev] 和 a[cur] 的值。这个交换操作将小于 key 的元素移动到了数组的左侧,维护了快速排序的分区特性。
2.2.cur 指针的作用
-
遍历数组:
- cur 指针从基准值 key 的下一个位置开始,向右遍历数组,直到到达数组的末尾。
-
寻找小于基准值的元素:
- 在遍历过程中,cur 指针负责寻找小于基准值 key 的元素。每当它找到一个这样的元素,prev 指针就会进行相应的操作来确保这个元素被放置在正确的位置。
-
继续遍历:
- 在每次找到小于 key 的元素并进行可能的交换之后,cur 指针继续右移一位(++cur),继续寻找下一个小于 key 的元素。
2.3.总结
通过这两个指针的协同工作,前后指针版本的快速排序能够有效地将数组划分为两个部分:一部分包含所有小于基准值的元素,另一部分包含所有大于或等于基准值的元素。这样的分区为后续的递归排序奠定了基础。
3.对于前后指针版本单趟排序的本质理解
图形解析:
3.1.前后指针版本单趟排序的本质
-
分区操作:
- 前后指针版本的单趟排序本质上是一个分区操作。它将数组区间 [begin, end] 划分为两个子区间:一个包含所有小于基准值 key 的元素,另一个包含所有大于或等于基准值 key 的元素。
- 在这个过程中,prev 指针和 cur 指针协同工作,prev 指针追踪小于 key 的元素的最后一个位置,而 cur 指针寻找下一个小于 key 的元素。
-
动态调整区间:
- 当 cur 遇到小于 key 的元素时,prev 右移一位,然后交换 a[prev] 和 a[cur],这样就把小于 key 的元素“推”到了数组的左侧。
- 当 cur 遇到大于或等于 key 的元素时,cur 继续右移,prev 不动,这样就在 prev 和 cur 之间形成了一个所有元素都大于或等于 key 的区间。
-
区间扩展与元素交换:
- 随着排序的进行,区间 (prev, cur) 中的元素数量不断增加,这个区间中的所有元素都是大于 key 的。如上图所示。
- 同时,区间 [begin, prev] 中的所有元素(除了基准值 key 本身)都是小于 key 的,这是因为每当找到一个小于 key 的元素,它就被交换到这个区间中。
-
结束条件:
- 单趟排序结束的条件是 cur 超过 end,即 cur > end。此时,prev 指向的是区间 [begin, end] 中最后一个小于 key 的元素的位置。
3.2.总结
前后指针版本单趟排序的本质是:
- 通过 prev 和 cur 两个指针的配合,将数组划分为两个子区间:一个包含所有小于基准值的元素,另一个包含所有大于或等于基准值的元素。
- 这个过程是通过动态调整两个指针的位置,并在适当的时候交换元素来实现的。
- 最终,prev 指针会停止在最后一个小于基准值的元素的位置,而 cur 指针会超出区间 [begin, end],标志着单趟排序的完成。
这个过程可以形象地看作是 prev 指针作为一个“推土机”,将小于基准值的元素推到数组的左侧,而 cur 指针则像是一个“探测器”,在数组中寻找下一个小于基准值的元素。通过这种方式,数组在单趟排序后,基准值 key 被放置在其最终位置,且其左侧的所有元素都不大于 key,右侧的所有元素都不小于 key。
4.前后指针版本单趟的代码
//交换函数
void Swap(int* p1, int* p2)
{
int tmp = *p1;
*p1 = *p2;
*p2 = tmp;
}
// 三数取中
// begin mid end
int GetMidIndex(int* a, int begin, int end)
{
int mid = (begin + end) / 2;
if (a[begin] < a[mid])
{
if (a[mid] < a[end])
{
return mid;
}
else if (a[begin] > a[end])
{
return begin;
}
else
{
return end;
}
}
else // a[begin] >= a[mid]
{
if (a[mid] > a[end])
{
return mid;
}
else if (a[begin] < a[end])
{
return begin;
}
else
{
return end;
}
}
}
int PartSort3(int* a, int begin, int end)
{
//printf("%d,%d\n", begin, end);
//1.三数取中
int mid = GetMidIndex(a, begin, end);
Swap(&a[begin], &a[mid]);
//2.选区间[begin,end]的最左边begin位置的元素做key
int keyi = begin;
//3.定义一前一后两个指针。而且无论什么时候指针cur都在指针prev的前面。
int prev = begin, cur = begin + 1;
//4.前后指针版本单趟的过程
while (cur <= end)
{
//4.1.cur从左往右一直走直到cur找比基准值key要小的数才会停下来,然后先++prev,再交
换cur和prev位置的元素。
//注意:if(a[cur] < a[keyi] && ++prev != cur) 语句中的++prev != cur作用是防止下面
这种情况:若prev是一直紧跟着cur的,当a[cur] < a[keyi]成立时我们没有必要用
Swap(&a[prev], &a[cur])函数交换cur位置和++prev位置的元素,因为此时++prev = cur使得
自己交换自己是没有必要的的。案例:int a[] = {6,1,2,7,9,3,4,5,10,8}.
if (a[cur] < a[keyi] && ++prev != cur)
Swap(&a[prev], &a[cur]);
//4.2.若cur从左往右一直没有找到比基准值key要小的数则cur会通过++cur一直走。即使cur
找到比基准值key要小的数则cur与++prev位置的元素交换完后也会用++cur让cur往后走一步。
++cur;
}
//4.3.当cur > end时,就把prev位置的元素与基准值key进行交换。交换完后前后指针版本的单趟
就结束了。注意:此时prev位置的元素一定比基准值key要小。
Swap(&a[prev], &a[keyi]);
//5.由于前后指针版本的单趟结束后,基准值key来到了下标prev的位置,更则要更新keyi的值使
得下标keyi始终指向基准值key。
keyi = prev;
//6.由于一次前后指针版本的单趟结束后,基准值key已经落到它排序后最终位置,所以这里返回基
准值keyi的下标来告诉主调函数基准值key在整个数组中的位置,以防止主调函数继续对基准值key
进行排序。
return keyi;
}
递归实现快速排序(三路划分版本)
1.三路划分版本的快速排序思路步骤
三路划分的快速排序是一种优化版的快速排序算法,特别适用于包含大量重复元素的数组。以下是三路划分快速排序的思路步骤:
图形解析:
三路划分快速排序的思路步骤:
-
选择基准值:
- 从当前待排序区间
[begin, end]
中选择一个元素作为基准值(key)。在代码中,通过三数取中法选择基准值,并将其与区间的最左边的元素交换,确保基准值位于区间的起始位置。
- 从当前待排序区间
-
初始化指针:
- 设置三个指针
left
、right
和cur
。left
初始化为begin
,指向区间的起始位置。right
初始化为end
,指向区间的结束位置。cur
初始化为begin + 1
,从区间的第二个元素开始。
- 设置三个指针
-
单趟划分过程:
cur
指针从左到右遍历当前区间[begin, end]
,根据a[cur]
与key
的比较结果,执行以下操作:- 如果
a[cur] < key
:将a[cur]
与a[left]
交换,并将left
和cur
都向右移动一位。这样做是为了将小于key
的元素“摔”到左边。 - 如果
a[cur] > key
:将a[cur]
与a[right]
交换,并将right
向左移动一位。这样做是为了将大于key
的元素“摔”到右边。 - 如果
a[cur] == key
:只将cur
向右移动一位。这样做是为了将等于key
的元素“推”到中间区域。
- 如果
-
结束条件:
- 当
cur
超过right
时,即cur > right
,单趟划分过程结束。此时,区间被划分为三个部分:- 左子区间
[begin, left-1]
:包含所有小于key
的元素。 - 中间区域
[left, right]
:包含所有等于key
的元素。 - 右子区间
[right+1, end]
:包含所有大于key
的元素。
- 左子区间
- 当
-
递归排序:
- 由于中间区域
[left, right]
已经是有序的(所有元素都等于key
),因此不需要对该区域进行递归排序。 - 只需递归地对左子区间
[begin, left-1]
和右子区间[right+1, end]
进行快速排序。
- 由于中间区域
-
小区间优化:
- 当区间大小小于某个阈值(在代码中为 15)时,使用直接插入排序来代替递归调用。这是因为在小区间内,直接插入排序的常数因子较小,实际运行可能更快。
通过以上步骤,三路划分快速排序能够高效地处理包含大量重复元素的数组,减少了不必要的比较和交换,从而提高了排序的效率。
2.三路划分版本的快速排序代码
//交换函数
void Swap(int* p1, int* p2)
{
int tmp = *p1;
*p1 = *p2;
*p2 = tmp;
}
//直接插入排序
void InsertSort(int* a, int n)
{
//for循环的作用是:进行n - 1次直接插入排序单趟把数组第2个元素开始往后的n - 1个数据插入
到有序序列进而把数组中n个数据排序成升序。
for (int i = 0; i < n-1; ++i)
{
//注意:我们一开始不认为整个要排序的数组是个有序序列,而是一开始我们认为数组的第一
个元素就是个有序序列而数组第二个元素就是要插入到有序序列中的数据,等到数组第二个元
素插入到有序序列之后会使得数组前2个元素组成一个有序序列,进而使得数组第三个元素就是
插入到有序序列中的据,以此类推下去。
//在一个有序序列中插入一个数据进行排序的单趟过程:
//1.用end指向数组中的有序序列最后一个元素
//一开始end表示的数组中的有序序列最后一个元素的下标,而i是数组元素的下标。
int end = i;
//2.用变量tmp临时存储插入到有序序列的数据。而且这个插入数据是在有序序列最后一个元素
的后面。
//一开始下标end+1就是插入到有序序列中插入数据的下标。由于插入数据a[end + 1]在插入
的过程中有可能被有序序列中的其他数据覆盖掉所以我们要用临时变量tmp把插入
数据存储起来。
int tmp = a[end + 1];
//3.插入数据tmp在有序序列中插入的过程
//3.1.在有序序列中找到比插入数据tmp要小的数
while (end >= 0)
{
//注意:即使一开始end表示的是有序序列最后一个元素的下标,但是我们是用end从后往
前遍历整个有序序列
//若插入数据tmp比有序序列元素a[end]要小,则有序序列元素a[end]要往后挪动一步。
if (tmp < a[end])
{
//挪动数据
a[end + 1] = a[end];
--end;
}
//若插入数据tmp大于或等于有序序列的元素a[end],则停止比较有序序列元素和插入数据
tmp,然后在此时下标end+1的位置插入数据。
else
{
break;
}
}
//3.2.插入数据tmp插入到有序序列中比自己要小的数据的后面一个位置。
//插入数据tmp永远是在数组下标end的后一个位置插入的,即插入数据在数组下标end+1的位
置插入数据。
a[end + 1] = tmp;
}
}
//三路划分版本的快速排序
// 1.三路划分版本的快速排序核心思路:
// (1)跟key相等的值往后推
// (2)比key小的甩在左边
// (3)比key大的甩在右边
// (4)跟key相等的就在中间
//注意:
//(1)下面是以排成升序且选当前区间最左边元素作为key为例的三路划分版本快速排序的代码。
//(2)三路划分的单趟和快速排序的Hoare、挖坑法的单趟的思路类似,都是把比key小的往左边甩在,比key大的往右边甩。只是三路划分有个特殊之处是跟key相等的就推在中间。
void QuickSort(int* a, int begin, int end)
{
//若begin >= end说明当前待排序区间[begin,end]只有1个或者0个元素,则此时可以认为当前区
间[begin,end]是有序区间,所以此时不需要对该区间[begin,end]进行排序。
if (begin >= end)
{
return;
}
//当要进行排序的数据元素个数小于15时,此时使用直接插入排序可以减少递归次数。
if ((end - begin + 1) < 15)
{
//小区间用直接插入替代,减少递归调用次数
InsertSort(a+begin, end - begin + 1);
}
else
{
//三数取中选key的过程:
int mid = GetMidIndex(a, begin, end);
Swap(&a[begin], &a[mid]);
int left = begin, right = end;
//选当前区间[begin,end]最左边的元素作为key。
int key = a[left];
//定义指针cur的目的是:让指针cur从左到右遍历当前区间[begin,end]。
int cur = begin + 1;
//三路划分的单趟过程:
//当cur > right时就结束三路划分的单趟,则单趟结束后此时当前区间的左子区间的所有元
素都小于key、中间区域的所有元素都等于key、右子区间的所有元素都大于key。
while (cur <= right)
{
//当a[cur]遇到的元素小于key时,就把a[cur]摔到左边,然后i++、cur++即i和cur都往
后走一步。
if (a[cur] < key)
{
Swap(&a[cur], &a[left]);
cur++;
left++;
}
//当a[cur]遇到的元素大于key时,就把a[cur]摔到右边,然后只有right--即right往前
走一步。
else if (a[cur] > key)
{
Swap(&a[cur], &a[right]);
--right;
}
//当a[cur]遇到的元素与key相等时,就把a[cur]往中间推,然后只有cur++即cur往后走一
步。
else // a[cur] == key
{
cur++;
}
}
//走的这一步说明当前区间[begin,end]被分成三个子区间:左子区间[begin, left-1]、中间
区域[left, right]、右子区间[right+1,end]。
//三路划分每次单趟的目的是确定包括key在内的所有与key相等的元素在快速排序完后最终位
置。即这个最终位置指的是存放在当前区间[begin,end]的中间区域[left, right]位置。
//利用递归把当前区间[begin,end]的左子区间[begin, left-1]排成有序。
QuickSort(a, begin, left - 1);
//利用递归把当前区间[begin,end]的右子区间[right+1,end]排成有序。
QuickSort(a, right + 1, end);
}
}
3.三路划分快速排序的作用
三路划分快速排序的作用主要体现在以下几个方面:
3.1.处理重复元素
在传统的快速排序中,如果数组中包含大量重复元素,那么在划分过程中会产生很多不必要的比较和交换,因为重复元素在排序过程中会频繁地与基准值key相等。
三路划分快速排序通过将数组分为小于、等于和大于基准值的三个部分,可以有效地处理这些重复元素,减少了比较和交换的次数,从而提高了算法的效率。
3.2.优化性能:
对于包含大量重复元素的数组,三路划分快速排序的性能比传统快速排序更优,因为它减少了不必要的操作。
在最坏情况下,传统快速排序(例如:二路划分)的复杂度可能会退化到O(n^2),而三路划分快速排序能够将复杂度保持在O(n*log n),即使数组中包含大量重复元素。
3.3.减少递归深度:
由于三路划分可以更好地处理重复元素,这通常意味着递归树更加平衡,从而减少了递归调用的深度。较低的递归深度意味着较少的栈空间使用,这在处理大数据集时尤其重要。
3.4.适用于实际数据:
在实际应用中,数据往往不是完全随机的,可能包含大量重复值。三路划分快速排序更适合这种类型的数据,因为它能够适应这种模式,提供更好的性能。
3.5.总结
三路划分快速排序的作用是优化快速排序算法,使其在处理包含大量重复元素的数组时具有更高的效率,减少了不必要的操作,提高了排序的整体性能,并且在实际应用中更加实用。