假设你管理着全国最大的快递分拣中心。你不用一个个拆开包裹来数数,因为你的仓库天然是分层的:总仓管全国,大区分仓管一片省份,市级中转站管一个城市,街道站点管最末端的具体地址。客户问“华东区今天运了多少件”,你只需要翻出华东分仓的账本即可,不用把每个仓库的包裹都倒出来数。这套“分层账本”的管理哲学,正是数据结构线段树的核心思想——把一个大区间不断对半拆分成小区间,每个节点保存一个小片区的汇总信息。 线段树(Segment Tree)是一种二叉树,它的每个节点对应一个区间:根节点管整个数组,叶子节点管单个元素,中间节点管一片连续下标。无论是查询某段区间的总和、最大值,还是对一段区间统一修改,它都能在 O(log n) 的时间里给出答案。本篇文章将带你从零理解线段树的构建、查询、修改、懒传播,以及动态开点、离散化、线段树合并与持久化等进阶优化。学完之后你会发现,“把大问题拆成小账本,查账时只翻相关的账页”这一直觉,几乎能解释线段树的一切操作。

岗位关联

对 AI 从业者而言,线段树是处理“范围统计”“滑窗聚合”“动态极值追踪”的高性能利器。在推荐系统特征工程、时序异常检测、大规模数据分析管道中,理解它的高效查询机制,能帮你设计出更低延迟、更省计算的模型服务,也是算法面试中绕不开的硬核考点。

目录
  1. 可视化演示
  2. 线段树概述
  3. 构建线段树
  4. 区间查询
  5. 单点修改
  6. 懒传播机制
  7. 优化策略
  8. 高级扩展
  9. 应用与对比
  10. AI 应用场景
  11. Python 演示
  12. 常见误区
  13. 面试题

可视化演示

交互展示线段树构建过程、区间查询遍历路径与懒传播延迟标记机制,理解O(log n)区间操作原理。

本节配有交互动效(进入视口后自动加载,约 3KB)。

线段树结构 每个节点表示一个区间 叶子节点对应单元素 node[l,r] = node[l,mid] + node[mid+1,r] 二分构建O(n)

线段树概述

假设你现在是快递公司的总调度。总仓管全国,一张大地图被划分为几个大区,每个大区再划分为城市、街道、站点。因为账本是按“片区”逐级登记的,所以任何一次关于“某片区域”的查询都不需要翻遍所有快递单。线段树要解决的核心问题,正是如何用一棵二叉树来组织这种“分层账本”。

线段树的每个节点对应一个“区间”。根节点管整个数组区间 [0, n-1],将区间一分为二给左右孩子,左孩子管左半边,右孩子管右半边;往下不断对半切分,直到每个叶子恰好管一个元素。由于每次拆分都取中点,整棵树天然是接近完全二叉树的形态,我们用数组从上到下、从左到右存储即可。

为什么说它是“完全二叉树”?因为每个非叶节点总是恰好有两个孩子,且所有叶子都出现在最后两层。这种规则的树形结构带来一个巨大的好处:可以不用指针,直接用数组下标。设根节点下标为 1,节点 p 的左孩子是 2p,右孩子是 2p+1,就像公司给每个分仓编号一样规律清晰。

回到快递的例子:总仓的“华东账本”被贴成两半——“华东东区”和“华东西区”,每次查询只要按片区逐级索引导航,就能迅速定位到具体哪本账。下标的编号规律让这台“账本机器”只靠一个数组就能运转,这就是线段树概述里最重要的直觉。

mid=⌊l+r2⌋,左孩子覆盖 [l,mid],右孩子覆盖 [mid+1,r]mid = \lfloor \frac{l+r}{2} \rfloor, \quad \text{左孩子覆盖 } [l, mid], \quad \text{右孩子覆盖 } [mid+1, r]
这个公式是线段树的“地基”:任意区间都能通过对半拆分成两个子区间,从而把一个大问题递归地拆成两个小问题。
  1. 求出中点用整数除法计算区间中点,将区间切成尽量均匀的两段。
    mid=⌊l+r2⌋mid = \lfloor \frac{l+r}{2} \rfloor
  2. 左孩子区间区间左半边交给左孩子 2p,包含从 l 到 mid 的所有下标。
    [l,mid][l, mid]
  3. 右孩子区间区间右半边交给右孩子 2p+1,包含从 mid+1 到 r 的所有下标。
    [mid+1,r][mid+1, r]

构建线段树

分拣中心的账本不是凭空出现的,它需要从最末端的街道站开始逐级汇总:每个街道站上报当日的包裹数,市级中转站把下属街道站相加,大区把下属中转站相加,最后总仓得到全国总量。这个过程天然是自底向上的汇总。线段树的构建也是如此——从叶子到根,逐层合并统计信息。

用程序来模拟这个过程,采用的是递归建树:从根节点 (1, 0, n-1) 出发,如果当前节点对应区间左右端点相同,说明到达叶子,直接把原始数组的值写入;否则计算 mid,递归构建左子树和右子树,等两个孩子都构建完,再把孩子的值合并到父节点上。

构建的复杂度是 O(n),因为每个节点只会被访问一次。8 个元素的数组会构建出 15 个节点(4 层),n 个元素则最多需要 4n 左右的数组空间——这比直接建一棵完全二叉树的 2n 更保险,因为递归划分会产生一些“空闲”的节点槽位。

这个过程就像街道站层层上报汇总表:每个节点存储的是它管辖片区的“总量账本”。构建完毕之后,整棵线段树就成了一份自下而上维护好的完整账目,任何区间查询都可以在这棵树上直接“查账”。

tree[p]=tree[2p]+tree[2p+1]tree[p] = tree[2p] + tree[2p+1]
这是“区间求和”型的线段树合并公式。若改成 max、min、gcd 等满足结合律的运算,同样可以构建对应的线段树。
  1. 构建左子树递归调用 build(2p, l, mid),返回左半个区间的统计值。
    build(2p,l,mid)\text{build}(2p, l, mid)
  2. 构建右子树递归调用 build(2p+1, mid+1, r),返回右半个区间的统计值。
    build(2p+1,mid+1,r)\text{build}(2p+1, mid+1, r)
  3. 合并统计结果父节点的值 = 左右孩子之和,完成区间汇总。
    tree[p]=tree[2p]+tree[2p+1]tree[p] = tree[2p] + tree[2p+1]

区间查询

客户打来电话:“我想知道下标 2 到 5 的包裹总数。”作为调度员,你不会拆开每一件包裹去数,而是看一眼账本树:[2,5] 可以被完整拆成 [2,3] 和 [4,5] 两个子区间,而这两个子区间恰好都对应树上的某个节点,直接翻出这两本账相加即可。

区间查询的递归逻辑只有两条规则:

  • 完全覆盖:如果当前节点区间 [l,r] 完全落在查询区间 [ql,qr] 内,直接返回该节点的值,不再往下递归。
  • 部分重叠:否则计算 mid,根据查询区间的边界决定进入左孩子、右孩子还是两边都进,把孩子的返回值相加。

为什么这样做很快?因为线段树每下降一层,区间长度就减半,最多只需要遍历 O(log n) 层。每一层虽然可能访问两个孩子,但总体访问节点数仍然控制在 O(log n) 的级别,这也是区间查询能“秒回”的原因。

回到快递比喻:整个“账本树”中,查询区间被切成少数几个完整的分区账本,其余的账本根本不用翻开。这种“只翻必要账页”的智慧,就是线段树一切高效的起点。

T(n)=O(log⁡n)T(n) = O(\log n)
线段树查询的复杂度证明:每次递归要么直接返回(完全覆盖),要么将区间一分为二继续递归。树高为 log2 n,每层最多访问常数个节点,因此总代价是 O(log n)。
  1. 完全覆盖分支当前节点的区间被查询区间完全包住,说明该节点的账本就是答案的一部分。
    [ql,qr]⊇[l,r]⇒return tree[p][ql, qr] \supseteq [l, r] \Rightarrow \text{return } tree[p]
  2. 递归下探否则求 mid,左半区间有重叠就进左孩子,右半区间有重叠就进右孩子,收集两边结果。
    query(2p,l,mid)+query(2p+1,mid+1,r)\text{query}(2p, l, mid) + \text{query}(2p+1, mid+1, r)
  3. 层数上界树的高度是 log2 n,每层最多访问 2 个节点,所以总复杂度为 O(log n)。
    O(log⁡n)=O(log⁡2n)O(\log n) = O(\log_2 n)

单点修改

某个包裹的数据录错了,比如下标 3 的实际重量应该是 8,而不是 3。这时你不能只改一张表——因为包含下标 3 的所有上级账本([0,7]、[0,3]、[2,3]、[3,3])都会受到影响,必须一路重新汇总。

单点修改的步骤很直观:从根节点出发,根据目标下标 pos 与 mid 的比较,选择向左子树或右子树递归;当到达叶子 L == R == pos 时,更新叶子的值。然后在回溯的路上,用左右子树的当前值重新计算每个祖先节点的 tree[p]。

修改路径的长度就是树的高度 log2 n。所以单点修改的时间复杂度也是 O(log n)——只需要更新从根到叶一条链上的节点,其他节点完全不受影响。这比数组的 O(1) 修改慢一些,但换来的是区间查询也能达到 O(log n)。

这就像街道站改了一笔单据后,立刻向市中转站、大区分仓逐级上报修正,但其他片区的账本完全不需要动。由于每次修改都精确沿着一条路径走,数据的正确性得以快速恢复。

update(pos)⇒只需更新 O(log⁡n) 个节点\text{update}(pos) \Rightarrow \text{只需更新 } O(\log n) \text{ 个节点}
单点修改的精髓:只更新一条“根到叶子”的路径,沿途重新汇总,其余节点保持不变。
  1. 定位叶子从根开始,根据 pos <= mid 判断进入左孩子还是右孩子,直到 L == R == pos。
    pos≤mid⇒左子树;否则右子树pos \le mid \Rightarrow \text{左子树;否则右子树}
  2. 更新叶子值到达叶子后,将数组的该位置更新为新值。
    tree[leaf]=new_valuetree[leaf] = new\_value
  3. 回溯汇总从叶子沿原路返回,每到一个祖先就重新计算左右孩子之和。
    tree[p]=tree[2p]+tree[2p+1]tree[p] = tree[2p] + tree[2p+1]

懒传播机制

如果接到一个大订单:某大区所有站点的包裹数统一加上 5。你如果挨个站点改账本,那就太慢了。聪明的调度员会在分仓的账本上贴一张便签:“本片区每件 +5”——等以后真正需要向下派送或查询时,再根据便签补算。这就是懒传播(lazy propagation)的思路。

线段树中的懒标记(lazy tag)正是这样一张便签:当区间修改操作“完全覆盖”某个节点时,我们不深入修改它的所有子孙节点,只修改这个节点自身的值,并在 lazy[p] 上记下“这里欠着一笔更新”。等到后续查询或修改必须访问它的孩子时,再执行 push_down(下推),把 lazy[p] 分发给左右孩子。

下推的过程也很简单:如果节点 p 有懒标记 lazy[p],就把它累加到两个孩子身上,同时更新两个孩子的 tree 值,最后清空 p 的懒标记。这样,每次访问子节点前,信息都是最新的。

回到快递比喻,懒传播像极了仓库管理中的“批量登记”:先在大仓账本上贴一张便签,等小仓库真的来调货时再逐单结算。这样一来,区间修改的时间也被压缩到 O(log n)——因为每层只需改一个节点的账本和便签,不必触碰整棵子树。

tree[p]+=v×(r−l+1),lazy[p]+=vtree[p] \mathrel{+}= v \times (r-l+1), \quad lazy[p] \mathrel{+}= v
区间整体加上 v 时,节点 p 管辖的区间有 r-l+1 个元素,所以总值增加 v × 长度,同时把增量打在懒标记上。
  1. 计算区间长度节点 p 覆盖的区间长度为 len = r - l + 1,整体加 v 相当于总值增加 v × len。
    len=r−l+1len = r - l + 1
  2. 更新自身汇总直接把增量合并进节点 tree[p],让它立即反映正确的区间和。
    tree[p]←tree[p]+v×lentree[p] \leftarrow tree[p] + v \times len
  3. 记录懒标记在 lazy[p] 中累计这一个“未下发的增量”,留待需要时再传给子孙。
    lazy[p]←lazy[p]+vlazy[p] \leftarrow lazy[p] + v

优化策略

如果全公司的仓库编号是稀疏的,比如只有 0、100、5000、100000 这几家站点,盖 10 万个仓库的账本显然浪费。聪明的做法之一是坐标离散化:把所有可能出现的坐标排序、去重、重新编号成 1、2、3、4,然后用这份连续编号去建树。这样原本 10 万 × 的空间被压缩到 4 个有效位置。

另一种策略叫动态开点:不预先分配 4n 的数组,而是“用多少、开多少”。线段树一开始只有一个根节点,每次递归要访问左孩子或右孩子时,先检查该节点是否已存在,若不存在就现场申请新节点编号。对海量范围、少量操作的场景(例如 n=10^9 但只操作 10^5 次),动态开点能省下海量空间。

这两个优化思路可以叠加使用:先离散化缩小范围,再用动态开点避免构建整棵完整的树;此外,如果线段树是按“值域”来建,就演变成了权值线段树——每个叶子代表一个数值,统计该数值出现的次数,可以用来求区间第 k 大、频次统计等。权值线段树是许多进阶算法(如主席树)的基石。

这就像仓库公司不按门牌号逐号建账,而是把实际有业务的几个仓址重新编号:10008号仓 → 1号仓、99999号仓 → 2号仓。账本只覆盖真实存在的位置,而后端调度算法完全不用改——这样既省空间,又保持了查询的高效。

f(x)=rank(x),f(x)∈[1,m]f(x) = \text{rank}(x), \quad f(x) \in [1, m]
离散化的核心是把原始大范围稀疏坐标映射成连续的小范围排名,从而减小线段树所需的空间。
  1. 排序去重将所有可能出现的坐标收集起来,排序并去掉重复值,得到有序序列。
    sorted:x1<x2<⋯<xmsorted: x_1 < x_2 < \dots < x_m
  2. 分配排名每个原始坐标在有序序列中的位置就是它的新编号。
    rank(x)=i,其中  x=xirank(x) = i, \quad 其中 \; x = x_i
  3. 用排名建树用压缩后的连续区间 [1, m] 作为线段树的管辖范围。
    [1,m][1, m]

高级扩展

两家快递公司合并时,若两套分仓体系里都有“华东大区”,最好的办法是把对应的大区账本合并成一本:逐级递归地合并左子树、右子树,叶子节点的数据相加。这在算法里就叫线段树合并。合并的复杂度与两棵树重叠的节点数成正比,通常很快。

另一种更高阶的需求是“查看历史快照”:比如系统需要回到 10 分钟前的状态,看看当时的区间和是多少。持久化线段树(俗称主席树)的做法很巧妙——每次修改不复制整棵树,而是只复制从根到被改叶子的一条链,其他子树继续复用旧版本。这样每次操作只产生 O(log n) 个新节点,却能保留所有历史版本。

线段树合并在处理“多棵小线段树合并成一棵大树”的场景(如并查集式树上操作)中非常常用;而主席树则广泛用于静态区间第 k 大、离线查询等问题。这两个扩展都建立在线段树“结构灵活、可以动态分配”的基础上。

在快递比喻里,线段树合并就是把两家公司的分仓一一“对账合成”,持久化则是每次改动只抄写一条新的账目链,老账本原封不动地存作历史档案。掌握了这些“升级版”技巧后,线段树就不只是一个查询工具,而是一个可以组合、可以回溯的完整“账本系统”。

merge(u,v)={uv=0vu=0merge(u.l,v.l)+merge(u.r,v.r)otherwisemerge(u, v) =\begin{cases}u & v = 0 \\v & u = 0 \\merge(u.l, v.l) + merge(u.r, v.r) & \text{otherwise}\end{cases}
合并两棵线段树的递归定义:一方为空则直接返回另一方;否则递归合并左右孩子,把结果加总。
  1. 空节点处理若有一棵子树为空,直接返回另一棵,避免无效递归。
    u=0⇒v,v=0⇒uu = 0 \Rightarrow v, \quad v = 0 \Rightarrow u
  2. 合并左孩子递归合并 u 和 v 的左孩子,得到新的左孩子。
    u.l←merge(u.l,v.l)u.l \leftarrow merge(u.l, v.l)
  3. 合并右孩子递归合并 u 和 v 的右孩子,得到新的右孩子。
    u.r←merge(u.r,v.r)u.r \leftarrow merge(u.r, v.r)
  4. 重新汇总合并完成后,用两个孩子的值重新计算节点 u 的统计值。
    tree[u]=tree[u.l]+tree[u.r]tree[u] = tree[u.l] + tree[u.r]

应用与对比

日常调度中,我们会有很多类似的问题:想查某个片区包裹最大单件重量(区间最值)、想查某几条街道的总重量(区间求和)、想给某一片区统一打上“优惠区”标记并反复查询(区间染色)。这三类问题恰好对应线段树的三个经典应用:RMQ(区间最值)、区间求和、区间染色/赋值。

其中区间求和还有一个漂亮的思维:任何区间和都可以用前缀和相减得到。树状数组正是利用这个思想,用更轻量的结构完成单点修改 + 前缀查询,但它天然不支持“区间修改 + 区间查询”这类复杂操作。线段树通过懒传播可以做到任意区间修改、任意区间查询,表达力更强;代价是实现更复杂、常数更大。

| 维度 | 线段树 | 树状数组 |

|------|--------|----------|

| 代码难度 | 较高,需递归/懒标记 | 很低,十几行 |

| 区间求和 | 任意区间修改 + 查询 | 单点修改 + 前缀查询 |

| 区间最值 | 支持 | 较难(不支持区间修改) |

| 区间染色 | 支持 | 不支持 |

| 时间复杂度 | 均为 O(log n) | 均为 O(log n) |

在物流场景中,无论你是想找最重包裹、算总重量,还是标记优惠片区,一套分层账本系统都能应对;树状数组则像一张“精简的速查表”,适合单纯的前缀汇总。根据业务复杂度选择合适的工具,才是最高效的调度策略。

sum(l,r)=pref(r)−pref(l−1)sum(l, r) = pref(r) - pref(l-1)
前缀和思想:把区间 [l,r] 的和转化为两个前缀和之差,这是树状数组的基础,也是理解线段树区间查询的一把钥匙。
  1. 定义前缀和pref(i) 表示从下标 0 到 i 的累计和。
    pref(i)=∑k=0ia[k]pref(i) = \sum_{k=0}^{i} a[k]
  2. 相减得区间和用右端点的前缀和减去左端点前一位的前缀和,即得 [l,r] 的和。
    sum(l,r)=pref(r)−pref(l−1)sum(l, r) = pref(r) - pref(l-1)
  3. 线段树上的类比和前缀和一样,线段树节点保存的区间汇总值允许我们“拼装”出任意子区间,而不是逐元素访问。

AI 应用场景

实时特征工程中的滑窗聚合

阶段:特征工程 / 离线与在线特征计算

解决的问题:推荐系统需要实时统计用户最近 N 次行为的总时长、最大消费金额、或者某个滑动窗口内的点击率峰值。传统做法是每次查询都遍历窗口内的全部记录,数据量大时延迟高。线段树可以在保障 O(log n) 查询速度的同时支持动态更新(新行为不断写入),使滑窗聚合既快又准。

具体案例:某短视频推荐平台需要统计“用户过去 1 小时浏览视频的最长单次观看时长”和“总观看时长”,用于实时兴趣建模。将时间轴划分成桶,用线段树维护每个桶的总观看时长与最大值,当新观看记录到达时做单点更新,刷新推荐特征时做区间查询。实测在每秒万级写入下,P99 延迟低于 5ms。

时序指标异常检测中的区间极值监控

阶段:模型服务监控 / 数据可观测性

解决的问题:AI 模型部署后,需要实时监测推理延迟、GPU 利用率等指标。传统轮询全量扫描无法快速定位“某一时间段内的最高延迟是否超阈值”。利用线段树的区间最值查询,可以在毫秒级返回任意时间窗口的峰值,辅助快速告警。

具体案例:某 MLOps 平台对线上推理服务的延迟数据按秒分桶后构建线段树,每次新延迟上报只需一次单点更新;当值班人员查询“最近 15 分钟最大延迟是否超过 800ms”时,一次区间查询即可秒级返回。若检测到异常区间,还可进一步用区间求和快速计算超时请求总量,辅助根因分析。

Python 演示

python74 行
class SegmentTree:    """支持区间加法和区间求和的线段树(懒传播)"""     def __init__(self, data):        self.n = len(data)        self.tree = [0] * (4 * self.n)        self.lazy = [0] * (4 * self.n)        self._build(data, 1, 0, self.n - 1)     def _build(self, data, node, left, right):        if left == right:            self.tree[node] = data[left]            return        mid = (left + right) // 2        self._build(data, node * 2, left, mid)        self._build(data, node * 2 + 1, mid + 1, right)        self.tree[node] = self.tree[node * 2] + self.tree[node * 2 + 1]     def _apply(self, node, left, right, val):        self.tree[node] += val * (right - left + 1)        self.lazy[node] += val     def _push_down(self, node, left, right):        if self.lazy[node] == 0 or left == right:            return        mid = (left + right) // 2        left_child = node * 2        right_child = node * 2 + 1        self._apply(left_child, left, mid, self.lazy[node])        self._apply(right_child, mid + 1, right, self.lazy[node])        self.lazy[node] = 0     def update_range(self, ql, qr, val):        self._update_range(1, 0, self.n - 1, ql, qr, val)     def _update_range(self, node, left, right, ql, qr, val):        if ql <= left and right <= qr:            self._apply(node, left, right, val)            return        self._push_down(node, left, right)        mid = (left + right) // 2        if ql <= mid:            self._update_range(node * 2, left, mid, ql, qr, val)        if qr > mid:            self._update_range(node * 2 + 1, mid + 1, right, ql, qr, val)        self.tree[node] = self.tree[node * 2] + self.tree[node * 2 + 1]     def query_range(self, ql, qr):        return self._query_range(1, 0, self.n - 1, ql, qr)     def _query_range(self, node, left, right, ql, qr):        if ql <= left and right <= qr:            return self.tree[node]        self._push_down(node, left, right)        mid = (left + right) // 2        result = 0        if ql <= mid:            result += self._query_range(node * 2, left, mid, ql, qr)        if qr > mid:            result += self._query_range(node * 2 + 1, mid + 1, right, ql, qr)        return result  if __name__ == "__main__":    data = [2, 1, 5, 3, 4, 7, 6, 9]    st = SegmentTree(data)    print("初始数组:", data)    print("区间 [2,5] 的和:", st.query_range(2, 5))    st.update_range(1, 4, 3)    print("给区间 [1,4] 整体加 3 后")    print("区间 [2,5] 的和:", st.query_range(2, 5))    st.update_range(0, 7, 1)    print("给整体加 1 后")    print("区间 [0,6] 的和:", st.query_range(0, 6))
目的:演示一个完整可运行的线段树,包含递归建树、单点/区间更新(带懒标记)以及区间求和查询,并用 __main__ 输出验证结果。
  1. self.tree = [0] * (4 * self.n)用 4 倍数组空间存储线段树,防止越界。
  2. self._build(data, 1, 0, self.n - 1)从根节点下标 1 开始递归建树,管辖区间 [0, n-1]。
  3. if left == right:判断到达叶子节点,直接把原始数组元素写入。
  4. self.tree[node] = self.tree[node * 2] + self.tree[node * 2 + 1]回溯时用左右孩子求和得到父节点汇总值。
  5. if ql <= left and right <= qr:完全覆盖判断,直接给整个区间的和加上 val × 长度 并打懒标记。
  6. self._apply(left_child, left, mid, self.lazy[node])下推时把父节点的懒标记累加到左孩子。
  7. self._push_down(node, left, right)查询或修改要进入子树前,先把尚未下发的懒标记推给两个孩子。
  8. result += self._query_range(node * 2, left, mid, ql, qr)递归查询左半部分并累加结果。
  9. st.update_range(1, 4, 3)一行代码完成区间整体加 3,验证懒传播确实生效。

常见误区

误区我看线段树长得像二叉树,就觉得必须要用 `left`、`right` 指针建树,不用指针就写不出来。

正解线段树大多数情况下是用数组实现的:因为每次二分区间,节点下标具备规律的倍数关系 2p 和 2p+1,天然适合连续数组存储。指针实现不仅浪费空间,还会让代码复杂化、缓存不友好。只有动态开点等特殊场景才会借助“类似指针”的节点编号方式。

为何常见:初学二叉树时,链表结构深入人心,容易把所有“树”都默认成指针实现;忽略了线段树“顺序存储”的数学规律。

误区给区间 `[1, 100000]` 整体加 5,我就递归到每个叶子节点,于是复杂度变成 O(n),难怪超时。

正解这正是懒标记要解决的问题——区间修改时若当前节点被查询区间完全覆盖,只需修改该节点的统计值和 lazy 标记,不必深入更新子孙。只有当后续操作真正需要访问子节点时,才把标记下推。这样区间修改的复杂度从 O(n) 降为 O(log n)。

为何常见:刚写完单点修改的人容易“惯性递归”,认为所有修改都必须立刻在叶子上落实,忽略了“延迟处理”的威力。

误区既然是“万能”树,那我就可以用它求区间众数、求区间最长递增子序列,甚至求区间随机采样。

正解线段树能高效处理的是“满足结合律”的信息,例如和、最大值、最小值、gcd、按位或等。如果一个区间统计无法通过合并两个子区间快速推导(如众数、严格 LIS 长度),普通线段树也无能为力,需要更复杂的分块或特殊技巧。

为何常见:这些误导来自于过度推广线段树功能,其实每个数据结构都有它的“能力边界”。

误区树状数组代码短、速度快,那我以后都用树状数组,没必要学线段树。

正解树状数组擅长“单点修改 + 前缀查询”,也能用两个 BIT 实现区间修改 + 区间求和,但无法自然支持区间最值、区间染色、动态开点等更复杂的操作。线段树在功能覆盖面上严格优于树状数组;树状数组则在常数和代码量上更优。二者是互补关系,不是替代关系。

为何常见:因为很多算法题只用树状数组就能过,容易让人低估线段树的扩展能力,直到遇到区间最值 + 区间修改才追悔莫及。

面试题

Q1. 线段树中,节点覆盖区间 [l, r],其左孩子覆盖的区间是?

单选题 · easy

  • A.[l, (l+r)/2-1]
  • B.[l, (l+r)/2]← 正确答案
  • C.[l, r/2]
  • D.[l+1, r/2]

答案:B。设 mid = floor((l+r)/2),左孩子覆盖 [l, mid],右孩子覆盖 [mid+1, r]。选项 B 正确,其他选项要么少了 l 到 mid 的连续性,要么错误地空出了中间位置。

Q2. 为什么线段树通常开 4 倍数组空间而不是 2 倍?

单选题 · mid

  • A.为了对齐内存,提高访问速度
  • B.因为递归划分会产生非严格满二叉树,4n 才足够容纳所有可能的节点← 正确答案
  • C.方便多存一层备用节点
  • D.因为 2n 只会越界,实际需要 8n

答案:B。n 个元素的线段树是一棵不完全的满二叉树,最坏情况下最后一层还会产生额外节点,2n 在边界情况下会越界(如 n 不是 2 的幂时)。开 4n 可以稳妥覆盖所有节点,代码简单且无越界风险。

Q3. 懒标记(lazy tag)的核心思想是?

单选题 · mid

  • A.每次区间修改都立即更新所有叶子节点
  • B.区间完全覆盖时只更新当前节点并记录标记,需要访问孩子时再下推← 正确答案
  • C.只在根节点上做修改,不关心子节点
  • D.用定时器延迟所有修改操作

答案:B。懒传播的核心是“延迟更新”。区间修改完全覆盖某个节点时,直接更新该节点并打上 lazy 标记,不递归进入子孙;等后续查询/修改必须访问孩子时再用 push_down 下推。这样可以确保区间修改的复杂度为 O(log n)。

Q4. 以下哪些操作可以用线段树在 O(log n) 内完成?

多选题 · hard

  • A.区间最大值查询← 正确答案
  • B.区间求和← 正确答案
  • C.区间染色(覆盖颜色后查询某段颜色种类数)← 正确答案
  • D.求任意区间的最长严格递增子序列长度

答案:ABC。区间最值(A)、区间求和(B)、区间染色(C)都满足结合律,可以用线段树加速。而区间最长严格递增子序列(LIS)无法仅通过合并两个子区间快速推导,普通线段树不能直接高效求解。

Q5. 一次单点更新,线段树上最多需要更新多少个节点?

单选题 · easy

  • A.所有叶子节点
  • B.从根到叶子的一条路径,共 O(log n) 个节点← 正确答案
  • C.只有目标叶子本身
  • D.整棵树的一半节点

答案:B。单点修改从根沿着路径走到叶子,再回溯更新路径上的每个祖先。树高为 log2 n,因此需要更新的节点数就是 O(log n),其他分支的节点值不受影响。

Q6. 关于线段树与树状数组,下列说法正确的是?

多选题 · mid

  • A.树状数组代码更短,常数更小← 正确答案
  • B.线段树支持区间修改 + 区间查询,适应性更广← 正确答案
  • C.在单点修改 + 前缀求和场景下,两者时间复杂度都是 O(log n)← 正确答案
  • D.树状数组可以完全替代线段树

答案:ABC。树状数组实现简单、速度快,更擅长单点修改与前缀查询;线段树功能更强大,支持区间修改/区间最值/染色等。两者在经典场景下都是 O(log n),但树状数组无法完全替代线段树,故 D 错。

Q7. 给定值域范围 [1, 10^9],但实际只操作 10^5 个离散点,选哪种线段树策略最合适?

单选题 · hard

  • A.直接开 4 × 10^9 的数组
  • B.使用普通数组模拟
  • C.使用坐标离散化或动态开点← 正确答案
  • D.用链表遍历所有值

答案:C。值域巨大而操作点稀疏,直接建满 4n 数组必然内存溢出。离散化把稀疏坐标压缩到连续排名,动态开点则按需分配节点,两者都能把空间降到 O(m),m 为实际操作点数。

Q8. 区间查询中,当节点区间被查询区间完全覆盖时,正确做法是?

单选题 · easy

  • A.继续递归到叶子
  • B.直接返回该节点的值← 正确答案
  • C.先修改该节点的值再返回
  • D.跳过该节点继续找兄弟节点

答案:B。完全覆盖是查询的“终止条件”。因为该节点的值已经正确汇总了其管辖区间所有元素的信息,直接返回即可,无需再向下递归。这也是线段树查询高效的关键。