参考资料:算法竞赛进阶指南-李煜东
https://www.luogu.com.cn/problem/solution/P3372
线段树是一种基于分治思想的二叉树结构,与树状数组相比,线段树更加通用。
其基本特点:
1.线段树每个节点都代表一个区间。
2.线段树具有唯一的根节点,代表的区间是整个统计范围,比如[1,N]
3.线段树每个叶节点都代表一个长度为1的元区间,[x,x]
4.对于每个内部节点[l,r]它的左子节点是[l,mid],右子节点是[mid+1,r],(其中mid=(l+r)/2(向下取整))
二叉树视角
(图片来源于网络)
上图展示了一棵线段树。可以发现,除去树的最后一层,整棵线段树一定是一棵完全二叉树,树的深度为O(log N)。因此,我们可以按照与二叉堆类似的“父子2倍”节点编号方法。
1.根节点编号为1.
2.编号为x的节点的左子节点编号为x*2,右子节点编号为x*2+1
这样一来,我们就能简单地用一个struct数组来保存你线段树。当然,树的最后一层节点在数组中保存的位置不是连续的,直接空出数组中多余的位置即可。在理想状况下,N个叶节点的满二叉树有 N+N/2+N/4+...+2+1=2N-1 个节点。因为在上述存储方式下,最后还有一层产生了空余,所以保存线段树的数组长度要不小于4N才能保证不会越界。
线段树的建树
线段树的基本用途是对序列进行维护,支持查询与修改指令。给定一个长度为N的序列A,我们可以在区间 [1,N] 上建立一棵线段树,每个叶节点 [i,i] 保存A[i] 的值。线段树的二叉树结构可以很方便的从下往上传递信息。以区间最大值问题为例,记dat(l,r)等于max{A[i]},显然 dat(l,r)=max(dat(l,mid),dat(mid+1,r))。
代码
struct SegmentTree {
int l,r;
int dat;
} t[SIZE * 4]; // struct 数组存储线段树
void build(int p, int l, int r){
t[p].l=l, t[p].r=r; // 节点p代表区间[l,r]
if (l==r) { t[p].dat = a[l]; return ;} // 叶节点
int mid = (l+r) / 2; // 折半
build(p*2, l, mid);
build(p*2+1, mid+1, r);
t[p].bat = max(t[p*2].dat, t[p*2+1].bat);
}
build(1, 1, n); //调用入口
线段树的单点修改
在线段树中,根节点是执行各种指令的入口。我们需要从根节点出发,递归找到代表区间 [x,x] 的叶节点,然后从下往上更新 [x,x] 以及它的所有祖先节点上保存的信息。时间复杂度为 O(log N).
代码
void change(int p, int x, int v){
if(t[p].l == t[p].r) { //找到叶节点
t[p].dat = v;
return ;
}
int mid = (t[p].l + t[p].r) / 2;
if(x <= mid) change(p*2,x,v); //x属于左半区间
else change(p*2+1,x ,v);
t[p].dat = max(t[p*2].dat, t[p*2+1].dat); //x属于右半区间
}
change(1,x ,v); //调用入口
线段树的区间修改
延迟标记
对于区间操作,我们每次遇到被 l,r 覆盖的区间可以直接将该节点的值添加到答案中。从分块的角度中我们可以证明,被询问的区间 [l,r] 线段树上会被分为 logN 个节点,从而在 logN 的时间求出答案,不过在区间操作中,区间 [l,r] 完全覆盖,那么该节点的全部子节点都会被改变,如果逐一进行更新,那么每次区间修改的时间复杂度会增加到 O(N)
在这里我们引进一个延迟标记,俗称懒标记(lazy tag)。
我们在找到被完全覆盖的节点后,如果将所有子节点更新一次,但是在查询时该节点被完全覆盖,根本用不到该节点的所有子节点,那么更新子节点就是徒劳的。所以我们可以将这个被完全覆盖的字节点打上懒标记,如果在下次修改或者查询操作时还需要用到该节点的子节点,我们就将懒标记下传,修改我们所需要的子节点。
在线段树里,无论是元素还是区间都是线段树上的一个节点,所以我们不需要区分区间还是元素,加个判断就好。
原本区间修改需要通过先改变叶子节点的值,然后不断地向上递归修改祖先节点直至到达根节点,时间复杂度最高可以到达 O(nlogn) 的级别。但当我们引入了懒标记之后,区间更新的期望复杂度就降到了 O(logn) 的级别且甚至会更低.
懒标记的正确打开方式
首先,懒标记的作用是记录每次、每个节点要更新的值。但线段树的优点不在于全记录(全记录依然很慢 qwq),而在于传递式记录:
整个区间都被操作,记录在公共祖先节点上;只修改了一部分,那么就记录在这部分的公共祖先上;如果只修改了自己的话,那就只改变自己。
如果我们需要用到以上优化方式的话,就需要在每次查询的时候pushdown一次,以免重复或者爆炸。
对于pushdown而言,只是pushup的逆向思维(但不是逆向操作),因为标记在父节点上,要由父节点下传 lazy tag
那么问题来了,怎么传导lazy tag呢?每次回溯时pushup是向上传递信息,那么要向下传递信息就改变顺序,向下递归是就pushdown好了!
代码
void pushdown(int p){
if(t[p].lan){ //节点p有标记
t[p*2].dat += (t[p*2].r- a[p*2].l + 1) * t[p].lan;//更新左子节点信息
//其中 *t[p].lan 因为p点可能被多次标记,所以每个标记都需要向下更新节点
t[p*2+1].dat += (t[p*2+1].r- t[p*2+1].l + 1) * t[p].lan;//更新右子节点
t[p*2].lan += t[p].lan; //给左子节点打标记
t[p*2+1].lan += t[p].lan; //给右子节点打标记
t[p].lan=0; //清除p的标记
}
}
void change(int p,int ls,int rs){ //区间修改,ls rs 为需要修改的区间 ,p为当前节点
if(t[p].l >= ls && t[p].r <= rs){//如果这个区间被完全覆盖
t[p].dat += (t[p].r - t[p].l + 1);//修改此节点的数据
t[p].lan++; //并打上懒标记,暂时不处理子节点,节省时间
return ;
}
pushdown(p); //回溯之前下传标记
//如果发现没有被覆盖,就需要向下寻找子节点,
//但考虑到子节点可能因为懒标记而没有被更新,所以需要下传标记
int mid = (t[p].l + t[p].r) / 2;
if(ls<=mid) aad(p*2,ls,rs);//如果修改的范围与左子节点有交集,就查找左子节点
if(rs>mid) aad(p*2+1,ls,rs);//右子节点同理
t[p].dat = t[p*2].dat + t[p*2+1].dat;//回溯,将子节点的信息向上传递
}
区间查询
单点查询只是区间查询的一个子问题,可以参考单点修改
从父节点开始查询区间,如果查询的区间被完全覆盖就直接返回维护的值,否则下传懒标记,将左右子节点的值累加起来。
具体看代码
int ask(int p, int ls, int rs){//ls,rs为查找区间
if(ls <= t[p].l && rs >= t[p].r) return t[p].dat;//区间被完全覆盖,返回维护值
pushdown(p); //如果没有被完全覆盖就下传懒标记
int mid = (t[p].l + t[p].r) / 2;
long long val = -10000000;
if(ls <= mid) val += ask(p*2, ls, rs);
//与区间修改同理,如果查找区间与左子节点有交集则查找左子节点
if(rs > mid) val += aks(p*2+1, ls, rs);// 累加左右子节点的值
return val;
}
懒标记的含义为“该节点曾被修改过,但其子节点未被更新”,因此,一个节点被打上懒标记后,应该及时将该节点的数值修改,所以在编写代码时,应注意“更新信息”与“打标记”之间的关系,避免出现错误。