Understand
30-second explanation
Build the interview-ready mental model before comparing implementations.
Group the nodes that appear in edges into connected components and report the smallest and largest component sizes (only components with at least two nodes count). Union each edge's endpoints with Union-Find, carry a size on every root, then sweep the roots for the min and max size. O(E · α(N)).
Interview contextWhat is testedWhere candidates slipHow to stand outExplore interview contextHide interview context
What's being tested
Whether you reach for Union-Find with size tracking on a connectivity-only question, read sizes only at roots, and correctly aggregate the min and max across components.
Where candidates slip
Reading the size array at a non-root node (stale value), counting isolated nodes that should be excluded, or initializing 'smallest' to 0 so it never updates.
How to stand out
Maintain size on merge, sweep only roots, seed smallest to +infinity, and note that the bipartite labeling is irrelevant to the merging — it is just an undirected graph.
Choose
Compare the available approaches
Use constraints and trade-offs to select the route you can defend in an interview.
Interactive decision treeInteractiveChoose the right approachFirst decision previewConnected components?Need paths or just sizes?PathsSizes onlyExplore the full treeCollapse the tree
How to read it:Edge 2–6 fuses two pairs into a size-4 component; the others stay at size 2.
Learn & trace
Build each solution step by step
Move from the core idea to a visual trace, implementation, proof, and edge cases.
Approach 1 of 2
DFS / BFS Flood Fill (Baseline)
1Hint 1Explore hintHide hint
What do you need per component? Just its node count. How do you count every node reachable from a start?
2Hint 2Explore hintHide hint
Build an adjacency list, then walk each unvisited node, flood-filling its whole component and counting how many nodes it touches.
3Hint 3Explore hintHide hint
Track the smallest and largest counts among components with size ≥ 2 as you discover them.
A · Core idea
If the only quantity you need is each component's size, a flood fill delivers it directly.
Why flood fill works. Start at any unvisited node and walk every node reachable through edges, marking visited and counting. When the walk stalls you have one full component and its exact size. Move to the next unvisited node and repeat. Each node is counted once, so the sizes partition every node that has an edge.
Why iterate over only the nodes in edges. Untouched labels are isolated and excluded by the problem, so it is cleanest to seed flood fills from nodes that actually appear in an edge (or to skip any component of size 1 when aggregating).
Why the traversal must be iterative. With up to 3 × 10⁴ labels and 10⁵ edges, a component can be a long chain. A recursive DFS would nest one frame per node and risk a stack overflow, so use an explicit stack or a BFS queue. Order does not affect the sizes.
Aggregating extremes. Keep smallest (seeded to infinity) and largest (seeded to 0). For each component of size s ≥ 2, update both. Seeding smallest to infinity — not 0 — is what lets the first real component set it.
B · Visual dry run
Interactive dry run
Select a frame to connect the decision with its visual state.
edges = [[1,5],[1,6],[2,4]]Interactive diagram
Flood fill measuring a component
flood from 1From node 1 the walk reaches 5 and 6 — size 3.
2 3C · Code with teaching comments
Reference implementation previewpublic int[] componentSizes(int totalNodes, int[][] edges) { List<List<Integer>> adj = new ArrayList<>(); for (int i = 0; i <= totalNodes; i++) adj.add(new ArrayList<>()); boolean[] inEdge = new boolean[totalNodes + 1]; for (int[] e : edges) {Explore the full codeHide the full code
D · Why it works and complexityO(V + E) timeO(V + E) spaceOpen the invariant and proofExplore the proofHide the proof
The problem reduces to component sizes; a flood fill produces them in one linear pass. The only subtlety is aggregating correctly — seed the minimum to infinity so it can actually shrink.
ComplexityO(V + E) time, O(V + E) space — Building the adjacency list is O(V + E). Each node and edge is visited once during the flood fills → O(V + E). Tracking min/max is O(1) per component.
E · Edge cases and pitfalls3Recursive DFS on a long chain — stack overflow.Explore edge casesHide edge cases
Pitfalls
- Recursive DFS on a long chain — stack overflow.
- Seeding smallest to 0 — it can never decrease below 0, so it never updates.
- Counting isolated nodes as size-1 components when the problem excludes them.
Approach 2 of 2 · Optimal
Union-Find with Size Tracking (Optimal)
1Hint 1Explore hintHide hint
You only need sizes, never paths. What structure merges groups and tracks their sizes in near-constant time per edge?
2Hint 2Explore hintHide hint
Union-Find: union each edge's endpoints with path compression and union by rank, and add the merged sizes onto the surviving root.
3Hint 3Explore hintHide hint
After all unions, scan the labels; for each label that is its own root, its size is its component size. Take the min and max over roots of size ≥ 2.
A · Core idea
Union-Find is the natural fit when edges arrive as a list and you only care about group sizes.
Building components. Every label starts in its own set. For each edge [u, v], union(u, v): if their roots differ, attach one tree under the other and add the smaller component's size onto the surviving root. After all edges, two labels share a root exactly when an edge-path connects them, and each root's size is its component size.
Path compression and union by rank. A naïve find can walk an O(N) chain; compressing the path during find and attaching the shorter tree beneath the taller one keep each operation at O(α(N)) — at most about 4 for any realistic input. The find loop is a while, never recursive, so even a long chain of merges cannot overflow the stack.
Why size lives on the root. When two sets merge, only the surviving root's size is updated. A non-root keeps whatever stale value it had before being absorbed, so you must read size only after confirming find(x) == x. Reading it at a non-root is the classic bug.
Aggregating. Sweep all labels; for each that is its own root with size ≥ 2, update smallest (seeded to infinity) and largest (seeded to 0). Because every label in an edge sits in a component of size ≥ 2, the filter mainly guards against any label you might have allocated but never connected. The bipartite labeling never enters the logic — union treats left and right labels identically.
B · Visual dry run
Interactive dry run
Select a frame to connect the decision with its visual state.
edges = [[1,6],[2,7],[3,8],[4,9],[2,6]]Interactive diagram
Disjoint-set forest after all unions
union 1-6, 2-7, 3-8, 4-9Root of the size-4 set holds size 4; the two size-2 sets hold 2 each.
2 4C · Code with teaching comments
D · Why it works and complexityO(E · α(N)) timeO(N) spaceOpen the invariant and proofExplore the proofHide the proof
Carrying size on the root during merges means the answer is one root sweep away. Union-Find's near-constant operations make this the go-to for size/min/max/count over components when the input is an edge list.
ComplexityO(E · α(N)) time, O(N) space — Each union and find is O(α(N)) — inverse Ackermann, effectively constant. E edges give O(E · α(N)); the final root sweep is O(N · α(N)). Space is O(N) for parent, rank, and size arrays.
E · Edge cases and pitfalls7edges = [[1,2]] — Single component of size 2; smallest and largest both 2.Explore edge casesHide edge cases
Edge cases handled
edges = [[1,2]]Single component of size 2; smallest and largest both 2.duplicate edge [[1,2],[1,2]]Second union finds equal roots → no-op; size stays 2.one giant chainAll labels merge into one root; smallest == largest == total connected nodes.
Pitfalls
- Reading size at a non-root — returns a stale partial size.
- Seeding smallest to 0 — it never updates downward.
- Recursive find on a long chain — risks stack overflow; use the iterative loop.
- Updating size on both roots during a merge — double counts.
Practice
Try the solution yourself
Edit the optimal implementation, test another input, and observe the result.
Editable code previewpublic int[] componentSizes(int totalNodes, int[][] edges) { int[] parent = new int[totalNodes + 1]; int[] rank = new int[totalNodes + 1]; int[] size = new int[totalNodes + 1]; boolean[] inEdge = new boolean[totalNodes + 1];Explore the playgroundClose the playground
Avoid mistakes
Recognize the interview traps
Understand why common attempts fail before reviewing the corrected implementation.
1Reading size at a non-root
When two sets merge, only the surviving root's size is updated; the absorbed node keeps its old size. Reading size[x] without checking find(x) == x returns a wrong, partial count.
Explore the code comparisonHide the code comparison
Reading size at a non-root
When two sets merge, only the surviving root's size is updated; the absorbed node keeps its old size. Reading size[x] without checking find(x) == x returns a wrong, partial count.
Don't write this
for i in range(1, total_nodes + 1):
largest = max(largest, size[i]) # size[i] stale unless i is a rootWrite this instead
for i in range(1, total_nodes + 1):
if find(i) == i: # only roots carry the true size
largest = max(largest, size[i])2Seeding smallest to zero
min(0, anything positive) is always 0, so the smallest never changes from its seed. The minimum must start above every possible size — infinity.
Explore the code comparisonHide the code comparison
Seeding smallest to zero
min(0, anything positive) is always 0, so the smallest never changes from its seed. The minimum must start above every possible size — infinity.
Don't write this
int smallest = 0;
smallest = Math.min(smallest, size[i]); // stuck at 0Write this instead
int smallest = Integer.MAX_VALUE;
smallest = Math.min(smallest, size[i]);3Recursive find on a long chain
A component can be a chain of tens of thousands of nodes. A recursive find nests one frame per node and overflows the stack; an iterative loop keeps depth in the heap.
Explore the code comparisonHide the code comparison
Recursive find on a long chain
A component can be a chain of tens of thousands of nodes. A recursive find nests one frame per node and overflows the stack; an iterative loop keeps depth in the heap.
Don't write this
def find(x):
if parent[x] == x:
return x
return find(parent[x]) # depth = chain lengthWrite this instead
def find(x):
while parent[x] != x:
parent[x] = parent[parent[x]]
x = parent[x]
return xRevise
Carry the pattern into the interview
Pattern to remember
Union-Find component sizing, then min/max over roots
Code pattern to memorize
union each edge's endpoints, maintain size[root]
smallest = +inf, largest = 0
for each root r with size[r] >= 2:
smallest = min(smallest, size[r])
largest = max(largest, size[r])
return (smallest, largest)Rules to remember
- 1Only components with ≥ 2 nodes count — every node here appears in some edge, so each component already has at least 2.
- 2Keep a
sizearray updated only on merges, and read it only at roots — non-roots hold stale sizes. - 3The graph is bipartite (left labels
1..N, right labelsN+1..2N), but Union-Find does not care about sides — it just merges labels. - 4Use path compression + union by rank so each operation is
O(α(N)), effectively constant. - 5
findclimbs parents in a loop — iterative, so a long chain cannot overflow the call stack.
Use this pattern when
- You only need component **sizes or membership**, not paths or distances
- Edges arrive as a flat list and you merge groups incrementally
- Min/max/count over connected components
- Bipartite or general undirected graphs where sides are irrelevant to connectivity
Choose another pattern when
- You need shortest paths or distances → BFS/Dijkstra, not Union-Find
- You must enumerate the actual members of each component → DFS/BFS flood fill is more natural
- Edges are removed over time → Union-Find does not support deletion cleanly
- You need the bipartite structure itself (2-coloring) → bipartite check, not sizing
Interview takeaway
Union-Find turns a flat edge list into component sizes in near-constant time per edge. Carry the size on the root during merges, then a single sweep over roots gives any aggregate you need — here the smallest and largest.
Continue
Transfer the pattern to another problem
Use a direct variation or pattern sibling to prove the idea is reusable.
Transferable patternUnion-Find Component Sizing with Aggregation. Recognize it when an edge list defines groups and you need a size aggregate — min, max, count, or per-group value. Carry size on the root during merges, sweep roots once. The same skeleton powers Number of Connected Components, Journey to the Moon, and Roads and Libraries.
Practice queueDirect variations and pattern siblings
Direct follow-ups
- Number of Connected ComponentsReturn the component count instead of the min/max sizes — same Union-Find.
- Journey to the MoonUse the same component sizes to count cross-component pairs.
- Roads and LibrariesPer-component greedy cost on top of the same size computation.
- Bipartite Graph CheckWhen the two sides matter, verify 2-colorability instead of just sizing components.