Logo Pigsyy 的博客

博客

标签
暂无

网络流从源点到汇点

本文章由 WyOJ Shojo 从洛谷专栏拉取,原发布时间为 2025-06-17 18:45:58

阅读方式:PDF(强烈推荐)

最大流、最小割、费用流

定义

网络(Network): 有向图 $G=(V,E)$,边 $(u,v)\in E$ 有容量 $c(u,v)$ 和流量 $f(u,v)$。通常来讲,存在两个特殊点:源点 $S$ 和汇点 $T$。如果边 $(u,v)$ 不存在,默认 $c(u,v)=0$。

可行流(Flow):从边集 $E$ 到整数集或实数集的函数,满足如下性质:

  1. 容量限制: $\forall (u,v)\in E$,$0\le f(u,v)\le c(u,v)$。每条边的流量不超过每条边的容量。
  2. 流量守恒: $\forall u\in V\setminus \set{S,T}$,$\sum_{(v,u)\in E} f(v,u)=\sum_{(u,v)\in E} f(u,v)$。除源点和汇点外,流入每个点的流量等于流出每个点的流量。
  3. 斜对称性: $\forall (u,v)\in E$,$f(u,v)=-f(u,v)$。从 $u\to v$ 流 $f(u,v)$ 的流量,等价于从 $v\to u$ 流 $-f(u,v)$ 的流量。

如图所示,网络可以想象成若干个过水装置,其中 $S$ 为出水装置,$T$ 为入水装置,边想象成管道,容量为管道最多承载的水,流量即为实际中的水流。那么,一张可行流就是与实际生活相符的流水的方式:容量限制——每个管道内的流量不能超过至多承载的水;流量守恒——除出水口和入水口外,其余点进去的必然要和出去的相同。

定义可行流的流量为 $s$ 点流出的流量之和或 $t$ 点流入的流量之和,即 $$ \sum_{(s,u)\in E} f(s,u)=\sum_{(u,t)\in E} f(u,t) $$ 相等的原因很简单,还是从出入水装置的角度考虑。因为从 $s$ 点流出的水最终流向 $t$ 点,中间又不会存水,所以必然是相等的。

残量网络(Residual Network):残量网络 $G_f=(V,E_f)$,其中 $E_f=\set{(u,v)\mid c_f(u,v)>0}$。$c_f(u,v)=c(u,v)-f(u,v)$。

⚠️:由于上文所提到的斜对称性,$f(u,v)$ 存在负值,所以 $E_f$ 可能存在 $E$ 中的反向边。

割(cut): 把 $V$ 分成两个点集 $s\in S,t\in T$,$S\cup T=V$。则割的容量 $$ c(S,T)=\sum_{u\in S}\sum_{v\in T} c(u,v) $$ 最大流、最小割(maxflow, mincut): 最大流指最大的割的流量,最小割指最小的割的容量。

最小费用最大流(mincost, maxflow):费用流指每条边不仅有容量还有费用,最终的费用定义为每条边的费用 $\times$ 每条边的流量之和。

如何求解最大流、最小割便是本节所要讨论的问题,接下来探讨最大流和最小割之间的关系。

最大流最小割定理

若笔者记得没错,定理的内容本身是很长的,证明也较为复杂(好像有 $3$ 条来着)。为了贴合 OI,这里言简意赅地解释核心。

核心: 最大流等于最小割。

证明: 相信大家都不喜欢枯燥无味的数学证明,这里从直观上证明一下。

  1. 任意可行流的流量 $\le $ 该可行流的任意割的容量:因为从出入水装置的角度思考,割相当于将管道劈断,想象一下可行流在该横截面流过的水,必然不能超出所有劈断管道的容量之和,否则就盛不下了。

  2. 存在可行流,满足某个割等于该可行流的流量:该可行流需要满足残量网络上 $s$ 无法到达 $t$。选取在残量网络 $s$ 所能到达的所有点作为 $S$,则 $T=V\setminus S$。这样, $$ \forall u\in S,v\in T, c_f(u,v)=c(u,v)-f(u,v)=0 $$ 即,$f(u,v)=c(u,v)$。所以,$c(S,T)=\sum_{u\in S}\sum_{v\in T}f(u,v)=可行流的流量$。

综上,可以得出最大流等于最小割。

Ford–Fulkerson 增广

有的读者可能问,为什么要定义残量网络?有的读者可能问,为什么要考虑 $-f(u,v)$ 的反向边?其实,这一切的一切,均是为了解决最大流问题。

直接的求解最大流的思路是每次在网络中找到一条还能流 $>0$ 的流量的路径(增广路径),并将流量增加上去。

很可惜,这是错的!如下图所示

如果最初选择了 $1\to 2\to 6\to 7$ 这条增广路径,流量为 $1$,则接下来只能选择 $1\to 2\to 4\to 7$ 和 $1\to 5\to 6\to 7$ 这两条路径,均有 $10^9-1$ 的流量,总共 $2(10^9-1)+1=2\times 10^9-1$。而实际上最大流是只流 $1\to 2\to 4\to 7$ 和 $1\to 5\to 6\to 7$,能获得 $2\times 10^9$ 的流量。

所以,我们需要反悔!所以,这就是加入反向边的原因,流反向边等价于将之前流的退回去,这个操作称之为推流。

于是,我们得到了一个求解最大流的方法,每次找出一条增广路,对应更新边的流量和反向边的流量。不过,这样做的时间复杂度是 $O(mV)$ 的,原因还在上图中:如果最初选择了 $1\to 2\to 6\to 7$,接下来选择 $1\to 5\to 6\to 2\to 4\to 7$,那么会无限的重复下去直到 $10^9$ 变成 $0$,也就是说会进行 $2\times 10^9$ 轮增广。

这个也叫 FF 算法,时间复杂度是伪多项式的,这很不好,接下来探索一些多项式做法。

Edmonds-Karp

注意到,每次增广路的条件是任意的。也就是说,一个优化方向是限制每次选择增广路的条件,从而获得更优的复杂度。

EK 算法每次增广选择最短路径(即边数最小的路径),而如果这样做增广的次数便是 $O(nm)$ 的。

证明: 每次增广时,必然会在残量网络上删除一条边。那么,对于边 $(u,v)$,如果先后删除 $(u,v)$ 和 $(v,u)$ 意味着什么?令删除 $(u,v)$ 的时候,$d_u$ 和 $d_v$ 为 $s\to u$ 和 $s\to v$ 的最短路,删除 $(v,u)$ 的时候,$d_u'$ 和 $d_v'$ 为 $s\to u$ 和 $s\to v$ 的最短路。则有如下不等式: $$ d_u+1=d_v\lt d_v'+1=d_u' $$ 根据传递性,不难得到 $$ d_u+2\le d_u' $$ 也就是说,对于每条边,至多删除 $\frac{n}{2}$ 次,总共有 $m$ 条边,则至多进行 $O(nm)$ 次增广。每次增广需要进行一次 BFS,所以说时间复杂度为 $O(nm^2)$。

【参考代码】

i64 EK(int s, int t) {
	i64 res = 0;
	while (1) {
		memset(flow, -1, sizeof flow);
		tt = 0, q[ ++ tt] = s, flow[s] = INT_MAX;
		for (int i = 1; i <= tt; i ++) {
			int u = q[i];
			for (int j = h[u]; ~j; j = ne[j]) {
				int v = e[j];
				if (~flow[v] || !f[j]) continue;
				flow[v] = min(flow[u], f[j]);
				q[ ++ tt] = v, pre[v] = j;
			}
		}
		if (flow[t] == -1) break;
		int cur = t, i;
		while (cur != s) {
			i = pre[cur], f[i] -= flow[t];
			f[i ^ 1] += flow[t], cur = e[i ^ 1];
		}
		res += flow[t];
	}
	return res;
}

Dinic

EK 算法每次增广一条增广路,而 BFS 可以同时处理出多条最短路,形成最短路 DAG。在这张最短路 DAG 上,可以同时增广多条增广路,所以只需要找出最大的可能流的流量即可。

这个可以 DFS 求解,对于每个点存储前面最小的剩余容量,每次枚举邻边 $(u,v,w)$,并判断 $v$ 是否在下一层,转移过去即可(DFS(v, min(lim - flow, w)))。

当前弧优化:当 DFS 从 $u$ 到 $v$ 时,则这条边剩余容量会变成 $0$。那么,后面再到达 $u$ 的时候,找第一个剩余容量不为 $0$ 的边,可以通过记录当前弧,从而找到第一条剩余容量不为 $0$ 边做到 $O(1)$。

这样,增广的次数是 $O(n)$ 的,因为每增广一次,会导致 $s\to t$ 的最短路至少增加 $1$,因为如果最短路不变,则可以多流一条增广路。DFS 的时间复杂度是 $O(nm)$ 的,因为每次 DFS 到 $t$ 会导致一条边删除,而至多只有 $m$ 条边,每次删除一条边至多经过 $n$ 个点。

综上,时间复杂度为 $O(n^2m)$,常数很小。

【参考代码】

bool bfs() {
	memset(dist, -1, sizeof dist);
	tt = 0, q[ ++ tt] = s, dist[s] = 0;
	for (int i = 1; i <= tt; i ++) {
		int u = q[i];
		cur[u] = h[u];
		for (int j = h[u]; ~j; j = ne[j]) {
			int v = e[j];
			if (~dist[v] || !f[j]) continue;
			dist[v] = dist[u] + 1;
			q[ ++ tt] = v;
		}
	}
	return ~dist[t];
}
i64 dfs(int u, i64 lim) {
	if (u == t) return lim;
	i64 flow = 0;
	for (int i = cur[u]; ~i && flow <= lim; i = ne[i]) {
		cur[u] = i;
		int v = e[i];
		if (dist[v] == dist[u] + 1 && f[i]) {
			i64 t = dfs(v, min(lim - flow, (i64)f[i]));
			flow += t, f[i] -= t, f[i ^ 1] += t;
		}
	}
	return flow;
}
i64 dinic() {
	i64 res = 0;
	while (bfs()) res += dfs(s, LONG_LONG_MAX);
	return res;
}

最小费用最大流

如今有费用,那么直观上的方式是每次增广费用和最小的路径,这样才是最优的。事实也确实如此,证明略,感兴趣的读者可以自行上网查找。

所以,每次只需要将 EK 中的 BFS 替换成 SPFA(注意有负边权,因为需要考虑反向边),时间复杂度 $O(nmf)$。因为每次选择费用和最小的路径,会导致并没有很好的性质,很容易卡到 $O(f)$ 次增广。SPFA 的时间复杂度为 $O(nm)$,故总复杂度为 $O(nmf)$。

该算法称之为 Successive Shortest Path,简称 SSP,业界公约。实现是简单的,参考代码略。

上下界网络流

无源汇上下界可行流

问题描述: 给定一张 $n$ 个点 $m$ 条边的网络,每条边有流量下界和上界,试求任意一个可行流。

如果有下界怎么办?考虑强制选择下界,容量变成上界 $-$ 下界(即流 $x$ 的流量等价于原图流 $x+\text{low}$ 的流量),但是问题在于下界不一定是流量守恒的。

于是,考虑进行补流。建立超级源点 $s$ 和超级汇点 $t$。对于点 $u$ 来讲,如果入的比出的多,则从 $u\to t$ 连接一条容量为差值的边;如果入的比出的少,则从 $s\to u$ 连接一条容量为差值的边。

由于 $s$ 与原图中的点只有补流的边,原图中的点到 $t$ 只有补流的边,所以必然将补流的边流满,为了流量守恒,也就导致原图中的边提高对应的流量。

【参考代码】

const int maxn = 205;

int n, m;
int diff[maxn];

int main() {
	cin.tie(0);
	cout.tie(0);
	ios::sync_with_stdio(0);

	cin >> n >> m;

	maxflow<int> mf(n + 1);
	std::vector<array<int, 3>> edge;
	for (int i = 1; i <= m; i ++) {
		int u, v, lo, hi;
		cin >> u >> v >> lo >> hi;
		diff[u] += lo, diff[v] -= lo;
		mf.add(u, v, hi - lo);
		edge.push_back({u, v, lo});
	}

	for (int i = 1; i <= n; i ++)
		if (diff[i] > 0) mf.add(i, n + 1, diff[i]);
		else if (diff[i] < 0) mf.add(0, i, -diff[i]);
	mf.dinic(0, n + 1);

	for (int i = 2 * m; i < mf.idx; i += 2)
		if (mf.f[i]) {
			cout << "NO" << endl;
			return 0;
		}

	cout << "YES" << endl;
	int id = 1;
	for (auto [u, v, w] : edge) {
		cout << mf.f[id] + w << endl;
		id += 2;
	}

	return 0;
}

有源汇上下界最大流

有源汇上下界可行流是简单的,这里直接讨论最大流的求解。沿用 $2.1$ 中强制选择 $\text{low}$ 的思路,把每条边的流量变成 $\text{low+x}$,容量为 $\text{high}-\text{low}$。

考虑如何将 $\text{low}$ 平衡,将有源汇转化为无源汇,只需要 $t\to s$ 连接一条容量为 $\infty$ 的边。接下来,按照无源汇上下界可行流的求解方式求出一张可行流。

在任意可行流上,继续跑 dinic 仍然可以求解出最大流,因为相当于每条边 $(u,v)$ 可以增加 $c(u,v)-f(u,v)$ 的流量,也即剩余容量为 $c(u,v)-f(u,v)$;可以减少 $f(u,v)$,也即反向边的剩余容量为 $f(u,v)$,这与该可行流的残量网络是一致的。所以只需要在这张可行流上从 $s\to t$ 跑一遍 dinic 即可。

【参考代码】

const int maxn = 205;

int n, m, s, t;
int diff[maxn];

int main() {
	cin.tie(0);
	cout.tie(0);
	ios::sync_with_stdio(0);

	cin >> n >> m >> s >> t;

	maxflow<i64> mf(n + 1);
	for (int i = 1; i <= m; i ++) {
		int u, v, lo, hi;
		cin >> u >> v >> lo >> hi;
		mf.add(u, v, hi - lo);
		diff[u] += lo, diff[v] -= lo;
	}
	mf.add(t, s, INT_MAX);

	for (int i = 1; i <= n; i ++)
		if (diff[i] > 0) mf.add(i, n + 1, diff[i]);
		else if (diff[i] < 0) mf.add(0, i, -diff[i]);

	mf.dinic(0, n + 1);
	for (int i = 2 * m + 2; i < mf.idx; i += 2)
		if (mf.f[i]) {
			cout << "please go home to sleep" << endl;
			return 0;
		}

	cout << mf.dinic(s, t) << endl;

	return 0;
}

有源汇上下界最小流

最小流与最大流的求解方式极为类似,核心思想不变:先求解可行流,将 $\text{low}$ 平衡。接下来,求解该流基础上的最小流。

第一步与最大流完全一样,这里不再赘述。考虑现在求解出一张平衡 $\text{low}$ 的可行流,接下来如何求解最小流。相当于边 $(u,v)$ 剩余容量为 $f(u,v)$,反向边剩余容量为 $\text{high}(u,v)-\text{low}(u,v)-f(u,v)$,不难发现,这相当于把之前建的反向边当正向边,正向边当反向边,也就是 $t\to s$ 的最大流。

注意:在求 $t\to s$ 最大流的时候,需要将 $t\to s$ 边删除。

【参考代码】

const int maxn = 50005;

int n, m, s, t;
i64 diff[maxn];

int main() {
	cin.tie(0);
	cout.tie(0);
	ios::sync_with_stdio(0);

	cin >> n >> m >> s >> t;

	maxflow<i64> mf(n + 1);
	for (int i = 1; i <= m; i ++) {
		int u, v, lo, hi;
		cin >> u >> v >> lo >> hi;
		mf.add(u, v, hi - lo);
		diff[u] += lo, diff[v] -= lo;
	}
	mf.add(t, s, INT_MAX);

	for (int i = 1; i <= n; i ++)
		if (diff[i] > 0) mf.add(i, n + 1, diff[i]);
		else if (diff[i] < 0) mf.add(0, i, -diff[i]);

	mf.dinic(0, n + 1);
	i64 res = mf.f[2 * m + 1];
	for (int i = 2 * m + 2; i < mf.idx; i += 2)
		if (mf.f[i]) {
			cout << "please go home to sleep" << endl;
			return 0;
		}
	mf.f[2 * m] = mf.f[2 * m + 1] = 0;

	cout << res - mf.dinic(t, s) << endl;

	return 0;
}

Trick 合集

本文章由 WyOJ Shojo 从洛谷专栏拉取,原发布时间为 2025-07-25 21:11:51

持续更新 ing……

数据结构(Data Structure)

  1. 区间查询操作的结果且为 $\text{poly log}$ 做法时:
    • 如果存在较小量且操作具有结合率:考虑倍增或线段树维护。 ::::info[[eJOI 2024] 古迹漫步 / Old Orhei] :::info[题意] 给定一个包含 $N$ 个节点 $M$ 条边的有向图(每条边编号为 $1 \sim M$)和一个长度为 $T$ 的序列 $A$($A_i$ 表示边编号)。需要处理 $Q$ 次操作:

      • 查询操作 ($\texttt{1 L R S}$):从节点 $S$ 出发,依次检查序列 $A$ 的子区间 $[L,R]$ 中的每个元素 $Z$。若当前节点 $X$ 是编号为 $Z$ 的边的起点,则移动到该边的终点,否则不动。输出最终停留的节点。
      • 修改操作 ($\texttt{2 i K}$):将序列 $A$ 的第 $i$ 项修改为 $K$。

      数据范围:$N \leq 50$,$M,T,Q \leq 10^5$。 ::: :::success[思路] 观察题目中的较小量是什么?不难发现 $N\le 50$。而且,题目中的操作相当于移动点,如果某个点 $X$ 经过前 $k$ 次操作到达 $Y$,$Y$ 经过后 $k$ 次操作到达 $Z$,则 $X$ 经过总共的 $2k$ 次操作会到达 $Z$。

      这说明,题目中的操作是满足结合率的。也就是说,线段树是可以合并节点的。对于每个节点,维护 $1\sim N$ 每个点经过该节点对应区间后对应的位置。pushup 的时候,直接按上文所说合并即可。 ::: ::::

    • 如果不存在较小量且每次操作本质上是合并两个集合(比较罕见):解决方案本质上是使用广义上的 Kruskal 重构树(或者应该叫做 DSU 重构树?),每次合并两个集合的时候,新建节点,并将两个集合的对应节点连接该点。

      这样的好处在于每两个点的 LCA 记录了这两个点最小加入同一集合的时刻。这对维护区间操作结果有很大的作用。

      :::::info[花田魔法 (flower)] ::::info[题意] 给定长度为 $n$ 的序列 $a$,初始 $a_i=i$。有 $m$ 种魔法:

      • $\tt x_i\ y_i$:$\forall i \in [1,n]\land a_i=x_i$,$a_i:=b_i$。

      接下来有 $q$ 次询问:

      • $\texttt{l r}$:依次施加第 $l,l+1,\dots,r$ 种魔法,序列 $a$ 有多少个极长颜色连续段。

      例如,$a$ 序列为 $1,1,2,2,3$,则极长颜色段为 $[1,1],[2,2],[3]$。

      注意: 每次只是假想地施加魔法,并没有真正的进行,即不同询问之间是独立的。

      数据范围: $1\le n,m,q\le 5\times 10^5$。 ::::

      ::::success[思路] :::warning[Hint 1:$O(nq)$ 怎么做?] 考虑对极长颜色段拆贡献,转化为 $a_i\ne a_{i+1}$ 的 $i$ 的数量。

      考虑将每次 $x_i\to y_i$ 看作是一条边,每次导致两个集合变为同一个值,也就是合并两个集合。

      于是,每次 DSU 暴力维护即可。查询判断有多少个 $i$ 和 $i+1$ 不在同一个集合。 ::: :::warning[Hint 2:如何用 DSU 重构树描述 Hint 1 中的过程] 考虑每次 $x_i$ 和 $y_i$ 合并的时候,新建一个节点,作为 $x_i$ 和 $y_i$ 的父亲,同时另该节点的颜色为 $y_i$。如果不存在颜色 $y_i$,则新建一个节点,作为 $x_i$ 的父亲。

      注意,这里再表述 $x_i$ 和 $y_i$ 节点的时候,均指最后一次颜色为 $x_i$ 和 $y_i$ 的节点编号。

      这样,在这棵树上,可以很好地通过 LCA 来判断两个点从什么时候开始在同一个集合。 ::: 现在是区间查询,有两个维度(左边界和右边界),扫描线来处理左边界,右边界在 DSU 重构树上处理。

      考虑 $l$ 从 $m$ 到 $1$ 枚举,每次相当于在树上插入首次操作。这是容易的,只需要新建节点 $u$,颜色为 $y_i$,并将 $x_i$ 的父亲定为 $u$,$y_i$ 的父亲定为 $u$,$u$ 的父亲定为原来 $y_i$ 的父亲(如果存在)。

      每次只会更新 $3$ 个点,LCA 只会变化 $2$ 个。因为只有 $x_i,x_i+1$ 和 $x_i-1,x_i$ 的 LCA 发生变化。于是,只需要动态维护 LCA,这里由于每次都是改叶子,所以是可以通过倍增数组做到 $O(\log n)$ 维护的。 :::: :::::

动态规划(Dynamic Programming)

  1. 分步转移:当 DP 维度中存在多个转移互相独立的维度,每次转移时每个维度同时枚举可以转变成仅枚举一个维度,并用 0/1/... 记录已经转移了哪些。

    ::::info[跳石头 (stone)] :::info[题意] 给定 $2\times (n+2)$ 的矩形,初始两只脚分别位于第 $0$ 列,终点为两只脚均位于第 $n+1$ 列。

    令 $(i,j)$ 表示左脚在第一排的第 $i$ 个石头上,右脚在第二排的第 $j$ 个石头上,那么你可以进行一次跳跃:选择一对整数 $(k,l),k>i,l>j,a_k=b_l$,然后左脚跳到第 $k$ 个石头上,右脚跳到第 $l$ 个石头上,这次跳跃消耗的体力为 $(k-i)^2+(l-j)^2$。

    试求从起点到终点最小消耗的体力是多少

    数据范围: $n\le 3000$。 :::

    :::success[思路] 令 $f_{i,j}$ 表示左脚位于 $i$ 位置,右脚位于 $j$ 位置的最小消耗的体力。

    转移枚举下一次的位置 $k,l$,时间复杂度为 $O(n^4)$。

    不过,不难发现题目斜率优化的形式,只不过二维斜率优化是困难的。于是,考虑左脚、右脚实际上是独立的,可以分部转移。从而优化到一维斜率优化。

    时间复杂度 $O(n^2)$。 ::: ::::

字符串(Strings)

  1. LCP 和 LCS 可以转化为区间最小值:将字符串按字典序排序,则两个字符串 $i$ 和 $j$ 的 LCP 为 $\text{ord}i\sim \text{ord}{j}$ 中相邻两个字符串的 LCP 的最小值(LCS 同理)。

    ::::info[字符串 (string)] :::info[题意] 给定两个字符串 $a, b$,定义 $f(a, b)$ 为通过以下操作使得 $a = b$ 的最小操作数。如果无法通过操作使它们相同,则 $f(a, b) = 6666$。

    一次操作定义为:选择 $a$ 或者 $b$,选择它的一个子区间 $[l, r]$,将该子区间的所有字符按字典序从小到大排序。

    现在给定 $n$ 个长度相等的字符串 $s_{1}, s_{2}, \cdots, s_{n}$,请计算出 $\sum_{i=1}^{n} \sum_{j=i+1}^{n} f(s_{i}, s_{j})$。

    数据范围: $1 \leq n$,$|s_i| \leq 5 \times 10^5$,$n \cdot |s_1| \leq 5 \times 10^5$,$|s_1| = |s_2| = ... = |s_n|$。 ::: :::success[思路] 观察到,$f(s, t)$ 的值仅有 $4$ 种:

    1. $s=t$:$f(s,t)=0$。
    2. $s$ 排序某个区间能变成 $t$:$f(s,t)=1$。
    3. $\text{sort}(s)\ne \text{sort}(t)$:$f(s,t)=6666$。
    4. $\text{otherwise}$:$f(s,t)=2$。

    考虑将字符串按照 $\text{sort}(s_i)$ 排序,那么只需要考虑每个极长相等段即可。

    于是,计算 $f(s,t)=1$ 的对数,这样用总数减去 $f(s,t)=0$(容易计算)减去 $f(s,t)=1$ 的对数,便是 $f(s,t)=2$ 的对数。

    考虑每个字符串 $s_i$ 的每个单调不降的极长区间 $[l,r]$,只需要计算有多少个字符串与 $i$ 的 LCP 大于等于 $l$,LCS 大于等于 $|s_i|-r+1$。

    将 LCP 和 LCS 转化为区间 $\min$ 之后,不难发现,合法答案在两段区间的交集,将每个字符串变成二元组,这样每次相当于矩阵和。二维偏序,扫描先即可。

    时间复杂度:$O(n\log n)$。 ::: ::::

数学

  1. 整除分块区间 $[L,R]$ 满足 $2L\ge R$。
  2. 数字 $x$ 除 $1\sim \sqrt x$ 下取整两两不同。

「梦熊 X16 · 异或赛」XOR and Rockets

本文章由 WyOJ Shojo 从洛谷专栏拉取,原发布时间为 2025-08-10 21:17:08

前言

:::epigraph[] 只有我赛时写的 Trie 树优化转移,然后卡了一年的常吗? :::

状态定义的方式不同,转移优化的难度和时间复杂度是不同的。这个还是第一次见,感觉挺深刻的。

:::info[题目描述] 给定两个长度为 $n$ 的非负整数序列 $a,b$。接下来进行若干次操作:

  • 选择一个整数 $x\in[1,n]$ 与一个正整数 $y$。
  • 进行操作 $\forall i\in[1,x],a_i\gets a_i\oplus y$。即将 $[1,x]$ 中数异或上 $y$。
  • 这次操作的代价为 $b_x$。

输出通过若干次操作使得 $a$ 变为单调不降的最小代价。 :::

思路

不难想到用 DP 求解。由于每次是将前缀异或,所以倒着做会比较方便(应该正着做也行,没细想)。

需要观察到答案分为两种情况:

  1. $a_n$ 不操作:任何时刻,后缀操作的异或和不会超过 $V$。
  2. $a_n$ 操作:最优异或 $2^{10000}+x$,其中 $x$ 为任意不超过 $V$ 的非负整数。一旦某个位置要操作,只需要将 $2$ 的指数次幂减少 $1$,并随意改动 $x$。

情况 1($a_n$ 不操作)

令 $f_{i,j}$ 表示 $i\sim n$ 位置中,所有位置的操作的异或和为 $j$。这时候,如果 $i$ 位置操作为异或 $k$,则需满足 $a_i\oplus k\oplus j\le a_{i+1}\oplus j$。所有合法的 $k$ 是 Trie 树上 $\log V$ 个子树。这样,复杂度即可做到 $O(nV\log V)$。

不过,这样太不好了。我们将限制放在转移中,但实际上可以将限制放在状态里。即,令 $f_{i,j}$ 表示 $i\sim n$ 位置中,$a_i$ 最终的值为 $j$。如果操作,则 $a_{i}$ 可以变成任何值。这样,转移只需要做后缀 $\max$ 即可(也就是转化成了一段区间)。

时间复杂度:$O(nV)$。

情况 2($a_n$ 操作)

根据上述分析,只要操作,异或值可以变成任何值。于是,令 $f_{i,j}$ 表示 $i\sim n$ 位置,所有操作的异或和为 $j$。转移是容易做到 $O(1)$ 的。

时间复杂度:$O(nV)$。

代码

:::success[赛时代码 $O(nV\log V)$]

#include <bits\/stdc++.h>
using namespace std;
using i64 = long long;

int n;
int a[1 << 13], b[1 << 13];
i64 dp[2][1 << 13], fg[13][1 << 13];

inline void chmin(i64 &a, i64 b) { a = min(a, b); }
void solve() {
	cin >> n;
	for (int i = 1; i <= n; i ++)
		cin >> a[i];
	for (int i = 1; i <= n; i ++)
		cin >> b[i];

	memset(dp, 0x3f, sizeof dp);
	dp[n & 1][0] = 0;
	for (int i = n - 1; i >= 1; i --) {
		memset(dp[i & 1], 0x3f, sizeof dp[i & 1]);
		memset(fg, 0x3f, sizeof fg);
		for (int j = 0; j < 1 << 13; j ++) {
			if (dp[~i & 1][j] >= 1E18) continue;
			int x = a[i] ^ j, y = a[i + 1] ^ j;
			int S = j;
			for (int k = 12; k >= 0; k --)
				if (y >> k & 1) {
					chmin(fg[k][(S ^ ((x >> k & 1) << k)) >> k], dp[~i & 1][j]);
					S ^= (~x >> k & 1) << k;
				} else if (x >> k & 1) S ^= 1 << k;
			fg[0][S] = min(fg[0][S], dp[~i & 1][j]);
			if (x <= y)
				dp[i & 1][j] = min(dp[i & 1][j], dp[~i & 1][j]);
		}
		for (int k = 12; k >= 0; k --)
			for (int x = 0; x < 1 << 13 - k; x ++)
				if (fg[k][x] < 1E18)
					for (int y = 0; y < 1 << k; y ++)
						dp[i & 1][x << k | y] = min(dp[i & 1][x << k | y], fg[k][x] + b[i]);
	}
	i64 res = 1ll << 60;
	for (int i = 0; i < 1 << 13; i ++)
		res = min(res, dp[1][i]);

	for (int i = 0; i < 1 << 13; i ++)
		dp[n & 1][i] = b[n];
	for (int i = n - 1; i >= 1; i --) {
		i64 mn = 1ll << 60;
		for (int j = 0; j < 1 << 13; j ++)
			mn = min(mn, dp[~i & 1][j]);
		for (int j = 0; j < 1 << 13; j ++) {
			dp[i & 1][j] = mn + b[i];
			if ((a[i] ^ j) <= (a[i + 1] ^ j))
				dp[i & 1][j] = min(dp[i & 1][j], dp[~i & 1][j]);
		}
	}
	for (int i = 0; i < 1 << 13; i ++)
		res = min(res, dp[1][i]);

	cout << res << '\n';
}

int main() {
	cin.tie(0);
	cout.tie(0);
	ios::sync_with_stdio(0);

	int T;
	cin >> T;

	while (T -- ) solve();

	return 0;
}

:::

:::success[$O(nV)$ 代码]{open}

void solve() {
	cin >> n;
	int mx = 0;
	for (int i = 1; i <= n; i ++)
		cin >> a[i], mx = max(mx, a[i]);
	for (int i = 1; i <= n; i ++)
		cin >> b[i];
	mx = (1 << __lg(mx) + 1);

	memset(dp, 0x3f, sizeof dp);
	dp[n & 1][a[n]] = 0;
	for (int i = n - 1; i >= 1; i --) {
		memset(dp[i & 1], 0x3f, sizeof dp[i & 1]);
		i64 mn = 1ll << 60;
		for (int j = mx - 1; j >= 0; j --) {
			int k = a[i] ^ j ^ a[i + 1];
			if (k <= j) dp[i & 1][k] = min(dp[i & 1][k], dp[~i & 1][j]);
			mn = min(mn, dp[~i & 1][j]);
			dp[i & 1][j] = min(dp[i & 1][j], mn + b[i]);
		}
	}
	i64 res = 1ll << 60;
	for (int i = 0; i < mx; i ++)
		res = min(res, dp[1][i]);

	for (int i = 0; i < mx; i ++)
		dp[n & 1][i] = b[n];
	for (int i = n - 1; i >= 1; i --) {
		i64 mn = 1ll << 60;
		for (int j = 0; j < mx; j ++)
			mn = min(mn, dp[~i & 1][j]);
		for (int j = 0; j < mx; j ++) {
			dp[i & 1][j] = mn + b[i];
			if ((a[i] ^ j) <= (a[i + 1] ^ j))
				dp[i & 1][j] = min(dp[i & 1][j], dp[~i & 1][j]);
		}
	}
	for (int i = 0; i < mx; i ++)
		res = min(res, dp[1][i]);

	cout << res << '\n';
}

:::

CSP-S2025 游记

本文章由 WyOJ Shojo 从洛谷专栏拉取,原发布时间为 2025-11-02 22:38:57

CSP-S2025 游记

风物长宜放眼量,心胸要开阔。

省流:打的并不是很理想,可能去不了 WC 了。/ll

往年都没写过游记,但今年可能是 OI 生涯的最后一年了。为了不留遗憾,记录一下每次考试的经历吧。

Day -2 (10.29)

从初赛到这天一直在 LCA 长训营,学习到了一些新知识。感觉是有长进的,但是 CSP 发现还是远远不够的,该加训了。

下午放假之前,教练 lxn 举办了出征仪式,有经典切蛋糕环节,被 Iceturky 同学蛋糕抹脸真实了。还是挺快乐的,感觉好久没这么闹过了。

Day 1 (11.1)

上午随便做了点题,主要是找点做题的感觉(感觉打板子没啥用。下午进考场了,还是决定先想前两题,然后写完前两题,再做三、四题(下述时间可能是大概,因为赛时没有仔细记:

  • $14:30\sim 14:40$:读了 T1 的题面,然后想了一小会,会了。

  • $14:40\sim 14:50$:读了 T2 的题面,然而想错题意了,以为自己会了。

  • $14:50\sim 15:00$:写 T1,接着写出来了。

  • $15:00\sim 15:40$:写了一点,发现自己假完了。重新开始做 T2,然后发现乡村的边,只用到最小点和其余点所构成的边,但是又想错了,以为乡村每次都有 $c_i$ 的代价。然后,才发现需要 $2^k$ 枚举激活哪些乡村,感觉前面浪费了很多时间。

    后面其实还挺顺的,首先暴力是 $O(2^k m\log m)$ 的。这里想了一会,发现只有最小生成树的边有用,会了 $O(2^k k n\log n)$,但是这个包过不了的。想了想优化,发现我可以把最小生成树整个全记下来,这样就不带 $k$ 了,同时还可以归并排序做到不带 $\log$。

  • $15:40\sim 16:00$:写 T2(其实前面写过了,只是都是假的),调了一会过了。

  • $16:00\sim 16:15$:看 T3,想到每个对的格式为 $\text{LCP} + (X \to Y)+\text{LCS}$ 的形式,中间 $(X,Y)$ $s,t$ 必须相等,$\text{LCP}$ 和 $\text{LCS}$ 发现需要满足前后缀,然后是经典二维数点,秒了。

  • $16:15\sim 17:30$:写 T3,发现虽然思路简单,但是巨难处理。需要先把 $\text{LCP} + (X \to Y)+\text{LCS}$ 格式处理出来,然后还要找出前后缀区间,由于长度有足足 $5\times 10^6$,没敢上 Trie,写的二分。总之,写了好久,不过最后没调就过大样例了,爽(虽然,中间停下来运行了一次,那次 好像写错了一点点)。

  • $17:30\sim 18:00$:想 T4,时间太紧张了,一开始胡了个假做法。然后,想了好久啥也不会,最后,终于想到可以延迟计算贡献,会了 $O(n^4)$ 做法。

  • $18:00\sim 18:15$:写 $O(n^4)$ 做法,写完了但是发现好像调不出来了。于是,果断放弃,写了更好写的 $O(2^n n^2)$ 做法。

  • $18:15\sim 18:25$:极限写出来了。

出考场后,发现群里有人发判断 T3 $|t_1|\ne |t_2|$ 的事,才瞬间反应过来题意里只写了 $|s_1|=|s_2|$ 没写 $|t_1|=|t_2|$,被 ccf 做局了。

Day 2 (11. 2)

发现山东的压缩包已经破解出来了,在民间数据下 T3 还真没卡我。希望 ccf 也别卡了吧,但是 T4 发现挂了一点,发现是数组开小了,不过好在挂也挂不太多。

哎哎,感觉 T4 类似的题做了一些了,可能还是没有很好的总结一下吧,至少没有秒掉,感觉该加训了。

估分:$100+100+[0,100]+[8,20]=[208,320]$。

波动还是非常大的,但是民间数据都大于 $300$ 了。所以,ccf 数据造弱点的话,可能还是能去 WC 的。

毕竟去年没有去 WC,希望今年能进吧,OI 感觉应该至少去次 WC,要不太遗憾了。这次貌似机房的人(除了 chb)打的都不太好,总之风物长宜放眼量,心胸要开阔,加油训练吧……

「UVA177」折纸痕 Paper Folding

本文章由 WyOJ Shojo 从洛谷专栏拉取,原发布时间为 2024-12-13 15:22:05

[UVA177] Paper Folding

Problem Statement

给定一张无限大的纸,从右往左对折 $N$ 次,形成狭长的纸条。接下来,每次沿着折痕展开,但是仅展开 $90\degree$,形成立体图形。输出展开后水平看所得到的图案。

数据范围:$1\le N\le 13$。

Solution

考虑每次展开 $90\degree$ 等价于将原图形复制一份并旋转 $90\degree$,比如说:

而最终会展开 $N$ 次。所以,考虑对于一个图形,如何将其旋转 $90\degree$ 的图形对应填充上,而且 $\tt \$ 变 $\tt |$,$\tt |$ 变 $\tt \$。直接处理并不好搞,不过这些线可以转化为一定的方向,比如说初始为向右,接下来是向右向上,然后是向右向上向左向上,再者向右向上向左向上向左向下向左向上……。

不难发现,这一次会在上一次的基础上倒着再做一遍,只是这时候方向都逆时针旋转了 $90\degree$。比如说:

  • 倒着做一遍是向右,方向逆时针旋转 $90\degree$ 为向上:向右向上。
  • 倒着做一遍是向上向右,方向逆时针旋转 $90\degree$ 为向左向上:向右向上向左向上。
  • 倒着做一遍是向上向左向上向右,方向逆时针旋转 $90\degree$ 为向左向下向左向上:向右向上向左向上向左向下向左向上。
  • ……

接下来考虑实现,由于并不知道图究竟有多大。所以,直接开 $1000\times 1000$ 的图,初始在 $(500,500)$ 的位置填 $\tt \_$,接下来按照上述方式暴力填位置即可,由于题目输出要求比较特殊,只需要预处理一张 $4\times 2$ 的表,表示从 $4$ 个方向移动到另外两个垂直方向横纵坐标的变化即可。输出表的时候,先将覆盖整张图的最小矩形计算出来,然后对于每行,仅输出到最后一个字符。

时间复杂度:$O(2^N)$。

Code

#include <bits\/stdc++.h>
using namespace std;

int main() {
    int n;
    while (cin >> n && n) {
        vector<vector<char>> ans(1000, vector<char>(1000, ' '));
        vector<vector<int>> dx = {{1, 1}, {1, -1}, {-1, -1}, {1, -1}}, dy = {{0, 1}, {-1, -1}, {0, 1}, {0, 0}};
        ans[500][500] = '_';
        int x = 500, y = 500, lst = 0;
        vector<int> sd = {0};
        while (n -- ) {
            vector<int> cur;
            reverse(sd.begin(), sd.end());
            for (auto v : sd) {
                int u = (v + 1) % 4;
                x += dx[lst][u \/ 2], y += dy[lst][u \/ 2];
                if (u % 2 == 0) ans[y][x] = '_';
                else ans[y][x] = '|';
                cur.push_back(u), lst = u;
            }
            reverse(sd.begin(), sd.end());
            sd.insert(sd.end(), cur.begin(), cur.end());
        }

        int lx = 2000, ly = 2000, rx = -1;
        vector<int> r(1000);
        for (int i = 1; i < 1000; i ++)
            for (int j = 1; j < 1000; j ++)
                if (ans[i][j] != ' ') {
                    lx = min(lx, i), ly = min(ly, j);
                    rx = max(rx, i), r[i] = max(r[i], j);
                }

        for (int i = lx; i <= rx; i ++) {
            for (int j = ly; j <= r[i]; j ++)
                cout << ans[i][j];
            cout << endl;
        }
        cout << "^\n";
    }
}

「WyOJ Round 1」启 · 破茧初阳

本文章由 WyOJ Shojo 从洛谷专栏拉取,原发布时间为 2025-03-31 13:32:13

Solution

算法标签: 容斥原理。

上图给出了所求答案的范围。被 $a$ 整除且被 $b$ 整除的数量为 $$ \left\lfloor \frac{n}{\mathrm{lcm}(a,b)} \right\rfloor $$ 那么,被 $a$ 整除且不能被 $b$ 整除的数量为 $$ \left\lfloor \frac{n}{a} \right\rfloor - \left\lfloor \frac{n}{\mathrm{lcm}(a,b)} \right\rfloor $$ 被 $c$ 整除的数量为 $$ \left\lfloor \frac{n}{c} \right\rfloor $$ 此时,已经计算了红色和蓝色的区域:

不难发现,中间的小区域被算了 $2$ 次,需要减去。中间的小区域为 $$ \left \lfloor \frac{n}{\mathrm{lcm(a,c)}} \right \rfloor-\left \lfloor \frac{n}{\mathrm{lcm(a,b,c)}} \right \rfloor $$ 综上,答案为 $$ \left\lfloor \frac{n}{a} \right\rfloor - \left\lfloor \frac{n}{\mathrm{lcm}(a,b)} \right\rfloor+\left\lfloor \frac{n}{c} \right\rfloor-\left \lfloor \frac{n}{\mathrm{lcm(a,c)}} \right \rfloor+\left \lfloor \frac{n}{\mathrm{lcm(a,b,c)}} \right \rfloor $$

Code

\/\/ 验题人(KSCD_)代码
#include<iostream>
#define ll long long
#define i128 __int128
using namespace std;
const int N=1e5+10;
i128 gcd(i128 a,i128 b) {return b?gcd(b,a%b):a;}
i128 read() {ll x; cin>>x; return x;} 
i128 lcm(i128 a,i128 b) {return a*b\/gcd(a,b);}
void sol()
{
	i128 n=read(),a=read(),b=read(),c=read();
	ll res=n\/a-n\/lcm(a,b)+n\/c-(n\/lcm(a,c)-n\/lcm(lcm(a,b),c));
	cout<<res<<'\n';
}
int main()
{
	ios::sync_with_stdio(false),cin.tie(0),cout.tie(0);
	int TT; cin>>TT;
	while(TT--) sol();
	return 0;
}

「WyOJ Round 1」炽 · 踏浪而歌

本文章由 WyOJ Shojo 从洛谷专栏拉取,原发布时间为 2025-03-31 13:34:19

Solution

算法标签: 贪心。

最小次数分为两种情况:

  1. 最大值 $>$ 总和 $-$ 最大值:最大值。
  2. $\text{otherwise}$:总和除 $2$ 上取整。

考虑依据答案进行贪心输出字典序最小的方案,令初始的最小次数为 $\text{ans}$。那么,使用 set 维护还未变成 $0$ 的位置,每次只有 $3$ 种变换方式:

  1. 将最小位置减 $1$。
  2. 最小位置和次小位置同时减 $1$。
  3. 最小位置和最大值位置同时减 $1$。

存在第一种方式是显然的,对于第 $2$ 种是因为第 $1$ 种只有在和是偶数的时候才能执行。第 $3$ 种是因为第 $2$ 种有时候因为最大值过大而不能再减。

于是,只需要维护最大值(使用线段树或 set 维护)并每次一次分讨这三种情况是否可行即可输出字典序最小的方案。

Code

\/\/ std
#include <bits\/stdc++.h>
using namespace std;
using i64 = long long;
using pii = pair<int, int>;

int ilog(int x) { return 32 - __builtin_clz(x); }
template <class Info>
struct SGT {
	std::vector<Info> info;
	int N;
	SGT() {}
	SGT(int _N) { N = 1 << ilog(_N), info.resize(2 * N, Info()); }
	SGT(std::vector<Info> _info) {
		N = 1 << ilog(_info.size());
		info.resize(N * 2, Info());
		for (int i = N; i < N + _info.size(); i ++) info[i] = _info[i - N];
		for (int i = N - 1; i >= 1; i --) info[i] = info[i * 2] + info[i * 2 + 1];
	}
	void modify(int x, Info d) {
		info[x + N] = d;
		for (int i = (x + N) \/ 2; i >= 1; i \/= 2) info[i] = info[i * 2] + info[i * 2 + 1];
	}
	Info operator[] (pair<int, int> seg) {
		int L = seg.first + N, R = seg.second + N;
		Info LS = Info(), RS = Info();
		for (; L <= R; L = L \/ 2, R = R \/ 2) {
			if (L & 1) LS = LS + info[L ++];
			if (~R & 1) RS = info[R --] + RS;
		}
		return LS + RS;
	}
	Info operator[] (int x) { return info[x + N]; }
};
const int inf = 1 << 30;
struct Info {
	int mx, pos;
	Info(int _ = -inf, int _p = 0): mx(_), pos(_p) {}
};
Info operator+ (Info a, Info b) {
	Info c;
	if (a.mx >= b.mx) c = a;
	else c = b;
	return c;
}

const int maxn = 100005;

int n;
int a[maxn];

int main() {
	cin.tie(0);
	cout.tie(0);
	ios::sync_with_stdio(0);

	cin >> n;
	SGT<Info> sgt(n + 1);
	set<int> sp;
	int sum = 0;
	for (int i = 1; i <= n; i ++) {
		cin >> a[i];
		if (!a[i]) continue;
		sgt.modify(i, {a[i], i});
		sp.insert(i), sum += a[i];
	}

	int res = sgt[{1, n}].mx * 2 > sum ? sgt[{1, n}].mx : sum + 1 >> 1;
	std::vector<pii> opt;
	while (sp.size() > 1) {
		auto check = [&](int u, int v) -> bool {
			bool ok = true;
			if (u == v) {
				a[u] --, sum --;
				sgt.modify(u, {a[u], u});
				\/\/ cout << sgt[{1, n}].mx << " " << sum << endl;
				int cur = sgt[{1, n}].mx * 2 > sum ? sgt[{1, n}].mx : sum + 1 >> 1;
				if (cur != res - 1) ok = false;
				a[u] ++, sum ++, sgt.modify(u, {a[u], u});
			} else {
				a[u] --, sum --;
				a[v] --, sum --;
				sgt.modify(u, {a[u], u});
				sgt.modify(v, {a[v], v});
				int cur = sgt[{1, n}].mx * 2 > sum ? sgt[{1, n}].mx : sum + 1 >> 1;
				if (cur != res - 1) ok = false;
				a[u] ++, a[v] ++, sum += 2;
				sgt.modify(u, {a[u], u});
				sgt.modify(v, {a[v], v});
			}
			return ok;
		};
		int u = *sp.begin(), v = *next(sp.begin()), w = sgt[{1, n}].pos;
		if (check(u, u)) {
			sum --, opt.push_back({u, u});
			a[u] --, sgt.modify(u, {a[u], u});
			if (!a[u]) sp.erase(u);
		} else if (check(u, v)) {
			opt.push_back({u, v});
			a[u] --, a[v] --, sum -= 2;
			sgt.modify(u, {a[u], u});
			sgt.modify(v, {a[v], v});
			if (!a[u]) sp.erase(u);
			if (!a[v]) sp.erase(v);
		} else {
			opt.push_back({u, w});
			a[u] --, a[w] --, sum -= 2;
			sgt.modify(u, {a[u], u});
			sgt.modify(v, {a[v], v});
			if (!a[u]) sp.erase(u);
			if (!a[w]) sp.erase(w);
		}
		res --;
	}
	if (sp.size() == 1) {
		int u = *sp.begin();
		for (int i = 1; i <= a[u]; i ++)
			opt.push_back({u, u});
	}

	cout << opt.size() << '\n';
	for (auto [u, v] : opt)
		cout << u << " " << v << '\n';

	return 0;
}

「WyOJ Round 1」持 · 山海为肩

本文章由 WyOJ Shojo 从洛谷专栏拉取,原发布时间为 2025-03-31 13:35:49

Solution

算法标签: 动态规划。

考虑三进制状压,令 $0$ 表示出石头,$1$ 表示出布,$2$ 表示出剪刀。令 $f_{i,j,k}$ 表示对于出招方式 $j$,前 $i$ 轮获胜 $-$ 失败等于 $k$ 且 $i+1\sim m$ 轮强制钦定与 $j$ 出招方式一样。转移分讨第 $i$ 轮对局情况(令 $x$ 为第 $i$ 轮出的方式):

  1. 获胜:第 $i$ 轮高适只能出 $(x+2)\bmod 3$,所以从对应的 $f_{i-1,j-3^ix+3^i(x+2)\bmod 3,k-1}$ 转移而来,因为这样强制钦定第 $i$ 位是 $(x+2)\bmod 3$,从而保证获胜。
  2. 平局:第 $i$ 轮高适只能出 $x$,所以从 $f_{i-1,j,k}$ 转移而来。
  3. 失败:第 $i$ 轮高适只能出 $(x+1)\bmod 3$,所以从对应的 $f_{i-1,j-3^ix+3^i(x+1)\bmod 3,k+1}$ 转移而来。

时间复杂度:$O(3^m m^2)$。

Code

#include <bits\/stdc++.h>
using namespace std;
using i64 = long long;

const double eps = 1e-10;

int n, m;
int pw[13];
double dp[2][531441][25];

int get(string x) {
	if (x == "rock") return 0;
	else if (x == "paper") return 1;
	else return 2;
}

int main() {
	cin.tie(0);
	cout.tie(0);
	ios::sync_with_stdio(0);

	cin >> n >> m;
	pw[0] = 1;
	for (int i = 1; i <= m; i ++)
		pw[i] = pw[i - 1] * 3;

	for (int i = 1; i <= n; i ++) {
		string out;
		double p;
		cin >> p;
		int mask = 0;
		for (int j = 0; j < m; j ++)
			cin >> out, mask += pw[j] * get(out);
		dp[0][mask][m] += p;
	}

	for (int j = 1; j <= m; j ++)
		for (int i = 0; i < pw[m]; i ++)
			for (int k = 0; k <= 2 * m; k ++) {
				dp[j & 1][i][k] = dp[(j - 1) & 1][i][k]; \/\/ 第 j 轮平局
				int out = i \/ pw[j - 1] % 3;
				\/\/ 第 j 轮获胜
				if (k) dp[j & 1][i][k] += dp[(j - 1) & 1][i - out * pw[j - 1] + (out + 2) % 3 * pw[j - 1]][k - 1];
				\/\/ 第 j 轮失败
				if (k + 1 < 2 * m) dp[j & 1][i][k] += dp[(j - 1) & 1][i - out * pw[j - 1] + (out + 1) % 3 * pw[j - 1]][k + 1];
			}

	double res = -1;
	int mask;
	vector<pair<string, int>> ord;
	for (int i = 0; i < pw[m]; i ++) {
		string temp;
		for (int j = 0; j < m; j ++)
			temp += char((i \/ pw[j] % 3) + '0');
		ord.push_back({temp, i});
	}
	sort(ord.begin(), ord.end());

	for (auto [t, i] : ord) {
		double sum = 0;
		for (int j = m; j <= 2 * m; j ++)
			sum += dp[m & 1][i][j];
		if (sum > res + eps)
			res = sum, mask = i;
	}

	printf("%.6lf\n", res);
	for (int i = 0; i < m; i ++)
		if (mask \/ pw[i] % 3 == 0) printf("rock ");
		else if (mask \/ pw[i] % 3 == 1) printf("paper ");
		else printf("scissors ");
	printf("\n");

	return 0;
}

「WyOJ Round 1」归 · 星穗垂野

本文章由 WyOJ Shojo 从洛谷专栏拉取,原发布时间为 2025-03-31 13:39:05

Solution

算法标签:动态规划、可持久化线段树。

$O(n^3)$

令 $f_{i,j}$ 表示前 $i$ 个数中,选择的最后一段(注意该段的末尾可以不为 $i$)的长度为 $j$ 的最小代价,转移如下:

$$ f_{i,j}=\min \begin{cases} & f_{i-j,k}+\gcd(a_{i-j+1},\dots,a_i)\times \sum_{i=l}^r b_i+C)\quad k\le \gcd\le j\ & f_{i-1,j} \end{cases} $$

其中 $\gcd$ 和 $\sum$ 可分别通过 ST 表和前缀和维护,这样查询时复杂度为 $O(1)$。

$O(n\log n\log V)$

性质:$\gcd$ 只有 $\log V$ 种不同的值(因为 $\gcd$ 相当于质因数集合取交,而至多只有 $\log V$ 个质因数)。

注意到,若 $\gcd$ 相同,则 $j$ 越小越好。原因如下:

  1. $b_i\ge 0$,那么显然 $j$ 越小 $\sum$ 越小。
  2. 转移中有 $f_{i,j}=f_{i-1,j}$,所以 $k$ 相同的情况下,$i-j$ 值越大必然 $f_{i-j,k}$ 越小。而且 $\gcd$ 相同的情况 $k$ 的范围是一样的。

综上,$j$ 越小越好,也就是只需要转移 $\log V$ 种 $j$ 的值。不过,这 $\log V$ 种不一定全都是在 $\gcd$ 第一次变成某个值的时候,因为有 $\gcd\le j$ 的条件,这里找到第一个 $\ge \gcd$ 的 $j$ 即可。

接下来,考虑到 $k$ 的可取范围是一个区间,$j$ 相当于单点修。于是,不难想到将第二维放到线段树上。不过,由于第一维每次取的是 $f_{i-j}$,所以需要可持久化线段树。

$f_{i,j}=f_{i-1,j}$ 的转移直接通过继承 $i-1$ 版本的线段树得到即可。

时空复杂度均为 $O(n\log n\log V)$。

Code 1

\/\/ std
#include <bits\/stdc++.h>
using namespace std;
using i64 = long long;
template <class T>
void ckmn(T &a, T b) {
    a = min(a, b);
}
template <class T>
T gcd(T a, T b) {
    return !b ? a : gcd(b, a % b);
}

const int maxn = 1e5 + 5;
int n;
i64 C;
int a[maxn];
i64 b[maxn];
i64 preb[maxn];

i64 sum(int l, int r) {
    return preb[r] - preb[l - 1];
}

struct ST {
    int _gcd[18][maxn];
    void init() {
        for (int i = 1; i <= n; i++) {
            _gcd[0][i] = a[i];
        }
        for (int i = 1; i <= 17; i++) {
            for (int j = 1; j <= n - (1 << i) + 1; j++) {
                _gcd[i][j] = gcd(_gcd[i - 1][j], _gcd[i - 1][j + (1 << i - 1)]);
            }
        }
    }
    int query(int l, int r) {
        int _ = __lg(r - l + 1);
        return gcd(_gcd[_][l], _gcd[_][r - (1 << _) + 1]);
    }
} st;
struct HJT {
    struct Node {
        int ls, rs;
        i64 mn;
    } nd[32400005];
    int ndcnt;
    void pushup(Node &x) {
        x.mn = min(nd[x.ls].mn, nd[x.rs].mn);
    }
    void build(int &rt, int l, int r) {
        if (!rt)
            rt = ++ndcnt;
        nd[rt].mn = 1e18;
        if (l == r) {
            return;
        }
        int mid = (l + r >> 1);
        build(nd[rt].ls, l, mid);
        build(nd[rt].rs, mid + 1, r);
    }
    void change(int &rtold, int &rtnew, int l, int r, int x, i64 _v) {
        if (!rtnew) {
            rtnew = ++ndcnt;
            nd[rtnew].mn = nd[rtold].mn;
        }
        if (l == r) {
            ckmn(nd[rtnew].mn, _v);
            return;
        }
        int mid = (l + r >> 1);
        if (mid >= x) {
            if (nd[rtnew].ls == nd[rtold].ls)
                nd[rtnew].ls = 0;
            change(nd[rtold].ls, nd[rtnew].ls, l, mid, x, _v);
        } else {
            if (nd[rtnew].rs == nd[rtold].rs)
                nd[rtnew].rs = 0;
            change(nd[rtold].rs, nd[rtnew].rs, mid + 1, r, x, _v);
        }
        if (!nd[rtnew].ls)
            nd[rtnew].ls = nd[rtold].ls;
        if (!nd[rtnew].rs)
            nd[rtnew].rs = nd[rtold].rs;
        pushup(nd[rtnew]);
    }
    i64 query(int rt, int curl, int curr, int l, int r) {
        if (!rt)
            return 1e18;
        if (curl >= l && curr <= r) {
            return nd[rt].mn;
        }
        int mid = (curl + curr >> 1);
        i64 ans = 1e18;
        if (mid >= l)
            ckmn(ans, query(nd[rt].ls, curl, mid, l, r));
        if (mid < r)
            ckmn(ans, query(nd[rt].rs, mid + 1, curr, l, r));
        return ans;
    }
} ds;
int rt[maxn];
void solve(int id_of_test) {
    cin >> n >> C;
    for (int i = 1; i <= n; i++) {
        cin >> a[i];
    }
    st.init();
    for (int i = 1; i <= n; i++) {
        cin >> b[i];
        preb[i] = preb[i - 1] + b[i];
    }
    ds.build(rt[0], 1, 100000);
    for (int r = 1; r <= n; r++) {
        rt[r] = ++ds.ndcnt;
        ds.nd[rt[r]] = ds.nd[rt[r - 1]];
        int ok = 0;
        int efl = 1, efr = r;
        while (efl <= efr) {
            int mid = (efl + efr >> 1);
            if (r - mid + 1 >= st.query(mid, r)) {
                ok = mid;
                efl = mid + 1;
            } else {
                efr = mid - 1;
            }
        }
        if (!ok) continue;
        int l = ok;
        int _gcd = st.query(l, r);
        while (l >= 1) {
            if (r - l + 1 >= _gcd) {
                i64 tot = 1ll * _gcd * sum(l, r);
                i64 ans = tot;
                ckmn(ans, tot + C + ds.query(rt[l - 1], 1, 100000, 1, _gcd));
                ds.change(rt[r - 1], rt[r], 1, 100000, r - l + 1, ans);
            }
            int efl = 1, efr = l;
            int ok = l;
            while (efl <= efr) {
                int mid = (efl + efr >> 1);
                int k = __lg(r - mid + 1);
                if (st._gcd[k][mid] % _gcd != 0 || st._gcd[k][r - (1 << k) + 1] % _gcd != 0) {
                    efl = mid + 1;
                } else {
                    ok = mid;
                    efr = mid - 1;
                }
            }
            l = ok - 1;
            if (l)
                _gcd = st.query(l, r);
        }
    }
    cout << ds.query(rt[n], 1, 100000, 1, 100000) + C << '\n';
}
int main() {
    ios::sync_with_stdio(0);
    cin.tie(0);
    cout.tie(0);
    int T;
    cin >> T;
    for (int _ = 1; _ <= T; _++) {
        solve(_);
    }
    return 0;
}

Code 2

\/\/ 验题人(KSCD_)的代码
#include<iostream>
#define ll long long
using namespace std;
const int N=1e5+10;
const int K=18;
const ll inf=2e18;
int n,a[N],rt[N]; ll C,s[N];
int gcd(int a,int b) {return (b?gcd(b,a%b):a);}
struct ST
{
	int w[N][K],lg[N],po[K];
	void build()
	{
		lg[0]=-1,po[0]=1;
		for(int i=1;i<=n;i++) lg[i]=lg[i>>1]+1,w[i][0]=a[i];
		for(int i=1;i<18;i++) po[i]=po[i-1]*2;
		for(int k=1;po[k]<=n;k++) for(int i=1;i+po[k]-1<=n;i++)
			w[i][k]=gcd(w[i][k-1],w[i+po[k-1]][k-1]);
	}
	int query(int l,int r)
	{
		int k=lg[r-l+1];
		return gcd(w[l][k],w[r-po[k]+1][k]);
	}
}Ta;
struct Exsgmtt
{
	int t,lc[N*K*K],rc[N*K*K]; ll w[N*K*K];
	int build(int l,int r)
	{
		int u=++t; w[t]=inf;
		if(l<r)
		{
			int mid=((l+r)>>1);
			lc[u]=build(l,mid),rc[u]=build(mid+1,r);
		}
		return u;
	}
	int update(int u,int l,int r,int p,ll x)
	{
		int v=++t; w[v]=min(w[u],x),lc[v]=lc[u],rc[v]=rc[u];
		if(l<r)
		{
			int mid=((l+r)>>1);
			if(p<=mid) lc[v]=update(lc[v],l,mid,p,x);
			else rc[v]=update(rc[v],mid+1,r,p,x);
		}
		return v;
	}
	ll query(int u,int l,int r,int L,int R)
	{
		if(l>=L&&r<=R) return w[u];
		ll res=inf; int mid=((l+r)>>1);
		if(L<=mid) res=min(res,query(lc[u],l,mid,L,R));
		if(R>mid) res=min(res,query(rc[u],mid+1,r,L,R));
		return res;
	}
}T;
void sol()
{
	cin>>n>>C,T.t=0,T.build(0,n),rt[0]=T.update(1,0,n,0,0);
	for(int i=1;i<=n;i++) cin>>a[i];
	for(int i=1,x;i<=n;i++) cin>>x,s[i]=s[i-1]+x;
	Ta.build();
	for(int i=1;i<=n;i++)
	{
		int L=1,R; rt[i]=rt[i-1];
		while(L<=i)
		{
			int k=Ta.query(i-L+1,i),l=L,r=i; R=L;
			while(l<=r)
			{
				int mid=((l+r)>>1);
				if(Ta.query(i-mid+1,i)==k) R=mid,l=mid+1;
				else r=mid-1;
			}
			if(k<=R)
			{
				int j=max(k,L);
				ll f=T.query(rt[i-j],0,n,0,k)+1ll*k*(s[i]-s[i-j])+C;
				rt[i]=T.update(rt[i],0,n,j,f);
			}
			L=R+1;
		}
	}
	cout<<T.query(rt[n],0,n,1,n)<<'\n';
}
int main()
{
	ios::sync_with_stdio(false),cin.tie(0),cout.tie(0);
	int TT; cin>>TT;
	while(TT--) sol();
	return 0;
}

浅谈 OI 中的连通性相关算法

本文章由 WyOJ Shojo 从洛谷专栏拉取,原发布时间为 2025-05-13 21:30:57

阅读方式:博客园(强烈推荐)

$\S 1\quad$ 前言

本文旨在深入探讨图论中的强连通分量和双连通分量的求解方法及其理论基础,深入剖析 Tarjan 算法的本质。本文仅讨论求解连通分量的算法而不讨论缩点等引申问题。DFS 和 BFS 作为图论研究中的核心工具,将在本文中频繁涉及。因此,读者在阅读本文之前,应具备对 DFS 算法及其相关性质的扎实理解。

在强连通分量部分,本文将详细介绍经典的 Tarjan 算法,并探讨相对小众但具有独特价值的 Kosaraju 算法。在双连通分量部分,同样讲解 Tarjan 算法,并进一步阐述如何运用 Kosaraju 算法求解边双连通分量。尽管该方法在 OI 界中较少提及,但其算法实现的复杂度并不高于 Tarjan 算法,具有一定的应用价值。

Tarjan 算法在 OI 中已成为解决连通性问题的主流算法,其广泛的应用性和高效性备受青睐。然而,Kosaraju 算法亦具有重要的学习意义。在特定问题场景下,Kosaraju 算法的实现过程更为简洁,能够为问题求解提供更为高效的途径。

在 OI 中,不管是强连通分量,还是双连通分量,通常均是为了缩点(将一个分量缩成一个点),从而使得图变得更有性质。强连通分量缩点后变成 DAG,双连通分量缩点后变成树。在 DAG / 树上做,显然会容易许多。

$\S 2\quad$ DFS 生成树

Tarjan 算法的关键便是 DFS 生成树,其内的大部分性质能够帮助我们有效地解决连通性问题,故让我们下来了解 DFS 生成树。DFS 生成树指在对有向图 / 无向图遍历的过程中所生成出的树,接下来将分别对有向图和无向图描述 DFS 生成树。

$\S 2.1\quad$ 有向图的 DFS 生成树

有向图的 DFS 生成树共存在 $4$ 种边:

  1. 树边($\texttt{tree edge}$):当 DFS 到 $u$ 点,向下搜索到还未被访问过的点 $v$ 时,便形成了一条树边 $(u,v)$。通过树边,构建出原图的一棵生成树。示意图中以黑色边表示。
  2. 反祖边($\texttt{back edge}$):在生成树中,$u$ 点指向祖先节点 $v$ 的边。示意图中以蓝色边表示。
  3. 前向边($\texttt{foward edge}$):在生成树中,$u$ 点指向 $u$ 点子树中节点 $v$ 的边。示意图中以红色边表示。
  4. 横叉边($\texttt{cross edge}$):在生成树中,$u$ 点指向与点 $u$ 不存在祖先关系的节点 $v$ 的边。示意图中以绿色边表示。

【性质】 图 $G$ 为有向无环图当且仅当 DFS 生成树中不存在反祖边,即环是通过反祖边形成的。

【习题】

  1. 如何通过 DFS 生成树判定有向图是否存在环?要求:时间复杂度为 $O(n+m)$。
  2. [HT-043-Rainbow] 美愿时代的恨意

$\S 2.2\quad$ 无向图的 DFS 生成树

无向图的 DFS 生成树共存在 $2$ 种边:

  1. 树边($\texttt{tree edge}$):当 DFS 到 $u$ 点,向下搜索到还未被访问过的点 $v$ 时,便形成了一条树边 $(u,v)$。通过树边,构建出原图的一棵生成树。示意图中以黑色边表示。
  2. 后向边($\texttt{back edge}$):在生成树中,$u$ 点连接祖先节点(非父亲节点)$v$ 的边。示意图中以红蓝绿边表示。

【思考】 为什么在无向图中没有横叉边呢?

【解答】 假设存在横叉边 $(u,v)$,不失一般性地令 $u$ 在 DFS 中比 $v$ 先遍历到。那么,DFS 过程中一定会把 $u$ 的出边均遍历完,也就是说会通过这条横叉边遍历到 $v$,于是 $(u,v)$ 应为树边。与假设矛盾,证毕。

【习题】 如何通过 DFS 生成树判定无向图是否存在环?要求:时间复杂度为 $O(n+m)$。

$\S 3\quad$ 强连通分量

【定义】

  1. 强连通:有向图 $G$ 强连通是指 $G$ 中任意两个结点连通。
  2. 强连通分量(Strongly Connected Components,SCC):极大的强连通子图。

$\S 3.1\quad$ Tarjan 算法

考虑 DFS 生成树与强连通分量之间的联系,对于一个强连通分量,其在 DFS 生成树上必然是一棵子树,而这棵子树的根记作 $u$。则有,该子树(强连通分量)内的所有点均无法到达 $u$ 点的祖先节点。

依此设计算法,若能够求解出每个节点能够到达的深度最浅(或时间戳最小)的祖先节点,那么当且仅当这个节点是节点 $u$ 本身时,说明 $u$ 是强连通分量对应子树的根。此时,$u$ 点对应的强连通分量中的其余节点,为以 $u$ 为根的子树中,排除其他强连通分量的节点后所剩余的节点。


可能略微难懂,举例说明:

如图橘色、红色、粉色、蓝色圈出的区域分别为 $\text{SCC 1,2,3,4}$ 对应的子树,而 $1,3,4,7$ 分别为这些子树的根节点。每个节点能够到达的深度最浅(或时间戳最小)的祖先节点分别为 $$ \begin{array}{|c|c|c|} \hline 节点 & 祖先节点 & \text{SCC } 编号\ \hline 1 & 1 & 1\ \hline 2 & 1 & 1\ \hline 3 & 3 & 2\ \hline 4 & 4 & 4\ \hline 5 & 1 & 1\ \hline 6 & 1 & 1\ \hline 7 &7 & 3\ \hline \end{array} $$ 不难发现,$1,3,4,7$ 节点其祖先节点等于其本身,也就是它们是 SCC 对应子树的根,而其余节点就相当于子树内排除别的 SCC 后剩下的点。比如说,$\text{SCC 1}$ 就相当于 $1$ 节点子树内(也就是整棵树),排除其余 SCC($\text{SCC 2,3,4}$)的节点($3,4,7$)后,剩下的节点 $1,2,5,6$。这样,就可以求解每个 SCC 内的点了。


于是,接下来的问题便是如何快速找到每个节点能够到达的深度最浅(或时间戳最小)的祖先节点,为此定义 $\texttt{dfn}$ 数组和 $\texttt{low}$ 数组:

  • $\texttt{dfn}_u$:DFS 访问到 $u$ 点的时间戳。
  • $\texttt{low}_u$:$u$ 点所能走到的 $\texttt{dfn}$ 最小的还在栈中的节点的时间戳(深度等等均可)。

注意到,在 $\texttt{low}$ 数组的定义中涉及到栈,栈是干什么的呢?栈就是为了排除 $u$ 子树内其余的 SCC 的节点,也是为了保证其对应的节点与 $u$ 存在祖先关系,不会通过横叉边跑到别的子树中去。

而 $\tt dfn,low$ 的维护只需在 DFS 的过程中,类似树形 DP 的方式去维护即可:

  1. $v$ 点未被访问过(树边):DFS(v) 并 $\texttt{low}_u=\min(\texttt{low}_u, \texttt{low}_v)$。
  2. $v$ 点在栈中:$\texttt{low}_u=\min(\texttt{low}_u, \texttt{low}_v)$。

每当计算出 $\texttt{low}_u$ 的值后(也就是最终 DFS(u) 结束的时候),判断 $u$ 是否为某个 SCC 对应子树的根节点。若是,则将栈中的节点依此弹出直至 $u$,注意不能全部弹出因为存在 $u$ 的祖先节点。而弹出的这些节点,便是这个 SCC 内部的点。至此,我们知晓如何通过 Tarjan 来求解 SCC。


$\text{Talk is cheap, show me the code.}$

void tarjan(int u) {
    dfn[u] = low[u] = ++ tot;
    stk[ ++ top] = u, in_stk[u] = true;
    for (auto v : g[u])
        if (!dfn[v]) {
            tarjan(v);
            low[u] = min(low[u], low[v]);
        } else if (in_stk[v])
            low[u] = min(low[u], low[v]);
    if (dfn[u] == low[u]) {
        int w;
        do {
            w = stk[top -- ];
            in_stk[w] = false;
            \/*
            根据不同的题目,维护不同的信息。
            *\/
        } while (w != u);
    }
}

$\S 3.2\quad$ Kosaraju 算法

原图的汇是反图的源。

Kosaraju 的思想与 Tarjan 算法截然不同,Kosaraju 算法并没有用到 DFS 生成树,只是简单的 DFS 就解决了复杂的 SCC 问题。所以,笔者认为 Kosaraju 算法是非常优美的!

如 $\S 1\ 前言$ 所言,强连通分量缩点后是一张 DAG(有向无环图),那么如果能够找到出度为 $0$ 的 SCC 内部的任意一个点,那么再跑 DFS 所经过的点便是这个 SCC 内部的点,因为其出度为 $0$ 无法到达其他 SCC 而且 SCC 内部任意两个点连通所以肯定能将这个 SCC 遍历完。将某个出度为 $0$ 的 SCC 处理完后,将其以及指向它的边删除并继续操作,便能找到所有 SCC。

原图的汇是反图的源,这其实是反图上拓扑排序的过程,如果能得到反图的拓扑序,则按照该拓扑序在原图上不断 DFS,每次经过的点便是一个 SCC,注意不能遍历到之前 SCC 的点。于是,我们只需要得到反图的拓扑序即可,而 DFS 离开的顺序刚好是倒着的拓扑序,所以只需要在反图上跑一遍 DFS 并将离开的点的顺序记录出,并反着在原图上依此跑 DFS 即可。

如图,上来从 $\text{SCC 4}$ 中的任意一个点开始跑 DFS,由于强连通分量内的任意两点连通,故必然能将 $\text{SCC 4}$ 内部的点遍历完,而且 $\text{SCC 4}$ 不存在出边,所以只能遍历 $\text{SCC 4}$ 内部的点。$\text{SCC 5}$ 同理。接下来,遍历 $\text{SCC 3}$,虽然其能遍历到 $\text{SCC 5}$,但是由于已经遍历过了所以仍然只能在 $\text{SCC 3}$ 中。以此类推……

【习题】 如果先在原图跑出拓扑序,再在反图上跑对吗?如果对,说明理由;否则,给出反例。


$\text{Talk is cheap, show me the code.}$

void dfs1(int u) {
    vis[u] = 1;
    for (auto v : rg[u])
        if (!vis[v]) dfs1(v);
    ord[ ++ tot] = u;
}
void dfs2(int u) {
    vis[u] = 1;
    for (auto v : g[u])
        if (!vis[v]) dfs2(v);
}
int main() {
    \/\/ 读入,g 为原图,rg 为反图。
    for (int i = 1; i <= n; i ++)
        if (!vis[i]) dfs1(i);
    memset(vis, 0, sizeof vis);
    for (int i = n; i >= 1; i --)
        if (!vis[ord[i]]) {
			dfs2(ord[i]);
            \/*
            根据不同题目维护不同信息
            *\/
        }
}

$\S 4\quad$ 双连通分量

【定义】

  1. 边双连通:对于两个点 $u$ 和 $v$,如果无论删去哪条边都不能使得它们不连通,就说 $u$ 和 $v$ 边双连通。
  2. 点双联通:对于两个点 $u$ 和 $v$,如果无论删去哪个点都不能使得它们不连通,就说 $u$ 和 $v$ 点双连通。
  3. 边双连通分量:对于一个无向图中的 极大 边双连通的子图,我们称这个子图为一个 边双连通分量
  4. 点双连通分量:对于一个无向图中的 极大 点双连通的子图,我们称这个子图为一个 点双连通分量

双连通分量的 Tarjan 算法与强连通分量原理是一样的,所以大家可以先尝试自行思考,再往下阅读。

$\S 4.1\quad$ 边双连通分量

边双连通分量和强连通分量是相通的。

$\S 4.1.1\quad$ Tarjan 算法

与强连通分量类似,还是维护 $\texttt{dfn}$ 和 $\texttt{low}$ 两个数组。只不过维护略有不同,甚至简单,因为无向图上没有横叉边。所以,我们不需要维护每个点是否在栈中,因为在维护 $\texttt{low}$ 的时候,如果不是树边,则一定是后向边,均与 $u$ 有祖先关系,不会导致维护到其余子树中。

接下来的部分均与强连通分量一致,有一个注意点是由于是无向边,一条树边 $(u,v)$,在 DFS 到 $v$ 的时候,仍会遍历 $u$,但不应该用 $\texttt{low}_u$ 来更新 $\texttt{low}_v$。所以,需要在 DFS 的时候,维护父亲节点的边,遍历的时候不遍历该边。

故,在写边双连通分量的时候,推荐使用链式前向星建图,这样容易维护父亲节点的边,因为每条边都有自己的编号。


$\text{Talk is cheap, show me the code.}$

void tarjan(int u, int fa) {
    dfn[u] = low[u] = ++ tot;
    stk[ ++ top] = u;
    for (int i = h[u]; ~i; i = ne[i]) {
        int v = e[i];
        if (i == fa) continue;
        if (!dfn[v]) {
            tarjan(v, i ^ 1);
            low[u] = min(low[u], low[v]);
        } else low[u] = min(low[u], low[v]);
    }
    if (low[u] == dfn[u]) {
        int w;
        do {
            w = stk[top -- ];
            \/*
            根据不同的题目,维护不同的信息。
            *\/
        } while (w != u);
    }
}

$\S 4.2\quad$ Kosaraju 算法

边双连通分量亦可 Kosaraju!

还记着,开头语说的边双连通分量和强连通分量是相通的?这是因为在通过 DFS 给无向图定向(树边向下,后向边向上)后,变成有向图,则每个强连通分量便是原来的边双连通分量!是不是很神奇?

实际上,原理是这样的。给无向图通过 DFS 定向后,原来每个环,现在仍然是有向环,而边双连通分量实际上就是由若干环拼接在一起的,而现在变成了若干个有向环拼接在一起,仍然是一个强连通分量(我相信没有人喜欢看枯燥无味的证明的,所以这里就不放,其实是不会证)。举个例子:

左图为边双连通图,右图为 DFS 定向后的图,蓝色虚线边为后向边,黑色边为树边。不难发现,这样定向后,最终是强连通图。

于是,考虑如何实现定向操作,笔者想到的实现方式是用深度实现,如果边 $(u,v)$,$v$ 的深度小于等于 $u$ 的深度,那么就说明这条边的方向就是现在的方向。这里,如果是树边,那么在 DFS 的过程中,$v$ 的深度还未计算,所以仍小于等于 $u$ 的深度,并无矛盾。

综上,使用链式前向星建图,第一次 DFS 的过程中标记哪些边使用了,第二次 DFS 在反图上跑一遍 DFS 即可。

实际上,这里只是将边双连通分量的求解转化为强连通分量的求解,并不是真正意义上的边双连通分量 Kosaraju。只是由于 Kosaraju 本身有一个 DFS 是在预处理,而定向可以同时进行,所以显得比较般配。但实际上,如果你先定向,然后再跑 Tarjan,只要不嫌麻烦,也没问题。


$\text{Talk is cheap, show me the code.}$

void dfs1(int u, int fa) {
	vis[u] = 1;
	for (int i = h[u]; ~i; i = ne[i]) {
		int v = e[i];
		if (dep[v] <= dep[u] && fa != i) st[i] = 1;
		if (!vis[v]) {
			dep[v] = dep[u] + 1;
			dfs1(v, i ^ 1);
		}
	}
	ord[ ++ tot] = u;
}
void dfs2(int u) {
	vis[u] = 1;
	for (int i = h[u]; ~i; i = ne[i])
		if (!vis[e[i]] && !st[i]) dfs2(e[i]);
}
int main() {
    for (int i = 1; i <= n; i ++)
		if (!vis[i]) dfs1(i, -1);
	memset(vis, 0, sizeof vis);
	for (int i = n; i >= 1; i --)
		if (!vis[ord[i]]) {
			dfs2(ord[i]);
            \/*
            根据不同的题目,维护不同的信息。
            *\/
        }
}

$\S 4.2\quad$ 点双连通分量(Tarjan 算法)

点双连通分量会比较反常,前文所述的连通分量都是互不相交地划分了所有点,而割点却可能出现在多个点双连通分量中。考虑如何求解割点,当知道点 $u$ 为割点后,则其每个儿子节点均为一个点双连通分量,也就很好找出每个点双连通分量了。

如何判断点 $u$ 为割点呢?考虑树边 $(u,v)$,当 DFS 完 $v$ 后,如果 $\texttt{low}_v\le \texttt{dfn}_u$,则说明点 $u$ 为割点,因为割掉后 $v$ 节点将于 $u$ 上面的节点不连通。不难发现,$u$ 是根的时候需要特判,如果根只有一个儿子,则不是割点;否则是割点。

那如何求点双连通分量呢?只需要当 $\texttt{low}_v\le \texttt{dfn}_u$​ 的时候,通过栈的方式将分量内部的点全部取出。这时候,就不需要特判根了,因为我们不需要知道根是否是割点,只是将根放入下面的分量中而已。但是,需要特判独立点,因为这时候没有儿子无法放入分量中,需要手动创建一个分量将其放入。


$\text{Talk is cheap, show me the code.}$

void tarjan(int u, int fa) {
    dfn[u] = low[u] = ++ tot;
    stk[ ++ top] = u;
    bool single = true;
    for (int i = h[u]; ~i; i = ne[i]) {
        int v = e[i];
        single = false;
        if (i == fa) continue;
        if (!dfn[v]) {
            tarjan(v, i ^ 1);
            low[u] = min(low[u], low[v]);
            if (low[v] >= dfn[u]) {
                int w;
                do {
                    w = stk[top -- ];
                    \/*
                    根据不同的题目,维护不同的信息。
                    *\/
                } while (w != v);
                \/\/ 注意:u 也在分量中
            }
        } else low[u] = min(low[u], dfn[v]);
    }
    if (single) res.push_back({u});
}

$\S 5\quad$ 结语

至此,我们讨论完了 OI 中的强联通分量和双连通分量的算法,讲述了 Tarjan 算法究竟是在干什么?为什么要这么干?以及 Kosaraju 算法和边双连通分量的 Kosaraju 算法(本质上是将边双转变为强连通分量)。

如果您认为文章哪里存在问题或理解不当,欢迎指出,感谢您的阅读。

共 14 篇博客