歸并排序(Merge Sort)

目標:將一個數(shù)組按照由低到高(或者由高到低)的順序排序。

歸并排序算法由 馮諾依曼 1945年發(fā)明巍膘。它是一種高效的排序算法厂财,其最好、平均峡懈、最差時間復(fù)雜度都是O(n log n)璃饱。

歸并排序算法使用了分治法(divide and conquer),即將一個大的問題分成更小的問題并解決它們肪康。我認為歸并算法就是拆分合并荚恶。

假設(shè)你需要將一個大小為 n 的數(shù)組排序。歸并排序算法的排序步驟是:

  • 將所有的數(shù)字放入一個無序的堆磷支。
  • 將堆分成兩部分谒撼,現(xiàn)在你有兩個無序的堆
  • 持續(xù)將無序的堆拆分雾狈,直到無法再拆分為止廓潜,你將得到 n 個堆,每一個堆中有一個數(shù)字。
  • 現(xiàn)在開始將這些堆按照一定順序按對合并辩蛋。每一次合并過程中呻畸,將堆中的數(shù)字放入有序的隊列。這一點很容易實現(xiàn)堪澎,因為每一個獨立的堆中的內(nèi)容都是有序的擂错。

示例

拆分

假設(shè)你有一個數(shù)組 [2, 1, 5, 4, 9]。這是一個無序的堆樱蛤。拆分的目的就是一直拆分,直到不可再拆分為止剑鞍。

首先昨凡,將數(shù)組分成兩堆: [2, 1][5, 4, 9]。還能繼續(xù)拆嗎蚁署?可以便脊!

先看左邊的堆[2, 1]分成 [2][1],還能繼續(xù)拆嗎光戈?不能哪痰,現(xiàn)在來看另一個堆。

[5, 4, 9] 分成 [5][4, 9]久妆, 毫無疑問[5] 已經(jīng)不能再分晌杰,但是 [4, 9]可以分成 [4][9]

拆分的過程結(jié)束時筷弦,有這樣一些堆:[2] [1] [5] [4] [9] 肋演。注意每一個堆中都只有一個數(shù)字。

合并

現(xiàn)在有了一堆拆開的數(shù)組烂琴,你需要在合并它們的同時對它們進行排序爹殊。記住,合并是逐漸地將很多小數(shù)組合并奸绷,而不是一次合并出一個大的數(shù)組梗夸。在每一次合并迭代中,你需要關(guān)注的是將一個堆與另一個堆合并号醉。

現(xiàn)在這些堆就是 [2] [1] [5] [4] [9]反症,第一次合并的結(jié)果是 [1, 2][4, 5][9]扣癣。由于[9] 落單了惰帽,在這一輪合并中它無法與其他堆合并。

下一輪是合并 [1, 2][4, 5]父虑,結(jié)果是[1, 2, 4, 5]该酗, [9] 繼續(xù)落單。

現(xiàn)在只有兩個堆[1, 2, 4, 5][9],現(xiàn)在是時候?qū)⑺鼈兒喜⒘宋仄牵Y(jié)果就是一個有序的數(shù)組 [1, 2, 4, 5, 9]悔叽。

遞歸法(Top-down)

我們先來看一個 Kotlin 實現(xiàn)的歸并排序:

fun mergeSort(array: IntArray): IntArray {
        if (array.size < 2) {
            return array                                                            //1
        }

        val middleIndex = array.size / 2                                            //2
        val leftArray = mergeSort(array.sliceArray(0 until middleIndex))    //3
        val rightArray = mergeSort(array.sliceArray(middleIndex until array.size))//4
        return merge(leftArray, rightArray)                                               //5
    }

我們逐行解讀這個函數(shù):

  1. 如果數(shù)組是空的或者只有一個元素,就沒有必要繼續(xù)拆分爵嗅,直接返回即可娇澎。
  2. 找到數(shù)組的中間位置。
  3. 根據(jù)上一步找到的中間位置,遞歸拆分數(shù)組的左半部分砸抛。
  4. 同樣遞歸拆分數(shù)組的右半部分窿克。
  5. 最后,將所有的值合并到一起戚啥,保證合并后的結(jié)果是有序的。

再來看一下合并算法:

private fun merge(leftPile: IntArray, rightPile: IntArray): IntArray {
         //1
        var leftIndex = 0
        var rightIndex = 0
        //2
        var orderedPile = intArrayOf()
        //3
        while (leftIndex < leftPile.size && rightIndex < rightPile.size) {
            when {
                leftPile[leftIndex] < rightPile[rightIndex] -> {
                    orderedPile += leftPile[leftIndex]
                    leftIndex++
                }
                leftPile[leftIndex] > rightPile[rightIndex] -> {
                    orderedPile += rightPile[rightIndex]
                    rightIndex++
                }
                else -> {
                    orderedPile += leftPile[leftIndex]
                    leftIndex++
                    orderedPile += rightPile[rightIndex]
                    rightIndex++
                }
            }

        }
        
        //4
        while (leftIndex < leftPile.size) {
            orderedPile += leftPile[leftIndex]
            leftIndex++
        }

        while (rightIndex < rightPile.size) {
            orderedPile += rightPile[rightIndex]
            rightIndex++
        }

        return orderedPile
    }

這個函數(shù)看起來可能很可怕锉试,但是它其實很簡單:

  1. 在合并的過程中需要兩個游標用于跟蹤兩個數(shù)組的合并過程猫十。
  2. 這個是存放合并結(jié)果的數(shù)組。一開始它是一個空的數(shù)組呆盖,但是你在隨后的步驟中會將其他數(shù)組中的元素添加進去拖云。
  3. 這個 while 循環(huán)就從左往右逐一比較兩個數(shù)組中的元素并將它們添加到 orderedPile,這樣就保證了結(jié)果是有序的应又。
  4. 當前面的 while 結(jié)束的時候宙项,意味著 leftPile 或者 rightPile 已經(jīng)完全合并到了 orderedPile。這個時候丁频,就不再需要比較杉允,直接將另一個數(shù)組中剩余的部分直接添加到 orderedPile

為了說明 merge() 函數(shù)的運行過程席里,我們假設(shè)現(xiàn)在有兩個堆:leftPile = [1, 7, 8] 以及 rightPile = [3, 6, 9]叔磷。注意每一個堆中的元素都已經(jīng)是有序的 -- 這一點在歸并排序中是肯定成立的。下面是將兩個堆合并的過程:

leftPile       rightPile       orderedPile
[ 1, 7, 8 ]    [ 3, 6, 9 ]     [ ]
  l              r

leftIndex(這我們用 l代表)指向 leftPile 的第一個元素 1奖磁。rightIndex(我們用 r 代表)指向 3改基。所以,添加到 orderedPile中的第一個元素時 1咖为,同時將 left index l 向右移動一個位置秕狰。

leftPile       rightPile       orderedPile
[ 1, 7, 8 ]    [ 3, 6, 9 ]     [ 1 ]
  -->l           r

現(xiàn)在 l 指向 7, 但是 r 還指向 3躁染,我們將最小的那一個元素加入有序堆鸣哀,所以應(yīng)該是 3。現(xiàn)在的情況是:

leftPile       rightPile       orderedPile
[ 1, 7, 8 ]    [ 3, 6, 9 ]     [ 1, 3 ]
     l           -->r

重復(fù)以上步驟吞彤。每一步我們都從 leftPile 或者 rightPile中取一個最小值放入 orderedPile:

leftPile       rightPile       orderedPile
[ 1, 7, 8 ]    [ 3, 6, 9 ]     [ 1, 3, 6 ]
     l              -->r

leftPile       rightPile       orderedPile
[ 1, 7, 8 ]    [ 3, 6, 9 ]     [ 1, 3, 6, 7 ]
     -->l              r

leftPile       rightPile       orderedPile
[ 1, 7, 8 ]    [ 3, 6, 9 ]     [ 1, 3, 6, 7, 8 ]
        -->l           r

現(xiàn)在左側(cè)的堆中已經(jīng)沒有數(shù)據(jù)我衬。我們只需要將右側(cè)堆中剩余的項目添加到 orderedPile叹放。最終的結(jié)果是: [ 1, 3, 6, 7, 8, 9 ]

注意這個算法非常簡單:它從左向右遍歷兩個堆挠羔,每一步都取一個最小的數(shù)字井仰。最終的結(jié)果能夠有序是因為我們保證了合并的每一個堆都已經(jīng)是有序的。

迭代法(Bottom-up)

上面我們所實現(xiàn)的歸并排序算法稱為遞歸法破加,因為他首先將數(shù)組拆分成更小的堆然后再合并俱恶。在排序數(shù)組的時候,實際上你可以跳過拆分的步驟立即執(zhí)行數(shù)組元素的合并范舀。這就是所謂的迭代法合是。

是時候加強一點難度了。先來看一個完整的迭代法實現(xiàn):

fun <T> mergeSortBottomUp(array: Array<T>, isOrderedBofore: (T, T) -> Boolean): Array<T> {
        val n = array.size

        val z = arrayOf(array.clone(), array.clone())          //1
        var d = 0

        var width = 1
        while (width < n) {                                   //2
            var i = 0
            while (i < n) {                                   //3
                var j = i
                var l = i
                var r = i + width

                val lmax = minOf(l + width, n)
                val rmax = minOf(r + width, n)
                while (l < lmax && r < rmax) {               //4
                    if (isOrderedBofore(z[d][l], z[d][r])) {
                        z[1 - d][j] = z[d][l]
                        l++
                    } else {
                        z[1 - d][j] = z[d][r]
                        r++
                    }
                    j++
                }

                while (l < lmax) {
                    z[1 - d][j] = z[d][l]
                    l++
                    j++
                }

                while (r < rmax) {
                    z[1 - d][j] = z[d][r]
                    r++
                    j++
                }

                i += width * 2

            }

            width *= 2
            d = 1 - d                   //5
        }
        return z[d]
    }

這個函數(shù)看起來比遞歸法版本要恐怖多了尿背,但是注意函數(shù)體中包含了和 merge() 方法一樣的 while 循環(huán)端仰。

我們先用一個實例來說明一下迭代排序的排序過程,假設(shè)有一個數(shù)組 [6, 2, 8, 1, 5, 4, 12, 3, 9] 需要排序田藐。一開始我們就申請了一個二維數(shù)組 z ,里面存放了兩份待排序的數(shù)組:

[[6, 2, 8, 1, 5, 4, 12, 3, 9],
 [6, 2, 8, 1, 5, 4, 12, 3, 9]]

第一步從二維數(shù)組的第一個元素z[0] 中取出數(shù)字吱七,按照兩兩結(jié)對排序合并(也就是合并大小為1的堆汽久,對應(yīng) width = 1 ),并將結(jié)果存入z[1]:

[[6, 2, 8, 1, 5, 4, 12, 3, 9],
  ↓  ↓  ↓  ↓  ↓  ↓   ↓  ↓  ↓
   --    --    --     --   |
  ↓  ↓  ↓  ↓  ↓  ↓   ↓  ↓  ↓
 [2, 6, 1, 8, 4, 5, 3, 12, 9]]

然后將合并的寬度加倍(width = 2)踊餐,從 z[1]中取出數(shù)字合并排序景醇,將結(jié)果存入 z[0]

[[1, 2, 6, 8, 3, 4, 5, 12, 9],
  ↑        ↑  ↑         ↑  ↑
  ----------   ---------   |
  ↑        ↑  ↑         ↑  ↑
 [2, 6, 1, 8, 4, 5, 3, 12, 9]]

如此循環(huán),每次都需要將寬度加倍吝岭,并且切換待排序數(shù)據(jù)的來源以及排序結(jié)果的存入位置三痰,對應(yīng)代碼中的 d = 1- d,從 z[d]中讀數(shù)據(jù)窜管,排序結(jié)果存入z[1-d]


[[1, 2, 6, 8, 3, 4, 5, 12, 9],
  ↓                     ↓  ↓
  -----------------------  |
  ↓                     ↓  ↓
 [1, 2, 3, 4, 5, 6, 8, 12, 9]]


[[1, 2, 3, 4, 5, 6, 8, 9, 12],
  ↑                        ↑
  --------------------------
  ↑                        ↑
 [1, 2, 3, 4, 5, 6, 8, 12, 9]]

最終得到排序好的數(shù)組z[d] : [1, 2, 3, 4, 5, 6, 8, 9, 12]散劫。

再來看一下代碼中的關(guān)鍵邏輯:

  1. 歸并排序算法需要一個臨時的數(shù)組作為工作區(qū),因為你不能在合并左右堆的同時覆蓋它們的內(nèi)容幕帆。因為每次申請一個新的數(shù)組對資源是極大的浪費获搏,所以為我們使用了兩個數(shù)組,然后通過 d 的值在兩個數(shù)組之間切換失乾,d 的值只能是 0 或者 1.數(shù)組 z[d] 用于讀取數(shù)據(jù)常熙,z[1-d] 用于寫入數(shù)據(jù)。這就是所謂的雙緩沖區(qū)碱茁。
  2. 從概念上講裸卫,迭代法版本和遞歸法版本的工作原理是一樣的。首先它將只有一個元素的堆合并纽竣,然后合并有兩個元素的堆墓贿,然后是有4個元素的堆,等等。堆的大小由 width 決定募壕。一開始调炬,width1,但是在每一次循環(huán)迭代結(jié)束時舱馅,我們將它的值乘以2缰泡,所以外層的循環(huán)決定了每次合并的堆的大小,并且每次循環(huán)之后待合并的子數(shù)組都會增大代嗤。
  3. 這個內(nèi)部循環(huán)逐一檢查堆中的每一個元素并將每一對堆合并成一個更大的堆棘钞。合并后的結(jié)果存入數(shù)組 z[1-d]
  4. 這里的邏輯和遞歸版本是一樣的干毅。主要的區(qū)別是使用了雙緩沖區(qū)宜猜,所以數(shù)據(jù)從 z[d] 中讀出來然后存入 z[1-d]。同時使用了 isOrderedBofore來比較元素硝逢,而不是單純的比較數(shù)字大小姨拥。所以這個歸并排序算法是一個通用算法,你可以用它來排序任何類型的對象渠鸽。
  5. 這個時候叫乌,從z[d] 中讀取出來的大小為 width 的堆已經(jīng)被合并成一個大小為 width*2 的大堆并存放在 z[1-d]。在這里我們需要交換兩個數(shù)組徽缚,所以下一步我們就從剛剛創(chuàng)建的新堆中讀取數(shù)據(jù)憨奸。

這個函數(shù)是一個泛型函數(shù),所以你可以用它來排序任何你需要的類型凿试,只有你提供合適的 isOrderedBofore函數(shù)來比較元素排宰。

使用范例:

        val numList: Array<Int> = arrayOf(21, 3, 12, 45, 6, 9, 56, 67, 1, 43, 0)
        val sortedNum = mergeSortBottomUp(numList) { x, y -> x < y }
        val sortedNumUp = mergeSortBottomUp(numList) { x, y -> x > y }
        
        val strList: Array<String> = arrayOf("e", "m", "ec", "q", "a", "dx", "adxz", "rf", "po")
        val sortedStr = mergeSortBottomUp(strList) { x: String, y: String -> x < y }
        val sortedStrUp = mergeSortBottomUp(strList) { x: String, y: String -> x > y }

性能

歸并排序算法的運行速度取決于待排序數(shù)組的大小。數(shù)組越大那婉,需要做的事情就越多板甘。

不管待排序的數(shù)組初始狀態(tài)是否有序,都不會影響歸并排序算法的運行速度吧恃,因為不管初始狀態(tài)是否有序虾啦,拆分的步驟都不會變。

所有痕寓,它的時間復(fù)雜度(最優(yōu)傲醉、平均、最差)都是O(n log n)呻率。

歸并排序算法的一個缺點就是需要一個臨時工作區(qū)硬毕,其大小與待排序數(shù)組的大小相同。它不是就地排序礼仗。

大多數(shù)歸并排序算法的實現(xiàn)都是穩(wěn)定排序吐咳。對于擁有相同排序鍵值的元素在排序后保持原來的相對位置不變逻悠。這一點對于一些簡單的數(shù)據(jù)如數(shù)字或者字符串顯得不是那么重要,但是對一些復(fù)雜的對象排序時非常重要韭脊。

本文編譯自: Switf 算法俱樂部 - 歸并排序

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末童谒,一起剝皮案震驚了整個濱河市,隨后出現(xiàn)的幾起案子沪羔,更是在濱河造成了極大的恐慌饥伊,老刑警劉巖,帶你破解...
    沈念sama閱讀 207,248評論 6 481
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件蔫饰,死亡現(xiàn)場離奇詭異琅豆,居然都是意外死亡,警方通過查閱死者的電腦和手機篓吁,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 88,681評論 2 381
  • 文/潘曉璐 我一進店門茫因,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人杖剪,你說我怎么就攤上這事冻押。” “怎么了盛嘿?”我有些...
    開封第一講書人閱讀 153,443評論 0 344
  • 文/不壞的土叔 我叫張陵翼雀,是天一觀的道長。 經(jīng)常有香客問我孩擂,道長,這世上最難降的妖魔是什么箱熬? 我笑而不...
    開封第一講書人閱讀 55,475評論 1 279
  • 正文 為了忘掉前任类垦,我火速辦了婚禮,結(jié)果婚禮上城须,老公的妹妹穿的比我還像新娘蚤认。我一直安慰自己,他們只是感情好糕伐,可當我...
    茶點故事閱讀 64,458評論 5 374
  • 文/花漫 我一把揭開白布砰琢。 她就那樣靜靜地躺著,像睡著了一般良瞧。 火紅的嫁衣襯著肌膚如雪陪汽。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 49,185評論 1 284
  • 那天褥蚯,我揣著相機與錄音挚冤,去河邊找鬼。 笑死赞庶,一個胖子當著我的面吹牛训挡,可吹牛的內(nèi)容都是我干的澳骤。 我是一名探鬼主播,決...
    沈念sama閱讀 38,451評論 3 401
  • 文/蒼蘭香墨 我猛地睜開眼澜薄,長吁一口氣:“原來是場噩夢啊……” “哼为肮!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起肤京,我...
    開封第一講書人閱讀 37,112評論 0 261
  • 序言:老撾萬榮一對情侶失蹤颊艳,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后蟆沫,有當?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體籽暇,經(jīng)...
    沈念sama閱讀 43,609評論 1 300
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 36,083評論 2 325
  • 正文 我和宋清朗相戀三年饭庞,在試婚紗的時候發(fā)現(xiàn)自己被綠了戒悠。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 38,163評論 1 334
  • 序言:一個原本活蹦亂跳的男人離奇死亡舟山,死狀恐怖绸狐,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情累盗,我是刑警寧澤寒矿,帶...
    沈念sama閱讀 33,803評論 4 323
  • 正文 年R本政府宣布,位于F島的核電站若债,受9級特大地震影響符相,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜蠢琳,卻給世界環(huán)境...
    茶點故事閱讀 39,357評論 3 307
  • 文/蒙蒙 一啊终、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧傲须,春花似錦蓝牲、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,357評論 0 19
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至已卸,卻和暖如春佛玄,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背咬最。 一陣腳步聲響...
    開封第一講書人閱讀 31,590評論 1 261
  • 我被黑心中介騙來泰國打工翎嫡, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人永乌。 一個月前我還...
    沈念sama閱讀 45,636評論 2 355
  • 正文 我出身青樓惑申,卻偏偏與公主長得像具伍,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子圈驼,可洞房花燭夜當晚...
    茶點故事閱讀 42,925評論 2 344

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

  • 聲明:算法和數(shù)據(jù)結(jié)構(gòu)的文章均是作者從github上翻譯過來人芽,為方便大家閱讀。如果英語閱讀能力強的朋友绩脆,可以直接到s...
    UnsanYL閱讀 1,570評論 0 2
  • 歸并排序是建立在歸并操作上的一種有效的排序算法萤厅,該算法是采用分治法(Divide and Conquer)的一個非...
    NEXTFIND閱讀 953評論 0 0
  • 題目 設(shè)計一個算法可將{4, 2, -3, 6, 1} (或其他亂序的數(shù)組)按升序排序得到 {-3, 1, 2, ...
    a_tomcat閱讀 1,823評論 0 50
  • 我爸在后院細細地將樹根整整齊齊地碼成一堆,好讓我媽過年不用操心柴火靴迫,其實我媽還有煤氣灶惕味。 年關(guān)將至,他們倆準備著各...
    木之Rickey閱讀 110評論 0 0