☰
C++手写二叉树:从节点设计到内存管理的完整指南
2026/10/15 5:04:14 网站建设 项目流程

1. 为什么我劝你手写一遍二叉树,而不是直接调库

刚入行那会儿,我也觉得二叉树这东西离业务很远。业务代码里全是数组、哈希表、队列,谁没事儿去手搓一棵树?直到有一次做权限系统的菜单树渲染,前端传过来的是一棵多叉树,后端需要做节点过滤、层级聚合、路径回溯。我第一反应是找个现成的树结构库,结果发现要么接口太重,要么不支持我要的遍历顺序,最后硬着头皮自己写了一个,才发现——二叉树是所有树形结构的“母语”,你把它吃透了,多叉树、字典树、堆、线段树全都是它的变体。

这篇笔记就是把我自己从“看得懂”到“写得出”这段过程完整记录下来。关键词很明确:C++、二叉树、数据结构、递归、指针、内存管理。适合两类人看:一类是正在学数据结构、被指针和递归绕晕的学生;另一类是工作几年、发现树形结构无处不在、想回头补课的开发者。我不会只给你贴一段能跑的代码,而是把每个设计决策背后的“为什么”讲清楚——为什么用指针不用数组、为什么遍历要分递归和迭代两套、为什么删除节点是整棵树里最反直觉的操作。

先给一个整体认知:二叉树在 C++ 里本质上就是一组通过指针互相连接的节点,每个节点存一个值,外加左右两个指向子节点的指针。听起来简单,但真正写起来,坑全在细节里:空指针怎么处理、递归的边界在哪、内存什么时候释放、迭代遍历时栈怎么模拟。下面我按“建树 → 遍历 → 增删查 → 内存 → 实战”这条线,把每个环节拆开讲。

2. 节点结构设计:一个struct里藏着的三个决策

2.1 用struct还是class,以及为什么成员默认公开

很多人一上来就纠结用struct还是class。在 C++ 里这两者唯一的区别就是默认访问权限:struct默认public,class默认private。对于二叉树节点这种纯数据载体,它不需要封装行为,也不需要隐藏状态,用struct是最自然的选择。我见过有人非要用class然后写一堆getLeft()、setLeft(),纯属给自己找麻烦——节点就是数据,数据就该直接访问。

struct TreeNode { int val; TreeNode* left; TreeNode* right; TreeNode(int x) : val(x), left(nullptr), right(nullptr) {} };

这段代码里有个细节值得说:构造函数用初始化列表。为什么不用在函数体里赋值?因为初始化列表是“初始化”,函数体里赋值是“先默认初始化再赋值”,对于指针类型差别不大,但对于有构造函数的成员类型,初始化列表能少一次构造+析构。这是 C++ 的基本功,写节点结构时就该养成习惯。

2.2 指针成员必须显式初始化为nullptr

这是我最想强调的一点。TreeNode* left;这样写,left里存的是垃圾值,不是空指针。你后面判断if (node->left == nullptr)的时候,它可能是个随机地址,程序直接崩溃或者出现诡异行为。我当年调一个段错误调了一下午,最后发现就是忘了初始化指针。

注意:C++11 之后统一用nullptr,不要再用NULL或0。nullptr有独立类型std::nullptr_t,能避免函数重载时的歧义。

2.3 值类型的选择:int只是教学用

教学代码里val都是int,但实际项目里你可能会存string、自定义对象、甚至void*。如果存的是大对象,节点拷贝成本会很高,这时候要考虑用std::shared_ptr或者存指针。但初学阶段不要碰智能指针,先把裸指针的来龙去脉搞清楚,否则你连内存泄漏是怎么发生的都不知道。等你能手写出一棵不会漏内存的树,再去用unique_ptr管理子节点,那才是水到渠成。

3. 建树:从手动连接到层序数组还原

3.1 手动建一棵小树,先建立“指针感”

学二叉树最有效的方法不是一上来就写通用建树函数,而是手动把几个节点连起来,亲眼看到指针是怎么指的:

TreeNode* root = new TreeNode(1); root->left = new TreeNode(2); root->right = new TreeNode(3); root->left->left = new TreeNode(4); root->left->right = new TreeNode(5);

这五条语句建出来的树,根是 1,左子树根是 2,右子树根是 3,2 的左右孩子是 4 和 5。你在纸上画一遍,把每个new出来的节点地址标上,把left、right指向的地址画成箭头,这一步不能省。指针的本质就是“存地址的变量”,二叉树的所有操作都是在这个认知上展开的。

3.2 用层序数组还原一棵树

手动连接只适合三五节点的玩具树。真正刷题或者测试的时候,输入通常是一个层序数组,比如[1,2,3,null,5],null表示该位置没有节点。写一个还原函数是很有必要的:

TreeNode* buildTree(const vector<string>& nodes) { if (nodes.empty() || nodes[0] == "null") return nullptr; TreeNode* root = new TreeNode(stoi(nodes[0])); queue<TreeNode*> q; q.push(root); int i = 1; while (!q.empty() && i < nodes.size()) { TreeNode* cur = q.front(); q.pop(); if (i < nodes.size() && nodes[i] != "null") { cur->left = new TreeNode(stoi(nodes[i])); q.push(cur->left); } i++; if (i < nodes.size() && nodes[i] != "null") { cur->right = new TreeNode(stoi(nodes[i])); q.push(cur->right); } i++; } return root; }

这个函数的逻辑是:用队列做层序推进,每次从队列取出一个节点,依次给它分配左孩子和右孩子。关键点是i的推进节奏——每处理一个孩子就i++,不管这个孩子是不是null,因为数组里null也占了一个位置。我见过有人在这里写错,导致树的结构整体错位,调半天调不出来。

3.3 建树时的内存分配策略

每次new TreeNode(...)都是一次堆分配。如果树很大(比如十万节点),频繁new会有性能问题。一种优化是预分配一个节点池,用一个vector<TreeNode>存所有节点,然后用下标代替指针。但这是进阶话题,初学阶段老老实实new,先把逻辑跑通。等你发现建树成了瓶颈,再考虑池化。

4. 四种遍历:递归是理解,迭代是功力

4.1 前中后序递归:三行代码背后的“访问时机”

递归遍历的代码短到令人发指,但很多人只是背下来了,没理解为什么这么写:

void preorder(TreeNode* root) { if (!root) return; cout << root->val << " "; // 访问根 preorder(root->left); // 左 preorder(root->right); // 右 }

前序、中序、后序的唯一区别就是那行cout的位置。前序在递归左右之前访问,中序在左递归之后、右递归之前访问,后序在左右递归都完成之后访问。为什么位置不同结果就不同?因为递归的本质是函数调用栈,cout放在哪一行,就决定了它在“进入节点”和“离开节点”之间的哪个时刻执行。

我建议你在纸上模拟一遍三个节点的树,把每次函数调用和返回画成栈的压入弹出,标出cout执行的时刻。这一步做完,你对递归的理解会上升一个台阶。

4.2 迭代遍历:用栈模拟递归调用栈

递归虽然简洁,但有个致命问题:递归深度受栈空间限制。一棵退化成链表的树,十万个节点,递归直接爆栈。所以生产环境里,遍历通常要写成迭代版。

前序迭代最直观,因为访问顺序和入栈顺序一致:

void preorderIter(TreeNode* root) { if (!root) return; stack<TreeNode*> st; st.push(root); while (!st.empty()) { TreeNode* cur = st.top(); st.pop(); cout << cur->val << " "; if (cur->right) st.push(cur->right); // 先压右 if (cur->left) st.push(cur->left); // 后压左 } }

注意这里先压右孩子再压左孩子,因为栈是后进先出,这样左孩子会先被弹出处理,符合前序“根左右”的顺序。这个细节是迭代遍历里最容易搞反的地方。

中序迭代就绕一些,需要用一个指针一路向左走到底:

void inorderIter(TreeNode* root) { stack<TreeNode*> st; TreeNode* cur = root; while (cur || !st.empty()) { while (cur) { st.push(cur); cur = cur->left; } cur = st.top(); st.pop(); cout << cur->val << " "; cur = cur->right; } }

这段代码的逻辑是:先把当前节点和它所有的左后代压栈,然后弹出栈顶访问,再转向右子树。为什么这样能得到中序?因为栈里保存的是“还没访问但左子树已经处理完”的节点,弹出时正好满足“左-根-右”。

4.3 层序遍历:队列才是主角

层序遍历和前三种不是一个体系,它用的是队列而不是栈:

void levelOrder(TreeNode* root) { if (!root) return; queue<TreeNode*> q; q.push(root); while (!q.empty()) { int size = q.size(); for (int i = 0; i < size; i++) { TreeNode* cur = q.front(); q.pop(); cout << cur->val << " "; if (cur->left) q.push(cur->left); if (cur->right) q.push(cur->right); } cout << endl; // 换行表示一层结束 } }

这里size = q.size()那行是关键,它把当前层的节点数固定下来,循环只处理这一层,处理完换行。如果你不记录 size,直接 while 循环,就没法区分层与层。这个技巧在“按层收集结果”“求树的最大宽度”这类题里反复出现。

4.4 四种遍历的对比与选择

遍历方式数据结构访问顺序典型用途
前序栈/递归根-左-右复制树、序列化
中序栈/递归左-根-右二叉搜索树有序输出
后序栈/递归左-右-根释放内存、计算子树
层序队列逐层求深度、按层处理

选择哪种遍历,取决于你要在“什么时候”处理根节点。需要先拿到根的信息再处理子树,用前序;需要子树结果汇总到根,用后序;需要有序,用中序;需要按层级,用层序。

5. 增删查:插入容易,删除才是真正的考验

5.1 二叉搜索树的查找与插入

普通二叉树没有顺序约束,查找只能遍历。但二叉搜索树(BST)有“左小右大”的性质,查找可以二分:

TreeNode* search(TreeNode* root, int target) { while (root) { if (root->val == target) return root; root = (target < root->val) ? root->left : root->right; } return 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; }

注意这里返回TreeNode*并重新赋值给root->left或root->right,是为了处理“空树插入第一个节点”的情况。如果写成void,就没法把新节点挂到父节点上,这是递归插入的经典写法。

5.2 删除节点:三种情况,最复杂的是“两个孩子”

删除是 BST 里最反直觉的操作。分三种情况:

  1. 叶子节点:直接删,父节点对应指针置空。
  2. 只有一个孩子:用孩子替代自己。
  3. 有两个孩子:找右子树的最小节点(或左子树的最大节点)替代自己,然后删除那个替代节点。
TreeNode* deleteNode(TreeNode* root, int key) { if (!root) return nullptr; if (key < root->val) { root->left = deleteNode(root->left, key); } else if (key > root->val) { root->right = deleteNode(root->right, key); } else { if (!root->left) { TreeNode* tmp = root->right; delete root; return tmp; } if (!root->right) { TreeNode* tmp = root->left; delete root; return tmp; } TreeNode* minNode = root->right; while (minNode->left) minNode = minNode->left; root->val = minNode->val; root->right = deleteNode(root->right, minNode->val); } return root; }

为什么用右子树最小节点替代?因为它是右子树里最小的,比左子树所有节点都大,又比右子树其他节点都小,放到根的位置正好满足 BST 性质。这个逻辑我当年看了三遍才想通,建议你手动模拟一遍删除过程。

5.3 删除时的内存释放陷阱

上面代码里delete root之后返回tmp,这个顺序不能反。如果你先返回再 delete,或者 delete 之后还访问 root 的成员,就是悬空指针访问,行为未定义。我踩过这个坑:delete 之后打印了一下 root->val,程序没崩但输出是垃圾值,这种“看起来正常”的 bug 最难查。

6. 内存管理:写完树不释放,等于白写

6.1 后序遍历释放整棵树

C++ 没有垃圾回收,new出来的节点必须手动delete。释放一棵树要用后序,因为必须先释放左右子树,再释放根:

void destroyTree(TreeNode* root) { if (!root) return; destroyTree(root->left); destroyTree(root->right); delete root; }

为什么不能用前序?如果你先delete root,那root->left和root->right就访问不到了,整棵子树泄漏。这个顺序问题,本质上和遍历顺序的选择是同一个道理。

6.2 用 RAII 思想管理树的生命周期

裸指针管理树很容易漏 delete。进阶做法是写一个Tree类,在析构函数里调用destroyTree:

class BinaryTree { TreeNode* root; public: BinaryTree() : root(nullptr) {} ~BinaryTree() { destroyTree(root); } // 禁止拷贝,或者实现深拷贝 BinaryTree(const BinaryTree&) = delete; BinaryTree& operator=(const BinaryTree&) = delete; };

这样只要BinaryTree对象离开作用域,整棵树自动释放。这就是 RAII——资源获取即初始化,资源释放绑定在对象生命周期上。现代 C++ 里更推荐用unique_ptr管理子节点,但那是另一个话题了。

6.3 内存泄漏怎么自查

如果你怀疑树有内存泄漏,最土但最有效的办法是:在new和delete处加计数器,程序结束时打印差值。差值不为零就是漏了。更专业的工具是 Valgrind,但学习阶段用计数器就够了。我当年写树的时候,计数器显示 new 了 15 次 delete 了 12 次,一查发现是删除节点时漏了释放替代节点。

7. 从二叉树到实战:我踩过的三个真实坑

7.1 递归返回值没接住,树结构断了

写递归插入的时候,我一开始写成:

void insert(TreeNode* root, int val) { if (!root) { root = new TreeNode(val); return; } // ... }

结果树永远是空的。原因很简单:root是按值传递的指针,函数内重新赋值只改了局部变量,外面的根指针没变。正确做法是传TreeNode*&或者返回新指针让调用方接住。这个坑我踩了两次才记住。

7.2 迭代中序时栈里存了已访问节点

中序迭代我一开始写成每轮把左右孩子都压栈,结果顺序全乱。后来才明白,中序迭代的核心是用指针一路向左,栈只存“待访问的祖先”,而不是把所有节点都塞进去。这个理解偏差导致我调了整整一个晚上。

7.3 层序遍历忘记记录 size,层边界丢失

层序遍历如果不记录每层 size,输出就是一串数字,没法按层分组。我一开始觉得“反正都遍历到了”,直到做“二叉树右视图”那道题,需要每层最后一个节点,才发现没有层边界根本做不了。记录 size 这个动作,是层序遍历从“能跑”到“能用”的分水岭。

8. 写在最后:二叉树教会我的事

手写二叉树这段时间,我最大的收获不是会写几种遍历,而是理解了递归和迭代的等价性。任何递归都能用栈+循环改写,任何迭代也能反过来写成递归。这个认知让我后来看很多算法题都通透了不少——动态规划是递归加缓存,回溯是递归加撤销,分治是递归加合并,本质上都是同一套思维。

如果你也在学数据结构,我的建议是:不要满足于看懂,一定要手写一遍。看懂别人的代码只需要五分钟,自己写出来可能要两小时,但这两小时里你踩的每个坑,都是别人代码里看不到的。尤其是删除节点和内存释放这两块,光看是永远学不会的,必须自己 new 几个节点、delete 几次、崩几次,才能真正记住。

后续如果还想深入,可以往这几个方向走:用unique_ptr重写节点结构、实现平衡二叉树(AVL 或红黑树)、把二叉树扩展到多叉树和字典树。每一步都是在现在这棵小树的基础上长出来的。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询