1.何为线段树
准确的来说,线段树是一种平衡二叉树,当我们的要换分的区间大小是2的幂的话,刚好我们的线段树就是一颗满二叉树,但是如果不是的话,我们最多只能叫他是平衡二叉树
正因为是一颗平衡二叉树,所以说,线段树的划分是均匀的,树高也稳定在logn上(这也就是我们的优化的源泉)
上面只是个人的一点小理解,下面我们步入正题
线段树Segment Tree,是BST二叉搜索树而一种应用,是一种应用广泛的高级数据结构
主要用来查找区间的覆盖问题等
定义:
线段树并不是如字面意思上来的说,我们每一个树中结点都保留一串数据,那样的话我们的空间复杂度就会变得难以想象了,在这里我们数据只有一份,但是我们的线段树中的节点保存的是
区间的范围
另外我们还会保存一些其他的数据域,这些都是看具体的题目的要求了
2.操作
作为一种数据结构,我们对线段树有以下几个基本操作的描述
1.建树
2.插入
3.修改
4.查询
5.先上维护数据域
6.向下继承lazy-tag(这个我们之后会讲解)
3.数据结构的选择
1.链式
我们一开始首先可以联想到线段树的链式存储方式,链式存储的优势在于我们可以动态分配内存,从而我们能够最大限度的利用的我们的空间,不会出现我们无法确定线段户的内存的大小的问题(之后会讲解为什么至少需要4倍内存空间)
但是对于链式存储来说,我们还需要额外开辟内存来保存指针变量,但是在数量很大并且捉摸不透的时候,采用链式存储是一个明智的选择
2.数组
在这里,我们的数组的存储方式是有些类似于二叉堆的存储的方式结构的
因为线段树和堆一样是一颗平衡二叉树,所以说对于节点的标号为n的话,n*2必定代表的是左儿子,n*2+1必定代表的是右儿子
这样的话,相对于链式存储我们可以节约出来两个指针域的内存空间,但是这样的话,我们就需要开辟至少4倍的内存空间的大小
在本文中,为了方便叙述,我们采用的是数组的形式
4.操作解析及复杂度:
数据
typedef struct node
{
int left; //区间左端
int right; //区间右端
int val; //区间数据域,按照题目要求可以修改
int lazy; //延迟标记 ,基本上是我们的对区间更新的数据,一般都会乘上我们的区间的长度饭换成我们的区间的数据域,我们一会可以解释
}point;
PS:小心为预算的优先级小于+,-
空间复杂度是O(4*n):
首先我们需要认识到
线段树作为一种平衡二叉树,当我们的最终的区间长度是n的时候,我们的线段树最少需要2*n-1的节点内存
但是,因为上述情况只有在我们的n是2的幂的时候才成立,但是当n不是2的幂的时候,我们因为在存储的时候,不是满二叉树
所以说实际上会存在内存的浪费,极限的情况下会需要远远比2*n-1大的内存
这时候,我们进行粗略的估计操作,我们找到比n大的最小的2的幂k,大致需要2*k-1的内存大小
但是在最糟糕的情况下,k的值可能几乎就是n的2倍
所以说,大致上我们开辟4*n的内存就完全够我们 的线段树的存储需要了
线段树作为一种平衡二叉树,当我们的最终的区间长度是n的时候,我们的线段树最少需要2*n-1的节点内存
但是,因为上述情况只有在我们的n是2的幂的时候才成立,但是当n不是2的幂的时候,我们因为在存储的时候,不是满二叉树
所以说实际上会存在内存的浪费,极限的情况下会需要远远比2*n-1大的内存
这时候,我们进行粗略的估计操作,我们找到比n大的最小的2的幂k,大致需要2*k-1的内存大小
但是在最糟糕的情况下,k的值可能几乎就是n的2倍
所以说,大致上我们开辟4*n的内存就完全够我们 的线段树的存储需要了
1.建树
建树操作,相当于我们前序遍历二叉树
我们对当前的节点进行初始化,然后依次的向下递归至我们的叶子节点
递归的终止是在于我们递归到的节点的区间为1,代表我们递归到了叶子节点,直接返回初始化并且更新就可以返回了
大致代码如下:
//建树操作 O(n)
void build(int left,int right,int pox) //pox代表的是线段树种的节点存储的物理顺序地址 ,初始的时候是1
{
tree[pox].left=left;
tree[pox].right=right;
tree[pox].lazy=0;
tree[pox].val=0; //数据域初始化,这一句可以根据题目要求随时来改变
if(left==right) //找到最底层叶子结点,我们更新fa访问数组强制返回
{
fa[left]=pox;
return ;
}
//递归操作,位运算加速
build(left,(left+right)/2,pox<<1);
build((left+right)/2+1,right,(pox<<1)+1);
}
时间复杂度是O(n),因为我们需要建立所有的叶子节点,故是O(n)的时间复杂度
2.查询
在这里,我们需要引入一个叫做完全覆盖的概念,这也是我们线段树优化的核心所在
如果我们将树中结点的信息都完全的保留在当前的树中结点的话,我们就完全没必要一直访问到叶子节点,我们找到完全福该节点的话,就可以直接的获取我们的需要的值然后退出就好了
//查询区间的数据域 O(logn)
int find(int left,int right,int pox)
{
int sum=0;
if(tree[pox].left==left&&tree[pox].right==right) return tree[pox].val; //找到了完全覆盖区间,直接返回,该句本身也是一个递归中值判断语句
pox<<=1;
if(tree[pox].right>=left)
{
if(right<=tree[pox].right) sum+=find(left,right,pox);
else sum+=find(left,tree[pox].right,pox);
}
pox+=1;
if(tree[pox].left<=right)
{
if(left>=tree[pox].left) sum+=find(left,right,pox);
else sum+=find(tree[pox].left,right,pox);
}
return sum;
}
因为查询的深度就是我们的线段树的额深度,所以说我们的时间复杂度就是 O(logn)
3.lazy-tag
之后的lazy-tag等问题就要牵扯到区间更新了
如果是区间更新的话,我们的朴素做法无非就是将区间整个的遍历一遍然后统一进行修改操作,时间复杂度是O(n)
但是对于线段树来说,如果我们每次都是一直递归到所有的叶子结点的话,我们也会发现,我们需要将所有的叶子结点
都要修改覆盖,时间复杂度也是O(n),这样我们就并没有实现复杂度上的优化,在这里我们就要引入lazy变量
可以就其本质上来说的话,父节点的区间是完全的包含我们的子节点的区间的,所以说,我们就只用父节点区间来代表子节点区间就好了
在这里,我们的引入的lazy变量就是这样的,我们将更新操作进行到完全覆盖的树中的节点的时候我们就退出,不对之后的叶子结点进行操作
但是我会对该节点是加一个lazy标记,代表我们的更新只进行到这里,该节点之后的子节点并没有执行更新操作
只有我们再次进行访问该节点额子节点的区间的时候,我们才会往下继续执行我们的更新操作,这样的话,通过完全覆盖的父区间,我们可以减少操作
的复杂度,降低到 O(logn),也就是最多我们也就执行到树的深度为止
但是对于线段树来说,如果我们每次都是一直递归到所有的叶子结点的话,我们也会发现,我们需要将所有的叶子结点
都要修改覆盖,时间复杂度也是O(n),这样我们就并没有实现复杂度上的优化,在这里我们就要引入lazy变量
可以就其本质上来说的话,父节点的区间是完全的包含我们的子节点的区间的,所以说,我们就只用父节点区间来代表子节点区间就好了
在这里,我们的引入的lazy变量就是这样的,我们将更新操作进行到完全覆盖的树中的节点的时候我们就退出,不对之后的叶子结点进行操作
但是我会对该节点是加一个lazy标记,代表我们的更新只进行到这里,该节点之后的子节点并没有执行更新操作
只有我们再次进行访问该节点额子节点的区间的时候,我们才会往下继续执行我们的更新操作,这样的话,通过完全覆盖的父区间,我们可以减少操作
的复杂度,降低到 O(logn),也就是最多我们也就执行到树的深度为止
所以说,我们的lazy-tag是非常有必要的
为了直白额表示我们上述的过程,附上一段代码
void pushdown_lazy(int pox) //传递lazy标记,并更新子区间的val,注意我们的pox节点已经将val域更新了
{
if(tree[pox].lazy) //该节点的lazy变量存在
{
tree[pox<<1].lazy+=tree[pox].lazy;
tree[pox<<1].val=tree[pox<<1].lazy*(tree[pox<<1].right-tree[pox<<1].left+1); //根据我们lazy的值修改我们的数据域,lazy的值代表我们扽区间修改的基
tree[(pox<<1)+1].lazy+=tree[pox].lazy;
tree[(pox<<1)+1].val=tree[(pox<<1)+1].lazy*(tree[(pox<<1)+1].right-tree[(pox<<1)+1].left+1);
tree[pox].lazy=0; //lazy标记清除
}
}
void pushup_val(int pox) //数据域的向上更新函数,一会在update_segment函数我再解释该函数以及上一个函数的额具体作用
{
tree[pox].val=tree[pox<<1].val+tree[(pox<<1)+1].val; //将左右区间的值合并生成该父区间的值
}
void update_segment(int left,int right,int pox,int k) //k代表区间更新的值,left和right代表的是当前需要更新的区间的范围,pox代表当前的树中节点的物理下标
{
if(tree[pox].left==left&&tree[pox].right==right) //完全覆盖的时候,我们就直接退出,这就是上面的叙述中的优化的本质,对lazy标记的应用
{
tree[pox].lazy+=k; //lazy打标成功
tree[pox].val=tree[pox].lazy*(tree[pox].right-tree[pox].left+1); //更新该节点的数据域
return ;
}
if(tree[pox].left==tree[pox].right) return ; //更新到了叶子结点,进行递归终止
//以下代码的运行的原因是还没有找到完全覆盖的树中节点,所以继续向下更新树
pushdown_lazy(pox); //没有找到完全覆盖的节点,向下查找完全覆盖的节点,并将我们的lazy标记传递下去,但是这里我们需要注意,pox节点的val域没有更新维护,还是空的
if(right<=tree[pox<<1].right) update_segment(left,right,pox<<1,k);
else if(left>=tree[(pox<<1)+1].left) update_segment(left,right,(pox<<1)+1,k);
else
{
update_segment(left,tree[pox<<1].right,pox<<1,k);
update_segment(tree[(pox<<1)+1].left,right,(pox<<1)+1,k);
}
pushup_val(pox); //上面已经说了,pox节点并没有更新维护,所以说,我们在回溯的时候必须要将我们的数据域更新,一直返还到我们的你根结点处,但是我们只要更新了,就必须要回溯的时候,对数据域必须要进行同步的更新
//当然该函数也可以封装在pushdown函数里面,这都无所谓了
}
上面一段的代码的作用是将一段子区间同时加上一个数的操作,主要的解释都已经注释了