Start learning free

LESSON 470 OF 536 · GRAPHS

Graph Valid Tree in JavaScript: Union-Find in Near-Linear Time

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 16
    Ctrl + Enter to check

    JAVASCRIPT · GRAPHS

    Graph Valid Tree in JavaScript, explained

    Problem overview

    The method validTree(n, edges) receives the number of nodes n, numbered 0 to n - 1, and an array edges where each entry [a, b] is an undirected edge. It returns true when the edges make the graph a tree, and false otherwise. A tree is connected, so you can get from any node to any other, and has no cycle. Equivalently, there is exactly one path between any two nodes.

    For n = 5 and edges = [[0, 1], [0, 2], [0, 3], [1, 4]] the answer is true: every node can be reached from node 0 and no edge closes a cycle. For edges = [[0, 1], [1, 2], [2, 3], [1, 3], [1, 4]] it is false, because the edges 1-2, 2-3 and 1-3 form a cycle. The third example, n = 4 with [[0, 1], [1, 2], [2, 0]], has 3 edges, the number a tree of 4 nodes has, but it is still false: the three edges form a cycle and node 3 has no edge at all.

    The lesson asks for time close to O(n + e), where e is the number of edges, and O(n) extra space.

    Approach: count the edges, then look for a cycle

    A tree with n nodes has exactly n - 1 edges. That gives a quick first test: with any other count the answer is false. With fewer edges some node is cut off, and with more some edge must close a cycle.

    The count alone isn't enough, as the third example shows. But with exactly n - 1 edges, the graph is a tree exactly when no edge closes a cycle. The reason: if every edge joins two separate groups of nodes, each edge merges two groups into one, and n single nodes joined by n - 1 merges end up as one group, so the graph is connected.

    Tracking groups is what union-find does. Each group is stored as a small tree in which 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 its two ends. If the roots are equal, the ends are already connected through other edges, so this edge would add a second path between them: a cycle. Otherwise, hang one root under the other.

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

    Running the example n = 5, edges = [[0, 1], [0, 2], [0, 3], [1, 4]], the parent and size arrays after each edge are:

    [0,1] parent [0,0,2,3,4] size [2,1,1,1,1]
    [0,2] parent [0,0,0,3,4] size [3,1,1,1,1]
    [0,3] parent [0,0,0,0,4] size [4,1,1,1,1]
    [1,4] parent [0,0,0,0,0] size [5,1,1,1,1]

    All four edges merged two groups, and at the end every node leads to node 0. For the cycle in the third example, the edges [0, 1] and [1, 2] merge, and the edge [2, 0] finds the same root for both ends: node 0.

    Step by step

    1. Check the edge count. If edges.length !== n - 1, return false right away.
    2. Make every node its own group. Array.from({length: n}, (_, i) => i) calls the arrow function once per index and keeps what it returns, so parent[i] is i. The first parameter is the unused value, named _.
    3. Create size with n ones. size[r] is the number of nodes in the group whose root is r; only roots' entries are kept current.
    4. Loop over the edges with for...of.
    5. Find the root of edge[0] with this.find(parent, edge[0]) and keep it in rootA. It is declared with let because the swap below may change it. The helper is written afterwards.
    6. Find the root of edge[1] the same way, in rootB.
    7. Detect the cycle. If rootA === rootB, the ends are already connected, so return false.
    8. Union by size. If size[rootA] < size[rootB], swap the two roots with [rootA, rootB] = [rootB, rootA], so that rootA is the root of the larger group. A node moves one level deeper only when its group at least doubles, so no path gets longer than about log₂(n) steps.
    9. Merge: parent[rootB] = rootA.
    10. Update the size: size[rootA] += size[rootB]. The entry for rootB is never read again, because it isn't a root any more.
    11. Return true after the loop: no edge closed a cycle, and with n - 1 edges that means one connected group.
    12. Write find(parent, x) as a second method of the class.
    13. Walk up while parent[x] !== x. A loop needs no call stack, however long the walk.
    14. Halve the path: parent[x] = parent[parent[x]] points x to its grandparent.
    15. Move up: x = parent[x], the grandparent it now points to.
    16. Return x, the root.

    The solution in JavaScript

    class Solution {
        validTree(n, edges) {
            if (edges.length !== n - 1) {
                return false;
            }
            const parent = Array.from({length: n}, (_, i) => i);
            const size = new Array(n).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 false;
                }
                if (size[rootA] < size[rootB]) {
                    [rootA, rootB] = [rootB, rootA];
                }
                parent[rootB] = rootA;
                size[rootA] += size[rootB];
            }
            return true;
        }
    
        find(parent, x) {
            while (parent[x] !== x) {
                parent[x] = parent[parent[x]];
                x = parent[x];
            }
            return x;
        }
    }
    

    JavaScript details worth knowing:

    • Array.from({length: n}, fn) builds an array of n elements from an object that only has a length. Each element is what fn(value, index) returns. With n = 3 and (_, i) => i it gives [0, 1, 2], and with () => [] it gives three separate empty arrays.
    • Destructuring swap. [rootA, rootB] = [rootB, rootA] builds a temporary array of the two values and unpacks it in the other order. With x = 1 and y = 2, [x, y] = [y, x] leaves x as 2 and y as 1. Both variables must be declared with let, which is why rootA and rootB are not const.
    • Arrays are passed by reference. When find changes parent, validTree sees the change. That is what makes path halving work. The parameter x, a number, is a copy, so changing it inside find doesn't affect the caller.
    • No recursion. The while loop in find never grows the call stack. A recursive search down a chain of 100,000 nodes could run out of stack; the loop handles it, and validTree(100000, ...) on a path of 100,000 nodes returns true.
    • !== and === compare numbers without any conversion, which is what the checks on counts and roots need.

    Complexity

    Time: O((n + e) · α(n)), nearly O(n + e). The edge count check is constant, creating the arrays is O(n), and each of the at most n - 1 edges costs two find calls. With union by size and path halving each costs O(α(n)) on average. Since the count check ensures e = n - 1, the total is nearly linear.

    Space: O(n). The arrays parent and size have n entries each.

    A BFS or DFS from node 0 that checks that every node is reached also runs in O(n + e) once the edge count is right. On a graph this size, use a BFS or a DFS with an explicit stack, because a recursive DFS down a long path can overflow the call stack.

    Edge cases and common mistakes

    • Trusting the count alone. n = 4 with [[0, 1], [1, 2], [2, 0]] has the right number of edges and is not a tree. The cycle check is what rejects it.
    • A single node. n = 1 with no edges returns true: 0 edges is n - 1, and the loop does nothing.
    • Isolated nodes. n = 2 with no edges returns false, because 0 is not n - 1 = 1. And n = 3 with only [[0, 1]] returns false for the same reason.
    • Edge direction. The edges are undirected, so [1, 0] connects the same nodes as [0, 1]. n = 2 with [[1, 0]] returns true.
    • Comparing nodes instead of roots. The cycle test compares rootA with rootB, not edge[0] with edge[1]. Two different nodes can share a root.
    • Linking the nodes instead of the roots. The merge must set parent[rootB], not parent[edge[1]]. Linking a non-root node would leave its group's other nodes disconnected from the merge.

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