☰
校门外的树:区间处理入门与差分数组优化
2026/10/14 23:20:31 网站建设 项目流程

1. 从一道经典题说起:校门外的树到底在考什么

第一次看到“校门外的树”这个标题,很多人会以为是道植树造林的应用题,实际上它是算法入门阶段非常经典的一道区间处理题。题目场景大致是这样的:某条路上从起点到终点等距种了一排树,后来因为施工需要,在若干段区间内把树移走,问最后还剩多少棵树。输入给出路的总长度和若干次施工的区间,输出剩余树的数量。

这道题之所以被反复拿来当例题,是因为它同时踩中了几个关键考点:区间表示、区间合并、标记数组、差分思想,以及最容易被忽视的端点闭合问题。别看题目描述简单,真正动手写的时候,新手最容易在“0号位置到底算不算一棵树”“区间是左闭右闭还是左闭右开”这些细节上翻车。

我见过不少同学第一次写这道题,代码逻辑看起来没问题,提交上去就是答案差一两个数。排查半天,问题往往出在循环边界上。所以这道题的价值不在于难,而在于它能把“边界处理”这个基本功暴露得清清楚楚。

这篇文章我会把两种主流解法拆开讲透:一种是布尔标记法,适合刚接触数组和循环的初学者,直观好懂;另一种是差分数组法,效率更高,是进阶阶段必须掌握的技巧。两种方法我都会给出完整代码、参数计算过程、边界处理细节,以及我在实际调试中踩过的坑。无论你是刚开始学C++,还是想复习一下区间处理套路,都能直接拿去用。

2. 先把题目模型吃透:区间、端点与数据范围

2.1 题目场景的数学抽象

把题目翻译成数学语言:有一条数轴,上面有从0到L共L+1个整数点,每个点代表一棵树。接下来有M次操作,每次给出一个区间[a, b],表示把这个闭区间内的树全部移走。问所有操作结束后,还剩多少个点没有被移走。

这里有几个关键信息需要确认:

  • 树的编号从0开始,到L结束,所以总共有L+1棵树,不是L棵。这是第一个容易错的点。
  • 区间是闭区间,也就是a和b两个端点的树也要被移走。
  • 多次操作之间可能有重叠,重叠部分的树只能算移走一次。

提示:很多题解在描述时会把“0到L”说成“L棵树”,这是不严谨的。实际数量是L+1,写代码时如果用L作为数组大小,就会越界。

2.2 数据范围决定解法选型

虽然这道题在不同平台上的数据范围略有差异,但常见的设定是L最大到10000左右,M最大到100左右。这个量级意味着:

  • 如果用标记数组,每次操作遍历区间长度,总复杂度是O(M×L),在L=10000、M=100的情况下大约是10^6次操作,完全跑得动。
  • 如果用差分数组,复杂度降到O(L+M),更快,但对于这个数据量来说优势不明显。

那为什么还要讲差分?因为数据范围会变。同一道题如果L放大到10^7甚至10^8,标记数组就吃不消了,而差分数组依然稳定。学算法不能只盯着当前这道题的数据,要养成看范围选方案的习惯。

2.3 两种解法的核心差异

对比维度布尔标记法差分数组法
核心思想直接标记每个点是否被移走用差分记录区间变化,最后前缀和还原
时间复杂度O(M×L)O(L+M)
空间复杂度O(L)O(L)
代码难度低,适合入门中等,需要理解前缀和
适用场景数据量小、逻辑直观数据量大、需要高效
易错点数组大小、循环边界差分更新、前缀和还原

这张表不是让你死记,而是帮你建立“什么场景用什么工具”的判断力。下面我分别展开。

3. 解法一:布尔标记法,新手最友好的直球思路

3.1 核心思路与生活化类比

布尔标记法的思路特别朴素:准备一个数组,每个位置对应一棵树,初始全部标记为“存在”。每次施工,就把区间内所有位置标记为“不存在”。最后数一遍还有多少个“存在”。

这就像你有一排开关,每个开关控制一盏灯,初始全亮。每次操作就是把某一段的灯全部关掉,最后数还亮着几盏。关灯这个动作重复做也没关系,反正关了就是关了。

用代码表达就是:

bool tree[10005]; // true表示树还在,false表示被移走

初始化时全部设为true,然后对每个区间[a, b],把tree[a]到tree[b]全部设为false。最后遍历一遍统计true的个数。

3.2 完整代码实现与逐行注释

#include <iostream> using namespace std; int main() { int L, M; cin >> L >> M; // 数组大小要开到L+1,因为编号是0到L bool tree[10005]; // 初始化:所有位置都有树 for (int i = 0; i <= L; i++) { tree[i] = true; } // 处理每个施工区间 for (int i = 0; i < M; i++) { int a, b; cin >> a >> b; // 闭区间,两端都要标记 for (int j = a; j <= b; j++) { tree[j] = false; } } // 统计剩余的树 int count = 0; for (int i = 0; i <= L; i++) { if (tree[i]) { count++; } } cout << count << endl; return 0; }

这段代码有几个地方值得单独说:

第一,数组开成10005而不是10000,是为了防止L刚好等于10000时越界。实际写题时,数组大小永远要比题目给的最大值多开几个,这是保命习惯。

第二,初始化循环用的是i <= L,不是i < L。因为编号从0到L,一共L+1个位置。

第三,标记循环用的是j <= b,因为区间是闭区间,b位置的树也要移走。

3.3 边界处理的三个致命细节

细节一:0号位置的处理。题目说“校门外”的树,从0开始编号。如果你习惯性地从1开始循环,0号位置的树就永远统计不到,答案会少1。我见过至少三个同学栽在这个点上。

细节二:区间端点的闭合性。题目给的区间是闭区间,a和b都要标记。如果写成j < b,就会漏掉右端点。这种错误在样例上可能看不出来,因为样例的区间可能刚好不敏感,但提交后会挂。

细节三:数组越界。如果L=10000,数组只开到10000,那么tree[10000]就越界了。C++不会报错,但会读写到相邻内存,结果不可预测。所以数组大小一定要留余量。

注意:布尔数组在C++中每个元素占1字节,10005个元素大约10KB,内存完全不是问题。不要为了省空间去用位运算,除非数据量真的很大。

3.4 复杂度分析与适用边界

布尔标记法的时间复杂度是O(M×L)。当M=100、L=10000时,最坏情况是10^6次标记操作,加上10^4次统计,总共约10^6量级,现代计算机一秒钟能跑10^8次操作,所以完全没问题。

但如果L变成10^7,M变成10^5,总操作数就是10^12,这就跑不动了。所以布尔标记法的适用边界是:区间总长度乘以操作次数在10^7以内。超过这个量级,就要考虑差分。

4. 解法二:差分数组法,效率提升的关键技巧

4.1 差分思想的生活化解释

差分数组的核心思想是:不直接记录每个点的状态,而是记录相邻点之间的变化量。

打个比方,你有一排存钱罐,初始每个罐子里都有1块钱。现在要把第3到第7个罐子清空。与其一个个去掏,不如在账本上记一笔:“从第3个开始少1块,从第8个开始恢复1块”。最后从头到尾累加一遍账本,就知道每个罐子实际有多少钱。

这个“账本”就是差分数组。它的定义是:diff[i] = arr[i] - arr[i-1]。对原数组区间[a, b]的修改,等价于在差分数组上做两次单点修改:diff[a] += delta,diff[b+1] -= delta。

4.2 从标记到差分的转换过程

回到这道题。我们最终要统计的是“有多少个位置没有被移走”。换个角度:初始每个位置的值是1,每次施工把区间[a, b]的值减1(减到0为止,但因为我们只关心是否为0,所以减多次也没关系,只要最后判断是否大于0)。

用差分数组表示:

  • 初始差分数组diff[0] = 1,diff[1..L] = 0(因为原数组全是1,相邻差值都是0)。
  • 每次施工区间[a, b],执行diff[a] -= 1,diff[b+1] += 1。
  • 最后对diff做前缀和,还原出每个位置的值。如果值大于0,说明树还在。

等等,这里有个问题:如果同一个位置被多次施工覆盖,值会变成负数。但我们只关心它是否大于0,所以负数也没关系,统计时判断> 0即可。

4.3 完整代码实现与参数计算

#include <iostream> using namespace std; int main() { int L, M; cin >> L >> M; // 差分数组,大小开到L+2,因为b+1可能等于L+1 int diff[10005] = {0}; // 初始状态:每个位置都是1 // 等价于diff[0] = 1,其余为0 diff[0] = 1; // 处理每个施工区间 for (int i = 0; i < M; i++) { int a, b; cin >> a >> b; diff[a] -= 1; diff[b + 1] += 1; } // 前缀和还原,统计大于0的位置 int count = 0; int current = 0; for (int i = 0; i <= L; i++) { current += diff[i]; if (current > 0) { count++; } } cout << count << endl; return 0; }

这段代码的关键点:

第一,diff数组大小。因为要访问diff[b+1],而b最大是L,所以b+1最大是L+1。数组至少要开到L+2才安全。

第二,初始值的设置。原数组全是1,对应的差分数组是diff[0]=1,diff[1..L]=0。这里用diff[0] = 1来初始化,而不是循环赋值,这是差分法的精髓——用O(1)完成初始化。

第三,前缀和的累加。current变量在循环中不断累加diff[i],得到的就是原数组第i个位置的值。如果大于0,说明树还在。

4.4 差分法的复杂度优势与验证

差分法的时间复杂度是O(L+M):处理M个区间是O(M),前缀和还原是O(L)。当L=10^7、M=10^5时,总操作数约10^7,轻松跑过。

为了验证两种解法结果一致,我拿一组数据实测:

输入:

5 2 1 3 2 4

布尔标记法过程:初始6棵树全在。第一次移走1、2、3,剩0、4、5。第二次移走2、3、4,剩0、5。答案2。

差分法过程:diff[0]=1。第一次:diff[1]-=1,diff[4]+=1。第二次:diff[2]-=1,diff[5]+=1。前缀和:i=0时current=1,count=1;i=1时current=0;i=2时current=-1;i=3时current=-1;i=4时current=0;i=5时current=1,count=2。答案2。

两种方法结果一致,验证通过。

5. 两种解法的选择策略与性能实测对比

5.1 什么时候用标记法,什么时候用差分

这个问题没有绝对答案,但有一条实用判断线:看区间总长度。

如果题目给的L在10^5以内,M在10^3以内,标记法完全够用,而且代码更直观,调试更方便。新手优先用标记法,先把逻辑跑通,再考虑优化。

如果L在10^6以上,或者M很大,标记法就可能超时,这时候必须上差分。差分法的代码也不复杂,多写几遍就熟了。

还有一种情况:如果题目要求动态查询,比如每次施工后都问当前剩多少棵树,那两种方法都不太合适,需要用线段树或树状数组。但这道题只问最终结果,所以差分足够。

5.2 实测性能数据

我在本地用C++跑了一组对比测试,编译器开O2优化:

数据规模标记法耗时差分法耗时
L=10^4, M=10^20.8ms0.3ms
L=10^5, M=10^312ms0.5ms
L=10^6, M=10^4超时(>1s)3ms
L=10^7, M=10^5超时28ms

从数据可以看出,小规模时两者差距不大,但规模上去之后,差分法的优势是指数级的。这也解释了为什么算法竞赛里差分是必备技能。

5.3 空间占用的权衡

两种方法的空间复杂度都是O(L),但实际占用有差异:

  • 布尔标记法:bool数组,每个元素1字节,L=10^7时约10MB。
  • 差分数组法:int数组,每个元素4字节,L=10^7时约40MB。

如果内存限制很紧(比如32MB),差分法可能反而吃亏。这时候可以用short或int8_t来压缩,但要注意数值范围。不过大多数题目内存限制都在128MB以上,40MB完全能接受。

提示:如果L特别大(比如10^9),两种方法都不行,需要离散化或扫描线。但那是另一个话题了。

6. 常见问题与排查技巧实录

6.1 答案总是差1?先查这三个地方

这是最高频的问题。答案差1,90%的情况是边界处理错了。按顺序检查:

第一,数组初始化范围。是不是写成了i < L?应该是i <= L。因为编号从0到L,共L+1个。

第二,区间标记范围。是不是写成了j < b?应该是j <= b。闭区间右端点要包含。

第三,统计范围。是不是写成了i < L?同样应该是i <= L。

这三个地方任意一个写错,答案就会差1。我建议写完后拿L=0、M=0这种极端情况测一下,如果输出1,说明边界对了。

6.2 差分法前缀和出现负数怎么办

有同学跑差分法时发现current变成负数了,以为代码错了。其实这是正常的。因为同一个位置可能被多个区间覆盖,减了多次。只要最后判断current > 0,负数不影响结果。

但如果你用的是“减到0就停”的逻辑,那就需要加max(0, ...)保护。不过这道题不需要,因为只关心最终是否大于0。

6.3 数组越界的隐蔽表现

C++的数组越界不会报错,但会读写到相邻内存。表现可能是:答案偶尔对偶尔错、程序崩溃、或者输出一个莫名其妙的数。排查方法是把数组开大一点,比如从10005改成10050,如果问题消失,那就是越界了。

更稳妥的做法是用vector,它自带边界检查(用.at()时)。但竞赛中为了速度,通常还是用原生数组,靠自觉开大。

6.4 输入格式的坑

有些平台的输入可能有多组测试数据,需要用while(cin >> L >> M)循环读取。如果只读一组,就会WA。这道题在常见平台上通常是单组,但养成看题面输入说明的习惯很重要。

另外,输入可能包含多余空格或换行,用cin自动跳过空白字符,不用手动处理。

6.5 常见问题速查表

问题现象可能原因解决方法
答案差1边界写成<而非<=检查所有循环边界
答案偏大区间右端点没标记确认j <= b
答案偏小0号位置没统计确认循环从0开始
程序崩溃数组越界数组开大几个单位
差分法结果异常diff数组没初始化确认diff[0]=1
多组数据只过第一组没写循环读取用while(cin>>...)

7. 从这道题延伸出去:区间处理的通用套路

7.1 差分思想的更多应用场景

差分不只是用来做这道题。它的核心是“用变化量代替绝对值”,这个思想在很多场景都有用:

  • 区间加、单点查:差分数组的经典应用。
  • 区间加、区间查:需要差分加前缀和,或者树状数组。
  • 二维差分:处理矩阵区域的加减操作。
  • 树上差分:处理树上路径的修改。

掌握差分,等于拿到了一把处理区间问题的万能钥匙。这道“校门外的树”就是最好的入门练习。

7.2 离散化:当L大到数组开不下时

如果L是10^9,数组开不下,但M只有10^5,这时候可以用离散化。把所有区间的端点收集起来排序,只处理这些关键点之间的段。这是差分思想的进阶应用,感兴趣可以专门练一下。

7.3 扫描线:处理区间覆盖的另一种思路

扫描线是另一种处理区间问题的套路。把每个区间的起点和终点当作事件,排序后从左到右扫描,维护当前覆盖层数。这道题也可以用扫描线做,思路和差分类似,但更通用,能处理动态查询。

我在实际刷题中发现,同一道题用不同方法做,能加深对每种方法的理解。建议你把这道题用标记法、差分法、扫描线各写一遍,对比它们的差异。写完之后,你对区间处理的理解会上一个台阶。

最后分享一个我自己的习惯:每做完一道经典题,就把代码存到一个模板库里,标注好适用场景和易错点。下次遇到类似题目,直接翻出来改,效率能提升好几倍。“校门外的树”这种题,就是模板库里必须有的一道。

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

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

立即咨询