Segment Tree 线段树
Segment ABC
What is segment tree 什么是线段树
线段树是一种二叉搜索树,什么叫做二叉搜索树,首先满足二叉树,每个结点度小于等于二,即每个结点最多有两颗子树,何为搜索,我们要知道,线段树的每个结点都存储了一个区间,也可以理解成一个线段,而搜索,就是在这些线段上进行搜索操作得到你想要的答案。
下图是一个长度为
7
7
7 的线段树样子。
What can segment tree do
线段树是算法竞赛中常用的用来维护 区间信息 的数据结构。
线段树可以在 O ( N l o g N ) O(NlogN) O(NlogN) 的时间复杂度内实现单点修改、区间修改、区间查询(区间求和,求区间最大值,求区间最小值)等操作。
线段树结构
首先线段树是一棵二叉树
, 平常我们所指的线段树都是指一维线段树,如下图所示。 故名思义, 线段树能解决的是线段上的问题, 这个线段也可指区间
. 我们先来看线段树的逻辑结构。
一颗线段树的构造就是根据区间的性质的来构造的, 如下是一棵区间[0, 3]
的线段树,每个[start, end]
都是一个二叉树中的节点。
[0,3]
/ \
[0,1] [2,3]
/ \ / \
[0,0] [1,1] [2,2] [3,3]
下面我们用最大值问题为例,来看一下线段树。
对于 A[1:6] = {1,8,6,4,3,5} 来说,线段树如下图所示,红色代表每个结点存储的区间,蓝色代表该区间最值。
可以发现,每个叶子结点的值就是数组的值,每个非叶子结点的度都为二,且左右两个孩子分别存储父亲一半的区间。每个父亲的存储的值也就是两个孩子存储的值的最大值。
对于上述线段树,我们增加绿色数字为每个结点的下标。
则每个结点下标如上所示,这里你可能会问,为什么最下一排的下标直接从 9 9 9 跳到了 12 12 12,道理也很简单,中间其实是有两个空间的呀!!虽然没有使用,但是他已经开了两个空间,这也是为什么无优化的线段树建树需要 2 ∗ 2 k ( 2 k − 1 < n < 2 k ) 2*2^k\ (2^{k-1} < n < 2^k) 2∗2k (2k−1<n<2k) 空间,一般会开到 4 × N 4 \times N 4×N 的空间防止 RE。
仔细观察每个父亲和孩子下标的关系,不难发现,每个左子树的下标都是偶数,右子树的下标都是奇数且为左子树下标 + 1 +1 +1,而且不难发现以下规律
- l = f a ∗ 2 l = fa*2 l=fa∗2 (左子树下标为父亲下标的两倍)
- r = f a ∗ 2 + 1 r = fa*2+1 r=fa∗2+1(右子树下标为父亲下标的两倍 + 1 +1 +1)
具体证明也很简单,把线段树看成一个完全二叉树(空结点也当作使用)对于任意一个结点 k k k 来说,它所在此二叉树的 l o g 2 k log_2k log2k 层,则此层共有 2 l o g 2 k 2^{log_2k} 2log2k 个结点,同样对于 k k k 的左子树那层来说有 2 l o g 2 k + 1 2^{log_2k+1} 2log2k+1个结点,则结点 k k k 和左子树间隔了 2 ∗ 2 l o g 2 k − k + 2 ∗ ( k − 2 l o g 2 k ) 2*2^{log_2k}-k + 2*(k-2^{log_2k}) 2∗2log2k−k+2∗(k−2log2k) 个结点,然后这就很简单就得到 k + 2 ∗ 2 l o g 2 k − k + 2 ∗ ( k − 2 l o g 2 k ) = 2 ∗ k k+2*2^{log_2k}-k + 2*(k-2^{log_2k}) = 2*k k+2∗2log2k−k+2∗(k−2log2k)=2∗k 的关系了吧,右子树也就等于左子树结点 + 1 +1 +1。
因为左子树都是偶数,所以我们常用位运算来寻找左右子树
- k<<1(结点 k k k 的左子树下标),其实就是 2*k
- k<<1|1(结点 k k k 的右子树下标),其实就是 2*k+1
因为左子树都是偶数,所以我们常用位运算
线段树主要操作和时间复杂度
查询 query
查询从 [ L , R ] [L, R] [L,R] 这个区间的数据和,最大值,最小值,公约数。
时间复杂度为 O ( l o g N ) O(logN) O(logN)。
更新 update
分为单点更新和区域更新。通过 Lazy 算法,我们可以江时间复杂度也控制在 O ( l o g N ) O(logN) O(logN)。
线段树存储空间
在算法竞赛中,我们一般使用一维数组来存储线段树。
对于任何一个长度为 N N N 的数组,对应的线段树深度为 ⌈ l o g 2 N ⌉ ⌈log_2N⌉ ⌈log2N⌉,因此对应每层节点数有: 1 + 2 + 4 + . . . + 2 ⌈ l o g 2 N ⌉ < 4 × N 1+2+4+...+2^{⌈log_2N⌉}<4 \times N 1+2+4+...+2⌈log2N⌉<4×N,对应的二叉树深度为 l o g N logN logN。
How to implement segment tree in C++
基础线段树
下面我们用区间和功能来展示基础线段树代码。所谓的区间和是
- 叶子节点存储原始数据。
- 非叶子节点存储的是和数据。
如下图所示。
定义存储空间
using LL=long long;
const int N=1e5+10;
LL a[N];//保存原来数据
LL seg[4*N];//定义线段树
Build Tree 建树
下面的模板代码,将从原数组 a a a 中,建立 [ l , r ] [l, r] [l,r] 区间的线段树,叶子节点数据为 a a a,其他节点为对应区间和。
例如数组 a a a 内数据为 { 1 , 8 , 3 , 4 , 7 , 1 , 6 , 2 } \{1,8,3,4,7,1,6,2\} {1,8,3,4,7,1,6,2},建立线段树的过程如下图。
模板代码
/*
从数组a中建立 [l,r] 长度线段树
输入参数:
node : 建立线段数的节点位置。一般固定在 1
l : 数组的起点位置。一般固定在 1
r : 数组的终点位置。一般固定在 n
用法:
一般都是 build(1,1,n);
*/
void build(LL node, LL l, LL r) {
if (l>=r) {
seg[node]=a[l];
return;
}
//使用分治的思路
LL mid=(l+r)/2;
LL l_node = 2*node;//左儿子
build_tree(l_node, l, r);//建立左子树
LL r_node = 2*node+1;//右儿子
build_tree(r_node, mid+1, r);//建立右子树
seg[node]=seg[l_node]+seg[r_node];//更新node的值,这里也就是push up操作
}
时间复杂度
O ( N ) O(N) O(N)。
练习题
学习系列——线段树 I —— 建立线段树 - 问题 - MYOJ
查询操作
我们现在有一个数组为 { 1 , 8 , 3 , 4 , 7 , 1 , 6 , 2 } \{1, 8, 3, 4, 7, 1, 6, 2\} {1,8,3,4,7,1,6,2},我们需要查询 [ 1 , 6 ] [1, 6] [1,6] 这个区间的和,如下图所示。
我们可以发现,其实在每次查询的时候,是不断对当前层级所表示的区间进行二分分治,直到最后每个分块的并集即为待查询区间。
我们需要查询
[
L
,
R
]
[L, R]
[L,R] 区间的数据和,也就是
∑
i
=
L
R
a
i
\sum_{i=L}^{R}a_i
∑i=LRai。
如上图所示的动画中,我们只需要查询
[
1
,
4
]
[1, 4]
[1,4] 和
[
5
,
6
]
[5, 6]
[5,6] 这两个区间即可。这样我们得到区间和为
16
+
8
=
24
16+8=24
16+8=24。
模板代码
/*
查询指定区间数据
输入参数:
node : 建立线段数的节点位置。一般固定在 1
l : 数组的起点位置。一般固定在 1
r : 数组的终点位置。一般固定在 n
ql : 查询区间起点
qr : 查询区间终点
用法:
query(1,1,n,ql,qr);
*/
LL query(LL node, LL l, LL r, LL ql, LL qr) {
if (ql<=l && r<=qr) {
//区间在查询范围内
return seg[node];
}
LL mid=(l+r)/2;
LL res=0;
if (ql<=mid) {
//如果在左边查询左边
res+=query(2*node, l, r, ql, qr);
}
if (qr>mid) {
//如果在右边查询右边
res+==query(2*node+1, mid+1, r, ql, qr);
}
return res;
}
时间复杂度
O ( l o g N ) O(logN) O(logN)。
练习题
学习系列——线段树 III —— 查询区间和操作 - 问题 - MYOJ
单点更新
将对线段树上的某个具体位置数据进行修改。一般这个操作称为单点修改。
比如我们现在有一个数组为
{
1
,
3
,
5
,
7
,
9
,
11
}
\{1, 3, 5, 7, 9, 11\}
{1,3,5,7,9,11},我们建立的线段树入下图所示。
下面我们进行单点修改。假设我要将
a
4
a_4
a4 改为
15
15
15。这样,原数组变为
{
1
,
3
,
5
,
15
,
9
,
11
}
\{1, 3, 5, 15, 9, 11\}
{1,3,5,15,9,11},对应的线段树变为下图,红色的数字为修改的地方。
模板代码
/*
单点更新数据
输入参数:
node : 建立线段数的节点位置。一般固定在 1
l : 数组的起点位置。一般固定在 1
r : 数组的终点位置。一般固定在 n
pos : 需要更新的位置
val : 更新后的值
用法:
update(1,1,n,pos,val);
*/
void update(LL node, LL l, LL r, LL pos, LL val) {
if (l>=r) {
seg[node]=val;
return;
}
LL mid=(l+r)/2;
LL l_node=node*2;
LL r_node=node*2+1;
if (pos<=mid) {
//更新左儿子
update(l_node, l, mid, pos, val);
} else {
//更新右儿子
update(r_node, mid+1, r, pos, val);
}
//更新节点数据
seg[node]=seg[l_node]+seg[r_node];
}
时间复杂度
O ( l o g N ) O(logN) O(logN)。
练习题
学习系列——线段树 II —— 单点更新 - 问题 - MYOJ
线段树其他功能
区间极值
对于最大值最小值问题,在非叶子节点保存极值,而不是和即可。
因此,我们需要在对应的建树、查询、修改代码进行相应的修改。
模板代码
下面的模板代码是保存了最大值。
void build(LL node, LL l, LL r) {
if (l>=r) {
seg[node]=a[st];
return;
}
//使用分治的思路
LL mid=(l+r)/2;
LL l_node = 2*node;//左儿子
build_tree(l_node, l, r);//建立左子树
LL r_node = 2*node+1;//右儿子
build_tree(r_node, mid+1, r);//建立右子树
seg[node]=max(seg[l_node],seg[r_node]);//更新node的值
}
LL query(LL node, LL l, LL r, LL ql, LL qr) {
if (ql<=l && r<=qr) {
//区间在查询范围内
return seg[node];
}
LL mid=(l+r)/2;
LL max_l=-9e18;
if (ql<=mid) {
//如果在左边查询左边
max_l=query(2*node, l, mid, ql, qr);
}
LL max_r=-9e18;
if (qr>mid) {
//如果在右边查询右边
max_r==query(2*node+1, mid+1, r, ql, qr);
}
return max(max_l, max_r);
}
单点更新 update 代码进行对应修改即可。
练习题
学习系列——线段树 IV —— 区间最值查询问题(RMQ)之最大值 - 问题 - MYOJ
学习系列——线段树 V —— 区间最值查询问题(RMQ)之最小值 - 问题 - MYOJ
极值+出现次数
思路还是一样的。数据保存可以使用 pair 即可,即使用 pair 替代原来的 LL。
pair<LL, LL> seg[N];
然后再修改对应 build, query, update 函数。
模板代码
using PLL=pair<LL, LL>;
PLL seg[N];//first表示最最大值, second表示次数
PLL combine(PLL a, PLL b) {
if (a.first>b.first) {
return a;
} else if (a.first<b.first) {
return b;
}
return make_pair(a.first, a.second+b.second);
}
void build(LL node, LL l, LL r) {
if (l>=r) {
seg[node]=make_pair(a[st], 1);
return;
}
//使用分治的思路
LL mid=(l+r)/2;
LL l_node = 2*node;//左儿子
build_tree(l_node, l, mid);//建立左子树
LL r_node = 2*node+1;//右儿子
build_tree(r_node, mid+1, r);//建立右子树
seg[node]=combine(seg[l_node],seg[r_node]);//更新node的值
}
PLL query(LL node, LL l, LL r, LL ql, LL qr) {
if (ql<=l && r<=qr) {
//区间在查询范围内
return seg[node];
}
LL mid=(l+r)/2;
PLL max_l=make_pair(0, 0);
if (ql<=mid) {
//如果在左边查询左边
max_l=query(2*node, l, mid, ql, qr);
}
PLL max_r=make_pair(0, 0);
if (qr>mid) {
//如果在右边查询右边
max_r==query(2*node+1, mid+1, r, ql, qr);
}
return combine(max_l, max_r);
}
区间最大公约数/最小公倍数
统计某个数字出现次数
第 k k k 次出现某个数字
线段树进阶操作
区间更新
和上面的单点更新不同,现在我们
[
l
,
r
]
[l, r]
[l,r] 区域对线段树进行更新。如果我们还是使用单点更新的模式,这样时间复杂度将高达
O
(
N
l
o
g
N
)
O(NlogN)
O(NlogN),这个代价是无法接受的。这样,我们就引入一个 Lazy Propagation,国内一般称为“懒惰标志”。
基本的思路是这样的:
父节点:我这里要区间加 4,你们都要加 4。
左子树:好的。
右子树:但是这样太慢了,因为我们子孙也都要加 4 啊。
父节点:嗯确实是这样。哪我标记一下,我们欠你们这些,以后再还(每次 update 和 query 时候)。
这样,我们将引入一个新的 lazy 数组。在每次 update 和 query 的时候进行下传。这样将总时间复杂度控制在 O ( l o g N ) O(logN) O(logN)。
现在有一个数组为
{
1
,
8
,
3
,
4
,
7
,
1
,
6
,
2
}
\{1, 8, 3, 4, 7, 1, 6, 2\}
{1,8,3,4,7,1,6,2},我们需要对线段树进行一个区间更新的操作,将
[
3
,
6
]
[3, 6]
[3,6] 这个区间上的所有数字增加
4
4
4。此时我们的增量数组如图所示:
为什么要单独用一个属性来记录增量?因为我们上面的图片发现我们需要查的
[
1
,
6
]
[1, 6]
[1,6] 这个区间,其最小颗粒度的查询节点是
[
1
,
4
]
[1, 4]
[1,4] 和
[
5
,
6
]
[5, 6]
[5,6] 这两个节点,所以我们将增量向下更新到这两个节点,就能保证我们查询
[
1
,
6
]
[1, 6]
[1,6] 这个区间的正确性。
于是,通过这个思路我们来思考,当我们需要对一个区间进行批量增减操作的时候,我们只要向下更新到我们所有查询操作的最小粒度即可,而不用完全对整个线段树进行更新,是不是就完成了复杂度的优化!
这就是
O
(
l
o
g
N
)
O(logN)
O(logN) 级别的批量更新思路,这就是算法中的“惰性”(Lazy)思想。
Push Down操作
Push Down 也就是向下更新的意思。因为我们要引入这个增量的记录数组,所以我们需要 Push Down 操作。
在 Push Down 操作中,我们已经保证了这个更新是最小的可查询的粒度。那么,如果我们在后面要在后面去查询更细的粒度,我们要怎么办呢?其实,思路很简单,当我们查询的时候,也执行 Push Down 按照之前需要更新的范围继续向下更新,是不是就可以了。
同样的,由于 Update 区间操作也需要想查询一样最小的可更新粒度,所以我们在每查询到一个节点时,也对其增加一个 Push Down 操作,如此可以保证下方节点都是最新的更新态。
模板代码
我们还是使用增加一个数组,用于标志下传。
//segment tree
const int MAXN=1e5+10;
int a[MAXN];
int seg[MAXN*4];
int add[MAXN*4];
void build(int node, int l, int r) {
if (l>=r) {
seg[node]=a[l];
return;
}
int mid=(l+r)/2;
int l_node=2*node;
build(l_node, l, mid);
int r_node=2*node+1;
build(r_node, mid+1, r);
seg[node]=seg[l_node]+seg[r_node];
}
/*
st 表示左儿子区域长度
ed 表示右儿子区域长度
*/
void push_down(int node, int st, int ed) {
if (add[node]) {
//左儿子
int l_node=2*node;
add[l_node]+=add[node];//标志下传
st [l_node]+=add[node]*st;//修改数据
//右儿子
int r_node=2*node+1;
add[r_node]+=add[node];
st [r_node]+=add[node]*ed;
add[node]=0;
}
}
void update(int node, int l, int r, int ql, int qr, int c) {
if (ql<=l&&r<=qr) {
add[node]+=c;//更新标志
st [node]+=c*(ed-st+1);//修改自己的数据
return;
}
int mid=(l+r)/2;
push_down(node, mid-l+1, r-mid);//下传
int l_node=2*node;
if (ql<=mid) {
update(l_node, st, r, ql, qr, c);
}
int r_node=2*node+1;
if (qr>mid) {
update(r_node, mid+1, r, ql, qr, c);
}
seg[node]=seg[l_node]+seg[r_node];
}
int query(int node, int l, int r, int ql, int qr) {
if (ql<=l&&r<=qr) {
return seg[node];
}
int mid=(l+r)/2;
push_down(node, mid-l+1, r-mid);
int ret=0;
if (ql<=mid) {
ret+=query(2*node,l,mid,ql,qr);
}
if (qr>mid) {
ret+=query(2*node+1,mid+1,r,ql,qr);
}
return ret;
}
从上面代码中,我们可以看到,update 和 query 都增加了一个 push_down()。这样,我们就将时间平摊,最终达到 O ( l o g N ) O(logN) O(logN) 级别。因为不需要操作的时候,当前数据是不会影响最终结果的。
终极线段树模板
T.B.C.