关于贪心算法的研究
作者:成都七中(高新校区)王驭洋
[摘要] 本文对贪心算法进行较详细的研究。第一部分明确其基本概念,第二部分介绍常见的贪心模型,第三部分给出常用的贪心证明方式,第四部分介绍贪心的经典算法,第五部分与其他算法进行比较,最后总结贪心算法的优劣性、竞赛应用及前景。
[关键词] 贪心算法、Prim、kruskal、Dijkstra、Huffman、拟阵、证明
贪心算法是在信息学竞赛中一个常用的重要算法。在许多的经典算法中都处处藏着贪心的思想。MST中的Prim和Kruskal都是很优的贪心算法。还有Dijkstra的单源最短路径和数据压缩(Huffman)编码以及Chvatal的贪心集合覆盖启发式。其实很多的智能算法(启发式算法),就是贪心算法和随机化算法结合。这样的算法结果虽然也是局部最优解,但是比单纯的贪心算法更靠近最优解,例如遗传算法,模拟退火算法。贪心算法是与人类通常思维方式极其相近的算法,所以使用贪心算法时最大的困难通常不是构造贪心,而是证明贪心算法的正确性。本文将对贪心算法的模型与包含贪心的经典算法进行介绍,同时给出贪心算法的证明方式。最后总结贪心算法的优劣性、竞赛应用及前景。
[目录]
1 贪心算法的基本概念………………………………………………..2
1.1 定义…………………………………………………………2
1.2 基本特性……………………………………………………..2
1.3 基本思路……………………………………………………..2
2 贪心模型…………………………………………………………2
2.1 硬币问题……………………………………………………..2
2.2 装载问题……………………………………………………..3
2.3 部分背包问题………………………………………………….4
2.4 乘船问题……………………………………………………..6
2.5 区间问题……………………………………………………..8
2.6 选点问题……………………………………………………..9
2.7 顺序问题…………………………………………………….11
3 贪心证明………………………………………………………..13
3.1 公式法………………………………………………………13
3.2 交换参数法…………………………………………………..13
3.3 归纳法………………………………………………………13
3.4 反证法………………………………………………………13
4 经典贪心算法…………………………………………………….13
4.1 Prim………………………………………………………..14
4.2 Kruskal……………………………………………………..14
4.3 Dijkstra…………………………………………………….14
4.4 Huffman……………………………………………………..14
5 拟阵…………………………………………………………..14
6 贪心算法与其他算法的比较………………………………………….15
6.1 贪心与动态规划的比较………………………………………….15
6.2 基本特性贪心与分治的比较………………………………………16
7 总结……………………………………………………………16
8 参考文献………………………………………………………..16
1 贪心算法的基本概念
1.1 定义
贪心算法(Greedy Algorithm),又称贪婪算法,它在对问题求解时,能根据每次所求得的局部最优解,推导出全局最优解或最优目标。我们可以根据这个策略,每次得到局部最优解,进而推导出问题的解,这种策略称为贪心算法。贪心算法不管之前或之后发生了什么,只与当前状态有关,只对当前的子问题选择最优解。
1.2 基本特性
①随着算法的进行,将积累起其它两个集合:一个包含已经被考虑过并被选出的候选对象,另一个包含已经被考虑过但被丢弃的候选对象。
②有一个函数来检查一个候选对象的集合是否提供了问题的解答。该函数不考虑此时的解决方法是否最优。
③还有一个函数检查是否一个候选对象的集合是可行的,也即是否可能往该集合上添加更多的候选对象以获得一个解。和上一个函数一样,此时不考虑解决方法的最优性。
④选择函数可以指出哪一个剩余的候选对象最有希望构成问题的解。
⑤最后,目标函数给出解的值。
⑥贪心算法需要寻找一个构成解的候选对象集合,用于优化目标函数。起初,算法选出的候选对象的集合为空。之后的每一步,根据选择函数,从剩余候选对象中选出最优解。如果集合中加上该解后不成立,那么该对象就被丢弃并不再考虑;否则就加到集合里。每一次都扩充集合,并检查该集合是否构成解。如果贪心算法正确工作,那么找到的第一个解通常是最优的。
1.3 基本思路
①建立数学模型来描述问题。
②把求解的问题分成若干个子问题。
③对每一子问题求解,得到子问题的局部最优解。
④把子问题的解局部最优解合成原来解问题的一个解。
2 贪心模型
2.1 硬币问题
2.1.1 例题
有1元、5元、10元、50元、100元、500元硬币若干枚,现在要用这些硬币来支付A元,求最少需要多少枚硬币?假定至少存在一种方案(0≤硬币数量≤10^9,0≤A≤10^9)。
2.1.2 贪心思路
每个大面值硬币均为小面值硬币倍数,应尽可能多的使用大面值硬币,能用则用。
2.1.3 程序实现
#include <cstdio>
#include <iostream>
using namespace std;
int a;
int tot,now;
int coin[6] = {500,100,50,10,5,1};
int main() {
scanf("%d",&a);
for(int i = 0; i <= 5; i++) {
now = a / coin[i];
tot += now;
a -= now * coin[i];
if(a == 0) break;
}
printf("%d",tot);
return 0;
}
2.2 装载问题
2.2.1 例题
n个物品,第i个物品重量为wi,选择尽量多的物品使得总重量不超过C。
2.2.2 贪心思路
由于目标是物品数量尽量多,所以选轻的显然划算。将所有物品按wi从小到大排序后,能选就选。
2.2.3 程序实现
#include <cstdio>
#include <iostream>
#include <algorithm>
using namespace std;
const int maxn = 1001;
int n,c;
int tot;
int w[maxn];
int main() {
scanf("%d%d",&n,&c);
for(int i = 1; i <= n; i++) scanf("%d",&w[i]);
sort(w+1, w+1+n);
for(int i = 1; i <= n; i++)
if(w[i] + tot <= c)tot += w[i];
else break;
printf("%d",tot);
return 0;
}
2.3 部分背包问题
2.3.1 例题
n个物品,第i个物品重量为wi,价值为vi。求在总重量不超过C的情况下选出物品的总价值最高是多少。物品可以只取一部分,价值重量按比例计算。
2.3.2 贪心思路
按性价比 (即vi/wi)进行贪心。显然一个物品要么不取,要么全部取走(除了最后一个)。
2.3.3 程序实现
#include <iostream>
#include <cstdio>
#include <algorithm>
using namespace std;
const int maxn = 1001;
int n,c;
int totw,totv;
struct data{
int w,v;
double s;
bool operator < (const data &right)const{
return s < right.s;
}
};
data a[maxn];
int main() {
scanf("%d%d",&n,&c);
for(int i = 1; i <= n; i++) scanf("%d%d",&a[i].w,&a[i].v),a[i].s = a[i].v / a[i].w;
sort(a+1, a+1+n);
for(int i = 1; i <= n; i++)
if(a[i].w+totw <= c) totw += a[i].w, totv += a[i].v;
else if(totw < c) totv += a[i].v * (c - totw) / a[i].w;
else break;
printf("%d",totv);
return 0;
}
2.4 乘船问题
2.4.1 例题
共计有n个人,第i个人重量为wi,每艘船载重量均为Ci,最多乘两个人。求最少几艘船能载所有人。
2.4.2 贪心思路
考虑最轻的人i,若他不能与任何一人同船,那么剩下的所有人只能每人单独一船。否则选最重的能和i同船的人j和i同船。
考虑反证:若i不和任何一人坐船,则把j换过来和i坐不会更劣。i和k同船,则把k,j交换,由于wk≤wj,因此原来j的那船不会超重,且i,j一船也不会超重,方案不会更劣。
2.4.3 程序实现
#include <iostream>
#include <cstdio>
#include <algorithm>
using namespace std;
const int maxn = 1001;
int n,c;
int tot,l;
int w[maxn];
int main() {
scanf("%d%d",&n,&c),l = n;
for(int i = 1; i <= n; i++) scanf("%d",&w[i]);
for(int i = 1; i <= l; i++)
for(int j = l; j >= i; j--) {
tot++; l--;
if(w[i] + w[j]< c) break;
}
printf("%d",tot);
return 0;
}
2.5 区间问题
2.5.1 例题
给定n个区间,每个区间左右端点分别为li,ri,现在要求选出尽量多的区间使得它们两两不相交(不包括端点),问最多能选出几个区间(n≤105,1≤li≤ri≤109)。
2.5.2 贪心思路
在可选区间中选右端点最小的,右端点越小,之后可选的区间越多。所有区间按ri排序。首先可以去除区间包含的情况(只留小区间)。此时的li同样是有序的。考虑前两个区间,若不选第一个区间,则相当于[l1,l2]这段区间无用。此时也是种包含情况,选第二个不会更优。
2.5.3 程序实现
#include <iostream>
#include <cstdio>
#include <algorithm>
using namespace std;
const int maxn = 1001;
int n;
int tot,lst;
struct data {
int r,l;
bool operator < (const data &right) const {
if(l == right.l) return r < right.r;
return l < right.l;
}
};
data a[maxn];
int main() {
scanf("%d",&n);
for(int i = 1; i <= n; i++) scanf("%d%d",&a[i].r,&a[i].l);
sort(a+1, a+1+n);
for(int i = 1; i <= n; i++) if(a[i].r >= lst) lst = a[i].l, tot++;
printf("%d",tot);
return 0;
}
2.6 选点问题
2.6.1 例题
给定n个区间[li,ri],取尽量少的点使得所有区间内部至少一个点。求最少需要多少个点。
2.6.2 贪心思路
首先去掉包含情况(只留小区间)。将所有区间按ri从小到大排序,ri相同按li从大到小排序。按顺序考虑每个区间,若当前区间内没点,则在ri处放一个点。若不放在ri,则将放置点往后移不会更劣(当前ri最小,所以能覆盖的区间不会减少)。
2.6.3 程序实现
#include <iostream>
#include <cstdio>
#include <algorithm>
using namespace std;
const int maxn = 1001;
int n,tot,lst;
struct data {
int r,l;
bool operator < (const data &right) const {
return r < right.r || r == right.r && l > right.l;
}
};
data a[maxn];
int main() {
scanf("%d",&n);
for(int i = 1; i <= n; i++) scanf("%d%d",&a[i].r,&a[i].l);
sort(a+1, a+1+n);
lst = -1;
for(int i = 1; i <= n; i++) if(lst < a[i].r) lst = a[i].l, tot++;
printf("%d",tot);
return 0;
}
2.7 顺序问题
2.7.1 例题
给定n数ai,再给出n个数bi,现在要求你重新排列b的顺序,使得 ∑n=1iai∙bi 最小。
2.7.2 贪心思路
一般这类排序问题都能直观的想到一些贪心方式,关键还要看能否证明。常用调整法或公式法。上述问题由排序不等式可直接得出结果,即逆序和最小。
2.7.3 程序实现
#include <iostream>
#include <cstdio>
#include <algorithm>
using namespace std;
const int maxn = 1001;
int n;
int a[maxn],b[maxn];
int aa[maxn],bb[maxn],lst[maxn];
bool cmp(const int &x, const int &y) { return x > y; }
int main() {
scanf("%d",&n);
for(int i = 1; i <= n; i++) scanf("%d%d",&a[i],&b[i]), aa[i] = a[i], bb[i] = b[i];
sort(aa+1, aa+1+n);
sort(bb+1, bb+1+n, cmp);
for(int i = 1; i <= n; i++)
for(int j = 1; j <= n; j++) {
if(a[i] == aa[j]) a[i] = j;
if(b[i] == bb[j]) b[i] = j;
}
for(int i = 1; i <= n; i++)
for(int j = 1; j <= n; j++)
if(a[i] == b[j]) lst[i] = bb[b[j]];
for(int i = 1; i <= n; i++) printf("%d ",lst[i]);
return 0;
}
3 贪心证明
3.1 公式法
先假设贪心算法产生某个解W,而真正的最佳解为Y,然后设法证明目标函数而言,W不比Y差。直接证明有困难时,可设法寻找一些过度解X1,X2,…,Xn,使能证明W不比X1差,Xi不比Xi+1差(0
3.2 交换参数法(Exchange Argument)
先假设存在一个最优的算法和我们的贪心算法最接近,然后通过交换两个算法里的一个步骤(或元素),得到一个新的最优的算法,同时这个算法比前一个最优算法更接近于我们的贪心算法,从而得到矛盾,原命题成立。
3.3 归纳法
将W和Y都归结为更小的相同问题的解W’和Y’,并且W’仍是贪心算法所产生的解。
3.4 反证法
首先假设我们的贪心算法不成立(即在原命题的题设下,结论不成立),然后推理出明显矛盾的结果,从而下结论说假设不成立,贪心算法得证。
4 经典贪心算法
4.1 Prim
4.1.1 算法流程
①初始选择边集E=∅,点集V={任意结点}。②选择一条权值w最小的边e=(u,v),使u∈V,v∉V。③E=E+{e},V=V+{v}。④点集V内若包含所有结点则算法结点,否则返回第二步。
使用优先队列加速过程②,复杂度O(nlogn)。
4.1.2 证明
令Prim算法得到的树为P,有一棵最小生成树T,假设它们不同。假设前k-1步P选择的边都在T中,令此时的树为P’。第k步选择的e=(u,v)不在T中,假设u在P’中,而v不在。T中必有一条u→v的路径,路径上必有一条边e’=(x,y)满足此时x在P’中而y不在。若w(e’)>w(e) 则在T中用e换掉e’可得到一个更小的生成树,矛盾。若w(e’)
4.2 Kruskal
4.2.1 算法流程
①将所有边按权值w(e)的大小排序。②初始选择边集E=∅。③按顺序考虑每条边e,e与已在E中的边不构成环则可选择。E=E+{e}。若构成环则放弃e。④选出n-1条边后E即为一棵最小生成树,否则原图不连通。
使用并查集维护过程③中的选择,复杂度O(mlogm+mlogn)。
4.2.2 证明
令Kruskal算法得到的树为K,有一棵最小生成树T,假设它们不同。找到边权最小的在K但不在T中的边e。把e加入T中,形成一个环,删掉这个环中一条不在K中的边e’得到新生成树T’。若不存在e’则K存在环,矛盾。若w(e’)>w(e),则T’权值和小于T,矛盾。若w(e’)
4.3 Dijkstra
4.3.1 算法流程
①起始点为s,初始时已选结点集合V=∅。②初始化距离标号,ds=0,其他点di=1。③找到不在V中且d值最小的结点u,V=V+{u}。④对于还不在V中的点v,更dv=min(dv,du+w(u,v))。⑤若V包含所有点则算法结点,du即为s到u的最短距离。否则返回第③步。
边权w应非负,利用优先队列加速。复杂度O(nlogm)。
4.3.2 证明
考虑归纳证明结点u进入V时,du就是到u的最短路。初始ds=0满足条件。假设之前集合V中的点全部满足,现在加入结点u,考虑任意一条从s到u的路径P=(s→…→x→y→…→u),其中s→x均在V中,y→u均不在V中。根据归纳,dx为到x的最短路。dx+w(x,y)≥dy。算法选择最小的d加入V,因此d≥du。length(P)=length(s→x)+w(x,y)+length(y→u)≥dx+w(x,y) +length(y→u)≥dy+length(y→u)≥dy≥du。因此du也是最短路。
4.4 Huffman
4.4.1 算法流程
①初始时对每个字符新建一个结点并都放进结点集合V中,结点权值w为对应字符的频率(即出现次数)。②找到V中两个权值最小的结点u,v合并成一个新结点w,新结点权值为w(u) +w(v).V=V+{w}-{u,v}。③若V中仅剩一个结点则算法结点,否则返回第2步。合并前两个结点的父亲设为合并后新结点,则算法构造出一棵二叉树,对应一个01编码,根到叶子的路径表示该字符的编码,整个文本长度为每个字符编码长度与其频率的乘积之和。
优先队列加速,复杂度O(nlogn)。
4.4.2 证明
考虑归纳证明。N≤2时显然成立。假设n=k时成立,考虑n=k+1时,最先合并的两个结点u1,u2。在最优编码中u1,u2显然应该在叶子且在同一层。否则交换后根据排序不等式不会更劣。若u1,u2父亲不同则可以交换为相同,编码总长度不变,这就相当于最先合并u1,u2。剩下n’=k个结点,由归纳知算法得出的确实是最优编码。最优编码不唯一,且算法可拓展为k进制。
5 拟阵
5.1 子集系统定义
一个子集系统是一个有序二元组(E, I)。E是一个非空集合,I是E的一个子集(符合一定规则的子集的集合)。I在包含运算下封闭,即①∀a∈I,a⊆E、②∀a’⊆a,a’∈I。E 中每个元素e都被赋予了一个正权w(e)。
5.2 最大独立集
将I中的元素都称为独立集。对于I中的元素a,若不存在I中的另一个元素a’使得a⊂a’,则称a是极大独立集。极大独立集的基数为它包含的元素个数。
5.3 子集系统优化问题
在子集系统中选取一个元素S∈I,使得w(S)最大,w(S)=∑s∈S w(s)。
5.4 权值最大的极大独立集
将所有e∈E按w(e)从大到小排序,能添加进S就贪心地加进去。它并不能在所有的子集系统中正确。但在一种特殊子集系统中它总是正确的,我们称这类子集系统为拟阵。
5.5 拟阵定义
有限拟阵是一个有序二元组M=(E,I),E是一个非空有限集,称为基础集。I是E的一个有限非空子集族,I中元素称为独立集。它满足三条公理:①独立集公理:∅∈I、②可遗传性:若A∈I,A’⊆A,则A’∈I、③独立扩充公理:若A1,A2∈I且|A1|<|A2|,则存在e∈A2- A1,使得A1∪{e}∈I。I中极大的独立集称为拟阵的基(basis)。用β表示基的集合,则β非空且它满足基交换性,即若A,B∈β,A≠B,a∈A-B,则∃b∈B-A使得A-{a}+{b}∈β。由基交换性也可知所有的基大小相同。拟阵的秩就是基的大小。拟阵的并仍是拟阵。若E中元素带权,则M为带权拟阵。存在定理:①一个子集系统是拟阵当且仅当其所有极大独立集具有相同的基数。②子集系统优化问题的贪心算法正确当且仅当该系统是一个拟阵。
5.6 证明
定理:①拟阵M=(S,I)的所有极大独立集都有相同大小。②设加权拟阵M=(S,I)的权函数为w且S 已按权非升排序。设x是第一个使得{x}独立的元素,若这样的元素存在,则存在一个包含x的最大权独立子集。③设x是对于加权拟阵M=(S,I)由定理②中所选择的S的第一个元素,则剩下问题可归结为求加权矩阵M’=(S’,I’)的最大权独立集问题,其中S’={y|y∈S且{x,y}∈I},I’={B|B⊆S-{x}且B∪{x}∈I}。
由独立扩充公理可证定理①。对于定理②设B是任意非空的最优子集,且x∉B,∀y∈B,w(x)≥w(y)。构造集合A={x},根据独立扩充公理,可反复地在B中找出新元素加入A中直到|A|=|B|,且A仍是独立集。因此∃y∈B,使得A=B-{y}+{x}。w(A)=w(B)-w(y) + w(x)≥w(B)。 定理②得证。对于定理3,在M’=(S’,I’)中的最优解X’,X’∈I’,由I’定义知X’∪{x}∈I。M=(S,I) 中包含x的最优解X,X-{x}⊂S-{x}且X∈I,即X-{x}∈I’且为I’中的最优解。定理③得证。定理③给出了一个递归算法来解决该问题。定理③中递归选择的元素显然不增,贪心算法所选择的元素就是定理③中递归算法所选择的元素,可由归纳法证明,即贪心算法在拟阵中是正确的。因此可以利用拟阵证明Kruskal。
6 贪心算法与其他算法的比较
6.1 贪心与动态规划的比较
贪心选择当前最优的决策;DP则是枚举所有状态进行更新。两者都有最优子问题。贪心一般没有子问题重叠,DP一般有。贪心一般复杂度较低,DP一般较高(相互之间比较)。这两种算法都是用于解决最优化问题。详见表1。
表1 贪心与动态规划的比较
比较项目 | 贪心 | 动态规划 |
---|---|---|
决策 | 选择当前最优的决策 | 枚举所有状态进行更新 |
最优子问题 | 有 | 有 |
子问题重叠 | 一般没有 | 一般有 |
空间复杂度 | 一般较低 | 一般较高 |
时间复杂度 | 一般较低 | 一般较高 |
后效性 | 有 | 无 |
正确性 | 需要证明 | 只要状态转移正确,则正确 |
6.2 贪心与分治的比较
分治法是将原问题分解为多个子问题,利用递归对各个子问题独立求解,最后利用各子问题的解进行合并形成原问题的解。分治法将分解后的子问题看成是相互独立的。通常对于同一子问题只有唯一解。详见表2。
表2 贪心与分治的比较
比较项目 | 贪心 | 分治 |
---|---|---|
决策 | 选择当前最优的决策 | 合并已知子问题的解 |
子问题 | 取最优 | 唯一解 |
子问题重叠 | 一般没有 | 无 |
空间复杂度 | 一般较低 | 一般较低 |
时间复杂度 | 一般较低 | 一般较高 |
后效性 | 有 | 有 |
正确性 | 需要证明 | 只要子问题解正确,则正确 |
7 总结
至此,我们已经对贪心算法有了比较深刻了认识。贪心算法不仅容易理解而且应用面极广。它可以和许多算法与数据结构融合形成新的算法,相信它一定在将来还会再构造出其他很巧的有趣的算法。贪心在竞赛中应用广泛。它设计与实现难度不大,但考试时通常对正确性无法保证,所以不容易拿满分。因此需要选手对题目条件细致分析,并小心求证贪心正确性。对于经典模型需要熟练运用与转化,了解经典贪心算法证明有助于更好理解,熟练使用几种证明方法,必要时可以使用暴力程序验证。
8 参考文献
[1] 傅清祥,王晓东.算法与数据结构(第二版)[M].北京:电子工业出版社,1998.
[2] Thomas H. Cormen,Charles E. Leiserson,Ronald L. Rivest,Clifford Stein Introduction to Algorithms (Second Edition)[M]. Cambridge:The MIT Press,2001.
[3] Mark Allen Weiss.Data Structures and Algorithm Analysis in C (Second Edition)[M]. New York:Pearson Education,1997.
[4] 秋叶拓哉,岩田阳一,北川宜稔.挑战程序设计竞赛(第2版)[M].北京:人民邮电出版社,2013.
[5] 刘汝佳,黄亮.算法艺术与信息学竞赛[M].北京:清华大学出版社,2004.