二叉搜索树(Binary Search Tree, BST)是计算机科学中最基础也最重要的数据结构之一。作为所有有序树结构的基石,它的核心思想贯穿了从数据库索引到编程语言标准库容器的方方面面。对于C++开发工程师而言,深入理解BST不仅是掌握算法与数据结构的必经之路,更是读懂std::map、std::set等标准库容器底层实现的关键。
二叉搜索树BST本质
BST的核心价值在于:在一棵二叉树上维护"左小右大"的顺序关系,从而让查找、插入、删除操作在理想情况下达到O(log n)的时间复杂度。
但有一个极其重要的前提必须时刻牢记:
普通BST不保证平衡。
它的性能取决于树高h,而非单纯取决于节点数n。
因此,BST所有核心操作的复杂度本质上都是O(h)。当树比较平衡时,h≈log₂n;当树退化成链表时,h=n,所有操作的复杂度都会退化到O(n)。
一、BST的基本定义与不变量
1.1 形式化定义
二叉搜索树是一棵满足以下性质的二叉树:对于任意节点x,其左子树中所有节点的值都小于x的值,其右子树中所有节点的值都大于x的值
left_subtree < root < right_subtree
合法BST示例:
8
/ \
3 10
/ \ \
1 6 14
/ \ /
4 7 13
1.2 最容易被误解的不变量
注意:BST的本质不是"某个节点的左孩子小、右孩子大",而是一个节点的整个左子树都小于它,整个右子树都大于它。
非法BST示例:
10
/ \
5 15
/ \
6 20
这棵树看似满足父子节点的大小关系,但6出现在10的右子树中,违反了BST的核心不变量。因此,验证BST合法性时,必须维护区间约束:
- 左子树范围:(-∞, root)
- 右子树范围:(root, +∞)
1.3 节点结构设计
普通BST节点的基础结构:
cpp
struct Node {
int key;
Node* left;
Node* right;
};
支持迭代器与前驱后继查找的增强结构:
cpp
struct Node {
int key;
Node* left;
Node* right;
Node* parent; // 指向父节点的指针
};
泛型业务节点结构:
cpp
template <typename Key, typename Value>
struct Node {
Key key;
Value value;
Node* left = nullptr;
Node* right = nullptr;
Node* parent = nullptr;
};
注意:C++标准库中的
std::map和std::set虽然底层是红黑树而非普通BST,但它们的节点结构和核心思想完全继承自BST。
二、BST核心操作实现
2.1 查找操作
查找是BST最基础的操作,其逻辑完全遵循"左小右大"的性质。从根节点开始,与目标值比较,小于则向左,大于则向右,直到找到目标或遍历结束。
cpp
Node* find(Node* root, int key) {
Node* cur = root;
while (cur != nullptr) {
if (key < cur->key) {
cur = cur->left;
} else if (key > cur->key) {
cur = cur->right;
} else {
return cur;
}
}
return nullptr;
}
时间复杂度:O(h)
h = height,树的高度,专门用于二叉树、平衡树、搜索树场景
2.2 插入操作
插入操作与查找类似,只是在遇到空指针时创建新节点并插入。
递归版本:
cpp
Node* insert(Node* root, int key) {
if (root == nullptr) {
return new Node{key, nullptr, nullptr};
}
if (key < root->key) {
root->left = insert(root->left, key);
} else if (key > root->key) {
root->right = insert(root->right, key);
}
return root;
}
迭代版本(推荐工程使用):
cpp
// 函数:向BST插入key,返回树根节点
Node* insertIterative(Node* root, int key) {
// 1. 新建待插入节点,左右孩子初始为空
Node* newNode = new Node{key, nullptr, nullptr};
// 2. 边界:树为空(root=nullptr),新节点直接作为根返回
if (root == nullptr) {
return newNode;
}
Node* cur = root; // 当前遍历指针,从根开始向下查找插入位置
Node* parent = nullptr; // 保存cur的父节点,最后用来挂载新节点
// 3. 循环向下遍历,找到空的插入位置
while (cur != nullptr) {
parent = cur; // 先把当前节点存为父节点,再往下走
if (key < cur->key) {
// 待插入值更小,去左子树找空位
cur = cur->left;
} else if (key > cur->key) {
// 待插入值更大,去右子树找空位
cur = cur->right;
} else {
// key == cur->key:重复值,不插入
delete newNode; // 释放提前开辟的节点,防止内存泄漏
return root; // 直接返回原树,不做修改
}
}
// 循环结束时 cur = nullptr,parent是最后一个有效节点,即插入点的父节点
// 4. 判断新节点挂在父节点左还是右
if (key < parent->key) {
parent->left = newNode;
} else {
parent->right = newNode;
}
// 树的根节点不会改变,返回原root
return root;
}
时间复杂度:O(h)
2.3 删除操作:最复杂也最容易出错
删除操作是BST中最复杂的部分,因为删除节点后必须重新维持BST的性质。根据待删除节点的子节点数量,分为三种情况:
情况1:删除叶子节点
直接将父节点对应的指针置空即可。
情况2:删除只有一个孩子的节点
用该节点的唯一孩子替代它的位置。
情况3:删除有两个孩子的节点
这是最复杂的情况。标准做法是用该节点的中序后继 (右子树中的最小节点)或中序前驱(左子树中的最大节点)来替代它,然后删除原来的后继/前驱节点。
递归实现:
cpp
Node* erase(Node* root, int 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 {
// 找到要删除的节点
// 情况1:没有左孩子
if (root->left == nullptr) {
Node* rightChild = root->right;
delete root;
return rightChild;
}
// 情况2:没有右孩子
if (root->right == nullptr) {
Node* leftChild = root->left;
delete root;
return leftChild;
}
// 情况3:两个孩子都存在
Node* succ = minimum(root->right);
root->key = succ->key;
root->right = erase(root->right, succ->key);
}
return root;
}
关键:删除操作可能改变当前子树的根节点,因此必须通过返回值更新父节点的指针。这是很多人写BST删除时出错的根本原因。
三、BST的关键性质与衍生操作
3.1 中序遍历的有序性
BST最重要的性质:对BST进行中序遍历,会得到一个严格递增的有序序列。
中序遍历顺序:左子树 → 当前节点 → 右子树
递归实现:
cpp
void inorder(Node* root) {
if (root == nullptr) {
return;
}
inorder(root->left);
std::cout << root->key << " ";
inorder(root->right);
}
非递归实现(避免栈溢出):
cpp
void inorderIterative(Node* root) {
// 栈:存放待访问的二叉树节点,模拟递归调用栈
std::stack<Node*> st;
// cur 遍历指针,初始指向根节点
Node* cur = root;
// 循环终止条件:当前指针为空 并且 栈里没有待处理节点
while (cur != nullptr || !st.empty()) {
// 1. 一路向左走到最左叶子,沿途所有节点压栈(保存根,之后再访问)
while (cur != nullptr) {
st.push(cur); // 当前节点入栈,暂存
cur = cur->left; // 移动到左孩子
}
// 2. 左子树走完,取出栈顶节点(最底层未访问的根)
cur = st.top();
st.pop();
// 访问根节点:中序中间步骤
std::cout << cur->key << " ";
// 3. 处理右子树,下一轮循环会继续遍历右子树的左分支
cur = cur->right;
}
}
这个性质是BST一切高级应用的基础。很多BST问题都可以转化为"BST + 中序遍历 = 有序数组"来解决。
3.2 极值查找
- BST的最小值一定在最左边
- BST的最大值一定在最右边
cpp
Node* minimum(Node* root) {
if (root == nullptr) {
return nullptr;
}
while (root->left != nullptr) {
root = root->left;
}
return root;
}
Node* maximum(Node* root) {
if (root == nullptr) {
return nullptr;
}
while (root->right != nullptr) {
root = root->right;
}
return root;
}
3.3 前驱与后继
前驱和后继是BST中非常重要的概念,也是std::map和std::set迭代器++和--操作的底层实现基础。
- 前驱:比当前节点小的最大节点
- 后继:比当前节点大的最小节点
后继查找实现:
cpp
Node* successor(Node* node) {
if (node == nullptr) {
return nullptr;
}
// 情况1:有右子树,后继是右子树的最小值
if (node->right != nullptr) {
return minimum(node->right);
}
// 情况2:没有右子树,向上找第一个左孩子的父节点
Node* parent = node->parent;
while (parent != nullptr && node == parent->right) {
node = parent;
parent = parent->parent;
}
return parent;
}
前驱查找与后继查找对称,只需将左右互换即可。
四、BST高级应用与增强版本
4.1 合法性验证
如前所述,验证BST不能只检查父子节点,必须维护上下界约束。
正确实现:
cpp
bool validate(Node* root, long long low, long long high) {
if (root == nullptr) {
return true;
}
if (root->key <= low || root->key >= high) {
return false;
}
return validate(root->left, low, root->key)
&& validate(root->right, root->key, high);
}
bool isValidBST(Node* root) {
return validate(root, LLONG_MIN, LLONG_MAX);
}
对于泛型类型,可以使用指针边界替代数值边界:
cpp
bool validate(Node* root, const Node* low, const Node* high) {
if (root == nullptr) {
return true;
}
if (low != nullptr && root->key <= low->key) {
return false;
}
if (high != nullptr && root->key >= high->key) {
return false;
}
return validate(root->left, low, root)
&& validate(root->right, root, high);
}
4.2 第k小元素与顺序统计树
利用中序遍历可以找到第k小元素,但时间复杂度为O(n)。更好的做法是在每个节点上维护子树的大小,从而实现O(h)时间复杂度的第k小查找。
cpp
struct Node {
int key;
int size; // 以该节点为根的子树的节点总数
Node* left;
Node* right;
};
int getSize(Node* node) {
return node == nullptr ? 0 : node->size;
}
Node* kthSmallest(Node* root, int k) {
if (root == nullptr) {
return nullptr;
}
// 拿到左子树一共有多少个节点
int leftSize = getSize(root->left);
// 情况1:当前根就是第 k 小
if (k == leftSize + 1) {
return root;
}
// 情况2:第 k 小在左子树内部
if (k <= leftSize) {
return kthSmallest(root->left, k);
}
// 情况3:第 k 小在右子树内部
return kthSmallest(root->right, k - leftSize - 1);
}
这种维护了子树大小的BST称为顺序统计树(Order Statistic Tree),可以高效支持排名查询和区间统计。
4.3 范围查询
BST非常适合范围查询,可以利用其性质进行剪枝,避免遍历整棵树。
cpp
void rangeQuery(Node* root, int L, int R, std::vector<int>& result) {
// 递归终止:空节点直接返回
if (root == nullptr) {
return;
}
// ① 当前节点值 > L → 左子树还有更小值可能落在区间内,递归搜左
if (root->key > L) {
rangeQuery(root->left, L, R, result);
}
// ② 当前节点正好在 [L, R] 区间,存入结果
if (root->key >= L && root->key <= R) {
result.push_back(root->key);
}
// ③ 当前节点值 < R → 右子树还有更大值可能落在区间内,递归搜右
if (root->key < R) {
rangeQuery(root->right, L, R, result);
}
}
时间复杂度:O(h + k),其中h是树高,k是查询结果的数量。
4.4 最近公共祖先(LCA)
在BST中查找最近公共祖先可以利用其大小关系,无需遍历整棵树。
cpp
Node* lowestCommonAncestor(Node* root, int p, int q) {
// 遍历指针从根出发
Node* cur = root;
while (cur != nullptr) {
// 情况1:p、q 都比当前节点小 → 两个节点都在左子树
if (p < cur->key && q < cur->key) {
cur = cur->left;
}
// 情况2:p、q 都比当前节点大 → 两个节点都在右子树
else if (p > cur->key && q > cur->key) {
cur = cur->right;
}
// 情况3:分叉了,当前节点就是最近公共祖先
else {
return cur;
}
}
// 树为空 / 找不到节点,返回空
return nullptr;
}
逻辑:如果p和q都小于当前节点,说明LCA在左子树;如果都大于,说明在右子树;否则当前节点就是分叉点,即LCA。
五、C++工程实践要点
5.1 完整的BST封装示例
下面是一个符合现代C++规范的BST实现,使用std::unique_ptr管理内存,支持泛型和自定义比较器。
cpp
#include <iostream>
#include <memory>
#include <vector>
#include <functional>
template <typename T, typename Compare = std::less<T>>
class BinarySearchTree {
private:
struct Node {
T key;
std::unique_ptr<Node> left;
std::unique_ptr<Node> right;
explicit Node(const T& value)
: key(value), left(nullptr), right(nullptr) {}
};
private:
std::unique_ptr<Node> root_;
std::size_t size_ = 0;
Compare comp_;
private:
bool equal(const T& a, const T& b) const {
return !comp_(a, b) && !comp_(b, a);
}
bool insertImpl(std::unique_ptr<Node>& node, const T& key) {
if (!node) {
node = std::make_unique<Node>(key);
++size_;
return true;
}
if (comp_(key, node->key)) {
return insertImpl(node->left, key);
}
if (comp_(node->key, key)) {
return insertImpl(node->right, key);
}
return false; // 不允许重复key
}
bool containsImpl(const Node* node, const T& key) const {
while (node != nullptr) {
if (comp_(key, node->key)) {
node = node->left.get();
} else if (comp_(node->key, key)) {
node = node->right.get();
} else {
return true;
}
}
return false;
}
Node* minNode(Node* node) const {
if (node == nullptr) {
return nullptr;
}
while (node->left) {
node = node->left.get();
}
return node;
}
bool eraseImpl(std::unique_ptr<Node>& node, const T& key) {
if (!node) return false;
if (comp_(key, node->key))
return eraseImpl(node->left, key);
if (comp_(node->key, key))
return eraseImpl(node->right, key);
// ========== 找到待删除节点 ==========
// 情况1:只有右孩子 / 无孩子
if (!node->left) {
node = std::move(node->right);
--size_;
return true;
}
// 情况2:只有左孩子
if (!node->right) {
node = std::move(node->left);
--size_;
return true;
}
// 情况3:左右都有孩子:用后继替换删除
Node* succ = minNode(node->right.get());
node->key = succ->key;
return eraseImpl(node->right, succ->key);
}
void inorderImpl(const Node* node, std::vector<T>& result) const {
if (node == nullptr) {
return;
}
inorderImpl(node->left.get(), result);
result.push_back(node->key);
inorderImpl(node->right.get(), result);
}
int heightImpl(const Node* node) const {
if (node == nullptr) {
return -1;
}
int leftHeight = heightImpl(node->left.get());
int rightHeight = heightImpl(node->right.get());
return std::max(leftHeight, rightHeight) + 1;
}
public:
bool insert(const T& key) { return insertImpl(root_, key); }
bool contains(const T& key) const { return containsImpl(root_.get(), key); }
bool erase(const T& key) { return eraseImpl(root_, key); }
std::vector<T> inorder() const {
std::vector<T> result;
result.reserve(size_); // 预分配内存,减少扩容开销
inorderImpl(root_.get(), result);
return result;
}
int height() const { return heightImpl(root_.get()); }
std::size_t size() const { return size_; }
bool empty() const { return size_ == 0; }
// 禁用拷贝,启用移动
BinarySearchTree(const BinarySearchTree&) = delete;
BinarySearchTree& operator=(const BinarySearchTree&) = delete;
BinarySearchTree(BinarySearchTree&&) = default;
BinarySearchTree& operator=(BinarySearchTree&&) = default;
};
- unique_ptr 本身不支持拷贝,树也不能浅拷贝,直接禁用拷贝语义,防止 double free、浅拷贝崩溃
- 允许移动:可以把树资源转移给另一个对象,代价极低,适合容器存放、函数返回树对象
使用示例:
cpp
int main() {
BinarySearchTree<int> bst;
bst.insert(8);
bst.insert(3);
bst.insert(10);
bst.insert(1);
bst.insert(6);
bst.insert(14);
bst.insert(4);
bst.insert(7);
bst.insert(13);
std::cout << std::boolalpha;
std::cout << "contains 7: " << bst.contains(7) << "\n";
std::cout << "contains 100: " << bst.contains(100) << "\n";
auto values = bst.inorder();
for (int x : values) {
std::cout << x << " ";
}
std::cout << "\nheight: " << bst.height() << "\n";
bst.erase(8);
values = bst.inorder();
for (int x : values) {
std::cout << x << " ";
}
std::cout << "\n";
}
5.2 比较器设计原则
- 不要硬编码
<运算符 :应该支持自定义比较器,这是std::map和std::set的设计原则 - 判断相等使用双重比较 :不要直接使用
==,而应该用!comp(a,b) && !comp(b,a) - 比较器必须满足严格弱序 :
- 自反性禁止:
comp(a,a)必须为false - 传递性:如果
a<b且b<c,则a<c - 等价关系稳定
- 自反性禁止:
错误比较器示例:
cpp
struct BadCompare {
bool operator()(int a, int b) const {
return a <= b; // 错误:comp(a,a)为true
}
};
5.3 内存管理
- 优先使用智能指针 :
std::unique_ptr可以自动管理内存,避免泄漏 - 所有权清晰 :父节点拥有子节点的所有权,
left和right使用unique_ptr;parent是观察者指针,使用裸指针 - 避免手动delete :在现代C++中,手动调用
delete应该被视为代码坏味道 - 对象池优化:对于频繁创建和销毁节点的场景,可以使用对象池减少内存分配开销
5.4 递归与迭代的选择
普通BST在最坏情况下高度为n,递归实现可能导致栈溢出。因此:
- 对于小规模数据或已知输入随机的场景,递归实现更简洁
- 对于生产环境代码,优先使用迭代实现
- 对于平衡BST(如AVL、红黑树),递归深度不会超过log₂(1e9)≈30,递归是安全的
六、性能分析与工程局限
6.1 时间复杂度分析
| 操作 | 平均情况 | 最坏情况 |
|---|---|---|
| 查找 | O(log n) | O(n) |
| 插入 | O(log n) | O(n) |
| 删除 | O(log n) | O(n) |
| 最小值 | O(log n) | O(n) |
| 最大值 | O(log n) | O(n) |
| 前驱/后继 | O(log n) | O(n) |
| 中序遍历 | O(n) | O(n) |
普通BST的性能完全取决于输入顺序。如果按升序或降序插入数据,BST会退化成链表,所有操作的复杂度都会退化到O(n)。
6.2 与其他数据结构的对比
| 数据结构 | 是否有序 | 是否平衡 | 查找 | 插入删除 | 典型用途 |
|---|---|---|---|---|---|
| 普通BST | 是 | 否 | 平均快,最坏慢 | 平均快,最坏慢 | 教学、基础结构 |
| AVL树 | 是 | 严格平衡 | 很快 | 旋转较多 | 查找密集型场景 |
| 红黑树 | 是 | 近似平衡 | 稳定 | 稳定 | std::map/set |
| B/B+树 | 是 | 严格平衡 | 稳定 | 稳定 | 数据库索引 |
| 跳表 | 是 | 概率平衡 | 平均快 | 平均快 | Redis有序集合 |
| 哈希表 | 否 | 不适用 | 平均很快 | 平均很快 | unordered_map/set |
| 有序数组 | 是 | 不适用 | O(log n) | O(n) | 静态数据查询 |
6.3 BST的工程局限
- 缓存不友好:节点分散在堆上,指针跳转多,CPU缓存命中率低
- 动态内存分配开销:每个节点都需要单独分配内存,产生额外开销和内存碎片
- 并发控制复杂:多线程访问需要复杂的同步机制
- 性能不稳定:最坏情况下退化成链表
因此,在实际生产环境中,我们几乎不会直接使用普通BST,而是使用其平衡版本,如红黑树或B+树。
七、常见面试题
7.1 高频面试题
- 判断一棵树是否是BST:核心是递归上下界验证
- BST的第k小元素:中序遍历或维护子树大小
- 删除BST节点:重点考察两个孩子节点的处理
- BST最近公共祖先:利用大小关系找分叉点
- 有序数组转平衡BST:取中点作为根,递归构建
- 恢复被交换的BST:利用中序遍历的有序性找到逆序位置
- BST迭代器实现:用栈模拟中序遍历
7.2 最容易踩的坑
- 误解BST不变量:只检查父子节点的大小关系,忽略了整个子树
- 删除操作错误:处理两个孩子节点时直接乱接,破坏BST性质
- 忘记BST会退化:误以为所有操作都是O(log n)
- 递归栈溢出:在退化的BST上使用递归实现
- 比较器不合法:使用不满足严格弱序的比较器
- 重复key规则不明确:插入、查找、删除逻辑不一致
总结
BST的核心可以用几句话概括:
- BST是维护有序关系的二叉树
- 任意节点满足:左子树全部小于它,右子树全部大于它
- 中序遍历BST会得到有序序列
- 所有核心操作的复杂度都是O(h)
- 普通BST不保证平衡,最坏会退化成链表
- 删除两个孩子的节点时,用前驱或后继替代
- std::map/std::set是平衡BST思想的工程化实现
从C++开发工程师的角度看,普通BST在生产环境中确实用得不多,但它是理解所有有序树结构的基础。它的不变量思想、删除逻辑、中序有序性、前驱后继查找、范围查询等核心概念,贯穿了从红黑树到B+树的所有高级树结构。深入理解BST,不仅能帮助我们更好地使用标准库容器,更能培养我们设计和实现复杂数据结构的能力。