Graphs: Matrix, Adjacency List, BFS, DFS, and Dijkstra

A tree has a strict rule: each node has exactly one parent and there are no cycles. That is enough for hierarchies, but falls short for almost everything else.

In a social network, if you are friends with Ana and Ana is friends with Luis, and Luis is also friends with you, who is whose parent? On a street map, which corner is the root? Neither question makes sense, because that is not a tree: it is a graph.

A graph is the most general structure of all. In fact, a tree is simply a graph with restrictions: connected, acyclic, and with a chosen root.


1. Vocabulary

An undirected graph and a weighted directed graph, with their basic vocabulary Undirected — the relation goes both ways A B C D E "A is friends with B" implies "B is friends with A". A's degree is 3: three edges touch it. Directed and weighted — direction and cost 4 2 5 1 7 A B C D E "A follows B" does not imply "B follows A". The weight is the cost of traversing that edge. VERTEX: each circle. EDGE: each line. A's NEIGHBORS: the vertices A shares a direct edge with.
With these four attributes — directed or not, weighted or not — you can model almost any real-world network.

Examples you already use every day:

SystemVerticesEdgesType
Social network (friendships)People”are friends”Undirected
Social network (followers)People”follows”Directed
Maps and navigationIntersectionsStreets with distanceDirected, weighted
The internetRoutersLinks with latencyDirected, weighted
Build dependenciesModules”depends on”Directed, acyclic

2. The two ways to represent it

A graph is not stored “as it looks”. There are two standard representations, and choosing wrong is expensive.

Adjacency matrix and adjacency list for the same graph Graph: A–B, A–C, B–C, C–D A B C D Adjacency matrix A B C D A B C D 0 1 1 0 1 0 1 0 1 1 0 1 0 0 1 0 Adjacency list A → [ B, C ] B → [ A, C ] C → [ A, B, D ] D → [ C ] Does edge A–D exist? O(1) — a single lookup O(degree) — you must scan Iterate A's neighbors O(V) — scans the whole row O(degree) — only what exists Memory O(V²) always, even when empty O(V + E) When it wins dense graph (nearly all connected) sparse graph: the normal case
A social network with a billion people and two hundred friends each: the matrix would demand a quintillion cells; the list, two hundred billion. That is why lists win in practice.

In Java, the adjacency list is written with a Map, which you already know from the Java Collections and Generics (JCF) lesson:

public class Graph<V> {
    private final Map<V, List<V>> adjacency = new HashMap<>();

    public void addVertex(V v) {
        adjacency.putIfAbsent(v, new ArrayList<>());
    }

    // Undirected: the edge is added in both directions
    public void addEdge(V from, V to) {
        adjacency.computeIfAbsent(from, k -> new ArrayList<>()).add(to);
        adjacency.computeIfAbsent(to,   k -> new ArrayList<>()).add(from);
    }

    public List<V> neighbors(V v) {
        return adjacency.getOrDefault(v, List.of());
    }
}

Notice that computeIfAbsent and getOrDefault — the Map methods from the Java Collections and Generics (JCF) lesson — do all the heavy lifting. Without them you would need four extra if blocks.


3. BFS and DFS: the same algorithm with different memory

Traversing a graph has a problem trees did not: cycles. If A connects to B, B to C, and C back to A, a naive traversal loops forever.

The fix is a Set of visited vertices. And with that, the two classic traversals differ in exactly one thing: which structure holds the pending vertices.

Visit order of BFS and DFS over the same graph starting from A BFS — with a QUEUE (FIFO) A1 B2 D3 C4 E5 F6 A · B · D · C · E · F Explores in LAYERS: all direct neighbors first, then the neighbors of those. Finds the shortest path measured in edges. DFS — with a STACK (LIFO) or recursion A1 B2 D5 C3 E4 F6 A · B · C · E · D · F DIVES to the bottom along one path before backtracking and trying another. Good for cycle detection and dependency ordering.
Same graph, same starting point, same code: the only difference is whether pending vertices leave in the order they arrived or in reverse.
// BFS — queue: first in, first out
public List<V> bfs(V start) {
    List<V> order = new ArrayList<>();
    Set<V> visited = new HashSet<>();
    Queue<V> pending = new ArrayDeque<>();

    visited.add(start);       // ← mark ON ENQUEUE, not on dequeue
    pending.offer(start);

    while (!pending.isEmpty()) {
        V current = pending.poll();
        order.add(current);

        for (V neighbor : neighbors(current)) {
            if (visited.add(neighbor)) {     // add returns false if already present
                pending.offer(neighbor);
            }
        }
    }
    return order;
}

// DFS — recursive: the JVM call stack serves as the stack
public List<V> dfs(V start) {
    List<V> order = new ArrayList<>();
    dfs(start, new HashSet<>(), order);
    return order;
}

private void dfs(V current, Set<V> visited, List<V> order) {
    if (!visited.add(current)) return;   // already visited: stop
    order.add(current);
    for (V neighbor : neighbors(current)) {
        dfs(neighbor, visited, order);
    }
}

Mark as visited on enqueue, not on dequeue. If you wait until you pull it off the queue, the same vertex can be enqueued several times before it is first processed. On a large graph that is not a detail: it multiplies the work and can exhaust memory.


4. Dijkstra: the cheapest path

BFS finds the path with the fewest edges. But when edges carry weight — kilometers, minutes, cost — the path with the fewest hops can be wildly expensive.

How Dijkstra's distance table evolves step by step 4 2 1 5 8 3 A B C D E step A B C D E start 0 ∞ ∞ ∞ ∞ visit A 0 4 2 ∞ ∞ visit C 0 3 2 10 ∞ visit B 0 3 2 8 ∞ visit D 0 3 2 8 11 The shortest path from A to B is NOT the direct edge, which costs 4. It is A → C → B, costing 2 + 1 = 3. The "visit C" row is where it is discovered. Dijkstra always visits the cheapest pending vertex and relaxes its edges: if arriving this way is cheaper than what is recorded, it updates the distance.
That drop from 4 to 3 in column B is the entire algorithm: a cheaper route can appear through a path with more hops.
public Map<V, Integer> dijkstra(V source) {
    Map<V, Integer> distance = new HashMap<>();
    Set<V> visited = new HashSet<>();

    // The priority queue from Java Collections and Generics (JCF): always pulls the cheapest
    PriorityQueue<V> queue = new PriorityQueue<>(
        Comparator.comparingInt(v -> distance.getOrDefault(v, Integer.MAX_VALUE))
    );

    distance.put(source, 0);
    queue.offer(source);

    while (!queue.isEmpty()) {
        V current = queue.poll();
        if (!visited.add(current)) continue;   // already processed

        for (Edge<V> edge : edgesFrom(current)) {
            int candidate = distance.get(current) + edge.weight();
            // "Relax": if this route is cheaper, record it
            if (candidate < distance.getOrDefault(edge.to(), Integer.MAX_VALUE)) {
                distance.put(edge.to(), candidate);
                queue.offer(edge.to());
            }
        }
    }
    return distance;
}

Dijkstra does not work with negative weights. Its guarantee rests on a visited vertex’s distance being final; a negative weight breaks that. For that case there is Bellman-Ford.


5. Common mistakes

MistakeWhat happensHow to fix it
Traversing without a visited SetInfinite loop as soon as there is a cycle, which is nearly always.A HashSet<V> of visited vertices, checked before enqueueing.
Marking as visited on dequeueA vertex enters the queue several times; the work multiplies.Mark at the moment of enqueueing.
Using an adjacency matrix for a large sparse graphO(V²) memory: a million vertices means a trillion cells.Adjacency list (Map<V, List<V>>).
Forgetting the reverse edge in an undirected graphThe graph becomes accidentally directed and traversals miss half of it.Add the edge in both directions.
Using BFS for weighted pathsIt returns the fewest-hops path, which may be the most expensive.Dijkstra when edges carry cost.
Using Dijkstra with negative weightsWrong results, with no exception thrown.Bellman-Ford.
Recursive DFS on a huge graphStackOverflowError past a few thousand levels.Iterative DFS with ArrayDeque as an explicit stack.

6. Guided hands-on exercise

Challenge: degrees of separation in a social network

Implement minimumHops(String from, String to) returning the minimum number of intermediaries between two people, plus shortestPath(...) returning the chain of people.

Think about why BFS is the only correct choice here, and why DFS would give the wrong answer.

See suggested solution
import java.util.*;

public class SocialNetwork {

    private final Map<String, List<String>> friends = new HashMap<>();

    public void addFriendship(String a, String b) {
        // Undirected: friendship goes both ways
        friends.computeIfAbsent(a, k -> new ArrayList<>()).add(b);
        friends.computeIfAbsent(b, k -> new ArrayList<>()).add(a);
    }

    /**
     * BFS: returns the minimum number of hops, or -1 when unreachable.
     * DFS is NO good here: it would find *a* path, not the shortest one.
     */
    public int minimumHops(String from, String to) {
        if (!friends.containsKey(from) || !friends.containsKey(to)) return -1;
        if (from.equals(to)) return 0;

        Set<String> visited = new HashSet<>();
        Queue<String> queue = new ArrayDeque<>();

        visited.add(from);
        queue.offer(from);
        int hops = 0;

        while (!queue.isEmpty()) {
            int peopleAtThisLevel = queue.size();   // ← the key to counting levels
            hops++;

            for (int i = 0; i < peopleAtThisLevel; i++) {
                String current = queue.poll();

                for (String friend : friends.getOrDefault(current, List.of())) {
                    if (friend.equals(to)) return hops;
                    if (visited.add(friend)) {      // add returns false if present
                        queue.offer(friend);
                    }
                }
            }
        }
        return -1;   // walked everything reachable and never found them
    }

    /**
     * Same idea, but recording where each person was reached from
     * so the path can be reconstructed at the end.
     */
    public List<String> shortestPath(String from, String to) {
        if (!friends.containsKey(from) || !friends.containsKey(to)) return List.of();

        Map<String, String> cameFrom = new HashMap<>();
        Set<String> visited = new HashSet<>();
        Queue<String> queue = new ArrayDeque<>();

        visited.add(from);
        queue.offer(from);

        while (!queue.isEmpty()) {
            String current = queue.poll();

            if (current.equals(to)) {
                // Rebuild the path by walking backwards from the destination
                LinkedList<String> path = new LinkedList<>();
                for (String p = to; p != null; p = cameFrom.get(p)) {
                    path.addFirst(p);
                }
                return path;
            }

            for (String friend : friends.getOrDefault(current, List.of())) {
                if (visited.add(friend)) {
                    cameFrom.put(friend, current);   // remember the traversal parent
                    queue.offer(friend);
                }
            }
        }
        return List.of();
    }

    public static void main(String[] args) {
        SocialNetwork net = new SocialNetwork();
        net.addFriendship("Ana",   "Beto");
        net.addFriendship("Beto",  "Carla");
        net.addFriendship("Carla", "Diego");
        net.addFriendship("Ana",   "Elena");
        net.addFriendship("Elena", "Diego");
        net.addFriendship("Fabian","Gala");   // group disconnected from the rest

        System.out.println("Ana → Diego  : " + net.minimumHops("Ana", "Diego"));
        System.out.println("Path         : " + net.shortestPath("Ana", "Diego"));
        System.out.println("Ana → Fabian : " + net.minimumHops("Ana", "Fabian"));
        System.out.println("Ana → Ana    : " + net.minimumHops("Ana", "Ana"));
    }
}

Output:

Ana → Diego  : 2
Path         : [Ana, Elena, Diego]
Ana → Fabian : -1
Ana → Ana    : 0

Why BFS and not DFS. From Ana to Diego there are two paths: Ana → Beto → Carla → Diego (3 hops) and Ana → Elena → Diego (2 hops). A DFS that starts with Beto finds the 3-hop path first, returns it, and never learns a better one existed.

BFS explores in layers: it exhausts everything one hop away before looking at anything two hops away. That is why the first path it finds is necessarily the shortest. It is a guarantee of the algorithm, not luck.

The queue.size() detail. Capturing the queue size at the top of each round is what lets you know where one level ends and the next begins. Without it you can tell whether a path exists, but not how many hops it takes.

And the cameFrom map. BFS tells you that you arrived, but not by which route. By recording each person’s parent as you discover them, you reconstruct the path afterwards by walking backwards from the destination. It is the same trick a GPS uses to draw your route.


8. Floyd–Warshall: all-pairs shortest paths

Dijkstra answers queries from one source and requires nonnegative weights. Floyd–Warshall computes all-pairs shortest paths and supports negative edges, provided the result is not affected by a negative cycle.

Initializing the distance matrix

For V vertices, build dist[V][V]:

  • dist[i][i] = 0;
  • dist[i][j] = weight(i, j) when an edge exists;
  • dist[i][j] = INF when j is directly unreachable from i.

INF must not be Long.MAX_VALUE: adding a weight could overflow and become negative. Use the safe sentinel Long.MAX_VALUE / 4, and never add unreachable distances.

Invariant and matrix evolution

Before processing k, dist[i][j] is the best known cost whose intermediate vertices belong to 0..k-1. Each pair either keeps that cost or takes a route through k:

dist[i][j] = min(dist[i][j], dist[i][k] + dist[k][j])

Example (∞ means unreachable), with edges A→B=4, B→C=-2, and A→C=5:

Initial             After allowing B
    A   B   C           A   B   C
A   0   4   5       A   0   4   2
B   ∞   0  -2       B   ∞   0  -2
C   ∞   ∞   0       C   ∞   ∞   0

The A→C value decreases from 5 to 2 because the A→B→C path becomes available. The entire matrix evolves once for each possible intermediate vertex.

Safe Java implementation

The method validates that its input is a square matrix, rejects null rows, out-of-range values, and empty matrices. The input uses the same INF sentinel to represent a missing edge.

public final class FloydWarshall {
    public static final long INF = Long.MAX_VALUE / 4;

    public record Result(long[][] distances, boolean hasNegativeCycle) {}

    public static Result compute(long[][] weights) {
        validateMatrix(weights);
        int vertices = weights.length;
        long[][] dist = new long[vertices][vertices];

        for (int i = 0; i < vertices; i++) {
            for (int j = 0; j < vertices; j++) {
                long weight = weights[i][j];
                dist[i][j] = i == j ? Math.min(0, weight) : weight;
            }
        }

        for (int k = 0; k < vertices; k++) {
            for (int i = 0; i < vertices; i++) {
                if (dist[i][k] == INF) continue;
                for (int j = 0; j < vertices; j++) {
                    if (dist[k][j] == INF) continue;
                    long throughK = dist[i][k] + dist[k][j];
                    if (throughK < dist[i][j]) {
                        dist[i][j] = throughK;
                    }
                }
            }
        }

        boolean negativeCycle = false;
        for (int v = 0; v < vertices; v++) {
            if (dist[v][v] < 0) {
                negativeCycle = true;
                break;
            }
        }
        return new Result(copy(dist), negativeCycle);
    }

    private static void validateMatrix(long[][] weights) {
        if (weights == null || weights.length == 0) {
            throw new IllegalArgumentException("matrix is required and cannot be empty");
        }
        int vertices = weights.length;
        for (int i = 0; i < vertices; i++) {
            if (weights[i] == null || weights[i].length != vertices) {
                throw new IllegalArgumentException("a square matrix is required");
            }
            for (long weight : weights[i]) {
                if (weight < -INF || weight > INF) {
                    throw new IllegalArgumentException("weight is outside the safe range");
                }
            }
        }
    }

    private static long[][] copy(long[][] matrix) {
        long[][] result = new long[matrix.length][];
        for (int i = 0; i < matrix.length; i++) {
            result[i] = matrix[i].clone();
        }
        return result;
    }
}

Addition is safe because two finite values, each between -INF and INF, cannot reach the limits of long. A pair remains unreachable when its cell ends at INF; it must not be printed as a real distance.

Negative edges and cycles

A negative edge is valid: it can represent credit, profit, or a cost correction. Dijkstra fails with negative edges because it finalizes a distance that a later edge could reduce. Floyd–Warshall incorporates them correctly.

A negative cycle makes it possible to reduce a cost indefinitely. After the three loops, dist[v][v] < 0 detects that v participates in such a cycle. The affected “shortest distances” are then undefined; the caller must treat hasNegativeCycle as an explicit failure rather than consuming the matrix as valid output.

Complexity and selection

Floyd–Warshall takes O(V³) time because of its three loops and O(V²) space for the matrix. It is straightforward for all pairs and works well for dense or moderately sized graphs.

Running Dijkstra from every vertex usually costs O(V · (E log V)) with adjacency lists and can be better for sparse graphs, but only when every edge is nonnegative. Representation, permitted weights, and query shape determine the algorithm; there is no universal winner.


Key takeaways

  • A graph is the most general structure: a tree is a connected, acyclic graph with a chosen root.
  • Adjacency list for sparse graphs (nearly all real ones); matrix only when it is dense.
  • In Java the adjacency list is a Map<V, List<V>>, and computeIfAbsent does the heavy lifting.
  • Without a visited Set, any cycle turns the traversal into an infinite loop.
  • BFS and DFS are the same algorithm: the only difference is a queue versus a stack.
  • Mark vertices visited on enqueue, never on dequeue.
  • BFS gives the fewest-edges path; when edges have weight, you need Dijkstra.
  • Dijkstra always visits the cheapest pending vertex and relaxes its edges. It is invalid with negative weights.