Greedy Approach
贪心算法:一种”短视“的,只考虑局部最优的方法(不像DP会考虑子问题的计算结果)
因此贪心法往往时间效率更高,但论证贪心算法的正确性也较为困难。
正确性证明常见方法:1.归纳法(对算法执行步数) 2.交换论证法(反证)
1. 活动选择问题
活动时间两两不能重叠,问能进行的最大活动数。
该问题等效于线段覆盖问题中的 最多不相交线段覆盖。
算法:按照右端点(活动结束时间)升序排序,依次添加线段。
正确性证明思路:对算法执行步数进行归纳。归纳基础:先证明存在一个最优解包含右端点最小的区间(否则可以进行替换)。归纳步骤:对选出的活动个数k进行归纳,证明最优解去掉前k个的活动,是剩下集合的最优解,否则矛盾,必然存在对剩下的集合的最优解包含第一个活动,归纳结论成立。
2. 任务调度问题
某工厂需要为n个客户提供任务,给定n个客户的任务的完成耗时
T
=
<
t
1
,
…
,
t
n
>
T= <t_1,\dots,t_n>
T=<t1,…,tn>,以及n个客户对完成时间的要求
D
=
<
d
1
,
…
,
d
n
>
D=<d_1,\dots,d_n>
D=<d1,…,dn> 。求调度方案
f
f
f,使得所有客户延迟时间的最大值最小。
数学建模:
min
f
max
i
{
f
(
i
)
+
t
i
−
d
i
}
\min\limits_f \max\limits_i \{f(i)+t_i -d_i\}
fminimax{f(i)+ti−di} (且
f
(
i
)
f(i)
f(i)保证维修时间互不重叠)
算法:根据截止时间 d n d_n dn 从小到大排序即可(即不存在逆序)
正确性证明:可通过交换论证法,将最优解变换成无逆序的情形(相邻的两两交换,总交换次数必然有限),最大延迟不会变差。
贪心算法得不到最优解的处理方法
- 输入参数分析
e.g. 找零钱问题:有n种硬币(数量不限),给出其重量和价值。需要付款的总价格为Y,求如何选择面值使得总重量最低。
多重背包问题:可使用DP法,时间复杂度为 O ( n Y 2 ) O(nY^2) O(nY2)
考虑贪心算法:对单位价值重量按最大到小排序,优先选相对轻的(编号大)
记使用前k种零钱,总钱数为y时,利用贪心法所获得重量为
G
k
(
y
)
G_k (y)
Gk(y),可得
G
k
(
y
)
=
w
k
⌊
y
v
k
⌋
+
G
k
−
1
(
y
m
o
d
v
k
)
G_k(y) = w_k \lfloor\dfrac y {v_k} \rfloor+G_{k-1}\ (y \ mod \ v_k) \\
Gk(y)=wk⌊vky⌋+Gk−1 (y mod vk)
观察:当n等于1,2时,贪心法是最优解。
当n=2时,对DP中的递推式 F k ( y ) = min { F k − 1 ( y − x k v k ) + w k x k } F_k(y) = \min\limits_{} \{F_{k-1}(y-x_kv_k)+w_k x_k \} Fk(y)=min{Fk−1(y−xkvk)+wkxk}。可证明当 x 2 x_2 x2取得最大值时得到最优解(和贪心法得到的解一样)
可证明定理:若对前k种硬币使用贪心法都能得到最优解,则对前k+1种硬币使用也得到最优解的充要条件为 ∀ y ∈ N , G k + 1 ( y ) ≤ G k ( y ) \forall y \in N, G_{k+1}(y) \le G_k(y) ∀y∈N,Gk+1(y)≤Gk(y)
-
但需要对所有非负整数y进行验证,这在实际运算中不可行。需要其他条件。
判别条件定理:(检验贪心法是否产生最优解)
G
k
(
y
)
=
F
k
(
y
)
,
k
∈
N
v
k
+
1
=
p
v
k
−
δ
G_k(y) = F_k(y),k \in N \\ v_{k+1} = pv_k - \delta
Gk(y)=Fk(y),k∈Nvk+1=pvk−δ
则下列命题等价:
条件(1): G k + 1 ( y ) = F k + 1 ( y ) , ∀ y ∈ N G_{k+1}(y) = F_{k+1}(y),\forall y \in N Gk+1(y)=Fk+1(y),∀y∈N
条件(2): G k + 1 ( p v k ) = F k + 1 ( p v k ) G_{k+1}(pv_k) = F_{k+1}(pv_k) Gk+1(pvk)=Fk+1(pvk)
条件(3): ω k + 1 + G k ( δ ) ≤ p ω k \omega_{k+1}+G_k(\delta) \le p \omega_k ωk+1+Gk(δ)≤pωk
条件3验证一次只需要 O ( k ) O(k) O(k)时间。一共需要验证 O ( k n ) O(kn) O(kn)的时间。
条件2则帮助找反例,“一点定理”
-
近似误差分析
典例:集合覆盖问题(Set Cover Problem)
属于NP-难问题;可以用贪心法进行近似,近似的因子为logn数量级。
3. Huffman编码
二元前缀码:prefix-free(从而保证解码唯一性)
前缀码的二叉树表示:叶子结点对应编码
平均传输位数:
B
=
∑
i
=
1
n
f
(
x
i
)
d
i
B = \sum\limits_{i=1}^n f(x_i)d_i
B=i=1∑nf(xi)di
Huffman树算法:
归纳证明:将两个最小的子结点合并成新结点,得到的新二叉树也是一个最优二叉编码树(否则可推出矛盾)
引理:对频率最小的两个字符,存在一种最优的编码方式使其是二叉树中的兄弟。
应用:文件二分归并
等效于建立二叉树,归并代价最小时恰好对应huffman树
4. 最小生成树
回顾图论中关于生成树的性质:
1)T是n阶连通图G的生成树当且仅当T有n-1条边且无圈
2)对G的生成树T,若 e ∉ T e \notin T e∈/T,那么添加e后包含一个圈
3)剔除2)中圈C的任意一条边,可以得到G的生成树
生成树性质的应用:
约束:不构成回路 截止:边数达到n-1
改进生成树:用回路中权值更小的边来替代
Prim算法
算法步骤:随意选择一个点加入已选集合
S
1
S_1
S1,然后寻找
S
1
S1
S1到剩余点集
S
2
S_2
S2中权值最小的一条边,将对应的
S
2
S_2
S2 中的点加入
S
1
S_1
S1,直到所有点都加入为止
算法正确性证明:
主要利用性质3)和反证法(假设任意最小生成树都不包含某条边)。如果存在权值更小的边且未被加入生成树,可以连接该条边,再从圈中选择一条边删去(必然存在连接 S 1 S_1 S1和 S 2 S_2 S2的边,否则不可能是连通树)得到的还是生成树。
证明方法:添加权值最小的边,再从圈中删除。
时间复杂度分析
需要添加n个结点,每次寻找最短边的时间复杂度为 O ( n ) O(n) O(n) 。实现方式:用距离数组d[i]记录 S 2 S_2 S2中的点到 S 1 S_1 S1的最短距离,只需要在每次添加结点时进行更新即可。更新的方法:只需要对V2中的点,检查到新添加到S1中的点的距离是否小于原来的值,若则进行更新,因此总的找最小和更新值的时间复杂度均为 O ( n ) O(n) O(n),因此时间复杂度为 O ( n 2 ) O(n^2) O(n2)。
优化:维护一个最小堆
先对m条边进行建堆,则时间复杂度为 O ( m ) O(m) O(m),然后按从小到大的顺序遍历所有边,直到找到第一条连接S和V-S的边。每次寻找最小边是 O ( 1 ) O(1) O(1),但删除元素(优先队列首元素出队)的时间复杂度是 O ( m ) O(m) O(m)的,因此总时间复杂度为 O ( m log m ) = O ( m log n ) O(m\log m) = O(m\log n) O(mlogm)=O(mlogn)
Kruskal算法
算法步骤:等价类(连通分支)
算法思想:考察当前最短边 e e e,若它与正在构建的最小生成树 T T T 不构成回路,则将该条边加入T中,否则不加入,继续检查下一条边。直到选择了n-1条边为止。
理解:构成回路 —— 相当于该条边连接的两个端点已经位于一个连通分支内(或者属于同一个等价类)
算法正确性证明
归纳证明:利用短接(边的收缩;类似于将huffman二叉树两个最小顶点合并)
先短接最短的边,对结点个数减少的情况利用归纳假设,然后恢复这条边。反证:前提是首先需证明存在一棵包含这条最短边(显然)
算法复杂度分析
预处理:按照边的权值大小进行排序,时间复杂度为 O ( m log m ) O(m\log m) O(mlogm)
关键步骤:如果判定两个顶点属于一个连通分支?
-
使用并查集维护和路径压缩算法
使用UNION函数和FIND函数。
时间复杂度:对于每条边,查找两个顶点是否已经连通,需要 O ( m α ( n ) ) O(m\alpha(n)) O(mα(n)) 的时间。其中 α \alpha α可认为是增长很缓慢、趋近于常数的因子;总时间复杂度瓶颈为排序,需要 O ( m log m ) = O ( m log n ) O(m\log m) = O(m\log n) O(mlogm)=O(mlogn)的时间。
-
也可采用标记数组FIND:用编号来标记当前顶点所在的分支,因此每次更新需要进行分支的合并(更改个数较少的分支编号。
可以证明更新FIND数组的总时间复杂度不超过 O ( n log n ) O(n\log n) O(nlogn)。因为每次合并集合之后,集合中的顶点个数至少增加一倍,因此合并次数不超过 log n \log n logn次。
由上述分析可知,总时间复杂度的瓶颈依然为排序过程。
比较:Prim算法和Kruskal算法
可以理解为prim算法围绕顶点出发,kruskal算法则是处理边的。
复杂度比较:考虑正常情形下的prim算法,时间复杂度 O ( n 2 ) O(n^2) O(n2);此时选择何种算法取决于图中边的疏密程度。若为稀疏图则选择Kruskal,反之选择Prim。
5. Dijkstra算法
目标问题:单源最短路径问题
使用条件:不存在负权边的情况
算法实现:贪心策略(每次选择V-S中距离起点最近(路径只能途径S中的点)的点,加入集合S中,并更新距离数组的值。
算法正确性证明
对集合S中的顶点个数做归纳法。
归纳基础:首先n=1时显然成立。
归纳步骤:证明对选入S的k个点,此时dist数组中存储的已经是起点到对应点的最短路径。采用反证法,若存在一条其他路径L更短,设该路径第一次离开S的边为<x,y>,其中x属于集合S。由于算法在这一步选择了顶点v,就说明dist[v] <= dist[y],可推出矛盾(从顶点y到v的顶点非负,此处利用了边的权值一定是非负的)
时间复杂度分析
每次遍历dist数组选择最小值,时间复杂度为 O ( n ) O(n) O(n);然后添加新的顶点后,只需遍历所有顶点,更新所有和新加入顶点相邻的顶点的距离值,时间复杂度也为 O ( n ) O(n) O(n)。因此总时间复杂度为 O ( n 2 ) O(n^2) O(n2)
比较:Floyd算法
-
动态规划,可以用来解决多源最短路径问题;可以解决负权边的问题(但不能解决负权回路)
拓展:Bellman-Ford算法
-
可以用来求解负权边问题
-
但注意如果图中存在负权环则无解
6. 覆盖问题
区间完全覆盖问题
问题:给定一个长度为m的区间([start, end]),再给出n条线段的起点s[i]和终点e[i](闭区间),求最少使用多少条线段可以将整个区间完全覆盖?
预处理:对区间左端点进行排序。
局部最优:从左端点不大于目标区间左端点的所有区间内,选择右端点最大的(尽可能多覆盖)
区间选点问题
问题描述:给定n个闭区间的起点s[i]和终点e[i](均为整数),以及n个正整数 c 1 , c 2 … , c n c_1,c_2…,c_n c1,c2…,cn。求元素最少的整数集Z,使得第i个区间中至少有 c i c_i ci个的整数点落在Z内。(保证 c i c_i ci <= e[i]-s[i]+1)
预处理:对区间右端点进行排序。(why ?)
局部最优:对当前右端点最小的区间而言,若还需选择t个点进行覆盖,则在扫描该区间时必须全部选择(否则更靠后就无法覆盖了)
算法正确性证明
1.归纳基础:证明存在一种最优解包含区间1的最右边 c 1 c_1 c1个点
2.归纳步骤:对算法执行的步数k进行归纳(仿造活动选择问题)
集合覆盖问题
问题描述:假设存在下面需要付费的广播台,以及广播台信号可以覆盖的地区。 如何选择最少的广播台,让所有的地区都可以接收到信号?
更一般化的表述:
- 属于NP-难问题,但可以使用贪心算法求近似解。
贪心算法的直接思想:每次选择未被覆盖元素最多(可理解为平均代价最小)的集合
拓展:估计贪心算法的解的误差
可证明贪心算法所得解 g 和真正最优解 c 满足:
g
=
O
(
c
log
n
)
,
n
=
∣
X
∣
g = O(c\log n), n = |X|
g=O(clogn),n=∣X∣
贪心算法总结
适用条件:求解优化问题
思路:自顶向下,只顾眼前的”局部最优“(需要对算法正确性进行
时间复杂度:算法本身往往较简单,主要时间复杂度取决于预处理操作(如排序算法)