离散化
离散化的原理与实现:保留大小关系,把很大的值域压缩成连续的小编号。
一句话算法
离散化保留大小关系,把很大的值域压缩成连续的小编号。
问题模型
有一组数值
很多算法需要用数组下标表示这些值,例如树状数组、线段树、桶统计。如果直接按原值开数组,会浪费空间甚至无法开出数组。
离散化要做的是:
- 相同的原值映射到相同编号。
- 原值大小关系不变:若
,则 。 - 编号连续,通常为
。
核心直觉
我们不关心数值本身有多大,只关心这些数之间的相对顺序。
例如:
1000000000 -5 1000000000 7
出现过的不同值排序后是:
-5 7 1000000000
于是映射为:
-5 -> 1
7 -> 2
1000000000 -> 3
原来的巨大值域被压缩成了
算法步骤
- 把所有需要离散化的值收集到数组
xs。 - 对
xs排序。 - 使用
unique去掉重复值。 - 对每个原值
x,用lower_bound找到它在xs中的位置。 - 位置加一,得到
下标离散编号。
“必须收集完整”
离散化前要先收集所有可能被查询或修改到的值。
如果后续出现了没有加入 `xs` 的值,`lower_bound` 只能告诉你它应该插入哪里,不能保证这是合法映射。
算法证明
关键不变量:xs 排序去重后,按升序保存所有出现过的不同值。
-
唯一性 去重后每个原值在
xs中只出现一次,所以相同原值一定映射到同一个位置。 -
保序性
xs是升序数组。若,则 在 xs中的位置一定在前面。 -
连续性
xs的下标天然连续。若不同值个数为,则编号正好是 到 。
所以离散化后的编号既保留大小关系,又把值域压缩成连续小范围。
复杂度分析
- 收集元素:
。 - 排序去重:
。 - 单次查询映射:
。 - 空间复杂度:
。
如果要查询很多次,可以把每个原值到编号的映射存入哈希表,把查询降到均摊 vector + lower_bound 更简单稳定。
代码实现
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
#include <bits/stdc++.h>
using namespace std;
struct Discrete {
vector<int> xs;
void clear() {
xs.clear();
}
void add(int x) {
xs.push_back(x);
}
void build() {
sort(xs.begin(), xs.end());
xs.erase(unique(xs.begin(), xs.end()), xs.end());
}
// 返回 x 离散化后的 1 下标编号。
int get(int x) const {
return lower_bound(xs.begin(), xs.end(), x) - xs.begin() + 1;
}
// 找不到时返回 -1,适合查询不确定是否出现过的值。
int get_maybe(int x) const {
auto it = lower_bound(xs.begin(), xs.end(), x);
if (it == xs.end() || *it != x) return -1;
return it - xs.begin() + 1;
}
// 根据 1 下标编号找回原值。
int origin(int k) const {
return xs[k - 1];
}
int size() const {
return (int)xs.size();
}
};
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n;
cin >> n;
vector<int> a(n);
Discrete disc;
for (int i = 0; i < n; i++) {
cin >> a[i];
disc.add(a[i]);
}
disc.build();
cout << disc.size() << '\n';
for (int i = 0; i < n; i++) {
if (i > 0) cout << ' ';
cout << disc.get(a[i]);
}
cout << '\n';
return 0;
}
测试用例
输入:
6
100 5 100 -3 5 9
输出:
4
4 2 4 1 2 3
不同值排序后为:
-3 5 9 100
所以原序列映射为:
100 -> 4
5 -> 2
100 -> 4
-3 -> 1
5 -> 2
9 -> 3
应用分类详解
离散化的本质是“值域压缩”。当值很大但不同值数量不多,并且算法只依赖大小关系或相等关系时,就应该考虑离散化。
一、坐标压缩
典型模式: 坐标范围巨大,但只会操作有限个坐标点。
识别信号: 题面出现“坐标可达
核心建模: 收集所有出现过的坐标,排序去重后把坐标映射成连续编号。
| 应用场景 | 经典题目 | 核心思路 |
|---|---|---|
| 扫描线 | luogu-P5490 | 矩形边界坐标很大,先离散化 y 坐标 |
| 区间覆盖 | 坐标覆盖类题目 | 只关心出现过的端点和相邻段 |
二、配合树状数组或线段树
典型模式: 原值很大,但需要按值域维护前缀信息、排名、数量。
识别信号: 题面要求“统计比当前数小的数量”“排名”“逆序对”,且数值范围远大于
核心建模: 把值离散成
| 应用场景 | 经典题目 | 核心思路 |
|---|---|---|
| 逆序对 | luogu-P1908 | 值离散化后,用树状数组统计前面有多少更大的数 |
| 动态排名统计 | 权值树状数组题 | 离散值作为下标维护出现次数 |
三、状态压缩前的重编号
典型模式: 原编号不连续,但后续算法要求编号连续。
识别信号: 输入给出字符串、巨大编号、稀疏编号,但需要建图或数组 DP。
核心建模: 把出现过的对象映射到连续整数编号,再用普通数组建结构。
| 应用场景 | 经典题目 | 核心思路 |
|---|---|---|
| 稀疏点建图 | 图论建模题 | 把实际编号压缩成 |
| 字符串 ID 映射 | 账户/名字类题 | 名字映射成整数点编号 |
四、离线处理端点
典型模式: 操作全部提前给出,端点很大,但查询只发生在这些端点附近。
识别信号: 题面允许先读入全部操作;操作端点值域大,操作数量不大。
核心建模: 先收集所有操作涉及的端点,有时还要加入 x+1 或相邻边界,保证区间长度信息不丢。
| 应用场景 | 经典题目 | 核心思路 |
|---|---|---|
| 区间染色 | 离线区间覆盖题 | 收集左右端点和必要的相邻点 |
| 差分坐标压缩 | 大坐标区间加 | 离散端点后再做差分 |
经典例题
1. luogu-P1908
逆序对中的数值范围可能较大,但只需要比较大小。先离散化,再用树状数组统计每个数前面已经出现过多少个更大的数。
2. luogu-P5490
扫描线求矩形面积并。矩形坐标可能很大,但线段树只需要维护出现过的 y 坐标相邻区间,因此要先对 y 坐标离散化。
3. luogu-P3368
如果区间端点本身来自巨大坐标,可以先离散化端点,再把区间修改转到压缩后的编号上处理。
参考
- 旧版文章:
Rbook_ejs_old/book/base/discrete/index.md - 旧版模板:
Rbook_ejs_old/book/base/discrete/discrete.cpp