Graph Representation Explained: Adjacency List vs Matrix (With Code)
Graphs power more of your daily software than you'd guess. Google Maps finds your fastest route using graph traversal. Facebook's friend suggestions use graph connectivity. LinkedIn's 'second connection' feature? Graph theory. Every time you see a network of relationships — people, cities, servers, web pages — there's a graph underneath. Understanding how to store that graph isn't a minor detail; it's the decision that determines whether your algorithm runs in milliseconds or minutes.
The core problem is this: a graph is an abstract concept, but your computer only understands memory — arrays, pointers, contiguous blocks of data. You need a concrete data structure that faithfully represents which nodes exist and which nodes are connected, without wasting memory or making lookups unnecessarily slow. Two approaches dominate: the adjacency matrix and the adjacency list. Each is a completely different philosophy about the trade-off between memory usage and access speed, and choosing the wrong one for your problem is a classic, costly mistake.
By the end of this article you'll be able to build both representations from scratch in Java, explain in plain English why each one exists, look at a problem description and immediately know which structure to reach for, and walk into an interview and answer the follow-up questions that trip most candidates up.
Adjacency Matrix — The 'Spreadsheet' Approach to Storing Edges
An adjacency matrix is a 2D grid where both the rows and columns represent nodes. If there's an edge between node A and node B, you put a 1 (or the edge weight) at position [A][B]. If there's no edge, you store 0. That's it.
Why would you choose a grid? Because checking whether two specific nodes are connected is O(1) — instantaneous. You just index directly into the array like matrix[3][7]. There's no searching, no traversal. That property is invaluable when your core operation is 'does this edge exist?'
The cost you pay is memory. If your graph has N nodes, you always allocate N × N space — even if your graph only has a handful of edges. A social network with 100 million users storing an adjacency matrix would need 10 quadrillion cells. That's why adjacency matrices are reserved for dense graphs — graphs where most possible edges actually exist — and for smaller graphs where the O(1) edge lookup is worth the memory price.
For weighted graphs (like road distances), you simply store the weight instead of 1. For unweighted undirected graphs, the matrix is always symmetric — matrix[i][j] always equals matrix[j][i].
public class AdjacencyMatrix { private final int[][] matrix; // The actual grid storing edge data private final int nodeCount; // Total number of nodes in the graph public AdjacencyMatrix(int nodeCount) { this.nodeCount = nodeCount; // Allocate a nodeCount x nodeCount grid, all initialised to 0 (no edges) this.matrix = new int[nodeCount][nodeCount]; } // Add an undirected edge between two nodes (both directions matter) public void addEdge(int fromNode, int toNode) { // Validate to avoid silent index-out-of-bounds bugs in production if (fromNode < 0 || toNode < 0 || fromNode >= nodeCount || toNode >= nodeCount) { throw new IllegalArgumentException( "Node index out of range: " + fromNode + ", " + toNode ); } matrix[fromNode][toNode] = 1; // Mark edge in one direction matrix[toNode][fromNode] = 1; // Mark edge in the other (undirected) } // O(1) check — this is the matrix's superpower public boolean hasEdge(int fromNode, int toNode) { return matrix[fromNode][toNode] == 1; } // Print the full grid so we can visualise the structure public void printMatrix() { System.out.print(" "); for (int col = 0; col < nodeCount; col++) { System.out.printf("%3d", col); // Column headers } System.out.println(); for (int row = 0; row < nodeCount; row++) { System.out.printf("%3d |", row); // Row header for (int col = 0; col < nodeCount; col++) { System.out.printf("%3d", matrix[row][col]); } System.out.println(); } } public static void main(String[] args) { // Modelling a small city road network with 5 intersections (nodes 0-4) AdjacencyMatrix cityRoads = new AdjacencyMatrix(5); cityRoads.addEdge(0, 1); // Road between intersection 0 and 1 cityRoads.addEdge(0, 4); // Road between intersection 0 and 4 cityRoads.addEdge(1, 2); // Road between intersection 1 and 2 cityRoads.addEdge(1, 3); // Road between intersection 1 and 3 cityRoads.addEdge(3, 4); // Road between intersection 3 and 4 cityRoads.printMatrix(); System.out.println(); // O(1) lookup — is there a direct road from 0 to 3? System.out.println("Direct road from 0 to 3? " + cityRoads.hasEdge(0, 3)); System.out.println("Direct road from 1 to 3? " + cityRoads.hasEdge(1, 3)); } }
0 | 0 1 0 0 1
1 | 1 0 1 1 0
2 | 0 1 0 0 0
3 | 0 1 0 0 1
4 | 1 0 0 1 0
Direct road from 0 to 3? false
Direct road from 1 to 3? true
Adjacency List — The 'Contacts App' Approach That Scales
An adjacency list stores a graph differently: instead of a giant grid, each node gets its own personal list of the nodes it's directly connected to. If node 3 connects to nodes 1, 5, and 7, then node 3's list contains [1, 5, 7]. Nothing more.
Think of it like a contacts app. Each person (node) has a list of their direct friends (neighbours). You don't store a slot for every person on the planet that says 'not friends'. You only store the actual connections.
This is why adjacency lists dominate in the real world: most graphs are sparse — they have far fewer edges than the theoretical maximum. A social network with a billion users might have each user averaging 200 friends. An adjacency matrix would waste an astronomical amount of memory on zeros. An adjacency list stores only the real edges, giving you O(V + E) space where V is vertices and E is edges.
The trade-off: checking if a specific edge exists is now O(degree of node) — you have to scan the neighbour list. But for most graph algorithms like BFS and DFS, you're iterating over neighbours anyway, which is exactly what adjacency lists are optimised for.
import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; public class AdjacencyList { // Each node maps to a list of its direct neighbours // Using a Map allows nodes to have any label (String, Integer, etc.) private final Map<Integer, List<Integer>> adjacencyList; public AdjacencyList() { this.adjacencyList = new HashMap<>(); } // Register a node so it exists in the graph even with no edges public void addNode(int node) { adjacencyList.putIfAbsent(node, new ArrayList<>()); } // Add an undirected edge — each side adds the other to its neighbour list public void addEdge(int fromNode, int toNode) { addNode(fromNode); // Ensure both nodes exist before adding edges addNode(toNode); adjacencyList.get(fromNode).add(toNode); // fromNode now knows about toNode adjacencyList.get(toNode).add(fromNode); // toNode now knows about fromNode } // O(degree) check — scan the neighbour list of fromNode public boolean hasEdge(int fromNode, int toNode) { List<Integer> neighbours = adjacencyList.getOrDefault(fromNode, new ArrayList<>()); return neighbours.contains(toNode); } // Get all direct neighbours — this is the operation BFS/DFS uses constantly public List<Integer> getNeighbours(int node) { return adjacencyList.getOrDefault(node, new ArrayList<>()); } // Print each node alongside its neighbour list public void printList() { for (Map.Entry<Integer, List<Integer>> entry : adjacencyList.entrySet()) { System.out.println("Node " + entry.getKey() + " --> " + entry.getValue()); } } public static void main(String[] args) { // Modelling a simplified version of a flight route network AdjacencyList flightRoutes = new AdjacencyList(); flightRoutes.addEdge(0, 1); // London -- Paris flightRoutes.addEdge(0, 4); // London -- Dubai flightRoutes.addEdge(1, 2); // Paris -- New York flightRoutes.addEdge(1, 3); // Paris -- Tokyo flightRoutes.addEdge(3, 4); // Tokyo -- Dubai System.out.println("=== Flight Route Adjacency List ==="); flightRoutes.printList(); System.out.println(); System.out.println("Neighbours of node 1 (Paris): " + flightRoutes.getNeighbours(1)); System.out.println("Direct flight from 0 to 3? " + flightRoutes.hasEdge(0, 3)); System.out.println("Direct flight from 1 to 4? " + flightRoutes.hasEdge(1, 4)); } }
Node 0 --> [1, 4]
Node 1 --> [0, 2, 3]
Node 2 --> [1]
Node 3 --> [1, 4]
Node 4 --> [0, 3]
Neighbours of node 1 (Paris): [0, 2, 3]
Direct flight from 0 to 3? false
Direct flight from 1 to 4? false
Directed and Weighted Graphs — When Edges Have Direction and Cost
So far the edges in our examples have been undirected — a road from A to B means you can also go from B to A. But real-world graphs often have direction. Twitter follows are directed: you following someone doesn't mean they follow you back. One-way streets are directed. Dependencies in a build system are directed.
A directed graph (digraph) changes one thing in the adjacency list: when you add an edge from A to B, you only add B to A's neighbour list — not the reverse. In the matrix, you only set matrix[A][B] = 1, not matrix[B][A].
Weighted graphs add a cost to each edge. Google Maps doesn't just care that roads exist — it cares how long each road takes. For an adjacency list, you swap the simple integer neighbour for an Edge object that holds both the destination and the weight. For a matrix, you store the weight value instead of 1.
The code below combines both concepts — a directed, weighted graph — which is the most realistic structure you'll implement in real interview problems and production systems.
import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; public class DirectedWeightedGraph { // Inner class representing a directed edge with a weight (travel cost) static class Edge { int destination; // Where this edge points TO int weight; // The cost to traverse this edge (e.g. distance in km) Edge(int destination, int weight) { this.destination = destination; this.weight = weight; } @Override public String toString() { return "->" + destination + "(cost:" + weight + ")"; } } // Each node has a list of outgoing weighted edges private final Map<Integer, List<Edge>> graph; public DirectedWeightedGraph() { this.graph = new HashMap<>(); } public void addNode(int node) { graph.putIfAbsent(node, new ArrayList<>()); } // Directed: ONLY adds toNode to fromNode's list, not the reverse public void addDirectedEdge(int fromNode, int toNode, int weight) { addNode(fromNode); addNode(toNode); graph.get(fromNode).add(new Edge(toNode, weight)); // Notice: we do NOT add fromNode to toNode's list here } public List<Edge> getOutgoingEdges(int node) { return graph.getOrDefault(node, new ArrayList<>()); } public void printGraph() { for (Map.Entry<Integer, List<Edge>> entry : graph.entrySet()) { System.out.print("Node " + entry.getKey() + " : "); for (Edge edge : entry.getValue()) { System.out.print(edge + " "); } System.out.println(); } } public static void main(String[] args) { // Modelling a package delivery network where roads are one-directional // and weight represents delivery time in minutes DirectedWeightedGraph deliveryNetwork = new DirectedWeightedGraph(); deliveryNetwork.addDirectedEdge(0, 1, 10); // Warehouse -> Depot A (10 mins) deliveryNetwork.addDirectedEdge(0, 2, 30); // Warehouse -> Depot B (30 mins) deliveryNetwork.addDirectedEdge(1, 3, 15); // Depot A -> Customer X (15 mins) deliveryNetwork.addDirectedEdge(2, 3, 5); // Depot B -> Customer X (5 mins) deliveryNetwork.addDirectedEdge(1, 2, 8); // Depot A -> Depot B (8 mins) System.out.println("=== Delivery Network (Directed + Weighted) ==="); deliveryNetwork.printGraph(); System.out.println(); System.out.println("Routes out of Depot A (node 1): " + deliveryNetwork.getOutgoingEdges(1)); // Customer X (node 3) has no outgoing edges — it's a terminal node System.out.println("Routes out of Customer X (node 3): " + deliveryNetwork.getOutgoingEdges(3)); } }
Node 0 : ->1(cost:10) ->2(cost:30)
Node 1 : ->3(cost:15) ->2(cost:8)
Node 2 : ->3(cost:5)
Node 3 :
Routes out of Depot A (node 1): [->3(cost:15), ->2(cost:8)]
Routes out of Customer X (node 3): []
| Feature / Aspect | Adjacency Matrix | Adjacency List |
|---|---|---|
| Space complexity | O(V²) — always allocates V×V | O(V + E) — only stores real edges |
| Check if edge exists | O(1) — direct index lookup | O(degree of node) — scan neighbour list |
| Find all neighbours of a node | O(V) — must scan full row | O(degree of node) — instant list access |
| Add a new edge | O(1) | O(1) amortised |
| Add a new node | O(V²) — must resize entire matrix | O(1) — just add a new list entry |
| Best for | Dense graphs, edge-lookup-heavy problems | Sparse graphs, BFS/DFS traversals |
| Typical use cases | Floyd-Warshall, small grid problems | BFS, DFS, Dijkstra, real-world networks |
| Memory for 1,000 nodes, 2,000 edges | ~4 MB (1M integer cells) | ~16 KB (only 2K edges stored) |
| Weighted graph support | Store weight instead of 1 | Store Edge object with weight field |
🎯 Key Takeaways
- Adjacency matrix = O(1) edge lookup but O(V²) space — only justifiable for dense graphs or algorithms like Floyd-Warshall that access every pair of nodes anyway.
- Adjacency list = O(V + E) space and O(degree) neighbour access — the default choice for virtually all real-world sparse graph problems and traversal algorithms.
- Directed graphs break the symmetry assumption:
addEdge(A, B)must NOT automatically addaddEdge(B, A). Getting this wrong produces silent logical bugs that are extremely hard to trace. - The most important decision in graph programming happens before you write a single traversal algorithm: choosing your representation. Get this wrong and no amount of algorithmic cleverness will save your performance.
⚠ Common Mistakes to Avoid
- ✕Mistake 1: Using an adjacency matrix for a sparse graph — If your graph has 10,000 nodes but only 15,000 edges, allocating a 10,000×10,000 matrix wastes 99.985% of its memory on zeros. The symptom is your program running out of heap space or being extremely slow even before any algorithm runs. Fix: Count your nodes and edges first. If E << V², use an adjacency list. The rule of thumb: if edge count is closer to V than V², go with the list.
- ✕Mistake 2: Forgetting to add both directions for undirected graphs — You call
addEdge(2, 5)and only add 5 to node 2's list. Now BFS from node 5 can't reach node 2, even though there should be a connection. The symptom is traversals that mysteriously miss nodes that are 'one step behind'. Fix: Always add both directions in theaddEdgemethod itself, not at the call site — make the API safe by default, not the caller responsible for remembering. - ✕Mistake 3: Using node indices that don't start at 0 for a matrix — You have nodes labelled 1 through N and allocate a matrix of size N×N, then access
matrix[nodeLabel]directly. Node N tries to access index N in an array of size N, throwing an ArrayIndexOutOfBoundsException. Fix: Either allocate size N+1 and accept the wasted row/column at index 0, or consistently subtract 1 from every node label before indexing. Pick one approach and document it clearly.
Interview Questions on This Topic
- QWhen would you choose an adjacency matrix over an adjacency list, and what's the concrete cost of making the wrong choice? Walk me through the memory calculation for a sparse graph with 50,000 nodes and 100,000 edges using both representations.
- QHow would you modify an adjacency list to support a weighted directed graph? What changes to the data structure and the addEdge method would you need, and how does it affect the space complexity?
- QIf I give you an adjacency matrix, can you convert it to an adjacency list in place? What's the time and space complexity of that conversion, and why might someone need to do it at runtime?
Frequently Asked Questions
What is the difference between adjacency list and adjacency matrix in graph representation?
An adjacency matrix is a V×V grid where matrix[i][j]=1 means an edge exists between nodes i and j — it uses O(V²) space but gives O(1) edge lookups. An adjacency list gives each node its own list of direct neighbours, using only O(V+E) space but requiring O(degree) time to check if a specific edge exists. Use the matrix for dense graphs or when edge lookups dominate; use the list for sparse graphs or traversal-heavy algorithms.
How do you represent a weighted graph using an adjacency list?
Instead of storing a plain integer neighbour in the list, you store an Edge object that contains both the destination node and the weight. For example: each node maps to a List where Edge holds int destination and int weight. This way you can retrieve all outgoing edges from a node and their costs in a single list traversal, exactly what Dijkstra's algorithm needs.
Why does the choice of graph representation affect algorithm performance?
Because graph algorithms like BFS and DFS repeatedly ask 'what are the neighbours of this node?' — and the cost of answering that question differs between representations. With an adjacency list it's instant (return the pre-built list). With a matrix you scan a full row of V cells to find non-zero entries. For a graph with 10,000 nodes, that's 10,000 wasted comparisons per node visit. Across millions of visits this difference is the gap between an algorithm that finishes in seconds and one that runs for hours.
Written and reviewed by senior developers with real-world experience across enterprise, startup and open-source projects. Every article on TheCodeForge is written to be clear, accurate and genuinely useful — not just SEO filler.