线段树介绍
线段树是一种基于分治思想的二叉树结构,用于在区间上进行信息统计。
与按照 “2
的次幂” 进行划分的树状数组相比,线段树则是一种更为通用的数据结构:
- ①线段树每个节点都代表一个区间
- ②线段树的根节点是唯一的,代表的区间是整个统计范围,如:
[1, N]
- ③线段树每个叶子节点都代表一个长度为
1
的单位区间[x, x]
- ④对于每个内部节点
[l, r]
,其左儿子是[l, mid]
,右儿子是[mid+1, r]
,其中mid=l+r>>1
(即(l+r)/2
向下取整)
区间视角下的线段树:
二叉树视角下的线段树:
上图所展示的线段树,如果除去最后一层,整棵树一定是个完全二叉树,且深度为O(logN)
。因此我们可以按照之前学过的,与二叉堆类似的 父子2倍 节点编号 方法:
- ①根节点编号为
1
- ②编号为
x
的节点左儿子编号为x<<1
,右儿子为x<<1|1
这样一来我们就可以用一个结构体数组来存储整个线段树了。
当然,树的最后一层节点在结构体数组中保存位置是不连续的,直接空出数组多余位置就行。
在理想情况下,N
个叶子结点的满二叉树有N + N/2 + N/4 + ... + 2 + 1 = 2N - 1
个节点。
因为在上述存储方式下,最后一层产生冗余(余最多2N
个节点),所以保存线段树的数组长度要不小于4N
(2N - 1 + 2N = 4N - 1
)才可以保证不越界!
一、线段树的建树:build
线段树的基本用途是对一个序列a
进行维护,支持查询ask
和修改modify
指令。
给定一个长度为N
的a
序列,我们可以在区间[1, N]
上建立一棵线段树。每个叶子结点[i, i]
保存a[i]
的值。
线段树的二叉树结构可以非常方便地从下往上传递信息,对于“从下到上”,即由儿子节点算父亲节点信息,我们可以编写一个pushup
函数,我们以区间最大值为例子:
记区间[l, r]
最大值为 dat(l, r) = max{a[l], a[l+1], ..., a[r]}
,
显然 dat(l, r) = max(dat(l, mid), dat(mid+1, r))
(mid = l+r>>1
)。
如果用于建堆的序列a = {3, 6, 4, 8, 1, 2, 9, 5, 7, 0}
,我们可以用build(1, 1, 10)
,从根节点"1
"开始,在序列 a
的[1, 10]
这个区间建立一棵线段树如下图:
我们先书写一下用于存储线段树的结构体数组:
struct node
{
int l, r;//区间的左、右边界
int dat;//区间[l, r]最大值
} t[4*N];//结构体数组存储线段树,大小至少是原序列a的4倍
pushup
函数:
//由儿子节点算父亲节点信息
void pushup(int u) {t[u].dat = max(t[u<<1].dat, t[u<<1|1].dat);}
build
函数:
void build(int u, int l, int r)//建立一颗线段树并在每个节点上保存对应区间最大值max
{
t[u].l = l, t[u].r = r;//节点u代表区间[l, r]
if(l==r) {t[u].dat = a[l]; return ;}//如果当前是叶节点直接return
int mid = l+r>>1;//当前区间中点
build(u<<1, l, mid), build(u<<1|1, mid+1, r);
//分别递归建立左边区间[l, mid], 编号u<<1 右边区间[mid+1, r],编号u<<1|1
pushup(u);
}
调用build
函数入口:
build(1, 1, n);//从根节点"`1`"开始,在序列 `a` 的`[1, n]`这个区间建立一棵线段树
二、线段树单点修改:modify
单点修改指令形如“C x v
”,表示把a[x]
修改为v
。
在线段树中,根节点(编号为1
的节点)是执行各种指令的入口。
从根节点出发,递归找到代表区间[x, x]
的叶子节点,之后从下往上更新[x, x]
以及它的所有祖先节点上保存的信息。如下图,展示一下modify(1, 7, 1),阴影部分是需要改动的节点。
modify
函数:时间复杂度:O(logN)
//从根节点出发,递归找到代表区间[x, x]的叶节点,之后从下往上更新[x, x]
//以及它的所有祖先节点上保存的信息。
void modify(int u, int x, int v)//单点修改 把a[x]的值修改为v
{
if(t[u].l==x&&t[u].r==x) {t[u].dat = v; return ;}//如果找到叶节点直接修改
//否则判断到底是往左递归还是往右递归
int mid = t[u].l+t[u].r>>1;//取中点
if(x<=mid) modify(u<<1, x, v);//x属于左边
else modify(u<<1|1, x, v);//x属于右边
//递归完成后,当前节点最大值信息一定要记得更新
pushup(u);//从下往上回溯更新信息
}
调用modify
函数入口:
modify(1, x, v);//从根节点出发,将a[x]的值修改为v
三、线段树的区间查询:ask
区间查询指令形如“Q l r
”,表示查询序列a
在区间[l, r]
上的最大值,即max{a[l], a[l+1], ..., a[r]}
。
我们从根节点开始,递归执行一下过程:
- ①若
[l, r]
完全覆盖当前节点代表区间,立即回溯,且该节点dat
值为候选答案。 - ②若左儿子与
[l, r]
有交集,递归访问左儿子。
③若右儿子与[l, r]
有交集,递归访问右儿子。
如图:执行ask(1, 2, 8) = max{6, 4, 8, 5} = 8
,区间[2, 8]恰好包含四个阴影节点。
ask
函数:时间复杂度:O(logN)
int ask(int u, int l, int r)//区间查询最大值max
{
if(l<=t[u].l&&r>=t[u].r) return t[u].dat;//树中节点,已经完全包含在[l, r]中了
int mid = t[u].l+t[u].r>>1;
int val = -(1<<30);//负无穷大
if(l<=mid) val = max(val, ask(u<<1, l, r));//和左边有交集
if(r>=mid+1) val = max(val, ask(u<<1|1, l, r));//和右边有交集
return val;
}
调用ask
函数入口:
cout<<ask(1, l, r)<<endl;//从根节点"1"开始,查询[l, r]内的最大值
可以证明,上述查询过程会将询问区间[l, r]
在线段树上分为O(logN)
个节点,取它们的最大值作为答案。
以上,就是关于线段树的讲解。
例题:AcWing 1275. 最大数
题意:
思路:
运用线段树实现两个操作:
Q L
:询问序列中最后 L
个数的最大数是多少
A t
:则表示向序列后面加一个数,加入的数是 (t+a) mod p
。其中,t
是输入的参数,a
是在这个添加操作之前最后一个询问操作的答案(如果之前没有询问操作,则 a=0
)
由于输入指令“A t”修改的时候所用到的值t
和上一次查询的值a
是有关系的,因此本题是个动态问题,我们不能用静态的方法,先把它们都读进来预处理一遍再去处理,只有知道前一个问题的答案才知道当前准备加入的数是多少。
本题没有设置序列a
来初始化线段树,因此初始直接先build
一个全0
的线段树即可。
之后基本上是前面讲过的线段树基本操作了。
时间复杂度:
O(mlogm)
空白代码:
#include<bits/stdc++.h>
using namespace std;
#define int long long
const int N = 2e5+10;
int m, p;
struct node
{
int l, r;
int dat;
} t[4*N];
void pushup(int u) {t[u].dat = max(t[u<<1].dat, t[u<<1|1].dat);}
void build(int u, int l, int r)
{
t[u].l = l, t[u].r = r;
if(l==r) return ;
int mid = l+r>>1;
build(u<<1, l, mid), build(u<<1|1, mid+1, r);
}
void modify(int u, int x, int v)
{
if(t[u].l==x&&t[u].r==x) {t[u].dat = v; return ;}
int mid = t[u].l+t[u].r>>1;
if(x<=mid) modify(u<<1, x, v);
else modify(u<<1|1, x, v);
pushup(u);
}
int ask(int u, int l, int r)
{
if(l<=t[u].l&&r>=t[u].r) return t[u].dat;
int mid = t[u].l+t[u].r>>1;
int val = -(1<<30);
if(l<=mid) val = max(val, ask(u<<1, l, r));
if(r>=mid+1) val = max(val, ask(u<<1|1, l, r));
return val;
}
signed main()
{
int now = 0;
int last = 0;
cin>>m>>p;
build(1, 1, m);
int x;
char op[2];
while(m--)
{
scanf("%s%d", op, &x);
if(*op=='Q')
{
last = ask(1, now-x+1, now);
printf("%d\n", last);
}
else
{
modify(1, now+1, (last+x)%p);
++now;
}
}
return 0;
}
注释代码:
#include<bits/stdc++.h>
using namespace std;
#define int long long
const int N = 2e5+10;
int m, p;//操作数和取模的数
//node结构体
struct node
{
int l, r;
int dat;//区间[l, r]最大值
} t[4*N];//结构体数组存储线段树
//pushup函数
//由儿子节点算父亲节点信息
void pushup(int u) {t[u].dat = max(t[u<<1].dat, t[u<<1|1].dat);}
//build函数
void build(int u, int l, int r)//建立一颗线段树并在每个节点上保存对应区间最大值max
{
t[u].l = l, t[u].r = r;//节点u代表区间[l, r]
if(l==r)
{
//t[p].dat = a[l];//本题没有用于建立线段树的初始数组a,故不要这一句
return ;//如果当前是叶节点直接return
}
int mid = l+r>>1;//当前区间中点
build(u<<1, l, mid), build(u<<1|1, mid+1, r);
//递归建立左边区间[l, mid], 编号u<<1 递归建立右边区间[mid+1, r],编号u<<1|1
//pushup(u);//本题初始化线段树时并没有给dat赋值,因此无需pushup
}
//modify函数
//从根节点出发,递归找到代表区间[x, x]的叶节点,之后从下往上更新[x, x]
//以及它的所有祖先节点上保存的信息。
void modify(int u, int x, int v)//单点修改 把a[x]的值修改为v
{
if(t[u].l==x&&t[u].r==x) {t[u].dat = v; return ;}//如果找到叶节点直接修改
//否则判断到底是往左递归还是往右递归
int mid = t[u].l+t[u].r>>1;//取中点
if(x<=mid) modify(u<<1, x, v);//x属于左边
else modify(u<<1|1, x, v);//x属于右边
//递归完成后,当前节点最大值信息一定要记得更新
pushup(u);//从下往上回溯更新信息
}
//ask函数
//从根节点开始,递归执行一下过程:
//1.若[l, r]完全覆盖当前节点代表的区间,立即回溯,并且该节点的dat值为候选答案
//2.若左子节点与[l, r]有交集,则递归访问左子节点
//3.若右子节点与[l, r]有交集,则递归访问右子节点
int ask(int u, int l, int r)//区间查询最大值max
{
if(l<=t[u].l&&r>=t[u].r) return t[u].dat;//树中节点,已经完全包含在[l, r]中了
int mid = t[u].l+t[u].r>>1;
int val = -(1<<30);//负无穷大
if(l<=mid) val = max(val, ask(u<<1, l, r));//和左边有交集
if(r>=mid+1) val = max(val, ask(u<<1|1, l, r));//和右边有交集
return val;
}
signed main()
{
int now = 0;//当前数据的个数
int last = 0;//存储上一个询问的答案
cin>>m>>p;
build(1, 1, m);//建立线段树,操作数是m因此最大长度是m
int x;
char op[2];
while(m--)
{
scanf("%s%d", op, &x);
if(*op=='Q')
{
last = ask(1, now-x+1, now);//从根节点开始查询,当前查询区间是后x个数,n-x+1即查询区间最左边的位置,最右边为n
printf("%d\n", last);
}
else//把下一个位置n+1的数修改
{
modify(1, now+1, (last+x)%p);//从第一个数开始找,修改第n+1个位置,修改为(last+x)%p
++now;
}
}
return 0;
}