Start learning free

LESSON 492 OF 569 · GRAPHS

Redundant Connection in Python: Union-Find in O(n·α(n))

Practice O(n · α(n)) Read the full explanation

A tree is an undirected graph that is connected (you can get from any node to any other by following edges) and has no cycle. Put another way, there is exactly one path between any two nodes.

You start with a tree of n nodes, numbered from 1 to n, and one extra edge is added to it. The extra edge joins two different nodes that were not joined by an edge before. The graph you get is given as a list edges of length n, where edges[i] = [ai, bi] means that there is an edge between nodes ai and bi.

Return an edge that can be removed so that the other n - 1 edges form a tree of all n nodes. If several edges work, return the one that comes last in edges.

 

Example 1:

Input: edges = [[1, 2], [1, 3], [2, 3]]
Output: [2, 3]
Explanation: The three edges form the cycle 1 - 2 - 3 - 1:
      1
     / \
    2 - 3
Removing any one of them leaves a tree. [2, 3] is the one that comes last.

Example 2:

Input: edges = [[1, 2], [2, 3], [3, 4], [1, 4], [1, 5]]
Output: [1, 4]
Explanation: The cycle is 1 - 2 - 3 - 4 - 1:
    5 - 1 - 2
        |   |
        4 - 3
[1, 5] comes after [1, 4] in the input, but removing it would cut node 5 off from the others.
Of the four edges on the cycle, [1, 4] comes last.

Example 3:

Input: edges = [[1, 2], [3, 4], [2, 3], [4, 5], [1, 5]]
Output: [1, 5]
Explanation: When [2, 3] comes, nodes 2 and 3 have both been seen already, but in two
separate groups, {1, 2} and {3, 4}: the edge joins them. All five edges form the cycle
1 - 2 - 3 - 4 - 5 - 1, and [1, 5] comes last.

 

Constraints:

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

Note:

Your algorithm should run in O(n·α(n)) time, nearly O(n), and use O(n) extra space.

Trying each edge from the last one back, removing it and checking with a DFS whether the rest is still connected, costs O(n2). The tutorial adds the edges one by one, in the order they are given, with union-find, as in Graph Valid Tree: an edge whose two nodes are already in the same group closes a cycle. The graph has exactly one cycle, and removing any of its edges leaves a tree, while removing an edge that is not on it cuts the tree in two. The cycle closes when the last of its edges comes, because all its other edges came before, so the first edge that closes a cycle is the answer. With path halving and union by size, each step costs O(α(n)) amortized, where α is the inverse Ackermann function, below 5 for any n a computer can hold.

STEP 1 OF 16
    Ctrl + Enter to check

    PYTHON · GRAPHS

    Redundant Connection in Python, explained

    Problem overview

    A tree is a connected graph with no cycle. You start with a tree of n nodes numbered 1 to n, and one extra edge is added between two nodes that weren't directly joined. The function findRedundantConnection receives the resulting edges, a list of n pairs [a, b], and returns an edge whose removal leaves a tree again. When several edges would work, it returns the one that comes last in the list.

    For [[1, 2], [1, 3], [2, 3]] the three edges form the cycle 1 - 2 - 3 - 1. Removing any one of them leaves a tree, and [2, 3] comes last, so that's the answer. For [[1, 2], [2, 3], [3, 4], [1, 4], [1, 5]] the cycle is 1 - 2 - 3 - 4 - 1 and the answer is [1, 4]. The edge [1, 5] comes later in the list, but removing it would cut node 5 off.

    Approach: add edges one by one and detect the first cycle

    One way is to try each edge from the last one back, remove it, and check with a depth-first search whether the rest is still connected. Each check costs O(n), so the total is O(n^2).

    A better idea is to add the edges in the given order and ask, before each one, whether its two nodes are already connected. If they are, this edge closes a cycle. The graph has exactly one cycle, and removing any edge of the cycle leaves a tree, while removing an edge that isn't on it splits the tree in two. The cycle closes when the last of its edges arrives, because all its other edges came earlier. So the first edge whose ends are already connected is the last edge of the cycle, which is the answer.

    The question "are these two nodes connected so far?" is what union-find answers. Each group of connected nodes is stored as a tree: every node points to a parent, and the root, which is its own parent, names the group. Two nodes are in the same group exactly when they lead to the same root. Merging two groups hangs one root under the other. Two refinements keep the trees shallow: union by size (the smaller tree goes under the larger one) and path halving (while climbing, each node skips to its grandparent). Together they make each operation cost O(α(n)) amortized, where α is the inverse Ackermann function, which stays below 5 for any input a computer can hold.

    Here is the run on [[1, 2], [3, 4], [2, 3], [4, 5], [1, 5]]. After each merge, the table shows the parent list (index 0 is unused):

    start parent [0, 1, 2, 3, 4, 5]
    [1, 2] roots 1 2 -> parent [0, 1, 1, 3, 4, 5]
    [3, 4] roots 3 4 -> parent [0, 1, 1, 3, 3, 5]
    [2, 3] roots 1 3 -> parent [0, 1, 1, 1, 3, 5]
    [4, 5] roots 1 5 -> parent [0, 1, 1, 1, 1, 1]
    [1, 5] roots 1 1 -> same group: cycle, return [1, 5]

    The edge [2, 3] joins the groups {1, 2} and {3, 4}, which are different, so it merges them. When [1, 5] arrives, 1 and 5 already share the root 1, so that edge closes the cycle 1 - 2 - 3 - 4 - 5 - 1.

    Step by step

    1. Store the edge count in n. A tree of n nodes has n - 1 edges, and the extra edge makes it n, so the count is also the number of nodes.
    2. Create parent with list(range(n + 1)). Nodes start at 1, so the list has n + 1 entries, index 0 is unused, and every node starts as the root of its own group.
    3. Create size as n + 1 ones. For a root r, size[r] counts the nodes in its group. Only the roots' entries are kept up to date.
    4. Loop over edges in the given order. The order decides which edge of the cycle is returned.
    5. Find rootA for edge[0] with the helper self.find, written at the end.
    6. Find rootB for edge[1] the same way.
    7. Test for the cycle. If rootA == rootB, the ends were already connected through earlier edges, so return edge.
    8. Make rootA the larger group. If size[rootA] < size[rootB], swap the two names with one tuple assignment. This is union by size.
    9. Merge. Set parent[rootB] = rootA, hanging the smaller tree under the larger one's root.
    10. Update the size with size[rootA] += size[rootB].
    11. Return [] after the loop. The input always has an extra edge, so the loop returns first, but the function must return a value on every path.
    12. Write find as a method taking parent and a node x.
    13. Climb while parent[x] != x. The root is the only node that is its own parent. A loop has no call-stack limit, however long the climb.
    14. Halve the path. Set parent[x] to parent[parent[x]], so the node skips a level for later walks.
    15. Move up by setting x = parent[x], which is now the old grandparent.
    16. Return x, the root.

    The solution in Python

    class Solution:
        def findRedundantConnection(self, edges: List[List[int]]) -> List[int]:
            n = len(edges)
            parent = list(range(n + 1))
            size = [1] * (n + 1)
            for edge in edges:
                rootA = self.find(parent, edge[0])
                rootB = self.find(parent, edge[1])
                if rootA == rootB:
                    return edge
                if size[rootA] < size[rootB]:
                    rootA, rootB = rootB, rootA
                parent[rootB] = rootA
                size[rootA] += size[rootB]
            return []
    
        def find(self, parent: List[int], x: int) -> int:
            while parent[x] != x:
                parent[x] = parent[parent[x]]
                x = parent[x]
            return x
    

    The Python details worth a closer look:

    • Swapping with a tuple. rootA, rootB = rootB, rootA evaluates the right side first, so the two names exchange values without a temporary variable.
    • The list is shared. find receives parent as the same list object, so its assignments to parent[x] are visible to the caller. That is how path halving persists between calls.
    • The returned edge is the input's own list. return edge gives back the very list that sits in edges, not a copy. The call on [[1, 2], [1, 3], [2, 3]] returns an object that is the third element of the input.
    • An iterative find. Python's default recursion limit is 1000. The loop doesn't depend on it, which keeps find safe on any shape of tree.

    Complexity

    Time: O(n · α(n)), nearly O(n). Each of the n edges makes two calls to find, each O(α(n)) amortized thanks to union by size and path halving, plus constant work to merge.

    Space: O(n). parent and size have n + 1 entries each.

    Edge cases and common mistakes

    • The smallest input. [[1, 2], [2, 3], [1, 3]] returns [1, 3], the last edge of the triangle.
    • An edge after the cycle. For [[1, 2], [2, 3], [3, 4], [1, 4], [1, 5]] the function returns [1, 4], not the last edge of the input, which is [1, 5]. Removing [1, 5] would disconnect node 5.
    • A star plus one edge. [[1, 2], [1, 3], [1, 4], [3, 4]] returns [3, 4].
    • A node seen before. In the example traced above, [2, 3] doesn't close a cycle even though both nodes have already appeared, because they were in separate groups. Checking whether the nodes have been seen is not enough; the roots must be compared.
    • Comparing parents instead of roots. parent[a] == parent[b] is not the same test: a node can sit several levels below its root.
    • Allocating n entries. Nodes are numbered from 1, so lists of length n make node n an out-of-range index.
    • Skipping union by size and path halving. The answer stays correct, but the trees can grow into long chains and each find into an O(n) walk. On a chain of 1000 edges that ends by connecting 1 and 1000, a plain version that hangs the first root under the second and never shortens paths climbs 999 steps in total before it returns [1, 1000].

    Testing

    • [[1, 2], [1, 3], [2, 3]] gives [2, 3].
    • [[1, 2], [2, 3], [3, 4], [1, 4], [1, 5]] gives [1, 4].
    • [[1, 2], [3, 4], [2, 3], [4, 5], [1, 5]] gives [1, 5].
    • [[1, 2], [2, 3], [1, 3]] gives [1, 3].
    • [[1, 2], [1, 3], [1, 4], [3, 4]] gives [3, 4].
    Keep going with Python500+ step-by-step lessons, from your first line of code to coding interviews. Free to start.
    Start learning free