news 2026/10/2 3:09:28

【C++】 二叉搜索树的实现

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
【C++】 二叉搜索树的实现

前言

二叉搜索树(Binary Search Tree,BST)是入门数据结构时第一个"带约束的树"。它解决的问题很朴素:在一堆键里快速找到某一个。相比线性表,它把查找从"逐个比对"变成了"每次砍掉一半",前提是这棵树保持有序结构。

关于 BST,网上流传的说法里有两个不太准确:

其一,"BST 查找是 O(log n)"。严格说,这是平均情况,而且这个平均值依赖于输入分布;如果按有序序列插入,BST 会退化成一条链,查找退化成 O(n)。标准库的std::set/std::map之所以可靠,是因为它们用的是自平衡的树(主流实现都是红黑树),而手写 BST 没有这个保证。

其二,"中序遍历 BST 就得到有序序列"。这句话本身对,但它成立的前提是这是一棵 BST,而不是任意二叉树。对普通二叉树做中序遍历,得到的只是一串按位置访问的节点。

本文用 C++17 实现一棵完整的、能编译能跑的 BST,涵盖插入、查找、删除、遍历,并把指针管理与内存安全一起讲清楚。

一、原理:有序性从哪来

BST 的约束只有一条,但它是递归定义的:


  • 对任意节点,其左子树中所有键都小于该节点的键;

  • 其右子树中所有键都大于该节点的键;

  • 左右子树各自也是 BST。


这条约束带来一个直接推论:沿任意路径向下走,键的大小关系是单调的。查找时比较一次即可决定往哪边走,因此一次比较排除一整棵子树。

操作平均时间最坏时间(退化成链)说明
查找O(log n)O(n)每次比较排除一半子树
插入O(log n)O(n)先查找插入位置,再挂新节点
删除O(log n)O(n)需要处理三种情形
中序遍历O(n)O(n)每个节点恰好访问一次
空间O(n)O(n)每个节点一份额外指针开销

注意"平均 O(log n)"是随机插入下的期望值,不是标准保证。要拿到稳定的对数上界,必须用平衡树。

二、节点与所有权:为什么用 std::unique_ptr

手写树最容易出的问题不是算法,而是内存。节点是new出来的,谁负责delete?如果每个节点用裸指针,就必须在析构函数里手写递归删除,中途任何一个return都可能漏掉一棵子树。

C++11 起可以用std::unique_ptr表达"父节点独占持有子节点"这个语义:unique_ptr的析构函数会自动delete它持有的对象,而该对象的析构又会级联释放它的左右孩子。整棵树的释放是自动的,不需要手写destroy函数。

代价是删除操作的写法要稍微绕一下:不能直接delete一个由unique_ptr持有的节点,必须让持有它的那个unique_ptr放弃所有权(reset)或转移所有权(std::move)。

这里有一个看起来很危险、实际安全的小技巧:写p = std::move(p->right)时,赋值会释放p原来持有的节点,而p->right正是这个节点的一个成员。之所以安全,是因为unique_ptr的移动赋值被规定为等价于先release源指针、再reset目标——释放动作发生时,源unique_ptr已经是空的了,不会二次释放。这个写法是移动语义的经典用法,不是 UB。

三、完整实现

下面的代码是完整、可编译的。请存成bst.cpp,用g++ -std=c++17 -Wall -Wextra或对应 MSVC 开关编译。

// bst.cpp — C++17 #include <cstddef> #include <initializer_list> #include <iostream> #include <memory> template<typename Key> class Bst { public: // 插入:键已存在返回 false bool insert(const Key& k) { if (insertImpl(root_, k)) { ++count_; return true; } return false; } // 查找 bool contains(const Key& k) const { const Node* cur = root_.get(); while (cur) { if (k < cur->key) cur = cur->left.get(); else if (cur->key < k) cur = cur->right.get(); else return true; } return false; } // 删除:键不存在返回 false bool erase(const Key& k) { if (eraseImpl(root_, k)) { --count_; return true; } return false; } // 中序遍历(升序) void inorder() const { inorderImpl(root_.get()); } void inorderImpl(const Node* p) const { if (!p) return; inorderImpl(p->left.get()); std::cout << p->key << ' '; inorderImpl(p->right.get()); } std::size_t size() const { return count_; } bool empty() const { return root_ == nullptr; } private: struct Node { explicit Node(const Key& k) : key(k) {} Key key; std::unique_ptr<Node> left; std::unique_ptr<Node> right; }; static bool insertImpl(std::unique_ptr<Node>& p, const Key& k) { if (!p) { p = std::make_unique<Node>(k); return true; } if (k < p->key) return insertImpl(p->left, k); // 小者左走 if (p->key < k) return insertImpl(p->right, k); // 大者右走 return false; // 相等:视为重复 } // 摘掉以 p 为根子树中键最小的节点(该节点必无左孩子) static void eraseMin(std::unique_ptr<Node>& p) { if (!p->left) { p = std::move(p->right); return; } eraseMin(p->left); } static bool eraseImpl(std::unique_ptr<Node>& p, const Key& k) { if (!p) return false; if (k < p->key) return eraseImpl(p->left, k); if (p->key < k) return eraseImpl(p->right, k); // 找到目标,分三种情形 if (!p->left && !p->right) { // 情形一:叶子 p.reset(); } else if (!p->left) { // 情形二:只有右孩子 p = std::move(p->right); } else if (!p->right) { // 情形二:只有左孩子 p = std::move(p->left); } else { // 情形三:两个孩子 // 用右子树最小键(中序后继)顶替,再摘掉那个节点 const Node* succ = p->right.get(); while (succ->left) succ = succ->left; Key succKey = succ->key; eraseMin(p->right); p->key = succKey; } return true; } std::unique_ptr<Node> root_; std::size_t count_ = 0; // 与真实节点数保持同步 }; int main() { Bst<int> t; for (int k : {50, 30, 70, 20, 40, 60, 80, 30}) { std::cout << "insert " << k << " -> " << (t.insert(k) ? "ok" : "dup") << '\n'; } t.inorder(); // 20 30 40 50 60 70 80 std::cout << '\n'; std::cout << std::boolalpha << t.contains(40) << ' ' // true << t.contains(41) << '\n'; // false t.erase(20); // 叶子 t.erase(30); // 一个孩子 t.erase(50); // 两个孩子 t.inorder(); // 40 60 70 80 std::cout << '\n'; }

运行输出:

insert 50 -> ok insert 30 -> ok insert 70 -> ok insert 20 -> ok insert 40 -> ok insert 60 -> ok insert 80 -> ok insert 30 -> dup 20 30 40 50 60 70 80 true false 40 60 70 80

3.1 删除的三种情形

删除是 BST 里最容易写错的部分,标准做法按孩子数量分三类:

情形孩子数处理方式为什么
一0(叶子)直接摘掉不影响其他节点
二1用唯一的孩子顶替自己子树整体上移,仍然满足 BST 约束
三2用中序后继(右子树最小键)或中序前驱(左子树最大键)的键顶替,再删除那个节点后继比左子树全部大、比右子树其余全部小,顶替后约束不变

情形三里,被摘掉的节点一定没有左孩子(因为它是最小值),所以它的删除必然退化成情形一或情形二,不会无限递归。

3.2 遍历方式对照

遍历访问顺序对 BST 的结果
前序 preorder根 → 左 → 右可用于序列化/复制
中序 inorder左 → 根 → 右键升序
后序 postorder左 → 右 → 根适合自底向上释放/求值
层序 level-order逐层需要队列辅助

常见坑点

1. 删除两个孩子节点时直接摘掉,丢掉一棵子树

❌ 看到两个孩子就p.reset(),右子树连同其中的节点一起没了。

✅ 用中序后继的键顶替,再删除后继节点;被删的后继节点没有左孩子。

2. 保存了节点指针,删除后继续用

❌ 先记下指向某节点的裸指针,然后调用erase,之后还用这个指针访问其key。erase释放该节点后,这个指针悬垂(dangling),解引用它是 UB,标准不保证任何行为。

✅ 需要跨erase保留信息时,先把键值拷贝出来。

3. 有序输入导致递归深度爆栈

❌ 依次插入 1、2、3、…、1000000,树退化成链,递归insertImpl的深度等于元素个数,栈溢出。

✅ 随机化插入顺序;或改用迭代写法;或直接用自平衡的std::set。

4. 深树析构也会爆栈

❌ 以为"析构是自动的所以没问题"。unique_ptr的删除器是递归的:释放根 → 释放左孩子 → 释放左孩子的左孩子……深度同样等于树高,退化成链时依然可能栈溢出。

✅ 极端场景下要自写迭代式释放(把子节点先摘到显式栈里)。

5. 用浮点数当键

❌ 用double作Key,插入 NaN。NaN 与任何值比较都返回 false,k < p->key和p->key < k同时为假,于是会被当成"重复键"处理,语义完全乱掉。

✅ 键类型必须能构成严格弱序(strict weak ordering);确实要用浮点时,先定义好 NaN 的处理规则。

6. 计数器与真实节点数不同步

❌ 只在insert里++count_,erase里忘了--count_,size()与实际节点数越差越多。

✅ 让count_在每条修改路径上都更新,或者干脆去掉它、靠遍历统计(代价是 O(n))。

7. 认为"中序遍历有序"对任意二叉树成立

❌ 对一棵普通二叉树做中序遍历,然后宣称结果已排序。

✅ 该性质只对满足 BST 约束的树成立。调试时先写一个isBst校验函数,比盯着输出猜要快得多。

8. 手写 BST 却没做平衡,还指望它有对数性能

❌ 在生产代码里用自写 BST 存用户可控的键,键恰好大致有序时性能退化。

✅ 直接使用std::set/std::map(libstdc++、libc++、MSVC STL 的实现都是红黑树),或者需要更强查找局部性时考虑 B 树 / 跳表。

总结

要点结论
核心约束左子树全部小、右子树全部大,递归成立
复杂度平均 O(log n),最坏 O(n),取决于树高
内存管理unique_ptr表达独占所有权,析构自动级联,但深树会递归爆栈
删除三种情形,两个孩子时用中序后继顶替
中序遍历升序输出,但仅在满足 BST 约束时成立
生产建议优先std::set/std::map,它们自带平衡


BST 的价值不在于"能存数据",而在于它用一条极简约束换来了可预测的查找路径。真正的工程结论是:有序结构必须配平衡,否则它的性能上界和链表没有区别。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/2 3:09:21

华硕天选2 Ubuntu下RTX 3060驱动深度适配指南

1. 华硕天选2不是“即插即用”的显卡平台&#xff0c;而是需要精细协同的硬件系统华硕天选2&#xff08;TUF Gaming A15 FA506HC/FA506II等型号&#xff09;搭载的是AMD Ryzen 5 4600H或Ryzen 7 4800H处理器&#xff0c;集成AMD Radeon Graphics&#xff08;非Intel HD Graphic…

作者头像 李华
网站建设 2026/10/2 3:08:36

RSTP快速生成树协议详解:从环路故障到毫秒级收敛

1. 先想清楚一个问题&#xff1a;RSTP到底在解决什么麻烦干网络这一行&#xff0c;谁没经历过几次“全网突然卡死、交换机CPU飙到99%、所有灯像呼吸灯一样同步闪烁”的诡异故障&#xff1f;最后翻半天机柜&#xff0c;发现就是一根不起眼的跳线&#xff0c;把交换机的两个端口插…

作者头像 李华
网站建设 2026/10/2 3:08:22

红盟发卡网源码优化版:自动发卡系统部署与避坑实战指南

简介&#xff1a;红盟发卡网系统源码&#xff08;优化版&#xff09;是一套基于PHP与MySQL的虚拟商品发卡系统&#xff0c;面向需要搭建自动发卡平台的站长、个人开发者或小微企业&#xff0c;提供虚拟商品自动售卖、卡密自动发货、订单管理等完整发卡流程&#xff0c;适用于网…

作者头像 李华
网站建设 2026/10/2 3:07:48

PyTorch CNN遥感滑坡识别:小样本、多光谱与距离场监督

简介&#xff1a;本资源是一套基于PyTorch实现的遥感图像滑坡识别系统&#xff0c;面向地理信息科学、遥感技术及人工智能交叉领域的高校学生与科研初学者&#xff0c;解决地质灾害智能解译中的关键识别问题。压缩包共15个文件&#xff0c;含7个核心Python脚本&#xff08;涵盖…

作者头像 李华
网站建设 2026/10/2 3:07:48

S7-1500协同机器人与变频器:自动化产线控制架构与调试指南

1. 这个项目到底在做什么接到这个题目的时候&#xff0c;我第一反应就是——这大概率是一条汽车焊装线或者大型零部件搬运线的控制系统。西门子S7-1500做主站&#xff0c;挂14台发那科机器人&#xff0c;三个SEW变频器驱动四面转台&#xff0c;再加阀岛和一堆外围IO&#xff0c…

作者头像 李华
网站建设 2026/10/2 3:07:22

Kali Linux防火墙配置实战:iptables与nftables渗透防御策略

1. 项目概述&#xff1a;Kali Linux 防火墙配置不是“关掉就完事”的操作&#xff0c;而是渗透测试者必须掌握的主动防御边界控制能力很多人第一次在 Kali 上敲systemctl stop firewalld或iptables -F&#xff0c;以为这就是“配置防火墙”——其实恰恰相反&#xff0c;这是在主…

作者头像 李华