二叉树增删改查全解析:从C++代码到内存管理实战

发布时间:2026/8/25 8:05:32
二叉树增删改查全解析:从C++代码到内存管理实战 1. 项目概述为什么二叉树是程序员的“基本功”如果你写过代码尤其是处理过稍微复杂一点的数据比如文件目录、组织架构图或者游戏里的技能树那你大概率已经和二叉树打过交道了。它不像数组、链表那样直观但却是理解更复杂数据结构如堆、红黑树、B树的基石。很多人学数据结构卡在二叉树这里就进行不下去了感觉概念都懂但一让写代码就无从下手增删改查每一步都像在走钢丝。这正是我们今天要彻底解决的问题。我不打算给你罗列一堆干巴巴的定义和公式而是带你像搭积木一样从零构建一棵二叉树。我们会用最直白的图解把每一个指针的指向、每一次递归的调用栈都画出来然后配上可以直接运行的C代码。你会发现所谓的“增删改查”核心就是理解指针或引用如何在节点之间“穿梭”以及递归思想如何优雅地处理树形结构。无论你是正在备战期末考试、准备技术面试还是单纯想夯实基础这篇内容都能让你对二叉树有一个“肌肉记忆”般的理解。2. 二叉树的“骨架”节点设计与创建在动手增删改查之前我们得先有“砖块”。二叉树的砖块就是节点Node。2.1 节点结构定义数据与两条“手臂”想象一下一个节点就像一个人他手里掌握着一份数据比如一个整数然后他还有左、右两条“手臂”分别用来拉住他的左孩子和右孩子。如果某个方向没有孩子他的那条手臂就空着指向NULL。用C代码来定义这个结构通常我们会用一个结构体struct// 二叉树节点的定义 struct TreeNode { int val; // 节点存储的数据这里以整型为例 TreeNode *left; // 左子节点的指针即“左臂” TreeNode *right; // 右子节点的指针即“右臂” // 构造函数方便创建新节点 TreeNode(int x) : val(x), left(nullptr), right(nullptr) {} };这里有几个关键点需要注意数据域val它可以是任意类型int, string, 甚至是自定义的结构体。为了聚焦于树的结构操作我们先用最简单的int。指针域left和right它们是指向TreeNode类型的指针。在C中我们用*来声明指针。nullptr是C11引入的空指针常量比传统的NULL更安全。构造函数TreeNode(int x)让我们可以用new TreeNode(5)这样的方式快速创建一个值为5且左右孩子都为空的节点。这比先new再分别赋值要简洁得多。注意在实际情况中根据需求节点可能还会包含一个指向父节点的指针parent这在某些操作如删除中会带来便利但也会增加维护成本。我们首先掌握最基础的结构。2.2 手动构建一棵简单的二叉树理解了节点我们就可以像拼乐高一样把节点连接起来形成树。假设我们要构建下面这棵简单的二叉树1 / \ 2 3 / \ 4 5对应的代码就是一步步创建节点并正确设置它们的左右指针// 手动构建二叉树的示例代码 TreeNode* buildSimpleTree() { // 1. 创建各个节点 TreeNode* node1 new TreeNode(1); TreeNode* node2 new TreeNode(2); TreeNode* node3 new TreeNode(3); TreeNode* node4 new TreeNode(4); TreeNode* node5 new TreeNode(5); // 2. 按照树形结构连接指针 node1-left node2; // 节点1的左臂拉住节点2 node1-right node3; // 节点1的右臂拉住节点3 node2-left node4; // 节点2的左臂拉住节点4 node2-right node5; // 节点2的右臂拉住节点5 // 节点3、4、5的左右臂默认是nullptr即没有孩子 // 3. 返回根节点通过根节点可以访问整棵树 return node1; }这个过程非常直观。关键在于树是由指针链接起来的节点集合它没有一个整体的容器对象。我们通常只持有根节点node1的指针通过它来访问整棵树。如果你把node1这个指针弄丢了那么即使其他节点还在内存里程序也无法再找到它们这就造成了内存泄漏。3. 二叉树的“查”遍历与搜索有了树我们首先想知道怎么“看”它这就是遍历。遍历是其他所有操作的基础。3.1 深度优先遍历DFS递归的经典舞台深度优先遍历顾名思义就是一条路走到黑走到叶子节点再回头。根据访问根节点的时机分为三种经典顺序。我会用下面这棵树来演示1 / \ 2 3 / \ \ 4 5 63.1.1 前序遍历根 - 左 - 右访问顺序是先访问根节点然后递归地前序遍历左子树再递归地前序遍历右子树。递归过程图解以节点1为起点访问节点1输出1。进入左子树节点2。访问节点2输出2。进入节点2的左子树节点4。访问节点4输出4。节点4是叶子返回。回到节点2进入其右子树节点5。访问节点5输出5。返回。回到节点1进入其右子树节点3。访问节点3输出3。进入节点3的右子树节点6。访问节点6输出6。结束。最终输出1, 2, 4, 5, 3, 6代码实现void preorderTraversal(TreeNode* root) { if (root nullptr) { return; // 递归的基准情况如果节点为空直接返回 } // 1. 访问根节点 std::cout root-val ; // 2. 递归遍历左子树 preorderTraversal(root-left); // 3. 递归遍历右子树 preorderTraversal(root-right); }递归代码的精妙之处在于它完美契合了树的定义树是递归定义的。if (root nullptr) return;这一行是递归的“安全出口”没有它程序会崩溃。3.1.2 中序遍历左 - 根 - 右访问顺序是先递归地中序遍历左子树然后访问根节点最后递归地中序遍历右子树。对同一棵树的遍历过程从根节点1开始先进入其左子树节点2。对节点2又先进入其左子树节点4。节点4是叶子访问4输出4返回。回到节点2访问2输出2。进入节点2的右子树节点5。访问5输出5返回。回到节点1访问1输出1。进入节点1的右子树节点3。节点3无左孩子直接访问3输出3。进入节点3的右子树节点6。访问6输出6。最终输出4, 2, 5, 1, 3, 6对于二叉搜索树BST中序遍历的结果是升序序列这是一个极其重要的性质。代码实现void inorderTraversal(TreeNode* root) { if (root nullptr) return; inorderTraversal(root-left); // 左 std::cout root-val ; // 根 inorderTraversal(root-right); // 右 }3.1.3 后序遍历左 - 右 - 根访问顺序是先递归地后序遍历左子树然后递归地后序遍历右子树最后访问根节点。对同一棵树的遍历过程从根节点1开始进入左子树节点2。对节点2进入左子树节点4。访问4输出4返回。对节点2进入右子树节点5。访问5输出5返回。访问节点2输出2。回到节点1进入右子树节点3。对节点3进入右子树节点6。访问6输出6返回。访问节点3输出3。最后访问根节点1输出1。最终输出4, 5, 2, 6, 3, 1后序遍历的特点是当你访问一个节点时其所有子孙节点都已被访问。这在“释放整棵树内存”或“计算子树结果”的场景中非常有用。代码实现void postorderTraversal(TreeNode* root) { if (root nullptr) return; postorderTraversal(root-left); // 左 postorderTraversal(root-right); // 右 std::cout root-val ; // 根 }实操心得很多初学者对递归遍历感到晕眩。一个有效的调试方法是在纸上画出一棵很小的树3-5个节点然后像上面图解那样用笔尖模拟程序执行流一步步写下每个递归调用和返回时访问的节点。坚持画两三次你就会对递归调用栈有“体感”。3.2 广度优先遍历BFS / 层序遍历使用队列层序遍历是按树的层级从上到下、从左到右依次访问节点。这需要用到队列Queue这个辅助数据结构。过程图解还是那棵树[1,2,3,4,5,6]。初始队列[1]。取出1并访问将其左右孩子2,3入队。队列[2, 3]。取出2并访问将其左右孩子4,5入队。队列[3, 4, 5]。取出3并访问将其右孩子6入队。队列[4, 5, 6]。取出4并访问无孩子。队列[5, 6]。取出5并访问无孩子。队列[6]。取出6并访问无孩子。队列空结束。访问顺序1, 2, 3, 4, 5, 6代码实现#include queue void levelOrderTraversal(TreeNode* root) { if (root nullptr) return; std::queueTreeNode* q; q.push(root); // 根节点入队 while (!q.empty()) { TreeNode* current q.front(); // 取出队首节点 q.pop(); std::cout current-val ; // 访问 // 将当前节点的左右孩子如果存在依次入队 if (current-left ! nullptr) { q.push(current-left); } if (current-right ! nullptr) { q.push(current-right); } } }层序遍历的逻辑非常清晰队列保证了“先被看到的节点先被访问”完美符合层级顺序。3.3 在二叉树中搜索特定值给定一个值判断它是否在树中。这本质上是遍历的一种应用。递归实现深度优先思想bool search(TreeNode* root, int target) { if (root nullptr) { return false; // 树空或走到叶子都没找到 } if (root-val target) { return true; // 找到了 } // 没找到则去左子树或右子树继续找 // 这里用逻辑或意味着左子树或右子树任何一个找到即可 return search(root-left, target) || search(root-right, target); }这是一个普通的二叉树搜索时间复杂度是O(N)因为最坏情况要遍历所有节点。如果这是一棵二叉搜索树BST我们可以利用其左小右大的性质将复杂度降至O(log N)。二叉搜索树BST的搜索bool searchBST(TreeNode* root, int target) { if (root nullptr) return false; if (root-val target) return true; // 利用BST性质进行剪枝 if (target root-val) { return searchBST(root-left, target); // 目标值小只搜左子树 } else { return searchBST(root-right, target); // 目标值大只搜右子树 } }4. 二叉树的“增”插入新节点插入操作与树的类型强相关。我们分别讨论普通二叉树和二叉搜索树BST。4.1 在普通二叉树中插入对于没有特定顺序的二叉树插入位置通常没有强制要求。一种常见的简单策略是利用层序遍历找到第一个缺少左孩子或右孩子的位置插入这样可以保持树的相对平衡性。思路与代码TreeNode* insertIntoBinaryTree(TreeNode* root, int value) { TreeNode* newNode new TreeNode(value); if (root nullptr) { return newNode; // 如果树是空的新节点就是根节点 } std::queueTreeNode* q; q.push(root); while (!q.empty()) { TreeNode* current q.front(); q.pop(); // 尝试插入到左孩子位置 if (current-left nullptr) { current-left newNode; return root; // 插入成功返回根节点 } else { q.push(current-left); // 左孩子不空将其入队待检查 } // 尝试插入到右孩子位置 if (current-right nullptr) { current-right newNode; return root; // 插入成功 } else { q.push(current-right); // 右孩子不空将其入队待检查 } } // 理论上只要树不是满二叉树while循环内一定会返回 return root; }这种方法插入的节点会使得树趋向于一颗“完全二叉树”。4.2 在二叉搜索树BST中插入BST的插入必须遵循其定义对于任意节点左子树所有节点值小于它右子树所有节点值大于它。插入过程就是一个寻找合适“空位”的过程。递归图解与代码假设我们要向BST[5,3,7,2,4]中插入值6。从根节点5开始6 5所以应该插入右子树。走到节点76 7所以应该插入左子树。节点7的左孩子为空这正是我们要插入的位置。将新节点6作为7的左孩子。TreeNode* insertIntoBST(TreeNode* root, int value) { // 基准情况找到了空位置创建新节点 if (root nullptr) { return new TreeNode(value); } // 递归寻找插入位置 if (value root-val) { // 新值小应插入左子树。递归结果成为新的左孩子。 root-left insertIntoBST(root-left, value); } else if (value root-val) { // 通常BST不允许重复值这里用 else if // 新值大应插入右子树。递归结果成为新的右孩子。 root-right insertIntoBST(root-right, value); } // 如果值相等根据定义可以不插入或做其他处理如计数 return root; // 返回当前可能更新后的节点指针 }注意root-left insertIntoBST(...)这行代码。递归调用返回的是更新后的左子树的根节点我们需要用它来更新当前节点的左指针。这是递归操作链表/树结构时的常见写法。5. 二叉树的“删”最复杂的操作删除是二叉树操作中最复杂的一环因为删除一个节点后需要妥善处理它的子树同时保持树的结构不被破坏。我们依然分普通二叉树和BST讨论。5.1 在普通二叉树中删除指定值的节点在普通二叉树中如果我们只知道要删除的节点的值操作会非常棘手因为可能有多个相同值的节点且删除后子树的重接方式不唯一。一种可行但并非唯一的方法是找到目标节点后用树中最后一个节点按层序遍历的最后一个节点来替换它然后删除最后一个节点。这样可以避免树中出现空洞。步骤详解层序遍历找到值为key的节点targetNode。层序遍历找到最后一个节点lastNode。将lastNode的值复制到targetNode。找到lastNode的父节点并断开父节点与lastNode的连接。删除lastNode。这个过程代码较长核心在于处理边界情况例如删除的就是最后一个节点或删除根节点。5.2 在二叉搜索树BST中删除节点BST的删除有明确的规则分为三种情况这是面试中的经典考点。设待删除节点为D。情况一D是叶子节点无子节点这是最简单的情况直接将其父节点对应的指针置为nullptr然后删除该节点即可。Parent / D (叶子)操作Parent-left nullptr(或Parent-right nullptr) 然后delete D。情况二D只有一个子节点用其唯一的子节点替代自己的位置。Parent Parent / \ \ D ... - Child / Child操作将Parent指向D的指针改为指向D的Child。然后delete D。情况三D有两个子节点这是最复杂的情况。为了保证删除后树仍保持BST性质不能简单提一个孩子上来。标准做法是找到D的中序遍历后继节点S即右子树中最小的节点也就是右子树一直向左走到底的节点。这个节点是大于D的最小值。用S的值覆盖D的值。现在问题转化为在D的右子树中删除这个值最小的节点S。而S一定没有左孩子否则就不是最小所以删除S就退化成了情况一或情况二变得容易处理。图解删除节点5(有两个孩子)5 6 / \ / \ 3 7 - 3 7 / \ / \ / \ \ 2 4 6 8 2 4 8找到5的后继节点6右子树的最小值。用6的值覆盖5。在右子树中删除原来的节点6它只有一个右孩子或没有孩子。递归代码实现TreeNode* deleteNode(TreeNode* root, int key) { if (root nullptr) return nullptr; // 没找到要删除的节点 // 1. 查找阶段 if (key root-val) { root-left deleteNode(root-left, key); // 去左子树删 } else if (key root-val) { root-right deleteNode(root-right, key); // 去右子树删 } else { // 2. 找到要删除的节点 root // 情况1 2: 只有一个子节点或没有子节点 if (root-left nullptr) { TreeNode* rightChild root-right; delete root; return rightChild; // 用右孩子替代自己 } else if (root-right nullptr) { TreeNode* leftChild root-left; delete root; return leftChild; // 用左孩子替代自己 } // 情况3: 有两个子节点 // 找到右子树的最小节点中序后继 TreeNode* successor findMin(root-right); // 用后继的值覆盖当前节点 root-val successor-val; // 递归删除右子树中的那个后继节点它现在值重复了 root-right deleteNode(root-right, successor-val); } return root; } // 辅助函数找到以 node 为根的树中的最小节点 TreeNode* findMin(TreeNode* node) { while (node-left ! nullptr) { node node-left; } return node; }这段递归代码非常精炼地处理了所有情况。root-left deleteNode(...)这种写法同样是为了在递归返回后更新父节点的指针。踩坑实录在情况三中一个常见的错误是直接交换节点而不是交换值然后去删除交换后的节点。这需要对指针进行复杂的操作极易出错。而“复制值删除后继节点”是更清晰、更安全的做法。务必记住后继节点位于右子树中且一定没有左孩子。6. 二叉树的“改”修改节点值与结构变更“改”操作通常指修改节点的值。对于普通二叉树直接找到节点修改其val即可。但对于二叉搜索树BST修改值可能会破坏BST的性质因此BST的修改不能直接改值而应该视为一个“删除旧节点 插入新值”的复合操作。BST修改值的正确做法TreeNode* modifyBST(TreeNode* root, int oldVal, int newVal) { if (root nullptr) return root; // 1. 删除旧值节点 root deleteNode(root, oldVal); // 复用之前的删除函数 // 2. 插入新值 root insertIntoBST(root, newVal); // 复用之前的插入函数 return root; }这个操作的时间复杂度是O(log N)两次查找结构调整直接改值再重新平衡虽然可能更快但实现起来复杂得多通常不这么做。除了改值广义的“改”还包括改变树的结构例如翻转二叉树镜像。这是一个经典的递归问题。翻转二叉树图解与代码翻转[4,2,7,1,3,6,9]原树 翻转后 4 4 / \ / \ 2 7 - 7 2 / \ / \ / \ / \ 1 3 6 9 9 6 3 1思路对于每个节点交换它的左右子树然后递归地对左右子树做同样的事。TreeNode* invertTree(TreeNode* root) { if (root nullptr) return nullptr; // 交换当前节点的左右孩子 TreeNode* temp root-left; root-left root-right; root-right temp; // 递归翻转左右子树 invertTree(root-left); invertTree(root-right); return root; }7. 核心辅助操作与内存管理7.1 计算二叉树的高度深度树的高度是根节点到最远叶子节点的最长路径上的节点数。空树高度为0单节点树高度为1。递归定义树的高度 1 max(左子树高度 右子树高度)。int getHeight(TreeNode* root) { if (root nullptr) { return 0; // 基准情况空树高度为0 } int leftHeight getHeight(root-left); int rightHeight getHeight(root-right); // 当前节点贡献一层高度加上左右子树中更高的那个 return 1 std::max(leftHeight, rightHeight); }7.2 计算二叉树的节点总数int countNodes(TreeNode* root) { if (root nullptr) return 0; return 1 countNodes(root-left) countNodes(root-right); }7.3 释放二叉树内存防止内存泄漏由于树节点是通过new在堆上分配的使用完毕后必须手动释放否则会造成内存泄漏。必须使用后序遍历因为只有先释放了左右子树才能安全地释放当前节点否则你会丢失对孩子节点的引用无法释放它们。void deleteTree(TreeNode* root) { if (root nullptr) return; deleteTree(root-left); // 释放左子树 deleteTree(root-right); // 释放右子树 delete root; // 释放当前节点 // 注意在函数外部应将指向根节点的指针置为nullptr避免成为悬空指针 }8. 从理论到实战一个完整的二叉搜索树程序示例最后我们把所有操作串起来写一个简单的、交互式的二叉搜索树管理程序以巩固理解。#include iostream #include queue struct TreeNode { int val; TreeNode *left; TreeNode *right; TreeNode(int x) : val(x), left(nullptr), right(nullptr) {} }; // 插入函数 TreeNode* insert(TreeNode* root, int val) { if (!root) return new TreeNode(val); if (val root-val) root-left insert(root-left, val); else if (val root-val) root-right insert(root-right, val); // 忽略重复值 return root; } // 查找函数 bool search(TreeNode* root, int val) { if (!root) return false; if (val root-val) return true; if (val root-val) return search(root-left, val); else return search(root-right, val); } // 找最小节点函数 TreeNode* findMin(TreeNode* node) { while (node node-left) node node-left; return node; } // 删除函数 TreeNode* deleteNode(TreeNode* root, int val) { if (!root) return nullptr; if (val root-val) { root-left deleteNode(root-left, val); } else if (val root-val) { root-right deleteNode(root-right, val); } else { // 找到节点 if (!root-left) { TreeNode* rightChild root-right; delete root; return rightChild; } else if (!root-right) { TreeNode* leftChild root-left; delete root; return leftChild; } // 有两个孩子 TreeNode* successor findMin(root-right); root-val successor-val; root-right deleteNode(root-right, successor-val); } return root; } // 中序遍历有序输出 void inorderPrint(TreeNode* root) { if (!root) return; inorderPrint(root-left); std::cout root-val ; inorderPrint(root-right); } // 层序遍历打印 void levelOrderPrint(TreeNode* root) { if (!root) return; std::queueTreeNode* q; q.push(root); while (!q.empty()) { TreeNode* cur q.front(); q.pop(); std::cout cur-val ; if (cur-left) q.push(cur-left); if (cur-right) q.push(cur-right); } } // 释放内存 void destroyTree(TreeNode* root) { if (!root) return; destroyTree(root-left); destroyTree(root-right); delete root; } int main() { TreeNode* root nullptr; root insert(root, 50); root insert(root, 30); root insert(root, 70); root insert(root, 20); root insert(root, 40); root insert(root, 60); root insert(root, 80); std::cout 中序遍历BST (有序): ; inorderPrint(root); std::cout std::endl; std::cout 层序遍历BST: ; levelOrderPrint(root); std::cout std::endl; std::cout 搜索40: (search(root, 40) ? 找到 : 未找到) std::endl; std::cout 搜索90: (search(root, 90) ? 找到 : 未找到) std::endl; std::cout 删除50 (根节点有两个孩子)... std::endl; root deleteNode(root, 50); std::cout 删除后中序遍历: ; inorderPrint(root); std::cout std::endl; destroyTree(root); // 程序结束前释放所有内存 root nullptr; return 0; }运行这个程序你可以直观地看到BST的构建、遍历、搜索和删除过程特别是删除根节点后树的结构是如何通过寻找后继节点来维持有序性的。动手把代码敲一遍在调试模式下观察指针的变化比看十遍图解都管用。二叉树的操作本质上就是指针操作和递归思想的应用理解了这一点你就掌握了它的精髓。