當你在leetcode做完一道題的時候,你一定不會滿足於只是通過,而是還希望你的代碼至少擊敗90%+的人。
然鵝,像我這樣的算法渣渣,提交完往往是這樣的。。。
之前用C++做題的時候,遇到這種情況,我都是到提交記錄去看最快的代碼,瞅瞅大神跟我到底有啥不同:
如果是思路上的問題,看了大神的代碼,可能你立馬就能發現問題,比如你用了雙層循環,而大神只用了一層。
但如果思路上差不多,那這個時候,決定程序好壞的,往往就是編程的細節了,這個時候,光是看大神的代碼,可能你並不能發現自己的問題到底在哪。
那麼這個時候,該如何定位代碼走的慢的地方,從而進一步提升代碼的性能呢?別的語言怎麼做我不知道,但是go語言,可以用pprof。
pprof是go sdk自帶的,在runtime/pprof這裏,可以用來分析代碼的各種性能。
我們刷題的話,主要關注的是時間複雜度,所以我們想得到的是,代碼的哪一塊耗時比較長,接下來我就說一下,如何用pprof來幫助我們分析代碼運行耗時。
下載"github.com/pkg/profile"包
在學習pprof的過程中,有的文章推薦了這個包,這個包對runtime/pprof和一些其他go sdk中的包進行了封裝,使得我們用起來更方便了,後面我用這個也覺得確實不錯,所以推薦給大家。
下載方式:
git clone https://github.com/pkg/profile $GOPATH/src/github.com/pkg
這個包不大,即使是普通網絡也可以幾秒鐘下好。
安裝Graphviz
這個東西,看名字就知道,是某種圖像化的工具。我們使用pprof可以生成一個分析結果文件,但是這個文件並不直觀,我們希望能將其圖像化,一眼就看出程序哪裏有問題,而Graphviz 就是幫助我們幹這件事的。
Graphviz的安裝很簡單,首先去官網(http://www.graphviz.org/)下載安裝包,這裏以windows系統爲例:
首先,點擊首頁上方的download。裏面有源碼包,還有編譯好的可執行程序,我們選擇可執行程序,這裏我選擇了一個windows裏面的穩定版本 :
然後,下載zip壓縮包:
這個包大概50兆,但下的很快。下載之後,先解壓,解壓出來會有一個叫release的文件夾,裏面有這些東西:
其中bin文件夾下就包含了我們會用到的,將二進制文件轉化爲圖像的命令,爲了讓這個命令可以用,我們需要將這個bin的路徑添加到環境變量的PATH後面,這裏我是先將release文件夾拷貝到E盤,並改名爲graphviz:
然後再把bin路徑加到PATH:
完成之後我們可以驗證一下,這裏面有一個叫dot的命令是後面會用到的,我們可以打開cmd,看看這個命令是不是可以用了:
看到這個,就歐克了。
代碼分析
接下來,我將完整的記錄一次代碼調優的全過程。
優化的對象,就是我最開始給的那張截圖,就是隻擊敗了16%的人的那段程序,這是leetcode的1382題。
這道題的思路呢,就是先中序遍歷二叉搜索樹,得到一個有序數組,然後,再將有序數組重新構造成二叉搜索樹,思路並不複雜,我也就不過多解釋了,直接貼出我只擊敗16%的代碼:
func inOrder(root *goBinaryTree.TreeNode, array *[]int) {
if root == nil {
return
}
inOrder(root.Left, array)
*array = append(*array, root.Val)
inOrder(root.Right, array)
}
func sortedArrayToBst(arr []int) *goBinaryTree.TreeNode {
if len(arr) == 0 {
return nil
} else if len(arr) == 1 {
return &goBinaryTree.TreeNode{arr[0], nil, nil}
} else {
mid := len(arr) / 2
root := goBinaryTree.TreeNode{arr[mid], nil, nil}
root.Left = sortedArrayToBst(arr[:mid])
root.Right = sortedArrayToBst(arr[mid+1:])
return &root
}
}
func balanceBST(root *goBinaryTree.TreeNode) *goBinaryTree.TreeNode {
//先中序遍歷得到有序數組
array := make([]int, 0)
inOrder(root, &array)
//然後構造平衡二叉搜索樹
return sortedArrayToBst(array)
}
接下來,我們編寫一段測試代碼。
package main
import (
"fmt"
"github.com/djq8888/goBinaryTree"
"github.com/pkg/profile"
"time"
)
func main() {
//開始性能分析, 返回一個停止接口
stopper := profile.Start(profile.CPUProfile, profile.ProfilePath("."))
//在main()結束時停止性能分析
defer stopper.Stop()
for i := 0; i < 100000; i++ {
root := goBinaryTree.BuildLevelOrder([]int{1,-1,2,-1,3,-1,4,-1,-1})
root = balanceBST(root)
}
fmt.Println("add breakpoint here.")
time.Sleep(time.Second)
}
這一段我要說明兩個地方,第一個是這裏:
這塊我們使用了最開始下的那個profile包,可以看到使用起來非常簡單,不管什麼代碼,運行的時候加上這兩行就歐克了,在程序運行過程中,profile包會幫我們記錄程序的性能指標,包括CPU使用時間之類的,並將其寫入一個pprof文件。
第二個要注意的是這裏:
是的,我把同一個測試例執行了10w次,爲什麼要這樣?你可以只執行一次試試,我可以負責任的告訴你,那樣你得不到任何分析結果。。。這個問題搞了我半天,一開始我以爲是我的用法不對,生成出來的圖總是這樣:
這啥也沒有啊。。。網上查了半天,也沒有什麼說法,最終我沉着冷靜的分析了一下,首先,圖片裏這個程序執行完只用了200ms,其中每一步的時間更是微乎其微,圖片裏還有一個Total sapmle,就是總的採樣時間,是0,也就是說pprof還啥都沒分析,程序就已經跑完了??帶着這個猜測,我把測試次數改成了10w,果然,出來了我想要的分析圖,不過現在還沒到看圖的時候,我們繼續下一步,先運行這個代碼,這裏爲了演示如何使用pprof,我就不用goland的運行按鍵了,我們自己用命令來編譯運行(當然看懂之後,你就可以用goland直接運行了)。
我的程序放在這個文件夾裏,1382.go是我寫的那幾個函數,test.go就是測試用的main函數:
首先,cd到這個路徑,然後編譯:
這時生成了一個可執行文件,我們運行它:
然後,就會發現,生成了一個pprof文件:
接下來,我們用命令,生成一個可以看懂的分析圖,注意命令裏面test是我們的可執行文件,cpu.pprof是程序運行輸出那個文件:
go tool pprof --pdf test cpu.pprof > cpu.pdf
執行之後,這裏生成了一個pdf:
這時,我們就可以打開這個分析圖一探究竟了。
圖有點大,我們先看上半部分:
你會發現,我用紅框框圈起來的那個方塊格外的引人注目,是的,因爲它最大,而最大意味着什麼呢,意味着這一塊耗時最多!也就是說,這裏,就是我們應當重點關注的目標。
我們看這個大方塊裏寫的是,growslice,也就是切片擴容,我們哪裏對切片進行擴容了呢?在中序遍歷那裏,也就是這一行:
*array = append(*array, root.Val)
append的時候,如果切片的容量已經滿了,就需要重新分配一段容量是原來2倍的內存,然後把原先切片裏的內容拷貝進去,如此說來,當容量很大時,拷貝的開銷也會很大,確實是一個耗時大戶。那麼這一段該如何優化呢?這塊之所以我們沒有一開始就創建一個容量很大的切片,是因爲我們也不知道遍歷的這個二叉樹到底有多少個節點,如果預先分配的容量太大,可能會浪費內存,但是如果不考慮內存開銷,我們確實可以通過一開始就把容量分配的大一些,從而減少後面的slice擴容,我們回到題目,題目說,節點個數小於10000:
所以我就乾脆一開始就把slice的容量設成10000,這樣中序遍歷的過程中就一定不需要擴容了,具體就是這樣:
然後,再提交一次:
效果顯著啊,從16%到了77%,到這我也是稍稍鬆了口氣,整了半天沒白整。。。
但到這還沒完,圖還有下半部分:
這裏我們不難看出,這個圖從上到下是符合代碼的執行順序的,也就是說,下面這幾個大方塊都是在growslice之後發生的,這一點很重要,這意味着分析後面這幾個大方塊的時候我們可以不用再去考慮前面的那部分程序了,更直接的說就是,這幾個大方塊和中序遍歷那段程序已經沒有關係了,我們只需要分析後面,根據有序數組構建二叉搜索樹那個函數。
後面的這幾個方塊呢,其實是一個依次調用的關係,即:
- mallocgc裏調用了heapBitsSetType
- heapBitsSetType調用了heapBitsForAddr
- heapBitsForAddr調用了arenaIndex
其中,mallocgc函數在runtime包的malloc.go中,你也可以自己去看看這一塊,這塊其實就是go語言的內存分配,這裏就不深入說了,因爲我還沒有研究過這塊。。。不過可以肯定的是,構建二叉樹這個函數的耗時主要來自內存分配,所以接下來我們就看一看,哪裏進行了內存分配。看了眼程序,一下就發現了,是構建樹的過程中,要創建樹節點:
root := goBinaryTree.TreeNode{arr[mid], nil, nil}
那麼這塊的內存分配可不可以避免呢?我們先來回顧一下我的思路,先中序遍歷,把每個樹節點的值存入數組,然後,再根據數組中的值創建樹節點,再簡單的說就是:
- 樹->數
- 數->樹
這麼一看是不是發現問題了,明明最開始就是好好的樹節點,我何必要先全都轉成數,再轉回去呢?中序遍歷,只是爲了按順序獲取所有的樹節點,但是中序遍歷的結果可以直接是樹節點數組啊,然後再把這些樹節點組裝成平衡二叉樹不就好了,這樣不就省去了創建樹節點時候的內存分配耗時了嗎???唉,真不知道自己一開始在想啥。。。
按照這個思路,代碼改成了下面這樣:
func inOrder(root *goBinaryTree.TreeNode, array *[]*goBinaryTree.TreeNode) {
if root == nil {
return
}
inOrder(root.Left, array)
*array = append(*array, root)
inOrder(root.Right, array)
}
func sortedArrayToBst(arr []*goBinaryTree.TreeNode) *goBinaryTree.TreeNode {
if len(arr) == 0 {
return nil
} else {
mid := len(arr) / 2
root := arr[mid]
root.Left = sortedArrayToBst(arr[:mid])
root.Right = sortedArrayToBst(arr[mid+1:])
return root
}
}
func balanceBST(root *goBinaryTree.TreeNode) *goBinaryTree.TreeNode {
//先中序遍歷得到有序數組
array := make([]*goBinaryTree.TreeNode, 0, 10000)
inOrder(root, &array)
//然後構造平衡二叉搜索樹
return sortedArrayToBst(array)
}
再次提交:
恩,這個結果,可以滿意了~
總結
本篇介紹了pprof的簡單用法,並記錄了本人第一次使用pprof優化代碼的全過程,效果還是非常不錯的,推薦所有刷題的小夥伴都用一用。
通過一次細緻的調優,我們可以對程序的細節有更多的關注,這可以幫助我們養成更好的編程習慣,從而寫出更優秀的代碼,當面試官看到我們對程序細節的把控十分到位的時候,對我們的印象自然也會提高一個檔次~