再帰 + インデックス渡し による O(n) / O(log n) 実装
💡 この問題を一言で言うと
昇順ソート済みリストを受け取り、左右の部分木の高さの差が常に 1 以下(=高さ平衡)になる二分探索木(BST)のルートノードを返す問題です。「真ん中の要素を根にする」という発想が核心で、これを再帰的に繰り返すことで自然に平衡が実現されます。
⚠️ なぜ単純な挿入では解けないのか
insert()
を繰り返すと、毎回右の子に追加されて「竹のような一本道の木」になり、高さが
O(n) になる
nums[:mid]
を使うと再帰ごとにリストコピーが発生し、空間計算量が O(n log n)
に悪化する
入出力例 1
入力: nums = [-10, -3, 0, 5, 9]
出力: [0, -3, 9, -10, null, 5]
→ 配列の中央
0(インデックス2)を根にすることで、左右に各2要素ずつ分配でき、高さ平衡が実現されます。
入出力例 2
入力: nums = [1, 3]
出力: [3, 1] または [1, null, 3]
→ 2要素の場合、中央インデックス
mid = (0+1)//2 = 0
なので
1
が根になります。どちらの形も正解として受理されます。
📌 制約
📋 このコードの構造(先に全体像を把握しよう)
build(lo, hi)
を定義して nums を引数なしで参照できるようにする
lo > hi
なら
None
を返して再帰終了
mid = (lo + hi) // 2
を計算して根ノードを作成
from typing import Optional
# LeetCode が提供する TreeNode クラス(定義済みとして扱う)
# class TreeNode:
# def __init__(self, val=0, left=None, right=None):
# self.val = val
# self.left = left
# self.right = right
class Solution:
def sortedArrayToBST(self, nums: list[int]) -> Optional["TreeNode"]:
def build(lo: int, hi: int) -> Optional["TreeNode"]:
# ベースケース:lo > hi のとき空区間 → None を返して再帰終了
# この条件がないと無限ループになり RuntimeError が発生する
if lo > hi:
return None
# 中央インデックスを床除算(//)で計算する
# スライス nums[lo:hi] を使わないのは、新しいリストのコピーが発生して
# 空間計算量が O(n log n) に悪化するため。インデックスなら O(1)。
mid = (lo + hi) // 2
# 中央の値でノードを作成(このノードが現在の区間の「根」になる)
# nums[mid] を根にすることで左右の要素数の差が常に 1 以下に保たれる
node = TreeNode(nums[mid])
# 左半分 [lo, mid-1] で左部分木を再帰構築
# mid 自体は現在のノードとして使用済みのため含まない(mid-1 まで)
node.left = build(lo, mid - 1)
# 右半分 [mid+1, hi] で右部分木を再帰構築
node.right = build(mid + 1, hi)
# 左右の子ノードが接続された完成ノードを返す
return node
# 全体の範囲(インデックス 0 〜 最後のインデックス)で再帰を開始
return build(0, len(nums) - 1)
▶ 入力例 nums = [-10, -3, 0, 5, 9] での動作トレース
build(0, 4):
mid = 2 → node = TreeNode(0) ← 根ノード確定!
node.left = build(0, 1)
mid = 0 → node = TreeNode(-10)
node.left = build(0, -1) → lo(0) > hi(-1) → None
node.right = build(1, 1)
mid = 1 → node = TreeNode(-3) ← 葉ノード
return TreeNode(-3)
return TreeNode(-10, left=None, right=TreeNode(-3))
node.right = build(3, 4)
mid = 3 → node = TreeNode(5)
node.left = build(3, 2) → lo(3) > hi(2) → None
node.right = build(4, 4)
mid = 4 → node = TreeNode(9) ← 葉ノード
return TreeNode(9)
return TreeNode(5, left=None, right=TreeNode(9))
完成した BST:
0 ← 根(配列の中央)
/ \
-10 5
\ \
-3 9
高さ: 左=2, 右=2 差=0 ✅ 高さ平衡!
🗺️ フローチャートの読み方
🔎 入力例 nums = [-10, -3, 0, 5, 9] でのフロー追跡
build(0, 4)
が呼ばれる
mid=2, node=TreeNode(0)
build(0,1)
で同じフローを再帰実行し
TreeNode(-10, right=TreeNode(-3))
を構築
build(3,4)
で
TreeNode(5, right=TreeNode(9))
を構築
TreeNode(0)
を返して終了
📖 Big-O 記法の読み方(入力サイズ n が大きくなるにつれて処理時間がどう増えるかの目安)
| 計算量の種類 | 本実装(インデックス版) | スライス版(比較) |
|---|---|---|
| 時間計算量 | O(n) | O(n log n) |
| 空間計算量(追加) | O(log n) | O(n log n) |
| スライスコピー | なし ✅ | あり(毎回コピー)❌ |
| n=10,000 時の再帰深度 | ≈ 14 段 | ≈ 14 段 |
🔍 なぜこの計算量になるのか
時間計算量 O(n):nums
の各要素はちょうど1回だけ
TreeNode(nums[mid])
として処理されます。n 個の要素なら n
回のノード生成が起こり、それ以外の処理(インデックス計算・代入)も O(1)
なので全体 O(n) です。
空間計算量 O(log n):スライスコピーを使わないため、追加メモリは再帰スタック(=関数の呼び出し履歴を記録するメモリ)のみです。高さ平衡な木の高さは log₂(n) 程度なので、n=10,000 のとき再帰深度は最大 ⌈log₂(10000)⌉ = 14 段程度。Python のデフォルト再帰上限(1000回)に対して余裕があります。
このページで登場した専門用語をまとめました。分からない言葉が出てきたときに参照してください。
lo > hi(有効な要素がない空区間)がベースケースで、None
を返します。ベースケースは「これ以上小さく分割できない最小の問題」です。
//
演算子で表します。例:(0+4)//2 = 2、(0+3)//2 = 1。int((0+4)/2)
と同じ結果ですが、CPython では
// は
C言語レベルの整数演算に最適化されており、型変換コストがありません。
nums[lo:hi]
のようにリストの一部を取り出す操作。Python
では新しいリストオブジェクトがヒープ(動的メモリ領域)に生成されます。再帰のたびに呼ぶと
O(n log n) のメモリが使われるため、本実装ではインデックス
lo/hi
を渡すことでこのコストを O(1) に抑えています。
None
のノード。木の末端を形成します。本問では1要素の区間(lo=hi)から作られるノードが葉ノードになります(左右の子がともに空区間として
None
を返す)。