深入解析归并排序:稳定高效的分治典范

目录

[1 · 引子](#1 · 引子)

[1 - 1 · 为什么需要高效稳定的排序算法](#1 - 1 · 为什么需要高效稳定的排序算法)

[1 - 2 · 归并排序概览](#1 - 2 · 归并排序概览)

[2 · 核心思想:分而治之](#2 · 核心思想:分而治之)

[2 - 1 · 分治法](#2 - 1 · 分治法)

[2 - 2 · 归并排序如何应用分治法](#2 - 2 · 归并排序如何应用分治法)

[3 · 代码实现(C)](#3 · 代码实现(C))

[4 · 性能分析](#4 · 性能分析)

[4 - 1 · 时间复杂度](#4 - 1 · 时间复杂度)

[4 - 2 · 空间复杂度](#4 - 2 · 空间复杂度)

[4 - 3 · 稳定性](#4 - 3 · 稳定性)

[5 · 非递归实现](#5 · 非递归实现)

[5 - 1 · 思想](#5 - 1 · 思想)

[5 - 2 · 代码实现](#5 - 2 · 代码实现)

总结


1 · 引子

1 - 1 · 为什么需要高效稳定的排序算法

在处理大规模数据集时,排序算法的效率和稳定性至关重要。

  • 效率考量

    最明显的需求是时间复杂度。数据量增大时,时间成本指数级增长;例如,简单排序算法(如冒泡排序)在平均情况下的时间复杂度为,这对于处理百万级数据非常低效。高效算法的时间复杂度应为或更优。

  • 稳定性需求

    某些应用(如排序复合数据表)要求稳定性:即相等元素的顺序在排序后保持不变。非稳定算法(如堆排序)、可能破坏原始顺序,导致数据失真。例如,在数据库中排序客户记录时,稳定性确保同一排序键的记录维持其录入序列。

这自然引出了归并排序------它兼具高效性和稳定性,成为选择排序算法的理想候选。


1 - 2 · 归并排序概览

  • 稳定性:由于合并操作比较元素而不改变相等元素的相对位置,所以它是稳定算法。
  • 时间复杂度 :在所有情况下(最好、最坏和平均情况)均实现。具体地,递归层数为,每层合并操作的总代价为。此外,空间复杂度为 用于临时存储。
  • 效率优势 :相比于其他算法(如快速排序),归并排序更稳定且性能一致,适用于大规模数据排序。

归并排序在处理外部数据(如硬盘数据)时特别有用,因为它通过分治策略管理内存使用。理解这些要素为深入学习排序算法奠定了基础。


2 · 核心思想:分而治之

2 - 1 · 分治法

分治是一种递归式的算法设计策略,核心包含:

  1. 分(Divide)

    将原始规模为n 的问题分解 为 k 个(通常 k≥2)结构相似、规模更小的子问题 ,子问题间相互独立。

    例如:在归并排序中,将长度为 n 的数组拆分为两个长度为 n/2 的子数组。

  2. 治(Conquer)

    递归求解子问题 :若子问题规模足够小(如 n=1),则直接求解;否则继续分解。

    例如:在归并排序中,当子数组长度为 1 时,默认有序;否则继续递归拆分。

  3. 合(Combine)

    将子问题的解合并 为原问题的解。

    例如:归并排序中,将两个已排序的子数组合并为一个有序数组。


2 - 2 · 归并排序如何应用分治法

归并排序(MERGE-SORT)是建立在归并操作上的⼀种有效的排序算法,该算法是采用分治法(Divide and Conquer)的⼀个非常典型的应用。将已有序的子序列合并,得到完全有序的序列。

即先使每个子序列有序,再使子序列段间有序。若将两个有序表合并成⼀个有序表,称为二路归并。过程如下图:


3 · 代码实现(C)

归并排序必须借助一个第三方数组,如果直接在原数组上操作,会覆盖有效数据。下面实现递归版本的归并排序:

复制代码
void _MergeSort(int* a, int left, int right, int* tmp)
{
	if (left >= right)
	{
		return;
	}

	int mid = left + (right - left) / 2;
	_MergeSort(a, left, mid, tmp);
	_MergeSort(a, mid + 1, right, tmp);

	int begin1 = left;
	int end1 = mid;
	int begin2 = mid + 1;
	int end2 = right;
	int count = 0;

	while (begin1 <= end1 && begin2 <= end2)
	{
		if (a[begin1] < a[begin2])
		{
			tmp[count++] = a[begin1];
			++begin1;
		}
		else
		{
			tmp[count++] = a[begin2];
			++begin2;
		}
	}

	//将另一组未完全入进tmp的数据放入tmp中
	while (begin1 <= end1)
	{
		tmp[count++] = a[begin1];
		++begin1;
	}

	while (begin2 <= end2)
	{
		tmp[count++] = a[begin2];
		++begin2;
	}

	//拷贝回去
	for (int i = 0; i < count; i++)
	{
		a[i + left] = tmp[i];
	}
}

void MergeSort(int* a, int n)
{
	int* tmp = (int*)malloc(sizeof(int) * n);
	if (tmp == NULL)
	{
		perror("malloc");
		exit(1);
	}

	_MergeSort(a, 0, n-1, tmp);

	free(tmp);
}

由于递归需要传数组与区间,所以我们递归放在子函数中。


4 · 性能分析

4 - 1 · 时间复杂度

可以想象成一棵二叉树,分割的高度是 logN 层,每层的操作需要处理所有N个元素,因此时间复杂度为


4 - 2 · 空间复杂度

归并排序在合并两个有序数组为一个有序数组时,需要借助额外的存储空间。这是因为合并操作本身需要在其他地方存储数据,然后再将其复制回原数组。这一点很容易理解,而进行递归的深度为 logN,那么归并排序的空间复杂度到底是多少呢?是 O(N) 还是 O(NlogN)?

实际上,归并排序的空间复杂度并不是通过累加递归过程中的空间来计算的。重要的一点是,尽管每次合并操作都需要申请额外的内存空间,但在合并完成之后,这些临时开辟的内存空间就被释放掉了。在任意时刻,CPU 只会有一个函数在执行,也就只会有一个临时的内存空间在使用。临时内存空间最大也不会超过 n 个数据的大小,因此归并排序的空间复杂度是 O(n)。


4 - 3 · 稳定性

是稳定的排序算法。原因:在合并过程中,当相等时,优先复制左区间元素(或右区间元素,但保证一致的策略),就能保留相等元素的原始相对顺序。


5 · 非递归实现

5 - 1 · 思想

实现非递归,一般会想到借助一个数据结构来模拟递归的过程,但是对于归并排序,仅仅借助一个容器来存储,只可以实现分割的过程,不能实现合并的过程,那么就需要两个容器来实现非递归写法。

这时可以想想另一条路,迭代(循环),记录每一次有序表的长度,从1开始,不断更新,进行归并。


5 - 2 · 代码实现

复制代码
void MergeSortNonR(int* a, int n)
{
	int* tmp = (int*)malloc(sizeof(int) * n);
	if (tmp == NULL)
	{
		perror("malloc");
		exit(1);
	}

	//记录有序表长度
	int count = 1;

	while (count < n)
	{
		for (int i = 0; i < n; i += 2 * count)
		{
			int begin1 = i;
			int end1 = i + count - 1;
			int begin2 = i + count;
			int end2 = i + 2 * count - 1;
			int k = i;//开始放入tmp的位置

			//判断越界
			//begin2越界说明后一个有序表越界,此时不动即可
			//end1越界同理
			if (begin2 >= n)
			{
				break;
			}

			//只有end2越界说明后一个有序表部分越界,此时更新end2
			if (end2 >= n)
			{
				end2 = n - 1;
			}

			while (begin1 <= end1 && begin2 <= end2)
			{
				if (a[begin1] < a[begin2])
				{
					tmp[k++] = a[begin1];
					++begin1;
				}
				else
				{
					tmp[k++] = a[begin2];
					++begin2;
				}
			}

			//将另一组未完全入进tmp的数据放入tmp中
			while (begin1 <= end1)
			{
				tmp[k++] = a[begin1];
				++begin1;
			}

			while (begin2 <= end2)
			{
				tmp[k++] = a[begin2];
				++begin2;
			}

			//拷贝回去
			for (int j = i; j < k; j++)
			{
				a[j] = tmp[j];
			}
		}

		count *= 2;
	}
	
	free(tmp);
}

**注意:**有些越界的情况是需要分情况讨论的,begin1 永远不会越界,其余的 end1 , begin2 , end2 均可能超出n,对于end1和begin2 超出n的情况,解法相同,此时后一个有序表整体越界,无需归并,直接不动。对于end2 超出n的情况,此时后一个有序表部分越界,仍需归并,这时便需要对end2进行更新。


总结

优点: 归并排序在各种情况下的时间复杂度都保持一致,这使得归并排序在处理大规模数据时效率稳定且高效,尤其在最坏情况下也不会退化为像某些排序算法那样的级别,同时归并排序也是一种稳定的排序算法。归并排序在处理链表结构的数据时特别有效。由于其有序合并的特性,在需要处理无法全部装入内存的庞大数据集时是一种很自然的选择(例如外部排序)。

缺点也同样致命:虽然归并排序在时间上非常有效,但它相对消耗更多的空间,通常在内部排序中不会使用这种方法,而是选择快速排序。

总结: 归并排序是一种高效、稳定、时间复杂度优秀的算法,非常适合大规模数据处理,尤其是链表数据排序和外部排序。


以上内容如有错误或不准确之处,欢迎指出,或者你有更好的想法,也欢迎交流。

相关推荐
DXM05211 小时前
第14期|高阶分割模型:Transformer/SegFormer遥感应用
人工智能·python·神经网络·算法·计算机视觉·cnn·ageo
Kurisu_红莉栖2 小时前
前缀和的另外一种用法,前缀和分解
算法
88号技师2 小时前
2026年2月一区SCI-交叉传播优化算法Propagation Alongside Crossover-附Matlab免费代码
开发语言·算法·数学建模·matlab·优化算法
悠仁さん2 小时前
数据结构 图(代码实现篇 C语言版)
数据结构·算法·图论
aini_lovee2 小时前
多智能体粒子群优化(Multi-Agent Particle Swarm Optimization, MAPSO)
算法
周末也要写八哥2 小时前
贪心法求经典算法题——最低加油次数
算法
插件开发2 小时前
vs2015 cuda c++ 线程号的计算详解
开发语言·c++·算法
有点。2 小时前
C++(前缀和与差分)
c++·算法
仍然.3 小时前
算法题目---BFS解决最短路问题
算法·宽度优先