Start learning free

LESSON 491 OF 568 · GRAPHS

Redundant Connection in C++: Union-Find in Nearly O(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 vector 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 18
    Ctrl + Enter to check

    C++ · GRAPHS

    Redundant Connection in C++, explained

    Problem overview

    A tree is a connected, undirected 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 joined before. The method findRedundantConnection receives the result as edges, a vector<vector<int>> with n pairs, and returns an edge whose removal leaves a tree. When several edges work, it returns the one that comes last in edges.

    For [[1,2],[1,3],[2,3]] the three edges form the cycle 1 - 2 - 3 - 1. Removing any of them leaves a tree, and [2,3] comes last, so that is the answer. For [[1,2],[2,3],[3,4],[1,4],[1,5]] the cycle is 1 - 2 - 3 - 4 - 1. The edge [1,5] comes after [1,4], but removing it would cut node 5 off, so it doesn't count. Of the four edges on the cycle, [1,4] comes last, and the answer is [1,4].

    The statement asks for about O(n · alpha(n)) time, where alpha is the inverse Ackermann function, and O(n) extra space.

    Approach: add the edges in order with union-find

    A slow way is to try the edges from the last one back, remove each and check with a search whether the rest is still connected. That costs O(n^2).

    Union-find does better. It keeps the nodes in groups, and each group is a small tree of parent pointers whose root names the group. Adding the edges one by one in the given order, each edge either joins two different groups or has both ends in the same group. If both ends are already in the same group, they were already connected through earlier edges, so this edge closes a cycle.

    The graph has exactly one cycle. Removing any edge of the cycle leaves a tree, while removing an edge that is not on the cycle cuts the tree in two. The cycle closes when the last of its edges arrives, because the rest of its edges came earlier. So the first edge that finds both ends in one group is the cycle's last edge, and it is the answer, even when other edges come after it.

    Two refinements keep each operation nearly constant. Union by size always hangs the smaller tree under the larger one's root, so trees stay shallow. Path halving makes each node visited while looking for a root point to its grandparent, which shortens later searches.

    On the second example the run goes like this:

    EdgeRoots of its endsWhat happens
    [1,2]1 and 2merged; node 1 is the root
    [2,3]1 and 3merged
    [3,4]1 and 4merged
    [1,4]1 and 1same group: returned

    The edge [1,5] is never looked at. In the third example, [[1,2],[3,4],[2,3],[4,5],[1,5]], the edge [2,3] finds the roots 1 and 3: both nodes have been seen, but in two separate groups, so the edge joins them. The edge [1,5] is the one that finds a single root, and it is returned.

    Step by step

    1. Store the edge count in n. A tree of n nodes has n - 1 edges, and the extra edge makes n, so the edge count is also the node count.
    2. Create parent with n + 1 entries. Nodes are numbered from 1, so node i sits at index i and index 0 stays unused.
    3. Create size with n + 1 entries, all 1. For a root r, size[r] counts the nodes in its group.
    4. Loop over the indexes from 0 to n inclusive,
    5. and make every node its own parent. A node that is its own parent is a root, so at the start each node is a group of one.
    6. Go over the edges in the given order with auto& edge. The order decides which edge of the cycle is returned.
    7. Find the root of edge[0] with the helper find and store it in rootA.
    8. Find the root of edge[1] in rootB. Two nodes are in the same group exactly when they lead to the same root.
    9. Return the edge when the roots are equal. Both ends are already connected through earlier edges, so this one closes the cycle.
    10. Make rootA the larger root. If size[rootA] < size[rootB], swap them. This is union by size.
    11. Merge: parent[rootB] = rootA hangs the smaller tree under the larger one's root.
    12. Update the size: size[rootA] += size[rootB]. The size of rootB is never read again.
    13. Return {} after the loop. The input always contains an extra edge, so the loop returns before it ends. The line is there because the method must return a value on every path.
    14. Write find. It takes parent as a vector<int>& and a node x, and returns the root of x's group. The reference is needed: find changes parent, and a copy would be changed instead of the real vector.
    15. Loop while parent[x] != x. The root is the only node that is its own parent. A loop needs no call stack, however long the walk is.
    16. Halve the path: parent[x] = parent[parent[x]] makes x point to its grandparent.
    17. Move up: x = parent[x]. Each turn climbs two levels of the old tree.
    18. Return x, the root.

    The solution in C++

    class Solution {
    public:
        vector<int> findRedundantConnection(vector<vector<int>>& edges) {
            int n = edges.size();
            vector<int> parent(n + 1);
            vector<int> size(n + 1, 1);
            for (int i = 0; i <= n; i++) {
                parent[i] = i;
            }
            for (auto& edge : edges) {
                int rootA = find(parent, edge[0]);
                int rootB = find(parent, edge[1]);
                if (rootA == rootB) {
                    return edge;
                }
                if (size[rootA] < size[rootB]) {
                    swap(rootA, rootB);
                }
                parent[rootB] = rootA;
                size[rootA] += size[rootB];
            }
            return {};
        }
    
        int find(vector<int>& parent, int x) {
            while (parent[x] != x) {
                parent[x] = parent[parent[x]];
                x = parent[x];
            }
            return x;
        }
    };
    

    The C++ details worth a closer look:

    • vector<int> parent(n + 1). This makes n + 1 zeros. The loop then overwrites each with its own index. The second vector, size(n + 1, 1), is filled with 1 directly.
    • auto& edge. The loop variable is a reference to each inner vector, so nothing is copied while looping. return edge then copies that vector into the returned vector<int>.
    • return {}. The empty braces build an empty vector<int>.
    • swap(rootA, rootB). It exchanges the values of the two local variables. It doesn't touch the arrays.
    • find changes its argument parent on purpose. The path halving writes into the real vector, which is why the next calls are faster. Changing x only changes the helper's own copy of the integer.
    • Index range. With n + 1 entries the largest valid index is n. A vector of n entries would make node n an out-of-range access, which is undefined behaviour.

    Complexity

    Time: O(n · alpha(n)), nearly O(n). Each of the n edges costs two calls to find, and with union by size and path halving each call costs O(alpha(n)) amortized. The inverse Ackermann function alpha is below 5 for any n that fits in a computer.

    Space: O(n). The two vectors have n + 1 entries each, and find uses a loop instead of recursion, so it needs no call stack.

    Edge cases and common mistakes

    • Returning the last edge of the input. It is often not on the cycle. For [[1,4],[3,4],[1,3],[1,2],[4,5]] the answer is [1,3], but the last edge is [4,5], and removing it would cut node 5 off.
    • The cycle's edge that comes first in the input. The answer is the cycle's last edge in input order. In example 2 that is [1,4], not [1,2].
    • Edges after the cycle. Edges that come after the one that closes the cycle are never examined, because the method returns at once. That is correct: they cannot change the answer.
    • Same node in different groups. An edge between two nodes that have both been seen is not necessarily a cycle. In the third example [2,3] joins the groups {1,2} and {3,4}. Only equal roots mean a cycle.
    • Comparing the nodes instead of the roots. The test is rootA == rootB. Comparing edge[0] with edge[1] would never be true, since the two ends are different nodes.
    • Forgetting to merge. Without parent[rootB] = rootA no group ever grows, so no edge can ever find two ends in the same group.
    • A long chain. For the chain [1,2], [2,3], ..., [999,1000] followed by [1,1000], the answer is [1,1000]. Union by size keeps the trees shallow even here.

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