算法导论学习——16章 贪心算法
应用范围
求解最优化问题中,每个步骤面临多种选择。对于许多最优化问题来说,使用动态规划过于繁琐。对于某些特定的问题,可以通过不断取局部最优化得到全局最优化的方法,这种方法就是贪心算法。
贪心算法不保证得到最优解,但有些时候可以得到最优解。
一个例子
活动选择问题
有 n n n个活动,这些活动争用同一个资源。每个活动有一个开始时间 s i s_i si和结束时间 f i f_i fi,假如两个活动 a i a_i ai和 a j a_j aj满足 [ s i , f i ) [s_i,f_i) [si,fi)和 [ s j , f j ) [s_j,f_j) [sj,fj)不重叠,则称这两个活动兼容。给定一个活动集合 A = { a 1 , a 2 , . . . , a n } A=\{a_1,a_2,...,a_n\} A={a1,a2,...,an},求最长的兼容活动集合的长度。
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 | 13 |
f i f_i fi | 4 | 5 | 6 | 7 | 9 | 9 | 10 | 11 | 12 | 14 | 16 |
使用动态规划解决
- 验证该问题具有最优子结构性质
令 S i j S_{ij} Sij表示在 a i a_i ai结束之后开始,在 a j a_j aj开始之前结束的活动集合,假定 A i j A_{ij} Aij是 S i j S_{ij} Sij的最大兼容子集,包含活动 a k a_k ak。由此得到两个子问题,寻找 S i k S_{ik} Sik的兼容活动和 S k j S_{kj} Skj的兼容活动。令 A i k = A i j ∩ S i k A_{ik}=A_{ij} \cap S_{ik} Aik=Aij∩Sik, A k j = A i j ∩ S k j A_{kj}=A_{ij}\cap S_{kj} Akj=Aij∩Skj,则 ∣ A i j ∣ = ∣ A i k ∣ + ∣ A k j ∣ + 1 |A_{ij}|=|A_{ik}|+|A_{kj}|+1 ∣Aij∣=∣Aik∣+∣Akj∣+1。
反证法证明必然包含子问题最优解:假设 S i k S_{ik} Sik有兼容子集 A i k ′ A_{ik}' Aik′使得 ∣ A i k ′ ∣ > ∣ A i k ∣ |A_{ik}'|>|A_{ik}| ∣Aik′∣>∣Aik∣,则将 A i k ′ A_{ik}' Aik′作为子问题的解,使得 ∣ A i j ′ ∣ > ∣ A i j ∣ |A_{ij}'|>|A_{ij}| ∣Aij′∣>∣Aij∣,与 A i j A_{ij} Aij是最优解的假设相悖。 - 刻画最优解结构
令 c [ i ] [ j ] c[i][j] c[i][j]表示 S i j S_{ij} Sij最优解的大小,则 c [ i ] [ j ] = c [ i ] [ k ] + c [ k ] [ j ] + 1 c[i][j]=c[i][k]+c[k][j]+1 c[i][j]=c[i][k]+c[k][j]+1- c [ i ] [ j ] = 0 c[i][j]=0 c[i][j]=0, ∣ S i j ∣ = 0 |S_{ij}|=0 ∣Sij∣=0
- c [ i ] [ j ] = max a k ∈ S i j { c [ i ] [ k ] + c [ k ] [ j ] + 1 } c[i][j]=\max_{a_k\in S_{ij}}\{c[i][k]+c[k][j]+1\} c[i][j]=maxak∈Sij{c[i][k]+c[k][j]+1}
通过自底向上或带备忘的自顶向下的求解策略,可以通过动态规划的算法解决问题。
复杂度分析
变化的参数有 i , j , k i,j,k i,j,k,因此有三层循环,时间复杂度为 O ( n 3 ) O(n^3) O(n3)。
贪心法解决
- 贪心法的策略是选择当前最优的策略,而不考虑后续的问题。对于活动选择问题来说,局部最优的策略是选择结束最早的活动,这样资源有更多的时间被结束较晚的活动所使用。
- 由于本问题具有最优子结构性质,选择一个活动后,子问题为在剩下的时间段内寻找最优,而最优解的定义为选择最早结束的活动,因此贪心法不断选择可选活动内结束时间最早的活动,加入集合中。选择过程结束后,即可得到最优解。
- 现在需要证明的问题变为,证明最早结束的活动总是最优解的一部分。
假设 A i j A_{ij} Aij为 S i j S_{ij} Sij的一个最优解, a m a_m am是 S i j S_{ij} Sij中最早结束的活动, a k a_{k} ak为 A i j A_{ij} Aij中最早结束的活动。若 a m = a k a_m=a_k am=ak则已经是一部分,否则将 a k a_k ak替换成 a m a_m am,由于 A i j A_{ij} Aij中活动都兼容, f m < f k f_m<f_k fm<fk,新得到的集合中活动也兼容,新集合也是一个最优解,得证。
贪心算法实现
vector<int> select(int *s,int *f,int n) // f为排好序的结束时间数组
{
int cur=0;
vector<int> tmp;
for (int i=0;i<n;i++)
{
if (s[i]>=cur) //当前最早结束的活动与已选择的活动兼容
{
tmp.push_back(i);// 选择
cur=f[i]; //更新结束时间
}
}
return tmp;
}
贪心算法原理
在设计贪心算法的过程中,通常采取下述步骤:
- 将最优化问题转化成这样的形式: 对其做出一种选择后,只剩下一个子问题需要求解
- 证明做出贪心选择后,原问题总是存在最优解,即贪心选择总是安全的
- 证明做出贪心选择后,剩余的子问题满足性质:其最优解与贪心选择组合即可得到原问题的最优解,这就得到了最优子结构
如何证明一个问题可以使用贪心算法解决?贪心选择性质和最优子结构是两个重要因素。
贪心选择性质
我们可以通过不断选择局部最优得到全局最优,这种性质称为贪心选择性质。选择时只需要考虑当前的最优情况,而不需要考虑子问题的解。
为证明具有贪心选择性质,通常首先考察某个子问题的最优解,用贪心选择替换某个选择,证明修改后的解仍然是最优解。
最优子结构性质
一个问题的最优解包含其子问题的最优解,称为最优子结构性质。这是动态规划和贪心算法的关键要素。
贪心对动态规划
一般来说,贪心算法解决的问题都有一个更复杂的动态规划解法,而有些动态规划问题解决的问题不能使用贪心算法。
- 0-1背包问题可以动态规划解决而不能贪心解决
- 分数背包问题可以动态规划解决也可以贪心解决
贪心算法应用——哈夫曼编码
为解决最优前缀码问题,哈夫曼提出哈夫曼编码。设前缀编码树
T
T
T,其中有编码节点
v
i
v_i
vi,对应出现频率
f
i
f_i
fi,在树中深度
d
i
d_i
di。
∑
f
i
⋅
d
i
\sum{f_i\cdot d_i}
∑fi⋅di最小时,
T
T
T为最优前缀编码树。现构造最优前缀编码树。
使用贪心算法解决问题:
直观的来看,使用频率最高的字符应该在树中深度最小,频率最低的字符在树中深度最大。使用贪心算法的思想来解题,可知从最低叶节点开始构建,每次选择频率最低的字符。现证明最优子结构性质:假设从最低叶节点开始,最优字符的选择顺序为
V
=
{
v
1
,
v
2
,
.
.
.
,
v
n
}
V=\{v_1,v_2,...,v_n\}
V={v1,v2,...,vn},按照贪心算法选择的顺序为
V
′
=
{
v
1
′
,
v
2
′
,
.
.
.
v
n
′
}
V'=\{v_1',v_2',...v_n'\}
V′={v1′,v2′,...vn′},其中存在
k
k
k,使得
v
i
=
v
i
′
,
i
≤
k
v_i=v_i', i\leq k
vi=vi′,i≤k。
v
k
+
1
≠
v
k
+
1
′
v_{k+1}\neq v_{k+1}'
vk+1=vk+1′。由于构造性质可知,选择顺序在前的字符深度不小于选择顺序在后的字符,因此
d
k
+
1
≥
d
k
+
1
′
d_{k+1} \geq d_{k+1}'
dk+1≥dk+1′,
f
k
+
1
≥
f
k
+
1
f_{k+1}\geq f_{k+1}
fk+1≥fk+1,因此将
v
k
+
1
v_{k+1}
vk+1替换为
v
k
+
1
v_{k+1}
vk+1后,编码树总代价不会增加(不变或减少)。由此可得,贪心算法的解对每个子问题都是最优解,且每个问题的最优解都包含其子问题的最优解。
编码实现
struct Node
{
char c;
int pos;
Node *left,*right;
Node(char ch='\0',int p=0,Node *l=NULL,Node *r=NULL)
{
c=ch;
pos=p;
left=l;
right=r;
}
};
bool cmp(Node *a, Node *b) //用来栈排序
{
return a->pos>b->pos;
}
Node* huffman(vector<Node*> v)
{
vector<Node*> p=v;
sort(p.begin(),p.end(),cmp);
while (!p.empty())
{
Node *l=p[p.size()-1]; //选择概率最小的
p.pop_back();
if (p.empty())
{
return l;
}
Node *r=p[p.size()-1];//选择概率次小的
p.pop_back();
Node *tmp=new Node;
tmp->left=l;
tmp->right=r;
tmp->pos=l->pos+r->pos; //合并
p.push_back(tmp);
sort(p.begin(),p.end(),cmp);//排序
}
}