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

news2024/11/17 11:00:05

前言

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

这次的重点正如标题,主要讲的是归并排序,还会带过相对简单很多的冒泡排序和选择排序。在最后还会给这七大排序做出一个时间复杂度和稳定性展示的总结收尾。同时,这也是初阶数据结构的最后一篇。待到再次与数据结构见面时,就会用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.coloradmin.cn/o/1567893.html

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

相关文章

基于FPGA的HDMI视频接口设计

HDMI介绍 HDMI(High-DefinitionMultimedia Interface)又被称为高清晰度多媒体接口,是首个支持在单线缆上传输,不经过压缩的全数字高清晰度、多声道音频和智能格式与控制命令数据的数字接口。HDMI接口由Silicon Image美国晶像公司倡导,联合索尼、日立、松下、飞利浦、汤姆逊、东…

STM32F407 FSMC并口读取AD7606

先贴一下最终效果图.这个是AD7606并口读取数据一个周期后的数据结果. 原始波形用示波器看是很平滑的. AD7606不知为何就会出现干扰, 我猜测可能是数字信号干扰导致的. 因为干扰的波形很有规律. 这种现象基本上可以排除是程序问题. 应该是干扰或者数字信号干扰,或者是数字和模拟…

截稿倒计时 CCF-B COCOON’24论文延期至4月8日提交

会议之眼 快讯 第30届COCOON 2024 (International Computing and Combinatorics Conference)即国际计算与组合学会议将于 2024 年 8月23日-25日在中国上海举行&#xff01;COCOON是一个专注于计算机科学理论领域的国际性学术会议&#xff01;COCOON会议自1995年起举办&#xf…

BugKu:Simple SSTI

1.进入此题 2.查看源代码 可以知道要传入一个名为flag的参数&#xff0c;又说我们经常设置一个secret_key 3.flask模版注入 /?flag{{config.SECRET_KEY}} 4.学有所思 4.1 什么是flask&#xff1f; flask是用python编写的一个轻量web开发框架 4.2 SSTI成因&#xff08;SST…

Java实现两数相除

题意 给你两个整数&#xff0c;被除数 dividend 和除数 divisor。将两数相除&#xff0c;要求不使用乘法、除法和取余运算。 整数除法应该向零截断&#xff0c;也就是截去&#xff08;truncate&#xff09;其小数部分。例如&#xff0c;8.345 将被截断为 8 &#xff0c;-2.7335…

【yolov5小技巧(1)】---可视化并统计目标检测中的TP、FP、FN

文章目录 &#x1f680;&#x1f680;&#x1f680;前言一、1️⃣相关名词解释二、2️⃣论文中案例三、3️⃣新建相关文件夹四、4️⃣detect.py推理五、5️⃣开始可视化六、6️⃣可视化结果分析 &#x1f440;&#x1f389;&#x1f4dc;系列文章目录 嘻嘻 暂时还没有~~~~ &a…

OpenCV4.9开发之Window开发环境搭建

1.打开OpenCV所在github地址 2.点击opencv仓库,进入仓库详情,点击右下方的OpenCV 4.9.0进入下载页面 3.点击opencv-4.9.0-windows.exe下载 开始下载中... 下载完成 下载完成后,双击运行解压,默认解压路径,修改为c:/

UE4_材质节点

UE4_材质节点 2017-12-07 13:56 跑九宫格 跑UV 评论(0)

spring总结-基于XML管理bean超详细

spring ioc总结-基于XML管理bean 前言实验一 [重要]创建bean1、目标和思路①目标②思路 2、创建Maven Module3、创建组件类4、创建spring配置文件7、无参构造器8、用IOC容器创建对象和自己建区别 实验二 [重要]获取bean1、方式一&#xff1a;根据id获取2、方式二&#xff1a;根…

LabVIEW专栏五、网口

该节目标编写一个网口调试VI。 上一章是串口&#xff0c;这章介绍网口的写法。 一、网口硬件 1.1、上位机网口 1.2、网口线 由线缆和水晶头组成&#xff0c;现在一般用5类和超5类的网线 1.3、接线方式 忽略&#xff0c;这里加上这点为了提醒一个硬件和上位机连接&#xf…

保健品wordpress外贸模板

保健品wordpress外贸模板 健康保养保健品wordpress外贸模板&#xff0c;做大健康行业的企业官方网站模板。 https://www.jianzhanpress.com/?p3514

Vue.js---------Vue基础

能够说出Vue的概念和作用能够使用vue/cli脚手架工程化开发能够熟练Vue指令 一.vue基本概念 1.学习vue Vuex 是一个专为 Vue.js 应用程序开发的状态管理模式。它采用集中式存储管理应用的所有组件的状态&#xff0c;并以相应的规则保证状态以一种可预测的方式发生变化。 渐进…

高分卫星助力台湾省花莲县地震应急救援

4月3日7时58分&#xff0c;在台湾省花莲县海域&#xff08;北纬23.81度&#xff0c;东经121.74度&#xff09;发生7.3级地震&#xff0c;震源深度12公里。接中国地震局地震预测研究所应急需求&#xff0c;国家航天局对地观测与数据中心&#xff08;以下简称“中心”&#xff09…

[技巧] 逆序对问题 的 分治解法

分治核心思想&#xff1a; 分解&#xff08;Divide&#xff09;&#xff1a;将原问题分解成一系列子问题。这些子问题应该是原问题的较小版本。解决&#xff08;Conquer&#xff09;&#xff1a;递归地解决这些子问题。如果子问题的规模足够小&#xff0c;则直接求解。合并&am…

java自动化-03-04java基础之数据类型举例

1、需要特殊注意的数据类型举例 1&#xff09;定义float类型&#xff0c;赋值时需要再小数后面带f float num11.2f; System.out.println(num1);2&#xff09;定义double类型&#xff0c;赋值时直接输入小数就可以 3&#xff09;另外需要注意&#xff0c;float类型的精度问题…

QT 实现无边框可伸缩变换有阴影的QDialog弹窗

实现无标题栏窗口的拖拽移动、调节窗口大小以及边框阴影效果。初始化时进行位或操作&#xff0c;将这些标志合并为一个值&#xff0c;并将其设置为窗口的标志。这些标志分别表示这是一个对话框、无边框窗口、有标题栏、有最小化按钮和最大化按钮。 setWindowFlags(Qt::Dialog |…

数据结构——图的概念,图的存储结构,图的遍历(dfs,bfs)

目录 1.图的定义和术语 2.案例引入 1.六度空间理论 3.图的类型定义 4.图的存储结构 1.邻接矩阵 1.无向图的邻接矩阵表示法 2.有向图的邻接矩阵表示法 3.网&#xff08;有权图&#xff09;的邻接矩阵表示法 代码示例&#xff1a; 2.采用邻接矩阵表示法创建无向图…

【Qt 学习笔记】认识QtSDK中的重要工具

博客主页&#xff1a;Duck Bro 博客主页系列专栏&#xff1a;Qt 专栏关注博主&#xff0c;后期持续更新系列文章如果有错误感谢请大家批评指出&#xff0c;及时修改感谢大家点赞&#x1f44d;收藏⭐评论✍ 认识QtSDK中的重要工具 文章编号&#xff1a;Qt 学习笔记 / 03 文章目…

单例模式以及线程安全问题

单例模式的概念 单例模式是指的是整个系统生命周期内&#xff0c;保证一个类只能产生一个实例对象 保证类的唯一性 。 通过一些编码上的技巧&#xff0c;使编译器可以自动发现咱们的代码中是否有多个实例&#xff0c;并且在尝试创建多个实例的时候&#xff0c;直接编译出错。 …

哈佛大学商业评论 -- 第二篇:增强现实是如何工作的?

AR将全面融入公司发展战略&#xff01; AR将成为人类和机器之间的新接口&#xff01; AR将成为人类的关键技术之一&#xff01; 请将此文转发给您的老板&#xff01; --- 本文作者&#xff1a;Michael E.Porter和James E.Heppelmann 虽然物理世界是三维的&#xff0c;但大…