2026 ICPC EC Online Contest I 比赛小结 + 补题
题目链接
前言
虽然是临时替补一场,好歹也是 ICPC 初见吧,所以写份博客 + 补题
AC:A,C,F,L,M;
Rank:849th,校排 4th / 5th
个人可以接受的结果吧,校内上面三队本来水平就更高,该做的也都做了。
唯一可惜的是最后 D 没想到我们做法的剪枝,就很简单的一行啊!
希望未来会更好。
个人题解
F - 50 Years of Excellence
题意
给出连续
一年的总分定义为这一年所有题目评分的和。
我们称一年为优秀的,当且仅当这一年的总分严格小于前一年的总分。
特别地,第一年之前一年的总分定义为
求有多少个优秀的年份。
思路
第一个签到,手速慢了。。
考虑到
复杂度
代码
inline void solve() {
ll n, m; cin >> n >> m;
ll pre = 0, ans = 0;
for (int i = 1; i <= n;++i) {
ll res = 0;
for (int j = 1; j <= m;++j) {
ll e; cin >> e;
res += e;
}
if (res < pre) ans++;
pre = res;
}
cout << ans;
}
M - Check In
题意
给出包含
对于每次查询,按以下规则输出结果:
-
该队名不在名单中 —— 输出
WRONG; -
该队名在名单中,且该队此前未签到 —— 输出
OK,并将该队标记为已签到; -
该队名在名单中,且该队此前已签到 —— 输出
REPEAT。 -
-
名单和所有查询中的队名长度之和不超过
10^6 。
思路
第二个签到,一个 set 维护有效用户名,另一个 set 动态维护已出现的用户名,对于每个询问分支判断即可。
代码
inline void solve() {
int n, m; cin >> n >> m;
unordered_set<string> st;
for (int i = 1; i <= n;++i) {
string s; cin >> s;
st.insert(s);
}
unordered_set<string> vis;
for (int i = 1; i <= m;++i) {
string t; cin >> t;
if (!st.count(t)) cout << "WRONG\n";
else {
if (!vis.count(t)) cout << "OK\n";
else cout << "REPEAT\n";
vis.insert(t);
}
}
}
C - Permutation Inversions
题意
有一个未知的长度为
每条限制给出一个区间
构造一个满足所有限制,且逆序对数量最少的排列
若无解,输出
- 多组数据,
1 ≤ T ≤ 10^6 ; -
- 所有测试用例的
n 之和、m 之和、所有限制的区间长度之和均不超过10^6 。
思路
注意到是根据给出的原排列的位置的大小约束确定原排列,很经典的图论建模 + 拓扑排序问题。
先考虑什么时候无解,显然一对约束关系矛盾时无解。如果用一条有向边表示一对位置的偏序关系,显然在图上体现为一个环。
我们可以拓扑排序得到一个可行的解,也就是原排列每个位置的排名,如果无解那么拓扑也可以判断。
再考虑第二个约束:逆序对数量最少。
这意味着我们安排没有确定偏序关系的位置时,越靠前的位置的排名要更靠前。
那我们用小根堆代替拓扑排序的队列即可,这样保证了更小的位置在更早的时候被弹出。
最后我们从
不过还有一个细节上的问题,直接根据输入存边会有重边导致拓扑出问题,用一个 set 去重边即可。
设
代码
const int N = 1e6 + 10;
vector<int> grid[N];
int deg[N];
inline void solve() {
int n, m; cin >> n >> m;
for (int i = 1; i <= n;++i) {
grid[i].clear();
deg[i] = 0;
}
set<pair<int, int>> st;
for (int i = 1; i <= m;++i) {
int l, r; cin >> l >> r;
int pre; cin >> pre;
for (int j = l + 1; j <= r;++j) {
int e; cin >> e;
st.insert({ pre,e });
pre = e;
}
}
for (auto& [u, v] : st) grid[u].push_back(v), deg[v]++;
// 建图
priority_queue<int, vector<int>, greater<int>> q;
vector<int> res, ans(n + 1, 0);
for (int i = 1; i <= n;++i) {
if (deg[i] == 0) q.push(i);
}
while (!q.empty()) {
int cur = q.top();
q.pop();
res.push_back(cur);
for (auto e : grid[cur]) {
if (--deg[e] == 0) q.push(e);
}
}
// 拓扑
if (res.size() != n) cout << "-1\n"; // 有环
else {
int cur = 1;
for (auto e : res) {
ans[e] = cur++;
}
for (int i = 1; i <= n;++i) cout << ans[i] << ' ';
cout << "\n";
}
}
L - Longest Common Prefix
题意
给出
对于
对于每个
其中
-
- 所有字符串的长度之和不超过
5 \times 10^5 。
思路
一开始以为是什么神必题,因为看榜的时候过题数处于一个不温不火的状态。
调 A 调的实在没招了仔细看了一下才发现是路边一条,直接踹死了。
我们考虑维护多个串的 LCP 的数据结构,显然只有 Trie 了。(不知道 SAM 能不能做不过还好我不太会)
在 Trie 上,我们会维护每个节点
考虑自根开始遍历一段公共前缀
再考虑插入一个新串
由于 Trie 的内容就是前
我们用
插入到
这样就更新了所有发生变化的
最后考虑怎么维护答案:用
还有一个细节,插入第
设所有字符串的长度之和为
代码
const int N = 5e5 + 10;
struct Trie {
int t[N][65];
ll cnt[N], f[N];
ll idx, sum = 0;
int toint(char x) {
if (x >= 'A' && x <= 'Z') return x - 'A';
else if (x >= 'a' && x <= 'z') return x - 'a' + 26;
else return x - '0' + 52;
}
void insert(const string str) {
int p = 0, len = str.size();
for (ll i = 0; i < len; i++) {
int c = toint(str[i]);
if (!t[p][c]) t[p][c] = ++idx;
p = t[p][c];
cnt[p]++;
sum -= (f[cnt[p]] ^ cnt[p]);
f[cnt[p]] = max(f[cnt[p]], i + 1);
sum += (f[cnt[p]] ^ cnt[p]);
}
}
} trie;
inline void solve() {
int n; cin >> n;
for (int i = 1; i <= n;++i) {
string s; cin >> s;
trie.sum += 1ll * i;
trie.insert(s);
cout << trie.sum << "\n";
}
}
A - Recall
题意
有一个初始为空的栈,支持三种操作:
+ x—— 将元素x 入栈;? x—— 查询栈中是否存在元素x ,存在时记录为T x,不存在时记录为F x;-—— 弹出栈顶元素,要求操作时栈非空。
现给出 + x、T x 和 F x,所有出栈操作均被省略。
需要在记录中补入若干出栈操作,使得任意时刻栈内元素两两不同,且所有查询结果与记录一致。
保证有解,构造任意一种合法的操作序列。只需输出由 +、? 和 - 组成的字符串,分别表示入栈、查询和出栈,无需输出操作中的
- 多组数据,
1 ≤ T ≤ 10^5 ; -
- 所有测试用例的
n 之和不超过10^6 。
思路
卡了好多人的难调题。。
考虑当前状态下任意一个合法的后续方案
因为 T,所以只有三种情况:
- 下一次是
F x:执行询问时x 必须已经不在栈中; - 下一次是
+ x:再次加入x 以前,旧的x 必须已经出栈; - 后面不再出现
x :为了最终清空栈,x 仍然必须出栈。
因此,在方案
基于此,赛时队友贡献了一个做法:对于一个数
由于这题的“保证给出的操作序列有解”需要自己判断,再加上猪脑过载,我们觉得只关注这样的一组操作之内的贪心是正确的。
过了 30min 终于构造出了一组反例:
7
+ 1
+ 2
T 1
T 2
+ 4
F 1
T 4
按照我们的思路,会在第六次操作前弹空整个栈,导致最后一次查询结果不对。
然而只要在第四次操作之后就把栈清空,最后一次查询显然就是正确的。
所以,实际上正确的贪心是:
- 每次操作后重复检查栈顶:如果栈顶的数的下一次对应操作不需要它在栈中,弹出,否则停止;
感性的理解:既然栈先进后出,且这个数后面需要出栈,那么不如趁早让它出去,防止对后续加入的数产生影响。
证明:
设
- 当前
x 正好位于栈顶,可以直接弹出,不会连带删除其他元素; - 当前到
t 之间没有关于x 的操作,因为讨论的是x 的“下一次出现”; - 弹出
x 不改变其他元素是否在栈中,所以其他元素的T/F查询结果不受影响; - 后面新加入的元素原本会压在
x 上面;提前弹出x 反而避免以后为了删除x 而被迫先删除这些新元素; - 在原来的时刻
t 不再执行该次弹栈,所以弹栈总数不变。
于是,从任意合法方案都能交换得到一个“现在立即弹出
因此,这一步贪心是安全的。
实现:预处理每一次操作的下一次操作,再在维护栈中数的下一次操作的同时模拟一下即可。
考虑最后弹空栈的情况,每个数出入栈一次,复杂度
代码
struct node {
char nxt = '#', op;
int x;
};
inline void solve() {
int n; cin >> n;
vector<node> ops(n + 1);
map<int, int> pre;
for (int i = 1; i <= n;++i) {
char op; int x;
cin >> op >> x;
ops[i].op = op; ops[i].x = x;
if (pre.count(x)) ops[pre[x]].nxt = op;
pre[x] = i;
}
pre.clear();
stack<int> st; set<int> vis;
string ans;
for (int i = 1; i <= n;++i) {
pre[ops[i].x] = i;
if (ops[i].op == '+') {
ans.push_back('+');
vis.insert(ops[i].x);
st.push(i);
} else {
ans.push_back('?');
}
while (!st.empty() && ops[pre[ops[st.top()].x]].nxt != 'T') {
vis.erase(ops[st.top()].x);
st.pop();
ans.push_back('-');
}
}
cout << ans << "\n";
}
D - Sequence
题意
对于一个长度为
即
现给出
求有多少个不同的
思路
队友赛时最后一小时提供了一个暴搜做法,但剪枝不全 T 了。
感觉这个做法加上剪枝比较直观,所以在此基础上补了。
首先我们需要注意到给出的是
随后考虑到原串是一个 01 串,也就是说
我们可以想到模拟填
可以以此为状态写一个暴搜:
设当前填到了第
- 若
mx = mn = a ,那么当前的p_{pos} 显然是a ,且原串中可以填0 / 1 ,答案加上后续填法的两倍,后续状态有mx + 1 ; - 若
mx \neq mn ,那么我们既可以填多的那个数,也可以填少的那个数,后续状态对应有mn + 1 和mx + 1 ;
这样很直观,但是会 T,需要考虑剪枝。
考虑每次填完一个数对
那么每个分枝都需要保证剩余的集合中不会有
这样加一条剪枝就过了,但是为什么?
实际上,当
- 若填入
mn 那个数时,此时需要取mx 作为p_{pos} ,后续状态会是\{ mx, mn + 1\} ; - 然而根据已知,
mn 也存在于集合中; - 后续状态是
\{ mx, mn + 1\} ,mn \lt mn + 1 ,该分支直接结束;
所以:
- 当
mx \neq mn 且集合中mx ,mn 均存在时,只会有一个分支 - 当
mx \neq mn 且集合中mx 或mn 存在时,只会有一个分支,另一个由于缺少合法的p_{pos} 本就不存在; - 若
mx = mn ,只会有一个分支,与mx \neq mn 的情况是互斥的;
因此,任意时候这份暴搜只有一个分支,复杂度是
不过递归写法看起来很直观,不也挺好的吗。
代码
int n;
multiset<int> f;
ll dfs(int pos, int mx, int mn) {
if (pos == n + 1) return 1;
if (*f.begin() < mn) return 0;
if (mx < mn)swap(mx, mn);
ll ans = 0;
auto it1 = f.find(mx);
auto it2 = f.find(mn);
if (mx == mn && it1 != f.end()) {
f.erase(it1);
ans += 2ll * dfs(pos + 1, mx + 1, mn);
f.insert(mx);
ans %= mod;
} else {
if (it1 != f.end()) {
f.erase(it1);
ans += dfs(pos + 1, mx, mn + 1);
f.insert(mx);
}
if (it2 != f.end()) {
f.erase(it2);
ans += dfs(pos + 1, mx + 1, mn);
f.insert(mn);
}
}
return ans % mod;
}
inline void solve() {
cin >> n;
for (int i = 1; i <= n;++i) {
int e; cin >> e; f.insert(e);
}
cout << dfs(1, 0, 0);
}
N - Red Sequence
题意
给出一个长度为
第
需要将整个序列划分为若干个连续段。若一个段内红色棋子的数量不少于黄色棋子的数量,且不少于蓝色棋子的数量,则称这一段为红色段。即对于区间
求划分后所有红色段的长度之和的最大值。段的长度指它包含的位置数,即
思路
CDQ 分治 + DP
首先贪心显然是不对的,因为本质是一个从合法单点开始的区间拓展问题,无法确定往左还是往右更优。
考虑 DP,设
但是考虑到约束,不是任意连接的,需要满足:
-
psR_i - psR_j \ge psY_i - psY_j -
psR_i - psR_j \ge psB_i - psB_j -
i \gt j
整理一下:
-
psR_i - psY_i \ge psR_j - psY_j -
psR_i - psB_i \ge psR_j - psB_j -
i \gt j
可以发现是个三维偏序问题,考虑 CDQ 分治;
先预处理一下三个前缀和,然后以三维偏序存点的模式存储一下每个下标的信息;
这里由于前缀和之差值的域是
第三维因为是下标已经有序,只存剩下两维,直接跑 CDQ 板子就行了。
注意优化 dp 时,先处理左半部分,再计算左边向右边的贡献,再计算右半部分;
同时,由于递推式
对于右半部分的每个下标
复杂度
代码
struct node {
ll b, c, id;
};
struct BIT {
int n;
ll c[N];
int lowbit(int x) { return x & -x; }
void init(int _n) {
n = _n;
for (int i = 0; i <= n; i++) {
c[i] = -inf32;
}
}
void update(int x, ll k) {
while (x <= n) {
c[x] = max(c[x], k);
x += lowbit(x);
}
}
void clear(int x) {
while (x <= n) {
c[x] = -inf32;
x += lowbit(x);
}
}
ll query(int x) {
ll res = -inf32;
while (x > 0) {
res = max(res, c[x]);
x -= lowbit(x);
}
return res;
}
} bit;
ll dp[N];
void CDQ(int l, int r, const vector<node>& ori) {
if (l == r)return;
int mid = (l + r) >> 1;
CDQ(l, mid, ori);
vector<node> L(ori.begin() + l, ori.begin() + 1 + mid);
vector<node> R(ori.begin() + mid + 1, ori.begin() + 1 + r);
sort(L.begin(), L.end(), [&](const node& A, const node& B) {
if (A.b != B.b)return A.b < B.b;
return A.c < B.c;
});
sort(R.begin(), R.end(), [&](const node& A, const node& B) {
if (A.b != B.b)return A.b < B.b;
return A.c < B.c;
});
int i = 0;
for (int j = 0; j < R.size();++j) {
//dp[R[j].id] = max(dp[R[j].id], dp[dp[R[j].id] - 1]);
while (i < L.size() && L[i].b <= R[j].b) {
bit.update(L[i].c, dp[L[i].id] - L[i].id);
i++;
}
dp[R[j].id] = max(dp[R[j].id], bit.query(R[j].c) + R[j].id);
}
for (int j = 0; j < i;++j) bit.clear(L[j].c);
for (int j = mid + 1; j <= r;++j) dp[j] = max(dp[j], dp[j - 1]);
CDQ(mid + 1, r, ori);
return;
}
inline void solve() {
int n; cin >> n;
vector<ll> psR(n + 1, 0), psY(n + 1, 0), psB(n + 1, 0);
for (int i = 1; i <= n;++i) {
int a, b, c;
cin >> a >> b >> c;
dp[i] = -inf32;
psR[i] = psR[i - 1] + a;
psY[i] = psY[i - 1] + b;
psB[i] = psB[i - 1] + c;
}
vector<node> a(n + 1);
ll div = 1e6;
bit.init(2 * div + 1);
a[0].b = div; a[0].c = div;a[0].id = 0;
for (int i = 1; i <= n;++i) {
a[i].b = psR[i] - psY[i] + div;
a[i].c = psR[i] - psB[i] + div;
a[i].id = i;
}
dp[0] = 0;
CDQ(0, n, a);
cout << dp[n];
}