线段树
在写主要内容之前先说说线段树应用的背景:
- 考核最多的高级数据结构
- 从初级水平到中级水平的标志:在掌握线段树之后才能说“我真正进入了算法竞赛的大门。”
- 应用背景:区间修改、区间查询、区间合并。
1、区间最值问题RMQ(Range Minimum/Maximum Query)
长度为n的数列{a1,a2,…,an}
- 求最值:给定i,j <= n, 求{ai,…,aj}区间内的最值。
- 修改元素:给定k和x,把a[k]改成x
(1)暴力法
- 用普通数组存储数列:
查询最值:区间内的最值,复杂度O(n)
修改元素:复杂度 O(1)
暴力法复杂度:如果有m次“修改元素+查询最值”,总复杂读O(mn)。m,n>10^5, O(mn) >10^10。
(2)高效的办法:线段树
- 用线段树,对n个数进行m次“修改元素+查询最值”,复杂度:O(mlogn)
- 线段树:一种用于区间处理的数据结构。
- 基于二叉树。
(3)把数列放在二叉树上
- 例:查询{1, 2, 5, 8, 6, 4 ,3}的最小值
- 首先,把数放在二叉树上:
- 每个结点上的数字是这个结点的子树的最小值。
- 那么经思考,查询某个区间的最小值,只需要O(logn)次。
- 思考:如何在二叉树上定位某个区间?
2、线段树的构造
- 线段树是建立在线段(或者区间)基础上的树,树的每个结点代表一条线段(或者称为区间)[L, R]。
- 例:线段[1, 5]的线段树。
- 线段[L, R]:L是左子结点,R是右子结点。
(1)L = R。它是一个叶子结点。
(2)L < R。有两个儿子,左儿子代表区间[L, M],右儿子代表区间[M+1, R],其中M = (L + R) / 2。
【线段树的复杂度】
- 每步处理,从二叉树的根结点开始到最下一层,最多需要更新 log4n 个结点,复杂度 O(logn);
- 一共有 n 个数字需要处理,总复杂度 O(nlogn)。
- 线段树把 n 个数按二叉树进行分组,每次更新有关的结点时,这个结点下面的所有子结点都隐含被更新了,从而大大地减少了处理次数。
最多需要更新log4n个结点是因为,线段树采取的时静态数组,若为完全二叉树,需要多开辟一行,故为4n。
【区间查询】
- 区间查询问题(最值、区间和)是线段树的一个基本应用场景。
- 以数列{1, 4, 5, 8, 6, 2, 3, 9, 10, 7}为例。
- 首先建立一棵用完全二叉树实现的线段树,用于查询任意子区间的最小值。
- 每个结点上圆圈内的数字是这棵子树的最小值。
- 圆圈旁边的数字,例如根结点的"1:[1,10]",1表示结点的编号,[1,10]是这个结点代表的元素范围,即第1到第10个元素。
查询任意区间[i, j]的最小值
- 例:查区间[4, 9]的最小值
- 递归查询到区间[4, 5]、[6, 8]、[9, 9],见图中画横线的线段,得最小值min{6, 2, 10} = 2。查询在O(logn)时间内完成。
- 线段树高效的原因:每个结点的值,代表了以它为根的子树上所有结点的值。查询这个子树的值时,不必遍历整棵树,而是直接读这个子树的根。
- m次“单点修改+区间查询”的总复杂度O(mlogn)。
用数组tree[]实现一颗满二叉树。每个结点的左右儿子:
- 左儿子:p<<1,即p2。
例:根tree[1]的左儿子是tree[2],结点tree[12]的左儿子是tree[24]。 - 右儿子:p<<1|1,即p*2+1。
例:根tree[1]的右儿子是tree[3],结点tree[12]的左儿子是tree[25]。
#定义根结点是tree[1],即编号为1的结点是根
tree = [0]*(N<<2) #用tree[i]记录线段i的最值或区间和
#父子关系,p是父
tree[p<<1] #左儿子,编号 p*2
tree[p<<1|1] #右儿子,编号 p*2+1
3、线段树的修改
- 点修改:在线段树中每次只修改一个点
- 区间修改:每次修改一个区间的所有数
(1) 重点:区间修改
- 给定n个元素{a1, a2, … , an} :
- 修改(加):给定i, j<=n,把{ai, …, aj}区间内的每个元素加v。
- 查询:给定L, R<=n,计算{aL, …, aR}的区间和。
多的不说,上例题:
下面来说解题思路和步骤
-
初始建一颗空树
-
build(p,pl,pr):p是tree[p],即建立以tree[p]为根的一棵子树,它代表区间[pl, pr]。
-
build()函数是一个递归函数,递归到最底的叶子结点,赋初始值tree[p] = -INF,即一个极小的值。本题是求最大值,把每个结点赋值为极小。
-
建树用二分法,从根结点开始逐层二分到叶子结点。
def build(p,pl,pr): if pl == pr: tree[p] = -INF return mid = (pl+pr)>>1 build(p<<1,pl,mid) #左儿子 build(p<<1|1,mid+1,pr) #右儿子 tree[p] = max(tree[p<<1],tree[p<<1|1]) #push_up
-
push_up:体现了线段树的精髓。
-
作用:把底层的值递归返回,赋值给上层结点。线段树的每个结点,代表了以这个结点为根的子树的最大值(本题是求最大值,有的题目是求区间和)
-
push_up利用递归函数的回溯,完成了这一任务。
-
-
更新线段树
-
update(p,pl,pr,L,R,d)是通用模板。p表示结点tree[p],pl是左子树,pr是右子树。区间[L, R]是需要更新的区间。d是修改或更新。
-
本题的更新功能是新增一个结点,有两个步骤。
1)把这个结点放在二叉树的叶子上。在本题中这样使用:
update(1, 1, N, cnt, cnt, (x+t)%D);
作用:把[cnt, cnt]区间的值赋值为(x+t)%D。
因为[cnt, cnt]这个区间只包含tree[cnt]一个结点,所以它是对新增叶子结点tree[cnt]赋值。
2)新增这个结点导致它上层结点的变化,需要把变化上传到上层结点。通过push_up,把变化递归到上层。3)把这个结点放在二叉树的叶子上。update(1, 1, N, cnt, cnt, (x+t)%D
def update(p,pl,pr,L,R,d): if L<=pl and pr<=R: tree[p] = d return mid = (pl+pr)<<1 if L<=mid
-
-
查询
-
查询区间[L, R]的最大值。函数query(p, pl, pr, L, R)查询以p为根的子树,这棵子树内区间[L, R]的最大值。
1)如果这棵子树完全被[L, R]覆盖,也就是说这棵子树在要查询的区间之内,那么直接返回tree[p]的值。这一步体现了线段树的高效率。如果不能覆盖,那么需要把这棵子树二分,再继续下面两步的查询。
2)如果L与左部分有重叠。
3)如果R与右部分右重叠。def query(p,pl,pr,L,R): res = -INF if L<=pl and pr<=R: return tree[p] mid = (pl+pr)<<1 if L <= mid: res = max(res,query(p<<1,pl,mid,L,R)) if R > mid: res = max(res,query(p<<1|1,mid,pr,L,R)) return res
-
完整代码:
N = 100001
INF = 0X7FFFFFFF
tree = [0]*(N<<2) #4倍
def build(p,pl,pr):
if pl==pr:
tree[p] = -INF
return
mid=(pl+pr)>>1
build(p<<1,pl,mid) #p<<1 是左儿子
build(p<<1|1,mid+1,pr) #p<<1|1是右儿子
tree[p] = max(tree[p<<1],tree[p<<1|1]) #push_up
def update(p,pl,pr,L,R,d):
if L<=pl and pr<=R:
tree[p]=d
return
mid =(pr+pl)>>1
if L<=mid: update(p<<1,pl,mid,L,R,d)
if R> mid: update(p<<1|1,mid+1,pr,L,R,d)
tree[p]=max(tree[p<<1],tree[p<<1|1])
return
def query(p,pl,pr,L,R):
res = -INF
if L<=pl and pr<=R: return tree[p]
mid=(pl+pr)>>1
if L<=mid: res=max(res,query(p<<1,pl,mid,L,R))
if R> mid: res=max(res,query(p<<1|1,mid+1,pr,L,R))
return res
m,D = map(int,input().split())
build(1,1,N) #不用build(),这样写也行:update(1,1,N,1,N,-INF)
cnt=0
t=0
for i in range(m):
op = list(input().split())
if op[0]=='A':
cnt+=1
update(1,1,N,cnt,cnt,(int(op[1])+t)%D)
if op[0]=='Q':
t=query(1,1,N,cnt-int(op[1])+1,cnt)
print(t)
注:目前这种方法几乎只能用单点修改,因为在进行update时,只修改了本层和上层的点,但是该节点下层的点并没有修改,但是在做题时用这种方式做单点修改非常便捷。那么为解决这个问题,我们引出线段树的核心技术:lazy-tag
4、线段树的核心技术:lazy-tag
- 背景:区间修改
- 简单区间修改,例如对一个数列的[L, R]区间内每个元素统一加上d。
如果在线段树上用“单点修改”一个个修改这些元素,一次区间修改O(nlogn) ,还不如直接暴力修改的O(n)。 - 解决办法,利用线段树的特征:线段树的结点tree[i],记录了i这个区间的值。
再定义一个tag[i],用它统一记录i这个区间的修改,而不是一个个修改区间内的每个元素
这个办法被称为“lazy-tag”。一次区间修改O(logn)。
(1) lazy-tag(懒惰标记,或者延迟标记)
- 修改一个线段区间时,只对这个线段区间进行整体修改(把修改记录在子树的根结点上),其内部每个元素的内容先不做修改。
- 当这个线段区间的一致性被破坏时,才把变化值传递给下一层。
- 复杂度:每次区间修改的复杂度是O(logn)的。
新的update()
update()中的lazy-tag
例:把[4, 9]区间内的每个元素加3:
(1)左子树递归到结点5,即区间[4, 5],完全包含在[4, 9]内,打标记tag[5] = 3,更新tree[5]为20,不再继续深入;
(2)左子树递归返回,更新tree[2]为30;
(3)右子树递归到结点6,即区间[6, 8],完全包含在[4, 9]内,打标记tag[6]=3,更新tree[6]为23。
(4)右子树递归到结点14,即区间[9, 9],打标记tag[14]=3,更新tree[14]=13;
(5)右子树递归返回,更新tree[7]=20;继续返回,更新tree[3]=43;
(6)返回到根结点,更新tree[1]=73。
给[4,9]区间内每个元素加3 流程图
这里需要注意一个点:
-
如果发生多次修改同一个节点上的tag会发生多次修改,导致冲突。
-
例如做2次区间修改,一次是[4, 9],一次是[5, 8],它们都会影响5:[4, 5]这个结点。
第一次修改[4, 9]覆盖了结点5,用tag[5]做了记录;
第二次修改[5, 8]不能覆盖结点5,需要再向下搜到结点11:[5, 5],从而破坏了tag[5],此时原tag[5]记录的区间统一修改就不得不往它的子结点传递和执行了,传递后tag[5]失去了用途,需要清空。
(2) push-down
- lazy-tag的主要操作是解决多次区间修改的冲突,用push_down()函数完成。
- 它首先检查结点p的tag[p],如果有值,说明前面做区间修改时给p打了tag标记,接下来就把tag[p]传给左右子树,然后把tag[p]清零。
- push_down()函数不仅在“区间修改”中用到,在“区间查询”中同样用到。
区间修改,又来了
带lazy-tag的基本操作
-
build()
def build(p,pl,pr): #建树 if p1 == pr: tree[p] = a[pl] mid = (pl+pr)>>1 build(p>>1,pl,mid) build(p>>1|1,mid+1,pr) tree[p] = tree[p>>1] + tree[p>>1|1] #push_up(p)
死记就行了
-
update()
update()函数更新区间的值,把区间内所有元素的值加上d。
如果tree[p]这棵子树完全被包含在需要修改的区间[L, R]中,只需要对根tree[p]打上标记即可,不用修改p的子结点。 若再需要对下层操作时,函数中的push_down()会先向下传递lazy-tag也就是说: update()函数更新区间的值,把区间内所有元素的值加上d。
如果tree[p]这棵子树不能完全被包含在需要修改的区间[L, R]中,需要解决多次修改的冲突 问题,用push_down()实现。def update(L,R,p,pl,pr,d): if L<=p1 and R>=pr: addtag(p,pl,pr,d) return push_down(p,pl,pr) #将懒标记传递给孩子 mid = (pl+pr)/2 if L <= mid: update(L,R,p>>1,pl,mid,d) if R >= mid+1: update(L,R,p>>1|1,mid+1,pr,d) tree[p] = tree[p>>1] + tree[p>>1|1] update(L,R,1,1,n,d)
-
addtag()
给当前节点打上标记
def addtag(p,pl,pr,d): tag[p] += d tree[p] += d*(pr-pl+1) #子节点个数
-
push_down()
解决tag的冲突问题。把tag分别传给左右子树。
注意:tag应该持续向下传递,直到能覆盖区间为止。但是push_down()只向下传递了一次。因为使用push_down()的update()是个递归函数,update()会在递归时一层层地用push_down()来传递tag。def push_down(p,pl,pr): if tag[p] > 0: #右tag标记才传给下一层 mid = (pl+pr)>>1 addtag(p>>1,pl,mid,tag[p]) #把tag标记传递给左 addtag(p>>1|1,mid+1,pr,tag[p]) #把tag标记传递给右 tag[p] = 0 #p自己的tag被传走了,归0
-
query()
查询区间和。查询若遇到tag,用push_down()来处理tag。
def query(L,R,p,pl,pr): if L <= pl and R >= pr: return tree[p] push_down(p,pl,pr) res = 0 mid = (pl+pr)>>1 if L <= mid: res += query(L,R,p>>1,pl,mid) if R > mid: res += query(L,R,p>>1|1,mid+1,pr) return res print(query(L,R,1,1,n))
完整的:
def build(p, pl, pr): # 建树
if pl == pr:
tree[p] = a[pl]
return
mid = (pl + pr) >> 1
build(p<<1, pl, mid)
build(p<<1|1, mid + 1, pr)
tree[p] = tree[p<<1] + tree[p<<1|1] #push_up(p)
def addtag(p,pl,pr,d): #给结点p打tag标记,并更新tree
tag[p] += d; #打上tag标记
tree[p] += d*(pr-pl+1); #计算新的tree
def push_down(p, pl, pr):
if tag[p]>0: #有tag标记,这是以前做区间修改时留下的
mid = (pl+pr)>>1
addtag(p<<1,pl,mid,tag[p]) #把tag标记传给左子树
addtag(p<<1|1,mid+1,pr,tag[p]) #把tag标记传给右子树
tag[p]=0 #p自己的tag被传走了,归0
def update(L, R, p, pl, pr, d):
if L<=pl and R>=pr:
addtag(p, pl, pr,d)
return
push_down(p, pl, pr) # 将懒惰标记传递给孩子
mid = (pl + pr) >> 1
if L <= mid: update(L, R, p<<1, pl, mid,d)
if R >= mid + 1: update(L, R, p<<1|1, mid+1, pr, d)
tree[p] = tree[p<<1] + tree[p<<1|1] #push_up(p)
def query(L, R, p, pl, pr):
if L <= pl and R >= pr: return tree[p]
push_down(p, pl, pr)
res = 0
mid = (pl + pr) >> 1
if L <= mid: res += query(L, R, p<<1, pl, mid,)
if R > mid: res += query(L, R, p<<1|1, mid+1,pr)
return res
n,m = map(int, input().split())
a = [0] + list(map(int, input().split()))
tag = [0]* (len(a)<<2)
tree = [0]* (len(a)<<2)
build(1,1,n) # 建树
for i in range(m):
w = list(map(int, input().split()))
if len(w) == 3: # 区间询问:[L,R]的区间和
q, L, R = w
print(query(L,R,1,1,n))
else: # 区间修改:把[L,R]的每个元素加上d
q, L, R, d = w
update(L,R,1,1,n,d)