算法作为智力活动的结果,并不是随机头脑风暴活动的产物,虽然因人而异,会有不同的结果,但是基本上它应该是遵循一定规律的活动结果。首先,它需要一些基础性的知识作为这种智力活动的着力点,比如相关领域的数学知识、各种数据结构的掌握等;其次,它需要对问题域做充分的分析和研究,高度概括并抽象出问题的精确描述,也就是各种建立数学模型的方法;最后,有一些常用的模式和原则,可以作为构造算法的选择项,有人将其称为算法设计方法,我建议将之称为算法设计模式或算法设计思想,以便于将其与一些具体的算法名称区分开。
模式作为算法演进的一些固定的思路,它提供了一些构造算法的常用思想。常用的算法设计思想有迭代法、贪婪法、穷举搜索法、递推法、递归法、回溯法、分治法、动态规划法等,这一课将介绍贪婪法。
贪婪法的基本思想
贪婪法(Greedy Algorithm),又称贪心算法,是寻找最优解问题的常用方法,这种方法模式一般将求解过程分成若干个步骤,但每个步骤都应用贪心原则,选取当前状态下最好的或最优的选择(局部最有利的选择),并以此希望最后堆叠出的结果也是最好或最优的解。贪婪法的每次决策都以当前情况为基础并根据某个最优原则进行选择,不从整体上考虑其他各种可能的情况。一般来说,这种贪心原则在各种算法模式中都会体现,这里单独作为一种方法来说明,是因为贪婪法对于特定的问题是非常有效的方法。
贪婪法和动态规划法以及分治法一样,都需要对问题进行分解,定义最优解的子结构,但是与其他方法最大的不同在于,贪婪法每一步选择完局部最优解之后就确定了,不再进行回溯处理,也就是说,每一个步骤的局部最优解确定以后,就不再修改,直到算法结束。因为不进行回溯处理,贪婪法只在很少的情况下可以得到真正的最优解,比如最短路径问题、图的最小生成树问题。在大多数情况下,由于选择策略的“短视”,贪婪法会错过真正的最优解,而得不到问题的真正答案。但是贪婪法简单、高效,省去了为找最优解可能需要的穷举操作,可以得到与最优解比较接近的近似最优解,通常作为其他算法的辅助算法来使用。
贪婪法的基本设计思想有以下三个步骤:
- 建立对问题精确描述的数学模型,包括定义最优解的模型;
- 将问题分解为一系列的子问题,同时定义子问题的最优解结构;
- 应用贪心原则确定每个子问题的局部最优解,并根据最优解的模型,用子问题的局部最优解堆叠出全局最优解。
定义最优解的模型通常和定义子问题的最优结构是同时进行的,最优解的模型一般都体现了最优子问题的分解结构和堆叠方式。对于子问题的分解有多种方式,有的问题可以按照问题的求解过程一步一步进行分解,每一步都在前一步的基础上选择当前最好的解,每做一次选择就将问题简化为一个规模更小的子问题,当最后一步的求解完成后就得到了全局最优解。还有的问题可以将问题分解成相对独立的几个子问题,对每个子问题求解完成后再按照一定的规则(比如某种公式或计算法则)将其组合起来得到全局最优解。
这里说的定义子问题分解和子问题的最优解结构可能有点抽象,我们来看一个具体的经典的例子——找零钱。假如,某国发行的货币有 25 分、10 分、5 分和 1 分四种硬币,如果你是售货员且要找给客户 41 分钱的硬币,如何安排才能找给客人的钱既正确且硬币的个数又最少?这个问题的子问题定义就是从四种币值的硬币中选择一枚,使这个硬币的币值和其他已经选择的硬币的币值总和不超过 41 分钱。子问题的最优解结构就是在之前的步骤已经选择的硬币再加上当前选择的一枚硬币,当然,选择的策略是贪婪策略,即在币值总和不超过 41 的前提下选择币值最大的那种硬币。按照这个策略,第一步会选择 25 分的硬币一枚,第二步会选择 10 分的硬币一枚,第三步会选择 5 分的硬币一枚,第四步会选择 1 分的硬币一枚,总共需要 4 枚硬币。
上面的例子得到的确实是一个最优解,但是很多情况下贪婪法都不能得到最优解。同样以找零钱为例,假如,某国货币发行为 25 分、20 分、5 分和 1 分四种硬币,这时候找 41 分钱的最优策略是 2 枚 20 分的硬币加上 1 枚 1 分硬币,一共 3 枚硬币,但是用贪婪法得到的结果却是 1 枚 25 分硬币、3 枚 5 分硬币和 1 枚 1 分硬币,一共 5 枚硬币。
贪婪法的例子:0-1 背包问题
本节课将介绍一个贪婪法的经典例子——0-1 背包问题:有 N 件物品和一个承重为 C 的背包(也可定义为体积),每件物品的重量是 wi,价值是 pi,求解将哪几件物品装入背包可使这些物品在重量总和不超过 C 的情况下价值总和最大。背包问题(Knapsack Problem)是此类组合优化的 NP 完全问题的统称,如货箱装载问题、货船载物问题等,因问题最初来源于如何选择最合适的物品装在背包中而得名,这个问题隐含了一个条件,每个物品只有一件,也就是限定每件物品只能选择 0 个或 1 个,因此又被称为 0-1 背包问题。
来看一个具体的例子,有一个背包,最多能承载重量为 C=150 的物品,现在有 7 个物品(物品不能分割成任意大小),编号为 1~7,重量分别是 wi=[35、30、60、50、40、10、25],价值分别是 pi=[10、40、30、50、35、40、30],现在从这 7 个物品中选择一个或多个装入背包,要求在物品总重量不超过 C 的前提下,所装入的物品总价值最高。这个问题的数学模型非常简单,就是一个承重是 C 的背包和 n 个物品,每个物品都有重量和价值两个属性。但是在对问题分析的过程中,我们发现,每个物品还需要一个状态用于标记该物品的选择状态,以确定该物品是否已经被选进背包了,状态是 1 表示物品已经被装到包里了,后续的选择不要再考虑这个物品了。需要特别说明的是状态值为 2 的情况,这种情况表示用当前策略选择的物品导致总重量超过了背包承重量,在这种情况下,如果放弃这个物品,按照策略从剩下的物品中再选一个,有可能就能满足背包承重的要求。因此,设置了一个状态 2,表示当前选择物品不合适,下次选择也不要再选这个物品了。描述每个物品的数据结构 OBJECT 定义为:
typedef struct
{
int weight;
int price;
int status; //0:未选中;1:已选中;2:已经不可选
}OBJECT;
接下来是背包问题的定义,背包问题包括两个属性,一个是可选物品列表,另一个是背包总的承重量,简单定义背包问题数据结构如下:
typedef struct
{
std::vector<OBJECT> objs;
int totalC;
}KNAPSACK_PROBLEM;
确定数学模型之后,接下来就要确定子问题了。根据题意,本题的子问题可以描述为:在背包承重还有 C’ 的情况下,选择一个还没有被选择过,且符合贪婪策略的物品装入背包。每选择一个物品 p[i],都要调整背包的承重量 C’=C’-p[i].weight,问题的初始状态是 C’=150,且所有物品都可以选择。假如选择了一个重为 35 的物品后,子问题就变成在背包容量 C’ 是 115 的情况下,从剩下 6 件物品中选择一个物品。确定了子问题的描述,算法的整体实现过程就是按照选择物品装入背包的过程,按部就班地一步一步解决子问题,直到背包不能再装入物品或所有物品都已经装入背包时,结束算法。
那么如何选择物品呢?这就是贪婪策略的选择问题。对于本题,常见的贪婪策略有三种:第一种策略是根据物品价值选择,每次都选价值最高的物品,根据这个策略最终选择装入背包的物品编号依次是 4、2、6、5,此时包中物品总重量是 130,总价值是 165。第二种策略是根据物品重量选择,每次都选择重量最轻的物品,根据这个策略最终选择装入背包的物品编号依次是 6、7、2、1、5,此时包中物品总重量是 140,总价值是 155。第三种策略是定义一个价值密度的概念,每次选择都选价值密度最高的物品,物品的价值密度 si 定义为 pi/wi,这 7 件物品的价值密度分别为 si=[0.286、1.333、0.5、1.0、0.875、4.0、1.2]。根据这个策略最终选择装入背包的物品编号依次是 6、2、7、4、1,此时包中物品的总重量是 150,总价值是 170。
GreedyAlgo() 函数是贪婪算法的主体结构,包括子问题的分解和选择策略的选择都在这个函数中。能够明显看出来这个算法使用了迭代法的算法模式,当然,这个算法主体的实现还可以使用递归法,正如函数所展示的那样,它可以作为此类问题的一个通用解决思路:
void GreedyAlgo(KNAPSACK_PROBLEM *problem, SELECT_POLICY spFunc)
{
int idx;
int ntc = 0;
//spFunc 每次选最符合策略的那个物品,选后再检查
while((idx = spFunc(problem->objs, problem->totalC - ntc)) != -1)
{
//所选物品是否满足背包承重要求?
if((ntc + problem->objs[idx].weight) <= problem->totalC)
{
problem->objs[idx].status = 1;
ntc += problem->objs[idx].weight;
}
else
{
//不能选这个物品了,做个标记后重新选
problem->objs[idx].status = 2;
}
}
PrintResult(problem->objs);
}
spFunc 参数是选择策略函数的接口,通过替换这个参数,可以实现上文提到的三种贪婪策略,分别得到各种贪婪策略下得到的解。以第一种策略为例,每次总是选择 price 最大的物品,可以这样实现:
int Choosefunc1(std::vector<OBJECT>& objs, int c)
{
int index = -1; //-1表示背包容量已满
int mp = 0;
for(int i = 0; i < static_cast<int>(objs.size()); i++)
{
if((objs[i].status == 0) && (objs[i].price > mp))
{
mp = objs[i].price;
index = i;
}
}
return index;
}
看起来第三种策略取得了最好的结果,和动态规划方法得到的最优结果是一致的,但是实际上,这只是对这组数据的验证结果而已,如果换一组数据,结果可能完全相反。当然,对于一些能够证明贪婪策略得到的就是最优解的问题,应用贪婪法可以高效地求得结果,比如求最小生成树的 Prim 算法和 Kruskal 算法。
在大多数情况下,贪婪法受自身策略模式的限制,通常很难直接求解全局最优解问题,也很难用于多阶段决策问题。贪婪法只能得到比较接近最优解的近似的最优解,但是作为一种启发式辅助方法在很多算法中都得到了广泛的应用,很多常用的算法在解决局部最优决策时,都会应用到贪婪法。比如 Dijkstra 的单源最短路径算法在从 dist 中选择当前最短距离的节点时,就是采用的贪婪法策略。事实上,在任何算法中,只要在某个阶段使用了只考虑局部最优情况的选择策略,都可以理解为使用了贪婪算法。