1. 为什么说差分算法是Java选手的“默认答案”
做Java算法题,尤其是准备蓝桥杯和面试手撕算法时,区间操作几乎一定会遇到:一个数组,反复让你把某个范围内的所有数字加上一个值,最后再把数组输出。暴力循环写法很简单,可数据量一大就是灾难;这时候最该第一个想到的,就是差分算法。差分算法在Java里实现特别简洁,不需要额外引入复杂数据结构,核心就只有三四句代码,却能从容搞定一维、二维甚至树上的区间更新问题。这篇文章写给准备蓝桥杯Java组、刷LeetCode或牛客、复习Java基础数据结构的读者;我会从一维差分的原理讲到二维模板,再给三个能直接跑的案例,最后把踩过的坑一次说清楚。
1.1 一个高频场景:区间修改、最后查询
以最常见的一维数组为例。假设数组长度 n=1000000,操作次数 m=100000,每一次操作都给出 l、r、v,要求把 arr[l] 到 arr[r] 全部加 v。如果每次真的用循环去跑,平均区间长度可能几十万,最坏要执行约 1e11 次加法,在Java里几乎不可能通过。而差分数组能用 O(1) 时间完成一次区间加,最后用 O(n) 时间还原,总复杂度从 O(n*m) 降到 O(n+m)。这个性价比是很多高级数据结构也比不上的。
你可能会问:线段树不是也能做吗?确实能,但线段树代码量至少在五十行以上,而且涉及建树、懒标记、区间更新、区间查询,面试手写时很容易出细节错。这里不是贬低线段树,而是说:当题目只要求“最后统一输出”时,用线段树属于杀鸡用牛刀。差分的代码量只有线段树的十分之一,一次区间加只有两次端点修改,几乎没有运行时的额外开销。
1.2 差分和差分隐私没有关系
搜索“差分算法”时,经常有人把“差分隐私算法”一起搜出来。这里先明确一下:本文讲的差分是一种数据结构思想,在算法题里用来做区间批量更新;差分隐私是另一种技术,属于数据发布和隐私保护领域,核心是往查询结果中加入噪声,两者除了名字都带“差分”外没有关系。我去面试时,曾遇到候选人把这两个概念混在一起,场面很尴尬。所以如果你面试时被问到差分,先确认面试官问的是算法模板还是隐私保护,别答错方向。
2. 一维差分:公式、原理与可复用模板
2.1 差分的定义与“端点记账”直觉
设原数组为 a,下标从 1 开始,并规定 a[0]=0。定义差分数组 d:
d[i] = a[i] - a[i-1]
比如 a = [1,4,2,8],那么 d[1]=1,d[2]=3,d[3]=-2,d[4]=6;反过来,对 d 做前缀和:d[1] 等于 a1,d[1]+d[2] 等于 a2,d[1]+d[2]+d[3] 等于 a3。所以 d 保存的是相邻元素的差值,前缀和可以把它恢复成 a。
这里有个很形象的类比:a 是账户余额,d 是每笔流水。我们希望知道某天余额,不需要记每一天的具体余额,只需要知道从开户日开始每天收入多少、支出多少,然后逐日累加。差分数组就是这个流水账,区间加就是给流水账加一笔“起始收入”和一笔“截止支出”。
2.2 区间加操作的推导,为什么是 r+1
现在要把 a[l..r] 每个数都加 v。我们先只改 d[l] += v,然后做前缀和,从 d[l] 开始,后面所有位置的前缀和都会多出一个 v。也就是说,a[l]、a[l+1]、a[l+2]……直到数组末尾,全都被加了 v。这显然不是我们想要的效果,因为它影响到了 r 之后的位置。
所以还要在 d[r+1] -= v。这样当前缀和累加到 r+1 时,前面多出来的 v 刚好被抵消。于是从 r+1 开始,前缀和又恢复正常。整段逻辑用一句话说就是:左端点记一笔“加”,右端点后面一格记一笔“减”,最后统一求前缀和,就能让修改只落在 [l,r] 区间内。
这个“右端点+1减掉”是差分算法里最核心、最反直觉的一个操作。一定要亲手推两遍,而不是只背结论。我当年第一次学的时候也觉得多余,直到自己拿小数组算了一遍,才理解这是一个“截止记号”。
2.3 Java代码:从空数组到前缀和还原
我的习惯是数组多开两个位置,下标从1开始。n个元素,diff长度至少n+2,这样当 r=n 时,diff[r+1] 也就是 diff[n+1] 仍可以安全写入,不会越界。
import java.util.*; public class Difference1D { public static void main(String[] args) { Scanner sc = new Scanner(System.in); int n = sc.nextInt(); int m = sc.nextInt(); long[] diff = new long[n + 2]; for (int i = 0; i < m; i++) { int l = sc.nextInt(); int r = sc.nextInt(); long v = sc.nextLong(); diff[l] += v; diff[r + 1] -= v; } StringBuilder sb = new StringBuilder(); for (int i = 1; i <= n; i++) { diff[i] += diff[i - 1]; // 前缀和还原 if (i > 1) sb.append(' '); sb.append(diff[i]); } System.out.println(sb); } }这里直接用 diff 数组做了前缀和,省得再开一个 result 数组。需要注意:如果做的是“多组测试数据”,每次 new 一个新的 diff 数组就好,或者用 Arrays.fill 把 diff 清零,千万别忘了上一组数据残留。
2.4 原数组不是0时怎么初始化差分
很多初学的人以为差分只能从全0数组开始。其实原数组 a 不为0时,先对原数组求一次差分,得到一个表示现状的 diff;之后的区间加,只需要继续在 diff 的端点做加减;最后再做前缀和还原。这样不用拿原数组去做区间运算,代码也很自然。
long[] diff = new long[n + 2]; for (int i = 1; i <= n; i++) { diff[i] = a[i] - a[i - 1]; // 先求原数组的差分 } // 之后的操作照旧:diff[l] += v; diff[r + 1] -= v; // 最后前缀和:diff[i] += diff[i - 1],结果就是更新后的a[i]实际比赛里,很多题目初始就是全0,直接用2.3的模板就行。但如果遇到初始数组有值,或者题目要求“在已有数组上修改”,这一小节的价值就体现出来了。
3. 二维差分:矩阵区间更新的“四角操作”
3.1 二维前缀和的逆运算
二维差分是二维前缀和的逆运算。二维前缀和公式大家应该熟悉:
s[i][j] = a[i][j] + s[i-1][j] + s[i][j-1] - s[i-1][j-1]
那么由二维数组 a 求差分 d 的公式就是反过来:
d[i][j] = a[i][j] - a[i-1][j] - a[i][j-1] + a[i-1][j-1]
这个公式看起来复杂,其实和一维一样:d 存的是“当前位置相比左上一片区域的增量”。如果初始矩阵全0,那差分矩阵也全0;如果初始矩阵有值,先按这个公式求一遍差分,后面区间操作直接改 d,最后再二维前缀和还原。
二维下标同样从1开始更方便。否则处理 i-1、j-1 时还要写 if 判断,代码会很啰嗦。
3.2 子矩阵加 v 的四个端点
若给以 (x1,y1) 为左上角、(x2,y2) 为右下角的子矩阵全部加 v,需要对 d 做四次修改:
- d[x1][y1] += v
- d[x2+1][y1] -= v
- d[x1][y2+1] -= v
- d[x2+1][y2+1] += v
为什么是四角?你可以把前缀和还原看成每个格子从左上角开始向下向右累加。在 (x1,y1) 加 v,右方和下方整片都会被影响;为了保证影响只落在目标子矩阵内,需要在右边界外一列、下边界外一行分别设置“截止记号”,再在右下角把重复多减的部分加回来。这就是容斥原理在差分里的体现。
我第一次写二维差分时,右下角那个加法总是忘,结果矩阵右下角一大片区域全多加了 v。后来我养成了一个习惯:假设目标区域只有 2x2 大小,自己手动把四个端点的值列出来,再一步步做前缀和,跑一遍就彻底记住了。
3.3 完整模板与边界处理
二维差分输入量通常比一维大很多,我建议直接用 BufferedReader + StringTokenizer,Scanner 在十万级输入下容易超时。
import java.io.*; import java.util.*; public class Difference2D { public static void main(String[] args) throws IOException { BufferedReader br = new BufferedReader(new InputStreamReader(System.in)); StringTokenizer st = new StringTokenizer(br.readLine()); int n = Integer.parseInt(st.nextToken()); int m = Integer.parseInt(st.nextToken()); int q = Integer.parseInt(st.nextToken()); long[][] diff = new long[n + 2][m + 2]; for (int k = 0; k < q; k++) { st = new StringTokenizer(br.readLine()); int x1 = Integer.parseInt(st.nextToken()); int y1 = Integer.parseInt(st.nextToken()); int x2 = Integer.parseInt(st.nextToken()); int y2 = Integer.parseInt(st.nextToken()); long v = Long.parseLong(st.nextToken()); diff[x1][y1] += v; diff[x2 + 1][y1] -= v; diff[x1][y2 + 1] -= v; diff[x2 + 1][y2 + 1] += v; } StringBuilder sb = new StringBuilder(); for (int i = 1; i <= n; i++) { for (int j = 1; j <= m; j++) { diff[i][j] += diff[i - 1][j] + diff[i][j - 1] - diff[i - 1][j - 1]; if (j > 1) sb.append(' '); sb.append(diff[i][j]); } sb.append('\n'); } System.out.print(sb); } }注意 diff 数组开了 (n+2) x (m+2),因为 x2+1 可能等于 n+1,y2+1 可能等于 m+1。还原时循环只到 n 和 m,不会读取 n+1 行的数据,因此不会越界。
3.4 为什么二维差分能省这么多时间
假设一个 1000x1000 的矩阵,执行 1000 次子矩阵加操作。暴力做法每次最多遍历 1e6 个格子,总操作量约 1e9;差分每次只动4个点,最后遍历矩阵还原,总操作量约 1e6 加 4000。差距是三个数量级。
矩阵越大、操作次数越多,差分的优势越明显。更关键的是,暴力写法不仅慢,代码里还容易出现下标错乱;差分的四行端点修改是固定套路,机械记忆即可,正确率反而更高。
4. 三个实战案例,照着敲就能AC
4.1 一维区间加:手把手验证结果
先用最初的一维例子。n=5,初始数组全0,执行两次操作:
- 把 [2,4] 加 3
- 把 [1,3] 加 5
用上面的 Difference1D 跑,输入样例:
5 2 2 4 3 1 3 5输出:
5 8 8 3 0手动验证一下:位置1被第二个操作加5,结果是5;位置2和3同时被两个操作覆盖,所以是8;位置4只被第一个操作加3,结果是3;位置5不在任何操作区间内,结果保持0。这个案例虽然简单,但能帮你确认 diff[l]+=v 和 diff[r+1]-=v 的方向没有搞反。
4.2 二维子矩阵加:3x4小样例跑通
用3行4列的零矩阵,执行两次操作:
- (1,1) 到 (2,2) 加 1
- (2,3) 到 (3,4) 加 2
预期输出:
1 1 0 0 1 1 2 2 0 0 2 2输入格式:
3 4 2 1 1 2 2 1 2 3 3 4 2把这段输入放到 Difference2D 里运行,应该能得到上面的矩阵。如果某个端点写错,比如右下角那个加 v 忘了,输出里右下角会多出一大片 2,一眼就能发现问题。
4.3 差分+前缀和求最大重叠区间数
这是差分数组非常经典的扩展应用:给一批闭区间,问任意时刻最多被多少个区间覆盖。不用逐个遍历区间内的点,直接把每个区间 [l,r] 变成 diff[l]++、diff[r+1]--,最后做前缀和。前缀和数组里每个位置的值,就是该点被多少个区间覆盖,求最大值即可。
import java.util.*; public class MaxOverlap { public static void main(String[] args) { Scanner sc = new Scanner(System.in); int t = sc.nextInt(); int maxEnd = 0; long[] diff = new long[100005]; // 根据题目最大端点调整 while (t-- > 0) { int l = sc.nextInt(); int r = sc.nextInt(); diff[l] += 1; diff[r + 1] -= 1; maxEnd = Math.max(maxEnd, r); } long cur = 0, ans = 0; for (int i = 1; i <= maxEnd; i++) { cur += diff[i]; ans = Math.max(ans, cur); } System.out.println(ans); } }输入样例:
3 1 3 2 5 4 6输出是 2。因为时间点2到5都有两个区间覆盖。这类题在蓝桥杯和日常笔试中很常见,本质是把区间事件转换为端点事件,这正是差分思想最迷人的地方。
4.4 在蓝桥杯里怎么识别差分题
蓝桥杯Java组里,差分出现频率很高。题干通常会出现这些关键词:“执行若干次区间加法”“把子矩阵都加上一个数”“最后输出数组/矩阵”。认准这几个关键字,第一反应就应该是差分。还有一些题表面在问“某个位置的值是多少”,实际只做一次全局查询,也可以先用差分记录变化量,再通过前缀和一次性回答。
准备竞赛时,差分、前缀和、二分、贪心这四个模板要滚瓜烂熟。差分是其中代码量最小、最容易检验的一个,性价比极高。省赛时遇到区间操作,别急着上线段树,先想差分能不能解,很多时候三分钟就能敲完。
5. 常见问题与排查技巧
5.1 为什么 diff 数组总是越界
数组越界是新手最常踩的坑。一维操作中 l、r 都在 1 到 n 之间,但如果 r=n,r+1=n+1,数组长度开成 n+1 就不够用了,下标 n+1 越界。所以模板里统一开 n+2。二维同理,行和列都多开两格。
我整理了一个常见错误对照表,排查时可以直接对照:
| 错误 | 现象 | 正确做法 |
|---|---|---|
| diff 数组长度开成 n | 在 r=n 时数组越界 | 开 n+2 |
| 忘记前缀和还原 | 输出结果完全不对 | for i=1..n: diff[i] += diff[i-1] |
| 二维右下角加号漏写 | 矩阵右下大片多加了 v | 补上 diff[x2+1][y2+1] += v |
| 用 Scanner 读百万级输入 | 运行超时 | 用 BufferedReader+StringTokenizer |
5.2 输出总不对?大概率忘了前缀和还原
diff[l] += v; diff[r+1] -= v 之后,diff 本身并不是最终数组。diff 只是“变化量”,必须从左到右逐个累加,也就是做前缀和,才能还原出每个位置真正的结果。很多新手把 diff 数组直接输出,当然看不到区间效果。
调试时我习惯先打印 diff 数组,再打印前缀和数组。比如上面 [2,4]+3、[1,3]+5 的例子,操作结束后 diff 数组应该是 [5,3,0,-5,-3](从下标1开始),前缀和才是 [5,8,8,3,0]。把中间态打出来,问题一下就能定位。
5.3 int 溢出和输入效率
区间操作累加次数多了,结果很容易超过 int 上限。长度为 1e6 的数组,做 1e6 次 +1,最大结果可能到 1e12,int 根本装不下。所以我模板里统一用 long,别在这种地方交冤枉分。
输入效率同样重要。一维输入量小,Scanner 还能用;但二维矩阵和十万级操作量下,Scanner 的 nextInt/nextLong 会比较慢。建议用 BufferedReader + StringTokenizer,代码就多一两行,性能却能提升一个档次。
5.4 二维差分的容斥怎么记才不会错
二维差分四角符号记不住,我的方法是画一个 4x4 方格,标记一个 2x2 目标区,把四个端点的修改都写出来,然后手算前缀和。三步下来就记住了。口诀是:左上和右下是加,右上和左下是减。这个减号来自二维前缀和公式里交叉项的符号,理解了就不会混。
如果实在怕记错,写代码前可以先构造一个 3x3 的小矩阵,心算一遍预期结果,再跑一下模板。花一分钟验证,比提交后白白丢分强得多。
5.5 闭区间与开区间:先确认题目定义
大部分算法题是闭区间 [l,r],对应 diff[l]+=v、diff[r+1]-=v。但有些题目用的是左闭右开 [l,r),也就是包含 l、不包含 r,此时右端点应该写作 diff[r]-=v。二维同理,看题目给的是闭区间还是开区间。
蓝桥杯里大多是闭区间,但面试题有时会故意写“半开半闭”。读题后先用小样例测边界,不要默认它是怎么定义的。
6. 从差分到线段树、树状差分:一条学习主线
6.1 差分能做什么,不能做什么
差分数组只适合“多次批量修改、最后统一查询”的场景。如果修改和查询交替出现,每次都要求实时区间和,那差分数组就无能为力了。这时候可以考虑树状数组或线段树:树状数组可以理解成在差分数组基础上再做一层统计,支持动态修改和区间查询;线段树更通用,能处理区间加、区间乘、区间最值等。
面试时如果被问到“区间操作你会怎么选型”,你可以顺着这条线回答:一次性离线操作选差分,动态单点改区间查选树状数组,复杂更新选线段树。答出这条演进路线,会比只背模板更有说服力。
6.2 进阶方向:树上差分
再进一步是树上差分。比如统计每条边被多少条路径覆盖,可以把一条从 s 到 t 的路径拆成 s 到 LCA、t 到 LCA 两条链,在端点做标记,最后 DFS 回溯时做累加。原理和数组差分完全一致,只是把“线性前缀和”换成了“树上自底向上的累计”。
这个概念在蓝桥杯国赛和部分面试算法轮偶尔出现,现在不用深究,知道有方向就行。先把一维和二维数组差分写熟,再去看树上差分,会顺手很多。
6.3 工程场景里的差分:从在线人数到计费
差分思想在业务系统里也很常见。比如统计每个时刻直播间在线人数:有人进来 +1,有人离开 -1;如果手里有一批进入/离开区间,用端点 +1/-1 记录,然后按时间排序累加,就能得到全天人数曲线。再比如停车场各时段剩余车位、优惠券在某个区间内可用次数,都属于同一模型。
在日常开发里把“区间事件”转化成“端点事件”,可以避开逐区间遍历的死循环。这也是为什么我觉得差分不仅仅是竞赛模板,更是一种值得训练的思维方式。
6.4 一个建议:建立自己的算法模板库
根据我个人的习惯,算法模板一定要沉淀成自己的代码库。差分、前缀和、二分、并查集、DFS/BFS,每个模板用熟后保存成一个类或一个文件,做题时直接复用。这样在蓝桥杯考场上能节省大量时间。建议你把上面的一维和二维差分模板改成自己喜欢的变量命名和输入方案,然后找几道区间操作的题反复测,练到能盲打。
我第一次学差分时觉得“d[r+1]-=v”特别反直觉,总觉得是多余的一步;直到自己用手算了五六个例子,才真正理解它是一个“截止记号”。后来做二维差分,我也没有硬背四角公式,而是每次先画 3x3 矩阵,手动验证。这个习惯让我的模板一直没写错过。希望你也别急着跳过推导,亲手推一遍,比背任何模板都管用。