💥 同一道题,两种解法——什么时候非线段树不可?
树状数组用lowbit把前缀和拆成O(logn)个块,更新与查询双双O(logn),代码只有十几行。
今天用同一道LC.307再解一遍——延续系列“同题两解、横向对照”的传统(先例:堆 vs 快速选择;Kahn vs DFS三色法)。
我们要正面回答一个问题:既然两者复杂度都是 O(logn),什么时候非线段树不可?
线段树像一把多功能工具钳——重一些(空间4n、代码更长),但什么区间聚合都能干:
| 能力 | 树状数组 | 线段树 |
|---|---|---|
| 单点更新 + 区间求和 | ✅ | ✅ |
| 区间最值查询 | ❌ 受限 | ✅ |
| 区间修改(整段加) | ❌ | ✅(懒标记) |
| 任意结合律聚合(gcd/矩阵/最大子段和) | ❌ | ✅ |
| 代码量与常数 | 小 | 较大 |
判定规则一句话:能用树状数组就用树状数组;一旦需要“区间最值”或“区间修改”,必须上线段树。
📦 题目速览(30秒读懂)
实现
NumArray类,支持update(index, val)和sumRange(left, right)。示例:
NumArray([1, 3, 5]) sumRange(0, 2) → 9 update(1, 2) → nums = [1, 2, 5] sumRange(0, 2) → 8约束:n ≤ 3×10⁴,操作次数 ≤ 3×10⁴。
🧠 核心思路:区间分治 + 二叉树
线段树的每个节点管一个区间:根管[0, n-1],对半劈开,递归到叶子。
[0,4] 和 = 15 / \ [0,2] [3,4] 和 = 9 和 = 6 / \ / \ [0,1] [2,2] [3,3] [4,4] 和 = 4 5 3 3 / \ [0,0] [1,1] 1 3三个操作
- 建树:自底向上,父 = 左右孩子聚合。O(n)
- 单点更新:从根往下找到叶子,改完后沿路回溯重算祖先。O(logn)
- 区间查询:从根往下,三种情况——
- 无交集→ 返回零元(0)
- 完全包含→ 直接返回该节点值(不用再往下走)
- 部分相交→ 拆给两个孩子递归
情况2是效率的关键:正因为能“整块复用”,才不必逐个元素访问。任意区间都能被拆成不超过2·logn个节点。
与树状数组的本质差异
- 树状数组的块按lowbit分,只能做“可减法拆分”的聚合(如区间和)
- 线段树的块按二分递归分,聚合操作只要满足结合律就行——和、最值、gcd、矩阵乘法、最大子段和,都能放进去
这个“任意结合律聚合”就是通用性的来源。
🖼️ 图解算法(手把手走一遍)
nums = [1, 3, 5, 3, 3](n=5),查询sumRange(1, 3):
| 步骤 | 当前节点 | 与 [1,3] 的关系 | 处理 |
|---|---|---|---|
| 1 | [0,4] | 部分相交 | 拆给左右 |
| 2 | [0,2] | 部分相交 | 继续拆 |
| 3 | [0,1] | 部分相交 | 继续拆 |
| 4 | [0,0] | 无交集 | 返回0 |
| 5 | [1,1] | 完全包含 | 返回3 |
| 6 | [2,2] | 完全包含 | 返回5 |
| 7 | [3,4] | 部分相交 | 继续拆 |
| 8 | [3,3] | 完全包含 | 返回3 |
| 9 | [4,4] | 无交集 | 返回0 |
结果3 + 5 + 3 = 11✓
单点更新update(1, 2):
[0,4]=15 → [0,2]=9 → [0,1]=4 → [1,1]=3 改为 2 ↓ 回溯 [0,4]=14 ← [0,2]=8 ← [0,1]=3 ←──────────┘只需重算路径上3个祖先,其他节点完全不动。
💻 代码实现(Python + Java)
Python(LC.307)
classNumArray:def__init__(self,nums:list[int]):self.n=len(nums)self.tree=[0]*(4*self.n)# 空间固定开4nself._build(nums,1,0,self.n-1)def_build(self,nums,node,l,r):ifl==r:self.tree[node]=nums[l];returnmid=(l+r)//2self._build(nums,node*2,l,mid)self._build(nums,node*2+1,mid+1,r)self.tree[node]=self.tree[node*2]+self.tree[node*2+1]defupdate(self,index,val):self._update(1,0,self.n-1,index,val)def_update(self,node,l,r,index,val):ifl==r:self.tree[node]=val;returnmid=(l+r)//2ifindex<=mid:self._update(node*2,l,mid,index,val)else:self._update(node*2+1,mid+1,r,index,val)self.tree[node]=self.tree[node*2]+self.tree[node*2+1]defsumRange(self,left,right):returnself._query(1,0,self.n-1,left,right)def_query(self,node,l,r,ql,qr):ifqr<lorr<ql:# 无交集return0ifql<=landr<=qr:# 完全包含returnself.tree[node]mid=(l+r)//2return(self._query(node*2,l,mid,ql,qr)+self._query(node*2+1,mid+1,r,ql,qr))Java
classNumArray{privateint[]tree;privateintn;publicNumArray(int[]nums){this.n=nums.length;this.tree=newint[4*n];build(nums,1,0,n-1);}privatevoidbuild(int[]nums,intnode,intl,intr){if(l==r){tree[node]=nums[l];return;}intmid=l+(r-l)/2;build(nums,node*2,l,mid);build(nums,node*2+1,mid+1,r);tree[node]=tree[node*2]+tree[node*2+1];}publicvoidupdate(intindex,intval){update(1,0,n-1,index,val);}privatevoidupdate(intnode,intl,intr,intindex,intval){if(l==r){tree[node]=val;return;}intmid=l+(r-l)/2;if(index<=mid)update(node*2,l,mid,index,val);elseupdate(node*2+1,mid+1,r,index,val);tree[node]=tree[node*2]+tree[node*2+1];}publicintsumRange(intleft,intright){returnquery(1,0,n-1,left,right);}privateintquery(intnode,intl,intr,intql,intqr){if(qr<l||r<ql)return0;if(ql<=l&&r<=qr)returntree[node];intmid=l+(r-l)/2;returnquery(node*2,l,mid,ql,qr)+query(node*2+1,mid+1,r,ql,qr);}}⚠️防坑提醒(必看):
- 空间开4n:用
node*2 / node*2+1存时最坏需约4n,开2n会越界。- 递归边界先判“无交集”再判“完全包含”,顺序反了会漏。
🔧 进阶:懒标记(区间修改)
线段树的真正主场是区间修改。若要把[l, r]每个元素加val,逐个单点更新是O(nlogn);懒标记压到O(logn)。
思想:修改时如果当前节点被完全覆盖,就只更新这个节点并打个欠条,不下推给孩子;等真需要访问孩子时再兑现。
classRangeSumTree:def__init__(self,nums):self.n=len(nums)self.tree=[0]*(4*self.n)self.lazy=[0]*(4*self.n)self._build(nums,1,0,self.n-1)def_build(self,nums,node,l,r):ifl==r:self.tree[node]=nums[l];returnmid=(l+r)//2self._build(nums,node*2,l,mid)self._build(nums,node*2+1,mid+1,r)self.tree[node]=self.tree[node*2]+self.tree[node*2+1]def_push_down(self,node,l,r):ifself.lazy[node]==0orl==r:returnmid=(l+r)//2add=self.lazy[node]self.tree[node*2]+=add*(mid-l+1)self.lazy[node*2]+=add self.tree[node*2+1]+=add*(r-mid)self.lazy[node*2+1]+=add self.lazy[node]=0defrange_add(self,ql,qr,val):self._range_add(1,0,self.n-1,ql,qr,val)def_range_add(self,node,l,r,ql,qr,val):ifql<=landr<=qr:self.tree[node]+=val*(r-l+1)self.lazy[node]+=valreturnself._push_down(node,l,r)mid=(l+r)//2ifql<=mid:self._range_add(node*2,l,mid,ql,qr,val)ifqr>mid:self._range_add(node*2+1,mid+1,r,ql,qr,val)self.tree[node]=self.tree[node*2]+self.tree[node*2+1]defrange_sum(self,ql,qr):returnself._range_sum(1,0,self.n-1,ql,qr)def_range_sum(self,node,l,r,ql,qr):ifql<=landr<=qr:returnself.tree[node]self._push_down(node,l,r)mid=(l+r)//2s=0ifql<=mid:s+=self._range_sum(node*2,l,mid,ql,qr)ifqr>mid:s+=self._range_sum(node*2+1,mid+1,r,ql,qr)returns懒标记三条纪律:
① 完全覆盖时只改自己 + 打标记;
② 任何需要下探的时刻(不论修改还是查询)先push_down;
③ 兑现后必须把父节点标记清零。
再进一步:把sum换成max——一套骨架装下所有“可合并”信息
整棵树只有两个业务钩子:
| 钩子 | 职责 | 求和版 | 最值版 |
|---|---|---|---|
push_up | 由孩子算父亲 | left + right | max(left, right) |
apply | 整段加v后节点值怎么变 | tree += v × 区间长度 | tree += v |
只要聚合满足结合律((a∘b)∘c == a∘(b∘c)),就能塞进这个骨架——和、最值、gcd、矩阵乘法、最大子段和都行。
这就是线段树被称为“最通用的区间结构”的原因:它不绑定任何具体运算,只要求你能把两个区间的信息合并成一个。
反过来看树状数组:它要求信息能用减法拆分(sum[l..r] = pre[r] - pre[l-1]),最值显然不满足——这一个是减法、一个是合并,就是两者能力边界的分水岭。
补充:值域太大怎么办?——动态开点
若值域是[1, 10⁹],开4n直接爆炸。两条路:
- 离散化(首选):把所有坐标排序去重,映射成
1..m再建树 - 动态开点:用到哪个节点才
new,空间O(操作次数 × log n),代价是代码更复杂
面试里先说离散化,被追问“必须在线”时再提动态开点。
⏱️ 复杂度分析(面试必问)
| 操作 | 复杂度 | 说明 |
|---|---|---|
| 建树 | O(n) | 每个节点建一次 |
| 单点更新 | O(logn) | 一条根到叶路径 |
| 区间查询 | O(logn) | 任意区间拆成 ≤ 2logn个节点 |
| 区间修改(懒标记) | O(logn) | 完全覆盖即停 |
| 空间 | O(4n) | 数组存储固定开4倍 |
与树状数组正面对比
| 树状数组 | 线段树 | |
|---|---|---|
| 时间(单点改 + 区间查) | O(logn),常数小 | O(logn),常数略大 |
| 空间 | O(n) | O(4n) |
| 代码量 | 十几行 | 五六十行 |
| 区间最值 / 区间修改 | ❌ | ✅ |
结论:单看“单点更新 + 区间求和”,树状数组更优;线段树买的保险是通用性。
实测:懒标记的O(logn)值多少钱?
n = 100,000、2万次“区间加 + 区间求和”:
| 做法 | 单次复杂度 | 2万次耗时 |
|---|---|---|
| 线段树 + 懒标记 | O(logn) | 0.937 s |
| 朴素数组 | O(区间长度) | ≈ 97.7s(外推) |
约104倍差距,且这里用的还是随机区间(平均长度约n/3)。如果专挑大区间,差距会拉到几百倍。
选型速查表
| 场景 | 该选谁 | 依据 |
|---|---|---|
| 单点改 + 区间求和 | 树状数组 | 快3.5×,代码短4倍 |
| 区间加 + 区间求和 | 双BIT或线段树 | 双BIT常数更优,线段树更好扩展 |
| 涉及最值 / gcd / 区间赋值 | 线段树 | 树状数组做不到 |
| 在线、坐标不可预知 | 线段树(动态开点) | 无法预分配 |
记住这张表比记住代码更有用——面试里真正被考的是“什么时候用谁”。
🚀 举一反三:5道高频变体题
| 题号 | 题目 | 与本题的关系 |
|---|---|---|
| LC.699 | 掉落的方块 | 区间最值,线段树主场 |
| LC.732 | 我的日程安排表III | 区间加 + 全局最值,懒标记典型 |
| LC.218 | 天际线问题 | 线段树 + 离散化 + 扫描线 |
| LC.308 | 二维区域和检索 - 可变 | 二维线段树 |
| LC.307 | 区域和检索 - 数组可修改 | 本题 |
💬 面试追问模拟(提前准备,惊艳全场)
Q1:空间为什么要开4n?
线段树用
node*2 / node*2+1存,形态是“不完全满”二叉树。
当n不是2的幂时最底层稀疏分布,所需下标上界约4n。开2n会在n非2的幂时越界——面试最常见的线段树bug。
Q2:懒标记为什么能保证正确?
它维护一个不变式:
tree[node]始终包含自己与祖先所有未下发的标记效果。访问孩子时push_down把标记折算进孩子并清空自己的——信息既不丢失也不重复计算。
Q3:什么时候必须用线段树而不是树状数组?
三个信号:
① 查询区间最值(最值不能用减法撤销);
② 需要区间修改;
③ 聚合操作不是加法(gcd、最大子段和、矩阵)。
Q4:线段树能改成非递归吗?
能。有经典的迭代写法(自底向上),常数更小、无递归栈风险,但可读性差。面试写递归版更稳妥。
🧩 实战小技巧(刷题党必备)
- 口诀:区间分治二叉树,完全覆盖即复用;懒标记打欠条,下探之前先兑现。
- 模板:
build / update / query三件套 +push_up / push_down两个钩子。 - 防坑:空间开4n;先判无交集再判完全包含;懒标记三条纪律。
📈 实际应用场景(不止是刷题)
- 游戏排行榜:区间统计与批量加成
- 数据库/时间窗口聚合:滑动窗口内求最值、求和
- 图形学扫描线:天际线问题
- 时间序列引擎:区间聚合查询
🎁 今日思考题
树状数组和线段树复杂度同为O(logn)。如果让你在同一个项目里二选一,你会看重哪些因素?
提示:除了复杂度,还有常数、代码维护成本、是否需要区间修改、团队熟悉度。