算法设计的常用思想
1 贪婪法
贪婪法(greedy algorithm),又称贪心算法,是寻找最优解问题的常用方法。这种方法模式 一般将求解过程分成若干个步骤,在每个步骤都应用贪心原则,选取当前状态下最好的或最优的 选择(局部最有利的选择),并以此希望最后堆叠出的结果也是最好或最优的解。贪婪法的每次 决策都以当前情况为基础并根据某个最优原则进行选择,不从整体上考虑其他各种可能的情况。 一般来说,这种贪心原则在各种算法模式中都会体现,单独作为一种方法来说明,是因为贪婪法 对于特定的问题是非常有效的方法。
贪婪法和动态规划法以及分治法一样,都需要对问题进行分解,定义最优解的子结构。但是, 贪婪法与其他方法最大的不同在于,贪婪法每一步选择完之后,局部最优解就确定了,不再进行 回溯处理,也就是说,每一个步骤的局部最优解确定以后,就不再修改,直到算法结束。因为不 进行回溯处理,贪婪法只在很少的情况下可以得到真正的最优解,比如最短路径问题、图的最小 生成树问题。大多数情况下,由于选择策略的“短视”,贪婪法会错过真正的最优解,得不到问题的真正答案。但是贪婪法简单高效,省去了为找最优解可能需要的穷举操作,可以得到与最优解比较接近的近似最优解,通常作为其他算法的辅助算法使用。
1.1 贪婪法的基本思想
贪婪法的基本设计思想有以下三个步骤。
- 建立对问题精确描述的数学模型,包括定义最优解的模型;
- 将问题分解为一系列子问题,同时定义子问题的最优解结构;
- 应用贪心原则确定每个子问题的局部最优解,并根据最优解的模型,用子问题的局部最优解堆叠出全局最优解。
定义最优解的模型通常和定义子问题的最优解结构是同时进行的,最优解的模型一般都体现 了最优解子问题的分解结构和堆叠方式。对于子问题的分解有多种方式,有的问题可以按照问题 的求解过程一步一步地进行分解,每一步都在前一步的基础上选择当前最好的解,每做一次选择 就将问题简化为一个规模更小的子问题,当最后一步的求解完成后就得到了全局最优解。还有的 问题可以将问题分解成相对独立的几个子问题,对每个子问题求解完成后再按照一定的规则(比 如某种公式或计算法则)将其组合起来得到全局最优解。
- 贪婪法的例子: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’的情况下,选择一个物品装 入背包。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。
根据前文的分析结果,我们给出贪婪法解决背包问题的算法实现。首先定义背包问题的数 据结构,根据问题描述,可以直接知道每个物品有两个属性,分别是重量和价值。此外,每个 物品只能被选择一次,因此还需要给每个物品增加一个选择状态的属性,因此物品的数据结构 定义如下:
public class TagObject implements Comparable<TagObject> {
private int weight; //重量
private int price; //价值
private int unitValue;//单位重量价值
public TagObject(int weight, int price) {
this.weight = weight;
this.price = price;
this.unitValue = (weight == 0) ? 0 : price / weight;
}
@Override
public int compareTo(TagObject tagObject) {
int value = tagObject.unitValue;
if (unitValue > value)
return 1;
if (unitValue < value)
return -1;
return 0;
}
}
需要特别说明的是状态值为 2 的情况,这种情况表示用当前策略选择的物品导致总重量超过 背包承重量,在这种情况下,如果放弃这个物品,按照策略从剩下的物品中再选一个,有可能就 能满足背包承重的要求。因此,设置了一个状态 2,表示当前选择物品不合适,下次选择也不要 再选这个物品了。接下来是背包问题的定义,背包问题包括两个属性,一个是可选物品列表,一 个是背包总的承重量。简单定义背包问题数据结构如下:
public class TagKnapsackProblem {
// 现有的物品
private TagObject[] bags;
// 背包的总承重
private int totalWeight;
// 背包最大总价值
private int bestValue;
public TagKnapsackProblem(TagObject[] bags, int totalWeight) {
this.bags = bags;
this.totalWeight = totalWeight;
// 对背包按单位重量价值从大到小排序
Arrays.sort(bags, Collections.reverseOrder());
}
public void solve() {
int tempWeight = totalWeight;
for (int i = 0; i < bags.length; i++) {
//判断当前物品是否可以放入背包中,若不能则继续循环,查找下一个物品
if (tempWeight - bags[i].getWeight() < 0)
continue;
tempWeight -= bags[i].getWeight();
bestValue += bags[i].getPrice();
}
}
public int getBestValue() {
return bestValue;
}
}
最后写一个测试类
public class Test {
public static void main(String[] args) {
TagObject[] bags = new TagObject[] { new TagObject(2, 13),
new TagObject(1, 10), new TagObject(3, 24), new TagObject(2, 15),
new TagObject(4, 28), new TagObject(5, 33), new TagObject(3, 20),
new TagObject(1, 8) };
int totalWeight = 12;
TagKnapsackProblem problem = new TagKnapsackProblem(bags, totalWeight);
problem.solve();
System.out.println(problem.getBestValue()); //结果为85
}
}
GreedyAlgo()函数是贪婪算法的主体结构,包括子问题的分解和选择策略的选择都在这个函数中。正如函数所展示的那样,它可以作为此类问题的一个通用解决思路。
2 分治法
分治,顾名思义,分而治之。分治法(divide and conquer)也是一种解决问题的常用模式, 分治法的设计思想是将无法着手解决的大问题分解成一系列规模较小的相同问题,然后逐个解决小问题,即所谓的分而治之。分治法产生的子问题与原始问题相同,只是规模减小,反复使用分治方法,可以使得子问题的规模不断减小,直到能够被直接求解为止。
应用分治法,一般出于两个目的:一是通过分解问题,使无法着手解决的大问题变成容易解 决的小问题;二是通过减小问题的规模,降低解决问题的复杂度(或计算量)。给 1000 个数排序, 可能会因为问题的规模太大而无从下手,但是如果减小这个问题的规模,将问题一分为二,变成 分别对两个拥有 500 个数的序列排序,然后再将两个排序后的序列合并成一个就得到了 1000 个 数的排序结果。对 500 个数排序仍然无法下手,需要继续分解,直到最后问题的规模变成 2 个数 排序的时候,只需要一次比较就可以确定顺序。
2.1 分治法的基本思想
很多情况下,分治法都会使用递归的方式对问题逐级分解,但是在每个子问题的层面上,分
治法基本上可以归纳为以下三个步骤。
(1) 分解:将问题分解为若干个规模较小,相互独立且与原问题形式相同的子问题,确保各 个子问题的解具有相同的子结构。
(2) 解决:如果上一步分解得到的子问题可以解决,则解决这些子问题,否则,对每个子问题 使用和上一步相同的方法再次分解,然后求解分解后的子问题,这个过程可能是一个递归的过程。
(3) 合并:将上一步解决的各个子问题的解通过某种规则合并起来,得到原问题的解。
分治法的实现模式可以是递归方式,也可以是非递归方式,一般采用递归方式的算法模式可 以用伪代码描述为:
void DivideAndConquer(P) {
if(P 可以直接解决) {
T <- P 的结果;
return T;
}
将 P 分解为子问题{P1, P2,..., Pn};
foreach(Pi : {P1, P2,..., Pn}) {
ti <- DivideAndConquer(Pi); //递归解决子问题 Pi
}
T <- Merge(t1, t2,...,tn); //合并子问题的解
return T;
}
分治法的难点是如何将子问题分解,并且将子问题的解合并出原始问题的解。针对不同的问 题,通常有不同的分解与合并的方式。
快速排序算法的分解思想是选择一个标兵数,将待排序的序列分成两个子序列,其中一 个子序列中的数都小于标兵数,另一个子序列中的数都大于标兵数,然后分别对这两个 子序列排序,其合并思想就是将两个已经排序的子序列一前一后拼接在标兵数前后,组成一个完整的有序序列。
2.2 递归和分治,一对好朋友
递归作为一种算法的实现方式,与分治法天生是一对好朋友。问题的分解肯定不是一步到位 的,需要反复使用分治手段,在多个层次上层层分解,这种分解的方法很自然地导致了递归方式 的使用。从算法实现的角度看,分治法得到的子问题和原问题是相同的,当然可以用相同的函数 来解决,区别只在于问题的规模和范围不同。通过特定的函数参数安排,使得同一个函数可以解决不同规模的相同问题,这就是递归方法的基础。
以快速排序为例,如果把待排序的序列作为问题的话,那么子问题的规模就可以定义为子序 列在原始序列中的起始位置。对此一般化之后,原始问题和子问题的描述就统一了,都是原始序 列+起始位置,原始问题的起始位置就是[1,n],子问题的起始位置就是[1,n]中的某一个子区间, 由此一来,递归的接口就明确了:
void quick_sort(int *arElem, int p, int r)
其中,p 和 r 分别是子序列在 arElem 中的起始位置,有了子问题的递归定义接口,快速排序
的算法实现也就水到渠成了:
void quick_sort(int *arElem, int p, int r) {
if(p < r) {
int mid = partion(arElem, p, r); quick_sort(arElem, p, mid - 1); quick_sort(arElem, mid + 1, r);
} }
3 动态规划
动态规划(dynamic programming)是解决多阶段决策问题常用的最优化理论。每种方法都有自身的局限性,动态规划法也不是万能的。动态规划适合求解多阶段(状态转 换)决策问题的最优解,也可用于含有线性或非线性递推关系的最优解问题,但是这些问题都必 须满足最优化原理和子问题的“无后向性”。
- 最优化原理:最优化原理其实就是问题的最优子结构的性质,如果一个问题的最优子结 构是不论过去状态和决策如何,对前面的决策所形成的状态而言,其后的决策必须构成 最优策略。也就是说,不管之前决策是否是最优决策,都必须保证从现在开始的决策是 在之前决策基础上的最优决策,则这样的最优子结构就符合最优化原理。
- 无后向性(无后效性):所谓“无后向性”,就是当各个阶段的子问题确定以后,对于某 个特定阶段的子问题来说,它之前的各个阶段的子问题的决策只影响该阶段的决策,对 该阶段之后的决策不产生影响,也就是说,每个阶段的决策仅受之前决策的影响,但是 不影响之后各阶段的决策。
3.1 动态规划的基本思想
和分治法一样,动态规划解决复杂问题的思路也是对问题进行分解,通过求解小规模的子问
一条小咸鱼