P7516 [省选联考 2021 A/B 卷] 图函数 解题报告
P7516 [省选联考 2021 A/B 卷] 图函数 解题报告
〇、写在前面
大家好!这篇解题报告将带你一步步攻克这道看似复杂的图论问题。
初看题目,f(u, G)
的定义非常绕:它在一个循环里检查点对的连通性,同时又会动态地删除图中的点,这让模拟变得异常困难。更棘手的是,我们还需要在不断删边的过程中反复求解。
遇到这种“动态维护+复杂查询”的题目,我们的第一反应通常是:这个问题有没有更简单的等价描述? 这正是解题的突破口。
一、核心思想:化繁为简,寻找等价条件
让我们先忘记删边,只专注于如何计算一次 \(h(G) = \sum_{u=1}^n f(u, G)\)。
\(h(G)\) 是所有 \(f(u, G)\) 的总和。我们可以不按 \(u\) 来计算,而是考虑每一对点 \((u, v)\) 对总答案的贡献。
点 \(v\) 会在计算 \(f(u, G)\) 时被计入贡献(并被删除),当且仅当在轮到检查 \(v\) 时,图 \(G'\) 中存在 \(u \to v\) 和 \(v \to u\) 的双向路径。这里的 \(G'\) 是 G 删掉了一些在 \(v\) 之前被检查并删除的点(即编号小于 \(v\) 的点)后的图。
经过一番(非常复杂的)推理和大胆猜想,我们可以得出一个惊人的结论:
\(h(G)\) 的值,等于图中满足特定条件的“点对”的数量。
这个特定条件是什么呢?一个无序点对 \(\{u, v\}\) (\(u\) 和 \(v\) 可以是同一个点)被称为“有效点对”,当且仅当:
在原图 \(G\) 中,存在 \(u \to v\) 和 \(v \to u\) 的双向路径,且这两条路径上的所有中间节点(不含起点和终点)的编号都必须大于等于 \(\min(u, v)\)。
让我们来验证一下这个结论:
- 对于任意点 \(u\),点对 \(\{u, u\}\) 显然满足条件(不需要中间节点),所以天然就是有效点对。这对应了在计算 \(f(u, G)\) 时,当检查到 \(v=u\) 时,\(u\) 自身总是和自身连通的,会被计入贡献。这部分总共有 \(n\) 对。
- 对于点对 \(\{u, v\}\) 且 \(u \ne v\)(假设 \(u < v\)),它成为有效点对的条件是:在图中,\(u\) 和 \(v\) 互相连通,且路径的中间节点编号都 \(\ge u\)。
- 这恰好对应了在计算 \(f(u,G)\) 时,检查 \(v\) 时,\(u,v\) 互通;以及在计算 \(f(v,G)\) 时,检查 \(u\) 时,\(v,u\) 互通。经过严谨证明可以发现,这两种情况的贡献可以合并,最终等价于我们定义的“有效点对”。
所以,原问题被转化成了:计算图中“有效点对”的总数。
二、解法:倒序加边与“Floyd”思想
现在问题清晰多了。但别忘了,我们还要处理 \(m\) 次删边操作。
“删边”操作通常很难维护。一个经典的技巧是时间倒流:把删边看作是倒序加边。我们从一个空图开始,按 \(m, m-1, \dots, 1\) 的顺序把边加回去。这样,问题就从“每次删一条边求答案”变成了“每次加一条边求答案”。
加边是单调的:如果一对点在某个时刻成为了“有效点对”,那么在后续加入更多边后,它们仍然是“有效点对”。
这启发我们,可以对每一个可能的点对 \(\{u, v\}\),计算出它最早在哪条边加入后,成为了“有效点对”。
比如说,点对 \(\{u, v\}\) 在加入第 \(k\) 条边后首次成为“有效点对”,那么它将对 \(G_0, G_1, \dots, G_{k-1}\) 的答案都产生 1 的贡献。
我们可以用一个差分数组 count[k]
记录“在加入第 \(k\) 条边时,新增了多少个有效点对”。最后,通过后缀和就能求出所有时刻的答案。
ans[i] = ans[i+1] + count[i]
现在,唯一的挑战就是如何高效地计算出每个点对 \(\{u, v\}\) 首次成为“有效点对”的时刻。
三、算法实现:带权 Floyd
如何找到点对 \(\{u, v\}\) (假设 \(u<v\))成为“有效点对”的时刻?我们需要找到这样两条路径:
- 一条 \(u \to v\) 的路径,和一条 \(v \to u\) 的路径。
- 两条路径上的中间节点编号都 \(\ge u\)。
- 组成这两条路径的所有边中,编号最小的边的编号要尽可能大(因为我们是倒序加边,编号越大意味着时间越早)。
我们把每条边的“边权”定义为它的输入顺序(即时间)。我们想找的路径,就是所谓的“瓶颈路”或“最大瓶颈路”。
这个问题和经典的 Floyd-Warshall 算法非常相似。Floyd 算法通过枚举中间点 k
来更新点对之间的最短路。我们可以稍作修改:
-
定义
dist[i][j]
为:从 \(i\) 到 \(j\) 的所有路径中,路径上“最小边权”的最大值。dist[i][j]
的更新法则为:dist[i][j] = max(dist[i][j], min(dist[i][k], dist[k][j]))
。意思是,从 \(i\) 到 \(j\) 可以直接走,也可以经过 \(k\)。如果经过 \(k\),那么整条路径的瓶颈取决于 \(i \to k\) 和 \(k \to j\) 这两段中较差的那一段。
-
为了处理“中间节点编号 \(\ge \min(u,v)\)”的限制,我们让 Floyd 的中转点
k
从n
到1
倒序枚举。
完整的算法流程如下:
-
初始化:
dist[i][j]
初始为 0。对于输入的第t
条边x -> y
,我们设置dist[x][y] = t
。count
数组全部初始化为 0。
-
核心计算 (倒序 Floyd):
我们从k = n
到1
循环:- 在更新
dist
之前,dist[i][j]
的值代表了从 \(i\) 到 \(j\),只使用编号 \(>k\) 的点作为中间节点的最大瓶颈。 - 此时,我们可以计算所有与
k
相关的点对 \(\{i, k\}\)(其中 \(i>k\))的“有效时刻”。- 点对 \(\{i, k\}\) 成为“有效点对”的条件是:\(i, k\) 互通,且中间节点编号 \(\ge k\)。由于我们此时只允许 \(>k\) 的中间节点,这个条件是满足的。
- 它们互通的时刻(即最早加入的边的编号)就是
min(dist[i][k], dist[k][i])
。 - 令
t = min(dist[i][k], dist[k][i])
。我们在count[t]
上加一,表示在t
时刻,有一个新的点对 \(\{i, k\}\) 达标了。
- 更新
dist
矩阵:for i = 1 to n
,for j = 1 to n
:dist[i][j] = max(dist[i][j], min(dist[i][k], dist[k][j]))
- 这样,
dist
矩阵现在就包含了允许使用k
作为中转点的信息,为下一轮循环(k-1
)做好了准备。
- 在更新
-
统计答案:
- 初始时,空图(\(G_m\))有 \(n\) 个有效点对(所有 \(\{i, i\}\))。所以
ans[m+1] = n
。 - 从
i = m
到1
倒序计算:ans[i] = ans[i+1] + count[i]
。 ans[1]
就是完整图 \(G\) 的答案。
- 初始时,空图(\(G_m\))有 \(n\) 个有效点对(所有 \(\{i, i\}\))。所以
这个算法的时间复杂度是 \(O(n^3 + m)\),其中 \(O(n^3)\) 来自于 Floyd 算法, \(O(m)\) 来自于初始化和最后的统计,可以通过所有数据点。
四、代码解析
这里附上题解中简洁的代码,并加上注释方便理解。
#include <stdio.h>
#include <algorithm> // 为了使用 max 和 min
using namespace std;
const int N = 1003; // 点数上限
const int M = 200003; // 边数上限
int n, m;
int u[M], v[M]; // 存储输入的边
int f[N][N]; // 即报告中的 dist 矩阵,f[i][j] 记录 i->j 路径的最大瓶颈
int g[M]; // 即报告中的 count 数组,g[t] 记录在 t 时刻新增的有效点对数量
long long ans[M]; // 答案数组
int main() {
scanf("%d %d", &n, &m);
// 初始化 f 矩阵,读入边,记录边的“时刻”
for (int i = 1; i <= m; i++) {
scanf("%d %d", &u[i], &v[i]);
f[u[i]][v[i]] = i;
}
// Floyd 核心部分,中转点 k 从 n 到 1 倒序枚举
for (int k = n; k >= 1; k--) {
// 1. 计算与 k 相关的点对的贡献
// 对于任意 i > k,检查点对 {i, k}
// 此时 f[i][k] 和 f[k][i] 代表只经过 >k 的中转点的路径瓶颈
for (int i = k + 1; i <= n; i++) {
// 点对 {i, k} 成为有效点对的时刻是 min(f[i][k], f[k][i])
// 如果二者有一个为0,表示不连通,min下来也是0,不产生贡献
g[min(f[i][k], f[k][i])]++;
}
// 2. 用 k 作为中转点,更新所有点对的路径瓶颈
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= n; j++) {
// i->j 的新路径可以经过 k
// 新路径的瓶颈是 i->k 和 k->j 两段中较小的值
// 我们在原有路径和新路径中取一个瓶颈更大的
f[i][j] = max(f[i][j], min(f[i][k], f[k][j]));
}
}
}
// 3. 倒序统计最终答案
// ans[m+1] 对应的是删掉所有边(m条)后的图 Gm
// 此时只有 n 个 {i,i} 点对是有效的
ans[m + 1] = n;
for (int i = m; i >= 1; i--) {
// ans[i] 是图 Gi-1 的答案(删了 1~i-1 条边)
// 它等于图 Gi 的答案 ans[i+1] 加上在第 i 时刻新产生的有效点对数 g[i]
ans[i] = ans[i + 1] + g[i];
}
// 输出所有答案
for (int i = 1; i <= m + 1; i++) {
printf("%lld ", ans[i]);
}
puts("");
return 0;
}
五、总结
这道题的精髓在于两步漂亮的转化:
- 将原问题中复杂的、带动态删除的函数求和问题,转化为一个静态的、组合计数的“有效点对”问题。
- 将多次删边查询,通过“时间倒流”转化为单次加边过程中的贡献累加问题。
最终,我们用一个巧妙的、倒序枚举中转点的 Floyd 算法,在 \(O(n^3)\) 的时间内解决了所有子问题。希望这篇报告能帮助你理解这道题的巧妙之处!