好多人都在刷leetcode,今天我也註冊了一個玩玩,發現裡面好多都是演算法題,好吧,畢業十來年,學的那點可憐的數學知識,全都還給學校了。好了閒話少說,言歸正傳,讓我們看看今天在裡面我嘗試的第一道題,有點意思, 不只是單純的演算法,還有資料和是否適合的問題。
承題
點開題庫,看了第一題,我們看看這道題:
給定一個整數陣列和一個目標值,找出陣列中和為目標值的兩個數。
你可以假設每個輸入只對應一種答案,且同樣的元素不能被重複利用。
示例:
給定 nums = [2, 7, 11, 15], target = 9
因為 nums[0] + nums[1] = 2 + 7 = 9
所以返回 [0, 1]
用了這麼多文字描述,其實總結起來就是:陣列裡那兩個數想加等於目標值,找出來這兩個數的索引。
題是不難,leetcode給出了兩種演算法:
- 暴力法,迴圈迭代找出來,時間複雜度O(n^2),空間複雜度是O(1)
- 一遍雜湊表,時間和空間複雜度都是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/,第一時間看後續精彩文章。「防爛人備註**……&*¥」覺得好的話,順手分享到朋友圈吧,感謝支援。