你每天都那麼努力,忍受了那麼多的寂寞和痛苦。可我也沒見你有多優秀。
當我還是一個年輕男孩的時候畫的一張關於樹的畫。
當你第一次學習編碼時,大部分人都是將陣列作為主要資料結構來學習。
之後,你將會學習到雜湊表。如果你是計算機專業的,你肯定需要選修一門資料結構的課程。上課時,你又會學習到連結串列,佇列和棧等資料結構。這些都被統稱為線性的資料結構,因為它們在邏輯上都有起點和終點。
當你開始學習樹和圖的資料結構時,你會覺得它是如此的混亂。因為它的儲存方式不是線性的,它們都有自己特定的方式儲存資料。
這篇文章幫助你更好的理解樹形資料結構並儘可能的幫你解決你的疑問。本章我們將學到
- 是什麼是樹?
- 一個簡單樹的例子
- 樹的術語和工作原理
- 如何在程式碼中實現樹結構
定義
當學習程式設計時,我們更容易理解線性的資料結構而不是樹和圖的資料結構。
樹是眾所周知的非線性資料結構。它們不以線性方式儲存資料。他們按層次組織資料。
我們來舉例一個現實生活中的例子
我們所說的層次組織到底是是什麼呢?
想象一下我們的家譜:祖父母,父母,子女,兄弟姐妹等等,我們通常按層次結構組織家譜。
我的家庭族譜
上圖是我的家譜。tossico,akikazu,hitomi和takemi是我的祖父母。
Toshiaki 和 Juliana 是我的父母。
TK 、Yuji 、Bruno 和 Kaio 是我父母的孩子(我和我的兄弟們)。
另一個層次結構的例子是企業的組織結構。
公司的結構也是是一個層次結構的例子
在 HTML 中,文件物件模型(DOM)是樹形結構的。
文件物件模型(dom)
HTML 標籤包含其他的標籤。我們有一個 head 標籤和 body 標籤。這些標籤包含特點的元素。head 標籤中有 meta 和 title 標籤。body 標籤中有在使用者介面展示的標籤,如 h1 、a 、li 等等。
樹的術語定義
樹(tree
)是被稱為結點(node
)的實體的集合。結點通過邊(edge
)連線。每個結點都包含值或資料(value/date
),並且每結節點可能有也可能沒有子結點。
樹的首結點叫根結點(即root
結點)。如果這個根結點和其他結點所連線,那麼根結點是父結點(parent node
,與根結點連線的是子結點(child node
)。
所有的結點都通過邊(edge
)連線。它是樹中很重要得一個概念,因為它負責管理節點之間的關係。
葉子結點(leaves
)是樹末端,它們沒有子結點。像真正的大樹一樣,我們可以看到樹上有根、枝幹和樹葉。
樹的高度(height
)和深度(depth
)
- 樹的高度是到葉子結點(樹末端)的長度
- 結點的深度是它到根結點的長度
術語彙總
- 根結點是樹最頂層結點
- 邊是兩個結點之間的連線
- 子結點是具有父結點的結點
- 父結點是與子結點有連線的結點
- 葉子結點是樹中沒有子結點的結點(樹得末端)
- 高度是樹到葉子結點(樹得末端)的長度
- 深度是結點到根結點的長度
二叉樹
現在我們來討論一個特殊的樹型別。我們把它叫作二叉樹。
“在電腦科學領域,二叉樹是一種樹形資料結構,它的每個節點最多有兩個孩子,被叫作左孩子和右孩” — Wikipedia
我們來寫一個二叉樹
當我們要實現二叉樹時,我們需要牢記的第一件事是它是一個結點集合。每個結點都有三個屬性:value
,left_child``和right_child
。
那麼我們怎麼才能實現一個有這三個屬性的簡單二叉樹呢?
我們來實現一個二叉樹的例子
/**
* Created on 2018/4/16.
*
* @author zlf
* @since 1.0
*/
public class BinaryTree {
public BinaryTree left; //左節點
public BinaryTree right; //右節點
public String data; //樹的內容
public BinaryTree() {
}
/**
* 構造方法
*
* @param data
* @param left
* @param right
*/
public BinaryTree(String data, BinaryTree left, BinaryTree right) {
this.left = left;
this.right = right;
this.data = data;
}
/**
* 構造方法
*
* @param data
*/
public BinaryTree(String data) {
this(data, null, null);
}
複製程式碼
好,這就是我們的二叉樹類
當我們例項化一個物件時,我們把值(點的相關資料)作為引數傳遞給類。看上面類的左孩子結點和右孩子結點。兩個都被賦值為null。
為什麼?
因為當我們建立節點時,它還沒有孩子,只有結點資料。
程式碼測試
/**
* 構建樹
*/
public static void testCreate() {
BinaryTree node = new BinaryTree("a");
System.out.println("【node data】:" + node.getData());
System.out.println("【node left data】:" + (node.left==null?"null":node.left.getData()));
System.out.println("【node right data】:" + (node.right==null?"null":node.right.getData()));
}
複製程式碼
輸出:
【node data】:a
【node left data】:null
【node right data】:null
複製程式碼
我們可以將字串'a'作為引數傳給二叉樹結點。如果將值、左孩子結點、右孩子結節點輸出的話,我們就可以看到這個值了。
下面開始插入部分的操作。那麼我們需要做些什麼工作呢?
有兩個要求:
-
如果當前的結點沒有左孩子結點,我們就建立一個新結點,然後將其設定為當前結點的左結點。
-
如果已經有了左結點,我們就建立一個新結點,並將其放在當前左結點的位置。然後再將原左結點值為新左結點的左結點。
圖形如下:
下面是插入的程式碼:
/**
* 插入節點 ,如果當前的節點沒有左節點,我們就建立一個新節點,然後將其設定為當前節點的左節點。
*
* @param node
* @param value
*/
public static void insertLeft(BinaryTree node, String value) {
if (node != null) {
if (node.left == null) {
node.setLeft(new BinaryTree(value));
} else {
BinaryTree newNode = new BinaryTree(value);
newNode.left = node.left;
node.left = newNode;
}
}
}
複製程式碼
再次強調,如果當前結點沒有左結點,我們就建立一個新結點,並將其置為當前結點的左結點。否則,就將新結點放在左結點的位置,再將原左結點置為新左結點的左結點。
同樣,我們編寫插入右結點的程式碼
/**
* 同插入左結點
* @param node
* @param value
*/
public static void insertRight(BinaryTree node, String value) {
if (node != null) {
if (node.right == null) {
node.setRight(new BinaryTree(value));
} else {
BinaryTree newNode = new BinaryTree(value);
newNode.right = node.right;
node.right = newNode;
}
}
}
複製程式碼
但是這還不算完成。我們得測試一下。
我們來構造一個像下面這樣的樹:
- 有一個根結點
- b是左結點
- c是右結點
- b的結節點是d(b沒有左結點)
- c的左結點是e
- c的右結點是f
- e,f都沒有子結點
下面是這棵樹的實現程式碼:
/**
* 測試插入結點
*/
public static void testInsert() {
BinaryTree node_a = new BinaryTree("a");
node_a.insertLeft(node_a, "b");
node_a.insertRight(node_a, "c");
BinaryTree node_b = node_a.left;
node_b.insertRight(node_b, "d");
BinaryTree node_c = node_a.right;
node_c.insertLeft(node_c, "e");
node_c.insertRight(node_c, "f");
BinaryTree node_d = node_b.right;
BinaryTree node_e = node_c.left;
BinaryTree node_f = node_c.right;
System.out.println("【node_a data】:" + node_a.getData());
System.out.println("【node_b data】:" + node_b.getData());
System.out.println("【node_c data】:" + node_c.getData());
System.out.println("【node_d data】:" + node_d.getData());
System.out.println("【node_e data】:" + node_e.getData());
System.out.println("【node_f data】:" + node_f.getData());
}
複製程式碼
輸出:
【node_a data】:a
【node_b data】:b
【node_c data】:c
【node_d data】:d
【node_e data】:e
【node_f data】:f
複製程式碼
插入已經結束
現在,我們來考慮一下樹的遍歷。
樹的遍歷有兩種選擇,深度優先搜尋(DFS)和廣度優先搜尋(BFS)。
DFS是用來遍歷或搜尋樹資料結構的演算法。從根節點開始,在回溯之前沿著每一個分支儘可能遠的探索。 — Wikipedia
BFS是用來遍歷或搜尋樹資料結構的演算法。從根節點開始,在探索下一層鄰居節點前,首先探索同一層的鄰居節點。 — Wikipedia
下面,我們來深入瞭解每一種遍歷演算法。
深度優先搜尋(Depth-First Search,DFS)
DFS 在回溯和搜尋其他路徑之前找到一條到葉節點的路徑。讓我們看看這種型別的遍歷的示例。
輸出結果為: 1–2–3–4–5–6–7
為什麼?
讓我們分解一下:
- 從根結點(1)開始。輸出
- 進入左結點(2)。輸出
- 然後進入左孩子(3)。輸出
- 回溯,並進入右孩子(4)。輸出
- 回溯到根結點,然後進入其右孩子(5)。輸出
- 進入左孩子(6)。輸出
- 回溯,並進入右孩子(7)。輸出
- 完成
當我們深入到葉結點時回溯,這就被稱為 DFS 演算法。
既然我們對這種遍歷演算法已經熟悉了,我們將討論下 DFS 的型別:前序、中序和後序。
前序遍歷
這和我們在上述示例中的作法基本類似。
- 輸出節點的值
- 進入其左結點並輸出。當且僅當它擁有左結點。
- 進入右結點並輸出之。當且僅當它擁有右結點
/**
* 前序遍歷
*
* @param node
*/
public static void preOrder(BinaryTree node) {
if (node != null) {
System.out.println(node.data);
if (node.left != null) {
node.left.preOrder(node.left);
}
if (node.right != null) {
node.right.preOrder(node.right);
}
}
}
複製程式碼
中序遍歷
示例中此樹的中序演算法的結果是3–2–4–1–6–5–7。
左結點優先,之後是中間,最後是右結點。
程式碼實現:
/**
* 中序遍歷
*
* @param node
*/
public static void inOrder(BinaryTree node) {
if (node != null) {
if (node.left != null) {
node.left.inOrder(node.left);
}
System.out.println(node.data);
if (node.right != null) {
node.right.inOrder(node.right);
}
}
}
複製程式碼
- 進入左結點並輸出之。當且僅當它有左結點。
- 輸出根結點的值。
- 進入結節點並輸出之。當且僅當它有結節點。
後序遍歷
以此樹為例的後序演算法的結果為 3–4–2–6–7–5–1 。
左結點優先,之後是右結點,根結點的最後。
程式碼實現:
/**
* 後序遍歷
*
* @param node
*/
public static void postOrder(BinaryTree node) {
if (node != null) {
if (node.left != null) {
node.left.postOrder(node.left);
}
if (node.right != null) {
node.right.postOrder(node.right);
}
System.out.println(node.data);
}
}
複製程式碼
- 進入左結點輸出,
- 進入右結點輸出
- 輸出根結點
廣度優先搜尋(BFS)
BFS是一層層逐漸深入的遍歷演算法
下面這個例子是用來幫我們更好的解釋該演算法。
我們來一層一層的遍歷這棵樹。本例中,就是1-2-5-3-4-6-7.
- 0層/深度0:只有值為1的結點
- 1層/深度1:有值為2和5的結點
- 2層/深度2:有值為3、4、6、7的結點
程式碼實現:
/**
* 廣度排序
*
* @param node
*/
public static void bfsOrder(BinaryTree node) {
if (node != null) {
Queue<BinaryTree> queue = new ArrayDeque<BinaryTree>();
queue.add(node);
while (!queue.isEmpty()) {
BinaryTree current_node = queue.poll();
System.out.println(current_node.data);
if (current_node.left != null) {
queue.add(current_node.left);
}
if (current_node.right != null) {
queue.add(current_node.right);
}
}
}
}
複製程式碼
為了實現BFS演算法,我們需要用到一個資料結構,那就是佇列。
佇列具體是用來幹什麼的呢?
請看下面解釋。
- 首先用add方法將根結點新增到佇列中。
- 當佇列不為空時迭代。
- 獲取佇列中的第一個結點,然後輸出其值
- 將左節點和右結點新增到佇列
- 在佇列的幫助下我們將每一個結點值一層層輸出
二叉搜尋樹
二叉搜尋樹有時候被稱為二叉有序樹或二叉排序樹,二叉搜尋樹的值儲存在有序的順序中,因此,查詢表和其他的操作可以使用折半查詢原理。——Wikipedia
二叉搜尋樹中的一個重要性質是,二叉搜尋樹中一個節點的值大於其左結點,但是小於其右結點
- 是反的二叉搜尋樹。子樹 7-5-8-6應該在右邊,而子樹2-1-3 應該在左邊。
- 是唯一正確的選項。它滿足二叉搜尋樹的性質
- 有一個問題:值為4的那個結點應該在根結點的左邊,因為這個節點的值比根結點的值5小。
程式碼實現二叉樹搜尋
插入:向我們的樹新增新的結點
現在想像一下我們有一棵空樹,我們想將幾個節點新增到這棵空樹中,這幾個結點的值為:50、76、21、4、32、100、64、52。
首先我們需要知道的是,50是不是這棵樹的根結點。
現在我們開始一個一個的插入結點
- 76比50大,所以76插入在右邊。
- 21比50小,所以21插入在左邊。
- 4比50小。但是50已經有了值為21的左結點。然後,4又比21小,所以將其插入在21的左邊。
- 32比50小。但是50已經有了值為21的左結點。然後,32又比21大,所以將其插入在21的右邊。
- 100比50大。但是50已經有了一個值為76的右結點。然後,100又比76大,所以將其插入在76的右邊。
- 64比50大。但是50已經有了一個值為76的右結點。然後,64又比76小,所以將其插入在76的左邊。
- 52比50大。但是50已經有了一個值為76的右結點。52又比76小,但是76也有一個值為64左結點。52又比64小,所以將52插入在64的左邊。
你注意到這裡的模式了嗎?
讓我們把它分解。
- 新結點值大於當前節點還是小於當前結點?
- 如果新節點的值大於當前結點,則轉到右結點。如果當前節點沒有右結點,則在那裡插入新結點,否則返回步驟1。
- 如果新節點的值小於當前結點,則轉到左結點。如果當前節點沒有左結點,則在那裡插入新結點,否則返回步驟1。
- 這裡我們沒有處理特殊情況。當新節點的值等於結點的當前值時,使用規則3。考慮在子結點的左側插入相等的值。
程式碼實現:
/**
* 插入樹
*
* @param node
* @param value
*/
public void insertNode(BinaryTree node, Integer value) {
if (node != null) {
if (value <= Integer.valueOf(node.data) && node.left != null) {
node.left.insertNode(node.left, value);
} else if (value <= Integer.valueOf(node.data)) {
node.left = new BinaryTree(String.valueOf(value));
} else if (value > Integer.valueOf(node.data) && node.right != null) {
node.right.insertNode(node.right, value);
} else {
node.right = new BinaryTree(String.valueOf(value));
}
}
}
複製程式碼
看起來很簡單。
該演算法的強大之處是其遞迴部分,即第9行和第13行。這兩行程式碼均呼叫 insertNode 方法,並分別為其左結點和右結點使用它。第11行和第15行則在子結點處插入新結點。
搜尋結點
我們現在要構建的演算法是關於搜尋的。對於給定的值(整數),我們會搜尋出我們的二叉查詢樹有或者沒有這個值。
需要注意的一個重要事項是我們如何定義樹的插入演算法。 首先我們有根結點。所有左子的節點值都比根結點小。所有右子樹的節點值都比根結點大。
讓我們看一個例子。
假設我們有這棵樹。
現在我們想知道是否有一個結點的值為52。
讓我們把它分解。
- 我們以根結點作為當前節點開始。給定值小於當前結點值嗎?如果是,那麼我將在左子樹上查詢它。
- 給定值大於當前結點值嗎?如果是,那麼我們將在右子樹上查詢它。
- 如果規則 #1 和 #2 均為假,我們可以比較當前節點值和給定值是否相等。如果返回真,那麼我們可以說:“是的,我們的樹擁有給定的值。” 否則,我們說: “不,我們的樹沒有給定的值。”
程式碼實現:
/**
* 查詢節點是否存在
*
* @param node
* @param value
* @return
*/
public boolean findNode(BinaryTree node, Integer value) {
if (node != null) {
if (value < Integer.valueOf(node.data) && node.left != null) {
return node.left.findNode(node.left, value);
}
if (value > Integer.valueOf(node.data) && node.right != null) {
return node.right.findNode(node.right, value);
}
return value == Integer.valueOf(node.data);
}
return false;
}
複製程式碼
程式碼分析:
- 第8行和第9行歸於規則#1。
- 第10行和第11行歸於規則#2。
- 第13行歸於規則#3。
刪除:移除和重新組織樹
刪除是一個更復雜的演算法,因為我們需要處理不同的情況。對於給定值,我們需要刪除具有此值的結點。想象一下這個節點的以下場景:它沒有孩子,有一個孩子,或者有兩個孩子。
一個沒有孩子的節點(葉節點) 。
# |50| |50|
# / \ / \
# |30| |70| (DELETE 20) ---> |30| |70|
# / \ \
# |20| |40| |40|
複製程式碼
如果要刪除的結點沒有子結點,我們簡單地刪除它。該演算法不需要重組樹。
僅有一個孩子(左或右孩子)的結點。
# |50| |50|
# / \ / \
# |30| |70| (DELETE 30) ---> |20| |70|
# /
# |20|
複製程式碼
在這種情況下,我們的演算法需要使節點的父節點指向子結點。如果節點是左孩子,則使其父結點指向其子結點。如果結點是右孩子,則使其父結點指向其子結點。
有兩個孩子的節點。
# |50| |50|
# / \ / \
# |30| |70| (DELETE 30) ---> |40| |70|
# / \ /
# |20| |40|
複製程式碼
當節點有兩個孩子,則需要從該節點的右孩子開始,找到具有最小值的結點。我們將把具有最小值的這個節點置於被刪除的節點的位置。
程式碼實現:
/**
* 刪除節點
* @param node
* @param value
* @param parent
* @return
*/
public boolean removeNode(BinaryTree node, Integer value, BinaryTree parent) {
if (node != null) {
if (value < Integer.valueOf(node.data) && node.left != null) {
return node.left.removeNode(node.left, value, node);
} else if (value < Integer.valueOf(node.data)) {
return false;
} else if (value > Integer.valueOf(node.data) && node.right != null) {
return node.right.removeNode(node.right, value, node);
} else if (value > Integer.valueOf(node.data)) {
return false;
} else {
if (node.left == null && node.right == null && node == parent.left) {
parent.left = null;
node.clearNode(node);
} else if (node.left == null && node.right == null && node == parent.right) {
parent.right = null;
node.clearNode(node);
} else if (node.left != null && node.right == null && node == parent.left) {
parent.left = node.left;
node.clearNode(node);
} else if (node.left != null && node.right == null && node == parent.right) {
parent.right = node.left;
node.clearNode(node);
} else if (node.right != null && node.left == null && node == parent.left) {
parent.left = node.right;
node.clearNode(node);
} else if (node.right != null && node.left == null && node == parent.right) {
parent.right = node.right;
node.clearNode(node);
} else {
node.data=String.valueOf(node.right.findMinValue(node.right));
node.right.removeNode(node.right,Integer.valueOf(node.right.data),node);
}
return true;
}
}
return false;
}
複製程式碼
- 首先: 注意下引數
value
和parent
。我們想找到值等於該value
的node
,並且該node
的父節點對於刪除該node
是至關重要的。 - 其次: 注意下返回值。我們的演算法將會返回一個布林值。我們的演算法在找到並刪除該節點時返回
true
。否則返回false
。 - 第2行到第9行:我們開始查詢等於我們要查詢的給定的
value
的node
。如果該value
小於current node
值,我們進入左子樹,遞迴處理(當且僅當,current node
有左孩子)。如果該值大於,則進入右子樹。遞迴處理。 - 第10行: 我們開始考慮刪除演算法。
- 第11行到第13行: 我們處理了沒有孩子、並且是父節點的左孩子的節點。我們通過設定父節點的左孩子為空來刪除該節點。
- 第14行和第15行: 我們處理了沒有孩子、並且是父節點的右孩子的節點。我們通過設定父節點的右孩子為空來刪除該節點。
- 清除節點的方法:我將會在後續文章中給出 clear_node 的程式碼。該函式將節點的左孩子、右孩子和值都設定為空。
- 第16行到第18行: 我們處理了只有一個孩子(左孩子)、並且它是其父節點的左孩子的節點。我們將父節點的左孩子設定為當前節點的左孩子(這是它唯一擁有的孩子)。
- 第19行到第21行: 我們處理了只有一個孩子(左孩子)、並且它是其父節點的右孩子的節點。我們將父節點的右孩子設定為當前節點的左孩子(這是它唯一擁有的孩子)。
- 第22行到第24行: 我們處理了只有一個孩子(右孩子),並且是其父節點的左孩子的節點。我們將父節點的左孩子設定為當前節點的右孩子(這是它唯一的孩子)。
- 第25行到第27行: 我們處理了只有一個孩子(右孩子),並且它是其父節點的右孩子的節點。我們將父節點的右孩子設定為當前節點的右孩子(這是它唯一的孩子)。
- 第28行到第30行: 我們處理了同時擁有左孩子和右孩子的節點。我們獲取擁有最小值的節點(程式碼隨後給出),並將其值設定給當前節點。通過刪除最小的節點完成節點移除。
- 第32行: 如果我們找到了要查詢的節點,就需要返回
true
。從第11行到第31行,我們處理了這些情況。所以直接返回true
,這就夠了。
- clear_node 方法:設定節點的三個屬性為空——(value, left_child, and right_child)
/**
* 清空n節點
*
* @param node
*/
public void clearNode(BinaryTree node) {
node.data = null;
node.left = null;
node.right = null;
}
複製程式碼
- find_minimum_value方法:一路向下找最左側的。如果我們無法找到任何節點,我們找其中最小的
/**
* 查詢樹中最小值
*/
public Integer findMinValue(BinaryTree node) {
if (node != null) {
if (node.left != null) {
return node.left.findMinValue(node.left);
} else {
return Integer.valueOf(node.data);
}
}
return null;
}
複製程式碼
原文連結:Everything you need to know about tree data structures
程式碼下載:
從我的 github 中下載,【譯】資料結構中關於樹的一切(java版)
推薦文章
???關注微信小程式java架構師歷程 上下班的路上無聊嗎?還在看小說、新聞嗎?不知道怎樣提高自己的技術嗎?來吧這裡有你需要的java架構文章,1.5w+的java工程師都在看,你還在等什麼?