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
63 changes: 63 additions & 0 deletions leetcode/310/memo.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# Step 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.

自力で解けませんでした。
C++ で 4 * 10^8 なら TLE にならないだろうと高を括ったところ、 TLE になりました。解法が思いつかず、下記の解法を読んで解きました。難しかったです。


素直に全ての root を試す方法しか思いつかなかった -> `step1_tle.py`。

後から思ったが、cycle がないことが保証されているので、一つ前に戻るのだけスキップする形の方がシンプルだった。

```py
for adj in node_to_adjacents[node]:
if adj == parent:
continue
child_height = max(child_height, 1 + height(adj, node))
return child_height
```

制約が

> `1 <= n <= 2 * 10^4`

で、全ての n から探索を行うと `(2 * 10^4) * (2 * 10^4) = 4 * 10^8`。
C++ が 1 秒間に `10^8 ~ 10^9` ステップの処理を行えるとすると、Pythonがそれより大体 100 倍遅いから 1 秒間で `10^6 ~ 10^7` ステップ。
なので、大体 `10 ~ 100` 秒くらいの実行時間になりそう。これは LeetCode 上でギリギリ Time Limit Exceeded になるかならないかくらいなので、微妙。案の定、TLEになった。

そのまま実装するとTLEになりそうなのはわかっていたので、何かしらのヒューリスティックが思いつかないか、考えてみて、一つ思い浮かんだのは、枝が一番多いノードを root として選ぶと木の高さが最小になるのでは、と思ったが、幾つか例を試してみると、うまくいかないことがわかった。

```
5
|
0 - 1 - 2 - 3 - 4
|
6
```

上の例だと、2 が本当の最小の高さを作る root。

LeetCode 上の Hint を考えてみる。

> How many MHTs can a graph have at most?
Copy link
Copy Markdown

@sasanquaneuf sasanquaneuf Mar 30, 2026

Choose a reason for hiding this comment

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

私はこのヒントの意図が全然わからなくて、相当強引に、一度てきとうなLeafをRootにして高さを数えさせてから、一つずつRootにするNodeをずらしていくとどうなるか、という方法で最初計算させました。(結果、そこまで遅くはないものの、60行ぐらいのとんでもないコードができてしまった...)
この方法でできるだろうという確信を得たのはこのヒントも関係なくはないのですが、それにしても、ヒントの出し方が中々難しいと思いました。


幾つか例を考えてみると、雑に考えてグラフの端から端までいく最長のパスかあって、それの真ん中のノードが 1 つか 2 つあり、それが Minimum Height Tree を形成しそう?
結構時間を使ってしまったので、悔しいが、解法を見てしまうことにする。

Discord 上で取り組まれた方はいなさそうなので、LeetCode の Solutions を眺めてみると、これらの記事が理解しやすそう

[dietpepsi - Share some thoughts](https://leetcode.com/problems/minimum-height-trees/solutions/76055/share-some-thoughts-by-dietpepsi-mjsc/)
[lc\_1000xCoder - [ Full Explanation ] BFS - Remove Leaf Nodes](https://leetcode.com/problems/minimum-height-trees/solutions/5060930/full-explanation-bfs-remove-leaf-nodes-b-4x00/)

まず、Minimum Height Tree を作る root は 1 つか 2 つという私の考えは正しかった。それをどう求めるのかだが、各ステップで現在の葉ノードを全て削除していって、最後に残った 1 個か 2 個のノードがグラフの一番長いパスの真ん中 -> Minimum Height Tree の root(s) になる。
Copy link
Copy Markdown

@sasanquaneuf sasanquaneuf Mar 30, 2026

Choose a reason for hiding this comment

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

この1つか2つになる、というのを直接証明するのは少しむずかしいかもと思ったりしたのですが、Treeから全てのLeafを取り去る操作を繰り返し行う場合を考えるとき、仮に3つ以上Nodeがある場合、いずれかのNodeは必ず2本以上のEdgeを持つことになり(そうでないとTree全体がつながることがない)、Leafを取り去る操作を繰り返すと必ず1つか2のNodeが残るのですね。このLeafを一式取り去る行為の回数が、heightになるわけですね。
(ただ、このやり方だと、やはり必ずしも1つか2つであることは使わなくてもよいですね。最後にhightが同じになったものを取ればよいので...)

Copy link
Copy Markdown

@sasanquaneuf sasanquaneuf Mar 31, 2026

Choose a reason for hiding this comment

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

そうか、そうすると連結成分の個数が2以上の場合はどうしますか?という追加の問いかけはできますね。

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

このやり方はエレガントですが、もう少し愚直には、無向木の直径を求めて、その経路の中間と考えると素直ではないですか。

木の上でできるだけ長い経路(直径)を求めるのはできますね。ちょっとトリッキーな方法として、DFS 2回で行うというのがあります。適当な点から最も遠い点を計算し、そこから最も遠い点を計算すると、直径の両端になります。両端が求まったらその最短経路を計算して、中間を取ります。

Copy link
Copy Markdown

@sasanquaneuf sasanquaneuf Mar 31, 2026

Choose a reason for hiding this comment

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

たしかにそうですね。半分になる中間点を見つける、というのがヒントで言いたいことだったのかもしれないと思いました。

これはお遊びですが、Geminiに3つの方法で計算する様子を可視化してもらいました。

  • エレガントな方法
  • 2回探索してその中間
  • 1回探索して、高さが逆転するまで最も高いnodeに移動し続ける(私の初手の解法)

https://sasanquaneuf.github.io/math-toybox/apps/cs_mht.html
BFSになっているのはご愛嬌ですね。(DFSと指示をしたのですが)


やり方だけ頭において、LeetCode 207\. Course Schedule を思い出しながら書いた `step1.py`。

時間計算量: `O(n)`、隣接リストの構築 `O(n)` + 各ノードは高々一回だけ葉として処理される `O(n)` + 各ノードの隣接リストは高々1回しか走査されないため (`adjacents[leaf]`) 合計で `O(n)`
空間計算量: `O(n)`、neighbors が合計で `2(n - 1)`、degrees/leaves/next leaves も `O(n)`。

# Step 2

上のポストを見返すと、`neighbors: list[set[int]]` を持ってそれを 隣接ノードの取得 + 長さによる枝の本数チェック、の両方に使用すれば、`neighbors` と `degrees` を別々に持たなくても済んでいる。
時間・空間計算量が変わることはなく、隣接ノードと持っている枝の本数を一つの変数にまとめることが読みやすさにどれだけ影響があるのか判断がつかないが、とりあえず書いてみることにする -> `step2.py`。

# Step 4

[Stack Overflow - Proof of correctness: Algorithm for diameter of a tree in graph theory](https://beta.stackoverflow.com/questions/20010472/proof-of-correctness-algorithm-for-diameter-of-a-tree-in-graph-theory)

Performing BFS (or DFS) twice to find the farthest node in a tree yields the diameter.
34 changes: 34 additions & 0 deletions leetcode/310/step1.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
class Solution:
def findMinHeightTrees(self, n: int, edges: list[list[int]]) -> list[int]:
if n == 1:
return [0]

neighbors = [[] for _ in range(n)]
degrees = [0] * n

for node1, node2 in edges:
neighbors[node1].append(node2)
neighbors[node2].append(node1)
degrees[node1] += 1
degrees[node2] += 1

leaves = [node for node in range(n) if degrees[node] == 1]
remaining = n
while remaining > 2:
next_leaves = []
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

取り去る解法でコードをかくとき、私はここでnext_leavesを作らずに、

while leaves:
    leaf, height = leaves.pop()
    ...
            leaves.append((neighbor, height + 1))

みたいな感じのコードを一度書いて失敗しました。(0-1-2-3-4みたいな場合に、4,3,2,1,0の順に取り去ってしまってダメ)

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.

引っかかった点も共有いただきありがとうございます。他の方々の感覚に触れ、また自分自身で考えるきっかけになるので助かります。

for leaf in leaves:
degrees[leaf] = 0
remaining -= 1

# Only one neighbor is active at this point since `leaf` is a leaf
for neighbor in neighbors[leaf]:
if degrees[neighbor] == 0:
continue

degrees[neighbor] -= 1
if degrees[neighbor] == 1:
next_leaves.append(neighbor)

leaves = next_leaves

return leaves
32 changes: 32 additions & 0 deletions leetcode/310/step1_tle.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import collections


class Solution:
def findMinHeightTrees(self, n: int, edges: list[list[int]]) -> list[int]:
node_to_adjacents = collections.defaultdict(list)
for node1, node2 in edges:
node_to_adjacents[node1].append(node2)
node_to_adjacents[node2].append(node1)

def traverse(node: int, visited: list[bool]) -> int:
if visited[node]:
return 0

visited[node] = True
child_height = 0
for adj in node_to_adjacents[node]:
child_height = max(child_height, traverse(adj, visited))
visited[node] = False
return 1 + child_height

visited = [False] * n
root_to_height = {}
for root in range(n):
root_to_height[root] = traverse(root, visited)

min_height = min(root_to_height.values())
mht_roots: list[int] = []
for root, height in root_to_height.items():
if height == min_height:
mht_roots.append(root)
return mht_roots
24 changes: 24 additions & 0 deletions leetcode/310/step2.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
class Solution:
def findMinHeightTrees(self, n: int, edges: list[list[int]]) -> list[int]:
if n == 1:
return [0]

neighbors = [set() for _ in range(n)]
for node1, node2 in edges:
neighbors[node1].add(node2)
neighbors[node2].add(node1)

remaining = n
leaves = [node for node in range(n) if len(neighbors[node]) == 1]
while remaining > 2:
next_leaves = []
for leaf in leaves:
neighbor = neighbors[leaf].pop() # only one should be left
neighbors[neighbor].remove(leaf)
if len(neighbors[neighbor]) == 1:
next_leaves.append(neighbor)
remaining -= 1

leaves = next_leaves

return leaves
25 changes: 25 additions & 0 deletions leetcode/310/step3.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
class Solution:
def findMinHeightTrees(self, n: int, edges: list[list[int]]) -> list[int]:
if n == 1:
return [0]

neighbors = [set() for _ in range(n)]
for node1, node2 in edges:
neighbors[node1].add(node2)
neighbors[node2].add(node1)

remaining = n
leaves = [node for node in range(n) if len(neighbors[node]) == 1]
while remaining > 2:
next_leaves = []

for leaf in leaves:
neighbor = neighbors[leaf].pop()
neighbors[neighbor].remove(leaf)
if len(neighbors[neighbor]) == 1:
next_leaves.append(neighbor)
remaining -= 1

leaves = next_leaves

return leaves
71 changes: 71 additions & 0 deletions leetcode/310/step4_dfs_twice_and_get_middle.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
"""
First attempt using two DFS passes to find the longest path (diameter) and return its middle node(s).
I haven't carefully reviewed or refactored yet.
TODO:
- Revisit review comments
- Improve/refactor this solution.
"""


class Solution:
def findMinHeightTrees(self, n: int, edges: list[list[int]]) -> list[int]:
neighbors = [[] for _ in range(n)]
for node1, node2 in edges:
neighbors[node1].append(node2)
neighbors[node2].append(node1)

def find_farthest(
node: int, visited: list[bool]
) -> tuple[int, int]: # (farthest node, path length)
path_length = 1
farthest = node
visited[node] = True
for neighbor in neighbors[node]:
if visited[neighbor]:
continue

farthest_from_neighbor, path_length_from_neighbor = find_farthest(
neighbor, visited
)

if path_length_from_neighbor + 1 > path_length:
path_length = path_length_from_neighbor + 1
farthest = farthest_from_neighbor

visited[node] = False
return farthest, path_length

longest_path_start, _ = find_farthest(0, [False] * n)
longest_path_end, longest_path_length = find_farthest(
longest_path_start, [False] * n
)

longest_path = []

def traverse(node: int, end: int, visited: list[bool], path: list[int]) -> None:
nonlocal longest_path

path.append(node)
if node == end:
longest_path = path.copy()
path.pop()
return

visited[node] = True

for neighbor in neighbors[node]:
if visited[neighbor]:
continue
traverse(neighbor, end, visited, path)

path.pop()
visited[node] = False

traverse(longest_path_start, longest_path_end, [False] * n, [])

if longest_path_length % 2 == 1:
return [longest_path[longest_path_length // 2]]
else:
return longest_path[
longest_path_length // 2 - 1 : longest_path_length // 2 + 1
]