资讯详情

C++二叉搜索树(BST)实现:插入、删除、遍历与防退化

📅 2026/10/12 2:15:18 | 华诺云谱 👁 阅读
C++二叉搜索树(BST)实现:插入、删除、遍历与防退化
1. 为什么每个C开发者都应该把BST吃透二叉搜索树BST这个数据结构说陌生也陌生说熟悉也算熟悉。很多人刷题时写过几十遍插入删除但工作三五年后真的让你在工程里手写一棵BST反而容易卡壳——节点释放顺序忘了、递归返回条件写错、删除双子树节点时指针悬空了。这些东西基础不牢现场写就是灾难现场。这篇文章我会用C完整拆解BST的定义、节点设计、插入、查找、删除、遍历以及它为什么会退化、退化后怎么办。没有废话直接上手。适合刚学数据结构的人跟着敲一遍也适合准备面试的人把细节补全。先说清楚BST能解决什么问题。它是一个把“有序性”和“动态性”结合得非常好的容器插入、删除、查找的平均时间复杂度都是O(log n)同时它还能在任意时刻以O(n)的时间输出有序序列。这个组合特性数组做不到链表做不到哈希表也做不到。哈希表查找是O(1)但没法有序遍历数组有序遍历很快但插入删除是O(n)。BST恰好站在中间。这也解释了为什么工程领域到处都有BST的影子。很多数据库的索引结构、内存分配器中的空闲块管理、编译器符号表甚至标准库里的某些容器实现底层都是BST或它的变体。可以说吃透BST你才算真正迈进了数据结构的大门后面看平衡树、红黑树、B树都会顺很多。2. 先搞清楚BST到底是个什么结构2.1 三条性质缺一不可BST不是什么高深的东西它的定义就三条左子树的所有节点值都小于根节点值右子树的所有节点值都大于根节点值左右子树本身也必须是二叉搜索树注意这里说的是“所有节点”不是“直接子节点”。也就是说根节点的左子树里无论多深的节点都必须比根小右子树里无论多深的节点都必须比根大。这条性质如果你只看直接子节点很容易在写代码时漏掉一些隐蔽的bug。举个例子一棵树如果根节点是10左子树的根是8这个8的右孩子是9那这棵树仍然满足BST约束因为9小于10。但如果你在插入时逻辑写错了让9跑到了10的右子树里那就坏了。后面我会专门讲怎么验证一棵树到底是不是合法的BST。还有一个容易忽略的点相等的值怎么办。大多数教材默认不含重复值但工程里经常需要处理重复。常见的策略有两种一种是拒绝插入重复值另一种是给每个节点加一个计数字段。我强烈建议你在实现初期就把这个决策定下来否则后面删除逻辑会很拧巴。我自己写练习代码时一般默认不重复除非题目要求支持多重集合。2.2 BST的形态特征它不是一棵“规整”的树很多人第一次画BST时有个误解觉得它长得像满二叉树或者完全二叉树。其实完全不是。BST只是满足有序性约束形状上可以五花八门可以很矮很胖也可以很高很瘦。这取决于插入顺序。比如插入序列{5, 3, 7, 2, 4, 6, 8}得到的是一个比较平衡的树。但如果插入序列是{1, 2, 3, 4, 5, 6, 7}那树就变成一条向右倾斜的链了。这就是BST最让人头疼的问题——退化。后面我会专门分析退化是怎么发生的以及它到底多危险。理解这一点很重要BST的时间复杂度O(log n)是基于树的高度h来算的准确说各种操作是O(h)只有当树比较平衡时h才约等于log n。一旦退化成链hn所有操作全部退化到O(n)跟线性表没区别还不如用数组。3. C节点设计与内存管理第一个坑从这里开始3.1 裸指针还是智能指针教科书里最常见的节点定义长这样template typename Key struct BSTNode { Key key; BSTNode* left; BSTNode* right; BSTNode(const Key k) : key(k), left(nullptr), right(nullptr) {} };这是最经典的教学写法简单、直观、好理解。但工程里直接这么用会有两个问题一是析构时要手动递归释放所有节点写不好就内存泄漏二是拷贝、赋值的时候遇到深拷贝容易出错。所以也有很多人推荐用std::unique_ptr来管理左右子树template typename Key struct BSTNode { Key key; std::unique_ptrBSTNode left; std::unique_ptrBSTNode right; };unique_ptr的好处是根节点析构时会连带析构left和right实现自动的递归释放省去手动写析构函数的麻烦。实测下来对于树这种天然递归结构用unique_ptr管理生命周期非常省心而且比shared_ptr更合适因为一棵树的子节点所有权是唯一的不存在共享场景。但我必须提醒一句如果你是想搞清楚算法本身而不是写生产级代码建议先用裸指针把逻辑撸清楚。因为智能指针会引入move语义、所有权转移这些额外概念对新手反而形成干扰。等你把递归逻辑吃透了再切换到unique_ptr去写一个能上生产环境的高质量实现会顺手很多。3.2 节点的key类型模板化设计实际写BST时最好一开始就设计成模板类别写死成int。因为工程里你需要排序的可能是字符串、自定义对象或者带custom comparator的数据类型。template typename Key, typename Compare std::lessKey class BST { private: struct Node { Key key; Node* left; Node* right; Node(const Key k) : key(k), left(nullptr), right(nullptr) {} }; Node* root; Compare comp; public: BST() : root(nullptr) {} ~BST() { clear(root); } };把比较器也做成模板参数好处是你可以自由决定排序规则。比如默认用std::lessKey让小的在左你也可以传一个自定义的std::greaterKey实现大的在左。有了Compare你就能在不改动核心逻辑的情况下让BST服务于不同的业务需求这就是泛型设计的意义。默认比较器用comp(a, b)来表示“a小于b”比我直接写a b要好。因为对于自定义类型可能没有重载运算符但你可以传一个lambda或函数对象进去。我见过不少人在这个细节上翻车写死运算符结果类型一换就得重写整个类。3.3 析构递归的栈溢出问题裸指针版本析构时一般这么写void clear(Node* node) { if (!node) return; clear(node-left); clear(node-right); delete node; }逻辑很明显先递归删除左子树再递归删除右子树最后删除自己。但这里有个很容易被忽视的工程问题——如果树的高度非常大比如退化成一条有一百万个节点的链递归析构会直接爆栈。我自己真踩过这个坑。当时在测试环境里插入了一百万个有序数据程序跑完准备退出时莫名其妙崩了查了半天才发现是析构函数的递归深度过大。后来改成用栈做非递归释放void clearIterative(Node* root) { std::stackNode* st; if (root) st.push(root); while (!st.empty()) { Node* cur st.top(); st.pop(); if (cur-left) st.push(cur-left); if (cur-right) st.push(cur-right); delete cur; } }这个方法的本质是不管遍历顺序只要保证先push子节点再delete当前节点就能确保一个节点被删除时它的子节点信息还没有丢。对于普通高度的树两个写法都一样对于极端退化的树非递归版本稳稳的。同样的问题也出现在其他递归操作里下面我讲到插入删除时还会再说一遍因为这是BST实现中最容易被忽视的细节之一。4. 插入与查找把握住“递归终点”就成功了一半4.1 插入不是“随便放”而是“一路找位置”BST插入的规则非常简单从根节点出发如果待插入的值比当前节点小就往左走比当前节点大就往右走直到走到空位置挂上新节点。这个思路跟二分查找很像但它的价值在于插入过程中不需要调整树的结构新节点永远是叶子或接近叶子的位置。这句话听起来平淡其实非常关键。正因为插入是原地查找加挂载所以它的平均代价就是查找链的长度O(log n)。递归写法是这样的Node* insert(Node* node, const Key key) { if (!node) { return new Node(key); } if (comp(key, node-key)) { node-left insert(node-left, key); } else if (comp(node-key, key)) { node-right insert(node-right, key); } else { // key已存在 return node; } return node; }注意递归的返回值——每一层都返回当前子树的根。这个模式非常典型你后面写删除、查找也会反复见到。它的好处是不需要在递归前保存父节点指针也不需要在外部手动处理“当前节点为空”的情况代码简洁且逻辑正确。如果你不喜欢递归也可以用迭代写void insertIterative(const Key key) { if (!root) { root new Node(key); return; } Node* cur root; while (true) { if (comp(key, cur-key)) { if (cur-left) { cur cur-left; } else { cur-left new Node(key); return; } } else if (comp(cur-key, key)) { if (cur-right) { cur cur-right; } else { cur-right new Node(key); return; } } else { return; // 已存在 } } }迭代的好处是没有函数调用开销、不担心栈溢出坏处是代码啰嗦一点而且需要小心访问空指针。我的建议是学习阶段两种都写一遍。考试面试时递归更好写工程里如果树可能特别深用迭代更稳妥。4.2 查找跟你说个反直觉的事查找是最简单的操作大部分人都会写Node* search(Node* node, const Key key) const { if (!node) return nullptr; if (comp(key, node-key)) return search(node-left, key); if (comp(node-key, key)) return search(node-right, key); return node; }这段代码没有问题但我要说一个反直觉的事BST的中序遍历结果是升序的但查找时你绝对不能去中序遍历找。很多人刚学时脑子转不过来觉得“既然有序那我遍历一遍不就行了”实际上那样就完全失去了BST的意义。查找的正确做法永远是沿着一条路径往下走每走一步舍弃一半的子树这样才能做到O(h)。另外工程里经常还要支持查找最小值、最大值Node* findMin(Node* node) const { if (!node) return nullptr; while (node-left) node node-left; return node; } Node* findMax(Node* node) const { if (!node) return nullptr; while (node-right) node node-right; return node; }思路就是“一路往左走到底”/“一路往右走到底”。这个操作后面删除双子树节点时会用到所以提前熟练掌握很重要。4.3 为什么比较器一致性是隐藏的地雷这是我在实际项目中踩过的一个比较隐蔽的坑插入时用的比较器和查找时用的比较器如果不一致查找会直接失败。举个例子我用某个字段A作为key建树插入时比较器按A升序但后来查找时我不小心传了一个按A降序的比较器结果明明存在的key却返回false。原因很简单BST的树形结构完全依赖于比较器不同比较器看到的“左右方向”是相反的查找路径自然对不上。所以写树形结构的时候最好把比较器存成类内部的一个成员插入查找删除全部共用同一个实例千万别图省事在函数里临时构造。这个经验不只是BST适用所有基于比较的容器都有这个问题。5. 删除节点BST里最容易写崩的操作5.1 三种情况分别处理删除是BST操作里最复杂的一个因为你需要保证“删除后仍然是一棵BST”。按照删除节点的子节点数量可以分为三种情况处理叶子节点直接删掉把父节点指向它的指针置空。只有一个子节点用子节点顶替被删节点的位置。有两个子节点这个最麻烦常见做法是用“前驱”或“后继”替换被删节点然后递归删除那个前驱/后继。为什么双子树节点要这么处理因为被删节点有两个孩子如果直接删除留下的两个子树无法同时挂在父节点的一个指针上。而如果随便拿一个节点顶替树可能不再满足BST性质。所以聪明的做法是找到中序遍历顺序中紧挨着它的那个节点——也就是左子树的最大值前驱或者右子树的最小值后继——用它的值覆盖当前节点然后去删除那个前驱/后继节点。由于前驱/后继节点最多只有一个子树前驱不可能有右子树后继不可能有左子树所以对它们的删除就退化成了情况一或情况二难度大大降低。5.2 递归删除的经典实现Node* remove(Node* node, const Key key) { if (!node) return nullptr; if (comp(key, node-key)) { node-left remove(node-left, key); } else if (comp(node-key, key)) { node-right remove(node-right, key); } else { // 找到目标节点 if (!node-left) { Node* rightChild node-right; delete node; return rightChild; } if (!node-right) { Node* leftChild node-left; delete node; return leftChild; } // 有两个子节点用后继替换 Node* successor findMin(node-right); node-key successor-key; node-right remove(node-right, successor-key); } return node; }这个代码看起来很短但里面全是细节。第一处细节返回值的含义。每一层递归把“处理完后的子树根”返回给上一层上一层再用node-left ...或node-right ...接住。这样最妙的地方在于删除根节点时不需要额外的逻辑整个树的根可能被替换外部调用只需要写root remove(root, key)即可。第二处细节处理单子树节点时不需要把父节点的指针单独记录下来。因为当node-left为空时我直接返回node-right上一层的指针自然接到了这个右子节点上被删节点的内存也释放了。第三处细节双子树节点替换后我传下去删除的key是成功者的key而不是原来的key。因为原始key可能已经不在树里了。很多人写到这里容易犯迷糊把remove(node-right, successor-key)写成remove(node-right, key)那样就永远删除不到目标节点死循环递归。5.3 用前驱还是后继有讲究但没那么讲究处理双子树节点时选前驱还是选后继从BST性质来说都可以结果都合法。但选谁会影响树的高度变化进而影响后续操作的性能。我自己的习惯是选后继因为后继一定在被删节点的右子树中而且后继是右子树的最小值。这条路径往左走到底通常不会让树变得更不平衡。但也有不少人说选前驱更稳因为可以复用上面的findMin逻辑反过来实现findMax。其实都行别纠结。真正需要注意的是替换的时候不要真的删除后继节点再把它的值搬过来那样可能会再次触发双子树的情况导致递归删除自己。上面代码的处理方式已经规避了这个问题先把后继的值拷贝过来然后删除右子树中的后继节点。因为后继节点最多只有一个右孩子所以那一层删除不会再次进入双子树分支。5.4 删除操作最常见的三类错误第一类忘了释放内存。在C里delete node;不是可选项。你如果只调整指针不释放节点程序跑起来没问题但内存会一点点泄漏。树一大数据量一多内存占用肉眼可见地涨。第二类返回节点的时机不对。有些人先删了节点再返回结果返回的是一个野指针。上面代码里我把rightChild先存下来再delete node最后返回rightChild顺序不能反。第三类delete了之后还访问它。比如有人写成if (node-left nullptr) { delete node; return node-right; // 错误node已经被释放 }这在某些编译器下可能“碰巧能用”但属于未定义行为换个编译器或加个优化就崩。记住一句话释放后的指针千万别再碰。6. 遍历BST的有序性是它最大的财富6.1 中序遍历为什么重要BST最迷人的特性就是“中序遍历即有序序列”。因为中序遍历的顺序是“左子树、根、右子树”结合BST左小右大的性质天然就是从小到大。void inorder(Node* node, std::vectorKey result) const { if (!node) return; inorder(node-left, result); result.push_back(node-key); inorder(node-right, result); }这个特性在工程里非常值钱。举个例子你有一个用户ID的BST想按ID升序输出所有用户一行递归就搞定了不需要额外的排序操作。在很多需要有序输出的场景里BST可以直接替代“插入后排序”的老办法。中序遍历还能配合操作实现“范围查询”。比如找出 [L, R] 之间的所有元素只需要按中序遍历遇到小于L就剪掉左子树、遇到大于R就剪掉右子树复杂度远低于全量遍历。6.2 前序、后序和层序怎么实现前序遍历的顺序是“根、左、右”通常用于树的序列化也就是把树保存到文件或网络中。后序遍历的顺序是“左、右、根”常用于先处理子树再处理根的场景比如前面写的析构释放。层序遍历是按层从上到下、从左到右输出需要用队列void levelOrder(Node* root) const { if (!root) return; std::queueNode* q; q.push(root); while (!q.empty()) { Node* cur q.front(); q.pop(); std::cout cur-key ; if (cur-left) q.push(cur-left); if (cur-right) q.push(cur-right); } }层序遍历在工程中常用于树的可视化、按层统计等场景。如果面试时让你“之字形打印二叉树”也就是偶数层从左到右、奇数层从右到左核心也是层序遍历再根据层号决定反转方向即可。6.3 非递归中序遍历想通栈的入栈时机就不难递归中序很优雅但同样的栈溢出问题也存在。非递归版本用显式栈模拟递归核心是模拟“先进入左子树打印再进入右子树”void inorderIterative(Node* root) const { std::stackNode* st; Node* cur root; while (cur || !st.empty()) { while (cur) { st.push(cur); cur cur-left; } cur st.top(); st.pop(); std::cout cur-key ; cur cur-right; } }不理解这段代码的人我建议你拿一棵小树手动画一遍。核心思想是“只要左子树没访问完就一直往左压栈左子树到底了弹栈访问根然后转向右子树”。这个过程跟递归版的调用栈几乎一一对应区别只是显式栈由你自己控制。还有一种更高阶的遍历技巧叫“莫里斯遍历”利用叶子节点的空指针制造临时的线索指针不需要栈空间复杂度O(1)。但实现比较复杂还涉及修改树结构再恢复面试中属于加分项。我建议先把前三种遍历吃透莫里斯作为扩展了解即可。7. 复杂度与退化为什么你的BST突然变慢了7.1 平均复杂度到底怎么算出来的BST的核心操作无论是查找、插入还是删除本质上都沿着从根到叶的一条路径工作耗时由路径长度决定。所以这些操作的时间复杂度都是O(h)h是树高。如果树是平衡的高度h约等于O(log n)因为每一层最多容纳2^k个节点n个节点的完全平衡树高度约为log2(n1)。这时候查找、插入、删除都是O(log n)。如果树不平衡比如退化成链h就等于n所有操作变成O(n)。所以评价BST性能的关键指标不是n而是h。这也是为什么很多同学在数据结构课上觉得BST很快但自己用有序数据测试时发现慢到怀疑人生——因为他们测的是最坏情况而教材讲的是平均情况。7.2 什么插入顺序会让树退化最典型的退化场景就是“有序插入”。插入序列{1,2,3,4,5}每个新节点都比前一个节点大算法每次都往右走到底最后长出右斜树反之插入{5,4,3,2,1}则得到左斜树。还有其他模式也会导致严重不平衡。比如插入序列{c, a, e, b, d, f}虽然不完全有序但长得也相当“高”。总而言之BST的形状完全由插入顺序决定你无法控制用户的数据怎么来。7.3 解决退化的两条路线一条路线是“随机化”。比如在插入时对某些操作引入随机因子使得树的期望高度是O(log n)。但这种方法在工程里用得不多因为随机性更难以预测。另一条路线是“构建平衡树”。这是真正的主流——在BST的基础上增加平衡约束例如AVL树要求任何节点的左右子树高度差不超过1红黑树则通过颜色标记和旋转维护近似平衡。两者都是O(log n)级别AVL更严格所以查找更快但插入删除时旋转更多红黑树平衡略松但是调整代价更低所以被广泛用于工程实际。很多标准库容器的底层就是红黑树比如std::map、std::set。所以你真的要在工程里用有序动态容器直接使用标准库就是最优解。自己手写BST更多是为了学习原理或应对特殊场景比如实现一个自定义的、按业务规则比较的有序结构。明白了这一点你就不用再困惑“为什么我不用std::map非要自己写BST”。因为前者是工业级成品后者是你的教学工具。但反过来如果你不亲手实现一遍可能一辈子也不知道红黑树为什么要旋转、为什么每个节点要带颜色。BST就是那片地基。8. 调试BST的高效方法与常见问题速查8.1 写一个直观的可视化打印省下无数调试时间调试二叉树最痛苦的是结构不直观。断点看指针只能看到一个个节点地址根本看不出树的形状。我强烈建议你写一个小工具函数把树打印成带有缩进的文本结构void dump(Node* node, int depth 0) { if (!node) return; dump(node-right, depth 1); for (int i 0; i depth; i) std::cout ; std::cout node-key std::endl; dump(node-left, depth 1); }输出时右子树先打印、深度越大的节点缩进越多所以实际打印出来树的右半边在屏幕上偏上左半边偏下整体的层次关系一目了然。调试时插入一个dum()基本能看清90%的树形问题。8.2 写一个BST性质验证器当你对某次删除后的树存在怀疑时不要靠肉眼硬看直接跑一个递归验证器它能检查“左子树所有节点小于根、右子树所有节点大于根”的性质bool isValid(Node* node, const Key* minKey, const Key* maxKey) { if (!node) return true; if (minKey !comp(*minKey, node-key)) return false; if (maxKey !comp(node-key, *maxKey)) return false; return isValid(node-left, minKey, node-key) isValid(node-right, node-key, maxKey); }思路是给每个子树传一个数值区间左子树的所有节点必须在(minKey, 父节点key)之间右子树必须在(父节点key, maxKey)之间。一旦发现越界立刻返回false。这个验证器在写完删除逻辑后跑一遍随机插入随机删除的测试能帮你发现大量边界问题。8.3 常见问题速查表问题现象可能原因解决思路删除后树形错乱或丢数据双子树节点处理不当后继/前驱删除位置错误打印删除前树形单步走递归确认返回值程序崩溃报空指针访问删除节点后没有正确接回子树检查每个return分支确保空子树返回另一个子树插入大量数据后内存暴涨析构未递归释放节点写clear函数或改用智能指针查找明明存在的key却返回null比较器不一致或key类型比较逻辑错误确认所有操作使用同一个比较器深树递归时栈溢出递归深度过大非递归实现插入删除析构或先做平衡化delete后程序偶发崩溃释放后仍访问该内存遵守“释放后置空”的习惯不要在delete后读取成员8.4 用随机测试把代码“磨”稳我写树相关的代码有个习惯写完以后不急着提交先做随机测试。随机生成几千个数字依次插入BST用搜索验证存在性再随机删除其中一半每删一次都用isValid检查树的性质最后删除完检查树为空。这个过程能覆盖大量边界情况比你自己徒手想用例靠谱得多。C里用std::mt19937生成随机数加上上面写的dump和isValid基本上能把写树代码时的常见bug全都逼出来。我自己带过一些同学做项目很多人一开始不习惯这种“测试驱动”的方式但用了之后都说真香。9. 再补充一个工程向的思考STL容器与自研BST的取舍聊到这儿很多读者可能会问既然标准库已经有std::map、std::set为什么还要自己写BST我的回答是分场景。如果你只是需要一个“有序容器”直接用标准库别折腾标准库的底层是红黑树性能、异常安全性、内存管理都不是自己能轻易超越的。但如果你是学习数据结构、参加算法面试或者需要在极特殊的约束下定制行为那手写BST就是必须练的基本功。面试时手写BST的概率极高。而且面试官不会只让你写出插入删除他往往还会追问删除双子树节点时为什么用后继递归和迭代各有什么优势和隐患怎么判断一棵树是不是BST这些问题如果你只是背代码被追问两三轮就会露馅。所以本文前面讲的那些“为什么”才是真正的核心干货。我自己当年第一次写删除时也花了很长时间才彻底搞明白“返回值接回”这个设计。后来教的同学多了发现大家的困惑点都差不多。如果你读到这里还有一些地方没想通别急拿纸画一棵小树跑一遍每一行代码多画几棵就通了。这比反复看文章有效得多。另外提醒一点练习时建议把递归版和迭代版的插入删除都实现一遍然后丢进随机测试里一起跑。递归版帮你理解逻辑迭代版帮你理解栈和指针操作两者互为佐证。等你两个版本都能一次写对BST这一关就算真正过了。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑