活动选择问题(贪心算法vs动态规划)
1.基础知识
在讲解活动选择问题之前,我们首先来介绍一动态规划
和贪心算法
的基础知识
1-1.动态规划
动态规划是用来求解多阶段决策过程
最优化问题的一种方法。多阶段决策过程本意是指有这样一类活动,他们可以按照时间顺序分解为若干个互相联系的阶段,称为时段
。每一个时段都要做出一个决策,使得整个活动的总体效果最优。
由上述可知,动态规划方法与时间
关系很密切,随着时间过程的发展而决定各阶段的决策,产生一决策序列,这就是动态
的意思。
动态规划座右铭
各阶段有机联系,互相影响,最终影响全局,达到最优。
1-2.贪心算法
贪心算法不同于动态规划,贪心算法只考虑本阶段需要作出的最优选择,一般不用从整体最优上进行考虑,所做到的就是局部意义
上的最优解,再由局部最优解得到全局最优解。
因为每一步都只考虑对自己最优的情况,而忽略整体情况,故称之为贪心
。
贪心算法的座右铭
每一步都尽量做到最优,最终结果就算不是最优,那么也是次最优。
1-3.贪心算法vs动态规划
贪心算法与动态规划都是用来求解最优化问题的,他们之间有什么相似与相异的性质呢?
动态规划两大性质:
- 最优子结构
- 重叠子问题
贪心算法两大性质:
- 最优子结构
- 贪心选择
从上面的描述中知道,动态规划和贪心算法所能解决的是具有最优子结构性质的问题,这一点很重要,至于他们的差别,我们会在后续文章中继续讨论!
2.活动选择问题描述
假设我们存在这样一个活动集合 S={a1,a2,a3,a4,...,an} ,其中每一个活动 ai 都有一个开始时间 si 和结束时间 fi保证(0≤si<fi) ,活动 ai 进行时,那么它占用的时间为 [si,fi) .现在这些活动占用一个共同的资源,就是这些活动会在某一时间段里面进行安排,如果两个活动 ai 和 aj 的占用时间 [si,fi),[sj,fj) 不重叠,那么就说明这两个活动是兼容的,也就是说当 si<=fj 或者 sj<=fi ,那么活动 ai,aj 是兼容的。
比如下面的活动集合 S :
我们假定在这个活动集合里面,都是按照 fi 进行升序排序的
即: 0≤f1≤f2≤f3≤...≤fn
isifi11423530645753965976108811981210214111216
从上面可见,我们观察可得兼容子集有:
{a3,a9,a11} ,但是这个并不是最大兼容子集,因为
{a1,a4,a8,a11} 也是这个活动的最大兼容子集,于是我们将
活动选择问题描述为:给定一个集合
S={a1,a2,a3,...an} ,在相同的资源下,求出最大兼容活动的个数。
3.活动选择问题最优子结构
在开始分析之前,我们首先定义几种写法
– Sij 表明是在 ai 之后 aj 之前的活动集合
– Aij 表明是在 ai 之后 aj 之前的最大兼容子集的集合
即如下图所示
a1,a2,...,ai,ai+1,...,aj−1Sij+aj,...,an−1,anS
我们假设在有活动集合 Sij 且其最大兼容子集为 Aij , Aij 之中包含活动 ak ,因为 ak 是在最大兼容子集里面,于是我们得到两个子问题集合 Sik 和 Skj 。令 Aik=Aij∩Sik 和 Akj=Aij∩Skj ,这样 Aik 就包含了 ak 之前的活动的最大兼容子集, Akj 就包含了 ak 之后的最大活动兼容子集。
因此我们有 Aij=Aik∪{ak}∪Akj
Sij 里面的最大活动兼容子集个数为 |Aij|=|Aik|+|Akj|+1
这里我们发现与之前讲过的动态规划有点类似,我们可以得到动态规划的递归式子:
c[i,j]=c[i,k]+c[k,j]+1
如果我们不知道
ak 的具体位置,那么我们需要便利
ai 到
aj 的所有位置来找到最大的兼容子集
c[i,j]={0max{c[i,k]+c[k,j]+1}(i≤k≤j)i=j−1i>=j
这里我们首先分析一下动态规划的代价,我们这里子问题数量为
O(n2) ,每一个子问题有
O(n) 种选择,于是动态规划的时间代价为
O(n3) 。
上面的分析中,我们得到了两个对我们求解问题非常有帮助的东西:最优子结构
和递归式
。
4.活动选择问题算法设计
下面我们将采用几种方法来设计算法,主要是
4-1.贪心算法之选择最早结束活动
对于活动选择问题来说,什么是贪心选择呢?那就是选取一个活动,使得去掉这个活动以后,剩下来的资源最多。那么这里怎么选择才能使得剩下来的资源最多呢?我们这里共享的资源是什么?就是大家共有的哪一个时间段呀,我们首先想到肯定是占用时间最短的呀,即 fi−si 最小的哪一个。还有另外一种就是选择最早结束的活动,即 fi 最小的哪一个,其实这两种贪心选择的策略都是可行的,我们这里选择第二种来进行讲解,第一种我们只给出实现代码。
因为我们给出的集合 S 里面的活动都是按照 fi 进行升序排序的,这里我们就首先选出 ak 作为最先结束的活动,那么我们只需要考虑 ak 之后的集合即可。我们之前只是假设每次都选出子问题的最早结束的活动加入到最优解里面,但是这样做真的是正确的么?下面我们来证明一下:
证明:
令 Ak 是 Sk 的一个最大兼容子集, aj 是 Ak 里面最早结束的活动,于是我们将 aj 从 Ak 里面去掉得到 Ak−1 , Ak−1 也是一个兼容子集。我们假设 ai 为 Sk 里面最早结束的活动,那么有 fi≤fj ,将活动 ai 张贴到 Ak−1 里面去,得到一个新的兼容兼容子集 Ak1 ,我们知道 |Ak|==|Ak1| ,于是 Ak1 也是 Sk 的一个最大兼容子集!
4-1-1.递归贪心算法
上面我们已经知道了贪心选择是什么,现在我们来看看怎么实现,我们首先选出最早结束的活动 ai ,那么之后最早结束活动一定是不和 ai 相交的,于是从 i 开始,一直找 si<fm 的那个活动,如果找到,就将活动加入到解里面,以此类推的寻找,下面我们采用递归和迭代的两种方式来实现代码:
递归方式
#include <iostream>
#include <utility>
#include <vector>
#include <string>
#include <cstdio>
#include <cstdlib>
#include <cstring>
using namespace std;
#define BufSize 20
// 用来存储解决方案
char buf[BufSize];
std::vector<string> solution;
size_t dealGreatActivitySelector(std::vector<pair<int , int> > & activities , int left , int right);
size_t greateActivitySelector(std::vector<pair<int , int> > & activities)
{
if(activities.size() == 0)
return 0;
return dealGreatActivitySelector(activities , 1 , activities.size()-1);
}
size_t dealGreatActivitySelector(std::vector<pair<int , int> > & activities , int left , int right)
{
if(left > right)
return 0;
// 找到第一个边界,使得与activies[left]兼容
int newLeft = left;
while(newLeft <= right && activities[left].second > activities[newLeft].first)
newLeft++;
snprintf(buf , BufSize , "a%d" , left);
solution.push_back(string(buf , buf+BufSize));
memset(buf , BufSize , 0);
return dealGreatActivitySelector(activities , newLeft , right)+1;
}
void printSolution()
{
for (std::vector<string>::iterator i = solution.begin(); i != solution.end(); ++i)
{
cout<<*i<<"\t";
}
cout<<endl;
}
int main(int argc, char const *argv[])
{
std::vector<pair<int , int> > activities;
activities.push_back(pair<int , int>(0,0));
activities.push_back(pair<int , int>(1,4));
activities.push_back(pair<int , int>(3,5));
activities.push_back(pair<int , int>(0,6));
activities.push_back(pair<int , int>(5,7));
activities.push_back(pair<int , int>(3,9));
activities.push_back(pair<int , int>(5,9));
activities.push_back(pair<int , int>(6,10));
activities.push_back(pair<int , int>(8,11));
activities.push_back(pair<int , int>(8,12));
activities.push_back(pair<int , int>(2,14));
activities.push_back(pair<int , int>(12,16));
cout<<"The max selectors is : "<<greateActivitySelector(activities)<<endl;
printSolution();
return 0;
}
运行结果为:
The max selectors is 4
a1 a4 a8 a11
4-1-2.迭代的方式进行
因为有大部分的代码是重复的,所以下面我们只贴出关键代码
/** many code */
size_t dealGreatActivitySelector(std::vector<pair<int , int> > & activities , int left , int right)
{
if(left > right)
return 0;
int count = 1;
snprintf(buf , BufSize , "a%d" , left);
solution.push_back(string(buf , buf+BufSize));
memset(buf , BufSize , 0);
int lastPos=left;
for (int i = left+1; i <= right; ++i)
{
// 不断的寻找边界
while(i<= right && activities[i].first < activities[lastPos].second)
++i;
if(i > right)
break;
//找到就加入到solution里面
snprintf(buf , BufSize , "a%d" , i);
solution.push_back(string(buf , buf+BufSize));
memset(buf , BufSize , 0);
lastPos = i;
count++;
}
return count;
}
/** many code */
运行结果为:
The max selectors is 4
a1 a4 a8 a11
上面两个算法很好理解的,就是首先找到一个开始活动加入到解集里面,然后再向后继续寻找后面与当前选出的活动兼容的活动加入到集合中,直到遍历一边所有活动!
Created with Raphaël 2.1.2 代码逐步具体表现,a1,…,an结束时间已升序排序 a1 a1 a2 a2 a3 a3 a4 a4 a5 a5 a6 a6 a7 a7 a8 a8 a9 a9 a10 a10 a11 a11 add a1 f1(4)<=s2(3)? no, skip a2 f1(4) <= s3(0)? no , skip a3 f1(4) <= s4(5)? yes , add a4 add a4 f4(7) <= s5(3)? no , skip a5 f4(7) <= s6(5)? no, skip a6 f4(7) <= s7(6)? no, skip a7 f4(7) <= s8(8)? yes, add a8 add a8 f8(11)<=a9(8)? no , skip a9 f8(11) <= a10(2)? no , skip a10 f8(11) <= a11(12)? yes , add a11 add a11
于是最终选出活动 a1,a4,a8,a11
4-2.贪心算法之选择最短时长活动
递归方式进行
因为大多数代码类似,所以只列出关键代码
/** many code 8*/
size_t dealGreatActivitySelector(std::vector<pair<int , int> > & activities , int left , int right)
{
if(left > right)
return 0;
//首先找到消耗最小的那个
int minPos = left;
int min = 100;
for(int i = left ; i < right ; ++i)
{
if((activities[i].second-activities[i].first) < min)
{
min = activities[i].second-activities[i].first;
minPos = i;
}
}
snprintf(buf , BufSize , "a%d" , left);
solution.push_back(string(buf , buf+BufSize));
memset(buf , BufSize , 0);
int leftTemp = minPos;
int rightTemp = minPos;
/** 找到左边界 */
while(leftTemp >= left && activities[leftTemp].second > activities[minPos].first )
leftTemp--;
/** 找到右边界 */
while(rightTemp <= right && activities[rightTemp].first < activities[minPos].second)
rightTemp++;
return dealGreatActivitySelector(activities , left , leftTemp)+\
dealGreatActivitySelector(activities , rightTemp, right)+1;
}
运行结果:
The max selectors is 4
a1 a4 a8 a11
4-3.动态规划方法实现
我们之前分析过,如果要设计一个动态规划的算法,那么首先就要经历这几步:
– 首先做出一个选择,在这里我们选择活动 ak
– 假设活动 ak 是最优解的一个选择
– 子问题产生,在选择 ak 以后会产生两个子问题 Sij1 和 Si1j ,这两个子问题的最优解加上 ak 构成原问题的最优解
– 证明:如果 Sik−1 或者 Sk+1j 子问题的解不是最优解,那么将最优解替换进去将会得到一个比原问题最优解更优的解,矛盾!
所以我们这里首先选出 ak ,于是有这样的子结构
A[i,j]=A[i,j1]+A[i1,j]+{ak}j1是小于k,且与ak兼容的活动标号i1是大于k,且与ak兼容的活动标号
由于我们的
ak 不确定,所以我们需要选出能产生最优解的那个
ak 值于是我们得到递归表达式:
A[i,j]={1max{A[i,j1]+A[i1,j]+1},i=j,i>j
有了递归表达式,我们接下来就可以用代码实现啦。为了与动态规划有对比,我们这里采用两种动态规划的方式实现,一种自上而下,一种自下而上:
4-3-1.自上而下的实现
#include <iostream>
#include <utility>
#include <vector>
using namespace std;
/** 最大活动的数目 */
#define MAX_ACTIVITY_NUM 20
size_t dealGreatActivitySelector(std::vector<pair<int , int> > & activities , int left , int right);
size_t great[MAX_ACTIVITY_NUM][MAX_ACTIVITY_NUM];//用来存储i到j的最大子集数目
size_t solution[MAX_ACTIVITY_NUM][MAX_ACTIVITY_NUM];//用来存储选择
pair<int , int> border[MAX_ACTIVITY_NUM][MAX_ACTIVITY_NUM];//用来存储边界值
/** * 最大的兼容子集 * @param activities 活动的链表,已经按照结束时间的先后顺序拍好了 * @return 返回最大兼容的数量 */
size_t greateActivitySelector(std::vector<pair<int , int> > & activities)
{
if(activities.size() == 0)
return 0;
dealGreatActivitySelector(activities , 0 , activities.size()-1);
return great[0][activities.size()-1];
}
/** * 实际处理最大兼容子集的函数 * @param activities 活动 * @param left 左边界 * @param right 右边界 * @return left到right的最大兼容子集数 */
size_t dealGreatActivitySelector(std::vector<pair<int , int> > & activities , int left , int right)
{
if(left > right)
return 0;
// 只有一个活动
if(left == right)
{
great[left][right] = 1;
solution[left][right] = left;
return 1;
}
if(great[left][right] != 0)
return great[left][right];// 之前已经算过
//求解过程
int max = 0;
int pos = left;
pair<int , int> borderTemp;
for (int i = left; i <= right ; ++i)
{
////////////////////////////
//以i为基准,向两边找到不与i活动相交的集合 //
////////////////////////////
int leftTemp = i;
int rightTemp = i;
/** 找到左边界 */
while(leftTemp >= left && activities[leftTemp].second > activities[i].first )
leftTemp--;
/** 找到右边界 */
while(rightTemp <= right && activities[rightTemp].first < activities[i].second)
rightTemp++;
int temp = dealGreatActivitySelector(activities , left , leftTemp)+\
dealGreatActivitySelector(activities , rightTemp , right)+1;
if(temp > max)
{
max = temp;
pos = i ;
borderTemp = pair<int , int>(leftTemp , rightTemp);
}
}
solution[left][right] = pos;
border[left][right] = borderTemp;
great[left][right] = max;
return max;
}
void printSolution(int left , int right)
{
if(left > right)
return;
if(left == right)
{
cout<<"from "<<left<<" to "<<right<<" -----> "<<solution[left][right]<<endl;
return;
}
cout<<"from "<<left<<" to "<<right<<" -----> "<<solution[left][right]<<endl;
printSolution(left , border[left][right].first);
printSolution(border[left][right].second , right);
return;
}
int main(int argc, char const *argv[])
{
std::vector<pair<int , int> > activities;
activities.push_back(pair<int , int>(1,4));
activities.push_back(pair<int , int>(3,5));
activities.push_back(pair<int , int>(0,6));
activities.push_back(pair<int , int>(5,7));
activities.push_back(pair<int , int>(3,9));
activities.push_back(pair<int , int>(5,9));
activities.push_back(pair<int , int>(6,10));
activities.push_back(pair<int , int>(8,11));
activities.push_back(pair<int , int>(8,12));
activities.push_back(pair<int , int>(2,14));
activities.push_back(pair<int , int>(12,16));
cout<<"The max selectors is : "<<greateActivitySelector(activities)<<endl;
printSolution(0 , activities.size()-1);
return 0;
}
运行结果为:
The max selectors is : 4
from 0 to 10 —–> 0
from 3 to 10 —–> 3
from 7 to 10 —–> 7
from 10 to 10 —–> 10
也就是a1,a4,a8,a11
4-3-2.自下而上的实现
因为大部分代码相同,所以这里只展示部分代码
/** many code */
/** * 实际处理最大兼容子集的函数 * @param activities 活动 * @param left 左边界 * @param right 右边界 * @return left到right的最大兼容子集数 */
void dealGreatActivitySelector(std::vector<pair<int , int> > & activities , int left , int right)
{
// 只有一个活动,初始化
for (int i = left; i < right; ++i)
{
great[i][i-1] = 0;
}
for(int k = 0 ; k <= right-left ; ++k)
{
for (int i = left; i <=right ; ++i)
{
int max = 0;
int pos = i;
int leftBorder=i;
int rightBorder=i;
for(int j = i ; j <= i+k ; ++j)
{
// 首先需要计算左右边界
int leftTemp = j;
int rightTemp = j;
/** 找到左边界 */
while(leftTemp >= i && activities[leftTemp].second > activities[j].first )
leftTemp--;
/** 找到右边界 */
while(rightTemp <= i+k && activities[rightTemp].first < activities[j].second)
rightTemp++;
int temp = great[i][leftTemp]+great[rightTemp][i+k]+1;
if(max < temp)
{
max = temp;
pos = j;
leftBorder = leftTemp;
rightBorder = rightTemp;
}
}
solution[i][i+k] = pos;
border[i][i+k] = pair<int , int>(leftBorder , rightBorder);
great[i][i+k] = max;
}
}
}
运行结果为:
The max selectors is : 4
from 0 to 10 —–> 0
from 3 to 10 —–> 3
from 7 to 10 —–> 7
from 10 to 10 —–> 10注意:
上面的有两段代码需要特别注意/** 找到左边界 */ while(leftTemp >= left && activities[leftTemp].second > activities[i].first ) leftTemp--; /** 找到右边界 */ while(rightTemp <= right && activities[rightTemp].first < activities[i].second) rightTemp++;
这段代码就是为了找到 i1 与 j1 的
5.结论
通过上面的分析我们可知贪心算法也是要有最优子结构的,而且一旦决定了一种贪心选择,那么速度是远远快于动态规划的,难就难在怎么决定贪心选择,到底怎么选是贪心算法的难点