线段树入门

当我们需要维护一个序列且进行一些满足结合律(加、乘、异或、\(\gcd\) 等)的区间操作时可以考虑使用线段树。如果单次运算是 \(O(1)\) 的,那么区间查询和区间修改等操作都是 \(O(\log n)\) 的。

线段树是一棵二叉树,它的每个结点都维护一个区间的信息。根结点对应整个区间 \([1,n]\),若父亲结点维护的区间是 \([l,r]\),则左右儿子分别维护 \([l,\lfloor \dfrac{l +r}{2}\rfloor]\)\([\lfloor \dfrac{l +r}{2}\rfloor+1,r]\) 这两个区间。区间长度为 \(1\) 的结点就是线段树的叶子结点。

显然,线段树的深度 \(h=\lceil \log n\rceil\),总结点个数不超过 \(2^{h + 1}-1\lt 4n\),要开四倍空间。

线段树一般采用堆式存储,下标为 \(p\) 的结点的左右儿子分别为 \(2p\)\(2p+1\),建树时从根结点往下递归,求出左右儿子的区间和后就可以得出父亲的区间和。建树的复杂度事实上是 \(O(n)\) 的,因为只经过每个结点一次。

例如我们对一个序列进行以下两种操作:

  1. 查询区间 \([l,r]\) 内所有元素之和
  2. 将区间 \([l,r]\) 内所有元素加上 \(k\)

建树的代码大概是这样的:

def push_up(pos):
    # 根据子树信息更新当前区间和
    tr[pos] = tr[pos * 2] + tr[pos * 2 + 1]


def build(pos, l, r):
    if(l == r):
        # 叶子结点
        tr[pos] = a[l - 1]
        return
    mid = (l + r) // 2
    build(pos * 2, l, mid)
    build(pos * 2 + 1, mid + 1, r)
    push_up(pos)

如果没有修改操作,查询区间和非常容易:从根结点开始向下递归,如果某个区间被查询的区间完全覆盖,直接把这个区间的和加入答案并返回;否则与区间中点进行比对,若 \(l\le mid\) 说明部分信息在左子树中,若 \(r\gt mid\) 说明部分信息在右子树中,分别递归进入这些有用的子树即可。在查询操作中,一个长度为 \(n\) 的区间能分裂出的小区间的数量级是 \(O(\log n)\) 的,因此查询的时间复杂度是 \(O(\log n)\)

如果要对区间 \([l,r]\) 进行修改(全部加上 \(k\)),可以发现最坏情况下需要修改的结点数是 \(O(n)\) 的,这样显然不能接受。但是我们可以注意到一个这样的事实:倘若我们要对某个子树进行修改,而今后的查询最多只会查到这棵子树的某一层,那么再下面的结点就算不更新也不会产生影响了。因此线段树引入了一个 lazy tag,用来延迟对结点信息的修改,这样就能减少修改次数。即每次修改时我们只修改 \(\log n\) 个直接分裂出来的区间,并将它们的 tag 均加上 \(k\),将来如果要修改或查询它们的子树时再将 tag 向下传递。

简单来说,结点上的标记的意义是该区间已被更新,但子区间并没有,标记中记录的就是子区间需要更新的信息。

def push_down(pos, l, r):
    mid = (l + r) // 2
    # 向下传递标记
    tag[pos * 2] += tag[pos]
    tag[pos * 2 + 1] += tag[pos]
    # 更新两个子区间的信息
    tr[pos * 2] += tag[pos] * (mid - l + 1)
    tr[pos * 2 + 1] += tag[pos] * (r - mid)
    # 清空当前区间的标记
    tag[pos] = 0


def add(pos, l, r, x, y, k):
    if(x <= l and y >= r):
        # 整个区间都被修改
        tr[pos] += k * (r - l + 1)
        tag[pos] += k
        return
    # 标记下传
    push_down(pos, l, r)
    mid = (l + r) // 2
    if(x <= mid):
        # 左边有一段要改
        add(pos * 2, l, mid, x, y, k)
    if(y > mid):
        # 右边有一段要改
        add(pos * 2 + 1, mid + 1, r, x, y, k)
    # 更新当前区间的和
    push_up(pos)


def query(pos, l, r, x, y):
    if(x <= l and y >= r):
        # 整个区间参与贡献
        return tr[pos]
    # 标记下传
    push_down(pos, l, r)
    mid = (l + r) // 2
    ret = 0
    if(x <= mid):
        # 统计左半边的贡献
        ret += query(pos * 2, l, mid, x, y)
    if(y > mid):
        # 统计右半边的贡献
        ret += query(pos * 2 + 1, mid + 1, r, x, y)
    return ret

线段树还有动态开点和标记永久化等写法,动态开点可以节约空间,标记永久化可以在结点很特殊的情况下(树套树)保证复杂度。

posted @ 2021-09-20 23:00  Theophania  阅读(40)  评论(0)    收藏  举报