DSA sheet · Graphs · 2-colouring pattern
Is Graph Bipartite?
Can we split the nodes of a graph into two teams so that every edge joins two different teams? The teacher explains the real definition (two groups), then switches to the version everyone uses in practice: give each node one of two colours, and give every neighbour the opposite colour. If a neighbour already has your colour, the graph is not bipartite. She stores the colours as 1 and −1 in one array that also acts as the visited array (0 = not visited), solves it with DFS, fixes a bug in her main loop on screen, and then shows the BFS version. The graph may be split into several pieces, so every piece must be checked.
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 · 2-colouring with DFS (the teacher's code)
- Part B · 2-colouring with BFS
- Part C · Revision page
Part 0 · Graph basics this page needs
Nodes and edges
A graph is a set of nodes (points, here numbered 0 to n−1) joined by edges (lines). Two nodes joined by an edge are neighbours (the teacher often calls them "children" of the node we're standing on).
Directed, undirected, weighted
| kind | meaning | drawn as |
|---|---|---|
| undirected | The edge works both ways: from a you can go to b, and from b to a. This problem is undirected. | (0)—(1) |
| directed | One-way edge. | (0)→(1) |
| weighted | Each edge has a number on it. No weights here. | (0)—5—(1) |
Three ways the input can arrive, and the adjacency list
| form | looks like (square 0-1-2-3-0) | neighbours of u |
|---|---|---|
| edge list | [[0,1],[1,2],[2,3],[3,0]] | scan all edges, so convert first |
| adjacency matrix | 4 × 4 grid of 0/1 | every v with M[u][v] == 1 |
| adjacency list | [[1,3],[0,2],[1,3],[0,2]] | graph[u] |
In this problem we are given the adjacency list directly: graph[u] is the list of u's neighbours. So, as the teacher points out, there is nothing to build. If you ever get an edge list instead, this is how you'd convert it:
n = 4
edges = [[0, 1], [0, 3], [1, 2], [2, 3]]
graph = [[] for _ in range(n)]
for u, v in edges:
graph[u].append(v)
graph[v].append(u) # both directions
print(graph) # [[1, 3], [0, 2], [1, 3], [0, 2]]The visited array, and why we can't skip it
The teacher repeats this in every video: around 90% of graph problems need a visited record. In an undirected graph, 0 lists 1 and 1 lists 0 back, so without a record we would bounce between them forever. You can keep a separate True/False array, or, when possible, reuse an array you already need by giving it special values. That's what we'll do here: one array holds 0 (not visited) or a colour (visited).
Connected components
A connected component is a group of nodes that can all reach each other. The problem warns that the graph may not be connected (recall Number of Provinces, where we counted these groups). So a single traversal from node 0 might not see everything. The fix is the usual outer loop: for every node, if it's not visited, start a new traversal from it.
(0)—(1) (4)—(5)
| | \ /
(3)—(2) (6)
two components: {0,1,2,3} and {4,5,6}
BFS and DFS
| DFS: Depth First Search | BFS: Breadth First Search | |
|---|---|---|
| idea | Go deep through one neighbour, then back up and try the next. | Spread level by level: the start, then all nodes 1 edge away, then 2 edges away… |
| tool | recursion (the call stack) | a queue: collections.deque |
from collections import deque
graph = [[1, 3], [0, 2], [1, 3], [0, 2], [5, 6], [4, 6], [4, 5]]
n = len(graph)
seen = [False] * n
order = []
def dfs(u):
seen[u] = True
order.append(u)
for v in graph[u]:
if not seen[v]:
dfs(v)
for i in range(n):
if not seen[i]:
dfs(i)
print(order) # [0, 1, 2, 3, 4, 5, 6]
seen = [False] * n
order = []
for i in range(n):
if not seen[i]:
seen[i] = True
q = deque([i])
while q:
u = q.popleft()
order.append(u)
for v in graph[u]:
if not seen[v]:
seen[v] = True
q.append(v)
print(order) # [0, 1, 3, 2, 4, 5, 6]New idea for this page: 2-colouring
Colour the start node with colour A. Every neighbour gets colour B, their neighbours get A again, and so on: the colour flips at every edge. If we ever find an edge whose two ends have the same colour, the flip rule has been broken, and no 2-team split is possible. That is the whole pattern.
Part A · 2-colouring with DFS
LeetCode 785 · Is Graph Bipartite?
1The question in simple words
You get an undirected graph with n nodes (0 to n−1) as an adjacency list graph. The graph is bipartite if you can split all nodes into two groups, A and B, so that every edge has one end in A and the other end in B. In other words, no edge joins two members of the same group. Return True or False.
(0)———(1) | \ | | \ | | \| (3)———(2) edges: 0-1 0-2 0-3 1-2 2-3
(0)———(1) | | | | (3)———(2) edges: 0-1 0-3 1-2 2-3
Reading the input: graph[0] = [1,2,3] means node 0 has edges to 1, 2 and 3. graph[1] = [0,2] means 1 is joined to 0 and 2, and so on. Since the graph is undirected, every edge shows up in both lists.
2What the constraints tell us
1 ≤ n ≤ 100→ small. Any O(n + E) or even O(n²) idea is instant. Recursion depth is at most 100, so DFS is safe in Python.- No self-edges (u is never in
graph[u]) and no duplicate edges. Good: a self-edge would join a node to itself, same group, instantly not bipartite; we don't have to think about it. - If v is in
graph[u], then u is ingraph[v]: it really is undirected. - The graph may not be connected. This one line decides the shape of the main function (see ④).
- Brute force would try every way to put n nodes into 2 groups: 2ⁿ splits. With n = 100 that's 2¹⁰⁰, hopeless. We need something linear.
3Intuition: alternate the teams
The teacher tries Example 1 by hand: put 0 in A, 1 in B, 2 in A, 3 in B. The idea behind "alternating": a node and its neighbour must be in different groups, so as you walk along edges the group must keep switching A, B, A, B…
A B (0)———(1) | \ | | \ | edge 0-2: A and A ✗ | \| (3)———(2) B A
Edges 0-1, 1-2, 2-3, 3-0 are all fine (A–B). But edge 0-2 joins two A's. And there was no other choice: once 0 is A, its neighbour 1 is forced to be B, and then 1's neighbour 2 is forced to be A. So Example 1 is not bipartite. In Example 2 the same alternation works on every edge, so it's bipartite.
4Building the logic step by step
Which values to store?
We could store the strings "red" and "yellow", but we only need two values, so numbers are simpler. The teacher picks 1 for red and −1 for yellow, and keeps 0 for "not visited yet".
value in color[u] | meaning |
|---|---|
| 0 | not visited yet |
| 1 | visited, colour red (group A) |
| −1 | visited, colour yellow (group B) |
→ Two reasons. (a) We also need a "not visited" state. If 0 and 1 were the two colours, a fresh 0 would look like a coloured node. So the two colours must both be different from the "unvisited" value. (b) With 1 and −1, the opposite colour is just
-color: −(1) = −1 and −(−1) = 1. One minus sign flips it.→ That works, but it's an extra array for nothing. "Not zero" already means "visited", whatever the colour. One array does both jobs.
→ While dry-running she reaches a node whose neighbour is already visited and needs to ask "is the neighbour's colour the same as mine?" If every visited node just holds 1, that question can't be answered: the colour was never stored. So she stores the colour itself as the visited mark: 1 or −1, both non-zero.
What happens at one node
We arrive at node with a colour. First mark it: color[node] = c. Then look at each neighbour (child):
- Child not visited (0) → visit it with the opposite colour:
dfs(child, -c). If that deeper call ever reports False, pass False straight up. - Child already visited, different colour → this edge joins A and B. Fine, nothing to do.
- Child already visited, same colour as us → this edge joins two nodes of the same group. Return False.
If the loop over children finishes without any problem, return True: this node and everything explored below it is fine.
return True after the loop?→ The caller checks
if not dfs(...). If the function fell off the end, Python would return None, which counts as False, and every graph would be reported "not bipartite".The main function: check every component
Because the graph may be in pieces, loop i from 0 to n−1. Whenever color[i] == 0 (a node no earlier search reached, so a new component), start dfs(i, 1). If any component returns False, the whole answer is False. If every component passes, return True.
(0)—(1) (4)—(5) | | \ / (3)—(2) (6) square: bipartite ✓ triangle: 4=A, 5=B, 6 must differ from both ✗ answer: False (one bad piece is enough)
→ The teacher's point: if a problem said "start from node 0" (meaning everything is reachable from 0), there is only one piece, and a single
dfs(0, 1) would decide the answer. Here the problem says the graph may be disconnected, so we must try every unvisited node.The bug she fixed on screen
Her first version of the main loop called dfs(i, 1) for every i, without checking whether i was already coloured. The fix: only call it when color[i] == 0. Why it matters, on the tiny graph (0)—(1):
- i = 0: dfs(0, 1) colours 0 = 1, then 1 = −1. Fine, True.
- i = 1 (buggy): dfs(1, 1) overwrites node 1 with colour 1. Its neighbour 0 also has colour 1 → "same colour" → False. But a single edge is obviously bipartite!
With the check, i = 1 is skipped because it's already coloured, and the answer is True. (Even when the buggy version gets lucky, it re-walks whole components again and again.)
5Approach steps
n = len(graph);color = [0] * n.- For each node i with
color[i] == 0: ifdfs(i, 1)is False → return False. - dfs(node, c): set
color[node] = c. For each child: if 0 → recurse with-c(pass False up); else ifcolor[child] == c→ return False. - dfs returns True after its loop; the main function returns True after its loop.
6Code (Python)
class Solution:
def isBipartite(self, graph):
n = len(graph)
color = [0] * n # 0 = not visited, 1 / -1 = the two colours
def dfs(node, c):
color[node] = c
for child in graph[node]:
if color[child] == 0:
if not dfs(child, -c): # child gets the opposite colour
return False
elif color[child] == c: # same colour on both ends of an edge
return False
return True
for i in range(n):
if color[i] == 0: # a new component
if not dfs(i, 1):
return False
return True7Code line by line
| line | what it means |
|---|---|
| n = len(graph) | The number of nodes is the length of the adjacency list. |
| color = [0] * n | Everyone starts unvisited. This one array is both "visited" and "colour". |
| color[node] = c | First thing on arrival: mark the node visited by giving it its colour. |
| for child in graph[node]: | Walk this node's neighbours from its adjacency list. |
| if color[child] == 0: | Not visited yet → it must take the other colour. |
| if not dfs(child, -c): return False | Go deeper with the flipped colour. If anything below found a clash, stop and report False all the way up. |
| elif color[child] == c: return False | Already visited and coloured the same as us: an edge inside one group. Not bipartite. |
| return True | No clash around this node or below it. |
| if color[i] == 0: | The fix from the video: only start from nodes no earlier search coloured. |
| if not dfs(i, 1): return False | Check this whole component. One bad component makes the answer False. |
| return True | Every component passed. |
8Dry run
Example 2 (square): expected True
| step | node visited (colour) | neighbours checked | call stack after | color after [0,1,2,3] | answer so far |
|---|---|---|---|---|---|
| 1 | i = 0 → dfs(0, 1) | - | dfs(0) | [1, 0, 0, 0] | - |
| 2 | 0 (1) | 1: unvisited → dfs(1, −1) | dfs(0) dfs(1) | [1, −1, 0, 0] | - |
| 3 | 1 (−1) | 0: colour 1 ≠ −1 ✓ · 2: unvisited → dfs(2, 1) | dfs(0) dfs(1) dfs(2) | [1, −1, 1, 0] | - |
| 4 | 2 (1) | 1: −1 ≠ 1 ✓ · 3: unvisited → dfs(3, −1) | dfs(0) dfs(1) dfs(2) dfs(3) | [1, −1, 1, −1] | - |
| 5 | 3 (−1) | 0: 1 ≠ −1 ✓ · 2: 1 ≠ −1 ✓ → returns True | dfs(0) dfs(1) dfs(2) | same | - |
| 6 | back in 2, then 1 | no more children → True, True | dfs(0) | same | - |
| 7 | back in 0 | 3: −1 ≠ 1 ✓ → True | (empty) | same | - |
| 8 | i = 1, 2, 3 | already coloured → skip | (empty) | same | True |
(0 R)———(1 Y) | | (3 Y)———(2 R) every edge R–Y ✓
Example 1: expected False
| step | node visited (colour) | neighbours checked | call stack after | color after | answer so far |
|---|---|---|---|---|---|
| 1 | dfs(0, 1) | 1: unvisited → dfs(1, −1) | dfs(0) dfs(1) | [1, −1, 0, 0] | - |
| 2 | 1 (−1) | 0: 1 ≠ −1 ✓ · 2: unvisited → dfs(2, 1) | dfs(0) dfs(1) dfs(2) | [1, −1, 1, 0] | - |
| 3 | 2 (1) | 0: colour 1 == 1 → return False | dfs(0) dfs(1) | [1, −1, 1, 0] | False found |
| 4 | back in 1 | not dfs(2) is True → return False | dfs(0) | same | - |
| 5 | back in 0 | return False | (empty) | same | - |
| 6 | main loop | not dfs(0, 1) → return False | - | - | False |
(0 R)———(1 Y)
\ |
\ |
(2 R) edge 0-2: R and R ✗Node 3 was never even reached: the first clash ends everything.
9Complexity & remember
- Time O(n + E), where E is the number of edges. Every node is coloured once, and from each node we scan its adjacency list once. The teacher counts it like this: the lengths of all the adjacency lists add up to 2 × E (every undirected edge is stored at both ends; she calls these counts the in-degrees). So the total is n + 2E, which is linear.
- Space O(n): the colour array, plus up to n calls waiting on the recursion stack.
dfs(child, -c). Visited child with my colour → False. Loop over every node with color[i] == 0, because the graph can be in pieces.→ Only when there is a cycle with an odd number of edges. Going round a cycle, the colour flips at every edge; after an odd number of flips you're back at the start with the wrong colour. Example 1 has the triangle 0-1-2 (3 edges). The square in Example 2 has 4 edges, so it's fine. A tree (no cycles at all) is always bipartite.
Part B · 2-colouring with BFS
The teacher shows that the same check works level by level with a queue. Steps ①–④ are the same as Part A: same colour array, same three cases for a neighbour, same loop over every component. What changes:
- Instead of recursion, a deque holds the nodes whose neighbours we still have to look at.
- When we colour an unvisited child (
-color[node]), we push it, so its own neighbours are checked later. - When we find a clash,
return Falseimmediately ends the whole function. In DFS a return only ended one call and had to be passed up; here everything happens inside the main function, so one return is enough.
→ It may visit them in a different order, but in a connected component the colours are forced anyway: once the start is 1, every node at an even number of steps from it must be 1 and every node at an odd number of steps must be −1. BFS makes this very visible: each BFS level has one colour, and levels alternate.
5Approach steps
- For every node i with
color[i] == 0: colour it 1 and push it. - While the queue isn't empty: pop
node; for each child: - child 0 →
color[child] = -color[node], push it. - child has the same colour as node → return False.
- After all components → return True.
6Code (Python)
from collections import deque
class Solution:
def isBipartite(self, graph):
n = len(graph)
color = [0] * n
for i in range(n):
if color[i] != 0:
continue # already checked with its component
color[i] = 1
q = deque([i])
while q:
node = q.popleft()
for child in graph[node]:
if color[child] == 0:
color[child] = -color[node] # opposite colour
q.append(child)
elif color[child] == color[node]:
return False # ends the whole function
return True7Code line by line
| line | what it means |
|---|---|
| if color[i] != 0: continue | Same fix as Part A: a coloured node was already handled with its component. |
| color[i] = 1 q = deque([i]) | New component: colour its first node red and put it in the queue. |
| node = q.popleft() | Take the oldest waiting node; its colour is already set. |
| color[child] = -color[node] q.append(child) | Colour the child the opposite way when pushing, so it can't be pushed twice. |
| elif color[child] == color[node]: return False | An edge inside one group. Return straight away: no recursion to unwind. |
| return True | Every component was coloured without a clash. |
8Dry run
Example 2 (square): expected True
| step | node popped | neighbours checked | queue after | color after [0,1,2,3] | answer so far |
|---|---|---|---|---|---|
| 0 | start i = 0 | - | [0] | [1, 0, 0, 0] | - |
| 1 | 0 (1) | 1 → −1, push · 3 → −1, push | [1, 3] | [1, −1, 0, −1] | - |
| 2 | 1 (−1) | 0: 1 ≠ −1 ✓ · 2 → 1, push | [3, 2] | [1, −1, 1, −1] | - |
| 3 | 3 (−1) | 0 ✓ · 2: 1 ≠ −1 ✓ | [2] | same | - |
| 4 | 2 (1) | 1 ✓ · 3 ✓ | [ ] | same | - |
| 5 | i = 1, 2, 3 coloured → skip | - | [ ] | same | True |
Example 1: expected False
| step | node popped | neighbours checked | queue after | color after | answer so far |
|---|---|---|---|---|---|
| 0 | start i = 0 | - | [0] | [1, 0, 0, 0] | - |
| 1 | 0 (1) | 1 → −1 · 2 → −1 · 3 → −1, all pushed | [1, 2, 3] | [1, −1, −1, −1] | - |
| 2 | 1 (−1) | 0: 1 ≠ −1 ✓ · 2: −1 == −1 | [2, 3] | same | False |
Here the clash shows up on edge 1-2 (both are neighbours of 0, so both are on level 1 with the same colour). DFS found it on edge 0-2. Different edge, same verdict: the triangle 0-1-2 can't be 2-coloured.
9Complexity & remember
- Time O(n + E), space O(n) (colour array + queue). Same as DFS.
- On LeetCode the BFS version ran a little slower; the teacher puts it down to the extra work the queue does internally (every node pushed and popped).
-color[node]. Same colour on a neighbour → return False right away. Outer loop over all uncoloured nodes.Part C · Revision page
| DFS (recursion) | BFS (queue) | |
|---|---|---|
| colour store | color = [0]*n: 0 unvisited, 1 / −1 the two groups | |
| start of a component | dfs(i, 1) if color[i] == 0 | color[i] = 1, push i |
| unvisited child | dfs(child, -c), pass False up | color[child] = -color[node], push |
| visited child, same colour | return False | |
| visited child, other colour | fine, ignore | |
| a False reaches the answer by | returning through every call | one return (we're in the main function) |
| time / space | O(n + E) / O(n) | O(n + E) / O(n) |
2. Think colours: a neighbour always gets the opposite colour.
3. One array: 0 = unvisited, 1 / −1 = colour; flip with a minus sign.
4. Neighbour already has my colour → False.
5. Loop over all nodes and start only from uncoloured ones: the graph can be in pieces.
✗ calling dfs on every i without checking
color[i] == 0 (re-colours nodes, wrong False)✗ using 0 as a colour while 0 also means "unvisited"
✗ storing just "visited = 1" and losing the colour
✗ forgetting
return True at the end of dfs (None counts as False)✗ ignoring the result of the recursive call (a deep clash gets lost)
s = Solution() print(s.isBipartite([[1, 2, 3], [0, 2], [0, 1, 3], [0, 2]])) # False print(s.isBipartite([[1, 3], [0, 2], [1, 3], [0, 2]])) # True print(s.isBipartite([[1], [0]])) # True print(s.isBipartite([[1, 3], [0, 2], [1, 3], [0, 2], [5, 6], [4, 6], [4, 5]])) # False print(s.isBipartite([[]])) # True
Based on this video: Is Graph Bipartite? | Graph | Pattern-wise DSA sheet