Graphs
SummaryCovers adjacency list and matrix implementations, topological sort,...
Covers adjacency list and matrix implementations, topological sort,...
Covers adjacency list and matrix implementations, topological sort, cycle detection, connected components, and bipartite checking.
Graphs
Graph problems test your ability to model relationships and traverse interconnected structures. They appear in every top-tier interview loop — Google, Meta, Amazon, and Apple all draw from this problem family. The good news: a small set of techniques covers the overwhelming majority of graph interview questions. This chapter gives you those techniques, with complete Java 25 implementations you can adapt to any problem.
Graph Representations
The Edge Record
Before building a graph class, define an edge. Java 25 records provide the ideal structure — immutable, compact, and readable:
public record Edge(int to, int weight) {}
For unweighted graphs, set weight to 1 or ignore it entirely. The record approach keeps edge data clean and eliminates boilerplate getters.
Adjacency List Implementation
The adjacency list is your go-to representation. It stores each vertex alongside its list of outgoing edges, consuming O(V + E) space — proportional to the graph’s actual size.
import java.util.*;
public class Graph {
private final Map<Integer, List<Edge>> adjacencyList = new HashMap<>();
private final boolean directed;
public Graph(boolean directed) {
this.directed = directed;
}
public void addVertex(int vertex) {
adjacencyList.putIfAbsent(vertex, new ArrayList<>());
}
public void addEdge(int from, int to, int weight) {
adjacencyList.computeIfAbsent(from, _ -> new ArrayList<>()).add(new Edge(to, weight));
if (!directed) {
adjacencyList.computeIfAbsent(to, _ -> new ArrayList<>()).add(new Edge(from, weight));
}
}
public void addEdge(int from, int to) {
addEdge(from, to, 1);
}
public List<Edge> neighbors(int vertex) {
return adjacencyList.getOrDefault(vertex, List.of());
}
public Set<Integer> vertices() {
return adjacencyList.keySet();
}
public int vertexCount() {
return adjacencyList.size();
}
}
The directed flag controls whether addEdge creates a one-way or two-way connection. This single class handles both directed and undirected graphs.
Adjacency Matrix Implementation
When the graph is dense or you need O(1) edge lookups, an adjacency matrix is the better choice:
public class GraphMatrix {
private final int[][] matrix;
private final boolean directed;
private final int size;
public GraphMatrix(int size, boolean directed) {
this.size = size;
this.directed = directed;
this.matrix = new int[size][size];
// Initialize with -1 to indicate no edge (0 could be a valid weight)
for (int[] row : matrix) Arrays.fill(row, -1);
}
public void addEdge(int from, int to, int weight) {
matrix[from][to] = weight;
if (!directed) {
matrix[to][from] = weight;
}
}
public boolean hasEdge(int from, int to) {
return matrix[from][to] != -1;
}
public int weight(int from, int to) {
return matrix[from][to];
}
public List<Integer> neighbors(int vertex) {
var result = new ArrayList<Integer>();
for (int i = 0; i < size; i++) {
if (matrix[vertex][i] != -1) result.add(i);
}
return result;
}
}
For unweighted graphs, swap the int[][] for a boolean[][] matrix — each cell stores true or false. The adjacency matrix uses O(V²) space, so reserve it for graphs where V is small or E is close to V².
Topological Sort
A topological sort produces a linear ordering of vertices in a DAG such that for every directed edge (u, v), vertex u appears before vertex v. Build systems, course schedulers, and dependency resolvers all depend on topological ordering.
A topological sort exists if and only if the graph is a DAG. If the graph contains a cycle, no valid ordering exists.
Kahn’s Algorithm (BFS with In-Degree)
Kahn’s algorithm works by repeatedly removing vertices with zero in-degree — vertices that have no unsatisfied dependencies.
Algorithm:
- Compute in-degree for every vertex.
- Add all vertices with in-degree 0 to a queue.
- While the queue is not empty: remove a vertex, add it to the result, and decrement the in-degree of all its neighbors. If any neighbor’s in-degree drops to 0, add it to the queue.
- If the result contains all vertices, the ordering is valid. Otherwise, the graph has a cycle.
public List<Integer> topologicalSortKahn(Graph graph) {
Map<Integer, Integer> inDegree = new HashMap<>();
for (int v : graph.vertices()) {
inDegree.putIfAbsent(v, 0);
for (Edge edge : graph.neighbors(v)) {
inDegree.merge(edge.to(), 1, Integer::sum);
}
}
Queue<Integer> queue = new LinkedList<>();
for (var entry : inDegree.entrySet()) {
if (entry.getValue() == 0) queue.add(entry.getKey());
}
List<Integer> order = new ArrayList<>();
while (!queue.isEmpty()) {
int current = queue.poll();
order.add(current);
for (Edge edge : graph.neighbors(current)) {
int newDegree = inDegree.merge(edge.to(), -1, Integer::sum);
if (newDegree == 0) queue.add(edge.to());
}
}
if (order.size() != graph.vertexCount()) {
throw new IllegalArgumentException("Graph contains a cycle — no topological order exists");
}
return order;
}
Time: O(V + E) — each vertex and edge is processed once. Space: O(V) for the in-degree map and queue.
DFS-Based Topological Sort
The DFS approach uses a different insight: when DFS finishes processing a vertex (all descendants have been visited), that vertex can appear after all its dependents. Collecting vertices in post-order and reversing the result produces a valid topological ordering.
public List<Integer> topologicalSortDFS(Graph graph) {
Set<Integer> visited = new HashSet<>();
Deque<Integer> stack = new ArrayDeque<>();
for (int vertex : graph.vertices()) {
if (!visited.contains(vertex)) {
dfsTopSort(graph, vertex, visited, stack);
}
}
return new ArrayList<>(stack);
}
private void dfsTopSort(Graph graph, int vertex, Set<Integer> visited, Deque<Integer> stack) {
visited.add(vertex);
for (Edge edge : graph.neighbors(vertex)) {
if (!visited.contains(edge.to())) {
dfsTopSort(graph, edge.to(), visited, stack);
}
}
stack.push(vertex); // Post-order: add after all descendants are processed
}
Time: O(V + E). Space: O(V) for the visited set and recursion stack.
Which to use in interviews? Kahn’s algorithm is easier to reason about and naturally detects cycles (the result size check). The DFS approach is more concise. Know both — interviewers sometimes ask for the alternative after you present one.
Applications
- Course Schedule: Given course prerequisites, find a valid enrollment order — or determine that no valid order exists (cycle).
- Build Order: Compile source files in dependency order so every file is compiled after its dependencies.
- Dependency Resolution: Package managers (Maven, npm) use topological sort to determine installation order.
Cycle Detection
Directed Graphs: Three-Color DFS
The three-color approach assigns each vertex a state during DFS. If DFS encounters a vertex that is currently being processed (GRAY), a cycle exists — the traversal has looped back to an ancestor in the current path.
public enum Color { WHITE, GRAY, BLACK }
public boolean hasCycleDirected(Graph graph) {
Map<Integer, Color> color = new HashMap<>();
for (int v : graph.vertices()) {
color.put(v, Color.WHITE);
}
for (int v : graph.vertices()) {
if (color.get(v) == Color.WHITE) {
if (dfsCycleDirected(graph, v, color)) return true;
}
}
return false;
}
private boolean dfsCycleDirected(Graph graph, int vertex, Map<Integer, Color> color) {
color.put(vertex, Color.GRAY); // Mark as in-progress
for (Edge edge : graph.neighbors(vertex)) {
Color neighborColor = color.get(edge.to());
if (neighborColor == Color.GRAY) return true; // Back edge → cycle
if (neighborColor == Color.WHITE) {
if (dfsCycleDirected(graph, edge.to(), color)) return true;
}
}
color.put(vertex, Color.BLACK); // Mark as fully processed
return false;
}
WHITE = unvisited. GRAY = currently on the recursion stack (being explored). BLACK = fully processed (all descendants explored). A GRAY→GRAY edge proves a cycle because the destination is an ancestor in the current DFS path.
Time: O(V + E). Space: O(V).
Undirected Graphs: DFS with Parent Tracking
In undirected graphs, every edge creates a “back” connection to the parent. You need to distinguish between the parent edge (not a cycle) and a genuine back edge (a cycle). Track the parent of each vertex during DFS:
public boolean hasCycleUndirected(Graph graph) {
Set<Integer> visited = new HashSet<>();
for (int v : graph.vertices()) {
if (!visited.contains(v)) {
if (dfsCycleUndirected(graph, v, -1, visited)) return true;
}
}
return false;
}
private boolean dfsCycleUndirected(Graph graph, int vertex, int parent, Set<Integer> visited) {
visited.add(vertex);
for (Edge edge : graph.neighbors(vertex)) {
if (!visited.contains(edge.to())) {
if (dfsCycleUndirected(graph, edge.to(), vertex, visited)) return true;
} else if (edge.to() != parent) {
return true; // Visited neighbor that isn't the parent → cycle
}
}
return false;
}
Time: O(V + E). Space: O(V).
Alternative: Union-Find. Process edges one at a time. If both endpoints belong to the same set, the edge creates a cycle. Union-Find cycle detection runs in nearly O(E × α(V)) time, where α is the inverse Ackermann function — effectively constant.
Connected Components
Undirected Graphs: DFS/BFS Labeling
A connected component is a maximal group of vertices reachable from each other. Finding all components requires launching a traversal from each unvisited vertex:
public List<List<Integer>> connectedComponents(Graph graph) {
Set<Integer> visited = new HashSet<>();
List<List<Integer>> components = new ArrayList<>();
for (int v : graph.vertices()) {
if (!visited.contains(v)) {
List<Integer> component = new ArrayList<>();
dfsComponent(graph, v, visited, component);
components.add(component);
}
}
return components;
}
private void dfsComponent(Graph graph, int vertex, Set<Integer> visited, List<Integer> component) {
visited.add(vertex);
component.add(vertex);
for (Edge edge : graph.neighbors(vertex)) {
if (!visited.contains(edge.to())) {
dfsComponent(graph, edge.to(), visited, component);
}
}
}
Time: O(V + E). Space: O(V).
Directed Graphs: Kosaraju’s Algorithm for SCCs
A Strongly Connected Component (SCC) is a maximal set of vertices where every vertex is reachable from every other vertex via directed edges. Kosaraju’s algorithm finds all SCCs in two passes:
- First pass: Run DFS on the original graph, recording the finish order (push to a stack when DFS completes a vertex).
- Transpose the graph: Reverse all edge directions.
- Second pass: Pop vertices from the stack and run DFS on the transposed graph. Each DFS from a new starting vertex discovers one SCC.
The finish-order trick guarantees that in the second pass, DFS from high-finish-order vertices never escapes their SCC in the transposed graph.
Time: O(V + E). Space: O(V).
Bipartite Check
A graph is bipartite if you can color every vertex with one of two colors such that no two adjacent vertices share the same color. Bipartite graphs are equivalent to graphs with no odd-length cycles.
Use BFS (or DFS) to attempt two-coloring. If you encounter a neighbor already colored with the same color as the current vertex, the graph is not bipartite:
public boolean isBipartite(Graph graph) {
Map<Integer, Integer> color = new HashMap<>(); // 0 or 1
for (int v : graph.vertices()) {
if (!color.containsKey(v)) {
if (!bfsBipartite(graph, v, color)) return false;
}
}
return true;
}
private boolean bfsBipartite(Graph graph, int start, Map<Integer, Integer> color) {
Queue<Integer> queue = new LinkedList<>();
queue.add(start);
color.put(start, 0);
while (!queue.isEmpty()) {
int current = queue.poll();
int currentColor = color.get(current);
for (Edge edge : graph.neighbors(current)) {
if (!color.containsKey(edge.to())) {
color.put(edge.to(), 1 - currentColor);
queue.add(edge.to());
} else if (color.get(edge.to()) == currentColor) {
return false; // Adjacent vertices share a color → not bipartite
}
}
}
return true;
}
Time: O(V + E). Space: O(V).
Applications: Bipartite checking appears in matching problems (job assignments, stable marriages) and in graph coloring problems where two groups must remain separated.
Classic Interview Problems
Course Schedule (Cycle Detection)
Problem: Given numCourses and a list of prerequisite pairs, determine whether you can finish all courses.
Insight: Model courses as vertices, prerequisites as directed edges. If the graph has a cycle, a circular dependency exists and you cannot complete all courses. Use Kahn’s algorithm — if the topological sort result contains fewer vertices than numCourses, a cycle exists.
public boolean canFinish(int numCourses, int[][] prerequisites) {
var graph = new Graph(true);
for (int i = 0; i < numCourses; i++) graph.addVertex(i);
for (int[] pre : prerequisites) graph.addEdge(pre[1], pre[0]);
try {
topologicalSortKahn(graph);
return true;
} catch (IllegalArgumentException e) {
return false;
}
}
Clone Graph
Problem: Given a reference to a node in a connected undirected graph, return a deep copy.
Insight: Use BFS or DFS with a map from original nodes to cloned nodes. When visiting a neighbor, check if it has already been cloned. If so, reuse the clone; otherwise, create a new clone and continue the traversal.
public Node cloneGraph(Node node) {
if (node == null) return null;
Map<Node, Node> cloned = new HashMap<>();
Queue<Node> queue = new LinkedList<>();
cloned.put(node, new Node(node.val));
queue.add(node);
while (!queue.isEmpty()) {
Node current = queue.poll();
for (Node neighbor : current.neighbors) {
if (!cloned.containsKey(neighbor)) {
cloned.put(neighbor, new Node(neighbor.val));
queue.add(neighbor);
}
cloned.get(current).neighbors.add(cloned.get(neighbor));
}
}
return cloned.get(node);
}
Number of Islands
Problem: Given a 2D grid of '1' (land) and '0' (water), count the number of islands (connected groups of land cells).
Insight: Treat the grid as an implicit graph where each land cell connects to its four cardinal neighbors. Each DFS/BFS from an unvisited land cell discovers one island. Mark visited cells to avoid double-counting.
public int numIslands(char[][] grid) {
int count = 0;
int rows = grid.length, cols = grid[0].length;
for (int r = 0; r < rows; r++) {
for (int c = 0; c < cols; c++) {
if (grid[r][c] == '1') {
count++;
dfsFloodFill(grid, r, c, rows, cols);
}
}
}
return count;
}
private void dfsFloodFill(char[][] grid, int r, int c, int rows, int cols) {
if (r < 0 || r >= rows || c < 0 || c >= cols || grid[r][c] != '1') return;
grid[r][c] = '0'; // Mark as visited by sinking the island
dfsFloodFill(grid, r + 1, c, rows, cols);
dfsFloodFill(grid, r - 1, c, rows, cols);
dfsFloodFill(grid, r, c + 1, rows, cols);
dfsFloodFill(grid, r, c - 1, rows, cols);
}
Time: O(rows × cols). Space: O(rows × cols) in the worst case for recursion stack.
Complexity Reference Table
| Operation | Adjacency List | Adjacency Matrix |
|---|---|---|
| BFS / DFS | O(V + E) | O(V²) |
| Topological Sort | O(V + E) | O(V²) |
| Cycle Detection | O(V + E) | O(V²) |
| Connected Components | O(V + E) | O(V²) |
| Bipartite Check | O(V + E) | O(V²) |
| Check edge exists | O(degree) | O(1) |
| Space | O(V + E) | O(V²) |
Edge Cases and Interviewer Tips
- Empty graph: Zero vertices or zero edges. Return an empty result, not an exception.
- Single vertex: A single-node graph is trivially connected, acyclic, and bipartite.
- Self-loops: An edge from a vertex to itself. In directed graphs, this is an immediate cycle. In undirected graphs, the parent-tracking approach needs adjustment — check for
edge.to() == vertexexplicitly. - Disconnected graphs: Always iterate over all vertices to launch traversals, not only from vertex 0. The graph can have multiple components.
- Grid problems: Grids are implicit graphs. Each cell has up to 4 neighbors. Use a direction array
int[][] dirs = {{0,1},{0,-1},{1,0},{-1,0}}to avoid repeating boundary checks. - State your representation early: Tell the interviewer whether you are using an adjacency list or matrix and why. This demonstrates intentional design.
- Mention complexity proactively: After presenting each algorithm, state the time and space complexity without waiting for the interviewer to ask. This signals depth of understanding.