0Pricing
DSA Interview Prep · レッスン

パスの合計と最近共通祖先

再帰的な下降処理を使い、一般的な二分木でroot-to-leaf path sum、all-paths-sum、lowest-common-ancestorを解きます。

「パスの合計と最近共通祖先」はCoddyKit上の無料DSA Interview Prepレッスンです。 これはレッスン4/4です。 下記で完全なレッスンを無料で読むことができます。その後、ブラウザ内の組み込みコードエディタと24時間対応のAIチューターでハンズオン演習できます。 これはDSA Interview Prep学習パスの一部であり、ウェブとCoddyKitアプリ全体で進捗が同期されます。 DSA Interview Prepコースには全4レッスンが含まれています。

根から葉へのパス合計

パス合計の問題では、根から葉までのパスの合計が目標値になるものが存在するかを判定します。再帰の中で各ノードの値を引きながら、残りの目標値を渡していきます。葉では、残りの値がその葉の値と等しいかを確認します。これにより明示的なパスのリストを保持する必要がなくなり、空間効率が高く、すっきりした実装になります。エッジケースとして、空の木にはパスがないため、すぐにFalseを返します。

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

def has_path_sum(root, target):
    if not root:
        return False
    if not root.left and not root.right:  # leaf
        return root.val == target
    remain = target - root.val
    return (has_path_sum(root.left, remain) or
            has_path_sum(root.right, remain))

root = TreeNode(5)
root.left = TreeNode(4)
root.right = TreeNode(8)
root.left.left = TreeNode(11)
root.left.left.left = TreeNode(7)
root.left.left.right = TreeNode(2)
print(has_path_sum(root, 22))  # True: 5->4->11->2

すべての根から葉へのパス

すべてのパスを列挙するには、現在のパスをリストとして保持します。各再帰呼び出しで現在のノードの値を追加し、子へ再帰してから、戻るときにpopします(バックトラッキング)。葉に到達したら、現在のパスのスナップショット(list(path))を記録します。この「選択、再帰、選択解除」というパターンは、木に対するバックトラッキングの基礎です。

def all_path_sums(root, target):
    results = []

    def dfs(node, path, remaining):
        if not node:
            return
        path.append(node.val)
        if not node.left and not node.right and remaining == node.val:
            results.append(list(path))  # snapshot
        else:
            dfs(node.left, path, remaining - node.val)
            dfs(node.right, path, remaining - node.val)
        path.pop()  # backtrack

    dfs(root, [], target)
    return results

root = TreeNode(5)
root.left = TreeNode(4)
root.right = TreeNode(8)
root.left.left = TreeNode(11)
root.left.left.right = TreeNode(2)
root.right.right = TreeNode(5)
print(all_path_sums(root, 22))  # [[5,4,11,2]]

パス合計III:任意のパス、任意のノード

パス合計III(LeetCode #437)では、パスの開始点と終了点をどこにしてもよい(根から葉へのパスに限らない)場合に、合計が目標値になるパスの数を数えます。単純な総当たり法の計算量はO(n²)で、各ノードからDFSを実行します。最適なO(n)の方法では累積和のハッシュマップを使い、累積和を追跡しながら、current_sum - targetがそれ以前に何回現れたかを数えます。これは部分配列の合計を求める方法と同じ考え方です。

def path_sum_iii(root, target):
    prefix_counts = {0: 1}

    def dfs(node, running_sum):
        if not node:
            return 0
        running_sum += node.val
        count = prefix_counts.get(running_sum - target, 0)
        prefix_counts[running_sum] = prefix_counts.get(running_sum, 0) + 1
        count += dfs(node.left, running_sum)
        count += dfs(node.right, running_sum)
        prefix_counts[running_sum] -= 1  # backtrack
        return count

    return dfs(root, 0)

root = TreeNode(10)
root.left = TreeNode(5)
root.right = TreeNode(-3)
root.left.left = TreeNode(3)
root.left.right = TreeNode(2)
root.right.right = TreeNode(11)
root.left.left.left = TreeNode(3)
root.left.left.right = TreeNode(-2)
root.left.right.right = TreeNode(1)
print(path_sum_iii(root, 8))  # 3

最低共通祖先とは

二分木における2つのノードpとqの最低共通祖先(LCA)とは、pとqの両方を子孫に持つ最も深いノードです(ノード自身をそのノードの子孫とみなすこともできます)。LCAは、「2つのノード間の距離」、「2つのノード間のパス」、「BSTの範囲クエリ」などの問題で登場します。LCAの理解は、中級レベルの木の問題に取り組むうえで不可欠です。

#       3
#      / \
#     5   1
#    / \ / \
#   6  2 0  8
#     / \
#    7   4
# LCA(5, 1) = 3  (root)
# LCA(5, 4) = 5  (p itself is ancestor of q)
# LCA(6, 4) = 5
# LCA(7, 4) = 2
# Key insight: the LCA is the node where p and q
# first 'split' into different subtrees.
print('LCA: deepest node that is ancestor of both p and q')

LCAの再帰アルゴリズム

洗練された再帰によるLCA解法では、pまたはq自身であるか、部分木にその両方を持つ最初のノードを返します。現在のノードがpまたはqなら、そのノードを返します。それ以外の場合は、左と右に対して再帰します。両方の側がnull以外を返した場合、現在のノードがLCAです。一方の側だけがnull以外の場合は、その結果を上へ伝播します。計算量は時間O(n)、空間O(h)です。

def lowest_common_ancestor(root, p, q):
    # Base case: empty or found one of the targets
    if not root or root == p or root == q:
        return root
    # Search both subtrees
    left = lowest_common_ancestor(root.left, p, q)
    right = lowest_common_ancestor(root.right, p, q)
    # If both sides found something, this node is the LCA
    if left and right:
        return root
    # Otherwise, return whichever side found something
    return left if left else right

root = TreeNode(3)
root.left = TreeNode(5)
root.right = TreeNode(1)
root.left.left = TreeNode(6)
root.left.right = TreeNode(2)
p, q = root.left, root.right  # 5 and 1
lca = lowest_common_ancestor(root, p, q)
print(lca.val)  # 3

ノード自身が祖先になれる場合のLCA

重要なエッジケースがあります。pがqの祖先である場合(またはその逆)、LCAはp自身です。再帰アルゴリズムはこれを自動的に処理します。pに到達すると、pの部分木を調べることなく、すぐにpを返します。親は、一方の側からpが返され、もう一方の側からnullが返されたことを確認するため、LCAとしてpを上へ伝播します。LCAを実装するときは、必ずこのケースをテストで確認してください。

# Test case: p is ancestor of q
# Tree: 3 -> left=5 -> left=6
# LCA(5, 6) should be 5
root = TreeNode(3)
root.left = TreeNode(5)
root.left.left = TreeNode(6)

p = root.left     # node 5
q = root.left.left  # node 6

lca = lowest_common_ancestor(root, p, q)
print(lca.val)  # 5 (p itself is the LCA)

親ポインタを持つ場合のLCA

各ノードが親ポインタを持っている場合、LCAは「2つの連結リストの交差」の問題に置き換えられます。pの祖先を集合に格納し、その後qから上へたどって、その集合に含まれるノードを見つけます。このO(h)時間、O(h)空間の方法は、ノード構造を自分で管理でき、親への参照を保存できるシステム設計面接でよく使われます。

class NodeWithParent:
    def __init__(self, val, parent=None):
        self.val = val
        self.parent = parent
        self.left = None
        self.right = None

def lca_with_parent(p, q):
    ancestors = set()
    # Collect all ancestors of p
    node = p
    while node:
        ancestors.add(node)
        node = node.parent
    # Walk up from q until we hit a known ancestor
    node = q
    while node:
        if node in ancestors:
            return node
        node = node.parent
    return None

print('With parent pointers: O(h) time and space')

二分探索木におけるLCA

BSTでは、順序付けの性質によって各ノードがどの部分木にあるかが分かるため、LCAはより簡単に求められます。pとqの両方が現在のノードより小さい場合、LCAは左部分木にあります。両方が大きい場合は、右部分木にあります。それ以外の場合は、現在のノードが両者を分けるため、それがLCAです。これにより、平衡したBSTでは問題をO(log n)に削減できます。

def lca_bst(root, p, q):
    if not root:
        return None
    if p.val < root.val and q.val < root.val:
        return lca_bst(root.left, p, q)  # both in left
    if p.val > root.val and q.val > root.val:
        return lca_bst(root.right, p, q)  # both in right
    return root  # split point = LCA

# Iterative BST LCA (no recursion overhead):
def lca_bst_iter(root, p, q):
    while root:
        if p.val < root.val and q.val < root.val:
            root = root.left
        elif p.val > root.val and q.val > root.val:
            root = root.right
        else:
            return root
    return None

print('BST LCA: O(log n) for balanced trees')

2つのノード間の距離

木における2つのノード間の距離は、それらを結ぶパス上の辺の数です。これはLCAから直接計算できます:distance(p, q) = depth(p) + depth(q) - 2 * depth(LCA(p,q))。まずLCAを求め、次に各ノードの深さを数えます。適切なヘルパー関数を使えば、計算量は時間O(n)、空間O(h)です。

def find_depth(root, target, depth=0):
    if not root:
        return -1
    if root == target:
        return depth
    left = find_depth(root.left, target, depth + 1)
    if left != -1:
        return left
    return find_depth(root.right, target, depth + 1)

def node_distance(root, p, q):
    lca = lowest_common_ancestor(root, p, q)
    # depth from LCA to p and q
    dp = find_depth(lca, p)
    dq = find_depth(lca, q)
    return dp + dq

root = TreeNode(1)
root.left = TreeNode(2)
root.right = TreeNode(3)
root.left.left = TreeNode(4)
root.left.right = TreeNode(5)
print(node_distance(root, root.left.left, root.left.right))  # 2

根から葉への最大合計パス

根から葉への最大合計パスでは、根から現在のノードまでの累積合計を追跡します。葉に到達したら、グローバルな最大値と比較します。これは現在のパスの合計をパラメータとして渡す、プレオーダーDFSです。一般的な最大パス合計とは異なり、この方法では根から葉へのパスに限定されるため、より単純です。任意のノード間のパスを考慮する必要はありません。

def max_root_to_leaf_sum(root):
    if not root:
        return float('-inf')
    best = [float('-inf')]

    def dfs(node, running):
        running += node.val
        if not node.left and not node.right:  # leaf
            best[0] = max(best[0], running)
            return
        if node.left:
            dfs(node.left, running)
        if node.right:
            dfs(node.right, running)

    dfs(root, 0)
    return best[0]

root = TreeNode(1)
root.left = TreeNode(2)
root.right = TreeNode(3)
root.left.left = TreeNode(4)
root.left.right = TreeNode(5)
print(max_root_to_leaf_sum(root))  # 1+2+5 = 8

根から葉までの数値の合計

根から葉までの数値の合計(LeetCode #129)では、根から葉までの各パスを10進数として扱い(たとえば、パス1→2→3は数値123を表します)、それらの合計を求めます。current_number * 10 + node.valを再帰に渡して数値を作ります。各葉で、完成した数値を合計に加えます。これは、蓄積した状態を下方向へ渡すプレオーダーDFSの分かりやすい例です。

def sum_numbers(root):
    def dfs(node, num):
        if not node:
            return 0
        num = num * 10 + node.val
        if not node.left and not node.right:  # leaf
            return num
        return dfs(node.left, num) + dfs(node.right, num)

    return dfs(root, 0)

root = TreeNode(1)
root.left = TreeNode(2)
root.right = TreeNode(3)
print(sum_numbers(root))  # 12 + 13 = 25

root2 = TreeNode(4)
root2.left = TreeNode(9)
root2.right = TreeNode(0)
root2.left.left = TreeNode(5)
root2.left.right = TreeNode(1)
print(sum_numbers(root2))  # 495 + 491 + 40 = 1026

確認テスト

このレッスンで扱ったData Structures & Algorithms — Coding Interview Prepの概念について、理解度を確認しましょう。

レッスンのまとめ

このレッスンでは、パス合計のバリエーション(根から葉へのパス、すべてのパス、累積和を使うパス合計III)、洗練された再帰的な分割による最低共通祖先、順序付けの性質を利用してO(log n)で求めるBSTのLCAを学びました。次は、挿入と検索の操作から二分探索木を始めます。

よくある質問

「パスの合計と最近共通祖先」レッスンは無料ですか?

はい。「パスの合計と最近共通祖先」の完全なテキストはこのウェブで無料で読めます。インタラクティブに演習し(組み込みコードエディタと24時間対応のAIチューター)、DSA Interview Prepコースの残りをアンロックするには、CoddyKit PROにアップグレードしてください。 DSA Interview Prepコースには全4レッスンが含まれています。

「パスの合計と最近共通祖先」で何を学びますか?

再帰的な下降処理を使い、一般的な二分木でroot-to-leaf path sum、all-paths-sum、lowest-common-ancestorを解きます。 ブラウザで直接実行するハンズオンコードでDSA Interview Prepを演習し、24時間対応のAIチューターがレッスンを進める中での質問に答えます。

DSA Interview Prepを始めるのに経験は必要ですか?

事前経験は必要ありません。CoddyKitのDSA Interview Prepは初級者から上級者向けに構成されているため、ここから始めるか最初から始めて、自分のペースで進むことができます。 これはレッスン4/4です。

「パスの合計と最近共通祖先」レッスンにはどのくらい時間がかかりますか?

ほとんどのCoddyKitレッスンは約5~10分かかります。各レッスンはコンパクトでインタラクティブなので、着実に進歩し、ウェブとアプリ全体で正確に前回の場所から再開できます。

このDSA Interview Prepレッスンでコードを書いて実行できますか?

はい。すべてのDSA Interview Prepレッスンに組み込みコードエディタが含まれているため、ブラウザでリアルコードを書いて実行し、即座のAIフィードバックを取得できます。ローカル設定は不要です。

このコースのすべてのレッスン

  1. TreeNodeクラスとレベル順BFS
  2. in-order、pre-order、post-order DFS
  3. 直径、高さ、平衡木
  4. パスの合計と最近共通祖先
← DSA Interview Prepに戻る