资讯动态

扫描线算法与线段树结合:高效解决矩形奇偶覆盖面积问题

发布时间:2026/8/29 10:30:57 来源:尧图企业网站定制
1. 项目概述奇偶覆盖问题的本质最近在复盘蓝桥杯的历年真题第十一届国赛C/CA组的“奇偶覆盖”这道题给我留下了挺深的印象。它初看像是一道普通的几何面积计算题但仔细琢磨后你会发现它巧妙地将扫描线算法和线段树这两个经典数据结构与算法结合并引入了一个“奇偶性”的判定维度使得题目从单纯的“求面积并”升级为了“求特定条件下的面积”。这种题目非常考验选手对基础算法的深刻理解和灵活运用能力而不仅仅是套模板。简单来说题目会给你平面上一系列矩形坐标都是整数然后问你所有被奇数个矩形覆盖的区域的总面积是多少同时可能还会问被偶数个矩形覆盖的区域面积。这和我们平时做的求矩形面积并无论被覆盖多少次只算一次或者求矩形面积交必须被所有矩形都覆盖都不一样。它关注的是覆盖的“层数”的奇偶性。这就引出了核心需求我们需要一个能高效统计每个位置被覆盖次数并能根据奇偶性进行聚合的数据结构。线段树维护区间和配合扫描线在时间维度这里是x轴或y轴上推进就成了解决这类问题的标准且高效的武器。这道题适合已经掌握线段树和扫描线基础想要深入理解其变种应用或者正在备战蓝桥杯等算法竞赛的开发者。通过拆解这道题我们不仅能巩固扫描线线段树的经典框架更能学会如何根据问题需求奇偶性统计来定制线段树的维护信息与更新策略这是从“会用”到“精通”的关键一步。2. 核心思路与算法选型分析面对“奇偶覆盖”问题最直接的暴力法是离散化坐标后在二维网格上模拟覆盖过程对每个单位格子进行计数。假设矩形坐标范围是[0, N]那么时间复杂度是 O(N²) 乘以矩形数量在坐标范围较大时完全不可行。因此我们必须降维。2.1 为什么是扫描线扫描线算法的核心思想是“化静为动”将二维的静态面积问题转化为一维的动态区间统计问题。我们想象有一根垂直的线从左到右或从上到下扫过整个平面。当扫描线移动时它与矩形的交集是一系列竖直线段如果水平扫描则是水平线段。这些线段的高度和覆盖情况会随着扫描线触及矩形的左右边界而发生变化。这样我们就把求面积问题分解成了无数个“在当前位置x计算被覆盖的纵向长度”的问题。总面积就是这些长度与扫描线移动的微小宽度即相邻x坐标的差值的积分离散求和。2.2 为什么是线段树在扫描线扫过的每一个x位置我们需要快速知道当前y轴上有哪些区间被覆盖了以及覆盖的总长度是多少。而且这个覆盖情况会频繁地动态更新遇到矩形的左边界就在对应的y区间上“增加一层覆盖”遇到右边界就“减少一层覆盖”。线段树正是为了高效处理这种区间修改增加/减少覆盖次数和区间查询查询被覆盖的总长度而生的数据结构。它可以在 O(logN) 的时间内完成一次更新或查询N是y轴坐标离散化后的点数。2.3 从“面积并”到“奇偶覆盖”的思维转换传统的扫描线求面积并线段树节点只需要维护一个信息len即当前节点代表的y轴区间内被覆盖次数大于0的长度。我们只关心“是否被覆盖”不关心覆盖了几层。 而“奇偶覆盖”要求我们区分覆盖次数的奇偶性。因此线段树节点需要维护更丰富的信息。一个经典的维护策略是cnt: 当前区间被整体覆盖的次数懒标记不下传到底用于表征区间整体的覆盖增减。len_odd: 当前区间内被覆盖次数为奇数的子区间总长度。len_even: 当前区间内被覆盖次数为偶数的子区间总长度包括0次。当我们对某个区间执行“覆盖次数1”遇到左边界时这个区间内所有点的覆盖次数奇偶性都会翻转原来的奇数次覆盖变成偶数次偶数次变成奇数次。因此更新操作就变成了交换len_odd和len_even并更新cnt。同理“覆盖次数-1”遇到右边界也是奇偶性翻转。线段树的push_up操作则需要根据左右儿子的len_odd和len_even来合并信息但这里有一个关键如果当前节点有cnt即被整体覆盖了若干层那么它的len_odd和len_even的含义是基于“子区间覆盖次数加上cnt”之后的奇偶性。这需要我们在push_up和更新时仔细设计。注意这里有一个非常重要的实现细节。很多初学者会试图在线段树中直接维护每个“点”被覆盖的次数然后通过遍历来统计奇偶长度这又退化为O(N)查询失去了线段树的意义。正确的做法是让线段树的节点直接维护“区间”的奇偶长度信息通过数学关系在O(1)时间内由子节点更新父节点这正是线段树高效的核心。3. 数据结构设计与关键实现细节理解了算法框架我们来具体设计线段树节点和操作。假设我们沿x轴方向扫描那么需要对y轴坐标建立线段树。3.1 坐标离散化处理矩形的y坐标可能是很大的整数直接作为线段树下标会浪费空间。我们需要离散化。收集所有矩形的y1和y2假设矩形由(x1, y1, x2, y2)定义且y2 y1。排序并去重得到一个有序数组ys。线段树中的每个“叶子节点”不再代表一个点而是代表一个区间[ys[i], ys[i1])。这是扫描线算法中处理“连续区间”的常见技巧避免了边界问题。因此如果ys有m个点线段树实际管理的区间数是m-1个。3.2 线段树节点定义struct SegNode { int l, r; // 节点管理的区间在离散化数组ys中的下标范围对应实际y轴区间[ys[l], ys[r1]) int cnt; // 懒标记表示整个区间被额外覆盖的次数不向下传递 int len_odd; // 当前区间内被奇数次覆盖的子区间总长度 int len_even; // 当前区间内被偶数次覆盖的子区间总长度 } tr[N * 4]; // N为离散化后ys的大小这里l和r是离散化后的索引。区间长度length ys[r1] - ys[l]。3.3 核心操作push_up 向上更新这是最容易出错的地方。一个节点的len_odd和len_even如何由其左右儿子计算得来如果当前节点u的cnt为奇数意味着这个区间整体被覆盖了一层。那么对于这个区间内的任何子区间其“真实”覆盖次数 “儿子维护的覆盖次数” 1。因此儿子维护的“奇数长度”对应到这里就是“偶数长度”儿子的“偶数长度”对应到这里就是“奇数长度”。所以tr[u].len_odd tr[u1].len_even tr[u1|1].len_even;tr[u].len_even tr[u1].len_odd tr[u1|1].len_odd;如果当前节点u的cnt为偶数包括0意味着cnt不改变奇偶性。那么儿子维护的奇偶长度就是真实长度。tr[u].len_odd tr[u1].len_odd tr[u1|1].len_odd;tr[u].len_even tr[u1].len_even tr[u1|1].len_even;如果当前节点是叶子节点l r区间长度为length ys[r1] - ys[l]。那么若cnt为奇数len_odd length,len_even 0。若cnt为偶数len_odd 0,len_even length。在代码实现中我们可以把叶子节点和非叶子节点的逻辑统一到push_up函数中void push_up(int u) { if (tr[u].cnt 1) { // 当前区间整体被奇数次覆盖 // 奇偶翻转 tr[u].len_odd (tr[u].l tr[u].r) ? (ys[tr[u].r1] - ys[tr[u].l]) : (tr[u1].len_even tr[u1|1].len_even); tr[u].len_even (tr[u].l tr[u].r) ? 0 : (tr[u1].len_odd tr[u1|1].len_odd); } else { // 当前区间整体被偶数次含0次覆盖 tr[u].len_odd (tr[u].l tr[u].r) ? 0 : (tr[u1].len_odd tr[u1|1].len_odd); tr[u].len_even (tr[u].l tr[u].r) ? (ys[tr[u].r1] - ys[tr[u].l]) : (tr[u1].len_even tr[u1|1].len_even); } }3.4 区间修改add当扫描线遇到一个矩形的左边时我们需要给对应y区间[y1, y2)的覆盖次数1遇到右边时-1。void add(int u, int l, int r, int v) { // v为1或-1 if (l tr[u].l tr[u].r r) { tr[u].cnt v; push_up(u); // 覆盖次数改变立即更新当前节点的奇偶长度 return; } // 不需要push_down因为cnt是懒标记且push_up的逻辑已经考虑了cnt。 int mid (tr[u].l tr[u].r) 1; if (l mid) add(u 1, l, r, v); if (r mid) add(u 1 | 1, l, r, v); push_up(u); // 合并儿子信息 }这里最关键的一点是我们不需要传统的push_down操作。因为cnt表示的是对整个区间的覆盖增减而push_up函数在计算len_odd/len_even时已经直接使用了cnt的值。修改时我们只更新当前节点的cnt然后调用push_up(u)。push_up会基于新的cnt和儿子的信息计算出正确的len_odd/len_even。这种“不下传懒标记只在更新和上传时考虑其影响”的线段树常被称为“不下传懒标记的线段树”或“计数线段树”在扫描线问题中非常常见且高效。4. 扫描线事件处理与主流程搭建有了线段树我们需要组织扫描线事件并驱动整个计算流程。4.1 事件定义与排序每个矩形产生两个事件入边x x1, 在y区间[y1, y2)上执行1操作。出边x x2, 在y区间[y1, y2)上执行-1操作。 我们将这些事件存储为结构体struct Event { int x; // 事件发生的x坐标 int y1, y2; // 影响的y轴区间 int type; // 1 表示入边-1 表示出边 bool operator(const Event e) const { return x e.x; // 按x坐标升序排序 } };将所有事件放入数组按x坐标排序。4.2 主算法流程离散化收集所有y1,y2排序去重得到数组ys。构建事件遍历每个矩形创建入边和出边事件。注意这里的y1,y2需要映射为离散化后的索引。我们需要找到满足ys[i] y1的最小索引i作为区间左端点找到满足ys[j] y2的最小索引j那么线段树操作的区间是[i, j-1]对应原始区间[y1, y2)。初始化线段树根据ys的大小m建立线段树管理区间[0, m-2]因为共有 m-1 个段。初始时所有节点的cnt0,len_odd0,len_even为对应区间长度。扫描sort(events.begin(), events.end()); long long ans_odd 0; // 奇覆盖总面积 long long ans_even 0; // 偶覆盖总面积 int last_x events[0].x; // 上一个事件的x坐标 for (int i 0; i events.size(); ) { int cur_x events[i].x; // 计算从last_x到cur_x这一段的面积 long long width cur_x - last_x; long long height_odd tr[1].len_odd; // 根节点存储了当前整个y轴上的奇覆盖长度 long long height_even tr[1].len_even; // 根节点存储了当前整个y轴上的偶覆盖长度 ans_odd width * height_odd; ans_even width * height_even; // 处理所有x坐标相同的事件 while (i events.size() events[i].x cur_x) { Event e events[i]; // 找到离散化后的y区间索引 int l lower_bound(ys.begin(), ys.end(), e.y1) - ys.begin(); int r lower_bound(ys.begin(), ys.end(), e.y2) - ys.begin() - 1; if (l r) { // 有效区间 add(1, l, r, e.type); } i; } last_x cur_x; }输出结果ans_odd即为被奇数个矩形覆盖的区域总面积。实操心得事件处理循环的写法很关键。外层for循环的i指针控制着扫描线的推进cur_x内层while循环处理同一x坐标上的所有事件入边和出边。一定要先计算last_x到cur_x之间的面积此时线段树反映的是last_x位置之后的覆盖状态然后再处理cur_x处的事件来更新线段树状态为下一段扫描做准备。顺序反了会导致面积计算错误。5. 边界情况与常见问题排查即使理解了算法实现时也极易踩坑。下面是我在实现和调试过程中遇到的一些典型问题及解决方法。5.1 坐标离散化与区间表示这是最易错点之一。线段树节点管理的是ys[i]到ys[i1]这段左闭右开的区间。当我们收到一个矩形[y1, y2)我们需要找到ys中第一个大于等于y1的点索引l和第一个大于等于y2的点索引r。那么线段树需要修改的区间是[l, r-1]。为什么是r-1因为ys[r]对应的是y2这个点而我们的区间是[ys[l], ys[r])即从ys[l]到ys[r]的前一个离散点。这恰好对应[y1, y2)。边界检查务必确保l r-1才进行更新否则当y1和y2离散到同一个点即区间长度为0时传入l r-1会给线段树函数带来问题。5.2 线段树节点信息更新的逻辑一致性push_up函数必须正确处理叶子节点和非叶子节点且逻辑要与cnt的含义自洽。一个有效的调试方法是构造极小数据手动模拟线段树的更新过程。测试用例1只有一个矩形(0,0,2,2)。预期奇覆盖面积4偶覆盖面积0。测试用例2两个完全重合的矩形(0,0,2,2)。预期奇覆盖面积0因为任何点都被覆盖2次偶数偶覆盖面积4。测试用例3两个部分重叠的矩形如(0,0,2,2)和(1,1,3,3)。可以手算重叠部分被覆盖2次偶非重叠部分被覆盖1次奇验证结果。5.3 数据范围与溢出坐标范围题目未明确但蓝桥杯国赛数据通常较强。离散化数组ys和线段树数组tr的大小应开到2N * 4每个矩形2个y坐标N个矩形。面积溢出最终面积可能是(x坐标差) * (y坐标差)两个1e5级别的数相乘会爆int。ans_odd,ans_even以及线段树中的len_odd/len_even都必须使用long long。5.4 事件排序与处理顺序如果两个事件x坐标相同先处理入边(1)还是先处理出边(-1)这取决于矩形的定义。通常我们认为矩形是左闭右开的区间[x1, x2)。那么在xx1这条线上矩形开始覆盖在xx2这条线上矩形结束覆盖。因此对于同一x坐标应该先处理出边结束旧的再处理入边开始新的还是反过来实际上由于我们的面积计算是(cur_x - last_x) * current_height而current_height是last_x之后的状态所以我们需要保证在处理cur_x处的事件之前线段树的状态对应的是区间(last_x, cur_x]的覆盖情况。对于x坐标相同的情况无论入边出边它们影响的都是x坐标以右的区间。因此常见的、也是正确的做法是将所有x坐标相同的事件视为同时发生。在计算完面积后一次性处理所有这些事件无论类型。这样线段树在last_x之后到cur_x之前的状态是稳定的计算出的面积是正确的。代码中内层while循环正是这样实现的。5.5 线段树build初始化建树时每个叶子节点的len_even应初始化为其区间长度(ys[r1]-ys[l])len_odd初始化为0cnt初始化为0。非叶子节点的信息通过push_up由叶子节点合并而来。6. 完整代码框架与注释将以上所有部分整合下面给出一个清晰的C实现框架。为了突出重点省略了IO部分和一些细节但核心逻辑完整。#include iostream #include vector #include algorithm using namespace std; const int MAXN 100010; // 根据题目规模调整 struct SegNode { int l, r; int cnt; // 区间整体覆盖次数 long long len_odd, len_even; // 奇/偶覆盖长度 } tr[MAXN * 8]; // 离散化后点数最多2N线段树开4倍再乘2以保安全 vectorint ys; // 离散化y坐标 vectorstruct Event events; struct Event { int x, y1, y2, type; bool operator(const Event other) const { return x other.x; } }; // 根据离散化数组ys获取实际y值 inline int getY(int idx) { return ys[idx]; } void push_up(int u) { if (tr[u].cnt 1) { if (tr[u].l tr[u].r) { int length getY(tr[u].r 1) - getY(tr[u].l); tr[u].len_odd length; tr[u].len_even 0; } else { tr[u].len_odd tr[u1].len_even tr[u1|1].len_even; tr[u].len_even tr[u1].len_odd tr[u1|1].len_odd; } } else { if (tr[u].l tr[u].r) { int length getY(tr[u].r 1) - getY(tr[u].l); tr[u].len_odd 0; tr[u].len_even length; } else { tr[u].len_odd tr[u1].len_odd tr[u1|1].len_odd; tr[u].len_even tr[u1].len_even tr[u1|1].len_even; } } } void build(int u, int l, int r) { tr[u] {l, r, 0, 0, 0}; if (l r) { // 叶子节点初始化len_even为区间长度 int length getY(r 1) - getY(l); tr[u].len_even length; tr[u].len_odd 0; return; } int mid (l r) 1; build(u 1, l, mid); build(u 1 | 1, mid 1, r); push_up(u); } void add(int u, int l, int r, int v) { if (l tr[u].l tr[u].r r) { tr[u].cnt v; push_up(u); return; } int mid (tr[u].l tr[u].r) 1; if (l mid) add(u 1, l, r, v); if (r mid) add(u 1 | 1, l, r, v); push_up(u); } int main() { int n; // 矩形数量 cin n; for (int i 0; i n; i) { int x1, y1, x2, y2; cin x1 y1 x2 y2; // 确保y2 y1, x2 x1 if (y1 y2) swap(y1, y2); if (x1 x2) swap(x1, x2); events.push_back({x1, y1, y2, 1}); // 入边 events.push_back({x2, y1, y2, -1}); // 出边 ys.push_back(y1); ys.push_back(y2); } // 1. 离散化y坐标 sort(ys.begin(), ys.end()); ys.erase(unique(ys.begin(), ys.end()), ys.end()); // 2. 构建线段树管理区间[0, m-2] int m ys.size(); // 离散化后点的数量 build(1, 0, m - 2); // 共有m-1个区间段 // 3. 事件排序 sort(events.begin(), events.end()); // 4. 扫描线 long long ans_odd 0; long long ans_even 0; int last_x events[0].x; for (int i 0; i events.size(); ) { int cur_x events[i].x; // 计算上一段扫描区域的面积 long long width cur_x - last_x; ans_odd width * tr[1].len_odd; ans_even width * tr[1].len_even; // 处理所有x坐标为cur_x的事件 while (i events.size() events[i].x cur_x) { Event e events[i]; // 找到离散化后的区间索引 int l lower_bound(ys.begin(), ys.end(), e.y1) - ys.begin(); int r lower_bound(ys.begin(), ys.end(), e.y2) - ys.begin() - 1; if (l r) { add(1, l, r, e.type); } i; } last_x cur_x; } cout ans_odd endl; // 输出奇覆盖面积 // 如果需要偶覆盖面积也可以输出 ans_even // cout ans_even endl; return 0; }7. 性能分析与优化空间上述算法的时间复杂度是O(N log N)其中N是矩形数量。主要开销在事件排序O(N log N)和每次线段树操作O(log M)M是离散化后y轴区间数总操作次数为2N次因此总复杂度为O(N log N N log M)通常M与N同阶故为O(N log N)。空间复杂度为O(N)。优化点离散化优化如果坐标范围不大例如在1e5以内可以不用离散化直接以坐标值为下标建树但空间消耗较大。离散化是更通用的做法。线段树实现上述实现是递归版易于理解。在竞赛中为了极致速度可以考虑非递归zkw线段树但代码复杂度会增加。对于此题递归版本完全足够。事件处理如果矩形数量极大事件数组的排序和遍历是瓶颈但O(N log N)已是此类问题最优。扩展思考 这道题是“奇偶覆盖”线段树节点维护了奇偶长度。如果问题变为“求被覆盖至少K次的面积”我们可以在节点中维护一个数组len[k]表示被覆盖恰好k次的长度或者维护一个差分数组更新时进行区间加查询时统计。其核心思想是一致的定义清楚线段树节点需要维护什么信息然后设计好push_up和更新操作使得父节点信息能由子节点信息在考虑懒标记后合并得到。最后在调试这类题目时我习惯先写一个暴力的版本用于对小规模随机数据验证正确性。用随机生成的矩形坐标范围小数量少分别用暴力法和扫描线法计算面积对比结果。一旦对拍通过再挑战大规模数据这样能快速定位是算法思想错误还是代码实现细节如边界、离散化错误。

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

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

免费获取报价