LeetCode 101 这道题,只要在树相关题目里刷过一遍,基本都会碰到。题面特别简洁:给一棵二叉树,判断它是不是“镜像对称”的。一个空节点、或者只有根节点的树算不算对称?在常见题面约定里都算。问题本身不涉及什么冷门数据结构,但做起来很有嚼头——它考的是你能不能把“两棵子树互为镜像”抽象成一个可递归、可迭代的子问题,并用 C 语言把它写成一段不崩指针、不走偏逻辑的代码。我当年用 C 写这道题的时候,最大的感受是:脚本语言里那些“空值安全”的便利,在这里全都要自己兜住,反而把算法骨架看得更清楚。这篇文章把题目拆解、递归解法、迭代解法、图解推演和常见踩坑点完整整理了一遍,适合刚把树遍历练熟、想系统过一遍镜像类逻辑的读者,也适合面试前想快速回顾核心套路的人。
1. 题目理解与核心思路
1.1 镜像对称到底在说什么
先别急着写代码,把“对称”这个词抠清楚。题目原文里的说法是镜像对称(symmetric),意思是在二叉树正中间竖一面镜子,左半边和右半边要互为镜像。这里的关键词是“镜像”,不是“相同”。
同样是两棵子树,镜像对称和完全相等是两码事。判断两棵树是否相等,是比较左子树的左孩子和右子树的左孩子;而判断镜像,是比较左子树的左孩子和右子树的右孩子。方向反过来了。
举个例子,经典的对称树数组是这样表示的:[1,2,2,3,4,4,3]。按照层序遍历还原出来,根节点是 1,左右孩子分别是两个 2,再往下,左边的 2 带着 3 和 4,右边的 2 带着 4 和 3。这才是镜像。如果数组是[1,2,2,null,3,null,3],根节点的左右孩子都是 2,但左右两个 2 的左边都没有孩子,右边都挂着 3。表面上看两边各有各的 3,实际镜像位置对不上——左边 2 的右孩子对应右边 2 的左孩子,而那个位置是空的,所以不对称。
这个例子说明了一个基本事实:对称判断不是发生在单个节点内部,而是发生在两条路径之间。根节点本身没什么可比的,真正的比较对象是根节点的左子树和右子树这两棵“大子树”。
1.2 先想清楚谁跟谁比
很多初学者会试着写一个判断函数,在函数里同时比较当前节点的左孩子和右孩子,然后递归下去。这种做法很容易把自己绕晕,因为对称是一个“跨子树”的关系。
更清晰的做法是:把问题改写成“判断两棵树是否互为镜像”。这两棵树,一个是根节点的左子树,一个是根节点的右子树。判断两棵树互为镜像时,才有明确的配对规则:第一对是左树的左子树和右树的右子树;第二对是左树的右子树和右树的左子树。
一旦你把这个改写想通,递归函数的结构就直接浮出来了。后面讲迭代解法时,也是同一个思想:每次从队列里取出来的两个节点,必须是镜像配对的那一对。
1.3 为什么用 C 语言写会更有收获
用 C 写这道题,比用解释型语言多出三层考验:没有现成的队列或栈容器,得自己用数组模拟;没有空值安全运算符,每个指针解引用之前都要自己确认非空;没有自动内存管理兜底,你必须清楚每个节点的生命周期。但也正因如此,你在 C 版代码里写的每个if都是带着思考的。递归里那句left == NULL && right == NULL,换成别的语言可能用null合并运算符或 Optional 链式调用一笔带过,但在 C 里你得显式表达“两个空指针也是镜像”。把这一层想明白,后面看任何语言的树题代码都会很轻松。
2. 递归解法:两棵树镜像问题的标准拆解
2.1 递归为什么是树题的第一直觉
树的定义本身就是递归的:一棵树由根节点、左子树、右子树构成,而子树依旧是树。所以树相关算法,递归是最贴合结构本身的表达方式。对称判断恰好也具备这种递归性质:“A 树和 B 树互为镜像”可以分解成三个子条件:
- A 的根值和 B 的根值相等;
- A 的左子树和 B 的右子树互为镜像;
- A 的右子树和 B 的左子树互为镜像。
第二个和第三个条件各自又是同样的问题,只是规模更小。这天然就是递归。
2.2 辅助函数的设计:别让入口函数扛太多
我建议不要直接把isSymmetric写成递归主函数,而是先定义一个辅助函数isMirror(struct TreeNode* left, struct TreeNode* right),语义是“以 left 为根的树和以 right 为根的树是否互为镜像”。入口函数isSymmetric只需要处理一种特殊情况:树本身为空,然后老老实实调用isMirror(root->left, root->right)。
辅助函数的好处是,你的大脑不需要同时处理“对称定义”和“边界情况”,只需要盯着一件事:给定两个节点指针,判断它们对应的子树是否镜像。这也是面试时比较好表达的思路,先给函数起名,再填判断逻辑。
2.3 终止条件为什么恰好是三个
isMirror的判断分三层:
第一层,两个指针都为空。这意味着镜像的两边都到尽头了,而且刚好同时到尽头,位置对称,返回 true。
第二层,一个为空另一个不为空。说明两边形状不一致,一边还有子树另一边已经没有了,返回 false。
第三层,两个都不为空,但值不同。形状对称但内容不对称,返回 false。
只有两个指针都非空且值相同时,才继续递归。后面两棵子树的检查交给isMirror自己。
这三个条件的顺序是有讲究的。第一层必须放在第二层前面,否则两个空指针会被第二层误判成 false。第二层必须放在解引用之前,否则你会在值比较那里对空指针取->val,C 语言直接段错误。第三层是内容检查,放在结构检查之后合理。
2.4 C 语言完整代码
/** * Definition for a binary tree node. * struct TreeNode { * int val; * struct TreeNode *left; * struct TreeNode *right; * }; */ bool isMirror(struct TreeNode* left, struct TreeNode* right) { if (left == NULL && right == NULL) { return true; } if (left == NULL || right == NULL) { return false; } if (left->val != right->val) { return false; } return isMirror(left->left, right->right) && isMirror(left->right, right->left); } bool isSymmetric(struct TreeNode* root) { if (root == NULL) { return true; } return isMirror(root->left, root->right); }这段代码的核心只有四行:两个终止条件、一个值比较、一个递归组合。isMirror(left->left, right->right)负责“外层”,也就是两棵树各自朝外的方向;isMirror(left->right, right->left)负责“内层”。这个配对规则是整道题的灵魂,写错哪怕一个方向,结果就完全反了。
2.5 短路求值让递归提前退出
注意递归返回值用的是&&连接。C 语言的&&是短路求值的:如果左操作数已经是 false,右操作数根本不会执行。这意味着当外层镜像已经失败时,isMirror(left->left, right->right)返回 false,内层那个递归调用完全不会发生。程序会在最早出现不对称的位置停下来,而不是把整棵树翻个底朝天再宣布失败。
这个特性对性能不是决定性的——复杂度量级还是 O(n)——但逻辑上很舒服。反过来如果写成先递归内层再递归外层,或者用了位运算&,就会丢失这种提前退出的能力。
2.6 递归的栈深度是把双刃剑
时间复杂度 O(n),每个节点最多被访问一遍。空间复杂度是 O(h),h 是树的高度,由递归调用栈决定。
最坏情况值得想一下。一棵树如果高度接近节点数,比如左右两侧分别是两条很长的链、彼此镜像地往下挂,递归就要一路扎到最深处。这时候递归深度可能达到节点数量级。本题常见的测试数据通常不会让栈崩掉,但如果你在设计一个可能处理几百万节点的系统,就必须考虑用迭代版本替代。这也是几乎每道树题的面试追问方向:递归能解,迭代能不能解。
3. 迭代解法:用队列把递归变成循环
3.1 为什么还需要迭代版本
递归版本已经足够优雅,但有两个理由让你必须会写迭代版本。第一,递归深度受调用栈限制,真实生产环境里数据规模不可控,栈溢出是实实在在的风险。第二,很多面试官会顺着你的递归解法追问一句“不用递归怎么写”。如果你卡住,前面再漂亮的递归代码都会打折扣。
迭代解法的本质,是把递归里隐式使用的系统栈,换成显式的队列或栈。你不需要改变“每次比较一对节点”的核心思想,只需要自己维护这个“待比较的节点对”集合。
3.2 队列里放什么:成对的镜像节点
队列里放的绝对不是单个节点,而是一对一对的镜像节点。初始化时,把root->left和root->right这一对放进去。此后每次从队列头部弹出两个节点,先做三种检查:两个都为空就继续;一空一非空就返回 false;值不同就返回 false。都通过了,就按镜像配对的规则,再压入四个孩子:
u->left和v->right成一对;u->right和v->left成一对。
这里有一个容易忽略的细节:压入孩子时,空的子节点也要压进去。因为空节点携带了“形状信息”。如果只在非空时才压入,两棵结构不同的树可能被洗成一样的序列,导致误判。这是迭代版本最常见的隐形 bug,后面会专门展开。
3.3 用数组手写一个队列
C 语言没有标准库队列,但用数组实现一个环形或线性队列非常容易。本题节点数量不大,直接用固定数组即可,队头队尾两个下标就够。关键是下标的管理要清晰:front负责出队,rear负责入队,当front == rear时队列为空。
bool isSymmetric(struct TreeNode* root) { if (root == NULL) { return true; } struct TreeNode* queue[10000]; int front = 0; int rear = 0; queue[rear++] = root->left; queue[rear++] = root->right; while (front < rear) { struct TreeNode* u = queue[front++]; struct TreeNode* v = queue[front++]; if (u == NULL && v == NULL) { continue; } if (u == NULL || v == NULL) { return false; } if (u->val != v->val) { return false; } queue[rear++] = u->left; queue[rear++] = v->right; queue[rear++] = u->right; queue[rear++] = v->left; } return true; }这个版本和递归版本在逻辑上是一一对应的。递归版本靠函数调用栈保存“还没比较的镜像对”,迭代版本靠数组队列保存同样的信息。两者检查的优先级顺序也完全一致。
3.4 关于队列容量的一点思考
固定开一个 10000 的数组,在这道题的数据规模下绝对够用。但我建议你想清楚为什么够,而不是无脑抄这个数字。按照常见的题面约束,节点数不超过 1000,每个节点作为“子节点”最多被压入队列一次,加上初始那一对,总压入量不超过 2n + 2 的量级。所以容量 10000 是富余的。
更严谨的做法,是先遍历一遍数出节点总数 n,再分配2 * n + 5大小的队列数组。这样无论数据怎么变,都不会越界。结构体数组直接分配在栈上,不用malloc,代码也更简单。面试时如果面试官问“队列开多大”,能答出这个推导过程,会比甩一个魔法数字加分不少。
3.5 队列和栈:BFS 与 DFS 的差别
上面的版本本质是广度优先,按层处理镜像对。你也可以用同样的配对规则,把队列换成栈,就变成了深度优先。栈版本会沿着一条镜像路径一路扎到最深层,如果不对称点恰好藏在深处,它能更早遇到。但从面试沟通的角度,队列版本更容易讲清楚:它的处理顺序和层序遍历一脉相承,状态变化更直观。推荐先把队列版本吃透,栈版本当作一道自我挑战题。
4. 图解思路:拿具体树把两种解法走一遍
4.1 先画一棵对称树做参照
为了把抽象逻辑落到具体例子上,先画一棵经典的对称树:
1 / \ 2 2 / \ / \ 3 4 4 3节点数值本身就给了提示:左子树从左到右是 3、4,右子树从右到左也是 3、4。镜像意味着一条从根向左延伸的路径,必然在右边有一条镜像的路径跟随。
再看一棵不对称的树,形状上最容易让人误判:
1 / \ 2 2 \ \ 3 3左右孩子的值都是 2,且都只有右孩子 3。问题在于镜像关系要求左边 2 的右孩子对应右边 2 的左孩子,而右边 2 的左孩子为空。所以第一组镜像对就已经失败。
4.2 递归调用过程的手动推演
对第一棵对称树,isSymmetric(root)调用isMirror(左2, 右2)。两个节点都非空、值相等,于是依次执行两个递归调用:
第一次递归:isMirror(左2->left=3, 右2->right=3)。两个节点都是 3,继续进入它们的孩子。3 是叶子节点,左右孩子都是空,于是isMirror(NULL, NULL)返回 true,这一支结束。
第二次递归:isMirror(左2->right=4, 右2->left=4)。同样,两个 4 的值相等,它们的左右孩子全为空,两个空对空的判断都返回 true,这一支也结束。
两个递归调用都返回 true,&&连接结果就是 true。
对第二棵不对称树,isMirror(左2, 右2)通过值比较后,第一个递归调用是isMirror(左2->left=NULL, 右2->right=3)。一个空一个非空,立刻返回 false。&&短路,第二个递归根本不执行,整体返回 false。这就是“最早分歧点退出”的直观体现。
4.3 递归的图解心法:两根指针必须反着走
我自己画递归过程时,习惯把isMirror(left, right)想象成两根指针同时在树上移动。规则只有一句话:指针往哪边动,完全取决于它属于哪一侧。
left 指针往左孩子移动时,right 指针必须往右孩子移动,因为这是“外层镜像对”;left 指针往右孩子移动时,right 指针必须往左孩子移动,这是“内层镜像对”。一旦两根指针中有一根落空、另一根没落空,或者落空后读到的值对不上,整棵树就不对称。
面试白板上画图时,可以用两个不同颜色的笔分别标 left 和 right,每递归一层就在两个节点之间画一条弧线,写上这是第几层。重复几次之后,镜像配对的方向感自然就建立起来了,比背代码有效得多。
4.4 迭代队列的状态变化表
还是第一棵对称树,把队列的变化每一步列出来。A表示左子树那个 2,B表示右子树那个 2,以此类推:
初始队列:[2L, 2R]
第一轮弹出(2L, 2R):值相等,压入3L, 3R,再压入4L, 4R,队列变为[3L, 3R, 4L, 4R]。
第二轮弹出(3L, 3R):值相等,3 是叶子,四个孩子全为空,压入四个 NULL,队列变为[4L, 4R, NULL, NULL, NULL, NULL]。
第三轮弹出(4L, 4R):值相等,再压入四个 NULL,队列变为[NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL]。
之后连续弹出四对(NULL, NULL),每对都直接 continue,队列清空,返回 true。
第二棵不对称树的队列过程更有意思。初始仍是[2L, 2R],弹出后压入的第一对是2L->left=NULL和2R->right=3。下一次弹出这一对时,一个空一个非空,立刻返回 false。整个过程连整棵树都不用遍历完。
4.5 白板速画法:把空节点也画出来
无论递归还是迭代,手动模拟时最容易出错的地方是漏掉空节点。我建议所有画图统一约定:非空节点画圆圈,空节点画一个实心小三角或者“×”。这样镜像路径两侧的空节点数量一目了然。
例如不对称树中,左边 2 的左孩子是 ×,右边 2 的右孩子是 3,用两条弧线连起来,不对称立刻跳出来。把空节点视觉化之后,代码里那些NULL判断就不再是枯燥的边界处理,而是对应图上一个个 × 记号。
5. 常见问题与排查技巧
5.1 空指针解引用:C 语言头号翻车点
最常见的错误是把空判断写在值比较之后。比如先写left->val != right->val再判断left == NULL。当指针为空时,left->val直接触发段错误。更隐蔽的情况是,你以为某个节点一定有值,结果它是空的,程序在深层递归或队列中间某轮崩掉,报错位置离真正的问题点很远。
解决方案就是严格遵守顺序:先判全空,再判半空,最后才碰值。哪怕你觉得某棵树不可能有空节点,也要把这个顺序当成肌肉记忆。
5.2 镜像配对方向写反
这个错误在递归和迭代版本里都容易出现。错误写法大概是isMirror(left->left, right->left),或者迭代压入孩子时压成u->left, v->left。这样的比较在判断“两棵树相同”,而不是“两棵树互为镜像”。
有一个自检办法:拿一棵确定对称的树,比如[1,2,2,3,4,4,3],如果跑出来是 false,多半就是配对方向错了。对称树对“方向”非常敏感,恰好是对称的树反而最容易暴露方向错误,因为真正的镜像对和错误的配对会在某个值上撞车。
5.3 迭代版漏压空节点
我在 3.2 节强调过:空节点也要压入队列。有些同学为了“节省空间”,只压非空孩子,然后就会莫名其妙地得出错误结果。
举个反例:[1,2,2,null,3,null,3]这种不对称树,如果只压非空节点,初始两个 2 相等,然后压入右侧的两个 3,队列变成[3, 3],再弹出比较值相等,最后返回 true,完美误判。本质原因就是省略空节点导致“形状信息”丢失。所以迭代版的信条是:位置比值更重要,空节点也是信息。
5.4 队列数组越界
数组队列的越界通常不是瞬间崩溃,而是悄悄写到相邻内存,产生莫名结果。防止办法有两种:一种是在循环开头断言rear < 队列容量;另一种是提前数节点总数,动态决定数组大小。LeetCode 场景下固定数组足够,但养成“先算容量再分配”的习惯,对以后写更复杂的数据结构很有帮助。
5.5 递归栈溢出
对称且深度极大的树,比如左右两侧是两条彼此镜像的长链,会让递归深度接近节点数。这种树本身不难构造,测试时就要警惕。如果你发现递归版本在数据量很大时表现诡异,先怀疑栈溢出,再考虑换迭代。
5.6 测试用例清单
整理一份可以直接照着跑的用例表,提交前逐条过一遍:
| 测试结构 | 预期结果 | 说明 |
|---|---|---|
| 空树(NULL) | true | 约定空树对称 |
单节点[1] | true | 单节点天然对称 |
[1,2,2,3,4,4,3] | true | 经典对称树 |
[1,2,2,null,3,null,3] | false | 镜像位置对不上 |
[1,2,3] | false | 左右值不等 |
[1,2,null] | false | 右侧缺失 |
[1,2,2,3,4,4,5] | false | 形状对称但数值不对称 |
最后一行特别适合用来验证你的代码是否真的比较了值,而不是只比较了形状。
5.7 调试技巧:把“正在比较的配对”打印出来
C 语言调试树题,与其盯着抽象逻辑,不如直接打印现场。可以在isMirror或迭代循环里临时加一行:
printf("compare(%d, %d)\n", left ? left->val : -1, right ? right->val : -1);用 -1 代表空节点。跑几个样例,观察打印序列是否符合你手推的步骤。对称失败时,最后打印的那一对就是你该盯住的位置。这本不该出现在最终代码里,但排查期特别好用。
6. 复杂度、变体与通用套路
6.1 两种解法的复杂度对比
| 解法 | 时间复杂度 | 空间复杂度 | 适用场景 |
|---|---|---|---|
| 递归 | O(n) | O(h),h 为树高 | 代码简洁,树高有限时优先 |
| 迭代(队列) | O(n) | O(n),队列最多接近 2n | 数据量大、怕栈溢出时使用 |
递归的空间复杂度不一定是 O(log n);链状或镜像链状的树会让 h 接近 n。迭代版本的空间浪费主要在数组容量上,但最坏队列长度也就在 O(n) 量级,两者量级一致时,迭代的常量往往更大,但胜在上限可控、不会爆栈。
6.2 镜像判断和相同判断只差一个方向
把这道题和“判断两棵树是否完全相同”放在一起看,收获最大。判断相同树时,两棵树的指针是同方向移动:left 的 left 对 right 的 left,left 的 right 对 right 的 right。判断镜像时,方向是反过来的:left 的 left 对 right 的 right,left 的 right 对 right 的 left。
这两个问题的代码结构几乎一模一样,只差最后递归调用时的参数顺序。能一眼看出这种关联,说明你对“比较两棵树”这类问题的建模已经熟了。很多看似不同的树题,本质上都在这个框架里打转,比如判断某棵树是不是另一棵的子树,就是把“相同判断”嵌套进一次遍历。
6.3 另一个视角:每一层都是回文
如果你把二叉树做层序遍历,并且把空节点也写进数组,那么一棵对称树每一层得到的序列,读起来必须是回文。例如经典对称树的第二层是[2,2],第三层是[3,4,4,3],都是回文。
这个视角很适合用来理解为什么空节点不能丢:一旦丢掉空节点,不对称的层可能会被洗成回文。不过层序回文法需要额外保存每一层的完整序列,空间开销比队列双指针法更大,实际编码也啰嗦一些。所以我把它当作“帮助理解对称”的辅助视角,真正提交代码还是用双指针队列。
6.4 现场答题的推荐顺序
如果是面试碰到这道题,我习惯按三步走:
第一步,向面试官确认边界约定:空树算不算对称。绝大多数情况下算,但确认一下能展示你的严谨。
第二步,给出递归解法,讲清楚isMirror的三个终止条件和镜像配对规则,再补一句时间和空间复杂度。
第三步,主动提出迭代版本,说明队列代替栈、空节点也要入队这两个关键点。如果面试官追问,还可以顺势讲 BFS 和 DFS 的取舍。
这套顺序让人看起来是从问题本质出发,而不是背模板。实际交流中我发现,能自然说出“为什么迭代要保留空节点”的候选人,通常对树问题的理解确实到位。
6.5 隔几天再默写一遍胜过当时抄十遍
这道题虽然简单,但非常值得反复“默写”。我的建议是:当天写完递归和迭代两个版本后,隔一天不查任何资料,凭记忆把两个版本各重写一遍;再过一周,用栈版本再实现一次。每次重写,你都会对“配对方向”“空节点入队”这些细节有不同的感受。
说到底,对称二叉树考的不是复杂算法,而是你是否真正理解了递归拆分、指针配对和显式状态管理。这三件事在任何树相关的问题里都通用,值得多花点时间把它练成条件反射。最后再分享一个小技巧:以后不管遇到哪道树题,先在纸上画一棵小树,用两三种不同颜色标出递归或迭代过程中“当前比较的两个节点”,画完再写代码,思路会清晰得多。