Dilworth 定理
一个奇技淫巧
P1020 [NOIP1999 普及组] 导弹拦截
题目分析:
第一问显然是求最长不上升子序列,二分优化即可(这个地方也算个不大不小的难点吧,自行查阅即可,不难)。
第二问需要用到 Dilworth 定理:一个序列最少的最长不上升子序列数量等于其最长上升子序列的长度。
证明就是:
假设我们已经将序列划分为数量最少的
k
k
k个最长不上升子序列,那么我们必然可以在每个序列里面选出一个元素,使得这些元素组成单调递增的一个序列。
否则假设序列 x x x没有选出来任何一个元素。那么考虑 x x x的末尾元素,首先如果把所有最长不上升子序列按照末尾编号的话, x x x不可能是第一个最长不上升子序列。否则 x x x只需要选择末尾元素就可以了。
因此 x x x前面一定存在一个最长不上升子序列已经结尾了,可以直接把 x x x拼接过去,这样就构造了一种情况使得划分的最长不上升子序列的数量减少,矛盾。
因此此时设整个数组的最长上升子序列的长度为 t t t,则 k ≤ t k\leq t k≤t。
并且一个序列只能选出一个数,如果在任意一个不上升子序列内选出两个数,那么这个序列就不可能不上升了。因此 k ≥ t k\geq t k≥t。
则 k = t k=t k=t
如果不能理解 Dilworth 定理,也可以试试这种说法:
引用自 离散小波变换°
示例代码:
#include<iostream>
#include<algorithm>
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<<f[i]<<' ';
// cout<<endl;
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 滑动窗口 /【模板】单调队列
题目分析:
题目分析假定你学习过单调队列的定义。如果你没有学过,董老师的视频非常简洁明了地讲解了单调队列(单调队列实际上维护了队列内元素的双重单调性,一般是值单调和下标单调)。
这个题目能用单调队列解决的关键在于:假设我们正在求max,如果y在x的后面,且y>x,那么x就永远没有机会成为max了,可以直接删去x。这和单调队列的特征是一样的。
其中,第一问和第二问维护的单调性相反,写完第一问之后不必再写第二问,直接把数组元素全部添上负号,再跑一遍第一问的单调队列,输出答案时再把负号删掉就好了。
单调队列最主要的难点在于代码不好写,结合下面的代码,我们可以总结一下,在for循环里面无非干了这三件事:
- 新的元素入队。这里注意如果队伍为空,那么(一定是第一次滑动窗口)一定需要入队。
- “过期”的元素出队。
- 输出答案。
我写的进队函数(push_up,代码很久之前写的了,我也不知道为啥起了这个名字)里无非干了这两件事:
- 如果现有的队尾>新加入的元素,队尾出队。这里注意如果队伍为空,跳过这一句。
- 新元素进队。
示例代码:
#include<iostream>
#include<queue>
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
// <<i<<":"
<<q.front().v
// <<"("<<q.front().p<<","<<q.back().p<<")"
<<' ';
}
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
// <<i<<":"
<<-q.front().v
// <<"("<<q.front().p<<","<<q.back().p<<")"
<<' ';
}
return 0;
}
其实这个代码是有很多细节的,比如||运算符两边的逻辑表达式位置不能调换,push_up函数必须传引用(这里不仅是需要修改q,也是确保时间复杂度)。
完全背包
用完全背包设计的思维题
P5020 [NOIP2018 提高组] 货币系统
题目分析:
这道题的关键在于证明较大的货币系统A(n,a),和较小的货币系统B(m,b)等价,当且仅当b是a的子集。
如果不存在m<n的等价的货币系统存在,显然A(n,a)和B(n,a)等价,答案就是n。
我们非形式化但不失严谨地证明一下:
我们一条数轴表示货币系统A,在这样的数轴上,蓝点代表现有的货币面值,红点代表我们可以表示的货币面值。
有些现有的钞票又可以被更小的钞票表示出来,我在这样的蓝点周围打了红色。
显然,b中所有点要么是蓝点,要么是红点,如果b中的一个点既不是蓝色也不是红色,那么对于非负整数 x,它只能被B表示出来,A、B就不等价了。
更进一步,b中的点不可能带有红色。如果有,删去它结果一定更优。因为比它数值小的点在B中一定能被表示出来(因为A与B等价),所以它也能被更小的点表示出来,完全不需要它本身面值的货币了。
因此,这道题目就被转换成了完全背包统计方案数的题目,如果对于现有货币x,没有除了自己之外的任何方法能把它表示出来,那么这个货币就是必须保留的,反之它就是可有可无的。
为了防止方案数太多,我们把它改成“求价值为i的点,最多能够被几张钞票表示出来”,把结果存在f[i]里面。
示例代码:
#include<iostream>
#include<cstdio>
#include<algorithm>
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] 选课
题目分析:
这个题是非常典型的书上背包,直接上代码:
#include<iostream>
#include<vector>
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<iostream>
#include<vector>
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;
}
这份代码与上面的代码有两个显著的区别,也揭示了上一份代码零分的原因:
- 转移方程错了,上一份代码的转移方程看似合理,但是其实f[u-k]就涵盖了f[u][1](也就是h[u])的情况,如果写成f[u-k-1]+f[u][1]的形式,其实f[u-k-1]已经蕴含着f[u][1],这样就重复计算了。
- 有些状态没有转移到:f[u][1]表示只选自己,那么f[u][m]表示的其实是选了m-1个结点,而不是m个结点,所以事实上f[u][m+1]才是选了m个结点。也就是转移的时候落状态了。