跳到内容

第 2 卷 / 第 15 章 / 第 4 课

15.4 线段树 ​

前置:二叉树与区间边界。本页实现单点赋值和半开区间求和;区间批量更新需要下一层的 lazy propagation 合同。

区间总量与单点值都在变化 ​

前缀和能用 O(1) 回答静态区间和,但一次点更新要影响后面的许多前缀。直接数组更新便宜,区间查询却要扫描。线段树在两者之间取平衡:叶子保存单点值,父节点保存两个孩子区间的和。

一次点更新只改变叶子到根的一条路径;一次区间查询把目标拆成 O(log n) 个互不重叠的树节点区间。

先把查询区间的边界说清楚 ​

“下标 3 到下标 8”可能包含两端,也可能采用右端开区间。接口若不先规定边界,算法再快也会稳定地算错。这里统一使用半开区间。

本实现接收整数可迭代对象并复制输入,更新值也必须为整数,布尔值不在合同内。构建与更新遇到非法值抛 TypeError;非法下标或区间抛 IndexError。长度固定,更新前先检查下标和值。

本页统一使用 [left, right):包含 left,不包含 right。空区间 [i, i) 的和是 0;整个数组是 [0, n)。这种约定与 Python 切片一致,也让相邻区间 [a, b)、[b, c) 不重叠且没有缝隙。

python
class SumSegmentTree:
    def __init__(self, values):
        data = list(values)
        for value in data:
            self._check_value(value)
        self.length = len(data)
        self._leaf_count = 1
        while self._leaf_count < self.length:
            self._leaf_count *= 2

        self._tree = [0] * (2 * self._leaf_count)
        for index, value in enumerate(data):
            self._tree[self._leaf_count + index] = value
        for node in range(self._leaf_count - 1, 0, -1):
            self._tree[node] = self._tree[2 * node] + self._tree[2 * node + 1]

    @staticmethod
    def _check_value(value):
        if not isinstance(value, int) or isinstance(value, bool):
            raise TypeError("values must be integers")

    def _check_index(self, index):
        if (
            not isinstance(index, int)
            or isinstance(index, bool)
            or not 0 <= index < self.length
        ):
            raise IndexError(f"index out of range: {index!r}")

    def update(self, index, value):
        self._check_index(index)
        self._check_value(value)
        node = self._leaf_count + index
        self._tree[node] = value
        node //= 2
        while node:
            self._tree[node] = self._tree[2 * node] + self._tree[2 * node + 1]
            node //= 2

    def query(self, left, right):
        if (
            not isinstance(left, int)
            or isinstance(left, bool)
            or not isinstance(right, int)
            or isinstance(right, bool)
            or not 0 <= left <= right <= self.length
        ):
            raise IndexError(f"invalid half-open range: [{left!r}, {right!r})")

        left += self._leaf_count
        right += self._leaf_count
        total = 0
        while left < right:
            if left & 1:
                total += self._tree[left]
                left += 1
            if right & 1:
                right -= 1
                total += self._tree[right]
            left //= 2
            right //= 2
        return total

if __name__ == "__main__":
    tree = SumSegmentTree([1, 3, 5, 7, 9, 11])
    assert tree.query(1, 4) == 15
    tree.update(2, 6)
    assert tree.query(1, 4) == 16
    assert tree.query(0, tree.length) == 37
    assert tree.query(3, 3) == 0

    empty = SumSegmentTree([])
    assert empty.query(0, 0) == 0
    print("线段树检查通过")

空树保留一个内部叶位置,因此不需要递归处理空区间;它只允许查询 [0, 0),点更新抛 IndexError。与 Python 切片不同,越界端点不会被自动截断,反向区间也不会被当成空区间。

把汇总树藏进一排格子 ​

_leaf_count 取不小于 n 的最小 2 的幂。叶子从这个下标开始,未使用的叶位置保持加法单位元 0。对任意内部节点 p:

text
tree[p] = tree[2*p] + tree[2*p+1]

根在下标 1,下标 0 留空。总数组长度是 2 * leaf_count,即 Θ(n+1),包括空数组时的两个槽。这种布局比笼统地写“开 4n 一定安全”更容易说明每个位置的含义。

构建从叶子上方倒序合并,每个节点处理一次,时间 Θ(n+1)。对 n 个值逐个调用 update 建树则为 O(n log(n+1)),两者不应混为一个复杂度。

一次查询只合并覆盖目标的摘要 ​

迭代查询维护尚未处理的半开区间。左边界下标为奇数,表示它是右孩子;左兄弟不在目标内,因此先取它,再把左边界加 1。右边界为奇数时,边界本身是被排除的右孩子,所以把右边界减 1,取其左兄弟。余下区间两端都已对齐兄弟对,才一起除以 2 上移。

例如叶容量为 8,查询 [1,6) 先得到边界 (9,14),取节点 9;上移到 (5,7) 后取节点 5 和 6。它们分别覆盖 [1,2)、[2,4)、[4,6),不重叠也不漏元素。

每层至多接纳两个节点,树高 O(log(n+1)),所以非空树的查询和点更新均为 O(log(n+1)) 时间,两个迭代方法额外空间 O(1);空树的合法查询为 O(1)。这些界把整数加法视为常数操作,大整数仍要计入位数成本。

整数求和满足结合律,并有单位元 0。浮点求和不严格满足结合律,这也是本实现限制整数输入的原因。线段树也能维护最小值、最大值、gcd 或自定义结合操作;若操作不满足结合律,任意分段再合并可能改变答案。若操作不满足交换律,还必须保持左右片段的合并顺序,本页单个 total 的写法不能直接照搬。可分别维护左、右结果:左侧按 combine(left_total, node) 追加,右侧按 combine(node, right_total) 前置,最后合并左右结果。AtCoder Library 的线段树接口将结合律、单位元与操作成本写成了明确前提。

整段数值一起变化时,先记录延迟更新 ​

若要把 [left, right) 内每个值都加上 delta,逐点更新需要 O(k log(n+1)),其中 k=right-left。Lazy propagation 把“整段都要执行的更新”暂存在覆盖节点上,等查询或继续下探时再推给孩子,从而把区间更新压到 O(log n)。

这需要额外定义:

  • 更新如何作用于聚合值,例如区间和要增加 delta * segment_length;
  • 两次延迟更新怎样组合;
  • 何时把标记下推并清除。

区间加、区间赋值和仿射变换的标记组合规则不同。没有先写清代数合同,就不该粘贴一份“通用 lazy 模板”。

与其他区间结构比较 ​

需求合适的起点
静态区间和前缀和
单点更新 + 前缀/区间和Fenwick 树或线段树
结合聚合;能高效作用于摘要并组合的区间更新线段树
静态 RMQsparse table 等静态结构

Fenwick 树通常代码更短、常数更小,但适用的代数操作与查询形式更受限制。实际选择要结合操作集合、内存与实现复杂度,而不是按“高级程度”排序。

动手检查区间边界 ​

  1. 与 Python sum(values[left:right]) 随机对照查询和更新。
  2. 把聚合改成最小值,空区间应返回什么单位元?
  3. 实现返回区间和但支持单点增量,而非单点赋值。
  4. 证明 _leaf_count < 2n(n > 0),从而推出存储 O(n)。
  5. 设计区间加的 lazy 标记,并写出父节点和子标记的更新公式。

最后一条祖先链 ​

线段树没有保存每个查询的答案,只保存可复用的区间摘要。单点变化后,只有一条祖先链失效;查询则从这些摘要中拼出目标区间。

回到第 15 章导览可以比较布隆过滤器、并查集、线段树与前面学过的 Trie。下一卷进入计算机系统。

Built with VitePress | Software Systems Atlas