【数据结构】考研数据结构核心考点:二叉排序树(BST)全方位详解与代码实现
二叉排序树
- 导读:从树形查找到二叉排序树——高效动态搜索的基石
- 一、定义
- 二、基本操作
- 2.1 查找
- 2.1.1 查找原理
- 2.1.2 查找过程
- 2.1.3 算法实现
- 2.2 插入
- 2.2.1 插入原理
- 2.2.2 插入过程
- 2.2.3 算法实现
- 2.3 构造
- 2.3.1 构造原理
- 2.3.2 构造过程
- 2.3.3 算法实现
- 2.4 删除
- 2.4.1 删除原理
- 2.4.2 删除过程
- 2.4.3 算法实现
- 三、算法评价
- 结语
导读:从树形查找到二叉排序树——高效动态搜索的基石
大家好,很高兴又和大家见面啦!!!
在上一篇探讨树形查找算法的文章中,我们系统性地梳理了各种查找树的特点与应用场景(【数据结构】数据结构考研核心:树形查找算法对比与应用场景全指南)。
当数据规模增长到百万乃至千万级别时,传统的顺序查找(O(n))和基于数组的折半查找(插入删除效率低)显露出明显局限性。此时,树形查找凭借其天然的层次结构和动态操作优势,成为解决大规模数据管理问题的关键。
在所有树形结构中,二叉排序树(Binary Search Tree, BST) 是最基础且重要的内存型查找树。
它巧妙地将“二分思想”具象化:每个节点满足“左子节点值 < 根节点值 < 右子节点值”的有序性,使得查找效率在理想情况下达到O(log n)。更重要的是,BST天然支持动态数据操作,插入和删除不再需要移动大量元素,只需调整指针指向即可,这是线性结构无法比拟的优势。
本文将深入解析二叉排序树的核心原理与操作:
- 从查找算法的实现与优化,到插入新节点时的位置决策;
- 从不同序列构建出不同形态的BST,到删除节点时如何处理“后继替代”的精妙逻辑。
我们还将探讨BST的局限性——当插入序列有序时,树会退化成链表,查找效率降至O(n)——这恰恰引出了后续需要学习的自平衡二叉树(如AVL树、红黑树)。
通过本文的学习,您将不仅掌握二叉排序树的实现细节,更能理解它作为整个树形查找体系基础的重要地位,为后续学习更复杂的平衡树和磁盘型B+树打下坚实基础。
🔍 提示:本文是树形查找系列的核心基础篇,后续将陆续推出AVL树、红黑树、B树和B+树等高级结构的深度解析,欢迎持续关注!
一、定义
二叉排序树(也称二叉查找树(Binary Search Tree, BST))。可以是一棵空树,也可以是具有下列特性的二叉树:
- 若左子树非空,则左子树上的所有结点的值均小于根结点的值
- 若右子树非空,则右子树上的所有结点的值均大于根结点的值
- 左、右子树也分别是一棵二叉排序树
构造一棵二叉排序树的目的并不是排序,而是提高查找、插入和删除关键字的速度,二叉排序树这种非线性结构也有利于插入和删除的实现。
根据 BST 的定义,左子树结点值 < 根结点 < 右子树结点值,因此对 BST 进行中序遍历,可以得到一个递增的有序序列。
在上图这棵 BST 中,当我们对其进行遍历时,得到的遍历序列为:
- 先序遍历:4213657
- 中序遍历:1234567
- 后序遍历:1325764
可以看到,当我们对 BST 进行中序遍历时,得到的遍历序列是一个递增的有序序列。
二、基本操作
2.1 查找
2.1.1 查找原理
BST 的查找是从根节点开始,沿某个分支逐层向下比较的过程。
- 当二叉树非空时,需要将给定值与根节点的关键字进行比较:
- 给定值 == 根结点关键字,则查找成功
- 给定值 < 根结点关键字,则往左子树继续查找
- 给定值 > 根结点关键字,则往右子树继续查找
2.1.2 查找过程
这里我们以上述的 BST 为例,来说明整个查找过程:
现在我们需要查找:3和8这两个值
- 第一次比较:
- 查找值3:由于 3<43 < 43<4 ,下一次查找,我们需要查找其左子树
- 查找值8:由于 8>48 > 48>4 ,下一次查找,我们需要查找其右子树
- 第二次比较:
- 查找值3:由于 3>23 > 23>2 ,下一次查找,我们需要查找其右子树
- 查找值8:由于 8>68 > 68>6 ,下一次查找,我们需要查找其右子树
- 第三次比较:
- 查找值3:由于 3==33 == 33==3 ,我们查找成功,返回该结点地址
- 查找值8:由于 8>78 > 78>7 ,下一次查找,我们需要查找其右子树。由于该右子树为空,因此查找失败,返回空指针
2.1.3 算法实现
该算法的C语言非递归实现如下所示:
typedef int ElemType; // 关键字数据类型
typedef struct Binary_Search_Tree {ElemType key; // 根结点关键字struct Binary_Search_Tree* lchild, * rchild; // 左孩子指针与右孩子指针
}BSTNode, * BST; // BSTNode: 结点指针,BST: 二叉排序树指针BSTNode* BST_Search_None_Recursion(BST t, ElemType key) {BSTNode* p = t;while (p && p->key != key) {if (key > p->key) { // 给定值大于根结点关键字p = p->rchild; // 查找右子树}else if (key < p->key) { // 给定值小于根节点关键字p = p->lchild; // 查找左子树}}return p;
}
其递归算法实现也比较简单:
BSTNode* BST_Search_Recursion(BST t, ElemType key) {if (!t) { // 树为空树return NULL; // 返回空指针}if (key > t->key) { // 给定值大于关键字return BST_Search_Recursion(t->rchild, key); // 查找右子树}if (key < t->key) { // 给定值小于关键字return BST_Search_Recursion(t->lchild, key); // 查找左子树}return t; // 给定值==关键字,查找成功
}
但是由于其执行效率较低,因此不建议大家采用该实现方式。
2.2 插入
2.2.1 插入原理
BST 作为一种动态树表,其特点是树的结构通常不是一次生成的,而是在查找过程中,当树中不存在关键字值等于给定值的结点时再进行插入。
- 当原二叉排序树为空时,将给定值直接插入
- 当给定值小于根结点关键字时,继续查找左子树
- 当给定值大于根结点关键字时,继续查找右子树
- 当给定值等于根节点关键字时,不执行任何操作
总而言之,BST 的插入操作实际上就是查找操作,但是,插入操作与查找操作又有所差异:
- 查找操作:仅查找给定值是否存在于树中
- 插入操作:确定给定值是否存在于树中:
- 存在:不执行任何操作
- 不存在:将给定值插入该树
从该原理我们不难发现,当我们执行插入操作时,插入的一定是一个叶子结点,且该结点一定是查找操作中查找失败时该查找路径上的最后一个结点的左右子树中的其中一棵子树。
2.2.2 插入过程
这里我们以上述的 BST 为例,来说明整个插入过程:
现在我们需要插入:3和8这两个值
- 第一次查找:
- 查找值3:由于 3<43 < 43<4 ,下一次查找,我们需要查找其左子树
- 查找值8:由于 8>48 > 48>4 ,下一次查找,我们需要查找其右子树
- 第二次查找:
- 查找值3:由于 3>23 > 23>2 ,下一次查找,我们需要查找其右子树
- 查找值8:由于 8>68 > 68>6 ,下一次查找,我们需要查找其右子树
- 第三次查找:
- 查找值3:由于 3==33 == 33==3 ,我们查找成功,此时不执行任何操作
- 查找值8:由于 8>78 > 78>7 ,下一次查找,我们需要查找其右子树。
- 第四次查找:
- 查找值8:由于此时查找的子树为空树,因此我们需要将给定值插入在此处
2.2.3 算法实现
这里我们只介绍该算法的非递归实现,具体的递归实现就留给大家自行思考了。
该算法的C语言实现如下所示:
int BST_Insert(BST* t, ElemType key) {BSTNode** node = t;while (*node) {if (key == (*node)->key) {return 0;}if (key > (*node)->key) {node = &((*node)->rchild);}else {node = &((*node)->lchild);}}*node = (BSTNode*)calloc(1, sizeof(BSTNode));assert(*node);(*node)->key = key;return 1;
}
我们要注意由于插入操作是对树的修改,因此我们需要通过二级指针来实现。
2.3 构造
2.3.1 构造原理
构造一棵 BST 就是在一棵空树中,不断的通过插入操作,将元素放入正确的位置。
2.3.2 构造过程
下面我们以关键字序列:{45,24,53,45,12,24}\{45, 24, 53, 45, 12, 24\}{45,24,53,45,12,24} 为例,说明一下整个构造的过程:
- 首先我们从一棵空树出发,开始构造一棵 BST
- 我们先将关键字:454545 通过插入操作插入到树中。由于树是空树,因此我们执行插入操作,使关键字 454545 存储于该树的根结点中:
- 接下来我们再次插入关键字:242424 。通过关键字比较,24<4524 < 4524<45 ,因此,该关键字需要插入到根结点的左子树:
- 下面我们继续插入关键字:535353 。通过关键字比较,53>4553 > 4553>45 ,因此,该关键字需要插入到根结点的右子树:
- 下面我们继续插入关键字:454545 。通过关键字比较,45==4545 == 4545==45 ,说明此时树中已经存在该值,因此不执行任何操作:
- 下面我们继续插入关键字:121212 。通过关键字比较:12<4512 < 4512<45 ,我们需要将该关键字插入到左子树。
- 再通过关键字比较:12<2412 < 2412<24 ,我们需要将该关键字继续插入到左子树
- 此时,由于该子树为空树,因此直接插入:
- 下面我们继续插入关键字:242424 。通过关键字比较,24<4524 < 4524<45 ,因此,我们需要将该关键字插入到左子树中:
- 通过关键字比较:24==2424 == 2424==24 ,因此,我们不需要执行任何操作:
- 现在我们就得到了最终构建好的 BST:
2.3.3 算法实现
接下来我们通过C语言来实现这一操作:
void Creat_BST(BST* t, ElemType* key, int len) {for (int i = 0; i < len; i++) {BST_Insert(t, key[i]);}
}
可以看到构造这一算法的实现是建立在插入操作上的,这里我就不再展开赘述。
2.4 删除
2.4.1 删除原理
BST 的删除操作就是通过在 BST 中查找到给定值,然后再将其删除,此时会有两种情况:
- 查找成功,删除给定值对应的结点
- 查找失败,不执行任何操作
在 BST 中执行删除操作,并不是简单的删除某个结点那么简单,为了确保执行删除操作后的二叉树仍然是一棵 BST ,因此我们需要处理三种删除情况:
- 若被删除的结点
z
为叶结点,则直接删除 - 若被删除的结点
z
只有一棵子树,则将z
删除后,将其子树插入到z
原本的位置 - 若被删除的结点
z
既有左子树,又有右子树,那么我们需要在删除z
后,用其直接前驱(或直接后继)来替代z
的位置
2.4.2 删除过程
下面我们以下面这棵 BST 为例,来说明三种删除情况:
- 删除结点:090909 。由于该结点为叶子结点,因此直接将其删除:
- 删除结点:171717 。由于该结点此时只有右子树,因此,将其删除后,用其子树代替其位置:
- 删除结点:787878 。由于该结点有两棵子树,因此我们将其删除后,需要用其直接前驱,或直接后继代替其位置:
- 该二叉树的中序遍历序列为:23,45,53,65,78,81,88,9423, 45, 53, 65, 78, 81, 88, 9423,45,53,65,78,81,88,94
- 结点 787878 的直接前驱为:656565
- 结点 787878 的直接后继为:818181
- 因此我们即可以选择用:656565 代替 787878 的位置,也可以用:818181 代替 787878 的位置
在这棵二叉树中,使用直接前驱替代比较简单,这里我就不再展开,完成删除后的二叉树如下所示:
我们主要需要了解的是通过直接后继替代的过程。在这棵二叉树中,当我们选择使用直接后继来替代时,需要将其转化为删除该后继:
可以看到,对于结点:818181 ,其只有一棵右子树,因此我们需要将其删除后,直接使用其子树替代:
之后,我们将删除的结点:818181 替代真正需要删除的结点:787878 即可:
2.4.3 算法实现
对于一棵二叉树而言,我们要找结点:z
的中序遍历的直接前驱和直接后继,可以转换成以下内容:
- 直接前驱:其左子树的最右侧结点
- 直接后继:其右子树的最左侧结点
其C语言算法实现如下所示:
BSTNode* Get_Pre_Node(BST t) {BSTNode* pre = t->lchild;while (pre && pre->rchild) {pre = pre->rchild;}return pre;
}
BSTNode* Get_Suc_Node(BST t) {BSTNode* suc = t->rchild;while (suc && suc->lchild) {suc = suc->lchild;}return suc;
}
整个删除操作的实现,我们可以总结为三步:
- 检查结点
key
是否存在- 若不存在,则不需要任何行动
- 若存在,进入第二步
- 检查结点
key
的子树是否存在- 若不存在子树,则直接删除该结点
- 若存在子树,则进入第三步
- 获取结点
key
的父结点parent
,并用其子树中的结点进行替代- 若只存在一棵子树,则删除该结点后,将其父结点的指针指向其子树
- 若存在左、右子树,则选择使用其直接前驱/直接后继替代该结点,并将其转化为删除该结点的直接前驱/直接后继,此时需要重复整个删除过程,直至被删除的结点是叶结点或只有单一子树结点为止
在这个过程中我们需要注意,为了避免繁琐的指针指向的改变,通常我们在选择用其它结点替代该结点的过程是通过值的覆盖,而不是真正意义上的删除后改变指针指向;
当一个结点存在左右子树时,其直接前驱/直接后继结点一定是两种情况:
- 叶结点
- 只存在单棵子树的结点
为了简化整个过程,我们真正实现删除操作时,只会对叶子结点或只有单棵子树的结点执行删除操作。整体的算法实现如下所示:
BSTNode* Get_Pre_Node(BST t) {BSTNode* pre = t->lchild;while (pre && pre->rchild) {pre = pre->rchild;}return pre;
}
BSTNode* Get_Suc_Node(BST t) {BSTNode* suc = t->rchild;while (suc && suc->lchild) {suc = suc->lchild;}return suc;
}
BSTNode* Get_Parent(BST t, ElemType key) {BSTNode* p = NULL, * child = t;while (child && child->key != key) {p = child;if (key < child->key) {child = child->lchild;}else if (key > child->key) {child = child->rchild;}else {break;}}return p;
}
void Delete_Node(BSTNode** parent, BSTNode** target) {// 获取删除结点的子树BSTNode* child = NULL;if ((*target)->lchild && (*target)->rchild == NULL) {child = (*target)->lchild;}else if ((*target)->lchild == NULL && (*target)->rchild) {child = (*target)->rchild;}// 修改父结点指针指向if ((*target)->key < (*parent)->key) {(*parent)->lchild = child;}else if ((*target)->key > (*parent)->key) {(*parent)->rchild = child;}// 删除目标结点free(*target);*target = NULL;
}
void BST_Delete(BST* t, ElemType key) {// 处理空指针if (!t) {return;}// 查找是否存在存放了 key 的结点 targetBSTNode* target = BST_Search_None_Recursion(*t, key);// 当存在该结点if (target) {// 即存在左子树,也存在右子树if (target->lchild && target->rchild) {// 获取该结点的直接后继BSTNode* suc = Get_Suc_Node(target);// 用直接后继的值覆盖该结点的值target->key = suc->key;// 获取父结点BSTNode* parent = Get_Parent(target, suc->key);// 删除直接后继Delete_Node(&parent, &suc);}else {// 获取父结点BSTNode* parent = Get_Parent(*t, key);// 当该结点存在父结点if (parent) {// 删除该结点Delete_Node(&parent, &target);}else {// 通过新的根结点指针指向其子树BST new_root = (*t)->lchild ? (*t)->lchild : (*t)->rchild;free(*t);// 更新根结点*t = new_root;}}}
}
从上述实现中我们可以看到,对双子树结点的删除,这里我们通过直接后继来取代的方式实现,而叶结点或单子树结点则是通过改变其父结点的指针指向实现;
整个过程的实现,实际上都是在查找操作的基础上进行的更进一步的处理,那么下面我们就来通过对评价查找操作的算法效率,来说明整个 BST 中的所有算法的算法效率;
三、算法评价
在二叉排序树的查找中,其查找效率主要取决于树的高度。
- 若二叉排序树的左、右子树的高度之差的绝对值不超过1,它的平均查找长度和 O(log2n)O(\log_{2}{n})O(log2n) 成正比;
- 在最坏的情况下,对于 nnn 个结点的二叉排序树,其树高为 nnn ,那么,其平均查找长度为 n+12\frac{n + 1}{2}2n+1
从查找的过程来看,二叉排序树与二分查找相似。
- 就平均时间性能而言,二叉排序树上的查找和二分查找差不多
- 但是二分查找的判定树唯一,而二叉排序树的查找不唯一
- 二分查找的判定树,是由有序序列构成的一棵二叉排序树
- 而相同的关键字,其插入的不同顺序,可以生成不同的二叉排序树
就维护表的有序性而言:
- 二叉排序树无须移动结点,只需修改指针即可完成插入和删除操作,平均执行时间为 O(log2n)O(\log_{2}{n})O(log2n) 。
- 二分查找的对象是有序顺序表,若由插入和删除操作,则需移动表中的元素,所花的时间代价为 O(n)O(n)O(n)
因此,当给定的有序表是静态查找表,只需执行查找操作时,宜采用顺序表作为其存储结构,并且可以采用二分查找实现其查找操作;
当给定的有序表是动态查找表,且需要频繁的进行插入、删除操作时,宜采用二叉排序树作为其存储结构,并通过实际需求来实现对应的操作;
结语
今天的内容到这里就全部结束了,通过本文的详细讲解,我们共同深入探索了二叉排序树(BST) 这一重要数据结构的核心原理与实现。让我们回顾一下本文的重点内容:
核心知识点回顾:
-
✅ 二叉排序树的定义:掌握了BST"左子树<根节点<右子树"的核心特性,理解了中序遍历会产生有序序列的重要性质
-
✅ 查找操作原理:学会了BST的高效查找算法,通过逐层比较实现O(h)时间复杂度的搜索
-
✅ 插入操作机制:理解了插入总是发生在叶子节点的特点,以及如何通过查找过程确定插入位置
-
✅ 删除操作策略:掌握了三种删除情况(叶子节点、单子树节点、双子树节点)的处理方法,特别是双子树节点使用前驱/后继替代的技巧
-
✅ 性能分析:认识了BST在理想情况下的高效性(O(log n))和最坏情况下的局限性(O(n)),为学习平衡树打下基础
实践价值:
二叉排序树不仅是数据结构课程的核心内容,更是许多高级树结构(AVL树、红黑树、B树等)的基础。掌握BST的原理和实现,对于提升算法设计能力、应对技术面试、以及在实际开发中处理动态数据集合都具有重要意义。
如果本文对您有帮助,欢迎:
-
👍 点赞支持 - 您的认可是我持续创作的最大动力!
-
⭐ 收藏文章 - 方便日后随时查阅和复习BST相关知识
-
🔄 转发分享 - 推荐给更多正在学习数据结构的朋友们
-
💬 评论交流 - 有任何疑问或想了解的主题,欢迎在评论区提出
下期预告:
在接下来的内容中,我们将继续深入树形查找领域,探讨如何解决BST可能退化成链表的问题,详细介绍AVL树和红黑树这两种重要的自平衡二叉搜索树。敬请关注!
感谢您的阅读,我们下期再见!🚀