Tree diameter: return one branch, combine two locally
Constructed practice problem; no company attribution. Prerequisites: tree levels and postorder return state.
Candidate brief
A binary network tree needs the maximum number of edges on a simple path between any two nodes. The path may cross the root or stay entirely inside a subtree. What information can one child return that lets its parent evaluate paths crossing the parent?
Write this:
@dataclass(eq=False)
class Node:
value: object
left: "Node | None" = None
right: "Node | None" = None
def tree_diameter(root):
...
| Contract | Decision |
|---|---|
| Input | Binary Node tree root or None; values opaque and irrelevant to distance |
| Output | Diameter measured in edges, as a nonnegative integer |
| Boundaries | Empty/singleton give 0; endpoints may be any nodes; no mutation |
| Invalid input | Malformed links, cycles, or shared-child DAGs raise ValueError |
| Excluded | Edge weights, path reconstruction, and directed reachability semantics |
Optional refresher · the underlying tool
A subtree can return one best downward branch to its parent, while combining two child branches locally to update the diameter:
height = 1 + max(left_height, right_height)
through_here = left_height + right_height
State the unit: this problem measures diameter in edges, so a single-node tree has diameter 0. Draw why the longest path need not pass through the root.
A design choice worth saying aloud
Separate the two quantities: height_from_node is one downward branch that a parent can extend, while best_diameter may join two branches at this node. Specify whether heights count nodes or edges before adding them; for the stated edge contract, a single-node tree returns 0.
What the interviewer expects
The interviewer gives you the scenario and the contract above. Explain what a successful call returns, walk through one example below, and name what your state means before choosing a data structure.
Done means: Diameter measured in edges, as a nonnegative integer.
Now predict each output before looking at the reference; invalid input should leave any existing state unchanged unless the contract says otherwise.
Test-case scenarios to settle before coding
01 · Representative
- Input / starting state
- a with b,c; b with d,e
- Expected result
3edges
What it is testing: The best path can combine two child heights.
02 · Empty
- Input / starting state
None- Expected result
0
What it is testing: No path has zero edges.
03 · Singleton
- Input / starting state
- one node
- Expected result
0
What it is testing: Diameter counts edges, not nodes.
04 · Chain
- Input / starting state
- four nodes in one line
- Expected result
3
What it is testing: The deepest subtree can contain the answer.
05 · Off-root maximum
- Input / starting state
- long path entirely within one subtree
- Expected result
- that subtree distance
What it is testing: Do not require the global root to be crossed.
06 · Invalid topology
- Input / starting state
- cycle or shared child
- Expected result
ValueError
What it is testing: Tree assumptions are enforced.
For each case, show which branch or state change produces that result.
For root a with children b,c and b with children d,e, tree_diameter(a) == 3
using d–b–a–c (or e–b–a–c). A chain of four nodes also has diameter 3.
tree_diameter(None) == 0; a self-child raises ValueError.
Before opening the explanation, restate the contract, trace the smallest useful example, implement a baseline, and identify the repeated work. Then implement your improvement independently and derive tests from the contract. Say what your state means before saying which data structure stores it.
Worked lesson, changed requirements, and reference
Separate the information returned upward from the answer here
A baseline considers every node as a possible highest point of a path and recomputes the heights of both child subtrees. It is correct, but repeated height walks cost O(n²) on a chain. The useful change is postorder traversal: solve children before their parent and retain their height return values.
Define height as the number of nodes on a longest downward path from a node;
an empty child has height zero. If left and right heights are L and R, the
longest path whose highest node is current has L + R edges: each child
height counts its connecting edge to the current node plus its remaining
downward edges. The height returned to the parent is 1 + max(L, R), because
a path extended by a parent can continue through only one child branch.
| Completed node | Left height | Right height | Return height | Candidate diameter |
|---|---|---|---|---|
| d, e, c | 0 | 0 | 1 | 0 |
| b | 1 | 1 | 2 | 2 |
| a | 2 | 1 | 3 | 3 |
Returning L + R + 1 as height is wrong: it would send a branching shape upward
and later count a path that visits the branch point twice. Keep a separate
maximum diameter, or return (height, best_diameter) with
best=max(left_best, right_best, L+R). Every simple tree path has exactly one
highest node relative to the root, so checking every node's crossing candidate
covers paths entirely inside subtrees as well as those through the root.
The reference simulates recursive entry/return with an explicit expanded flag, stores child heights, and updates one best value. It validates identities on entry so malformed graphs cannot be treated as trees. Each node is processed twice, giving O(n) time. Retained heights and structural identities cost O(n) auxiliary space, plus O(h) frame space; output is O(1). A trusted recursive variant can use O(h) auxiliary space but may exceed Python's recursion budget. Node values and equality never affect topology or distance.
Follow each child height to its parent. Predict the returned height and local diameter before the second return arrives; their units and purposes differ.
Follow-up 1: return endpoint identities as well as distance
Predict the additional return state: each downward height must also name its
deepest endpoint. When L + R improves the best, record the two branch endpoints,
using current itself for an empty side.
| At node b | Returned upward | Best local endpoints |
|---|---|---|
| children d and e are tied leaves | height 2 plus a deterministic choice, say d | d, e |
| caller requires actual path | endpoint pair is insufficient alone | retain parents or reconstruct afterward |
Define a tie rule using stable IDs or traversal order; comparing opaque values cannot enforce identity ordering. A path with k edges has k+1 returned nodes.
Follow-up 2: edges carry nonnegative weights
The edge-count diameter is three, but weighted distance b–a–c–d is ten. Each
returned branch becomes edge_weight + child_downward_distance; take the best
two branches locally and one upward. Empty downward distance is zero, replacing
node-count height. Allowing negative weights requires a new policy on empty
paths and endpoint distinctness before using zero as a fallback. A senior
candidate derives units and distinguishes returned height from local diameter.
A lead candidate defines weight validation, tie semantics, and snapshot ownership;
adding cross-links destroys the unique-path proof and requires a graph problem.
Run and check
From the repository root:
cd curriculum/01-code/02-data-structures-algorithms/problems/20-tree-diameter
python -m unittest -v test_solution.py
Reference implementation (download file, source below) · Contract and oracle tests (download file, source below). Read the tests after your attempt. A green reference suite verifies the supplied implementation; it does not demonstrate independent transfer. Reimplement one follow-up with the reference closed and explain which old invariant no longer holds.
from dataclasses import dataclass
@dataclass(eq=False)
class Node:
value: object
left: "Node | None" = None
right: "Node | None" = None
def tree_diameter(root):
if root is None:
return 0
stack = [(root, False)]
seen, heights = set(), {}
best = 0
while stack:
node, expanded = stack.pop()
if not expanded:
if not isinstance(node, Node) or node in seen:
raise ValueError("expected a tree without malformed or repeated nodes")
seen.add(node)
stack.append((node, True))
if node.right is not None:
stack.append((node.right, False))
if node.left is not None:
stack.append((node.left, False))
continue
left = heights.get(node.left, 0)
right = heights.get(node.right, 0)
heights[node] = 1 + max(left, right)
best = max(best, left + right)
return best
import random
import unittest
from collections import deque
from solution import Node, tree_diameter
class DiameterTests(unittest.TestCase):
def test_units_and_deep_chain(self):
self.assertEqual(tree_diameter(None), 0)
self.assertEqual(tree_diameter(Node(1)), 0)
root = Node("a", Node("b", Node("d"), Node("e")), Node("c"))
self.assertEqual(tree_diameter(root), 3)
chain = None
for _ in range(5000):
chain = Node(7, chain)
self.assertEqual(tree_diameter(chain), 4999)
# Diameter is entirely below root, so root-height alone is insufficient.
fork = Node(0, Node(0, Node(0, Node(0))), Node(0, right=Node(0, right=Node(0))))
self.assertEqual(tree_diameter(Node(0, fork)), 6)
def test_invalid_structure(self):
child = Node(1)
root = Node(0)
root.left = root
for invalid in [root, Node(0, child, child), Node(0, right=42), 42]:
with self.assertRaises(ValueError):
tree_diameter(invalid)
def test_all_pairs_distance_oracle(self):
rng = random.Random(120)
for _ in range(150):
nodes = [Node(7) for _ in range(rng.randrange(1, 30))]
adjacent = {node: [] for node in nodes}
slots = [(nodes[0], "left"), (nodes[0], "right")]
for child in nodes[1:]:
parent, side = slots.pop(rng.randrange(len(slots)))
setattr(parent, side, child)
adjacent[parent].append(child)
adjacent[child].append(parent)
slots.extend([(child, "left"), (child, "right")])
expected = 0
for start in nodes:
queue, seen = deque([(start, 0)]), {start}
while queue:
node, distance = queue.popleft()
expected = max(expected, distance)
for neighbor in adjacent[node]:
if neighbor not in seen:
seen.add(neighbor)
queue.append((neighbor, distance + 1))
self.assertEqual(tree_diameter(nodes[0]), expected)
if __name__ == "__main__":
unittest.main()