算法复习——贪心策略篇之活动选择问题
以下内容主要参考中国大学MOOC《算法设计与分析》,墙裂推荐希望入门算法的童鞋学习!
1. 问题背景
假设有一个会场,可以举行公司年会、婚礼宴请、生日聚会和学术研讨等活动,但是不同活动举办的时间不同,为了提高会场的利用率,我们希望会场进行多种不同的活动,活动数越多,会场的利用率越高。如果把活动的时间跨度用图表现出来,即如下图所示。
由于选择出租的活动时间不能冲突,因此我们的问题就是怎样选择才能选更多的活动?
2. 问题定义
活动选择问题(Activity Selection Problem)
输入:
- n个活动组成的集合S = {a1, a2, …, an}
- 每个活动ai的开始时间si和结束时间fi
输出:
-
找出活动集合S的子集S’,令
m a x ∣ S ′ ∣ max|S^{'}| max∣S′∣s . t . ∀ a i , a j , s i ≥ f j 或 s j ≥ f i s.t. \forall a_i, a_j, s_i \geq f_j或s_j \geq f_i s.t.∀ai,aj,si≥fj或sj≥fi
上述两式分别为优化目标(最大化选择活动个数)和约束条件。
3. 贪心策略初窥与正确性证明
在使用贪心时,我们可以先直观地想出看似合理的贪心策略,再依次地去证明或证伪这些策略是不是全局最优。只要能举出反例的贪心策略,就一定不是全局最优的策略。
3.1 最短活动优先
3.2 最早开始活动优先
3.3 最早结束活动优先
选择最早结束的活动,可以给后面的活动留更大的选择空间,似乎一下子举不出反例,那就来尝试证明一下这个策略是否正确。
如果大家学习过算法复习——贪心策略篇之部分背包问题和算法复习——贪心策略篇之霍夫曼编码,就知道我们通常证明贪心策略正确性的方法就是替换法,即假设存在一个最优解,然后依次将最优解替换成贪心解,并证明贪心解不劣于最优解,那么贪心解也是最优解。本题同理。
假设最优解(蓝色)如左图所示,贪心解(红色)如右图所示。
假设一开始最优解与贪心解选择的活动相同,则无需相同。但是它们一定会在某个活动的选择上出现不同(不然贪心解不就和最优解一样了么qwq,就不用证了嘤嘤嘤),如下图所示。
那么,我们就可以把最优解选择的活动替换成贪心解选择的活动,因为贪心解每次选择的都是当前最早结束的活动,因此最优解选择的活动的结束时间一定晚于贪心解选择的活动的结束时间,所以如果把最优解选择的活动替换成贪心解选择的活动,一定不会影响最优解后面选择的活动。
上面这段可能有点绕,其实是很简单的道理(原谅我的语文水平),总之,原则就是活动相同,无需替换;活动不同,必能替换。
4. 伪代码
输入:活动集合S = {a1, a2, …, an},每个活动ai的起止时间si, fi
输出:不冲突活动的最大子集S’
把活动按照结束时间升序排序
S' <- {a1}
k <- 1
for i <- 2 to n do
if s[i] >= f[k] then
S' <- S' | {ai} // 并集
k <- i
end
end
return S'
时间复杂度也很好分析,整体的时间复杂度就是O(n log n)。
5. 问题变形
5.1 问题背景
在原问题背景的基础下,假如我们的目标不是让会场中活动越多越好,而是通过出租场地获得的收益越高越好。如果依然要求选择出租的活动时间不能冲突,活动出租收益各不相同,怎样选才能使收益总和最高呢?下面给出这个问题的形式化定义。
5.2 问题定义
带权活动选择问题(Weighted Activity Selection Problem)
输入:
- n个活动组成的集合S = {a1, a2, …, an}
- 每个活动ai的开始时间si,结束时间fi和权重wi
输出:
-
找出活动集合S的子集S’,令
m a x ∑ a i ∈ S ′ w i max\sum_{a_{i} \in S'}w_i maxai∈S′∑wis . t . ∀ a i , a j , s i ≥ f j 或 s j ≥ f i s.t. \forall a_i,a_j,s_i \geq f_j或s_j \geq f_i s.t.∀ai,aj,si≥fj或sj≥fi
5.3 确定算法
如果了解动态规划的同学,很容易发现在带权后,原问题从贪心策略问题变成了动态规划问题。动态规划算法则要求必须存在最优子结构和重叠子问题。
我们在使用贪心策略的时候会发现,也存在最优子结构,因为贪心的正确性一定是满足最优子结构的,贪心每次都选择局部最优解,但最终仍然能找到全局最优解,而局部最优解其实就是子结构的最优解。对于贪心策略的问题,只有不断地选择最优子结构,最终能达到全局最优解的贪心策略才是正确的。
因此,贪心策略与动态规划最大的区别点是重叠子问题,而这个问题的变形恰恰具有重叠子问题的性质。
如图所示,无论是选择a6还是选择a5,接下去都要去解决如何从a1,a2,a3
中选择最优解的问题,这是个重叠子问题。所以,由于存在重叠子问题的特性,我们要用动态规划算法去解决这个问题。
5.4 动态规划
第一步是问题结构分析。在这个问题里,我们要对数据进行预处理,即按活动结束时间升序对活动进行排序,其目的是为了规避活动冲突的问题。我们需要用一个数组p[i]来维护在活动ai开始前最后结束的活动,例如在上图中,p[5]=3。由于我们已经根据结束时间排好顺序了,我们就可以使用二分查找去寻找活动ai的开始时间在排好顺序的活动结束时间数组中的位置。然后,可以给出问题表示D[i],它表示在集合{a1, a2, a3, …, ai}中不冲突活动最大权重和;原始问题则是D[n],它表示在集合{a1, a2, a3, …, an}中不冲突活动最大权重和。
第二步是递推关系建立(分析最优子结构)。其实这个问题可以类比0-1背包问题,也就是当我们考察活动ai时,我们要不要选择它。如果选择ai,则如下图所示。
如果不选择ai,则如下图所示。
因此,我们可以很容易地得到递推公式:
D
[
i
]
=
m
a
x
{
D
[
p
[
i
]
]
+
w
i
,
D
[
i
−
1
]
}
.
D[i] = max\{D[p[i]]+w_i, D[i-1]\}.
D[i]=max{D[p[i]]+wi,D[i−1]}.
这样的话,就具有了最优子结构的特点,即D[i]是个最优值,它依赖于D[p[i]]和D[i-1],这两个都是它的子问题,但都是子问题的最优值,这就是最优子结构的特点。
第三步是自底向上计算(确定计算顺序)。首先初始化,D[0]=0,因为空活动集最大权重和为0;然后基于递推公式,我们已知所有活动的权重,就可以计算出D[1…n]。
第四步是最优方案追踪。我们通过rec数组来记录决策过程,即当选择活动ai时,rec[i]=1;当不选活动ai时,rec[i]=0。然后就可以输出最优方案了,当rec[i]=1时,选择活动ai,考察子问题D[p[i]];当rec[i]=0时,不选活动ai,考察子问题D[i-1]。
5.5 伪代码
输入:活动集合S = {a1, a2, …, an},每个活动ai的起止时间si,fi和权重wi;
输出:不冲突活动的最大子集S‘
// 预处理
把活动按照时间升序排序
for i <- 1 to n do
二分查找求解p[i]
end
// 初始化
新建数组D[0..n], Rec[0..n]
D[0] <- 0
// 动态规划
for j <- 1 to n do
if D[p[j]] + w[j] > D[j-1] then
D[j] <- D[p[j]] + w[j]
Rec[j] <- 1
end
else
D[j] <- D[j-1]
Rec[j] <- 0
end
end
// 输出方案
k <- n
while k > 0 do
if Rec[k] = 1 then
print 选择a[k]
k <- p[k]
end
else
k <- k - 1
end
end
return D[n]
时间复杂度分析:
排序的时间复杂度是O(n log n),维护p数组的时间复杂度是O(n log n),动态规划维护备忘录的时间复杂度是O(n),输出方案的时间复杂度是O(n),所以整体算法的时间复杂度是O(n log n)。
5.6 动态规划与贪心策略比较
动态规划考察全局,求解子问题,组合最优解;
贪心策略考察局部,直接做决策,构造最优解。