算法:归并排序实现

示意图

归并排序的核心思想还是很简单的。如果要排序一个数组,我们先把数组从中间分层前后两部分,然后对前后两部分分别排序,再将排好序的两部分合并在一起。这样整个数组就有序了
在这里插入图片描述
可以看到这种结构很像一棵完全二叉树,

归并排序使用的就是分治思想。分治,顾名思义,就是分而治之,将一个大问题分解成小的子问题来解决。小的子问题解决了,大问题也就解决了。

分治算法一般都是用递归实现的,分阶段可以理解为就是递归拆分子序列的过程。(分治是一种解决问题的处理思想,递归是一种编程技巧,这两者并不冲突。)

为此我们需要先找到递推公式,然后找到终止条件。

递 推 公 式 : m e r g e S o r t ( p . . . r ) = m e r g e ( m e r g e S o r t ( p . . . q ) , m e m g e S o r t ( q + 1... r ) ) 递推公式:mergeSort(p...r) = merge(mergeSort(p...q), memgeSort(q+1...r)) mergeSort(p...r)=merge(mergeSort(p...q),memgeSort(q+1...r))
终 止 条 件 : p > = r 终止条件:p >= r p>=r

merge_sort(p…r) 表示,给下标从 p 到 r 之间的数组排序。我们将这个排序问题转化为了两个子问题,merge_sort(p…q) 和 merge_sort(q+1…r),其中下标 q 等于 p 和 r 的中间位置,也就是 (p+r)/2。当下标从 p 到 q 和从 q+1 到 r 这两个子数组都排好序之后,我们再将两个有序的子数组合并在一起,这样下标从 p 到 r 之间的数据就也排好序了。

 // 归并排序算法, a是数组,n表示数组大小
  public static void mergeSort(int[] a, int n) {
    mergeSortInternally(a, 0, n-1);
  }

  // 递归调用函数
  private static void mergeSortInternally(int[] a, int p, int r) {
    // 递归终止条件
    if (p >= r) return;

    // 取p到r之间的中间位置q,防止(p+r)的和超过int类型最大值
    int q = p + (r - p)/2;
    // 分治递归
    mergeSortInternally(a, p, q);
    mergeSortInternally(a, q+1, r);

    // 将A[p...q]和A[q+1...r]合并为A[p...r]
    merge(a, p, q, r);
  }

从上面可以看出,merge(A[p...r], A[p...q], A[q+1...r])的作用就是将已经有序的A[p…q]和A[q+1…r]合并成一个有序的数组,并且放入A[p…r]。那这个过程具体应该如何做呢?

如图所示,我们申请一个临时数据tmp,大小与A[p…r]相同。我们用两个游标i和j,分别指向A[p…q]和A[q+1…r]的第一个元素。比较这两个元素A[i]和A[j],如何A[i]<=A[j],我们就把A[i]放入到临时数组tmp,并且i后移一位,否则将A[j]放入到数组tmp,j后移一位。

继续比较过程,直到其中一个子数组中的所有数组都放入临时数组中,再把另一个数组中的数据依次加入临时数组的末尾,这个时候,临时数组中存储的就是两个子数组合并之后的结果了。最后再把临时数组tmp中的数据拷贝到原数组A[p…r]中

现在看一下治的阶段

我们需要将两个已经有序的子序列合并成一个有序序列,比如上图中的最后一次合并,要将 [4,5,7,8] 和[1,2,3,6]两个已经有序的子序列,合并为最终序列 [1,2,3,4,5,6,7,8],来看下实现步骤
在这里插入图片描述
在这里插入图片描述

实现

C++

#include <iostream>
#include <list>
#include <vector>
#include <map>
#include <string>
using namespace std;
#include <vector>

template<typename T>
std::ostream& print(std::ostream &out,T const &val) {
    return (out << val << " ");
}

template<typename T1,typename T2>
std::ostream& print(std::ostream &out,std::pair<T1,T2> const &val) {
    return (out << "{" << val.first << " " << val.second << "} ");
}

template<template<typename,typename...> class TT,typename... Args>
std::ostream& operator<<(std::ostream &out,TT<Args...> const &cont) {
    for(auto&& elem : cont) print(out,elem);
    out << "\n";
    return out;
}

/**
 * 合并的方法
 * @param arr 待排序的原始数组
 * @param left 左边有序序列的初始索引
 * @param mid 中间索引
 * @param right 右边索引
 * @param temp 做中转的数组
 */
void merge(std::vector<int>&arr, int left, int mid, int right, std::vector<int> &temp){
    int i = left; // 初始化i, 左边有序序列的初始索引
    int j = mid + 1; //初始化j, 右边有序序列的初始索引
    int t = 0; // 指向temp数组的当前索引

    //(一)
    //先把左右两边(有序)的数据按照规则填充到temp数组
    //直到左右两边的有序序列,有一边处理完毕为止
    while (i <= mid && j <= right){
        //如果左边的有序序列的当前元素,小于等于右边有序序列的当前元素
        //即将左边的当前元素,填充到temp数组
        //然后 t++, i++
        if(arr[i] <= arr[j]){
            temp[t++] = arr[i++];
        }else{ //反之,将右边有序序列的当前元素,填充到temp数组
            temp[t++] = arr[j++];
        }
    }

    //(二)
    //把有剩余数据的一边的数据依次全部填充到temp
    while (i <= mid){  //左边的有序序列还有剩余的元素,就全部填充到temp
        temp[t++] = arr[i++];
    }

    while (j <= right){
        temp[t++] = arr[j++];
    }

    //(三)
    //将temp数组的元素拷贝到arr
    //注意,并不是每次都拷贝所有,这句话和递归有关,要理解有一定的难度
    //因为这里并不是全部分完之后再合,而是分一点合一点
    t = 0;
    int tempLeft = left;
    while (tempLeft <= right){
        arr[tempLeft++] = temp[t++];
    }

}

void mergeSort(std::vector<int>&arr, int left, int right, std::vector<int> &temp){
    if(left < right) {
        int mid = (left + right) / 2; //中间索引
        //向左递归进行分解
        mergeSort(arr, left, mid, temp);
        //向右递归进行分解
        mergeSort(arr, mid + 1, right, temp);
        //合并
        merge(arr, left, mid, right, temp);
    }
}



int main(int argc, char* argv[]){
    vector<int> arr =  {8, 4, 5, 7, 1, 3, 6, 2, 2};
    vector<int> temp;
    temp.resize(arr.size());

    mergeSort(arr, 0, arr.size() - 1, temp);

    std::cout << arr;
    return 0;
}

java

public class MergeSort {
    public static void main(String[] args) {
    
        int arr[] = {8, 4, 5, 7, 1, 3, 6, 2};
        int temp[] = new int[arr.length]; //归并排序需要一个额外空间
        
        mergeSort(arr, 0, arr.length - 1, temp);
        System.out.println("排序后的结果为:" + Arrays.toString(arr));
	}


    //分 + 合方法:先递归分,再合
    public static void mergeSort(int[] arr, int left, int right, int[] temp) {
        if(left < right) {
            int mid = (left + right) / 2; //中间索引
            //向左递归进行分解
            mergeSort(arr, left, mid, temp);
            //向右递归进行分解
            mergeSort(arr, mid + 1, right, temp);
            //合并
            merge(arr, left, mid, right, temp);
        }
    }

    /**
     * 合并的方法
     * @param arr 待排序的原始数组
     * @param left 左边有序序列的初始索引
     * @param mid 中间索引
     * @param right 右边索引
     * @param temp 做中转的数组
     */
    public static void merge(int[] arr, int left, int mid, int right, int[] temp) {

        int i = left; // 初始化i, 左边有序序列的初始索引
        int j = mid + 1; //初始化j, 右边有序序列的初始索引
        int t = 0; // 指向temp数组的当前索引

        //(一)
        //先把左右两边(有序)的数据按照规则填充到temp数组
        //直到左右两边的有序序列,有一边处理完毕为止
        while (i <= mid && j <= right) { //继续
            //如果左边的有序序列的当前元素,小于等于右边有序序列的当前元素
            //即将左边的当前元素,填充到temp数组
            //然后 t++, i++
            if(arr[i] <= arr[j]) {
                temp[t] = arr[i];
                t = t + 1;
                i = i + 1;
            } else { //反之,将右边有序序列的当前元素,填充到temp数组
                temp[t] = arr[j];
                t += 1;
                j += 1;
            }
        }

        //(二)
        //把有剩余数据的一边的数据依次全部填充到temp
        while( i <= mid) { //左边的有序序列还有剩余的元素,就全部填充到temp
            temp[t] = arr[i];
            t += 1;
            i += 1;
        }
        while( j <= right) { //右边的有序序列还有剩余的元素,就全部填充到temp
            temp[t] = arr[j];
            t += 1;
            j += 1;
        }

        //(三)
        //将temp数组的元素拷贝到arr
        //注意,并不是每次都拷贝所有,这句话和递归有关,要理解有一定的难度
        //因为这里并不是全部分完之后再合,而是分一点合一点
        t = 0;
        int tempLeft = left; //
        //第一次合并 tempLeft = 0 , right = 1 //  tempLeft = 2  right = 3 // tL=0 ri=3
        //最后一次 tempLeft = 0  right = 7
        while(tempLeft <= right) {
            arr[tempLeft] = temp[t];
            t += 1;
            tempLeft += 1;
        }
    }
}

golang

package main

import "fmt"

func merge(low []int, high []int)[]int  {
	left := 0
	right := 0
	res := make([]int,0)
	for left < len(low) && right < len(high) {
		if low[left] < high[right]{
			res = append(res, low[left])
			left++
		}else if low[left] >  high[right] {
			res = append(res, high[right])
			right++
		}else{
			res = append(res, low[left], high[right])
			left++
			right++
		}
	}

	if left < len(low){
		res = append(res, low[left:]...)
	}

	if right < len(high) {
		res = append(res, high[right:]...)
	}

	return res
}

func mergeSort(arr []int) []int {
	length := len(arr)
	if length <= 1{
		return arr
	}else{
		mid := length/2;
		low := mergeSort(arr[:mid])
		high := mergeSort(arr[mid:])
		return merge(low, high)
	}
}
func main() {
	arr := []int{23,19,81,79,89,83,17,48,55,26,16,1,46,95,10}
	fmt.Println(mergeSort(arr))
}

优化: 我们没有必要把他切割到一个然后合并,一般我们采用小于N个的时候用插入或者其他排序方法来提高排序效率

func mergeSort(arr []int) []int {
	length := len(arr)
	if length<=1{
		return arr  //小与10改用插入排序
	}else if length>1 &&length <5{
		return 插入排序(arr)
	} else{
		mid := length/2;
		low := mergeSort(arr[:mid])
		high := mergeSort(arr[mid:])
		return merge(low, high)
	}
}

性能分析

(1)归并排序是稳定的排序算法吗?

归并排序稳不稳定关键是要看merge()函数,也就是两个有序子数组合并成一个有序数组的那部分代码。

在合并的过程中,如果A[p…q] 和A[q+1…r] 之间有相同的元素,那我们可以像伪代码中那样,先把 A[p…q] 中的元素放入 tmp 数组。这样就保证了值相同的元素,在合并前后的先后顺序不变。所以,归并排序是一个稳定的排序算法

(2)归并排序的时间复杂度是多少?

我们知道,递归的使用场景是一个问题a可以分解为多个子问题b、c,那求解问题a就可以分解为求解问题b、c。问题b、c解决之后,我们再把b、c的结果合并成a的结果。

如果我们定义了求解问题a的时间是T(a),求解问题b、c的时间分别是T(b)和T©,那我们就可以得到如下递推关系式:
T ( a ) = T ( b ) + T ( c ) + K T(a) = T(b) + T(c) + K T(a)=T(b)+T(c)+K

其中 K 等于将两个子问题 b、c 的结果合并成问题 a 的结果所消耗的时间。

套用这个公式,我们来分析一下归并排序的时间复杂度。

我们假设对n个元素进行归并排序需要的时间是T(n),那分解成两个子数组排序的时间都是T(n/2)。我们知道,merge()函数合并两个有序子数组的时间复杂度是O(n)。所以,套用前面的公式,归并排序的时间复杂度的计算公式是:

T ( 1 ) = C ; n = 1 时 , 只 需 要 常 量 级 的 执 行 时 间 , 所 以 表 示 为 C 。 T(1) = C; n=1 时,只需要常量级的执行时间,所以表示为 C。 T(1)=Cn=1C
T ( n ) = 2 ∗ T ( n / 2 ) + n ; n > 1 T(n) = 2*T(n/2) + n; n>1 T(n)=2T(n/2)+nn>1

通过这个公式,如何来求解 T(n) 呢?还不够直观?那我们再进一步分解一下计算过程。

T ( n ) = 2 ∗ T ( n / 2 ) + n T(n) = 2*T(n/2) + n T(n)=2T(n/2)+n
= 2 ∗ ( 2 ∗ T ( n / 4 ) + n / 2 ) + n = 4 ∗ T ( n / 4 ) + 2 ∗ n = 2*(2*T(n/4) + n/2) + n = 4*T(n/4) + 2*n =2(2T(n/4)+n/2)+n=4T(n/4)+2n
= 4 ∗ ( 2 ∗ T ( n / 8 ) + n / 4 ) + 2 ∗ n = 8 ∗ T ( n / 8 ) + 3 ∗ n = 4*(2*T(n/8) + n/4) + 2*n = 8*T(n/8) + 3*n =4(2T(n/8)+n/4)+2n=8T(n/8)+3n
= 8 ∗ ( 2 ∗ T ( n / 16 ) + n / 8 ) + 3 ∗ n = 16 ∗ T ( n / 16 ) + 4 ∗ n = 8*(2*T(n/16) + n/8) + 3*n = 16*T(n/16) + 4*n =8(2T(n/16)+n/8)+3n=16T(n/16)+4n
. . . . . . ...... ......
= 2 k ∗ T ( n / 2 k ) + k ∗ n = 2^k * T(n/2^k) + k * n =2kT(n/2k)+kn
. . . . . . ...... ......

通过这样一步一步分解推导,我们可以得到 T ( n ) = 2 k T ( n / 2 k ) + k n T(n) = 2^kT(n/2^k)+kn T(n)=2kT(n/2k)+kn。当 T ( n / 2 k ) = T ( 1 ) T(n/2^k)=T(1) T(n/2k)=T(1)时,也就是 $n/2^k=1 $,我们得到 k = l o g 2 n k=log_2n k=log2n 。我们将 k 值代入上面的公式,得到 T ( n ) = C n + n l o g 2 n T(n)=Cn+nlog_2n T(n)=Cn+nlog2n 。如果我们用大 O 标记法来表示的话,T(n) 就等于 O ( n l o g n ) O(nlogn) O(nlogn)。所以归并排序的时间复杂度是 O ( n l o g n ) O(nlogn) O(nlogn)

从我们的原理分析和伪代码可以看出,归并排序的执行效率与要排序的原始数组的有序程度无关,所以其时间复杂度是非常稳定的,不管是最好情况、最坏情况,还是平均情况,时间复杂度都是 O ( n l o g n ) O(nlogn) O(nlogn)

(3)归并排序的空间复杂度是多少?

归并排序的时间复杂度任何情况下都是O(nlogn)$,看起来非常优秀,但是,归并排序并没有像快排那样,应用广泛,这是为什么呢?因为它有一个致命的弱点,那就是归并排序不是原地排序算法。

这是因为归并排序的合并函数,在合并两个有序数组为一个有序数组时,需要借助额外的存储空间。那,归并排序的空间复杂度到底是多少呢?

递归代码的空间复杂度并不能像时间复杂度那样累加。而且尽管每次合并操作都需要申请额外的内存空间,但在合并完成之后,临时开辟的内存空间就被释放掉了。在任意时刻,CPU只有一个函数在执行,也就只会有一个临时的内存空间在使用。临时内存空间最大也不会超过n个数据的大小,所以空间复杂度是O(n)

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值