算法笔记:贪心法
一、概念
1. 贪心法(Greedy Algorithm)定义
求解最优化问题的算法通常需要经过一系列的步骤,在每个步骤都面临多种选择;
贪心法就是这样的算法:它在每个决策点作出在当时看来最佳的选择,即总是遵循某种规则,做出局部最优的选择,以推导出全局最优解(局部最优解->全局最优解)
2. 对贪心法的深入理解
(1)原理:一种启发式策略,在每个决策点作出在当时看来最佳的选择
(2)求解最优化问题的两个关键要素:贪心选择性质+最优子结构
①贪心选择性质:进行选择时,直接做出在当前问题中看来最优的选择,而不必考虑子问题的解;
②最优子结构:如果一个问题的最优解包含其子问题的最优解,则称此问题具有最优子结构性质
(3)解题关键:贪心策略的选择
贪心算法不是对所有问题都能得到整体最优解的,因此选择的贪心策略必须具备无后效性,即某个状态以前的过程不会影响以后的状态,只与当前状态有关。
(4)一般步骤:
①建立数学模型来描述最优化问题;
②把求解的最优化问题转化为这样的形式:对其做出一次选择后,只剩下一个子问题需要求解;
③证明做出贪心选择后:
1°原问题总是存在全局最优解,即贪心选择始终安全;
2°剩余子问题的局部最优解与贪心选择组合,即可得到原问题的全局最优解。
并完成2°
3. 贪心法与动态规划
最优解问题大部分都可以拆分成一个个的子问题,把解空间的遍历视作对子问题树的遍历,则以某种形式对树整个的遍历一遍就可以求出最优解,大部分情况下这是不可行的。贪心算法和动态规划本质上是对子问题树的一种修剪,两种算法要求问题都具有的一个性质就是子问题最优性(组成最优解的每一个子问题的解,对于这个子问题本身肯定也是最优的)。动态规划方法代表了这一类问题的一般解法,我们自底向上构造子问题的解,对每一个子树的根,求出下面每一个叶子的值,并且以其中的最优值作为自身的值,其它的值舍弃。而贪心算法是动态规划方法的一个特例,可以证明每一个子树的根的值不取决于下面叶子的值,而只取决于当前问题的状况。换句话说,不需要知道一个节点所有子树的情况,就可以求出这个节点的值。由于贪心算法的这个特性,它对解空间树的遍历不需要自底向上,而只需要自根开始,选择最优的路,一直走到底就可以了。
二、典型问题分析
1. (引例)矩阵选数问题
在N行M列的正整数矩阵中,要求从每行中选出1个数,使得选出的总共N个数的和最大。(1<=N, M<=100,结果在int范围内)
【分析】要使总和最大,则每个数要尽可能大,自然应该选每行中最大的那个数。
局部最优解:每行中的最大数;全局最优解:N个数和的最大值。
[cpp] view plain copy
2. 钱币找零问题
有1元、5元、10元、50元、100元、500元的硬币各C1, C5, C10, C50, C100, C500枚。现在要用这些硬币来支付A元,最少需要多少枚硬币?若有解,输出最少硬币数;否则输出“-1”(0<=C1, C5, C10, C50, C100, C500<=109,0<=A<=109)
【分析】凭直觉,我们可以优先使用面值大的硬币(在这里是500、100、50、10、5、1)
[cpp] view plain copy
3. “背包”相关问题
(Ⅰ)最优装载问题
有n个物体,第i个物体的重量为wi(wi为正整数)。选择尽量多的物体,使得总重量不超过C。
【分析】由于只关心选择的物品的最大数量(而不是最大重量,最大重量需要考虑DP),所以装重的物体没有装轻的物体划算。这里只需对n个物体按重量递增排序,依次选择每个物体直到装不下为止。
这是一种典型的贪心算法,它只顾眼前,却能得到最优解。
[cpp] view plain copy
(Ⅱ)部分背包(分数背包)问题
有n个物体,第i个物体的重量为wi,价值为vi(wi, vi均为正整数)。在总重量不超过C的情况下让总价值尽量高。每一个物体都可以只取走一部分,价值和重量按比例计算。
【分析】本题在(Ⅰ)的基础上增加了价值,所以不能像(Ⅰ)中那样先拿轻的(轻的可能价值也小),也不能先拿价值大的(可能它特别重),这两种情况极易造成资源浪费;而是应该综合考虑,重量和价值两个因素。
这里在选择物品i装入背包时,可以选择物品的一部分,而不一定要全部装入背包。
一种直观的贪心策略是:引入“价重比”(价值/重量)这一概念,优先拿价重比大的物体,直到重量和恰为C,或者物体全部拿走重量和 注意: (1)由于每个物体可以只拿一部分,因此上述策略成立,并且除了最后一个物体以外,所有的物体要么不拿,要么拿走全部。 (2)很容易证明,0-1背包问题不能使用贪心算法。在0-1背包问题中贪心选择之所以不能得到最优解原因是贪心选择无法保证最终能将背包装满,部分闲置的背包空间使每公斤背包空间的价值降低了。在程序中已经事先将单位重量价值按照从大到小的顺序排好。 例:考虑以下情景: 有3种物品(为简便起见,重量及价值单位均省略),商品1重量10价值60,商品2重量20价值100,商品3重量30价值120,背包重量50。 因此可求得价重比:商品1:6,商品2:5,商品3:4。 若使用上述贪心策略解决0-1背包问题,商品1和商品2将优先被拿走,此时背包剩余容量20,无法装商品3,此时商品总价值为160;但是还有2种方案也可以行得通,并且均优于上述贪心策略:拿走商品1和商品3,总价值180;拿走商品2和商品3,总价值220。 而对于部分背包(分数背包)问题,按照上述贪心策略,依次拿商品1、商品2和商品3,可生成最优解,最大总价值为60+100+(20/30)*120=240。 [cpp] view plain copy (Ⅲ)乘船问题 有n个人,第i个人重量为wi,每艘船的最大载重量为C,且最多只能乘两个人。用最少的船装载所有人。题目保证有解。 【样例输入1】 6 5 84 85 80 84 83 85 【样例输出1】 5 【样例输入2】 90 45 60 90 【样例输出2】 3 【样例输入3】 50 50 90 40 60 100 【样例输出3】 3 【分析】从最轻的人i开始考虑:如果每个人都无法和他一起坐船,则唯一的方法就是每人坐一艘船;否则,他应该选择能和他一起坐船的人中最重的一个j。这样的方法是贪心的,因为它只是让“眼前的浪费”最少。 可以用反证法证明此策略的正确性: (1)i不与任何人同船。如果将j拉来与其同船,使用的船数<=原来的船数; (2)i与k同船。由贪心策略,因为此时i是最轻的,j是与i匹配的人中最重的,所以w[k]<=w[j],则j加入其它船可能会使其它船超重,用的船数会变多; 综上,说明这样的贪心法不会丢失最优解。 故解题步骤:(循环过程) (1)将所有人的重量进行排序; (2)从当前最轻的人i开始考虑,找能跟其坐一艘船的最重的人j; (3)比最重的人j都重的人都单独坐一个船; 需要特别注意的是:循环过程中若发现i=j,表明仅剩1人待安排,此时这个人自己一船。 [cpp] view plain copy =========================================================================== 3. 排队打水问题 有n个人排队到r个水龙头去打水,他们装满水桶的时间t1,t2,…,tn为整数且各不相等,应如何安排他们的打水顺序才能使他们总共花费的时间最少?这里每个人的花费时间=每个人装满水桶的时间+等待时间。 输入 第一行n,r (n<=500,r<=75) 第二行为n个人打水所用的时间Ti (Ti<=100); 输出 最少的花费时间 样例输入 3 2 1 2 3 样例输出 7 【分析】由于排队时,越靠前面的计算的次数越多,显然越小的排在越前面得出的结果越小(可以用数学方法简单证明,这里就不再赘述),所以这道题可以用贪心法解答,基本步骤: (1)将输入的时间按从小到大排序; (2)将排序后的时间按顺序依次放入每个水龙头的队列中; (3)统计,输出答案。 4. 区间问题 (1)不相交区间选择问题【区间调度问题】 有n项工作,每项工作分别在si时间开始,在ti时间结束。对于每项工作,你都可以选择参与与否。如果选择了参与,那么自始至终都必须全程参与。此外,参与工作的时间段不能重叠(即使是开始与结束的瞬间重叠也是不允许的)。 你的目标是参与尽可能多的工作,那么最多能参与多少项工作呢?1<=n<=100000,1<=si<=ti<=10^9。 输入 n n项工作的开始与结束时间 输出 最多参与的工作项数 样例输入1 5 1 3 2 5 4 7 6 9 8 10 样例输出1 3 样例输入2 3 5 20 14 17 8 11 样例输出2 2 【分析】 此问题可通过贪心算法求解,我们容易想到以下3种算法 (1)在可选的工作中,每次都选取开始时间最早的工作 (2)在可选的工作中,每次都选取结束时间最早的工作 (3)在可选的工作中,每次都选取用时最短的工作 (4)在可选的工作中,每次都选取与最少可选工作有重叠的工作 显然(2)是正确的,其它3种算法都易举出反例;或者说在有些情况下,它们并不一定能得到最优解。 法一:结构体+sort [cpp] view plain copy 法二:pair数组+sort [cpp] view plain copy (2)区间选点问题 数轴上有n个闭区间[ai, bi],取尽量少的点,使得每个区间内都至少有一个点。(不同区间内含的点可以是同一个,1<=n<=10000,1<=ai<=bi<=10^9)。求最少点的个数。 输入输出同(1) 样例输入1 4 3 13 6 20 4 14 1 10 样例输出1 1 样例输入2 3 4 7 6 8 11 20 样例输出2 2 【分析】若区间i内已经有一个点被取到,则称此区间已经被满足。由于小区间被满足时大区间也一定被满足,所以在区间包含的情况下,大区间不需考虑。 把所有区间按b递增排序(b相同时a递减排序),则如果出现区间包含的情况,小区间一定排在前面。此处的贪心策略:取第一个区间的最后一个点。 如下图,如果选灰色点,移动到黑色点更优。 [cpp] view plain copy (3)区间覆盖问题 数轴上有n个闭区间[ai, bi],取尽量少的点覆盖一条指定线段[s, t]。求最少区间个数,如果无解,输出”-1”。(1<=n<=10000,1<=ai<=bi<=10^9) 输入 闭区间个数n n个闭区间的左右端点ai, bi 指定线段的左右端点s, t 输出 最少区间个数,无解时输出"-1" 样例输入(1)最大整数【拼数问题】 7 2 5 1 4 3 8 3 10 7 10 4 6 1 3 1 10 样例输出 2 【分析】本题突破口仍是区间包含和排序扫描,不过需要进行一次预处理:每个区间在[s, t]以外的部分都应预先被切掉,因为它们的存在毫无意义。预处理后,在相互包含的情况下小区间显然不应考虑。 把各区间按照a递增排序,若区间1的起点不是s,则无解(因为其它区间的起点更大,不可能覆盖到s点),否则选择起点在s的最长区间。选择此区间[ai, bi]后,新的起点设置为bi,并且忽略所有区间在bi之前的部分,就像预处理一样。仍只需一次区间扫描即可完成。 5. 几个字典序问题 (1)最大整数【拼数问题】 设有n个正整数(n≤20),将它们联接成一排,组成一个最大的多位整数。 例如:n=3时,3个整数13,312,343联接成的最大整数为:34331213; 又如:n=4时,4个整数7,13,4,246联接成的最大整数为:7424613 输入 n n个数 输出 连接成的多位数 样例输入 3 13 312 343 样例输出 34331213 【分析】字符串排序的规则:首先按字典序,然后看串长度。如7>414 321>32 [cpp] view plain copy (2)删数问题 键盘输入一个高精度的正整数n(≤240位),去掉其中任意s个数字后剩下的数字按原左右次序将组成一个新的正整数。编程对给定的n和s,寻找一种方案,使得剩下的数字组成的新数最小。 输入 n s 输出 最后剩下的最小数。 样例输入 175438 4 样例输出 13 【分析】此问题关键在于确定每次操作中哪一位被删除。 是不是最大的s个数呢?并不是。例如n=154289,s=3,删除最大的3位5、8、9后得到142,而删除5、4、9后得到128,但最小的新数是128。 因此,为尽可能逼近目标,选取贪心策略:每次操作总是选一个使剩下的数最小的数字删去。即按高位到低位的顺序搜索,比较相邻两位数字大小。若各位数字递增,则删除最后一个数字;否则删除第一个递减区间的首字符,这样删一位便形成了一个新数字串。然后回到串首按上述规则重复s次,即得到解。(这样删数问题就与如何寻找递减区间首字符这个问题对应起来了) [cpp] view plain copy (3)字典序最小问题 给定长度为N的字符串S,要构造一个长度为N的字符串T。起初T是一个空串,随后反复进行下列任意操作: 1. 从S的头部删除一个字符,加到T的尾部 2. 从S的尾部删除一个字符,加到T的尾部 目标是构造字典序尽可能小的字符串T(1<=N<=2000,字符串S只包含大写英文字母) 【分析】从字典序的性质上看,无论T的末尾有多大,只要前面部分的较小就可以。因此不难想到该贪心算法:不断取S得开头和末尾中较小的一个字符放到T的末尾。不过针对S的开头和末尾相同的情形还未定义。在这种情形下,因为我们希望能够尽早使用更小的字符,所以就要比较下一个字符的大小,而下一个字符也可能相同,因此就有如下算法: ① 按照字典序比较S和将S反转后的字符串S’(实际上就是双指针扫描) ② 如果S较小,就从S的开头取出一个字符追加到T的末尾 ③ 如果S’较小,就从S的末尾取出一个字符追加到T的末尾 [cpp] view plain copy 6. 拓展情境 (1)均分纸牌(NOIP2002) 有 N 堆纸牌,编号分别为 1,2,…, N。每堆上有若干张,但纸牌总数必为 N 的倍数。可以在任一堆上取若干张纸牌,然后移动。 移牌规则为:在编号为 1 堆上取的纸牌,只能移到编号为 2 的堆上;在编号为 N 的堆上取的纸牌,只能移到编号为 N-1 的堆上;其他堆上取的纸牌,可以移到相邻左边或右边的堆上。 现在要求找出一种移动方法,用最少的移动次数使每堆上纸牌数都一样多。 例如 N=4,4 堆纸牌数分别为:① 9 ② 8 ③ 17 ④ 6 移动3次可达到目的: 从 ③ 取4张牌放到④(9 8 13 10)->从③取3张牌放到 ②(9 11 10 10)-> 从②取1张牌放到①(10 10 10 10)。 输入 N(N 堆纸牌,1 <= N <= 100) A1 A2 … An (N 堆纸牌,每堆纸牌初始数,l<= Ai <=10000) 输出 所有堆均达到相等时的最少移动次数。 输入样例 4 9 8 17 6 输出样例 3 【分析】如果你想到把每堆牌的张数减去平均张数,题目就变成移动正数,加到负数中,使大家都变成0,那就意味着成功了一半!拿例题来说,平均张数为10,原张数9,8,17,6,变为-1,-2,7,-4,其中没有为0的数,我们从左边出发:要使第1堆的牌数-1变为0,只须将-1张牌移到它的右边(第2堆)-2中;结果是-1变为0,-2变为-3,各堆牌张数变为0,-3,7,-4;同理:要使第2堆变为0,只需将-3移到它的右边(第3堆)中去,各堆牌张数变为0,0,4,-4;要使第3堆变为0,只需将第3堆中的4移到它的右边(第4堆)中去,结果为0,0,0,0,完成任务。每移动1次牌,步数加1。也许你要问,负数张牌怎么移,不违反题意吗?其实从第i堆移动-m张牌到第i+1堆,等价于从第i+1堆移动m张牌到第i堆,步数是一样的。 如果张数中本来就有为0的,怎么办呢?如0,-1,-5,6,还是从左算起(从右算起也完全一样),第1堆是0,无需移牌,余下与上相同;再比如-1,-2,3,10,-4,-6,从左算起,第1次移动的结果为0,-3,3,10,-4,-6;第2次移动的结果为0,0,0,10,-4,-6,现在第3堆已经变为0了,可节省1步,余下继续。 [cpp] view plain copy (2)Saruman's Army(标记点问题) 直线上有N个点,点i的位置是Xi。从这N个点中选择若干个,给它们加上标记。对每一个点,其距离为R以内的区域内必须有带有标记的点(自己本身带有标记的点,可以认为与其距离为0的地方有一个带有标记的点)。在满足这个条件的情况下,希望能为尽可能少的点添加标记。请问至少有多少点被加上标记?(1<=N<=1000,0<=R<=1000,0<=XI<=1000) 样例输入 6 10 1 7 15 20 30 50 样例输出 3 [cpp] view plain copy
3
5
设有n个正整数(n≤20),将它们联接成一排,组成一个最大的多位整数。
例如:n=3时,3个整数13,312,343联接成的最大整数为:34331213;
又如:n=4时,4个整数7,13,4,246联接成的最大整数为:7424613
输入
n
n个数
输出
连接成的多位数
样例输入
3
13 312 343
样例输出
34331213
【分析】字符串排序的规则:首先按字典序,然后看串长度。如7>414 321>32