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是C++11引入的空指针常量,比传统的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::queue<TreeNode*> 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::queue<TreeNode*> 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,所以应该插入右子树。 - 走到节点
7,6 < 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::queue<TreeNode*> 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的构建、遍历、搜索和删除过程,特别是删除根节点后,树的结构是如何通过寻找后继节点来维持有序性的。动手把代码敲一遍,在调试模式下观察指针的变化,比看十遍图解都管用。二叉树的操作,本质上就是指针操作和递归思想的应用,理解了这一点,你就掌握了它的精髓。