1. 第十四天:从线性结构迈入二叉树的第一个门槛
如果你跟着代码随想录的路线刷到了第十四天,大概率已经对数组、链表、哈希表、字符串、栈和队列这些线性结构有了比较扎实的底子。但从这一天开始,整个刷题节奏会发生一个明显变化:二叉树登场了。我当初学到这一天时最直观的感受就是,前十三天学的所有东西,本质上都在围绕“一个元素接一个元素”的线性顺序做文章;而二叉树一出现,你的大脑需要立刻切换到另一个维度,去理解“分叉”“层级”“递归展开”这些全新的概念。
为什么代码随想录要把二叉树安排在这个位置?原因很简单,二叉树是后续所有非线性结构的基础。回溯算法本质上是树的深度优先搜索,动态规划中的很多状态转移也能抽象成树形结构,图论里最常用的 DFS、BFS 框架照样能用二叉树来入门。可以说,第十四天是整个刷题路线里第一个真正的分水岭,今天的内容吃不透,后面学习回溯和动态规划时还要回来补课。这一天适合谁?适合刚学完栈和队列、准备接触树结构的初学者,也适合那些刷过题但始终对树的遍历顺序模棱两可的人。这一天的核心交付物就三件事:理解二叉树的存储与分类、吃透递归遍历的三个顺序、能用手写栈写出迭代遍历。
这篇文章我会按照当天实际学习的知识顺序来讲,把我当时踩过的坑、后来面试中遇到的问题、以及如何从“背模板”变成“真理解”的过程一起写出来。内容全部基于代码随想录第十四天对应的二叉树章节,用 C++ 演示,思路上任何语言都能照搬。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 先别急着写代码,二叉树的基础概念必须一次过清楚
第十四天一开始其实没有直接甩代码,而是先铺了一批概念。当时我还觉得有点拖节奏,后来才发现,这一批概念里面随便拎一个出来,后面都是高频考点。
2.1 二叉树的三种常见分类
首先是满二叉树和完全二叉树。满二叉树就是每一层的节点数都达到最大值,比如深度为 k 的满二叉树总节点数是 2^k - 1。完全二叉树则是最后一层可以不满,但必须从左到右连续排列,不能出现左边空缺、右边却有节点的状态。完全二叉树有一个很重要的性质,就是它可以用数组连续存储,不需要额外的指针,这也是后面堆排序和优先队列实现的基础。我当时对这个概念最大的误解是觉得满二叉树一定是完全二叉树,其实反过来才对,而且这两个概念在 LeetCode 上经常作为题目背景出现,比如求完全二叉树的节点个数,就会考你如何利用完全二叉树的性质把时间复杂度压到 O(logN * logN)。
然后是二叉搜索树和平衡二叉搜索树。二叉搜索树的定义是左子树所有节点都小于根节点,右子树所有节点都大于根节点,并且左右子树本身也都是二叉搜索树。这个结构决定了它的中序遍历结果一定是有序的,这是一个非常容易被考到的点。平衡二叉搜索树则是为了保证搜索效率,在插入删除后自动调整树的高度,让左右子树的高度差不超过 1,C++ 里的 map 和 set 底层就是红黑树,一种自平衡的二叉搜索树。我当时学习时把这些概念当成“背景知识”跳过了,结果后面学到二叉搜索树的操作题时,不得不回头重新补。
2.2 链式存储和顺序存储怎么选
二叉树的存储方式分两种:链式存储和顺序存储。链式存储就是我们最常见的结构体,每个节点包含值、左孩子指针、右孩子指针。顺序存储则是用数组按层存放节点,如果某个节点在数组中的下标是 i,那么它的左孩子下标是 2*i + 1,右孩子下标是 2*i + 2(这里以 0 开头计)。顺序存储对于完全二叉树来说非常高效,但对于普通二叉树会浪费大量数组空间,因为不存在的节点也得占位。平时刷题和面试中绝大多数情况用的是链式存储,我建议你把链式结构体的定义直接背下来,写顺了后面所有树相关代码都从它开始:
cpp复制struct TreeNode {
int val;
TreeNode *left;
TreeNode *right;
TreeNode() : val(0), left(nullptr), right(nullptr) {}
TreeNode(int x) : val(x), left(nullptr), right(nullptr) {}
TreeNode(int x, TreeNode *left, TreeNode *right) : val(x), left(left), right(right) {}
};
2.3 深度优先和广度优先,到底在说啥
二叉树的遍历方式分两类:深度优先遍历和广度优先遍历。深度优先就是沿着一条路径走到黑,再回头走另一条,它细分出前序遍历、中序遍历、后序遍历三种;广度优先则是按层从上到下、从左到右逐个访问节点,也叫层序遍历。理解这四种遍历之间的区别,是第十四天的核心任务。
我当时用的记忆方法是看根节点的位置。前序遍历是“中左右”,先访问根节点,再遍历左子树,最后遍历右子树;中序遍历是“左中右”,先左子树,再根节点,再右子树;后序遍历是“左右中”,先左子树,再右子树,最后根节点。你不需要去死记“前中后”这几个字的含义,只要记住一句话:前中后指的是根节点被访问的时机。后面写到代码时你就会发现,三者代码结构完全一样,只是把“记录节点值”这一行放在不同位置而已。
深度优先遍历在代码实现上有递归和迭代两种写法,这也是第十四天的主要内容。我先把这两种方法分别拆开讲,然后再说一个非常关键的理解方式,很多人在这一步卡住,就是因为在递归和迭代之间没有建立起联系。
3. 递归遍历:三行代码背后的核心逻辑
递归是理解二叉树的第一把钥匙。我一直觉得,学二叉树之前如果你对递归心存畏惧,那二叉树会帮你彻底克服它;反过来,二叉树也是检验递归是否真正理解的试金石。
3.1 写递归前先想清楚的三个问题
代码随想录里反复强调递归三要素,我觉得比任何模板都实用:
- 确定递归函数的参数和返回值。对于二叉树遍历,参数通常是当前节点指针,返回值是 void,因为结果直接通过引用类型的 vector 收集。
- 确定终止条件。遍历到一个空节点时,直接返回,不再往下递归。
- 确定单层递归的逻辑。这一步只处理当前节点“该做的事”,剩下的交给递归。
这三个要素看着简单,但实际写代码时最容易出问题的就是第二个。很多人写递归时不由自主地想去判断“当前节点是否为空”,其实递归的终止条件只需要处理空节点这一种情况,其他的都交给递归自身去处理。我见过不少人在递归函数里写了 if (node->left) 这样的判断,这在某些场景下没问题,但会让代码变复杂,而且思路不清晰。正确做法是直接递归调用 node->left,因为递归函数的第一行就会处理空节点的情况。
3.2 前序中序后序的递归代码对比
下面是三种遍历的 C++ 实现,你可以放在一起对比看:
cpp复制// 前序遍历:中左右
void preorder(TreeNode* cur, vector<int>& vec) {
if (cur == nullptr) return;
vec.push_back(cur->val);
preorder(cur->left, vec);
preorder(cur->right, vec);
}
// 中序遍历:左中右
void inorder(TreeNode* cur, vector<int>& vec) {
if (cur == nullptr) return;
inorder(cur->left, vec);
vec.push_back(cur->val);
inorder(cur->right, vec);
}
// 后序遍历:左右中
void postorder(TreeNode* cur, vector<int>& vec) {
if (cur == nullptr) return;
postorder(cur->left, vec);
postorder(cur->right, vec);
vec.push_back(cur->val);
}
注意看,三个函数唯一不同的地方就是 vec.push_back(cur->val) 这一行的位置。前序遍历先把根节点记下来,再往左走;中序遍历先一路向左,到最左边才开始记录,然后访问右子树;后序遍历则是左右都处理完了才记录根节点。这个结构重复度极高,如果你能一次写对三个,说明递归的基本功已经没问题了。
3.3 递归过程中,函数调用栈是怎么走的
很多人能默写递归代码,但一被问到“中序遍历输出顺序为什么是那样”就答不上来。这里我建议你亲手画一遍调用栈。以一棵三个节点的树为例:根节点为 1,左孩子为 2,右孩子为 3。中序遍历时,先调用 inorder(root),进入函数后不是立刻记录 1,而是先调用 inorder(root->left)。这时函数调用栈里压入了第一层 inorder(1),接着进入 inorder(2)。inorder(2) 又先调用 inorder(2->left),发现是空节点,直接 return,然后记录 2,再调用 inorder(2->right),空节点 return。这样一来 inorder(2) 执行完毕,栈回到 inorder(1),此时才记录 1,然后调用 inorder(1->right),也就是 inorder(3),记录 3。
这个“先把左边全部走完,再一层层回溯”的过程,就是递归遍历的本质。我在学习时养成了一个习惯:每次遇到递归代码,都用手在纸上模拟一遍函数调用栈的变化。尤其是后序遍历,它必须等左右子树都递归返回了才记录当前节点,这个执行顺序在递归里很好写,但后面迭代实现时就会暴露问题。
4. 迭代遍历:从隐式栈到显式栈的思维升级
递归写法虽然简洁,但面试时面试官常常会追问:不用递归,你怎么遍历二叉树?原因有二:一是递归调用有函数栈深度限制,树深度过大时可能栈溢出;二是理解迭代实现能真正检验你是否弄懂了遍历顺序的本质。第十四天在这一块安排得很有层次,先讲前序和后序的简单迭代,再单独拿出最折磨人的中序迭代。
4.1 前序遍历的迭代写法:用栈模拟递归
前序遍历的顺序是“中左右”,迭代实现时借助一个栈。初始时把根节点入栈,然后循环:弹出栈顶节点,记录它的值,再把右孩子入栈,再把左孩子入栈。为什么先右后左?因为栈是后进先出的,先把右孩子入栈,左孩子就会先被弹出,这样就保证了“左子树先遍历”。
cpp复制vector<int> preorderTraversal(TreeNode* root) {
vector<int> result;
stack<TreeNode*> st;
if (root != nullptr) st.push(root);
while (!st.empty()) {
TreeNode* node = st.top();
st.pop();
result.push_back(node->val);
if (node->right) st.push(node->right);
if (node->left) st.push(node->left);
}
return result;
}
这段代码非常直观,我当时一次就写对了。但注意,它和前序递归的思路其实不同。递归是先处理当前节点,然后递归整个左子树,左子树处理完再递归右子树;而迭代写法是每一轮只处理一个节点,再把它左右孩子按反序入栈。两者最终输出一样,但执行脉络不同。这是很多初学者容易混淆的点。
4.2 后序遍历迭代:先求“中右左”再反转
前序遍历代码只要稍作修改就能得到后序遍历。后序是“左右中”,如果先按“中右左”的顺序遍历,再把结果数组反转,就得到“左右中”了。怎么得到“中右左”?把前序遍历里面的入栈顺序反过来,先压左孩子,再压右孩子即可。
cpp复制vector<int> postorderTraversal(TreeNode* root) {
vector<int> result;
stack<TreeNode*> st;
if (root != nullptr) st.push(root);
while (!st.empty()) {
TreeNode* node = st.top();
st.pop();
result.push_back(node->val);
if (node->left) st.push(node->left);
if (node->right) st.push(node->right);
}
reverse(result.begin(), result.end());
return result;
}
这个技巧是我个人比较喜欢的解法,因为它把新问题转化成了已经会的问题,代码量小且不容易出错。不过你要清楚,这只是一种实现技巧,不是后序遍历的本质逻辑。如果面试官要你“用迭代实现后序遍历但不能用反转”,那就得用更麻烦的双栈法或者稍后要讲的统一迭代法。我建议你把反转法作为首选,因为它可读性强,面试时解释起来也快。
4.3 中序遍历迭代:为什么不能照搬前序
中序迭代是最容易卡住的地方。我当初按照前序遍历的思路,以为只要调整入栈顺序就能写出来,结果发现完全不对。原因在于前序和中序的处理时机不一样。前序遍历是访问到某个节点时立刻记录,而中序遍历必须等左子树全部遍历完才能记录当前节点。如果在访问节点时立刻记录,就破坏了这个顺序。
正确做法是使用“指针 + 栈”的组合。用一个指针 cur 从根节点开始,一直往左走,沿途把经过的节点全部压栈,直到 cur 为空。这时从栈里弹出一个节点,记录值,然后把 cur 指向该节点的右子树。整个过程可以概括为两步:先一路左压到最底,再弹栈处理,每次弹出后转向右子树。
cpp复制vector<int> inorderTraversal(TreeNode* root) {
vector<int> result;
stack<TreeNode*> st;
TreeNode* cur = root;
while (cur != nullptr || !st.empty()) {
if (cur != nullptr) {
st.push(cur);
cur = cur->left;
} else {
cur = st.top();
st.pop();
result.push_back(cur->val);
cur = cur->right;
}
}
return result;
}
我当时第一次看到这段代码时有个疑问:为什么 cur 能直接指到右子树,不会把右子树的左孩子漏掉?答案是,下一轮 while 循环里,如果 cur 不等于空,会再走“一路往左压栈”的分支,把右子树的所有左孩子也按顺序压进去。这个过程其实就是递归中“先处理左子树”的模拟。
提示:如果你发现中序迭代代码里没有用到类似“标记节点”的机制,却依然能正确实现,核心就在于 cur 指向右子树后,会重新触发内部的 while 逻辑,自动完成左子树的处理。理解这个细节,比背代码重要得多。
5. 统一迭代法:一套代码通吃三种遍历
中序遍历的迭代实现和前序遍历的迭代实现长得完全不一样,这会让很多人觉得别扭,我也是。当时我就想,能不能有一种统一的写法,让三种遍历只改一行代码,就能从递归无缝切换到迭代?代码随想录里讲到的统一迭代法就解决了这个问题。
5.1 空节点标记法的核心思路
统一迭代法的思路是在栈中压入节点时,用一个特殊的空指针来标记“这个节点已经访问过,但值还没记录”。当遍历到某个节点时,先不急着处理它,而是按遍历顺序的反方向把节点入栈:对于前序“中左右”,入栈顺序是右、左、中(再加一个空标记);对于中序“左中右”,入栈顺序是右、中、左(再加一个空标记);对于后序“左右中”,入栈顺序是中、右、左(再加一个空标记)。这里的“入栈顺序要反过来”是因为栈是后进先出的。空标记入栈后,当它被弹出时,就代表着我们要真正记录下一个栈顶节点了。
这段描述对新手来说有点绕,但代码一写出来就很清楚。下面是前序的统一迭代实现:
cpp复制vector<int> preorderTraversal(TreeNode* root) {
vector<int> result;
stack<TreeNode*> st;
if (root != nullptr) st.push(root);
while (!st.empty()) {
TreeNode* node = st.top();
if (node != nullptr) {
st.pop();
if (node->right) st.push(node->right);
if (node->left) st.push(node->left);
st.push(node);
st.push(nullptr);
} else {
st.pop();
node = st.top();
st.pop();
result.push_back(node->val);
}
}
return result;
}
中序只需要调整非空处理分支中入栈的顺序:
cpp复制vector<int> inorderTraversal(TreeNode* root) {
vector<int> result;
stack<TreeNode*> st;
if (root != nullptr) st.push(root);
while (!st.empty()) {
TreeNode* node = st.top();
if (node != nullptr) {
st.pop();
if (node->right) st.push(node->right);
st.push(node);
st.push(nullptr);
if (node->left) st.push(node->left);
} else {
st.pop();
node = st.top();
st.pop();
result.push_back(node->val);
}
}
return result;
}
后序同理:
cpp复制vector<int> postorderTraversal(TreeNode* root) {
vector<int> result;
stack<TreeNode*> st;
if (root != nullptr) st.push(root);
while (!st.empty()) {
TreeNode* node = st.top();
if (node != nullptr) {
st.pop();
st.push(node);
st.push(nullptr);
if (node->right) st.push(node->right);
if (node->left) st.push(node->left);
} else {
st.pop();
node = st.top();
st.pop();
result.push_back(node->val);
}
}
return result;
}
5.2 统一迭代法和递归之间到底有什么关系
如果你稍微观察一下,会发现统一迭代法其实就是把递归里函数调用栈做的事情,用显式栈完整复刻了一遍。递归中每次调用函数处理左子树,相当于在栈里压入一个“待处理”状态;而统一迭代法里的 nullptr 标记,相当于在栈里标记“该轮到中间节点被记录了”。这种对应关系,我认为是理解二叉树遍历的终极钥匙。
我当时为了验证自己是不是真懂了,做了一件事:把统一迭代法的前序代码手工走一遍,模拟一棵三层满二叉树的栈变化。走完之后,我突然明白了为什么后序遍历的递归代码是“左右中”,以及为什么递归写法里“记录根节点”一定要放在最后。因为递归调用右子树返回之后,函数栈的上层才能继续执行,这种“暂存状态”用显式栈表达出来,就是统一迭代法中把结点和标记一起入栈的逻辑。
5.3 实际刷题时,统一迭代法值不值得用
说实话,正式笔试面试中,我一般不会优先写统一迭代法。前序和后序用“反转法”最快,中序用“指针+栈”最经典,统一迭代法的代码量偏大,现场手写出错风险较高。那为什么还要学它?因为它的价值不在“做题时用”,而在于帮你把三种遍历的底层逻辑拉齐。我见过太多人前序会用栈写,中序就卡壳,就是因为他们在用两套完全不同的思维模式理解同一种东西。统一迭代法让这两种模式归一,理解它之后,你再回头看指针加栈的中序实现,会觉得非常自然,而不是背了个玄学模板。
6. 第十四天最容易踩的坑和排查技巧
这一节是我最想写的,因为当天我踩过的坑,后面在带别人刷题时几乎每个新手都踩了一遍。我按照实际出现频率排序,整理成一张速查表:
| 典型问题 | 现象 | 原因 | 解决办法 |
|---|---|---|---|
| 递归终止条件漏写 | 栈溢出或节点被重复访问 | 空节点没有处理就往下递归 | 函数第一行必须写 if (cur == nullptr) return |
| 记录节点值的位置放错 | 前序写出了中序的结果 | 对遍历顺序理解不清 | 记住“前中后指代根节点的访问时机” |
| 迭代前序把左右孩子入栈顺序写反 | 输出变成中右左 | 忘记栈是后进先出 | 要“先遍历左子树”,就得先压右孩子 |
| 中序迭代用“压栈再访问”的老思路 | 死循环或输出完全不对 | 中序必须用指针一路向左压栈 | 改用指针 + 栈写法 |
| 递归函数参数没加引用 | 结果没收集到 | vector 形参是值拷贝 | 用 vector |
| 后序反转法忘了 reverse | 输出是中右左 | 忘了最后一步 | 或者从前序推后序时先标注“需要反转” |
除了表里这些,我还想特别提醒一个很容易被忽略但非常影响调试效率的问题:怎么快速验证自己的遍历代码对不对。我推荐一个方法,拿一棵固定的树(比如根节点 1,左孩子 2,右孩子 3,2 的左孩子 4,3 的右孩子 5),在纸上写出你期望的前序、中序、后序结果,然后每次写完代码,直接把树的构造代码写在 main 函数里跑一遍,对照输出。这个树很小,根本不需要 LeetCode 平台,本地 10 秒钟就能验证。我当时刷遍历题时写了不下二十遍这段测试代码,后面所有二叉树相关题目都用它当“冒烟测试”。
还有一个非常实用的调试习惯:当你的二叉树遍历代码结果不对时,不要盯着代码发呆,在关键节点打印日志。比如迭代遍历中,在每次弹出栈顶节点时输出当前值,在每次节点入栈时也输出入栈值,然后和你在纸上手动模拟的栈变化逐行对比。这样做最大的好处是,你能快速定位是“入栈顺序错了”还是“出栈处理时机错了”,而不是靠猜。
7. 第十四天之后,这些能力会跟着你走很远
学习代码随想录第十四天的经历,让我在后来的刷题路上获得了三个实实在在的能力。第一个是递归思维的突破。以前写递归总是小心翼翼,现在看到树相关的题目,脑子里第一反应是“递归函数接收什么参数、返回什么、终止条件是什么”,而不是去脑补整个递归过程。第二个是调试心态的改变。遇到二叉树相关的问题,我已经不指望一次能写对,而是默认先写一个最小测试用例跑一遍,出了问题再去对照栈的变化,这比反复读代码更高效。第三个是对遍历顺序的理解变得极其敏感。后面学回溯算法时,我经常发现回溯本质上就是树的深度优先搜索,而树的遍历顺序又直接决定了回溯的结果顺序,比如全排列的去重逻辑,说到底就是“在树的同一层不能重复选相同元素”这一句话。
第十四天只是代码随想录二叉树篇章的开端。后面还有层序遍历、翻转二叉树、对称二叉树、二叉树的最大深度、平衡二叉树、所有路径,直到最后的二叉搜索树和公共祖先问题。你会发现这些题目没有一个能绕开今天学的遍历框架。我个人的建议是,这一天的内容值得你放慢速度,哪怕多花两三天把四种遍历方式都做到“闭着眼能写出来”,也完全值得。因为后面每一个树相关的题目,都会在今天的基础上反复叠加新条件。今天的基础打得越牢,后续的学习才会越顺。
最后再分享一个我在实际练习中反复用到的技巧:把四种遍历(前序递归、中序递归、后序递归、前序迭代、中序迭代、后序迭代、层序)全部写进同一个测试文件里,用同一棵树分别调用,然后对比输出。这套代码写完之后不要删,后面做很多题目时都可以直接拿过来当工具函数用。我用这个办法把六种深度优先遍历写法练到肌肉记忆之后,再去接触统一迭代法,才发现这些代码之间原本就存在着同一套逻辑,只是表现方式不同罢了。
