恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
搜索二叉树C++实现:从插入删除到拷贝析构的完整指南
首页
资讯中心
/
搜索二叉树C++实现:从插入删除到拷贝析构的完整指南
搜索二叉树C++实现:从插入删除到拷贝析构的完整指南
发布时间:2026/10/12 3:18:54
前阵子帮人调试一个搜索二叉树的模拟实现代码看起来完整无缺一运行就崩。排查下来发现是经典的浅拷贝问题两个对象共享同一棵树析构时互相抢着释放内存。这类问题在我自己刚开始写搜索二叉树时也踩过不少所以这篇不打算只贴一份能跑的代码而是把我整个实现过程、每一步的设计取舍和容易崩的点全部摊开讲。适合正在学数据结构的同学、准备手撕搜索二叉树的面试者以及想补一补C工程实现细节的老手参考。1. 先明确一下搜索二叉树到底在解决什么问题1.1 三个核心操作与BST本质性质二叉搜索树本质上就是一棵普通二叉树额外加了一条大小约束任意节点的左子树所有节点都比它小右子树所有节点都比它大整体满足左 根 右。有了这条约束查找、插入、删除都可以沿着一条确定路径定向移动平均只需要走树高个节点而不必把全部数据扫一遍。和有序数组做二分查找相比BST最大的优势在于动态性。数组一旦插入或删除元素新节点需要在O(N)时间移动内存而树结构只需要改几个指针就能完成插入和删除时间复杂度只在查找路径上。这也是为什么现代标准库的关联容器普遍使用平衡搜索树系列结构而不是简单数组加二分。当然这个优势是有条件的树必须保持相对平衡否则性能会退化。这点放到后面专门说。搜索二叉树从定义上看有三种核心操作查找、插入、删除。查找是其余操作的基础插入本质是“查找失败时在空位置挂新节点”删除则是在查找成功的基础上处理指针的重新连接。把这个链条理清楚后面写代码会顺畅很多。1.2 一个不太起眼但值得记住的性质中序遍历有序BST最容易被忽略的隐藏性质是中序遍历必然得到递增序列。中序遍历的顺序是“左子树 - 根 - 右子树”恰好和左小右大的约束完全吻合。这个性质看似平凡实际用途极广对一棵BST做排序中序遍历一遍就是有序输出检查一棵树是否真的满足BST性质可以中序遍历后看序列是否严格递增后面学习AVL、红黑树时中序遍历有序也是验证整棵树结构正确性的常用手段。所以我在模拟实现时第一个想写的辅助接口就是InOrder。每次插入、删除完都调一次看输出序列是否严格递增。如果序列乱了说明左右指针接错或者大小比较方向反了可以立刻发现问题不用等到最后一起胡掉。2. 结点与框架设计一个能让后续所有操作都好写的底座2.1 用模板类还是普通类BST中存放的数据可能是整数、字符串甚至是自定义结构体。C的标准做法是写成模板类这样实例化时想存什么类型都可以。但有一个隐藏前提模板参数类型必须支持比较运算。插入和查找都依赖key之间的大小比较所以类型要么内置支持运算要么自定义类型重载了operator。这个细节在工程里经常被忽略往模板里塞一个没有重载比较运算的结构体编译链接都正常运行结果却是乱序排查半天才发现是比较逻辑根本没法用。模板参数我习惯用K表示key类型和一个典型的标准库关联容器保持一致的语义。类内部再typedef出Node别名后续代码能短一截可读性也好一些。2.2 结点定义与类的接口骨架结点的设计这里需要做个选择用原生裸指针还是智能指针。题目场景和面试环境中原生指针是主流因为能把指针操作和生命周期问题看得更清楚工程级代码更倾向智能指针但那属于另一个层面的讨论而且会影响整个类的拷贝语义设计。我这个实现里先用原生指针结点的定义写成内部结构体templateclass K struct BSTreeNode { BSTreeNodeK* _left; BSTreeNodeK* _right; K _key; BSTreeNode(const K key) : _left(nullptr), _right(nullptr), _key(key) {} };接下来是类的大体骨架。构造函数必须初始化_root为nullptr否则默认构造出来的对象里就是一个野指针后面任何操作访问根时都可能崩溃。这个坑非常隐蔽尤其对刚接触C类封装的人找半天发现是构造函数没写初始化特别冤。templateclass K class BSTree { typedef BSTreeNodeK Node; public: BSTree() : _root(nullptr) {} // 查找、插入、删除、中序遍历等公开接口 private: Node* _root; };2.3 查找接口为什么大多数实现都先写Find查找是三个核心操作中最简单的但它也是验证结点连接方式是否正确、以及观察树成长情况的最佳入口。非递归查找用一个cur指针从根出发循环向左或向右移动遇到目标key返回true走到空指针说明没找到。建议先实现它还有一个原因插入操作的前半段完全复用查找逻辑删除操作的前半段也一样。查找写顺了后面两个操作的结构就清晰了。bool Find(const K key) { Node* cur _root; while (cur) { if (key cur-_key) cur cur-_left; else if (key cur-_key) cur cur-_right; else return true; } return false; }这段代码没什么好说的就是沿着二叉树的路径往下走。递归版本虽然更简洁但这里我刻意不用递归因为非递归版把循环内的三个分支展示得很直白也更贴近工程里对栈深度的考虑。等后面递归删除时再体现递归的威力。3. 插入操作反复出现的parent指针其实可以省掉一半3.1 非递归插入定位与悬挂插入的思路非常直白按照查找的路径走走到一个空位置就说明新节点应该挂在这里。等真正挂节点时问题来了——我们只知道走到了空位置不知道这个空位置到底是父节点的左指针还是右指针所以必须保留一个parent指针。网上相关实现版本很多核心代码高度相似循环里记录parent cur然后cur cur-_left/_right循环结束后创建新节点再比较key和parent的key大小决定挂到左还是右。这个思路没有错但有几个顺序细节要注意更新parent必须发生在移动cur之前否则parent记录的是错误的节点到了空位置后判断挂左还是挂右不能想当然地写死某一个方向。bool Insert(const K key) { if (_root nullptr) { _root new Node(key); return true; } Node* parent nullptr; Node* cur _root; while (cur) { if (key cur-_key) { parent cur; cur cur-_left; } else if (key cur-_key) { parent cur; cur cur-_right; } else { return false; // key已存在插入失败 } } cur new Node(key); if (key parent-_key) parent-_left cur; else parent-_right cur; return true; }这里默认删除序列中不允许重复key一旦发现相同key就返回false。原因很简单搜索二叉树要求互异性否则查找时无法确定该返回哪个节点。如果需要支持重复key可以用multiset那套放宽唯一性的设计那个方案本质上是每个key带计数或者允许同key并列属于另一个话题。3.2 利用引用的递归版插入真正省心的写法非递归插入的代码并不长但每个分支都要小心parent的维护。递归版本可以把这个负担彻底去掉关键就在递归函数参数使用Node* root。引用传参的妙处在于当递归调用_InsertR(root-_left, key)时传进去的不是root-_left的拷贝而是指针变量本身。在递归函数内部执行root new Node(key)等于是直接修改了父节点成员变量里的指针值新节点自然就连上了树。这在C语言里相当于用二级指针才能做到的事情C用引用表达得更优雅。bool InsertR(const K key) { return _InsertR(_root, key); } // 私有辅助函数 bool _InsertR(Node* root, const K key) { if (root nullptr) { root new Node(key); return true; } if (key root-_key) return _InsertR(root-_left, key); else if (key root-_key) return _InsertR(root-_right, key); else return false; }我第一次看这个写法时觉得像变魔术——为什么函数里直接root new Node(key)就能把新节点接到树上因为这里的root是上一层调用中_root-_left或_root-_right的别名。写进新节点指针的值会立刻出现在父节点的对应成员变量里。在递归回退阶段不会再做额外操作整个过程发生在递归调用的最深一层然后逐层返回。递归版本也有代价最坏情况下树退化成链递归深度等于节点数栈可能不够用。所以工程级的实现通常选非递归但学习阶段递归版本能帮你建立“树本身就是递归定义”的思维方式而且对理解删除操作帮助更大。3.3 中序遍历验证写完插入立刻自检插入写完后最关心的问题就是树到底建对没有。中序遍历递归函数就是最好的验证工具void InOrder() { _InOrder(_root); cout endl; } void _InOrder(Node* root) { if (root nullptr) return; _InOrder(root-_left); cout root-_key ; _InOrder(root-_right); }连续往树里插入一组乱序数据再调InOrder如果输出是从小到大严格递增的说明大小比较方向、左右指针挂接都正确。这个自检方法值得养成习惯等写好删除操作后每次删除完再调一次中序遍历能立刻暴露指针接错的问题。4. 删除操作三种分支情况里最毒的其实是两个孩子的场景删除是搜索二叉树里最容易写错的环节网上能找到的版本也不少很多看着逻辑完备一到边界就崩。这里我把整个删除过程拆成处理逻辑来分析先讲非递归再讲递归。4.1 非递归删除找节点和改指针要分开思考删除的核心矛盾在于找到要删的节点后不能让父节点的孩子指针继续指向一块即将释放的内存。所以必须根据待删除节点的不同情况决定父节点指针如何重新连接。节点一共分为三种基本形态叶子节点直接删除把父节点对应的孩子指针置空只有左孩子或只有右孩子删除后把唯一的孩子接到父节点的对应位置左右孩子都存在不能直接删除必须用替换法找右子树最小节点或左子树最大节点的值复制到待删除节点位置再删除那个最小节点。为什么替换法有效因为右子树最小节点大于待删除节点所有左子树的元素同时又小于右子树中其他所有元素把它提到待删除节点的位置整棵树的大小关系完全不受破坏。这相当于把一个双孩子节点的删除问题转化为删除一个最多只有一个右孩子的最小节点的删除问题难度瞬间下降。非递归实现需要同时维护parent和cur。特别要注意的是找到目标节点后如果cur就是根节点parent为nullptr就不能访问parent-_left必须直接更新_root。另外替换节点可能在待删除节点的直接右孩子位置也可能在右子树左链的末端这两种情况的指针连接方向完全不同。bool Erase(const K key) { Node* parent nullptr; Node* cur _root; while (cur) { if (key cur-_key) { parent cur; cur cur-_left; } else if (key cur-_key) { parent cur; cur cur-_right; } else { // 情况一左孩子为空同时覆盖了叶子节点 if (cur-_left nullptr) { if (cur _root) { _root cur-_right; } else if (cur parent-_left) { parent-_left cur-_right; } else { parent-_right cur-_right; } delete cur; } // 情况二右孩子为空 else if (cur-_right nullptr) { if (cur _root) { _root cur-_left; } else if (cur parent-_left) { parent-_left cur-_left; } else { parent-_right cur-_left; } delete cur; } // 情况三左右孩子都存在用右子树最小节点替换 else { Node* minParent cur; Node* min cur-_right; while (min-_left) { minParent min; min min-_left; } cur-_key min-_key; if (minParent-_left min) minParent-_left min-_right; else minParent-_right min-_right; delete min; } return true; } } return false; }这段代码里情况一已经覆盖叶子节点因为叶子节点的_left和_right都是nullptr走第一个分支后父节点对应指针被置空删除节点本身完成操作。情况二的前提是左孩子不为空所以这里的逻辑没有模糊空间。4.2 递归删除引用参数带来一种极少出错的写法递归删除用的仍然是Node* root引用参数。当递归进入某个子树时当前root变量就是父节点成员指针的别名。找到目标节点后如果它只有左或右孩子直接把root指向对应的孩子然后delete掉原节点父节点的指针连接自动完成如果它有两个孩子先复制右子树最小节点的key到当前位置再递归到右子树去删除那个“最小key”。因为那个最小节点最多只有一个右孩子删除难度已被简化。bool EraseR(const K key) { return _EraseR(_root, key); } // 私有辅助函数 bool _EraseR(Node* root, const K key) { if (root nullptr) return false; if (key root-_key) return _EraseR(root-_left, key); else if (key root-_key) return _EraseR(root-_right, key); else { Node* del root; if (root-_left nullptr) { root root-_right; } else if (root-_right nullptr) { root root-_left; } else { Node* min root-_right; while (min-_left) min min-_left; root-_key min-_key; return _EraseR(root-_right, root-_key); } delete del; return true; } }这个版本基本不用考虑parent指针根节点场景、连续删除场景都能正确应对因为根指针本身的引用也在递归里被正确处理。唯一要注意的是双孩子分支中递归删除后必须立刻return true不能继续执行函数末尾的delete del否则同一个节点会被释放两次。4.3 删除操作几个反复踩的坑这里把我在调试过程中反复踩过的坑集中列一下。第一删除双孩子节点时只把key替换了却忘记删除被替换的最小节点导致内存泄漏。这类问题不容易直接暴露运行几万次看内存增长才明显但用场景覆盖也能提前发现。第二替换节点如果是待删除节点的直接右孩子时判断minParent-_left min会失效因为此时minParent等于curmin是cur-_right应该走else分支。如果把判断写成只认左孩子右孩子场景下删除会接错指针。第三递归删除后有些人习惯先delete当前节点再返回结果已经把节点释放了函数末尾的delete del又释放一次轻则逻辑错乱重则直接段错误。双孩子分支中的return不能少。这三类问题我都分别跑过测试用例。最靠谱的验证方式是随机插入一批数据随机删除其中一部分每次操作后中序遍历确认严格递增最后正常退出、析构不报错才敢说删除逻辑是真的对了。5. 第一个容易崩的地方析构、拷贝构造和赋值运算符很多人写完插入删除就以为模拟实现结束了其实搜索二叉树最大的坑藏在生命周期管理里。类内部持有一个Node* _root如果使用默认生成的析构函数它只丢掉指针本身的栈内存并不会释放整棵树的堆节点内存泄漏几乎是必然的。而默认拷贝构造是浅拷贝两个对象共享同一棵树第一个对象析构后第二个对象的_root成了悬挂指针任何访问都可能崩这种问题表现随机排查起来非常恶心。5.1 析构函数递归后序遍历删除所有节点释放一棵BST的正确方式是后序遍历先释放左子树再释放右子树最后释放当前节点。顺序不能颠倒否则先删了当前节点左右子树的指针就找不到了。用递归实现非常自然~BSTree() { Destroy(_root); _root nullptr; } void Destroy(Node* root) { if (root nullptr) return; Destroy(root-_left); Destroy(root-_right); delete root; root nullptr; }Destroy的root参数用引用delete后顺手置空可以避免后续在任何一个残留指针上误用已释放内存。析构函数最后再把_root置空一次是防御性写法即使某个流程已经置空也不会有副作用。5.2 拷贝构造前序递归建一棵全新树正确的拷贝构造需要递归复制每个节点对源树的每个节点在目标树上创建一个一模一样的节点然后递归复制左右子树。整个过程本质是前序遍历建树因为必须先有根节点才能挂它的左右孩子。BSTree(const BSTreeK t) { _root Copy(t._root); } Node* Copy(Node* root) { if (root nullptr) return nullptr; Node* newNode new Node(root-_key); newNode-_left Copy(root-_left); newNode-_right Copy(root-_right); return newNode; }这里有个隐含问题每复制一个节点就要分配一次内存树很大时拷贝代价并不低。如果业务中明确不允许拷贝最好把拷贝构造和赋值运算符直接删除也就是C11里的 delete比实现一个没必要的深拷贝更合理。教程场景为了展示完整通常保留深拷贝版。5.3 赋值运算符借用传值参数实现copy-and-swap常见的赋值写法是if (this ! t)加释放旧节点再逐个复制这个写法容易在释放后复制时抛异常对象会处于半毁状态。更稳的惯用法是基于copy-and-swap参数按值传递一份临时副本内部直接交换根指针BSTreeK operator(BSTreeK t) { swap(_root, t._root); return *this; }我第一次看到这个写法时觉得像变魔术但它确实是工程中非常经典的实现。参数按值传入编译器调用拷贝构造生成临时对象临时对象持有源树的独立深拷贝然后交换两个根指针当前对象拿到新副本临时对象接管旧根在函数返回时自动析构顺便把旧树释放干净。异常安全、代码极短比手写释放和复制的逻辑可靠得多。如果认真学C对象生命周期设计这个惯用法值得单独记住。5.4 一个容易忽略的细节递归辅助函数全部放进private区域析构、拷贝、递归插入、递归删除、中序遍历的递归版本对外部调用者来说没有任何意义。把这些辅助函数放在private区域公开接口统一封装一层一来防止调用者误传指针参数二来也是类封装的基本素养。我习惯用下划线开头命名私有接口和标准库里约定俗成的风格保持一致代码读起来也顺手。6. 整体验证与性能退化插入有序数据会发生什么6.1 简单性能验证插入有序序列让BST退化成链表模拟实现完成后最好跑一轮完整的验证。我通常从1连续插入到N再中序遍历结果发现树的高度变成了N查找时几乎要遍历整条链复杂度退化成了O(N)和预期中O(log N)的平衡表现相去甚远。原因很直接搜索二叉树只约束节点间的大小关系不约束树的形状。当数据本身有序时每次新插入的节点都挂在右子树的最末端树自然被拉成一条链。这不是实现错误而是普通搜索二叉树的天然缺陷。标准库关联容器不用普通BST正是这个原因——它们用平衡树结构旋转纠正树形约束高度保证最坏情况下复杂度仍然是O(log N)。6.2 如何验证你的实现正确三件套测试方法实测时我用三组数据分别做检查随机序列例如5、3、7、1、9、2、8升序序列例如1到15连续插入降序序列例如15到1连续插入。每组插入后先做一次中序遍历确认输出严格递增再随机查找一些存在和不存在的key确认Find返回值正确最后删除部分节点再次中序遍历并比对输出长度确保节点没有被重复释放或漏删。几轮循环后程序能正常退出、析构不报错实现才算基本过关。代码层面还可以配合内存检测工具观察是否存在节点泄漏这些工具能直接标出哪一行分配的内存没有配对释放排查起来比人工看代码快得多。6.3 从BST到AVL、红黑树的自然过渡写完搜索二叉树下一步的方向基本就是平衡树。理解了删除双孩子时的替换法之后再看AVL的旋转会顺理成章——旋转的目的就是控制树高让性能不会退化。红黑树进一步放宽了严格平衡的约束减少旋转次数更符合标准库中频繁增删、要求稳定性能的场景。课堂上把普通BST彻底吃透后面学这些扩展结构会快很多因为节点定义、递归遍历、指针重连等操作思路完全是相通的。如果只让我留一条建议那就是递归版本删除和析构的引用传参一定要自己亲手敲一遍。那种“函数内改引用就把父节点的指针改了”的感觉只有亲手调试才能形成肌肉记忆。搜索二叉树本身并不难真正难的从来不是算法定义而是把二叉树的指针操作落实到真实可运行、可析构、可复制的C类里。把这些问题挨个打通你对C对象模型和二叉树的理解会一起上一个台阶。