前言
在计算机科学中,数据结构和算法是程序的基石。而二叉搜索树(Binary Search Tree,简称BST)作为一种经典的非线性数据结构,以其优秀的查找性能和简洁的规则,成为了许多高级数据结构(如AVL树、红黑树、B树)的基础。无论是数据库索引、编译器符号表,还是内存中的集合与映射,都能看到二叉搜索树的身影。
本文将带你从零开始,全面深入地理解二叉搜索树。我们将从基本概念出发,分析其性能,详细讲解插入、查找、删除三大核心操作,并提供完整的C++模板实现。更进一步,我们会介绍两种应用模型:Key模型和Key/Value模型,并给出实际场景的代码示例。最后,我们将探讨二叉搜索树的局限性,并引出平衡二叉搜索树的概念。无论你是准备面试、学习数据结构,还是想深入理解STL中set/map的底层原理,这篇文章都将为你提供扎实的知识体系。
全文约4500字,建议配合代码实践,边看边练。
一、二叉搜索树的概念
1.1 定义与性质
二叉搜索树(BST)又称二叉排序树(Binary Sort Tree),它要么是一棵空树,要么满足以下三个性质:
-
左子树性质 :若左子树不为空,则左子树上所有 结点的值都小于等于根结点的值。
-
右子树性质 :若右子树不为空,则右子树上所有 结点的值都大于等于根结点的值。
-
递归性质:左右子树本身也各是一棵二叉搜索树。
注意:关于相等值的处理,不同的设计有不同的策略。有些BST实现不允许重复值(例如C++ STL中的
set和map),有些允许重复值(例如multiset和multimap)。本文的实现采用不允许重复值的版本,插入时若已存在则插入失败。
这种特性意味着,当我们对BST进行中序遍历 (左→根→右)时,得到的是一个递增的有序序列 。因此,BST本质上是一种动态维护有序序列的数据结构。
1.2 为什么叫"搜索树"?
因为它的结构天然支持快速查找。想象一下,你从根结点出发,每次比较目标值key与当前结点的大小关系:
-
若
key等于当前结点值,查找成功; -
若
key小于当前结点值,则进入左子树(左子树所有值都更小); -
若
key大于当前结点值,则进入右子树。
每一步都将搜索范围缩小了一半(理想情况下),就像二分查找一样高效。但二分查找只能用于数组,而BST以链式结构实现,插入和删除操作不需要移动大量元素,兼顾了查找和动态修改的效率。
二、二叉搜索树的性能分析
2.1 时间复杂度与树高的关系
BST所有核心操作(插入、查找、删除)的时间复杂度都取决于树的高度h 。每次比较都能向下一层,因此时间复杂度为O(h)。那么h与结点数n的关系如何?这完全取决于树的形态。
最优情况 ------ 完全二叉树
当BST是一棵完全二叉树(或接近完全二叉树)时,树的高度为 h = ⌈log₂(n+1)⌉ ,此时操作的时间复杂度为 O(log n)。这是BST的理想状态。
最差情况 ------ 单支树
如果每次插入的数据都是递增或递减的(例如依次插入1,2,3,4,5),BST会退化成一条链表 ,每个结点只有一个孩子,此时高度h = n,时间复杂度退化为 O(n)。这种极端情况下,BST的性能甚至不如普通的链表查找(但链表查找也是O(n))。
2.2 平均情况 ------ 随机插入
如果插入顺序是随机的,那么BST的期望高度为 O(log n) 。但实际上,由于无法保证插入序列的随机性(实际应用中数据往往有局部性),普通BST在最坏情况下的性能是不可接受的。这也就是为什么我们需要平衡二叉搜索树(如AVL树、红黑树)来自动调整结构,保证树高始终保持在O(log n)。
2.3 与二分查找的对比
二分查找也能达到O(log n)的查找效率,而且常数更小。但它有两个致命缺陷:
-
必须基于数组等支持随机访问的顺序结构,且数组必须有序。
-
插入和删除非常低效:在数组中间插入或删除元素需要移动大量数据,时间复杂度为O(n)。
而BST以结点链接的方式存储,插入和删除只需要修改指针,不需要移动物理内存。因此,在需要频繁动态增删且要求较高查找性能的场景下,BST是比数组更优的选择。当然,普通BST存在退化风险,这才催生了平衡二叉树的诞生。
三、二叉搜索树的插入操作
插入操作的思路非常简单:先查找要插入的值应该放置的位置,然后在找到的空位创建新结点。
3.1 插入步骤
-
空树处理:若根结点为空,直接新建结点作为根结点。
-
非空树查找位置 :从根开始,若待插入值
key大于当前结点值,则向右走;若小于,则向左走;若相等(且不允许重复),则插入失败。 -
插入新结点 :重复步骤2直到找到空位置(
cur == nullptr),此时根据父结点与key的比较结果,将新结点链接到父结点的左指针或右指针上。
如果允许重复值,那么在遇到相等值时,可以规定统一向右走(或向左走),最终将重复值插入到已有结点的右子树中。
3.2 图示示例
假设我们需要插入序列 {8, 3, 1, 10, 6, 4, 7, 14, 13}:
-
插入8:空树,8成为根。
-
插入3:3<8,成为8的左孩子。
-
插入1:1<8 -> 1<3,成为3的左孩子。
-
插入10:10>8,成为8的右孩子。
-
插入6:6<8 -> 6>3,成为3的右孩子。
-
插入4:4<8 -> 4>3 -> 4<6,成为6的左孩子。
-
插入7:7<8 -> 7>3 -> 7>6,成为6的右孩子。
-
插入14:14>8 -> 14>10,成为10的右孩子。
-
插入13:13>8 -> 13>10 -> 13<14,成为14的左孩子。
最终得到的BST满足:任意结点左子树所有值小于它,右子树所有值大于它。
3.3 代码实现(非递归)
template<class K>
class BSTree {
struct Node {
K _key;
Node* _left;
Node* _right;
Node(const K& key) : _key(key), _left(nullptr), _right(nullptr) {}
};
Node* _root = nullptr;
public:
bool Insert(const K& key) {
if (_root == nullptr) {
_root = new Node(key);
return true;
}
Node* parent = nullptr;
Node* cur = _root;
while (cur) {
if (cur->_key < key) {
parent = cur;
cur = cur->_right;
} else if (cur->_key > key) {
parent = cur;
cur = cur->_left;
} else {
return false; // 不允许重复
}
}
// 创建新结点并链接
cur = new Node(key);
if (parent->_key < key)
parent->_right = cur;
else
parent->_left = cur;
return true;
}
};
3.4 递归写法(更简洁)
递归版本的插入更符合树结构的直觉:
Node* _Insert(Node* root, const K& key) {
if (root == nullptr) return new Node(key);
if (key < root->_key)
root->_left = _Insert(root->_left, key);
else if (key > root->_key)
root->_right = _Insert(root->_right, key);
// 相等则什么也不做
return root;
}
递归代码虽然短小,但函数调用有栈开销,在树极高时可能造成栈溢出。实际工程中,非递归版本更为常用。
四、二叉搜索树的查找操作
查找是BST的最基础操作,也是其名字的由来。
4.1 查找步骤
-
从根结点开始。
-
若当前结点为空,则查找失败。
-
若目标值等于当前结点值,查找成功,返回结点指针(或布尔值)。
-
若目标值小于当前结点值,递归或迭代进入左子树。
-
若目标值大于当前结点值,进入右子树。
最多查找高度次,若走到空还没找到,说明值不存在。
4.2 支持重复值的查找
如果BST允许重复值,通常需要查找中序遍历下的第一个目标值。例如,树中有多个3,我们要找到最左边的那个3。这需要额外处理:当相等时继续往左走,直到左孩子不是目标值。
4.3 代码实现
bool Find(const K& key) {
Node* cur = _root;
while (cur) {
if (cur->_key < key)
cur = cur->_right;
else if (cur->_key > key)
cur = cur->_left;
else
return true;
}
return false;
}
查找可以非常高效地返回结点指针,后续我们可以利用它来修改结点中的附加信息(如value),但不能修改key,否则会破坏BST的性质。
五、二叉搜索树的删除操作
删除是BST中最复杂的操作,因为它需要处理四种不同的情况。删除前首先要找到待删除结点N,如果找不到则返回false。找到后,根据N的孩子情况分别处理。
5.1 四种情况分析
情况1:左右孩子均为空(叶子结点)
直接删除N,并将父结点指向N的指针置空。
情况2:左孩子为空,右孩子不为空
将父结点指向N的指针改为指向N的右孩子,然后删除N。
情况3:右孩子为空,左孩子不为空
将父结点指向N的指针改为指向N的左孩子,然后删除N。
情况4:左右孩子均不为空
这是最棘手的情况。我们不能直接删除N,因为它的两棵子树无处安放。解决方案是替换法:
-
从
N的右子树 中找到值最小的结点R(即右子树中最左边的结点),或者从N的左子树 中找到值最大的结点R(即左子树中最右边的结点)。 -
将
N的值与R的值交换(实际代码中只需拷贝R的值到N中)。 -
然后问题转化为删除结点
R。由于R是右子树的最小结点,它一定没有左孩子(否则还可以更小),因此R要么是叶子,要么只有右孩子,属于情况1或情况2,可以直接删除。
注意:如果右子树的根就是最小结点(即右根没有左孩子),那么
R就是cur->_right,此时处理指针时要特别小心,避免丢失右子树。
5.2 删除的代码实现(非递归)
下面给出完整的删除代码,其中对于情况1,我们可以合并到情况2或情况3中处理(叶子结点视为左孩子为空且右孩子为空,任一种分支都能处理)。
bool Erase(const K& key) {
Node* parent = nullptr;
Node* cur = _root;
// 1. 查找待删除结点
while (cur) {
if (cur->_key < key) {
parent = cur;
cur = cur->_right;
} else if (cur->_key > key) {
parent = cur;
cur = cur->_left;
} else {
break; // 找到了
}
}
if (cur == nullptr) return false; // 未找到
// 2. 处理待删除结点
// 情况2+1:左孩子为空(包含叶子结点)
if (cur->_left == nullptr) {
if (parent == nullptr) { // 删除根结点
_root = cur->_right;
} else {
if (parent->_left == cur)
parent->_left = cur->_right;
else
parent->_right = cur->_right;
}
delete cur;
return true;
}
// 情况3+1:右孩子为空
else if (cur->_right == nullptr) {
if (parent == nullptr) {
_root = cur->_left;
} else {
if (parent->_left == cur)
parent->_left = cur->_left;
else
parent->_right = cur->_left;
}
delete cur;
return true;
}
// 情况4:左右都不为空,使用右子树最小结点替换
else {
Node* rightMinParent = cur;
Node* rightMin = cur->_right;
// 找右子树最左结点
while (rightMin->_left) {
rightMinParent = rightMin;
rightMin = rightMin->_left;
}
// 替换值
cur->_key = rightMin->_key;
// 删除 rightMin 结点(它一定没有左孩子)
if (rightMinParent->_left == rightMin)
rightMinParent->_left = rightMin->_right;
else
rightMinParent->_right = rightMin->_right;
delete rightMin;
return true;
}
}
小技巧:在情况4中,我们也可以先拷贝值再删除,但要注意如果
rightMin有右子树,需要将其链接到父结点对应位置。由于rightMin没有左孩子,所以上述代码是正确的。
5.3 递归删除
递归删除更抽象,但逻辑清晰。基本思想是:先递归找到待删除结点,然后根据不同情况处理,并将子树的新根返回给父结点。
Node* _Erase(Node* root, const K& key) {
if (root == nullptr) return nullptr;
if (key < root->_key)
root->_left = _Erase(root->_left, key);
else if (key > root->_key)
root->_right = _Erase(root->_right, key);
else {
// 找到了
if (root->_left == nullptr) {
Node* right = root->_right;
delete root;
return right;
} else if (root->_right == nullptr) {
Node* left = root->_left;
delete root;
return left;
} else {
// 找右子树最小结点
Node* minNode = root->_right;
while (minNode->_left) minNode = minNode->_left;
root->_key = minNode->_key;
// 递归删除右子树中的那个最小结点
root->_right = _Erase(root->_right, minNode->_key);
}
}
return root;
}
递归删除的代码更简洁,但同样有递归深度的问题,面试时两种写法都应掌握。
六、二叉搜索树的完整C++实现(Key模型)
以下是一个完整的Key模型BSTree,支持插入、查找、删除、中序遍历以及拷贝构造、赋值、析构。代码基于模板,可以存储任意可比较类型。
#include <iostream>
using namespace std;
template<class K>
class BSTree {
struct Node {
K _key;
Node* _left;
Node* _right;
Node(const K& key) : _key(key), _left(nullptr), _right(nullptr) {}
};
Node* _root = nullptr;
void _InOrder(Node* root) {
if (root == nullptr) return;
_InOrder(root->_left);
cout << root->_key << " ";
_InOrder(root->_right);
}
Node* _Copy(Node* root) {
if (root == nullptr) return nullptr;
Node* newRoot = new Node(root->_key);
newRoot->_left = _Copy(root->_left);
newRoot->_right = _Copy(root->_right);
return newRoot;
}
void _Destroy(Node* root) {
if (root == nullptr) return;
_Destroy(root->_left);
_Destroy(root->_right);
delete root;
七、二叉搜索树的两种应用模型
7.1 Key模型 ------ 判断"在不在"
Key模型只存储关键字key,不附带其他信息。它解决的是集合问题:判断一个元素是否存在于集合中。
典型场景1:小区车库自动抬杆
小区业主车牌录入后台系统。当车辆到达入口,摄像头扫描车牌号,系统在BST中查找该车牌。若存在,抬杆放行;否则拒绝进入。
典型场景2:英文拼写检查
将正确单词库构建成BST,然后扫描英文文章,检查每个单词是否在树中。若不在,则标红提示。
这种模型下,只需要提供插入和查找接口,不需要修改,因为修改key会破坏树结构。
7.2 Key/Value模型 ------ 通过Key找Value
Key/Value模型中,每个结点存储一个键值对(key, value)。查找时以key为比较依据,找到后可以访问或修改对应的value。这种模型适合字典映射场景。
场景1:中英文互译字典
BST中存储英文单词(key)和中文释义(value)。输入英文,快速查找中文。
场景2:停车场计时收费
入口时记录车牌(key)和入场时间(value)。出口时根据车牌查找入场时间,计算费用。这里查找后需要修改value吗?不修改,只是读取。但另一场景中,统计单词出现次数需要修改value。
场景3:统计文章单词频率
遍历文章单词,对每个单词在BST中查找。如果不存在,插入(单词, 1);如果存在,将其value自增1。最后中序遍历即可输出每个单词及其出现次数。
7.3 Key/Value模型代码实现
结点结构增加_value成员,所有操作比较时只比较_key,删除和查找按key进行。下面给出插入和查找的核心代码。
template<class K, class V>
struct BSTNode {
K _key;
V _value;
BSTNode* _left;
BSTNode* _right;
BSTNode(const K& key, const V& value)
: _key(key), _value(value), _left(nullptr), _right(nullptr) {}
};
template<class K, class V>
class BSTree {
typedef BSTNode<K, V> Node;
Node* _root = nullptr;
public:
bool Insert(const K& key, const V& value) {
// 与Key模型类似,只是new结点时传入value
// ...
}
Node* Find(const K& key) {
Node* cur = _root;
while (cur) {
if (cur->_key < key) cur = cur->_right;
else if (cur->_key > key) cur = cur->_left;
else return cur;
}
return nullptr;
}
// 删除与Key模型完全一致,只比较key
bool Erase(const K& key) { /* 同前 */ }
void InOrder() { _InOrder(_root); }
};
使用示例:统计水果出现次数
int main() {
string arr[] = {"苹果", "西瓜", "苹果", "西瓜", "苹果", "苹果", "西瓜", "苹果", "香蕉", "苹果", "香蕉"};
BSTree<string, int> countTree;
for (const auto& str : arr) {
auto ret = countTree.Find(str);
if (ret == nullptr) {
countTree.Insert(str, 1);
} else {
ret->_value++;
}
}
countTree.InOrder(); // 输出每个水果及其次数
return 0;
}
使用示例:英汉词典
int main() {
BSTree<string, string> dict;
dict.Insert("left", "左边");
dict.Insert("right", "右边");
dict.Insert("insert", "插入");
string word;
while (cin >> word) {
auto ret = dict.Find(word);
if (ret) cout << ret->_value << endl;
else cout << "查无此词" << endl;
}
return 0;
}
八、二叉搜索树的缺陷与改进
8.1 缺陷
从性能分析可知,当插入序列有序时,普通BST会退化为单支树,查找效率降为O(n)。而实际应用中,数据往往具有一定的顺序性(例如时间戳递增),这导致BST在多数场景下性能不稳定。
8.2 改进方向 ------ 平衡二叉搜索树
为了让树始终保持平衡(左右子树高度差不超过某个阈值),计算机科学家们发明了自平衡二叉搜索树:
-
AVL树:任何结点的左右子树高度差绝对值不超过1。通过旋转操作(左旋、右旋、左右双旋等)在插入和删除后恢复平衡。AVL树查找极快(严格O(log n)),但旋转代价稍高。
-
红黑树 :不追求严格平衡,而是近似平衡。通过结点颜色(红/黑)和几条简单规则来保证最长路径不超过最短路径的两倍。红黑树插入删除旋转次数更少,性能更稳定,是C++ STL中
set和map的底层实现。 -
B树/B+树:多路搜索树,一个结点可以存储多个key,降低树高,适合磁盘I/O场景(数据库索引)。
8.3 工程实践建议
-
如果数据量小或插入序列随机且不要求高并发,普通BST足够简单。
-
在生产环境中,直接使用标准库中的
std::set、std::map(红黑树实现)或std::unordered_set、std::unordered_map(哈希表实现),而不是自己造轮子。 -
学习BST的核心价值在于理解树形结构的递归思想 以及插入/删除时指针操作的技巧,为后续学习更复杂的平衡树打下基础。
九、总结
本文从概念到实现,从单key到键值对,全面剖析了二叉搜索树。我们可以得到以下核心结论:
-
本质:BST是一个动态维护有序序列的二叉树,中序遍历可得排序结果。
-
时间复杂度:最优O(log n),最差O(n),平均O(log n)(随机插入)。
-
操作要点:
-
插入:找到空位链接。
-
查找:根据大小比较转向。
-
删除:处理四种情况,重点掌握替换法删除有两个孩子的结点。
-
-
应用模型:
-
Key模型:用于集合存在性判断(如车牌识别、拼写检查)。
-
Key/Value模型:用于字典映射和统计(如词典、计数器)。
-
-
局限性:容易退化为链表,需要引入平衡树优化。
希望通过这篇文章,你对二叉搜索树有了系统而深入的理解。接下来,你可以尝试自己实现一遍所有操作,并测试在不同插入顺序下的性能表现。然后,勇敢地向AVL树和红黑树进发吧!
参考阅读:
-
《算法导论》第12章:二叉搜索树
-
《C++ Primer》第17章:标准库特殊设施