本篇对应源码见仓库
solutions/xcpc/algorithm/ds/segment-tree/。
线段树(Segment Tree)详解
什么是线段树?
线段树,顾名思义,就是一棵“树”,它用来处理“线段”上的问题。更准确地说,它是一种数据结构,用于高效地处理区间查询和区间修改。
想象一下你有一个数组 A,你想知道某个区间 [L, R] 内元素的和,或者把某个区间 [L, R] 内的每个元素都加上一个值 X。如果只是简单地遍历数组,查询和修改都需要 O(N) 的时间复杂度。当 N 很大,操作次数很多时,就会非常慢。线段树就是为了解决这个问题而生的。
线段树的核心思想:分治
线段树的核心思想是分治(Divide and Conquer)。它把一个大区间分成两个小区间,再把小区间继续分成更小的区间,直到每个区间都只包含一个元素。这样,整个数组就被组织成了一棵二叉树的结构。
每个节点都代表一个区间。
- 根节点代表整个大区间(例如
[0, N-1])。 - 左孩子代表其父节点区间的左半部分。
- 右孩子代表其父节点区间的右半部分。
- 叶子节点代表长度为1的区间(即数组中的一个元素)。
线段树的结构
我们用一个数组来存储线段树。如果原始数组有 N 个元素,那么线段树通常需要 2N 到 4N 的空间(因为它是完全二叉树,最后一层叶子节点可能不完全填满,为了方便计算父子关系,我们通常分配更多空间)。
假设原始数组 A 的索引范围是 [0, N-1]。
- 根节点的索引可以是1(方便计算左右子节点)。
- 如果一个节点的索引是
p,那么它的左孩子是2p,右孩子是2p+1。
每个节点需要存储它所代表区间的某种信息,比如:
- 区间和
- 区间最大值
- 区间最小值
- 其他需要维护的信息,取决于具体问题
线段树的构建(Build)
构建线段树的过程是一个递归的过程。
build(node_idx, start, end):
- 基准情况(叶子节点):如果
start == end,说明当前节点代表一个单一元素。将tree[node_idx]设置为A[start](或者A[end])。 - 递归构建子树:
- 计算
mid = (start + end) / 2。 - 递归构建左子树:
build(2 * node_idx, start, mid)。 - 递归构建右子树:
build(2 * node_idx + 1, mid + 1, end)。
- 计算
- 合并信息:
tree[node_idx]的值由其左右子节点的值合并而来。例如,如果是求区间和,tree[node_idx] = tree[2 * node_idx] + tree[2 * node_idx + 1]。
时间复杂度:O(N)。因为每个节点只被访问一次,并且进行常数次操作。
线段树的查询(Query)
查询一个区间 [query_start, query_end] 的信息。
query(node_idx, current_start, current_end, query_start, query_end):
- 完全包含:如果当前节点代表的区间
[current_start, current_end]完全包含在查询区间[query_start, query_end]内(即query_start <= current_start且current_end <= query_end),那么直接返回tree[node_idx]的值。 - 完全不相交:如果当前节点代表的区间
[current_start, current_end]和查询区间[query_start, query_end]完全不相交,返回一个“空值”(例如,求和时返回0,求最大值时返回负无穷)。 - 部分相交:如果部分相交,就需要递归地查询左右子树,并将结果合并。
- 计算
mid = (current_start + current_end) / 2。 - 递归查询左子树:
left_result = query(2 * node_idx, current_start, mid, query_start, query_end)。 - 递归查询右子树:
right_result = query(2 * node_idx + 1, mid + 1, current_end, query_start, query_end)。 - 将
left_result和right_result合并后返回。
- 计算
时间复杂度:O(logN)。因为每次递归都会将区间减半,最多访问 logN 层。在每一层,最多访问常数个节点(通常是2个)。
线段树的修改(Update)
修改一个区间 [update_start, update_end] 的信息(例如,将某个区间的每个元素都加上一个值)。
单点修改(Point Update): 修改数组中 A[idx] 的值。
update(node_idx, current_start, current_end, update_idx, new_value):
- 基准情况(找到叶子节点):如果
current_start == current_end,并且current_start == update_idx,说明找到了要修改的叶子节点。更新tree[node_idx] = new_value。 - 递归修改子树:
- 计算
mid = (current_start + current_end) / 2。 - 如果
update_idx <= mid,说明修改的元素在左子树,递归update(2 * node_idx, current_start, mid, update_idx, new_value)。 - 否则,说明修改的元素在右子树,递归
update(2 * node_idx + 1, mid + 1, current_end, update_idx, new_value)。
- 计算
- 更新当前节点信息:修改完子节点后,需要更新当前节点的信息,例如
tree[node_idx] = tree[2 * node_idx] + tree[2 * node_idx + 1]。
时间复杂度:O(logN)。同样是每次递归减半,最多访问 logN 层。
区间修改(Range Update): 将区间 [update_start, update_end] 内的每个元素都加上一个值 delta。
区间修改比单点修改复杂一些,因为如果直接修改所有叶子节点,时间复杂度又回到 O(N)。为了保持 O(logN) 的复杂度,我们需要引入一个叫做**懒惰标记(Lazy Propagation)**的技术。
懒惰标记(Lazy Propagation)
当需要对一个大区间进行修改时,如果当前节点代表的区间完全包含在修改区间内,我们就不再继续向下递归修改其所有子节点,而是在这个节点上打一个“标记”(lazy tag),表示它下面的子节点需要被修改,但是我们暂时不修改它们。这个标记会累加,当之后有查询或者其他修改操作涉及到这个节点的子节点时,我们再把这个标记“下推”到子节点。
每个节点除了存储维护的信息(比如区间和),还需要额外存储一个 lazy 标记。
update(node_idx, current_start, current_end, update_start, update_end, delta):
- 下推懒惰标记:在进行任何操作之前,如果当前节点有懒惰标记,先将其下推到子节点。
push_down(node_idx, current_start, current_end):- 如果
lazy[node_idx]不为0:- 更新左右子节点的
tree值和lazy值。 - 例如:
tree[2*node_idx] += lazy[node_idx] * (mid - current_start + 1) lazy[2*node_idx] += lazy[node_idx]tree[2*node_idx+1] += lazy[node_idx] * (current_end - mid)lazy[2*node_idx+1] += lazy[node_idx]- 清除当前节点的
lazy[node_idx] = 0。
- 更新左右子节点的
- 如果
- 完全包含:如果当前节点代表的区间完全包含在修改区间内,直接更新
tree[node_idx]的值,并给lazy[node_idx]加上delta。然后返回。 - 完全不相交:如果完全不相交,直接返回。
- 部分相交:
- 计算
mid = (current_start + current_end) / 2。 - 递归修改左子树。
- 递归修改右子树。
- 更新当前节点信息(合并左右子节点)。
- 计算
query(node_idx, current_start, current_end, query_start, query_end):
- 下推懒惰标记:在进行任何操作之前,如果当前节点有懒惰标记,先将其下推到子节点。
- 完全包含:如果当前节点代表的区间完全包含在查询区间内,直接返回
tree[node_idx]的值。 - 完全不相交:如果完全不相交,返回一个“空值”。
- 部分相交:
- 递归查询左子树。
- 递归查询右子树。
- 合并结果返回。
时间复杂度:O(logN)。懒惰标记保证了每个节点最多被访问常数次。
C++ 实现示例(以区间求和为例)
C++
#include <iostream>
#include <vector>
const int MAXN = 100005; // 假设数组最大长度
long long arr[MAXN]; // 原始数组
long long tree[MAXN * 4]; // 线段树存储区间和
long long lazy[MAXN * 4]; // 懒惰标记,存储需要累加的值
// 构建线段树
// node_idx: 当前节点的索引
// current_start, current_end: 当前节点代表的区间范围
void build(int node_idx, int current_start, int current_end) {
lazy[node_idx] = 0; // 初始化懒惰标记
if (current_start == current_end) {
tree[node_idx] = arr[current_start];
} else {
int mid = (current_start + current_end) / 2;
build(2 * node_idx, current_start, mid);
build(2 * node_idx + 1, mid + 1, current_end);
tree[node_idx] = tree[2 * node_idx] + tree[2 * node_idx + 1];
}
}
// 下推懒惰标记
// node_idx: 当前节点的索引
// current_start, current_end: 当前节点代表的区间范围
void push_down(int node_idx, int current_start, int current_end) {
if (lazy[node_idx] != 0 && current_start != current_end) { // 如果有懒惰标记且不是叶子节点
int mid = (current_start + current_end) / 2;
// 更新左子节点
tree[2 * node_idx] += lazy[node_idx] * (mid - current_start + 1);
lazy[2 * node_idx] += lazy[node_idx];
// 更新右子节点
tree[2 * node_idx + 1] += lazy[node_idx] * (current_end - mid);
lazy[2 * node_idx + 1] += lazy[node_idx];
lazy[node_idx] = 0; // 清除当前节点的懒惰标记
}
}
// 区间更新(给区间 [update_start, update_end] 加上 delta)
// node_idx: 当前节点的索引
// current_start, current_end: 当前节点代表的区间范围
// update_start, update_end: 要更新的目标区间
// delta: 累加的值
void update_range(int node_idx, int current_start, int current_end, int update_start, int update_end, long long delta) {
// 下推懒惰标记
push_down(node_idx, current_start, current_end);
// 情况1: 当前区间完全不相交于更新区间
if (current_start > update_end || current_end < update_start) {
return;
}
// 情况2: 当前区间完全包含于更新区间
if (update_start <= current_start && current_end <= update_end) {
tree[node_idx] += delta * (current_end - current_start + 1);
lazy[node_idx] += delta; // 打上懒惰标记
return;
}
// 情况3: 部分相交,递归处理
int mid = (current_start + current_end) / 2;
update_range(2 * node_idx, current_start, mid, update_start, update_end, delta);
update_range(2 * node_idx + 1, mid + 1, current_end, update_start, update_end, delta);
// 更新当前节点的值(由子节点合并而来)
tree[node_idx] = tree[2 * node_idx] + tree[2 * node_idx + 1];
}
// 区间查询(查询区间 [query_start, query_end] 的和)
// node_idx: 当前节点的索引
// current_start, current_end: 当前节点代表的区间范围
// query_start, query_end: 要查询的目标区间
long long query_range(int node_idx, int current_start, int current_end, int query_start, int query_end) {
// 下推懒惰标记
push_down(node_idx, current_start, current_end);
// 情况1: 当前区间完全不相交于查询区间
if (current_start > query_end || current_end < query_start) {
return 0; // 返回0,因为是求和
}
// 情况2: 当前区间完全包含于查询区间
if (query_start <= current_start && current_end <= query_end) {
return tree[node_idx];
}
// 情况3: 部分相交,递归处理
int mid = (current_start + current_end) / 2;
long long p1 = query_range(2 * node_idx, current_start, mid, query_start, query_end);
long long p2 = query_range(2 * node_idx + 1, mid + 1, current_end, query_start, query_end);
return p1 + p2;
}
int main() {
int n; // 原始数组长度
std::cout << "请输入数组长度 N: ";
std::cin >> n;
std::cout << "请输入 " << n << " 个元素: ";
for (int i = 0; i < n; ++i) {
std::cin >> arr[i];
}
build(1, 0, n - 1); // 从根节点1开始构建,区间为 [0, n-1]
std::cout << "初始数组构建完成。" << std::endl;
// 示例操作
std::cout << "查询区间 [0, " << n - 1 << "] 的和: " << query_range(1, 0, n - 1, 0, n - 1) << std::endl;
int l, r;
long long delta;
std::cout << "请输入要更新的区间 [L, R] (0-based) 和增量 delta: ";
std::cin >> l >> r >> delta;
update_range(1, 0, n - 1, l, r, delta);
std::cout << "区间 [" << l << ", " << r << "] 已增加 " << delta << std::endl;
std::cout << "更新后查询区间 [0, " << n - 1 << "] 的和: " << query_range(1, 0, n - 1, 0, n - 1) << std::endl;
std::cout << "请输入要查询的区间 [L, R] (0-based): ";
std::cin >> l >> r;
std::cout << "区间 [" << l << ", " << r << "] 的和: " << query_range(1, 0, n - 1, l, r) << std::endl;
return 0;
}
总结与技巧
- 分治思想:理解线段树的核心是分治,每个节点负责一个区间,通过左右子节点合并信息。
- 数组表示:用数组存储线段树,根节点通常从1开始,方便
2p和2p+1的计算。空间复杂度4N是一个安全的上界。 - 递归实现:构建、查询、修改都是递归实现,注意递归的基准情况和如何合并子节点信息。
- 懒惰标记:理解懒惰标记的原理,它在区间修改时避免了对所有叶子节点的遍历,从而将时间复杂度降到
O(logN)。记住push_down的时机:在访问子节点之前,如果当前节点有懒惰标记,就需要下推。 - 问题转化:线段树可以解决很多区间问题,例如:
- 区间和、最大值、最小值
- 区间异或和、区间GCD
- 区间覆盖、区间赋值(需要更复杂的懒惰标记)
- 配合离散化解决坐标范围很大的问题
- 调试:线段树的调试可能会比较困难,尤其是懒惰标记。建议画图理解递归过程和标记下推过程。
- 熟悉模板:一旦理解了基本原理,就可以尝试记住一个线段树的通用模板,然后根据具体问题修改节点存储的信息和合并/更新逻辑。
给你一些练习题(来自洛谷)
- P3374 【模板】线段树 1 (单点修改,区间查询和) - 这是最基础的线段树,不需要懒惰标记。
- P3372 【模板】线段树 2 (区间修改,区间查询和) - 包含懒惰标记的经典问题。
- P3373 【模板】线段树 3 (区间加法、区间乘法、区间查询和) - 更复杂的懒惰标记,需要处理两种优先级不同的操作。
从最简单的模板开始,一步一步理解,多画图,多调试。你会发现线段树虽然写起来代码量稍微大一些,但它的逻辑是相当清晰和优雅的。
加油,acmer!你一定能掌握线段树的!如果你在学习过程中遇到任何具体问题,随时可以再问我。
例题
例题一:【模板】线段树 1 - 单点修改,区间查询和 (洛谷 P3374)
这是线段树最基础的应用,没有懒惰标记。
题目描述:
给定 N 个数,支持两种操作:
1 x k:将第 x 个数改为 k。2 x y:查询区间 [x,y] 的和。
思路分析:
- 数据结构:线段树的每个节点存储其代表区间的和。
- 构建 (
build):- 叶子节点:
tree[node_idx] = arr[index]。 - 非叶子节点:
tree[node_idx] = tree[2*node_idx] + tree[2*node_idx+1]。
- 叶子节点:
- 单点修改 (
update):- 找到对应的叶子节点(即
current_start == current_end == update_idx)。 - 更新叶子节点的值。
- 沿着递归路径回溯,更新所有祖先节点的值(因为子节点改变了,父节点的和也会变)。
- 找到对应的叶子节点(即
- 区间查询 (
query):- 如果当前区间完全包含在查询区间内,直接返回当前节点的值。
- 如果当前区间与查询区间不相交,返回 0。
- 如果部分相交,递归查询左右子树,并将结果相加。
代码框架(与之前的构建和查询部分基本一致,只是修改部分是单点修改):
C++
#include <iostream>
#include <vector>
const int MAXN = 100005;
long long arr[MAXN]; // 原始数组
long long tree[MAXN * 4]; // 线段树存储区间和
// 构建线段树
void build(int node_idx, int current_start, int current_end) {
if (current_start == current_end) {
tree[node_idx] = arr[current_start];
} else {
int mid = (current_start + current_end) / 2;
build(2 * node_idx, current_start, mid);
build(2 * node_idx + 1, mid + 1, current_end);
tree[node_idx] = tree[2 * node_idx] + tree[2 * node_idx + 1];
}
}
// 单点修改 (将 arr[update_idx] 修改为 new_value)
void update_point(int node_idx, int current_start, int current_end, int update_idx, long long new_value) {
if (current_start == current_end) { // 找到叶子节点
tree[node_idx] = new_value;
return;
}
int mid = (current_start + current_end) / 2;
if (update_idx <= mid) { // 在左子树
update_point(2 * node_idx, current_start, mid, update_idx, new_value);
} else { // 在右子树
update_point(2 * node_idx + 1, mid + 1, current_end, update_idx, new_value);
}
// 回溯时更新父节点信息
tree[node_idx] = tree[2 * node_idx] + tree[2 * node_idx + 1];
}
// 区间查询 (查询区间 [query_start, query_end] 的和)
long long query_range(int node_idx, int current_start, int current_end, int query_start, int query_end) {
// 完全不相交
if (current_start > query_end || current_end < query_start) {
return 0;
}
// 完全包含
if (query_start <= current_start && current_end <= query_end) {
return tree[node_idx];
}
// 部分相交,递归查询
int mid = (current_start + current_end) / 2;
long long p1 = query_range(2 * node_idx, current_start, mid, query_start, query_end);
long long p2 = query_range(2 * node_idx + 1, mid + 1, current_end, query_start, query_end);
return p1 + p2;
}
int main() {
std::ios_base::sync_with_stdio(false); // 加速IO
std::cin.tie(NULL);
int n, m; // n: 数组长度, m: 操作次数
std::cin >> n >> m;
for (int i = 0; i < n; ++i) {
std::cin >> arr[i];
}
build(1, 0, n - 1); // 0-based index for original array
for (int i = 0; i < m; ++i) {
int op_type;
std::cin >> op_type;
if (op_type == 1) {
int x; // 注意题目通常是1-based,需要转换为0-based
long long k;
std::cin >> x >> k;
update_point(1, 0, n - 1, x - 1, k); // 转换为0-based
} else {
int x, y; // 注意题目通常是1-based,需要转换为0-based
std::cin >> x >> y;
std::cout << query_range(1, 0, n - 1, x - 1, y - 1) << "\n"; // 转换为0-based
}
}
return 0;
}
要点:
- 这道题很简单,关键在于理解线段树的递归结构和父子关系。
- 单点修改时,只影响一条从叶子节点到根节点的路径上的节点,所以只需要更新这些节点即可。
例题二:【模板】线段树 2 - 区间修改,区间查询和 (洛谷 P3372)
这道题引入了懒惰标记。
题目描述:
给定 N 个数,支持两种操作:
1 x y k:将区间 [x,y] 内的每个数都加上 k。2 x y:查询区间 [x,y] 的和。
思路分析:
- 数据结构:
tree[node_idx]:存储当前节点代表区间的和。lazy[node_idx]:存储当前节点待下推的累加值。
- 懒惰标记的原理:
- 当我们对一个区间
[L, R]进行区间修改时,如果某个节点u的区间[current_start, current_end]完全包含了[L, R],我们直接更新tree[u]的值,并在lazy[u]上累加修改值。我们不再继续向下递归修改其子节点。 - 这样,当之后有查询或修改操作需要访问
u的子节点时,我们必须先调用push_down函数,将u上的lazy标记下推到其子节点。
- 当我们对一个区间
push_down函数:- 判断
lazy[node_idx]是否为 0。不为 0 才需要下推。 - 更新左子节点:
tree[2*node_idx] += lazy[node_idx] * (左子节点区间长度) - 更新左子节点懒惰标记:
lazy[2*node_idx] += lazy[node_idx] - 更新右子节点(同理)。
- 清空当前节点的懒惰标记:
lazy[node_idx] = 0。 - 注意:
push_down应该在任何访问子节点(无论是查询还是修改)之前执行。
- 判断
- 区间修改 (
update_range):- 首先
push_down当前节点的懒惰标记。 - 如果当前区间完全不相交于更新区间,直接返回。
- 如果当前区间完全包含于更新区间:
- 更新
tree[node_idx]。 - 更新
lazy[node_idx]。 - 返回。
- 更新
- 如果部分相交:
- 递归调用左右子树进行修改。
- 回溯时更新
tree[node_idx](由新的子节点和合并而来)。
- 首先
- 区间查询 (
query_range):- 首先
push_down当前节点的懒惰标记。 - 如果当前区间完全不相交于查询区间,返回 0。
- 如果当前区间完全包含于查询区间,返回
tree[node_idx]。 - 如果部分相交,递归查询左右子树,并将结果相加。
- 首先
代码框架(这就是我上一个回答中给出的代码示例):
C++
#include <iostream>
#include <vector>
const int MAXN = 100005; // 假设数组最大长度
long long arr[MAXN]; // 原始数组
long long tree[MAXN * 4]; // 线段树存储区间和
long long lazy[MAXN * 4]; // 懒惰标记,存储需要累加的值
// 构建线段树
void build(int node_idx, int current_start, int current_end) {
lazy[node_idx] = 0; // 初始化懒惰标记
if (current_start == current_end) {
tree[node_idx] = arr[current_start];
} else {
int mid = (current_start + current_end) / 2;
build(2 * node_idx, current_start, mid);
build(2 * node_idx + 1, mid + 1, current_end);
tree[node_idx] = tree[2 * node_idx] + tree[2 * node_idx + 1];
}
}
// 下推懒惰标记
void push_down(int node_idx, int current_start, int current_end) {
if (lazy[node_idx] != 0 && current_start != current_end) { // 如果有懒惰标记且不是叶子节点
int mid = (current_start + current_end) / 2;
// 更新左子节点
tree[2 * node_idx] += lazy[node_idx] * (mid - current_start + 1);
lazy[2 * node_idx] += lazy[node_idx];
// 更新右子节点
tree[2 * node_idx + 1] += lazy[node_idx] * (current_end - mid);
lazy[2 * node_idx + 1] += lazy[node_idx];
lazy[node_idx] = 0; // 清除当前节点的懒惰标记
}
}
// 区间更新(给区间 [update_start, update_end] 加上 delta)
void update_range(int node_idx, int current_start, int current_end, int update_start, int update_end, long long delta) {
// 下推懒惰标记
push_down(node_idx, current_start, current_end);
// 情况1: 当前区间完全不相交于更新区间
if (current_start > update_end || current_end < update_start) {
return;
}
// 情况2: 当前区间完全包含于更新区间
if (update_start <= current_start && current_end <= update_end) {
tree[node_idx] += delta * (current_end - current_start + 1);
lazy[node_idx] += delta; // 打上懒惰标记
return;
}
// 情况3: 部分相交,递归处理
int mid = (current_start + current_end) / 2;
update_range(2 * node_idx, current_start, mid, update_start, update_end, delta);
update_range(2 * node_idx + 1, mid + 1, current_end, update_start, update_end, delta);
// 更新当前节点的值(由子节点合并而来)
tree[node_idx] = tree[2 * node_idx] + tree[2 * node_idx + 1];
}
// 区间查询(查询区间 [query_start, query_end] 的和)
long long query_range(int node_idx, int current_start, int current_end, int query_start, int query_end) {
// 下推懒惰标记
push_down(node_idx, current_start, current_end);
// 情况1: 当前区间完全不相交于查询区间
if (current_start > query_end || current_end < query_start) {
return 0; // 返回0,因为是求和
}
// 情况2: 当前区间完全包含于查询区间
if (query_start <= current_start && current_end <= query_end) {
return tree[node_idx];
}
// 情况3: 部分相交,递归处理
int mid = (current_start + current_end) / 2;
long long p1 = query_range(2 * node_idx, current_start, mid, query_start, query_end);
long long p2 = query_range(2 * node_idx + 1, mid + 1, current_end, query_start, query_end);
return p1 + p2;
}
int main() {
std::ios_base::sync_with_stdio(false);
std::cin.tie(NULL);
int n, m;
std::cin >> n >> m;
for (int i = 0; i < n; ++i) {
std::cin >> arr[i];
}
build(1, 0, n - 1); // 0-based index
for (int i = 0; i < m; ++i) {
int op_type;
std::cin >> op_type;
if (op_type == 1) {
int x, y;
long long k;
std::cin >> x >> y >> k;
update_range(1, 0, n - 1, x - 1, y - 1, k);
} else {
int x, y;
std::cin >> x >> y;
std::cout << query_range(1, 0, n - 1, x - 1, y - 1) << "\n";
}
}
return 0;
}
要点:
- 理解
push_down的必要性,它确保了在访问子节点时,子节点的值是最新的。 - 懒惰标记的值是累加的,因为可能多次对同一个区间进行修改。
- 下推时,懒惰标记的值需要乘上子区间长度,因为
lazy标记存储的是每个元素要增加的值,而不是区间总和要增加的值。 lazy[node_idx] = 0在下推后必须清除,否则会重复下推。
例题三:【模板】线段树 3 - 区间加法、区间乘法、区间查询和 (洛谷 P3373)
这道题是线段树的进阶,需要处理两种优先级不同的懒惰标记:加法和乘法。
题目描述:
给定 N 个数和 P,支持三种操作:
1 x y k:将区间 [x,y] 内的每个数都乘以 k。2 x y k:将区间 [x,y] 内的每个数都加上 k。3 x y:查询区间 [x,y] 的和,对 P 取模。
思路分析:
-
难点:同时有加法和乘法标记时,它们的顺序会影响结果。例如,
(a + b) * c不等于a * c + b。通常遵循先乘后加的原则。 -
数据结构:
tree[node_idx]:存储区间和。mul_lazy[node_idx]:乘法懒惰标记,初始为 1。add_lazy[node_idx]:加法懒惰标记,初始为 0。
-
push_down函数:- 处理顺序:先乘后加。
- 如果
mul_lazy[node_idx] != 1或add_lazy[node_idx] != 0:- 下推乘法标记:
- 更新左子节点:
tree[2*node_idx] = (tree[2*node_idx] * mul_lazy[node_idx]) % P - 更新左子节点懒惰标记:
mul_lazy[2*node_idx] = (mul_lazy[2*node_idx] * mul_lazy[node_idx]) % Padd_lazy[2*node_idx] = (add_lazy[2*node_idx] * mul_lazy[node_idx]) % P
- 右子节点同理。
- 更新左子节点:
- 下推加法标记:
- 更新左子节点:
tree[2*node_idx] = (tree[2*node_idx] + add_lazy[node_idx] * (左子节点区间长度)) % P - 更新左子节点懒惰标记:
add_lazy[2*node_idx] = (add_lazy[2*node_idx] + add_lazy[node_idx]) % P - 右子节点同理。
- 更新左子节点:
- 清除当前节点标记:
mul_lazy[node_idx] = 1; add_lazy[node_idx] = 0;
- 下推乘法标记:
-
update_multiply(区间乘法):push_down。- 如果完全包含:
tree[node_idx] = (tree[node_idx] * k) % Pmul_lazy[node_idx] = (mul_lazy[node_idx] * k) % Padd_lazy[node_idx] = (add_lazy[node_idx] * k) % P(这里的add_lazy也需要乘以k,因为以后下推时,它代表的加法值也需要被k放大)
- 否则递归,回溯时更新
tree[node_idx]。
-
update_add(区间加法):push_down。- 如果完全包含:
tree[node_idx] = (tree[node_idx] + k * (区间长度)) % Padd_lazy[node_idx] = (add_lazy[node_idx] + k) % P
- 否则递归,回溯时更新
tree[node_idx]。
-
query_range(区间查询):push_down。- 返回逻辑和之前一样。
代码框架(只展示关键部分,具体实现需要仔细):
C++
#include <iostream>
#include <vector>
const int MAXN = 100005;
long long arr[MAXN];
long long tree[MAXN * 4];
long long mul_lazy[MAXN * 4]; // 乘法懒惰标记
long long add_lazy[MAXN * 4]; // 加法懒惰标记
long long MOD; // 取模的值 P
// 辅助函数:计算区间长度
long long get_len(int current_start, int current_end) {
return current_end - current_start + 1;
}
// 构建线段树
void build(int node_idx, int current_start, int current_end) {
mul_lazy[node_idx] = 1; // 乘法标记初始化为1
add_lazy[node_idx] = 0; // 加法标记初始化为0
if (current_start == current_end) {
tree[node_idx] = arr[current_start] % MOD; // 初始化时也要取模
} else {
int mid = (current_start + current_end) / 2;
build(2 * node_idx, current_start, mid);
build(2 * node_idx + 1, mid + 1, current_end);
tree[node_idx] = (tree[2 * node_idx] + tree[2 * node_idx + 1]) % MOD;
}
}
// 下推懒惰标记
void push_down(int node_idx, int current_start, int current_end) {
if (mul_lazy[node_idx] == 1 && add_lazy[node_idx] == 0) { // 没有懒惰标记,直接返回
return;
}
// 不是叶子节点才能下推
if (current_start == current_end) {
// 叶子节点不向下推,直接更新它自己的值
// 但其实这里可以不用处理,因为叶子节点不会有子节点可以下推
// 如果是叶子节点,懒惰标记就相当于已经作用在它自己身上了
return;
}
int mid = (current_start + current_end) / 2;
long long left_child_len = get_len(current_start, mid);
long long right_child_len = get_len(mid + 1, current_end);
// 1. 下推乘法标记 (先乘)
// 更新左子节点
tree[2 * node_idx] = (tree[2 * node_idx] * mul_lazy[node_idx]) % MOD;
mul_lazy[2 * node_idx] = (mul_lazy[2 * node_idx] * mul_lazy[node_idx]) % MOD;
add_lazy[2 * node_idx] = (add_lazy[2 * node_idx] * mul_lazy[node_idx]) % MOD; // 重要的!加法标记也要乘以乘法标记
// 更新右子节点
tree[2 * node_idx + 1] = (tree[2 * node_idx + 1] * mul_lazy[node_idx]) % MOD;
mul_lazy[2 * node_idx + 1] = (mul_lazy[2 * node_idx + 1] * mul_lazy[node_idx]) % MOD;
add_lazy[2 * node_idx + 1] = (add_lazy[2 * node_idx + 1] * mul_lazy[node_idx]) % MOD;
// 2. 下推加法标记 (后加)
// 更新左子节点
tree[2 * node_idx] = (tree[2 * node_idx] + add_lazy[node_idx] * left_child_len) % MOD;
add_lazy[2 * node_idx] = (add_lazy[2 * node_idx] + add_lazy[node_idx]) % MOD;
// 更新右子节点
tree[2 * node_idx + 1] = (tree[2 * node_idx + 1] + add_lazy[node_idx] * right_child_len) % MOD;
add_lazy[2 * node_idx + 1] = (add_lazy[2 * node_idx + 1] + add_lazy[node_idx]) % MOD;
// 清除当前节点标记
mul_lazy[node_idx] = 1;
add_lazy[node_idx] = 0;
}
// 区间乘法
void update_multiply(int node_idx, int current_start, int current_end, int update_start, int update_end, long long k) {
push_down(node_idx, current_start, current_end); // 先下推
if (current_start > update_end || current_end < update_start) {
return;
}
if (update_start <= current_start && current_end <= update_end) {
tree[node_idx] = (tree[node_idx] * k) % MOD;
mul_lazy[node_idx] = (mul_lazy[node_idx] * k) % MOD;
add_lazy[node_idx] = (add_lazy[node_idx] * k) % MOD; // 加法标记也需要乘以 k
return;
}
int mid = (current_start + current_end) / 2;
update_multiply(2 * node_idx, current_start, mid, update_start, update_end, k);
update_multiply(2 * node_idx + 1, mid + 1, current_end, update_start, update_end, k);
tree[node_idx] = (tree[2 * node_idx] + tree[2 * node_idx + 1]) % MOD;
}
// 区间加法
void update_add(int node_idx, int current_start, int current_end, int update_start, int update_end, long long k) {
push_down(node_idx, current_start, current_end); // 先下推
if (current_start > update_end || current_end < update_start) {
return;
}
if (update_start <= current_start && current_end <= update_end) {
tree[node_idx] = (tree[node_idx] + k * get_len(current_start, current_end)) % MOD;
add_lazy[node_idx] = (add_lazy[node_idx] + k) % MOD;
return;
}
int mid = (current_start + current_end) / 2;
update_add(2 * node_idx, current_start, mid, update_start, update_end, k);
update_add(2 * node_idx + 1, mid + 1, current_end, update_start, update_end, k);
tree[node_idx] = (tree[2 * node_idx] + tree[2 * node_idx + 1]) % MOD;
}
// 区间查询
long long query_range(int node_idx, int current_start, int current_end, int query_start, int query_end) {
push_down(node_idx, current_start, current_end); // 先下推
if (current_start > query_end || current_end < query_start) {
return 0;
}
if (query_start <= current_start && current_end <= query_end) {
return tree[node_idx];
}
int mid = (current_start + current_end) / 2;
long long p1 = query_range(2 * node_idx, current_start, mid, query_start, query_end);
long long p2 = query_range(2 * node_idx + 1, mid + 1, current_end, query_start, query_end);
return (p1 + p2) % MOD;
}
int main() {
std::ios_base::sync_with_stdio(false);
std::cin.tie(NULL);
int n, m;
std::cin >> n >> m >> MOD; // 额外输入 MOD
for (int i = 0; i < n; ++i) {
std::cin >> arr[i];
}
build(1, 0, n - 1); // 0-based index
for (int i = 0; i < m; ++i) {
int op_type;
std::cin >> op_type;
int x, y;
long long k;
if (op_type == 1) { // 乘法
std::cin >> x >> y >> k;
update_multiply(1, 0, n - 1, x - 1, y - 1, k);
} else if (op_type == 2) { // 加法
std::cin >> x >> y >> k;
update_add(1, 0, n - 1, x - 1, y - 1, k);
} else { // 查询
std::cin >> x >> y;
std::cout << query_range(1, 0, n - 1, x - 1, y - 1) << "\n";
}
}
return 0;
}
要点:
- 懒惰标记的优先级:乘法优先于加法。在
push_down时,总是先处理乘法标记,再处理加法标记。 - 加法标记的更新:当有乘法操作
*k时,节点上的加法标记add_lazy也需要乘以k。因为如果之前有+A的操作,现在整个区间乘以k,那么原本+A的效果就变成了+A*k。 - 取模:题目要求对 P 取模,所有涉及到求和、乘法的地方都要进行
% MOD操作,防止溢出。尤其注意负数取模的问题(C++ 中%负数可能得到负数,需要(X % MOD + MOD) % MOD来保证非负)。这里假设都是正数。 push_down的判断:只有当mul_lazy不为1或者add_lazy不为0时才需要下推。
总结与提高
通过这三个例题,你应该对线段树的构建、单点修改、区间修改(带懒惰标记)、区间查询有了更深入的理解。
更深入的思考:
- 线段树的应用不限于求和:
- 区间最大/最小值:节点存储最大/最小值,合并时取
max(left_child, right_child)。懒惰标记可能更复杂,例如区间赋值(assign)操作。 - 区间异或和:合并时取
left_child ^ right_child。 - 区间 GCD:合并时取
gcd(left_child, right_child)。 - 统计某种性质的数量:例如区间内有多少个奇数/偶数。
- 区间最大/最小值:节点存储最大/最小值,合并时取
- 动态开点线段树:如果区间的范围非常大(例如 109),但实际需要操作的点的数量相对较少,普通的线段树会占用太多空间。这时可以使用动态开点线段树,只有在需要时才创建节点。
- 线段树套线段树:处理二维或多维问题,例如查询一个矩形区域内的和。
- 扫描线:线段树结合扫描线技术可以解决矩形面积并、周长并等几何问题。
给你的建议:
- 从简单开始:先彻底理解单点修改和区间查询。
- 再攻克懒惰标记:这是线段树的难点,需要反复推敲
push_down的逻辑。可以尝试在纸上模拟push_down的过程,看看标记是如何传递和更新的。 - 多写多练:刷题是最好的学习方式。尝试洛谷上的线段树模板题,以及其他相关题目。
- 画图辅助理解:当遇到复杂逻辑时,画出线段树的结构图,标出节点和区间,能帮助你理清思路。
线段树是一个非常强大的工具,掌握它之后,你会发现很多看起来复杂的区间问题变得迎刃而解。加油!
