2024年大连理工大学(开发区校区)科技文化节程序设计竞赛题解

补题链接 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 n1 次交换才可以完全复位,因此我们只需要把所有环跑一遍就行了,时间复杂度 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)=Cektkv0
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(ekt1)
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| RS 时, ∣ R ∣ − ∣ S ∣ |R|-|S| RS 是偶数, R R R 的种类有 2 6 ∣ R ∣ − ∣ S ∣ 2 26^{\frac{|R|-|S|}{2}} 262RS 种。由于 x x x 的和没有限制,所以本题需要预处理 ∑ i = 0 5 × 1 0 6 2 6 i \displaystyle \sum_{i = 0}^{5 \times 10^6} 26^i i=05×10626i 或者使用快速幂 + 等比数列求和公式。

下面解决 ∣ R ∣ < ∣ S ∣ |R| < |S| R<S 的问题。发现需要求的实际上是“有多少长度小于等于 ∣ S ∣ 2 \frac{|S|}{2} 2S 且长度大于等于 ∣ S ∣ − x 2 \frac{|S| - x}{2} 2Sx 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 转移的单调性,我认为将这几步一步一步想通会很有帮助。

  1. 最朴素的 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[i1][k]+ A[i][j]A[i1][k] ) ,这样复杂度是 O ( n m 2 ) O(nm^2) O(nm2) 的。
  2. 绝对值有些难处理了,可不可以去掉绝对值?那就假设 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[i1][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[i1][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[i1][k]+A[i][j]A[i1][k]) ,我们找到最小的 d p [ i − 1 ] [ k ] − A [ i − 1 ] [ k ] dp[i - 1][k] - A[i - 1][k] dp[i1][k]A[i1][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;
}
  1. 还是只假设 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[i1][k] 转移而来。当 A [ i ] [ j ] A[i][j] A[i][j] A [ i − 1 ] [ k ] A[i-1][k] A[i1][k] 相差较大的时候真的会是最佳转移吗?这时候试试构造出 A [ i − 2 ] [ u ] A[i - 2][u] A[i2][u] A [ i − 1 ] [ v ] A[i - 1][v] A[i1][v] 再到 A [ i ] [ w ] A[i][w] A[i][w] 的转移, A [ i − 1 ] [ v ] A[i - 1][v] A[i1][v] A [ i ] [ w ] A[i][w] A[i][w] 不是最近但是转移最佳的例子。你会发现,你构造不出来,而且应该画出来了这样的图。

魔杖DP转移示意图

假设实线是非最近的最优转移,发现更近的转移(用虚线表示)要么更好,要么不差,所以最近转移就是最佳转移。因此,只需要从分别从大于等于和小于等于 A [ i ] [ j ] A[i][j] A[i][j] 的最近 A [ i − 1 ] [ k ] A[i-1][k] A[i1][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)=RaLa

对两个区间交集不为空的区间 a a a, b b b ,不失一般性,我们设 L a ≤ L b L_a \le L_b LaLb 。先不管左端点相同的情况,对 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 RbRa ,则并减交等于 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 xy x → z x \rightarrow z xz ,那么只要 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 n1i 的右子树,如此递推求得。而卡特兰数的递推形式正是这样:

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=1nHi1Hni1n2,nN+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)!(nN)

下面看看分子部分, n n n 个点的二叉树的本质不同的儿子数量。一个 n n n 点的二叉树可以看作是一个 n − 1 n - 1 n1 点的二叉树加了一个叶子节点得到的。让我们看看 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 n1 条边占用,所以还剩下 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!} nHn1=n(n1)!n!(2n2)!

最终答案是 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(n1)!n!(2n2)!=2(2n1)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 2601 个点,根本不可能存储下来,怎么办呢?方案是只存储 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;
}
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值