目录
推荐书籍👌 |
《大话数据结构》 |
《数据结构与算法图解》 |
一、进阶排序算法
(接下来的排序难度上升较大,需要耐心学习)
1.1 希尔排序
介绍:
希尔排序又称缩小增量排序 ,之前接触的三种排序时间复杂度均为 O(n^2) ,而 D·L·Shell 于 1959 年提出的希尔排序实现了时间复杂度的突破,其时间复杂度为 O(n*logn);
通过怎样的方法提高排序效率呢?首先我们来了解一个概念:
基本有序:较小的数基本都排在前面,较大的数基本都排在前面,例如{2,1,3,6,7,5,9,7,8}
如果一个序列他基本有序,序列中待排序的记录个数会大量的减少,那么现在再用头上讲的插入排序,效率自然就高了;问题来了,用何种方法将序列趋于基本有序呢?
※ 希尔选取间隔一定增量的数组成一个子序列,在这个子序列中执行插入排序,实现局部有序,之后缩小增量,再在组成的子序列中进行插入排序,当增量为1时,就相当于对整个序列进行插入排序。图例⬇️
(1)看下面代码,进入循环后增量(gap)分别有3、2、1;当gap为3时,进入for循环(i),带入之前学习的插入排序,进入while循环 { 将与end相隔gap的数插入到由gap拆分的子序列中 },之后随着 i ++,序列中的数依次进行在自己子区间中的插入排序;
(2)到最后进入while循环 gap = gap / 3 + 1 = 1,此时整个序列已经是基本有序了,再次执行插入排序,这样之后整个序列就变成有序的了✔️
void ShellSort(int* a, int n)
{
int gap = n;
while (gap > 1)
{
gap = gap / 3 + 1; //特别注意:最后gap必须为 1
for (int i = 0; i < n - gap; i++) //同上,注意i的范围
{
int end = i;
int tmp = a[end + gap];
while (end >= 0)
{
if (a[end] > tmp)
{
a[end + gap] = a[end];
end -= gap;
}
else
break;
}
a[end + gap] = tmp;
}
}
}
1.2 堆排序
引入:
我们想想之前学习的完全二叉树的形状是不是也与堆相似呢堆的逻辑结构是一颗完全二叉树,而物理结构是一个数组(二叉树中结点位置就是数组的下标)⬇️;
既然是完全二叉树,那么就有父节点与子结点了,由以下公式可求出左孩子、右孩子以及父节点的下标:
leftchild = parent * 2 +1 rightchild = parent * 2 + 2
parent = (child - 1) / 2
堆又分为大顶堆(树中所有父亲的值都大于或等于孩子)和小顶堆 (树中所有的父亲的值都小于或等于孩子)例如⬇️:
若给出一个数组,让判断是否是堆,那么就用图二中的方法将其逻辑化为一个完全二叉树,再判断其是否为大小堆;
那么接下来我们一起来实现这个堆排序(小堆):
建堆:(1)若将一个数组建成一个小堆,首先我们需要用到向下调整算法:从根节点开始,我们需要其左右子树均为小堆,之后选择左右孩子中较小的一个,将其与根比较,若小于根的值则交换,然后继续往往下走,循环上面的步骤到叶结点。⬇️
以下为向下调整算法的代码(解析写在了代码的注释当中)✔️:
void AdjustDown(int* a, int n, int root) /*root为传入的数组下标*/
{
int parent = root;
int child = parent * 2 + 1; /*用之前的公式先找到左孩子*/
while (child < n) /*注意 child < n */
{
if (child + 1 < n && a[child] < a[child + 1]) /*若右孩子较大,则将child+1*/
{
child += 1;
}
if (a[child] < a[parent] /*如果想要得到大堆,只需要将<改成>即可,上面代码也要改*/
{
Swap(&a[child], &a[parent]);
parent = child;
child = child * 2 + 1; /*交换完之后需要转到子结点,再进入while循环*/
}
else
break;
}
}
(2)如果左右子树不是小堆,也就意味着不能使用向下调整算法了,那怎么让左右子树均为小堆呢?(结合两段代码)⬇️
要使每个子树均为小堆,那么我们“从整棵树中最后的一颗小树起手”,首先将随后一个叶结点的父亲传入(AdjustDown),对其使用向下调整算法,之后( i -- )到下一个结点,直至最后到第一个结点的时候(i= 0)将整棵树进行向下调整算法,及建得小堆。
我们画图分析:✔️
由图,从“小树”开始,需要传入他的父节点,我们知道二叉树从上到下依次对应这数组的下标,那么最后一个叶结点的下标就为 n - 1 (n为数组元素个数),其父节点由公式可得:(n - 1 - 1)/ 2
void HeapSort(int* a, int n)
{
for (int i = (n - 1 - 1) / 2; i >= 0; i--) /* 建堆的时间复杂度O(N)*/
{
AdjustDown(a, n, i);
}
/*排升序,建大堆; 排降序,建小堆*/
int end = n - 1;
while (end > 0)
{
Swap(&a[0], &a[end]); /*整体时间复杂度为O( N * logN )*/
end --;
AdjustDown(a, end, 0);
}
}
排序:
排升序,建大堆;排降序,建小堆;为什么呢?设想如果是排降序,建大堆,那么首先选出堆顶最大数,之后剩下的数又要重新建堆,效率就大打折扣了;
但如果是建小堆,那么每次将堆顶的元素,也就是最小的元素放在最后,在将其排出(end --,end指向的是最后一个元素),又因为左右子树是小堆,所以再次对0到end的数用向下调整算法,让堆顶的数再次变为最小值,再次循环⬆️
我们再看图分析:⬇️
这样之后也就彻底完成堆排序了,步骤确实复杂有难度。俗话说好的东西需要时间的沉淀,那么堆排序有什么优缺点呢:
堆排序不同于希尔排序,不用考虑原始的排序状态,时间复杂度总体是O( n*logn ),但是因为是跳跃式的交换,堆排序是一种不稳定的排序。
1.3 快速排序—递归
简介:
快速排序是Hoare于1962年提出的一种二叉树结构的交换排序方法,其基本思想为:任取待排序元素序列中的某元素作为基准值,按照该排序码将待排序集合分割成两子序列,左子序列中所有元素均小于基准值,右子序列中所有元素均大于基准值(升序),然后左右子序列重复该过程,直到所有元素都排列在相应位置上为止。
总体思想是这样的——分区间加上递归,但是如何将两个区间分出来就是个问题了,接下来我们一起学习一下三种方法🙌
方法(1):
挖坑法:
选择一个关键字key = a[begin](左边或者是右边)作为基准值,第一趟排完序之后,左边的值比key小,右边比key大,所以key的位置就可以不用动了,之后再递归key的左边和右边,循环以上步骤;
🔢我们先看里面的while循环,右边end--,直到找到比key小的值,将它放到提前挖好的坑里(pivot = begin,因为begin指向的值已经被key拷贝,所以可以覆盖),之后再将拿走的位置形成新的坑,让其放入从左边找到的比key大的值。
🔢经过之前的一趟下来只交换了两个数,没有保证key最后放置好后左边小右边大,所以还需要再上面的步骤上套一层while循环;
🔢当begin与end指向的位置相同,该位置也就是key存放的坑了; 图解如下⬇️
🔢联想二叉树,采用分治递归的方法将每个区间的左右区间变为有序,最后实现整个序列有序;
void QuickSort1 (int* a, int left, int right)
{
if(left >= right){
return;
} //递归返回条件
int begin = left, end = right;
int key = a[begin];
int pivot = begin;
while (begin < end)
{
while (begin < end && a[end] >= key)
{
end--;
}
a[pivot] = a[end];
pivot = end; //end空出变为新坑
while (begin < end && a[begin] <= key)
{
begin++;
}
a[pivot] = a[begin];
pivot = begin; //begin空出变为新坑
}
pivot = begin; //此时 end=begin
a[pivot] = key;
QuickSort1(a, left, pivot - 1);
QuickSort1(a, pivot + 1, right); //将整个序列分为[left,pivot-1] [pivot] [pivot+1,right]
}
铺垫:
之后的方法根挖坑法类似的,也是排完一个值,将左右区间递归,所以我们可以把这个步骤写成一个函数,函数结束返回基准值的下标(方法一中的pivot),将不同的方法放入快速排序的函数中⬇️
void QuickSort(int* a, int left,int right) { if (left >= right) { return; } //int keyIndex = PartSort1(a, left, right); 方法一 int keyIndex = PartSort2(a, left, right); //方法二 //int keyIndex = PartSort3(a, left, right); 方法三 QuickSort(a, left, keyIndex - 1); QuickSort(a, keyIndex + 1, right); }
方法(2):
左右指针法:
🔢 while循环之前的步骤跟方法一是相同的,加上三数找中,将三个数中不大不小的放在第一位;
🔢 先看里面的那层while循环:跟挖坑法一样,从左边找大,从右边找小,之后将大于key和小于key的值交换
※特别注意:while的判断条件里必须加上 begin < end ,设想如果从右边找,所有数都大于key的值,那么end就会减到begin的左边,end就越界了,所以每次进入while循环都需要对begin与end比较大小。✔️
🔢 之后看外面的一层循环:若begin < end ,继续循环第二步,直到begin与end指向同一位置,该位置及为基准值存放的位置(代码注释里有其余的补充说明⬇️)
🔢 函数结束将begin(end)传回QuickSort函数,再递归左右区间。
在画图分析时,我发现必须先从后开找,如果先从前找大的话,画图后的结果是错的,这个等待之后的确认吧
int PartSort2(int* a, int left, int right)
{
int index = GetMidIndex(a, left, right);
Swap(&a[index], &a[left]); //后续改良介绍的三数取中
int begin = left ,end = right;
int key = begin;
while (begin < end)
{
while (begin < end && a[end] >= a[key]) //后面的>= 也是必需的,大家可自行画图理解
{
end--;
}
while (begin < end && a[begin] <= a[key])
{
begin++;
}
Swap(&a[begin], &a[end]);
}
Swap(&a[begin], &a[key]); //因为key在首元素没有动,所以直接与begin指向的位置交换
return begin;
}
方法(3):
前后指针法:
🔢 跟之前两个方法相似,三数去中后将第一个元素定为key
🔢 定义两个指针,cur指向prev的后面一个,让cur向后移去找比key小的值,找到之后停住cur,将prev向后移一位,将cur与prev指向的值交换(若两者指向同一位置就可不用交换了)
🔢 当cur找完整个序列之后,prev指向的就是基准值带存放的位置了,最后将prev指向的值与key交换,本次排序即可完成
🔢 函数结束将prev传回QuickSort函数,再递归左右区间。⬇️
(图中的key实际上是下标,key = 0,a[key] = 6)
当cur指向的数小于a[key]时,prev++,所以到最后prev之前的值肯定都比a[key]的值小了
int PartSort3(int* a, int left, int right)
{
int index = GetMidIndex(a, left, right);
Swap(&a[index], &a[left]);
int key = left;
int prev = left, cur = left + 1;
while (cur <= right)
{
if (a[cur] < a[key] && ++prev!= cur) //如果小于再执行后面一句让++prev,前置++,先加后
Swap(&a[prev], &a[cur]); //然后判断prev与cur是否相等,如果相等可省去交换
cur++;
}
Swap(&a[prev], &a[key]);
return prev;
}
快排总结:
到此为止,快速排序的三种方法就讲解到这里了;快排的核心思想其实就是通过一次排序将一个数排到它最后属于的位置,然后以他为界递归左右区间
时间复杂度:O(logn)
1.4 归并排序—递归
引入:
归并排序是目前为止我认为最复杂的排序了,要弄懂归并排序需要对递归以及二叉树有更深层次的理解;
归并排序的理解非常抽象,简单来说就是将一个无序序列不断分成两个序列,直到分成单独元素,然后开始归并:将两两序列归并成有序序列,具体如何实现,我们一起看下面代码和图解。
解析:
🔢 因为需要将数归并回去,所以我们需要创建一个临时空间(malloc)
🔢 当left >= right,说明递归到只有一个元素,这时候就可以返回去归并了
🔢 后面定义五个变量指向不同位置,左右区间的开头和结尾,以及临时数组的开头;后面的代码实现起来就简单了:比较大小,将小的数放在临时数组里,如果遇到一个区间的数放完,另一个区间有剩的,直接将剩下区间的数挨个放入。
说着简单,但是代码如何运行、如何递归的以及为什么这样写是个问题,接下来我用视频录制的方法将我理解的思路分享给大家 ⬇️
《归并排序的递归过程》
视频中我只讲递归的代码截图了,在看时需要直到当右区间递归完之后,紧接的就是将本次的左右区间归并成一个有序序列✔️
看了视频之后有没有发现这个递归步骤很想之前讲解二叉树中的后序遍历🤔
void _MergeSort(int* a, int left, int right, int* tmp)
{
if (left >= right)
return;
int mid = (left + right) >> 1;
_MergeSort(a, left, mid, tmp);
_MergeSort(a, mid + 1, right, tmp);
//归并
int begin1 = left, end1 = mid;
int begin2 = mid + 1, end2 = right;
int index = left;
while (begin1 <= end1 && begin2 <= end2)
{
if (a[begin1] < a[begin2])
tmp[index++] = a[begin1++];
else
tmp[index++] = a[begin2++];
}
while (begin1 <= end1)
{
tmp[index++] = a[begin1++];
}
while (begin2 <= end2)
{
tmp[index++] = a[begin2++];
}
for (int i = left;i <= right; i++)
{
a[i] = tmp[i];
}
}
void MergeSort(int* a, int n)
{
int* tmp = (int*)malloc(sizeof(int) * n);
_MergeSort(a, 0, n - 1, tmp);
free(tmp);
}
二、改良排序
2.1 三数找中
我们回想一下之前学习的快速排序,如果每次选择的key都位于排序之后的中间值,那么这个排序就无限地接近于二叉树(O(logn))地结构了,排序地效率会大大增加。
将序列地头尾以及中间值比较大小⬇️
int GetMidIndex(int* a, int left, int right)
{
int mid = (left + right) >> 1;//除2;
if (a[left] < a[mid])
{
if (a[right] > a[mid])
return mid; //a[left] < a[mid] < a[right]
else if (a[left] > a[right])
return left; //a[right] < a[left] < a[mid]
else
return right; //a[left] < a[right] < a[mid]
}
if (a[left] > a[mid])
{
if (a[mid] > a[right])
return mid;
else if (a[right] > a[left])
return left;
else
return right;
}
}
2.2 小区间优化
通过之前对快速排序(递归)的了解,当分成的很小的递归区间时,还要通过递归左区间和右区间;如果需要排序的是一串很长的数字,到最后递归区间只有几个数时,需要递归的次数变得非常多,效率自然就低了,这样不如直接对剩余的小区间序列用之前学习的排序方法,一次性将其排序🤔
那么选择何种排序呢?
首先排除冒泡和选择这两个效率较低的排序,从插入和希尔排序,因为最后待排序数量较小,所以直接用较为简单的插入排序就行了。
所以更改之后的代码如下 ⬇️
if (keyIndex - 1 - left > 10) //可根据待排序的总量确定小区间优化的范围
QuickSort(a, left, keyIndex - 1);
else
InsertSort(a + left, keyIndex - 1 - left + 1);
if (right - (keyIndex + 1) > 10)
QuickSort(a, keyIndex + 1, right);
else
InsertSort(a + keyIndex + 1, right - (keyIndex + 1) + 1);
如果这里对插入排序有遗忘的,可以返回去看插入排序的内容https://blog.csdn.net/Dusong_/article/details/127061544?spm=1001.2014.3001.5502
回来我们继续看,传入插入排序的第一个参数是数组及排序的首元素—> 数组名+n表示数组下标为n的地址,第二个参数是待排序个数,所以需要对,(keyIndex - 1) - left 再加上1(左区间) / right - (keyIndex + 1) 再加上1(右区间);
• 之前学习的快速排序和归并排序都是用递归来实现的,但是递归有个致命的缺陷,那就是当递归调用栈帧的深度非常深的时候就会导致栈溢出(Stack overfolw),所以想要完全吃透快排和归并我们还需要掌握他的非递归算法
• 将递归改成非递归的方法有两种:① 直接用循环,
② 用数据结构的栈模拟递归过程。
* 2.3 快速排序—非递归
我们用之前数据结构学习的栈(先进后出)模拟内存中递归开辟的栈帧:
🔢 首先初始化一个栈,将序列的头尾进行压栈
🔢 当栈里没有数据时,循环停下;进入循环,抽出栈中的一个序列(用left,right指向范围里的一个区间),对其进行一次排序
🔢 排完一个数之后再模拟之前的递归快排,将左右区间压入栈中,(※注意:要想先排左区间,就得后压左区间),再次循环,将一个区间出栈,再次进行一次排序,直到区间里只有一个数为止
(具体栈的应用在代码块的注释之中),我们再次用画图的方式解析这个过程(部分)⬇️
void QuickSortNonR(int* a, int n)
{
ST st; //创建栈
StackInit(&st); //初始化
StackPush(&st, n - 1);
StackPush(&st, 0);
while (!StackEmpty(&st))
{
int left = StackTop(&st); //后压的是左边,所以先取出
StackPop(&st);
int right = StackTop(&st);
StackPop(&st);
int keyIndex = PartSort1(a, left, right); //这里选择之前的三种方法中的其中一种
if (keyIndex + 1 < right)
{
StackPush(&st, right);
StackPush(&st, keyIndex + 1);
}
if (keyIndex - 1 > left)
{
StackPush(&st, keyIndex-1);
StackPush(&st, left);
}
}
StackDestory(&st); //销毁,防止内存泄漏
}
创建的栈是malloc出的,在堆上申请的空间,而函数调用是在栈上调用的空间,而堆的空间是远大于栈的,所以完全不用考虑堆的空间不够用的情况
* 2.4 归并排序—非递归
• 如图,直接设定一个间隔(类似与希尔排序),先一个一个排序,再两个两个的排序,对间隔(gap)每次乘2;
• 所以对于gap的控制就非常的复杂了,我们直接用图来解析这个gap变化的过程⬇️
• 其中的排序部分跟之前的递归版归并排序是一样的
void MergeSortNonR(int* a, int n)
{
int* tmp = (int*)malloc(sizeof(int) * n);
int gap = 1;
while (gap < n)
{
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;
int index = i;
if (begin2 >= n)
break; //右半区间不存在的情况
if (end2 >= n)
end2 = n - 1; //右区间的右边可能越界
while (begin1 <= end1 && begin2 <= end2)
{
if (a[begin1] < a[begin2])
tmp[index++] = a[begin1++];
else
tmp[index++] = a[begin2++];
}
while (begin1 <= end1)
{
tmp[index++] = a[begin1++];
}
while (begin2 <= end2)
{
tmp[index++] = a[begin2++];
}
for (int j = i; j <= end2; j++)
a[j] = tmp[j];
}
gap *= 2;
}
free(tmp);
}
三、总结
排序的内容可以算是非常多了,要想单纯的将所有排序的代码记住是不可靠的,理解和运用才是吃透排序的方法
如果以上文章中画图分析有误或者对其不解,可以评论或私信讨论💐💐💐