
二叉搜索树这个东西我在不同阶段写过好几版C版是所有语言里最麻烦、也最能逼你把指针和内存管理想清楚的一版。很多教程把概念讲得头头是道一上手写代码就崩二叉搜索树尤其如此——它的核心不在“插入比根节点小就往左走”这句话而在“节点怎么连接”“递归怎么返回”“删除的时候怎么把父节点和孩子节点接上”。这篇文章我会带着大家从零手撕一棵二叉搜索树不是贴一段完整代码让你抄而是拆开每一个函数讲清楚为什么要这么写、递归的返回机制是怎么回事、删除节点的三种情况到底怎么处理、指针引用为什么能简化一大半代码。全程C实现兼顾严格的内存管理所有代码我都用标准C17跑过需要的直接拿去用。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。templatetypename K struct BSTNode { K key; BSTNodeK* left; BSTNodeK* 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); }这里我建议你在打印之外加一个回调函数的版本templatetypename 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只有右孩子BA的父节点之前指向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*。templatetypename K class BinarySearchTree { private: BSTNodeK* root; void insert(BSTNodeK* node, const K key); bool search(BSTNodeK* node, const K key) const; void remove(BSTNodeK* node, const K key); void clear(BSTNodeK* node); BSTNodeK* findMin(BSTNodeK* node) const; void inorder(BSTNodeK* node, std::vectorK 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::vectorK inorderTraversal() const; bool empty() const { return root nullptr; } };析构函数很重要忘记写会导致整棵树的内存泄漏。clear用后序遍历释放所有节点templatetypename K void BinarySearchTreeK::clear(BSTNodeK* node) { if (node ! nullptr) { clear(node-left); clear(node-right); delete node; } }先杀左子树再杀右子树最后删当前节点。顺序不能反过来否则先删当前节点就找不到孩子指针了。3.2 完整可运行的插入实现插入的递归版本前面讲过原理这里给出完整实现并补上重复键的处理templatetypename K void BinarySearchTreeK::insert(BSTNodeK* node, const K key) { if (node nullptr) { node new BSTNodeK(key); return; } if (key node-key) { insert(node-left, key); } else if (key node-key) { insert(node-right, key); } // key相等不插入保证键唯一 }如果你想支持重复键有两个选择节点加一个count计数或者允许重复键但插入时相等就往右走。前者适合统计场景后者会让删除逻辑变复杂。实际工程中大多数场景要求键唯一所以我默认走不插入分支。迭代版插入顺便给一个理解“怎么在原地修改叶子节点的空指针”templatetypename K void BinarySearchTreeK::insertIterative(const K key) { if (root nullptr) { root new BSTNodeK(key); return; } BSTNodeK* cur root; while (true) { if (key cur-key) { if (cur-left nullptr) { cur-left new BSTNodeK(key); return; } cur cur-left; } else if (key cur-key) { if (cur-right nullptr) { cur-right new BSTNodeK(key); return; } cur cur-right; } else { return; // 重复键不插入 } } }这个版本不用引用是因为每轮循环都能拿到父节点的指针直接改父节点的孩子指针即可。3.3 完整可运行的删除实现删除函数承接前面的三种情况完整代码templatetypename K void BinarySearchTreeK::remove(BSTNodeK* 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) { BSTNodeK* temp node-right; delete node; node temp; } else if (node-right nullptr) { BSTNodeK* temp node-left; delete node; node temp; } else { BSTNodeK* 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的简单版本方便测试代码比对输出templatetypename K std::vectorK BinarySearchTreeK::inorderTraversal() const { std::vectorK result; inorder(root, result); return result; } templatetypename K void BinarySearchTreeK::inorder(BSTNodeK* node, std::vectorK out) const { if (node nullptr) return; inorder(node-left, out); out.push_back(node-key); inorder(node-right, out); }验证二叉搜索树正确性有个非常快的办法插入一堆乱序数据然后中序遍历如果输出是升序的说明插入逻辑没问题。删除后再遍历仍然升序说明删除也保持了树的性质。3.5 对象拷贝问题与禁用拷贝写完了基本功能有个C特有的坑必须提醒如果你直接把类对象赋值给另一个对象比如BinarySearchTreeint t2 t1;默认拷贝构造函数做浅拷贝两个对象的root指针指向同一棵树的节点。接下来t2析构时把树删了t1析构时再删一次直接崩。处理方案很简单明确禁止拷贝BinarySearchTree(const BinarySearchTree) delete; BinarySearchTree operator(const BinarySearchTree) delete;如果你确实需要拷贝那就写深拷贝构造函数递归复制每个节点。但对大多数场景禁止拷贝是最省心的选择。移动构造可以留着BinarySearchTreeint t2 std::move(t1);是安全的因为移动后t1的root为nullptr析构没问题。3.6 测试驱动插入、中序、删除一轮验证写完代码要立即可测。我习惯用一组包含各种情况的序列来测比如插入序列[50, 30, 70, 20, 40, 60, 80, 10, 35]这棵树既有单孩子节点也有双子节点删除时能覆盖所有情况。测试流程BinarySearchTreeint bst; std::vectorint 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 环境准备与编译器选择代码基于C17标准随便一个现代编译器都能编译。Windows上我用Visual Studio 2022或者MinGW-w64Linux上g直接编g -stdc17 -Wall -Wextra -O2 -o bst_test bst_test.cppVSCode配置C/C环境的话题在网络上一搜一大堆这里不展开。需要注意的点就两个编译器一定要支持C17gcc 7以上、clang 6以上、MSVC 2017以后都行调试时建议加-g选项生成调试信息方便打断点看指针变化。4. 递归与迭代的对比和选择策略4.1 什么时候选递归什么时候选迭代二叉搜索树相关的操作天然适合递归因为树本身就是递归定义的结构。递归代码写出来跟定义一一对应不容易错。但递归有代价每层调用涉及函数调用开销和栈空间占用。树高100的时候无所谓但如果退化成链表结构递归深度可能到10万甚至100万就会栈溢出。迭代版本在查找和插入场景很好写删除场景复杂很多因为删除时需要修改父节点指针。虽然也能用prev指针维护父节点但代码明显更啰嗦。我的建议查找优先写迭代简单又节省栈空间插入递归和迭代都可以递归更易理解删除优先递归引用传参简化代码实在要迭代务必用父指针跟踪前驱节点遍历递归树遍历用递归更好读4.2 尾递归问题有些语言有尾递归优化递归不涨栈空间。C标准不保证尾递归优化编译器在O2优化下可能会做但依赖编译器行为不靠谱。所以对可能深度很大的树比如插入有序序列导致退化的树迭代查找更稳。4.3 手撕代码时的调试技巧调试二叉搜索树有个习惯我推荐大家养成不管写什么操作先中序遍历看当前树的整体有序性。排查步骤如下插入后中序遍历确认新值出现在正确位置删除后中序遍历确认被删的值消失且剩余元素依然有序检查子树结构是否错乱写一个计算树高的函数辅助观察templatetypename K int height(BSTNodeK* 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函数只递归不清除都会泄漏。除了写析构函数还可以加一个计数器验证templatetypename K int countNodes(BSTNodeK* node) const { if (node nullptr) return 0; return 1 countNodes(node-left) countNodes(node-right); }在程序结束前调用对比你插入的总数减去删除的总数对不上就是哪里丢了节点。5.4 删除双子节点后树的结构异常这种问题最有意思多半出在“复制后继节点键值”这步。如果你做强删拿后继节点指针替换当前节点但忘了处理后继节点的右子树那棵树就断了。我的建议是坚持复制递归删不要用指针替换方案。复制方案最多就是多一次递归但绝不会出现结构断链。5.5 调试用的辅助函数集合下面是我调试树时常用的三个函数拷过去直接用// 打印树的中序遍历 templatetypename K void printInorder(const BSTNodeK* node) { if (!node) return; printInorder(node-left); std::cout node-key ; printInorder(node-right); } // 计算树高 templatetypename K int treeHeight(const BSTNodeK* node) { if (!node) return 0; return 1 std::max(treeHeight(node-left), treeHeight(node-right)); } // 验证是否为合法的二叉搜索树中序遍历检查 templatetypename K bool isBST(const BSTNodeK* 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树你都会觉得是在已有基础上做加法而不是从零开始。