數(shù)據(jù)結(jié)構(gòu)與算法--歸并排序

數(shù)據(jù)結(jié)構(gòu)與算法--歸并排序

歸并排序

歸并排序基于一種稱為“歸并”的簡(jiǎn)單操作。比如考試可能會(huì)分年級(jí)排名和班級(jí)排名,那么這個(gè)年級(jí)排名可能是合并了各個(gè)班級(jí)的排名后對(duì)其再排名得到——每個(gè)班級(jí)會(huì)上報(bào)本班學(xué)生成績(jī)排名情況,上面的人根據(jù)各班提交的排名情況匯總成一個(gè)總排名。這就是歸并在生活中的例子。那么歸并排序,說(shuō)得抽象些,其實(shí)就是將兩個(gè)已經(jīng)有序的數(shù)組歸并成一個(gè)更大的有序數(shù)組,這里注意要求在歸并之前兩個(gè)子數(shù)組已經(jīng)有序。于是,對(duì)整個(gè)大數(shù)組排序,可以先(遞歸地)將其分成兩半分別排序,然后將結(jié)果歸并起來(lái)。

實(shí)現(xiàn)歸并排序的一種簡(jiǎn)單方法是:將兩個(gè)不同的有序數(shù)組歸并到第三個(gè)數(shù)組中去,因此該方法實(shí)現(xiàn)的歸并排序需要額外空間,且所需空間的大小和待排序數(shù)組的長(zhǎng)度N成正比。下面來(lái)看,通過(guò)怎樣的比較才能將兩個(gè)數(shù)組的元素有序地歸并到一個(gè)數(shù)組中。

private static void merge(Comparable[] a, Comparable[] aux, int low, int mid, int high) {
    int i = low; // 左半數(shù)組的指針 [0, mid]
    int j = mid + 1; // 右半數(shù)組的指針 [mid +1, high]
    // 將待歸并的數(shù)組元素全歸并到一個(gè)新數(shù)組中
    for (int k = low; k <= high; k++) {
        aux[k] = a[k];
    }

    for (int k = low; k <= high; k++) {
        // 左半數(shù)組指針超出,被取完。于是取右半數(shù)組中的元素
        if (i > mid) {
            a[k] = aux[j++];
        // 右半數(shù)組被取完,取左半數(shù)組中的元素
        } else if (j > high) {
            a[k] = aux[i++];
        // 已滿足i <= mid && j <= high
        // 右半數(shù)組的元素小,就取右半數(shù)組中元素
        } else if (less(aux[j], aux[i])) {
            a[k] = aux[j++];
            // 已滿足i <= mid && j <= high
            // 左半數(shù)組元素小或者相等,取左半數(shù)組中的元素,相等時(shí)取左邊保證了排序穩(wěn)定性
        } else {
            a[k] = aux[i++];
        }
    }
}

該方法將數(shù)組區(qū)間[low, high]之間的所有元素都復(fù)制到了一個(gè)新的數(shù)組aux中,然后在歸并回原數(shù)組a中。為此進(jìn)行了4個(gè)判斷:

  1. 左半數(shù)組被取盡 --> 取右半數(shù)組中的元素;
  2. 右半數(shù)組被取盡 --> 取左半數(shù)組中的元素;
  3. 左半數(shù)組的當(dāng)前元素小于右半數(shù)組的當(dāng)前元素 --> 取左半數(shù)組的元素;
  4. 左半數(shù)組的當(dāng)前元素不小于右半數(shù)組中的元素 --> 取右半數(shù)組中的元素,因此當(dāng)左右數(shù)組中當(dāng)前元素等值時(shí)會(huì)去右半數(shù)組中的元素。

注意條件1、2和條件3、4的順序不可顛倒,因?yàn)槿绻麠l件1、2不通過(guò),執(zhí)行條件3、4時(shí)候就保證了i <= mid && j <= mid,使得左右半數(shù)組的訪問(wèn)都不會(huì)出現(xiàn)下標(biāo)越界。如果條件3、4放在前面判斷,隨著j的自增,可能就下標(biāo)越界了。

如下圖演示了歸并的過(guò)程,依據(jù)上面的條件,不斷從右邊的aux[]中取處元素順序放回原數(shù)組a中,達(dá)到將兩個(gè)數(shù)組歸并(由原數(shù)組分割成的左右數(shù)組),從而實(shí)現(xiàn)了排序。

自頂向下的歸并排序

為了將小數(shù)組歸并成大數(shù)組,需要保證小數(shù)組是有序的。于是我們可以利用的遞歸的方法,將數(shù)組分成左右兩半分別排序,左右數(shù)組有序后就能將結(jié)果歸并到一起了。根據(jù)描述,可寫(xiě)出如下代碼。

package Chap9;

public class MergeSort {

    /* private static void merge...
       merge方法見(jiàn)上面
    */

    private static void sort(Comparable[] a, Comparable[] aux, int low, int high) {
        // high = low說(shuō)明數(shù)組被劃分到只有一個(gè)元素,無(wú)需排序和歸并直接返回
        if (high <= low) {
            return;
        }

        int mid = low + (high - low) / 2;

        sort(a, aux, low, mid);
        sort(a, aux, mid + 1, high);
        merge(a, aux, low, mid, high);
    }

    public static void sort(Comparable[] a) {
        Comparable[] aux = new Comparable[a.length];
        sort(a, aux, 0, a.length - 1);
    }

    private static boolean less(Comparable v, Comparable w) {
        return v.compareTo(w) < 0;
    }


    public static boolean isSorted(Comparable[] a) {
        for (int i = 0; i < a.length - 1; i++) {
            if (less(a[i + 1], a[i])) {
                return false;
            }
        }
        return true;
    }

    public static String toString(Comparable[] a) {
        if (a.length == 0) {
            return "[]";
        }

        StringBuilder sb = new StringBuilder();
        sb.append("[");
        for (int i = 0; i < a.length; i++) {
            sb.append(a[i]);
            if (i == a.length - 1) {
                return sb.append("]").toString();
            } else {
                sb.append(", ");
            }
        }
        return sb.toString();
    }

    public static void main(String[] args) {
        Integer[] a = {9, 1, 5, 8, 3, 7, 4, 6, 2};
        MergeSort.sort(a);
        System.out.println(MergeSort.toString(a));
        System.out.println(MergeSort.isSorted(a));
    }
}

輔助數(shù)組aux是sort方法的局部變量,這表明每對(duì)一個(gè)序列調(diào)用sort方法都會(huì)生成一個(gè)aux數(shù)組。不能將aux設(shè)置成 類靜態(tài)成員static Comparable[] aux,因?yàn)槿绻卸鄠€(gè)程序用這個(gè)類進(jìn)行排序,它們就共享了該輔助數(shù)組aux,在多線程中很容易出問(wèn)題。將aux定義在merge方法里也不合適,這意味著每次歸并都會(huì)生成一個(gè)數(shù)組,無(wú)疑是浪費(fèi)。

下圖顯示了歸并過(guò)程

第一次歸并是a[0]和a[1],第二次歸并是a[2]和a[3],第三次歸并是a[0..3],然后是a[4]和a[5]...以此類推。你可能詫異為什么是這樣的歸并順序,看下圖遞歸方法調(diào)用的順序就清楚了。

我們還可以將這種“先對(duì)左半數(shù)組進(jìn)行排序,再將右半數(shù)組進(jìn)行排序”的歸并方法用樹(shù)來(lái)表示,如下

該樹(shù)的高度為lg N,設(shè)lg N = n,對(duì)于[0, n]中任意k,自頂向下的第k層有2^k個(gè)子數(shù)組,每個(gè)數(shù)組的長(zhǎng)度為2^(n-k),歸并最多需要2^(n-k)次比較,所以第k層需要2^k * 2^(n-k) = 2^n次比較,總共n層需要n * 2^nNlg N次比較。故時(shí)間復(fù)雜度為O(Nlg N)

歸并排序可以處理大規(guī)模的數(shù)組,但是主要缺點(diǎn)是引入的輔助數(shù)組所需的額外空間與數(shù)組大小N成正比。

改進(jìn)

對(duì)小規(guī)模數(shù)組換用插入排序

用不同的算法處理小規(guī)模問(wèn)題能改進(jìn)大多數(shù)遞歸算法的性能,因?yàn)檫f歸會(huì)使得小規(guī)模問(wèn)題的方法調(diào)用過(guò)于頻繁。對(duì)排序來(lái)說(shuō),我們知道插入排序非常簡(jiǎn)單而且適合小規(guī)模數(shù)組,因此在歸并排序中如果需要處理的數(shù)組比較小,比如數(shù)組長(zhǎng)度小只有十幾,就可以用插入排序代替歸并過(guò)程。

測(cè)試數(shù)組是否有序

我們知道在歸并之前,左半數(shù)組和右半數(shù)組已經(jīng)分別有序,如果左半數(shù)組的最后一個(gè)元素(下標(biāo)為mid)比右半數(shù)組的小于等于第一個(gè)數(shù)組(下標(biāo)為mid + 1),說(shuō)明該數(shù)組本身就有序,無(wú)需再調(diào)用merge方法。

基于以上兩點(diǎn),對(duì)自頂而下的歸并排序優(yōu)化如下

private static void sort(Comparable[] a, Comparable[] aux, int low, int high) {

    // high <= low + 15說(shuō)明當(dāng)數(shù)組很小時(shí)直接換用插入排序,當(dāng)數(shù)組長(zhǎng)度不超過(guò)16時(shí)都使用插入排序
    if (high <= low + 15) {
        InsertSort.sort(a);
        return;
    }

    int mid = low + (high - low) / 2;

    sort(a, aux, low, mid);
    sort(a, aux, mid + 1, high);
    // a[mid] <= a[mid + 1]已經(jīng)有序,跳過(guò)歸并操作
    if (a[mid].compareTo(a[mid + 1]) > 0) {
        merge(a, aux, low, mid, high);
    }
}

在原來(lái)的基礎(chǔ)上只是加了簡(jiǎn)單幾行,就可以稍微提升一點(diǎn)性能!歸并排序的可視化過(guò)程見(jiàn)下圖

自底向上的歸并排序

遞歸實(shí)現(xiàn)的歸并是算法設(shè)計(jì)中分治思想體現(xiàn),我們將一個(gè)大問(wèn)題分割成小問(wèn)題分別解決,然后用所有的小問(wèn)題的答案解決整個(gè)大問(wèn)題。實(shí)現(xiàn)歸并排序的另一種思路是直接從小數(shù)組開(kāi)始?xì)w并(因此無(wú)需使用遞歸的方法將大數(shù)組分成左右子數(shù)組),然后再成對(duì)歸并得到的子數(shù)組,如此這般,直到將整個(gè)數(shù)組歸并到一起。

首先我們進(jìn)行兩兩歸并,然后四四歸并、八八歸并,一直下去。最后一次歸并的第二個(gè)子數(shù)組可能比第一個(gè)子數(shù)組要 小,即便如此merge方法也能很好地處理,因此不用在意。

public class MergeBU {

    public static void sort(Comparable[] a) {
        Comparable[] aux = new Comparable[a.length];
        // sz = 1, 2, 4, 8...
        for (int sz = 1; sz < a.length; sz = sz + sz) {
            // sz = 1: low= 0, 2, 4, 6, 8, 10...
            // sz = 2: low= 0, 4, 8, 12, 16...
            // sz = 4: low= 0, 8, 16, 24...
            for (int low = 0; low < a.length-sz; low += (sz + sz)) {
                // sz = 1: 歸并子數(shù)組 (0,0,1) (2,2,3) (4,4,5)...
                // sz = 2: 歸并子數(shù)組 (0,1,3) (4,5,7) (8,9,11)...
                // sz = 4: 歸并子數(shù)組 (0,3,7) (8,11,15) (16,19,23)...

                // 可由歸納法得到mid = low + sz -1; high = low + 2sz -1
                // 最后一個(gè)子數(shù)組可能比sz小,所以通過(guò)low + 2sz -1計(jì)算high可能比原數(shù)組還要大,因此和a.length - 1取最小
                merge(a, aux, low,  low + sz - 1, Math.min(low + sz + sz - 1, a.length - 1));
            }
        }
    }
}

merge方法直接使用自頂向下的歸并排序中的改進(jìn)的merge方法。注釋中寫(xiě)出了sz和low的遞增過(guò)程,外循環(huán)中sz=1表示兩兩歸并,sz=2表示四四歸并,以此類推,每次都成倍增長(zhǎng)...內(nèi)循環(huán)中l(wèi)ow每次增長(zhǎng)sz + sz,實(shí)際上是直接跳到了下一個(gè)子數(shù)組,邊界條件low < a.length - sz比較難懂,如果是low < a.length就比較好理解,表示的是最后一個(gè)子數(shù)組的開(kāi)始下標(biāo),后者當(dāng)然能得出正確結(jié)果,但是相比前者在處理最后一個(gè)子數(shù)組時(shí)可能有多余的歸并操作

low < a.length - sz終止條件,說(shuō)明頂多最后一個(gè)sz長(zhǎng)度的子數(shù)組沒(méi)有進(jìn)行歸并操作就跳出循環(huán)了,這種情況發(fā)生在倒數(shù)第二個(gè)子數(shù)組的low剛好等于a.length - 3sz時(shí),該子數(shù)組的范圍是[a.length - 3sz, a.length - sz -1],下一次low自增low += (sz + sz)得到low = a.length - sz剛好不滿足條件,于是后面長(zhǎng)度為sz的子數(shù)組沒(méi)有歸并。如下圖所示。

其他情況下,沒(méi)有被歸并的子數(shù)組長(zhǎng)度都比上述情況要小。如下兩幅圖所示,

再來(lái)看,最后一個(gè)長(zhǎng)度為小于等于sz的數(shù)組沒(méi)有被歸并影響結(jié)果嗎?由外層for循環(huán)的sz = sz + sz可知,sz正好是上一輪中被歸并的子數(shù)組長(zhǎng)度(現(xiàn)在的sz是上一輪中sz的兩倍,而被歸并的子數(shù)組長(zhǎng)度2sz),因此本輪中最后這長(zhǎng)度為sz的子數(shù)組,在上一輪中就已經(jīng)歸并過(guò)了,條件low < a.length - sz正好規(guī)避了多余的歸并操作。

至于merge方法中,mid = low + sz -1,以及high = low + sz + sz -1都可以通過(guò)數(shù)學(xué)歸納法得到,有時(shí)候最后一個(gè)子數(shù)組可能比sz小,所以通過(guò)low + sz + sz -1計(jì)算high可能超出原數(shù)組長(zhǎng)度,因此和a.length - 1取最小以防止下標(biāo)越界。

下面是自底向上的歸并排序軌跡圖以及可視化過(guò)程。

和自頂而下的歸并排序相比,可以發(fā)現(xiàn)他們的歸并順序并不一樣。

歸并排序在最壞情況下需要比較~Nlg N次,這已經(jīng)是所有基于比較的排序算法中最好的效率了,但是歸并排序需要額外空間,這不是最優(yōu)的。

歸并排序中先對(duì)子數(shù)組兩兩歸并,由于merge方法代碼中else分支包含了左半數(shù)組元素和右半數(shù)組元素相等的情況,此時(shí)取的是左半數(shù)組的元素,這保證了等值元素的相對(duì)位置不會(huì)發(fā)生改變,隨著歸并數(shù)組的增大,這種關(guān)系依然成立。所以歸并排序是穩(wěn)定的排序算法。


by @sunhaiyu

2017.10.29

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書(shū)系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

推薦閱讀更多精彩內(nèi)容

  • 概述 排序有內(nèi)部排序和外部排序,內(nèi)部排序是數(shù)據(jù)記錄在內(nèi)存中進(jìn)行排序,而外部排序是因排序的數(shù)據(jù)很大,一次不能容納全部...
    蟻前閱讀 5,220評(píng)論 0 52
  • 1.插入排序—直接插入排序(Straight Insertion Sort) 基本思想: 將一個(gè)記錄插入到已排序好...
    依依玖玥閱讀 1,282評(píng)論 0 2
  • 概述:排序有內(nèi)部排序和外部排序,內(nèi)部排序是數(shù)據(jù)記錄在內(nèi)存中進(jìn)行排序,而外部排序是因排序的數(shù)據(jù)很大,一次不能容納全部...
    每天刷兩次牙閱讀 3,743評(píng)論 0 15
  • 44444
    NgJing閱讀 199評(píng)論 0 0
  • 一個(gè)平凡90后的旅行故事——我的單車(chē)回家路 2015-03-08 17:09 一個(gè)平凡90后的旅行故事——我的單車(chē)...
    背著女兒去旅行閱讀 645評(píng)論 1 0