一、數的概念 Tree

- 如上圖所示,是一個樹形機構,這里面每個元素叫作“節點”,用來連線相鄰節點之間的關系,叫作“父子關系”。
- A 節點是 B 節點的父節點, B 節點是 A 節點的子節點。
- B、 C、 D 這三個節點的父節點是同一個節點,所以它們之間互稱為兄弟節點。
- 沒有父節點的節點叫作根節點,也就是圖中的節點 E。
- 我們把沒有子節點的節點叫作葉子節點或者葉節點,比如圖中的 G、 H、 I、 J、 K、 L 都是葉子節點。
- 節點的高度:節點到葉子節點的最長路徑(邊數)。
- 節點的深度:根節點到這個節點所經歷的邊的個數。
- 節點的層數:節點的深度 + 1.
- 樹的高度:就是根節點的高度。
-
二、二叉樹
2.1、二叉樹介紹
-
二叉樹,顧名思義,每個節點最多有兩個“叉”,也就是兩個子節點,分別是左子節點和右子節點。
-
不過,二叉樹並不要求每個節點都有兩個子節點,有的節點只有左子節點,有的節點只有右子節點。
-
-
編號2的二叉樹中,葉子節點全都在最底層,除了葉子節點之外,每個節點都有左右兩個子節點,這種二叉樹就叫作滿二叉樹。
-
編號3的二叉樹中,葉子節點都在最底下兩層,最后一層的葉子節點都靠左排列,並且除了最后一層,其他層的節點個數都要達到最大,這種二叉樹叫作完全二叉樹。
-
2.2、二叉樹的存儲
存儲一棵二叉樹,有兩種方法,一種是基於指針或者引用的二叉鏈式存儲法,一種是基於數組的順序存儲法。
1.鏈式存儲法
- 鏈式存儲比較簡單、直觀。
- 如下圖所示,每個節點有三個字段,其中一個存儲數據,另外兩個是指向左右子節點的指針。
- 只要拎住根節點,就可以通過左右子節點的指針,把整棵樹都串起來。
- 這種存儲方式我們比較常用。大部分二叉樹代碼都是通過這種結構來實現的。
-
2.基於數組的順序存儲
-
如下圖所示,把根節點存儲在下標 i = 1 的位置,那左子節點存儲在下標 2 * i = 2 的位置,右子節點存儲在 2 * i + 1 = 3 的位置。
-
以此類推, B 節點的左子節點存儲在 2 * i = 2 * 2 = 4 的位置,右子節點存儲在 2 * i + 1 = 2 * 2 + 1 = 5 的位置。
-
-
如果節點 X 存儲在數組中下標為 i 的位置,下標為 2 * i 的位置存儲的就是左子節點,下標為 2 * i + 1 的位置存儲的就是右子節點。
-
反過來,下標為 i/2 的位置存儲就是它的父節點。
-
通過這種方式,我們只要知道根節點存儲的位置(一般情況下,為了方便計算子節點,根節點會存儲在下標為1的位置),這樣就可以通過下標計算,把整棵樹都串起來。
2.3。二叉樹的遍歷
-
前序遍歷:對於樹中的任意節點來說,先打印這個節點,然后再打印它的左子樹,最后打印它的右子樹。
-
中序遍歷:對於樹中的任意節點來說,先打印它的左子樹,然后再打印它本身,最后打印它的右子樹。
-
后序遍歷:對於樹中的任意節點來說,先打印它的左子樹,然后再打印它的右子樹,最后打印這個節點本身。
-
-
實際上,二叉樹的前、中、后序遍歷就是一個遞歸的過程。
-
比如,前序遍歷,其實就是先打印根節點,然后再遞歸地打印左子樹,最后遞歸地打印右子樹。
-
前序遍歷的遞推公式:preOrder(r) = print r->preOrder(r->left)->preOrder(r->right)。
-
中序遍歷的遞推公式:inOrder(r) = inOrder(r->left)->print r->inOrder(r->right)
-
后序遍歷的遞推公式:postOrder(r) = postOrder(r->left)->postOrder(r->right)->print r
-
從上面的前、中、后序遍歷的順序圖,可以看出來,每個節點最多會被訪問兩次,所以遍歷操作的時間復雜度,跟節點的個數 n 成正比,也就是說二叉樹遍歷的時間復雜度是 O(n)。
static class Node {
Node rightNode;
Node leftNode;
String data;
}
/**
* 先序遍歷,先打印本身,在打印左節點,在打印右節點
*
* @param node
*/
public static void preOrderTraverse(Node node) {
if (node == null) {
return;
}
System.out.print(node.data + " ");
preOrderTraverse(node.leftNode);
preOrderTraverse(node.rightNode);
}
/**
* 中序遍歷,先打印左節點,在打印本身,在打印右節點
*
* @param node
*/
public static void inOrderTraverse(Node node) {
if (node == null) {
return;
}
inOrderTraverse(node.leftNode);
System.out.print(node.data + " ");
inOrderTraverse(node.rightNode);
}
/**
* 后序遍歷,先打印左節點,在打印右節點,在打印自己
*
* @param node
*/
public static void postOrderTraverse(Node node) {
if (node == null) {
return;
}
preOrderTraverse(node.rightNode);
preOrderTraverse(node.leftNode);
System.out.print(node.data + " ");
}
/**
* 數的層級遍歷
*
* @param root
*/
public static void levelTraverse(Node root) {
if (root == null) {
return;
}
LinkedList<Node> queue = new LinkedList<Node>();
Node current = null;
// 根節點入隊
queue.offer(root);
// 左側數的深度
int leftNum = 0;
// 右側數的深度
int rightNum = 0;
// 只要隊列中有元素,就可以一直執行,非常巧妙地利用了隊列的特性
while (!queue.isEmpty()) {
// 出隊隊頭元素
current = queue.poll();
System.out.print("-->" + current.data);
// 左子樹不為空,入隊
if (current.leftNode != null) {
queue.offer(current.leftNode);
leftNum++;
}
// 右子樹不為空,入隊
if (current.rightNode != null) {
queue.offer(current.rightNode);
rightNum++;
}
}
System.out.println(rightNum + "\t" + leftNum);
}
三、二叉查找樹(Binary Search Tree)
- 二叉查找樹是二叉樹中最常用的一種類型,也叫二叉搜索樹。
- 二叉查找樹是為了實現快速查找而生的。
- 它不僅僅支持快速查找一個數據,還支持快速插入、刪除一個數據。
- 二叉查找樹要求,在樹中的任意一個節點,其左子樹中的每個節點的值,都要小於這個節點的值,而右子樹節點的值都大於這個節點的值。
- 二叉查找樹最大的特點就是,支持動態數據集合的快速插入、刪除、查找操作。
- 中序遍歷二叉查找樹,可以輸出有序的數據序列,時間復雜度是 O(n),非常高效。
-
3.1、二叉查找樹的查找操作
- 先取根節點,如果它等於要查找的數據,就返回。
- 如果要查找的數據比根節點的值小,那就在左子樹中遞歸查找;
- 如果要查找的數據比根節點的值大,那就在右子樹中遞歸查找。
-
public class BinarySearchTree {
private Node tree;
public Node find(int data) {
Node p = tree;
while (p != null) {
if (data < p.data) p = p.leftNode;
else if (data > p.data) p = p.rightNode;
else return p;
}
return null;
}
class Node {
private int data;
private Node leftNode;
private Node rightNode;
public Node(int data) {
this.data = data;
}
}
}
3.2、二叉查找樹的插入操作
- 二叉查找樹的插入過程有點類似查找操作。
- 新插入的數據一般都是在葉子節點上,所以只需要從根節點開始,依次比較要插入的數據和節點的大小關系。
- 如果要插入的數據比節點的數據大,並且節點的右子樹為空,就將新數據直接插到右子節點的位置;
- 如果不為空,就再遞歸遍歷右子樹,查找插入位置。
- 同理,如果要插入的數據比節點數值小,並且節點的左子樹為空,就將新數據插入到左子節點的位置;如果不為空,就再遞歸遍歷左子樹,查找插入位置。
/**
* 插入操作
*
* @param data
* @return
*/
public Boolean insert(int data) {
if (tree == null) {
tree = new Node(data);
return true;
}
Node p = tree;
while (p != null) {
if (data > p.data) {
// 插入右節點
if (p.rightNode == null) {
p.rightNode = new Node(data);
return true;
}
p = p.rightNode;
} else {
// 插入 左節點
if (p.leftNode == null) {
p.leftNode = new Node(data);
return true;
}
p = p.leftNode;
}
}
return false;
}
3.3、二叉查找樹的刪除操作
- 針對要刪除節點的子節點個數的不同,需要分三種情況來處理。
- 第一種情況是,如果要刪除的節點沒有子節點
- 只需要直接將父節點中,指向要刪除節點的指針置為 null。
- 比如圖中的刪除節點 55。
- 第二種情況是,如果要刪除的節點只有一個子節點(只有左子節點或者右子節點)
- 只需要更新父節點中,指向要刪除節點的指針,讓它指向要刪除節點的子節點就可以了。
- 比如圖中的刪除節點 13。
- 第三種情況是,如果要刪除的節點有兩個子節點
- 需要找到這個節點的右子樹中的最小節點,把它替換到要刪除的節點上。
- 然后再刪除掉這個最小節點,因為最小節點肯定沒有左子節點(如果有左子結點,那就不是最小節點了)。
- 所以,可以應用上面兩條規則來刪除這個最小節點。比如圖中的刪除節點 18。
-
/**
* 刪除
* @param data
*/
public void delete(int data) {
// p指向要刪除的節點,初始化指向根節點
Node p = tree;
// pp記錄的是p的父節點
Node pp = null;
// 查找要刪除的節點位置,及其父節點
while (p != null && p.data != data) {
pp = p;
if (data > p.data) {
p = p.rightNode;
} else {
p = p.leftNode;
}
}
if (p == null) {
return;// 沒有找到
}
// 要刪除的節點有兩個子節點
if (p.leftNode != null && p.rightNode != null) {
// 查找右子樹中最小節點
Node minp = p.rightNode;
Node minpp = p; // minPP表示minP的父節點
while (minp.leftNode != null) {
minpp = minp;
minp = minp.leftNode;
}
// 將 minp 的數據替換到 p 中
p.data = minp.data;
// 下面就變成了刪除 minp 了
p = minp;
pp = minpp;
}
// 刪除節點是葉子節點或者僅有一個子節點
Node child; // p 的子節點
if (p.leftNode != null) {
child = p.leftNode;
} else if (p.rightNode != null) {
child = p.rightNode;
} else {
child = null;
}
if (pp == null) {
// 刪除的是根節點
tree = child;
} else if (pp.leftNode == p) {
pp.leftNode = child;
} else {
pp.rightNode = child;
}
}
- 實際上,關於二叉查找樹的刪除操作,還有個非常簡單、取巧的方法,就是單純將要刪除的節點標記為“已刪除”,但是並不真正從樹中將這個節點去掉。
- 這樣原本刪除的節點還需要存儲在內存中,比較浪費內存空間,但是刪除操作就變得簡單了很多。
- 而且,這種處理方法也並沒有增加插入、查找操作代碼實現的難度。
3.4、支持重復數據的二叉查找樹
- 上文提到的二叉查找樹,默認樹中節點存儲的都是數字。很多時候,在二叉查找樹中存儲的,是一個包含很多字段的對象。
- 利用對象的某個字段作為鍵值(key)來構建二叉查找樹。把對象中的其他字段叫作衛星數據。
- 前面講的二叉查找樹的操作,針對的都是不存在鍵值相同的情況。
- 那如果存儲的兩個對象鍵值相同,解決方法如下:
-
第一種方法比較容易。二叉查找樹中每一個節點不僅會存儲一個數據,因此通過鏈表和支持動態擴容的數組等數據結構,把值相同的數據都存儲在同一個節
點上。 -
第二種方法是,每個節點仍然只存儲一個數據。
-
在查找插入位置的過程中,如果碰到一個節點的值,與要插入數據的值相同,就將這個要插入的數據放到這個節點的右子樹,也就是說,把這個新插入的數據當作大於這個節點的值來處理。
-
-
當要查找數據的時候,遇到值相同的節點,並不停止查找操作,而是繼續在右子樹中查找,直到遇到葉子節點,才停止。這樣就可以把鍵值等於要查找值的所有節點都找出來。
-
-
對於刪除操作,也需要先查找到每個要刪除的節點,然后再按前面講的刪除操作的方法,依次刪除。
-
-
3.5、二叉查找樹的時間復雜度分析
-
二叉查找樹的形態各式各樣。如下圖所示,對於同一組數據,構造了三種二叉查找樹。它們的查找、插入、刪除操作的執行效率都是不一樣的。
-
圖中第一種二叉查找樹,根節點的左右子樹極度不平衡,已經退化成了鏈表,所以查找的時間復雜度就變成了 O(n)。
-
-
從前面的例子、圖,以及還有代碼來看,不管操作是插入、刪除還是查找, 時間復雜度其實都跟樹的高度成正比,也就是 O(height)。
-
既然這樣,現在問題就轉變成另外一個了,也就是,如何求一棵包含 n 個節點的完全二叉樹的高度?
-
樹的高度就等於最大層數減一,為了方便計算,轉換成層來表示。
-
從圖中可以看出,包含 n 個節點的完全二叉樹中,第一層包含1個節點,第二層包含2個節點,第三層包含4個節點,依次類推,下面一層節點個數是上一層的 2 倍,第 K 層包含的節點個數就是 2^(K-1)。
-
不過,對於完全二叉樹來說,最后一層的節點個數有點兒不遵守上面的規律了。它包含的節點個數在 1 個到 2^(L-1) 個之間(假設最大層數是L)。
-
如果我們把每一層的節點個數加起來就是總的節點個數 n。也就是說,如果節點的個數是 n,那么 n 滿足這樣一個關系:
-
n >= 1+2+4+8+...+2^(L-2)+1
-
n <= 1+2+4+8+...+2(L-2)+2(L-1)
-
借助等比數列的求和公式,我們可以計算出,L 的范圍是 [log2(n+1), log2n +1]。完全二叉樹的層數小於等於 log2n +1,也就是說,完全二叉樹的高度小於等於 log2n。
-
顯然,極度不平衡的二叉查找樹,它的查找性能肯定不能滿足需求。
-
需要構建一種不管怎么刪除、插入數據,在任何時候,都能保持任意節點左右子樹都比較平衡的二叉查找樹,這就是平衡二叉查找樹。
-
平衡二叉查找樹的高度接近 logn,所以插入、刪除、查找操作的時間復雜度也比較穩定,是 O(logn)。
四、紅黑樹
- 二叉查找樹是最常用的一種二叉樹,它支持快速插入、刪除、查找操作,各個操作的時間復雜度跟樹的高度成正比,理想情況下,時間復雜度是 O(logn)。
- 不過,二叉查找樹在頻繁的動態更新過程中,可能會出現樹的高度遠大於 log2n 的情況,從而導致各個操作的效率下降。
- 極端情況下,二叉樹會退化為鏈表,時間復雜度會退化到 O(n)。
- 要解決這個復雜度退化的問題,需要設計一種平衡二叉查找樹,比如紅黑樹。
4.1、平衡二叉查找樹
-
平衡二叉樹的嚴格定義是這樣的:二叉樹中任意一個節點的左右子樹的高度相差不能大於 1。
-
從這個定義來看,完全二叉樹、滿二叉樹其實都是平衡二叉樹,但是非完全二叉樹也有可能是平衡二叉樹。
-
-
平衡二叉查找樹不僅滿足上面平衡二叉樹的定義,還滿足二叉查找樹的特點。
-
最先被發明的平衡二叉查找樹是 AVL 樹,它嚴格符合剛講到的平衡二叉查找樹的定義,即任何節點的左右子樹高度相差不超過 1,是一種高度平衡的二叉查找樹。
-
但是很多平衡二叉查找樹其實並沒有嚴格符合上面的定義(樹中任意一個節點的左右子樹的高度相差不能大於1),比如紅黑樹,它從根節點到各個葉子節點的最長路徑,有可能會比最短路徑大一倍。
-
發明平衡二叉查找樹這類數據結構的初衷是,解決普通二叉查找樹在頻繁的插入、刪除等動態更新的情況下,出現時間復雜度退化的問題。
-
所以, 平衡二叉查找樹中“平衡”的意思,其實就是讓整棵樹左右看起來比較“對稱”、比較“平衡”,不要出現左子樹很高、右子樹很矮的情況。
-
這樣就能讓整棵樹的高度相對來說低一些,相應的插入、刪除、查找等操作的效率高一些。
-
所以,如果現在設計一個新的平衡二叉查找樹,只要樹的高度不比 log2n 大很多(比如樹的高度仍然是對數量級的),盡管它不符合嚴格的平衡二叉查找樹的定義,但仍然可以說,這是一個合格的平衡二叉查找樹。
4.2、紅黑樹
- 平衡二叉查找樹其實有很多,比如, Splay Tree(伸展樹)、 Treap(樹堆)等,但是提到平衡二叉查找樹,聽到的基本都是紅黑樹。
- 他的出鏡率甚至要高於“平衡二叉查找樹”這幾個字,有時候,甚至默認平衡二叉查找樹就是紅黑樹。
- 紅黑樹的英文是“Red-Black Tree”,簡稱R-B Tree。它是一種不嚴格的平衡二叉查找樹,它的定義是不嚴格符合平衡二叉查找樹的定義的。
- 顧名思義,紅黑樹中的節點,一類被標記為黑色,一類被標記為紅色。除此之外,一棵紅黑樹還需要滿足這樣幾個要求:
- 根節點是黑色的;
- 每個葉子節點都是黑色的空節點(NIL),也就是說,葉子節點不存儲數據;
- 任何相鄰的節點都不能同時為紅色,也就是說,紅色節點是被黑色節點隔開的;
- 每個節點,從該節點到達其可達葉子節點的所有路徑,都包含相同數目的黑色節點;
- 這里的第二點要求“葉子節點都是黑色的空節點”,它主要是為了簡化紅黑樹的代碼實現而設置的。
- 下圖中將黑色的、空的葉子節點都省略掉了。
-
1.為什么說紅黑樹是“近似平衡”的?
-
平衡二叉查找樹的初衷,是為了解決二叉查找樹因為動態更新導致的性能退化問題。所以,“平衡”的意思可以等價為性能不退化。
-
“近似平衡”就等價為性能不會退化的太嚴重。
-
二叉查找樹很多操作的性能都跟樹的高度成正比。
-
一棵極其平衡的二叉樹(滿二叉樹或完全二叉樹)的高度大約是 log2n,所以如果要證明紅黑樹是近似平衡的,只需要分析,紅黑樹的高度是否比較穩定地趨近 log2n 就好了。
-
如果將紅色節點從紅黑樹中去掉,那單純包含黑色節點的紅黑樹的高度是多少呢?
-
紅色節點刪除之后,有些節點就沒有父節點了,它們會直接拿這些節點的祖父節點(父節點的父節點)作為父節點。所以,之前的二叉樹就變成了四叉樹。
-
-
前面紅黑樹的定義里有這么一條:從任意節點到可達的葉子節點的每個路徑包含相同數目的黑色節點。
-
從四叉樹中取出某些節點,放到葉節點位置,四叉樹就變成了完全二叉樹。所以,僅包含黑色節點的四叉樹的高度,比包含相同節點個數的完全二叉樹的高度還要小。
-
完全二叉樹的高度近似 log2n,這里的四叉“黑樹”的高度要低於完全二叉樹,所以去掉紅色節點的“黑樹”的高度也不會超過 log2n。
-
現在知道只包含黑色節點的“黑樹”的高度,那我們現在把紅色節點加回去,高度會變成多少呢?
-
從上面畫的紅黑樹的例子和定義看,在紅黑樹中,紅色節點不能相鄰,也就是說,有一個紅色節點就要至少有一個黑色節點,將它跟其他紅色節點隔開。
-
紅黑樹中包含最多黑色節點的路徑不會超過l og2n,所以加入紅色節點之后,最長路徑不會超過 2log2n,也就是說,紅黑樹的高度近似 2log2n。
-
所以,紅黑樹的高度只比高度平衡的AVL樹的高度(log2n)僅僅大了一倍,在性能上,下降得並不多。
-
這樣推導出來的結果不夠精確,實際上紅黑樹的性能更好。