DSA sheet · Graphs · Connected components pattern
Number of Provinces
This is the first real graph question in the sheet, and it teaches the single most useful graph idea: counting connected components. The teacher shows that the input is already a ready-made adjacency matrix, that one DFS call colours a whole group as "visited", and that the number of times we have to start a fresh DFS is the answer. She codes it with DFS and explains what changes for BFS. The same loop ("for every node, if not visited, start a traversal and count it") comes back in Number of Islands, Flood Fill, Surrounded Regions and many more.
Every part below follows the same order:
① the question in simple words → ② what the constraints tell us → ③ intuition → ④ building the logic from examples → ⑤ approach steps → ⑥ code → ⑦ code line by line → ⑧ dry run → ⑨ complexity & remember
- Part 0 · Graph basics this page needs
- Part A · Count provinces with DFS (the teacher's code)
- Part B · Count provinces with BFS
- Part C · Revision page
Part 0 · Graph basics this page needs
Nodes and edges
A graph is a set of points joined by lines. The points are called nodes (or vertices). The lines are called edges. In this problem every city is a node and every direct road between two cities is an edge.
(1)—(2)—(3) (4)—(5) nodes: 1 2 3 4 5 edges: 1-2, 2-3, 4-5
Directed, undirected, weighted
| kind | meaning | drawn as |
|---|---|---|
| undirected | The edge works both ways. If 1 is joined to 2, then 2 is joined to 1. Provinces is undirected: a road between two cities can be used in both directions. | (1)—(2) |
| directed | The edge is a one-way street: you can go from 1 to 2 but not back (unless there is a second edge 2 → 1). | (1)→(2) |
| weighted | Each edge carries a number, such as a distance or a cost. Provinces has no weights: an edge either exists or not. | (1)—5—(2) |
Three ways the input can arrive
| form | what it looks like (for the picture above, cities 1..5 stored as 0..4) | how to find the neighbours of node u |
|---|---|---|
| edge list | [[0,1],[1,2],[3,4]]: one pair per edge | you must scan every edge, so first convert it to an adjacency list |
| adjacency matrix | an n × n grid of 0/1. M[u][v] = 1 means u and v are joined. | walk along row u and pick every column v with a 1 |
| adjacency list | [[1],[0,2],[1],[4],[3]]: list number u holds u's neighbours | just read adj[u] |
Most graph problems hand you an edge list, and the first job is to build an adjacency list, because it gives the neighbours of a node directly. Here is how in Python:
n = 5
edges = [[0, 1], [1, 2], [3, 4]]
adj = [[] for _ in range(n)] # one empty list per node
for u, v in edges:
adj[u].append(v) # u can reach v
adj[v].append(u) # undirected: v can reach u too
print(adj) # [[1], [0, 2], [1], [4], [3]]
# the same list built from an adjacency matrix
M = [[1, 1, 0, 0, 0],
[1, 1, 1, 0, 0],
[0, 1, 1, 0, 0],
[0, 0, 0, 1, 1],
[0, 0, 0, 1, 1]]
adj2 = [[v for v in range(n) if M[u][v] == 1 and v != u] for u in range(n)]
print(adj2) # [[1], [0, 2], [1], [4], [3]]For a directed edge u → v, you would only do adj[u].append(v).
The visited array, and why we cannot live without it
A graph, unlike a tree, has no "down" direction. In an undirected graph, if 1 lists 2 as a neighbour, then 2 also lists 1. Start at 1, go to 2, and 2 says "my neighbour 1 is waiting"… you go back to 1, then to 2 again, forever. A visited array (one True/False slot per node) breaks the loop: mark a node the moment you reach it, and never enter a marked node again.
Connected components
A connected component is a group of nodes where you can walk from any node to any other node using edges, and nothing outside the group is reachable. The picture above has 2 components: {1, 2, 3} and {4, 5}. "Province" in this problem is just another name for a connected component.
The two ways to walk a graph
| DFS: Depth First Search | BFS: Breadth First Search | |
|---|---|---|
| idea | Go deep along one neighbour, then its neighbour, and so on. Come back only when stuck. | Visit the start, then all its neighbours, then their neighbours: layer by layer, like a ripple. |
| tool | recursion (Python's call stack) or an explicit stack | a queue, collections.deque |
| for counting components | Either one is fine. Both reach every node that is connected to the start, and that is all we need. | |
from collections import deque
adj = [[1], [0, 2], [1], [4], [3]]
n = len(adj)
def dfs(u, visited, order):
visited[u] = True # mark as soon as we arrive
order.append(u)
for v in adj[u]:
if not visited[v]:
dfs(v, visited, order)
def bfs(start, visited, order):
visited[start] = True
queue = deque([start])
while queue:
u = queue.popleft()
order.append(u)
for v in adj[u]:
if not visited[v]:
visited[v] = True # mark when we put it in the queue
queue.append(v)
seen = [False] * n
out = []
dfs(0, seen, out)
print(out) # [0, 1, 2]
seen = [False] * n
out = []
bfs(3, seen, out)
print(out) # [3, 4]Notice that one call from node 0 reached only 0, 1, 2. It could not reach 3 or 4. That is exactly the fact this problem is built on.
Part A · Count provinces with DFS
LeetCode 547 · in the sheet it is also called "count number of components"
1The question in simple words
There are n cities. Some pairs have a direct road between them. Connection also passes through a middle city: if A is joined to B, and B is joined to C, then A and C count as connected too (indirectly, through B).
A province is a group of cities that are all connected to each other (directly or indirectly), with no city outside the group connected to them. Return how many provinces there are, which means how many separate groups.
The roads are given as an n × n grid called isConnected: isConnected[i][j] = 1 means city i and city j have a direct road, and 0 means they don't.
(1)—(2)—(3) (4)—(5) group 1: 1, 2, 3 group 2: 4, 5 answer = 2
1 2 3 4 5 1 [ 1, 1, 0, 0, 0 ] 2 [ 1, 1, 1, 0, 0 ] 3 [ 0, 1, 1, 0, 0 ] 4 [ 0, 0, 0, 1, 1 ] 5 [ 0, 0, 0, 1, 1 ]
Cities 1 and 3 have no direct road (row 1, column 3 is 0), but both are joined to 2, so they still sit in the same province.
2What the constraints tell us
- 1 ≤ n ≤ 200 → at least one city, so the answer is at least 1. And n is small: an n × n matrix has at most 40,000 cells, so even reading every cell is cheap. An O(n²) solution is perfectly fine.
isConnected[i][i] = 1→ every city is marked as joined to itself. This is the diagonal. It is not a real road, and our code must not get confused by it (we'll see that the visited check handles it).isConnected[i][j] = isConnected[j][i]→ the matrix is symmetric, so the graph is undirected.- Values are only 0 or 1 → no weights.
- n ≤ 200 also means a recursion at most 200 calls deep, well under Python's default limit of 1000. Recursive DFS is safe here.
3Intuition: how to think about it
Picture the cities as islands of houses, and you have a tin of paint. Pick any unpainted city and spill paint on it. The paint flows along every road to every city it can reach, directly or through others, and stops only where roads end. One spill paints one whole province.
Now walk past every city in order. Whenever you find one that is still unpainted, it must belong to a province you haven't touched yet, so spill paint again and add 1 to your count. When you have walked past every city, the number of spills is the number of provinces.
In code, "spilling paint" is one DFS call, and "painted" is the visited array.
4Building the logic from the teacher's example
Step 1: reading the matrix: each row is a list of children
The teacher reads the first row slowly: [1, 1, 0, 0, 0].
- 1st value is 1 → city 1 is joined to city 1, which is itself.
- 2nd value is 1 → city 1 is joined to city 2.
- 3rd, 4th, 5th are 0 → city 1 is not directly joined to 3, 4 or 5.
So row u is simply the list of children (neighbours) of node u, written as 0/1 flags. Row 2 is [1, 1, 1, 0, 0]: city 2 is joined to 1, to itself, and to 3.
→ No. The reason we normally build one is to answer "who are the children of u?" quickly. This matrix already answers that: go to row u and walk across it, and every column with a 1 is a child. So the input itself is our adjacency structure (an adjacency matrix), and we skip the building step.
→ Python lists start at index 0. Her drawing labels cities 1..5, so city k lives in row k − 1. In the code we simply number the cities 0..n−1 from the start, and then node u is row u with no subtraction.
Step 2: one DFS marks one whole group
Send city 1 into a DFS. The DFS has a single job: mark the node as visited, then go to each unvisited child and do the same. From 1 it reaches 2, and from 2 it reaches 3. Then nothing new is left, so the call ends. Visited is now {1, 2, 3}. It could not reach 4 or 5, because no road leads there.
Now make a second call from 4 (the teacher adds that starting from 5 would work just as well, since any unvisited city of that group will do). It marks 4 and then 5. Now every city is visited, after 2 calls. And there are 2 groups. That's no accident: number of fresh DFS calls = number of provinces.
Step 3: why must we mark visited? (the infinite loop)
Without marking: at 1 we go to child 2. Row 2 says 1 is a child of 2, so we go to 1. Row 1 says 2 is a child, so we go to 2… The calls bounce 1 → 2 → 1 → 2 forever (in Python this ends in a RecursionError). Marking 1 as visited before looking at its children stops 2 from going back.
Step 4: how big is the visited array, and how do we know n?
The matrix has one row per city, so n = len(isConnected). Make visited of size n, all False (the teacher uses an int array with 0/1 in Java; True/False is the same idea). Slot u says whether city u has been painted.
Step 5: which cities do we start a DFS from?
We don't know in advance how far one call will spread. So we try every city in order, but only call DFS if it is still unvisited:
- city 1: not visited → call DFS, count = 1. (This paints 1, 2, 3.)
- city 2: already visited by city 1's call → skip.
- city 3: already visited → skip.
- city 4: not visited → call DFS, count = 2. (This paints 4, 5.)
- city 5: already visited → skip.
→ Because one call only spreads through roads. A city in a different province has no road to city 1, so the call can never reach it. The outer loop is what finds those other provinces.
Step 6: inside the DFS: which children can we enter?
In DFS(node), walk across row node with an index i from 0 to n−1. Enter i only if both:
isConnected[node][i] == 1, so there really is a road from node to i, andvisited[i]is False, so i is not painted yet.
i != node check?→ No. When DFS(node) walks its row, it meets
isConnected[node][node] = 1. But we marked node visited as the very first line, so condition 2 fails and we don't go "from 2 to 2". The visited check quietly handles the self-loop. The teacher points this out in her walk-through: at city 1, column 1 is a 1, "but it is already visited, so we won't go".!= 1. Which is right?→ She corrected herself on screen: it must be
== 1. A 1 means "there is a road", and we want to follow roads. With != 1 you'd be walking to cities you are not connected to, and everything would merge into one group.5Approach steps
n = len(isConnected),visited = [False] * n,provinces = 0.- For every city i from 0 to n−1: if i is not visited, call
dfs(i)and doprovinces += 1. dfs(node): markvisited[node] = True. Then for every i from 0 to n−1: ifisConnected[node][i] == 1and i is not visited, calldfs(i).- Return
provinces.
6Code (Python)
class Solution:
def findCircleNum(self, isConnected):
n = len(isConnected) # one row per city
visited = [False] * n
provinces = 0
for i in range(n):
if not visited[i]: # a city no earlier call reached
self.dfs(i, visited, isConnected)
provinces += 1 # one new call = one new province
return provinces
def dfs(self, node, visited, isConnected):
visited[node] = True # mark first
n = len(isConnected) # n is not known in here, so read it again
for i in range(n): # walk across row "node"
if isConnected[node][i] == 1 and not visited[i]:
self.dfs(i, visited, isConnected)The teacher hit an error on screen because her DFS function used n, which only existed in the main function. The fix was to read the length again inside DFS, as in the line marked above.
7Code line by line
| line | what it means |
|---|---|
| n = len(isConnected) | Number of rows = number of cities. |
| visited = [False] * n | Nobody is painted yet. |
| for i in range(n): if not visited[i]: | Try every city as a start, but only the unpainted ones. A painted city already belongs to a province we counted. |
| self.dfs(i, visited, isConnected) | Paint the whole province that contains city i. We pass the same visited list, so marks made inside are seen by the outer loop. |
| provinces += 1 | Each fresh start found a brand-new group. |
| visited[node] = True | Mark on arrival, so no child can bounce back to us. |
| for i in range(n): | Look at every column of row node, which means every possible child. |
| if isConnected[node][i] == 1 and not visited[i]: | Go only along a real road, and only to an unpainted city. This also skips the diagonal. |
| self.dfs(i, visited, isConnected) | Go deep: paint i and everything reachable from i before coming back to the next column. |
8Dry run
The teacher's example, written with code indexes: cities 1..5 become 0..4. So the roads are 0–1, 1–2 and 3–4.
(0)—(1)—(2) (3)—(4)
| step | where we are | columns checked in this row | call stack after | visited after | provinces |
|---|---|---|---|---|---|
| 1 | outer i = 0, unvisited → start dfs(0) | - | dfs(0) | {0} | 0 (+1 when the call returns) |
| 2 | dfs(0) walks row 0 | col 0: 1 but visited (self) ✗ · col 1: 1, unvisited ✓ | dfs(0), dfs(1) | {0, 1} | 0 |
| 3 | dfs(1) walks row 1 | col 0: visited ✗ · col 1: self ✗ · col 2: 1, unvisited ✓ | dfs(0), dfs(1), dfs(2) | {0, 1, 2} | 0 |
| 4 | dfs(2) walks row 2 | col 0: 0 ✗ · col 1: visited ✗ · col 2: self ✗ · cols 3, 4: 0 ✗ | dfs(0), dfs(1) | {0, 1, 2} | 0 |
| 5 | back in dfs(1), finish row 1 | cols 3, 4: 0 ✗ | dfs(0) | {0, 1, 2} | 0 |
| 6 | back in dfs(0), finish row 0 | cols 2, 3, 4: 0 ✗ | (empty) | {0, 1, 2} | 1 |
| 7 | outer i = 1, i = 2 | already visited → skip | (empty) | {0, 1, 2} | 1 |
| 8 | outer i = 3, unvisited → start dfs(3) | cols 0–2: 0 ✗ · col 3: self ✗ · col 4: 1, unvisited ✓ | dfs(3), dfs(4) | {0, 1, 2, 3, 4} | 1 |
| 9 | dfs(4) walks row 4 | col 3: visited ✗ · col 4: self ✗ | (empty after both return) | all | 2 |
| 10 | outer i = 4 | visited → skip | (empty) | all | 2 |
(0✓)—(1✓)—(2✓) (3)—(4) provinces = 1
(0✓)—(1✓)—(2✓) (3✓)—(4✓) provinces = 2
Two fresh calls were made, so the answer is 2 ✓.
9Complexity & remember
- Time O(n²). DFS runs exactly once per city in total, because visited stops repeats. Each run walks a whole row of n columns, whether the values are 0 or 1. So n cities × n columns = n². With n ≤ 200 that is at most 40,000 checks, far below the roughly 10⁸ operations that would cause TLE.
- Space O(n). The visited array is n. The recursion stack depends on the shape of the graph. In the example, the first call only grew 3 deep (0 → 1 → 2) even though there were 5 cities. In the worst case every city is in one province, joined like a chain, and the stack grows n deep. So the stack is also O(n).
→ Two small slips of the tongue. It should be 200 × 200 = 40,000, which is 4 × 10⁴. The conclusion is the same: far under 10⁸, so it runs fast.
Inside DFS: mark first, then follow every 1 in the row to an unvisited city.
The number of fresh DFS calls is the number of components.
Part B · Count provinces with BFS
The teacher's words: the only real job is to reach every node of a group and mark it visited. DFS does that, but so does BFS. The outer loop and the counting stay exactly the same. Only the "paint one province" function changes.
1The question
Same as Part A: count the groups in isConnected.
2Constraints
Same as Part A. One small bonus for BFS: it uses no recursion, so it never worries about Python's recursion limit, even if n were much bigger.
3Intuition: ripples instead of a deep dive
Instead of chasing one road as far as it goes, BFS paints the start city, then all its direct neighbours, then their neighbours, like a ripple spreading on water. It still stops when no new city can be reached, so one BFS still paints exactly one province.
4What changes, and the "when to mark" question
The teacher's recipe: create a queue and put the start node in it. To find the children, use the same row scan with the same if-check. But where DFS would make a recursive call, BFS just appends that child to the queue, so it gets processed later when it is popped.
→ The teacher says "pop and mark". That still gives the right count, but a city could be pushed twice before it is popped (two neighbours could both see it as unvisited). Example: in a triangle 0–1–2, popping 0 pushes 1 and 2, and popping 1 sees 2 still unmarked and pushes it again. Marking at push time avoids these duplicates, so each city enters the queue once. We use that version. The answer is identical either way.
5Approach steps
- Same outer loop: for each unvisited city i, call
bfs(i), thenprovinces += 1. bfs(start): mark start, put it in a deque.- While the queue isn't empty: pop the front node, then scan its row. For every column i with a 1 that isn't visited: mark it and append it.
6Code (Python)
from collections import deque
class Solution:
def findCircleNum(self, isConnected):
n = len(isConnected)
visited = [False] * n
provinces = 0
for i in range(n):
if not visited[i]:
self.bfs(i, visited, isConnected)
provinces += 1
return provinces
def bfs(self, start, visited, isConnected):
n = len(isConnected)
visited[start] = True
queue = deque([start])
while queue:
node = queue.popleft()
for i in range(n):
if isConnected[node][i] == 1 and not visited[i]:
visited[i] = True # mark when pushed: never queued twice
queue.append(i) # instead of a recursive call7Code line by line (only the new lines)
| line | what it means |
|---|---|
| visited[start] = True queue = deque([start]) | Paint the starting city and line it up to have its row scanned. |
| while queue: | Keep going while some painted city still has an unscanned row. |
| node = queue.popleft() | Take the city that has waited longest (front of the queue). This is what makes it level by level. |
| if isConnected[node][i] == 1 and not visited[i]: | Exactly the DFS check: a real road, to an unpainted city. |
| visited[i] = True queue.append(i) | Paint it now, and scan its row later. |
8Dry run
Same graph: (0)—(1)—(2) (3)—(4).
| step | node popped | new neighbours found | queue after | visited after | provinces |
|---|---|---|---|---|---|
| 1 | outer i = 0 → bfs(0) | - | [0] | {0} | 0 |
| 2 | 0 | 1 | [1] | {0, 1} | 0 |
| 3 | 1 | 2 (0 is visited) | [2] | {0, 1, 2} | 0 |
| 4 | 2 | none | [ ] | {0, 1, 2} | 1 |
| 5 | outer i = 1, 2 visited → skip; i = 3 → bfs(3) | - | [3] | {0..3} | 1 |
| 6 | 3 | 4 | [4] | all | 1 |
| 7 | 4 | none | [ ] | all | 2 |
9Complexity & remember
- Time O(n²): each city is popped once and its whole row is scanned. That's the same reasoning as DFS, so the teacher says the time is the same for both.
- Space O(n): the visited array plus a queue that never holds more than n cities.
Part C · Revision page
| DFS (recursion) | BFS (queue) | |
|---|---|---|
| outer loop | for each city: if not visited → traverse from it, count += 1 | |
| children of u | every column i in row u with isConnected[u][i] == 1 | |
| moving to a child | recursive call dfs(i) | visited[i] = True; queue.append(i) |
| where pending work lives | the call stack | the deque |
| time / space | O(n²) / O(n) | O(n²) / O(n) |
| input format | what you do first |
|---|---|
| adjacency matrix (this problem) | nothing: row u already lists the children of u |
| edge list | build adj with two appends per undirected edge |
| adjacency list | nothing: use adj[u] |
2. The matrix row of u is u's list of children, so no adjacency list is needed.
3. One DFS/BFS from a city marks its whole province as visited.
4. Loop over all cities, and every unvisited one starts a new traversal and adds 1.
5. O(n²) time because each row is scanned once. O(n) space.
visited → 1 → 2 → 1 → 2 … forever✗ writing
!= 1 instead of == 1 for "there is a road"✗ counting inside DFS instead of in the outer loop (you'd count cities, not groups)
✗ using
n inside the helper when it only exists in the main function✗ thinking the diagonal 1s are roads to other cities (visited makes them harmless)
s = Solution() print(s.findCircleNum([[1,1,0,0,0],[1,1,1,0,0],[0,1,1,0,0],[0,0,0,1,1],[0,0,0,1,1]])) # 2 print(s.findCircleNum([[1,1,0],[1,1,0],[0,0,1]])) # 2 print(s.findCircleNum([[1,0,0],[0,1,0],[0,0,1]])) # 3 print(s.findCircleNum([[1,1,1],[1,1,1],[1,1,1]])) # 1 print(s.findCircleNum([[1]])) # 1
Based on this video: Number of Provinces | Graph