二叉樹是資料結構中非常重要的一種資料結構,它是樹的一種,但是每個節點的子節點不能多餘兩個,可以是0,1,2個子節點,0個子節點代表沒有子節點。常見的二叉樹結構如下圖所示:
每個節點的子節點不多於2個,其中3,4,5沒有子節點,2有一個子節點,0,1都有兩個子節點。
基礎概念
根節點:樹的其實節點,沒有父節點。
葉子節點:沒有子節點的節點叫做葉子節點。
節點深度:從根節點到該節點的距離叫做深度,如上圖:節點3的深度是2,節點1的深度是1。
節點高度:該節點到距離最長的葉子節點的距離。
二叉查詢樹
二叉樹最重要的一個應用是在查詢方面的應用,很多的索引結構都是二叉查詢樹,還有向HashMap裡也使用到了紅黑樹,紅黑樹也是二叉查詢樹的一種。二叉查詢樹的一個重要性質,就是任何一個節點,它的左子樹中的節點都小於該節點,它的右子樹中的節點都大於該節點。最開始我們的例圖它不是一棵二叉查詢樹,它不符合我們剛才說的性質。我們再看看下面的例圖:
這是一棵二叉查詢樹,它的任何一個節點的子節點都小於該節點,右子樹的節點都大於該節點。這樣我們在查詢資料的時候,就可以從根節點開始查詢,如果查詢的值小於該節點,就去左子樹中查詢,如果大於該節點,就去右子樹中查詢,如果等於,那就不用說了,直接返回就可以了。這種可以大大提升我們的查詢效率,它的時間複雜度是O(logN)。
手擼二叉查詢樹
首先我們要抽象出節點類,每個節點可以有左子節點,和右子節點,當然節點要儲存一個值,這個值的型別我們不做限制,可以是數字型,也可以是字串,還可以是自己定義的類,但是這裡要加一個前提條件,就是這個值是可比較的,因為兩個節點比較後才能確定位置,所以節點值的型別要實現Comparable
介面。好了,滿足上面的條件,我們就可以抽象出二叉樹節點的類了,如下:
public class BinaryNode<T extends Comparable<T>> {
//節點資料
@Setter@Getter
private T element;
//左子節點
@Setter@Getter
private BinaryNode<T> left;
//右子節點
@Setter@Getter
private BinaryNode<T> right;
//建構函式
public BinaryNode(T element) {
this(element,null,null);
}
//建構函式
public BinaryNode(T element, BinaryNode<T> left, BinaryNode<T> right) {
if (element == null) {
throw new RuntimeException("二叉樹節點元素不能為空");
}
this.element = element;
this.left = left;
this.right = right;
}
}
我們定義二叉樹節點的類為BinaryNode
,我們注意一下後面的泛型,它要實現Comparable
介面。然後我們定義節點資料element
,左子節點left
,和右子節點right
,並且使用@Setter@Getter
註解實現其set和get方法。接下來就是定義兩個構造方法,一個是隻傳入節點元素的,另一個是傳入節點元素和左右子樹的。節點的元素是不能為空的,如果是空則丟擲異常。
然後,我們再定義二叉查詢樹類,類中包括一些二叉查詢樹的基本操作方法,這些基本的操作方法我們後面講,先看定義的基本元素,如下:
public class BinarySearchTree<T extends Comparable<T>> {
//根節點
private BinaryNode<T> root;
public BinarySearchTree() {
this.root = null;
}
//將樹變為空樹
public void makeEmpty() {
this.root = null;
}
//判斷樹是否為空
public boolean isEmpty() {
return this.root == null;
}
}
類的名字定義為:BinarySearchTree
,同樣我們注意一下這裡的泛型,它和BinaryNode
的泛型是一樣的,因為這個型別我們傳遞給BinaryNode
。類中定義了樹的根節點root
,以及構造方法,構造方法只是定義了一棵空樹,根節點為空。然後是兩個比較基礎的樹的操作方法makeEmpty
和isEmpty
,將樹變為空樹和判斷樹是否為空。
- 現在我們要編寫一些樹的操作方法了,首先我們要編寫的就是
contains
方法,它會判斷樹中是否包含某個元素,比如上面例圖中,我們判斷樹中是否包含3這個元素。具體實現如下:
/**
* 二叉樹是否包含某個元素
*
* @param element 檢查的元素
* @return true or false
*/
public boolean contains(T element) {
return contains(root, element);
}
/**
* 二叉樹是否包含某個元素
*
* @param tree 整棵樹或左右子樹
* @param element 檢查的元素
* @return true or false
*/
private boolean contains(BinaryNode<T> tree, T element) {
if (tree == null) {
return false;
}
int compareResult = element.compareTo(tree.getElement());
if (compareResult > 0) {
return contains(tree.getRight(), element);
}
if (compareResult < 0) {
return contains(tree.getLeft(), element);
}
return true;
}
這裡我們定義了兩個contains
方法,第一個contains
方法呼叫第二個contains
方法,第二個contains
方法是私有的,外部不能訪問。在呼叫第二個contains
方法時,我們將root傳進去,也就是整棵樹傳入去查詢。在第二個contains
方法中,我們先判斷樹是否為空,如果為空,肯定不會包含我們要查詢的元素,則直接返回false。然後我們用查詢的元素和當前節點的元素作比較,這裡我們使用compareTo
方法,它是Comparable
介面中定義好的方法,這也是我們定義泛型時要實現Comparable
介面的原因了。比較結果大於0,說明查詢的值大於當前節點值,我們遞迴呼叫contains
方法,將右子樹和查詢的值傳入;比較結果小於0,說明查詢的值小於當前節點值,我們同樣遞迴呼叫contains
方法,將左子樹和查詢的值傳入進行查詢。最後如果比較結果等於0,說明查詢的值和當前節點值是一樣的,我們返回true就可以了。
contains
方法算是一個開胃小菜,其中用到了遞迴,這也讓我們對二叉樹的編寫方法有了一個初步的瞭解。
- 接下來我們要編寫的是
findMin
和findMax
方法,分別是找出樹中最小值和最大值的方法。由於我們的樹是一棵二叉查詢樹,左子樹的值要小於當前節點,右子樹的值大於當前節點,所以,最左側節點的值就是最小值,最右側的值則是最大值。我們用程式碼實現一下,
/**
* 找出二叉樹的最小元素
*
* @return
*/
public T findMin() {
if (isEmpty()) throw new RuntimeException("二叉樹為空");
return findMin(root);
}
private T findMin(BinaryNode<T> tree) {
if (tree.getLeft() != null) {
return findMin(tree.getLeft());
}
return tree.getElement();
}
/**
* 找出二叉樹的最大元素
*
* @return
*/
public T findMax() {
if (isEmpty()) throw new RuntimeException("二叉樹為空");
return findMax(root);
}
private T findMax(BinaryNode<T> tree) {
while (tree.getRight() != null) {
tree = tree.getRight();
}
return tree.getElement();
}
我們先來看findMin
方法,先判斷樹是否為空,空樹沒有最小值,也沒有最大值,所以我們這裡丟擲異常。然後我們將整棵樹傳入第二個findMin
方法,在第二個findMin
方法中,我們一直去尋找左子節點,如果左子節點不為空,我們就遞迴的再去尋找,直到節點的左子節點為空,那麼當前節點就是整棵樹的最左節點,那麼它的值就是最小的,我們返回就可以了。
我們再來看findMax
方法,和findMin
方法一樣,先判斷樹是否為空,為空則丟擲異常。我們要重點看的是第二個findMax
方法,這個方法中,我們沒有使用遞迴去尋找最右側的節點,而是使用了一個while迴圈,去找到最右側的節點。這裡我們使用了兩種不同的方法實現了findMin
和findMax
,一個使用了遞迴,另一個使用了while迴圈,其實這兩種方式也是互通的,能用遞迴的方法也可以用while迴圈去實現,反之亦然。
- 接下來我們再來看一下二叉查詢樹的一個非常重要的方法,那就是
insert
插入方法了。當我們向二叉查詢樹中新增一個節點時,要和當前節點做比較,如果小於當前節點值,則在左側插入,如果大於則在右側插入,這裡我們不討論等於的情況。具體程式碼如下:
/**
* 插入元素
*
* @param element
*/
public void insert(T element) {
if (root == null) {
root = new BinaryNode<>(element);
return;
}
insert(root, element);
}
private void insert(BinaryNode<T> tree, T element) {
int compareResult = element.compareTo(tree.getElement());
if (compareResult > 0) {
if (tree.getRight() == null) {
tree.setRight(new BinaryNode<>(element));
} else {
insert(tree.getRight(), element);
}
}
if (compareResult < 0) {
if (tree.getLeft() == null) {
tree.setLeft(new BinaryNode<>(element));
} else {
insert(tree.getLeft(), element);
}
}
}
在插入節點的過程中,我們先判斷根節點是否為空,如果為空,說明是一棵空樹,我們直接將插入元素給到根節點就可以了。如果根節點不為空,我們進入到第二個insert方法,在第二個insert方法中,我們先將插入的值和當前節點做比較,比較結果如果大於0,說明插入的值比當前節點大,所以我們要在右側插入,如果當前節點的右子節點為空,我們直接插入就可以了;如果右子節點不為空,還要和右子節點作比較,這裡我們用遞迴的方法實現,邏輯比較清晰。同理,如果比較結果小於0,我們對左側節點做操作就可以了,這裡不再贅述。
- 上面我們做了節點的插入,最後再來看看節點的刪除
remove
。要刪除一個節點,首先我們要找到這個節點,找到這個節點後,要分情況對這個節點進行處理,如下:
- 刪除節點沒有子節點:我們直接將該節點刪除,也就是將節點置為null;
- 刪除節點只有左子節點或右子節點:這種只有一個子節點的情況,我們直接將要刪除的節點改為它的唯一的子節點就可以了。這裡等於是用子節點覆蓋掉當前節點;
- 刪除節點有兩個子節點:這種是最複雜的情況,要解決這個問題,我們還是要利用二叉查詢樹的特性,就是當前節點的左子樹的值都比當前節點小,右子樹的值都比當前節點大。那麼我們把當前節點刪除後,用哪個節點代替當前節點呢?這裡我們可以在左子樹中找到最大的值,或者從右子樹中找到最小的值,代替當前要刪除的節點。這樣替換後,還是可以保證左子樹的值比當前值小,右子樹的值比當前值大。然後我們再把替換的值,也就是左子樹中的最大值,或者右子樹中的最小值,在左或右子樹中刪掉就可以了。這一段邏輯比較繞,小夥伴們可以多讀幾遍,理解一下。具體實現如下:
/**
* 刪除元素
* @param element
*/
public void remove(T element) {
remove(root, element);
}
private void remove(BinaryNode<T> tree, T element) {
if (tree == null) {
return;
}
int compareResult = element.compareTo(tree.getElement());
if (compareResult > 0) {
remove(tree.getRight(), element);
return;
}
if (compareResult < 0) {
remove(tree.getLeft(), element);
return;
}
if (tree.getLeft() != null && tree.getRight() != null) {
tree.setElement(findMin(tree.getRight()));
remove(tree.getRight(), tree.getElement());
} else {
tree = tree.getLeft() != null ? tree.getLeft() : tree.getRight();
}
}
第一個remove方法不說了,我們重點看第二個。方法進來後,節點是否為空,為空則說明是空樹,或者要刪除的節點沒有找到,那麼直接返回就可以了。然後再用刪除的元素和當前節點作比較,如果大於0,我們用遞迴方法在右子樹中繼續執行刪除方法。同理如果小於0,用左子樹遞迴。再下面就是等於0的情況,也就是找到了要刪除的節點。我們先處理最複雜的情況,就是刪除節點左右子節點都存在的情況,我們使用上面的邏輯,使用右子樹中最小的節點覆蓋當前節點,然後再在右子樹中,將這個值刪掉,我們也是遞迴的呼叫了remove方法。當然這裡也可以使用左子樹中的最大值,小夥伴們自己實現吧。最後就是處理沒有子節點和只有一個子節點的情況,這兩種情況在程式碼中可以合併,如果左子節點不為空,就用左子節點覆蓋掉當前節點,否則使用右子節點覆蓋。如果右子節點也為空,也就是沒有子節點,那麼當前節點也就變為空了。
問題
到這裡,二叉查詢樹的基本的操作方法就編寫完了。這裡引申一個問題,如果我們順序的向一棵樹中插入1,2,3,4,5,這個樹會是什麼形狀?這個也不難想象,如下:
這和連結串列沒有什麼區別了呀,查詢的效能和連結串列一樣了,並沒有提升。這就引出了下一篇的內容:平衡二叉樹,小夥伴們,敬請期待~~