Go 中 slice 的 In 功能實現探索

本文首發於個人博客,若是以爲有用,歡迎點贊收藏。python

以前在知乎看到一個問題:爲何 Golang 沒有像 Python 中 in 同樣的功能?因而,搜了下這個問題,發現仍是有很多人有這樣的疑問。git

今天來談談這個話題。github

in 是一個很經常使用的功能,有些語言中可能也稱爲 contains,雖然不一樣語言的表示不一樣,但基本都是有的。不過惋惜的是,Go 卻沒有,它即沒有提供相似 Python 操做符 in,也沒有像其餘語言那樣提供這樣的標準庫函數,如 PHP 中 in_array。算法

Go 的哲學是追求少便是多。我想或許 Go 團隊以爲這是一個實現起來不足爲道的功能吧。數組

爲什麼說微不足道?若是要本身實現,又該如何作呢?bash

我所想到的有三種實現方式,一是遍歷,二是 sort 的二分查找,三是 map 的 key 索引。微信

本文相關源碼已經上傳在個人 github 上,poloxue/gotindom

遍歷

遍歷應該是咱們最容易想到的最簡單的實現方式。函數

示例以下:性能

func InIntSlice(haystack []int, needle int) bool {
	for _, e := range haystack {
		if e == needle {
			return true
		}
	}

	return false
}
複製代碼

上面演示瞭如何在一個 []int 類型變量中查找指定 int 是否存在的例子,是否是很是簡單,由此咱們也能夠感覺到我爲何說它實現起來微不足道。

這個例子有個缺陷,它只支持單一類型。若是要支持像解釋語言同樣的通用 in 功能,則需藉助反射實現。

代碼以下:

func In(haystack interface{}, needle interface{}) (bool, error) {
	sVal := reflect.ValueOf(haystack)
	kind := sVal.Kind()
	if kind == reflect.Slice || kind == reflect.Array {
		for i := 0; i < sVal.Len(); i++ {
			if sVal.Index(i).Interface() == needle {
				return true, nil
			}
		}

		return false, nil
	}

	return false, ErrUnSupportHaystack
}
複製代碼

爲了更加通用,In 函數的輸入參數 haystack 和 needle 都是 interface{} 類型。

簡單說說輸入參數都是 interface{} 的好處吧,主要有兩點,以下:

其一,haystack 是 interface{} 類型,使 in 支持的類型不止於 slice,還包括 array。咱們看到,函數內部經過反射對 haystack 進行了類型檢查,支持 slice(切片)與 array(數組)。若是是其餘類型則會提示錯誤,增長新的類型支持,如 map,其實也很簡單。但不推薦這種方式,由於經過 _, ok := m[k] 的語法便可達到 in 的效果。

其二,haystack 是 interface{},則 []interface{} 也知足要求,而且 needle 是 interface{}。如此一來,咱們就能夠實現相似解釋型語言同樣的效果了。

怎麼理解?直接示例說明,以下:

gotin.In([]interface{}{1, "two", 3}, "two")
複製代碼

haystack 是 []interface{}{1, "two", 3},並且 needle 是 interface{},此時的值是 "two"。如此看起來,是否是實現瞭解釋型語言中,元素能夠是任意類型,沒必要徹底相同效果。如此一來,咱們就能夠肆意妄爲的使用了。

但有一點要說明,In 函數的實現中有這樣一段代碼:

if sVal.Index(i).Interface() == needle {
	...
}
複製代碼

Go 中並不是任何類型均可以使用 == 比較的,若是元素中含有 slice 或 map,則可能會報錯。

二分查找

以遍歷確認元素是否存在有個缺點,那就是,若是數組或切片中包含了大量數據,好比 1000000 條數據,即一百萬,最壞的狀況是,咱們要遍歷 1000000 次才能確認,時間複雜度 On。

有什麼辦法能夠下降遍歷次數?

天然而然地想到的方法是二分查找,它的時間複雜度 log2(n) 。但這個算法有前提,須要依賴有序序列。

因而,第一個要咱們解決的問題是使序列有序,Go 的標準庫已經提供了這個功能,在 sort 包下。

示例代碼以下:

fmt.Println(sort.SortInts([]int{4, 2, 5, 1, 6}))
複製代碼

對於 []int,咱們使用的函數是 SortInts,若是是其餘類型切片,sort 也提供了相關的函數,好比 []string 可經過 SortStrings 排序。

完成排序就能夠進行二分查找,幸運的是,這個功能 Go 也提供了,[]int 類型對應函數是 SearchInts。

簡單介紹下這個函數,先看定義:

func SearchInts(a []int, x int) int 複製代碼

輸入參數容易理解,從切片 a 中搜索 x。重點要說下返回值,這對於咱們後面確認元素是否存在相當重要。返回值的含義,返回查找元素在切片中的位置,若是元素不存在,則返回,在保持切片有序狀況下,插入該元素應該在什麼位置。

好比,序列以下:

1 2 6 8 9 11
複製代碼

假設,x 爲 6,查找以後將發現它的位置在索引 2 處;x 若是是 7,發現不存在該元素,若是插入序列,將會放在 6 和 8 之間,索引位置是 3,於是返回值爲 3。

代碼測試下:

fmt.Println(sort.SearchInts([]int{1, 2, 6, 8, 9, 11}, 6)) // 2
fmt.Println(sort.SearchInts([]int{1, 2, 6, 8, 9, 11}, 7)) // 3
複製代碼

若是判斷元素是否在序列中,只要判斷返回位置上的值是否和查找的值相同便可。

但還有另一種狀況,若是插入元素位於序列最後,例如元素值爲 12,插入位置即爲序列的長度 6。若是直接查找 6 位置上的元素就可能發生越界的狀況。那怎麼辦呢?其實判斷返回是否小於切片長度便可,小於則說明元素在切片序列中。

完整的實現代碼以下:

func SortInIntSlice(haystack []int, needle int) bool {
	sort.Ints(haystack)

	index := sort.SearchInts(haystack, needle)
	return index < len(haystack) && haystack[index] == needle
}
複製代碼

但這還有個問題,對於無序的場景,若是每次查詢都要通過一次排序並不划算。最好能實現一次排序,稍微修改下代碼。

func InIntSliceSortedFunc(haystack []int) func(int) bool {
	sort.Ints(haystack)

	return func(needle int) bool {
		index := sort.SearchInts(haystack, needle)
		return index < len(haystack) && haystack[index] == needle
	}
}
複製代碼

上面的實現,咱們經過調用 InIntSliceSortedFunc 對 haystack 切片排序,並返回一個可屢次使用的函數。

使用案例以下:

in := gotin.InIntSliceSortedFunc(haystack)

for i := 0; i<maxNeedle; i++ {
	if in(i) {
		fmt.Printf("%d is in %v", i, haystack)
	}
}
複製代碼

二分查找的方式有什麼不足呢?

我想到的重要一點,要實現二分查找,元素必須是可排序的,如 int,string,float 類型。而對於結構體、切片、數組、映射等類型,使用起來就不是那麼方便,固然,若是要用,也是能夠的,不過須要咱們進行一些適當擴展,按指定標準排序,好比結構的某個成員。

到此,二分查找的 in 實現就介紹完畢了。

map key

本節介紹 map key 方式。它的算法複雜度是 O1,不管數據量多大,查詢性能始終不變。它主要依賴的是 Go 中的 map 數據類型,經過 hash map 直接檢查 key 是否存在,算法你們應該都比較熟悉,經過 key 可直接映射到索引位置。

咱們常會用到這個方法。

_, ok := m[k]
if ok {
	fmt.Println("Found")
}
複製代碼

那麼它和 in 如何結合呢?一個案例就說明白了這個問題。

假設,咱們有一個 []int 類型變量,以下:

s := []int{1, 2, 3}
複製代碼

爲了使用 map 的能力檢查某個元素是否存在,能夠將 s 轉化 map[int]struct{}。

m := map[interface{}]struct{}{
	1: struct{}{},
	2: struct{}{},
	3: struct{}{},
	4: struct{}{},
}
複製代碼

若是檢查某個元素是否存在,只須要經過以下寫法便可肯定:

k := 4
if _, ok := m[k]; ok {
	fmt.Printf("%d is found\n", k)
}
複製代碼

是否是很是簡單?

補充一點,關於這裏爲何使用 struct{},能夠閱讀我以前寫的一篇關於 Go 中如何使用 set 的文章。

按照這個思路,實現函數以下:

func MapKeyInIntSlice(haystack []int, needle int) bool {
	set := make(map[int]struct{})

	for _ , e := range haystack {
		set[e] = struct{}{}
	}

	_, ok := set[needle]
	return ok
}
複製代碼

實現起來不難,但和二分查找有着一樣的問題,開始要作數據處理,將 slice 轉化爲 map。若是是每次數據相同,稍微修改下它的實現。

func InIntSliceMapKeyFunc(haystack []int) func(int) bool {
	set := make(map[int]struct{})

	for _ , e := range haystack {
		set[e] = struct{}{}
	}

	return func(needle int) bool {
		_, ok := set[needle]
		return ok
	}
}
複製代碼

對於相同的數據,它會返回一個可屢次使用的 in 函數,一個使用案例以下:

in := gotin.InIntSliceMapKeyFunc(haystack)

for i := 0; i<maxNeedle; i++ {
	if in(i) {
		fmt.Printf("%d is in %v", i, haystack)
	}
}
複製代碼

對比前兩種算法,這種方式的處理效率最高,很是適合於大數據的處理。接下來的性能測試,咱們將會看到效果。

性能

介紹完全部方式,咱們來實際對比下每種算法的性能。測試源碼位於 gotin_test.go 文件中。

基準測試主要是從數據量大小考察不一樣算法的性能,本文中選擇了三個量級的測試樣本數據,分別是 十、1000、1000000。

爲便於測試,首先定義了一個用於生成 haystack 和 needle 樣本數據的函數。

代碼以下:

func randomHaystackAndNeedle(size int) ([]int, int){
	haystack := make([]int, size)

	for i := 0; i<size ; i++{
		haystack[i] = rand.Int()
	}

	return haystack, rand.Int()
}
複製代碼

輸入參數是 size,經過 rand.Int() 隨機生成切片大小爲 size 的 haystack 和 1 個 needle。在基準測試用例中,引入這個隨機函數生成數據便可。

舉個例子,以下:

func BenchmarkIn_10(b *testing.B) {
	haystack, needle := randomHaystackAndNeedle(10)

	b.ResetTimer()
	for i := 0; i < b.N; i++ {
		_, _ = gotin.In(haystack, needle)
	}
}
複製代碼

首先,經過 randomHaystackAndNeedle 隨機生成了一個含有 10 個元素的切片。由於生成樣本數據的時間不該該計入到基準測試中,咱們使用 b.ResetTimer() 重置了時間。

其次,壓測函數是按照 Test+函數名+樣本數據量 規則編寫,如案例中 BenchmarkIn_10,表示測試 In 函數,樣本數據量爲 10。若是咱們要用 1000 數據量測試 InIntSlice,壓測函數名爲 BenchmarkInIntSlice_1000。

測試開始吧!簡單說下個人筆記本配置,Mac Pro 15 版,16G 內存,512 SSD,4 核 8 線程的 CPU。

測試全部函數在數據量在 10 的狀況下的表現。

$ go test -run=none -bench=10$ -benchmem
複製代碼

匹配全部以 10 結尾的壓測函數。

測試結果:

goos: darwin
goarch: amd64
pkg: github.com/poloxue/gotin
BenchmarkIn_10-8                         3000000               501 ns/op             112 B/op         11 allocs/op
BenchmarkInIntSlice_10-8                200000000                7.47 ns/op            0 B/op          0 allocs/op
BenchmarkInIntSliceSortedFunc_10-8      100000000               22.3 ns/op             0 B/op          0 allocs/op
BenchmarkSortInIntSlice_10-8            10000000               162 ns/op              32 B/op          1 allocs/op
BenchmarkInIntSliceMapKeyFunc_10-8      100000000               17.7 ns/op             0 B/op          0 allocs/op
BenchmarkMapKeyInIntSlice_10-8           3000000               513 ns/op             163 B/op          1 allocs/op
PASS
ok      github.com/poloxue/gotin        13.162s
複製代碼

表現最好的並不是 SortedFunc 和 MapKeyFunc,而是最簡單的針對單類型的遍歷查詢,平均耗時 7.47ns/op,固然,另外兩種方式表現也不錯,分別是 22.3ns/op 和 17.7ns/op。

表現最差的是 In、SortIn(每次重複排序) 和 MapKeyIn(每次重複建立 map)兩種方式,平均耗時分別爲 501ns/op 和 513ns/op。

測試全部函數在數據量在 1000 的狀況下的表現。

$ go test -run=none -bench=1000$ -benchmem
複製代碼

測試結果:

goos: darwin
goarch: amd64
pkg: github.com/poloxue/gotin
BenchmarkIn_1000-8                         30000             45074 ns/op            8032 B/op       1001 allocs/op
BenchmarkInIntSlice_1000-8               5000000               313 ns/op               0 B/op          0 allocs/op
BenchmarkInIntSliceSortedFunc_1000-8    30000000                44.0 ns/op             0 B/op          0 allocs/op
BenchmarkSortInIntSlice_1000-8             20000             65401 ns/op              32 B/op          1 allocs/op
BenchmarkInIntSliceMapKeyFunc_1000-8    100000000               17.6 ns/op             0 B/op          0 allocs/op
BenchmarkMapKeyInIntSlice_1000-8           20000             82761 ns/op           47798 B/op         65 allocs/op
PASS
ok      github.com/poloxue/gotin        11.312s
複製代碼

表現前三依然是 InIntSlice、InIntSliceSortedFunc 和 InIntSliceMapKeyFunc,但此次順序發生了變化,MapKeyFunc 表現最好,17.6 ns/op,與數據量 10 的時候相比基本無變化。再次驗證了前文的說法。

一樣的,數據量 1000000 的時候。

$ go test -run=none -bench=1000000$ -benchmem
複製代碼

測試結果以下:

goos: darwin
goarch: amd64
pkg: github.com/poloxue/gotin
BenchmarkIn_1000000-8                                 30          46099678 ns/op         8000098 B/op    1000001 allocs/op
BenchmarkInIntSlice_1000000-8                       3000            424623 ns/op               0 B/op          0 allocs/op
BenchmarkInIntSliceSortedFunc_1000000-8         20000000                72.8 ns/op             0 B/op          0 allocs/op
BenchmarkSortInIntSlice_1000000-8                     10         138873420 ns/op              32 B/op          1 allocs/op
BenchmarkInIntSliceMapKeyFunc_1000000-8         100000000               16.5 ns/op             0 B/op          0 allocs/op
BenchmarkMapKeyInIntSlice_1000000-8                   10         156215889 ns/op        49824225 B/op      38313 allocs/op
PASS
ok      github.com/poloxue/gotin        15.178s
複製代碼

MapKeyFunc 依然表現最好,每次操做用時 17.2 ns,Sort 次之,而 InIntSlice 呈現線性增長的趨勢。通常狀況下,若是不是對性能要特殊要求,數據量特別大的場景,針對單類型的遍歷已經有很是好的性能了。

從測試結果能夠看出,反射實現的通用 In 函數每次執行須要進行大量的內存分配,方便的同時,也是以犧牲性能爲代價的。

總結

本文經過一個問題引出主題,爲何 Go 中沒有相似 Python 的 In 方法。我認爲,一方面是實現很是簡單,沒有必要。除此之外,另外一方面,在不一樣場景下,咱們還須要根據實際狀況分析用哪一種方式實現,而不是一種固定的方式。

接着,咱們介紹了 In 實現的三種方式,並分析了各自的優劣。經過性能分析測試,咱們能得出大體的結論,什麼方式適合什麼場景,但整體仍是不能說足夠細緻,有興趣的朋友能夠繼續研究下。

參考

Does Go have 「if x in」 construct similar to Python?

爲何Golang沒有像Python中in同樣的功能?


歡迎關注個人微信公衆號

相關文章
相關標籤/搜索