Problem overview
A tree is a connected graph with no cycle. You get a tree of n nodes, numbered from 1 to n, with one extra edge added between two nodes that were not joined before. The graph is given as edges, an Array[Array[Int]] of length n where each element is a pair [a, b]. The method findRedundantConnection returns an edge that can be removed so that the other n - 1 edges form a tree. When several edges would work, it returns the one that comes last in edges.
In [[1,2],[2,3],[3,4],[1,4],[1,5]] the cycle is 1 - 2 - 3 - 4 - 1. Removing any of its four edges leaves a tree, and [1,4] comes last among them. The edge [1,5] comes later in the input, but it is not on the cycle: removing it would cut node 5 off. The answer is [1,4].
Approach: add the edges one by one with union-find
Trying every edge from the last one back, removing it and checking with a depth-first search whether the rest is still connected, costs O(n^2). A better way is to build the graph edge by edge, in the given order, and watch for the first edge whose two ends are already connected.
That edge closes a cycle. The graph has exactly one cycle, and all the other edges of the cycle came before the edge that closes it, so it is the cycle's last edge in the input. That is the answer, even when edges that are not on the cycle come after it.
Union-find answers "are these two nodes already connected?" quickly. It keeps each group of connected nodes as a tree of parent pointers. The root, the only node that is its own parent, names the group. Two nodes are in the same group exactly when they lead to the same root. Two refinements keep the trees shallow: union by size attaches the smaller tree under the larger one, and path halving shortens paths while walking up. Together they make each operation nearly constant time, O(alpha(n)) amortized, where alpha is the inverse Ackermann function.
Step by step
- Store
nasedges.length. A tree ofnnodes hasn - 1edges and the extra one makesn, so the edge count is also the node count. - Create
parentwithArray.tabulate(n + 1)(i => i): every node starts as its own root. Nodes are numbered from 1, so the arrays haven + 1entries and index 0 is unused. - Create
sizewithArray.fill(n + 1)(1). For a rootr,size(r)is the number of nodes in its group. - Loop over the edges in the given order. The order decides which edge of the cycle comes back.
- Find the root of
edge(0)withfind, stored in avarcalledrootA, because it may be swapped later. - Find the root of
edge(1)the same way, asrootB. - Compare the roots. If
rootA == rootB, the two ends are already connected through earlier edges, so this edge closes the cycle: returnedge. - Union by size. If
size(rootA) < size(rootB), swap the two roots, so thatrootAis the root of the larger group. - Save
rootAintempbefore overwriting it. - Copy and restore:
rootA = rootB, thenrootB = temp. - Merge:
parent(rootB) = rootAhangs the smaller tree under the larger one. - Update the size:
size(rootA) += size(rootB). - Return an empty array after the loop,
Array[Int](). The input always has an extra edge, so the loop returns first; the line exists because the method must produce a value on every path. - Write
find, takingparentandnodeand returning anInt. - Copy
nodeintovar x, because parameters can't be reassigned. - Loop while
parent(x) != x: a node that is not its own parent is not a root yet. - Halve the path:
parent(x) = parent(parent(x))makesxpoint to its grandparent. - Move up:
x = parent(x), so each turn climbs two levels. - Return
x, the root.
The solution in Scala
class Solution {
def findRedundantConnection(edges: Array[Array[Int]]): Array[Int] = {
val n = edges.length
val parent = Array.tabulate(n + 1)(i => i)
val size = Array.fill(n + 1)(1)
for (edge <- edges) {
var rootA = find(parent, edge(0))
var rootB = find(parent, edge(1))
if (rootA == rootB) {
return edge
}
if (size(rootA) < size(rootB)) {
val temp = rootA
rootA = rootB
rootB = temp
}
parent(rootB) = rootA
size(rootA) += size(rootB)
}
Array[Int]()
}
def find(parent: Array[Int], node: Int): Int = {
var x = node
while (parent(x) != x) {
parent(x) = parent(parent(x))
x = parent(x)
}
x
}
}
Scala details worth a look:
- Arrays are shared, not copied.
findreceives the sameparentarray, so the path halving it does is visible to the caller. valversusvar.rootAandrootBarevars because they are swapped.parentandsizearevals: the reference never changes, although the array's contents do.- The swap needs a temporary. Writing
rootA = rootBfirst would lose the old value ofrootA. size(rootA) += size(rootB)is shorthand for reading, adding and storing back into the array element.return edgeinside thefor. Aforover an array is a call toforeachwith a lambda, so this return leaves the whole method from inside that lambda. Scala 3.5 accepts it and the method works, but the compiler prints a warning: "Non local returns are no longer supported; useboundaryandboundary.breakinscala.utilinstead".- The last expression is the value.
xalone on the last line offindis what it returns.
A trace
For edges = [[1,2],[3,4],[2,3],[4,5],[1,5]], this is the state after each edge. The lists show parent and size for the nodes 1 to 5:
| Edge | Roots | parent | size |
|---|---|---|---|
[1,2] | 1 and 2 | 1, 1, 3, 4, 5 | 2, 1, 1, 1, 1 |
[3,4] | 3 and 4 | 1, 1, 3, 3, 5 | 2, 1, 2, 1, 1 |
[2,3] | 1 and 3 | 1, 1, 1, 3, 5 | 4, 1, 2, 1, 1 |
[4,5] | 1 and 5 | 1, 1, 1, 1, 1 | 5, 1, 2, 1, 1 |
[1,5] | 1 and 1 | same root: return [1,5] | |
When [2,3] arrives, nodes 2 and 3 are both known, but they are in different groups, the one with root 1 and the one with root 3, so the edge joins them. The last edge finds both nodes under root 1.
Complexity
Time: O(n * alpha(n)), nearly O(n). Each of the n edges costs two calls to find, each O(alpha(n)) amortized, plus constant work for the union.
Space: O(n) for the parent and size arrays.
Edge cases and common mistakes
- The cycle is closed before the end. For
[[1,2],[2,3],[1,3],[3,4]]the method returns[1,3]. The edge[3,4]comes after it, but it is not on the cycle. - Returning the last edge of the input. That is wrong whenever the last edge is not on the cycle, as in the previous case or in the example with node 5.
- Index 0. With nodes numbered from 1, arrays of size
nwould be too short for noden. That is why they haven + 1entries. - Comparing the nodes instead of the roots. Two nodes in the same group are not equal; the test must compare the results of
find. - Skipping the swap. Attaching the larger tree under the smaller one still gives correct answers, but the trees can grow deep and lose the near-constant cost.
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]]gives[1,3].
