2026 ICPC沈阳邀请赛Vp补题
赛时在签到题上浪费了太多时间,没机会看更难的题,一些中档难度的题涉及的知识其实也有学过,也是非常可惜,所以这篇博客准备补一下题,并且反思一下
补题点这 :The 2026 ICPC China Shenyang National Invitational Programming Contest - 比赛主页 - 比赛 - QOJ.ac
难度:

榜单还是挺正的,题目难度区分度也很高
先从简单的题开始:
L. 渐隐的记忆
这道题刚开始和队友wm读了一遍题,算是有点模糊的意思,然后wm看了样例发现了规律,五分钟就直接ac了这道题,在他写代码的时候我认真理解了下题目,也是认可了wm的思路
题目:
小明有一段长度为N的字符串密码S,他记错了其中一段连续子串**L~i~,R~i~,并输入修改后的字符串A,而在区间 L~i~,R~i~外的所有位置,他输入的字符和原字符串S都一样,已知对于密码的任意字符位置 k 这个位置落在记错区间 L~i~,R~i~**内的总次数严格少于总尝试次数M 的一半,现在给小明M次输入,请帮小明还原出最开始的密码
思路:
题目的意思其实就是M个字符串中每个位置错误的次数必须小于M/2,既然错误次数小于M/2那么正确次数就要大于M/2,这时候就总结出规律对于M次尝试每个位置出现次数最多的字符就一定是正确的并且一定大于M/2,那就找出每个位置出现次数最多的,最后自然就是对的,这在wm写代码的时候我就想出来了,所以他在样例中总结出的规律也是完全正确,极速AC这道签到题
代码:
cpp
#include<bits/stdc++.h>
using namespace std;
const int N=1e6+10;
int n,m;
string s[N];
void solve(){
cin>>n>>m;
for(int i=1;i<=m;i++) cin>>s[i];
for(int i=0;i<n;i++){
map<char,int> mp;
for(int j=1;j<=m;j++){
mp[s[j][i]]++;
}
vector<pair<int,char>> v;
for(auto &p : mp){
v.push_back({p.second, p.first});
}
sort(v.begin(), v.end());
cout << v.back().second;
}
cout << '\n';
}
int main(){
ios::sync_with_stdio(0);
cin.tie(0);
int _=1;
// cin>>_;
while(_--){
solve();
}
}
AC了这道题之后榜单过的第二多的题就是K题了其次是F题,可是当时我和wm正在看F题感觉也不算难,然后另一个队友hw就在看K题,然后我们三个人就一起去看了K题,想了很多思路也是都不行,我和wm就选择先去写F题了(榜单瞬间过了很多)然后就死磕了三个小时才做出来
F. 等距矩阵
在这道题上面浪费的时间是最多的,主要原因是理解错了题目,导致思路一直是错的也wa了好几次,中途也是一直说重新读下题可是脑子里一直想的是那个错误的思路,其实在最开始的理解是对的,可是写题之后思路就错了之后最后wm去了厕所,我重新看了下题目突然就恍然大悟了,然后思路正确之后很快就ac了
题目:

思路:
题目给了八连通和四连通两种路径,一个只能上下左右,另一个可以八个方向就是四连通基础上还能斜着走,要求构造一个矩阵,使得两种路径的长度最小值一样,题目也说了八连通更灵活
所以我们最开始就想错了方向,并且写上题之后就忘记了八连通也可以走上下左右,还以为只能斜着走,我们就默认八连通最小路径自然就是走对角线,然后四连通走直角,让四连通走最小的,然后八连通的格子就是四连通(左上角到右上角再到右下角这个L型)对应位置的求和最后保证L4和L8相等(太错误了,一个斜着走一个直着走,八连通一步等于四连通两步,数还不能重复那怎么可能会是最优的)
既然八连通比四连通要灵活,那自然是不能走八连通,所以我们强制都走四连通,让四连通的长度最短,然后八连通的最短也是走四连通的走法那就符合题意了,所以就是走一个L型强制去走四连通路径,并且右上角一定要放0,因为那个位置八连通斜着走可以跳过,要使L4和L8相等,就要忽略掉右上角的数
代码:
cpp
#include<bits/stdc++.h>
using namespace std;
#define IOS ios::sync_with_stdio(0),cin.tie(0),cout.tie(0)
#define endl '\n'
#define int long long
#define pii pair<int,int>
#define fi first
#define se second
#define YES cout<<"YES"<<endl;
#define NO cout<<"NO"<<endl;
const int INF=1e6+5;
void solve()
{
int n;
cin>>n;
if(n==1){
cout<<0<<endl;
return;
}
if(n==2){
cout<<"1 0"<<endl;
cout<<"3 2"<<endl;
return;
}
vector<vector<int>> a(n+1,vector<int>(n+1,-1));
a[1][n]=0;
int num=1;
vector<int> b(n*n,0);
for(int j=1;j<=n;j++)
{
if(j==n) continue;
a[1][j]=num;
b[num]=1;
num++;
}
for(int i=2;i<=n;i++)
{
a[i][n]=num;
b[num]=1;
num++;
}
vector<int> ans;
for(int i=1;i<n*n;i++)
{
if(b[i]==0) ans.push_back(i);
}
int index=0;
for(int i=1;i<=n;i++)
{
for(int j=1;j<=n;j++)
{
if(a[i][j]==-1) {
a[i][j]=ans[index];
index++;
}
}
}
for(int i=1;i<=n;i++)
{
for(int j=1;j<=n;j++)
{
if(j==1) cout<<a[i][j];
else cout<<' '<<a[i][j];
}
cout<<endl;
}
// cout<<fixed<<setprecision(x)<<
}
signed main()
{
IOS;
int _=1;
cin>>_;
while(_--)
solve();
return 0;
}
K. 环线补给
在写完F题之后还剩下最后一小时才继续研究K题(现在发现这道题是这周的训练题中的一道题(VP是在上周周末))

思路:
首先第一个操作就是断环为链,这样可以方便处理对于每一个k的情况。说通俗点就是在n后面再接上1到(n-1)的值,所以数组要开双倍长度
如果从每个起始点开始遍历时间复杂度O(n2)会超时,所以预处理一个前缀和更好的去知道1到2n-1中有没有小于0的情况,然后用单调递增队列去维护区间的最小前缀和,最小前缀和减去开头如果小于零那就不合法
代码:
cpp
#include<cstdio>
#include<iostream>
using namespace std;
int n,head=1,tail,ans;
long long a[2000001],s[2000001],q[2000001];
int main()
{
cin>>n;
for(int i=1;i<=n;i+=1)
cin>>a[i];
for(int i=1;i<=n-1;i+=1)
a[i+n]=a[i];
for(int i=1;i<=2*n-1;i+=1)
s[i]=s[i-1]+a[i];
for(int i=1;i<=2*n-1;i+=1)
{
while(head<=tail&&max(i-n+1,1)>q[head]) head++;
while(head<=tail&&s[i]<=s[q[tail]]) tail--;
q[++tail]=i;
if(i-n+1>0&&s[q[head]]-s[i-n]>=0) ans++;
}
cout<<ans;
return 0;
}
E. 时空回溯
这题赛时看了几眼,当时认为是贪心,排序后先把用时短的都做了,然后在合适的时候用卡片,同时记录一下如果出现更优解就反悔,事实这题局部最优不能逼近全局最优,使用卡片会影响你做题的顺序,可以举反例看出来
题目:

解题思路:
先用反例去看出贪心为什么不对:
取
N = 6,时间[2, 2, 2, 3, 3, 3]贪心法
排序现在已经是升序的了
[2, 2, 2, 3, 3, 3]枚举计算每一个位置使用卡牌的罚时
k=0: 48k=1: 48 - 5*2/2 = 43k=2: 48 - 4*4/2 = 40k=3: 48 - 3*6/2 = 39k=4: 48 - 2*9/2 = 39k=5: 48 - 1*12/2 = 42k=6: 48
最小为 39.0
优解:
顺序
[2, 2, 3, 2, 3, 3]
P[1]=2P[2]=4P[3]=7P[4]=9P[5]=12P[6]=15总罚时(不用卡片)
S = 2 + 4 + 7 + 9 + 12 + 15 = 49。使用卡片在
k=3后:
F = S - (6-3) * P[3] / 2 = 49 - 3 * 7 / 2 = 49 - 10.5 = 38.5
从这就可以看出来了,在第三个位置使用卡牌后,可以把罚时更大的题提到前面更优
这里我们可以算出每道题的权重,就是罚时的次数,对于做完k 题后使用卡牌,做题的时间序列有a1 到 ai , 就会有
- 若
i>k那么ai 会被计算N-i+1次(使用卡牌后的牌都不会被减半直到最后每次罚时都会被累加) - 若
i<=k那么ai在使用卡牌前被计算k-i+1 次,使用卡牌后被计算N-k次,可是代价被减半所以相当于被计算**(N-k)/2**次
所以这些就是每道题的罚时ai 对应的权重(出现次数)
然后需要**掌握排序不等式**,两个有序的数组想要乘积和最小那应该怎么操作呢,一个升序一个降序,分别用最大的乘另一个数组中最小的,两两相乘最后的总和就是最小的
证明:
你有两组数,时间(a和b),权重(x和y)
现在a<b,x<y
那你也只有两种组合方式
1、大乘大,小乘小:
b*y+a*x2、大乘小,小乘大:
b*x+a*y
(a*x + b*y) - (a*y + b*x)
= a*x - a*y + b*y - b*x
= a(x - y) + b(y - x)
= (b - a)(y - x)
这两种情况明显是第二种要更小,同序减反序明显是正数
知道了这个工具,我们就可以枚举计算对每个位置都使用卡牌后的总罚时(每道题的罚时乘上它的权重也就是出现次数~~(权重具体计算方法上面解释了)~~),最终获得最小的罚时
cpp
#include<bits/stdc++.h>
using namespace std;
#define int long long
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n;
cin >> n;
vector<int> T(n);
for (int i = 0; i < n; i++) cin >> T[i];
sort(T.begin(), T.end());
int ans = LLONG_MAX;
for (int k = 0; k <= n; k++) {
vector<int> W(n);
for (int i = 0; i < n; i++) {
if (i < k)
W[i] = 2 * (k - i) + (n - k);
else
W[i] = 2 * (n - i);
}
sort(W.begin(), W.end(), greater<int>());
int sum = 0;
for (int i = 0; i < n; i++) sum += T[i] * W[i];
ans = min(ans, sum);
}
cout << ans / 2;
if (ans & 1) cout << ".5\n";
else cout << ".0\n";
return 0;
}
后面两道难度比较大,赛时根本没做到,只好赛后补题
I. Alice的压岁钱

这题看到题目下意识就以为是博弈论,看完题目之后想用贪心,后来发现了反例就不知道咋写了,看了题解之后才知道是用dp
贪心反例:n = 9(即 M = n + 1 = 10)
- 贪心路径(每次取最大
k = gcd(A, M)):A=1, gcd=1→ 取1,A=2A=2, gcd=2→ 取2,A=4A=4, gcd=2→ 取2,A=6A=6, gcd=2→ 取2,A=8A=8, gcd=2→ 取2,A=10
共 5 步。
- 最优路径:
A=1 → 2(取1)A=2 → 4(取2)A=4 → 5(取1,这里没有取2,而是少取了1)A=5 → 10(因为gcd(5,10)=5,一次取5)
共 4 步。
正确思路:
题目中说**A + B = n + 1 始终不变**,意味着 Bob 的硬币数 B = (n+1) - A 完全由 A 决定
于是 gcd(A, B) = gcd(A, n+1 - A) = gcd(A, n+1)
每步可拿的最大只取决于A和n+1的最大公约数
再通过上面的贪心反例就可以感觉出来,只考虑约数的情况即可就是最优的
M=10 约数是 [1, 2, 5, 10],那你可以1 → 2 → 4 → 5 → 10
2到4然后再到5需要两步,那么从 2 直接跳到 5 需要 ceil((5-2)/2) = 2 步,是一样的所以4这个状态没用
所以最优就是每一步都跳最大,到最后再调整到约数
转化成最短路径动态规划问题,贪心(局部最优)无法推出全局最优,并且当前的选择会影响后续未来的变化
dp 就是一张"最少步数记录表",每次你从当前约数出发,试着更新所有能到达的更大约数,如果发现新路径更短,就擦掉旧的、写上新的
代码:
cpp
#include<bits/stdc++.h>
using namespace std;
const int N=1e6+10;
typedef long long ll;
ll n,m,k;
ll x[N],H[N];
ll ans[N];
vector<int> ys;
ll dp[N];
void __(){
ys.clear();
cin>>n;
for(int i=1;i<=(n+1)/i;i++){//只处理到sqrt(n+1)就可以
if((n+1)%i==0){
ys.push_back(i);
if(i!=(n+1)/i) ys.push_back((n+1)/i);//不是完全平方数,也放进去
}
}
sort(ys.begin(),ys.end());
// for(auto j:ys) cout<<j<<" ";
// cout<<endl;
for(int i=1;i<ys.size();i++) dp[i]=1e18;
for(int i=0;i<ys.size()-1;i++){//以下一个约数为基准
for(int j=i+1;j<ys.size();j++){//在当前i的情况下变成每个数的最小步数
dp[j]=min(dp[j],dp[i]+(ys[j]-1)/ys[i]);//更新最小步数
if(ys[j]%ys[i]==0) break;
}
}
cout<<dp[ys.size()-1]<<"\n";
}
int main(){
ios::sync_with_stdio(0);
cin.tie(0);
int _=1;
cin>>_;
while(_--){
__();
}
}
感觉不是很难写,主要是判断出这道题考的什么,我正常的思考过程的话就是想模拟然后会超时,然后贪心看一下行不行,dp还没有掌握确实想不出来,这道题考的就是约数状态压缩 + DAG 最短路径 DP,感觉约数这里有点数论的知识,可是推例子的话也可以看出来
J. 霓虹天际线



题意还算好理解,把题目提取一下,就是x正半轴有好多竖着的直线,然后在y轴上找一点,过这个点做一条直线,能和x轴上那些直线的顶点相交,求最多能有多少相交
从题目中的示意图也可以看出,如果最右侧的点比左侧的点都要矮的话,那就出现看不到最右侧点的情况,因为会被左侧的点给拦住,这样可以在斜率的方向去想,x轴正半轴,i>j,Y是y轴上点的坐标

经过推导就可以求出想连到那个点需要的最小Y的高度(就是右侧点和左侧最高和距离最近的点 (与当前点相连直线最小)该支线与Y轴的交点)
我们要找的正是斜率最小的那个左侧点 ,因为它与当前点的连线在 Y 轴上的截距最大,而这个最大截距就是当前楼的可见阈值 Li
可是我们如果枚举每一个右侧点,让它与左侧点去计算时间复杂度 O(n2) 就会超时,所有想到用一个下凸包去维护(其实就是高级单调栈)
关于凸包的知识可以看我之前的这篇博客 栈&队列(单调结构总结+题型拓展讲解)-CSDN博客
这里其实是一种常见的优化方法,因为咱们对x轴上所有点对其坐标进行排序,所以斜率变化也是有规律的,既然我们要找斜率最小,那么对于左侧所有点两两依次去算斜率,比如A,B,C,如果AB的斜率大于BC,那么点B就可以扔了 ,这三点大概就是一个倒着的V型,B肯定高于A和C,所以我们要维护的是两两之间的所有斜率单调递增,这里就可以用一个下凸包
这里下凸包中所有斜率都是单调递增 的,既然有这样的单调性我们就想到用二分查找 ,其实就是每次找中间的点去算斜率,然后再缩区间
因为下凸包筛选出来的数据都是可行的(已经把那些没用的都扔了),所以在这些具有单调性的点(也可以把这些点理解为一个有序数组)用二分查找去找峰值
现在我们已经知道了每个楼需要的最小高度了,然后我们排序还是用二分,看咱输入的每个Y最多可以满足多少个楼需要的高度
到此这道题就算是写完了
代码:
cpp
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
struct Point { ll x, y; };
struct Node { ll x, y; Point v; }; // 凸包点,v 为指向下一个点的向量
vector<Node> hull; // 下凸包
// 叉积符号:正逆时针,负顺时针,零共线
int cross(const Point& a, const Point& b) {
__int128 g = (__int128)a.x * b.y - (__int128)a.y * b.x;
return (g > 0) - (g < 0);
}
// 插入点 (x, y) 到凸包(维护下凸包,斜率单调递增)
void insert(ll x, ll y) {
while (hull.size() > 1) {
int m = hull.size();
Point v1 = {x - hull[m-1].x, y - hull[m-1].y}; // 新点与倒数第二点的向量
// 如果折线向右拐或共线,弹出倒数第一个点
if (cross(hull[m-2].v, v1) >= 0) hull.pop_back();
else break;
}
if (!hull.empty()) {
int m = hull.size();
hull[m-1].v = {x - hull[m-1].x, y - hull[m-1].y};
}
hull.push_back({x, y, {0, 1}});
}
// 在凸包上二分查找向量 v 的切点位置(返回 hull 中的下标)
int find(const Point& v) {
int l = -1, r = (int)hull.size() - 1;
while (r - l > 1) {
int mid = (l + r) >> 1;
if (cross(v, hull[mid].v) <= 0) r = mid;
else l = mid;
}
return r;
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int N, Q;
cin >> N >> Q;
vector<Point> p(N);
for (int i = 0; i < N; ++i) cin >> p[i].x >> p[i].y;
sort(p.begin(), p.end(), [](const Point& a, const Point& b) { return a.x < b.x; });
vector<ll> L; // 每个大楼的可见阈值
for (int i = 0; i < N; ++i) {
ll x = p[i].x, y = p[i].y;
if (i == 0) {
// 第一栋楼在任何高度 Y>=1 都可见,最小阈值为1(题目Y最小为1)
L.push_back(1);
} else {
// 二分查找最小高度 Y,使得大楼 i 可见
ll l = 0, r = 1e12; // 上界足够大
while (r - l > 1) {
ll mid = (l + r) >> 1;
Point v = {x, y - mid}; // 从 (0, mid) 到 (x,y) 的向量
int pos = find(v);
// 检查在凸包上,点 i 是否可见
// 条件:mid * x >= (mid - y) * hull[pos].x + x * hull[pos].y
// 移项后等价于:mid * (x - hull[pos].x) >= x * (hull[pos].y - y) ?
// 直接使用整数比较避免精度问题
__int128 lhs = (__int128)mid * x;
__int128 rhs = (__int128)(mid - y) * hull[pos].x + (__int128)x * hull[pos].y;
if (lhs >= rhs) r = mid;
else l = mid;
}
L.push_back(r);
}
insert(x, y); // 将当前点加入凸包
}
sort(L.begin(), L.end());
while (Q--) {
ll Y;
cin >> Y;
int ans = upper_bound(L.begin(), L.end(), Y) - L.begin();
cout << ans << '\n';
}
return 0;
}
赛后这两道题感觉难度还是很大的,凸包之前虽然了解过,但是如果真的写这道题也很难可以用出来,思维上很难把题目的模型提取出来,可以看出来是操作斜率但是到底怎么去优化也很难想,所以还是要多做题提升熟练度
总结
被队友打哭了