Non-Linear Data Structures: Graphs

SA
StudyAI
AI-generated study notes
· Published Updated Pro Plus model

From the data structures and algorithms (python curriculum

TL;DR

You'll learn how to represent graphs in Python using adjacency lists and matrices, and when to pick each. You'll walk through BFS and DFS with real code, tracing exactly how they explore a network. You'll finish knowing which traversal to reach for depending on whether you need shortest paths, connectivity, or cycle detection.

1. The Mental Model

A graph is just a set of things (nodes) and connections between them (edges) — nothing more. A tree is a graph with rules (no cycles, one parent); a graph drops those rules entirely, so it can model anything connected to anything. Roads between cities, friendships, web pages linking to each other — all graphs. If a tree is a family, a graph is a social network — messier, looping, and far more realistic.

2. The Core Material

2.1 Representing a Graph

Detailed charts and graphs on a document next to a laptop, representing data analysis.
Photo by Lukas Blazek on Pexels

You have two real choices in practice: adjacency list and adjacency matrix.

An adjacency list stores, for each node, the list of neighbors it connects to. In Python, a dictionary of lists works beautifully:

graph = {
    'A': ['B', 'C'],
    'B': ['A', 'D'],
    'C': ['A', 'D'],
    'D': ['B', 'C', 'E'],
    'E': ['D']
}

This costs $O(V + E)$ space (V = vertices, E = edges), which is efficient for sparse graphs (few edges relative to possible connections). Checking "is X a neighbor of Y" takes $O(\text{deg}(Y))$ time — you scan Y's list.

An adjacency matrix is a $V \times V$ grid of 0s and 1s (or weights):

import numpy as np
nodes = ['A', 'B', 'C', 'D', 'E']
idx = {n: i for i, n in enumerate(nodes)}
matrix = np.zeros((5, 5), dtype=int)
matrix[idx['A']][idx['B']] = 1
matrix[idx['B']][idx['A']] = 1  # undirected: symmetric

This costs $O(V^2)$ space regardless of how many edges actually exist, but checking "is X connected to Y" is $O(1)$ — just index into the matrix. Dense graphs (most nodes connected to most others) favor matrices; sparse real-world graphs (social networks, road maps) almost always favor lists.

2.2 Breadth-First Search (BFS)

A close-up view of a laptop displaying a search engine page.
Photo by cottonbro studio on Pexels

BFS explores a graph level by level — all neighbors first, then their neighbors, and so on. It's the right tool when you need the shortest path in an unweighted graph, because the first time BFS reaches a node, it's guaranteed to be via the shortest possible route (fewest edges).

BFS uses a queue (FIFO — first in, first out):

from collections import deque

def bfs(graph, start):
    visited = {start}
    queue = deque([start])
    order = []

    while queue:
        node = queue.popleft()
        order.append(node)
        for neighbor in graph[node]:
            if neighbor not in visited:
                visited.add(neighbor)
                queue.append(neighbor)
    return order

print(bfs(graph, 'A'))  # ['A', 'B', 'C', 'D', 'E']

Mark nodes as visited when you enqueue them, not when you dequeue them — a classic source of bugs. If you wait until dequeue, the same node can be added to the queue multiple times by different neighbors before it's processed, wasting work and in worst cases breaking correctness.

Time complexity is $O(V + E)$: every vertex is dequeued once, and every edge is examined once (across all adjacency list traversals).

2.3 Depth-First Search (DFS)

Image displaying DeepSeek AI interface for messaging and search functionality.
Photo by Matheus Bertelli on Pexels

DFS dives as deep as possible down one path before backtracking. It uses a stack (LIFO — last in, first out), either explicitly or via recursion (which uses the call stack).

def dfs_recursive(graph, start, visited=None, order=None):
    if visited is None:
        visited, order = set(), []
    visited.add(start)
    order.append(start)
    for neighbor in graph[start]:
        if neighbor not in visited:
            dfs_recursive(graph, neighbor, visited, order)
    return order

print(dfs_recursive(graph, 'A'))  # ['A', 'B', 'D', 'C', 'E']

Iterative version with an explicit stack avoids Python's recursion depth limit on large graphs:

def dfs_iterative(graph, start):
    visited = set()
    stack = [start]
    order = []
    while stack:
        node = stack.pop()
        if node not in visited:
            visited.add(node)
            order.append(node)
            for neighbor in reversed(graph[node]):
                if neighbor not in visited:
                    stack.append(neighbor)
    return order

DFS is also $O(V + E)$, but it's the natural choice for cycle detection, topological sorting, and connected components — anything where you need to fully explore one branch before moving to the next.

flowchart TD
    Start["Need to explore a graph"] --> Q1{"Need shortest path\n in unweighted graph?"}
    Q1 -->|Yes| BFS["Use BFS (queue)"]
    Q1 -->|No| Q2{"Detecting cycles or\ndoing topological sort?"}
    Q2 -->|Yes| DFS["Use DFS (stack/recursion)"]
    Q2 -->|No| Q3{"Just need connectivity\n or all reachable nodes?"}
    Q3 -->|Yes| Either["Either works — pick DFS\n for lower memory on deep graphs"]
    Q3 -->|No| Q4{"Edges have weights?"}
    Q4 -->|Yes| Dijkstra["Use Dijkstra / Bellman-Ford\n (not plain BFS/DFS)"]

2.4 Directed vs Undirected, Weighted vs Unweighted

An undirected graph's edges go both ways (friendship); a directed graph's edges have direction (Twitter follows, one-way streets). In code, the only difference in adjacency-list representation is whether you add the edge to both nodes' lists or just one:

# Undirected: add both directions
def add_edge_undirected(graph, u, v):
    graph.setdefault(u, []).append(v)
    graph.setdefault(v, []).append(u)

# Directed: add only one direction
def add_edge_directed(graph, u, v):
    graph.setdefault(u, []).append(v)

Weighted graphs attach a cost to each edge (distance, time, price). You represent this as a list of tuples instead of bare neighbor names:

weighted_graph = {
    'A': [('B', 4), ('C', 1)],
    'B': [('A', 4), ('D', 2)],
    'C': [('A', 1), ('D', 6)],
    'D': [('B', 2), ('C', 6)]
}

Plain BFS/DFS ignore weights entirely — they only count edges, not cost. If you need shortest weighted path, you need Dijkstra's algorithm or Bellman-Ford, which is a separate topic built on top of this same adjacency-list foundation.

3. Worked Example

Let's find the shortest path (not just the order of visits) from 'A' to 'E' in our original unweighted graph using BFS, tracking parents so we can reconstruct the path:

from collections import deque

graph = {
    'A': ['B', 'C'],
    'B': ['A', 'D'],
    'C': ['A', 'D'],
    'D': ['B', 'C', 'E'],
    'E': ['D']
}

def bfs_shortest_path(graph, start, goal):
    visited = {start}
    queue = deque([start])
    parent = {start: None}

    while queue:
        node = queue.popleft()
        if node == goal:
            # Reconstruct path by walking parents backwards
            path = []
            while node is not None:
                path.append(node)
                node = parent[node]
            return path[::-1]
        for neighbor in graph[node]:
            if neighbor not in visited:
                visited.add(neighbor)
                parent[neighbor] = node
                queue.append(neighbor)
    return None  # no path exists

print(bfs_shortest_path(graph, 'A', 'E'))

Trace it by hand:
- Start: queue = ['A'], parent = {A: None}.
- Dequeue A. Neighbors B, C — both unvisited. Enqueue both. parent = {A: None, B: A, C: A}. queue = ['B', 'C'].
- Dequeue B. Neighbors A (visited), D (new). Enqueue D. parent[D] = B. queue = ['C', 'D'].
- Dequeue C. Neighbors A (visited), D (already visited via B). Nothing new. queue = ['D'].
- Dequeue D. It's not the goal yet. Neighbors B, C (visited), E (new). Enqueue E. parent[E] = D. queue = ['E'].
- Dequeue E. It's the goal. Walk back: E → D → B → A. Reverse: ['A', 'B', 'D', 'E'].

Output: ['A', 'B', 'D', 'E'] — length 3 edges. This is provably the shortest route, because BFS explores strictly in order of distance from the start.

4. Production Pitfalls & Best Practices

4.1 Real-World Best Practices

  • Use collections.deque for BFS queues, never a plain Python list with .pop(0) — that's $O(n)$ per removal, turning your $O(V+E)$ algorithm into $O(V^2)$.
  • Mark nodes visited at enqueue time for BFS, not at dequeue time, to prevent duplicate queue entries.
  • Prefer iterative DFS for large or unpredictable graphs — Python's default recursion limit (~1000) will crash on a long chain of nodes; sys.setrecursionlimit() is a band-aid, not a fix.
  • Use defaultdict(list) or .setdefault() when building adjacency lists dynamically — avoids KeyError when a node appears as a destination before being added as a source.
  • Separate graph construction from traversal logic — build the adjacency structure once, then reuse it across multiple BFS/DFS calls; don't rebuild it per query.
  • For weighted shortest-path problems, don't try to bolt weights onto BFS — reach for heapq and Dijkstra instead; BFS's correctness guarantee only holds for unweighted (or equal-weight) edges.

4.2 Common Bugs & Anti-Patterns

Bug 1: Forgetting to track visited nodes → infinite loop on cyclic graphs.

# BAD — no visited set, loops forever on a cycle
def dfs_broken(graph, node):
    print(node)
    for neighbor in graph[node]:
        dfs_broken(graph, neighbor)
# FIXED
def dfs_fixed(graph, node, visited=None):
    if visited is None:
        visited = set()
    visited.add(node)
    print(node)
    for neighbor in graph[node]:
        if neighbor not in visited:
            dfs_fixed(graph, neighbor, visited)

Bug 2: Marking visited at dequeue time in BFS → duplicate work, wrong shortest-path results in some edge cases.

# BAD
def bfs_bad(graph, start):
    visited = set()
    queue = deque([start])
    while queue:
        node = queue.popleft()
        if node in visited:
            continue
        visited.add(node)
        for neighbor in graph[node]:
            queue.append(neighbor)  # may enqueue duplicates repeatedly
# FIXED — mark visited when enqueuing
def bfs_good(graph, start):
    visited = {start}
    queue = deque([start])
    while queue:
        node = queue.popleft()
        for neighbor in graph[node]:
            if neighbor not in visited:
                visited.add(neighbor)
                queue.append(neighbor)

Bug 3: Using a list as a queue — silent performance killer.

# BAD — O(n) per pop(0)
queue = []
queue.append(start)
node = queue.pop(0)
# FIXED
from collections import deque
queue = deque([start])
node = queue.popleft()

Bug 4: Assuming an adjacency matrix is cheap for sparse graphs.

# BAD — wastes memory for a graph with 1M nodes, 2M edges
matrix = [[0]*1_000_000 for _ in range(1_000_000)]  # ~1TB, crashes
# FIXED — adjacency list scales with actual edges
from collections import defaultdict
graph = defaultdict(list)
graph[0].append(1)  # O(V+E) space, not O(V^2)

4.3 When to Use What (Decision Matrix)

Situation Representation Traversal
Sparse graph (social network, road map) Adjacency list BFS or DFS as needed
Dense graph (most nodes interconnected) Adjacency matrix Either works; matrix gives O(1) edge checks
Need shortest path, unweighted edges Either BFS
Need to detect a cycle Adjacency list DFS (track recursion stack)
Need topological order (task scheduling) Adjacency list DFS-based topological sort
Edges have costs/weights List of (neighbor, weight) tuples Dijkstra (not plain BFS/DFS)

5. Now Try It

Write a function has_path(graph, start, end) that returns True or False depending on whether any path exists between start and end in an undirected graph, using BFS or DFS (your choice). Test it on this graph:

graph = {
    'A': ['B'],
    'B': ['A', 'C'],
    'C': ['B'],
    'D': ['E'],
    'E': ['D']
}

Check: has_path(graph, 'A', 'C') should be True; has_path(graph, 'A', 'D') should be False (two disconnected components). Then modify your function to return the actual path (list of nodes) instead of just a boolean, reusing the parent-tracking technique from Section 3.

Success looks like: your function correctly returns True/False for both test cases above, and the path-returning version prints a valid node-by-node route from A to C.

Frequently asked about Non-Linear Data Structures: Graphs

You'll learn how to represent graphs in Python using adjacency lists and matrices, and when to pick each. You'll walk through BFS and DFS with real code, tracing exactly how they explore a network. Read the full notes above for the details.

Non-Linear Data Structures: Graphs is a core topic in data structures and algorithms (python. Most exam papers test it via a mix of definitions, worked examples, and applied problems. The notes above cover the high-yield sub-topics, common pitfalls, and the kind of questions examiners typically set.

Yes — every note in the StudyAI Campus Hub is free to read in full, right here on this page, with no account needed. If you clone the plan into your own dashboard, the free plan shows a preview of each note there; Basic and above unlock the full notes in your dashboard, along with practice quizzes, flashcards and offline study. You can always come back here to read the complete note for free.
Continue with
Non-Linear Data Structures: Trees and Heaps

Study this next


Get the full data structures and algorithms (python curriculum

Clone the complete plan to your dashboard for unlimited AI-generated notes, practice quizzes, and a personalised revision schedule.

Save this course free