Start learning free

LESSON 474 OF 540 · GRAPHS

Graph Valid Tree in Scala: Union-Find with Union by Size

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

You have a graph of n nodes, numbered from 0 to n - 1, and an array edges where edges[i] = [ai, bi] means that there is an undirected edge between nodes ai and bi.

The graph is a tree when it is connected (you can get from any node to any other by following edges) and has no cycle (no path of edges leads from a node back to itself without using an edge twice). Put another way, there is exactly one path between any two nodes.

Return true if the edges make the graph a valid tree, or false otherwise.

 

Example 1:

Input: n = 5, edges = [[0, 1], [0, 2], [0, 3], [1, 4]]
Output: true
Explanation: Every node can be reached from node 0, and no edge closes a cycle:
        0
      / | \
     1  2  3
     |
     4

Example 2:

Input: n = 5, edges = [[0, 1], [1, 2], [2, 3], [1, 3], [1, 4]]
Output: false
Explanation: The edges 1 - 2, 2 - 3 and 1 - 3 form a cycle, so there are two paths from 1 to 3.

Example 3:

Input: n = 4, edges = [[0, 1], [1, 2], [2, 0]]
Output: false
Explanation: There are 3 edges for 4 nodes, the number a tree of 4 nodes has, but the edges
0 - 1, 1 - 2 and 2 - 0 form a cycle, and node 3 has no edge at all. Counting the edges is not enough.

 

Constraints:

  • 1 <= n <= 105
  • 0 <= edges.length <= 105
  • edges[i].length == 2
  • 0 <= ai, bi < n
  • ai != bi
  • There are no repeated edges.

Note:

Your algorithm should run in O((n + e)·α(n)) time, nearly O(n + e), where e is the number of edges, and use O(n) extra space.

A tree with n nodes has exactly n - 1 edges. With that many edges, the graph is a tree exactly when no edge closes a cycle, because then every edge joins two separate groups and the n single nodes end up as one group. The tutorial checks the count first, then adds the edges one by one with union-find, as in Number of Connected Components in an Undirected Graph: an edge whose two nodes are already in the same group closes a cycle. With path halving and union by size, each step costs O(α(n)) on average, where α is the inverse Ackermann function, below 5 for any n a computer can hold.

A DFS or BFS from node 0 that checks that every node is reached also works in O(n + e) once the count is right; on a graph this big, use a BFS or a DFS with its own stack, since a recursive DFS down a path of 105 nodes can run out of call stack.

STEP 1 OF 19
    Ctrl + Enter to check

    SCALA · GRAPHS

    Graph Valid Tree in Scala, explained

    Problem overview

    The method validTree(n: Int, edges: Array[Array[Int]]): Boolean gets a graph of n nodes, numbered from 0 to n - 1, and a list of undirected edges. It returns true when the graph is a tree: connected, so every node can reach every other, and free of cycles. Put another way, there is exactly one path between any two nodes.

    With n = 5 and edges [0, 1], [0, 2], [0, 3], [1, 4] the answer is true: every node hangs off node 0 and no edge closes a loop. With edges [0, 1], [1, 2], [2, 3], [1, 3], [1, 4] the answer is false, because the edges 1-2, 2-3 and 1-3 form a cycle.

    The third example is a warning: n = 4 with edges [0, 1], [1, 2], [2, 0] has three edges, the number a tree of 4 nodes needs, and still the answer is false. The edges form a cycle and node 3 has no edge at all. Counting edges is necessary but not sufficient.

    Approach: count the edges, then look for a cycle

    A tree with n nodes has exactly n - 1 edges. With fewer, some node is cut off from the rest. With more, some edge closes a cycle. So the first test is the count: any other number of edges returns false at once.

    With exactly n - 1 edges, the graph is a tree exactly when no edge closes a cycle. If every one of the n - 1 edges joins two separate groups, the n single nodes we start with end up as one group, so the graph is connected as well.

    That suggests union-find. It keeps each group of connected nodes as a small tree of its own: every node points to a parent, and the root, the one node that is its own parent, names the group. To process an edge, find the roots of both ends. If the roots are equal, the ends are already connected through other edges, and this edge would add a second path between them: a cycle. If the roots differ, merge the groups by hanging one root under the other.

    Two refinements keep the trees shallow. Union by size always hangs the smaller group under the larger one. Path halving makes each node visited during a search skip one level. Together they make each step cost O(α(n)), where α is the inverse Ackermann function, below 5 for any size a computer can hold.

    Here is the state after each edge for the first example, n = 5, as parent and size arrays:

    start parent=[0,1,2,3,4] size=[1,1,1,1,1]
    edge 0-1: parent=[0,0,2,3,4] size=[2,1,1,1,1]
    edge 0-2: parent=[0,0,0,3,4] size=[3,1,1,1,1]
    edge 0-3: parent=[0,0,0,0,4] size=[4,1,1,1,1]
    edge 1-4: parent=[0,0,0,0,0] size=[5,1,1,1,1]
    

    The second example reaches the edge 1-3 after three merges, when nodes 1 and 3 already lead to root 0:

    start parent=[0,1,2,3,4] size=[1,1,1,1,1]
    edge 0-1: parent=[0,0,2,3,4] size=[2,1,1,1,1]
    edge 1-2: parent=[0,0,0,3,4] size=[3,1,1,1,1]
    edge 2-3: parent=[0,0,0,0,4] size=[4,1,1,1,1]
    edge 1-3: roots both 0 -> cycle
    

    Step by step

    1. Check the edge count. If edges.length != n - 1, return false.
    2. Create parent with Array.tabulate(n)(i => i). Element i is i, so every node starts as the root of a group of one.
    3. Create size with Array.fill(n)(1). size(r) counts the nodes in the group whose root is r; only roots' entries are kept up to date.
    4. Loop over the edges with edge <- edges.
    5. Find the root of edge(0) and keep it in rootA, a var, because the size step may swap it. The helper find is written at the end.
    6. Find the root of edge(1) the same way, in rootB.
    7. If rootA == rootB, return false. Both ends already lead to the same root, so this edge adds a second path between them.
    8. Test size(rootA) < size(rootB). If the group of rootA is the smaller one, swap the roots so that rootA is always the root of the larger group.
    9. Swap through a temporary: save rootA in temp, copy rootB into rootA, then put temp into rootB.
    10. Merge: parent(rootB) = rootA hangs the smaller tree under the larger one's root.
    11. Update the size: size(rootA) += size(rootB). The size of rootB is never read again, because it is no longer a root.
    12. Return true after the loop: no edge closed a cycle.
    13. Write find(parent: Array[Int], node: Int): Int. It copies node into a var x, because a parameter cannot be reassigned.
    14. Loop while parent(x) != x, that is, while x is not a root. Set parent(x) to parent(parent(x)) (path halving), then set x to parent(x).
    15. Return x, the root.

    The solution in Scala

    class Solution {
        def validTree(n: Int, edges: Array[Array[Int]]): Boolean = {
            if (edges.length != n - 1) {
                return false
            }
            val parent = Array.tabulate(n)(i => i)
            val size = Array.fill(n)(1)
            for (edge <- edges) {
                var rootA = find(parent, edge(0))
                var rootB = find(parent, edge(1))
                if (rootA == rootB) {
                    return false
                }
                if (size(rootA) < size(rootB)) {
                    val temp = rootA
                    rootA = rootB
                    rootB = temp
                }
                parent(rootB) = rootA
                size(rootA) += size(rootB)
            }
            true
        }
    
        def find(parent: Array[Int], node: Int): Int = {
            var x = node
            while (parent(x) != x) {
                parent(x) = parent(parent(x))
                x = parent(x)
            }
            x
        }
    }
    

    The Scala details worth a closer look:

    • Array.tabulate(n)(i => i) builds an array of n elements where element i is the function's result for i. Here the function just returns its argument. Array.fill(n)(1) gives every element the same value instead.
    • Arrays are shared, not copied. find receives the array itself, so when it changes parent through path halving, validTree sees the change. That is why find can write to the array and return only the root.
    • size(rootA) += size(rootB) is shorthand for size(rootA) = size(rootA) + size(rootB) on an array element.
    • var for the roots. rootA and rootB get new values in the swap, so they are vars. temp never changes, so it is a val.
    • return inside for. The last expression of a Scala method is its value, so true and x need no return. Early exits do. The first return false sits outside the loop. The second one, inside the for body, is a non-local return, because the body of a for is a function. Scala 3.5 compiles it, and it works, but the compiler warns that non-local returns are no longer supported.
    • The loop in find avoids recursion. A chain of 100,000 nodes would be a problem for a recursive version; while needs no extra stack. A path of 100,000 nodes, with edges [0, 1], [1, 2] and so on, returns true.

    Complexity

    Let e be the number of edges. After the count check, e = n - 1.

    Time: O((n + e) · α(n)), nearly O(n + e). Creating the arrays costs O(n), and each edge costs two find calls and a merge, each close to constant time thanks to union by size and path halving.

    Space: O(n). The two arrays hold one entry per node.

    A DFS or BFS from node 0 that checks every node was reached also works once the edge count is right. On a graph this size it needs its own stack or queue rather than recursion.

    Edge cases and common mistakes

    • One node, no edges. n = 1 with no edges is a tree: the count 0 equals n - 1, the loop does nothing and the method returns true.
    • Two nodes, no edges. n = 2 with no edges gives false: the count is 0 instead of 1.
    • Disconnected graphs. n = 4 with edges [0, 1], [2, 3] gives false from the count alone: 2 edges, and a tree of 4 nodes has 3. With the right count, a disconnected graph must contain a cycle, which the root comparison finds, as in the third example.
    • Edge direction. The graph is undirected, so [1, 0] means the same as [0, 1]: n = 2 with [1, 0] gives true.
    • Counting edges only. The third example shows why: the right number of edges does not make a tree.
    • Comparing parents instead of roots. Two nodes can have different parents and still lie in the same group. Compare roots, found with find.
    • Merging without finding roots. Setting parent(edge(1)) = edge(0) directly would detach part of an existing group.

    Testing

    • n = 5, edges [0, 1], [0, 2], [0, 3], [1, 4] gives true.
    • n = 5, edges [0, 1], [1, 2], [2, 3], [1, 3], [1, 4] gives false.
    • n = 4, edges [0, 1], [1, 2], [2, 0] gives false.
    • n = 1, no edges, gives true.
    • n = 2, no edges, gives false.
    • n = 2, edges [1, 0], gives true.
    • n = 4, edges [0, 1], [2, 3] gives false.
    • n = 4, edges [0, 1], [1, 2], [2, 3] gives true.
    Keep going with Scala500+ step-by-step lessons, from your first line of code to coding interviews. Free to start.
    Start learning free