- 原文地址:Golang Datastructures: Trees
- 原文做者:Ilija Eftimov
- 譯文出自:掘金翻譯計劃
- 本文永久連接:github.com/xitu/gold-m…
- 譯者:steinliber
- 校對者:Endone,LeoooY
在你編程生涯的大部分時間中你都不用接觸到樹這個數據結構,或者即便並不理解這個結構,你也能夠輕易地避開使用它們(這就是我過去一直在作的事)。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 是什麼樣子的。若是你仍是不知道,那麼我建議你右鍵單擊當前正在閱讀的頁面,而後單擊「查看源代碼」就能夠看到。
說真的,去看看吧,我會在這等着的。。。
瀏覽器有個內置的東西,叫作 DOM —— 一個跨平臺且語言獨立的應用程序編程接口,它會將這些 網絡文檔視爲一個樹結構,其中的每一個節點都是表示文檔其中一部分的對象。這意味着當瀏覽器讀取你文檔中的 HTML 代碼時它將會加載這個文檔並基於此建立一個 DOM。
因此,讓咱們短暫的設想一下,咱們是 Chrome 或者 Firefox 瀏覽器的開發者,咱們須要來爲 DOM 建模。好吧,爲了讓這個練習更簡單點,讓咱們來看一個小的 HTML 文檔:
<html>
<h1>Hello, World!</h1>
<p>This is a simple HTML document.</p>
</html>
複製代碼
因此,若是咱們把這個文檔建模成一個樹結構,它看起將會是這樣:
如今,咱們能夠把文本節點視爲單獨的Node
,可是簡單起見,咱們能夠假設任何 HTML 元素均可以包含文本。
html
節點將會有兩個子節點,h1
和 p
節點,這些節點包含字段 tag
,text
和 children
。讓咱們把這些放到代碼裏:
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}, } } 複製代碼
這看起來還能夠,咱們創建了一個基礎的樹結構而且運行了。
如今咱們已經有了一些樹結構,讓咱們退一步來看看 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
結構將會有 tag
,children
,它是指向該 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 © 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 © 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}, } 複製代碼
咱們開始自下而上構建這個樹結構。這意味着從嵌套最深的結構起來構建這個結構,一直到 body
和 html
節點。讓咱們來看一下這個樹結構的圖形:
讓咱們來繼續實現咱們的目標 —— 讓 JavaScript 能夠在咱們的 document
中調用 getElementById
並找到它想找到的 Node
。
爲此,咱們須要實現一個樹查詢算法。搜索(或者遍歷)圖結構和樹結構最流行的方法是廣度優先搜索(BFS)和深度優先搜索(DFS)。
顧名思義,BFS 採用的遍歷方式會首先考慮探索節點的「寬度」再考慮「深度」。下面是 BFS 算法遍歷整個樹結構的可視化圖:
正如你所看到的,這個算法會先在深度上走兩步(經過 html
和 body
節點),而後它會遍歷 body
的全部子節點,最後深刻到下一層從而訪問到 span
和 img
節點。
若是你想要一步一步的說明,它將會是:
html
節點開始queue
queue
不爲空,這個循環會一直運行queue
中的下一個元素是否與查詢的匹配。若是匹配上了,咱們就返回這個節點而後整個就結束了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 個關鍵點:
queue
—— 它將包含算法訪問的全部節點queue
中的第一個元素,檢查它是否匹配,若是該節點未匹配,則繼續下一個節點queue
的下一個元素以前把節點的全部子節點都入隊列。從本質上講,整個算法圍繞着在隊列中推入子節點和檢測已經在隊列中的節點實現。固然,若是在隊列的末尾仍是找不到匹配項的話咱們就返回 nil
而不是指向 Node
的指針。
爲了完整起見,讓咱們來看看 DFS 是如何工做的。
如前所述,深度優先搜索首先會在深度上訪問儘量多的節點,直到到達樹結構中的一個葉節點。當這種狀況發生時,它就會回溯到上面的節點並在樹結構中找到另外一個分支再繼續向下訪問。
讓咱們看下這看起來意味着什麼:
若是這讓你以爲困惑,請不要擔憂——我在講述步驟中增長了更多的細節支持個人解釋。
這個算法開始就像 BFS 同樣 —— 它從 html
到 body
再到 div
節點。而後,與之不一樣的是,該算法並無繼續遍歷到 h1
節點,它往葉節點 span
前進了一步。一旦它發現 span
是個葉節點,它就會返回 div
節點以查找其它分支去探索。由於在 div
也找不到,因此它會移回 body
節點,在這個節點它找到了一個新分支,它就會去訪問該分支中的 h1
節點。而後,它會繼續以前一樣的步驟 —— 返回 body
節點而後發現還有另外一個分支要去探索 —— 最後會訪問到 p
和 img
節點。
若是你想要知道「咱們如何在沒有指向父節點指針狀況下返回到父節點的話」,那麼你已經忘了在書中最古老的技巧之一 —— 遞歸。讓咱們來看下這個算法在 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
將會須要兩個步驟:
Node
的父節點而後把它從父節點的子節點集合中刪去;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 連接。
掘金翻譯計劃 是一個翻譯優質互聯網技術文章的社區,文章來源爲 掘金 上的英文分享文章。內容覆蓋 Android、iOS、前端、後端、區塊鏈、產品、設計、人工智能等領域,想要查看更多優質譯文請持續關注 掘金翻譯計劃、官方微博、知乎專欄。