Taro Logo

Number of Good Paths

Hard
Amazon logo
Amazon
1 view
Topics:
GraphsTreesGreedy Algorithms

You are given a tree (i.e. a connected, undirected graph with no cycles) consisting of n nodes numbered from 0 to n - 1 and exactly n - 1 edges.

You are given a 0-indexed integer array vals of length n where vals[i] denotes the value of the i<sup>th</sup> node. You are also given a 2D integer array edges where edges[i] = [a<sub>i</sub>, b<sub>i</sub>] denotes that there exists an undirected edge connecting nodes a<sub>i</sub> and b<sub>i</sub>.

A good path is a simple path that satisfies the following conditions:

  1. The starting node and the ending node have the same value.
  2. All nodes between the starting node and the ending node have values less than or equal to the starting node (i.e. the starting node's value should be the maximum value along the path).

Return the number of distinct good paths.

Note that a path and its reverse are counted as the same path. For example, 0 -> 1 is considered to be the same as 1 -> 0. A single node is also considered as a valid path.

For example:

vals = [1,3,2,1,3], edges = [[0,1],[0,2],[2,3],[2,4]]

The answer would be 6. There are 5 good paths consisting of a single node and 1 additional good path: 1 -> 0 -> 2 -> 4.

As another example:

vals = [1,1,2,2,3], edges = [[0,1],[1,2],[2,3],[2,4]]

The answer would be 7. There are 5 good paths consisting of a single node and 2 additional good paths: 0 -> 1 and 2 -> 3.

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 values within the `vals` array, and can they be negative or zero?
  2. Can there be multiple paths between two nodes, and if so, do we consider them distinct 'good paths'?
  3. If there are no 'good paths' in the graph, what should the function return (e.g., 0)?
  4. Are the nodes labeled 0 to n-1, or is there a possibility of gaps in the node numbering?
  5. Are the edges undirected, and is the graph guaranteed to be connected?

Brute Force Solution

Approach

The brute force method for finding good paths involves checking every single possible path between each pair of locations. We explore all ways to get from one location to another. The key is that we only consider paths where the highest value we encounter on the path is equal to the values of the start and end locations.

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

  1. For every pair of locations, consider it as a potential start and end point of a path.
  2. For each pair, find all possible routes between those two locations.
  3. For each route, check the highest value found along that specific path.
  4. If the highest value on that path matches the value of both the start and end locations, then that route is considered a 'good path'.
  5. Count each of these 'good paths' that you find.
  6. After checking all pairs of locations and all their possible routes, report the total count of 'good paths'.

Code Implementation

def number_of_good_paths_brute_force(values, edges):
    number_of_locations = len(values)
    adjacency_list = [[] for _ in range(number_of_locations)]
    for start_node, end_node in edges:
        adjacency_list[start_node].append(end_node)
        adjacency_list[end_node].append(start_node)

    good_path_count = 0

    for start_location in range(number_of_locations):
        for end_location in range(start_location, number_of_locations):
            # Consider each pair of locations as potential start and end points
            def find_all_paths(current_location, end_location, current_path):
                current_path.append(current_location)

                if current_location == end_location:
                    yield current_path
                else:
                    for neighbor in adjacency_list[current_location]:
                        if neighbor not in current_path:
                            yield from find_all_paths(neighbor, end_location, current_path[:])

            for path in find_all_paths(start_location, end_location, []):
                # Check if the current path is a 'good path'
                maximum_value_on_path = max(values[location] for location in path)

                if maximum_value_on_path == values[start_location] == values[end_location]:
                    # Increment counter since we have a good path
                    good_path_count += 1

    return good_path_count

Big(O) Analysis

Time Complexity
O(n^(n+1))The brute force approach iterates through all possible pairs of nodes, which contributes O(n^2) where n is the number of nodes. For each pair, we find all possible paths between them. In the worst-case scenario, with edges connecting every node, the number of possible paths between two nodes can grow exponentially with the number of nodes, potentially reaching O(n^n). For each path, we iterate through it to find the maximum value, which takes O(n) time in the worst case. Therefore, the overall time complexity becomes O(n^2 * n^n * n) which can be simplified to O(n^(n+1)).
Space Complexity
O(N^N)The brute force method considers all possible routes between every pair of locations. In the worst-case scenario, the number of paths between two nodes in a graph with N nodes can be exponential, potentially exploring every possible combination of nodes (up to N^N if we allow cycles). Keeping track of each route during exploration consumes memory proportional to the length of the path, and since we are exploring all possible routes, this results in significant auxiliary space. The intermediate results of the routes being explored and the recursive calls can lead to a maximum depth proportional to N. Therefore, the space complexity is O(N^N).

Optimal Solution

Approach

The problem asks us to find 'good paths' in a graph based on node values. The most efficient solution cleverly groups nodes with similar values and then connects these groups to count the paths.

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

  1. First, think of each unique value as a club and assign each node to its club based on its value.
  2. Then, process the values from smallest to largest. When processing a value, consider only nodes with that value or smaller.
  3. For each edge in the graph, if both nodes connected by the edge belong to the current value's club (or a club with a smaller value), merge their respective sub-clubs together.
  4. For each value, after merging sub-clubs of all nodes with this value, the number of 'good paths' starting and ending with this value is calculated by counting the number of nodes belonging to the same sub-club within the current value's club. Essentially we need to count the pairs in each sub-club.
  5. Sum up the number of 'good paths' calculated for each unique value to get the final result.

Code Implementation

def number_of_good_paths(values, edges):
    number_of_nodes = len(values)
    parent = list(range(number_of_nodes))
    node_values = sorted([(values[i], i) for i in range(number_of_nodes)])
    adjacency_list = [[] for _ in range(number_of_nodes)]

    for node_a, node_b in edges:
        adjacency_list[node_a].append(node_b)
        adjacency_list[node_b].append(node_a)

    def find(node):
        if parent[node] != node:
            parent[node] = find(parent[node])
        return parent[node]

    def union(node_a, node_b):
        root_a = find(node_a)
        root_b = find(node_b)
        if root_a != root_b:
            parent[root_a] = root_b

    good_paths_count = 0

    # Initially, each node is a good path of length 1
    for _ in range(number_of_nodes):
        good_paths_count += 1

    node_index = 0
    while node_index < number_of_nodes:
        current_value = node_values[node_index][0]
        current_nodes = []

        # Group all nodes with the same value.
        while node_index < number_of_nodes and node_values[node_index][0] == current_value:
            current_nodes.append(node_values[node_index][1])
            node_index += 1

        # Iterate through neighbors and union if neighbor's value <= current value.
        for node in current_nodes:
            for neighbor in adjacency_list[node]:
                if values[neighbor] <= current_value:
                    union(node, neighbor)

        # Count paths within the current value's club.
        group_counts = {}
        for node in current_nodes:
            root = find(node)
            if root not in group_counts:
                group_counts[root] = 0
            group_counts[root] += 1

        # Calculate number of good paths based on group sizes.
        for count in group_counts.values():
            good_paths_count += count * (count - 1) // 2

    return good_paths_count

Big(O) Analysis

Time Complexity
O(n log n + e alpha(n))The algorithm sorts the unique node values, which takes O(n log n) time where n is the number of nodes. The union-find operations contribute to the time complexity. For each edge e, we potentially perform union operations. With path compression and union by rank, each union-find operation has an amortized time complexity of alpha(n), where alpha(n) is the inverse Ackermann function, which grows extremely slowly and can be considered almost constant for practical input sizes. Thus, merging sub-clubs contributes O(e alpha(n)). Therefore, the overall time complexity is O(n log n + e alpha(n)).
Space Complexity
O(N)The auxiliary space complexity is primarily determined by the size of the parent array used for the union-find data structure. This parent array stores the parent node for each node in the graph, requiring O(N) space where N is the number of nodes. Additionally, the storage of unique values in a sorted structure like a set or list might take O(N) space in the worst case. Other variables used during processing contribute negligibly to the overall space complexity, thus, the dominant factor is the parent array.

Edge Cases

CaseHow to Handle
Empty values array or edges arrayReturn 0, since no paths exist in an empty graph.
values array with a single node (n=1)Return 1, as the single node forms a valid path.
Graph with all nodes having the same valueThe number of good paths will be the number of connected components, and connections between nodes with the same value must be accounted for.
Graph forms a single chain (linear graph)Ensure the algorithm efficiently traverses the chain without unnecessary computations.
Graph is a complete graph (all nodes connected to each other)The number of edges will be n*(n-1)/2, ensuring the algorithm doesn't exceed time limits due to excessive processing of edges.
Values are very large, leading to potential integer overflow when counting paths.Use a 64-bit integer type (long long in C++, long in Java) to store the path counts.
Edges are duplicated in the inputThe algorithm should handle duplicate edges without affecting the correctness of path counting, perhaps by using a set to represent edges in each component.
Disconnected graph with multiple components.Process each connected component separately and sum the number of good paths from each to arrive at the global solution.