3249. Count the Number of Good Nodes
Problem Description
You are given an undirected tree with n
nodes labeled from 0
to n - 1
, rooted at node 0
. The tree structure is defined by a 2D integer array edges
of length n - 1
, where each edges[i] = [aáµ¢, báµ¢]
represents an edge between nodes aáµ¢
and báµ¢
.
A node is considered good if all the subtrees rooted at its children have the same size (same number of nodes).
Your task is to count and return the total number of good nodes in the tree.
Key Definitions:
- A subtree of a node consists of that node and all of its descendants
- A node with no children (leaf node) is considered good by default since it has no subtrees to compare
- The root node checks if all its children's subtrees have equal sizes
Example Understanding:
If a node has 3 children, and their subtrees contain 5, 5, and 5 nodes respectively, then this node is good. But if the subtrees contain 5, 3, and 5 nodes, then this node is not good because not all subtree sizes are equal.
The problem asks you to traverse the entire tree and count how many nodes satisfy this "good" condition.
Flowchart Walkthrough
First, let's pin down the algorithm using the Flowchart. Here's a step-by-step walkthrough:
Is it a graph?
- Yes: The problem explicitly mentions an undirected tree with nodes and edges. A tree is a special type of graph (connected and acyclic).
Is it a tree?
- Yes: The problem clearly states we have an undirected tree with
n
nodes labeled from0
ton - 1
, rooted at node0
. The tree structure is defined by edges connecting nodes.
DFS
-
Yes: Since we're dealing with a tree structure and need to:
- Calculate subtree sizes for each node's children
- Compare these subtree sizes to determine if a node is "good"
- Process each node and its descendants recursively
DFS is the natural choice here because:
- We need to explore each subtree completely to calculate its size
- We need to process children before determining if a parent is "good"
- The recursive nature of DFS perfectly matches the recursive structure of trees
Conclusion: The flowchart suggests using DFS (Depth-First Search) for this problem. This aligns perfectly with the solution approach, where we recursively traverse the tree, calculate subtree sizes bottom-up, and check at each node whether all its children's subtrees have equal sizes.
Intuition
To determine if a node is "good", we need to know the sizes of all subtrees rooted at its children. This immediately suggests a bottom-up approach where we first calculate information about children before processing the parent.
Think about it this way: if we're standing at a node and want to check if it's good, we need to look down at each child and ask "how many nodes are in your subtree?" If all children answer with the same number, our node is good.
This naturally leads to a recursive DFS approach because:
-
We need complete subtree information: To know if a node is good, we must fully explore each of its children's subtrees to count their nodes. We can't make a decision about a node until we've completely processed all its descendants.
-
Information flows bottom-up: Leaf nodes are trivially good (they have no children to compare). As we move up the tree, each node needs the subtree sizes from its children, which those children calculated when they examined their own children.
-
Each node does two jobs:
- Count the total nodes in its subtree (including itself)
- Check if all its children's subtrees are equal in size
The key insight is that while traversing, we can kill two birds with one stone: as we recursively calculate subtree sizes for the parent's use, we can also check at each node whether it qualifies as "good" by comparing the sizes we get from different children.
For example, if a node has three children returning subtree sizes of [5, 5, 5]
, we know it's good. But if they return [5, 3, 5]
, it's not good. We accumulate the count of good nodes as we traverse, and also return the total subtree size (1 + 5 + 5 + 5 = 16) for our parent to use.
Learn more about Tree and Depth-First Search patterns.
Solution Approach
The implementation uses DFS with an adjacency list representation of the tree. Let's walk through the key components:
1. Building the Graph Structure
First, we construct an adjacency list g
from the edges array. Since the tree is undirected, we add both directions for each edge:
g = defaultdict(list)
for a, b in edges:
g[a].append(b)
g[b].append(a)
2. The DFS Function
The core logic is in dfs(a, fa)
where:
a
is the current node being processedfa
is the parent node (to avoid revisiting in an undirected tree)
The function returns the total number of nodes in the subtree rooted at a
.
3. Key Variables in DFS
pre = -1
: Stores the size of the first child's subtree (initialized to -1 to detect the first child)cnt = 1
: Counts total nodes in current subtree (starts at 1 for the current node)ok = 1
: Flag indicating if current node is good (assumes good initially)
4. Processing Children
For each neighbor b
of node a
:
for b in g[a]: if b != fa: # Skip the parent to avoid cycles cur = dfs(b, a) # Get subtree size of child b cnt += cur # Add to total count
5. Checking if Node is Good
The clever part is comparing subtree sizes:
if pre < 0: pre = cur # First child: store its subtree size elif pre != cur: ok = 0 # Different size than first child: not good
- For the first child, we store its subtree size in
pre
- For subsequent children, we compare their subtree sizes with
pre
- If any child has a different subtree size, we mark
ok = 0
6. Accumulating the Answer
After processing all children:
nonlocal ans ans += ok # Add 1 if good, 0 if not good return cnt # Return total subtree size for parent's use
7. Starting the Traversal
We start DFS from the root (node 0) with parent -1:
ans = 0 dfs(0, -1) return ans
The algorithm efficiently combines two tasks in a single traversal: counting subtree sizes (needed for parent nodes) and determining good nodes (our actual goal). The time complexity is O(n)
since we visit each node once, and space complexity is O(n)
for the recursion stack and adjacency list.
Ready to land your dream job?
Unlock your dream job with a 3-minute evaluator for a personalized learning plan!
Start EvaluatorExample Walkthrough
Let's walk through a small example to illustrate the solution approach.
Consider a tree with 7 nodes and the following edges:
edges = [[0,1], [0,2], [1,3], [1,4], [2,5], [2,6]]
This creates the following tree structure:
0 / \ 1 2 / \ / \ 3 4 5 6
Step-by-step DFS traversal:
-
Start at node 0 (root, parent = -1)
- Visit child 1
-
At node 1 (parent = 0)
- Visit child 3 (leaf): returns subtree size = 1
- Store
pre = 1
(first child's subtree size) - Visit child 4 (leaf): returns subtree size = 1
- Compare:
pre (1) == cur (1)
✓ Same size! - Node 1 is good (
ok = 1
), incrementans = 1
- Return subtree size = 1 + 1 + 1 = 3 to parent
-
Back at node 0
- First child (node 1) returned size = 3
- Store
pre = 3
- Visit child 2
-
At node 2 (parent = 0)
- Visit child 5 (leaf): returns subtree size = 1
- Store
pre = 1
(first child's subtree size) - Visit child 6 (leaf): returns subtree size = 1
- Compare:
pre (1) == cur (1)
✓ Same size! - Node 2 is good (
ok = 1
), incrementans = 2
- Return subtree size = 1 + 1 + 1 = 3 to parent
-
Back at node 0 (continuing)
- Second child (node 2) returned size = 3
- Compare:
pre (3) == cur (3)
✓ Same size! - Node 0 is good (
ok = 1
), incrementans = 3
- Return subtree size = 1 + 3 + 3 = 7
-
Process leaf nodes (3, 4, 5, 6)
- Each leaf has no children, so
pre
remains -1 - All leaves are good by default
- Final
ans = 3 + 4 = 7
- Each leaf has no children, so
Result: All 7 nodes are good!
- Node 0 is good: both children have subtrees of size 3
- Nodes 1 and 2 are good: both have children with subtrees of size 1
- Nodes 3, 4, 5, 6 are good: they're leaves with no children to compare
Solution Implementation
1class Solution:
2 def countGoodNodes(self, edges: List[List[int]]) -> int:
3 # Build adjacency list representation of the tree
4 adjacency_list = defaultdict(list)
5 for node_a, node_b in edges:
6 adjacency_list[node_a].append(node_b)
7 adjacency_list[node_b].append(node_a)
8
9 # Initialize counter for good nodes
10 self.good_nodes_count = 0
11
12 def dfs(current_node: int, parent_node: int) -> int:
13 """
14 Perform DFS to count subtree sizes and identify good nodes.
15
16 Args:
17 current_node: Current node being processed
18 parent_node: Parent of current node (to avoid revisiting)
19
20 Returns:
21 Size of subtree rooted at current_node
22 """
23 # Track the size of first child's subtree (for comparison)
24 first_child_subtree_size = -1
25
26 # Track if current node is good (all children have same subtree size)
27 is_good_node = True
28
29 # Start with size 1 (current node itself)
30 subtree_size = 1
31
32 # Process all children of current node
33 for child_node in adjacency_list[current_node]:
34 # Skip parent to avoid going back up the tree
35 if child_node != parent_node:
36 # Get size of child's subtree
37 child_subtree_size = dfs(child_node, current_node)
38
39 # Add child's subtree size to current subtree
40 subtree_size += child_subtree_size
41
42 # Check if all children have same subtree size
43 if first_child_subtree_size < 0:
44 # First child encountered, store its subtree size
45 first_child_subtree_size = child_subtree_size
46 elif first_child_subtree_size != child_subtree_size:
47 # Different subtree size found, node is not good
48 is_good_node = False
49
50 # Update global counter if current node is good
51 self.good_nodes_count += is_good_node
52
53 # Return total size of subtree rooted at current node
54 return subtree_size
55
56 # Start DFS from node 0 with no parent (-1)
57 dfs(0, -1)
58
59 return self.good_nodes_count
60
1class Solution {
2 private int goodNodeCount;
3 private List<Integer>[] adjacencyList;
4
5 /**
6 * Counts the number of good nodes in a tree.
7 * A node is good if all its child subtrees have the same size.
8 *
9 * @param edges Array of edges representing the tree
10 * @return Number of good nodes in the tree
11 */
12 public int countGoodNodes(int[][] edges) {
13 // Initialize the tree with n nodes (edges.length + 1)
14 int nodeCount = edges.length + 1;
15 adjacencyList = new List[nodeCount];
16 Arrays.setAll(adjacencyList, index -> new ArrayList<>());
17
18 // Build the adjacency list representation of the tree
19 for (int[] edge : edges) {
20 int nodeA = edge[0];
21 int nodeB = edge[1];
22 adjacencyList[nodeA].add(nodeB);
23 adjacencyList[nodeB].add(nodeA);
24 }
25
26 // Start DFS from node 0 with no parent (-1)
27 dfs(0, -1);
28 return goodNodeCount;
29 }
30
31 /**
32 * Performs DFS traversal to count good nodes and calculate subtree sizes.
33 *
34 * @param currentNode The current node being processed
35 * @param parentNode The parent of the current node (-1 if root)
36 * @return The size of the subtree rooted at currentNode
37 */
38 private int dfs(int currentNode, int parentNode) {
39 int previousSubtreeSize = -1; // Size of the first child subtree encountered
40 int currentSubtreeSize = 1; // Size of current subtree (starts with 1 for current node)
41 int isGoodNode = 1; // Flag to track if current node is good (1 = good, 0 = not good)
42
43 // Iterate through all neighbors of the current node
44 for (int neighbor : adjacencyList[currentNode]) {
45 // Skip the parent to avoid going back up the tree
46 if (neighbor != parentNode) {
47 // Recursively calculate the size of the child subtree
48 int childSubtreeSize = dfs(neighbor, currentNode);
49 currentSubtreeSize += childSubtreeSize;
50
51 if (previousSubtreeSize < 0) {
52 // First child subtree encountered, store its size
53 previousSubtreeSize = childSubtreeSize;
54 } else if (previousSubtreeSize != childSubtreeSize) {
55 // Found a child subtree with different size, node is not good
56 isGoodNode = 0;
57 }
58 }
59 }
60
61 // Add to the count if this is a good node
62 goodNodeCount += isGoodNode;
63
64 // Return the total size of the subtree rooted at current node
65 return currentSubtreeSize;
66 }
67}
68
1class Solution {
2public:
3 int countGoodNodes(vector<vector<int>>& edges) {
4 // Calculate the number of nodes (edges + 1 for a tree)
5 int n = edges.size() + 1;
6
7 // Build adjacency list representation of the tree
8 vector<vector<int>> adjacencyList(n);
9 for (const auto& edge : edges) {
10 int nodeA = edge[0];
11 int nodeB = edge[1];
12 adjacencyList[nodeA].push_back(nodeB);
13 adjacencyList[nodeB].push_back(nodeA);
14 }
15
16 int goodNodesCount = 0;
17
18 // DFS function to traverse the tree and count good nodes
19 // Returns the size of the subtree rooted at current node
20 function<int(int, int)> dfs = [&](int currentNode, int parentNode) -> int {
21 int firstChildSize = -1; // Size of the first child's subtree
22 int subtreeSize = 1; // Current subtree size (including current node)
23 bool isGoodNode = true; // Flag to check if current node is good
24
25 // Traverse all children of the current node
26 for (int childNode : adjacencyList[currentNode]) {
27 // Skip the parent to avoid revisiting
28 if (childNode != parentNode) {
29 // Recursively get the size of child's subtree
30 int childSubtreeSize = dfs(childNode, currentNode);
31 subtreeSize += childSubtreeSize;
32
33 // Check if all children have the same subtree size
34 if (firstChildSize < 0) {
35 // First child encountered
36 firstChildSize = childSubtreeSize;
37 } else if (firstChildSize != childSubtreeSize) {
38 // Found a child with different subtree size
39 isGoodNode = false;
40 }
41 }
42 }
43
44 // Increment good nodes count if current node is good
45 goodNodesCount += isGoodNode;
46
47 return subtreeSize;
48 };
49
50 // Start DFS from node 0 with -1 as parent (root has no parent)
51 dfs(0, -1);
52
53 return goodNodesCount;
54 }
55};
56
1/**
2 * Counts the number of "good" nodes in a tree.
3 * A node is considered "good" if all its subtrees have the same size.
4 *
5 * @param edges - Array of edges representing the tree structure
6 * @returns The count of good nodes in the tree
7 */
8function countGoodNodes(edges: number[][]): number {
9 // Calculate total number of nodes (edges + 1 for a tree)
10 const nodeCount: number = edges.length + 1;
11
12 // Build adjacency list representation of the tree
13 const adjacencyList: number[][] = Array.from({ length: nodeCount }, () => []);
14 for (const [nodeA, nodeB] of edges) {
15 adjacencyList[nodeA].push(nodeB);
16 adjacencyList[nodeB].push(nodeA);
17 }
18
19 // Counter for good nodes
20 let goodNodeCount: number = 0;
21
22 /**
23 * Performs depth-first search to calculate subtree sizes and identify good nodes.
24 *
25 * @param currentNode - The current node being visited
26 * @param parentNode - The parent node to avoid revisiting
27 * @returns The size of the subtree rooted at currentNode
28 */
29 const dfs = (currentNode: number, parentNode: number): number => {
30 // Initialize variables:
31 // previousSubtreeSize: Size of the first subtree encountered (-1 if none)
32 // currentSubtreeSize: Total size of current subtree including current node
33 // isGoodNode: Flag indicating if current node is good (1 = good, 0 = not good)
34 let previousSubtreeSize: number = -1;
35 let currentSubtreeSize: number = 1; // Start with 1 for current node
36 let isGoodNode: number = 1;
37
38 // Explore all neighbors (children in the tree)
39 for (const neighbor of adjacencyList[currentNode]) {
40 // Skip the parent node to avoid cycles
41 if (neighbor !== parentNode) {
42 // Recursively calculate the size of the child's subtree
43 const childSubtreeSize: number = dfs(neighbor, currentNode);
44
45 // Add child's subtree size to current subtree
46 currentSubtreeSize += childSubtreeSize;
47
48 // Check if all subtrees have the same size
49 if (previousSubtreeSize < 0) {
50 // First subtree encountered, store its size
51 previousSubtreeSize = childSubtreeSize;
52 } else if (previousSubtreeSize !== childSubtreeSize) {
53 // Found a subtree with different size, node is not good
54 isGoodNode = 0;
55 }
56 }
57 }
58
59 // Increment good node counter if this node is good
60 goodNodeCount += isGoodNode;
61
62 // Return the total size of the subtree rooted at current node
63 return currentSubtreeSize;
64 };
65
66 // Start DFS from node 0 with -1 as parent (indicating no parent)
67 dfs(0, -1);
68
69 return goodNodeCount;
70}
71
Time and Space Complexity
Time Complexity: O(n)
The algorithm performs a depth-first search (DFS) traversal of the tree. Each node is visited exactly once during the traversal. For each node, we iterate through its adjacent nodes (edges). Since the input represents a tree with n
nodes, there are exactly n-1
edges, and each edge is traversed twice (once from each direction). Therefore, the total number of operations is proportional to the number of nodes plus the number of edges, which gives us O(n + (n-1)) = O(n)
.
Space Complexity: O(n)
The space complexity consists of:
- The adjacency list
g
which stores all edges. For a tree withn
nodes andn-1
edges, each edge appears twice in the adjacency list (once for each direction), requiringO(2(n-1)) = O(n)
space. - The recursion stack for DFS, which in the worst case (a skewed tree) can go up to depth
n
, requiringO(n)
space. - Additional variables like
pre
,cnt
,ok
, andans
use constant spaceO(1)
.
The overall space complexity is therefore O(n)
.
Learn more about how to find time and space complexity quickly.
Common Pitfalls
1. Forgetting to Handle Leaf Nodes Correctly
A common mistake is not recognizing that leaf nodes (nodes with no children) should automatically be considered "good" nodes. The code might incorrectly skip counting them if the logic isn't properly structured.
Pitfall Example:
def dfs(current_node, parent_node):
children = []
for child in adjacency_list[current_node]:
if child != parent_node:
children.append(child)
# Wrong: Leaf nodes won't be counted as good
if len(children) == 0:
return 1 # Only returns size, doesn't count as good node
Solution:
Initialize is_good_node = True
at the start, which naturally handles leaf nodes since they have no children to compare, maintaining their "good" status.
2. Using Global Variables Without Proper Scoping
When using a counter variable across recursive calls, forgetting to use nonlocal
or class attributes can lead to incorrect results.
Pitfall Example:
def countGoodNodes(self, edges):
# ...
ans = 0 # Local variable
def dfs(current_node, parent_node):
# ...
ans += is_good_node # Error: UnboundLocalError
return subtree_size
Solution:
Either use nonlocal ans
inside the DFS function or use a class attribute like self.good_nodes_count
to maintain the counter across recursive calls.
3. Incorrect Parent Tracking in Undirected Tree
Since the tree is undirected, failing to track the parent node properly can cause infinite recursion or incorrect traversal.
Pitfall Example:
def dfs(current_node): # Missing parent parameter
for child in adjacency_list[current_node]:
# Will revisit parent, causing infinite recursion
child_size = dfs(child)
Solution: Always pass and check the parent node to avoid traversing back up the tree:
def dfs(current_node, parent_node):
for child in adjacency_list[current_node]:
if child != parent_node: # Skip parent
child_size = dfs(child, current_node)
4. Edge Case: Single Node Tree
When the tree has only one node (n=1), the edges array is empty. Not handling this case can cause errors when building the adjacency list or starting DFS.
Pitfall Example:
def countGoodNodes(self, edges):
if not edges: # Might return wrong answer
return 0 # Wrong: single node is good
Solution: The current implementation handles this correctly because:
- An empty edges array creates an empty adjacency list
- DFS on node 0 with no children marks it as good (leaf node)
- Returns 1, which is correct
5. Comparing with -1 Instead of Using a Flag
Using -1
as a sentinel value for first_child_subtree_size
can be confusing and error-prone if subtree sizes could theoretically be negative (though they can't in this problem).
Alternative Approach:
def dfs(current_node, parent_node):
child_sizes = []
subtree_size = 1
for child in adjacency_list[current_node]:
if child != parent_node:
child_size = dfs(child, current_node)
child_sizes.append(child_size)
subtree_size += child_size
# Check if all sizes are equal (or no children)
is_good = len(set(child_sizes)) <= 1
self.good_nodes_count += is_good
return subtree_size
This approach is cleaner but slightly less efficient due to set creation. The original approach with -1
is more efficient but requires careful handling.
Which of these pictures shows the visit order of a depth-first search?
Recommended Readings
Everything About Trees A tree is a type of graph data structure composed of nodes and edges Its main properties are It is acyclic doesn't contain any cycles There exists a path from the root to any node Has N 1 edges where N is the number of nodes in the tree and
https assets algo monster cover_photos dfs svg Depth First Search Prereqs Recursion Review problems recursion_intro Trees problems tree_intro With a solid understanding of recursion under our belts we are now ready to tackle one of the most useful techniques in coding interviews Depth First Search DFS As the name suggests
Coding Interview Patterns Your Personal Dijkstra's Algorithm to Landing Your Dream Job The goal of AlgoMonster is to help you get a job in the shortest amount of time possible in a data driven way We compiled datasets of tech interview problems and broke them down by patterns This way
Want a Structured Path to Master System Design Too? Don’t Miss This!