Skip to content
Open
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
327 changes: 327 additions & 0 deletions 617_MergeTwoBinaryTrees/solution.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,327 @@
# 問題
https://leetcode.com/problems/merge-two-binary-trees/

2つの二分木を受け取り、重ねて1つにマージした木を返す。
同じ位置に両方のノードが存在する場合は値を足し合わせ、片方のみ存在する場合はそのノードをそのまま使う。

- 例1: root1 = [1,3,2,5], root2 = [2,1,3,null,4,null,7] → [3,4,5,5,4,null,7]
- 制約: 各木のノード数は [0, 2000]、-10^4 <= Node.val <= 10^4

# 前提
- 答えを見ずに考えて、5分考えて分からなかったら答えを見てください。答えを見て理解したと思ったら、答えを隠して書いてください。筆が進まず5分迷ったら答えを見てください。そして、見ちゃったら一回全部消してやり直しです。答えを送信して、正解になったら、まずは一段階目です。
- 次にコードを読みやすくするようにできるだけ整えましょう。これで動くコードになったら二段階目です。
- そしたらまた全部消しましょう。今度は、時間を測りながら、もう一回、書きましょう。書いてアクセプトされたら文字を消してもう一回書きましょう。これを10分以内に一回もエラーを出さずに書ける状態になるまで続けてください。3回続けてそれができたらその問題はひとまず丸です。


# 1回目
```go
/**
* Definition for a binary tree node.
* type TreeNode struct {
* Val int
* Left *TreeNode
* Right *TreeNode
* }
*/

// 方針: treeを走査する処理なので,DFSか,BFSのどちらかを使うとよさそうだ.
// よくわからないポイントとして,片方にしかないノードのとき,どのような処理にすればいいのだろうか?
// 2つのルートを受け取る再帰的な処理にすれば,まぁいけそうかな?
// スタックオーバーフローになるかの簡単な検算をしておく
// N=2000 の配列が2つ受け取る.int32以上なら各Valは収まる.
// まぁでも,TreeNodeのstruct見る感じ,(24+8)くらいで32byteとして,これを二つ受け取る感じの関数が再帰することになりそう
Comment on lines +30 to +32

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

どういう計算なのか気になりました。

コメント集やChatGPTを参考に、自分も見積もってみました。
https://discord.com/channels/1084280443945353267/1237649827240742942/1278333082201489491
見積もる前に、まず(64bit環境なら)ポインタは8Bで、TreeNodeの実体はヒープ領域にありスタック領域にはポインタだけがあると思いました。

  • 引数:2 * 8B = 16B
  • ローカル変数:とりあえずnextLeftのひとつだけと考えて、8Bと見積もる
  • リターンアドレス:8B
  • リターン値設定アドレス:8Bと見積もる。ただしレジスタに収まるので、実際にはスタックには積まれなさそう
  • レジスタ退避域・一時変数:0Bと見積もる
  • 合計で40B
    • ただ、あくまでおおざっぱな見積もりなので、いろいろ増減があると思います。

自分はあまりちゃんと見積もりをやっていなかったので、考え方の違いなど、なにかあればコメントもらえるとうれしいです。

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.

すみません,これコメントが完全に古くなっているので,無視していただいて構いません.
ちょっと逸れますが,コメントいただいたことで以下の点について,今回は勘違いしていたことに気づきました.ありがとうございます.

TreeNodeの実体はヒープ領域にありスタック領域にはポインタだけがあると思いました。

元々考えていたことを思い出してみます,パッと考えてみたのですが,数日時間を置きながら解いたこともあり,支離滅裂なことになってしまっていそうです.(のちほど追記します)

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
Owner Author

Choose a reason for hiding this comment

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

たぶん以下です.

  • (step1時点でコメントアウトしてますが)もともとmergeTreeFuncという再帰用の関数を用意して,それを利用するつもりでした.
  • mergeTreeFuncの引数はもともと,root1,root2, resultTree(結果ノードを書き込む)の3つの引数がいるかなと検討をつけました.これらはいずれもポインタなので,よって8B * 3で24byte,あとはリターンアドレスが保持されるだろうと思い,+8Bして,32byteと計算した,気がしています.ですが最終的にstep1ではresultTree は不要と判断しました.
  • ローカル変数の見積もりは完全に漏れていますね.これはこれまで毎回漏れていたように思います.勉強になりました.

  • まぁでも,TreeNodeのstruct見る感じ,(24+8)くらいで32byteとして,これを二つ受け取る感じの関数が再帰することになりそう という文章全体は,完全に意味不明だなと思います.これはもう無視してください.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

状況がわかりました。ありがとうございます。

// 最大2000回のスタックなので,2*10^3 * 32 = 64 * 10^3 = 64KB程度

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

最大2000回のスタックなので

日本語として不自然に感じました。「最大 2000 回(再帰関数が呼ばれて、スタックメモリに)スタックフレームが詰まれるので、」でしょうか。

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.

その通りですね.
別の方のコメントもあり,方針メモ(残すことにだけを重視しすぎて)を適当に書きすぎているなと思いました.

// Goのgoroutineのスタック上限である1GBに収まりそうなので,では気にせず書いていく
func mergeTrees(root1 *TreeNode, root2 *TreeNode) *TreeNode {
switch {
case root1 != nil && root2 != nil:
nextLeft := mergeTrees(root1.Left, root2.Left)
nextRight := mergeTrees(root1.Right, root2.Right)
return &TreeNode{
Val: root1.Val + root2.Val,
Left: nextLeft,
Right: nextRight,
}
case root1 != nil && root2 == nil:
return &TreeNode{
Val: root1.Val,
Left: root1.Left,
Right: root1.Right,
}
case root1 == nil && root2 != nil:
return &TreeNode{
Val: root2.Val,
Left: root2.Left,
Right: root2.Right,
}
default:
return nil
}
}

// 再帰でやること
// 2つのnodeと,resultのparentを受け取る.
// 片方がnilのとき,non-nilのLeft,Rightを繋げて終わり
// 両方がnilのとき,なにもしない(停止条件)
// 両方がnon-nilのとき,newNodeを作り,valを足して...?
// parent受け取るだけじゃ,leftなのかrightなのかわからないからそれも判別できるようにわたしたほうがいいのかな?
// えっと,これトップダウンで組み立てるか,ボトムアップなのかで違うかな?ボトルアップなら,left/rightは戻すときに判別すればよさそう
// func mergeTreeFunc(root1, root2 *TreeNode) *TreeNode {
// switch {
// case root1 != nil && root2 != nil:
// nextLeft := mergeTreeFunc(root1.Left, root2.Left)
// nextRight := mergeTreeFunc(root1.Right, root2.Right)
// return &TreeNode{
// Val: root1.Val + root2.Val,
// Left: nextLeft,
// Right: nextRight,
// }
// case root1 != nil && root2 == nil:
// return &TreeNode{
// Val: root1.Val,
// Left: root1.Left,
// Right: root1.Right,
// }
// case root1 == nil && root2 != nil:
// return &TreeNode{
// Val: root2.Val,
// Left: root2.Left,
// Right: root2.Right,
// }
// default:
// return nil
// }
// }
```
- 2つめの関数は,一度書いたけど,全く同じシグネチャになったので不要だと気づいてコメントアウトした.
- あとこれは,もとのroot1,root2とアドレスを共有しているので破壊的になりうる
- 書き直すところは,あまり浮かばないが,わざわざnextLeftとかをnextとつける意味は薄いから削ってもいいかも.くらい
- 計算量をあらためて確認する
- mergeTrees自体はO(1)
- root1, root2が両方あるときだけ再帰がかかるので,2000回の再帰が上限
- 時間計算量はO(N) 2000 * 10^-8 = 2*10^-5 = 0.02ms
- 空間計算量もO(N) 32 * 2000 = 64 * 10^3 = 64KB

# 2回目
```go
func mergeTrees(root1 *TreeNode, root2 *TreeNode) *TreeNode {
switch {
case root1 != nil && root2 != nil:
left := mergeTrees(root1.Left, root2.Left)
right := mergeTrees(root1.Right, root2.Right)
return &TreeNode{
Val: root1.Val + root2.Val,
Left: nextLeft,
Right: nextRight,
}
case root1 != nil && root2 == nil:
return root1
case root1 == nil && root2 != nil:
return root2
default:
return nil
}
}
```
- 先に他の人のコードを読む
- https://github.com/hiroki-horiguchi-dev/leetcode/pull/23
- 非破壊的なほうがいいという点には同意なんだよな.Goではどうしよう.
- https://github.com/jjysogfy/arai60-202603/pull/12
- step1の20,21行目が改行入っている理由とかよくわからんかった.もう一度読んだらこれ関数呼び出しか.わかりづらくて好みではない.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

読んでもらってありがとうございます。たしかに、その2行は一見すると独立した2文のようにも見えますね。
(Optionalをやめたほうが見やすいのはさておき)同じ方針で書き直すなら、var leftNode = mergeTrees(の直後でも改行するのがいいでしょうか。
自分のコードの話になってすみません。もしお時間あれば、お返事いただけるとうれしいです。

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.

コメントありがとうございます.
そうですね.これを読んだときに考えていたことは,以下のような感じです.

  • 変数宣言で行頭を変数と同じ位置に揃えていたので,二つ変数を宣言したのかなとパッとみ思いました.
  • で,よく読むと関数の引数の区切りで改行されていることに気づきました.
  • 関数呼び出しの引数ならば,関数名よりもすこし下げた位置にインデントされているといいな,と感じました

個人の思考メモなので,ぶっきらぼうな書き方になってしまってすみません.
Javaのお作法についても全く踏まえずに書いてしまってますので,適当言ってる部分ありましたらすみません.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

変数宣言で行頭を変数と同じ位置に揃えていたので,二つ変数を宣言したのかなと

関数名よりもすこし下げた位置にインデントされているといいな,と

ありがとうございます。たしかに、と思いました。JavaのGoogleスタイルガイド的にも、スペース4つより深くインデントすることは許されていそうです。

個人の思考メモなので,ぶっきらぼうな書き方になってしまってすみません.

承知していますので、お気になさらず。むしろ読んでコメントいただけるのはありがたいです。

- https://github.com/nicah4o/arai60/pull/22
- step1の35,36行目は`if (root1 && root2)` 内に入っているべきだなと感じた
- https://github.com/attractal/leetcode/pull/32
- 番兵はたしかにな.思いつかなかった.
- https://github.com/hiro111208/leetcode/pull/17
- pythonの三項演算子,rubyの `condition ? true-value : false-value` みたいな形式に慣れている身からすると読みにくかった
- https://github.com/h-masder/Arai60/pull/26
- Goで完全に非破壊的にする(inputとアドレスを共有しないようにする)のはどうしようかなと思った.
- https://github.com/rimokem/arai60/pull/23
- 自分もループverのDFSを書いてみないとな
- https://github.com/Hiroto-Iizuka/coding_practice/pull/23
- DFS(再帰)のコードを読んで,無駄なallocationをしていることに気づいた.
- BFSの実装,理解するのにすこし時間がかかったが,これはTreeが配列で実装されていることに依存しすぎてやしないかと少し気になった
- pythonのシグネチャがそうだからこれは考えすぎのようだ

# 2回目 非破壊
```go
func mergeTrees(root1 *TreeNode, root2 *TreeNode) *TreeNode {
switch {
case root1 != nil && root2 != nil:
return &TreeNode{
Val: root1.Val + root2.Val,
Left: mergeTrees(root1.Left, root2.Left),
Right: mergeTrees(root1.Right, root2.Right),
}
case root1 != nil:
return &TreeNode{
Val: root1.Val,
Left: mergeTrees(root1.Left, nil),
Right: mergeTrees(root1.Right, nil),
}
case root2 != nil:
return &TreeNode{
Val: root2.Val,
Left: mergeTrees(nil, root2.Left),
Right: mergeTrees(nil, root2.Right),
}
default:
return nil
}
}
```
- どうすんだ?と悩んでしまったのでLLMに聞いてみたら上記のコードを出力された
- こうすることで,全ノードが新規アロケーションされるようになり,非破壊になる
- 非破壊にすることで,片方のnodeがnilでも関数呼び出しが起きるし,アロケーションされるようになったので,時間計算量,空間計算量どちらもO(N)のままだが,定数倍の悪化が起きる
- なのでこれは結局は本番の使われ方による気がした.

# 2回目 非破壊/ループ
```go
func mergeTrees(root1 *TreeNode, root2 *TreeNode) *TreeNode {
dummy := &TreeNode{}

type task struct {
n1, n2 *TreeNode
parent *TreeNode
left bool
}

stack := []task{{root1, root2, dummy, true}}

for len(stack) > 0 {
t := stack[len(stack)-1]
stack = stack[:len(stack)-1]

if t.n1 == nil && t.n2 == nil {
continue
}

val := 0
var left1, right1, left2, right2 *TreeNode
if t.n1 != nil {
val += t.n1.Val
left1, right1 = t.n1.Left, t.n1.Right
}
if t.n2 != nil {
val += t.n2.Val
left2, right2 = t.n2.Left, t.n2.Right
}

newNode := &TreeNode{Val: val}
if t.left {
t.parent.Left = newNode
} else {
t.parent.Right = newNode
}

stack = append(stack, task{left1, left2, newNode, true})
stack = append(stack, task{right1, right2, newNode, false})
}

return dummy.Left
}
```
- これも一度自力で書いてみようとして,時間かかったので,LLMに聞いたもの.
- 自力でやろうとしたとき,あれ,parentとかどうすんだっけ?という点は番兵とparent, left or notという情報を加えることで解決されている.やっぱその情報必要だよな,と思ったのと,parent一番最初に自力で解こうとしたときの方針を完走できればこれっぽくなったんだろうなという気持ち
- 自分の理解でかきくだすとこんな感じ
- dummyを作る
- taskを定義して,tasksというstackを作る
- root1,root2を積む.dummyのLeftということにしておく
- tasksがある限りループする
- n1,n2両方がnilならskip
- val, left1, right1, left2, right2を宣言して情報を集める
- newNodeを宣言して,parentにつなげておく
- LeftとRightをtasksとして積む
- return dummy.Left


# 3回目
```go
# 非破壊・再帰
func mergeTrees(root1, root2 *TreeNode) *TreeNode {
switch {
case root1 != nil && root2 != nil:
return &TreeNode{
Val: root1.Val + root2.Val,
Left: mergeTrees(root1.Left, root2.Left),
Right: mergeTrees(root1.Right, root2.Right),
}
case root1 != nil && root2 == nil:
return &TreeNode{
Val: root1.Val,
Left: mergeTrees(root1.Left, nil),
Right: mergeTrees(root1.Right, nil),
}
case root1 == nil && root2 != nil:
return &TreeNode{
Val: root2.Val,
Left: mergeTrees(nil, root2.Left),
Right: mergeTrees(nil, root2.Right),
}
// case root1 == nil && root2 == nil:
default:
return nil
}
}

# 非破壊・ループ
func mergeTrees(root1, root2 *TreeNode) *TreeNode {
dummy := &TreeNode{}

type task struct {
pair [2]*TreeNode
parent *TreeNode
isLeft bool
}

tasks := []task{
{
pair: [2]*TreeNode{root1, root2},
parent: dummy,
isLeft: true,
},
}

for len(tasks) > 0 {
t := tasks[len(tasks)-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.

node1 = t.pair[0]node2 = t.pair[1]と名前を付ける方が好みです。今のコードはnode1, node2left1, left2, right1, right2との対応が見づらく感じたからです。

また、tの使われる範囲が一文字変数にしては長めで抵抗があります。ただ個人的にはそれほどには読みづらくは感じなかったです。

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.

node1, node2に関して,その通りだなと思いました.ありがとうございます.

t については,taskだと,定義名と被っちゃうしなと思って,tasksからとりだしたんだからまぁわかるだろう,くらいの判断で一文字にしてました.

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は型名も小文字なんですね。それはたしかにちょっと命名に迷いますね。)

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://go.dev/tour/basics/3
最初を大文字にすると、パッケージの外から見えるようになりますね。

tasks = tasks[:len(tasks)-1]

if t.pair[0] == nil && t.pair[1] == nil {
continue
}

val := 0
var left1, left2, right1, right2 *TreeNode

if t.pair[0] != nil {
val += t.pair[0].Val
left1, right1 = t.pair[0].Left, t.pair[0].Right
}
if t.pair[1] != nil {
val += t.pair[1].Val
left2, right2 = t.pair[1].Left, t.pair[1].Right
}

newNode := &TreeNode{Val: val}
if t.isLeft {
t.parent.Left = newNode
} else {
t.parent.Right = newNode
}

tasks = append(tasks, task{pair: [2]*TreeNode{left1, left2}, parent: newNode, isLeft: true})
tasks = append(tasks, task{pair: [2]*TreeNode{right1, right2}, parent: newNode, isLeft: false})
}

return dummy.Left
}
```
- それぞれ3回ずつ.再帰のほうがしっくりくる
- かき終わってから典型コメントを読んだ.
- https://discord.com/channels/1084280443945353267/1262688866326941718/1297934906189549599
- これいいな.だいぶ短く.すっきりした形でループが書ける.
- parentとisLeftの二つを渡すのではなく,参照の参照を渡すことで分岐せずに書ける.
- ん〜でもちょっと頭が追いつかない感じがある.
- LLM解説させたらわかった気になった
- &と*をごっちゃにしていた感じがある.個人的にnilポインタを `&(nilポインタ)` という操作ができないような気がしていたが,できないのは `*(nilポインタ)` のほうだった
- https://go.dev/play/p/_wJJOw01eSY