CSP-S 2020 编程算法比赛
作者:
| 更新日期:最后一道黑题好难,看了题解才会做
本文首发于公众号:天空的代码世界,微信号:tiankonguse
零、背景
最近我计划研究 CSP-J 与 CSP-S 的比赛题目,之前已经完成了 9 场比赛的题解,今天将分享 2020 年 CSP-S 第二轮编程算法比赛的详细题解。
A: 模拟
B: 位运算
C: DAG 与 嵌套表达式展开
D: 贪心博弈
代码地址: https://github.com/tiankonguse/leetcode-solutions/tree/master/other/CSP-S/
比赛题目分类与题解 |
---|
CSP-J 2024 题解 A:扑克牌 入门 B: 地图探险 普及− C: 小木棍 普及/提高− D: 接龙 提高+/省选− |
CSP-S 2024 题解 A:决斗 普及− B: 超速检测 普及+/提高 C: 染色 提高+/省选− D: 擂台游戏 NOI/NOI+/CTSC |
CSP-J 2023 题解 A:小苹果 普及− B: 公路 普及− C: 一元二次方程 普及/提高− D: 旅游巴士 普及+/提高 |
CSP-S 2023 题解 A:密码锁 普及− B: 消消乐 提高+/省选− C: 结构体 提高+/省选− D: 种树 提高+/省选− |
CSP-J 2022 题解 A:乘方 入门 B: 解密 普及− C: 逻辑表达式 普及+/提高 D: 上升点列 普及/提高− |
CSP-S 2022 题解 A:假期计划 提高+/省选− B: 策略游戏 普及+/提高 C: 星战 省选/NOI− D: 数据传输 省选/NOI− |
CSP-J 2021 题解 A:分糖果 普及− B: 插入排序 普及/提高− C: 网络连接 普及/提高− D: 小熊的果篮 普及+/提高 |
CSP-S 2021 题解 A:廊桥分配 普及+/提高 B: 括号序列 提高+/省选− C: 回文 普及+/提高 D: 交通规划 省选/NOI− |
CSP-J 2020 题解 A:优秀的拆分 入门 B: 直播获奖 普及− C: 表达式 普及+/提高 D: 方格取数 普及+/提高 |
CSP-S 2020 题解 A:儒略日 普及+/提高 B: 动物园 普及/提高− C: 函数调用 提高+/省选− D: 贪吃蛇 NOI/NOI+/CTSC |
一、儒略日
题意:给一个日历规则,公元 1582 年 10 月 4 日之前按儒略历计算年月日,公元 1582 年 10 月 15 日之后按格里高利历计算年月日,中间的天数不存在。
假设公元前 4713 年 1 月 1 日是第 1 天,问给你一个年月日,是第几天。
儒略历:4年闰月一次。
格里高利历:4年闰月一次,100年不闰月,400年闰月。
思路:模拟
1600 年之前的年份,数据量不大,可以预处理每一年1月1日是第几天,储存在数组中。
while (p < Y1582) { // -4712年 ~ 1581年
ll oneDay = D365;
if (p % 4 == 0) {
oneDay++;
}
mp[day] = p; // p 年1月1日,是第 day 天
day += oneDay;
p++;
}
mp[day] = p; // 1582 年 1 月 1 日,是第 day 天
day += D365 - 10; // 有 10 天不存在
p++;
while (p < 1600) { // 1583 年 ~ 1599 年
ll oneDay = D365;
if (p % 400 == 0 || (p % 4 == 0 && p % 100 != 0)) {
oneDay++;
}
mp[day] = p; // p 年1月1日,是第 day 天
day += oneDay;
p++;
}
1600 年之后的,400年一个周期,只需要再预处理 400年每一年1月1日是第几天,储存起来。
offfsetDayY400.reserve(400);
ll offsetDay = 0;
for (int i = 0; i < 400; i++) {
offfsetDayY400.push_back(offsetDay);
offsetDay += D365;
if (i % 400 == 0 || (i % 4 == 0 && i % 100 != 0)) {
offsetDay++;
}
}
查询时,先判断是否在 1600 年之前。
如果在之前,直接二分查找确定具体年份,然后根据是否是闰年,计算是几月几日。
if (r < day) { // 1600 年之内的,需要二分查找
auto it = mp.upper_bound(r);
it--;
auto [itDay, itYear] = *it;
if (itYear < Y1582) {
ll leftDay = r - itDay;
const vector<pair<ll, ll>>& yearDay = (itYear % 4 != 0) ? yearNormal : yearLeap;
auto [month, day] = yearDay[leftDay];
return make_tuple(itYear, month, day);
} else if (itYear == Y1582) {
ll leftDay = r - itDay;
const vector<pair<ll, ll>>& yearDay = year1582;
auto [month, day] = yearDay[leftDay];
return make_tuple(itYear, month, day);
} else {
ll leftDay = r - itDay;
const vector<pair<ll, ll>>& yearDay =
(itYear % 400 == 0 || (itYear % 4 == 0 && itYear % 100 != 0)) ? yearLeap : yearNormal;
auto [month, day] = yearDay[leftDay];
return make_tuple(itYear, month, day);
}
}
如果在之后,根据剩余的天数计算有多少个 400年周期,以及剩余的天数。
之后二分查找,确定在400年具体第几年,然后根据是否是闰年,计算是几月几日。
ll leftDay = r - day;
ll itYear = p;
if (leftDay > Y400) {
ll num = leftDay / Y400;
itYear += num * 400;
leftDay -= num * Y400;
}
auto it = upper_bound(offfsetDayY400.begin(), offfsetDayY400.end(), leftDay);
it--;
ll yearNum = it - offfsetDayY400.begin();
itYear += yearNum;
leftDay -= *it;
const vector<pair<ll, ll>>& yearDay =
(itYear % 400 == 0 || (itYear % 4 == 0 && itYear % 100 != 0)) ? yearLeap : yearNormal;
auto [month, day] = yearDay[leftDay];
return make_tuple(itYear, month, day);
小技巧:平年和闰年天数和日期的关系可以提前预处理好,这样就可以是输入天数得到月份和日期。
const ll normalMonth[12] = {31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31};
const ll leapMonth[12] = {31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31};
for (int m = 0; m < M12; m++) {
for (int d = 1; d <= normalMonth[m]; d++) {
yearNormal.push_back({m + 1, d});
}
}
for (int m = 0; m < M12; m++) {
for (int d = 1; d <= leapMonth[m]; d++) {
yearLeap.push_back({m + 1, d});
}
}
for (int m = 0; m < M12; m++) {
for (int d = 1; d <= normalMonth[m]; d++) {
if (m + 1 == 10 && d == 5) {
d = 15; // 10月5日~10月14日不存在
}
year1582.push_back({m + 1, d});
}
}
二、动物园
题意:给 n 个动物和一个条件列表:如果存在一个动物第 i 位为 1,则购买第 i 位的饲料,否则不购买第 i 位饲料。
根据当前的动物,确定了饲料集合。
问在不改变饲料集合的前提下,还能加入多少个动物。
思路:二进制
题目比较绕,不容易理解。
分析条件列表,可以发现二进制分为三类:
1)位数列表中存在,动物对应的位数为1,则购买该位饲料。
2)位数列表中存在,动物对应的位数为0,不购买该位饲料。
3)位数列表中不存在,动物对应的位数值不做要求,加入这个位数不影响饲料集合。
根据上面的分析,可以确定,新加入的动物不能与第二个规则冲突。
故需要统计,命中第二个规则的位数,剩余的其他位则都是可以存在的。
故这道题可以分三步来做:
第一步:统计当前动物的位数
ull mask = 0;
for (int i = 0; i < n; i++) {
ull x;
scanf("%llu", &x);
mask |= x;
}
第二步:统计第二个规则的位数。
ull umask = 0;
for (int i = 0; i < m; i++) {
int p;
ull q;
scanf("%d%llu", &p, &q);
if ((mask & (1ULL << p)) == 0) {
umask |= (1ULL << p);
}
}
int bit = 0;
for (int i = 0; i < k; i++) {
if (umask & (1ULL << i)) {
bit++;
}
}
第三步:计算可以加入的动物数。
由于数字最大可能为 1<<64
,故这里使用 int128
代替。
__int128_t ans = 1;
for (int i = 0; i < k - bit; i++) {
ans *= 2;
}
ans -= n;
string str = MyToString(ans);
printf("%s\n", str.c_str());
三、函数调用
题意:有一个数组,有三类函数,问执行一个函数列表后,最终数组的值是多少。
函数1:指定位置加上一个值。
函数2:所有位置乘以一个值。
函数3:依次调用若干个其他函数。
思路:DAG 与 嵌套表达式展开
题目已经说了函数不存在循环调用,所以调用链是一个 DAG 函数。
每个函数相当于一个表达式,整个 DAG 相当于嵌套表达式。
只看一个位置,嵌套表达式大概是下面的形式
((x*a*b+c)*d*e + f)*g*h...
嵌套表达式去除括号展开,大概如下
x*a*b*c*d*e*f*g*h... + c*d*e*g*h... + f*g*h...
总结下规律,对于每个函数1,需要乘以后面的所有函数2。
假设 DAG 是一个树,则可以逆序遍历树,累计所有乘积,从而计算出函数1需要加的值,从而计算出每个位置的值。
如果 DAG 是一个图,则同一个函数会被调用多次,从而导致函数1 有多个乘积,需要累加。
如上图,给每个顶点和每个边定义一个概念,叫做之后的乘积。
例如函数 A 是依次调用函数 B、C、D。
表达式展开时,函数 C 需要乘以函数 D 的所有乘积,这里记作为 after(C)
。
一个函数内部有多个函数调用,一个叶子节点内部累计的乘积,记作inner(H)
。
函数 C 调用函数 E 时,之后的乘积是after(C)*inner(H)
。
函数 B 调用函数 E 时,之后的乘积是 after(B)*inner(H)
。
此时,对于函数 E 来说,之后的乘积是 after(C)*inner(H) + after(B)*inner(H)
。
即对于有多个入边的 DAG,需要累加所有来源顶点的乘积。
基于这个逻辑,先进行 DAG 拓扑排序,然后逆序遍历 DAG 计算每个顶点的乘积即可。
拓扑排序需要维护入度的个数,入度为0时就可以随时选择了。
void TopologicalSort() {
orderedIndex.reserve(m + 1);
orderedIndex.push_back(0); // 0 是询问,也是根节点
queue<int> que;
for (int i = 1; i <= m; i++) {
if (inDeg[i] == 0) {
que.push(i);
}
}
while (!que.empty()) {
const int u = que.front();
que.pop();
orderedIndex.push_back(u);
for (const auto v : G[u]) {
inDeg[v]--;
if (inDeg[v] == 0) {
que.push(v);
}
}
}
}
内部的乘积,可以由底往上,与拓扑排序相反的顺序来计算。
void CalNodeMul() { // 计算每个节点自身的累计乘积
for (int i = m; i >= 0; i--) {
const int u = orderedIndex[i];
for (const auto v : G[u]) {
innerMul[u] = (innerMul[u] * innerMul[v]) % mod9;
}
}
}
之后的乘积,则按照拓扑排序的顺序访问节点,节点内也累计计算出节点子树内的之后乘积。
vector<ll> afterMul; // 计算每个节点后续的乘积
void calNodeCount() {
afterMul.resize(m + 1);
afterMul[0] = 1;
for (int i = 0; i <= m; i++) {
const int u = orderedIndex[i];
ll cnt = afterMul[u]; // 处理到 u, 说明所有依赖 u 的节点都处理完了
for (const auto v : G[u]) {
afterMul[v] = (afterMul[v] + cnt) % mod9;
cnt = (cnt * innerMul[v]) % mod9;
}
}
}
最后的查询其实也可以当做一个函数,这样跑完 DAG,答案就出来了。
for (int i = 1; i <= n; i++) {
A[i] = A[i] * innerMul[0] % mod9;
}
for (int i = 1; i <= m; i++) {
if (P[i]) { // 操作1,需要进行累加
const int u = P[i];
A[u] = (A[u] + V[i] * afterMul[i]) % mod9;
}
}
for (int i = 1; i <= n; i++) {
printf("%lld%c", A[i], i < n ? ' ' : '\n');
}
四、贪吃蛇
题意:n 个有体力值的蛇按顺序排列,如果体力数值相等,则按编号大的体力值更高。
每条蛇都很聪明,问在自己不被吃掉并尽可能的吃其他蛇时,最终剩余几条蛇。
前提:选择体力值最大的蛇 A 与 体力值最小的蛇 B。
选择1:A 吃掉 B,此时 A 的体力值需要减去 B 的体力值,之后继续游戏。
选择2:A 不吃 B,游戏结束。
思路:贪心与博弈。
原始题目很难理解,上面是简化后的,就容易理解多了。
分析最大值和最小值的关系。
如果只有两个蛇,显然吃掉是没问题的。
如果有三个蛇 A < B < C
,则需要看吃掉之后 C-A
与另外一个蛇 B
的关系。
如果 C-A > B
,则可以吃掉 B,否则就会被吃,此时不能吃 A
。
如果有四个蛇 A < B < C < D
,则需要看吃掉之后 D-A
与另外一个蛇 B
的关系。
如果 D-A > B
,则剩余蛇的关系是 B < D-A < C
。
由于 A < B < C < D
,可以推导出 C-B < D-A
。
即 C
吃掉 B
时,依旧在 D-A
前面,会被 D
吃掉。
所以 C
此时的最优选择是不吃 B
,游戏结束。
由此可以得出一个结论:体力值最大的蛇减去体力值最小的蛇后,如果结果不是体力值最小的蛇,则可以果断选择吃掉,自己肯定不会被吃掉。
如果是最小值呢,则关系是 D-A < B < C
。
此时决定权交到了 C
的手上。
如果 C
选择吃,且 C
自己不会被吃,则 C
肯定会选择吃。此时D
会被吃掉,故 D
就不能选择吃掉 A
。
如果 C
选择吃之后,C
之后也会被吃掉,则 C
肯定选择不吃,此时 D
就存活下来,故 D
可以果断选择吃 A
。
这里 C 会不会被吃掉,和 D 会不会被吃掉是等价的,故是一个递归博弈问题。
不管怎么选择,进入博弈状态后,最多只会吃1条蛇了。
故,可以先贪心在保证不被吃的前提下(吃之后不是最小值),尽可能的吃蛇。
之后,进行博弈递归,判断是否可以再吃一条蛇。
deque<pair<ll, ll>> que[2];
ll ans = 0;
while (que[0].size() + que[1].size() >= 3) { // 必吃
auto [maxVal, maxIdx, maxType] = GetMax();
auto [minVal, minIdx, minType] = GetMin();
auto [secondMinVal, secondMinIdx, secondMinType] = GetMin();
if (maxVal - minVal > secondMinVal || (maxVal - minVal == secondMinVal && maxIdx > secondMinIdx)) {
que[1].push_front({maxVal - minVal, maxIdx}); // 先加入大的
que[secondMinType].push_front({secondMinVal, secondMinIdx});
ans++;
} else { // 撤销操作
que[secondMinType].push_front({secondMinVal, secondMinIdx});
que[minType].push_front({minVal, minIdx});
que[maxType].push_back({maxVal, maxIdx});
break;
}
}
if (que[0].size() + que[1].size() == 2) {
ans++;
} else { // 可能不吃
if (StartGame()) {
ans++;
}
}
return ans;
博弈游戏的代码与贪心的代码很类似。
必胜时游戏结束,否则尝试吃掉,然后递归判断自己会不会被吃。
bool StartGame() { // 最小值是否被吃
int leftNum = que[0].size() + que[1].size();
if (leftNum <= 1) return false;
if (leftNum == 2) return true; // 被吃
auto [maxVal, maxIdx, maxType] = GetMax();
auto [minVal, minIdx, minType] = GetMin();
auto [secondMinVal, secondMinIdx, secondMinType] = GetMin();
if (maxVal - minVal > secondMinVal || (maxVal - minVal == secondMinVal && maxIdx > secondMinIdx)) {
return true;
}
que[secondMinType].push_front({secondMinVal, secondMinIdx});
que[1].push_front({maxVal - minVal, maxIdx});
return !StartGame();
}
优化:你可能注意到,上面的代码直接从双向队列中获取最大值和最小值。
这个可以证明,不断贪心吃的时候,新产生的蛇的体力值是递减的。
证明也很简单,由于 A < B < C < D
可以推导出 C-B < D-A
,因此后面吃的蛇小于前面吃的蛇。
当然,你可能会想,会不会若干轮下来,最小值在新的队列里呢?
这是不可能的,因为根据前面的贪心要求,加入新队列的值不是最小的,即旧队列肯定存在更小的值。
如果旧队列只剩一个最小值,吃之后,会不会需要插入到中间位置呢?
也不会,因为此时吃之后剩余的必定是最小值,不再满足贪心规则。
因此,可以通过贪心加博弈的方法来解决这个问题。
五、最后
这次比赛难度比较大,第三题 DAG 与嵌套表达式展开,不容易思考。
第四题贪心就不容易想到,博弈更不容易想到,难度定位为黑题也是没问题的。
《完》
-EOF-
本文公众号:天空的代码世界
个人微信号:tiankonguse
公众号 ID:tiankonguse-code
本文首发于公众号:天空的代码世界,微信号:tiankonguse
如果你想留言,可以在微信里面关注公众号进行留言。