归并排序(Merge Sort)

目标:将一个数组按照由低到高(或者由高到低)的顺序排序。

归并排序算法由 冯诺依曼 1945年发明。它是一种高效的排序算法,其最好、平均、最差时间复杂度都是O(n log n)

归并排序算法使用了分治法(divide and conquer),即将一个大的问题分成更小的问题并解决它们。我认为归并算法就是拆分合并

假设你需要将一个大小为 n 的数组排序。归并排序算法的排序步骤是:

  • 将所有的数字放入一个无序的堆。
  • 将堆分成两部分,现在你有两个无序的堆
  • 持续将无序的堆拆分,直到无法再拆分为止,你将得到 n 个堆,每一个堆中有一个数字。
  • 现在开始将这些堆按照一定顺序按对合并。每一次合并过程中,将堆中的数字放入有序的队列。这一点很容易实现,因为每一个独立的堆中的内容都是有序的。

示例

拆分

假设你有一个数组 [2, 1, 5, 4, 9]。这是一个无序的堆。拆分的目的就是一直拆分,直到不可再拆分为止。

首先,将数组分成两堆: [2, 1][5, 4, 9]。还能继续拆吗?可以!

先看左边的堆[2, 1]分成 [2][1],还能继续拆吗?不能,现在来看另一个堆。

[5, 4, 9] 分成 [5][4, 9], 毫无疑问[5] 已经不能再分,但是 [4, 9]可以分成 [4][9]

拆分的过程结束时,有这样一些堆:[2] [1] [5] [4] [9] 。注意每一个堆中都只有一个数字。

合并

现在有了一堆拆开的数组,你需要在合并它们的同时对它们进行排序。记住,合并是逐渐地将很多小数组合并,而不是一次合并出一个大的数组。在每一次合并迭代中,你需要关注的是将一个堆与另一个堆合并。

现在这些堆就是 [2] [1] [5] [4] [9],第一次合并的结果是 [1, 2][4, 5][9]。由于[9] 落单了,在这一轮合并中它无法与其他堆合并。

下一轮是合并 [1, 2][4, 5],结果是[1, 2, 4, 5][9] 继续落单。

现在只有两个堆[1, 2, 4, 5][9],现在是时候将它们合并了,结果就是一个有序的数组 [1, 2, 4, 5, 9]

递归法(Top-down)

我们先来看一个 Kotlin 实现的归并排序:

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
    }

我们逐行解读这个函数:

  1. 如果数组是空的或者只有一个元素,就没有必要继续拆分,直接返回即可。
  2. 找到数组的中间位置。
  3. 根据上一步找到的中间位置,递归拆分数组的左半部分。
  4. 同样递归拆分数组的右半部分。
  5. 最后,将所有的值合并到一起,保证合并后的结果是有序的。

再来看一下合并算法:

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
    }

这个函数看起来可能很可怕,但是它其实很简单:

  1. 在合并的过程中需要两个游标用于跟踪两个数组的合并过程。
  2. 这个是存放合并结果的数组。一开始它是一个空的数组,但是你在随后的步骤中会将其他数组中的元素添加进去。
  3. 这个 while 循环就从左往右逐一比较两个数组中的元素并将它们添加到 orderedPile,这样就保证了结果是有序的。
  4. 当前面的 while 结束的时候,意味着 leftPile 或者 rightPile 已经完全合并到了 orderedPile。这个时候,就不再需要比较,直接将另一个数组中剩余的部分直接添加到 orderedPile

为了说明 merge() 函数的运行过程,我们假设现在有两个堆:leftPile = [1, 7, 8] 以及 rightPile = [3, 6, 9]。注意每一个堆中的元素都已经是有序的 -- 这一点在归并排序中是肯定成立的。下面是将两个堆合并的过程:

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

现在 l 指向 7, 但是 r 还指向 3,我们将最小的那一个元素加入有序堆,所以应该是 3。现在的情况是:

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

重复以上步骤。每一步我们都从 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

现在左侧的堆中已经没有数据。我们只需要将右侧堆中剩余的项目添加到 orderedPile。最终的结果是: [ 1, 3, 6, 7, 8, 9 ]

注意这个算法非常简单:它从左向右遍历两个堆,每一步都取一个最小的数字。最终的结果能够有序是因为我们保证了合并的每一个堆都已经是有序的。

迭代法(Bottom-up)

上面我们所实现的归并排序算法称为递归法,因为他首先将数组拆分成更小的堆然后再合并。在排序数组的时候,实际上你可以跳过拆分的步骤立即执行数组元素的合并。这就是所谓的迭代法。

是时候加强一点难度了。先来看一个完整的迭代法实现:

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]
    }

这个函数看起来比递归法版本要恐怖多了,但是注意函数体中包含了和 merge() 方法一样的 while 循环。

我们先用一个实例来说明一下迭代排序的排序过程,假设有一个数组 [6, 2, 8, 1, 5, 4, 12, 3, 9] 需要排序。一开始我们就申请了一个二维数组 z ,里面存放了两份待排序的数组:

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

第一步从二维数组的第一个元素z[0] 中取出数字,按照两两结对排序合并(也就是合并大小为1的堆,对应 width = 1 ),并将结果存入z[1]:

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

然后将合并的宽度加倍(width = 2),从 z[1]中取出数字合并排序,将结果存入 z[0]

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

如此循环,每次都需要将宽度加倍,并且切换待排序数据的来源以及排序结果的存入位置,对应代码中的 d = 1- d,从 z[d]中读数据,排序结果存入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]]

最终得到排序好的数组z[d] : [1, 2, 3, 4, 5, 6, 8, 9, 12]

再来看一下代码中的关键逻辑:

  1. 归并排序算法需要一个临时的数组作为工作区,因为你不能在合并左右堆的同时覆盖它们的内容。因为每次申请一个新的数组对资源是极大的浪费,所以为我们使用了两个数组,然后通过 d 的值在两个数组之间切换,d 的值只能是 0 或者 1.数组 z[d] 用于读取数据,z[1-d] 用于写入数据。这就是所谓的双缓冲区
  2. 从概念上讲,迭代法版本和递归法版本的工作原理是一样的。首先它将只有一个元素的堆合并,然后合并有两个元素的堆,然后是有4个元素的堆,等等。堆的大小由 width 决定。一开始,width1,但是在每一次循环迭代结束时,我们将它的值乘以2,所以外层的循环决定了每次合并的堆的大小,并且每次循环之后待合并的子数组都会增大。
  3. 这个内部循环逐一检查堆中的每一个元素并将每一对堆合并成一个更大的堆。合并后的结果存入数组 z[1-d]
  4. 这里的逻辑和递归版本是一样的。主要的区别是使用了双缓冲区,所以数据从 z[d] 中读出来然后存入 z[1-d]。同时使用了 isOrderedBofore来比较元素,而不是单纯的比较数字大小。所以这个归并排序算法是一个通用算法,你可以用它来排序任何类型的对象。
  5. 这个时候,从z[d] 中读取出来的大小为 width 的堆已经被合并成一个大小为 width*2 的大堆并存放在 z[1-d]。在这里我们需要交换两个数组,所以下一步我们就从刚刚创建的新堆中读取数据。

这个函数是一个泛型函数,所以你可以用它来排序任何你需要的类型,只有你提供合适的 isOrderedBofore函数来比较元素。

使用范例:

        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 }

性能

归并排序算法的运行速度取决于待排序数组的大小。数组越大,需要做的事情就越多。

不管待排序的数组初始状态是否有序,都不会影响归并排序算法的运行速度,因为不管初始状态是否有序,拆分的步骤都不会变。

所有,它的时间复杂度(最优、平均、最差)都是O(n log n)

归并排序算法的一个缺点就是需要一个临时工作区,其大小与待排序数组的大小相同。它不是就地排序。

大多数归并排序算法的实现都是稳定排序。对于拥有相同排序键值的元素在排序后保持原来的相对位置不变。这一点对于一些简单的数据如数字或者字符串显得不是那么重要,但是对一些复杂的对象排序时非常重要。

本文编译自: Switf 算法俱乐部 - 归并排序

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 211,884评论 6 492
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,347评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,435评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,509评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,611评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,837评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,987评论 3 408
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,730评论 0 267
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,194评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,525评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,664评论 1 340
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,334评论 4 330
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,944评论 3 313
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,764评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,997评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,389评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,554评论 2 349

推荐阅读更多精彩内容