归并排序算法

目录

何为归并排序

排序步骤

合并过程

全过程

归并排序实现代码(C语言描述)

复杂度分析

归并排序的优缺点


何为归并排序

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

排序步骤

        归并排序在结构上是递归的,按照分治模式在每层递归时都有3个步骤:


分解:分解待排序的n个元素的序列成各具n/2个元素的两个子序列。

解决:使用归并排序递归地排序两个子序列。

合并:合并两个已排序的子序列以产生已排序的答案。


当待排序的序列长度为1时,递归“开始回升”,即开始合并。

合并过程

        归并排序的关键操作为“合并”,下面用一个 MERGE (A, p, q, r) 的伪代码来表示合并的过程。其中A是一个数组,p、q、r 为数组下标,满足 p ≤ q < r 。假设子数组 A [p..q] 和 A [q+1..r] 都已排好序。

MERGE (A, p, q, r) 的伪代码如下:

MERGE(A, p, q, r)
    n1 = q - p + 1
    n2 = r - q
    let L[1..n1 + 1] and R[1..n2 + 1] be new arrays
    for i = 1 to n1
        L[i] = A[p + i - 1]
    for j = 1 to n2
        R[j] = A[q + j]
    L[n1 + 1] = ∞
    R[n2 + 1] = ∞
    i = 1
    j = 1
    for k = p to r
        if L[i] ≤ R[j]
            A[k] = L[i]
            i = i + 1
        else A[k] = R[j]
            j = j + 1

3281b9af4ae943d1a5e2c9da5ee6e3e7.png

        在 MERGE 过程中,先计算了两个子数组的长度,然后在两个子数组的最后额外加了一个哨兵标记,即 ∞ 。接着对两个子数组中最小的值进行比较,将较小的一个元素放入原数组中。循环这个过程,直到检查到两个子数组中任意一个子数组的哨兵标记。当检查到其中一个子数组的哨兵标记,判断该子数组完成了全部元素的比较。另一个子数组中剩下的元素都比哨兵标记 ∞ 小,于是将另一个子数组中剩下的全部元素按顺序放入到原数组中,合并过程结束,并且原数组已排好序。

全过程

        将 MERGE 过程作为全过程的子程序来使用,下面用 MERGE-SORT (A, p, r) 来表示归并排序的全过程。A 为子数组 A[p..r],p、r 为子数组下标。若 p ≥ r ,则该子数组最多有一个元素,所以已经排好序。否则,分解步骤将计算一个下标q,并将子数组 A [p..r] 分解为两个子数组 A [p..q] 和 A [q+1..r] 。这两个子数组包含 n/2 个元素。

MERGE-SORT (A, p, r) 的伪代码如下:

MERGE-SORT(A, p, r)
    if p < r
        q = (p + r)/2
        MERGE-SORT(A, p, q)
        MERGE-SORT(A, q+1, r)
        MERGE(A, p, q, r)

​

        全过程分析:在 MERGE-SORT 过程中,先将长度为n的原数组分解为两个子数组,如果两个子数组中的元素多于一个,则递归调用 MERGE-SORT 过程将两个子数组分解为四个子数组,以此类推,直到全部的子数组中只含有一个元素。即最小子数组。接着调用 MERGE 过程对所有的最小子数组进行合并。由于最小子数组中只包含一个元素,所以每个最小子数组已排好序。将每个最小子数组按照大小进行合并,合并后产生一个包含两个元素的子数组,并且已排好序。以此类推,最终将所有的子数组合并成长度为n的数组,并且已排好序。

eb1a145d7b4143e59ede025f8c71b5a1.png

归并排序实现代码(C语言描述)

实现代码如下:

#include <stdio.h>

void merge(int* array1, int* array2, int left, int mid, int right){	//定义合并函数 
	int i = left, j = mid+1, k = left;		//定义左、右子数组和暂存数组的索引 
	while(i!=mid+1 && j!=right+1){			//循环判断是否检查到哨兵标记 
		if(*(array1+i) > *(array1+j))		//判断左、右子数组中的较小值 
            *(array2+k++) = *(array1+j++);	//将较小值放入暂存数组中,子数组、暂存数组的索引加1 
        else
            *(array2+k++) = *(array1+i++);
	}
	while(i != mid+1)						//当检查到右子数组的哨兵标记 
        *(array2+k++) = *(array1+i++);		//将左子数组剩下的全部元素按顺序放入暂存数组 
    while(j != right+1)						//当检查到左子数组的哨兵标记 
        *(array2+k++) = *(array1+j++);		//将右子数组剩下的全部元素按顺序放入暂存数组 
    for(i=left; i<=right; i++)				//将暂存数组中的元素放回原数组 
        *(array1+i) = *(array2+i);			
}

void merge_sort(int* array1, int* array2, int left, int right){	//array1为指向初始数组的指针,array2为指向暂存数组的指针,left、right为初始数组起始索引和终止索引 
	if(left<right){							//判断是否对子数组进行分解 
		int mid = left + (right - left)/2;	//计算将数组分解的中间下标 
		merge_sort(array1, array2, left, mid);		//递归调用归并排序函数将左子数组A[left..mid]分解 
		merge_sort(array1, array2, mid+1, right);	//递归调用归并排序函数将右子数组A[mid+1..right]分解
		merge(array1, array2, left, mid, right);	//调用合并函数合并子数组A[left..mid]和A[mid+1..right] 
	}
}

int main(){
	int a1[8]={2, 4, 5, 7, 1, 2, 3, 6};	//定义初始数组a1
	int a2[8], i;						//定义暂存数组a2,用于保存排序过程中产生的中间变量 
	merge_sort(a1, a2, 0, 7);			//调用归并排序函数 
	for(i=0;i<8;i++)					//循环输出排序后的结果 
	printf("%d ", a1[i]);
	return 0;
}

 输出结果:

1 2 2 3 4 5 6 7

实现代码解析:

        从31行进入 merge_sort 函数,函数先判断是否对参数 array1 所指向的数组进行分解,即比较该数组起始索引 left 和终止索引 right 的大小。若起始索引 left 小于终止索引 right ,则表明该数组中的元素多于一个,并对该数组进行分解;若起始索引 left 等于终止索引 right ,则表明该数组中只有一个元素,停止对该数组的分解。变量 mid 被定义为分解该数组的中间下标,该变量将该数组二分为左子数组 A [left..mid] 和右子数组 A [mid+1..right] ,并且作为左子数组 A [left..mid] 的终止索引,加1后作为右子数组 A [mid+1..right] 的起始索引。分解完初始数组后将继续递归调用 merge_sort 函数对左、右子数组进行分解,直到所有的子数组只包含一个元素。

        仔细观察 merge_sort 函数的递归调用,即22、23行,会发现 merge_sort 函数的第一个和第二个参数一直没有改变。在 merge_sort 函数中实际操作的是数组的索引,即第三个和第四个参数,通过索引的值来控制子数组的范围。实际上并没有对初始数组做任何分解,真正分解的是初始数组的索引 left 和 right 。将初始数组的索引 [left..right] 分解为 [left..mid] 、 [mid+1..right] ,通过分解索引达到将一个数组分解为两个子数组的效果。

27ee150486ea4e14abc964dc5a177c79.png

        当 merge_sort 函数递归结束,即所有的子数组只包含一个元素时,进入 merge 函数,开始对所有的子数组进行合并。在 merge 函数中,先定义了左、右子数组和暂存数组的起始索引 left 、 mid+1 和 left 。然后开始 while 循环,并对左、右子数组进行比较。每次比较前先检查当前左、右子数组的索引是否为哨兵标记的索引。检查哨兵标记是为了判断是否完成了子数组中所有元素的比较。哨兵标记为子数组最后一个元素的后一个元素,在这里左、右子数组的哨兵标记为索引 mid+1 和 right+1 所对应的元素。每次只对索引进行检查,不在乎该索引所对应的元素(可能会想 mid+1 和 right+1 所对应的元素不在子数组中)。

        刚开始比较时,左、右子数组的索引为起始索引 left 和 mid+1 ,所对应的元素为数组中的第一个元素。暂存数组的索引为起始索引 left ,所对应的元素为数组中的第一个元素。比较之后将较小的元素放入暂存数组的索引所对应的位置,并且将该元素所在的子数组的索引和暂存数组的索引加1。重复比较操作,当检查到哨兵标记时, while 循环停止。

        检查到哨兵标记,表明已完成了某一个子数组中全部元素的比较,即该数组的全部元素都放入了暂存数组。11、13行的 while 循环为检查到哨兵标记后对另一个子数组的处理。当某一个子数组中的全部元素完成比较后,另一个子数组中未完成比较的元素必定比完成比较的元素大(因为子数组均为上一次合并排好序的数组,比较从起始索引开始,未比较的元素必定大于正在比较的元素),因此将剩下的元素按照该子数组中的顺序放入暂存数组中。最后将暂存数组中的元素放回原数组中。至此完成了两个子数组的合并,并且已排好序。

        每一层 merge_sort 函数的递归都会调用一次 merge 函数来合并这一层分解的两个子数组,通过递归回升不断地合并每一层的两个子数组,最终将所有子数组合并成原数组,并且已排好序。最后将排好序的原数组输出。

复杂度分析

          假设求解规模为1的问题需要常量时间 c 。在过程 MERGE 中,需要合并 n 个数,运行所需要的时间为 cn 。下面分析建立归并排序 n 个数的最坏情况运行时间的递归式当 n > 1 个元素时,分解运行时间如下:


分解:分解步骤仅仅计算子数组的中间位置,需要常量时间 c 。

解决:递归地求解两个规模均为 n/2 的子问题,将贡献 2T(n/2) 的运行时间。

合并:合并一个具有 n 个元素的子数组需要 cn 的时间。


当 n 大于1时,需要将解决和合并的运行时间相加,由此得到归并排序的递归式:

be2e6d065aa1400a8f837e0099e7d7fd.png

 为了更加直观地表示求解递归式,可以描绘一棵递归式的树,即递归树

45ddf112826f41bea64526420aaaa372.png

         a 部分图示了 T (n),由递归式 T(n) = 2T(n/2) + cn 可得 b 部分。 按此将递归树逐渐扩展,直到问题规模下降到1时,每个子问题需要消耗的时间代价为 c 。顶层的代价为 cn ,下一层的代价为 cn/2 + cn/2 = cn ,顶层之下的第 i 层具有 gif.latex?2%5E%7Bi%7D 个结点,每层代价为 c(n/gif.latex?2%5E%7Bi%7D) ,那么第 i 层的代价为gif.latex?2%5E%7Bi%7Dc(n/gif.latex?2%5E%7Bi%7D) = cn 。底层具有 n 个结点,每个结点的代价为 c ,该层的代价为 cn 。从 cn/2 到底层的 cn/n 需要 gif.latex?%5Clog%20n 步,因此该递归树共有 gif.latex?%5Clog%20n+1 层,总代价为 cn( gif.latex?%5Clog%20n+1) = cn  gif.latex?%5Clog%20n + cn 。忽略低阶项和常量 c 便给出了期望结果 O(n  gif.latex?%5Clog%20n) ,即归并排序的时间复杂度 O(n  gif.latex?%5Clog%20n) 。

        在归并排序的过程中,使用了大小为 n 的暂存数组来保存中间变量。在递归回升过程中,每次合并需要额外的内存空间来保存中间变量,即暂存数组。暂存数组的大小为 n ,因此归并排序的空间复杂度为 O(n) 。

归并排序的优缺点

        归并排序的速度仅次于快速排序,效率非常高,时间复杂度为 O(n gif.latex?%5Clog%20n ,是基于比较的算法的最高巅峰。归并排序为稳定排序算法(即在排序过程中大小相同的元素能够保持排序前的顺序,例如3(1)、1(2)、2(3)、2(4),括号中为排序前的序号,排序后为1(2)、2(3)、2(4)、3(1),两个2的顺序保持不变)在某些情况下这一性质将至关重要。归并排序为常用的外排序算法(处理超过内存上限的数据的算法)外排序一般采用“排序-归并”策略,即先将数据划分为可读入内存的分段,读入每段数据后进行排序,并将排序后的数据输出到临时文件。重复操作完所有分段的数据后,将所有的临时文件归并为一个有序的数据。

        归并排序需要大小为 n 的额外内存空间,空间复杂度为 O(n) ,在同效率的算法中空间复杂度略高

  • 1
    点赞
  • 14
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

#include <bug>

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值