☰
差分数组入门:从USACO Tallest Cow看区间覆盖与前缀和优化
2026/10/2 18:02:28 网站建设 项目流程

说个有点暴露年龄的事——让我彻底吃透差分数组的,不是哪本算法书,而是USACO 2007年10月黄金组里的Tallest Cow。题面讲的是牛,实际考的是区间覆盖和前缀和还原。当时我用了最直白的做法:拿到一对关系,就把中间每一头牛的身高减一,样例没问题,交上去只拿了一半的分。后来我才明白,USACO的黄金组从来不考你背模板,它考的是你能不能把一段农场故事翻译成一个干净的数据结构模型。

这篇文章就把这一题的完整拆解写出来,包括差分为什么能一刀砍掉O(NR)的复杂度、重复区间和端点到底怎么处理,以及从这一题延伸出去的黄金组刷题思路。不管你是准备USACO银升金,还是刷算法题时想补差分这块,这篇都值得看完再动手。

1. 2007年10月这一场:为什么我只拿Tallest Cow当主线

1.1 那个年代黄金组在考什么

先交代一下背景。2007年的USACO月赛是很多老选手记忆里的“黄金年代”:每个月一场,分Bronze、Silver、Gold三个组别,每组一般三道题,限时四小时左右。题目描述普遍很短,没有太多花哨的包装,数据范围也克制,不像现在有些竞赛题恨不得给你嵌套三层故事线。

当时的黄金组,算法考察范围相当固定:差分、前缀和、简单DP、最短路、最小生成树、二分答案、贪心排序、基础字符串处理,大概就这些。难点不在于“某个算法你没学过”,而在于“你看不出来这道题该用哪个算法”。不少人在银组能靠模板打天下,一上黄金组就现原形,原因也在这里:模板是死的,题目里那个牛棚、围栏、谷仓是活的,你得自己把活的东西翻译成死的结构。

2007年10月这一场,正好处在赛季开头,题目整体不算刁钻,但其中那道Tallest Cow,后来被POJ、洛谷等一堆OJ反复收录,变成了差分的“招牌例题”。所以这篇解析我只拿它当主线,因为它几乎概括了黄金组最核心的出题思路:把一个生活场景抽象成区间操作,再用一个简单的数据结构把复杂度压下来。

1.2 一道好题不在于难住你,而在于改变你看问题的方式

很多人刷题有个误区,觉得只有那种让全场爆零的题才值得复盘。我恰恰相反。Tallest Cow这道题,你给一个学过几天算法的中学生讲,他也能听懂;但你要是让他自己想,他大概率会掉进“枚举区间暴力修改”的坑里。

我当年就是那个掉坑的人。老实说,暴力做法在小数据下完全没有问题,甚至代码写起来更直观,可一旦区间长度和关系数同时拉满,效率就崩了。后来看到题解里的差分写法,我第一反应不是“这题我会了”,而是“原来区间修改还能这样记账”。

这就是真题解析该有的价值:不是给你一份标准答案,而是让你看看一条更优的思考路径是怎么长出来的。接下来的内容,我会从题意开始,一步步走到差分数组,再把我在实现时踩过的坑全摆出来。

2. 题面翻译:所谓“互相看见”其实是开区间约束

2.1 把题面逐句拆开看

Tallest Cow这道题,标准的题目描述大概是这样的:

有N头牛站成一排,位置从1到N。已知第I头牛的身高是H,而且它是整个牛群里最高的牛之一,换句话说,没有任何牛的身高会超过H。接下来给出R个关系,每个关系包含两个位置A和B,表示牛A和牛B能互相看见。

什么叫“能互相看见”?题里给了一个非常具体的几何条件:如果A和B能互相看见,那么它们之间的所有牛,身高都必须严格小于min(身高A, 身高B)。这个条件是用来模拟视线的——只要中间有一头牛身高不低于较矮的那头牛,视线就会被挡住。

最后题目问你:在所有条件都满足的情况下,每头牛可能的最大身高分别是多少。

这里有几个关键词值得划重点:“所有牛身高不超过H”“严格小于”“最大可能身高”。如果你只盯着“最高的牛是H”这句,很容易把思路带偏,以为这题是让你从H往下倒推。实际上,正确姿势是先假设每头牛都能长到H,然后根据关系把必须矮一截的位置压下去。

2.2 把“互相看见”翻译成区间减一

只看一对关系(A,B),假设A < B。要让A和B能互相看见,中间所有位置必须比两端的矮,而且至少矮1。因为你要求的是“最大可能身高”,所以没有额外约束的牛我们都希望它尽量高,那么这中间每个位置都减1就是最省事的方案。

注意这里减的是开区间:位置A和位置B本身不能减,要减的是A+1到B-1这一整段。

如果有多对关系呢?那就叠加。假设位置5同时被三个关系的开区间覆盖,那它就一共被减三次。这个逻辑很朴素:被越多“视线”挡住的牛,就必须越矮,每一条视线关系都是一条独立的约束。

所以整道题的本质就变成了:给你若干条区间,每个区间内部的点都需要被减1,最后问每个点被覆盖了多少次。这就是标准的区间覆盖模型。

2.3 边界情况比主逻辑更容易让你翻车

如果说上面这个翻译还算顺利,那接下来的细节才是真正的分水岭。第一,输入的A和B不保证A < B,必须先比较再处理。第二,同一个关系可能重复出现,重复减会造成结果偏小,虽然仍然满足约束,但已经不是最大身高了。第三,如果A和B是相邻位置,中间没有牛,这个关系本质上没有任何约束力,应该直接跳过。

还有一个很重要的题设保证:题目给出的关系区间不会出现部分交叉。什么意思?比如(1, 5)和(3, 7)这种就是部分交叉,这种数据不会出现;但(1, 9)和(3, 6)这种一个包含另一个的情况可以有。这个保证是后续所有简化处理的前提,少了它,这道题的难度会直接上升一个档次。

3. 差分数组:把区间减一的复杂度从O(NR)压到O(N+R)

3.1 暴力做法为什么不够好

先别急着上差分,我们把暴力想清楚,你才知道差分的优化到底优化在哪。

最朴素的做法是:开一个数组h,全部初始化为H。每读入一对关系(A,B),就写一个for循环,把A+1到B-1的位置全部减1。全部处理完之后,逐位输出。

这个做法正确性没问题,但复杂度是O(NR)量级的。就算老题的数据只有N、R都在1万左右,遇到“关系数多、每个区间又很长”的组合,运算量也轻轻松松到亿这个级别。更难受的是,它还要求你每读入一个关系就立刻暴力修改,完全没有缓存、批量处理的空间。

我那一次就是栽在这里:小数据全对,一到大数据就开始超时。后来我才意识到,这种“对一段连续区间做同样修改”的操作,天生就是差分数组的菜。

3.2 差分数组的区间加减原理

差分数组的思路特别简单,却特别容易被新手忽略。假设你有一个普通数组a,你想把闭区间[l, r]里每个数都加上x,可以不去碰a本身,而是开一个差分数组d,执行d[l] += x和d[r+1] -= x。处理完所有修改之后,对d做一次前缀和,得到的累加值再加上a的初始值,就是最终数组。

这个技巧的本质是“把区间修改转换成端点上的标记”。你不需要真的去改动区间里每一个元素,只要在区间起点打一个“从这里开始生效”的标记,在区间终点后一格打一个“到这里失效”的标记,最后统一结算就行。

对比一下暴力做法的耗时:一个长度为L的区间,暴力改需要O(L)时间,差分只需要O(1)时间记两个标记。这就是Tallest Cow从超时到AC的关键一步。

3.3 开区间和闭区间:一个极易搞混的偏移

Tallest Cow里的关系是开区间:我们要减的是(L, R)内部,也就是L+1到R-1。翻译成差分操作,应当是d[L+1] -= 1以及d[R] += 1。

这里有个容易搞混的点。如果你之前习惯了闭区间的写法d[l] += x、d[r+1] -= x,换到开区间时一定要记得往中间缩一格。为什么要d[R]加回来,而不是d[R+1]?因为位置R本身是端点,它的身高不应该被这个关系压低。如果写成d[R+1],前缀和结算时位置R也会被减掉,整段区间就偏移了。

可以这样记:闭区间[L, R]对应d[L]、d[R+1];开区间(L, R)对应d[L+1]、d[R]。一个是往右挪起点,一个是往左挪终点,思路完全对称。

3.4 为什么初始全设为H就一定是最大解

这一步我当年想了很久。直觉上你会担心:如果每头牛都被减了好几轮,会不会“减过头”?换句话说,我们在每个关系内部减1,这样得到的一定是满足所有约束的最大身高吗?

答案是肯定的,关键就在“严格小于”和“每次只减1”这两个点上。

对任意一个关系(L, R)来说,中间位置v被这个关系至少减了1。就算v同时被其他关系覆盖,被减了更多次,它也不可能反过来高于端点,因为端点本身也可能被其他关系压低。而由于题目保证区间不部分交叉,每条关系的约束都是兼容的,不存在“一个关系要v减1,另一个关系又要求v保持原样”这种矛盾。

最终每个位置的身高等于H减去它被覆盖的区间数。这个数已经是在满足所有“必须矮一点”的约束下,能取到的最大值了。任何一头牛再想长高哪怕1,都会直接违反某条“严格小于”的条件。这就是正确性的核心。

3.5 完整参考实现

这道题用C++写起来非常短,核心代码十几行就够。我这里给一版带注释的,把刚才说的边界情况全部处理掉:

#include <bits/stdc++.h> using namespace std; int main() { ios::sync_with_stdio(false); cin.tie(nullptr); int n, I, H, R; cin >> n >> I >> H >> R; // I和H在这个做法里只需要读进来,用不到 vector<int> d(n + 2, 0); // 差分数组,多开两个防止越界 set<pair<int, int>> seen; // 去重 for (int i = 0; i < R; i++) { int a, b; cin >> a >> b; if (a > b) swap(a, b); // 统一成左小右大 if (seen.count({a, b})) continue; // 重复关系直接跳过 seen.insert({a, b}); // 开区间(a, b),实际修改的是[a+1, b-1] if (a + 1 <= b - 1) { d[a + 1] -= 1; d[b] += 1; } } int cur = 0; for (int i = 1; i <= n; i++) { cur += d[i]; // 前缀和还原 cout << H + cur << "\n"; } return 0; }

注意最后一行的H + cur,cur是负数,代表身高被压低的部分。如果你在写的时候把符号搞反了,输出会变成一头牛比H还高,一测样例就能发现问题。

4. 四十分钟踩出来的坑:端点、重复区间与差分符号

4.1 忘记交换端点导致区间逻辑错乱

这可能是最不起眼却最容易犯的错。题目里关系给出的两个点并没有规定顺序,如果你不先swap一下,直接用a和b去操作,分情况讨论会变得非常痛苦。

比如输入是(7, 3),如果不处理,你的第一反应可能是“把7到3之间的点减1”。但数组下标没有3到7之间的概念,只有索引从3到7。最稳妥的做法就是一开始就统一成左小右大,后面所有逻辑都只需要写一遍。这算是竞赛里非常基础的习惯,但高压环境下真的会有人忘记。

4.2 重复关系:样例大概率测不出来的错误

另一个隐蔽的坑是重复关系。题目并没有保证R个关系两两不同。如果同一对关系出现两次,而你每次都去减一次,那中间位置会多减1,最终输出虽然仍然满足所有约束,但不再是“最大可能身高”。这种错误样例一般看不出来,因为你没有对照数据。

处理方式很简单,用一个set或者布尔矩阵记录已经出现过的关系,重复的跳过就行。数据范围不大,set完全够用。

4.3 开区间写成闭区间:整个区间偏移一格

这是初学者最容易写错的地方。如果误把开区间(L, R)写成了闭区间[L, R],那么端点L也被减了,而R+1被加回来,结果就是整段被修改的位置向左偏移一格。当多组关系叠加后,最终答案会变得凌乱不堪。

我把两种写法放在一起对比,方便你对照检查:

想做的事情差分操作
闭区间[L, R]整体减1d[L] -= 1, d[R+1] += 1
开区间(L, R)整体减1d[L+1] -= 1, d[R] += 1

在Tallest Cow里,内部牛是开区间,端点牛是“视线”所在位置,不需要被压低,所以必须用第二种写法。

4.4 数组越界和输出格式的小坑

差分数组在更新时可能访问到n+1附近的位置,所以数组大小开成n+2比较安全。输出时注意是从1到N逐行输出,不要脑抽输出成0到N-1。

另外,老版本USACO是文件输入输出,题目名就是文件名。现代OJ复刻版大多改成了标准输入输出,但如果你直接去USACO官网翻原题,记得按官网要求的文件读写方式写,否则WA了都不知道为什么。

5. 这一题背后的黄金组能力模型:建模优先于码模板

5.1 你看到的是一堆牛,脑子里应该是一张区间表

Tallest Cow最值得反复琢磨的,不是差分数组本身,而是“看到题面立刻想到区间覆盖”这种建模直觉。USACO黄金组几乎所有题目都在干同一件事:把自然语言翻译成数学结构。

你可以整理一个自己的“翻译手册”——比如:

  • 出现“两个位置之间有某些限制” → 考虑区间、差分、前缀和
  • 出现“两两之间的大小比较” → 考虑排序、贪心、树状数组
  • 出现“配对、依赖、传递关系” → 考虑图、并查集、拓扑排序
  • 出现“求满足条件的最大值/最小值” → 考虑二分答案或DP

这个手册会随着刷题量越来越多,最终变成一种条件反射。到那时候,你看题就不怕了。

5.2 同赛季值得一起刷的延伸题

如果只刷Tallest Cow,你的差分理解还是不够立体。USBACO 2007年前后的黄金组里,还有几道题跟它属于同一能力模型,建议连着做。

比如Balanced Lineup,它考的是区间最大值与最小值的快速查询,核心是RMQ或者线段树,和Tallest Cow一样都是“把区间操作做高效”。再比如Protecting the Flowers,表面上是赶牛回牛棚,实际上是个贪心排序题,需要你用相邻交换法推结论。还有Milk Patterns,经典的字符串问题,需要你掌握后缀数组或者哈希加二分。

这几道题不一定在同一个月出现,但它们拼在一起,恰恰能还原出黄金组最常见的考察方向:区间模型、贪心论证、字符串处理。把这一组题吃透,比零散刷几十道简单题有意义得多。

5.3 一份可以直接照做的黄金组训练计划

如果你现在正准备从银组往黄金组冲,我给你一个三周的训练模板,完全围绕这类“建模优先”的题目来设计:

第一周主攻差分与前缀和。除了Tallest Cow之外,找十道区间覆盖、区间和的题,每道都先写暴力版,再写差分版,最后对拍验证。对拍这个习惯强烈建议养成,它能把你自以为会的题真正变成会的题。

第二周主攻排序贪心和二分答案。这周的目标不是会写排序,而是会证明为什么这么排序是对的。Protecting the Flowers这类题一定要自己推一遍相邻交换的公式,不要只看题解点头。

第三周主攻简单DP和最短路。黄金组考的最短路一般就是Dijkstra、Bellman-Ford这个级别,DP也不会太难,但状态定义需要动点脑子。刷题时记住一个原则:代码不是核心,状态转移方程才是。

6. 写在最后:一道老题值得反复拆三遍

最后说点我自己的实操体会。Tallest Cow我在不同阶段写过三版:第一版暴力,第二版差分,第三版线段树。每一版都不是白写,暴力版让我理解了约束本身,差分版让我看到了优化空间,线段树版则是在想“如果题目加强到支持动态修改,该怎么办”。

这个过程中最有收获的,是每次重写时都逼自己重新做一遍“题面到模型”的转化。你刷老题不要只追求AC,要把暴力版和优化版并排摆着看,看看差分到底砍掉了哪些重复计算,看看线段树又多了哪些能力。折腾完这一遍,你对差分的理解会比连做十道简单题深很多。

如果你的目标就是USACO黄金组,那请记住:黄金组真正筛选的不是谁手速快,而是谁能在混乱的题面里一眼看穿底层结构。Tallest Cow就是最好的第一课。

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

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

立即咨询