基本思想
生活中有很多使用贪心思想的例子,比如找零钱,如果售货员需要找给小朋友67美分的零钱,售货员手中只有25美分、10美分、5美分和1美分的硬币,她的做法是:先找不大于67的25美分2个,再找不大于17的10美分1个,再找不大于7的5美分1个,最后找2个1美分,最后,找给小朋友6枚硬币。我们不难发现售货员的目标是硬币个数尽可能少。
再看一个经典的背包问题,假设有\(n\)个物品,他们的体积相同,重量分别为\(w_1, w_2, ..., w_n\),背包的最大载重量是\(c\),目标是往背包里装入尽可能多的物品,一个自然的想法是“先装重量最轻的物品”。如果用数学语言描述这个问题是这样的:
其中\(x_i\)表示是否选择第\(i\)件物品,1时选择,0时不选择。
从中我们不难发现贪心算法的基本思想。贪心算法,是在决策中总是做出在当前看来是最好的选择。但是我们必须注意的是,局部最优并不等同于全局最优,所以贪心算法并不一定总能得到正确的解。但是必须指出的是,有相当一部分问题是可以由贪心原则达到整体最优的。
背包问题
本节中提到的背包问题特指可分割的背包问题。
已知容量为\(M\)的背包和\(n\)件物品。第\(i\)件物品的重量为\(w_i\),价值是\(p_i\)。因而将物品\(i\)的一部分\(x_i\)放进背包即获得 \(p_ix_i\)的价值。问题是:怎样装包使所获得的价值最大?即是如下的优化问题:
因为物品可分割,所以我们优先装单价最高的物品,这样就可以得到这个优化问题的最优解。这里必须指出的是,我们并没有证明局部最优就可以达到全局最优,事实上,这个问题是可以通过严谨的数学语言证明的,由于篇幅所限,这里不做介绍。
贪心算法主要用于处理优化问题。每个优化问题都是由目标函数和约束条件组成。满足约束条件的解称为可行解,而那些使得目标函数取得最大(最小)值的可行解称为最优解。如背包问题是一个优化问题,\(\sum\limits_{i = 1}^n {{p_i}{x_i}}\)是目标函数,而\(\sum\limits_{i = 1}^n {{w_i}{x_i} \le M,{x_i} = [0,1]}\)描述的要求是约束条件,这里优化是使目标函数取最大值。
贪心算法在每一步的决策中虽然没有完全顾及到问题整体最优,但在局部择优中是朝着整体最优的方向发展的。为此,贪心算法首先要确定一个度量准则(称为贪心准则),每一步都是按这个准则选取优化方案。如(可分割)背包问题的贪心准则是选取单位价值最大物品;找零钱问题所用的贪心准则是选取面值最大的硬币。对于一个给定的问题,初看起来,往往有若干种贪心准则可选,但在实际上,其中的多数都不能使贪心算法达到问题的最优解。
所以我们不难发现,贪心算法的核心就在于设计一个产生最优解的贪心准则。
下面给出贪心算法的伪代码:
Greedy(A)
{
ans = {};
for i to A.size()
{
x = Select(A);
if Feasible(ans, x)
{
ans = Union(ans, x);
}
}
return ans;
}
- Select(A):实现贪心准则,并按照贪心准则从输入\(A\)中选择当前的元素。
- Feasible(ans, x):判断已知解的部分\(ans\)与新选取的\(x\)的结合是否是可行的。
- Union(ans, x):如果可行,在可行解中加入\(x\)。
所以,贪心问题的求解就转化成对这几个函数的实现。
调度问题
活动安排问题
问题描述:已知\(n\)个活动\(E={1, 2, … ,n}\),要求使用同一资源,第\(k\)个活动要求的开始和结束时间为\(s_k, f_k\), 其中 \(s_k
这个问题的贪心准则是在未安排的活动中选择结束时间最早的活动安排,这样可以留出更多的时间安排后面的活动。同样要注意的是,我们并没有证明这个贪心准则获得的可行解就是最优解,尽管这是对的。
通过以上的分析,不难写出代码:
void quicksort(vector& s, vector& f, int start, int end)
{
if(start > end) return;
int i = start, j = end + 1;
int pivot = f[start];
while(true)
{
while(f[++i] < pivot && i < end);
while(f[--j] > pivot && j > start);
if(i < j)
{
swap(s[i], s[j]);
swap(f[i], f[j]);
}
else break;
}
swap(s[j], s[start]);
swap(f[j], f[start]);
quicksort(s, f, start, j-1);
quicksort(s, f, j + 1, end);
}
vector> greedyAction(vector& s, vector& f)
{
int n = s.size();
// 顺便复习一下快排hhh
quicksort(s, f, 0, n-1);
int j = 0;
vector> res = {{s[0], f[0]}};
for(int i = 1 ; i < n ; ++i)
{
if(s[i] >= f[j])
{
res.push_back({s[i], f[i]});
j = i;
}
}
return res;
}
带期限的单机调度问题
为使问题简化,我们假定完成每项作业所用的时间都是一样的,如都是 1。
问题陈述: 已知\(n\)项作业\(E = { 1 , 2 , … ,n}\),要求使用同台机器完成(该台机器在同一时刻至多进行一个作业),而且每项作业需要的时间都是1。第\(k\)项作业要求在时刻\(f_k\)之前完成,而且完成这项作业将获得效益\(p_k, k=1, 2, … , n\)。如果其中的作业可以被安排由一台机器完成,作业集\(E\)的子集称为相容的。带限期单机作业安排问题就是要在所给的作业集合中选出总效益值最大的相容子集。
首先考虑贪心准则,容易想到尽量选取效益值大的作业安排,所以算法前提是任务按照价值由高到低的顺序排列。之后考虑如何判断是否是可行解,用两个数组\(f\)和\(p\)分别存放作业的期限值和效益值,并使得数组\(p\)中元素按照不增的顺序排列。那么根据相容性要求,可以得出:对于作业\(i\),只要目前可行解\(J\)中期限值不大于\(f_i\)的作业少于\(f_i\)个,作业\(i\)就可以加入可行解。看起来不错,是吗?但是这个判断方法还不全面,看下面的例子:
作业的期限数组\(f=[4,2,4,3,4,7,3]\)
显然最优解是\(J=[0,1,2,3,5]\),但是如果应用上面的可行解分析,得出的结果是\(J'=[0,1,2,3,5,6]\),手推一下发现,判断最后一个任务时,可行解中期限值小于等于3的任务有两个,小于3,符合可行解条件,但是前4个时间片已经被任务\([0,1,2,3]\)占满。
那么怎样判断可行解呢?很简单,我们只要给所有任务分配时间片,只要当前任务期限值之前有空闲的时间片,那么就可以扩充可行解。进一步,怎样确定一个分配规则呢?
我们可以将\(J\)看做一个调度时间表,其中\(J[i]\)表示第\(i\)个时间片分配的活动编号,我们可以使用插入排序的思路,把任务\(i\)插入相应的位置,只需要\({f[i] < f[J[r]]} \&\& f[J[r]] != r\), for r from J.size() to 0 by -1
即可找到插入位置\(r\),随后把r之后的元素后移即可插入作业\(i\)。
那么这个贪心准则是否可以保证得到的可行解就是最优解呢?答案是肯定的,理论上我们需要证明,一个简略的思路是使用反证法,假设得到的可行解\(J\)不是最优解,那么一定存在一个解\(I\),使得效益最大。往证\(I=J\)。具体的证明不做详细介绍。
根据以上的分析写出代码(这里时间片从1开始):
vector greedyJob(vector& f)
{
vector J{-1, 0};
int cnt = 1;
for(int i = 1 ; i < f.size() ; ++i)
{
int r = cnt;
while(r > 0 && f[J[r]] > f[i] && f[J[r]] != r)
--r;
if(r == 0 || (f[J[r]] <= f[i] && f[i] > r))
{
J.push_back(-1);
for(int j = cnt ; j > r ; --j)
J[j+1] = J[j];
J[r+1] = i;
++cnt;
}
}
return J;
}
分配的步骤如下所示:
time: 1
activity: 0
threshold: 4
time: 1 2
activity: 1 0
threshold: 2 4
time: 1 2 3
activity: 1 0 2
threshold: 2 4 4
time: 1 2 3 4
activity: 1 3 0 2
threshold: 2 3 4 4
time: 1 2 3 4
activity: 1 3 0 2
threshold: 2 3 4 4
time: 1 2 3 4 5
activity: 1 3 0 2 5
threshold: 2 3 4 4 7
可见在最坏情况下,时间复杂度为\(O(n^2))\)。我们发现,很大一部分的开销在频繁的移动元素上,为了避免频繁的移动数据,我们的分配规则可以改为尽可能的把某任务向接近其期限值的时间片上安排。根据以上分析可以修改为:
vector greedyJob(vector& f)
{
int n = f.size();
vector J(n + 1, -1);
J[1] = 0;
for(int i = 1 ; i < f.size() ; ++i)
{
print(J, f);
for(int j = min(n, f[i]) ; j > 0 ; --j)
{
if(J[j] == -1)
{
J[j] = i;
break;
}
}
}
return J;
}
分配的步骤如下所示:
time: 1
activity: 0
threshold: 4
time: 1 2
activity: 0 1
threshold: 4 2
time: 1 2 4
activity: 0 1 2
threshold: 4 2 4
time: 1 2 3 4
activity: 0 1 3 2
threshold: 4 2 3 4
time: 1 2 3 4
activity: 0 1 3 2
threshold: 4 2 3 4
time: 1 2 3 4 7
activity: 0 1 3 2 5
threshold: 4 2 3 4 7
虽然减少了元素的移动,但是时间复杂度并没有改变,最坏情况下的时间复杂度依旧是\(O(n^2)\)。
理论上,我们可以通过并查集优化算法的复杂度,不妨叫做快速带期限单机调度算法,可使时间复杂度达到\(O(m*\alpha(n))\),其中\(m\)表示执行\(find\)和\(union\)的次数,\(\alpha(n)\)与修订的\(Ackerman\)函数有关,\(\alpha(n) = min\{k \in N | A_k(1) \ge n \}\)。并查集的解答待续(可能鸽了hhh)。
多机调度问题
看到现在,好像给人一种“贪心准则可以达到最优解”的错觉,而多机调度问题,就是一个贪心策略无法达到最优解的问题。
问题描述:设有\(n\)项独立的作业\(\{1,2,…, n\}\),由\(m\)台相同的机器加工处理。作业\(i\)所需要的处理时间为\(t_i\)。约定:任何一项作业可在任何一台机器上处理,但未完工前不准中断处理;任何作业不能拆分更小的子作业分段处理。多机调度问题要求给出一种调度方案,使所给的\(n\)个作业在尽可能短的时间内由\(m\)台机器处理完。
这是一个\(NP\)完全问题,到目前为止还没有一个有效的解法。利用贪心策略,有时可以设计出较好的近似解。可以采用贪心准则:需要长时间处理的作业优先处理。而调度策略则选择:将需要时间最长的未被安排作业首先安排给能够最早空闲下来的机器处理。
上述的贪心算法叫做\(LPT\)算法,而这个算法与最优解之间有一些非常漂亮的结论,有兴趣的朋友可以阅读下这篇文章[1]。
(20200727待续)
最优生成树问题(Prim, Kruskal)
单源最短路径问题(Dijkstra)
Huffman编码
贪心算法最优性理论
参考文献
[1] Graham, R.. (1969). Bounds on Multiprocessing Timing Anomalies. Siam Journal on Applied Mathematics - SIAMAM. 17. 10.1137/0117039.