[譯] Golang 資料結構:樹

掘金翻譯計劃發表於2019-03-17

在你程式設計生涯的大部分時間中你都不用接觸到樹這個資料結構,或者即使並不理解這個結構,你也可以輕易地避開使用它們(這就是我過去一直在做的事)。

現在,不要誤會我的意思 —— 陣列,列表,棧和佇列都是非常強大的資料結構,可以幫你在帶你在程式設計之路上走的很遠,但是它們無法解決所有的問題,且不論如何去使用它們以及效率如何。當你把雜湊表放入這個組合中時,你就可以解決相當多的問題,但是對於許多問題而言,如果你能掌握了樹結構,那它將是一個強大的(或許也是唯一的)工具。

那麼讓我們來看看樹結構,然後我們可以通過一個小練習來學習如何使用它們。

一點理論

陣列,列表,佇列,棧把資料儲存在有頭和尾的集合中,因此它們被稱作“線性結構”。但是當涉及到樹和圖這種資料結構時,這就會變得讓人困惑,因為資料並不是以線性方式儲存到結構中的。

樹被稱作非線性結構。實際上,你也可以說樹是一種層級資料結構因為它的資料是以分層的方式儲存的。

為了你閱讀的樂趣,下面是維基百科對樹結構的定義:

樹是由節點(或頂點)和邊組成不包含任何環的資料結構。沒有節點的樹被稱為空樹。一顆非空的樹是由一個根節點和可能由多個層級的附加節點形成的層級結構組成。

這個定義所要表示的意思就是樹只是節點(或者頂點)和邊(或者節點之間的連線)的集合,它不包含任何迴圈。

[譯] Golang 資料結構:樹

比如說,圖中表示的資料結構就是節點的組合,依次從 A 到 F 命名,有六條邊。雖然它的所有元素都使它們看起來像是構造了一棵樹,但節點 A,D,F 都有一個迴圈,因此這個資料結構並不是樹。

如果我們打斷節點 F 和 E 之間的邊並且增加一個節點 G,把 G 和 F 用邊連起來,我們會得到像下圖這樣的結構:

[譯] Golang 資料結構:樹

現在,因為我們消除了在圖中的迴圈,可以說我們現在有了一個有效的樹結構。它有一個稱作 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>
複製程式碼

所以,如果我們把這個文件建模成一個樹結構,它看起將會是這樣:

[譯] Golang 資料結構:樹

現在,我們可以把文字節點視為單獨的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 節點。讓我們來看一下這個樹結構的圖形:

[譯] Golang 資料結構:樹

實現節點查詢?

讓我們來繼續實現我們的目標 —— 讓 JavaScript 可以在我們的 document 中呼叫 getElementById 並找到它想找到的 Node

為此,我們需要實現一個樹查詢演算法。搜尋(或者遍歷)圖結構和樹結構最流行的方法是廣度優先搜尋(BFS)和深度優先搜尋(DFS)。

廣度優先搜素⬅➡

顧名思義,BFS 採用的遍歷方式會首先考慮探索節點的“寬度”再考慮“深度”。下面是 BFS 演算法遍歷整個樹結構的視覺化圖:

[譯] Golang 資料結構:樹

正如你所看到的,這個演算法會先在深度上走兩步(通過 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 是如何工作的。

如前所述,深度優先搜尋首先會在深度上訪問儘可能多的節點,直到到達樹結構中的一個葉節點。當這種情況發生時,它就會回溯到上面的節點並在樹結構中找到另一個分支再繼續向下訪問。

讓我們看下這看起來意味著什麼:

[譯] Golang 資料結構:樹

如果這讓你覺得困惑,請不要擔心——我在講述步驟中增加了更多的細節支援我的解釋。

這個演算法開始就像 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前端後端區塊鏈產品設計人工智慧等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章