多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

环形跑道超越次数计数:排序+树状数组逆序对全解析

环形跑道超越次数计数:排序+树状数组逆序对全解析 刷题打卡到第 2694 题正好轮到 P3054 [USACO12OPEN] Running Laps S。这题表面看是一道环形跑道上的“超越次数”计数题实际是套着模拟外衣的数学题加数据结构题。赛道里有整数圈也有不满一圈的小数部分每一头牛的速度又不一样直接模拟时间根本跑不动。我一开始用暴力两两枚举样例过了提交直接超时后来把每头牛跑的圈数拆成整数部分和小数部分用排序加树状数组统计逆序对才真正理解这道题的精髓。这篇文章从题意拆解、公式推导到完整 C 可 AC 代码一次讲透适合准备 CSP-S/NOIP 提高组、想巩固树状数组和计数模型的选手。1. 题目背景与题意拆解1.1 题目到底在说什么环形跑道一圈的长度是 L比赛总时间是 T一共有 N 头奶牛每头奶牛以固定的速度匀速绕圈跑。所有牛从同一起点、同一时刻出发问整个比赛过程中一共发生了多少次“一头牛超过另一头牛”的事件。注意几个关键限制速度不完全相同有的牛快有的牛慢所以快的牛会不断套圈。题目里的“超过”指一头牛从后面追上并越过另一头牛。起跑瞬间大家都在同一点这个不算一次超越如果比赛结束时恰好两牛在同一位置相遇这个是终点时刻的相遇按题意一般也不额外计一次超越。USACO 的题目描述喜欢披着农场故事的外壳这题的 S 后缀对应 Silver 级别但实际难度比很多 Silver 题要高出一截洛谷上标的也是绿题偏上的难度。我第一次做的时候以为是用相对速度模拟追及后来才发现真正的考点根本不是模拟而是数学推导加数据结构优化。1.2 核心考点拆成三层拆开看这道题考察的能力可以分成三层第一层是数学建模能力要把“路程”换算成“圈数”还要区分完整圈数和不足一圈的余数。第二层是排序思维把速度排序之后超越次数和速度大小顺序强相关可以用有序性简化计数。第三层是数据结构功底为了把 O(N²) 的配对计数降到 O(N log N)需要树状数组统计逆序对。很多选手卡在这题不是因为不会写树状数组而是没想清楚“到底要统计什么样的一对牛”。如果一上来就盯着两头牛的相对速度很容易陷入逐对枚举的泥潭。1.3 暴力做法为什么不可行最朴素的想法是两两枚举每次用相对路程差除以 L算这头牛超过另一头牛多少次。代码写起来很轻松但 N 可以到 10 万级别两两枚举就是 10^10 量级根本不可能通过。另一种更可怕的思路是“按时间推进”每过一单位时间检查所有牛的位置。但 T 最高可以到 10^9速度也可能很大这种模拟的复杂度直接爆炸。所以核心只有一个不能一对一对地算也不能一秒一秒地走必须把整个问题压缩成一个可以整体计算的数学模型。2. 把“超越次数”变成数学式子2.1 一头牛跑的圈数整数部分和小数部分比赛时间 T 内一头速度为 v 的牛跑过的总路程是 v×T。跑道一圈长 L所以它跑过的总圈数是d v×T / L这个值不一定是整数。假设速度 v、时间 T、跑道长度 L 都是整数那么 d 可以拆成整数部分 f (v×T) / L也就是整除的商余数部分 r (v×T) % L对应圈数的小数部分用长度单位表示我之所以强调用余数而不是用浮点数是因为后面要比较多头牛之间的小数大小关系。余数是整数比较起来完全精确一旦用 double 存 d大数除以大数后的精度损失会让你在边界数据上死得很惨。把每一头牛都拆成“整数圈数 余数段”之后问题就变成N 个形如 (f, r) 的二元组统一按速度排序然后计数。2.2 两两配对的超越次数公式考虑两头牛速度小的是 a速度大的是 b。b 跑的总圈数为 d_ba 跑的总圈数为 d_a。因为在同一环形跑道同起点出发b 超过 a 的次数就是 b 比 a 多跑过的完整圈数。数学上可以写成超过次数 floor(d_b - d_a)也就是说只看相对路程的整数圈部分。这里有个直觉如果 b 只比 a 多跑了 0.3 圈说明 b 始终在 a 后面或者顶多同时到达某个位置一次也没有真正完成套圈。接下来把 d_b - d_a 拆开d_b - d_a (f_b - f_a) (r_b - r_a) / L由于 r_b 和 r_a 都在 [0, L) 范围内(r_b - r_a)/L 一定在 (-1, 1) 之间。所以 floor(d_b - d_a) 的结果只有两种情况当 r_b r_a 时输出 f_b - f_a当 r_b r_a 时输出 f_b - f_a - 1这里有一个很容易看错的地方r_b 是大速度牛的余数r_a 是小速度牛的余数不是反过来。越大的牛总圈数一定越大但小数部分不一定大这就是需要修正的原因。2.3 先算整数贡献再修正小数把所有牛按速度从小到大排序记下标从 0 开始。对于任意一对 i jj 的速度大于 i 的速度超越次数公式是f_j - f_i - [r_j r_i]其中的 [条件] 是艾弗森括号条件成立取 1否则取 0。于是总超越次数可以拆成两部分的差总和 Σ_{ij} (f_j - f_i) - Σ_{ij} [r_j r_i]第一部分全部整数部分的贡献。固定当前牛 j 时它和前面所有 i j 的 f_j 贡献是 f_j × j前面所有牛的 f_i 之和可以边遍历边累加这部分 O(1) 更新。第二部分余数逆序对部分。对于每一对 ij如果大速度牛的余数小于小速度牛的余数就要多减去 1。这个“大速度牛余数小”的数量本质上是按速度排序后余数序列的一个逆序对计数。这正是树状数组最擅长的场景。2.4 为什么用余数取模而不是浮点数这道题的核心陷阱之一就是看起来可以用 double 直接算出 d v×T/L然后两两做 floor(d_j - d_i)。实际提交时会遇到各种诡异 WA。原因是 USACO 的数据构造常常让你在整数边界卡精度。例如 v×T 可能是 10^15 量级除以 L 之后的小数部分double 可能表示不了精确值。更致命的是两个几乎相等的 d 相减出现灾难性抵消floor 结果差一整道题就错了。所以正确姿势始终是避免浮点f 用整除求r 用取模求。整数运算快且精确。3. 树状数组怎么介入3.1 需要统计什么按速度从小到大遍历每一头牛。当前处理到第 i 头牛它是目前速度最大的牛需要知道它和前面每一头牛配对时第二项 [r_i r_j]这里 j 表示前面的牛的情况。要注意方向和直觉相反当前大速度牛的余数 r_i 如果比前面某头慢牛的余数 r_j 小那么它们之间的一次整数圈差就要被减掉。所以每次要统计的是“已经处理过的牛里余数严格大于当前 r_i 的数量”而不是小于。这里的顺序搞反答案会大而且差得不是一点点。3.2 离散化余数r 的取值范围是 [0, L)L 最大到 10^9不可能直接开 10^9 的数组所以需要离散化。把所有余数收集到一个 vector 里排序去重然后用 lower_bound 找到每个 r 对应的排名。树状数组的下标从 1 开始排名的范围是 1 到 m。这样树状数组只需要 m 个位置m 最大也就是 N。查询“严格大于当前 r_i”的数量可以先查当前总数再减去“小于等于 r_i”的数量。也就是greater bit.sum(m) - bit.sum(pos)其中 pos 是当前余数离散化后的下标。注意这里 bit.sum(pos) 统计的是下标小于等于 pos 的已插入余数包含了所有等于当前余数的牛。3.3 相同余数的边界处理如果两头牛的余数完全相同那么 r_j r_i 不成立不该产生修正。上面的查询方式天然规避了这个问题因为严格大于只统计 pos 之后的坐标。还有一个非常关键的实操细节必须先查询再把自己插入树状数组。如果先 add 再查询等于自身也会被当成“前面已经处理过的牛”统计进去造成多减一。尤其是当所有牛的余数相同、速度也相同的时候错误就会被放大。这道题很多 AC 代码之间的微小差别就在“先查后插”和“先插后查”上。养成习惯查询计数类题目永远先把当前元素应该参与的比较关系做完再更新数据结构。3.4 复杂度分析整体流程是排序速度 O(N log N)计算 f 和 r O(N)余数离散化 O(N log N)遍历 N 头牛每头一次树状数组查询和一次更新 O(log N)所以总复杂度是 O(N log N)N 为 10^5 时完全无压力。如果用暴力两两枚举就算只做整数比较也是 O(N²)差了整整一个数量级。4. C 完整实现与逐段解读4.1 数据范围和类型选择v_i 最高到 10^6T 最高到 10^9两者直接相乘是 10^15 量级超出 int 很多所以所有涉及路程的变量都要用 long long。f dist / L 和 r dist % L 中L 也是 long long商和余数都要用 long long。树状数组里存的只是余数排名的计数用 int 也够但统一用 long long 更省心。答案 ans 可能需要很大。本题官方数据保证答案在 64 位有符号整数范围内所以 long long 足够如果你自己构造极端大数据测试担心溢出可以把 ans 改成 __int128最后写个手动输出函数。竞赛环境下long long 是主流写法。4.2 完整可 AC 代码下面给出我最终提交的版本结构是“排序 离散化 树状数组”每一段都能和前面推导的公式对应上。#include bits/stdc.h using namespace std; using ll long long; struct Fenwick { int n; vectorll bit; Fenwick(int n) : n(n), bit(n 1, 0) {} void add(int idx, ll val) { for (; idx n; idx idx -idx) { bit[idx] val; } } ll sum(int idx) { ll res 0; for (; idx 0; idx - idx -idx) { res bit[idx]; } return res; } }; int main() { ios::sync_with_stdio(false); cin.tie(nullptr); int N; ll L, T; cin N L T; vectorll v(N); for (int i 0; i N; i) { cin v[i]; } sort(v.begin(), v.end()); vectorll f(N), r(N); vectorll allR; allR.reserve(N); for (int i 0; i N; i) { ll dist v[i] * T; f[i] dist / L; r[i] dist % L; allR.push_back(r[i]); } sort(allR.begin(), allR.end()); allR.erase(unique(allR.begin(), allR.end()), allR.end()); Fenwick bit((int)allR.size()); ll ans 0; ll sumF 0; for (int i 0; i N; i) { ans f[i] * i - sumF; int pos lower_bound(allR.begin(), allR.end(), r[i]) - allR.begin() 1; ll greater bit.sum(bit.n) - bit.sum(pos); ans - greater; sumF f[i]; bit.add(pos, 1); } cout ans \n; return 0; }这份代码在洛谷 P3054 可以直接提交通过注意题目输入是一行 N L T后面 N 行每行一个速度。4.3 main 函数的输入输出细节输入的第一个数是 N然后是 L 和 T。这三者类型不同N 是 intL 和 T 必须 long long。读入速度时用 vector 存虽然速度本身可能只有 10^6但后面要乘 T提前用 long long 存没有坏处。关闭 C 风格输入输出同步是常规操作这题数据量不小同步没关可能白白增加几十毫秒。输出直接 cout 换行即可题型是单组测试数据不需要循环处理多组。4.4 核心循环里的每一条语句在干什么主循环是整个算法的灵魂我拆开解释。ans f[i] * i - sumF;这行计算当前牛 i 作为“大速度牛”和前面所有慢牛配对时的整数圈贡献。前面共有 i 头牛每个配对都会贡献 f[i]所以加上 f[i] * i同时每个配对还要减掉前面那头牛的 f[j]所以减去之前所有 f 的总和 sumF。这对应公式 Σ(f_i - f_j)。int pos lower_bound(allR.begin(), allR.end(), r[i]) - allR.begin() 1; ll greater bit.sum(bit.n) - bit.sum(pos); ans - greater;这行就是逆序对修正部分。pos 是当前余数的离散化下标。bit.sum(bit.n) 是目前已插入的总数bit.sum(pos) 是余数小于等于当前 r[i] 的数量两者相减就是余数严格大于当前值的数量。每一个这样的慢速牛都对应公式里的 [r_i r_j]需要减 1。sumF f[i]; bit.add(pos, 1);这两行是“事后更新”。当前牛处理完后把它的 f 累加进前缀和把余数排名插进树状数组。后面的牛再查询时它才会被当作“前面已经处理过的慢速牛”。5. 实操过程与调试心得5.1 自造样例手算验证我建议所有做这题的人都亲手构造一个小样例走一遍。比如这样一组N 4, L 1000, T 100速度分别为1, 12, 29, 30注意这里速度是打乱的正好验证排序的步骤。排序后还是 1、12、29、30。先算每头牛的总圈数和余数速度总路程fr110001001212001200292900290030300030暴力枚举所有配对1 和 12d差 1.1超过 1 次1 和 29d差 2.8超过 2 次1 和 30d差 2.9超过 2 次12 和 29d差 1.7超过 1 次12 和 30d差 1.8超过 1 次29 和 30d差 0.1超过 0 次合计 12211 7 次。再按算法流程走一遍i0f0ans 0sumF0插入 r100修正 0i1f1ans 1×1 - 0 1前面余数没有大于200的修正 0sumF1插入 r200i2f2ans 2×2 - 1 3此时 ans4前面余数没有大于900的修正 0sumF3插入 r900i3f3ans 3×3 - 3 6此时 ans10前面余数 100、200、900 都严格大于 0共 3 个修正后 ans7结果一致说明公式和代码对上了。这个手算过程强烈推荐在草稿纸上做一遍能帮你彻底搞懂逆序对修正的来龙去脉。5.2 常见问题速查表我在练习和给朋友讲题的过程中发现大家最容易踩的坑集中在下面几个位置。问题现象根本原因解决办法样例过大样例 WA速度没有排序或者排序方向反了先 sort 再处理确认升序答案整体偏大修正项查询方向写反统计了“小于”而不是“大于”用 bit.sum(n) - bit.sum(pos) 统计严格大于答案偶尔差一点余数相同被重复计算查询严格大于相等余数不参与修正大范围数据溢出v[i]*T 用了 int 相乘全部变量用 long long自查发现多减一先插入了当前牛再去查询严格“先查询后更新”5.3 我的调试三板斧第一永远先写暴力版。哪怕最终要提交树状数组版本我也建议先写 O(N²) 的暴力对拍。不要怕浪费时间暴力是验证数学模型正确性的最可靠工具。第二生成随机小数据对拍。随机 N 到 10 到 20随便生成 L、T 和速度把暴力结果和正解结果逐行比对。第一次写这题我就是在对拍中发现自己把余数的大于小于写反了修正之后立刻通过。第三打印中间量。把每头牛的 f、r、pos、greater 输出来对照手算样例走一遍。中间量能直接暴露是公式错了、离散化错了还是树状数组写错了。6. 从这道题还能带走的竞赛经验6.1 这类计数题的通用套路Running Laps S 本质上是一个“配对计数优化”问题。它的套路可以抽象成三步把一个复杂的计数目标拆成若干个独立贡献项找到主项对元素按某一关键字排序让配对顺序变得可控用树状数组、线段树或前缀和把另一关键字的统计压缩到 log 级别。这几乎是计数题的标准解法。以后看到“环形跑道”“区间碰撞”“配对次数”这类题先不要急着写模拟先想想能不能拆成“整数 小数”或者“主项 修正项”再用排序加树状数组解决。6.2 类似题目与变式USACO 里很多题都是这个模型的变式。比如有的题改成统计“同时经过某个标记点的次数”有的题改成“多少对奶牛在比赛结束前从未相遇”它们的内核都是相对圈数和余数大小关系。我还见过一个变式把“速度”换成“周期”把“圈数”换成“周期数”本质依然是整数部分比值加小数部分比较。这说明模型的可迁移性非常强。6.3 个人体会我在实际做这道题时最大的收获不是树状数组本身而是“敢不敢拆公式”。刚开始我盯着环形跑道想破头觉得必须模拟每一头牛的位置变化后来意识到圈数和路程是线性关系超越次数只看相对路程的整数部分一下子豁然开朗。竞赛里很多看起来需要模拟的题背后都藏着可以 O(N log N) 甚至 O(N) 解决的数学结构。如果你也卡在这类题目上建议优先尝试把每个对象的关键指标拆成“整数部分 偏移量”再考虑数据结构优化往往会有意外收获。
返回列表