[学习笔记]浅谈贪心与分治
写在前面:本篇含有大量口胡,欢迎爆踩。
本篇题单:浅谈贪心与分治
贪心
什么是贪心
贪心算法,是指用计算机来模拟一个 贪心 的人做出决策的过程。这个人十分贪婪,每一步行动总是按某种指标选取最优的操作。而且他目光短浅,总是只看眼前,并不考虑以后可能造成的影响。
显然,不是所有的问题贪心都能保证正确,所以在用贪心算法时要证明其正确性。
适用范围及证明方式
决定一个贪心算法是否能找到全局最优解的条件:
1.有最优子结构
2.有最优贪心选择属性
最优子结构很好理解,就是指一个问题的最优解包含其子结构的最优解,和动态规划上理解是一样的。而贪心选择性是指所求问题的整体最优解可以通过一系列可以通过一系列局部最优的选择来达到。他总是作出当前最好的选择,该选择可以依赖于之前的选择,但绝不依赖于将来的选择和子问题的选择,这是他与动态规划的重要区别。
一般我们证明一个题目可以用贪心就是证明上面两点均满足。
反证法:如果交换方案中任意两个元素/相邻的两个元素后,答案不会变得更好,那么可以推定目前的解已经是最优解了。
归纳法:先算得出边界情况(例如 \(n = 1\))的最优解 \(F_1\),然后再证明:对于每个 \(n\) ,\(F_{n+1}\) 都可以由 \(F_{n}\) 推导出结果。
如何求解
从问题的某个初始解出发,当可以向求解目标前进一步时,就根据局部最优策略,得到一个不分解,缩小问题的范围或规模;将所有的部分解综合起来,得到问题的最终解。
常见题型
在提高组难度以下的题目中,最常见的贪心有两种。
「我们将 XXX 按照某某顺序排序,然后按某种顺序(例如从小到大)选择。」。
「我们每次都取 XXX 中最大/小的东西,并更新 XXX。」(有时「XXX 中最大/小的东西」可以优化,比如用优先队列维护)
二者的区别在于一种是离线的,先处理后选择;一种是在线的,边处理边选择。
在提高组难度以上的题目中,贪心往往是作为一种辅助思想而非全部考点。
一些解法
1.排序解法
用排序法常见的情况是输入一个包含几个(一般一到两个)权值的数组,通过排序然后遍历模拟计算的方法求出最优值。
2.后悔解法
思路是无论当前的选项是否最优都接受,然后进行比较,如果选择之后不是最优了,则反悔,舍弃掉这个选项;否则,正式接受。如此往复。
推荐:
点击查看代码
int n,m,ans=0;
bool f[200010];
struct A
{
int l,r,val;
}a[200010];
struct node
{
int val,pos;
bool operator <(node k) const
{
return val<k.val;
}
};
priority_queue<node>q;
void shanchu(int x)
{
a[x].l=a[a[x].l].l;
a[x].r=a[a[x].r].r;
a[a[x].l].r=x;
a[a[x].r].l=x;
return ;
}
int main()
{
n=read(),m=read();if (n<m*2) puts("Error!"),exit(0);
for (int i=1;i<=n;++i) a[i].val=read(),a[i].l=i-1,a[i].r=i+1,q.push((node){a[i].val,i});
a[1].l=n,a[n].r=1;
for (int i=1;i<=m;++i)
{
while (f[q.top().pos]) q.pop();
node now=q.top();q.pop();
ans+=now.val;
f[a[now.pos].l]=f[a[now.pos].r]=1;
a[now.pos].val=a[a[now.pos].l].val+a[a[now.pos].r].val-a[now.pos].val;
q.push((node){a[now.pos].val,now.pos});
shanchu(now.pos);
}
cout<<ans;
return 0;
}
我们可以快速想出一种贪心策略:买入价格最小的股票,在可以赚钱的当天卖出。
显然我们可以发现,上面的贪心策略是错误的,因为我们买入的股票可以等到可以赚最多的当天在卖出。
我们考虑设计一种反悔策略,使所有的贪心情况都可以得到全局最优解。定义 \(C_b\) 为全局最优解中买入当天的价格,\(C_s\) 为全局最优解中卖出当天的价格,则:
\(C_i\) 为任意一天的股票价格。
即我们买价格最小的股票去卖价格最大的股票,以期得到最大的利润。我们先把当前的价格放入小根堆一次(这次是以上文的贪心策略贪心),判断当前的价格是否比堆顶大,若是比其大,我们就将差值计入全局最优解,再将当前的价格放入小根堆(这次是反悔操作)。相当于我们把当前的股票价格若不是最优解,就没有用,最后可以得到全局最优解。
点击查看代码
priority_queue<ll,vector<ll>,greater<ll>> q;
int main()
{
ll n=read(),x,ans=0;
for (int i=1;i<=n;++i) {x=read();
if (!q.empty()&&q.top()<x) ans+=x-q.top(),q.pop(),q.push(x);q.push(x);}
cout<<ans;
}
带悔贪心基础常见题型为以上两种,均有较多例题,可以多倍经验可以多加练习。
高级带悔贪心题目全是模拟费用流,暂且不表(
其实贪心更像是一种思想,而我们平时做题中经常用到,所以没什么题好推荐。
分治
“分治”即“分而治之”的意思。分治算法解决问题的思路是:先将整个问题拆分成多个相互独立且数据量更少的小问题,通过逐一解决这些简单的小问题,最终找到解决整个问题的方案。
所谓问题间相互独立,简单理解就是每个问题都可以单独处理,不存在“谁先处理,谁后处理”的次序问题。
使用分治算法解决的问题都具备这样的特征,当需要处理的数据量很少时,问题很容易就能解决,随着数据量增多,问题的解决难度也随之增大。分治算法通过将问题“分而治之”,每个小问题只需要处理少量的数据,每个小问题都很容易解决,最终就可以解决整个问题。
分治算法的弊端也很明显,该算法经常和递归算法搭配使用,整个解决问题的过程会耗费较多的时间和内存空间,严重时还可能导致程序运行崩溃。
适用情况
1.该问题的规模缩小到一定的程度就可以容易地解决;
2.该问题可以分解为若干个规模较小的相同问题,即该问题具有最优子结构性质;
3.利用该问题分解出的子问题的解可以合并为该问题的解;
4.该问题所分解出的各个子问题是相互独立的,即子问题之间不包含公共的子子问题。
第一特征是绝大多数问题都可以满足的,因为问题的计算复杂性一般是随着问题规模的增加而增加。
第二特征是应用分治法的前提它也是大多数问题可以满足的,此特征反映了递归思想的应用。
第三特征是关键,能否利用分治法完全取决于问题是否具有第三特征,如果具备了第一和第二特征,而不具备第三特征,则可以考虑用贪心法或动态规划法。
第四特征涉及到分治法的效率,如果各子问题是不独立的,则分治法要做许多不必要的工作,重复地解公共的子问题,此时虽然可用分治法,但一般用动态规划法较好。
二分
二分查找(英语:binary search),也称折半搜索(英语:half-interval search)、对数搜索(英语:logarithmic search),是用来在一个有序数组中查找某一元素的算法。
过程(以在一个升序数组中查找一个数为例)
它每次考察数组当前部分的中间元素,如果中间元素刚好是要找的,就结束搜索过程;如果中间元素小于所查找的值,那么左侧的只会更小,不会有所查找的元素,只需到右侧查找;如果中间元素大于所查找的值同理,只需到左侧查找。
二分答案:对于题目中要求答案进行二分,再通过一个判断函数确定答案的正确性。
这个算法大家应该都比较熟悉,不做展开。
三分
三分主要用来解决单峰函数的极值点。
三分与二分思路相似,我们先求出一个 \(mid\) ,再随机选出两个 \(lmid\) 和 \(rmid\) ,判断 \(f_{lmid}\) 与 \(f_{rmid}\) 的大小关系,根据题目要求舍去一个。
eps三分:取 \(lmid=mid-eps\) , \(rmid=mid+eps\) ,这样写起来就和二分一样,比较好写。
代码(感谢pcq):
点击查看代码
while(fabs(r-l)>=eps)
{
double mid=(l+r)/2;
if(f(mid+eps)>f(mid-eps))l=mid;
else r=mid;
}
黄金分割比三分:见博客,不要求掌握。
板子:三分法
CDQ分治
CDQ分治,又称基于时间的分治算法,常用于解决多维偏序问题。该算法可以通过增加 \(log(n)\) 的代价将偏序问题降掉一维,从而转化成更易解决的多维偏序问题。一般分为三类:解决和点对有关的问题;一维动态规划的优化与转移;通过 CDQ 分治,将一些动态问题转化为静态问题。
设解决无动态修改操作的原问题 的复杂度为f(n),则总复杂度为O(f(n)log(n))
CDQ分治一般分三步。
- 找到当前区间 \([l,r]\) 。
- 递归处理区间 \([l,mid] , [mid+1,r]\) 。
- 处理左区间对右区间的影响并对答案进行修正(核心)(双指针和树状数组查询前缀和一类的操作。)。
第一类就是以上做法,分为 \(i,j\) 在同一个递归区间中和不在同一个递归区间中,核心是设计算法处理 \(i,j\) 不在同一个递归区间中的情况。
第二类的处理顺序要发生改变:
- 递归处理区间 $[l,mid] $。
- 处理左区间对右区间的影响并对答案进行修正。
- 递归处理区间 \([mid+1,r]\) 。
为什么呢。
普通 CDQ 的写法是,当递归到 \([l,mid],[mid+1,r]\) 时,会计算它内部的相互贡献,然后通过 \([l,mid]\) 计算对 \([mid+1,r]\) 的贡献。
但是有可能出现计算左边部分对右边部分的贡献的结果会影响到右边部分内部的情况,所以不能先计算左右区间内部贡献。
因为这种情况需要保证当递归 \([l,r]\) 时,\(dp_l\) 至 \(dp_r\) 的值必须全部计算好。所以在递归 \([mid+1,r]\) 时要保证左边区间不再对右边做出贡献。
前两类问题使用分治目的是:将序列折半,递归处理点对之间的关系。
不过第三类问题要求折半 时间序列。
它适用于一些「需要支持做 xxx 修改然后做 xxx 询问」的数据结构题。该类题目有两个特点:
若把询问离线,所有操作会按时间排成一个序列。
每一个修改和之后的询问息相关,有 \(O(n^2)\) 对。
分治的操作和处理点对关系的分支操作也相同。
如果各个修改之间是 独立 的话,就不需要处理左右区间的时序问题。
如果不独立,那么两个区间可能会进行依赖关系,此时所有跨越 \(mid\) 的修改必须放在 solve(l,mid)
和solve(mid+1,r)
之间。
推荐习题:
(板子)三维偏序
点击查看代码
int lowbit(int x) {return x & -x;}
void add(int x,int kk) {while (x<=k) tree[x]+=kk,x+=lowbit(x);return ;}
int ask(int x) {int now=0;while (x) now+=tree[x],x-=lowbit(x);return now;}
bool cmx(node kk,node kkk)
{
if (kk.x==kkk.x)
{
if (kk.y==kkk.y) return kk.z<kkk.z;
return kk.y<kkk.y;
}
return kk.x<kkk.x;
}
bool cmy(node kk,node kkk)
{
if (kk.y==kkk.y) return kk.z<kkk.z;
return kk.y<kkk.y;
}
void cdq(int l,int r)
{
if (l==r) return ;
cdq(l,mid);cdq(mid+1,r);
sort(a+l,a+mid+1,cmy);sort(a+mid+1,a+r+1,cmy);
int j=l;
for (int i=mid+1;i<=r;++i) {while (a[j].y<=a[i].y&&j<=mid) add(a[j].z,a[j].w),j++;a[i].ans+=ask(a[i].z);}
for (int i=l;i<j;++i) add(a[i].z,-a[i].w);
return ;
}
int main()
{
n1=read(),k=read();
for (int i=1;i<=n1;++i) b[i].x=read(),b[i].y=read(),b[i].z=read();
sort(b+1,b+n1+1,cmx);
int now=0;
for (int i=1;i<=n1;++i) {now++;if (b[i].x!=b[i+1].x||b[i].y!=b[i+1].y||b[i].z!=b[i+1].z) a[++n]=b[i],a[n].w=now,now=0;}
cdq(1,n);
for (int i=1;i<=n;++i) ans[a[i].ans+a[i].w-1]+=a[i].w;
for (int i=0;i<n1;++i) cout<<ans[i]<<endl;
return 0;
}
(优化DP)Cool loves touli
点击查看代码
bool cmp1(node x,node y) {return x.l<y.l;}
bool cmp2(node x,node y) {return x.a<y.a;}
bool cmp3(node x,node y) {return x.s<y.s;}
void cdq(int l,int r)
{
if (l==r) return ;
sort(alpha+l,alpha+r+1,cmp1);
cdq(l,mid);
sort(alpha+l,alpha+mid+1,cmp2),sort(alpha+mid+1,alpha+r+1,cmp3);
//for (int i=l;i<=r;++i) cout<<p[i]<<' ';cout<<endl;
int j=l;
for (int i=mid+1;i<=r;++i)
{
while (j<=mid&&alpha[j].a<=alpha[i].s) add(alpha[j].w,alpha[j].dp),j++;
alpha[i].dp=max(alpha[i].dp,ask(alpha[i].a)+1);
//cout<<p[i]<<' '<<f[p[i]]<<endl;
}
for (int i=l;i<=j-1;++i) cl(alpha[i].w);
cdq(mid+1,r);
return ;
}
(动态转静态)城市建设
四维偏序:
CDQ套CDQ,不一定讲。
德丽莎世界第一可爱 (要卡常)
点击查看代码
void add(int x,ll kk) {while (x<=n) tree[x]=max(tree[x],kk),x+=lowbit(x);return ;}
ll ask(ll x) {ll now=0;while (x) now=max(now,tree[x]),x-=lowbit(x);return now;}
void cl(int x){while(x<=n&&tree[x]) tree[x]=0,x+=x&-x;return ;}
bool cmp1(node x,node y) {return x.a==y.a?(x.b==y.b?(x.c==y.c?(x.d<y.d):x.c<y.c):x.b<y.b):x.a<y.a;}
bool cmp2(node x,node y) {return x.b==y.b?(x.c==y.c?x.d<y.d:x.c<y.c):x.b<y.b;}
bool cmp3(node x,node y) {return x.c==y.c?x.d<y.d:x.c<y.c;}
void cdq2(int l,int r)
{
if (l==r) return ;
cdq2(l,mid);ll nl=l,now;
sort(b+l,b+mid+1,cmp3),sort(b+mid+1,b+r+1,cmp3);
for (int i=mid+1;i<=r;++i) {while (nl<=mid&&b[nl].c<b[i].c) {if (!b[nl].fl) add(b[nl].d,dp[b[nl].pos]);nl++;}if (b[i].fl) now=ask(b[i].d),dp[b[i].pos]=max(1ll*dp[b[i].pos],now+b[i].w);}
for (int i=l;i<nl;++i) if (!b[i].fl) cl(b[i].d);
for (int i=mid+1;i<=r;++i) lss[b[i].dp]=b[i];
for (int i=mid+1;i<=r;++i) b[i]=lss[i];
cdq2(mid+1,r);
return ;
}
void cdq(int l,int r)
{
if (l==r) return ;
cdq(l,mid);
for (int i=l;i<=r;++i) b[i]=a[i];for (int i=mid+1;i<=r;++i) b[i].fl=1;
sort(b+l,b+r+1,cmp2);for (int i=l;i<=r;++i) b[i].dp=i;
cdq2(l,r);
cdq(mid+1,r);
return ;
}
点击查看代码
#include<bits/stdc++.h>
#define ll long long
#define mid ((l+r)>>1)
using namespace std;
const int N=1000010;
struct node
{
int fl,a,b,c,d,w,pos,ans;
}a[N],b[N],lss[N];
ll tree[N<<2];
int n=1,k,sum[N],su[N],dp[N],pos[N],nn,id[N];
inline int read(){
int X=0, W=0;char ch=getchar();
while(!isdigit(ch)) W|=ch=='-', ch=getchar();
while(isdigit(ch)) X=(X<<1)+(X<<3)+(ch^48), ch=getchar();
return W?-X:X;
}
inline void write(ll x){
if(x<0) x=-x, putchar('-');
if(x>9) write(x/10);
putchar(x%10+'0');
}
int lowbit(int x) {return x & -x;}
void add(int x,ll kk) {while (x<=n) tree[x]=max(tree[x],kk),x+=lowbit(x);return ;}
ll ask(ll x) {ll now=0;while (x) now=max(now,tree[x]),x-=lowbit(x);return now;}
void cl(int x){while(x<=n) tree[x]=0,x+=lowbit(x);return ;}
bool cmp1(node x,node y) {return x.a==y.a?(x.b==y.b?(x.c==y.c?(x.d<y.d):x.c<y.c):x.b<y.b):x.a<y.a;}
bool cmp2(node x,node y) {return x.b==y.b?(x.a==y.a?(x.c==y.c?x.d<y.d:x.c<y.c):x.a<y.a):x.b<y.b;}
bool cmp3(node x,node y) {return x.c<y.c;}
void cdq2(int l,int r)
{
if (l==r) return ;
cdq2(l,mid);ll nl=l,now;
sort(a+l,a+mid+1,cmp3),sort(a+mid+1,a+r+1,cmp3);
for (int i=mid+1;i<=r;++i)
{while (nl<=mid&&a[nl].c<=a[i].c)
{if (a[nl].fl) add(a[nl].d,a[nl].ans);nl++;}
if (!a[i].fl) now=ask(a[i].d),a[i].ans=max(1ll*a[i].ans,now+a[i].w);}
for (int i=l;i<nl;++i) if (a[i].fl) cl(a[i].d);
for (int i=l;i<=r;++i) b[id[a[i].pos]]=a[i];
for (int i=l;i<=r;++i) a[i]=b[i];
cdq2(mid+1,r);
return ;
}
void cdq(int l,int r)
{
if (l==r) return ;
cdq(l,mid);
for (int i=l;i<=mid;++i) a[i].fl=1;for (int i=mid+1;i<=r;++i) a[i].fl=0;
sort(a+l,a+r+1,cmp2);for (int i=l;i<=r;++i) id[a[i].pos]=i;
cdq2(l,r);
for (int i=l;i<=r;++i) b[pos[a[i].pos]]=a[i];for (int i=l;i<=r;++i) a[i]=b[i];
cdq(mid+1,r);
return ;
}
int main()
{
nn=read();
for (int i=1;i<=nn;++i)
{
a[i].a=read(),a[i].b=read(),a[i].c=read(),a[i].d=read(),a[i].w=1,
a[i].fl=0,su[i]=a[i].d;
}
sort(su+1,su+nn+1);int noww=unique(su+1,su+nn+1)-su-1;
for (int i=1;i<=nn;++i) a[i].d=lower_bound(su+1,su+noww+1,a[i].d)-su;
sort(a+1,a+nn+1,cmp1);
for (int i=2;i<=nn;++i)
{
if (a[i-1].a!=a[i].a||a[i-1].b!=a[i].b||a[i-1].c!=a[i].c||a[i-1].d!=a[i].d)
a[++n]=a[i];
else a[n].w+=a[i].w;
}
for (int i=1;i<=n;++i) a[i].pos=i,a[i].ans=a[i].w,pos[a[i].pos]=i;
cdq(1,n);
ll ans=0;
for (int i=1;i<=nn;++i) ans=max(ans,1ll*a[i].ans);
cout<<ans;
return 0;
}
整体二分
整体二分是一种将所有修改和查询放在一起二分的离线算法。通过将所有操作二分,给每个查询查找一个正确的答案,需满足一下条件:
- 可离线;
- 答案可二分;
- 修改对判定答案的贡献相对独立,修改之间不互相影响;
- 修改操作对其影响的询问的贡献情况不随判定标准的改变而改变。
比如,求带修改的区间k小值,可以主席树套树状数组,但是很难写,我也不会。
这时候,看看整体二分?
将原数组 \(n\) 个值看成 \(n\) 次插入操作,将修改操作看成一次删除和一次插入,再将所有的操作按时间顺序放到一个队列里。
然后开始整体二分,设 \(ask(head,tail,l,r)\) 表示表示对于队列中\(head\)到\(tail\)的操作,将所有查询操作赋上答案,保证这段操作中的所有查询操作的答案都在\([ l , r ]\)中。
对于 \(ask\) 函数,如果 \(l==r\),那么队列中\(head\)到\(tail\)的所有查询操作答案都为\(l\)。否则:
1.用数状数组维护插入和删除操作,到查询操作时,在数状数组内求这段中有多少值,设为\(tmp_i\);
2.再枚举一次,如果是查询操作,若该操作原有值加上 \(tmp_i\) ,小于等于目标值 \(k\) 则放在第一类,否则放在第二类;如果是插入或删除操作,判断操作的位置,若操作位置小于等于 \(mid\) ,则放在第一类,否则放在第二类;
3.第一类 \(ask\) 一次,第二类 \(ask\) 一次。
设数列长为 \(n\) ,操作数为 \(q\) ,则总时间复杂度为 \(O((n+q)log^2(n+q))\)
整体二分的思想与cdq二分类似,都很巧妙,建议结合代码。
推荐习题:Dynamic Rankings
点击查看代码
int lowbit(int x) {return x & -x ;}
void add(int x,int k) {while (x<=n) tree[x]+=k,x+=lowbit(x);return ;}
int find(int x) {int an=0;while (x) an+=tree[x],x-=lowbit(x);return an;}
void work(int he,int ta,int l,int r)
{
int lt=0,rt=0;
if (he>ta) return ;
if (l==r) {for (int i=he;i<=ta;++i) if (w[i].tp==3) ans[w[i].id]=l;return ;}
for (int i=he;i<=ta;++i)
{
if (w[i].tp==1&&w[i].y<=mid) add(w[i].x,1);
if (w[i].tp==2&&w[i].y<=mid) add(w[i].x,-1);
if (w[i].tp==3) v[i]=find(w[i].y)-find(w[i].x-1);
}
for (int i=he;i<=ta;++i) {if (w[i].tp==1&&w[i].y<=mid) add(w[i].x,-1);if (w[i].tp==2&&w[i].y<=mid) add(w[i].x,1);}
for (int i=he;i<=ta;++i)
{
if (w[i].tp==3)
{
if (w[i].now+v[i]>=w[i].z) w1[++lt]=w[i];
else w[i].now+=v[i],w2[++rt]=w[i];
}
else
{
if (w[i].y<=mid) w1[++lt]=w[i];
else w2[++rt]=w[i];
}
}
for (int i=1;i<=lt;++i) w[he+i-1]=w1[i];
for (int i=1;i<=rt;++i) w[he+i+lt-1]=w2[i];
work(he,he+lt-1,l,mid);work(he+lt,ta,mid+1,r);
return ;
}
int main()
{
n=read(),m=read();
for (int i=1;i<=n;++i)
{
a[i]=read();
w[++tot]=(node){i,a[i],0,1,0,0};
}
for (int i=1;i<=m;++i)
{
char c;int x,y,z;cin>>c>>x>>y;
if (c=='C')
{
w[++tot]=(node){x,a[x],0,2,0,0};
a[x]=y;
w[++tot]=(node){x,a[x],0,1,0,0};
}
else if (c=='Q') {cin>>z;w[++tot]=(node){x,y,z,3,++dl,0};}
}
work(1,tot,0,inf);
for (int i=1;i<=dl;++i) printf("%d\n",ans[i]);
return 0;
}
参考资料:
感谢PrincessQi提供的部分代码与查错。