二叉搜索树(Binary Search Tree)从零实现
说明:
- ✏️ 原文 ------ 你草稿中的内容,保留原意,仅做了语句通顺上的微调
- 📝 补充/修正 ------ 我添加的内容或指出的错误
所有代码均保持你原本的实现不变,错误处会单独标注并给出修正。
一、定义和性质
✏️ 原文:
二叉搜索树是在二叉树的基础上,增加了几个规则约束:
- 如果它的左子树不空,则左子树上所有结点的值均小于它的根结点的值。
- 如果它的右子树不空,则右子树上所有结点的值均大于它的根结点的值。
- 它的左、右子树也分别为二叉搜索树。
根据这个性质,我们可以用递归来对树进行各种操作。
📝 补充:
中序遍历有序性: 对二叉搜索树进行中序遍历(左 → 根 → 右),得到的结果是一个递增序列。这既是 BST 最重要的性质,也是验证一棵树是否为 BST 的常用手段。
为什么需要 BST?
| 数据结构 | 查找 | 插入 | 删除 |
|---|---|---|---|
| 顺序表 | O(log n) 二分 | O(n) | O(n) |
| 链表 | O(n) | O(1) | O(1) |
| BST | O(log n) 平均 | O(log n) 平均 | O(log n) 平均 |
BST 在查找和插入/删除之间取得了平衡,是动态数据结构的经典方案。
二、数据结构定义
✏️ 原文:
c
typedef struct BSTNode {
int data;
struct BST* l;
struct BST* r;
}BSTNode,*BSTree;
📝 ⚠️ 错误修正:
上面的定义中,struct BST* l 和 struct BST* r 指向的类型 BST 并不存在 ,结构体标签是 BSTNode。应该写成:
c
typedef struct BSTNode {
int data;
struct BSTNode* l;
struct BSTNode* r;
} BSTNode, *BSTree;
或者用更简洁的前向声明写法:
c
typedef struct BSTNode {
int data;
struct BSTNode *l, *r;
} BSTNode, *BSTree;
三、创建新节点
✏️ 原文:
c
BSTNode* CreateNode(int x) {
BSTNode* s = (BSTNode*)malloc(sizeof(BSTNode));
if (s == NULL) {
printf("内存申请失败\n");
return s;
}
s->data = x;
s->l = s->r = NULL;
return s;
}
📝 补充:
注意 malloc 可能返回 NULL,代码中做了防御性检查,这是一个好习惯。
四、查找操作
1. 递归实现 ------ Find1
✏️ 原文:
确定递归出口 root == NULL,如果遍历完整棵树都没有找到目标节点,则返回 NULL。
如果根节点的数据等于目标数据,直接返回 root。
- 如果
x < root->data,说明目标数据在左子树中,递归进入左子树查找。 - 如果
x > root->data,说明目标数据在右子树中,递归进入右子树查找。
c
BSTNode* Find1(BSTree root, int x) {
if (root == NULL) {
return NULL;
}
if (x == root->data) {
return root;
}
else if (x < root->data) {
return Find1(root->l, x);
}
else {
return Find1(root->r, x);
}
}
2. 非递归实现 ------ Find2
✏️ 原文:
用指针 p 指向 root,通过 while 循环和 BST 特性遍历整棵树找到目标节点。在循环条件中先判断 p 是否为 NULL,再判断 p->data 和 x 是否相等,防止访问空指针。如果找到则跳出循环返回 p,如果没找到最终 p == NULL,返回 NULL。
c
BSTNode* Find2(BSTree root, int x) {
BSTNode* p = root;
while (p != NULL && p->data != x) {
if (x > p->data) {
p = p->r;
}
else {
p = p->l;
}
}
return p;
}
📝 补充:递归 vs 非递归对比
| 维度 | 递归 (Find1) | 非递归 (Find2) |
|---|---|---|
| 代码简洁性 | 代码少,逻辑清晰 | 略长 |
| 性能 | 有函数调用开销 | 纯循环,略快 |
| 栈溢出风险 | 树高较大时有风险 | 无 |
时间复杂度:平均 O(log n),最坏 O(n)(退化成链表时)。
五、插入操作
1. 递归实现 ------ Insert1
✏️ 原文:
首先确定递归的出口:当根节点为空时找到了空位置,创建节点 s 并返回 s。
当根节点不为空时,判断根节点数据和插入数据 x 的大小:
- 如果
x < root->data,说明x应该插入在root的左子树中,递归调用Insert1本身。因为插入操作会改变子树的结构,所以将返回值赋给root->l,从而把新创建的节点接入树中。 - 如果
x > root->data,说明x应该插入在右子树中。
最后返回根节点 root。
c
BSTree Insert1(BSTree root, int x) {
if (root == NULL) {
BSTNode* s = CreateNode(x);
return s;
}
if (x < root->data) {
root->l = Insert1(root->l, x);
}
else {
root->r = Insert1(root->r, x);
}
return root;
}
📝 补充:
递归版最巧妙的地方在于 root->l = Insert1(root->l, x) ------ 通过返回值将新节点(或修改后的子树)接回原树,不需要显式记录父节点。
2. 非递归实现 ------ Insert2
✏️ 原文:
如果这是一棵空树,则创建根节点并返回。
用 p 指针遍历树,pre 指针记录 p 的父节点。通过 while 循环找到插入位置:每次先更新 pre = p,然后根据 BST 特性决定往左还是往右。跳出循环后创建新节点 s,根据 x 与 pre->data 的大小关系接入左或右。
最后返回根节点 root。
c
BSTree Insert2(BSTree root, int x) {
if (root == NULL) {
BSTNode* s = CreateNode(x);
return s;
}
BSTNode* p = root;
BSTNode* pre = NULL;
while (p != NULL) {
pre = p;
if (x < p->data) {
p = p->l;
}
else {
p = p->r;
}
}
BSTNode* s = CreateNode(x);
if (x > pre->data) {
pre->r = s;
}
else {
pre->l = s;
}
return root;
}
📝 补充:关于重复值
上述插入代码中,当 x == root->data 时走 else 分支(插入右子树)。这意味着重复元素会被插入到右子树。这是一种常见做法,但在实际应用中通常不允许重复,或者需要额外处理(如计数或禁止插入)。
六、删除操作(重点 + 难点)
📝 补充:删除操作的核心思想
删除一个节点,根据它的度(孩子数量)分为三种情况:
| 情况 | 处理方式 |
|---|---|
| 度 0(叶子节点) | 直接删除,父节点对应指针置空 |
| 度 1(只有一个孩子) | 让孩子顶替它的位置 |
| 度 2(有两个孩子) | 找前驱(左子树最右节点)或后继(右子树最左节点)替换值,转化为删除度 0 或 1 的节点 |
为什么要对度 2 做转化?因为直接删除度 2 的节点会"留下两个空洞",无法简单处理。而用一个值来替换后,我们只需要去删除那个被拿走值的节点,它必然在树的最底层(度 0 或 1),就容易处理了。
1. 非递归实现 ------ Delete1
✏️ 原文:
先判断空树。
用指针 p 指向 root,pre 置空,类似于 Find 函数找到要删除的节点。跳出循环后判断 p 是否为空,即判断目标节点是否存在。
判断 p 的度:
- 如果度为 2,找到中序遍历中
p的前驱 (左子树的最右节点),把前驱的数据复制到p,问题转化为删除前驱原节点(度必为 0 或 1)。 - 此时被删除节点的度变为 0 或 1,判断其左孩子是否为空:左孩子不为空则度为 1,用
ch指向左孩子;左孩子为空则度为 0,ch指向右孩子(为空,统一处理)。 - 如果
pre != NULL说明p不是根节点,根据p是pre的左/右孩子来决定pre的哪个指针指向ch。 - 如果
pre == NULL说明p是根节点,直接让ch成为新根。 - 最后
free(p)并置空。
c
BSTree Delete1(BSTree root, int x) {
if (root == NULL) {
printf("空树\n");
return root;
}
BSTNode* p = root;
BSTNode* pre = NULL;
BSTNode* ch = NULL;
while (p != NULL && p->data != x) {
pre = p;
if (x < p->data) {
p = p->l;
}
else {
p = p->r;
}
}
if (p == NULL) {
printf("p不存在\n");
return root;
}
if (p->l != NULL && p->r != NULL) {
BSTNode* t = p;
BSTNode* tf = NULL;
while (t->r != NULL) {
tf = t;
t = t->r;
}
p->data = t->data;
p = t;
pre = tf;
}
if (p->l != NULL) {
ch = p->l;
}
else {
ch = p->r;
}
if (pre != NULL) {
if (pre->l == p) {
pre->l = ch;
}
else {
pre->r = ch;
}
}
else {
root = ch;
}
free(p);
p = NULL;
return root;
}
📝 ⚠️ 错误修正(重要):
代码第 28 行 BSTNode* t = p; 是错误的 ,应该改为 BSTNode* t = p->l;。
原因分析:
当 p 的度为 2 时,我们需要找的是 p 的前驱------即左子树中的最右节点 。所以 t 应该从 p->l(左孩子)开始,然后一路向右。
而 BSTNode* t = p; 是从 p 本身开始向右走,会进入右子树找到右子树的最大值(最右节点)。用这个值替换 p 后,会出现右子树中仍有比新根小的节点的情况,破坏 BST 性质。
举例说明:
markdown
8 删除 8(度 2)
/ \
3 10
/ \ \
1 6 14
/ \ /
4 7 13
错误写法:找到右子树最大值 14 → 8 变成 14 → 14 被删除 → 13 成为 10 的右孩子
markdown
14(原来是8)
/ \
3 10
/ \ \
1 6 13 ← 13 < 14,违反 BST!
/ \
4 7
正确做法:找到左子树前驱 7 → 8 变成 7 → 删除原节点 7
markdown
7
/ \
3 10
/ \ \
1 6 14
/ /
4 13
修正后的代码片段:
c
if (p->l != NULL && p->r != NULL) {
// 找前驱:左子树的最右节点(最大值)
BSTNode* t = p->l; // ✏️ 从 p->l 开始,不是 p
BSTNode* tf = NULL;
while (t->r != NULL) {
tf = t;
t = t->r;
}
p->data = t->data; // 前驱数据复制过来
p = t; // 转为删除前驱节点
pre = tf;
}
2. 递归实现 ------ Delete2
✏️ 原文:
递归出口和之前相同。
- 如果
x < root->data,在左子树中递归删除。 - 如果
x > root->data,在右子树中递归删除。 - 如果
x == root->data,root就是我们要删除的节点:- 如果度为 2,这次我们找中序遍历的后继 (右子树的最左节点),用后继数据替换
root的数据,然后去root的右子树中删除后继节点。 - 如果度为 1 或 0,判断
root有没有左孩子,有则用左孩子代替自己,否则用右孩子代替。
- 如果度为 2,这次我们找中序遍历的后继 (右子树的最左节点),用后继数据替换
c
BSTree Delete2(BSTree root, int x) {
if (root == NULL) {
printf("空树\n");
return root;
}
if (x < root->data) {
root->l = Delete2(root->l, x);
}
else if (x > root->data) {
root->r = Delete2(root->r, x);
}
else {
if (root->l != NULL && root->r != NULL) {
BSTNode* t = root->r;
while (t->l != NULL) {
t = t->l;
}
root->data = t->data;
root->r = Delete2(root->r, t->data);
}
else {
BSTNode* p = root;
if (root->l != NULL) root = root->l;
else root = root->r;
free(p);
p = NULL;
}
}
return root;
}
📝 补充:
Delete2 的实现是正确的。这里找的是后继(右子树的最左节点)。由于不涉及显式的父节点维护,递归版代码比非递归版简洁许多。
递归版的核心技巧 :root->l = Delete2(root->l, x) 通过返回值将修改后的子树接回来,和插入操作的递归版如出一辙。
七、中序遍历
✏️ 原文:
c
void InOrder(BSTree root) {
if (root == NULL) return;
InOrder(root->l);
printf("%d ", root->data);
InOrder(root->r);
}
📝 补充:
对 BST 做中序遍历,输出的是升序序列。可以通过这个性质来验证树是否正确。
八、完整测试
✏️ 原文:
c
int main() {
BSTree root = NULL;
int n, x;
scanf("%d", &n);
for (int i = 1; i <= n; i++) {
scanf("%d", &x);
root = Insert2(root, x);
}
InOrder(root);
printf("\n");
scanf("%d", &x);
root = Delete2(root, x);
InOrder(root);
printf("\n");
return 0;
}
/*
输入:
9
8 3 10 1 6 14 4 7 13
输出:
1 3 4 6 7 8 10 13 14
*/
📝 补充:
建树后的中序遍历结果应该为升序序列 1 3 4 6 7 8 10 13 14。 删除节点后再次中序遍历,结果仍然保持升序。
九、总结与延伸
📝 补充:
递归 vs 非递归
| 操作 | 递归 | 非递归 |
|---|---|---|
| 查找 | 代码简洁,理解直观 | 无函数调用开销 |
| 插入 | 通过返回值接回树,最优雅 | 需要手动维护父节点 |
| 删除 | 同样通过返回值,逻辑清晰 | 父节点 + 前驱/后继指针维护复杂 |
递归版的代码更简洁、更接近数学定义,但树高较大时有栈溢出风险。非递归版性能略优,但代码维护难度更高。
BST 的局限性
BST 在有序插入 时(如 1 2 3 4 5)会退化成一条链表,查找复杂度退化为 O(n)。
解决方案:平衡二叉树
| 类型 | 特点 |
|---|---|
| AVL 树 | 严格平衡,左右子树高度差 ≤ 1 |
| 红黑树 | 近似平衡,插入/删除效率更高 |
这两种树在 BST 的基础上增加了自平衡机制,是工业界的标准方案(如 C++ STL 的 std::map 就是红黑树)。
全文完