学习笔记:图论算法

参考资料

  1. 高级图论 - qAlex_Weiq - 博客园
  2. 简单树论 - qAlex_Weiq - 博客园

2-SAT

SAT 的定义

该部分与 OI 没有太大关系,不感兴趣的读者可以跳过。

为方便说明,首先给出相关术语。

  • 合取:用符号 表示,是自然语言联结词 “且” 的抽象。命题 pq 表示 p,q合取,称为合取式,读作 pq,其为真当且仅当 p,q 均为真。简单地说,合取就是逻辑与 &&,可以类比计算机科学中的按位与 &,相信这个概念大家并不陌生。
  • 析取:用符号 表示,是自然语言联结词 “或” 的抽象。命题 pq 表示 p,q析取,称为析取式,读作 pq,其为真当且仅当 p,q 至少有一个为真。同样的,合取是逻辑或 ||,类比按位或 |
  • 否定:用符号 ¬ 表示,¬p 表示命题 p否定,其真假性与 p 相反。否定是逻辑非 !,类比按位取反 ~

上述三条概念均为基本命题联结词,大概可以看作给常见的 “与或非” 三种运算起了高大上的名字。将命题变元(可真可假的布尔变量)用合取 ,析取 和否定 ¬ 联结在一起,得到布尔逻辑式(叫法不唯一)。

布尔逻辑式可满足,指存在一个对所有命题变元的真假赋值,使得该布尔逻辑式为真。

布尔可满足性问题(Boolean Satisfiability Problem)简称 SAT,它定义为检查一个布尔逻辑式是否可满足,是第一个被证明的 NPC 问题。

  • 命题变元或其否定称为文字。
  • 若干个文字的析取称为简单析取式,形如 P=x1x2xk,其中 xi 表示命题 pi 或其否定 ¬pi
  • 若干简单析取式的合取称为合取范式(Conjunctive Normal Form,CNF),形如 P1P2Pn,其中 Pi 表示一个简单析取式。

考虑 SAT 的简单版本:命题公式为合取范式,且组成它的每个简单析取式至多含有 k 个文字。这一问题称为 k-SAT。

k3k-SAT 是 NPC 问题,但 2-SAT 存在多项式复杂度的解法。接下来介绍这一算法。

2-SAT 算法

我们可以将 2-SAT 看作一组需要同时为真的条件,每个条件可能的形态为:p¬ppqp¬q¬p¬q

注意到每个条件至多与两个文字(布尔变量)有关,这启发我们转化为图论问题。

可以用一条有向边 pq 表示 “若 pq” 的条件。那么有:

条件(简单析取式) 自然语言 充分条件形式命题
p p 为真 ¬pp
¬p p 为假 p¬p
pq pq 至少有一个为真 ¬pq,若 ¬qp
p¬q p¬q 至少有一个为真 ¬p¬q,若 qp
¬p¬q ¬p¬q 至少有一个为真 p¬q,若 q¬p

注意命题的 逆否命题 也成立。

我们对每个布尔变量 p 在图中建出两个点表示 p¬p。根据所有条件建出有向图后,一条路径 pq 就表示命题 pq

首先我们考虑无解的情况。根据第一、二种条件,如果存在路径 ¬ppp 一定为真,如果存在路径 p¬pp 一定为假。进一步地,若 p¬p 强连通,则该 2-SAT 问题无解。于是 缩点 即可判断无解的充分条件。

除此之外,我们考虑是否一定有解。对于一个变元 p,布尔式 p¬p 一定有且仅有一个为真,对应变元 p 为真或假。因此,在图中,如果我们设 P 为 我们钦定布尔值为真的 n 个节点 构成的集合,那么一定不存在某个 p0¬p0,同时能被 P 中任意的某些点到达。所以,P 中所有点能到达的点恰有 n 个,即 P 导出的子图是一个闭合子图。也就是说,如果 pp 为真,那么 p 一定不可能到达 ¬p,反之亦然。

接下来尝试构造一组解。对于缩点后的图,若 ¬p 能到达 p,即 p 的拓扑序一定在 ¬p 后面,则 p 为真。所以对于每个布尔变元 p,可以考虑选择 p¬p 中拓扑序较大的一个设为真。

证明 反证法。假设存在 pq 满足拓扑序 p>¬p,q>¬q(即按照上面的规则 p,q 为真),而 p 能到达 ¬q,即 p¬q。那么它的逆否命题成立,即 q¬p。考虑拓扑序大小,有 p<¬q<q,且 q<¬p<p,矛盾。于是选择拓扑序较大的设为真,一定不存在不合法的情况。

因此,解一定存在,并且我们可以构造出某一组合法的解,构造方法即为:将 p¬p拓扑序较大 的设为真。

注意到在用 Tarjan 算法缩点的过程中,我们已经得到了缩点后 DAG 的反向拓扑序:若 u 能到达 v,则 vu 之前从栈中被弹出,即 SCC 的编号 sccid[v] < sccid[u],因此我们只需要在 p¬p 中选择 sccid 较小的设为真。

时间复杂度 O(n+m)

例题

P4782 【模板】2-SAT 问题

#include <stack>
#include <iostream>
#define f(x, y, z) for (int x = (y); x <= (z); ++x)
using namespace std;
int constexpr N = 2e6 + 10;
int n, m, ans[N >> 1];

int head[N], cnt;
struct Edge {
    int to, nxt;
} e[N << 1];
inline void add(int from, int to) {
    e[++cnt].to = to, e[cnt].nxt = head[from], head[from] = cnt;
    return;
}

int dfn[N], low[N], tot;
int sccid[N], num;
stack<int> st;
bool in[N];
void Tarjan(int u) {
    dfn[u] = low[u] = ++tot;
    st.push(u), in[u] = true;
    for (int i = head[u]; i; i = e[i].nxt) {
        int v = e[i].to;
        if (!dfn[v]) {
            Tarjan(v);
            low[u] = min(low[u], low[v]);
        } else if (in[v])
            low[u] = min(low[u], dfn[v]);
    }
    if (dfn[u] == low[u]) {
        ++num;
        while (!st.empty()) {
            int t = st.top();
            st.pop(), in[t] = false;
            sccid[t] = num;
            if (t == u) break;
        }
    }
    return;
}

signed main() {
    ios::sync_with_stdio(false);
    cin.tie(0), cout.tie(0);
    
    cin >> n >> m;
    int x, y, a, b;
    f(i, 1, m) {
        cin >> x >> a >> y >> b;
        add(x + n * a, y + n * (!b));
        add(y + n * b, x + n * (!a));
    }
    f(i, 1, n << 1) if (!dfn[i]) Tarjan(i);
    f(i, 1, n) {
        if (sccid[i] == sccid[i + n]) return cout << "IMPOSSIBLE\n", 0;
        ans[i] = (sccid[i] < sccid[i + n]);
    }
    cout << "POSSIBLE\n";
    f(i, 1, n) cout << ans[i] << ' ';
    
    return 0;
}

LOJ 3629 「2021 集训队互测」序列

有一个长度为 n 的序列 a1,a2,,an,序列里的每个元素都是 [1,109] 内的正整数。

现在已知了 m 条信息,每条信息形如 i,j,k,x,表示这个序列满足 ai+aj+akmax(ai,aj,ak)min(ai,aj,ak)=x

请构造一个满足条件的序列。

对于全部数据,1n,m105,1i,j,kn,1x109

首先把条件转化一下,可以发现这个条件的意思就是 {ai,aj,ak} 的中位数等于 x

注意到题目的标签中有 2-SAT, 注意到这个题出现在 2-SAT 专题中,

那么我们怎么把条件转化为 2-SAT 中的 二元条件 呢?

不妨先设 ai<x,那么有 ajxakx。同理有 aj<xaix,akxak<xaix,ajx

我们设布尔变元 p(i,x)=[aix]。那么有

¬p(i,x)p(j,x),p(k,x),¬p(j,x)p(i,x),p(k,x),¬p(k,x)p(i,x),p(j,x).

按 2-SAT 算法建边即可。

但是注意有 隐含条件:如果 aix1,并且 x1>x2,那么 aix2

所以对于每一组相邻的 x1,x2,有 p(i,x1)p(i,x2)。“相邻的” 是指在 x 的离散化数组中相邻。不需要对于每一组 x1,x2 都建边,因为可以推导过去(在图上就体现为能到达)。

执行完 2-SAT 算法之后,我们得到了一组 p 的值。对于一个 ai,二分找出相邻的 s<t 满足 p(ai,s) 为真,p(ai,t) 为假。令 ai=s 即为一组答案。

xbc 到此一游:其实不一定,对于一个 i,可能所有的 x 都满足 p(i,x) 是真的,或者所有都是假的。

原题的条件是要令 ai[1,109],因此,对于一个 i

如果所有 p(i,xj) 都是真的,将 ai 设成 109 即可;

而如果所有 p(i,xj) 都是假的,看这些 x 中有没有出现 1,如果出现 1(即能推出 p(i,1) 为假)问题就无解,否则直接将 ai 设成 1 即可。

代码不会写。

Kruskal 重构树

构建

首先考虑 Kruskal 算法的实现过程:按边权从小到大遍历每条边 (u,v),根据贪心思想,如果 uv 当前未通过边权更小的边连通,就将当前边加入最小生成树,并且连接 uv。使用并查集维护连通性。

但是有时候,我们会遇到有 边权限制 的问题,并且数据范围不允许暴力跑多次 Kruskal。

注意到,Kruskal 越往后加的边权越大。所以可以用某种数据结构维护加入边的顺序,以刻画边权有限制时图的连通情况。

Kruskal 重构树 的构建过程:

连接 u,v 时,找到当前 u,v代表元 U,V。新建节点 c,在重构树 T 上设 cU,V 的父亲。注意 U,V 可能不是原图中的节点,而是设置的虚点。

通常设 c权值 wc=wu,v。为虚点设置权值方便解题,巧妙设置点权对解题有极大帮助。

性质

设原图 G 的 Kruskal 重构树为 T。Kruskal 重构树有很多优秀性质:

  1. T 是一棵二叉树。对于部分题目,特殊的重构树建法可以有效减小常数。
  2. T叶子 对应于 G 的所有 n 个节点,T 的其他节点对应于 G 的 MST 的 n1 条边。T 共有 2n1 个节点。
  3. 对于任意 T 中节点 uvu祖先,则有 wuwv。即父亲权值大于儿子权值。

其中,性质 3 尤其重要。它是 Kruskal 重构树的核心:

如果边权限制 d,那么节点 x 可达的所有点就是它在 T 上的权值 d 的最浅祖先 a 的子树内的所有叶子节点。

于是我们可以倍增求解 a

综上,我们可以总结出一个常用套路:当题目限制形如 “只经过权值不大于某个值的点或边” 时,从 Kruskal 重构树角度入手。

部分题目也可以在 Kruskal 过程中将并查集可持久化,以刻画边权有限制时图的连通情况,但时空复杂度更劣,不建议使用。

例题

P4768 [NOI2018] 归程

T 组数据。每组数据给定一张 n 个点 m 条边的无向图,边有两个参数:长度 l海拔 a。每组数据 Q 次询问,每次询问给定出发点 v水位线 p,其中海拔不超过水位线的边有积水,从出发点 v 可以驾车通过任意没有积水的边,之后步行到达节点 1。每次询问输出最小的步行经过的边的总长度。部分测试点强制在线。

n2×105m4×105Q4×105,可能的最高水位线 S109。对于所有边,1l1041a109

di 为从点 i 到点 1 的最短路长度,V 为从点 v 驾车能到达的所有点,那么答案即为 miniVdi

驾车能通过的边即为海拔 a 大于水位线 p 的边。

考虑用 Kruskal 重构树快速查找从点 v 出发,只经过海拔大于 p 的边能到达的所有点。

具体地,将边按 a 从大到小排序(海拔越大越不可能有积水),建出 Kruskal 重构树 T,从点 v 对应的叶子节点向上倍增,找到深度最浅的祖先 A 满足海拔大于 p。那么以 A 为根的子树内所有的叶子节点都可以到达。dfs 处理倍增数组的同时,记录子树内叶子节点的 d 的最小值,即可快速得到 A 的答案。

注意数组范围的细节。

#include <queue>
#include <vector>
#include <cstring>
#include <iostream>
#include <algorithm>
#define f(x, y, z) for (int x = (y); x <= (z); ++x)
#define g(x, y, z) for (int x = (y); x >= (z); --x)
using namespace std;
typedef pair<int, int> pii;
int constexpr N = 2e5 + 10, M = 4e5 + 10;
int T, n, m, lg, Q, K, S;

vector<pii> e[N];

struct Edge {
    int u, v, a, l;
    inline void read() {
        cin >> u >> v >> l >> a;
        e[u].emplace_back(v, l);
        e[v].emplace_back(u, l);
        return;
    }
} edge[M], tree[N << 1];

int L[N << 1], R[N << 1], num;

int fa[N << 1];
int getfa(int x) { return x == fa[x] ? x : fa[x] = getfa(fa[x]); }
inline bool cmp(Edge const &_1, Edge const &_2) { return _1.a > _2.a; }
void Kruskal() {
    num = n;
    f(i, 1, n) fa[i] = i, L[i] = R[i] = 0;
    f(i, n + 1, n * 2 - 1) fa[i] = i;
    sort(edge + 1, edge + m + 1, cmp);
    int cnt = 0;
    f(i, 1, m) {
        int fu = getfa(edge[i].u), fv = getfa(edge[i].v);
        if (fu ^ fv) {
            ++num;
            L[num] = fu, R[num] = fv;
            fa[fu] = fa[fv] = num;
            tree[num].a = edge[i].a;
            if (++cnt == n - 1) break;
        }
    }
    return;
}

priority_queue<pii> q;
bool vis[N];
int d[N];
void Dijkstra(int s = 1) {
    memset(d, 0x3f, sizeof d);
    memset(vis, 0, sizeof vis);
    d[s] = 0;
    q.emplace(0, s);
    while (!q.empty()) {
        int u = q.top().second;
        q.pop();
        if (vis[u]) continue;
        vis[u] = true;
        for (pii i: e[u]) {
            int v = i.first, w = i.second;
            if (d[v] > d[u] + w) {
                d[v] = d[u] + w;
                q.emplace(-d[v], v);
            }
        }
    }
    return;
}

int f[19][N << 1], mn[N << 1];
void dfs(int u, int fa) {
    f[0][u] = fa;
    f(i, 1, lg) f[i][u] = f[i - 1][f[i - 1][u]];
    if (!L[u] && !R[u]) return mn[u] = d[u], void();
    dfs(L[u], u), dfs(R[u], u);
    mn[u] = min(mn[L[u]], mn[R[u]]);
    return;
}

int query(int v, int p) {
    g(i, lg, 0) if (f[i][v] && tree[f[i][v]].a > p) v = f[i][v];
    return mn[v];
}

void Init() {
    f(i, 1, n) e[i].clear();
    return;
}

void solve() {
    Init();
    cin >> n >> m;
    lg = __builtin_log2(n);
    f(i, 1, m) edge[i].read();
    Kruskal();
    Dijkstra();
    dfs(num, 0);
    cin >> Q >> K >> S;
    int v0, p0, v, p, ans = 0;
    while (Q--) {
        cin >> v0 >> p0;
        v = (v0 + K * ans - 1) % n + 1;
        p = (p0 + K * ans) % (S + 1);
        cout << (ans = query(v, p)) << '\n';
    }
    return;
}

signed main() {
    ios::sync_with_stdio(false);
    cin.tie(0), cout.tie(0);
    
    cin >> T;
    while (T--) solve();
    
    return 0;
}

P4899 [IOI2018] werewolf 狼人

给定一张 N 个点 M 条边的无向图,点的编号从 0N1Q 次询问,每次询问给定 S,E,L,R,问能否从 S 到达 E,且路径满足如下限制:

  • 把整条路径分成三段,第一段必须避开点 0,1,,L1,第二段为 L,L+1,,R 中的某一个点,第三段必须避开点 R+1,R+2,,N1

2N200000N1M4000001Q200000

下面将节点编号、LR 加一。

题目意思其实就是:从 S 出发只能经过编号 [L,N] 范围内的点,能到达的点集设为 V1;从 E 出发只能经过编号在 [1,R] 范围内的点,能到达的点集设为 V2。问是否存在点同时包含于 V1V2

我们把编号看作点的权值(其实原题的题面中有提示),那么题意就转化为:限制经过点的点权,求从某点出发能到达的点的集合,然后再求两个这样的集合是否有交集。这样就可以考虑用 Kruskal 重构树解决。

题目中分别限制了权值不小于 L 和权值不大于 R,于是我们建出两棵 Kruskal 重构树 T1,T2,设边 (u,v) 的权值分别为 min(u,v)max(u,v),分别按权从大到小从小到大排序并建树。

T1 上从 S 向上倍增,容易求出权值不小于 LS 的最浅祖先 A1,那么从 S 出发经过点权不小于 L 的点能到达的所有点的集合 V1,即为 T1 上以 A1 为根的子树中的所有叶子节点。同理求出从 E 出发经过点权不大于 R 的点能到达的所有点的集合 V2

接下来的问题就只剩下如何求 V1V2 是否有交。

T1 的所有叶子按 dfn 序从小到大排序,得到的是一个 1N 的排列,记为 p1

由于我们求得的是一棵子树内的所有叶子,这些叶子在 p1 上必然是连续的一段。

同理,将 T2 的所有叶子按 dfn 序从小到大排序得到 p2,那么我们求得的叶子在 p2 上也是连续的一段。

于是就变成了:给两个 1N 的排列 p1,p2,多次询问集合 {p1l1,p1l1+1,,p1r1}{p2l2,p2l2+1,,p2r2} 是否有交。

这是一个二维数点问题,由于这是两个排列,我们把每个数 xp1p2 中出现的位置记为 (ax,bx),那么询问的就是在二维平面上(l1,l2),(r1,r2) 为左下角和右上角的矩形内是否有点。

洛谷上这题没有强制在线,我们可以把询问离线下来,然后用树状数组实现二维数点。

如果强制在线,就把所有 xax 排序,对维护 b 的权值线段树进行可持久化,询问差分权值线段树 Tr1Tl11[l2,r2] 范围内的 sum 是否大于 0

#include <iostream>
#include <algorithm>
#define f(x, y, z) for (int x = (y); x <= (z); ++x)
#define g(x, y, z) for (int x = (y); x >= (z); --x)
using namespace std;
int constexpr N = 2e5 + 10, M = 4e5 + 10;
int n, m, qq, lg;

struct Edge {
    int u, v, mn, mx;
} edge[M];

inline bool cmp1(Edge const &p, Edge const &q) {
    return p.mn > q.mn;
}
inline bool cmp2(Edge const &p, Edge const &q) {
    return p.mx < q.mx;
}

struct Kruskal { //重构树
    int L[N << 1], R[N << 1], num, val[N << 1];
    bool fl;

    int f[20][N << 1];
    int a[N], l[N << 1], r[N << 1], tot;
    void dfs(int u, int fa) {
        f[0][u] = fa;
        f(i, 1, lg) f[i][u] = f[i - 1][f[i - 1][u]];
        if (!L[u] && !R[u]) {
            a[++tot] = u; //将所有叶子拍平在序列上(按 dfn 序)
            l[u] = r[u] = tot;
            return;
        }
        dfs(L[u], u), dfs(R[u], u);
        l[u] = l[L[u]], r[u] = r[R[u]]; //子树内叶子在序列上的最小和最大编号
        return;
    }

    int fa[N << 1];
    int getfa(int x) { return x == fa[x] ? x : fa[x] = getfa(fa[x]); }
    void build() {
        num = n;
        f(i, 1, n) fa[i] = val[i] = i, L[i] = R[i] = 0;
        f(i, n + 1, n << 1) fa[i] = i;
        sort(edge + 1, edge + m + 1, fl ? cmp2 : cmp1);
        int cnt = 0;
        f(i, 1, m) {
            int fu = getfa(edge[i].u), fv = getfa(edge[i].v);
            if (fu ^ fv) {
                ++num;
                val[num] = fl ? edge[i].mx : edge[i].mn;
                // val[num] = fl ? max(val[fu], val[fv]) : min(val[fu], val[fv]);
                L[num] = fu, R[num] = fv;
                fa[fu] = fa[fv] = num;
                if (++cnt == n - 1) break;
            }
        }
        dfs(num, 0);
        return;
    }

} kk1, kk2;

struct Node { //平面上的点
    int a, b;
    inline bool operator<(Node const &o) const {
        return a == o.a ? b < o.b : a < o.a;
    }
} node[N];

int c;
struct Point { //二维数点的询问点
    int x, y, c, id;
    Point() {}
    Point(int _x, int _y, int _c, int _id): x(_x), y(_y), c(_c), id(_id) {}
    inline bool operator<(Point const &o) const {
        return x == o.x ? y < o.y : x < o.x;
    }
} q[N << 2];

struct BIT {
    int c[N];
    inline int lb(int const &x) { return x & (-x); }
    void add(int x) { while (x <= n) ++c[x], x += lb(x); };
    int sum(int x) { int r = 0; while (x) r += c[x], x -= lb(x); return r; }
} T;

struct Query { //离线询问
    int S, E, L, R, ans;
    inline void read(int id) {
        cin >> S >> E >> L >> R;
        ++S, ++E, ++L, ++R;
        g(i, lg, 0) {
            int t = kk1.f[i][S];
            if (t && kk1.val[t] >= L) S = t;
        }
        int l1 = kk1.l[S], l2 = kk1.r[S];
        g(i, lg, 0) {
            int t = kk2.f[i][E];
            if (t && kk2.val[t] <= R) E = t;
        }
        int r1 = kk2.l[E], r2 = kk2.r[E];
        q[++c] = Point(l1 - 1, r1 - 1, 1, id);
        q[++c] = Point(l1 - 1, r2, -1, id);
        q[++ c] = Point(l2, r1 - 1, -1, id);
        q[++c] = Point(l2, r2, 1, id);
        return;
    }
} Q[N];
int pos[N];

void solve() {
    sort(q + 1, q + c + 1);
    sort(node + 1, node + n + 1);
    int cur = 1;
    f(i, 1, c) {
        while (cur <= n && node[cur].a <= q[i].x)
            T.add(node[cur].b), ++cur;
        Q[q[i].id].ans += T.sum(q[i].y) * q[i].c;
    }
    return;
}

signed main() {
    ios::sync_with_stdio(false);
    cin.tie(0), cout.tie(0);
    
    kk1.fl = 0, kk2.fl = 1;
    cin >> n >> m >> qq;
    lg = __builtin_log2(n);
    f(i, 1, m) {
        cin >> edge[i].u >> edge[i].v;
        ++edge[i].u, ++edge[i].v;
        edge[i].mn = min(edge[i].u, edge[i].v);
        edge[i].mx = max(edge[i].u, edge[i].v);
    }
    kk1.build(), kk2.build();
    f(i, 1, n) node[kk1.a[i]].a = node[kk2.a[i]].b = i;
    f(i, 1, qq) Q[i].read(i);
    solve();
    f(i, 1, qq) cout << (Q[i].ans ? 1 : 0) << '\n';
    
    return 0;
}

同余最短路

广义圆方树

广义圆方树是刻画图上点的必经性的强力工具。它可以描述原图任意两点之间的所有割点,即 uv 的所有必经点。

定义

在图论中,我们一般用「圆点」代表原图中的点,「方点」代表新建的虚点。

对于原图中的每一个点双连通分量 S,新建一个代表 S 的方点连向 S 中的所有点。

每个点双缩成一张菊花图,多个菊花图通过原图中的割点相连,因为点双的分隔点是割点。如下图(来自 WC 2017 课件)。

img

这样,方点和圆点就会形成一个树形结构,称为(广义)圆方树

  • 实际上,如果原图有 k 个连通分量,则它的圆方树也会形成 k 棵树形成的森林。

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

构建

对 Tarjan 算法求点双连通分量的过程稍作修改即可。

具体地,在 Tarjan 过程中,对于当前节点 u,如果发现存在子节点 v 满足 low(v)=dfn(u),说明 u 是割点,栈中 v 子树中的点以及 u 都在这个边双中。注意不能弹出 u,因为 u 和别的儿子可能形成其他的点双。

代码:(注意,与新图有关的变量需要开二倍

vector<int> e[N], g[N << 1];
inline void add(int u, int v) { g[u].push_back(v), g[v].push_back(u); }
int dfn[N], low[N], tot;
stack<int> st;
int num = n;
void Tarjan(int u) {
    dfn[u] = low[u] = ++tot;
    st.push(u);
    for (int v: e[u]) {
        if (!dfn[v]) {
            Tarjan(v);
            low[u] = min(low[u], low[v]);
            if (low[v] == dfn[u]) {
                add(u, ++num);
                while (!st.empty) {
                    int t = st.top(); st.pop();
                    add(t, num);
                    if (t == v) break;
                }
            }
        } else low[u] = min(low[u], dfn[v]);
    }
    return;
}

性质

前面讲到,圆方树是一种可以刻画必经性的结构。有引理 1:

引理 1 若 cx,y 点双连通,但 x,y 不点双连通,那么 cx,y 间路径的必经点。

证明:考虑 yx 的两条仅在端点处相交的路径 P1,P2 以及 xz 的两条仅在端点处相交的路径 P3,P4P1P3P2P4 使得 y,z 之间只有 x 可能是必经点,再根据 y,z 不点双连通,得证。

性质 1 圆点 c 的度数等于包含 c 的点双个数。

性质 2 圆方树上圆、方点相间。即圆方树上的一条边的两个端点必然是一圆点一方点。

上面两个性质由圆方树的构建方式,显然成立。

性质 3 原图上如果存在一条边 (u,v),那么 u,v 属于同一个点双,即在圆方树上 uv 之间有一个方点。

一条边的两个端点 u,v 点双连通。

性质 4 圆点 c 是叶子节点当且仅当 c 在原图中是非割点。

证明:c 是叶子说明它只属于一个点双,因此删掉 c 并不会使原图不连通,所以 c 不是割点。

如果 c 属于至少两个点双,那么存在 x,y 使得 x,c 点双连通、y,c 点双连通,并且 x,y 不点双连通(点双的定义和极大性)。因此 cx,y 间路径的必经点,所以删掉 c 后原图不连通,c 是割点。因此逆否命题成立:c 不是割点 c 只属于一个点双,即 c 是圆方树的叶子。证毕。

性质 5 在圆方树上删除圆点 c 后剩余圆点的连通性 与 在原图中删除点 c 后剩余点的连通性 相同。

证明:因为删去 c 时只删去其邻边,所以只需证明与 c 直接相邻的任意两点 x,y 连通性相等。

x,y,c 在同一点双,则据点双定义原图删去 cx,y 连通,同时圆方树上删去 cx,y 通过方点连通。

c,xc,y 在不同点双 S1,S2,据引理 1 原图删去 cc,y 不连通,同时圆方树上删去 c 后方点 S1 和方点 S2 不连通(cS1,S2 直接相连,且圆方树的形态是树),得出圆方树上删去 cx,y 不连通。得证。

性质 6 圆方树上 x,y 简单路径上的所有圆点恰好是原图中 x,y 间路径的必经点。

证明:设某个圆点 c 在圆方树上 x,y 简单路径上,则圆方树中删去 cx,y 不连通。根据性质 5,原图中删去 cx,y 也不连通。因此原图中 cx,y 间路径的必经点。

总结:若在圆方树上 cx,y 的必经点,则原图 cx,y 的必经点。换言之,圆方树完整地保留了原图的必经性

例题

P5058 [ZJOI2004]嗅探器

a,b 两点间路径的编号最小的必经点(不算 a,b 本身),无解输出 No solution1n2×105,边数 m5×105

建出圆方树,找出 a,b​ 间简单路径上编号最小的圆点即可。无解当且仅当 a,b 属于同一点双。

具体地,在圆方树上 DFS,过程中记下所有点的父亲,以及所有点的深度,然后类似倍增求 LCA,从 a,b 分别向上走,并且更新答案即可。注意 u,v 本身不算。时间 O(n+m)

#include <stack>
#include <vector>
#include <iostream>
#define f(x, y, z) for (int x = (y); x <= (z); ++x)
using namespace std;
int constexpr N = 2e5 + 10;
int n, num, dfn[N], low[N], tot, fa[N << 1], dep[N << 1], ans;
vector<int> edge[N], g[N << 1];
inline void add(int u, int v) { g[u].push_back(v), g[v].push_back(u); }
stack<int> st;

void Tarjan(int u) {
    dfn[u] = low[u] = ++tot;
    st.push(u);
    for (int v: edge[u]) {
        if (!dfn[v]) {
            Tarjan(v);
            low[u] = min(low[u], low[v]);
            if (low[v] == dfn[u]) {
                add(u, ++num);
                while (!st.empty()) {
                    int t = st.top();
                    st.pop();
                    add(t, num);
                    if (t == v) break;
                }
            }
        } else low[u] = min(low[u], dfn[v]);
    }
    return;
}

void dfs(int u, int f) {
    fa[u] = f, dep[u] = dep[f] + 1;
    for (int v: g[u]) if (v ^ f) dfs(v, u);
    return;
}

signed main() {
    cin >> n; num = n, ans = n + 1;
    int u, v;
    while (cin >> u >> v) {
        if (!u && !v) break;
        edge[u].push_back(v);
        edge[v].push_back(u);
    }
    Tarjan(1);
    cin >> u >> v;
    dfs(1, 0);
    if (dep[u] < dep[v]) swap(u, v);
    while (dep[u] > dep[v]) if ((u = fa[u]) ^ v) ans = min(ans, u);
    while (u ^ v) ans = min(ans, min(u = fa[u], v = fa[v]));
    if (ans > n) cout << "No solution\n";
    else cout << ans << '\n';
    return 0;
}

*P4630 [APIO2018] 铁人两项

给定一张无向图(不保证连通),求总共有多少种不同的选取两两不同的 s,c,f 的方案,使得存在简单路径 scf,即 csf 的途径点。

n105m2×105

有一个点双的性质:

一个点双内,任何一个三元互异点对 (s,c,f) 都满足存在一条 sf 的路径经过 c

证明要用到最大流最小割定理,这里忽略(我不会),感性理解即可。证明参见 圆方树学习笔记 - 小粉兔 的博客 - 洛谷博客

由圆方树的构建方式,我们知道,方点 S 的度数即为边双连通分量 S 的所包含的节点数。

考虑圆方树上从 sf 的路径,一定是这样的形式:

sS1C1S2C2Skf,

其中 Si 表示方点,Ci 表示圆点。那么从前一个圆点到后一个圆点,之间可以选的途径点即为中间的方点所包含的节点数减去 2(两端的圆点)。

因此,对于确定的 s,f,可以选的途径点即为圆方树上 sf 的路径上所有方点的度数之和减去圆点个数。

那么我们把方点的权值设为其度数(点双大小),把圆点的权值设为 1,计算整条路径上所有点的权值之和即可。

原问题为求所有有序圆点对的贡献之和。转换贡献方式,我们考虑每个点的贡献。

设点 u 有子树 v1,,vk,那么答案为

uvalu×(sizu×(nsizu)+i=1ksizvi×(nsizvi)+[u ]×1×(n1)),

其中 valx 表示节点 x 的权值,sizx 表示以 x 为根的子树中圆点个数。

原图可能不连通,所以上式的 n 要改成 u 所在的原图的连通分量大小。

时间复杂度线性。

#include <stack>
#include <vector>
#include <iostream>
#define f(x, y, z) for (int x = (y); x <= (z); ++x)
using namespace std;
typedef long long ll;
int constexpr N = 1e5 + 10;
int n, m, num;
vector<int> e[N], g[N << 1];
inline void add(int u, int v) { g[u].push_back(v), g[v].push_back(u); }

int dfn[N], low[N], tot;
stack<int> st;
void Tarjan(int u) {
    dfn[u] = low[u] = ++tot;
    st.push(u);
    for (int v: e[u]) {
        if (!dfn[v]) {
            Tarjan(v);
            low[u] = min(low[u], low[v]);
            if (low[v] == dfn[u]) {
                add(u, ++num);
                while (!st.empty()) {
                    int t = st.top(); st.pop();
                    add(t, num);
                    if (t == v) break;
                }
            }
        } else low[u] = min(low[u], dfn[v]);
    }
    return;
}

ll ans;
int sz, siz[N << 1];
void dfs(int u, int fa) {
    siz[u] = (u <= n);
    ll sum = 0;
    for (int v: g[u]) {
        if (v == fa) continue;
        dfs(v, u);
        sum += 1ll * siz[v] * (sz - siz[v]);
        siz[u] += siz[v];
    }
    sum += 1ll * siz[u] * (sz - siz[u]);
    if (u <= n) sum += sz - 1;
    ans += sum * (u <= n ? -1 : g[u].size());
    return;
}

void work(int s) {
    sz = tot;
    Tarjan(s);
    sz = tot - sz;
    dfs(s, 0);
    return;
}

signed main() {
    ios::sync_with_stdio(false);
    cin.tie(0), cout.tie(0);
    
    cin >> n >> m; num = n;
    int u, v;
    f(i, 1, m) {
        cin >> u >> v;
        e[u].push_back(v);
        e[v].push_back(u);
    }
    f(i, 1, n) if (!dfn[i]) work(i);
    cout << ans << '\n';
    
    return 0;
}
posted @   f2021ljh  阅读(87)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 分享一个免费、快速、无限量使用的满血 DeepSeek R1 模型,支持深度思考和联网搜索!
· 使用C#创建一个MCP客户端
· ollama系列1:轻松3步本地部署deepseek,普通电脑可用
· 基于 Docker 搭建 FRP 内网穿透开源项目(很简单哒)
· 按钮权限的设计及实现
点击右上角即可分享
微信分享提示