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 就错了)。

最终结论

\[\text{答案} = \begin{cases} 1 & n = 1 \\ \lfloor n/2 \rfloor & n > 1 \end{cases} \]

时间复杂度 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 时能过。

满分做法:

每个乘客只会选择左边最近的司机右边最近的司机

所以我们可以:

  1. 从左往右扫:记录"当前最近的左边司机"是谁
  2. 从右往左扫:记录"当前最近的右边司机"是谁
  3. 对每个乘客,比较到左右两个司机的距离,选择更近的那个

用一张图来理解:

司机(位置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,怎么判断三位雕刻师能否覆盖所有需求?

贪心策略:每位雕刻师尽量"往右靠"来覆盖更多图案

  1. 第一位雕刻师:既然 a_1 是第一个没被覆盖的,我们把 x 设为 a_1 + k(尽量往右,这样能覆盖更多右边的人)
  2. 第一位雕刻师的覆盖范围是 [x-k, x+k] = [a_1, a_1+2k],跳过所有在这个范围内的 a_i
  3. 找到下一个没被覆盖的 a_i,第二位雕刻师选择 y = a_i + k
  4. 同理找到第三个没被覆盖的 a_j,第三位雕刻师选择 z = a_j + k
  5. 如果三轮过后还有没覆盖的 → 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,有两种选择:
    1. 把它放入一个已有的堆(前提:x 与堆内所有数都互质)
    2. 新建一堆,把 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 个。

最直接的做法:

  1. 枚举所有子区间 [l, r]:两层循环
  2. 计算每个子区间的 LCM:从左到右递推,cur = lcm(cur, a[r])
  3. 把算出来的 LCM 值全部存进 set
  4. 最后从 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 最大也就是这个阈值附近)。

所以我们可以:

  1. 对每个左端点 l,如果当前的 LCM 已经 > 5×10⁶,直接 break 掉,不用继续往右扩展
  2. 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 取得好成绩!🎉

posted @ 2026-07-08 08:13  surprise_ying  阅读(52)  评论(0)    收藏  举报