☰
高频必考!线段树:同题对照树状数组,谁更通用?
2026/10/16 3:06:50 网站建设 项目流程

💥 同一道题,两种解法——什么时候非线段树不可?

树状数组用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)
  • 区间查询:从根往下,三种情况——
    1. 无交集→ 返回零元(0)
    2. 完全包含→ 直接返回该节点值(不用再往下走)
    3. 部分相交→ 拆给两个孩子递归

情况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 + rightmax(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. 离散化(首选):把所有坐标排序去重,映射成1..m再建树
  2. 动态开点:用到哪个节点才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)。如果让你在同一个项目里二选一,你会看重哪些因素?
提示:除了复杂度,还有常数、代码维护成本、是否需要区间修改、团队熟悉度。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询