Command Palette

Search for a command to run...

MTH 4300

Lecture 22

Weighted Graphs

Lecture 20 used an adjacency list where each entry was just a neighbor index. Weighted graphs attach a numeric cost to each edge. The adjacency list now stores {neighbor, weight} pairs.

WeightedGraph — directed, weighted adjacency list
#include <climits>
#include <iostream>
#include <queue>
#include <tuple>
#include <vector>

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

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

    std::vector<int> dijkstra(int src) const;

private:
    std::vector<std::vector<std::pair<int,int>>> adj_; // {neighbor, weight}
};

All three algorithms in this lecture find shortest paths in weighted directed graphs. They differ in what they can handle and how fast they run.

Relaxation

Relaxation is the operation every shortest-path algorithm is built on. Given a known tentative distance to vertex u and an edge u → v with weight w, we check whether routing through u improves the known distance to v:

Relax edge u → v with weight w
if (dist[u] + w < dist[v])
    dist[v] = dist[u] + w;

Dijkstra's applies relaxation greedily in order of current distance. Bellman-Ford applies it exhaustively across all edges, repeatedly.

Dijkstra's Algorithm

Dijkstra's finds the shortest path from a single source to all other vertices in a graph with non-negative edge weights. It is the graph analogue of BFS — where BFS explores by hop count, Dijkstra's explores by accumulated cost.

The algorithm uses a min-heap priority queue keyed on current distance. When a vertex is popped, its distance is finalized. Any later entry for the same vertex in the queue is stale and skipped.

Dijkstra's
std::vector<int> WeightedGraph::dijkstra(int src) const {
    const int INF = 1e9;
    int n = adj_.size();
    std::vector<int> dist(n, INF);

    // min-heap: {dist, vertex}
    std::priority_queue<std::pair<int,int>,
                        std::vector<std::pair<int,int>>,
                        std::greater<>> pq;
    dist[src] = 0;
    pq.push({0, src});

    while (!pq.empty()) {
        auto [d, v] = pq.top();
        pq.pop();
        if (d > dist[v]) continue;  // stale entry — already found a shorter path

        for (auto [neighbor, w] : adj_[v]) {
            if (dist[v] + w < dist[neighbor]) {
                dist[neighbor] = dist[v] + w;
                pq.push({dist[neighbor], neighbor});
            }
        }
    }
    return dist;
}

Dijkstra's trace on a 5-vertex graph:

Dijkstra's trace — source=0
// Initial: dist=[0,∞,∞,∞,∞], pq={(0,0)}
//
// Pop (0,0): relax 1 → dist[1]=4; relax 2 → dist[2]=1
//   dist=[0,4,1,∞,∞], pq={(1,2),(4,1)}
//
// Pop (1,2): d==dist[2]; relax 1 → 1+2=3 < 4, dist[1]=3; relax 3 → dist[3]=6
//   dist=[0,3,1,6,∞], pq={(3,1),(4,1),(6,3)}
//
// Pop (3,1): d==dist[1]; relax 3 → 3+1=4 < 6, dist[3]=4
//   dist=[0,3,1,4,∞], pq={(4,1),(4,3),(6,3)}
//
// Pop (4,1): d=4 > dist[1]=3 → skip (stale)
//
// Pop (4,3): d==dist[3]; relax 4 → dist[4]=7
//   dist=[0,3,1,4,7], pq={(6,3),(7,4)}
//
// Pop (6,3): d=6 > dist[3]=4 → skip (stale)
// Pop (7,4): d==dist[4]; no outgoing edges
//
// Result: 0 3 1 4 7

The shortest path to vertex 1 is not the direct edge (cost 4) but the path 0 → 2 → 1 (cost 3). Dijkstra's finds this because vertex 2 is finalized first — its distance is smaller — and relaxation through 2 improves vertex 1 before vertex 1 is popped from the queue.

Dijkstra's is incorrect on graphs with negative edge weights. A finalized vertex might later be reachable via a negative edge at a lower cost, but the algorithm never revisits finalized vertices. Use Bellman-Ford when negative weights are present.

Bellman-Ford

Bellman-Ford finds single-source shortest paths and handles negative edge weights. It also detects negative cycles — cycles whose total weight is negative, which would make the shortest path unbounded.

The algorithm works by relaxing every edge V−1 times. A shortest path can contain at most V−1 edges (visiting each vertex at most once), so V−1 passes guarantee all shortest paths are found. A Vth pass that still improves a distance reveals a negative cycle.

Bellman-Ford — edge list: {u, v, weight}
std::vector<int> bellman_ford(int src, int n,
    const std::vector<std::tuple<int,int,int>>& edges) {
    const int INF = 1e9;
    std::vector<int> dist(n, INF);
    dist[src] = 0;

    for (int i = 0; i < n - 1; ++i) {
        for (auto [u, v, w] : edges) {
            if (dist[u] < INF && dist[u] + w < dist[v])
                dist[v] = dist[u] + w;
        }
    }

    // one more pass — any improvement means a negative cycle exists
    for (auto [u, v, w] : edges) {
        if (dist[u] < INF && dist[u] + w < dist[v])
            return {};
    }

    return dist;
}

Bellman-Ford trace on a 4-vertex graph with a negative edge:

Bellman-Ford trace — source=0, edges in order: 0→1, 0→2, 1→3, 2→1, 2→3
// Initial: dist=[0,∞,∞,∞]
//
// Iteration 1:
//   0→1(6):  dist[1] = min(∞, 0+6)  = 6
//   0→2(7):  dist[2] = min(∞, 0+7)  = 7
//   1→3(5):  dist[3] = min(∞, 6+5)  = 11
//   2→1(-3): dist[1] = min(6, 7-3)  = 4   ← negative edge improves path to 1
//   2→3(9):  dist[3] = min(11, 7+9) = 11  (16 > 11, no change)
//   dist=[0,4,7,11]
//
// Iteration 2:
//   0→1(6):  dist[1] = min(4, 6)    = 4   (no change)
//   0→2(7):  dist[2] = min(7, 7)    = 7   (no change)
//   1→3(5):  dist[3] = min(11, 4+5) = 9   ← improved using updated dist[1]
//   2→1(-3): dist[1] = min(4, 7-3)  = 4   (no change)
//   2→3(9):  dist[3] = min(9, 16)   = 9   (no change)
//   dist=[0,4,7,9]
//
// Iteration 3: no changes
// Result: 0 4 7 9

Notice that the best path to vertex 1 goes through vertex 2 (0 → 2 → 1, cost 4), exploiting the negative edge. Dijkstra's would finalize vertex 1 at cost 6 before discovering this cheaper route.

Floyd-Warshall

Floyd-Warshall solves the all-pairs shortest path problem: the shortest distance between every pair of vertices simultaneously. It works by progressively allowing more intermediate vertices into candidate paths.

For each intermediate vertex k, it checks whether routing through k improves any path i → j:

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

The crucial detail is that k must be the outermost loop. Only when k is fixed do the inner loops correctly query paths that are allowed to use only vertices 0..k as intermediates.

Floyd-Warshall — in-place 2D distance matrix
// Initialize: dist[i][i]=0, dist[i][j]=edge weight or INF if no direct edge
void floyd_warshall(std::vector<std::vector<int>>& dist) {
    const int INF = 1e9;
    int n = dist.size();
    for (int k = 0; k < n; ++k)
        for (int i = 0; i < n; ++i)
            for (int j = 0; j < n; ++j)
                if (dist[i][k] < INF && dist[k][j] < INF)
                    dist[i][j] = std::min(dist[i][j], dist[i][k] + dist[k][j]);
}
Floyd-Warshall example — 4 vertices
const int INF = 1e9;

// Initial distance matrix (direct edges only)
//      0    1    2    3
// 0 [  0,   3,  INF,  7 ]
// 1 [  8,   0,   2, INF ]
// 2 [  5, INF,   0,   1 ]
// 3 [  2, INF, INF,   0 ]

// After Floyd-Warshall (all-pairs shortest paths)
//      0    1    2    3
// 0 [  0,   3,   5,   6 ]
// 1 [  7,   0,   2,   3 ]
// 2 [  5,   8,   0,   1 ]
// 3 [  2,   5,   7,   0 ]

Comparison

AlgorithmTimeSpaceNegative weightsNegative cycle detectionUse case
Dijkstra'sO((V+E) log V)O(V)NoNoSingle-source, dense maps
Bellman-FordO(VE)O(V)YesYesSingle-source, general
Floyd-WarshallO(V³)O(V²)YesYes (negative diagonal)All-pairs, small graphs

Exercises

Q1.

Without running the code, predict what this program prints.

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

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

    auto dist = g.dijkstra(0);
    for (int d : dist) std::cout << d << " ";
    std::cout << "\n";
    return 0;
}

Q2.

Without running the code, predict what this program prints.

#include <iostream>
#include <tuple>
#include <vector>

int main() {
    std::vector<std::tuple<int,int,int>> edges = {
        {0, 1, 6}, {0, 2, 7}, {1, 3, 5}, {2, 1, -3}, {2, 3, 9}
    };

    auto dist = bellman_ford(0, 4, edges);
    for (int d : dist) std::cout << d << " ";
    std::cout << "\n";
    return 0;
}

Q3.

Each snippet has a bug. Identify what's wrong.

// Snippet A — Dijkstra's missing the stale entry check
while (!pq.empty()) {
    auto [d, v] = pq.top(); pq.pop();
    // no stale check
    for (auto [neighbor, w] : adj_[v]) {
        if (dist[v] + w < dist[neighbor]) {
            dist[neighbor] = dist[v] + w;
            pq.push({dist[neighbor], neighbor});
        }
    }
}

// Snippet B — Dijkstra's uses a max-heap instead of a min-heap
std::priority_queue<std::pair<int,int>> pq; // default: max-heap

// Snippet C — Bellman-Ford runs n-2 iterations instead of n-1
for (int i = 0; i < n - 2; ++i) {
    for (auto [u, v, w] : edges) {
        if (dist[u] < INF && dist[u] + w < dist[v])
            dist[v] = dist[u] + w;
    }
}

// Snippet D — Floyd-Warshall has k in the innermost loop
for (int i = 0; i < n; ++i)
    for (int j = 0; j < n; ++j)
        for (int k = 0; k < n; ++k)   // k must be outermost
            if (dist[i][k] < INF && dist[k][j] < INF)
                dist[i][j] = std::min(dist[i][j], dist[i][k] + dist[k][j]);

Q4.

Implement dijkstra for the weighted graph below.

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

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

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

    std::vector<int> dijkstra(int src) const {
        // TODO:
        // 1. Initialize dist to INF=1e9, dist[src]=0
        // 2. Push {0, src} onto a min-heap priority queue
        // 3. While queue non-empty: pop {d, v}; skip if d > dist[v] (stale);
        //    relax each neighbor; push improved distances
        // Return dist
    }

private:
    std::vector<std::vector<std::pair<int,int>>> adj_; // {neighbor, weight}
};

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

    auto dist = g.dijkstra(0);
    for (int d : dist) std::cout << d << " ";
    std::cout << "\n";
    return 0;
}

Q5.

Implement bellman_ford for the edge list below.

#include <iostream>
#include <tuple>
#include <vector>

// Returns shortest distances from src, or empty vector if a negative cycle exists.
std::vector<int> bellman_ford(int src, int n,
    const std::vector<std::tuple<int,int,int>>& edges) {
    // TODO:
    // 1. Initialize dist to INF=1e9, dist[src]=0
    // 2. Run n-1 iterations: relax every edge {u, v, w} if dist[u] < INF
    // 3. One more pass: if any edge still relaxes, return {} (negative cycle)
    // Return dist
}

int main() {
    // edges: {u, v, weight}
    std::vector<std::tuple<int,int,int>> edges = {
        {0, 1, 4}, {0, 2, 5}, {1, 3, 3}, {2, 1, -6}, {3, 2, 2}
    };

    auto dist = bellman_ford(0, 4, edges);
    if (dist.empty()) {
        std::cout << "negative cycle\n";
    } else {
        for (int d : dist) std::cout << d << " ";
        std::cout << "\n";
    }
    return 0;
}

Practice Problems