1.前言
本文着重讨论可以用贪心算法求解的问题的一般特征。
对于一个具体的问题,怎么知道是否可用贪心算法解此问题,以及能否得到问题的最优解呢?
这个问题很难给予肯定的回答。但是,从许多可以用贪心算法求解的问题中看到这类问题一般具有2个重要的性质:贪心选择性质和最优子结构性质。
2.贪心算法简介
顾名思义,贪心算法总是作出在当前看来最好的选择。也就是说贪心算法并不从整体最优考虑,它所作出的选择只是在某种意义上的局部最优选择。当然,希望贪心算法得到的最终结果也是整体最优的。虽然贪心算法不能对所有问题都得到整体最优解,但对许多问题它能产生整体最优解。如单源最短路经问题,最小生成树问题等。在一些情况下,即使贪心算法不能得到整体最优解,其最终结果却是最优解的很好近似。
3.贪心算法的基本要素
3.1贪心选择性质
所谓贪心选择性质是指所求问题的整体最优解可以通过一系列局部最优的选择,即贪心选择来达到。这是贪心算法可行的第一个基本要素,也是贪心算法与动态规划算法的主要区别。
动态规划算法通常以自底向上的方式解各子问题,而贪心算法则通常以自顶向下的方式进行,以迭代的方式作出相继的贪心选择,每作一次贪心选择就将所求问题简化为规模更小的子问题。
对于一个具体问题,要确定它是否具有贪心选择性质,必须证明每一步所作的贪心选择最终导致问题的整体最优解。
3.2 最优子结构性质
当一个问题的最优解包含其子问题的最优解时,称此问题具有最优子结构性质。问题的最优子结构性质是该问题可用动态规划算法或贪心算法求解的关键特征。
怎么来理解最优子结构性质呢?下面以一个例子来说明。
例子1:矩阵连乘问题
给定n个矩阵A1,A2,…,An, 其中Ai与Ai+1是可乘的。考察这n个矩阵的连乘积:A1A2…An
由于矩阵乘法满足结合律,所以计算矩阵的连乘可以有许多不同的计算次序。这种计算次序可以用加括号的方式来确定。
若一个矩阵连乘积的计算次序完全确定,也就是说该连乘积已完全加括号,则可以依此次序反复调用2个矩阵相乘的标准算法计算出矩阵连乘积。
将矩阵连乘积A1A2…An 简记为A[i:j] ,这里i≤j
考察计算A[i:j]的最优计算次序。设这个计算次序在矩阵
Ak和Ak+1之间将矩阵链断开,i≤k<j,则其相应完全
加括号方式为(AiAi+1…Ak)(AkAk+1Aj)
特征:计算A[i:j]的最优次序所包含的计算矩阵子链 A[i:k]和A[k+1:j]的次序也是最优的。
为什么说
当A[i:j]满足在k处断开,此时加括号的方式(AiAi+1…Ak)(AkAk+1Aj)最优,
要求 A[i:k]和A[k+1:j]在某处断开也是最优的???
可以用反证法证明:因为要是A[i:k]和A[k+1:j]在某处断开获得不了最优计算次序,那必定在k处断开的A[i:j]也不会获得最优计算次序。而这就叫做最优子结构性质,即问题的最优解包含着其子问题的最优解。
3.3 贪心算法与动态规划算法的差异
贪心算法和动态规划算法都要求问题具有最优子结构性质,这是2类算法的一个共同点。但是,对于具有最优子结构的问题应该选用贪心算法还是动态规划算法求解?是否能用动态规划算法求解的问题也能用贪心算法求解?下面研究2个经典的组合优化问题,并以此说明贪心算法与动态规划算法的主要差别。
例子2:0-1背包问题
给定n种物品和一个背包。物品i的重量是Wi,其价值为Vi,背包的容量为C。应如何选择装入背包的物品,使得装入背包中物品的总价值最大?
注:在选择装入背包的物品时,对每种物品i只有2种选择,即装入背包或不装入背包。不能将物品i装入背包多次,也不能只装入部分的物品i。
例子3:背包问题
与0-1背包问题类似,所不同的是在选择物品i装入背包时,可以选择物品i的一部分,而不一定要全部装入背包,1≤i≤n。
这2类问题都具有最优子结构性质,极为相似,但背包问题可以用贪心算法求解,而0-1背包问题却不能用贪心算法求解。
用贪心算法解背包问题的基本步骤:
首先计算每种物品单位重量的价值Vi/Wi,然后,依贪心选择策略,将尽可能多的单位重量价值最高的物品装入背包。
若将这种物品全部装入背包后,背包内的物品总重量未超过C,则选择单位重量价值次高的物品并尽可能多地装入背包。
依此策略一直地进行下去,直到背包装满为止。
具体算法可描述如下:
public static float knapsack(float c,float [] w, float [] v,float [] x)
{
int n=v.length;
Element [] d = new Element [n];
for (int i = 0; i < n; i++) d[i] = new Element(w[i],v[i],i);
MergeSort.mergeSort(d);
int i;
float opt=0;
for (i=0;i<n;i++) x[i]=0;
for (i=0;i<n;i++) {
if (d[i].w>c) break;
x[d[i].i]=1;
opt+=d[i].v;
c-=d[i].w;
}
if (i<n){
x[d[i].i]=c/d[i].w;
opt+=x[d[i].i]*d[i].v;
}
return opt;
}
注:算法knapsack的主要计算时间在于将各种物品依其单位重量的价值从大到小排序。因此,算法的计算时间上界为O(nlogn)。
当然,为了证明算法的正确性,还必须证明背包问题具有贪心选择性质。
对于0-1背包问题,贪心选择之所以不能得到最优解是因为在这种情况下,它无法保证最终能将背包装满,部分闲置的背包空间使每公斤背包空间的价值降低了。事实上,在考虑0-1背包问题时,应比较选择该物品和不选择该物品所导致的最终方案,然后再作出最好选择。由此就导出许多互相重叠的子问题。这正是该问题可用动态规划算法求解的另一重要特征。
实际上也是如此,动态规划算法的确可以有效地解0-1背包问题。
4.贪心算法的基本步骤
为了形式化地描述贪心算法的一般处理过程,我们对所要求解的问题做如下假设。
1)有一个以优化方式来求解的问题。
为了构造问题的解决方案,有一个候选的对象的集合C,如活动安排问题中的活动集合E ={1,2,3,…,n}。
2)随着问题求解过程的进行,这个集合将逐步被划分为两个集合: 一个包含已经被考虑过并被选择的候选对象集合S;另一个包含已经被考虑过但被丢弃的候选对象。
3)有一个函数solution(S)来检查一个所选择的对象集合是否提供了问题的解答。该函数不考虑此时的解决方法是否最优。如活动安排问题中所有活动是否已经被考察。
4)还有一个函数feasible(S)检查是否一个所选对象的集合是可行的,也即是否可能往该集合上添加更多的候选对象以获得一个解。和上一个函数一样,此时不考虑解决方法的最优性。在活动安排问题中,这个函数用来判断所选出的活动之间是否相容。
5)选择函数select ©用来从剩余候选对象集合中,根据当前状态选择出最有希望问题最优解的对象。在在活动安排问题中,选择函数是从剩余活动中选择最早结束的活动。
6)最后,有一个目标函数给出问题解的值,如在活动安排问题中被选择安排的活动数。
其中,选择函数是贪心算法设计的关键,通常与目标函数有关。
function greedy(C) { /*C是候选对象集合*/
S=Ф; /*集合S是构造解*/
while (C!= Ф && ! solution(S) ) {
x=select(C);
C=C-{x};
if ( feasible(S∪{x}) ) S= S∪{x};
}
if ( solution(S) ) return S;
else return “No solutions”;
}
5.一些经典的贪心算法问题
这部分待补充~
笔者要充电了~
笔者的文章还有:
动态规划算法的基本思想-求解步骤-基本要素和一些经典的动态规划问题【干货】
Kopernio一键获取学术期刊全文文献【科研必备小插件】
JetBrains PyCharm 2018.3.5 的完整安装过程【初级必看!!!】