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

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

Presenter: Kaylyn Gibilterraandroid

Liveblogger: Geoffrey Gilmoreios

算法的學習勢不可擋也使人氣餒,但其實大可沒必要如此。在本次演講中,Kaylyn 使用 Go 代碼做爲例子,直接了當的闡述了二叉查找樹算法。git


介紹

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

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

Kaylyn 將在演講中解釋爲何會出現這種狀況、併爲你展現如何使用二叉查找樹。在這以前,咱們須要問:爲何學習算法的體驗如此糟糕?算法

學習算法很可怕

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

舉幾個例子:bash

  • 此頁引用了本書在其餘地方定義的許多術語。因此你須要瞭解:函數

    • 什麼是衛星數據(satellite data)
    • 什麼是鏈表(linked list)
    • 什麼是樹的先序(pre-order)、後序(post-order)遍歷

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

  • 若是你和 Kaylyn 同樣,那麼你看這一頁的第一件事就是去看代碼。可是,頁面上惟一的代碼只解釋了一種遍歷二叉查找樹的方法,而不是二叉查找樹其實是什麼。

  • 本頁的整個底部四分之一是定理和證實,這多是善意的。許多教科書做者認爲向你證實他們的陳述是真實的是至關重要的;不然,你就沒法相信他們。好笑的是,算法應該是一本入門教科書。可是,初學者不須要知道算法正確的全部具體細節,由於他們會聽你的話。

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

結論:

  1. 學術教科書的做者不必定是好老師,最好的老師常常不寫教科書。
  2. 惋惜大多數人都複製了標準教科書使用的教學風格或格式。 在查看二叉查找樹以前,他們默認你已經瞭解了相關的術語。事實上,大多數這種『必需的知識』並非必需的。

本演講的其他部分將介紹二叉查找樹的內容。若是你是 Go 新手或算法新手,你會發現它頗有用。而若是你都不是,那麼它能夠做爲一次很好的回顧,同時你也分享給對 Go 或者算法感興趣的人。

猜數遊戲

這是你在接下來所有演講中惟一須要知道的東西。

這是一個『猜數遊戲』,不少人兒時玩過的遊戲。你邀請你的朋友來參加在某個範圍內(好比 1 至 100)猜一個特定數的遊戲。而後你朋友可能會說『57』。通常狀況下第一次猜會猜錯,可是你會告訴他們猜想的數字是大了仍是小了。而後他能夠繼續猜想知道最後猜中爲止。

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

二叉查找樹的規則

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

二叉查找樹包含三個主要操做:

  • 查找
  • 插入
  • 刪除

二叉查找樹可讓上面這三個操做變得更快,這也是他們爲何如此熱門的緣由。

查找

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

一個很是重要的性質是二叉查找樹一個節點右子樹中節點的值老是大於節點自身的值,而左子樹中節點的值老是小於節點自身的值。好比圖中 57 右邊的數老是大於 57 ,而左邊老是小於 57

這個性質除了根節點外,對樹中每一個節點都有效。在上圖中,全部右子樹的值都大於 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
}
複製代碼

插入

上面的 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 的說明。

刪除

上面的 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
}
複製代碼

最小值

若是不停的向左移,你會找到最小值(圖中爲 24

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

最大值

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)。

二叉查找樹術語

最後咱們來看一些二叉查找樹的術語。若是你但願瞭解二叉查找樹的更多內容,那麼這些術語是有幫助的:

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

圖中樹的高度 5

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

48 的深度爲 2

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

徹底二叉樹:每層結點都徹底填滿,在最後一層上若是不是滿的,則只缺乏右邊的若干結點。

一個非平衡樹

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

一個平衡樹

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

聯繫方式

Twitter: @kgibilterra Email: kgibilterra@gmail.com

若是發現譯文存在錯誤或其餘須要改進的地方,歡迎到 掘金翻譯計劃 對譯文進行修改並 PR,也可得到相應獎勵積分。文章開頭的 本文永久連接 即爲本文在 GitHub 上的 MarkDown 連接。


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

相關文章
相關標籤/搜索