文章目录
线段树简介
线段树(Segment Tree)经常出现在竞赛中,可以以 O ( l o g n ) O(logn) O(logn)的时间复杂度实现单点修改、单点查询、区间修改、区间查询等功能。实现相对于树状数组更复杂,但是原理相对来说更清晰。
一、关键要点
1. 整体结构
- 原数组: n u m s nums nums,长度为 n n n
- 树数组:
t
r
e
e
tree
tree,长度为
4
n
4n
4n (关于为什么取
4
n
4n
4n,可以查看相关文章:线段树数组到底是2n还是4n)。
树的根节点为 t r e e [ 0 ] tree[0] tree[0],节点 t r e e [ i ] tree[i] tree[i]的左右子节点分别是 t r e e [ 2 ∗ i + 1 ] tree[2*i+1] tree[2∗i+1]和 t r e e [ 2 ∗ i + 2 ] tree[2*i+2] tree[2∗i+2] - 树节点内部类: N o d e Node Node,(也可以使用几个数组代替,使用内部类相对直观)
- 代码如下:
private class Node{
// 区间左边界
int left;
// 区间右边界
int right;
// 节点对应区间和
int sum;
// 节点懒标记
int lazy;
}
*上述结构均不需要暴露给外部,因此都可以设为私有类型
2. 线段树的构建
线段树的构建分为两步,首先是变量的初始化,其次是在初始化完成后的进行相关计算,填充树的各节点的值。
变量的初始化
- 向构造方法中传入原数组 n u m s nums nums
- 根据 n u m s nums nums构建 t r e e tree tree数组,并初始化其值
- 调用build方法填充数据
- 代码如下:
public SegmentTree(int[] nums) {
// 保存原数组
this.nums = nums;
// 初始化tree数组
this.tree = new Node[4*nums.length];
for(int i = 0; i<this.tree.length; i++){
this.tree[i] = new Node();
}
// 调用build方法创建线段树
build(0,nums.length-1,0);
}
节点值的填充
- 采用递归方法填充树中的各节点值
- 针对非叶子节点
- 若此节点对应区间为 [ l e f t , r i g h t ] [left,right] [left,right], m i d mid mid是区间的中点,则其左右子节点对应的区间分别为 [ l e f t , m i d ] [left,mid] [left,mid]和 [ m i d + 1 , r i g h t ] [mid+1,right] [mid+1,right]
- 当前节点对应的区间和(
s
u
m
sum
sum)等于左右两子节点的区间和之和:
t r e e [ i ] . s u m = t r e e [ i ∗ 2 + 1 ] . s u m + t r e e [ i ∗ 2 + 2 ] . s u m tree[i].sum = tree[i*2+1].sum+tree[i*2+2].sum tree[i].sum=tree[i∗2+1].sum+tree[i∗2+2].sum
- 针对叶子节点
- 此节点区间左右边界重合( l e f t = = r i g h t left==right left==right),因此只对应原数组中的一个元素
- 当前节点对应区间和即为原数组中对应元素的值:
t r e e [ i ] . s u m = n u m s [ t r e e [ i ] . l e f t ] tree[i].sum = nums[tree[i].left] tree[i].sum=nums[tree[i].left]
- 代码如下:
/**
* @param index:
* @return: void
* @date: 2022/5/19 9:09
* @description: 更新指定节点值
*/
private void updateNode(int index){
this.tree[index].sum = this.tree[index*2+1].sum+this.tree[index*2+2].sum;
}
/**
* @param left:
* @param right:
* @param index:
* @return: void
* @date: 2022/5/19 9:10
* @description: 创建指定节点
*/
private void build(int left, int right, int index){
Node node = tree[index];
// 为节点赋值
node.left = left;
node.right = right;
// 生成区间值
// 若当前节点为叶子节点,则当前节点值为单点值
if(left == right){
node.sum = this.nums[left];
return;
}
// 若当前节点不为叶子节点,则当前节点的值需要由两个子节点的值相加而得
// 由于各个区间不重叠,因此不存在重复计算的情况
int mid = left + (right - left)/2;
// 建立左子节点
build(left,mid,index*2+1);
// 建立右子节点
build(mid+1, right,index*2+2);
// 更新当前节点的值
updateNode(index);
}
3. 区间修改和lazy标记
线段树中数据的修改包括单点修改和区间修改,单点修改即将原数组中某个位置的值替换为新的值,而区间修改则是将指定区间内的数加上或者减去某个值。单点修改只是区间修改的特殊情况,可以采用统一的方式解决。
在区间修改的过程中,线段树巧妙地采用lazy标记,将部分值的修改操作延迟到区间查询时执行。lazy标记对于线段树及其重要,可以极大的提升区间修改的速度!
设对于第 i i i个节点,要修改的区间为 [ l , r ] [l,r] [l,r],区间中元素需要加上 v a l val val
-
若要修改的区间和 i i i节点对应的区间不重合,则对于 i i i的子节点来说,会出现三种情况:
- 左子节点对应的区间可以完全覆盖 [ l , r ] [l,r] [l,r],则将修改操作传递给左子节点,被修改区间不变
- 右子节点对应的区间可以完全覆盖 [ l , r ] [l,r] [l,r],则将修改操作传递给右子节点,被修改区间不变
- 被修改区间在左右子节点对应的区间中皆有分布,若节点 i i i对应的区间的中点为 m i d mid mid,则将修改操作传递给左右子节点,被修改区间分别为 [ l , m i d ] [l,mid] [l,mid]和 [ m i d + 1 , r ] [mid+1,r] [mid+1,r]
在节点 i i i的子节点完成修改后,更新节点 i i i的值。
-
若要修改的区间和 i i i节点对应的区间重合,则无需再向下传递修改操作,直接修改当前节点的区间和,并添加lazy标记:
- 首先,统计区间中包含的元素的数目 n = r − l + 1 n=r-l+1 n=r−l+1
- 之后,将当前节点的区间和修改为 t r e e [ i ] . s u m = t r e e [ i ] . s u m + n ∗ v a l tree[i].sum = tree[i].sum+n*val tree[i].sum=tree[i].sum+n∗val,表示区间中每个数都加上了 v a l val val (子节点实际上没有修改)
- 最后,将当前节点的lazy标记设为 v a l val val
-
代码如下:
/**
* @param index:
* @param num:
* @return: void
* @date: 2022/5/19 10:02
* @description: 单点修改
*/
public void update(int index, int num){
if(index<0||index>=this.nums.length)return;
// 从根节点改起
num = num-this.nums[index];
modifySegment(index,index,num,0);
this.nums[index]+=num;
}
/**
* @param left:
* @param right:
* @param num:
* @return: void
* @date: 2022/5/19 10:13
* @description: 区间修改,将l到r中的所有值都加上num
* 若当前要修改的区间完全覆盖左子区间或者右子区间,则在左右子节点上加上懒标记,延迟修改
*/
public void update(int left, int right, int num){
// 将l到r区间内的值全部加上num
if(left > right || left <0|| right <0|| left >=this.nums.length|| right >=this.nums.length){
System.out.println("区间修改失败!");
return;
}
// 采用子函数进行修改
modifySegment(left, right,num,0);
// 更改原数组
for(int i = left; i<= right; i++)this.nums[i]+=num;
}
/**
* @param left:
* @param right:
* @param num:
* @param indNode:
* @return: void
* @date: 2022/5/19 10:22
* @description: 区间修改,采用懒标记延迟修改
*/
public void modifySegment(int left, int right, int num, int indNode){
// 获取当前节点
Node node = this.tree[indNode];
if(node.left == left &&node.right == right){
// 当前区间被完全覆盖
// 区间中有几个需要修改的值,区间就加几倍的num
node.sum += (right - left +1)*num;
// 添加懒标记,在查询时传递给子节点
node.lazy += num;
return;
}
// 若当前区间没有被完全覆盖,则根据具体情况判断
int mid = node.left +(node.right -node.left)/2;
if(mid>= right){
// 左子区间被完全覆盖,修改左子区间
modifySegment(left, right,num,indNode*2+1);
}else if(mid+1<= left){
// 右子区间被完全覆盖,修改右子区间
modifySegment(left, right,num,indNode*2+2);
}else{
// 左右子区间都有部分覆盖,两边都要修改
modifySegment(left,mid,num,indNode*2+1);
modifySegment(mid+1, right,num,indNode*2+2);
}
// 更新当前节点的值
updateNode(indNode);
}
*在完成子节点修改之后,由子节点区间和计算父节点区间和的过程被称为线段树的pushUp过程
4. 区间查询和lazy标记的下传
在区间修改时,采用了lazy标记实现部分修改的延迟处理,在区间查询时,就会对存在lazy标记的被查询区间进行标记下传(pushDown),并对产生lazy标记的区间进行实际修改。
pushDown操作
pushDown操作的目的就是执行区间修改中未实际执行的修改,并将lazy标记向子节点传递,若当前查询到节点 i i i,且节点 i i i具有lazy标记,则根据节点 i i i的类型,将操作分为两种情况:
- 若节点 i i i为不存在子节点的叶子节点,则lazy标记无需也无法下传,直接清除当前节点的lazy标记 t r e e [ i ] . l a z y = 0 tree[i].lazy=0 tree[i].lazy=0
- 若节点
i
i
i不为叶子节点,则操作分为三个步骤:
1. 根据节点 i i i的lazy标记,修改子节点的值,修改方式与区间修改相同,即:
新的区间和=区间中元素的个数X t r e e [ i ] . l a z y tree[i].lazy tree[i].lazy+原区间和
2. 向子节点传递lazy标记
t r e e [ i ∗ 2 + 1 ] . l a z y = t r e e [ i ] . l a z y tree[i*2+1].lazy = tree[i].lazy tree[i∗2+1].lazy=tree[i].lazy
t r e e [ i ∗ 2 + 2 ] . l a z y = t r e e [ i ] . l a z y tree[i*2+2].lazy = tree[i].lazy tree[i∗2+2].lazy=tree[i].lazy
3. 清除节点 i i i的lazy标记 t r e e [ i ] . l a z y = 0 tree[i].lazy = 0 tree[i].lazy=0 - 代码如下:
/**
* @param indNode:
* @return: void
* @date: 2022/5/19 11:02
* @description: 向下传递lazy标记
*/
private void pushDown(int indNode){
Node node = this.tree[indNode];
// 查看当前节点是否为叶节点
// 若为叶节点,则直接清除标记,不在下传
if(node.left ==node.right){
node.lazy = 0;
return;
}
// 传递lazy
Node left = this.tree[indNode*2+1];
Node right =this.tree[indNode*2+2];
// 将lazy传递给子节点
left.lazy = node.lazy;
right.lazy = node.lazy;
// 修改子节点的值
left.sum += (left.right - left.left +1)*left.lazy;
right.sum += (right.right -right.left +1)*right.lazy;
// 清除当前节点的lazy值
node.lazy = 0;
}
区间查询操作
设当前节点为 i i i,需要查询的区间为 [ l , r , ] [l,r,] [l,r,]
- 首先,判断当前节点是否存在lazy标记,若存在,则进行pushDown操作
- 之后,判断当前节点对应的区间是否和要查询的区间重合 t r e e [ i ] . l e f t = = l & & t r e e [ i ] . r i g h t = = r tree[i].left==l\&\&tree[i].right==r tree[i].left==l&&tree[i].right==r,若重合,则直接返回当前当前节点的区间和 t r e e [ i ] . s u m tree[i].sum tree[i].sum,否则进行下一步
- 判断待查询区间和当前节点对应区间的关系,判断标准与区间修改过程中的判断标准相同,判断完成后,对子节点进行查询操作,并由子节点的查询结果生成当前节点的查询结果
- 代码如下:
/**
* @param left:
* @param right:
* @return: int
* @date: 2022/5/19 11:11
* @description: 区间查询
*/
public int query(int left, int right){
if(left > right || left <0|| left >=this.nums.length|| right <0|| right >=this.nums.length){
System.out.println("输入区间不合法!");
return -1;
}
return querySegment(left, right, 0);
}
/**
* @param left:
* @param right:
* @param index:
* @return: int
* @date: 2022/5/19 11:18
* @description: 区间查询
*/
private int querySegment(int left, int right, int index){
Node node = this.tree[index];
// 判断是否需要传递lazy标记
if(node.lazy!=0){
pushDown(index);
}
// 查看当前区间是否可以直接返回
if(node.left == left &&node.right == right)return node.sum;
// 查询左右区间的值,并进行拼接
int mid = node.left +(node.right -node.left)/2;
if(right <=mid){
// 左子区间被完全包含
return querySegment(left, right,index*2+1);
}else if(left >mid){
// 右子区间被完全包含
return querySegment(left, right,index*2+2);
}
// 返回左右区间之和
return querySegment(left,mid,index*2+1)+querySegment(mid+1, right,index*2+2);
}
到此为止,线段树中所有的关键方法都已经介绍完毕,下面给出线段树的完整的Java实现
二、Java实现
public class SegmentTree {
// 用来保存节点的数组,以树的形式组织
// 根节点为0,n的左子节点为n*2+1,右子节点为n*2+2
private Node[] tree;
private int[] nums;
public SegmentTree(int[] nums) {
// 保存原数组
this.nums = nums;
// 初始化tree数组
this.tree = new Node[4*nums.length];
for(int i = 0; i<this.tree.length; i++){
this.tree[i] = new Node();
}
// 创建线段树
build(0,nums.length-1,0);
}
/**
* @param index:
* @return: void
* @date: 2022/5/19 9:09
* @description: 更新指定节点值
*/
private void updateNode(int index){
this.tree[index].sum = this.tree[index*2+1].sum+this.tree[index*2+2].sum;
}
/**
* @param left:
* @param right:
* @param index:
* @return: void
* @date: 2022/5/19 9:10
* @description: 创建指定节点
*/
private void build(int left, int right, int index){
Node node = tree[index];
// 为节点赋值
node.left = left;
node.right = right;
// 生成区间值
// 若当前节点为叶子节点,则当前节点值为单点值
if(left == right){
node.sum = this.nums[left];
return;
}
// 若当前节点不为叶子节点,则当前节点的值需要由两个子节点的值相加而得
// 由于各个区间不重叠,因此不存在重复计算的情况
int mid = left + (right - left)/2;
// 建立左子节点
build(left,mid,index*2+1);
// 建立右子节点
build(mid+1, right,index*2+2);
// 更新当前节点的值
updateNode(index);
}
/**
* @param index:
* @param num:
* @return: void
* @date: 2022/5/19 10:02
* @description: 单点修改
*/
public void update(int index, int num){
if(index<0||index>=this.nums.length)return;
// 从根节点改起
num = num-this.nums[index];
modifySegment(index,index,num,0);
this.nums[index]+=num;
}
/**
* @param left:
* @param right:
* @param num:
* @return: void
* @date: 2022/5/19 10:13
* @description: 区间修改,将l到r中的所有值都加上num
* 若当前要修改的区间完全覆盖左子区间或者右子区间,则在左右子节点上加上懒标记,延迟修改
*/
public void update(int left, int right, int num){
// 将l到r区间内的值全部加上num
if(left > right || left <0|| right <0|| left >=this.nums.length|| right >=this.nums.length){
System.out.println("区间修改失败!");
return;
}
// 采用子函数进行修改
modifySegment(left, right,num,0);
// 更改原数组
for(int i = left; i<= right; i++)this.nums[i]+=num;
}
/**
* @param left:
* @param right:
* @param num:
* @param indNode:
* @return: void
* @date: 2022/5/19 10:22
* @description: 区间修改,采用懒标记延迟修改
*/
public void modifySegment(int left, int right, int num, int indNode){
// 获取当前节点
Node node = this.tree[indNode];
if(node.left == left &&node.right == right){
// 当前区间被完全覆盖
// 区间中有几个需要修改的值,区间就加几倍的num
node.sum += (right - left +1)*num;
// 添加懒标记,在查询时传递给子节点
node.lazy += num;
return;
}
// 若当前区间没有被完全覆盖,则根据具体情况判断
int mid = node.left +(node.right -node.left)/2;
if(mid>= right){
// 左子区间被完全覆盖,修改左子区间
modifySegment(left, right,num,indNode*2+1);
}else if(mid+1<= left){
// 右子区间被完全覆盖,修改右子区间
modifySegment(left, right,num,indNode*2+2);
}else{
// 左右子区间都有部分覆盖,两边都要修改
modifySegment(left,mid,num,indNode*2+1);
modifySegment(mid+1, right,num,indNode*2+2);
}
// 更新当前节点的值
updateNode(indNode);
}
/**
* @param indNode:
* @return: void
* @date: 2022/5/19 11:02
* @description: 向下传递lazy标记
*/
private void pushDown(int indNode){
Node node = this.tree[indNode];
// 查看当前节点是否为叶节点
// 若为叶节点,则直接清除标记,不在下传
if(node.left ==node.right){
node.lazy = 0;
return;
}
// 传递lazy
Node left = this.tree[indNode*2+1];
Node right =this.tree[indNode*2+2];
// 将lazy传递给子节点
left.lazy = node.lazy;
right.lazy = node.lazy;
// 修改子节点的值
left.sum += (left.right - left.left +1)*left.lazy;
right.sum += (right.right -right.left +1)*right.lazy;
// 清除当前节点的lazy值
node.lazy = 0;
}
/**
* @param left:
* @param right:
* @return: int
* @date: 2022/5/19 11:11
* @description: 区间查询
*/
public int query(int left, int right){
if(left > right || left <0|| left >=this.nums.length|| right <0|| right >=this.nums.length){
System.out.println("输入区间不合法!");
return -1;
}
return querySegment(left, right, 0);
}
/**
* @param left:
* @param right:
* @param index:
* @return: int
* @date: 2022/5/19 11:18
* @description: 区间查询
*/
private int querySegment(int left, int right, int index){
Node node = this.tree[index];
// 判断是否需要传递lazy标记
if(node.lazy!=0){
pushDown(index);
}
// 查看当前区间是否可以直接返回
if(node.left == left &&node.right == right)return node.sum;
// 查询左右区间的值,并进行拼接
int mid = node.left +(node.right -node.left)/2;
if(right <=mid){
// 左子区间被完全包含
return querySegment(left, right,index*2+1);
}else if(left >mid){
// 右子区间被完全包含
return querySegment(left, right,index*2+2);
}
// 返回左右区间之和
return querySegment(left,mid,index*2+1)+querySegment(mid+1, right,index*2+2);
}
/**
* @date: 2022/5/19 9:01
* @description: 线段树上的节点,在数组中以数组位置确定父子关系
* 节点中存储区间左右边界(闭区间),区间内元素和,区间懒标记
*/
private class Node{
// 区间左边界
int left;
// 区间右边界
int right;
// 节点对应区间和
int sum;
// 节点懒标记
int lazy;
}
}
总结
线段树不仅可以用于区间和查询,区间和修改等场景,经适当修改后,还可以应对区间最大值,区间乘积等问题,是一种非常有用的数据结构,但是线段树的实现有许多要点,只有经过多次练习,才能把它用得炉火纯青。