為什么需要樹這種數據結構?
數組存儲方式的分析
- 優點:
- 通過 下標 方式訪問元素,速度快
- 對於 有序數組,還可以使用二分查找提高檢索速度
- 缺點:如果無序數組要檢索具體某個值,或插入值(按一定順序)會整體移動,並且數組的大小固定,如果該數組已經滿了但又想插入數據,那就必須對該數組進行擴容,效率較低,如下的示意圖
鏈表存儲方式的分析
-
優點:在一定程度上對數組存儲方式有優化
例如:插入一個數值節點,只需要將插入節點,鏈接到鏈表中即可,同理,刪除效率也很好
-
缺點:檢索效率較低
需要從頭結點開始遍歷查找。
簡單說:
- 數組訪問快,增刪慢
- 鏈表增刪快,訪問慢
所以就出現了 樹 這種數據結構。如下圖就是二叉樹模型
樹 存儲數據方式分析
提供數據 存儲 、讀取 效率。
例如:利用 二叉排序樹(Binary Sort Tree),既可以保證數據的檢索速度,同時也可以保證數據的插入、刪除、修改 的速度
如圖所示:
- 插入時,小的數在 左節點、大的數在 右節點
- 查找時:根據插入事的特性,基本上就類似折半查找了,每次都過濾一半的節點
- 刪除時:只需要移動相鄰的節點的引用
樹 的常用術語
-
節點
:每一個圓圈表示一個節點,也稱節點對象 -
根節點
:最上面,最頂部的那個節點,也就是一棵樹的入口 -
父節點
:有子節點的節點 -
子節點
:看圖 -
兄弟節點
:具有相同父節點的節點互稱為兄弟節點 -
葉子節點
:沒有子節點的節點 -
非終端節點
或分支節點
:度不為零的節點 -
節點的度
:一個節點含有的子樹的個數稱為該節點的度 -
樹的度
:一棵樹中,最大的節點度稱為樹的度 -
節點的權
:可以簡單的理解為節點值有時候也用 路徑 來表示
-
路徑
:從 root 節點找到該節點的路線 -
層
:看圖 -
子樹
:有子節點的父子兩層就可以稱為是一個子樹 -
樹的高度
:最大層數 -
森林
:多棵子樹構成森林
二叉樹的概念
二叉樹(Binary tree)是樹形結構的一個重要類型。許多實際問題抽象出來的數據結構往往是二叉樹形式,即使是一般的樹也能簡單地轉換為二叉樹,而且二叉樹的存儲結構及其算法都較為簡單,因此二叉樹顯得特別重要。二叉樹特點是每個結點最多只能有兩棵子樹,且有左右之分 。
二叉樹是n個有限元素的集合,該集合或者為空、或者由一個稱為根(root)的元素及兩個不相交的、被分別稱為左子樹和右子樹的二叉樹組成,是有序樹。當集合為空時,稱該二叉樹為空二叉樹。在二叉樹中,一個元素也稱作一個結點 (節點)。
定義:
二叉樹(binary tree)是指樹中節點的度不大於2的有序樹,它是一種最簡單且最重要的樹。二叉樹的遞歸定義為:二叉樹是一棵空樹,或者是一棵由一個根節點和兩棵互不相交的,分別稱作根的左子樹和右子樹組成的非空樹;左子樹和右子樹又同樣都是二叉樹。
-
二叉樹的子節點分為 左節點 和 右節點
-
如果該二叉樹的所有 葉子節點 都在 最后一層,並且 節點總數 =
2^n -1
(n 為層數),則我們稱為 滿二叉樹 -
如果該二叉樹的所有葉子節點都在最 后一層或倒數第二層,而且 最后一層的葉子節點在左邊連續,倒數第二層的葉子節點在右邊連續,我們稱為 完全二叉樹
詳細點的解析:
一棵深度為k且有
個結點的二叉樹稱為滿二叉樹。
根據二叉樹的性質2, 滿二叉樹每一層的結點個數都達到了最大值, 即滿二叉樹的第i層上有
個結點 (i≥1) 。
如果對滿二叉樹的結點進行編號, 約定編號從根結點起, 自上而下, 自左而右。則深度為k的, 有n個結點的二叉樹, 當且僅當其每一個結點都與深度為k的滿二叉樹中編號從1至n的結點一一對應時, 稱之為完全二叉樹。 [2]
從滿二叉樹和完全二叉樹的定義可以看出, 滿二叉樹是完全二叉樹的特殊形態, 即如果一棵二叉樹是滿二叉樹, 則它必定是完全二叉樹。
完全二叉樹的特點:葉子結點只能出現在最下層和次下層,且最下層的葉子結點集中在樹的左部。需要注意的是,滿二叉樹肯定是完全二叉樹,而完全二叉樹不一定是滿二叉樹。
二叉樹的遍歷
有三種:
前序遍歷
:先輸出父節點,再遍歷左子樹(遞歸)和右子樹(遞歸)中序遍歷
:先遍歷左子樹(遞歸),再輸出父節點,再遍歷右子樹(遞歸)后序遍歷
:先遍歷左子樹(遞歸),再遍歷右子樹(遞歸),最后輸出父節點
看上述粗體部分:前中后說的就是父節點的輸出時機。
注意理清楚描述中的遞歸,這關乎着你如何找到下一個輸出節點
關於遞歸,請看 數據結構與算法——初談遞歸
二叉樹遍歷思路分析
-
前序遍歷:
- 先輸出當前節點(初始節點是 root 節點)
- 如果左子節點不為空,則遞歸繼續前序遍歷
- 如果右子節點不為空,則遞歸繼續前序遍歷
上圖的輸出順序為:1、2、3、4
-
中序遍歷:
- 如果當前節點的左子節點不為空,則遞歸中序遍歷
- 輸出當前節點
- 如果當前節點的右子節點不為空,則遞歸中序
上圖的輸出順序為:2、1、3、4
-
后序遍歷:
- 如果左子節點不為空,則遞歸繼續前序遍歷
- 如果右子節點不為空,則遞歸繼續前序遍歷
- 輸出當前節點
上圖的輸出順序為:2、4、3、1
如果不理解,就結合下面的代碼進行理解。
二叉樹遍歷代碼實現
注意這里
this
的含義。
/**
* 二叉樹測試
*/
public class BinaryTreeTest {
// 先編寫二叉樹節點
class HeroNode {
public int id;
public String name;
public HeroNode left;
public HeroNode right;
public HeroNode(int id, String name) {
this.id = id;
this.name = name;
}
@Override
public String toString() {
return "HeroNode{" +
"id=" + id +
", name='" + name + '\'' +
'}';
}
/**
* 前序遍歷
*/
public void preOrder() {
// 1. 先輸出當前節點
System.out.println(this);
// 2. 如果左子節點不為空,則遞歸繼續前序遍歷
if (this.left != null) {
this.left.preOrder();
}
// 3. 如果右子節點不為空,則遞歸繼續前序遍歷
if (this.right != null) {
this.right.preOrder();
}
}
/**
* 中序遍歷
*/
public void infixOrder() {
// 1. 如果左子節點不為空,則遞歸繼續前序遍歷
if (this.left != null) {
this.left.infixOrder();
}
// 2. 先輸出當前節點
System.out.println(this);
// 3. 如果右子節點不為空,則遞歸繼續前序遍歷
if (this.right != null) {
this.right.infixOrder();
}
}
/**
* 后序遍歷
*/
public void postOrder() {
// 1. 如果左子節點不為空,則遞歸繼續前序遍歷
if (this.left != null) {
this.left.postOrder();
}
// 2. 如果右子節點不為空,則遞歸繼續前序遍歷
if (this.right != null) {
this.right.postOrder();
}
// 3. 先輸出當前節點
System.out.println(this);
}
}
// 編寫 二叉樹 類
class BinaryTree {
public HeroNode root;//樹根
/**
* 前序遍歷
*/
public void preOrder() {
//判斷二叉樹是否為空
if (root == null) {
System.out.println("二叉樹為空");
return;
}
root.preOrder();
}
/**
* 中序遍歷
*/
public void infixOrder() {
if (root == null) {
System.out.println("二叉樹為空");
return;
}
root.infixOrder();
}
/**
* 后續遍歷
*/
public void postOrder() {
if (root == null) {
System.out.println("二叉樹為空");
return;
}
root.postOrder();
}
}
/**
* 前、中、后 遍歷測試
*/
@Test
public void fun1() {
// 手動創建節點與構建二叉樹
HeroNode n1 = new HeroNode(1, "宋江");
HeroNode n2 = new HeroNode(2, "無用");
HeroNode n3 = new HeroNode(3, "盧俊");
HeroNode n4 = new HeroNode(4, "林沖");
n1.left = n2;
n1.right = n3;
n3.right = n4;
BinaryTree binaryTree = new BinaryTree();
binaryTree.root = n1;
System.out.println("\n 前序遍歷:");
binaryTree.preOrder();
System.out.println("\n 中序遍歷:");
binaryTree.infixOrder();
System.out.println("\n 后序遍歷:");
binaryTree.postOrder();
}
}
測試輸出
前序遍歷:
HeroNode{id=1, name='宋江'}
HeroNode{id=2, name='無用'}
HeroNode{id=3, name='盧俊'}
HeroNode{id=4, name='林沖'}
中序遍歷:
HeroNode{id=2, name='無用'}
HeroNode{id=1, name='宋江'}
HeroNode{id=3, name='盧俊'}
HeroNode{id=4, name='林沖'}
后序遍歷:
HeroNode{id=2, name='無用'}
HeroNode{id=4, name='林沖'}
HeroNode{id=3, name='盧俊'}
HeroNode{id=1, name='宋江'}
思考一下:
如上圖,給 盧俊義 增加一個節點 關勝,寫出他的前、中、后序的打印順序:
注意:上面這個新增的節點,並不是按照順序增加的,這里考的知識點是 前、中、后序的遍歷規則
-
前序:1、2、3、5、4
-
中序:2、1、5、3,4
-
后序:2、5、4、3、1
那么下面通過程序來檢測答案是否正確:
/**
* 考題:給盧俊新增一個 left 節點,然后打印前、中、后 遍歷順序
*/
@Test
public void fun2() {
// 創建節點與構建二叉樹
HeroNode n1 = new HeroNode(1, "宋江");
HeroNode n2 = new HeroNode(2, "無用");
HeroNode n3 = new HeroNode(3, "盧俊");
HeroNode n4 = new HeroNode(4, "林沖");
HeroNode n5 = new HeroNode(5, "關勝");
n1.left = n2;
n1.right = n3;
n3.right = n4;
n3.left = n5;
BinaryTree binaryTree = new BinaryTree();
binaryTree.root = n1;
System.out.println("\n 前序遍歷:");
binaryTree.preOrder();
System.out.println("\n 中序遍歷:");
binaryTree.infixOrder();
System.out.println("\n 后序遍歷:");
binaryTree.postOrder();
}
輸出信息
前序遍歷:
HeroNode{id=1, name='宋江'}
HeroNode{id=2, name='無用'}
HeroNode{id=3, name='盧俊'}
HeroNode{id=5, name='關勝'}
HeroNode{id=4, name='林沖'}
中序遍歷:
HeroNode{id=2, name='無用'}
HeroNode{id=1, name='宋江'}
HeroNode{id=5, name='關勝'}
HeroNode{id=3, name='盧俊'}
HeroNode{id=4, name='林沖'}
后序遍歷:
HeroNode{id=2, name='無用'}
HeroNode{id=5, name='關勝'}
HeroNode{id=4, name='林沖'}
HeroNode{id=3, name='盧俊'}
HeroNode{id=1, name='宋江'}
可以看到,后序是最容易弄錯的規則,所以在后續上,一定要多多 debug,多多思考 ,看下他的調用軌跡。
二叉樹的查找
要求:
- 編寫前、中、后序查找方法(和上面的遍歷類似,只是添加了一些東西,注意觀察,細看代碼,思想是一樣的)
- 並分別使用三種查找方式,查找
id=5
的節點 - 並分析各種查找方式,分別比較了多少次
由於二叉樹的查找是遍歷查找,所以就簡單了,前面遍歷規則已經寫過了,改寫成查找即可
/**
* 二叉樹測試
*/
public class BinaryTreeTest {
// 先編寫二叉樹節點
class HeroNode {
public int id;
public String name;
public HeroNode left;
public HeroNode right;
public HeroNode(int id, String name) {
this.id = id;
this.name = name;
}
@Override
public String toString() {
return "HeroNode{" +
"id=" + id +
", name='" + name + '\'' +
'}';
}
/**
* 前序遍歷
*/
public void preOrder() {
// 1. 先輸出當前節點
System.out.println(this);
// 2. 如果左子節點不為空,則遞歸繼續前序遍歷
if (this.left != null) {
this.left.preOrder();
}
// 3. 如果右子節點不為空,則遞歸繼續前序遍歷
if (this.right != null) {
this.right.preOrder();
}
}
/**
* 中序遍歷
*/
public void infixOrder() {
// 1. 如果左子節點不為空,則遞歸繼續前序遍歷
if (this.left != null) {
this.left.infixOrder();
}
// 2. 先輸出當前節點
System.out.println(this);
// 3. 如果右子節點不為空,則遞歸繼續前序遍歷
if (this.right != null) {
this.right.infixOrder();
}
}
/**
* 后序遍歷
*/
public void postOrder() {
// 1. 如果左子節點不為空,則遞歸繼續前序遍歷
if (this.left != null) {
this.left.postOrder();
}
// 2. 如果右子節點不為空,則遞歸繼續前序遍歷
if (this.right != null) {
this.right.postOrder();
}
// 3. 先輸出當前節點
System.out.println(this);
}
/**
* 前序查找
*/
public HeroNode preOrderSearch(int id) {
System.out.println(" 進入前序遍歷"); // 用來統計查找了幾次
// 1. 判斷當前節點是否相等,如果相等,則返回
if (this.id == id) {
return this;
}
// 2. 如果左子節點不為空,則遞歸繼續前序遍歷
if (this.left != null) {
HeroNode result = this.left.preOrderSearch(id);
if (result != null) {
return result;
}
}
// 3. 如果右子節點不為空,則遞歸繼續前序遍歷
if (this.right != null) {
HeroNode result = this.right.preOrderSearch(id);
if (result != null) {
return result;
}
}
return null;
}
/**
* 中序查找
*/
public HeroNode infixOrderSearch(int id) {
// System.out.println(" 進入中序遍歷"); // 用來統計查找了幾次,不能在這里打印,這里打印是進入了方法幾次,看的是比較了幾次
// 1. 如果左子節點不為空,則遞歸繼續前序遍歷
if (this.left != null) {
HeroNode result = this.left.infixOrderSearch(id);
if (result != null) {
return result;
}
}
System.out.println(" 進入中序遍歷");// 用來統計查找了幾次
// 2. 如果相等,則返回
if (this.id == id) {
return this;
}
// 3. 如果右子節點不為空,則遞歸繼續前序遍歷
if (this.right != null) {
HeroNode result = this.right.infixOrderSearch(id);
if (result != null) {
return result;
}
}
return null;
}
/**
* 后序查找
*/
public HeroNode postOrderSearch(int id) {
// System.out.println(" 進入后序遍歷"); // 用來統計查找了幾次,不能在這里打印,這里打印是進入了方法幾次,看的是比較了幾次
// 1. 如果左子節點不為空,則遞歸繼續前序遍歷
if (this.left != null) {
HeroNode result = this.left.postOrderSearch(id);
if (result != null) {
return result;
}
}
// 2. 如果右子節點不為空,則遞歸繼續前序遍歷
if (this.right != null) {
HeroNode result = this.right.postOrderSearch(id);
if (result != null) {
return result;
}
}
System.out.println(" 進入后序遍歷");// 用來統計查找了幾次
// 3. 如果相等,則返回
if (this.id == id) {
return this;
}
return null;
}
}
// 編寫 二叉樹 類
class BinaryTree {
public HeroNode root;
/**
* 前序遍歷
*/
public void preOrder() {
if (root == null) {
System.out.println("二叉樹為空");
return;
}
root.preOrder();
}
/**
* 中序遍歷
*/
public void infixOrder() {
if (root == null) {
System.out.println("二叉樹為空");
return;
}
root.infixOrder();
}
/**
* 后續遍歷
*/
public void postOrder() {
if (root == null) {
System.out.println("二叉樹為空");
return;
}
root.postOrder();
}
/**
* 前序查找
*/
public HeroNode preOrderSearch(int id) {
//判斷樹是否為空
if (root == null) {
System.out.println("二叉樹為空");
return null;
}
return root.preOrderSearch(id);
}
/**
* 中序查找
*/
public HeroNode infixOrderSearch(int id) {
if (root == null) {
System.out.println("二叉樹為空");
return null;
}
return root.infixOrderSearch(id);
}
/**
* 后序查找
*/
public HeroNode postOrderSearch(int id) {
if (root == null) {
System.out.println("二叉樹為空");
return null;
}
return root.postOrderSearch(id);
}
}
/**
* 查找 id=5 的前、中、后序測試
*/
@Test
public void fun3() {
// 創建節點與構建二叉樹
HeroNode n1 = new HeroNode(1, "宋江");
HeroNode n2 = new HeroNode(2, "無用");
HeroNode n3 = new HeroNode(3, "盧俊");
HeroNode n4 = new HeroNode(4, "林沖");
HeroNode n5 = new HeroNode(5, "關勝");
n1.left = n2;
n1.right = n3;
n3.right = n4;
n3.left = n5;
BinaryTree binaryTree = new BinaryTree();
binaryTree.root = n1;
System.out.println("找到測試:");
int id = 5;
System.out.println("\n前序遍歷查找 id=" + id);
System.out.println(binaryTree.preOrderSearch(id));
System.out.println("\n中序遍歷查找 id=" + id);
System.out.println(binaryTree.infixOrderSearch(id));
System.out.println("\n后序遍歷查找 id=" + id);
System.out.println(binaryTree.postOrderSearch(id));
System.out.println("找不到測試:");
id = 15;
System.out.println("\n前序遍歷查找 id=" + id);
System.out.println(binaryTree.preOrderSearch(id));
System.out.println("\n中序遍歷查找 id=" + id);
System.out.println(binaryTree.infixOrderSearch(id));
System.out.println("\n后序遍歷查找 id=" + id);
System.out.println(binaryTree.postOrderSearch(id));
}
}
測試輸出
找到測試:
前序遍歷查找 id=5 # 共查找 4 次
進入前序遍歷
進入前序遍歷
進入前序遍歷
進入前序遍歷
HeroNode{id=5, name='關勝'}
中序遍歷查找 id=5 # 共查找 3 次
進入中序遍歷
進入中序遍歷
進入中序遍歷
HeroNode{id=5, name='關勝'}
后序遍歷查找 id=5 # 共查找 2 次
進入后序遍歷
進入后序遍歷
HeroNode{id=5, name='關勝'}
找不到測試:
前序遍歷查找 id=15
進入前序遍歷
進入前序遍歷
進入前序遍歷
進入前序遍歷
進入前序遍歷
null
中序遍歷查找 id=15
進入中序遍歷
進入中序遍歷
進入中序遍歷
進入中序遍歷
進入中序遍歷
null
后序遍歷查找 id=15
進入后序遍歷
進入后序遍歷
進入后序遍歷
進入后序遍歷
進入后序遍歷
null
可以看出:
- 找到的次數和 查找的順序 有關,而查找順序就是於遍歷方式有關
- 找不到的次數則是相當於都遍歷完成,所以是相等的次數
二叉樹的刪除
要求:
- 如果刪除的節點是 葉子節點,則刪除該節點
- 如果刪除的節點是非葉子節點,則刪除該子樹
測試:刪除 5 號葉子節點和 3 號子樹。
說明:目前的二叉樹不是規則的,如果不刪除子樹,則需要考慮哪一個節點會被上提作為父節點。這個后續講解排序二叉樹時再來實現。先實現簡單的
思路分析:
-
由於我們的二叉樹是單向的
-
所以我們判定一個節點是否可以刪除,是判斷它的 子節點 是否可刪除,否則則沒法回到父節點刪除了,因為要判斷被刪除的節點滿足前面的兩點要求(因為鏈表的關系)
- 當前節點的 左子節點 不為空,並且左子節點就是要刪除的節點,則 this.left = null,並且返回(結束遞歸刪除)
- 當前節點的 右子節點 不為空,並且右子節點就是要刪除的節點,則 this.right = null,並且返回(結束遞歸刪除)
如果前面都沒有刪除,則繼續遞歸,直到找到並刪除,或者是找不到對應的節點,輸出沒有該節點即可。上面的要求是 2 點,實際上是,找到符合條件的節點則直接刪除(因為不考慮是否有子樹)
// BinaryTree 新增刪除的方法
/**
* 刪除節點
*
* @param id
* @return
*/
public HeroNode delete(int id) {
if (root == null) {
System.out.println("樹為空");
return null;
}
HeroNode target = null;//保存要刪除的目標節點
// 如果 root 節點就是要刪除的節點,則直接置空
if (root.id == id) {
target = root;
root = null;
} else {
target = this.root.delete(id);
}
return target;
}
// HeroNode 中新增刪除的方法
/**
* 刪除節點,思路,先看左右,看完再遞歸,具體看代碼
* @param id
* @return 如果刪除成功,則返回刪除的節點
*/
public HeroNode delete(int id) {
// 判斷左子節點是否是要刪除的節點
HeroNode target = null;
if (this.left != null && this.left.id == id) {
target = this.left;
this.left = null;
return target;
}
if (this.right != null && this.right.id == id) {
target = this.right;
this.right = null;
return target;
}
// 嘗試左遞歸
if (this.left != null) {
target = this.left.delete(id);
if (target != null) {
return target;
}
}
// 嘗試右遞歸
if (this.right != null) {
target = this.right.delete(id);
if (target != null) {
return target;
}
}
return null;
}
刪除方法測試用例
/**
* 構建當前這個樹
*
* @return
*/
private BinaryTree buildBinaryTree() {
HeroNode n1 = new HeroNode(1, "宋江");
HeroNode n2 = new HeroNode(2, "無用");
HeroNode n3 = new HeroNode(3, "盧俊");
HeroNode n4 = new HeroNode(4, "林沖");
HeroNode n5 = new HeroNode(5, "關勝");
n1.left = n2;
n1.right = n3;
n3.right = n4;
n3.left = n5;
BinaryTree binaryTree = new BinaryTree();
binaryTree.root = n1;
return binaryTree;
}
/**
* 不考慮子節點的刪除
*/
@Test
public void delete() {
System.out.println("\n刪除 3 號節點");
delete3();
System.out.println("\n刪除 5 號節點");
delete5();
System.out.println("\n刪除一個不存在的節點");
deleteFail();
System.out.println("\n刪除 root 節點");
deleteRoot();
}
@Test
public void delete3() {
// 創建節點與構建二叉樹
BinaryTree binaryTree = buildBinaryTree();
binaryTree.preOrder();
// 刪除 3 號節點
HeroNode target = binaryTree.delete(3);
String msg = (target == null ? "刪除失敗,未找到" : "刪除成功:" + target.toString());
System.out.println(msg);
binaryTree.preOrder();
}
@Test
public void delete5() {
// 創建節點與構建二叉樹
BinaryTree binaryTree = buildBinaryTree();
binaryTree.preOrder();
// 刪除 5 號節點
HeroNode target = binaryTree.delete(5);
String msg = (target == null ? "刪除失敗,未找到" : "刪除成功:" + target.toString());
System.out.println(msg);
binaryTree.preOrder();
}
/**
* 刪除一個不存在的節點
*/
@Test
public void deleteFail() {
// 創建節點與構建二叉樹
BinaryTree binaryTree = buildBinaryTree();
binaryTree.preOrder();
// 刪除 5 號節點
HeroNode target = binaryTree.delete(9);
String msg = (target == null ? "刪除失敗,未找到" : "刪除成功:" + target.toString());
System.out.println(msg);
binaryTree.preOrder();
}
/**
* 刪除 root 節點
*/
@Test
public void deleteRoot() {
// 創建節點與構建二叉樹
BinaryTree binaryTree = buildBinaryTree();
binaryTree.preOrder();
// 刪除 1 號節點
HeroNode target = binaryTree.delete(1);
String msg = (target == null ? "刪除失敗,未找到" : "刪除成功:" + target.toString());
System.out.println(msg);
binaryTree.preOrder();
}
分別輸出信息如下
刪除 3 號節點
HeroNode{id=1, name='宋江'}
HeroNode{id=2, name='無用'}
HeroNode{id=3, name='盧俊'}
HeroNode{id=5, name='關勝'}
HeroNode{id=4, name='林沖'}
刪除成功:HeroNode{id=3, name='盧俊'}
HeroNode{id=1, name='宋江'}
HeroNode{id=2, name='無用'}
刪除 5 號節點
HeroNode{id=1, name='宋江'}
HeroNode{id=2, name='無用'}
HeroNode{id=3, name='盧俊'}
HeroNode{id=5, name='關勝'}
HeroNode{id=4, name='林沖'}
刪除成功:HeroNode{id=5, name='關勝'}
HeroNode{id=1, name='宋江'}
HeroNode{id=2, name='無用'}
HeroNode{id=3, name='盧俊'}
HeroNode{id=4, name='林沖'}
刪除一個不存在的節點
HeroNode{id=1, name='宋江'}
HeroNode{id=2, name='無用'}
HeroNode{id=3, name='盧俊'}
HeroNode{id=5, name='關勝'}
HeroNode{id=4, name='林沖'}
刪除失敗,未找到
HeroNode{id=1, name='宋江'}
HeroNode{id=2, name='無用'}
HeroNode{id=3, name='盧俊'}
HeroNode{id=5, name='關勝'}
HeroNode{id=4, name='林沖'}
刪除 root 節點
HeroNode{id=1, name='宋江'}
HeroNode{id=2, name='無用'}
HeroNode{id=3, name='盧俊'}
HeroNode{id=5, name='關勝'}
HeroNode{id=4, name='林沖'}
刪除成功:HeroNode{id=1, name='宋江'}
二叉樹為空
一、順序存儲二叉樹
基本說明-概念
從數據存儲來看,數組存儲 方式和 樹 的存儲方式可以 相互轉換。即使數組可以轉換成樹,樹也可以轉換成數組。如下示意圖
上圖閱讀說明:
- 圓圈頂部的數字對應了數組中的索引
- 圓圈內部的值對應的數數組元素的值
現在有兩個要求:
- 上圖的二叉樹的節點,要求以數組的方式來存儲
arr=[1,2,3,4,5,6,7]
- 要求在遍歷數組 arr 時,仍然可以以 前序、中序、后序的方式遍歷
特點(思路)
想要 實現上面的兩個要求,需要知道順序存儲二叉樹的特點:
- 順序二叉樹 通常只考慮 完全二叉樹(完全二叉樹上面有解釋)
- 第 n 個元素的 左子節點 為
2*n+1
- 第 n 個元素的 右子節點 為
2*n+2
- 第 n 個元素的 父節點 為
(n-1)/2
注:n 表示二叉樹中的第幾個元素(按 0 開始編號)
比如:
- 元素 2 的左子節點為:
2 * 1 + 1 = 3
,對比上圖去查看,的確是 3 - 元素 2 的右子節點為:
2 * 1 + 2 = 4
,也 就是元素 5 - 元素 3 的左子節點為:
2 * 2 + 1 = 5
,也就是元素 6 - 元素 3 的父節點為:
(2-1)/2= 1/2 = 0
,也就是根節點 1
搞懂特點規律,下面進行代碼實現。
前序遍歷
使用如上的知識點,進行前序遍歷,需求:將數組 arr=[1,2,3,4,5,6,7]
,以二叉樹前序遍歷的方式進行遍歷,遍歷結果為 1、2、4、5、3、6
前序遍歷概念(上面有講):
- 先輸出當前節點(初始節點是 root 節點)
- 如果左子節點不為空,則遞歸繼續前序遍歷
- 如果右子節點不為空,則遞歸繼續前序遍歷
/**
* 順序存儲二叉樹
*/
public class ArrBinaryTreeTest {
/**
* 前序遍歷測試
*/
@Test
public void preOrder() {
int[] arr = new int[]{1, 2, 3, 4, 5, 6, 7};
ArrBinaryTree tree = new ArrBinaryTree(arr);
tree.preOrder(0); // 1,2,4,5,3,6,7
}
}
class ArrBinaryTree {
int[] arr;
public ArrBinaryTree(int[] arr) {
this.arr = arr;
}
/**
* 前序遍歷
*
* @param index 就是知識點中的 n,從哪一個節點開始遍歷
*/
public void preOrder(int index) {
/*
1. 順序二叉樹 通常只考慮 **完成二叉樹**
2. 第 n 個元素的 **左子節點** 為 `2*n+1`
3. 第 n 個元素的 **右子節點** 為 `2*n+2`
4. 第 n 個元素的 **父節點** 為 `(n-1)/2`
*/
if (arr == null || arr.length == 0) {
System.out.println("數組為空,不能前序遍歷二叉樹");
return;
}
// 1. 先輸出當前節點(初始節點是 root 節點)
System.out.println(arr[index]);
// 2. 如果左子節點不為空,則遞歸繼續前序遍歷
int left = 2 * index + 1;
if (left < arr.length) {
preOrder(left);
}
// 3. 如果右子節點不為空,則遞歸繼續前序遍歷
int right = 2 * index + 2;
if (right < arr.length) {
preOrder(right);
}
}
}
測試輸出
1
2
4
5
3
6
7
中序、后序遍歷
/**
* 中序遍歷:先遍歷左子樹,再輸出父節點,再遍歷右子樹
*
* @param index
*/
public void infixOrder(int index) {
if (arr == null || arr.length == 0) {
System.out.println("數組為空,不能前序遍歷二叉樹");
return;
}
int left = 2 * index + 1;
if (left < arr.length) {
infixOrder(left);
}
System.out.println(arr[index]);
int right = 2 * index + 2;
if (right < arr.length) {
infixOrder(right);
}
}
/**
* 后序遍歷:先遍歷左子樹,再遍歷右子樹,最后輸出父節點
*
* @param index
*/
public void postOrder(int index) {
if (arr == null || arr.length == 0) {
System.out.println("數組為空,不能前序遍歷二叉樹");
return;
}
int left = 2 * index + 1;
if (left < arr.length) {
postOrder(left);
}
int right = 2 * index + 2;
if (right < arr.length) {
postOrder(right);
}
System.out.println(arr[index]);
}
測試代碼
/**
* 中序遍歷測試
*/
@Test
public void infixOrder() {
int[] arr = new int[]{1, 2, 3, 4, 5, 6, 7};
ArrBinaryTree tree = new ArrBinaryTree(arr);
tree.infixOrder(0); // 4,2,5,1,6,3,7
}
/**
* 后序遍歷測試
*/
@Test
public void postOrder() {
int[] arr = new int[]{1, 2, 3, 4, 5, 6, 7};
ArrBinaryTree tree = new ArrBinaryTree(arr);
tree.postOrder(0); // 4,5,2,6,7,3,1
}
應用案例
學會了順序存儲二叉樹,那么它可以用來做什么呢?
八大排序算法中的 堆排序,就會使用到順序存儲二叉樹。
二、線索化二叉樹
為什么要線索化二叉樹?
看如下問題:將數列 {1,3,6,8,10,14}
構成一顆二叉樹
可以看到上圖的二叉樹為一顆 完全二叉樹。對他進行分析,可以發現如下的一些問題:
- 當對上面的二叉樹進行中序遍歷時,數列為
8,3,10,1,14,6
- 但是
6,8,10,14
這幾個節點的左右指針,並沒有完全用上
如果希望充分利用各個節點的左右指針,讓各個節點可以 指向自己的前后節點,這個時候就可以使用 線索化二叉樹 了
介紹
n 個節點的二叉樹鏈表中含有 n + 1
個空指針域,他的推導公式為 2n-(n-1) = n + 1
。
利用二叉樹鏈表中的空指針域,存放指向該節點在 某種遍歷次序下(前序,中序,后序)的 前驅節點
和 后繼節點
的指針,這種附加的指針稱為「線索」
前驅
:一個節點的前一個節點后繼
:一個節點的后一個節點
如下圖,在中序遍歷中,下圖的中序遍歷為 8,3,10,1,14,6
,那么 8 的后繼節點
就為 3,8 沒有前驅節點
,10 的前驅節點
是 3,10 的后繼節點
是 1 (主要是看遍歷出來的數列的順序來判定)
這種加上了線索的二叉樹鏈表稱為 線索鏈表(一般的二叉樹本來就是用鏈表實現的),相應的二叉樹稱為 線索二叉樹(Threaded BinaryTree)。根據線索性質的不同,線索二叉樹可分為:前、中、后序線索二叉樹。
思路分析
將上圖的二叉樹,進行 中序線索二叉樹,中序遍歷的數列為 8,3,10,1,14,6
。
那么以上圖為例,線索化二叉樹后的樣子如下圖
- 8 的后繼節點為 3
- 3 由於 左右節點都有元素,不能線索化
- 10 的前驅節點為 3,后繼節點為 1
- 1 不能線索化
- 14 的前驅節點為 1,后繼節點為 6
- 6 有左節點,不能線索化
注意:當線索化二叉樹后,那么一個 Node 節點的 left 和 right 屬性,就有如下情況:
-
left 指向的是 左子樹,也可能是指向 前驅節點
例如:節點 1 left 節點指向的是左子樹,節點 10 的 left 指向的就是前驅節點
-
right 指向的是 右子樹,也可能是指向 后繼節點
例如:節點 3 的 right 指向的是右子樹,節點 10 的 right 指向的是后繼節點
代碼實現
下面的代碼,有幾個地方需要注意:
-
HeroNode
就是一個 簡單的二叉樹節點,不同的是多了兩個type
屬性:leftType
:左節點的類型:0:左子樹,1:前驅節點rightType
:右節點的類型:0:右子樹,1:后繼節點
為什么需要?上面原理講解了,left 或則 right 會有兩種身份,需要一個額外 的屬性來指明
-
threadeNodes
:線索化二叉樹是將一棵二叉樹,進行線索化標記。只是將可以線索化的節點進行賦值。
/**
* 線索化二叉樹
*/
public class ThreadedBinaryTreeTest {
class HeroNode {
public int id;
public String name;
public HeroNode left;
public HeroNode right;
/**
* 左節點的類型:0:左子樹,1:前驅節點
*/
public int leftType;
/**
* 右節點的類型:0:右子樹,1:后繼節點
*/
public int rightType;
public HeroNode(int id, String name) {
this.id = id;
this.name = name;
}
@Override
public String toString() {
return "HeroNode{" +
"id=" + id +
", name='" + name + '\'' +
'}';
}
}
class ThreadedBinaryTree {
public HeroNode root;
public HeroNode pre; // 保留上一個節點
/**
* 線索化二叉樹:以 中序的方式線索化
*/
public void threadeNodes() {
// 從 root 開始遍歷,然后 線索化
this.threadeNodes(root);
}
private void threadeNodes(HeroNode node) {
if (node == null) {
return;
}
// 中序遍歷順序:先左、自己、再右
threadeNodes(node.left);
// 難點就是在這里,如何線索化自己
// 當自己的 left 節點為空,則設置為前驅節點
if (node.left == null) {
node.left = pre;
node.leftType = 1;
}
// 因為要設置后繼節點,只有回到自己(node)的后繼節點的時候,才能把自己設置為前一個的后繼節點 !!這里自己好好意會一下
// 當前一個節點的 right 為空時,則需要自己(node)是后繼節點
if (pre != null && pre.right == null) {
pre.right = node;
pre.rightType = 1;
}
// 數列: 1,3,6,8,10,14
// 中序: 8,3,10,1,14,6
// 這里最好結合上面圖示的二叉樹來看,容易理解
// 因為中序遍歷,先遍歷左邊,所以 8 是第一個輸出的節點
// 當 node = 8 時,pre 還沒有被賦值過,則為空。這是正確的,因為 8 就是第一個節點
// 當 8 處理完成之后,處理 3 時
// 當 node = 3 時,pre 被賦值為 8 了。
pre = node; //關鍵!!!!
threadeNodes(node.right);
}
}
@Test
public void threadeNodesTest() {
HeroNode n1 = new HeroNode(1, "宋江");
HeroNode n3 = new HeroNode(3, "無用");
HeroNode n6 = new HeroNode(6, "盧俊");
HeroNode n8 = new HeroNode(8, "林沖2");
HeroNode n10 = new HeroNode(10, "林沖3");
HeroNode n14 = new HeroNode(14, "林沖4");
n1.left = n3;
n1.right = n6;
n3.left = n8;
n3.right = n10;
n6.left= n14;
ThreadedBinaryTree tree = new ThreadedBinaryTree();
tree.root = n1;
tree.threadeNodes();
// 驗證:
HeroNode left = n10.left;
HeroNode right = n10.right;
System.out.println("10 號節點的前驅節點:" + left.id);
System.out.println("10 號節點的后繼節點:" + right.id);
}
}
測試輸出
10 號節點的前驅節點:3
10 號節點的后繼節點:1
如果看代碼注釋看不明白的話 ,現在來解釋:
-
線索化的時候,就是要按照 中序遍歷 的順序,去找可以線索化的節點
中序遍歷順序:先左、自己、再右
我們主要的代碼是在 自己這一塊
-
確定前一個節點 pre
這個 pre 很難理解,對照下圖進行理解
// 數列: 1,3,6,8,10,14 // 中序: 8,3,10,1,14,6 // 因為中序遍歷,先遍歷左邊,所以 8 是第一個輸出的節點 // 當 node = 8 時,pre 還沒有被賦值過,則為空。這是正確的,因為 8 就是第一個節點 // 當 8 處理完成之后,處理 3 時 // 當 node = 3 時,pre 被賦值為 8 了。
-
設置前驅節點
難點的講解在於 pre,解決了這里就簡單了
如果當 node = 8 時,pre 還是 null,因為 8 就是中序的第一個節點。因此 8 沒有前驅
如果當 node = 3 時,pre = 8,那么 3 是不符合線索化要求的,因為 8 是 3 的 left
-
設置后繼節點
接上面的邏輯。
如果當 node = 8 時,本來 該給 8 設置他的后繼節點,但是此時根本就獲取不到節點 3,因為節點是單向的。
這里就得利用前一個節點 pre,
當 node=3 時,pre = 8,這時就可以為節點 8 處理它的后繼節點了,因為根據中序的順序,左、自己、后。那么自己(node)一定是前一個的后繼。只要前一個的 right 為 null,就符合線索化了
上述最難的 3 個點說明,請對照上圖看,先看一遍代碼,再看說明。然后去 debug 你就了解了。
遍歷線索化二叉樹
結合圖示來看思路說明最直觀
對於原來的中序遍歷來說,無法使用了,因為左右節點再也不為空了。這里直接利用線索化節點提供的線索,找到他的后繼節點遍歷,思路如下:
-
首先找到它的第一個節點,並打印它
中序遍歷,先左,所以一直往左找,直到 left 為 null 時,則是第一個節點
-
然后看它的 right節點是否為線索化節點,是的話則打印它
因為:如果 right 是一個線索化節點,也就是 right 是當前節點的 后繼節點,可以直接打印。
這里判斷是否為線索化節點就得用到新添加的那兩個屬性了,
leftType
:左節點的類型:0:左子樹,1:前驅節點rightType
:右節點的類型:0:右子樹,1:后繼節點
-
right 如果是一個普通節點,那么就直接處理它的右側節點
因為:按照中序遍歷順序,左、自己、右,這里就理所當然是右了
看描述索然無味,結合下面的代碼來看,就比較清楚了
/**
* 遍歷線索化二叉樹
*/
public void threadedList() {
// 前面線索化使用的是中序,這里也同樣要用中序的方式
// 但是不適合使用之前那種遞歸了
HeroNode node = root;
while (node != null) {
// 中序:左、自己、右
// 數列: 1,3,6,8,10,14
// 中序: 8,3,10,1,14,6
// 那么先找到左邊的第一個線索化節點,也就是 8. 對照圖示理解,比較容易
while (node.leftType == 0) {
node = node.left;
}
// 找到這個線索化節點之后,打印它
System.out.println(node);
// 如果該節點右子節點也是線索化節點,則打印它
while (node.rightType == 1) {
node = node.right;
System.out.println(node);
}
//否則
// 到達這里,就說明遇到的不是一個 線索化節點了
// 而且,按中序的順序來看:這里應該處理右側了
node = node.right;
}
}
測試
/**
* 線索化遍歷測試
*/
@Test
public void threadedListTest() {
// 1,3,6,8,10,14
HeroNode n1 = new HeroNode(1, "宋江");
HeroNode n3 = new HeroNode(3, "無用");
HeroNode n6 = new HeroNode(6, "盧俊");
HeroNode n8 = new HeroNode(8, "林沖2");
HeroNode n10 = new HeroNode(10, "林沖3");
HeroNode n14 = new HeroNode(14, "林沖4");
n1.left = n3;
n1.right = n6;
n3.left = n8;
n3.right = n10;
n6.left = n14;
ThreadedBinaryTree tree = new ThreadedBinaryTree();
tree.root = n1;
tree.threadeNodes();
tree.threadedList(); // 8,3,10,1,14,6
}
輸出信息
HeroNode{id=8, name='林沖2'}
HeroNode{id=3, name='無用'}
HeroNode{id=10, name='林沖3'}
HeroNode{id=1, name='宋江'}
HeroNode{id=14, name='林沖4'}
HeroNode{id=6, name='盧俊'}
前序線索化
public void preOrderThreadeNodes() {
preOrderThreadeNodes(root);
}
/**
* 前序線索化二叉樹
*/
public void preOrderThreadeNodes(HeroNode node) {
// 前序:自己、左(遞歸)、右(遞歸)
// 數列: 1,3,6,8,10,14
// 前序: 1,3,8,10,6,14
if (node == null) {
return;
}
System.out.println(node);
// 當自己的 left 節點為空,則可以線索化
if (node.left == null) {
node.left = pre;
node.leftType = 1;
}
// 當前一個節點 right 為空,則可以把自己設置為前一個節點的后繼節點
if (pre != null && pre.right == null) {
pre.right = node;
pre.rightType = 1;
}
// 因為是前序,因此 pre 保存的是自己
// 到下一個節點的時候,下一個節點如果是線索化節點 ,才能將自己作為它的前驅節點
pre = node;
// 那么繼續往左,查找符合可以線索化的節點
// 因為先處理的自己,如果 left == null,就已經線索化了
// 再往左的時候,就不能直接進入了
// 需要判定,如果不是線索化節點,再進入
// 比如:當前節點 8,前驅 left 被設置為了 3
// 這里節點 8 的 left 就為 1 了,就不能繼續遞歸,否則又回到了節點 3 上
// 導致死循環了。
if (node.leftType == 0) {
preOrderThreadeNodes(node.left);
}
if (node.rightType == 0) {
preOrderThreadeNodes(node.right);
}
}
這里代碼相對於中序線索化來說,難點在於:什么時候該繼續往左查找,什么時候該繼續往右查找。
測試
/**
* 前序線索化
*/
@Test
public void preOrderThreadedNodesTest() {
// 1,3,6,8,10,14
HeroNode n1 = new HeroNode(1, "宋江");
HeroNode n3 = new HeroNode(3, "無用");
HeroNode n6 = new HeroNode(6, "盧俊");
HeroNode n8 = new HeroNode(8, "林沖2");
HeroNode n10 = new HeroNode(10, "林沖3");
HeroNode n14 = new HeroNode(14, "林沖4");
n1.left = n3;
n1.right = n6;
n3.left = n8;
n3.right = n10;
n6.left = n14;
ThreadedBinaryTree tree = new ThreadedBinaryTree();
tree.root = n1;
tree.preOrderThreadeNodes();
// 驗證: 前序順序: 1,3,8,10,6,14
HeroNode left = n10.left;
HeroNode right = n10.right;
System.out.println("10 號節點的前驅節點:" + left.id); // 8
System.out.println("10 號節點的后繼節點:" + right.id); // 6
left = n6.left;
right = n6.right;
System.out.println("6 號節點的前驅節點:" + left.id); // 14, 普通節點
System.out.println("6 號節點的后繼節點:" + right.id); // 14,線索化節點
}
輸出
HeroNode{id=1, name='宋江'}
HeroNode{id=3, name='無用'}
HeroNode{id=8, name='林沖2'}
HeroNode{id=10, name='林沖3'}
HeroNode{id=6, name='盧俊'}
HeroNode{id=14, name='林沖4'}
10 號節點的前驅節點:8
10 號節點的后繼節點:6
6 號節點的前驅節點:14 注意:這里不是前驅,而是正常的一個left節點
6 號節點的后繼節點:14
可以看到,我們線索化二叉樹的時候,是按照前序的順序 1,3,8,10,6,14 的順序遍歷查找處理的。處理之后的 6 號節點兩個都是一樣的,但是 left 是正常的節點 14,right 是線索化節點 14,不明白可以結合下圖想一下
前序線索化遍歷
前序線索化遍歷,還是要記住它的特點是:自己、左(遞歸)、右(遞歸),那么遍歷思路如下:
- 先打印自己
- 再左遞歸打印
- 直到遇到一個節點有 right 且是后繼節點,則直接跳轉到該后繼節點,繼續打印
- 如果遇到的是一個普通節點,則打印該普通節點,完成一輪循環,進入到下一輪,從第 1 步開始
/**
* 前序線索化二叉樹遍歷
*/
public void preOrderThreadeList() {
HeroNode node = root;
// 最后一個節點無后繼節點,就會退出了
// 前序:自己、左(遞歸)、右(遞歸)
while (node != null) {
// 先打印自己
System.out.println(node);
while (node.leftType == 0) {
node = node.left;
System.out.println(node);
}
while (node.rightType == 1) {
node = node.right;
System.out.println(node);
}
node = node.right;
}
}
測試代碼
@Test
public void preOrderThreadeListTest() {
ThreadedBinaryTree tree = buildTree();
tree.preOrderThreadeNodes();
System.out.println("前序線索化遍歷");
tree.preOrderThreadeList(); // 1,3,8,10,6,14
}
測試輸出
HeroNode{id=1, name='宋江'}
HeroNode{id=3, name='無用'}
HeroNode{id=8, name='林沖2'}
HeroNode{id=10, name='林沖3'}
HeroNode{id=6, name='盧俊'}
HeroNode{id=14, name='林沖4'}
前序線索化遍歷
HeroNode{id=1, name='宋江'}
HeroNode{id=3, name='無用'}
HeroNode{id=8, name='林沖2'}
HeroNode{id=10, name='林沖3'}
HeroNode{id=6, name='盧俊'}
HeroNode{id=14, name='林沖4'}
總結
還有一個后序線索化,這里不寫了,從前序、中序獲取到幾個重要的點:
-
線索化時:
-
根據不同的「序」,如何進行遍歷的同時,處理線索化節點
對於中序來說:
- 先遞歸到最左節點
- 開始線索化
- 再遞歸到最右節點
它的順序:先左(遞歸)、自己(node)、再右(遞歸)
對於前序來說:
- 開始線索化
- 一直往左遞歸
- 一直往右遞歸
它的順序:自己(node)、左(遞歸)、右(遞歸)
-
根據不同的「序」,考慮如何跳過或進入下一個節點,因為要考慮前驅和后繼
「序」:前、中、后序
- 中序:由於它的順序,第一個線索化節點,就是他的順序的第一個節點,不用管接下來遇到的節點是否已經線索化過了,這是由於它天然的順序,已經線索化過的節點,不會在下一次處理
- 前序:由於它的順序,第一個順序輸出的節點,並不是第一個線索化節點。所以它需要對他的 左右節點進行類型判定,是普通節點的話,再按:自己、左、右的順序進行左、右進行遞歸,因為下一次出現的節點有可能是已經線索化過的節點,如果不進行判定,就會導致又回到了已經遍歷過的節點。就會導致死循環了。這里可以結合上圖思考一下。
-
-
遍歷線索化時:基本上和線索化時的「序」一起去考慮,何時該進行輸出?什么時候遇到后繼節點時,跳轉到后繼節點處理。最重要的一點是:遍歷時,不用考慮前驅節點,之后考慮何時通過后繼節點進行跳轉輸出(看遍歷代碼就懂)。