3600. Maximize Spanning Tree Stability with Upgrades
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ᵢandvᵢindicate an undirected edge between nodesuᵢandvᵢ.sᵢis the strength of the edge.mustᵢis an integer (0or1). Ifmustᵢ == 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:
- Build a spanning tree that includes every required edge (those with
mustᵢ == 1). - Optionally upgrade up to
knon-required edges, each upgrade doubling the strength of one edge (each such edge upgraded at most once). - Maximize the smallest edge strength in the resulting spanning tree.
- Return
-1if no valid spanning tree exists (for example, the graph cannot be connected, or the required edges already form a cycle).
How We Pick the Algorithm
Why Disjoint Set Union?
This problem maps to Disjoint Set Union through a short path in the full flowchart.
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 FlowchartIntuition
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 >= limcan be used directly, no upgrade needed. - Any edge whose strength is below
limbut wheres * 2 >= limcan 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 < limare 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:
- 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 boundmnfor binary search. We also union these edges first; if any of them forms a cycle, the required set is invalid and we return-1. - 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
findmethod, so that lookups become nearly constant time. - Union by size in the
unionmethod, 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
mnamong them, since the final stability can never exceed this value. - Union the two endpoints of each required edge. If a
unioncall returnsFalse, 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:
- Create a fresh Union-Find.
- First pass: union all edges whose original strength satisfies
s >= lim. These are "free" edges that meet the requirement without any upgrade. - Second pass: with remaining upgrade budget
rem = k, scan edges wheres * 2 >= lim. For each such edge, if it connects two separate components (uf.union(u, v)returnsTrue), we spend one upgrade and decrementrem. We stop spending onceremreaches0. - 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)isTrue, the answer can be at leastmid, so we movel = mid. - Otherwise,
midis too large, so we shrink the range withr = mid - 1.
When the loop ends, l holds the maximum possible stability.
Complexity Analysis
- Time complexity:
O(m × log(mn) × α(n)), wheremis the number of edges,mnis the upper bound of stability, andαis the inverse Ackermann function from Union-Find. Eachcheckcall scans all edges twice, and we performO(log(mn))such calls during binary search. - Space complexity:
O(n), used by the Union-Find arrayspandsize.
Example Walkthrough
Let's trace through a concrete example to see how Binary Search + Union-Find solves this problem.
Input:
n = 4(nodes0, 1, 2, 3)k = 1(at most one upgrade allowed)edges:
| Index | u | v | s (strength) | must |
|---|---|---|---|---|
| 0 | 0 | 1 | 6 | 1 |
| 1 | 1 | 2 | 3 | 0 |
| 2 | 2 | 3 | 5 | 0 |
| 3 | 0 | 3 | 2 | 0 |
| 4 | 1 | 3 | 4 | 0 |
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
0and1. 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
- 0–1 ✓ →
- Already connected with free edges, no upgrades needed. Returns
True. check(4)isTrue→ setl = 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
- 0–1 ✓ →
- 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.
- edge1(1–2, s=3, 3*2=6≥5): connects
- Returns
True. check(5)isTrue→ setl = 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
- 0–1 ✓ →
- 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
3is still isolated.
- edge1(1–2, s=3, 6≥6): connects
- Returns
False(cnt == 2). check(6)isFalse→ setr = 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
811/**
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}
1541class 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};
1301// 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}
140Time 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], wheremnis bounded by the maximum edge strengthM. This results inO(log M)iterations. -
Cost per
check(lim)call: Inside each invocation:- A new
UnionFind(n)instance is created, costingO(n)for initializing thep,sizearrays. - The edges are iterated twice. Each iteration processes all
medges, and eachunion/findoperation costsO(α(n))amortized time (whereαis the inverse Ackermann function) due to path compression and union by size. This contributesO(m × α(n)). - Thus, each
checkcall costsO(m × α(n) + n).
- A new
-
Combining: The binary search runs
checkforO(log M)iterations, givingO((m × α(n) + n) × log M). -
Preprocessing before binary search: Building the initial
UnionFindand processing the must-have edges and connectivity check costsO(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, usemid = (left + right + 1) >> 1. - When the "keep" branch does
right = mid, usemid = (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 RoadmapWhat'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
271private 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}
301const 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}
30Recommended Readings
Greedy Introduction Greedy Algorithms A greedy algorithm builds a solution one step at a time and at every step it takes the choice that looks best right now without reconsidering earlier choices It never backtracks and never plans ahead The bet is that a sequence of locally best choices adds up to
Union Find Disjoint Set Union Data Structure Introduction Prerequisite Depth First Search Review problems dfs_intro So far in our DFS discussions we have mostly dealt with graphs with all the nodes connected to each other and thus forming one connected component Let's now look at a more general case where
https assets algo monster cover_photos graph svg Graph Fundamentals Tree with 0 cycle At this point you should be pretty familiar with trees A tree is a special kind of graph a connected acyclic cycle less graph A graph may contain cycle s and nodes could be disconnected A tree
Want a Structured Path to Master System Design Too? Don’t Miss This!