排序算法是一種能將一系列數據按照特定順序進行排列的算法,好比說一個學校的考試分數從高到低排名、一個公司的數據報表從大到小排列,都須要用到排序算法。常見的排序算法有冒泡排序、快速排序、字典排序、歸併排序、堆排序等等,而歸併排序是其中的一種較爲穩定、高效的排序算法,時間複雜度N*log2N。 本文經過Go語言開源社區的歸併排序算法優化案例爲例,講解通用的算法分析、優化方法。git
摘自OptimizeLab: https://github.com/OptimizeLab/docsgithub
做者:zaneChou1 golang
1. Go語言的歸併排序算法
Go語言的sort包對插入排序、歸併排序、快速排序、堆排序作了支持,歸併排序是sort包實現的穩定排序算法。 Go語言基於遞歸實現了歸併排序算法,代碼以下:算法
// 優化前的歸併排序代碼,使用遞歸實現兩個有序子序列data[a:m]和data [m:b]的合併 func symMerge(data Interface, a, m, b int) { // 初始化mid,start,r的值,mid表示[a:b]的中位數,start表示[a:m]區間進行數據交換的開始下標,r表示[a:m]區間進行數據交換的最右下標 mid := int(uint(a+b) >> 1) n := mid + m var start, r int if m > mid { start = n - b r = mid } else { start = a r = m } p := n - 1 // 折半查找對稱比較數據塊[a:m]和數據塊[m:b],找出數據塊[a:m]開始大於數據[m:b]的下標位置start for start < r { c := int(uint(start+r) >> 1) if !data.Less(p-c, c) { start = c + 1 } else { r = c } } // 翻轉數據塊[start:m] 和 [m:end], 使得數據塊[start:end]有序 end := n - start if start < m && m < end { rotate(data, start, m, end) } // 調用遞歸函數,排序兩組子數據塊[a:start]和[start:mid],[mid:end]和[end:b] if a < start && start < mid { symMerge(data, a, start, mid) } if mid < end && end < b { symMerge(data, mid, end, b) } }
2. 場景及問題分析
歸併排序算法的應用場景主要是數據塊有序歸併,好比說有序切片data[0:20]和data[20:40]的合併有序場景,主要處理過程以下:函數
- 調用遞歸函數symMerge,初始化切片參數。
- 通過折半查找、對稱比較、翻轉等方法使得切片data[0:20]總體小於切片data[20:40]
- 再次調用遞歸函數symMerge歸併排序子切片data[0:start]和data[start:20],回到步驟1
- 再次調用遞歸函數symMerge歸併排序子切片data[20:end]和data[end:40],回到步驟1
- 直到排序完全部的子切片,使得整個切片data[0:40]有序。
從這個常見的應用場景的處理過程當中發現,歸併排序須要不斷調用遞歸函數處理子序列,而這也是歸併排序算法的性能損耗的主要緣由。在切片的歸併排序場景中,若是能避免或減小調用遞歸處理子切片,算法的運行性能就能夠獲得提高。好比說在長度爲1的子切片data[0]和長度爲9的子切片data[1:10]進行歸併排序的場景下,使用插入排序來避免調用遞歸,能夠減小算法的運行耗時,優化算法的性能。性能
3. 優化方案及實現
經過上述對Go語言歸併排序算法的代碼和場景分析,找到了一種優化方法,即在歸併排序中,針對長度爲1的數據塊,利用二分插入排序算法處理數據塊排序,能夠減小函數遞歸的調用,提升算法的性能。 Go語言開源社區也應用了基於二分插入排序的優化方法,提高了歸併排序算法的性能,優化先後的代碼對好比下:學習
優化代碼以下:測試
// 優化代碼增長了對切片長度的判斷,且當切片長度爲1時,使用二分插入排序直接排序切片返回。 func symMerge(data Interface, a, m, b int) { // data[a:m]只有一個元素時,使用二分插入排序找到有序的插入點,插入data[a]到data[m:b] // 避免調用遞歸,提升算法性能 if m-a == 1 { i := m j := b // 二分查找,找到合適的有序插入點 for i < j { h := int(uint(i+j) >> 1) if data.Less(h, a) { i = h + 1 } else { j = h } } // 插入數據 for k := a; k < i-1; k++ { data.Swap(k, k+1) } return } // data[m:b]只有一個元素時,使用二分插入排序找到合適的有序插入點,插入data[m]到data[a:m] // 避免調用遞歸,提升算法性能 if b-m == 1 { i := a j := m // 二分查找,找到合適的有序插入點 for i < j { h := int(uint(i+j) >> 1) if !data.Less(m, h) { i = h + 1 } else { j = h } } // 插入數據 for k := m; k > i; k-- { data.Swap(k, k-1) } return } // 歸併排序的遞歸實現 ...... }
4. 優化結果
使用Go benchmark測試優化先後的算法性能,再用Go benchstat對比優化先後的性能測試結果,並統計了優化先後遞歸函數的調用次數,整理到以下表格:優化
測試項 | 測試用例 | 優化前每操做耗時 time/op | 優化後每操做耗時 time/op | 耗時對比 | 優化前遞歸總次數 | 優化後遞歸總次數 | 遞歸總次數對比 |
---|---|---|---|---|---|---|---|
BenchmarkStableString1K-8 | 長度1K的字符串切片 | 302278 ns/op | 288879 ns/op | -2.71% | 1110 | 790 | -28.8% |
BenchmarkStableInt1K-8 | 長度1K的整型切片 | 144207 ns/op | 139911 ns/op | -0.96% | 689 | 586 | -14.9% |
BenchmarkStableInt64K-8 | 長度64K的整型切片 | 12291195 ns/op | 12119536 ns/op | -0.75% | 48465 | 41280 | -14.8% |
BenchmarkStable1e2-8 | 長度1e2(100)的結構體切片 | 135357 ns/op | 124875 ns/op | -7.17% | 1079 | 807 | -25.2% |
BenchmarkStable1e4-8 | 長度1e4(10000)的結構體切片 | 43507732 ns/op | 40183173 ns/op | -7.64% | 449735 | 350205 | -22.1% |
BenchmarkStable1e6-8 | 長度1e6(1000000)的結構體切片 | 9005038733 ns/op | 8440007994 ns/op | -7.69% | 79347843 | 61483110 | -22.5% |
[注]-8
表示函數運行時的GOMAXPROCS值,ns/op
表示函數每次執行的平均納秒耗時。ui
上述表格顯示了在字符串切片、整型切片、結構體切片等不一樣數據類型的場景下的性能測試結果,對比分析以下:
- 對比長度爲1K的字符串和整型切片數據,優化後字符串切片的遞歸次數減小了28.8%,而整型切片的遞歸次數只減小了14.9%,說明優化技術在字符串切片數據集上發揮了更大的做用,性能提高更大;
- 對比長度爲64K和1K的整型切片數據,優化後兩者的遞歸總次數減小的比例都在14.8%左右,性能提高相近在0.5%-1%之間;
- 對比長度爲1e2\1e4\1e6的結構體切片數據,優化後三者的遞歸總次數都減小在22%左右,性能提高基本同樣在7%左右。
經過性能測試結果的對比分析,發現優化後的性能提高大小跟測試數據集的關聯性大,算法的遞歸嵌套中出現數據切片長度爲1的狀況越多,優化方法發揮的做用就越大,遞歸次數減小的更多,性能提高也會更大。
5. 總結
Go語言的歸併排序算法優化案例,從一個具體的場景出發分析歸併排序算法存在的性能問題,給出了基於二分插入排序的優化方案,並最終驗證了優化結果,是一個值得學習借鑑的算法優化實踐。
若是對開源或優化技術感興趣,歡迎下方留言或者經過https://github.com/OptimizeLab/docs/issues聯繫咱們。