Command Palette

Search for a command to run...

MTH 4300

Lecture 20

What Is a Graph?

A graph is a set of vertices (also called nodes) connected by edges. Unlike a tree, a graph has no root, no parent-child hierarchy, and may contain cycles — paths that loop back to a vertex already visited.

Two fundamental distinctions:

  • Undirected — edges have no direction; an edge between A and B means you can travel in either direction. Directed (digraph) — each edge has a specific direction, written A → B.
  • Unweighted — edges carry no cost. Weighted — each edge carries a numeric weight (distance, latency, cost).

Lecture 21 will use directed graphs for topological sort. Here we focus on undirected, unweighted graphs and the two fundamental traversal algorithms: BFS and DFS.

Representing a Graph

Two standard representations exist:

RepresentationStorageAdd edgeCheck edgeBest when
Adjacency listO(V + E)O(1)O(degree)sparse graphs (few edges)
Adjacency matrixO(V²)O(1)O(1)dense graphs (many edges)

An adjacency list stores, for each vertex, the list of vertices it connects to. An adjacency matrix stores a V×V grid where matrix[u][v] == 1 means an edge exists between u and v.

Adjacency list — vector of vectors
// 5 vertices, edges: 0-1, 0-2, 1-3, 2-3, 3-4
std::vector<std::vector<int>> adj(5);
adj[0] = {1, 2};
adj[1] = {0, 3};
adj[2] = {0, 3};
adj[3] = {1, 2, 4};
adj[4] = {3};
Adjacency matrix — same graph
int matrix[5][5] = {};
matrix[0][1] = matrix[1][0] = 1;  // 0-1
matrix[0][2] = matrix[2][0] = 1;  // 0-2
matrix[1][3] = matrix[3][1] = 1;  // 1-3
matrix[2][3] = matrix[3][2] = 1;  // 2-3
matrix[3][4] = matrix[4][3] = 1;  // 3-4

Most graph algorithms use an adjacency list: traversing all neighbors of vertex u takes O(degree(u)) instead of O(V).

Graph Class

Graph — adjacency list, undirected
#include <iostream>
#include <queue>
#include <vector>

class Graph {
public:
    Graph(int vertices) : adj_(vertices) {}

    void add_edge(int u, int v) {
        adj_[u].push_back(v);
        adj_[v].push_back(u);
    }

    void bfs(int start);
    void dfs(int start);

private:
    std::vector<std::vector<int>> adj_;

    void dfs_helper(int v, std::vector<bool>& visited);
};

Each vertex is an integer in [0, vertices). add_edge appends to both neighbor lists because the graph is undirected.

BFS visits vertices layer by layer starting from a source vertex. It is the graph equivalent of level-order traversal on a tree: all vertices at distance 1 are visited before any vertex at distance 2.

The algorithm uses a queue and a visited array to prevent revisiting vertices — trees never revisit, but graphs can have cycles:

  1. Mark the source visited and enqueue it.
  2. While the queue is non-empty: dequeue a vertex, print it, enqueue all unvisited neighbors and mark them visited.
BFS
void Graph::bfs(int start) {
    std::vector<bool> visited(adj_.size(), false);
    std::queue<int> q;

    visited[start] = true;
    q.push(start);

    while (!q.empty()) {
        int v = q.front();
        q.pop();
        std::cout << v << " ";

        for (int neighbor : adj_[v]) {
            if (!visited[neighbor]) {
                visited[neighbor] = true;
                q.push(neighbor);
            }
        }
    }
    std::cout << "\n";
}

BFS on the example graph starting at 0:

BFS trace — start=0, adj: 0:{1,2}, 1:{0,3}, 2:{0,3}, 3:{1,2,4}, 4:{3}
// Start: visited[0]=true, queue=[0]
// Dequeue 0, print 0 → enqueue 1,2; visited={0,1,2}; queue=[1,2]
// Dequeue 1, print 1 → 0 visited; enqueue 3; visited={0,1,2,3}; queue=[2,3]
// Dequeue 2, print 2 → 0,3 both visited; queue=[3]
// Dequeue 3, print 3 → 1,2 visited; enqueue 4; visited={0,1,2,3,4}; queue=[4]
// Dequeue 4, print 4 → 3 visited; queue empty
// Output: 0 1 2 3 4

Marking a vertex visited when it is enqueued (not when it is dequeued) is critical. If you wait until dequeue, the same vertex can be enqueued multiple times through different neighbors, producing duplicate visits.

DFS visits as deep as possible before backtracking — the graph equivalent of preorder traversal on a tree. It uses recursion (the implicit call stack) and the same visited array to avoid cycles.

DFS
void Graph::dfs(int start) {
    std::vector<bool> visited(adj_.size(), false);
    dfs_helper(start, visited);
    std::cout << "\n";
}

void Graph::dfs_helper(int v, std::vector<bool>& visited) {
    visited[v] = true;
    std::cout << v << " ";

    for (int neighbor : adj_[v]) {
        if (!visited[neighbor]) {
            dfs_helper(neighbor, visited);
        }
    }
}

DFS on the same graph starting at 0:

DFS trace — start=0, adj: 0:{1,2}, 1:{0,3}, 2:{0,3}, 3:{1,2,4}, 4:{3}
// dfs_helper(0): visited[0]=true, print 0; neighbors: 1, 2
//   dfs_helper(1): visited[1]=true, print 1; neighbors: 0(visited), 3
//     dfs_helper(3): visited[3]=true, print 3; neighbors: 1(visited), 2, 4
//       dfs_helper(2): visited[2]=true, print 2; neighbors: 0(visited), 3(visited)
//       dfs_helper(4): visited[4]=true, print 4; neighbors: 3(visited)
//   2 already visited, skip
// Output: 0 1 3 2 4

Without the visited array, DFS on a cycle never terminates. A tree has no cycles so tree traversals never revisit a node — graph DFS always needs this guard.

BFS vs DFS

PropertyBFSDFS
Data structureQueueCall stack (recursion)
OrderLayer by layerDeep first, then backtrack
Shortest path (unweighted)Yes — first visit is shortestNo — may not find shortest path
MemoryO(V) queue at peakO(V) call stack at peak
Typical usesShortest path, connected componentsCycle detection, topological sort

Exercises

Q1.

Without running the code, predict what this program prints.

#include <iostream>
#include <queue>
#include <vector>

class Graph {
public:
    Graph(int v) : adj_(v) {}

    void add_edge(int u, int v) {
        adj_[u].push_back(v);
        adj_[v].push_back(u);
    }

    void bfs(int start) {
        std::vector<bool> visited(adj_.size(), false);
        std::queue<int> q;
        visited[start] = true;
        q.push(start);
        while (!q.empty()) {
            int v = q.front(); q.pop();
            std::cout << v << " ";
            for (int nb : adj_[v]) {
                if (!visited[nb]) { visited[nb] = true; q.push(nb); }
            }
        }
        std::cout << "\n";
    }

private:
    std::vector<std::vector<int>> adj_;
};

int main() {
    Graph g(6);
    g.add_edge(0, 1);
    g.add_edge(0, 3);
    g.add_edge(1, 2);
    g.add_edge(3, 4);
    g.add_edge(4, 5);

    g.bfs(0);  // line A
    g.bfs(3);  // line B
    return 0;
}

Q2.

Without running the code, predict what this program prints.

#include <iostream>
#include <vector>

class Graph {
public:
    Graph(int v) : adj_(v) {}

    void add_edge(int u, int v) {
        adj_[u].push_back(v);
        adj_[v].push_back(u);
    }

    void dfs(int start) {
        std::vector<bool> visited(adj_.size(), false);
        dfs_helper(start, visited);
        std::cout << "\n";
    }

private:
    std::vector<std::vector<int>> adj_;

    void dfs_helper(int v, std::vector<bool>& visited) {
        visited[v] = true;
        std::cout << v << " ";
        for (int nb : adj_[v]) {
            if (!visited[nb]) dfs_helper(nb, visited);
        }
    }
};

int main() {
    Graph g(5);
    g.add_edge(0, 1);
    g.add_edge(0, 4);
    g.add_edge(1, 2);
    g.add_edge(1, 3);
    g.add_edge(2, 3);

    g.dfs(0);  // line A
    g.dfs(2);  // line B
    return 0;
}

Q3.

Each snippet has a bug related to graph traversal. Identify what's wrong.

// Snippet A — visited is marked when dequeued, not when enqueued
void bfs(int start) {
    std::vector<bool> visited(adj_.size(), false);
    std::queue<int> q;
    q.push(start);
    while (!q.empty()) {
        int v = q.front(); q.pop();
        visited[v] = true;              // too late
        std::cout << v << " ";
        for (int nb : adj_[v]) {
            if (!visited[nb]) q.push(nb);
        }
    }
}

// Snippet B — DFS marks visited after recursing, not before
void dfs_helper(int v, std::vector<bool>& visited) {
    std::cout << v << " ";
    for (int nb : adj_[v]) {
        if (!visited[nb]) dfs_helper(nb, visited);
    }
    visited[v] = true;                  // too late
}

// Snippet C — add_edge only records one direction
void add_edge(int u, int v) {
    adj_[u].push_back(v);               // missing: adj_[v].push_back(u)
}

// Snippet D — visited is passed by value instead of by reference
void dfs(int start) {
    std::vector<bool> visited(adj_.size(), false);
    dfs_helper(start, visited);
}

void dfs_helper(int v, std::vector<bool> visited) { // should be &visited
    visited[v] = true;
    std::cout << v << " ";
    for (int nb : adj_[v]) {
        if (!visited[nb]) dfs_helper(nb, visited);
    }
}

Q4.

Implement bfs for the graph below.

#include <iostream>
#include <queue>
#include <vector>

class Graph {
public:
    Graph(int v) : adj_(v) {}

    void add_edge(int u, int v) {
        adj_[u].push_back(v);
        adj_[v].push_back(u);
    }

    void bfs(int start) {
        // TODO: BFS from start; print each vertex as it is dequeued,
        //       separated by spaces, followed by a newline.
    }

private:
    std::vector<std::vector<int>> adj_;
};

int main() {
    Graph g(6);
    g.add_edge(0, 1);
    g.add_edge(0, 2);
    g.add_edge(1, 3);
    g.add_edge(2, 4);
    g.add_edge(3, 5);
    g.add_edge(4, 5);

    g.bfs(0);
    return 0;
}

Q5.

Implement dfs and dfs_helper for the graph below.

#include <iostream>
#include <vector>

class Graph {
public:
    Graph(int v) : adj_(v) {}

    void add_edge(int u, int v) {
        adj_[u].push_back(v);
        adj_[v].push_back(u);
    }

    void dfs(int start) {
        // TODO: allocate visited array and call dfs_helper;
        //       print a newline after the traversal
    }

private:
    std::vector<std::vector<int>> adj_;

    void dfs_helper(int v, std::vector<bool>& visited) {
        // TODO: mark v visited, print v, recurse into unvisited neighbors
    }
};

int main() {
    Graph g(5);
    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);

    g.dfs(0);
    return 0;
}

Practice Problems