About thirty lines of C# that answer “are these two things connected?” in near constant time. They keep answering it correctly as new connections arrive.
DFS also finds connected components, in O(V + E). Union-Find wins when the graph is dynamic. Edges arrive one at a time and you must answer after each one. DFS would have to start over every time. Union-Find just absorbs the edge.
int[]. The root points at itself and acts as the set’s name. Find(x) walks up to the root. Two elements are in the same set exactly when Find returns the same root. Union(a, b) hangs one root under the other. That merges two whole trees in one array write.Reading the figure. Arrows point from a node to its parent. Before the union there are two trees, rooted at 1 and 5. Union(3, 5) finds root 1 for node 3 and root 5 for node 5. The smaller tree’s root, 5, gets one new parent pointer, drawn in green. Node 6 never moves.
Naive Union-Find can turn into a linked list. Then each operation costs O(n). Two small heuristics fix it, and you need both to get the famous bound.
O(log n).Find, re-point the nodes you walk past closer to the root. The walk was happening anyway, so it is free.O(log n) amortised.O(α(n)).α is the inverse Ackermann function. It grows so slowly that α(n) ≤ 4 for any n that fits in memory. So the operations are constant time for every practical purpose. Tarjan proved this bound is tight. The full proof is in CLRS chapter 19.Both work and both give the same bound. Size counts the elements in the set. Rank is an upper bound on the tree height. Prefer size. The number means something on its own, so “how big is this group” comes for free. It is also harder to get subtly wrong.
/// <summary>
/// Union-Find over 0 .. n-1, with path halving and union by size.
/// </summary>
public sealed class DisjointSet
{
private readonly int[] _parent; // _parent[x] = x means x is a root
private readonly int[] _size; // only valid at roots: elements in that set
/// <summary>The number of disjoint sets remaining.</summary>
/// <remarks>Settable so a caller can start from 0, as Islands II does.</remarks>
public int Count { get; set; }
/// <summary>Creates n singleton sets, one per element.</summary>
/// <param name="n">Number of elements, labelled 0 .. n-1.</param>
/// <example>new DisjointSet(4).Count == 4</example>
public DisjointSet(int n)
{
ArgumentOutOfRangeException.ThrowIfNegative(n);
_parent = new int[n];
_size = new int[n];
// Invariant: elements 0 .. i-1 are already their own one-element roots.
for (int i = 0; i < n; i++)
{
_parent[i] = i; // everyone starts as their own root
_size[i] = 1; // 1: each set holds just its root
}
Count = n; // n: one set per element at the start
}
/// <summary>Root of x's set, shortening the path on the way up.</summary>
/// <param name="x">Any element.</param>
/// <returns>The root that names x's set.</returns>
/// <example>new DisjointSet(3).Find(2) == 2</example>
public int Find(int x)
{
// Invariant: x is always on the path from the start node to the root.
// A root is the one node that is its own parent.
while (_parent[x] != x)
{
// Path halving: point x at its grandparent, then step up.
// Same bound as full compression, in one loop with no recursion.
_parent[x] = _parent[_parent[x]];
x = _parent[x];
}
return x;
}
/// <summary>Merges the sets holding a and b.</summary>
/// <param name="a">One element.</param>
/// <param name="b">The other element.</param>
/// <returns>
/// True if they were separate and are now merged. False if they were
/// already together, which means this edge closes a cycle.
/// </returns>
/// <example>new DisjointSet(2).Union(0, 1) == true</example>
public bool Union(int a, int b)
{
int rootA = Find(a), rootB = Find(b);
if (rootA == rootB)
return false; // same root: already one set
// Attach the smaller tree under the larger one.
// This keeps trees shallow, so Find stays fast.
if (_size[rootA] < _size[rootB])
(rootA, rootB) = (rootB, rootA);
_parent[rootB] = rootA;
_size[rootA] += _size[rootB]; // rootA now holds both sets
Count--; // -1: two sets became one
return true;
}
/// <summary>True if a and b are in the same set.</summary>
/// <param name="a">One element.</param>
/// <param name="b">The other element.</param>
/// <returns>Whether a and b share a root.</returns>
/// <example>new DisjointSet(2).Connected(0, 1) == false</example>
public bool Connected(int a, int b) => Find(a) == Find(b); // same root, same set
/// <summary>How many elements share x's set.</summary>
/// <param name="x">Any element.</param>
/// <returns>The size of x's set.</returns>
/// <example>new DisjointSet(5).SizeOf(3) == 1</example>
public int SizeOf(int x) => _size[Find(x)]; // sizes are only kept at roots
}
Write this once and reuse it. Every problem below is this class plus a few lines of mapping.
bool return from Union is the most useful line in the class. false means the two endpoints were already connected. So the edge is redundant and closes a cycle. That one return value solves Redundant Connection outright and drives Kruskal’s algorithm.// Full compression: every node on the path ends up pointing at the root.
int Find(int x) => _parent[x] == x ? x : (_parent[x] = Find(_parent[x]));
It costs one stack frame per level. A .NET thread has about 1 MB of stack. A chain of a million nodes, before any compression, can throw StackOverflowException. You cannot catch that exception, so the process dies. Path halving points each node at its grandparent while walking. It meets the same amortised bound and never recurses. Prefer it in C#.Find is shorter.Reading the figure. Arrows point from a node to its parent. The green node 0 is the root. Amber nodes are the ones Find(4) stands on as it jumps two levels at a time. On the right, the two green arrows are the pointers it rewrote. Nodes 1 and 3 keep their old parents, yet every node is now closer to the root.
Dictionary. When the elements are already 0 .. n-1, two int[] arrays are smaller and faster than a Dictionary<int, int>. Use a dictionary only for sparse or non-integer keys. Even then, the usual move is to map each key to a small integer first, then use this class unchanged.a == b instead of Find(a) == Find(b). Only roots identify a set._parent[b] = a instead of _parent[rootB] = rootA. Linking non-roots corrupts the structure.1..n, size the arrays at n + 1 and ignore slot 0. Otherwise you get an IndexOutOfRangeException on node n.Count. Decrement only on a real merge, inside the branch that links.DisjointSet a struct. A struct copy shares the arrays but not Count. Pass it to a helper and the count changes on the copy only. Keep it a class.Given n nodes labelled 0 to n-1 and a list of undirected edges, return the number of connected components.
n components, one per node. Every edge that joins two different components lowers the count by one. Edges inside a component change nothing.Count property does the whole job. There is no final pass to count distinct roots.public static class ConnectedComponents
{
/// <summary>Number of connected components in an undirected graph.</summary>
/// <param name="n">Node count. Nodes are labelled 0 .. n-1.</param>
/// <param name="edges">Undirected edges as [a, b] pairs.</param>
/// <returns>The number of connected components.</returns>
/// <example>CountComponents(5, [[0, 1], [1, 2], [3, 4]]) returns 2</example>
public static int CountComponents(int n, int[][] edges)
{
var dsu = new DisjointSet(n); // n nodes, so n separate sets to start
// Invariant: dsu.Count = components among the edges seen so far.
// [0] and [1] are the two endpoints of the pair.
foreach (var edge in edges)
dsu.Union(edge[0], edge[1]); // only a real merge lowers dsu.Count
return dsu.Count;
}
}
n = 5, edges [[0,1], [1,2], [3,4]]:
Count = 5.[0, 1]. Different roots, so merge. Count = 4.[1, 2]. Different roots, so merge. Count = 3.[3, 4]. Different roots, so merge. Count = 2.Components are {0, 1, 2} and {3, 4}.
Reading the figure. Arrows point from a node to its parent. Green nodes are roots, and each root names one set. Blue nodes point to a parent. The thick green arrow is the link made in that step. Count the green nodes in any frame and you get Count.
Effectively O(E). Quote the α factor, then say “which is at most 4, so effectively linear”. That is the complete answer.
public static class GraphValidTree
{
/// <summary>
/// A graph is a tree when it is connected and has exactly n-1 edges.
/// Equivalently: n-1 edges and no edge is ever redundant.
/// </summary>
/// <param name="n">Node count. Nodes are labelled 0 .. n-1.</param>
/// <param name="edges">Undirected edges as [a, b] pairs.</param>
/// <returns>True if the edges form one tree over all n nodes.</returns>
/// <example>IsTree(3, [[0, 1], [1, 2]]) returns true</example>
public static bool IsTree(int n, int[][] edges)
{
// n - 1: a tree on n nodes has exactly one fewer edge than nodes.
if (edges.Length != n - 1)
return false;
var dsu = new DisjointSet(n); // n nodes, each its own set
// Union is false on a cycle edge. With n - 1 edges and no cycle,
// the graph must be connected. All stops at the first false.
return edges.All(edge => dsu.Union(edge[0], edge[1]));
}
}
If any Union returns false, that edge closed a cycle, so it is not a tree. With exactly n - 1 edges and no cycle, connectivity follows on its own. That two-fact argument is worth having ready. Note that LINQ’s All short-circuits, so it stops at the first cycle edge. A lambda with a side effect inside LINQ is fine here, but say you know it is unusual.
n.1.[2, 2]: also a no-op. Correct.n = 0: the answer is 0. Zero-length arrays are legal in C#.A tree on n nodes had one extra edge added, which made exactly one cycle. Given the edge list, return the edge you can remove to restore a tree. If several answers exist, return the one that appears last in the input.
bool from Union is the entire solution. This problem exists to test whether you have that return value.1..n, so size the structure at n + 1.public static class RedundantConnection
{
/// <summary>The edge that closes the single cycle in a tree plus one edge.</summary>
/// <param name="edges">Undirected edges over nodes 1..n, in input order.</param>
/// <returns>The edge to remove: the last one in the input that closes a cycle.</returns>
/// <exception cref="ArgumentException">No edge closes a cycle.</exception>
/// <example>FindRedundant([[1, 2], [1, 3], [2, 3]]) returns [2, 3]</example>
public static int[] FindRedundant(int[][] edges)
{
// Size by the largest label, so bad input cannot index past the end.
// 0: labels start at 1, so 0 is a safe floor for the running max.
int maxNode = 0;
foreach (var edge in edges)
maxNode = Math.Max(maxNode, Math.Max(edge[0], edge[1]));
// + 1: nodes are 1-indexed, so slots 0..maxNode. Slot 0 is never used.
var dsu = new DisjointSet(maxNode + 1);
// Invariant: the edges before this one form a forest with no cycle.
foreach (var edge in edges)
{
// Union returns false when both ends already share a root.
// That means this edge creates a cycle.
if (!dsu.Union(edge[0], edge[1]))
return [edge[0], edge[1]];
}
throw new ArgumentException("no redundant edge: the graph is already a forest",
nameof(edges));
}
}
[[1,2], [1,3], [2,3]]:
[1, 2]. Find(1) = 1, Find(2) = 2. Different, so merge.[1, 3]. Find(1) = 1, Find(3) = 3. Different, so merge.[2, 3]. Find(2) = 1, Find(3) = 1. Same, so return [2, 3].Reading the figure. Each box adds one input edge. A green edge joined two different sets. Gray edges were added in earlier steps. The dashed red edge connects 2 and 3, which already share root 1. Union returns false there, and that edge is the answer.
A tree on n nodes has n - 1 edges. This graph has one more, so edges.Length == n. Many solutions just write new DisjointSet(edges.Length + 1), and that is correct under the promise. Nodes run from 1 to n, so the arrays need n + 1 slots. Slot 0 is allocated and never touched.
The version above scans for the largest label instead. It costs one extra pass. In return, input that breaks the promise gets a clear ArgumentException. With edges.Length + 1, a plain tree input would crash with IndexOutOfRangeException on the highest node. That is easy to miss on a small test.
[[1,2],[2,1]]: caught on the second edge.Union(x, x) returns false at once, so it is reported. Ask whether self-loops can appear.ArgumentException. Ask if null or an empty array is preferred.Union returning false is the answer. Scanning forwards also satisfies the last-in-input rule, because there is only one extra edge.”Each account is a name followed by a list of emails. Two accounts belong to the same person if they share at least one email. Merge them. Return each merged account as the name followed by its emails in sorted order. The same name may belong to different people.
Dictionary<string, int> from each email to the first account index that mentioned it.Find(owner). Each bucket is one person.StringComparer.Ordinal and put the name in front. Any account in the group has the right name. The root index is handy.public static class AccountsMerge
{
/// <summary>Merges accounts that share any email address.</summary>
/// <param name="accounts">Each entry is [name, email1, email2, ...].</param>
/// <returns>One entry per person: [name, ...emails sorted ascending].</returns>
/// <example>
/// Merge([["A", "x@z"], ["B", "y@z"], ["A", "x@z", "w@z"]])
/// returns [["A", "w@z", "x@z"], ["B", "y@z"]] in some order
/// </example>
public static List<List<string>> Merge(string[][] accounts)
{
var dsu = new DisjointSet(accounts.Length); // one set per account to start
var ownerOf = new Dictionary<string, int>(); // email -> first account index with it
// Invariant: every email in accounts 0 .. index-1 has an owner, and
// any two of those accounts that share an email are in one set.
for (int index = 0; index < accounts.Length; index++)
{
// [1..]: skip slot 0, which is the name. The rest are emails.
foreach (var email in accounts[index][1..])
{
// TryAdd is false when the email already has an owner.
if (!ownerOf.TryAdd(email, index))
dsu.Union(index, ownerOf[email]); // shared email: same person
}
}
// Bucket every email under the root of the account that owns it.
var groups = new Dictionary<int, List<string>>();
foreach (var (email, owner) in ownerOf)
{
int root = dsu.Find(owner);
if (!groups.TryGetValue(root, out var bucket))
groups[root] = bucket = [];
bucket.Add(email);
}
// Every account in a group carries the same name, so the root's will do.
// [0]: the name is the first entry of an account.
// Ordinal: plain char-code order, the same on every machine and culture.
return [.. groups.Select(g =>
(List<string>)[accounts[g.Key][0], .. g.Value.Order(StringComparer.Ordinal)])];
}
}
Accounts [["A","x@z"], ["B","y@z"], ["A","x@z","w@z"]]:
x@z. New, so owner is 0.y@z. New, so owner is 1.x@z. Seen, so Union(2, 0).w@z. New, so owner is 2.Accounts 0 and 2 now share a root, so their emails land in one bucket. Account 1 stands alone.
Reading the figure. Boxes on the left are accounts. Boxes in the middle are emails. Gray lines are emails seen for the first time, so they just record an owner. The amber line is x@z showing up a second time, which triggers Union(2, 0). Same color on the left means same root, and the right side shows the merged output.
E is the total number of emails. The Union-Find work is effectively linear, so the sort dominates. Name the sort as the bottleneck, not the merging. That is the sharp answer here.
Dictionary enumeration order is not guaranteed. So the groups come out in no fixed order, and tests must sort them before comparing. Second, the default string sort is culture-aware. Pass StringComparer.Ordinal to get the plain character order the problem expects.[1..] is empty, so it adds nothing and disappears from the output. Ask whether it should appear as a bare name.Union(i, i) is a no-op.You start with an m × n grid of water. Land is added one cell at a time. After each addition, report the current number of islands. Return the list of counts.
(r, c) becomes index r * n + c. Then the integer class works unchanged, with no tuple keys.Count at zero, because there is no land yet. This is why Count has a public setter.Count first, treating the new cell as its own island. Then union it with each of the four neighbours that is already land. Every successful union subtracts one, so the arithmetic settles itself.public static class NumberOfIslandsII
{
// The four neighbours of a cell: down, up, right, left.
// Each pair is (row change, column change). 1 moves forward, -1 back, 0 stays.
private static readonly (int Dr, int Dc)[] Directions = [(1, 0), (-1, 0), (0, 1), (0, -1)];
/// <summary>Island count after each land addition to an empty grid.</summary>
/// <param name="m">Number of rows.</param>
/// <param name="n">Number of columns.</param>
/// <param name="positions">Cells [row, col] turned into land, in order.</param>
/// <returns>The island count after each addition.</returns>
/// <example>
/// NumIslands2(3, 3, [[0, 0], [0, 1], [1, 2], [2, 1]]) returns [1, 1, 2, 3]
/// </example>
public static List<int> NumIslands2(int m, int n, int[][] positions)
{
// m * n: one slot per grid cell. checked: throw rather than wrap on overflow.
var dsu = new DisjointSet(checked(m * n)) { Count = 0 }; // 0: all water at first
var isLand = new bool[m, n]; // m rows of n cells, all false (water)
var answer = new List<int>(positions.Length); // one count per addition
// Invariant: dsu.Count = number of islands among the land cells so far.
foreach (var pos in positions)
{
int r = pos[0], c = pos[1]; // [0] is the row, [1] the column
if (isLand[r, c])
{
answer.Add(dsu.Count); // repeat position: nothing changes
continue;
}
isLand[r, c] = true;
dsu.Count++; // a brand new island, for the moment
foreach (var (dr, dc) in Directions)
{
int nr = r + dr, nc = c + dc; // the neighbour cell
// 0 <= ... < m and 0 <= ... < n keep the neighbour inside the grid.
if (nr >= 0 && nr < m && nc >= 0 && nc < n && isLand[nr, nc])
{
// Each successful merge takes the count back down by one.
// r * n + c flattens (row, col) to one id: n cells per row.
dsu.Union(r * n + c, nr * n + nc);
}
}
answer.Add(dsu.Count);
}
return answer;
}
}
Two C# details are worth a sentence. new bool[m, n] is a true rectangular array, one block of memory, indexed as isLand[r, c]. And checked(m * n) turns a silent 32-bit overflow into an OverflowException if the grid is huge.
m = 3, n = 3, positions [[0,0], [0,1], [1,2], [2,1]]:
[0, 0]. Count after +1 is 1. No land neighbours. Reported: 1.[0, 1]. Count after +1 is 2. Joins (0,0), so −1. Reported: 1.[1, 2]. Count after +1 is 2. Nothing adjacent. Reported: 2.[2, 1]. Count after +1 is 3. Nothing adjacent. Reported: 3.Notice [1,2] is diagonal to [0,1], and diagonals do not connect.
Reading the figure. Each grid is the state after one addition. The cell with the amber border is the one just added. Cells of the same color belong to the same island. In frame 2 the new cell touches (0,0). One union succeeds, so the count goes up 1 and back down 1. In frame 3 the new cell only touches (0,1) at a corner, so it starts a new island.
k is the number of additions. Each one does at most four unions, so the work per addition is constant.
+1 -3, a net -2. That is right: three islands plus a new cell became one. You get this for free because Union returns whether it merged.[1].m × n is huge but k is small, the full grid wastes memory. A Dictionary<(int, int), int> that maps each land cell to a small id avoids it. It costs a constant factor. Worth a sentence.int[], roots name the set, and Find(a) == Find(b) is the connectivity test.α(n) ≤ 4 for any real input. Treat the operations as constant time and say why.Union return whether it merged. That one bool gives cycle detection, component counting and Kruskal.r * cols + c, and accounts become their index.That closes the five classical parts. Part VI adds seven more shapes, starting with Pattern 17, Greedy. After the patterns come the advanced C# chapters. They start with types and memory, which explains why DisjointSet must be a class. The guides cover the mechanics around the patterns. See the C# toolkit, how to read a constraint as a hint, and the script to follow in the room. Also see how to test your own code and the BCL essentials. The Python version of this page is here.