对称二叉树
对称不是左右子树长得一样,而是把一边翻过镜子后能与另一边重合。递归每次比较一对镜像位置。
判断二叉树是否围绕根节点的中心轴镜像对称。
同时验证节点值和左右结构的镜像关系。
两空通过,一空失败,值不同失败;外侧与外侧、内侧与内侧交叉比较。
先说结论:这道题到底解决什么
两棵子树的节点值看起来一样,为什么还不能说明二叉树对称?怎样让递归参数始终表示一对真正的镜像位置?
中心结论:两空通过,一空失败,值不同失败;外侧与外侧、内侧与内侧交叉比较。
- 1.对称树比较的是“相同方向”还是“交叉方向”,为什么?
- 2.为什么必须先区分两边都空、只有一边空和值不同三种情况?
- 3.当前一对节点相等后,为什么还要继续检查外侧与内侧两组孩子?
完整题目与题意拆解
给定二叉树根节点 root,检查它是否轴对称。所谓轴对称,是指沿根节点中心轴翻转后,左右两侧的结构和值都能重合。
空树是对称的;非空树从 root.Left 与 root.Right 这第一对镜像位置开始比较。
- • 值相同是必要条件,但不是充分条件。
- • nil 的位置也属于树结构。
- • 比较方向必须交叉。
输入:[1,2,2,3,4,4,3]
输出:true
失败例:[1,2,2,null,3,null,3]
输出:false站在镜轴两边的一对节点必须同时存在或同时为空;如果都存在,它们的值还必须相同。
继续向下时,左边节点的外侧孩子对应右边节点的外侧孩子,内侧孩子也互相对应。
对称不是左右两边长得差不多
中轴出现后,把左右孩子连成第一对镜像位置,并强调必须同时比较结构、值和方向。
第一层方案:暴力做法
可以复制一棵子树、翻转它,再与另一棵子树做相同树比较。思路直观,但创建了额外树结构,也把一个简单关系拆成两个问题。
也可以把左右遍历结果序列化后比较,但必须保留 nil 占位,空间为 O(n)。
mirror := invert(copy(root.Left))
return isSameTree(mirror, root.Right)同方向比较为什么会走错
先演示 left.Left 对 right.Left 的错误连线,再切换到跨越中轴的交叉连线,展示两个问题的本质差异。
优化方向:双节点递归直接把“当前是否镜像”写进函数参数,每一步同时检查结构和值。
整体地图:先做什么,再做什么
先把根节点视作一面镜子,问题从“遍历一棵树”改写成“同时检查镜子两侧的一对位置”。只要递归参数始终是一对镜像位置,判断条件就会自然浮现。
接着分别处理结构和值:两边都空说明这一对位置对称;只有一边空说明轮廓已经不同;都有节点时先比值,再交叉检查外侧和内侧。
- • 先确定一对镜像位置,而不是分别遍历两棵子树。
- • 结构不对称要比值不相等更早处理。
- • 外侧与内侧必须全部成立,不能只验证其中一组。
把一个树问题改写成成对位置的镜像检查
定义 mirror(left,right):它回答的不是两棵树是否相同,而是 left 所在位置与 right 所在位置是否关于根轴镜像。初始调用是 root.Left 与 root.Right。
镜像会交换方向,因此 left.Left 应与 right.Right 配对,left.Right 应与 right.Left 配对。若写成 left.Left 与 right.Left,就把镜像问题误写成了相同树问题。
外侧配对像人的左手和镜中影像的右手;内侧配对则是靠近中轴的两个位置。外侧对外侧,内侧对内侧
两组彩色连线依次连接外侧和内侧孩子,让每次递归的参数关系在画面上保持可见。
先判结构,再判值,最后交叉递归
每一对位置按固定顺序处理:两边都 nil 返回 true;只有一边 nil 返回 false;两边都有节点但值不同返回 false。只有结构和值都通过,才继续向下一层。
随后计算 outer=mirror(left.Left,right.Right) 与 inner=mirror(left.Right,right.Left),返回 outer && inner。AND 表示每一处镜像关系都不能缺席。
- • 两空是这一条镜像分支正确结束。
- • 一空是结构失败,即使现有节点值与别处相同也无法补救。
- • 值相同只证明当前一对,不代表后代已经对称。
- • 外侧和内侧各负责树轮廓的一半。
空根直接返回 true。否则调用 mirror(root.Left, root.Right)。
mirror 按基础情况、值比较、交叉递归的顺序执行。这个顺序先保证访问安全,再保证当前节点,最后保证后代。
基础情况先保护树的轮廓
动画依次展示两空成功、一空失败和值不同失败,使代码的判断顺序与结构判断一一对应。
为什么交叉递归完整覆盖了整棵树
递归不变量是:mirror(left,right) 检查的两个参数始终位于关于根轴互为镜像的位置。初始左右孩子显然满足它。
若两边都空,这一对轮廓一致;若一边空或值不同,当前镜像关系必然失败。两边都有相同值时,镜像定义只剩两项:外侧后代互为镜像,内侧后代互为镜像。
交叉递归恰好生成这两类子问题,因此由结构归纳可知,outer 与 inner 都为 true 当且仅当当前两棵子树互为镜像。
- • 两空与一空规则完整刻画当前位置的结构是否镜像。
- • 当前值相同保证这一对节点内容一致。
- • 交叉递归分别验证外侧和内侧,二者都成立时整棵子树对称。
完整执行过程
动画中央有一条镜轴。每一帧只送一对节点到镜面前,明确它们是外侧配对还是内侧配对。
- 1从根的左右孩子 (2,2) 开始,值相同且两边都存在。
- 2先检查外侧:左侧节点的左孩子 3 对右侧节点的右孩子 3。
- 3外侧孩子的对应空位也成对为空,因此外侧返回 true。
- 4再检查内侧:左侧右孩子 4 对右侧左孩子 4,同样成立。
- 5外侧与内侧都为 true,结果逐层汇总,整棵树被判为对称。
两组结果如何合并成最终答案
外侧和内侧检查分别返回后汇入 AND 门,只有两条支路全部通过,根节点才得到成功结论。
把动画和 Go 代码逐行对应
每一帧使用稳定的语义行 ID,不依赖容易漂移的数字行号。先观察分支为什么成立, 再看对应代码,而不是先背模板。
把镜像方向绑定到 Go 递归参数
代码高亮跟随当前镜像对,重点对齐两空、一空、值比较以及 outer/inner 两次交叉调用。
根节点自己位于镜轴上,真正需要互相对应的是它的左右子树。
对称还要求下面的结构和值继续按镜像位置对应。
它们是离镜轴最远、翻转后互相重合的位置。
两边都空说明结构在这里同步结束。
它们都靠近镜轴,翻转后互相对应。
当前值相同,并且所有镜像后代位置都一一对应。
即使其他值相同,结构已经无法沿镜轴重合。
镜子会交换左右方向,递归参数必须交叉。
每次只做常数比较,递归栈取决于树高。
完整 Go 提交代码与最小测试
1func isSymmetric(root *TreeNode) bool {2 if root == nil { return true }3 return mirror(root.Left, root.Right)4}5 6func mirror(left, right *TreeNode) bool {7 if left == nil && right == nil { return true }8 if left == nil || right == nil { return false }9 if left.Val != right.Val { return false }10 11 outer := mirror(left.Left, right.Right)12 inner := mirror(left.Right, right.Left)13 return outer && inner14}// 标准对称树
fmt.Println(isSymmetric(buildTree([]any{1, 2, 2, 3, 4, 4, 3}))) // true
// 空位结构不对称
fmt.Println(isSymmetric(buildTree([]any{1, 2, 2, nil, 3, nil, 3}))) // false
// 空树与单节点天然对称
fmt.Println(isSymmetric(nil)) // true
fmt.Println(isSymmetric(&TreeNode{Val: 1})) // true正确性与复杂度
每个节点最多作为某个镜像对的一员被比较一次。
递归栈深度由树高决定;平衡树 O(log n),退化结构最坏 O(n)。
最容易写错的地方
同方向比较左右孩子,把镜像写成相同树。
只比较值,忽略 nil 位置表达的结构。
当前一对相等就提前返回 true。
忽略递归栈空间。
最后复盘:带走逻辑链
- 1.递归参数始终是一对镜像位置。
- 2.基础情况顺序:两空、一空、值不同。
- 3.后续方向必须交叉:外侧对外侧,内侧对内侧。
- 4.迁移题:LC100 相同树和 LC226 翻转二叉树。
- 1.定义 mirror(left,right) 判断两个位置是否互为镜像。
- 2.两边都空返回 true,只有一边空或值不同返回 false。
- 3.继续交叉比较 left.Left/right.Right 和 left.Right/right.Left。
- 4.每个节点比较一次,时间 O(n),递归栈空间 O(h)。