Go語言效能優化-兩數之和演算法效能研究

飛雪無情發表於2018-10-17

好多人都在刷leetcode,今天我也註冊了一個玩玩,發現裡面好多都是演算法題,好吧,畢業十來年,學的那點可憐的數學知識,全都還給學校了。好了閒話少說,言歸正傳,讓我們看看今天在裡面我嘗試的第一道題,有點意思, 不只是單純的演算法,還有資料和是否適合的問題。

承題

點開題庫,看了第一題,我們看看這道題:

給定一個整數陣列和一個目標值,找出陣列中和為目標值的兩個數。
你可以假設每個輸入只對應一種答案,且同樣的元素不能被重複利用。
示例:
給定 nums = [2, 7, 11, 15], target = 9
因為 nums[0] + nums[1] = 2 + 7 = 9
所以返回 [0, 1]

用了這麼多文字描述,其實總結起來就是:陣列裡那兩個數想加等於目標值,找出來這兩個數的索引。

題是不難,leetcode給出了兩種演算法:

  1. 暴力法,迴圈迭代找出來,時間複雜度O(n^2),空間複雜度是O(1)
  2. 一遍雜湊表,時間和空間複雜度都是O(n)

暴力法

我用Go語言(golang)實現了暴力法,下面看看程式碼。

func TwoSum1(nums []int, target int) []int {

	n:=len(nums)

	for i,v:=range nums {
		for j:=i+1;j<n;j++ {
			if v+nums[j] == target {
				return []int{i,j}
			}
		}
	}

	return nil
}
複製程式碼

兩層迴圈巢狀,很黃很暴力。這個演算法是如果運氣好了,迴圈兩遍就出來結果了,如果運氣不好,要找的元素正好在最後兩位,那麼真的是O(n^2)了。

雜湊法

Go語言裡有map型別,這個預設的Hash實現,基於這個我們用Golang實現雜湊法。

func TwoSum2(nums []int, target int) []int {

	m:=make(map[int]int,len(nums))

	for i,v:=range nums {
		sub:=target-v
		if j,ok:=m[sub];ok{
			return []int{j,i}
		}else{
			m[v]=i
		}
	}

	return nil
}
複製程式碼

這個演算法中規中矩,時間和空間複雜度都是O(n),如果運氣好,陣列內重複的元素多,空間佔用還會再少一些。

測試

寫好了演算法,還要測試一下,要保證結果是正確的,不能搞烏龍。

package main

import (
	"flysnow.org/hello/lib"
	"fmt"
)

func main(){
	r1:=lib.TwoSum1([]int{2, 7, 11, 15},9)
	fmt.Println(r1)
	r2:=lib.TwoSum2([]int{2, 7, 11, 15},9)
	fmt.Println(r2)
}
複製程式碼

執行輸出:

[0 1]
[0 1]
複製程式碼

和期望的結果一樣,說明我們的演算法沒有問題。

效能期望

這兩種演算法,leetcode也給了空間和時間複雜度,從我們自己的程式碼實現分析看,也是第二種雜湊法要比暴力法好的多,真實的情況真的是這樣嗎?我們用Go語言的基準測試(Benchmark),測試一下。

關於基準測試(Benchmark)可以參考 Go語言實戰筆記(二十二)| Go 基準測試 ,這裡不再詳述。

func BenchmarkTwoSum1(b *testing.B) {
	b.ResetTimer()
	for i:=0;i<b.N;i++{
		TwoSum1([]int{2, 7, 11, 15},9)
	}
}

func BenchmarkTwoSum2(b *testing.B) {
	b.ResetTimer()
	for i:=0;i<b.N;i++{
		TwoSum2([]int{2, 7, 11, 15},9)
	}
}
複製程式碼

執行➜ lib go test -bench=. -benchmem -run=none命令檢視Golang Benchmark 測試的結果。

pkg: flysnow.org/hello/lib
BenchmarkTwoSum1-8      50000000    26.9 ns/op  16 B/op   1 allocs/op
BenchmarkTwoSum2-8      20000000    73.9 ns/op  16 B/op   1 allocs/op
複製程式碼

我用的測試用例,直接用題中給的,我們發現在這種測試用例的情況下,我們不看好的暴力法,反而效能比雜湊法高出2.5倍,好像和我們想的有點不一樣。

陣列位置調整

我們看測試的陣列,答案就在陣列的前兩位,這對於暴力法來說,的確有優勢,我們把這兩個答案2、7調整到陣列的末尾,也就是測試陣列為{11, 15, 2, 7},看看測試結果。

BenchmarkTwoSum1-8      50000000    29.1 ns/op  16 B/op     1 allocs/op
BenchmarkTwoSum2-8      10000000    140 ns/op   16 B/op     1 allocs/op
複製程式碼

好吧,這一調,暴力法還是一如既往的堅挺,但是雜湊法的效能下降了1倍,把雜湊法給調死了。

擴大陣列個數

我們發現,陣列個數少的時候,暴力法是佔有優勢的,效能是最好的。下面我們調整下陣列的個數,再進行測試。

const N  = 10

func BenchmarkTwoSum1(b *testing.B) {
	nums:=[]int{}
	for i:=0;i<N;i++{
		nums=append(nums,rand.Int())
	}
	nums=append( nums,7,2)

	b.ResetTimer()
	for i:=0;i<b.N;i++{
		TwoSum1(nums,9)
	}
}

func BenchmarkTwoSum2(b *testing.B) {
	nums:=[]int{}
	for i:=0;i<N;i++{
		nums=append(nums,rand.Int())
	}
	nums=append( nums,7,2)

	b.ResetTimer()
	for i:=0;i<b.N;i++{
		TwoSum2(nums,9)
	}
}
複製程式碼

仔細看上面的程式碼,我採用自動隨機生成陣列元素的方式,但是為了保證答案,陣列的最後兩位還是7,2。 先測試下陣列大小為10個的情況。

BenchmarkTwoSum1-8      20000000    73.3 ns/op  16 B/op     1 allocs/op
BenchmarkTwoSum2-8       2000000    660 ns/op   318 B/op    2 allocs/op
複製程式碼

10個元素是,暴力法比雜湊法的效能快10倍。

繼續調整陣列大小為50,直接修改常量N就好了,測試50個元素的情況。

BenchmarkTwoSum1-8       2000000    984 ns/op   16 B/op     1 allocs/op
BenchmarkTwoSum2-8        500000    3200 ns/op  1451 B/op   6 allocs/op
複製程式碼

隨著陣列大小的增加,雜湊法的優勢開始凸現,50個陣列元素時,相差只有4倍。

從不斷的增加陣列的大小開始,在我的電腦上,當陣列的大小為300時,兩者打平,效能一樣。

當陣列大小為1000時,雜湊法的效能已經是暴力法的4倍,反過來了。

當陣列大小為10000時,雜湊法的效能已經是暴力法的20倍,測試資料如下:

BenchmarkTwoSum1-8      100     21685955 ns/op      16 B/op         1 allocs/op
BenchmarkTwoSum2-8      2000    641821 ns/op        322237 B/op     12 allocs/op
複製程式碼

從基準測試的資料來看,陣列越大,每次操作耗費的時間越長,但是暴力法的耗時增長太大,導致效能低下。

從資料中也可以看出,雜湊法是空間換時間的方式,記憶體佔用和分配都比較大。

小結

從這測試和效能分析來看,不存在最優的演算法,只存在最合適的。

如果你的陣列元素比較少,那麼暴力演算法是更適合你的。 如果陣列元素非常多,那麼採用雜湊演算法就是一個比較好的選擇了。

所以,根據我們自己系統的實際情況,來選擇合適的演算法,比如動態判斷陣列的大小,採用不同的演算法,達到最大的效能。

本文為原創文章,轉載註明出處,「總有爛人抓取文章的時候還去掉我的原創說明」歡迎掃碼關注公眾號flysnow_org或者網站www.flysnow.org/,第一時間看後續精彩文章。「防爛人備註**……&*¥」覺得好的話,順手分享到朋友圈吧,感謝支援。

掃碼關注

相關文章