OI入门算法详解:含大量优质习题及题解!

文章目录

  • 单调队列
  • 单调栈
      • 拓展:区间问题的另一个常见解法——双指针
  • 优先队列
  • 链表
  • 分治
  • ST表

单调队列

P2698
题目描述,给定一些矩形,有横坐标x,高度h

求一个最小的窗口,可以使得有一个窗口中的最大高度减最小高度>=d

输入 d 、 n 每个 x y d、n 每个x y dn每个xy

解法:二分答案,判断用一个递增单调队列求滑动窗口最大值,一个递减单调队列求最小值

启示
1.答案满足单调性,即本题中窗口变大一定不会使得窗口内最大值-最小值变小,即答案在某个拐点一测check()=0,另一测check()=1,这个拐点就是答案
2.单调队列滑动窗口不只是可以下标<=窗口大小,对于指定的x也可以做差,使得一个维度控制所有元素在窗口内,另一个维度取最值

Code

#include
#include
using namespace std;
const int maxn=1e5+10;
struct node{
	int x,y;
}a[maxn];
int hx=1,tx,hn=1,tn,mx[maxn],mn[maxn],n,d,ans=-1;
bool cmp(node x,node y){
	return x.x<y.x;
}
bool check(int k){
	hx=1,tx=0,hn=1,tn=0;
	for(int i=1;i<=n;i++){
		while(hx<=tx&&a[mx[hx]].x<a[i].x-k) hx++;
		while(hn<=tn&&a[mn[hn]].x<a[i].x-k) hn++;
		while(hx<=tx&&a[mx[tx]].y<=a[i].y) tx--;
		while(hn<=tn&&a[mn[tn]].y>=a[i].y) tn--;
		mx[++tx]=i,mn[++tn]=i;
		if(a[mx[hx]].y-a[mn[hn]].y>=d) return 1; 
	}
	return 0;
}
int main(){
	scanf("%d%d",&n,&d);
	for(int i=1;i<=n;i++) scanf("%d%d",&a[i].x,&a[i].y);
	sort(a+1,a+1+n,cmp);
	int l=1,r=d;
	while(l<=r){
		int mid=(l+r)>>1;
		if(check(mid)) ans=mid,r=mid-1;
		else l=mid+1;
	}
	printf("%d",ans);
	return 0;
}

单调栈

应用1:找第一个比当前元素大/小的下标

找第一个比当前元素大的下标

解法:与单调队列的思考方式相同,考虑怎么样的 i , j i,j ij中会出现没有用的
若 i > j 并且 a [ i ] < = a [ j ] 则 i 没用 若i>j并且a[i]<=a[j]则i没用 i>j并且a[i]<=a[j]i没用 根据这个原则维护一个单调递减栈,倒序循环,将没有用的排除掉,栈顶即为所求

或者顺序循环,一个元素比原来栈中元素大,弹出栈中比该元素小的元素,那些被弹出的元素的 f ( ) f() f()就是这个元素(不通用,不推荐)

应用2:
维护一个单调的序列(常用,常配合二分进行优化)

这题其实是在主栈的基础上为了实现取最大值的功能加了一个辅助的单调栈,记录最大值,pop与主栈同步。

日志分析

题面:维护一个栈支持入栈出栈,找栈中元素最值

维护一个单调递增的栈,第一个出现的大值有用,后面的值若不必栈顶的大,则没用 若 i > j 并且 a [ i ] < = a [ j ] 则 i 没用 若i>j并且a[i]<=a[j]则i没用 i>j并且a[i]<=a[j]i没用这里和第一题的区别是第一题倒序,所以维护栈的增减性不同,本质上是一样的,都是下标越小值越大越好。

#include
#include
#include 
using namespace std;
stack<pair<int,int> >s1,s2;
int n,cnt;
int main(){
   scanf("%d",&n);
   while(n--){
   	int op;
   	scanf("%d",&op);
   	if(op==0){
   		int t;scanf("%d",&t);
   		s1.push(make_pair(t,++cnt));
   		if(!s2.empty()&&t<=s2.top().first) continue;
   		else s2.push(s1.top());
   	}
   	else if(op==1){
   		if(s1.empty()) continue;
   		while(!s2.empty()&&s1.top().second<=s2.top().second) s2.pop();
   		s1.pop();
   	}
   	else{
   		printf("%d\n",s2.empty()?0:s2.top().first);
   	}
   }
   return 0;
}

不满足要求的不允许进栈

应用3:单调栈维护单调区间+二分优化

和至少为k的子数组
题目描述:找一个子数组和至少为k要连续,原数组中正负都有,最小化子数组长度

解法:若没有负数可以利用单调性双指针 O ( n ) O(n) O(n)

trick:面对有一段连续的区间(删除,求和…)的问题,通常可以考虑前后缀

利用这个trick我们不难想到可以利用前缀和,通过观察前缀和我们可以发现,如果去更多的负数会使得子数组更长不会更优,观察得出好像能成为最优解的前缀和都满足某种单调性

trick:分析一个区间时我们往往先定右端点讨论左端点的情况

s u m [ l , r ] = s [ r ] − s [ l − 1 ] sum[l,r]=s[r]-s[l-1] sum[l,r]=s[r]s[l1]

当我们确定右端点为 s [ r ] s[r] s[r]时可以采用单调队列/栈的思维考虑那些点是没用的,从而对进一步优化有帮助

s [ i ] , s [ j ] 是 s [ r ] 的两个不同的左端点 , i < j ,若 s [ r ] − s [ i − 1 ] < s [ r ] − s [ j − 1 ] 那么 i 没用 , 进一步如果 i < j 并且 s [ i − 1 ] > s [ j − 1 ] 那么 i 没用 s[i],s[j]是s[r]的两个不同的左端点,is[j-1]那么i没用 s[i],s[j]s[r]的两个不同的左端点,i<j,若s[r]s[i1]<s[r]s[j1]那么i没用,进一步如果i<j并且s[i1]>s[j1]那么i没用

这样我们就可以利用单调栈维护一个单调递增的栈,栈中的元素都是合法的左端点,枚举右端点,二分在单调栈中找左端点即可

#include
#include
#define int long long
using namespace std;
const int maxn=1e5+10;
int n,a[maxn],s[maxn],sta[maxn],top,k,ans=0x3f3f3f3f;
signed main(){
   scanf("%lld%lld",&n,&k);
   for(int i=1;i<=n;i++) scanf("%lld",&a[i]);
   for(int i=1;i<=n;i++) s[i]=s[i-1]+a[i];
   for(int i=0;i<=n;i++){
   	if(top){
   		int l=1,r=top,t=-1;
   		while(l<=r){
   			int mid=(l+r)>>1;
   			if(s[i]-s[sta[mid]]>=k) t=sta[mid],l=mid+1;
   			else r=mid-1;
   		}
   		if(t!=-1) ans=min(ans,i-t);
   	}
   	while(top&&s[sta[top]]>=s[i]) top--;
   	sta[++top]=i; 
   }
   printf("%lld",ans);
   return 0;
}

如何想到单调递增栈:下标小值大的元素我们不要,也就是说我们要下标大值小的元素,所以我们接受下标小值小,下标大值大的元素,继而可以确定是单调递增栈

trick:区间问题的常见解法是双指针和前后缀,在有单调性的情况下首选双指针

trick:单调性+二分的情况通常可以被贪心将二分的log给优化掉

优化:维护单调性的除了单调栈还有单调队列,我们可以贪心的想,给一个右端点能和最小的左端点构成的子数组之和>=k那么这个点右边若还有更右的右端点能与最小的匹配肯定没有第一个可以匹配的优,即一个左端点唯一匹配一个右端点,除了这个右端点之外别的右端点跟他匹配都不会成为最优解,因此面对每个右端点每次取队首的若能匹配队首出队更新答案.。时间复杂度: O ( n ) O(n) O(n)

code O ( n ) O(n) O(n)

#include
#include
#define int long long
using namespace std;
const int maxn=1e5+10;
int n,a[maxn],s[maxn],sta[maxn],top,k,ans=0x3f3f3f3f;
int q[maxn],head=1,tail;
signed main(){
	scanf("%lld%lld",&n,&k);
	for(int i=1;i<=n;i++) scanf("%lld",&a[i]);
	for(int i=1;i<=n;i++) s[i]=s[i-1]+a[i];
	for(int i=0;i<=n;i++){
		while(head<=tail&&s[i]-s[q[head]]>=k) ans=min(ans,i-q[head]),head++;
		while(head<=tail&&s[q[tail]]>=s[i]) tail--;
		q[++tail]=i;
	}
	printf("%lld",ans);
	return 0;
}

应用4:单调栈动态(在线)查前/后缀中的最值
奶牛排队
给的一个序列,求l , r ,r ,r使得 a [ l ] a[l] a[l] [ l , r ] [l,r] [l,r]中最小的 a [ r ] a[r] a[r]是最大的
最大化区间长度

解法:枚举右端点(trick)找最优的左端点,首先最优的左端点一定在第一个大于右端点的位置k右边,否则右端点不合法,其次左端点要取 [ k + 1 , r ] [k+1,r] [k+1,r]中最小的,第一是保证了可行性,第二是在他左边的点不可能合法的与这个右端点匹配,其次在他右边的点不会更优

实现:单调栈找第一个大于右端点的位置,用st表找最小的(单调栈也行)

#include
#include
#include
#include
const int maxn=1e5+10;
int a[maxn],n,f[maxn],ans;
stack<int>s;
struct node{
	int val,pos;
}F[maxn][20];
int que(int l,int r){
	int k=log(r-l+1)/log(2);
	return	F[l][k].val<F[r-(1<<k)+1][k].val?F[l][k].pos:F[r-(1<<k)+1][k].pos;
}
int main(){
	scanf("%d",&n);
	for(int i=1;i<=n;i++) scanf("%d",&a[i]),F[i][0].val=a[i],F[i][0].pos=i;
	for(int j=1;j<20;j++)
		for(int i=1;i<=n-(1<<j)+1;i++){
			F[i][j]=min(F[i][j-1],F[i+(1<<(j-1))][j-1]);
			if(F[i][j-1].val<F[i+(1<<(j-1))][j-1].val) F[i][j].val=F[i][j-1].val,F[i][j].pos=F[i][j-1].pos;
			else F[i][j].val=F[i+(1<<(j-1))][j-1].val,F[i][j].pos=F[i+(1<<(j-1))][j-1].pos;
		}
	for(int i=1;i<=n;i++){
		while(!s.empty()&&a[i]>=a[s.top()]) s.pop();
		if(s.empty()) f[i]=0;
		else f[i]=s.top();
		s.push(i);
	}
	for(int r=n;r>1;r--){
		int l=f[r]+1;
		int t=que(l,r);
		if(r-t+1>=2) ans=max(ans,r-t+1);
	}
	printf("%d",ans);
	return 0;
}

实现2:两个单调栈(应用4)
原理: i < j 且 a [ i ] > a [ j ] 得出 i 没用 ia[j]得出i没用 i<ja[i]>a[j]得出i没用而维护最小值,单调栈排除了不可能是最值的情况,使得整个前缀中的元素具有单调性,从而可以二分查找优化,序列中的都可能是某个前缀中要查的子区间的最值。
注意单调栈排除元素取不取等号认真分析其含义,不要想当然

#include
#include
#include
using namespace std;
const int maxn=1e5+10;
int mn[maxn],top,ans,n,a[maxn];
stack<int>s;
int main(){
	scanf("%d",&n);
	for(int i=1;i<=n;i++) scanf("%d",&a[i]);
	for(int r=1;r<=n;r++){
		while(!s.empty()&&a[s.top()]<a[r]) s.pop();//这里相等的也要留在栈用,因为右端点要严格最大
		while(top&&a[mn[top]]>=a[r]) top--;
		int l=1,rr=top,k=-1,pos=s.empty()?1:s.top()+1;
		while(pos!=-1&&l<=rr){
			int mid=(l+rr)>>1;
			if(mn[mid]>=pos) k=mn[mid],rr=mid-1;
			else l=mid+1;
		}
		if(k!=-1) ans=max(ans,r-k+1);
		s.push(r),mn[++top]=r;
	}
	printf("%d",ans);
	return 0;
} 

拓展:区间问题的另一个常见解法——双指针

逛画展

题目描述:给定一个长为n的数组和k,找一个最短的子数组使得其中包含1-k所有元素

解法:l=1,r=2一开始,统计区间中出现的元素,不合法移动右指针,直到合法,移动左指针直到不合法,对于每个可能合法且可能成为最优解的右指针,都会统计到,利用单调性易证,因为固定右端点后左指针总会到一个临界处再往右就不合法了(即有单调性),所以可以双指针

Code

#include
using namespace std;
const int maxn=1e6+10;
bool vis[maxn];
int n,m,a[maxn],last[maxn],ans=0x3f3f3f3f,al,ar;
int main(){
	scanf("%d%d",&n,&m);
	for(int i=1;i<=n;i++) scanf("%d",&a[i]);
	int i=1,cnt=1;
	last[a[1]]=1;
	for(int j=2;j<=n;j++){
		if(last[a[j]]<i) cnt++;
		vis[last[a[j]]]=1,last[a[j]]=j;
		while(vis[i]) i++;
		if(cnt==m){
			if(ans>j-i+1){
				al=i,ar=j,ans=j-i+1;
			}
			i++,cnt--;
		}
	}
	printf("%d %d\n",al,ar);
	return 0;
}

优先队列

应用1:对顶堆
中位数
开两个堆,动态维护区间第k大,大根存比中位数元素小的,小根存比中位数大的,插入时和大根顶或小根顶比较一下确定插到哪个堆,如果两个堆的size的差值在新插入一个元素后变得不满足要求了,则将一个对顶的元素换到另一个 O ( n l o g n ) O(nlogn) O(nlogn)动态查排名

应用2:多路归并优化

序列合并

trick1:排序问题可以分析哪些东西是有序的,如果有多个有序的序列时,可以考虑归并
trick2:当我们遇到很多有序序列要合并成一个较长的有序序列时,可以采用归并策略,取最值时可以用堆优化

实现时模拟归并顺序即可

Code:

#include
#include
using namespace std;
const int maxn=1e5+10;
int a[maxn],b[maxn],n;
struct node{
	int x,y,sum;
	node(int x_,int y_,int sum_){
		x=x_,y=y_,sum=sum_;
	}
	node(){}
};
bool operator<(node x,node y){
	return x.sum>y.sum;
}
priority_queue<node>q;
int main(){
	scanf("%d",&n);
	for(int i=1;i<=n;i++) scanf("%d",&a[i]);
	for(int i=1;i<=n;i++) scanf("%d",&b[i]);
	for(int i=1;i<=n;i++) q.push(node(1,i,a[1]+b[i]));
	for(int i=1;i<=n;i++){
		printf("%d ",q.top().sum);
		node now=q.top();
		q.pop();
		q.push(node(now.x+1,now.y,a[now.x+1]+b[now.y]));
	}
	return 0;
}

解法2:调和级数枚举

面对超时的枚举,有可能出现(乘积,倍数等特征时可以利用调和级数优化)
题解+证明

应用3:找n个数中最大、小的m个数 O ( n l o g m ) O(nlogm) O(nlogm),现将前m个压入堆,后面每扫到一个能替换就和小根堆中的最小元素替换,最后堆内的元素就是最大的m个元素

链表

小技巧:

	a[0].nxt=1,a[n+1].pre=n;

数组模拟双向链表时可以加以上这句,方便找头和判断是否出界

遇到多次插入、删除、合并两个数组操作首选链表

查找能直接下标尽量下标,避免浪费时间

写链表要注意的点:
1.链表的第0,1,n,n+1个元素的链接关系,初值千万不要忘记赋,仔细检查这几个元素和其他元素的链接关系修改后会不会越界
2.链表要删/增与合并同时进行时,一定要搞清先后关系,尽量拆分成清晰的多个步骤,不同步骤之间不要有重合部分(如:合并后再删去新的大的中的元素,又在大的中再合并),不要有,非常容易错。一般先增删完成之后再考虑合并操作
3.多造数据检查,不要过了样例想当然,测大样例时观察出错部分有什么结构,自己可以根据这个结构构造

分治

三要素:
1.子与原形式相同
2.子与子直接相互独立(没有交集)
3.能缩成可以直接求解的小问题

trick:一维分组我们常常二分,二维上我们常常在x,y轴上都二分,将整个平面一份为4的求解
在二分法中,我们将合并和分解的总时间与O(n)比较如果相等则复杂度为 O ( n l o g n ) O(nlogn) O(nlogn)否则取 O ( n ) O(n) O(n) O ( f ( 合并和分解 ) ) O(f(合并和分解)) O(f(合并和分解))中较大的

所以我们想用分治讲 O ( n 2 ) O(n^2) O(n2)的优化成 O ( n l o g n ) O(nlogn) O(nlogn)一定要想出一种较快的( O ( n l o g n ) 或 O ( n ) 之类的 O(nlogn)或O(n)之类的 O(nlogn)O(n)之类的)方法合并分解,否则达不到优化效果

trick:一个问题用 O ( n 2 ) O(n^2) O(n2)的方法TLE要优化成 O ( n l o g n ) O(nlogn) O(nlogn)时(通常不会有做法比 O ( n l o g n ) O(nlogn) O(nlogn)更优),可以往排序、二分、分治等方面想,满足分治要素通常都可以用分治优化

解题方法:

分治算法一般假设两个部分已经处理好,思考的难点在于统计两个部分都在的答案的方法,合并时答案通常为左部分+右部分+公共(公共难想)

e.g.最近点对

最近点对有待于理解透彻

ST表

必要条件:

  • 区间满足可重复贡献的性质。(即重叠的部分计算两次对答案没影响)
  • 运算满足结合律。

原理,由于最后计算用的是可相交的性质,所以有以上要求。由于可以直接用两个段表示,所以可以O(1)查询。

相交的性质是st比线段树快的关键。

线段树都是不相交的。

不支持修改 O ( n l o g n ) O(nlogn) O(nlogn)建表, O ( 1 ) O(1) O(1)查询。

优势相对于线段树在于:

  • 常数小。
  • 查询快。

不光可以计算RMQ,还可以计算区间gcd,lcm,按位与和,按位或和等。

都要用二元运算,不要算lcm的时候用多个数乘积。

有趣的是,这些计算往往和RMQ有关,lcm和gcd和唯一分解定理的指数取maxmin,按位与或是按位取minmax。

特别的,区间gcd,lcm查询不会比线段树优,因为gcd需要log的时间。

ST表+二分妙用

由于区间扩大最值肯定单调递增/减,利用这个性质可以在序列问题上要查最值、最近等信息的时候,配合二分可以找到最近能满足的

e.g.JFCA

一种有通用性的思想:一个区间内如果最值满足其他一定满足,例如找一个位置使得数组左边都比他大,那么找左边的最小值,如果最小值都比他大了那么肯定是符合要求的

你可能感兴趣的:(算法,数据结构)