Path Sum
レベル順で配列 tree に格納された二分木と、数値 targetSum が与えられます。根はインデックス 0 にあり、インデックス i のノードの子は 2*i+1(左)と 2*i+2(右)にあります。-1 は空の位置を示し、配列の末尾に余分な -1 の要素が含まれる場合があります。根から葉までのいずれかの経路上の値の合計が targetSum になる場合は true を、そうでない場合は false を返してください。葉とは子を持たないノードのことです。つまり、左右両方の子の位置が空です。
関数
- treeinteger-array
- レベル順に並べた二分木。空の位置は -1 で表します。
- targetSuminteger
- 根から葉へのパスが到達しなければならない合計
- 戻り値boolean
- あるルートからリーフまでのパスの合計が targetSum になる場合は true、そうでない場合は false
制約
1 ≤ tree.length ≤ 32767- 各
tree[i]は-1、または0 ≤ tree[i] ≤ 1000を満たす値です。 tree[0]が-1になることはないため、木には少なくとも1つのノードがあります。- 配列の末尾には、最後のノードの後に余分な
-1の要素が含まれる場合があります。 - 空のスポットの子も両方とも空で、深さは最大でも
14です。 0 ≤ targetSum ≤ 15000
例
- 入力
- tree = [3, 9, 6, -1, 2, 1, 7]targetSum = 14
- 出力
- true
- 説明
- パス
3、9、2(インデックス0、1、4)の合計は14で、インデックス4の2は葉です。
- 入力
- tree = [3, 9, 6, -1, 2, 1, 7]targetSum = 12
- 出力
- false
- 説明
3 + 9 = 12ですが、9には子があるため、そこで終わるパスはありません。根から葉までの3つのパスの合計は14、10、16で、そのどれも12ではありません。
- 入力
- tree = [4, -1, -1]targetSum = 4
- 出力
- true
- 説明
- ルートの子の位置は両方とも空なので、ルートはそれ自体が葉です。
4だけを含む経路の合計は4です。
提出時に隠しテスト+14件
発展問題
パスがルートから葉までに限られず、任意のノードから始まり、その下にある任意のノードで終わる場合、合計がtargetSumになるパスの数を数えられますか?
ヒント
1つずつ開いてください。開くたびに少しずつ答えに近づきます。
ルートから下に進み、合計を記録していきます。その合計を
targetSumと比較できるのはどこですか?- 葉に限ります。つまり、2つの子ノードの位置がどちらも空のノードです。子が1つあるノードは、合計がすでに一致していても、パスの終点にはなりません。ここまでのパスの合計を各子ノードへ引き継ぎます。
ペアのスタックを保持します。ペアはノードのインデックスと、そのノードまでの根からの合計です。ペアを取り出し、ノードが葉であり、合計が
targetSumと等しければ、trueを返します。そうでなければ、実際に存在する各子ノードを、その子の値を合計に加えてプッシュします。
解説
この問題は、根から葉までの経路全体について尋ねています。累積合計が途中でtargetSumに達しても、まだ子を持つノードであれば条件を満たしません。そこで、これまでの経路の合計を各ノードまで引き継ぎ、葉でのみ目標値と比較します。再帰ではその合計を引数として引き継ぎ、スタックでは各ノードと一緒に保持します。
残りの合計に対する再帰
考え方
まず、配列内をどのように移動するかを見てみましょう。インデックス i のノードは、左の子を 2*i+1 に、右の子を 2*i+2 に持ちます。子が実在するのは、そのインデックスが配列内にあり、そこにある値が -1 ではない場合だけです。[3, 9, 6, -1, 2, 1, 7] では、ルートの 3 はインデックス 1 と 2 に子を持ち、インデックス 1 の 9 はインデックス 3 の左側が空で、右側のインデックス 4 に 2 を持ちます。
次に考え方です。合計が targetSum になるパスはルートの値から始まるため、ルートの子のどちらかから始まる残りのパスの合計は、targetSum からその値を引いたものになる必要があります。これは、より小さな木で同じ問いを考えることです。下へ進むたびに各ノードの値を引きます。葉ではパスが終わるので、残りがゼロかどうかが答えになります。
最初の例では、ルートで残りが 14 - 3 = 11 になり、9 で 2 になり、葉の 2 で 0 になります。つまり true です。2つ目の例では、9 の時点ですでに残りは 0 ですが、子があるため探索は続き、その葉では残りが -2 になります。各ノードは最大1回訪問され、時間計算量は O(n) です。呼び出しスタックには各階層につき1フレームが保持され、O(h) となります。ここでは最大15フレームです(深さ14はルートより下の辺の数を数えます)。
アルゴリズム
walk(i, remaining)を記述し、remainingからtree[i]を引きます。iの両方の子の位置が空(インデックスが末尾を超えているか、-1)なら、remainingが0かどうかを返します。- それ以外の場合は、実際に存在する左の子または右の子に対する
walkがtrueを返すなら、trueを返します。 walk(0, targetSum)を返します。
def hasPathSum(tree, targetSum):
n = len(tree)
def walk(i, remaining):
# remaining is what the path still needs once it reaches node i.
remaining -= tree[i]
left, right = 2 * i + 1, 2 * i + 2
has_left = left < n and tree[left] != -1
has_right = right < n and tree[right] != -1
if not has_left and not has_right:
return remaining == 0 # a leaf: the path ends here
return (has_left and walk(left, remaining)) or (has_right and walk(right, remaining))
return walk(0, targetSum)明示的なスタックを使った深さ優先探索
考え方
再帰では、呼び出しごとに1つの数値を保持します。つまり、目標値のうち、まだ不足している量です。各ノードの横にあるスタックにそのような数値を自分で保持すれば、呼び出しをなくせます。ルートからそのノードまでのパスの合計を、そのノード自身を含めて格納します。(0, tree[0])から始め、各子には親の合計に自分の値を加えたものを渡します。
ペアを取り出します。ノードが葉で、その合計がtargetSumと等しければ完了です。そうでなければ、実際の子をスタックに積みます。最初の例では、右側が先にスタックから取り出されます。葉7と1は、それぞれ16と10を持っています。次に、9に対応する(1, 12)が取り出されます。これは葉ではないため、正しい合計を持つ葉である(4, 14)を積みます。
実際の各ノードは一度だけ積まれるため、時間計算量はO(n)で、検索は条件に一致する最初の葉で停止します。スタックには現在のパスに沿って待機中の兄弟ノードが保持され、各レベルにおよそ1つずつあるので、空間計算量はO(h)です。同じループは、再帰ではスタックが尽きる可能性のある、深いポインタベースの木でも動作します。
アルゴリズム
(0, tree[0])をスタックにプッシュします。- ペア
(i, total)をポップし、子の位置2*i+1と2*i+2を確認します。 - どちらの子も実在せず、
totalがtargetSumと等しい場合、trueを返します。 - 実在する各子
cを(c, total + tree[c])としてプッシュします。 - スタックが空になったら、
falseを返します。
def hasPathSum(tree, targetSum):
n = len(tree)
stack = [(0, tree[0])] # (node index, sum of the path from the root to it)
while stack:
i, total = stack.pop()
left, right = 2 * i + 1, 2 * i + 2
has_left = left < n and tree[left] != -1
has_right = right < n and tree[right] != -1
if not has_left and not has_right and total == targetSum:
return True # a leaf whose path adds up
if has_left:
stack.append((left, total + tree[left]))
if has_right:
stack.append((right, total + tree[right]))
return False
落とし穴と境界ケース
この問題のバグは、ほとんどがパスの終わりに関するものです。
- すべてのノードで合計を比較する。2つ目の例では、
3 + 9 = 12は子を持つ9の時点で一致するため、答えはfalseです。比較するのは葉だけです。 - 空の子の位置をパスの終わりとして扱う。空の位置での
walkがremaining == 0を返すと、2つ目の例の9は空の左の位置を通って葉として数えられます。ノードが葉となるのは、左右両方の位置が空の場合だけです。 - ルートだけの場合を忘れる。単一ノードは葉なので、
targetSum = 4の[4]はtrueであり、targetSum = 0の[0]も同様です。 - 合計が目標値を超えた時点で探索を打ち切る。この問題では値が負になることはないため安全ですが、木に負の値を含められるようになると、同じコードでは誤った答えになります。
- 配列の末尾を越えて読み取る。末尾近くの葉は、配列が最後のノードの直後で終わっている場合、最後の要素を越える子インデックスを持つことがあります。
tree[c]を読み取る前にインデックスを確認してください。 - 配列の開始インデックスが1であるLuaとRで、オフセットを取り違える。ノードのインデックスは
2*i+1の計算に合わせて0始まりのままにし、tree[i + 1]を読み取ります。
よくある質問4
Path Sum の時間計算量はどれくらいですか?
各ノードは最大1回しか訪問されないため、時間計算量はO(n)です。また、一致する最初の葉ノードで探索を終了できます。追加の空間計算量は、探索中のパスに対するO(h)で、これは呼び出しフレームとして、または自分で用意したスタック上の要素として使われます。
Path Sum では、なぜ葉ノードでのみ合計を確認するのですか?
この問題では根から葉へのパスが求められており、子を持つノードで終わるパスは該当しません。すべてのノードで確認すると、trueを返しすぎてしまいます。たとえば、根の値だけが target と等しく、根に子がある場合です。ノードがパスの終点となるのは、左右両方の子の位置が空である場合に限られます。
Path Sum は BFS で解けますか?
はい。ノードとその経路の合計のペアをスタックではなくキューに入れ、取り出すたびに各葉ノードを調べます。時間計算量は引き続き O(n) ですが、キューには1つのレベル全体、つまり完全二分木のノードのおよそ半数を保持できる一方、スタックが保持するのは各レベルにつきおよそ1つのノードです。
合計が目標値になるすべての経路を見つけるにはどうすればよいですか?
下へ進むときは現在の経路上のノードのリストを保持し、合計が一致する各葉でそれを答えにコピーして、上へ戻るときに最後のノードを削除します。走査方法は同じで、管理する情報だけが増えます。一致する葉が多い場合、経路のコピーには走査そのものよりも多くのコストがかかることがあります。
似た問題
同じ考え方を使う問題です。2〜3問解くとパターンが身につきます。
Python
def hasPathSum(tree, targetSum):
# ここにコードを書いてくださいケース1
ケース2
ケース3
入力
tree = [3, 9, 6, -1, 2, 1, 7] targetSum = 14
期待値
true