线段树:区间树:逻辑上的二分法,以空间换时间完成一段区间上的add,update,query任务
提示:线段树是非常非常强大的优化技能,难,但是捋清逻辑,就不难了
重要的基础知识:
【1】堆,系统堆结构,手动改写堆结构,堆结构远比比堆排序更重要
题目
问题引入:请你设计一个数据结构,搞定下面的几个功能。
给你一个数组arr,请你在给定的L–R这段区间范围上的完成这三种任务:
【1】add(L,R,C),在arr的L–R区间上整体每一个元素都 加 C,
【2】update(L,R,C),把arr的L–R区间上整体每一个元素都更新为C,
【3】query(L,R),请返回arr的L–R区间上整体每一个元素累加和。
笔试AC:暴力解理解题目
笔试也不可能考这么简单的题目,这都是为面试准备的!
暴力解o(R-L)速度即,o(n)速度遍历搞,简单而高效
简单
给你一个数组
arr=1 1 1 1 1
i =0 1 2 3 4
记住,为了方便你理解,咱们不用0下标
新搞一个数组把arr向右挪一个位置
arr=1 1 1 1 1
i =1 2 3 4 5
设计一个暴力数据结构Right:
不妨设
【1】add(1,2,2),在arr的L–R区间上整体每一个元素都 加 2,
之后arr=3 3 1 1 1
【2】update(1,5,5),把arr的L–R区间上整体每一个元素都更新为5,
之后arr=5 5 5 5 5
【3】query(1,5),请返回arr的L–R区间上整体每一个元素累加和。
返回ans= 5*5=25
直接用arr暴力遍历搞定
//首先,笔试中,直接暴力解,o(N^2)复杂度
public static class Right{
public int[] arr;//拿来重新搞数组,不要0坐标
public Right(int[] origin){
//初始化,Right结构
arr = new int[origin.length + 1];//咱不用0下标
for (int i = 0; i < origin.length; i++) {
arr[i + 1] = origin[i];
}
}
//add任务,L--R,全部加一个C
public void add(int L, int R, int C){
for (int i = L; i <= R; i++) {
arr[i] += C;
}
}
//update, L--R,全部更新为C
public void update(int L, int R, int C){
for (int i = L; i <= R; i++) {
arr[i] = C;
}
}
//query, L--R,查询这个范围的累加和
public int query(int L, int R){
int res = 0;
for (int i = L; i <= R; i++) {
res += arr[i];
}
return res;
}
//非常非常容易,但是复杂度为O(N)
}
public static void test(){
int[] origin = {1,1,1,1,1};
Right right = new Right(origin);
right.add(1, 2, 2);//1--2上全部加一个2
for (int i = 1; i < right.arr.length; i++) {
System.out.print(right.arr[i] +" ");
}
System.out.println();
right.update(1, 5, 5);//1--5上全部更新为2
for (int i = 1; i < right.arr.length; i++) {
System.out.print(right.arr[i] +" ");
}
System.out.println();
int res = right.query(1, 5);//1--5上所有元素累加和
System.out.println(res);
}
结果:
3 3 1 1 1
5 5 5 5 5
25
线段树:区间数,利用逻辑上的二分法把arr转化为二叉树
咱们这么想:
你暴力遍历需要o(n)速度,咱可否利用额外空间,预存一些范围内的累加和信息呢?
说白了,打表搞一个sum数组,直接一开始,就把所有1–N范围内的任意l–r范围的累加和放到sum里面,
方便来一个L–R查询累加和时,咱们直接二分去查这个sum
l–r范围内的信息,咱放sum的一个下标i里面,这样简洁一些,满二叉树可以搞定这个对应关系
上图中
5 6 7 8下标分别代表arr的1 2 3 4位置元素,和就是1 1 1 1
2下标代表1–2范围内的累加和,就是5 6 节点的和2
3下标代表3–4范围内的累加和,就是7 8 节点的和2
1下标代表1–4范围的累加和,就是2 3节点的和4
那么,反过来看,1–4范围内二分,为1-2,3-4,再二分下去,基本把各个区间的和都准备好了
你如果查L–R=1-2的累加和,咱们直接从头结点开始查,下面二分去找,1–2的累加和就是2,速度很快,o(log(n)速度
这个事情的本质就是:用额外空间,帮助加速查询累加和,达到优化时间复杂度的目的。
这个事情的本质就是:用额外空间,帮助加速查询累加和,达到优化时间复杂度的目的。
这个事情的本质就是:用额外空间,帮助加速查询累加和,达到优化时间复杂度的目的。
众所周知,但凡想要算法快,就要耗费额外空间,但凡想节约空间,就需要拖慢算法的速度。
这个做法类似于堆:
当年咱们把数组heap,逻辑上想象为一个完全二叉树,如何访问呢?就是通过下标的关系
下文讲得很清楚:
【1】堆,系统堆结构,手动改写堆结构,堆结构远比比堆排序更重要
从heap数组的i=0位置,开始,往下推
左子left=2i+1
右子right=2i+2
今天,咱们为了不那么复杂,咱们arr的下标i,不要0,从1开始记,看下图
因此左子和右子
分别为2i和2i+1
是不很简单!
当然,咱们今天要讲得线段树,它和堆不一样,堆可以是完全二叉树,但是今天的线段树是满二叉树,
因为我们要利用二分法,把arr的1–N范围,2分为一段一段的区间,每一段,代表arr的l–r范围【小写l和r哦】,
我们待会要用额外的数组p来记录区间l–r上的信息,这个信息p,就是咱们要干的3个任务的标记信息【先不说啥信息】
这里信息是p数组,p[i]就是整体l–r范围上的信息,下面我们会解释p信息具体是啥。
头结点下标为i=1,它代表arr的l–r范围,其实是固定的是1–N范围,N是arr的长度
往下分,mid=(l+r)/2
下标2节点代表l–mid范围,下标3节点代表mid+1–r范围
下标4节点又是二分2节点的范围,下面蓝色l–mid,mid+1–r
下标5节点又是二分3节点的范围,下面粉色L–mid,mid+1–r
……
直到所有叶节点都是一个节点,就代表一个整体范围
比如下图右边那个二叉树,N=15个节点,i=1开始,它代表arr的l-r=1–15范围内的信息,然后不断地推二叉树的下标们
每个左子和右子的下标,都代表来自父节点l–r范围内整体的一半信息,l–mid,mid+1–r
这里信息是p数组,p[i]就是整体l–r范围上的信息,下面我们会解释p信息具体是啥。
okay,你知道了吧,i从1开始,往下递推下标,其实代表的是arr的特定的l–r范围的信息
我们利用额外p数组,p[i]就是整体l–r范围上的信息
现在,给你一个数组arr,请你在给定的L–R这段区间范围上的完成这三种任务:
【1】add(L,R,C),在arr的L–R区间上整体每一个元素都 加 C,
【2】update(L,R,C),把arr的L–R区间上整体每一个元素都更新为C,
【3】query(L,R),请返回arr的L–R区间上整体每一个元素累加和。
以上三个任务,每次调用任务时,咱们都把任务发到头结点,i=1位置,它代表的是l–r=1–N范围,因此让上面仨函数,统统带上i,l,r三个固定的参数,这仨参数是固定不变的,
当然了,递推下标i时,它代表的lr是变化的,这仨参数又是可变的。
L,R,C是不同任务范围和操作的C,仨都是可变的参数。
我们把线段树定义为SegmentTree类,它有arr,有信息p数组,有三大任务函数,下面挨个说
给定arr,准备信息p数组
我们说了,不用0下标,所以arr需要扩到N+1长度,把0–N-1转移到1–N上来
信息p可能是下面几种数组:
(1)sum数组:对应下标i处放信息:sum[i],它代表此时arr的l–r范围内的累加和【每次任务都可能让sum变化】
(2)lazy数组:对应下标i处放信息:lazy[i],它代表此时arr的l–r范围内揽下了一个增加任务,增加的值是多少呢?C,【揽下发音类似于lazy,所以取名为lazy,方便记忆】
(3)change数组:对应下标i处放信息:change[i],它代表此时arr的l–r范围内揽下了更新任务,更新后arr的l–r统统为多少?全是C
(4)isUpdated数组:配合change数组用,当有更新任务时,isUpdated[i]=true,代表l–r范围内就有更新任务,更新的具体任务看change数组,默认情况isUpdated[i]=false,表示当前l–r范围内没有揽下更新任务
以上所有p数组,他们的长度是多少呢?4N
为啥呢?
我们说过,这个二叉树是一个满二叉树,如果arr长度为N,则二分下去,就需要N个叶节点,非叶节点N-1个,总共就是2N
那为啥要4N?
情况是这样的,万一,长度是N没法组合出满二叉树,恰巧就在刚刚满的二叉树得叶节点右边多一个点
比如,N=8好说,1–8恰好能搞出一个满二叉树来
但是N=9呢,这9的话,就得额外把二分范围扩充到1–16了,这样仍然是可以满二叉树,也就是arr的N必须是2的x次方才行
看下图右边那种arr,9长度的,你就得额外多准备很多空间
故叶节点至少2N个节点,非业节点,2N-1个,总体4N-1个,那么p数组信息长为4N即可
好,下面我们来准备p信息数组们
//然后再,面试中,要考虑用线段树,将复杂度降到o(logN)这样子提升身段
public static class SegmentTree{
public int[] arr;//转移新数组,不要坐标0
public int[] sum;//起初就要建树,线段树的真个树的累加和,二分下去,左右收集
public int[] lazy;//增加任务的,任务记录信息
public int[] change;//update任务的记录信息
public boolean[] isUpdated;//配合change任务的Boolean数组
//首先拿到数组,先转移到N+1长度的数组中,然后开始build sum信息,目前这个数组arr就可以整好当前所有sum
public SegmentTree(int[] origin){
//传一个origin数组进来
int N = origin.length + 1;//不用0下标,简单
arr = new int[N];
for (int i = 0; i < origin.length; i++) {
arr[i + 1] = origin[i];//转移好
}
sum = new int[4 * N];
lazy = new int[4 * N];
change = new int[4 * N];
isUpdated = new boolean[4 * N];
}
给定arr,创建累加和sum信息
有了上面的准备,最开始来了一个arr
咱们就要把最初的sum累加和信息构建好
说白了就是让你构建每个二分区间的累加和,耗费了这么多额外空间,但是一旦问你L–R上累加和是多少?你直接o(log(n))速度二分下去查累加和,返回来给出答案。这样的话,咱就不要遍历L–R上的元素相加了【o(n)速度慢】
所以,arr一来,咱就递归去填写sum数组
递归过程中,遇到i位置怎么填?很简单,左子2i和右子2i+1的sum信息,综合加起来,即使i的sum信息
下图粉色下标那,你看看,2位置和是2,3位置和是2,故1位置作为父节点,就是左子2和右子3的和,2+2=4
代码这么玩:
//然后定义汇总sum的build函数,把左子右子收集出来
public void sumUp(int i){
//当前rt左右子的信息,我当前rt就代表l--r
sum[i] = sum[i << 1] + sum[i << 1 | 1];//2i和2i+1的信息汇总出来,放在我rt
}
这里的下标,你想快速运算,就用位运算
任意下标汇总sum有了
那让你在l–r范围内汇总,你会了吧
先去左边递归收集左边部分的信息,然后去右边收集右边部分的信息
最后汇总sum给i
递归终止条件,l=r时,就一个元素,返回它就是了比如,下图5 6 7 8这种节点,就一个元素,返回arr[l]=arr[r]都行的
代码很容易:
//一开始构建sum数组
public void build(int l, int r, int i){
//从l--r范围上填写信息,rt代表l--r,实际调用直接就1--N,i==1位置开始
if (l == r) {
sum[i] = arr[l];//直接填叶节点的值
return;
}
//否则先去左边一半填写,再去右边一半填写,最后汇总
int mid = (l + r) >> 1;//一半
build(l, mid, i << 1);//左子
build(mid + 1, r, i << 1 | 1);//右子,建好了以后
//汇总
sumUp(i);//左右子相加返回sun[i]中放着即可
}
这种递归咱们玩实在太多了。
mid下标的计算,你想快速求则用位运算:
是不是很容易?
add(L,R,C,l,r,i)任务
来了新任务L–R上整体每个元素加C,
咱们把这个任务散播到头结点,也就是整个1–N范围上,让二分法往下找,找到L–R段上,让他们加C
具体来到l–r上时,
(1)如果L–R恰好包住l–r,即L<=l && r<=R,说明l–r上直接可以揽住这个任务,不要再往下发了,咱直接在l–r上做加C的任务【然后汇总信息往父节点报,调用 sumUp(i);】
(0)每次干任务之前,检查一下之前有没有来过任务,之前有任务的话,我接不住,要把这个旧任务给左右子干,然后我才能接当前这个新任务,调用sendTaskDown(int i, int ln, int rn),i的左子,右子,分别干ln和rn个,替我分忧。
(2)mid=(l+r)/2,如果L<=mid,说明L–R的任务还需要让左子树知道,故需要下发给左子树l–mid范围,即调用add(L,R,C,l,mid,i)
(3)mid=(l+r)/2,如果R>mid,说明L–R的任务还需要让右子树知道,故需要下发给右子树mid+1–r范围,即调用add(L,R,C,mid+1,r,i)
比如arr=1 1 1 1
一开始咱就有了sum= 4 2 2 1 1 1 1
如果你来了新任务add(L=1,R=2,C=1,l=1,r=4,i=1)
调用任务时l=1,r=4,i=1固定的不变
LRC是可变的
okay,咱们需要在L–R=1–2上每个都加C=1,看下图
(1)如果L–R恰好包住l–r,即L<=l && r<=R,说明l–r上直接可以揽住这个任务,不要再往下发了,咱直接在l–r上做加C的任务
这里r=4,显然不满足(1)条件,需要下发,发给谁呢?
(0)每次下发任务之前,检查一下之前有没有来过任务,之前有任务的话,我接不住,要把这个旧任务给左右子干,然后我才能接当前这个新任务,调用sendTaskDown(int i, int ln, int rn),i的左子,右子,分别干ln和rn个,替我分忧。【咱们现在不讲,你就知道这个意思就行,目前我i是空手,可以接新任务】
(2)mid=(l+r)/2,如果L<=mid,说明L–R的任务还需要让左子树知道,故需要下发给左子树l–mid范围,即调用add(L,R,C,l,mid,i)
这里,mid=(1+4)/2=2,L=1<mid=2,显然左边需要知道这个任务,下发给左边,调用add(L=1,R=2,C=1,l=1,r=mid,i=1)
(3)mid=(l+r)/2,如果R>mid,说明L–R的任务还需要让右子树知道,故需要下发给右子树mid+1–r范围,即调用add(L,R,C,mid+1,r,i)
这里,mid=2,因为R=2,R不>mid=2哦,所以右子不需要知道这个任务,它们不干这事。
上面调用add(L=1,R=2,C=1,l=1,r=mid,i=1)调用之后
l=1,r=2,L=1,R=2
恰好满足(1):如果L–R恰好包住l–r,即L<=l && r<=R,说明l–r上直接可以揽住这个任务,不要再往下发了,咱直接在l–r上做加C的任务
此时让sum[i=2] += (r-l+1)×C = 2×1,这就是咱们要给l–r段增加的累加和。
同时,咱们要让lazy[i=2] += C,代表l–r段,增加了C任务——lazy就是拿来记录增加任务这件事的,增加了谁?C
干完这个任务,注意要逐层返回给上面的节点们,也就是汇总信息,告诉父节点,我这范围改改变,你们也跟着变!
调用 sumUp(i);
懂了吧,反正来了L–R加C的任务,
从头结点,开始下发,然后咱们去找,哪些l–r段确实需要接下这个任务,下发任务之前,检查之前我还有旧任务吗?有旧任务话,我需要左右子分担,然后我才能揽住新任务,揽住了任务之后,干活,干完标记lazy,标记完汇总信息告诉父节点们,我变了!你们也变。
okay,撸代码这样的:
//然后可以定义add任务
//从当前l--r,i==1位置操作,往L--R上增加C;lazy新任务来了
public void add(int L, int R, int C, int l, int r, int i){
//若果任务范围包了当前lr范围,则直接加
if (L <= l && r <= R){
lazy[i] += C;//递增,因为可能之前也有增加任务
sum[i] += (r - l + 1) * C;//累加这么多
return;
}
//没有包,先把当前范围的任务下发,有的话
//再去左右发当前的任务,最后汇总
int mid = (l + r) >> 1;
//先检查下发任务
sendTaskDown(i, mid - l + 1, r - mid);//先给左右子发之前攒下的任务
if (L <= mid) add(L, R, C, l, mid, i << 1);//如果任务范围还在mid左边,则左边需要接你的任务
if (R > mid) add(L, R, C, mid + 1, R, i <<1 | 1);//任务范围R在mid右边,则右子需要接任务
//汇总
sumUp(i);
}
搞定之后,情况是这样的,1 2 位置各加1,1–2范围内多了2,1–4整体也是多了2,下面绿色那些变化的数
update(L,R,C,l,r,i)任务
更新任务,和add如出一辙!
来了新任务L–R上整体每个元素,直接更新为C,就是变C,
咱们把这个任务散播到头结点,也就是整个1–N范围上,让二分法往下找,找到L–R段上,让他们整体变C
具体来到l–r上时,
从头结点,开始下发,然后咱们去找,哪些l–r段确实需要接下这个任务,下发任务之前,检查之前我还有旧任务吗?有旧任务话,我需要左右子分担,然后我才能揽住新任务,揽住了任务之后,干活(这个活,是更新任务哦),干完标记lazy,change和isChanged,标记完汇总信息告诉父节点们,我变了!你们也变。
(1)如果L–R恰好包住l–r,即L<=l && r<=R,说明l–r上直接可以揽住这个任务,不要再往下发了,咱直接在l–r上做 变 C的任务【然后汇总信息往父节点报,调用 sumUp(i);】
(0)每次干任务之前,检查一下之前有没有来过任务,之前有任务的话,我接不住,要把这个旧任务给左右子干,然后我才能接当前这个新任务,调用sendTaskDown(int i, int ln, int rn),i的左子,右子,分别干ln和rn个,替我分忧。
(2)mid=(l+r)/2,如果L<=mid,说明L–R的任务还需要让左子树知道,故需要下发给左子树l–mid范围,即调用update(L,R,C,l,mid,i)
(3)mid=(l+r)/2,如果R>mid,说明L–R的任务还需要让右子树知道,故需要下发给右子树mid+1–r范围,即调用update(L,R,C,mid+1,r,i)
比如arr=1 1 1 1
一开始咱就有了sum= 4 2 2 1 1 1 1
如果你来了新任务update(L=1,R=2, C=2 ,l=1,r=4,i=1)
调用任务时l=1,r=4,i=1固定的不变
LRC是可变的
okay,咱们需要在L–R=1–2上每个都变C=2,看下图
(1)如果L–R恰好包住l–r,即L<=l && r<=R,说明l–r上直接可以揽住这个任务,不要再往下发了,咱直接在l–r上做变C的任务
这里r=4,显然不满足(1)条件,需要下发,发给谁呢?
(0)每次下发任务之前,检查一下之前有没有来过任务,之前有任务的话,我接不住,要把这个旧任务给左右子干,然后我才能接当前这个新任务,调用sendTaskDown(int i, int ln, int rn),i的左子,右子,分别干ln和rn个,替我分忧。【咱们现在不讲,你就知道这个意思就行,目前我i是空手,可以接新任务】
(2)mid=(l+r)/2,如果L<=mid,说明L–R的任务还需要让左子树知道,故需要下发给左子树l–mid范围,即调用update(L,R,C,l,mid,i)
这里,mid=(1+4)/2=2,L=1<mid=2,显然左边需要知道这个任务,下发给左边,调用update(L=1,R=2,C=2,l=1,r=mid,i=1)
(3)mid=(l+r)/2,如果R>mid,说明L–R的任务还需要让右子树知道,故需要下发给右子树mid+1–r范围,即调用update(L,R,C,mid+1,r,i)
这里,mid=2,因为R=2,R不>mid=2哦,所以右子不需要知道这个任务,它们不干这事。
上面调用update(L=1,R=2,C=2,l=1,r=mid,i=1)调用之后
l=1,r=2,L=1,R=2
恰好满足(1):如果L–R恰好包住l–r,即L<=l && r<=R,说明l–r上直接可以揽住这个任务,不要再往下发了,咱直接在l–r上做变C的任务
此时让sum[i=2] = (r-l+1)×C = 2×1,这不是sum+=哦!!!而是sum=,更新操作,直接变,变成了新的l–r段的累加和。
同时,咱们要让lazy[i=2] = 0,代表l–r段,之前增加了的任务全部废了,因为我是update操作
同时,咱们要让change[i] = C,代表l–r段,来了一个新的更新为c的操作——这个C跟lazy类似,记录更新操作
通知,咱们要让isUpdated[i] = true,代表l–r段,确实有更新操作——配合change用
干完这个任务,注意要逐层返回给上面的节点们,也就是汇总信息,告诉父节点,我这范围改改变,你们也跟着变!
调用 sumUp(i);
代码手撕:
//然后定义update任务
public void update(int L, int R, int C, int l, int r, int i){
if (L <= l && r <= R){
isUpdated[i] = true;
change[i] = C;
sum[i] = (r - l + 1) * C;//更新任务只看自己,不是递增的关系
lazy[i] = 0;//本节点的增加任务被废了
return;
}
//没有包范围的话,需要先将攒的任务下发,再发本次任务
int mid = (r + l) >> 1;
//先检查下发任务
sendTaskDown(i, mid - l + 1, r - mid);//分别往左右发这么多
if (L <= mid) update(L, R, C, l, mid, i << 1);
if (R > mid) update(L, R, C, mid + 1, r,i << 1 | 1);
//左右需要的话,下方本任务,然后汇总
sumUp(i);
}
干完活,sum变了
检查l–r是否曾经接过旧任务,有就需要左右子分担
上面的add和update操作,在来了新任务之后,
都干了一件事:
从头结点,开始下发,然后咱们去找,哪些l–r段确实需要接下这个任务,下发任务之前,检查之前我还有旧任务吗?有旧任务话,我需要左右子分担,然后我才能揽住新任务,揽住了任务之后,干活(这个活,是更新任务哦),干完标记lazy或者change和isChanged,标记完汇总信息告诉父节点们,我变了!你们也变。
这里面的这个条件:
(0)每次下发任务之前,检查一下之前有没有来过任务,之前有任务的话,我接不住,要把这个旧任务给左右子干,然后我才能接当前这个新任务,调用sendTaskDown(int i, int ln, int rn),i的左子,右子,分别干ln和rn个,替我分忧。【咱们之前不讲,现在来讲】
目的是想让我i腾空手,只有目前我i是空手,才可以接新任务
检查怎么检查?无非就是检查是否有更新任务?是否有增加任务?
而且是先检查更新任务,因为更新任务会废掉增加任务,前面增加任务再多都没卵用,一段来了更新任务,直接以更新任务为核心任务
add和update调用时,调用sendTaskDown(i, mid - l + 1, r - mid);//分别往左右发这么多
左边mid-l+1个,左子承担这么多
右子承担r-mid个
检查下发函数如下:
首先检查update任务:
把左右子的sum,lazy,change,和isChanged数组全部检查变为我i的任务。
分发下去后,我isChanged=false,代表我i是空手的。
其他不管,因为add和update来了直接变sum,lazy,change,会覆盖的。
再检查add任务:
只需要把左右子的sum和lazy变我i的信息就行
分发下去之后,我lazy[i] = 0,代表我i从此是空手了。
//然后定义提前检查下发任务的函数,把更新任务和增加任务先下发给左右子
//pushDown方法,先查更新任务,因为更新任务优先级高,再查增加任务
public void sendTaskDown(int i, int ln, int rn){
//在rt上看,有之前攒下来的任务,需要下发的,左子有多少个呢,ln,右子有多少个节点呢,ln
//左子右子都要承担这些任务,信息更新到sum,lazy和change与isUpdated中
//操作的范围是l--r,当前代表坐标是rt
//左子承担多少个mid-l + 1个,不管是加,还是更新
//右子承担多少个r-mid 个,不管是加还是更新
if (isUpdated[i]){
//如果本节点之前有任务揽住了,需要先下发给下面的范围,左子右子都需要干的任务,再接新的
//是更新的,左子右子都得更新,啥增加,啥更新任务都清掉
//更新是颠覆性的,所有sum改变,lazy清零
sum[i << 1] = ln * change[i];//我rt有一个更新过的任务,下面所有人都要叠加这么多个
sum[i << 1 | 1] = rn * change[i];//左右子都是
lazy[i << 1] = 0;//清零,方便接新的任务
lazy[i << 1 | 1] = 0;//清零,接新任务
change[i << 1] = change[i];
change[i << 1 | 1] = change[i];//承担我的
isUpdated[i << 1] = true;//标记已经承担过更新任务
isUpdated[i << 1 | 1] = true;//标记左右子都承担过update任务
isUpdated[i] = false;//分发完以后,跟我无关了,意思是我清了
}
//再看看有没有之前揽过的增加任务,也需要干
if (lazy[i] != 0){
//不为0就说明我有增加任务,需要下发,注意,下发是+=,
// 因为下面的那些可能还有多个任务,所以是递增的
lazy[i << 1] += lazy[i];
lazy[i << 1 | 1] += lazy[i];
sum[i << 1] += ln * lazy[i];//左子整体增加
sum[i << 1 | 1] += rn * lazy[i];//右子也整体增加
lazy[i] = 0;//清空我任务
}
}//所有任务干活之前,都需要把之前揽住的任务下发,再去看别的,再汇总,所以这个是通用的代码
query(L,R,l,r,i)任务
query就更简单了,不干活,直接查,就是二分法查而已
但是查之前,往下分发任务,轮到谁干,你干完,把信息给我,返回sum
来了新任务L–R上整体每个元素,查sum,返回,
咱们把这个任务散播到头结点,也就是整个1–N范围上,让二分法往下找,找到L–R段上,查到sum返回
具体来到l–r上时,
从头结点,开始下发,然后咱们去找,哪些l–r段确实需要接下这个任务,下发任务之前,检查之前我还有旧任务吗?有旧任务话,我需要左右子分担,然后我才能揽住新任务,揽住了任务之后,干活(这个活,是返回累加和的任务哦)直接返回就行。
(1)如果L–R恰好包住l–r,即L<=l && r<=R,说明l–r上直接可以揽住这个任务,不要再往下发了,直接返回sum[i]
(0)每次干任务之前,检查一下之前有没有来过任务,之前有任务的话,我接不住,要把这个旧任务给左右子干,然后我才能接当前这个新任务,调用sendTaskDown(int i, int ln, int rn),i的左子,右子,分别干ln和rn个,替我分忧。
准备ans=0,去收集左右的信息,融合加起来给ans,最后作为返回结果
(2)mid=(l+r)/2,如果L<=mid,说明L–R的任务还需要让左子树知道,故需要下发给左子树l–mid范围,即调用query(L,R,l,mid,i)
(3)mid=(l+r)/2,如果R>mid,说明L–R的任务还需要让右子树知道,故需要下发给右子树mid+1–r范围,即调用query(L,R,mid+1,r,i)
(4)ans=(2)+(3),返回ans;
比如arr=1 1 1 1
一开始咱就有了sum= 4 2 2 1 1 1 1
如果你来了新任务query(L=1,R=2,l=1,r=4,i=1)
调用任务时l=1,r=4,i=1固定的不变
LRC是可变的
okay,咱们需要在L–R=1–2上每个都变C=2,看下图
(1)如果L–R恰好包住l–r,即L<=l && r<=R,说明l–r上直接可以揽住这个任务,不要再往下发了,咱直接在l–r上做变C的任务
这里r=4,显然不满足(1)条件,需要下发,发给谁呢?
(0)每次下发任务之前,检查一下之前有没有来过任务,之前有任务的话,我接不住,要把这个旧任务给左右子干,然后我才能接当前这个新任务,调用sendTaskDown(int i, int ln, int rn),i的左子,右子,分别干ln和rn个,替我分忧。【咱们现在不讲,你就知道这个意思就行,目前我i是空手,可以接新任务】
准备ans=0,去收集左右的信息,融合加起来给ans,最后作为返回结果
(2)mid=(l+r)/2,如果L<=mid,说明L–R的任务还需要让左子树知道,故需要下发给左子树l–mid范围,即调用query(L,R,l,mid,i)
这里,mid=(1+4)/2=2,L=1<mid=2,显然左边需要知道这个任务,下发给左边,调用query(L=1,R=2,l=1,r=mid,i=1)
(3)mid=(l+r)/2,如果R>mid,说明L–R的任务还需要让右子树知道,故需要下发给右子树mid+1–r范围,即调用query(L,R,mid+1,r,i)
这里,mid=2,因为R=2,R不>mid=2哦,所以右子不需要知道这个任务,它们不干这事。
上面调用query(L=1,R=2,l=1,r=mid,i=1)调用之后
l=1,r=2,L=1,R=2
恰好满足(1):如果L–R恰好包住l–r,即L<=l && r<=R,说明l–r上直接可以揽住这个任务,不要再往下发了,咱直接返回sum[i=2]=2
最终ans+=2,返回ans
整体查到1–2上的累加和就是ans=2;
撸一下代码:
//然后定义query==查询一段累加和任务
public long query(int L, int R, int l, int r, int i){
if (L <= l && r <= R){
//任务刚好包了就直接算
return sum[i];
}
//每包就需要左右收集,先下发攒过的任务
int mid = (r + l) >> 1;
//先检查下发任务
sendTaskDown(i, mid - l + 1, r - mid);//左右分发多少个
long ans = 0;
if (L <= mid) ans += query(L, R, l, mid, i << 1);
if (R >mid) ans += query(L, R, mid + 1, r, i << 1 | 1);
return ans;
}
到此,query就轻而易举搞定了
速度非常快,每次往下查二叉树,二分法就是o(log(n))的速度!比暴力遍历快。
线段树的操作思路,三个任务都一样
(1)从头结点,开始下发任务,然后咱们去找,哪些l–r段确实需要接下这个任务,
(2)下发任务之前,检查之前我还有旧任务吗?有旧任务话,我需要左右子分担,然后我才能揽住新任务,
(3)揽住了任务之后,干活(这个活,可能是增加,可能是更新,也可能是返回累加和的任务哦),
(4)是add和update任务,则干完标记lazy,change和isChanged,标记完汇总信息告诉父节点们,我变了!你们也变。
(5)是query任务需要去左右树收集累加和信息,然后加到ans上,直接返回ans即可
这就是线段树:区间数,是很复杂,你要捋清咱们的目的,是用额外空间,帮助加速查询累加和,达到优化时间复杂度的目的。
手撕代码,遇到题目的时候,可以想想是否和区间操作有关,有的话,可以用这个方法
虽然很难!
手撕线段树的代码:
//这个事情的本质就是:用额外空间,帮助加速查询累加和,达到优化时间复杂度的目的。
public static class SegmentTree{
//成员属性
//(1)sum数组:对应下标i处放信息:sum[i],它代表此时arr的l–r范围内的累加和【每次任务都可能让sum变化】
public int[] sum;
//(2)lazy数组:对应下标i处放信息:lazy[i],它代表此时arr的l–r范围内揽下了一个增加任务,
// 增加的值是多少呢?C,【揽下发音类似于lazy,所以取名为lazy,方便记忆】
public int[] lazy;
//(3)change数组:对应下标i处放信息:change[i],它代表此时arr的l–r范围内揽下了更新任务,
// 更新后arr的l–r统统为多少?全是C
public int[] change;
//(4)isUpdated数组:配合change数组用,当有更新任务时,isUpdated[i]=true,
// 代表l–r范围内就有更新任务,更新的具体任务看change数组,默认情况isUpdated[i]=false,
// 表示当前l–r范围内没有揽下更新任务
public boolean[] isChanged;
//转移arr0到arr
public int[] arr;//不要0下标
//构造函数
//构造的时候,就告知arr是啥,我好抹掉0下标
public SegmentTree(int[] arr0){
//先抹掉下标0
arr = new int[arr0.length + 1];
int N = arr.length;
for (int i = 0; i < arr0.length; i++) {
arr[i + 1] = arr0[i];
}
//现在开始都是操作arr,1--N
//初始化成员属性,4N宽度
sum = new int[N << 2];//x<<1=2x
lazy = new int[N << 2];//x<<1=2x
change = new int[N << 2];//x<<1=2x
isChanged = new boolean[N << 2];//x<<1=2x
}
//首次给你arr,请你用递归构建满二叉树,把累加和sum构建出来
//从arr的l--r上构建
//先建左,再建右,最后综合建我的
public void build(int l, int r, int i){
//从i=1开始填写,i=0这个位置的信息就代表l--r范围上的累加和sum
//base case,l==r,就是每一个元素触底
if (l == r) {
sum[i] = arr[l];//取lr均可
return;
}
//左右构建,并求和上来
int mid = l + ((r - l) >> 1);
build(l, mid, i << 1);//i<<1=2i
build(mid + 1, r, i << 1 | 1);//i<<1 | 1=2i+1
//然后把左右子,汇总给我sum[i]--不管啥任务,都是围绕变动sum而进行的
sumUp(i);//把i下标的左右子汇总给我i
}
//把i下标的左右子汇总给我i
public void sumUp(int i){
sum[i] = sum[i << 1] + sum[i << 1 | 1];
}
//成员方法
//检查l–r是否曾经接过旧任务,有就需要左右子分担
//从头结点,开始下发,然后咱们去找,哪些l–r段确实需要接下这个任务,下发任务之前,
// 检查之前我还有旧任务吗?有旧任务话,我需要左右子分担,然后我才能揽住新任务,
// 揽住了任务之后,干活(这个活,是更新任务哦),干完标记lazy或者change和isChanged,sum
public void checkPreTaskDown(int i, int ln, int rn){
//把i位置之前接的任务,分发下去给左子和右子分担,i上的任务时r-L+1个,那
//左子承担mid - l + 1个
//右子承担r-mid个
//不过先查update任务,它可以干废add任务
//更新任务涉及sum,change,isChanged,lazy
if (isChanged[i]){//之前i的l--r范围内有任务,下发
lazy[i << 1] = 0;//左右子的add任务,被update废了
lazy[i << 1 | 1] = 0;//左右子的add任务,被update废了
sum[i << 1] = ln * change[i];//change中放着i更新为谁了,你现在就可以分发
sum[i << 1 | 1] = rn * change[i];//change中放着i更新为谁了,你现在就可以分发
change[i << 1] = change[i];//左子右子的change完全承接i
change[i << 1 | 1] = change[i];//左子右子的change完全承接i
isChanged[i << 1] = true;//左子右子的isChanged完全承接i
isChanged[i << 1 | 1] = true;//左子右子的isChanged完全承接i
//然后更新任务重要的一步就是废掉add,同时自己的update也清空
isChanged[i] = false;//我i的update任务没了
lazy[i] = 0;
}
//后检查add任务
//add任务涉及sum,lazy
if (lazy[i] != 0){//确实接过任务,干
sum[i << 1] += ln * lazy[i];//加过的C给左右子
sum[i << 1 | 1] += ln * lazy[i];//加过的C给左右子
lazy[i << 1] += lazy[i];//lazy记录纸i的l--r要加的C,给左右子
lazy[i << 1 | 1] += lazy[i];//lazy记录纸i的l--r要加的C,给左右子
lazy[i] = 0;//然后清掉我i的任务
}
}
//add(L,R,C,l,r,i)任务
public void add(int L, int R, int C, int l, int r, int i){
//带着L--R上要加C的任务,来到i位置,i位置代表l--r范围内的信息,决定分下去还是不分?
//(1)如果L–R恰好包住l–r,即L<=l && r<=R,说明l–r上直接可以揽住这个任务,
// 不要再往下发了,咱直接在l–r上做加C的任务【然后汇总信息往父节点报,调用 sumUp(i);】
if (L <= l && r <= R){
//干
sum[i] += (r - l + 1) * C;
lazy[i] += C;//增加任务,叠加的关系
return;//base case结束,返回,看看左右子还需要下发吗,不需要就汇总信息返回
}
//(0)每次干任务之前,检查一下之前有没有来过任务,之前有任务的话,我接不住,
// 要把这个旧任务给左右子干,然后我才能接当前这个新任务,
// 调用sendTaskDown(int i, int ln, int rn),i的左子,右子,分别干ln和rn个,替我分忧。
int mid = l + ((r - l) >> 1);
checkPreTaskDown(i, mid - l + 1, r - mid);
//(2)mid=(l+r)/2,如果L<mid,说明L–R的任务还需要让左子树知道,
// 故需要下发给左子树l–mid范围,即调用add(L,R,C,l,mid,i)
if (L <= mid) add(L, R, C, l, mid, i << 1);
//(3)mid=(l+r)/2,如果R>mid,说明L–R的任务还需要让右子树知道,故需要下发给右子树mid+1–r范围,
// 即调用add(L,R,C,mid+1,r,i)
if (R > mid) add(L, R, C, mid + 1, r, i << 1 | 1);
//下发完最新任务给左右子,汇总左右子信息返回给我sum
sumUp(i);
}
//update(L,R,C,l,r,i)任务
public void update(int L, int R, int C, int l, int r, int i) {
//带着L--R上要变C的任务,来到i位置,i位置代表l--r范围内的信息,决定分下去还是不分?
//(1)如果L–R恰好包住l–r,即L<=l && r<=R,说明l–r上直接可以揽住这个任务,不要再往下发了,
// 咱直接在l–r上做 变 C的任务【然后汇总信息往父节点报,调用 sumUp(i);】
if (L <= l && r <= R){
//干,整体更新,覆盖增加任务
sum[i] = (r - l + 1) * C;
lazy[i] = 0;//更新任务直接会废掉增加任务的
change[i] = C;//增加任务,叠加的关系
isChanged[i] = true;//标记i的l--r范围内有更新任务
return;//base case结束,返回,看看左右子还需要下发吗,不需要就汇总信息返回
}
//(0)每次干任务之前,检查一下之前有没有来过任务,之前有任务的话,我接不住,
// 要把这个旧任务给左右子干,然后我才能接当前这个新任务,
// 调用sendTaskDown(int i, int ln, int rn),i的左子,右子,分别干ln和rn个,替我分忧。
int mid = l + ((r - l) >> 1);
checkPreTaskDown(i, mid - l +1, r - mid);
//(2)mid=(l+r)/2,如果L<mid,说明L–R的任务还需要让左子树知道,
// 故需要下发给左子树l–mid范围,即调用update(L,R,C,l,mid,i)
if (L <= mid) update(L, R, C, l, mid, i << 1);
//(3)mid=(l+r)/2,如果R>mid,说明L–R的任务还需要让右子树知道,
// 故需要下发给右子树mid+1–r范围,即调用update(L,R,C,mid+1,r,i)
if (R > mid) update(L, R, C, mid + 1, r, i << 1 | 1);
//下发完最新任务给左右子,汇总左右子信息返回给我sum
sumUp(i);
}
//query(L,R,l,r,i)任务
public int query(int L, int R, int l, int r, int i) {
//带着L--R上要查找L--R上累加和的任务,来到i位置,i位置代表l--r范围内的信息,决定分下去还是不分?
//(1)如果L–R恰好包住l–r,即L<=l && r<=R,说明l–r上直接可以揽住这个任务,不要再往下发了,
// 直接返回sum[i]
if (L <= l && r <= R) {
//干,整体找到了这个范围,直接返回sum[i],代表覆盖l--r的arr的累加和
return sum[i];
}
//(0)每次干任务之前,检查一下之前有没有来过任务,之前有任务的话,我接不住,
// 要把这个旧任务给左右子干,然后我才能接当前这个新任务,
// 调用sendTaskDown(int i, int ln, int rn),i的左子,右子,分别干ln和rn个,替我分忧。
int mid = l + ((r - l) >> 1);
checkPreTaskDown(i, mid - l +1, r - mid);
//准备ans=0,去收集左右的信息,融合加起来给ans,最后作为返回结果
int ans = 0;
//(2)mid=(l+r)/2,如果L<mid,说明L–R的任务还需要让左子树知道,
// 故需要下发给左子树l–mid范围,即调用query(L,R,l,mid,i)
if (L <= mid) ans += query(L, R, l, mid, i << 1);
//(3)mid=(l+r)/2,如果R>mid,说明L–R的任务还需要让右子树知道,
// 故需要下发给右子树mid+1–r范围,即调用query(L,R,mid+1,r,i)
if (R > mid) ans += query(L, R, mid + 1, r, i << 1 | 1);
//(4)ans=(2)+(3),返回ans;
return ans;
}
}
测试一下:
public static void test(){
int[] arr = {1,1,1,1,1};
System.out.println("原始数组");
for(Integer i : arr) System.out.print(i +" ");
System.out.println();
SegmentTree segmentTree = new SegmentTree(arr);
System.out.println("不要下标0的数组");
for(Integer i : segmentTree.arr) System.out.print(i +" ");
System.out.println();
int[] sum = segmentTree.sum;
int N = segmentTree.arr.length;
System.out.println("arr的sum数组");
segmentTree.build(1, N - 1, 1);//从i=1开始构建arr的1--N范围
for(Integer i : sum) System.out.print(i +" ");
System.out.println();
//在1--2上整体加1看看,arr的l--r=1,代表线段树的下标时i=1,固定不变的
segmentTree.add(1, 2, 1, 1, N - 1, 1);
sum = segmentTree.sum;
for(Integer i : sum) System.out.print(i +" ");
System.out.println();
//在1--5上整体变5看看,arr的l--r=1,代表线段树的下标时i=1,固定不变的
segmentTree.update(1, 5, 5, 1, N - 1, 1);
sum = segmentTree.sum;
for(Integer i : sum) System.out.print(i +" ");
System.out.println();
//查询1--5上arr的累加和,arr的l--r=1,代表线段树的下标时i=1,固定不变的
int ans = segmentTree.query(1, 5, 1, N - 1, 1);
System.out.println(ans);
}
public static void main(String[] args) {
test();
}
跟测暴力累加那个一样
原始数组
1 1 1 1 1
不要下标0的数组
0 1 1 1 1 1
arr的sum数组
0 5 3 2 2 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 7 5 2 4 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 25 5 2 4 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0
25
不容易的,这个线段树,代码是真的很复杂,你需要抠清楚细节,自己手撕代码
我是学了2遍,复习了不下3遍,今天自己捋清这个线段树,然后我自己亲自又手撕了一遍代码
这个代码不是看着谁抄的,就是我闭眼手写出来。
为什么要逼自己手写代码???
很简单,因为字节跳动的招聘笔试题目,它就要你闭眼写代码!你没办法用idea,你也不可能照着别人的代码抄,统统自己手写
你今天不辛苦逼自己手撕代码,到时候,上了考场,你不会,怎么进互联网大厂?
不知道面试会不会考这么难,万一考,你起码把思想说清楚,让面试官知道你会这个数据结构与算法
面试时大概率不会让你手撕线段树的所有代码,但是你要熟悉每个函数的功能,知道核心思想,至少你要会手撕一个add,update或者query的其中一个函数
总结
提示:重要经验:
1)线段树区间树,就是利用额外空间加速时间,通过二分法的形式,以最快的速度下发任务,查询任务,完成区间操作
2)面试时大概率不会让你手撕线段树的所有代码,但是你要熟悉每个函数的功能,知道核心思想,至少你要会手撕一个add,update或者query的其中一个函数
3)笔试求AC,可以不考虑空间复杂度,但是面试既要考虑时间复杂度最优,也要考虑空间复杂度最优。