CSP难度的经典题目/有趣的思维题选讲(一)

引言

这里讲到的难题是一部分非常典型的题目,但并不是所有。因此这并非是一个全面的知识列表,而只适合提高组同学用来提升能力和拓展视野。

这篇文章在很多地方讲述的不够详细和严谨,因为它的作用并非是题解,而是更加深刻地理解题目的核心难点!

下面列举出的所有问题,一遍看不懂都是非常正常的,我挑选的题目肯定都是我一遍没能搞懂的,其中的重点都会在题目分析里清晰指出。如果看了题目分析后仍然搞不懂,请自行查阅相关题解。

Dilworth 定理

一个奇技淫巧

P1020 [NOIP1999 普及组] 导弹拦截
CSP难度的经典题目/有趣的思维题选讲(一)_第1张图片
题目分析:
第一问显然是求最长不上升子序列,二分优化即可(这个地方也算个不大不小的难点吧,自行查阅即可,不难)。
第二问需要用到 Dilworth 定理:一个序列最少的最长不上升子序列数量等于其最长上升子序列的长度。

证明如下:
假设我们已经将序列划分成了最少的几个最长不上升子序列,那么一定可以从每个中找到一个元素,新生成的序列一定单调上升(否则就最长不上升子序列的数量会减少),而且每个最长不上升子序列中只会被选出一个元素,否则单调性就会产生冲突,因此 Dilworth 定理是对的。

引用自 Apathy_Cui

如果不能理解 Dilworth 定理,也可以试试这种说法:
CSP难度的经典题目/有趣的思维题选讲(一)_第2张图片
引用自 离散小波变换°

示例代码:

#include
#include
using namespace std;
int&merge(int f[],int l,int r,int x) {//在一个不升序列中找到第一个<=x的数字
	if(l==r) return f[l];
	int mid=l+r>>1;
	if(f[mid]<x) return merge(f,l,mid,x);
	else return merge(f,mid+1,r,x);
}
int main() {
	const int N=1.1e5;
	int a[N],f[N],n=1;
	int x;
	while(cin>>x) a[n++]=x;
	n--;
	for(auto&i:f) i=0;
	int cnt=1;
	f[cnt]=a[1];
	for(int i=2;i<=n;i++) {
		if(a[i]<=f[cnt]) f[++cnt]=a[i];
		else merge(f,1,cnt,a[i])=a[i];
	}
//	for(int i=1;i<=cnt;i++) cout<
//	cout<
	cout<<cnt<<endl;

	for(auto&i:f) i=0;
	cnt=1;
	f[cnt]=a[1];
	for(int i=2;i<=n;i++) {
		if(a[i]>f[cnt]) f[++cnt]=a[i];
		else *lower_bound(f+1,f+1+cnt,a[i])=a[i];
	}
	cout<<cnt;
}

单调队列

单调队列可优化具有“滑动窗口”求最值特征的动态规划,就是代码不太好写。

P1886 滑动窗口 /【模板】单调队列
CSP难度的经典题目/有趣的思维题选讲(一)_第3张图片
题目分析:
题目分析假定你学习过单调队列的定义。如果你没有学过,董老师的视频非常简洁明了地讲解了单调队列(单调队列实际上维护了队列内元素的双重单调性,一般是值单调和下标单调)。
这个题目能用单调队列解决的关键在于:假设我们正在求max,如果y在x的后面,且y>x,那么x就永远没有机会成为max了,可以直接删去x。这和单调队列的特征是一样的。
其中,第一问和第二问维护的单调性相反,写完第一问之后不必再写第二问,直接把数组元素全部添上负号,再跑一遍第一问的单调队列,输出答案时再把负号删掉就好了。

单调队列最主要的难点在于代码不好写,结合下面的代码,我们可以总结一下,在for循环里面无非干了这三件事:

  • 新的元素入队。这里注意如果队伍为空,那么(一定是第一次滑动窗口)一定需要入队。
  • “过期”的元素出队。
  • 输出答案。

我写的进队函数(push_up,代码很久之前写的了,我也不知道为啥起了这个名字)里无非干了这两件事:

  • 如果现有的队尾>新加入的元素,队尾出队。这里注意如果队伍为空,跳过这一句。
  • 新元素进队。

示例代码:

#include
#include
using namespace std;
struct coor {
	int v;
	int p;//这里v表示值,p表示下标
};
void push_up(deque<coor>&q,coor x) {
	while(!q.empty()&&q.back().v>x.v) q.pop_back();
	q.push_back(x);
}
int main() {
	int n,m;
	cin>>n>>m;
	int a[n];
	for(auto&i:a) cin>>i;
	deque<coor> q;
	int cnt=0;
	for(int i=0;cnt<n;i++) {
		while(q.empty()||q.back().p<i+m-1) push_up(q,coor{a[cnt],cnt++});
		while(q.front().p<i) q.pop_front();
		cout
//		<
		<<q.front().v
//		<<"("<
		<<' ';
	}
	cout<<endl;
	q.clear();
	for(auto&i:a) i=-i;
	cnt=0;
	for(int i=0;cnt<n;i++) {
		while(q.empty()||q.back().p<i+m-1) push_up(q,coor{a[cnt],cnt++});
		while(q.front().p<i) q.pop_front();
		cout
//		<
		<<-q.front().v
//		<<"("<
		<<' ';
	}
	return 0;
}

其实这个代码是有很多细节的,比如||运算符两边的逻辑表达式位置不能调换,push_up函数必须传引用(这里不仅是需要修改q,也是确保时间复杂度)。

完全背包

用完全背包设计的思维题

P5020 [NOIP2018 提高组] 货币系统
CSP难度的经典题目/有趣的思维题选讲(一)_第4张图片
CSP难度的经典题目/有趣的思维题选讲(一)_第5张图片
题目分析:
这道题的关键在于证明较大的货币系统A(n,a),和较小的货币系统B(m,b)等价,当且仅当b是a的子集。
如果不存在m 我们非形式化但不失严谨地证明一下:
CSP难度的经典题目/有趣的思维题选讲(一)_第6张图片
我们一条数轴表示货币系统A,在这样的数轴上,蓝点代表现有的货币面值,红点代表我们可以表示的货币面值。
有些现有的钞票又可以被更小的钞票表示出来,我在这样的蓝点周围打了红色。
显然,b中所有点要么是蓝点,要么是红点,如果b中的一个点既不是蓝色也不是红色,那么对于非负整数 x,它只能被B表示出来,A、B就不等价了。
更进一步,b中的点不可能带有红色。如果有,删去它结果一定更优。因为比它数值小的点在B中一定能被表示出来(因为A与B等价),所以它也能被更小的点表示出来,完全不需要它本身面值的货币了。

因此,这道题目就被转换成了完全背包统计方案数的题目,如果对于现有货币x,没有除了自己之外的任何方法能把它表示出来,那么这个货币就是必须保留的,反之它就是可有可无的。
为了防止方案数太多,我们把它改成“求价值为i的点,最多能够被几张钞票表示出来”,把结果存在f[i]里面。
示例代码:

#include
#include
#include
using namespace std;
int f[25000+5];
int a[105];
int main() {
	int T;
	scanf("%d",&T); 
	while(T--) {
		for(auto&i:f) i=0; 
		int n;
		scanf("%d",&n);
		for(int i=1;i<=n;i++) 
			scanf("%d",&a[i]),f[a[i]]=1;
		sort(a+1,a+1+n);
		for(int i=a[1];i<=a[n];i++) {
			int k=upper_bound(a+1,a+n+1,i)-a;
			for(int j=1;j<k;j++) 
				if(f[i-a[j]])
					f[i]=max(f[i],f[a[j]]+f[i-a[j]]);
		} 
		int cnt=0;
		for(int i=1;i<=n;i++) 
			if(f[a[i]]>1) cnt++;
		printf("%d\n",n-cnt);
	}
	return 0;
}

整理一下代码思路:

  • 初始化:f[a[i]]=1,本身数值就是货币面值的钱,一定能被它本身表示出来,这是一种方案。
  • 排序一下a数组,方便DP和接下来的二分
  • 枚举一下从a[1]到a[n]的每个数值,然后枚举所有可能组成这个数值的货币,它们的面值一定小于i,可以二分一下。
  • 转移:f[i]=max(f[i],f[a[j]]+f[i-a[j]])
    好像有些优化是多余的,但是这些优化也只是对洛谷上的数据多余而已,理论上它们都不是多余的。

树上背包

思路非常简单,代码不太好写,我们主要说一下树上背包的坑点

P2014 [CTSC1997] 选课
CSP难度的经典题目/有趣的思维题选讲(一)_第7张图片
题目分析:
这个题是非常典型的书上背包,直接上代码:

#include
#include
using namespace std;
int h[305];
bool vis[305];
vector<vector<int>>G;
int f[305][305];
//f[i][j]表示i节点中选修了j门课,
//并且选择i节点(不选那么f=0) 所获得的学分
int fa[305];
int n,m;
void dfs(int u) {
	if(vis[u]) return;
	vis[u]=true;
	f[u][1]=h[1];
	for(auto&v:G[u])dfs(v);
	for(auto&v:G[u])
		for(int k=1; k<=m; k++)
			for(int x=1; x<k; x++)
				f[u][k]=max(f[u][k],f[u][k-x-1]+f[u][1]+f[v][x]);
}
int main() {
	cin>>n>>m;
	vector<int> x;
	for(int i=0; i<=n; i++) G.push_back(x);
	for(int i=1; i<=n; i++) {
		int u=i,v,w;
		cin>>v>>w;
		fa[i]=v;
		h[i]=w;
		G[v].push_back(i);
	}
	dfs(0);
	cout<<f[0][m];
	return 0;
}

转移方程:转移方程:f[u][k]=max(f[u][k],f[u][k-x-1]+f[v][x]+f[u][1]),意思很清晰。
但这个代码是错的,交到洛谷上零分。

感谢北京大学叶博文老师在此代码的基础上做了一定改进,成功通过该题:

#include
#include
using namespace std;
int h[305];
bool vis[305];
vector<vector<int>>G;
int f[305][305];
//f[i][j]表示i节点中选修了j门课,
//并且选择i节点(不选那么f=0) 所获得的学分
int n,m;
void dfs(int u) {
	f[u][1]=h[u];
	for(auto&v:G[u])dfs(v);
	for(auto&v:G[u])
		for(int k=m+1; k>=1; k--)
			for(int x=0; x<k; x++)
				f[u][k]=max(f[u][k],f[u][k-x]+f[v][x]);
}
int main() {
	cin>>n>>m;
	vector<int> x;
	for(int i=0; i<=n; i++) G.push_back(x);
	for(int i=1; i<=n; i++) {
		int u=i,v,w;
		cin>>v>>w;
		h[i]=w;
		G[v].push_back(i);
	}
	dfs(0);
	cout<<f[0][m+1];
	return 0;
}

这份代码与上面的代码有两个显著的区别,也揭示了上一份代码零分的原因:

  1. 转移方程错了,上一份代码的转移方程看似合理,但是其实f[u-k]就涵盖了f[u][1](也就是h[u])的情况,如果写成f[u-k-1]+f[u][1]的形式,其实f[u-k-1]已经蕴含着f[u][1],这样就重复计算了。
  2. 有些状态没有转移到:f[u][1]表示只选自己,那么f[u][m]表示的其实是选了m-1个结点,而不是m个结点,所以事实上f[u][m+1]才是选了m个结点。也就是转移的时候落状态了。

你可能感兴趣的:(算法,c++)