线段树总集
引入
一个数列,单点修改(加),区间查询(和)。
上述问题有很多种解法,如树状数组、分块、平衡树等,今天的主题是著名的线段树。
正题
(不确保按难度升序排序,自己看着目录调顺序吧)
线段树基本原理
因为需要区间查询,所以我们希望有一些捷径能将部分的数的和提前算好,并能够直接上手用,这样可以节省不少的时间。
这正是线段树、树状数组、分块的主要思想。
考虑将整个序列平均分成两截,将 两部分的数提前算好,修改的时候顺手再改一下,就能省掉一部分时间。
但是这还是不够优秀。
接下来又有两条路可以走:
- 将序列拆分成的段数增加,用同样的方法维护(这就是分块的方向)。
- 将分成的两截继续分,分到每段只有一个数为止(这就是线段树和树状数组的思想),形成一个类似树的结构。
第一条的最优时间复杂度是
走第二条路得到的结构是一棵树,我们可以不用链式建树,用堆式建树即可(即父亲节点编号为
根据上文中提到的方法,我们需要将这个树形的数据结构(线段树)上的所有点都给予一个权值
因为每个结点上的
void pushup(int id) {
sum[id] = sum[ls] + sum[rs]; // ls,rs 代表两个儿子的编号,下同
}
每一次查询
示例代码:
int query(int id, int lft, int rht, int l, int r) { // 从左到右依次是:目前找到的结点编号、结点管辖的左端点、右端点、查询的左端点、右端点
if (lft > r || rht < l) return 0; // 如果不在结点的辖区内,返回 0 不考虑。
if (l <= lft && rht <= r) return sum[id]; // 如果完全包含,不用往下递归,直接返回。
int mid = (lft + rht) >> 1; // 因为分是对半分,所以取中点
return query(ls, lft, mid, l, r) + query(rs, mid + 1, rht, l, r); // 递归返回
}
但是它若修改
其实这也不难,从根(代表
示例代码:
void change(int id, int lft, int rht, int x, int v) { // 从左到右依次是:目前找到的结点编号、结点管辖的左端点、右端点、修改的下标、修改的权值
if (x < lft || rht < x) return; // 如果不包含在区间中,就返回
if (lft == rht) return sum[id] += v, void(); // 如果恰好找到了,就修改回去
int mid = (lft + rht) >> 1;
change(ls, lft, mid, x, v), change(rs, mid + 1, rht, x, v); // 递归修改
pushup(id); // 记得上传
}
大家可以参考图
(图
时空复杂度放到后面分析。
懒标记基本原理
那如果单修换成区修呢?
显然不可能一个一个地改,那该怎么办?
先放下线段树,我们举一个形象的例子。
你是一名学生,你的老师给你们班委派了一份作业。
并且老师嘱咐她会突击检查。
你想着等老师检查再做,就开始玩florr。
突然有一天,老师要突击检查,但是没检查到你,你放心了,还是不需要做。
但是这天,老师要检查你,你只好把所有的陈年旧账翻出来做了,交给老师。
老师没检查出问题,以为你是一个按时做作业的好学生。
故事讲完了,但是线段树还没完。
我们可以将区修类比成老师布置作业,检查看作查询。
而树上的每个点都看作你不做作业的学生。
那么对于每一个树上的结点,我们再加一个变量
因为这是人类懒才制造出来的东西,所以叫“懒标记”。
示例代码:
void pushdown(int id) { // 将存储于结点 id 的懒标记下传
if (tag[id]) { // 如果该结点上存有标记
tag[ls] += tag[id], tag[rs] += tag[id]; // 将标记传递到两个子结点的标记
sum[ls] += tag[id] * len[ls]; // 传递到两个儿子结点的 sum 信息中,但是修改是对区间中每个数的修改,所以要乘上区间长度
sum[rs] += tag[id] * len[rs];
tag[id] = 0; // 已经下传完,不用留着
}
}
int query(int id, int lft, int rht, int l, int r) {
if (lft > r || rht < l) return 0;
if (l <= lft && rht <= r) return sum[id];
pushdown(id); // 记得 pushdown,否则还没来得及做就被检查了
int mid = (lft + rht) >> 1;
return query(ls, lft, mid, l, r) + query(rs, mid + 1, rht, l, r);
}
void change(int id, int lft, int rht, int l, int r, int v) {
if (lft > r || rht < l) return;
if (lft == rht) return sum[id] += (rht - lft + 1) * v, tag[id] += v, void(); // 修改,记得加标记
pushdown(id); // 记得 pushdown
int mid = (lft + rht) >> 1;
change(ls, lft, mid, l, r, v), change(rs, mid + 1, rht, l, r, v);
pushup(id);
}
复杂度分析
-
时间复杂度
-
在查询中,发现每一层都至多有两个线段被选中,一共有
层,那么时间复杂度为 。 -
在单修中,只需要找到修改对应的结点的位置即可,时间复杂度
。 -
在区修中,与查询大致相同,时间复杂度也为
。
则总时间复杂度为
。 -
-
空间复杂度
因为我们是用堆式建树来构建二叉树的,那么整棵数一共有
个结点。但是为了避免越界,我们需要开
个结点。故线段树的空间复杂度为
,记得别开小 RE 了。
扫描线 + 线段树
看一道题:
P5490 【模板】扫描线 & 矩形面积并
假设有一条直线,初始与
像这样:
(图
最后将扫过的长度乘上宽即可算出一条长方形的面积了。
代码不放了,当时写得很丑。
广义扫描线
上文解决了矩阵面积并问题,是具象的扫描线(当然也是狭义的)。
但是有一些序列问题也可以转化为扫描线。
核心思想便是枚举一维,然后考虑用某种数据结构维护另外一维。
P1908 逆序对
没错就是它,虽然并没有用线段树(也不是不行),但是它的树状数组解法运用了扫描线思想。
对于每一个数
像这样:

(图
而逆序对的定义是

(图
好,你已经知道广义扫描线是什么了,我们来小试牛刀一下。
Pudding Monsters
区区紫题,何足惧哉?
因为每行每列只会有一个 Monster,所以可以将二维压缩成一维,横坐标作为下标,纵坐标作为值记为
不要被紫题的表象吓到,冷静分析,它求的无非是满足以下条件的
可以来推柿子了:
仔细分析,发现:
那不好办了,直接算算上述值的最小值个数不就行了。
但是复杂度好像还是
不过,你可别忘了,这题要用扫描线。
枚举
小小紫题,不过如此。
#include <bits/stdc++.h>
#define int long long
#define pii pair<int, int>
#define FRE(x) freopen(x ".in", "r", stdin), freopen(x ".out", "w", stdout)
#define ALL(x) x.begin(), x.end()
using namespace std;
int _test_ = 1;
const int N = 3e5 + 5;
int n, a[N], b[N];
struct node {
int v, l, r; // 存储左右端点和权值
};
bool operator<(node a, node b) { return a.v < b.v; }
bool operator>(node a, node b) { return a.v > b.v; }
bool operator<(node a, int b) { return a.v < b; }
bool operator>(node a, int b) { return a.v > b; }
bool operator<(int a, node b) { return a < b.v; }
bool operator>(int a, node b) { return a > b.v; }
struct segment {
#define ls (id << 1)
#define rs (id << 1 | 1)
int mn[N << 2], cnt[N << 2], tag[N << 2]; // 最小值、最小值个数、加和懒标记
void pushup(int id) {
mn[id] = min(mn[ls], mn[rs]);
if (mn[ls] < mn[rs]) cnt[id] = cnt[ls];
if (mn[ls] > mn[rs]) cnt[id] = cnt[rs];
if (mn[ls] == mn[rs]) cnt[id] = cnt[ls] + cnt[rs];
}
void pushdown(int id) {
if (tag[id]) {
mn[ls] += tag[id], mn[rs] += tag[id];
tag[ls] += tag[id], tag[rs] += tag[id];
tag[id] = 0;
}
}
void build(int id, int lft, int rht) {
if (lft == rht) return mn[id] = b[lft], cnt[id] = 1, void();
int mid = (lft + rht) >> 1;
build(ls, lft, mid), build(rs, mid + 1, rht);
pushup(id);
}
void change(int id, int lft, int rht, int l, int r, int v) {
if (lft > r || rht < l) return;
if (l <= lft && rht <= r) {
mn[id] += v;
tag[id] += v;
return;
}
pushdown(id);
int mid = (lft + rht) >> 1;
change(ls, lft, mid, l, r, v), change(rs, mid + 1, rht, l, r, v);
pushup(id);
}
pii query(int id, int lft, int rht, int l, int r) {
if (lft > r || rht < l) return {LL\mathcal ONG_MAX, 0};
if (l <= lft && rht <= r) return {mn[id], cnt[id]};
int mid = (lft + rht) >> 1;
pii x = query(ls, lft, mid, l, r), y = query(rs, mid + 1, rht, l, r);
if (x.first < y.first) return x;
if (x.first > y.first) return y;
return {x.first, x.second + y.second};
}
} seg;
void init() {}
void clear() {}
void solve() {
cin >> n;
for (int i = 1, l; i <= n; i++) cin >> l >> a[l];
for (int i = 1; i <= n; i++) b[i] = i; // 这里用了一个比较巧妙的方法,放后面说一下
seg.build(1, 1, n);
stack<node> mx, mn; // 两个单调栈
int ans = 0;
for (int i = 1; i <= n; i++) {
int lft = i;
while (mx.size() && a[i] > mx.top()) {
lft = mx.top().l;
seg.change(1, 1, n, mx.top().l, mx.top().r, -mx.top().v); // 将所有不再是最大值的合并到一起
mx.pop();
}
seg.change(1, 1, n, lft, i, a[i]); // 同意赋值,上文中 max 部分是正的所以是 + a[i]
mx.push({a[i], lft, i});
lft = i;
while (mn.size() && a[i] < mn.top()) {
lft = mn.top().l;
seg.change(1, 1, n, mn.top().l, mn.top().r, mn.top().v); // 将所有不再是最小值的合并到一起
mn.pop();
}
seg.change(1, 1, n, lft, i, -a[i]); // 同理,为 - a[i]
mn.push({a[i], lft, i});
ans += seg.cnt[1]; // 加上所有的总和
}
cout << ans;
}
signed main() {
ios::sync_with_stdio(0);
cin.tie(0), cout.tie(0);
// cin >> _test_;
init();
while (_test_--) {
clear();
solve();
}
return 0;
}
代码中的 for (int i = 1; i <= n; i++) b[i] = i;
不知道你注意到没有。
因为柿子中的
线段树维护最大子段和及相关问题
link.
永久化标记原理
还是线段树模板
将标记停留在结点上,不下传,查询的时候沿途收集标记,并加上子树内标记即为答案。
就像一个扫帚:

(图
void change(int id, int lft, int rht, int l, int r, int v) {
val[id] += (r - l + 1) * v; // 将子树内标记提前打好
if (l == lft && rht == r) return tag[id] += v, void(); // 沿途标记更新
int mid = (lft + rht) >> 1;
if (r <= mid) return change(ls, lft, mid, l, r, v);
if (l > mid) return change(rs, mid + 1, rht, l, r, v);
return change(ls, lft, mid, l, mid, v), change(rs, mid + 1, rht, mid + 1, r, v); // 继续递归子树
}
int query(int id, int lft, int rht, int l, int r, int cnt) { // cnt 为沿途标记和
if (lft == l && rht == r) return val[id] + (rht - lft + 1) * cnt; // 子树内标记和加上沿途标记
int mid = (lft + rht) >> 1;
cnt += tag[id]; // 收集该结点标记
if (r <= mid) return query(ls, lft, mid, l, r, cnt);
if (l > mid) return query(rs, mid + 1, rht, l, r, cnt);
return query(ls, lft, mid, l, mid, cnt) + query(rs, mid + 1, rht, mid + 1, r, cnt);
}
这个方法会使得常数变小一些,而且适用于一些懒标记无法维护的情况。
当遇到懒标记无法解决修改或解决起来很慢时可以考虑使用永久化标记。
线段树上二分技巧
当你需要对线段树做二分时,正常情况下是
这时候就要用线段树上二分了。
因为线段树本身就是一个二分的结构,所以不如干脆就顺着线段树一直往下找,找到结果就返回。
复杂度就省去了一只
动态开点技巧
当你需要建的线段树不是下标线段树而是值域线段树,就需要考虑使用动态开点。
因为值域的范围不仅仅是
虽然值域很大,但是我们的操作数不可能太大(需要读入)。
故我们不需要建立所有的点,只建立所有出现过的点即可。
譬如说修改一个

(图
其他所有不包含
树链剖分
link.
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】凌霞软件回馈社区,博客园 & 1Panel & Halo 联合会员上线
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】博客园社区专享云产品让利特惠,阿里云新客6.5折上折
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 一个费力不讨好的项目,让我损失了近一半的绩效!
· 清华大学推出第四讲使用 DeepSeek + DeepResearch 让科研像聊天一样简单!
· 实操Deepseek接入个人知识库
· CSnakes vs Python.NET:高效嵌入与灵活互通的跨语言方案对比
· Plotly.NET 一个为 .NET 打造的强大开源交互式图表库