On This Page

What Is a Graph?

A graph is a set of vertices (also called nodes β€” individual points or entities in the structure) connected by edges (the links or connections between those nodes). That's the entire formal definition. Unlike trees, there's no root, no parent-child hierarchy, and cycles are perfectly fine.

The concept goes back to 1736, when the mathematician Leonhard Euler tackled the famous KΓΆnigsberg Bridge Problem: could you walk through the city of KΓΆnigsberg crossing each of its seven bridges exactly once? Euler proved it was impossible β€” and in doing so, invented graph theory. He modeled the landmasses as vertices and the bridges as edges. That's the first graph ever drawn, and it changed mathematics forever.

Here's an analogy that clicks for most people. Think of a city's road map. Intersections are vertices. Roads connecting them are edges. One-way streets are directed edges (they only go one way). Two-way streets are undirected edges. The distance between intersections is the edge weight. GPS navigation is basically graph traversal β€” finding the shortest weighted path from point A to point B.

Types of Graphs

Where Graphs Show Up

Graphs are everywhere once you start looking. Seriously β€” any time you model relationships between things, you're building a graph:

How to Store a Graph

Two main approaches, each with different trade-offs:

Adjacency List: For each vertex, keep a list of its neighbors. If the graph has V vertices and E edges, this uses O(V + E) space. Best for sparse graphs (few edges relative to vertices) β€” which is most real-world graphs. This is what you'll use 90% of the time in interviews and production code. In Python, it's usually a defaultdict(list). In JavaScript, a Map of arrays.

Adjacency Matrix: A VΓ—V grid where matrix[i][j] = 1 (or the weight) if there's an edge from vertex i to vertex j. Uses O(VΒ²) space regardless of how many edges exist. The big advantage: checking "is there an edge between A and B?" is O(1) β€” just index into the matrix. The big disadvantage: wastes tons of space when the graph is sparse. A social network with 1 billion users but an average of 300 friends would need a billion Γ— billion matrix. That's... not going to fit in memory.

Edge List: A third option you'll occasionally see β€” just store a list of all edges as (u, v) pairs. Uses O(E) space. Simple, but checking "does edge (u,v) exist?" requires scanning the whole list. It's mainly used as input format or when you need to process edges in order (like Kruskal's algorithm).

πŸ’‘ Quick Rule of Thumb If the graph is sparse (E is much less than VΒ²), use an adjacency list. If the graph is dense (E is close to VΒ²) or you need fast edge-existence checks, use an adjacency matrix. For most interview problems, adjacency list wins.

How It Works

Graph traversal means visiting every reachable vertex starting from some source vertex. There are two fundamental traversal strategies, and they're behind nearly every graph algorithm you'll ever write.

BFS β€” Breadth-First Search

Start at a source node. Visit all its immediate neighbors first. Then visit all of their unvisited neighbors. Then all of those neighbors' unvisited neighbors. You expand outward in layers, like ripples spreading from a stone dropped in a pond.

How it works step-by-step:

  1. Add the source vertex to a queue (a First-In-First-Out data structure β€” elements come out in the order they were added). Mark it as visited.
  2. While the queue isn't empty: dequeue the front vertex. For each of its unvisited neighbors, mark them visited and enqueue them.
  3. When the queue empties, you've visited everything reachable from the source.

BFS discovers vertices in order of their distance from the source. All vertices at distance 1 are processed before any vertex at distance 2, and so on. This is why BFS finds shortest paths in unweighted graphs β€” it's guaranteed to reach a vertex via the fewest possible edges first.

Time: O(V + E). You visit every vertex once and examine every edge once.

Space: O(V) for the visited set and the queue (worst case, all vertices end up in the queue).

DFS β€” Depth-First Search

Start at a source node. Pick one neighbor and go as deep as possible down that path before backtracking. It's like exploring a maze by always turning left until you hit a dead end, then backing up and trying the next option.

How it works step-by-step:

  1. Push the source vertex onto a stack (a Last-In-First-Out structure β€” the most recently added element comes out first). Or, equivalently, use recursion (the call stack is your implicit stack).
  2. Pop the top vertex. If it hasn't been visited, mark it visited. Push all its unvisited neighbors onto the stack.
  3. Repeat until the stack is empty.

DFS plunges deep into the graph before exploring breadth. This makes it ideal for problems where you need to explore entire paths β€” cycle detection, topological sorting, finding strongly connected components, and maze generation.

Time: O(V + E). Same as BFS β€” each vertex and edge examined once.

Space: O(V) worst case for the call stack (imagine a graph that's just a long chain β€” DFS goes all the way to the end before backtracking).

BFS vs. DFS β€” When to Use Which

Edge Cases to Watch For

Interactive Visualization

Click the canvas to add nodes. Click two nodes in sequence to create an edge between them. Then run BFS or DFS to watch the traversal animate through the graph.

Graph Explorer

Operations & Complexity

Operation Adjacency List Adjacency Matrix
Add Vertex O(1) O(VΒ²) β€” resize matrix
Add Edge O(1) O(1)
Remove Vertex O(V + E) O(VΒ²)
Remove Edge O(E) worst O(1)
Check Edge Exists O(degree) O(1)
Get All Neighbors O(degree) O(V)
BFS / DFS O(V + E) O(VΒ²)
Space O(V + E) O(VΒ²)

For sparse graphs (E β‰ͺ VΒ²), adjacency lists win on space and traversal time. For dense graphs (E β‰ˆ VΒ²), the matrix's O(1) edge lookup can be worth the space cost. The degree of a vertex is the number of edges connected to it β€” in an adjacency list, checking if an edge exists requires scanning that vertex's neighbor list, which takes O(degree) time.

Implementation

Python β€” Adjacency List + BFS/DFS

Python
from collections import defaultdict, deque

class Graph:
    def __init__(self, directed=False):
        # defaultdict(list) creates an empty list for any new key automatically
        # This avoids KeyError when accessing a vertex with no edges yet
        self.adj = defaultdict(list)
        self.directed = directed

    def add_edge(self, u, v, weight=1):
        """Add an edge from vertex u to vertex v with optional weight."""
        self.adj[u].append((v, weight))
        if not self.directed:
            # Undirected: add the reverse edge too
            self.adj[v].append((u, weight))

    def add_vertex(self, v):
        """Ensure vertex v exists even if it has no edges."""
        if v not in self.adj:
            self.adj[v] = []

    def bfs(self, start):
        """Traverse the graph in BFS order from start.
        Returns list of vertices in the order they were discovered.
        BFS visits vertices layer by layer β€” all vertices at distance d
        before any vertex at distance d+1."""
        visited = {start}         # Set of vertices we've already seen
        queue = deque([start])    # deque is O(1) for both append and popleft
        order = []                # The final traversal order

        while queue:
            node = queue.popleft()   # Take the OLDEST vertex (FIFO)
            order.append(node)

            for neighbor, _ in self.adj[node]:
                if neighbor not in visited:
                    visited.add(neighbor)    # Mark BEFORE enqueuing
                    queue.append(neighbor)   # to prevent duplicates in queue
        return order

    def dfs(self, start):
        """Traverse the graph in DFS order from start (iterative).
        Returns list of vertices in the order they were discovered.
        DFS plunges deep before exploring breadth."""
        visited = set()
        stack = [start]
        order = []

        while stack:
            node = stack.pop()      # Take the NEWEST vertex (LIFO)
            if node in visited:
                continue            # Skip if already processed
            visited.add(node)
            order.append(node)

            # Push neighbors in reverse to visit in natural order
            # (stack reverses the order, so we reverse first)
            for neighbor, _ in reversed(self.adj[node]):
                if neighbor not in visited:
                    stack.append(neighbor)
        return order

    def has_cycle_directed(self):
        """Detect cycles in a DIRECTED graph using DFS 3-color method.
        WHITE (0) = unvisited, GRAY (1) = in current DFS path,
        BLACK (2) = fully processed. A back edge (hitting GRAY) means cycle."""
        WHITE, GRAY, BLACK = 0, 1, 2
        color = defaultdict(int)  # default WHITE

        def _dfs(node):
            color[node] = GRAY    # Entering this node's DFS subtree
            for neighbor, _ in self.adj[node]:
                if color[neighbor] == GRAY:
                    return True   # Back edge! Cycle found.
                if color[neighbor] == WHITE and _dfs(neighbor):
                    return True
            color[node] = BLACK   # Done with this node entirely
            return False

        # Must check all vertices (graph may be disconnected)
        return any(
            _dfs(v) for v in self.adj if color[v] == WHITE
        )

    def has_cycle_undirected(self):
        """Detect cycles in an UNDIRECTED graph using DFS with parent tracking.
        If we reach an already-visited neighbor that isn't our parent,
        that's a cycle."""
        visited = set()

        def _dfs(node, parent):
            visited.add(node)
            for neighbor, _ in self.adj[node]:
                if neighbor not in visited:
                    if _dfs(neighbor, node):
                        return True
                elif neighbor != parent:
                    return True  # Visited neighbor that isn't parent = cycle
            return False

        for v in self.adj:
            if v not in visited:
                if _dfs(v, -1):
                    return True
        return False

    def shortest_path_bfs(self, start, end):
        """Find shortest path in an UNWEIGHTED graph using BFS.
        Returns the path as a list of vertices, or empty list if no path."""
        if start == end:
            return [start]

        visited = {start}
        queue = deque([(start, [start])])  # (vertex, path_so_far)

        while queue:
            node, path = queue.popleft()
            for neighbor, _ in self.adj[node]:
                if neighbor not in visited:
                    new_path = path + [neighbor]
                    if neighbor == end:
                        return new_path
                    visited.add(neighbor)
                    queue.append((neighbor, new_path))
        return []  # No path exists


# Usage examples
g = Graph(directed=False)
g.add_edge(0, 1)
g.add_edge(0, 2)
g.add_edge(1, 3)
g.add_edge(2, 3)
g.add_edge(3, 4)

print("BFS from 0:", g.bfs(0))          # [0, 1, 2, 3, 4]
print("DFS from 0:", g.dfs(0))          # [0, 1, 3, 2, 4] or similar
print("Shortest 0β†’4:", g.shortest_path_bfs(0, 4))  # [0, 1, 3, 4]
print("Has cycle:", g.has_cycle_undirected())       # True (0-1-3-2-0)

Python β€” Topological Sort (Kahn's Algorithm)

Python
from collections import deque, defaultdict

def topological_sort(num_vertices, edges):
    """Kahn's algorithm for topological sorting.
    
    Topological sort: order vertices so that for every directed edge u→v,
    u comes before v. Only possible on DAGs (no cycles).
    
    Args:
        num_vertices: number of vertices (labeled 0 to n-1)
        edges: list of (u, v) pairs meaning u must come before v
    
    Returns:
        List of vertices in topological order, or empty list if cycle exists.
    """
    # Build adjacency list and count incoming edges for each vertex
    adj = defaultdict(list)
    in_degree = [0] * num_vertices  # How many edges point INTO each vertex

    for u, v in edges:
        adj[u].append(v)
        in_degree[v] += 1

    # Start with vertices that have no prerequisites (in-degree 0)
    queue = deque()
    for v in range(num_vertices):
        if in_degree[v] == 0:
            queue.append(v)

    order = []
    while queue:
        node = queue.popleft()
        order.append(node)

        # "Remove" this node by decrementing in-degree of its neighbors
        for neighbor in adj[node]:
            in_degree[neighbor] -= 1
            if in_degree[neighbor] == 0:
                queue.append(neighbor)  # All prerequisites satisfied

    # If we processed all vertices, the sort succeeded
    # If not, a cycle prevented some vertices from reaching in-degree 0
    if len(order) != num_vertices:
        return []   # Cycle detected!
    return order


# Example: course prerequisites
# Course 1 requires Course 0, Course 2 requires Course 1, etc.
edges = [(0, 1), (1, 2), (0, 3), (3, 2)]
print(topological_sort(4, edges))  # [0, 1, 3, 2] or [0, 3, 1, 2]

JavaScript β€” Adjacency List + BFS/DFS

JavaScript
class Graph {
  constructor(directed = false) {
    this.adj = new Map();  // vertex β†’ array of {node, weight}
    this.directed = directed;
  }

  addVertex(v) {
    if (!this.adj.has(v)) this.adj.set(v, []);
  }

  addEdge(u, v, weight = 1) {
    this.addVertex(u);
    this.addVertex(v);
    this.adj.get(u).push({ node: v, weight });
    if (!this.directed) {
      this.adj.get(v).push({ node: u, weight });
    }
  }

  bfs(start) {
    const visited = new Set([start]);
    const queue = [start];     // Use array as queue (shift is O(n) but fine for interviews)
    const order = [];
    let front = 0;             // Pointer optimization: avoid shift()

    while (front < queue.length) {
      const node = queue[front++];  // Dequeue from front
      order.push(node);

      for (const { node: neighbor } of this.adj.get(node) || []) {
        if (!visited.has(neighbor)) {
          visited.add(neighbor);
          queue.push(neighbor);
        }
      }
    }
    return order;
  }

  dfs(start) {
    const visited = new Set();
    const stack = [start];
    const order = [];

    while (stack.length) {
      const node = stack.pop();
      if (visited.has(node)) continue;
      visited.add(node);
      order.push(node);

      // Push in reverse for natural ordering
      const neighbors = this.adj.get(node) || [];
      for (let i = neighbors.length - 1; i >= 0; i--) {
        if (!visited.has(neighbors[i].node)) {
          stack.push(neighbors[i].node);
        }
      }
    }
    return order;
  }

  // Count connected components in an undirected graph
  connectedComponents() {
    const visited = new Set();
    let count = 0;

    for (const vertex of this.adj.keys()) {
      if (!visited.has(vertex)) {
        count++;
        // BFS/DFS to mark all vertices in this component
        const stack = [vertex];
        while (stack.length) {
          const node = stack.pop();
          if (visited.has(node)) continue;
          visited.add(node);
          for (const { node: neighbor } of this.adj.get(node) || []) {
            if (!visited.has(neighbor)) stack.push(neighbor);
          }
        }
      }
    }
    return count;
  }
}

Common Mistakes

⚠️ Beginner Pitfalls with Graphs
  • Forgetting to mark visited BEFORE enqueuing (BFS): If you mark vertices as visited when you dequeue them (instead of when you enqueue them), you'll add the same vertex to the queue multiple times from different neighbors. This wastes time and can cause incorrect results. Always mark visited at the moment you add to the queue.
  • Forgetting disconnected components: Running BFS/DFS from a single source only visits one connected component. If the graph is disconnected, you need an outer loop over all vertices to ensure full coverage. The "Number of Islands" problem is basically this mistake turned into a question.
  • Using DFS for shortest path in unweighted graphs: DFS finds a path, not the shortest. BFS always finds the shortest path (fewest edges) because it explores level by level. This is the single most common wrong approach in graph problems.
  • Confusing directed vs. undirected cycle detection: In undirected graphs, you need parent tracking to avoid false positives (the edge you just came from isn't a cycle). In directed graphs, you need the 3-color method (white/gray/black) β€” a simple visited set doesn't work because a vertex can be visited without being on the current path.
  • Not handling edge weights in Dijkstra correctly: If you use BFS for a weighted graph, you'll get wrong answers. Dijkstra requires a priority queue (min-heap), not a regular queue. And Dijkstra doesn't work with negative weights β€” use Bellman-Ford for that.

Real-World Example: Social Network Friend Suggestions

Facebook's "People You May Know" feature is graph traversal in action. The idea: suggest friends-of-friends, ranked by how many mutual friends you share. Here's a simplified version:

Python
from collections import defaultdict, Counter

class SocialNetwork:
    """Simplified friend suggestion engine using graph traversal."""

    def __init__(self):
        self.friends = defaultdict(set)  # user β†’ set of friends

    def add_friendship(self, user_a, user_b):
        """Friendships are bidirectional (undirected edge)."""
        self.friends[user_a].add(user_b)
        self.friends[user_b].add(user_a)

    def suggest_friends(self, user, max_suggestions=5):
        """Suggest people the user might know, ranked by mutual friends.

        Algorithm:
        1. Get all friends-of-friends (distance 2 in the graph)
        2. Exclude people who are already friends
        3. Rank by number of mutual connections (higher = stronger signal)
        """
        direct_friends = self.friends[user]
        mutual_count = Counter()  # candidate β†’ how many mutual friends

        for friend in direct_friends:
            for fof in self.friends[friend]:  # friends-of-friends
                if fof != user and fof not in direct_friends:
                    mutual_count[fof] += 1

        # Sort by mutual friend count (descending), then by name for ties
        ranked = sorted(mutual_count.items(),
                       key=lambda x: (-x[1], x[0]))

        return [(person, count) for person, count in ranked[:max_suggestions]]

    def degrees_of_separation(self, user_a, user_b):
        """BFS to find shortest path length between two users.
        Returns -1 if they're not connected at all."""
        if user_a == user_b:
            return 0

        visited = {user_a}
        queue = [(user_a, 0)]
        front = 0

        while front < len(queue):
            node, dist = queue[front]
            front += 1

            for neighbor in self.friends[node]:
                if neighbor == user_b:
                    return dist + 1
                if neighbor not in visited:
                    visited.add(neighbor)
                    queue.append((neighbor, dist + 1))

        return -1  # Not connected


# Demo
network = SocialNetwork()
network.add_friendship("Alice", "Bob")
network.add_friendship("Alice", "Charlie")
network.add_friendship("Bob", "Charlie")
network.add_friendship("Bob", "David")
network.add_friendship("Charlie", "David")
network.add_friendship("Charlie", "Eve")
network.add_friendship("David", "Frank")

suggestions = network.suggest_friends("Alice")
print("Friend suggestions for Alice:")
for person, mutual in suggestions:
    print(f"  {person} ({mutual} mutual friends)")
# Output:
#   David (2 mutual friends)  β€” connected through Bob AND Charlie
#   Eve (1 mutual friends)    β€” connected through Charlie
#   Frank (0 mutual friends)  β€” wait, actually 0? No, through David...

print(f"\nDegrees of separation (Alice→Frank): "
      f"{network.degrees_of_separation('Alice', 'Frank')}")  # 3

The real Facebook system is vastly more complex β€” it considers profile similarity, location, workplace, events attended, and dozens of other signals. But at its core, it's a graph traversal ranked by edge density.

Interview Patterns

πŸ’‘ Pattern: Grid as a Graph Many interview problems give you a 2D grid (matrix) and ask you to find islands, shortest paths, or connected regions. The trick: treat each cell as a node with up to 4 edges (up/down/left/right). Then it's just BFS or DFS on an implicit graph β€” you don't build an adjacency list, you just check bounds and visited status inline. The standard 4-direction array is dirs = [(0,1),(0,-1),(1,0),(-1,0)].
πŸ’‘ Pattern: Topological Sort When you have dependencies ("course A requires course B"), model it as a DAG and topologically sort. Two approaches: DFS with post-order reversal, or Kahn's algorithm with in-degree (the number of edges pointing into a vertex) tracking and a queue. Kahn's is easier to code under pressure and naturally detects cycles (if the output has fewer vertices than the input, there's a cycle).
πŸ’‘ Pattern: Union-Find vs. BFS/DFS For problems about connected components β€” "are these two nodes connected?" or "how many groups?" β€” Union-Find (disjoint set) can be faster and cleaner than BFS/DFS, especially when the input is an edge list rather than an adjacency structure. BFS/DFS shine when you need traversal order or path reconstruction.
πŸ’‘ Shortest Path Cheat Sheet Unweighted β†’ BFS. Non-negative weights β†’ Dijkstra (min-heap, O((V+E) log V)). Negative weights (no negative cycles) β†’ Bellman-Ford (O(VE)). All pairs β†’ Floyd-Warshall (O(VΒ³)). Know when to reach for each one and why the others don't work for that case.
πŸ’‘ Pattern: Multi-Source BFS Sometimes you start from multiple sources simultaneously β€” "what's the distance from every cell to the nearest zero?" or "how fast does the infection spread from all initial infected nodes?" Push ALL sources into the queue at the start, then BFS as normal. This is equivalent to adding a virtual super-source connected to all real sources.
πŸ’‘ Pattern: Bipartite Check (Graph Coloring) "Can you split the nodes into two groups with no edges within a group?" Run BFS. Color the start node red. Color all its neighbors blue. Color all their neighbors red. If you ever need to color a node that's already the wrong color, the graph isn't bipartite. This pattern appears in problems like "Is Graph Bipartite?" (LC 785) and "Possible Bipartition" (LC 886).
πŸ’‘ Pattern: Build the Graph First Many problems don't hand you a graph directly β€” they give you relationships disguised as arrays, strings, or pairs. Step one is always: figure out what the vertices and edges are, then build the adjacency list explicitly. "Word Ladder" (LC 127) is a great example: words are vertices, and an edge exists between two words that differ by exactly one character.

Practice Problems

Graph problems show up everywhere. About 25% of LeetCode mediums and hards involve graph traversal in some form. Start with BFS/DFS on grids, then move to explicit graphs and shortest paths.

Problem Difficulty Key Technique
LC 733 β€” Flood Fill Easy BFS/DFS on grid β€” your first graph problem
LC 200 β€” Number of Islands Medium DFS/BFS connected components on a grid
LC 133 β€” Clone Graph Medium BFS/DFS + hashmap for deep copying
LC 207 β€” Course Schedule Medium Cycle detection / topological sort
LC 417 β€” Pacific Atlantic Water Flow Medium Multi-source BFS from ocean edges inward
LC 261 β€” Graph Valid Tree Medium Union-Find or DFS cycle check (V-1 edges, connected, no cycles)
LC 127 β€” Word Ladder Hard BFS on implicit word graph β€” build-the-graph pattern
LC 269 β€” Alien Dictionary Hard Build graph from comparison pairs + topological sort