前言:排序问题,是数据结构中的一大重要的组成板块,很多的面试机试中都会多多少少的涉及到排序问题,之前在上数据结构的那个学期整理过排序问题,不过大都是囫囵吞枣,不求甚解,今天,我们就来详细的,将几大排序算法展开,从0到1的进行理解和学习。考试可没有模板给你看,所以,理解原理很重要。
目录
一.经典的冒泡排序
实现原理:
冒泡排序(Bubble Sort)是一种简单的排序算法。它会重复地遍历要排序的数列,每次遍历时将相邻的两个数进行比较,如果它们的顺序错误就进行交换 ,这样每一遍遍历都会使数列的最后一个数到达它的最终位置,从而完成排序过程,时间复杂度为O(n^2)。
具体来说,冒泡排序的原理如下:
1.从数列的第一个元素开始,依次比较每一对相邻的元素,如果它们的顺序错误就进行交换,把较大的数交换到后面,较小的数交换到前面。
2.对整个数列进行一次遍历后,则数列中最大的数就会移到最后面的位置。
3.重复进行以上操作,每次遍历数列的元素个数减一,直到遍历完所有元素为止。
代码实现:
cpp
//冒泡排序
void Bubble_Sort(Elemtype* arr, int size)
{
for (int i = 0; i < size; i++)
{
for (int j = 0; j < size - i - 1; j++)//每次都确定待排序区间内的最后一个元素,所以,每次的查找区间就可以缩小一个
{
if (arr[j] > arr[j + 1])//交换
{
Elemtype temp = arr[j];
arr[j] = arr[j + 1];
arr[j + 1] = temp;
}
}
}
}
性能分析:
在平均情况下,冒泡排序的时间复杂度也是O(n^2),因为每趟遍历中平均需要比较和交换n/2次。考虑到冒泡排序的时间复杂度较高,对于大规模的数据排序是不推荐使用的。当待排序数组的大小较小(例如100个元素以内)时,冒泡排序的性能可以接受,并且代码简单易懂。
二.插入排序
实现原理:
插入排序(Insertion Sort)的原理是将数列分成已排序部分和未排序部分,每次从未排序的部分中取出一个数,然后插入到已排序部分的合适位置中,直到所有数都插入完成。这个过程类似于打扑克牌时将手里的牌排序,时间复杂的为O(n^2),在非纯逆序的情况下,时间复杂的一般为O(n)和O(n^2)之间。
具体来说,插入排序的原理如下:
1.将数列的第一个元素看作已排序部分,将后面的所有元素看作未排序部分。
2.每次从未排序部分中取出一个元素,然后将它插入到已排序部分的合适位置中,使得插入后的数列仍然有序。
3.重复执行步骤 2,直到所有元素都插入完成。
cpp
//直接插入排序
void Insert_Sort(Elemtype* arr, int size)
{
for (int i = 0; i <size; i++)
{
int end = i - 1;
Elemtype temp = arr[i];
//[0,end]是有序的,我们插入一个数据后依然有序
while (end >= 0)
{
if (arr[end] > temp)//只要比要插入的数据大,那么就将较大的数据后移给要插到前面去数字腾出位置来
{
arr[end + 1] = arr[end];
end--;
}
else//找到了新插入的数该待的位置就退出
break;
}
arr[end + 1] = temp;//最后不要忘了把插入的数据插入到它该待的位置上去
}
}
性能分析
在平均情况下,插入排序的时间复杂度也是O(n^2)。
部分有序数组:当待排序数组在某种程度上已经部分有序时,插入排序的性能较好。
数据量较小:当待排序数组的大小较小(例如100个元素以内)时,插入排序的性能可以接受,并且代码简单易懂,或者需要原地排序:插入排序是一种原地排序算法,不需要额外的存储空间。
三.希尔排序
实现原理:
希尔排序(Shell Sort)是一种插入排序的改进算法。它的原理是通过设置一个增量序列来对数列中的元素进行前后移动,从而让数列中的元素逐步接近它们最终的位置,进而缩短排序时间,该排序是基于插入排序的优化,较插入排序有较大的提升,特别是在数据量大时提升较为明显。
具体来说,希尔排序的原理如下:
1.定义一个增量序列(如下文中的h序列),按照该序列对数列进行分组。
2.对于每一组内部,使用插入排序对该组进行排序。
3.逐渐将增量序列减小,重复执行步骤 2,直到增量序列减小至 1,此时使用插入排序完成最后的排序。
希尔排序的时间复杂度与增量序列的选择有关。最佳的增量序列至今仍未被确定,不过已经有一些经验性的结论:
当初始数列大致有序时,希尔排序的时间复杂度为 O(n)。
当增量序列按照 Shell 原始序列(1, 3, 7, 15, 31, 63, ...)来定制时,希尔排序的时间复杂度最坏情况下约为 O(n1.5)。而当增量序列按照 Sedgewick 序列(1, 5, 19, 41, 109, 209, 505, 929, 2161, 3905, ...)来定制时,希尔排序的时间复杂度可以降低至 O(n1.3) 左右。一般的,我们认为希尔排序的时间复杂的为O(n1.3)左右。
代码实现:
cpp
//希尔排序
void Shell_Sort(Elemtype* arr, int size)
{
//定义一个间隔,规定数组按这个间隔划分
int gap = size;
while (gap > 1)
{
gap = gap / 3 + 1;//加1的目的可以保证最后一个gap一定是1,从而能够执行最后的插入排序
for (int i = 0; i < size - gap; i++)
{
int end = i;
Elemtype temp = arr[end + gap];
while (end >= 0)
{
if (arr[end] > temp)
{
arr[end + gap] = temp;
end -= gap;
}
else
break;
}
arr[end + gap] = temp;
}
}
}
性能分析
希尔排序的平均时间复杂度在理论上仍然是一个开放问题,没有找到确定的结果。但是,在实际情况下,希尔排序通常比其他平均时间复杂度为O(n^2)的排序算法更快,因为它可以在较早的阶段提前将较小的元素移动到正确的位置。希尔排序适用于中等大小的数组,特别是当其他复杂度更高的排序算法性能较差时。需要注意的是,希尔排序的性能取决于所选择的增量序列,不同的增量序列可能会导致不同的性能表现。因此,在实践中,选择适当的增量序列对于希尔排序的性能至关重要。
四.选择排序
实现原理:
选择排序(Selection Sort)是一种简单的排序算法。它的原理是从数列中选择最小(最大)的元素,将它与数列中的第一个(最后一个)元素进行交换,然后从剩余的元素中选择最小(最大的)的元素,将它与数列中的第二个(倒数第二个)元素进行交换,以此类推,直到所有元素都排序完毕。
具体来说,选择排序的原理如下:
1.在数列中选择一个元素作为最小(最大)值,将它记录下来。
2.从数列中剩余的所有元素中找出最小(最大)的元素,并与记录的最小(最大)值进行比较,如果比记录的最小(最大)值还小(大),则将它记录下来。
3.重复执行步骤 2,直到所有元素都完成比较。
4.将找到的最小(最大)值与数列中的第一个(倒数第一个)元素交换位置,继续进行下一轮排序,直到所有元素都排序完成。
5.在实现时我们一般一次取最大和最小的分别安插在区间的两端,依次往数组中间缩进。
实现代码:
cpp
void SelectSort(Elemtype* a, int n)
{
int begin = 0, end = n - 1;
while (begin < end)
{
int maxi = begin, mini = begin;
for (int i = begin; i <= end; i++)
{
if (a[i] > a[maxi])
{
maxi = i;
}
if (a[i] < a[mini])
{
mini = i;
}
}
//找到了最大的和最小的,分别放在两端,缩小区间继续查询
//这里注意交换可能会出现问题,如果begin或者end处就是是最大值或者最小值,那么在同一个位置就会被交换两次值,导致排序发生错误,我们应当单独处理它们
//第一次交换是没有问题的
Elemtype temp = a[begin];
a[begin] = a[mini];
a[mini] = temp;
//如果原来begin处的位置是最大的数,那么它此时已经被交换到了mini下标处,
if (maxi == begin)
maxi = mini;//此时的最大值已经被交换到mini下标处
Elemtype temp2 = a[maxi];
a[maxi] = a[end];
a[end] = temp2;
//不要忘记向中间缩进
begin++;
end--;
}
}
性能分析
选择排序的最佳情况、最坏情况和平均情况下,选择排序的时间复杂度都是O(n^2),其中n是数组的大小。无论数据的初始顺序如何,选择排序都需要进行n-1次比较和交换操作。相对于其他排序算法,选择排序的性能较低。即使在最优情况下,选择排序也需要进行大量的比较操作。选择排序需要进行n-1次交换操作,这使得它在交换次数方面不如其他排序算法(例如冒泡排序)。选择排序的主要优点是简单易懂,实现起来较为简单。
五.堆排序
实现原理
堆排序是一种基于堆数据结构的排序算法。它利用了堆的性质,通过构建最大堆或最小堆来进行排序。
堆排序的实现原理如下:
构建最大堆(或最小堆):将待排序的数组看作是一个完全二叉树,通过调整节点的位置构建最大堆(或最小堆)。最大堆的性质是父节点的值大于或等于其子节点的值,最小堆的性质是父节点的值小于或等于其子节点的值。
排序:将堆顶元素与最后一个叶子节点交换位置,然后将交换后的最后一个节点排除在堆之外,即将堆的大小减1。接着,通过对堆顶元素进行下沉操作,使得剩余节点重新构成最大堆(或最小堆)。重复此过程,直到堆的大小为1,即完成排序。
首先,我们要先知道如何将一个数组的所有元素用二叉树的形式表示,这里我们有如下的规则:
1.对于需要建堆的二叉树,我们一般都是以建立一棵完全二叉树,这样既方便表示二叉树的父节点和儿子节点之间的联系,也方便我们取元素和建立最大堆或者最小堆;
2.对于一棵完全二叉树来说,我们以宽度优先遍历的方式对数组元素加入并建立一棵完全二叉树,也就是我们有,2*(父节点在数组中的下标)+1=(左孩子节点在数组中的下标,也就是父节点所代表的元素的下一个元素),2*(父节点在数组中的下标)+2=(右孩子下标,也就是父节点所在在下标的下两个元素)。
1.建堆
堆分为最大堆和最小堆,最大堆和最小堆都是完全二叉树,并且满足堆的性质。堆是一种用数组实现的二叉树,其中任意节点的值都大于(或小于)其子节点的值。
最大堆的性质:
对于最大堆中的任意节点i,其值大于或等于其左右子节点的值,即arr[i] >= arr[2i+1] 且 arr[i] >= arr[2i+2]。
最小堆的性质:
对于最小堆中的任意节点i,其值小于或等于其左右子节点的值,即arr[i] <= arr[2i+1] 且 arr[i] <= arr[2i+2]。
最大堆和最小堆的关键性质是根节点的值是所有节点中最大(或最小)的。这种性质允许堆排序算法通过不断交换根节点和最后一个叶子节点的位置来进行排序操作。
如何建堆,建大堆还是建小堆?
我们以上面的这个已经根据数组建好的一棵完全二叉树为例:
我们如何将上述的完全二叉树转换为一个最大堆或者最小堆呢?
向下调整算法
建堆的关键就是让完全二叉树具有堆的性质,也就是根节点是数组的最大或者最小元素,为此,我们需要建上面的二叉树进行调整使最大或者最小的元素放在根节点的位置上去,我们可以选择从最容易调整的部分开始,也就是选择最右下角的最末尾的那棵子树先进行调整,接着逐层往上,每次调整的原则是将父节点与儿子节点中的最小的或者最大的进行交换,这样可以保证最小的或者最大的那个元素一直在向上走,我们以上面的二叉树为例给出建一个最小堆的演示:
2.如何通过调整堆来进行排序?
从上面堆的性质中我们,不论是大堆还是小堆,只有根节点是所有元素中最大或者最小的,我们一般只关心堆的根节点,结合排序需要每次找到当前剩余元素中最大或者最小的,我们便可以将堆的根节点和排序联系起来,如果要排升序,那么就需要我们每次找到剩余元素中最小的元素,如果建一个小堆,那么我们就需要对堆顶的元素每次都要进行输出,输出之后,将堆顶元素从待排序序列中删除(可以直接将堆的根节点和待排序的二叉树的最右下角的在当前排序范围内的根节点交换,之后我们需要将缩减后的二叉树再次重新调整为小堆;若为大堆,我们每次都能直接找到对应的最大值,并将其放在二叉树的末尾(最右下角,也就是层序遍历时该数应该在的位置上去,这样,我们最终可以得到一个排好序的数组,两种方式的对比如下,为了方便作为函数调用,一般都采取:排升序建大堆,排逆序建小堆的规则:
代码实现
cpp
//堆排序->(排升序建大堆,排降序建小堆)
//向下调整算法
void Adjustdown(Elemtype* a,int parent, int n)
{
int child = 2 * parent + 1;//左孩子下标
while (child < n)
{
if (child + 1 < n && a[child + 1] > a[child])//降序排序找较大的则修改为a[child + 1] < a[child]
child++;
if (a[parent] < a[child])//降序排序建大堆则修改为a[parent] > a[child]
{
Elemtype temp = a[parent];
a[parent] = a[child];
a[child] = temp;
//第一次交换完成后,更新父节点和孩子节点,将后续的已经更新的堆再次更新
parent = child;
child = 2 * parent + 1;
}
else//如果父节点比儿子节点大了,说明其下方的已经满足大堆的条件了,不需要在调整了
break;
}
}
void HeapSort(Elemtype* a, int n)
{
//首先我们需要建堆,这里我们以排升序为例,建大堆
//对比于将for循环放在向下调整算法内部,下面的Floyed建堆算法能在o(n)时间内建好堆,虽然函数逻辑上没有发生变化,但是复杂度计算上可以优化很多,因为只有嵌套的复杂度才是相乘的,在向下调整算法中,一次只传入一个父节点进行比较,相比于向下调整算法中的for循环更好
for (int i = (n - 1 - 1) / 2; i >= 0; i--)
{
Adjustdown(a,i, n);
}
//printf("建大堆后:->\n");
//for (int i = 0; i < n; i++)
// printf("%d ", a[i]);
//printf("\n");
int end = n - 1;
//先将根节点和最后一个叶子节点交换,输出并删除最后一个元素,重新调整堆并继续输出
while (end > 0)//没必要等于0,end=1就已经有序了
{
//堆的根节点和最后一个节点交换
Elemtype temp = a[0];
a[0] = a[end];
a[end] = temp;
//printf("%d ", a[end]);
//由于堆结构被破坏,我们需要再次调用向下调整算法恢复大堆结构
Adjustdown(a,0, end);
end--;//删除最后一个元素
}
}
性能分析
堆排序的时间复杂度为O(n log n),其中n是数组的大小。对于堆的构建操作,时间复杂度为O(n)。它将无序的数组构建成一个堆。进行n次删除堆顶元素操作,每次Heapify的时间复杂度为O(log n)。这是因为堆的高度是log n。
堆排序是一种原地排序算法,不需要额外的存储空间。
- 堆排序具有稳定性,可以保持相同值的元素相对顺序。
- 堆排序是一种不稳定的原地排序算法,因为交换元素会破坏相同值的元素原有的相对顺序。
堆排序适用于以下情况:
- 对于大规模数据的排序,堆排序相对较快,并且不会导致额外的内存开销。
- 当需要对稳定性没有要求时,堆排序是一种高效的排序算法选择。
需要注意的是,堆排序虽然具有较好的时间复杂度和空间复杂度,但在实现上稍微复杂一些。相比于其他排序算法,堆排序的代码可能相对复杂。因此,在实践中,如果不考虑稳定性的情况下,快速排序和归并排序可能更常用,因为它们更简单直观。
六.快速排序
实现原理:
快排是比较重要的一个排序,很多编程语言的排序有关的接口,都是基于快排的思想所实现的,快排也是综合性能较好的一个排序算法,快排是基于递归分治的思想,通过选定基准元素,并且将数组中的元素按与基准元素的比较结果放在基准元素的两侧,其带来的结果就是使得数组以基准元素分成两部分,然后我们分别对这两部分再重复进行上述的过程,知道被分成的某一部分元素个数为1或者为0即可终止并返回。
代码实现:
递归函数
在递归函数中,我们需要实现按选定的基准元素来将数组分成两个部分,并且确定递归结束返回的条件,对于返回条件,易知是待排序部分只剩至多一个元素时即可停止,所以递归函数便可以得出。
cpp
void QuickSort(Elemtype* a, int n,int l,int r)
{
if (l < r)
{
int idx = partion2(a, l, r);//找到基准元素应在的位置
QuickSort(a,n, l, idx - 1);
QuickSort(a, n, idx + 1, r);
}
}
基准值位置寻找函数
1.hoare版本
原理1:如何保证每次找到的位置上的数一定比基准元素小?
这里我们先给出结论:
左边做基准元素,就让右边先走,保证了相应的位置比基准元素小;右边做基准元素,让左边先走,保证了相应的位置比基准元素大。
我们就拿其中一条来解释:左边做基准元素,就让右边先走,保证相应的位置比基准元素小。
左边做基准元素,我们期望找到的是一个能将整个数组按基准元素划分为大于基准元素和小于基准元素的两部分,所以我们需要找到中间的某个位置上的数,这个位置上的数比基准元素小,才能将基准元素与找到的位置进行交换,所以此时两个指针相遇的位置上的元素一定是要比基准元素小的,两个指针相遇无非就是两种情况:
1.最后一步是R遇到的L,在上一轮的交换中,由于L和R位置上的交换,所以当前L上的元素是小于基准元素,R上的位置是大于基准元素的,如果开始就让R先走,则在当前轮次中R先走,R去找到了L。也就是到了一个小于基准元素的位置,这样该位置上的元素才能够和基准元素进行交换以满足要求。
2.最后一步是L遇上的R,同样的,在上一轮交换中,由于L和R的交换,此时R上的元素是大于基准元素的数,让R先走,保证了最后一步L遇上R时相应的R的位置上是比基准元素小的元素。
综上,在根据上面的动图,我们不难根据该原理写出如下的初始逻辑代码:
cpp
void partion(Elemtype* a, int n, int l, int r)
{
int keyi = l;//选定最左侧为基准元素
while (l < r)
{
while (a[r] > a[keyi]) r--;
while (a[l] < a[keyi]) l++;
//交换
Elemtype temp = a[l];
a[l] = a[r];
a[r] = temp;
}
//两个指针相遇时即找到了基准元素的位置
Elemtype temp1 = a[keyi];
a[keyi] = a[l];
a[l] = temp1;
}
下面我们一起来修正上面代码中的细节问题,以修正我们的逻辑代码:
问题1:while带来的死循环问题
上面的代码逻辑中我们的在寻找基准元素的位置时,如果左右两测同时遇到了与基准元素相等的值,那么根据上面的代码逻辑,交换后左右指针的位置不会改变,就会造成死循环,相应的,我们要避免这种情况,所以我们就需要在找到与基准元素相同的元素时指针仍然能继续走下去,所以我们在左右两测寻找,我们直到找到一个比基准元素大或者小的元素才停下来即可,并且我们还需要注意左指针从左侧开始不是从左侧加1的位置,这样能避免基准值为最值产生错误的排序结果。
问题2:基准元素无限小导致数组访问越界
基于上面我们对问题1的优化,接着又有了新的问题,如果基准元素无限小,那么进入内部的两个while循环的时候就有可能出现越界问题,所以我们在内部的while循环仍然需要保持着左右指针不相遇的原则。
经过上述的修改,我们就可以得出正确的分割函数:
cpp
//法1,经典算法
int partion1(Elemtype* a, int l, int r)
{
int keyi = l;//选择最左侧为基准元素
while (l < r)
{
while (l < r && a[r] >= a[keyi])r--;//右侧先找到第一个比基准值小的数
while (l < r && a[l] <= a[keyi]) l++;//左侧在找到第一个比基准值大的数
//交换
Elemtype temp = a[l];
a[l] = a[r];
a[r] = temp;
}
//当r和l相遇,我们就找到了基准元素在数组中的位置,也就是r和l所在的位置
Elemtype temp1 = a[keyi];
a[keyi] = a[l];
a[l] = temp1;
return l;//返回基准元素应该在的位置
}
第n次排序后的结果问题
在快速排序中,每次排序都选择一个基准元素,并根据该基准元素将数组分割为两个子数组。根据基准的选择和分割操作,每次排序都会将数组中的一部分元素放置在正确的位置上。
在排序的过程中,通过不断地选择基准元素和分割数组,最终得到有序的数组。但是每次排序的具体结果取决于基准元素的选择以及分割操作的具体实现。
所以,要回答第n次排序后数组的问题,需要了解每次排序中基准元素的选择方式,并可跟踪每次排序后的分割操作。这也是应试中经常考察的问题。
比如,给定一个数组为{ 4,7,1,9,3,6,5,8,3,2,0 },假设以最左侧为基准元素,求解第n次排序后的数组,我们只需要在分割函数内部进行数组的打印即可。
2.挖坑法
挖坑法就是经典方法的变种,转变一种方式,我们将初始基准元素保存并将该位置空出,接着我们继续上面找数的方法,每次将左右两个指针找出来的数和坑的位置进行交换并更新坑的位置,这样一来,最终两个指针相遇的位置也就是最后一个坑的位置,此时就找到了基准元素的位置,以此位置进行分割即可。
cpp
//法二,挖坑法
int partion2(Elemtype* a,int n, int l, int r)
{
int key = a[l];//将基准值保存起来
int holei = l;//保存基准元素的位置
while (l < r)
{
//从右侧找到第一个小于基准元素的值,交换数据并更新坑的位置
while (l < r && a[r] >= key)
r--;
a[holei] = a[r];
holei = r;
//同上
while (l < r && a[l] <= key)
l++;
a[holei] = a[l];
holei = l;
}
//最终找到了基准元素的位置也即两个指针相遇的位置
a[holei] = key;
return holei;
}
3.前后指针法
通过设置前后两个指针,满指针追赶快指针的方式,如果两个指针所指的元素都满足比基准元素大或者比基准元素小的关系,则两个指针就会一直处于相邻的状态,倘若快指针遇到了不满足和慢指针相同的对基准元素的关系,那么就只让快指针继续向前走,慢指针留在原位置直到快指针再一次向前找到符合慢指针处的元素与基准元素的大小关系的元素,这样一来快慢指针中间就会产生间隔,我们将中间的间隔的比基准元素大的值用快指针指向的小的元素对其一一交换位置即可。
cpp
//法三,双指针法
int partion3(Elemtype* a,int n, int l,int r)
{
//前后指针初始化,将cur初始为prev指针的下一个
int keyi = l;
int prev = l;
int cur = l + 1;
while (cur <= r)
{
if (a[cur] < a[keyi]&&++prev<=cur)//一旦快慢指针有了间隔,我们就将快指针指向的元素逐渐与慢指针指向的元素交换
{
Elemtype temp = a[prev];
a[prev] = a[cur];
a[cur] = temp;
}
++cur;//让快指针指向下一个位置
}
//当cur走到结束,prev指针就是基准元素应该在的位置
Elemtype temp1 = a[prev];
a[prev] = a[keyi];
a[keyi] = temp1;
return prev;
}
性能分析
快速排序是一种常用的排序算法,其性能在平均情况下非常出色,但在最坏情况下会出现较差的性能。
性能分析:
-
最好情况下:当待排序的序列能够均匀划分时,快速排序的时间复杂度为O(nlogn)。
-
平均情况下:快速排序的时间复杂度为O(nlogn)。
-
最坏情况下:当待排序的序列已经有序或基本有序时,快速排序的时间复杂度为O(n^2)。这是因为在每一轮划分时,选择的基准元素可能导致划分非常不均匀,从而导致性能下降。
-
空间复杂度:快速排序的空间复杂度为O(logn),主要是由于递归调用栈的使用。
性能优化
通过上面的分析我们不难发现,快速排序在最坏的情况下复杂的能够达到O(n^2),反应到原理上就是每次的分割函数选择的位置都是偏向于头部或者末尾的位置,导致每次都要对将近数组长度的数据进行排序,从这里也可以看出,如果我们的基准元素每次能选到待排序部分的中位数,那么就会一直分成一半,效率也会大大提高,所以我们就照着这个方向进行优化。
三数取中法
因为我们只能大致的选择一个中间的数,而我们又不可能一个一个去找,所以我们大致认为选择最左侧,最右侧,中间位置,三个数中中等大小的那个数为较好的中间值,将选出来的值再与我们选择的基准元素的位置交换,这里的交换是为了保证原始的逻辑代码仍然成立,不至于基准元素的位置改变导致算法出错。当然,还有一种方法就是在每一轮排序开始前都随机选一个元素作为基准元素,代码与之类似,只是要注意将选出的元素与我们规定好的基准元素所在的位置上原本的值进行交换之后再继续就可以了。
代码实现就比较简单了,就是三个数比较取中间的数即可:
cpp
//优化:三数取中法
int GetMidIndex(Elemtype * 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[left] < a[right])
{
return right;
}
else
{
return left;
}
}
else // a[left] > a[mid]
{
if (a[mid] > a[right])
{
return mid;
}
else if (a[left] > a[right])
{
return right;
}
else
{
return left;
}
}
}
缺陷优化
快排的优良特性导致很多企业开始专门针对快排缺陷设计测试样例,比如数组全都是重复数字等样例,数据量一旦上涨,就会导致超时问题,所以,我们需要设计出一种可以防止针对的快排方法用来解决缺陷(有优就有劣,相对应的复杂度就不那么好了),这里我们给出一种hoare版本和前后指针版本的结合-三路划分法。
三路划分法-防针对
快速排序的三路划分法是对传统的快速排序算法的一个改进,主要用于解决在有大量重复元素的数组中排序时出现的性能问题。三路划分法将待排序的数组划分成三个部分:小于、等于和大于基准元素的部分。
具体步骤如下:
选择一个基准元素,通常是待排序数组的第一个元素或者随机选择。
设置三个指针:lt(less than),gt(greater than)和i(current index)。初始时,lt和gt都指向数组的起始位置,i指向第一个元素。
开始遍历数组,通过比较当前元素和基准元素的大小,进行三路划分:
如果当前元素小于基准元素,则将当前元素与lt指针所指的元素交换,然后将lt和i指针都向后移动一位。
如果当前元素大于基准元素,则将当前元素与gt指针所指的元素交换,然后将gt指针向前移动一位。由于交换后的新元素还未进行过比较,所以i指针保持不动。
如果当前元素等于基准元素,则将i指针向后移动一位,继续比较下一个元素。
重复步骤3,直到遍历完整个数组。
最后,得到的数组将被划分为三部分:小于基准元素的部分、等于基准元素的部分和大于基准元素的部分。
分别对小于和大于基准元素的部分递归地应用上述步骤,继续进行快速排序。
通过三路划分,快速排序可以更好地处理包含大量重复元素的数组,减少重复元素的比较次数,提高排序性能。三路划分法可以有效地将重复元素放在中间部分,避免了重复元素在划分过程中被频繁地移动。这种改进在处理存在大量相同元素的数组或具有多个重复元素的情况下,能够显著提升快速排序的效率。
代码实现
cpp
//快排优化-三路划分法(优化重复元素问题)
void QuickSort2(Elemtype* a, int n, int l, int r)
{
if (l >= r)
return;
//选出合适的基准元素与左侧元素交换(以最左侧为基准元素)
int midi = rand() % (r - l + 1) + l;//取随机数防止针对
std::swap(a[midi], a[l]);
int key = a[l];
int cur = l+1, left = l, right = r;//三个指针
while (cur <= right)
{
if (a[cur] < key)//小于基准元素的放在左边
{
std::swap(a[cur], a[left]);
cur++;
left++;
}
else if (a[cur] > key)
{
std::swap(a[cur], a[right]);
right--;
}
else
cur++;
}
QuickSort2(a, n, l, left - 1);
QuickSort2(a, n, right+1,r);
}
非递归实现
原理介绍
快速排序是一种常用的、高效的排序算法,其递归实现通常基于分治的思想。然而,递归实现可能会导致调用栈溢出的问题,特别是在处理大规模数据时。为了解决这个问题,可以使用非递归的方式来实现快速排序,其中常用的数据结构是栈。
非递归实现快速排序的原理如下:
- 初始化栈,并将初始的左右边界(通常是数组的起始和结束索引)入栈。
- 当栈不为空时,执行以下操作:
- 出栈,获取当前的左右边界。
- 对当前边界进行划分,选择一个基准元素,并通过分区操作将所有小于基准元素的元素放在左边,所有大于基准元素的元素放在右边,并返回基准元素的索引。
- 如果左边的边界小于基准元素的索引减一,将左边界和基准元素索引减一入栈(表示对左边一段区间进行划分)。
- 如果右边的边界大于基准元素的索引加一,将基准元素索引加一和右边界入栈(表示对右边一段区间进行划分)。
- 重复步骤2,直到栈为空。
使用栈实现非递归的快速排序有以下意义:
- 避免了递归调用带来的额外开销。递归调用可能导致函数栈帧的创建和销毁,而使用栈可以手动控制每个子问题的边界,避免了频繁的函数调用开销。
- 解决了递归调用带来的可能的调用栈溢出问题。递归实现在处理大规模数据时,调用层次可能过深,导致调用栈溢出。非递归实现通过栈来保存每个子问题的边界,可以有效地避免这个问题。
- 降低了空间复杂度。非递归实现只需要一个栈来保存每个子问题的边界,相比递归实现,节省了额外的内存空间。
代码实现
cpp
//快速排序非递归实现
void QuickSort1(Elemtype* a, int n, int l, int r)
{
std::stack<std::pair<int, int>>st;//创建左右边界的数对元素构成的栈
st.push(std::make_pair( l,r ));
while (!st.empty())
{
std::pair<int, int> pa = st.top();
st.pop();
/*
if (pa.first < pa.second)
{
int idx = partion3(a, n, pa.first, pa.second);//找到基准元素应在的位置
//如果我们想要下一次还是按顺序遍历,栈先进后出,我们就需要将小的区间最后放入
st.push(std::make_pair( idx + 1,pa.second ));
st.push(std::make_pair( pa.first,idx - 1 ));
}
*/
//优化上述的注释部分,因为存在很多的无效区间被判断增加了复杂度,改为先判断区间再决定是否入栈
int idx = partion3(a, n, pa.first, pa.second);
if (idx + 1 < pa.second)
st.push(std::make_pair(idx + 1, pa.second));
if (idx - 1 > pa.first)
st.push(std::make_pair(pa.first, idx - 1));
}
}
快排完整代码
cpp
//快速排序
//优化:三数取中法选取较优的基准元素并返回位置
int GetMidIndex(Elemtype * 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[left] < a[right])
{
return right;
}
else
{
return left;
}
}
else // a[left] > a[mid]
{
if (a[mid] > a[right])
{
return mid;
}
else if (a[left] > a[right])
{
return right;
}
else
{
return left;
}
}
}
//法1,经典算法
int flag = 0;
int partion1(Elemtype* a,int n, int l, int r)
{
//优化,将三数中的中间大小的数与最左侧的基准元素交换
int midi = GetMidIndex(a, l, r);
//交换选择的元素与最左侧位置元素,以保证原逻辑正确性
Elemtype k = a[midi];
a[midi] = a[l];
a[l] = k;
int keyi = l;//选择最左侧为基准元素
while (l < r)
{
while (l < r && a[r] >= a[keyi])r--;//右侧先找到第一个比基准值小的数
while (l < r && a[l] <= a[keyi]) l++;//左侧在找到第一个比基准值大的数
//交换
Elemtype temp = a[l];
a[l] = a[r];
a[r] = temp;
}
//当r和l相遇,我们就找到了基准元素在数组中的位置,也就是r和l所在的位置
Elemtype temp1 = a[keyi];
a[keyi] = a[l];
a[l] = temp1;
printf("第%d次排序后的结果为:->\n",++flag);
for (int k = 0; k < n; k++)
printf("%d ", a[k]);
printf("\n");
return l;//返回基准元素应该在的位置
}
//法二,挖坑法
int partion2(Elemtype* a,int n, int l, int r)
{
//优化,将三数中的中间大小的数与最左侧的基准元素交换
int midi = GetMidIndex(a, l, r);
//交换选择的元素与最左侧位置元素,以保证原逻辑正确性
Elemtype k = a[midi];
a[midi] = a[l];
a[l] = k;
int key = a[l];//将基准值保存起来
int holei = l;//保存基准元素的位置
while (l < r)
{
//从右侧找到第一个小于基准元素的值,交换数据并更新坑的位置
while (l < r && a[r] >= key)
r--;
a[holei] = a[r];
holei = r;
//同上
while (l < r && a[l] <= key)
l++;
a[holei] = a[l];
holei = l;
}
//最终找到了基准元素的位置也即两个指针相遇的位置
a[holei] = key;
return holei;
}
//法三,双指针法
int partion3(Elemtype* a,int n, int l,int r)
{
//前后指针初始化,将cur初始为prev指针的下一个
//优化,将三数中的中间大小的数与最左侧的基准元素交换
int midi = GetMidIndex(a, l, r);
//交换选择的元素与最左侧位置元素,以保证原逻辑正确性
Elemtype k = a[midi];
a[midi] = a[l];
a[l] = k;
int keyi = l;
int prev = l;
int cur = l + 1;
while (cur <= r)
{
if (a[cur] < a[keyi]&&++prev<=cur)//一旦快慢指针有了间隔,我们就将快指针指向的元素逐渐与慢指针指向的元素交换
{
Elemtype temp = a[prev];
a[prev] = a[cur];
a[cur] = temp;
}
++cur;//让快指针指向下一个位置
}
//当cur走到结束,prev指针就是基准元素应该在的位置
Elemtype temp1 = a[prev];
a[prev] = a[keyi];
a[keyi] = temp1;
return prev;
}
void QuickSort(Elemtype* a,int n,int l,int r)
{
if (l < r)
{
int idx = partion1(a,n, l, r);//找到基准元素应在的位置
QuickSort(a,n, l, idx - 1);
QuickSort(a,n, idx + 1, r);
}
}
//快速排序非递归实现
void QuickSort1(Elemtype* a, int n, int l, int r)
{
std::stack<std::pair<int, int>>st;//创建左右边界的数对元素构成的栈
st.push(std::make_pair( l,r ));
while (!st.empty())
{
std::pair<int, int> pa = st.top();
st.pop();
/*
if (pa.first < pa.second)
{
int idx = partion3(a, n, pa.first, pa.second);//找到基准元素应在的位置
//如果我们想要下一次还是按顺序遍历,栈先进后出,我们就需要将小的区间最后放入
st.push(std::make_pair( idx + 1,pa.second ));
st.push(std::make_pair( pa.first,idx - 1 ));
}
*/
//优化上述的注释部分,因为存在很多的无效区间被判断增加了复杂度,改为先判断区间再决定是否入栈
int idx = partion3(a, n, pa.first, pa.second);
if (idx + 1 < pa.second)
st.push(std::make_pair(idx + 1, pa.second));
if (idx - 1 > pa.first)
st.push(std::make_pair(pa.first, idx - 1));
}
}
七.归并排序
实现原理
归并排序是一种高效、稳定的排序算法,它基于分治的思想。它的主要原理可以简单概括如下:
分解:将待排序的数组不断划分成较小的子数组,直到每个子数组只有一个元素(因为单个元素被认为是已排好序的)或为空。
合并:将两个已排序的子数组合并成一个有序的数组。这个步骤会递归地进行,将合并的子数组作为新的子数组,直到最后只剩下一个完整的有序数组。
具体步骤如下:
首先,对原始数组进行递归地分解,将数组划分成越来越小的子数组,直到每个子数组只包含一个元素。
然后,对每一对相邻的子数组进行合并操作。合并操作的原理是比较两个子数组的元素,并按照升序或降序的要求将它们合并成一个有序的大数组。可以使用额外的辅助数组来辅助合并操作。
递归地执行步骤2,直到所有的子数组合并成一个完整的有序数组。
归并排序的关键是合并操作,而合并操作的核心思想是通过比较两个有序的子数组的元素,创建一个新的有序数组。在合并的过程中,能够保持相同元素的相对顺序,从而保证归并排序的稳定性。
代码实现
递归版本
对于数组的分解过程,我们可以采用递归的分治方法解决,二分取中即可,而对于两个有序数组的合并不算是什么难题,但是我们需要注意将两个数组合并后还要更新到原数组的对应的区间里,以更新我们的排序结果,所以我们需要额外的O(n)空间开辟辅助数组来保存每次的对应区间的排序结果。
cpp
//归并排序的递归策略
void MergeFunc(Elemtype* a, int begin, int end, Elemtype* tmp)
{
if (begin >= end)
return;
int mid = (begin + end) / 2;
MergeFunc(a, begin, mid, tmp);
MergeFunc(a, mid + 1, end, tmp);
int i = begin, j = mid + 1;
int t = 0;
while (i <= mid && j <= end)
{
if (a[i] <= a[j])//如果i对应的位置比j对应的位置上的元素小,那么放入tmp数组后i++
tmp[t++] = a[i++];
else //反之j++
tmp[t++] = a[j++];
}
//当while循环结束后,[begin,mid]和[mid+1,end]两个区间必定有一个已经跑完,剩余的元素必定已经有序
//下述两个while只会选择走一个
while (i <= mid)
tmp[t++] = a[i++];
while (j <= end)
tmp[t++] = a[j++];
//再将tmp保存好的有序数组按位置更新到原数组对应的区间上去
for (int i = 0; i < t; i++)
a[begin + i] = tmp[i];
}
void MergeSort(Elemtype* a, int n)
{
//首先定义临时数组tmp,将排序后将结果更新到原数组使用
Elemtype* tmp = (Elemtype*)malloc(sizeof(Elemtype) * n);
MergeFunc(a, 0, n - 1, tmp);
free(tmp);
}
非递归策略
非递归策略作为了解,实际中一般不常用,因为归并排序是将对应的区间划分后再由一个元素逐层向上合并排序,不同的区间之间互不影响,所以,我们可以直接跳过分组过程,来到合并阶段,我们将数组直接分组到底,也就是每组只剩一个元素,这样每组的元素就是有序的,再以2,4,8......等符合合并二叉树的结构的2的次幂向上合并,每次都确定的一个分割数将整个数组合并并排序,直到分割数等于整个数组的元素个数即可合并完毕,但是,我们需要注意整个合并过程中的边界问题。
cpp
//归并排序的非递归策略
void MergeSort1(Elemtype* a, int n)
{
Elemtype* tmp = (Elemtype*)malloc(sizeof(Elemtype) * n);
int gap = 1;//初始间隔
while (gap <n)
{
printf("按每组%d个元素分割\n",gap);
int t = 0;
for (int i = 0; i < n; i += 2 * gap)
{
int begin1 = i, end1 = i + gap - 1;
int begin2 = i + gap, end2=i+ 2 * gap - 1>=n ? n-1 : i + 2 * gap - 1;//防止越界
//我们需要判断越界问题
//如果两个边界只有一个,那么我们不需要在对其排序,因为每个区间都已经是有序的状态
if (end1 >= n || begin2 >= n)
break;
printf("[%d,%d] [%d,%d]\n", begin1, end1, begin2, end2);
while (begin1 <= end1 && begin2 <= end2)
{
if (a[begin1] <= a[begin2])
tmp[t++] = a[begin1++];
else
tmp[t++] = a[begin2++];
}
while (begin1 <= end1)
tmp[t++] = a[begin1++];
while (begin2 <= end2)
tmp[t++] = a[begin2++];
for (int k = i; k <t; k++)//从对应的位置将tmp拷贝到原数组
a[k] = tmp[k];
}
PrintArray(a, n);
printf("\n");
gap*=2;
}
free(tmp);
}
逆序对问题
归并排序经常用于解决逆序对问题,这里不在详细展开,可以参考求解逆序对问题。
性能分析
归并排序的时间复杂度是 O(nlogn),其中 n 是待排序数组的长度。虽然归并排序在最坏情况下的复杂度也是 O(nlogn),但由于它始终都要使用额外的辅助数组,在空间复杂度上略高于其他排序算法,为 O(n)。因为归并排序是一种稳定且效率高的排序算法,所以它被广泛应用在各种排序场景中。
八.非比较排序--计数排序
实现原理
计数排序是一种线性时间复杂度的排序算法,适用于待排序的元素范围较小且已知的情况。它的基本思想是通过统计每个元素的个数,然后根据元素的顺序和个数依次输出排序结果,计数排序只适用于非负整数或小范围整数的排序,且当元素范围过大时,计数数组的大小会变得很大,可能会造成空间浪费,因此泛用性比不过其他的排序。
具体步骤如下:
找出待排序数组中的最大值max和最小值min,确定计数数组的大小范围。
创建一个计数数组 count[],大小为 max - min + 1,用于存储每个元素出现的次数。
遍历待排序数组,将每个元素出现的次数记录在计数数组中。具体操作是将元素的值减去min作为在计数数组中的索引,然后将对应位置的计数值加一。
对计数数组进行顺序求和操作。即对每个元素,将它与前一个元素相加,得到小于等于该元素的个数。
创建一个临时数组temp[],大小与待排序数组相同。
逆序遍历待排序数组,将每个元素根据计数数组中的信息放置在临时数组中的正确位置。
将临时数组temp[]复制回原始数组。
计数排序的核心是计数数组,而计数数组的大小取决于待排序元素的范围。它通过对待排序数组进行两次遍历实现排序,一次遍历用于统计元素出现的次数,一次遍历用于将元素放置在排序后的正确位置。由于计数排序的时间复杂度与元素的范围相关,因此在元素范围较小时,计数排序的性能非常好。
代码实现
cpp
// 时间复杂度:O(N+Range)
// 空间复杂度:O(Range)
// 缺陷1:依赖数据范围,适用于范围集中的数组
// 缺陷2:只能用于整形
void CountSort(int* a, int n)
{
int min = a[0], max = a[0];
for (int i = 0; i < n; i++)
{
if (a[i] < min)
{
min = a[i];
}
if (a[i] > max)
{
max = a[i];
}
}
int range = max - min + 1;
int* countA = (int*)malloc(sizeof(int) * range);
memset(countA, 0, sizeof(int) * range);
// 统计次数
for (int i = 0; i < n; i++)
{
countA[a[i] - min]++;
}
// 排序
int k = 0;
for (int j = 0; j < range; j++)
{
while (countA[j]--)
{
a[k++] = j + min;
}
}
}