Problem overview
A tree is a connected graph with no cycle. The input of findRedundantConnection is edges, the edges of a tree of n nodes numbered 1 to n with one extra edge added, so the array holds exactly n edges. Each edge is a pair [a, b] with a < b, there are no repeated edges, and the graph is connected. The method returns an edge that can be removed so the remaining n - 1 edges form a tree again. If 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, and the answer is [1, 4]. The edge [1, 5] comes later in the input, but removing it would cut node 5 off.
The algorithm should run in nearly O(n) time, written O(n·α(n)), and use O(n) extra space.
Approach: add edges one by one and watch for the first cycle
A slow 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. That costs O(n^2).
The faster way adds the edges in the order given and keeps track of which nodes are connected so far. When an edge joins two nodes that are already connected, it closes a cycle. The graph has exactly one cycle, and removing any edge of it leaves a tree, while removing an edge that is not on it cuts the tree in two. All the other edges of the cycle came earlier in the input, so the edge that closes the cycle is the last edge of the cycle, which is the answer.
The test is only "are these two nodes already in the same group?", and union-find answers that quickly. It keeps each group as a tree: every node points to a parent, and the root, the one node that is its own parent, names the group. Two nodes are in the same group when they lead to the same root. To merge two groups, hang one root under the other.
Two tricks keep the trees shallow:
- Union by size. Always hang the smaller tree under the larger one. A node goes one level deeper only when its group at least doubles, so no path to a root has more than about log2(n) steps.
- Path halving. While walking up to the root, point each visited node at its grandparent. Later walks from there take about half the steps.
Together they make each operation O(α(n)) amortized, where α is the inverse Ackermann function, below 5 for any input a computer can hold.
A tempting shortcut is to return the first edge whose two nodes have both appeared in earlier edges. It is wrong: for [[1, 2], [3, 4], [2, 3], [4, 5], [1, 5]] it returns [2, 3]. Nodes 2 and 3 have both been seen, but in two separate groups, {1, 2} and {3, 4}, and the edge joins them. The real cycle is 1 - 2 - 3 - 4 - 5 - 1, and the answer is [1, 5].
Step by step
- Store
nasedges.length. A tree ofnnodes hasn - 1edges, and with the extra one there aren, so it is also the number of nodes. - Create
parentwithArray.from({length: n + 1}, (_, i) => i), so each node starts as its own parent. Nodes start at 1, so the arrays haven + 1entries and index 0 is never used. The_is the unused value, andiis the index. - Create
size,n + 1entries filled with 1. Only the roots' entries are kept up to date. - Loop over
edgesin the given order, because the order decides which edge of the cycle is returned. - Find the root of
edge[0]intorootA, declared withletbecause it may be swapped. - Find the root of
edge[1]intorootB. - Test for the cycle: if
rootA === rootB, the two ends are already connected, so returnedge. - Make
rootAthe larger root. Ifsize[rootA] < size[rootB], swap them with[rootA, rootB] = [rootB, rootA], a destructuring assignment. - Merge: set
parent[rootB]torootA. - Update the size:
size[rootA] += size[rootB]. The size ofrootBis never read again because it is no longer a root. - Return
[]after the loop. The input always contains a cycle, so the loop returns first, but a function with a return type must return on every path. - Write
find(parent, x): loop whileparent[x] !== x. - Halve the path: set
parent[x] = parent[parent[x]], thenx = parent[x]. Each turn climbs two levels. - Return
x, the root.
The solution in TypeScript
class Solution {
findRedundantConnection(edges: number[][]): number[] {
const n = edges.length;
const parent: number[] = Array.from({length: n + 1}, (_, i) => i);
const size: number[] = new Array(n + 1).fill(1);
for (const edge of edges) {
let rootA = this.find(parent, edge[0]);
let rootB = this.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 [];
}
find(parent: number[], x: number): number {
while (parent[x] !== x) {
parent[x] = parent[parent[x]];
x = parent[x];
}
return x;
}
}
TypeScript details:
- Arrays are passed by reference.
findreceives the sameparentarray and writes into it, so the path halving is seen by the caller. Forparent = [0, 0, 1, 2],find(parent, 3)returns 0 and leaves[0, 0, 1, 1]. - Reassigning a parameter.
x = parent[x]changes only the local copy of the number. The caller's value is not affected, because numbers are passed by value. - Swapping with destructuring.
[rootA, rootB] = [rootB, rootA]builds a temporary array and unpacks it. Aftera = 1, b = 2the same line givesa = 2, b = 1. Both variables were declared withlet, so they can be reassigned. - The returned array is the input's own.
return edgehands back the pair fromedges, not a copy. That is fine here, since nothing changes it afterwards. - Erased types. The annotations on
parent,sizeand the signatures are checked by the compiler only.
Complexity
Time: O(n·α(n)). Each of the n edges makes two calls to find and at most one merge, each O(α(n)) amortized thanks to union by size and path halving. That is nearly O(n).
Space: O(n). The parent and size arrays have n + 1 entries each. The loop in find uses no call stack, however long the walk.
Edge cases and common mistakes
- The smallest input.
[[1, 2], [2, 3], [1, 3]]returns[1, 3]: the triangle's last edge. - Edges after the cycle. The answer is the edge that closes the cycle, even when other edges follow it. For
[[1, 3], [3, 4], [1, 5], [3, 5], [2, 3]]the answer is[3, 5], though[2, 3]comes after it. - A cycle in the middle.
[[1, 2], [2, 3], [3, 4], [2, 4], [4, 5], [5, 6]]returns[2, 4]. - Comparing nodes instead of roots. Two nodes can be connected without sharing a parent. Always compare the roots from
find, neverparent[a]andparent[b]. - Checking only whether both nodes were seen. It returns
[2, 3]where[1, 5]is correct, as above. - Arrays too short. The nodes start at 1, so the arrays need
n + 1entries. Withnentries, nodenwould be out of range. - Linking without size. If you always hang the first root under the second, a chain of edges
[1, 2], [2, 3], ..., [999, 1000]followed by[1, 1000]builds a path where one lookup climbs 999 steps. Union by size keeps such chains short.
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], [2, 3], [3, 4], [2, 4], [4, 5], [5, 6]]gives[2, 4].[[1, 3], [3, 4], [1, 5], [3, 5], [2, 3]]gives[3, 5].
