再帰DFS(深さ優先探索)による O(n) 実装
💡 一言で言うと:「木の根から葉まで下りるパスの合計が目標値と一致するか判定する問題」です。
💡 この問題を一言で言うと:「根から葉まで下りるルートの数値の合計が targetSum と一致するパスが1本でも存在するか?」を判定する問題です。
木(ツリー)は配列と違い、インデックスで直接アクセスできません。根から分岐を辿りながら合計を積み上げ、葉(末端)に到達した瞬間に目標値と比較する必要があります。
⚠️ なぜ単純な方法では解けないのか
None)を正確に判定しないと、木の途中のノードで誤って答えを確定してしまいます。
例:root = [5,4,8,11,null,13,4,7,2,...], targetSum = 22 を使って解説します。
📋 このコードの構造(先に全体像を把握しよう)
# LeetCode 112 – Path Sum(競技版:再帰DFS)
# Time: O(n) Space: O(h) n=ノード数, h=木の高さ
class Solution(object):
def hasPathSum(self, root, targetSum):
"""
:type root: Optional[TreeNode]
:type targetSum: int
:rtype: bool
"""
# ベースケース①: root が None = 空の木 or 葉を超えた
# パスが存在しないので即 False を返す
if root is None:
return False
# 現在ノードの値を targetSum から引く
# 「残りあとどれだけ合計が必要か」を次の階層に引き継ぐ
# 例) targetSum=22, root.val=5 → 次は 17 を目標にする
targetSum -= root.val
# ベースケース②: 葉(左右両方の子が None)に到達した
# パスの終点なので、残り目標値がちょうど 0 か確認する
if root.left is None and root.right is None:
return targetSum == 0
# 再帰ステップ: 左または右の子ツリーで条件を満たすパスがあれば True
# 「or」は短絡評価: 左が True なら右を評価せずに即 True を返す
return (self.hasPathSum(root.left, targetSum) or
self.hasPathSum(root.right, targetSum))
▶ 入力例 root=[5,4,8,11,null,13,4,7,2,...], targetSum=22 での動作トレース
hasPathSum(Node(5), 22) → targetSum = 22-5 = 17 → 葉でない → 左へ
hasPathSum(Node(4), 17) → targetSum = 17-4 = 13 → 葉でない → 左へ
hasPathSum(Node(11), 13) → targetSum = 13-11 = 2 → 葉でない → 左へ
hasPathSum(Node(7), 2) → targetSum = 2-7 = -5 → 葉!→ -5==0? → False ❌
hasPathSum(Node(2), 2) → targetSum = 2-2 = 0 → 葉!→ 0==0? → True ✅
← True が伝播
← True が伝播
← True が伝播
最終出力: True(パス 5→4→11→2, 合計=22)
deque
を使って再帰なしで安全に実装します。
from collections import deque
class Solution:
def hasPathSum(self, root, targetSum):
if root is None:
return False
stack = deque([(root, targetSum)])
while stack:
node, remaining = stack.pop()
remaining -= node.val
if node.left is None and node.right is None:
if remaining == 0:
return True
continue
if node.right is not None:
stack.append((node.right, remaining))
if node.left is not None:
stack.append((node.left, remaining))
return False
🗺️ フローチャートの読み方
✅ return False の出口は1か所に統合しています(② and ⑤ の両方が合流)
✅ 「はい」「いいえ」ラベルは分岐点の直隣に配置し、視線移動を最小化しています
✅ 再帰呼び出し⑥⑦ は二重縦線ボックスで通常の処理と区別しています
%%{init: {'theme': 'base', 'themeVariables': {'fontSize': '13.5px', 'lineColor': '#64748b', 'primaryBorderColor': '#2563eb', 'tertiaryColor': '#ede9fe'}}}%%
flowchart TD
A(["① 開始\nhasPathSum( root, targetSum )"]):::start
A --> B{"② root is None?\n空の木・存在しない子"}:::cond
B -- "はい" --> F(["return False ❌\n【共通の False 出口】\n② と ⑤ の両方がここへ合流"]):::falseNode
B -- "いいえ" --> C["③ remaining = targetSum − root.val\n例: 22 − 5 = 17"]:::proc
C --> D{"④ 葉ノードか?\nleft is None かつ right is None"}:::cond
D -- "はい(葉に到達)" --> E{"⑤ remaining == 0?\n合計がぴったり一致したか"}:::cond
E -- "はい" --> T(["return True ✅\nパスが見つかった!"]):::trueNode
E -- "いいえ" --> F
D -- "いいえ(葉でない)" --> G[["⑥ hasPathSum( root.left, remaining )\n左の子ツリーへ再帰\nTrue なら即 True(短絡評価 or)"]]:::rec
G --> H[["⑦ hasPathSum( root.right, remaining )\n右の子ツリーへ再帰\nleft or right の結果を返す"]]:::rec
H --> R(["⑧ 結果を上の階層へ返す\nleft_result or right_result"]):::result
classDef start fill:#d1fae5,stroke:#059669,stroke-width:2.5px,color:#065f46,font-weight:bold
classDef cond fill:#fef9c3,stroke:#ca8a04,stroke-width:2px,color:#78350f
classDef proc fill:#dbeafe,stroke:#2563eb,stroke-width:2px,color:#1e3a8a
classDef falseNode fill:#fee2e2,stroke:#dc2626,stroke-width:2.5px,color:#7f1d1d,font-weight:bold
classDef trueNode fill:#dcfce7,stroke:#16a34a,stroke-width:2.5px,color:#14532d,font-weight:bold
classDef rec fill:#ede9fe,stroke:#7c3aed,stroke-width:2px,color:#3b0764
classDef result fill:#f1f5f9,stroke:#64748b,stroke-width:2px,color:#1e293b
linkStyle 1 stroke:#dc2626,stroke-width:2.5px,color:#dc2626
linkStyle 2 stroke:#16a34a,stroke-width:2px
linkStyle 4 stroke:#16a34a,stroke-width:2px
linkStyle 5 stroke:#16a34a,stroke-width:2.5px
linkStyle 6 stroke:#dc2626,stroke-width:2.5px
linkStyle 7 stroke:#7c3aed,stroke-width:2px
linkStyle 8 stroke:#7c3aed,stroke-width:2px
linkStyle 9 stroke:#64748b,stroke-width:2px
🔎 入力例 root=[5,4,8,11,null,13,4,7,2,...], targetSum=22 でのフロー追跡
🔴 return False の2つの経路について
② → False:root が
None(空の木・存在しない子ノードを辿った)場合。パス自体が存在しない。
⑤ → False:葉に到達したが残り目標値が 0
でない場合。このパスの合計は targetSum と一致しない。
両経路とも「このパスに答えはない」という同じ意味を持つため、図では1つの出口ノードに合流させています。
📖 Big-O 記法の読み方
| 項目 | 競技版(再帰) | 業務版(反復deque) |
|---|---|---|
| 時間計算量 | O(n) | O(n) |
| 空間計算量 | O(h) | O(h) |
| 均衡木の空間 | O(log n) | O(log n) |
| 最悪(一直線) | O(n) ⚠️再帰制限 | O(n) ✅安全 |
| 可読性 | ★★★ 高い | ★★☆ 中程度 |
🔍 なぜこの計算量になるのか
時間計算量 O(n):すべてのノードをちょうど1回ずつ訪問するためです。最良ケース(根が葉)でも最悪ケース(全ノードを見る)でも、訪問回数は必ずノード数
n 以下に収まります。
空間計算量 O(h):再帰呼び出しのたびに関数の情報がコールスタックに積まれます。その最大の深さが木の高さ
h です。均衡した木では h ≈ log₂(n)(5000ノードで約12段)、一直線の木では h =
n(最悪5000段)になります。
このページで登場した専門用語を五十音順でまとめました。分からない言葉が出てきたときに参照してください。
list
は先頭への追加・削除が O(n) かかりますが、deque
は O(1) です。スタックやキューとして使うのに最適です。
RecursionError)が発生します。
sys.getrecursionlimit()
で確認できます。5000
ノードの一直線の木では超過する可能性があるため、業務版では
deque
を使った反復 DFS で回避します。
A or B
という式で、A が
True なら B
を評価せずに即
True
を返す仕組みです。今回の実装では左のサブツリーで答えが見つかった場合に右のサブツリー全体の探索をスキップできるため、最良ケースで処理を大幅に短縮できます。