【算法】线段树

线段树

在写主要内容之前先说说线段树应用的背景:

  • 考核最多的高级数据结构
  • 从初级水平到中级水平的标志:在掌握线段树之后才能说“我真正进入了算法竞赛的大门。”
  • 应用背景:区间修改、区间查询、区间合并。

1、区间最值问题RMQ(Range Minimum/Maximum Query)

长度为n的数列{a1,a2,…,an}

  1. 求最值:给定i,j <= n, 求{ai,…,aj}区间内的最值。
  2. 修改元素:给定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)
  • 线段树:一种用于区间处理的数据结构。
  • 基于二叉树。

image-20230318185803518

(3)把数列放在二叉树上

  • 例:查询{1, 2, 5, 8, 6, 4 ,3}的最小值
  • 首先,把数放在二叉树上:

image-20230318191944987

  • 每个结点上的数字是这个结点的子树的最小值。
  • 那么经思考,查询某个区间的最小值,只需要O(logn)次。
  • 思考:如何在二叉树上定位某个区间?

2、线段树的构造

  • 线段树是建立在线段(或者区间)基础上的树,树的每个结点代表一条线段(或者称为区间)[L, R]。
  • 例:线段[1, 5]的线段树。

image-20230318192255032

  • 线段[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个元素。

image-20230318193727536

查询任意区间[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. 点修改:在线段树中每次只修改一个点
  2. 区间修改:每次修改一个区间的所有数

(1) 重点:区间修改

  • 给定n个元素{a1, a2, … , an} :
  • 修改(加):给定i, j<=n,把{ai, …, aj}区间内的每个元素加v。
  • 查询:给定L, R<=n,计算{aL, …, aR}的区间和。

多的不说,上例题:
image-20230320161224835

下面来说解题思路和步骤

  1. 初始建一颗空树

    • 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利用递归函数的回溯,完成了这一任务。

  2. 更新线段树

    • 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
      
  3. 查询

    • 查询区间[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 流程图

image-20230320172714755

这里需要注意一个点:

  • 如果发生多次修改同一个节点上的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()函数不仅在“区间修改”中用到,在“区间查询”中同样用到。

区间修改,又来了

image-20230320193745279

带lazy-tag的基本操作

  1. 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)
    

    死记就行了

  2. 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)
    
  3. addtag()

    给当前节点打上标记

    def addtag(p,pl,pr,d):
        tag[p] += d
        tree[p] += d*(pr-pl+1)	#子节点个数
    
  4. 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
    
  5. 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)
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

菜小田

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值