241 lines
10 KiB
YAML
241 lines
10 KiB
YAML
title: Minimum Height Trees
|
|
slug: minimum-height-trees
|
|
difficulty: medium
|
|
leetcode_id: 310
|
|
leetcode_url: https://leetcode.com/problems/minimum-height-trees/
|
|
categories:
|
|
- graphs
|
|
- trees
|
|
patterns:
|
|
- bfs
|
|
- tree-traversal
|
|
|
|
function_signature: "def find_min_height_trees(n: int, edges: list[list[int]]) -> list[int]:"
|
|
|
|
test_cases:
|
|
visible:
|
|
- input: { n: 4, edges: [[1, 0], [1, 2], [1, 3]] }
|
|
expected: [1]
|
|
- input: { n: 6, edges: [[3, 0], [3, 1], [3, 2], [3, 4], [5, 4]] }
|
|
expected: [3, 4]
|
|
hidden:
|
|
- input: { n: 1, edges: [] }
|
|
expected: [0]
|
|
- input: { n: 2, edges: [[0, 1]] }
|
|
expected: [0, 1]
|
|
- input: { n: 3, edges: [[0, 1], [1, 2]] }
|
|
expected: [1]
|
|
- input: { n: 7, edges: [[0, 1], [1, 2], [2, 3], [3, 4], [4, 5], [5, 6]] }
|
|
expected: [3]
|
|
- input: { n: 8, edges: [[0, 1], [1, 2], [2, 3], [3, 4], [4, 5], [5, 6], [6, 7]] }
|
|
expected: [3, 4]
|
|
|
|
description: |
|
|
A tree is an undirected graph in which any two vertices are connected by *exactly* one path. In other words, any connected graph without simple cycles is a tree.
|
|
|
|
Given a tree of `n` nodes labelled from `0` to `n - 1`, and an array of `n - 1` `edges` where `edges[i] = [a_i, b_i]` indicates that there is an undirected edge between the two nodes `a_i` and `b_i` in the tree, you can choose any node of the tree as the root. When you select a node `x` as the root, the result tree has height `h`. Among all possible rooted trees, those with minimum height (i.e. `min(h)`) are called **minimum height trees** (MHTs).
|
|
|
|
Return *a list of all MHTs' root labels*. You can return the answer in **any order**.
|
|
|
|
The **height** of a rooted tree is the number of edges on the longest downward path between the root and a leaf.
|
|
|
|
constraints: |
|
|
- `1 <= n <= 2 * 10^4`
|
|
- `edges.length == n - 1`
|
|
- `0 <= a_i, b_i < n`
|
|
- `a_i != b_i`
|
|
- All the pairs `(a_i, b_i)` are distinct
|
|
- The given input is **guaranteed** to be a tree and there will be **no repeated** edges
|
|
|
|
examples:
|
|
- input: "n = 4, edges = [[1,0],[1,2],[1,3]]"
|
|
output: "[1]"
|
|
explanation: "The height of the tree is 1 when the root is node 1, which is the only MHT. If we root at any leaf (0, 2, or 3), the height would be 2."
|
|
- input: "n = 6, edges = [[3,0],[3,1],[3,2],[3,4],[5,4]]"
|
|
output: "[3,4]"
|
|
explanation: "Both nodes 3 and 4 produce trees of minimum height 2. Rooting at any other node would produce a taller tree."
|
|
|
|
explanation:
|
|
intuition: |
|
|
Imagine a tree as a physical structure where you're trying to find the **centre of gravity** — the node(s) that minimise the maximum distance to any edge of the tree.
|
|
|
|
Here's the key insight: **leaves can never be the root of a minimum height tree** (unless the tree has only 1 or 2 nodes). Why? A leaf sits at the edge of the tree, so rooting there guarantees a long path to the opposite side.
|
|
|
|
Think of it like peeling an onion from the outside in. If we repeatedly remove all the current leaves (nodes with only one connection), we'll gradually work our way toward the centre. The last remaining node(s) — either 1 or 2 — will be the roots that minimise the tree height.
|
|
|
|
Why at most 2 nodes? In a tree, the "centre" lies on the longest path (the diameter). If the diameter has an odd number of edges, there's exactly one centre node. If even, there are two adjacent centre nodes.
|
|
|
|
approach: |
|
|
We solve this using **Topological Sort (Leaf Trimming)**:
|
|
|
|
**Step 1: Handle edge cases**
|
|
|
|
- If `n == 1`, return `[0]` — a single node is its own MHT
|
|
- If `n == 2`, return `[0, 1]` — both nodes are valid MHT roots
|
|
|
|
|
|
|
|
**Step 2: Build the adjacency list and track degrees**
|
|
|
|
- Create an adjacency list representing the undirected graph
|
|
- Track the degree (number of connections) of each node
|
|
- Identify initial leaves: nodes with degree 1
|
|
|
|
|
|
|
|
**Step 3: Iteratively trim leaves**
|
|
|
|
- Add all current leaves to a queue
|
|
- While more than 2 nodes remain:
|
|
- Remove all current leaves from the tree
|
|
- For each removed leaf, decrement the degree of its neighbour
|
|
- If a neighbour's degree becomes 1, it's a new leaf — add it to the next round
|
|
- Repeat until 1 or 2 nodes remain
|
|
|
|
|
|
|
|
**Step 4: Return the remaining nodes**
|
|
|
|
- The last batch of "leaves" are the MHT roots
|
|
- These are the centre node(s) of the tree
|
|
|
|
common_pitfalls:
|
|
- title: Brute Force TLE
|
|
description: |
|
|
A naive approach tries rooting the tree at every node and computing heights via BFS/DFS:
|
|
- For each of `n` nodes as root: O(n)
|
|
- Compute tree height: O(n)
|
|
|
|
This gives **O(n²) time complexity**. With `n <= 2 * 10^4`, this results in up to 400 million operations — likely causing TLE.
|
|
|
|
The topological sort approach runs in O(n) time by exploiting the tree structure.
|
|
wrong_approach: "BFS/DFS from every node to find minimum height"
|
|
correct_approach: "Trim leaves iteratively to find the centre"
|
|
|
|
- title: Forgetting the Single/Double Node Cases
|
|
description: |
|
|
When `n == 1`, return `[0]`. When `n == 2`, return `[0, 1]`.
|
|
|
|
These edge cases break the general algorithm since there are no "leaves" to trim in the traditional sense, or the leaves ARE the answer.
|
|
wrong_approach: "Assume n >= 3 and skip edge cases"
|
|
correct_approach: "Handle n == 1 and n == 2 explicitly"
|
|
|
|
- title: Using Directed Graph Logic
|
|
description: |
|
|
Trees are **undirected** graphs. When trimming a leaf, you must update the degree of its neighbour by removing the leaf from the neighbour's adjacency set.
|
|
|
|
If you only track one direction, you'll incorrectly identify which nodes become leaves after removal.
|
|
wrong_approach: "Track edges in only one direction"
|
|
correct_approach: "Maintain bidirectional adjacency and update both sides"
|
|
|
|
key_takeaways:
|
|
- "**Tree centrality**: The MHT roots are the 'centre' of the tree — the node(s) that minimise maximum distance to any leaf"
|
|
- "**Leaf trimming pattern**: Repeatedly removing leaves reveals the structural centre — useful for finding tree diameters and centres"
|
|
- "**At most 2 centres**: A tree always has 1 or 2 centre nodes, lying on its diameter"
|
|
- "**O(n) efficiency**: By processing each node and edge exactly once, we avoid the quadratic cost of brute-force height calculation"
|
|
|
|
time_complexity: "O(n). Each node and edge is processed exactly once during the leaf trimming process."
|
|
space_complexity: "O(n). We store the adjacency list and degree array, each with at most `n` entries."
|
|
|
|
solutions:
|
|
- approach_name: Topological Sort (Leaf Trimming)
|
|
is_optimal: true
|
|
code: |
|
|
from collections import deque
|
|
|
|
def find_min_height_trees(n: int, edges: list[list[int]]) -> list[int]:
|
|
# Edge cases: single node or two nodes
|
|
if n == 1:
|
|
return [0]
|
|
if n == 2:
|
|
return [0, 1]
|
|
|
|
# Build adjacency list using sets for O(1) removal
|
|
adj = [set() for _ in range(n)]
|
|
for a, b in edges:
|
|
adj[a].add(b)
|
|
adj[b].add(a)
|
|
|
|
# Find initial leaves (nodes with only one connection)
|
|
leaves = deque()
|
|
for i in range(n):
|
|
if len(adj[i]) == 1:
|
|
leaves.append(i)
|
|
|
|
# Trim leaves until 1 or 2 nodes remain
|
|
remaining = n
|
|
while remaining > 2:
|
|
# Process all current leaves
|
|
leaf_count = len(leaves)
|
|
remaining -= leaf_count
|
|
|
|
for _ in range(leaf_count):
|
|
leaf = leaves.popleft()
|
|
# Get the single neighbour of this leaf
|
|
neighbour = adj[leaf].pop()
|
|
# Remove leaf from neighbour's adjacency
|
|
adj[neighbour].remove(leaf)
|
|
|
|
# If neighbour is now a leaf, add to next round
|
|
if len(adj[neighbour]) == 1:
|
|
leaves.append(neighbour)
|
|
|
|
# Remaining nodes are the MHT roots
|
|
return list(leaves)
|
|
explanation: |
|
|
**Time Complexity:** O(n) — We process each node once when it becomes a leaf and each edge once when removing connections.
|
|
|
|
**Space Complexity:** O(n) — The adjacency list stores each edge twice (bidirectional), giving O(n) edges for a tree with n nodes.
|
|
|
|
The algorithm peels away layers of leaves like an onion. After each round, nodes that were previously internal may become leaves. When only 1 or 2 nodes remain, they're the MHT roots — the structural centre of the tree.
|
|
|
|
- approach_name: BFS from Every Node (Brute Force)
|
|
is_optimal: false
|
|
code: |
|
|
from collections import deque
|
|
|
|
def find_min_height_trees(n: int, edges: list[list[int]]) -> list[int]:
|
|
if n == 1:
|
|
return [0]
|
|
|
|
# Build adjacency list
|
|
adj = [[] for _ in range(n)]
|
|
for a, b in edges:
|
|
adj[a].append(b)
|
|
adj[b].append(a)
|
|
|
|
def get_height(root: int) -> int:
|
|
"""BFS to find tree height when rooted at given node."""
|
|
visited = [False] * n
|
|
queue = deque([(root, 0)])
|
|
visited[root] = True
|
|
max_depth = 0
|
|
|
|
while queue:
|
|
node, depth = queue.popleft()
|
|
max_depth = max(max_depth, depth)
|
|
for neighbour in adj[node]:
|
|
if not visited[neighbour]:
|
|
visited[neighbour] = True
|
|
queue.append((neighbour, depth + 1))
|
|
|
|
return max_depth
|
|
|
|
# Try every node as root and track minimum height
|
|
min_height = float('inf')
|
|
heights = []
|
|
|
|
for node in range(n):
|
|
h = get_height(node)
|
|
heights.append(h)
|
|
min_height = min(min_height, h)
|
|
|
|
# Return all nodes that achieve minimum height
|
|
return [i for i, h in enumerate(heights) if h == min_height]
|
|
explanation: |
|
|
**Time Complexity:** O(n²) — For each of n nodes, we run BFS which takes O(n) time.
|
|
|
|
**Space Complexity:** O(n) — Adjacency list and visited array.
|
|
|
|
This brute force approach tries every node as a potential root and computes the tree height using BFS. While correct, it's too slow for large inputs. Included to illustrate why the leaf-trimming approach is necessary.
|