Facebook Pixel

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 from 0 to n - 1, rooted at node 0. The tree structure is defined by edges connecting nodes.

DFS

  • Yes: Since we're dealing with a tree structure and need to:

    1. Calculate subtree sizes for each node's children
    2. Compare these subtree sizes to determine if a node is "good"
    3. 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.

Quick Interview Experience
Help others by sharing your interview experience
Have you seen this problem before?

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:

  1. 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.

  2. 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.

  3. 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 processed
  • fa 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 Evaluator

Example 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:

  1. Start at node 0 (root, parent = -1)

    • Visit child 1
  2. 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), increment ans = 1
    • Return subtree size = 1 + 1 + 1 = 3 to parent
  3. Back at node 0

    • First child (node 1) returned size = 3
    • Store pre = 3
    • Visit child 2
  4. 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), increment ans = 2
    • Return subtree size = 1 + 1 + 1 = 3 to parent
  5. 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), increment ans = 3
    • Return subtree size = 1 + 3 + 3 = 7
  6. 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

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:

  1. The adjacency list g which stores all edges. For a tree with n nodes and n-1 edges, each edge appears twice in the adjacency list (once for each direction), requiring O(2(n-1)) = O(n) space.
  2. The recursion stack for DFS, which in the worst case (a skewed tree) can go up to depth n, requiring O(n) space.
  3. Additional variables like pre, cnt, ok, and ans use constant space O(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.

Discover Your Strengths and Weaknesses: Take Our 3-Minute Quiz to Tailor Your Study Plan:

Which of these pictures shows the visit order of a depth-first search?


Recommended Readings

Want a Structured Path to Master System Design Too? Don’t Miss This!

Load More