Start learning free

LESSON 496 OF 573 · GRAPHS

Redundant Connection in Go: 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 an array 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 19
    Ctrl + Enter to check

    GO · GRAPHS

    Redundant Connection in Go, 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 add one extra edge between two nodes that weren't directly joined. The function findRedundantConnection receives the resulting edges, a [][]int of length n where each element is a pair of nodes. It returns an edge that can be removed so the remaining n - 1 edges form a tree again. If several edges would work, it returns the one that comes last in edges.

    With edges = [[1 2] [1 3] [2 3]] the three edges form the cycle 1, 2, 3, and removing any one leaves a tree. The last of them is [2 3]. With [[1 2] [2 3] [3 4] [1 4] [1 5]] the cycle is 1, 2, 3, 4, and the answer is [1 4]: the edge [1 5] comes later in the input, but removing it would cut node 5 off. With [[1 2] [3 4] [2 3] [4 5] [1 5]] all five edges form one cycle, and the answer is [1 5].

    The statement asks for about O(n·α(n)) time, where α is the inverse Ackermann function, which stays below 5 for any input a computer can hold, and O(n) extra space.

    Approach: union-find, edge by edge

    A slow idea tries the edges from the last one back, removes each and checks with a depth-first search whether the rest is still a connected tree. That costs O(n²).

    The faster idea adds the edges one at a time, in the given order, and keeps track of which nodes are connected with union-find. Each group of connected nodes is stored as a small tree: every node points to a parent, and the root, which is its own parent, names the group. An edge whose two ends already have the same root closes a cycle.

    The graph has exactly one cycle. Every edge on it can be removed, and an edge that is not on it cannot, because removing it would cut the tree in two. The cycle closes when its last edge arrives, because all of its other edges came before. So the first edge that joins two nodes already in the same group is the answer.

    Tracing the third example

    Running the algorithm on [[1 2] [3 4] [2 3] [4 5] [1 5]] and printing the roots of both ends before each merge gives:

    [1 2] roots 1 2
    [3 4] roots 3 4
    [2 3] roots 1 3
    [4 5] roots 1 5
    [1 5] roots 1 1

    The edge [2 3] joins the groups of 1 and 3, which are different, so it merges them. For [1 5] both roots are 1: the nodes are already connected, so this edge closes the cycle and is returned.

    Step by step

    1. Store len(edges) in n. A tree of n nodes has n - 1 edges, plus the extra one gives n, so it is also the number of nodes.
    2. Create parent with make, n + 1 ints. Nodes start at 1, so node i sits at index i and index 0 is never used.
    3. Create size the same way. size[r] counts the nodes in the group whose root is r.
    4. Initialise every node in a loop from 0 to n: set parent[i] = i, so each node is the root of a group of one, and size[i] = 1, since make filled the sizes with zeros.
    5. Go over edges in order with range.
    6. Find both roots: rootA for edge[0] and rootB for edge[1], with the helper find.
    7. Detect the cycle. If rootA == rootB, the two ends are already connected, so return edge.
    8. Union by size. If size[rootA] < size[rootB], swap them with rootA, rootB = rootB, rootA, so rootA is the root of the larger group.
    9. Merge: parent[rootB] = rootA hangs the smaller tree under the larger one.
    10. Update the size: size[rootA] += size[rootB].
    11. Return []int{} after the loop. Since the input always has an extra edge, the loop returns first, and this line is only there because the function must return a value on every path.
    12. Write find. While parent[x] != x, set parent[x] = parent[parent[x]] and then x = parent[x], and return x at the end. This is path halving.

    The solution in Go

    func findRedundantConnection(edges [][]int) []int {
        n := len(edges)
        parent := make([]int, n + 1)
        size := make([]int, n + 1)
        for i := 0; i <= n; i++ {
            parent[i] = i
            size[i] = 1
        }
        for _, edge := range edges {
            rootA := find(parent, edge[0])
            rootB := 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 []int{}
    }
    
    func find(parent []int, x int) int {
        for parent[x] != x {
            parent[x] = parent[parent[x]]
            x = parent[x]
        }
        return x
    }

    The Go details worth a closer look:

    • Slices are shared. find receives the slice header by value, but it points at the same array, so the writes to parent inside find are seen by findRedundantConnection. Reassigning x, on the other hand, changes only find's own copy of the number.
    • A for with only a condition. Go has no while. for parent[x] != x walks up to the root with no recursion, however long the chain is.
    • Swapping in one line. rootA, rootB = rootB, rootA evaluates the right side first and then assigns both, so no temporary variable is needed.
    • The returned edge. return edge returns the same two-element slice that was in the input, not a copy.
    • Path halving and union by size. Halving makes each node on the walk skip a level, so later walks are shorter. Union by size keeps trees shallow because a node only moves one level deeper when its group at least doubles. Together they give the O(α(n)) amortized cost per call.

    Complexity

    Time: O(n·α(n)), nearly O(n). Each of the n edges costs two calls to find, each O(α(n)) amortized, plus constant work for the merge.

    Space: O(n) for the parent and size slices.

    Edge cases and common mistakes

    • Returning the last edge of the input. That is wrong whenever the last edge isn't on the cycle. For [[1 2] [2 3] [3 4] [1 4] [1 5]] it would give [1 5], but the answer is [1 4].
    • The extra edge comes early. For [[1 2] [2 3] [1 3] [3 4] [4 5]] the function returns [1 3], even though two more edges follow it.
    • A cycle through a hub. For [[1 2] [1 3] [1 4] [2 4]] the answer is [2 4], and for [[1 2] [1 3] [1 4] [1 5] [4 5]] it is [4 5].
    • The longest cycle. A chain from 1 to 1000 followed by [1 1000] returns [1 1000]. The search stays fast and never recurses.
    • Slices of size n. Node numbers go up to n, so parent needs n + 1 entries. With n entries and the first example, the program stops with index out of range [3] with length 3.
    • Comparing the nodes, not the roots. Two nodes are connected exactly when find gives them the same root. Comparing edge[0] with edge[1] directly never detects a cycle.

    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] [3 4] [4 5]] gives [1 3].
    • [[1 2] [1 3] [1 4] [2 4]] gives [2 4].
    Keep going with Go500+ step-by-step lessons, from your first line of code to coding interviews. Free to start.
    Start learning free