CSP-J 模拟赛2 讲评
2024 CSP-J 普及模拟赛 2 —— 讲评课件
整体概述
本场模拟赛共有 4 道题,难度逐步递增:
| 题目 | 难度 | 核心知识点 |
|---|---|---|
| 互质划分 | ★☆☆☆☆ | 数学观察、gcd 性质 |
| 出租车 | ★★☆☆☆ | 双指针 / 前后缀扫描 |
| 木雕玩具 | ★★★☆☆ | 二分答案 + 贪心验证 |
| 幽默数 | ★★★★☆ | LCM 性质、set 维护、mex |
第一题:互质划分(coprime)
题意回顾
把 1 到 n 这 n 个正整数分成尽量少的堆,使得每堆内的任意两个数都互质(即最大公因数为 1)。求最少堆数。
样例回顾
- n = 2:{1, 2} 放一堆就行(gcd(1,2)=1),答案 = 1
- n = 5:{1, 2, 5} 和 {3, 4} 两堆,答案 = 2
思路分析
核心观察:
相邻两个正整数一定互质!即 gcd(k, k+1) = 1。
为什么?假设 d 能同时整除 k 和 k+1,那么 d 也能整除它们的差 (k+1) - k = 1,所以 d = 1。
那最少堆数怎么求?
考虑 2 的倍数:2, 4, 6, 8, ...,一共有 ⌊n/2⌋ 个。任意两个偶数都不互质(它们的 gcd 至少是 2),所以每个 2 的倍数必须单独放入不同的堆!这意味着堆数至少是 ⌊n/2⌋。
那能不能恰好 ⌊n/2⌋ 堆呢?可以!构造方案:
- 把相邻的奇偶数配对:(1, 2)、(3, 4)、(5, 6)、...
- 如果 n 是奇数,最后一个数 n 单独没法配,就把它塞进最后一堆变成三个数,比如 ..., (n-2, n-1, n)。
验证一下三连堆:gcd(2i-1, 2i) = 1,gcd(2i-1, 2i+1) = gcd(2i-1, 2) = 1,gcd(2i, 2i+1) = 1 ✅
特殊情况: n = 1 时,只有一堆,答案是 1(⌊1/2⌋ = 0 就错了)。
最终结论
时间复杂度 O(1),注意 n 最大到 10^18,要用 long long。
代码核心
long long n;
cin >> n;
if (n == 1) cout << 1 << endl;
else cout << n / 2 << endl;
第二题:出租车(taxi)
题意回顾
一条数轴上有 n 个乘客和 m 个司机,位置各不相同。每个乘客打车时,系统会把订单派给离他最近的司机(距离相同选坐标小的)。问每个司机会收到多少订单。
样例回顾
n=3, m=2
位置: 2 3 4 5 6
身份: 1 0 0 0 1 (1=司机, 0=乘客)
司机在位置 2 和 6。
- 位置 3 的乘客 → 离司机 2 更近 → 归司机 2
- 位置 5 的乘客 → 离司机 6 更近 → 归司机 6
- 位置 4 的乘客 → 距离都是 2,选坐标小的司机 2 → 归司机 2
答案: 2 1
思路分析
朴素做法(40 分):
对每个乘客,枚举所有司机找最近的 —— O(n × m),最多 10^6,n=m=1000 时能过。
满分做法:
每个乘客只会选择左边最近的司机或右边最近的司机!
所以我们可以:
- 从左往右扫:记录"当前最近的左边司机"是谁
- 从右往左扫:记录"当前最近的右边司机"是谁
- 对每个乘客,比较到左右两个司机的距离,选择更近的那个
用一张图来理解:
司机(位置2) 乘客(3) 乘客(4) 乘客(5) 司机(位置6)
| | | | |
←——左司机————→ ←——比较左右距离——→ ←——右司机——→
距离比较的细节:
- 如果
乘客位置 - 左司机位置 ≤ 右司机位置 - 乘客位置→ 选左边(相等时选坐标小的 = 左边) - 否则 → 选右边
边界处理:
- 如果左边没有司机(比如第一个乘客在第一个司机左边),左边距离设为无穷大
- 如果右边没有司机,同理
时间复杂度 O(n + m)。
代码核心
#include<bits/stdc++.h>
using namespace std;
const int N=2e5+5;
int n,m,a[N],t[N],l[N],r[N],ans[N];
int main()
{
cin>>n>>m;
for(int i=1;i<=n+m;i++) cin>>a[i];
for(int i=1;i<=n+m;i++) cin>>t[i];
int last=0;
for(int i=1;i<=n+m;i++)
if(t[i]==1) last=i;
else l[i]=last;
last=n+m+1;
for(int i=n+m;i>=1;i--)
if(t[i]==1) last=i;
else r[i]=last;
for(int i=1;i<=n+m;i++)
if(t[i]==0)
{
if(l[i]==0) ans[r[i]]++;
else if(r[i]==n+m+1) ans[l[i]]++;
else
{
if(a[i]-a[l[i]]<=a[r[i]]-a[i]) ans[l[i]]++;
else ans[r[i]]++;
}
}
for(int i=1;i<=n+m;i++)
if(t[i]==1) cout<<ans[i]<<" ";
cout<<endl;
return 0;
}
第三题:木雕玩具(toy)
题意回顾
有 n 个人,每人想要图案 a_i。3 位雕刻师各自事先选定一个图案 x,制作图案 y 需要耗时 |x - y|。可以同时工作。问最大等待时间的最小值是多少。(即让等待最久的人等得尽量短)
样例回顾
n=6, a = [1, 7, 7, 9, 9, 9]
选 x = 1, 7, 9 → 所有人都能立刻拿到(等待 0),答案 = 0
n=6, a = [5, 4, 2, 1, 30, 60]
选 x = 3, 30, 60 →
图案 1,2,4,5 由 x=3 的师傅做,等待 ≤ 2
图案 30 由 x=30 的师傅做,等待 0
图案 60 由 x=60 的师傅做,等待 0
最大等待 = 2,答案 = 2
思路分析
第一步:排序去重
相同的图案只需考虑一次(因为同一图案可以归同一个雕刻师,耗时相同)。
第二步:二分答案
答案具有单调性:如果等待时间 k 能做到,那么 k+1 肯定也能做到(更宽松)。所以可以二分最小的 k。
第三步:贪心验证
给定一个 k,怎么判断三位雕刻师能否覆盖所有需求?
贪心策略:每位雕刻师尽量"往右靠"来覆盖更多图案。
- 第一位雕刻师:既然 a_1 是第一个没被覆盖的,我们把 x 设为
a_1 + k(尽量往右,这样能覆盖更多右边的人) - 第一位雕刻师的覆盖范围是
[x-k, x+k]=[a_1, a_1+2k],跳过所有在这个范围内的 a_i - 找到下一个没被覆盖的 a_i,第二位雕刻师选择
y = a_i + k - 同理找到第三个没被覆盖的 a_j,第三位雕刻师选择
z = a_j + k - 如果三轮过后还有没覆盖的 → k 不够大,不合法
为什么雕刻师选 a_first + k 而不是更右?因为如果更右,a_first 就覆盖不到了。选 a_first + k 是保证覆盖 a_first 的前提下最右的选择。
图解:
a[1] a[i] a[j]
|——————————| |——————————| |——————————|
← 师傅1覆盖 → ...未覆盖... ← 师傅2覆盖 → ... ← 师傅3覆盖 →
x = a[1]+k y = a[i]+k z = a[j]+k
时间复杂度:排序 O(n log n),二分 O(n log W),其中 W 是值域范围。
代码核心
#include<bits/stdc++.h>
using namespace std;
const int N=2e5+5;
int n,a[N];
int check(int k)
{
int x=a[1]+k;
int i=1;
while(a[i]<=x+k)
{
i++;
if(i>n) return 1;
}
int y=a[i]+k;
while(a[i]<=y+k)
{
i++;
if(i>n) return 1;
}
int z=a[i]+k;
while(a[i]<=z+k)
{
i++;
if(i>n) return 1;
}
return 0;
}
int main()
{
cin>>n;
for(int i=1;i<=n;i++) cin>>a[i];
sort(a+1,a+n+1);
int l=0,r=1e9,ans=-1;
while(l<=r)
{
int mid=(l+r)/2;
if(check(mid))
{
ans=mid;
r=mid-1;
}
else l=mid+1;
}
cout<<ans<<endl;
return 0;
}
第四题:幽默数(humor)
题意回顾
给定一个长度为 n 的整数序列 a,问最小的、不能由任何一个子区间的 LCM(最小公倍数)表示的正整数是多少。
换句话说:枚举序列的所有子区间(连续的一段),对每个子区间求 LCM,把所有能得到的 LCM 值收集起来。然后从 1, 2, 3, ... 往上数,第一个不在这个集合里的数就是答案。
样例回顾
样例 1:
a = [1, 2, 3]
枚举所有子区间(共 6 个):
[1] → LCM = 1
[1,2] → LCM = lcm(1,2) = 2
[1,2,3] → LCM = lcm(1,2,3) = 6
[2] → LCM = 2
[2,3] → LCM = lcm(2,3) = 6
[3] → LCM = 3
去重后能表示的数:{1, 2, 3, 6}
从 1 开始数:1✓ 2✓ 3✓ 4✗ → 答案是 4
样例 2:
a = [1, 2, 3, 4, 5]
能表示的数(去重后):{1, 2, 3, 4, 5, 6, 12, 20, 60, ...}
从 1 开始数:1✓ 2✓ 3✓ 4✓ 5✓ 6✓ 7✗ → 答案是 7
暴力做法(为什么会超时)
最朴素的想法:枚举所有子区间,每个都算一遍 LCM。
子区间数量 = n(n+1)/2
n = 10^5 时,子区间数量 ≈ 5×10^9,根本算不完 ❌
而且就算只算一部分,LCM 会指数级增长,很快就会爆 long long(超过 9×10^18),没法存。
所以我们需要寻找 LCM 的特殊性质来优化。
关键性质 1:LCM 只会"翻倍增长"
固定左端点 L,右端点 R 不断往右移动时,区间 [L, R] 的 LCM 只会不变或至少翻倍(乘以 ≥2)。
为什么? 我们从一个具体例子理解:
a = [2, 3, 5, 6, 7]
固定 L=1(值为 2)
R=1: LCM([2]) = 2
R=2: LCM([2,3]) = lcm(2,3) = 6 ← 从 2 变成 6,翻了 3 倍(≥2)
R=3: LCM([2,3,5]) = lcm(6,5) = 30 ← 从 6 变成 30,翻了 5 倍(≥2)
R=4: LCM([2,3,5,6]) = lcm(30,6) = 30 ← 不变!因为 6 的质因子 2、3 已经在 30 里了
R=5: LCM([...,7]) = lcm(30,7) = 210 ← 从 30 变成 210,翻了 7 倍(≥2)
规律:每次 LCM 发生变化,必然是因为新加入的数包含了 LCM 中还没有的质因子。 新的质因子至少是 2,所以 LCM 至少会乘以 2。
💡 核心直觉:LCM 要么不动,要么"跳跃式增长"——每次至少翻一倍。
关键性质 2:"有用的"LCM 有一个上界
题目问的是"最小的不能表示的正整数"。如果某个 LCM 值非常非常大(比如 10^9),它对我们找答案几乎没有帮助——因为我们只关心从 1 开始连续能表示到哪。
结论:大于 V = 4256233 ≈ 4.3×10^6 的 LCM 值可以直接扔掉!
为什么是这个数? 这是第 3×10^5 + 1 个质数。简单理解:n 最大 3×10^5,而大于这个上界的 LCM 值不可能影响"最小的不能表示的数"——因为 mex 一定在合理范围内。
💡 实用记忆:设定一个阈值 V ≈ 5×10^6,超过就丢弃。
关键性质 3:每个左端点只产生很少的 LCM
结合性质 1 和 2:
- 固定左端点 L,LCM 每次变化至少翻倍(×2)
- LCM 一旦超过 V(≈ 5×10^6)就没用了
- 从 1 翻倍到 5×10^6,最多需要 log₂(5×10^6) ≈ 23 次
结论:对于每个左端点 L,有用的 LCM 值不会超过 23 个!
n = 10^5,每个左端点最多约 23 个 LCM,总共最多约 2.3×10^6 个值要处理——完全可以接受!
算法设计
现在我们利用以上性质,设计一个高效的算法。
核心思想:枚举所有左端点,但利用"只有少量不同 LCM"的性质,每个左端点只维护一个很小的集合。
为什么从右往左枚举?
如果我们从左往右枚举:
- 左端点从 L 移到 L+1 时,原来以 L 为左端点的所有 LCM 都"废了"
- 必须重新计算以 L+1 为左端点的所有 LCM
- 没法复用之前的结果
如果我们从右往左枚举:
- 左端点从 L+1 移到 L 时,以 L+1 为左端点的 LCM 已经算好了(存在
tmp里) - 以 L 为左端点的 LCM =
a[L]与tmp中每个值取 lcm - 可以增量更新,复用之前的结果!
💡 关键技巧:倒序枚举左端点,让
tmp集合可以增量更新而不是每次重新计算。
算法步骤
初始:myset = 空集合(收集所有出现过的 LCM)
tmp = 空集合(以当前位置为左端点的 LCM 集合)
从右往左枚举左端点 i(i = n, n-1, ..., 1):
步骤①:创建新集合 new_tmp,先把 a[i] 自己放进去(长度=1 的子区间)
步骤②:遍历 tmp 中的每个值 val:
val 代表区间 [i+1, 某个 R] 的 LCM
那么 lcm(a[i], val) 就是区间 [i, 那个 R] 的 LCM
如果 lcm(a[i], val) ≤ V,加入 new_tmp
步骤③:tmp = new_tmp(完成增量更新)
步骤④:把 tmp 中所有值倒入 myset(这些值都是某个子区间的 LCM)
枚举完毕:从 1 开始往上找,第一个不在 myset 中的数就是答案
用纸笔模拟一遍
以 a = [1, 2, 3] 为例,V = 5×10^6:
初始状态:
myset = {}
tmp = {}
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
i = 3(a[3] = 3):
步骤①:new_tmp = {3}
步骤②:tmp 是空的,跳过
步骤③:tmp = {3}
步骤④:myset = {3}
当前状态:myset = {3}, tmp = {3}
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
i = 2(a[2] = 2):
步骤①:new_tmp = {2}
步骤②:遍历 tmp = {3}
lcm(2, 3) = 6 ≤ V ✓ → new_tmp = {2, 6}
步骤③:tmp = {2, 6}
步骤④:myset = {3} ∪ {2, 6} = {2, 3, 6}
当前状态:myset = {2, 3, 6}, tmp = {2, 6}
【理解】tmp = {2, 6} 的含义:
- 2 = LCM([2]) ← 子区间 [2,2]
- 6 = LCM([2, 3]) ← 子区间 [2,3]
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
i = 1(a[1] = 1):
步骤①:new_tmp = {1}
步骤②:遍历 tmp = {2, 6}
lcm(1, 2) = 2 ≤ V ✓ → new_tmp = {1, 2}
lcm(1, 6) = 6 ≤ V ✓ → new_tmp = {1, 2, 6}
步骤③:tmp = {1, 2, 6}
步骤④:myset = {2, 3, 6} ∪ {1, 2, 6} = {1, 2, 3, 6}
当前状态:myset = {1, 2, 3, 6}, tmp = {1, 2, 6}
【理解】tmp = {1, 2, 6} 的含义:
- 1 = LCM([1]) ← 子区间 [1,1]
- 2 = LCM([1, 2]) ← 子区间 [1,2]
- 6 = LCM([1, 2, 3]) ← 子区间 [1,3]
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
最终:myset = {1, 2, 3, 6}
从 1 开始找不在集合中的数:
1 在吗?✓
2 在吗?✓
3 在吗?✓
4 在吗?✗ → 答案 = 4
可以看到,整个过程遍历了所有子区间,但每个左端点只保留了 ≤ V 的 LCM 值,所以集合一直很小。
再举一个更复杂的例子
a = [2, 3, 5, 6],V = 5×10^6:
i = 4: a[4]=6 → new_tmp={6}, tmp={6}, myset={6}
【子区间 [4,4]: LCM = 6】
i = 3: a[3]=5 → new_tmp={5}
lcm(5,6)=30 → new_tmp={5,30}, tmp={5,30}, myset={5,6,30}
【子区间 [3,3]: LCM=5, [3,4]: LCM=30】
i = 2: a[2]=3 → new_tmp={3}
lcm(3,5)=15, lcm(3,30)=30 → new_tmp={3,15,30}, tmp={3,15,30}, myset={3,5,6,15,30}
【子区间 [2,2]:LCM=3, [2,3]:LCM=15, [2,4]:LCM=30】
i = 1: a[1]=2 → new_tmp={2}
lcm(2,3)=6, lcm(2,15)=30, lcm(2,30)=30
→ new_tmp={2,6,30}, tmp={2,6,30}, myset={2,3,5,6,15,30}
【子区间 [1,1]:LCM=2, [1,2]:LCM=6, [1,3]:LCM=30, [1,4]:LCM=30】
mex({2,3,5,6,15,30}) = 1 ← 1 不在集合中,答案 = 1
⚠️ 注意:这个例子中
a不包含 1,所以 1 无法被任何子区间表示,答案就是 1。
复杂度分析
- 外层循环:枚举 n 个左端点
- 内层循环:每个左端点的
tmp集合大小 ≤ log₂(V) ≈ 23 - 每次计算
lcm(a,b) = a / gcd(a,b) * b,gcd 用欧几里得算法 O(log min(a,b))
总复杂度:O(n × log V × log V) = O(n × log²V)
代入数字:n = 10^5, log V ≈ 23,总操作约 10^5 × 23 × 23 ≈ 5×10^7,加上 set 操作的开销,在 1 秒内可以完成。
几个容易踩的坑
| 问题 | 说明 |
|---|---|
| LCM 爆 long long | lcm(a,b) 如果不剪枝,可能算出一个巨大的数(远超 10^18),直接溢出。必须在计算前就判断:如果 a / gcd(a,b) 已经大于 V / b,那结果必然 > V,直接跳过 |
| 为什么是 4256233 | 这是第 300001 个质数。它是一个安全的上界——任何大于它的 LCM 值对答案(mex)没有影响。比赛时如果记不住,设成 5×10^6 也能过 |
| set 去重 | 同一个 LCM 值可能由多个不同子区间得到,用 set 自动去重 |
代码核心(带详细注释)
#include<bits/stdc++.h>
using namespace std;
typedef long long ll;
const int N=2e5+5;
int n,a[N];
ll lcm(ll x,ll y)
{
return x*y/__gcd(x,y);
}
int main()
{
int t;
cin>>t;
while(t--)
{
cin>>n;
for(int i=1;i<=n;i++) cin>>a[i];
set<ll>s;
set<ll>tmp;
for(int i=n;i>=1;i--)
{
set<ll>new_tmp;
new_tmp.insert(a[i]);
for(auto v:tmp)
{
ll val=lcm(a[i],v);
if(val>1e5) continue;
new_tmp.insert(val);
}
tmp=new_tmp;
for(auto v:tmp) s.insert(v);
}
int ans=1;
while(s.count(ans)) ans++;
cout<<ans<<endl;
}
return 0;
}
思路回顾(一句话总结)
枚举所有左端点,利用"LCM 要么不变、要么翻倍"的性质,每个左端点最多只产生 ~23 个有用的 LCM 值。用 set 去重收集,最后找 mex 即可。
总结
| 题目 | 核心技巧 | 易错点 |
|---|---|---|
| 互质划分 | 相邻正整数互质;2 的倍数必分到不同堆 | n=1 要特判 |
| 出租车 | 每个乘客只看左右最近司机 | 边界(没有左/右司机)的处理 |
| 木雕玩具 | 排序去重 → 二分答案 → 贪心验证 | 去重别忘了;INF 要开够大 |
| 幽默数 | LCM 倍增性质;set 维护;mex | LCM 可能爆 long long 要剪枝 |
祝大家 CSP-J 取得好成绩!🎉
2024 CSP-J 普及模拟赛 2 —— 部分分题解
本文档针对每道题的不同数据范围,给出不用正解也能拿到对应分数的暴力/部分分做法。每份代码都是完整可运行的,且只瞄准对应 subtask 的数据范围。
目录
第一题:互质划分(coprime)
50 分:DFS 搜索(n ≤ 10)
思路
当 n ≤ 10 时,数据规模非常小。我们可以用深度优先搜索(DFS)来暴力尝试所有可能的分组方案:
- 依次处理数字 1, 2, 3, ..., n
- 对于当前数字 x,有两种选择:
- 把它放入一个已有的堆(前提:x 与堆内所有数都互质)
- 新建一堆,把 x 放进去
- 当 n 个数字全部分配完毕,统计用了多少堆,更新最小值
为什么能过? n=10 时,所有可能的分组方案数约为 Bell 数 B₁₀ = 115975,对于计算机来说一瞬间就能搜完。
图解
n = 3 时的搜索树:
开始(x=1)
|
新建堆 {1}
|
x=2 处理
/ \
放入{1} 新建堆{2}
(gcd=1 ✅) |
| |
{1,2} {1},{2}
| |
x=3 处理 x=3 处理
/ \ / \
放入 新建 放入{1} 放入{2} 新建
{1,2} 堆{3} (gcd=1) (gcd=1) 堆{3}
| | | | |
... ans=3 ... ... ans=3
完整代码
#include <bits/stdc++.h>
using namespace std;
int n, ans;
vector<vector<int>> groups; // groups[i] 表示第 i 堆里的数字
// 判断 x 是否能放入 groups[g]
bool can_put(int x, int g) {
for (int y : groups[g]) {
if (__gcd(x, y) != 1) return false;
}
return true;
}
// 当前处理到数字 x
void dfs(int x) {
// 剪枝:当前堆数已经 >= 已知最优,不必继续
if ((int)groups.size() >= ans) return;
// 所有数字处理完了
if (x > n) {
ans = min(ans, (int)groups.size());
return;
}
// 尝试 1:放入已有堆
for (int i = 0; i < (int)groups.size(); i++) {
if (can_put(x, i)) {
groups[i].push_back(x);
dfs(x + 1);
groups[i].pop_back(); // 回溯
}
}
// 尝试 2:新建一堆
groups.push_back({x});
dfs(x + 1);
groups.pop_back(); // 回溯
}
int main() {
freopen("coprime.in", "r", stdin);
freopen("coprime.out", "w", stdout);
cin >> n;
ans = n; // 最坏情况:每个数单独一堆
dfs(1);
cout << ans << endl;
return 0;
}
复杂度
- 时间复杂度:O(Bₙ × n²),Bₙ 是 Bell 数,n ≤ 10 时约 1.2 × 10⁵ 个状态,加上剪枝后更快
- 空间复杂度:O(n²),存储分组情况
能过哪些点
✅ n ≤ 10 的所有测试点(50%)
第二题:出租车(taxi)
40 分:暴力枚举(n, m ≤ 1000)
思路
当 n 和 m 都不超过 1000 时,最朴素的思路就能过:
对于每个乘客,遍历所有司机,找到距离最近的那个。
解决"距离相同时选坐标小的":在比较时,如果距离相等,选择坐标更小的司机。
图解
乘客在位置 4,枚举所有司机:
司机位置: 2 6 9
距离: |4-2|=2 |4-6|=2 |4-9|=5
↑ 和 2 一样近
选坐标小的 → 司机(位置 2)
→ 乘客(4) 归司机(2)
完整代码
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
int main() {
freopen("taxi.in", "r", stdin);
freopen("taxi.out", "w", stdout);
int n, m;
cin >> n >> m;
int total = n + m;
vector<ll> pos(total);
vector<int> type(total);
for (int i = 0; i < total; i++) cin >> pos[i];
for (int i = 0; i < total; i++) cin >> type[i];
// 收集所有司机的下标
vector<int> drivers;
for (int i = 0; i < total; i++) {
if (type[i] == 1) drivers.push_back(i);
}
// 对每个乘客,找最近的司机
vector<int> ans(m, 0);
for (int i = 0; i < total; i++) {
if (type[i] == 1) continue; // 跳过司机
int best_driver = -1;
ll best_dist = LLONG_MAX;
for (int j = 0; j < m; j++) {
int d = drivers[j];
ll dist = abs(pos[i] - pos[d]);
// 更近,或者距离相同但坐标更小
if (dist < best_dist ||
(dist == best_dist && pos[d] < pos[drivers[best_driver]])) {
best_dist = dist;
best_driver = j;
}
}
ans[best_driver]++;
}
for (int i = 0; i < m; i++) {
cout << ans[i] << " \n"[i == m - 1];
}
return 0;
}
复杂度
- 时间复杂度:O(n × m),当 n = m = 1000 时,约 10⁶ 次计算
- 空间复杂度:O(n + m)
能过哪些点
✅ n, m ≤ 1000 的所有测试点(40%)
70 分:司机在连续区间(特殊性质 30%)
思路
这个特殊性质说的是:所有司机在排序后的数组中处于一个连续的区间。也就是说存在 [L, R],使得位置第 L 到第 R 小的居民全是司机。
这种情况下有个很强的性质:
司机把数轴分成了"只归左边"和"只归右边"两段:L 左边的乘客全部归第一个司机,R 右边的乘客全部归最后一个司机。
对于区间内的乘客,每个乘客只会在左右两个相邻司机之间做选择。
图解
乘客 乘客 司机 司机 司机 乘客 乘客
←——全部归————→| |←——全部归——→
司机L 区间[L,R] 司机R
完整代码
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
int main() {
freopen("taxi.in", "r", stdin);
freopen("taxi.out", "w", stdout);
int n, m;
cin >> n >> m;
int total = n + m;
vector<ll> pos(total);
vector<int> type(total);
for (int i = 0; i < total; i++) cin >> pos[i];
for (int i = 0; i < total; i++) cin >> type[i];
// 给司机编号(按坐标排序后的顺序)
vector<int> driver_id(total, -1);
int cnt = 0;
for (int i = 0; i < total; i++) {
if (type[i] == 1) driver_id[i] = cnt++;
}
// 第一步:从左往右扫,记录每个乘客左边最近的司机
vector<int> left_driver(total, -1);
int last = -1;
for (int i = 0; i < total; i++) {
if (type[i] == 1) last = i;
else left_driver[i] = last;
}
// 第二步:从右往左扫,记录每个乘客右边最近的司机
vector<int> right_driver(total, -1);
last = -1;
for (int i = total - 1; i >= 0; i--) {
if (type[i] == 1) last = i;
else right_driver[i] = last;
}
// 第三步:每个乘客根据左右距离决定选谁
vector<int> ans(m, 0);
for (int i = 0; i < total; i++) {
if (type[i] == 1) continue;
if (left_driver[i] == -1) {
// 左边没司机 → 归右边
ans[driver_id[right_driver[i]]]++;
} else if (right_driver[i] == -1) {
// 右边没司机 → 归左边
ans[driver_id[left_driver[i]]]++;
} else {
// 两边都有,比较距离
ll distL = pos[i] - pos[left_driver[i]];
ll distR = pos[right_driver[i]] - pos[i];
if (distL <= distR)
ans[driver_id[left_driver[i]]]++;
else
ans[driver_id[right_driver[i]]]++;
}
}
for (int i = 0; i < m; i++) {
cout << ans[i] << " \n"[i == m - 1];
}
return 0;
}
复杂度
- 时间复杂度:O(n + m),两遍扫描即可
- 空间复杂度:O(n + m)
能过哪些点
✅ 司机在连续区间的所有测试点(30%)+ 暴力能过的点(40%)= 共 70%
第三题:木雕玩具(toy)
30 分:暴力枚举三位雕刻师的图案(n ≤ 10, aᵢ ≤ 100)
思路
当 n ≤ 10 且 aᵢ ≤ 100 时,我们可以直接枚举三位雕刻师分别选择什么图案:
- x, y, z 的取值范围:从 min(aᵢ) 到 max(aᵢ)(因为选在这个范围之外只会让距离更大)
- 一共有至多 100³ 种组合,考虑对称性(xyz 无序),实际只需枚举约 100³ / 6 ≈ 17 万种
- 对每一种组合,计算每个客人的等待时间(取三个雕刻师中最近的),取最大值
- 所有组合中取最小
为什么 x, y, z 只需在 [min(a), max(a)] 范围内枚举?
如果选了范围外的值,比如 x < 所有的 aᵢ,那把 x 改成 min(aᵢ) 不会让任何人的等待时间增加——所以最优解一定在范围内。
图解
a = [2, 5, 8],枚举 x=2, y=5, z=8
客人要图案 2 → 离 x=2 距离 0 ✅
客人要图案 5 → 离 y=5 距离 0 ✅
客人要图案 8 → 离 z=8 距离 0 ✅
最大等待 = 0
再试 x=3, y=5, z=8:
客人要图案 2 → min(|2-3|,|2-5|,|2-8|) = min(1,3,6) = 1
客人要图案 5 → min(|5-3|,|5-5|,|5-8|) = min(2,0,3) = 0
客人要图案 8 → min(|8-3|,|8-5|,|8-8|) = min(5,3,0) = 0
最大等待 = 1
完整代码
#include <bits/stdc++.h>
using namespace std;
int main() {
freopen("toy.in", "r", stdin);
freopen("toy.out", "w", stdout);
int n;
cin >> n;
vector<int> a(n);
for (int i = 0; i < n; i++) cin >> a[i];
int min_a = *min_element(a.begin(), a.end());
int max_a = *max_element(a.begin(), a.end());
int ans = INT_MAX;
// 枚举三位雕刻师的图案(x <= y <= z 减少枚举量)
for (int x = min_a; x <= max_a; x++) {
for (int y = x; y <= max_a; y++) {
for (int z = y; z <= max_a; z++) {
int max_wait = 0;
for (int val : a) {
int wait = min({abs(val - x),
abs(val - y),
abs(val - z)});
max_wait = max(max_wait, wait);
}
ans = min(ans, max_wait);
}
}
}
cout << ans << endl;
return 0;
}
复杂度
- 时间复杂度:O((max_a)³ × n) ≈ 100³/6 × 10 ≈ 1.7 × 10⁶
- 空间复杂度:O(n)
能过哪些点
✅ n ≤ 10, aᵢ ≤ 100 的所有测试点(30%)
50 分:频率数组优化(n ≤ 2000, aᵢ ≤ 100)
思路
和 30 分做法思路一致,但 n 扩大到了 2000。如果还用 O(n) 计算每个三元组的最大等待,总量级就是 17 万 × 2000 ≈ 3.4 亿,有点危险。
优化技巧: 注意 aᵢ ≤ 100,这意味着只有 1~100 这 100 种可能的图案值。我们可以用一个频率数组 freq[101] 统计每种图案出现了多少次,计算每个三元组时只需遍历 100 个可能值(跳过频率为 0 的)。
17 万 × 100 ≈ 1700 万,轻松通过!
完整代码
#include <bits/stdc++.h>
using namespace std;
int main() {
freopen("toy.in", "r", stdin);
freopen("toy.out", "w", stdout);
int n;
cin >> n;
// 频率数组:freq[v] = 图案 v 出现了多少次
int freq[101] = {0};
int min_a = 101, max_a = 0;
for (int i = 0; i < n; i++) {
int val;
cin >> val;
freq[val]++;
min_a = min(min_a, val);
max_a = max(max_a, val);
}
int ans = INT_MAX;
// 枚举三位雕刻师的图案
for (int x = min_a; x <= max_a; x++) {
for (int y = x; y <= max_a; y++) {
for (int z = y; z <= max_a; z++) {
int max_wait = 0;
// 只需检查 1~100 中出现过的图案
for (int val = 1; val <= 100; val++) {
if (freq[val] == 0) continue;
int wait = min({abs(val - x),
abs(val - y),
abs(val - z)});
if (wait > max_wait) max_wait = wait;
// 小剪枝:如果已经超过当前最优,提前跳出
if (max_wait >= ans) break;
}
ans = min(ans, max_wait);
}
}
}
cout << ans << endl;
return 0;
}
复杂度
- 时间复杂度:O((max_a)³ × 100) ≈ 1.7 × 10⁷,加上剪枝后更快
- 空间复杂度:O(100) = O(1)
能过哪些点
✅ n ≤ 2000, aᵢ ≤ 100 的所有测试点(50%,已包含 30% 的点)
第四题:幽默数(humor)
20 分:枚举所有子区间(n ≤ 100)
思路
当 n ≤ 100 时,子区间数量只有 100 × 101 / 2 = 5050 个。
最直接的做法:
- 枚举所有子区间 [l, r]:两层循环
- 计算每个子区间的 LCM:从左到右递推,
cur = lcm(cur, a[r]) - 把算出来的 LCM 值全部存进
set - 最后从 1 开始往上找第一个不在 set 中的数 → 答案
注意溢出: LCM 可能变得非常大(超过 long long 范围),一旦超过 1e12 我们就可以停止扩展这个左端点了,因为答案不可能那么大。
图解
a = [1, 2, 3],枚举所有子区间:
l=0: [1]→LCM=1, [1,2]→LCM=2, [1,2,3]→LCM=6
l=1: [2]→LCM=2, [2,3]→LCM=6
l=2: [3]→LCM=3
set = {1, 2, 3, 6}
从 1 开始找 mex → 4 ✓
完整代码
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
ll gcd(ll a, ll b) {
return b == 0 ? a : gcd(b, a % b);
}
ll lcm(ll a, ll b) {
return a / gcd(a, b) * b;
}
void solve() {
int n;
cin >> n;
vector<ll> a(n);
for (int i = 0; i < n; i++) cin >> a[i];
set<ll> all_lcm; // 收集所有出现过的 LCM
for (int l = 0; l < n; l++) {
ll cur = a[l];
all_lcm.insert(cur);
for (int r = l + 1; r < n; r++) {
cur = lcm(cur, a[r]);
// 超过 1e12 就停下,防止溢出且答案不可能这么大
if (cur > 1e12) break;
all_lcm.insert(cur);
}
}
// 找 mex
ll ans = 1;
while (all_lcm.count(ans)) ans++;
cout << ans << "\n";
}
int main() {
freopen("humor.in", "r", stdin);
freopen("humor.out", "w", stdout);
int T;
cin >> T;
while (T--) solve();
return 0;
}
复杂度
- 时间复杂度:O(T × n² × log C),n ≤ 100 时子区间共 5050 个,T ≤ 10,总计 5 万次 LCM 运算
- 空间复杂度:O(n²),存储所有 LCM 值
能过哪些点
✅ n ≤ 100 的所有测试点(20%)
40 分:LCM 剪枝优化(n ≤ 1000)
思路
n 扩大到 1000 后,子区间数量变成约 50 万个。如果对每个子区间都老老实实算 LCM,会不会超时?
关键观察: 固定左端点 l,当右端点 r 不断往右移动时,LCM 只会单调不减,而且一旦超过某个阈值(比如 5×10⁶),后续的 LCM 只会更大,对我们找 mex 没有帮助(因为 mex 最大也就是这个阈值附近)。
所以我们可以:
- 对每个左端点 l,如果当前的 LCM 已经 > 5×10⁶,直接
break掉,不用继续往右扩展 - 用
set去重存储
这样大大减少了有效计算的次数。
为什么阈值取 5×10⁶? 正解中已经证明:所有可能对答案有影响的 LCM 都不超过 4256233(第 3×10⁵+1 个质数)。我们保守一点取 5×10⁶。
完整代码
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
const ll LIMIT = 5000000; // LCM 超过这个值就不用再往下算了
ll gcd(ll a, ll b) {
return b == 0 ? a : gcd(b, a % b);
}
ll lcm(ll a, ll b) {
return a / gcd(a, b) * b;
}
void solve() {
int n;
cin >> n;
vector<ll> a(n);
for (int i = 0; i < n; i++) cin >> a[i];
set<ll> all_lcm;
for (int l = 0; l < n; l++) {
ll cur = a[l];
all_lcm.insert(cur);
for (int r = l + 1; r < n; r++) {
cur = lcm(cur, a[r]);
// 超过限制就停止扩展这个左端点
if (cur > LIMIT) break;
all_lcm.insert(cur);
}
}
// 找 mex
ll ans = 1;
while (all_lcm.count(ans)) ans++;
cout << ans << "\n";
}
int main() {
freopen("humor.in", "r", stdin);
freopen("humor.out", "w", stdout);
ios::sync_with_stdio(false);
cin.tie(0);
int T;
cin >> T;
while (T--) solve();
return 0;
}
复杂度
- 时间复杂度:O(T × n × log LIMIT),因为每个左端点最多扩展 log₂(LIMIT) ≈ 23 次就会超过阈值
- 空间复杂度:O(n × log LIMIT)
能过哪些点
✅ n ≤ 1000 的所有测试点(40%,已包含 20% 的点)
总结
| 题目 | 部分分 | 做法 | 核心技巧 |
|---|---|---|---|
| 互质划分 | 50% | DFS 搜索 | 回溯枚举所有分组 |
| 出租车 | 40% | O(nm) 暴力 | 对每个乘客枚举所有司机 |
| 出租车 | +30% | 连续区间特判 | 左右扫描找最近司机 |
| 木雕玩具 | 30% | 枚举 x,y,z | 值域小,三重循环 |
| 木雕玩具 | 50% | 频率数组优化 | 值域 ≤ 100,去重加速 |
| 幽默数 | 20% | 枚举所有子区间 | 递推计算 LCM + 溢出保护 |
| 幽默数 | 40% | LCM 剪枝 | 超过阈值立即停止 |
提示: 比赛时先通读所有题目,把能拿的部分分先拿到手,再去攻正解。稳扎稳打!
祝大家 CSP-J 取得好成绩!🎉
浙公网安备 33010602011771号