跳转至

第11章 图论基础

图(Graph) 是算法竞赛中出现频率最高的数据结构之一。从最短路到网络流,从拓扑排序到强连通分量,图论贯穿了整个竞赛生涯。本章将系统讲解图的基本概念、存储方式、遍历算法,以及最短路、最小生成树、拓扑排序、并查集等核心内容。


11.1 图的基本概念

11.1.1 图的定义

\(G = (V, E)\) 由两个集合组成:

  • 顶点集(Vertex Set) \(V\):图中所有节点的集合,\(|V| = n\) 表示节点数。
  • 边集(Edge Set) \(E\):图中所有边的集合,\(|E| = m\) 表示边数。

11.1.2 有向图与无向图

类型 边的表示 示例 实际含义
无向图 \((u, v)\) 表示 \(u\)\(v\) 之间的边 社交网络中的好友关系 双向通行
有向图 \(\langle u, v \rangle\) 表示从 \(u\)\(v\) 的有向边 网页间的超链接 单向通行

11.1.3 度

  • 无向图的度:节点 \(v\) 的度 \(deg(v)\) 是与 \(v\) 相连的边数。
  • 有向图的度:分为 入度\(indeg(v)\),指向 \(v\) 的边数)和 出度\(outdeg(v)\),从 \(v\) 出发的边数)。
  • 握手定理:无向图中所有节点的度之和等于边数的两倍,即 \(\sum_{v \in V} deg(v) = 2m\)

11.1.4 连通性

  • 连通图(无向):任意两个节点之间都存在路径。
  • 强连通图(有向):任意两个节点之间都存在有向路径。
  • 弱连通图(有向):忽略方向后为连通图。
  • 连通分量:无向图中的极大连通子图。

11.1.5 路径与环

  • 路径(Path):从节点 \(u\) 到节点 \(v\) 的一个节点序列,其中相邻节点之间有边相连。简单路径要求所有节点互不相同。
  • 环(Cycle):起点与终点相同的路径。简单环要求除起点和终点外其余节点互不相同。
  • 有向无环图(DAG):不存在环的有向图,是拓扑排序的前提。

11.1.6 图的分类示意

graph TD
    A[图] --> B[按方向]
    A --> C[按边权]
    A --> D[按连通性]
    B --> B1[无向图]
    B --> B2[有向图]
    C --> C1[无权图]
    C --> C2[带权图]
    D --> D1[连通图]
    D --> D2[非连通图]
    D --> D3[强连通图]

11.1.7 常见术语图示

graph LR
    A --- B
    B --- C
    C --- D
    D --- A
    B --- D
    E --- F
    style E fill:#f9f,stroke:#333
    style F fill:#f9f,stroke:#333

上图展示了:左侧 \(A, B, C, D\) 构成一个连通分量,包含环 \(A \to B \to D \to A\);右侧 \(E, F\) 构成另一个连通分量。整个图是非连通图,有两个连通分量。


11.2 图的存储

11.2.1 邻接矩阵

邻接矩阵 使用一个二维数组 g[i][j] 存储节点 \(i\) 到节点 \(j\) 的边权信息。

// 邻接矩阵存储
const int MAXN = 1005;
int g[MAXN][MAXN];

// 初始化(无权图:0表示无边,1表示有边;带权图:INF表示无边)
memset(g, 0x3f, sizeof(g));  // 初始化为无穷大

// 添加无向边
void addEdge(int u, int v, int w) {
    g[u][v] = w;
    g[v][u] = w;  // 有向图去掉这一行
}

// 添加有向边
void addDirectedEdge(int u, int v, int w) {
    g[u][v] = w;
}

适用场景:

优点 缺点
查询两点间是否有边 \(O(1)\) 空间 \(O(n^2)\),稠密图浪费
实现简单 遍历某点所有邻居需要 \(O(n)\)
适合稠密图(\(m \approx n^2\) 不适合稀疏图(\(m \ll n^2\)

11.2.2 邻接表

邻接表 使用 vector 数组,每个节点维护一个链表存储其所有邻居。

// 无权图邻接表
vector<int> adj[MAXN];

void addEdge(int u, int v) {
    adj[u].push_back(v);
    adj[v].push_back(u);  // 有向图去掉这一行
}

// 带权图邻接表
struct Edge {
    int to, weight;
};

vector<Edge> adj[MAXN];

void addEdge(int u, int v, int w) {
    adj[u].push_back({v, w});
    adj[v].push_back({u, w});  // 有向图去掉这一行
}

// 遍历节点u的所有邻居
for (auto& e : adj[u]) {
    // e.to 是邻居节点, e.weight 是边权
}

复杂度对比:

操作 邻接矩阵 邻接表
空间 \(O(n^2)\) \(O(n + m)\)
判断 \((u,v)\) 是否有边 \(O(1)\) \(O(deg(u))\)
遍历 \(u\) 的所有邻居 \(O(n)\) \(O(deg(u))\)
添加一条边 \(O(1)\) \(O(1)\)

竞赛推荐:绝大多数题目使用邻接表(vector<Edge>),在 \(n\) 很大(\(>5000\))时邻接矩阵会 MLE。

11.2.3 链式前向星

链式前向星 是一种静态链表存储方式,兼具邻接表的空间效率和数组的访问速度,在早期竞赛中广泛使用。

核心思想: 用数组模拟链表。每条边有一个 next 指针,指向同一起点的下一条边。head[u] 指向节点 \(u\) 的第一条边。

struct Edge {
    int to, next, weight;
} e[MAXM];

int head[MAXN], cnt;

void init() {
    memset(head, -1, sizeof(head));
    cnt = 0;
}

void addEdge(int u, int v, int w) {
    e[++cnt] = {v, head[u], w};
    head[u] = cnt;
}

// 遍历节点u的所有邻居
for (int i = head[u]; i != -1; i = e[i].next) {
    int v = e[i].to;
    int w = e[i].weight;
    // 处理边 (u, v, w)
}

存储原理:

graph LR
    subgraph "head[] 数组"
        H2["head[2] -> 2"]
        H4["head[4] -> 4"]
    end
    subgraph "e[] 边数组"
        E1["e[1]: to=4, next=-1"]
        E2["e[2]: to=3, next=1"]
        E3["e[3]: to=2, next=-1"]
        E4["e[4]: to=1, next=3"]
    end
    H2 --> E2
    E2 --> E1
    H4 --> E4
    E4 --> E3

假设添加了边:(2,4), (2,3), (4,2), (4,1)。则:

  • head[2] = 2,即 e[2] 是节点 2 的第一条边(to=3),e[2].next = 1 指向 e[1]to=4),e[1].next = -1 结束。
  • head[4] = 4,即 e[4] 是节点 4 的第一条边(to=1),e[4].next = 3 指向 e[3]to=2),e[3].next = -1 结束。

三种存储方式总结:

方式 空间 实现难度 适用场景
邻接矩阵 \(O(n^2)\) 简单 小规模稠密图
邻接表 (vector) \(O(n + m)\) 简单 大多数竞赛题首选
链式前向星 \(O(n + m)\) 中等 对常数要求极高时

11.3 图的遍历

11.3.1 DFS 遍历

深度优先搜索(DFS) 沿着一条路径尽可能深地探索,直到无路可走时回溯。

vector<int> adj[MAXN];
bool vis[MAXN];

void dfs(int u) {
    vis[u] = true;
    for (int v : adj[u]) {
        if (!vis[v]) {
            dfs(v);
        }
    }
}

时间复杂度: \(O(n + m)\)

递归层数问题: 当图的深度很大(如链状图 \(n=10^5\))时,递归 DFS 可能栈溢出。解决方法:

// 方法1:手动扩栈(注意:此写法是 MSVC 专用的链接器指令,
// GNU G++ 下无效!Linux 评测机可在本地用 ulimit -s unlimited 调栈,
// 若评测环境栈空间受限,最稳妥的做法是改用下面的迭代式 DFS)
#pragma comment(linker, "/STACK:1024000000,1024000000")

// 方法2:迭代式DFS(用栈模拟)
void dfs_iterative(int start) {
    stack<int> st;
    st.push(start);
    vis[start] = true;
    while (!st.empty()) {
        int u = st.top();
        st.pop();
        for (int v : adj[u]) {
            if (!vis[v]) {
                vis[v] = true;
                st.push(v);
            }
        }
    }
}

11.3.2 BFS 遍历

广度优先搜索(BFS) 从起点开始,逐层向外扩展,保证第一次到达每个节点时经过的路径是最短的(在无权图中)。

void bfs(int start) {
    queue<int> q;
    vector<int> dist(n + 1, -1);
    dist[start] = 0;
    q.push(start);

    while (!q.empty()) {
        int u = q.front();
        q.pop();
        for (int v : adj[u]) {
            if (dist[v] == -1) {
                dist[v] = dist[u] + 1;
                q.push(v);
            }
        }
    }
}

时间复杂度: \(O(n + m)\)

BFS 的性质: 在无权图中,BFS 天然求出从起点到所有节点的最短路径(边数最少)。这是因为 BFS 按层遍历,先到达的节点距离更近。

11.3.3 连通分量计数

使用 DFS 或 BFS 可以方便地计算无向图的连通分量个数:

int countComponents(int n) {
    int cnt = 0;
    vector<bool> vis(n + 1, false);
    for (int i = 1; i <= n; i++) {
        if (!vis[i]) {
            cnt++;
            // BFS标记整个连通分量
            queue<int> q;
            q.push(i);
            vis[i] = true;
            while (!q.empty()) {
                int u = q.front(); q.pop();
                for (int v : adj[u]) {
                    if (!vis[v]) {
                        vis[v] = true;
                        q.push(v);
                    }
                }
            }
        }
    }
    return cnt;
}

技巧:并查集也可以高效处理连通分量问题,详见 11.7 节。


11.4 最短路

最短路问题是图论中最经典的问题之一。根据问题规模和边权特征,选择合适的算法至关重要。

graph TD
    A[最短路问题] --> B{单源还是多源?}
    B -->|单源| C{有负权边?}
    B -->|多源| D[Floyd 全源最短路]
    C -->|无负权| E{稀疏图还是稠密图?}
    C -->|有负权| F{是否有负环?}
    E -->|稀疏 m~n| G[Dijkstra 堆优化]
    E -->|稠密 m~n^2| H[Dijkstra 朴素]
    F -->|无负环| I[Bellman-Ford / SPFA]
    F -->|有负环| J[问题无解]

11.4.1 Dijkstra 算法

贪心思想

Dijkstra 算法基于贪心策略:每次选择当前距离起点最近的未确定节点,用它来更新其邻居的距离。

核心原理: 当一个节点 \(u\) 被选中时(即 \(dist[u]\) 最小),\(dist[u]\) 已经是最终的最短距离。因为所有未确定的节点距离都 \(\geq dist[u]\),不可能通过它们找到更短的路径到 \(u\)

算法图解

graph TD
    subgraph "第1步: 初始状态"
        A1((A: 0)) -->|4| B1((B: INF))
        A1 -->|2| C1((C: INF))
        C1 -->|1| B1
        B1 -->|5| D1((D: INF))
    end

    subgraph "第2步: 选A, 更新B=4, C=2"
        A2((A: 确定)) -->|4| B2((B: 4))
        A2 -->|2| C2((C: 2确定))
        C2 -->|1| B2
        B2 -->|5| D2((D: INF))
    end

    subgraph "第3步: 选C, 更新B=3"
        A3((A)) -->|4| B3((B: 3确定))
        A3 -->|2| C3((C))
        C3 -->|1| B3
        B3 -->|5| D3((D: 8确定))
    end

朴素版 Dijkstra

适用于稠密图,时间复杂度 \(O(n^2 + m)\)

const int INF = 0x3f3f3f3f;
const int MAXN = 5005;

struct Edge {
    int to, w;
};

vector<Edge> adj[MAXN];
int dist[MAXN];
bool vis[MAXN];
int n, m;

void dijkstra(int s) {
    memset(dist, 0x3f, sizeof(dist));
    memset(vis, false, sizeof(vis));
    dist[s] = 0;

    for (int i = 1; i <= n; i++) {
        // 找未确定节点中距离最小的
        int u = -1;
        for (int j = 1; j <= n; j++) {
            if (!vis[j] && (u == -1 || dist[j] < dist[u])) {
                u = j;
            }
        }
        if (dist[u] == INF) break;  // 剩余节点不可达
        vis[u] = true;
        // 用u更新邻居
        for (auto& e : adj[u]) {
            if (dist[e.to] > dist[u] + e.w) {
                dist[e.to] = dist[u] + e.w;
            }
        }
    }
}

堆优化版 Dijkstra

适用于稀疏图,使用优先队列(小根堆),时间复杂度 \(O(m \log n)\)

const int INF = 0x3f3f3f3f;

struct Edge {
    int to, w;
};

vector<Edge> adj[MAXN];
int dist[MAXN];
int n, m;

void dijkstra(int s) {
    memset(dist, 0x3f, sizeof(dist));
    dist[s] = 0;
    // pair<距离, 节点>,小根堆
    priority_queue<pair<int, int>, vector<pair<int, int>>, greater<>> pq;
    pq.push({0, s});

    while (!pq.empty()) {
        auto [d, u] = pq.top();
        pq.pop();
        if (d > dist[u]) continue;  // 懒删除:跳过已确定的节点
        for (auto& e : adj[u]) {
            if (dist[e.to] > dist[u] + e.w) {
                dist[e.to] = dist[u] + e.w;
                pq.push({dist[e.to], e.to});
            }
        }
    }
}

关键点if (d > dist[u]) continue 这行"懒删除"非常重要。因为优先队列中同一个节点可能被多次插入,我们只处理第一次弹出(即最短距离)的那次。

11.4.2 Bellman-Ford 算法

适用场景: 存在负权边的图。

核心思想: 对所有边进行 \(n - 1\) 轮松弛操作。可以归纳证明:进行 \(k\) 轮松弛后,所有最短路径边数 \(\leq k\) 的结点已求出正确值;由于任意最短路径至多含 \(n - 1\) 条边,故至多 \(n - 1\) 轮即收敛。

struct Edge {
    int u, v, w;
};

vector<Edge> edges;
int dist[MAXN];
int n, m;

bool bellmanFord(int s) {
    memset(dist, 0x3f, sizeof(dist));
    dist[s] = 0;

    for (int i = 1; i <= n - 1; i++) {  // 至多 n-1 轮
        bool updated = false;
        for (auto& e : edges) {
            if (dist[e.u] < INF && dist[e.v] > dist[e.u] + e.w) {
                dist[e.v] = dist[e.u] + e.w;
                updated = true;
            }
        }
        if (!updated) break;  // 无更新说明已收敛
    }

    // 检测负环:再松弛一轮,如果还能更新则存在负环
    for (auto& e : edges) {
        if (dist[e.u] < INF && dist[e.v] > dist[e.u] + e.w) {
            return false;  // 存在负环
        }
    }
    return true;
}

时间复杂度: \(O(nm)\)

负环检测: 如果第 \(n\) 轮松弛仍然有更新,则说明存在负权环(负环)。因为正常情况下 \(n - 1\) 轮即可收敛。

11.4.3 SPFA 算法

SPFA(Shortest Path Faster Algorithm) 是 Bellman-Ford 的队列优化版本。

核心思想: 只有被更新过的节点才需要去更新其邻居,用队列维护这些"活跃"节点。

const int INF = 0x3f3f3f3f;

struct Edge {
    int to, w;
};

vector<Edge> adj[MAXN];
int dist[MAXN];
bool inq[MAXN];  // 是否在队列中
int cnt[MAXN];   // 入队次数,用于判负环
int n, m;

bool spfa(int s) {
    memset(dist, 0x3f, sizeof(dist));
    memset(inq, false, sizeof(inq));
    memset(cnt, 0, sizeof(cnt));
    dist[s] = 0;

    queue<int> q;
    q.push(s);
    inq[s] = true;
    cnt[s] = 1;

    while (!q.empty()) {
        int u = q.front();
        q.pop();
        inq[u] = false;
        for (auto& e : adj[u]) {
            if (dist[e.to] > dist[u] + e.w) {
                dist[e.to] = dist[u] + e.w;
                if (!inq[e.to]) {
                    q.push(e.to);
                    inq[e.to] = true;
                    if (++cnt[e.to] > n) {
                        return false;  // 存在负环
                    }
                }
            }
        }
    }
    return true;
}

时间复杂度: 平均 \(O(km)\)\(k\) 为小常数),最坏 \(O(nm)\)

注意:SPFA 在稠密图上可能退化到 \(O(nm)\),与朴素 Bellman-Ford 相同。如果题目数据没有负权边,强烈建议使用 Dijkstra,因为出题人可能会构造卡 SPFA 的数据。

11.4.4 Floyd 算法

适用场景: 求所有节点对之间的最短路(全源最短路),\(n \leq 500\) 左右。

核心思想: 动态规划。设 dp[k][i][j] 表示只经过前 \(k\) 个节点作为中转时,\(i\)\(j\) 的最短路。

状态转移方程:\(dp[k][i][j] = \min(dp[k-1][i][j], \ dp[k-1][i][k] + dp[k-1][k][j])\)

const int INF = 0x3f3f3f3f;
const int MAXN = 505;

int dp[MAXN][MAXN];
int n, m;

void floyd() {
    // 初始化:dp[i][j] 表示 i 到 j 的距离
    for (int k = 1; k <= n; k++) {         // 中转点
        for (int i = 1; i <= n; i++) {     // 起点
            for (int j = 1; j <= n; j++) { // 终点
                dp[i][j] = min(dp[i][j], dp[i][k] + dp[k][j]);
            }
        }
    }
}

// 使用前初始化
void init() {
    memset(dp, 0x3f, sizeof(dp));
    for (int i = 1; i <= n; i++) dp[i][i] = 0;
    // 读入边并设置 dp[u][v] = w
}

时间复杂度: \(O(n^3)\)

空间优化: Floyd 天然可以原地更新,无需额外滚动数组。

11.4.5 最短路算法对比

算法 时间复杂度 负权边 适用场景
Dijkstra 朴素 \(O(n^2 + m)\) 不支持 稠密图,无负权
Dijkstra 堆优化 \(O(m \log n)\) 不支持 稀疏图,无负权(首选)
Bellman-Ford \(O(nm)\) 支持 有负权边,需判负环
SPFA \(O(km)\) 均摊 支持 有负权边的稀疏图
Floyd \(O(n^3)\) 支持 全源最短路,\(n \leq 500\)

11.5 最小生成树

最小生成树(MST) 是一棵包含图中所有 \(n\) 个节点、\(n - 1\) 条边的树,使得边权之和最小。前提是图连通。

11.5.1 Kruskal 算法

核心思想: 将所有边按边权从小到大排序,依次考虑每条边。如果这条边连接了两个不同的连通分量(用并查集判断),则加入答案。

贪心正确性: 假设当前最短的合法边 \(e\) 不在某棵 MST 中,将 \(e\) 加入 MST 会形成一个环,从环中删除比 \(e\) 更长的边,得到一棵更优的 MST,矛盾。

graph LR
    subgraph "步骤1: 排序所有边"
        E1["(A,B,1)"] --> E2["(B,C,2)"] --> E3["(A,C,3)"] --> E4["(C,D,4)"] --> E5["(B,D,5)"]
    end
struct Edge {
    int u, v, w;
    bool operator<(const Edge& o) const {
        return w < o.w;
    }
};

vector<Edge> edges;
int parent[MAXN], rnk[MAXN];

int find(int x) {
    return parent[x] == x ? x : parent[x] = find(parent[x]);
}

bool unite(int x, int y) {
    x = find(x); y = find(y);
    if (x == y) return false;
    if (rnk[x] < rnk[y]) swap(x, y);
    parent[y] = x;
    if (rnk[x] == rnk[y]) rnk[x]++;
    return true;
}

int kruskal(int n) {
    // 初始化并查集
    for (int i = 1; i <= n; i++) parent[i] = i, rnk[i] = 0;
    sort(edges.begin(), edges.end());

    int mstWeight = 0, edgeCount = 0;
    for (auto& e : edges) {
        if (unite(e.u, e.v)) {
            mstWeight += e.w;
            edgeCount++;
            if (edgeCount == n - 1) break;
        }
    }
    // edgeCount < n-1 说明图不连通
    return (edgeCount == n - 1) ? mstWeight : -1;
}

时间复杂度: \(O(m \log m)\)(排序主导)

11.5.2 Prim 算法

核心思想: 类似 Dijkstra,从任意节点出发,每次选择连接已选集合和未选集合的最短边,将其对应的未选节点加入集合。

朴素版 Prim

\(O(n^2 + m)\),适用于稠密图。

const int INF = 0x3f3f3f3f;

int g[MAXN][MAXN];
int lowcost[MAXN];   // 到已选集合的最短距离
bool vis[MAXN];
int n;

int prim() {
    memset(vis, false, sizeof(vis));
    memset(lowcost, 0x3f, sizeof(lowcost));
    lowcost[1] = 0;
    int total = 0;

    for (int i = 1; i <= n; i++) {
        int u = -1;
        for (int j = 1; j <= n; j++) {
            if (!vis[j] && (u == -1 || lowcost[j] < lowcost[u])) {
                u = j;
            }
        }
        if (lowcost[u] == INF) return -1;  // 不连通
        vis[u] = true;
        total += lowcost[u];

        for (int v = 1; v <= n; v++) {
            if (!vis[v] && g[u][v] < lowcost[v]) {
                lowcost[v] = g[u][v];
            }
        }
    }
    return total;
}

堆优化版 Prim

\(O(m \log n)\),适用于稀疏图。

const int INF = 0x3f3f3f3f;

struct Edge { int to, w; };
vector<Edge> adj[MAXN];
int lowcost[MAXN];
bool vis[MAXN];
int n;

int prim() {
    memset(lowcost, 0x3f, sizeof(lowcost));
    memset(vis, false, sizeof(vis));
    lowcost[1] = 0;
    priority_queue<pair<int, int>, vector<pair<int, int>>, greater<>> pq;
    pq.push({0, 1});
    int total = 0, cnt = 0;

    while (!pq.empty()) {
        auto [d, u] = pq.top();
        pq.pop();
        if (vis[u]) continue;
        vis[u] = true;
        total += d;
        cnt++;
        for (auto& e : adj[u]) {
            if (!vis[e.to] && e.w < lowcost[e.to]) {
                lowcost[e.to] = e.w;
                pq.push({e.w, e.to});
            }
        }
    }
    return (cnt == n) ? total : -1;
}

11.5.3 算法对比

算法 时间复杂度 适用场景
Kruskal \(O(m \log m)\) 稀疏图首选,实现简单
Prim 朴素 \(O(n^2 + m)\) 稠密图
Prim 堆优化 \(O(m \log n)\) 稀疏图(与 Kruskal 相近)

竞赛推荐:大多数情况下 Kruskal 更实用——只需排序 + 并查集,代码量少,不容易出错。


11.6 拓扑排序

拓扑排序 是对 DAG(有向无环图)的节点进行线性排序,使得对于每条有向边 \(\langle u, v \rangle\)\(u\) 在排序中出现在 \(v\) 之前。

11.6.1 Kahn 算法(BFS 实现)

核心思想: 维护一个入度为 0 的节点队列。每次取出一个入度为 0 的节点,将它从图中"删除"(即将其所有邻居的入度减 1),新的入度为 0 的节点加入队列。

vector<int> adj[MAXN];
int indeg[MAXN];
int n, m;

vector<int> topoSort() {
    queue<int> q;
    for (int i = 1; i <= n; i++) {
        if (indeg[i] == 0) q.push(i);
    }

    vector<int> order;
    while (!q.empty()) {
        int u = q.front();
        q.pop();
        order.push_back(u);
        for (int v : adj[u]) {
            if (--indeg[v] == 0) {
                q.push(v);
            }
        }
    }

    // 如果排序结果不包含所有节点,说明有环
    if ((int)order.size() != n) {
        return {};  // 有环,拓扑排序不存在
    }
    return order;
}

时间复杂度: \(O(n + m)\)

11.6.2 判断 DAG

如果拓扑排序的结果包含所有 \(n\) 个节点,则图为 DAG;否则图中存在环。

11.6.3 入度变化图解

graph TD
    subgraph "初始图"
        A((A)) --> B((B))
        A --> C((C))
        B --> D((D))
        C --> D
        D --> E((E))
    end

    subgraph "步骤1: 选A(入度=0)"
        B1((B: indeg 1->0))
        C1((C: indeg 1->0))
        D1((D: indeg 2))
        E1((E: indeg 1))
    end

    subgraph "步骤2: 选B,C(入度=0)"
        D2((D: indeg 2->0))
        E2((E: indeg 1))
    end

    subgraph "步骤3: 选D(入度=0)"
        E3((E: indeg 1->0))
    end

    subgraph "步骤4: 选E(入度=0)"
        RESULT["排序: A -> B,C -> D -> E"]
    end

上图展示了一个拓扑排序过程:

步骤 选中节点 入度变化
1 A(入度=0) B: 1→0, C: 1→0
2 B, C(入度=0) D: 2→0
3 D(入度=0) E: 1→0
4 E(入度=0) 完成

最终排序:\(A \to B \to C \to D \to E\)(B 和 C 的相对顺序可交换)

11.6.4 应用

  • 判断有向图是否有环:拓扑排序结果长度 \(< n\) 则有环。
  • DAG 上的最短路/最长路:按拓扑序依次 DP,\(O(n + m)\)
  • 任务调度:确定有依赖关系的任务的执行顺序。

11.7 并查集

并查集(Disjoint Set Union, DSU / Union-Find) 是一种高效的集合合并与查询数据结构,广泛用于处理连通性问题。

11.7.1 基本操作

  • find(x):查找 \(x\) 所在集合的代表元素(根节点)。
  • unite(x, y):合并 \(x\)\(y\) 所在的两个集合。
  • same(x, y):判断 \(x\)\(y\) 是否在同一个集合。

11.7.2 基本实现

int parent[MAXN];

void init(int n) {
    for (int i = 1; i <= n; i++) parent[i] = i;
}

int find(int x) {
    if (parent[x] == x) return x;
    return find(parent[x]);  // 递归查找
}

void unite(int x, int y) {
    parent[find(x)] = find(y);
}

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

11.7.3 路径压缩

问题: 基本实现中,如果树退化成链,find 操作的复杂度退化为 \(O(n)\)

路径压缩:find 过程中,将路径上所有节点直接指向根节点,使树变得扁平。

int find(int x) {
    if (parent[x] == x) return x;
    return parent[x] = find(parent[x]);  // 路径压缩
}

11.7.4 按秩合并

秩(Rank) 是树的上界高度。合并时将矮树挂到高树上,避免树增高。

int parent[MAXN], rnk[MAXN];

void init(int n) {
    for (int i = 1; i <= n; i++) {
        parent[i] = i;
        rnk[i] = 0;
    }
}

int find(int x) {
    return parent[x] == x ? x : parent[x] = find(parent[x]);
}

void unite(int x, int y) {
    x = find(x); y = find(y);
    if (x == y) return;
    if (rnk[x] < rnk[y]) swap(x, y);
    parent[y] = x;
    if (rnk[x] == rnk[y]) rnk[x]++;
}

11.7.5 树结构变化图解

graph TD
    subgraph "初始: 每个节点独立"
        A1((1)) ~~~ B1((2)) ~~~ C1((3)) ~~~ D1((4)) ~~~ E1((5))
    end

    subgraph "unite(1,2) 后"
        A2((1)) --> B2((2))
        C2((3)) ~~~ D2((4)) ~~~ E2((5))
    end

    subgraph "unite(3,4) 后"
        A3((1)) --> B3((2))
        C3((3)) --> D3((4))
        E3((5))
    end

    subgraph "unite(1,3) 后(按秩合并)"
        A4((1)) --> B4((2))
        A4 --> C4((3))
        C4 --> D4((4))
        E4((5))
    end

    subgraph "路径压缩后 find(4)"
        A5((1)) --> B5((2))
        A5 --> C5((3))
        A5 --> D5((4))
        E5((5))
    end

11.7.6 复杂度分析

操作 仅路径压缩 仅按秩合并 路径压缩 + 按秩合并
单次均摊 \(O(\log n)\) \(O(\log n)\) \(O(\alpha(n))\)

其中 \(\alpha(n)\)反阿克曼函数,增长极其缓慢,对所有实际输入 \(\alpha(n) \leq 5\),可以认为是常数时间

11.7.7 完整代码模板

struct DSU {
    vector<int> parent, rnk;
    int components;  // 连通分量个数

    DSU(int n) : parent(n + 1), rnk(n + 1, 0), components(n) {
        iota(parent.begin(), parent.end(), 0);
    }

    int find(int x) {
        return parent[x] == x ? x : parent[x] = find(parent[x]);
    }

    bool unite(int x, int y) {
        x = find(x); y = find(y);
        if (x == y) return false;
        if (rnk[x] < rnk[y]) swap(x, y);
        parent[y] = x;
        if (rnk[x] == rnk[y]) rnk[x]++;
        components--;
        return true;
    }

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

11.7.8 带权并查集(拓展)

在某些问题中,节点之间存在某种关系(如距离、权值),可以为每个节点维护一个额外的 val 数组表示与父节点的关系。

int parent[MAXN], val[MAXN];  // val[x] 表示 x 到 parent[x] 的权值差

int find(int x) {
    if (parent[x] == x) return x;
    int root = find(parent[x]);
    val[x] += val[parent[x]];  // 路径压缩时更新权值
    return parent[x] = root;
}

void unite(int x, int y, int w) {
    // 表示 y = x + w 的关系
    int fx = find(x), fy = find(y);
    if (fx != fy) {
        parent[fy] = fx;
        val[fy] = val[x] + w - val[y];
    }
}

练习题

ACM Day6 图论(11题)

编号 题号 题目名称 知识点 难度
1 2041D Cross Coloring 图的着色 / 连通性 1500
2 2023B Increase/Decrease/Copy 图论建模 / 贪心 1400
3 1915G Bicycles 最短路(Dijkstra) 1700
4 2014E Treasure Hunt BFS / 图上搜索 1600
5 1975D Paint the Tree 树的直径 / BFS / DFS 1800
6 2060E Graph Compression 并查集 / 图论 1700
7 2027C Add Zeros 图论建模 / BFS 1900
8 abc384_e Takahashi's Angry 2 BFS / 优先队列
9 arc185_d Palindrome Factory 图论 / 构造
10 P1144 最短路计数 Dijkstra + DP 普及/提高
11 P9754 [CSP-S 2023] 消消乐 图论建模 / 拓扑排序 提高+/省选

LeetCode 寒假补充

编号 题号 题目名称 知识点 难度
1 200 岛屿数量 DFS / BFS / 连通分量 Medium
2 695 岛屿的最大面积 DFS / 连通分量 Medium
3 102 二叉树的层序遍历 BFS Medium

本章小结: 图是算法竞赛的核心数据结构。掌握图的存储方式(邻接表为首选)和两种遍历方式(DFS/BFS)是基础中的基础。最短路算法要根据图的特点(是否有负权、稀疏还是稠密、单源还是多源)选择合适的算法。最小生成树推荐 Kruskal + 并查集。拓扑排序是 DAG 上的基础操作。并查集要熟练掌握路径压缩和按秩合并,它是处理连通性问题的利器。