顾名思义,贪心算法总是做出当前看来最好的选择。也就是说,贪心算法并不从整体最优上加以考虑,它所做的选择只是在某种意义上的局部最优解。
可以使用贪心算法求解的问题,一般具有以下两个的性质:贪心选择性质 和 最优子结构性质 。
> 贪心选择
贪心选择 是指所求问题的整体最优解可以通过一系列局部最优的选择来实现。
在贪心选择算法中,仅在当前状态下做出最好的选择,然后再去考虑做出这个选择后产生的相应子问题。
所做的贪心选择可以依赖以往所做选择,但不依赖将来所做的选择,也不依赖子问题的解。
贪心算法常采用自顶向下的方式进行,以迭代方式做出相继的贪心选择,每做出一次选择,就将问题简化为规模更小的子问题。
对于一个具体问题,要证明其是否具有贪心选择性,必须证明每步所做的贪心选择最终导致问题的整体最优解。
> 最优子结构
能将贪心选择后问题简化为规模更小的类似子问题的关键,就在于该问题具有 最优子结构性质 。即原问题的最优解中包含其子问题的最优解。
> 贪心算法不一定总产生整体最优解
虽然贪心算法不是对所有问题都能得到整体最优解,但对范围相当广的问题能产生整体最优解,如最小生成树、图的单源最短路径问题。在一些情况下,即使贪心算法不能得到整体最优解,但其最终结果却是最优解的近似解。
> 贪心算法能否产生整体最优解是需要证明的
贪心算法正确性的证明在后文 活动安排问题 中结合实例给出。
> 问题描述
设 S = { 1 , 2 , . . . , n } S={\{ 1,2,...,n \}} S={1,2,...,n} 是 n 个活动的集合,各个活动使用同一个资源,同一时间只能有一个活动使用这一资源。每个活动 i i i 有起始时间 s i s_i si 和终止时间 f i f_i fi, s i ≤ f i s_i ≤ f_i si≤fi 。
若活动 i i i 和 j j j 是相容的,则有 s j ≤ f i s_j ≤ f_i sj≤fi 或 s i ≥ f j s_i ≥ f_j si≥fj 。活动安排问题就是要在所给的活动集合中选出最大的相容活动子集合。
> 思路分析
那么这个题应该怎么贪呢?可以这么想,为了选择最多的相容活动,每次选结束时间最早也就是 f i f_i fi 最小的活动,使我们能够选更多的活动。那么,这个想法是否能解决该问题是需要去证明的。
先证明总存在以贪心选择开始的最优活动安排方案:
S = { 1 , 2 , . . . , n } S={\{ 1,2,...,n \}} S={1,2,...,n} 是 n 个活动集合, [ s i , f i ] [s_i,f_i] [si,fi] 是活动起始时间,且 f 1 ≤ f 2 ≤ . . . ≤ f n f_1≤f_2≤...≤f_n f1≤f2≤...≤fn,要证贪心选择即要说明 S 的活动选择问题的某个最优解包括活动 1 。
设 A ⊆ S A ⊆ S A⊆S 是该问题的一个最优解,且 A A A 也是按 f i f_i fi 排序。设其第一个活动为 k k k,第二个活动为 j j j 。
① k = 1,则 A 就是以贪心选择开始的最优解
② k != 1,设集合 B = A − { k } ∪ { 1 } B=A-\{k\}∪\{1\} B=A−{k}∪{1}。由于 A 中活动相容, f 1 ≤ f k ≤ s j f_1≤f_k≤s_j f1≤fk≤sj,故 B 中活动也是相容的。又有 A 和 B 集合中元素个数相等,故 B 是以贪心选择活动 1 开始的最有活动安排。
由 ①②,则可以证明出总存在以贪心选择开始的最优活动安排方案。
再证明具有最优子结构:
进一步,选择了活动 1 后,原问题简化为对 S 中所有与活动 1 相容的活动进行安排的子问题。即如果 A 是原问题的最优解,则 A’ = A - {1} 是活动安排问题 S ′ = { i ∈ S ∣ s i ≥ f 1 } S' = \{i∈S | s_i≥f_1\} S′={i∈S∣si≥f1} 的最优解。
可以利用反证法来证明,如果 A’ 不是最优解,存在一个最优解 B’ 是 S’ 的最优解。那么则有 B’ 的元素个数大于 A’ ,将活动 1 加入 B’ 后,其元素个数大于 A ,这与 A 是最优解相矛盾。
这样就证明了该问题具有最优子结构。
最后证明每步贪心选择最终导致了整体最优解:
对贪心选择次数做数学归纳法来证明贪心选择性,也就是证明每次都取局部最优最终可以得到整体最优的结果。
① 当 ∣ A ∣ = 1 |A| = 1 ∣A∣=1 时,由第一次证明可得,无需多言其显然成立
② 设 ∣ A ∣ < k |A| < k ∣A∣<k 时,命题成立
③ 当 ∣ A ∣ = k |A| = k ∣A∣=k 时,由最优子结构 A = { 1 } ∪ A 1 A=\{1\}∪A_1 A={1}∪A1, A 1 A_1 A1 是 S 1 = { j ∈ S ∣ s j ≥ f 1 } S_1=\{j∈S|s_j≥f_1\} S1={j∈S∣sj≥f1} 的最优解。由归纳假设, ∣ A 1 ∣ < k |A_1| < k ∣A1∣<k ,故 A 1 A_1 A1 为每次选取局部最优解所得。又 S 1 = { j ∈ S ∣ s j ≥ f 1 } S_1=\{j∈S|s_j≥f_1\} S1={j∈S∣sj≥f1} 与活动 1 相容,故 A A A 也满足此命题。
其实最后一步数学归纳法套路是固定的,只需证明前两步即可说明贪心算法的正确性
> 代码实现
#include
#include
using namespace std;
const int N = 11;
void GreedSelector(int n, int s[], int f[], bool A[])
{
A[0] = true;
int j = 0;
for (int i = 1; i < N; i++)
{
if (s[i] >= f[j])
{
A[i] = true;
j = i;
}
else
A[i] = false;
}
}
int main()
{
// 默认 f[i] 已按照非减序排好,如果没有排列,需要 O(nlogn) 时间排序
int s[] = {1, 3, 0, 5, 3, 5, 6, 8, 8, 2, 12};
int f[] = {4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14};
bool A[N]; // 用于判断取与不取
GreedSelector(N, s, f, A);
// 遍历最大相容集合
for (int i = 0; i < N; i++)
if (A[i])
cout << i + 1 << " : [ " << s[i] << " , " << f[i] << " ]" << endl;
return 0;
}
该算法 GreedSelector 的计算过程和打印结果如下:
> 问题描述
圣诞节来临了,圣诞老人准备分发糖果,现在有多箱不同的糖果,每箱糖果有自己的价值和重量,每箱糖果都可以拆分成任意散装组合带走。圣诞老人的驯鹿雪橇最多只能装下重量 W 的糖果,请问圣诞老人最多能带走多大价值的糖果。
其实此问题就是 0-1 背包问题的一个变型,不同点在于 “ 可以拆分成任意散装组合 ” 。
> 输入、输出
输入第一行由两个部分组成,分别为糖果箱数正整数 n (1 <= n <= 100),驯鹿能承受的最大重量正整数 w(0 < w < 10000),两个数用空格隔开。
其余 n 行每行对应一箱糖果,由两部分组成,分别为一箱糖果的价值正整数 v i v_i vi 和重量正整数 w i w_i wi ,中间用空格隔开。
输出圣诞老人能带走的糖果的最大总价值,保留 1 位小数。输出为一行,以换行符结束。
> 思路分析
其实,这个例子和 0-1 背包问题很相似,只不过此问题中物品并不要求完整选取,而是可以只选取部分。
对于此题,我们可以先根据糖果的单位重量价值 v i / w i v_i/w_i vi/wi 进行排序。之后,尽可能的选取单位重量价值最高的糖果即可,直至达到驯鹿能承受的最大重量。
该问题的关键在于对各种糖果根据 v i / w i v_i/w_i vi/wi 进行从高到低排序,因此复杂度为 O ( n l o g n ) O(nlogn) O(nlogn) 。
> 代码实现
#include
#include
#include
using namespace std;
const double eps = 1e-6;
struct Candy
{
int v, w;
bool operator<(const Candy &c) const
{
return double(v) / w - double(c.v) / c.w > eps;
}
} candies[110];
int main()
{
int n, w;
cin >> n >> w;
for (int i = 0; i < n; ++i)
cin >> candies[i].v >> candies[i].w;
sort(candies, candies + n);
int totalW = 0;
double totalV = 0;
for (int i = 0; i < n; ++i)
{
if (totalW + candies[i].w <= w)
{
totalW += candies[i].w;
totalV += candies[i].v;
}
else
{
totalV += double(w - totalW) * candies[i].v / candies[i].w;
break;
}
}
printf("%.1f", totalV);
}
> 贪心选择策略对于 0-1 背包问题不再适用
例如,考虑这个例子。
有 3 箱糖果,其价值、重量分别为 (8,6) (5,5) (5,5),雪橇总容量为 10 。若用贪心选择来做,则会优先选择 (8,6) 总价值为 8,之后就无法再选择其他箱糖果,因为所剩容量不足以再拿一箱糖果。
但实际上,最优的选择是两箱 (5,5) 总价值为 10 的糖果。
哈夫曼编码可以很有效地压缩数据:通常可以节省 20%~90% 的空间,具体压缩率依赖于数据的特性。我们将待压缩数据看做字符序列。根据每个字符的出现频率,哈夫曼贪心算法构造出字符的最优二进制表示。
> 问题描述
例如,若一个文件包含 100 000 个字符(字符及频率如下),要对其进行数据压缩,可以有定长码和变长码两种方式:
经计算,定长编码总码长 300 000,而变长编码总码长 224 000 位。由此可以发现,采用变长码要比定长码好的多。
为使得平均码长达最小,哈夫曼提出了 构造最优前缀码的贪心算法 ,由此产生的哈夫曼编码方案称为哈夫曼算法。
哈夫曼算法以自底而上的方式构造最优前缀码的二叉树 T。算法以 |C| 个叶节点开始,执行 |C| - 1 次合并运算后产生最终要求的树 T 。
> 思路分析
简单来说,就是要构造 最优二叉树(哈夫曼树) ,只不过这里的 权值是字符出现的频率,路径长度是字符的码长 。
如何构造最优二叉树相信学过数据结构的同学都应该很熟悉了,可以分为下面几步:
① 将字符集中每个字符 c
出现的频率 f(c)
作为权值
② 根据给定的权值 { w 1 , w 2 , ⋅ ⋅ ⋅ , w n } \{w_1,w_2,···,w_n\} {w1,w2,⋅⋅⋅,wn} 构造 n 个二叉树 F = { T 1 , T 2 , ⋅ ⋅ ⋅ , T n } F=\{T_1,T_2,···,T_n\} F={T1,T2,⋅⋅⋅,Tn},每个 T i T_i Ti 只有一个根结点,权重为 w i w_i wi 。
③ 在 F 中选取两棵根结点的权值最小的树构成一棵新的二叉树,其根的权值为左右子树根的权值的和。
④ 在 F 中删去这两棵树,加上新得的树。
⑤ 重复步骤 ③④,直到只剩一棵树。
生成最优二叉树后,倒序寻找叶子节点的父节点并进行编码即可。
例如,一个文件由 4 个字母 {b, c, j, m, p}
构成,其出现的频率分别为 {5, 6, 2, 9, 7}
,构造哈夫曼树。
当然由于最优二叉树不唯一,此题可以有多种编码方式。对于上图有哈夫曼编码 b:010
、c:11
、j:011
、m:00
、p:10
> 代码实现
#include
#include
#include
using namespace std;
typedef struct
{
int weight;
int parent, lc, rc;
} HTNode, *HuffmanTree;
void SelectMin(HuffmanTree &HT, int n, int &s1, int &s2)
{
int minum;
// 先找最小值,(也可以定义 const int max = 0x3f3f3f3f)
for (int i = 1; i <= n; i++)
{
if (HT[i].parent == 0)
{
minum = i;
break;
}
}
for (int i = 1; i <= n; i++)
{
if (HT[i].parent == 0)
if (HT[i].weight < HT[minum].weight)
minum = i;
}
s1 = minum;
// 寻找第二小的值
for (int i = 1; i <= n; i++)
{
if (HT[i].parent == 0 && i != s1)
{
minum = i;
break;
}
}
for (int i = 1; i <= n; i++)
{
if (HT[i].parent == 0 && i != s1)
if (HT[i].weight < HT[minum].weight)
minum = i;
}
s2 = minum;
}
void CreatHuff(HuffmanTree &HT, int *w, int n)
{
int m, s1, s2;
// m 为节点总数
m = n * 2 - 1;
HT = (HuffmanTree)malloc(m * sizeof(HTNode));
// 叶子结点初始化
for (int i = 1; i <= n; i++)
{
HT[i].weight = w[i];
HT[i].parent = 0;
HT[i].lc = 0;
HT[i].rc = 0;
}
// 非叶结点的初始化
for (int i = n + 1; i <= m; i++)
{
HT[i].weight = 0;
HT[i].parent = 0;
HT[i].lc = 0;
HT[i].rc = 0;
}
// 不断合并,创建哈夫曼树
for (int i = n + 1; i <= m; i++)
{
SelectMin(HT, i - 1, s1, s2);
HT[s1].parent = i;
HT[s2].parent = i;
HT[i].rc = s1;
HT[i].lc = s2;
HT[i].weight = HT[s1].weight + HT[s2].weight;
// 测试打印哈夫曼树
// cout << HT[i].weight << " : " << HT[s1].weight << " 1 "
// << " , " << HT[s2].weight << " 0 "
// << endl;
}
}
void HuffmanTreeCode(HuffmanTree HT, char *str, int n, int child, int &e)
{
int j = 0, m = 0;
int parent = HT[child].parent; //获取第一个叶子结点的父节点的值
cout << "Leafe node : " << HT[child].weight << endl;
//寻找根节点生成编码:左 0 右 1
for (int i = n; parent != 0; i--) //parent != -1 当前结点不是根结点
// 当前叶子为左孩子
if (HT[parent].lc == child)
{
str[j++] = '0';
child = parent;
parent = HT[child].parent;
}
else // 当前叶子为右孩子
{
str[j++] = '1';
child = parent;
parent = HT[child].parent;
}
e = j; //表示一个叶子结点的编码结束
}
int main()
{
HuffmanTree HT;
int *w; // 存储频率
int n, c; // n 为节点个数,c 为频率
cin >> n;
w = (int *)malloc(n * sizeof(int));
for (int i = 1; i <= n; i++)
{
cin >> c;
w[i] = c;
}
CreatHuff(HT, w, n); // 创建哈夫曼树
char str[n]; // 存储编码
int e = 0; // 记录编码结束的位置
for (int i = 1; i <= n; i++)
{
HuffmanTreeCode(HT, str, n, i, e);
cout << "Huffman coding : ";
for (int j = e - 1; j >= 0; j--)
cout << str[j];
cout << endl;
}
free(w);
free(HT);
return 0;
}