一、算法思想
贪心就是把整个问题分解成多个步骤,在每个步骤中选取当前步骤的最优方案,直到所有步骤结束。其中每一步都不考虑对后续步骤的影响,后续步骤也不考虑前面步骤的选择,在特定规则下能够使局部最优达到全局最优,或者近似全局最优。这就是贪心思想。
二、贪心法的性质
1.最优子结构性质。当一个问题的最优解包含子问题的最优解时,这种情况下就称满足最优子结构性质,能从局部最优达到全局最优。
2.贪心选择性质。问题的全局最优解可以由局部最优解所得到。
三、求解过程
1.将具体问题抽象成数学模型,并选择合适的贪心策略;
2.将要求解的问题分成若干个子问题;
3.寻求每个子问题的局部最优解;
4.将局部最优解整合得到全局最优解。
四、简单问题
1.活动安排问题(区间调度问题)
有若干个电视节目,给出它们放映的起止时间,其中有些节目时间会发生冲突,问能完整看完的电视节目最多有多少个?
根据题目所给的条件,有三种贪心策略可供选择:
(1)最早开始时间;(2)最早结束时间;(3)放映最短时间。
通过简单的分析判断可知,(1)和(3)的策略都是不可行的,原因是如果只考虑最早开始时间,而不考虑终止时间,那么会存在最早开始但是最晚结束的情况;如果选择放映最短时间,那么节目放映的时间点关系就不得而知,无法进行合理判断。(2)的策略是可行的,按最早结束时间进行排序,就可以安排接下来要看的节目,进而通过判断条件达到题目的要求。
知道了用什么策略,接下来就是考虑对这个策略的贪心算法步骤:
(1)将 n 个活动按结束时间从小到大进行排序;
(2)选择第 1 个结束的活动,并排除与它时间冲突的活动;
(3)每次选择剩下的活动种最早结束的活动再进行(2)的步骤,直到没有活动可以选择为止。
再检查一下以上算法,可以发现它符合贪心算法的性质,大功告成!
AC的C++代码如下:
#include <iostream>
#include <cstring>
#include <algorithm>
using namespace std;
typedef long long ll;
const int N = 100 + 5;
struct node{ // 定义活动起止时间
int start, end;
}telecast[N];
bool cmp(const node& a, const node& b){ // 结束时间从小到大排序规则
return a.end < b.end;
}
int main()
{
int n;
while(cin >> n && n){
for(int i = 0; i < n; i++)
cin >> telecast[i].start >> telecast[i].end;
sort(telecast, telecast + n, cmp); // 按结束时间从小到大进行排序
int cnt = 0;
int lastend = -1; // 上一个节目结束时间,初值赋为-1
for(int i = 0; i < n; i++){
if(telecast[i].start >= lastend){
cnt++;
lastend = telecast[i].end; // 记录该活动终止时间,用于下次判断
}
}
cout << cnt << endl;
}
return 0;
}
2.区间覆盖问题
农场主John有 N 头牛,他分配这些牛去打扫圈地卫生(给出每头牛的工作起止时间),John把一天分成若干个时间段让牛去打扫,每头牛打扫的时间是整段时间(不可被分割),你的任务是安排尽量少的牛去完成一天的打扫任务。
根据题意,可以很容易想到,要想安排的牛尽量少,每头牛工作的时间就得尽量长,因此我们考虑的贪心策略为:每头牛的工作时间。当然,同时还得考虑上每头牛的工作开始时间,使得最终的安排充斥整个时间段。开始分析贪心步骤:
(1)把每头牛的工作时间按起始时间递增排序;
(2)更新已经覆盖的区间,再在剩下的牛的工作时间中找到起始时间小于等于区间右端点且终止时间最大的工作时间,再将这个区间加入到已覆盖的区间;
(3)重复步骤(2),直到区间全部覆盖。
再检查一下以上算法,可以发现它符合贪心算法的性质,大功告成!
AC的C++代码如下:
#include <iostream>
#include <cstring>
#include <algorithm>
using namespace std;
const int N = 25000 + 5;
struct node{
int start, end;
}cow[N];
bool cmp(const node& a, const node& b){
return a.start < b.start;
}
int main()
{
int n, t;
cin >> n >> t;
for(int i = 0; i < n; i++)
cin >> cow[i].start >> cow[i].end;
sort(cow, cow + n, cmp);
cow[n].start = 0x3f3f3f3f;
int right = 0, left = 1, flag = 0, cnt = 0;
for(int i = 0; i < n; i++){
if(cow[i].start <= left){
if(cow[i].end > right){
right = cow[i].end;
flag = 1;
}
if(cow[i + 1].start > left && flag){
left = right + 1;
flag = 0;
cnt++;
}
}
}
if(left - 1 == t) cout << cnt << endl;
else cout << -1 << endl;
return 0;
}
3.最优装载问题
有 n 种药水,体积都是 V,浓度不同,把它们混合起来,得到浓度不大于w%的药水,问怎么混合才能得到最大体积药水?注意一种药水要么全用,要么都不用,不能只取一部分。
根据题意,可以很自然想到贪心策略为:找浓度最小的药水。那么贪心的步骤为:
(1)对药水浓度按从小到大的顺序排序;
(2)循环药水,并每次对其进行判断:如果浓度不大于w%就加入,如果大于w%,计算混合后总浓度,不大于w%就加入,否则结束判断。
再检查一下以上算法,可以发现它符合贪心算法的性质,大功告成!
AC的C++代码如下:
#include <iostream>
#include <cstring>
#include <algorithm>
#include <iomanip>
using namespace std;
const int N = 100 + 5;
int liquid[N];
int main()
{
int c, n, v, w;
cin >> c;
while(c--){
cin >> n >> v >> w;
for(int i = 0; i < n; i++)
cin >> liquid[i];
sort(liquid, liquid + n);
int vsum = 0;
double csum = 0;
for(int i = 0; i < n; i++){
//if(liquid[i] <= w) csum = (csum * vsum + liquid[i] * v) / (vsum + v);
if(csum * vsum + liquid[i] * v <= w * (vsum + v)){
csum = (csum * vsum + liquid[i] * v) / (vsum + v);
vsum += v;
}
else break;
}
if(vsum == 0) cout << 0 << ' ' << "0.00" << endl;
else cout << vsum << ' ' << setiosflags(ios::fixed) << setprecision(2) << csum / 100 << endl;
}
return 0;
}
由于不论是药水浓度小于等于w%还是大于w%,都得更新一次浓度,并且更新浓度的式子是相同的,因此实际代码中只需判断加入第 i 瓶药水后的浓度是否小于w%(可以转化为物质的量的比较),然后更新即可。
例题:hdu2570 迷瘴
4.多机调度问题
设有 n 个独立的作业,由 m 台相同的计算机进行加工。作业 i 的处理时间为 ,每个作业可在任何一台计算机上加工处理,但不能间断、拆分。要求给出一种作业调度方案,在尽可能短的时间内,由 m 台计算机加工处理完成这 n 个作业。
多机调度问题是个NP问题,目前无法得到最佳解,只能通过贪心得到最佳解的近似解。
求解该问题的贪心策略是把处理时间最长的作业交给最先空闲的计算机,让处理时间长的作业优先处理,从而整体获得尽可能短的处理时间。
该问题有两种情况:
(1):需要的时间就是 n 个作业种最长的处理时间 t。
(2):先将作业按处理时间从大到小排序,再按顺序分配给空闲的计算机。
C++代码如下:
#include <iostream>
#include <algorithm>
#include <queue>
using namespace std;
const int M = 1e5 + 5;
int a[M];
priority_queue<int, vector<int>, greater<int> > q;
int main()
{
int n, m;
cin >> n >> m;
for(int i = 0; i < m; i++){
cin >> a[i];
}
sort(a, a + m, greater<int>());
if(n >= m)
cout << a[0] << endl;
else{
for(int i = 0; i < n; i++){
q.push(a[i]);
}
for(int i = n; i < m; i++){
a[i] += q.top();
q.pop();
q.push(a[i]);
}
for(int i = 0; i < m - 1; i++)
q.pop();
cout << q.top() << endl;
}
return 0;
}
五、扩展算法
1.哈夫曼(Huffman)编码
由于每种字符出现的频次不同,因此用普通的二进制编码虽然方便,但不节省空间,这就引出了Huffman编码的方法。
Huffman编码是利用贪心思想构造二叉编码树的算法。每个二叉树的分支左边是0,右边是1,二叉树的末端的叶子放编码,这样可以保证每个编码不发生重复。将出现频次最高的字符放在靠近根的叶子上,编码最短;将出现频次最低的字符放在二叉树最深处的叶子上,编码最长。这样就可以实现空间节省的效果,并且能做到每个编码都不发生重复。
那么接下来的问题就是如何构造这样的一棵编码二叉树:
(1)对所有字符按出现的频次从小到大进行排序;
(2)从出现频次最少的字符开始,用贪心思想安排在二叉树上(每一步都要按频次重新排序,排序要包括生成的二叉树的当前根结点),具体步骤如下图。
可以证明,以上步骤符合贪心法的性质,因此这样编码的结果是最优的。
接下来就该考虑代码如何实现了,我们来看一道例题:
输入一个字符串,分别用普通ASCII编码(每个字符8bit)和Huffman编码,输出编码后的长度,并输出压缩比。
AC的C++代码如下:
#include <iostream>
#include <algorithm>
#include <cstring>
#include <iomanip>
#include <vector>
#include <queue>
using namespace std;
int main()
{
string s;
priority_queue<int, vector<int>, greater<int> > q; // 优先队列存储,频次最小的在队首
while(cin >> s && s != "END"){
int ans1 = 8 * s.size(); // ASCII码的编码长度
int t = 1;
sort(s.begin(), s.end()); // 对字符进行排序
int len = s.size();
for(int i = 1; i < len; i++){ // 统计各字符出现的次数,并放入优先队列中
if(s[i] != s[i - 1]){
q.push(t);
t = 1;
}
else t++;
}
q.push(t);
int ans2 = 0;
if(q.size() == 1) ans2 = q.top(); // 字符串只有一个字符的情况
while(q.size() > 1){
int a = q.top(); // 提取队列中最小的两个
q.pop();
int b = q.top();
q.pop();
q.push(a + b); // 将当前根结点放回队列排序
ans2 += a + b; // 计算Huffman编码的总长度
}
q.pop();
cout << ans1 << ' ' << ans2 << ' ' << setiosflags(ios::fixed) << setprecision(1) << (double)ans1 / ans2 << endl;
}
return 0;
}
2.模拟退火
模拟退火算法基于物理原理:高温物体降温的情况是温度越高降温越快,温度越低降温越慢。基于这种思想,进行多次降温迭代,直到获得可行解(无法得到精确解)。
迭代过程中,选择下一个状态有两种可能:
(1)新状态比原状态更优,那么接受新状态;
(2)新状态比原状态更差,那么以一定概率接受新状态,这个概率随迭代次数逐渐降低。
因此模拟退火算法的步骤如下:
(1)设置初始温度T、降温系数Δ和终止温度eps;
(2)每次迭代温度按降温系数下降,以新的温度计算当前状态;
(3)温度降到设定的终止温度时,迭代停止。
我们来看道典型应用——求函数最值问题:
函数。输入y值,求函数的最小值。
AC的C++代码如下:
#include <iostream>
#include <algorithm>
#include <cstring>
#include <cmath>
#include <cstdlib>
#include <iomanip>
using namespace std;
const double eps = 1e-8; // 终止温度
double y; // 函数值
double cal(double x){
return 6 * pow(x, 7.0) + 8 * pow(x, 6.0) + 7 * pow(x, 3.0) + 5 * pow(x, 2.0) - y * x;
}
double anneal(){
double T = 100; // 初始温度
double delta = 0.98; // 降温系数
double x = 50.0; // x的初始值
double now = cal(x); // 计算初始函数值
double ans = now;
while(T > eps){
int f[2] = {1, -1};
double newx = x + f[rand() % 2] * T; // 按温度概率改变x的值,概率随温度降低而降低
if(newx >= 0 && newx <= 100){
double next = cal(newx);
ans = min(ans, next);
if(now - next > eps){
x = newx;
now = next;
}
}
T *= delta;
}
return ans;
}
int main()
{
int t;
cin >> t;
while(t--){
cin >> y;
cout << setiosflags(ios::fixed) << setprecision(4) << anneal() << endl;
}
return 0;
}