Taro Logo

Find the Last Marked Nodes in Tree

Hard
Asked by:
Profile picture
5 views
Topics:
TreesGraphs

You are given a tree with n nodes numbered from 0 to n - 1. You are given a 0-indexed integer array parent of length n, where parent[i] is the parent of node i. The tree's root is the node with parent[i] == -1.

You are also given an integer array degree of length n, where degree[i] is the degree of the ith node.

You will mark the nodes in the tree in multiple rounds. In each round, you mark all unmarked nodes that satisfy the following conditions:

  • The node is a leaf node.
  • The degree of the node is in the given array degree.

Return an array containing all the marked nodes in the last round.

You can return the answer in any order.

Example 1:

Input: parent = [-1,0,0,1,1,2,2,3,3,4,4,5,5], degree = [0,1,2]
Output: [6,7,8,9,10,11,12]
Explanation: The graph is shown above.
In the first round, nodes [6,7,8,9,10,11,12] are marked because they are leaf nodes and their degrees are in the array degree.
So, the answer is [6,7,8,9,10,11,12].

Example 2:

Input: parent = [-1,0,0,1,1,2,2,3,3,4,4,5,5], degree = [2]
Output: []
Explanation: 
The graph is shown above.
There is no leaf node with degree 2.
So, the answer is an empty array.

Constraints:

  • 2 <= n <= 105
  • parent.length == n
  • -1 <= parent[i] < n
  • degree.length == n
  • 0 <= degree[i] <= n
  • The input is generated such that parent represents a valid tree.

Solution


Clarifying Questions

When you get asked this question in a real-life environment, it will often be ambiguous (especially at FAANG). Make sure to ask these questions in that case:

  1. What is the range of node values (n), and can I assume the nodes are labeled from 1 to n?
  2. Can I assume that the input `edges` always represents a valid tree (connected and no cycles)?
  3. If the tree is empty (n=0) or has only one node, what should the return value be?
  4. If the process results in all nodes being removed in the same round, what order should the returned node labels be in?
  5. Are the edge pairs in `edges` guaranteed to be unique, or could there be duplicate edges?

Brute Force Solution

Approach

Imagine you're exploring a maze and want to find the last rooms you visit before escaping. The brute force approach means we try marking rooms based on every single possible order until we've checked all the nodes.

Here's how the algorithm would work step-by-step:

  1. Start with any node in the tree and consider it to be the first marked node.
  2. Mark that node.
  3. Next, consider every unmarked node to be the next marked node.
  4. Mark that node.
  5. Keep doing this, picking each unmarked node as the next one to mark, one at a time, exploring all possible sequences.
  6. Continue until all nodes have been marked.
  7. For each possible order you created by marking the nodes, check if that order is valid based on the tree's structure (parent/child relationships).
  8. If the order is valid, look at the last node in the sequence that was marked. This is a potential answer.
  9. Repeat this process for every possible order of marking the nodes.
  10. Finally, among all the potential answers you found, choose the node that appears last in a valid order.

Code Implementation

import itertools

def find_last_marked_nodes_brute_force(tree):
    nodes = list(tree.keys())
    all_permutations = list(itertools.permutations(nodes))
    last_marked_nodes_for_all_orders = []

    for marking_order in all_permutations:
        last_marked_nodes_for_current_order = simulate_marking(tree, marking_order)
        last_marked_nodes_for_all_orders.append(last_marked_nodes_for_current_order)

    final_result = determine_final_last_marked_nodes(last_marked_nodes_for_all_orders)
    return final_result

def simulate_marking(tree, marking_order):
    marked_nodes = set()
    last_marked_nodes = []

    for node_to_mark in marking_order:
        marked_nodes.add(node_to_mark)
        is_removable = False

        for neighbor in tree[node_to_mark]:
            if neighbor in marked_nodes:
                is_removable = True
                break

        if not is_removable:
            last_marked_nodes.append(node_to_mark)

    return last_marked_nodes

def determine_final_last_marked_nodes(all_last_marked_nodes):
    # Find the intersection of last marked nodes across all permutations
    if not all_last_marked_nodes:
        return []

    # Use the first list as the basis for intersection.
    intersection_of_last_marked_nodes = set(all_last_marked_nodes[0])

    # Iteratively find the intersection with other permutations' results.
    for last_marked_nodes in all_last_marked_nodes[1:]:
        intersection_of_last_marked_nodes.intersection_update(last_marked_nodes)

    return list(intersection_of_last_marked_nodes)

def example_usage():
    # Example tree structure represented as an adjacency list.
    example_tree = {
        'A': ['B', 'C'],
        'B': ['A', 'D'],
        'C': ['A', 'E'],
        'D': ['B'],
        'E': ['C']
    }
    
    result = find_last_marked_nodes_brute_force(example_tree)
    print(f"Last marked nodes: {result}")

if __name__ == "__main__":
    example_usage()

Big(O) Analysis

Time Complexity
O(n!)The algorithm explores all possible orders of marking n nodes in the tree. Generating all possible orderings of n nodes results in n! (n factorial) permutations. For each permutation, we need to validate if the marking order respects the tree's parent-child relationships which takes O(n) time in the worst case. Therefore, the overall time complexity is O(n * n!). Since n! grows much faster than n, we approximate this to O(n!).
Space Complexity
O(N!)The algorithm explores all possible orderings of marking the N nodes, implying a recursive or iterative process to generate these permutations. Generating all permutations of N nodes requires storing these orderings, which can grow up to N! in size. Also, checking the validity of each ordering likely involves storing the currently marked nodes, leading to further space usage. Therefore, the auxiliary space complexity is dominated by the storage of permutations, resulting in O(N!).

Optimal Solution

Approach

This problem asks us to figure out which nodes in a tree are the last to be marked given a specific marking process. The key insight is to simulate the marking process in reverse, starting from the end to efficiently determine the last marked nodes.

Here's how the algorithm would work step-by-step:

  1. Start by understanding that the last marked nodes are the roots of the 'unmarked' subtrees right before the entire tree is marked.
  2. Think about running the marking process backward. Instead of marking nodes, we're now 'unmarking' them.
  3. Begin with all nodes marked and try to 'unmark' a node. A node can only be 'unmarked' if all its children are already 'unmarked'.
  4. Repeatedly try to 'unmark' nodes from the bottom up. If a node's children are 'unmarked', you can 'unmark' it.
  5. Continue this process until you can't 'unmark' any more nodes. The nodes that remain 'marked' are the last ones that were originally marked.

Code Implementation

def find_last_marked_nodes(tree, marking_threshold):    last_marked_nodes = []
    # Helper function to determine if a node is a potential last marked node.
    def is_potential_last_marked(node):        if not node:
            return False
        
        if not node.children:
            return True
        
        # Check if the node satisfies criteria for being last marked.
        unmarked_children_count = 0
        for child in node.children:
            if not child.marked:
                unmarked_children_count += 1
        
        if unmarked_children_count <= (len(node.children) - marking_threshold):
            return True
            
        return False

    # Need to explore all nodes. 
    def explore_tree(node):
        if not node:
            return

        for child in node.children:
            explore_tree(child)

        # Add potential last marked nodes.
        if is_potential_last_marked(node):
            last_marked_nodes.append(node)

    explore_tree(tree)
    # Filter out nodes that depend on descendants' marking.
    final_last_marked_nodes = []
    for node in last_marked_nodes:
      is_truly_last = True
      for other_node in last_marked_nodes:
        if node != other_node and is_ancestor(node, other_node):
          is_truly_last = False
          break
      if is_truly_last:
        final_last_marked_nodes.append(node)

    return final_last_marked_nodes

def is_ancestor(node, potential_descendant):
  if not node or not potential_descendant:
    return False

  current = potential_descendant
  while current:
    if current == node:
      return True
    current = current.parent
  return False

Big(O) Analysis

Time Complexity
O(n)The algorithm iterates through each node of the tree to potentially 'unmark' it. In the worst-case scenario, each node is visited a constant number of times to check if all its children are unmarked. The number of operations is directly proportional to the number of nodes, n, in the tree, making the time complexity O(n). The backward marking simulation visits each node a limited number of times.
Space Complexity
O(N)The algorithm uses a boolean array of size N, where N is the number of nodes in the tree, to keep track of which nodes are marked/unmarked. This array represents the auxiliary space used to simulate the unmarking process. The space required grows linearly with the number of nodes in the tree. Therefore, the space complexity is O(N).

Edge Cases

Null or empty edges array
How to Handle:
Return an empty list as there are no edges and thus no tree.
n is 0 or negative
How to Handle:
Return an empty list as the number of nodes must be positive.
n is 1, and edges is empty
How to Handle:
Return a list containing only node 0, as it's the only node and therefore a leaf in the initial state.
Fully connected graph (complete graph)
How to Handle:
The algorithm should correctly identify leaves and remove them iteratively, eventually resulting in the last marked nodes.
Star graph (one central node connected to all others)
How to Handle:
The algorithm should remove all leaf nodes (outer nodes) in the first round, leaving only the central node.
Linear chain of nodes (a single path)
How to Handle:
The algorithm should remove the ends of the chain in each round, converging towards the center.
Large n with edges representing a tree, but high degree nodes exist
How to Handle:
The solution should scale efficiently, likely requiring adjacency list representation rather than adjacency matrix to avoid memory issues and speed up neighbor lookups.
Duplicate edges in the edges array
How to Handle:
The algorithm should treat duplicate edges as a single edge, which is inherently handled by degree tracking.