浅谈矩阵乘法维护线段树标记的技巧
George_Plover · · 算法·理论
一、引述
懒标记在一类数据结构问题中的应用是非常普遍的。在数据结构的简单运用中,懒标记的维护通常非常简洁。例如经典的线段树区间加法与区间求和操作,通过懒标记维护区间未向下处理的加法信息,这样在修改的时候不必向下递归,等到查询向下递归的时候再同步下传标记。这个简单的例子中,我们只需要简单维护一个加法标记就可以了。
然而,在近期的竞赛中,这样一类只需维护单个标记的基础题目已经十分少见了。以线段树为例,在提高题目难度的时候,除了考察线段树的变种,还有一种方式就是增加需要支持的操作种类,使得线段树需要维护两个或者多个不同的懒标记。当标记大于一个的时候,我们就需要思考不同标记在下传过程中的相互影响,然而这会极大地增加临场的思考时间。对此,掌握一些线性代数方面的技巧,通过构造向量和矩阵乘法,能够更迅速准确地抓住维护这些标记的方法。
本文将从一些题目入手,以线段树为例,介绍几种维护区间线性变换的问题场景,谈一谈利用矩阵维护懒惰标记的技巧。
二、技巧分析
(〇)区间加乘线段树
在进入主题之前,不妨先来回忆一个最基础的双标记模型:区间加法乘法线段树。
在这个题目中,给出了一个长度为
我们都知道,这道题目可以通过维护乘法标记 mul_tag 和加法标记 add_tag 来实现。对于一个暂时没有被下传更新的区间和
(1)区间信息的向量表示
我们假设,线段树的每一个叶子结点,维护的不仅仅是
(这里向量中的
这样的定义下,结点向量
接下来,我们考虑对一个结点进行区间的加法操作,不妨设该结点上,
我们把
接着,我们考虑对一个结点进行区间的乘法操作,不妨设该结点上,
我们把
(2)向量表示的好处
上面的表示方法看起来似乎有些繁琐,用看起来笨重的矩阵乘法去替代了原来的操作。但是这样的维护方法,把两种原本看起来不同类型的操作,都化作了同一种类型:矩阵乘法。
于是,我们可以把每种操作,都看成区间矩阵乘法操作,我们只需要在每个结点维护一个标记即可(即,一个矩阵)。在标记下传的时候,只需要对子结点的标记和向量都左乘下传矩阵即可。
这样,我们可以用线段树在
(3)从向量法回推双标记法
注意到上面我们提到的加法矩阵
也就是说,我们只需要维护两个变量
在标记下传的时候,用
可以看到,第一维度更新后的形式,恰好就是上文提到的:
同理,在标记下传的时候,会发生标记相乘的情况,我们考虑
可以看到更新后的形式,和我们双标记法下传时采用的 “乘法标记相乘,加法标记先乘再加” 的维护方式是完全一致的!
综合来看,我们可以不必维护
在下文中会给出该分析方法在更多问题上的运用。
(一)序列累加线段树
考虑这样一类模型:给出一个长度为
可以看到,这个模型在传统的区间修改查询的基础上,新增了“把当前序列的值加入到另一个数列”这样的操作。自然而然,可能会想到通过维护两种不同的懒标记来实现。比如这样的思路:用一个标记维护
不妨回想上面提到的方法:考虑用向量维护。
(1)向量表示
在线段树每个叶子结点维护一个三维向量
同样,对每个非叶子结点,我们在上面维护一个向量,等于左右儿子向量的和。于是线段树每个结点维护的三维向量可以写作如下形式:
其中,
(2)操作矩阵化
我们来分析两种操作的对应的矩阵乘法。
首先,考虑区间加法操作,给区间所有
然后,考虑把
于是,我们把两种操作都转化为了三阶矩阵的左乘,可以用线段树维护矩阵标记来实现了。在查询答案的时候,我们只需要想经典线段树区间查询那样,找到
(3)从矩阵标记回推多标记
如果我们不能容忍矩阵乘法的
也就是说,可以用三个标记
从这里我们可以看出,通过线性操作的方式来维护标记,至少需要三个标记,这也解释了为什么我们在尝试用仅两个标记解决该问题时会遇到困难。
从上面两个例子我们可以逐渐感受到:一些繁琐的序列操作,只要我们能够构造出一个向量,然后能够把所有的操作化为向量的线性变换,我们就可以把操作转变为矩阵乘法。无论题目给出了多少种不同的线性操作,我们只需要构造出对应的矩阵,就能简单地使用线段树维护矩阵乘法的方式进行维护。
(二)线段树维护二次项求和
考虑这样一类模型:给出一个长度为
该问题的难点在于如何维护
(1)向量表示
在线段树每个叶子结点维护一个三维向量
同样,对每个非叶子结点,我们在上面维护一个向量,等于左右儿子向量的和。于是线段树每个结点维护的三维向量可以写作如下形式:
其中,
(2)操作矩阵化
我们来分析两种操作的对应的矩阵乘法。
首先,考虑区间加法操作,给区间所有
然后,考虑区间乘法操作,不难求出这一操作等价于如下矩阵乘法:
于是,我们把两种操作都转化为了三阶矩阵的左乘。注意到矩阵标记都可以写作
从上面的三个部分,我们可以看到,设计良好的向量,可以把多样的操作矩乘化,这是一种实用的技巧,下面我们将从几个题目来分析并巩固这种技巧。
三、题目分析
(1)【ICPC-2021 南京】Paimon Segment Tree
(gym/103470/E)
【题意】
给出一个长度为
接下来会进行
然后,会有
【分析】
这道题目在这场比赛中只有 29/680 支队伍通过,属于金牌题的范畴。然而,它只是序列累加线段树和二次求和线段树的综合运用。
首先我们把询问离线,几个连续版本的变量求和,我们可以转化为前缀版本和的差分。前缀版本和,其实本质就是,在每次修改
具体而言,对于每个询问
构造叶子结点维护的四维向量:
于是区间加
直接维护矩阵乘法常数较大,总时间复杂度为
【参考代码】
见附录 code1.cpp
(2)【HDU多校-2021 第二场 1007】I love data structure
(记不清题号了)
【题意】
给出两个长度为
接下来会进行
【分析】
这道题目更好地呈现了线性变换操作。无论是交换两个数,还是加上常数,亦或是直接的线性变换。这些都可以看作矩阵的乘法。对两个数的向量而言,交换两个数其实就是左乘
不过查询的值是
具体而言,我们可以构造叶子结点的六维向量:
下面直接对每种操作给出矩阵左乘:
直接维护六阶矩阵,在乘法运算的时候具有
由于矩阵过于庞大,分析其中的有效部分还是非常繁琐的。有一种更好的分析方法:
【参考代码】
(因HDU无法访问) 略
四、小结
当遇到操作数量繁多且复杂的数据结构操作时,需要观察其性质。在合适的向量构造下,如果所有的操作都是线性的,我们能够把繁杂的操作统一成矩阵的乘法操作,进而解决。
当然,在初步分析的时候,我们可能会得到一个较大的矩阵,为了优化常数,我们还需要对矩阵做一些处理,从中提取有效的、必要的成分,从而减少维护矩阵乘法的代价,降低常数。
五、附录
code1.cpp
#include <cstdio>//出于个人习惯,本代码用的是: 行向量表示,维护矩阵右乘
#include <cstring>
#include <iostream>
#include <algorithm>
#define MAXN 51000
#define MOD 1000000007
using namespace std;
int n,m,q,b[MAXN];
struct Mat{
int a[5][5];
void gen(int k)//初始化加法操作矩阵,参数为k
{
memset(a,0,sizeof(a));
a[1][2]=k;
a[2][3]=a[2][4]=(k+k)%MOD;
a[1][1]=a[2][2]=a[3][3]=a[4][4]=a[3][4]=1;
a[1][3]=a[1][4]=1ll*k*k%MOD;
}
void I(){//初始化单位矩阵
clear();a[1][1]=a[2][2]=a[3][3]=a[4][4]=1;
}
bool is_I()//判断是否为单位矩阵
{
return a[1][2]==0 && a[1][3] == 0 && a[1][4]==0 &&a[2][3]==0 && a[2][4]==0 && a[3][4]==0;
}
void clear(){
memset(a,0,sizeof(a));
}
};
Mat operator * (Mat A,Mat B)//展开实现的矩阵乘法
{
Mat C;
C.clear();
C.a[1][1] = C.a[2][2]=C.a[3][3]=C.a[4][4]=1;
C.a[1][2] = (B.a[1][2] + A.a[1][2])%MOD;
C.a[1][3] = (B.a[1][3] + 1ll*A.a[1][2]*B.a[2][3] + A.a[1][3])%MOD;
C.a[1][4] = (B.a[1][4] + 1ll*A.a[1][2]*B.a[2][4] + 1ll*A.a[1][3]*B.a[3][4] + A.a[1][4])%MOD;
C.a[2][3] = (0 + B.a[2][3] + A.a[2][3]) %MOD;
C.a[2][4] = (0 + B.a[2][4] + 1ll*A.a[2][3]*B.a[3][4] + A.a[2][4])%MOD;
C.a[3][4] = (B.a[3][4] + A.a[3][4])%MOD;
return C;
}
struct Vec{//维护的向量
int a[5];
Vec(){memset(a,0,sizeof(a));}
};
Vec operator +(Vec A, Vec B)//向量加法
{
for(int i=1;i<=4;i++)
A.a[i]=(A.a[i]+B.a[i])%MOD;
return A;
}
struct Segment_Tree//线段树
{
struct node{
int l,r;
Mat tag;
Vec A;
node(){l=r=0;tag.clear();}
}tr[MAXN*4];
int num;
Segment_Tree(){
num=1;
}
void modify_node(int x,Mat &M)//展开实现的向量左乘
{
tr[x].tag = tr[x].tag * M;
Vec tmp;
tmp.a[1] = tr[x].A.a[1];
tmp.a[2] = (1ll * tr[x].A.a[1] * M.a[1][2] + tr[x].A.a[2])%MOD;
tmp.a[3] = (1ll * tr[x].A.a[1] * M.a[1][3] + 1ll * tr[x].A.a[2] * M.a[2][3] + tr[x].A.a[3])%MOD;
tmp.a[4] = (1ll * tr[x].A.a[1] * M.a[1][4] + 1ll * tr[x].A.a[2] * M.a[2][4] + 1ll * tr[x].A.a[3] * M.a[3][4] + tr[x].A.a[4])%MOD;
tr[x].A = tmp;
}
void pushdown(int x)//下传标记
{
if(!tr[x].tag.is_I())
{
modify_node(tr[x].l,tr[x].tag);
modify_node(tr[x].r,tr[x].tag);
tr[x].tag.I();
}
}
void pushup(int x)
{
tr[x].A = tr[tr[x].l].A+tr[tr[x].r].A;
}
void Build(int x,int L,int R)//建树操作,初始化叶子的向量
{
tr[x].tag.I();
if(L==R)
{
tr[x].A.a[1]=1;
tr[x].A.a[2]=b[L];
tr[x].A.a[4]=tr[x].A.a[3]=1ll*b[L]*b[L]%MOD;
return;
}
int mid=(L+R)/2;
tr[x].l=++num;
Build(num,L,mid);
tr[x].r=++num;
Build(num,mid+1,R);
pushup(x);//pushup初始化非叶子的向量
}
void Update(int x,int L,int R,int al,int ar,Mat &M)//区间矩阵乘法
{
if(R<al||ar<L)return;
if(al<=L&&R<=ar)
{
modify_node(x,M);
return;
}
int mid=(L+R)/2;
pushdown(x);
Update(tr[x].l,L,mid,al,ar,M);
Update(tr[x].r,mid+1,R,al,ar,M);
pushup(x);
}
int Query(int x,int L,int R,int al,int ar)//查询版本前缀和
{
if(R<al||ar<L)return 0;
if(al<=L&&R<=ar)
return tr[x].A.a[4];
pushdown(x);
int mid=(L+R)/2;
return (Query(tr[x].l,L,mid,al,ar)+Query(tr[x].r,mid+1,R,al,ar))%MOD;
}
}seg;
int ans[MAXN];//对询问离线的一些辅助变量
struct Op{
int l,r,k;
}op[MAXN];
struct Q{
int t,l,r;
bool is_l;
int belong;
}p[MAXN*2];
bool cmp(Q x,Q y){
return x.t<y.t;
}
int cnt;
int main()
{
scanf("%d%d%d",&n,&m,&q);
for(int i=1;i<=n;i++)
{
scanf("%d",&b[i]);
b[i]=(b[i]+MOD)%MOD;
}
seg.Build(1,1,n);
for(int i=1;i<=m;i++)
{
scanf("%d%d%d",&op[i].l,&op[i].r,&op[i].k);
if(op[i].k<0)op[i].k=(op[i].k+MOD)%MOD;
}
for(int i=1;i<=q;i++)
{
int x,y,l,r;
scanf("%d%d%d%d",&l,&r,&x,&y);
p[++cnt].belong=i;
p[cnt].l=l;p[cnt].r=r;
p[cnt].t=x-1;
p[cnt].is_l=1;
if(x==0)cnt--;
p[++cnt].belong=i;
p[cnt].l=l;p[cnt].r=r;
p[cnt].is_l=0;
p[cnt].t=y;
}
sort(p+1,p+cnt+1,cmp);
for(int i=0,j=1;i<=m;i++)
{
if(i>0)
{
Mat tmp;
Mat nsh;nsh.gen(0);
tmp.gen(op[i].k);
seg.Update(1,1,n,op[i].l,op[i].r,tmp);
if(op[i].l>1)
seg.Update(1,1,n,1,op[i].l-1,nsh);
if(op[i].r<n)
seg.Update(1,1,n,op[i].r+1,n,nsh);
}
while(j<=cnt && p[j].t==i)
{
int tmp = seg.Query(1,1,n,p[j].l,p[j].r);
int u=p[j].belong;
if(p[j].is_l)
ans[u]=(ans[u]-tmp+MOD)%MOD;
else
ans[u]=(ans[u]+tmp)%MOD;
j++;
}
}
for(int i=1;i<=q;i++)
printf("%d\n",ans[i]);
return 0;
}