Graph Storage & Traversal¶
In one sentence
Store, for every vertex, the list of its neighbours. Then walk the graph with DFS or BFS, marking what you have visited, and each walk visits exactly one connected component.
1. What problem does it solve?¶
Example — 217A · Ice Skating (6-DSU · E, rating 1200)
There are \(n\) snow drifts at distinct integer points \((x_i, y_i)\). From a drift, Bajtek can push off north, east, south or west and slide until he hits another drift. What is the minimum number of new drifts needed so he can get from any drift to any other?
Limits: \(n \le 100\), \(1 \le x_i, y_i \le 1000\).
Input Output
2
2 1
1 2 1
2
2 1
4 1 0
Modelling. Two drifts on the same row (\(y_i = y_j\)) or the same column (\(x_i = x_j\)) can reach each other by sliding (possibly stopping at drifts in between, which is fine). So:
- vertices = drifts,
- edge between \(i\) and \(j\) when \(x_i = x_j\) or \(y_i = y_j\).
A group of drifts that can reach each other is a connected component (see Graph Concepts). With \(c\) components the answer is exactly \(c - 1\):
- \(c - 1\) drifts are enough. A new drift at \((x_a, y_b)\), where \(a\) and \(b\) are drifts from two different components, shares a column with \(a\) and a row with \(b\), so it merges those two components. Repeat \(c - 1\) times.
- Fewer are not. All drifts in one column are connected to each other, and so are all drifts in one row. A new drift lies in one column and one row, so it touches at most two components and lowers the count by at most \(1\).
First test: \((2, 1)\) and \((1, 2)\) share nothing, so \(c = 2\) and the answer is \(1\). Second test: both have \(y = 1\), so \(c = 1\) and the answer is \(0\).
So the real work is: count the components.
2. The ideas¶
2.1 Three ways to store a graph¶
Take this graph with \(n = 5\):
graph LR
1 --- 2
1 --- 3
2 --- 3
4 --- 5
| storage | what it looks like | memory | "neighbours of \(u\)" | "is \((u, v)\) an edge?" |
|---|---|---|---|---|
| edge list | [(1,2), (1,3), (2,3), (4,5)] |
\(O(m)\) | \(O(m)\), scan all | \(O(m)\) |
| adjacency matrix | g[u][v] = 1 if connected, an \(n \times n\) table |
\(O(n^2)\) | \(O(n)\) | \(O(1)\) |
| adjacency list | adj[1] = [2, 3], adj[2] = [1, 3], adj[3] = [1, 2], adj[4] = [5], adj[5] = [4] |
\(O(n + m)\) | \(O(\deg u)\) | \(O(\deg u)\) |
| adjacency set | same, but set() instead of lists |
\(O(n + m)\) | \(O(\deg u)\) | \(O(1)\) average |
With \(n = 10^5\) a matrix needs \(10^{10}\) cells, which is impossible. Adjacency lists are the default. Switch to sets when edges get deleted or you must test "is \(v\) a neighbour?" often; the lesson notes below explain why. Ice Skating has only \(n = 100\), so any of them works.
2.2 Traversal: visit everything reachable¶
Both DFS and BFS follow one rule:
Start from \(s\) and mark it visited. Whenever you are at \(u\), go to every neighbour \(v\) that is not visited yet, and mark it.
Claim. Starting from \(s\), the walk visits exactly the vertices of \(s\)'s component, each once.
- Only that component: we only ever move along edges, so everything we reach is connected to \(s\).
- All of it: suppose some \(t\) connected to \(s\) is never visited. On the path \(s = v_0, v_1, \dots, v_k = t\), take the first unvisited vertex \(v_i\). Then \(v_{i-1}\) was visited, so when we processed \(v_{i-1}\) we looked at its neighbour \(v_i\) and would have visited it. Contradiction.
- Once: the
visitedmark stops a second visit.
Cost. Each vertex is processed once, and processing \(u\) looks at its \(\deg(u)\) neighbours. By the handshake lemma, \(\sum \deg(u) = 2m\), so the total is \(O(n + m)\).
Counting components. Loop over all vertices; every time you find an unvisited one, add \(1\) and traverse from it. Each traversal marks one whole component, so the counter ends at \(c\).
2.3 DFS vs. BFS¶
| DFS (depth-first) | BFS (breadth-first) | |
|---|---|---|
| goes | as deep as possible, then backs up | layer by layer: distance 1, then 2, … |
| uses | recursion, or a stack | a queue (collections.deque) |
| gives for free | tree structure, subtree sizes | shortest distances in unweighted graphs |
| Python risk | recursion depth (default limit \(1000\)) | none |
For "which vertices are connected", either works. In Python, BFS with a deque avoids recursion-limit crashes. Details on each: DFS, BFS.
Trace BFS from \(1\) on the picture above:
| step | pop | new neighbours marked | queue after |
|---|---|---|---|
| start | \(1\) | 1 |
|
| 1 | \(1\) | \(2, 3\) | 2 3 |
| 2 | \(2\) | — (\(1, 3\) already visited) | 3 |
| 3 | \(3\) | — | empty |
Component of \(1\) is \(\{1, 2, 3\}\). Next unvisited vertex is \(4\), so start again there: \(\{4, 5\}\). Two components.
3. Lesson notes — March 29¶
Recap¶
Today we continued learning graph theory in Python.
One important topic today was the adjacency list. If we want to delete an edge (u, v) from a graph, then in essence we need to delete v from the adjacency list of u, and also delete u from the adjacency list of v.
This naturally leads to a “why” question: why might set be a better choice than list in some graph problems?
If we use a list, then deleting a specific neighbor is often inconvenient and inefficient, because we may need to search through the whole list first. This is acceptable in some static graph problems, but it becomes awkward when the graph changes during the process.
In contrast, set is much more suitable when we need to add edges, delete edges, or quickly check whether a neighbor exists. So today we introduced set as an important Python tool for dynamic graph operations.
We also reviewed several important properties of trees:
- A tree is connected.
- A tree has no cycles.
- A tree with \(n\) nodes has exactly \(n - 1\) undirected edges.
From these facts, we discussed an important related structure:
If a connected graph has \(n\) nodes and \(n\) edges, then it is a unicyclic graph. In other words, it contains exactly one cycle, and every other part of the graph can be viewed as a forest of trees attached to that cycle.
This is a very useful structure to recognize in graph problems.
Notes¶
Adjacency List¶
An adjacency list stores, for each node, which other nodes are directly connected to it.
For an undirected edge (u, v):
vshould appear in the adjacency list ofuushould appear in the adjacency list ofv
If we want to remove this edge, we must remove both entries.
Python set¶
A set is a collection of distinct elements.
Unlike a list, a set does not keep duplicates.
This makes it useful when we only care whether an element exists, rather than where it appears.
Example:
s = set()
You can also create a set from a list:
s = set([1, 2, 3])
Time Complexity: list vs set¶
Here are some common operations and their usual time complexities in Python:
| Operation | list |
set |
|---|---|---|
| Add one element | append: \(O(1)\) amortized | add: \(O(1)\) average |
| Delete one known value | \(O(N)\) | \(O(1)\) average |
Check whether x exists |
\(O(N)\) | \(O(1)\) average |
| Iterate through all elements | \(O(N)\) | \(O(N)\) |
Get size with len(...) |
\(O(1)\) | \(O(1)\) |
This is the main reason set is attractive in graph problems with dynamic edge updates.
If we need to repeatedly add, delete, or test whether a neighbor exists, set is usually much more efficient than list.
Common set Operations¶
Add an Element¶
Use .add(x) to insert one element into a set.
s = {1, 2, 3}
s.add(4)
# s is now {1, 2, 3, 4}
If the element is already in the set, nothing goes wrong.
Remove an Element¶
Use .remove(x) to delete an element.
s = {1, 2, 3}
s.remove(2)
# s is now {1, 3}
Be careful: if x is not in the set, .remove(x) causes an error.
If you want a safer version, use .discard(x).
s = {1, 2, 3}
s.discard(5)
# no error
Check Whether an Element Exists¶
Use in to test membership.
s = {1, 2, 3}
if 2 in s:
print("yes")
This is one of the most common uses of a set.
Iterate Through a Set¶
You can loop through all elements in a set.
s = {1, 2, 3}
for x in s:
print(x)
Note that a set is unordered, so the output order is not guaranteed.
Get the Number of Elements¶
Use len(s).
s = {1, 2, 3}
print(len(s))
Clear the Set¶
Use .clear() to remove all elements.
s = {1, 2, 3}
s.clear()
# s is now set()
set in Graph Problems¶
If we store adjacency lists as sets, then each node keeps a set of its neighbors.
Example:
adj = [set() for _ in range(n + 1)]
To add an undirected edge (u, v):
adj[u].add(v)
adj[v].add(u)
To remove an undirected edge (u, v):
adj[u].remove(v)
adj[v].remove(u)
To check whether u and v are directly connected:
if v in adj[u]:
print("connected")
This representation is especially useful when edges may be inserted or deleted during the problem.
Tree¶
A tree is a connected graph with no cycles.
Important properties:
- A tree with \(n\) nodes has exactly \(n - 1\) edges.
- There is exactly one simple path between any two nodes.
Unicyclic Graph¶
If a connected graph has \(n\) nodes and \(n\) edges, then it has exactly one cycle.
You can think of it as:
- one cycle
- plus several trees attached to nodes on that cycle
Recognizing this structure often makes graph problems much easier.
4. Worked solution — Ice Skating¶
Build the adjacency list from all pairs (\(n \le 100\), so \(n^2 = 10^4\) pairs is nothing), then count components with BFS.
import sys
from collections import deque
input = sys.stdin.readline
def solve():
n = int(input())
pts = []
for _ in range(n):
x, y = map(int, input().split())
pts.append((x, y))
adj = [[] for _ in range(n)]
for i in range(n):
for j in range(i + 1, n):
if pts[i][0] == pts[j][0] or pts[i][1] == pts[j][1]:
adj[i].append(j)
adj[j].append(i)
visited = [False] * n
components = 0
for s in range(n):
if visited[s]:
continue
components += 1
visited[s] = True
q = deque([s])
while q:
u = q.popleft()
for v in adj[u]:
if not visited[v]:
visited[v] = True
q.append(v)
print(components - 1)
solve()
The same problem is also in the DSU set: union(i, j) for every connected pair, then count roots. See DSU.
5. Common mistakes¶
Marking visited when popping instead of when pushing
If you mark visited[v] only after popleft(), the same vertex can be pushed many times, which is slow and double-counts. Mark it when you push it, as above.
Recursive DFS on \(10^5\) vertices
A path graph \(1 - 2 - \dots - 10^5\) makes recursion \(10^5\) deep. Python's default limit is \(1000\). Use BFS, an explicit stack, or sys.setrecursionlimit plus care.
Forgetting isolated vertices
A vertex with no edges is its own component. Looping for s in range(n) catches it; looping only over edge endpoints does not.
Adding an undirected edge in one direction only
adj[i].append(j) without adj[j].append(i) makes BFS from \(j\) miss \(i\), and the component count comes out too big.
list.remove(v) to delete an edge
That is \(O(\deg)\) per deletion. If deletions are frequent, store neighbours in sets (see the lesson notes).
6. Practice¶
| Problem | Set | Rating | Idea |
|---|---|---|---|
| 217A · Ice Skating | 6-DSU · E | 1200 | components (this page) |
| 1676G · White-Black Balanced Subtrees | 5-Graph · B | 1300 | DFS on a tree, count colours per subtree |
| 1167C · News Distribution | 6-DSU · B | 1400 | component sizes |
| 277A · Learning Languages | 6-DSU · A | 1400 | employees and languages as one graph |
| 1833E · Round Dance | 5-Graph · C | 1600 | components + degrees |
| 1702E · Split Into Two Sets | 5-Graph · D | 1600 | components must be even cycles |
Credits & licenses
- Storage comparison (§2.1): adapted and translated from OI Wiki — Graph Storage by Ir1d, sshwy, Xeonacid, partychicken, Anguei, HeRaNO and other OI Wiki contributors, licensed CC BY-SA 4.0.
- Section structure follows USACO Guide — Graph Traversal by Siyong Huang, Benjamin Qi and Ryan Chou, licensed CC BY-NC-SA 4.0.
- Everything else (the Ice Skating model, the correctness proof, traces, code, mistakes) is ours. §3 is our lesson recap.