[4]贪心算法
贪心算法不从整体最优上考虑,而是在局部最优上做出选择。对于很多问题贪心法不能得到整体最优解,但对于某些特殊的问题,仍然可以得到整体最优解。使用贪心算法应满足这些性质:
①最优子结构性质:一个问题的最优解包含的子问题也是最优的。
②贪心选择性质:整体最优解可以通过一系列局部最优的选择,即贪心选择来达到。
活动安排问题
n个活动的集合E,都需要使用同一资源,活动i有开始时间si和结束时间fi,如果两个活动时间不冲突,那么就是相容的,目标是选择出一些活动,使它们是集合中最多的相容活动。
贪心算法解决这个问题的方式是,先把所有活动按结束时间非减序排序:
从这个序列中选择第一个活动,然后向后循环,发现和最近选择的一个活动相容的活动就立即选择。该算法在排序后只需要O(n)的时间。
活动安排问题的算法很容易,但应证明用这种算法得到的解是整体最优的。
证明该问题的贪心选择性质
先证明有一个最优解以贪心选择开始,在这个问题中也就是去证明存在最优解以完成时间非减序排列后的问题1为开始。假设有一个最优解,以这个序列中的活动k开始而不是活动1,那么因为活动k的完成时间不小于活动1的完成时间,所以这个活动k一定可以用活动1来代替(得到的解是同等优的),因此一定有一个最优解以贪心选择开始。
在第一个活动通过贪心选择得到了以后,原问题缩减成了规模更小的子问题——从f[1]时间开始剩下的问题的活动安排问题。这时做贪心选择,选择的将是与活动1相容的问题中完成时间最早的的一个,记为m。假设有一个最优解,以活动r开始而不是m,因为m已经是最早的了,所以r一定大于m,因此r的完成时间不小于活动m的完成时间,所以这个活动r一定可以用活动m来代替(得到的解是同等优的)。
综上,证明了贪心选择性质。结合该问题的最优子结构性质,知在该问题中贪心算法得到的解一定是最优解。
最优装载问题
和0-1背包问题不同,最优装载问题的目标是装入更多的物体,不涉及价值一说:
最优装载问题的贪心策略是重量最轻者先装入。
证明该问题的贪心选择性质
先证明有一个最优解以贪心选择开始,即证明按重量排列后存在最优解选择了重量最轻的1号物品。假设有一个最优解,在这个序列中从左往右去看选择的第一个物品是k号而不是1号,那么因为k>1,k号物品的重量一定不小于1号物品,所以k号物品可以用1号物品代替,因此一定有一个最优解以贪心选择开始。
而对于接下来要选择的物品,也是一样,如果没有选择2号物品,那么因为1号被选过了,那么选过的物品一定可以用2号物品代替,得到的解是同等优的,所以满足贪心选择性质。
哈夫曼编码问题
哈夫曼编码问题的贪心选择策略是每次选权重最短的两个子树合并成一棵树。
#include<iostream>
#include<cstdio>
#include<queue>
#include<string>
using namespace std;
class Node{
public:
int j;//在数组中的标号
int weight;//重量
int parent;//双亲编号
int L;//左孩子
int R;//右孩子
//构造函数
Node()
:weight(0),parent(-1),L(-1),R(-1){}
//构造函数
Node(int w,int p,int l,int r)
:weight(w),parent(p),L(l),R(r){}
//覆写<运算符
friend bool operator<(const Node &n1, const Node &n2);
//覆写=运算符(赋值)
Node& operator=(const Node &r);
};
//覆写<运算符
bool operator<(const Node &n1, const Node &n2)
{
//反向覆写,以把后面的最大优先队列变成最小堆
if(n1.weight>n2.weight)
return 1;
else if(n1.weight==n2.weight)//相同时用j来判断谁是先输入的
if(n1.j<n2.j)
return 1;
else
return 0;
else
return 0;
}
//覆写=运算符(赋值)
Node& Node::operator=(const Node &r)
{
this->j=r.j;
this->weight=r.weight;
this->parent=r.parent;
this->L=r.L;
this->R=r.R;
return *this;
}
int count;//Case计数
int n;//测试数目
int t;//编码字符的数目
Node *a;//保存这棵树的数组
Node k1,k2;//两个临时用的Node
int *b;//用来存编码串的数组
int m;//编码数组的游标
int main()
{
scanf("%d",&n);
while(n--)
{
scanf("%d",&t);//读入一个数字t
a=new Node[2*t-1];//开辟存放树的空间
for(int i=0;i<2*t-1;i++)//把每个节点在数组中的标号存进去
a[i].j=i;
priority_queue<Node> q;//存放Node节点的优先级队列模仿最小堆
for(int i=0;i<t;i++)
{
scanf("%d",&a[i].weight);//读入每个字符的出现次数(权重)
q.push(a[i]);//push到最小堆里去
}
for(int i=t;i<2*t-1;i++)//对于后面的每个节点
{
k1=q.top();//取出一个给k1
q.pop();
k2=q.top();//再取出一个给k2
q.pop();
a[i].R=k1.j;//在数组中记录:右儿子是最小的k1
a[i].L=k2.j;//在数组中记录:左儿子是次小的k2
a[i].weight=k1.weight+k2.weight;//这个节点的权=两个儿子加起来
a[k1.j].parent=a[k2.j].parent=i;//在数组中记录:双亲变化
//printf("%d ",a[i].weight);
q.push(a[i]);//合成后的新节点入堆
}
/*
for(int i=0;i<2*t-1;i++)
{
printf("[%d]weight:%d,parent:%d,L:%d,R:%d\n",a[i].j,a[i].weight,a[i].parent,a[i].L,a[i].R);
}
*/
printf("Case %d\n",++count);//先输出Case计数
b=new int[t];//开辟存放编码串的空间:反向存储
for(int i=0;i<t;i++)//对于每个待编码的单位
{
m=0;//编码数组的游标,每次清0
printf("%d ",a[i].weight);//先输出weight
for(int j=i;a[j].parent!=-1;j=a[j].parent)//不断向上找直到根节点
{
if(a[a[j].parent].L==j)//左孩子是j
b[m++]=0;
else//右孩子是j
b[m++]=1;
}
for(m=m-1;m>=0;m--)//反向输出编码,因为是反向存储
printf("%d",b[m]);
printf("\n");
}
printf("\n");
delete[] a;
delete[] b;
}
return 0;
}
贪心算法和动态规划的不同
贪心算法必须满足贪心选择性质,而动态规划不一定。能用贪心算法解决的问题都可以用动态规划来求解。一般来说动态规划是自底向上的,从小的问题得到大的问题的解;而贪心算法是自顶向下的,每次贪心选择都会把问题缩减为规模更小的子问题。
[5]回溯法
先明确问题的解空间的结构是子集树还是排列树,然后从根结点出发,深度优先遍历解空间。如果在当前扩展结点不能再向纵深方向移动,则当前扩展结点成为死结点,这时回退到最近的一个活结点处,并使其成为当前扩展结点。
传送门:回溯法几个经典问题
[6]分支限界法
分支限界法和回溯法主要区别在于对当前扩展结点所采用的扩展方式不同,分支限界法采用广度优先遍历,回溯法采用深度优先遍历。
但是,从活结点表中选取下一扩展结点的方式也可能有所不同:
①FIFO(队列)式分支限界法:就是使用普通的队列,按照广度优先遍历的次序从队列中拿出先放入队列中的结点。
②优先队列式分支限界法:一般是使用覆写了比较运算的堆或者按优先级排序的队列,并不是先进的一定先出,所以不是完全的广度优先。
比如在装载问题中,以(当前载重+剩余所有物品的重量和)为优先级,问优先级队列中结点的变化过程,不妨先画出解空间树:
假设给定背包总容量是25,三个物品重量w1=10,w2=16,w3=9,在图上标注每个可达节点的(当前载重,剩余所有物品的重量和):
所以优先级队列中的变化:
可以看到用这种方式并不是完全的广度优先,但是可以更快的找到一个不错的解。更早的找到不错的解,可以有效地刷新某些Bound限界条件,使得限界条件剪枝剪枝做的更好。例如,如果以重量最大为目标,一个可行的Bound条件是:如果接下来的所有物品加进来的重量都没有当前找到的临时最优解的重量大,这条路就不可能更优,直接剪掉这棵子树。