Tree diameter: return one branch, combine two locallyLESSON 2.21 · 21 OF 43 IN CHAPTER
PART A / Coding problems and trade-offs
Step 47 of 252
LESSON 2.21 · 21 OF 43 IN CHAPTERTry it, then open the solution

Tree diameter: return one branch, combine two locally

Constructed practice problem; no company attribution. Prerequisites: tree levels and postorder return state.

Candidate brief

THE PROBLEM

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
3 edges

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.

Diagram: Separate the information returned upward from the answer here

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.

Child heights return upward while each parent combines two branches locally

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

Diagram: 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.

Reference implementation · solution.py
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
Contract and oracle tests · test_solution.py
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()