ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

二叉搜索树C++实现:插入、删除、查找与面试变形题全解析

二叉搜索树C++实现:插入、删除、查找与面试变形题全解析 写二叉搜索树的文章不少但大多停留在“贴代码 背性质”的层面。这篇我打算换个讲法从一个实际工程和面试双重角度切入把插入、查找、删除这些基础操作的实现逻辑、边界处理、性能陷阱一次说透顺便把 BST 最常见的面试变形题也梳理一遍。代码全部用 C11/14 风格可直接编译运行适合正在学数据结构、准备算法面试、或者想自己写一棵树的读者。1. 二叉搜索树到底是什么先搞清楚它解决的问题二叉搜索树Binary Search Tree简写 BST的核心性质只有一句话每个节点的左子树所有值都小于它右子树所有值都大于它且左右子树各自也满足这个条件。很多初学者容易把 BST 理解成“二叉树的一种”然后就开始背性质。这样学完很快会忘。我更建议反过来想BST 是为了解决什么问题才被设计出来的先看一个场景。你有一批动态数据频繁做三件事查找某个值是否存在插入一个新值删除一个旧值用数组存查找可以二分O(log n)但插入删除要搬移元素O(n)。用链表存插入删除方便O(1)但查找只能顺序扫O(n)。两种结构都没法同时做到查找和修改都快。BST 的思路很朴素让数据在存储时就按大小排好队。每个节点比左子树大、比右子树小查找的时候每次比较都能扔掉一半的子树插入的时候顺着路径走到底挂上去就行删除稍微麻烦一点但也只是局部调整。在树保持平衡的前提下这三种操作都能做到 O(log n)。这才是 BST 真正的价值它本质是一种“可动态维护的有序结构”。我刚学 BST 的时候是在刷算法题的过程中逐渐理解这点的后来在工作中用std::map、std::set用得多了才意识到它们的底层红黑树本质上就是一棵“会自我平衡的 BST”。BST 是它们的地基红黑树只是在 BST 的插入、删除规则上额外加了一层旋转和变色逻辑。所以你要真想弄懂std::map为什么插入删除查找都是 O(log n)先自己手写一棵不讲究平衡的裸 BST是绕不开的一步。这篇文章适合这么几类人正在学 C 和数据结构想动手实现一篇能跑的 BST 代码准备面试想系统梳理 BST 的常见考点和变形题已经会用 BST 但没自己写过的开发者想补一补内部机制的细节后面所有内容我都按“先想清楚动机和约束再动手写代码”的顺序来每个关键步骤都会解释为什么这样设计而不是直接甩一堆代码让你背。2. 类的基本框架与节点设计动手前要先想清楚的几件事任何数据结构的学习实现第一步不是写逻辑而是定义清楚“每个元素长什么样”和“容器对外暴露哪些能力”。BST 的节点结构很简单通常是三件套存储的值、左孩子指针、右孩子指针。template typename Key struct BSTNode { Key key; BSTNode* left; BSTNode* right; explicit BSTNode(const Key k) : key(k), left(nullptr), right(nullptr) {} };这里有几个设计上的取舍点值得展开说说。为什么用模板而不用 int 写死因为 BST 作为一种容器不应该关心元素的类型只要这个类型支持和比较就够了。工作中你很可能存的是int也可能存的是std::string甚至是一个自定义结构体。模板化之后这套代码能直接复用。为什么用裸指针而不是std::unique_ptr或std::shared_ptr这是一个经常被问到的问题。面试和教学中写裸指针是为了把“谁拥有谁、什么时候释放”这件事暴露在明面上让你真正理解资源管理。工程上如果要自己实现一棵树推荐用std::unique_ptr管理子节点能省去手写析构的很多麻烦也天然防止拷贝。但std::unique_ptr的递归析构在极端情况下会爆栈这是另一个话题本文不展开。节点的生命周期归属问题必须在类设计时就定清楚。我这里采用最简单也最常见的策略节点由 BST 容器独占容器析构时负责释放所有节点。这要求我们显式地写析构函数禁止拷贝构造和拷贝赋值。template typename Key class BSTree { public: BSTree() : root_(nullptr) {} ~BSTree() { clear(root_); } // 禁止拷贝二叉树的深拷贝实现起来并不复杂 // 但为了保持代码聚焦这里直接禁掉。 BSTree(const BSTree) delete; BSTree operator(const BSTree) delete; // 对外接口 void insert(const Key k) { root_ insertRec(root_, k); } bool contains(const Key k) const { return findRec(root_, k) ! nullptr; } void remove(const Key k) { root_ removeRec(root_, k); } void clear() { clear(root_); root_ nullptr; } // 中序遍历结果按升序输出到 out void inorder(std::vectorKey out) const { inorderRec(root_, out); } private: BSTNodeKey* root_; // 对应私有递归实现 };对外只暴露insert、contains、remove、inorder这几个典型操作内部用递归或迭代实现。私有辅助函数用Rec后缀区分递归版本这个命名习惯在刷题和工程代码里都很常见。清空树的递归实现void clear(BSTNodeKey* node) { if (node nullptr) return; clear(node-left); clear(node-right); delete node; }注意必须先删子树再删当前节点。如果先delete node再去访问node-left就是典型的悬垂指针问题很可能触发段错误或未定义行为。在进入具体操作之前还有一个小建议先画一棵小树再动手写代码。比如插入[8, 3, 10, 1, 6, 14, 4, 7]形成的树。大脑里有了这棵树的图像理解递归的返回值设计、理解删除时“子树重建”的过程都会容易很多。3. 插入操作先从递归版写起再理解迭代版的取舍插入的逻辑是 BST 里最直观的一句话概括从根节点出发要插入的值比当前节点小就往左走比当前节点大就往右走直到走到空位置把新节点挂上去。递归版本写出来很简短BSTNodeKey* insertRec(BSTNodeKey* node, const Key k) { if (node nullptr) { return new BSTNodeKey(k); } if (k node-key) { node-left insertRec(node-left, k); } else if (k node-key) { node-right insertRec(node-right, k); } // 相等的情况默认不插入重复值 return node; }这段代码的关键在于理解返回值。insertRec返回的是“以入参 node 为根、插入 k 之后的新的根节点”。在递归调用中node-left insertRec(node-left, k)的含义是左子树内部发生的结构变化需要把新的子树根接回来。如果左子树为空insertRec返回新节点父节点的左指针就被设置为这个新节点如果左子树不为空返回的还是原来的node-left父节点指针不变。这种“递归返回新子树根”的模式在 BST 的插入和删除中极其常见。理解它你等于握住了一把万能钥匙。很多初学朋友一开始写不出这种递归就是因为把递归当成了“过程”而不是“返回一个结果”。每层递归都应回答一个问题这棵子树调整完之后新的根是谁重复值怎么处理我这里选择直接忽略。这是一种常见策略std::map不支持重复键std::multimap支持重复键但底层实现也不是简单地“无视”而是会维护计数或在节点中做扩充。面试中遇到“BST 允许重复值”的题时你有两个常用方案节点内加一个int count计数相同值直接count规定等于当前节点时走左子树或右子树比如走左边方案一空间上更优方案二实现更省事。我个人的习惯是如果面试官没明确要求直接说“忽略重复值”然后把理由讲清楚——把重复逻辑加进来会拖累查找和删除的复杂度实际工程里一般用带计数的节点结构。迭代版插入也不难而且更能暴露你对指针操作的理解void insertIter(const Key k) { auto* newNode new BSTNodeKey(k); if (root_ nullptr) { root_ newNode; return; } BSTNodeKey* cur root_; while (true) { if (k cur-key) { if (cur-left nullptr) { cur-left newNode; return; } cur cur-left; } else if (k cur-key) { if (cur-right nullptr) { cur-right newNode; return; } cur cur-right; } else { delete newNode; // 重复值不插入释放已申请的内存 return; } } }迭代版的优点是省掉了函数调用栈不会因为树高过大而爆栈缺点是代码里需要自己处理“申请了节点但发现重复”的情况上面代码里的delete newNode就是干这个的。递归版容错更优雅因为它只在真正需要时创建节点。插入的时间复杂度取决于树高。平衡的树是 O(log n)退化的链表结构是 O(n)。这个点我们后面单独用一节展开因为很多面试题都围绕这个特性做文章。4. 查找与最值一个被很多简单实现忽略的小细节查找的逻辑比插入还简单BSTNodeKey* findRec(BSTNodeKey* node, const Key k) const { if (node nullptr || node-key k) { return node; } if (k node-key) { return findRec(node-left, k); } return findRec(node-right, k); }写查找时有一个细节值得注意判断顺序。先判断空指针再判断值是否相等这是最稳妥的写法。有些精简写法会把node nullptr和node-key k用||合并C 的||是短路求值逻辑没问题但可读性没有分开写来得好。查找也可以写成迭代版BSTNodeKey* findIter(BSTNodeKey* node, const Key k) const { while (node ! nullptr node-key ! k) { if (k node-key) { node node-left; } else { node node-right; } } return node; }递归与迭代的本质差异在查找这里体现得很明显。很多人有个误区觉得递归一定比迭代慢。在开了编译器优化的 Release 版本里现代编译器很可能把尾递归优化成循环两者性能差距很小。我建议你两种都写一遍因为查找是最容易理解“递归返回值”和“循环终止条件”对应关系的操作。BST 查找顺带支持两个高频操作找最小值和最大值。这个实现简直让人舒适BSTNodeKey* findMin(BSTNodeKey* node) const { if (node nullptr) return nullptr; while (node-left ! nullptr) { node node-left; } return node; } BSTNodeKey* findMax(BSTNodeKey* node) const { if (node nullptr) return nullptr; while (node-right ! nullptr) { node node-right; } return node; }最小节点就是“一路向左走到头”最大节点就是“一路向右走到头”。由 BST 的性质可以直接推出这个结论不需要额外记忆。查找的最值还有一个工程上的实际意义它经常作为删除操作的基础工具。删除一个有两个孩子的节点时要么找它右子树的最小值后继来顶替要么找它左子树的最大值前驱来顶替。后一节会详细讲。所以不只是面试会考查找最值你自己实现删除的时候也得用上。5. 中序遍历与有序性验证怎么看一棵树是不是真 BSTBST 有一条非常漂亮的推论对 BST 做中序遍历得到的结果序列必然是升序的。这条推论的价值有两个一是验证一棵树是否正确二是很多面试题都基于它出变形。为什么中序遍历必然有序因为中序的顺序是“左子树 → 当前节点 → 右子树”而 BST 的定义恰好保证了左子树所有值小于当前节点、右子树所有值大于当前节点。递归地把这个逻辑套到每一棵子树上得到的序列自然严格递增不考虑重复值。中序遍历的递归实现void inorderRec(BSTNodeKey* node, std::vectorKey out) const { if (node nullptr) return; inorderRec(node-left, out); out.push_back(node-key); inorderRec(node-right, out); }我想强调一个排查 bug 的实战技巧写完插入函数后第一件事不是去调试一棵复杂的树而是插入若干数据后进行中序遍历检查输出序列是否严格升序。举个例子BSTreeint tree; std::vectorint keys {8, 3, 10, 1, 6, 14, 4, 7}; for (int k : keys) { tree.insert(k); } std::vectorint sorted; tree.inorder(sorted); // 期望输出1 3 4 6 7 8 10 14如果插入时比较方向写反了或者递归回来后父节点指针没接好中序遍历基本立刻暴露问题。这个验证法简单可靠比肉眼盯着一堆指针 Debug 高效太多。中序遍历还有一个常见考点能不能不用递归实现中序遍历可以用显式栈模拟递归void inorderIter(BSTNodeKey* root, std::vectorKey out) const { std::stackBSTNodeKey* st; BSTNodeKey* cur root; while (cur ! nullptr || !st.empty()) { while (cur ! nullptr) { st.push(cur); cur cur-left; } cur st.top(); st.pop(); out.push_back(cur-key); cur cur-right; } }迭代中序的思路是一路把左孩子压栈走到空之后弹出栈顶访问再把指针移到右子树继续。这个“向左走到尽头再回头”的模式在后续很多树相关题目里都会反复出现值得背下来形成肌肉记忆。还有一种更隐蔽的验证树是否为 BST 的方法面试里很爱考。这个方法我在后面“面试变形题”那节单独展开因为它牵扯到一个新手特别容易掉进去的坑只看每个节点和左右孩子的大小关系是不够的。6. 删除操作二叉搜索树中最容易翻车的环节完整排错思路删除是 BST 所有基础操作里最复杂的没有之一。它的难点在于删除一个节点后必须保持整棵树仍然是 BST。按待删节点的孩子数量可以分成三种情况情况孩子数量处理方式情况一没有孩子叶子节点直接删掉父节点对应的孩子指针置空情况二只有一个孩子让这个孩子顶替被删节点的位置情况三有两个孩子找右子树最小值后继或左子树最大值前驱替换值然后删除那个用于替换的节点前两种情况很好理解用一个简单的递归实现就能覆盖BSTNodeKey* removeRec(BSTNodeKey* node, const Key k) { if (node nullptr) return nullptr; if (k node-key) { node-left removeRec(node-left, k); } else if (k node-key) { node-right removeRec(node-right, k); } else { // 找到了要删除的节点 if (node-left nullptr) { BSTNodeKey* rightChild node-right; delete node; return rightChild; } if (node-right nullptr) { BSTNodeKey* leftChild node-left; delete node; return leftChild; } // 情况三有两个孩子 BSTNodeKey* successor findMin(node-right); node-key successor-key; node-right removeRec(node-right, successor-key); } return node; }情况三的核心思路值得反复琢磨在当前节点有两个孩子时我们不能简单地删掉当前节点否则左子树和右子树都会失去归属。解决办法是“狸猫换太子”——找一个既能保持左子树所有值都小于它、又能保持右子树所有值都大于它的节点。这个“合适的节点”有两个候选右子树里的最小值也就是右子树一路向左到底的那个节点称为“后继”左子树里的最大值也就是左子树一路向右到底的那个节点称为“前驱”找到后继之后把后继的key复制到当前节点然后去右子树里把它本身删掉。为什么这样可行因为后继是右子树里最小的它的 key 一定大于当前节点左子树的所有值又一定小于等于右子树的其他值替换后 BST 性质全部保持。这里有一个我踩过的坑分享给你。用自增自减运算符时要注意前置和后置的区别。比如下面这段有问题的写法// 错误示范 node-key successor-key; node-right removeRec(node-right, successor-key);这段本身没问题但如果你在findMin的实现里写了node node-left;后没有判空就直接delete node就会把左子树删掉。更隐蔽的是在删除的情况三中我们复制了successor-key之后不能直接delete successor因为那个节点还挂在原树上直接删除会让父节点的左或右指针变成悬垂指针。所以正确姿势是递归地调用removeRec去删它让函数去处理指针的重新挂接。迭代版删除的实现思路和递归版完全一致只是需要额外维护一个 parent 指针代码量会大一些。面试中写递归版通常就够用面试官更看重思路清晰而不是长度。删除操作的时间复杂度同样取决于树高。查找待删节点 O(h)删除和调整 O(h)。平衡树时 O(log n)退化时 O(n)。删除操作有一个非常容易出问题的细节值得单独拿出来强调如果待删除的节点的子树节点特别多用后继替换法进行递归删除时递归深度可能比预想更深。删除一个节点时会先在右子树里findMin然后又对右子树执行一次 removeRec这个过程中如果有大量右孩子链递归深度会叠加。实际工程里如果树的规模特别大很多实现会选择用迭代法。7. 退化陷阱与平衡思想为什么真实工程不直接用裸 BST好现在到了全文我认为最重要的一节。前面所有内容都在说 BST 在平衡状态下能达到 O(log n)但BST 并不保证自己平衡。如果插入的顺序是有序的比如依次插入1, 2, 3, 4, 5, ...那么每次新节点都会挂在右孩子的位置BST 就会退化成一条链表。你可以亲手复现这个问题BSTreeint tree; for (int i 1; i 100000; i) { tree.insert(i); }插入100000个有序数据后树的高度是100000。此时查找最后一个元素的时间复杂度是 O(n)跟顺序扫描没任何区别BST 的优势完全消失了。更麻烦的是递归操作这棵退化树会非常容易爆栈removeRec递归100000层足够触发栈溢出。那么在数据完全随机插入的情况下树的高度大约是多少数学上有一个结论从空树开始随机插入 n 个不同键树高的期望是 O(log n)。这解释了一个工程现象如果插入顺序接近随机裸 BST 通常表现够好但如果数据本身有序或接近有序裸 BST 就崩了。现实世界中的数据往往不是随机生成的比如按时间戳生成的 ID、按字典序插入的字符串很容易让 BST 退化。所以真实工程里没人直接使用裸 BST 作为核心数据结构一般会用它的升级版本结构平衡策略典型实现AVL 树严格平衡任意节点左右子树高度差不超过 1教学、需要频繁查找的场景红黑树近似平衡最长路径不超过最短路径的两倍std::map、std::set、std::multimapB 树 / B 树多路平衡节点可以存多个键数据库索引、文件系统但不管哪种平衡树插入和删除的初始逻辑都是先按普通 BST 的规则走完成之后再做旋转或变色调整。所以你现在花时间手写裸 BST不是在做无用功而是在给红黑树、AVL 树打地基。我工作后读std::map的实现源码时最大的感受就是所有旋转、染色的目的只有一个就是把 BST 的树高控制住。如果面试官问到你“BST 的缺点是什么”标准答案就是插入顺序敏感可能退化成链表递归操作在树高较大时有栈溢出风险无法保证 O(log n) 的时间复杂度上界一个巧妙的追问是“既然 BST 有这些缺点你怎么改进”这其实就是引导你答 AVL 树或红黑树你能说出“在插入和删除后通过旋转恢复平衡”就算过关如果能进一步说出“红黑树不追求绝对平衡但保证了最长路径不超过最短路径的两倍所以它比 AVL 树插入删除时旋转更少实际应用更广”那面试官会对你另眼相看。8. 面试变形题BST 基础上的高频考点与易错点BST 在面试里的出镜率极高而且很少直接问你“实现一棵 BST”通常是给你一个 BST 的变体场景考察你是否真正理解了它的性质。这里整理四个最典型的高频题每一道都是我在准备面试时反复写过的。8.1 验证一棵二叉树是否是有效的 BST这是最容易答错的一道题错误答案通常是这样的bool isValidBST(BSTNodeint* root) { if (root nullptr) return true; if (root-left root-left-key root-key) return false; if (root-right root-right-key root-key) return false; return isValidBST(root-left) isValidBST(root-right); }这个解法错在哪里它只检查了每个节点和直接左右孩子的大小关系但没有检查跨越层级的关系。举一个经典反例10 / \ 5 15 / \ 6 20在这个树里6大于它的父节点15但它同时小于根节点10这违反了 BST 的定义。然而上面那段错误代码会返回 true因为它只比较了15和6没拿6去和上层祖先10比较。正确做法是自上而下传递区间约束。每个节点在递归时都会带一个允许的上下界下界往左子树传上界往右子树传bool isValidHelper(BSTNodeint* node, BSTNodeint* minNode, BSTNodeint* maxNode) { if (node nullptr) return true; if (minNode node-key minNode-key) return false; if (maxNode node-key maxNode-key) return false; return isValidHelper(node-left, minNode, node) isValidHelper(node-right, node, maxNode); } bool isValidBST(BSTNodeint* root) { return isValidHelper(root, nullptr, nullptr); }这个方法的核心思路用一句话总结递归过程中每个节点都被祖先限定了取值范围只有满足所有祖先的约束才合法。如果觉得用指针当哨兵不方便也可以用long long上下界初始值用LONG_LONG_MIN和LONG_LONG_MAX来规避int的边界问题。8.2 查找 BST 中第 K 小的元素利用 BST 中序遍历升序的性质问题直接变成“中序遍历到第 K 个节点”。用一个引用计数的递归可以轻松解决int kthSmallest(BSTNodeint* node, int count, int k) { if (node nullptr) return -1; int left kthSmallest(node-left, count, k); if (left ! -1) return left; // 左子树里已经找到了 count; if (count k) return node-key; return kthSmallest(node-right, count, k); }这里的关键点是左子树没找到时count 已经累加了左子树的节点数来到当前节点再count如果正好等于 k当前节点就是答案。k 从 1 开始计数所以调用前传k时要注意语义。如果树的结构允许面试官可能期望你回答“给每个节点维护一个 size 字段可以做到 O(h)”的方案。这属于进阶优化能做到就多一个亮点。做法是节点结构里加一个int size表示子树节点总数插入和删除时同步更新。8.3 求 BST 中两个节点的最近公共祖先LCABST 的 LCA 问题比普通二叉树简单得多因为它可以用值的大小关系直接剪枝。假设要查找的两个值是 p 和 qBSTNodeint* lowestCommonAncestor(BSTNodeint* root, int p, int q) { if (root nullptr) return nullptr; if (p root-key q root-key) { return lowestCommonAncestor(root-left, p, q); } if (p root-key q root-key) { return lowestCommonAncestor(root-right, p, q); } return root; }如果 p 和 q 都在左子树答案一定在左子树都在右子树答案一定在右子树一个在左一个在右或者其中一个等于当前节点当前节点就是分岔点也就是 LCA。这个解法同样有迭代版一路往下走不用递归写法也非常简洁BSTNodeint* lowestCommonAncestorIter(BSTNodeint* root, int p, int q) { while (root ! nullptr) { if (p root-key q root-key) { root root-left; } else if (p root-key q root-key) { root root-right; } else { break; } } return root; }8.4 BST 转有序双向链表这道题考的是对中序遍历的灵活运用。要求在不创建新节点的前提下把 BST 原地转换为一个双向链表。核心思路是中序遍历时维护前一个访问的节点 pre每次访问当前节点时current-left pre; // 左指针指向前驱 if (pre ! nullptr) pre-right current; // 前驱的右指针指向当前 pre current; // 更新前驱当整棵中序遍历完成之后所有节点的 left/right 指针就被重新定义成了双向链表的前驱和后继链表的最小节点就是中序遍历的第一个节点。这类题在面试中属于“想到就简单、没想到就卡壳”的类型关键点是把中序遍历的执行顺序用熟。9. 测试与调试我自己踩过的三个坑写完 BST 并不代表它正确测试和调试才是工程能力的体现。分享三个我自己实际踩过的坑每一个都花了我不少时间才定位到问题。9.1 打印二叉树的调试技巧二叉树的问题很难用眼睛定位尤其是当它不平衡的时候。我写过一个非常简单但好用的“横向打印”辅助函数void dump(BSTNodeKey* node, int depth 0) { if (node nullptr) return; dump(node-right, depth 1); for (int i 0; i depth; i) { std::cout ; } std::cout node-key \n; dump(node-left, depth 1); }这个函数把树顺时针旋转 90 度打印出来右子树在上、左子树在下、根在中间。插入和删除后调一下这个函数树的形状一目了然很多指针错乱问题瞬间就能看出来。我建议你在insert和remove外部包一层调试用的dump()封装Debug 的时候比单步追踪高效太多。9.2 随机插入 中序遍历验证这是我最推荐的功能测试方式。插入大量随机数据之后跑一遍中序遍历如果输出不是严格升序说明插入逻辑或节点链接有 bugstd::vectorint keys; for (int i 0; i 10000; i) { int val rand() % 100000; tree.insert(val); keys.push_back(val); } std::vectorint sorted; tree.inorder(sorted); for (size_t i 1; i sorted.size(); i) { if (sorted[i - 1] sorted[i]) { std::cerr BST property violated! std::endl; break; } }再换一种思路验证删除每次删除一个值之后重新做中序遍历同时用原序列的排序结果和保留值集合做对照能系统性地暴露删除中的边界问题。9.3 悬垂指针与重复删除问题删除操作最容易翻车的就是悬垂指针。最常见的错误写法是在情况三中找到了 successor 后直接delete successor而 successor 还在原树中删除后原树里还挂着指向已释放内存的指针。正确做法是只替换 key不直接释放后继节点本身让递归删除负责释放。还有一个相关坑递归删除时返回值必须正确挂接到父节点。很多人写删除时把return node写成了return nullptr遇到只有一个孩子的节点时直接把整棵子树丢了。调试这类问题的方法也很朴素插入数据构造树然后删除一个叶子节点、一个单孩子节点、一个双孩子节点各一次每一步都打印整棵树确认结构符合预期。10. 从裸 BST 到平衡树的进阶路线如果你已经手写实现过 BST 的插入、删除、查找和中序遍历接下来自然进入平衡树的学习。这里我给一条清晰的学习路线AVL 树先学它。因为它的平衡条件是“任意节点左右子树高度差不超过 1”判断标准清晰旋转类型LL、RR、LR、RL固定适合建立“通过旋转恢复平衡”的直觉。红黑树理解了 AVL 之后学它。红黑树的平衡条件不如 AVL 直观但它的工程价值更高C 的标准库关联容器底层就是它。B 树 / B 树数据库方向会接触核心思想从“二叉树”变成“多路平衡”维护的依然是“有序 平衡”这两个本质。学习平衡树时有一个好用的心法不要在脑子里模拟旋转先在纸上画出旋转前后子树高度的变化。旋转的本质是三段子树的重新排列画几次之后你会发现LL 型、RR 型、LR 型、RL 型只是同一个“找失衡节点 沿路径旋回去”思路的四种不同表现。我自己学红黑树时一开始被“红黑冲突变黑、叔叔节点变色”这套规则劝退过好几次后来换成“每步都在纸上画一遍”的方法才真正内化。如果你也有类似困扰建议别急着记五个规则先确保自己能在红黑树上模拟出插入后的一次左旋和右旋。最后的个人体会很多人学数据结构喜欢背代码模板但我建议你至少把 BST 的删除操作自己从头推导三遍。你会发现每写一遍对“递归返回值代表新子树根”这个抽象的理解都会加深一层。这个抽象一旦通了后面学 AVL、红黑树、B 树都会顺很多。有一些平时不太起眼的工程习惯比如节点分配和释放成对出现、递归函数明确入口出口、测试时从最简单输入开始也都是在写这棵树的过程中慢慢养成的这些习惯放到更大的项目和更复杂的系统里都是通用的。
返回列表