Skip to content

Conversation

ryosuketc
Copy link
Owner

* https://github.com/nittoco/leetcode/pull/30/files#r1693985989
* これでシンプルに書ける
* https://github.com/nittoco/leetcode/pull/30/files#r1693945861
* 仕事を押し付ける、押し付けないの議論がよく理解できていないのでまた読む。
Copy link

Choose a reason for hiding this comment

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

C++ だと分かりやすいんですが

string f() {
    // 色々な操作
    return result;
}

void f(string& return_value) {
    // 色々な操作
    return_value = result;
}

と書き換えたとしましょう。
a = f(); を f(a); とすることができます。

この変形は、状況によっては、再帰を末尾再帰の形に変形できる可能性があります。
さて、ループに直しやすい再帰と直りにくい再帰の違いはなんでしょうか。

Copy link
Owner Author

Choose a reason for hiding this comment

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

前者は関数の引数としてそのまま返していて、後者は、return_value という参照?ポインタ? (すみません、C++ を書かないので違いが怪しいです…GitHub のどこかで議論を見たかも) に、result を書き込んでいますよね。つまり Python でいうと引数として mutable なコンテナなんかを渡して、そこに書き込ませるということですよね。

とここまでは理解しましたが、ここに対する答えはよくわからなかったので、Gemini に手伝ってもらいつつ調べてみました (内容は記録として続くコメントに載せます)。

この変形は、状況によっては、再帰を末尾再帰の形に変形できる可能性があります。
さて、ループに直しやすい再帰と直りにくい再帰の違いはなんでしょうか。

結局調べたことも踏まえて改めて質問と元のコード (https://github.com/nittoco/leetcode/pull/30/files#r1693945861) に戻ると

  1. 「部下に仕事を押し付けないstack」= 参照渡し的なものを使う方は、結局帰りがけにも上司が仕事をしなければならない = 仕事がまだ残っている、ので末尾再帰にしにくく
  2. 一方で「部下に仕事を押し付けるstack」は、関数を呼んだ時点でもう仕事が残っていないので末尾再帰しやすい

と理解しました。

ただここと、例示いただいたコード、特に「a = f(); を f(a); とすることができます。この変形は…」のところが繋がりません。
a = f(); を f(a); に変形することによって (つまり参照渡しでない方法から参照渡しに返ることによって) 末尾再帰に変形できる可能性がある、ということですが、上記 1, 2 を踏まえると、末尾再帰しやすいのは参照渡しを使わない「部下に仕事を押し付けるstack」ほう、つまり a = f() の方が末尾再帰にしやすいということではないかと思いました。

ちょっと混乱していると思います。

Copy link
Owner Author

Choose a reason for hiding this comment

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

上のコメントをするために使った情報を記録しておきます。


調査

元のコードの読み直し

ここの実装を読み直してみました。要は、go_or_back で再帰の行きがけと帰りがけを管理していて、まず行きがけでは stack に積む処理のみ行っておき、帰りで実際に left / right child と繋いでいるんですね (上司が自分でつなぐ)。

https://github.com/nittoco/leetcode/pull/30/files#r1693945861

元のコードの解説 (by Gemini)

なぜ「実用性があまりない」のか?

  1. 処理が非常に複雑
    このコードは、再帰を使わずに後順(post-order)探索をスタックで模倣しています。

"go" と "back" というフラグを使って、木を「下っていく処理」と「上がってくる処理」を1つのスタックで管理しています。

まずスタックに ("go", ...) を積んでおき、これを取り出したら、今度は逆の処理をするために ("back", ...) を積み直します。その間に子ノードの処理をスタックに追加します。

これにより、子ノードの処理がすべて完了してから、親ノードの結合処理(merged_current[0] = ...)が行われる、という再帰と同じ流れを実現しています。

このロジックはアルゴリズムの学習としては面白いですが、コードが長くなり、一見して何をしているのか理解するのが困難です。

  1. C言語的なポインタの模倣
    このコードの最も「Pythonらしくない」部分が、merged_current や merged_left に [None] という1要素のリストを使っている点です。
# C言語などではポインタを渡して、関数内でその指す先を書き換える
# change_value(&my_pointer);

# このコードはそれをリストで模倣している
merged_current[0] = TreeNode(node1.val + node2.val)

これは、C言語などでポインタ(アドレス)を渡して、関数の中でそのポインタが指す先の値を書き換える、というテクニックを模倣したものです。Pythonでは、このように参照を渡して中身を書き換えるために、リストのようなミュータブル(変更可能)なオブジェクトが使われることがあります。

しかし、これはPythonでは一般的ではなく、トリッキーな書き方と見なされます。もっとシンプルでPythonらしい書き方があります。

末尾最適についての解説 (by Gemini)

A. 変換しやすい再帰 = 末尾再帰 (Tail Recursion)
特徴: 再帰呼び出しが、関数の最後の処理になっているものです。再帰呼び出しの後に、何も計算や操作が残っていません。

例:

def factorial_tail(n, accumulator):
    if n == 0:
        return accumulator
    # 再帰呼び出しが最後の処理
    return factorial_tail(n - 1, n * accumulator)

なぜ変換しやすいか: 戻ってきた後にやるべきことが何もないので、関数の状態を保存しておく必要がありません。単純なループ(goto文のようなジャンプ)に置き換えることができます。あなたの2つ目の実装**「部下に仕事を押し付けるstack」**は、この考え方に近いです。親ノードの処理を終えたら、子ノードの処理をスタックに積むだけで、親ノードはもう何もする必要がありません。

B. 変換しにくい再帰 = 末尾再帰でない再帰
特徴: 再帰呼び出しの後にも、何らかの処理が残っているものです。

例:

def factorial_normal(n):
    if n == 0:
        return 1
    # 再帰呼び出しの結果を使って、さらに計算(n *)を行っている
    return n * factorial_normal(n - 1)

なぜ変換しにくいか: 再帰から戻ってきた後に n * ... という計算をする必要があります。ループに変換するには、この「後でやるべき処理」の内容をどこかに保存しておかなければなりません。その保存場所として、通常は明示的なスタックが必要になります。

Copy link

Choose a reason for hiding this comment

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

「部下に仕事を押し付けないstack」= 参照渡し的なものを使う方

逆です。

Copy link
Owner Author

Choose a reason for hiding this comment

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

あれ、https://github.com/nittoco/leetcode/pull/30/files#r1693945861 を改めて読んでいたんですが、「部下に仕事を押し付けないstack」の方ではこんな感じで [None] のようなのを使って、参照渡し的なことをしていると読んだのですが…。

nodes_stack = [(root1, root2, merged_root, [None], [None], "go")] 

もうしばらく考えてみます…

Copy link

Choose a reason for hiding this comment

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

再帰の呼び出し元がする返ってきた値の代入を呼び出し先で行うことを、部下に仕事を押し付けるといっているだけなので、あまり言葉尻にとらわれずに考えてください。

return merge_trees_helper(root1, root2)


# ベタ書き。これはひどい

Choose a reason for hiding this comment

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

私も最初似たようなコードを書いてました。
https://github.com/tokuhirat/LeetCode/pull/23/files
この処理でも書き方を工夫すればわかりやすくなる例が過去にあったので共有いたします。以下リンクの617の箇所です。
https://discord.com/channels/1084280443945353267/1192736784354918470/1192805202684805120

Copy link
Owner Author

Choose a reason for hiding this comment

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

これはいいですね。全て先に 0 や None で初期化してしまうんですね。
https://discord.com/channels/1084280443945353267/1192736784354918470/1192805202684805120

  1. なんか謎に冗長な感じを受けます。mergeTrees 直接読んで再帰できませんか。
    def mergeTrees(self, root1: Optional[TreeNode], root2: Optional[TreeNode]) -> Optional[TreeNode]:
        if not (root1 or root2):
            return None
        merged = TreeNode()
        merged.val = 0
        root1_left = None
        root1_right = None
        root2_left = None
        root2_right = None
        if root1:
            merged.val += root1.val
            root1_left = root1.left
            root1_right = root1.right
        if root2:
            merged.val += root2.val
            root2_left = root2.left
            root2_right = root2.right
        merged.left = self.mergeTrees(root1_left, root2_left)
        merged.right = self.mergeTrees(root1_right, root2_right)
        return merged

くらいでいきません? (確認していない。)

new_root = TreeNode(root1.val + root2.val)
new_root.left = self.mergeTrees(root1.left, root2.left)
new_root.right = self.mergeTrees(root1.right, root2.right)
return new_root
Copy link

Choose a reason for hiding this comment

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

読みやすいです。
ただユースケースによると思いますが、元のツリーのnodeの一部が再利用されるというのはもしかすると混乱するかもしれないと思いました。

Choose a reason for hiding this comment

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

Copy link
Owner Author

Choose a reason for hiding this comment

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

最初よくわかっていませんでしたが、reference いただいたコメントで理解しました。ありがとうございます!

入力を破壊はしていないが、入力と同じ参照をもつオブジェクトを作るので、将来的に意図せず入力の値が変わってしまうことがある、ということですね。

### step1

* 最初 iterative DFS (`nodes_pairs = [(root1, root2)]` のような stack) で書こうとしたが、結局、今の node だけに注目していると、どの node の child なのか管理しないといけない気がして、再帰に方針変更
* limit が 2000 なので、まあ recursionlimit を変更するかは環境次第か

Choose a reason for hiding this comment

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

再帰で書くときは、再帰の深さについて忘れないようにしたいです。

* なるほど既存の木を編集していく想定だった?
* ああそうか片方が None のときは merge する必要がなく、もう片方を返せばいいだけか。
* そうすると merge するのは、いずれも None でないケースに限定されるので書きやすい
* ここなんとなく書きたくはなるが確かに `if root1 is None` でカバーされているから不要ではある

Choose a reason for hiding this comment

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

条件式を明確にできるので、ある方がわかりやすいと思いました。

# self.right = right
class Solution:
def mergeTrees(self, root1: Optional[TreeNode], root2: Optional[TreeNode]) -> Optional[TreeNode]:
# if root1 is None or root2 is None:

Choose a reason for hiding this comment

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

ここをコメントにするのいいですね。

@ryosuketc ryosuketc added the comments reviewed Reviewe comments are examined. No major re-review of the problem is needed. label Jul 17, 2025
@ryosuketc ryosuketc merged commit c622808 into main Jul 30, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comments reviewed Reviewe comments are examined. No major re-review of the problem is needed.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants