You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

13 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

【算法学习】圆方树

本知识点,涉及最大流,最小割等知识点,超出目前的通力范围,暂不研究,明年初开发学习进阶课,预计后年开始做周边习题。

写于 2023-07-28

众所周知,树(或森林)有很好的性质,并且容易通过很多常见数据结构维护。

一般图 则没有那么好的性质,所幸有时我们可以 把一般图上的某些问题转化到树上考虑

圆方树 就是一种 将图变成树 的方法。本文将介绍圆方树的构建,性质和一些应用。

限于篇幅,本文中有一些结论未经证明,读者可以自行理解或证明。

一、圆方树的定义

圆方树最初是处理 仙人掌图 (每条边在不超过一个简单环中的无向图)的一种工具,不过发掘它的更多性质,有时我们可以在一般无向图上使用它。

仙人掌图例子

要介绍圆方树,首先要介绍 点双连通分量

一个点双连通图的一个定义是:图中任意两不同点之间都有至少两条点不重复的路径。 点不重复既指路径上点不重复(简单路径),也指两条路径的交集为空(当然,路径必然都经过出发点和到达点,这不在考虑范围内)。

可以发现对于只有一个点的图比较难定义它是不是一个点双,这里先不考虑节点数为 1 的图

一个近乎等价的定义是:不存在割点的图

这个定义只在图中只有两个点,一条连接它们的边时失效。它没有割点,但是并不能找到两条不相交的路径,因为只有一条路径。 (也可以理解为那一条路径可以算两次,的确没有交,因为不经过其他点)

虽然原始的定义的确是前者,但是为了方便,我们规定点双图的定义采用后者。

而一个图的点双连通分量则是一个极大点双连通子图。 与强连通分量等不同,一个点可能属于多个点双,但是一条边属于恰好一个点双(如果定义采用前者则有可能不属于任何点双)。

在圆方树中,原来的每个点对应一个圆点,每一个点双对应一个方点。 所以共有 n+c 个点,其中 n 是原图点数,c 是原图点双连通分量的个数。

而对于每一个点双连通分量,它对应的方点向这个点双连通分量中的每个点连边。 每个点双形成一个 菊花图 ,多个 菊花图 通过原图中的割点连接在一起(因为点双的分隔点是割点)。

显然,圆方树中每条边连接一个圆点和一个方点。

下面有一张图,来自 WCPPT,显示了一张图对应的点双和圆方树形态。

解释 原始无向连通图->划出点双->每个点双对应一个方点->抽象出这个方点->点双中所有点向这个点点连边->将无向图转化为圆方树

圆方树的点数小于 2n,这是因为割点的数量小于 n,所以请注意各种数组大小要开两倍

其实,如果原图连通,则 圆方树 才是一棵树,如果原图有 k 个连通分量,则它的圆方树也会形成 k 棵树形成的森林。

如果原图中某个连通分量只有一个点,则需要具体情况具体分析,我们在后续讨论中不考虑孤立点

二、圆方树的构建

对于一个图,如何构造出它的圆方树呢?首先可以发现如果图不连通,可以拆分成每个连通子图考虑,所以我们只考虑连通图。

因为圆方树是基于点双连通分量的,而点双连通分量又基于割点,所以只需要用类似求割点的方法即可。

求割点的常用算法是 Tarjan 算法,如果你会了理解下面的内容就很简单了,如果你不会也没关系。

我们跳过 Tarjan 求割点,直接介绍圆方树使用的算法(其实是 Tarjan 的变体):

对图进行 DFS,并且中间用到了两个关键数组 dfnlow(类似于 Tarjan)。

  • dfn[u] 存储的是节点 uDFS 序,即第一次访问到 u 时它是第几个被访问的节点。
  • low[u] 存储的是节点 uDFS 树中的子树中的某个点 v 通过最多一次返祖边或向父亲的树边能访问到的点的最小 DFS 序。

如果没有听说过 Tarjan 算法可能会有点难理解,让我们举个例子吧:

(可以发现这张图其实和上面图片中的图等价)

这里树边从上至下用直线画出,返祖边从下至上用曲线画出。节点的编号便是它的 DFS 序。

则有 low 数组如下:

并不是很难理解吧,注意这里 9low7,与一些求割点的做法有差异,因为为了方便,我们规定了可以通过父边向上,但主要思想是相同的。

我们可以很容易地写出计算 dfnlowDFS 函数(初始时 dfn 数组清零):

void Tarjan(int u) {
	low[u] = dfn[u] = ++dfc; // low 初始化为当前节点 dfn
	for (int v : G[u]) {     // 遍历 u 的相邻节点
		if (!dfn[v]) {       // 如果未访问过
			Tarjan(v);       // 递归
			low[u] = min(low[u], low[v]); // 未访问的和 low 取 min
		} else low[u] = min(low[u], dfn[v]); // 已访问的和 dfn 取 min
	}
}

接下来,我们考虑点双和 DFS 树以及这两个数组之间的关联。

可以发现,每个点双在 DFS 树上是一棵连通子树,并至少包含两个点;特别地,最顶端节点仅往下接一个点。

同时还可以发现每条树边恰好在一个点双内。

我们考虑一个点双在 DFS 树中的最顶端节点 u,在 u 处确定这个点双,因为 u 的子树包含了整个点双的信息。

因为至少有两个点,考虑这个点双的下一个点 v,则有 uv 之间存在一条树边。

不难发现,此时一定有 low[v]=dfn[u]

更准确地说,对于一条树边 u→vu,v 在同一个点双中,且 u 是这个点双中深度最浅的节点当且仅当 low[v]=dfn[u]

这并不难处理,我们可以在 DFS 过程中维护一个栈,存储还未确定所属点双(可能有多个)的节点。

在找到点双时,点双中除了 u 以外的其他的点都集中在栈顶端,只需要不断弹栈直到弹出 v 为止即可。

当然,我们可以同时处理被弹出的节点,只要将其和新建的方点连边即可。最后还要让 u 和方点连边。

这样就很自然地完成了圆方树的构建,我们可以 给方点标号为 n+1 开始的整数,这样可以有效区分圆点和方点

这部分可能讲述得不够清晰,下面贴出一份代码,附有详尽注释以及帮助理解的输出语句和一份样例,建议读者复制代码并自行实践理解,毕竟代码才是最能帮助理解的(不要忘记开 c++11

#include <cstdio>
#include <vector>
#include <algorithm>
using namespace std;

const int MN = 100005;

int N, M, cnt;
vector<int> G[MN], T[MN * 2];

int dfn[MN], low[MN], dfc;
int stk[MN], tp;

void Tarjan(int u) {
    printf("  Enter : #%d\n", u);
    low[u] = dfn[u] = ++dfc;              // low 初始化为当前节点 dfn
    stk[++tp] = u;                        // 加入栈中
    for (int v : G[u]) {                  // 遍历 u 的相邻节点
        if (!dfn[v]) {                    // 如果未访问过
            Tarjan(v);                    // 递归
            low[u] = min(low[u], low[v]); // 未访问的和 low 取 min
            if (low[v] == dfn[u]) {       // 标志着找到一个以 u 为根的点双连通分量
                ++cnt;                    // 增加方点个数
                printf("  Found a New BCC #%d.\n", cnt - N);
                // 将点双中除了 u 的点退栈,并在圆方树中连边
                for (int x = 0; x != v; --tp) {
                    x = stk[tp];
                    T[cnt].push_back(x);
                    T[x].push_back(cnt);
                    printf("    BCC #%d has vertex #%d\n", cnt - N, x);
                }
                // 注意 u 自身也要连边(但不退栈)
                T[cnt].push_back(u);
                T[u].push_back(cnt);
                printf("    BCC #%d has vertex #%d\n", cnt - N, u);
            }
        } else
            low[u] = min(low[u], dfn[v]); // 已访问的和 dfn 取 min
    }
    printf("  Exit : #%d : low = %d\n", u, low[u]);
    printf("  Stack:\n    ");
    for (int i = 1; i <= tp; ++i) printf("%d, ", stk[i]);
    puts("");
}

int main() {
#ifndef ONLINE_JUDGE
    freopen("YFS.in", "r", stdin);
#endif
    scanf("%d%d", &N, &M);
    cnt = N; // 点双 / 方点标号从 N 开始
    for (int i = 1; i <= M; ++i) {
        int u, v;
        scanf("%d%d", &u, &v);
        G[u].push_back(v); // 加双向边,无向图
        G[v].push_back(u);
    }
    // 处理非连通图
    for (int u = 1; u <= N; ++u)
        if (!dfn[u]) Tarjan(u), --tp;
    // 注意到退出 Tarjan 时栈中还有一个元素即根,将其退栈
    return 0;
}

注:黄海修改了一个使用链式前向星的版本,也是无向图,只有无向图中才有点双连通分量的概念

#include <bits/stdc++.h>
using namespace std;
const int N = 1e5 + 10, M = N << 2;

int n, m;
int ts, root, dfn[N], low[N], stk[N], top;
vector<int> bcc[N];
int bcc_cnt;

int e[M], h[N], idx, w[M], ne[M];
void add(int a, int b, int c = 0) {
    e[idx] = b, ne[idx] = h[a], w[idx] = c, h[a] = idx++;
}

void tarjan(int u) {
    printf("  Enter : #%d\n", u);
    dfn[u] = low[u] = ++ts;
    stk[++top] = u;
    if (u == root && h[u] == -1) {
        bcc[++bcc_cnt].push_back(u);
        return;
    }
    for (int i = h[u]; ~i; i = ne[i]) {
        int v = e[i];
        if (!dfn[v]) {
            tarjan(v);
            low[u] = min(low[u], low[v]);

            if (low[v] >= dfn[u]) {
                bcc_cnt++;
                printf("  Found a New BCC #%d.\n", bcc_cnt);

                int bcc_id = bcc_cnt + n;
                int x;
                do {
                    x = stk[top--];
                    bcc[bcc_cnt].push_back(x);
                    // 将x向圆方树方格建边
                    add(x, bcc_id);
                    printf("    BCC #%d has vertex #%d\n", bcc_cnt, x);
                } while (x != v);
                bcc[bcc_cnt].push_back(u);
                // 将u向圆方树方格建边
                add(u, bcc_id);
                printf("    BCC #%d has vertex #%d\n", bcc_cnt, u);
            }
        } else
            low[u] = min(low[u], dfn[v]);

        printf("  Exit : #%d : low = %d\n", u, low[u]);
        printf("  Stack:\n    ");
        for (int i = 1; i <= top; ++i) printf("%d, ", stk[i]);
        puts("");
    }
}

int main() {
#ifndef ONLINE_JUDGE
    freopen("YFS.in", "r", stdin);
    freopen("YFS_2.out", "w", stdout);
#endif
    memset(h, -1, sizeof h);
    cin >> n >> m;
    for (int i = 0; i < m; i++) {
        int a, b;
        cin >> a >> b;
        add(a, b), add(b, a);
    }

    for (root = 1; root <= n; root++)
        if (!dfn[root]) tarjan(root);

    // for (int i = 1; i <= bcc_cnt; i++) {
    //     for (int j = 0; j < bcc[i].size(); j++)
    //         printf("%d ", bcc[i][j]);
    //     puts("");
    // }
    return 0;
}

提供一个测试用例:

13 15
1 2
2 3
1 3
3 4
3 5
4 5
5 6
4 6
3 7
3 8
7 8
7 9
10 11
11 10
11 12

这个例子对应的图(包含了重边和孤立点的情况):

三、圆方树的应用

我们讲一些可以使用圆方树求解的例题。

[APIO2018]铁人两项

这题可以作为圆方树模板题看待。