☰
环形调度模拟题精解:Balloon Robot的数学建模与排序优化
2026/10/14 22:52:55 网站建设 项目流程

第一次看到编号3981的这道题时,我一开始是有点轻敌的。题名叫 Balloon Robot,标签干干净净写着“模拟”,我心想:模拟题嘛,照着题意把机器人绕圈、发气球的过程跑一遍,最多注意一下边界条件。结果真动手写才发现,这题根本不是让你“跑过程”,而是要把整个环形运动过程抽成数学表达式,再用排序加枚举的方式去优化。如果你和我一样,刚开始刷竞赛模拟题,这道题非常适合拿来纠正对“模拟”二字的理解。

简单说,题目描述的是ACM比赛现场最常见的画面:很多队伍围着一圈桌子坐,每过一段时间有队伍AC,需要机器人沿环形路线送气球。机器人从某个位置出发,匀速绕圈,每到一个队伍位置就把该队攒下的气球一次性发掉。问题是,我们可以任意选择机器人的出发时间,让所有队伍等待气球的总时间最小。下面我会从题意细节、数学建模、优化思路、代码实现到踩坑记录,完整过一遍这道题的思考路径。

1. 气球机器人在比赛现场到底做了什么

1.1 从一场比赛说起

先想象一个具体的场景。比赛场地不是一排排座位,而是很多队伍围成一圈,座位编号从1到m顺时针排列。题目里n支队伍分别坐在其中某些位置上,同一个位置可能有队伍也可能没有,但每支队伍的位置是固定的。比赛进行过程中,某个队伍在某个时刻AC了一道题,他对应的那个座位上就会“产生”一个等待被送达的气球。

场地里有一个机器人,从0时刻开始出发,每过1分钟移动到下一个位置。它走到某个队伍的位置时,会把该队伍从上一轮之后积压的所有气球一次性全部发掉。假如某个队伍在第3分钟AC,而机器人第4分钟正好路过这个位置,那他的等待时间是1分钟;如果机器人刚在第2分钟路过,第3分钟这个队伍才AC,那机器人要到下一圈,也就是第m+2分钟才会再来,等待时间就变成了m-1分钟。

这里有一个关键点:一个队伍如果连续AC了两道题,机器人一次路过时可以同时发两个气球,但我们的目标是让所有单次AC的等待时间总和最小,所以可以把每个AC事件分开来算。因为每次AC的气球都是独立等待的,合并发送不影响总等待时间,分开算反而更清晰。

1.2 输入数据到底怎么给

这题的输入格式很直白。第一行是一个整数T,表示测试数据组数。每组数据第一行有三个整数n、m、q,分别表示队伍数量、环形座位总数、AC事件总数。接下来一行是n个整数,表示第i支队伍坐在哪个座位编号上。再往下有q行,每行两个整数team和time,表示编号为team的队伍在time这个时刻AC了一道题。

输出只要求一个整数:在所有可能的机器人出发时间中,能让总等待时间最小的是多少。

这里的出发时间可以理解为:机器人本来默认0时刻从座位1出发,我们可以选择让它延后x分钟再出发,x的范围是0到m-1。超过m分钟没有意义,因为机器人绕一圈之后又会回到一模一样的相对状态。

1.3 为什么它是“模拟”而不是别的算法

很多同学看到“模拟”标签,会直接写一个while循环让机器人一步一步走,把每个事件发生的时间点存进队列,再模拟送气球过程。这种做法如果m很小还能凑合,但这题m和q都能到1e5级别,直接按时间轴一步步走,复杂度会爆炸。

实际上,这道题的“模拟”指的是模拟一个环形周期过程:机器人不断绕圈,事件不断发生,我们要做的就是找到一个最优的“相位偏移”,让所有事件发生时刻和机器人到站时刻尽量对齐。它不像贪心题那样有一个局部最优决策,也不像DP题有状态转移,它更接近一个“把所有事件的时间戳和位置信息统一映射到模m的剩余类里,然后枚举最优偏移”的数学模拟题。

所以我的建议是:遇到这种带“模拟”标签但数据范围又很大的题,先别着急写循环,先想想能不能把每个独立事件化简成一个关于偏移量的函数。只要能写成函数,下一步就可以考虑用排序、前缀和或者差分来优化。

2. 把环形追及问题写成数学式子

2.1 位置编号和时间轴怎么对齐

从座位1出发,机器人每分钟走一格。那么0时刻它在座位1,1时刻它在座位2,2时刻它在座位3……以此类推。更准确地,它到达座位p的时刻,永远满足一个条件:这个时刻对m取模的结果等于p-1。

举个例子,m=10,座位5。机器人到达座位5的时刻是4、14、24……这些数字对10取模都是4,而p-1也是4。反过来,如果某个事件发生在第4分钟,队伍坐在座位5,那机器人立刻就能送达,因为它的到达序列里正好有一个4。

这就是环形运动题目最常见的处理方式:把“位置”转换成一个“时间剩余类”。位置p对应的剩余类是p-1 mod m。一旦建立了这个对应关系,后面所有计算都可以在模m的整数环上进行。

2.2 每个事件默认状态下的等待时间

现在先用默认情况算一下:机器人0时刻从座位1出发,不调整任何东西。对于第i个AC事件,队伍位置是pos,AC时间是time。机器人最早能送达这个气球的时刻,应该是到达序列里第一个不小于time的、且对m取模等于pos-1的数。

设d_i = (pos - 1 - time) mod m,并且把这个差值规范到0到m-1之间。如果这个差值本身就是非负的,那机器人从事件发生之后再过d_i分钟就能到达,等待时间就是d_i。如果差值是负数,比如pos-1小于time,那么机器人需要绕一圈回来才能送,等待时间就是差值加上m,正好也等于规范到0到m-1之后的d_i。

所以结论很漂亮:在默认出发状态下,第i个事件的等待时间就是d_i。这个d_i不依赖其他任何事件,只跟队伍位置和AC时间有关。于是初始总等待时间是所有d_i之和。

2.3 调整出发时间等价于整体平移

现在我们要考虑机器人延后x分钟出发。这个操作会带来什么变化?可以把延后出发理解成:所有事件的AC时间相对机器人而言都提前了x分钟。原本事件时间为time,现在就等价于time减x。于是新的等待时间是:

wait_i(x) = (pos - 1 - (time - x)) mod m = (d_i + x) mod m

注意这里取模结果要规范到0到m-1之间。这个式子非常关键,它说明每个事件的等待时间都只是同一个x作用在各自的d_i上。x从0变到m-1,就是在枚举机器人的所有可能出发相位。

有人可能会问,为什么不能是(pos - 1 - time - x)呢?因为延后机器人出发,会让它到达每个位置的时间整体变晚。对事件而言,相当于事件时间提前了,所以AC时刻减去x是对的。

2.4 直接枚举为什么不行

如果直接枚举x从0到m-1,每个x重新算一遍所有q个事件的等待时间,复杂度是O(mq)。当m和q都是1e5时,这就是1e10次运算,显然不能接受。

必须要利用wait_i(x)这个函数的性质。观察一下,每一项wait_i(x)都是随着x从0增加到m-1而线性增长,碰到模m边界就跳回0。整个总等待时间W(x)是一个分段线性函数,每一段内部都是斜率为q的直线,在边界处会突然掉下来。这个性质给了我们优化空间。

3. 排序加断点枚举:让复杂度从O(mq)降到O(q log q)

3.1 W(x)是一个分段线性函数

先看单个事件,wait_i(x) = (d_i + x) mod m。当x从0开始增加时,这个值先是匀速变大,一直大到m-1,然后下一个瞬间跳回0。每个事件跳回0的时刻,正好是x = m - d_i的时候,也就是d_i + x恰好等于m的时候。

对总和W(x)来说,当x加1时,正常情况下q个事件每个都加1,总和增加q。但如果某些事件恰好在这个x变化的过程中到达了模m边界,也就是它们的值从m-1跳回0,那么它们没有增加,反而减少m。于是W(x)在每个步进处的变化量是:

W(x+1) - W(x) = q - m * cnt

其中cnt是这一次步进中发生跳变的事件数量。

因为q和m都是恒定值,而cnt只有某些特殊x取值时才不为0,所以W(x)的每一段内部其实是一条斜率为q的直线。直线是单调递增的,每一段的最小值一定落在这一段的左端点。而每一段的左端点,恰好就是某个事件刚刚跳变回0的那个x。

3.2 最优出发点只可能在q个临界点里

这个性质直接告诉我们:不需要检查所有x。只需要检查那些让某个事件等待时间恰好为0的x,也就是x = (m - d_i) mod m。一共有q个这样的候选点。

那x=0有没有可能成为最优?当然有可能。如果没有任何一个d_i使得x=0是临界点,那x=0就是第一段直线的左端点,也可能是最小值。所以稳妥起见,把初始总等待时间sum = Σd_i作为答案的初值。

现在问题变成:对每个d_i,当x = m - d_i时,W(x)等于多少?我们需要快速计算。

3.3 从公式到可以AC的代码

把所有的d_i排序,从小到大。现在固定考虑某个d值,记为cur。令x = m - cur。

对任意一个事件j,如果d_j >= cur,那么(d_j + x) mod m = d_j - cur,因为d_j + m - cur不会超过2m,而d_j >= cur时,结果落在0到m-1。如果d_j < cur,那么d_j + m - cur会超过m,取模后是d_j - cur + m。

也就是说,小于cur的事件,每一项结果都要额外加一个m;大于等于cur的事件,结果直接是d_j - cur。假设排序后严格小于cur的事件一共有less个,其他事件有q - less个,那么总等待时间是:

W = sum + less * m - cur * q

其中sum是所有d_i的和。

这个公式非常简洁。计算时先排序,然后依次枚举每个不同值,用当前下标作为less数量计算W,取最小值。注意排序后的重复值要小心处理,后面我会专门讲。

3.4 核心代码实现

输入输出部分就直接按前面说的格式来写。核心思路是先算出所有d_i,排序后枚举不同的d值。完整代码如下:

#include <bits/stdc++.h> using namespace std; typedef long long ll; ll norm_mod(ll x, ll m) { return (x % m + m) % m; } int main() { ios::sync_with_stdio(false); cin.tie(nullptr); int T; cin >> T; while (T--) { int n, m, q; cin >> n >> m >> q; vector<int> pos(n + 1); for (int i = 1; i <= n; i++) { cin >> pos[i]; } vector<ll> d(q); ll sum = 0; for (int i = 0; i < q; i++) { int team, time; cin >> team >> time; d[i] = norm_mod(pos[team] - 1 - time, m); sum += d[i]; } sort(d.begin(), d.end()); ll ans = sum; for (int i = 0; i < q; i++) { if (i > 0 && d[i] == d[i - 1]) continue; ll cur = sum + 1LL * i * m - 1LL * q * d[i]; ans = min(ans, cur); } cout << ans << '\n'; } return 0; }

代码里最关键的是这一行:

sum + 1LL * i * m - 1LL * q * d[i]

因为d已经排好序,而且我们只在第一次出现某个d值时计算,所以此时i正好是严格小于当前d值的事件数量。这样既不会漏掉候选点,又避免了重复值带来的多余计算。

4. 我调这道题时踩过的四个坑

4.1 位置减一:队伍编号和坐标别搞混

我最开始写d_i计算时,写的是pos[team] - time,结果样例都过不了。原因是座位1对应的到达时刻是0,不是1。机器人0时刻在座位1,1时刻到座位2,所以座位p对应的剩余类是p-1。

这个错误的隐蔽之处在于:如果pos刚好等于time加1,结果看起来是对的,但换到其他位置就全乱了。所以拿到这种环形位置题,第一步就是在草稿纸上写下“位置p对应时刻p-1 mod m”,然后再写代码。

4.2 C++负数取模的隐蔽bug

C++的%运算符对负数的结果也是负数,不是数学上的模运算。比如-3 % 10在C++里是-3,而我们希望它是7。如果直接拿这个结果去排序,会产生一堆负数,整个枚举公式就全错了。

所以要写一个规范化的取模函数:

ll norm_mod(ll x, ll m) { return (x % m + m) % m; }

这个写法比单独加一次m更安全,因为x可能远小于-m,只加一次m仍然可能是负数。

4.3 long long:不看数据范围必WA

q最大到1e5,m最大到1e5,一个d_i可能到1e5,sum就可能到1e10。这早就超过int的范围了。更隐蔽的是枚举公式里的q * d[i],即使sum用long long,这两个int相乘也会先溢出再赋值。

解决办法是计算时强制转long long。我代码里用的是1LL * q * d[i],这一行绝对不能省。我见过有人用int存sum,样例过了,一到大数据就WA,查半天查不出原因,其实就是这里爆了。

4.4 重复d值会影响断点枚举

排序之后可能存在大量相同的d值。固定某个d值cur时,less应该是严格小于cur的事件数量。如果枚举重复值时也直接用下标计算,那么重复值段中间的枚举结果会比真实值偏大,因为有些等于cur的事件被错误地加上了m。

比如d排序后是1、5、8、8,枚举到第二个8时,下标是3,但严格小于8的只有2个。公式就会多算一个m,导致结果偏大。不过因为真实的最小值在第一个8那里已经被枚举过了,所以就算不跳过重复值,最终ans还是对的。但为了逻辑严谨,我建议只在每个d值第一次出现时计算,这样下标正好等于严格小于数量。

我代码里的写法是:

if (i > 0 && d[i] == d[i - 1]) continue;

这就是一个很低成本但能避免误导性结果的习惯。

5. 这类环形调度题给我的启发

5.1 环形运动题的通用建模套路

做完这道题之后,我对所有“环形运动+事件时间”的题都有了一个固定套路:先把位置的剩余类和事件的绝对时间对齐,算出每个事件的基准偏移;然后寻找一个全局偏移变量,把每个事件表示成关于这个偏移量的函数;最后观察函数形态,用排序或差分优化。

这个方法不只适用于送气球,还适用于很多实际问题。举个例子,环形班车到站问题:一条环线公交在固定站点接人,每个人有到达站点的时间,问怎么调整发车时间让总等待最小。建模方式几乎一模一样,先把每个人的到达时间映射成站点编号对应的剩余类,再枚举班车的出发偏移。

5.2 如果题面改一改

做完这道题之后,我试着自己改了一些条件来加深理解。如果机器人允许逆时针走,问题就变成了每个事件可以选两个方向中的较优方向,最优偏移不再是所有d_i的简单函数,而是要额外考虑方向选择,复杂度会上升不少。

如果每个队伍有多个不同的位置,或者机器人一次只能带一个气球,那每次路过时是否需要停下、是否需要返回基地补给,都会让问题从纯数学变成真正的调度问题。那时候就需要贪心或网络流了。

这些变体说明,Balloon Robot本身其实是这一类“环形相位优化”问题里最基础、最干净的一个版本。理解了它,再看后续变体会有很清晰的坐标参考。

5.3 我现在看到这类题的解题习惯

最后分享一个我自己刷完这道题之后养成的习惯:看到“环形运动”“时间戳”“求最小总等待”这几个关键词,我不会再急着写一个大循环去模拟整个比赛过程,而是先花十分钟在草稿纸上写出每个事件相对默认状态的偏移量,再画出总等待时间随全局偏移变化的函数图像。

这个习惯帮我解决了不少看起来像大模拟、实际本质是“排序+断点枚举”的题。Balloon Robot这道题最大的价值,不是让你记住那个sum加i乘m减q乘d的公式,而是让你理解一个道理:很多模拟题的真正难点不在于过程本身,而在于怎么把过程折叠成一个可以快速枚举的数学对象。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询