图论 II
1. 2-SAT
前置知识:强连通分量。
2-SAT 是布尔代数领域的经典问题。为了更好地理解相关算法,读者需要掌握一些基本的逻辑概念。
1.1 相关定义
- 合取:合取就是逻辑与
&&
,记为 。 表示 的合取,称为合取式,表达式为真当且仅当 均为真。 - 析取:析取就是逻辑或
||
,记为 。 表示 的析取,称为析取式,表达式为真当且仅当 至少一个为真。 - 否定:否定就是逻辑非
!
,记为 。 表示 的否定,其真假性与 相反。
如何记忆符号才不会混淆呢?
- 布尔逻辑式:将命题变元(要么真要么假的布尔变量)用合取
,析取 和否定 联结在一起,就是 布尔逻辑式。 - 布尔逻辑式 可满足,当且仅当存在一个对所有命题变元的真假赋值,使得该布尔逻辑式为真。例如,逻辑式
是可满足的:令 , , 任取。但逻辑式 是不可满足的。 - SAT:检查一个布尔逻辑式是否可满足称为 布尔可满足性问题,简称 SAT。
为了解决 SAT,我们希望把任意布尔逻辑式转化为标准形式,比如说外层合取内层析取的
- 双重否定:
。 - 结合:
, 。 - 交换:
, 。 - 分配:
, 。 - 反演:
, 。
以上的
首先根据反演律,将所有逻辑非下放到命题变元的前面。再根据双重否定律让每个命题变元前面的逻辑非的个数不超过
- 命题变元或其否定称为 文字。在布尔逻辑式中,一个命题变元
可以以 和 的形式出现多次,每次出现都是一个文字,这些文字的真假由 的真假确定。 - 若干文字的析取称为 简单析取式,形如
,其中 表示命题 或其否定 。 - 若干简单析取式的合取称为 合取范式 (conjunctive normal form, CNF),形如
,其中 表示简单析取式。
类似定义简单合取式与析取范式。根据上述分析,任意布尔逻辑式均可转化为合取范式和析取范式。
检查合取范式是否可满足的问题称为 k-SAT,其中组成它的每个简单析取式至多含有
1.2 算法介绍
考虑一个合取范式。外层的合取要求内层的每个简单析取式都要为真。简单析取式只有五种形态:
这样表示有什么好处呢?注意到若
具体地,对于每个命题
- 形如
: 必须为真。用若 则 限制 为真。 - 形如
: 必须为假。用若 则 限制 为假。 - 形如
: 和 不能同时为假。因此,若 则 ,若 则 。 - 形如
:若 则 ,若 则 。 - 形如
:若 则 ,若 则 。
特别地,第三、四、五条的连边具有对称性,因为若一个命题成立,则其逆否命题成立:若
这样,若命题
首先确定解的形态。对于命题
接下来尝试构造一组解。注意到若
证明
反证法。假设存在
满足 的拓扑序大于 , 的拓扑序大于 ,但是 可达 (某个被选文字的否定形式由另一个被选文字可达是方案不合法的唯一情况),则 的拓扑序大于 。根据命题与其逆否命题的等价性, 可达 ,则 的拓扑序大于 。 可得拓扑序关系:
,矛盾。
一个最简单的例子是上图。绿色是我们选择的点。如果红色边存在,那么红色点和绿色点将导致方案不合法。但根据对称性,一定存在蓝色边,于是
和 在同一个强连通分量,和有解矛盾。
在缩点后的图上拓扑排序。对每个命题相关的两个文字,选择拓扑序较大的为真。因为在 Tarjan 缩点时已经得到了 DAG 的反向拓扑序,只需选择所在 SCC 编号较小的文字赋为真即可。时间复杂度
若要求字典序最值解,可以按位贪心,并检查当前决策是否出现矛盾(一个命题对应的两个文字均由已选择的文字可达)。贪心的局部决策不影响全局合法性,证明是类似的。时间复杂度
模板题 代码。
#include <bits/stdc++.h>
using namespace std;
constexpr int N = 2e6 + 5; // 两倍空间
int cnt, hd[N], nxt[N], to[N];
void add(int u, int v) {nxt[++cnt] = hd[u], hd[u] = cnt, to[cnt] = v;}
int n, m, dn, dfn[N], low[N], top, stc[N], vis[N], cn, col[N];
void tarjan(int id) {
dfn[id] = low[id] = ++dn, vis[id] = 1, stc[++top] = id;
for(int i = hd[id]; i; i = nxt[i]) {
int it = to[i];
if(!dfn[it]) tarjan(it), low[id] = min(low[id], low[it]);
else if(vis[it]) low[id] = min(low[id], dfn[it]);
}
if(low[id] == dfn[id]) {
col[id] = ++cn;
while(stc[top] != id) col[stc[top]] = cn, vis[stc[top--]] = 0;
vis[id] = 0, top--;
}
}
int main() {
cin >> n >> m;
for(int i = 1; i <= m; i++) {
int u, a, v, b;
scanf("%d%d%d%d", &u, &a, &v, &b);
add(u + (!a) * n, v + b * n); // 当 u 等于 !a 时,v 必须等于 b
add(v + (!b) * n, u + a * n);
}
for(int i = 1; i <= n * 2; i++) if(!dfn[i]) tarjan(i);
for(int i = 1; i <= n; i++) if(col[i] == col[i + n]) puts("IMPOSSIBLE"), exit(0);
puts("POSSIBLE");
for(int i = 1; i <= n; i++) putchar('0' + (col[i + n] < col[i])), putchar(' '); // 选 col 较小的
return 0;
}
1.3 例题
P3825 [NOI2017] 游戏
如果没有 x
就是裸的 2-SAT。
注意到 x
的状态:a
或 c
,这保证了任何一种合法解都被考虑到。
时间复杂度
*P6965 [NEERC2016] Binary Code
一个字符串至多含有一个问号,所以状态至多有两种,考虑 2-SAT,设
容易发现,若字符串
刻画前缀关系的结构是字典树。对于若
我们还要处理
综上,点数和边数关于
*[ARC161E] Not Dyed by Majority (Cubic Graph)
好题。
考虑操作前的序列,因为全黑和恰有一白对应相同结果,所以一定有解。
将距离一个点为
随机,判定是否无解是容易的:2-SAT 即可。代码。
2. 点双进阶:广义圆方树
前置知识:Tarjan 求割点。
关于点双相关基础知识,见 “图论基础”。
广义圆方树是刻画无向图上点必经性的强力工具,是用于解决仙人掌上问题的圆方树的扩展,以下简称 圆方树 (block forest)。它是点双缩点的产物,描述了原图任意两点之间的所有割点,即
点双缩点和边双缩点的方法类似,都是借助 Tarjan 算法求出所有连通分量的形态。但是它们缩点得到的结构不同:每个点恰好属于一个边双(相对应地,每条边恰好属于一个点双),所以边双缩点时可以将边双内部的所有点看成一个点。但一个点可能出现在多个点双中(这样的点一定是割点)。而且,为了刻画必经点,我们希望在缩点时保留所有割点,而不能将它们缩起来,就像边双缩点时保留了所有割边一样。
2.1 算法介绍
考察一个点双。如果我们希望将它缩成一棵树,且树上任意两点之间的简单路径恰为它们之间的所有必经点,那么因为点双内不存在必经点,所以任意两点都必须直接相连,但是这和 “缩成一棵树” 矛盾。
尝试建出点双的 “代表点” 并向点双内所有点连边,发现这样形成的菊花图满足条件:任意两点通过代表点间接地直接相连,不经过点双内其它点。经过代表点则可以理解为必须经过这个点双。
这自然地引出了圆方树的定义:将原图的点视为圆点,对于原图的每个点双,删去其中所有边,新建代表该点双的方点连向点双内所有圆点,形成的结构称为圆方树。每个点双缩成一张菊花图,多个菊花图通过原图割点连接(割点是点双的分隔点),类比边双缩点时,每个边双缩成一个点,多个边双通过原图割边连接。
如下图,黑色虚线表示原图的边,每个颜色表示一个点双连通分量,不同颜色的实边表示对应点双连通分量缩成的菊花图。黑色的
圆方树的建法只需在 Tarjan 求割点的基础上稍作修改。从结点
时间复杂度
注意:
- 处理完每个连通块后栈内会剩下一个点,为该连通块 DFS 树的根。
- 每个点双新建一个方点,需要开两倍空间存储圆方树。当图是一张菊花时,点双数量为
。
用该算法求点双连通分量时:
- 与边双缩点和割点判定不同的是,我们不需要将栈内剩下的点单独作为一个点双,也不需要特殊处理根结点。因为在根结点上用非根割点判定法则,每个儿子都会将根判定为割点,这正好是我们想要的:根结点和每个儿子子树在栈内的部分均形成一个点双。因此,不存在没有被考虑到的点双,也没有多余的点双。
- 特判孤立点。
圆方树小技巧:
- 判定一条边
属于哪个点双,也就是求出 之间(它们距离为 )唯一的方点。若 的父亲相同,则方点是它们共同的父亲。若 的父亲不同,则方点是深度较深的结点的父亲。 - 对于仙人掌的每个环,对应方点的所有邻点的顺序就是环上所有点的顺序。这是因为 Tarjan 是通过 DFS 实现的,所以对应方点的加入邻点的顺序一定是从当前点开始绕环一周。
点双缩点 代码。圆方树代码见例题 P5058。
#include <bits/stdc++.h>
using namespace std;
constexpr int N = 5e5 + 5;
int n, m;
vector<int> e[N];
vector<vector<int>> ans;
int dn, dfn[N], low[N], stc[N], top;
void form(int id, int it) {
vector<int> S = {id};
for(int x = 0; x != it; ) S.push_back(x = stc[top--]);
ans.push_back(S);
}
void tarjan(int id) {
dfn[id] = low[id] = ++dn;
stc[++top] = id;
for(int it : e[id]) {
if(!dfn[it]) {
tarjan(it), low[id] = min(low[id], low[it]);
if(low[it] >= dfn[id]) form(id, it); // 写 low[it] == dfn[id] 也可以, 因为 low[it] <= dfn[id]
}
else low[id] = min(low[id], dfn[it]);
}
}
int main() {
ios::sync_with_stdio(0), cin.tie(0), cout.tie(0);
cin >> n >> m;
for(int i = 1; i <= m; i++) {
int u, v;
cin >> u >> v;
if(u == v) continue;
e[u].push_back(v);
e[v].push_back(u);
}
for(int i = 1; i <= n; i++) {
if(e[i].empty()) ans.push_back({i});
else if(!dfn[i]) tarjan(i);
}
cout << ans.size() << "\n";
for(auto S : ans) {
cout << S.size() << " ";
for(int it : S) cout << it << " ";
cout << "\n";
}
return 0;
}
2.2 圆方树的性质
回忆必经点的定义:从
回忆点双的性质:
- 点双交点的唯一性:若两点双有交点,则交点唯一。
- 边的点双连通性:直接相连的两点点双连通。
在图论基础部分,我们给出了点双最重要的基本性质:
对于
的点双内的任意两点 ,存在经过 的长度不小于 的简单环。 等价表述:对于
的点双内的任意两点 ,存在两条端点为 且仅在端点处相交的路径。
在研究圆方树前,给出以下引理:
引理 1
不是 的必经点当且仅当存在不经过 的连接 的路径。 证明
根据必经点的定义,显然。
引理 2
是 的必经点当且仅当删去 后 不连通。 证明
删去
后 连通等价于存在不经过 的连接 的路径,根据引理 1,等价于 不是 的必经点。因此,删去 后 不连通等价于 是 的必经点。 结合割点与点双连通的定义,易知:
推论
点双连通当且仅当不存在必经点 。
引理 3
若
与 均点双连通,但 不点双连通,则 是 的必经点。 证明
考虑包含
的点双 和包含 的点双 。根据点双交点唯一性, 。根据点双基本性质,考虑 的两条仅在端点处相交的路径 和 的两条仅在端点处相交的路径 。 和 两条路径使得 之间只有 可能是必经点,再结合 不点双连通与引理 2 的推论得证。
引理 4
是割点当且仅当 属于至少两个点双。 证明
若
是割点,则删去 后存在与 相邻的 满足 不连通,否则易证整张图仍连通。因此 是 的必经点, 不点双连通。根据边的点双连通性, 点双连通, 点双连通,因此 属于至少两个点双。 若
属于至少两个点双,则根据边的点双连通性,存在 使得 点双连通, 点双连通,但 不点双连通。根据引理 3, 是 的必经点,因此 是割点。
结合上述引理,回忆图的点双结构的形态:点双交点为割点,若干点双由公共割点相连,形成树状结构。在此基础上,得到圆方树的基本结构:
- 圆点
的度数等于包含它的点双个数。 - 方点
的度数等于它对应的点双大小。 - 圆点
不是叶子当且仅当它是原图的割点(引理 4)。 - 圆方树上圆方点相间。
- 连通无向图的圆方树连通(相邻两点点双连通)。
- 原图的每个连通分量对应一棵连通的圆方树。
我们提出圆方树的目的是刻画无向图上点的必经性,可以证明它确实做到了这一点。
性质 1
原图删去
后剩余结点的连通性等于在圆方树上删去 后剩余结点的连通性。 证明
对任意不同于
的两点 以及它们之间的任意一条简单路径 ,若 经过 ,则必然在经过 之前经过 的某个邻居,且在经过 之后经过 的另一个邻居。因此,只需证明 的所有邻居之间的连通性相等。这是证明删点后连通性不变的常用手段。 考虑
的任意两个邻居 ,则 点双连通, 点双连通。
- 若原图删去
后 连通,则 点双连通, 属于同一点双,存在方点 同时连接圆点 ,圆方树删去 后 通过 连通。 - 若原图删去
后 不连通,则存在方点 连接圆点 ,方点 连接圆点 。因此 落在 在圆方树上的简单路径,圆方树删去 后 不连通。
性质 2
圆点
在圆方树上的简单路径上的所有圆点恰为原图 之间的必经点。 证明
若圆点
在圆点 的简单路径上,则根据性质 1,原图删去 后 不连通, 为 的必经点。若 为 的必经点,则原图删去 后 不连通,根据性质 1,圆点 在圆点 的简单路径上。 推论
在性质 2 的基础上,原图
到 的任意简单路径上所有必经点出现的顺序恰为圆方树 到 的唯一的简单路径上所有圆点依次出现的顺序。
这些性质全部在描述一个核心结论:若
圆方树最主要的应用是:当题目只关心无向图必经性时,可以将图上问题转化为树上问题。用圆方树判定必经性(两点之间的边必经性和点必经性均可使用圆方树判定)的方法见例题 P4334。
2.3 例题
P5058 [ZJOI2004] 嗅探器
建出圆方树,那么
如果不存在这样的圆点则无解,此时
时间复杂度
#include <bits/stdc++.h>
using namespace std;
constexpr int N = 4e5 + 5;
int n, a, b, node;
int dn, dfn[N], low[N], top, stc[N];
vector<int> e[N], g[N];
void tarjan(int id) {
dfn[id] = low[id] = ++dn, stc[++top] = id;
for(int it : e[id]) {
if(!dfn[it]) {
tarjan(it), low[id] = min(low[id], low[it]);
if(low[it] == dfn[id]) {
g[++node].push_back(id);
g[id].push_back(node);
for(int x = 0; x != it; ) {
g[node].push_back(x = stc[top--]);
g[x].push_back(node);
}
}
}
else low[id] = min(low[id], dfn[it]);
}
}
int fa[N], dep[N];
void dfs(int id, int ff) {
fa[id] = ff, dep[id] = dep[ff] + 1;
for(int it : g[id]) if(it != ff) dfs(it, id);
}
int main() {
cin >> n, node = n;
scanf("%d%d", &a, &b);
while(a && b) {
e[a].push_back(b), e[b].push_back(a);
scanf("%d%d", &a, &b);
}
tarjan(1), dfs(1, 0);
scanf("%d%d", &a, &b);
int ans = N;
if(dep[a] < dep[b]) swap(a, b);
while(dep[a] > dep[b]) if((a = fa[a]) != b) ans = min(ans, a);
while(a != b) ans = min(ans, min(a = fa[a], b = fa[b]));
if(ans > n) puts("No solution");
else cout << ans << "\n";
return 0;
}
P4334 [COI2007] Policija
对于类型
对于类型
建出圆方树。
对于类型
对于类型
判定某点是否落在两点简单路径上是容易的:若
时间复杂度
P4630 [APIO2018] Duathlon 铁人两项
首先进行转化,对所有点对
由于本题简单路径定义为不经过重复点的路径,且题目考察连通性相关,不难想到建出圆方树。因此相当于求
注意,路径上每个除了
如上图,从最左边的
设每个点的权值为
转换贡献方式,考察圆方树上每个结点对答案的贡献。容易通过一遍 DFS 求出子树大小的同时求解该问题。
注意原图可能不连通。
时间复杂度线性。代码。
CF1763F Edge Queries
建出圆方树,则
对于
视
P4606 [SDOI2018] 战略游戏
删去某结点后
对每个点是否为圆点做树上前缀和,以计算虚树上一条边的贡献(不含两端)。再加上所有不属于
时间复杂度线性对数。代码。
*P3225 [HNOI2012] 矿场搭建
不那么套路的连通性相关题目。
题目希望我们给出一种选择关键点的方式,满足删去任何一个点后形成的每个连通块内都存在至少一个关键点。
首先,关键点数量至少为
进一步地,由于删去非割点后整张图仍连通,所以删去非割点后连通块存在关键点蕴含于删去割点后连通块存在关键点。唯一的特例是不存在割点,整张图点双连通。此时关键点数量最小值显然为
特判后,每个点双至少有一个原图上的割点。
为了解题,我们需要深入剖析圆方树的结构。剔除圆方树上所有叶子,即原图的非割点,我们得到了圆方树的由原图割点和点双方点构成的骨架,称为块割树。
定义一个点双的度数等于它对应的方点在块割树上的度数,等价于该点双包含的原图割点数量。
考虑一个大小为
删去主干树上任何一个割点,形成的每个连通块至少包含一个主干树的叶子,因此方案合法。
综上,令主干树的叶子对应点双大小分别为
时间复杂度
CF487E Tourists
一道圆方树经典题。
问题在于修改点权时可能影响到很多方点权值,无法承受。考虑使用 只维护儿子信息 的技巧,将方点权值改为所有儿子的权值最小值,用 multiset
维护。修改圆点权值时修改其父结点的 multiset
并更新其父结点权值。
查询同样求出
时间复杂度
*P8456 「SWTR-8」地地铁铁
题解。
*P8331 [ZJOI2022] 简单题
因为简单路径不包含重复点,所以建出圆方树。考虑
对每个深度大于
也就是说,我们需要支持:给定点双连通分量,
通过推导后,可以证明若原所有简单环长相等,那么点双只能是 “杏仁”:存在
时间复杂度
3. 连通性进阶:耳分解与双极定向
前置知识:无向图 DFS 树,有向图 DFS 树,双连通分量、圆方树。
一个小众而简单的知识点,有助于加深对 DFS 树的理解。
3.1 相关定义
- 耳:在无向图
上,简单路径或简单环 称为子图 的 耳,若 且 。 - 开耳:在耳的基础上,若
是简单路径( ),则 称为 的 开耳。 - 耳分解:将无向连通图
划分为若干耳的方案称为 耳分解 (ear decomposition)。具体地,耳分解是一系列无向连通图 ,满足 是 添加一个耳,且 是一个点或一个简单环。当所有 是 添加一个开耳时,称为 开耳分解。
注意
类似定义有向图上的耳、开耳和耳分解。
- 双极定向:无向连通图
关于 的 双极定向 (bipolar orientation) 是给所有无向边的一种定向方案,满足定向后的图是 DAG,且 是唯一没有入度的点, 是唯一没有出度的点。因为 是定向的两极,所以称为双极定向。
3.2 耳分解
先考虑什么样的 无向图 存在耳分解。如果一张图存在耳分解,那么每条边恰好属于一个耳。耳是简单路径或简单环,这启发我们考虑双连通图。
不存在含割边的简单环,且含割边的简单路径的两端一定在割边两侧。因为耳分解初始的所有点在割边的同一侧,而如果想包含割边另一侧的点,就必须从割边一侧走过去再走回来,显然不可能,所以 非边双没有耳分解。
3.2.1 边双连通图
在边双连通图上,对每条边,存在经过这条边的简单环。容易想到每次选择一条连接
这样构造比较麻烦,我们需要更简单的方法。图论相关的构造题,首先考虑 DFS 树。
方法一
考虑以
如下图,返祖边由弧线表示。首先根据返祖边
方法二
考虑边双的性质:
考虑
如下图,红色是
最后
两个方法的时间复杂度均为
3.2.2 点双连通图
设
考虑点双的性质:对
相反,如果图上有割点,那么容易证明没有开耳分解。
此时,使用边双连通的耳分解构造方法,就能得到开耳分解:
方法一
到点
特别地,对于根结点
方法二
利用
两个方法的时间复杂度均为
3.2.3 强连通图
对于有向强连通图
在不断加入耳的过程中,
构造方案和边双方法二一样:对所有非根结点
3.3 双极定向
关于双极定向的另一种等价定义,见 3.3.3 小节。
3.3.1 点双连通图
边双不一定有双极定向:
通过开耳分解构造双极定向:以
但是如果直接做则需要维护拓扑序,支持插入以及查询两个数在拓扑序上的前后关系,不能做到线性时间。我们必须快速确定耳的方向。
考虑方法二,则
结论
设树边
,其中 是 的父亲。若定向为 ,则 子树任何点在构造方案的任意时刻不可达 。若定向为 ,则 在构造方案的任意时刻不可达 子树任何点。
若
如下图,红色边
在 DFS 加入每个点的过程中维护根到当前点的路径上每条边的方向,即可
3.3.2 一般无向图
然而,并非只有点双才有双极定向:
建出一般无向图
如果有结点
假设
这等价于
3.3.3 更简单的写法
来自 rainbow_sjy,链接见参考资料部分。考虑点双连通图
能够定向成 DAG 就有拓扑序,而拓扑序意味着什么呢?注意到不可能有未访问的结点指向访问的结点,所以任意时刻已经访问的结点形成含
不要给边定向,而是直接求出这个排序。从
引理
如果一个点
的度数为 ,那么它可以立刻删去。 证明
删去的点形成连通块:因为
点双连通,所以每个点的初始度数不小于 。如果 的度数 ,那么在此之前存在和它相邻的点被删去。 没有删去的点形成连通块:因为
点双连通,所以删去 时剩下的图连通。而删去度数为 的叶子不会影响图的连通性。
由 Tarjan 算法,对每个点只保留最浅的返祖边不影响图的点双连通性。处理过后,DFS 树的叶子的度为
具体怎么实现呢?注意到
考虑到 DFS 序的倒序一定是缩点序,以及一个点被缩掉的时候一定是加入
- 按 DFS 序倒序枚举所有点,如果
不在 到 的树上路径上,那么将 加入 和 的列表。 - 按顺序依次访问
到 的树上路径的所有结点。访问一个点时,按列表顺序递归访问其列表,跳过已经访问的结点。 - 访问顺序即合法拓扑序。
这个做法好写很多。代码见 rainbow_sjy 的博客 的方法 1。
3.4 例题
CF555E Case of Computer Network
由耳分解可知边双可被定向为 SCC。
因此,先对原图边双缩点得到一棵树,若
时间复杂度为
P9394 白鹭兰
考虑
对
- 方点的权值是所有相邻的不在链上的圆点的子树大小的最大值;
- 圆点的权值是所有相邻的不在链上的方点的子树大小和。
方点和圆点都是向大小最大(圆点数量最多)的子树延伸,且链的 LCA 一定向大小最大和次大的子树延伸。
双极定向构造方案,时间复杂度
4. 有向图必经点:支配树
前置知识:有向图 DFS 树。
无向图的必经点由割点和割边刻画,有向图同样存在必经点相关的概念与算法:支配点和支配树。学习一般图支配树能够有效提升图论水平!
4.1 定义与性质
首先明确:接下来的所有探究基于 起点固定 这一前提条件。
对一般有向图
对
和无向图一样,在讨论支配关系时,只考虑 简单路径。如果重复经过一个点,那么将这两次经过之间的点删去。被删去且没有在新路径上出现的点不是支配点。
支配是一种二元关系,我们记作
- 传递性:若
, ,则 的任意路径都经过 ,且 的任意路径都经过 。因此, 在 的任意路径上,即 。 - 自反性:
的任意路径都经过 ,即 。 - 反对称性:若
, ,则 的任意路径都经过 。如果 ,那么存在 的路径不经过 ,和 矛盾。因此 。反对称性 即对称性的反面:对称性要求对于任意 ,若 则 。
满足以上三条性质的关系称为 偏序关系,元素集合和它对应的偏序关系一起称为偏序集。
补充
偏序关系的含义是 “部分” 满足 “序关系”,即局部满足 全序关系。在全序关系
里,元素两两可比较(对 , 或 恰有一个成立)。这种关系抽象成图是一排点,每个点指向它后面的所有点。 直观地,在一个偏序集内部,元素之间通过偏序关系形成很多条链,链与链之间有交叉。在交叉点以外,分别位于两个不同链的元素不能比较。但是单独取出某一条链,上面的元素形成全序集。
对偏序关系建图只能得到 DAG:如果环长大于
性质 1
若
且 ,则 或 。 证明
的情况显然。 因为
且 ,不妨设 在 之前,那么 不支配 。如果在此基础上 不支配 ,那么存在一条 的路径不经过 ,与 矛盾。所以 。
当一个偏序关系满足性质 1 时,就可以用树状结构刻画:考虑支配
设
从
4.2 有向无环图
DAG 无环的特殊性质使得我们能够方便地求出其支配树。
拓扑排序。设当前结点为
当
当
根据上述分析,容易证明:若
因为拓扑序在
模板题 代码。
#include <bits/stdc++.h>
using namespace std;
constexpr int N = 1 << 16;
constexpr int K = 16;
constexpr int M = 1e6 + 5;
int p, topo[N], sz[N];
int n, dep[N], deg[N], anc[K][N];
int cnt, hd[N], nxt[M], to[M];
void add(int u, int v) {nxt[++cnt] = hd[u], hd[u] = cnt, to[cnt] = v;}
int lca(int u, int v) {
if(!u || !v) return u | v;
if(dep[u] < dep[v]) swap(u, v);
for(int i = K - 1; ~i; i--) {
if(dep[anc[i][u]] >= dep[v]) u = anc[i][u];
}
if(u == v) return u;
for(int i = K - 1; ~i; i--) {
if(anc[i][u] != anc[i][v]) {
u = anc[i][u], v = anc[i][v];
}
}
return anc[0][u];
}
int main() {
cin >> n;
for(int i = 1, u; i <= n; i++) {
scanf("%d", &u);
while(u) deg[i]++, add(u, i), scanf("%d", &u);
if(!deg[i]) deg[i] = 1, add(n + 1, i);
}
queue<int> q;
q.push(n + 1), dep[n + 1] = 1;
while(!q.empty()) {
int t = q.front();
q.pop(), topo[++p] = t;
dep[t] = dep[anc[0][t]] + 1;
for(int i = 1; i < K; i++) anc[i][t] = anc[i - 1][anc[i - 1][t]];
for(int i = hd[t]; i; i = nxt[i]) {
int it = to[i];
anc[0][it] = lca(anc[0][it], t);
if(!--deg[it]) q.push(it);
}
}
for(int i = p; i; i--) {
int id = topo[i];
sz[anc[0][id]] += sz[id] + 1;
}
for(int i = 1; i <= n; i++) printf("%d\n", sz[i]);
return 0;
}
4.3 一般图
先考虑朴素做法。和无向图的必经性一样,我们删掉
Thomas Lengauer 和 Robert Tarjan 于 1979 年给出了更快的支配树算法。具体多快呢?
首先求出以
引理 1
且 是 的祖先。
在研究强连通分量横叉边的性质的时候,一个基本结论是如果
引理 2
如果
,那么所有 到 的路径都经过 的祖先。 证明
在考虑 DFS 树的时候,一个形象的方法是把儿子按 DFS 顺序从左到右排列。这样,对于两个没有祖先后代关系的点,左边的点的时间戳小于右边的点。
这个引理的一句话证明是,因为只有树边或前向边增加编号,所以从小于
的点走到不小于 的点这一步 是树边或前向边,那么 是 的祖先(如果 且 不是 的祖先,那么 子树内所有点都小于 ),而从 开始走到某个 的祖先的过程中, 的 LCA 只会不断变浅。
如上图,
, ,虚线是非树边。从 走到某个 的祖先 之前的路径用红色标出,之后的路径用蓝色标出。绿色是编号大于 的点,可以发现能够直接走到它们的编号不大于 的点只有 的祖先。红色路径上每个点和 的 LCA 依次是 ,这个 LCA 只会变得越来越浅。 具体地,设
是当前 和 的 LCA,初始为 ,当 时停止。从 出发依次考虑路径上的每一步:
- 如果走树边或前向边,那么
不变。 - 如果走返祖边且没有停止,那么
不变。 - 如果走横叉边,那么
只会变成 的祖先,因为能够使得 变深的 ,但横叉边只会减小时间戳。例如上图中不会出现 或 到 的使得和 的 LCA 变深的横叉边。 所以最终的
是 的祖先,路径一定经过 的祖先。
引理 2 是有向图上路径的强有力而不那么平凡的结论。它可以想象成一个单向的 “屏障”,从
考虑
半支配点
存在路径
且 的最小的 的祖先 称为 的 半支配点 (semidominator),记为 。
将上述定义的 “
为方便描述,对
已知
如上图,
引理 3
是 的祖先。
引理 3 可以理解为
具体地,
以上分析表明被二元组跳过的点一定不是支配点。那么没有被任何二元组跳过的点是否一定是支配点呢?
引理 4
对
的真祖先 ,如果不存在 的祖先 使得 落在 到 在树上的路径上(不含两端),那么 支配 。 证明
假设存在
到 的不经过 的路径。考虑路径上所有 的祖先,可知存在 到 的路径 ,使得路径上不经过其它 的祖先,其中 都是 的祖先且 是 的真祖先, 是 的真后代。 假设
中途经过了小于 的点,那么根据引理 2, 在此之后经过了 的真祖先,和 不经过其它 的祖先矛盾。于是 是好路径。因此 。因为 是 的真祖先,所以 是 的真祖先,和 不落在任何 到 之间矛盾。
如上图,蓝色是从
出发跳过 的好路径,红色是从 出发经过 所以最终一定经过 ( 的小于 的祖先)的路径。
引理 3 和引理 4 合起来告诉我们一个重要结论:
- 一个等价表述是仅保留树边和
到 的边不影响支配关系。于是可以使用 DAG 支配树的做法。
设
称添加一个点
结论 1
如上图,红色的
(可能和 相等)不会被 和 覆盖,而蓝色的 又被 覆盖,所以 。
具体如何实现呢?只需计算
现在还要求
如果有中间点,那么根据 DP 的思想,自然地考虑路径的最后一条边
设中途经过的最小点是
对于每条边
结论 2
其中
表示 的祖先集合。
如上图,蓝色边
对应的 用蓝色标出,红色边 对应的 用红色标出。
后半部分怎么算呢?和
时间复杂度是并查集
#include <bits/stdc++.h>
using namespace std;
constexpr int N = 2e5 + 5;
int n, m, dn, fa[N], ind[N], dfn[N];
int sdom[N], idom[N], sz[N];
vector<int> e[N], rev[N], buc[N];
void dfs(int id) {
ind[dfn[id] = ++dn] = id;
for(int it : e[id]) if(!dfn[it]) fa[it] = id, dfs(it);
}
struct dsu {
int fa[N], mi[N]; // mi 维护 sdom 最小的点的编号
int find(int x) {
if(fa[x] == x) return fa[x];
int f = fa[x];
fa[x] = find(f);
if(sdom[mi[f]] < sdom[mi[x]]) mi[x] = mi[f];
return fa[x];
}
int get(int x) {
return find(x), mi[x];
}
} tr;
int main() {
cin >> n >> m;
for(int i = 1; i <= m; i++) {
int u, v;
cin >> u >> v;
e[u].push_back(v);
rev[v].push_back(u);
}
dfs(1), sdom[0] = n + 1;
for(int i = 1; i <= n; i++) tr.fa[i] = i;
for(int i = n; i; i--) { // i = 1 的时候有些语句不用执行, 不过执行了也没事
int id = ind[i];
for(int it : buc[i]) idom[it] = tr.get(it); // 此时的 idom 是 sdom 最小的 pi
if(i == 1) break;
sdom[id] = i;
for(int it : rev[id]) {
if(dfn[it] <= i) sdom[id] = min(sdom[id], dfn[it]);
else sdom[id] = min(sdom[id], sdom[tr.get(it)]);
}
tr.mi[id] = id, tr.fa[id] = fa[id]; // 连接 id 和 fa[id]
buc[sdom[id]].push_back(id); // 把询问挂到对应位置
}
for(int i = 2; i <= n; i++) {
int id = ind[i];
if(sdom[idom[id]] != sdom[id]) idom[id] = idom[idom[id]]; // 如果 sdom 最小的 sdom[pi] 不等于 sdom[id], 那么 idom[id] = idom[pi]
else idom[id] = sdom[id]; // 否则 idom[id] = sdom[id]
}
for(int i = n; i; i--) {
int id = ind[i];
sz[i] += 1;
if(i > 1) sz[ind[idom[i]]] += sz[i];
}
for(int i = 1; i <= n; i++) cout << sz[i] << " ";
cout << "\n";
return 0;
}
CHANGE LOG
- 2022.5.26:修改文章。
- 2022.6.8:添加 SAT 的定义。
- 2022.6.10:添加 DAG 的支配树。
- 2022.9.30:添加 DAG 链剖分。
- 2023.7.6:移除同余最短路至 “图论 I”。
- 2024.8.13:添加无向图的耳分解。
- 2024.8.26:添加有向图的耳分解,双极定向。
- 2024.10.10:添加双极定向代码(P9394)。
- 2025.2.8:更新 2-SAT,添加一些图片。
- 2025.2.10:添加更简单的双极定向实现方法。
- 2025.2.13:添加一般图支配树,移除 DAG 链剖分至 “图论 III”。
参考资料
第一章
- 数理逻辑(2):命题逻辑的等值、范式和推理演算 —— tetradecane。
- Conjunctive normal form —— Wikipedia。
- Boolean satisfiability problem —— Wikipedia。
- 算法学习笔记(71):2-SAT —— Pecco。
第二章
- 【算法学习】圆方树 —— PinkRabbit。
第三章
- Ear decomposition —— Wikipedia。
- 2023 集训队论文《综述图论中连通性及相关问题的一些处理方法》—— 万成章。
- 双极定向的构造 —— Rainbow_sjy。
第四章
- Lengauer, Thomas; Tarjan, Robert Endre (July 1979). "A fast algorithm for finding dominators in a flowgraph".
- Partially ordered set —— Wikipedia。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 阿里最新开源QwQ-32B,效果媲美deepseek-r1满血版,部署成本又又又降低了!
· 单线程的Redis速度为什么快?
· SQL Server 2025 AI相关能力初探
· AI编程工具终极对决:字节Trae VS Cursor,谁才是开发者新宠?
· 展开说说关于C#中ORM框架的用法!
2022-02-15 根号算法