數據結構和算法(Golang實現)(23)排序算法-歸併排序

歸併排序

歸併排序是一種分治策略的排序算法。它是一種比較特殊的排序算法,經過遞歸地先使每一個子序列有序,再將兩個有序的序列進行合併成一個有序的序列。算法

歸併排序首先由著名的現代計算機之父John_von_Neumann1945年發明,被用在了EDVAC(一臺美國早期電子計算機),足足用墨水寫了 23 頁的排序程序。注:馮·諾依曼(John von Neumann,1903年12月28日-1957年2月8日),美籍匈牙利數學家、計算機科學家、物理學家,是20世紀最重要的數學家之一。segmentfault

1、算法介紹

咱們先介紹兩個有序的數組合併成一個有序數組的操做。數組

  1. 先申請一個輔助數組,長度等於兩個有序數組長度的和。
  2. 從兩個有序數組的第一位開始,比較兩個元素,哪一個數組的元素更小,那麼該元素添加進輔助數組,而後該數組的元素變動爲下一位,繼續重複這個操做,直至數組沒有元素。
  3. 返回輔助數組。

舉一個例子:數據結構

有序數組A:[3 8 9 11 13]
有序數組B:[1 5 8 10 17 19 20 23]
[] 表示比較的範圍。

由於 1 < 3,因此 1 加入輔助數組
有序數組A:[3 8 9 11 13]
有序數組B:1 [5 8 10 17 19 20 23] 
輔助數組:1

由於 3 < 5,因此 3 加入輔助數組
有序數組A:3 [8 9 11 13]
有序數組B:1 [5 8 10 17 19 20 23] 
輔助數組:1 3

由於 5 < 8,因此 5 加入輔助數組
有序數組A:3 [8 9 11 13]
有序數組B:1 5 [8 10 17 19 20 23] 
輔助數組:1 3 5

由於 8 == 8,因此 兩個數都 加入輔助數組
有序數組A:3 8 [9 11 13]
有序數組B:1 5 8 [10 17 19 20 23] 
輔助數組:1 3 5 8 8

由於 9 < 10,因此 9 加入輔助數組
有序數組A:3 8 9 [11 13]
有序數組B:1 5 8 [10 17 19 20 23] 
輔助數組:1 3 5 8 8 9

由於 10 < 11,因此 10 加入輔助數組
有序數組A:3 8 9 [11 13]
有序數組B:1 5 8 10 [17 19 20 23] 
輔助數組:1 3 5 8 8 9 10

由於 11 < 17,因此 11 加入輔助數組
有序數組A:3 8 9 11 [13]
有序數組B:1 5 8 10 [17 19 20 23] 
輔助數組:1 3 5 8 8 9 10 11

由於 13 < 17,因此 13 加入輔助數組
有序數組A:3 8 9 11 13
有序數組B:1 5 8 10 [17 19 20 23] 
輔助數組:1 3 5 8 8 9 10 11 13

由於數組A已經沒有比較元素,將數組B剩下的元素拼接在輔助數組後面。

結果:1 3 5 8 8 9 10 11 13 17 19 20 23

將兩個有序數組進行合併,最多進行n次比較就能夠生成一個新的有序數組,n是兩個數組長度較大的那個。併發

歸併操做最壞的時間複雜度爲:O(n),其中n是較長數組的長度。app

歸併操做最好的時間複雜度爲:O(n),其中n是較短數組的長度。數據結構和算法

正是利用這個特色,歸併排序先排序較小的數組,再將有序的小數組合並造成更大有序的數組。函數

歸併排序有兩種遞歸作法,一種是自頂向下,一種是自底向上。優化

1.1. 自頂向下歸併排序

從一個大數組開始,不斷地往下切分,如圖:spa

從上往下進行遞歸,直到切分的小數組沒法切分了,而後不斷地對這些有序數組進行合併。

每次都是一分爲二,特別均勻,因此最差和最壞時間複雜度都同樣。歸併操做的時間複雜度爲:O(n),所以總的時間複雜度爲:T(n)=2T(n/2)+O(n),根據主定理公式能夠知道時間複雜度爲:O(nlogn)。咱們能夠本身計算一下:

歸併排序,每次歸併操做比較的次數爲兩個有序數組的長度: n/2

T(n) = 2*T(n/2) + n/2
T(n/2) = 2*T(n/4) + n/4
T(n/4) = 2*T(n/8) + n/8
T(n/8) = 2*T(n/16) + n/16
...
T(4) = 2*T(2) + 4
T(2) = 2*T(1) + 2
T(1) = 1

進行合併也就是:

T(n) = 2*T(n/2) + n/2
     = 2^2*T(n/4)+ n/2 + n/2
     = 2^3*T(n/8) + n/2 + n/2 + n/2
     = 2^4*T(n/16) + n/2 + n/2 + n/2 + n/2
     = ...
     = 2^logn*T(1) + logn * n/2
     = 2^logn + 1/2*nlogn
     = n + 1/2*nlogn

由於當問題規模 n 趨於無窮大時 nlogn 比 n 大,因此 T(n) = O(nlogn)。

所以時間複雜度爲:O(nlogn)。

由於不斷地遞歸,程序棧層數會有logn層,因此遞歸棧的空間複雜度爲:O(logn),對於排序十億個整數,也只要:log(100 0000 0000)=29.897,佔用的堆棧層數最多30層憂。

1.2. 自底向上歸併排序

從小數組開始排序,不斷地合併造成更大的有序數組。

時間複雜度和自頂向上歸併排序同樣,也都是O(nlogn)

由於不須要使用遞歸,沒有程序棧佔用,所以遞歸棧的空間複雜度爲:O(1)

2、算法實現

自頂向下的歸併排序遞歸實現:

package main

import "fmt"

// 自頂向下歸併排序,排序範圍在 [begin,end) 的數組
func MergeSort(array []int, begin int, end int) {
    // 元素數量大於1時才進入遞歸
    if end-begin > 1 {

        // 將數組一分爲二,分爲 array[begin,mid) 和 array[mid,high)
        mid := begin + (end-begin+1)/2

        // 先將左邊排序好
        MergeSort(array, begin, mid)

        // 再將右邊排序好
        MergeSort(array, mid, end)

        // 兩個有序數組進行合併
        merge(array, begin, mid, end)
    }
}

// 歸併操做
func merge(array []int, begin int, mid int, end int) {
    // 申請額外的空間來合併兩個有序數組,這兩個數組是 array[begin,mid),array[mid,end)
    leftSize := mid - begin         // 左邊數組的長度
    rightSize := end - mid          // 右邊數組的長度
    newSize := leftSize + rightSize // 輔助數組的長度
    result := make([]int, 0, newSize)

    l, r := 0, 0
    for l < leftSize && r < rightSize {
        lValue := array[begin+l] // 左邊數組的元素
        rValue := array[mid+r]   // 右邊數組的元素
        // 小的元素先放進輔助數組裏
        if lValue < rValue {
            result = append(result, lValue)
            l++
        } else {
            result = append(result, rValue)
            r++
        }
    }

    // 將剩下的元素追加到輔助數組後面
    result = append(result, array[begin+l:mid]...)
    result = append(result, array[mid+r:end]...)

    // 將輔助數組的元素複製回原數組,這樣該輔助空間就能夠被釋放掉
    for i := 0; i < newSize; i++ {
        array[begin+i] = result[i]
    }
    return
}

func main() {
    list := []int{5}
    MergeSort(list, 0, len(list))
    fmt.Println(list)

    list1 := []int{5, 9}
    MergeSort(list1, 0, len(list1))
    fmt.Println(list1)

    list2 := []int{5, 9, 1, 6, 8, 14, 6, 49, 25, 4, 6, 3}
    MergeSort(list2, 0, len(list2))
    fmt.Println(list2)
}

輸出:

[5]
[5 9]
[1 3 4 5 6 6 6 8 9 14 25 49]

自頂向下遞歸排序,咱們能夠看到每次合併都要申請一個輔助數組,而後合併完再賦值回原數組,這樣每次合併後輔助數組的內存就能夠釋放掉,存儲空間佔用n,而程序遞歸棧依舊是logn層。

自底向上的非遞歸實現:

package main

import "fmt"

// 自底向上歸併排序
func MergeSort2(array []int, begin, end int) {

    // 步數爲1開始,step長度的數組表示一個有序的數組
    step := 1

    // 範圍大於 step 的數組才能夠進入歸併
    for end-begin > step {
        // 從頭至尾對數組進行歸併操做
        // step << 1 = 2 * step 表示偏移到後兩個有序數組將它們進行歸併
        for i := begin; i < end; i += step << 1 {
            var lo = i                // 第一個有序數組的上界
            var mid = lo + step       // 第一個有序數組的下界,第二個有序數組的上界
            var hi = lo + (step << 1) // 第二個有序數組的下界

            // 不存在第二個數組,直接返回
            if mid > end {
                return
            }

            // 第二個數組長度不夠
            if hi > end {
                hi = end
            }

            // 兩個有序數組進行合併
            merge(array, lo, mid, hi)
        }

        // 上面的 step 長度的兩個數組都歸併成一個數組了,如今步長翻倍
        step <<= 1
    }
}

// 歸併操做
func merge(array []int, begin int, mid int, end int) {
    // 申請額外的空間來合併兩個有序數組,這兩個數組是 array[begin,mid),array[mid,end)
    leftSize := mid - begin         // 左邊數組的長度
    rightSize := end - mid          // 右邊數組的長度
    newSize := leftSize + rightSize // 輔助數組的長度
    result := make([]int, 0, newSize)

    l, r := 0, 0
    for l < leftSize && r < rightSize {
        lValue := array[begin+l] // 左邊數組的元素
        rValue := array[mid+r]   // 右邊數組的元素
        // 小的元素先放進輔助數組裏
        if lValue < rValue {
            result = append(result, lValue)
            l++
        } else {
            result = append(result, rValue)
            r++
        }
    }

    // 將剩下的元素追加到輔助數組後面
    result = append(result, array[begin+l:mid]...)
    result = append(result, array[mid+r:end]...)

    // 將輔助數組的元素複製回原數組,這樣該輔助空間就能夠被釋放掉
    for i := 0; i < newSize; i++ {
        array[begin+i] = result[i]
    }
    return
}

func main() {
    list := []int{5}
    MergeSort2(list, 0, len(list))
    fmt.Println(list)

    list1 := []int{5, 9}
    MergeSort2(list1, 0, len(list1))
    fmt.Println(list1)

    list2 := []int{5, 9, 1, 6, 8, 14, 6, 49, 25, 4, 6, 3}
    MergeSort2(list2, 0, len(list2))
    fmt.Println(list2)
}

輸出:

[5]
[5 9]
[1 3 4 5 6 6 6 8 9 14 25 49]

自底向上非遞歸排序,咱們能夠看到沒有遞歸那樣程序棧的增長,效率比自頂向上的遞歸版本高

3、算法改進

歸併排序歸併操做佔用了額外的輔助數組,且歸併操做是從一個元素的數組開始。

咱們能夠作兩點改進:

  1. 對於小規模數組,使用直接插入排序。
  2. 原地排序,節約掉輔助數組空間的佔用。

咱們建議使用自底向上非遞歸排序,不會有程序棧空間損耗。

咱們先來介紹一種翻轉算法,也叫手搖算法,主要用來對數組兩部分進行位置互換,好比數組:[9,8,7,1,2,3],將前三個元素與後面的三個元素交換位置,變成[1,2,3,9,8,7]

再好比,將字符串abcde1234567的前5個字符與後面的字符交換位置,那麼手搖後變成:1234567abcde

如何翻轉呢?

  1. 將前部分逆序
  2. 將後部分逆序
  3. 對總體逆序

示例以下:

翻轉 [1234567abcde] 的前5個字符。

1. 分紅兩部分:[abcde][1234567]
2. 分別逆序變成:[edcba][7654321]
3. 總體逆序:[1234567abcde]

歸併原地排序利用了手搖算法的特徵,不須要額外的輔助數組。

首先,兩個有序的數組,分別是arr[begin,mid-1],arr[mid,end],此時初始化i=beginj=midk=end,從i~j爲左有序的數組,k~j爲右有序的數組,如圖:

i向後移動,找到第一個arr[i]>arr[j]的索引,這個時候,i前面的部分已經排好序了,begin~i這些元素已是兩個有序數組的前n小個元素。如圖:

而後將j向後移動,找到第一個arr[j]>arr[i]的索引,如圖:

這個時候,mid~j中的元素都小於arr[i],前面已經知道從begin~i已是前n小了,因此這兩部分begin~i,mid~j也是有序的了,咱們要想辦法將這兩部分鏈接在一塊兒。

咱們只需進行翻轉,將i~midmid,j-1部分進行位置互換便可,咱們能夠用手搖算法。

具體的代碼以下:

package main

import "fmt"

func InsertSort(list []int) {
    n := len(list)
    // 進行 N-1 輪迭代
    for i := 1; i <= n-1; i++ {
        deal := list[i] // 待排序的數
        j := i - 1      // 待排序的數左邊的第一個數的位置

        // 若是第一次比較,比左邊的已排好序的第一個數小,那麼進入處理
        if deal < list[j] {
            // 一直往左邊找,比待排序大的數都日後挪,騰空位給待排序插入
            for ; j >= 0 && deal < list[j]; j-- {
                list[j+1] = list[j] // 某數後移,給待排序留空位
            }
            list[j+1] = deal // 結束了,待排序的數插入空位
        }
    }
}

// 自底向上歸併排序優化版本
func MergeSort3(array []int, n int) {
    // 按照三個元素爲一組進行小數組排序,使用直接插入排序
    blockSize := 3
    a, b := 0, blockSize
    for b <= n {
        InsertSort(array[a:b])
        a = b
        b += blockSize
    }
    InsertSort(array[a:n])

    // 將這些小數組進行歸併
    for blockSize < n {
        a, b = 0, 2*blockSize
        for b <= n {
            merge(array, a, a+blockSize, b)
            a = b
            b += 2 * blockSize
        }
        if m := a + blockSize; m < n {
            merge(array, a, m, n)
        }
        blockSize *= 2
    }
}

// 原地歸併操做
func merge(array []int, begin, mid, end int) {
    // 三個下標,將數組 array[begin,mid] 和 array[mid,end-1]進行原地歸併
    i, j, k := begin, mid, end-1 // 由於數組下標從0開始,因此 k = end-1

    for j-i > 0 && k-j >= 0 {
        step := 0
        // 從 i 向右移動,找到第一個 array[i]>array[j]的索引
        for j-i > 0 && array[i] <= array[j] {
            i++
        }

        // 從 j 向右移動,找到第一個 array[j]>array[i]的索引
        for k-j >= 0 && array[j] <= array[i] {
            j++
            step++
        }

        // 進行手搖翻轉,將 array[i,mid] 和 [mid,j-1] 進行位置互換
        // mid 是從 j 開始向右出發的,因此 mid = j-step
        rotation(array, i, j-step, j-1)
        i = i + step
    }

}

// 手搖算法,將 array[l,l+1,l+2,...,mid-2,mid-1,mid,mid+1,mid+2,...,r-2,r-1,r] 從mid開始兩邊交換位置
// 1.先逆序前部分:array[mid-1,mid-2,...,l+2,l+1,l]
// 2.後逆序後部分:array[r,r-1,r-2,...,mid+2,mid+1,mid]
// 3.上兩步完成後:array[mid-1,mid-2,...,l+2,l+1,l,r,r-1,r-2,...,mid+2,mid+1,mid]
// 4.總體逆序: array[mid,mid+1,mid+2,...,r-2,r-1,r,l,l+1,l+2,...,mid-2,mid-1]
func rotation(array []int, l, mid, r int) {
    reverse(array, l, mid-1)
    reverse(array, mid, r)
    reverse(array, l, r)
}

func reverse(array []int, l, r int) {
    for l < r {
        // 左右互相交換
        array[l], array[r] = array[r], array[l]
        l++
        r--
    }
}

func main() {
    list := []int{5}
    MergeSort3(list, len(list))
    fmt.Println(list)

    list1 := []int{5, 9}
    MergeSort3(list1, len(list1))
    fmt.Println(list1)

    list2 := []int{5, 9, 1, 6, 8, 14, 6, 49, 25, 4, 6, 3}
    MergeSort3(list2, len(list2))
    fmt.Println(list2)

    list3 := []int{5, 9, 1, 6, 8, 14, 6, 49, 25, 4, 6, 3, 45, 67, 2, 5, 24, 56, 34, 24, 56, 2, 2, 21, 4, 1, 4, 7, 9}
    MergeSort3(list3, len(list3))
    fmt.Println(list3)
}

輸出:

[5]
[5 9]
[1 3 4 5 6 6 6 8 9 14 25 49]
[1 1 2 2 2 3 4 4 4 5 5 6 6 6 7 8 9 9 14 21 24 24 25 34 45 49 56 56 67]

咱們自底開始,將元素按照數量爲blockSize進行小數組排序,使用直接插入排序,而後咱們對這些有序的數組向上進行歸併操做。

歸併過程當中,使用原地歸併,用了手搖算法,代碼以下:

func rotation(array []int, l, mid, r int) {
    reverse(array, l, mid-1)
    reverse(array, mid, r)
    reverse(array, l, r)
}

由於手搖只多了逆序翻轉的操做,時間複雜度是O(n),雖然時間複雜度稍稍多了一點,但存儲空間複雜度降爲了O(1)

歸併排序是惟一一個有穩定性保證的高級排序算法,某些時候,爲了尋求大規模數據下排序先後,相同元素位置不變,可使用歸併排序。

系列文章入口

我是陳星星,歡迎閱讀我親自寫的 數據結構和算法(Golang實現),文章首發於 閱讀更友好的GitBook

相關文章
相關標籤/搜索