学习笔记:图论算法
参考资料
2-SAT
SAT 的定义
该部分与 OI 没有太大关系,不感兴趣的读者可以跳过。
为方便说明,首先给出相关术语。
- 合取:用符号
表示,是自然语言联结词 “且” 的抽象。命题 表示 的 合取,称为合取式,读作 且 ,其为真当且仅当 均为真。简单地说,合取就是逻辑与 &&
,可以类比计算机科学中的按位与&
,相信这个概念大家并不陌生。- 析取:用符号
表示,是自然语言联结词 “或” 的抽象。命题 表示 的 析取,称为析取式,读作 或 ,其为真当且仅当 至少有一个为真。同样的,合取是逻辑或 ||
,类比按位或|
。- 否定:用符号
表示, 表示命题 的 否定,其真假性与 相反。否定是逻辑非 !
,类比按位取反~
。上述三条概念均为基本命题联结词,大概可以看作给常见的 “与或非” 三种运算起了高大上的名字。将命题变元(可真可假的布尔变量)用合取
,析取 和否定 联结在一起,得到布尔逻辑式(叫法不唯一)。 布尔逻辑式可满足,指存在一个对所有命题变元的真假赋值,使得该布尔逻辑式为真。
布尔可满足性问题(Boolean Satisfiability Problem)简称 SAT,它定义为检查一个布尔逻辑式是否可满足,是第一个被证明的 NPC 问题。
- 命题变元或其否定称为文字。
- 若干个文字的析取称为简单析取式,形如
,其中 表示命题 或其否定 。 - 若干简单析取式的合取称为合取范式(Conjunctive Normal Form,CNF),形如
,其中 表示一个简单析取式。 考虑 SAT 的简单版本:命题公式为合取范式,且组成它的每个简单析取式至多含有
个文字。这一问题称为 -SAT。 当
时 -SAT 是 NPC 问题,但 2-SAT 存在多项式复杂度的解法。接下来介绍这一算法。
2-SAT 算法
我们可以将 2-SAT 看作一组需要同时为真的条件,每个条件可能的形态为:
注意到每个条件至多与两个文字(布尔变量)有关,这启发我们转化为图论问题。
可以用一条有向边
条件(简单析取式) | 自然语言 | 充分条件形式命题 |
---|---|---|
若 |
||
若 |
||
若 |
||
若 |
||
若 |
注意命题的 逆否命题 也成立。
我们对每个布尔变量
首先我们考虑无解的情况。根据第一、二种条件,如果存在路径
除此之外,我们考虑是否一定有解。对于一个变元
接下来尝试构造一组解。对于缩点后的图,若
证明 反证法。假设存在
和 满足拓扑序 (即按照上面的规则 为真),而 能到达 ,即 。那么它的逆否命题成立,即 。考虑拓扑序大小,有 ,且 ,矛盾。于是选择拓扑序较大的设为真,一定不存在不合法的情况。
因此,解一定存在,并且我们可以构造出某一组合法的解,构造方法即为:将
注意到在用 Tarjan 算法缩点的过程中,我们已经得到了缩点后 DAG 的反向拓扑序:若 sccid[v] < sccid[u]
,因此我们只需要在 sccid
较小的设为真。
时间复杂度
例题
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 集训队互测」序列
有一个长度为
的序列 ,序列里的每个元素都是 内的正整数。 现在已知了
条信息,每条信息形如 ,表示这个序列满足 。 请构造一个满足条件的序列。
对于全部数据,
。
首先把条件转化一下,可以发现这个条件的意思就是
注意到题目的标签中有 2-SAT, 注意到这个题出现在 2-SAT 专题中,
那么我们怎么把条件转化为 2-SAT 中的 二元条件 呢?
不妨先设
我们设布尔变元
按 2-SAT 算法建边即可。
但是注意有 隐含条件:如果
所以对于每一组相邻的
执行完 2-SAT 算法之后,我们得到了一组
xbc 到此一游:其实不一定,对于一个
,可能所有的 都满足 是真的,或者所有都是假的。 原题的条件是要令
,因此,对于一个 , 如果所有
都是真的,将 设成 即可; 而如果所有
都是假的,看这些 中有没有出现 ,如果出现 (即能推出 为假)问题就无解,否则直接将 设成 即可。
代码不会写。
Kruskal 重构树
构建
首先考虑 Kruskal 算法的实现过程:按边权从小到大遍历每条边
但是有时候,我们会遇到有 边权限制 的问题,并且数据范围不允许暴力跑多次 Kruskal。
注意到,Kruskal 越往后加的边权越大。所以可以用某种数据结构维护加入边的顺序,以刻画边权有限制时图的连通情况。
Kruskal 重构树 的构建过程:
连接
通常设
性质
设原图
是一棵二叉树。对于部分题目,特殊的重构树建法可以有效减小常数。 的 叶子 对应于 的所有 个节点, 的其他节点对应于 的 MST 的 条边。 共有 个节点。- 对于任意
中节点 , 是 的 祖先,则有 。即父亲权值大于儿子权值。
其中,性质 3 尤其重要。它是 Kruskal 重构树的核心:
如果边权限制
,那么节点 可达的所有点就是它在 上的权值 的最浅祖先 的子树内的所有叶子节点。
于是我们可以倍增求解
综上,我们可以总结出一个常用套路:当题目限制形如 “只经过权值不大于某个值的点或边” 时,从 Kruskal 重构树角度入手。
部分题目也可以在 Kruskal 过程中将并查集可持久化,以刻画边权有限制时图的连通情况,但时空复杂度更劣,不建议使用。
例题
P4768 [NOI2018] 归程
组数据。每组数据给定一张 个点 条边的无向图,边有两个参数:长度 和海拔 。每组数据 次询问,每次询问给定出发点 和水位线 ,其中海拔不超过水位线的边有积水,从出发点 可以驾车通过任意没有积水的边,之后步行到达节点 。每次询问输出最小的步行经过的边的总长度。部分测试点强制在线。
, , ,可能的最高水位线 。对于所有边, , 。
设
驾车能通过的边即为海拔
考虑用 Kruskal 重构树快速查找从点
具体地,将边按
注意数组范围的细节。
#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 狼人
给定一张
个点 条边的无向图,点的编号从 到 。 次询问,每次询问给定 ,问能否从 到达 ,且路径满足如下限制:
- 把整条路径分成三段,第一段必须避开点
,第二段为 中的某一个点,第三段必须避开点 。
, , 。
下面将节点编号、
题目意思其实就是:从
我们把编号看作点的权值(其实原题的题面中有提示),那么题意就转化为:限制经过点的点权,求从某点出发能到达的点的集合,然后再求两个这样的集合是否有交集。这样就可以考虑用 Kruskal 重构树解决。
题目中分别限制了权值不小于
在
接下来的问题就只剩下如何求
将
由于我们求得的是一棵子树内的所有叶子,这些叶子在
同理,将
于是就变成了:给两个
这是一个二维数点问题,由于这是两个排列,我们把每个数
洛谷上这题没有强制在线,我们可以把询问离线下来,然后用树状数组实现二维数点。
如果强制在线,就把所有
#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;
}
同余最短路
广义圆方树
广义圆方树是刻画图上点的必经性的强力工具。它可以描述原图任意两点之间的所有割点,即
定义
在图论中,我们一般用「圆点」代表原图中的点,「方点」代表新建的虚点。
对于原图中的每一个点双连通分量
每个点双缩成一张菊花图,多个菊花图通过原图中的割点相连,因为点双的分隔点是割点。如下图(来自 WC 2017 课件)。
这样,方点和圆点就会形成一个树形结构,称为(广义)圆方树。
-
实际上,如果原图有
个连通分量,则它的圆方树也会形成 棵树形成的森林。如果原图中某个连通分量只有一个点,则需要具体情况具体分析,我们在后续讨论中默认不考虑孤立点。
构建
对 Tarjan 算法求点双连通分量的过程稍作修改即可。
具体地,在 Tarjan 过程中,对于当前节点
代码:(注意,与新图有关的变量需要开二倍)
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 若
与 点双连通,但 不点双连通,那么 是 间路径的必经点。
证明:考虑
性质 1 圆点
的度数等于包含 的点双个数。
性质 2 圆方树上圆、方点相间。即圆方树上的一条边的两个端点必然是一圆点一方点。
上面两个性质由圆方树的构建方式,显然成立。
性质 3 原图上如果存在一条边
,那么 属于同一个点双,即在圆方树上 和 之间有一个方点。
一条边的两个端点
性质 4 圆点
是叶子节点当且仅当 在原图中是非割点。
证明:
如果
性质 5 在圆方树上删除圆点
后剩余圆点的连通性 与 在原图中删除点 后剩余点的连通性 相同。
证明:因为删去
若
若
性质 6 圆方树上
简单路径上的所有圆点恰好是原图中 间路径的必经点。
证明:设某个圆点
总结:若在圆方树上
例题
P5058 [ZJOI2004]嗅探器
求
两点间路径的编号最小的必经点(不算 本身),无解输出 No solution
。,边数 。
建出圆方树,找出
具体地,在圆方树上 DFS,过程中记下所有点的父亲,以及所有点的深度,然后类似倍增求 LCA,从
#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] 铁人两项
给定一张无向图(不保证连通),求总共有多少种不同的选取两两不同的
的方案,使得存在简单路径 ,即 是 到 的途径点。
, 。
有一个点双的性质:
一个点双内,任何一个三元互异点对
都满足存在一条 到 的路径经过 。 证明要用到最大流最小割定理,这里忽略(我不会),感性理解即可。证明参见 圆方树学习笔记 - 小粉兔 的博客 - 洛谷博客。
由圆方树的构建方式,我们知道,方点
考虑圆方树上从
其中
因此,对于确定的
那么我们把方点的权值设为其度数(点双大小),把圆点的权值设为
原问题为求所有有序圆点对的贡献之和。转换贡献方式,我们考虑每个点的贡献。
设点
其中
原图可能不连通,所以上式的
时间复杂度线性。
#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;
}
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 分享一个免费、快速、无限量使用的满血 DeepSeek R1 模型,支持深度思考和联网搜索!
· 使用C#创建一个MCP客户端
· ollama系列1:轻松3步本地部署deepseek,普通电脑可用
· 基于 Docker 搭建 FRP 内网穿透开源项目(很简单哒)
· 按钮权限的设计及实现