Graphs and adjacency lists
Which of your friends' friends haven't you met yet? Can you fly from Lisbon to Osaka with one change of plane? Which of a program's files must Python load before main.py can run? Each question is about some things and the links between pairs of them. Put that way, it is a graph: a set of vertices (the things, also called nodes) and a set of edges, each joining two vertices. You have met one kind of graph already, the tree, where every node but the root hangs from exactly one parent. A graph in general has no such rules: it can have loops, several routes between the same two vertices, and pieces with no link between them at all. Interview questions about mazes, flights, schedules and groups of people are very often graph questions in disguise.
Links come in kinds. A flight has a price and a road a driving time: a number on an edge is its weight. The fifth step of this module uses weights; until then, every edge counts as one step. And a link may or may not work both ways. If Ana is Ben's cousin, Ben is Ana's cousin too, so the edge is undirected. But Ana can follow Ben online without Ben following back, and a file can import another without being imported by it: those edges are directed, from a to b.
From a list of edges to an adjacency list
A graph often arrives as a list of edges, each a pair of vertices. But the question that graph algorithms ask over and over is "where can I go from v in one step?": the neighbors of v. Scanning the whole list of edges for every such question costs O(E) each time, for E edges. An adjacency list answers at once. It is a dict that maps each vertex to the list of its neighbors:
roads = [("Rome", "Naples"), ("Florence", "Rome"), ("Naples", "Bari"), ("Bologna", "Florence"), ("Rome", "Bari"), ("Cagliari", "Sassari")]
graph = {}
for a, b in roads:
if a not in graph:
graph[a] = []
if b not in graph:
graph[b] = []
graph[a].append(b)
graph[b].append(a)
for city in graph:
print(city, len(graph[city]), graph[city])
Rome 3 ['Naples', 'Florence', 'Bari']
Naples 2 ['Rome', 'Bari']
Florence 2 ['Rome', 'Bologna']
Bari 2 ['Naples', 'Rome']
Bologna 1 ['Florence']
Cagliari 1 ['Sassari']
Sassari 1 ['Cagliari']A road is an undirected edge, so it goes into two lists: Naples is in Rome's list, and Rome is in Naples's. The number of neighbors of a vertex is its degree: Rome's is 3. Each edge adds 1 to the degree of both its ends, so the degrees add up to 2E, here 12 for 6 roads. Cagliari and Sassari are on the island of Sardinia, with a road only to each other: a graph can come in separate pieces, and no road leads from Rome to Cagliari.
The two if lines give every vertex a list of its own the first time it shows up, as a or as b. Without them, the first road to a new city stops with a KeyError. (collections.defaultdict(list) does the same for you: a missing key gets an empty list the first time it's used.) When the vertices are numbered 0 to n - 1, a list of lists works just as well: graph[v] is the list of v's neighbors.
Directed graphs: out and in
A directed edge goes into one list only, the list of the vertex it leaves from. That list's length is the vertex's out-degree. The in-degree, the number of edges coming in, needs a count of its own, made in the same single pass over the edges:
follows = [(0, 1), (0, 2), (1, 2), (2, 0), (3, 2)]
n = 4
following = [[] for _ in range(n)]
followers = [0] * n
for a, b in follows:
following[a].append(b)
followers[b] += 1
print(following)
print([len(people) for people in following])
print(followers)
[[1, 2], [2], [0], [2]]
[2, 1, 1, 1]
[1, 1, 3, 0]Person 2 has three followers and follows one person, and nobody follows 3. Counting each person's followers with a scan of all the edges per person gives the same numbers, but costs O(V·E) instead of O(V + E).
A Python trap: [[]] * n
To make n empty lists, [[]] * n looks right. But * repeats a reference to one list n times, so every vertex shares the same list:
>>> graph = [[]] * 3
>>> graph[0].append(1)
>>> graph
[[1], [1], [1]]Make each list separately: [[] for _ in range(n)].
What it costs
An adjacency list takes O(V + E) memory: a list for each vertex, plus an entry for each directed edge (two for an undirected one). Going through the neighbors of v takes O(deg v), and through the neighbors of every vertex O(V + E). That's what the searches of the next steps do.
"Is there an edge from a to b?" is b in graph[a]: O(deg a) with a list. With a set of neighbors instead, it's O(1) on average, the trade of memory for speed from the step on sets and dicts in the module on complexity. (Sets of strings have no fixed order, so sort them before you print them.) The other classic layout, an adjacency matrix, is a V by V table of True and False, a list of V lists in Python. It answers in O(1) too, but takes O(V²) memory, whatever the number of edges. Take a city's street map: 50,000 crossings, each joined to about 4 others. Its adjacency lists hold about 200,000 entries. Its matrix has 2.5 billion cells, and since every item of a Python list is an 8-byte reference, that's 20 GB, almost all of it False. Graphs like this one, with far fewer than V² edges, are called sparse, and most real graphs are, so adjacency lists are the usual choice.