时间复杂度更低程序执行却更慢?

昨天做一道算法题,发现时间复杂度更低的算法程序执行却更慢。在分析的过程中我对于程序和算法的性能有了更加深入的理解。下文仅限于Go语言实现,提交是在英文 leetcode 网站,与中文网站结果不同,不知道测算的方式有没有差别。

leetcode 239题 - 数据的滑动窗口最大值

我只想到了暴力解法,每次滑动丢弃和新加入值以后判断窗口内的最大值,时间复杂度是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 时,才使用快速排序。

猜你喜欢

转载自blog.csdn.net/qq_35753140/article/details/108033700