๐ŸŽ“ A1Learner
๐Ÿ”

DSA Number of Provinces

Number of Provinces counts the separate groups in a network of cities. Union-find merges linked cities, and the answer is the count of sets left standing.

The problem

Number of Provinces gives a square matrix about n cities. Entry i, j is one when cities i and j link directly, and zero when they do not.

A province is a maximal group of cities linked directly or through others. Maximal means no linked city sits outside the group. The answer is the number of provinces.

The cases used in this article

cases = [
    ([[1, 1, 0], [1, 1, 0], [0, 0, 1]], 2),
    ([[1, 0, 0, 1], [0, 1, 1, 0], [0, 1, 1, 0], [1, 0, 0, 1]], 2),
    ([[1, 0, 0], [0, 1, 0], [0, 0, 1]], 3),
]
for matrix, expected in cases:
    print("map", matrix, "-> provinces:", expected)

The second map holds two pairs, cities 0 and 3, and cities 1 and 2. No link joins the pairs. The third map links nothing, so every city stands alone.

Intuition: subtract the links from the cities

The first idea counts links. Each link seems to fuse two cities into one group. Cities minus links looks like a province count.

The link count on a triangle of cities

matrix = [[1, 1, 1], [1, 1, 1], [1, 1, 1]]
n = 3
links = sum(matrix[i][j] for i in range(n) for j in range(i + 1, n))
print("cities:", n, "links:", links)
print("cities minus links:", n - links)
print("true provinces: 1")

The count reaches zero provinces for a map that plainly holds one. The third link joins two cities already in the same group. Only merges that join different groups lower the count.

Brute force: flood each province

The brute force floods outward from the first unvisited city. A flood is a walk that spreads along links to every reachable city. Every city the flood reaches belongs to one province, and the next unvisited city starts the next flood.

A flood scans the full row of each city it visits. The check counter records every matrix entry read.

The flood fill, with its checks counted

def provinces_brute(matrix):
    n = len(matrix)
    seen = [False] * n
    checks = 0
    count = 0
    for start in range(n):
        if seen[start]:
            continue
        count += 1
        stack = [start]
        seen[start] = True
        while stack:
            city = stack.pop()
            for other in range(n):
                checks += 1
                if matrix[city][other] == 1 and not seen[other]:
                    seen[other] = True
                    stack.append(other)
    return count, checks

for matrix in ([[1, 1, 0], [1, 1, 0], [0, 0, 1]], [[1, 0, 0], [0, 1, 0], [0, 0, 1]]):
    count, checks = provinces_brute(matrix)
    print("provinces:", count, "matrix checks:", checks)

Each flood reads n entries per city, and the floods together read the whole matrix. The method is simple and correct, and it pays the full matrix price.

The optimal solution: union-find over the links

Union-find keeps one set per province, and each link unions the sets of its two cities while the province count falls only when a union really merges. That one sentence is the whole method.

Find returns the root that names a set. Two cities share a province exactly when their roots match. The final answer is the number of distinct roots.

Union-find on every case

class UnionFind:
    def __init__(self, n):
        self.parent = list(range(n))
        self.rank = [0] * n

    def find(self, x):
        if self.parent[x] != x:
            self.parent[x] = self.find(self.parent[x])
        return self.parent[x]

    def union(self, a, b):
        ra, rb = self.find(a), self.find(b)
        if ra == rb:
            return False
        if self.rank[ra] < self.rank[rb]:
            ra, rb = rb, ra
        self.parent[rb] = ra
        if self.rank[ra] == self.rank[rb]:
            self.rank[ra] += 1
        return True

def provinces(matrix):
    n = len(matrix)
    uf = UnionFind(n)
    merges = 0
    for i in range(n):
        for j in range(i + 1, n):
            if matrix[i][j] == 1:
                if uf.union(i, j):
                    merges += 1
    roots = len({uf.find(i) for i in range(n)})
    return roots, merges

cases = [
    ([[1, 1, 0], [1, 1, 0], [0, 0, 1]], 2),
    ([[1, 0, 0, 1], [0, 1, 1, 0], [0, 1, 1, 0], [1, 0, 0, 1]], 2),
    ([[1, 0, 0], [0, 1, 0], [0, 0, 1]], 3),
]
for matrix, expected in cases:
    count, merges = provinces(matrix)
    print("map -> provinces:", count, "merges:", merges, "match:", count == expected)

All three maps pass. Map two needs two merges for its two pairs, and the counter shows them.

Walkthrough: two pairs in the second map

Four cities start as four single sets. The matrix holds two links. Each link triggers one union, and the parent array prints after each one.

The parent array after each union

parent = list(range(4))

def find(x):
    if parent[x] != x:
        parent[x] = find(parent[x])
    return parent[x]

def union(a, b):
    ra, rb = find(a), find(b)
    if ra != rb:
        parent[rb] = ra

print("start:", parent)
union(0, 3)
print("after union(0, 3):", parent)
union(1, 2)
print("after union(1, 2):", parent)
print("roots:", sorted({find(i) for i in range(4)}))
print("provinces:", len({find(i) for i in range(4)}))

Two roots remain standing. Each root names one province, so the answer is two. City 3 shares a root with city 0 without any direct scan of their link beyond the one union.

Complexity

The flood reads every matrix entry, so its time is O(n squared). Union-find reads only the upper triangle, half the entries, and each find is nearly constant with path compression.

Both methods store one small array per city, so their extra space is O(n).

Counting the work instead of timing it

def chain(n):
    m = [[0] * n for _ in range(n)]
    for i in range(n):
        m[i][i] = 1
        if i + 1 < n:
            m[i][i + 1] = m[i + 1][i] = 1
    return m

def brute_checks(matrix):
    n = len(matrix)
    seen = [False] * n
    checks = 0
    for start in range(n):
        if seen[start]:
            continue
        stack = [start]
        seen[start] = True
        while stack:
            city = stack.pop()
            for other in range(n):
                checks += 1
                if matrix[city][other] == 1 and not seen[other]:
                    seen[other] = True
                    stack.append(other)
    return checks

def union_checks(matrix):
    n = len(matrix)
    parent = list(range(n))

    def find(x):
        if parent[x] != x:
            parent[x] = find(parent[x])
        return parent[x]

    checks = 0
    for i in range(n):
        for j in range(i + 1, n):
            checks += 1
            if matrix[i][j] == 1:
                ra, rb = find(i), find(j)
                if ra != rb:
                    parent[rb] = ra
    return checks

for n in (4, 8, 16):
    matrix = chain(n)
    print("cities", n, "- flood checks:", brute_checks(matrix), " union checks:", union_checks(matrix))

At 16 cities the flood pays 256 checks against 120 for the triangle scan. The matrix itself forces real work on any method. Reading only half of it is the saving on offer.

Edge cases and pitfalls

One city forms one province. A lone city needs no links to count.

The diagonal of the matrix is always one. Reading it as links would union every city with itself. Those unions change nothing, and the scan skips the diagonal for that reason.

Edge cases, run

class UnionFind:
    def __init__(self, n):
        self.parent = list(range(n))
        self.rank = [0] * n

    def find(self, x):
        if self.parent[x] != x:
            self.parent[x] = self.find(self.parent[x])
        return self.parent[x]

    def union(self, a, b):
        ra, rb = self.find(a), self.find(b)
        if ra == rb:
            return False
        if self.rank[ra] < self.rank[rb]:
            ra, rb = rb, ra
        self.parent[rb] = ra
        if self.rank[ra] == self.rank[rb]:
            self.rank[ra] += 1
        return True

def provinces(matrix):
    n = len(matrix)
    uf = UnionFind(n)
    merges = 0
    for i in range(n):
        for j in range(i + 1, n):
            if matrix[i][j] == 1:
                if uf.union(i, j):
                    merges += 1
    roots = len({uf.find(i) for i in range(n)})
    return roots, merges

print("one city:", provinces([[1]])[0])
print("two unlinked cities:", provinces([[1, 0], [0, 1]])[0])
print("all linked:", provinces([[1, 1, 1], [1, 1, 1], [1, 1, 1]])[0])
print("empty matrix:", provinces([])[0])

The empty matrix answers zero. No cities means no provinces, and the loops simply never run.

Exercise

Five cities start alone and three unions each merge two sets. How many provinces remain?