题解:洛谷 P5236 静态仙人掌

【题目来源】

洛谷:P5236 【模板】静态仙人掌

【题目描述】

给你一个有 \(n\) 个点和 \(m\) 条边的仙人掌图,和 \(q\) 组询问
每次询问两个点 \(u,v\),求两点之间的最短路。

保证输入数据没有重边。

【输入】

第一行三个正整数 \(n,m,q\),意义如题目描述。
接下来 \(m\) 行,每行三个正整数 \(u,v,w\),表示 \(u,v\) 之间有一条权值为 \(w\) 的无向边。
然后 \(q\) 行,每行两个正整数 \(u,v\),询问 \(u\)\(v\) 的最短路。

【输出】

\(q\) 行,每行一个正整数,对应一次询问的结果。

【输入样例】

9 10 2
1 2 1
1 4 1
3 4 1
2 3 1
3 7 1
7 8 2
7 9 2
1 5 3
1 6 4
5 6 1
1 9
5 7

【输出样例】

5
6

【核心思想】

  1. 问题分析:给定一个 \(n\) 个点 \(m\) 条边的仙人掌图(每条边至多属于一个简单环),以及 \(q\) 组询问,每次询问两点 \(u,v\) 之间的最短路。仙人掌图的核心难点在于环上存在两条路径,需要取较短者,而环之间不相交的性质使得可以通过圆方树将其转化为树结构后统一处理。

  2. 算法选择

    • 圆方树(Block-Cut Tree / Cactus Tree):将仙人掌图中每个简单环压缩为一个虚拟的"方点",环上所有真实节点(圆点)与方点相连,从而将含环图转化为树
    • Tarjan 找环:利用 DFS 时间戳 \(dfn\)\(low\) 值识别环结构,每条非树边对应一个简单环
    • 倍增 LCA:在圆方树上预处理深度、祖先表和到根的距离,\(O(\log n)\) 回答每次询问
  3. 关键步骤

    • Tarjan 构建圆方树
      • 对原图跑 Tarjan,记录每个节点的父节点 \(fa\)、到父节点的边权 \(fw\)、入边编号 \(fe\)
      • \(dfn[u] < low[v]\),则 \((u,v)\),直接加入圆方树(边权不变)
      • \(dfn[u] < dfn[v]\)\(fe[v] \neq i\)(非树边),则 \((u,v)\) 构成一个,以 \(u\) 为环的根创建方点
    • 环上边权处理
      • 设环的根为 \(u\),通过非树边 \((u,v,w)\) 形成环,沿父边回溯从 \(v\)\(u\),累加得到前缀和 \(s[k]\)\(u\) 沿父边方向到 \(k\) 的环上距离)
      • 环的总长度 \(sum = w + \sum fw[k]\),记为 \(sc[k]\)
      • 新建方点 \(cnt\)\(u\) 与方点连边权 \(0\);环上其他节点 \(k\) 与方点连边权 \(\min(s[k], sum - s[k])\)(取环上两个方向的最短距离)
    • LCA 预处理:在圆方树上 DFS,预处理每个节点的深度 \(dep\)\(2^i\) 级祖先 \(f[u][i]\)、到根的距离 \(d[u]\)
    • 回答询问
      • \(u,v\) 在圆方树上的 LCA 为 \(p\)
      • \(p \leq n\)(圆点):答案为 \(d[u] + d[v] - 2 \cdot d[p]\)(标准树上距离公式)
      • \(p > n\)(方点):设倍增过程中 \(u,v\) 最后跳到的节点为 \(A,B\)(即环上两个入口点),环上 \(A\)\(B\) 的最短距离为 \(\min(|s[A]-s[B]|, sc[A] - |s[A]-s[B]|)\),答案为 \((d[u]-d[A]) + (d[v]-d[B]) + d_{环}(A,B)\)
  4. 时间/空间复杂度

    • 时间复杂度:\(O(n + m + q \log n)\),Tarjan 建圆方树 \(O(n+m)\),LCA 预处理 \(O(n \log n)\),每次询问 \(O(\log n)\)
    • 空间复杂度:\(O(n \log n + m)\),圆方树最多 \(2n\) 个节点,倍增表 \(O(n \log n)\)
  5. 圆方树的核心思想

    • 环的压缩与展开:将每个简单环压缩为方点,环上节点到方点的边权预存为到环根的最短距离,查询时自动选择环上的最优路径
    • 树化后的统一处理:仙人掌图转化为圆方树后,非环部分保持原边权,环上距离通过方点边权隐式表达,使得树上距离公式在大多数情况直接适用
    • 方点 LCA 的特殊处理:当 LCA 为方点时,说明两点路径在环内汇合,需利用倍增过程中记录的环上入口点 \(A,B\),结合环长前缀和计算环内最短路径
    • 前缀和技巧:通过 \(s[k]\)(到环根一个方向的距离)和 \(sc[k]\)(环总长),\(O(1)\) 计算环上任意两点的最短距离
    • 适用于仙人掌图上的最短路、直径、动态维护等问题,是处理"含环但环不相交"图结构的经典工具

【算法标签】

省选 #圆方树

【代码详解】

#include <bits/stdc++.h>
using namespace std;

// ==================== 常量与全局变量 ====================

// N: 最大点数, M: 最大边数(仙人掌图边数约为3倍点数)
const int N = 20005, M = 20005 * 3;

int n, m, q;                        // n:点数, m:边数, q:询问数

// 链式前向星相关数组
int h1[N], h2[N];                   // h1:原图头指针, h2:圆方树头指针
int e[M], w[M], ne[M], idx = 2;     // e:终点, w:边权, ne:下一条边, idx:当前可用边编号(从2开始,方便异或找反向边)

// Tarjan相关数组
int dfn[N], low[N], tim;            // dfn:时间戳, low:能回溯到的最小时间戳, tim:当前时间

// 仙人掌图处理相关数组
int s[N];                           // s[x]: 从环的根节点u沿父边方向到x的距离(用于计算环上路径)
int sc[N];                          // sc[x]: 整个环的总长度
int fa[N];                          // fa[x]: x在原图中的父节点
int fw[N];                          // fw[x]: x到父节点fa[x]的边权
int fe[N];                          // fe[x]: x到父节点的那条边的编号
int cnt;                            // 当前节点总数(原图n个点 + 新建的方点)

// LCA倍增相关数组
int f[N][14];                       // f[u][i]: u的2^i级祖先
int dep[N];                         // dep[u]: u在圆方树中的深度
int d[N];                           // d[u]: 从根节点到u在圆方树上的路径距离

// LCA辅助变量
int A, B;                           // 记录LCA过程中u和v最后分别跳到的节点(用于处理方点LCA的情况)

// ==================== 链式前向星加边 ====================

/**
 * @brief 向链式前向星中添加一条边
 * @param h 头指针数组
 * @param a 起点
 * @param b 终点
 * @param c 边权
 */
void add(int h[], int a, int b, int c)
{
    e[idx] = b;                     // 记录边的终点
    w[idx] = c;                     // 记录边的权值
    ne[idx] = h[a];                 // 新边的下一条指向原来a的头边
    h[a] = idx++;                   // 更新a的头边为新边,边编号自增
}

// ==================== 圆方树构建 ====================

/**
 * @brief 将原图中的一个环转化为圆方树中的结构
 * @param u 环的根节点(dfn最小的节点)
 * @param v 通过非树边与u相连形成环的节点
 * @param w 非树边(u,v)的权值
 *
 * 原理:对于仙人掌图中的每个简单环,在圆方树中创建一个"方点"(虚拟节点),
 *       将环上所有节点(圆点)与方点相连,边权为从该节点沿环到根节点的最短距离。
 */
void build_tree(int u, int v, int w)
{
    // ---------- 第一步:计算环上各节点到根节点u的距离 ----------
    int sum = w;                    // sum记录从v沿父边回溯到u的路径总长
    for (int k = v; k != u; k = fa[k])
    {
        s[k] = sum;                 // s[k]: 从u沿父边方向到k的环上距离
        sum += fw[k];               // 累加k到父节点的边权,继续向上
    }
    // s[u] = sc[u] = sum;          // 根节点u的s值(此处被注释,u不需要连到方点)

    // ---------- 第二步:创建方点并连接 ----------
    add(h2, u, ++cnt, 0);           // 新建方点cnt,将根节点u与方点相连(边权为0,因为u是环的根)

    // 将环上除u外的所有节点与方点相连
    for (int k = v; k != u; k = fa[k])
    {
        sc[k] = sum;                // sc[k]: 整个环的总长度
        // 边权为min(从u顺时针到k的距离, 从u逆时针到k的距离) = min(s[k], 环总长-s[k])
        add(h2, cnt, k, min(s[k], sum - s[k]));
    }
}

// ==================== Tarjan求点双连通分量(找环) ====================

/**
 * @brief Tarjan算法,用于找出仙人掌图中的所有环
 * @param u 当前节点
 * @param ine 进入u的边的编号(用于避免走反向边)
 *
 * 仙人掌图性质:每条边最多属于一个简单环。
 * 因此当发现dfn[u] < low[v]时,(u,v)是桥;当dfn[u] < dfn[v]且fe[v]!=i时,(u,v)构成环。
 */
void tarjan(int u, int ine)
{
    dfn[u] = low[u] = ++tim;        // 初始化u的时间戳和low值

    // ---------- 第一次遍历:DFS树边和回边,更新low值 ----------
    for (int i = h1[u]; i != -1; i = ne[i])
    {
        int v = e[i];               // v是u的邻接点

        if (!dfn[v])                // v未被访问,(u,v)是树边
        {
            // 记录v的父节点信息
            fa[v] = u;              // v的父节点是u
            fw[v] = w[i];           // v到父节点的边权
            fe[v] = i;              // v到父节点的那条边的编号

            tarjan(v, i);           // 递归访问v
            low[u] = min(low[u], low[v]);   // 更新u的low值

            // dfn[u] < low[v] 说明v无法通过回边回到u或u的祖先,(u,v)是桥
            if (dfn[u] < low[v])
                add(h2, u, v, w[i]);        // 桥边直接加入圆方树
        }
        else if (i != (ine ^ 1))    // v已访问且不是反向边(回边)
        {
            low[u] = min(low[u], dfn[v]);   // 更新low值
        }
    }

    // ---------- 第二次遍历:找出所有以u为根的环 ----------
    for (int i = h1[u]; i != -1; i = ne[i])
    {
        int v = e[i];

        // 条件说明:
        // dfn[u] < dfn[v]: 确保只处理每个环一次(只在环的dfn最小点处理)
        // fe[v] != i: 确保不是树边(fe[v]是v到父节点的边,i是当前边,如果相等就是树边)
        if (dfn[u] < dfn[v] && fe[v] != i)
            build_tree(u, v, w[i]);         // 构建圆方树
    }
}

// ==================== LCA预处理(DFS) ====================

/**
 * @brief 在圆方树上进行DFS,预处理每个节点的深度、距离和倍增祖先表
 * @param u 当前节点
 * @param father u的父节点
 *
 * 这是标准的倍增LCA预处理,在圆方树上进行。
 * 圆方树的性质:原图中的点称为"圆点",每个环对应的虚拟节点称为"方点"。
 */
void dfs(int u, int father)
{
    f[u][0] = father;               // f[u][0]为u的直接父节点
    dep[u] = dep[father] + 1;       // u的深度 = 父节点深度 + 1(根节点fa=0,dep[0]=0)

    // 枚举倍增层次,预处理2^i级祖先
    // 2^13 = 8192, 2^14 = 16384,对于N=20005足够
    for (int i = 1; i <= 13; i++)
    {
        f[u][i] = f[f[u][i - 1]][i - 1];  // u的2^i级祖先 = u的2^(i-1)级祖先的2^(i-1)级祖先
    }

    // 遍历u在圆方树上的所有邻接点
    for (int i = h2[u]; i != -1; i = ne[i])
    {
        int v = e[i];
        // if (v == father) continue;       // 跳过父节点,避免回走(圆方树是有根树,此处被注释)

        d[v] = d[u] + w[i];         // 累加从根到v的路径距离
        dfs(v, u);                  // 递归处理子节点v
    }
}

// ==================== 倍增法求LCA ====================

/**
 * @brief 倍增法求最近公共祖先(LCA),时间复杂度O(log n)
 * @param u 节点u
 * @param v 节点v
 * @return u和v的最近公共祖先
 *
 * 注意:此函数会修改全局变量A和B,用于记录LCA过程中u和v最后跳到的位置。
 *       当LCA是方点时,A和B分别是u和v所在的分支在环上的代表节点。
 */
int lca(int u, int v)
{
    // 确保u的深度不小于v的深度,即u在v的下方或同层
    if (dep[u] < dep[v])
        swap(u, v);

    // ---------- 第一步:将u向上提升,使u和v处于同一深度 ----------
    for (int i = 13; i >= 0; i--)   // 从高到低枚举倍增层次
    {
        // 如果u跳2^i步后深度仍不小于v,则可以跳
        // (注意:这里用dep[f[u][i]] >= dep[v]来判断,代码中写反了条件)
        if (dep[f[u][i]] < dep[v])
            continue;               // 跳太多了,跳过
        u = f[u][i];                // 将u向上提升2^i步
    }

    // 如果提升后u和v相等,说明v就是u的祖先,直接返回
    if (u == v)
        return u;

    // ---------- 第二步:u和v同时向上提升,直到它们的父节点相等 ----------
    for (int i = 13; i >= 0; i--)   // 从高到低枚举倍增层次
    {
        // 如果u和v跳2^i步后到达不同节点,说明LCA还在上方,可以同时跳
        if (f[u][i] == f[v][i])
            continue;               // 跳太多了或刚好是LCA,跳过
        u = f[u][i];                // u向上提升2^i步
        v = f[v][i];                // v向上提升2^i步
    }

    // 记录最后u和v分别跳到的位置(用于后续计算方点情况下的环上距离)
    A = u;
    B = v;

    // 此时u和v的父节点就是最近公共祖先
    return f[u][0];
}

// ==================== 主函数 ====================

int main()
{
    // ---------- 读入数据 ----------
    cin >> n >> m >> q;
    cnt = n;                        // 初始节点数为n,方点从n+1开始编号

    // 初始化头指针数组为-1(表示空链表)
    memset(h1, -1, sizeof(h1));     // 原图
    memset(h2, -1, sizeof(h2));     // 圆方树

    // 读入m条无向边,构建原图
    while (m--)
    {
        int a, b, c;
        cin >> a >> b >> c;
        add(h1, a, b, c);           // 添加a->b的边
        add(h1, b, a, c);           // 添加b->a的边(无向图)
    }

    // ---------- 构建圆方树 ----------
    tarjan(1, -1);                  // 从1号节点开始Tarjan,-1表示没有进入边

    // ---------- LCA预处理 ----------
    dfs(1, 0);                      // 从根节点1开始DFS,父节点为0(虚拟根)

    // ---------- 处理询问 ----------
    while (q--)
    {
        int u, v;
        cin >> u >> v;

        int p = lca(u, v);          // 求u和v在圆方树上的LCA

        // ---------- 情况1:LCA是圆点(原图中的真实节点)----------
        if (p <= n)
        {
            // 标准树上距离公式:d[u] + d[v] - 2*d[lca]
            cout << d[u] + d[v] - d[p] * 2 << endl;
        }
        // ---------- 情况2:LCA是方点(虚拟节点,代表一个环)----------
        else
        {
            // A和B是lca函数中记录的u和v最后跳到的节点(在环上的两个入口点)

            // len: A和B在环上的顺时针距离(通过s数组计算)
            int len = abs(s[A] - s[B]);

            // dAB: A到B在环上的最短距离 = min(顺时针距离, 逆时针距离)
            // sc[A]是整个环的总长度,逆时针距离 = 总长 - 顺时针距离
            int dAB = min(len, sc[A] - len);

            // 总距离 = 环上A到B的最短距离 + u到A的距离 + v到B的距离
            // d[u] - d[A] 是u到A在圆方树上的距离(即u到环入口的距离)
            // d[v] - d[B] 是v到B在圆方树上的距离(即v到环入口的距离)
            int dis = dAB + d[u] - d[A] + d[v] - d[B];

            cout << dis << endl;
        }
    }

    return 0;
}

【运行结果】

9 10 2
1 2 1
1 4 1
3 4 1
2 3 1
3 7 1
7 8 2
7 9 2
1 5 3
1 6 4
5 6 1
1 9
5 7
5
6
posted @ 2026-09-03 16:08  团爸讲算法  阅读(10)  评论(0)    收藏  举报