Remove Max Number of Edges to Keep Graph Fully Traversable
HardPrompt
Alice and Bob have an undirected graph of n nodes and three types of edges:
- Type 1: Can be traversed by Alice only.
- Type 2: Can be traversed by Bob only.
- Type 3: Can be traversed by both Alice and Bob.
Given an array edges where edges[i] = [typei, ui, vi] represents a bidirectional edge of type typei between nodes ui and vi, find the maximum number of edges you can remove so that after removing the edges, the graph can still be fully traversed by both Alice and Bob. The graph is fully traversed by Alice and Bob if starting from any node, they can reach all other nodes.
Return the maximum number of edges you can remove, or return -1 if Alice and Bob cannot fully traverse the graph.
Example 1:

Input: n = 4, edges = [[3,1,2],[3,2,3],[1,1,3],[1,2,4],[1,1,2],[2,3,4]]
Output: 2
Explanation: If we remove the 2 edges [1,1,2] and [1,1,3]. The graph will still be fully traversable by Alice and Bob. Removing any additional edge will not make it so. So the maximum number of edges we can remove is 2.Example 2:

Input: n = 4, edges = [[3,1,2],[3,2,3],[1,1,4],[2,1,4]]
Output: 0
Explanation: Notice that removing any edge will not make the graph fully traversable by Alice and Bob.Example 3:

Input: n = 4, edges = [[3,2,3],[1,1,2],[2,3,4]]
Output: -1
Explanation: In the current graph, Alice cannot reach node 4 from the other nodes. Likewise, Bob cannot reach 1. Therefore it's impossible to make the graph fully traversable.
Constraints:
1 <= n <= 1051 <= edges.length <= min(105, 3 * n * (n - 1) / 2)edges[i].length == 31 <= typei <= 31 <= ui < vi <= n- All tuples
(typei, ui, vi)are distinct.
Approaches
2 approaches with complexity analysis and trade-offs.
This approach also follows the same greedy strategy of prioritizing Type 3 edges, then Type 1 for Alice, and Type 2 for Bob. However, instead of using a specialized data structure like Union-Find to check for connectivity and cycles, it builds the graphs for Alice and Bob explicitly using adjacency lists and performs a graph traversal (like Breadth-First Search or Depth-First Search) for each potential edge to see if it's necessary.
Algorithm
- Initialize two adjacency lists,
aliceAdjandbobAdj, fornnodes. - Initialize
edges_used = 0. - For each Type 3 edge
(u, v):- Run a traversal (BFS/DFS) on
aliceAdjto check ifuandvare already connected. - If not, add the edge to both
aliceAdjandbobAdj, and incrementedges_used.
- Run a traversal (BFS/DFS) on
- For each Type 1 edge
(u, v):- Run a traversal on
aliceAdjto check ifuandvare connected. - If not, add the edge to
aliceAdjand incrementedges_used.
- Run a traversal on
- For each Type 2 edge
(u, v):- Run a traversal on
bobAdjto check ifuandvare connected. - If not, add the edge to
bobAdjand incrementedges_used.
- Run a traversal on
- Finally, run a traversal on
aliceAdjto check if it's a single connected component. - Do the same for
bobAdj. - If both are fully connected, return
total_edges - edges_used. Otherwise, return-1.
Walkthrough
We maintain two separate graphs, graph_alice and graph_bob, represented by adjacency lists. The goal is to add the minimum number of edges to make both graphs connected.
- Process Type 3 Edges: Iterate through all Type 3 edges. For each edge
(u, v), we first check ifuandvare already connected in the currentgraph_alice. This check is done by starting a traversal (e.g., BFS) fromuand seeing ifvis reachable. If they are not connected, we add the edge to bothgraph_aliceandgraph_boband increment a counter for used edges. - Process Type 1 Edges: Next, iterate through Type 1 edges. For each edge
(u, v), we check for connectivity only ingraph_alice. Ifuandvare not connected ingraph_alice, we add the edge to it and increment the used edges count. - Process Type 2 Edges: Similarly, for each Type 2 edge
(u, v), we check for connectivity ingraph_bob. If they are not connected, we add the edge tograph_boband increment the used edges count. After considering all edges, we perform a final check. We run a traversal ongraph_alicestarting from an arbitrary node (e.g., node 1) and count the visited nodes. If the count is notn, Alice's graph is not fully traversable. We do the same forgraph_bob. If both graphs are fully connected, we returntotal_edges - edges_used. Otherwise, it's impossible, so we return -1.
import java.util.*; class Solution { public int maxNumEdgesToRemove(int n, int[][] edges) { List<List<Integer>> aliceAdj = new ArrayList<>(); List<List<Integer>> bobAdj = new ArrayList<>(); for (int i = 0; i <= n; i++) { aliceAdj.add(new ArrayList<>()); bobAdj.add(new ArrayList<>()); } int edgesUsed = 0; // Process Type 3 edges for (int[] edge : edges) { if (edge[0] == 3) { if (!isConnected(aliceAdj, edge[1], edge[2], n)) { aliceAdj.get(edge[1]).add(edge[2]); aliceAdj.get(edge[2]).add(edge[1]); bobAdj.get(edge[1]).add(edge[2]); bobAdj.get(edge[2]).add(edge[1]); edgesUsed++; } } } // Process Type 1 edges for (int[] edge : edges) { if (edge[0] == 1) { if (!isConnected(aliceAdj, edge[1], edge[2], n)) { aliceAdj.get(edge[1]).add(edge[2]); aliceAdj.get(edge[2]).add(edge[1]); edgesUsed++; } } } // Process Type 2 edges for (int[] edge : edges) { if (edge[0] == 2) { if (!isConnected(bobAdj, edge[1], edge[2], n)) { bobAdj.get(edge[1]).add(edge[2]); bobAdj.get(edge[2]).add(edge[1]); edgesUsed++; } } } if (isFullyConnected(aliceAdj, n) && isFullyConnected(bobAdj, n)) { return edges.length - edgesUsed; } return -1; } // Checks if two nodes are connected using BFS private boolean isConnected(List<List<Integer>> adj, int u, int v, int n) { Queue<Integer> queue = new LinkedList<>(); boolean[] visited = new boolean[n + 1]; queue.offer(u); visited[u] = true; while (!queue.isEmpty()) { int curr = queue.poll(); if (curr == v) return true; for (int neighbor : adj.get(curr)) { if (!visited[neighbor]) { visited[neighbor] = true; queue.offer(neighbor); } } } return false; } // Checks if the entire graph is connected private boolean isFullyConnected(List<List<Integer>> adj, int n) { if (n == 0) return true; Queue<Integer> queue = new LinkedList<>(); boolean[] visited = new boolean[n + 1]; int count = 0; queue.offer(1); visited[1] = true; count++; while (!queue.isEmpty()) { int curr = queue.poll(); for (int neighbor : adj.get(curr)) { if (!visited[neighbor]) { visited[neighbor] = true; queue.offer(neighbor); count++; } } } return count == n; }}Complexity
Time
`O(E * (N + E))`. For each of the `E` edges, we perform a traversal to check for connectivity. The traversal takes `O(N + K)` where `K` is the number of edges currently in the graph. In the worst case, `K` is `O(E)`, leading to `O(N + E)` per check. This results in a total time complexity of `O(E * (N + E))`.
Space
`O(N + E)` to store the adjacency lists for both Alice's and Bob's graphs.
Trade-offs
Pros
Conceptually straightforward, relying on basic graph traversal algorithms.
Avoids the need for a more complex data structure like Union-Find.
Cons
Very inefficient. The repeated traversals lead to a high time complexity.
For large graphs, this approach will be too slow and likely result in a "Time Limit Exceeded" error.
This approach uses a greedy strategy combined with the Union-Find (or Disjoint Set Union) data structure. The core idea is to prioritize edges that are useful for both Alice and Bob (Type 3), as they provide the most value. We build the required spanning trees for Alice and Bob by first adding all necessary Type 3 edges, and then supplementing with Type 1 edges for Alice and Type 2 edges for Bob. The Union-Find data structure is used to efficiently track the connected components and determine if adding an edge is useful (i.e., connects two previously disconnected components).
Algorithm
- Initialize two DSU structures,
dsu_aliceanddsu_bob, fornnodes. - Initialize
edges_used = 0. - Iterate through
edgesfor Type 3 edges. For each(u, v):- If
dsu_alice.union(u, v)is successful, also performdsu_bob.union(u, v)and incrementedges_used.
- If
- Iterate through
edgesfor Type 1 edges. For each(u, v):- If
dsu_alice.union(u, v)is successful, incrementedges_used.
- If
- Iterate through
edgesfor Type 2 edges. For each(u, v):- If
dsu_bob.union(u, v)is successful, incrementedges_used.
- If
- Check if both
dsu_aliceanddsu_bobrepresent connected graphs (i.e., have one component). - If both are connected, return
total_edges - edges_used. - Otherwise, return
-1.
Walkthrough
We maintain two separate Union-Find structures, one for Alice (dsu_alice) and one for Bob (dsu_bob), to track their respective graph connectivity. The algorithm proceeds in three phases:
- Process Type 3 Edges: Iterate through all Type 3 edges. For each edge
(u, v), attempt to union the nodesuandvindsu_alice. If the union is successful (meaninguandvwere in different components), it means this edge is essential for building the spanning forest. We increment our count of used edges. Since this is a Type 3 edge, it's also useful for Bob, so we perform the same union operation indsu_bob. - Process Type 1 Edges: After considering all Type 3 edges, iterate through Type 1 edges. For each edge
(u, v), attempt to union them only indsu_alice. If the union is successful, increment the used edges count. These edges only contribute to Alice's connectivity. - Process Type 2 Edges: Similarly, iterate through Type 2 edges. For each edge
(u, v), attempt to union them only indsu_bob. If successful, increment the used edges count. Finally, after processing all edges, we check if both Alice's and Bob's graphs are fully connected. A graph withnnodes is connected if its corresponding DSU structure has exactly one component. If both are connected, the maximum number of removable edges is the total number of initial edges minus the count of used edges. If either is not connected, it's impossible to make them both fully traversable, so we return -1.
class DSU { private int[] parent; private int components; public DSU(int n) { parent = new int[n + 1]; for (int i = 1; i <= n; i++) { parent[i] = i; } components = n; } public int find(int i) { if (parent[i] == i) { return i; } return parent[i] = find(parent[i]); } public boolean union(int i, int j) { int rootI = find(i); int rootJ = find(j); if (rootI != rootJ) { parent[rootI] = rootJ; components--; return true; } return false; } public boolean isConnected() { return components == 1; }} class Solution { public int maxNumEdgesToRemove(int n, int[][] edges) { DSU dsuAlice = new DSU(n); DSU dsuBob = new DSU(n); int edgesUsed = 0; // Process Type 3 edges for (int[] edge : edges) { if (edge[0] == 3) { if (dsuAlice.union(edge[1], edge[2])) { dsuBob.union(edge[1], edge[2]); edgesUsed++; } } } // Process Type 1 edges for (int[] edge : edges) { if (edge[0] == 1) { if (dsuAlice.union(edge[1], edge[2])) { edgesUsed++; } } } // Process Type 2 edges for (int[] edge : edges) { if (edge[0] == 2) { if (dsuBob.union(edge[1], edge[2])) { edgesUsed++; } } } if (dsuAlice.isConnected() && dsuBob.isConnected()) { return edges.length - edgesUsed; } return -1; }}Complexity
Time
`O(E * α(N))`, where `E` is the number of edges and `N` is the number of nodes. `α(N)` is the inverse Ackermann function, which is practically a small constant. This is because we iterate through the edges a constant number of times, and each DSU operation is amortized `O(α(N))`.
Space
`O(N)` to store the parent arrays for the two DSU structures.
Trade-offs
Pros
Highly efficient due to the near-constant time complexity of Union-Find operations.
The greedy strategy is intuitive and correctly minimizes the number of edges required.
Simple to implement.
Cons
Requires understanding of the Union-Find data structure.
Solutions
Solution
class UnionFind { private int [] p ; private int [] size ; public int cnt ; public UnionFind ( int n ) { p = new int [ n ]; size = new int [ n ]; for ( int i = 0 ; i < n ; ++ i ) { p [ i ] = i ; size [ i ] = 1 ; } cnt = n ; } public int find ( int x ) { if ( p [ x ] != x ) { p [ x ] = find ( p [ x ]); } return p [ x ]; } public boolean union ( int a , int b ) { int pa = find ( a - 1 ), pb = find ( b - 1 ); if ( pa == pb ) { return false ; } if ( size [ pa ] > size [ pb ]) { p [ pb ] = pa ; size [ pa ] += size [ pb ]; } else { p [ pa ] = pb ; size [ pb ] += size [ pa ]; } -- cnt ; return true ; } } class Solution { public int maxNumEdgesToRemove ( int n , int [][] edges ) { UnionFind ufa = new UnionFind ( n ); UnionFind ufb = new UnionFind ( n ); int ans = 0 ; for ( var e : edges ) { int t = e [ 0 ], u = e [ 1 ], v = e [ 2 ]; if ( t == 3 ) { if ( ufa . union ( u , v )) { ufb . union ( u , v ); } else { ++ ans ; } } } for ( var e : edges ) { int t = e [ 0 ], u = e [ 1 ], v = e [ 2 ]; if ( t == 1 && ! ufa . union ( u , v )) { ++ ans ; } if ( t == 2 && ! ufb . union ( u , v )) { ++ ans ; } } return ufa . cnt == 1 && ufb . cnt == 1 ? ans : - 1 ; } }class UnionFind { public: int cnt ; UnionFind ( int n ) { p = vector < int > ( n ); size = vector < int > ( n , 1 ); iota ( p . begin (), p . end (), 0 ); cnt = n ; } bool unite ( int a , int b ) { int pa = find ( a - 1 ), pb = find ( b - 1 ); if ( pa == pb ) { return false ; } if ( size [ pa ] > size [ pb ]) { p [ pb ] = pa ; size [ pa ] += size [ pb ]; } else { p [ pa ] = pb ; size [ pb ] += size [ pa ]; } -- cnt ; return true ; } int find ( int x ) { if ( p [ x ] != x ) { p [ x ] = find ( p [ x ]); } return p [ x ]; } private: vector < int > p , size ; }; class Solution { public: int maxNumEdgesToRemove ( int n , vector < vector < int >>& edges ) { UnionFind ufa ( n ); UnionFind ufb ( n ); int ans = 0 ; for ( auto & e : edges ) { int t = e [ 0 ], u = e [ 1 ], v = e [ 2 ]; if ( t == 3 ) { if ( ufa . unite ( u , v )) { ufb . unite ( u , v ); } else { ++ ans ; } } } for ( auto & e : edges ) { int t = e [ 0 ], u = e [ 1 ], v = e [ 2 ]; ans += t == 1 && ! ufa . unite ( u , v ); ans += t == 2 && ! ufb . unite ( u , v ); } return ufa . cnt == 1 && ufb . cnt == 1 ? ans : - 1 ; } };class UnionFind : def __init__ ( self , n ): self . p = list ( range ( n )) self . size = [ 1 ] * n self . cnt = n def find ( self , x ): if self . p [ x ] != x : self . p [ x ] = self . find ( self . p [ x ]) return self . p [ x ] def union ( self , a , b ): pa , pb = self . find ( a - 1 ), self . find ( b - 1 ) if pa == pb : return False if self . size [ pa ] > self . size [ pb ]: self . p [ pb ] = pa self . size [ pa ] += self . size [ pb ] else : self . p [ pa ] = pb self . size [ pb ] += self . size [ pa ] self . cnt -= 1 return True class Solution : def maxNumEdgesToRemove ( self , n : int , edges : List [ List [ int ]]) -> int : ufa = UnionFind ( n ) ufb = UnionFind ( n ) ans = 0 for t , u , v in edges : if t == 3 : if ufa . union ( u , v ): ufb . union ( u , v ) else : ans += 1 for t , u , v in edges : if t == 1 : ans += not ufa . union ( u , v ) if t == 2 : ans += not ufb . union ( u , v ) return ans if ufa . cnt == 1 and ufb . cnt == 1 else - 1Video walkthrough
Newsletter
One sharp idea, every week
System design and interview prep — short enough to finish.
No spam. Unsubscribe anytime.
Practice
Same difficulty — related problems to reinforce the pattern.