数据结构----二叉排序树(ai修改版)

9 阅读10分钟

二叉搜索树(Binary Search Tree)从零实现


说明:

  • ✏️ 原文 —— 你草稿中的内容,保留原意,仅做了语句通顺上的微调
  • 📝 补充/修正 —— 我添加的内容或指出的错误

所有代码均保持你原本的实现不变,错误处会单独标注并给出修正。


一、定义和性质

✏️ 原文:

二叉搜索树是在二叉树的基础上,增加了几个规则约束:

  1. 如果它的左子树不空,则左子树上所有结点的值均小于它的根结点的值。
  2. 如果它的右子树不空,则右子树上所有结点的值均大于它的根结点的值。
  3. 它的左、右子树也分别为二叉搜索树。

根据这个性质,我们可以用递归来对树进行各种操作。

📝 补充:

中序遍历有序性: 对二叉搜索树进行中序遍历(左 → 根 → 右),得到的结果是一个递增序列。这既是 BST 最重要的性质,也是验证一棵树是否为 BST 的常用手段。

为什么需要 BST?

数据结构查找插入删除
顺序表O(log n) 二分O(n)O(n)
链表O(n)O(1)O(1)
BSTO(log n) 平均O(log n) 平均O(log n) 平均

BST 在查找和插入/删除之间取得了平衡,是动态数据结构的经典方案。


二、数据结构定义

✏️ 原文:

typedef struct BSTNode {
    int data;
    struct BST* l;
    struct BST* r;
}BSTNode,*BSTree;

📝 ⚠️ 错误修正:

上面的定义中,struct BST* lstruct BST* r 指向的类型 BST 并不存在,结构体标签是 BSTNode。应该写成:

typedef struct BSTNode {
    int data;
    struct BSTNode* l;
    struct BSTNode* r;
} BSTNode, *BSTree;

或者用更简洁的前向声明写法:

typedef struct BSTNode {
    int data;
    struct BSTNode *l, *r;
} BSTNode, *BSTree;

三、创建新节点

✏️ 原文:

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,说明目标数据在右子树中,递归进入右子树查找。
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->datax 是否相等,防止访问空指针。如果找到则跳出循环返回 p,如果没找到最终 p == NULL,返回 NULL

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

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,根据 xpre->data 的大小关系接入左或右。

最后返回根节点 root

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 指向 rootpre 置空,类似于 Find 函数找到要删除的节点。跳出循环后判断 p 是否为空,即判断目标节点是否存在。

判断 p 的度:

  • 如果度为 2,找到中序遍历中 p前驱(左子树的最右节点),把前驱的数据复制到 p,问题转化为删除前驱原节点(度必为 0 或 1)。
  • 此时被删除节点的度变为 0 或 1,判断其左孩子是否为空:左孩子不为空则度为 1,用 ch 指向左孩子;左孩子为空则度为 0,ch 指向右孩子(为空,统一处理)。
  • 如果 pre != NULL 说明 p 不是根节点,根据 ppre 的左/右孩子来决定 pre 的哪个指针指向 ch
  • 如果 pre == NULL 说明 p 是根节点,直接让 ch 成为新根。
  • 最后 free(p) 并置空。
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 性质。

举例说明:

      8          删除 8(度 2)
    /   \
   3     10
  / \      \
 1   6      14
    / \    /
   4   7  13

错误写法:找到右子树最大值 14 → 8 变成 14 → 14 被删除 → 13 成为 10 的右孩子

      14(原来是8)
    /    \
   3      10
  / \       \
 1   6       13   ← 13 < 14,违反 BST!
    / \
   4   7

正确做法:找到左子树前驱 7 → 8 变成 7 → 删除原节点 7

       7
    /     \
   3      10
  / \       \
 1   6       14
    /       /
   4       13

修正后的代码片段:

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->dataroot 就是我们要删除的节点:
    • 如果度为 2,这次我们找中序遍历的后继(右子树的最左节点),用后继数据替换 root 的数据,然后去 root 的右子树中删除后继节点。
    • 如果度为 1 或 0,判断 root 有没有左孩子,有则用左孩子代替自己,否则用右孩子代替。
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) 通过返回值将修改后的子树接回来,和插入操作的递归版如出一辙。


七、中序遍历

✏️ 原文:

void InOrder(BSTree root) {
    if (root == NULL) return;
    InOrder(root->l);
    printf("%d ", root->data);
    InOrder(root->r);
}

📝 补充:

对 BST 做中序遍历,输出的是升序序列。可以通过这个性质来验证树是否正确。


八、完整测试

✏️ 原文:

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 就是红黑树)。


全文完