动态规划贪心算法的基础,因为贪心法与动态规划都利用了最优子结构的性质(对于一个问题来说,如果它的一个最优解包含了其子问题的最优解,则称该问题具有最优子结构)。所有的贪心算法均可用动态规划实现。
贪心算法通常是自顶向下,从最上层的子问题开始,做出贪心选择,只选出当时最优的子问题,在对最优子问题的子问题进行贪心选择,这样一层层的做着贪心选择,不断的将问题规约为更小的问题。重点是要采用合适的贪心选择方法。
贪心算法步骤:
- 贪心算法寻找最优子结构,找到子问题的最优解,即通过一系列贪心选择找到一个当时(看起来)最佳的选择(只剩下一个子问题)
- 设计出一个实现贪心策略的递归算法,递归任意阶段最优选择之一总是贪心选择
- 将递归算法转换成迭代算法(递归算法的时间复杂度为线性即可很容易转为迭代算法)
活动选择
活动选择问题:每个活动要求以独占的方式使用资源(即每个活动的开始和结束时间的区间内不允许有其他活动在执行),给出每个活动的开始和结束时间,找出最大的相互兼容的集合
下面是活动集合S,其中各活动已按结束时间单调递增排序:
活动 | a1 | a2 | a3 | a4 | a5 | a6 | a7 | a8 | a9 | a10 | a11 |
---|---|---|---|---|---|---|---|---|---|---|---|
开始时间 si | 1 | 3 | 0 | 5 | 3 | 5 | 6 | 8 | 8 | 2 | 12 |
结束时间 fi | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 |
对以上例子来说,子集 {a3, a9, a11} 由互相兼容的活动组成,然而它并不是最大的子集,子集 {a1, a4, a8, a11}更大,还有 {a2, a4, a9, a11},这两个都是最大的相互兼容子集。
首先我们需要寻找一个最优子结构,对于活动集合Si,j,假设存在活动 ak,在[sk,fk)的区间内不存在其他活动,则最大兼容集合的活动有
c[i,j] = c[i,k] + c[k,j] + 1
可以看出这是一个典型的动态规划问题,具体实现不过多展开,我们现在要将其转为贪心解,贪心算法只会求出最优解的其中一个。
以上可以看出是每次都有两个子问题待求解,上面有提到活动已按结束时间排序,可以分析出:
- 具有最早结束时间的活动 am,必然存在于某最大兼容活动子集中
- c[i,j] = c[i,m] + c[m,j] + 1 中,c[i,m]=1,所以只需求解c[m,j],从两个子问题变成一个子问题的求解
可以看出我们的贪心解只求了子集中包含最早结束时间的活动 am 的集合,并不会求解全部的最大兼容子集。
源码:https://github.com/yangbijia/algorithm/tree/master/src/main/java/greedy/activityselector
/**
* 活动选择,每个活动要求以独占的方式使用资源,给出每个活动的开始和结束时间,找出最大的相互兼容的集合
* @author ellin
* @since 2019/03/25
*/
public class Main {
...
/**
* 递归方式
* @param i
* @param j
* @return
*/
public static List<Integer> recursiveActivitySelector(int i, int j) {
int m = i + 1;
// 上一个节点的结束时间大于当前节点的开始时间,则继续寻找下一个当前节点
while (m < j && s.get(m) < f.get(i)) {
m++;
}
List<Integer> collector = new ArrayList<>();
if (m < j) {
collector.add(m);
collector.addAll(recursiveActivitySelector(m, j));
}
return collector;
}
/**
* 迭代贪心算法,以上的一个子问题的向下尾递归完全可以转为迭代算法
* @return
*/
public static List<Integer> greedyActivitySelector() {
int i = 0, m = i + 1;
List<Integer> collector = new ArrayList<>();
while (m < n) {
if (s.get(m) > f.get(i)) {
i = m;
collector.add(m);
}
m++;
}
return collector;
}
}
背包问题
0-1背包问題是这样的:有一个贼在偷窃一家商店时发现有n件物品;第i件物品值vi元,重wi磅,他希望带走的东西越值钱越好,但他的背包中至多只能装下W磅的东丙,以上数据均为整数,应该带走哪几样东西?(这个问题之所以称为0-1背包问题,是因为每件物品要么被带走要么被留下;小偷不能只带走某个物品的一部分或带走两次以上的同一物品)
在部分背包问题中场景等与上面问题一样,但是窃贼可以带走物品的一部分,而不必做出0-1的二分选择,可以把0-1背包问题的一件物品想像成一个金锭,而部分背包问题中的一件物品则更像金粉。
两种背包问题都具有最优子结构性质。要获取重量最多W磅最值钱的东西:
- 0-1背包问题中,我们假设先拿掉物品j,余下必须从除j以外的n-1件物品中带走至多W-wj重的最值钱的东西
- 部分背包问题中,我们假设先拿掉物品j的w重部分,余下必须从其他n-1件物品和物品j的wj-w磅中带走至多中W-w重的最值钱的东西
实例问题:共3件物品(物品不可拆分),物品1重10磅价值60元,物品2重20磅价值100元,物品3重30磅价值120元,背包可容纳50磅重的东西。
可以看出实例属于0-1背包问题。
对于部分背包问题,按照一种贪心策略,窃贼先尽可能多的取具有最大每磅价值的物品,接下来再拿其次的,直到不能再取为止。这样通过每磅价值来对所有物品排序,贪心算法就可以O(nlgn)时间运行。如果按照部分背包问题用贪心策略求解以上实例,最先取的是物品1,但是最优解却是取物品2和3。
对于0-1背包问题,不适用贪心策略,按每磅价值最高的物品取并不是最优解,因为该物品必须全部拿走,
a) 重量可能超过W磅。
b) 也可能小于W磅但最后剩下的空间也放不进其它物品,无法将背包填满,空余的空间降低了货物的有效每磅价值。
但0-1背包问题满足最优子结构,n个物品重任选一件先取(每件物品都可作为最先取的物品),余下子问题求最优解,子问题之间有重叠,我们就可以用动态规划来解决了。
赫夫曼编码
赫夫曼编码是一种广泛应用且有效的数据压缩技术,根据待压缩数据的特征,一般可压缩掉20%~90%。这里考虑压缩的数据为字符串,假设有一个包含100000个字符的数据文件要压缩,下图为字符出现频度表:
a | b | c | d | e | f | |
---|---|---|---|---|---|---|
频度(千字) | 45 | 13 | 12 | 16 | 9 | 5 |
固定长编码 | 000 | 001 | 010 | 011 | 100 | 101 |
变长编码 | 0 | 101 | 100 | 111 | 1101 | 1100 |
可用两种方法表示这个文件
固定长编码:固定长度,每个字符由一个唯一定长的二进制串表示,上述由3位二进制数表示的编码方法就需要30万位来对原始文件进行编码
变长编码:对频度高的字符赋以短编码,频度低的赋以长编码,这种方式可以极大程度压缩数据文件的大小
前缀编码:在赫夫曼编码中,没有一个编码是另一个编码的前缀,这样的编码成为前缀编码。前缀编码解码很方便,因为没有一个码是其他码的前缀,所以被编码文件的第一个编码是确定的,后面每个编码都可以很容易确定。
通常利用赫夫曼树构造赫夫曼编码,以下为赫夫曼树的特征:
- 每个字符对应一个叶子节点
- 每个非叶子节点都有两个子节点,即赫夫曼树是一颗满二叉树
- 树中的每个节点的值为频次
- 指向左节点的路径标记为0,指向右节点的路径标记为1
- 根据第4条,从顶点向每个叶子节点访问的路径标记集合为需要的赫夫曼编码集合
以上两种编码方式均可用赫夫曼树表示:
我们总是希望有一种最优前缀编码来实现数据文件的最大压缩,用贪心算法构造哈弗曼树得到的编码可以成为最优前缀码。
最优前缀码的问题具有最优子结构性质:
赫夫曼树的任一非叶子节点(父节点),均可用另一个字符代替该节点下的所有叶子节点的字符,即砍去该节点的下面所有节点,使之成为新的叶子节点,这个新的叶子节点可以表示一个新的字符。
有一个包含n个字符的集合C,集合中的任一字符c出现的频度为 f ( c ),用贪心选择为集合C构造赫夫曼树得到最优前缀码(也称HUFFMAN算法)。
- 这里所做的贪心选择是将所有字符按频度 f 从小到大排序,放入一个队列
- 每次按频度最低两个节点组成新节点的两个子节点,将新节点插入队列
- 一直取出频度最低的两个节点直到队列为空,这样最终得到一颗赫夫曼树。
下面是HUFFMAN算法的具体实现:
源码:https://github.com/yangbijia/algorithm/tree/master/src/main/java/greedy/huffman
/**
* 构造HUFFMAN树
* @param frequencyInfos 按频次排序好的字符列表
*/
public static void huffmman(List<Node> frequencyInfos) {
int n;
Node root = null;
while ((n = frequencyInfos.size()) > 1) {
Node left = frequencyInfos.get(0);
Node right = frequencyInfos.get(1);
// 两个最低频次的节点作为子节点组合出一个新节点
Node parent = new Node();
parent.frequency = left.frequency + right.frequency;
parent.left = left;
parent.right = right;
left.parent = parent;
right.parent = parent;
// 节点队列移除以上两个节点
frequencyInfos.remove(0);
frequencyInfos.remove(0);
// 将新节点加入队列
frequencyInfos.add(parent);
// 对新队列重新排序,方便直接取出频度最低的两个节点
frequencyInfos.sort(Comparator.comparing(info -> info.frequency));
root = parent;
}
List<String> huffmanCodes = getHuffmanCode(root);
for (String code : huffmanCodes) {
System.out.println(code);
}
printHuffmanTree(root, countHigh(root), 4);
System.out.println();
}
/**
* 根据HUFFMAN树获取赫夫曼编码
* 先序遍历(根左右)访问到每个叶子节点时,向上访问其到根节点的路径,存下路径后继续遍历
* @param root
* @return
*/
public static List<String> getHuffmanCode(Node root) {
Map<StringBuilder, StringBuilder> paths = new HashMap<>();
Stack stack = new Stack();
stack.push(root);
while (!stack.empty()) {
Node cur = (Node) stack.pop();
// 访问到叶子节点
if (cur.left == null) {
StringBuilder leaf = new StringBuilder(cur.charactor);
Node parent;
StringBuilder path = paths.get(cur.charactor) == null ?
new StringBuilder() : paths.get(cur.charactor);
// 找出该叶子节点的路径
while ((parent = cur.parent) != null) {
// 左边路径标记为0
if (parent.left == cur) {
path.append(0);
// 右边路径标记为1
} else {
path.append(1);
}
cur = parent;
}
paths.put(leaf, path);
} else {
stack.push(cur.left);
stack.push(cur.right);
}
}
return paths.values().stream().map(path -> path.toString()).collect(Collectors.toList());
}