目錄
插入的本質是什么?
如何尋找“合法”的位置?—— 模擬查找過程
遞歸插入(Recursive Insert)—— 優雅的實現
代碼逐步完善
總結
上一節我們從第一性原理搞清楚了二叉搜索樹(BST)是什么,以及為什么要有它。現在,我們來解決下一個核心問題:如何向一個已有的 BST 中插入一個新元素?
插入的本質是什么?
我們有一個新數字,比如 10
,要把它插入到上次創建的這棵樹里:
數據結構:二叉搜索樹(Binary Search Tree)-CSDN博客
15/ \8 20/ \
3 12
在動手之前,我們必須牢記 BST 的唯一天條(The Golden Rule):
對于任何節點,其左子樹的所有值都比它小,右子樹的所有值都比它大。
所以,插入一個新節點,本質上就兩個步驟:
-
找到一個“合法”的位置:這個位置必須能容納新節點,并且新節點放進去之后,整棵樹依然要滿足 BST 的“天條”。
-
執行插入動作:把新節點安家落戶在這個位置上。
如何尋找“合法”的位置?—— 模擬查找過程
這個尋找位置的過程,其實和我們查找一個數的過程一模一樣。我們就像這個新來的數字 10
,要在這棵樹里找個家。
15/ \8 20/ \
3 12
從根節點 15
開始問路:
-
10
小于15
(10 < 15
)。 -
根據天條,
10
的家一定在15
的左子樹里。我們往左走。
來到節點 8
:
-
10
大于8
(10 > 8
)。 -
根據天條,
10
的家一定在8
的右子樹里。我們往右走。
來到節點 12
:
-
10
小于12
(10 < 12
)。 -
根據天條,
10
的家一定在12
的左子樹里。我們往左走。
12
的左邊是什么?
-
是
NULL
!一個空位! -
這是一個“死胡同”,我們沒法再往下走了。
這個“死胡同”(NULL
指針)就是我們夢寐以求的“合法”位置。為什么?
-
把它放在這里,它會成為
12
的左孩子。因為10 < 12
,這滿足了對于節點12
的天條。 -
同時,
10 > 8
,10 < 15
,它也滿足了對于它所有祖先節點的天條。 -
最重要的是,把它放在一個
NULL
的位置,作為一個新的葉子節點,我們不需要改動樹中任何其他節點的現有連接關系,這是成本最低的操作!
?新節點插入的位置,必然是樹中某個節點的
NULL
的left
或right
指針所在的位置。尋找這個位置的過程,就是一個模擬查找的過程。
遞歸插入(Recursive Insert)—— 優雅的實現
現在我們用代碼來實現這個邏輯。遞歸是處理樹結構最自然、最優雅的方式。
遞歸的思想: 我們把一個大問題,分解成一個性質相同、但規模更小的子問題。 對于插入操作:
-
大問題:在以
root
為根的整棵樹中插入value
。 -
子問題:
-
如果
value < root->data
,問題就縮小為:在以root->left
為根的左子樹中插入value
。 -
如果
value > root->data
,問題就縮小為:在以root->right
為根的右子樹中插入value
。
-
這個分解過程什么時候結束呢?—— 當我們遇到一個 NULL
的節點時,也就是找到了那個“死胡同”,這就是遞歸的基本情況(Base Case)。
代碼逐步完善
函數簽名和基本情況 (Base Case)
首先,我們需要一個函數。它應該做什么?
-
它需要知道從哪個節點開始(
Node* root
)。 -
它需要知道要插入的值是多少(
int value
)。 -
當插入完成后,樹的結構可能發生變化(比如,從一棵空樹變成有一個節點的樹),所以這個函數必須返回新樹的根節點指針。
#include <cstddef> // 為了 NULLstruct Node {int data;Node* left;Node* right;Node(int value) {data = value;left = right = NULL;}
};/** 函數功能:向以 root 為根的樹中插入 value* 返回值: 返回插入新節點后,樹(或子樹)的根節點*/
Node* insert(Node* root, int value) {// 基本情況 (Base Case):// 如果當前的 root 是 NULL (無論是整棵樹為空,還是我們已經走到了一個“死胡同”)// 這就是我們要插入新節點的地方。if (root == NULL) {// 創建一個新節點,它自己就是一棵子樹return new Node(value);}// ... 遞歸的步驟還沒寫 ...
}
思考一下 return new Node(value);
這一行。
它返回一個指向新創建節點(值為value
)的指針。誰會接收這個指針呢?是上一層的函數調用。我們馬上就能看到。
加入遞歸步驟
現在,如果 root
不是 NULL
,我們就需要決定是往左還是往右。
Node* insert(Node* root, int value) {if (root == NULL) {return new Node(value);}// 遞歸步驟 (Recursive Step):if (value < root->data) {// 新值應該插入到左子樹中// 我們遞歸調用 insert,讓它去處理左子樹的問題// **關鍵一步**: 遞歸調用返回的結果(可能是新的左子樹的根)// 必須被連接到當前節點的左指針上!root->left = insert(root->left, value);} else if (value > root->data) {// 新值應該插入到右子樹中// 同理,連接到當前節點的右指針上root->right = insert(root->right, value);}// ... 還有一種情況:value == root->data ...// ... 以及,這個函數在遞歸調用后應該返回什么?...
}
我們來剖析 root->left = insert(root->left, value);
這句代碼,它是遞歸插入的精髓。
假設我們要在節點 12
的左邊插入 10
。
15/ \8 20/ \
3 12
-
當前
root
是12
。value
是10
。 -
10 < 12
,所以執行root->left = insert(root->left, value);
-
此時
root->left
是NULL
。所以insert(NULL, 10)
被調用。 -
根據我們的基本情況,
insert(NULL, 10)
會new Node(10)
并返回指向這個新節點的指針。 -
這個返回的指針,被賦值給了
root->left
(也就是12
的left
指針)。 -
效果達成:新節點
10
被成功地連接為了12
的左孩子。
處理重復值并完成函數
標準的 BST 通常不允許重復值。如果待插入的值和當前節點的值相等,我們什么都不做,直接返回。
最后,如果當前節點不是 NULL
,并且完成了對子樹的插入操作后,當前節點本身(root
)的地址沒有變,所以我們要把它返回給它的上層調用,以保持樹的連接。
/** 函數功能:向以 root 為根的樹中插入 value* 返回值: 返回插入新節點后,樹(或子樹)的根節點*/
Node* insert(Node* root, int value) {// 基本情況:如果當前節點是 NULL,說明找到了插入位置。if (root == NULL) {// 創建新節點并返回,上一層的調用會把它連接到樹中。return new Node(value);}// 遞歸步驟:if (value < root->data) {// 向左子樹遞歸插入root->left = insert(root->left, value);} else if (value > root->data) {// 向右子樹遞歸插入root->right = insert(root->right, value);}// else (value == root->data) 的情況:// 我們選擇什么都不做,因為不允許重復值。// 將當前的(可能未改變的)根節點指針返回給上一層。// 這一步確保了樹中未受影響部分的連接保持不變。return root;
}
如何使用這個函數?
主調函數需要一個 Node*
變量來保存整棵樹的根。每次插入,都需要用這個變量來接收 insert
函數的返回值,以防根節點發生改變(比如第一次插入時)。
#include <iostream>// (這里是 Node 結構體和 insert 函數的定義)// 為了方便觀察,我們寫一個中序遍歷函數來打印樹
void inorderTraversal(Node* root) {if (root == NULL) {return;}inorderTraversal(root->left);std::cout << root->data << " ";inorderTraversal(root->right);
}int main() {Node* root = NULL; // 一開始,樹是空的// 第一次插入,root 會從 NULL 變成新節點的地址root = insert(root, 15);root = insert(root, 8);root = insert(root, 20);root = insert(root, 3);root = insert(root, 12);// 現在我們插入新值 10std::cout << "插入 10 之前的中序遍歷: ";inorderTraversal(root); // 輸出: 3 8 12 15 20 std::cout << std::endl;root = insert(root, 10);std::cout << "插入 10 之后的中序遍歷: ";inorderTraversal(root); // 輸出: 3 8 10 12 15 20 std::cout << std::endl;// 嘗試插入一個重復的值 12root = insert(root, 12);std::cout << "插入重復值 12 之后的中序遍歷: ";inorderTraversal(root); // 輸出: 3 8 10 12 15 20 (沒有變化)std::cout << std::endl;return 0;
}
注意:inorderTraversal
只是為了驗證我們插入的正確性,它本身也是遞歸的一個經典應用。
總結
-
第一性原理:插入操作必須維護 BST 的“左小右大”核心性質。
-
推導:最簡單、成本最低的插入位置是某個葉子節點的
NULL
孩子位置。這個位置可以通過模擬“查找”過程來找到。 -
遞歸實現:
-
Base Case (基本情況):當前節點為
NULL
。這是遞歸的終點,也是新節點被創建和“返回”的地方。 -
Recursive Step (遞歸步驟):比較新值和當前節點的值,決定向左還是向右“委托”任務(遞歸調用)。
-
關鍵連接:
root->left = insert(root->left, ...)
是實現連接的核心。它用遞歸調用的返回值(可能是新節點,也可能是原來的子樹根)來更新自己的孩子指針。 -
返回值:函數必須返回根節點指針,以確保調用者能正確地更新樹的結構。
-