算法学习笔记(34): 矩阵乘法与线段树标记
矩阵乘法与线段树标记
线段树的懒标记是非常普遍且巧妙的,但是对于越来越进阶的线段树操作来说,懒标记的维护常常需要细致的讨论,从而导致在考场上思考,推导的时间成本大大增高,并且随之而来的可能是遗漏了某个情况,是的正确性出了问题。
对此,如果能够找到一套通用的理论或者方法,使得可以构造向量和矩阵来快速的推导标记的维护,那么这将是一大胜利。
线段树区间加
我们重新考虑线段树维护区间加和区间和信息的本质:
- \(a_i \leftarrow a_i + k\) 多此一举可以看作 \([a_i] \leftarrow [a_i] + [k]\)
- 对于 \(\sum_l^r a_i + k\) 利用加法的交换律,结合律可以变为 \((r - l + 1)k + \sum_l^r a_i\),多此一举看作 \((r - l + 1)[k] + \sum_l^r [a_i]\)
注意到向量的加法满足交换律,结合律,对于乘法满足分配律,于是我们只需要维护每次增加的 \([k]\) 即可,这也就是我们常用的那个懒标记。
但是这是不优秀的,我们仍然困在一般的标记中,无法自拔。
考虑每一个区间维护一个向量 \(\vec a\):
我们对于这个区间加上某一个数的操作可以看作左乘一个矩阵:
此时,我们只需要维护左乘矩阵即可。
然而到这里就可以解释为什么只需要一个懒标记标记维护区间加信息了。
考虑到左乘的是一个上三角矩阵,而可以证明上三角乘上三角还是上三角,并且在这个情景中,只有右上角的位置数值会变化,于是只需要用一个标记维护右上角的值即可。
也就是说,右上角的值就是一般我们维护那个
lazy
标记。
此时我们多次多此一举,将简单的懒标记换成了笨重的矩阵和向量。但是,我们成功的将懒标记的维护统一规约为了矩阵操作。
于是我们可以把每一种操作看成一个左乘的矩阵,于是我们只需要在每一个节点维护一个矩阵,下传的时候将矩阵向左右儿子下放即可。
线段树历史版本和
我当时在他们讲的时候根本听不懂标记是怎么维护的,但是隐隐感觉是可以通过矩阵来理解的,于是在网上猛搜矩阵理解线段树,于是就学到了
历史版本和说的是每次对于序列 \(A\) 操作完之后,使得序列 \(B \leftarrow B + A\)。
假设我们还是只有区间加操作罢了。
此时我们在线段树上的向量不得不多一维了:
其中 \(his\) 表示历史版本和,\(sum\) 表示当前区间和,\(len\) 是区间长度。
对于区间加的操作左乘矩阵没有什么变化,但是我们多了令 \(his \leftarrow his + sum\) 的操作,考虑利用矩阵表示:
只有这两个操作,于是可以分别维护。
线段树历史版本最大/最小值
此时还是只有区间加这个修改操作,但是发现我们需要有 \(\max / \min\) 的运算,这里不妨是最大值,于是:
利用 \((+, \max)\) 下的矩阵,线段树上的向量为:
其中 \(mx\) 表示当前的最值,\(his\) 表示历史的最值。
对于区间加操作,我们不难构造出矩阵:
对于 \(mx\) 刷到 \(his\) 的操作,我们也不难构造出矩阵:
然而发现只有在利用加法改变这个值的时候,才需要将 \(mx\) 刷到 \(his\),所以可以将两个矩阵合在一起,这样就不用单独的刷 \(his\) 了:
线段树区间取 \(\min\) 与历史版本最大
这也就是 # 【模板】线段树 3
为了保证复杂度,我们需要将操作划分为最大值和非最大值两个部分,将区间取 \(\min\) 转化为对区间最大值的加减来维护。
利用摊还分析,使得其复杂度正确,为 \(O(n \log^2 n)\)。
具体来说,对于区间取 \(\min\),设 \(mx\) 表示区间最大值,\(se\) 表示区间严格次大值,我们找到所有满足 \(mx \ge x \gt se\) 的区间,那么操作只需要变成对最大值的加减即可,也就是 \(mx \leftarrow mx + (x - mx)\)。
于是这颗线段树上维护了两组标记,一组是对于最大值的,一组是对于非最大值的。
然而对于区间和,由于需要乘法,我们不能直接集成到这套标记中,于是我们需要另开一套标记,利用的是普通矩阵的 \((+, \times)\) 操作。
其中 \(sum\) 是区间和,但是注意,由于我们分成了两套标记,所以对应的 \(len\) 需要有相应的变化,对于最大值那一套来说,\(len\) 是最大值的个数,对于非最大来说,也就是剩下的数的个数。
总和一下,我们需要维护的标记有 \(6\) 个,分为两套,每一套的组成为 \(\vec {sum}, \vec {mx}, \vec {laz}\),也就是区间和标记,最大值标记和对于最大值的懒标记即可。
如果我们继续加上区间历史版本和,那么我们只需要修改 \(\vec a\) 的表示为:
同时,额外维护一个关于 \(\vec a\) 的懒标记矩阵即可,总共需要维护 \(8\) 个矩阵。
好吧,我承认,有些时候用矩阵反而麻烦了……QwQ
于是顺便说说对于历史版本标记的一般性维护套路:
- 标记对标记的影响?
- 标记对值的影响?
- 值对值的影响?
如果三者都可以快速的维护或者计算,那么细细讨论一下是可以得到标记的维护方法的。
具体问题具体分析,标记怎么可能都是一样的呢?
NOIP2022 比赛
真是对不起,又是这道被放烂了的题……
首先我们还是考虑扫描线,以及利用单调栈将区间取 \(\max\) 变为区间加,所有子区间,于是还要有一个历史版本和。
于是问题转化为,有两个序列 \(A\),\(B\),需要分别支持区间加,以及查询 \(\sum_l^r A_i \times B_i\)
先考虑只改变 \(A\) 会如何影响,发现 \(ab \leftarrow (a + x)b = ab + xb\),于是发现我们需要维护这样一个向量:
其中 \(his\) 表示历史版本和,\(ab\) 表示 \((\sum A_i) \times (\sum B_i)\),\(a, b\) 分别表示 \(\sum A_i, \sum B_i\)。
于是,对于 \(A\) 的区间加操作可以表示为:
对于 \(B\) 的区间操作可以表示为:
然后还有一个刷历史版本和的操作,这个矩阵很简单。
每一个操作的矩阵都出来了,那么我们就可以快乐的 TLE 这道题了(毕竟快乐的 \(O(5^3)\) 常数还是有点小大的),接下来我们来减少常数。
优化标记常数
--from ZJ
直接矩阵乘法的常数为 \(O(k^3)\),虽然小,但是有些时候也不容小觑,于是我们此刻利用矩阵操作本身的一些性质来减少矩阵乘法的复杂度。
我们以如下乘法为例:
操作的矩阵有两种:
也就意味着我们只需要关注形如:
然而发现事实上右上角都会有值,于是重新修正:
于是意味着我们只需要维护右上角的三个位置,每次按照上式直接修改即可,这样常数大大减少,与维护一堆 \(tag\) 的常数一致了。
同理,对于 NOIP2022 比赛 发现乘出来之后,有用的位置只会有 \(9\) 个(对角线全是 \(1\)),于是只需要维护这 \(9\) 个位置的值即可。
关于向量构造的一些小技巧
一般来说,我们需要构造出来的向量,对于每一个操作都应该是一个上/下三角矩阵的形式,这样更加方便我们观察与理解。
而如果要成为一个上三角,就意味着对于 \(\vec a_i\) 只会由 \(j > i, \vec a_j\) 转移而来。
于是一般来说,会将不变的长度放在最下面,将历史版本信息放在最上面,一般的信息则放在中间。
作者有话说
越学到后面,这句话感觉越发深奥……我不需要复杂度讨论标记下放会带来怎样的变化,我将一切线性的操作都归为了矩阵的乘法。
--from EI's blog
~~我们~~(我)都是平凡人,而平凡人更加需要的是充分利用前辈的方法,否则终其一生也只能在天才后面望着。