数据结构闲谈:范围分治的「双半群」模型
从「双半群」出发看范围分治问题
对于区间修改 + 区间查询的线段树(此处以其为例)问题,使用懒标记维护时,记信息 (Data) 集合为 \(D\),标记 (Tag) 集合为 \(T\),需要满足如下性质:
- 支持通过合并得出区间信息,即存在运算 \(+:D \times D \to D\),其中 \(\times\) 为笛卡尔积。
- 标记可以作用于信息,作用之后信息仍然是信息,即存在运算 \(\operatorname{apply}: T \times D \to D\)。对于 \(\operatorname {apply}(t,d)\),下文简记为 \(td\)。
- 标记可以复合,复合之后标记仍然是标记,即存在运算 \(\cdot: T \times T \to T\)。对于 \(t_2 \cdot t_1\),下文简记为 \(t_2t_1\)。
- 当节点上没有标记时,事实上存储了一个代表「什么都不做」的空标记,即要求 \((T,\cdot)\) 存在单位元。
同时,我们注意到:
-
我们要支持以任意顺序合并得出区间信息,即要求 \(D\) 对 \(+\) 满足结合律,且信息合并之后仍然是信息,因此 \((D,+)\) 需要构成半群。对于两个条件,后者和前者是等价的。但是,区间信息的合并不仅仅是一个冷冰冰的运算,它是有实际意义的,只要我们确信我们的运算确实将两个区间的信息合并到了一起,\(+\) 就具有结合律。
-
要求标记可以复合、且标记对复合封闭,同时 \((T,\cdot)\) 存在单位元。因为复合一定是满足结合律的,因此这些条件使得 \((T,\cdot)\) 需要构成幺半群。后者比前者更弱(映射的复合一定满足结合律),因此我们设计标记时,直接考虑如何使标记能够复合即可。
-
先合并区间信息,再总体作用标记(区间修改),与先作用标记,再合并区间信息(标记下传时),得到的信息应该等价,因此 \(t(d_1+d_2) = td_1 + td_2\),因此要求 $\cdot $ 对 \(+\) 有分配律。
信息和合并 \((D,+)\) 构成半群,标记和复合 \((T,\cdot)\) 构成幺半群,\(\cdot\) 对 \(+\) 有分配律,且标记可以作用于信息上,此即为可以使用线段树维护的信息要求,称之为「双半群」模型。
经过上面的观察,为了构成「双半群」模型,有两点十分重要:
- 标记对复合封闭,且存在单位元。
- 信息可以合并。
设计标记和信息时,大多只需要考虑这两点,其它条件便自然满足。更具体地,我们设计时,遵从以下步骤进行考虑:
- 如何设计信息才能使其构成半群,并能够计算出答案?
- 为了使标记能够作用在信息上,标记需要包含些什么?
- 如何使标记能够复合,并对复合封闭?如果需要添加信息,需要添加哪些信息才能够使原来的信息被正确维护?
- 不断迭代 2,3,直到不需要添加任何信息为止。
举一个非常极端的例子:区间加法,区间颜色数。我们只想知道区间颜色数,但如果我们的信息仅仅是区间颜色数,它首先是不能合并的,并且标记也无法作用在信息上,于是我们必须添加更多信息(事实上只能维护一个 bitset 存储每个颜色是否出现),然后继续考虑标记如何作用在添加的信息上。很幸运,这一次不需要添加任何信息了,于是迭代结束。
实际问题中的标记与信息设计
大部分情况下,只要找到了一种描述标记的好方式使得它可以复合,那么它大概率也是可以作用在信息上的。而要找到描述标记的方式,又要先从信息入手,即为了维护出这些信息,需要如何描述标记。
例 1
维护数列 \(a_1,a_2,\cdots,a_n\),支持:
- 区间加法
- 区间乘法
- 询问区间和
我们手算了一些任意标记的复合,发现它们都可以写作:区间中的每一个数 \(x\) 都变为 \(ax+b\)。为了正确维护区间和,我们还需要添加区间长度的信息,而这次我们发现区间长度不受标记影响,于是迭代结束了。
- 描述标记:使用二元组 \((a,b)\) 表示,区间中的每一个数 \(x\) 都变为 \(ax + b\)
- 信息:记录区间长度,区间和
例 2 (历史版本和线段树)
维护数列 \(a_1,a_2,\cdots,a_n\),支持:
- 区间加法,随后生成一个新的历史版本
- 询问区间和
- 询问区间历史版本和
维护区间和的部分是简单的,此处不再赘述。
标记作用于信息时,考虑:一个位置被取历史版本和时,贡献为 \(a_i + tag_i\),因此该标记的贡献被分为两个部分:一是原来的区间和对历史版本和的贡献,二是标记对历史版本和的贡献(即标记的历史版本和),前者即为原来的区间和与标记中取历史版本和次数的乘积,我们只需要重点考虑后者。
我们把任意标记的复合看作一个标记队列 \(q\),其中的标记要么是一次区间加法,要么是一次取历史版本和。考虑两个标记队列 \(q_1,q_2\),其中各有一些区间加法操作和一些取历史和操作,现在我们要把 \(q_2\) 接在 \(q_1\) 之后形成一个新的标记队列 \(q_3 = q_1 + q_2\)。
我们发现复合后,\(q_2\) 中每有一个取历史版本和标记,\(q_1\) 中的每个加法标记都会对历史版本和贡献标记本身的值。于是,我们还要额外维护加法标记的和,以及取历史版本和的次数。这些标记可以直接复合,不需要添加额外信息,于是迭代结束了。
- 描述标记:记录 \(tagh\),表示区间中每个位置的历史版本和加上 \(tagh\);记录 \(tag\),表示加法标记和;记录 \(cnt\),表示取历史版本次数
- 信息:记录区间长度,区间历史版本和
例 3 (改编自 [NOIP2022] 比赛)
维护两个数列 \(a_1,a_2,\cdots,a_n\),\(b_1,b_2,\cdots,b_n\),支持:
- \(a\) 区间加法
- \(b\) 区间加法
- 标记一个新的历史版本
- 区间 \([l,r]\) 中 \(a_ib_i\) 的历史版本和
不太需要直觉就可以想到先维护 \(a,b,ab\) 的区间和,这三者是简单的。
- 描述标记:使用二元组 \((x,y)\) 表示,表示区间中每个 \(a_i\) 都加上 \(x\),区间中每个 \(b_i\) 都加上 \(y\)
- 信息:记录区间长度,区间 \(a,b,ab\) 区间和
标记作用于信息的时候,需要考虑四个部分。注意到一个位置 \(i\) 被取历史版本和时,它的贡献是 \((a_i + taga_i)(b_i + tagb_i)\),其中 \(taga_i,tagb_i\) 表示位置 \(i\) 当前的 \(a,b\) 标记。 拆开之后,我们需要维护的有:取历史版本和的次数 \(cnt\),\(taga_i\) 的历史和 \(htaga\),\(tagb_i\) 的历史和 \(htagb\),以及 \(taga_itagb_i\) 的历史和 \(htagab\)。
接下来考虑标记队列,其中的标记仅有对 \(a\) 的加法,对 \(b\) 的加法,以及取历史版本和。我们发现复合后,\(q_1\) 中的标记有如下的额外贡献:
- 每个对 \(a\) 的加法标记对于 \(htaga\) 有额外贡献,贡献次数为 \(cnt(q_2)\)
- 每个对 \(b\) 的加法标记同理
- \(q_1\) 中的 \(tagatagb\) 可以对 \(htagab\) 产生额外贡献,贡献次数为 \(cnt(q_2)\)
- \(q_1\) 中对 \(a\) 的加法标记和 \(q_2\) 中对 \(b\) 的加法标记可以对 \(htagab\) 产生额外贡献,总贡献为二者的在各自队列中的历史和;\(q_1\) 中对 \(b\) 的加法标记同理
详见代码:
inline void applytag(int k,int l,int r,ull tagA,ull tagB,ull htagA,ull htagB,ull htagAB,ull cnt){
tr[k].hsumAB+=cnt*tr[k].sumAB+tr[k].sumA*htagB+tr[k].sumB*htagA+htagAB*(ull)(r-l+1); // 标记作用于信息
tr[k].htagA+=tr[k].tagA*cnt+htagA;
tr[k].htagB+=tr[k].tagB*cnt+htagB;
tr[k].htagAB+=tr[k].tagA*tr[k].tagB*cnt+tr[k].tagB*htagA+tr[k].tagA*htagB+htagAB;
// 标记的复合
tr[k].sumAB+=tr[k].sumB*tagA,tr[k].sumA+=tagA*(ull)(r-l+1),
tr[k].sumAB+=tr[k].sumA*tagB,tr[k].sumB+=tagB*(ull)(r-l+1);
tr[k].tagA+=tagA,tr[k].tagB+=tagB;
tr[k].cnt+=cnt;
// sumA,sumB,sumAB 的维护,这是简单的
return;
}
例 4
给定数列 \(a_1,a_2,\cdots,a_n\),支持:
- 区间加法
- 区间赋值
- 询问区间最大值
- 询问区间在所有历史版本中的最大值
标记作用于信息时,需要考虑:最值为之前产生 / 最值为标记作用后产生。如果最值为标记作用之后产生,则需要记录标记的历史最值。
我们发现,一次区间赋值之后,后面的任何操作都可以看作区间赋值,于是任何标记队列都可以被看作若干次区间加法 + 若干次区间赋值。
两个标记的复合亦是容易的。分类讨论 \(q_1\) 中是否存在区间赋值操作即可。
例 5
题面略。
历史操作也可以结合区间最值操作,即:我们分别考虑对于区间最值的标记,以及非区间最值的标记。只要我们维护好这两套标记,就和普通历史操作线段树无异,复杂度和 Segment Tree Beats 的复杂度相同,为 \(O((n+m) \log n)\) 或 \(O((n+m) \log^2 n)\)。
// mxtag: 对区间最值的加法标记
// mxhtag: 对区间最值的历史最大加法标记
// tag / htag: 同理,对非区间最值的标记
inline void applytag(int k,int l,int r,int mxtag,int mxhtag,int tag,int htag){
tr[k].sum+=1ll*tr[k].cnt*mxtag+1ll*(r-l+1-tr[k].cnt)*tag;
tr[k].hmax=std::max(tr[k].hmax,tr[k].mx+mxhtag);
tr[k].mx+=mxtag;
if(tr[k].cmx!=-INF) tr[k].cmx+=tag; // 特判区间全部为 1 种数的情况
tr[k].mxhtag=std::max(tr[k].mxhtag,tr[k].mxtag+mxhtag),tr[k].mxtag+=mxtag;
tr[k].htag=std::max(tr[k].htag,tr[k].tag+htag),tr[k].tag+=tag;
return;
}
inline void psdown(int k,int l,int r){
int mid=(l+r)>>1,maxval=std::max(tr[lc(k)].mx,tr[rc(k)].mx);
if(tr[lc(k)].mx==maxval) // 若左区间最值不为区间最值,则需要按照非区间最值的标记处理
applytag(lc(k),l,mid,tr[k].mxtag,tr[k].mxhtag,tr[k].tag,tr[k].htag);
else applytag(lc(k),l,mid,tr[k].tag,tr[k].htag,tr[k].tag,tr[k].htag);
if(tr[rc(k)].mx==maxval) // 若右区间最值不为区间最值,同理
applytag(rc(k),mid+1,r,tr[k].mxtag,tr[k].mxhtag,tr[k].tag,tr[k].htag);
else applytag(rc(k),mid+1,r,tr[k].tag,tr[k].htag,tr[k].tag,tr[k].htag);
tr[k].tag=tr[k].htag=tr[k].mxtag=tr[k].mxhtag=0;
return;
}
特殊的标记 / 信息形式:矩阵
在某些题目中,考虑标记如何作用于信息实在是过于复杂,于是我们找到了一种偷懒的方法:使用矩阵来描述信息。我们知道,矩阵的本质是映射。对于列向量 \(x\),我们可以利用映射 \(Ax = y\),通过左乘矩阵 \(A\) 的方式来将列向量 \(x\) 变为 \(y\)。
如果所有信息都可以被表示为行数相同的列向量 \(x\),合并信息可以表示为向量加法,一个标记作用在信息上可以用矩阵 \(A\) 来描述,那么这种标记 / 信息形式就是一个双半群模型:行数相同的列向量对加法构成交换群,矩阵乘法满足结合律且存在单位矩阵 \(I\),并且矩阵乘法对矩阵加法也是天然满足分配律的。
常见的矩阵有两种。如果涉及区间加法,区间求和,那么最常见的 \((+,\times )\) 矩阵绝对是你的不二之选,因为给区间打上加法标记 \(v\) 后,区间和的变化量是 \(len \times v\),这里出现了乘号。
如果涉及最值,那就应该试试 \((\min,+)\) 矩阵,即,我们把原来的一切 \(+\) 都改成 \(\min\),\(\times\) 改为 \(+\),这样还是会满足我们需要的所有性质。
例题:
- Luogu P4314 CPU 监控 (广义矩阵乘法)
- [NOIP2022] 比赛 (普通矩阵乘法)
- Luogu P7453 [THUSCH2017] 大魔法师 (普通矩阵乘法;同时这也是一个直接考虑标记会过于复杂的例子)