Representing graphs
On This Lesson 11 topics
Lesson Overview
In the last lesson you learned to see graphs: nodes, edges, directions, paths, and cycles. Now we store one in a Java program. The question sounds small, but the answer shapes every algorithm that follows: for each node, how do we find its neighbors quickly?
There are two classic answers. The adjacency list keeps, for each node, a list of its neighbors. The adjacency matrix keeps a grid with one cell for every pair of nodes. We will build both, compare them, and then use the list, because it is what real graphs usually call for.
Everything in this lesson was compiled and run with JDK 21. The code uses only Map, List, ArrayList, and HashMap, which you know from the collections modules.
Why this matters: the choice of representation decides how much memory a graph uses and how fast you can walk it. A wrong choice can make a program thousands of times larger than it needs to be.
This lesson builds on "Graphs everywhere", and on HashMap and ArrayList from the earlier modules.
Learning Objectives
By the end of this lesson you will be able to:
- Draw the adjacency list of a small graph, directed or undirected.
- Store a graph in Java as
Map<String, List<String>>. - Add an undirected edge by updating two lists and a directed edge by updating one.
- Use
computeIfAbsentandgetOrDefaultso nodes without edges do not break the code. - Describe the adjacency matrix and explain why its memory grows with the square of the number of nodes.
- Choose between list and matrix for a given graph.
Written Explanation
The adjacency list
An adjacency list stores, for each node, the list of nodes it has an edge to. Take the graph with edges A-B, A-C, B-D, C-D, and D-E (all undirected). Its adjacency list is:
A: [B, C]
B: [A, D]
C: [A, D]
D: [B, C, E]
E: [D]Each line answers one question: "who are my neighbors?" To visit the neighbors of D you read one short list. You never look at nodes that are not connected to D. Think of it as the printed list of connections beside each station on a metro map.
The total number of entries in an adjacency list depends on the edges. With V nodes and E edges, the list holds V entries and, for an undirected graph, 2 E neighbor slots (each edge is recorded at both ends). The memory is therefore proportional to V + E, which is small for graphs where each node has only a few neighbors.
In Java: Map of Lists
The natural Java type is a map from a node's name to the list of its neighbors:
Map<String, List<String>> g = new HashMap<>();The key is the node, and the value is its neighbor list. Nodes here are Strings such as station names; any type with proper equals and hashCode works as a key, as the hashing module taught.
Adding the first neighbor to a node that has no entry yet is the awkward moment. g.get("A") would return null, and calling .add(...) on null throws a NullPointerException. The method computeIfAbsent solves this: if the key is absent, it creates the value with the function you give it, stores it, and returns it. The documentation describes it as computing and storing a value only when the key has no mapping.
g.computeIfAbsent("A", k -> new ArrayList<>()).add("B");Read this as: "get the list for A, creating an empty one if there is none, then add B to it."
Undirected and directed edges in code
For an undirected edge, the relationship goes both ways, so we record it in both lists:
static void addUndirected(String a, String b) {
g.computeIfAbsent(a, k -> new ArrayList<>()).add(b);
g.computeIfAbsent(b, k -> new ArrayList<>()).add(a);
}For a directed edge from from to to, only from gets to in its list. We also make sure to exists as a node, with an empty list if it has no outgoing edges:
static void addDirected(String from, String to) {
g.computeIfAbsent(from, k -> new ArrayList<>()).add(to);
g.computeIfAbsent(to, k -> new ArrayList<>());
}That second line matters. A node that nothing leaves, such as a library that needs nothing else, must still be a key in the map. Otherwise code that loops over g.keySet() would never see it.
Reading neighbors safely
When code asks for the neighbors of a node, two things can go wrong: the node is not in the graph, or it is there with no neighbors. Use getOrDefault so both cases give an empty list instead of null:
for (String next : g.getOrDefault(node, List.of())) { ... }getOrDefault(key, defaultValue) returns the default when the map has no mapping for the key. List.of() is an immutable empty list: safe to loop over, but you must never try to add to it. Add to graphs only through computeIfAbsent.
The adjacency matrix
The other classic representation is the adjacency matrix: a square grid where row i, column j says whether there is an edge from node i to node j. Nodes must be numbered 0, 1, 2, and so on.
boolean[][] m = new boolean[3][3]; // nodes 0, 1, 2
m[0][1] = true; // edge 0 - 1
m[1][0] = true; // undirected: set both cellsPrinting Arrays.deepToString(m) for this example gives [[false, true, false], [true, false, false], [false, false, false]].
The matrix answers "is there an edge between i and j?" in one array lookup, which is fast. But it needs V x V cells no matter how few edges exist, and listing the neighbors of one node means scanning a whole row of V cells.
Which one to choose
| Adjacency list | Adjacency matrix | |
|---|---|---|
| Memory | About V + E | V x V |
| List a node's neighbors | Read its list | Scan a row of V cells |
| "Is there an edge A-B?" | Scan A's short list | One lookup |
| Best for | Sparse graphs (few edges per node) | Dense graphs (most pairs linked) |
Reference material on graph representations makes the same point: the list's space is proportional to nodes plus edges while the matrix's is proportional to the square of the nodes, so the list wins when the graph is sparse. A metro network with 1,000 stations might have 1,100 tracks. A matrix would need a million cells, almost all false. A list holds roughly 2,200 neighbor entries. Most real graphs are sparse: people do not know most other people, and a service calls a handful of others. This course uses the adjacency list from now on.
Examples
Example 1: the five-node graph in code (simple)
import java.util.*;
public class L02 {
static Map<String, List<String>> g = new HashMap<>();
static void addUndirected(String a, String b) {
g.computeIfAbsent(a, k -> new ArrayList<>()).add(b);
g.computeIfAbsent(b, k -> new ArrayList<>()).add(a);
}
public static void main(String[] args) {
addUndirected("A", "B");
addUndirected("A", "C");
addUndirected("B", "D");
addUndirected("C", "D");
addUndirected("D", "E");
System.out.println(g);
int entries = 0;
for (List<String> l : g.values()) entries += l.size();
System.out.println("nodes=" + g.size() + " listEntries=" + entries);
System.out.println(g.getOrDefault("Z", List.of()));
}
}Output:
{A=[B, C], B=[A, D], C=[A, D], D=[B, C, E], E=[D]}
nodes=5 listEntries=10
[]Five edges give ten list entries, because each undirected edge is stored twice. The unknown node Z gives an empty list, not an error. Note that the order in which a HashMap prints its keys is not part of its contract; it happened to be alphabetical for these one-letter names.
Example 2: a directed dependency graph (practical)
addDirected("app", "lib");
addDirected("lib", "core");
System.out.println(g);
System.out.println(g.get("core").isEmpty());Starting from an empty map, the output is:
{app=[lib], core=[], lib=[core]}
truecore needs nothing, yet it is a key with an empty list, thanks to the second line of addDirected. The key order is again the HashMap's own.
Example 3: the bug that get hides (realistic)
A common first attempt reads the list with get and loops over it directly:
g.clear();
addDirected("a", "b");
for (String n : g.get("zzz")) System.out.println(n);get("zzz") returns null because the key does not exist, and the loop throws a NullPointerException. In a real program this happens when a user types a station name that is not on the map. Replacing g.get("zzz") with g.getOrDefault("zzz", List.of()) makes the loop run zero times. Whether "unknown node" should then be treated as an error is a separate decision, one we make explicitly in the lab by checking g.containsKey(name) first.
Backend Insight
Backends rarely hold a graph as a picture; they hold it as rows. A table of "follows" rows, with columns for follower and followed, is an edge list. A service loads those rows into an adjacency list in memory when it needs fast neighbor lookups, much as we did above. The in-memory map you wrote here is the same shape that graph libraries and recommendation or routing code use internally.
Two engineering judgments follow. First, estimate before you choose: a graph of V nodes costs about V x V cells as a matrix. With 100,000 nodes, that is ten billion cells, which will not fit in memory comfortably, while the list stays proportional to the actual links. Second, decide up front whether edges are directed. In an undirected graph, forgetting the second add is a quiet bug: routes work in one direction only.
Common Mistakes
Mistake 1: Using get(...).add(...) before the key exists
- Incorrect:
g.get("A").add("B");on a new node, which throwsNullPointerException. - Correct:
g.computeIfAbsent("A", k -> new ArrayList<>()).add("B");
Mistake 2: Forgetting the reverse edge in an undirected graph
If you only run g.get(a).add(b), a path from A to B works but B to A does not. Put both additions in one helper such as addUndirected so you cannot forget.
Mistake 3: Not registering nodes that have no outgoing edges
In a directed graph, core appears only as a target. If you never create its key, g.keySet() misses it and g.get("core") returns null. Create it, with an empty list, in addDirected.
Mistake 4: Adding to List.of()
List.of() is immutable. Using it as the default in getOrDefault is right for reading; trying .add on the result throws UnsupportedOperationException. Never use it to store neighbors.
Exercises
Try each exercise yourself before opening the solutions.
Level A: Foundation
A1. Write the adjacency list for the directed edges P -> Q, P -> R, Q -> R, R -> S.
Show solution A1
A1.
P: [Q, R]
Q: [R]
R: [S]
S: []S is a node with no outgoing edges, so it still has an (empty) entry.
A2. An undirected graph has 6 edges. How many neighbor entries does its adjacency list hold in total?
Show solution A2
A2. 12, because each undirected edge appears in two lists.
A3. Which representation, list or matrix, is better for a graph with 50,000 nodes where each node has about 5 neighbors? Why?
Show solution A3
A3. The list. A matrix would need 50,000 x 50,000 = 2.5 billion cells, nearly all empty. The list needs about 250,000 neighbor entries (50,000 nodes x about 5 neighbors), proportional to the actual links.
Level B: Application
B1. Write a Java method neighbors(String node) that returns the neighbor list of a node, or an empty list if the node is unknown, never null. Assume the map g from the lesson.
Show solution B1
B1.
static List<String> neighbors(String node) {
return g.getOrDefault(node, List.of());
}The caller may read or loop over the result but must not modify it.
B2. How many cells does an adjacency matrix need for 1,000 nodes? How many neighbor entries does the adjacency list need for an undirected graph of 1,000 nodes and 3,000 edges?
Show solution B2
B2. 1,000 x 1,000 = 1,000,000 cells. The list needs 3,000 x 2 = 6,000 neighbor entries, plus 1,000 map entries for the nodes.
B3. This code builds an undirected graph, but a route from "B" to "A" fails while "A" to "B" works. Find the bug.
static void addEdge(String a, String b) {
g.computeIfAbsent(a, k -> new ArrayList<>()).add(b);
}Show solution B3
B3. The method adds b to a's list only. For an undirected graph it must also run g.computeIfAbsent(b, k -> new ArrayList<>()).add(a);.
Level C: Challenge
C1. Write removeEdge(String a, String b) for an undirected graph. It must not crash if either node is unknown. Hint: List.remove(Object) removes the first matching element.
Show solution C1
C1.
static void removeEdge(String a, String b) {
g.getOrDefault(a, new ArrayList<>()).remove(b);
g.getOrDefault(b, new ArrayList<>()).remove(a);
}A missing node gives a throwaway empty ArrayList, so the call does nothing. It uses new ArrayList<>() rather than List.of() because removing from an immutable list would throw. I compiled and ran this with JDK 21 on a small graph: removing A-B left A with no neighbors and B with only C. If the same edge was added twice, only one copy is removed per call.
C2. A teammate stores the graph as Map<String, Set<String>>, using a HashSet for neighbors, instead of lists. Name one advantage and one difference in behavior compared with List. (Think about duplicate edges and order.)
Show solution C2
C2. A set ignores a duplicate edge: adding B to A's neighbors twice stores it once, while a list would store it twice. The difference is order: a HashSet makes no promise about iteration order, so neighbors come out in no predictable sequence, and an algorithm that prints a visiting order would give unpredictable output. A list keeps insertion order, which is why this module uses lists.
Mini Challenge
Build a tiny metro in code. Write a class with addUndirected and neighbors as in this lesson, then add these tracks: Airport-Center, Center-Market, Market-Harbor, Center-Stadium, Island-Dock.
- Print the whole map and check that Center has three neighbors and Airport has one.
- Print
neighbors("Nowhere")and confirm it is an empty list without an exception. - Count the total neighbor entries and check that the number is twice the number of tracks.
- Add a method that returns how many neighbors a station has, and print it for every station.
Check your work: Island and Dock appear as nodes even though they are not connected to the mainland, and no method throws on an unknown station.
Summary & Next Lesson
Key takeaways
- An adjacency list gives each node a list of its neighbors; in Java it is
Map<String, List<String>>. - For an undirected edge, add each node to the other's list. For a directed edge, add only one direction, and still register the target node.
- Use
computeIfAbsentto add andgetOrDefaultto read, so missing nodes never causenullproblems. - An adjacency matrix uses V x V cells and suits dense graphs. Most real graphs are sparse, so the list is the default.
- Choose the representation by estimating memory and by what you need to do most: list neighbors or test one pair.
Next lesson
The next lesson, "Breadth-first search", uses this adjacency list to explore a graph level by level, with a queue, and to find the fewest-edge route between two nodes.
Self-check
- What does an adjacency list store for each node?
- How many list entries does one undirected edge create?
- Why use
computeIfAbsentwhen adding an edge? - What does
getOrDefault(node, List.of())return for an unknown node? - For a very sparse graph with many nodes, which representation uses less memory?
Show answers
- The list of that node's neighbors.
- Two: one in each endpoint's list.
- It creates the list when the node has none yet, avoiding a
NullPointerException. - An empty list.
- The adjacency list.
References
- Oracle, Map (Java SE 21 API). Supports the behavior of
computeIfAbsent(creates and stores a value when the key is absent) andgetOrDefault(returns the default when no mapping exists), and the fact thatMap.ofand similar factories are unmodifiable. I did not fetch theList.ofpage, so the claim thatList.of()is immutable (and rejectsadd) rests on theList.offactory contract as I know it and is not separately verified here. - Wikipedia, Adjacency list. Supports the comparison that list space is proportional to nodes plus edges while a matrix is proportional to the square of the node count, and that lists suit sparse graphs.
- All Java code in the examples was compiled and run with JDK 21. The metro and library numbers (1,000 stations, 50,000 nodes) are invented teaching figures, not measurements.
Practice
Apply this lesson with the module's lab, exercises and quiz.
Marking a lesson complete is optional and you can undo it any time. Your progress stays in this browser.