☑️前言
🚩本章给大家带来的是八大排序中的选择排序和堆排序。
🚩选择排序为什么被称为最烂的排序?
🚩堆排序如何来操作?最开始如何对数组建堆?
1. 选择排序
1.1. 选择排序基础
基本的选择排序思路如下:
每一次从数组的待排序的数据元素中找出最小或者最大的元素放在起始位置,直到所有待排序的数据元素放在应在的位置为止。
如图 ,为选择排序操作升序的情况:
- 首先在没有排序的序列中找到最小(大)元素,并与排序序列的起始位置元素交换;
- 再继续从剩余没有排序的序列续寻找最小(大)元素,然后与没有排序的序列的起始位置元素交换;
- 重复第二步,直到所有元素均排序完毕即可。
代码实现(这里以升序为例):
void swap(int* a, int* b) { int tmp = *a; *a = *b; *b = tmp; } void SelectSort(int* a, int n) { // 确认数组的有效性 assert(a); // 这里的 n - 1 是表示选择只需要选择 n - 1 次 // 因为最后一次只剩下一个元素已经是有序的了 // 当然 n 也可以,最后一次循环不进入嘛 for (int i = 0; i < n - 1; i++) { // mini 表示:假设待排序序列的起始元素为最小元素 int mini = i; // j = i + 1,表示待排序序列起始元素位置的下一个位置 // 说明从待排序序列起始元素位置的下一个位置开始选择 for (int j = i + 1; j < n; j++) { // 找到比 a[mini] 还小的元素就将mini更新为这个元素的下标 if (a[j] < a[mini]) { mini = j; } } // 最后将 mini(此时为待排序序列中最小的元素)指向的元素与待排序序列的起始位置的元素交换 swap(&a[i], &a[mini]); // 到了这里一趟选择排序就完成了 } }
测试:
void testSelectSort() { int arr[] = { 9,8,7,6,5,4,3,2,1,0 }; int n = sizeof(arr) / sizeof(arr[0]); SelectSort(arr, n); PrintArray(arr, n); }
运行结果:
1.2. 选择排序优化
(以升序为例)
- 这个优化其实也就那样,表面看起来算是优化了,实际上也差不多。
- 在上面的基础上,多加些操作:在遍历未排序序列时,不仅找到最小值,还要找到最大值,然后最小值与未排序序列的初始位置交换,最大值与未排序序列的最后一个位置交换,当两个数都到了相应位置后,未排序序列两边同时缩减,重复前面的操作,直到序列缩没了。
图示:
由上面的分析可以知道,我们需要两个指针指向未排序序列的两端,每次未排序序列两边缩小1
,都需要遍历一遍未排序序列找最小最大值往两边甩,这样从两端开始向内逐渐有序,最终数组就会有序。
代码实现(这里以升序为例):
// 选择排序 void SelectSort(int* a, int n) { // 判断数组有效性 assert(a); // 未排序序列的头尾边界,l 为左边界,r 为右边界 int l = 0, r = n - 1; // 如果 l < r ,说明中间可能还存在未排序序列 while (l < r) { // 一开始假设最小和最大值为未排序序列的头位置 int mini = l, maxi = l; // 从 l 到 r 遍历未排序序列 依次寻找最小和最大值 for (int i = l; i <= r; i++) { if (a[i] < a[mini]) { mini = i; } if (a[i] > a[maxi]) { maxi = i; } } // 将最小和最大值往两边甩 swap(&a[l], &a[mini]); // 这个操作是因为:可能未排序序列头就是最大值,所以maxi指向头位置,而最小值需要甩到头位置来 // 当最小值放过来后,最大值(刚好指向刚放过来的最小值的位置)又要甩到尾的位置,这就会出现冲突 // 所以这里判断一下,如果maxi是指向未排序序列的头位置,就需要更新一下maxi if (maxi == l) { maxi = mini; } swap(&a[r], &a[maxi]); // 两端有序后,将未排序序列区间缩小 l++; r--; } }
1.3.复杂度的分析
对于选择排序的时间复杂度,为 O(N ^ 2),优化与不优化都是如此,每次放置一个元素或者两个元素到有效的位置都需要遍历数组,第一次可能是 n - 1 次,第二次就是 n - 2,依次类推,就是一个等差数列,所以为O(N ^ 2)。那最好的情况呢?如果此时数组已经有序,它还是要跟不有序一样依次遍历数组,老实的很捏,所以还是O(N ^ 2),这也就是选择排序是八大排序中最烂排序的原因。
而空间复杂度毫无疑问是O(1)。
2. 堆排序
2.1. 对堆的认识和数组建堆
- 对堆的认识可以看看博主之前的文章:->传送门<-。
- 总之,堆在逻辑结构上是一棵完全二叉树,在物理结构上是用数组来存储的。
对数组排序,首先就是要对这个数组建堆,如果是要将数组升序,就建为大堆,如果是要将数组降序,就建为小堆。
如何建堆?我们从最后一个结点的父节点开始,依次执行向下调整算法,直到根节点执行完全后,便建成了堆。当然我们也可以从第二个结点开始,依次执行向上调整算法,直到最后一个结点执行完后便建成了堆,不过这样的时间复杂度为O(n * logn),而前面的向下调整算法的方式的时间复杂度为O(n),所以这里我们采用向下调整算法的方式来建堆。至于这两个调整算法的时间复杂度是如何计算出来的,这里就不做讨论,它的本质其实是有关数列求和的计算。
对于向下调整算法,我们先要找到该结点(假设下标为parent)的孩子结点,而孩子结点又分为左孩子结点(下标为parent * 2 + 1)和右孩子结点(下标为parent * 2 + 2),所以我们需要找出两个孩子结点当中较大的那个,如果该节点的数据比较大的那个孩子结点的数据要小,那就进行交换,然后循环往复继续向下寻找孩子结点重整堆。
整个操作,我们可以先比较两个孩子的大小找出大的那个,然后在与大的这个孩子结点进行比较,如果父结点比他小(以大堆为例),说明这个孩子结点该上位了。然后继续向下执行这个操作。
向下调整算法方式建堆图示:
向下调整算法代码实现(这里以升序为例):
void adjustdown(int* a, int n, int parent) { // 先假设大的那个孩子结点为左孩子结点 int child = parent * 2 + 1; while (child < size) // 如果child小于此时数组的长度就继续 { // 第一个判断是防止没有右孩子结点的情况 // 第二个判断是如果右孩子存在并且右孩子结点的数据大于左孩子结点的数据,就child加一指向右孩子结点 if (child + 1 < size && a[child + 1] > a[child]) child++; // 如果父节点数据小于child结点数据,就交换重整堆 if (a[child] > a[parent]) { swap(&a[child], &a[parent]); parent = child; child = parent * 2 + 1; } else break; // 如果父节点数据大于child结点数据,说明堆已经调整完毕,直接跳出循环不在调整 } }
2.2. 对数组进行堆排序操作
- 有了建堆的认识,后面的操作也不难了,只不过需要注意几个细节。
- 当数组建成大堆形式后,堆顶元素是最大的,此时我们可以将堆顶元素与最后一个元素进行交换,这样最大的元素就到了数组的末尾了。然后我们对这个处在数组最后一个位置的最大元素视而不见,将交换过去的堆顶元素执行向下调整算法,这时,第二大的元素就到了堆顶,然后此时的堆顶元素继续与最后一个元素进行交换 (注意第一个交换过去的最大的元素已经不在范围内了,也就是说每将一个当前最大的数交换过去后,可视作n(数组的长度)减一一次) ,然后再将交换过去的堆顶元素执行向下调整算法…这样循环往复,最终该数组就变成了升序。
动图过程展示
堆排序整体代码实现:
// 堆排序 void adjustdown(int* a, int n, int parent) { // 先假设大的那个孩子结点为左孩子结点 int child = parent * 2 + 1; while (child < size) // 如果child小于此时数组的长度就继续 { // 第一个判断是防止没有右孩子结点的情况 // 第二个判断是如果右孩子存在并且右孩子结点的数据大于左孩子结点的数据,就child加一指向右孩子结点 if (child + 1 < size && a[child + 1] > a[child]) child++; // 如果父节点数据小于child结点数据,就交换重整堆 if (a[child] > a[parent]) { swap(&a[child], &a[parent]); parent = child; child = parent * 2 + 1; } else break; // 如果父节点数据大于child结点数据,说明堆已经调整完毕,直接跳出循环不在调整 } } void HeapSort(int* a, int n) { assert(a); // 向下调整, 这里是建大堆 for (int i = (n - 2) / 2; i >= 0; i--) adjustdown(a, n, i); // 排序(建的大堆就是升序) int end = n - 1; while (end > 0) { swap(&a[end], &a[0]); adjustdown(a, end, 0); end--; } }
2.3. 复杂度的分析
堆排序的时间复杂度为 O(N * logN):排序前对数组建堆的向下调整算法整个过程为O(N),后面排序阶段的操作相当于遍历了一遍数组,每一次都需要从根节点(数组开头)执行一次向下调整算法,因此排序阶段的时间复杂度为O(N * logN),所以整体就是O(N * logN)。而最好的情况也是O(N * logN),可以说,堆排序也是很老实的,尽管数组开始有序,在建堆的过程中,就先需要将数组打乱,后面的操作也就一样了。
堆排序没有创建额外的空间,所以空间复杂度为O(1)。
☑️写在最后
💝排序相对来说较为简单,不过我们还是要认真对待,理清楚排序的思路。
❤️🔥后续将会继续输出有关数据结构与算法的文章,你们的支持就是我写作的最大动力!
感谢阅读本小白的博客,错误的地方请严厉指出噢~