news 2026/10/7 10:25:56

差分数组经典应用:从“最高的牛”理解区间更新与前缀和

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
差分数组经典应用:从“最高的牛”理解区间更新与前缀和

说实话,第一次拿到这题的时候,我盯着题目愣了好一会儿。题目描述绕来绕去的,又是"最高的牛"又是"互相看见",乍一看跟差分数组八竿子打不着。但等我把条件翻译完,才发现这就是差分的一个标准模板题。这篇文章就把我的完整思路捋一遍,从题意还原到代码实现,再到几个容易翻车的细节,尽量讲透。

1. 题意还原:从"互相看见"到区间更新的关键一步

1.1 还原题目本身在说什么

题目给的是这么个场景:有N头牛站成一排,第P头牛是最高的,高度为H。然后给了M组关系,每组关系是(A, B),表示A和B这两头牛能互相看见。

什么叫"能互相看见"?这是整道题的题眼。两头牛之间如果隔着别的牛,它们还能互相看见,说明什么?说明夹在它们中间的那些牛,身高都比这两头要矮。不然中间有头牛比A或B高,视线就被挡住了,压根看不见。

所以(A, B)这组关系翻译过来就是:A和B之间的所有牛,高度都严格小于A和B。

题目要求的是:在所有条件都满足的前提下,每头牛可能的最高高度是多少。注意是"最高",所以我们得在满足约束的前提下,尽量让每头牛都往高了取。

1.2 一句话把条件翻译成区间操作

那"所有中间牛都比A、B矮"这个条件,怎么落到高度值上?

假设我们先把所有牛的高度都初始化成H,也就是全局最大值。那对(A, B)这组关系来说,为了让A和B能互相看见,我只需要把A和B之间的牛各减1就行。减1只降低了1的高度,已经是最小幅度的调整,这样能保证其他牛尽可能高。

所以说白了,(A, B)这组关系等价于一次区间操作:把区间[A+1, B-1]内所有牛的高度减1(假设A < B)。

这个转化是整个题的核心。一旦想明白这一步,后面就是套差分模板的问题了。我当时就是卡在这一步很久,脑子里一直在想"互相看见"该怎么用数组表示,其实根本不用那么复杂,就是区间减1。

2. 为什么这题是差分思想的教科书案例

2.1 暴力做法一眼就能看到头

既然已经转成"区间减1"了,最直白的做法就是开一个数组h[N],初始化全是H,然后对每组关系,从A+1遍历到B-1,逐个减1。M组关系,每组区间长度最坏是O(N),整体复杂度O(N*M)。N和M到10的5次方量级的时候,铁定超时。

这个暴力做法的问题在于:明明很多牛的减1操作是重复的,我们还是老老实实一个一个处理。比如(1, 5)和(2, 6)两对关系,中间区间有重叠,重叠部分的牛被减了两次,但暴力做法完全没利用这个重叠信息,每次都是从头到尾扫。

2.2 从"逐个减"到"打标记再统一结算"的思维转变

差分数组的核心思路其实特别接地气:既然要做的操作都是区间内统一加减某个值,那我没必要真的去碰区间里的每一个元素。我在区间的起点打一个标记,说从这里开始每个元素都要减1,在区间的结束位置之后打一个标记,说从这里开始不用减了。所有标记打完之后,我从前到后扫一遍,把这些"增量变化"累加起来,就还原出了每个位置实际被加减了多少。

你可以类比记账。传统的"逐笔记录每一块钱花在哪",和"记下每天余额变化、月末统一算总账",后者就是差分的思路。区间内每个位置都减1,我没必要把每个位置都记录一遍"减1",只需要在区间开头记一笔"从这开始减少",在区间结束的后一个位置记一笔"到这里停止减少"。

2.3 差分数组的区间修改原理

具体来说,我维护一个差分数组d,初始全是0。要给[l, r]这个区间内每个元素加v,操作是:

d[l] += v; // 从l开始,累计变化量增加v d[r + 1] -= v; // 从r+1开始,累计变化量减少v

最后从头到尾累加一遍,得到的就是每个位置真正要加的总量。这个公式得刻在脑子里,几乎所有差分区间的题目都是围绕它转的。

放到这道题里,对(A, B)(A < B),区间是[A+1, B-1],所以操作是:

d[A + 1]--; // 区间内每个牛减1 d[B]++; // 注意!结束位置是B-1,所以结束标记打在B

这里有一个特别容易出错的点:区间右端是B-1,那撤销标记要打在(B-1)+1 = B的位置,不是B-1,也不是B+1。我当时第一次写就写成了d[B-1]++,结果恢复出来的高度完全不对。

3. 完整实现:代码、去重与边界处理的细节

3.1 主流程代码

理清思路之后,代码其实很短。下面是我最终通过的版本,用的是C++:

#include <iostream> #include <algorithm> #include <set> using namespace std; const int N = 10010; int d[N]; // 差分数组 int main() { int n, p, h, m; cin >> n >> p >> h >> m; set<pair<int, int>> seen; // 用于去重 for (int i = 0; i < m; i++) { int a, b; cin >> a >> b; if (a > b) swap(a, b); // 保证a < b // 只处理第一次出现的关系对 if (seen.count({a, b})) continue; seen.insert({a, b}); // 区间[a+1, b-1]内所有牛高度减1 d[a + 1]--; d[b]++; } // 前缀和还原 int cur = 0; for (int i = 1; i <= n; i++) { cur += d[i]; // cur就是第i头牛相对H的偏移量 cout << h + cur << "\n"; } return 0; }

3.2 关系对去重为什么是必须的

题目里并没有明确说不会给重复的关系对,实战中这种"隐含重复"的情况太常见了。如果完全不加处理,同一组(A, B)出现两次,中间区间就被减了两次,牛的高度就被额外压低,算出来的就不是"最高高度"了。

去重我用的是set<pair<int, int>>,天然去重且能保证唯一性。实际比赛中更简单的做法是开二维bool数组标记,但牛的数量如果到10的5次方量级,二维数组就爆内存了,set更稳。还有一种思路是把所有关系对排个序再相邻去重,本质上一样,选自己顺手的就行。

我在实际做题时踩过一次坑:忘了先保证a < b就塞进set,结果(3, 7)和(7, 3)被当成两组不同的关系,重复处理了。所以排序、交换这两个动作必须先做,再去重。

3.3 端点调整:为什么是[A+1, B-1]而不是[A, B]

很多人初学会疑惑:A和B既然能互相看见,说明A和B本身是"高"的,不需要被减。所以区间只包含夹在中间的牛,也就是从A+1到B-1。

这个边界极其关键。如果错写成d[a]-- 到 d[b-1]++,那A自己也跟着被减了,最终结果全错。我在草稿纸上演算的时候用了一个最简单的例子:三头牛,1和3互相看见,中间只有2。正确的做法是把2减1,1和3不动。如果端点算错,可能1也被减了,那就完全违背题意了。

第一个样例我当时手算验证过,是这么推的:N=9,最高的是第3头,高度H=5,关系有(1, 3)和(3, 7)。对(1, 3),区间是[2, 2],2号牛减1。对(3, 7),区间是[4, 6],4、5、6号牛减1。最后结果应该是5、4、5、4、4、4、5、5、5,和样例输出对得上。

4. 自己构造数据验证与常见错误排查

4.1 手算数据验证的完整流程

写完代码别急着交,先自己构造几组小数据验证。我的做法是写个暴力程序对拍。暴力程序逻辑简单,就是开数组逐项减:

// 暴力验证代码 for (int i = 0; i < m; i++) { int a, b; cin >> a >> b; if (a > b) swap(a, b); for (int j = a + 1; j <= b - 1; j++) { ans[j]--; } }

然后随机生成N、M、关系对,分别跑差分版和暴力版,比对输出。对拍能抓出非常多"你以为对了其实错了"的情况,尤其是边界问题和重复关系问题。我自己调试时对拍过几百组随机数据,差分版和暴力版结果全部一致,才放心提交。

4.2 三个高频错误:重复关系、方向颠倒、区间端点算错

根据我身边同学和网上讨论区的情况,这题常见的WA基本逃不出下面三个原因:

第一是没去重。重复的关系对导致区间被重复减,结果偏小。表现是某些数据点答案比预期低1或更多。

第二是左右端点没调整。输入给你的(A, B)不保证A < B,你如果不先交换就直接做区间操作,那d[a+1]--可能打在了一个错误位置,甚至d[b]++打到了更小的下标上,最终结果一塌糊涂。这类错误往往不是WA在某些大数据点,而是连小样例都能错。

第三是区间右端点写错。不少人会把d[b-1]++,想当然地认为"区间到B-1结束,所以撤销标记打在B-1"。实际上撤销标记要打在区间最后一个元素的后一个位置,所以是d[b]++。这个细节我在前面已经强调过了,但值得再说一遍:你维护的差分数组在第i个位置的累加值,表示"从第1个位置到第i个位置累计的变化量",所以区间结束之后,变化量必须归零,也就是从B位置开始不再减1。

4.3 边界数据永远值得单独测

边界情况也是这题的隐藏考点。比如N=1或者N=2的时候,牛之间根本没有"中间的牛",区间是空的。这种情况差分操作d[a+1]--和d[b]++会让d数组越界吗?不会,因为a+1可能大于b,但数组开大一点就没事。d[N+1]这个位置也可能被写入,所以数组长度记得开成N+5,别刚好N。

还有一种情况是两头牛相邻,比如(3, 4)。这俩牛中间没有别的牛,互相看见条件天然满足,不需要做任何区间更新。如果代码不对这种情况做处理,d[4]--和d[4]++会刚好抵消,其实结果也没错,这是差分的自洽性在兜底。知道这个特性之后,我对"空区间不用特判"这件事就放心了。

5. 差分思想迁移:从这道题看一类区间问题

5.1 差分能解决的一类问题特征

做完这题我最大的收获,不是会了这道题的代码,而是彻底理解了差分数组的适用场景。总结下来,凡是满足这几个特征的问题,都可以优先往差分上想:

  • 操作全是区间级别的统一加减(区间加、区间减、区间赋值可拆成加减)
  • 每个位置的最终值依赖于所有作用于它的区间操作的累加
  • 不需要在操作过程中随时查询某个位置的实时值,只需要最后一次性输出

这类问题的共同套路就是:把"区间操作"转换成差分数组上的"点操作",最后用前缀和还原。区间加变成两个点的修改,复杂度从O(N*M)降到O(N+M),质的飞跃。

5.2 差分和前缀和是互逆的

学差分的时候最好跟前缀和一起理解。前缀和是"已知原数组,求区间和";差分是"已知区间操作,还原原数组"。

从数学上看,差分数组d是原数组a的相邻差:d[i] = a[i] - a[i-1]。对d求前缀和就得到a:a[i] = d[1] + d[2] + ... + d[i]。

所以"区间[l, r]加v"在差分数组上表现为d[l] += v和d[r+1] -= v,本质是对差分数组做两次点更新,再前缀和还原。这个互逆关系是理解一切差分题目的基石。网上有些热词提到的"中心差分卷积""差分放大电路",其实都是"差分"这个概念在不同领域的延伸——都是取变化量、抓差异,思路底层是相通的。搞懂算法里的差分,对理解这些概念也有帮助。

5.3 类似题型的举一反三

在OJ上刷题的时候,你会发现一票题目都是这个套路换皮:

有一个很常见的区间涂色问题:M次操作,每次把[l, r]区间涂成某种颜色,问最后每种颜色出现多少次。把"涂色"看作区间赋值为某个颜色,如果颜色种类有限,可以对每种颜色分别开差分数组统计覆盖次数。

还有经典的"挤牛奶"区间覆盖问题:给若干时间段,求被覆盖的总长度和最长连续覆盖区间。端点差分+扫描,一趟就能出结果。

以及"摆花"问题:M次区间加花,最后问每个位置有多少花。这就是原封不动的差分模板题。

学会从"最高的牛"里抽象出"区间操作"这个本质之后,再看这些题基本就是秒杀。这就是为什么我强烈建议把这道题彻底弄透、最好背下来的原因——它是差分思想的一个最小完备模板。

5.4 差分和树状数组、线段树的边界对比

很多人在学差分的时候会顺手学到树状数组和线段树,然后开始纠结"到底该用哪个"。我的建议是这样的:如果只是多次区间修改、最后统一输出,差分是首选,代码短、常数小、不容易写错。如果需要在修改过程中实时查询某个位置或区间的最新值,差分就撑不住了,这时候才考虑树状数组或线段树。

这么说吧,差分的定位是"离线批量处理",树状数组和线段树的定位是"在线动态维护"。把它俩的关系理清楚,你在选择数据结构的时候就不会再犯迷糊。这道题里我们压根不需要中间查询,所以差分+最后前缀和,就是时间和代码量上性价比最高的方案。

另外提一句,如果题目有多组测试数据,记得每组数据开始前把差分数组d清零,用memset或者fill,别偷懒用循环只清一部分,否则上一组的残留数据会污染下一组的结果。这种低级错误最冤。

6. 实测表现与优化空间

6.1 复杂度分析到底有多划算

差分版本的复杂度是O(N+M),其中M组关系每组只做两次O(1)的数组修改,最后前缀和还原是O(N)。内存上只开了一个长度为N的差分数组,空间O(N)。

对比暴力的O(N*M),这个提升是决定性的。N和M都到10的5次方甚至10的6次方量级时,暴力算力完全不可接受,差分几乎是瞬间出结果。在实际判题环境里,同样是这一题,暴力在N=10^5、M=10^5的极限数据下会跑到秒级以上甚至超时,差分版本跑下来是毫秒级的。

6.2 可读性优化和代码风格建议

这题的逻辑不算复杂,但我在代码里做了两件事让思路更清晰:

一是把"关系对去重"单独提取出来,用set维护,逻辑独立,后期调试时可以直接注释掉set相关代码来验证去重的必要性。

二是用变量cur记录当前前缀和,而不是把结果直接写回d数组。这样做的原因是d数组本身还在记录差分的原始信息,如果把前缀和覆盖回去,中间一旦想回头查某个位置的原始差值就找不到了。保险起见,用一个独立的cur变量累加,d数组保持只读。

这两点虽然不影响AC,但对代码的可读性和可调试性帮助很大。刷题多了你会发现,好的编码习惯在后期debug时能省出大量时间。尤其是像"最高的牛"这种代码量不大但边界细节众多的题,清晰的变量命名和职责划分是避免低级错误的第一道防线。

6.3 换个输入方式:从cin到scanf的取舍

这题输入量可能在10的5次方级别,用cin和cout默认情况下也不至于超时,但如果判题环境比较严格,或者你本身就喜欢用C风格,那直接用scanf/printf更稳。或者用ios::sync_with_stdio(false)关掉同步,也能把cin的速度拉到接近scanf。

我的习惯是C++代码里都写上这一句:

ios::sync_with_stdio(false); cin.tie(0);

然后放心用cin。这样写代码更简洁,也不怕输入量大的问题。不过注意,关掉同步之后,不要混用cin和scanf,否则输入顺序可能错乱,这种bug排查起来很折磨人。

7. 从这道题延伸出去的几个思想实验

7.1 如果要输出每头牛实际高度而不是相对值

题目是让输出每头牛可能的最高高度,也就是H + cur。如果换个问法,只问每头牛比最高的牛矮多少,那输出-cur或者干脆输出差分累加值的相反数就行。这种小变形很常见,理解了相对偏移和绝对高度的关系,再怎么变都不怕。

7.2 如果关系对变成了"不能互相看见"

再引申一个思路:如果条件反过来了,说某两头牛不能互相看见,那意味着它们之间至少有一头牛比它们俩都高,这就不再是简单的"区间全部减1"能描述的了。这种条件往往需要结合最大值的位置来推,题目复杂度会上去一个档次。从这也能看出来,差分解决的是"区间内所有元素统一变化"的问题,一旦条件变成了"区间内存在某个特殊元素",差分就力不从心了。

7.3 如果区间更新不是减1而是减k

题目里每次减1是因为我们要在满足条件的前提下尽可能高,所以每次只压低最小幅度。如果题目改成每次必须把区间内所有牛压低至少k,那操作就变成区间减k,差分数组上就变成d[a+1] -= k; d[b] += k。整体框架完全不变,只有参数变了。这再次验证了差分模板的通用性——你只需要掌握核心操作公式,剩下就是套参数的事。

我在刷题的时候经常用这种"对模板题做变体"的方法来检验自己是不是真懂了。把区间减1改成减k、把输出方式改一改、把条件从"互相看见"换成"不能看见"……每扭曲一次,就逼自己重新思考一遍题目本质和差分适用的边界在哪里。这个方法虽然朴素,但确实是我用下来提升最大的一种练习方式。

最后说句实在话

这道题我在自己的做题记录里标记为"差分思想入门必刷"。它最妙的地方在于,把一整个绕来绕去的"互相看见"条件,压缩成了两个数组下标的加减操作。想明白的那一刻,你会觉得差分这玩意真是为这种题量身定做的。我到现在每次遇到区间修改+最终输出的题,第一反应还是差分,不为别的,就因为它简单、快、不容易错。如果你刚开始学差分,把这题吃透,比盲目刷十道同类题都管用。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/7 10:25:50

Modbus RTU单报文收发:协议边界与CRC校验实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/7 10:25:47

UE编辑器工具开发:用HighlightPickedActors实现视口点选高亮

做自定义编辑器工具时&#xff0c;最常遇到的一个需求就是&#xff1a;让用户在关卡视口里点一下某个物件&#xff0c;工具立刻把这个物件高亮出来&#xff0c;然后拿着这个物件去干后续的活——批量改材质、收集资产信息、检查贴图尺寸&#xff0c;诸如此类。 这个动作在运行…

作者头像 李华
网站建设 2026/10/7 10:24:29

Hot 100普通数组刷题笔记:六道高频面试题的边界与复杂度解析

如果你准备面试或者正在刷题&#xff0c;LeetCode Hot 100应该是绕不开的一份清单。这份榜单把高频面试题按数据结构分成了十几个分区&#xff0c;其中“普通数组”这一栏很不起眼&#xff0c;题量不大&#xff0c;也不涉及链表、树、图这些复杂结构&#xff0c;但它是我刷了三…

作者头像 李华
网站建设 2026/10/7 10:23:21

从Docker到Kubernetes:容器化部署到集群运维的实战排错指南

如果你已经能熟练地写 Dockerfile、能跑通docker-compose up -d&#xff0c;甚至习惯了把 MySQL、Redis 都塞进容器里跑&#xff0c;那说实话&#xff0c;单机容器化这一关你已经过了。但"阶段二"的挑战&#xff0c;恰好是从你试图把这些经验搬到 Kubernetes 集群里那…

作者头像 李华
网站建设 2026/10/7 10:23:20

SpringBoot+Vue问卷系统实战:从表设计到部署避坑指南

做一个基于SpringBoot的调查问卷系统&#xff0c;听起来像是毕业设计里最经典的那类选题&#xff0c;但实际上手之后你会发现&#xff0c;它远没有题目看起来那么“标准”。问卷要支持多少种题型、答案怎么存才能方便统计、如何防止同一个人重复提交、前端怎么和一个后端工程打…

作者头像 李华
网站建设 2026/10/7 10:23:05

冬月廿六感怀:平日里的复盘与生活整理术

晨光透过窗帘的时候&#xff0c;我翻开手机日历&#xff0c;上面写着“乙巳年冬月廿六”&#xff0c;下面一行小字备注“平日”。冬月是农历十一月&#xff0c;一年中最冷的一段日子&#xff1b;平日&#xff0c;在老黄历里是普通的一天&#xff0c;没有特别的宜忌&#xff0c;…

作者头像 李华