【七大排序算法(三)】之交换排序
本篇文章篇幅较长,如有需要请耐心看完,对于不太理解交换排序的朋友,看完此篇保证你有所收获(ノ ̄▽ ̄)(ノ ̄▽ ̄)(ノ ̄▽ ̄)(ノ ̄▽ ̄)
一、冒泡排序(最常见)
1.1概念
冒泡排序是最常见的排序方式,根据序列中两个记录键值的比较结果来进行交换这两个记录在序列中的位置。即每次两两比较,将大(小)的向后面移动的过程。
1.2图解(动图)
1.3思路剖析及代码演示
冒泡排序就是从左往右依次遍历,选出两个数据比较,如果是升序排列,将选出最大的那个数依次往后比较,直到遇见最后一个数(如上图)。每排完一次,待排序数字个数都要减一。
void Swap(int* p1, int* p2)
{
int tmp = *p1;
*p1 = *p2;
*p2 = tmp;
}
void BubbleSort(int* a, int n) {
for (int j = 0; j < n-1;j++) {//控制趟数
for (int i = 0; i < n - j - 1;i++) {//控制比较次数
if (a[i]>a[i+1]) {
Swap(&a[i],&a[i+1]);
}
}
}
}
void BubbleSort(int* a, int n)
{
for (int j = 0; j < n-1; ++j)
{
int exchange = 0;
for (int i = 1; i < n-j; ++i)
{
if (a[i - 1] > a[i])
{
Swap(&a[i - 1], &a[i]);
exchange = 1;
}
}
// 一趟冒泡过程中,没有发生交换,说明已经有序了,不需要再处理
if (exchange == 0)
{
break;
}
}
}
冒泡排序的核心就在于两层循环的终止条件,对于n个数据,每一趟只能排一个数据,所以需要跑n次;由于每一趟的比较都会确定一个最大值,第二趟的时候就不需要比较最大一个数据,以此类推,每次减少一个,相当于n-j-1次。
1.4复杂度及稳定性分析
**【时间复杂度】:O(N2)
【空间复杂度】:O(1)
稳定性:稳定
**
1.对于冒泡排序而言,执行次数是一个等差数列,也是经典的O(n^2)。
2.空间复杂度也是一样的O(1)
3.排序过程只比较相邻两个记录的关键字,若交换记录也只在相邻二个记录之间进行,从而可知在交换过程中不会出现跨越多个记录的情形。即使是相邻两个记录关键字相同时,经过比较也不会产生相邻记录的交换。所以冒泡排序法不会改变相同关键字记录的相对次序,故是稳定的。
二、⚠️快速排序(排序算法的老大哥,牢记,掌握!!!)
2.1 概念
任取待排序元素序列中的某元素作为基准值,按照该排序码将待排序集合分割成两子序列,左子序列中所有元素均小于基准值,右子序列中所有元素均大于基准值,然后最左右子序列重复该过程,直到所有元素都排列在相应位置上为止
2.2 hoare版本实现方法
2.1.1 图示(动图)
2.1.2 思路分析
- 首先再度明确规则:选取最左边作为基准值,然后让right去找比基准值小的数字,left找比它大的值。即左边做key,右边先走,右边找到小的停下来,左边再去找大,最后交换key。
- L与R相遇可分为两种情况
第一种R停住:右边R找到小的停住了。此时左边L在找的过程中并没有找到比key大的,因此二者只能会面,那么会面的这个值一定要比key要来的小。
第二种L停住:R在右边找到了比key小的,L在左边找到了比key大的,二者进行交换,此时L上的数就是比key要小的(交换过来了)。后面R在进行第二轮搜寻的时候并没有再找到比key小的了,只能和L相遇然后和key交换。
然后根据思路用代码实现一下
2.1.3 代码演示及常见问题分析
代码的核心:控制L,R走动的循环条件
//快速排序,以key为中心,右边小于key,左边大于key
void QuickSort(int* a, int begin, int end) {
if (begin>=end) {//递归结束条件
return;
}
int left = begin, right = end;
int key = left;
while (left<right)
{
//key在左边,右边先走,找比key小的
//如果key右边全大于key,避免越界加个left<right
//如果不加=,存在死循环,左右都碰到与key相等的值
//6, 1, 2, 7,6, 9, 3, 4, 5, 6, 8
while (left<right&&a[right]>=a[key]) {
right--;
}
//左边再走,找比key大的
while (left < right && a[left] <= a[key])
{
left++;
}
Swap(&a[left],&a[right]);
}
//最后当left和right相遇的时候将相遇位置的值与keyi位置的值交换
Swap(&a[left],&a[key]);
key = left;//更新key
QuickSort(a,begin,key-1);
QuickSort(a,key + 1,end);
}
来,我们慢慢分析
- 伙计们在一上来可能就会写成这样,也是很多人的通病(没有考虑到特殊情况)
while (a[right]>a[key]) {
right--;
}
//左边再走,找比key大的
while (a[left] < a[key])
{
left++;
}
对上述代码来说:
!!!内部嵌套while会导致越界风险
!!!没考虑相等情况导致死循环
对于递归调用方面,读者可以尝试画一下递归展开图去理解一下。
2.3 复杂度及稳定性分析
【时间复杂度】:O(NlogN)
【空间复杂度】:O(logN)
稳定性:不稳定
1.可以看到,每一次要搜寻遍历的数字个数,随着key值不断地确定,在递归的过程中便慢慢减少,但是量级上和N相差太多,可以忽略不计
2.递归调用次数根据图可以发现类似一个二叉树,深度约为【logN】,所以时间复杂度理想状态下为O(NlogN)
前面我们也分析过,有最好最坏和平均,那么,他的最坏情况是什么呢?
就是序列本来就有序,无论是【顺序】还是【逆序】,选key的时候会选出最大值或者最小值,它都会处于最坏情况:
假设你在左边选到了一个最大的数做key,此时这个序列还是逆序。但是要将比它小的数都放到它左边,他会退化成冒泡排序,时间复杂度为O(n^2)
假设你选取的key值是在最右边,选择了一个最小的数,此时这个序列还是顺序,那么就需要将它左边的所有数都放到这个key值的右边,也是N^2
缺陷二:用递归那就不得不说说递归面临的问题**【栈溢出】**
当递归深度过高的时候,栈溢出很常见。对于快速排序而言,递归就要调用函数,函数就需要建立栈帧。所以在不断向下递归的过程中,就会产生许多栈帧,不过在回调的时候还是会去重复利用栈帧的。因此空间复杂度参考二叉树的深度(logn)
2.4 💪快速排序优化
2.4 .1三数取中法、小区间优化法
【三数取中法】就是从三个数中取出中间的那个数,我们设左边的数为left,设右边的数为right,然后它们的中间值为mid作为新的key,这种方法几乎(后面会说为什么)避免了最坏情况。
【小区间优化法】主要针对递归调用进行优化,通过其它排序,减少递归调用次数,总体上提高效率。
//三数取中法
int GetMidIndex(int* a,int begin,int end) {
int mid = (begin + end) / 2;
if (a[mid]>a[begin]) {
if (a[mid]<a[end]) {
return mid;
}
else if (a[begin]>a[end]) {
return begin;
}
else
{
return end;
}
}
else//a[mid]<a[begin]
{
if (a[end]<a[mid]) {
return mid;
}
else if(a[begin]<a[end])
{
return begin;
}
else
{
return end;
}
}
}
//更新key值
int mid = GetMidIndex(a, begin, end);
Swap(&a[begin], &a[mid]);
三数取中法看起来很好理解,但是编写的时候常常会出现逻辑不清的情况,需要细心分析一下
//小区间优化
if ((end-begin+1)<15) {
//小区间直接插入排序,减少递归次数
//注意,递归调用的时候不能用原来的起始位置,要加上begin变成递归调用区间的起始位置
InsertSort(a+begin,end-begin+1);
}
当递归调用到需要排序的数字个数小于15(一般选取15–20)时,我们就不用快速排序了,改用直接插入排序更好,虽然它的性能并不占优势,但是在此处数的个数很少的情况下还是很合适的。
不接着使用快排是为什么呢?看张图
递归调用次数类似一个二叉树,根据二叉树的性质,那么调用次数就服从1,2,4… 2^(n-1),如果把最后一层去掉,那么就相当于减少了约50%的递归调用次数,大大减少了递归调用次数,提高了效率。
完整代码:
//快速排序优化
//三数取中
int GetMidIndex(int* a,int begin,int end) {
int mid = (begin + end) / 2;
if (a[mid]>a[begin]) {
if (a[mid]<a[end]) {
return mid;
}
else if (a[begin]>a[end]) {
return begin;
}
else
{
return end;
}
}
else//a[mid]<a[begin]
{
if (a[end]<a[mid]) {
return mid;
}
else if(a[begin]<a[end])
{
return begin;
}
else
{
return end;
}
}
}
void QuickSort(int* a, int begin, int end) {
if (begin >= end) {
return;
}
//小区间优化
if ((end-begin+1)<15) {
//小区间直接插入排序,减少递归次数
//注意,递归调用的时候不能用原来的起始位置,要加上begin变成那个区间的起始位置
InsertSort(a+begin,end-begin+1);
}
else {
//三数取中,避免最坏情况(key为最大值或最小值)
int mid = GetMidIndex(a, begin, end);
Swap(&a[begin], &a[mid]);
int left = begin, right = end;
int key = left;
while (left < right)
{
while (left < right && a[right] >= a[key]) {
right--;
}
while (left < right && a[left] <= a[key])
{
left++;
}
Swap(&a[left], &a[right]);
}
Swap(&a[left], &a[key]);
key = left;
QuickSort(a, begin, key - 1);
QuickSort(a, key + 1, end);
}
}
2.5快排方法拓展
2.5.1挖坑法
挖坑法实在hoare方法的基础上进行简单的优化,限制较少,同时也便于理解。
图示:
思路:
①直接将最左端的值选出来作为key值,然后【右边找小】,放入坑位,然后更新坑位值为右侧找到的那个数所在的下标;
②出现了新的坑位后,【左边找大】,找到之后将数字放到新的坑位中,然后继续更新坑位。
③循环往复上面的步骤,直到两者相遇为止,更新相遇处为最新的坑位,然后将key值放入坑位即可,保证左边比key小,右边比key大
代码展示
//挖坑法---把key所在位置设置为一个空,找小,找大的值,都与坑交换
int PartSort2(int* a, int begin, int end){
int mid = GetMidIndex(a, begin, end);
Swap(&a[begin], &a[mid]);
int left = begin, right = end;
int key = a[left];
//设置一个坑
int hole = left;
while (left < right)
{
//右边找小
while (left < right && a[right] >= key) {
right--;
}
//小的值放进坑里,并把原来的位置置为坑
a[hole] = a[right];
hole = right;
//左边找大
while (left < right && a[left] <= key)
{
left++;
}
a[hole] = a[left];
hole = left;
}
//把原来坑位的值放到hole
a[hole] = key;
//返回值赋给key再去划分子区间
return hole;
}
排序过程图解:
2.5.2 前后指针法
图示:
思路:
①定义一个prev指针位于起始端,再定义一个cur指针指向prev的后一个,记录当前位置上的key值
②cur指针向后找比key小的值,若是找不到,则一直++;若是cur找到了比key小的值,++prev,然后交换二者的值之后cur再++
③直到cur越界,将此时prev位置上的值与key值做一个交换,保证左边比key小,右边比key大
代码展示
//双指针,prev,cur;cur往前走,找到比key小的
int PartSort3(int* a, int begin, int end) {
int mid = GetMidIndex(a, begin, end);
Swap(&a[begin], &a[mid]);
int left = begin, right = end;
int key = begin;
//双指针
int prev = begin, cur = begin + 1;
while (cur <= end)
{
//cur向右找比key小的数,找到之后停下与++prev交换
if (a[cur]<a[key] && ++prev !=cur) {//减少交换次数
Swap(&a[prev],&a[cur]);
}
/*if (a[cur]<a[key]) {
Swap(&a[++prev],&a[cur]);
}*/
++cur;
}
//prev位置的数一定比key小,交换并更新key;
Swap(&a[prev],&a[key]);
key = prev;
return key;
}
上述代码最多的疑问是为什么要&& ++prev !=cur
,看图可以发现第一、二次在交换的时候,数据就没有动,因为它们是相同的,此时我们可以去做一个优化,那就是判断++prev之后的位置是否与cur是相同的,若是则不进行交换
过程图解:
2.6💪快排缺陷及再次优化(三路划分法)🥲
我们测试的都是一些正常的数据,如果遇到特别极端的数据,比如全是一个数(4,4,4,4,4,4,4,4,4,4)这种,这就会导致快速排序的效率大幅度下降,我们引用三路划分的方法,再次优化此算法。
三路划分法思想:
此时我们需要三个指针,一个【left】指向首端,一个【right】指向尾端,再一个【cur】指向left的后一个位置,对于【key值】也是一样取首端所在位置的值(已经完成三数取中)。
核心:cur对应的值与key比较,把比key小的仍在左边,与key相等的往后推,比key大的扔右边,这样就会出现跟key相等的处于中间位置。只需要递归调用旁边的两个区间就ok。
分布图解:
代码演示
普通测试记得注销小区间优化哦,不然直接用直接插入排序了(^_-)
void QuickSort2(int* a, int begin, int end) {
if (begin >= end) {
return;
}
//小区间优化
//if ((end-begin+1)<15) {
// //小区间直接插入排序,减少递归次数
// //注意,递归调用的时候不能用原来的起始位置,要加上begin变成那个区间的起始位置
// InsertSort(a+begin,end-begin+1);
//}
//else
{
//三数取中,避免最坏情况(key为最大值或最小值)
int mid = GetMidIndex(a, begin, end);
Swap(&a[begin], &a[mid]);
int left = begin, right = end;
int key = a[left];
int cur = begin + 1;
while (cur<=right) {
if (a[cur]<key) {
Swap(&a[cur],&a[left]);
cur++;
left++;
}
else if(a[cur]>key)
{
Swap(&a[cur],&a[right]);
--right;
}
else//a[cur]==key
{
cur++;
}
}
//[begin,left-1][left,right][right+1,end]
QuickSort2(a, begin, left-1);
QuickSort2(a, right+1, end);
}
}
2.7快速排序的“非递归写法”🥲(有点难度)
递归虽然看起来简便,但也存在的缺陷,随着递归的层层深入,会建许多的栈帧,但若是建立的栈帧数量超出了编译器预留的栈空间大小,此时就会导致栈溢出。
递归的是一层嵌套一层,一直递归到结束条件为止然后返回,看起来是很舒服的,但是随着这个数据量的增大,递归的深度也会逐渐地加深。而且递归它是需要在栈空间中开辟栈帧的,在内存中,这个栈空间很小,只有1MB。
代码实现:
非递归主要使用栈来实现,有关于栈的底层实现在这里不做描述,不理解的读者可以先去搜索一下。
//快速排序非递归实现
void QuickSortNonR(int* a, int begin, int end) {
if (begin>=end) {
return;
}
//创建栈
ST st;
//初始化栈
StackInit(&st);
//入栈
StackPush(&st,begin);
StackPush(&st, end);
while (!StackEmpty(&st))//控制条件,栈不为空
{
//取栈顶元素
int right = StackTop(&st);
//出栈
StackPop(&st);
int left = StackTop(&st);
StackPop(&st);
//区间内排序,PartSort1及上述提到的hoare方法
int key = PartSort1(a,left,right);
//[left,key-1]key[key+1,right]
//根据栈的性质,先进后出,后面的先进
if (key+1<right) {
StackPush(&st,key+1);
StackPush(&st, right);
}
if (left<key-1) {
StackPush(&st, left);
StackPush(&st, key-1);
}
}
//销毁栈
StackDestroy(&st);
}
①这里的内部循环始终要遵循的一条原则是栈的【先进后出】,此时看到要先将左右两个端点先入栈,然后在循环中,先取出栈顶的两个数据然后出栈,后入的便是右区间端点,先入的是左区间端点,分别用right和left进行保存
②然后将这个两个端点值通过上面所说到的三种快速排序的方法,使用单趟的一个逻辑,去求出每一次进来之后的key位置,然后再利用这个key进行一个左右区间的分化,也是一样的规则,先入右,后入左,但是在入栈之前要先判断一下当前这个区间的值的个数,若是只有一个数或者是这个区间根本就不存在的话,那就不需要再入了,若是区间值的个数> 1 就将这个区间入栈即可
③最后循环往复执行模拟递归,直到递归完左区间之后再递归右区间,最终到栈空为止表示没有区间需要在进行排序了。
可以参考一下图片深入理解:
文章篇幅较长,感谢你的阅读,如对你有帮助,欢迎点赞评论,谢谢!😃