
树形结构这块内容我上一篇写了基础概念部分把什么是树、什么是度、什么是深度这些术语过了一遍。但说实话那些东西更像是“看图说话”真正到了写代码的时候很多人才发现自己压根不会用。尤其是链式结构二叉树——也就是每个节点真正通过指针串起来的那种实现——才是日常开发、面试笔试、甚至阅读开源框架源码时最常遇到的东西。这几天我在几个技术社群里转了一圈发现“写二叉树程序时为什么总是报运行时错误”这个话题的热度出奇地高。很多人贴出来的代码乍一看逻辑通顺一跑就崩要么是空指针要么是栈溢出要么是删了一棵子树结果整棵树都没了。这也是我为什么想把“树”这个系列单独拆一篇出来专门聊链式结构二叉树的原因。所以这篇就围绕链式结构二叉树从存储结构选型、节点定义、三种遍历、递归出口设计、树的构建与验证一路讲到底层的调试经验和扩展视野。适合刚学完树概念、准备动手写第一棵链式二叉树的读者也适合那些写了二叉树但总被运行时错误折腾得头皮发麻的朋友。1. 为什么到了“链式”这一步才算真正开始用二叉树1.1 数组式二叉树的尴尬空间浪费与插入删除的代价很多教材讲二叉树会先提一种叫“顺序存储”的方式——直接用数组来存。这个方案对完全二叉树非常友好因为父节点在数组中的下标是i左孩子就是2*i1右孩子就是2*i2拿公式一算就到位了不需要额外存指针省内存、命中缓存还快。但你要拿数组存一棵普通二叉树问题就来了。假设有一棵只有三个节点的斜树根节点只有左孩子左孩子又只有左孩子。按照完全二叉树的编号规则去填数组你会发现数组长度要开到 7 才能放得下这 3 个节点中间一大片下标全是空的。极端情况下一棵高度为 h 的“单链表式”二叉树用数组存需要2^h - 1个位置才能装下 h 个有效节点空间浪费是指数级的。这还没完数组式存储要做删除节点、插入新子树必须先移动大片数据时间复杂度直接 O(n)。1.2 链式结构解决的三个核心问题链式结构二叉树的核心思路特别简单每个节点用一个结构体表示里面存自己的数据再加两个指针分别指向左孩子和右孩子。没有孩子就把指针置空。这样逻辑上相邻的两个节点物理上可以放在内存的任何位置完全不需要维护“下标公式”。链式方案解决了三个实际问题空间按需分配节点是一个个动态创建的不存在预分配大片数组的问题斜树有多少节点就只占多少内存。删除插入成本低改指针就行不需要移动其他节点。比如把一个左子树整体替换掉先保存新子树根指针再更新当前节点的左指针两步完成O(1) 复杂度。表达能力强不管是完全二叉树、满二叉树还是歪歪扭扭的普通树链式结构都能一个萝卜一个坑地表示没有“空洞”的概念。1.3 递归定义和链式存储天然是一对二叉树的递归定义是二叉树要么是空树要么是一个根节点加上左右两棵子树。这不就是链式结构的真实写照吗每个节点的指针域里挂着的正是“以某个节点为根的子树”。所以用链式结构去实现二叉树其实是把逻辑定义直接翻译成了物理存储。你在纸上画一个树形图然后照葫芦画瓢地定义节点出来的代码就是链式二叉树。很多人学二叉树觉得吃力是因为他们还在用“数组思维”去理解树。一旦你想明白“每个节点就是一个结构体对象孩子在对象里以指针形式存在”很多递归操作就顺理成章了。2. 链式二叉树的骨架节点定义与遍历的代码级拆解2.1 节点选型左右孩子指针为什么够用先看一段最经典的 C 节点定义struct TreeNode { int val; TreeNode* left; TreeNode* right; TreeNode(int x) : val(x), left(nullptr), right(nullptr) {} };三个成员一个存值两个存指针。构造函数把指针初始化为空保证新节点创建出来不会带着野指针。在 Java、Python、Go 里对应的写法也差不多无非是 Python 用None、Go 用*TreeNode指针类型。有一些场景会用到“三叉链”——也就是在左右孩子的基础上再加一个parent指针指向父节点。这在需要从子节点回溯到父节点的场景里非常有用比如实现红黑树的旋转调整、并查集的可持久化、或者解决二叉树最近公共祖先问题里的“自底向上”访问需求。代价是每个节点多一个指针的存储开销而且修改树结构时必须同步维护 parent 字段稍不留神就会形成“孤儿节点”。我个人的建议是做基础练习和大多数业务场景左右孩子指针完全够用不要急着上三叉链。先把递归玩明白再考虑回溯优化。2.2 前中后序遍历递归版本的三种姿势链式二叉树最经典的操作就是遍历。递归版本极其简洁核心就三个动作访问当前节点用V表示、遍历左子树L、遍历右子树R。按访问时机不同得到三种顺序void preorder(TreeNode* root) { // 前序V L R if (!root) return; cout root-val ; // 先访问根 preorder(root-left); preorder(root-right); } void inorder(TreeNode* root) { // 中序L V R if (!root) return; inorder(root-left); cout root-val ; // 中间访问根 inorder(root-right); } void postorder(TreeNode* root) { // 后序L R V if (!root) return; postorder(root-left); postorder(root-right); cout root-val ; // 最后访问根 }三份代码长得几乎一样唯一的区别就是cout那行代码的位置。这种“对称美”是很多人喜欢二叉树递归的原因之一。但要注意递归虽然好写底层却是靠函数调用栈撑着的。如果树的高度非常高比如一万层的斜树递归下去栈可能直接爆掉。所以面试或工程实践中必须掌握迭代写法。2.3 迭代遍历为什么递归转迭代是基本功迭代写法的核心是显式维护一个栈。以前序遍历为例由于栈是后进先出想要实现“根左右”的顺序入栈时反而要先压右孩子、再压左孩子void preorderIterative(TreeNode* root) { if (!root) return; stackTreeNode* 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 levelOrder(TreeNode* root) { if (!root) return; queueTreeNode* q; q.push(root); while (!q.empty()) { int n q.size(); while (n--) { 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; // 每层换行区分层级 } }最外面那层while (n--)是一个很常用的技巧它确保每一轮循环里处理的节点都来自同一层方便按层输出。如果你需要统计树的最大宽度、按层打印、或者做二叉树的右视图这个框架可以直接套。2.4 运行时错误集中爆发的场所遍历中的空指针回到开头那个问题——“写二叉树程序时为什么总是报运行时错误”。我见过的绝大多数情况都发生在遍历代码访问空指针上。最常见的错误是void badTraverse(TreeNode* root) { cout root-val ; // 没判空root 一旦是 nullptr 直接崩 badTraverse(root-left); // 调用时 root 可能已经是空指针 badTraverse(root-right); }每次递归调用都假设传入的节点不为空但递归到叶子节点的左右孩子时传入的其实是nullptr这时候第一行就崩了。解决办法是在函数入口处统一判空返回这就是 2.2 节代码里if (!root) return;这行存在的意义。它相当于递归的“刹车”。还有一种隐蔽一些的运行时错误是在层序遍历的循环里漏了对孩子节点的判空。子树缺失时q.push(cur-left)把一个空指针推入队列下一轮循环访问front()再取val同样崩。连锁反应之下报错位置往往离真正的 bug 很远排查起来特别头疼。3. 递归的出口设计树的高度、节点数与那些“差一行”的错误3.1 求高度/深度的两种典型写法对比求一棵二叉树的高度也叫最大深度是递归设计的经典例题。最简单优雅的写法int maxDepth(TreeNode* root) { if (!root) return 0; return max(maxDepth(root-left), maxDepth(root-right)) 1; }这里有个约定俗成的口径问题有些人认为空树高度是 0叶子节点高度是 1另一些人认为叶子节点高度是 0。不同教材定义不同所以刷题时一定要先看题目对“高度”的定义。LeetCode 默认高度从 1 算起空树高度 0。还有一类题目会问“最小深度”这时不能简单地改成min因为最小深度的定义是从根到最近的叶子节点如果直接取左右子树的最小值加一遇到只有左子树、没有右子树的斜树时会得到错误答案 1其实深度应该是左子树深度加一。正确的写法是分情况讨论int minDepth(TreeNode* root) { if (!root) return 0; if (!root-left) return minDepth(root-right) 1; if (!root-right) return minDepth(root-left) 1; return min(minDepth(root-left), minDepth(root-right)) 1; }这个例子很能说明问题递归设计不能光套模板出口条件必须结合实际语义来定。3.2 节点计数与叶子统计递归返回值的设计思路统计节点总数递归返回值就是“左子树节点数 右子树节点数 1”int countNodes(TreeNode* root) { if (!root) return 0; return countNodes(root-left) countNodes(root-right) 1; }统计叶子数则要把“叶子是左右孩子都为空”的节点单独识别出来int countLeaves(TreeNode* root) { if (!root) return 0; if (!root-left !root-right) return 1; return countLeaves(root-left) countLeaves(root-right); }初学者最容易踩的坑是把“叶子节点返回 1”这个分支漏掉或者把“空节点返回 0”写成“返回 1”导致节点数凭空多出一堆。这个问题的本质是递归出口的条件写得不严谨。写递归函数时先想清楚两件事空节点返回什么当前节点自身贡献多少然后再往下递归。3.3 空指针保护是递归的“防火墙”无论写什么二叉树递归函数我建议在函数第一行统一判空返回。这不是降低性能而是给自己加一层保护。如果你自信某个节点不可能为空可以在后续逻辑里直接访问但入口判空这个习惯能帮你避免 90% 的运行时崩溃。举个例子假设你要计算一棵树所有节点值的和错误版本是int sumBad(TreeNode* root) { return root-val sumBad(root-left) sumBad(root-right); }第一次调用没问题但递归到空节点时就访问了nullptr-val直接段错误。正确版本int sumTree(TreeNode* root) { if (!root) return 0; return root-val sumTree(root-left) sumTree(root-right); }两行代码的区别就是崩溃和正常运行的区别。3.4 递归深度的极限尾递归救不了二叉树很多语言对递归深度都有上限。C 的默认调用栈大小通常在 1MB 到 8MB 之间栈上每层递归帧少则几十字节多则上百字节。极端情况下几万层递归就可能导致栈溢出。有人会问能不能用尾递归优化答案是二叉树的遍历和统计并不是标准的尾递归——每次递归调用完还有后续操作比如把两个孩子的结果相加没有把递归调用放在函数尾部编译器很难做优化。所以当树的高度可能很大时优先考虑迭代写法比如用显式栈做前序/中序遍历或者用队列做层序遍历。这也是为什么 2.3 节的迭代遍历不仅仅是面试题而是真实项目中防止栈溢出的有效手段。4. 从零构建一棵二叉树输入顺序、序列化与实操验证4.1 用先序序列加空标记构树最简单的反序列化动态构建链式二叉树我见过的最直接方法是借助先序遍历的序列其中用某个特殊值比如#或-1表示空节点。比如字符串1 2 # # 3 4 # # 5 # #表示根节点 1左孩子 2且 2 无左右孩子右孩子 33 的左孩子 44 无孩子3 的右孩子 5。按照这个顺序就能递归还原整棵树。看代码TreeNode* buildFromPre(vectorint vals, int idx) { if (idx vals.size()) return nullptr; int v vals[idx]; if (v -1) return nullptr; // -1 作为空标记 TreeNode* root new TreeNode(v); root-left buildFromPre(vals, idx); root-right buildFromPre(vals, idx); return root; }注意这里idx以引用方式传递确保每次递归都在同一个“读取游标”上继续往后读。如果用值传递你会陷入无限循环——因为每一层递归都从同一个位置开始读永远读不完。这种构建方式特别适合两类场景一是测试时快速手工构造一棵指定结构的树二是把内存中的二叉树序列化之后存储起来下次再反序列化还原。4.2 层序构建与递归构建的差异还有一种常见的建树方式是层序构建。比如 LeetCode 风格的输入[3, 9, 20, null, null, 15, 7]这种输入代表三层的完全二叉树填充形式3 是根9 是左孩子20 是右孩子15 和 7 分别是 20 的左右孩子9 没有孩子。实现时用队列TreeNode* buildFromLevelOrder(vectorint vals) { if (vals.empty() || vals[0] -1) return nullptr; TreeNode* root new TreeNode(vals[0]); queueTreeNode* q; q.push(root); int i 1; while (!q.empty() i vals.size()) { TreeNode* cur q.front(); q.pop(); if (i vals.size() vals[i] ! -1) { cur-left new TreeNode(vals[i]); q.push(cur-left); } i; if (i vals.size() vals[i] ! -1) { cur-right new TreeNode(vals[i]); q.push(cur-right); } i; } return root; }用层序序列建树优点是可以精确定位每一层节点的位置关系适合描述“形状比较规整”的树。用先序序列建树则更贴近递归思路适合表达任意形态的树。我个人在本地调试时更常用先序空标记因为只需递归前序遍历就能写出来不依赖队列这种额外结构。4.3 验证构建结果用遍历反推树的结构树构建完之后怎么验证对不对不要用眼睛干瞪直接打印中序或层序序列和预期比对。这里有个非常实用的规律在一棵二叉搜索树里中序遍历结果是递增序列。所以如果你构建的是搜索二叉树只要把中序结果打印出来确认有序就能判定结构基本正确。对普通二叉树可以同时打印前序和中序序列然后拿前序序列去指导“树根的相对位置”拿中序序列去验证“左右子树划分”两者结合可以唯一还原树的形状。这也是面试常考的“从前序与中序遍历序列构造二叉树”题目的理论基础。如果你发现自己写的树构建代码一旦跑起来就报“Segmentation fault”之类运行时错误建议先做两件事第一把输入序列打印一遍检查空标记的位置第二把递归建树的函数加上“进入函数时打印当前读到的值”的调试信息肉眼观察递归路径是否符合预期。很多时候问题出在idx的移动位置不对或者空标记与真实节点值冲突比如树里正好要存一个 -1 值空标记就必须换成别的值比如INT_MIN。4.4 一个完整的调试样例索引边界引发的连锁崩溃我给你还原一个我实际帮人排查过的场景。有人写了一段代码从数组[1, 2, 3, -1, -1, 4, 5]构建树他的错误版本是在层序构建时没有检查i vals.size()就直接访问vals[i]。因为在处理完右孩子后i又向后走了一步最后一次循环时i已经越界程序直接崩溃。这个是典型的“边界检查不完整”导致的运行时错误。修法也简单每次移动i之前先判断是否越界。我在 4.2 节的代码里把判空和越界都写进去了这套逻辑是可以直接拿去用的。如果你使用的是 Python构建过程会更“舒适”一点因为不需要手动管理节点内存但思路完全一致class TreeNode: def __init__(self, val0, leftNone, rightNone): self.val val self.left left self.right right def build_from_level_order(vals): if not vals or vals[0] is None: return None root TreeNode(vals[0]) q [root] i 1 while q and i len(vals): cur q.pop(0) if i len(vals) and vals[i] is not None: cur.left TreeNode(vals[i]) q.append(cur.left) i 1 if i len(vals) and vals[i] is not None: cur.right TreeNode(vals[i]) q.append(cur.right) i 1 return rootPython 的动态类型让节点定义更简洁但空值的判断依然要格外谨慎0、False和None在布尔语境下容易被混淆建议一律用is None来判断空节点。5. 从二叉树到一棵“真正的技能树”链式结构的延伸视野5.1 表达式树把计算顺序藏进树结构里链式二叉树绝不只活在教材和面试题里。表达式树就是一个很直观的工业级应用。比如中缀表达式(a b) * (c - d)可以用一棵二叉树表示根节点是*左子树是左孩子 a右孩子 b右子树是-左孩子 c右孩子 d。后序遍历这棵树得到的正是逆波兰表达式中序遍历再加上括号能还原出原始中缀表达式。表达式树最大的价值是它把“运算顺序”这种隐式规则变成了“树的形状”这种显式结构。只要树建对了求值就是递归地先算左子树、再算右子树、最后应用根节点的运算符。这也是很多计算器程序、编译原理课程里语法分析用树结构去组织表达式的根本原因。5.2 二叉搜索树、哈夫曼树、字典树、红黑树都是链式思考的延伸二叉搜索树节点之间约定“左小右大”插入和查找的平均复杂度是 O(log n)。链式结构让插入只需顺着指针往下走走到空位就挂上去简洁高效。哈夫曼树由若干叶节点反复合并两个权值最小的节点每次合并生成一个新父节点。这个过程本质上是在动态创建链式节点最终形成一棵带权路径长度最短的二叉树。字典树看起来是多叉树但每个节点的“孩子数组”本身就是一种指针数组用空间换时间实现 O(词长) 的字符串查找。红黑树、AVL 树在链式二叉树基础上加入平衡因子或颜色标记通过旋转调整树的形态本质上还是在管理链表式节点的指针关系。你把这些东西放在一起看会有一个很明显的感触一旦掌握了链式二叉树的基本功上面的每一种树核心都是“节点 指针 递归操作”。学习的边际成本会递减得很快。5.3 现实世界里到处都是“技能树”设备树、电源树、时钟树、工作树有意思的是“树”这个结构不仅在程序里到处存在在真实工程系统里也被当成基础思维工具在使用。比如嵌入式 Linux 开发里常说的设备树就是用一个树形结构去描述硬件设备的层级关系和资源分配根节点下面挂总线节点总线节点下面再挂一个个外设节点驱动开发时顺着树去解析设备信息。芯片设计里的时钟树用来描述时钟信号从源头如何一级一级分发到各个模块电源树描述电源轨的上下级关系与供电路径项目协作里还有“工作树”这种概念用来拆分任务层级把大目标逐步拆成一个个可执行的小任务。这些实际系统里的“树”本质上跟链式二叉树的底层思想是同构的用层级关系组织信息用递归/递推关系定义处理逻辑遍历时从上层往下层展开。所以我一直觉得数据结构课上学到的链式二叉树训练的不只是写几道题的能力更是一种“用层级结构组织复杂信息”的思维方式。你以后看任何带父子关系的东西都会自然地想能不能用一棵树去建模要不要做遍历要不要做剪枝5.4 从链式二叉树继续往前走下一步学什么如果是刚把链式二叉树的基本操作练完我建议按这个顺序继续先把各种遍历的递归和迭代版本都写一遍保证不看参考答案也能默写做 10 道左右经典的二叉树递归题比如最大深度、最小深度、翻转二叉树、对称二叉树、最近公共祖先然后过渡到二叉搜索树练习插入、删除、查找理解删除节点时如何调整指针有余力的话再看 AVL 树或红黑树重点理解旋转操作的本质最后回到应用层面用表达式树写一个简单的四则运算求值器或者用字典树写一个敏感词过滤模块把链式结构真正用起来。我的个人体会是二叉树最难的不是某个具体操作而是整个思考模型的转换从“线性遍历”转向“分而治之”。一旦你在递归函数里想明白了“当前节点做什么、左右子树递归返回什么”后面学图论、学动态规划、学编译原理都会轻松很多。最后分享一个我实际调试二叉树程序时的土办法把所有递归函数第一步都塞一句打印当前节点的地址和值。跑一遍之后你用工具或者人工把打印顺序和预期树形图对照一下。树的形状对不对、递归走到哪一步断了、哪个位置出现了空指针访问都会一清二楚。这个方法听着笨但在一次深夜调试时救了我两个小时后来我教过的每一个新手都靠它快速定位问题。链式二叉树这东西写对一次就“通了”后面再遇到任何树你都觉得不过尔尔。