Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

After this page, you should be able to:

Builds on: Graph modelling and the adjacency-list invariant.

Code languages: Python defines the explanations, figures and self-checks. Use the language guide for native conventions and dependencies. Complete runnable topic examples: Python · JavaScript · Julia.

JavaScript graphs use Map; Julia graphs use Dict. Stored neighbour order is preserved, but Julia Dict vertex iteration can change valid component ordering or tied choices. The shared DequeBuffer supplies constant-time linked endpoints. The figures and check answers use Python’s declared iteration order.

Keep the vertex set and connection policy explicit

These helpers represent a simple, unweighted graph as a dictionary of neighbour lists. Labels are hashable and non-None; examples use strings. Every destination is a dictionary key. Neighbours are distinct, and self-loops are excluded. For an undirected graph, both endpoint lists contain the connection. A directed graph stores only the outgoing direction.

Choose directedness when constructing a graph and pass that same choice to every edge update and count. The dictionary itself does not store that choice. Helpers assume an already well-formed graph; they do not validate or repair arbitrary dictionaries. Do not mutate adjacency lists during a traversal.

OperationResult and effect
build_graph(vertices, edges, directed=False)New dictionary; supplied vertices remain present even if isolated; each edge is an endpoint pair
add_vertex(graph, v)None; add an empty list if absent; an existing vertex and its edges remain unchanged
add_edge(graph, u, v, directed=False)None; add the connection if absent; repeat insertion has no effect
has_edge(graph, u, v)Boolean; inspect the outgoing list of u
neighbours(graph, u)New list of u’s neighbours in stored order; changing this list does not alter the graph
remove_edge(graph, u, v, directed=False)None; remove the connection; an absent edge raises KeyError
remove_vertex(graph, v)None; remove v and every incoming/outgoing connection; an absent vertex raises KeyError
vertex_count(graph) / len(graph)Number of vertices; not the number of edges
edge_count(graph, directed=False)Number of connections; count reciprocal undirected entries once
list(graph)New vertex-list snapshot in dictionary insertion order
len(graph[u])Outdegree for directed lists; degree for undirected lists; unknown u raises KeyError
not graphWhether the vertex set is empty; isolated vertices make this False

Edge queries/updates require both endpoints to exist and raise KeyError otherwise. add_edge raises ValueError for a self-loop; add_vertex rejects None with ValueError. Use add_vertex explicitly rather than silently creating an endpoint when adding an edge. build_graph consumes the supplied iterables, including duplicate vertices/edges under the idempotent insertion rules. Returned neighbour snapshots are shallow copies of references to labels.

Maintain reciprocal lists when required

Python
JavaScript
Julia
def add_vertex(graph, v):
    if v is None:
        raise ValueError("None is reserved for a missing parent")
    if v not in graph:
        graph[v] = []

def _require_endpoints(graph, u, v):
    if u not in graph:
        raise KeyError(u)
    if v not in graph:
        raise KeyError(v)

def add_edge(graph, u, v, *, directed=False):
    _require_endpoints(graph, u, v)
    if u == v:
        raise ValueError("simple graphs have no self-loops")
    if v not in graph[u]:
        graph[u].append(v)
    if not directed and u not in graph[v]:
        graph[v].append(u)

def has_edge(graph, u, v):
    _require_endpoints(graph, u, v)
    return v in graph[u]

def neighbours(graph, u):
    return list(graph[u])

def remove_edge(graph, u, v, *, directed=False):
    _require_endpoints(graph, u, v)
    if v not in graph[u]:
        raise KeyError((u, v))
    graph[u].remove(v)
    if not directed:
        graph[v].remove(u)

def remove_vertex(graph, v):
    if v not in graph:
        raise KeyError(v)
    for u in graph:
        if u != v and v in graph[u]:
            graph[u].remove(v)
    del graph[v]

def vertex_count(graph):
    return len(graph)

def edge_count(graph, *, directed=False):
    entries = sum(len(adjacent) for adjacent in graph.values())
    return entries if directed else entries // 2

def build_graph(vertices, edges, *, directed=False):
    graph = {}
    for v in vertices:
        add_vertex(graph, v)
    for u, v in edges:
        add_edge(graph, u, v, directed=directed)
    return graph

remove_vertex visits all vertex lists because incoming connections are not indexed separately. It changes lists during that loop but deletes the dictionary key only afterwards. The simple-graph invariant means each other list contains v at most once. Successful operations preserve valid endpoints, distinct neighbours and, where applicable, reciprocity.

Observe which changes affect later traversals

Build g = build_graph("ABH", [("A", "B")]). It contains {"A": ["B"], "B": ["A"], "H": []}: three vertices and one edge. Repeating add_vertex(g, "A") or add_edge(g, "A", "B") changes nothing. Appending H to neighbours(g, "A") changes only that temporary snapshot.

Removing A–B leaves three isolated vertices. Removing B then leaves A and H. With directed=True, inserting A→B changes A’s list only; deleting B still removes A’s incoming reference to B. It does not create A→H or any replacement route.

Compare the implementation choices and costs

Let n be the current vertex count, m the edge count and d(u) the outgoing-list length (degree for an undirected graph). Bounds assume constant-cost labels and expected dictionary access. Dynamic Python lists and dictionaries may occasionally allocate or copy storage.

OperationShown list-based implementationAdditional result/workspace
Add vertexExpected amortised O(1); allocation can cost more on one callOne empty list; dictionary growth can temporarily use O(n+1) space
Add directed edge / test edgeO(d(u)+1) scan; insertion includes amortised appendO(1) local workspace; append resizing can temporarily use O(d(u)+1) space
Add undirected edgeO(d(u)+d(v)+1), including scans and list growthSame allocation qualification at both endpoints
Neighbour snapshotΘ(d(u)+1)Θ(d(u)+1) returned list
Remove directed edgeO(d(u)+1), including scan and shiftsO(1) local workspace; list shrink may allocate/copy
Remove undirected edgeO(d(u)+d(v)+1)Same qualification at both endpoints
Remove vertexO(n+m+1), including incoming scans and shiftsO(1) local workspace; list shrink can temporarily use O(n+1) space
Vertex count / emptiness / outgoing degreeO(1)O(1)
Vertex snapshotΘ(n+1)Θ(n+1) returned list
Edge countΘ(n+1), summing cached list lengthsO(1)

Persistent graph storage is Θ(n+m+1). build_graph starts a new dictionary and repeatedly uses the checked insertions: construction is not automatically O(n+m) with duplicate-detection list scans. For n distinct supplied vertices and m distinct edges in a simple graph, its worst-case bound is O(n+m(n+1)); repeated input records add their own scans. A trusted edge list can be appended in linear total time when uniqueness and endpoints are guaranteed, as in the later weighted builder.

Neighbour sets offer expected constant-time edge membership and updates, but no promised traversal order. A matrix offers constant-time edge tests/updates after allocation, while enumeration scans a full row. Compact matrix vertex removal requires row/column and index-map maintenance; an inactive-slot policy trades that work for spare storage. These are representation choices under related graph contracts, not changes to BFS’s FIFO rule.

Check your understanding

Predict the result before opening the explanation.

1. A directed graph stores A→B and isolated H. After removing B, how many vertices and edges remain?

Show explanation

Two vertices, A and H, and zero edges. Removing B also removes A’s reference to B; isolated vertices are still part of the graph.

2. Does replacing neighbour lists with sets preserve our exact BFS trace automatically?

Show explanation

No. Reachability and shortest hop distances remain the same, but neighbour iteration can change discovery order and tied parents. Specify an ordering policy if the exact trace matters.

Next: Breadth-first search.