OI+ACM 笔记:F - 图论
F - 图论 杂项
图论基础
参考:
图的储存
邻接表的封装:
template <const int N, const int M>
struct adj {
int tot, head[N], ver[M], edge[M], Next[M];
void add_edge(int u, int v, int w) {
ver[++ tot] = v; edge[tot] = w; Next[tot] = head[u]; head[u] = tot;
}
};
邻接表的储存成对变换:在具有双向边的图中,把正反方向的边分别储存在邻接表的位置 \(n, n + 1\)(\(n\) 为偶数),就可以通过 \(\mathrm{xor} \ 1\) 的操作获得当前边的反向边的储存位置。具体实现时,初始化要令 tot = 1
。
图的遍历
BFS
- Breadth First Search。
DFS
- Depth First Search。
拓扑排序
拓扑序:一张有向无环图中的一个节点序列 \(A\),满足对于图中的每条边 \((x, y)\),\(x\) 在 \(A\) 中都出现在 \(y\) 之前。
- 维护一个入度为 \(0\) 的点集 \(S\)。每次扩展时,从 \(S\) 中取出任意一点 \(u\) 加入拓扑序,然后枚举 \(u\) 的所有出边,若某个出点 \(v\) 在删除了 \((u, v)\) 这条边之后入度变为 \(0\),则将 \(v\) 加入点集 \(S\)。
- 由拓扑排序的算法得到的拓扑序按照层次分层,但同一层次的节点未必需要在拓扑序中相邻。
- 若要求字典序最小的拓扑序,将队列换成优先队列即可。
int tot, head[N], ver[M], Next[M], deg[N];
void add_edge(int u, int v) {
ver[++ tot] = v; Next[tot] = head[u]; head[u] = tot; deg[v] ++;
}
int seqlen, seq[N];
void topsort() {
std::queue<int> q;
for (int i = 1; i <= n; i ++)
if (deg[i] == 0) q.push(i);
while (q.size()) {
int u = q.front(); q.pop();
seq[++ seqlen] = u;
for (int i = head[u]; i; i = Next[i]) {
int v = ver[i];
if (-- deg[v] == 0) q.push(v);
}
}
}
欧拉路、欧拉回路
欧拉路:恰好不重不漏的经过每条边一次(可以重复经过图中的节点)的通路。
欧拉回路:恰好不重不漏的经过每条边一次(可以重复经过图中的节点)的回路。
欧拉图:存在欧拉回路的无向图。
欧拉图的判定:
- 一张无向图为欧拉图,当且仅当无向图连通,并且每个点的度数都是偶数。
- 一张有向图为欧拉图,当且仅当非零度点强联通,每个点的入度与出度相等。
欧拉路的存在性判定:一张图中存在欧拉路,当且仅当无向图连通,并且途中恰好有两个节点的度数为奇数,其他节点的度数都是偶数。这两个度数为奇数的节点就是欧拉路的起点 \(S\) 和终点 \(T\)。
三元环计数、四元环计数
无向图三元环计数:
- 将所有点以度数为第一关键字(小到大)编号为第二关键字(小到大)排序,将所有边按排名从小到大定向,形成一张 DAG。
- 此时所有三元环的边向情况,只有 \(x \to y\)、\(y \to z\)、\(x \to z\) 这一种情况。
- 枚举点 \(x\),第一轮枚举 \(x\) 在新图中的出边,将所到达的点 \(z\) 打上标记;第二轮枚举 \(x\) 在新图中的出边,对于所到达的点 \(y\),再枚举 \(y\) 在新图中的出边到达的点 \(z\),如果 \(z\) 上有标记,则 \(x, y, z\) 构成三元环。
std::vector<int> e[N];
int id[N], rk[N];
std::vector<int> out[N];
bool vis[N];
int main() {
for (int i = 1, u, v; i <= m; i ++)
e[u].push_back(v), e[v].push_back(u);
for (int i = 1; i <= n; i ++) id[i] = i;
std::sort(id + 1, id + 1 + n);
for (int i = 1; i <= n; i ++) rk[id[i]] = i;
for (int u = 1; u <= n; u ++)
for (int v : e[u]) if (rk[u] < rk[v]) out[u].push_back(v);
int ans = 0;
for (int x = 1; x <= n; x ++) {
for (int z : out[x]) vis[z] = 1;
for (int y : out[x])
for (int z : out[y])
if (vis[z]) ans ++;
for (int z : out[x]) vis[z] = 0;
}
}
无向图三元环计数,时间复杂度:\(\mathcal{O}(m\sqrt{m})\)。
无向图三元环计数,时间复杂度分析:
- 记 \(\mathrm{deg}_u\) 为原图中点 \(u\) 的度,\(\mathrm{out}_u\) 为新图中点 \(u\) 的出度。可证 \(\mathrm{out}_u\) 为 \(\mathcal{O}(\sqrt{m})\) 级别。
- 若 \(\mathrm{deg}_u \leq \sqrt{m}\),则 \(\mathrm{out}_u \leq \sqrt{m}\)。
- 若 \(\mathrm{deg}_u > \sqrt{m}\),则 \(u\) 在新图中指向的点的度数大于 \(\sqrt{m}\),故 \(\mathrm{out}_u \leq \sqrt{m}\)。
- 上述流程的复杂度,相当于对定向后每条边 \((u, v)\) 的 \(v\) 的 \(\mathrm{out}_v\) 求和。每条边的贡献为 \(\mathcal{O}(\sqrt{m})\) 级别,故上述流程的复杂度为 \(\mathcal{O}(m\sqrt{m})\)。
无向图三元环计数的推论:无向图的三元环个数为 \(\mathcal{O}(m\sqrt{m})\) 级别。
有向图三元环计数:转化为无向图三元环计数,找到三元环时判断方向即可。
竞赛图三元环计数:记点 \(i\) 的出度为 \(\mathrm{out}_i\),则三元环个数为
无向图四元环计数:
- 将所有点以度数为第一关键字(小到大)编号为第二关键字(小到大)排序,将所有边按排名从小到大定向,形成一张 DAG。
- 此时所有四元环的边向情况,只有两条形如 \((x, y)\)、\(y \to z\) 的路径拼起来的情况:
- \(x \to y_1\)、\(y_1 \to z\)、\(x \to y_2\)、\(y_2 \to z\)。
- \(x \gets y_1\)、\(y_1 \to z\)、\(x \to y_2\)、\(y_2 \to z\)。
- \(x \gets y_1\)、\(y_1 \to z\)、\(x \gets y_2\)、\(y_2 \to z\)。
- 枚举点 \(x\),再枚举 \(x\) 在原图中的出边,对于所到达的点 \(y\),再枚举 \(y\) 在新图中的出边到达的点 \(z\)(为避免第三种边向情况重复计数,此处枚举到的 \(z\) 应满足 \(\mathrm{rk}_x < \mathrm{rk}_z\)),则先前枚举的所有形如 \((x, y)\)、\(y \to z\) 的路径都与当前枚举的路径构成四元环。
std::vector<int> e[N];
int id[N], rk[N];
std::vector<int> out[N];
int cnt[N];
int main() {
for (int i = 1, u, v; i <= m; i ++)
e[u].push_back(v), e[v].push_back(u);
for (int i = 1; i <= n; i ++) id[i] = i;
std::sort(id + 1, id + 1 + n);
for (int i = 1; i <= n; i ++) rk[id[i]] = i;
for (int u = 1; u <= n; u ++)
for (int v : e[u]) if (rk[u] < rk[v]) out[u].push_back(v);
int ans = 0;
for (int x = 1; x <= n; x ++) {
for (int y : e[x])
for (int z : out[y])
if (rk[x] < rk[z]) ans += cnt[z] ++;
for (int y : e[x])
for (int z : out[y])
if (rk[x] < rk[z]) cnt[z] = 0;
}
}
无向图四元环计数,时间复杂度:\(\mathcal{O}(m\sqrt{m})\)。
无向图四元环计数,时间复杂度分析:同无向图三元环计数。
Prufer 序列
Prufer 序列:一个包含 \(n-2\) 个取值范围在 \([1, n]\) 中的正整数的序列。可以理解为带标号的完全图生成树与数列的双射。
Prufer 序列构造:每次选择一个编号最小的叶结点并删掉它,并且在序列中记录下它连接到的那个结点。重复 \(n-2\) 次后,只剩下两个节点后停止。
Prufer 序列性质:
- 在构造完 Prufer 序列后原树中会剩下两个结点,其中一个一定是编号最大的点 \(n\)。
- 每个节点在 Prufer 序列中的出现次数是其度数减 \(1\),叶节点不出现。
树转 Prufer 序列:
- \(\mathcal{O}(n \log n)\) 构造:堆优化。
- \(\mathcal{O}(n)\) 构造:注意到叶节点个数是单点不增的。考虑维护一个指针 \(p\),初始时 \(p\) 指向编号最小的叶节点。同时维护每个节点的度数,考虑重复如下过程:
- 删除节点 \(p\),并检查是否产生新的叶节点。
- 如果产生新的叶节点 \(x\),比较 \(x, p\) 的大小关系,若 \(x > p\),则不做其他操作;若 \(x < p\),则立刻删除 \(x\),然后检查是否产生新的叶节点 ...(重复步骤 2),直到未产生新节点或新节点的编号 \(>p\)。
- 让 \(p\) 自增,直到遇到一个未被删除的叶节点为止。
int tot, head[N], ver[N * 2], Next[N * 2], deg[N];
void add_edge(int u, int v) {
ver[++ tot] = v; Next[tot] = head[u]; head[u] = tot; deg[v] ++;
}
int Fa[N];
void dfs(int u) {
for (int i = head[u]; i; i = Next[i]) {
int v = ver[i];
if (Fa[v]) continue;
Fa[v] = u;
dfs(v);
}
}
int prufer[N];
int main() {
Fa[n] = -1, dfs(n);
int leaf = 0, p = 0;
for (int i = 1; i <= n; i ++)
if (deg[i] == 1) { leaf = p = i; break; }
for (int i = 1; i <= n - 2; i ++) {
int x = Fa[leaf];
prufer[i] = x;
if (-- deg[x] == 1 && x < p) {
leaf = x;
} else {
p ++;
while (deg[p] != 1) p ++;
leaf = p;
}
}
for (int i = 1; i <= n - 2; i ++)
printf("%d ", prufer[i]);
puts("");
}
Prufer 序列转树:通过 Prufer 序列反推每个节点的度数。依次枚举 Prufer 序列的每一个点,每次选择一个度数为 \(1\) 且编号最小的节点,与当前枚举到的 Prufer 序列的点连边,并且令这两个点的度数减 \(1\)。重复 \(n-2\) 次后,只剩下两个度数为 \(1\) 的节点,将这两个点连边。
- \(\mathcal{O}(n \log n)\) 构造:堆优化。
- \(\mathcal{O}(n)\) 构造:同 "树转 Prufer 序列"。
int n;
int prufer[N];
int deg[N];
std::pair<int, int> e[N];
int main() {
for (int i = 1; i <= n; i ++) deg[i] = 1;
for (int i = 1; i <= n - 2; i ++) deg[prufer[i]] ++;
int leaf = 0, p = 0;
for (int i = 1; i <= n; i ++)
if (deg[i] == 1) { leaf = p = i; break; }
for (int i = 1; i <= n - 2; i ++) {
int x = prufer[i];
e[i] = std::make_pair(leaf, x);
if (-- deg[x] == 1 && x < p) {
leaf = x;
} else {
p ++;
while (deg[p] != 1) p ++;
leaf = p;
}
}
e[n - 1] = std::make_pair(leaf, n);
}
Cayley 公式(有标号完全图生成树计数):有标号完全图有 \(n^{n-2}\) 棵生成树。
F - 图论 最小生成树
Kruskal
- \(\mathcal{O}(n + m \log m)\)。
struct edg {
int u, v, w;
bool operator < (const edg &rhs) const {
return w < rhs.w;
}
} e[M];
int fa[N];
int get(int x) {
return fa[x] == x ? x : fa[x] = get(fa[x]);
}
void kruskal() {
std::sort(e + 1, e + 1 + m);
for (int i = 1; i <= n; i ++) fa[i] = i;
for (int i = 1; i <= m; i ++) {
int u = e[i].u, v = e[i].v, w = e[i].w;
int p = get(u), q = get(v);
if (p == q) continue;
// Calculate the information of the (u, v, w)
fa[p] = q;
}
}
Kruskal 重构树
Kruskal 重构树:按 Kruskal 的流程,每加入一条边 \((u, v, w)\),就新建一个点 \(x\),同时将 \(x\) 的点权设为 \(w\),将 \(u, v\) 所在的树的根节点分别设为点 \(x\) 的左儿子与右儿子。在进行 \(n - 1\) 轮合并后,得到了一棵恰有 \(n\) 个叶子的二叉树,同时每个非叶子节点恰好有两个儿子,这棵树即为 kruskal 重构树。
struct edg {
int u, v, w;
bool operator < (const edg &rhs) const {
return w < rhs.w;
}
} e[M];
namespace KRT {
const int SIZE = N * 2;
int nClock;
struct node {
int lc, rc;
int val;
} t[SIZE];
int fa[SIZE];
int get(int x) {
return fa[x] == x ? x : fa[x] = get(fa[x]);
}
void build() {
std::sort(e + 1, e + 1 + m);
nClock = n;
for (int i = 1; i <= n; i ++) fa[i] = i;
for (int i = 1; i <= m; i ++) {
int u = e[i].u, v = e[i].v, w = e[i].w;
int p = get(u), q = get(v);
if (p == q) continue;
int x = ++ nClock;
t[x].val = w, t[x].lc = p, t[x].rc = q;
fa[p] = fa[q] = fa[x] = x;
}
}
}
Kruskal 重构树的简单性质:
- kruskal 最小重构树是大根堆,kruskal 最大重构树是小根堆。
- kruskal 最小重构树中,两叶子节点之间 LCA 的权值,对应原图中两点之间所有简单路径上最大边权的最小值。
- kruskal 最小重构树中,对于叶子节点 \(x\) 到根的路径上权值 \(\leq \mathrm{val}\) 的最浅节点,其子树内的所有节点,即为从 \(x\) 开始只经过边权 \(\leq \mathrm{val}\) 的边所能到达的节点(满足两点之间所有简单路径上最大边权的最小值 \(\leq \mathrm{val}\))。
Prim
- \(\mathcal{O}(n^2)\)。
const int inf = 0x3f3f3f3f;
int a[N][N];
int d[N];
bool exist[N];
void prim() {
for (int i = 1; i <= n; i ++) d[i] = inf;
d[1] = 0;
for (int i = 1; i <= n; i ++) exist[i] = 0;
for (int i = 1; i <= n; i ++) {
int u = 0;
for (int x = 1; x <= n; x ++)
if (!exist[x] && (u == 0 || d[x] < d[u])) u = x;
exist[u] = 1;
for (int v = 1; v <= n; v ++)
if (!exist[v]) d[v] = std::min(d[v], a[u][v]);
}
}
Brouvka
- 初始时,每一个点都是一个连通块。每一轮,遍历所有点和边,连接一个连通块中和其他连通块相连的最小边。
- \(\mathcal{O}((n + m) \log n)\)。
const int inf = 0x3f3f3f3f;
struct edg {
int u, v, w;
} e[M];
int fa[N];
int get(int x) {
return fa[x] == x ? x : fa[x] = get(fa[x]);
}
int great_val[N], great_id[N];
void boruvka() {
for (int i = 1; i <= n; i ++) fa[i] = i;
while (1) {
for (int i = 1; i <= n; i ++) great_val[i] = inf, great_id[i] = 0;
bool exist = 0;
for (int i = 1; i <= m; i ++) {
int p = get(e[i].u), q = get(e[i].v);
if (p == q) continue;
exist = 1;
if (e[i].w < great_val[p]) great_val[p] = e[i].w, great_id[p] = i;
if (e[i].w < great_val[q]) great_val[q] = e[i].w, great_id[q] = i;
}
if (!exist) break;
for (int i = 1; i <= n; i ++) {
if (great_id[i] == 0) continue;
int id = great_id[i], p = get(e[id].u), q = get(e[id].v);
if (p == q) continue;
// Calculate the data of this edge.
fa[p] = q;
}
}
}
const int inf = 0x3f3f3f3f;
int cur_block;
int fa[N];
int get(int x) {
return fa[x] == x ? x : fa[x] = get(fa[x]);
}
int great_val[N], great_id[N];
void boruvka() {
cur_block = n;
for (int i = 1; i <= n; i ++) fa[i] = i;
while (cur_block ^ 1) {
for (int i = 1; i <= n; i ++) great_val[i] = inf, great_id[i] = 0;
for (int i = 1; i <= n; i ++) {
int p = get(i);
// update the data of this connected block p with i.
}
for (int i = 1; i <= n; i ++) {
if (great_id[i] == 0) continue;
int p = get(i), q = get(great_id[i]);
if (p == q) continue;
// Calculate the data of this edge.
fa[p] = q, cur_block --;
}
}
}
F - 图论 最短路径
Dijkstra
- Dijkstra:\(\mathcal{O}(n^2)\)。
- Dijkstra + Heap:\(\mathcal{O}((n + m) \log m)\)。
const int inf = 0x3f3f3f3f;
int tot, head[N], ver[M], edge[M], Next[M];
void add_edge(int u, int v, int w) {
ver[++ tot] = v; edge[tot] = w; Next[tot] = head[u]; head[u] = tot;
}
int dist[N];
bool vis[N];
void dijkstra() {
for (int i = 1; i <= n; i ++) dist[i] = inf;
for (int i = 1; i <= n; i ++) vis[i] = 0;
std::priority_queue< pair<int, int> > q;
q.push(make_pair(0, 1)), dist[1] = 0;
while (q.size()) {
int u = q.top().second; q.pop();
if (vis[u]) continue;
vis[u] = 1;
for (int i = head[u]; i; i = Next[i]) {
int v = ver[i], w = edge[i];
if (dist[u] + w < dist[v]) {
dist[v] = dist[u] + w;
q.push(make_pair(-dist[v], v));
}
}
}
}
Bellman-ford & SPFA
- SPFA:\(\mathcal{O}(nm)\)。
const int inf = 0x3f3f3f3f;
int tot, head[N], ver[M], edge[M], Next[M];
void add_edge(int u, int v, int w) {
ver[++ tot] = v; edge[tot] = w; Next[tot] = head[u]; head[u] = tot;
}
int dist[N];
bool vis[N];
void SPFA() {
for (int i = 1; i <= n; i ++) dist[i] = inf;
for (int i = 1; i <= n; i ++) vis[i] = 0;
std::queue<int> q;
q.push(1), dist[1] = 0;
while (q.size()) {
int u = q.front(); q.pop(); vis[u] = 0;
for (int i = head[u]; i; i = Next[i]) {
int v = ver[i], w = edge[i];
if (dist[u] + w < dist[v]) {
dist[v] = dist[u] + w;
if (!vis[v]) q.push(v), vis[v] = 1;
}
}
}
}
Bellman-Ford 判负环:若经过 \(n\) 轮迭代后,图中仍有能更新的边,则图中有负环。
SPFA 判负环:设 \(\mathrm{cnt}_x\) 表示 \(1\) 到 \(x\) 的最短路包含的边数。在迭代的过程中,若发现 \(\mathrm{cnt}_y \geq n\),则图中有负环。
Floyd
- Floyd:\(\mathcal{O}(n^3)\)。
- Floyd 本质上是 dp。定义 \(f(k, i, j)\) 表示允许经过 \(1\) 到 \(k\) 号点作为中间点(除了 \(i, j\) 分别为起点终点,经过的其他点只能是 \(1 \sim k\) 的点)的情况下,点 \(i\) 到点 \(j\) 的最短路。通过滚动数组优化掉了阶段维,才得到最常见的 Floyd 写法。如果以任意的顺序枚举 \(k\),得到的 \(f(i, j)\) 即为允许经过枚举过的点作为中间点的情况下,点 \(i\) 到点 \(j\) 的最短路。
差分约束
差分约束:一种特殊的 \(n\) 元一次不等式组,包含 \(n\) 个变量 \(x_1, x_2, \cdots, x_n\) 以及 \(m\) 组限制关系 \(x_i - x_j \leq c_k\)。
- \(x_i - x_j \leq c_k\) 可以变形为 \(x_i \leq x_j + c_k\),形似三角形不等式,因此从点 \(j\) 向点 \(i\) 连一条长度为 \(c_k\) 的有向边。
- 若存在负环则无解,否则最短路即为一组合法解。
同余最短路
同余最短路,问题 1:给出 \(a_1, a_2, \cdots, a_n\) 和一个模数 \(K\),对于每一个 \(p \in [0, K)\) 求使得 \((\sum a_i x_i) \bmod K = p\) 的 \(\sum a_ix_i\) 最小值。
- 建 \(K\) 个点,编号为 \(0 \sim K - 1\)。
- 对于每一个 \(p \in [0, K)\) 和每一个 \(a_i\),令 \(p\) 向 \((p + a_i) \bmod K\) 连一条长度为 \(a_i\) 的边。转化为图论模型。
同余最短路,问题 2:给出 \(a_1, a_2, \cdots, a_n\) 和一个上界 \(H\),求 \(\sum a_ix_i\) 的数值在 \([0, H]\) 有多少个解。
F - 图论 连通性
无向图 tarjan
时间戳与追溯值
时间戳 \(\mathrm{dfn}_x\):在深度优先遍历的过程中,节点 \(x\) 第一次被访问时的时间顺序。
追溯值 \(\mathrm{low}_x\):以下节点的时间戳的最小值
- \(\mathrm{subtree}(x)\) 中的节点。
- 通过一条非搜索树边,能够到达 \(\mathrm{subtree}(x)\) 中的节点的点。
时间戳 \(\mathrm{dfn}_x\) 与追溯值 \(\mathrm{low}_x\) 的计算方式:对整张图进行深度优先遍历,一开始 \(\mathrm{low}_x = \mathrm{dfn}_x\),考虑从 \(x\) 出发的每条边 \((x, y)\)
- 若 \(x\) 是 \(y\) 的父亲,则
- 若 \((x, y)\) 为非搜索树边,则
割点
割点:满足删去该点以及连接该点的边后,原图不连通的点。
割点判定法则:在搜索树上,若存在 \(x\) 的某个儿子 \(y\) 满足
则 \(x\) 为割点。特别地,若 \(x\) 为搜索树的根,则至少要找出两个满足条件的儿子。
int tot, head[N], ver[M * 2], Next[M * 2];
void add_edge(int u, int v) {
ver[++ tot] = v; Next[tot] = head[u]; head[u] = tot;
}
int root, dfsClock, dfn[N], low[N];
bool cut[N];
void tarjan(int u) {
dfn[u] = low[u] = ++ dfsClock;
int cp = 0;
for (int i = head[u]; i; i = Next[i]) {
int v = ver[i];
if (!dfn[v]) {
tarjan(v);
low[u] = std::min(low[u], low[v]);
if (dfn[u] <= low[v]) {
cp ++;
if (u != root || cp > 1) cut[u] = 1;
}
} else {
low[u] = std::min(low[u], dfn[v]);
}
}
}
int main() {
for (int i = 1; i <= n; i ++)
if (!dfn[i]) root = i, tarjan(i);
}
割边
割边:满足删去该边后,原图不连通的边。
割边判定法则:在搜索树上,若 \(x\) 为 \(y\) 的父亲,且满足
则 \((x, y)\) 为割边。
int tot = 1, head[N], ver[M * 2], Next[M * 2];
void add_edge(int u, int v) {
ver[++ tot] = v; Next[tot] = head[u]; head[u] = tot;
}
int dfsClock, dfn[N], low[N];
bool bridge[M * 2];
void tarjan(int u, int in_edge) {
dfn[u] = low[u] = ++ dfsClock;
for (int i = head[u]; i; i = Next[i]) {
int v = ver[i];
if (!dfn[v]) {
tarjan(v, i);
low[u] = std::min(low[u], low[v]);
if (dfn[u] < low[v]) bridge[i] = bridge[i ^ 1] = 1;
} else if (i != (in_edge ^ 1)) {
low[u] = std::min(low[u], dfn[v]);
}
}
}
int main() {
for (int i = 1; i <= n; i ++)
if (!dfn[i]) tarjan(i, 0);
}
点双连通分量
点双连通:若删去任意一个点(除 \(u, v\) 以外),\(u, v\) 仍连通,则称 \(u\) 与 \(v\) 是点双连通的。
点双连通图:不存在割点的无向连通图。
点双连通分量(v-DCC):无向图的极大点双连通子图。
v-DCC 的简单性质:
- 一张无向连通图是 v-DCC,当且仅当任意两个点都包含在至少一个简单环中。
- 对于一个 v-DCC 的任意两个点,它们之间都有至少两个点不重复的路径。
- 对于一个 v-DCC 中任意两个点,它们之间简单路径的并集,恰好完全等于这个 v-DCC。
- 割点至少在两个 v-DCC 中,其他非割点属于且仅属于一个 v-DCC 中。
v-DCC 的缩点(圆方树):
- 在圆方树中,原图中每个点对应一个圆点,每个 v-DCC 对应一个方点。
- 对于每个 v-DCC,令其对应的方点向该 v-DCC 中的每一个圆点连边。每个 v-DCC 构成一个菊花图,多个菊花图通过原图中的割点连接。
- 对原图做一遍无向图 tarjan。考虑一个 v-DCC 在 DFS 树中的最顶端节点 \(u\),在点 \(u\) 处确定这个 v-DCC。对于一个树边 \((u, v)\),\(u, v\) 在同一个 v-DCC 中且 \(u\) 是这个 v-DCC 中的最顶端节点,当且仅当 \(\mathrm{dfn}_u = \mathrm{low}_v\)。
- 可以在 DFS 的过程中,维护一个栈,储存还未确定所属 v-DCC(可能有多个)的节点。找到 v-DCC 时,v-DCC 中除了 \(u\) 以外的其他点都集中在栈顶端,只需不断弹出栈顶直到弹出 \(v\) 为止。\(u\) 和被弹出的所有点,构成一个 v-DCC,都需要向新建的方点连边。
const int SIZE = N * 2;
template <const int N, const int M>
struct adj {
int tot, head[N], ver[M], Next[M];
void add_edge(int u, int v) {
ver[++ tot] = v; Next[tot] = head[u]; head[u] = tot;
}
};
adj<N, M * 2> G;
adj<SIZE, SIZE * 2> V;
int dfsClock, dfn[N], low[N];
int stk, top[N];
int v_dcc;
void tarjan(int u) {
dfn[u] = low[u] = ++ dfsClock;
stk[++ top] = u;
for (int i = G.head[u]; i; i = G.Next[i]) {
int v = G.ver[i];
if (!dfn[v]) {
tarjan(v);
low[u] = std::min(low[u], low[v]);
if (dfn[u] == low[v]) {
v_dcc ++;
int x, p = n + v_dcc;
do {
x = stk[top --];
V.add_edge(x, p), V.add_edge(p, x);
} while (x != v);
V.add_edge(u, p), V.add_edge(p, u);
}
} else {
low[u] = std::min(low[u], dfn[v]);
}
}
}
int main() {
for (int i = 1, u, v; i <= m; i ++)
G.add_edge(u, v), G.add_edge(v, u);
for (int i = 1; i <= n; i ++)
if (!dfn[i]) top = 0, tarjan(i);
}
边双连通分量
边双连通:若删去任意一条边,\(u, v\) 仍连通,则称 \(u\) 与 \(v\) 是边双连通的。
边双连通图:不存在割边的无向连通图。
边双连通分量(e-DCC):无向图的极大边双连通子图。
e-DCC 的简单性质:
- 一张无向连通图是 e-DCC,当且仅当任意一条边都包含在至少一个简单环中。
- 对于一个 e-DCC 中的任意两个点,它们之间都有至少两个边不重复的路径。
- 对于一个 e-DCC 中的任意两个点,它们之间简单路径的并集,恰好完全等于这个 e-DCC。
- 割边不属于任意 e-DCC,其他非割边属于且仅属于一个 e-DCC。
- 一张图经过 e-DCC 缩点后会得到树或森林。
e-DCC 的缩点:
- 对原图做一遍无向图 tarjan。考虑一个 e-DCC 在 DFS 树中的最顶端节点 \(u\),在点 \(u\) 处确定这个 e-DCC。\(u\) 是这个 e-DCC 中的最顶端节点,当且仅当 \(\mathrm{dfn}_u = \mathrm{low}_u\)。
- 可以在 DFS 的过程中,维护一个栈,储存还未确定所属 e-DCC(可能有多个)的节点。找到 e-DCC 时,e-DCC 中的所有点都集中在栈顶端,只需不断弹出栈顶直到弹出 \(u\) 为止。被弹出的所有点,构成一个 e-DCC。
- 在找出了所有的 e-DCC 后,枚举原图中的所有边 \((u, v)\),若 \(u\) 与 \(v\) 不属于同一个 e-DCC,则在 \(u, v\) 所属的 e-DCC 之间连一条边。
template <const int N, const int M>
struct adj {
int tot = 1, head[N], ver[M * 2], Next[M * 2];
void add_edge(int u, int v) {
ver[++ tot] = v; Next[tot] = head[u]; head[u] = tot;
}
};
adj<N, M * 2> G;
adj<N, N * 2> V;
int dfsClock, dfn[N], low[N];
int top, stk[N];
int e_dcc, col[N];
void tarjan(int u, int in_edge) {
dfn[u] = low[u] = ++ dfsClock;
stk[++ top] = u;
for (int i = G.head[u]; i; i = G.Next[i]) {
int v = G.ver[i];
if (!dfn[v]) {
tarjan(v, i);
low[u] = std::min(low[u], low[v]);
} else if (i != (in_edge ^ 1)) {
low[u] = std::min(low[u], dfn[v]);
}
}
if (dfn[u] == low[u]) {
e_dcc ++;
int x;
do {
x = stk[top --];
col[x] = e_dcc;
} while (x != u);
}
}
int main() {
for (int i = 1, u, v; i <= m; i ++)
G.add_edge(u, v), G.add_edge(v, u);
for (int i = 1; i <= n; i ++)
if (!dfn[i]) tarjan(i, 0);
for (int i = 2; i < G.tot; i += 2) {
int u = G.ver[i ^ 1], v = G.ver[i];
if (col[u] == col[v]) continue;
V.add_edge(col[u], col[v]), V.add_edge(col[v], col[u]);
}
}
有向图 tarjan
流图
时间戳 \(\mathrm{dfn}_x\):在深度优先遍历的过程中,节点 \(x\) 第一次被访问时的时间顺序。
流图:给定有向图 \(G = (V, E)\),若存在 \(r \in V\),满足从 \(r\) 出发能够到达 \(V\) 中的所有点,则称 \(G\) 是一个流图,其中 \(r\) 称为流图的源点。
流图中的有向边 \((x, y)\) 分类:对于流图中的有向边 \((x, y)\),必是以下四种之一
- 树枝边。指搜索树中的边,即 \(x\) 是 \(y\) 的父节点。
- 前向边。指搜索树中 \(x\) 是 \(y\) 的祖先节点。
- 后向边。指搜索树中 \(y\) 是 \(x\) 的祖先节点。
- 横叉边。指除了以上三种情况之外的边,一定满足 \(\mathrm{dfn}_x > \mathrm{dfn}_y\)。
强连通分量
强连通:若既存在 \(x\) 到 \(y\) 的路径,又存在 \(y\) 到 \(x\) 的路径,则称 \(x\) 与 \(y\) 是强连通的。
强连通图:任意两点都强连通的有向图。
强连通分量(SCC):有向图的极大强连通子图。
时间戳 \(\mathrm{dfn}_x\):在深度优先遍历的过程中,节点 \(x\) 第一次被访问时的时间顺序。
追溯值 \(\mathrm{low}_x\):以下节点的时间戳的最小值
- 栈中的节点。有向图 tarjan 在深度优先遍历的同时维护了一个栈,当访问到节点 \(x\),保存从 \(x\) 出发的后向边、横叉边形成环的节点:
- 搜索树上 \(x\) 的祖先节点,记作 \(\mathrm{anc}(x)\)。
- 已经访问过,并且存在一条路径到达 \(\mathrm{anc}(x)\) 的节点。
- 通过一条从 \(\mathrm{subtree}(x)\) 中出发的有向边,以该点为终点。
时间戳 \(\mathrm{dfn}_x\) 与追溯值 \(\mathrm{low}_x\) 的计算方式:对整张图进行深度优先遍历,一开始 \(\mathrm{low}_x = \mathrm{dfn}_x\),将 \(x\) 入栈,考虑从 \(x\) 出发的每条边 \((x, y)\)
- 若 \(y\) 没有访问过,则说明 \(x\) 为 \(y\) 的父亲,则
- 若 \(y\) 被访问过,且 \(y\) 在栈中,则
在 \(x\) 回溯之前,若 \(\mathrm{dfn}_x = \mathrm{low}_x\),则不断弹出栈顶直到弹出 \(x\) 为止。
SCC 判定法则:在 \(x\) 回溯之前,若 \(\mathrm{dfn}_x = \mathrm{low}_x\),则栈中从 \(x\) 到栈顶的所有节点构成一个 SCC。
SCC 的缩点:在找出了所有的 SCC 后,枚举原图中的所有边 \((u, v)\),若 \(u\) 与 \(v\) 不属于同一个 SCC,则在 \(u, v\) 所属的 SCC 之间连一条边。
template <const int N, const int M>
struct adj {
int tot, head[N], ver[M], Next[M];
void add_edge(int u, int v) {
ver[++ tot] = v; Next[tot] = head[u]; head[u] = tot;
}
};
adj<N, M * 2> G, V;
int dfsClock, dfn[N], low[N];
int top, stk[N]; bool inc[N];
int scc, col[N];
std::vector<int> h[N];
void tarjan(int u) {
dfn[u] = low[u] = ++ dfsClock;
stk[++ top] = u, inc[u] = 1;
for (int i = G.head[u]; i; i = G.Next[i]) {
int v = G.ver[i];
if (!dfn[v]) {
tarjan(v);
low[u] = std::min(low[u], low[v]);
} else if (inc[v]) {
low[u] = std::min(low[u], dfn[v]);
}
}
if (dfn[u] == low[u]) {
scc ++;
int x;
do {
x = stk[top --], inc[x] = 0;
col[x] = scc, h[scc].push_back(x);
} while (x != u);
}
}
int main() {
for (int i = 1, u, v; i <= m; i ++)
G.add_edge(u, v);
for (int i = 1; i <= n; i ++)
if (!dfn[i]) tarjan(i);
for (int u = 1; u <= n; u ++)
for (int i = G.head[u]; i; i = G.Next[i]) {
int v = G.ver[i];
if (col[u] == col[v]) continue;
V.add_edge(col[u], col[v]);
}
}
tarjan 编号:tarjan 编号,为原图缩点后 DAG 的拓扑序反序。
2-SAT
2-SAT:有 \(n\) 个变量,每个变量只有两种可能的取值。给定 \(m\) 个条件,每个条件都形如「若 \(a_i = p\) 则 \(a_j = q\)」。求是否存在对 \(n\) 个变量的合法赋值,使得 \(m\) 个条件均得到满足。
2-SAT 的判定:
- 建立包含 \(2n\) 个节点的有向图,\(a_i = 0\) 对应节点 \(i\),\(a_i = 1\) 对应节点 \(i + n\)。
- 建立条件「若 \(a_i = p\) 则 \(a_j = q\)」在图中的边,令 \(a_i = p\) 所代表的节点向 \(a_j = q\) 所代表的节点连一条有向边。
- 使用有向图 tarjan 算法求出图中所有的强连通分量。若存在一个 \(i\) 使得 \(a_i = 0\) 与 \(a_i = 1\) 所代表的节点存在于同一个强联通分量中,则没有合法的赋值方案,否则肯定可以找到一组合法的赋值方案。
2-SAT 求方案:
- 在缩点后 DAG " 自底向上 " 的拓扑序中,若 \(a_i = 0\) 比 \(a_i = 1\) 靠后,则 \(a_i = 1\) 则 \(a_i = 0\)。
- tarjan 得到的 SCC 编号,即为缩点后 DAG " 自底向上 " 的拓扑序。
F - 图论 二分图
二分图:若一张无向图的 \(n\) 个节点可以分成 \(A, B\) 两个非空无交集合,并且在同一集合内的点之间都没有边相连,那么这张无向图称为一张二分图,\(A, B\) 分别称为二分图的左部与右部。
二分图染色
二分图判定定理:一张无向图是二分图,当且仅当图中不存在奇环。
二分图判定定理推论:一张二分图的任意子图为二分图。
二分图路径长度简单性质:
- 一张二分图中,任意两点间路径经过的边数奇偶性确定。
- 一张连通非二分图中,任意两点间路径经过的边数可以是奇数也可以是偶数。
二分图染色:对整张图进行深度优先遍历,尝试用黑白两种颜色标记图中的节点,当一个节点被标记后,它的所有相邻节点应被标记与它相反的颜色。若标记过程中产生冲突,则说明图中存在奇环。
int tot, head[N], ver[M * 2], Next[M * 2];
void add_edge(int u, int v) {
ver[++ tot] = v; Next[tot] = head[u]; head[u] = tot;
}
int col[N];
bool dfs(int u, int color) {
col[u] = color;
for (int i = head[u]; i; i = Next[i]) {
int v = ver[i];
if (col[u] == col[v])
return 0;
else if (!col[v] && !dfs(v, 3 - color))
return 0;
}
return 1;
}
二分图匹配
匹配:一组边的集合 \(S\),满足任意两条边都没有公共端点。属于 \(S\) 的边被称为匹配边,否则被称为非匹配边。若存在 \(S\) 中的一条边的端点为该点,则称该点为匹配点,否则称该点为非匹配点。
最大匹配:包含边数最多的一组匹配。
增广路:若二分图中,存在一条连接两个非匹配点的路径,使得非匹配边和匹配边在该路径上交替出现,则称该路径是匹配 \(S\) 的增广路,也叫交错路。满足
- 长度 \(\mathrm{len}\) 为奇数。
- 路径上第 \(1, 3, 5, \cdots, \mathrm{len}\) 条边为非匹配边,路径上第 \(2, 4, 6, \cdots, \mathrm{len} - 1\) 条边为匹配边。
增广路的简单性质:若将增广路上所有边的状态取反,得到的新的边集 \(S'\) 仍然是一组匹配,并且匹配边数加一。故二分图的一组匹配 \(S\) 是最大匹配,当且仅当图中不存在 \(S\) 的增广路。
完美匹配:给定一张二分图,其左、右部分别为 \(X, Y\),若最大匹配包含 \(\min(|X|, |Y|)\) 条匹配边,则称该二分图具有完备匹配。
多重匹配:给定一张二分图,其左、右部节点数分别为 \(n_l, n_r\)。从中选出尽量多的边,使第 \(i(1 \leq i \leq n_l)\) 个左部节点至多与 \(kl_i\) 条选出的边相连,第 \(j(1 \leq j \leq n_r)\) 个右部节点至多与 \(kr_i\) 条选出的边相连。
二分图最大匹配:匈牙利算法
匈牙利算法:初始时,所有边都是非匹配边。匈牙利算法不断寻找增广路,依次给每个左部节点 \(x\) 寻找一个匹配的右部节点 \(y\),需要满足以下两个条件之一
- \(y\) 是非匹配点。则 \(x \to y\) 构成一条长度为 \(1\) 的增广路。
- \(y\) 与左部点 \(x'\) 匹配,但从 \(x'\) 出发能找到另一个右部点 \(y'\) 与 \(x'\) 匹配。则 \(x \to y \to x' \to y'\) 构成一条增广路。
匈牙利算法原理:一个节点成为匹配点后,至多因为找到增广路而更换匹配对象,但是绝对不会再变回非匹配点。本质上是贪心。
匈牙利算法,时间复杂度:\(\mathcal{O}(nm)\)。
int tot, head[N], ver[M * 2], Next[M * 2];
void add_edge(int u, int v) {
ver[++ tot] = v; Next[tot] = head[u]; head[u] = tot;
}
bool vis[N];
int match[N];
bool dfs(int u) {
for (int i = head[u]; i; i = Next[i]) {
int v = ver[i];
if (vis[v]) continue;
vis[v] = 1;
if (!match[v] || dfs(match[v])) {
match[v] = u;
return 1;
}
}
return 0;
}
int main() {
int ans = 0;
for (int i = 1; i <= nl; i ++) {
memset(vis, 0, sizeof(vis));
if (dfs(i)) ans ++;
}
}
匈牙利算法求字典序最小的最大匹配:考虑匈牙利算法的流程,是一个左部节点不断地挤掉右部节点已匹配的左部节点的过程。可以考虑按编号从大到小加入左部节点,将左部节点的所有出边从小到大排序,保证优先考虑较小的右部节点,即可求得字典序最小的最大匹配。
二分图最大匹配:最大流模型
最大流模型:将源点向左部所有点连一条容量为 \(1\) 的边,将右部所有点向汇点连一条容量为 \(1\) 的边。将左部向右部的连边容量设为 \(1\)。此时该网络的最大流即为最大匹配。
最大流模型,时间复杂度:使用 Dinic 算法求最大流,时间复杂度为 \(\mathcal{O}(m\sqrt{n})\)。
二分图最大带权匹配:KM 算法
待填。
二分图最大带权匹配:费用流模型
费用流模型:将源点向左部所有点连一条容量为 \(1\) 费用为 \(0\) 的边,将右部所有点向汇点连一条容量为 \(1\) 费用为 \(0\) 的边。将左部向右部的连边容量设为 \(1\) 并保持费用不变。此时该网络的最大费用最大流即为最大匹配。
二分图最大多重匹配:最大流模型
最大流模型:将源点向左部的节点 \(i(1 \leq i \leq n_l)\) 连一条容量为 \(kl_i\) 的边,将右部的节点 \(j(1 \leq j \leq n_r)\) 向汇点连一条容量为 \(kr_i\) 的边。将左部向右部的连边容量设为 \(1\)。此时该网络的最大流即为最大多重匹配。
二分图常用模型
点覆盖:一组点的集合 \(S\),满足任意一条边都至少有一个端点属于该点集。
边覆盖:一组边的集合 \(S\),满足任意一个点都至少有一条邻边属于该边集。
独立集:一组点的集合 \(S\),满足该点集中的点两两没有边。
团:一组点的集合 \(S\),满足该点集中的点两两有边。
二分图中的最小点覆盖(König 定理):在二分图中,最小点覆盖等于最大匹配。
König 定理证明:对于同一个二分图的任意一个匹配,由于匹配中的边的端点不相交,则匹配中的任意一条边至少有一个端点要在点覆盖中,即任意匹配 \(\leq\) 任意点覆盖。现在考虑对最大匹配构造一个点覆盖
- 对二分图求一个最大匹配。
- 从左部每个非匹配点出发,再执行一次 DFS 寻找增广路的过程(一定会失败,以匹配边结束),标记路上经过的所有点。
- 取左部未被标记点,右部标记点,就得到了一组点覆盖。
上述构造,对点覆盖所包含点数等于最大匹配所包含边数的讨论:
- 左部非匹配点一定都被标记。因为他们是出发点。
- 右部非匹配点一定都没被标记。否则就找到了增广路。
- 一对匹配点要么都被标记,要么都没被标记。因为在找增广路的过程中,左部匹配点只能通过右部到达。
由于在上述构造中,我们取了左部未标记点,右部标记点。根据对点的讨论可以发现,恰好是每条匹配边取了一个点,所以选出的点数等于最大匹配所包含的边数。
上述构造,对点覆盖合法性的讨论:
- 匹配边一定被覆盖。因为恰好有一个端点被取走。
- 连接两个非匹配点的边一定不存在。否则就有长度为 \(1\) 的增广路了。
- 连接左部非匹配点 \(i\) 与右部匹配点 \(j\) 的边。\(j\) 一定被访问,因为 \(i\) 是出发点,而我们取了所有右部标记点,因此这样的边也被覆盖。
- 连接左部匹配点 \(i\) 与右部非匹配点 \(j\) 的边。\(i\) 一定没有被访问,否则再走到 \(j\) 就找到了增广路,而我们取了所有左部未被标记点,因此这样的边也被覆盖。
二分图中的最大独立集:在二分图中,最大独立集与最小点覆盖互补。
二分图中的最小边覆盖:在二分图中,若不存在独立点,则最小边覆盖等于最大独立集。
无向图中的最大团:在无向图中,原图的最大团等于其补图的最大独立集。
有向无环图中的最小不相交路径覆盖:在有向无环图 \(G = (V, E)\) 中,原图 \(G\) 的最小不相交路径覆盖等于 \(|V|\) 减去其拆点二分图 \(G_0\) 的最大匹配。
- 构造拆点二分图 \(G_0\),其左、右部节点数均为 \(|V|\),若 \((x, y) \in E\),则在左部点 \(x\) 与右部点 \(y\) 之间连边。
Hall 定理
Hall 定理:一张二分图存在完备匹配,当且仅当对于 \(1 \leq k \leq |X|\),均满足从 \(X\) 中选出 \(k\) 个不同点,连向 \(Y\) 的点集大小 \(\geq k\)。
Trick
树上最大独立集
- 先选上所有叶子,然后从叶子向上考察每个点能否进入独立集,能选则选。
F - 图论 网络流
网络基础
网络流图:
- 源点:有且仅有一个点 \(s\),它的入度为 \(0\),称这个点为源点。
- 汇点:有且仅有一个点 \(t\),它的出度为 \(0\),称这个点为汇点。
- 容量:每一条有向边,均有流量的上限,即容量。
容量函数 \(c\):\(V \times V \to \R\) 的函数,满足
流量函数 \(f\):\(V \times V \to \R\) 的函数,满足以下条件
- 容量限制:\(\forall (u, v), f(u, v) \leq c(u, v)\)。
- 斜对称性:\(\forall (u, v), f(u, v) = -f(v, u)\)。
- 流守恒性:从源点流出的流量等于汇点流出的流量,即
或
饱和弧 / 非饱和弧:在一个可行流中,若一条边的流量等于容量,则称这条边为饱和弧。否则称这条边为非饱和弧。
零弧:在一个可行流中,若一条边的流量为 \(0\),则称这条边为零弧。
前向弧 / 后向弧:在一个可行流中,对于一条 \(s \to t\) 的路径,与路径方向相同的边称为前向弧。否则称为后向弧。
剩余容量函数 \(c_f\):\(c_f(u, v) = c(u, v) - f(u, v)\)。
残量网络 \(G_f\):\(G_f = (V_f = V, E_f = \{(u, v), c_f(u, v) > 0\})\)。
整张网络的流量 \(|f|\):\(|f| = \sum_v f(s, v)\)。
最大流
增广路:残量网络上 \(s \to t\) 的路径,前向弧都是非饱和弧,后向弧都是非零弧。
EK
EK:
-
每次在当前流 \(f\) 的残量网络 \(G_f\) 上,用 BFS 找到一个最短增广路 \((x_1 = s, x_2, \cdots, x_{k - 1}, x_k = t)\),将增广路描述成一个流函数 \(f_0\):
- 对于 \(1 \leq i < k\):
\[f_0(x_i, x_{i + 1}) = + \min_{1 \leq j < k} \{c_f(x_j, x_{j + 1})\} \\f_0(x_{i + 1}, x_{i}) = - \min_{1 \leq j < k} \{c_f(x_j, x_{j + 1})\} \]- 而其余函数值为 \(0\)。
-
得到一个流量更大的流函数 \(f' = f + f_0\)。
-
不断重复此过程,直到当前流 \(f\) 的残量网络 \(G_f\) 上不存在增广路。
EK 时间复杂度:\(\mathcal{O}(nm^2)\)。
EK 时间复杂度证明:
- 每次寻找的增广路长度是不降的。
- 更强结论:\(\forall v \in V, d'(s, v) \geq d(s, v)\)。
- 增广次数:\(\mathcal{O}(nm)\)。
- 每次增广时,\(c_f(u, v)\) 最小的边记作关键边。一条边成为关键边的次数至多为 \(\frac{n - 1}{2}\)。
const int inf = 0x3f3f3f3f;
namespace nw {
const int Nx = ..., Mx = ...;
int S, T;
int tot = 1, head[Nx], ver[Mx * 2], edge[Mx * 2], Next[Mx * 2];
void add_edge(int u, int v, int w) {
ver[++ tot] = v; edge[tot] = w; Next[tot] = head[u]; head[u] = tot;
}
void add_network(int u, int v, int w) {
add_edge(u, v, w), add_edge(v, u, 0);
}
int pre[Nx], mf[Nx];
bool vis[Nx];
bool bfs() {
for (int i = S; i <= T; i ++) vis[i] = 0;
std::queue<int> q; while (q.size()) q.pop();
q.push(S), vis[S] = 1, mf[S] = inf;
while (q.size()) {
int u = q.front(); q.pop();
for (int i = head[u]; i; i = Next[i]) {
int v = ver[i], w = edge[i];
if (w > 0 && !vis[v]) {
vis[v] = 1, pre[v] = i, mf[v] = std::min(mf[u], w);
q.push(v);
if (v == T) return 1;
}
}
}
return 0;
}
int EK() {
int max_flow = 0;
while (bfs()) {
max_flow += mf[T];
for (int x = T; x != S; x = ver[pre[x] ^ 1])
edge[pre[x]] -= mf[T], edge[pre[x] ^ 1] += mf[T];
}
return max_flow;
}
}
Dinic
Dinic:本质和 EK 相同,但优化了寻找增广路的过程。
- 分层图优化:Dinic 算法首先对图进行一次 BFS,然后在 BFS 生成的分层图中进行多次 DFS。这样就切断了原图中许多不必要的连接,可以进行多路增广,减少了调用增广函数的次数。
- 不完全 BFS 优化:在 BFS 到汇点 \(t\) 之后就可以停止了,因为后面的路径一定更长。
- 当前弧优化:每次 DFS 完,找到容量最小的一条边。在这条边之前的路径容量 \(\geq\) 这条边的容量,可能会引出其他的增广路。这样的话,在找到第一条增广路后,回溯到容量最小边的起点时,可以避免继续枚举已经流满的容量最小边。
- 点优化:在同一次 DFS 中,如果一个点引发不出任何的增广路,可以将该点在层次图中抹去。
Dinic 时间复杂度:\(\mathcal{O}(n^2m)\)。
Dinic 时间复杂度证明:
- 在一张分层图上,执行 DFS 的次数最多为 \(\mathcal{O}(m)\)。
- 建立分层图的次数不超过 \(n - 1\)。
- 每轮增广后,残量网络的 \(d(s, t)\) 严格单调递增。
const int inf = 0x3f3f3f3f;
namespace nw {
const int Nx = ..., Mx = ...;
int S, T;
int tot = 1, head[Nx], ver[Mx * 2], edge[Mx * 2], Next[Mx * 2];
void add_edge(int u, int v, int w) {
ver[++ tot] = v; edge[tot] = w; Next[tot] = head[u]; head[u] = tot;
}
void add_network(int u, int v, int w) {
add_edge(u, v, w), add_edge(v, u, 0);
}
int lev[Nx];
int cur[Nx];
bool bfs() {
for (int i = S; i <= T; i ++) cur[i] = head[i];
for (int i = S; i <= T; i ++) lev[i] = 0;
std::queue<int> q; while (q.size()) q.pop();
q.push(S), lev[S] = 1;
while (q.size()) {
int u = q.front(); q.pop();
for (int i = head[u]; i; i = Next[i]) {
int v = ver[i], w = edge[i];
if (w > 0 && !lev[v]) {
lev[v] = lev[u] + 1;
q.push(v);
if (v == T) return 1;
}
}
}
return 0;
}
int dfs(int u, int flow) {
if (u == T) return flow;
int res = 0;
for (int &i = cur[u]; i; i = Next[i]) {
int v = ver[i], w = edge[i];
if (w > 0 && lev[u] < lev[v]) {
int delta = dfs(v, std::min(w, flow - res));
if (delta) {
edge[i] -= delta, edge[i ^ 1] += delta;
res += delta;
if (res == flow) break;
}
}
}
if (res < flow) lev[u] = 0;
return res;
}
int dinic() {
int max_flow = 0;
while (bfs()) max_flow += dfs(S, inf);
return max_flow;
}
}
常用模型
限制点的流量:可以考虑将点拆成「入点」与「出点」,从入点向出点连一条有容量的边。
最小割
割:在图 \(G = (V, E)\) 上,对于某个点集 \(P \subseteq V\),割 \((P, V \backslash P)\) 定义为
割的容量:割包含的边的容量之和,即
\(s \to t\) 割:满足 \(s \in P\) 且 \(t \in V \backslash P\) 的割。
最大流与最小割:在一张网络中,最大流等于最小割。
最大流与最小割证明:对于同一个网络的任意一个流 \(f\) 和任意一个 \(s \to t\) 割 \((S, T)\),均有
即任意流 \(\leq\) 任意割。考虑 EK 算法求得的流 \(f\),记流 \(f\) 对应的残量网络中从 \(s\) 出发可达的所有点组成的点集为 \(S\),不可达的所有点组成的点集为 \(T\),由于找不到流 \(f\) 的增广路,故 \((S, T)\) 是一个 \(s \to t\) 割,且由残量网络的定义可得 \(|f| = c(S, T)\)。故最大流等于最小割。
最小割的可行边(并)与必经边(交):
- 可行边:一条边 \((u, v, w)\) 是最小割可行边,当且仅当这条边满流,且 \(G_f\) 不存在 \(u \to v\) 的路径(\(u, v\) 不在同一个强连通分量内)。
- 必经边:一条边 \((u, v, w)\) 是最小割必经边,当且仅当这条边满流,且 \(G_f\) 上存在 \(s \to u\) 和 \(v \to t\) 的路径(\(u\) 和 \(s\) 在同一个强连通分量内,\(v\) 和 \(t\) 在同一个强连通分量内)。
常用模型
二选一模型:有 \(n\) 个任务,每个任务可以在机器 \(A\) 或机器 \(B\) 上完成,花费分别为 \(a_i\) 和 \(b_i\)。有 \(m\) 对二元关系 \((x_i, y_i)\),若第 \(x_i\) 个任务与第 \(y_i\) 个任务不在同一个机器上完成,则增加 \(v_i\) 的花费。求最小总花费。
-
模型构造:
- 建源点 \(s\) 与汇点 \(t\),每个任务都建一个点,标号 \(1 \sim n\)。
- \(S\) 向第 \(i\) 个任务连一条容量为 \(a_i\) 的边,第 \(i\) 个任务向 \(T\) 连一条容量为 \(b_i\) 的边。
- 对于每个二元关系 \((x_i, y_i)\),在第 \(x_i\) 个任务与第 \(y_i\) 个任务间连一条容量为 \(v_i\) 的双向边。
-
模型分析:
- 第 \(i\) 个任务在机器 \(A\) 上完成,就要割去边权为 \(a_i\) 的边。第 \(i\) 个任务在机器 \(B\) 上完成,就要割去边权为 \(b_i\) 的边。
- 对于每个二元关系 \((x_i, y_i)\),若 \(x_i, y_i\) 没有被分配到同一个机器,就要割去边权为 \(v_i\) 的边。
闭合子图:一个有向图 \(G = (V, E)\) 的闭合子图,是该有向图的一个子图,且该子图内点集的所有出边都指向该点集。
最大权闭合子图:点权和最大的闭合子图。
最大权闭合子图模型:有 \(n\) 个事件,事件发生会带来收益 \(w_i\),\(w_i\) 可正可负,一个事件发生的前提是指定的若干事件必须发生。求最优的确定所有事件是否发生的方案,使得收益最大。
- 模型构造:
- 建源点 \(s\) 与汇点 \(t\),对每个事件都建一个点,标号 \(1 \sim n\)。
- 若 \(w_i > 0\),则令 \(S\) 向 \(i\) 连一条容量为 \(w_i\) 的边;若 \(w_i < 0\),则令 \(i\) 向 \(T\) 连一条容量为 \(-w_i\) 的边。
- 对于原图中的边,容量设为 \(+\infty\)。
- 模型分析:最大权闭合子图 \(=\) 正点权和 \(-\) 最小割。
- 在一个割中割去的边,负权点表示选择,正权点表示不选。
- 可以证明,在一个割中,已割去的负权点与未割去的正权点组成的子图 \(W\) 与原图的闭合子图对应。如果 \(W\) 不是闭合子图,出边可能指向已割去的正权点或未割去的负权点。若为前者,则 \(W\) 内的流越过了这个割;若为后者,则 \(W\) 内的流可以流向汇点。
费用流
EK & Dinic:每次需要扩展一条费用最小的增广路。
const int inf = 0x3f3f3f3f;
namespace nw {
const int Nx = ..., Mx = ...;
int S, T;
int tot = 1, head[Nx], ver[Mx * 2], Next[Mx * 2]; int edge[Mx * 2], cost[Mx * 2];
void add_edge(int u, int v, int w, int c) {
ver[++ tot] = v; edge[tot] = w; cost[tot] = c; Next[tot] = head[u]; head[u] = tot;
}
void add_network(int u, int v, int w, int c) {
add_edge(u, v, w, +c), add_edge(v, u, 0, -c);
}
int max_flow, min_cost;
int dist[Nx];
bool exist[Nx];
int cur[Nx];
bool vis[Nx];
bool spfa() {
for (int i = S; i <= T; i ++) cur[i] = head[i], vis[i] = 0;
for (int i = S; i <= T; i ++) dist[i] = inf;
std::queue<int> q;
q.push(S), dist[S] = 0;
while (q.size()) {
int u = q.front(); q.pop(); exist[u] = 0;
for (int i = head[u]; i; i = Next[i]) {
int v = ver[i], w = edge[i], c = cost[i];
if (w > 0 && dist[u] + c < dist[v]) {
dist[v] = dist[u] + c;
if (!exist[v]) {
exist[v] = 1;
q.push(v);
}
}
}
}
return dist[T] < inf;
}
int dfs(int u, int flow) {
if (u == T) {
min_cost += flow * dist[T];
return flow;
}
vis[u] = 1;
int res = 0;
for (int &i = cur[u]; i; i = Next[i]) {
int v = ver[i], w = edge[i], c = cost[i];
if (w > 0 && !vis[v] && dist[u] + c == dist[v]) {
int delta = dfs(v, std::min(w, flow - res));
if (delta) {
edge[i] -= delta, edge[i ^ 1] += delta;
res += delta;
if (res == flow) break;
}
}
}
if (res == flow) vis[u] = 0;
return res;
}
void dinic() {
max_flow = 0, min_cost = 0;
while (spfa()) max_flow += dfs(S, inf);
}
}