二叉樹基礎下
1. 二叉查詢樹(Binary Search Tree)
二叉查詢樹是二叉樹中最常用的一種型別,也叫二叉搜尋樹。二叉查詢樹是為了實現快速查詢而生的,它不僅僅支援快速查詢一個數據,還支援快速地插入、刪除一個數據。
二叉查詢樹要求,在樹中的任意一個節點,其左子樹中的每個節點的值,都要小於這個節點的值,而右子樹節點的值都大於這個節點的值。
1.1. 二叉查詢樹的查詢操作
在二叉查詢樹中查詢時,我們先取根節點,如果其值正好等於要查詢的數,就直接返回;如果大於要查詢的數,我們就遞迴在左子樹中進行查詢;如果小於要查詢的數,我們就遞迴在右子樹中進行查詢。
int Find_Tree (TreeNode *tree, int data)
{
TreeNode *temp = tree;
while(temp != NULL)
{
if (temp->val < data) temp = temp->right;
else if (temp->val > data) temp = temp->left;
else return temp->val;
}
return -1;
}
1.2. 二叉查詢樹的插入操作
二叉查詢樹的插入操作和查詢操作類似,新插入的資料一般都是在葉子節點上,因此我們需要從根節點開始,依次比較新插入的資料和節點資料的大小關係。
如果節點的資料小於新插入的資料,並且節點的右子樹為空,我們就將新資料插入到右子節點的位置;如果右子樹不為空,我們就繼續遞迴查詢右子樹,直到找到正確的位置。同理,如果節點的資料大於新插入的資料,並且節點的左子樹為空,我們就將新資料插入到左子節點的位置;如果左子樹不為空,我們就繼續遞迴查詢左子樹,直到找到正確的位置。
void Insert_Tree(TreeNode *tree, int data)
{
TreeNode *temp = tree;
if (tree == NULL)
{
tree = new TreeNode(data) ;
return;
}
while (temp != NULL)
{
if (temp->val < data)
{
if (temp->right == NULL)
{
temp->right = new TreeNode(data);
return;
}
temp = temp->right;
}
else
{
if (temp->left == NULL)
{
temp->left = new TreeNode(data);
return;
}
temp = temp->left;
}
}
}
1.3. 二叉查詢樹的刪除操作
二叉查詢樹的刪除操作相對查詢和插入操作來說比較複雜,可以分為以下幾種情況。
如果待刪除的節點沒有子節點,我們直接刪除掉這個節點,讓父節點指向這個節點的指標指向 NULL 即可,如下圖中的節點 55。
如果待刪除的節點只有一個子節點,我們需要刪除掉這個節點,然後讓其子節點移到該節點位置,也即讓父節點指向該節點的指標重新指向該節點的子節點,如下圖中的節點 13。
如果待刪除的節點同時具有左右子節點,我們需要找到這個節點的右子樹中最小的節點,把它替換到待刪除的節點上,然後再刪除這個最小節點。因為這個最小節點肯定沒有左子節點,因此我們可以應用上面的兩條規則來刪除這個最小節點。如下圖中的節點 18。
void Delete_Tree(TreeNode *tree, int data)
{
TreeNode *deleted_node = tree; // 指向待刪除節點
TreeNode *parent = NULL; // 指向待刪除節點的父節點
TreeNode *child = NULL; // 指向待刪除節點的子節點
while (deleted_node != NULL && deleted_node->val != data)
{
parent = deleted_node;
if (deleted_node->val < data) deleted_node = deleted_node->right;
else deleted_node = deleted_node->left;
}
if (deleted_node == NULL) return; // 待刪除節點為空,沒找到
TreeNode *min_node = tree; // 指向右子樹最小節點
TreeNode *min_parent = NULL; // 指向待右子樹最小節點的父節點
// 待刪除節點有左右子節點,查詢右子樹的最小節點
if (deleted_node->right != NULL && deleted_node->left != NULL)
{
min_node = deleted_node->right;
while (min_node->left != NULL)
{
min_parent = min_node;
min_node = min_node->left;
}
deleted_node->val = min_node->val; // 待刪除節點的值等於右子樹最小節點的值
// 接下來刪除最小節點即可
deleted_node = min_node;
parent = min_parent;
}
// 待刪除結點只有一個子結點或者是葉節點沒有子節點
else if(deleted_node->right == NULL) child = deleted_node->left;
else if(deleted_node->left == NULL) child = deleted_node->right;
else child = NULL;
if (deleted_node == tree) tree = child; // 待刪除節點是根節點
else if (parent->left == deleted_node) parent->left = child;
else parent->right = child;
}
另外,我們還可以只將待刪除節點標記為“已刪除”,而不是真正從樹中刪除掉這個節點,這樣操作就會簡單很多,但比較浪費記憶體。
1.4. 二叉查詢樹的其它操作
除了查詢、插入和刪除操作,二叉查詢樹還可以支援快速地查詢最大節點和最小節點、前驅節點和後繼節點。此外,如果我們中序遍歷二叉查詢樹,就可以輸出一個有序的資料序列,時間複雜度為 O(n),非常高效。
2. 支援重複資料的二叉查詢樹
我們前面講的二叉查詢樹,其節點儲存的都是數字。在實際開發中,二叉查詢樹中儲存的都是一個包含很多欄位的物件,我們利用物件的其中一個欄位作為鍵值(key)來構建二叉查詢樹,而其它欄位稱為衛星資料。
而且,上面的分析我們都是針對不存在鍵值相同的情況,如果鍵值相同的話,我們有以下兩種解決辦法。
第一種方法比較簡單,就是在每個節點不會僅儲存一個數據,還會通過連結串列和支援動態擴容的陣列等資料結構,把值相同的資料都儲存在同一個節點上。
第二種方法不好理解,但更加優雅。如果插入的時候遇到一個和當前節點值相同的資料,我們就把這個值相同的資料放到這個節點的右子樹中去,也就是當作大於這個節點的值來處理。
查詢的時候,遇到值相同的節點,我們並不停止查詢,而是繼續在右子樹中查詢,直到遇到葉子節點才停止,這樣就可以把所有鍵值等於要查詢值的節點都找出來。
對於刪除操作,我們也需要查詢到所有要刪除的節點,然後再按照前面講的刪除節點的方法,依次對節點進行刪除。
3. 二叉查詢樹的時間複雜度分析
實際上,二叉查詢樹的形態各式各樣。對於同一組資料,我們可以構造出下面這三種二叉查詢樹。
不同的二叉樹結構,其查詢、插入和刪除操作的執行效率都是不一樣的。針對第一個二叉樹,根節點的左右子樹嚴重不平衡,已經退化成了連結串列,所以查詢的時間複雜度就變成了 O(n)。
相反,如果是最理想的情況,二叉查詢樹就是一棵完全二叉樹(或滿二叉樹),這時候,其時間複雜度是多少呢?
由前面的程式碼和圖中都可以看出,二叉查詢樹的時間複雜度其實都和樹的高度成正比,而樹的高度也就是樹的層數減一。
針對一個包含 n 個節點的完全二叉樹,第一層包含 1 個節點,第二層包含 2 個節點,以此類推,第 k 層就包含 個節點。除了最後一層,因為完全二叉樹的最後一層可能包含 個節點,L 為最大層數。因此,二叉樹的節點個數 n 和二叉樹的最大層數 L 之間存在如下關係:
n >= 1+2+4+8+...+2^(L-2)+1
n <= 1+2+4+8+...+2^(L-2)+2^(L-1)
我們可以計算出 L 的範圍為 ,也就是說二叉樹的高度小於等於 。因此,極度不平衡的二叉查詢樹,它的查詢效能肯定不能滿足我們的要求。我們需要構建一種不管怎麼刪除、插入資料,它都能保持任意節點左右子樹都比較均衡的二叉查詢樹。
4. 散列表和二叉查詢樹的對比
散列表的插入、刪除和查詢操作的時間複雜度都可以做到常量級,但二叉查詢樹在最好情況下也才是 O(logn),那我們為什麼還要用二叉查詢樹呢?
-
散列表中的資料時無序儲存的,要輸出有序的資料則需要先進行排序,而二叉查詢樹則可以通過中序遍歷在 O(n) 時間複雜度內輸出有序的資料。
-
散列表擴容耗時很多,而且遇到雜湊衝突時,效能不穩定,但實際中我們最常用的二叉平衡查詢樹的效能非常穩定,時間複雜度穩定在 O(logn)。
-
由於雜湊衝突的存在,散列表實際的查詢速度可能並不一定比 O(logn) 快,再加上雜湊函式的計算耗時,其效率也就不一定比平衡二叉查詢樹要好。
-
散列表的構造比較複雜,要考慮雜湊函式的設計、雜湊衝突的解決、擴容、縮容等問題,而平衡二叉樹只需要考慮平衡性這一個問題,而且這個問題解決方案也比較成熟、固定。
-
為了避免過多的雜湊衝突,散列表裝載因子一般不能太大,特別是基於開放定址法解決衝突的散列表,這樣就會浪費一定的儲存空間。
因此,在實際的開發過程中,我們需要結合具體的需求來選擇使用哪一種資料結構。
獲取更多精彩,請關注「seniusen」!