Facebook Pixel

3608. Minimum Time for K Connected Components

MediumUnion FindGraphBinary SearchSorting
LeetCode ↗

Problem Description

You are given an integer n and an undirected graph with n nodes labeled from 0 to n - 1. The graph is described by a 2D array edges, where each edges[i] = [u_i, v_i, time_i] represents an undirected edge connecting node u_i and node v_i. This edge can be removed at the moment time_i.

You are also given an integer k.

At the start, the graph might already be connected or it might be split into several pieces. Your goal is to determine the minimum time t so that, once every edge whose time <= t has been removed, the graph is broken into at least k connected components.

Return this minimum time t.

A connected component is a subgraph in which there is a path between any pair of vertices, and none of its vertices is connected by an edge to any vertex outside the subgraph.

In other words, as time increases, edges with smaller time values get removed first. Removing edges can only split components apart (or leave the count unchanged), increasing the number of connected components. You need to find the smallest t such that after deleting all edges with time <= t, the total number of connected components reaches k or more. If the graph already has at least k components without removing any edge, then the answer is 0.

Quick Interview Experience
Help others by sharing your interview experience
Have you seen this problem before?

How We Pick the Algorithm

Why Disjoint Set Union?

This problem maps to Disjoint Set Union through a short path in the full flowchart.

Graphproblem?yesDoes theprobleminvolveyesDisjoint SetUnion

The problem finds the minimum time to reach k connected components by removing edges; reversing the process and adding edges largest-first with Union-Find tracks the component count.

Open in Flowchart

Intuition

The first key observation is that the answer has a clear monotonic structure. If removing all edges with time <= t already produces at least k connected components, then choosing any larger time would only remove even more edges, which can never decrease the number of components. So the number of components is non-decreasing as t grows. This tells us we are looking for the smallest threshold t where the component count first reaches k.

Instead of thinking about removing edges (which is hard to model with a union-find, since union-find handles adding edges, not deleting them), we flip the process around. Imagine starting with no edges at all — every node is its own component, giving n components. Then we add edges back in decreasing order of time.

Why decreasing order? When we want the minimum time t, the edges that survive are exactly those with time > t. So if we add the largest-time edges first, at any point we are looking at the graph that remains after removing all edges with smaller times. Each edge we add represents an edge that "survives" a particular threshold.

As we add edges from largest time to smallest, we maintain the current number of connected components with a union-find. Every time an edge successfully joins two previously separate components, the component count drops by one. We keep going until the count falls below k. The time of the edge that caused this drop is precisely the answer: this edge must stay in the graph to keep the count at k or above, which means the threshold t must be at least its time, so removing everything with time <= t (where t equals this edge's time) gives us exactly k components.

If we can connect all edges and the component count never drops below k, it means the graph already satisfies the requirement even without removing anything, so the answer is 0.

Pattern Learn more about Union Find, Graph, Binary Search and Sorting patterns.

Solution Approach

Solution 1: Union-Find

We sort the edges by their time in ascending order, then add them back one by one starting from the edge with the largest time. Throughout this process, we use a union-find data structure to track the number of connected components in the current graph. The moment the component count drops below k, the current edge's time is the minimum time we are searching for.

Data Structure: Union-Find

The UnionFind class maintains two arrays:

  • p: where p[x] is the parent of node x. Initially every node is its own parent (p[x] = x).
  • size: where size[x] records the size of the tree rooted at x, used for union by size.

The class supports two main operations:

  • find(x): returns the root representative of the set containing x. It uses path compression (self.p[x] = self.find(self.p[x])) to flatten the tree, speeding up future queries.
  • union(a, b): tries to merge the sets containing a and b. It first finds their roots pa and pb. If they are already the same root, it returns False (no merge happened). Otherwise it attaches the smaller tree under the larger one (union by size), updates the size, and returns True to signal a successful merge.

Main Logic

  1. Sort edges by their third element time in ascending order: edges.sort(key=lambda x: x[2]).
  2. Create a union-find structure over n nodes, and initialize a counter cnt = n, since with no edges added every node forms its own component.
  3. Iterate over the edges in reverse order (edges[::-1]), i.e., from the largest time to the smallest. For each edge (u, v, t):
    • Call uf.union(u, v). If it returns True, two separate components were merged, so we decrement cnt by one.
    • After decrementing, check whether cnt < k. If so, the edge with time = t is the one that, if removed, keeps the graph at k or more components. Hence we return t as the answer.
  4. If we finish processing all edges and cnt never falls below k, the graph already has at least k components without removing anything, so we return 0.

Why iterating in reverse works

By adding edges from largest to smallest time, at the step where we process an edge with time t, the graph we have built contains exactly all edges with time >= t. The edge that triggers cnt < k is the smallest-time edge that still needs to survive to keep at least k components — therefore the threshold t must equal its time, meaning all edges with time <= t are removed.

Complexity Analysis

  • Time complexity: O(n + m × α(n) + m log m), where m is the number of edges. Sorting takes O(m log m), and the union-find operations are nearly constant time thanks to path compression and union by size (α is the inverse Ackermann function).
  • Space complexity: O(n) for the p and size arrays in the union-find structure.

Example Walkthrough

Let's trace through a small example to see the solution approach in action.

Input:

  • n = 4 (nodes labeled 0, 1, 2, 3)
  • edges = [[0, 1, 5], [1, 2, 3], [2, 3, 8]]
  • k = 2

We want the minimum time t such that after removing all edges with time <= t, the graph has at least 2 connected components.


Step 1: Sort edges by time in ascending order

[[1, 2, 3], [0, 1, 5], [2, 3, 8]]

Step 2: Initialize

  • Union-Find over 4 nodes: each node is its own parent → p = [0, 1, 2, 3]
  • cnt = 4 (no edges added yet, so 4 separate components)

Step 3: Iterate over edges in reverse (largest time first)

We process edges in this order: [2,3,8], then [0,1,5], then [1,2,3].

Edge processedunion(u, v) resultEffect on cntcnt < k (cnt < 2)?Action
[2, 3, 8]True (2 and 3 merged)cnt = 4 → 33 < 2? NoContinue
[0, 1, 5]True (0 and 1 merged)cnt = 3 → 22 < 2? NoContinue
[1, 2, 3]True (merges {0,1} and {2,3})cnt = 2 → 11 < 2? YesReturn t = 3

Why is the answer 3?

When we process edge [1, 2, 3], the graph currently built contains all edges with time >= 3 (i.e., all three edges), forming a single connected component {0, 1, 2, 3}cnt = 1, which is below k = 2.

This tells us that edge [1, 2, 3] is the smallest-time edge that must survive to keep the graph at 2 or more components. Therefore the threshold t must equal its time, 3.

Verification: If we remove all edges with time <= 3, we delete [1, 2, 3]. The surviving edges are [0, 1, 5] and [2, 3, 8], producing two components: {0, 1} and {2, 3}. That's exactly k = 2 components. ✓

If we had chosen any smaller threshold (e.g., t = 2), no edge would be removed, leaving just 1 component — not enough. So 3 is indeed the minimum valid time.

Solution Implementation

1from typing import List
2
3
4class UnionFind:
5    """Disjoint Set Union (Union-Find) with union by size and path compression."""
6
7    def __init__(self, n: int) -> None:
8        # parent[i] points to the parent of node i (itself initially)
9        self.parent: List[int] = list(range(n))
10        # size[i] tracks the number of nodes in the component rooted at i
11        self.size: List[int] = [1] * n
12
13    def find(self, x: int) -> int:
14        # Path compression: flatten the tree so future lookups are faster
15        if self.parent[x] != x:
16            self.parent[x] = self.find(self.parent[x])
17        return self.parent[x]
18
19    def union(self, a: int, b: int) -> bool:
20        root_a, root_b = self.find(a), self.find(b)
21        # Already in the same component, nothing to merge
22        if root_a == root_b:
23            return False
24        # Union by size: attach the smaller tree under the larger one
25        if self.size[root_a] > self.size[root_b]:
26            self.parent[root_b] = root_a
27            self.size[root_a] += self.size[root_b]
28        else:
29            self.parent[root_a] = root_b
30            self.size[root_b] += self.size[root_a]
31        return True
32
33
34class Solution:
35    def minTime(self, n: int, edges: List[List[int]], k: int) -> int:
36        # Sort edges by their time/weight in ascending order
37        edges.sort(key=lambda edge: edge[2])
38
39        uf = UnionFind(n)
40        # Number of connected components, starting with each node isolated
41        component_count = n
42
43        # Iterate edges from largest weight to smallest.
44        # Removing edges with weight >= t leaves only edges with smaller time;
45        # adding them in reverse simulates which connections survive at time t.
46        for u, v, t in reversed(edges):
47            # When a union actually merges two components, the count drops
48            if uf.union(u, v):
49                component_count -= 1
50                # Once we have fewer than k components, t is the answer
51                if component_count < k:
52                    return t
53
54        # Already satisfies the component requirement without removing any edge
55        return 0
56
1/**
2 * Union-Find (Disjoint Set Union) data structure with
3 * path compression and union by size optimizations.
4 */
5class UnionFind {
6    // parent[i] holds the parent of node i; if parent[i] == i, then i is a root
7    private int[] parent;
8    // componentSize[i] holds the size of the tree rooted at i (valid only for roots)
9    private int[] componentSize;
10
11    /**
12     * Initializes n disjoint sets, each containing a single element.
13     *
14     * @param n the number of elements
15     */
16    public UnionFind(int n) {
17        parent = new int[n];
18        componentSize = new int[n];
19        for (int i = 0; i < n; i++) {
20            parent[i] = i;          // each node is initially its own parent (root)
21            componentSize[i] = 1;   // each component starts with size 1
22        }
23    }
24
25    /**
26     * Finds the representative (root) of the set containing x,
27     * applying path compression to flatten the tree.
28     *
29     * @param x the element to find
30     * @return the root of the set containing x
31     */
32    public int find(int x) {
33        if (parent[x] != x) {
34            // recursively find the root and compress the path
35            parent[x] = find(parent[x]);
36        }
37        return parent[x];
38    }
39
40    /**
41     * Merges the sets containing a and b using union by size.
42     *
43     * @param a the first element
44     * @param b the second element
45     * @return true if a merge occurred, false if they were already connected
46     */
47    public boolean union(int a, int b) {
48        int rootA = find(a);
49        int rootB = find(b);
50
51        // already in the same set, nothing to merge
52        if (rootA == rootB) {
53            return false;
54        }
55
56        // attach the smaller tree under the larger one to keep depth small
57        if (componentSize[rootA] > componentSize[rootB]) {
58            parent[rootB] = rootA;
59            componentSize[rootA] += componentSize[rootB];
60        } else {
61            parent[rootA] = rootB;
62            componentSize[rootB] += componentSize[rootA];
63        }
64        return true;
65    }
66}
67
68class Solution {
69    /**
70     * Determines the minimum edge weight (time) such that, after keeping only
71     * the edges processed so far (from largest weight downward), the graph is
72     * split into at least k connected components.
73     *
74     * @param n     the number of nodes
75     * @param edges the edges, where each edge is [u, v, time]
76     * @param k     the target number of connected components
77     * @return the answer time value, or 0 if it is never reached
78     */
79    public int minTime(int n, int[][] edges, int k) {
80        // sort edges by weight in ascending order
81        Arrays.sort(edges, (a, b) -> Integer.compare(a[2], b[2]));
82
83        UnionFind unionFind = new UnionFind(n);
84        // initially every node is its own component
85        int componentCount = n;
86
87        // iterate from the largest-weight edge down to the smallest
88        for (int i = edges.length - 1; i >= 0; i--) {
89            int u = edges[i][0];
90            int v = edges[i][1];
91            int time = edges[i][2];
92
93            // if this edge connects two previously separate components,
94            // merging them reduces the total component count by one
95            if (unionFind.union(u, v)) {
96                // once the component count drops below k, the current
97                // edge's time is the minimum satisfying value
98                if (--componentCount < k) {
99                    return time;
100                }
101            }
102        }
103
104        // the target component count was never crossed
105        return 0;
106    }
107}
108
1class UnionFind {
2public:
3    // parent[i] stores the representative (root) of the set that i belongs to
4    vector<int> parent;
5    // setSize[i] stores the size of the set rooted at i (only valid for roots)
6    vector<int> setSize;
7
8    // Initialize n disjoint sets, each element is its own parent
9    UnionFind(int n) {
10        parent.resize(n);
11        setSize.resize(n, 1);
12        for (int i = 0; i < n; i++) {
13            parent[i] = i;
14        }
15    }
16
17    // Find the root of x with path compression
18    int find(int x) {
19        if (parent[x] != x) {
20            parent[x] = find(parent[x]);  // Flatten the tree for future queries
21        }
22        return parent[x];
23    }
24
25    // Union the sets containing a and b using union by size
26    // Returns true if a merge happened, false if they were already connected
27    bool unite(int a, int b) {
28        int rootA = find(a);
29        int rootB = find(b);
30        if (rootA == rootB) {
31            return false;  // Already in the same set
32        }
33
34        // Attach the smaller tree under the larger one to keep trees shallow
35        if (setSize[rootA] > setSize[rootB]) {
36            parent[rootB] = rootA;
37            setSize[rootA] += setSize[rootB];
38        } else {
39            parent[rootA] = rootB;
40            setSize[rootB] += setSize[rootA];
41        }
42        return true;
43    }
44};
45
46class Solution {
47public:
48    int minTime(int n, vector<vector<int>>& edges, int k) {
49        // Sort edges by their time/weight in ascending order
50        sort(edges.begin(), edges.end(),
51             [](const vector<int>& a, const vector<int>& b) {
52                 return a[2] < b[2];
53             });
54
55        UnionFind uf(n);
56        int componentCount = n;  // Initially every node is its own component
57
58        // Iterate from the largest weight edge down to the smallest.
59        // By adding edges from heaviest to lightest, the answer is the weight
60        // of the edge that first makes the number of components drop below k.
61        for (int i = static_cast<int>(edges.size()) - 1; i >= 0; i--) {
62            int u = edges[i][0];
63            int v = edges[i][1];
64            int time = edges[i][2];
65
66            // Only merging distinct components reduces the component count
67            if (uf.unite(u, v)) {
68                componentCount--;
69                if (componentCount < k) {
70                    // This edge's time is the minimum required answer
71                    return time;
72                }
73            }
74        }
75
76        // Already satisfied the component requirement without removing any edge
77        return 0;
78    }
79};
80
1// Parent array for the disjoint set (each node's representative)
2let p: number[] = [];
3// Size array tracking the number of nodes in each component
4let size: number[] = [];
5
6// Find the root of x with path compression
7function find(x: number): number {
8    if (p[x] !== x) {
9        p[x] = find(p[x]);
10    }
11    return p[x];
12}
13
14// Merge the sets containing a and b using union by size.
15// Returns false if they are already connected, true otherwise.
16function union(a: number, b: number): boolean {
17    const pa = find(a);
18    const pb = find(b);
19    // Already in the same component, nothing to merge
20    if (pa === pb) return false;
21
22    // Attach the smaller tree under the larger one
23    if (size[pa] > size[pb]) {
24        p[pb] = pa;
25        size[pa] += size[pb];
26    } else {
27        p[pa] = pb;
28        size[pb] += size[pa];
29    }
30    return true;
31}
32
33function minTime(n: number, edges: number[][], k: number): number {
34    // Sort edges by their time/weight in ascending order
35    edges.sort((a, b) => a[2] - b[2]);
36
37    // Initialize the disjoint set: every node is its own parent with size 1
38    p = Array.from({ length: n }, (_, i) => i);
39    size = Array(n).fill(1);
40
41    // cnt tracks the current number of connected components
42    let cnt = n;
43
44    // Iterate edges from largest weight to smallest.
45    // By adding heavier edges first, we keep components merging;
46    // the moment the component count drops below k, the current
47    // edge's time is the answer (the minimum time to reach >= k components
48    // by removing edges with time >= t).
49    for (let i = edges.length - 1; i >= 0; i--) {
50        const [u, v, t] = edges[i];
51
52        // Merge the two endpoints; if a real merge happens, decrement count
53        if (union(u, v)) {
54            if (--cnt < k) {
55                return t;
56            }
57        }
58    }
59
60    // All nodes merged without reaching the threshold below k
61    return 0;
62}
63

Time and Space Complexity

  • Time Complexity: O(m × log m + m × α(n)), where m is the number of edges and n is the number of nodes.

    • Sorting: The line edges.sort(key=lambda x: x[2]) sorts all edges by their time value, costing O(m × log m).
    • Union-Find traversal: The loop iterates over all m edges in reverse. For each edge, the find operation (with path compression) and the union operation (with union by size) take nearly constant amortized time, O(α(n)), where α is the inverse Ackermann function. Thus this phase costs O(m × α(n)).
    • Combining both phases gives O(m × log m + m × α(n)), which is dominated by the sorting term O(m × log m). If we follow the reference answer's convention of treating the edge count proportional to n and focusing on the union-find portion, this simplifies to O(n × α(n)).
  • Space Complexity: O(n).

    • The UnionFind structure maintains two arrays, self.p and self.size, each of length n, requiring O(n) space.
    • The recursive find method may incur O(n) stack space in the worst case, but path compression keeps the effective tree height nearly flat in practice.
    • Sorting may use O(log m) to O(m) auxiliary space depending on the implementation, but the dominant term remains O(n).

Pattern Learn more about how to find time and space complexity quickly.

Common Pitfalls

Pitfall 1: Adding edges in ascending order instead of descending order

The most common mistake is iterating over the sorted edges in forward order (smallest time first) rather than in reverse. The core idea relies on the fact that at time t, all edges with time <= t are removed, leaving only edges with time > t intact. To simulate this correctly, we must rebuild the graph by adding the largest-time edges first, so that at the moment we process an edge with time t, the union-find structure already contains exactly the edges that would survive.

# WRONG: adds smallest-time edges first
for u, v, t in edges:
    if uf.union(u, v):
        component_count -= 1
        if component_count < k:
            return t

# CORRECT: adds largest-time edges first
for u, v, t in reversed(edges):
    if uf.union(u, v):
        component_count -= 1
        if component_count < k:
            return t

If you iterate forward, the returned t will be the time of an edge that does not correspond to the actual cut threshold, producing incorrect answers.


Pitfall 2: Forgetting the case where the graph already has k or more components

If the graph is already broken into at least k components without removing any edge (for example, when n is large but very few edges connect the nodes, or when k <= initial component count), no edge will ever drive component_count below k. In this situation, the loop completes without returning, and the answer must default to 0.

# Must return 0 after the loop ends
return 0

Omitting this final return 0 (or returning some sentinel like -1) breaks the edge case where the requirement is satisfied at time 0. Always verify that the function returns 0 when the component count never falls below k.


Pitfall 3: Off-by-one error in the component-count comparison

A subtle bug arises from using the wrong comparison operator. The goal is at least k components, so we want the smallest t such that the number of components is >= k. When rebuilding in reverse, the count decreases with each successful union. The threshold edge is the one whose addition would drop the count below k:

# CORRECT: check strictly less than k
if component_count < k:
    return t

# WRONG variants that cause off-by-one errors:
if component_count <= k:   # returns too early
    return t
if component_count == k:    # may skip the right edge if it jumps past k
    return t

Using <= k returns one edge too early, and using == k can miss the answer entirely if multiple unions happen (though with union-find each union reduces the count by exactly one, == k still risks logical confusion). The correct semantics: as soon as adding this edge would make the surviving graph have fewer than k components, the edge's time must be the cut threshold.


Pitfall 4: Recursion depth limit in find with path compression

For very large graphs (large n), the recursive find method can hit Python's default recursion limit (typically 1000), causing a RecursionError on deep, unbalanced trees formed before path compression flattens them.

# Iterative alternative to avoid recursion depth issues
def find(self, x: int) -> int:
    root = x
    while self.parent[root] != root:
        root = self.parent[root]
    # Path compression in a second pass
    while self.parent[x] != root:
        self.parent[x], x = root, self.parent[x]
    return root

Although union by size keeps trees shallow in practice, converting find to an iterative implementation is a safe defensive measure for competitive or production environments handling millions of nodes.

Ready to land your dream job?

Unlock your dream job with a 5-minute quiz for a personalized study roadmap!

Get My Roadmap
Discover Your Strengths and Weaknesses: Take Our 5-Minute Quiz to Get a Personalized Study Roadmap:

Depth first search is equivalent to which of the tree traversal order?


Recommended Readings

Want a Structured Path to Master System Design Too? Don’t Miss This!

Load More