多种方法求解区间最值问题
著名计算机学家曾提出:程序=算法+数据结构,这句话被广大程序员们奉为圭臬。我是这样理解这句话的:如果说算法是指导我们用什么样的方法与步骤来解决一个问题,则在问题中不可避免的要处理各种数据信息,如何来组织这些数据信息,就依赖于数据结构了,是将这些数据组织成线性的,还是树型的,则见仁见智、不一而足了。
例如下面这个问题:
给定M及一列数,每个数在0到100,000之间。(为了方便描述,设共N个数,1< N < = 2500000) 输出每M个数中的最大数,即1~M中的最大数,2~M+1中的最大数……N-M+1~N中的最大数,共N-M+1个。
输入
第一行是一个数M,接下来是N个数,每个数一行,以-1作为结尾.
输出
输出N-M+1个最大数,每个数一行。
样例输入
3
10
11
10
0
0
0
1
2
3
2
-1
样例输出
11
11
10
0
1
2
3
3
这个题意非常简单,就是求一个固定长度区间内的最大值。如果我们不加任何思考的话,可以将读入的数据放到一个线性表f数组中,然后枚举开始点i,遍历求出区间[i,i+m-1]中最大值,于是时间复杂度为O(n*m),程序代码如下:
for (int i=1;i<=n-m+1;i++)
{
ans=f[i];
for (int j=i+1;j<=i+m-1;j++)
if (f[j]>ans)
ans=f[j];
cout<<ans<<endl;
}
对于题中的信息,有两个要素即位置和权值。上面这个做法优先考虑了位置关系,即先固定好要考察的区间[i,i+m-1],然后再来解决求最大值的问题。如果我们变换下思维方式,优先考虑最大值,再来解决区间这个约束条件,会发现这个问题中最核心的需求就是不断的求最大值,而堆是解决这一类需要的一个利器。于是我们可以将数据组织成树型结构,即将读入的数值设计成一个大根堆,即堆顶元素就是全局最大值,并记下每个数值对应的在输入时的位置,由于本题是求一个指定区间的最大值,于是我们还需要对堆顶元素进行判断一下它是否位于指定区间,如果在的话,则直接输出堆顶元素的值,否则踢掉堆顶元素。程序代码如下:
#include<bits/stdc++.h>
using namespace std;
const int N=6e5+5;
struct num
{
int w,v;
bool operator <(const num x)const
{
return (v<x.v)||(v==x.v&&w<x.w);
}
} p;
priority_queue<num>q;
int n,k,top,a[N],ans[N];
int main() {
int x,k;
cin>>k;
while(~scanf("%d",&x)&&x!=-1)
a[++n]=x;
for(int i=1; i<=k; i++)
{
p.w=i,p.v=a[i];
q.push(p);
}
top=1;
ans[top]=q.top().v;
for(int i=k+1; i<=n; i++)
{
p.w=i,p.v=a[i];
q.push(p);
while(i-q.top().w>=k)
q.pop();
ans[++top]=q.top().v;
}
for(int i=1; i<=top; i++)
printf("%d\n",ans[i]);
}
进一步反思上面这个做法,会发现存在大量的数据冗余------存在大量明显无用的数据在堆里面。而在求出一个区间[i,i+m-1]的结果后,接下来我们要求[i+1,i+m]这个区间的结果,这两个区间比较一下就会发现,无非将第i个位置上的数值去掉,加入第i+m个位置上的值。此时的结果有两种可能,要么仍是从前区间[i,i+m-1]结果,要么是新加入的元素。于是我们又回到线性数据结构,用代表第个数对应的答案,表示第个数,于是维护这样一个队列:队列中的每个元素有两个域{position,value},分别代表他在原队列中的位置和,我们随时保持这个队列中的元素position域单调递增,value域单调递减,。则在计算的时候,先将加入到队列中,如何加入队列呢?我们让a[i]与队尾元素的 value域进行比较,但凡发现小于a[i]的,一律从队列中踢掉,为什么要踢掉呢?那是因为由于是随着单调递增的,所以对于,在计算任意一个状态的时候,都不会比优,所以j被踢掉是有理有据的,并且通过这样的“踢数据”的操作,我们有效的降低了需要维护的数据的量,对比堆的操作中,这些无效的数据仍放在堆中,在加数据的操作时,无疑增加了操作的次数。于是采用这种方法,每个数据进出队列都只有一次,所以时间复杂度为O(n),而堆的时间复杂度为O(n*log2n)。接下来我们还要在队首不断删除,直到队首的position大于等于,那此时队首的value必定是的不二人选,因为队列是单调的!程序代码如下:
#include<bits/stdc++.h>
using namespace std;
int n,d,num,f[2500001],a[2500001],b[2500001];
int main()
{
num=0;
scanf("%d",&n);
while(true)
{
scanf("%d",&d);
if(d==-1)
break;
num++;
a[num]=d;
}
int head=1,tail=1,now=1;
f[1]=a[1]; //存放值域
b[1]=1; //存放位置
for (int now=2;now<=num;now++)
{
while(tail>=head&&a[now]>f[tail])
//维护一个值域单调不上升的队列
tail--;
tail++;
f[tail]=a[now];
b[tail]=now;
if(now-b[head]>=n)
//控制队列头的位置域在所要求的范围之内
head++;
if(now>=n)
printf("%d\n",f[head]);
}
}
此外本题还可以使用st表,线段树等数据结构来进行维护,介于篇幅原因,不再赘述,列表如下:
数据结构 |
空间复杂度 |
时间复杂度 |
编码难度 |
堆 |
O(n) |
O(n*log2n) |
简单 |
单调队列 |
O(n) |
O(n) |
简单 |
线段树 |
O(4*n) |
O(n*log2n) |
中等 |
St表 |
O(n*log2n) |
O(n*log2 m) |
中等 |
综上所述,有两点心得体会,首先对于给定的信息,往往会有多个属性,当我们选择的主攻方向,如果实际效果并不好时,就要变换思考的方式了,这个现象是在编程学习中经常遇到的。其次数据结构与算法如同习武之人的内功与外功,两者相辅相成,我们在针对某个问题设计程序时,当发现在算法上没有好的方法时,就想想如何从数据结构上进行突破,反之亦然,而当有多种数据结构可供选择时,则需要细细体会它们之间的优劣,例如此题用线段树也可以完成,然而线段树的常数较大,并且编码相对来说要复杂一些,所以并不推荐,但如果本题进行改编,变成即有询问操作,又有数值的更改操作时,线段树就能发挥其特长了,所以每种数据结构都有其适用的场景,关键是我们在了解其来龙去源的前提下,活学活用。