1. 贪心算法
基本思想:
- 优化问题的算法往往包含一系列步骤,贪心算法在每一步选择中都采取在当前状态下最优的选择,目的是希望由此导出的果是最优的
- 贪心算法在求解问题时并不着眼于整体最优,它所作出的选择仅仅是当前看来是最优的
- 贪心算法得到的结果不能保证全局最优!
贪心算法与动态规划的区别:
- 动态规划算法:每一步的最优解是由上一步的局部最优解进行选择得到的,因此需要保存(之前求解的)所有子问题的最优解备查
- 贪心算法:下一步的最优解是由上一步的最优解推导得到的,当前最优解包含上一步的最优解,之前的最优解则不作保留,因此在贪心算法中作出的每步决策都无法改变(不能回退)
- 动态规划算法通常以自底向上的方式求解各子问题;贪心算法则通常以自顶向下的方式进行,每一次贪心选择就将所求问题简化为规模更小的子问题
- 二者关系:贪心算法本质上是一种(更快的)动态规划算法;贪心法正确的条件是每一步的最优解一定包含上一步的最优解;如果可以证明:在递归求解的每一步,按贪心选择策略选出的局部最优解,最终可导致全局最优解,则二者是等价的
贪心算法的基本要素:
- 贪心选择性质:所求问题的整体最优解可以通过一系列局部最优的选择得到,这是贪心算法可行的第一个基本要素,对于一个具体问题,要确定它是否具有贪心选择性质,必须证明每一步所作的贪心选择最终能够导致问题的整体最优解
- 最优子结构性质:当一个问题的最优解包含其子问题的最优解时,称此问题具有最优子结构性质,这是一个问题可用动态规划算法或贪心算法求解的关键特征
2.示例: 活动安排问题
问题描述:
- 设有n个活动的集合E={1,2,…,n},其中:每个活动都要求竞争使用同一资源(如演讲会场等),而在同一时间内只有一个活动能使用这一资源
- 每个活动 i 都有一个请求使用该资源的起始时间 si和结束时间 fi,且 si < fi
- 如果选择了活动 i,则它在半开时间区间[si, fi)内占用资源
- 若区间[si, fi)与[sj, fj)不相交,则称活动i与活动j是相容的
- 活动安排问题就是要在所给的活动集合中,选出最大的相容活动子集合,即使得尽可能多的活动能兼容地使用公共资源
求解活动安排问题:
11个活动的起始,结束时间入下图所示:
将活动按照结束时间进行从小到大排序。然后用i代表第i个活动,s[i]代表第i个活动开始时间,f[i]代表第i个活动的结束时间。按照从小到大排序,挑选出结束时间尽量早的活动,并且满足后一个活动的起始时间晚于前一个活动的结束时间,全部找出这些活动就是最大的相容活动子集合。用数学归纳法可证明这种解法能否确保全局最优。
代码:
//4d1 活动安排问题 贪心算法
#include "stdafx.h"
#include <iostream>
using namespace std;
template<class Type>
void GreedySelector(int n, Type s[], Type f[], bool A[]);
const int N = 11;
int main()
{
//下标从1开始,存储活动开始时间
int s[] = {0,1,3,0,5,3,5,6,8,8,2,12};
//下标从1开始,存储活动结束时间
int f[] = {0,4,5,6,7,8,9,10,11,12,13,14};
bool A[N+1];
cout<<"各活动的开始时间,结束时间分别为:"<<endl;
for(int i=1;i<=N;i++)
{
cout<<"["<<i<<"]:"<<"("<<s[i]<<","<<f[i]<<")"<<endl;
}
GreedySelector(N,s,f,A);
cout<<"最大相容活动子集为:"<<endl;
for(int i=1;i<=N;i++)
{
if(A[i]){
cout<<"["<<i<<"]:"<<"("<<s[i]<<","<<f[i]<<")"<<endl;
}
}
return 0;
}
template<class Type>
void GreedySelector(int n, Type s[], Type f[], bool A[])
{
A[1]=true;
int j=1;//记录最近一次加入A中的活动
for (int i=2;i<=n;i++)//依次检查活动i是否与当前已选择的活动相容
{
if (s[i]>=f[j])
{
A[i]=true;
j=i;
}
else
{
A[i]=false;
}
}
}
3.示例: 背包问题
问题描述:
- 与0-1背包问题类似,所不同的是在选择物品i装入背包时,可以选择物品 i 的一部分,而不一定要全部装入背包
- 这两类问题都具有相似的最优子结构性质,但背包问题可以用贪心算法求解,而0-1背包问题却不能用贪心算法求解
求解思路:
- 基本步骤
- 首先计算每种物品单位重量的价值:Vi/Wi
- 然后按照贪心选择策略,将尽可能多的单位重量价值最高的物品装入背包,若将这种物品全部装入后,背包内的物品总重量未超过C,则选择单位重量价值次高的物品并尽可能多地装入背包
- 依此策略一直地进行下去,直到背包装满为止
- 算法复杂度分析
- 计算时间主要用于对各种物品按单位重量的价值排序
- 因此算法的计算时间上界为: O(nlogn)
4.示例: 哈夫曼编码
问题描述:
- 哈夫曼编码是广泛应用于数据文件压缩的一种十分有效的编码方法,其压缩率通常在20%~90%之间
- 哈夫曼编码算法使用字符在文件中出现的频率表作为输入,构建一个用0/1位串表示各字符的最优表示方式
- 为出现频率较高的字符赋予较短的编码
- 为出现频率较低的字符赋予较长的编码
- 由此可以大大缩短总码长,实现压缩编码
Huffman树的构造方法:Huffman算法
- 根据给定的n个权值:{w1,w2,……wn},构造n棵只含根结点的二叉树,令每棵树的权值为相应的结点权值(wj)
- 在森林中选取两棵根结点权值最小的树作为左右子树,构造一棵新的二叉树,新树根节点权值为其左右子树根结点权值之和
- 在森林中删除这两棵树,同时将新得到的二叉树加入森林中
- 重复上述两步,直到森林中只含一棵树为止,这棵树即哈夫曼树
Huffman编码:
- 设有n种字符,每种字符出现的次数为f(i),其编码长度为d(i) (i=1,2,...n),则整个电文总长度为Σf(i)d(i)
- 要得到最短的电文,即使得Σf(i)d(i)最小,为此:
- 以字符出现的次数为权值,构造一棵Huffman树
- 规定左分支编码为0,右分支编码为1
- 则字符的编码为:从根节点到该字符所在的叶结点的路径上的分支编号构成的序列
- 用Huffman树编出来的码,称为Huffman编码
5.示例: 单源最短路径
问题描述:
- 在有向图中,寻找从某个源点到其余各个顶点或者每一对顶点之间的最短带权路径的运算,称为最短路径问题
- 单源最短路径问题
- 给定:带权有向图G=(V,E),其中,每条边的权是非负实数
- 给定:顶点集合V中的一个顶点v,称为源点
- 求解:从源点v到G中其余各顶点之间的最短路径,这里路径长度是指各条边的权值之和
迪杰斯特拉(Dijkstra)算法:
- 算法基本思想
- 按路径长度递增的次序产生到各顶点的最短路径
- 方法:设置顶点集合S并不断地做贪心选择来扩充这个集合
- 一个顶点属于S当且仅当从源到该顶点的最短路径长度已知
- 依据:可以证明V0到T=V-S中顶点Vk的最短路径:或是从V0到Vk的直接路径的权值,或是从V0经S中顶点到Vk的路径权值之和
- 算法设计思路 (注:最短路径长度缩写为SP)
- 把V分成两组:① S:已求出最短路径的顶点的集合;② T=V-S:尚未确定最短路径的顶点集合
- 初始时,集合S中仅包含源点V0
- 将T中顶点按最短路径递增的次序加入到S中,需确保:从源点V0到S中各顶点的SP ≤ 从V0到T中任何顶点的SP;每个顶点对应一个距离值,S中顶点:从V0到此顶点的最短路径长度,T中顶点:从V0到此顶点的只包括S中顶点作为中间顶点的SP
算法伪代码:
- 初始化条件:
- 令: S={V0},T={其余顶点}
- T中顶点 Vi 对应的距离值 记为Di :若存在 < V0, Vi >, Di 为< V0,Vi >弧上的权值;若不存在< V0,Vi >: Di 为∞
- 从T中选取一个距离值最小的顶点W加入S
- 对T中顶点的距离值进行修改:
- 若增加W作中间顶点之后,从V0 到Vi 的距离值比不加W的路径要短,则更新 Vi 距离值(为较小的值)
- 重复上述步骤,直到S中包含所有顶点(即S=V)为止
- 例子