尺取法
今天要分享的例题和尺取法有关。如下
题目描述:给一个长度为 n 的整型数组,要求在这个数组中找出一个最长的连续片段,满足该片段中包含不同数字的个数不超过 k 。
输入格式:第一行给出两个整型数字 n 和 k ,n 和 k 均不超过 5 * 1e5,第二行给出 n 个整型数字,数字均不超过 1e6 。
有两点需要注意
-
要求找到的这个片段必须是连续的,中间不能断开。
-
要求不同数字的个数不超过 k ,而非数字的个数。
样例测试数据为
以第二组测试数据为例,先来模拟一下人工找这个片段的过程
9 3
6 5 1 2 3 2 1 4 5
第一步 ( 6 ) 5 1 2 3 2 1 4 5 片段长度 1 ,不同数字个数 1 满足条件
第二步 ( 6 5 ) 1 2 3 2 1 4 5 片段长度 2,不同数字个数 2 满足条件
第三步 ( 6 5 1 ) 2 3 2 1 4 5 片段长度 3,不同数字个数 3 满足条件
第四步 ( 6 5 1 2 ) 3 2 1 4 5 片段长度 4,不同数字个数 4 不满足条件
这时候,下一步应该怎么进行?超过了长度,那么肯定应该将这个片段变短并且确定下一片段的开始位置和结束位置。
先将片段变短,将最后一个加入的数字清出去。
( 6 5 1 ) 2 3 2 1 4 5
现在摆在面前的有两条路,
第一种选择,从使得上一个片段超出界限的位置开始,寻找新的片段,如下:
6 5 1 ( 2 ) 3 2 1 4 5
这种方法每次找新片段只是在上一个片段结束之后的片段里,有局限性,不能确定上一片段中间的数字是否能够和后面的数字拼接一个更长的满足条件的片段。
第二种选择,从上一个片段开始的下一个位置开始,寻找新的片段,如下:
6 ( 5 ) 1 2 3 2 1 4 5
上一个片段不满足条件后,舍弃掉之后的片段,从上一个片段的第二个数字开始找新的片段,舍弃了上一片段之后所有的可能情况,也有局限性。
根据第一种确定下一片段开始位置的机制,现在为它量身定做了一个实现办法:外层设一个循环,从第一个数字到最后一个数字分别以该数字作为首元素,往后判断,遇到不满足的数字时,将该数字作为下一片段判断的起点。时间复杂度是 o(n*n)。用这种方法肯定能找到结果,但观察发现,这个方法有一个缺点,存在重复判断同一个片段的情况。不信请看:
设外层循环 i = 0 开始,到 i = n 时停止,不包括 i = n 。
假设当 i = 0 时,此时确定的片段为 6 5 1 (2 3 2 1 )4 5 ,
当 i = 3 时,也会确定到片段 6 5 1 (2 3 2 1 ) 4 5 。
这种重复比较的现象在这种方法中是经常出现的。
现在考虑第二种方法。第二种方法看起来也是有局限性的,因为在上一个片段不满足条件后,不判断后面剩余的序列,直接从上一片段开始位置的下一个数字找新的片段,看起来似乎会漏掉之后序列存在最优解的情况。但是,请注意,这个方法是将开始位置从第一个数字逐个往后移的,也就是说,在将正在进行判断的片段位置从前往后移的过程中,一定会将后面的情况排查到。题目要求该片段必须连续,因此在后移的过程中,一定存在一个位置是最优解的位置,所以之前所说的那个缺点被排除了。用这种方法,外层设循环移动初始位置,然后从初始位置开始,进行 o(n)的查找,判断找到的这个片段是不是满足条件长度最大的。这么来看,时间复杂度也是 o(n*n)的。但是相比第一种方案,这种方案有一个优越的地方就是没有重复比较这种现象的发生。
有人就会对第二种方法进行吐槽了:别人重复判断那么多次,和你不重复判断时间是同一量级的。
那现在就对第二种方法进行改造,通过改造让算法的效率大幅提高,改造后的方法就称为“尺取法”。
第二种方法中,后一次确定的片段实际上用到的绝大多数序列成员都属于前一次确定的片段,细微的区别在于减去前一次的尾巴,再给前一次加上新的头。(这里所说的头是片段中新加入的元素,而尾是片段中最早加入的元素)
举例如下:
第一次 ( 6 5 1 ) 2 3 2 1 4 5
第二次 6 ( 5 1 2 )3 2 1 4 5
第二次 6 ( 5 1 2 )3 2 1 4 5
第三次 6 5 ( 1 2 3 )2 1 4 5
(黄色部分保留,改变的只是红色部分)
既然在这种方法中,片段中间是固定的,只是头尾不一样,那就充分利用上一次的结果,改变头尾就可以,这样就不用再一次用 o (n)的时间来确定新片段,只需要使用o (n)的时间将这个片段位置移动一次,整个过程时间复杂度为 o(n),比起改造之前性能有了明显的提升。
改造之后的方法实现是这样的:
while(头没有到右边界)
{
如果长度过长,那么(去掉一个尾部元素)
否则(在头部加入一个新元素)
}
在上面三种方法的实现过程中,需要设置临时变量用来存储之前所有比较过的片段中满足情况的最长片段的左右端点以及长度,并且每确定一个片段,与最优解进行比较,修正最优解的值。这道题要求输出的是长度,因此不用实际存储最优解的序列,如果题目要求存最优解的序列,那么用队列进行实现,每次修正边界值的同时对队列进行修正即可,使用队列主要是考虑到队列 先进先出 的特点。
(说明一下,网上有的地方称尺取法是像尺子一样截取片段,我更钟情于另一种说法,另一种可能更容易被理解,即从整体上欣赏尺取法的过程,可以发现片段加头去尾的过程和毛毛虫蠕动的过程很相似:先将尾巴缩回来,再将头往前伸)
这是全网最后一张能形象展示尺取法过程的动图了,大家不要嫌弃。
#include<cstdio>
#include<cstring>
#include<algorithm>
#include<cmath>
#include<iostream>
using namespace std;
const int MAXN = 5e5+50;
const int MAX = 1e6+5;
int a[MAXN];
int sum[MAX];
int l,r;
int main()
{
int k,n,i;
cin>>n>>k;
for(i=1; i<=n; i++)
cin>>a[i];
int top=0;
int r=1,l=1,start=1;
for(i=1; i<=n; i++)
{
sum[a[i]]++;
if(sum[a[i]]==1)top++;
while(top>k)
{
sum[a[start]]--;
if(sum[a[start]]==0)
top--;
start++;
}
if(i-start+1>r-l+1)
{
l=start;
r=i;
}
}
printf("%d %d\n",l,r);
return 0;
}