深入理解权值线段树

在上一篇探讨线段树的文章中 https://www.cnblogs.com/ofnoname/p/18625369,我们已经掌握了如何利用线段树高效处理数组区间查询与更新问题。这种经典线段树以数组下标为构建基础,完美解决了诸如区间求和、最值查询等典型场景。

而线段树结构还有另外一个用处:想象这样一个场景:我们需要实时统计当前集合中数值在[L,R]范围内的元素个数,或者快速查询第K大的数值。此时,权值线段树(Weight Segment Tree)便闪亮登场——它巧妙的维护基础从"数组下标"转换为"值域空间",开辟了线段树应用的新维度。

与常规线段树不同,权值线段树面临两个独特挑战:首先,值域范围可能非常庞大(如[1,1e9]),使得传统的数组存储方式变得不切实际;其次,值域分布往往极其稀疏(仅有少量离散点被使用)。这促使我们放弃静态的数组表示法,转而采用动态节点生成的指针表示法,只在必要时创建节点,从而大幅节省空间。

权值线段树的基本原理

从下标维护到值域维护

传统线段树维护的是数组下标区间的信息,例如:

  • 区间和: sum ( l , r ) = ∑ i = l r a i \text{sum}(l, r) = \sum_{i=l}^{r} a_i sum(l,r)=i=lrai
  • 区间最大值: max ⁡ ( l , r ) = max ⁡ ( a l , a l + 1 , … , a r ) \max(l, r) = \max(a_l, a_{l+1}, \dots, a_r) max(l,r)=max(al,al+1,,ar)

而权值线段树(也称为值域线段树)维护的是数值的值域区间,例如:

  • 数值在 [ L , R ] [L, R] [L,R] 范围内的出现次数
  • 整个集合中第 K K K 小的数

二叉树的表示方法

线段树是一个二叉树。由于权值线段树的值域可能非常大(如 [ 1 , 10 9 ] [1, 10^9] [1,109]),甚至稀疏(仅有少量数值被插入),我们无法像传统线段树那样使用固定数组存储,而必须采用动态节点生成的指针表示法:

数组表示法(静态存储)
  • 适用于紧凑且值域较小的情况(如 [ 1 , N ] [1, N] [1,N] N ≤ 10 6 N \leq 10^6 N106
  • 类似堆式存储,节点 i i i 的左儿子是 2 i 2i 2i,右儿子是 2 i + 1 2i+1 2i+1
  • 缺点:如果值域很大(如 [ 1 , 10 9 ] [1, 10^9] [1,109]),空间爆炸
指针表示法(动态开点)
  • 仅在实际插入数值时才创建对应的节点
  • 每个节点存储:
    • 值域区间 [ l , r ] [l, r] [l,r]
    • 统计信息(如出现次数 c n t cnt cnt,区间和 s u m sum sum
    • 左儿子 left 和右儿子 right 指针
  • 优点:节省空间,仅 O ( M log ⁡ N ) O(M \log N) O(MlogN),其中 M M M 是操作次数, N N N 是值域范围

关键点

  • 只有被访问过的区间才会生成节点
  • 父节点的信息由其子节点汇总(如 cnt = left->cnt + right->cnt

image

权值线段树的实现细节

节点结构设计

我们采用面向对象的方式定义权值线段树的节点结构:


   
struct Node {
int l, r; // 值域区间[l,r]
int cnt; // 该值域内数字出现次数
int sum; // 该值域内数字的和(可选)
Node *left, *right; // 左右子节点指针
Node(int _l, int _r) : l(_l), r(_r), cnt(0), sum(0), left(nullptr), right(nullptr) {}
};

核心操作:插入数值

每次插入一个数x时,我们从根节点开始递归更新线段树:

  1. 如果当前节点的值域区间[l,r]不包含x,直接返回
  2. 如果当前节点是叶子节点(l==r),更新统计信息
  3. 否则递归处理左右子树,并动态创建不存在的子节点

   
void insert(Node* root, int x) {
if (x < root->l || x > root->r) return;
if (root->l == root->r) {
root->cnt++;
root->sum += x;
return;
}
int mid = root->l + (root->r - root->l) / 2;
if (x <= mid) {
if (!root->left) root->left = new Node(root->l, mid);
insert(root->left, x);
} else {
if (!root->right) root->right = new Node(mid + 1, root->r);
insert(root->right, x);
}
// 更新父节点统计信息
root->cnt = (root->left ? root->left->cnt : 0)
+ (root->right ? root->right->cnt : 0);
root->sum = (root->left ? root->left->sum : 0)
+ (root->right ? root->right->sum : 0);
}

查询值域统计

查询值域 [ L , R ] [L,R] [L,R]内的数字个数(类似地可以查询和、最大值等):


   
int query(Node* root, int L, int R) {
if (!root || R < root->l || L > root->r) return 0;
if (L <= root->l && root->r <= R) return root->cnt;
return query(root->left, L, R) + query(root->right, L, R);
}

image

查询第K小的数

利用权值线段树可以高效查询第K小的数:


   
int kth(Node* root, int k) {
if (root->l == root->r) return root->l;
int left_cnt = root->left ? root->left->cnt : 0;
if (k <= left_cnt) return kth(root->left, k);
return kth(root->right, k - left_cnt);
}

内存管理

由于采用动态开点,需要手动管理内存以避免泄漏:


   
void clear(Node* root) {
if (!root) return;
clear(root->left);
clear(root->right);
delete root;
}

复杂度分析

时间复杂度分析

权值线段树的所有核心操作都遵循二叉树搜索模式,其时间复杂度主要取决于树的高度。设值域范围为 [ 1 , N ] [1, N] [1,N]

  • 插入操作:每次插入需要从根节点递归到叶子节点,时间复杂度为 O ( log ⁡ N ) O(\log N) O(logN)
  • 查询操作
    • 区间统计查询: O ( log ⁡ N ) O(\log N) O(logN)
    • 第K小查询: O ( log ⁡ N ) O(\log N) O(logN)
  • 删除操作(实现类似插入): O ( log ⁡ N ) O(\log N) O(logN)

值得注意的是,这里的 N N N 是值域范围,而非元素个数。当值域极大时(如 [ 1 , 10 18 ] [1,10^{18}] [1,1018]),可以通过离散化预处理将 N N N降至元素数量级。

空间复杂度分析

权值线段树的空间消耗主要来自动态创建的节点:

  • 最坏情况:每个操作都访问全新的路径,需要创建 O ( log ⁡ N ) O(\log N) O(logN)个新节点
  • M次操作的空间消耗 O ( M log ⁡ N ) O(M \log N) O(MlogN)
  • 优化技巧
    • 惰性删除:标记删除而非立即回收节点
    • 内存池预分配:减少动态内存分配开销

下表对比了不同情况下的空间使用:

场景空间复杂度说明
静态数组实现 O ( N ) O(N) O(N)值域较大时不可行
动态开点(最坏) O ( M log ⁡ N ) O(M \log N) O(MlogN)每次操作都访问新路径
动态开点(平均) O ( K log ⁡ N ) O(K \log N) O(KlogN)K为一个小于 M 的数

与离散化的配合使用

对于极大值域但数据较为稀疏,若允许离线,可以先进行离散化处理:


   
vector<int> nums = {5, 3, 7, 1e9}; // 原始数据
sort(nums.begin(), nums.end());
nums.erase(unique(nums.begin(), nums.end()), nums.end());
// 建立值到排名的映射
unordered_map<int, int> val2rank;
for (int i = 0; i < nums.size(); ++i) {
val2rank[nums[i]] = i + 1; // 排名从1开始
}
// 此时权值线段树的值域可设为[1, nums.size()]
WeightSegmentTree tree(1, nums.size());

离散化后的优势:

  1. 值域 N N N从原始范围降至实际数据规模,此时可以改用区间线段树的四倍数组写法了。
  2. 保持数值间的大小关系不变
  3. 查询时需要先将查询边界值离散化

权值线段树 vs 平衡树

权值线段树和平衡树(如AVL树、红黑树)都可以解决以下常见问题:

  • 动态插入/删除数值
  • 查询第K小的数
  • 统计值域区间内的元素个数
  • 查询前驱/后继

但它们在实现方式和扩展性上存在本质差异:

功能实现方式对比

功能权值线段树实现方式平衡树实现方式
插入/删除递归更新值域区间统计量通过旋转操作维持平衡
第K小查询利用子树节点数二分搜索中序遍历或维护子树大小
前驱/后继查询转化为值域边界查询直接查找相邻节点
区间统计天然支持区间求和/最值需要额外维护统计信息

优先选择权值线段树当:

  • 需要频繁查询第K小或区间统计
  • 值域范围可离散化处理
  • 需要可持久化或高维扩展
  • 数据离线处理(预先知道值域)

优先选择平衡树当:

  • 需要频繁插入/删除动态数据
  • 需要有序遍历或范围迭代
  • 处理非数值型数据或自定义比较
  • 内存限制严格
原创作者: ofnoname 转载于: https://www.cnblogs.com/ofnoname/p/18823844
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值