算法导论(5)贪心算法
对于很多最优问题,相比较使用动态规划算法求最优解,贪心算法便是更简单更高效的选择。它在每一步都做出当时看起来是最佳的选择,即局部最佳的选择,以此来求得全局最优解。
虽然对于很多算法可以求得最优解,但贪心算法并不保证得到最优解。
我们先看下面的一个实例。
1.活动选择问题
这是一个调度竞争共享资源的多个活动问题,其目标是选出最大的互动兼容活动集合。
加入有 n n n个活动的集合 S = a 1 , a 2 , . . . , a n S={a_1,a_2,...,a_n} S=a1,a2,...,an,这些活动使用同一个资源,而这个资源在某个时刻只能供一个活动使用。如活动 a i a_i ai发生在半开区间 [ s i , f i ) [s_i,f_i) [si,fi)期间。如果两个活动 a i a_i ai和 a j a_j aj不重叠( s i ≤ f j s_i\le f_j si≤fj或者 s i ≤ f i s_i\le f_i si≤fi),则他们是兼容的。我们需要选出最大兼容活动集。比如,我们考虑下面的活动集合 S S S:
i | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 |
---|---|---|---|---|---|---|---|---|---|---|---|
s i s_i si | 1 | 3 | 0 | 5 | 3 | 5 | 6 | 8 | 8 | 2 | 12 |
f i f_i fi | 4 | 5 | 6 | 7 | 9 | 9 | 10 | 11 | 12 | 14 | 16 |
这个例子中,最大兼容活动子集为 a 1 , a 4 , a 8 , a 11 {a_1,a_4,a_8,a_{11}} a1,a4,a8,a11和 a 2 , a 4 , a 9 , a 11 {a_2,a_4,a_9,a_{11}} a2,a4,a9,a11。
对于这个问题,我们可以用动态规划将问题分为两个子问题然后将两个子问题的最优解整合成原问题的一个最优解。我们下面直接考虑贪心算法。
贪心选择
对于我们这种活动选择问题,贪心选择意味着我们应该选择一个活动使得选出它后剩下的资源能被尽量多的其他任务所用。理所当然的,我们首选的活动是S中最早结束的活动。所以贪心选择就是活动 a 1 a_1 a1。接下来就是一个子问题:选择 a 1 a_1 a1结束后开始的活动。那么问题在于,最早结束的 活动是不是一定在最优解中,这个是可以证明的(可以参考《算法导论》)。这样我们这里不必像动态规划那样自顶向上的计算,贪心算法通常都是自顶向下的设计:做出选择然后求解剩下的子问题。
下面是我们的递归贪心算法
RECURSIVE-ACTIVITY-SELECTOR(s,f,k,n)
m=k+1
while m<=n and s[m]<f[k]
m=m+1
if m<=n
return {a_m}U RECURSIVE-ACTIVITY-SELECTOR(s,f,m,n)
else return empty
C++版本,注意按照上面的算法运行的话输出的结果是没有活动 a 1 a_1 a1的,这时我们需要在算法外添加 a 1 a_1 a1才是我们想要的结果.
#include<iostream>
#include<vector>
using namespace std;
vector<int> ras(vector<int>s, vector<int>f, int k, int n) {
int m = k + 1;
while (m <= n && s[m] < f[k])
++m;
if (m <= n) {
vector<int>r = {m+1 },ra=ras(s,f,m,n);
r.insert(r.end(), ra.begin(), ra.end());
return r;
}
else {
vector<int>r;
return r;
}
}
int main() {
vector<int>s = { 1,3,0,5,3,5,6,8,8,2,12 }, f = {4,5,6,7,9,9,10,11,12,14,16};
vector<int>test = ras(s, f, 0, s.size() - 1);
for (auto c : test)
cout << c << endl;
}
我们也可以将上面的算法转换为迭代形式:
GREEDY-ACTIVITY-SELECCTOR(s,f)
n=s.length
A={a_1}
k=1
for m=2 to n
if s[m]>=f[k]
A=A U{a_m}
k=m
return A
C++版本,
vector<int>gas(vector<int>s, vector<int>f) {
vector<int> A = { 1 };
int k = 0;
for (int m = 1; m <= s.size() - 1; ++m) {
if (s[m] >= f[k]) {
A.push_back(m + 1);
k = m;
}
}
return A;
}
int main() {
vector<int>s = { 1,3,0,5,3,5,6,8,8,2,12 }, f = {4,5,6,7,9,9,10,11,12,14,16};
vector<int>test = gas(s,f);
for (auto c : test)
cout << c << endl;
}
2.贪心算法原理
通过前面的例子我们对贪心算法有了大概的了解了把。
一般情况下,我们可以按一下步骤设计贪心算法:
- 将最优化问题转换为这样的形式:对其做出一些选择后,只剩下一个子问题需要求解
- 证明做出贪心选择后原问题总是存在最优解,即贪心选择总是安全的
- 证明做出贪心选择后剩余的子问题满足性质:其最优解与贪心选择组合即可得到原问题的最优解,这样就得到了最优子结构
如何证明一个贪心算法是否能求解一个最优问题并没有一个通用的适合所有情况的方法,但有两个关键要素:贪心选择性质和最优子结构,如果能证明问题具有这两个性质就向贪心算法迈出了重要一步。
贪心选择性质:我们可以通过做出局部最优(贪心)来选择构造全局最优。它在做出选择的时候是不用考虑子问题的解而是看当前情况,相比较动态规划每次选择同时要依赖于子问题的解。当然我们必须证明每个步骤做出贪心选择可以生成全局最优解。(局部最优==全局最优?)这个证明通常首先考虑某个子问题的最优解,然后用贪心选择替换你某个其他的选择来修改此解,从而得到应给相似但更小的子问题。
最优子结构:如果一个问题的最优解包含其子问题的最优解,则称此问题具有最优子结构性质。贪心和动态规划都利用了最优子结构性质。这样的话,对于一个具有最优字结构的问题,我们可以会难以确定此时是采用动态规划还是贪心算法。可以看下面一个例子:
- 0-1 背包问题:一个小偷发现了n个商品,每件商品的价格和重量已知,小偷的背包只能容纳重量为W,小偷需要带走尽可能高价值的商品,每件商品只能整个带走而不能只带走一部分。
- 分数背包问题:与0-1背包不同的是小偷可以只带走商品的部分而不必全部带走
这两个问题有点意思,也能体现贪心算法和动态规划这两种方法面向的对象的差别。贪心算法只能解决分数背包问题而不能解决0-1背包问题。而0-1背包问题适合用动态规划来解决。(比如,三件商品,1重10价值60元,2重20价值100元,3重30价值120元,若用贪心算法解决对应的0-1背包问题得到的结果不是最优的,而解决分数背包问题就没什么问题)
3.赫夫曼编码
赫夫曼编码用于压缩数据。我们将待压缩数据看作字符序列,根据每个字符出现的频率赫夫曼贪心算法构造出字符的最优二进制表示。
比如我们希望压缩一个10万个字符的数据文件,下面给出了文件中字符出现的频率。
a | b | c | d | e | f | |
---|---|---|---|---|---|---|
频率/千次 | 45 | 13 | 12 | 16 | 9 | 5 |
定长编码 | 000 | 001 | 010 | 011 | 100 | 101 |
变长编码 | 0 | 101 | 100 | 111 | 1101 | 1100 |
我们这里考虑前缀码,它可以保证达到最优数据压缩率。
而解码过程需要前缀码的一种方便的表示形式,我们可以用一种二叉树表示。0表示转向左孩子,1表示转向右孩子。
赫夫曼设计了一个贪心算法来构造最优前缀码。下面伪代码中C是一个n个字符的集合,其中的每个字符都是一个对象。
HUFFMAN©
n=|C|
Q=C
for i=1 to n-1
allocate a new node z
z.left=x=EXTRACT-MIN(Q)
z.right=y=EXTRACT-MIN(Q)
z.freq=x.freq+y.freq
INSSERT(Q,z)
return EXTRACT-MIN(Q)
由于算法其中设计二叉树的用法,感觉要实现起来没那么简单。我研究了一下,找到一个稍微简单的思路来实现,最终输出结果虽然不是二叉树,但是二叉树的信息都放到一段字符串中,最终输出的对象中包含这段字符串,这样还是能得到我们要的结果。所以基本上所有的数据结构都可以映射为一段字符串。下面的这段代码我相信对C++有点了解的人理解起来应该不难,对于伪代码中的二叉树,如果我们也创建一个类实现一个二叉树的话那代码会复杂很多,而且我们最终的结果要输出并在控制台打印出来还是要以字符串的形式,所以我认为我这个思路是完全可以的。
#include<iostream>
#include<vector>
using namespace std;
class tree {
public:
tree() = default;
tree(string n, double f) :name(n), freq(f) {};
double getfreq() const{ return freq; }
string getname() const{ return name; }
void setname(string n) { name = n; }
void setfreq(double d) { freq = d; }
private:
string name;
double freq;
};
tree extract_min(vector<tree>&Q) {
tree t = Q[0];
int ind = 0;
for (int i = 0; i < Q.size();++i) {
if (Q[i].getfreq() < t.getfreq()) {
t = Q[i];
ind = i;
}
}
Q.erase(Q.begin() + ind);
return t;
}
tree huffman(vector<tree>&C) {
vector<tree>Q = C;
for (int i = 1; i < C.size(); ++i) {
tree z,x=extract_min(Q),y=extract_min(Q);
z.setname("left->{" + x.getname() + "},r->{" + y.getname() + "}");
z.setfreq(x.getfreq() + y.getfreq());
Q.insert(Q.begin(),z);
}
return extract_min(Q);
}
int main() {
vector<tree>test = { {"a",0.45},{"b",0.13},{"c",0.12},{"d",0.16},{"e",0.09},{"f",0.05} };
tree hf = huffman(test);
cout << hf.getname() <<"---" <<hf.getfreq() << endl;
}