昨天做一道算法题,发现时间复杂度更低的算法程序执行却更慢。在分析的过程中我对于程序和算法的性能有了更加深入的理解。下文仅限于Go语言实现,提交是在英文 leetcode 网站,与中文网站结果不同,不知道测算的方式有没有差别。
我只想到了暴力解法,每次滑动丢弃和新加入值以后判断窗口内的最大值,时间复杂度是O((n-k+1)*k)。官方推荐使用双端队列来实现线性时间复杂度,我在 小浩算法 看到他用暴力解法的结果性能不好。
然而我用双端队列AC以后看最快的答案竟然是暴力的方法。 难道是leetcode测算不准确?我自己做 benchmark 验证一下吧。
这是答案里最快的暴力算法:
func maxSlidingWindow(nums []int, k int) []int {
res := make([]int, (len(nums)+1)-k)
max := -10000
for i := 0; i <= len(nums)-k; i++ {
if max > -10000 && nums[i-1] != max && nums[i+k-1] < max {
res[i] = max
continue
} else {
max = -10000
}
for _, v := range nums[i : i+k] {
if v > max {
max = v
}
}
res[i] = max
}
return res
}
我用一个简单的用例做测试:
func Benchmark_maxSlidingWindow(b *testing.B) {
a := []int{9, 10, 9, -7, -4, -8, 2, -6}
for i := 0; i < b.N; i++ {
_ = maxSlidingWindow(a, 5)
}
}
结果是 38.4 ns/op
Benchmark_maxSlidingWindow-8 27957754 38.4 ns/op 32 B/op 1 allocs/op
这是双端队列的算法:
func maxSlidingWindow(nums []int, k int) []int {
res := make([]int, (len(nums)+1)-k)
win := make([]int, 0, k)
for i, v := range nums {
for len(win) > 0 && win[len(win)-1] < v {
win = win[:len(win)-1]
}
win = append(win, v)
if i >= k && win[0] == nums[i-k] {
win = win[1:]
}
if i+1 >= k {
res[i-k+1] = win[0]
}
}
return res
}
结果是 172 ns/op
Benchmark_maxSlidingWindow-8 6647554 172 ns/op 112 B/op 6 allocs/op
暴力算法的确要快很多,benchmark 的结果给了我启发:双端队列做了6次内存分配而暴力解法只做了一次。这不仅导致了更高的内存消耗,也是算法时间复杂度更低但程序耗时更长的原因。
在双端队列的算法中,创建了两个 slice,而且向 slice 添加元素的操作都是 append
,使得程序进行了多次内存分配和拷贝。暴力解法只创建了一个 slice 并且声明了其长度,修改时直接对元素赋值而不用 append
,就不再需要内存分配的操作。
对双端队列方法进行一些优化:
func maxSlidingWindow(nums []int, k int) []int {
res := make([]int, (len(nums)+1)-k)
win := make([]int, 0, k)
for i, v := range nums {
if i >= k && win[0] == i-k {
win = win[1:]
}
for len(win) > 0 && nums[win[len(win)-1]] <= v {
win = win[:len(win)-1]
}
win = append(win, i)
if i+1 >= k {
res[i-k+1] = nums[win[0]]
}
}
return res
}
结果是 67.0 ns/op, 耗时缩减60%
Benchmark_maxSlidingWindow-8 17957082 67.0 ns/op 80 B/op 2 allocs/op
速度快了很多,但是仍然由于多创建了一个 slice 而慢于暴力方法。
通过 pprof 分析改善前的程序可以看到,内存和扩容slice的操作比算法代码执行消耗的 CPU 时间更多,算法的性能对程序执行整体性能的影响就小了。
flat flat% sum% cum cum%
0.33s 23.24% 23.24% 0.83s 58.45% runtime.mallocgc
0.26s 18.31% 41.55% 1.18s 83.10% runtime.growslice
0.22s 15.49% 57.04% 1.42s 100% algorithm/sliding_window.maxSlidingWindow
0.15s 10.56% 67.61% 0.15s 10.56% runtime.nextFreeFast (inline)
0.07s 4.93% 72.54% 0.07s 4.93% runtime.memclrNoHeapPointers
0.05s 3.52% 76.06% 0.05s 3.52% runtime.pageIndexOf (inline)
0.04s 2.82% 78.87% 0.04s 2.82% runtime.memmove
0.04s 2.82% 81.69% 0.04s 2.82% runtime.releasem (inline)
0.02s 1.41% 83.10% 0.02s 1.41% runtime.(*mspan).objIndex (inline)
0.02s 1.41% 84.51% 0.02s 1.41% runtime.(*spanSet).pop
正好昨天看到 Go语言中文网 的一篇文章《学习 Rob Pike 的 6 条编程原则》,其中一段关于“算法复杂度与处理的数据量之间的关系”的观点很适合作为总结:
Unix 之父,也是 Go 联合创始人 Ken Thompson 针对原则 3 和原则 4 进一步强调:拿不准就穷举。简单、暴力的方法很多时候是最好的选择。通常我们认为快速排序是“最快”的排序算法,然而我们日常碰到的问题,待排序的数一般都较少,这时候简单的冒泡排序更适合。如果你是一个有心的读者会发现编程语言中的排序算法实现,会根据不同的数据量选择不同的排序算法。比如 Go 语言排序算法的实现,当元素个数超过 12 时,才使用快速排序。