考录到线段树的篇幅过多,写五六十页的观感不佳,我把它分为多个章节,大家可以关注我看后续的更新。每章节我会把涉及的内容放在前面,给大家进行选择和查找。(章节我会用红体来表示,内容我会用蓝体表示)。
本章会介绍:一般线段树(主要是模板),主席树(可持久化的线段树),二逼平衡树(树套树,线段树套平衡树)。
标题一:线段树+懒标记
先了解什么是线段树:
定义:是一种基于分治思想的二叉树,用来维护区间信息(区间和,区间最值,区间GCD等),在logn的时间内执行区间修改和区间查询。
构造原理:每个叶子节点储存元素本身,非叶子节点储存元素的统计值。
图文讲解:(这里用董老师的图)
这里8号和9号点,8号区间是[1,2]的区间和是7,9号点就是叶子节点。
所以我们代码实现的时候显然要用结构体。
代码格式:
#define first x
#define second y
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
typedef pair<ll, ll> pii;
typedef pair<double, int> pdi;
const int N = 1e5 + 10, mod = 998244353, inf = 0x3f3f3f3f;
#define lc p<<1
#define rc p<<1|1
//递归建树
//父节点i,左儿子2*i,右二子2*i + 1
int w[N],n;
struct node{
int l;
int r;
int sum;
}tr[N*4];
void build(int p,int l,int r){
tr[p] = {l,r,w[l]};
if(l == r) return ;//叶子返回
int m = l + r >> 1;
build(lc,l,m);
build(rc,m+1,r);
tr[p].sum = tr[lc].sum + tr[rc].sum;
}
void work(){
}
int main()
{
ios::sync_with_stdio(0);
cin.tie(0);
int _;
_ = 1;
while (_--)
{
work();
}
return 0;
}
代码运行轨迹:
点修改的方式(单点修改):
如果想修改7的这个位置的步骤;
第一步:用递归找到叶子节点
第二步:向上更新祖先节点
代码实现:
void updata(int p,int x,int y,int k){
if(tr[p].l == x&&tr[p].r == x){//查询叶子节点
tr[p].sum += k;
return ;
}
int m = tr[p].l + tr[p].r >> 1;
//裂开查找
if(x <= m) updata(lc,x,y,k);
if(x > m) updata(rc,x,y,k);
tr[p].sum = tr[lc].sum + tr[rc].sum;
}
图像显示:
可见时间复杂度是O(logn);
区间查询:(方法:拆分和拼凑)
举例:我们想查询区间[4,9];看图(下面的):
最后合并答案即可;
步骤;
第一步:从根节点进入,递归下面的步骤
步骤1:如果区间[x,y],完全在我们要找的区间内,回溯即可,并返回sum的值
步骤2;若左儿子节点和[x,y]有交点且互不包含,那么就递归访问左子树
步骤3: 若右儿子节点和[x,y]有交点且互不包含,那么就递归访问右子树
代码实现:(图像的步骤:红色-> 紫色 -> 绿色 -> 蓝色 -> 黑色)
int query(int p,int x,int y){
if(x <= tr[p].l&&tr[p].r <= y){
return tr[p].sum;
}
int m = tr[p].l + tr[p].r >> 1;
int sum = 0;
if(x <= m) sum += query(lc,x,y);
if(y > m) sum += query(rc,x,y);
return sum;
}
时间复杂度还是O(logn)
区间修改(区间修改):
对于区间[x,y],中每个数抖加上k,那么我们要修改覆盖的每个叶子节点,时间复杂度就是O(n);
优化方法(懒惰修改):当[x,y]完全覆盖节点区间[a,b]时,先修改该区间的sum值,再打上一个“懒标记”,然后返回。到下次需要时再向下传递“懒标记”。
绿色是优化和没有优化的区别:灰色是懒标记
代码实现:
struct node{
int l;
int r;
int sum;
int add;
}tr[N*4];
void pushup(int p){
tr[p].sum = tr[lc].sum + tr[rc].sum;
}
void pushdown(int p){
if(tr[p].add){
tr[lc].sum += tr[p].add * (tr[lc].r - tr[lc].l + 1);
tr[rc].sum += tr[p].add * (tr[rc].r - tr[rc].l + 1);
tr[lc].add += tr[p].add;
tr[rc].add += tr[p].add;
tr[p].add = 0;
}
}
void uodata(int p,int x,int y,int k){
if(x <= tr[p].l&&tr[p].r <= y){
tr[p].sum += (tr[p].r - tr[p].l + 1)*k;//区间宽度*k
tr[p].add += k; // 标记这个点对叶子节点的亏欠
return ;
}
int m = tr[p].l + tr[p].r >> 1;
pushdown(p);
if(x <= m) updata(lc,x,y,k);
if(y > m) updata(rc,x,y,k);
pushup(p);
}
图文讲解
知识点巩固:
(明天晚上我会更新线段树和树状数组的区别)
标题二:主席树
好,这里也是园上了算法提升--二叉堆与树状数组-CSDN博客 这个章节最后的主席树了,splay我后面精进一些就会更新。
主席树也被成为可持久化的线段树
介绍前:我们先搞清每个线段树的特征
节点区间 | 序列的下标区间 | 序列的值域 |
节点维护的信息 | 区间最值,区间和 | 值域内树的出现次数 |
主席树(可持久线段树)的特征:支持回退,访问之前版本的线段树。
操作方法:每次只更改logn + 1个节点,对于没有改变的就用借用继承的方法,这里就有点像,洛谷里用二进制,满足异或的方法来走最大的数,我们都知道这可以用树状数组来写。
这里我们也应用一个名词 -- 动态开点。对于每个节点保存左右儿子的编号,对于每个历史版本要保留根节点编号。
我这里用董老师的图来说明:
大家看 ,t = log(n) + 1 = 3 ,改变3个节点 。我们枚举3次。
然后我们看到白色区间是没有动过的,我们就可以将这个图合并。
我说明一下别人都没有主要到的东西,这里我们不能用堆式存储法了(就是上面用的父节点i,左儿子2*i,右二子2*i + 1),为什么呢:
我们看上面的图:[1,4] 这个区间改变了,用堆式存储法,我们就不能做到访问之前的版本。
好,我们言归正传动态开点怎么做:
初始的我们建立一个2*n - 1个空间,然后我们就可以插入,每次最多增加logn + 1个节点。
sum = 2*n - 1 + n*(logn + 1) ;
代码实现:
#define lc(x) tr[x].ch[0]
#define rc(x) tr[x].ch[1]
int n,m,a[N];
vector<int> v;
struct node{
int ch[2];
int s;
}tr[N];
int root[N],idx;
void build(int &x,int l,int r){
x = ++idx;
if(l == r) return ;
int m = l + r >> 1;
build(lc(x),l,m);
build(rc(x),m + 1,r);
}
这里大家可能会想到,你修改了n次,你的空间不就扩大了n倍了吗。是的,所以我们会用离散化来解决,这个最后我来说明。
构建主席树:
方法:递归建立每个历史版本的线段树
怎么做呢:设两个指针;x,y. x是前一个版本的节点指针,y是当前版本的节点指针,通过子空间y值,给父空间的lc(y)或者rc(y)赋值。
代码实现:
void insert(int x,int &y,int l,int r,int v){
y = ++idx;tr[y] = tr[x];tr[y].s++;
if(l == r) return ;
int m = l + r >> 1;
if(v <= m) insert(lc(x),lc(y),l,m,v);
else insert(rc(x),rc(y),m + 1,r,v);
}
图文讲解:
这里我给大家解释一下节点左右儿子为什么会改变。
大家看图:
看到了每次在insert后y都会++,然后&引用符号会统一上下的y值这样就实现了对历史的维护,对现在的改变
查询:
就是在主席树上找区间[l,r]的第k小 (第k个小,我会发布一篇堆的方法)
简化:先找[1,r]区间的第k小,然后找到查入r时的历史版本,在权值线段树上用二分查找。然后用前缀和的方法;用[1,r] - [l,l - 1]的第k小,就是答案了。
代码实现:
int query(int x,int y,int l,int r,int k){
if(l == r) return l;
int m = l + r >> 1;
int s = tr[lc(y)].s - tr[lc(x)].s;
if(k <= s) return query(lc(x),lc(y),l,m,k);
else return query(rc(x),rc(y),m + 1,r,k - s);
}
主席树基本上每个函数都会有双指针同步搜索。
最后我们补上前面的坑----离散化来解决空间问题:
步骤(给大家一个口诀):排序,去重,二分找下标。
int getid(int x){
return lower_bound(v.begin(),v.end(),x) - v.begin() + 1;
}
void work(){
cin >> n;
for(int i = 1;i <= n;i++){
cin >> a[i];
v.push_back(a[i]);
}
sort(v.begin(),v.end());
v.erase(unique(v.begin(),v.end()),v.end());
}
最后告诉大家这样我们的时间复杂度就是O(n*log(n*n));
知识点应用:【模板】可持久化线段树 2 - 洛谷
标题三:二逼平衡树(树套树)
这里的章节涉及了splay的这个算法,我后天好吧给大家带来我对于splay算法的理解,明天我是给大家发布上面的第i小的堆方法的算法。
代码实现:
#define ls(x) tr[x].s[0]
#define rs(x) tr[x].s[1]
struct node{
int s[2];
int p;
int v;
int siz;
void init(int p1,int v1){
p = p1,v = v1,siz = 1;
}
}tr[N*40];
int n,m,w[N],idx;
void pushup(int x){
tr[x].siz = tr[ls(x)].siz + tr[rs(x)].siz + 1;
}
void rotate(int x){
int y = tr[x].p,z = tr[y].p;
int k = tr[y].s[1] == x;
tr[z].s[tr[z].s[1] == y] = x,tr[x].p = z;
tr[y].s[k] = tr[x].s[k^1],tr[tr[x].s[k^1]].p = y;
tr[x].s[k^1] = y;
tr[y].p = x;
}
void splay(int &root,int x,int k){
while(tr[x].p != k){
int y = tr[x].p,z = tr[y].p;
if(z != k){
if((rs(y)== x)^(rs(z) == y)) rotate(x);
else rotate(y);
}
rotate(x);
}
if(!k) root = x;
}
其实对于没有学习或者掌握不熟splay的同学,看过我写梳妆数组那一章节,也是可以看懂的.
查找某个区间中某个值的排名:
方法:线段树用来裂开,平衡树负责查找。把线段树不断裂开,遇到已覆盖的区间,在该区间平衡树中查找比某值小的元素个数,区间结果合并时,将小的元素个位求和。
代码实现:
int getrank(int root,int v){
int u = root,res = 0;
while(u){
if(tr[u].v < v){
res += tr[ls(u)].siz + 1;
u = rs(u);
}
else u = ls(u);
}
return res;
}
int queryrank(int u,int l,int r,int x,int y,int v){
if(x <= l && r <= y) return getrank(root[u],v) - 1;
int mid = l + r >> 1, res = 0;
if(x <= mid) res += queryrank(lc,l,mid,x,y,v);
if(y > mid) res += queryrank(rc,mid + 1,r,x,y,v);
}
这里考一下大家 为什么这里会有一个 -1?
那是因为我们初始化的时候插入了两个极端的数字,所以你要减去1;
查找区间中排名k的数值:
这里大家可能会直接想用上面学的分裂求排名,但这个分裂的区间的大小关系是不一样的,是不可能凑出来第k小的数字,除非题目可以这样安排,这就要回归以前的知识--- 二分查找。
代码实现:
int queryval(int u,int x,int y,int k){
int l = 0,r = 1e8,ans;
while(l <= r){
int mid = l + r >> 1;
if(queryrank(1,1,n,x,y,mid) + 1 <= k) l = mid + 1,ans = mid;
else r = mid - 1;
}
return ans;
}
修改某一个位置的数值:
void del(int &root,int v){
int u = root;
while(u){
if(tr[u].v == v) break;
if(tr[u].v < v) u = rs(u);
else u = ls(u);
}
splay(root,u,0);
int l = ls(u),r = rs(u);
while(rs(l)) l = rs(l);
while(ls(r)) r = ls(r);
splay(root,l,0);
splay(root,r,l);
ls(r) = 0;
splay(root,r,0);
}
void change(int u,int l,int r,int pos,int v){
del(root[u],w[pos]);
insert(root[u],v);
if(l == r) return ;
int mid = l + r >> 1;
if(pos <= mid) change(lc,l,mid,pos,v);
else change(rc,mid + 1,r,pos,v);
}
最后就是求前驱,后继的问题,这里的代码原理是和我前2个文章上已经有了介绍,这里就不做陈述了。
知识点巩固:【模板】树套树 - 洛谷
好,到这里我们的线段树的第一部分就这样结束了,其实线段树就已经结束了,之后的文章是用来补充线段树的杂碎知识,比如合并问题,和与其他算法连用的我的一些算法心得。