打卡代码随想录第十四天。前面十三天刷数组、链表、哈希表、字符串、栈和队列时,我其实还挺顺的,因为这些结构说到底都是“线性的”,顺着一个方向往下读就能推演;到了第十四天,题目突然换成二叉树,前序、中序、后序、递归、迭代一下子全涌过来,我第一次有了“上一行还明白,下一行就开始懵”的感觉。后来才发现,这个节点恰好是代码随想录课程设计里最容易劝退人的分水岭,跨过去之后,后面树形结构的题都会顺很多。
这篇文章把我在第十四天的完整复盘整理出来:二叉树基础、三种深度优先遍历的递归写法、迭代写法、统一迭代法,再把 LeetCode 144、94、145 三道题逐题拆解,最后附上大家容易踩的坑。无论你是正准备刷二叉树,还是已经卡在这一天,这篇文章应该都能给你省下不少时间。
1. 代码随想录第十四天:打开二叉树这扇大门之前
1.1 一个从“线性”到“非线性”的转折点
前十三天基本是一个渐进式铺垫:数组的双指针、滑动窗口,链表的增删改查,哈希表的计数和去重,字符串的双指针与反转,再到栈和队列的先进后出、先进先出。这些结构有一个共同特点,就是数据在逻辑上是一条线,遍历时只要顺着一条路径走完,问题基本就解决了一半。
到了二叉树这里,逻辑结构突然变成分支结构。一个节点不再只有一个“下一个节点”,而是同时存在左孩子和右孩子,遍历路径也从一条变成两条。代码随想录第十四天安排的内容,绝大多数围绕二叉树的遍历展开,目的就是用最基础的遍历题,把“分支结构怎么写递归”这件事讲透。
我当时的感受是:如果前面的线性结构可以用“套路”快速过题,那从二叉树开始,就不能再靠背模板硬套了,必须在脑内刻出节点的访问顺序。这也是为什么代码随想录第十四天在网络上被反复搜索、讨论,因为很多人在这一天第一次意识到,算法题不是“会写某个函数”,而是“能完整模拟某棵树上的操作”。
1.2 第十四天,你只需要抓三件事
虽然第十四天的题目看起来有好几道,但核心就三件事:
- 会用递归实现前序、中序、后序遍历;
- 会用显式栈实现前序、中序、后序迭代遍历;
- 会写一种统一结构的迭代写法,避免三种遍历顺序混在一起时崩溃。
这三件事的顺序是递进的。递归是二叉树的天然表达方式,先用递归把三种遍历顺序搞对;再用迭代的方式手写一个栈,理解系统递归时到底发生了什么;最后用“标记法”把三种迭代写法统一起来,让代码看起来更一致。
不要一上来就背三种迭代写法,那样今天记住了,明天一觉醒来就会混。我的学习策略是先画出访问路径,再写代码,后面会详细说怎么画。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 二叉树基础:先弄清这些,代码才能写得顺
2.1 二叉树的递归定义,和普通树差在哪
二叉树的定义本身就带着递归:二叉树要么是空树,要么是一个根节点加上左右两棵互不相交的子树,左右子树本身也是二叉树。正因为这个定义是递归的,遍历二叉树的代码几乎一定会用到递归,或者说不用递归反而更难写清楚。
很多人在第十四天之前没怎么接触树结构,容易把它和普通多叉树混淆。普通树的一个节点可以有很多孩子,结构上更自由,但描述和处理起来也更复杂;二叉树限定了每个节点最多只有两个分支,左孩子和右孩子是有顺序的,不能随意交换,这给后续的遍历、搜索、平衡等操作提供了统一的基础模型。
另外要注意,在刷题题目里,二叉树节点定义通常是这样的:
python复制class TreeNode:
def __init__(self, val=0, left=None, right=None):
self.val = val
self.left = left
self.right = right
不要小看这个三行定义。很多新人在递归里拿不到节点值,就是因为把 root、root.left、root.val 这三者搞混了。root 是节点本身,root.left 是节点,root.val 是整数,这三种类型在递归里的用法完全不一样。
2.2 链式存储和数组存储,面试题最爱问的细节
二叉树在代码里有两种常见的存储方式。
链式存储就是我们上面写的那种结构,每个节点用指针或引用连接左右子树,直观、容易操作,日常刷题用的基本都是这种。
另一类是顺序存储,也就是用数组来存二叉树。数组存储有一个很关键的限定条件:它最适合完全二叉树。父节点下标如果是 i,左孩子就是 2*i+1,右孩子就是 2*i+2;反过来,孩子节点 i 找父节点就是 (i-1)//2。这个公式并不难记,但它只有在树的节点尽量“靠左填满”时,数组下标才能稳定对应节点位置。如果是一棵随意的普通二叉树,中间缺了节点,数组里就得留空位,空间浪费很严重。
这两种存储方式在第十四天的遍历题目中,主要用链式结构,但数组存储的推导逻辑在后面的“完全二叉树”“堆”相关题目里会反复出现,建议今天就把公式记住了。
2.3 三种深度优先遍历,分别解决什么场景
深度优先遍历分为前序、中序、后序三种,区别在于“根节点何时被访问”:
| 遍历方式 | 访问顺序 | 典型应用场景 |
|---|---|---|
| 前序遍历 | 根、左、右 | 复制一棵树、序列化、自顶向下的处理 |
| 中序遍历 | 左、根、右 | 二叉搜索树中按顺序输出节点 |
| 后序遍历 | 左、右、根 | 先处理子节点,再计算父节点的值,如删除树、计算高度 |
这三个顺序不只是面试题目,它们正好对应真实场景里的三种处理节奏。比如你要把一棵二叉树的值原样保存下来,前序遍历最直观;你要判断一棵二叉搜索树是否合法,中序遍历会给出一个有序序列;你要释放节点或统计子树信息,往往得后序遍历,因为只有知道了孩子的结果,父节点才算得出来。
第十四天的核心就是这三条顺序。很多人喜欢死记“前序就是根左右”,但建议你在草稿纸上手画一棵三层二叉树,手动模拟一遍三种遍历路径,比背十遍都管用。
3. 递归遍历:三要素记牢,三种顺序自然就会写
3.1 递归和二叉树,天生就是一对
二叉树的递归定义,决定了递归写法几乎是“顺着树的定义写出来”的。处理一个根节点时,先处理它自己的一部分逻辑,然后递归处理左子树,再递归处理右子树。左子树又是一棵更小的二叉树,右子树也是一棵更小的二叉树,所以同一个函数从根节点一路调用下去,最终覆盖整棵树。
很多新手觉得递归难,是因为试图用“人脑”去一层层展开整个调用栈。实际上不要展开,你只需要信任三条约定:函数干了什么、什么时候停下来、这一步怎么调用自己。把这三件事写对,递归就自然成立了。
3.2 递归函数三要素逐条拆解
代码随想录第十四天里强调的递归三要素,我是这样理解的:
- 确定函数参数和返回值:遍历时,我们不需要对树做返回值拼接,只需要把节点值放进一个外部结果列表,所以递归函数一般不返回值,参数里要带上当前节点,或者通过闭包访问外部列表。
- 确定终止条件:当前节点是空的时候,立刻返回,不再继续找它的孩子。
- 确定单层递归逻辑:对当前节点做“访问”,然后决定先走左子树还是先走右子树。
举例来说,如果我们要写一个中序递归遍历,单层逻辑就是“先递归左子树,再访问根节点,最后递归右子树”。中间的访问顺序就决定了一场遍历是前序、中序还是后序。
3.3 前中后序遍历代码:只改一行顺序
用 Python 写三种递归遍历,最直观的版本是这样的:
python复制# 前序遍历:根、左、右
def preorder(root):
res = []
def dfs(node):
if node is None:
return
res.append(node.val) # 访问根节点
dfs(node.left) # 递归左子树
dfs(node.right) # 递归右子树
dfs(root)
return res
# 中序遍历:左、根、右
def inorder(root):
res = []
def dfs(node):
if node is None:
return
dfs(node.left) # 递归左子树
res.append(node.val) # 访问根节点
dfs(node.right) # 递归右子树
dfs(root)
return res
# 后序遍历:左、右、根
def postorder(root):
res = []
def dfs(node):
if node is None:
return
dfs(node.left) # 递归左子树
dfs(node.right) # 递归右子树
res.append(node.val) # 访问根节点
dfs(root)
return res
观察这三个函数,代码结构几乎一模一样,唯一不同的是 res.append(node.val) 这一行放在哪里。放在最前面是前序,放在两个递归中间是中序,放在最后是后序。理解这一点,比记住三套完整代码重要得多。
3.4 写递归最容易踩的三个坑
第一个坑是终止条件写错。写 if root is None: return 时,递归函数接收的可能是 root.left 或 root.right,空节点一定会遇到,所以终止条件必须放在递归函数入口处。有人会把 root.val is None 作为判断条件,这等于在空节点上访问 val,程序会直接报空指针异常。
第二个坑是递归里返回了局部列表,却没有把结果累加起来。有人会在每个递归层新建一个 res,然后 return res,这样左右子树的结果就丢了,最终只返回根节点那部分。遍历题里,我们通常在外部用一个列表收集结果,递归函数只负责把节点值填进外部列表,这样最不容易出错。
第三个坑是在不需要返回值的递归函数里,又把 dfs(node.left) 的结果当成左右子树结果使用。这属于概念混淆,会导致遍历结果为空。建议你在写每一道递归题时,先问自己三个问题:这层函数要不要返回值?它需不需要接收子问题结果?它收集的结果存在哪里?
4. 迭代遍历:用显式栈把“系统栈”搬出来
4.1 递归已经能做了,为什么还要学迭代
递归写起来轻松,但面试时考官经常会追问一句:“能不能不用递归写一个?”原因也很实际:递归依赖系统调用栈,当树的高度足够大时,递归深度过深会导致栈溢出。虽然面试题里的树通常不会故意构造得那么极端,但“用显式栈模拟递归”本身就能考察一个人到底有没有真正理解遍历顺序,而非单纯背模板。
另外,迭代写法的调试体验也不同。递归不好打断点,因为你会一次次跳进函数内部;显式栈可以把每一步压栈、弹栈的动作打印出来,更适合观察访问顺序。
4.2 前序迭代:根左右,入栈顺序要反着来
前序迭代的思路是:先把根节点压入栈,然后循环弹出栈顶元素并访问它,再把它的右孩子和左孩子依次入栈。为什么先压右再压左?因为栈是后进先出,我们希望下一次弹出的是左孩子,那就必须让左孩子最后入栈。
python复制def preorderTraversal(root):
if not root:
return []
res = []
stack = [root]
while stack:
node = stack.pop()
res.append(node.val)
if node.right:
stack.append(node.right)
if node.left:
stack.append(node.left)
return res
这个写法很整齐,几乎可以说模板本身就是“前序遍历”。如果你不理解为什么先右后左,拿一棵只有左子树和右子树的三层树模拟一遍,立刻就能明白。
4.3 后序迭代:直接套“反转法”
后序的顺序是“左、右、根”,直接迭代需要多点技巧。代码随想录里有一个聪明的取巧方法:先做一次“根、右、左”的遍历,然后把结果反转,就变成“左、右、根”。
具体做法是:在前序迭代的基础上,出栈时先访问节点,却先压左孩子再压右孩子,这样弹出来的顺序就会是“根、右、左”。把这一串值反转过来,就是后序结果。
python复制def postorderTraversal(root):
if not root:
return []
res = []
stack = [root]
while stack:
node = stack.pop()
res.append(node.val)
if node.left:
stack.append(node.left)
if node.right:
stack.append(node.right)
return res[::-1]
这个方法在理解上很友好,面试里用也没问题。只是写完要记得注释说明“这里得到的是根右左,反转一下才是左右根”,否则后期看代码容易懵。
4.4 中序迭代:先一路左到底
中序迭代是三种迭代里最需要动脑子的。因为前序是“到根就访问”,后序是“前序反转”,中序则不能简单靠栈顺序解决,它需要维护一个 cur 指针,一路向左走到底,把路径上的节点都压入栈;当走到空节点时,弹出栈顶节点并访问,然后让 cur 指向它的右子树,继续重复。
python复制def inorderTraversal(root):
res = []
stack = []
cur = root
while cur or stack:
while cur:
stack.append(cur)
cur = cur.left
cur = stack.pop()
res.append(cur.val)
cur = cur.right
return res
这段代码的关键点在于:访问节点的时机,并不是节点第一次被遇到,而是它从栈中被弹出来的时候。这个“先压左、到底再弹”的节奏,对应中序遍历的“左根右”顺序。
我当时第一次写中序迭代没想明白,手动模拟后才发现它和前序的最大区别:前序是“边压边访问”,中序是“一直压,出栈时访问”。这个差异理解了,中序迭代就算真正掌握了。
5. 统一迭代法:三种遍历共用一套结构
5.1 为什么三种迭代写法长得都不一样
前序迭代代码很简短,后序迭代用了反转,中序迭代又多了一个 cur 指针。三种迭代写法看起来像三个不同的模板,记起来容易混。代码随想录第十四天里还补充了一种“统一迭代法”,核心思路是给节点加一个标记,用来区分“该访问了”和“只是路过还没处理完”,这样三种遍历的代码结构就完全统一了。
这个技巧在刷题阶段不一定每道题都要用,但当你连续刷多个二叉树遍历题时,统一写法能明显减少切换成本,而且面试讲解时也更清楚。
5.2 标记法的核心:在栈里放一个空节点
统一迭代法的做法是:遇到非空节点时,不是立刻访问它,而是把它重新放回栈里,同时放一个 None 作为标记。下一次再从栈里弹出 None 时,就说明它下面那个节点“处理时机到了”,这时候再去访问节点值。
由于栈是后进先出,放进栈里的顺序,决定了哪个节点会先被取出来。只要我们按照不同的遍历顺序调整入栈顺序,就能用同一套循环逻辑写出前序、中序、后序三种遍历。
我第一次看到这个思路时,觉得这不就是在模拟递归里“先记个位置,等会儿再回来”吗。确实如此,None 标记其实就是在栈里存了一个“返回点”,和递归里的系统栈保存返回地址是同一个道理。
5.3 统一迭代版前中后序遍历代码对比
先看前序统一迭代:
python复制def preorderTraversal(root):
res = []
stack = [root] if root else []
while stack:
node = stack.pop()
if node:
if node.right:
stack.append(node.right)
if node.left:
stack.append(node.left)
stack.append(node)
stack.append(None)
else:
node = stack.pop()
res.append(node.val)
return res
中序统一迭代:
python复制def inorderTraversal(root):
res = []
stack = [root] if root else []
while stack:
node = stack.pop()
if node:
if node.right:
stack.append(node.right)
stack.append(node)
stack.append(None)
if node.left:
stack.append(node.left)
else:
node = stack.pop()
res.append(node.val)
return res
后序统一迭代:
python复制def postorderTraversal(root):
res = []
stack = [root] if root else []
while stack:
node = stack.pop()
if node:
stack.append(node)
stack.append(None)
if node.right:
stack.append(node.right)
if node.left:
stack.append(node.left)
else:
node = stack.pop()
res.append(node.val)
return res
对比三个代码块后会发现,它们共享同一套 while 和 if node 判断,差异只在非空节点入栈的顺序。这个版本不太适合用于追求最短代码的场景,但非常适合理清遍历顺序,也方便和面试官展开聊“递归本质”。
6. 配合题库做实战:144、94、145 题完整拆解
6.1 LeetCode 144 前序遍历:递归和迭代各写一遍
LeetCode 144 要求返回二叉树的前序遍历结果。最简单的解法直接用前面写的递归模板,一提交就能过。但第十四天我们不只是为了过题,建议递归写完后,再补一遍迭代写法。
迭代写法的步骤很清楚:初始化栈,根节点入栈;循环弹出栈顶,把值放入结果;右孩子入栈,左孩子入栈。每一步的时间复杂度都是 O(n),空间复杂度是 O(n),栈用来保存待处理节点。
这道题的一个小测试点是空树输入。递归版本直接返回空数组,迭代版本也要先判空,否则拿 root 初始化栈会报空引用。不要觉得这种错误低级,真机敲代码时很容易漏。
6.2 LeetCode 94 中序遍历:重点理解“左根右”
LeetCode 94 是二叉树中序遍历。递归解法就是“左、访问、右”,迭代解法使用 cur 指针和栈来模拟。这道题我最想提醒的是,递归里顺手就写完的“左根右”,一旦换成迭代,顺序就很容易乱。我见过不少人把中序迭代写成前序模板,结果输出顺序成了“根左根右”之类的混合序列。
我的排查方式是,拿一棵三节点满二叉树做输入:根节点是 1,左孩子是 2,右孩子是 3。中序正确结果应该是 [2, 1, 3]。如果代码输出 [1, 2, 3],说明你其实写成了前序;如果输出 [2, 3, 1],就说明右子树处理时机不对。手动跑一个最小用例,比盯半天代码更有效。
6.3 LeetCode 145 后序遍历:反转法的正确使用姿势
LeetCode 145 要求输出后序。我在第十四天用的是反转法,也就是先做“根右左”,再整体反转。代码很短,不容易出错,面试里讲明白了也完全站得住。
如果你不想用反转法,也可以直接用统一迭代里的后序版本,先压当前节点和 None,再压右孩子、左孩子。两条路都可以,但不要同一个晚上交替尝试两种方法,否则第二天容易搞混。我建议第一遍先用反转法刷过,等第二遍复习时再补统一迭代写法。
6.4 做完三道题后,可以顺手总结的结论
三道题都完成后,建议整理一张表:
| 题目 | 遍历方式 | 递归思路 | 迭代思路 | 重点 |
|---|---|---|---|---|
| 144 | 前序 | 根左右 | 先右后左入栈 | 入栈顺序决定访问顺序 |
| 94 | 中序 | 左根右 | 一路压左,出栈访问 | 访问时机在出栈时 |
| 145 | 后序 | 左右根 | 根右左反转 | 反转法减少记忆负担 |
三道题的时间复杂度都是 O(n),空间复杂度最坏也是 O(n),因为树退化成链表时,递归和迭代都会用掉和节点数一样多的栈空间。
7. 第十四天避坑清单:我实际踩过的、以及大家常问的问题
7.1 递归无限循环:优先检查终止条件
递归最常见的报错是栈溢出。出现这个问题,第一个要查的点就是终止条件。最常见的错误是把节点本身和节点值混在一起判断,比如 if node is None 写成了 if node.val is None。只要访问到空节点,后者就会报空指针,而在非空的末节点上,node.val 又不是 None,递归会一直往下走,最终栈溢出。
正确的终止条件一定是判断“当前节点是否为空”。你不需要关心节点值存不存在,因为空节点就是整棵树的结束标志。
7.2 迭代顺序错了:八成是入栈方向问题
前序迭代如果输出顺序变成了“根、右、左”,不用怀疑,一定是你把右孩子和左孩子入栈顺序写反了。你期望下一次弹出左孩子,就必须让左孩子最后入栈,也就是先压右孩子,再压左孩子。这和孩子数量无关,只和栈的后进先出特性有关。
中序迭代如果输出顺序乱了,要检查 cur 的更新逻辑。内层 while cur 负责一路左压,弹栈后 res.append(cur.val),紧接着一定要写 cur = cur.right。漏掉这一步,程序会永远循环在左子树上。
7.3 想调试又不想反复打印,怎么排查
我常用的调试方式是,找一个只有三到五个节点的二叉树样本,手动写出期望遍历序列,然后再在代码里每次 pop 后输出当前节点值。这样你可以直接在纸上对照每一步入栈、出栈,很快定位顺序问题。
等到代码能跑通,再把所有打印注释掉。我个人不建议长期保留打印语句,因为真排查复杂错误时,打印太多反而淹没关键信息。
7.4 常见问题速查表
| 现象 | 可能原因 | 处理方式 |
|---|---|---|
| 递归栈溢出 | 终止条件写错或缺失 | 检查 if node is None: return |
| 前序遍历输出成根右左 | 入栈顺序反了 | 先压右孩子,再压左孩子 |
| 后序遍历输出成根右左 | 没有反转结果 | 用 res[::-1] 反转 |
| 中序迭代死循环 | cur 没有更新到右子树 |
弹栈后写 cur = cur.right |
| 统一迭代输出乱序 | None 标记位置不对 |
按遍历顺序调整非空节点入栈顺序 |
| 空树报错 | 没判断 root 是否为空 |
入口处增加 if not root 判断 |
8. 给坚持到第十四天的你:几个不会过时的经验
第十四天最让我受用的不是代码本身,而是“先画图再写代码”这个习惯。我后来刷二叉树相关题目,都先在草稿纸上画一棵最小树,手动写出期望结果,再开始敲代码。你会发现,res.append 放在哪里、左右子树入栈顺序是什么,所有这些看起来花里胡哨的问题,只要对着图走一遍,答案自己就出来了。
另一个很实在的建议是,不要嫌递归简单就跳过迭代。代码随想录第十四天把递归和迭代放在同一天,不是让你二选一,而是让你通过两种实现互相验证。递归写顺手了,迭代能够帮你理解递归背后的栈;迭代写顺了,递归又变成最优雅的表达。两者一起掌握,后面再刷翻转二叉树、对称二叉树、最大深度这类题时,你才能真正游刃有余。
我自己的节奏是:早上先默写一遍三种递归遍历,晚上再默写一遍三种迭代遍历。连续两天,基本就能在几分钟内无脑写完整套代码。这个方法听起来笨,但对第十四天这个节点来说,确实是最稳的。
