CC++ & Algorithm

权值线段树与动态开点

极难2
语言版本:通用
概述:学习以值域为建树区间的线段树,用于统计每个数值出现的次数,并通过动态开点节省空间。

权值线段树与动态开点:用“值域桶”快速统计和查找第k小

想象一下,期末考试后老师想知道全班成绩的分布情况。成绩范围是0到100分。老师可以画一个表格,每个分数对应一个“桶”,统计有多少人得这个分数。然后老师可以快速回答:“成绩在60~80分之间有多少人?”或者“第5小的成绩是多少?”如果你把每个分数当作一个“位置”,建立一个“值域”上的线段树,就是权值线段树

普通线段树是根据数组下标(位置)来建树的,而权值线段树是根据数值大小来建树的。叶子节点代表一个具体的数值,存储这个数值出现的次数。内部节点存储区间内所有数值的出现次数之和。这样,我们可以快速查询某个数值范围内的总出现次数,或者查询第k小的数。

权值线段树常用于:

  • 统计某分数段内的人数(如60~80分有多少人)
  • 查找排名第k的成绩(比如班级第5名考了多少分)
  • 在线游戏里求玩家战力排行榜中第k名的战力值
  • 处理大数据流中的中位数、众数等

下面我们就从零开始,一步步理解它的原理和代码实现。


1. 什么是权值线段树?

1.1 核心思想

我们把所有可能的数值(比如0~100分)当作一个值域区间,每个数值对应一个“桶”。然后在这个值域上建立一棵线段树:

  • 每个节点对应一个值域区间 [l, r]
  • 叶子节点对应单个数值,存储该数值出现的次数(count)。
  • 内部节点存储它管辖的区间内所有数值的出现次数之和。

例如,插入成绩 85 后,树中所有包含85的节点的count都会加1。当我们想统计“成绩在60~80分之间的总人数”时,只需要在树上查询区间[60,80]的和即可。

1.2 与普通线段树的区别

对比项普通线段树权值线段树
建树依据数组下标(位置)数值大小(值域)
叶子节点含义数组元素的值某个数值出现的次数
典型操作区间求和、区间修改查询值域内数的个数、查找第k小的数
适用场景下标范围固定、数据连续值域可能很大但实际数据稀疏

1.3 生活中的类比

假设全班有50位同学,成绩范围为0~100分。你有一张成绩单,想快速知道“排名第3的成绩是多少分”。用权值线段树的做法是:先把所有成绩插入树中(每个成绩出现次数+1),然后从根节点开始,根据左子树的总人数判断第3小的数在左还是右子树,一路向下,直到找到叶子节点。这个过程就像在图书馆里按图书编号找书——你根据每一层书架上的藏书量决定往左还是往右找。


2. 静态权值线段树(数组开满)

当值域不大时(比如0100、01e5),我们可以直接用数组开满所有节点,就像普通线段树一样。下面先讲这种简单版本,帮你理解基本原理。

2.1 数据结构与插入操作

  • 开辟数组 tree[],大小 4 * (max_val + 1)
  • 插入一个值 x:从根节点开始,递归到叶子节点,沿途所有节点的count加1。
  • 查询第k小的数:比较左子树的count,决定往左还是往右。

2.2 常见新手错误

  1. 忘记更新父节点:插入后必须用 tree[p] = tree[left] + tree[right] 更新当前节点。
  2. 数组开太小:静态线段树需要4倍空间,值域为 [0, max_val] 时数组大小至少 4*(max_val+1),否则越界。
  3. 递归层次过深:当值域很大时(如1e9),静态开数组会内存爆炸,此时必须用动态开点。

2.3 完整代码示例

C++:静态权值线段树(插入+查询第k小)

#include <iostream>
using namespace std;

const int MAX_VAL = 100; // 值域0~100
int tree[4 * (MAX_VAL + 1)]; // 静态数组,存储每个节点对应区间内数值的总出现次数

// 插入一个值x(出现次数+1)
// p: 当前节点编号, l,r: 当前节点覆盖的值域区间
void insert(int p, int l, int r, int x) {
    if (l == r) {
        tree[p]++; // 叶子节点,次数加1
        return;
    }
    int mid = (l + r) / 2;
    if (x <= mid) {
        insert(p * 2, l, mid, x); // 左子树
    } else {
        insert(p * 2 + 1, mid + 1, r, x); // 右子树
    }
    tree[p] = tree[p * 2] + tree[p * 2 + 1]; // 更新当前节点
}

// 查询第k小的数(k从1开始计数)
// 返回该数值
int query_kth(int p, int l, int r, int k) {
    if (l == r) {
        return l; // 叶子节点就是答案
    }
    int mid = (l + r) / 2;
    if (tree[p * 2] >= k) {
        return query_kth(p * 2, l, mid, k); // 第k小在左子树
    } else {
        return query_kth(p * 2 + 1, mid + 1, r, k - tree[p * 2]); // 右边,减去左边个数
    }
}

int main() {
    int arr[] = {5,3,7,3,9,5,2}; // 待插入数据(成绩)
    int n = sizeof(arr) / sizeof(arr[0]);

    // 依次插入所有成绩
    for (int i = 0; i < n; i++) {
        insert(1, 0, MAX_VAL, arr[i]);
    }

    // 查询第1小、第2小、第4小的数
    cout << "第1小的数: " << query_kth(1, 0, MAX_VAL, 1) << endl; // 2
    cout << "第2小的数: " << query_kth(1, 0, MAX_VAL, 2) << endl; // 3
    cout << "第4小的数: " << query_kth(1, 0, MAX_VAL, 4) << endl; // 5
    return 0;
}

输出:

第1小的数: 2
第2小的数: 3
第4小的数: 5

Python:静态权值线段树(使用列表)

class WeightSegmentTree:
    def __init__(self, max_val):
        self.max_val = max_val
        # 树数组,大小4*(max_val+1) 足够
        self.tree = [0] * (4 * (max_val + 1))

    def insert(self, p, l, r, x):
        """插入数值x(出现次数加1)"""
        if l == r:
            self.tree[p] += 1  # 叶子节点,次数加1
            return
        mid = (l + r) // 2
        if x <= mid:
            self.insert(p * 2, l, mid, x)  # 左子树
        else:
            self.insert(p * 2 + 1, mid + 1, r, x)  # 右子树
        self.tree[p] = self.tree[p * 2] + self.tree[p * 2 + 1]  # 更新父节点

    def query_kth(self, p, l, r, k):
        """查询第k小的数(k从1开始)"""
        if l == r:
            return l
        mid = (l + r) // 2
        left_cnt = self.tree[p * 2]  # 左子树的总出现次数
        if left_cnt >= k:
            return self.query_kth(p * 2, l, mid, k)
        else:
            return self.query_kth(p * 2 + 1, mid + 1, r, k - left_cnt)

if __name__ == "__main__":
    arr = [5, 3, 7, 3, 9, 5, 2]
    max_val = 100  # 成绩范围0~100
    st = WeightSegmentTree(max_val)
    for x in arr:
        st.insert(1, 0, max_val, x)

    print("第1小的数:", st.query_kth(1, 0, max_val, 1))  # 2
    print("第2小的数:", st.query_kth(1, 0, max_val, 2))  # 3
    print("第4小的数:", st.query_kth(1, 0, max_val, 4))  # 5

输出与C++相同。


3. 动态开点权值线段树

当值域很大时(比如0~1e9),直接用数组开满会占用数百MB甚至GB内存,而实际插入的数值个数(n)可能只有几万或几百万。这时我们采用动态开点:只在需要时才创建节点。

3.1 动态开点的原理

  • 初始时,整棵树只有一个根节点(编号1)。
  • 插入数值x时,如果某个子节点不存在,就创建一个新节点,并为其分配编号(或指针)。
  • 每个节点用结构体(或类)存储:左右孩子索引(或引用)和当前节点的count。
  • 总节点数大约为 n * log N(n为插入次数,N为值域大小),远小于 4N

3.2 常见新手错误

  1. 节点数组开太小:动态开点虽然节省空间,但节点数仍可能达到 n * logN,最多约 n * 30(当值域为1e9时),要预留足够空间。
  2. 忘记传递引用/指针:插入时如果子节点不存在,需要创建新节点并修改父节点的孩子指针,必须使用引用或指针的指针。
  3. 未处理空节点:在查询第k小时,如果左子树不存在(即左孩子索引为0),则左子树的count为0,不能直接访问 node[left].cnt,需要先判断。
  4. 递归深度过大:当值域很大时,递归深度约 logN(最多30层),一般不会爆栈,但如果n极大且值域极小,深度也可能危险。

3.3 完整代码示例

C++:动态开点(使用结构体数组)

#include <iostream>
using namespace std;

struct Node {
    int lc, rc; // 左右孩子下标,0表示不存在
    int cnt;    // 当前节点区间内数值的总出现次数
} node[1000000]; // 预留足够大,一般n * 30
int root = 1, tot = 1; // root: 根节点编号, tot: 当前已用节点数

// 插入数值x(出现次数+1),p为当前节点引用,用于可能创建新节点
void insert(int &p, int l, int r, int x) {
    if (p == 0) {
        p = ++tot; // 动态创建新节点,分配编号
        node[p].lc = node[p].rc = 0; // 初始化孩子为空
        node[p].cnt = 0;
    }
    if (l == r) {
        node[p].cnt++; // 叶子节点,次数加1
        return;
    }
    int mid = (l + r) / 2;
    if (x <= mid) {
        insert(node[p].lc, l, mid, x); // 递归左子树
    } else {
        insert(node[p].rc, mid + 1, r, x); // 递归右子树
    }
    // 更新当前节点:左右子树的cnt之和,注意孩子可能不存在
    node[p].cnt = (node[node[p].lc].cnt) + (node[node[p].rc].cnt);
}

// 查询第k小的数(k从1开始)
int query_kth(int p, int l, int r, int k) {
    if (l == r) {
        return l; // 叶子节点即为答案
    }
    int mid = (l + r) / 2;
    int left_cnt = node[node[p].lc].cnt; // 左子树的出现次数,若左孩子不存在则为0
    if (left_cnt >= k) {
        return query_kth(node[p].lc, l, mid, k);
    } else {
        return query_kth(node[p].rc, mid + 1, r, k - left_cnt);
    }
}

int main() {
    int arr[] = {100, 3, 1000, 5, 7, 3};
    int n = sizeof(arr) / sizeof(arr[0]);

    // 插入所有数值,值域设为 0 ~ 1e9
    for (int i = 0; i < n; i++) {
        insert(root, 0, 1000000000, arr[i]);
    }

    cout << "第1小的数: " << query_kth(root, 0, 1000000000, 1) << endl; // 3
    cout << "第2小的数: " << query_kth(root, 0, 1000000000, 2) << endl; // 3
    cout << "第3小的数: " << query_kth(root, 0, 1000000000, 3) << endl; // 5
    return 0;
}

输出:

第1小的数: 3
第2小的数: 3
第3小的数: 5

Python:动态开点(使用类+对象引用)

class Node:
    __slots__ = ('lc', 'rc', 'cnt')  # 节省内存
    def __init__(self):
        self.lc = None  # 左孩子(Node对象或None)
        self.rc = None  # 右孩子
        self.cnt = 0    # 出现次数

class DynamicWeightSegmentTree:
    def __init__(self, left, right):
        self.root = Node()  # 根节点
        self.L = left       # 值域左边界
        self.R = right      # 值域右边界

    # 内部插入函数,返回更新后的节点
    def _insert(self, p, l, r, x):
        if p is None:
            p = Node()  # 动态创建新节点
        if l == r:
            p.cnt += 1
            return p
        mid = (l + r) // 2
        if x <= mid:
            p.lc = self._insert(p.lc, l, mid, x)
        else:
            p.rc = self._insert(p.rc, mid + 1, r, x)
        # 更新当前节点次数(注意处理None)
        left_cnt = p.lc.cnt if p.lc else 0
        right_cnt = p.rc.cnt if p.rc else 0
        p.cnt = left_cnt + right_cnt
        return p

    # 对外插入接口
    def insert_value(self, x):
        self.root = self._insert(self.root, self.L, self.R, x)

    # 内部查询第k小函数
    def _query_kth(self, p, l, r, k):
        if l == r:
            return l
        mid = (l + r) // 2
        left_cnt = p.lc.cnt if p.lc else 0
        if left_cnt >= k:
            return self._query_kth(p.lc, l, mid, k)
        else:
            return self._query_kth(p.rc, mid + 1, r, k - left_cnt)

    # 对外查询接口
    def kth(self, k):
        return self._query_kth(self.root, self.L, self.R, k)

if __name__ == "__main__":
    arr = [100, 3, 1000, 5, 7, 3]
    st = DynamicWeightSegmentTree(0, 1000000000)  # 值域0~10^9
    for x in arr:
        st.insert_value(x)

    print("第1小的数:", st.kth(1))  # 3
    print("第2小的数:", st.kth(2))  # 3
    print("第3小的数:", st.kth(3))  # 5

输出同上。


4. 权值线段树的其他应用

除了查询第k小,权值线段树还可以:

  • 查询某个数值的出现次数:直接走到叶子节点,返回count。
  • 查询某值域内的总数:区间求和操作(类似普通线段树)。
  • 查询小于x的数的个数:区间 [min_val, x-1] 的和。
  • 查询大于x的数的个数:区间 [x+1, max_val] 的和。
  • 配合离散化:当值域极大但数值种类有限时,可以先离散化,然后用静态权值线段树。

动态开点还可以扩展到可持久化线段树(主席树),用于处理历史版本查询、在线段树上二分等高级问题。


5. 总结与相关指引

  • 权值线段树 以数值为建树区间,统计每个值的出现次数。
  • 支持快速查询某个值域内数的个数,以及查找第k小的数(经典应用)。
  • 当值域很大时,采用动态开点,只在需要时创建节点,大幅节省内存。
  • 动态开点使得权值线段树可以处理值域范围大但实际数据稀疏的情况。
  • 权值线段树是许多高级数据结构(如可持久化线段树、树套树、平衡树替代品)的基础。

如果你已经掌握了单点修改和区间查询的普通线段树,权值线段树只是把建树依据从“下标”换成了“数值”。再结合动态开点,你就能处理各种大数据场景下的排名、频率统计问题了。

下一步推荐学习

  • 可持久化线段树(主席树):基于动态开点,能查询历史版本的第k小。
  • 树状数组 + 权值线段树:用于离线处理动态第k小问题。
  • 平衡树:如Treap、Splay,也能实现类似功能,但线段树更稳定。

通过这篇文章,你已经从零到一掌握了权值线段树的核心思想、静态与动态实现、常见错误和典型应用。继续实践,试着用它解决一些在线排名统计的问题吧!

例题精讲

1单选题

关于权值线段树,下列说法中错误的是?

A权值线段树的建树区间通常是值域范围
B权值线段树可以用于求整个数组的第k小元素
C权值线段树可以用于求任意区间[l, r]的最大值
D权值线段树可以用于求逆序对的数量
2判断题

在使用权值线段树时,如果值域范围很大(如1e9),但实际插入元素数量较少(如1e5),采用动态开点技术可以将空间复杂度从O(值域)降低到O(n log 值域)。

3填空题
以下是用动态开点实现权值线段树插入操作的代码片段,请在空格处填写正确代码,创建一个新节点并返回索引。
struct Node {
    int l, r, sum;
} tree[N];
int root, cnt;
void update(int &p, int l, int r, int x, int val) {
    if (!p) p = ___; // 创建新节点
    if (l == r) {
        tree[p].sum += val;
        return;
    }
    int mid = (l + r) >> 1;
    if (x <= mid) update(tree[p].l, l, mid, x, val);
    else update(tree[p].r, mid+1, r, x, val);
    tree[p].sum = tree[tree[p].l].sum + tree[tree[p].r].sum;
}
4填空题
以下是用动态开点权值线段树查询第k小的函数,请在空格处填写正确代码。
int query_kth(int p, int l, int r, int k) {
    if (l == r) return l;
    int mid = (l + r) >> 1;
    int left_sum = tree[tree[p].l].sum;
    if (k <= left_sum) return query_kth(tree[p].l, l, mid, k);
    else return query_kth(tree[p].r, mid+1, r, ___);
}
5单选题

对于动态开点权值线段树,以下说法正确的是?

A动态开点可以避免在建树时预先分配所有节点,但需要提前知道值域范围
B动态开点权值线段树的叶子节点个数等于实际插入的元素个数
C动态开点权值线段树中,每个节点都会在第一次访问时创建,因此一次查询操作可能创建O(log值域)个新节点
D动态开点权值线段树在插入操作中,如果节点已存在,则不会创建新节点