专题:数据结构与算法系统学习
关键词:数据结构, 算法, 线段树, Segment Tree, 区间查询, 区间更新, Lazy Propagation, 延迟标记
一、概述
线段树(Segment Tree)是一种二叉搜索树,它将一个区间划分成若干个单元区间,每个单元区间对应线段树中的一个叶子节点。线段树是算法竞赛和工程开发中解决区间问题的利器,能够在 O(log n) 的时间复杂度内完成区间查询(如求和、最大值、最小值)和区间更新操作。
线段树的核心思想是分治——将大区间递归分解为小区间,每个节点维护其对应子区间的聚合信息。通过这种树形结构,我们可以在不遍历整个数组的前提下快速获取任意区间的统计信息。
线段树支持的主要操作包括:区间查询(Range Query)、单点更新(Point Update)、区间更新(Range Update),其中区间更新配合 Lazy Propagation(延迟标记)技术同样能达到 O(log n) 的复杂度。
核心特性:线段树用空间换时间,以 O(n) 的预处理时间和 O(n) 的存储空间,换取 O(log n) 的查询与更新性能,非常适合处理静态数据上的动态区间操作。
二、基本概念与数据结构定义
2.1 区间分解原理
给定一个长度为 n 的数组 arr,线段树将区间 [0, n-1] 作为根节点,然后递归地将每个节点区间二等分,直到区间长度为 1(即单元素区间,对应叶子节点)。每个节点负责维护其对应区间上的某种聚合值(和、最大值、最小值等)。
例如,对于数组 arr = [1, 3, 5, 7, 9, 11],区间 [0, 5] 被分解为:
- 根节点:区间 [0, 5] 维护 arr[0..5] 的聚合值
- 左孩子:区间 [0, 2] 维护 arr[0..2] 的聚合值
- 右孩子:区间 [3, 5] 维护 arr[3..5] 的聚合值
- 继续分解直到区间长度为 1 的叶子节点
2.2 节点与数组存储
线段树通常使用数组来存储,而非显式的树节点对象。假设根节点存储在索引 1 处,那么对于节点 i:
- 左孩子节点:i * 2
- 右孩子节点:i * 2 + 1
- 父节点:i // 2
2.3 4 倍空间原则
线段树的数组大小通常开为 4 * n。这是因为一棵满二叉树在最坏情况下需要约 4n 的空间来存储所有节点(原因在于线段树不是完全二叉树,最后一层可能有空位)。具体来说,一棵有 n 个叶子节点的线段树,其总节点数不超过 4n。
空间推导:线段树的高度为 ⌈log₂n⌉ + 1,满二叉树节点数为 2^(h+1) - 1 ≈ 4n。因此开 4n 的数组可以确保空间充足。
2.4 数据结构定义(Python)
class SegmentTree:
def __init__(self, arr):
self.n = len(arr)
self.tree = [0] * (4 * self.n) # 4倍空间
self.lazy = [0] * (4 * self.n) # 延迟标记数组
self.build(1, 0, self.n - 1, arr)
# 建树:递归构造
def build(self, node, l, r, arr):
if l == r: # 叶子节点
self.tree[node] = arr[l]
return
mid = (l + r) // 2
self.build(node * 2, l, mid, arr)
self.build(node * 2 + 1, mid + 1, r, arr)
self.tree[node] = self.tree[node * 2] + self.tree[node * 2 + 1]
三、线段树的建树(递归构造与数组存储)
建树过程采用递归方式,自顶向下将区间二分,到达叶子节点时填入原始数组的值,然后回溯时将子节点的值合并到父节点。合并操作取决于线段树维护的信息类型:求和树用加法,最大值树用 max,最小值树用 min。
3.1 建树过程详解
以 arr = [1, 3, 5, 7, 9, 11] 为例,建树过程如下:
- 调用 build(1, 0, 5):代表从根节点(编号1)开始构建区间 [0, 5]
- mid = 2,递归构建左子树 build(2, 0, 2) 和右子树 build(3, 3, 5)
- build(2, 0, 2):mid = 1,再分 build(4, 0, 1) 和 build(5, 2, 2)
- build(4, 0, 1):mid = 0,分 build(8, 0, 0) 和 build(9, 1, 1)
- build(8, 0, 0):l == r,tree[8] = arr[0] = 1,返回
- build(9, 1, 1):l == r,tree[9] = arr[1] = 3,返回
- 回溯:tree[4] = tree[8] + tree[9] = 1 + 3 = 4
- build(5, 2, 2) 返回 tree[5] = arr[2] = 5
- 回溯:tree[2] = tree[4] + tree[5] = 4 + 5 = 9
- 以此类推,最终 tree[1] = 36(即数组总和)
3.2 建树代码实现(求和线段树)
def build(self, node, l, r, arr):
"""
node: 当前节点在线段树数组中的索引
l, r : 当前节点负责的区间范围 [l, r]
arr : 原始数组
"""
if l == r:
self.tree[node] = arr[l]
return
mid = (l + r) // 2
self.build(node * 2, l, mid, arr)
self.build(node * 2 + 1, mid + 1, r, arr)
# 回溯时合并子节点的值
self.tree[node] = self.tree[node * 2] + self.tree[node * 2 + 1]
# 调用方式:
# seg = SegmentTree(arr)
# 构造完成后,tree[1] 存储整个数组的区间和
时间复杂度:建树过程需要遍历所有节点,每个节点计算一次合并操作,总共有 2n-1 个节点(对于 n 个叶子节点的满二叉树),因此建树的时间复杂度为 O(n)。
四、区间查询(Range Query)
区间查询是线段树最核心的操作之一。给定查询区间 [ql, qr],我们需要在线段树中找出完全位于 [ql, qr] 内的所有节点,并将它们的值合并起来。这一过程同样采用递归实现。
4.1 查询算法流程
递归查询函数 query(node, l, r, ql, qr) 的流程如下:
- 完全包含:如果 [l, r] 完全位于 [ql, qr] 内,直接返回 tree[node] 的值
- 完全无关:如果 [l, r] 与 [ql, qr] 没有交集,返回单位元(求和返回0,求最大值返回 -inf)
- 部分重叠:否则递归查询左右子节点,合并结果后返回
4.2 查询代码实现
def query(self, node, l, r, ql, qr):
"""
查询区间 [ql, qr] 的和
node: 当前节点索引
l, r : 当前节点覆盖的区间范围
ql, qr: 查询区间范围
"""
if ql <= l and r <= qr:
return self.tree[node] # 完全包含,直接返回
if r < ql or l > qr:
return 0 # 无交集,返回单位元(求和为0)
mid = (l + r) // 2
left_res = self.query(node * 2, l, mid, ql, qr)
right_res = self.query(node * 2 + 1, mid + 1, r, ql, qr)
return left_res + right_res
# 调用方式:
# seg.query(1, 0, n-1, 2, 4) # 查询区间 [2, 4] 的和
4.3 查询过程图解分析
假设 arr = [1, 3, 5, 7, 9, 11],查询区间 [2, 4] 的和:
- 根节点 [0, 5] 与 [2, 4] 部分重叠,递归到左右子树
- 左子树 [0, 2] 与 [2, 4] 部分重叠(区间 [2,2] 重叠),继续递归
- 节点 [2, 2](叶子)完全包含在 [2, 4] 中,返回 5
- 右子树 [3, 5] 与 [2, 4] 部分重叠,继续递归
- [3, 3] 完全包含,返回 7;[4, 4] 完全包含,返回 9;[5, 5] 无交集
- 最终结果 = 5 + 7 + 9 = 21
时间复杂度分析:区间查询每次递归将查询区间最多分割到 O(log n) 个节点上,每个节点只访问一次,因此单次查询的时间复杂度为 O(log n)。最坏情况下(查询整个数组)也只需访问 O(log n) 个节点。
五、区间更新(Range Update)
区间更新分为两种情况:单点更新和区间更新。单点更新只需修改一个叶子节点并更新其所有祖先节点即可,实现简单。区间更新则需要将一段连续区间内的所有元素统一修改(如全部加上某个值),如果逐点更新,时间复杂度将退化为 O(n log n)。为此我们需要引入Lazy Propagation(延迟标记)技术。
5.1 单点更新
单点更新只需要找到对应的叶子节点,修改其值,然后回溯更新路径上的所有父节点。
def point_update(self, node, l, r, idx, val):
"""
将 arr[idx] 更新为 val
"""
if l == r: # 找到叶子节点
self.tree[node] = val
return
mid = (l + r) // 2
if idx <= mid:
self.point_update(node * 2, l, mid, idx, val)
else:
self.point_update(node * 2 + 1, mid + 1, r, idx, val)
self.tree[node] = self.tree[node * 2] + self.tree[node * 2 + 1]
# 时间复杂度:O(log n)
六、Lazy Propagation(延迟标记)
当我们需要对区间 [2, 5] 内的所有元素都加上 3 时,如果使用单点更新逐个修改,需要更新 n 个点,每个点 O(log n),总复杂度 O(n log n)。Lazy Propagation 的核心思想是:推迟更新——当更新区间完全覆盖当前节点区间时,只修改当前节点的值并打上延迟标记,不再递归向下更新子节点。等到将来需要查询或更新子节点时,再将标记下推(push down),确保子节点的数据一致性。
6.1 延迟标记的工作原理
- 打标记:更新操作递归到某节点时,如果更新区间完全覆盖该节点区间,则修改该节点 tree[node] 的值,同时在 lazy[node] 中记录待下推的更新信息
- 下推标记(push):当需要访问某节点的子节点时(如后续的查询或更新),先将当前节点的 lazy 标记下推到子节点中,并更新子节点的值,然后清除当前节点的 lazy 标记
6.2 完整实现代码
class SegmentTree:
def __init__(self, arr):
self.n = len(arr)
self.tree = [0] * (4 * self.n)
self.lazy = [0] * (4 * self.n)
self._build(1, 0, self.n - 1, arr)
def _build(self, node, l, r, arr):
if l == r:
self.tree[node] = arr[l]
return
mid = (l + r) // 2
self._build(node * 2, l, mid, arr)
self._build(node * 2 + 1, mid + 1, r, arr)
self.tree[node] = self.tree[node * 2] + self.tree[node * 2 + 1]
def _push(self, node, l, r):
"""下推延迟标记"""
if self.lazy[node] != 0 and l != r:
mid = (l + r) // 2
# 下推到左孩子
self.tree[node * 2] += self.lazy[node] * (mid - l + 1)
self.lazy[node * 2] += self.lazy[node]
# 下推到右孩子
self.tree[node * 2 + 1] += self.lazy[node] * (r - mid)
self.lazy[node * 2 + 1] += self.lazy[node]
# 清除当前节点的标记
self.lazy[node] = 0
def range_update(self, node, l, r, ul, ur, val):
"""
将区间 [ul, ur] 内的所有元素加上 val
"""
if ul <= l and r <= ur:
# 完全覆盖,修改当前节点并打标记
self.tree[node] += val * (r - l + 1)
self.lazy[node] += val
return
self._push(node, l, r) # 下推已有标记
mid = (l + r) // 2
if ul <= mid:
self.range_update(node * 2, l, mid, ul, ur, val)
if ur > mid:
self.range_update(node * 2 + 1, mid + 1, r, ul, ur, val)
self.tree[node] = self.tree[node * 2] + self.tree[node * 2 + 1]
def range_query(self, node, l, r, ql, qr):
"""带延迟标记的区间查询"""
if ql <= l and r <= qr:
return self.tree[node]
self._push(node, l, r) # 查询前先下推标记
mid = (l + r) // 2
res = 0
if ql <= mid:
res += self.range_query(node * 2, l, mid, ql, qr)
if qr > mid:
res += self.range_query(node * 2 + 1, mid + 1, r, ql, qr)
return res
# 使用示例:
# seg = SegmentTree([1, 3, 5, 7, 9, 11])
# seg.range_update(1, 0, 5, 2, 4, 3) # 下标2~4全部+3
# print(seg.range_query(1, 0, 5, 1, 3)) # 查询区间 [1,3]
6.3 延迟标记的正确性保证
Lazy Propagation 的精髓在于标记只在需要时下推。当我们进行区间更新时,遇到完全覆盖的节点就打标记停止递归;当我们进行区间查询遇到完全覆盖的节点就直接返回当前值(标记保留);只有当我们递归到子节点时,才需要下推标记以保证子节点值的正确性。这一机制保证了:
- 每个节点的延迟标记最多被下推一次
- 区间更新和区间查询的复杂度均为 O(log n)
- 延迟标记可以叠加(多个更新累积在同一个标记中)
注意事项:延迟标记的类型取决于更新操作。对于区间加(add操作),标记是累加的;对于区间赋值(set操作),标记是覆盖的,下推逻辑需要相应调整。混合操作(既有 add 又有 set)需要传递多个标记并进行优先级排序。
七、线段树的应用场景
线段树的应用范围极其广泛,几乎任何需要高效区间操作的问题都可以考虑使用线段树。以下是几种典型的应用场景。
7.1 区间最值查询(RMQ)
Range Minimum/Maximum Query 是线段树最经典的应用之一。构建最大值线段树,每个节点存储区间最大值,查询和更新都只需 O(log n)。
# 最大值线段树的合并逻辑
def build_max(self, node, l, r, arr):
if l == r:
self.tree[node] = arr[l]
return
mid = (l + r) // 2
self.build_max(node * 2, l, mid, arr)
self.build_max(node * 2 + 1, mid + 1, r, arr)
self.tree[node] = max(self.tree[node * 2],
self.tree[node * 2 + 1])
# 最大值查询,不重叠时返回 -inf
def query_max(self, node, l, r, ql, qr):
if ql <= l and r <= qr:
return self.tree[node]
if r < ql or l > qr:
return -10**9 # 负无穷
mid = (l + r) // 2
return max(self.query_max(node * 2, l, mid, ql, qr),
self.query_max(node * 2 + 1, mid + 1, r, ql, qr))
7.2 区间和与区间乘积
区间和线段树是最基础的形式(前面的示例均以区间和为例)。区间乘积线段树原理相同,只需将合并操作改为乘法即可,但需注意数值溢出问题(常需要取模)。
7.3 区间最大子段和
这是一个经典的线段树进阶应用。每个节点需要维护四个信息:区间和 sum、区间最大前缀和 pre、区间最大后缀和 suf、区间最大子段和 max_sub。
class NodeInfo:
def __init__(self, sum_val=0, pre=0, suf=0, max_sub=0):
self.sum = sum_val # 区间总和
self.pre = pre # 区间最大前缀和
self.suf = suf # 区间最大后缀和
self.max_sub = max_sub # 区间最大子段和
def merge(self, left, right):
"""合并两个子区间的信息"""
res = NodeInfo()
res.sum = left.sum + right.sum
res.pre = max(left.pre, left.sum + right.pre)
res.suf = max(right.suf, right.sum + left.suf)
res.max_sub = max(left.max_sub, right.max_sub,
left.suf + right.pre)
return res
# 利用线段树可以在 O(log n) 内查询任意区间的最大子段和
7.4 权值线段树与离散化
权值线段树是一种特殊的线段树,它的叶子节点代表值域而非数组下标。每个节点统计落在该值域区间内的元素个数。权值线段树可用于:
- 求第 K 大/小:在树上二分查找,根据左子树元素个数判断第 K 个元素在左还是右
- 求逆序对:扫描数组,对每个元素查询值域中比它大的元素个数
- 离散化配合:当值域很大时,需要先对值进行离散化压缩
7.5 扫描线算法
线段树是扫描线算法(Sweep Line)的核心数据结构,用于计算矩形面积并、矩形周长并等几何问题。扫描线算法将矩形的上下边沿 x 坐标排序,用线段树维护 y 轴方向的覆盖长度,每处理一条边就更新覆盖长度并累加面积。
扫描线核心思想:将二维问题转化为一维区间问题。每个矩形用两条水平边表示(入边和出边),线段树维护当前被覆盖的 y 轴区间长度,乘以 x 方向的增量即为面积元。
7.6 可持久化线段树(主席树)
可持久化线段树(Persistent Segment Tree,又称主席树)是线段树的高阶变体。每次修改操作都创建一个新的版本,保留所有历史版本,从而支持历史版本查询和区间第 K 大等问题。其核心技巧是每次更新只创建 O(log n) 个新节点(被修改的路径),其余节点复用旧版本,空间复杂度 O(n log n)。
八、线段树 vs 树状数组(Fenwick Tree)
树状数组(Binary Indexed Tree, BIT 或 Fenwick Tree)是与线段树功能相似但实现更简洁的数据结构。二者各有优劣,理解它们的区别有助于在具体问题中选择合适的工具。
| 对比维度 |
线段树(Segment Tree) |
树状数组(Fenwick Tree) |
| 代码复杂度 |
较复杂,需要递归/建树/标记下推 |
非常简洁,核心只有几行 |
| 空间复杂度 |
O(4n) |
O(n) |
| 单点更新+区间查询 |
O(log n) |
O(log n) |
| 区间更新+区间查询 |
O(log n) 支持(Lazy) |
需差分技巧,较复杂 |
| 区间最值 |
O(log n) 直接支持 |
不支持(或需复杂扩展) |
| 可持久化 |
支持(主席树) |
不支持 |
| 非交换操作(如矩阵乘法) |
支持 |
不支持 |
| 适用场景 |
复杂区间操作、多种信息维护 |
简单前缀和、逆序对 |
选型建议:如果问题只需要单点更新和前缀和查询,树状数组是更轻量的选择。如果涉及区间更新、区间最值、非交换运算或需要维护多种聚合信息,则线段树是更通用的方案。在算法竞赛中,线段树往往作为"万能工具"使用,因其可扩展性远超树状数组。
8.1 树状数组代码示例(对比参考)
class FenwickTree:
def __init__(self, n):
self.n = n
self.bit = [0] * (n + 1)
def add(self, idx, delta):
"""单点增加"""
i = idx + 1 # 树状数组 1-indexed
while i <= self.n:
self.bit[i] += delta
i += i & -i # lowbit
def sum(self, idx):
"""前缀和 [0, idx]"""
i = idx + 1
res = 0
while i > 0:
res += self.bit[i]
i -= i & -i
return res
def range_sum(self, l, r):
"""区间和 [l, r]"""
return self.sum(r) - self.sum(l - 1)
# 树状数组核心:lowbit(x) = x & -x
# 空间 O(n),代码极简,但功能受限
九、总结与进阶学习路径
线段树核心要点回顾:
- 数据结构本质:二叉树 + 分治,每个节点维护一个区间的聚合信息
- 存储方式:数组存储,根节点索引1,左子 2i, 右子 2i+1,空间 4n
- 建树:递归二分,回溯合并,时间复杂度 O(n)
- 区间查询:递归搜索完全覆盖的节点,合并结果,O(log n)
- 区间更新:Lazy Propagation 延迟标记,完全覆盖时打标记不递归,O(log n)
- 标记下推:在访问子节点前下推当前节点的延迟标记,保证数据一致性
进阶学习路径
掌握基础线段树后,可以按以下路径深入:
- 基础巩固:熟练实现带 Lazy 的线段树,完成「线段树模板题」(洛谷 P3372、P3373)
- 权值线段树:理解值域建树,实现求第 K 大、逆序对统计
- 动态开点线段树:不预先开满 4n 空间,在需要时动态创建节点,适合值域大但数据稀疏的场景
- 线段树合并:将两棵权值线段树合并,用于树上问题(如 dsu on tree)
- 可持久化线段树:保留历史版本,经典应用如「静态区间第 K 小」(洛谷 P3834)
- 线段树优化建图:利用线段树的区间分解特性优化建图,将 O(n²) 的边数降到 O(n log n)
- 线段树分治:离线处理需要撤销操作的动态问题,按时间轴建线段树
算法竞赛提示:线段树是 NOI/ACM 的必考知识点。建议从模板题入手,熟练默写四种核心操作(建树、查询、更新、下推),再逐步攻克进阶应用。理解递归过程和区间分解思想是掌握线段树的关键。
常见误区与调试技巧
- 数组越界:线段树数组一定要开 4 * n,不要用 2 * n
- 递归边界:区间查询时完全包含的条件是 ql <= l and r <= qr,不要写成 l <= ql and qr <= r
- 标记叠加:区间加操作的标记是累加的,区间赋值是覆盖的,不要混淆
- 整数溢出:在 C++ 中使用 long long,在 Python 中注意大数运算性能
- 调试方法:先写一个暴力算法用于对拍,对线段树的结果进行验证