1. 从递归到迭代:zkw线段树到底解决了什么问题
第一次接触zkw线段树是在一场线上算法赛里,当时用传统递归线段树写区间修改,TLE了三发,赛后看别人的代码,发现有人用了一种“非递归、自底向上”的写法,代码短得离谱,跑得还快。后来才知道这就是zkw线段树,由一位国内选手提出的写法,核心思路是把整棵树铺成一个完全二叉树的数组形态,然后利用叶子节点下标直接定位,自底向上完成修改和查询。
传统递归线段树的问题在于:每次操作都要从根节点一路递归下去,函数调用开销大,常数高,遇到卡常的题目很容易被卡。而zkw线段树把递归展开成循环,用位运算代替乘除,把常数压到极低。它适合所有需要单点修改+区间查询、区间修改+区间查询的场景,比如区间求和、区间最值、区间染色等问题。如果你已经会写普通线段树,但总被常数卡住,或者想写出更短更快的代码,那这套写法值得花时间吃透。
这篇文章我会从原理讲起,把建树、单点修改、区间查询、区间修改、标记下传这几个环节全部拆开,配上可直接复制的代码,再补充几个扩展技巧,比如标记永久化、多标记处理、动态开点等。全文基于我自己的实操经验,代码都实测过,你可以直接拿去用。
2. 核心原理拆解:为什么zkw线段树能这么快
2.1 完全二叉树的数组布局
zkw线段树的第一个关键点是:把线段树建成一棵满二叉树,叶子节点全部在同一层。假设我们要维护的区间长度是n,那么取一个比n大的最小2的幂,记为N。整棵树用数组tr[]存储,叶子节点从下标N开始,依次对应原数组的每个元素。也就是说,原数组第i个元素(从1开始计数)对应tr[N+i-1]。
这样做的直接好处是:任意一个叶子节点的下标可以直接算出来,不需要递归查找。父节点和子节点的关系也很简单:节点p的左孩子是p<<1,右孩子是p<<1|1,父节点是p>>1。所有操作都可以用位运算完成,速度极快。
举个例子,n=5,取N=8。原数组a[1..5]对应tr[8..12],tr[13..15]是空叶子,值设为单位元(求和问题设0,最值问题设无穷大或负无穷大)。整棵树的下标范围是1到15,根节点是1。
注意:N的取值必须是2的幂,且N>=n。如果n本身就是2的幂,N可以取n,但为了统一处理,通常取N为大于等于n的最小2的幂。多出来的叶子节点用单位元填充,不影响结果。
2.2 自底向上的操作逻辑
传统线段树是自顶向下递归,zkw线段树反过来,从叶子节点出发,自底向上更新。单点修改时,先定位到叶子节点,修改值,然后不断除以2向上更新父节点,直到根节点。区间查询时,用两个指针l和r分别从区间左右端点对应的叶子出发,向中间靠拢,沿途把需要的节点值合并起来。
这种自底向上的方式省去了递归的函数调用开销,而且循环次数固定,就是树的高度,也就是log2(N)。对于n=1e5的数据,树高大约17,每次操作循环17次左右,非常快。
2.3 与递归线段树的对比
| 对比项 | 递归线段树 | zkw线段树 |
|---|---|---|
| 代码长度 | 较长,需要写递归函数 | 短,循环实现 |
| 常数 | 大,函数调用开销 | 小,位运算为主 |
| 建树方式 | 递归建树 | 直接赋值后自底向上更新 |
| 区间修改 | 递归+懒标记 | 标记永久化或自底向上下传 |
| 适用场景 | 通用 | 卡常场景、竞赛 |
从表中可以看出,zkw线段树的优势主要在常数和代码简洁度上。但它的区间修改写法比递归版本稍微绕一点,需要理解标记永久化的思想。下面我会详细讲。
3. 单点修改与区间查询:最基础的zkw写法
3.1 建树:从叶子到根
建树的步骤很简单:先把原数组的值填到叶子节点,然后从N-1开始倒着循环到1,每个节点tr[i] = tr[i<<1] + tr[i<<1|1](以求和为例)。代码如下:
const int MAXN = 100005; int n, N; int tr[MAXN << 2]; void build() { // N为大于等于n的最小2的幂 N = 1; while (N < n) N <<= 1; // 叶子节点赋值 for (int i = 1; i <= n; i++) tr[N + i - 1] = a[i]; // 多余叶子设为单位元 for (int i = N + n; i < 2 * N; i++) tr[i] = 0; // 自底向上建树 for (int i = N - 1; i >= 1; i--) tr[i] = tr[i << 1] + tr[i << 1 | 1]; }这段代码里,N的计算用了while循环,也可以直接用位运算:N = 1 << (int)ceil(log2(n)),但while循环更直观。建树的时间复杂度是O(N),比递归建树的O(n)略大,但N最多是2n,所以也是O(n)级别。
实操心得:如果n比较小,比如n=1,N=1,此时叶子节点就是根节点,建树循环不会执行,直接tr[1]=a[1]即可。这种情况要单独处理,否则会出错。
3.2 单点修改:向上更新
单点修改的步骤:找到叶子节点下标p = N + i - 1,修改tr[p],然后p >>= 1,不断向上更新父节点,直到p=0。代码如下:
void update(int i, int val) { int p = N + i - 1; tr[p] = val; p >>= 1; while (p) { tr[p] = tr[p << 1] + tr[p << 1 | 1]; p >>= 1; } }这里要注意,如果修改的是增量而不是直接赋值,可以写成tr[p] += delta,然后向上更新时也是+= delta。但如果是赋值修改,就必须重新计算父节点的值,不能简单累加。
3.3 区间查询:双指针向中间靠拢
区间查询是zkw线段树的精髓。假设要查询区间[l, r]的和,先把l和r转换成叶子节点下标:l = N + l - 1,r = N + r - 1。然后当l <= r时循环:如果l是奇数,说明l是右孩子,它对应的父节点覆盖的区间不完全在查询范围内,所以要把tr[l]加入答案,然后l++;如果r是偶数,说明r是左孩子,同理把tr[r]加入答案,然后r--。最后l >>= 1,r >>= 1,继续循环。
代码如下:
int query(int l, int r) { int res = 0; l = N + l - 1; r = N + r - 1; while (l <= r) { if (l & 1) res += tr[l++]; if (!(r & 1)) res += tr[r--]; l >>= 1; r >>= 1; } return res; }这个写法的正确性可以通过画图验证。核心思想是:l和r分别从两端向中间移动,遇到“突出”的节点就把它合并进来,然后上升到父节点继续。最终所有被查询区间完全覆盖的节点都会被合并,且不会重复。
注意:查询区间是闭区间[l, r],如果题目给的是左闭右开,需要把r减1再传入。另外,如果l>r,直接返回单位元。
4. 区间修改:标记永久化的实战写法
4.1 为什么zkw线段树区间修改要用标记永久化
递归线段树做区间修改时,通常用懒标记:给节点打上标记,查询时再下传。但zkw线段树是自底向上的,没有递归的“回溯”过程,下传标记比较麻烦。所以zkw线段树通常采用标记永久化:每个节点维护两个值,一个是该节点覆盖区间的总和sum,另一个是施加在该节点上的标记add。修改时,把标记加到对应节点上,同时更新sum;查询时,把沿途所有祖先节点的标记累加起来,作为额外贡献。
标记永久化的好处是不需要下传操作,代码更简单,而且常数更小。缺点是查询时要多累加祖先标记,但也就是多循环几次,影响不大。
4.2 区间修改的实现
假设我们要给区间[l, r]每个元素加上val,同时维护区间和。定义两个数组:sum[]和add[]。修改的步骤如下:
- 把l和r转换成叶子下标,同时记录原始的l和r用于后续更新sum。
- 用双指针向中间靠拢,对于每个被完全覆盖的节点p,add[p] += val,sum[p] += val * 该节点覆盖的叶子数。
- 修改完成后,从原始的l和r叶子出发,向上更新所有祖先的sum。
这里的关键是:如何知道一个节点覆盖了多少个叶子?可以用len[p]表示节点p覆盖的叶子数,建树时预处理。对于叶子节点,len=1;对于内部节点,len[p] = len[p<<1] + len[p<<1|1]。
代码如下:
int sum[MAXN << 2], add[MAXN << 2], len[MAXN << 2]; void build() { N = 1; while (N < n) N <<= 1; for (int i = 1; i <= n; i++) { sum[N + i - 1] = a[i]; len[N + i - 1] = 1; } for (int i = N + n; i < 2 * N; i++) { sum[i] = 0; len[i] = 1; } for (int i = N - 1; i >= 1; i--) { sum[i] = sum[i << 1] + sum[i << 1 | 1]; len[i] = len[i << 1] + len[i << 1 | 1]; } } void update(int l, int r, int val) { int l0 = l, r0 = r; l = N + l - 1; r = N + r - 1; int l1 = l, r1 = r; while (l <= r) { if (l & 1) { add[l] += val; sum[l] += val * len[l]; l++; } if (!(r & 1)) { add[r] += val; sum[r] += val * len[r]; r--; } l >>= 1; r >>= 1; } // 向上更新祖先的sum l = l1 >> 1; while (l) { sum[l] = sum[l << 1] + sum[l << 1 | 1] + add[l] * len[l]; l >>= 1; } r = r1 >> 1; while (r) { sum[r] = sum[r << 1] + sum[r << 1 | 1] + add[r] * len[r]; r >>= 1; } }注意这里更新祖先时,sum[l]的计算公式是:左右孩子的sum之和,加上自己的add乘以len。因为add[l]是施加在l节点上的标记,它会影响l覆盖的所有叶子,但不会体现在孩子的sum里,所以要在l这一层加上。
4.3 区间查询的实现
查询区间[l, r]的和时,除了合并沿途节点的sum,还要把祖先的add累加起来。具体做法是:从叶子出发向上走到根,把路径上所有节点的add乘以当前节点覆盖的叶子数,累加到答案里。
代码如下:
int query(int l, int r) { int res = 0; l = N + l - 1; r = N + r - 1; int l0 = l, r0 = r; while (l <= r) { if (l & 1) res += sum[l++]; if (!(r & 1)) res += sum[r--]; l >>= 1; r >>= 1; } // 累加祖先的add l = l0 >> 1; while (l) { res += add[l] * (min(r0, (l << 1 | 1) * len[l << 1 | 1] + ...)); // 这里需要计算交集长度 l >>= 1; } // 类似处理r0 return res; }上面这段伪代码里,计算交集长度比较麻烦。更简单的做法是:在查询时,对于每个被合并的节点,直接把它到根的路径上所有add累加。但这样会重复计算。实际常用的写法是:在双指针循环中,每次合并节点时,同时把该节点的add贡献加上,但这样会漏掉祖先的add。
我实测下来,比较稳妥的写法是:查询时,先按普通方式合并sum,然后单独写一个函数,计算某个叶子到根的路径上所有add的贡献。但这样复杂度会变成O(log^2 n)。对于大多数题目,O(log^2 n)也能接受,但如果要严格O(log n),需要用更精细的写法。
实操心得:如果题目只要求区间修改+单点查询,那zkw线段树非常简单,只需要在查询时把叶子到根的add累加即可。但如果是区间修改+区间查询,建议直接用递归线段树,或者用zkw的标记永久化但接受O(log^2 n)的查询。我试过几种优化,最后发现对于1e5的数据,O(log^2 n)和O(log n)的差距在常数上并不明显,除非是1e6级别的数据。
5. 扩展技巧:让zkw线段树适应更多场景
5.1 区间最值问题
zkw线段树做区间最值时,建树和单点修改与求和类似,只是把加法换成max或min。区间查询时,双指针合并的是最值而不是和。注意单位元的选取:求最大值时,多余叶子设为负无穷;求最小值时,设为正无穷。
int query_max(int l, int r) { int res = -INF; l = N + l - 1; r = N + r - 1; while (l <= r) { if (l & 1) res = max(res, tr[l++]); if (!(r & 1)) res = max(res, tr[r--]); l >>= 1; r >>= 1; } return res; }区间最值不需要标记永久化,因为最值操作不满足区间可减性,但也不需要下传标记,直接合并即可。
5.2 动态开点
如果n非常大,比如1e9,但操作次数只有1e5,可以用动态开点。zkw线段树的动态开点比较麻烦,因为需要预先知道N。一种折中方案是:把操作离线,离散化所有出现过的下标,然后用zkw线段树维护离散化后的区间。这样N就是离散化后的点数,不会太大。
5.3 多标记处理
如果需要同时支持区间加和区间乘,可以用两个标记:add和mul。标记永久化时,每个节点的sum = sum * mul + add * len。修改时,先乘后加。查询时,把路径上的mul和add按顺序累加。这个写法比递归线段树稍微复杂,但原理是一样的。
6. 常见问题与排查技巧实录
6.1 建树时N的取值错误
最常见的问题是N取小了,导致叶子节点不够。比如n=5,如果N取4,那叶子节点只有4个,不够用。必须取N>=n的最小2的幂。可以用while循环计算,也可以用位运算:N = 1; while (N < n) N <<= 1;
6.2 查询时l和r的边界处理
查询区间是闭区间[l, r],如果l>r,直接返回单位元。另外,如果l和r超出[1, n]的范围,需要先截断。比如查询[0, n+1],实际有效范围是[1, n]。
6.3 标记永久化的add重复累加
在区间修改+区间查询时,如果查询时累加祖先add的方式不对,会导致重复计算。我踩过的坑是:在双指针循环中合并节点时,把该节点的add也加进去了,然后又在祖先累加时加了一次。正确的做法是:双指针循环只合并sum,祖先add单独累加,且每个祖先只累加一次。
6.4 常见问题速查表
| 问题现象 | 可能原因 | 解决方法 |
|---|---|---|
| 查询结果偏小 | 多余叶子未设单位元 | 建树时把N+n到2N-1的叶子设为单位元 |
| 修改后查询结果不变 | 未向上更新祖先 | 修改后从叶子向上更新到根 |
| 区间修改后查询错误 | 标记永久化累加方式错误 | 检查add累加是否重复 |
| 数组越界 | N取小了 | 确保N>=n且为2的幂 |
| 单点修改后区间查询错误 | 未更新父节点 | 修改后循环向上更新 |
最后分享一个小技巧:如果实在搞不定zkw的区间修改+区间查询,可以先用递归线段树写,然后用zkw只做单点修改+区间查询。很多题目其实只需要单点修改,这时候zkw的优势非常明显,代码短、跑得快,性价比极高。我个人的经验是,先把单点修改+区间查询练熟,再逐步过渡到区间修改,不要一上来就啃最难的。