Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
start_new_problem.sh
main.go
go.mod
go.sum
*.go
173 changes: 173 additions & 0 deletions 112PathSum.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
以下のコードは全てGoのデフォルトフォーマッターgofmtにかけてあります。

```Go
// Definition for a binary tree node.
type TreeNode struct {
Val int
Left *TreeNode
Right *TreeNode
}
```

### Step 1
- 気にした制約条件: 二分木はバランスされているかどうか
- 方針: 各ノードについて、根からそのノードまでのパスの値の合計値を保持する構造体を作り、
DFSをして、葉ノードに到達したらパスの合計値がtargetSumと等しいか調べ、等しかったらtrueを返す。
等しくならないまま全探索を終えたらfalseを返す
- 実装: 実装はスムーズに進んだが、テストケースでnilポインタへのアクセスが生じてエラーが出た。
原因はスライス`stack`をmakeする際に`stack := make([]nodePathSum, 5000)`としてしまい、
キャパシティの値を間違って要素数として設定してしまっていたこと。
これによって、5000個のnil値が格納されてしまっていた。
- nをノード数とすると、計算量は以下の通り
- 時間計算量: O(n)
- 空間計算量: O(n)
- あるノードでpathSumがtargetSumを超えたらそのノードの子は探索する必要がなくなる(stackに入れなくて良い)と思ったが、
制約条件でノードの値は負の数もあるのでこの最適化手法は使えない
- ノード数が最大で5000で、各ノードの値の最大値が1000なので、pathSumは最大5e6になりうる。
2^10 ≒ 1e3 より、5e6 < 2^22。Goのint型は32bitマシンで32bitなので、integer overflowは起きない。

```Go
type nodePathSum struct {
node *TreeNode
pathSum int
}

func hasPathSum(root *TreeNode, targetSum int) bool {
if root == nil {
return false
}
stack := make([]nodePathSum, 0, 5000)
stack = append(stack, nodePathSum{root, root.Val})
for len(stack) > 0 {
top := stack[len(stack)-1]
stack = stack[:len(stack)-1]
if top.node.Left == nil && top.node.Right == nil {
if top.pathSum != targetSum {
continue
}
return true
}
if top.node.Left != nil {
stack = append(stack, nodePathSum{top.node.Left, top.pathSum + top.node.Left.Val})
}
if top.node.Right != nil {
stack = append(stack, nodePathSum{top.node.Right, top.pathSum + top.node.Right.Val})
}
}
return false
}
```

### Step 2
#### 2a 再帰
- 再帰による回答
- 再帰の深さは高々5000で、1スタックフレームの大きさを100Bとして考えると、500KBのスタックサイズが必要。
Goは1GBで1e7回再帰できるので大丈夫。
- 考え方: 再帰は役割分担の仕様書を考えるようにやると良いと以前どこかでodaさんが言っていた。
以下、`hasPathSumRecursive`の仕様書
1. 入力として現在地node、targetSum(不変)、現在地nodeの前までのパスの合計値、を取る
2. 現在地nodeがnilだったらfalseを返す
3. 現在地nodeが葉ノードだったら、パスの合計値がtargetSumと等しいかどうかを返す
4. それ以外の場合は、まず左側のノードに処理をさせる。
どこかで葉ノードが見つかったら3の結果が返ってくる。
trueだったらtrueを親ノードに渡す(return値trueとして関数から出ようとする)。
falseだったら右側のノードに処理を渡す。
同様に、どこかで葉ノードが見つかったら3の結果が返ってくるので、自身もその結果を返す。
- なんか複雑な仕様書になった気がする。よりシンプルな解法はないだろうか?

```Go
func hasPathSum(root *TreeNode, targetSum int) bool {
return hasPathSumRecursive(root, targetSum, 0)
}

func hasPathSumRecursive(node *TreeNode, targetSum int, pathSumBeforeNode int) bool {
if node == nil {
return false
}
pathSum := pathSumBeforeNode + node.Val
if node.Left == nil && node.Right == nil {
return pathSum == targetSum
}
found := hasPathSumRecursive(node.Left, targetSum, pathSum)
if found {
return true
}
Comment on lines +92 to +94
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

下のliquoriceさんのレビューでもありますが、短絡評価になるよう実装すればここの複雑なことをしなくても早めにreturnできますね。

found = hasPathSumRecursive(node.Right, targetSum, pathSum)
return found
}
```

#### 2b 再帰改良版
- https://github.com/hayashi-ay/leetcode/pull/30/files
でシンプルな再帰の解答を発見
- targetSumを減らしていくという発想ができなかった。
これは再帰を書く時の基本的な発想の一つだという気がするので自分で思いつけるようになりたい
- あとは2aの最後の5行程度をorを使うことにより簡略化できることに気が付かなかった
- こちらの再帰も仕様書を書いてみる
1. 上から仕事が回ってきた時、入力rootがnilだったらfalseを返す。
葉ノードだったらroot.ValがtargetSumと等しいかどうかを返す
2. 1以外の場合、targetSumを更新して左ノードと右ノードにそれぞれ仕事を送る
3. 下から仕事の結果が返ってきたら上にそのまま届ける

```Go
func hasPathSum(root *TreeNode, targetSum int) bool {
if root == nil {
return false
}
if root.Left == nil && root.Right == nil {
return root.Val == targetSum
}
remaining := targetSum - root.Val
leftFound := hasPathSum(root.Left, remaining)
rightFound := hasPathSum(root.Right, remaining)
return leftFound || rightFound
}
```

#### 2c スタックDFS nilノードもスタックに入れる
- スタックDFSでnilノードもスタックに入れる方法。
これにより、条件分岐が少なくなる。
一方、nilノードもスタックに入れるので、最悪の場合、一本のパスだけからなる二分木だとスタックの要素数が木の要素数の倍になってしまう
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

なるほど、この概算はできてなかったです。勉強になりました。

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

速度は実験してみるといいと思います。分岐予測の失敗もそこそこコストがかかります。

そもそも論として、速度にこだわる必要があるかは状況次第です。
Python は C/C++ などから50倍くらい遅いので、それを使っている時点でそこの速度が問題でない可能性が高いです。

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@nittoco
ここの文間違っていました!
#24 (comment) でも記載しましたが、stackからは毎ループ1つpopされるので、常に2-1=1要素数が増えます。なので、一本のパスだけからなる二分木だと、「スタックに入った通算要素数」は木の要素数の倍になりますが、「スタックに入っている瞬間最大要素数」は木の要素数になるはずです

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

勝手に自分で文を「スタックに入った通算要素数」で解釈してしまってました。確かに要素数というと瞬間要素数を指すのが一般的かもしれないですね、ありがとうございます。

分岐予測は確かに頭になかったです。言語間の比較や、分岐予測も含めてコストを考えてみます。


```Go
type nodePathSum struct {
node *TreeNode
pathSumBeforeNode int
}

func hasPathSum(root *TreeNode, targetSum int) bool {
stack := make([]nodePathSum, 0, 10000)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

どうしてcapacityを指定しましたか?

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

スライスに入りうる最大要素数を知っている場合にはキャパシティを指定した方が再割り当てが起きずパフォーマンスが向上することが多いので、キャパシティを指定しました

ただ、今見返したら最大要素数は1e4ではなく5e3でした。木の要素数が最大で5e3で、木が右側への直線の時に、各ノードがnilノードも合わせて二つの子ノードを持ち、両方スタックに積まれるので 2 * 5e3 = 1e4個スタックに入ることになるかと思ったのですが、popされることを忘れていたので、5e3でした

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

capacityを大きく割り当てるとインプットのサイズが小さい時に悪影響がありそうでしょうか?

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

はい、5e3個分のキャパシティを割り当てたのに実際の要素数が0だと5e3だけ無駄にメモリ使用量として数えられてしまうことになります。
つまり、キャパシティを5e3に設定すると、

  1. 木の要素数が大きいと、メモリ領域の再割り当てを避けられるので実行時間的に良い
  2. 木の要素数が小さいと、無駄にメモリ領域を割り当てることになってしまい、メモリ使用量的に良くない
    というトレードオフがあり、今回は1の場合に実行時間を抑えたくてキャパシティを設定しました。

というところまで吟味しました、というところまで書いておくべきでした

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

なるほど、まず、本当にそこの速度が大事なのかと全体が何秒速くなっているかを考えるか試してみるかして下さい。
速度が大事で速くしたいといっても、一般にボトルネックでないところを速くしてもそれほど効果はありません。

次、メモリー使用量です、ポインターと int なので環境によりますがサイズは 8 + 4 バイトですか? たぶん、パディングされて16バイトでしょうか。それで、1万とっているので、これ、160キロバイトですかね。環境にもよりますがL1キャッシュのサイズを超えていそうです。ランダムアクセスではないのでかもしれませんが遅くなるかも。

最後に、10000という数がコードに現れると疑問が湧きます。「わざわざデフォルト引数から動かさなくてはいけなかったはずだ。どれくらいのコストを払ってこの数字を決めたのか、どれくらい動かしてはいけないのか、たとえば、1週間実験を繰り返した結果こうしたほうがいいと分かったのか、あるバグがあって特定条件で遅くなるのでこの数字にしておく必要があるのか。」コメントつけておいて欲しいですね。

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

メモリー確保などに分解して時間を測って、上の表と比較をすると面白いかもしれません。

「精進」は、2010年前後の JOI 勢がいい始めた気がしています。
もともとのプログラミングコンテストや競技プログラミング同好会では聞かなかった気がしますね。
だいたいこれが競技プログラミング同好会の次の次あたりの世代です。

https://qnighy.github.io/informatics-olympiad/joi-glossary.html#:~:text=%E4%B8%80%E7%A8%AE%E3%81%A7%E3%81%82%E3%82%8B%E3%80%82-,%E7%B2%BE%E9%80%B2,-(%E3%81%97%E3%82%87%E3%81%86%E3%81%98%E3%82%93)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

https://touyoubuntu.hatenablog.com/entry/20111216/1323992210
精進について2011年の言及を発見しました。
やはり、JOI (2010-2011) の合宿から広まったということで正しいようです。

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

なんと、考察記事まであるとは。想像以上に界隈で使われていたようですね。

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2005年に ICPC の世界出場メンバーが私に向かって精進と書いているのを見つけました。
実は精進という言葉もっと歴史があるのかしら。

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

その方が「精進」を根付かせた説はありますかね

stack = append(stack, nodePathSum{root, 0})
for len(stack) > 0 {
top := stack[len(stack)-1]
stack = stack[:len(stack)-1]
node, pathSumBeforeNode := top.node, top.pathSumBeforeNode
if node == nil {
continue
}
pathSum := pathSumBeforeNode + node.Val
if node.Left == nil && node.Right == nil && pathSum == targetSum {
return true
}
stack = append(stack, nodePathSum{node.Left, pathSum})
stack = append(stack, nodePathSum{node.Right, pathSum})
}
return false
}
```

### Step 3
```Go
func hasPathSum(root *TreeNode, targetSum int) bool {
if root == nil {
return false
}
if root.Left == nil && root.Right == nil {
return root.Val == targetSum
}
remaining := targetSum - root.Val
leftFound := hasPathSum(root.Left, remaining)
rightFound := hasPathSum(root.Right, remaining)
return leftFound || rightFound
Comment on lines +169 to +171
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

	return hasPathSum(root.Left, remaining) || hasPathSum(root.Right, remaining)

とした方が短絡評価が起こるので良さそうです。

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

短絡評価という言葉を知らなかったです。ブール演算子による計算において、第一引数を評価して結果が定まらない場合のみ第二引数を評価するということですね
ありがとうございます

}
```