对于最优化问题的求解我们之前说过可以使用动态规划,但是有时候我们不需要使用动态规划,而是每步都选择当时看起来最佳的选择,并且寄希望于可以通过这样的方式寻找出最优的解。虽然这种方式不一定能找到最优解,但是对于许多问题这种算法确实适用。
一、贪心算法的步骤
贪心算法通过在每个决策点做出当时看起来最佳的选择来寄希望于使用这种方式找到最优解,过程如下:
1、确认问题的最优子结构;
2、设计一个递归算法;
3、证明如果我们做出了一个贪心选择,则只剩下一个子问题;
4、证明在当前场景下贪心算法的正确性;
5、设计递归算法实现贪心策略;
6、将递归算法转化为迭代算法。
二、贪心算法的性质
贪心算法是以动态规划为基础的,但是与动态规划的繁琐不同,我们可以使用贪心选择来改进最优子结构,使得选择后只留下一个子问题。
贪心算法的一个关键因素在于贪心选择性质:我们可以通过做出局部最优选择来构造全局最优解。我们总是做出当时看起来最佳的选择,而不依赖将来的选择或者剩余子问题的解。
如果一个问题的最优解包含其子问题的最优解,则称此问题具有最优子结构性质。
贪心算法不用像动态规划一样考虑太多的选择,因此贪心算法比动态规划会更高效。
三、贪心算法的例子
1、假定有一个n个活动的集合S={a1,a2,a3,…,an},这些活动使用同一个资源,这个资源在某个时刻只提供一个活动使用。每个活动ai都有一个开始时间si和一个结束时间fi,活动将在[s1,fi)区间举行。如果两个活动ai,aj不重叠,则称它们是兼容的。找出一个最大的互相兼容的活动集合。
问题分析:记不记得我们之前讲的动态规划算法技术(不记得点击此处),这个问题很明显可以使用动态规划解决。对于这n个活动,我们可以将其按照结束时间进行排序,对于在ai活动结束之后,aj活动开始之前的活动集合Sij,其最大兼容的活动集合等于Sik +Skj+ak,其中Sik表示ai活动结束之后,ak活动开始之前的最大活动集合,Skj同理,ak是最大兼容活动集合中的一个活动,因此需要遍历活动ak,判断ak是否在最大兼容活动集合内。边界条件为Sij为空集,其最大兼容活动集合为空集。
a、动态规划实现
使用动态规划技术的Java代码实现如下:
private static class Action {
private int startTime;
private int endTime;
public Action(int startTime , int endTime){
this.startTime = startTime;
this.endTime = endTime;
}
public static final Comparator<? super Action> actionComparator = new Comparator<Action>(){
@Override
public int compare(Action o1, Action o2) {
if(o1.endTime > o2.endTime)
return 1;
else if(o1.endTime < o2.endTime)
return -1;
else
return 0;
}
};
public static ArrayList<Action> getMaxNumActionWithMemo(ArrayList<Action> allAction){
allAction.sort(Action.actionComparator);
HashMap<ArrayList<Action>, ArrayList<Action>> memo = new HashMap<ArrayList<Action>, ArrayList<Action>>();
return getMaxNumActionMemo(allAction, memo);
}
private static ArrayList<Action> getMaxNumActionMemo(ArrayList<Action> allAction, HashMap<ArrayList<Action>, ArrayList<Action>> memo){
ArrayList<Action> maxAction = new ArrayList<Action>();
ArrayList<Action> actionPart1 = new ArrayList<Action>();
ArrayList<Action> actionPart2 = new ArrayList<Action>();
for(int i = 0; i < allAction.size(); i++){
//以allAction.get[i]作为最佳分割活动 将活动分为两个部分
divide(allAction, i, actionPart1, actionPart2);
ArrayList<Action> maxActionPart1 = null;
ArrayList<Action> maxActionPart2 = null;
if(!memo.containsKey(actionPart1)){
maxActionPart1 = getMaxNumActionMemo(actionPart1, memo);
memo.put(actionPart1, maxActionPart1);
}
else
maxActionPart1 = memo.get(actionPart1);
if(!memo.containsKey(actionPart2)){
maxActionPart2 = getMaxNumActionMemo(actionPart2, memo);
memo.put(actionPart2, maxActionPart2);
}
else
maxActionPart2 = memo.get(actionPart2);
if(maxAction.size() < maxActionPart1.size() + maxActionPart2.size() + 1){
maxAction.clear();
maxAction.addAll(maxActionPart1);
maxAction.add(allAction.get(i));
maxAction.addAll(maxActionPart2);
}
}
return maxAction;
}
//通过allAction.get(index)对allAction进行分割 分成两个部分
private static void divide(ArrayList<Action> allAction, int index,
ArrayList<Action> actionPart1, ArrayList<Action> actionPart2){
actionPart1.clear();
actionPart2.clear();
Action midAction = allAction.get(index);
for(int i = 0; i < index ; i++){
if(allAction.get(i).endTime > midAction.startTime)
continue;
actionPart1.add(allAction.get(i));
}
for(int i = index + 1; i < allAction.size(); i++){
if(allAction.get(i).startTime < midAction.endTime)
continue;
actionPart2.add(allAction.get(i));
}
}
暴露出来的getMaxNumActionWithMemo(ArrayList<Action> allAction)方法就是我们使用动态规划技术实现的方法,使用了带备忘的自顶向下递归。我们遍历活动ak来确定ak在最大兼容活动集合中,但是事实上我们在选择ak的时候可以只考虑一个选择,比如我们直观上认为需要留出更多的时间来做其他活动的活动是最优选择,也就是结束得最早的活动是最优选择,而不是遍历所有的活动去确定最优选择。这就是下面要说的贪心算法。
b、贪心算法实现
private static class Action {
private int startTime;
private int endTime;
public Action(int startTime , int endTime){
this.startTime = startTime;
this.endTime = endTime;
}
public static final Comparator<? super Action> actionComparator = new Comparator<Action>(){
@Override
public int compare(Action o1, Action o2) {
if(o1.endTime > o2.endTime)
return 1;
else if(o1.endTime < o2.endTime)
return -1;
else
return 0;
}
};
}
public static ArrayList<Action> getMaxNumActionWithGreedyAl(ArrayList<Action> list){
list.sort(Action.actionComparator);
ArrayList<Action> maxAction = new ArrayList<Action>();
int lastEndTime = 0;
for(int i = 0; i < list.size(); i++){
if(list.get(i).startTime > lastEndTime)
maxAction.add(list.get(i));
}
return maxAction;
}
使用贪心算法之后和动态规划相比代码简洁了很多,复杂度也下来了。因为在动态规划中我们虽然确定了状态转移方程Sij = Sik +Skj+ak,但是对于ak是否属于最大兼容活动集合我们需要循环判断。然而在贪心算法中我们直接可以确定,我们只需选择结束得最早的活动即可。
四、贪心算法的安全性证明
贪心算法是以动态规划方法为基础的,但是对于某些问题我们可以做出贪心选择,使得一个问题演变成一次贪心选择和一个子问题。几乎每个贪心算法背后,都可以用一个繁琐的动态规划算法实现。不得不承认贪心算法在某些场景下的运用带来的便捷,但是注意贪心算法的安全性。
证明贪心算法安全性的目标在于证明一个最优解是贪心选择得到的(因为可能存在多个最优解),对于不太严谨的证明如下:
a、证明一个全局最优解的开始是一次贪心选择;
b、证明对于一个n的最优解可以分解为一次贪心选择和剩下的n-1的子问题的最优解。
较为严谨的证明叫做exchange argument,如下:
a: 给出贪心算法A的描述,假设其不是最优的;
b: A不是最优的,那么就存在O是和A最相似的,也就是前k步都相同,第k+1步开始不同的最优算法;
c: 修改算法O(用Exchange Argument,交换A和O中的一个元素),得到新的算法O’,使得O’比O与A更相似;
d: 证明O’ 可行,也就是O’;
e: 证明O’至少和O一样,即O’也是最优的;
f: 得到矛盾,因为O’ 比O 更和A 相似。
我们使用exchange argument来证明我们在这里使用贪心算法是正确的:
我们之前每次选择结束时间最早的活动ai作为解,假设不是最优的,那么存在一个O,O是和A最相近的一个最优算法。
假设A在第k步选择的活动是ai,这一步不是最优的,O的第k步选择是aj,其中aj的结束时间大于ai的结束时间。
现在构造一个O’,O’ = O- aj + ai。也就是O’等于O在第k步不选择aj,而是选择ai,这样就使得O’比O更与A相似。
O’很显然是可行的,因为第k步之前的选择和A一样,第k步之后的选择和我们假设的最优算法O一样,因此O’是可行的,也就是O’也是最优的。
由于之前假设过O与A是最相似的最优算法,而这里O’是比O与A更相似的最优算法,相矛盾。
所以A是最优算法。