二叉搜索树这个东西,我在不同阶段写过好几版,C++版是所有语言里最麻烦、也最能逼你把指针和内存管理想清楚的一版。很多教程把概念讲得头头是道,一上手写代码就崩,二叉搜索树尤其如此——它的核心不在“插入比根节点小就往左走”这句话,而在“节点怎么连接”“递归怎么返回”“删除的时候怎么把父节点和孩子节点接上”。
这篇文章我会带着大家从零手撕一棵二叉搜索树,不是贴一段完整代码让你抄,而是拆开每一个函数讲清楚:为什么要这么写、递归的返回机制是怎么回事、删除节点的三种情况到底怎么处理、指针引用为什么能简化一大半代码。全程C++实现,兼顾严格的内存管理,所有代码我都用标准C++17跑过,需要的直接拿去用。
1. 整体设计与思路拆解
1.1 二叉搜索树到底解决什么问题
先回答一个很多人心里没底的问题:数组查一个数可以用二分,O(log n),链表插入删除快,但查找只能O(n),二叉搜索树就是两者的折中——查找、插入、删除平均都是O(log n),而且中序遍历直接输出有序序列。
拿一个场景举例:维护一个动态的学生成绩表,随时要插入新成绩、删除错误记录、查询某个分数是否存在,数据量是十万级。用有序数组,插入要搬移元素,O(n);用链表,查找要遍历,O(n);用二叉搜索树,每次操作沿着树高走,平衡情况下树高只有log n级别。
这是它最核心的价值:动态数据的快速查找与有序维护。C++里map、set底层的红黑树就是一种自平衡的二叉搜索树,先把这个普通的写明白,后面理解红黑树、AVL树就不会卡在基础上。
1.2 为什么用C++而不是C或Java来写
C写二叉搜索树会遇到一个很痛苦的情况:插入节点需要修改指针,你得传二级指针,删除节点更是要把各种指针绕来绕去,代码写出来又长又容易错。Java有引用传递,写起来舒服很多,但没法直接感受内存管理的细节。
C++的优势在于引用。Node*& root这种写法能把二级指针的复杂度直接消掉,让逻辑变得非常清爽。同时C++的析构函数可以递归释放整棵树,不用像C那样手动遍历释放,代码干净不少。
我在教学和实际工程中写这一版,遵循的原则是:
- 根节点指针作为私有成员,外部只暴露接口,不暴露节点细节
- 内部递归函数都带上下文参数,方便控制递归方向
- 所有分配的内存必须有对应的释放路径,析构函数保证不泄漏
这样写出来的代码,扩展成红黑树或AVL树的时候,整体框架不用动,只替换平衡相关的逻辑就行。
1.3 树的表示与节点结构设计
节点的核心设计很简单,就是左孩子指针、右孩子指针、键值。这里我直接写了个模板类,树是可以存储任意可比较类型的,不要只写死成int。
template<typename K> struct BSTNode { K key; BSTNode<K>* left; BSTNode<K>* right; explicit BSTNode(const K& k) : key(k), left(nullptr), right(nullptr) {} };为什么键不加mutable?搜索树的键默认不允许修改,改键等于破坏整棵树的结构约束。你如果需要键值对,把value塞进节点里就行,搜索比较仍然用key,这是后话。
有个小细节要注意:构造函数用了explicit,防止编译器隐式转换把普通值变成节点,这类结构体的构造越明确越好。
2. 核心细节解析与实操要点
2.1 插入操作的递归与引用机制
插入的逻辑一句话就能说清:比当前节点小走左子树,比当前节点大走右子树,遇到空位就创建新节点。但代码怎么写,直接决定你会不会遇到“插入完根丢了”这种问题。
先看第一版常见的错误写法:
void insert(Node* root, int key) { if (root == nullptr) { root = new Node(key); return; } if (key < root->key) insert(root->left, key); else if (key > root->key) insert(root->right, key); }这个版本看着没问题,实际上插入的节点根本没有接到树上。原因在于参数root是值传递,函数内部修改root只修改了形参副本,调用结束后这个新节点就丢了。
正确的做法是用指针的引用:
void insert(Node*& root, int key) { if (root == nullptr) { root = new Node(key); return; } if (key < root->key) insert(root->left, key); else if (key > root->key) insert(root->right, key); // 相等时不处理,保持键唯一 }Node*&表示“指针的引用”,传入的是root->left或root->right这个指针变量本身。当递归走到root == nullptr时,root引用的就是父节点那个指向空的孩子指针,new Node(key)直接给它赋值,父节点就自然连上了。
这个机制是所有二叉树操作的基石,你搞懂这一行,后面删除操作的迭代版本也能想明白。说人话版本:普通传参是把地址复印件发给函数,引用传参是把原件地址发给函数,只有后者才能改写原件。
2.2 查找:写递归前先想清楚返回值
查找分两种需求:查存在性和查具体节点。存在性返回bool,查节点返回指针。
bool searchRecursive(Node* root, const K& key) const { if (root == nullptr) return false; if (key == root->key) return true; if (key < root->key) return searchRecursive(root->left, key); return searchRecursive(root->right, key); }这个递归的结束条件有两个:找到空节点说明不存在,返回false;找到匹配的键返回true。递归方向的选择依据是键的大小比较。
查找的效率取决于树的高度。理想情况下高度为log n,最坏情况(插入序列有序)退化成链表,高度为n。所以你如果面试时被人问“二叉搜索树查找是不是一定O(log n)”,答案是否定的,只有平衡树才有保证。这也是为什么C++标准库的map用的是红黑树而不是普通二叉搜索树。
查找的迭代版本写起来也很简单,while循环顺着比较往下走,没人会写错。但递归版本对于理解“返回值如何一层层向上传递”很有帮助,写删除节点时你会用上这种思路。
2.3 中序遍历:为什么它天生有序
中序遍历的顺序是左子树、根、右子树。因为左子树所有节点比根小,右子树所有节点比根大,所以递归访问的结果天然是升序。
void inorder(Node* root) const { if (root == nullptr) return; inorder(root->left); std::cout << root->key << " "; inorder(root->right); }这里我建议你在打印之外,加一个回调函数的版本:
template<typename Func> void inorderTraversal(Node* root, Func&& visit) const { if (root == nullptr) return; inorderTraversal(root->left, visit); visit(root->key); inorderTraversal(root->right, visit); }回调版本的好处是可以把“遍历输出”和“对每个元素做点什么”解耦,比如收集到vector里、对每个节点做统计、比较两棵树是否结构相同。我在实际写树相关题目时基本都是回调版本。
2.4 删除节点:全网最啰嗦但最明白的拆解
删除是二叉搜索树里公认最难的环节,难在删除后要保持搜索树的性质,且不能丢子节点。按被删节点的孩子数量分成三种情况。
情况一:叶子节点(没孩子)
直接delete,把父节点指向它的指针置空。如果删的是根且整棵树只有一个节点,根直接置空。用引用参数的话,一行解决:
if (node->left == nullptr && node->right == nullptr) { delete node; node = nullptr; }这里node是Node*&,delete node释放内存后,node = nullptr会把父节点的指针也置空,因为它们是同一个变量。
情况二:只有一个孩子
把被删节点的孩子提上来顶替它。画个图想象一下:被删节点A只有右孩子B,A的父节点之前指向A,现在直接指向B即可,中间没有其他节点,搜索树性质不会受影响。
else if (node->left == nullptr) { Node* temp = node->right; delete node; node = temp; } else if (node->right == nullptr) { Node* temp = node->left; delete node; node = temp; }node = temp这个操作同样借助引用,直接修改了父节点的指针指向。
情况三:有两个孩子
两个孩子的删除策略有个经典思路:用右子树的最小节点(或左子树的最大节点)替换被删节点。什么意思呢?被删节点有左右两棵子树,为了保持搜索树性质,新节点必须大于左子树所有值、小于右子树所有值,而右子树最小节点正好满足。
实操上,可以先把右子树最小节点的值复制到当前节点,然后去右子树把那个最小节点删掉。那个最小节点必然没有左孩子,所以对它的删除退化成了“情况一或情况二”。
else { Node* successor = findMin(node->right); node->key = successor->key; deleteNode(node->right, successor->key); // 递归删除右子树中的这个后继节点 }为什么不直接拿后继节点地址替换当前节点?因为还要处理后继节点的右子树。复制键值,再递归删除后继节点,把“双孩子删除”转化成“删一个没左孩子的节点”,逻辑简单很多。
网上也有直接改写指针的做法,省掉一次递归,但代码复杂度高、容易忘接孩子指针。我实际写代码的经验是,先用复制+递归删,逻辑正确率接近百分之百,等性能确实成为瓶颈再优化不迟。
2.5 最小值和最大值的查找
这个太常用了,单独拿出来说。最左节点就是最小值,最右节点就是最大值,顺着指针走就行。
Node* findMin(Node* root) const { if (root == nullptr) return nullptr; while (root->left != nullptr) root = root->left; return root; }递归版本同样简单,边界条件是左孩子为空返回当前节点。
3. 实操过程与核心环节实现
3.1 完整类的框架:接口与私有工具方法分离
写二叉搜索树的完整实现,我习惯把对外接口和内部递归函数分开。对外接口是用户调用的,内部递归函数做真正的递归工作,通常带一个Node*参数,而且为了修改指针,参数写Node*&。
template<typename K> class BinarySearchTree { private: BSTNode<K>* root; void insert(BSTNode<K>*& node, const K& key); bool search(BSTNode<K>* node, const K& key) const; void remove(BSTNode<K>*& node, const K& key); void clear(BSTNode<K>* node); BSTNode<K>* findMin(BSTNode<K>* node) const; void inorder(BSTNode<K>* node, std::vector<K>& out) const; public: BinarySearchTree() : root(nullptr) {} ~BinarySearchTree() { clear(root); } void insert(const K& key) { insert(root, key); } bool search(const K& key) const { return search(root, key); } void remove(const K& key) { remove(root, key); } std::vector<K> inorderTraversal() const; bool empty() const { return root == nullptr; } };析构函数很重要,忘记写会导致整棵树的内存泄漏。clear用后序遍历释放所有节点:
template<typename K> void BinarySearchTree<K>::clear(BSTNode<K>* node) { if (node != nullptr) { clear(node->left); clear(node->right); delete node; } }先杀左子树,再杀右子树,最后删当前节点。顺序不能反过来,否则先删当前节点就找不到孩子指针了。
3.2 完整可运行的插入实现
插入的递归版本前面讲过原理,这里给出完整实现,并补上重复键的处理:
template<typename K> void BinarySearchTree<K>::insert(BSTNode<K>*& node, const K& key) { if (node == nullptr) { node = new BSTNode<K>(key); return; } if (key < node->key) { insert(node->left, key); } else if (key > node->key) { insert(node->right, key); } // key相等,不插入,保证键唯一 }如果你想支持重复键,有两个选择:节点加一个count计数;或者允许重复键,但插入时相等就往右走。前者适合统计场景,后者会让删除逻辑变复杂。实际工程中大多数场景要求键唯一,所以我默认走不插入分支。
迭代版插入顺便给一个,理解“怎么在原地修改叶子节点的空指针”:
template<typename K> void BinarySearchTree<K>::insertIterative(const K& key) { if (root == nullptr) { root = new BSTNode<K>(key); return; } BSTNode<K>* cur = root; while (true) { if (key < cur->key) { if (cur->left == nullptr) { cur->left = new BSTNode<K>(key); return; } cur = cur->left; } else if (key > cur->key) { if (cur->right == nullptr) { cur->right = new BSTNode<K>(key); return; } cur = cur->right; } else { return; // 重复键,不插入 } } }这个版本不用引用,是因为每轮循环都能拿到父节点的指针,直接改父节点的孩子指针即可。
3.3 完整可运行的删除实现
删除函数承接前面的三种情况,完整代码:
template<typename K> void BinarySearchTree<K>::remove(BSTNode<K>*& node, const K& key) { if (node == nullptr) return; if (key < node->key) { remove(node->left, key); } else if (key > node->key) { remove(node->right, key); } else { // 找到要删除的节点 if (node->left == nullptr && node->right == nullptr) { delete node; node = nullptr; } else if (node->left == nullptr) { BSTNode<K>* temp = node->right; delete node; node = temp; } else if (node->right == nullptr) { BSTNode<K>* temp = node->left; delete node; node = temp; } else { BSTNode<K>* successor = findMin(node->right); node->key = successor->key; remove(node->right, successor->key); } } }注意删除双子节点时,remove(node->right, successor->key)的node->right也是引用传递,所以能正确修改右子树根节点的指向。这个递归删除的后续调用会进入“情况一或情况二”分支,把后继节点真正从树中移除。
如果被删节点是根节点且有两个孩子,node引用的是root成员变量,先复制键值,然后递归删除右子树的后继节点,整个过程根的地址不变,树结构依然完整。
3.4 中序遍历收集结果的实现
前面回调版本比较通用,这里给出一个返回vector的简单版本,方便测试代码比对输出:
template<typename K> std::vector<K> BinarySearchTree<K>::inorderTraversal() const { std::vector<K> result; inorder(root, result); return result; } template<typename K> void BinarySearchTree<K>::inorder(BSTNode<K>* node, std::vector<K>& out) const { if (node == nullptr) return; inorder(node->left, out); out.push_back(node->key); inorder(node->right, out); }验证二叉搜索树正确性有个非常快的办法:插入一堆乱序数据,然后中序遍历,如果输出是升序的,说明插入逻辑没问题。删除后再遍历,仍然升序,说明删除也保持了树的性质。
3.5 对象拷贝问题与禁用拷贝
写完了基本功能,有个C++特有的坑必须提醒:如果你直接把类对象赋值给另一个对象,比如BinarySearchTree<int> t2 = t1;,默认拷贝构造函数做浅拷贝,两个对象的root指针指向同一棵树的节点。接下来t2析构时把树删了,t1析构时再删一次,直接崩。
处理方案很简单,明确禁止拷贝:
BinarySearchTree(const BinarySearchTree&) = delete; BinarySearchTree& operator=(const BinarySearchTree&) = delete;如果你确实需要拷贝,那就写深拷贝构造函数,递归复制每个节点。但对大多数场景,禁止拷贝是最省心的选择。移动构造可以留着,BinarySearchTree<int> t2 = std::move(t1);是安全的,因为移动后t1的root为nullptr,析构没问题。
3.6 测试驱动:插入、中序、删除一轮验证
写完代码要立即可测。我习惯用一组包含各种情况的序列来测,比如插入序列:[50, 30, 70, 20, 40, 60, 80, 10, 35],这棵树既有单孩子节点,也有双子节点,删除时能覆盖所有情况。
测试流程:
BinarySearchTree<int> bst; std::vector<int> vals = {50, 30, 70, 20, 40, 60, 80, 10, 35}; for (int v : vals) bst.insert(v); auto sorted = bst.inorderTraversal(); // 期望 10 20 30 35 40 50 60 70 80 bst.remove(20); // 叶子节点 bst.remove(30); // 单孩子节点 bst.remove(50); // 根节点且双子节点 auto sorted2 = bst.inorderTraversal(); // 期望 10 35 40 60 70 80测试输出如果满足预期,说明删除的三种情况都处理正确。我实际测试时还会加一步,检查删除后树的高度是否合理,避免删除后继节点时误伤结构。
3.7 环境准备与编译器选择
代码基于C++17标准,随便一个现代编译器都能编译。Windows上我用Visual Studio 2022或者MinGW-w64,Linux上g++直接编:
g++ -std=c++17 -Wall -Wextra -O2 -o bst_test bst_test.cppVSCode配置C/C++环境的话题在网络上一搜一大堆,这里不展开。需要注意的点就两个:编译器一定要支持C++17(gcc 7以上、clang 6以上、MSVC 2017以后都行);调试时建议加-g选项生成调试信息,方便打断点看指针变化。
4. 递归与迭代的对比和选择策略
4.1 什么时候选递归,什么时候选迭代
二叉搜索树相关的操作天然适合递归,因为树本身就是递归定义的结构。递归代码写出来跟定义一一对应,不容易错。但递归有代价,每层调用涉及函数调用开销和栈空间占用。树高100的时候无所谓,但如果退化成链表结构,递归深度可能到10万甚至100万,就会栈溢出。
迭代版本在查找和插入场景很好写,删除场景复杂很多,因为删除时需要修改父节点指针。虽然也能用prev指针维护父节点,但代码明显更啰嗦。我的建议:
- 查找:优先写迭代,简单又节省栈空间
- 插入:递归和迭代都可以,递归更易理解
- 删除:优先递归,引用传参简化代码;实在要迭代,务必用父指针跟踪前驱节点
- 遍历:递归,树遍历用递归更好读
4.2 尾递归问题
有些语言有尾递归优化,递归不涨栈空间。C++标准不保证尾递归优化,编译器在O2优化下可能会做,但依赖编译器行为不靠谱。所以对可能深度很大的树,比如插入有序序列导致退化的树,迭代查找更稳。
4.3 手撕代码时的调试技巧
调试二叉搜索树有个习惯我推荐大家养成:不管写什么操作,先中序遍历看当前树的整体有序性。排查步骤如下:
- 插入后中序遍历,确认新值出现在正确位置
- 删除后中序遍历,确认被删的值消失且剩余元素依然有序
- 检查子树结构是否错乱,写一个计算树高的函数辅助观察:
template<typename K> int height(BSTNode<K>* node) const { if (node == nullptr) return 0; return 1 + std::max(height(node->left), height(node->right)); }插入有序序列后看height是否等于n,如果等于n说明退化成了链表,这时候要意识到不是代码bug,而是二叉搜索树的天然缺陷。
5. 常见问题与排查技巧实录
5.1 插入节点丢失,树没有变化
最常见的原因就是值传递。你写的函数参数是Node* root而不是Node*& root,函数里new出来的节点挂在形参上,函数结束就丢了。
排查方法很简单:插入完调用中序遍历,如果新值没出现,99%是引用问题。记住:要修改指针本身的指向,必须传指针的引用或二级指针。
5.2 程序崩溃:空指针访问
崩溃点通常在node->key这里,原因是node是nullptr却还在访问它的成员。回顾删除逻辑,递归调用remove(node->left, key)后没有判空就继续?不会,因为remove函数第一行就是:
if (node == nullptr) return;所以递归调用安全。如果你把递归函数的判空删掉,那就等着崩。
另一个常见崩溃是删除双子节点节点时,用了findMin(node->right)但没检查node->right是否为空。双子节点说明左右都有孩子,node->right不可能是nullptr,所以这里不判空也安全。但如果你的代码走到这个分支之前已经错误地删过节点,情况就不一定了。
5.3 内存泄漏:析构没写或者写错
很多新手跑完程序发现内存占用持续增长,就是树的节点没释放。把delete node漏掉,或者clear函数只递归不清除,都会泄漏。
除了写析构函数,还可以加一个计数器验证:
template<typename K> int countNodes(BSTNode<K>* node) const { if (node == nullptr) return 0; return 1 + countNodes(node->left) + countNodes(node->right); }在程序结束前调用,对比你插入的总数减去删除的总数,对不上就是哪里丢了节点。
5.4 删除双子节点后树的结构异常
这种问题最有意思,多半出在“复制后继节点键值”这步。如果你做强删,拿后继节点指针替换当前节点,但忘了处理后继节点的右子树,那棵树就断了。我的建议是坚持复制+递归删,不要用指针替换方案。复制方案最多就是多一次递归,但绝不会出现结构断链。
5.5 调试用的辅助函数集合
下面是我调试树时常用的三个函数,拷过去直接用:
// 打印树的中序遍历 template<typename K> void printInorder(const BSTNode<K>* node) { if (!node) return; printInorder(node->left); std::cout << node->key << " "; printInorder(node->right); } // 计算树高 template<typename K> int treeHeight(const BSTNode<K>* node) { if (!node) return 0; return 1 + std::max(treeHeight(node->left), treeHeight(node->right)); } // 验证是否为合法的二叉搜索树(中序遍历检查) template<typename K> bool isBST(const BSTNode<K>* node, K& prev, bool& first) { if (!node) return true; if (!isBST(node->left, prev, first)) return false; if (!first && node->key <= prev) return false; prev = node->key; first = false; return isBST(node->right, prev, first); }isBST这个函数用中序遍历天然有序的性质来验证,比递归比较min/max的方式直观得多,我强烈推荐。
5.6 踩坑实录:有序插入导致斜树后的性能灾难
有次我在测试时往树里插入1到100000的有序序列,插入后就发现后面查找一个值慢到离谱。算一下:每个节点只有右孩子,树高等于100000,查找最后一个数要比较10万次,完全退化成了链表。
这个问题不是代码能解决的,是二叉搜索树的物理特性决定的。解决方向是让树平衡,比如在插入后检测平衡因子做旋转,或者直接用C++标准库的std::map、std::set,它们在底层用红黑树保证平衡。手写普通二叉搜索树做算法演示、理解原理、应付面试没问题,生产环境还是交给经过长期验证的标准库比较稳妥。
6. 从二叉搜索树到进阶结构的拓展思路
6.1 为什么二叉搜索树不够用:平衡问题的本质
当我面完一堆二叉树题目后,最大的感悟是:普通二叉搜索树的最大问题就是不平衡。平衡是指左右子树的高度差不要太大,最理想是任意节点左右子树高度差不超1,树的查询、插入、删除都稳定在对数级别。
旋转是解决失衡的核心手段:左旋、右旋、先左后右、先右后左。AVL树就是严格平衡的二叉搜索树,插入删除后检查平衡因子,失衡就旋转修正。红黑树是弱平衡,通过节点颜色约束从根到叶子的路径,保证任意路径长度不超过最短路径的2倍。
如果你把本文的插入、删除代码理解透彻,再去学AVL树的旋转会非常快,因为旋转本质就是在改变指针指向,而你已经掌握了“修改指针指向必须用引用”这个关键技巧。
6.2 顺序统计量:找第K小元素
二叉搜索树可以扩展出顺序统计量的功能,就是在节点里增加一个size字段记录子树节点数。查找“第K小的元素”时:
- 左子树的节点数加上1,得到当前节点在中序遍历中的位置
- 如果K等于这个位置,返回当前节点
- 如果K小于这个位置,去左子树找
- 如果K大于这个位置,去右子树找,K减去已经跨越的节点数
这个功能的实现比遍历全树找第K个高效得多,能到O(log n),应用在排行榜、中位数查询等场景。我需要强调一下:加size字段时,插入删除后都要同步更新祖先节点的size,这也是一个容易漏的细节。
6.3 伸展树和Treap:另两条路
伸展树每次访问后把访问节点旋转到根,利用局部性原理,经常访问的节点越用越快。Treap是树和堆的结合,每个节点带一个随机优先级,通过旋转维护堆性质的同时保持搜索树性质,随机化让树期望平衡。
这些树都不难,但都需要旋转操作。再次强调,旋转操作的本质就是指针的替换,理解了引用传参,一切都迎刃而解。
6.4 工程实践中的选择
数据量小、逻辑简单、不要求平衡,直接手写二叉搜索树没问题;数据量上万且要求稳定性能,直接std::map;需要写B树索引、数据库存储引擎,那要专门研究磁盘友好的树结构。
我自己的习惯是:面试和学习阶段手撕二叉树,项目里能用STL就用STL,真要自己实现树型结构,优先选Treap,因为代码量小且期望性能好。每个人可以根据自己的场景做选择,但底层原理一定要懂,因为你不知道哪一天就会碰到必须自己动手设计树结构的需求。
我个人在实际操作中的体会是:二叉搜索树的代码写三遍都不嫌多。第一遍照着抄,第二遍合上书默写,第三遍完全不用看参考直接写出来并能讲解每一行为什么这么写,这时候才算真正掌握。写代码的过程中,卡在删除双子节点的情况是最正常的,那说明你真的在理解指针怎么接,而不是背代码。如果能顺着这篇文章的思路把每个函数的每一步都验证一遍,后面遇到AVL、红黑树、B树,你都会觉得是在已有基础上做加法,而不是从零开始。