在前兩篇文章中我們詳細介紹了使用智慧指標構建二叉樹並進行了層序遍歷。
現在我們已經掌握了足夠的前置知識,可以深入瞭解二叉搜尋樹的查詢和刪除了。
本文索引
二叉搜尋樹的查詢
查詢將分為兩部分,最值查詢和特定值查詢。
本章中使用的二叉搜尋樹的結構和上一篇文章中的相同。
下面我們先來看看最值查詢。
查詢最小值和最大值
這是最簡單的一種查詢。
根據二叉搜尋樹的性質,左子樹的值都比根節點小,右子樹的值都比根節點大,且這一性質對根節點下任意的左子樹或右子樹都適用。
根據以上的性質,對於一棵二叉搜尋樹來說,最小的值的節點一定在左子樹上,且是最左邊的一個節點;同理最大值一定是右子樹上最右邊的那個節點,如圖所示:
查詢的演算法也極為簡單,只要不停遞迴搜尋左子樹/右子樹,然後將左邊或右邊的葉子節點返回,這就是最小值/最大值:
NodeType BinaryTreeNode::max()
{
// 沒有右子樹時根節點就是最大的
if (!right) {
return shared_from_this();
}
auto child = right;
while (child) {
if (child->right) {
child = child->right;
} else {
return child;
}
}
return nullptr;
}
NodeType BinaryTreeNode::min()
{
// 沒有左子樹時根節點就是最小的
if (!left) {
return shared_from_this();
}
auto child = left;
while (child) {
if (child->left) {
child = child->left;
} else {
return child;
}
}
return nullptr;
}
這裡我們用迴圈替代了遞迴,使用遞迴的實現將會更簡潔,讀者可以自己留作聯絡。
查詢特定值
查詢特定值的情況較最值要複雜一些,因為需要判斷如下幾種情況,假設我們查詢的值是value
:
- value和當前節點的值相等,查詢完成返回當前節點
- value小於當前節點的值,繼續所搜左子樹,左子樹的值都比當前節點小
- value大於當前節點的值,繼續所搜右子樹,右子樹的值都比當前節點大
- 當前節點沒有左/右子樹而需要繼續搜尋子樹時,查詢失敗value在樹中不存在,返回
nullptr
。
這次我們決定採用遞迴實現,基於上述描述使用遞迴實現更簡單,如果有興趣的話也可以用迴圈實現,雖然兩者在效能上的表現並不會相差太多(因為遞迴查詢的次數只有log2(N)+1次,次數較少無法充分體現迴圈帶來的效能優勢):
NodeType BinaryTreeNode::search(int value)
{
if (value == value_) {
return shared_from_this();
}
// 繼續向下搜尋
if (value < value_ && left) {
return left->search(value);
} else if (value > value_ && right) {
return right->search(value);
}
// 未找到value
return nullptr;
}
刪除節點
查詢演算法雖然分了兩部分,但和刪除節點相比還是比較簡單的。
通常我們刪除一棵樹的某個節點時,將其子節點轉移給自己的parent即可,然而二叉搜尋樹需要自己的每一部分都遵守二叉樹搜尋樹的性質,因此對於大部分情況來說直接將子節點交給parent將會導致二叉搜尋樹被破壞,所以我們需要對如下幾個情況分類討論:
- 情況a:待刪除節點沒有任何子節點,此節點是葉子節點,這時可以直接刪除它
- 情況b:待刪除節點只有左/右子樹,這時直接刪除節點,將子節點交給parent即可,不會影響二叉搜尋樹的性質
- 情況c:待刪除節點同時擁有左右子樹,這時為了刪除節點後仍是一棵二叉搜尋樹,有兩個待選方案:
- 選擇待刪除節點的左子樹的最大值,和待刪除節點交換值,然後將這個左子樹的最大節點刪除,因為左子樹的值都需要比根節點小,因此刪除根節點時從左子樹中找到最大值交換到根節點的位置,即可保證滿足二叉搜尋樹的性質;接著對左子樹最大節點做相同的分類討論,最後經過交換後節點會滿足前兩種中的一種情況,這是刪除這個節點,整個刪除過程即可完成
- 原理同上一種,只不過我們選擇了右子樹中的最小值的節點
只有描述會比較抽象,因此每種情況我們來看圖:
情況a:
紅色虛線的部分即為待刪除節點,這是直接刪除即可。
情況b:
如圖所示,當只存在一邊的子樹時,直接刪除節點,將子節點交給parent即可。
情況c較為複雜,我們舉例選擇右子樹最小值的情況,另一種情況是相似的:
圖中黃色虛線部分就是“待刪除節點”,加引號是因為我們並不真正刪除它,而是先要把它的值和右子樹的最小值也就是紅色虛線部分交換:
交換後我們刪除右子樹的最小值節點,這是它滿足情況a,因此直接被刪除,刪除後的樹仍是一棵二叉搜尋樹:
這裡解釋下為什麼需要交換,首先交換是把情況c儘量往情況a或b轉化簡化了問題,同時保證了二叉搜尋樹的性質;其次如果不進行交換,則需要大量移動節點,效能較差且實現極為複雜,因此我們才會選擇交換節點值的做法。
我們的程式碼也會根據上述情況進行分類討論,這次我們使用遞迴實現來簡化程式碼,同樣讀者如果有興趣可以研究下迴圈版本:
// 公開的介面,方便使用者呼叫,具體實現在私有方法remove_node中
void BinaryTreeNode::remove(int value)
{
auto node = search(value);
if (!node) {
return;
}
node->remove_node();
}
// 刪除節點的具體實現
void BinaryTreeNode::remove_node()
{
// parent是weak_ptr,需要檢查是否可訪問
auto p{parent.lock()};
if (!p) {
return;
}
// 情況a,這時判斷節點在parent的左側還是右側
// 隨後對正確的parent子節點賦值nullptr,當前節點會在函式返回後自動被釋放
if (!left && !right) {
if (value_ > p->value_) {
p->right = nullptr;
} else {
p->left = nullptr;
}
return;
}
// 情況c,選擇和右子樹最小值交換
if (left && right) {
auto target = right->min();
target->remove_node();
// 這裡和圖解有一點小小的不同
// 刪除target前改變了value_,會導致target被刪除時無法正確確認自己是在parent的左側還是右側
// 所以只能在target刪除結束後再將值賦值給當前節點
value_ = target->value_;
return;
}
// 下面是情況b的兩種可能的形式
// 只存在左子樹
if (left) {
if (value_ > p->value_) {
p->right = left;
} else {
p->left = left;
}
left->parent = p;
return;
}
// 只存在右子樹
if (right) {
if (value_ > p->value_) {
p->right = right;
} else {
p->left = right;
}
right->parent = p;
return;
}
}
進行分類討論後程式碼實現起來也就沒有那麼複雜了。
測試
現在該測試上面的程式碼了:
int main()
{
auto root = std::make_shared<BinaryTreeNode>(3);
root->insert(1);
root->insert(0);
root->insert(2);
root->insert(5);
root->insert(4);
root->insert(6);
root->insert(7);
root->layer_print();
std::cout << "max: " << root->max()->value_ << std::endl;
std::cout << "min: " << root->min()->value_ << std::endl;
root->remove(1);
// 刪除後是否還是二叉搜尋樹使用中序遍歷即可得知
std::cout << "after remove 1\n";
root->ldr();
root->insert(1);
root->remove(5);
std::cout << "after remove 5\n";
root->ldr();
}
結果:
如圖,二叉搜尋樹的中序遍歷結果是一個有序的序列,兩次元素的刪除後中序遍歷的結果都為有序序列,演算法是正確的。