归并排序

归并排序

要将一个数组排序,可以先(递归地)将它分成两半分别排序,然后将结果归并起来。

实现归并的一种最直接的方法:将两个不同的有序数组归并到第三个数组中。但是,由于我们需要进行很多次归并,在每次归并时都要创建一个新数组来存储。
所以我们希望有个能在原地归并的方法,这样就可以先将前半部分排序,再将后半部分排序,然后在数组中移动元素而不需要使用额外的空间。

一 原地归并的抽象方法

先将所有元素复制到 temp[] 中,然后再归并回 a[] 中。
方法在归并时(第二个for循环)进行了4个条件判断:

  • 左半边用尽,取右半边的元素
  • 右半边用尽,取左半边的元素
  • 右半边的当前元素小于左半边的当前元素,取右半边的元素
  • 左半边的当前元素小于右半边的当前元素,取左半边的元素
//合并算法
public void merge (int[] a, int lo, int mid, int hi, int[] temp){
        int i = lo, j = mid + 1;
        for (int k = lo; k <= hi ; k++) {  //将a[]复制到aux[]
            temp[k] = a[k];
        }
        for (int k = lo; k <= hi ; k++) {
            if (i > mid)    a[k] = temp[j++]; //左半边用尽,取右半边的元素
            else if (j > hi)    a[k] = temp[i++];//右半边用尽,取左半边的元素
            else if (temp[j] < temp[i])   a[k] = temp[j++];
            else a[k] = temp[i++];
        }
}

这里写图片描述

二 自顶向下的归并排序 (递归)

2.1 原理与实现

分治法的基本思想将原问题分解为若干个规模更小但结构与原问题相似的子问题。递归地解这些子问题,然后将这些子问题的解组合为原问题的解。
基于分治的归并排序算法有两个基本的操作:一个是,也就是把原数组划分成两个子数组的过程。另一个是,它将两个有序数组归并成一个更大的有序数组。

它将数组平均分成两部分: center = (left + right)/2,当数组分得足够小时,即数组中只有一个元素时,只有一个元素的数组自然而然地就可以视为是有序的,此时就可以进行合并操作了。因此,上面讲的合并两个有序的子数组,是从 只有一个元素 的两个子数组开始合并的。

合并后的元素个数:从 1–>2–>4–>8……

比如初始数组:[24,13,26,1,2,27,38,15]
①分成了两个大小相等的子数组:[24,13,26,1] [2,27,38,15]

②再划分成了四个大小相等的子数组:[24,13] [26,1] [2,27] [38,15]

③此时,left < right 还是成立,再分:[24] [13] [26] [1] [2] [27] [38] [15]

此时,有8个小数组,每个数组都可以视为有序的数组了!!!,每个数组中的left == right,从递归中返回(从11行–12行的代码中返回),故开始执行合并(第13行):

merge([24], [13]) 得到 [13,24]

merge([26], [1]) 得到[1,26]

…..

最终得到 有序数组。

//自顶向下 递归的 归并排序
public class mergeSortUTD {
    public void sort(int[] a){
        int[] temp = new int[a.length];  //在排序前,先建好一个长度等于原数组长度的临时数组,避免递归中频繁开辟空间
        sort(a, 0, a.length - 1, temp);
    }

    public void sort(int[] a, int lo, int hi, int[] temp){
        if (lo < hi) {
            int mid = (lo + hi) / 2;
            sort(a, lo, mid, temp);    //左边排序,使得左子序列有序
            sort(a, mid + 1, hi, temp);  //右边排序,使得右子序列有序
            merge(a, lo, mid, hi, temp);  //将两个有序子数组合并操作
        }
    }

    public void merge (int[] a, int lo, int mid, int hi, int[] temp){
        int i = lo, j = mid + 1;
        for (int k = lo; k <= hi ; k++) {  //将a[]复制到temp[]
            temp[k] = a[k];
        }
        for (int k = lo; k <= hi ; k++) {
            if (i > mid)    a[k] = temp[j++]; //左半边用尽,取右半边的元素
            else if (j > hi)    a[k] = temp[i++];//右半边用尽,取左半边的元素
            else if (temp[j] < temp[i])   a[k] = temp[j++];
            else a[k] = temp[i++];
        }
    }
}

这里写图片描述
如图2.2.2中的轨迹所示。要将 a[0..15] 排序,sort()方法会调用自己将 a[0..7] 排序,再在其中调用自己将 a[0..3] 和 a[0..1] 排序。在将 a[0] 和 a[1] 分别排序之后(简单起见,我们在轨迹中把对单个元素进行排序的调用省略了),才会将 a[0] 和 a[1] 归并。第二次归并是 a[2] 和 a[3],然后是 a[0..1] 和 a[2..3],以此类推。
这里写图片描述

2.2 性能分析

对于长度为N的数组,自顶向下的归并排序需要大约 12NlgN 1 2 N l g N NlgN N l g N 次比较

我们可以通过图2.2.3所示的树状图来理解。每个节点都表示一个 sort() 方法通过 merge() 方法归并而成的子数组。
假设数组长 N=2n N = 2 n 。这棵树正好有 n n 层,自顶向下的第k层有 2k 2 k 个子数组,每个数组的长度为 2nk 2 n − k ,归并最多需要 2nk 2 n − k 次比较,最少需要 122nk 1 2 2 n − k 次比较。
因此每层的比较次数最多为 2k2nk=2n 2 k ∗ 2 n − k = 2 n n n 层总共最多比较 n2n=NlgN 次,最少比较 12n2n=12NlgN 1 2 n 2 n = 1 2 N l g N

对于长度为N的数组,自顶向下的归并排序最多需要访问数组 6NlgN 6 N l g N

每次归并最多需要访问数组 6N 次(2N 次用来复制,2N 次用来将排好序的元素移动回去,另外最多比较 2N 次)

这里写图片描述
分析方法2:(利用递归公式)
这里写图片描述

2.3 算法特点

1、归并排序所需的时间和 NlgN N l g N 成正比。它表明我们只需要比遍历整个数组多个对数因子的时间就能将一个庞大的数组排序。可以用归并排序处理数百万甚至更大规模的数组,这是插入排序或选择排序做不到的。
数组的初始顺序会影响排序的比较次数,但是总的而言,对复杂度没有影响。平均 or 最坏情况下时间复杂度都是 O(NlogN)

2、 归并排序的主要缺点是辅助数组所使用的额外空间和 N N 的大小成正比。用到了一个临时数组,故空间复杂度为 O(N)

3、归并排序的比较次数是所有排序中最少的。原因是,它一开始是不断地划分,比较只发生在合并各个有序的子数组时。
因此,JAVA的泛型排序类库中实现的就是归并排序。因为:对于JAVA而言,比较两个对象的操作代价是很大的(根据Comparable接口的compareTo方法进行比较),而移动两个对象,其实质移动的是引用,代价比较小。(排序本质上是两种操作:比较操作和移动操作

java.util.Arrays.sort(T[] arr)使用的是归并排序
java.util.Arrays.sort(int[] arr) 使用的是快速排序


三 自底向上的归并排序 (非递归)

先归并那些微型数组,然后再成对归并得到的子数组,如此这般,直到我们将整个数组归并在一起。
即,先两两归并,然后四四归并、八八归并等一直下去。

//自底向上 非递归的 归并排序
public class mergeSortDTU {
    public void sort(int[] a){  //进行lgN次两两归并
        int N = a.length;
        int[] temp = new int[N];
        for (int len = 1; len < N; len = len+len) {  //len:子数组大小
            for (int lo = 0; lo < N-len; lo += len+len) {  //lo:子数组索引
                merge(a, lo, lo+len-1, Math.min(lo+len+len-1, N-1), temp);
            }
        }
    }
    public void merge(int[] a, int lo, int mid, int hi, int[] temp){
        int i = lo, j = mid + 1; //左序列指针、右序列指针
        for (int k = lo; k <= hi; k++) {
            temp[k] = a[k];
        }
        for (int k = lo; k <= hi ; k++) {
            if (temp[i] <= temp[j]) a[k] = temp[i++];
            else if (temp[i] > temp[j]) a[k] = temp[j++];
            else if (i > mid) a[k] = temp[j++];
            else if (j > hi) a[k] = temp[i++]; //
        }
    }
}

这里写图片描述

对于长度为N的数组,自底向上的归并排序需要大约 12NlgN NlgN N l g N 次比较;最多需要访问数组 6NlgN 6 N l g N


综上所述,当数组长度为2的幂时,自顶向下和自底向上的归并排序所用的比较次数和数组访问次数正好相同,只是顺序不同。


推荐博客:浅谈算法和数据结构: 三 合并排序

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值