Day7
离散化(Discretization)是算法竞赛中一个非常重要的技巧。它的核心思想是:当你在意的是数据之间的“相对大小(排名)”而不是“绝对数值”时,把大范围、稀疏的数据映射到小范围、紧凑的区间内。
以下从三个维度为你细讲:
1. 为什么要离散化?(痛点所在)
假设有一个问题:给你 \(n=10^5\) 个数,让你用树状数组统计一些信息。
如果这些数的大小范围是:
- 情况 A: 数值在 \(1\) 到 \(10^5\) 之间。直接开
int tree[100005],完美。 - 情况 B: 数值在 \(1\) 到 \(10^9\) 之间。你无法开出一个
int tree[1000000005]的数组,内存会直接炸掉(MLE)。 - 情况 C: 数值包含负数,如 \(-10^9\) 到 \(10^9\)。数组下标不能为负,树状数组直接瘫痪。
矛盾点: 树状数组(和线段树)的性能和空间是随数值范围(值域)增长的,而我们的内存是有限的。
突破口: 只有 \(10^5\) 个数。虽然数值很大,但如果把它们按从小到大排个名,第 1 小、第 2 小……第 \(10^5\) 小。这个“排名”的范围只有 \([1, 10^5]\)。
2. 离散化的核心逻辑
离散化就像是给这组数“发号码牌”。
例子:
原数组 \(A = [100, 5, 20000, 5, 999999]\)
- 去重排序:得到有序序列 \(L = [5, 100, 20000, 999999]\)
- 映射(发牌):
- 数值 \(5\) \(\to\) 排名 \(1\)
- 数值 \(100\) \(\to\) 排名 \(2\)
- 数值 \(20000\) \(\to\) 排名 \(3\)
- 数值 \(999999\) \(\to\) 排名 \(4\)
- 替换:原数组 \(A\) 变成了 \(A' = [2, 1, 3, 1, 4]\)
观察: \(A'\) 中数字的相对大小关系和 \(A\) 完全一致(比如 \(A[0] > A[1]\),对应的 \(A'[0] > A'[1]\) 依然成立)。现在我们只需要一个大小为 \(4\) 的树状数组就能处理原本需要 \(10^6\) 空间的逻辑。
3. 代码实现细节
在你提供的代码中:
for(int i=1;i<=n;i++) scanf("%lld",&a[i]), lsh[i] = a[i]; // 1. 复制一份到离散化数组
sort(lsh+1, lsh+n+1); // 2. 排序
// unique(lsh+1, lsh+n+1); // (可选) 去重
for(int i=1;i<=n;i++)
a[i] = lower_bound(lsh+1, lsh+n+1, a[i]) - lsh; // 3. 查找排名并替换
sort:确定谁大谁小。unique:如果你希望相同的数字对应同一个排名,就需要去重。在求逆序对时,如果不对相同元素去重,lower_bound也会让相同的数找到相同的排名(第一个出现的位置),逻辑上也是通的。lower_bound:这是在有序数组中找“排名”的最快方法(二分查找),复杂度 \(O(\log n)\)。
4. 什么时候需要离散化?
当你看到以下特征时,就要考虑离散化:
- 空间限制:数组下标需要用到题目给出的数值,但数值范围(如 \(10^9\))远大于数组能开的大小。
- 性质只关乎大小:题目只关心 \(a_i\) 是否大于 \(a_j\),或者关心 \(a_i\) 在所有数中的排名(如:逆序对、求第 \(K\) 大、统计比当前数小的数有多少个)。
- 静态数据:离散化通常针对初始就给定的数据。如果是动态增加全新的、范围极大的数值,离散化就比较难做,通常需要改用动态开点线段树或平衡树。
总结
离散化就是把“稀疏的大值域”压缩成“连续的紧凑值域”。它是为了在不改变元素相对关系的前提下,让数据能够适配那些“以数值为下标”的数据结构(如树状数组)。
这个问题问得非常深刻,直击数据结构设计的核心。
简单来说,树状数组和线段树在最原始的设计中,是“以数值作为下标”来直接寻址的。
我们可以从以下两个维度来拆解“为什么”:
1. 空间维度:下标即位置(Direct Addressing)
树状数组和线段树在本质上都是基于数组(Array)实现的。
- 逻辑关系:在你的代码里,
add(x, v)的含义是“在数值为x的那个槽位加上v”。 - 物理实现:计算机在内存里开辟数组时,必须是连续的一块空间。如果你要访问
tree[1000000000](10亿),操作系统就必须为你预留出从0到10亿这么长的空间。 - 计算开销:
- 1个
int占 4 字节。 - 如果你有 1 个数是 \(10^9\),你需要开一个大小为 \(10^9\) 的数组。
- \(10^9 \times 4\) 字节 \(\approx 4000\) MB \(\approx 4\) GB。
- 现实:一般的编程竞赛题目内存限制通常是 256MB 或 512MB。如果你直接开数组,程序还没运行就会因为 MLE (Memory Limit Exceeded) 而崩溃。
- 1个
这就是为什么说空间随“值域”增长:因为数组的长度必须等于数值的最大范围。
2. 性能维度:树的高度(Tree Height)
树状数组和线段树的查找效率都是 \(O(\log N)\)。这里的 \(N\) 就是你维护的值域范围。
- 树状数组:它的每一个节点代表一个区间的和。从根节点到叶子节点的路径长度取决于下标的二进制位数。
- 如果值域是 \(10^5\),\(\log_2(10^5) \approx 17\),每次操作转 17 圈。
- 如果值域是 \(10^9\),\(\log_2(10^9) \approx 30\),每次操作转 30 圈。
- 线段树:线段树是一棵二叉树,它不断地把区间 \([L, R]\) 二分为 \([L, mid]\) 和 \([mid+1, R]\)。
- 如果区间跨度是 \(10^9\),树的深度就是 30 层。每一层都要消耗计算时间。
虽然 \(\log\) 增长很慢,但真正致命的往往不是那点时间,而是无法承受的空间消耗。
3. 一个直观的类比
想象你要给一排房子送信:
- 普通情况:街道上有 100 个房子,门牌号是 1 到 100。你只需要准备 100 个信箱。
- 极端情况(值域大):这条街上只有 2 个人住,但他们的门牌号一个是
1,另一个是1,000,000,000(10亿)。- 如果你用树状数组的思想(原始方法):你必须沿着街道建 10 亿个信箱,哪怕中间那 9.9 亿个信箱全是空的。因为你的算法(
lowbit)只知道通过门牌号直接跳到对应的信箱位置。 - 如果你用离散化的思想:你会说:“虽然你的门牌号是10亿,但你是这条街上第 2 个住户,我就把你对应到 2号 信箱”。这样你只需要 2 个信箱就够了。
- 如果你用树状数组的思想(原始方法):你必须沿着街道建 10 亿个信箱,哪怕中间那 9.9 亿个信箱全是空的。因为你的算法(
总结
因为标准的树状数组是一张“死表”,它的下标直接映射了数值。
如果你不想用离散化,但又想处理巨大的值域,通常有两种高端方案:
- 动态开点线段树:只有当某个数值出现时,才临时在内存里申请对应的节点(像链表一样)。
- 平衡树(如 Treap, Splay):它不依赖数值大小做下标,而是根据节点的相对大小动态维护树形结构。
但离散化依然是最高效、最常用的手段,因为它把一个“稀疏”的、难以预测的大空间,压缩成了一个“稠密”的、易于操作的小空间。
这段代码实现了一个支持区间修改(区间加法)和区间查询(区间求和)的标准线段树(Segment Tree)。
线段树是一种二叉树形数据结构,它将一个区间划分为若干个单位区间,用于高效处理区间相关的问题。下面我将通过结构、核心操作和关键机制(懒标记)来详细讲解这段代码。
1. 线段树的结构与定义
#define lson rt << 1 // 左儿子:节点编号 * 2
#define rson rt << 1 | 1 // 右儿子:节点编号 * 2 + 1
struct Segmtr {
int l, r; // 当前节点负责的区间范围 [l, r]
int pre, add; // pre: 当前区间的区间和;add: 懒标记(Lazy Tag)
} tr[maxn << 2]; // 数组大小通常开 4 倍
- 空间开销:线段树通常需要开
4n的空间,因为一棵平衡二叉树在最坏情况下(n不是2的幂时)底层节点分布可能达到这个规模。 - 节点关系:如果根节点编号为
1,那么编号为rt的节点,其左儿子是rt*2,右儿子是rt*2+1。
2. 向上更新:PushUp
void PushUp(int rt){
tr[rt].pre = tr[lson].pre + tr[rson].pre;
}
当左右儿子的信息(区间和)确定后,父节点的信息等于两个儿子信息之和。
3. 建树:Build
void Build(int rt, int l, int r){
tr[rt].l = l, tr[rt].r = r;
if(l == r){
tr[rt].pre = a[l]; // 叶子节点,直接赋值
return ;
}
int mid = (l + r) >> 1;
Build(lson, l, mid); // 递归建左树
Build(rson, mid + 1, r); // 递归建右树
PushUp(rt); // 向上更新父节点和
}
这是一个递归过程,将区间 [1, n] 不断折半,直到 l == r(叶子节点)。
4. 核心:懒标记(Lazy Tag)与 PushDown
这是区间修改最关键的地方。如果不使用懒标记,区间修改的时间复杂度会退化到 \(O(n)\)。
原理:当我们修改一个区间 [l, r] 时,如果当前节点 rt 的范围正好被 [l, r] 包含,我们只修改 rt 的总和值,并记录一个“增量”到 add 变量中,而不立即去修改它的子孙节点。
void PushDown(int rt){
if(tr[rt].add){
// 更新左儿子的区间和:原值 + 增量 * 长度
tr[lson].pre += tr[rt].add * (tr[lson].r - tr[lson].l + 1);
// 更新右儿子的区间和
tr[rson].pre += tr[rt].add * (tr[rson].r - tr[rson].l + 1);
// 将增量传递给儿子的懒标记
tr[lson].add += tr[rt].add;
tr[rson].add += tr[rt].add;
// 当前节点标记清零
tr[rt].add = 0;
}
}
什么时候下传? 当我们之后的操作需要访问子节点(比如查询或更细粒度的修改)时,才调用 PushDown 将欠下的账补上。
5. 区间修改:Update
void Update(int rt, int l, int r, int v){
// 情况1:当前节点区间完全在目标区间内
if(l <= tr[rt].l && tr[rt].r <= r){
tr[rt].pre += v * (tr[rt].r - tr[rt].l + 1); // 更新当前节点和
tr[rt].add += v; // 打上懒标记
return ;
}
// 情况2:如果不完全包含,需要下传标记,递归子树
PushDown(rt);
int mid = (tr[rt].l + tr[rt].r) >> 1;
if(l <= mid) Update(lson, l, r, v);
if(r > mid) Update(rson, l, r, v);
PushUp(rt); // 递归回来后更新当前节点
}
6. 区区间查询:query
ll query(int rt, int l, int r){
if(l <= tr[rt].l && tr[rt].r <= r){
return tr[rt].pre; // 完全包含,直接返回
}
PushDown(rt); // 下传标记,保证子节点数据的准确性
int mid = (tr[rt].l + tr[rt].r) >> 1;
int res = 0;
if(l <= mid) res += query(lson, l, r);
if(r > mid) res += query(rson, l, r);
return res;
}
总结:
- 时间复杂度:
- 建树:\(O(n)\)
- 单次区间修改:\(O(\log n)\)
- 单次区间查询:\(O(\log n)\)
- 核心思想:
- 分治:将大区间拆分成小区间处理。
- 延迟更新(Lazy Tag):通过“打标记”的方式避免不必要的重复计算。
- 代码小细节:
#define int long long:这是为了防止区间和超出int的范围(常见的防爆 long long 技巧)。read():手写快读函数,加速输入。maxn << 2:保证数组空间足够。
这套模板是解决“区间修改+区间查询”类问题的万能钥匙,比如洛谷 P3372 【模板】线段树 1。

浙公网安备 33010602011771号