[译] Golang 数据结构:树

在你编程生涯的大部分时间中你都不用接触到树这个数据结构,或者即便并不理解这个结构,你也能够轻易地避开使用它们(这就是我过去一直在作的事)。html

如今,不要误会个人意思 —— 数组,列表,栈和队列都是很是强大的数据结构,能够帮你在带你在编程之路上走的很远,可是它们没法解决全部的问题,且不论如何去使用它们以及效率如何。当你把哈希表放入这个组合中时,你就能够解决至关多的问题,可是对于许多问题而言,若是你能掌握了树结构,那它将是一个强大的(或许也是惟一的)工具。前端

那么让咱们来看看树结构,而后咱们能够经过一个小练习来学习如何使用它们。node

一点理论

数组,列表,队列,栈把数据储存在有头和尾的集合中,所以它们被称做“线性结构”。可是当涉及到树和图这种数据结构时,这就会变得让人困惑,由于数据并非以线性方式储存到结构中的。android

树被称做非线性结构。实际上,你也能够说树是一种层级数据结构由于它的数据是以分层的方式储存的。ios

为了你阅读的乐趣,下面是维基百科对树结构的定义:git

树是由节点(或顶点)和边组成不包含任何环的数据结构。没有节点的树被称为空树。一颗非空的树是由一个根节点和可能由多个层级的附加节点造成的层级结构组成。github

这个定义所要表示的意思就是树只是节点(或者顶点)和边(或者节点之间的链接)的集合,它不包含任何循环。golang

好比说,图中表示的数据结构就是节点的组合,依次从 A 到 F 命名,有六条边。虽然它的全部元素都使它们看起来像是构造了一棵树,但节点 A,D,F 都有一个循环,所以这个数据结构并非树。算法

若是咱们打断节点 F 和 E 之间的边而且增长一个节点 G,把 G 和 F 用边连起来,咱们会获得像下图这样的结构:编程

如今,由于咱们消除了在图中的循环,能够说咱们如今有了一个有效的树结构。它有一个称做 A 的根部节点,一共有 7 个节点。节点 A 有 3 个子节点(B,D 和 F)以及这些节点下一层的节点(分别为 C,E 和 G)。所以,节点 A 有 6 个子孙节点。此外,这个树有 3 个叶节点(C,E 和 G)或者把它们叫作没有子节点的节点。

B,D 和 F 节点有什么共同之处?由于它们有同一个父节点(节点 A)因此它们是兄弟节点。它们都位于第一层由于其中的每个要到达根节点都只须要一步。例如,节点 G 位于第二层,由于从 G 到 A 的路径为:G -> F -> A,咱们须要走两条边来才能到达节点 A。

如今咱们已经了解了树的一点理论,让咱们来看看如何用树来解决一些问题。

为 HTML 文档建模

若是你是一个从没写过任何 HTML 的软件开发者, 我会假设你已经看到过(或者知道)HTML 是什么样子的。若是你仍是不知道,那么我建议你右键单击当前正在阅读的页面,而后单击“查看源代码”就能够看到。

说真的,去看看吧,我会在这等着的。。。

浏览器有个内置的东西,叫作 DOM —— 一个跨平台且语言独立的应用程序编程接口,它会将这些 网络文档视为一个树结构,其中的每一个节点都是表示文档其中一部分的对象。这意味着当浏览器读取你文档中的 HTML 代码时它将会加载这个文档并基于此建立一个 DOM。

因此,让咱们短暂的设想一下,咱们是 Chrome 或者 Firefox 浏览器的开发者,咱们须要来为 DOM 建模。好吧,为了让这个练习更简单点,让咱们来看一个小的 HTML 文档:

<html>
  <h1>Hello, World!</h1>
  <p>This is a simple HTML document.</p>
</html>
复制代码

因此,若是咱们把这个文档建模成一个树结构,它看起将会是这样:

如今,咱们能够把文本节点视为单独的Node,可是简单起见,咱们能够假设任何 HTML 元素均可以包含文本。

html节点将会有两个子节点,h1p 节点,这些节点包含字段 tagtextchildren 。让咱们把这些放到代码里:

type Node struct {
    tag      string
    text     string
    children []*Node
}
复制代码

一个 Node 将只有标签名和子节点可选。让咱们经过上面看到的 Node 树来亲手尝试建立这个 HTML 文档:

func main() {
        p := Node{
                tag:  "p",
                text: "This is a simple HTML document.",
                id:   "foo",
        }

        h1 := Node{
                tag:  "h1",
                text: "Hello, World!",
        }

        html := Node{
                tag:      "html",
                children: []*Node{&p, &h1},
        }
}
复制代码

这看起来还能够,咱们创建了一个基础的树结构而且运行了。

构建 MyDOM - DOM 的直接替代😂

如今咱们已经有了一些树结构,让咱们退一步来看看 DOM 有哪些功能。好比说,若是在真实环境中用 MyDOM(TM)替代 DOM,那么咱们应该可使用 JavaScript 访问其中的节点并修改它们。

使用 JavaScript 执行这个操做的最简单方法是使用以下代码

document.getElementById('foo')
复制代码

这个函数将会在 document 树中查找以 foo 做为 ID 的节点。让咱们更新咱们的 Node 结构来得到更多的功能,而后为咱们的树结构编写一个查询函数:

type Node struct {
  tag      string
  id       string
  class    string
  children []*Node
}
复制代码

如今,咱们的每一个 Node 结构将会有 tagchildren,它是指向该 Node 子节点的指针切片,id 表示在该 DOM 节点中的 ID,class 指的是可应用于该 DOM 节点的类。

如今回到咱们以前的 getElementById 查询函数。来如何去实现它。首先,让咱们构造一个可用于测试咱们查询算法的树结构:

<html>
  <body>
    <h1>This is a H1</h1>
    <p>
      And this is some text in a paragraph. And next to it there's an image. <img src="http://example.com/logo.svg" alt="Example's Logo"/> </p> <div class='footer'> This is the footer of the page. <span id='copyright'>2019 &copy; Ilija Eftimov</span> </div> </body> </html> 复制代码

这是一个很是复杂的 HTML 文档。让咱们使用 Node 做为 Go 语言中的结构来表示其结构:

image := Node{
        tag: "img",
        src: "http://example.com/logo.svg",
        alt: "Example's Logo",
}

p := Node{
        tag:      "p",
        text:     "And this is some text in a paragraph. And next to it there's an image.",
        children: []*Node{&image},
}

span := Node{
        tag:  "span",
        id:   "copyright",
        text: "2019 &copy; Ilija Eftimov",
}

div := Node{
        tag:      "div",
        class:    "footer",
        text:     "This is the footer of the page.",
        children: []*Node{&span},
}

h1 := Node{
        tag:  "h1",
        text: "This is a H1",
}

body := Node{
        tag:      "body",
        children: []*Node{&h1, &p, &div},
}

html := Node{
        tag:      "html",
        children: []*Node{&body},
}
复制代码

咱们开始自下而上构建这个树结构。这意味着从嵌套最深的结构起来构建这个结构,一直到 bodyhtml 节点。让咱们来看一下这个树结构的图形:

实现节点查询🔎

让咱们来继续实现咱们的目标 —— 让 JavaScript 能够在咱们的 document 中调用 getElementById 并找到它想找到的 Node

为此,咱们须要实现一个树查询算法。搜索(或者遍历)图结构和树结构最流行的方法是广度优先搜索(BFS)和深度优先搜索(DFS)。

广度优先搜素⬅➡

顾名思义,BFS 采用的遍历方式会首先考虑探索节点的“宽度”再考虑“深度”。下面是 BFS 算法遍历整个树结构的可视化图:

正如你所看到的,这个算法会先在深度上走两步(经过 htmlbody 节点),而后它会遍历 body 的全部子节点,最后深刻到下一层从而访问到 spanimg 节点。

若是你想要一步一步的说明,它将会是:

  1. 咱们从根部 html 节点开始
  2. 咱们把它推到 queue
  3. 咱们开始进入一个循环,若是 queue 不为空,这个循环会一直运行
  4. 咱们检查 queue 中的下一个元素是否与查询的匹配。若是匹配上了,咱们就返回这个节点而后整个就结束了
  5. 当找不到匹配项时,咱们把被检查节点的子节点都放入队列中,这样就能够在以后检查它们了
  6. GOTO 第四步

让咱们看看在 Go 里面这个算法的简单实现,我将会分享一些如何能够轻松记住算法的建议。

func findById(root *Node, id string) *Node {
        queue := make([]*Node, 0)
        queue = append(queue, root)
        for len(queue) > 0 {
                nextUp := queue[0]
                queue = queue[1:]
                if nextUp.id == id {
                        return nextUp
                }
                if len(nextUp.children) > 0 {
                        for _, child := range nextUp.children {
                                queue = append(queue, child)
                        }
                }
        }
        return nil
}
复制代码

这个算法有 3 个关键点:

  1. queue —— 它将包含算法访问的全部节点
  2. 获取 queue 中的第一个元素,检查它是否匹配,若是该节点未匹配,则继续下一个节点
  3. 在查看 queue 的下一个元素以前把节点的全部子节点都入队列

从本质上讲,整个算法围绕着在队列中推入子节点和检测已经在队列中的节点实现。固然,若是在队列的末尾仍是找不到匹配项的话咱们就返回 nil 而不是指向 Node 的指针。

深度优先搜索 ⬇

为了完整起见,让咱们来看看 DFS 是如何工做的。

如前所述,深度优先搜索首先会在深度上访问尽量多的节点,直到到达树结构中的一个叶节点。当这种状况发生时,它就会回溯到上面的节点并在树结构中找到另外一个分支再继续向下访问。

让咱们看下这看起来意味着什么:

若是这让你以为困惑,请不要担忧——我在讲述步骤中增长了更多的细节支持个人解释。

这个算法开始就像 BFS 同样 —— 它从 htmlbody 再到 div 节点。而后,与之不一样的是,该算法并无继续遍历到 h1 节点,它往叶节点 span 前进了一步。一旦它发现 span 是个叶节点,它就会返回 div 节点以查找其它分支去探索。由于在 div 也找不到,因此它会移回 body 节点,在这个节点它找到了一个新分支,它就会去访问该分支中的 h1 节点。而后,它会继续以前一样的步骤 —— 返回 body 节点而后发现还有另外一个分支要去探索 —— 最后会访问到 pimg 节点。

若是你想要知道“咱们如何在没有指向父节点指针状况下返回到父节点的话”,那么你已经忘了在书中最古老的技巧之一 —— 递归。让咱们来看下这个算法在 Go 中的简单递归实现:

func findByIdDFS(node *Node, id string) *Node {
        if node.id == id {
                return node
        }

        if len(node.children) > 0 {
                for _, child := range node.children {
                        findByIdDFS(child, id)
                }
        }
        return nil
}
复制代码

经过类名搜索🔎

MyDOM(TM)应该具备的另外一个功能是经过类名来查找节点。基本上,当 JavaScript 脚本执行 getElementsByClassName 时,MyDOM 应该知道如何收集具备某个特定类名的全部节点。

能够想像,这也是一种必须探寻整个 MyDOM(TM)结构树从中获取符合特定条件的节点的算法。

简单起见,咱们先来实现一个 Node 结构的方法,叫作 hasClass

func (n *Node) hasClass(className string) bool {
        classes := strings.Fields(n.classes)
        for _, class := range classes {
                if class == className {
                        return true
                }
        }
        return false
}
复制代码

hasClass 获取 Node 结构的 classes 字段,经过空格字符来分割它们,而后再循环这个 classes 的切片并尝试查找到咱们想要的类名。让咱们来写几个测试用例来验证这个函数:

type testcase struct {
        className      string
        node           Node
        expectedResult bool
}

func TestHasClass(t *testing.T) {
        cases := []testcase{
                testcase{
                        className:      "foo",
                        node:           Node{classes: "foo bar"},
                        expectedResult: true,
                },
                testcase{
                        className:      "foo",
                        node:           Node{classes: "bar baz qux"},
                        expectedResult: false,
                },
                testcase{
                        className:      "bar",
                        node:           Node{classes: ""},
                        expectedResult: false,
                },
        }

        for _, case := range cases {
                result := case.node.hasClass(test.className)
                if result != case.expectedResult {
                        t.Error(
                                "For node", case.node,
                                "and class", case.className,
                                "expected", case.expectedResult,
                                "got", result,
                        )
                }
        }
}
复制代码

如你所见,hasClass 函数会检测 Node 的类名是否在类名列表中。如今,让咱们继续完成对 MyDOM 的实现,即经过类名来查找全部匹配的 Node

func findAllByClassName(root *Node, className string) []*Node {
        result := make([]*Node, 0)
        queue := make([]*Node, 0)
        queue = append(queue, root)
        for len(queue) > 0 {
                nextUp := queue[0]
                queue = queue[1:]
                if nextUp.hasClass(className) {
                        result = append(result, nextUp)
                }
                if len(nextUp.children) > 0 {
                        for _, child := range nextUp.children {
                                queue = append(queue, child)
                        }
                }
        }
        return result
}
复制代码

这个算法是否是看起来很熟悉?那是由于你正在看的是一个修改过的 findById 函数。findAllByClassName 的运做方式和 findById 相似,可是它不会在找到匹配项后就直接返回,而是将匹配到的 Node 加到 result 切片中。它将会继续执行循环操做,直到遍历了全部的 Node

若是没有找到匹配项,那么 result 切片将会是空的。若是其中有任何匹配到的,它们都将做为 result 的一部分返回。

最后要注意的是在这里咱们使用的是广度优先的方式来遍历树结构 —— 这种算法使用队列来储存每一个 Node 结构,在这个队列中进行循环若是找到匹配项就把它们加入到 result 切片中。

删除节点 🗑

另外一个在 Dom 中常用的功能就是删除节点。就像 DOM 能够作到这个同样,咱们的MyDOM(TM)也应该能够进行这种操做。

在 Javascript 中执行这个操做的最简单方法是:

var el = document.getElementById('foo');
el.remove();
复制代码

尽管咱们的 document 知道如何去处理 getElementById(在后面经过调用 findById),但咱们的 Node 并不知道如何去处理一个 remove 函数。从 MyDOM(TM)中删除 Node 将会须要两个步骤:

  1. 咱们找到 Node 的父节点而后把它从父节点的子节点集合中删去;
  2. 若是要删除的 Node 有子节点,咱们必须从 DOM 中删除这些子节点。这意味着咱们必须删除全部指向这些子节点的指针和它们的父节点(也就是要被删除的节点),这样 Go 里的垃圾收集器才能够释放这些被占用的内存。

这是实现上述的一个简单方式:

func (node *Node) remove() {
        // Remove the node from it's parents children collection for idx, sibling := range n.parent.children { if sibling == node { node.parent.children = append( node.parent.children[:idx], node.parent.children[idx+1:]..., ) } } // If the node has any children, set their parent to nil and set the node's children collection to nil
        if len(node.children) != 0 {
                for _, child := range node.children {
                        child.parent = nil
                }
                node.children = nil
        }
}
复制代码

一个 *Node 将会拥有一个 remove 函数,它会执行上面所描述的两个步骤来实现 Node 的删除操做。

在第一步中,咱们把这个节点从 parent 节点的子节点列表中取出来,经过遍历这些子节点,合并这个节点前面的元素和后面的元素组成一个新的列表来删除这个节点。

在第二步中,在检查这个节点是否存在子节点以后,咱们将全部子节点中的 parent 引用删除,而后把这个 Node 的子节点字段设为 nil

接下来呢?

显然,咱们的 MyDOM(TM)实现永远不可能替代 DOM。可是,我相信这是一个有趣的例子能够帮助你学习,这也是一个颇有趣的问题。咱们天天都与浏览器交互,所以思考它们暗地里是如何工做的会是一个有趣的练习。

若是你想使用咱们的树结构并为其写更多的功能,你能够访问 WC3 的 JavaScript HTML DOM 文档而后考虑为 MyDOM 增长更多的功能。

显然,本文的主旨是为了让你了解更多关于树(图)结构的信息,了解目前流行的搜索/遍历算法。可是,不管如何请保持探索和实践,若是对你的 MyDOM 实现有任何改进请在文章下面留个评论。

若是发现译文存在错误或其余须要改进的地方,欢迎到 掘金翻译计划 对译文进行修改并 PR,也可得到相应奖励积分。文章开头的 本文永久连接 即为本文在 GitHub 上的 MarkDown 连接。


掘金翻译计划 是一个翻译优质互联网技术文章的社区,文章来源为 掘金 上的英文分享文章。内容覆盖 AndroidiOS前端后端区块链产品设计人工智能等领域,想要查看更多优质译文请持续关注 掘金翻译计划官方微博知乎专栏

相关文章
相关标签/搜索