1. 项目概述:奇偶覆盖问题的本质
最近在复盘蓝桥杯的历年真题,第十一届国赛C/C++A组的“奇偶覆盖”这道题给我留下了挺深的印象。它初看像是一道普通的几何面积计算题,但仔细琢磨后,你会发现它巧妙地将扫描线算法和线段树这两个经典数据结构与算法结合,并引入了一个“奇偶性”的判定维度,使得题目从单纯的“求面积并”升级为了“求特定条件下的面积”。这种题目非常考验选手对基础算法的深刻理解和灵活运用能力,而不仅仅是套模板。
简单来说,题目会给你平面上一系列矩形(坐标都是整数),然后问你:所有被奇数个矩形覆盖的区域的总面积是多少?同时,可能还会问被偶数个矩形覆盖的区域面积。这和我们平时做的求矩形面积并(无论被覆盖多少次,只算一次)或者求矩形面积交(必须被所有矩形都覆盖)都不一样。它关注的是覆盖的“层数”的奇偶性。这就引出了核心需求:我们需要一个能高效统计每个位置被覆盖次数,并能根据奇偶性进行聚合的数据结构。线段树维护区间和,配合扫描线在时间维度(这里是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[i+1])。这是扫描线算法中处理“连续区间”的常见技巧,避免了边界问题。因此,如果ys有m个点,线段树实际管理的区间数是m-1个。
3.2 线段树节点定义
struct SegNode { int l, r; // 节点管理的区间在离散化数组ys中的下标范围,对应实际y轴区间[ys[l], ys[r+1]) int cnt; // 懒标记,表示整个区间被额外覆盖的次数(不向下传递) int len_odd; // 当前区间内,被奇数次覆盖的子区间总长度 int len_even; // 当前区间内,被偶数次覆盖的子区间总长度 } tr[N * 4]; // N为离散化后ys的大小这里l和r是离散化后的索引。区间长度length = ys[r+1] - ys[l]。
3.3 核心操作:push_up 向上更新这是最容易出错的地方。一个节点的len_odd和len_even如何由其左右儿子计算得来?
- 如果当前节点
u的cnt为奇数:意味着这个区间整体被覆盖了一层。那么对于这个区间内的任何子区间,其“真实”覆盖次数 = “儿子维护的覆盖次数” + 1。因此,儿子维护的“奇数长度”对应到这里就是“偶数长度”,儿子的“偶数长度”对应到这里就是“奇数长度”。所以:tr[u].len_odd = tr[u<<1].len_even + tr[u<<1|1].len_even;tr[u].len_even = tr[u<<1].len_odd + tr[u<<1|1].len_odd; - 如果当前节点
u的cnt为偶数(包括0):意味着cnt不改变奇偶性。那么儿子维护的奇偶长度就是真实长度。tr[u].len_odd = tr[u<<1].len_odd + tr[u<<1|1].len_odd;tr[u].len_even = tr[u<<1].len_even + tr[u<<1|1].len_even; - 如果当前节点是叶子节点(
l == r),区间长度为length = ys[r+1] - 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].r+1] - ys[tr[u].l]) : (tr[u<<1].len_even + tr[u<<1|1].len_even); tr[u].len_even = (tr[u].l == tr[u].r) ? 0 : (tr[u<<1].len_odd + tr[u<<1|1].len_odd); } else { // 当前区间整体被偶数次(含0次)覆盖 tr[u].len_odd = (tr[u].l == tr[u].r) ? 0 : (tr[u<<1].len_odd + tr[u<<1|1].len_odd); tr[u].len_even = (tr[u].l == tr[u].r) ? (ys[tr[u].r+1] - ys[tr[u].l]) : (tr[u<<1].len_even + tr[u<<1|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 个段)。初始时,所有节点的cnt=0,len_odd=0,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[i+1]这段左闭右开的区间。当我们收到一个矩形[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)。那么,在x=x1这条线上,矩形开始覆盖;在x=x2这条线上,矩形结束覆盖。因此,对于同一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[r+1]-ys[l]),len_odd初始化为0,cnt初始化为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以保安全 vector<int> ys; // 离散化y坐标 vector<struct 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[u<<1].len_even + tr[u<<1|1].len_even; tr[u].len_even = tr[u<<1].len_odd + tr[u<<1|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[u<<1].len_odd + tr[u<<1|1].len_odd; tr[u].len_even = tr[u<<1].len_even + tr[u<<1|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和更新操作,使得父节点信息能由子节点信息在考虑懒标记后合并得到。
最后,在调试这类题目时,我习惯先写一个暴力的版本,用于对小规模随机数据验证正确性。用随机生成的矩形(坐标范围小,数量少),分别用暴力法和扫描线法计算面积,对比结果。一旦对拍通过,再挑战大规模数据,这样能快速定位是算法思想错误还是代码实现细节(如边界、离散化)错误。