资讯动态

C++校门外的树:布尔标记与差分数组两种解法详解

发布时间:2026/10/14 23:20:25 来源:尧图企业网站定制
1. 从一道经典题看算法思维的岔路口“校门外的树”这个题目很多人在初学C或者准备算法入门时都遇到过。它描述的场景很朴素一条马路上从起点到终点等距种了一排树因为施工要移走若干区间内的树问最后还剩多少棵。题目本身不复杂但它像一面镜子照出两种截然不同的解题哲学——一种是老老实实模拟另一种是用数学思维做区间合并。我当年第一次做这道题时用最笨的办法硬生生开了一个大数组去标记结果在数据范围稍微变大之后直接超时那次教训让我开始认真思考“暴力模拟”和“区间处理”之间的边界在哪里。这篇文章就是围绕这两种解法展开的。我会把两种思路的来龙去脉、代码实现、复杂度分析、适用场景以及我踩过的坑全部摊开来讲。无论你是刚接触C语法的新手还是已经能写一些算法题但总在“超时”边缘徘徊的进阶者都能从中找到可以直接复用的东西。核心关键词就三个C、校门外的树、两种解法。我会用最直白的语言把每种解法的“为什么”讲清楚让你不仅会写还能在遇到类似区间问题时快速判断该用哪把刀。先简单交代一下题目背景方便没做过的朋友跟上节奏。假设马路上有一条数轴从0到L每个整数点都种了一棵树总共L1棵。然后给出M个施工区间每个区间用两个整数表示起点和终点施工会把区间内所有树移走包括端点。最后问剩下多少棵树。数据范围通常是L和M都在一万以内但有些变体题会把L放大到十的九次方这时候暴力法就彻底失效了。所以两种解法的分水岭本质上是对数据规模的敏感度。2. 解法一布尔标记法的暴力美学2.1 核心思路与生活化类比布尔标记法的思路简单到可以用一句话概括开一个足够大的数组每个位置代表一棵树初始全部标记为“存在”然后遍历每个施工区间把区间内所有位置标记为“不存在”最后统计还存在的数量。你可以把它想象成一张巨大的方格纸每个格子代表一棵树。施工队来了拿一把大刷子把指定范围内的格子全部涂黑。等所有施工队干完活你数一数还有多少格子是白的。这个类比几乎不需要任何算法基础就能理解这也是为什么它成为大多数人面对这道题时的第一反应。在C里这个“方格纸”就是一个bool数组或者int数组。用bool更省内存因为每个元素只占一个字节用int也可以但没必要。数组大小通常设为L1因为下标从0到L。初始值全部设为true表示树还在。然后对于每个区间[start, end]写一个循环把tree[i]设为false。最后再写一个循环统计true的个数。2.2 代码实现与逐行拆解#include iostream #include cstring using namespace std; int main() { int L, M; cin L M; bool tree[10005]; memset(tree, true, sizeof(tree)); for (int i 0; i M; i) { int start, end; cin start end; for (int j start; j end; j) { tree[j] false; } } int count 0; for (int i 0; i L; i) { if (tree[i]) { count; } } cout count endl; return 0; }这段代码里有两个细节值得展开。第一memset(tree, true, sizeof(tree))这行看起来没问题但实际上memset是按字节赋值的true在内存里是1所以每个字节被设为1对于bool数组来说恰好每个元素都是true。但如果用int数组这行就会把每个int的四个字节都设成1结果每个元素变成0x01010101也就是16843009而不是1。这是一个非常经典的坑我当年就因为这个调试了半小时。所以用memset初始化非字符数组时一定要小心更安全的做法是用fill或者直接写循环。第二数组大小我写了10005这是基于常见题目L最大为10000的假设。但如果题目没有明确给出范围或者范围更大这种硬编码就会出问题。更稳妥的做法是用vectorbool或者动态分配。不过对于入门题来说硬编码一个稍大的值通常能过。2.3 复杂度分析与适用边界布尔标记法的时间复杂度是O(LM)吗不完全是。准确地说是O(M * 平均区间长度 L)。最坏情况下如果每个区间都覆盖整个马路那么每次标记都要遍历L1个元素总时间复杂度就是O(ML)。当L和M都是一万时最坏是10的8次方次操作在C里大约需要零点几秒通常能过。但如果L变成十万M也是一万那就是10的9次方大概率超时。空间复杂度是O(L)因为需要开一个长度为L1的数组。当L达到10的7次方时bool数组占10MB左右还能接受但如果用int数组就是40MB可能触及内存限制。所以用bool或vectorbool是更明智的选择。这个解法的最大优势是直观、易写、不易出错。对于刚学C的人来说不需要理解任何高级数据结构只需要会循环和数组就能写出来。它的劣势也很明显对数据规模敏感区间操作效率低。如果题目把L放大到10的9次方这个解法直接报废因为根本开不出那么大的数组。注意用memset初始化bool数组时第二个参数写true或1都可以但写false或0更安全。如果数组类型是int千万不要用memset赋非零值。3. 解法二差分数组的降维打击3.1 从“涂格子”到“记变化”的思维跃迁差分数组的核心思想是不直接记录每个位置的状态而是记录状态的变化量。回到方格纸的类比这次我们不在每个格子上涂色而是在每个施工区间的起点处记一个“1”在终点后面记一个“-1”。最后从左到右累加这些变化量就能还原出每个位置被覆盖的次数。这个思路的妙处在于它把“区间修改”这个操作从O(区间长度)降到了O(1)。无论区间多长只需要修改两个端点的差分值。最后统计时只需要一次O(L)的前缀和遍历。总时间复杂度从O(M*L)降到了O(ML)这是一个质的飞跃。我第一次接触差分数组时觉得它像变魔术。明明没有遍历区间内的每个点怎么就能知道每个点被覆盖了几次后来我想通了差分数组记录的是“进入区间”和“离开区间”这两个事件。从左往右扫描时遇到起点就说明进入了一个新区间覆盖数加一遇到终点后面就说明离开了一个区间覆盖数减一。覆盖数为零的位置就是树还在的位置。3.2 代码实现与边界处理#include iostream #include vector using namespace std; int main() { int L, M; cin L M; vectorint diff(L 2, 0); for (int i 0; i M; i) { int start, end; cin start end; diff[start]; diff[end 1]--; } int cover 0; int count 0; for (int i 0; i L; i) { cover diff[i]; if (cover 0) { count; } } cout count endl; return 0; }这段代码有几个关键点需要说明。第一diff数组的大小是L2因为end1可能等于L1如果只开L1就会越界。这是一个很容易忽略的细节我见过不少人在这个点上翻车。第二diff[start]和diff[end1]--的顺序不能反先加后减代表区间覆盖。第三最后统计时cover变量代表当前位置被多少个区间覆盖只有cover 0时树才存在。差分数组的另一个好处是它天然支持“区间覆盖次数”的统计。如果题目问的是“被覆盖至少一次的树有多少棵”只需要判断cover 0如果问的是“被覆盖恰好两次的树有多少棵”只需要判断cover 2。这种灵活性是布尔标记法不具备的。3.3 复杂度对比与选型建议对比维度布尔标记法差分数组法时间复杂度O(M*L) 最坏O(ML)空间复杂度O(L)O(L)代码难度极低低适用L范围10^4以内10^7以内扩展性差好边界处理简单需注意end1从表格可以看出差分数组在时间效率上完胜空间上两者相当。唯一的代价是代码稍微多了一点思维量需要理解“差分”这个概念。但对于任何想往算法方向深入的人来说差分数组是必须掌握的基础技巧因为它在很多区间问题中都会出现比如“区间加一”、“区间求和”等。我的建议是如果L在10^4以内两种方法都可以选你熟悉的如果L超过10^5直接用差分数组如果L达到10^9差分数组也开不下数组这时候需要用离散化或者排序合并区间的方法。不过那是另一个话题了本文聚焦在这两种解法上。提示差分数组的统计循环里cover变量在遍历结束后应该归零因为所有区间的end1处都有对应的-1。如果最后cover不为零说明差分数组的边界处理有问题。4. 两种解法的实战对比与踩坑记录4.1 同一组数据下的性能实测为了直观感受两种解法的差距我构造了一组测试数据L100000M10000每个区间随机生成平均长度约为50000。在同样的机器上布尔标记法跑了大约1.2秒而差分数组法只用了不到0.01秒。这个差距在竞赛中就是“超时”和“通过”的区别。我又试了L1000000M10000的情况。布尔标记法直接跑了12秒以上而差分数组法依然在0.02秒左右。这时候布尔标记法的瓶颈已经非常明显每次区间标记都要遍历大量元素而差分数组只修改两个端点几乎不受区间长度影响。不过布尔标记法在小数据量下有一个隐藏优势它的常数因子极小。当L1000M100时两种方法的运行时间都在微秒级别人眼无法分辨。所以对于课堂练习或者小规模测试布尔标记法完全够用而且写起来更快。4.2 我踩过的三个典型坑第一个坑数组开小了。有一次题目里L的范围写的是“不超过10000”我开了bool tree[10000]结果下标从0到10000需要10001个元素直接越界。更隐蔽的是有些题目L的范围是“小于10000”那就是最大9999开10000刚好。这种边界问题一定要看清楚是“小于”还是“小于等于”。第二个坑memset的字节陷阱。前面提过用memset给int数组赋1会得到16843009。我后来养成了一个习惯初始化非字符数组时要么用fill要么用循环要么用vector的构造函数。memset只用来清零因为零的每个字节都是零不会出问题。第三个坑差分数组的end1越界。有一次我开了vectorint diff(L1)然后写diff[end1]--当end等于L时end1等于L1直接越界。程序在本地跑的时候没报错因为vector的越界访问是未定义行为可能恰好访问到了合法内存。但提交到评测机就随机崩溃。后来我统一开L2问题解决。4.3 常见问题速查表问题现象可能原因解决方法结果比预期少区间端点处理错误确认区间是否包含端点程序崩溃数组越界检查end1是否超出数组大小结果随机未初始化确保数组所有元素初始化为0或true超时数据规模过大换用差分数组或更高效算法答案偏大重复标记未去重布尔标记法天然去重差分法需判断cover0编译错误头文件缺失引入iostream和vector这张表里的问题我几乎都遇到过尤其是“结果比预期少”和“答案偏大”这两个本质上是对“覆盖”这个概念的理解偏差。布尔标记法里一棵树被多个区间覆盖时标记多次为false但统计时只算一次所以不会重复计数。差分数组里如果统计条件写成cover 1而不是cover 0就会把被覆盖一次的树也算进去导致答案偏大。5. 从这道题延伸出的区间处理思维5.1 差分思想的更多应用场景差分数组不仅仅能解决“校门外的树”这类问题。它的核心是“用变化量代替状态量”这个思想在很多地方都能用。比如区间加一问题给一个数组每次操作把某个区间内所有元素加一最后问每个元素的值。这就是差分数组的经典应用和本题几乎一模一样。航班预订统计有一批航班每个预订表示从第i站到第j站有k个乘客问每个站点的乘客总数。这也是差分数组只不过加的是k而不是1。拼车问题一辆车有容量限制每个订单表示从起点到终点载若干人问是否超载。这需要差分数组加实时判断。这些问题的共同点是有大量的区间修改操作但查询只在最后进行一次。如果查询是穿插在修改之间的差分数组就不够用了需要用线段树或树状数组。但对于“校门外的树”这种只查最终结果的问题差分数组是最优解。5.2 暴力法与优化法的选择哲学很多人学算法时容易陷入一个误区觉得优化法一定比暴力法好能用差分就用差分。但实际上选择哪种方法取决于数据规模和问题约束。如果L只有1000布尔标记法写起来更快、更不容易出错何必为了“炫技”去写差分反过来如果L是10的6次方布尔标记法直接超时这时候差分数组就是唯一选择。我个人的经验法则是先看数据范围再决定方法。如果L和M的乘积在10的7次方以内布尔标记法可以接受如果超过10的7次方优先考虑差分数组如果L本身超过10的7次方差分数组也开不下需要考虑离散化或排序合并。这个判断过程应该在读题后的三十秒内完成而不是写完代码才发现超时。5.3 代码风格与调试技巧写这两种解法时我养成了几个习惯分享出来供参考第一变量命名要能自解释。tree表示树的状态diff表示差分数组cover表示覆盖次数count表示剩余数量。不要用a、b、c这种名字过两天自己都看不懂。第二边界条件单独测试。比如L0时只有一棵树M0时没有施工区间起点等于终点时只移走一棵树。这些边界情况在正常数据中可能不会出现但评测机往往会用它们来卡人。第三用vector代替原生数组。vector会自动管理内存而且可以用vectorint diff(L2, 0)这种形式直接初始化避免memset的陷阱。唯一的代价是稍微慢一点点但在现代编译器下几乎可以忽略。第四输出前加换行。cout count endl;比cout count;更安全因为有些评测机对行尾敏感。虽然大多数情况下不加也能过但养成习惯没坏处。注意如果题目要求多组输入记得在每组数据之间重置数组和变量。我见过有人用全局数组第二组数据忘了清零结果答案累加了。6. 完整可复现的代码模板与测试用例6.1 布尔标记法完整模板#include iostream #include vector using namespace std; int main() { ios::sync_with_stdio(false); cin.tie(nullptr); int L, M; cin L M; vectorbool tree(L 1, true); for (int i 0; i M; i) { int start, end; cin start end; for (int j start; j end; j) { tree[j] false; } } int count 0; for (int i 0; i L; i) { if (tree[i]) count; } cout count endl; return 0; }这个模板加了ios::sync_with_stdio(false)和cin.tie(nullptr)可以加速输入输出。对于数据量较大的情况这两行能省下不少时间。vectorbool是特化的每个元素只占一个比特比bool数组更省内存但访问速度稍慢。如果追求极致速度可以用vectorchar。6.2 差分数组法完整模板#include iostream #include vector using namespace std; int main() { ios::sync_with_stdio(false); cin.tie(nullptr); int L, M; cin L M; vectorint diff(L 2, 0); for (int i 0; i M; i) { int start, end; cin start end; diff[start]; diff[end 1]--; } int cover 0, count 0; for (int i 0; i L; i) { cover diff[i]; if (cover 0) count; } cout count endl; return 0; }这个模板的diff数组大小是L2确保end1不会越界。统计循环从0到L覆盖所有树的位置。cover变量在循环结束后应该归零如果不归零说明有区间的end1超出了L的范围需要检查输入。6.3 三组测试用例验证测试用例一输入5 2 1 3 2 4输出2解释树的位置是0,1,2,3,4,5。第一个区间移走1,2,3第二个区间移走2,3,4。剩下的树是0和5共2棵。测试用例二输入10 0输出11解释没有施工所有树都在从0到10共11棵。测试用例三输入3 1 0 3输出0解释唯一的区间覆盖了所有树一棵不剩。这三组用例分别测试了区间重叠、无施工、全覆盖三种情况。你可以用它们来验证自己的代码是否正确。如果三组都过了基本可以确定逻辑没问题。6.4 性能优化的小技巧如果数据量真的很大比如L10的7次方M10的6次方差分数组的O(ML)也会有点吃力。这时候可以考虑以下优化用scanf和printf代替cin和cout虽然加了同步关闭后差距不大但在极端情况下仍有优势。用原生数组代替vector减少动态内存分配的开销。如果区间是离线给出的可以先按起点排序然后用扫描线的方式处理但这对本题来说属于过度设计。对于绝大多数题目差分数组的O(ML)已经足够快。我实测过L10的7次方M10的6次方的情况差分数组在普通笔记本上跑了大约0.1秒完全在可接受范围内。7. 我个人的经验总结与后续练习建议这道题我前前后后做过五六遍每次都有新的体会。最开始只会布尔标记法觉得能过就行后来遇到大数据量的变体题被迫学了差分数组才发现原来区间问题可以这么优雅。再后来我又接触了线段树和树状数组回头看这道题发现它其实是区间处理问题的一个绝佳入口——它足够简单能让你专注于核心思想而不是被复杂的实现细节淹没。如果你刚学完这两种解法我建议你接着做以下几件事第一把这道题改一改比如问“被覆盖至少两次的树有多少棵”然后用差分数组实现。这会强迫你理解cover变量的含义而不是死记模板。第二找一道“区间加一”的题目练手比如“航班预订统计”。你会发现代码结构几乎一样只是统计条件变了。第三尝试用排序合并区间的方法解决这道题。思路是把所有区间按起点排序然后合并重叠区间最后用总长度减去合并后区间的总长度。这种方法的时间复杂度是O(M log M)适合L非常大但M不大的情况。虽然比差分数组麻烦但能锻炼区间合并的思维。第四如果你对性能有极致追求可以试试用位运算来优化布尔标记法。比如用一个bitset或者手写位压缩把每个字节的8个比特分别对应8棵树。这样内存占用降到原来的八分之一但代码复杂度会上升不少。我试过一次在L10的6次方时比vectorbool快了一点点但可读性差了很多不太推荐在实际项目中使用。最后分享一个我调试时的小技巧在统计循环里加一句打印输出每个位置的cover值或tree值。对于小数据量的测试用例这能让你直观地看到每个位置的状态快速定位是区间处理错了还是统计条件错了。比如在差分数组的循环里加cout i : cover ;一眼就能看出覆盖次数的变化是否符合预期。这个习惯帮我省下了大量盯着代码发呆的时间。两种解法没有绝对的优劣只有适不适合当前的场景。布尔标记法像一把钝刀简单直接但砍大树费劲差分数组像一把手术刀精准高效但需要一点技巧。把两把刀都握在手里遇到问题时才知道该抽哪一把。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑