[译] 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前端后端区块链产品设计人工智能等领域,想要查看更多优质译文请持续关注 掘金翻译计划官方微博知乎专栏

相关文章
相关标签/搜索