Facebook Pixel

3600. Maximize Spanning Tree Stability with Upgrades

HardGreedyUnion FindGraphBinary SearchMinimum Spanning Tree
LeetCode ↗

Problem Description

You are given an integer n, representing n nodes numbered from 0 to n - 1, and a list of edges, where each edges[i] = [uᵢ, vᵢ, sᵢ, mustᵢ] describes the following:

  • uᵢ and vᵢ indicate an undirected edge between nodes uᵢ and vᵢ.
  • sᵢ is the strength of the edge.
  • mustᵢ is an integer (0 or 1). If mustᵢ == 1, the edge must be included in the spanning tree, and such edges cannot be upgraded.

You are also given an integer k, which is the maximum number of upgrades you can perform. Each upgrade doubles the strength of an edge, and each eligible edge (one with mustᵢ == 0) can be upgraded at most once.

The stability of a spanning tree is defined as the minimum strength score among all edges included in it.

Your task is to return the maximum possible stability of any valid spanning tree. If it is impossible to connect all the nodes, return -1.

A spanning tree of a graph with n nodes is a subset of the edges that connects all nodes together (i.e., the graph is connected) without forming any cycles, and uses exactly n - 1 edges.

In summary, you need to:

  1. Build a spanning tree that includes every required edge (those with mustᵢ == 1).
  2. Optionally upgrade up to k non-required edges, each upgrade doubling the strength of one edge (each such edge upgraded at most once).
  3. Maximize the smallest edge strength in the resulting spanning tree.
  4. Return -1 if no valid spanning tree exists (for example, the graph cannot be connected, or the required edges already form a cycle).
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 connects a minimum spanning tree with upgradeable edge strengths, using Union-Find to check feasibility for a given stability threshold during binary search.

Open in Flowchart

Intuition

The key observation lies in the definition of stability: it is the minimum strength among all edges in the spanning tree. We want to make this minimum value as large as possible.

This kind of "maximize the minimum" objective is a strong hint toward binary search on the answer. Why? Because the feasibility of a target stability value is monotonic. If we can build a valid spanning tree where every edge has strength at least x, then we can certainly build one where every edge has strength at least y for any y < x (the constraint y is looser, so any tree satisfying x also satisfies y). This monotonic property is exactly what makes binary search applicable.

So the problem reduces to: for a given target stability lim, can we build a valid spanning tree where every edge has strength at least lim?

To answer this check(lim) question, we think about which edges are usable when the target is lim:

  • Any edge whose original strength s >= lim can be used directly, no upgrade needed.
  • Any edge whose strength is below lim but where s * 2 >= lim can still be used if we spend one upgrade on it (doubling brings it up to the requirement). Since each eligible edge can be upgraded at most once, doubling is the best we can do for a single edge.
  • Edges with s * 2 < lim are useless for this target, even with an upgrade.

Now connecting the nodes naturally becomes a Union-Find task. We first merge all nodes using the "free" edges (those with s >= lim), since using them costs nothing. Only after that do we spend our limited k upgrades on the weaker-but-upgradable edges (s * 2 >= lim), and only when such an edge actually joins two separate components (otherwise the upgrade would be wasted on a cycle). If after this process all nodes belong to a single component, then lim is achievable.

There are two important details handled before the binary search:

  1. The required edges (must == 1) must always be in the tree. They directly limit the answer, because the final stability can never exceed the smallest strength among these forced edges. This gives us the natural upper bound mn for binary search. We also union these edges first; if any of them forms a cycle, the required set is invalid and we return -1.
  2. If, after trying to union with all edges, the graph is still not fully connected (cnt > 1), then no spanning tree exists at all, so we return -1.

With the search range fixed at [1, mn] and a clear check function, binary search efficiently narrows down to the largest feasible stability, which is our answer.

Pattern Learn more about Greedy, Union Find, Graph, Binary Search and Minimum Spanning Tree patterns.

Solution Approach

We solve this problem using Binary Search + Union-Find.

Step 1: Set up the Union-Find data structure

We use a UnionFind class that supports the standard operations with two common optimizations:

  • Path compression in the find method, so that lookups become nearly constant time.
  • Union by size in the union method, attaching the smaller tree under the larger one.

It also tracks cnt, the current number of connected components. Whenever a successful union merges two components, cnt is decreased by 1. When cnt == 1, all nodes are connected. The union method returns False when the two nodes are already in the same component (i.e., adding this edge would form a cycle).

Step 2: Handle required edges and determine the upper bound

We first iterate over all edges and process the required ones (must == 1):

  • Record the minimum strength mn among them, since the final stability can never exceed this value.
  • Union the two endpoints of each required edge. If a union call returns False, the required edges contain a cycle, so a valid spanning tree is impossible and we return -1.

This gives us the search upper bound mn.

Step 3: Check overall connectivity

Next, we add all edges into the Union-Find (continuing from the required-edge state). If the final number of components uf.cnt > 1, the graph simply cannot be fully connected, so we return -1.

Step 4: The check(lim) feasibility function

For a candidate stability lim, we determine whether a valid spanning tree with every edge strength at least lim exists:

  1. Create a fresh Union-Find.
  2. First pass: union all edges whose original strength satisfies s >= lim. These are "free" edges that meet the requirement without any upgrade.
  3. Second pass: with remaining upgrade budget rem = k, scan edges where s * 2 >= lim. For each such edge, if it connects two separate components (uf.union(u, v) returns True), we spend one upgrade and decrement rem. We stop spending once rem reaches 0.
  4. Return whether uf.cnt == 1 (all nodes connected).

The order matters here: we prioritize free edges first, so that the precious k upgrades are only consumed on edges that genuinely need them to bridge disconnected parts.

Step 5: Binary search for the maximum stability

We binary search over the range [1, mn]:

l, r = 1, mn
while l < r:
    mid = (l + r + 1) >> 1
    if check(mid):
        l = mid
    else:
        r = mid - 1
return l

Because we are searching for the largest feasible value, we use the upper-biased midpoint mid = (l + r + 1) >> 1:

  • If check(mid) is True, the answer can be at least mid, so we move l = mid.
  • Otherwise, mid is too large, so we shrink the range with r = mid - 1.

When the loop ends, l holds the maximum possible stability.

Complexity Analysis

  • Time complexity: O(m × log(mn) × α(n)), where m is the number of edges, mn is the upper bound of stability, and α is the inverse Ackermann function from Union-Find. Each check call scans all edges twice, and we perform O(log(mn)) such calls during binary search.
  • Space complexity: O(n), used by the Union-Find arrays p and size.

Example Walkthrough

Let's trace through a concrete example to see how Binary Search + Union-Find solves this problem.

Input:

  • n = 4 (nodes 0, 1, 2, 3)
  • k = 1 (at most one upgrade allowed)
  • edges:
Indexuvs (strength)must
00161
11230
22350
30320
41340

Step 1 & 2: Process required edges and find the upper bound

Only edge 0 has must == 1:

  • Record mn = 6 (the minimum strength among required edges → our upper bound).
  • Union nodes 0 and 1. This succeeds (no cycle).

Components so far: {0, 1}, {2}, {3}cnt = 3.

Since the only required edge formed no cycle, we continue.


Step 3: Check overall connectivity

Add all remaining edges into Union-Find to test if the graph can connect at all:

  • Edge 1 (1–2): merges {0,1} and {2}{0,1,2}, {3}
  • Edge 2 (2–3): merges {0,1,2} and {3}{0,1,2,3}cnt = 1

cnt == 1, so the graph can be connected. We proceed to binary search. (If cnt > 1 here, we'd return -1.)


Step 5: Binary search over [1, mn] = [1, 6]

We search for the largest stability that passes check(lim).

Iteration 1: l=1, r=6, mid = (1+6+1)>>1 = 4. Run check(4):

  • Free pass (s >= 4): edge0(6), edge2(5), edge4(4).
    • 0–1 ✓ → {0,1}
    • 2–3 ✓ → {2,3}
    • 1–3 ✓ → merges → {0,1,2,3}, cnt = 1
  • Already connected with free edges, no upgrades needed. Returns True.
  • check(4) is True → set l = 4.

Iteration 2: l=4, r=6, mid = (4+6+1)>>1 = 5. Run check(5):

  • Free pass (s >= 5): edge0(6), edge2(5).
    • 0–1 ✓ → {0,1}
    • 2–3 ✓ → {2,3}
    • Components: {0,1}, {2,3}, cnt = 2
  • Upgrade pass (s*2 >= 5, i.e. s >= 2.5), rem = 1:
    • edge1(1–2, s=3, 3*2=6≥5): connects {0,1} and {2,3} ✓ → spend 1 upgrade → {0,1,2,3}, cnt = 1, rem = 0.
  • Returns True.
  • check(5) is True → set l = 5.

Iteration 3: l=5, r=6, mid = (5+6+1)>>1 = 6. Run check(6):

  • Free pass (s >= 6): edge0(6).
    • 0–1 ✓ → {0,1}, {2}, {3}, cnt = 3
  • Upgrade pass (s*2 >= 6, i.e. s >= 3), rem = 1:
    • edge1(1–2, s=3, 6≥6): connects {0,1} and {2} ✓ → spend upgrade → {0,1,2}, {3}, cnt = 2, rem = 0.
    • Budget exhausted, but node 3 is still isolated.
  • Returns False (cnt == 2).
  • check(6) is False → set r = 6 - 1 = 5.

Loop ends: l == r == 5.


Result

The maximum possible stability is 5.

Verifying the winning tree: edges {0–1 (6), 2–3 (5), 1–2 (3→upgraded to 6)} form a valid spanning tree connecting all 4 nodes, includes the required edge 0–1, uses exactly one upgrade (≤ k), and its minimum strength is min(6, 5, 6) = 5. This matches our answer, and we confirmed 6 is unreachable because only one upgrade is available — never enough to lift two weak edges simultaneously to bridge all components.

Solution Implementation

1from typing import List
2
3
4class UnionFind:
5    def __init__(self, n: int) -> None:
6        # parent[i] is the parent of node i; initially each node is its own root
7        self.parent = list(range(n))
8        # size[i] is the number of nodes in the tree rooted at i
9        self.size = [1] * n
10        # count tracks the number of disjoint connected components
11        self.count = n
12
13    def find(self, x: int) -> int:
14        # Find the root of x with path compression
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        # Merge the sets containing a and b; return False if already connected
21        root_a, root_b = self.find(a), self.find(b)
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        self.count -= 1
32        return True
33
34
35class Solution:
36    def maxStability(self, n: int, edges: List[List[int]], k: int) -> int:
37        def check(limit: int) -> bool:
38            # Determine whether all nodes can be connected such that every
39            # edge used has an effective stability >= limit.
40            uf = UnionFind(n)
41            # First, use all edges whose original stability already meets the limit (free).
42            for u, v, strength, _ in edges:
43                if strength >= limit:
44                    uf.union(u, v)
45            # Then, use upgradeable edges (doubled stability) up to k times,
46            # only if they actually connect two separate components.
47            remaining = k
48            for u, v, strength, _ in edges:
49                if strength * 2 >= limit and remaining > 0:
50                    if uf.union(u, v):
51                        remaining -= 1
52            # The graph is fully connected when only one component remains.
53            return uf.count == 1
54
55        # Validate mandatory edges and compute the upper bound for the binary search.
56        uf = UnionFind(n)
57        min_must_strength = 10**6
58        for u, v, strength, must in edges:
59            if must:
60                # The answer can never exceed the smallest mandatory edge's stability.
61                min_must_strength = min(min_must_strength, strength)
62                # Mandatory edges forming a cycle make the configuration invalid.
63                if not uf.union(u, v):
64                    return -1
65
66        # Add all remaining edges to verify overall connectivity is achievable.
67        for u, v, _, _ in edges:
68            uf.union(u, v)
69        if uf.count > 1:
70            return -1
71
72        # Binary search for the maximum achievable minimum stability.
73        left, right = 1, min_must_strength
74        while left < right:
75            mid = (left + right + 1) >> 1
76            if check(mid):
77                left = mid
78            else:
79                right = mid - 1
80        return left
81
1/**
2 * Weighted Union-Find (Disjoint Set Union) with path compression
3 * and union by size.
4 */
5class UnionFind {
6    // parent[i] holds the parent of node i; root nodes point to themselves
7    private int[] parent;
8    // size[i] holds the number of nodes in the tree rooted at i
9    private int[] size;
10    // count of connected components
11    int count;
12
13    UnionFind(int n) {
14        parent = new int[n];
15        size = new int[n];
16        count = n;
17        // Initially every node is its own root, each component has size 1
18        for (int i = 0; i < n; i++) {
19            parent[i] = i;
20            size[i] = 1;
21        }
22    }
23
24    /**
25     * Find the root representative of x, applying path compression.
26     */
27    int find(int x) {
28        if (parent[x] != x) {
29            parent[x] = find(parent[x]);
30        }
31        return parent[x];
32    }
33
34    /**
35     * Merge the components containing a and b (union by size).
36     * Returns true if a merge actually happened, false if already connected.
37     */
38    boolean union(int a, int b) {
39        int rootA = find(a);
40        int rootB = find(b);
41        if (rootA == rootB) {
42            return false;
43        }
44        // Attach the smaller tree under the larger one to keep depth low
45        if (size[rootA] > size[rootB]) {
46            parent[rootB] = rootA;
47            size[rootA] += size[rootB];
48        } else {
49            parent[rootA] = rootB;
50            size[rootB] += size[rootA];
51        }
52        count--;
53        return true;
54    }
55}
56
57class Solution {
58
59    private int n;                 // number of nodes
60    private int[][] edges;         // edge list: [u, v, strength, mustUse]
61    private int k;                 // number of edges we may "upgrade" (double strength)
62
63    /**
64     * Feasibility check for the binary search.
65     * Returns true if the graph can be fully connected such that every used
66     * edge has an effective strength >= limit.
67     *
68     * Two passes are performed:
69     *   1) Use edges whose original strength already meets the limit.
70     *   2) Use up to k "upgraded" edges whose doubled strength meets the limit.
71     */
72    private boolean check(int limit) {
73        UnionFind uf = new UnionFind(n);
74
75        // Pass 1: connect using edges that satisfy the limit without upgrading
76        for (int[] edge : edges) {
77            int u = edge[0];
78            int v = edge[1];
79            int strength = edge[2];
80            if (strength >= limit) {
81                uf.union(u, v);
82            }
83        }
84
85        // Pass 2: connect using upgraded edges (strength * 2), limited to k upgrades
86        int remaining = k;
87        for (int[] edge : edges) {
88            int u = edge[0];
89            int v = edge[1];
90            int strength = edge[2];
91            if (strength * 2 >= limit && remaining > 0) {
92                // Only consume an upgrade if it actually merges two components
93                if (uf.union(u, v)) {
94                    remaining--;
95                }
96            }
97        }
98
99        // Connected iff exactly one component remains
100        return uf.count == 1;
101    }
102
103    public int maxStability(int n, int[][] edges, int k) {
104        this.n = n;
105        this.edges = edges;
106        this.k = k;
107
108        UnionFind uf = new UnionFind(n);
109        // Track the minimum strength among mandatory edges; this caps the answer
110        int minMustStrength = (int) 1e6;
111
112        // Step 1: process mandatory edges (must == 1)
113        for (int[] edge : edges) {
114            int u = edge[0];
115            int v = edge[1];
116            int strength = edge[2];
117            int must = edge[3];
118            if (must == 1) {
119                minMustStrength = Math.min(minMustStrength, strength);
120                // If a mandatory edge forms a cycle, the configuration is invalid
121                if (!uf.union(u, v)) {
122                    return -1;
123                }
124            }
125        }
126
127        // Step 2: add all remaining edges to test overall connectivity
128        for (int[] edge : edges) {
129            uf.union(edge[0], edge[1]);
130        }
131
132        // If the graph cannot be fully connected at all, return -1
133        if (uf.count > 1) {
134            return -1;
135        }
136
137        // Step 3: binary search the maximum achievable minimum stability.
138        // The answer cannot exceed minMustStrength (mandatory edges are always used).
139        int left = 1;
140        int right = minMustStrength;
141        while (left < right) {
142            // Upper-mid to avoid infinite loop when searching for the max feasible value
143            int mid = (left + right + 1) >>> 1;
144            if (check(mid)) {
145                left = mid;   // mid is feasible, try to go higher
146            } else {
147                right = mid - 1; // mid is infeasible, lower the bound
148            }
149        }
150
151        return left;
152    }
153}
154
1class UnionFind {
2public:
3    vector<int> parent;     // parent[i] = parent of node i in the disjoint set
4    vector<int> rankSize;   // rankSize[i] = size of the set rooted at i
5    int count;              // number of disjoint connected components
6
7    explicit UnionFind(int n) {
8        parent.resize(n);
9        rankSize.assign(n, 1);
10        count = n;
11        // Initially every node is its own root
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]);
21        }
22        return parent[x];
23    }
24
25    // Merge the sets containing a and b (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), rootB = find(b);
29        if (rootA == rootB) {
30            return false;
31        }
32        // Attach the smaller tree under the larger one
33        if (rankSize[rootA] > rankSize[rootB]) {
34            parent[rootB] = rootA;
35            rankSize[rootA] += rankSize[rootB];
36        } else {
37            parent[rootA] = rootB;
38            rankSize[rootB] += rankSize[rootA];
39        }
40        --count;
41        return true;
42    }
43};
44
45class Solution {
46public:
47    int n;                       // number of nodes
48    int k;                       // maximum number of edges we are allowed to boost
49    vector<vector<int>> edges;   // edge list: {u, v, strength, mustInclude}
50
51    // Check whether a connected graph can be formed such that every used edge
52    // has effective stability >= lim.
53    // - An edge with strength s can be used directly if s >= lim.
54    // - An edge can be "boosted" (doubling its strength) if s * 2 >= lim,
55    //   but we may boost at most k edges in total.
56    bool check(int lim) {
57        UnionFind uf(n);
58
59        // First pass: connect with edges that already meet the threshold for free
60        for (auto& e : edges) {
61            int u = e[0], v = e[1], s = e[2];
62            if (s >= lim) {
63                uf.unite(u, v);
64            }
65        }
66
67        // Second pass: use boosted edges (limited by remaining budget) to
68        // connect components that are still separated.
69        int remaining = k;
70        for (auto& e : edges) {
71            int u = e[0], v = e[1], s = e[2];
72            if (s * 2 >= lim && remaining > 0) {
73                if (uf.unite(u, v)) {
74                    --remaining;
75                }
76            }
77        }
78
79        // The graph is feasible if everything collapses into one component
80        return uf.count == 1;
81    }
82
83    int maxStability(int n, vector<vector<int>>& edges, int k) {
84        this->n = n;
85        this->edges = edges;
86        this->k = k;
87
88        UnionFind uf(n);
89        int minMustStrength = 1e6;   // smallest strength among mandatory edges
90
91        // Process all mandatory edges. They must all be included, so:
92        // - Track the minimum strength among them (caps the achievable answer).
93        // - If two mandatory edges create a cycle, the configuration is invalid.
94        for (auto& e : edges) {
95            int u = e[0], v = e[1], s = e[2], mustInclude = e[3];
96            if (mustInclude) {
97                minMustStrength = min(minMustStrength, s);
98                if (!uf.unite(u, v)) {
99                    return -1;   // mandatory edges form a cycle -> impossible
100                }
101            }
102        }
103
104        // Add every remaining edge to test overall connectivity feasibility
105        for (auto& e : edges) {
106            uf.unite(e[0], e[1]);
107        }
108
109        // If the graph cannot be fully connected at all, no answer exists
110        if (uf.count > 1) {
111            return -1;
112        }
113
114        // Binary search on the answer (the maximum achievable minimum stability).
115        // The upper bound is minMustStrength because mandatory edges cannot be
116        // boosted and limit the threshold.
117        int left = 1, right = minMustStrength;
118        while (left < right) {
119            int mid = (left + right + 1) >> 1;
120            if (check(mid)) {
121                left = mid;       // mid is achievable, try for higher
122            } else {
123                right = mid - 1;  // mid too high, lower the bound
124            }
125        }
126
127        return left;
128    }
129};
130
1// Union-Find (Disjoint Set Union) global state
2let parent: number[]; // parent[i] points to the representative of i's set
3let rank: number[]; // rank/size used for union by size optimization
4let count: number; // number of disjoint connected components
5
6/**
7 * Initialize the Union-Find structure for n elements.
8 * Each element starts as its own parent (separate component).
9 */
10function init(n: number): void {
11    parent = Array.from({ length: n }, (_, i) => i);
12    rank = new Array(n).fill(1);
13    count = n;
14}
15
16/**
17 * Find the representative (root) of the set containing x.
18 * Uses path compression to flatten the tree for efficiency.
19 */
20function find(x: number): number {
21    if (parent[x] !== x) {
22        parent[x] = find(parent[x]);
23    }
24    return parent[x];
25}
26
27/**
28 * Merge the sets containing a and b using union by size.
29 * Returns true if a merge happened, false if they were already connected.
30 */
31function union(a: number, b: number): boolean {
32    const rootA = find(a);
33    const rootB = find(b);
34    if (rootA === rootB) return false;
35
36    // Attach the smaller tree under the larger tree
37    if (rank[rootA] > rank[rootB]) {
38        parent[rootB] = rootA;
39        rank[rootA] += rank[rootB];
40    } else {
41        parent[rootA] = rootB;
42        rank[rootB] += rank[rootA];
43    }
44
45    count--;
46    return true;
47}
48
49// Global problem inputs shared with the check function
50let nodeCount: number; // total number of nodes
51let edgeList: number[][]; // list of edges: [u, v, strength, must]
52let maxUpgrades: number; // maximum number of edges allowed to be upgraded (x2)
53
54/**
55 * Verify whether the graph can be fully connected (single component)
56 * while every used edge meets the strength threshold `limit`.
57 *
58 * Step 1: Add all edges whose original strength already satisfies `limit`.
59 * Step 2: Use remaining upgrade budget on edges that satisfy `limit`
60 *         only after doubling their strength (strength * 2 >= limit).
61 * Returns true if the whole graph becomes one connected component.
62 */
63function check(limit: number): boolean {
64    init(nodeCount);
65
66    // Phase 1: edges that already meet the threshold without upgrading
67    for (const [u, v, strength] of edgeList) {
68        if (strength >= limit) {
69            union(u, v);
70        }
71    }
72
73    // Phase 2: spend upgrade budget on edges that qualify after doubling
74    let remaining = maxUpgrades;
75    for (const [u, v, strength] of edgeList) {
76        if (strength * 2 >= limit && remaining > 0) {
77            if (union(u, v)) {
78                remaining--;
79            }
80        }
81    }
82
83    return count === 1;
84}
85
86/**
87 * Compute the maximum achievable stability of the network.
88 *
89 * The stability is defined as the minimum edge strength across all used
90 * edges. We can optionally double the strength of up to `k` non-mandatory
91 * edges. Mandatory edges (must === 1) must always be included.
92 *
93 * Approach:
94 * 1. Connect all mandatory edges first; if any forms a cycle, the
95 *    configuration is invalid (returns -1).
96 * 2. Track the minimum strength among mandatory edges as the upper bound,
97 *    since the answer can never exceed the weakest mandatory edge.
98 * 3. Verify the full graph is connectable using all edges; otherwise -1.
99 * 4. Binary search on the answer (the threshold strength), using `check`
100 *    to test feasibility for each candidate value.
101 */
102function maxStability(n: number, edges: number[][], k: number): number {
103    nodeCount = n;
104    edgeList = edges;
105    maxUpgrades = k;
106
107    init(n);
108    let minMandatory = 1e6; // upper bound of the answer
109
110    // Step 1 & 2: add mandatory edges and track their minimum strength
111    for (const [u, v, strength, must] of edges) {
112        if (must) {
113            minMandatory = Math.min(minMandatory, strength);
114            if (!union(u, v)) return -1; // mandatory edges form a cycle
115        }
116    }
117
118    // Step 3: ensure the graph can be fully connected using every edge
119    for (const [u, v] of edges) {
120        union(u, v);
121    }
122
123    if (count > 1) return -1; // graph cannot be fully connected
124
125    // Step 4: binary search for the largest feasible threshold
126    let left = 1;
127    let right = minMandatory;
128
129    while (left < right) {
130        const mid = (left + right + 1) >> 1;
131        if (check(mid)) {
132            left = mid; // mid is feasible, try a higher threshold
133        } else {
134            right = mid - 1; // mid is too high, lower the threshold
135        }
136    }
137
138    return left;
139}
140

Time and Space Complexity

Time Complexity: O((m × α(n) + n) × log M)

The analysis is as follows:

  • Binary search range: The binary search is performed over the strength values in the range [1, mn], where mn is bounded by the maximum edge strength M. This results in O(log M) iterations.

  • Cost per check(lim) call: Inside each invocation:

    • A new UnionFind(n) instance is created, costing O(n) for initializing the p, size arrays.
    • The edges are iterated twice. Each iteration processes all m edges, and each union / find operation costs O(α(n)) amortized time (where α is the inverse Ackermann function) due to path compression and union by size. This contributes O(m × α(n)).
    • Thus, each check call costs O(m × α(n) + n).
  • Combining: The binary search runs check for O(log M) iterations, giving O((m × α(n) + n) × log M).

  • Preprocessing before binary search: Building the initial UnionFind and processing the must-have edges and connectivity check costs O(n + m × α(n)), which is dominated by the binary search portion.

Therefore, the overall time complexity is O((m × α(n) + n) × log M).

Space Complexity: O(n)

The UnionFind data structure uses the p and size arrays, each of length n, requiring O(n) space. The recursive find with path compression may add O(n) stack depth in the worst case. No additional structures scale beyond O(n), so the total space complexity is O(n).

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

Common Pitfalls

Pitfall 1: Forgetting to force-include mandatory edges inside check(limit)

The most dangerous bug in this solution is that the feasibility function check(limit) completely ignores the must flag. It only unions edges based on whether their (possibly doubled) strength meets limit:

for u, v, strength, _ in edges:   # the must flag is discarded with `_`
    if strength >= limit:
        uf.union(u, v)

This means a mandatory edge whose strength is below limit will silently be left out of the candidate spanning tree, yet the algorithm may still happily connect all the nodes using other edges and report success. The returned tree then violates the core requirement that every required edge must be included.

Concretely, suppose a must == 1 edge has strength 3, but the rest of the graph can be connected with edges of strength 5. The current code would happily return 5 (because the mandatory edge is simply never selected), even though any valid spanning tree is forced to contain that strength-3 edge, capping the true answer at 3.

The reason the bug is partially masked is that min_must_strength clamps the binary-search upper bound to the smallest mandatory strength. So for limit <= min_must_strength, the mandatory edges happen to satisfy strength >= limit and get unioned anyway. This makes the code produce the correct answer only because the upper bound equals the minimum mandatory strength — a fragile coincidence rather than correct logic. If the upper bound logic were ever changed, or if you reasoned about a mandatory edge that could be upgraded, the bug would surface immediately.

Solution: Always union mandatory edges first inside check, unconditionally, and detect cycles among them:

def check(limit: int) -> bool:
    uf = UnionFind(n)
    # 1. Mandatory edges MUST be included regardless of strength.
    for u, v, strength, must in edges:
        if must:
            uf.union(u, v)          # always include
    # 2. Free edges that meet the limit.
    for u, v, strength, must in edges:
        if not must and strength >= limit:
            uf.union(u, v)
    # 3. Upgradeable edges, spending at most k upgrades on edges
    #    that actually bridge two components.
    remaining = k
    for u, v, strength, must in edges:
        if not must and strength < limit and strength * 2 >= limit and remaining > 0:
            if uf.union(u, v):
                remaining -= 1
    return uf.count == 1

Note the refinement in step 3: only edges with strength < limit need an upgrade. An edge that already satisfies strength >= limit was handled for free in step 2, so spending an upgrade on it would waste budget.


Pitfall 2: Wasting upgrade budget on edges that already meet the limit

In the original second pass, the condition is only strength * 2 >= limit:

if strength * 2 >= limit and remaining > 0:
    if uf.union(u, v):
        remaining -= 1

An edge with strength >= limit also satisfies strength * 2 >= limit. If such an edge was not used in the first pass (because it would have formed a cycle at that moment) but later becomes a bridging edge, the code "spends" an upgrade on it even though no upgrade is needed. This prematurely drains remaining, potentially causing check to return False for a limit that is actually feasible — yielding an answer that is too small.

Solution: Restrict the upgrade pass to edges that genuinely require an upgrade by adding strength < limit:

if strength < limit and strength * 2 >= limit and remaining > 0:
    if uf.union(u, v):
        remaining -= 1

Pitfall 3: Off-by-one in the binary search midpoint causing an infinite loop

Because we search for the maximum feasible value, the midpoint must be upper-biased:

mid = (left + right + 1) >> 1
if check(mid):
    left = mid          # keep mid as a candidate
else:
    right = mid - 1

If you mistakenly use the standard lower-biased midpoint mid = (left + right) >> 1 together with the left = mid branch, then when right == left + 1, mid evaluates to left, and left = mid leaves the interval unchanged — an infinite loop. The + 1 in the midpoint is essential to guarantee progress when assigning left = mid.

Solution: Pair the assignment style with the matching midpoint bias:

  • When the "keep" branch does left = mid, use mid = (left + right + 1) >> 1.
  • When the "keep" branch does right = mid, use mid = (left + right) >> 1.

Pitfall 4: Integer overflow / wrong initial upper bound when no mandatory edges exist

min_must_strength is initialized to 10**6. If there are no mandatory edges, this sentinel survives and becomes the binary-search upper bound right. This is only correct if 10**6 is a genuine upper bound on the achievable stability. If an edge's doubled strength can exceed 10**6 (i.e., original strength approaching or above 10**6), the true answer could be larger than the sentinel, and the binary search will never explore it — silently capping the result.

Solution: When there are no mandatory edges, derive the upper bound from the data itself, accounting for the doubling:

if min_must_strength == 10**6 and not any(must for *_, must in edges):
    # No mandatory edge: the cap is the largest possible upgraded strength.
    min_must_strength = max((s * 2 for u, v, s, _ in edges), default=0)

This guarantees the search range covers every reachable stability value, regardless of the input magnitude.

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:

What's the output of running the following function using input 56?

1KEYBOARD = {
2    '2': 'abc',
3    '3': 'def',
4    '4': 'ghi',
5    '5': 'jkl',
6    '6': 'mno',
7    '7': 'pqrs',
8    '8': 'tuv',
9    '9': 'wxyz',
10}
11
12def letter_combinations_of_phone_number(digits):
13    def dfs(path, res):
14        if len(path) == len(digits):
15            res.append(''.join(path))
16            return
17
18        next_number = digits[len(path)]
19        for letter in KEYBOARD[next_number]:
20            path.append(letter)
21            dfs(path, res)
22            path.pop()
23
24    res = []
25    dfs([], res)
26    return res
27
1private static final Map<Character, char[]> KEYBOARD = Map.of(
2    '2', "abc".toCharArray(),
3    '3', "def".toCharArray(),
4    '4', "ghi".toCharArray(),
5    '5', "jkl".toCharArray(),
6    '6', "mno".toCharArray(),
7    '7', "pqrs".toCharArray(),
8    '8', "tuv".toCharArray(),
9    '9', "wxyz".toCharArray()
10);
11
12public static List<String> letterCombinationsOfPhoneNumber(String digits) {
13    List<String> res = new ArrayList<>();
14    dfs(new StringBuilder(), res, digits.toCharArray());
15    return res;
16}
17
18private static void dfs(StringBuilder path, List<String> res, char[] digits) {
19    if (path.length() == digits.length) {
20        res.add(path.toString());
21        return;
22    }
23    char next_digit = digits[path.length()];
24    for (char letter : KEYBOARD.get(next_digit)) {
25        path.append(letter);
26        dfs(path, res, digits);
27        path.deleteCharAt(path.length() - 1);
28    }
29}
30
1const KEYBOARD = {
2    '2': 'abc',
3    '3': 'def',
4    '4': 'ghi',
5    '5': 'jkl',
6    '6': 'mno',
7    '7': 'pqrs',
8    '8': 'tuv',
9    '9': 'wxyz',
10}
11
12function letter_combinations_of_phone_number(digits) {
13    let res = [];
14    dfs(digits, [], res);
15    return res;
16}
17
18function dfs(digits, path, res) {
19    if (path.length === digits.length) {
20        res.push(path.join(''));
21        return;
22    }
23    let next_number = digits.charAt(path.length);
24    for (let letter of KEYBOARD[next_number]) {
25        path.push(letter);
26        dfs(digits, path, res);
27        path.pop();
28    }
29}
30

Recommended Readings

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

Load More