【数据结构与算法】归并排序(详解:递归与非递归的归并排序 | 赠:冒泡排序和选择排序)

前言

本篇博客会对排序做一个收尾,将最经典的七大排序介绍完毕。

这次的重点正如标题,主要讲的是归并排序,还会带过相对简单很多的冒泡排序和选择排序。在最后还会给这七大排序做出一个时间复杂度和稳定性展示的总结收尾。同时,这也是初阶数据结构的最后一篇。待到再次与数据结构见面时,就会用C++来讲解,因为进阶数据结构相对复杂,用C++会相对轻松一些。话不多说,开始我们今天的内容。

归并排序

归并的思想逻辑

思想:归并排序(MERGE-SORT)是建立在归并操作上的一种有效的排序算法,该算法是采用分治法(Divide andConquer)的一个非常典型的应用。将已有序的子序列合并,得到完全有序的序列;即先使每个子序列有序,再使子序列段间有序。若将两个有序表合并成一个有序表,称为二路归并。

归并排序核心步骤:

主要可以分为三个步骤: 

  1. 分解
    • 归并排序开始于将待排序的数组不断地“一分为二”,直到每个子数组只包含一个元素。这个过程是递归进行的,即每个子数组也会继续被分解成更小的子数组,直到每个子数组只包含一个元素。
  2. 递归排序与合并
    • 在分解过程完成后,递归地开始合并这些子数组。合并时,会取出两个相邻的子数组,并将它们合并成一个有序的新数组。
    • 合并过程中,会比较两个子数组中的元素,并按照大小顺序依次放入新数组中,直到两个子数组中的所有元素都被考虑完毕。
    • 这个合并过程是递归进行的,每次合并两个子数组,生成的新有序数组又会被视为新的子数组,继续参与后续的合并过程。
  3. 结束条件
    • 当所有子数组都合并完毕,最终得到的数组就是完全有序的。

这个过程可以想象成不断地拆分和组合:首先把一个大问题(排序整个数组)拆分成许多小问题(排序单个元素的子数组),然后解决这些小问题(它们实际上已经是有序的),最后把这些小问题的解(有序的子数组)合并起来,形成最终的大问题的解(完全有序的数组)。这也就是所谓的分治法。

为了更好的理解,这里有一份动图:

在上面的动图中,不知道大家有没有发现一点,归并排序算法并没有直接在原数组上执行,而是借助了一个malloc出来的临时数组。归并排序的核心在于将两个已排序的子数组合并成一个有序的数组。合并过程中,我们需要同时比较两个子数组的元素,并将它们按照大小顺序放入一个新的序列中。这个新的序列通常就是一个临时数组,它用于存储合并后的结果,并且在合并完成后会覆盖或者替换原来的子数组。

如果不使用临时数组,我们就没有一个独立的空间来存储合并后的结果。尝试直接在原数组上进行合并操作会导致数据覆盖和丢失的问题。具体来说,当我们从两个子数组中取出元素并放入原数组时,会破坏那些尚未参与合并的元素的顺序,使得整个排序过程变得混乱。

归并排序的递归性质也要求我们在每个递归层级上都有一个独立的临时空间来存储合并后的结果。如果没有这个临时空间,递归过程将无法进行。基于归并排序的原理和特性,我们必须使用临时数组来实现合并操作。当然,有些优化或变种的排序算法可能会尝试减少临时空间的使用,但它们通常会在算法的其他方面做出妥协,比如增加算法的复杂度或降低排序的稳定性等。

不使用临时数组是无法实现标准的归并排序算法的。临时数组在归并排序中起到了至关重要的作用,它保证了合并操作的正确性和高效性。

归并排序合并代码

这合并代码是归并排序中最底层最重要的逻辑,以图中两段合并为例:

合并过程中,比较两个子数组中的元素,并按照大小顺序依次放入新数组中,直到两个子数组中的所有元素都被考虑完毕。下面为此过程实现代码:

int begin1 = begin, end1 = mid;
int begin2 = mid + 1, end2 = end, tmpn = begin;
while (begin1 <= end1 && begin2 <= end2) {if (a[begin1] <= a[begin2]) tmp[tmpn++] = a[begin1++];else tmp[tmpn++] = a[begin2++];
}
while (begin1 <= end1)tmp[tmpn++] = a[begin1++];
while (begin2 <= end2)tmp[tmpn++] = a[begin2++];
//这部分排完之后,将排好的数从临时tmp数组移回原数组
memmove(a + begin, tmp + begin, sizeof(int) * (end - begin + 1));

归并排序递归实现

快速排序中,是先由大组开始排序,再慢慢细分排至无法再细分的小组,类似于二叉树的前序遍历;而归并恰恰相反,在归并排序中,在逻辑上是从末尾开始,将无法细分的小组排成有序,在由有序的小组集合一步步排列成一个有序的大组,最终完成排序,类似于二叉树的后序遍历。有了以上的思考,就会发现归并排序可以用后续的递归来完成这一过程。由于归并的排序思路需要我们创建一个临时数组,我们可以先写一个归并排序框架

void MergeSort(int* a, int n)
{int* tmp = (int*)malloc(sizeof(int) * n);if (tmp == NULL) {perror("malloc fail:");exit(1);}//归并排序实现_MergeSort(a, 0, n - 1, tmp);free(tmp);tmp = NULL;
}

上面代码的_MergeSort()是正真完成归并排序的函数,接下来我们可以看看其中的实现:

void _MergeSort(int* a, int begin, int end, int* tmp)
{//当无法再细分时判断退出if (begin >= end)return;int mid = (begin + end) / 2;//递归过程_MergeSort(a, begin, mid, tmp);_MergeSort(a, mid + 1, end, tmp);int begin1 = begin, end1 = mid;int begin2 = mid + 1, end2 = end, tmpn = begin;while (begin1 <= end1 && begin2 <= end2) {if (a[begin1] <= a[begin2]) tmp[tmpn++] = a[begin1++];else tmp[tmpn++] = a[begin2++];}while (begin1 <= end1)tmp[tmpn++] = a[begin1++];while (begin2 <= end2)tmp[tmpn++] = a[begin2++];memmove(a + begin, tmp + begin, sizeof(int) * (end - begin + 1));
}

以上就是归并排序递归实现的所有代码。

归并排序非递归实现

见到非递归,你可能会想像实现快速排序非递归那样用栈或者队列实现归并排序的非递归,但可能你需要仔细思考一下,用栈或者队列似乎不好解决二叉树的后序遍历,也就是归并排序这一过程。

  • 首先看队列,队列是先入先出(FIFO)的数据结构,这意味着最早进入队列的元素会最先被取出。在后序遍历中,我们需要在访问根节点之前先访问其左右子树。但是,如果我们使用队列,根节点会先于其子节点被访问,这与后序遍历的顺序不符。因此实现后序先pass掉队列。
  • 再来看栈,栈的结构虽然是后进先出(LIFO)的数据结构,但如果想要找到左右结点,就需要找到其父节点。假使你能通过栈找到,但父节点层可能会有很多同类父节点,想要控制子节点都在父节点之前遍历,是个极其困难的过程,无法单纯通过简单的栈来实现。

所以,我们这里给出的方案是:不使用栈或队列的非递归归并排序实现,可以通过一种自底向上的方式来完成。这种方法通常被称为“迭代归并排序”或“自底向上归并排序”。简单说,就是通过控制循环来实现这一过程。

  1. 初始化
    • 确定待排序数组的长度n
    • 设定一个子数组的大小gap,初始值为1,这表示每个子数组最初只包含一个元素,因此它们自然是有序的。
  2. 合并子数组
    • gap小于n时,重复以下步骤:
      a. 遍历数组,每次以gap为步长,选择两个相邻的子数组进行合并。
      b. 对于每一对相邻的子数组,使用标准的归并过程将它们合并成一个有序数组。
      c. 如果只剩下一个子数组,或者两个子数组的大小不同(因为可能到达数组的末尾),如果end1>=n或者begin2>=n,跳出循环;如果只是end2>=n则改变end2的值为n - 1。
  3. 递增子数组大小
    • gap翻倍,准备在更大的子数组上进行合并操作。
  4. 重复
    • 重复步骤2和3,直到整个数组排序完成。

归并排序非递归代码实现:

void MergeSortNonR(int* a, int n)
{int* tmp = (int*)malloc(sizeof(int) * n);if (tmp == NULL) {perror("malloc fail:");exit(1);}int gap = 1;while (gap < n) {for (int i = 0; i < n; i += gap * 2){int begin1 = i, end1 = begin1 + gap - 1, tmpn = begin1;int begin2 = end1 + 1, end2 = begin2 + gap - 1;//越界处理if (end1 >= n || begin2 >= n)break;if (end2 >= n) end2 = n - 1;//-------while (begin1 <= end1 && begin2 <= end2) {if (a[begin1] <= a[begin2])tmp[tmpn++] = a[begin1++];else tmp[tmpn++] = a[begin2++];}while (begin1 <= end1)tmp[tmpn++] = a[begin1++];while (begin2 <= end2)tmp[tmpn++] = a[begin2++];//这里需要根据gap和end1的变化控制memmove的空间大小memmove(a + i, tmp + i, sizeof(int) * (gap + end2 - end1));}gap *= 2;}free(tmp);tmp = NULL;
}

这个算法的关键在于,它从一个已排序的子数组开始(即单个元素),并通过不断合并相邻的子数组来逐步构建更大的有序数组,直到整个数组排序完成。

这种非递归方法避免了递归调用栈的开销,并且对于大数据集来说可能更加高效。然而,它需要额外的空间来执行归并操作,这与递归版本的归并排序是一样的。

除了需要通过gap控制每一组的间隔,越界处理以及tmp临时数组与原数组之间的空间拷贝大小,其他大部分的内容和递归的实现没什么大的不同。

归并排序特性总结

  1. 归并的缺点在于需要O(N)的空间复杂度,归并排序的思考更多的是解决在磁盘中的外排序问题。
  2. 时间复杂度:O(N*logN)
  3. 空间复杂度:O(N)
  4.  稳定性:稳定

以上就是归并排序的所有内容了,接下来两个排序的难度大大降低。

冒泡排序

冒泡排序的思想逻辑

冒泡排序(Bubble Sort)是一种简单的排序算法,其基本思想是通过相邻元素之间的比较和交换,使得每一轮循环后最大(或最小)的元素能够“浮”到数组的一端。这个过程就像气泡一样,逐步将较大的元素“浮”到数组的末尾,因此得名“冒泡排序”。

冒泡排序的逻辑:

  1. 比较相邻的元素:从数组的第一个元素开始,比较相邻的两个元素。如果前一个元素大于后一个元素,则交换它们的位置。
  2. 逐步推进:每一对相邻元素进行比较和可能的交换后,最大的元素就会像气泡一样“浮”到当前序列的末尾。这个过程在每一轮循环中都会发生。
  3. 多轮比较:由于每轮循环只能确保一个最大(或最小)的元素移到正确的位置,因此需要对整个数组进行多轮比较,直到整个数组排序完成。
  4. 优化:为了提高效率,可以在每一轮循环后检查是否有过交换操作。如果在某一轮循环中没有进行过任何交换,说明数组已经是有序的,此时可以提前终止排序过程。

这里有一份动图帮助理解:

冒泡排序的步骤:

  1. 从数组的第一个元素开始,比较相邻的两个元素。
  2. 如果前一个元素大于后一个元素,则交换它们的位置。
  3. 继续比较下一对相邻元素,直到到达数组的末尾。
  4. 完成一轮比较后,最大的元素将被放置在数组的末尾。
  5. 重复上述步骤,但忽略已经排序好的末尾部分,直到整个数组排序完成。

冒泡排序代码实现

作为最简单好理解的排序方式之一,这里直接附上代码。

void Swap(int* x, int* y)
{int tmp = *x;*x = *y;*y = tmp;
}
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;}
}

冒泡排序特性总结

  1. 冒泡排序是一种非常容易理解的排序
  2. 时间复杂度:O(N^2) 
  3. 空间复杂度:O(1)
  4. 稳定性:稳定

选择排序(直接选择排序)

选择排序思想逻辑

直接选择排序(Straight Select Sorting),也叫简单选择排序,是一种简单直观的排序算法。其工作原理是,每一次从待排序的数据元素中选出最小(或最大)的一个元素,存放在序列的起始位置,直到全部待排序的数据元素排完。

对于直接选择排序每次只选一个最小值,我们给出一种优化方案,每次遍历选两个数,一个最大值和一个最小值,可以看下其运行逻辑:

  1. 初始化
    • 设定两个变量来记录当前遍历过程中的最小值和最大值,以及它们对应的索引位置。
  2. 查找最大和最小值
    • 遍历整个序列,比较每个元素,更新最小值和最大值的记录。
    • 记录最小值和最大值对应的索引位置。
  3. 交换位置
    • 将起始预留位置和最小下标索引处交换。
    • 将末尾预留位置与最大下标索引处交换。
    • 如果最大值索引正好在刚才最小值索引的位置,则需要在交换最小值之后改变最大值索引的位置,再进行交换。
  4. 缩减范围并重复
    • 排除已经放置好的最大值和最小值,缩减查找范围,然后重复步骤2和3,直到整个序列有序。

注:

  • 每一轮排序后,最大值和最小值分别被放置在了序列的两端,因此下一轮的查找范围可以缩小。
  • 如果最大值和最小值相邻,并且最小值位于序列的开始位置,交换最大值到末尾后,需要改变最小值的索引,因为它已经被移动到了原本最大值的位置。

选择排序代码实现

也是作为最简单好理解的排序方式之一,这里直接附上代码。

void SelectSort(int* a, int n)
{int left = 0, right = n - 1;while (left < right) {int mini = left, maxi = right;for (int i = left; i <= right; i++) {if (a[i] < a[mini])mini = i;if (a[i] > a[maxi])maxi = i;}Swap(&a[mini], &a[left]);if (left == maxi) maxi = mini;Swap(&a[maxi], &a[right]);left++;right--;}
}

选择排序特性总结

  1. 直接选择排序思考非常好理解,但是效率不是很好。实际中很少使用
  2. 时间复杂度:O(N^2)
  3. 空间复杂度:O(1)
  4. 稳定性:不稳定

关于直接选择不稳定的原因,我们拿一趟排一个数的思路来举例:

如果我们有一个序列[5, 8, 5, 2, 9],在第一轮排序后,第一个5会和2交换位置,导致原本在后面的5排到了前面,从而破坏了稳定性。

尽管直接选择排序在算法逻辑上相对简单,但由于其时间复杂度较高,在处理大规模数据时可能不是最优选择。在实际应用中,更高效的排序算法如归并排序、快速排序等通常会被优先考虑。

七大排序算法复杂度及稳定性

 什么?你说我还没讲堆排序?看看这篇博客:初阶数据结构之---堆的应用(堆排序和topk问题)-CSDN博客

你问我什么是稳定性?好吧,这里再回顾一下。

稳定性:假定在待排序的记录序列中,存在多个具有相同的关键字的记录,若经过排序,这些记录的相对次序保持不变,即在原序列中,r[i]=r[j],且r[i]在r[j]之前,而在排序后的序列中,r[i]仍在r[j]之前,则称这种排序算法是稳定的;否则称为不稳定的。

简单来说就是:在一定的规则下,两个值相等的元素,在排序算法处理前后的相对位置是否发生变化,如果相对位置变化,称这种排序算法是稳定的,否则为不稳定的。

结语

本篇博客对归并排序,冒泡排序和直接插入排序做了深入分析和讲解,最后展示了七大经典排序的算法复杂度和稳定性。掌握排序是一个程序员的基本素养,对开拓思维也有很大的帮助。数据结构初阶相关的所有内容到这里就结束了,在进入进阶数据结构之前,我会写一些关于C++的基础语法的内容做一个过度,希望能帮助到大家。

感谢支持,博主后续会产出更多有意思的内容!♥

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mzph.cn/news/791658.shtml

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

(4)(4.6) Triducer

文章目录 前言 1 安装triducer 2 故障排除 3 参数说明 前言 Triducer 集速度、温度和深度传感器于一体。埃文在这篇 ardupilot.org 博文底部提供了这些说明(Evan at the bottom of this ardupilot.org blog post)。 1 安装triducer 下面的示例提供了在 Pixhawk 上安装 tri…

C语言中的数组与函数指针:深入解析与应用

文章目录 一、引言二、数组的定义1、数组的定义与初始化2、char*与char[]的区别1. 存储与表示2. 修改内容3. 作为函数参数 三、字符串指针数组1. 定义与概念2. 使用示例3. 内存管理 四、从字符串指针数组到函数指针的过渡1、字符串指针数组的应用场景2、函数指针的基本概念3、如…

【管理咨询宝藏48】AA银行信息科技提升分析报告

本报告首发于公号“管理咨询宝藏”&#xff0c;如需阅读完整版报告内容&#xff0c;请查阅公号“管理咨询宝藏”。 【管理咨询宝藏48】AA银行信息科技提升分析报告 【格式】PPT版本&#xff0c;可编辑 【关键词】战略规划、商业分析、管理咨询 【强烈推荐】这是一套市面上非常…

【代码篇】事件监听函数的内存泄漏,都给我退散吧!

前言 内存泄漏是个很严肃的问题&#xff0c;可是迄今也没有一个非常有效的排查方案&#xff0c;本方案就是针对性的单点突破。 工作中&#xff0c;我们会对window, DOM节点&#xff0c;WebSoket, 或者单纯的事件中心等注册事件监听函数, 添加了&#xff0c;没有移除&#xff…

开源软件技术社区方案

开源软件技术社区是一个由开发者、贡献者、用户和维护者组成的共享平台&#xff0c;主要目的是打造技术、软件产品良性互动、开源技术安全可控的软件生态环境&#xff0c;实现可复用应用或服务的快速部署与使用、完成资源与能力的高度共享、促进社区成员的共建共赢&#xff0c;…

c语言--枚举类型(声明、使用、优点)

目录 一、枚举类型的声明二、 枚举类型的优点三、 枚举类型的使用 一、枚举类型的声明 枚举顾名思义就是一一列举。 把可能的取值⼀⼀列举。 比如我们现实生活中&#xff1a; ⼀周的星期⼀到星期日是有限的7天&#xff0c;可以⼀⼀列举 性别有&#xff1a;男、女、保密&#x…

渐进式图片解决前端在页面中使用大图,图片体积过大导致页面出现白屏现象

1、演示 可以看到&#xff0c;图片还在拼命加载的时候&#xff0c; 页面上就已经有内容了 2、什么渐进式图片 图片一开始是模糊的&#xff0c;然后逐渐的开始变的清晰。如果页面上有一些大图&#xff0c;如果直接扔给浏览器的话那么图片的传输时间就会比较长&#xff0c;用户就…

软考高级架构师:校验码概念和例题

作者&#xff1a;明明如月学长&#xff0c; CSDN 博客专家&#xff0c;大厂高级 Java 工程师&#xff0c;《性能优化方法论》作者、《解锁大厂思维&#xff1a;剖析《阿里巴巴Java开发手册》》、《再学经典&#xff1a;《Effective Java》独家解析》专栏作者。 热门文章推荐&am…

【STM32】ST-LINK 下载时遇到的问题

如果出现“ST-Link USB communication error”ST-Link USB通信错误&#xff0c;则需要启动STM32 ST-LINK Utility&#xff0c;点击【ST-LINK】->【Firmaware】更新固件&#xff0c;然后打开Kei&#xff0c;点击魔术棒->->Debug->Settings&#xff0c;开到出现类似“…

电磁兼容故障整改-静电放电抗扰度不合格

电磁兼容故障整改-静电放电抗扰度不合格 ESD干扰机理不合格的主要原因 静电放电对设备的影响有下面几种安装滤波器也对静电放电有所帮助通过良好的搭接与接地防止ESD ESD干扰机理 ESD干扰电流其实是一种共模电流&#xff0c;因为ESD电压总是以参考接地板为基准的。如空击穿放电…

从redux的基本概念渐进式理解redux/toolkit的用法

概念 Redux toolkit是帮助提高redux开发效率的一个库 React-redux 是将React和Redux toolkit绑定在一起的一个库 action 是一个对象,里面有一个type属性 action creator是一个函数,这个函数可以返回上面的action对象。 reducer 是一个函数,接受两个参数(initilastate, acti…

微软邮箱被锁住,该如何解除限制?

ChatGPT账号是微软邮箱注册的&#xff0c;我们在登陆微软邮箱时&#xff0c;http://www.outlook.com&#xff0c;不需要开魔法工具&#xff0c;直接就可以登陆&#xff0c;否则会出现安全验证&#xff0c;限制登陆。 那么如果账号被锁&#xff0c;我们该如何解除限制呢&#x…

mongoDB 优化(2)索引

执行计划 语法&#xff1a; db.collection_xxx_t.find({"param":"xxxxxxx"}).explain(executionStats) 感觉这篇文章写得很好&#xff0c;可以参考 MongoDB——索引&#xff08;单索引&#xff0c;复合索引&#xff0c;索引创建、使用&#xff09;_mongo …

asf是什么格式的文件?用手机怎么打开?

由于手机操作系统和硬件的限制&#xff0c;大部分手机并不直接支持asf文件的播放。因此&#xff0c;如果你想在手机上打开asf文件&#xff0c;你可能需要先将文件转换为手机支持的格式&#xff0c;如MP4。可以通过使用一些视频转换软件来实现&#xff0c;比如野葱视频转换器。 …

【VSCode】修改插件地址

不想放在原始C盘下面C:\Users\{用户}\.vscode\extensions为了后续存储空间考虑&#xff0c;想通过添加环境变量创建名为VSCODE_EXTENSIONS的环境变量&#xff0c;内容指向vs Code扩展所在目录即可 直接配置环境变量&#xff0c;不要在有空格的文件夹下面 变量名称&#xff1a;…

TCP的十个重要的机制

注&#xff1a;TCP不是只有十个机制 TCP 可靠传输是tcp最为重要的核心&#xff08;初心&#xff09; 可靠传输&#xff0c;并不是发送方把数据能够100%的传输给接收方 而是退而求其次 让发送方发送出去数据之后&#xff0c;能够知道接收方是否收到数据。 一但发现对方没有…

CVE-2021-30517:Type confusion bug in LoadSuperIC

前言 这个漏洞是一个比较老的洞&#xff0c;之所以分析这个漏洞&#xff0c;只要是想再学习一下 ICs 相关的知识。并该漏洞的利用是利用与 String/Function 之间的混淆&#xff0c;比较有意思。 环境搭建 sudo apt install python git checkout 7d5e5f6c62c3f38acee12dc4114…

vite.config.js

Vue3vite vite和webpack区别&#xff1f; 1.vite服务器启动速度比webpack快&#xff0c;由于vite启动的时候不需要打包&#xff0c;也就无需分析模块依赖、编译&#xff0c;所以启动速度非常快。当浏览器请求需要的模块时&#xff0c;再对模块进行编译&#xff0c;这种按需动态…

AI智能涂抹修补解决方案助力企业高效创作

传统的手动涂抹修补方式不仅效率低下&#xff0c;而且往往难以达到理想的视觉效果。美摄科技凭借深厚的AI技术研发实力&#xff0c;推出了面向企业的AI智能涂抹修补解决方案&#xff0c;为企业带来前所未有的创作体验。 美摄科技的AI智能涂抹修补解决方案&#xff0c;具备强大…

年少不知EFCore好,错把SqlSugar当成宝

背景&#xff1a;依然记得我的第一份WebApi项目使用得是SqlSugar&#xff0c;当时还没有系统学习b/s这边的知识&#xff0c;跟着别人做项目用SqlSugar觉得非常方便&#xff0c;减少了自己手写ADO.Net的痛苦。但是今天发现这个EFCore也是巨好用啊&#xff0c;下面写一下他的简单…