基本操作
由于题目是从报名的课程中选取的,出于版权原因,只是记录一下思路,如确实需要可以与我邮件联系。
首先分析线段树中常见的函数。
build(u, L, R): 递归对线段树进行初始化。
pushup(u, L, R): 通过左右两个子节点的信息维护当前节点的信息。
spread(u, data, L, R): 将修改应用到给定节点,并且处理懒标记。
pushdown(u, L, R): 将懒标记通过 spread 下放。
modify(u, data, l, r, L, R): 区间修改 / 单点修改(函数签名不太一样)。
query(u, l, r, L, R): 区间查询 / 单点查询(函数签名不太一样)。注意操作子树前一定要进行 pushdown。
下面以一个稍复杂的题目为例。
普通线段树
例1
给定数列 a1,a2,…,an,进行 m 次下列操作:
-
1 l r v 区间 al∼ar 全部加 v;
-
2 l r v 区间 al∼ar 全部乘 v;
-
3 l r 区间 al∼ar 两两乘积之和。
经典的乘加操作,对于这种可以认为每一个操作都是 a←xa+y,这样我们就可以对两个不同的操作进行合并了。
然后考虑两两乘积之和应该如何形式化地表示,显然对于 a,b,c 结果是 ab+ac+bc,所以 al∼ar 应当是 ∑l<i<j<raiaj。
但是这样写并不好定量分析,所以进一步转化为 ∑i=lr∑j=i+1raiaj,对于节点 u,设 cu=∑∑aiaj,su=∑ai。
Pushup
首先我们考虑如何通过子节点 ls,rs 推父节点 u 的值,显然的有:
sucu←sls+srs←cls+crs+something
那么我们接下来看 something 应该是什么。假设左边的所有元素是 a1,a2,…,ap,右边是 b1,b2,…,bq,那么交叉乘积应当是:
a1b1+a1b2+⋯+a1bq+a2b1+a2b2+⋯+a2bq+⋯
不难看出它其实就是 sls×srs,所以:
sucu←sls+srs←cls+crs+slssrs
Spread & Pushdown
下面我们看如果每一个 ai←xai+y 这个式子会变成什么,其中令 m=r−l+1:
i=l∑rj=i+1∑r(xai+y)(xaj+y)=x2i=l∑rj=i+1∑raiaj+i=l∑rj=i+1∑rxyai+i=l∑rj=i+1∑rxyaj+i=l∑rj=i+1∑ry2=x2i=l∑rj=i+1∑raiaj+xyi=l∑rai(r−i)+xyj=l∑ri=l∑j−1aj+y22m(m−1)=x2i=l∑rj=i+1∑raiaj+xyi=l∑rai(r−i)+xyj=l∑raj(j−l)+y22m(m−1)=x2i=l∑rj=i+1∑raiaj+xy(m−1)i=l∑rai+y22m(m−1)
注意,在交换求和顺序时,需要保证最终遍历到的 (i,j) 是相同的,这里只需要保证 i<j 就可以。
所以我们维护 ∑ai 与 ∑∑aiaj 就可以在常数的时间求出更新后的 ∑∑aiaj 了。
例2
维护一个数列 ai,支持两种操作:
1 l r k d:区间加等差数列,即令 al←al+k,al+1←al+1+k+d,…
2 p:查询序列第 p 个数 ap。
这一类区间加数列的题目,可以考虑通过每个元素的位置来处理每一个位置需要的操作不一样这个问题。
具体地说,对于 al∼ar 中的 ai,它需要的操作是 ai←ai+k+(i−l)d,我们把加的部分抽出来:
Δ=di+k−dl
不难发现,一项与位置 i 有关,另一项是常数。所以我们维护两个标记 t1,t2 表示 i 的系数和常数项。
在更新区间和 Su 的时候,设它维护的区间是 l∼r,显然有:
Su←Su+t1i=l∑ri+t2(r−l+1)
这是可以在常数时间完成的更新。
例3
维护一个数列 ai,支持两种操作:
1 l r:区间加数列 12,22,…,(r−l+1)2;
2 l r:查询 al∼ar 的和。
同样的思路,我们发现对于 ai 需要的操作是 ai←ai+[i−(l−1)]2,我们把加的部分抽出来:
Δ=i2−2(l−1)i+(l−1)2
可以看出,分别与 i2,i1,i0 有关。所以维护三个标记 t1,t2,t3,在更新区间和 Su 时就可以:
Su←Su+t1i=l∑ri2+t2i=l∑ri+t3(r−l+1)
这里用一下 ∑i=1ni2=6n(n+1)(2n+1) 就可以了。
线段树二分
仓鼠的鸡蛋
有 n 堆鸡蛋,每堆个数 ai。
有 n 个篮子编号 1∼n,每个篮子最多放 m 个鸡蛋,且最多放 k 堆鸡蛋。
如果从 a1∼an 每次都将该堆鸡蛋放到编号最小的,求每次放入的篮子编号。
这里有两个约束条件,其一是 ∑ai≤m,其二是 ai 本身的个数 ≤k。
我们可以这样思考,当某个篮子放的个数 ≥k 时,直接把它装满,这样新来的鸡蛋就不会往里面放了,所以现在约束条件只有一个。
这里要求我们尽可能编号小,所以在查询可行位置时,如果能去左儿子就去,否则就去右儿子。
在叶子节点判断是否可行然后返回编号即可。
Little w and Discretization
给定一个长度为 n 的数组 1≤ai≤109。
有 m 次询问将 al∼ar 离散化处理后,多少个元素和原来不同。
这里离散化后的值从 1 开始取。
显然地,如果元素 x 和原来相同,那么 1∼x 都应该在 al∼ar 中出现。
也就是说我们要找到从 1 开始最小的没有出现的那个元素 k。
这是一个类似 mex 的问题,使用值域线段树。
具体地做法是,将询问离线下来,保证每个询问的 r 非严格递增,查询 l 时就我们需要找到最小的 k 使得它最后一次出现的位置 <l。
现在知道了 k,那么需要统计区间 (l,r) 内 ≥k 的值的数量。
对于数量,我们不难想到前缀和的思想,可以离线下 l−1,r 统计前缀中 ≥k 的元素的数量,也可以主席树。
势能线段树
势能分析
势能分析是一种均摊复杂度的分析方法,更详细的说明可自行搜索 OI-WIKI,这里简要说明一下。
假设一个数据结构的势能是 Φ≥0 且 Φ0=0,第 i 次操作的实际代价为 ai,那么均摊代价 bi 为:
bi=ai+Φi−Φi−1
我们将 n 次操作求和,可以得到:
i=1∑nbi=i=1∑nai+Φn−Φ0≥i=1∑nai
因此最终的复杂度是比均摊复杂度的和 ∑bi 要小的。
我们需要找到合适的势能函数,将昂贵操作的代价与势能的减少量抵消掉,就可以算出一个正确的复杂度。
The Child and Sequence
给定长度为 n 的数列 ai,有三种操作:
- 区间 al∼ar 求和;
- 区间 al∼ar 对 p 取模;
- 单点修改。
需要用到取模的性质:xmodp≤2x(x≥p),xmodp=x(x<p)。
证明第一条:由于 xmodp<p,那么 xmodp<⌊x/p⌋p,那么 2(xmodp)<⌊x/p⌋p+(xmodp)=x。
第二条显然,那于是我们可以记录一下区间 u 的最大值 mu,当最大值 <p 时,区间 u 无需操作。
现在我们考虑如何定义势能函数。由于每一次真正的取模操作都会至少减少一半,这个性质使得我们可以考虑以对数。
定义 Φ=∑ulog2mu,设暴力操作的节点的集合是 A,mu 为操作前区间 u 的最大值,那么一次取模操作均摊复杂度为:
bi=O(logn)+∣A∣+Φi−Φi−1≤O(logn)+∣A∣+u∈A∑log22mu−u∈A∑log2mu=O(logn)
然后求单点修改的均摊复杂度,设被修改的节点集合为 A,修改的值最大为 V:
bi=O(logn)+Φi−Φi−1≤O(logn)+u∈A∑log2V=O(lognlogV)
因此最终的复杂度是 O(nlognlogV)。注意暴力操作的时候不要漏掉 pushup。
And RMQ
给定长度为 n 的数列 ai,有三种操作:
- 区间 al∼ar 按位与 v;
- 单点修改;
- 查询 al∼ar 的最大值。
注意到与运算的性质,aandv 相当于把 a 的某些位置设置为 0,这个“某些位置”就是 v 中为 0 的位置。
因此,如果整个区间 v 为 0 的位置都是 0,那么这个区间就不用操作。
所以我们维护一个区间的按位或,区间 u 的按位或记作 ru。
如果需要进行暴力操作,一定会把所有操作节点的 ru 删掉至少一个 1,因此势能定义为 ∑upopcount(ru)。
与上一题相同,最终的复杂度同样是 O(nlognlogV),其中 V 指 ai 的最大值。
区间开根号
给定长度为 n 的数列 ai,有两种操作:
- 区间 al∼ar 开根号下取整;
- 区间求和。
我们记录区间 u 的最大值 mu,需要暴力操作的时候,我们希望势能的减少量与暴力的节点数量抵消掉。又因为 log2log2mu=log2log2mu−1,因此取 ∑uloglogmu 为势能函数。
那么,进行一次开根的均摊复杂度就是 O(logn)。那最终的复杂度是 O(nlogn) 吗?
这里有一个问题,就是我们建树之后的初始势能 ∑uloglogmu 并不为 0,而是 O(nlognloglogV)。
因此复杂度应当是 O(∑bi+Φ0)=O(nlognloglogV)。
上面两个题没有说这个的原因是,我们可以把建树操作当作进行了 n 次单点修改,而这个题是没有单点修改的。