Command Palette

Search for a command to run...

MTH 4300

Lecture 21

What Is Topological Sort?

A topological sort is a linear ordering of vertices in a directed acyclic graph (DAG) such that for every directed edge u → v, vertex u appears before vertex v in the ordering.

Topological sort only exists for DAGs — any cycle makes a consistent ordering impossible. The classic example is course prerequisites: if course A must be taken before course B, then A must appear before B in any valid schedule.

Kahn's Algorithm

Kahn's algorithm computes a topological sort using BFS. The key insight is that every DAG has at least one vertex with in-degree 0 (no prerequisites). Process those first; removing them may expose new zero-in-degree vertices.

Steps:

  1. Compute the in-degree of every vertex.
  2. Enqueue all vertices with in-degree 0.
  3. While the queue is non-empty: dequeue a vertex, append it to the result, decrement the in-degree of each of its neighbors; if a neighbor reaches in-degree 0, enqueue it.
  4. If the result contains fewer than V vertices, the graph has a cycle — no topological ordering exists.

DiGraph Implementation

DiGraph — directed graph with Kahn's topological sort
#include <iostream>
#include <queue>
#include <vector>

class DiGraph {
public:
    DiGraph(int vertices) : adj_(vertices), indegree_(vertices, 0) {}

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

    std::vector<int> toposort() {
        std::vector<int> indegree = indegree_;  // local copy — don't modify the stored state
        std::queue<int> q;

        for (int i = 0; i < (int)adj_.size(); ++i) {
            if (indegree[i] == 0) q.push(i);
        }

        std::vector<int> result;
        while (!q.empty()) {
            int v = q.front();
            q.pop();
            result.push_back(v);
            for (int neighbor : adj_[v]) {
                if (--indegree[neighbor] == 0) q.push(neighbor);
            }
        }

        return result;  // size < adj_.size() means a cycle exists
    }

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

Unlike the undirected Graph from lecture 20, add_edge here only records the edge in one direction and tracks the in-degree of the destination.

Kahn's trace on a 5-node DAG:

Kahn's trace — edges: 0→1, 0→2, 1→3, 2→3, 3→4
// Initial in-degrees: 0:0, 1:1, 2:1, 3:2, 4:1
// Queue: [0]
//
// Dequeue 0 → result=[0]; decrement 1,2; in-degrees: 1:0,2:0; enqueue 1,2
// Dequeue 1 → result=[0,1]; decrement 3; in-degrees: 3:1
// Dequeue 2 → result=[0,1,2]; decrement 3; in-degrees: 3:0; enqueue 3
// Dequeue 3 → result=[0,1,2,3]; decrement 4; in-degrees: 4:0; enqueue 4
// Dequeue 4 → result=[0,1,2,3,4]
//
// result.size()==5 == adj_.size() → no cycle; valid topological order

Topological order is not unique. Both 0 1 2 3 4 and 0 2 1 3 4 are valid orderings for the graph above — vertex 1 and vertex 2 have no dependency between them, so either can come first. Kahn's processes them in FIFO queue order, producing one valid ordering.

Always check result.size() == V after the loop. If the result is shorter, a cycle prevented some vertices from ever reaching in-degree 0 — the caller has no way to know the sort is incomplete unless you check.

What Is Union Find?

Union Find (also called Disjoint Set Union) tracks a collection of elements partitioned into disjoint sets. It supports two operations:

  • find(x) — returns the representative (root) of x's set.
  • unite(x, y) — merges the sets containing x and y.

Two elements are in the same connected component if and only if find(x) == find(y).

Union Find represents each component as a tree stored in the parent_ array. Each node points to its parent; roots point to themselves. find(x) walks up to the root; unite(x,y) links two roots together.

After unite(0,1), unite(1,2), and unite(4,5):

Roots — 0, 3, 4 — have no incoming arrow. find(2) follows 2 → 0 and returns 0. find(5) follows 5 → 4 and returns 4. Since find(2) ≠ find(5), the two elements are in different components.

After unite(2,3), node 3 (or its root) is attached under 0:

Now find(3) also returns 0 — 0, 1, 2, and 3 all belong to the same component.

Union Find Implementation

The underlying structure is a parent_ array: each element points to its parent, and the root of a set points to itself.

UnionFind
#include <vector>

class UnionFind {
public:
    UnionFind(int n) : parent_(n), rank_(n, 0) {
        for (int i = 0; i < n; ++i) parent_[i] = i;
    }

    int find(int x) {
        if (parent_[x] != x)
            parent_[x] = find(parent_[x]);  // path compression
        return parent_[x];
    }

    void unite(int x, int y) {
        int rx = find(x), ry = find(y);
        if (rx == ry) return;
        if (rank_[rx] < rank_[ry]) std::swap(rx, ry);
        parent_[ry] = rx;
        if (rank_[rx] == rank_[ry]) ++rank_[rx];
    }

    bool connected(int x, int y) {
        return find(x) == find(y);
    }

private:
    std::vector<int> parent_;
    std::vector<int> rank_;
};

Path Compression and Union by Rank

Two optimizations keep operations near-constant amortized:

Path compression — when find walks up to the root, it rewires every node along the path to point directly to the root. Future find calls on those nodes take O(1).

Union by rank — always attach the shallower tree under the root of the taller tree. The rank_ array tracks tree height. This prevents long chains from forming.

Union find trace — 6 elements
// Initial: parent=[0,1,2,3,4,5], rank=[0,0,0,0,0,0]
//
// unite(0,1): find(0)=0, find(1)=1; ranks equal → parent[1]=0; rank[0]=1
//   parent=[0,0,2,3,4,5]
//
// unite(2,3): find(2)=2, find(3)=3; ranks equal → parent[3]=2; rank[2]=1
//   parent=[0,0,2,2,4,5]
//
// unite(4,5): find(4)=4, find(5)=5; ranks equal → parent[5]=4; rank[4]=1
//   parent=[0,0,2,2,4,4]
//
// unite(1,2): find(1)→parent[1]=0→root=0; find(2)=2
//   rx=0(rank=1), ry=2(rank=1); equal → parent[2]=0; rank[0]=2
//   parent=[0,0,0,2,4,4]
//
// connected(0,3): find(0)=0; find(3)→parent[3]=2→parent[2]=0 (compressed)→0; 0==0 → true
// connected(0,4): find(0)=0; find(4)=4; 0≠4 → false
// connected(3,5): find(3)=0; find(5)→parent[5]=4→4; 0≠4 → false
OperationNaive findWith path compression + union by rank
find (worst case)O(n)O(α(n)) ≈ O(1) amortized
uniteO(n)O(α(n)) ≈ O(1) amortized

α(n) is the inverse Ackermann function — it grows so slowly that it is effectively constant for any realistic input size.

Exercises

Q1.

Without running the code, predict what this program prints.

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

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

    std::vector<int> order = g.toposort();
    for (int v : order) std::cout << v << " ";
    std::cout << "\n";
    return 0;
}

Q2.

Without running the code, predict what this program prints.

#include <iostream>
#include <vector>

int main() {
    UnionFind uf(6);
    uf.unite(0, 1);
    uf.unite(2, 3);
    uf.unite(4, 5);
    uf.unite(1, 2);

    std::cout << uf.connected(0, 3) << "\n"; // line A
    std::cout << uf.connected(0, 4) << "\n"; // line B
    std::cout << uf.connected(3, 5) << "\n"; // line C
    return 0;
}

Q3.

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

// Snippet A — toposort returns without checking for a cycle
std::vector<int> toposort() {
    // ... Kahn's algorithm ...
    return result;  // missing: cycle check
}

// Snippet B — find without path compression
int find(int x) {
    while (parent_[x] != x) x = parent_[x];  // no rewiring
    return x;
}

// Snippet C — unite ignores rank and always attaches ry under rx
void unite(int x, int y) {
    int rx = find(x), ry = find(y);
    if (rx == ry) return;
    parent_[ry] = rx;   // always, even when rank[rx] < rank[ry]
}

// Snippet D — add_edge increments in-degree of the source instead of destination
void add_edge(int u, int v) {
    adj_[u].push_back(v);
    ++indegree_[u];     // wrong vertex
}

Q4.

Implement toposort() using Kahn's algorithm.

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

class DiGraph {
public:
    DiGraph(int v) : adj_(v), indegree_(v, 0) {}

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

    std::vector<int> toposort() {
        // TODO:
        // 1. Copy indegree_ locally
        // 2. Enqueue all 0-in-degree vertices
        // 3. Process queue: append to result, decrement neighbors' in-degrees,
        //    enqueue any that reach 0
        // Return result (size < adj_.size() means a cycle)
    }

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

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

    std::vector<int> order = g.toposort();
    for (int v : order) std::cout << v << " ";
    std::cout << "\n";
    return 0;
}

Q5.

Implement find and unite for the union find below.

#include <iostream>
#include <vector>

class UnionFind {
public:
    UnionFind(int n) : parent_(n), rank_(n, 0) {
        for (int i = 0; i < n; ++i) parent_[i] = i;
    }

    int find(int x) {
        // TODO: return the root of x's set; apply path compression
    }

    void unite(int x, int y) {
        // TODO: merge the sets containing x and y using union by rank
    }

    bool connected(int x, int y) {
        return find(x) == find(y);
    }

private:
    std::vector<int> parent_;
    std::vector<int> rank_;
};

int main() {
    UnionFind uf(7);
    uf.unite(0, 1);
    uf.unite(1, 2);
    uf.unite(3, 4);
    uf.unite(5, 6);
    uf.unite(2, 3);

    std::cout << uf.connected(0, 4) << "\n"; // line A
    std::cout << uf.connected(0, 5) << "\n"; // line B
    std::cout << uf.connected(4, 5) << "\n"; // line C
    return 0;
}

Practice Problems