JavaScript數據結構:樹
Chinese (Traditional) (中文(繁體)) translation by Stypstive (you can also view the original English article)



樹是在 web 開發中最常用的數據結構之一。 這種說法對開發者和用戶都是正確的。 每個編寫HTML並將其加載到Web瀏覽器的Web開發人員都創建了一個樹,稱為文檔對象模型(DOM)。 相應地,每個網絡上的消費信息的人,接受信息也以DOM樹的形式。
現在,高潮來了:你正在讀的本文在瀏覽器中就是以樹的形式進行渲染的。 元素又嵌套在元素中;元素又嵌套在元素中。文字由<p>
元素進行表示;<p>
元素又嵌套在<body>
元素中;<body>
元素又嵌套在<html>
元素中。
這些嵌套數據和家族數類似。 <html>
是父元素,<body>
是子元素,<p>
又是<body>
的子元素。 如果这个比喻对你有点用的话,你将会发现在我们介绍树的时候会用到更多的类比。
在本文中,我們將會通過兩個不同的樹遍歷方式來創建一個樹:深度優先(DFS)和廣度優先(BFS)。 如果你對遍歷這個詞感到比較陌生,不妨將他想象成訪問樹中的每一個節點。) 這兩種類型的遍歷強調了與樹交互的不同方式,而且,結合這個系列的數據結構。 DFS和BFS分別用棧和隊列來訪問節點。 聽起來很酷!!!
樹(深度搜索和廣度搜索)
在計算機科學中,樹是一種用節點來模擬分層數據的數據結構。 每個樹節點都包含他本身的數據及指向其他節點的指針。
節點和指針這些術語可能對一些讀者來說比較陌生,所以讓我們用類比來進一步描述他們。 讓我們將樹與組織圖結構圖進行比較。 這個結構圖有一個頂級位置(根節點),比如CEO。 在這個節點下面還有一些其他的節點,比如副總裁(VP)。 在這個節點下面還有一些其他的節點,比如副總裁(VP)。
為了表示這種關系,我們用箭頭從CEO指向VP。 一個位置,比如CEO,是一個節點;我們創建的CEO到VP的關系是一個指針。 在我們的組織結構圖中去創建更多的關系,我們只要重復這些步驟即可---我們讓一個節點指向另一個節點。
在概念層次上,我希望節點和指針有意義。 在實際中,我們能從更科學的實例中獲取收益。 讓我們來思考DOM。 DOM有<html>
元素作為其頂級位置(根節點)。 這個節點指向<head>
元素和<body>
元素。 這些步驟在DOM的所有節點中重復。
這種設計的一個優點是能夠嵌套節點:例如:一個<ul>
元素能夠包含很多個<li>
元素;此外,每個<li>
元素能擁有兄弟<li>
元素。 著很怪異,但是確實真實有趣! 著很怪異,但是確實真實有趣!
操作樹
由於每個樹都包含節點,其可以是來自樹的單獨構造器,我們將概述兩個構造函數的操作:Node
和Tree
節點
-
data
存儲值。 -
parent
指向節點的父節點。 -
children
指向列表中的下一個節點。
樹
-
_root
指向一個樹的根節點。 -
traverseDF(callback)
對樹進行DFS遍歷。 -
traverseBF(callback)
對樹進行BFS遍歷。 -
contains(data, traversal)
搜索樹中的節點。 -
add(data, toData, traverse)
向樹中添加節點。 -
remove(child, parent)
移除樹中的節點。
實現樹
現在讓我們寫下樹的代碼!
節點的屬性
在我們的實現中,我們首先定義一個叫做Node
的函數,然後構造一個Tree
。
function Node(data) { this.data = data; this.parent = null; this.children = []; }
每一個Node實例都包含三個屬性:data
,parant
,和children
。 第一個屬性保存與節點相關聯的數據。 第二個屬性指向一個節點。 第三個屬性指向許多子節點。
樹的屬性
現在讓我們來定義Tree
的構造函數,其中包括Node
構造函數的定義:
function Tree(data) { var node = new Node(data); this._root = node; }
Tree
包含兩行代碼。 第一行創建了一個Node
的新實例;第二行讓node
等於樹的根節點。
Tree
和Node
的定義只需要幾行代碼。 但是,通過這幾行足以幫助我們模擬分層數據。 為了證明這一點,讓我們用一些示例數據去創建Tree
的示例(和間接的Node
)。
var tree = new Tree('CEO'); // {data: 'CEO', parent: null, children: []} tree._root;
幸虧有parent
和children
的存在,我們可以為_root
添加子節點和讓這些子節點的父節點等於_root
。 換一種說法,我們可以模擬分層數據的創建。
Tree的方法
接下來我們將要創建以下五種方法。
樹
traverseDF(callback)
traverseBF(callback)
contains(data, traversal)
add(child, parent)
remove(node, parent)
因為每種方法都需要我們去遍歷一個樹,所以我們首先要實現一個方法去定義不同的樹遍歷。 (遍歷樹是一種正式的方式來訪問樹的每個節點。)
1 of 5: traverseDF(callback)
這種方法以DFS這種方式遍歷樹。
Tree.prototype.traverseDF = function(callback) { // this is a recurse and immediately-invoking function (function recurse(currentNode) { // step 2 for (var i = 0, length = currentNode.children.length; i < length; i++) { // step 3 recurse(currentNode.children[i]); } // step 4 callback(currentNode); // step 1 })(this._root); };
traverseDF(callback)
有一個參數callback
。 如果對這個名字不明白,callback
被假定是一個函數,將在後面被traverseDF(callback)
調用。
traverseDF(callback)
的函數體含有另一個叫做recurse
的函數。 這個函數是一個遞歸函數! 換句話說,它是自我調用和自我終止。 使用recurse
的註釋中提到的步驟,我將描述遞歸用來recurse
整個樹的一般過程。
這裏是步驟:
- 立即使用樹的根節點作為其參數調用
recurse
。 此時,currentNode
指向當前節點。 - 進入
for
循環並且從第一個子節點開始,每一個子節點都叠代一次currentNode
函數。 - 在
for
循環體內,使用currentNode
的子元素調用遞歸。 確切的子節點取決於當前for
循環的當前叠代。 - 當
currentNode
不存在子節點時,我們退出for
循環並callback
我們在調用traverseDF(callback)
期間傳遞的回調。
步驟2(自終止),3(自調用)和4(回調)重復,直到我們遍歷樹的每個節點。
遞歸是一個非常困難的話題,需要一個完整的文章來充分解釋它。 由於遞歸的解釋不是本文的重點 - 重點是實現一棵樹 - 我建議任何讀者沒有很好地掌握遞歸做以下兩件事。
首先,實驗我們當前的traverseDF(callback)
實現,並嘗試一定程度上理解它是如何工作的。 第二,如果你想要我寫一篇關於遞歸的文章,那麽請在本文的評論中請求它。
以下示例演示如何使用traverseDF(callback)
遍歷樹。 要遍歷樹,我將在下面的示例中創建一個。 我現在使用的方法不是理想的,但它能很好的工作。 一個更好的方法是使用add(value)
,我們將在第4步和第5步中實現。
var tree = new Tree('one'); tree._root.children.push(new Node('two')); tree._root.children[0].parent = tree; tree._root.children.push(new Node('three')); tree._root.children[1].parent = tree; tree._root.children.push(new Node('four')); tree._root.children[2].parent = tree; tree._root.children[0].children.push(new Node('five')); tree._root.children[0].children[0].parent = tree._root.children[0]; tree._root.children[0].children.push(new Node('six')); tree._root.children[0].children[1].parent = tree._root.children[0]; tree._root.children[2].children.push(new Node('seven')); tree._root.children[2].children[0].parent = tree._root.children[2]; /* creates this tree one ├── two │ ├── five │ └── six ├── three └── four └── seven */
現在,讓我們調用traverseDF(callback)
tree.traverseDF(function(node) { console.log(node.data) }); /* logs the following strings to the console 'five' 'six' 'two' 'three' 'seven' 'four' 'one' */
2 of 5: traverseBF(callback)
這個方法使用深度優先搜索去遍歷樹
深度優先搜索和廣度優先搜索之間的差別涉及樹的節點訪問的序列。 為了說明這一點,讓我們使用我們從traverseDF(callback)
創建的樹。
/* tree one (depth: 0) ├── two (depth: 1) │ ├── five (depth: 2) │ └── six (depth: 2) ├── three (depth: 1) └── four (depth: 1) └── seven (depth: 2) */
現在,讓我們傳遞traverseBF(callback)
和我們用於traverseDF(callback)
的回調。
tree.traverseBF(function(node) { console.log(node.data) }); /* logs the following strings to the console 'one' 'two' 'three' 'four' 'five' 'six' 'seven' */
來自控制臺的日誌和我們的樹的圖顯示了關於廣度優先搜索的模式。 從根節點開始;然後行進一個深度並訪問該深度從左到右的每個節點。 重復此過程,直到沒有更多的深度要移動。
由於我們有一個廣度優先搜索的概念模型,現在讓我們實現使我們的示例工作的代碼。
Tree.prototype.traverseBF = function(callback) { var queue = new Queue(); queue.enqueue(this._root); currentTree = queue.dequeue(); while(currentTree){ for (var i = 0, length = currentTree.children.length; i < length; i++) { queue.enqueue(currentTree.children[i]); } callback(currentTree); currentTree = queue.dequeue(); } };
我們對traverseBF(callback)
的定義包含了很多邏輯。 因此,我將以可管理的步驟解釋邏輯:
- 創建
Queue
的實例。 - 將調用
traverseBF(callback)
的節點添加到Queue
的實例。 - 定義一個變量
currentNode
並且將他的值初始化為剛才添加到隊列裏的node。
- 當
currentNode
指向一個節點時,執行wille
循環裏面的代碼。 - 用
for
循環去叠代currentNode
的子節點。 - 在
for
循環體內,將每個子元素加入隊列。 - 獲取
currentNode
並將其作為callback
的參數傳遞。 - 將
currentNode
重新分配給正從隊列中刪除的節點。 - 直到
currentNode
不在指向任何節點-也就是說樹中的每個節點都訪問過了-重復4-8步。
3 of 5: contains(callback, traversal)
讓我們定義一個方法,讓我們在樹中搜索一個特定的值。 去使用我們創建的任一種樹遍歷方法,我們已經定義了contains(callback, traversal)
接收兩個參數:搜索的數據和遍歷的類型。
Tree.prototype.contains = function(callback, traversal) { traversal.call(this, callback); };
在contains(callback, traversal)
函數體內,我們用call
方法去傳遞this
和callback
。 第一個參數將traversal
綁定到被調用的樹contains(callback,traversal)
;第二个参数是树种每个节点被调用的函数。
想象一下,我們要將包含奇數數據的任何節點記錄到控制臺,並使用BFS遍歷樹中的每個節點。 代碼我們可以這麽寫:
// tree is an example of a root node tree.contains(function(node){ if (node.data === 'two') { console.log(node); } }, tree.traverseBF);
4 of 5: add(data, toData, traversal)
現在我們有了一個可以搜索樹中特定節點的方法。 現在讓我們定義一個允許我們向指定節點添加節點的方法。
Tree.prototype.add = function(data, toData, traversal) { var child = new Node(data), parent = null, callback = function(node) { if (node.data === toData) { parent = node; } }; this.contains(callback, traversal); if (parent) { parent.children.push(child); child.parent = parent; } else { throw new Error('Cannot add node to a non-existent parent.'); } };
add(data, toData, traversal)
定義了三個參數。 第一個參數,data
,用來創建一個Node
的新實例。 第二個參數,toData
,用來比較樹中的每個節點。 第三個參數,traversal
,是這個方法中用來遍歷樹的類型。
在add(data, toData, traversal)
函數體內,我們聲明了三個變量。 第一個變量,child
,代表初始化的Node
實例。 第二個變量,parent
,初始化為null
;但是將來會指向匹配toData
值的樹中的任意節點。 parent
的重新分配發生在我們聲明的第三個變量,這就是callback
。
callback
是一個將toData
和每一個節點的data
屬性做比較的函數。 如果if
語句的值是true
,那麽parent
將被賦值給if
語句中匹配比較的節點。
每個節點的toData
比較發生在contains(callback, traversal)
。 遍歷類型和callback
必須作為contains(callback, traversal)
的參數進行傳遞。
最後,如果parent
不存在於樹中,我們將child
推入parent.children
; 同時也要將parent
賦值給child
的父級。 否則,將拋出錯誤。 否則,將拋出錯誤。
讓我們用add(data, toData, traversal)
做個例子:
var tree = new Tree('CEO'); tree.add('VP of Happiness', 'CEO', tree.traverseBF); /* our tree 'CEO' └── 'VP of Happiness' */
這裏是add(addData, toData, traversal)
的更加復雜的例子:
var tree = new Tree('CEO'); tree.add('VP of Happiness', 'CEO', tree.traverseBF); tree.add('VP of Finance', 'CEO', tree.traverseBF); tree.add('VP of Sadness', 'CEO', tree.traverseBF); tree.add('Director of Puppies', 'VP of Finance', tree.traverseBF); tree.add('Manager of Puppies', 'Director of Puppies', tree.traverseBF); /* tree 'CEO' ├── 'VP of Happiness' ├── 'VP of Finance' │ ├── 'Director of Puppies' │ └── 'Manager of Puppies' └── 'VP of Sadness' */
5 of 5: remove(data, fromData, traversal)
為了完成Tree
的實現,我們將添加一個叫做remove(data, fromData, traversal)
的方法。 跟從DOM裏面移除節點類似,這個方法將移除一個節點和他的所有子級。
Tree.prototype.remove = function(data, fromData, traversal) { var tree = this, parent = null, childToRemove = null, index; var callback = function(node) { if (node.data === fromData) { parent = node; } }; this.contains(callback, traversal); if (parent) { index = findIndex(parent.children, data); if (index === undefined) { throw new Error('Node to remove does not exist.'); } else { childToRemove = parent.children.splice(index, 1); } } else { throw new Error('Parent does not exist.'); } return childToRemove; };
與add(data, toData, traversal)
類似,移除將遍歷樹以查找包含第二個參數的節點,現在為fromData
。 如果這個節點被發現了,那麽parent
將指向它。
在這時候,我們到達了第一個if
語句。 如果parent
不存在,將拋出錯誤。 如果parent
不存在,我們使用parent.children
調用findIndex()
和我們要從parent節點的子節點中刪除的數據 (findIndex()
是一個幫助方法,我將在下面定義。) (findIndex()
是一個幫助方法,我將在下面定義。)
function findIndex(arr, data) { var index; for (var i = 0; i < arr.length; i++) { if (arr[i].data === data) { index = i; } } return index; }
在findIndex()
裏面,以下邏輯將發生。 如果parent.children
中的任意一個節點包含匹配data
值的數據,那麽變量index
賦值為一個整數。 如果沒有子級的數值屬性匹配data
,那麽index
保留他的默認值undefined
。 在最後一行的findIndex()
方法,我們返回一個index
。
我們現在去remove(data, fromData, traversal)
。 如果index
的值是undefined
,將會拋出錯誤。 如果index
的值存在,我們用它來拼接我們想從parent
的子節點中刪除的節點。同樣我們給刪除的子級賦值為childToRemove
。
最後,我們返回childToRemove
。
至此我們完成了Tree的完整實現。
至此我們完成了Tree的完整實現。 回過頭看看-我們完成了很多工作:
function Node(data) { this.data = data; this.parent = null; this.children = []; } function Tree(data) { var node = new Node(data); this._root = node; } Tree.prototype.traverseDF = function(callback) { // this is a recurse and immediately-invoking function (function recurse(currentNode) { // step 2 for (var i = 0, length = currentNode.children.length; i < length; i++) { // step 3 recurse(currentNode.children[i]); } // step 4 callback(currentNode); // step 1 })(this._root); }; Tree.prototype.traverseBF = function(callback) { var queue = new Queue(); queue.enqueue(this._root); currentTree = queue.dequeue(); while(currentTree){ for (var i = 0, length = currentTree.children.length; i < length; i++) { queue.enqueue(currentTree.children[i]); } callback(currentTree); currentTree = queue.dequeue(); } }; Tree.prototype.contains = function(callback, traversal) { traversal.call(this, callback); }; Tree.prototype.add = function(data, toData, traversal) { var child = new Node(data), parent = null, callback = function(node) { if (node.data === toData) { parent = node; } }; this.contains(callback, traversal); if (parent) { parent.children.push(child); child.parent = parent; } else { throw new Error('Cannot add node to a non-existent parent.'); } }; Tree.prototype.remove = function(data, fromData, traversal) { var tree = this, parent = null, childToRemove = null, index; var callback = function(node) { if (node.data === fromData) { parent = node; } }; this.contains(callback, traversal); if (parent) { index = findIndex(parent.children, data); if (index === undefined) { throw new Error('Node to remove does not exist.'); } else { childToRemove = parent.children.splice(index, 1); } } else { throw new Error('Parent does not exist.'); } return childToRemove; }; function findIndex(arr, data) { var index; for (var i = 0; i < arr.length; i++) { if (arr[i].data === data) { index = i; } } return index; }
總結
樹的模擬分層數據。 我們的周圍有許多類似於這種類型的層次結構,如網頁和我們的家庭。 任何時候你發現自己需要結構化數據與層次結構,考慮使用樹。
Subscribe below and we’ll send you a weekly email summary of all new Code tutorials. Never miss out on learning about the next big thing.
Update me weekly