アルゴリズム概要

💡 この問題を一言で言うと:

前順探索(preorder)中順探索(inorder)という2種類の配列をもとに、それを生成した元の二分木を Python のノードオブジェクトとして再構築する問題」です。
preorder の先頭は必ずルートになり、inorder でのルート位置が左右の分割点を教えてくれます。この2つの性質を組み合わせることで木を一意に復元できます。

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

  • 片方の配列だけでは木が一意に決まらない:例えば preorder [1,2,3] に対して複数の異なる二分木が存在します。左右どちらの子かが不明なためです。
  • 毎回 list.index() を使うと O(n²):n 個のノードそれぞれで線形探索すると合計 O(n²) になり n=3000 では約450万回の比較が発生します。dict の前処理(O(n))が不可欠です。
  • カーソル変数の共有が難しい:preorder の消費位置を再帰呼び出し間で正確に共有するために nonlocal の理解が必要です。
O(n)
時間計算量
O(n)
空間計算量
再帰 + dict
アルゴリズム
n ≤ 3000
制約

📥 入力例 1

preorder = [3, 9, 20, 15, 7]
inorder  = [9, 3, 15, 20,  7]

[3,9,20,null,null,15,7]
preorder先頭の3がルート。inorderでルート3の左は[9]、右は[15,20,7]と分かる。

📥 入力例 2

preorder = [-1]
inorder  = [-1]

TreeNode(-1)
ノードが1つだけ。左右の子ともNone。

🔑 2つの配列が持つ情報

preorder = [3, 9, 20, 15, 7]
↑先頭 = 必ずルート!
「ルート→左→右」の順に並ぶ
inorder = [9, 3, 15, 20, 7]
ルート「3」の位置が境界線!
左[9] → ルート3 → 右[15,20,7]

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

Python 実装

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

  1. sys.setrecursionlimit:最悪3000段の再帰に備えて上限を緩和する
  2. 入力検証:型チェック・長さ不一致・空リストを早期検出する
  3. 前処理:inorder の「値→インデックス」を dict 内包表記で O(n) 構築する
  4. 再帰関数 build(lo, hi):preorder カーソルを進めながら左→右の順で部分木を再帰構築して返す
import sys
from typing import Optional

sys.setrecursionlimit(10_000)  # 偏った木(n=3000)の深い再帰に備えて上限を緩和


class TreeNode:
    __slots__ = ("val", "left", "right")
    def __init__(self, val=0, left=None, right=None):
        self.val   = val
        self.left  = left
        self.right = right


class Solution:
    def buildTree(
        self,
        preorder: list[int],
        inorder:  list[int],
    ) -> Optional[TreeNode]:
        """
        preorder(前順)と inorder(中順)から二分木を復元する。
        Time:  O(n) - 各ノードをちょうど1回処理
        Space: O(n) - dict(n エントリ) + 再帰スタック(高さ h)
        """
        # ① 型チェック: list 以外が渡された場合に分かりやすいエラーを出す
        if not isinstance(preorder, list) or not isinstance(inorder, list):
            raise TypeError("Both preorder and inorder must be lists")

        # ② 長さ不一致チェック: 同じ木でなければ復元不可
        if len(preorder) != len(inorder):
            raise ValueError(
                f"Length mismatch: preorder={len(preorder)}, inorder={len(inorder)}"
            )

        # ③ 空リストチェック: ノード0個 → Noneを返す
        if not preorder:
            return None

        n = len(inorder)

        # ④ 前処理: inorder の「値→インデックス」を dict 内包表記で O(n) 構築
        #    ★毎回 list.index() を使うと全体 O(n²) → dict なら O(1) ルックアップ
        inorder_index: dict[int, int] = {val: i for i, val in enumerate(inorder)}

        # ⑤ preorder を先頭から消費するカーソル
        #    int はイミュータブル(変更不可)なので nonlocal で外側変数を共有する
        preorder_idx: int = 0

        def build(lo: int, hi: int) -> Optional[TreeNode]:
            """inorder の [lo, hi] 範囲に対応する部分木を再帰構築する"""
            nonlocal preorder_idx

            # ⑥ 再帰の終了条件: 範囲が空(lo > hi) → 部分木なし
            if lo > hi:
                return None

            # ⑦ preorder の現在位置 = この部分木のルート値
            #    preorder は「ルート→左→右」順なので呼ばれた時点の先頭が必ずルート
            root_val: int = preorder[preorder_idx]
            preorder_idx += 1  # 次の再帰のためにカーソルを進める

            # ⑧ dict でルートの inorder 上の位置を O(1) で取得
            #    この位置(mid)が左部分木と右部分木の境界線になる
            mid: int = inorder_index[root_val]

            # ⑨ ノードを生成し左→右の順で再帰構築して接続する
            #    ★左を先にする理由: preorder が「ルート→左→右」順のため
            #    左の再帰が終わると次のカーソル位置が右部分木のルートになる
            node = TreeNode(root_val)
            node.left  = build(lo,      mid - 1)  # 左部分木(mid の左側)
            node.right = build(mid + 1, hi)        # 右部分木(mid の右側)
            return node

        # ⑩ inorder 全体(0〜n-1)を対象に木全体を構築して返す
        return build(0, n - 1)

▶ 入力例 preorder=[3,9,20,15,7] / inorder=[9,3,15,20,7] での動作トレース

前処理: inorder_index = {9:0, 3:1, 15:2, 20:3, 7:4}
preorder_idx = 0

build(0, 4):
  root_val=3 (preorder[0]), preorder_idx→1, mid=inorder_index[3]=1
  node = TreeNode(3)
  node.left  = build(0, 0)
    root_val=9, preorder_idx→2, mid=0
    node.left  = build(0,-1) → lo>hi → None
    node.right = build(1, 0) → lo>hi → None
    ✅ return TreeNode(9)
  node.right = build(2, 4)
    root_val=20, preorder_idx→3, mid=3
    node.left  = build(2, 2)
      root_val=15, preorder_idx→4, mid=2
      → TreeNode(15, None, None)  ✅
    node.right = build(4, 4)
      root_val=7, preorder_idx→5, mid=4
      → TreeNode(7, None, None)   ✅
    ✅ return TreeNode(20, left=15, right=7)
✅ return TreeNode(3, left=9, right=20)

最終ツリー:
        3
       / \
      9  20
         / \
        15   7
Output: [3,9,20,null,null,15,7]  ✅

処理フローチャート

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

楕円(緑)= 開始・終了
四角(青)= 処理ステップ
ひし形(黄)= 条件分岐
緑=Yes 赤=No
buildTree 開始 ① 型チェック isinstance(preorder, list) and isinstance(inorder, list) 型は正しい? No TypeError Yes ② 長さチェック len(preorder) == len(inorder) 同じ長さ? No ValueError Yes preorder が空? Yes None No ③ 前処理: inorder_dict 構築 [O(n)] {val: i for i, val in enumerate(inorder)} ④ preorder_idx = 0 (カーソル初期化) build(0, n-1) 呼び出し ─── 再帰 build(lo, hi) ─── lo > hi ? (空の部分木) Yes None No ⑤ ルート値を取得しカーソルを進める root_val = preorder[idx]; idx += 1 ⑥ inorder_dict でルート位置を O(1) 取得 mid = inorder_dict[root_val] ⑦ node = TreeNode(root_val) 生成 node.left = build(lo, mid-1) ⑧ 右部分木を再帰構築して接続 node.right = build(mid+1, hi) ⑨ return node (再帰が終わると最終的にルートが返る) 完成した木のルートを返す ✅

🔎 入力例 preorder=[3,9,20,15,7] / inorder=[9,3,15,20,7] でのフロー追跡

  1. 「buildTree 開始」→ 両方 list ✅ 長さ5=5 ✅ 空でない ✅
  2. 「前処理」→ {9:0,3:1,15:2,20:3,7:4} を O(n) で構築
  3. 「build(0,4)」→ root_val=3, mid=1, TreeNode(3) 生成
  4. 「build(0,0)」→ root_val=9, mid=0, TreeNode(9) → 左右None → ✅
  5. 「build(2,4)」→ root_val=20, mid=3, TreeNode(20)
  6. 「build(2,2)」→ TreeNode(15)、「build(4,4)」→ TreeNode(7) → ✅
  7. 全再帰が完了 → ルート TreeNode(3) を返す ✅

フローの説明:
入口の検証(①②③)でエラーを早期発見し、dict 前処理(③)で O(n²) を O(n) に改善します。再帰 build(lo,hi) では終了条件 lo>hi を確認した後、preorder カーソルからルートを取得(⑤)し、dict でルートの inorder 位置を特定(⑥)して左→右の順に再帰呼び出しを行います(⑦⑧)。各再帰は必ず処理対象範囲が縮小するため有限回で終了します。

計算量分析

📖 Big-O 記法の読み方

O(1)
常に一定
例: dict のルックアップ
O(n)
入力に比例
例: リストを1回走査
O(n log n)
n よりやや多い
例: ソートアルゴリズム
O(n²)
入力の2乗
例: 二重ループ総当たり

⏱ 時間計算量

処理 計算量 理由
inorder_dict 構築 O(n) 全要素を1回ずつ登録
build 再帰呼び出し合計 O(n) 各ノードをちょうど1回だけ処理
inorder_dict[val] 1回あたり O(1) dict の平均ルックアップコスト
合計 O(n)

💾 空間計算量

使用メモリ 計算量 理由
inorder_dict O(n) n 個のキー・値ペアを保持
再帰スタック O(h) h=木の高さ。バランス木 O(log n)、偏り木 O(n)
生成ノード(出力) O(n) 復元した木全体のノード数
合計 O(n)

⚖ アプローチ比較

アプローチ 時間 空間 備考
★ dict前処理 + 再帰(今回採用) O(n) O(n) 最速・コード明瞭
list.index() + 再帰 O(n²) O(n) n=3000 で約450万回比較、TLE リスク
反復(スタック)+ dict O(n) O(n) 同速だが実装が複雑

🔍 なぜ O(n) になるのか

ポイント1:dict の前処理は全要素を1回走査するだけなので O(n)。
ポイント2:再帰関数 build は各ノードに対して「1回だけ」呼ばれます。preorder カーソルは単調増加(0→n-1)で、各値を重複なく消費するからです。
ポイント3:build の中で行う inorder_dict[val] は平均 O(1) なので、合計 n × O(1) = O(n)。
まとめると O(n) + O(n) = O(n) となります。

📖 用語集

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

inorder(中順探索)
二分木を「左部分木 → ルート → 右部分木」の順に訪れる探索方法。配列中のルートの位置が左右の境界線になるため、preorder と組み合わせると木を一意に復元できます。
O(n²)(オーダーn二乗)
入力サイズが2倍になると処理時間が約4倍になることを示す計算量記法。二重ループや毎回の線形探索に多く見られます。本問で list.index() を使い続けると この計算量になります。
dict(ハッシュマップ)
キーから値を平均 O(1) で取り出せる Python の組み込みデータ構造。内部はハッシュテーブル(キーのハッシュ値から格納場所を直接計算する仕組み)です。図書館の索引カード(タイトル→棚番号)に例えられます。
nonlocal(ノンローカル宣言)
内側の関数から外側スコープにある変数を書き換えるための Python キーワード。global(モジュール変数)とは異なり、直近の外側スコープのみが対象。これがないと += が新しいローカル変数への代入として扱われ、外側が更新されないバグが起きます。
preorder(前順探索)
二分木を「ルート → 左部分木 → 右部分木」の順に訪れる探索方法。配列の先頭要素が必ずルートになるという性質があります。
RecursionError(再帰深度エラー)
Python の再帰深度制限(デフォルト1000回)を超えたときに発生するエラー。完全に偏った木(n=3000)では深さ3000の再帰が起きるため、sys.setrecursionlimit(10_000) で上限を緩和する必要があります。
再帰(Recursion)
関数が自分自身を呼び出して問題を小さな部分問題に分解して解く手法。ロシア人形(マトリョーシカ)の入れ子構造に似ており、必ず「これ以上小さくできない=終了条件」が必要です。
不変条件(Invariant)
アルゴリズムが正しく動作するために、処理中ずっと成り立ち続けるべき条件のこと。本問では「preorder[preorder_idx] は現在処理中の部分木のルート値である」という条件が不変条件です。
偏った木(Skewed Tree)
すべてのノードが左だけ・または右だけにつながった、一直線の木。再帰深度が n(ノード数)と等しくなるため最悪ケースとなります。