目录
[如何寻找"合法"的位置?------ 模拟查找过程](#如何寻找“合法”的位置?—— 模拟查找过程)
[递归插入(Recursive Insert)------ 优雅的实现](#递归插入(Recursive Insert)—— 优雅的实现)
上一节我们从第一性原理搞清楚了二叉搜索树(BST)是什么,以及为什么要有它。现在,我们来解决下一个核心问题:如何向一个已有的 BST 中插入一个新元素?
插入的本质是什么?
我们有一个新数字,比如 10
,要把它插入到上次创建的这棵树里:
数据结构:二叉搜索树(Binary Search Tree)-CSDN博客
cpp
15
/ \
8 20
/ \
3 12
在动手之前,我们必须牢记 BST 的唯一天条(The Golden Rule):
对于任何节点,其左子树的所有值都比它小,右子树的所有值都比它大。
所以,插入一个新节点,本质上就两个步骤:
-
找到一个"合法"的位置:这个位置必须能容纳新节点,并且新节点放进去之后,整棵树依然要满足 BST 的"天条"。
-
执行插入动作:把新节点安家落户在这个位置上。
如何寻找"合法"的位置?------ 模拟查找过程
这个寻找位置的过程,其实和我们查找一个数的过程一模一样。我们就像这个新来的数字 10
,要在这棵树里找个家。
cpp
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
)。 -
当插入完成后,树的结构可能发生变化(比如,从一棵空树变成有一个节点的树),所以这个函数必须返回新树的根节点指针。
cpp
#include <cstddef> // 为了 NULL
struct 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
,我们就需要决定是往左还是往右。
cpp
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
。
cpp
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
)的地址没有变,所以我们要把它返回给它的上层调用,以保持树的连接。
cpp
/*
* 函数功能:向以 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
函数的返回值,以防根节点发生改变(比如第一次插入时)。
cpp
#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);
// 现在我们插入新值 10
std::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;
// 尝试插入一个重复的值 12
root = 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, ...)
是实现连接的核心。它用递归调用的返回值(可能是新节点,也可能是原来的子树根)来更新自己的孩子指针。 -
返回值:函数必须返回根节点指针,以确保调用者能正确地更新树的结构。
-