アルゴリズム概要

💡 この問題を一言で言うと:「二分木の左右が完全に鏡写しかどうかを確認する問題」

「鏡写し」とは、木の中心軸(ルート)を境に、左サブツリーを裏返すと右サブツリーとぴったり重なる状態です。 単に「左右の値が同じ」だけでは不十分で、構造(形)と値の両方が対応していなければなりません。

⚠️ なぜ単純な方法では解けないのか

  • 「左と右が同じ構造」ではなく「左の左 ↔ 右の右左の右 ↔ 右の左」という交差した対応関係を正確に追う必要がある
  • ノードが None(存在しない)の場合のケースを3パターン(両方None・片方None・両方あり)に分けて処理しないとバグになる
  • 値が全て同じでも構造が非対称なケース(例:[2,2,2,null,2])で誤検知しやすい
✅ 例1:対称な木
入力: [1, 2, 2, 3, 4, 4, 3]
出力: True

       1
      / \
     2   2
    / \ / \
   3  4 4  3

理由: 左右が完全に鏡写し
  左の左子(3) ↔ 右の右子(3) ✅
  左の右子(4) ↔ 右の左子(4) ✅
❌ 例2:非対称な木
入力: [1, 2, 2, null, 3, null, 3]
出力: False

       1
      / \
     2   2
      \   \
       3   3

理由: left.leftがnullである一方で
  right.rightは3なので
  nullと3が一致せず非対称になる ❌
O(n)
時間計算量
O(h)
空間計算量(再帰)
1〜1000
ノード数の制約
-100〜100
ノード値の範囲

ステップバイステップ解説

各ステップをクリックすると詳細が表示されます。▶ Play で自動再生も可能です。

Python 実装

📋 このコードの構造(先に全体像を把握しよう)

  1. isSymmetric:エントリーポイント。root が None なら即 True、そうでなければヘルパーへ
  2. _is_mirror:2つのノードを受け取り、3ケース(両方None・片方None・両方あり)で鏡写し判定
  3. 値が等しく、外側ペア・内側ペアも再帰的に鏡写しなら True を返す
  4. isSymmetricIterative:deque を使う反復版(スタック深度制限を回避したい場合)
from collections import deque


# LeetCode が提供する TreeNode クラス(提出時はコメント済みのものを使用)
# class TreeNode(object):
#     def __init__(self, val=0, left=None, right=None):
#         self.val = val
#         self.left = left
#         self.right = right

class Solution(object):

    # =========================================================
    # 解法①: 再帰版(メイン)
    # =========================================================
    def isSymmetric(self, root):
        """
        二分木が鏡写し(対称)かどうかを再帰で判定する。

        :type  root: Optional[TreeNode]
        :rtype: bool

        Time:  O(n) - 全ノードを1回ずつ訪問する
        Space: O(h) - 再帰スタックの深さ(h = 木の高さ)
        """
        # rootがNone(空の木)は「対称」と定義する
        if root is None:
            return True

        # rootは中心軸なので比較しない。左子と右子を最初のペアとして渡す
        return self._is_mirror(root.left, root.right)

    def _is_mirror(self, left, right):
        """
        2つのノードが「鏡写し」の関係かどうかを再帰的に確認するヘルパー。

        :type  left:  Optional[TreeNode]
        :type  right: Optional[TreeNode]
        :rtype: bool
        """
        # ── 基底条件① ──
        # 両方Noneなら「空同士」= 対称 → True
        # 例: 葉ノードの子(存在しない位置)同士を比較した場合
        if left is None and right is None:
            return True

        # ── 基底条件② ──
        # 片方だけNoneなら「一方だけ枝がある」= 非対称 → False
        # `is None` を使う: `== None` より高速(同一性チェック)
        if left is None or right is None:
            return False

        # ── 再帰ステップ ──
        # 3条件を `and` で繋ぐ。短絡評価で値が違えば即Falseを返す
        return (
            left.val == right.val                          # 条件1: 値が同じか?
            and self._is_mirror(left.left, right.right)   # 条件2: 外側ペア
            and self._is_mirror(left.right, right.left)   # 条件3: 内側ペア
        )

    # =========================================================
    # 解法②: 反復版(フォローアップ)
    # =========================================================
    def isSymmetricIterative(self, root):
        """
        二分木が鏡写しかどうかを反復(deque)で判定する。
        RecursionError が心配な場合はこちらを使う。

        deque を使う理由: list.pop(0) は O(n) だが
                          deque.popleft() は O(1) で高速。

        :type  root: Optional[TreeNode]
        :rtype: bool

        Time:  O(n)  Space: O(w) - wは木の最大幅
        """
        if root is None:
            return True

        # dequeに「鏡ペア」をタプルで格納して順番に比較する
        queue = deque()
        queue.append((root.left, root.right))

        while queue:
            # FIFO(先入れ先出し)でペアを取り出す
            left, right = queue.popleft()

            if left is None and right is None:
                continue           # 両方None → OK、次のペアへ
            if left is None or right is None:
                return False       # 片方だけNone → 非対称
            if left.val != right.val:
                return False       # 値が違う → 非対称

            # 次に確認すべき鏡ペアをキューに追加
            queue.append((left.left, right.right))   # 外側ペア
            queue.append((left.right, right.left))   # 内側ペア

        return True

▶ 入力例 root = [1, 2, 2, 3, 4, 4, 3] での動作トレース(再帰版)

isSymmetric(root=1)
  → root != None → _is_mirror(root.left=2, root.right=2)

_is_mirror(left=Node(2), right=Node(2))
  → 両方Noneでない、片方Noneでない
  → 2 == 2 ✅
  → _is_mirror(left.left=Node(3), right.right=Node(3))  ← 外側ペア

    _is_mirror(left=Node(3), right=Node(3))
      → 3 == 3 ✅
      → _is_mirror(None, None) → True ✅
      → _is_mirror(None, None) → True ✅
      → True ✅

  → _is_mirror(left.right=Node(4), right.left=Node(4))  ← 内側ペア

    _is_mirror(left=Node(4), right=Node(4))
      → 4 == 4 ✅
      → _is_mirror(None, None) → True ✅
      → _is_mirror(None, None) → True ✅
      → True ✅

最終結果: True and True and True = True ✅

▶ 入力例 root = [1, 2, 2, null, 3, null, 3] での動作トレース(短絡評価の効果)

_is_mirror(left=Node(2), right=Node(2))
  → 2 == 2 ✅
  → _is_mirror(left.left=None, right.right=Node(3))  ← 外側ペア

    _is_mirror(left=None, right=Node(3))
      → 片方だけ None → False ❌ ← ここで即終了!

  → False なので and の短絡評価が働き
    内側ペアの _is_mirror は呼ばれない(省エネ!)

最終結果: False ❌

処理フローチャート

🗺️ フローチャートの読み方

楕円(緑)= 開始・終了
四角(青)= 処理ステップ
ひし形(黄)= 条件分岐
はい いいえ

この図は _is_mirror(left, right) ヘルパー関数の処理の流れを表しています。 上から下へ読み進め、ひし形の分岐で「はい/いいえ」のどちらかの経路を進みます。

開始 root is None? (空の木か?) True を返す はい いいえ _is_mirror(root.left, root.right) 左子と右子を「最初の鏡ペア」として渡す left と right 両方 None? True を返す はい いいえ left または right 片方だけ None? False を返す はい いいえ left.val == right.val? False を返す いいえ はい 外側ペアを再帰確認 _is_mirror(left.left, right.right) 内側ペアを再帰確認 _is_mirror(left.right, right.left) 3条件の AND で結合 値一致 and 外側OK and 内側OK 終了(結果を返す)

🔎 入力例 [1, 2, 2, 3, 4, 4, 3] でのフロー追跡

  1. 「開始」ノード → 入力 root=1 を受け取る
  2. 「root is None?」ノード → root=1 なので「いいえ」の経路へ
  3. 「_is_mirror を呼ぶ」ノード → _is_mirror(left=2, right=2) を呼び出す
  4. 「両方 None?」ノード → 両方ノードが存在するので「いいえ」の経路へ
  5. 「片方だけ None?」ノード → どちらもNoneでないので「いいえ」の経路へ
  6. 「left.val == right.val?」ノード → 2 == 2 なので「はい」の経路へ
  7. 「外側ペアを再帰」ノード → _is_mirror(3, 3) を呼び True が返る
  8. 「内側ペアを再帰」ノード → _is_mirror(4, 4) を呼び True が返る
  9. 「3条件の AND で結合」ノード → True and True and True = True
  10. 「終了」ノード → True を返す ✅

計算量分析

📖 Big-O 記法の読み方(入力サイズ n が大きくなるにつれて処理時間がどう増えるかの目安)

O(1)
常に一定
例:辞書の直接引き
O(n)
入力に比例
例:リストを1回走査
O(n log n)
n より少し多い
例:ソートアルゴリズム
O(n²)
入力の2乗
例:二重ループ総当たり
解法 時間計算量 空間計算量 最悪ケース(空間)
再帰(DFS) O(n) O(h) O(n)(一直線の木)
反復(BFS with deque) O(n) O(w) O(n)(完全二分木最下段)

🔍 なぜこの計算量になるのか

時間計算量 O(n):どちらの解法も、各ノードを最大1回ずつ訪問します。n個のノードがある木では、最大 n/2 ペアを比較するため O(n/2) = O(n) です。
空間計算量(再帰)O(h):h は木の高さです。再帰呼び出しは「コールスタック(=関数の呼び出し履歴を記録するメモリ領域)」に積み重なります。最悪ケースの一直線の木では h = n になるため O(n) です。バランスの取れた木では h = log n になります。
空間計算量(反復)O(w):w は木の最大幅です。deque には同じ深さのペアが格納されるため、完全二分木の最下段(= n/2 個のノード)が最悪ケースで O(n) になります。

⚡ 再帰 vs 反復:どちらを選ぶか

🌀 再帰版を選ぶ場面

  • コードの読みやすさを優先したい
  • バランスの取れた木(深さがスタック上限に達しない)
  • 「鏡写しの定義」をそのままコードに落としたい

🔁 反復版を選ぶ場面

  • 深さが約1000前後になる退化した木(深さ ≈ スタック上限)では再帰が危険になるため反復版を検討する
  • スタックオーバーフローを完全に回避したい
  • 本番環境など安全性を最優先にしたい

📖 用語集

このページで登場した専門用語をまとめました。分からない言葉が出てきたときに参照してください。

BFS(幅優先探索)
Breadth-First Search の略。同じ深さのノードを左から右へ横断していく探索方法。キューと相性が良い。 この問題の反復版で使用。対義語はDFS(深さ優先探索)。
DFS(深さ優先探索)
Depth-First Search の略。根から葉へ向かって深く潜っていく探索方法。再帰と相性が良い。 この問題の再帰版で使用。木を「縦に」探索するイメージ。
collections.deque(デック)
Python 標準ライブラリの両端キュー。「前からも後ろからも出し入れできる箱」のようなデータ構造。 popleft() がO(1)で高速(list.pop(0) はO(n))。 C言語で実装されているため Pure Python より大幅に高速。
コールスタック
関数が呼び出されるたびに積み重なる「呼び出し履歴」のメモリ領域。 再帰呼び出しが深くなるほど消費するメモリが増える。 Pythonはデフォルトで1000回の再帰呼び出しまで許容(sys.getrecursionlimit())。
基底条件
再帰の終了条件。「これ以上再帰しない」と判断して値を返す条件。 基底条件がないと無限再帰(スタックオーバーフロー)になる。 この問題では「両方None → True」「片方None → False」の2つが基底条件。
再帰(Recursion)
関数が自分自身を呼び出す仕組み。木の探索に非常に相性が良い。 「鏡写しかどうか」の定義(= 値が同じで、さらに外側・内側ペアも鏡写し)をそのままコードに書き下せる。 ロシアのマトリョーシカ人形のように「大きい問題を小さい同じ問題に分解する」イメージ。
短絡評価(Short-circuit Evaluation)
A and B でAが False なら、Bをまったく評価せず即座に False を返す仕組み。 この問題では値が違えば外側・内側ペアの再帰呼び出しが省略される。 非対称が早い段階で分かるほど効果が大きい最適化テクニック。
二分木(Binary Tree)
各ノードが最大2つの子(left と right)を持つ木構造のデータ構造。 家系図に例えると、各人物が最大2人の子供を持てる構造。 LeetCodeでは TreeNode クラスで表現され、.val, .left, .right の3つの属性を持つ。
FIFO(先入れ先出し)
First In First Out の略。最初に入れたものを最初に取り出すキューの動作原則。 コンビニのおにぎり棚に例えると、奥から補充して手前から取り出す仕組み(賞味期限管理)と同じ。 deque の append() で末尾追加、popleft() で先頭取り出しにより実現する。
RecursionError
Pythonの再帰呼び出し上限(デフォルト1000回)を超えたときに発生するエラー。 深さ1000の一直線の木で再帰版を使うと発生する可能性がある。 sys.setrecursionlimit(n) で上限を変更可能。または反復版を使うことで根本的に回避できる。