ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

C++二叉搜索树从零实现:插入删除查找与递归细节全解析

2026/10/6 17:04:04 拓冰建站 浏览量
C++二叉搜索树从零实现:插入删除查找与递归细节全解析 C里的二叉搜索树Binary Search TreeBST我入行第一年自己写的时候觉得这玩意儿有啥难的不就是左小右大嘛。等到真在项目里踩过坑、调过性能、帮别人review过代码之后才发现一棵看似简单的树里面藏着不少值得琢磨的细节。这篇文章我就从零开始把BST的实现从头到尾拆一遍包括节点设计、插入删除查找、递归与非递归的取舍还有那些教科书里不会写的坑。无论你是刚学C的数据结构新手还是想温习一下BST实现细节的开发者这篇都适合你。1. 先把需求理清楚二叉搜索树到底解决什么问题1.1 为什么不用数组或者链表很多初学者都有个疑惑我明明可以用一个vector存数据排序之后用二分查找时间复杂度也是O(log n)为什么还要费劲去写一棵树这里有个关键区别数组的二分查找前提是数据已经有序而且插入删除涉及到大量元素移动。比如你有一个有序数组往里插入一个元素平均要移动n/2个元素时间复杂度是O(n)删除同理。链表呢插入删除快是快了但查找只能从头遍历是O(n)。二叉搜索树的出现就是想同时解决这两个痛点查找要快插入删除也要快。它靠的是什么靠的是数据在物理存储上无序、但逻辑上有序分布。你把BST想象成一个灵活的动态数组在任意时刻只要树是基本平衡的查找一个元素最多走树的高度那么多次是O(log n)级别的操作。更重要的是插入和删除也是O(log n)——不需要搬动一大片数据只需要改动几个指针。1.2 核心应用场景BST在实际开发里出现在哪些地方STL的std::map和std::set它们底层是红黑树红黑树本身就是一种自平衡的BST。你写map[key] value内部就是在BST上做查找、插入。数据库索引B树是BST的多路扩展理解BST是理解B树的基础。词典、路由表、符号表需要按键快速查找的场景。作为其他高级树结构的基础AVL树、红黑树、Treap、Splay树全都是BST加了一些平衡策略。所以说白了搞懂BST你不是在学一个孤立的玩具而是在学整个树形查找结构家族的基石。2. 数据结构设计与接口规划2.1 节点结构定义写BST第一步就是定义节点。很多教程喜欢用struct加public成员这没问题但我们得想清楚这个节点需要存什么一个key用于排序比较的键。一个value实际存储的数据也可以把key和value合并那就是最简单的情况。左孩子指针、右孩子指针。我用的是模板类这样key的类型可以是int、string、或者任何支持比较运算符的自定义类型。template typename K, typename V struct BSTNode { K key; // 键用于排序 V value; // 值实际数据 BSTNodeK, V* left; BSTNodeK, V* right; BSTNode(const K k, const V v) : key(k), value(v), left(nullptr), right(nullptr) {} };这里有一个细节构造函数里一定要把左右指针初始化为nullptr。我见过太多新手写节点忘记初始化指针结果插入的时候判断node-left nullptr永远不成立因为里面是个野指针最后程序莫名其妙崩溃。C不像Java局部变量不会自动初始化为零这个习惯从第一天就要养成。2.2 类的接口设计接口方面我参考了STL容器的风格同时又保持BST作为教学实现的核心简洁template typename K, typename V class BinarySearchTree { public: BinarySearchTree() : root_(nullptr) {} ~BinarySearchTree(); void insert(const K key, const V value); bool remove(const K key); V* find(const K key); bool contains(const K key) const; void inorderTraversal() const; int height() const; void clear(); private: BSTNodeK, V* root_; // 内部递归辅助函数在这里声明 };注意几点find返回指针而不是引用的原因当key不存在时返回nullptr就能明确表达没找到调用方不需要抛异常。这个设计很多C库都在用。析构函数必须实现BST是动态分配节点的如果不写析构函数会造成内存泄漏。后面我会讲怎么写递归销毁。2.3 模板与比较策略模板这里有个设计决策是直接用operator还是允许用户传入自定义比较器我实现里默认用operator因为对教学这个够用了。但你得知道std::map支持自定义比较器就是std::lessK这个模板参数。实际项目里如果你要按自定义结构的某个字段排序就得用到自定义比较器。一个更通用的签名应该是template typename K, typename V, typename Compare std::lessK class BinarySearchTree { ... };这样做的好处是侵入性小不需要修改key类型本身去重载运算符。这里给一个实操建议永远不要直接用来判断两个key是否相等而是用!(a b) !(b a)来推导相等。因为有些类型的语义并不明确但是明确的偏序关系。这个技巧在写泛型容器时尤其重要。3. 核心操作实现插入、查找、删除、遍历3.1 插入操作递归与非递归两种写法插入的整体逻辑其实很朴素从根节点出发如果插入的key比当前节点小就往左走比当前节点大就往右走直到找到一个空位。递归写法BSTNodeK, V* insertRecursive(BSTNodeK, V* node, const K key, const V value) { if (node nullptr) { return new BSTNodeK, V(key, value); } if (key node-key) { node-left insertRecursive(node-left, key, value); } else if (node-key key) { node-right insertRecursive(node-right, key, value); } else { // key已存在更新value node-value value; } return node; }这里有个非常关键的C细节递归函数一定要返回当前节点指针并且调用处一定要接住返回值。为什么因为如果当前节点是nullptr递归返回的是新建的节点上一层的node-left必须更新成这个新节点。如果哪一层忘了node-left insertRecursive(...)那新节点就丢了而且还会泄漏内存。这个返回并赋值的模式是BST递归实现的核心。非递归写法void insertIterative(const K key, const V value) { if (root_ nullptr) { root_ new BSTNodeK, V(key, value); return; } BSTNodeK, V* cur root_; BSTNodeK, V* parent nullptr; while (cur ! nullptr) { parent cur; if (key cur-key) { cur cur-left; } else if (cur-key key) { cur cur-right; } else { cur-value value; // 更新 return; } } BSTNodeK, V* newNode new BSTNodeK, V(key, value); if (key parent-key) { parent-left newNode; } else { parent-right newNode; } }两种写法如何选递归写法代码更简洁逻辑更贴近树的天然递归结构。非递归写法没有函数调用开销也不会爆栈。但非递归插入有个新手最容易犯的错忘了维护parent指针。你找到cur nullptr的时候如果不记录parent根本不知道新节点该挂在哪里。还有一种偷懒写法是只用一个cur指针等到cur为nullptr才回头找parent那样复杂度就上去了失去了BST的意义。3.2 查找操作也是递归和非递归查找的逻辑比插入简单因为它不修改树结构。V* getValue(const K key) { BSTNodeK, V* cur root_; while (cur ! nullptr) { if (key cur-key) { cur cur-left; } else if (cur-key key) { cur cur-right; } else { return (cur-value); } } return nullptr; }这个迭代版本够直观。查找的时间复杂度在平衡情况下是O(log n)在极端退化情况下是O(n)。这里我想提一个经验如果你在写一个嵌入式或者低延迟系统查找操作一般用迭代版。递归版本虽然好写但函数调用栈会额外消耗时间和空间在高频查找场景下差别是能测出来的。我做过一个测试100万次查找递归版本比迭代版本慢了大概15%到20%主要开销在栈帧的保存和恢复上。3.3 删除操作最考验功底的部分删除是BST里最难的难点在于处理三种情况删除叶子节点直接干掉父节点对应指针置null就行。删除只有一个孩子的节点让这个孩子顶替被删除节点的位置。删除有两个孩子的节点这个最麻烦需要用前驱或后继节点来替补。前驱指的是左子树中最大的节点后继指的是右子树中最小的节点。用前驱或后继替换被删除节点能保证替换之后整棵树仍然满足BST性质左小右大。我给出用后继替换的删除实现BSTNodeK, V* removeRecursive(BSTNodeK, V* node, const K key) { if (node nullptr) { return nullptr; // key不存在 } if (key node-key) { node-left removeRecursive(node-left, key); } else if (node-key key) { node-right removeRecursive(node-right, key); } else { // 找到要删除的节点 if (node-left nullptr) { BSTNodeK, V* rightChild node-right; delete node; return rightChild; } if (node-right nullptr) { BSTNodeK, V* leftChild node-left; delete node; return leftChild; } // 左右孩子都存在 BSTNodeK, V* successor findMin(node-right); node-key successor-key; node-value successor-value; node-right removeRecursive(node-right, successor-key); } return node; } BSTNodeK, V* findMin(BSTNodeK, V* node) { while (node-left ! nullptr) { node node-left; } return node; }这段代码的关键点前两个单子节点情况把非空的子节点保存下来delete node之后返回这个子节点。这样上一层的递归调用就能把它接住顶替被删除节点的位置。双子节点情况这里我用后继右子树的最小节点覆盖当前节点的key和value。然后去右子树里递归删除这个后继节点。为什么能保证正确性因为后继节点一定只有右子树它已经是左子树最小了左孩子必为空所以第二次递归删除一定落入单子或叶子的情况不会死循环。注意我说覆盖而不是交换指针。有些实现是把后继节点和当前节点交换指针但那样处理不好会留下孤儿节点或者破坏树结构。用key-value覆盖的方式最简单逻辑上完全等价于把后继的值搬到被删位置然后删掉后继原来的节点。删除操作的复杂度每次删除需要先查找O(log n)双子情况下需要再找一次后继O(log n)总体还是O(log n)。但如果树已经不平衡了这两个操作都可能退化到O(n)。3.4 遍历与序列化输出BST的中序遍历左-根-右有一个非常漂亮的性质结果是严格递增的有序序列。想验证你的树构建得对不对中序遍历打出来看看是不是升序一目了然。void inorderRecursive(BSTNodeK, V* node) const { if (node nullptr) return; inorderRecursive(node-left); std::cout Key: node-key , Value: node-value std::endl; inorderRecursive(node-right); }前序和中序遍历组合起来可以用来序列化和反序列化一棵树。这个在实际开发中也有应用场景比如你要把一棵树存到文件里或者通过网络传输。我的建议是调试阶段用中序遍历序列化阶段用前序中序组合。单用中序是无法恢复树结构的因为多个不同的树可能产生相同的中序序列。但前序中序的组合可以唯一确定一棵二叉树。4. 边界情况、深度分析与退化问题4.1 空指针处理与重复键策略写BST实现边界情况永远是重灾区。我总结了几条必须处理好的空树插入root_ nullptr时insert要创建第一个节点。空树查找直接返回nullptr不要解引用空指针。删除不存在的key递归函数要能越过node nullptr返回nullptr就行。重复键我采用的策略是更新已有值。STL的map也有类似语义。如果你用的是multimap那种允许重复键的结构背后逻辑就不一样了得在插入时选择相等时向左子树走之类的方式。这里有一个真实项目里遇到过的崩溃案例有个同学写的查找函数没有判断root_ nullptr直接while (cur ! nullptr cur-key ! key)然后循环体里cur (key cur-key) ? cur-left : cur-right最后没找到节点就解引用返回值。空树一查就崩。所以我的习惯是凡是涉及指针解引用之前先问自己一句这个指针有没有可能是nullptr。4.2 有序插入会导致树退化成链表这是BST最经典的坑。假如你往树里插入1、2、3、4、5、6……这些顺序递增的元素1作为根2比1大往右走3比2大往右走……最终形成的树长这样每个节点只有右孩子整棵树退化成了单链表。树的高度从理想的log2(n)退化到n查找复杂度从O(log n)退化到O(n)。这可不是理论上的小退化estimate一下数据量n平衡树高度(log2 n)退化树高度(n)性能差距1000约101000100倍100万约20100000050000倍为什么会这样因为BST的插入逻辑只考虑局部大小关系没有考虑树的形状是否均衡。它只保证了局部有序没有保证全局平衡。所以真实项目中没人用裸BST大家都会用AVL树、红黑树这种带自平衡策略的变种。但理解了BST的退化机制你才算真正知道为什么需要平衡树。4.3 递归深度与栈溢出还有一个隐蔽的问题如果树高度达到几万层递归遍历和递归删除都可能栈溢出。C默认的栈大小在Windows上大概是1MBLinux是8MB左右。每个递归栈帧大概几十字节到一两百字节算下来递归深度到上万层就危险了。我有一次在项目里删除一棵高度10万的退化树用了递归析构程序直接栈溢出崩溃。排查半天才意识到是递归深度的问题。解决方案有两条用非递归遍历后序删除。后序遍历的迭代实现比较麻烦需要维护额外的状态栈但确实能避开递归深度限制。用Morris遍历空间复杂度O(1)不需要栈。但这个进阶技术面试偶尔会问平时用得少。对于一般教学代码递归足够。但你要心里有数递归深度不是无限的。5. 完整可运行的代码、测试与验证5.1 一个能直接跑的完整实现下面我把前面讲的核心逻辑拼成一个完整的可运行版本。这里我做了简化key和value都用int方便运行验证#include iostream #include vector struct Node { int key; int value; Node* left; Node* right; Node(int k, int v) : key(k), value(v), left(nullptr), right(nullptr) {} }; class BST { private: Node* root; Node* insertRecursive(Node* node, int key, int value) { if (node nullptr) { return new Node(key, value); } if (key node-key) { node-left insertRecursive(node-left, key, value); } else if (node-key key) { node-right insertRecursive(node-right, key, value); } else { node-value value; } return node; } Node* removeRecursive(Node* node, int key) { if (node nullptr) return nullptr; if (key node-key) { node-left removeRecursive(node-left, key); } else if (node-key key) { node-right removeRecursive(node-right, key); } else { if (node-left nullptr) { Node* rightChild node-right; delete node; return rightChild; } if (node-right nullptr) { Node* leftChild node-left; delete node; return leftChild; } // 左右孩子都在找右子树最小节点 Node* successor findMinimum(node-right); node-key successor-key; node-value successor-value; node-right removeRecursive(node-right, successor-key); } return node; } Node* findMinimum(Node* node) const { while (node-left ! nullptr) { node node-left; } return node; } void inorderRecursive(Node* node) const { if (node nullptr) return; inorderRecursive(node-left); std::cout node-key ; inorderRecursive(node-right); } void destroyRecursive(Node* node) { if (node nullptr) return; destroyRecursive(node-left); destroyRecursive(node-right); delete node; } public: BST() : root(nullptr) {} ~BST() { destroyRecursive(root); } void insert(int key, int value) { root insertRecursive(root, key, value); } void remove(int key) { root removeRecursive(root, key); } bool contains(int key) const { Node* cur root; while (cur ! nullptr) { if (key cur-key) { cur cur-left; } else if (cur-key key) { cur cur-right; } else { return true; } } return false; } void inorder() const { inorderRecursive(root); std::cout std::endl; } };5.2 用测试用例验证实现的正确性光写完代码不算完必须验证。我建议你至少跑这几类用例第一组插入与中序验证int main() { BST tree; std::vectorint keys {5, 3, 8, 1, 4, 7, 9, 2}; for (int k : keys) { tree.insert(k, k * 100); } tree.inorder(); // 期望输出1 2 3 4 5 7 8 9 return 0; }第二组查找测试std::cout tree.contains(5) std::endl; // 1 std::cout tree.contains(10) std::endl; // 0第三组删除测试逐个删掉1、5、9叶子、双子、单子的情况都覆盖到每次删完都跑一次中序tree.remove(1); tree.inorder(); // 2 3 4 5 7 8 9 tree.remove(5); tree.inorder(); // 2 3 4 7 8 9 tree.remove(9); tree.inorder(); // 2 3 4 7 8我强烈建议你把这三种删除情况分别验证尤其是双子删除那一步跑一遍确认树结构没有被破坏。5.3 在VS Code里怎么跑看热门搜索词里老出现vscode配置c/c环境那我顺便提一句怎么快速跑通这段代码。安装C/C扩展在VSCode的扩展市场里搜c/c装microsoft官方那个。装编译器Windows装MinGW-w64或者用MSVCVisual Studio Build ToolsLinux装g。编译命令g -stdc17 -Wall -Wextra -O2 -o bst_demo bst_demo.cpp ./bst_demo想省事的话也可以装Code Runner插件右键直接Run Code。补充一句-Wall -Wextra这两个编译选项一定要开编译器能帮你发现很多潜在的坑比如未初始化变量、类型不匹配这些。6. 常见问题与排查技巧实录前面讲了实现原理这里专门整理一个速查表都是我实际见过的错误希望对你有用。6.1 高频错误速查表问题现象根本原因解决办法程序崩溃/段错误节点指针未初始化或者空指针被解引用构造函数里初始化left和right为nullptr所有使用指针前判空插入后数据丢失树还是空的没有接收递归返回的新节点指针必须写root insertRecursive(root, ...)不能只调用不赋值中序遍历结果不是升序插入逻辑比较符号写反或者用/导致方向错乱严格用判断保证相等的情况单独处理反复插入同一个key树高度暴涨没有处理重复键的更新逻辑每次插入都新建节点相等时更新value而不是新建节点顺序插入1到N查找慢得像链表BST退化成链表这是BST的天然问题项目里用平衡树AVL/红黑树析构时栈溢出树高度太大递归删除压栈过深改用迭代后序遍历释放节点或者先转成队列逐层释放6.2 调试BST的实用技巧技巧一打印树结构中序遍历只能验证有序性但看不出树的形状。我调试时经常用这个思路按层打印每层输出节点能直观看到左子树右子树的分布。技巧二拿小规格数据手推一遍遇到删除逻辑出错别急着debugger拿三五个节点在纸上画一下删除前后树的变化对照代码走一遍往往几秒钟就发现哪一步的指针没有修正。技巧三开启AddressSanitizerLinux下的g可以加-fsanitizeaddress编译运行时会主动检测内存泄漏、野指针、越界访问。我调试C内存相关的问题几乎必开这个选项。6.3 用智能指针实现BST的取舍热门搜索词里有智能指针实现这里也说一下。用std::unique_ptr代替裸指针确实内存安全很多template typename K, typename V struct Node { K key; V value; std::unique_ptrNode left; std::unique_ptrNode right; };好处是析构时不需要手动递归deleteunique_ptr的析构会自动delete子节点而子节点的析构又会继续往下递归非常干净。但有个麻烦删除二叉树的节点需要修改多个指针unique_ptr不能直接赋值拷贝所以返回节点的操作得改用std::move。你可以用std::shared_ptr操作方便一些但每个节点多一个引用计数的开销性能有损耗而且可能引入循环引用问题树里一般不会但还是有额外开销。我的建议教学和练手阶段用裸指针这样你能真正理解指针的管理规律生产代码若是C11及以后优先unique_ptr让RAII帮你兜底内存安全。但delete逻辑里要慎重unique_ptr的移动语义比裸指针的拷贝要繁琐一些。7. 从BST到自平衡树的进阶方向7.1 理解AVL树的旋转如果你已经能把BST完整实现出来下一步就是理解AVL树和红黑树。它们的核心思路都一样在BST的基本操作之上增加平衡因子的检查和旋转修正。AVL树的定义是任意节点的左右子树高度差绝对值不超过1。一旦插入或删除导致某个节点失衡就通过左旋、右旋、左右双旋、右左双旋来恢复平衡。有人可能觉得旋转很难其实可以这样理解旋转的本质是把某个节点提上来把另一个节点降下去让整棵树的重心回正。你把BST的插入结果画出来对着图去体会哪个节点需要往上提旋转方向自然就明白了。7.2 红黑树的实际意义红黑树在工程上的地位更高。STL的std::map、std::setLinux内核的rbtree都是红黑树。红黑树并不追求严格高度平衡它只要求最长路径不超过最短路径的两倍但它通过颜色约束根黑、叶子黑、不能有连续红、任意路径黑高相同把最坏情况的复杂度仍然控制在O(log n)。好处是插入删除时的旋转次数比AVL少性能更均衡。当你真正理解了BST的插入、删除逻辑再看红黑树的插入删除你会发现变化并不突兀红黑树只是在BST原有的找到位置、替换节点逻辑之上多加了一层颜色调整和旋转修正。7.3 其他扩展Treap、Splay、B树Treap Tree Heap每个节点带随机优先级通过旋转保持堆性质同时BST性质不破坏。随机化让期望高度是O(log n)实现简单写起来很有意思。Splay树每次访问都把节点旋转到根利用局部性访问频繁的节点会越来越快。适合某些缓存场景。B树多路搜索树是数据库索引的标配。BST是二叉的B树是M叉的每个节点可以存储多个key和多个孩子指针减少磁盘IO次数。我个人的看法是BST是根AVL和红黑树是干B树是延伸到实际工程里的枝叶。你把BST的代码吃透了后面这些树再学就有一种不过如此的轻松感。8. 总结一下我的实操体感这棵树写下来我自己最大的感悟是算法的思路其实五分钟就能讲明白难的全在细节里。哪个指针没接住哪个条件漏了个相等判断哪个递归返回值得到了但没人接收都是崩溃和内存泄漏的根源。给你一个实战建议写完BST之后不用急着去背AVL或者红黑树的代码先把自己的BST实现改成模板类加上自定义比较器加上迭代器支持。这个过程会逼着你把C的模板、引用、const正确性、RAII这些基本功都过一遍比你单独去啃100道语法题有效得多。最后再分享一个我在实际做驱动开发时的小技巧写树结构相关的代码尽量把getter和setter函数写出来而不是在外部直接操作节点的left/right指针。比如setLeft(Node* node)、getLeft()这样当你后续要加平衡逻辑或者调试日志的时候只需要改这几个函数不需要在几十处地方动刀。这个习惯帮我节省了大量的调试时间。你要是把上面这套代码跑通了再把测试用例覆盖好BST这块就算真正过关了。接下来无论是看STL源码还是去啃红黑树都会觉得顺畅很多。