一、贪心算法的基本概念
1.贪心算法,即期望通过一系列相对孤立却满足局部最优性的决策,得到整体意义上的全局最优解。
2.贪心算法仅取决于当前决策的最优性,而不考虑对整体利益的影响。
3.贪心算法通常以迭代的方式进行,决策之间不宜相互制约。
二、从局部最优到全局最优
例1:noip2004合并果子:传送门
这个贪心思想很容易想到:每次合并两堆果子最少的,关键是怎么证明。其实这就像是huffman编码,考虑维护一棵二叉树,每棵树的子节点就是刚开始的堆,两个子节点的父亲就是合并后的一堆,可以发现,我们使代价最小就是要使深度较深的子节点的权值尽量小,而深度最深的节点恰好是果子最少的堆,所以我们可以得到贪心算法。
例2:noip2012国王游戏:传送门
这个就是非常经典的利用“冒泡排序”的思想来证明贪心算法的题了。这道题其实就是让我们求怎么要把这个序列排序得到的答案最优,一开始我们假设我们得到了最优的排序过的序列,这个排序的方法我们暂且不知道,既然这个序列是最优的,那么我们交换任意一对相邻的肯定比之前的要差,我们当前考虑两个二元组:(ai,bi),(ai+1,bi+1),同时设p = a1*a2*...*ai-1
对于原序列,答案是max{p/bi,p*ai/bi+1},对于交换过的序列,答案是max{p/bi+1,p*ai+1/bi},显然,原序列的答案肯定要小于交换过的序列,而p/bi<p*ai+1/bi,p/bi+1<p*ai/bi+1,所以我们只需要考虑p*ai/bi+1和p*ai+1/bi的大小,我们需要p*ai/bi+1<p*ai+1/bi,化简一下得到ai*bi<ai+1*bi+1,这就得到了排序的方法,于是我们以ai*bi为关键字从小到大排序就能做出来了,是不是很巧妙?
#include <cstdio> #include <cstring> #include <iostream> #include <algorithm> using namespace std; const int maxn = 10010, maxm = 50000, inf = 100000000; int n,lens, ans[maxm], sum[maxm], t[maxm],lena,lent; struct node { int a, b; }s[maxn]; bool cmp(node x, node y) { return x.a * x.b < y.a * y.b; } void cheng(int x) { int jinwei = 0; for (int i = 0; i < lens; i++) { sum[i] = sum[i] * x + jinwei; jinwei = sum[i] / inf; sum[i] %= inf; } while (jinwei > 0) { sum[lens++] = jinwei % inf; jinwei /= inf; } } void chu(int x) { int buwei = 0; for (int i = lens - 1; i >= 0; i--) { buwei = buwei * inf + sum[i]; t[i] = buwei / x; buwei -= x * t[i]; } for (int i = lens - 1; i >= 0; i--) if (t[i] > 0) { lent = i + 1; break; } } void fuzhi() { for (int i = lent - 1; i >= 0; i--) ans[i] = t[i]; } void gengxin() { if (lent > lena) { fuzhi(); lena = lent; } else if (lent == lena) for (int i = lent - 1; i >= 0; i--) { if (t[i] > ans[i]) { fuzhi(); break; } else if (t[i] < ans[i]) break; } } void huifu(int x) { int h = inf; while (h >= 10) { h /= 10; if (x < h) printf("0"); } } int main() { scanf("%d", &n); for (int i = 0; i <= n; i++) scanf("%d%d", &s[i].a, &s[i].b); sort(s + 1, s + n + 1, cmp); sum[0] = 1; lens = 1; for (int i = 0; i < n; i++) { cheng(s[i].a); chu(s[i + 1].b); gengxin(); } for (int i = lena - 1; i >= 0; i--) { if (i != lena - 1) huifu(ans[i]); printf("%d", ans[i]); } printf("\n"); return 0; }
可以发现:这种让你安排一个顺序之类的排序题,如果可以用贪心算法,我们可以假设交换相邻的两个元素,通过证明来得到贪心策略.这样既得到了贪心策略,又证明了这个方法(反着来一遍就行了).
例3:火柴排队:传送门
分析:这道题如果用上一题的方法来分析,会发现得不到贪心策略,所以我们需要先猜一下贪心策略,然后来证明,如果我们任意交换b中的两个数,其实影响了4个数(还有a中的两个),它们的平方都是一定的,关键是乘积,因为(a-b)^2 = a^2+b^2 - 2ab,我们要ab尽量大,结果才能尽量小,考虑a,b,c,d四个数,其中a<b<c<d,我们有两种组合方式:1.a*c+b*d 2.a*b+c*d,那么那种更大呢?显然是第二种,作个差就能证出来了,那么也就是说a中的数和b中的数所对应的元素的位次应该是一样的答案才是最优的,这样我们就证明了贪心策略,也发现了贪心策略,不过这个是带有猜测意味的,因为我们毕竟还是假设了a<b<c<d,那么我们在以a中的数为次序的基础上对b求逆序对就可以了.
#include <cstdio> #include <cstring> #include <iostream> #include <algorithm> using namespace std; const int maxn = 100010, mod = 99999997; int n, ans = 0, d[maxn],c[maxn]; struct node { int x, id; }a[maxn], b[maxn]; bool cmp(node a, node b) { return a.x < b.x; } void update(int x, int v) { while (x <= n) { d[x] += v; x += x & (-x); } } int sum(int x) { int cnt = 0; while (x) { cnt += d[x]; x -= x & (-x); } return cnt; } int main() { scanf("%d", &n); for (int i = 1; i <= n; i++) { scanf("%d", &a[i].x); a[i].id = i; } for (int i = 1; i <= n; i++) { scanf("%d", &b[i].x); b[i].id = i; } sort(a + 1, a + 1 + n, cmp); sort(b + 1, b + 1 + n, cmp); for (int i = 1; i <= n; i++) c[a[i].id] = b[i].id; for (int i = 1; i <= n; i++) { update(c[i], 1); ans = (ans + i - sum(c[i])) % mod; } printf("%d\n", ans % mod); return 0; }
例4:
分析:我们考虑怎么样局部最优,首位两个位置只能乘1次,所以这两个位置肯定是放最小的数和次小的数,那么和他们乘的肯定是比他们稍微大一点的前两个数,我们把这四个数分别放在排列两端,然后可以发现这就是一个递归的过程,我们不断的往中间放更大的数,最后就构成了最优的排列.至于证法,和上一题几乎是一样的.
例5:如果例4中可以出现负数该怎么办呢?
分析:我们的目的是让乘积中出现的负数的数量最少,这个数量最少是1,方法就是把负数分在一堆,正数分在一堆,因为负数*负数=正数,所以负数也可以按照正数那样处理,通过绝对值排个序添加即可,那么两堆数该怎么合并呢?两堆的交界处的乘积一定是一个负数,我们要使这个负数最小,就从正数堆负数堆中分别找绝对值最小的,以这两个数作为交接点即可.
三、数位中的贪心思想?
在数位问题中,由于对于整数大小的限制,较低数位的取值通常受到较高数位取值的限制,即无法同时满足所有的局部最优决策。对于此类问题,具有一定普适性的做法为:通过数位的高低确定其权重,进而按权重从高到低依次尽量满足当前最优决策。其实就是通过数位权重来贪心.
例1:
分析:and的情况非常好处理,我们只需要把每个数转化为2进制,补前导0到m位,这里的m是这n个数转化为2进制后最大的位数,然后从高位往低位扫描,每次删除当前位为0的数字,直到只剩下2个,如果删的不够怎么办呢?我们暂且不删,到下一位再处理.
xor的情况就没有and的情况这么好处理,我们可以像建一个字符串树一样,建一个二进制数的树,先选一个节点走,然后另一个节点走跟这个节点权值相反的路,走到最后形成两个二进制数就是答案了,如果往下的子节点都是跟父亲节点相同的,那么我们随意走一个即可.
例2:
[Noi2014]起床困难综合症
Time Limit: 10 Sec Memory Limit: 512 MB
Submit: 2275 Solved: 1271
[Submit][Status][Discuss]
Description
21 世纪,许多人得了一种奇怪的病:起床困难综合症,其临床表现为:起床难,起床后精神不佳。作为一名青春阳光好少年,atm 一直坚持与起床困难综合症作斗争。通过研究相关文献,他找到了该病的发病原因:在深邃的太平洋海底中,出现了一条名为 drd 的巨龙,它掌握着睡眠之精髓,能随意延长大家的睡眠时间。正是由于 drd 的活动,起床困难综合症愈演愈烈,以惊人的速度在世界上传播。为了彻底消灭这种病,atm 决定前往海底,消灭这条恶龙。历经千辛万苦,atm 终于来到了 drd 所在的地方,准备与其展开艰苦卓绝的战斗。drd 有着十分特殊的技能,他的防御战线能够使用一定的运算来改变他受到的伤害。具体说来,drd 的防御战线由 n扇防御门组成。每扇防御门包括一个运算op和一个参数t,其中运算一定是OR,XOR,AND中的一种,参数则一定为非负整数。如果还未通过防御门时攻击力为x,则其通过这扇防御门后攻击力将变为x op t。最终drd 受到的伤害为对方初始攻击力x依次经过所有n扇防御门后转变得到的攻击力。由于atm水平有限,他的初始攻击力只能为0到m之间的一个整数(即他的初始攻击力只能在0,1,...,m中任选,但在通过防御门之后的攻击力不受 m的限制)。为了节省体力,他希望通过选择合适的初始攻击力使得他的攻击能让 drd 受到最大的伤害,请你帮他计算一下,他的一次攻击最多能使 drd 受到多少伤害。
Input
第1行包含2个整数,依次为n,m,表示drd有n扇防御门,atm的初始攻击力为0到m之间的整数。接下来n行,依次表示每一扇防御门。每行包括一个字符串op和一个非负整数t,两者由一个空格隔开,且op在前,t在后,op表示该防御门所对应的操作, t表示对应的参数。n<=10^5
Output
一行一个整数,表示atm的一次攻击最多使 drd 受到多少伤害。
Sample Input
3 10
AND 5
OR 6
XOR 7
Sample Output
1
HINT
【样例说明1】
atm可以选择的初始攻击力为0,1,...,10。
假设初始攻击力为4,最终攻击力经过了如下计算
4 AND 5 = 4
4 OR 6 = 6
6 XOR 7 = 1
类似的,我们可以计算出初始攻击力为1,3,5,7,9时最终攻击力为0,初始攻击力为0,2,4,6,8,10时最终攻击力为1,因此atm的一次攻击最多使 drd 受到的伤害值为1。
0<=m<=10^9
0<=t<=10^9
一定为OR,XOR,AND 中的一种
【运算解释】
在本题中,选手需要先将数字变换为二进制后再进行计算。如果操作的两个数二进制长度不同,则在前补0至相同长度。OR为按位或运算,处理两个长度相同的二进制数,两个相应的二进制位中只要有一个为1,则该位的结果值为1,否则为0。XOR为按位异或运算,对等长二进制模式或二进制数的每一位执行逻辑异或操作。如果两个相应的二进制位不同(相异),则该位的结果值为1,否则该位为0。 AND 为按位与运算,处理两个长度相同的二进制数,两个相应的二进制位都为1,该位的结果值才为1,否则为0。
例如,我们将十进制数5与十进制数3分别进行OR,XOR 与 AND 运算,可以得到如下结果:
0101 (十进制 5) 0101 (十进制 5) 0101 (十进制 5)
OR 0011 (十进制 3) XOR 0011 (十进制 3) AND 0011 (十进制 3)
= 0111 (十进制 7) = 0110 (十进制 6) = 0001 (十进制 1)
分析:其实每次二进制的操作可以先对每一位进行分析,因为两个二进制数的操作可以变成对每一位的操作,也就是说每一位可以使相对独立的,只是影响了二进制数的大小。
然而二进制数的位数越高,权重越大,所以我们从高到低枚举每一位,记录这一位是否能是1,判断方法就是记录下来的答案加上二进制下的这一位数是不是小于m,如果能是1,则判断这一位是0或1经过给定的n次操作后哪一个更大,选一个更大的累加进答案中,如果两个相等,则选0,目的是为了给后面的数更多选择的机会.
#include <cstdio> #include <cstring> #include <iostream> #include <algorithm> #include <queue> using namespace std; int n, m,t[100010],a[100010],ans; int sum(int x) { for (int i = 1; i <= n; i++) { if (a[i] == 1) x &= t[i]; if (a[i] == 2) x |= t[i]; if (a[i] == 3) x ^= t[i]; } return x; } int main() { scanf("%d%d", &n, &m); for (int i = 1; i <= n; i++) { char op[10]; scanf("%s%d", op, &t[i]); if (op[0] == ‘A‘) a[i] = 1; else if (op[0] == ‘O‘) a[i] = 2; else a[i] = 3; } for (int i = 30; i >= 0; i--) { if (ans + (1 << i) > m) continue; int x = sum(ans), y = sum(ans + (1 << i)); if (x < y) ans += (1 << i); } printf("%d\n", sum(ans)); return 0; }
总结:数位中的贪心的关键就是“位数越大权重越大”,根据这一条来设计贪心策略即可.
四、字典序中的贪心
有很多问题要我们输出字典序最小的方案,解决这种问题的方法之一就是我们总是对字典序最小的最优答案进行处理,而不用管其它的.
例1.求字典序最小的拓扑序
这道题我们每次选一个字典序最小的入度为0的节点进行处理就好了,它的前面的字典序最小,那么整个字符串的字典序一定是最小的.
例2.求一个拓扑序,使得第一个1的位置尽量靠前,如果相等,则让第一个2的位置尽量靠前......
分析:换个思路,假设我们要让大的数出现的位置尽量靠后,那么我们可以构造一种方案,只有走投无路是再走这个数,那么这就满足了要求。也就是说,我们把能走的点都给走过了,如果只剩下i没走,那么i肯定在最后,于是我们参照例1的方法,每次走字典序最小的路,就能把大数出现的位置弄到后面去了.
那么对于这道题,我们把图反着连边,每次走字典序最大的就可以了.
例3.
分析:增加了字典序,元素可重的条件,这道题就变得非常麻烦。首先,还是考虑之前的方法,从两端往中间放,如果当前可以放的区间的左端点的值不等于右端点的值,那么我们能放的方法是唯一的,否则一般放在左边,如果放在右边,当且仅当恰好比这个数大的第一个数的下标小于这个数的下标。如果我们要放的数有多个并且是重复的就有点不好办了,我们先按字典序排个序,把字典序小的放在左边,字典序大的放在右边,如果还剩下一个要放,我们就要还要判断左右端点,然后进行下一轮.
五、搜索的贪心策略
搜索中用贪心策略主要是为了减少搜索层数,降低复杂度,一般都是往搜索状态少的方向扩展。
可以先看我之前总结的一篇搜索博客:传送门,这里面有大量的例题和剪枝方法.
这里还补充一道题:
分析:可以发现我们只要搜到一个解就可以完事,那么我们的目标是要减少搜索的状态数以尽快结束程序,如果在中间,那么我们的状态数就太多了,可以跳8个方向,如果在角落,状态数就少了,每次可以扩展出的新的状态数是指数级别增长的,因此我们每次都优先选择往角落里跳.
总结:贪心策略在搜索算法中可以剪枝,也可以决定我们优先搜哪里(如果只要求输出有没有解则能大幅度优化时间),是一种常用的优化手段.可以学一学A*来感悟一下.
待填的坑:1.noi2015荷马史诗
2.二中例4出现环的情况分析
3.2 ? SAT 问题字典序最小的解
4.字典序最小的完备匹配