补题链接 https://hydro.ac/d/ssdut/training/676a284fb30ce767f1b62844 。
这场的定位是比赛 + 教学,希望大家好好补题,这次的题目里面涉及到很多基础知识和一些常用的思考方法,还算是一套好题。
AC 难度估计:
- Easy : 签到题、旅行
- Normal : 静水监狱、简单的平方串
- Medium : 魔杖、回忆、三元环计数
- Hard : 地牢探索、感染的圣巢
这个难度不是根据在场选手的分数进行估计的。比如,Normal 类的两题的得分率不如 Medium 的几题,但 Normal 类的题在计算几何和字符串中属于完全的基础题,且满分难度小于 Medium 的几题。
A.签到题
要通过交换将一个排列变得有序。用样例 1 1 1 解释一下里面的交换结构
一个有 n n n 个数字的环需要 n − 1 n - 1 n−1 次交换才可以完全复位,因此我们只需要把所有环跑一遍就行了,时间复杂度 O ( n ) O(n) O(n) 。
#include<bits/stdc++.h>
using namespace std;
int a[1000006], vis[1000006];
int main() {
int n, ans = 0; cin >> n;
for(int i = 1; i <= n; i++) cin >> a[i];
for(int i = 1; i <= n; i++) {
if(vis[i]) continue;
for(int j = i; !vis[j]; j = a[j]) ans++, vis[j] = 1;
ans--;
}
cout << ans << endl;
return 0;
}
还有一个朴素的想法是,找到一个位置错误的元素,将其交换到正确位置,一直这样操作下去,这种思路有一些人是这么写的:发现第
x
x
x 号位不是
x
x
x 就去找
x
x
x ,而找
x
x
x 的时间复杂度是
O
(
n
)
O(n)
O(n) 的,这样整个程序复杂度就来到
O
(
n
2
)
O(n^2)
O(n2) 。但事实上,当
x
x
x 位是
y
y
y 时,
y
y
y 就是一个位置错误的元素,所以直接把
y
y
y 放到正确的位置即可。下面的写法中,有一些同学把 while
写成了 if
,导致答案错误。
#include<bits/stdc++.h>
using namespace std;
int a[1000006];
int main() {
int n, ans = 0; cin >> n;
for(int i = 1; i <= n; i++) cin >> a[i];
for(int i = 1; i <= n; i++) {
while(a[i] != i) swap(a[i], a[a[i]]), ans++;
}
cout << ans << endl;
return 0;
}
E.旅行
这是一道构造题,这里给出一种可行的构造方案:
两个点之间都有两条边,即一个来回。我们直接假设从编号小的点再到编号大的点再回来,但是在回来之前要把编号大的那个点连接的编号更大的点都跑完。从 1 1 1 号点分别连接其它点,在 x x x 号点返回 1 1 1 号点前,把 x x x 连接的边跑完即可。注意不要重复跑同一条边了。
#include<bits/stdc++.h>
using namespace std;
int main() {
int n; cin >> n;
cout << 1 << ' ';
for(int i = 2; i <= n; i++) {
for(int j = i + 1; j <= n; j++) {
cout << i << " " << j << " ";
}
cout << i << " " << 1 << " ";
}
return 0;
}
有同学表示自己搜索也能过,应该是搜索方式与有向完全图正好契合,只要搜一次就能出结果,完全不用回溯。
C.静水监狱
除去解微分方程,就是计算几何板子题。之前新生赛的计算几何也是我出的,那时候考虑到大家还没有学线性代数,只出了一个在圆上二分的题目。这一次要让大家认识到在计算几何中线代的地位了——准确地说,是向量点乘和叉乘的地位。
我的队里一般我写计算几何,个人提供几点计算几何经验,仅供大家参考;
- 一般题目给的点都是整点,那么程序就要尽量在整型上做计算,尽量不要碰浮点数,或者在最后再碰浮点数,否则精度会不可控地丢失。出题人只要卡一下,浮点数就要完蛋。
- 求一个东西的时候,首先思考怎么用点乘和叉乘做,这是保持整型最好的方法。实在不行,再上正弦定理余弦定理极角公式之类的。
- 对于一根直线,表示它的最好的式子是一般式 A x + B y + C = 0 Ax + By + C = 0 Ax+By+C=0 和参数式 x = x 0 + a t , y = y 0 + b t x = x_0 + at, y = y_0 + bt x=x0+at,y=y0+bt 。我喜欢用参数式,方便且契合整型要求。可以考虑将参数式中的 a a a 和 b b b 除以它们的最大公因数(若有一个为 0 0 0,则另一个直接设成 1 1 1 ),这样的话, t t t 是整数就是 ( x , y ) (x, y) (x,y) 是整点的充分必要条件了。其它类型的直线方程处理与坐标轴平行的线会很麻烦。
- 整型不需要关注精度,但是尤其需要关注数据范围。一定要评估你的写法产生的最大值,几乎所有题目都要开
long long
,有时甚至需要开__int128_t
。
回到这道题。如果一个点在凸多边形内部,只需要算出点到每一条边所在的直线的距离,然后取最小值,这就是点到多边形边缘的最近距离(这是正确的,我就不在这里证明正确性了)。推荐使用的方法:求点和边总共三个点围成的三角形的面积,再除以边的长度再乘 2 2 2。三角形面积怎么求?两个向量的叉乘的绝对值除以 2 2 2 即可。
下面的问题,怎么判断点是否在凸多边形内?这道题的凸多边形是顺时针给出的,所以对每一条边的向量来说,左边就是多边形外,右边就是多边形内,多边形实际上是所有向量的右半平面的交集。那只要点在所有向量的右边,就在形内,否则在形外。怎么判断左右?令边向量为 A B → \overrightarrow{AB} AB ,询问点为 C C C ,那么 A B → × A C → \overrightarrow{AB} \times \overrightarrow{AC} AB×AC 的符号就对应 C C C 在 A B → \overrightarrow{AB} AB 的左还是右。由于有题目给顺时针多边形,有的给逆时针,具体正负与左右的对照关系需要仔细思考。强调一下,这种判断方法只适用于凸多边形。
最后一步解微分方程。已经算出了询问点到多边形边缘的最近距离,又由于与墙壁的距离与瞬时速度有关,逃跑路线就是最短线段(不在此做证明)。为了计算方便(也为了少打一点公式),反向思考:从监狱墙壁跑到询问点。当位移为
x
x
x 时,速度
v
=
v
0
+
k
x
v = v_0 + kx
v=v0+kx 。不管位移与时间的函数是什么,肯定有这么一个函数,所以一定有一个函数
f
(
t
)
=
x
f(t) = x
f(t)=x ,那么
f
′
(
t
)
=
v
f'(t) = v
f′(t)=v ,现在有表达式
f
′
(
t
)
=
v
0
+
k
f
(
t
)
f'(t) = v0 + kf(t)
f′(t)=v0+kf(t)
解微分方程得
f
(
t
)
=
C
e
k
t
−
v
0
k
\displaystyle f(t) = Ce^{kt} - \frac{v_0}{k}
f(t)=Cekt−kv0
当
t
=
0
t = 0
t=0 时,显然
x
=
0
x = 0
x=0 ,将这对参数代入得到
C
=
v
0
k
C = \frac{v_0}{k}
C=kv0,于是得到特解
f
(
t
)
=
v
0
k
(
e
k
t
−
1
)
\displaystyle f(t) = \frac{v_0}{k}(e^{kt} - 1)
f(t)=kv0(ekt−1)
令
a
a
a 表示我们之前求得的距离,令
f
(
t
)
=
a
f(t) = a
f(t)=a,解得
t
=
ln
(
a
k
+
v
0
)
−
ln
v
0
k
\displaystyle t = \frac{\ln(ak + v_0) - \ln v_0}{k}
t=kln(ak+v0)−lnv0
不反向思考也行,也能求得相同的答案,就是过程稍微难算一点。
#include<bits/stdc++.h>
using namespace std;
typedef long long ll;
struct pii {
ll x, y;
pii(ll x = 0, ll y = 0) : x(x), y(y) {}
pii(pii a, pii b) : x(b.x - a.x), y(b.y - a.y) {}
} point[1010], edge[1010];
ll dot(pii a, pii b) {return a.x * b.x + a.y * b.y;}
ll cross(pii a, pii b) {return a.x * b.y - a.y * b.x;}
double len[1010];
int main() {
int n; cin >> n;
for(int i = 0; i < n; i++)
cin >> point[i].x >> point[i].y;
for(int i = 0; i < n; i++)
edge[i] = pii(point[i], point[(i + 1) % n]);
for(int i = 0; i < n; i++)
len[i] = sqrt(dot(edge[i], edge[i]));
int T, v0, k; cin >> T >> v0 >> k;
while(T--) {
pii q; cin >> q.x >> q.y;
double dis = 1e9;
for(int i = 0; i < n; i++) {
if(cross(edge[i], pii(point[i], q)) >= 0) {
dis = -1; break;
}
pii t1 = pii(q, point[(i + 1) % n]);
pii t2 = pii(q, point[i]);
dis = min(dis, abs(cross(t1, t2) / len[i]));
}
if(dis < 0) puts("-1");
else if(k == 0) printf("%.12lf\n", dis / v0);
else printf("%.12lf\n", (log(dis * k + v0) - log(v0)) / k);
}
return 0;
}
H.简单的平方串
当 ∣ R ∣ ≥ ∣ S ∣ |R| \ge |S| ∣R∣≥∣S∣ 时, ∣ R ∣ − ∣ S ∣ |R|-|S| ∣R∣−∣S∣ 是偶数, R R R 的种类有 2 6 ∣ R ∣ − ∣ S ∣ 2 26^{\frac{|R|-|S|}{2}} 262∣R∣−∣S∣ 种。由于 x x x 的和没有限制,所以本题需要预处理 ∑ i = 0 5 × 1 0 6 2 6 i \displaystyle \sum_{i = 0}^{5 \times 10^6} 26^i i=0∑5×10626i 或者使用快速幂 + 等比数列求和公式。
下面解决 ∣ R ∣ < ∣ S ∣ |R| < |S| ∣R∣<∣S∣ 的问题。发现需要求的实际上是“有多少长度小于等于 ∣ S ∣ 2 \frac{|S|}{2} 2∣S∣ 且长度大于等于 ∣ S ∣ − x 2 \frac{|S| - x}{2} 2∣S∣−x 的 S S S 的公共前后缀”,可以使用哈希,也可以使用 KMP 算法( KMP 使用的 pmt 数组就是最长公共前后缀长度,只要不断地遍历 i = pmt[i] 就可以统计出来)。
KMP
#include<bits/stdc++.h>
using namespace std;
const int mod = 998244353;
const int N = 2e6 + 10;
const int M = 5e6 + 10;
int p26[M], pmt[N];
int main() {
ios::sync_with_stdio(0);
cin.tie(0), cout.tie(0);
p26[0] = 1;
for(int i = 1; i < M; i++)
p26[i] = (26LL * p26[i - 1] + 1) % mod;
int T; cin >> T;
while(T--) {
string s; int x, ans = 0;
cin >> s >> x;
if(x >= s.length())
ans = p26[(x - s.length()) / 2];
for(int i = 1, j = 0; i < s.length(); i++) {
while(j && s[i] != s[j]) j = pmt[j];
j += (s[j] == s[i]);
pmt[i + 1] = j;
}
for(int i = pmt[s.length()]; i; i = pmt[i]) {
if(i * 2 + x < s.length()) break;
if(i <= s.length() / 2) ans++;
}
ans %= mod;
cout << ans << '\n';
}
return 0;
}
哈希
#include<bits/stdc++.h>
using namespace std;
typedef long long ll;
const int mod = 998244353;
const int N = 2e6 + 10;
const int M = 5e6 + 10;
int p26[M];
struct pii {
ll x, y;
pii(ll x = 0, ll y = 0) : x(x), y(y) {}
} ha[N], p[N];
pii operator + (pii a, pii b) {return pii((a.x + b.x) % mod, (a.y + b.y) % mod);}
pii operator + (pii a, int b) {return pii((a.x + b) % mod, (a.y + b) % mod);}
pii operator * (pii a, pii b) {return pii((a.x * b.x) % mod, (a.y * b.y) % mod);}
pii operator - (pii a, pii b) {return pii((a.x - b.x + mod) % mod, (a.y - b.y + mod) % mod);}
bool operator == (pii a, pii b) {return a.x == b.x && a.y == b.y;}
const pii base(131, 13331);
pii gethash(int L ,int R) {
return ha[R] - ha[L - 1] * p[R - L + 1];
}
int main() {
ios::sync_with_stdio(0);
cin.tie(0), cout.tie(0);
p26[0] = 1; p[0] = {1, 1};
for(int i = 1; i < M; i++)
p26[i] = (26LL * p26[i - 1] + 1) % mod;
for(int i = 1; i < N; i++)
p[i] = p[i - 1] * base;
int T; cin >> T;
while(T--) {
string s; int x, ans = 0;
cin >> s >> x;
for(int i = 0; i < s.length(); i++)
ha[i + 1] = ha[i] * base + s[i];
for(int i = (s.length() & 1); i < s.length() && i <= x; i += 2) {
int len = i + s.length();
len = s.length() - len / 2;
if(gethash(1, len) == gethash(s.length() - len + 1, s.length())) ans++;
}
if(x >= s.length()) ans = (ans + p26[(x - s.length()) / 2]) % mod;
cout << ans << '\n';
}
return 0;
}
B.魔杖
个人觉得是很不错的 DP 题目,完全想清楚是有一定难度的,现场选手或许能过,但大多应该不知道自己为什么能过。这道题的几步优化需要充分发掘这道题的 DP 转移的单调性,我认为将这几步一步一步想通会很有帮助。
- 最朴素的 DP 思路就是 d p [ i ] [ j ] = min k = 1 m ( d p [ i − 1 ] [ k ] + ∣ A [ i ] [ j ] − A [ i − 1 ] [ k ] ∣ ) dp[i][j] = \min_{k = 1}^{m} \Big( dp[i - 1][k] + \Big\lvert A[i][j] - A[i - 1][k] \Big\rvert \Big) dp[i][j]=mink=1m(dp[i−1][k]+ A[i][j]−A[i−1][k] ) ,这样复杂度是 O ( n m 2 ) O(nm^2) O(nm2) 的。
- 绝对值有些难处理了,可不可以去掉绝对值?那就假设 d p [ i ] [ j ] dp[i][j] dp[i][j] 一定从小于等于 A [ i ] [ j ] A[i][j] A[i][j] 的 A [ i − 1 ] [ k ] A[i - 1][k] A[i−1][k] 转移而来,再假设 d p [ i ] [ j ] dp[i][j] dp[i][j] 一定从大于等于 A [ i ] [ j ] A[i][j] A[i][j] 的 A [ i − 1 ] [ k ] A[i - 1][k] A[i−1][k] 转移而来。不失一般性地,我们只讨论第一个假设。 d p [ i ] [ j ] = min k = 1 m ( d p [ i − 1 ] [ k ] + A [ i ] [ j ] − A [ i − 1 ] [ k ] ) dp[i][j] = \min_{k = 1}^{m} (dp[i - 1][k] + A[i][j] - A[i - 1][k]) dp[i][j]=mink=1m(dp[i−1][k]+A[i][j]−A[i−1][k]) ,我们找到最小的 d p [ i − 1 ] [ k ] − A [ i − 1 ] [ k ] dp[i - 1][k] - A[i - 1][k] dp[i−1][k]−A[i−1][k] 即可。不算排序,此时 DP 复杂度已经是 O ( n m ) O(nm) O(nm) 的了。
#include<bits/stdc++.h>
using namespace std;
typedef long long ll;
ll a[102][20002];
ll dp[102][20002];
const ll inf = 1e18;
int main() {
ios::sync_with_stdio(0);
cin.tie(0), cout.tie(0);
memset(dp, 0x3f, sizeof(dp));
int n, m; cin >> n >> m;
for(int i = 1; i <= n; i++) {
for(int j = 1; j <= m; j++) cin >> a[i][j];
sort(a[i] + 1, a [i] + m + 1);
}
for(int i = 1; i <= m; i++) dp[1][i] = 0;
for(int i = 2; i <= n; i++) {
for(int j = 1, p = 0, k = 0; j <= m; j++) {
while(k < m && a[i - 1][k + 1] <= a[i][j]) {
k++;
if(dp[i - 1][k] - a[i - 1][k] < dp[i - 1][p] - a[i - 1][p]) p = k;
}
dp[i][j] = min(dp[i][j], dp[i - 1][p] + a[i][j] - a[i - 1][p]);
}
for(int j = m, p = m + 1, k = m + 1; j >= 1; j--) {
while(k > 1 && a[i - 1][k - 1] >= a[i][j]) {
k--;
if(dp[i - 1][k] + a[i - 1][k] < dp[i - 1][p] + a[i - 1][p]) p = k;
}
dp[i][j] = min(dp[i][j], dp[i - 1][p] + a[i - 1][p] - a[i][j]);
}
}
ll ans = inf;
for(int i = 1; i <= m; i++) ans = min(ans, dp[n][i]);
cout << ans << endl;
return 0;
}
- 还是只假设 d p [ i ] [ j ] dp[i][j] dp[i][j] 一定从小于等于 A [ i ] [ j ] A[i][j] A[i][j] 的 A [ i − 1 ] [ k ] A[i - 1][k] A[i−1][k] 转移而来。当 A [ i ] [ j ] A[i][j] A[i][j] 和 A [ i − 1 ] [ k ] A[i-1][k] A[i−1][k] 相差较大的时候真的会是最佳转移吗?这时候试试构造出 A [ i − 2 ] [ u ] A[i - 2][u] A[i−2][u] 到 A [ i − 1 ] [ v ] A[i - 1][v] A[i−1][v] 再到 A [ i ] [ w ] A[i][w] A[i][w] 的转移, A [ i − 1 ] [ v ] A[i - 1][v] A[i−1][v] 到 A [ i ] [ w ] A[i][w] A[i][w] 不是最近但是转移最佳的例子。你会发现,你构造不出来,而且应该画出来了这样的图。
假设实线是非最近的最优转移,发现更近的转移(用虚线表示)要么更好,要么不差,所以最近转移就是最佳转移。因此,只需要从分别从大于等于和小于等于 A [ i ] [ j ] A[i][j] A[i][j] 的最近 A [ i − 1 ] [ k ] A[i-1][k] A[i−1][k] 转移即可。
#include<bits/stdc++.h>
using namespace std;
typedef long long ll;
ll a[102][20002];
ll dp[102][20002];
const ll inf = 1e18;
int main() {
ios::sync_with_stdio(0);
cin.tie(0), cout.tie(0);
memset(dp, 0x3f, sizeof(dp));
int n, m; cin >> n >> m;
for(int i = 1; i <= n; i++) {
for(int j = 1; j <= m; j++) cin >> a[i][j];
sort(a[i] + 1, a [i] + m + 1);
}
for(int i = 1; i <= m; i++) dp[1][i] = 0;
for(int i = 2; i <= n; i++) {
for(int j = 1, k = 0; j <= m; j++) {
while(k < m && a[i - 1][k + 1] <= a[i][j]) k++;
if(k >= 1) dp[i][j] = min(dp[i][j], dp[i - 1][k] + a[i][j] - a[i - 1][k]);
if(k + 1 <= m) dp[i][j] = min(dp[i][j], dp[i - 1][k + 1] + a[i - 1][k + 1] - a[i][j]);
}
}
ll ans = inf;
for(int i = 1; i <= m; i++) ans = min(ans, dp[n][i]);
cout << ans << endl;
return 0;
}
G.回忆
对区间 a a a ,我们令 w ( a ) = L a + R a w(a) = L_a + R_a w(a)=La+Ra , l e n ( a ) = R a − L a len(a) = R_a - L_a len(a)=Ra−La 。
对两个区间交集不为空的区间 a a a, b b b ,不失一般性,我们设 L a ≤ L b L_a \le L_b La≤Lb 。先不管左端点相同的情况,对 L a < L b L_a < L_b La<Lb ,若 b b b 是 a a a 的子集,并减交等于 − l e n ( b ) + l e n ( a ) -len(b) + len(a) −len(b)+len(a) ,否则并减交等于 w ( b ) − w ( a ) w(b) - w(a) w(b)−w(a) 。你会发现,很神奇, L a = L b L_a = L_b La=Lb 时,上面两个式子互为相反数,你只需要取较大值即可。
以上是申老师的思路,他是用优先队列维护的最值。
#include<bits/stdc++.h>
#define endl '\n'
#define fi first
#define se second
#define ll long long
using namespace std;
typedef vector<ll> vint;
typedef pair<ll, ll> pii;
int main() {
ios::sync_with_stdio(false);
cin.tie(0);
ll n, ans = 0; cin >> n;
vint l(n), r(n), ind(n);
for(int i = 0; i < n; i++) cin >> l[i] >> r[i];
iota(ind.begin(), ind.end(), 0);
sort(ind.begin(), ind.end(), [&](int i, int j) {
return l[i] < l[j];
});
priority_queue<pii> p1, p2;
for(int i = 0; i < n; i++) {
int t = ind[i];
p1.push({-l[t] - r[t], t});
p2.push({r[t] - l[t], t});
while(!p1.empty() && r[p1.top().second] < l[t]) p1.pop();
while(!p2.empty() && r[p2.top().second] < l[t]) p2.pop();
ans = max(ans, l[t] + r[t] + p1.top().fi);
ans = max(ans, l[t] - r[t] + p2.top().fi);
}
cout << ans << endl;
return 0;
}
我自己的的思路略有不同。对于两个交集不为空的区间 a , b a, b a,b ,假设 R b ≥ R a R_b \ge R_a Rb≥Ra ,则并减交等于 max ( w ( b ) − w ( a ) , l e n ( b ) − l e n ( a ) ) \max \Big(w(b)-w(a), len(b)-len(a)\Big) max(w(b)−w(a),len(b)−len(a)) 。思考过程也是分情况讨论包含与普通相交,然后发现用另一个式子不会使答案更大导致出错。我是用 ST 表维护的,写起来稍微烦了一些。
#include<bits/stdc++.h>
using namespace std;
const int N = 2e5 + 10;
struct pii {
int L, R;
pii(int L = 0, int R = 0) : L(L), R(R) {}
int w() {return L + R;}
int len() {return R - L;}
} p1[N], p2[N];
int rmq1[N][20], Log2[N], rmq2[N][20];
int n, ans;
int find(int x) {
int L = 0, R = n, mid;
while(L < R) {
mid = (L + R + 1) / 2;
if(p2[mid].R > x) R = mid - 1;
else L = mid;
}
return L;
}
int main() {
ios::sync_with_stdio(0);
cin.tie(0), cout.tie(0);
for(int i = 2; i < N; i++)
Log2[i] = Log2[i / 2] + 1;
cin >> n;
for(int i = 1; i <= n; i++) {
cin >> p1[i].L >> p1[i].R;
p2[i] = p1[i];
}
sort(p2 + 1, p2 + n + 1, [](const pii A, const pii B){
return A.R < B.R;
});
for(int i = 1; i <= n; i++) rmq1[i][0] = p2[i].w();
for(int i = 1; i <= 18; i++) {
for(int j = 1; j + (1 << i) - 1 <= n; j++) {
rmq1[j][i] = min(rmq1[j][i - 1], rmq1[j + (1 << (i - 1))][i - 1]);
}
}
for(int i = 1; i <= n; i++) rmq2[i][0] = p2[i].len();
for(int i = 1; i <= 18; i++) {
for(int j = 1; j + (1 << i) - 1 <= n; j++) {
rmq2[j][i] = min(rmq2[j][i - 1], rmq2[j + (1 << (i - 1))][i - 1]);
}
}
for(int i = 1; i <= n; i++) {
int L = find(p1[i].L - 1) + 1;
int R = find(p1[i].R);
int s = Log2[R - L + 1];
ans = max(ans, p1[i].w() - min(rmq1[L][s], rmq1[R - (1 << s) + 1][s]));
ans = max(ans, p1[i].len() - min(rmq2[L][s], rmq2[R - (1 << s) + 1][s]));
}
cout << ans << endl;
return 0;
}
还有更多的写法,比如线段树维护、扫描线算法等。衣老师在他的题解里就写了扫描线做法 https://blog.csdn.net/Code92007/article/details/140088343 。
I.三元环计数
O ( n 3 ) O(n^3) O(n3) 的算法很好写(但是注意到有同学写了 O ( n 4 ) O(n^4) O(n4) 的算法),加一点优化就可以 O ( n 3 6 ) O(\frac{n^3}{6}) O(6n3) ,拿到 70 70 70 分。
这一题还是要发掘性质。画出合法的三元环与不合法的三元环,发现不合法的三元环的三个点的出度减入度一定是 0 , 2 , − 2 0, 2, -2 0,2,−2 。也就是说,如果我们有两条边 x → y x \rightarrow y x→y 和 x → z x \rightarrow z x→z ,那么只要 y , z y,z y,z 间有一条边(不论方向),这里就有一个不合法的三元环。根据题目描述,任意两点之间一定有一条边,因此我们只用对每个点计数出度 m m m(入度也行),然后组合数 C m 2 C_m^2 Cm2 就可以求出不合法的三元环数,然后用总数减去不合法数量即可。
#include<bits/stdc++.h>
using namespace std;
int main() {
long long n, ans = 0; cin >> n;
ans = n * (n - 1) * (n - 2) / 6;
for(int i = 0; i < n; i++) {
string s; cin >> s;
int temp = 0;
for(char c : s) temp += (c & 1);
ans -= temp * (temp - 1) / 2;
}
cout << ans << endl;
return 0;
}
还有一种方法是
O
(
n
3
w
)
O(\frac{n^3}{w})
O(wn3) 的,需要用到 std::bitset
。出题人赵老师说这题不想卡掉这种做法,顺便也让大家见识一下 std::bitset
。
#include<bits/stdc++.h>
using namespace std;
const int N = 4003;
bitset<N> pre[N], nxt[N];
char s[N];
int main() {
long long n, ans = 0;
scanf("%lld", &n);
for(int i = 0; i < n; i++)
pre[i].reset(), nxt[i].reset();
for(int i = 0; i < n; i++) {
scanf("%s", s);
for(int j = 0; j < n; j++) {
if(s[j] == '1') nxt[i].set(j), pre[j].set(i);
}
}
for(int i = 0; i < n; i++) {
for(int j = 0; j < n; j++) {
if(i == j || !nxt[i][j]) continue;
ans += (nxt[j] & pre[i]).count();
}
}
printf("%lld\n", ans / 3);
return 0;
}
D.地牢探索
若所有具有 n n n 个点的二叉树出现的概率均等,二叉树的儿子期望是多少?好吧,这题其实是一个经典题目,你可以看看 洛谷 P3978 [TJOI2015] 概率论 。
一个非常直白的思路是:算出所有具有 n n n 个点的二叉树的儿子的总数,除以具有 n n n 个点的不同二叉树的数量。
先看看分母部分,具有 n n n 个点的不同二叉树的数量是多少?这是一个非常经典的问题,答案是卡特兰数 H n H_n Hn 。 n n n 个点有多少种二叉树可以这样求得:一个点左边挂一个大小为 i i i 的左子树,再挂一个大小为 n − 1 − i n - 1 - i n−1−i 的右子树,如此递推求得。而卡特兰数的递推形式正是这样:
H n = { ∑ i = 1 n H i − 1 H n − i n ≥ 2 , n ∈ N + 1 n = 0 , 1 H_n = \begin{cases} \sum_{i = 1}^{n} H_{i - 1}H_{n - i} & n \ge 2, n \in \mathbb{N^+} \\ 1 & n = 0, 1 \end{cases} Hn={∑i=1nHi−1Hn−i1n≥2,n∈N+n=0,1
上述递推关系的解为: H n = ( 2 n n ) n + 1 = ( 2 n ) ! n ! ( n + 1 ) ! ( n ∈ N ) H_n = \frac{\binom{2n}{n}}{n+1} = \frac{(2n)!}{n!(n+1)!} (n \in \mathbb{N}) Hn=n+1(n2n)=n!(n+1)!(2n)!(n∈N)
下面看看分子部分, n n n 个点的二叉树的本质不同的儿子数量。一个 n n n 点的二叉树可以看作是一个 n − 1 n - 1 n−1 点的二叉树加了一个叶子节点得到的。让我们看看 2 2 2 个点的二叉树 → \rightarrow → 3 3 3 个点的二叉树是怎么变的。每个 2 2 2 点二叉树都拥有 3 3 3 个可以再挂一个叶子的地方,分别挂上后,可以得到:
经过思考你会发现,所有 n n n 点的二叉树的可以再挂叶子的地方,与所有 n + 1 n + 1 n+1 点二叉树的所有叶子一一对应。那 n n n 点的二叉树有多少空的挂载点呢?每个点都有 2 2 2 个挂载点,但总共 2 n 2n 2n 个挂载点被已经存在的 n − 1 n - 1 n−1 条边占用,所以还剩下 n + 1 n + 1 n+1 个挂载点。综上,分子部分的值应当是 n H n − 1 = n ( 2 n − 2 ) ! ( n − 1 ) ! n ! nH_{n-1} = n\frac{(2n-2)!}{(n-1)!n!} nHn−1=n(n−1)!n!(2n−2)! 。
最终答案是 n ( 2 n − 2 ) ! ( n − 1 ) ! n ! ( 2 n ) ! n ! ( n + 1 ) ! = n ( n + 1 ) 2 ( 2 n − 1 ) \frac{n\frac{(2n-2)!}{(n-1)!n!}}{\frac{(2n)!}{n!(n+1)!}} = \frac{n(n + 1)}{2(2n - 1)} n!(n+1)!(2n)!n(n−1)!n!(2n−2)!=2(2n−1)n(n+1) 。
#include<bits/stdc++.h>
using namespace std;
typedef long long ll;
const ll mod = 2148473647;
ll inv(ll x) {
ll ret = 1, p = mod - 2;
while(p) {
if(p & 1) ret = ret * x % mod;
p >>= 1; x = x * x % mod;
}
return ret;
}
int main() {
ll n; cin >> n;
cout << n * (n + 1) % mod * inv(2 * (2 * n - 1) % mod) % mod << endl;
return 0;
}
F.感染的圣巢
实际上要求的就是白色树的直径。
关于树的直径,有一些很有用的性质:
- 直径两端点一定是两个叶子节点
- 若树上所有边边权均为正,则树的所有直径中点重合
- 有两颗分离的树
A
,
B
A, B
A,B,
(
u
,
v
)
(u, v)
(u,v) 是树
A
A
A 的一条直径,
(
x
,
y
)
(x, y)
(x,y) 是树
B
B
B 的一条直径,在
A
,
B
A, B
A,B 间任意连一条边,
(
u
,
v
,
x
,
y
)
(u, v, x, y)
(u,v,x,y) 两两组成的
6
6
6 条路径中必有至少一条是新树的一条直径。
- 特别地,若树 B B B 只有一个点 w w w , ( u , v ) , ( u , w ) , ( v , w ) (u, v), (u, w), (v, w) (u,v),(u,w),(v,w) 中至少有一个是新树的一条直径。
两树相连/树上加点 维护直径的题目我推荐一条:https://codeforces.com/gym/105143/problem/E,来自 2024 ICPC 武汉邀请赛。2024湖北省赛还有一道 https://codeforces.com/gym/105139/problem/I,这题就有点难了。
这道题的思路就是反过来做——从最后把点一个一个加上去,加点的时候维护直径即可。但这题还有一个问题:树上总共有多达 2 60 − 1 2^{60}-1 260−1 个点,根本不可能存储下来,怎么办呢?方案是只存储 n m nm nm 个点,也就是 m m m 次询问,都将 1 1 1 号点到 x x x 号点经过的点存下来。其余部分,在实际存下来的点里标注一下左儿子/右儿子是一颗全白的树即可。由于在这种处理方式下,一个点不再只是一个点,而是一个点加上全白子树,因此 d i s ( u , v ) dis(u, v) dis(u,v) 函数的含义变成了 “从 u u u 的全白子树(如果有的话)到 v v v 的全白子树(如果有的话)” 的最长路径长度。添加一个点 w w w 之后,除了检查 d i s ( u , w ) dis(u, w) dis(u,w) 和 d i s ( v , w ) dis(v, w) dis(v,w),还要记得检查直径长度是否能被更新为 d i s ( w , w ) dis(w, w) dis(w,w) 。
这里感谢衣老师提供的 numdis()
函数写法,他的写法比我的快多了,我之前的
O
(
log
n
)
O(\log n)
O(logn) 写法常数太大,导致比
O
(
n
)
O(n)
O(n) 的还慢。
顺便,这题带有一些卡常的成分,因此在赛后练习时间开放成 2 2 2 秒。毕竟大家将来要打 ACM 赛制的比赛,题目没有部分分,因此一般也不会卡常。
#include<bits/stdc++.h>
using namespace std;
typedef long long ll;
const int N = 2e5 + 5;
const int M = 60 * N;
ll q[N], num[M];
int cnt, ans[N], n, m;
int son[M][2], dep[M], t[M], fa[M];
char soncnt[M];
inline ll rd() {
ll ret = 0; char ch = getchar();
for(; isdigit(ch); ch = getchar())
ret = (ret << 1) + (ret << 3) + (ch ^ 48);
return ret;
}
inline void insert(ll x, int time) {
int u = 1, flag = 0;
for(int i = 59; i >= 0; i--) {
int temp = !!(x & (1LL << i));
if(flag) {
if(!son[u][temp]) {
son[u][temp] = ++cnt;
dep[cnt] = dep[u] + 1;
num[cnt] = num[u] * 2 + temp;
t[cnt] = m;
fa[cnt] = u;
}
u = son[u][temp];
} else if(temp) flag = 1;
}
t[u] = min(t[u], time);
}
inline int lg(ll x){
return 63 - __builtin_clzll(x);
}
inline int numdis(ll u, ll v) {
if(u > v) swap(u, v);
int x = lg(u), y = lg(v);
v >>= (y - x);
if(u == v) return y - x;
for(int i = 32; i > 0; i >>= 1)
if((u ^ v) >> i) u >>= i, v >>= i;
u >>= 1;
int z = lg(u);
return y + x - (z << 1);
}
inline int dis(int u, int v) {
if(u == v) {
switch(soncnt[u]) {
case 0: return (n - dep[u]) << 1;
case 1: if(n != 2) return (n - dep[u] - 1) << 1;
else return max((n - dep[u] - 1) << 1, n - dep[u]);
case 2: return 0;
}
}
int ret = numdis(num[u], num[v]);
if(soncnt[u] < 2) ret += n - dep[u];
if(soncnt[v] < 2) ret += n - dep[v];
return ret;
}
vector<int> vec[N];
int main() {
n = rd(), m = rd();
dep[1] = num[1] = cnt = 1;
for(int i = 1; i < M; i++) t[i] = m;
for(int i = 1; i <= m; i++)
q[i] = rd(), insert(q[i], i - 1);
for(int i = 2; i <= cnt; i++) {
t[i] = min(t[i], t[fa[i]]);
vec[t[i]].push_back(i);
soncnt[i] = !!son[i][0] + !!son[i][1];
}
soncnt[1] = !!son[1][0] + !!son[1][1];
int u = 1, v = 1, d = dis(1, 1), tu = u, tv = v, td = d, temp;
for(int i = m; i > 0; i--) {
for(int w : vec[i]) {
if((temp = dis(w, w)) > td) tu = w, tv = w, td = temp;
if((temp = dis(v, w)) > td) tu = v, tv = w, td = temp;
if((temp = dis(u, w)) > td) tu = u, tv = w, td = temp;
u = tu, v = tv, d = td;
}
ans[i] = d;
}
for(int i = 1; i <= m; i++) {
cout << ans[i];
if(i == m) cout << '\n';
else cout << ' ';
}
return 0;
}
以上是出题人的做法,下面分享验题人侯老师的简单做法:正着做。令 f ( x ) f(x) f(x) 代表以 x x x 点为根的子树中的直径长度, g ( x ) g(x) g(x) 代表 x x x 点的子树中与 x x x 距离最远的白点的距离 + 1 +1 +1 。当我们将 x x x 的子树涂色后,令 f ( x ) = g ( x ) = 0 f(x) = g(x) = 0 f(x)=g(x)=0 。除了 x x x 点外,所有 1 1 1 号点到 x x x 点路径上的点的 f f f 值和 g g g 值都可能受到影响,因此我们需要将它们从下往上依次更新。 f f f 值可能来自两个儿子的 g g g 的和,也可能直接来自儿子的 f f f 值; g g g 值只需要访问儿子的 g g g 值,取大的那个 + 1 +1 +1 即可。
代码使用了两个 unordered_map
来维护
f
f
f 值和
g
g
g 值,若有存储就直接返回,没有存储说明可能没有被更新过,可能还是白的,那就按照白色的算。为什么说“可能”呢?如果某个点的父亲已经被涂上颜色,它肯定也已经涂色,但是可能确实没有被访问过,此时上述逻辑失效,按照白色算会得到错误的答案,因此 upd2root
第一行的逻辑不可或缺:第一个条件用于结束递归,第二个条件用于阻止这种情况下的错误答案向上传递。
#include<bits/stdc++.h>
using namespace std;
typedef long long i64;
int n, m;
unordered_map<i64, int> f, g;
// f : 以某点为根的子树中的最大直径
// g : 某点为根的子树中最远白点距离 + 1
int getf(i64 x, int dep) {
if(x >= (1LL << n)) return 0;
else if(f.count(x)) return f[x];
else return (n - dep) * 2;
}
int getg(i64 x, int dep) {
if(x >= (1LL << n)) return 0;
else if(g.count(x)) return g[x];
else return n - dep + 1;
}
void upd2root(const i64 x, const int dep) {
if(x == 0 || !getg(x, dep)) return;
const i64 lc = x * 2, rc = x * 2 + 1;
int fx = getf(x, dep), gx = getg(x, dep);
// 以 x 为根的子树中的最长直径, 可能经过 x (第一个式子), 也可能不经过 x (后两个式子)
f[x] = max({getg(lc, dep + 1) + getg(rc, dep + 1), getf(lc, dep + 1), getf(rc, dep + 1)});
g[x] = max(getg(lc, dep + 1), getg(rc, dep + 1)) + 1;
// 如果发生了实际的更新, 那影响还可能继续上传, 因此继续向上更新
if(fx != f[x] || gx != g[x]) upd2root(x / 2, dep - 1);
}
int main() {
ios::sync_with_stdio(0);
cin.tie(0), cout.tie(0);
cin >> n >> m;
while(m--) {
i64 x; cin >> x;
f[x] = g[x] = 0;
upd2root(x / 2, 63 - __builtin_clzll(x));
cout << getf(1, 1) << ' ';
}
return 0;
}