连通性相关

连通性相关

强连通分量

强连通分量(SCC):极大的强连通子图。

Tarjan 算法

维护一个栈存储搜索到的还未确定强连通分量的点,定义:

  • \(dfn_u\) :节点 \(u\) 被搜索的次序。
  • \(low_u\)\(u\) 子树中能回溯到的最小的 \(dfn\)

不难得到:

  • 一个点子树内的 \(dfn\) 大于该点的 \(dfn\)
  • 从根开始的路径上的 \(dfn\) 递增,\(low\) 非降。

对于 \(u\) 的出点 \(v\) ,考虑

  • \(v\) 未被访问过:继续 dfs ,并用 \(low_v\) 更新 \(low_u\) 。因为存在 \(u \to v\) ,所以 \(v\) 可以直接回溯到已在栈中的点 \(u\) 一定可以回溯到。

  • \(v\) 被访问过

    • 已在栈中:根据 \(low\) 的定义,用 \(dfn_v\) 更新 \(low_u\)

    • 不在栈中:说明 \(v\) 已搜索完毕,其所在的连通分量已被处理,不用管它。

对于一个强连通分量,不难发现只有一个 \(u\) 满足 \(dfn_u = low_u\) ,其一定是这个强连通分量的根。

因此回溯过程中,若 \(dfn_u = low_u\) ,则新增一个强连通分量。

void Tarjan(int u) {
	dfn[u] = low[u] = ++dfstime, sta[++top] = u;

	for (int v : G.e[u]) {
		if (!dfn[v]) {
			Tarjan(v);
			low[u] = min(low[u], low[v]);
		} else if (!leader[v])
			low[u] = min(low[u], dfn[v]);
	}

	if (dfn[u] == low[u]) {
		++scc;

		while (sta[top] != u)
			leader[sta[top--]] = scc;
		
		leader[sta[top--]] = scc;
	}
}

应用

对于一张有向图,其可能存在环。可以将每个强连通分量分别缩成一个点,这个图就会变成一张 DAG,可能会便于处理。

Kosaraju 算法

  • 第一次 dfs:遍历所有点并在回溯时入栈。
  • 第二次 dfs:在反图上依次从栈顶开始 dfs ,此时遍历到的点集就是一个强连通分量。

时间复杂度 \(O(n + m)\)

void dfs1(int u) {
	vis[u] = true;
	
	for (int v : G.e[u])
		if (!vis[v])
			dfs1(v);
	
	sta.emplace(u);
}

void dfs2(int u) {
	leader[u] = scc;
	
	for (int v : rG.e[u])
		if (!leader[v])
			dfs2(v);
}

inline void kosaraju() {
	for (int i = 1; i <= n; ++i)
		if (!vis[i])
			dfs1(i);
	
	for (; !sta.empty(); sta.pop())
		if (!leader[sta.top()])
			++scc, dfs(sta.top());
}

bitset 优化可以做到 \(O(\dfrac{n^2}{\omega})\) 的复杂度,某些题目有奇效。

void dfs1(int u) {
	vis.set(u);
	bitset<N> now = ~vis & e1[u];
	
	while (now.any())
		dfs1(now._Find_first()), now &= ~vis;
	
	sta.push(u);
}

void dfs2(int u) {
	vis.set(u), leader[u] = scc;
	bitset<N> now = ~vis & e2[u];
	
	while (now.any())
		dfs2(now._Find_first()), now &= ~vis;
}

inline void kosaraju() {
	vis.reset();
	
	for (int i = 1; i <= n; ++i)
		if (!vis.test(i))
			dfs1(i);
	
	vis.reset();
	
	for (; !sta.empty(); sta.pop())
		if (!vis.test(sta.top()))
			++scc, dfs(sta.top());
}

应用

BZOJ5218 省队十连测 友好城市

给出一张有向图,\(q\) 次询问仅保留编号属于 \([l_i, r_i]\) 的边时有多少无序对城市满足可以两两到达。

\(n \leq 150, m \leq 3 \times 10^5, q \leq 5 \times 10^4\)

注意到 \(n\) 很小,使用 kosaraju 配合莫队即可,时间复杂度 \(O(\dfrac {(\sqrt{m} \log m + q) n^2}{\omega} + q \sqrt m)\)

#include <bits/stdc++.h>
using namespace std;
const int N = 1.5e2 + 7, M = 3e5 + 7, Q = 5e4 + 7;

struct Edge {
	int u, v;
} E[M];

struct Query {
	int l, r, *ans, bid;
	
	inline bool operator < (const Query &rhs) const {
		return bid == rhs.bid ? (bid & 1 ? r < rhs.r : r > rhs.r) : bid < rhs.bid;
	}
} qry[Q];

bitset<N> e1[N], e2[N];
bitset<N> vis;
stack<int> sta;

int cnt1[N][N], cnt2[N][N], ans[Q];

int n, m, q, block, scc;

template <class T = int>
inline T read() {
	char c = getchar();
	bool sign = c == '-';
	
	while (c < '0' || c > '9')
		c = getchar(), sign |= c == '-';
	
	T x = 0;
	
	while ('0' <= c && c <= '9')
		x = (x << 1) + (x << 3) + (c & 15), c = getchar();
	
	return sign ? (~x + 1) : x;
}

inline void Add(int x) {
	int u = E[x].u, v = E[x].v;
	
	if (!cnt1[u][v])
		e1[u][v] = true;
	
	if (!cnt2[v][u])
		e2[v][u] = true;
	
	++cnt1[u][v], ++cnt2[v][u];
}

inline void Del(int x) {
	int u = E[x].u, v = E[x].v;
	--cnt1[u][v], --cnt2[v][u];
	
	if (!cnt1[u][v])
		e1[u][v] = false;
	
	if (!cnt2[v][u])
		e2[v][u] = false;
}

void dfs1(int u) {
	vis.set(u);
	bitset<N> now = ~vis & e1[u];
	
	while (now.any())
		dfs1(now._Find_first()), now &= ~vis;
	
	sta.push(u);
}

int dfs2(int u) {
	vis.set(u);
	bitset<N> now = ~vis & e2[u];
	int siz = 1;
	
	while (now.any())
		siz += dfs2(now._Find_first()), now &= ~vis;

	return siz;
}

inline int kosaraju() {
	vis.reset(), scc = 0;
	int res = 0;
	
	for (int i = 1; i <= n; ++i)
		if (!vis.test(i))
			dfs1(i);
	
	vis.reset();
	
	for (; !sta.empty(); sta.pop())
		if (!vis.test(sta.top())) {
			int siz = dfs2(sta.top());
			res += siz * (siz - 1) / 2;
		}
	
	return res;
}

signed main() {
	n = read(), m = read(), q = read();
	block = sqrt(m);
	
	for (int i = 1; i <= m; ++i)
		E[i].u = read(), E[i].v = read();
	
	for (int i = 1; i <= q; ++i)
		qry[i].l = read(), qry[i].r = read(), qry[i].bid = qry[i].l / block, qry[i].ans = ans + i;
	
	sort(qry + 1, qry + 1 + q);
	
	for (int i = 1, l = 1, r = 0; i <= q; ++i) {
		while (l > qry[i].l)
			Add(--l);
		
		while (r < qry[i].r)
			Add(++r);
		
		while (l < qry[i].l)
			Del(l++);
		
		while (r > qry[i].r)
			Del(r--);
		
		*qry[i].ans = kosaraju();
	}
	
	for (int i = 1; i <= q; ++i)
		printf("%d\n", ans[i]);
	
	return 0;
}

应用

P9150 邮箱题

给定一张有向图,每个点有 \(k_i\) 号钥匙,保证 \(k\) 为排列。能进入一个点当且仅当你有该点的钥匙。

对每个点 \(i\) ,求拿着 \(i\) 号点的钥匙从 \(i\) 出发可达多少点,以及有多少点可达且能够返回 \(i\)

\(\sum n \leq 1.5 \times 10^6, \sum m \leq 3 \times 10^6\)

首先在任意时刻,接下来新进入的点是固定的,且该点的钥匙在上一个新进入的点内。因此只要依次考虑 \(k_i, k_{k_i}, \cdots\) 是否可达。

对于当前点 \(j\) 以及从 \(i\) 不断跳 \(k_i\)\(j\) 的序列 \(a = \{i, k_i, k_{k_i}, \cdots, j \}\) ,考虑 \(k_j\) 可达的充要条件:

  • \(k_j \not \in a\) ,即 \(k_j \neq i\),因为 \(k\) 形成排列。
  • 对于最大的 \(p\) 使得 \(a_p\to k_j\)\(a_p\) 存在且 \(j\) 可以回到 \(a_p\)

因为 \(a_i\) 可达 \(a_{i + 1}\),所以每次新进入点 \(j\) 时一条返祖边覆盖的点强连通。枚举 \(k_j\) 的入边 \(u \to k_j\) ,若 \(u \in a\)\(u\)\(j\) 强连通,则 \(k_j\) 可达。用并查集维护 SCC 的合并,于是得到 \(O(n^2 \alpha(n))\) 的做法。

因为 \(k\) 形成排列,考虑将 \(k\) 上的环单独考虑。首先断环成链,将环复制两份得到序列 \(c\) 。令 \(k_i\)\(i\) 的后继,则每个点的前一份复制品在链上的答案就是它在环上的答案。

因为后面的点不会到达前面的点,所以我们从后往前加入计算每个点的答案。不难发现可达性具有传递性,于是可以用若干条链描述。

\(c_{i + 1} \sim c_L\) 的答案已知,计算 \(c_i\) 的答案,则整个过程相当于不断尝试合并两条链。设第一条链的末尾为 \(c_p\) ,第二条链的开头为 \(c_{p + 1}\) ,根据朴素做法中的判定条件,考虑 \(c_{p + 1}\) 的最靠近 \(c_p\) 且落在 \(c_i \sim c_p\) 上的入边 \(c_u \to c_{p + 1}\)\(i \leq u \leq p\)\(u\) 最大),若 \(c_u\) 存在且和 \(c_p\) 强连通,则可以合并,否则无法合并。

用两个并查集分别维护可达链和强连通分量,预处理每个点在链上最靠近它且在它前方的入点的位置即可。

接下来考虑合并两条可达链时强连通分量的合并。虽然产生贡献的返祖边数量总数只有 \(O(m)\),但若直接枚举第二条链上的所有返祖边就无法跳过无贡献的返祖边。一个解决方案是对每个可达链维护所有终点编号不小于 \(i\) 且未考虑过的返祖边。合并两条链时,统计第二条链维护的所有返祖边的影响,并将它们全部删除。此时新链的返祖边集合就等于第一条链的返祖边集合,不需要启发式合并。

于是得到 \(O(n \alpha(n))\) 的做法。

注意到如果两条链可以合并,那么第一条链的开头,即当前的 \(c_i\) 一定产生了贡献,否则在加入 \(c_i\) 之前这两条链就已经可以合并了。而如果 \(c_i\) 要产生贡献,一定将第一条链的末尾 \(c_p\) 所在的强连通分量扩大了,这说明 \(c_i\)\(c_p\) 在同一强连通分量,即第一条链整体是一个强连通分量。这样,对于每条链,只需维护编号最大的有还未统计过的返祖边的节点,因为这些返祖边具体指向哪些点是不重要的,反正它们在同一个强连通分量,而每个起始点都相当于将 \(c_i\) 到该起始点的强连通分量全部合并,所以我们只关心编号最大的起始点。

此外,有了该性质,在判定是否可以合并两条链时,也可以维护每个点 \(c_{p + 1}\) 是否被之前 \(c_i \sim c_p\) 的某个点指向,代替计算每个点的最近前驱的位置,但这样就要存正向边了。

#include <bits/stdc++.h>
using namespace std;
const int N = 3e6 + 7;

struct Graph {
	vector<int> e[N];

	inline void clear(int n) {
		for (int i = 1; i <= n; ++i)
			e[i].clear();
	}
	
	inline void insert(int u, int v) {
		e[u].emplace_back(v);
	}
} rG;

struct DSU {
	int fa[N];

	inline void prework(int n) {
		iota(fa + 1, fa + 1 + n, 1);
	}

	inline int find(int x) {
		while (x != fa[x])
			fa[x] = fa[fa[x]], x = fa[x];

		return x;
	}

	inline void merge(int x, int y) {
		x = find(x), y = find(y);

		if (x != y)
			fa[y] = x;
	}
} dsu1, dsu2;

pair<int, int> ans[N];

int k[N], cyc[N], idx[N], mxid[N], pre[N];
// idx : 结点在环上的编号
// mxid : 结构编号最大的有返祖边的分量
// pre : 结点的最近前驱入边位置
bool vis[N];

int n, m, tot;

template <class T = int>
inline T read() {
	char c = getchar();
	bool sign = (c == '-');
	
	while (c < '0' || c > '9')
		c = getchar(), sign |= (c == '-');
	
	T x = 0;
	
	while ('0' <= c && c <= '9')
		x = (x << 1) + (x << 3) + (c & 15), c = getchar();
	
	return sign ? (~x + 1) : x;
}

signed main() {
	int T = read();

	while (T--) {
		n = read(), m = read();

		for (int i = 1; i <= n; ++i)
			k[i] = read();

		rG.clear(n);

		for (int i = 1; i <= m; ++i) {
			int u = read(), v = read();
			rG.insert(v, u);
		}

		memset(vis + 1, false, sizeof(bool) * n);

		for (int i = 1; i <= n; ++i) {
			if (vis[i])
				continue;

			int cur = i;

			while (!idx[cur])
				cyc[idx[cur] = ++tot] = cur, cur = k[cur];

			dsu2.prework(tot * 2), dsu1.prework(tot * 2);
			memset(mxid + 1, 0, sizeof(int) * tot * 2);
			memset(pre + 1, 0, sizeof(int) * tot * 2);

			for (int j = tot * 2; j; --j) {
				int u = cyc[j <= tot ? j : j - tot];

				for (int v : rG.e[u]) {
					if (!idx[v])
						continue;

					v = idx[v];

					if (v + tot < j)
						v += tot;
					else if (v > j)
						v -= tot;

					pre[j] = max(pre[j], v);

					if (v < j)
						v += tot;

					if (v <= tot * 2)
						mxid[dsu1.find(v)] = max(mxid[dsu1.find(v)], dsu2.find(v));
				}

				for (;;) {
					for (;;) {
	                    int id1 = dsu1.find(j), id2 = dsu2.find(j);

	                    if (id2 < mxid[id1])
	                        dsu2.merge(id2 + 1, id2);
	                    else
	                        break;
					}

					int id1 = dsu1.find(j), id2 = dsu2.find(j);
	                mxid[id1] = 0;

	                if (id1 == tot * 2 || id2 != id1 || pre[id1 + 1] < j)
	                    break;

	                dsu1.merge(id1 + 1, id1);
				}

				ans[u] = make_pair(min(tot, dsu1.find(j) - j + 1), min(tot, dsu2.find(j) - j + 1));
			}

			for (int j = 1; j <= tot; ++j)
				idx[cyc[j]] = 0, vis[cyc[j]] = true;

			tot = 0;
		}

		for (int i = 1; i <= n; ++i)
			printf("%d %d\n", ans[i].first, ans[i].second);
	}

	return 0;
}

CF1515G Phoenix and Odometers

给定一张带边权的有向图,\(q\) 次询问,每次给定 \(v, s, t\) ,询问时候存在一条经过 \(v\) 的回路满足长度与 \(-s\) 在模 \(t\) 意义下同余。

\(n, m, q \leq 2 \times 10^5\)

首先不难发现每个 SCC 的答案是一致的,且不同 SCC 之间相互独立,故考虑对于每个 SCC 分开计算。

假设经过 \(u\) 有两个长度为 \(a\)\(b\) 的环,那么就相当于找两个非负整数 \(x\)\(y\),使得 \(ax + by = w\),其中 \(w\) 为题中的路径长,根据裴蜀定理得到上述方程成立当且仅当 \(\gcd(a, b) \mid w\)

考虑如何求出经过点 \(u\) 的所有环长度的 \(\gcd\) 。通过分析发现,所有的非树边 \(u \to v\) 对答案的贡献都是 \(dis_u + w - dis_v\) 。于是搜索时顺便记录贡献即可。

#include <bits/stdc++.h>
typedef long long ll;
using namespace std;
const int N = 2e5 + 7;


struct Graph {
	vector<pair<int, int> > e[N];
	
	inline void insert(const int u, const int v, const int w) {
		e[u].emplace_back(v, w);
	}
} G;

ll dis[N], g[N];
int dfn[N], low[N], sta[N], leader[N];
bool vis[N];

int n, m, q, dfstime, top, scc;

template <class T = int>
inline T read() {
	char c = getchar();
	bool sign = (c == '-');
	
	while (c < '0' || c > '9')
		c = getchar(), sign |= (c == '-');
	
	T x = 0;
	
	while ('0' <= c && c <= '9')
		x = (x << 1) + (x << 3) + (c & 15), c = getchar();
	
	return sign ? (~x + 1) : x;
}

inline ll gcd(ll a, ll b) {
	if (!a || !b)
		return a | b;

	while (a ^= b ^= a ^= b %= a);

	return b;
}

void Tarjan(int u) {
	dfn[u] = low[u] = ++dfstime, sta[++top] = u;

	for (auto it : G.e[u]) {
		int v = it.first;

		if (!dfn[v]) {
			Tarjan(v);
			low[u] = min(low[u], low[v]);
		} else if (!leader[v])
			low[u] = min(low[u], dfn[v]);
	}

	if (low[u] == dfn[u]) {
		++scc;

		while (sta[top] != u)
			leader[sta[top--]] = scc;

		leader[sta[top--]] = scc;
	}
}

void dfs(int u, int cur) {
	vis[u] = true;

	for (auto it : G.e[u]) {
		int v = it.first, w = it.second;

		if (leader[v] != cur)
			continue;

		if (!vis[v])
			dis[v] = dis[u] + w, dfs(v, cur);
		else
			g[cur] = gcd(g[cur], abs(dis[u] - dis[v] + w));
	}
}

signed main() {
	n = read(), m = read();

	for (int i = 1; i <= m; ++i) {
		int u = read(), v = read(), w = read();
		G.insert(u, v, w);
	}

	for (int i = 1; i <= n; ++i)
		if (!dfn[i])
			Tarjan(i);

	for (int i = 1; i <= n; ++i)
		if (!vis[i])
			dfs(i, leader[i]);

	q = read();

	while (q--) {
		int x = read(), s = read(), t = read();

		if (g[leader[x]])
			puts(s % gcd(g[leader[x]], t) ? "NO" : "YES");
		else
			puts(s ? "NO" : "YES");
	}

	return 0;
}

CF1361E James and the Chase

给定一张有向强连通图。一个点是好的当且仅当它到其它点有且只有一条简单路径。如果好的点至少有 \(20\%\) ,则输出所有好的点, 否则输出 \(-1\)

\(\sum n \leq 10^5, \sum m \leq 2 \times 10^5\)

考虑如何判定 \(u\) 是好的。只要以 \(u\) 为根建出 dfs 树,若无横叉边或前向边则 \(u\) 即为好的。于是可以做到 \(O(n)\) 的判定。

接下来考虑确定一个好节点 \(u\) 后求出其余好节点。以 \(u\) 为根建立 dfs 树,考虑某个 \(v\) 的子树,由于整个图的强连通性,\(v\) 的子树中有连向其祖先的返祖边。不难发现这样的边有且仅有一条,否则 \(v\) 有两条路径可以到 \(fa_v\)

那么先把所有子树 \(v\) 内返祖到根的祖先的边的数量记录下来,如果数量 \(\geq 2\)\(v\) 不为好点,否则就顺便记录每个 \(v\) 子树的那条返祖边指向的点。

假设 \(v\) 的子树这条返祖边指向了 \(w\),那么 \(v\) 是好点,当且仅当 \(w\) 是好点。

于是一个点 \(v\) 是好点,当且仅当 \(v\) 的子树内有且仅有一条连向 \(v\) 的祖先的返祖边,并且这条边所连向的点是好点。

第一个条件可以考虑所有返祖边 \((a,b)\),它对哪些 \(v\) 的子树内返向 \(v\) 祖先的边的数量的有贡献。显然这样的 \(v\) 分布在 \(a\to fa_a\to...\to son_b\) 上,这里的 \(son_b\)\(b\) 的儿子节点中靠近 \(a\) 侧的那个,树上差分即可和第二个条件一起解决。

于是可以在已知一个好点的情况下线性求出所有好点。

考虑随机选取一定数量的点进行上述算法流程,若均不满足条件则输出 \(-1\) 。注意随机取点不能取重。

#include <bits/stdc++.h>
using namespace std;
const int N = 1e5 + 7;

struct Graph {
	vector<int> e[N];

	inline void clear(int n) {
		for (int i = 1; i <= n; ++i)
			e[i].clear();
	}
	
	inline void insert(int u, int v) {
		e[u].emplace_back(v);
	}
} G, T;

int cnt[N], pre[N], dep[N];
bool vis[N], in[N], ans[N];

mt19937 myrand(time(0));
int n, m;

template <class T = int>
inline T read() {
	char c = getchar();
	bool sign = (c == '-');
	
	while (c < '0' || c > '9')
		c = getchar(), sign |= (c == '-');
	
	T x = 0;
	
	while ('0' <= c && c <= '9')
		x = (x << 1) + (x << 3) + (c & 15), c = getchar();
	
	return sign ? (~x + 1) : x;
}

bool dfs(int u) {
	vis[u] = in[u] = true;

	for (int v : G.e[u]) {
		if (vis[v] && !in[v])
			return false;
		else if (!in[v]) {
			dep[v] = dep[u] + 1, T.insert(u, v);
			
			if (!dfs(v))
				return false;
		} else  {
			++cnt[u], --cnt[v];

			if (dep[v] < dep[pre[u]])
				pre[u] = v;
		}
	}

	in[u] = 0;
	return true;
}

inline bool check(int u) {
	memset(cnt + 1, 0, sizeof(int) * n);
	memset(pre + 1, 0, sizeof(int) * n);
	memset(vis + 1, false, sizeof(bool) * n);
	memset(in + 1, false, sizeof(bool) * n);
	T.clear(n), dep[u] = 1, dep[0] = n + 1;
	return dfs(u);
}

inline void calc(int u) {
	for (int v : T.e[u]) {
		calc(v), cnt[u] += cnt[v];

		if (dep[pre[v]] < dep[pre[u]])
			pre[u] = pre[v];
	}
}

void solve(int u) {
	if(cnt[u] == 1 && dep[pre[u]] < dep[u] && ans[pre[u]])
		ans[u] = true;

	for (int v : T.e[u])
		solve(v);
}

signed main() {
	int T = read();

	while (T--) {
		n = read(), m = read();
		G.clear(n);

		for (int i = 1; i <= m; ++i) {
			int u = read(), v = read();
			G.insert(u, v);
		}

		vector<int> id(n);
		iota(id.begin(), id.end(), 1);
		shuffle(id.begin(), id.end(), myrand);
		int root = -1;

		for (int i = 0; i < min(n, 100); ++i)
			if (check(id[i])) {
				root = id[i];
				break;
			}

		if (root == -1) {
			puts("-1");
			continue;
		}

		calc(root);
		memset(ans + 1, false, sizeof(bool) * n);
		ans[root] = true, solve(root);

		if (count(ans + 1, ans + 1 + n, true) * 5 < n)
			puts("-1");
		else {
			for (int i = 1; i <= n; ++i)
				if (ans[i])
					printf("%d ", i);

			puts("");
		}
	}

	return 0;
}

2-SAT

2-SAT 问题:给定一串布尔变量,每个变量只能为真或假。要求对这些变量进行赋值,满足布尔方程。

实现

构造状态

点的状态:将点 \(u\)​​ 拆分成 \(u0,u1\)​ 两个点,分别表示 \(u\)​ 点为假、真。

边的状态:若连的边为 \(u \to v\) ,就表示选择 \(u\) 就必须选 \(v\)

判断有无解

由所构造的状态可知,对于图中的每一个强连通分量,如果选择了其中任意一个点,那就意味着这个强连通分量中的所有点都要选。显然 \(x0,x1\)​​ 不可以同时选,由此可判断有无解。

方案输出

由连边的方式可以得知,我们对于每个点的两种状态,选择拓扑序大的,舍弃掉另一个。

注意到用 Tarjan 求得的强连通分量编号就是反拓扑序,于是选择强连通分量编号较小的点即可。

如果要求字典序最小,就深搜枚举点 \(1 \to 2n\) ,贪心选取。

P4782 【模板】2-SAT 问题

#include <bits/stdc++.h>
using namespace std;
const int N = 2e6 + 7;

vector<int> e[N];
stack<int> sta;

int dfn[N], low[N], leader[N];

int n, m, dfstime, cnt;

inline void AddEdge(int u, int v) { 
    e[u].push_back(v); 
}

inline void Tarjan(int u) {
    dfn[u] = low[u] = ++dfstime;
    sta.push(u);
    
    for (int i = 0, v; i < e[u].size(); ++i) {
        v = e[u][i];
        
        if (!dfn[v]) {
            Tarjan(v);
            low[u] = min(low[u], low[v]);
        } else if (!leader[v])
            low[u] = min(low[u], dfn[v]);
    }
    
    if (dfn[u] == low[u]) {
        for (++cnt; sta.top() != u; sta.pop()) 
            leader[sta.top()] = cnt;
            
        leader[sta.top()] = cnt;
        
        sta.pop();
    }
}

signed main() {
    scanf("%d%d", &n, &m);
    
    for (int i = 1, a, x, b, y; i <= m; ++i) {
        scanf("%d%d%d%d", &a, &x, &b, &y);
        
        if (!x && !y)
            AddEdge(a + n, b), AddEdge(b + n, a);
        else if (x && y)
            AddEdge(a, b + n), AddEdge(b, a + n);
        else if (!x && y)
            AddEdge(b, a), AddEdge(a + n, b + n);
        else if (x && !y)
            AddEdge(a, b), AddEdge(b + n, a + n);  // a 表示 a0, a + n 表示 a1
    }
    
    for (int i = 1; i <= (n << 1); ++i)
        if (!dfn[i])
            Tarjan(i);
    
    for (int i = 1; i <= n; ++i)
        if (leader[i] == leader[i + n])  // 若 a0 和 a1 必须同时选, 就无解
            return puts("IMPOSSIBLE"), 0;
    
    puts("POSSIBLE");
    
    for (int i = 1; i <= n; ++i) 
        printf("%d ", leader[i] > leader[i + n]);  // 输出一组解
    
    return 0;
}

应用

P3825 [NOI2017] 游戏

给定一串序列,有 \(d\)\(x\) 位置有三种选择,其他位置有两种选择,求解一种合法方案。

\(n \leq 5 \times 10^4, d \leq 8\)

暴力枚举每个 \(x\) 地图不填 \(A\) 或不填 \(B\) 。因为不填 \(A\) 就可以填 \(B, C\) ,不填 \(B\) 就可以填 \(A, C\) ,这样就包含了 \(A, B, C\) 三种赛车。

时间复杂度降为 \(O((n+m) \times 2^d)\)​ 。

#include <bits/stdc++.h>
using namespace std;
const int N = 1e5 + 7;

struct Graph {
	vector<int> e[N];

	inline void clear(int n) {
		for (int i = 1; i <= n; ++i)
			e[i].clear();
	}
	
	inline void insert(const int u, const int v) {
		e[u].emplace_back(v);
	}
} G;

struct Node {
	int x, y;
	char cx, cy;
} nd[N];

int dfn[N], low[N], leader[N], sta[N];
char str[N];

int n, d, m, dfstime, top, scc;

template <class T = int>
inline T read() {
	char c = getchar();
	bool sign = (c == '-');
	
	while (c < '0' || c > '9')
		c = getchar(), sign |= (c == '-');
	
	T x = 0;
	
	while ('0' <= c && c <= '9')
		x = (x << 1) + (x << 3) + (c & 15), c = getchar();
	
	return sign ? (~x + 1) : x;
}

inline char readc() {
	char c = getchar();

	while (c < 'A' || c > 'Z')
		c = getchar();

	return c;
}

inline int trans(int x) {
	return x <= n ? x + n : x - n;
}

inline int getid(int x, char op) {
	if (str[x] == 'a')
		return op == 'b' ? x : x + n;
	else
		return op == 'a' ? x : x + n;
}

inline void clear(int n) {
	G.clear(n);
	memset(dfn + 1, 0, sizeof(int) * n);
	memset(low + 1, 0, sizeof(int) * n);
	memset(leader + 1, 0, sizeof(int) * n);
	dfstime = scc = 0;
}

void Tarjan(int u) {
	dfn[u] = low[u] = ++dfstime, sta[++top] = u;

	for (int v : G.e[u]) {
		if (!dfn[v]) {
			Tarjan(v);
			low[u] = min(low[u], low[v]);
		} else if (!leader[v])
			low[u] = min(low[u], dfn[v]);
	}

	if (dfn[u] == low[u]) {
		++scc;

		while (sta[top] != u)
			leader[sta[top--]] = scc;
		
		leader[sta[top--]] = scc;
	}
}

inline bool solve() {
	clear(n * 2);

	for (int i = 1; i <= m; ++i) {
		if (str[nd[i].x] == nd[i].cx)
			continue;

		int x = getid(nd[i].x, nd[i].cx), y = getid(nd[i].y, nd[i].cy);

		if (str[nd[i].y] == nd[i].cy)
			G.insert(x, trans(x));
		else
			G.insert(x, y), G.insert(trans(y), trans(x));
	}

	for (int i = 1; i <= n * 2; ++i)
		if (!dfn[i])
			Tarjan(i);

	for (int i = 1; i <= n; ++i)
		if (leader[i] == leader[i + n])
			return false;

	return true;
}

bool dfs(int pos) {
	if (pos > n)
		return solve();
	else if (str[pos] != 'x')
		return dfs(pos + 1);

	for (int i = 0; i < 2; ++i) {
		str[pos] = 'a' + i;

		if (dfs(pos + 1))
			return true;
	}

	return str[pos] = 'x', false;
}

signed main() {
	n = read(), d = read();
	scanf("%s", str + 1);
	m = read();

	for (int i = 1; i <= m; ++i) {
		nd[i].x = read(), nd[i].cx = tolower(readc());
		nd[i].y = read(), nd[i].cy = tolower(readc());
	}

	if (!dfs(1))
		return puts("-1"), 0;

	for (int i = 1; i <= n; ++i)
		if (str[i] == 'a')
			putchar(leader[i] < leader[i + n] ? 'B' : 'C');
		else if (str[i] == 'b')
			putchar(leader[i] < leader[i + n] ? 'A' : 'C');
		else
			putchar(leader[i] < leader[i + n] ? 'A' : 'B');

	return 0;
}

[ARC161E] Not Dyed by Majority (Cubic Graph)

给出每个点的度数恰为 \(3\) 的无向图。一次操作为将每个点的颜色变为所有邻居颜色的众数。构造黑白颜色序列使得无论如何染色,操作后都不可能变为该颜色序列。

\(\sum n \leq 5 \times 10^4\) ,保证 \(n\) 为偶数。

考虑如何判定一个颜色序列是否可作为操作后的颜色序列。

设操作前的颜色序列为 \(a_i\)(不妨使 \(a_i = 0, 1\) 代表黑和白),操作后的颜色序列为 \(b_i\) 。对于点 \(i\)\(e_{i, j}\) 为与 \(i\) 相邻的三个点。

对每个 \(i\) 有两种情况:

  • \(b_i = 0\):则对 \(j \not = k\),若 \(a_{e_{i, j}} = 1\)\(a_{e_{i, k}} = 0\)
  • \(b_i=1\):则对 \(j\neq k\),若 \(a_{e_{i, j}} = 0\)\(a_{e_{i, k}} = 1\)

这是一个关于 \(a_i\),有 \(6n\) 个条件的 2-SAT 问题,可以 \(O(n)\) 解决。

考虑 \(1\) 的邻域 \(x,y,z\),以及他们的邻域 \(\{ 1, x_0, x_1 \}, \{ 1, y_0, y_1 \}, \{1, z_0, z_1\}\) ,若 \(d_{x_0}=d_{x_1},d_{y_0}=d_{y_1},d_{z_0}=d_{z_1}\) ,那么无论 \(d_1\) 是什么,答案都不会更改。

因此至少有 \(\dfrac 1{16}\) 的序列会互相重复,那么期望 \(O(1)\) 次随机后能得到一组解。

#include <bits/stdc++.h>
using namespace std;
const int N = 1e5 + 7;

struct Graph {
	vector<int> e[N];

	inline void clear(int n) {
		for (int i = 1; i <= n; ++i)
			e[i].clear();
	}
	
	inline void insert(int u, int v) {
		e[u].emplace_back(v);
	}
} G, nG;

int col[N], dfn[N], low[N], sta[N], leader[N];

mt19937 myrand(time(0));
int n, dfstime, scc, top;

template <class T = int>
inline T read() {
	char c = getchar();
	bool sign = (c == '-');
	
	while (c < '0' || c > '9')
		c = getchar(), sign |= (c == '-');
	
	T x = 0;
	
	while ('0' <= c && c <= '9')
		x = (x << 1) + (x << 3) + (c & 15), c = getchar();
	
	return sign ? (~x + 1) : x;
}

void Tarjan(int u) {
	dfn[u] = low[u] = ++dfstime, sta[++top] = u;

	for (int v : nG.e[u]) {
		if (!dfn[v]) {
			Tarjan(v);
			low[u] = min(low[u], low[v]);
		} else if (!leader[v])
			low[u] = min(low[u], dfn[v]);
	}

	if (low[u] == dfn[u]) {
		++scc;

		while (sta[top] != u)
			leader[sta[top--]] = scc;

		leader[sta[top--]] = scc;
	}
}

signed main() {
	int T = read();

	while (T--) {
		G.clear(n = read());

		for (int i = 1; i <= n / 2 * 3; ++i) {
			int u = read(), v = read();
			G.insert(u, v), G.insert(v, u);
		}

		for (;;) {
			nG.clear(n * 2);

			for (int u = 1; u <= n; ++u) {
				col[u] = rand() & 1;

				for (int v : G.e[u])
					for (int w : G.e[u])
						if (v != w) {
							if (col[u])
								nG.insert(v, w + n);
							else
								nG.insert(v + n, w);
						}
			}

			memset(dfn + 1, 0, sizeof(int) * (n * 2));
			memset(low + 1, 0, sizeof(int) * (n * 2));
			memset(leader + 1, 0, sizeof(int) * (n * 2));
			dfstime = scc = 0;

			for (int i = 1; i <= n * 2; ++i)
				if (!dfn[i])
					Tarjan(i);

			bool flag = false;

			for (int i = 1; i <= n; ++i)
				if (leader[i] == leader[i + n]) {
					flag = true;
					break;
				}

			if (flag)
				break;
		}

		for (int i = 1; i <= n; ++i)
			putchar(col[i] ? 'B' : 'W');

		puts("");
	}

	return 0;
}

割点

定义:对于一个无向图,若把一个点删除后这个图的极大连通分量增加了,则这个点就是图的一个割点。

特判根节点。对于非根节点,若存在一个儿子点 \(v\) 使得 \(low_v \geq dfn_u\) (即不能回到祖先),则该点为割点。

P3388 【模板】割点(割顶)

void Tarjan(int u, int f) {
	dfn[u] = low[u] = ++dfstime;
	int sonsum = 0;

	for (int v : G.e[u])
		if (!dfn[v]) {
			++sonsum, Tarjan(v, u);
			low[u] = min(low[u], low[v]);

			if (f && low[v] >= dfn[u])
				tag[u] = true;
		} else if (v != f)
			low[u] = min(low[u], dfn[v]);

	if (!f && sonsum >= 2)
		tag[u] = true;
}

桥(割边)

定义:对于一个无向图,若把一条边删除后这个图的极大连通分量增加了,则这条边就是图的一个桥。

代码和割点差不多,只要改一处: \(low_v > dfn_u\) ,而且不用特判根节点。

void Tarjan(int u, int f) {
	dfn[u] = low[u] = ++dfstime;
	
	for (int i = G.head[u]; i; i = G.e[i].nxt) {
		int v = G.e[i].v;
		
		if (!dfn[v]) {
			Tarjan(v, u);
			
			if (low[v] > dfn[u])
				G.e[i].tag = G.e[i ^ 1].tag = true;
			
			low[u] = min(low[u], low[v]);
		} else if (v != f)
			low[u] = min(low[u], dfn[v]);
	}
}

边双连通

  • 边双连通:无向图中对于两点 \(u, v\) ,若满足删去任意边两点均连通,则称 \(u, v\) 边双连通。
  • 边双连通分量:不存在割边的极大连通块。

求解

遍历时不走割边即可求得边双。

也可以用栈维护 dfs 到的所有点,每次找到割边 \((fa,son)\) 就不断弹栈直到弹出 \(son\) ,则弹出的所有点是一个边双。

P8436 【模板】边双连通分量

#include <bits/stdc++.h>
using namespace std;
const int N = 5e5 + 7, M = 4e6 + 7;

struct Graph {
	struct Edge {
		int nxt, v;
		bool tag;
	} e[M];
	
	int head[N];
	
	int tot = 1;
	
	inline void insert(int u, int v) {
		e[++tot] = (Edge) {head[u], v, true}, head[u] = tot;
	}
} G;

vector<vector<int> > edcc;

int dfn[N], low[N];
bool vis[N];

int n, m, dfstime;

template <class T = int>
inline T read() {
	char c = getchar();
	bool sign = (c == '-');
	
	while (c < '0' || c > '9')
		c = getchar(), sign |= (c == '-');
		
	T x = 0;
	
	while ('0' <= c && c <= '9')
		x = (x << 1) + (x << 3) + (c & 15), c = getchar();
		
	return sign ? (~x + 1) : x;
}

void Tarjan(int u, int f) {
	dfn[u] = low[u] = ++dfstime;
	
	for (int i = G.head[u]; i; i = G.e[i].nxt) {
		int v = G.e[i].v;
		
		if (!dfn[v]) {
			Tarjan(v, u), low[u] = min(low[u], low[v]);
			
			if (low[v] > low[u])
				G.e[i].tag = G.e[i ^ 1].tag = false;
		} else if (v != f)
			low[u] = min(low[u], dfn[v]);
	}
}

void dfs(int u) {
	vis[u] = true, edcc.back().emplace_back(u);
	
	for (int i = G.head[u]; i; i = G.e[i].nxt) {
		int v = G.e[i].v;
		
		if (!vis[v] && G.e[i].tag)
			dfs(v);
	}
}

int main() {
	n = read(), m = read();
	
	for (int i = 1; i <= m; ++i) {
		int u = read(), v = read();
		G.insert(u, v), G.insert(v, u);
	}
	
	for (int i = 1; i <= n; ++i) 
		if (!dfn[i])
			Tarjan(i, 0);
		
	for (int i = 1; i <= n; ++i)
		if (!vis[i]) {
			edcc.emplace_back(vector<int>());
			dfs(i);
		}
	
	printf("%d\n", edcc.size());
	
	for (auto it : edcc) {
		printf("%d ", it.size());
		
		for (int x : it)
			printf("%d ", x);
		
		puts("");
	}
	
	return 0;
}

相关结论

  • 边双对点有传递性。

  • 每个点恰属于一个边双。

  • 对于边双内任意一条边 \(e\) ,存在经过 \(e\) 的回路。

  • 对于边双内任意两点 \(u, v\) ,存在经过 \(u, v\) 的简单环。

  • 两点之间任意一条迹(不经过重复边的路径)上的所有割边,就是两点之间的所有必经边。

  • \(u, v\) 边双连通当且仅当 \(u, v\) 间无必经边。

点双连通

  • 点双连通:无向图中对于两点 \(u, v\) ,若满足删去任意除这两点以外的点两点均连通,则称 \(u, v\) 点双连通。
  • 点双连通分量:不存在割点的极大连通块。

求解

每次判定 \(low_v \geq dfn_u\) 时,\(v\) 子树栈内的点与 \(u\) 共同构成一个点双,需要特判一下孤立点的情况。

P8435 【模板】点双连通分量

#include <bits/stdc++.h>
using namespace std;
const int N = 5e5 + 7;

struct Graph {
	vector<int> e[N];
	
	inline void insert(int u, int v) {
		e[u].emplace_back(v);
	}
} G;

vector<vector<int> > vdcc;

int dfn[N], low[N], sta[N];

int n, m, dfstime, top;

template <class T = int>
inline T read() {
	char c = getchar();
	bool sign = (c == '-');
	
	while (c < '0' || c > '9')
		c = getchar(), sign |= (c == '-');
		
	T x = 0;
	
	while ('0' <= c && c <= '9')
		x = (x << 1) + (x << 3) + (c & 15), c = getchar();
		
	return sign ? (~x + 1) : x;
}

void Tarjan(int u, int f) {
	dfn[u] = low[u] = ++dfstime, sta[++top] = u;
	int sonsum = 0;
	
	for (int v : G.e[u]) {
		if (!dfn[v]) {
			++sonsum, Tarjan(v, u), low[u] = min(low[u], low[v]);
			
			if (low[v] >= dfn[u]) {
				vdcc.emplace_back((vector<int>){u});
				
				while (sta[top] != v)
					vdcc.back().emplace_back(sta[top--]);
				
				vdcc.back().emplace_back(sta[top--]);
			}
		} else if (v != f)
			low[u] = min(low[u], dfn[v]);
	}
	
	if (!sonsum && !f)
		vdcc.emplace_back((vector<int>){sta[top--]});
}

int main() {
	n = read(), m = read();
	
	for (int i = 1; i <= m; ++i) {
		int u = read(), v = read();
		G.insert(u, v), G.insert(v, u);
	}
	
	for (int i = 1; i <= n; ++i) 
		if (!dfn[i])
			Tarjan(i, 0);
	
	printf("%d\n", vdcc.size());
	
	for (auto it : vdcc) {
		printf("%d ", it.size());
		
		for (int x : it)
			printf("%d ", x);
		
		puts("");
	}
	
	return 0;
}

相关结论

  • 点双对点不具有传递性。
  • 每条边恰属于一个点双。
  • 一个点是割点当且仅当它属于多个点双。
  • 由一条边直接相连的两个点点双连通。
  • 对于点双内的任意点 \(u\) ,存在经过 \(u\) 的简单环。
  • \(n \geq 3\) 时,在边中间插入点不影响点双连通性,因此钦定经过一个点和经过一条边是几乎等价的。
  • \(n \geq 3\) 的点双中任意点 \(u\) 与任意边 \(e\) ,存在经过 \(u, e\) 的简单环。
  • \(n \geq 3\) 的点双中任意不同两点 \(u, v\) 与任意边 \(e\) ,存在 \(u \rightsquigarrow e \rightsquigarrow v\) 的简单路径。
  • \(n \geq 3\) 的点双中任意不同三点 \(u, v, w\) ,存在 \(u \rightsquigarrow v \rightsquigarrow w\) 的简单路径。
  • 两点之间任意一条路径上的所有割点,就是两点之间的所有必经点。
  • 若两点双有交,那么交点一定是割点。

应用

P8456 「SWTR-8」地地铁铁

给定边权为 \(0\)\(1\) 的无向连通图,求有多少组点对之间存在同时经过 \(0\)\(1\) 的简单路径。

\(n \leq 4 \times 10^5, m \leq 10^6\)

对于落在不同点双的点对,如果经过的点双包含 \(0\) 边和 \(1\) 边则合法,否则显然不合法。

对于落在相同点双的点对,如果点双内部边权相同,显然不合法,否则只有一对特殊点(点双唯二既有 \(0\) 出边又有 \(1\) 出边的点对)不合法。

#include <bits/stdc++.h>
typedef long long ll;
using namespace std;
const int N = 4e5 + 7, M = 1e6 + 7;

struct DSU {
	int fa[N << 1], siz[N << 1];

	inline void prework(int n, int ext) {
		iota(fa + 1, fa + 1 + n + ext, 1);
		fill(siz + 1, siz + 1 + n, 1);
		fill(siz + 1 + n, siz + 1 + n + ext, 0);
	}

	inline int find(int x) {
		while (x != fa[x])
			fa[x] = fa[fa[x]], x = fa[x];

		return x;
	}

	inline void merge(int x, int y) {
		x = find(x), y = find(y);

		if (x == y)
			return;

		fa[y] = x, siz[x] += siz[y];
	}
} dsu1, dsu2;

struct Graph {
	struct Edge {
		int nxt, v, w;
	} e[M << 1];
	
	int head[N];
	
	int tot = 1;
	
	inline void insert(int u, int v, int w) {
		e[++tot] = (Edge) {head[u], v, w}, head[u] = tot;
	}
} G;

vector<int> bscc[N];

int dfn[N], low[N], sta[N], esta[M], tag[N], tp[N];
bool in[M];

ll ans;
int testid, n, m, dfstime, top, etop, ext;

template <class T = int>
inline T read() {
	char c = getchar();
	bool sign = (c == '-');
	
	while (c < '0' || c > '9')
		c = getchar(), sign |= (c == '-');
	
	T x = 0;
	
	while ('0' <= c && c <= '9')
		x = (x << 1) + (x << 3) + (c & 15), c = getchar();
	
	return sign ? (~x + 1) : x;
}

inline char readc() {
	char c = getchar();

	while (c != 'd' && c != 'D')
		c = getchar();

	return c;
}

void Tarjan(int u) {
	dfn[u] = low[u] = ++dfstime, sta[++top] = u;

	for (int i = G.head[u]; i; i = G.e[i].nxt) {
		int v = G.e[i].v;

		if (!dfn[v]) {
			in[esta[++etop] = i >> 1] = true;
			Tarjan(v);
			low[u] = min(low[u], low[v]);
			
			if (low[v] >= dfn[u]) {
				bscc[++ext].emplace_back(u);

				while (sta[top] != v)
					bscc[ext].emplace_back(sta[top--]);
				
				bscc[ext].emplace_back(sta[top--]);
				vector<int> E;

				while (esta[etop] != (i >> 1))
					E.emplace_back(esta[etop--]);
				
				E.emplace_back(esta[etop--]);

				for (int x : E) {
					in[x] = false;
					tp[ext] |= 1 << G.e[x << 1].w;
					tag[G.e[x << 1].v] |= 1 << G.e[x << 1].w;
					tag[G.e[x << 1 | 1].v] |= 1 << G.e[x << 1].w;
				}

				int sum = 0;

				for (int x : bscc[ext])
					sum += (tag[x] == 3), tag[x] = 0;

				if (sum == 2)
					--ans;
			}
		} else {
			low[u] = min(low[u], dfn[v]);

			if (dfn[v] < dfn[u] && !in[i >> 1])
				esta[++etop] = i >> 1;
		}
	}
}

signed main() {
	testid = read(), n = read(), m = read();

	for (int i = 1; i <= m; ++i) {
		int u = read(), v = read(), w = (readc() == 'd');
		G.insert(u, v, w), G.insert(v, u, w);
	}

	ans = 1ll * n * (n - 1) / 2;
	Tarjan(1);
	dsu1.prework(n, ext), dsu2.prework(n, ext);

	for (int u = 1; u <= ext; ++u)
		for (int v : bscc[u])
			if (tp[u] == 1)
				dsu1.merge(u + n, v);
			else if (tp[u] == 2)
				dsu2.merge(u + n, v);

	for (int i = 1; i <= n + ext; ++i) {
		if (dsu1.find(i) == i)
			ans -= 1ll * dsu1.siz[i] * (dsu1.siz[i] - 1) / 2;

		if (dsu2.find(i) == i)
			ans -= 1ll * dsu2.siz[i] * (dsu2.siz[i] - 1) / 2;
	}

	printf("%lld", ans);
	return 0;
}

连通度

  • 边连通度:对任意不同的两点 \(u, v\) ,使 \(u, v\) 不连通所需删去的边的数量的最小值 \(k\) 等于 \(u, v\) 之间边不相交的迹的数量的最大值,\(k\) 即为 \(u, v\) 间的边连通度。
  • 点连通度:对任意不同且不相邻的两点 \(u, v\) ,使得 \(u, v\) 不连通所需删去的点(除去 \(u, v\) )的数量的最小值 \(k\) 等于 \(u, v\) 之间点(除去 \(u, v\) )不相交的路径数量的最大值,\(k\) 即为 \(u, v\) 间的点连通度。

\(k\) -边连通当且仅当任意两点之间 \(k\) -边连通,点连通同理。

可以用最大流计算边连通度。

Menger 定理:两点间的迹的最大数量等于割集的最小大小。

Whitney 不等式:边连通度不大于点连通度不大于最小度,且对每个满足它的三元组,均可以找出满足这个三元组的图。

posted @ 2024-07-17 10:59  我是浣辰啦  阅读(14)  评论(0编辑  收藏  举报