本站点使用cookies,继续浏览表示您同意我们使用cookies。Cookies和隐私政策

开源软件优化-Go语言归并排序算法调优

2021年08月25日

排序算法是一种能将一系列数据按照特定顺序进行排列的算法,比如说一个学校的考试分数从高到低排名、一个公司的数据报表从大到小排列,都需要用到排序算法。常见的排序算法有冒泡排序、快速排序、字典排序、归并排序、堆排序等等,而归并排序是其中的一种较为稳定、高效的排序算法,时间复杂度N*log2N。 本文通过Go语言开源社区的归并排序算法优化案例为例,讲解通用的算法分析、优化方法。

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]的合并有序场景,主要处理过程如下:

  1. 调用递归函数symMerge,初始化切片参数。
  2. 经过折半查找、对称比较、翻转等方法使得切片data[0:20]整体小于切片data[20:40]
  3. 再次调用递归函数symMerge归并排序子切片data[0:start]和data[start:20],回到步骤1
  4. 再次调用递归函数symMerge归并排序子切片data[20:end]和data[end:40],回到步骤1
  5. 直到排序完所有的子切片,使得整个切片data[0:40]有序。

从这个常见的应用场景的处理过程中发现,归并排序需要不断调用递归函数处理子序列,而这也是归并排序算法的性能损耗的主要原因。在切片的归并排序场景中,如果能避免或减少调用递归处理子切片,算法的运行性能就可以得到提升。比如说在长度为1的子切片data[0]和长度为9的子切片data[1:10]进行归并排序的场景下,使用插入排序来避免调用递归,可以减少算法的运行耗时,优化算法的性能。

3. 优化方案及实现

通过上述对Go语言归并排序算法的代码和场景分析,找到了一种优化方法,即在归并排序中,针对长度为1的数据块,利用二分插入排序算法处理数据块排序,可以减少函数递归的调用,提高算法的性能。 Go语言开源社区也应用了基于二分插入排序的优化方法,提升了归并排序算法的性能,优化前后的代码对比如下: image

优化代码如下:

// 优化代码增加了对切片长度的判断,且当切片长度为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表示函数每次执行的平均纳秒耗时。

上述表格显示了在字符串切片、整型切片、结构体切片等不同数据类型的场景下的性能测试结果,对比分析如下:

  • 对比长度为1K的字符串和整型切片数据,优化后字符串切片的递归次数减少了28.8%,而整型切片的递归次数只减少了14.9%,说明优化技术在字符串切片数据集上发挥了更大的作用,性能提升更大;
  • 对比长度为64K和1K的整型切片数据,优化后二者的递归总次数减少的比例都在14.8%左右,性能提升相近在0.5%-1%之间;
  • 对比长度为1e2\1e4\1e6的结构体切片数据,优化后三者的递归总次数都减少在22%左右,性能提升基本一样在7%左右。

通过性能测试结果的对比分析,发现优化后的性能提升大小跟测试数据集的关联性大,算法的递归嵌套中出现数据切片长度为1的情况越多,优化方法发挥的作用就越大,递归次数减少的更多,性能提升也会更大。

5. 总结

Go语言的归并排序算法优化案例,从一个具体的场景出发分析归并排序算法存在的性能问题,给出了基于二分插入排序的优化方案,并最终验证了优化结果,是一个值得学习借鉴的算法优化实践。