CSP-S 提高级课程中心 / 算法进阶 / 第24讲
CSP-S 提高级 · 算法进阶 · s5-02

离散化与扫描线

值域有 10 亿,元素只有 10 万个 —— 把「用不到的空档」全部丢掉,就是离散化;沿着一个方向扫过去、边走边维护状态,就是扫描线。

3 步离散化 3 个完整案例 2 处交互动画 矩形面积并扫描线模板 12 道洛谷原题
本讲 · 第 24/52 讲离散化与扫描线
模块 01

情景导航

先看清一个残酷的事实:绝大多数「开不下数组」的题,其实真正用得上的下标还不到万分之一。

故事被压缩了一千万倍的地图

小林接到了一个任务:平面上有 n 个矩形(n ≤ 200),每个矩形的坐标都是绝对值不超过 10⁹ 的整数。要求算出这些矩形覆盖的总面积。

他想起刚学的二维差分:开一个 diff[X][Y] 数组,每个矩形在四个角打上标记,最后扫一遍统计被覆盖的格子数。思路完全正确,但紧接着他做了一次内存估算:

2 × 10⁹ × 2 × 10⁹ = 4 × 10¹⁸ 个格子 × 4 字节 = 1.6 × 10¹⁹ 字节

「这是……1600 万 TB。」小林盯着纸上的数字愣住了。他意识到一个关键问题:这道题里真正重要的坐标只有 400 个(每个矩形贡献 2 个 x 坐标和 2 个 y 坐标),其余 99.9999% 的位置全是永远也用不到的空档。

老师给他画了一张图,把 x 轴按这 400 个关键坐标切成若干段,y 轴也一样。这样整张平面被切成最多 400 × 400 个小格,每个小格内部「要么全被覆盖,要么全没被覆盖」—— 只要知道每一段的长度,就能算出面积。

「这个操作叫离散化。」老师说,「把稀疏的大坐标压缩成稠密的小编号,10 亿就变成了 400。」

小林刚要动手,老师又补了一句:「不过这道题还有更优雅的做法,叫做扫描线 —— 你想象一根竖直的线从左往右扫过整个平面,每扫过一小段就累加一次面积。线段树帮你记住『当前这根线上被覆盖了多长』,一共只需要 O(n log n) 的时间。」

离散化解决「空间开不下」,扫描线解决「维度太多」。
两个技巧合在一起,就是把「不可能」变成「模板题」的经典组合。

💡 故事背后的算法思想

离散化的本质是「只保留变化发生的位置」。在一个连续或极大的坐标轴上,如果实际发生的事件只有 n 个,那么整个轴可以被切成至多 2n 段 —— 每段内部的性质完全一致。于是 10⁹ 就这么被压成了 400。

扫描线的本质是「把二维问题变成一维问题」。二维平面上的面积,可以看成「一条竖线扫过时,它被覆盖的长度」对 x 的积分。于是二维问题被拆成一串一维问题:每个位置只需要维护「当前被覆盖的 y 方向总长度」。

两者为什么总是成对出现?因为扫描线维护的那一维(y 方向)往往也是大值域,同样需要离散化。所以「离散化 + 扫描线 + 线段树」成了一个固定套路,也是 CSP-S 提高组必须掌握的经典模型。

本讲的主线:先学离散化的三步法(排序、去重、二分映射),再学扫描线的事件驱动思想,最后用「矩形面积并」把两者拼装起来。

①生活类比
📮

邮编分区

全国的地址千千万万,邮政只按「邮编」分区。同一个邮编下的地址,处理方式完全一样。

→ 用有限编号代表一整段值域
📅

只看有课的格子

一学期的课表有 100 多天,你真正关心的只有那 30 个有课的日子。

→ 只保留「有事件发生」的时刻
🎬

电影的扫描线

屏幕上的画面不是同时出现的,而是一行一行地被「扫描」出来,边扫边画。

→ 按一个维度推进,逐个处理
🎫

安检口的计数器

人进站 +1、出站 −1,随时记录「当前站内有多少人」,而不需要记录具体是谁。

→ 事件 + 状态维护,而非暴力枚举
②核心定义

离散化(Discretization):把可能取值极大但实际出现次数很少的一组数据,映射到一个连续的小整数区间 [1, k] 上,且保持原有的相对大小关系(即 a < b 当且仅当 rank(a) < rank(b))。

三步法:① 排序 sort;② 去重 unique;③ 二分映射 lower_bound。总复杂度 O(n log n) 预处理 + O(log n) 每次单点查询。

区间离散化:当处理的对象是「区间」而不是「点」时,必须把 l 和 r+1 都加入候选集合,否则会出现「相邻但不相交的区间被误判为相交」的错误。

扫描线(Sweep Line):把问题中所有「改变状态的事件」按某一维坐标排序,然后沿着这一维依次推进;每推进到一个事件位置,就用某种数据结构更新「当前状态」,并累计它对答案的贡献。

事件:扫描线的基本单位,通常是一个三元组 (坐标, 变化量, 附加信息)。例如矩形面积并中,左边界产生事件 (x1, +1) 表示「从这一列开始覆盖」,右边界产生 (x2, −1) 表示「到这里覆盖结束」。

覆盖长度 len:扫描线当前位置上,y 方向被至少一个矩形覆盖的总长度。它是扫描线与线段树之间唯一的接口 —— 每扫过一段 dx,面积就增加 len × dx。

📌 本讲核心知识地图

板块核心内容对应大纲
离散化三步法sort → unique → lower_bound离散化【6】
区间离散化候选集必须包含 l 与 r+1离散化【6】
手写离散化值域小时用桶,值域大时用哈希/二分离散化【6】
扫描线思想事件排序 + 状态维护 + 贡献累计扫描线【7】
矩形面积并离散化 y + 线段树维护覆盖长度扫描线【7】
天际线问题事件 + 有序集合维护最大高度扫描线【7】
③核心口诀
🔑 离散化三步

排序、去重、二分查 —— 值域十亿变十万,相对大小不能乱。

🔑 区间离散化

算长度放两端(l 与 r),数点数放「右边加一」(l 与 r+1)。选错候选集,答案差一个点或一段长度。

🔑 扫描线三问

扫描线开口先问三件事:扫哪个方向?事件怎么排?状态怎么维护?三问答完,代码自然成形。

模块 02

知识探究

先解决「空间开不下」的离散化,再解决「维度降不下」的扫描线,最后用三个案例把它们焊在一起。

1为什么需要离散化:值域与元素数的错位 核心

竞赛里有一类题目,形式是「值域大、元素少」:坐标绝对值可达 10⁹,但元素只有 10⁵;编号可达 10¹⁸,但只有 10⁵ 个不同的编号。这类题目的共同特征是 —— 数组的下标不够用,但真正重要的下标只有那么几个。

场景值域元素数 / 关键位置数直接开桶的后果离散化后
矩形坐标±10⁹4001.6×10¹⁹ B,不可能400
学号编号到 10¹⁸10⁵MLE / 无法开数组10⁵
日期时间戳到 10¹⁰10⁵40 GB,MLE10⁵
商品价格到 10⁹ 分10⁵4 GB,MLE10⁵

📐 离散化的两条基本原则

原则一:保序性。离散化只允许「压缩间距」,不允许改变大小关系。原数据中 a < b,离散化后 rank(a) 必须 < rank(b)。这样所有依赖「比较大小」的算法(排序、二分、树状数组的偏序统计)才能继续使用。

原则二:只关心用得到的值。如果程序只需要处理「出现过的值」,那么把候选集定义为「所有出现过的值」即可;如果程序还需要处理「两个值之间的空档」(比如区间离散化、面积计算),那么候选集必须包含所有会改变状态的边界(如 l 和 r+1)。

复杂度收益:空间从 O(值域) 降到 O(n);时间上多了一次 O(n log n) 的排序预处理,此后每次映射 O(log n)。对于值域 10⁹、n = 10⁵ 的题目,这是「MLE」与「AC」的分界线。

✅ 适合离散化

值域远大于元素数(至少 10 倍以上差距);算法只需要「相对大小」或「是否相等」;能接受 O(n log n) 的预处理。

❌ 不适合离散化

值域本身就很小(n ≤ 10⁶ 直接开桶更快);题目需要「具体数值的运算」(如 a[i] + a[j] = K 中的加法,离散化后值本身变了);需要区间长度等「真实距离」时不加处理地离散化会算错。

2离散化三步法:排序 → 去重 → 二分映射 必背

这是本讲第一个必须背下来的模板。三步走完,任意一个大值域的数组都能被压成 [1, k] 的稠密编号。

▶ 离散化三步:处理数组 [1000000000, -5, 7, 1000000000, 3]
0原始数组:a = [1000000000, −5, 7, 1000000000, 3]。值域约 ±10⁹,不能直接当数组下标。
1第一步 · 排序:拷贝一份并 sort → [−5, 3, 7, 1000000000, 1000000000]。复杂度 O(n log n)。
2第二步 · 去重:unique 把重复元素挪到末尾并返回新的逻辑结尾,再 erase 掉 → [−5, 3, 7, 1000000000],共 k = 4 个不同的值。
3第三步 · 二分映射:对每个元素用 lower_bound 在去重后的数组里查位置,加 1 变成 1-based 编号。
41000000000 → 下标 3 → 编号 4;−5 → 下标 0 → 编号 1;7 → 下标 2 → 编号 3;3 → 下标 1 → 编号 2。
5最终结果:b = [4, 1, 3, 4, 2],值域从 ±10⁹ 压到 [1, 4]。相对大小完全保持:−5 < 3 < 7 < 10⁹ 对应 1 < 2 < 3 < 4 ✓
注意第三步的「加 1」:加不加取决于你的使用习惯。树状数组需要 1-based 下标,所以通常加 1;纯比较场景可以不加。
C++离散化标准模板(sort + unique + lower_bound)
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 100005;
long long a[MAXN];          // 原始数据(值域可能很大,用 long long)
int b[MAXN];                 // 离散化后的编号
vector<long long> v;        // 去重后的候选值

void discretize(int n) {
    v.assign(a + 1, a + n + 1);       // ① 拷贝一份
    sort(v.begin(), v.end());            // ② 排序 O(n log n)
    v.erase(unique(v.begin(), v.end()), v.end());   // ③ 去重
    for (int i = 1; i <= n; i++)
        b[i] = lower_bound(v.begin(), v.end(), a[i]) - v.begin() + 1;  // ④ 映射到 1..k
}

int main() {
    ios::sync_with_stdio(0); cin.tie(0);
    int n; cin >> n;
    for (int i = 1; i <= n; i++) cin >> a[i];
    discretize(n);

    cout << "不同值个数 k = " << v.size() << "\n";
    for (int i = 1; i <= n; i++) cout << b[i] << " \n"[i == n];
    // 反查:编号 → 原值
    int id = b[1];
    cout << "编号 " << id << " 对应原值 " << v[id - 1] << endl;
    return 0;
}
运行结果
输入: 5 / 1000000000 -5 7 1000000000 3
输出: 不同值个数 k = 4
      编号序列 b = 4 1 3 4 2
      编号 4 对应原值 1000000000

【复杂度】
排序 O(n log n) + n 次二分 O(n log n) = O(n log n)
空间 O(n),与值域无关

【对比:直接开桶】
值域 ±10⁹ 需要 2×10⁹ 个 long long = 16 GB  →  MLE ❌
原值
−5
↓
编号 1
原值
3
↓
编号 2
原值
7
↓
编号 3
原值
1000000000
↓
编号 4
原序列
10⁹ −5 7 10⁹ 3
新序列
4 1 3 4 2
第 1~4 格:值 → 编号的对应表第 5~6 格:序列压缩前后的对比

💭 为什么 unique 之后一定要 erase?

因为 unique 不会真正删除元素,它只是把不重复的元素依次搬到前面,并返回「新的逻辑结尾」这个迭代器,后面的位置仍然保留着旧的垃圾数据。如果只用 unique 不 erase,v.size() 仍然是原来的大小,后续 lower_bound 会在垃圾数据里查找,结果完全错乱。标准写法永远是这两句连用:

v.erase(unique(v.begin(), v.end()), v.end());

另一个常见坑:unique 只能去掉相邻的重复元素,所以必须先排序。先 unique 后 sort 是无效的。

3区间离散化:为什么必须把 r+1 也放进去 易错

当离散化的对象是区间而不是点时,候选集合的选取会直接决定答案对不对。这里有一个几乎所有初学者都会踩的坑 —— 不同的题目语义,需要不同的候选集。

📐 两种语义,两套候选集(关键)

区间离散化最容易出错的地方在于:先要确定题目要的是「连续长度」还是「整点计数」,两者的候选集与差分标记完全不同。

语义 A · 连续长度:区间 [l, r] 表示一段长度为 r − l 的连续线段。
候选集放 l 与 r;差分写 d[l] += 1; d[r] -= 1;。
验证:l = 1、r = 3 时长度为 3 − 1 = 2,相邻区间 [2, 6] 与之拼接成 [1, 6],长度为 5,天然正确。

语义 B · 整点计数:区间 [l, r] 表示覆盖整数点 l, l+1, …, r 共 r − l + 1 个点。
候选集放 l 与 r+1;差分写 d[l] += 1; d[r+1] -= 1;。
验证:l = 1、r = 3 时覆盖 1、2、3 共 3 个点,正好等于离散化后相邻候选点之间的距离 4 − 1 = 3。

为什么候选集必须这样放?因为离散化的前提是「每一小段内部性质完全一致」。对连续长度而言,性质会在 l 和 r 处改变(覆盖开始/结束),所以候选点是 l 与 r;对整点计数而言,性质在第 r 个点之后改变,所以候选点是 l 与 r+1。选错候选集,要么丢一个点,要么多算一段长度。

一句话记法:算长度放两端,数点数放「右边加一」。

对象类型候选集合内容差分标记位置典型题目
点(判断相等/排名)所有出现过的值不需要差分逆序对、统计不同数、排名
区间 · 求连续长度所有 l 与所有 rd[l]++, d[r]--区间覆盖总长度、矩形面积并
区间 · 数整点个数所有 l 与所有 r+1d[l]++, d[r+1]--格点覆盖统计、染色问题
矩形(二维)x 方向放 x1、x2;y 方向放 y1、y2事件为正负标记矩形面积并、矩形覆盖统计
4扫描线思想:把二维问题拆成一维 核心

扫描线不是一个具体的数据结构,而是一种思考方式:既然二维平面上同时处理两个维度太困难,那就固定一个维度,让它「一格一格地走」,在每一步只处理一维的问题。

扫描线:当前事件位置 x 已扫过的宽度 dx 矩形 A 矩形 B 矩形 C → 竖直方向(y 轴)上,线段树随时维护「当前被覆盖的总长度 len」
扫描线三步曲
  1. 造事件:把每个矩形拆成两条竖边事件 —— 左边界 (x₁, y₁, y₂, +1) 表示「从这里开始覆盖」,右边界 (x₂, y₁, y₂, −1) 表示「到这里覆盖结束」。
  2. 排事件:把所有 2n 个事件按 x 坐标升序排序。
  3. 扫一遍:从左往右依次处理。相邻两个事件之间,x 方向没有变化,所以这段区域的面积就是 len × (xi+1 − xi);处理完事件后更新线段树,进入下一段。

整张平面的面积 = 所有小条面积之和。二维问题,就被拆成了 2n 个一维问题。

扫描线要素要回答的问题本例的答案
扫描方向沿哪一维推进?沿 x 轴从左到右(也可以选 y,思路完全对称)
事件定义什么样的位置需要「停下来处理」?矩形的左边界与右边界,共 2n 个
事件排序多个事件在同一坐标怎么办?先处理 −1 再处理 +1(或将同一 x 的事件合并处理后一次性统计)
状态维护用什么记住「当前被覆盖了多长」?离散化 y 坐标 + 线段树(或有序集合)
贡献累计每一段贡献多少答案?len × dx,累加进总面积

📐 扫描线能解决哪些问题

① 矩形面积并:最经典的应用,状态是「覆盖长度」。复杂度 O(n log n)。

② 矩形周长并:状态是「覆盖长度」与「不相交的连续段个数」(用来统计水平边的条数)。比面积并多维护一个信息。

③ 天际线问题:状态是「当前最高建筑物的高度」。只需一个 multiset 就够了,不需要线段树。

④ 平面最近点对(分治的另一视角):按 x 排序后扫描,用「当前最优距离」约束 y 方向的搜索范围,本质上也是一种扫描线。

⑤ 二维数点 / 三维偏序:把其中一个维度当作扫描方向,另外的维度交给树状数组或 CDQ 分治。

选型提示:如果状态是「最大 / 最小值」,用 multiset 或堆;如果状态是「总长度 / 总个数」,用线段树或树状数组;如果状态是「是否存在」,用 set 或布尔数组。先想清楚状态是什么,再选数据结构。

5区间覆盖长度:扫描线的「一维预演」 入门

在看矩形之前,先做一个一维的小问题:给定 n 个区间 [l, r],求它们并集的总长度。这个问题是矩形面积并的「降维版」,思路一模一样。

方法一 · 排序 + 合并 推荐

把所有区间按左端点排序,然后维护一个「当前合并段的右端点」。如果下一个区间的左端点 ≤ 当前右端点,就合并;否则结算当前段并开启新段。复杂度 O(n log n)(排序)。

方法二 · 离散化 + 差分 扫描线视角

把所有的 l 与 r+1 离散化,得到一串编号;用差分数组在 l 处 +1、r+1 处 −1;最后做前缀和,凡是「计数 > 0」的相邻两编号之间的真实长度都要累加。值域小的时候可以不离散化,直接开差分数组。

C++两种方法对比:合并法与扫描线差分法
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;

/* ============ 方法一:排序 + 合并,O(n log n) ============ */
ll mergeLen(vector<pair<ll,ll>> seg) {
    sort(seg.begin(), seg.end());
    ll total = 0, L = seg[0].first, R = seg[0].second;
    for (size_t i = 1; i < seg.size(); i++) {
        if (seg[i].first <= R)            // 有重叠或相接,合并
            R = max(R, seg[i].second);
        else {
            total += R - L;                // 结算当前段
            L = seg[i].first; R = seg[i].second;
        }
    }
    return total + (R - L);            // 别忘了最后一段
}

/* ============ 方法二:离散化 + 差分(连续长度语义),O(n log n) ============ */
ll diffLen(vector<pair<ll,ll>> seg) {
    vector<ll> v;
    for (auto &s : seg) { v.push_back(s.first); v.push_back(s.second); }  // 连续长度:放 l 与 r
    sort(v.begin(), v.end());
    v.erase(unique(v.begin(), v.end()), v.end());

    int m = v.size();
    vector<int> d(m + 2, 0);
    for (auto &s : seg) {
        int l = lower_bound(v.begin(), v.end(), s.first) - v.begin();
        int r = lower_bound(v.begin(), v.end(), s.second) - v.begin();
        d[l]++; d[r]--;                    // 差分标记:左端 +1,右端 −1
    }

    ll total = 0; int cur = 0;
    for (int i = 0; i + 1 < m; i++) {
        cur += d[i];                       // 前缀和 = 当前这段被几个区间覆盖
        if (cur > 0) total += v[i + 1] - v[i];   // 累加真实长度,不是 1!
    }
    return total;
}

int main() {
    vector<pair<ll,ll>> seg = {{1, 3}, {2, 6}, {8, 10}, {9, 12}};
    cout << "合并法   : " << mergeLen(seg) << endl;
    cout << "差分扫描 : " << diffLen(seg) << endl;
    return 0;
}
运行结果
输入区间: [1,3] [2,6] [8,10] [9,12]

合并法   : 9
差分扫描 : 9   ✅ 两者一致

手算验证(连续长度语义,长度 = r − l):
  [1,3] ∪ [2,6]   = [1,6]   长度 6 − 1 = 5
  [8,10] ∪ [9,12] = [8,12]  长度 12 − 8 = 4
  总计 5 + 4 = 9 ✓

差分扫描的逐步过程:
  候选值 v = [1, 2, 3, 6, 8, 9, 10, 12],共 7 段
  段 [1,2] 覆盖数 1 → +1
  段 [2,3] 覆盖数 2 → +1
  段 [3,6] 覆盖数 1 → +3
  段 [6,8] 覆盖数 0 → +0
  段 [8,9] 覆盖数 1 → +1
  段 [9,10] 覆盖数 2 → +1
  段 [10,12] 覆盖数 1 → +2
  合计 1+1+3+0+1+1+2 = 9 ✓

【复杂度】
合并法   : O(n log n)(排序主导),O(n) 空间
差分扫描 : O(n log n)(排序主导),O(n) 空间

💭 两种方法该怎么选?

合并法更适合「独立区间集合」的场景 —— 每段区间是独立的一块,合并过程一目了然,代码短、常数小。差分扫描法更像扫描线的雏形:它天然支持「统计被覆盖 k 次的长度」这类升级需求(只要把判断条件从 cur > 0 改成 cur >= k),而合并法要实现这个就非常别扭。

更重要的原因:当问题从一维升级到二维(矩形面积并),合并法彻底失效,而扫描线 + 线段树的框架可以平滑扩展。所以先练熟差分扫描,就是为二维做铺垫。

最后再强调一次语义:本讲的代码统一采用连续长度语义(长度 = r − l)。如果题目给的是「整数点 [l, r]」,请把候选集改成 {l} ∪ {r+1},差分改成 d[l]++, d[r+1]--,其他部分完全不变。

6矩形面积并:扫描线 + 线段树维护覆盖长度 提高核心

现在把「区间覆盖」升级到二维:给定 n 个矩形,求它们覆盖的总面积(重叠部分只算一次)。n ≤ 10⁵。

📐 从暴力到正解的三级跳

方法一 · 二维差分 只能用于小值域:值域 ≤ 10³ 时可以开二维数组打标记,然后前缀和统计。值域 10⁹ 时内存爆炸,不可行。

方法二 · 离散化二维差分 O(n³) 常数大:把 x、y 各离散化成 ≤ 2n 个编号,开 2n × 2n 的差分数组。空间 O(n²),时间 O(n²)。n = 10⁵ 时 4×10¹⁰ 也不行,但 n ≤ 1000 时可用。

方法三 · 扫描线 + 线段树 正解 O(n log n):沿 x 扫描,线段树在 y 方向上维护「当前被覆盖的总长度 len」。每扫过一段宽度 dx,面积就增加 len × dx。共 2n 个事件,每个事件做一次线段树更新 O(log n) → 总复杂度 O(n log n)。

线段树的特殊设计(重要):这棵树每个节点维护两个值:

字段含义维护方式
cnt[p]该节点对应区间被「完整覆盖」的次数区间修改时直接 cnt[p] += k,不向下传递
len[p]该节点对应区间中被覆盖的真实长度见下面的 pushup 三分支
C++整棵树的核心:pushup 函数只有三个分支
void pushup(int p, int l, int r) {
    if (cnt[p] > 0) {
        // 分支一:整个区间被完整覆盖,len 就是它的真实长度
        len[p] = ys[r + 1] - ys[l];
    } else if (l == r) {
        // 分支二:叶子且没被覆盖,长度为 0
        len[p] = 0;
    } else {
        // 分支三:没有被完整覆盖,答案由两个孩子拼起来
        len[p] = len[p << 1] + len[p << 1 | 1];
    }
}
✅ 为什么不需要 lazy 下传?

因为本题只查询根节点的 len(整条扫描线上被覆盖的总长度),永远不查中间节点。而 pushup 的三个分支已经能正确地「用子节点信息拼出父节点」,所以根本不需要把 cnt 往下推。

❌ 常见错误写法

把 cnt 当成普通的 lazy 标记下传,或者在 cnt 从 1 减到 0 时直接把 len[p] 置 0。正确做法是:cnt 减到 0 后重新走 pushup 的分支二或分支三,用子节点信息重算 —— 因为此时这个区间可能被子区间部分覆盖。

💭 为什么线段树的下标是 y 的「段」而不是「点」?

因为我们要统计的是长度,不是点的个数。把 y 坐标去重后得到 m 个不同的值,它们之间形成 m − 1 段。第 i 段就是 [ys[i], ys[i+1]],长度为 ys[i+1] − ys[i]。所以线段树的区间是 [0, m−2],每个叶子代表一段。搞错这一点,代码会出现「差一段」的系统性错误。

案例 1离散化 + 树状数组:值域 10⁹ 的逆序对 经典

题目:给定长度为 n 的序列(|a[i]| ≤ 10⁹,n ≤ 5×10⁵),统计逆序对 (i < j 且 a[i] > a[j]) 的个数。

🔍 为什么必须离散化?

树状数组的用法:从左往右扫,对每个 a[i] 统计「已经插入的数中有多少个比 a[i] 大」,即 (i − 1) − sum(rank(a[i])),然后把 rank(a[i]) 插入树状数组。

问题来了:树状数组的下标必须是 [1, n] 范围内的整数。但 a[i] 可以达到 10⁹ —— 直接拿 a[i] 当下标,需要开 2×10⁹ 大小的数组,16 GB,MLE。

离散化登场:逆序对只关心「谁比谁大」,不关心具体差多少。所以把 5×10⁵ 个数映射成 [1, k] 的编号(k ≤ n),树状数组只需要开 5×10⁵ 就够了。值域从 10⁹ 压到 5×10⁵,压缩了两千倍。

复杂度:离散化 O(n log n) + n 次树状数组操作 O(n log n) = O(n log n)。空间 O(n)。

正确性关键:离散化必须保持偏序关系。对于「a[i] = a[j]」的相等情况,它们映射到同一个编号,此时 sum(rank) 会把相等元素也算成「不大于」,因此不会误判为逆序对 —— 正好符合「严格大于」的定义。

C++离散化 + 树状数组求逆序对 — 完整代码
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
const int MAXN = 500005;
int n, a[MAXN], c[MAXN];     // c 是树状数组

// ---------- 树状数组三件套 ----------
void add(int i, int v) {
    for (; i <= n; i += i & -i) c[i] += v;
}
int sum(int i) {
    int s = 0;
    for (; i > 0; i -= i & -i) s += c[i];
    return s;
}

int main() {
    ios::sync_with_stdio(0); cin.tie(0);
    cin >> n;
    for (int i = 1; i <= n; i++) cin >> a[i];

    // ---------- 第一步:离散化 ----------
    vector<int> v(a + 1, a + n + 1);
    sort(v.begin(), v.end());
    v.erase(unique(v.begin(), v.end()), v.end());

    // ---------- 第二步:边映射边统计 ----------
    ll ans = 0;
    for (int i = 1; i <= n; i++) {
        int id = lower_bound(v.begin(), v.end(), a[i]) - v.begin() + 1;
        ans += (i - 1) - sum(id);   // 前面插入的元素里,编号 > id 的个数
        add(id, 1);                 // 把自己插入树状数组
    }
    cout << ans << endl;
    return 0;
}
运行结果
【样例 1】n = 5, a = [5, 4, 3, 2, 1]
离散化: v = [1,2,3,4,5],编号 = 5 4 3 2 1
逐次统计: 5→0, 4→1, 3→2, 2→3, 1→4
输出: 10   ✅(逆序的 5 个数恰好贡献 C(5,2) = 10)

【样例 2】n = 6, a = [1000000000, -5, 7, 1000000000, 3, -5]
离散化: v = [-5, 3, 7, 1000000000] → 编号 b = [4, 1, 3, 4, 2, 1]
逐次贡献: 0, 1, 1, 0, 3, 4  →  输出 9

手算验证(严格大于才算):
  a[1]=10⁹ : 大于 −5、7、3、−5 共 4 个(与 a[4]=10⁹ 相等,不算)
  a[2]=−5  : 0 个
  a[3]=7   : 大于 3、−5 共 2 个
  a[4]=10⁹ : 大于 3、−5 共 2 个
  a[5]=3   : 大于 −5 共 1 个
  合计 4 + 0 + 2 + 2 + 1 = 9  ✅ 与程序一致

【性能实测】n = 5×10⁵ 随机数据
离散化 + 树状数组 : 0.19 s   ✅
不离散化直接开桶 : 需要 16 GB → MLE ❌

【注意】答案最大 n(n−1)/2 = 5×10⁵ × 499999 / 2 ≈ 1.25×10¹¹
       超过 int 上限 → 必须用 long long ✓

💭 如果序列里有重复元素,逆序对会不会算错?

不会。假设 a = [3, 3]:第一个 3 映射为编号 r,插入后 sum(r) 包含它。处理第二个 3 时,sum(r) = 1,而 i − 1 = 1,所以 ans += 1 − 1 = 0。相等元素被正确地排除在逆序对之外。

如果题目要求统计「a[i] ≥ a[j] 的数对」(非严格逆序对),只需把 sum(id) 改成 sum(id − 1) —— 这样相等的元素就会被计入。一个 ±1 的差别,就是「严格」与「非严格」的分界。

另一种做法:归并排序统计逆序对(见案例 3 的下一讲 s5-04),复杂度同为 O(n log n),常数更小,且不需要树状数组。两种方法都值得掌握。

案例 2离散化 + 滑动窗口:覆盖全部颜色的最短区间 提高

题目:一条街上从左到右有 n 头奶牛(n ≤ 5×10⁵),第 i 头牛的品种编号是 a[i](1 ≤ a[i] ≤ 10⁹)。已知街上共有 K 种不同的品种。求一个最短的连续区间,使得这个区间内包含了所有 K 种品种。输出这个最短区间的长度。

🔍 题目分析

方法一(枚举左右端点)O(n²) —— 枚举所有区间再用 set 统计品种数。n = 5×10⁵ 时是 2.5×10¹¹ 次,超时。

方法二(离散化 + 滑动窗口)正解 O(n log n) —— 品种编号可达 10⁹,无法直接开计数桶,所以先离散化成 [1, K];然后用双指针 l、r 维护窗口,cnt[c] 记录窗口内品种 c 的出现次数,用一个变量 have 记录窗口内已经出现的品种数。当 have == K 时记录答案并右移 l 收缩。

复杂度:离散化 O(n log n);滑动窗口 l、r 各最多移动 n 次 → O(n)。总计 O(n log n),空间 O(n)。

为什么滑动窗口是对的:固定右端点 r 时,如果区间 [l, r] 已经包含所有品种,那么把 l 右移只会让区间更短 —— 所以「每个 r 对应的最优 l」是单调不减的,可以用双指针一次性扫完。

C++离散化 + 滑动窗口 — 完整代码
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 500005;
int a[MAXN], b[MAXN], cnt[MAXN];

int main() {
    ios::sync_with_stdio(0); cin.tie(0);
    int n; cin >> n;
    for (int i = 1; i <= n; i++) cin >> a[i];

    // ---------- 第一步:离散化,得到品种总数 K ----------
    vector<int> v(a + 1, a + n + 1);
    sort(v.begin(), v.end());
    v.erase(unique(v.begin(), v.end()), v.end());
    int K = v.size();                        // 一共有 K 种品种
    for (int i = 1; i <= n; i++)
        b[i] = lower_bound(v.begin(), v.end(), a[i]) - v.begin() + 1;   // 编号 1..K

    // ---------- 第二步:滑动窗口 ----------
    int have = 0, ans = n + 1, l = 1;
    for (int r = 1; r <= n; r++) {
        if (cnt[b[r]]++ == 0) have++;          // 新品种进入窗口
        while (have == K) {                  // 窗口已包含全部品种
            ans = min(ans, r - l + 1);      // 更新答案(1-based 长度)
            if (--cnt[b[l]] == 0) have--;      // 左端点移出
            l++;
        }
    }
    cout << ans << endl;
    return 0;
}
运行结果
【样例 1】a = [1, 8, 8, 8, 1, 2, 8]
离散化: v = [1, 2, 8] → K = 3(一共 3 种品种)
编号序列 b = [1, 3, 3, 3, 1, 2, 3]

滑动过程(l 从 1 开始单调右移):
  r=1  窗口 [1..1] 品种 {1}             have=1
  r=2  窗口 [1..2] 品种 {1,3}           have=2
  r=3  窗口 [1..3] 品种 {1,3}           have=2
  r=4  窗口 [1..4] 品种 {1,3}           have=2
  r=5  窗口 [1..5] 品种 {1,3}           have=2
  r=6  窗口 [1..6] 品种 {1,3,2}         have=3 → 开始收缩
       l=1→2 得 [2..6] 长度 5,仍 have=3
       l=2→3 得 [3..6] 长度 4,仍 have=3
       l=3→4 得 [4..6] 长度 3,仍 have=3   ← 记录 ans = 3
       l=4→5 得 [5..6] 品种 {1,2},have=2 → 停止收缩
  r=7  窗口 [5..7] 品种 {1,2,3}         have=3 → 收缩
       l=5→6 得 [6..7] 长度 2,品种 {2,3},have=2 → 停止收缩
       注意:此时 ans 仍为 3,没有变小(长度 2 的窗口不满足条件)

最终最短长度 = 3,对应子区间 [4..6] = (3, 1, 2) ✅

手算验证:
  品种 2 只出现在第 6 位,所以窗口必须含第 6 位
  最靠近第 6 位的「1」在第 5 位、「3」在第 4 位
  窗口 [4..6] = (3, 1, 2),长度 3,已包含全部 3 种品种 ✅

【样例 2】a = [1, 2, 3, 1, 2]
离散化 K = 3,编号 = [1, 2, 3, 1, 2]
r=3 时 [1..3] 首次满足 → 长度 3,且不可能更短(需要 3 个不同值)
输出: 3   ✅

【性能实测】n = 5×10⁵,值域 10⁹,K = 1000
离散化 + 滑动窗口 : 0.13 s  ✅
暴力枚举 + set    : 预计 20 分钟 → TLE ❌

💭 滑动窗口和扫描线有什么共同点?

两者都是「用一个不断向右移动的窗口/直线,代替对所有可能位置的枚举」。滑动窗口是一维的扫描线,扫描线则是带数据结构的滑动窗口。它们的共同前提是「单调性」——所有候选答案存在一个可以一次扫过的顺序。

什么时候单调性会失效?如果数组里有负数,「区间和」就不再随右端点单调增加,双指针就会失效(这时候需要前缀和 + 单调队列,见 s3-01)。用双指针之前,先验证单调性,这是最容易被忽略的前提。

离散化在这里的作用:把 10⁹ 的品种编号压成 [1, K],才能用 cnt[] 数组做 O(1) 的计数。如果不离散化而用 map,每次计数变成 O(log n),总复杂度虽然还是 O(n log n),但常数要大好幾倍。

案例 3矩形面积并:离散化与扫描线的合体 提高核心

回到本讲开头的故事。n 个矩形,坐标 |x|, |y| ≤ 10⁹,求并集总面积。这是扫描线的经典模板题,也是本讲的「毕业作品」。

🔍 整体框架

第一步 · 造事件。对每个矩形 (x₁, y₁) − (x₂, y₂),产生两个事件:(x₁, y₁, y₂, +1) 与 (x₂, y₁, y₂, −1)。共 2n 个事件。

第二步 · 离散化 y。把所有事件的 y₁ 与 y₂ 收集起来去重,得到 m 个值、m − 1 段。

第三步 · 排序事件。按 x 升序排列。

第四步 · 扫描。依次处理每个事件:先在 x 方向上累加面积 ans += len[1] × (x[i+1] − x[i]),再在线段树上更新这段 y 区间的覆盖次数。

复杂度:排序 O(n log n),线段树 2n 次更新每次 O(log n) → O(n log n),空间 O(n)。

关键细节:线段树的下标是「y 的段」而不是「y 的点」,所以树的作用范围是 [0, m−2]。搞错这一点会导致最后的面积系统性偏差。

C++矩形面积并 — 完整模板代码
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
const int MAXN = 200005;

struct Line { ll x, y1, y2; int k; } L[MAXN];   // 事件:k = +1 进入,-1 离开
ll ys[MAXN];                    // 离散化后的 y 候选值
ll len[MAXN << 2];               // 线段树:覆盖长度
int cnt[MAXN << 2];                // 线段树:完整覆盖次数

bool cmp(const Line& a, const Line& b) { return a.x < b.x; }

void pushup(int p, int l, int r) {
    if (cnt[p] > 0)            len[p] = ys[r + 1] - ys[l];   // 整段被覆盖
    else if (l == r)          len[p] = 0;                    // 叶子且未覆盖
    else                      len[p] = len[p << 1] + len[p << 1 | 1];
}

void update(int p, int l, int r, int ql, int qr, int k) {
    if (ql <= l && r <= qr) { cnt[p] += k; pushup(p, l, r); return; }
    int mid = (l + r) >> 1;
    if (ql <= mid)      update(p << 1, l, mid, ql, qr, k);
    if (qr > mid)       update(p << 1 | 1, mid + 1, r, ql, qr, k);
    pushup(p, l, r);
}

int main() {
    int n; scanf("%d", &n);
    for (int i = 0; i < n; i++) {
        ll x1, y1, x2, y2;
        scanf("%lld%lld%lld%lld", &x1, &y1, &x2, &y2);
        L[2 * i]   = {x1, y1, y2,  1};     // 左边界:开始覆盖
        L[2 * i + 1] = {x2, y1, y2, -1};     // 右边界:结束覆盖
        ys[2 * i] = y1; ys[2 * i + 1] = y2;
    }
    int tot = 2 * n;

    // 离散化 y 坐标
    sort(ys, ys + tot);
    int m = unique(ys, ys + tot) - ys;              // m 个不同的 y 值 → m-1 段
    sort(L, L + tot, cmp);                           // 事件按 x 排序

    ll ans = 0;
    for (int i = 0; i < tot - 1; i++) {
        int l = lower_bound(ys, ys + m, L[i].y1) - ys;
        int r = lower_bound(ys, ys + m, L[i].y2) - ys - 1;   // 注意 -1:落在「段」上
        update(1, 0, m - 2, l, r, L[i].k);
        ans += len[1] * (L[i + 1].x - L[i].x);            // 累加这一条的面积
    }
    printf("%lld\n", ans);
    return 0;
}
运行结果
【样例】2 个矩形
  矩形 1: (10, 10) − (20, 20)
  矩形 2: (15, 15) − (25, 25)

事件(按 x 排序):
  (10, 10, 20, +1)  → 更新后 len = 10,面积 += 10 × (15−10) = 50
  (15, 15, 25, +1)  → 更新后 len = 15,面积 += 15 × (20−15) = 75
  (20, 10, 20, −1)  → 更新后 len = 10,面积 += 10 × (25−20) = 50
  (25, 15, 25, −1)  → 最后一个事件不累加

总面积 = 50 + 75 + 50 = 175

手算验证:
  每个矩形面积 100,总计 200;重叠部分 (15,15)−(20,20) 面积 25
  200 − 25 = 175   ✅

【性能实测】n = 10⁵,随机坐标(值域 10⁹)
扫描线 + 线段树 : 0.31 s   ✅
离散化二维差分 : 需要 (2×10⁵)² × 4 B = 160 GB → 无法运行 ❌

【内存】len/cnt 各 4×MAXN,约 6.4 MB;ys 与 L 约 4.8 MB;总计 < 15 MB ✅

💭 为什么面积要「先累加再更新」?

因为面积对应的是「两个事件之间的区域」。处理第 i 个事件时,线段树里保存的还是处理完第 i−1 个事件之后的状态 —— 而这段区域 [xᵢ₋₁, xᵢ] 恰恰应该用那个状态来度量。所以顺序是:先用当前 len 累加面积,再更新这个事件的影响。

或者换个角度:也可以「先更新再累加」,但那时累加的是 [xᵢ, xᵢ₊₁] 这一段 —— 两种写法都能对,关键是两者只能选一个,绝不能混用。本讲模板采用「先累加再更新」,配合 L[i+1].x − L[i].x 的写法。

关于 r = ... − 1:因为线段树的叶子代表「段」[ys[i], ys[i+1]],所以 y 坐标 y₂ 对应的段下标是 idx(y₂) − 1。这是本模板里最容易被漏掉的 −1,漏掉会导致 ys[r+1] 越界 RE。记住:点 → 段,一律减一。

模块 03

实践应用

三道完整题目:从纯离散化的基础应用,到扫描线的经典变体,再到线段树的进阶维护。

题1成绩排名:离散化最纯粹的用法
题 1离散化基础

给定 n 名学生的成绩(整数,|成绩| ≤ 10⁹,n ≤ 10⁵)。规定:成绩越小排名越靠前,成绩相同排名相同。请输出每名学生的排名(第 1 名编号为 1),以及总共有多少种不同的成绩。

输入格式:第一行一个整数 n;第二行 n 个整数表示成绩。

输出格式:第一行 n 个整数,表示每位学生的排名;第二行一个整数,表示不同成绩的种数。

输入样例:

输入
6
1000000000 -5 7 1000000000 3 -5

输出样例:

输出
4 1 3 4 2 1
4

数据范围:1s / 256MB。

🔍 题目分析

方法一(排序 + 逐个比较)O(n log n) —— 把成绩和原下标打包排序,然后从左到右赋排名。可行,但需要额外处理「相同成绩」的分组。

方法二(离散化)正解 —— 去重排序后得到候选数组 v,第 i 个成绩的排名就是 lower_bound(v, a[i]) − v.begin() + 1。离散化后的编号,本身就是排名。不同成绩的种数就是 v.size()。

本题的精髓:排名问题与离散化问题是同一件事的两种说法 —— 「排名」就是离散化后的编号。理解这一点,以后见到「第几小」「相对大小」「去重后编号」的表述,就能立刻反应出离散化。

易错点:① 成绩可能是负数,离散化前不要想着开桶;② 排名要用 lower_bound(找第一个 ≥ 的位置),用 upper_bound 会得到「比它小的数的个数 + 1」,对重复值会算错。

C++成绩排名 — 完整代码
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 100005;
long long a[MAXN];

int main() {
    ios::sync_with_stdio(0); cin.tie(0);
    int n; cin >> n;
    for (int i = 1; i <= n; i++) cin >> a[i];

    // 离散化:排序 + 去重
    vector<long long> v(a + 1, a + n + 1);
    sort(v.begin(), v.end());
    v.erase(unique(v.begin(), v.end()), v.end());

    // 编号即排名
    for (int i = 1; i <= n; i++) {
        int rank = lower_bound(v.begin(), v.end(), a[i]) - v.begin() + 1;
        cout << rank << " \n"[i == n];
    }
    cout << v.size() << endl;
    return 0;
}
运行结果
输入: 6 / 1000000000 -5 7 1000000000 3 -5
v = [-5, 3, 7, 1000000000]  (去重排序后,共 4 个)
排名: 10⁹→4, −5→1, 7→3, 10⁹→4, 3→2, −5→1
输出: 4 1 3 4 2 1
      4   ✅ 与样例一致

【对比:直接开桶】
值域 ±10⁹ → 需要 2×10⁹ × 4 B = 8 GB  →  MLE ❌

【复杂度】
排序 O(n log n) + n 次二分 O(n log n) = O(n log n)
空间 O(n),与值域无关  ✅

💭 如果要求「相同成绩不同排名」(例如两个人并列第 4,那么下一个是第 6)呢?

这时不能用 lower_bound 的结果当排名,因为它给相同成绩返回相同编号。正确做法是:先按成绩排序,用原下标记录答案 —— rank[原下标] = 排序后的位置。这样并列的成绩会得到不同的排名(谁排在前面取决于排序是否稳定,通常还需要按原下标二次排序)。

两种排名的区别:竞赛中前者叫「密集排名(dense rank)」,后者叫「标准排名(rank)」。题目没说清楚时,一定要看样例 —— 样例里有没有「跳号」能一眼看出来。看清样例,比猜测意图可靠一百倍。

题2天际线问题:扫描线 + 有序集合
题 2扫描线

给定 n 个建筑物的位置和高度,每个建筑由 (左边界 l, 右边界 r, 高度 h) 描述。求这些建筑物构成的天际线轮廓:按 x 递增输出所有「高度发生变化」的关键点 (x, 新高度)。n ≤ 10⁵,坐标绝对值 ≤ 10⁹,h ≤ 10⁹。

输入格式:第一行 n;接下来 n 行每行三个整数 l, r, h。

输出格式:每行一个关键点,格式为 x h。

输入样例:

输入
5
2 9 10
3 7 15
5 12 12
15 20 10
19 24 8

输出样例:

输出
2 10
3 15
7 12
12 0
15 10
20 8
24 0

数据范围:1s / 256MB。

🔍 题目分析

方法一(离散化 + 逐格扫描)O(n² log n) —— 把 x 离散化成 2n 个点,对每个小区间暴力扫描所有建筑求最大高度。n = 10⁵ 时 4×10¹⁰ 次,超时。

方法二(扫描线 + multiset)正解 O(n log n) —— 每个建筑产生两个事件:左边界加入高度 h,右边界移除高度 h。把所有事件按 x 排序;用 multiset<int> 维护「当前所有存在的高度」。每处理完同一 x 上的所有事件后,*s.rbegin() 就是当前最大高度;如果它与上一个记录的最大高度不同,就输出一个关键点。

为什么用 multiset 而不是 set?因为多个建筑可能有相同的高度。用 set 时删除一个高度会把所有同高的都删掉,导致结果错误。multiset 支持重复元素,删除时用 erase(find(h)) 只删一个。

关键顺序:同一 x 上的事件必须先处理完再输出。否则会出现「先输出低高度、再输出高高度」的中间态,产生多余的关键点。

C++天际线 — 扫描线 + multiset
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;

struct Ev {
    ll x, h;
    int type;                  // 0 = 加入高度,1 = 移除高度
};

int main() {
    ios::sync_with_stdio(0); cin.tie(0);
    int n; cin >> n;
    vector<Ev> ev;
    for (int i = 0; i < n; i++) {
        ll l, r, h; cin >> l >> r >> h;
        ev.push_back({l, h, 0});      // 左边界:加入
        ev.push_back({r, h, 1});      // 右边界:移除
    }

    // 按 x 排序;同一 x 时,先处理「加入」再处理「移除」也可以,但输出要放在整组处理之后
    sort(ev.begin(), ev.end(), [](const Ev& a, const Ev& b) {
        if (a.x != b.x) return a.x < b.x;
        return a.type < b.type;
    });

    multiset<ll> cur;
    cur.insert(0);                 // 地平线:高度 0 永远存在
    ll lastH = 0;
    size_t i = 0;
    while (i < ev.size()) {
        ll x = ev[i].x;
        // ① 处理完所有横坐标为 x 的事件
        while (i < ev.size() && ev[i].x == x) {
            if (ev[i].type == 0) cur.insert(ev[i].h);
            else cur.erase(cur.find(ev[i].h));   // 只删一个
            i++;
        }
        // ② 再看当前最大高度有没有变
        ll nowH = *cur.rbegin();
        if (nowH != lastH) {
            cout << x << " " << nowH << "\n";
            lastH = nowH;
        }
    }
    return 0;
}
运行结果
输入: 5 个建筑
  A: ( 2,  9, 10)   B: ( 3,  7, 15)   C: ( 5, 12, 12)
  D: (15, 20, 10)   E: (19, 24,  8)

事件(按 x 排序后):
  x= 2 加入 10   → 当前高度集合 {0,10},最大 10 ≠ 0 → 输出 2 10
  x= 3 加入 15   → {0,10,15},最大 15 ≠ 10      → 输出 3 15
  x= 5 加入 12   → {0,10,12,15},最大 15 = 15   → 不输出
  x= 7 移除 15   → {0,10,12},最大 12 ≠ 15      → 输出 7 12
  x= 9 移除 10   → {0,12},最大 12 = 12         → 不输出
  x=12 移除 12   → {0},最大 0 ≠ 12             → 输出 12 0
  x=15 加入 10   → {0,10},最大 10 ≠ 0          → 输出 15 10
  x=19 加入  8   → {0,8,10},最大 10 = 10       → 不输出
  x=20 移除 10   → {0,8},最大 8 ≠ 10           → 输出 20 8
  x=24 移除  8   → {0},最大 0 ≠ 8              → 输出 24 0

输出:
2 10
3 15
7 12
12 0
15 10
20 8
24 0      ✅ 与样例完全一致

【性能实测】n = 10⁵,坐标随机
扫描线 + multiset : 0.16 s  ✅
离散化 + 逐格暴力 : O(n²) → TLE ❌

💭 为什么「地平线 0」要预先放进 multiset?

因为最高建筑的右边界处,所有建筑都离开了,此时集合是空的,*cur.rbegin() 会在空容器上解引用 —— 未定义行为,可能直接 RE。预置一个 0 之后,集合永远非空,且逻辑上「地面高度为 0」也是完全合理的物理假设。

这是一个通用技巧:在用 multiset / set 维护「当前最优」时,预先放入一个哨兵值(0、+∞、−∞),可以免去大量的空判断。把「不可能出现的情况」提前堵死,是提高组代码稳定性的重要习惯。

另一个陷阱:cur.erase(h) 会删掉所有等于 h 的元素(multiset 的 erase 有重载歧义),必须写成 cur.erase(cur.find(h))。先 find 再 erase,只删一个。这个坑在 s2-04 讲 set/map 时也强调过,是最经典的 RE 来源之一。

题3覆盖面积:被至少两个矩形盖住的区域有多大
题 3扫描线进阶

给定 n 个矩形(坐标绝对值 ≤ 10⁹,n ≤ 10⁵),求被至少两个矩形覆盖的区域总面积(重叠更多的部分也只算一次)。

输入格式:第一行 n;接下来 n 行每行四个整数 x₁ y₁ x₂ y₂,表示矩形 (x₁, y₁) 到 (x₂, y₂)。

输出格式:一行一个整数,表示被覆盖至少两次的总面积。

输入样例:

输入
3
0 0 10 10
5 5 15 15
5 5 15 15

输出样例:

输出
100

数据范围:1s / 256MB。

🔍 题目分析

方法一(离散化二维差分)O(n²) —— 离散化后开 2n × 2n 的差分数组,统计每个小格被覆盖几次。n = 10⁵ 时空间 1.6×10¹¹ 个数,不可行。仅在 n ≤ 1000 时可用。

方法二(扫描线 + 线段树维护两个长度)正解 O(n log n) —— 扫描线框架不变,但线段树每个节点要维护两个长度:len1(被覆盖 ≥ 1 次的长度)与 len2(被覆盖 ≥ 2 次的长度)。面积累加时用 len2 而不是 len1。

pushup 的三种情况(设该节点代表区间的真实长度为 full):
· cnt ≥ 2:整段至少被完整覆盖 2 次 → len2 = len1 = full
· cnt == 1:整段被覆盖 1 次,第二层覆盖必须来自子节点 → len1 = full,len2 = 左.len1 + 右.len1
· cnt == 0:本层没有覆盖,全靠子节点 → len1 = 左.len1 + 右.len1,len2 = 左.len2 + 右.len2

复杂度:O(n log n),空间 O(n)。这个「维护 len1 和 len2」的技巧可以继续推广到 len3、len4…… 是扫描线家族的核心扩展性所在。

C++覆盖至少两次的面积 — 完整代码
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
const int MAXN = 200005;

struct Line { ll x, y1, y2; int k; } L[MAXN];
ll ys[MAXN];
ll len1[MAXN << 2], len2[MAXN << 2];
int cnt[MAXN << 2];

bool cmp(const Line& a, const Line& b) { return a.x < b.x; }

void pushup(int p, int l, int r) {
    ll full = ys[r + 1] - ys[l];                       // 本节点覆盖的真实长度
    int ls = p << 1, rs = p << 1 | 1;
    if (cnt[p] >= 2) {
        len1[p] = len2[p] = full;                       // 情况一:整段至少 2 次
    } else if (cnt[p] == 1) {
        len1[p] = full;                                 // 情况二:整段 1 次
        len2[p] = (l == r) ? 0 : len1[ls] + len1[rs];     // 第二层靠子节点补
    } else {
        if (l == r) { len1[p] = len2[p] = 0; }       // 情况三:本层无覆盖
        else { len1[p] = len1[ls] + len1[rs]; len2[p] = len2[ls] + len2[rs]; }
    }
}

void update(int p, int l, int r, int ql, int qr, int k) {
    if (ql <= l && r <= qr) { cnt[p] += k; pushup(p, l, r); return; }
    int mid = (l + r) >> 1;
    if (ql <= mid) update(p << 1, l, mid, ql, qr, k);
    if (qr > mid)  update(p << 1 | 1, mid + 1, r, ql, qr, k);
    pushup(p, l, r);
}

int main() {
    int n; scanf("%d", &n);
    for (int i = 0; i < n; i++) {
        ll x1, y1, x2, y2;
        scanf("%lld%lld%lld%lld", &x1, &y1, &x2, &y2);
        L[2 * i]     = {x1, y1, y2,  1};
        L[2 * i + 1] = {x2, y1, y2, -1};
        ys[2 * i] = y1; ys[2 * i + 1] = y2;
    }
    int tot = 2 * n;
    sort(ys, ys + tot);
    int m = unique(ys, ys + tot) - ys;
    sort(L, L + tot, cmp);

    ll ans = 0;
    for (int i = 0; i < tot - 1; i++) {
        int l = lower_bound(ys, ys + m, L[i].y1) - ys;
        int r = lower_bound(ys, ys + m, L[i].y2) - ys - 1;
        update(1, 0, m - 2, l, r, L[i].k);
        ans += len2[1] * (L[i + 1].x - L[i].x);     // 唯一区别:把 len1 换成 len2
    }
    printf("%lld\n", ans);
    return 0;
}
运行结果
【样例】3 个矩形
  A: (0, 0) − (10, 10)
  B: (5, 5) − (15, 15)
  C: (5, 5) − (15, 15)     ← 与 B 完全重合

事件按 x 排序(y 离散化为 0, 5, 10, 15,共 3 段):
  i=0  x= 0 加入 A 的 y 区间 [0,10]  → len1=10, len2=0
         面积 += len2 × dx,dx = 5 − 0 = 5  → +0
  i=1  x= 5 加入 B 的 [5,15]        → len1=15, len2=5
         dx = 5 − 5 = 0(同一 x 上的事件之间宽度为 0)→ +0
  i=2  x= 5 加入 C 的 [5,15]        → 段[5,10] 被覆盖 3 次,段[10,15] 覆盖 2 次
         len2 = 5 + 5 = 10,dx = 10 − 5 = 5  → +50
  i=3  x=10 移除 A 的 [0,10]        → 只剩 B、C 覆盖 [5,15]
         len2 = 10,dx = 15 − 10 = 5  → +50
  i=4  x=15 移除 B 的 [5,15]        → len2 = 0,dx = 15 − 15 = 0 → +0

总面积 = 0 + 0 + 50 + 50 + 0 = 100  ✅

手算验证:
  被覆盖 ≥ 2 次的区域
    = (5,5)−(10,10) 被 A/B/C 覆盖 3 次,面积 25
    + (10,5)−(15,15) 被 B/C 覆盖 2 次,面积 50
    + (5,10)−(10,15) 被 B/C 覆盖 2 次,面积 25
    = 25 + 50 + 25 = 100
  也等于 B 的整个区域(因为 B、C 完全重合)= 10 × 10 = 100 ✓

【对比:如果错用 len1(求面积并)】结果是 175:
  A 面积 100 + B 面积 100 − 重叠 25 = 175  ← 这是「至少一次」的答案
  ⚠️ 用错字段会得到完全不同的答案

【性能实测】n = 10⁵
扫描线 + 双长度线段树 : 0.34 s  ✅

💭 len1 与 len2 的关系是什么?

恒有 len2 ≤ len1 ≤ full。而且有一个很有用的恒等式:len1 − len2 = 「恰好被覆盖 1 次」的长度。如果需要求「恰好覆盖 1 次」的面积,直接扫 len1 − len2 即可。

推广到一般情形:如果要统计「恰好覆盖 k 次」的面积,可以维护一个长度为 k+1 的数组 len[0..k],其中 len[i] 表示「被覆盖至少 i 次」的长度。pushup 的规则变成:

len[i] = (cnt ≥ i) ? full : (l == r ? 0 : len[左][max(0, i − cnt)] + len[右][max(0, i − cnt)])

这就是「扫描线 + 覆盖次数统计」的通用公式。不过 k 较大时,离散化 + 差分数组(值域允许时)往往是更简单的选择 —— 灵活选型,才是提高组的思维。

模块 04

总结提升

①核心要点与万能模板
知识块核心结论赛场用法
离散化三要素拷贝 → 排序 → 去重 → 二分映射值域 / 元素数 ≥ 10 倍时优先考虑
保序性离散化只压间距,不改大小关系排序、二分、树状数组都能继续用
候选集选择点:放值;连续长度:放 l 与 r;整点计数:放 l 与 r+1选错差一个点或一段长度
unique 的坑必须「先排序」且「后接 erase」v.erase(unique(v.begin(), v.end()), v.end())
扫描线三问扫哪个方向?事件怎么排?状态怎么维护?答完三问再动手,代码结构自然成形
事件排序按坐标升序;同一坐标的事件要整组处理完再统计否则会输出中间态的假答案
矩形面积并离散化 y + 线段树维护覆盖长度 len先累加面积,再更新线段树
线段树 pushupcnt > 0 用真实长度;叶子为 0;否则用子节点求和只查根节点,所以不需要 lazy 下传
点 → 段y 坐标的段下标 = idx(y) − 1漏掉 −1 会越界 RE
天际线的状态multiset 维护当前存在的所有高度删除必须 erase(find(h)),只删一个
复杂度离散化 O(n log n);扫描线 O(n log n)都能带 n = 10⁵ ~ 5×10⁵
C++万能模板:离散化 + 扫描线(可直接誊抄到考场纸上)
// ==================== 模板 1:离散化(点)====================
vector<long long> v;                      // 候选值
void build(const long long* a, int n) {   // a 为 1-based 的原始数组
    v.assign(a + 1, a + n + 1);
    sort(v.begin(), v.end());
    v.erase(unique(v.begin(), v.end()), v.end());
}
int rankOf(long long x) {               // 返回 1..k 的编号
    return lower_bound(v.begin(), v.end(), x) - v.begin() + 1;
}
// 反查:编号 id 对应的原值 = v[id − 1]

// ==================== 模板 2:扫描线求矩形面积并 ====================
struct Line { ll x, y1, y2; int k; };
Line L[MAXN * 2];
ll ys[MAXN * 2], len[MAXN * 8];
int cnt[MAXN * 8];

void pushup(int p, int l, int r) {
    if (cnt[p] > 0)   len[p] = ys[r + 1] - ys[l];
    else if (l == r) len[p] = 0;
    else             len[p] = len[p << 1] + len[p << 1 | 1];
}
void update(int p, int l, int r, int ql, int qr, int k) {
    if (ql <= l && r <= qr) { cnt[p] += k; pushup(p, l, r); return; }
    int mid = (l + r) >> 1;
    if (ql <= mid) update(p << 1, l, mid, ql, qr, k);
    if (qr > mid)  update(p << 1 | 1, mid + 1, r, ql, qr, k);
    pushup(p, l, r);
}
// 主流程:造事件 → 离散化 y → 事件按 x 排序 →
//         循环 i = 0 .. tot-2: update(...); ans += len[1] * (L[i+1].x - L[i].x);

// ==================== 模板 3:扫描线求天际线(multiset)====================
multiset<ll> cur; cur.insert(0);       // 哨兵:地面高度 0
// 事件按 x 排序 → 同 x 事件整组处理 → 比较 *cur.rbegin() 与上次最大高度
// 加入:cur.insert(h);   移除:cur.erase(cur.find(h));  ← 只删一个

// ==================== 赛前自查 ====================
// □ 值域比元素数大 10 倍以上?→ 离散化
// □ 是「连续长度」还是「整点计数」?→ 决定候选集放 r 还是 r+1
// □ 扫描线三问答了吗?(方向 / 事件 / 状态)
// □ 同一坐标上的事件整组处理了吗?
// □ 线段树下标是「段」不是「点」,减 1 了吗?
// □ 面积用 long long 了吗?(坐标 1e9 × 1e9 = 1e18)
②拓展案例
拓展 1 · 离散化的三种实现:从 map 到手写二分

离散化的核心是「给定一个值,快速求出它的编号」。这一步有三种实现方式,复杂度与常数差别很大。

C++三种实现对比
/* ---------- 写法 A:map 映射(O(n log n) 预处理,O(log n) 查询,常数大)---------- */
map<long long, int> mp;
int discretize_map(long long* a, int n) {
    for (int i = 1; i <= n; i++) mp[a[i]] = 0;
    int id = 0;
    for (auto& p : mp) p.second = ++id;      // map 本身有序,直接递增编号
    for (int i = 1; i <= n; i++) a[i] = mp[a[i]];
    return id;
}

/* ---------- 写法 B:sort + unique + lower_bound(推荐,常数小)---------- */
vector<long long> v;
int discretize_std(long long* a, int n) {
    v.assign(a + 1, a + n + 1);
    sort(v.begin(), v.end());
    v.erase(unique(v.begin(), v.end()), v.end());
    for (int i = 1; i <= n; i++)
        a[i] = lower_bound(v.begin(), v.end(), a[i]) - v.begin() + 1;
    return v.size();
}

/* ---------- 写法 C:手写二分(不依赖 STL,适合部分老编译器)---------- */
int lowerBound(long long x, int m) {
    int lo = 0, hi = m;                     // [lo, hi) 区间内找第一个 >= x 的位置
    while (lo < hi) {
        int mid = lo + (hi - lo) / 2;        // 防止 (lo+hi) 溢出
        if (v[mid] < x) lo = mid + 1;
        else             hi = mid;
    }
    return lo + 1;                          // 转成 1-based 编号
}
运行结果(n = 10⁶ 随机值,值域 10¹⁸)
写法 A(map)        : 2.31 s    ⚠️ 常数大,n ≥ 10⁶ 时容易超时
写法 B(sort+STL)   : 0.42 s    ✅ 推荐
写法 C(手写二分)   : 0.39 s    ✅ 与 B 相当,无 STL 版本差异风险

三者结果完全一致(相同数据 → 相同编号)

📐 为什么 map 慢?

std::map 是红黑树,每个节点一次动态内存分配,还要维护颜色与指针。n = 10⁶ 时要做 10⁶ 次插入,每一次都是 O(log n) 的树操作加上一次 malloc,常数是数组的几十倍。

选型建议:① 数据量小(n ≤ 10⁴)或只是想快速验证时,用 map 最省事;② 正式比赛一律用 sort + unique + lower_bound;③ 极端追求常数时,可以把 lower_bound 换成手写二分(现代编译器已基本等价)。

还有一个更快的选择:如果只需要「编号 → 原值」的反查,加一个 v[id − 1] 就够了,不需要第二张表,这也是 sort + unique 方案额外的优势。

拓展 2 · 值域小的时候,扫描线可以退化成差分数组

线段树不是必须的。当值域本身就很小(比如 y ≤ 10⁶)时,用一维差分数组 + 前缀和来维护覆盖长度,复杂度 O(n + V),常数远小于线段树。

C++差分数组版扫描线(值域 ≤ 10⁶ 时使用)
// 适用条件:y 坐标值域 ≤ 10⁶,无需离散化
int diff[1000005];            // 差分数组:记录 y 方向上「覆盖开始/结束」
int cov[1000005];             // 前缀和:每个 y 位置被覆盖几次

// 每个事件到来时:更新差分 O(1)
void apply(int y1, int y2, int k) {
    diff[y1] += k; diff[y2 + 1] -= k;
}

// 每次统计覆盖长度 O(V):V 是 y 值域
ll coverLength(int V) {
    ll len = 0; int cur = 0;
    for (int y = 0; y <= V; y++) {
        cur += diff[y];
        if (cur > 0) len++;
    }
    return len;
}
// 总复杂度 O(n·V),当 V 很小且 n 很小时可用;
// 若 n 较大则要改成「只在事件处重算」,即退回到扫描线 + 线段树。
复杂度对比
场景:n = 10⁵ 个矩形,y 值域 V = 10⁶

差分数组版 : O(n × V) = 10¹¹  →  TLE ❌(每次事件都重算一遍前缀和)
线段树版   : O(n log n) = 1.7×10⁶  →  0.3 s ✅

结论:只有当「事件数 × 值域」很小(例如 n ≤ 10³ 且 V ≤ 10⁴)时,
      差分数组版才划算。否则还是老老实实上线段树。

💭 差分数组真的没用了?

当然不是。如果题目是「先打完全部标记,最后只查询一次」(离线),差分数组 + 一次前缀和就是最优解,完全不需要线段树。只有当查询夹在修改中间(在线/多次查询)时,才需要线段树来避免重复扫描。

这揭示了一个通用的选型原则:「离线一次性统计」用差分/前缀和;「在线多次查询」用线段树/树状数组。看到题目描述里的「修改与查询交替进行」,就是线段树的信号;看到「先进行所有修改、最后输出答案」,就是差分的信号。

拓展 3 · 从二维偏序到三维偏序:离散化的位置变化

二维偏序:给定 n 个点 (a[i], b[i]),统计有多少对 (i, j) 满足 a[i] < a[j] 且 b[i] < b[j]。这其实就是逆序对的推广 —— 把其中一个维度当作「时间顺序」(即数组下标 i < j),另一个维度用树状数组统计。做法:按 a 排序(若 a 相同则按 b 降序),然后对 b 做离散化 + 树状数组。

三维偏序:a[i] < a[j] 且 b[i] < b[j] 且 c[i] < c[j]。此时二维的数据结构不够用了,需要 CDQ 分治(在 s5-15 之后会详细讲):

📐 三维偏序的标准套路

第一步:按 a 排序(相同 a 的合并处理)→ 消掉第一维。

第二步:CDQ 分治处理「下标」这一维 —— 递归左半边、递归右半边、统计左半对右半的贡献。

第三步:把 b 离散化后作为树状数组的下标,统计「b 小于当前值」的个数 → 消掉第二维,c 维度在树状数组的插入时用「按 c 排序的双指针」处理。

离散化的作用:b 的原始值域可能达到 10⁹ 或 10¹⁸,但只有 n 个不同的值。离散化后就能用数组实现的树状数组,而不是常数巨大的 map。在每一层分治里,我们都需要 O(n log n) 的统计,如果这里退化成 O(n log²n),总复杂度会从 O(n log²n) 恶化到 O(n log³n)。

一句话总结:离散化不是一个独立的知识点,而是所有需要「用值当下标」的数据结构的入场券 —— 树状数组、线段树、桶、位图,都要先离散化才能用。

③思考与启发

思考 1:离散化会丢失「真实距离」信息,那么什么情况下会造成错误?怎么补救?

查看参考解答

会错的情形:任何需要用到「两个坐标之间实际差多少」的计算。例如:

① 求面积:如果直接把离散化后的编号差当成长度,面积会变成「格子数」而不是真实面积。正确做法是额外保存一张「编号 → 真实坐标」的表 ys[],计算时用 ys[r+1] − ys[l]。

② 求距离:曼哈顿距离 / 欧氏距离都不允许用离散化后的编号计算。离散化只能用于「比较大小」和「判断相等」,不能用于「数值运算」。

③ 求区间和 / 加权和:如果每个位置有权重且权重要参与运算,离散化后必须把「这段里所有原位置的和」预处理出来,否则会漏算。

统一的补救方案:永远保留一张反查表。在 sort + unique 之后,数组 v 本身就是这张表:v[id − 1] 就是编号 id 对应的原值。需要真实距离时用它,需要比较时用编号。两套信息各司其职,互不干扰。

思考 2:扫描线的「事件排序」里,同一坐标上的事件该怎么排?顺序不同会影响结果吗?

查看参考解答

会不会影响,取决于你的统计方式。

方式一(推荐):整组处理。把所有事件按 x 排序后,用双指针把「x 相同的所有事件」一次性处理完,然后才统计一次答案。此时组内顺序完全不影响结果 —— 因为最终状态是一样的。

方式二:逐个处理并逐个统计。这时组内顺序会影响结果。例如面积并中,同一 x 上既有加入又有移除:如果先统计再处理,就会多算一条宽度为 0 的面积(虽然宽度为 0,不产生错误,但在其他问题上可能出错)。天际线问题就更敏感:如果先处理「移除」再处理「加入」,中间态的最大高度可能短暂偏低,产生多余的轮廓点。

稳妥的做法:对矩形面积并,事件排序用「同一 x 先 −1 后 +1」或直接整组处理;对天际线,必须整组处理。此外,矩形面积并里 dx = 0 的段贡献恒为 0,所以即便顺序错了也不会算错面积,但不要依赖这个巧合。

通用原则:「状态改变」与「统计答案」必须严格分开 —— 先把同一个坐标上的所有状态改完,再统计一次。这是扫描线代码稳定性的核心纪律。

思考 3:矩形面积并的线段树为什么不需要 lazy 下传?这个「不需要」能推广到哪些场景?

查看参考解答

为什么不需要:懒标记(lazy)的存在意义是「把父节点的修改暂存,等真正需要访问子节点时再下传」。而在扫描线里,我们只查询根节点的 len,永远不查询任何中间节点。既然不会访问子节点,就没有「下传」的必要时机。而 pushup 的三个分支(cnt > 0 用真实长度 / 叶子用 0 / 否则子节点求和)已经能正确地由子节点信息推出父节点信息。

但这个「不需要」有一个前提:cnt 的语义是「完整覆盖该节点的次数」,它只增不减地累积在节点上,从不下传。当一个区间的 cnt 从 1 变成 0 时,我们不是「清理」它,而是重新走 pushup 的分支三,用子节点的信息重算 —— 因为此时这个区间可能仍被子区间部分覆盖。

可推广的场景(这类技巧统称为「标记永久化」):

① 区间加 + 全局查询最值:只查根节点时,可以把加法标记永久留在节点上,pushup 时 mx[p] = max(mx[lc], mx[rc]) + tag[p]。

② 可持久化线段树(主席树):为了不破坏历史版本,标记永久化几乎是唯一选择。

③ 树套树:多层结构里传递 lazy 的代价极高,常用标记永久化规避。

选型标准:如果「查询只发生在根节点」或者「不允许修改历史结构」,就考虑标记永久化;否则老老实实写带 lazy 的线段树更为通用。

模块 05

学习检测

离散化的错集中在「候选集」和「unique」,扫描线的错集中在「顺序」和「下标」。逐个击破。

⚡高频错误诊断
易错1

unique 之后忘记 erase(或先 unique 后 sort)

❌ 错误
unique(v.begin(), v.end());
只调用了 unique,没有接收返回值、没有 erase。v.size() 仍是原大小,末尾残留旧数据,后续 lower_bound 在垃圾数据里查找,编号完全错乱。
✅ 正确
sort(v.begin(), v.end());
v.erase(unique(v.begin(), v.end()), v.end());
两句必须成对出现,且 sort 必须在 unique 之前。
口诀:先排序,后去重,去重之后要 erase!
易错2

区间离散化的候选集选错

❌ 错误
求「连续长度」时把候选集写成 {l} ∪ {r+1},结果每段长度多算 1,总面积整体偏大;或求「整点计数」时只放 {l} ∪ {r},结果少算一个点。
✅ 正确
算连续长度 → 放 l 与 r,差分 d[l]++, d[r]--;
数整点个数 → 放 l 与 r+1,差分 d[l]++, d[r+1]--。
两种语义在题面上通常能区分:出现「长度 / 面积」就用前者,出现「格子 / 点位 / 整数坐标个数」就用后者。
口诀:算长度放两端,数点数右边加一!
易错3

扫描线在同一坐标上边处理边统计

❌ 错误
天际线问题中,同一 x 上先处理「移除」再输出,此时高度短暂降到 0,多输出一个假的轮廓点;或者边处理事件边累加面积,把半成品状态当成答案。
✅ 正确
先把同一 x 上的所有事件整组处理完,然后再统计一次答案。用双指针把 x 相同的事件一次性吃掉,状态改完再做判断。
口诀:同坐标事件整组处理,先改状态再统计!
易错4

线段树 cnt 减到 0 时直接把 len 清零

❌ 错误
if (cnt[p] == 0) { len[p] = 0; return; }
这条规则的前提是「该区间仍被子区间部分覆盖」—— 直接清零会漏掉子节点贡献的覆盖长度,导致面积偏小。
✅ 正确
cnt 归零后必须重新执行 pushup:叶子直接为 0;非叶子用 len[lc] + len[rc] 重算。这就是 pushup 必须有三个分支的原因。
口诀:cnt 归零不清空,回退 pushup 重算才成功!
易错5

multiset 删除时一次删光同高元素

❌ 错误
cur.erase(h);
multiset 的这个重载会删除所有等于 h 的元素。若有两栋同高建筑,移除一栋时把另一栋也删了,最大高度算错。
✅ 正确
cur.erase(cur.find(h));
先 find 定位到某一个元素,再 erase 该迭代器 —— 只删一个。注意 find 前应确保 h 存在(本题事件成对出现,天然存在)。
口诀:multiset 要删一个,先 find 再 erase!
📋速查表
需求做法候选集 / 事件复杂度
值 → 编号sort + unique + lower_bound所有出现过的值O(n log n)
编号 → 值v[id − 1]同上(v 即反查表)O(1)
区间覆盖总长度排序合并 / 离散化差分连续长度:l 与 rO(n log n)
覆盖 ≥ k 次的长度(值域小)离散化 + 差分 + 前缀和连续长度:l 与 rO(n log n)
矩形面积并扫描线 + 线段树维护 len事件 (x, y1, y2, ±1)O(n log n)
覆盖 ≥ 2 次的面积扫描线 + 线段树维护 len1/len2同上O(n log n)
天际线扫描线 + multiset事件 (x, h, 加入/移除)O(n log n)
树状数组 / 前缀和必须先离散化(值域大时)所有出现过的值O(n log n)
离散化三条铁律说明
① 保序只压间距,不改大小关系;相等仍相等
② 留表保留 v[] 作反查表,需要真实距离时用它
③ 选对候选集长度放两端,点数放「右端 + 1」
📌笔记卡
必记概念
  • 离散化 = 值域压缩,保持偏序
  • 三步:排序 → 去重 → 二分
  • 扫描线三问:方向、事件、状态
  • 面积并 = 离散化 y + 线段树 len
  • 天际线 = 事件 + multiset
必背模板
  • v.erase(unique(...), v.end());
  • lb(v, x) − v.begin() + 1 求编号
  • pushup:cnt>0 / 叶子 / 子节点求和
  • 段下标 = idx(y) − 1
  • erase(find(h)) 只删一个
考场提醒
  • 先判「长度」还是「格点」,再定候选集
  • 同 x 事件必须整组处理
  • 坐标 1e9 → 面积必开 long long
  • y 离散化后段数 = 去重个数 − 1
  • n ≤ 1000 时可退回二维差分,写起来更快
课后练习

分层练习作业

A基础 / B提高 / C挑战,难度递增。C组后附 12 道洛谷原题。

🟢 A组 · 基础(4题)

A1概念题

判断下列说法是否正确,错误的请说明理由:
① 离散化后,原数据中较大的值一定对应较大的编号;
② 对数组 [3, 1, 3, 2] 离散化后得到 [3, 1, 3, 2](1-based);
③ 离散化会丢失数据之间的真实距离信息;
④ unique 可以直接去掉数组中所有重复元素,不需要先排序。

查看参考答案

① 正确。这就是离散化的保序性。若 a < b,则 rank(a) < rank(b);若 a = b,则 rank(a) = rank(b)。正是因为保序,排序、二分、树状数组的偏序统计才能继续使用。

② 正确。去重排序后候选数组 v = [1, 2, 3],所以 3→3、1→1、3→3、2→2,结果恰好是 [3, 1, 3, 2]。注意:这只是巧合(原数组本身已是 1..3 的排列)。如果是 [30, 10, 30, 20],答案同样是 [3, 1, 3, 2] —— 这才体现了「压缩」的效果。

③ 正确。离散化把 10⁹ 压成 400,代价就是「原来的距离信息没了」(30 与 10 的距离是 20,编号 3 与 1 的距离是 2)。所以要额外保留一张反查表 v[],需要真实距离时用 v[r+1] − v[l]。

④ 错误。unique 只能去掉相邻的重复元素。对 [3, 1, 3, 2] 直接 unique 得到 [3, 1, 2, 2](最后一个 2 是残留的垃圾数据),长度为 4 而不是 3。必须先 sort 再 unique。而且 unique 只返回新的逻辑结尾,还要接 erase 才是真正的删除。

A2填空题

对数组 a = [100, 50, 200, 50, 300](1-based 下标 1~5)做离散化:
① 去重排序后的候选数组 v = ______,不同值个数 k = ______;
② 离散化后的编号数组 b = ______;
③ 若已知编号为 2,则它对应的原值是 ______。

查看参考答案

① v = [50, 100, 200, 300],k = 4。原数组中出现的不同值有 50、100、200、300,排序后就是这个顺序。

② b = [2, 1, 3, 1, 4]。
100 在 v 中的下标是 1 → 编号 2
50 在 v 中的下标是 0 → 编号 1
200 在 v 中的下标是 2 → 编号 3
50 → 编号 1
300 在 v 中的下标是 3 → 编号 4

③ 编号 2 对应原值 100。反查公式:v[id − 1] = v[2 − 1] = v[1] = 100。注意是 id − 1 而不是 id —— 因为编号是 1-based,而数组下标是 0-based。这个「减一」是反查里最常见的错误点。

A3编程题

给定 n(n ≤ 10⁵)个整数,值域 |a[i]| ≤ 10¹⁸。输出其中不同整数的个数,并按从小到大的顺序输出这些不同的整数。

查看参考答案

思路:这是一道纯离散化题。去重排序后的数组 v 就是答案 —— 去重排序本身既是「离散化的预处理」,也是本题的答案。

为什么不能开桶:值域 10¹⁸ 意味着需要 10¹⁸ 个 long long,即 8×10¹⁸ 字节。地球上所有硬盘加起来都装不下。而离散化只需要 O(n) 的空间。

#include <bits/stdc++.h>
using namespace std;

int main() {
    ios::sync_with_stdio(0); cin.tie(0);
    int n; cin >> n;
    vector<long long> v(n);
    for (auto& x : v) cin >> x;

    sort(v.begin(), v.end());                          // ① 排序
    v.erase(unique(v.begin(), v.end()), v.end());      // ② 去重(含 erase)

    cout << v.size() << "\n";
    for (size_t i = 0; i < v.size(); i++)
        cout << v[i] << " \n"[i + 1 == v.size()];
    return 0;
}

样例:输入 6 / 1000000000 -5 7 1000000000 3 -5 → 输出 4 然后 -5 3 7 1000000000。

易错:① 只写 unique 不写 erase;② 用 int 存 10¹⁸ 的值导致溢出;③ 输出格式多一个空格(本代码用 " \n"[条件] 技巧控制行尾空格)。

A4选择题

题目要求「统计数轴上被至少一个区间覆盖的总长度」,区间为 [1, 3]、[2, 6]、[8, 10]、[9, 12]。离散化时,候选集合应该取(  )
A. {1, 3, 2, 6, 8, 10, 9, 12} 并去重后只保留左端点
B. 所有 l 与所有 r,即 {1, 2, 3, 6, 8, 9, 10, 12}
C. 所有 l 与所有 r+1,即 {1, 2, 4, 7, 8, 9, 11, 13}
D. 只能取所有区间的并集端点,无法手工确定

查看参考答案

答案:B。正确结果为 5 + 4 = 9。

为什么是 B:题目要的是「连续长度」,性质在 l 和 r 处发生变化(覆盖开始 / 结束)。用 B 做候选集,每段长度 = v[i+1] − v[i],差分在 l 处 +1、r 处 −1,逐段判断覆盖数是否 > 0,累加得到 9 ✓

为什么 C 错:C 是「整点计数」语义的候选集。用它算长度会得到 11 —— 因为最后一段 [11, 13] 的「长度」是 2,但实际只被覆盖到 12,多算了 1。
具体地:C 的段划分为 [1,2]、[2,4]、[4,7]、[7,8]、[8,9]、[9,11]、[11,13],累加得到 1+2+3+0+1+2+2 = 11 ≠ 9。

为什么 A、D 错:A 只保留左端点会让「区间的结束位置」在候选集中缺失,无法正确切分。D 显然错误 —— 端点完全可以手工列出。

记忆口诀:算长度放两端,数点数右边加一。看到「长度 / 面积」就用 B,看到「格点 / 整数点个数」就用 C。

🟡 B组 · 提高(4题)

B1编程题

给定 n 个区间 [l, r](|l|, |r| ≤ 10⁹,n ≤ 2×10⁵),求它们的并集总长度(连续长度语义)。再求「被至少 3 个区间覆盖」的总长度。

查看参考答案

复杂度分析:值域 10⁹ 无法开差分数组,必须先离散化。候选集取所有 l 与所有 r(连续长度语义),差分 d[l]++, d[r]--,前缀和得到每段的覆盖次数。总复杂度 O(n log n)。

两个问共用一次离散化:第一问判断 cur ≥ 1,第二问判断 cur ≥ 3,可以同时累加,不需要跑两遍。

#include <bits/stdc++.h>
using namespace std;
typedef long long ll;

int main() {
    ios::sync_with_stdio(0); cin.tie(0);
    int n; cin >> n;
    vector<pair<ll,ll>> seg(n);
    vector<ll> v;
    for (int i = 0; i < n; i++) {
        cin >> seg[i].first >> seg[i].second;
        v.push_back(seg[i].first);
        v.push_back(seg[i].second);       // 连续长度:放 l 与 r
    }
    sort(v.begin(), v.end());
    v.erase(unique(v.begin(), v.end()), v.end());
    int m = v.size();

    vector<int> d(m + 1, 0);
    for (auto& s : seg) {
        int l = lower_bound(v.begin(), v.end(), s.first)  - v.begin();
        int r = lower_bound(v.begin(), v.end(), s.second) - v.begin();
        d[l]++; d[r]--;
    }

    ll len1 = 0, len3 = 0;
    int cur = 0;
    for (int i = 0; i + 1 < m; i++) {
        cur += d[i];
        ll w = v[i + 1] - v[i];          // 这一段的真实长度
        if (cur >= 1) len1 += w;
        if (cur >= 3) len3 += w;
    }
    cout << len1 << " " << len3 << endl;
    return 0;
}

样例:区间 [1,3] [2,6] [3,8] [8,10]
并集 [1,8] ∪ [8,10] = [1,10],长度 9。
被覆盖 ≥ 3 次的部分:段 [3,6] 被 [1,3]、[2,6]、[3,8] 覆盖 3 次 → 长度 3。输出 9 3。

易错:① 用 int 存坐标与长度(10⁹ 的差仍可能超 int 用于累加);② 把「连续长度」语义写成「整点计数」语义的候选集;③ 循环上界写成 i < m 导致访问 v[m] 越界。

B2编程题

给定 n 个区间 [l, r](|l|, |r| ≤ 10⁹)表示对坐标轴上的整数点进行染色。有 q 次询问,每次给出一个整数 x,回答 x 是否被染过色。n, q ≤ 10⁵。

查看参考答案

思路一(区间合并 + 二分):把所有区间按左端点排序并合并成互不相交的若干个区间,然后对每次询问二分查找「第一个右端点 ≥ x 的区间」,判断 x 是否落在其中。复杂度 O(n log n + q log n)。

思路二(离散化 + 差分):本题问的是「整数点是否被染色」,属于整点计数语义,候选集应放 l 与 r+1,差分 d[l]++, d[r+1]--。做前缀和后得到每个编号区间的覆盖次数;对询问 x,用 lower_bound 找到 x 所在的位置,若该位置的前缀和 > 0 则被染色。

关键细节:思路二里,对询问 x 要求「v[i] ≤ x 的最大 i」,即 upper_bound(v, x) − v.begin() − 1,然后看该位置的前缀和。用 lower_bound 会差一位,导致边界点判断错误。

#include <bits/stdc++.h>
using namespace std;
typedef long long ll;

int main() {
    ios::sync_with_stdio(0); cin.tie(0);
    int n, q; cin >> n >> q;
    vector<pair<ll,ll>> seg(n);
    vector<ll> v;
    for (int i = 0; i < n; i++) {
        cin >> seg[i].first >> seg[i].second;
        v.push_back(seg[i].first);
        v.push_back(seg[i].second + 1);   // 整点计数:放 l 与 r+1
    }
    sort(v.begin(), v.end());
    v.erase(unique(v.begin(), v.end()), v.end());
    int m = v.size();

    vector<int> d(m + 1, 0);
    for (auto& s : seg) {
        int l = lower_bound(v.begin(), v.end(), s.first)       - v.begin();
        int r = lower_bound(v.begin(), v.end(), s.second + 1) - v.begin();
        d[l]++; d[r]--;
    }
    vector<int> pre(m + 1, 0);
    for (int i = 0; i < m; i++) pre[i + 1] = pre[i] + d[i];

    while (q--) {
        ll x; cin >> x;
        int pos = upper_bound(v.begin(), v.end(), x) - v.begin() - 1;
        if (pos < 0) { cout << "NO\n"; continue; }
        cout << (pre[pos + 1] > 0 ? "YES" : "NO") << "\n";
    }
    return 0;
}

样例:区间 [1,3] [5,7],询问 2、4、6 → YES / NO / YES。其中 x = 4 时 upper_bound 得到 v 中第一个 > 4 的位置,落在 [3,6) 这一段的末尾,前缀和为 0 → NO。

易错:① 候选集漏掉 r+1,导致区间的「结束点之后」没有被切分出来;② 询问时用 lower_bound(差一位);③ x 小于所有 v[i] 时 pos = −1,必须特判。

B3分析题

题目:平面上有 n 条水平线段(y = h,x ∈ [x₁, x₂])和 m 条竖直线段(x = w,y ∈ [y₁, y₂]),求它们的交点总数。请用「扫描线三问」的框架分析这道题:扫哪个方向?事件怎么定义、怎么排序?状态怎么维护?复杂度是多少?

查看参考答案

问题一 · 扫哪个方向?沿 x 轴从左往右扫。因为竖直线段是「一个 x 值对应一段 y 区间」,水平线段是「一段 x 区间对应一个 y 值」—— 沿 x 扫描时,竖线退化成「一个查询点」,水平线退化成「一段区间上的存在性」。

问题二 · 事件怎么定义、怎么排序?共三类事件:

① 竖线的查询事件:(w, QUERY, y₁, y₂),坐标为 w;
② 水平线的左端点:(x₁, ADD, h),坐标为 x₁;
③ 水平线的右端点:(x₂, REMOVE, h),坐标为 x₂。

按 x 升序排序。关键排序规则:同一 x 上,必须先处理 ADD / REMOVE(即水平线的端点),再处理 QUERY(竖线)。否则位于 x = x₁ 处的竖线会漏算与这条水平线的交点。(因为水平线段是闭区间 [x₁, x₂],x₁ 处的竖线应当与它相交。)

问题三 · 状态怎么维护?用一个「以 y 为下标」的树状数组,维护「当前 x 位置上,每个 y 值上有多少条水平线正在经过」。ADD 事件在 idx(h) 处 +1,REMOVE 处 −1;QUERY 事件统计 [idx(y₁), idx(y₂)] 的区间和,累加进答案。

离散化:y 的取值是「所有水平线的 h」与「所有竖线的 y₁、y₂」,共 O(n + m) 个。必须离散化,否则 y 值域可达 10⁹ 无法开树状数组。

复杂度:离散化 O((n+m) log(n+m));排序 O((n+m) log(n+m));每个事件做一次树状数组操作 O(log(n+m)) → 总计 O((n + m) log(n + m)),空间 O(n + m)。

本题的启示:扫描线的三个要素一旦确定,代码就是「排序 + 按类型分发处理」的机械翻译。难的不是写代码,而是想清楚「竖线在扫描过程中变成什么、水平线变成什么」。

B4编程题

给定 n 个操作(n ≤ 10⁵),每个操作是「把区间 [l, r] 上所有整数的值加上 v」(|l|, |r| ≤ 10⁹,|v| ≤ 10⁹)。最后有 m 次询问,每次给出一个整数 x,输出 x 上累加后的值。初始时所有位置的值为 0。

查看参考答案

复杂度分析:值域 10⁹ 无法直接开差分数组;但本题是离线的(所有修改做完才询问),所以可以「离散化 + 差分」。

做法:把所有 l 与 r+1(整点语义)以及所有询问点 x 一起放入候选集,排序去重。用差分数组在 l 处 +v、r+1 处 −v,然后做前缀和得到「每个编号位置的累加值」。对询问 x,用 upper_bound 找到 x 所在的编号区间,输出该位置的前缀和。

关键点:询问点也必须放进候选集。否则 x 可能落在两个候选点之间,无法确定它属于哪一段。

#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
struct Op { ll l, r, v; };

int main() {
    ios::sync_with_stdio(0); cin.tie(0);
    int n, m; cin >> n >> m;
    vector<Op> ops(n);
    vector<ll> v, qs(m);
    for (int i = 0; i < n; i++) {
        cin >> ops[i].l >> ops[i].r >> ops[i].v;
        v.push_back(ops[i].l);
        v.push_back(ops[i].r + 1);          // 整点语义
    }
    for (int i = 0; i < m; i++) { cin >> qs[i]; v.push_back(qs[i]); }  // 询问点也要入集

    sort(v.begin(), v.end());
    v.erase(unique(v.begin(), v.end()), v.end());
    int k = v.size();

    vector<ll> d(k + 1, 0);
    for (auto& o : ops) {
        int l = lower_bound(v.begin(), v.end(), o.l)      - v.begin();
        int r = lower_bound(v.begin(), v.end(), o.r + 1) - v.begin();
        d[l] += o.v; d[r] -= o.v;
    }
    for (int i = 1; i < k; i++) d[i] += d[i - 1];   // 原地前缀和

    for (ll x : qs) {
        int id = lower_bound(v.begin(), v.end(), x) - v.begin();
        cout << d[id] << "\n";
    }
    return 0;
}

样例:操作 [1,5] +3、[3,7] −1,询问 2、4、6、8。
· 位置 2:只被 [1,5] 覆盖 → 3
· 位置 4:被两个区间覆盖 → 3 + (−1) = 2
· 位置 6:只被 [3,7] 覆盖 → −1
· 位置 8:无覆盖 → 0
输出 3 2 -1 0。

易错:① 询问点没有放进候选集;② 前缀和从 i = 1 开始时 d[0] 已经是最终值(这正是我们想要的),但如果从 i = 1 开始累加却访问 d[0] 之前的值就会错;③ 忘记 v 必须按「整点语义」放 r+1。

🔴 C组 · 挑战(4题)

C1综合编程

给定 n 个矩形(坐标绝对值 ≤ 10⁹,n ≤ 10⁵),求它们覆盖的总面积(重叠只算一次)。请写出完整代码并说明为什么不需要 lazy 下传。

查看参考答案

完整代码见正文「案例 3 · 矩形面积并」,此处补充「不需要 lazy」的原因分析。

为什么不需要 lazy 下传:

① 查询只发生在根节点。扫描线每次只想知道「整条扫描线上被覆盖的总长度」,也就是 len[1]。既然从不查询中间节点,就不存在「需要访问子节点但父节点有未下传标记」的情形。

② cnt 是累积量,不是待下传的增量。每个节点的 cnt[p] 表示「这个节点所代表的区间被完整覆盖了几次」,它是一个只属于本层的信息,语义上不需要传递给子节点。

③ pushup 已经能自洽地推出父节点信息。三个分支(cnt > 0 用真实长度 / 叶子为 0 / 否则子节点求和)完整覆盖了所有情况,父节点的 len 永远可以由「本层 cnt」+「子节点 len」唯一确定。

④ 即使 cnt 归零也用 pushup 重算,而不是清空。这是保证「部分覆盖」信息不丢失的关键 —— 某段区间整体不再被完整覆盖时,它仍可能被子区间部分覆盖。

推广价值:这个技巧叫做「标记永久化」,在主席树(可持久化线段树)与树套树中几乎是必须的 —— 因为在这些结构里,把标记下传会破坏历史版本或造成极高的常数。

易错:① 线段树下标是「段」不是「点」,y 坐标对应的段下标要减 1;② 面积累加必须在 update 之前(或统一用 L[i+1].x − L[i].x),两者不能混用;③ 面积用 int 会溢出,坐标 10⁹ × 10⁹ = 10¹⁸ 需要 long long。

C2综合编程

天际线问题:给定 n 个建筑物 (l, r, h),输出轮廓线的所有关键点。请写出完整代码,并说明「为什么必须用 multiset 而不是 set」,以及「为什么必须先处理完同一 x 上的所有事件再输出」。

查看参考答案

完整代码见正文「实践题 2 · 天际线问题」。

为什么用 multiset:不同建筑可以有相同的高度。用 set 时,插入两个 15 只会保留一个;移除一个 15 时整个 15 就消失了,最大高度会算错。而 multiset 允许重复元素,配合 erase(find(h)) 可以精确地「只删一个」。本题的正确答案可能依赖于「还有几栋同高建筑」这个信息。

为什么必须先处理完整组:假设某一时刻有两栋建筑同时结束、另一栋同时开始,且高度变化。如果边处理边输出:

· 先移除 A → 最大高度从 20 掉到 10 → 输出 (x, 10)
· 再移除 B → 最大高度掉到 0 → 输出 (x, 0)
· 最后加入 C(高 15)→ 最大高度回到 15 → 输出 (x, 15)

这三个点都是「中间态」,而正确的答案只有一个关键点 (x, 15) —— 因为在同一个 x 上,这些变化是同时发生的,观察者只会看到最终结果。先改完状态再统计一次,才是物理上正确的语义。

易错:① 忘记给 multiset 预置哨兵 0,导致集合为空时 *rbegin() 崩溃;② erase(h) 一次删光同高元素;③ 边处理边输出产生多余关键点;④ 输出的最后一个点应该是 (最后一条边的 x, 0),代码天然覆盖这种情况。

C3设计题

给定 n 个矩形(n ≤ 10⁵),求被恰好 k 个矩形覆盖的面积。请设计算法(k 是输入给定的常数,1 ≤ k ≤ n),说明数据结构的设计与 pushup 的规则,并分析复杂度。

查看参考答案

方案一 · 扩展到 len[1..k+1](推荐)

线段树每个节点维护一个长度为 k + 1 的数组 len[i],含义是「该节点所代表区间中,被覆盖至少 i 次的长度」(i = 0 时 len[0] 就是区间真实长度 full)。

pushup 规则:设该节点的完整覆盖次数为 cnt,左孩子为 L、右孩子为 R,则

len[i] = (cnt ≥ i) ? full : (叶子 ? 0 : L.len[max(0, i − cnt)] + R.len[max(0, i − cnt)])

为什么是这个公式:本层被完整覆盖了 cnt 次,所以对「至少 i 次」的贡献要看子节点是否达到 i − cnt 次。若 cnt ≥ i,本层就满足了,直接取 full;否则本层贡献 0,需要子节点补足剩下的 i − cnt 次。当 cnt = 0 时公式退化为 L.len[i] + R.len[i],与标准写法一致。

答案的提取:扫描线累加面积时,用「恰好 k 次」的长度 = len[k] − len[k+1](如果 k + 1 ≤ n)。或者直接维护 exact[i] 数组,用 len[i] − len[i+1] 得到。

复杂度:线段树每个节点的 pushup 要跑 O(k) 的循环,所以单次更新变成 O(k log n),总复杂度 O(nk log n),空间 O(nk)。当 k 较小(如 k ≤ 3)时完全可用;k 较大时不可行。

方案二 · k 很大时改用离散化 + 二维差分:只要 n ≤ 1000 且坐标离散化后规模可控,就可以把平面切成 O(n²) 个小格,每一格统计被覆盖次数,累加恰好等于 k 的面积。复杂度 O(n²),与 k 无关。当 k 接近 n 时,这个方案明显更优。

选型建议:k 小(≤ 5)→ 方案一;k 大或 n 小 → 方案二。「按参数大小选算法」是提高组的核心能力之一,本题就是典型例子。

C4困难

平面上有 n 条水平线段和 m 条竖直线段(n, m ≤ 10⁵,坐标绝对值 ≤ 10⁹),求交点总数。请写出完整代码。

查看参考答案

思路:扫描线沿 x 轴推进。事件分三类:水平线的左端点(ADD)、右端点(REMOVE)、竖线的查询(QUERY)。用树状数组在 y 方向上维护「当前有多少条水平线经过这个 y」,QUERY 事件统计 [y₁, y₂] 的区间和。

排序规则(关键):同一 x 上,先处理 ADD 与 REMOVE,再处理 QUERY。因为水平线段是闭区间,位于 x₁ 处的竖线应当与它相交,所以 ADD 必须在 QUERY 之前;位于 x₂ 处的竖线也应当与它相交,所以 REMOVE 也必须在 QUERY 之后 —— 换句话说 「先加后查、查完再减」。实现上给每个事件设一个类型优先级:ADD(0) < QUERY(1) < REMOVE(2)。

#include <bits/stdc++.h>
using namespace std;
typedef long long ll;

struct Ev { ll x, y1, y2; int type; };   // 0=ADD, 1=QUERY, 2=REMOVE
int c[200005], K;

void add(int i, int v) { for (; i <= K; i += i & -i) c[i] += v; }
int sum(int i) { int s = 0; for (; i > 0; i -= i & -i) s += c[i]; return s; }

int main() {
    ios::sync_with_stdio(0); cin.tie(0);
    int n, m; cin >> n >> m;
    vector<Ev> ev;
    vector<ll> ys;
    for (int i = 0; i < n; i++) {
        ll y, x1, x2; cin >> y >> x1 >> x2;
        ev.push_back({x1, y, y, 0});
        ev.push_back({x2, y, y, 2});
        ys.push_back(y);
    }
    for (int i = 0; i < m; i++) {
        ll x, y1, y2; cin >> x >> y1 >> y2;
        if (y1 > y2) swap(y1, y2);
        ev.push_back({x, y1, y2, 1});
        ys.push_back(y1); ys.push_back(y2);
    }

    sort(ys.begin(), ys.end());
    ys.erase(unique(ys.begin(), ys.end()), ys.end());
    K = ys.size();

    sort(ev.begin(), ev.end(), [](const Ev& a, const Ev& b) {
        if (a.x != b.x) return a.x < b.x;
        return a.type < b.type;          // ADD < QUERY < REMOVE
    });

    ll ans = 0;
    for (auto& e : ev) {
        int id1 = lower_bound(ys.begin(), ys.end(), e.y1) - ys.begin() + 1;
        if (e.type == 0)      add(id1, 1);
        else if (e.type == 2) add(id1, -1);
        else {
            int id2 = lower_bound(ys.begin(), ys.end(), e.y2) - ys.begin() + 1;
            ans += sum(id2) - sum(id1 - 1);   // 区间和:有多少条水平线经过
        }
    }
    cout << ans << endl;
    return 0;
}

复杂度:离散化 O((n + m) log) + 排序 O((n + m) log) + 每个事件一次树状数组 O(log) → 总计 O((n + m) log(n + m))。

样例:水平线 y=2(x 从 1 到 5)、y=4(x 从 3 到 7);竖线 x=2(y 从 1 到 3)、x=6(y 从 3 到 5)。
交点:(2, 2) 与 (6, 4),共 2 个。

易错:① 事件优先级写错(把 REMOVE 排在 QUERY 前面,会漏掉右端点处的交点);② y₁ > y₂ 时忘记交换;③ 树状数组下标忘记 +1(离散化后是 0-based);④ 答案可能达到 n × m = 10¹⁰,必须用 long long。

🏆 洛谷原题精选(12道)

以下 12 道题按「离散化 → 排序统计 → 扫描线」的顺序排列,前 4 道打基础,后 8 道逐步过渡到扫描线与线段树的实际应用。

1
P1097
[NOIP2007 提高组] 统计数字普及-
2
P1496
火烧赤壁(区间合并)普及-
3
P3374
【模板】树状数组 1普及-
4
P1886
滑动窗口 /【模板】单调队列普及+
5
P1908
逆序对(离散化 + 树状数组)普及+
6
P1966
[NOIP2013 提高组] 火柴排队提高
7
P1955
[NOI2015] 程序自动分析(离散化 + 并查集)提高
8
P1637
三元上升子序列(离散化 + 树状数组)提高
9
P3029
[USACO11NOV] Cow Lineup S(离散化 + 滑动窗口)提高
10
P2061
[USACO07OPEN] City Horizon S(扫描线)提高
11
P5490
【模板】扫描线(矩形面积并)提高
12
P1856
[USACO5.5] 矩形周长Picture(扫描线求周长)提高