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\),则三元环个数为

\[\dbinom{n}{3} - \sum\limits_{i = 1}^n \dbinom{\mathrm{out}_i}{2} \]

无向图四元环计数

  • 将所有点以度数为第一关键字(小到大)编号为第二关键字(小到大)排序,将所有边按排名从小到大定向,形成一张 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\) 指向编号最小的叶节点。同时维护每个节点的度数,考虑重复如下过程:
    1. 删除节点 \(p\),并检查是否产生新的叶节点。
    2. 如果产生新的叶节点 \(x\),比较 \(x, p\) 的大小关系,若 \(x > p\),则不做其他操作;若 \(x < p\),则立刻删除 \(x\),然后检查是否产生新的叶节点 ...(重复步骤 2),直到未产生新节点或新节点的编号 \(>p\)
    3. \(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; 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 SZ = N * 2;

	int nClock;
	struct node {
		int lc, rc;
		int val;
	} t[SZ];

	int fa[SZ];
	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.
		}

		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\) 的父亲,则

\[\text{low}_x = \min(\text{low}_x, \text{low}_y) \]

  • \((x, y)\) 为非搜索树边,则

\[\text{low}_x = \min(\text{low}_x, \text{dfn}_y) \]

割点

割点:满足删去该点以及连接该点的边后,原图不连通的点。

割点判定法则:在搜索树上,若存在 \(x\) 的某个儿子 \(y\) 满足

\[\mathrm{dfn}_x \leq \mathrm{low}_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\) 的父亲,且满足

\[\mathrm{dfn}_x < \mathrm{low}_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 SZ = 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<SZ, SZ * 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\) 的父亲,则

\[\mathrm{low}_x = \min(\mathrm{low}_x, \mathrm{low}_y) \]

  • \(y\) 被访问过,且 \(y\) 在栈中,则

\[\mathrm{low}_x = \min(\mathrm{low}_x, \mathrm{dfn}_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\) 的函数,满足

\[\forall (u, v) \in E, c(u, v) \geq 0 \\ \forall (u, v) \notin E, c(u, v) = 0 \]

流量函数 \(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)\)
  • 流守恒性:从源点流出的流量等于汇点流出的流量,即

\[\forall x \in V \backslash \{s, t\}, \sum\limits_{(u, x) \in E} f(u, x) = \sum\limits_{(x, v) \in E} f(x, v) \]

\[\forall x \in V \backslash \{s, t\}, \sum\limits_{(x, y)} f(x, y) = 0 \]

饱和弧 / 非饱和弧:在一个可行流中,若一条边的流量等于容量,则称这条边为饱和弧。否则称这条边为非饱和弧。

零弧:在一个可行流中,若一条边的流量为 \(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 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)\) 定义为

\[(P \times (V \backslash P)) \bigcap E \]

割的容量:割包含的边的容量之和,即

\[c(P, V \backslash P) = \sum\limits_{u \in P}\sum\limits_{v \in V \backslash P} c(u, v) \]

\(s \to t\):满足 \(s \in P\)\(t \in V \backslash P\) 的割。

最大流与最小割在一张网络中,最大流等于最小割

最大流与最小割证明:对于同一个网络的任意一个流 \(f\) 和任意一个 \(s \to t\)\((S, T)\),均有

\[|f| = \sum\limits_{u \in S}\sum\limits_{v \in T} f(u, v) \leq \sum\limits_{u \in S} \sum\limits_{v \in T} c(u, v) = c(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);
	}
}
posted @ 2022-12-19 10:23  Calculatelove  阅读(147)  评论(0编辑  收藏  举报