作为算法学习者,遇到二叉树相关的题目是绕不开的门槛,而LeetCode 101这个对称二叉树问题,几乎是每位刷题人必过的关卡。别小看这道题,它考察的不仅是遍历框架,更是对递归出口设计、迭代遍历顺序以及空节点处理的综合理解。很多朋友在做这道题时,递归版本写起来倒是顺手,一到迭代就卡壳,或者写出来了却搞不懂为什么要用两个队列、为什么入队顺序有讲究。这篇文章我会用C语言完整实现两种解法,把每一步的思路逻辑、边界条件、甚至调试时容易踩的坑都展开说说,配合我对"图解思路"的理解——没有画图工具,我用文字把树的形态和遍历过程拆解清楚,保证你看完之后,不仅会写代码,还能给同桌讲明白。
这道题适合谁看?刚刷完二叉树遍历、对递归有基础概念的同学,以及想掌握迭代写法以应对面试手写场景的求职者。看完后你不仅能AC这一题,还能迁移到许多"比较两棵树"的同类问题中。话不多说,直接进主题。
1. 对题目的第一眼拆解:什么是对称二叉树
1.1 从一棵树的镜像说起
先看题目描述:给定一个二叉树的根节点,检查它是否轴对称。所谓轴对称,可以理解为这棵树沿着根节点的垂直中线对折,左右两侧完全重合。更严谨一点:根节点的左子树和右子树互为镜像,而镜像不仅要求节点值相等,还要求左子树的左孩子和右子树的右孩子对应相等,左子树的右孩子和右子树的左孩子对应相等。
这里最容易出现的理解偏差是:新手容易只看左右子树各自的结构是否相同,比如判断左子树和右子树是否是完全一样的两棵树。这是严重错误的。对称不是"相同",对称是"镜像"。我们用一个简单例子辅助理解。假设根节点值为1,左孩子为2,右孩子也是2,这是最简单的对称;继续往下,如果左孩子的左孩子是3,左孩子的右孩子是4,那么对应右孩子的左孩子必须是4,右孩子的右孩子必须是3。也就是说,从根节点出发,比较的是左子树的左分支与右子树的右分支,同时左子树的右分支与右子树的左分支。
1.2 判断的基准条件
无论递归还是迭代,核心都是要比较两个节点是否满足"镜像关系"。假如需要比较的两个节点分别是p和q,那么满足对称的条件可以拆成三条,缺一不可。第一条,如果p和q都为空,那它们是对称的,毕竟两边都没有内容;第二条,如果p和q中只有一个为空,另一个非空,那么结构不对称,直接返回假;第三条,如果p和q都不为空,那么首先要求p的值等于q的值,然后递归判断p的左孩子和q的右孩子是否对称,以及p的右孩子和q的左孩子是否对称。这里要注意,递归判断的对象是两对节点,而不是单棵树上的递归。
用C语言写递归函数时,明确返回类型是布尔值或者整数都行。LeetCode的C语言环境里,习惯上用整数0和1表示真假。我常在代码里写一个辅助函数isMirror(struct TreeNode* left, struct TreeNode* right),主函数直接调用isMirror(root->left, root->right)。如果根节点为空,按题目定义,空树也是对称的,直接返回1。
2. 递归解法:两棵子树同时展开
2.1 递归函数的出口设计
递归解决这个问题的优雅之处在于:每一次函数调用只需要处理一个"层"的判断。先看边界。辅助函数接收两个指针,分别代表当前需要比较的两个节点。写递归第一步必须先写终止条件,我下面把判断顺序写清楚,这个顺序不能乱。
int isMirror(struct TreeNode* t1, struct TreeNode* t2) { if (t1 == NULL && t2 == NULL) return 1; if (t1 == NULL || t2 == NULL) return 0; return (t1->val == t2->val) && isMirror(t1->left, t2->right) && isMirror(t1->right, t2->left); }注意第二个条件,我的写法是t1 == NULL || t2 == NULL,这已经隐含了"其中一个为空另一个不为空"的情况。因为在第一个条件已经排除了两者都为空,所以到这里还能进入这个分支的,一定是恰好有一个为空。这种写法比分开写更简洁,也符合C语言习惯。很多初学者容易在这里写成t1->val != t2->val就直接返回0,但这忽略了空节点的可能性,会导致解引用空指针,这是C语言的大忌。
2.2 递归压栈过程模拟
为了让你彻底看懂递归过程,我们拿一棵具体的树来走一遍。假设树的结构如下:
1 / \ 2 2 / \ / \ 3 4 4 3根节点1非空,主函数调用isMirror(2, 2)。注意这里的两个2是左右子树各自的根节点。进入函数,两个都不为空,值相等,于是递归调用isMirror(2的左孩子, 2的右孩子),也就是isMirror(3, 3);同时还要调用isMirror(2的右孩子, 2的左孩子),也就是isMirror(4, 4)。
这里的关键点在于,递归调用是在同一个函数内连续执行两个isMirror。由于使用了&&运算符,从左到右求值,只有前一个isMirror返回真,才会执行后一个。先看isMirror(3, 3),它的两个节点都是叶子,值相等,而且左右孩子都为空,所以两个isMirror(NULL, NULL)都返回1,整体返回真。接着执行isMirror(4, 4),同理返回真。最后整体返回真,这棵树对称。
接着再看一棵不对称的例子:
1 / \ 2 2 / \ / 3 4 4根节点1调用isMirror(2, 2),值相等。先递归判断isMirror(3, 4),这两个节点值不同,直接返回0。由于&&短路,后面的isMirror(4, 4)不会执行,整个函数返回0。这里可以看到,我们并不需要完整遍历整棵树,一旦某一层发现不对称,立刻逐层返回0,效率上比迭代有时候更"早停"。
2.3 递归解法的性能分析
时间复杂度方面,每个节点最多被访问一次,所以为O(n),这里的n是树中节点总数。空间复杂度主要消耗在递归调用栈上。最理想的情况下,树是完全对称的平衡二叉树,递归深度为树的高度,即O(log n)。最坏情况是树极度倾斜,比如每个节点只有一个孩子,并且一路延伸,递归深度会达到O(n)。对于对称树的判断,理论上如果树本身不对称,递归可能提前终止,所以实际运行时间往往低于节点的全量访问,但最坏复杂度仍然是O(n)。
这里要提醒一下,C语言的函数调用栈是有限的,如果一棵树特别深,比如几万层,递归可能会导致栈溢出。在LeetCode的测试数据里通常不会出现,但如果在本地做极端压力测试时需要注意。这也是下面为什么要学习迭代解法的原因之一。
3. 迭代解法:用队列模拟分层镜像比较
3.1 为什么选择队列而不是栈
递归本质上是借助系统调用栈完成回溯,迭代则需要我们自己管理一个数据结构。判断对称树最直观的迭代思路,是把每一层需要比较的两个节点成对放入容器,每次取出两个节点进行比较。这里既可以用栈,也可以用队列。我优先推荐队列,原因是队列的处理顺序天然符合"从根向下、逐层展开"的感觉,而且与层序遍历的框架一脉相承。当然,栈也可以做到,只要保证每次弹出的两个节点是配对的,具体顺序不影响对称判断的正确性。
如果选择队列,我们需要一个能存储节点指针的队列。C语言标准库没有内置队列,所以要么自己用数组模拟,要么使用链表实现。在LeetCode刷题环境中,最实用的是用一个动态数组模拟环形队列,或者简单用数组+头尾下标。考虑到题目中节点数量未知,为了安全,我通常直接用一个足够大的静态数组,或者调用malloc动态分配。这里为了演示清晰,我用动态数组的形式,同时把队列操作封装好。
3.2 入队顺序的巧妙设计
迭代的核心思路是:最开始将根节点的左右孩子放入队列,这两个节点是第一次需要比较的镜像对。之后每次从队列中取出两个节点,记为leftNode和rightNode,比较它们的值。如果相等,则按照"leftNode的左孩子 与 rightNode的右孩子"配对入队,再按照"leftNode的右孩子 与 rightNode的左孩子"配对入队。每次入队都是成对入队,这保证了后续出队时拿到的两个节点一定是需要比较的镜像节点。
这里容易搞错的细节是:当某个节点为空时,要不要入队?答案是",空节点也要入队"。因为我们不仅要比较值,还要比较结构。如果节点为空,直接跳过不处理,会丢失"这一侧有节点、另一侧没有节点"的信息,导致误判。举个例子,如果左子树有左孩子而右子树的右孩子为空,那么这一对节点应该被比较,发现一个非空一个为空,结果返回0。如果空节点不入队,那么在这层比较时我们根本不知道这里存在过不对称的结构。
正确的处理方式是:取出leftNode和rightNode后,首先检查它们是否都为空。如果都为空,说明这一对节点不存在,且没有孩子需要继续比较,直接跳过本轮,继续取下一对。如果只有一个为空,说明结构不对称,直接返回0。如果两个都不为空,检查值是否相等。相等时,把它们的四个相关孩子按镜像配对规则入队,这里注意四个孩子中可能某几个为空,但依然要入队,因为下一轮比较时需要用空和非空来判定结构。
3.3 C语言队列实现细节
我用一个结构体封装动态数组队列。提供三个基础操作:初始化、入队、出队。为了简单,我直接使用struct TreeNode*数组。下面的代码是我在LeetCode上验证过的写法,你可以在本地测试。
/** * Definition for a binary tree node. * struct TreeNode { * int val; * struct TreeNode *left; * struct TreeNode *right; * }; */ bool isSymmetric(struct TreeNode* root) { if (root == NULL) return true; struct TreeNode** queue = (struct TreeNode**)malloc(sizeof(struct TreeNode*) * 2000); int head = 0, tail = 0; // 初始时,根节点的左右孩子入队 queue[tail++] = root->left; queue[tail++] = root->right; while (head < tail) { struct TreeNode* t1 = queue[head++]; struct TreeNode* t2 = queue[head++]; if (t1 == NULL && t2 == NULL) continue; if (t1 == NULL || t2 == NULL) { free(queue); return false; } if (t1->val != t2->val) { free(queue); return false; } // 注意入队顺序:左的左 vs 右的右;左的右 vs 右的左 queue[tail++] = t1->left; queue[tail++] = t2->right; queue[tail++] = t1->right; queue[tail++] = t2->left; } free(queue); return true; }上面代码中,我使用了固定大小的数组。题目给的数据范围一般不会让队列超过这个容量,但在更严格的工程环境中,建议动态扩容。LeetCode上这种写法通过没问题。如果你担心容量不够,可以改为每次入队时检查tail是否超过容量,超出则用realloc扩容。这里为了简洁,我先用固定大小,后面在常见问题里再讲扩容方式。
注意每轮循环要出队两个节点。如果队列中节点数量不是偶数,说明之前的入队操作配错了对。但在我们严格的成对入队逻辑下,只要初始为两个,后续每次出队两个、入队四个,队列长度始终是偶数,所以不会发生这种情况。
3.4 迭代执行流程模拟
继续用前面那棵对称树举例。初始队列:[2, 2]。出队两个2,值相等,分别把左2的左孩子(3)和右2的右孩子(3)入队,再把左2的右孩子(4)和右2的左孩子(4)入队。此时队列变成 [3, 3, 4, 4]。下一轮出队3和3,都为叶子,左右孩子全部为空,继续。出队4和4,同理。队列为空后,返回真。
再模拟不对称树:初始入队 [2, 2]。出队后值相等,将队列变成 [3, 4, 4, null]。注意最后一个null是右子树2的左孩子位置缺失。下一轮出队3和4,值不相等,立即返回假。这里我们看到,如果当时没有把null入队,那么这个位置可能被误认为没有节点,而实际上不对称性在第一层就已经暴露了。所以null入队是迭代解法最关键的细节之一。
3.5 迭代解法的复杂度
时间复杂度和递归一样也是O(n)。空间复杂度是O(n),因为队列中最多会存储某一层的所有节点,最坏情况下(完全二叉树最后一层)大约有n/2个节点。但这里我们存储的是成对节点,所以空间依然O(n)。迭代的一个好处是不用担心递归深度导致的栈溢出,这对处理深度很大的树更友好。不过代价是需要手动管理队列,代码相对繁琐。面试时如果被要求手写,通常默认写递归版本更快;但如果面试官追问非递归写法,队列法是最自然的回答。
4. 递归与迭代的对比:各自优势与适用场景
4.1 代码可读性与面试选择
递归代码极其简洁,5行完成核心逻辑,非常适合用来表达"镜像比较"这个语义。面试时提到对称二叉树,几乎所有面试官都期待你先给出递归思路,因为它最容易解释。但面试官也喜欢追问一句:"如果树很深,递归会不会有问题?请写一个迭代版本。"所以两种写法都要掌握,但心里要有优先级:先说递归,再补充迭代。
迭代版本虽然代码长一些,但它的思路其实更贴近人类逐层观察的方式。我曾经在面试时先写递归,然后面试官问我能否用非递归实现,我便改用队列,并把"空节点入队"这个关键点主动讲出来,面试官立刻点头。实际上,很多候选人写着写着就把空节点弄丢了,导致逻辑错误。如果你能把这一点放在前面讲清楚,印象分会提高不少。
4.2 性能实测对比
我自己在LeetCode上提交过两个版本。递归版本的运行时间通常介于0ms到4ms之间,迭代版本也差不多。空间上,两者在不同形态的树上表现不一样。随机平衡树两者差不多;极深链状树,迭代稳过,递归可能爆栈。所以在生产环境中,如果需要处理不可控深度的二叉树,我优先选择迭代方案。但在刷题阶段,为了最短时间内AC,递归是首选。
4.3 一个容易踩的坑:C语言中bool的包含
LeetCode的C环境里直接使用bool类型是可以的,因为编译器已经包含了相关头文件。但在本地IDE中,如果只写了标准输入输出,没有包含<stdbool.h>,那么bool、true、false都会报错。我记得自己第一次在本地调试时就是忘了加这个头文件,结果编译报错半天。你如果习惯用int和0/1来写,也可以避免这个烦恼。不过我建议统一用bool,语义清晰,记得加头文件就好。
5. 图解思路:用文字还原对称树的比较过程
5.1 分层视角的图形化描述
对称二叉树的判断过程,可以看成是对每一层节点从两侧向中间挤压合并。想象这棵树被根节点分成左右两半,我们用两个指针分别从左右两侧出发,沿着镜像路径同步移动。第一层比较左右根,第二层比较左之左与右之右、左之右与右之左,第三层继续递归。递归调用的树形展开本身就像一棵倒置的“比较树”,每次分解成两个子问题。
如果硬要画图,我们可以把每次比较的节点对用括号表示,例如:
第1层: (2, 2) 第2层: (3, 3) (4, 4) 第3层: ((3的左,3的右), ...) 分解迭代版本的层序过程则类似于宽度优先扫描,每一层都维护一个待比较节点对的队列。队列里的元素永远是两个一组,这就是“对称比较波纹”在数据结构上的体现。理解了这一层,再去看代码中的四次入队就不会打架了。
5.2 边界情况图形化:空节点位置的标记
我们经常遇到的一种情况是,左子树比右子树多一层,但多出来的节点恰好都在内侧。比如:
1 / \ 2 2 / \ 3 3这棵树是对称的吗?答案是肯定的。左子树2的左孩子是3,右子树2的右孩子是3,对应镜像关系成立。如果你用递归,会比较isMirror(3, 3),它们都是叶子,返回真。用迭代时,初始队列为 [2, 2],出队后入队 [3, null, null, 3]。注意这里入队的顺序:第一个3是左2的左孩子,第二个null是右2的右孩子?等等,我们需要重新整理一下。
按照规则,第一次出队t1=左2,t2=右2,入队顺序为:
- t1->left (3) 与 t2->right (3) 配对
- t1->right (null) 与 t2->left (null) 配对
所以队列实际为:[3, 3, null, null]。下一轮出队3和3,值相等,两个节点都是叶子,它们的左右孩子都是null,于是入队 [null, null, null, null, null, null, null, null]?仔细想想,3的左右孩子都是空,t2->right也空,所以四个配对的入队其实是 [null, null, null, null, null, null, null, null]?不是的,每次出队两个3,每个3要分两次入队:第一次把t1->left(null)和t2->right(null)入队;第二次把t1->right(null)和t2->left(null)入队。也就是四个null。接着再出队null和null,continue。所以整体返回真。这里可以看出来,空节点的传递会让队列多出很多空位,但不要紧,它们会被成对消费掉。
再看看不对称的情况:
1 / \ 2 2 / \ 3 4初始队列[2,2],出队后入队 [3, null, null, 4]。下一轮出队3和null,发现一个非空一个空,立即返回假。很好,结构不对称被识别出来了。
5.3 从不完全对称的变体中学到的
有一种变体是,根节点的左右孩子值相同,但下面的结构出现了“外侧缺失”,比如:
1 / \ 2 2 / \ 3 3这个我们刚判断过是对称的。那如果把上面的树改成左子树多了一个4挂在3右边,右子树却在对应位置没有节点,如下:
1 / \ 2 2 / \ 3 3 \ 4这里左子树整体是 2->3->4(右链),右子树整体是 2->3(右链)。对称吗?显然不对称,因为右侧缺少了对应4的位置。递归过程中比较isMirror(3,3)时,两者值相等,但3的左孩子和右孩子:左3的左孩子为null,右3的右孩子为null,配对成功;左3的右孩子为4,右3的左孩子为null,一个非空一个空,所以返回假。迭代版本会走到队列中的一对 (4, null),然后返回假。通过这两个例子,可以看到“空节点入队”和“递归时比较孩子对”是多么重要。
6. 常见问题与排查技巧实录
6.1 递归写法为什么返回了错误结果
有同学写递归时喜欢把主函数和辅助函数分开,这没问题,但容易在主函数里写错判断条件。比如在主函数里先比较左右孩子值,然后递归调用自身而不是调用辅助函数。曾有朋友写出这样的代码:
bool isSymmetric(struct TreeNode* root) { if (root == NULL) return true; if (root->left == NULL && root->right == NULL) return true; if (root->left == NULL || root->right == NULL) return false; return root->left->val == root->right->val && isSymmetric(root->left) && isSymmetric(root->right); }这个代码在简单的对称树上可能通过,但遇到我之前举的复杂镜面对称树就会出错。原因在于isSymmetric(root->left)只检查了左子树本身是否对称,而不是和右子树进行镜像比较。对称树要求左子树与右子树互为镜像,而不是各自内部对称。内部对称的树(比如节点2的左孩子是3,右孩子是4;右子树的节点2左孩子是4,右孩子是3)整体才是对称的。如果改为各自内部对称,整个判断标准就变了。所以一定要用双参数的辅助函数。
6.2 迭代写法中忘记处理空节点
这是迭代最常见的bug。很多人判断完t1和t2的值后,直接检查左右孩子是否存在,存在才入队,不存在就不管。这样做的后果是:当一侧的节点深入到某层为空、另一侧对应位置是非空时,无法被检测出来。举一个具体例子:
1 / \ 2 2 / \ 3 3 \ 4左树有4,右树没有。迭代时,第一次出队2和2,入队左2的左(3)和右2的右(3),入队左2的右(null)和右2的左(null),此时队列为[3,3,null,null]。如果跳过空节点,队列就变成[3,3],下一轮比较3和3,发现相等,随后入队的只有左3右孩子4和右3左孩子null,如果继续跳过null,队列变成[4],下一次取出一个节点4,程序可能出错(因为约定每次取两个)。即使硬取,也可能导致队列长度不匹配。所以不能跳过空节点,而是要在比较阶段明确处理。
6.3 本地调试时如何构造测试用例
我建议你写一个简单的建树函数,利用数组表示层序序列,空节点用特殊值标记,比如INT_MIN。因为C语言中直接手写节点结构太繁琐,层序数组转树是最快的。下面给一个简单实现示例,方便你在本地验证上面两个版本的代码。
#include <stdio.h> #include <stdlib.h> #include <stdbool.h> #include <limits.h> struct TreeNode { int val; struct TreeNode *left; struct TreeNode *right; }; struct TreeNode* createNode(int val) { struct TreeNode* node = (struct TreeNode*)malloc(sizeof(struct TreeNode)); node->val = val; node->left = NULL; node->right = NULL; return node; } struct TreeNode* buildTree(int* arr, int size) { if (size == 0 || arr[0] == INT_MIN) return NULL; struct TreeNode** nodes = (struct TreeNode**)malloc(sizeof(struct TreeNode*) * size); for (int i = 0; i < size; i++) { if (arr[i] == INT_MIN) nodes[i] = NULL; else nodes[i] = createNode(arr[i]); } for (int i = 0; i < size; i++) { if (nodes[i] == NULL) continue; int leftIdx = 2 * i + 1; int rightIdx = 2 * i + 2; if (leftIdx < size) nodes[i]->left = nodes[leftIdx]; if (rightIdx < size) nodes[i]->right = nodes[rightIdx]; } struct TreeNode* root = nodes[0]; free(nodes); return root; }本地测试时,把int arr[] = {1, 2, 2, 3, 4, 4, 3};传进去,调用isSymmetric(root),打印结果。这里要注意,如果数组用INT_MIN表示空节点,那么节点值不能出现INT_MIN,实际题目中的节点值范围在[-100, 100],所以没问题。构建树时,如果某节点为空,其孩子即使有值也无法正确建立,所以层序数组需要把空节点孩子的空位置也补上INT_MIN,这里示例代码已经考虑了这一点,但前提是数组符合完全二叉树的层序填充方式。LeetCode测试用例通常用这种格式存储。
6.4 队列容量扩容技巧
如果你不想固定分配大数组,可以封装一个动态队列。每次入队前检查tail是否等于容量,相等则执行容量翻倍并拷贝。这里要注意,使用realloc后,原来的指针会自动被释放或移动,所以只用维护新的指针即可。head和tail是相对数组起始位置的偏移量,realloc不会改变两者的相对关系,所以可以直接更新数组指针后继续使用。由于我们不能改LeetCode函数的签名,所以在函数内部使用静态局部数组或者动态分配都是允许的。固定2000在题目限制下足够,但如果节点数可到10000,最好改成动态扩容。这里我提供一个小技巧:既然队列长度最多为节点数的两倍,你可以先遍历树计算节点总数(如果你有现成的计数函数),然后精确分配。遍历一次会增加一点开销,但能保证不浪费内存。
6.5 测试数据的陷阱
LeetCode的判定中,节点值可能出现负数,所以不要用-1作为特殊值。调试时我用INT_MIN作为占位符。另外要注意,空树返回真,单节点返回真,这两个边界一定要在本地先测。还有一个极端情况,根节点只有左孩子,没有右孩子,一定返回假,因为结构不对称,但如果你在代码里只比较值,空指针解引用会导致程序崩溃。所以先判断空指针再比较值这个顺序必须严格遵守。
7. 扩展思考:从对称二叉树到更一般的镜像问题
7.1 同一套思路迁移到其他题目
对称二叉树的递归写法其实可以抽象成"比较两棵树是否互为镜像"的原型。很多其他题目都基于这个框架。举例来说,判断两棵树是否相同,只需要把递归比较条件改成"左比左、右比右"而不是"左比右、右比左"。判断一棵树是否是另一棵树的子树,也需要在遍历中不断比较两棵子树是否相同。再比如,反转二叉树其实就是把每个节点的左右孩子交换,而对称树可以理解为"把左子树反转后与右子树相同"。所以吃透这道题的递归解法,后面的许多树题会变得异常简单。
迭代版本同样可以模板化。用一个队列存需要比较的节点对,入队顺序决定比较关系。这个模式在"检查两棵树是否结构相同"的迭代写法中也能复用,只要把配对入队的顺序改成对应位置即可。
7.2 进一步优化:是否可以用后序遍历或Morris遍历
我见过有人讨论是否可以用后序遍历来判断对称。思路是分别对左子树和右子树进行特定的遍历序列,然后比较序列是否相等。若对左子树进行"根右左"遍历,对右子树进行"根左右"遍历,如果两棵子树在该自定义遍历下序列一致,则这棵树对称。这种办法从理论上可行,但要额外申请存储,且需要定义清楚空节点的表示。不过它揭示了一个角度:对称本质上是两种遍历序列的反向匹配。Morris遍历能在O(1)空间遍历二叉树,对称判断如果需要同时比较两个序列,则需要两个Morris遍历的协同推进,实现难度较高,面试极少要求,作为扩展了解即可。
我自己在练习时,通常先用递归快速解决问题,然后强迫自己再写出迭代版本。这个习惯让我在手写队列时越来越熟练,也深刻理解了为什么递归写法在代码清晰度上无可替代。对称二叉树这道题,像是二叉树遍历框架的“集大成者”,能把递归、迭代、边界处理都串起来。
7.3 最后再分享一个小技巧
调试二叉树的递归函数时,我们往往很难直观看到调用过程。我建议在本地加一个全局深度计数变量,在递归函数入口打印当前比较的两个节点值,出口前缩进。比如:
int depth = 0; int isMirrorDebug(struct TreeNode* t1, struct TreeNode* t2) { for (int i = 0; i < depth; i++) printf(" "); printf("compare %d %d\n", t1 ? t1->val : -1, t2 ? t2->val : -1); depth++; // 原有逻辑... depth--; }这样你能直观看到递归的路线。对于理解镜像配对,效果比看代码要好得多。如果你在迭代版本中出了问题,也可以打印每次出队和入队的节点值,看配对是否正确。我记得有一次在本地调试时发现入队顺序写错了,把相邻配对写成了左右错位,打印队列后立刻发现第一个t1的左孩子和t2的左孩子被放在了一起,赶紧修正。打印调试法虽然原始,但排查树形结构问题真的非常有效。
通过这道题,希望你对“镜像”这个概念有更深的理解,也掌握递归和迭代两种思路在树结构中的应用。接下来如果再遇到类似的“比较两棵树”的题目,你就能举一反三,轻松应对了。