View on NeetCode
View on LeetCode

Problem

In this problem, a tree is an undirected graph that is connected and has no cycles.

You are given a graph that started as a tree with n nodes labeled from 1 to n, with one additional edge added. The added edge has two different vertices chosen from 1 to n, and was not an edge that already existed. The graph is represented as an array edges of length n where edges[i] = [ai, bi] indicates that there is an edge between nodes ai and bi in the graph.

Return an edge that can be removed so that the resulting graph is a tree of n nodes. If there are multiple answers, return the answer that occurs last in the input.

Example 1:

Input: edges = [[1,2],[1,3],[2,3]]
Output: [2,3]

Example 2:

Input: edges = [[1,2],[2,3],[3,4],[1,4],[1,5]]
Output: [1,4]

Constraints:

  • n == edges.length
  • 3 <= n <= 1000
  • edges[i].length == 2
  • 1 <= ai < bi <= edges.length
  • ai != bi
  • There are no repeated edges.
  • The given graph is connected.

Solution

Approach 1: Union-Find (Disjoint Set Union)

The key insight is that the redundant edge connects two nodes that are already connected. We use Union-Find to track connected components.

Implementation

class Solution:
    def findRedundantConnection(self, edges: List[List[int]]) -> List[int]:
        n = len(edges)
        parent = list(range(n + 1))  # parent[i] = parent of node i

        def find(x):
            if parent[x] != x:
                parent[x] = find(parent[x])  # Path compression
            return parent[x]

        def union(x, y):
            root_x = find(x)
            root_y = find(y)

            if root_x == root_y:
                return False  # Already in same set, edge is redundant

            parent[root_x] = root_y
            return True

        for u, v in edges:
            if not union(u, v):
                return [u, v]

Approach 2: DFS Cycle Detection

class Solution:
    def findRedundantConnection(self, edges: List[List[int]]) -> List[int]:
        n = len(edges)
        graph = [[] for _ in range(n + 1)]

        def has_cycle(u, v):
            """Check if there's already a path from u to v"""
            visited = set()

            def dfs(node, target):
                if node == target:
                    return True
                visited.add(node)

                for neighbor in graph[node]:
                    if neighbor not in visited:
                        if dfs(neighbor, target):
                            return True
                return False

            return dfs(u, v)

        for u, v in edges:
            if has_cycle(u, v):
                return [u, v]

            # Add edge to graph
            graph[u].append(v)
            graph[v].append(u)

Complexity Analysis

Union-Find Approach:

  • Time Complexity: O(n * α(n)), where α is the inverse Ackermann function (practically constant). With path compression, nearly O(n).
  • Space Complexity: O(n) for the parent array.

DFS Approach:

  • Time Complexity: O(n²), as we may perform DFS for each edge, and DFS is O(n).
  • Space Complexity: O(n) for the graph and visited set.

Key Insights

  1. Tree Property: A tree with n nodes has exactly n-1 edges. The nth edge creates a cycle.

  2. Union-Find Efficiency: Union-Find is ideal for this problem as it efficiently tracks connected components and detects when we’re connecting already-connected nodes.

  3. Path Compression: The find function uses path compression to optimize future lookups.

  4. Last Occurrence: By processing edges in order and returning the first redundant edge found, we automatically return the last one in the input.

  5. Alternative Detection: DFS can detect cycles by checking if a path already exists between nodes before adding an edge.

  6. Undirected Graph: Since the graph is undirected, we add edges in both directions in the DFS approach.