NeFut Logo NeFut
EN 管理员登录

Kruskal 重构树:从最小生成树到瓶颈路问题的优雅转化

发布于:2026-05-29 01:49 最后更新:2026-06-06 13:04
#algorithm #Data Structure #Graph

核心逻辑与数学原理

本章聚焦于最小生成树(MST)理论的衍生战术形态:Kruskal 重构树(Kruskal Reconstruction Tree / Kruskal Tree)。这是一种将图的边权信息转化为树的节点属性的高级数据结构。

1. 代数空间向拓扑空间的升维映射

传统的 Kruskal 算法在合并两个连通块时,仅仅是在并查集中将一个根节点指向另一个根节点。而 Kruskal 重构树的核心物理本质是:在每次合并时,新建一个虚拟节点,以此虚拟节点作为两侧连通块根节点的共同父亲

这种建树范式带来了极为精妙的代数同构特性:

2. 瓶颈路问题的空间化转化

在原图上求解“从点 $u$ 到点 $v$ 的所有路径中,最大边权的最小值是多少”,本质是一个 MST 瓶颈路问题。在重构树上,由于大根堆性质,从叶子节点 $u$ 走到叶子节点 $v$ 路径上的最大边权,被优雅地转化为它们在重构树上的最近公共祖先(LCA)的节点权值

$$ ext{Filter extunderscore Edge extunderscore Weight}(u, v) = ext{val}[ ext{LCA}(u, v)]$$

这一转化将复杂的图论路径搜索,直接降维拉平为树上的静态 LCA 查询。


算法推导与状态设计

1. 重构树的动态构建

定义计数器 node_idx = n,用于为新产生的虚拟节点分配编号。在 Kruskal 遍历到一条有效边 $(u, v)$,权值为 $w$ 时:

  1. 寻找并查集根节点:$root_u = ext{find}(u), \, root_v = ext{find}(v)$。
  2. 若 $root_u \neq root_v$,触发重构升级:

$$ ext{node extunderscore idx} \leftarrow ext{node extunderscore idx} + 1$$

$$ ext{val}[ ext{node extunderscore idx}] = w$$

  1. 在重构树中建立有向父子边:从 $ ext{node extunderscore idx}$ 分别连向 $root_u$ 和 $root_v$。
  2. 更新并查集基座:$ ext{fa}[root_u] = ext{node extunderscore idx}, \, ext{fa}[root_v] = ext{node extunderscore idx}$。

2. 状态查询拓扑驱动(连通块可达性)

经典问题描述:求从点 $v$ 出发,只经过边权 $\ ext{\le} x$ 的边,能到达哪些点?


C++ 标准源码(Kruskal 重构树基础架构模板)

#include <iostream>
#include <vector>
#include <algorithm>

const int MAXN = 100005;
const int MAXM = 300005;

struct Edge {
    int u, v;
    long long w;
    bool operator<(const Edge& rhs) const { return w < rhs.w; }
};

Edge edges[MAXM];
int dsu_fa[MAXN * 2]; // 重构树节点翻倍,并查集数组必须开两倍空间
long long val[MAXN * 2]; // 存储虚拟节点的边权属性
std::vector<int> tr[MAXN * 2]; // 重构树的邻接表

// LCA 倍增状态
int depth[MAXN * 2];
int parent[MAXN * 2][20];

int find_set(int i) {
    if (dsu_fa[i] == i) return i;
    return dsu_fa[i] = find_set(dsu_fa[i]);
}

// DFS 预处理重构树的倍增祖先
void dfs_build(int u, int p, int d) {
    depth[u] = d;
    parent[u][0] = p;
    for (int k = 1; k < 20; ++k) {
        parent[u][k] = parent[parent[u][k-1]][k-1];
    }
    for (size_t i = 0; i < tr[u].size(); ++i) {
        int v = tr[u][i];
        dfs_build(v, u, d + 1);
    }
}

// 寻找满足只通过权值 <= max_w 的边能跳到的最高祖先
int get_highest_ancestor(int v, long long max_w) {
    for (int k = 19; k >= 0; --k) {
        // 如果祖先存在,且其关联的原图边权仍满足 <= max_w,则向上跳跃
        if (parent[v][k] != 0 && val[parent[v][k]] <= max_w) {
            v = parent[v][k];
        }
    }
    return v;
}

int main() {
    std::ios_base::sync_with_stdio(false);
    std::cin.tie(NULL);

    int n, m, q;
    if (!(std::cin >> n >> m >> q)) return 0;

    for (int i = 0; i < m; ++i) {
        std::cin >> edges[i].u >> edges[i].v >> edges[i].w;
    }

    // 核心驱动:按边权升序排列
    std::sort(edges, edges + m);

    // 并查集底座初始化,空间必须开齐 2 * n
    for (int i = 1; i < 2 * n; ++i) dsu_fa[i] = i;

    int node_idx = n; // 虚拟节点编号从 n + 1 开始
    int edge_cnt = 0;

    for (int i = 0; i < m; ++i) {
        int root_u = find_set(edges[i].u);
        int root_v = find_set(edges[i].v);

        if (root_u != root_v) {
            node_idx++;
            val[node_idx] = edges[i].w; // 新节点托管边权

            // 重构树建边:从新节点指向两侧连通块的旧根
            tr[node_idx].push_back(root_u);
            tr[node_idx].push_back(root_v);

            // 并查集归拢到新虚拟节点上
            dsu_fa[root_u] = node_idx;
            dsu_fa[root_v] = node_idx;

            edge_cnt++;
            if (edge_cnt == n - 1) break;
        }
    }

    // 注意:若图本身不连通,重构树会是一个森林。
    // 为了完备建立倍增状态,必须对每个联通块的根(即 dsu_fa[i] == i 的点)都跑一遍 DFS
    for (int i = node_idx; i >= 1; --i) {
        if (depth[i] == 0) { // 未被访问过,说明是某棵树的根
            int root = find_set(i);
            dfs_build(root, 0, 1);
        }
    }

    // 响应可达性瓶颈询问
    while (q--) {
        int v;
        long long x;
        std::cin >> v >> x;

        int ans_node = get_highest_ancestor(v, x);
        // 此时,以 ans_node 为根的子树内所有的叶子节点(编号 <= n 的点)即为所求可达点集
        std::cout << "Highest node ID: " << ans_node << ", Boundary weight: " << val[ans_node] << "\n";
    }

    return 0;
}

NOIP 实战避坑指南

  1. 并查集及相关重构树数组空间未开双倍: 这是 Kruskal 重构树最经典的爆零点。原图只有 $N$ 个点,但重构树总共会产生 $2N-1$ 个节点。如果选手的 dsu_favaldepth 数组或者倍增矩阵 parent 仍然按照传统惯性开辟成 MAXN,在程序执行到 node_idx > n 时会发生严重的内存越界覆盖,导致运行时崩溃(RE)或输出诡异错解。必须铁律:涉及重构树点集的数组开双倍空间
  2. 森林多根未处理导致倍增拓扑断层: 如果题目给出的原图不是完全连通的,Kruskal 跑完后会形成多棵独立的树(重构森林)。如果选手在 main 函数中盲目地只写一句 dfs_build(node_idx, 0, 1);,那么其余未连通孤立块的虚拟节点和叶子节点的深度 depth 将全部保持为 0,倍增祖先也无法熟化。后续如果查询这些孤立块内的点,会导致死循环或彻底错解。

经典 NOIP/洛谷 真题

1. 洛谷 P1967 [NOIP2013 提高组] 货车运输

2. 洛谷 P4768 [NOI2018] 归程

  1. 底层底座:首先无视下雨限制,在原图上以 $1$ 号点为源点,利用 Dijkstra 算法跑一次全量单源最短路,求出每个点到 $1$ 号点的物理最短步行距离 $mindist[i]$。
  2. 空间重构:将所有边按照海拔高度从大到小(降序)排列,建立海拔属性的 Kruskal 最大重构树。重构树的虚拟节点记录海拔。
  3. 树上检索:当给出一个特定的水位 $p$ 和起点 $v$ 时,利用倍增快速向上跳跃,找到深度最浅且海拔 val[anc] > p 的虚拟祖先节点 $anc$。此时,以 $anc$ 为根的子树内所有的叶子节点,就是开车能免费到达的所有城市。
  4. 极值秒杀:为了让步行消耗的体力最少,我们只需要在这群开车可达的叶子节点中,找出 $mindist[i]$ 最小的那个值即可。我们可以通过一次树上 DFS 预处理,用 min_d[u] 记录以 u 为根的子树中所有叶子节点的 $mindist$ 最小值。查询时直接输出 min_d[anc] 即可,单次查询时间复杂度仅为 $O(\log N)$。
原文链接: local://11.4

[h] 返回首页