【Coel.学习笔记】仙人掌与圆方树

你是什么仙人?

引入

仙人掌是一种特殊的无向图,它的任意一条边至多只出现在一条简单回路(每个点只出现一次的回路是简单回路,特殊地,自环不算简单回路)。

这里借用一下 [SHOI2008] 仙人掌 中的图片。

仙人掌的特殊性质使得某些问题(如两点最短路)有更为高效的算法,其中圆方树是求解仙人掌的有力工具,但圆方树的性质又不止于此。

例题

【模板】静态仙人掌 / Freda 的传呼机

洛谷传送门
没有双倍经验,别想了。

给定一个 \(n\)\(m\) 边的仙人掌,进行 \(q\) 次询问,每次询问仙人掌上两点间的最短路径。\(n,q\leq 10^4,m\leq2\times 10^4\),时间限制为 \(300\text{ms}\)

解析:先思考一下,如果给定的是一棵树而非仙人掌如何求解。对于一棵树而言,任意两点间的路径是唯一确定的,因此问题转化为快速求树上两点权值和。

用什么算法呢?当然是 LCT 树上差分了。类比序列上的差分操作,我们记录下每个点到根节点的权值和。那么对于任意两点,只要找到它们的最近公共祖先,答案就是两点到根节点的权值和相加减去祖先到根节点的权值的两倍(因为两个节点都算了一次)。

现在考虑仙人掌。对于每一个环,我们都可以通过 tarjan 算法缩点。设缩点后得到的点为方点,原图的点(没有形成环)为圆点,就可以得到一棵圆方树。需要注意的是,圆方树是一棵有向树,从父节点指向子节点。

下面这张图来自 WC2018 的讲课论文,展示了圆方树的构建过程。

讨论仙人掌转圆方树的具体过程:

  1. 任取一点作为根。没根玩锤子呢
  2. 对于仙人掌上的每一个环,找到这个环与根距离最近的点(即环的“头部”),然后将环的头部向认为建立的方点连边,方点与剩下的点连边。
  3. 为保证环与树的等价,我们定义头部点与方点边权为 \(0\),方点与剩余点的边权为头部与剩余点的最短距离。

但需要注意的是,圆方树建立后仍然不能直接树上差分求最短路。当两点的最近公共祖先为方点(即原图的环),则需要对其进行分类讨论,即找到对应环上哪两个点并且去较短的一侧。

时间复杂度为 \(O(n\log n)\),瓶颈在于倍增求解最近公共祖先。

#include <iostream>
#include <algorithm>
#include <cstring>

using namespace std;

const int maxn = 5e4 + 10;

int n, m, q, sign, a, b; //sign 标记方点编号,a 和 b 在求 lca 时要用
int head1[maxn], head2[maxn], nxt[maxn], to[maxn], val[maxn], cnt; // 两个 head 分别存原图和圆方树
/*只要建两个表头就可以让两张图互不干涉,不需要写别的*/
int dfn[maxn], low[maxn], idx;
int sum[maxn], len[maxn]; //记录点到根的权值和以及环内点到头部点的距离
int anc[maxn][20], dep[maxn], dis[maxn];
int fa[maxn], fw[maxn], fe[maxn]; //圆方树内的父节点,与父节点连边权值,与父节点连边的编号

void add(int head[], int u, int v, int w) {
    nxt[cnt] = head[u], to[cnt] = v, val[cnt] = w, head[u] = cnt++;
}

void buildCircle(int u, int v, int w) {
    int s = w;
    for (int k = v; k != u; k = fa[k]) {
        sum[k] = s;
        s += fw[k];
    }
    sum[u] =  len[u] = s;
    add(head2, u, ++sign, 0); //方点和头部点连边
    for (int k = v; k != u; k = fa[k]) {
        len[k] = s;
        add(head2, sign, k, min(sum[k], s - sum[k])); //在环两侧中取较小距离连边
    }
}

void tarjan(int u, int f) {
    dfn[u] = low[u] = ++idx;
    for (int i = head1[u]; ~i; i = nxt[i]) {
        int v = to[i], w = val[i];
        if (!dfn[v]) {
            fa[v] = u, fw[v] = w, fe[v] = i;
            tarjan(v, i);
            low[u] = min(low[u], low[v]);
            if (dfn[u] < low[v]) add(head2, u, v, w);
        } else if (i != (f ^ 1)) low[u] = min(low[u], dfn[v]);
    }
    for (int i = head1[u]; ~i; i = nxt[i]) {
        int v = to[i], w = val[i];
        if (dfn[u] < dfn[v] && fe[v] != i) //fe 防止出现重边时对环的判断出错
            buildCircle(u, v, w);
    }
}

void initLca(int u, int f) { //对圆方树预处理倍增
    dep[u] = dep[f] + 1;
    anc[u][0] = f;
    for (int i = 1; i <= 15; i++)
        anc[u][i] = anc[anc[u][i - 1]][i - 1];
    for (int i = head2[u]; ~i; i = nxt[i]) {
        int v = to[i], w = val[i];
        dis[v] = dis[u] + w;
        initLca(v, u);
    }
}

int lca(int u, int v) {
    if (dep[u] < dep[v]) swap(u, v);
    for (int i = 15; i >= 0; i--)
        if (dep[anc[u][i]] >= dep[v])
            u = anc[u][i];
    if (u == v) return u;
    for (int i = 15; i >= 0; i--)
        if (anc[u][i] != anc[v][i])
            u = anc[u][i], v = anc[v][i];
    a = u, b = v; //记录最近公共祖先的两个子节点
    return anc[u][0];
}

int main(void) {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);
    cin >> n >> m >> q;
    sign = n;
    memset(head1, -1, sizeof(head1));
    memset(head2, -1, sizeof(head2));
    for (int i = 1; i <= m; i++) {
        int u, v, w;
        cin >> u >> v >> w;
        add(head1, u, v, w);
        add(head1, v, u, w);
    }
    tarjan(1, -1);
    initLca(1, 0);
    while (q--) {
        int u, v, Lca;
        cin >> u >> v;
        Lca = lca(u, v);
        if (Lca <= n) cout << dis[u] + dis[v] - dis[Lca] * 2 << '\n'; 
        /*最近公共祖先为圆点,直接求解*/
        else {
            int da = dis[u] - dis[a], db = dis[v] - dis[b];
            int dm = min(abs(sum[a] - sum[b]), len[a] - abs(sum[a] - sum[b]));
            /*最近公共祖先为方点,取环内较小值作为贡献*/
            cout << da + dm + db << '\n';
        }
    }
    return 0;
}

用圆方树求解仙人掌的效率和原理都很简单,但变量很多,难以记忆,所以一定要多练多记。

[SHOI2008] 仙人掌图 II

洛谷传送门

给定一个仙人掌,设每条边的权值均为 \(1\),求解其直径。图的直径定义为所有两点间最短路的最大值。

解析:求解树的直径有两种方法:第一种为二次 DFS 法,先任取一个点找到最长路,然后对最长路的那个端点再找其最长路,即可得到直径的两个端点;第二种为树形 DP 法,任取一个点作为根,求出每个点向下的最长路径和次长路径,相加即为答案。

对于仙人掌,先构建对应的圆方树,然后使用上面提到的方法(这里采用树形 DP)同样对圆点和方点分类讨论:

  1. 当前点为圆点,那么按照正常方法即可;
  2. 当前点为方点,则枚举两个非头点,对应的贡献为两点深度之和加上它们之间的距离。

如果方点按照这样做时间复杂度比较大,考虑进行优化。由于本题中边权均为 \(1\),所以枚举非头点时,所走的距离一定小于等于环长的一半。因此,枚举时先固定一个点,然后找到能够到达更短的位置。为了能够枚举到每个情况,将环破成链,然后再利用单调队列把找最小值的过程优化到 \(O(n)\)

int dfs(int u) {
    static int Q[maxn];
    int head = 0, tail = -1;
    int d1 = 0, d2 = 0;
    for (int i = head2[u]; ~i; i = nxt[i]) {
        int v = to[i], t = dfs(v) + val[i];
        if (t >= d1) d2 = d1, d1 = t;
        else d2 = max(d2, t);
    }
    maxDep[u] = d1;
    if (u <= n) ans = max(ans, d1 + d2); //所选点为圆点,直接计算
    else { //所选点是方点
        int size = 0;
        dep[size++] = -inf;
        for (int i = head2[u]; ~i; i = nxt[i]) dep[size++] = maxDep[to[i]]; //记录环的长度并更新环的深度
        for (int i = 0; i < size; i++) dep[size + i] = dep[i];
        for (int i = 0; i < size * 2; i++) { //单调队列找最小值
            if (head <= tail && i - Q[head] > size / 2) head++;
            if (head <= tail) ans = max(ans, dep[i] + i + dep[Q[head]] - Q[head]);
            while (head <= tail && dep[Q[tail]] - Q[tail] <= dep[i] - i) tail--;
            Q[++tail] = i;
        }
    }
    return maxDep[u];
}

int main(void) {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);
    memset(head1, -1, sizeof(head1));
    memset(head2, -1, sizeof(head2));
    cin >> n >> m;
    sign = n;
    for (int i = 1; i <= m; i++) {
        int k, u, v;
        cin >> k >> u;
        for (int j = 1; j <= k - 1; j++) {
            cin >> v;
            add(head1, u, v, 1), add(head1, v, u, 1);
            u = v;
        }
    }
    tarjan(1, -1);
    dfs(1);
    cout << ans;
    return 0;
}
posted @ 2022-09-25 22:10  秋泉こあい  阅读(43)  评论(0编辑  收藏  举报