ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

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

差分数组经典应用:从“最高的牛”理解区间更新与前缀和 说实话第一次拿到这题的时候我盯着题目愣了好一会儿。题目描述绕来绕去的又是最高的牛又是互相看见乍一看跟差分数组八竿子打不着。但等我把条件翻译完才发现这就是差分的一个标准模板题。这篇文章就把我的完整思路捋一遍从题意还原到代码实现再到几个容易翻车的细节尽量讲透。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)这组关系等价于一次区间操作把区间[A1, B-1]内所有牛的高度减1假设A B。这个转化是整个题的核心。一旦想明白这一步后面就是套差分模板的问题了。我当时就是卡在这一步很久脑子里一直在想互相看见该怎么用数组表示其实根本不用那么复杂就是区间减1。2. 为什么这题是差分思想的教科书案例2.1 暴力做法一眼就能看到头既然已经转成区间减1了最直白的做法就是开一个数组h[N]初始化全是H然后对每组关系从A1遍历到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; // 从r1开始累计变化量减少v最后从头到尾累加一遍得到的就是每个位置真正要加的总量。这个公式得刻在脑子里几乎所有差分区间的题目都是围绕它转的。放到这道题里对(A, B)A B区间是[A1, B-1]所以操作是d[A 1]--; // 区间内每个牛减1 d[B]; // 注意结束位置是B-1所以结束标记打在B这里有一个特别容易出错的点区间右端是B-1那撤销标记要打在(B-1)1 B的位置不是B-1也不是B1。我当时第一次写就写成了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; setpairint, 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}); // 区间[a1, 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)出现两次中间区间就被减了两次牛的高度就被额外压低算出来的就不是最高高度了。去重我用的是setpairint, int天然去重且能保证唯一性。实际比赛中更简单的做法是开二维bool数组标记但牛的数量如果到10的5次方量级二维数组就爆内存了set更稳。还有一种思路是把所有关系对排个序再相邻去重本质上一样选自己顺手的就行。我在实际做题时踩过一次坑忘了先保证a b就塞进set结果(3, 7)和(7, 3)被当成两组不同的关系重复处理了。所以排序、交换这两个动作必须先做再去重。3.3 端点调整为什么是[A1, B-1]而不是[A, B]很多人初学会疑惑A和B既然能互相看见说明A和B本身是高的不需要被减。所以区间只包含夹在中间的牛也就是从A1到B-1。这个边界极其关键。如果错写成d[a]-- 到 d[b-1]那A自己也跟着被减了最终结果全错。我在草稿纸上演算的时候用了一个最简单的例子三头牛1和3互相看见中间只有2。正确的做法是把2减11和3不动。如果端点算错可能1也被减了那就完全违背题意了。第一个样例我当时手算验证过是这么推的N9最高的是第3头高度H5关系有(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[a1]--可能打在了一个错误位置甚至d[b]打到了更小的下标上最终结果一塌糊涂。这类错误往往不是WA在某些大数据点而是连小样例都能错。第三是区间右端点写错。不少人会把d[b-1]想当然地认为区间到B-1结束所以撤销标记打在B-1。实际上撤销标记要打在区间最后一个元素的后一个位置所以是d[b]。这个细节我在前面已经强调过了但值得再说一遍你维护的差分数组在第i个位置的累加值表示从第1个位置到第i个位置累计的变化量所以区间结束之后变化量必须归零也就是从B位置开始不再减1。4.3 边界数据永远值得单独测边界情况也是这题的隐藏考点。比如N1或者N2的时候牛之间根本没有中间的牛区间是空的。这种情况差分操作d[a1]--和d[b]会让d数组越界吗不会因为a1可能大于b但数组开大一点就没事。d[N1]这个位置也可能被写入所以数组长度记得开成N5别刚好N。还有一种情况是两头牛相邻比如(3, 4)。这俩牛中间没有别的牛互相看见条件天然满足不需要做任何区间更新。如果代码不对这种情况做处理d[4]--和d[4]会刚好抵消其实结果也没错这是差分的自洽性在兜底。知道这个特性之后我对空区间不用特判这件事就放心了。5. 差分思想迁移从这道题看一类区间问题5.1 差分能解决的一类问题特征做完这题我最大的收获不是会了这道题的代码而是彻底理解了差分数组的适用场景。总结下来凡是满足这几个特征的问题都可以优先往差分上想操作全是区间级别的统一加减区间加、区间减、区间赋值可拆成加减每个位置的最终值依赖于所有作用于它的区间操作的累加不需要在操作过程中随时查询某个位置的实时值只需要最后一次性输出这类问题的共同套路就是把区间操作转换成差分数组上的点操作最后用前缀和还原。区间加变成两个点的修改复杂度从O(N*M)降到O(NM)质的飞跃。5.2 差分和前缀和是互逆的学差分的时候最好跟前缀和一起理解。前缀和是已知原数组求区间和差分是已知区间操作还原原数组。从数学上看差分数组d是原数组a的相邻差d[i] a[i] - a[i-1]。对d求前缀和就得到aa[i] d[1] d[2] ... d[i]。所以区间[l, r]加v在差分数组上表现为d[l] v和d[r1] - v本质是对差分数组做两次点更新再前缀和还原。这个互逆关系是理解一切差分题目的基石。网上有些热词提到的中心差分卷积差分放大电路其实都是差分这个概念在不同领域的延伸——都是取变化量、抓差异思路底层是相通的。搞懂算法里的差分对理解这些概念也有帮助。5.3 类似题型的举一反三在OJ上刷题的时候你会发现一票题目都是这个套路换皮有一个很常见的区间涂色问题M次操作每次把[l, r]区间涂成某种颜色问最后每种颜色出现多少次。把涂色看作区间赋值为某个颜色如果颜色种类有限可以对每种颜色分别开差分数组统计覆盖次数。还有经典的挤牛奶区间覆盖问题给若干时间段求被覆盖的总长度和最长连续覆盖区间。端点差分扫描一趟就能出结果。以及摆花问题M次区间加花最后问每个位置有多少花。这就是原封不动的差分模板题。学会从最高的牛里抽象出区间操作这个本质之后再看这些题基本就是秒杀。这就是为什么我强烈建议把这道题彻底弄透、最好背下来的原因——它是差分思想的一个最小完备模板。5.4 差分和树状数组、线段树的边界对比很多人在学差分的时候会顺手学到树状数组和线段树然后开始纠结到底该用哪个。我的建议是这样的如果只是多次区间修改、最后统一输出差分是首选代码短、常数小、不容易写错。如果需要在修改过程中实时查询某个位置或区间的最新值差分就撑不住了这时候才考虑树状数组或线段树。这么说吧差分的定位是离线批量处理树状数组和线段树的定位是在线动态维护。把它俩的关系理清楚你在选择数据结构的时候就不会再犯迷糊。这道题里我们压根不需要中间查询所以差分最后前缀和就是时间和代码量上性价比最高的方案。另外提一句如果题目有多组测试数据记得每组数据开始前把差分数组d清零用memset或者fill别偷懒用循环只清一部分否则上一组的残留数据会污染下一组的结果。这种低级错误最冤。6. 实测表现与优化空间6.1 复杂度分析到底有多划算差分版本的复杂度是O(NM)其中M组关系每组只做两次O(1)的数组修改最后前缀和还原是O(N)。内存上只开了一个长度为N的差分数组空间O(N)。对比暴力的O(N*M)这个提升是决定性的。N和M都到10的5次方甚至10的6次方量级时暴力算力完全不可接受差分几乎是瞬间出结果。在实际判题环境里同样是这一题暴力在N10^5、M10^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[a1] - k; d[b] k。整体框架完全不变只有参数变了。这再次验证了差分模板的通用性——你只需要掌握核心操作公式剩下就是套参数的事。我在刷题的时候经常用这种对模板题做变体的方法来检验自己是不是真懂了。把区间减1改成减k、把输出方式改一改、把条件从互相看见换成不能看见……每扭曲一次就逼自己重新思考一遍题目本质和差分适用的边界在哪里。这个方法虽然朴素但确实是我用下来提升最大的一种练习方式。最后说句实在话这道题我在自己的做题记录里标记为差分思想入门必刷。它最妙的地方在于把一整个绕来绕去的互相看见条件压缩成了两个数组下标的加减操作。想明白的那一刻你会觉得差分这玩意真是为这种题量身定做的。我到现在每次遇到区间修改最终输出的题第一反应还是差分不为别的就因为它简单、快、不容易错。如果你刚开始学差分把这题吃透比盲目刷十道同类题都管用。
返回列表