倍增、ST、RMQ
RMQ(区间最值查询)问题有多种解决方法,用线段树和ST解决RMQ问题对比如下:
- 线段树预处理的时间为O(nlogn),查询的时间为O(logn),支持在线修改;
- ST预处理的时间为O(nlogn),查询的时间为O(1),不支持在线修改。
ST(sparse table,稀疏表)算法采用了倍增思想,在O(nlogn)时间构造了一个二维表之后,可以在O(1)时间在线查询[l,r]区间的最值,有效解决在线RMQ(range minimum/maximum query,区间最值查询)问题。
#include<bits/stdc++.h>
using namespace std;
const int maxn=1001;
int f[maxn][21];//f[i][j]记录从i开始长度为2^j的区间最值
int a[maxn];
int n;
int lb[maxn];
void st_creat()//ST表建立
{
for(int i=1;i<=n;i++)
f[i][0]=a[i];
int k=log2(n);
for(int j=1;j<=k;j++)
{
for(int i=1;i<=n-(1<<j)+1;i++)
{
f[i][j]=max(f[i][j-1],f[i+(1<<(j-1))][j-1]);
}
}
}
int st_query(int l,int r)//ST表查询
{
int k=log2(r-l+1);
return max(f[l][k],f[r-(1<<k)+1][k]);
}
int main()
{
cin>>n;
for(int i=1;i<=n;i++)
cin>>a[i];
st_creat();
while(1)
{
int l,r;
cin>>l>>r;
cout<<st_query(l,r);
}
}
也可为提前计算好所有可能的log值。算法如下:
找到log(i-1)和log(i)之间的关系:
若i是2的某次幂:如i=8,二进制的1000,则i&(i-1)等于0;
否则:i&(i-1)不等于0;
void initlog()
{
lb[0]=-1;
for(int i=1;i<maxn;i++)
lb[i]=(i&(i-1))?lb[i-1]:lb[i-1];
}
最近公共祖先LCA
求解LCA的方法有很多,包括暴力搜索法、树上倍增法、在线RMQ算法、离线Tarjan算法和树链剖分
- 在线算法:以序列化方式一个一个地处理输入,也就是说,在开始时并不需要知道所有输入,在解决一个问题后立即输出结果。
- 离线算法:在开始时已知问题地所有输入数据,可以一次性回答所有问题。
暴力搜索法
有两种:
- 向上标记法:从u节点一直向上走到根节点,标记所有经过的节点,若能标记v,则v=LCA(u,v);否则v也向上走,第一次遇到已标记的节点则为LCA(u,v)。
- 同步前进法:将u,v中较深的节点一直向上走到和另一节点相同深度,二者再同时向上推进,直到走到同一个节点,该点就是 LCA(u,v);若较深的节点u到达另一节点的同一深度时,所在节点就是v,则v=LCA(u,v)。
以暴力法求解LCA,时间复杂度最差都为O(n)。
树上倍增法
f[i][j]表示i的2^j个祖先,如f[u][0]表示u的父节点,f[u][1]表示u的父节点的父节点,若f[i][j]超过根节点,则赋值为0.
递推公式:f[i][j]=f[f[i][j-1]][j-1]
算法思路:
首先让深度较深的节点向上走到与另一节点同一深度,只不过按倍增思想走。
然后到达同一深度的两节点一起向上走,也是采用倍增思想。
void st_creat()
{
for(int j=1;j<=k;j++)
for(int i=1;i<=n;i++)
f[i][j]=f[f[i][j-1]][j-1];
}
int LCA(int x,int y)
{
if(d[x]>d[y])//保证x的深度小于等于y
swap(x,y);
for(int i=k;i>=0;i--)
{
if(d[f[y][i]]>=d[x])//y向上走到与x同一深度
y=f[y][i];
}
if(x==y)
return x;
for(int i=k;i>=0;i--)
if(f[x][i]!=f[y][i])//x,y一起向上走
{
x=f[x][i];
y=f[y][i];
}
return f[x][0];
}
算法分析:
创建ST需要O(nlogn)时间,每次查询需要O(logn)时间,一次建表,多次使用,若只有几次查询,还不如暴力搜索快。
在线RMQ算法
欧拉序列指在深度遍历过程中把经过的节点一个个记录下来,把回溯时的节点也记录下来。
两个节点的LCA一定是两个节点之间欧拉序列中深度最小的节点,寻找深度最小时可以使用RMQ算法
void dfs(int u,int d)
{
vis[u]=true;
pos[u]=++tot;//u首次出现的下标
seq[tot]=u;//dfs遍历得到的欧拉序列
dep[tot]=d;//深度
for(int i=head[i];i;i=e[i].next)
{
int v=e[i].to;
int w=e[i].c;
if(vis[v])
continue;
dist[v]=dis[u]+w;
dfs(v,d+1);
seq[++tot]=u;
deq[tot]=d;
}
}
void st_creat()
{
for(int i=1;i<=tot;i++)//f[i][j]表示从i开始长度为2^j的序列里深度最小的节点的下标
f[i][0]=i;
int k=log2(tot);
for(int j=1;j<=k;j++)
for(int i=1;i<=tot-(1<<j)+1;i++)
if(dep[f[i][j-1]]<dep[f[i+(1<<(j-1))][j-1]])
f[i][j]=f[i][j-1];
else
f[i][j]=f[i+(1<<(j-1))][j-1];
}
int rmq_query(int l,int r)//查询[l,r]的区间最值
{
int k=log2(r-l+1);
if(dep[f[l][k]]<dep[f[r-(1<<k)+1][k]])
return f[l][k];
else
return f[r-(1<<k)+1][k];
}
int LCA(int x,int y)//求x,y的最近公共祖先
{
int l=pos[x];//首次出现的下标
int r=pos[y];
if(l>r)
swap(l,r);
return seq[rmq_query(l,r)];
}
算法分析:
在线RMQ算法是基于倍增和RMQ的动态规划算法,其预处理包括深度遍历和创建ST,需要
O(nlogn)时间,每次查询需要O(1)时间。
注意:虽然都用到了ST,但在线RMQ算法中表示区间最值,树上倍增算法中的ST表示向上走的步数
Tarjan算法
这里的Tarjan算法是用于解决LCA问题的离线算法,利用并查集优越的时空复杂性,可以在O(n+m)时间内解决LCA问题。
- 初始化集合号数组和访问数组,fa[i]=i,vis[i]=0;
- 从u出发深度优先遍历,标记vis[u]=1,深度优先遍历u所有未被访问的邻接点,在遍历过程中更新距离,回退是更新集合号。
- 当u的邻接点全部遍历完毕时,检查关于u的所有查询,若存在一个查询u,v,而vis[v]==1,则利用并查集查找v的祖宗,找到的节点就是u,v的最近公共祖先。
int find(int x)
{
return x!=fa[x]?fa=find(fa[x]):x;
}
void tarjan(int u)
{
vis[u]=1;
for(int i=head[u];i;i=e[i].next)
{
int v=e[i].to;
int w=e[i].c;
if(vis[v])
continue;
dis[v]=dis[u]+w;
tarjan(v);
fa[v]=u;
}
for(int i=0;i<query[u].size();i++)
{
int v=query[u][i];
int id=query_id[u][i];
if(vis[y])
{
int lca=find(v);
ans[id]=dis[u]+dis[v]-2*dis[lca];
}
}
}
树状数组
一维树状数组
树状数组又称二进制索引树(Binary Indexed Trees),修改和查询的时间复杂度均为O(logn),设原数组为a[i],树状数组为c[i],则:
区间长度
c[i]存储的区间长度是2^i的二进制表示下末尾0的个数次幂,如c[6],6二进制是110,末尾有1个0,则c[6]存储长度为2^1,即c[6]=a[5]+a[6]。同理c[5]=a[5],而这个长度其实就是最后一个1和剩下的0,怎么去获得这个数呢?
如i=20,二进制为10100,对它取反为01011,加一为01100,将它与原数相与即可得100,也就是4.
在计算机中二进制数采用的是补码表示,-i的补码正好是i取反加1,因此(-i)&i就是区间的长度。
int lowbit(int i)
{
return (-i)&i;
}
前驱和后继
- 直接前驱:c[i]的直接前驱为c[i-lowbit(i)],即c[i]左侧紧邻的子树的根。
- 直接后继:c[i]的直接后继为c[i+lowbit(i)],即c[i]的父节点。
可利用直接前驱和后继一步步推出所有前驱和后继。
查询前缀和
前i个元素的前缀和sum[i]等于c[i]加上c[i]的前驱
int sum(int i)
{
int s=0;
for(;i>0;i-=lowbit(i))
s+=c[i];
return s;
}
点更新
若对a[i]进行修改,令a[i]加上一个数z,则只需要更新c[i]及其后继(祖先),另这些节点都加上z即可.
void add(int i,int z)
{
for(;i<=n;i+=lowbit(i))
c[i]+=z;
}
注意:树状数组的下标从1开始,不可以从0开始,因为lowbit(0)=0时会出现死循环。
查询区间和
int sum(int i,int j)
{
return sum(j)-sum(i-1);
}
多维树状数组
二维数组a[n][n],树状数组c[][]的查询和修改方法如下:
查询前缀和
二维数组的前缀和实际上是从数组左上角到当前位置(x,y)矩阵的区间和,在一位数组查询前缀和的代码上加上一层循环即可。
int sum(int x,int y)
{
int s=0;
for(int i=x;i>0;i-=lowbit(i))
for(int j=y;j>0;j-=lowbit(j))
s+=c[i][j];
return s;
}
更新
也是加一层循环即可。
void add(int x,int y,int z)
{
for(int i=x;i<=n;i+=lowbit(i))
for(int j=y;j<=n;j+=lowbit(j))
c[i][j]+=z;
}
查询区间和值
int sum(int x1,int y1,int x2,int y2)
{
return sum(x2,y2)-sum(x1-1,y2)-sum(x2,y1-1)+sum(x1-1,y1-1);
}
树状数组的局限性
树状数组主要用于查询前缀和、区间和及点更新,对点查询、区间修改效率较低
- 前缀和查询:普通数组需要O(n)时间,树状数组需要O(logn)时间
- 区间和查询:普通数组需要O(n)时间,树状数组需要O(logn)时间
- 点更新:普通数组需要O(1)时间,树状数组需要O(logn)时间
- 点查询:普通数组需要O(1)时间,树状数组需要O(logn)时间
- 区间修改:普通数组需要O(n)时间,树状数组需要O(n logn)时间
- 减法规则:当问题满足减法规则时,如求从a[i]到a[j]的区间和,可以用sum[j]-sum[i-1],但问题不满足减法规则,如求区间a[i]到a[j]的最大值,就不可以用树状数组,此时可以用线段树解决。
线段树
线段树是一种基于分治思想的二叉树,它的每一个节点都对应一个[L,R]区间,叶子节点的l和r相等,线段树除了最后一层,其他层构成一颗满二叉树。
对于区间最值问题,线段树的每个节点都包含三个域:l,r,mx。其中l和r分别表示区间的左右端点,mx表示区间[l,r]的最值。
线段树的构建:
void build(int k,int l,int r)//创建线段树,节点的存储下标为k,节点的区间为[l,r]
{
tree[k].l=l;
tree[r].r=r;
if(l==r)
{
tree[k].mx=a[l];//scanf("%d",&tree[k].mx);
return;
}
int mid,lc,rc;
mid=(l+r)/2;//划分点
lc=k*2;//左子节点的存储下标
rc=k*2+1;//右子节点的存储下标
build(lc,l,mid);
build(rc,mid+1,r);
tree[k].mx=max(tree[lc].mx,tree[rc].mx);//节点的最大值等于左右子节点最值的最值
}
点更新
采用递归进行点更新:
- 若是叶子节点则修改该节点的最值
- 若是非叶子节点,则判断是在左子树中更新还是在右子树中更新
- 返回时更新节点的最值
void update(int k,int i,int v)//将a[i]更新为v
{
if(tree[k].l==tree[k].r && tree[k].l==i)
{
tree[k].mx=v;
return;
}
int mid,lc,rc;
mid=(tree[k].l+tree[k].r)/2;
lc=k*2;
rc=k*2+1;
if(i<=mid)
update(lc,i,v);
else
update(rc,i,v);
tree[k].mx=max(tree[lc].mx,tree[rc].mx);
}
区间查询
查询一个[l,r]区间的最值。
- 若节点所在的区间被查询区间[l,r]覆盖,则返回该节点的最值。
- 判断是在左子树中查询,还是在右子树中查询。
- 返回最值。
int query(int k,int l,int r)//求[l,r]区间的最大值
{
if(tree[k].l>=l && tree[k].r<=r)
return tree[k].mx;
int mid,lc,rc;
mid(tree[k].l+tree[k].r)/2;
lc=k*2;
rc=k*2+1;
int Max=-inf;//注意:局部变量可以,全局变量不可以
if(l<=mid)
Max=max(Max,query(lc,l,r));
if(r>mid)
Max=max(Max,query(rc,l,r));
return Max;
}
线段树中的“懒操作”
若对区间的所有点都进行更新,不可以对其中每个点更新,复杂度过高,可以引入懒操作。
区间更新
- 若当前节点的区间被查询区间[l,r]覆盖,则仅对该节点进行更新并做懒标记,表示该节点已被更新,对该节点的子节点暂不更新。
- 判断是在左子树中查询还是在右子树中查询。在查询过程中,若当前节点带有懒标记,则将懒标记下传给子节点(将当前节点的懒标记清除,将子节点更新并做懒标记),继续查询。
- 在返回时更新最值。
void lazy(int k,int v)
{
tree[k].mx=v;//更新最值
tree[k].lz=v;//做懒标记
}
void pushdown(int k)//向下传递懒标记
{
lazy(2*k,tree[k].lz);
lazy(2*k+1,tree[k].lz);
tree[k].lz=-1;//清除自己的懒标记
}
void update(int k,int l,int r,int v)//将[l,r]区间的所有元素都更新为v
{
if(tree[k].l>=l && tree[k].r<=r)
return lazy(k,v);
if(tree[k].lz!=-1)
pushdown(k);
int mid,lc,rc;
mid=(tree[k].l+tree[k].r)/2;
lc=2*k;
rc=2*k+1;
if(l<=mid)
update(lc,l,r,v);
if(r>mid)
update(rc,l,r,v);
tree[k].mx=max(tree[lc].mx,tree[rc].mx);
}
区间查询
带有懒标记的区间查询,在查询中若遇到节点有懒标记,则下传懒标记,继续查询。
int query(int k,int l,int r)
{
int Max=-inf;
if(tree[k].l>=l && tree[k].r<=r)
return tree[k].mx;
if(tree[k].lz!=-1)
pushdown(k);
int mid,lc,rc;
mid=(tree[k].l+tree[k].r)/2;
lc=2*k;
rc=2*k+1;
if(l<=mid)
Max=max(Max,query(lc,l,r));
if(r>mid)
Max=max(Max,query(rc,l,r));
return Max;
}
分块
树状数组和线段树维护的信息必须满足信息合并特性(如区间可加、可减),而分块算法作为优化的暴力算法,几乎可以解决所有区间更新和区间查询问题。
分块算法通常将块的大小设为根号n,每一块都有sqrt(n)个元素,最后一块可能少于它,用pos[i]表示第i个位置所属的块。
单点更新:一般先将对应块的懒标记下传,再暴力更新块的状态,时间复杂度为O(sqrt(n));
区间更新:若区间更新横跨若干块,则只需对完全覆盖的块打上懒标记,最多需要修改两端的两个块,对两端剩余的部分暴力更新块的状态。每次更新都最多遍历sqrt(n)个块,遍历每个块的时间复杂度为O(1),两端两个块暴力更新sqrt(n)次,总时间复杂度为O(sqrt(n));
区间查询:与区间更新类似,“大段维护,局部朴素”。
t=sqrt(n*1.0);
int num=n/t;
if(n%t)
num++;
for(int i=1;i<=num;i++)
{
L[i]=(i-1)*t+1;
R[i]=i*t;
}
R[num]=n;
for(int i=1;i<=num;i++)
for(int j=L[i];j<=R[i];j++)
{
pos[j]=i;//表示属于哪个块
sum[i]+=a[j];//计算每块的和值
}
void change(int l,int r,long long d)//[l,r]区间的元素加d
{
int p=pos[l];
int q=pos[r];
if(p==q)
{
for(int i=l;i<=r;i++)
a[i]+=d;
sum[p]+=d*(r-l+1);
}
else
{
for(int i=p+1;i<=q-1;i++)//对中间完全覆盖的块打上懒标记
add[i]+=d;
for(int i=l;i<=R[p];i++)
a[i]+=d;
sum[p]+=d*(R[p]-l+1);
for(int i=L[q];i<=r;i++)
a[i]+=d;
sum[q]+=d*(r-L[q]+1);
}
}
long long ask(int l,int r)//区间查询
{
int p=pos[l];
int q=pos[r];
long long ans=0;
if(p==q)
{
for(int i=l;i<=r;i++)
ans+=a[i];
ans+=add[p]*(r-l+1);
}
else
{
for(int i=p+1;i<=q-1;i++)//
ans+=sum[i]+add[i]*(R[i]-L[i]+1);
for(int i=l;i<=R[p];i++)
ans+=a[i];
ans+=add[p]*(R[p]-l+1);
for(int i=L[q];i<=r;i++)
ans+=a[i];
ans+=add[q]*(r-L[q]+1);
}
return ans;
}