☰
楼房搭建问题:从差分到贪心与堆的进阶
2026/10/17 9:58:51 网站建设 项目流程

最近在模拟赛里做到一道叫《楼房搭建》(building)的题,源文件名很直白,但题面并不是"给你几个数模拟盖楼"那么简单。它把区间操作、差分、贪心三件事揉在一起,考场上不少人是"看着样例能过,改个数据就懵"。我在赛后把整个思路重新推了一遍,顺便把同族的变式也整理了,写出来给准备省选的朋友做个参考。

下面按最常见的版本展开:有 n 栋楼,初始高度都为 0,给定每栋楼的目标高度 h_i,每次操作可以选一个连续区间 [l, r],把区间内所有楼房同时加高 1 层,问最少操作多少次,才能让每栋楼高度恰好等于目标高度,且过程中任意楼房高度不为负。如果你在别的 OJ 上看到的版本多了"可减"或"初始高度不为 0",不用慌,第 2 节会补上通用化的结论。

1. 拆题:先把"盖楼"翻译成差分

1.1 这题到底在问什么

把目标高度数组 h 画出来,它其实就是一条从左到右起伏的轮廓线。每次"区间加 1",相当于在某一段轮廓线上方再铺一层"土"。

换个角度理解:所有楼房都是一层一层从下往上盖的。每一层水平方向看过去,就是若干个互不相交的连续区间。比如目标高度是 [2, 0, 3],第一层要盖的区间是 [1,1] 和 [3,3],第二层同样,第三层只需要盖 [3,3]。于是问题变成:拼出整张轮廓线,最少需要多少个这样的"水平层段"。

从左往右扫目标高度,很容易发现规律:当 h_i 比 h_{i-1} 高时,说明这里必须"新开始"若干层;当 h_i 比 h_{i-1} 低时,只是某些层在这里"结束",并不需要额外的操作。所以答案只和"上升量"有关。

1.2 一步到位的公式

记 h_0 = 0,最少操作次数为:

ans = Σ_{i=1}^{n} max(0, h_i - h_{i-1})

这个公式又常写成差分形式。定义差分数组 d_i = h_i - h_{i-1},则答案就是所有正 d_i 的和。

为什么必须是正的增量?因为每一层从左边开始向右延伸时,只有在高度上升的位置才会产生"这个层段从这里开始"的需求。下降的位置只是让之前的某些层段结束,不需要额外开新层段。

例如 h = [2, 0, 3]:

ih_ih_i - h_{i-1}新开层段数
1222
20-20
3333

总数 = 2 + 3 = 5。实际操作:第 1 层盖 [1,1] 与 [3,3],第 2 层盖 [1,1] 与 [3,3],第 3 层盖 [3,3],正好 5 次。

1.3 为什么这个贪心是对的

先看下界:如果 h_i > h_{i-1},多出来的 h_i - h_{i-1} 层在位置 i 必须从某个区间的左端点开始,否则位置 i-1 早就达到这个高度,而位置 i 却不够。每一层段的起点对应一次操作,所以上升量之和就是操作次数的下界。

再看上界:按照从左到右的顺序,每遇到一次上升,就立刻新开足够多的区间,左端点放在当前位置;右端点先不固定,等到后面高度下降时再关闭对应数量的区间。这种构造一定能达到目标高度,而且操作次数恰好等于上升量之和。

所以这个贪心不仅正确,而且实现起来只有一层循环。

提示:这个结论同样适用于"每栋楼至少要达到某个高度"的版本。区别只是把"恰好等于"改成"覆盖住需求",答案不变,因为多盖的楼层没有意义,最优解总是贴着目标高度走。

2. 通用化:初始高度不同,允许正负调整

2.1 引入差异序列 c

模拟赛里有时候不会把初始高度全给成 0,而是给一个初始数组 a 和一个目标数组 b。这时候真正需要处理的是净增量:

c_i = b_i - a_i

如果题目规定只能加高,那么必须满足所有 c_i ≥ 0。此时答案仍然是:

ans = Σ_{i=1}^{n} max(0, c_i - c_{i-1})

其中 c_0 = 0。这等价于把"从 0 盖到 c_i"看成一个新问题。

如果题目允许加高也允许拆除,也就是每次操作可以选区间加 1,也可以选区间减 1,那么结论会更漂亮:答案等于差分数组的正部分之和。

设 c_0 = c_{n+1} = 0,构造差分:

e_i = c_i - c_{i-1}

最少操作次数 = Σ_{i=1}^{n+1} max(0, e_i) = -Σ_{i=1}^{n+1} min(0, e_i) = Σ |e_i| / 2

为什么这次连下降量也要算?因为拆除不能凭空发生,一次"区间减 1"在差分里是在左端点产生一个 -1,在右端点后一位产生一个 +1。要让差分数组全部归零,正负必须配对,而每一对配对就是一次操作,所以至少需要正差分总量这么多次操作。

2.2 一个完整例子

初始 a = [1, 3, 2],目标 b = [2, 0, 4]。

净增量 c = [1, -3, 2]

差分 e_1 = 1 - 0 = 1 e_2 = -3 - 1 = -4 e_3 = 2 - (-3) = 5 e_4 = 0 - 2 = -2

正差分之和 = 1 + 5 = 6,所以答案是 6。这 6 次操作可以这样构造:

  • 对区间 [1,3] 加 1 一次:高度从 [1,3,2] 变成 [2,4,3]
  • 对区间 [2,2] 减 1 四次:高度从 [2,4,3] 变成 [2,0,3]
  • 对区间 [3,3] 加 1 一次:高度从 [2,0,3] 变成 [2,0,4]

总共 6 次,过程中所有楼高非负,完全合法。

这个例子想说明一件事:允许拆除的时候,不一定非得严格地一层一层盖,中间某栋楼可以先被抬起来,之后再拆掉。差分模型把这种"超调再恢复"的过程也统一处理了。

2.3 什么时候配对会不合法

用差分配对时,隐含要求是"左端点在右端点之前"。比如 e_3 = +5 出现在位置 3,e_2 = -4 出现在位置 2,如果不加限制地让它们配对,就会得到一个右端点在左端点左边的非法区间,这种方案不可行。

解决办法是规定配对顺序:正差分对应"区间加 1"的左端,所以它必须匹配到它右侧的某个负差分;反过来,负差分在左、正差分在右,可以理解为"区间减 1"。在实现里,这通常用一个栈来维护尚未关闭的正差分位置,从左到右扫描,遇到负数时优先从栈里取最近的候选来配对。

如果题目还规定了"每栋楼任何时刻不能超过目标高度""区间长度不能超过 L"这类限制,配对就不能只看左右顺序了,这时候才会轮到堆出场。

3. 哪些变式必须用堆,以及怎么用

3.1 不定长区间加"长度限制"

有些版本会加一个很现实的条件:一次操作只能覆盖长度不超过 L 的连续段。比如脚手架长度受限,刷墙工人一次最多刷 L 栋楼。

这时候"把区间右端点一直拖到下降处"的策略不成立了,因为右端点和左端点的距离可能超过 L。正确贪心是:从左到右扫描,维护当前正在被覆盖的层数 cur。如果当前位置的需求 h_i 大于 cur,就必须新开 h_i - cur 个区间,每个区间从 i 开始,右端点尽量向右延伸到 i + L - 1(不能越界)。用差分数组记录每个位置有多少区间同时结束,就能在 O(n) 内完成。

这种写法不需要堆,因为新区间的右端点只有"尽量远"这一种最优选择,不需要比较多个候选。核心思想是:能延伸多远就延伸多远,反正左端点已经固定,右端点越远对后面的覆盖只会更有利。

3.2 给定一批固定区间,求最少选几个

这是另一类更常见的"楼房"题:有 m 个已经给定的施工区间,每个区间使用一次,会给区间内所有楼加高一层,问最少选多少个区间,才能让每栋楼被覆盖次数至少达到 h_i。

这种题才真正需要堆。贪心策略是:

  • 所有区间按左端点从小到大排序
  • 从左到右处理每个位置 i
  • 把所有左端点 ≤ i 的区间加入一个大根堆,堆里按照区间右端点排序
  • 弹出所有右端点 < i 的区间(已经无法覆盖当前位置)
  • 如果当前覆盖次数不够 h_i,就从堆顶取右端点最大的区间使用,每用一个覆盖计数加 1,直到满足需求或堆空

为什么取右端点最大的?因为对于当前位置左边的区间,右端点越大,就意味着它还能继续覆盖后边更多的位置。同样的代价,当然选剩余覆盖能力最强的区间。

核心代码如下:

#include <bits/stdc++.h> using namespace std; struct Seg { int l, r; bool operator<(const Seg& other) const { return l < other.l; } }; int main() { int n, m; cin >> n >> m; vector<long long> h(n + 2); for (int i = 1; i <= n; i++) cin >> h[i]; vector<Seg> seg(m); for (int i = 0; i < m; i++) { cin >> seg[i].l >> seg[i].r; } sort(seg.begin(), seg.end()); vector<long long> add(n + 3, 0); priority_queue<int> pq; // 大根堆,存右端点 int ptr = 0; long long cover = 0, ans = 0; for (int i = 1; i <= n; i++) { while (ptr < m && seg[ptr].l <= i) { pq.push(seg[ptr].r); ptr++; } cover += add[i]; while (!pq.empty() && pq.top() < i) pq.pop(); long long need = h[i] - cover; while (need > 0 && !pq.empty()) { int r = pq.top(); pq.pop(); ans++; cover++; add[r + 1]--; need--; } if (need > 0) { cout << -1 << '\n'; return 0; } } cout << ans << '\n'; return 0; }

这个版本要求区间使用次数不限,每个区间最多用一次。如果允许同一个区间重复使用,就在弹出后重新入堆,并做好次数限制,逻辑类似。

3.3 怎么判断自己的题该用哪种算法

我整理了一个简单的对照表,做题前先对号入座:

题面特征核心做法是否需要堆
任意连续区间加 1,目标恰好为 h差分正增量之和不需要
初始高度不为 0,允许加减差异数组 c 的差分正项和不需要
区间长度有上限,要求覆盖从左到右贪心,右端点尽量远不需要
给定若干可用区间,选最少区间覆盖需求堆贪心,按右端点取最大需要
区间可加减,但过程高度有上下限约束差分配对 + 栈维护合法性通常用栈,复杂时用堆

很多同学一看"省选模拟"就觉得必须写堆,结果把简单题做复杂了。其实基础版是一个 O(n) 扫描,连排序都不用。先看数据范围和操作自由度,再决定优化方向。

4. 代码实现与调试心得

4.1 基础版参考代码

以初始全 0、目标数组 h、只能加高为例:

#include <bits/stdc++.h> using namespace std; int main() { ios::sync_with_stdio(false); cin.tie(nullptr); int n; cin >> n; vector<long long> h(n + 1); for (int i = 1; i <= n; i++) cin >> h[i]; long long ans = 0; for (int i = 1; i <= n; i++) { if (h[i] > h[i - 1]) ans += h[i] - h[i - 1]; } cout << ans << '\n'; return 0; }

如果题目允许区间加也允许区间减,那就把 h 数组换成差异数组 c,再对差分数组统计正项和:

vector<long long> c(n + 2); for (int i = 1; i <= n; i++) c[i] = b[i] - a[i]; long long ans = 0; for (int i = 1; i <= n + 1; i++) { long long d = c[i] - c[i - 1]; if (d > 0) ans += d; }

注意 c[0] 和 c[n + 1] 都要定义为 0,循环到 n + 1 是必须的,不然最后一个差分项会漏掉。

4.2 最容易踩的坑

先说最经典的:答案要开 long long。n 到 2e5,高度到 1e9,正增量之和完全可能到 1e14 量级,int 必炸。很多选手公式写对了,最后挂在输出类型上,非常可惜。

其次是差分数组的开法。无论用哪种模型,只要涉及差分,数组至少开 n + 3。原因是区间右端点在 n 的时候,对应的差分位置是 r + 1 = n + 1,循环里还可能访问到 n + 2。开小了会出现随机越界,本地可能不报,放到 OJ 上就是一堆莫名其妙的 RE。

第三是堆贪心版本里"先弹失效区间"的顺序。不能在取堆顶之后才判断右端点是否小于当前位置,因为堆顶只是右端点最大的,不代表所有堆内区间都有效。必须先循环弹出所有 r < i 的区间,再尝试取区间使用。

第四,也是最容易被忽略的:一定要分清题面写的是"恰好等于目标高度"还是"至少达到目标高度"。基础版对"恰好"成立,堆贪心版对"至少"成立。有的题目数据弱,两种写法都能过样例,但换一组数据就全错。我模拟赛里就被这个坑过,审题花了二十分钟,最后改一行才 AC。

4.3 对拍:用暴力给自己兜底

这类题的正确性容易想当然,我强烈建议写一个暴力程序对拍。

暴力思路很简单:n 很小的时候,直接 DFS 枚举所有可能的区间操作,每次选一个区间加 1,直到所有楼高度达到目标,记录最小步数。也可以用 BFS 按层搜索,状态压缩成高度数组。

对拍脚本的流程是:

  • 随机生成 n,范围 1 到 8
  • 随机生成 h_i,范围 0 到 5
  • 跑暴力程序,得到答案 ans_brute
  • 跑贪心程序,得到答案 ans_fast
  • 两者不一致就输出当前数据和两个答案

我一般会跑 1 万组随机数据。如果对拍全部通过,基本可以放心提交。这个方法看起来笨,但对这类"区间 + 差分"的题特别管用,因为暴力的状态空间很小,而贪心的边界情况很多。

5. 从这道题能带走什么

先说结论:遇到任何"区间统一加/减"的题,第一步永远是写差分。区间操作在差分数组上只会影响两个点,这个转换能把问题维度从"区间"降到"点",后续无论是贪心还是数据结构优化都会清晰很多。

第二步是判断操作自由度。如果可以任意选择区间,且没有长度限制,多半是 O(n) 扫描;如果可用区间是离散给定的,或者区间长度受限且需要动态比较候选,才需要堆。很多省选模拟题其实是在考你"能不能忍住不写堆"。

第三步是别忽视合法性约束。差分配对只保证数量最少,不保证每栋楼在操作过程中不违反高度限制。遇到这种额外限制,先从栈/堆维护合法性的角度想一想,而不是直接套公式。

我在实际比赛中体会最深的一点是:这种看起来"名字很工程"的题,往往核心数学模型并不复杂。复杂的是人容易先入为主,觉得省选模拟必出数据结构。先把题意翻译成数学语言,再决定用什么工具,比一开始就抱着线段树和堆去找题眼要靠谱得多。

最后分享一个小技巧:比赛时拿到题,先在草稿纸上把题面里的操作翻译成符号语言,比如"区间 +1 = 左差分 +1、右端点后一位 -1",翻译完再去看数据范围。我自己的经验是,这类翻译做完,至少一半的思路已经出来了。剩下的,就是老老实实写暴力、对拍、补边界。希望这篇复盘能帮你在下一次模拟赛里少踩几个坑。

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

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

立即咨询