Loading learning content...
When you first learned about trees in data structures, you probably started with the concept of a root—that special node at the top from which everything else descends. But here's a profound insight: in graph theory, trees don't have roots. They're just connected, acyclic graphs where no vertex is inherently 'special.'
The root is something we choose to add. And that choice transforms an undirected, symmetric structure into a directed, hierarchical one. Understanding this distinction is crucial for appreciating both the mathematical foundations of trees and their practical implementations.
By the end of this page, you will understand the fundamental difference between rooted and unrooted trees, learn how choosing a root imposes direction and hierarchy, see how the same underlying graph can be rooted in multiple ways, and appreciate how this duality appears in algorithms and data structures.
An unrooted tree is the natural graph-theoretic view of a tree: a connected, acyclic, undirected graph. In this view:
Unrooted trees are common in several domains:
Biology: Phylogenetic trees often start as unrooted, showing evolutionary relationships without assuming which species came first.
Network Design: Physical network topologies (cables, connections) are inherently undirected—data can flow both ways.
Graph Algorithms: Many algorithms process trees without caring about root position—they just need the connectivity structure.
In the unrooted tree above, notice that no vertex is marked as special. We could redraw this with any vertex at the 'top' of our visualization, and it would represent the exact same tree. Vertices A, B, C, D, E, and F are all just connected nodes in a symmetric structure.
Key Properties of Unrooted Trees:
Unrooted trees are typically represented using adjacency lists where each edge appears twice (once for each direction), or as a set of undirected edges. There's no 'root' field, no 'parent' pointer—just neighbor relationships.
A rooted tree is an unrooted tree with one distinguished vertex designated as the root. This single choice transforms the entire structure:
Definition: A rooted tree is a tree in which one vertex is designated as the root, inducing a parent-child relationship on all edges, with edges directed away from the root toward the leaves.
The moment you choose a root, several things happen automatically:
The diagram above shows the same underlying tree rooted at two different vertices. Notice how:
The connectivity is identical, but the hierarchical relationships are completely different. The choice of root is an additional piece of information on top of the graph structure.
Think of rooting a tree as choosing a 'viewpoint.' Just as looking at a 3D object from different angles gives different 2D projections, rooting a tree at different vertices gives different hierarchical structures—but the underlying graph is unchanged.
Let's formalize how we transform an unrooted tree into a rooted tree. This process is also called rooting a tree at vertex r.
Algorithm: Root Tree at Vertex r
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748
from collections import defaultdictfrom typing import Dict, List, Optional, Tuple def root_tree(edges: List[Tuple[int, int]], root: int) -> Dict[int, List[int]]: """ Convert an unrooted tree (given as edge list) into a rooted tree. Returns: Adjacency list where children[v] = list of v's children Key insight: In the unrooted tree, edges are bidirectional. Rooting assigns direction: parent → child (away from root). """ # Build undirected adjacency list adj = defaultdict(list) for u, v in edges: adj[u].append(v) adj[v].append(u) # BFS/DFS from root to assign parent-child relationships children = defaultdict(list) parent = {root: None} # Root has no parent def dfs(node: int, par: Optional[int]): """DFS to assign children based on rooting at 'root'""" for neighbor in adj[node]: if neighbor != par: # Don't go back to parent parent[neighbor] = node children[node].append(neighbor) dfs(neighbor, node) dfs(root, None) return dict(children), parent # Example: Root the same tree at different verticesedges = [(0, 1), (1, 2), (1, 3), (0, 4), (4, 5)] print("Tree rooted at 0:")children, parent = root_tree(edges, 0)for node in sorted(children.keys()): print(f" {node}'s children: {children[node]}") print("Tree rooted at 1:")children, parent = root_tree(edges, 1)for node in sorted(children.keys()): print(f" {node}'s children: {children[node]}") # Output shows different hierarchies from the same graph!Time Complexity: O(V + E) = O(V) since E = V - 1 for trees.
Space Complexity: O(V) for the parent and children data structures.
Key Implementation Details:
Tracking visited vertices: We don't need a separate 'visited' set—instead, we pass the parent as a parameter and avoid going back to it.
Building the children list: As we DFS from the root, every neighbor except the parent becomes a child.
Storing parent pointers: Often useful for algorithms that need to traverse toward the root.
When discussing trees, the same concepts often have different interpretations depending on whether we're in a rooted or unrooted context. Understanding these nuanced differences prevents confusion.
| Term | Unrooted Tree | Rooted Tree |
|---|---|---|
| Leaf | Vertex with degree 1 | Vertex with no children (may have degree 1 or be the root if tree has 1 node) |
| Internal Node | Vertex with degree > 1 | Vertex with at least one child |
| Neighbor | Adjacent vertex (symmetric) | Parent or child (direction matters) |
| Path | Sequence of adjacent vertices | Often specific: root-to-node path has special significance |
| Distance | Number of edges on path between two vertices | Often: depth (distance from root) or height (max distance to leaf) |
| Subtree | Any connected subgraph that is a tree | All descendants of a vertex plus the vertex itself |
| Height | Maximum distance between any two vertices (diameter/2) | Maximum depth of any leaf in the subtree |
When reading about trees in textbooks, papers, or code comments, always determine whether the author is discussing rooted or unrooted trees. The term 'leaf' alone, for instance, can mean different things. In an unrooted tree, a degree-1 vertex is a leaf even if it becomes an internal node when rooted elsewhere.
Special Case: The Root in a Small Tree
Consider a tree with just two vertices connected by one edge. In the unrooted view, both vertices have degree 1—both are 'leaves.'
If we root this tree at vertex A:
If we root at vertex B:
Same graph, different hierarchies!
Not all algorithms care about which vertex is the root—some only need the tree structure. Others are deeply affected by root choice. Understanding this distinction helps you design efficient solutions.
Choosing a Good Root:
Some problems allow you to choose the root cleverly to simplify the algorithm:
Example: Tree DP
Many tree DP problems compute values for each subtree (like sum, count, or max). These algorithms typically:
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647
def count_subtree_sizes(n: int, edges: list, root: int = 0) -> dict: """ Count the size of each subtree when the tree is rooted at 'root'. This is a classic tree DP pattern: - Process children first (post-order) - Aggregate results at each node The answer depends on the root! Different roots give different subtree sizes. """ from collections import defaultdict # Build adjacency list adj = defaultdict(list) for u, v in edges: adj[u].append(v) adj[v].append(u) subtree_size = {} def dfs(node: int, parent: int) -> int: """Returns size of subtree rooted at 'node'""" size = 1 # Count this node for child in adj[node]: if child != parent: size += dfs(child, node) # Add all children's subtree sizes subtree_size[node] = size return size dfs(root, -1) return subtree_size # Exampleedges = [(0, 1), (0, 2), (1, 3), (1, 4)]# 0# / \# 1 2# / \# 3 4 print("Rooted at 0:", count_subtree_sizes(5, edges, 0))# {3: 1, 4: 1, 1: 3, 2: 1, 0: 5}# Node 1's subtree has 3 nodes (1, 3, 4) print("Rooted at 1:", count_subtree_sizes(5, edges, 1))# {3: 1, 4: 1, 2: 1, 0: 2, 1: 5}# Now node 0's subtree has 2 nodes (0, 2)!A powerful technique in tree algorithms is re-rooting: computing an answer for every possible root efficiently. Naively, this would require O(n²) time (root at each of n vertices, O(n) per computation). The re-rooting technique achieves O(n) total.
The Key Insight:
When you 'move' the root from a vertex u to its neighbor v:
Re-rooting works in two phases: (1) Root at any vertex and compute 'down' values via DFS. (2) DFS again, computing 'up' values by considering what happens when the root moves to each child. This combines to give the answer for every root in O(n) time.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354
def sum_of_distances_in_tree(n: int, edges: list) -> list: """ For each node, compute sum of distances to all other nodes. Naive approach: BFS from each node = O(n²) Re-rooting approach: O(n) total When we move root from u to v (where v is u's child): - Nodes in v's subtree get 1 closer - All other nodes get 1 farther - answer[v] = answer[u] - subtree_size[v] + (n - subtree_size[v]) """ from collections import defaultdict adj = defaultdict(list) for u, v in edges: adj[u].append(v) adj[v].append(u) subtree_size = [1] * n dist_sum = [0] * n # dist_sum[i] = sum of distances from i to all nodes in i's subtree # Phase 1: Root at 0, compute subtree sizes and subtree distance sums def dfs1(node: int, parent: int) -> None: for child in adj[node]: if child != parent: dfs1(child, node) subtree_size[node] += subtree_size[child] # Distance to all nodes in child's subtree = their internal distances + 1 each dist_sum[node] += dist_sum[child] + subtree_size[child] dfs1(0, -1) # Phase 2: Re-root to compute answer for all nodes answer = [0] * n answer[0] = dist_sum[0] # Answer for root is just its subtree distance sum def dfs2(node: int, parent: int) -> None: for child in adj[node]: if child != parent: # Moving root from 'node' to 'child': # - subtree_size[child] nodes get 1 closer # - (n - subtree_size[child]) nodes get 1 farther answer[child] = answer[node] - subtree_size[child] + (n - subtree_size[child]) dfs2(child, node) dfs2(0, -1) return answer # Exampleedges = [(0, 1), (0, 2), (2, 3), (2, 4), (2, 5)]result = sum_of_distances_in_tree(6, edges)print(f"Sum of distances for each node: {result}")# answer[i] = sum of distances from i to all other nodesThe re-rooting technique is a beautiful example of how understanding the relationship between rooted and unrooted views leads to efficient algorithms. By leveraging the incremental change when moving the root, we avoid redundant computation.
In practice, rooted and unrooted trees are represented differently in code, reflecting their structural differences.
| Representation | Best For | Example |
|---|---|---|
| Edge List | Unrooted trees, input parsing | [(0,1), (1,2), (0,3)] |
| Adjacency List (undirected) | Unrooted trees, traversals | {0: [1,3], 1: [0,2], 2: [1], 3: [0]} |
| Children List | Rooted trees (from known root) | {0: [1,3], 1: [2], 2: [], 3: []} |
| Parent Array | Rooted trees, LCA queries | parent = [-1, 0, 1, 0] (root has -1) |
| Node Objects with parent/children | OOP tree structures | class Node: parent, children, val |
| Binary tree arrays | Complete binary trees (heaps) | [root, left, right, ...] |
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384
from typing import List, Dict, Optionalfrom collections import defaultdictfrom dataclasses import dataclass, field # ===============================# UNROOTED TREE REPRESENTATIONS# =============================== # 1. Edge List - Simple, common for inputedges_list = [(0, 1), (1, 2), (1, 3), (0, 4)] # 2. Adjacency List - Standard for traversalsdef edges_to_adj_list(edges: List[tuple]) -> Dict[int, List[int]]: adj = defaultdict(list) for u, v in edges: adj[u].append(v) adj[v].append(u) # Undirected: add both directions return dict(adj) # ===============================# ROOTED TREE REPRESENTATIONS# =============================== # 3. Children list - When you have a specific rootdef build_children_list(edges: List[tuple], root: int) -> Dict[int, List[int]]: adj = edges_to_adj_list(edges) children = defaultdict(list) visited = {root} def dfs(node): for neighbor in adj[node]: if neighbor not in visited: visited.add(neighbor) children[node].append(neighbor) dfs(neighbor) dfs(root) return dict(children) # 4. Parent array - Compact, good for LCAdef build_parent_array(edges: List[tuple], root: int, n: int) -> List[int]: adj = edges_to_adj_list(edges) parent = [-1] * n # -1 indicates no parent (root) visited = {root} def dfs(node): for neighbor in adj[node]: if neighbor not in visited: visited.add(neighbor) parent[neighbor] = node dfs(neighbor) dfs(root) return parent # 5. Node objects - OOP style@dataclassclass TreeNode: val: int parent: Optional['TreeNode'] = None children: List['TreeNode'] = field(default_factory=list) def build_tree_objects(edges: List[tuple], root: int) -> TreeNode: nodes = {i: TreeNode(val=i) for i in range(len(edges) + 1)} adj = edges_to_adj_list(edges) visited = {root} def dfs(node_id: int, parent_id: Optional[int]): if parent_id is not None: nodes[node_id].parent = nodes[parent_id] for neighbor in adj[node_id]: if neighbor not in visited: visited.add(neighbor) nodes[node_id].children.append(nodes[neighbor]) dfs(neighbor, node_id) dfs(root, None) return nodes[root] # Example usageedges = [(0, 1), (1, 2), (1, 3), (0, 4)]print("Adjacency list:", edges_to_adj_list(edges))print("Children (root=0):", build_children_list(edges, 0))print("Parent array (root=0):", build_parent_array(edges, 0, 5))We've explored the fundamental distinction between rooted and unrooted trees—one of the most important conceptual bridges between graph theory and practical data structures. Let's consolidate:
What's Next:
Now that we understand the rooted/unrooted distinction, we'll explore tree properties through the graph-theoretic lens—examining how concepts like diameter, center, height, and balance emerge from the fundamental graph structure.
You now understand the fundamental distinction between rooted and unrooted trees. This duality is central to tree algorithms—knowing when roots matter and when they don't helps you design efficient solutions. Next, we'll explore tree properties in graph terms.