[譯] 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前端後端區塊鏈產品設計人工智能等領域,想要查看更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章
相關標籤/搜索