[譯] GopherCon 2018:揭祕二叉查詢樹演算法

歐長坤發表於2019-03-03

By Geoffrey Gilmore for the GopherCon Liveblog on August 30, 2018

Presenter: Kaylyn Gibilterra

Liveblogger: Geoffrey Gilmore

演算法的學習勢不可擋也令人氣餒,但其實大可不必如此。在本次演講中,Kaylyn 使用 Go 程式碼作為例子,直接了當的闡述了二叉查詢樹演算法。


介紹

Kaylyn 在最近的一年裡嘗試通過實現各種演算法來找樂子。可能這件事情對於你來說很奇怪,但演算法對她而言尤其詭異。她在大學課堂裡尤其討厭演算法。她的教授經常使用一些複雜的術語來授課,而且還拒絕解釋一些『顯然』的概念。結果就是,她只學到了一些能夠幫助她找到工作的基本知識。

然而她的態度在當她開始使用 Go 來實現這些演算法時就開始轉變了。將那些由 C 或者 Java 編寫的演算法轉換到 Go 身上令人意想不到的簡單,於是她開始逐漸理解這些演算法,並且比在大學期間理解得更為透徹。

Kaylyn 將在演講中解釋為什麼會出現這種情況、併為你展示如何使用二叉查詢樹。在這之前,我們需要問:為什麼學習演算法的體驗如此糟糕?

學習演算法很可怕

[譯] GopherCon 2018:揭祕二叉查詢樹演算法

此截圖來自《演算法導論》的二叉查詢樹部分。演算法導論被認為是演算法書籍的聖經。據作者所說,在 1989 年出版之前,沒有一本很好的演算法教科書。但是,任何閱讀演算法導論的人都可以說它是由主要受眾具有學術意識的教授編寫的。

舉幾個例子:

  • 此頁引用了本書在其他地方定義的許多術語。所以你需要了解:

    • 什麼是衛星資料(satellite data)
    • 什麼是連結串列(linked list)
    • 什麼是樹的先序(pre-order)、後序(post-order)遍歷

    如果你沒有在書中的每一頁上做筆記,你就無法知道這些都是什麼。

  • 如果你和 Kaylyn 一樣,那麼你看這一頁的第一件事就是去看程式碼。但是,頁面上唯一的程式碼只解釋了一種遍歷二叉查詢樹的方法,而不是二叉查詢樹實際上是什麼。

  • 本頁的整個底部四分之一是定理和證明,這可能是善意的。許多教科書作者認為向你證明他們的陳述是真實的是相當重要的;否則,你就無法相信他們。可笑的是,演算法應該是一本入門教科書。但是,初學者不需要知道演算法正確的所有具體細節,因為他們會聽你的話。

  • 他們確實有一個兩句話區域(以綠色框突出顯示),解釋了二叉查詢樹演算法是什麼。但它隱藏在一個幾乎看不見的句子中,並稱之為二元查詢樹『性質』,這對於初學者而言是非常令人困惑的術語。

結論:

  1. 學術教科書的作者不一定是好老師,最好的老師經常不寫教科書。
  2. 可惜大多數人都複製了標準教科書使用的教學風格或格式。 在檢視二叉查詢樹之前,他們預設你已經瞭解了相關的術語。事實上,大多數這種『必需的知識』並不是必需的。

本演講的其餘部分將介紹二叉查詢樹的內容。如果你是 Go 新手或演算法新手,你會發現它很有用。而如果你都不是,那麼它可以作為一次很好的回顧,同時你也分享給對 Go 或者演算法感興趣的人。

猜數遊戲

這是你在接下來全部演講中唯一需要知道的東西。

[譯] GopherCon 2018:揭祕二叉查詢樹演算法

這是一個『猜數遊戲』,很多人兒時玩過的遊戲。你邀請你的朋友來參加在某個範圍內(比如 1 至 100)猜一個特定數的遊戲。然後你朋友可能會說『57』。一般情況下第一次猜會猜錯,但是你會告訴他們猜測的數字是大了還是小了。然後他可以繼續猜測知道最後猜中為止。

[譯] GopherCon 2018:揭祕二叉查詢樹演算法

這個猜數遊戲基本上就是一個二叉查詢的過程了。如果你正確理解了這個猜數遊戲,那麼你也能夠理解二叉查詢樹演算法背後的原理。你朋友猜測的數字就是查詢樹中的某個節點,『高了』和『低了』決定了移動的方向:右節點或左節點。

二叉查詢樹的規則

  1. 每個節點包含一個唯一的 key,用於比較不同的節點大小。一個 key 可以是任何型別:字串、整數等等。
  2. 每個節點至多兩個子節點
  3. 節點的值小於右子樹種節點的值
  4. 節點的值大於左子樹種節點的值
  5. 沒有重複的 key

二叉查詢樹包含三個主要操作:

  • 查詢
  • 插入
  • 刪除

二叉查詢樹可以讓上面這三個操作變得更快,這也是他們為什麼如此熱門的原因。

查詢

[譯] GopherCon 2018:揭祕二叉查詢樹演算法

上面的 GIF 圖給出了在樹種查詢 39 的例子。

[譯] GopherCon 2018:揭祕二叉查詢樹演算法

一個非常重要的性質是二叉查詢樹一個節點右子樹中節點的值總是大於節點自身的值,而左子樹中節點的值總是小於節點自身的值。比如圖中 57 右邊的數總是大於 57 ,而左邊總是小於 57

[譯] GopherCon 2018:揭祕二叉查詢樹演算法

這個性質除了根節點外,對樹中每個節點都有效。在上圖中,所有右子樹的值都大於 32,左子樹則小於 32

好了,我們知道了基本原理,可以開始寫程式碼了。

type Node struct {
    Key   int
    Left  *Node
    Right *Node
}
複製程式碼

基本結構是一個 stuct ,如果你還沒有用過 stuctstruct 基本上可以解釋為一些欄位的集合。這個結構體你需要的只是一個 Key(用於比較其他節點),一個 LeftRight 子節點。

當定義一個 節點(Node)時,你可以使用這樣的字面量,你可以使用這樣的字面量:

tree := &Node{Key: 6}
複製程式碼

它建立了一個 Key6Node。你可能好奇 LeftRight 去哪兒了。事實上他們都被初始化成零值了。

tree := &Node{
    Key:   6,
    Left:  nil,
    Right: nil,
}
複製程式碼

然而你也可以顯式什麼這些欄位的值(比如上面指定了 Key)。

又或者在沒有欄位名稱的情況下指定欄位的值:

tree := &Node{6, nil, nil}
複製程式碼

這種情況下,第一個引數為 Key,第二個為 Left,第三個為 Right

指定完後你就可以通過點語法來訪問他們的值了:

tree := &Node{6, nil, nil}
fmt.Println(tree.Key)
複製程式碼

現在我們來實現查詢演算法 Search

func (n *Node) Search(key int) bool {
    // 這是我們的基本情況。如果 n == nil,則 `key`
    // 在二叉查詢樹種不存在
    if n == nil {
        return false
    }
    if n.Key < key { // 向右走
        return n.Right.Search(key)
    }
    if n.Key > key { // 向左走
        return n.Left.Search(key)
    }
    // 如果 n.Key == key,就說明找到了
    return true
}
複製程式碼

插入

[譯] GopherCon 2018:揭祕二叉查詢樹演算法

上面的 GIF 圖片展示了在一個數中插入 81 的例子,插入與查詢非常類似。我們想要找到應該在什麼位置插入 81,於是開始查詢,然後在合適的位置插入。

func (n *Node) Insert(key int) {
    if n.Key < key {
        if n.Right == nil { // 我們找到了一個空位,結束!
            n.Right = &Node{Key: key}
            return
        }
        // 向右邊找
        n.Right.Insert(key)
       	return
    } 
    if n.Key > key {
        if n.Left == nil { // 我們找到了一個空位,結束
            n.Left = &Node{Key: key}
            return
        } 
        // 向左邊找
        n.Left.Insert(key)
    }
    // 如果 n.Key == key,則什麼也不做
}
複製程式碼

如果你沒見過 (n *Node) 語法,可以看看這裡關於指標型 receiver 的說明。

刪除

[譯] GopherCon 2018:揭祕二叉查詢樹演算法

上面的 GIF 圖展示了從一個樹種刪除 78 的情況。78 的查詢過程和之前類似。這種情況下,我們只需要正確的將 78 從樹中『剪掉』、將右子節點 57 連線到 85 就行了。

func (n *Node) Delete(key int) *Node {
    // 按 `key` 查詢
    if n.Key < key {
        n.Right = n.Right.Delete(key)
        return n
    }
    if n.Key > key {
        n.Left = n.Left.Delete(key)   
        return n
    }

    // n.Key == `key`
    if n.Left == nil { // 只指向反向的節點
        return n.Right
    }
    if n.Right == nil { // 只指向反向的節點
        return n.Left
    }

    // 如果 `n` 有兩個子節點,則需要確定下一個放在位置 n 的最大值
    // 使得二叉查詢樹保持正確的性質
    min := n.Right.Min()

    // 我們只使用最小節點來更新 `n` 的 key
    // 因此 n 的直接子節點不再為空
    n.Key = min
    n.Right = n.Right.Delete(min)
    return n
}
複製程式碼

最小值

[譯] GopherCon 2018:揭祕二叉查詢樹演算法

如果不停的向左移,你會找到最小值(圖中為 24

func (n *Node) Min() int {
    if n.Left == nil {
        return n.Key
    }
    return n.Left.Min()
}
複製程式碼

最大值

[譯] GopherCon 2018:揭祕二叉查詢樹演算法
func (n *Node) Max() int {
    if n.Right == nil {
        return n.Key
    }
    return n.Right.Max()
}
複製程式碼

如果你一直向右移,則會找到最大值(圖中為 96)。

單元測試

既然我們已經為二叉查詢樹的每個主要函式編寫了程式碼,那麼讓我們實際測試一下我們的程式碼吧! 測試實踐過程中最有意思的部分:Go 中的測試比許多其他語言(如 Python 和 C )更直接。

// 必須匯入標準庫
import "testing"

// 這個稱之為測試表。它能夠簡單的指定測試用例來避免寫出重複程式碼。
// 見 https://github.com/golang/go/wiki/TableDrivenTests
var tests = []struct {
    input  int
    output bool
}{
    {6, true},
    {16, false},
    {3, true},
}

func TestSearch(t *testing.T) {
    //     6
    //    /
    //   3
    tree := &Node{Key: 6, Left: &Node{Key: 3}}
    
    for i, test := range tests { 
        if res := tree.Search(test.input); res != test.output {
            t.Errorf("%d: got %v, expected %v", i, res, test.output)
        }
    }

}
複製程式碼

然後只需要執行:

> go test
複製程式碼

Go 會執行你的測試並輸出一個標準格式的結果,來告訴你測試是否通過,測試失敗的訊息以及測試花費的時間。

效能測試

等等,還有更多內容!Go 可以讓效能測試變得非常簡潔,你只需要:

import "testing"

func BenchmarkSearch(b *testing.B) {
    tree := &Node{Key: 6}

    for i := 0; i < b.N; i++ {
        tree.Search(6)
    }
}
複製程式碼

b.N 會反覆執行 tree.Search() 來獲得 tree.Search() 的穩定執行結果。

通過下面的命令執行測試:

> go test -bench=
複製程式碼

輸出類似於:

goos: darwin
goarch: amd64
pkg: github.com/kgibilterra/alGOrithms/bst
BenchmarkSearch-4       1000000000               2.84 ns/op
PASS
ok      github.com/kgibilterra/alGOrithms/bst   3.141s
複製程式碼

你需要關注的是下面這行:

BenchmarkSearch-4       1000000000               2.84 ns/op
複製程式碼

它表明了你函式的執行速度。這種情況下,test.Search() 的執行時間大約為 2.84 納秒。

既然可以簡單執行效能測試,那麼可以開始做一些實驗了,比如:

  • 如果樹非常大或者非常深灰髮生什麼?
  • 如果我修改了需要查詢的 key 會發生什麼?

發現它特別利於理解 map 和 slice 之間的效能特性。希望你能在網上快速找到相關反饋。

譯者注:二叉查詢樹的插入、刪除、查詢時間複雜度為 O(log(n)),最壞情況為 O(n);Go 的 map 是一個雜湊表,我們知道雜湊表的插入、刪除、查詢的平均時間複雜度為 O(1),而最壞情況下為 O(n);而 Go 的 Slice 的查詢需要遍歷 Slice 複雜度為 O(n),插入和刪除在必要時會重新分配記憶體,最壞情況為 O(n)。

二叉查詢樹術語

最後我們來看一些二叉查詢樹的術語。如果你希望瞭解二叉查詢樹的更多內容,那麼這些術語是有幫助的:

樹的高度:從根節點到葉子節點中最長路徑的邊數,這決定了演算法的速度。

[譯] GopherCon 2018:揭祕二叉查詢樹演算法

圖中樹的高度 5

節點深度:從根節點到節點的邊數。

48 的深度為 2

[譯] GopherCon 2018:揭祕二叉查詢樹演算法

滿二叉樹:每個非葉子節點均包含兩個子節點。

[譯] GopherCon 2018:揭祕二叉查詢樹演算法

完全二叉樹:每層結點都完全填滿,在最後一層上如果不是滿的,則只缺少右邊的若干結點。

[譯] GopherCon 2018:揭祕二叉查詢樹演算法

一個非平衡樹

[譯] GopherCon 2018:揭祕二叉查詢樹演算法

想象一下在這顆樹上查詢 47,你可以看到找到需要花費七步,而查詢 24 則只需要花費三步,這個問題隨著『不平衡』的增加而變得嚴重。解決方法就是使樹變得平衡:

一個平衡樹

[譯] GopherCon 2018:揭祕二叉查詢樹演算法

此樹包含與非平衡樹相同的節點,但在平衡樹上查詢平均比在不平衡樹上查詢更快。

聯絡方式

Twitter: @kgibilterra
Email: kgibilterra@gmail.com

如果發現譯文存在錯誤或其他需要改進的地方,歡迎到 掘金翻譯計劃 對譯文進行修改並 PR,也可獲得相應獎勵積分。文章開頭的 本文永久連結 即為本文在 GitHub 上的 MarkDown 連結。


掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為 掘金 上的英文分享文章。內容覆蓋 AndroidiOS前端後端區塊鏈產品設計人工智慧等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章