1007
快速IO tarjan缩点
IO
首先这题卡IO,非常罕见,可能这就是OI出题人的毛病吧。。。
使用scanf和关流cin都会超时,使用getchar()的普通快读,也很极限。出题人的预期做法是fread,fwrite的快读快写,这里贡献一个基于fread,fwriteFastIO类的模板。
一般的io慢,是因为都是基于getchar()的,每次只读取一个字符,但却要调用一次read系统调用。在io优化中一个重要的思想是,内存里的数据解析是很快的,瓶颈在于把数据从stdin或file读取到内存的过程中系统调用的开销,应该尽量减少系统调用的次数。
fread之所以快,就是优化了这一点,他的参数接受一个数组指针,用这个数组作为缓冲区,然后依次读取指定大小的数据块到缓冲区,然后接下来的数据解析都直接在这块缓冲区上进行。
fread(in_buf, 1, BUF_SIZE, stdin),这里的四个参数依次是,
- 缓冲区数组指针,
- 单个数据大小,也就是每一步赋值多大的数据,这里设为一个字节,也就是认为读取的是
char类型, - 读取的数据块大小
- 输入流类型,可以是
stdin,也可以是file
这里我们可以设置很大的缓冲区,比如1MB,这样每读1MB的数据,才需要进行一次read系统调用,大大加速读入。
对于输出,也是类似的,设置一个输出缓冲区,写满了才调用一次fwrite,进行一次到stdout的输出。
题目思路
回到这题本身,输入一个网格图,有一些障碍物,还有一些单向传送门,可以从一个点传到另一个点。q次询问,每次问一个点能否移动到另一个点?
首先不考虑传送门,网格图上每个联通块内都可以相互到达,可以做一个dfs染色找到所有联通块,然后把每个联通块抽象成一个点。
对于传送门,在抽象出来的这个新图上相当于加了一些有向边,查询连通性,考虑用tarjan求强联通分量即可,在一个scc的相互可达,把一个scc的点缩点,成为一个点。
这样又建了一个新图,这个新图一定是个DAG了。这个新图上查询连通性,可以从每个点出发,做bfs找到所有可达点,也可以用Bitset表示每个点的状态,把DAG的边全部取反,做一个DP,每个点的bitset记录,从这个点出发可以到达的点集。
bitset是一般解法。但注意到这题保证传送门个数k=100,也就是虽然点数n=5e4,但是有向边只有100条,那么这个图大部分点都是孤立点,我们显然只用对有边的点为起点做bfs,并且这些边组成的子图,规模也只有O(k)O(k)O(k),这样复杂度只有O(k2)O(k^2)O(k2),并不会O(n2)O(n^2)O(n2),第一种bfs做法也完全可以接受。
c
struct FastIO {
static const int BUF_SIZE = 1 << 20; // 1MB 输入/输出缓冲区
char in_buf[BUF_SIZE], out_buf[BUF_SIZE];
int in_pos, in_len, out_pos;
// 构造函数:初始化指针
FastIO() : in_pos(0), in_len(0), out_pos(0) {}
// 析构函数:程序结束或对象销毁时,自动将残余数据刷新到 stdout
~FastIO() {
flush();
}
// 手动刷新输出缓冲区
inline void flush() {
if (out_pos > 0) {
fwrite(out_buf, 1, out_pos, stdout);
out_pos = 0;
}
}
// --- 输入逻辑 ---
inline char get_char() {
if (in_pos == in_len) {
in_pos = 0;
in_len = fread(in_buf, 1, BUF_SIZE, stdin);
if (in_len == 0) return EOF;
}
return in_buf[in_pos++];
}
// 读入整数 (支持 int, long long, __int128 等)
template <typename T>
inline bool read(T &x) {
x = 0;
int f = 1;
char ch = get_char();
if (ch == EOF) return false;
while (!isdigit(ch)) {
if (ch == '-') f = -1;
ch = get_char();
if (ch == EOF) return false;
}
while (isdigit(ch)) {
x = x * 10 + (ch ^ 48);
ch = get_char();
}
x *= f;
return true;
}
// 读入非空白字符
inline bool read(char &c) {
c = get_char();
while (c != EOF && c <= 32) c = get_char();
return c != EOF;
}
// 读入单词/字符串
inline bool read(std::string &s) {
s.clear();
char c = get_char();
while (c != EOF && c <= 32) c = get_char();
if (c == EOF) return false;
while (c != EOF && c > 32) {
s.push_back(c);
c = get_char();
}
return true;
}
// --- 输出逻辑 ---
inline void write_char(char c) {
if (out_pos == BUF_SIZE) flush();
out_buf[out_pos++] = c;
}
inline void write(char c) { write_char(c); }
// 输出整数
template <typename T>
inline void write(T x) {
if (x < 0) {
write_char('-');
x = -x;
}
if (x == 0) {
write_char('0');
return;
}
char stk[24];
int top = 0;
while (x) {
stk[top++] = (x % 10) ^ 48;
x /= 10;
}
while (top) write_char(stk[--top]);
}
// 输出字符串
inline void write(const char *s) {
while (*s) write_char(*s++);
}
inline void write(const std::string &s) {
for (char c : s) write_char(c);
}
} io; // 定义全局对象 io
void solve() {
int n, m, k, q;
io.read(n);
io.read(m);
io.read(k);
io.read(q);
vvi a(n + 1, vi(m + 1));
rep(i, 1, n) {
rep(j, 1, m) {
char c;
io.read(c);
if (c == '.') {
a[i][j] = 1;
}
}
}
vvi vis(n + 1, vi(m + 1));
int c0 = 0;
auto &&dfs = [&](auto &&dfs, int x, int y, int c)->void{
vis[x][y] = c;
rep(i, 0, 3) {
int nx = x + dx[i];
int ny = y + dy[i];
if (nx < 1 || nx > n || ny < 1 || ny > m)continue;
if (a[nx][ny] == 0 || vis[nx][ny] > 0)continue;
dfs(dfs, nx, ny, c);
}
};
rep(i, 1, n) {
rep(j, 1, m) {
if (!vis[i][j] && a[i][j]) {
dfs(dfs, i, j, ++c0);
}
}
}
vvi g(c0 + 1);
rep(i, 1, k) {
int x1, y1, x2, y2;
io.read(x1), io.read(y1), io.read(x2), io.read(y2);
int c1 = vis[x1][y1], c2 = vis[x2][y2];
if (c1 != c2)g[c1].push_back(c2);
}
vector<pii>Q(q + 1);
rep(i, 1, q) {
int x1, y1, x2, y2;
io.read(x1), io.read(y1), io.read(x2), io.read(y2);
Q[i] = {vis[x1][y1], vis[x2][y2]};
}
int cnt = 0, tot = 0, top = 0;
vi co(c0 + 10), dfn(c0 + 10), in(c0 + 10), low(c0 + 10), s(c0 + 10), ind(c0 + 10);
auto &&tarjan = [&](auto &&tarjan, int x)->void {
dfn[x] = low[x] = ++cnt;
s[++top] = x;
ind[x] = 1;
for (int v : g[x]) {
if (!dfn[v]) {
tarjan(tarjan, v);
low[x] = min(low[x], low[v]);
} else if (ind[v]) {
low[x] = min(low[x], low[v]);
}
}
if (low[x] == dfn[x]) {
++tot;
while (1) {
int X = s[top--];
co[X] = tot;
ind[X] = 0;
if (!(x ^ X)) {
break;
}
}
}
};
rep(i, 1, c0) {
if (!dfn[i]) {
tarjan(tarjan, i);
}
}
vvi g1(tot + 10);
rep(u, 1, c0) {
for (int v : g[u]) {
if (co[v] != co[u]) {
g1[co[u]].push_back(co[v]);
}
}
}
vi ans(q + 1);
vector<vector<pii>> bin(tot + 10);
rep(i, 1, q) {
int u = Q[i].first, v = Q[i].second;
if (co[u] == co[v]) {
ans[i] = 1;
} else {
bin[co[u]].emplace_back(co[v], i);
}
}
vi d(tot + 10), time(tot + 10);
int TIME = 0;
rep(i, 1, tot) {
TIME++;
if (g1[i].size() != 0) {
queue<int>q;
q.push(i);
if (time[i] != TIME) {
time[i] = TIME;
d[i] = 0;
}
d[i] = 1;
while (q.size()) {
int u = q.front();
q.pop();
for (int v : g1[u]) {
if (time[v] != TIME) {
d[v] = 0;
time[v] = TIME;
}
if (!d[v]) {
d[v] = 1;
q.push(v);
}
}
}
for (auto [v, id] : bin[i]) {
if (time[v] != TIME) {
time[v] = TIME;
d[v] = 0;
}
if (d[v]) {
ans[id] = 1;
}
}
}
}
rep(i, 1, q) {
io.write(ans[i]);
io.write('\n');
}
// cout << '\n';
}
1008
路径DP mex转化
一个树,可以给n个点,分别赋值为一个0,n−10,n-10,n−1的排列。问整棵树所有点对之间的路径,mex之和的最大值和最小值?
最小值
先看最小值,是可以很容易做到很小的具体来说,只要让0位于一个叶子上,那么除了以这个叶子为一个端点的n条路径外,其他路径都不含0,mex都是0了
对于以这个0叶子为一个端点的全部路径,mex至少为1,那么类似的,我们想让mex=1的路径个数最大化,更大的mex尽量少,那么可以把1安排在另一个叶子上,这样只有以01为两个端点的这一条路径,mex大于1,其余以0为端点的路径mex=1。
接下来考虑2的位置,如果这棵树不是一条链,那么可以把2放在01这条路径之外的点上,这样01这条路径mex就是2了。这样整体答案是1×(n−1)+2=n+11\times (n-1)+2=n+11×(n−1)+2=n+1
如果这棵树是一条链,那么01这两个叶子就是链的两个端点了,01路径上包含0,n−10,n-10,n−1全部点,01路径mex等于n,其余路径,前面的分析仍然是成立的,有n−1n-1n−1条mex=1的,其余mex都为0。整体答案为1×(n−1)+n=2n−11\times(n-1)+n=2n-11×(n−1)+n=2n−1
最大值
最大值要复杂一些,没有这种O(1)O(1)O(1)的策略了。
考虑转化一下mex公式,设si=(0,1...i)s_i=(0,1...i)si=(0,1...i),一个路径的mex,等于它包含的sis_isi的个数,比如路径点集为(0,1,2)(0,1,2)(0,1,2),包含s0,s1,s2s_0,s_1,s_2s0,s1,s2,3个前缀集合,则路径mex等于3
那么所有路径p的mex之和,就等于
∑p∑i=0n−1p包含si \sum_p\sum_{i=0}^{n-1}p包含s_i p∑i=0∑n−1p包含si
其中中括号,里面的语句为真则取值1,否则取值0
可以交换求和顺序
∑i=0n−1∑pp包含si \sum_{i=0}^{n-1}\sum_pp包含s_i i=0∑n−1p∑p包含si
也就是对于每个前缀集合sis_isi,求有多少路径包含它。现在树上点权是我们决定的,那么就是要找到一个策略,让包含sis_isi的路径数尽量多。
显然这样的策略就是让所有sis_isi都尽可能排布在一条链上,设当前uv链长度k,包含了0,k−10,k-10,k−1,那么此时包含sk−1s_{k-1}sk−1的所有路径,就是u,v都断开uv这条路径上的边后,所在的联通块中各选一点,设断开后联通块大小分别a,b,那么路径数就是a×ba\times ba×b。
那么这个过程可以dpdpdp,f(u,v)f(u,v)f(u,v)表示当前选中的链是路径(u,v)(u,v)(u,v),在这条链上,设长度为k的话,已经放了0,k−10,k-10,k−1这些点,此时的∑i=0k−1∑pp包含si\sum_{i=0}^{k-1}\sum_pp包含s_i∑i=0k−1∑pp包含si的最大值。
由于能选中的路径不同,需要考虑一个dp,维护所有路径的答案,取最大值。这里n=2000,允许O(n2)O(n^2)O(n2)的dp。转移就枚举u,vu,vu,v不在路径(u,v)(u,v)(u,v)上的邻居,采用刷表,每次拓展一个点。
这类似于树上的区间dp。因为转移顺序必须按照链长度转移,计算长度k的链之前,必须把所有长度k-1的链都算完。我们考虑先做一个dfs,把链按照长度收集,然后按长度升序枚举所有链。
转移中还需要知道,uv的邻居中哪个是在路径(u,v)上的,以及排除掉这个在路径上的邻居后,剩余联通块的大小。对于u,这等价于以v为根时,求u的父亲,以及u子树的大小,这可以以每个点为根,分别dfs,在O(n2)O(n^2)O(n2)的复杂度解决。这和前面按照长度收集所有路径都需要以每个点为根的dfs,可以放一起做。
c
#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;
const int MAXN = 2005;
// 邻接表与树拓扑信息
vector<int> adj[MAXN];
int depth[MAXN][MAXN]; // depth[r][u]: 以 r 为根时 u 的深度(即 u 到 r 的路径距离)
int parent[MAXN][MAXN];// parent[r][u]: 以 r 为根时 u 的父节点
int sz[MAXN][MAXN]; // sz[r][u]: 以 r 为根时 u 的子树大小
int degree[MAXN];
// 按路径距离(边数)分类的点对集合
vector<pair<int, int>> pairs_by_dist[MAXN];
// DP 数组及全局答案
long long dp[MAXN][MAXN];
long long max_score;
// 更新最大值的辅助函数
inline void update_max(long long &x, long long y) {
if (x < y) x = y;
}
// 预处理以 root 为根的 DFS 信息
void dfs(int root, int u, int p, int d) {
depth[root][u] = d;
parent[root][u] = p;
sz[root][u] = 1;
// 将点对 (root, u) 按距离挂载到对应的桶中
pairs_by_dist[d].push_back({root, u});
for (int v : adj[u]) {
if (v != p) {
dfs(root, v, u, d + 1);
sz[root][u] += sz[root][v];
}
}
}
void solve() {
int n;
cin >> n;
// 1. 重置与清空数据
max_score = 0;
int max_degree = 0;
for (int i = 1; i <= n; ++i) {
adj[i].clear();
pairs_by_dist[i - 1].clear();
degree[i] = 0;
fill(dp[i], dp[i] + n + 1, 0);
}
// 2. 建图
for (int i = 0; i < n - 1; ++i) {
int u, v;
cin >> u >> v;
adj[u].push_back(v);
adj[v].push_back(u);
degree[u]++;
degree[v]++;
}
for (int i = 1; i <= n; ++i) {
max_degree = max(max_degree, degree[i]);
}
// 3. 计算最小值
// 树为链时最小分数为 2N-1;非链树通过破坏第三个标签的共线性,最小分数恒为 N+1
long long min_score = (max_degree <= 2) ? (2LL * n - 1) : (n + 1);
// 4. 以每个节点为根全量 DFS 预处理树形态
for (int r = 1; r <= n; ++r) {
dfs(r, r, 0, 0);
}
// 5. 初始化 DP 基础状态:边界 dp(u, u) = F({u})
for (auto& p : pairs_by_dist[0]) {
int u = p.first;
long long single_contrib = 1;
long long current_sum = 1;
for (int v : adj[u]) {
single_contrib += current_sum * sz[u][v];
current_sum += sz[u][v];
}
dp[u][u] = single_contrib;
}
// 6. 按距离从小到大(刷表法 Push DP)拓展路径
for (int dist = 0; dist < n; ++dist) {
for (auto& path : pairs_by_dist[dist]) {
int u = path.first;
int v = path.second;
update_max(max_score, dp[u][v]);
// 向 u 的外侧扩展邻居节点 z
for (int z : adj[u]) {
// 必须保证 z 不在 u->v 的路径上(即 z 不为以 v 为根时 u 的父节点)
if (z != parent[v][u]) {
int l = min(v, z);
int r = max(v, z);
// 刷表转移:dp(u, v) -> dp(z, v)
update_max(dp[l][r], dp[u][v] + 1LL * sz[u][z] * sz[z][v]);
}
}
// 向 v 的外侧扩展邻居节点 z
for (int z : adj[v]) {
// 必须保证 z 不在 v->u 的路径上(即 z 不为以 u 为根时 v 的父节点)
if (z != parent[u][v]) {
int l = min(u, z);
int r = max(u, z);
// 刷表转移:dp(u, v) -> dp(u, z)
update_max(dp[l][r], dp[u][v] + 1LL * sz[v][z] * sz[z][u]);
}
}
}
}
cout << min_score << " " << max_score << "\n";
}
int main() {
ios_base::sync_with_stdio(false);
cin.tie(NULL);
int t;
if (cin >> t) {
while (t--) {
solve();
}
}
return 0;
}
1010
扫描线 前缀和 取模
对一个序列,求所有子区间的权值和。一个子区间的权值定义为,对区间做任意次操作,每次操作把两个相邻元素删掉,变成他们的和模三,再插入。直到变成字典序最小的形式,把这个结果看成一个三进制数,就是权值,从左往右看成三进制从低位到高位。
对所有区间求和,n=2e5,不能枚举区间,一般考虑枚举区间右端点,维护前缀和信息,对于每个右端点,利用前缀和算出他和所有可能左端点的贡献。
本题也这样考虑。维护模3的前缀和sis_isi。枚举rrr,根据sr−sl(mod3)s_r-s_l \pmod 3sr−sl(mod3)的值,也就是子区间的和模3的结果分类讨论
- 如果为0,字典序最小的方案就是合成一个0,方案可能有很多,但对答案的贡献都是0,忽略
- 如果为1那么字典序最小的方案是,前面合成尽量多的0,最后一段合成一个1,因为按字典序00001是比01要小的。对于一个rrr,多个lll,每个的贡献是一个3∗x3*x3∗x的形式,xxx是这个lll后面还有多少个余数相同的位置,每有一个都可以合成一个0,由于三进制从左往右,是从低位到高位,也就使得最后那个1的权重变高。举个例子就是01,001,001这样的序列,贡献是3,9,27。对于这样的贡献,可以用一个等比数列求和,只要知道有多少个lll,满足sr−sl(mod3)=1s_r-s_l\pmod 3=1sr−sl(mod3)=1,设有x个,那么总贡献就是∑i=1x3x−i=1+3+⋯+3x−1=3x−12\sum_{i=1}^{x}3^{x-i} =1+3+\cdots+3^{x-1} =\frac{3^x-1}{2}∑i=1x3x−i=1+3+⋯+3x−1=23x−1。
- 如果余数为2,可以类似的0002,前面若干个0,最后一段合成2.但这样不是字典序最小的,实际上00010001这样才是最优的,第一段0的个数,是lll右侧到rrr之前,和lll余数相同的个数,这些都可以合成0。最后一段和的余数为2,可以先找到第一个余数1的位置合成一个1,然后类似的,尽量多的合成0,直到最后一段合成1。
具体分析,sl=sr−2(mod3)s_l=s_r-2\pmod 3sl=sr−2(mod3),等价于sl=sr+1(mod3)s_l=s_r+1\pmod 3sl=sr+1(mod3)。这里第一个1的位置i,应该满足si=sl+1(mod3)s_i=s_l+1\pmod 3si=sl+1(mod3),接下我们需要知道这个1右侧能合成的0个数,就是右侧相同余数的位置数。对于这个东西,我们不止要保存前缀和的每种余数的出现次数cnt,还需要保存前缀和的每种余数,上一次出现时,的出现次数,也就是对cnt数组做一个快照last_cnt。这样查last_cnt,即可得到第一个1后面,还有多少个余数相同的位置,也就知道第二段0的长度,设为y,之前第一段0的个数,设为x。那么这个子区间的贡献为3x(1+3y)3^x(1+3^y)3x(1+3y)
对于多个lll,xxx是等差数列,yyy是相同的,因此可以把(1+3y)(1+3^y)(1+3y)提出来,剩下的部分仍然是∑i=1x3x−i=1+3+⋯+3x−1=3x−12\sum_{i=1}^{x}3^{x-i} =1+3+\cdots+3^{x-1} =\frac{3^x-1}{2}∑i=1x3x−i=1+3+⋯+3x−1=23x−1。
这些都分析清楚了,只需要为互一个前缀和余数的计数数组cnt,以及一个计数数组的历史版本(对每种余数,上次出现时的cnt数组快照)
c
#include <iostream>
#include <vector>
using namespace std;
const int MOD = 998244353;
const int INV_TWO = (MOD + 1) / 2; // 2 在模 MOD 意义下的逆元 (499122177)
void solve() {
int n;
cin >> n;
vector<int> a(n);
for (int i = 0; i < n; ++i) {
cin >> a[i];
}
// 预处理 3 的幂次数组
vector<int> pow3(n + 1, 1);
for (int i = 1; i <= n; ++i) {
pow3[i] = 1LL * pow3[i - 1] * 3 % MOD;
}
// count[r]: 到目前为止,前缀余数 r 出现的总次数(初始 S_0 = 0 算 1 次)
int count[3] = {1, 0, 0};
// last_count[s][m]: 上一次出现余数 s 的瞬间,余数 m 的历史出现次数
int last_count[3][3] = {0};
int cur_sum = 0; // 当前前缀和模 3
long long ans = 0;
for (int r = 0; r < n; ++r) {
cur_sum = (cur_sum + a[r]) % 3;
// ------------------ 1. 统计余数差为 1 的情况 ------------------
int s1 = (cur_sum + 2) % 3; // 所需左端点余数 s1
int cnt1 = count[s1]; // 符合条件的左端点个数 x
if (cnt1 > 0) {
// 所有左端点 3^z 的等比数列和: (3^x - 1) / 2
long long geom1 = 1LL * (pow3[cnt1] - 1 + MOD) * INV_TWO % MOD;
ans = (ans + geom1) % MOD;
}
// ------------------ 2. 统计余数差为 2 的情况 ------------------
int s2 = (cur_sum + 1) % 3; // 所需左端点余数 s2
int m2 = (s2 + 1) % 3; // 中转点余数 m2
int cnt2 = count[s2]; // 符合条件的左端点个数 x
if (cnt2 > 0) {
// 所有左端点 3^z 的等比数列和
long long geom2 = 1LL * (pow3[cnt2] - 1 + MOD) * INV_TWO % MOD;
// 下标统一:m2 当前总次数 - 上一次出现 s2 时 m2 的历史次数
int c = count[m2] - last_count[s2][m2];
// 计算公共系数 (1 + 3^c)
long long factor = (pow3[c] + 1) % MOD;
ans = (ans + geom2 * factor) % MOD;
}
// ------------------ 3. 更新历史快照与频次 ------------------
// 当遇到当前余数 cur_sum 时,存下此时所有余数 m 的快照
for (int m = 0; m < 3; ++m) {
last_count[cur_sum][m] = count[m];
}
// 累加当前余数的频次
count[cur_sum]++;
}
cout << ans << "\n";
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int t;
if (cin >> t) {
while (t--) {
solve();
}
}
return 0;
}
1012
二分图
给一个可以黑白染色的图,问k元环的个数,k是质数。
可以黑白染色意味着二分图,二分图只有偶环。由于k是质数,只有k=2时才是偶环,其他情况k都是奇数,k元环都不存在。
对于k=2,实际上只有重边能组成,一个位置有xxx个重边,能构成x(x−1)/2x(x-1)/2x(x−1)/2个2元环。统计重边即可。