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
356 changes: 356 additions & 0 deletions 98ValidateBinarySearchTree.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,356 @@
問題: https://leetcode.com/problems/validate-binary-search-tree/description/

以下のコードは全てGoのデフォルトフォーマッターgofmtにかけてあります。

```Go
type TreeNode struct {
Val int
Left *TreeNode
Right *TreeNode
}
```

### Step 1
- 方針: サブツリーがそれぞれvalidであるかどうかを調べる。
一つでもinvalidであれば全体もinvalid。
任意のノードに対して、左側の子が根となるサブツリーの最大要素がノードの値より大きかったらinvalid。
同様に、右側の子が根となるサブツリーの最小要素がノードの値より小さかったらinvalid。
- n: 木のノード数
- 時間計算量: O(n)
- 空間計算量: O(n)
- 一つのスタックフレームの大きさはO(1)であり、
最悪の場合、木が一直線になっていると、
スタックフレームがn個積まれることになるから
- balancedな木であればO(log n)
- 上記より、再帰の回数は最大n回。
1スタックフレームの大きさは高々100Bで、
Goのスタックサイズは1GBなので1e7回分くらいまで耐えられる

```Go
type subtree struct {
valid bool
maxValue int
minValue int
}

func isValidBST(root *TreeNode) bool {
tree := isValidBSTRecursively(root)
return tree.valid
}

func isValidBSTRecursively(root *TreeNode) subtree {
if root == nil {
return subtree{valid: true, maxValue: math.MinInt, minValue: math.MaxInt}
}
leftSubtree := isValidBSTRecursively(root.Left)
isLeftSubtreeValid := leftSubtree.valid && leftSubtree.maxValue < root.Val
rightSubtree := isValidBSTRecursively(root.Right)
isRightSubtreeValid := rightSubtree.valid && rightSubtree.minValue > root.Val
return subtree{
valid: isLeftSubtreeValid && isRightSubtreeValid,
maxValue: max(root.Val, rightSubtree.maxValue),
minValue: min(root.Val, leftSubtree.minValue),
}
}
```

### Step 2
#### 2a
- step1の修正
- step1のコードはleetcodeのテストは通ってしまったが、
以下のvalidなBSTに対してinvalidと判定してしまう
```Go
root := &TreeNode{2, &TreeNode{Val: math.MinInt}, &TreeNode{Val: 3}}
```
- 原因は、再帰関数にnilノードが渡された時の返り値が、
```Go
return subtree{valid: true, maxValue: math.MinInt, minValue: math.MaxInt}
```
になっており、
```Go
isLeftSubtreeValid := leftSubtree.valid && leftSubtree.maxValue < root.Val
```
のときにroot.Valがmath.MinIntと等しい値だとfalseとして判定されてしまうからである
- 修正方法として考えたのは、再帰関数にnilノードが渡された時にnilノードであった旨が返り値を見てわかるようにすること。
そのために、subtree構造体のフィールドであるmaxValue, minValueをそれぞれint型へのポインタとし、nilポインタを返せるようにした。
- n: 木のノード数
- 時間計算量: O(n)
- 空間計算量: O(n)


```Go
type subtree struct {
valid bool
maxValue *int
minValue *int
}

func isValidBST(root *TreeNode) bool {
tree := isValidBSTRecursively(root)
return tree.valid
}

func isValidBSTRecursively(root *TreeNode) subtree {
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

些末なことかも知れないですが、返り値が boolean 値出ないのに接頭辞が is になっているのが気になりました。

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.

なんと、本当ですね
subtree構造体のvaidフィールドが何かということをメインに調べたいので、checkSubtreeRecursivelyみたいな感じの方がいいですかね

if root == nil {
return subtree{valid: true, maxValue: nil, minValue: nil}
}
leftSubtree := isValidBSTRecursively(root.Left)
isLeftSubtreeValid := leftSubtree.valid && (leftSubtree.maxValue == nil || *leftSubtree.maxValue < root.Val)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

leftSubtree.maxValue == nil || *leftSubtree.maxValue < root.Valというのが、ここにもL115にも出ているのはやや気になりました。
ここだけで関数化するかというと、なかなか難しいですね

rightSubtree := isValidBSTRecursively(root.Right)
isRightSubtreeValid := rightSubtree.valid && (rightSubtree.minValue == nil || *rightSubtree.minValue > root.Val)
return subtree{
valid: isLeftSubtreeValid && isRightSubtreeValid,
maxValue: pointerToMaxValue(root.Val, rightSubtree.maxValue),
minValue: pointerToMinValue(root.Val, leftSubtree.minValue),
}
}

func pointerToMaxValue(rootValue int, rightSubtreeMaxValue *int) *int {
if rightSubtreeMaxValue == nil || rootValue > *rightSubtreeMaxValue {
return &rootValue
} else {
return rightSubtreeMaxValue
}
}

func pointerToMinValue(rootValue int, leftSubtreeMinValue *int) *int {
if leftSubtreeMinValue == nil || rootValue < *leftSubtreeMinValue {
return &rootValue
} else {
return leftSubtreeMinValue
}
}
```

#### 2b
- in-orderに調べていく方法
- 参考: https://github.com/hayashi-ay/leetcode/pull/38/files
- n: 木のノード数
- 時間計算量: O(n)
- 空間計算量: O(n)

```Go
func isValidBST(root *TreeNode) bool {
var previousValue *int = nil
stack := []*TreeNode{}
node := root
for node != nil {
stack = append(stack, node)
node = node.Left
}
for len(stack) > 0 {
top := stack[len(stack)-1]
stack = stack[:len(stack)-1]
if previousValue != nil && *previousValue >= top.Val {
return false
}
previousValue = &top.Val
node = top.Right
for node != nil {
stack = append(stack, node)
node = node.Left
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

L135~138と同じ処理ですね(これは関数化しやすいかもです)

}
return true
}
```

#### 2c
- inorderに値を入れたリストを作り、最後にstrictな昇順になっているかどうかを確かめる
- 標準ライブラリslicesのIsSortedFuncの第二引数の関数の引数が、
a = s[i], b = s[i+1]の順に並んでいると思ったら逆だった。
なぜ逆順なのだろう?
pkg.go.devのドキュメントに特に何も注意書きがなく、内部実装を見て初めて気がついた
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

IsSorted の気持ちは分かります。sorted な配列に追加することがあるから後ろから見ていきたくて、less のほうが基本的だからこの構造です。Func も合わせたんでしょう。
https://cs.opensource.google/go/go/+/refs/tags/go1.23.2:src/slices/sort.go;drc=6d93de2c110f66457f103c33ba496ff2e2bf33af;l=53

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.

そういうことだったんですね
そういえばslices.Sortで使われているpattern-defeating quicksortもソートされた配列に最後の要素だけ追加が生じた場合にO(n)でできるアルゴリズムであると以前知りました

https://cs.opensource.google/go/go/+/refs/tags/go1.23.2:src/slices/sort.go;l=16;drc=6d93de2c110f66457f103c33ba496ff2e2bf33af
https://github.com/orlp/pdqsort?tab=readme-ov-file#the-best-case

- n: 木のノード数
- 時間計算量: O(n)
- 他の解法はinvalidな部分を見つけ次第returnできるが、
valuesInorderを作りきるまで待って最後に検証するので、
効率が悪い
- 改善方法として、valuesInorderの要素数が100とか任意の値になった時に逐次IsSortedFuncをかけて確かめ、okだったらclearすればよし、という策が挙げられる
- 空間計算量: O(n)
- 要素がO(n)入るスライスが2つあることと、
valuesInorderに必ずn個要素が入ることから、
他の解法と比べると同じO(n)でも少し効率が悪い

```Go
func isValidBST(root *TreeNode) bool {
valuesInorder := []int{}
stack := []*TreeNode{}
node := root
for node != nil {
stack = append(stack, node)
node = node.Left
}
for len(stack) > 0 {
top := stack[len(stack)-1]
stack = stack[:len(stack)-1]
valuesInorder = append(valuesInorder, top.Val)
node = top.Right
for node != nil {
stack = append(stack, node)
node = node.Left
}
}
return slices.IsSortedFunc(valuesInorder, func(a, b int) int {
return a - b - 1
})
}
```

#### 2d
- 末尾再帰
- 参考: https://github.com/hayashi-ay/leetcode/pull/38/files
- step1の再帰より簡潔
- odaさんが何度もいろんなところで再帰の考え方を説明していて、
役割分担をどう定義するかなんだな、と頭でわかっているつもりだが、
いまだに実装の苦手意識がある。
今Haskellを使う授業を受けているので再帰のいい訓練になることを願う
- 一般的に、末尾再帰はコンパイラの最適化がかかることが多いので、
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

2ndのコードは、末尾再帰のある言語、処理系でも末尾再帰最適化されないと思います。
returnする際に、

  • 左のノードについて評価
  • 結果を保存
  • 右のノードについて評価
  • 最後に&&を評価

という流れで呼び出した結果を呼び出し元で保存する必要があるので、関数呼び出しをジャンプに変換することができないと思います。

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.

ご指摘ありがとうございます。末尾再帰とその最適化についてよく理解できていなさそうだったので、以下の記事を読んでみました。
https://qiita.com/pebblip/items/cf8d3230969b2f6b3132
https://qiita.com/TakekazuKATO/items/fc3fedf0ab36ff039aaa#%E6%9C%AB%E5%B0%BE%E5%86%8D%E5%B8%B0%E3%81%A8%E6%9C%AB%E5%B0%BE%E5%86%8D%E5%B8%B0%E6%9C%80%E9%81%A9%E5%8C%96

<自分用メモ>
末尾呼び出し: ある関数fのリターン前の最後の計算が関数gの呼び出しであるということ
末尾再帰最適化: 末尾再帰になっているコードに対してコンパイラが行う最適化で、コールスタックサイズの縮小が期待される。具体的には、

func f(x1) {
    x2 := x1 * 2
    return f(x2)
}

みたいなコードがあったとして、return f(x2)の時にf(x1)の現状をスタックフレームとしてコールスタックに積む代わりに、func f(x1)の行にジャンプして引数をx2に更新する。といった動作をする

末尾再帰最適化はあくまで言語処理系が行う最適化であるので、最適化してくれない処理系を使っているときに末尾再帰を書いてもパフォーマンス上の利点はない

Goは末尾再帰最適化がないと思っていたが、一部あるらしい
https://groups.google.com/g/golang-nuts/c/0oIZPHhrDzY/m/2nCpUZDKZAAJ

JSはES6でサポートされるようになったらしい

そうでない再帰より望ましいが、
Goコンパイラは末尾再帰に対する最適化をかけないので、
あまりパフォーマンスに影響はない
- 時間・空間: O(n)

```Go
func isValidBST(root *TreeNode) bool {
return isValidBSTRecursively(root, nil, nil)
}

func isValidBSTRecursively(node *TreeNode, lowerBound, upperBound *int) bool {
if node == nil {
return true
}
if lowerBound != nil && node.Val <= *lowerBound {
return false
}
if upperBound != nil && node.Val >= *upperBound {
return false
}
return isValidBSTRecursively(node.Left, lowerBound, &node.Val) && isValidBSTRecursively(node.Right, &node.Val, upperBound)
}
```

### Step 3
- inorderにチェック

```Go
func isValidBST(root *TreeNode) bool {
var previousValue *int
node := root
stack := []*TreeNode{}
for node != nil {
stack = append(stack, node)
node = node.Left
}
for len(stack) > 0 {
top := stack[len(stack)-1]
stack = stack[:len(stack)-1]
if previousValue != nil && top.Val <= *previousValue {
return false
}
previousValue = &top.Val
node = top.Right
for node != nil {
stack = append(stack, node)
node = node.Left
}
}
return true
}
```

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 らしさのあるものをと思ったので chan を使ったコードとかを上げてみます。

func traverseInorderHelper(node *TreeNode, c chan *TreeNode) {
    if (node.Left != nil) {
        traverseInorderHelper(node.Left, c)
    }
    c <- node
    if (node.Right != nil) {
        traverseInorderHelper(node.Right, c)
    }
}

func traverseInorder(root *TreeNode, c chan *TreeNode) {
    traverseInorderHelper(root, c)
    close(c)
}

func isValidBST(root *TreeNode) bool {
    c := make(chan *TreeNode)
    go traverseInorder(root, c)
    var previous *TreeNode = nil
    for {
        node, ok := <- c
        if !ok {
            return true
        }
        if previous != nil && node.Val <= previous.Val {
            return false
        }
        previous = node
    }
}

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.

@oda
ありがとうございます。goroutineに慣れていないので確認の質問をさせてください。
このコードの利点はinorderなノード探索の結果をチャンネルに押し出していく形にすることによって、スライスに溜める時と比べてメモリ消費量を抑えられることにある、ということですか?
あともうひとつの利点はgoroutineの並列処理を使っているので、ノード同士の関係性チェックとinorder探索が並列で行われているので、実行時間の短縮が期待される、ということも挙げられると思うのですが、合っていますか?

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.

それから別の類の質問なのですが、odaさんのコードを見ていると上から子関数->親関数という順序で書いていることが多いのですが、この順序は単に好みですか?styleguideに特に記載はなかったのですが、個人的には親->子という順序で書いたほうが読みやすいので、子->親としている背景があったら知りたいと思った次第です

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

あー、メリットとしては複数のスレッドで並列計算されることくらいでしょうが、どちらかというとこれは「言語の機能を見てみよう」くらいの気持ちで書いているので、そんなに真面目に考えなくていいと思います。メモリー消費量は分かりませんが直感的には増えるんじゃないでしょうか。関数呼び出しのスタックが積まれるので。

子供から書くのは、おそらく C/C++ 由来の癖と思います。先に signature がないといけません。どちらでもいいんじゃないでしょうか。

- 末尾再帰

```Go
func isValidBST(root *TreeNode) bool {
return isValidBSTRecursive(root, nil, nil)
}

func isValidBSTRecursive(node *TreeNode, lowerBound, upperBound *int) bool {
if node == nil {
return true
}
if lowerBound != nil && node.Val <= *lowerBound {
return false
}
if upperBound != nil && node.Val >= *upperBound {
return false
}
return isValidBSTRecursive(node.Left, lowerBound, &node.Val) &&
isValidBSTRecursive(node.Right, &node.Val, upperBound)
}
```

### Step 4
#### 4a
- 再帰inorderを実装
- 経緯
- https://github.com/hroc135/leetcode/pull/27#issuecomment-2462253173
- https://github.com/hroc135/leetcode/pull/27#issuecomment-2462311329
- https://github.com/hroc135/leetcode/pull/27#issuecomment-2465026481
- https://github.com/hroc135/leetcode/pull/27#issuecomment-2465128526

```Go
type optionalInt struct {
hasValue bool
value int
}

func isValidBST(root *TreeNode) bool {
previousValue := &optionalInt{hasValue: false, value: 0}
return isValidBSTHelper(root, previousValue)
}

func isValidBSTHelper(node *TreeNode, previousValue *optionalInt) bool {
if node == nil {
return true
}
if !isValidBSTHelper(node.Left, previousValue) {
return false
}
if previousValue.hasValue && node.Val <= previousValue.value {
return false
}
previousValue.hasValue = true
previousValue.value = node.Val
return isValidBSTHelper(node.Right, previousValue)
}
```

#### 4b
- goroutine
- マルチスレッドを使える
- 参考: https://github.com/hroc135/leetcode/pull/27#discussion_r1828839054

```Go
func isValidBST(root *TreeNode) bool {
var previousValue *int
c := make(chan *TreeNode)
go traverseInorder(root, c)
for {
node, ok := <-c
if !ok {
return true
}
if previousValue != nil && node.Val <= *previousValue {
return false
}
previousValue = &node.Val
}
}

func traverseInorder(node *TreeNode, c chan *TreeNode) {
traverseInorderHelper(node, c)
close(c)
}

func traverseInorderHelper(node *TreeNode, c chan *TreeNode) {
if node.Left != nil {
traverseInorderHelper(node.Left, c)
}
c <- node
if node.Right != nil {
traverseInorderHelper(node.Right, c)
}
}
```