P1638 逛画展 题解
这篇文章不是你想的那样简单。
这篇文章通过正解讲述了双指针技巧的应用,以及介绍了一种维护不可差分信息的树状数组。
本题解法非常多,这里先讲代码简单,个人认为最好的双指针。
0x00 前置知识
想看双指针:敏捷的思维即可
想看树状数组的(不正规)解法:加上对树状数组和 lowbit 的基本了解
0x01 双指针解法
注意到,由于区间连续,我们可以通过双指针的形式枚举闭区间 [ l , r ] [l,r] [l,r] 。
显然每一步可以从左往右枚举区间的右端点 r r r 。对于每一个右端点 r r r ,我们要通过去除区间内多余的(不止一幅的)大师的画。
由于枚举的上一个区间 [ l ′ , r ′ ] [l',r'] [l′,r′] 已经包含所有大师的画,我们只要右移上一步留在原地的左指针 l ′ l' l′ 而不需要重新从头遍历。这属于双指针的一个典型应用。
那么就可以依次求解每一个右端点的最优区间:(伪代码)
procedure best_gap()
{
int l=1,r=1;
区间 ans = Infinity;
forall r in 1 to n:
for (this r):
if (区间 [l,r] 包含了所有大师的画)
尽可能向右推上一步留在原处的 l
ans=min(ans,区间[l,r]);
return ans;
}
对于 O ( 1 ) O(1) O(1) 判断什么时候有了所有大师的画,可以用一个记录每个大师的作品出现次数的桶 bucket [ ] \text{bucket}[] bucket[] 和大师计数器 c n t cnt cnt 解决。
我们先扩张区间 [ l , r ] [l,r] [l,r] ,令每位大师的画都在其中出现。
然后,只要左端点画作对应的大师的画作多于一张,这张画就不必保留,可以右移左指针。
这一操作的伪代码:
procedure push_left_pointer(r)
{
for (this r) ://先更新右指针上的画作
if (bucket[画作[r]的作者]==0)//这个大师没有出现过
大师计数器++;
bucket[右端点大师]++;
while(bucket[画作[l]的作者]>1)//再右移左指针
l++,bucket[画作[l]的作者]--;
}
代码实现如下。
#include <bits/stdc++.h>
using namespace std;
int n,m;
int a[1000005];
int vis[2005],cnt;
int main()
{
scanf("%d %d",&n,&m);
for(int i=1;i<=n;i++)
scanf("%d",&a[i]);
int l=1,r=1,ansl=-1,ansr=1000001;
for(r=1;r<=n;r++)
{
if(!vis[a[r]]) cnt++;
vis[a[r]]++;
while(vis[a[l]]>1) vis[a[l]]--,l++;
if(cnt==m)
if(r-l<ansr-ansl)
ansl=l,ansr=r;
}
cout<<ansl<<' '<<ansr;
return 0;
}
时间复杂度 O ( n ) O(n) O(n)。
0x02 树状数组(歪解)
事先声明:这个解法虽然废但是可以省掉一个 1 0 6 10^6 106 的大数组。
这一切都要从偶然间发现的一个性质说起。
考虑以下样例,取自一次对拍数据。
13 5
331454235124
Match Table:
| 1 2 3 4 5 | L
—|———————————————|——————
1| 0 0 1 0 0 | -
2| 0 0 2 0 0 | -
3| 3 0 2 0 0 | -
4| 3 0 2 4 0 | -
5| 3 0 2 4 5 | -
6| 3 0 2 6 5 | -
7| 3 7 2 6 5 | 7-2=5
8| 3 7 8 6 5 | 8-3=5
9| 3 7 8 6 9 | 9-3=6
10| 10 7 8 6 9 | 10-6=4
11| 10 11 8 6 9 | 11-6=5
12| 10 12 8 6 9 | 12-6=6
13| 10 12 8 13 9 | 13-8=5
其中 Match Table [i] [j] 表示读入到第 i i i 幅画作后,大师 j j j 的画作最后一次出现的位置。不难发现, max ( i ) − min ( i ) \max(i)-\min(i) max(i)−min(i) 最小那一行的行号,就是最优区间右端点的编号。
这其实就是双指针带来的性质的变形,因为在第 i i i 次求解最优区间后, max ( 第 i 行 ) = r , min ( 第 i 行 ) = l \max(第i行)=r,~\min(第i行)=l max(第i行)=r, min(第i行)=l 。
可惜我没想到正解,然而,我写了一个暴力程序模拟。
居然拿了 90 90 90 p t s pts pts 。
#include <bits/stdc++.h>
using namespace std;
int n,m;
int arr[2005];
int Max()
{
int x=-1;
for(int j=1; j<=m; j++)
x=max(x,arr[j]);
return x;
}
int Min()
{
int x=INT_MAX;
for(int j=1; j<=m; j++)
x=min(x,arr[j]);
return x;
}
int main()
{
scanf("%d %d",&n,&m);
int x,l=9999,p;
for(int i=1; i<=n; i++)
{
scanf("%d",&x);
arr[x]=i;
int A=Max(),B=Min();
if(A-B<l && B!=0)
l=A-B,p=B;
}
cout<<p<<' '<<p+l;
return 0;
}
时间复杂度 O ( n m ) O(nm) O(nm) ,过不去很正常。
(但是空间复杂度奇迹般地变成了原来的约 1 500 \frac{1}{500} 5001 ?)
目前看来最能也最该优化的,就是 max ( ) \max() max() 和 min ( ) \min() min() 函数了。每次都遍历一行来 RMQ 显然不现实。
由于在区间内部修改,所以单调栈和单调队列先不考虑;ST 表和笛卡尔树不带修;线段树不会写。一般的树状数组只能维护可差分可结合律的信息,而 RMQ 不满足可差分性质。
我真心不想写线段树,于是就有了这篇题解。
0x03 树状数组维护不可差分信息
整理自非常多的资料,不胜枚举,十分感谢各位神犇作者。
这里以 max \max max 为例,记元素总数为 t o t tot tot 。数组下标从 1 1 1 开始,原数组为 a [ ] a[] a[] ,树状数组为 c [ ] c[] c[] 。
首先是建树操作,只需要将求和换成 max \max max 即可。
void build()
{
for(int i=1; i<=tot; i++)
{
for(int j=i-lowbit(i)+1; j<=i; j++)
c[i]=max(c[i],a[j]);
}
}
接着是查询 (即 queryMax() 函数)。
注意到,c[i] 维护的是 max k = i − lowbit ( i ) + 1 i a [ k ] \max\limits_{k=i-\text{lowbit}(i)+1}^{i}a[k] k=i−lowbit(i)+1maxia[k] 。
那么我们就可以得到
q
u
e
r
y
(
i
,
j
)
=
max
{
(
c
[
j
]
,
q
u
e
r
y
(
i
,
j
−
lowbit
(
j
)
)
)
j
−
lowbit
(
j
)
>
i
a
[
j
]
,
q
u
e
r
y
(
j
−
1
)
j
−
lowbit
(
j
)
<
=
i
query(i,j)=\max \begin{cases} (c[j],query(i,j-\text{lowbit}(j)))~~~~j-\text{lowbit}(j)>i\\ a[j],query(j-1)~~~~~~~~~~~~~~~~~~~~~~~j-\text{lowbit}(j)<=i\\ \end{cases}
query(i,j)=max{(c[j],query(i,j−lowbit(j))) j−lowbit(j)>ia[j],query(j−1) j−lowbit(j)<=i
解释一下:当
j
−
lowbit
(
j
)
>
i
j-\text{lowbit}(j)>i
j−lowbit(j)>i 时,表明
c
[
j
]
c[j]
c[j] 维护的区间最大值在
[
i
,
j
]
[i,j]
[i,j] 之内,递归求解。反之,则只能和
a
[
j
]
a[j]
a[j] 进行比较,并将区间变成
[
i
,
j
−
1
]
[i,j-1]
[i,j−1] 在更小范围内试图求解。
代码中改写为非递归形式求解。这里查询范围是固定的。
int queryMax(int l=1,int r = 0)
{
r=tot;//在这里,类成员函数初始化时,不能同时以这个类的成员设默认值。
int s=-1;
while(r>=l)
{
s=max(a[r],s);
r--;
for(; r-lowbit(r)>=l; r-=lowbit(r))
s=max(c[r],s);
}
return s;
}
最后一步是更新。
结合 c [ i ] c[i] c[i] 的定义可得,一个元素 c [ x ] c[x] c[x] 可转移到 c [ i ] c[i] c[i],当且仅当 x + lowbit ( x ) = i x+\text{lowbit}(x)=i x+lowbit(x)=i。
如下所示。
i = 1001000
可以直接或间接转移到 i 的元素:
1000100 + 100 = 1001000
1000010 + 10 = 1000100
1000001 + 1 = 1000010
反推可得 c [ i ] c[i] c[i] 只可能被 { c [ x ] ∣ x ∈ { i − 2 k ∣ 2 k < l o w b i t ( i ) } } \{c[x]~|~x\in \{i-2^k~|~2^k<lowbit(i)\}\} {c[x] ∣ x∈{i−2k ∣ 2k<lowbit(i)}} 转移过来。
代码如下。
void update(int pos,int val)//更新 a[pos] 为 val
{
a[pos]=val;
int lpos;
while(pos<=tot)
{
c[pos]=a[pos];
lpos=lowbit(pos);
for(int i=1; i<lpos; i<<=1)
c[pos]=max(c[pos],c[pos-i]);
pos+=lpos;
}
}
处理最小值时,把上文的所有 max \max max 换成 min \min min ,将无穷小和无穷大对调即可。
0x04 具体解法
先求出第一个存在 L L L 的 Match Table(即,所有大师的画作第一次都出现时的位置表),用它分别建维护最大值和最小值的树状数组。
每一步修改就在树状数组里 update ( x , i ) \text{update}(x,i) update(x,i) ,然后求值取最优。
时间复杂度约为 O ( n log 2 m ) O(n\log^2m) O(nlog2m)。
通过共用全局数组,可以进一步减小空间占用。
#include <bits/stdc++.h>
using namespace std;
int n,m;
int a[2005];//模拟用的数组,这里共用了
struct fenwick_tree_max//维护区间Max
{
int c[2005],tot;
int lowbit(int x)
{
return x&-x;
}
void build()
{
for(int i=1; i<=tot; i++)
{
for(int j=i-lowbit(i)+1; j<=i; j++)
c[i]=max(c[i],a[j]);
}
}
int queryMax(int l=1,int r = 0)
{
r=tot;
int s=-1;
while(r>=l)
{
s=max(a[r],s);
r--;
for(; r-lowbit(r)>=l; r-=lowbit(r))
s=max(c[r],s);
}
return s;
}
void update(int pos,int val)
{
a[pos]=val;
int lpos;
while(pos<=tot)
{
c[pos]=a[pos];
lpos=lowbit(pos);
for(int i=1; i<lpos; i<<=1)
c[pos]=max(c[pos],c[pos-i]);
pos+=lpos;
}
}
} f1;
struct fenwick_tree_min//维护区间Min
{
int c[2005],tot;
int lowbit(int x)
{
return x&-x;
}
void build()
{
for(int i=1; i<=tot; i++)
{
c[i]=a[i];
for(int j=i-lowbit(i)+1; j<=i; j++)
c[i]=min(c[i],a[j]);
}
}
int queryMin(int l=1,int r = 0)
{
r=tot;
int s=INT_MAX;
while(r>=l)
{
s=min(a[r],s);
r--;
for(; r-lowbit(r)>=l; r-=lowbit(r))
s=min(c[r],s);
}
return s;
}
void update(int pos,int val)
{
a[pos]=val;
int lpos;
while(pos<=tot)
{
c[pos]=a[pos];
lpos=lowbit(pos);
for(int i=1; i<lpos; i<<=1)
c[pos]=min(c[pos],c[pos-i]);
pos+=lpos;
}
}
} f2;
int main()
{
scanf("%d %d",&n,&m);
int x,l=INT_MAX,p,clk=m,i;
for(i=1; clk!=0; i++)
{
scanf("%d",&x);
if(clk!=0)
{
if(!a[x]) clk--;
a[x]=i;
}
}
f1.tot=m,f2.tot=m;
f1.build();
f2.build();
int aa=f1.queryMax(),b=f2.queryMin();
if(aa-b<l)
p=b,l=aa-b;
for(; i<=n; i++)
{
scanf("%d",&x);
f1.update(x,i);
f2.update(x,i);
aa=f1.queryMax(),b=f2.queryMin();
if(aa-b<l)
p=b,l=aa-b;
}
cout<<p<<' '<<l+p;
return 0;
}
空间占用理论上为双指针等解法的 1 167 \frac{1}{167} 1671。
0x05 感想
考的还是思维。我是因为没想到双指针才采用动态 RMQ,大家平时一定要注意思考题目的性质,多打草稿。
一道黄题杀鸡用牛刀不太好,也希望大家能与我一起共同思考对新题进行分析的方法。