一、理论知识
1.贪心算法的要素
- 贪心选择性质
1) 通过做出局部最优选择来构造全局最优解。
2) 必须证明贪心选择的正确性。
寻找全局最优解,假如其包含贪心选择,完成;
否则,修改它使之包含贪心选择,产生另一个最优解。
3) 根据贪心选择特性可以进行下列操作:
将输入预处理成适合贪心的顺序
若是动态数据,使用优先级队列 - 最优子结构
1) 贪心算法中的最优子结构更直接.
2) 证明:可将子问题的最优解和贪心选择合并得到原问题的最优解.
3) 既是应用DP的关键,也是应用贪心的关键.
2.贪心算法的步骤
- 确定最优子结构
- 给出递归式
- 提出贪心选择,并证明是安全的,即,问题的最优解中一定包含这个贪心选择
- 经过贪心选择后,只剩下一个子问题要进行求解,那么这个子问题可以按照前面的做法来进行求解(进行贪心选择,变为更小的相似子问题)
- 递归解法
- 迭代解法
3.贪心和动态规划的差异
从整体来看,贪心算法实现过程是由整体到局部的过程。即考虑当前整体问题,进行贪心选择,将问题转变为规模更小的子问题,依次迭代求解。
而动态规划求解方式与之恰好相反:考虑规模最小的子问题,解决子问题并保存到数组中,逐步扩大问题规模,对于重复的子问题可以不需要再次求解,而实现自底向上的求解。
对比 | 分治 | 动态规划 | 贪心算法 |
---|---|---|---|
适用类型 | 通用问题 | 优化问题 | 优化问题 |
子问题结构 | 每个子问题不同 | 很多子问题重复 | 只有一个子问题 |
最优子结构 | 不需要 | 必须满足 | 必须满足 |
子问题数 | 全部子问题都要解决 | 全部子问题都要解决 | 只要解决一个子问题 |
子问题在最优解里 | 全部 | 部分 | 部分 |
选择与求解次序 | 先选择后解决子问题 | 先解决子问题后选择 | 先选择后解决子问题 |
二、应用
1.活动安排问题
- 问题描述:
设有n个活动的集合E={1,2,…,n},其中每个活动都要求使用同一资源,如演讲会场等,而在同一时间内只有一个活动能使用这一资源。每个活动i都有一个要求使用该资源的起始时间si和一个结束时间fi,且si <fi。如果选择了活动i,则它在半开时间区间[si, fi)内占用资源。若区间[si, fi)与区间[sj, fj)不相交,则称活动i与活动j是相容的。也就是说,当si≥fj或sj≥fi时,活动i与活动j相容。活动安排问题就是要在所给的活动集合中选出最大的相容活动子集合。 - 贪心选择性
选择最早结束的活动后,剩下可安排的时间尽量多,这样就可以安排尽可能多的其他活动。证明:
令Ak是Sk的一个最大相容活动子集,且aj是Ak中结束时间最早的活动。
若aj = am, 则定理得证(am是Sk中最早结束时间的活动)
否则若aj≠am,令集合Ak’=Ak –{aj }∪ {am},
∵Ak中的活动都是相容的, aj是Ak中结束时间最早的活动,而fm≤fj
∴ Ak’中的活动都是相容的,
∵ | Ak’|=| Ak |
∴ Ak’也是Sk的一个最大相容活动子集,且包含am。 - 最优子结构
活动安排问题具有最优子结构,证明如下:在进行贪心选择后,原问题S就变成了对于选择尽量多的后n-1个活动的子问题S’。若A=(am,A’)是原问题的最优解,则A’是子问题S’的最优解,其最优值为TA’。
假设A’不是子问题S’的最优解,其子问题的最优解为B’,最优值为TB’,则有TB’>TA’,则对于原问题S来说可以安排比A解更多的活动,与原条件矛盾。故活动安排问题具有最优子结构。时间复杂度为O(nlogn)。 - 源码
#include<iostream>
#include<algorithm>
using namespace std;
struct node{
int start;
int end;
int flag;
};
bool cmp(node a, node b)
{
if(a.end <= b.end)
return true;
return false;
}
int main()
{
int n;
cin >> n;
struct node huiyi[n];
for(int i = 0; i < n; i++)
{
cin >> huiyi[i].start >> huiyi[i].end;
huiyi[i].flag = 0;
}
sort(huiyi, huiyi+n, cmp);
huiyi[0].flag = 1;
int j = 0;
for(int i = 1; i < n; i++)
{
if(huiyi[i].start >= huiyi[j].end)
{
huiyi[i].flag = 1;
j = i;
}
}
for(int i = 0; i < n; i++)
{
if(huiyi[i].flag == 1)
cout << i+1 << " ";
}
cout << endl;
return 0;
}
2.最优装载问题
- 问题描述
有一批集装箱要装上一艘载重量为c的轮船,其中集装箱i的重量为wi,假设装载体积不受限制,最多可将多少个集装箱装上轮船? - 问题分析
该问题为0-1背包问题的特殊情况,即每个集装箱的价值为1,求总价值最大的装载方案,时间复杂度O(nlogn)。
该问题存在最优子结构
存在贪心选择性——“轻者先装”
证明贪心选择的正确 - 伪码
Greedy-Loading (w, C, n)
sort(w,n)
I ← {1}
W← w1
for j← 2 to n
do if W+wj ≦ C
then W←W+ wj
I ← I ∪ {j}
else return I,W
3.哈夫曼编码
- 贪心选择性
设C为编码字符集,其中每个字符c∈C 具有频率f[c]。设x和y为C中具有最低频率的两个字符,则存在C的一种最优前缀编码,其中x和y的编码长度相同,但最后一位不同。 - 最优子结构
设T为表示字符集C的一种最优前缀编码的完全二叉树,对每个字符c∈C 定义有频率f[c]。考虑T中任意两个为兄弟的叶节点的字符x和y,并设z为它们的父节点。
那么,若认为z是一个频率为f[z]=f[x]+f[y]的字符的话,树T’=T-{x,y}就表示了字符集C’=C-{x,y} ∪{z}上的一种最优前缀编码。 - 采用了优先队列的结构,时间复杂度为O(nlogn)。
- 源码
#include <iostream>
#include <queue>
#include <vector>
#include <map>
#include <string>
using namespace std;
string s = "";
struct Node{
int n;
Node* left;
Node* right;
};
struct cmp
{
bool operator()(Node* n1, Node* n2)
{
return n1->n > n2->n;//"<"为从大到小排列,">"为从小到大排列
}
};
Node* Huffman(priority_queue<Node*, vector<Node*>, cmp> q, int n)
{
for(int i = 1; i <= n-1; i++)
{
Node* z = new Node;
Node* x = q.top();
q.pop();
Node* y = q.top();
q.pop();
z->n = x->n + y->n;
z->left = x;
z->right = y;
q.push(z);
}
return q.top();
}
void makehuffman(map<int, string>& mp, Node* z)
{
if(z->left == NULL && z->right == NULL)
{
mp[z->n] = s;
return ;
}
else
{
s+="0";
makehuffman(mp, z->left);
s=s.substr(0, s.length() - 1);
s += "1";
makehuffman(mp, z->right);
s=s.substr(0, s.length()-1);
}
}
int main()
{
int n;
cin>>n;
char* ch = new char[n];
int f[n];
for(int i = 0; i < n; i++)
{
cin >> ch[i];
}
for(int i = 0; i < n; i++)
{
cin >> f[i];
}
Node* point[n];
priority_queue<Node*, vector<Node*>, cmp> q;
map<int,string> mp;
map<char,int> ms;
for(int i = 0; i < n; i++)
{
point[i] = new Node;
point[i]->n = f[i];
point[i]->left = point[i]->right = NULL;
mp.insert(make_pair(f[i], ""));
ms.insert(make_pair(ch[i], f[i]));
q.push(point[i]);
}
Node* m = Huffman(q,n);
makehuffman(mp, m);
for(int i = 0; i < n; i++)
{
cout << ch[i] << ":" << mp[ms[ch[i]]] << endl;
}
return 0;
}
4.单源最短路径
- 贪心选择性
设源到u的最短路径l,其中路径上包含若干节点,S={……}为路径上的节点集合,假设源到x的距离是所有点到源点之间距离最小的,然后设j为S集合中距离源点最近的点,若d(u,x)=d(u,j),则证明x在S中,贪心选择正确,若d(u,x)<d(u,j),则S’=S-{j}U{x},则可以得出dist[S]>dist[S’],即得出x在S’中,则贪心选择正确。 - 最优子结构
只需要考虑在添加u到S中后,dist[u]的值所起的变化,S’为添加u之前的S,当加了u之后可能会出现一条到顶点i的新的特殊路,如果这条路经过S’到达u,再到i,则l=dist[u]+c[u][i],如果dist[u]+c[u][i]<dist[i],则dist[u]+c[u][i]就作为dist[i]的新值,得证最优子结构。 - 采用了优先队列的结构,时间复杂度为O(nlogn)。
- 源码
#include<iostream>
#include<algorithm>
#include<cstdio>
#include<stdlib.h>
#include<cstring>
#include<queue>
using namespace std;
int n, m;
struct node{
int v;
int w;
bool operator < (const node &b)const//重载小于号
{
return w>b.w;//把小于号重载为特殊的大于号,所以这里是 >
}
}now,tmp;
#define INF 0x3f3f3f
vector<node> MAP[500001];
bool vis[500001];
int d[500001];
void dijkstra(int s, int e)
{
memset(d, INF, sizeof(d));
memset(vis, false, sizeof(vis));
d[s] = 0;
now.v = s;
now.w = 0;
priority_queue<node> q;
q.push(now);
while(!q.empty())
{
now = q.top();
q.pop();
if(vis[now.v] == true)//如果已经被访问过,那么跳过
{
continue;
}
vis[now.v] = true;
int len = MAP[now.v].size();
for(int i = 0; i < len; i++)
{
tmp = MAP[now.v][i];
if(d[now.v] + tmp.w < d[tmp.v])
{
d[tmp.v] = d[now.v] + tmp.w;
q.push((node){tmp.v, d[tmp.v]});
}
}
}
}
int main()
{
cin >> n >> m;
while(m--)
{
int u, v, w;
cin >> u >> v >> w;
MAP[u].push_back((node){v, w});
MAP[v].push_back((node){u, w});
}
int s, e;
cin >> s >> e;
dijkstra(s, e);
cout << d[e] << endl;
return 0;
}
5.最小生成树
- prim算法的证明
首先,我们要知道构造最小生成树G的Prim算法的基本思想:首先置S={1},然后。只要S是V的真子集,就做如下的贪心选择:选取满足条件i属于S,j属于V-S,且C[i][j]最小的边,并将顶点j添加到S中,这个过程一直进行到S=V时为止,选取到的所有边恰好构成G的一颗最小生成树。
接下来,我们用反证法进行简单证明:
(1)假设最小权值的边不在该最小生成树中。
(2)之后将最小权值的边加入到该生成树中构成回路,将该生成树权值最大的边删掉,构成新的生成树。
(3)与假设矛盾,所以最小的边一定在最小生成树上。
证毕; - Kruskal算法的证明
步骤1,选择边e1,使得权值w(e1)尽可能小;
步骤2,若已选定边e1,e2,…,ei,则从E{e1,e2,…,ei}选取e(i+1),使得
(1)G[{e1,e2,…,e(i+1)}]为无圈图
(2)权值w(e(i+1))是满足(1)的尽可能小的权;
步骤3,当步骤2不能继续执行时停止。
证明:由Kruskal算法构成的任何生成树T*=G[{e1,e2,…,e(n-1)}]都是最下生成树,这里n为赋权图G的顶点数。
使用反证法
1、有kruskal算法构成的生成树T和异于T的生成树T,这两种生成树。
2、定义函数f(T)表示不在T中的最小权值i的边ei。假设T不是最小树,T真正的最小树,显然T会使f(T)尽可能大的,即T本身权重则会尽可能小,。
3、设f(T)=k,表示存在一个不在T中的最小权值边ek=k,也就是说e1,e2,…e(k-1)同时在T和T中,ek=k不在T中
4、T+ek包含唯一圈C。设ek ’ 是C的一条边,他在T中而不在T中。(想象圈C中至少有ek 和ek ’ ,其中ek是又Kruskal算法得出的最小权边)
5、令T ’ =W(T)+w(ei)-w(ei ’ ),kruskal算法选出的是最小权边ek,(而ek’是T自己根据f(T)选出来的边)有w(ek ’ )>=w(ek) 且W(T ’ )=W(T)(T ’ 也是一个最小生成树)
6、但是f(T ’ )>k= f(T),即T没有做到使得f(T)尽可能大,不再是真正的最小树,所以T=T*,从而T*确实是一棵最小数。 - prim算法-优先队列实现
#include <iostream>
#include <queue>
#include <map>
#include <cstring>
using namespace std;
#define maxint 0x3f3f3f3f
#define maxnum 1051
int link[maxnum][maxnum];
int c[maxnum][maxnum];
int sum,n;//sum为最小权之和,n为顶点个数
struct node
{
int s;//起点
int e;//终点
int w;//权
};
bool operator < (const node &a,const node &b)
{
return a.w > b.w;
}
void prim(int s)
{
int i,j,k,m,t,u,total;
int vis[maxnum];//标记访问
memset(vis,0,sizeof(vis));//初始化vis均为0,即未被访问
priority_queue <node> qq;//声明一个存储node结构体的优先队列
struct node nn;
total = 1;
vis[s] = 1;
sum = 0;
while(total < n)//遍历所有的顶点
{
for(i=1;i<link[s][0];i++)//遍历所有和s点相连的边,s点为源点
{
if(!vis[link[s][i]])//若这个边没被访问,就将其加入优先队列
{
nn.s = s;
nn.e = link[s][i];
nn.w = c[s][nn.e];
qq.push(nn);
}
}
//这里就是简单处理一下特殊情况
while(!qq.empty() && vis[qq.top().e])//遇到顶点和集合外的顶点没有相连的
qq.pop();//刚巧这个点作为终点是最短的,因为这个顶点没背标记过,所以会错误的计入在内
//将优先队列的队顶元素输出
nn = qq.top();
s = nn.e;
sum += nn.w;//队顶的边就是最适合的边,因为优先队列的作用就是对权值进行排序,队顶总是
//最大或最小的权值的边,又因为没被访问过,所有一定是最适合的
//cout<<nn.s<<" "<<nn.e<<" "<<nn.w<<endl;
vis[s] = 1;//标记为集合内的元素
qq.pop();
total++;//访问的点数加一
}
}
int main()
{
int i,j,k;
int line,len;
int t,s,d,p,q;
cin>>n>>line;
for(i=1;i<=n;i++)
{
link[i][0] = 1;
}
for(i=1;i<=line;i++)
{
cin>>p>>q>>len;
c[p][q] = c[q][p] = len;
link[p][link[p][0]++] = q;
link[q][link[q][0]++] = p;
}
cin>>s;//输入起始点
prim(s);
cout<<sum<<endl;
return 0;
}
- kruskal算法-优先队列实现
#include<iostream>
#include<cstring>
#include<algorithm>
#include<queue>
using namespace std;
#define N 1000
int par[N];
int rank1[N];
int sum;
struct node
{
int from;
int to;
int p;
}f1,f2;
struct cmp
{
bool operator()(node f1, node f2)
{
return f1.p > f2.p;
}
};
priority_queue<node,vector<node>,cmp> pq;
void inin(int n)
{
int i;
for (i = 0; i < n; i++)
{
par[i] = i;
rank1[i] = 1;
}
}
int find(int x)
{
if (x == par[x])
return x;
else
return par[x] = find(par[x]);
}
bool join(int a, int b)
{
int fa;
int fb;
fa = find(a);
fb = find(b);
if (fa == fb)
{
return false;
}
else if (rank1[fa] > rank1[fb])
{
par[fb] = fa;
}
else {
if (rank1[fa] == rank1[fb])
{
rank1[fb]++;
}
par[fa] = fb;
}
return true;
}
void krustal(int n)
{
int i;
while (pq.empty() != 1)
{
int x = pq.top().from;
int y = pq.top().to;
int s = pq.top().p;
pq.pop();
if (join(x, y))
sum += s;
}
cout << sum << endl;
}
int main()
{
int n, m;
cin >> n >> m;
inin(n);
int i;
sum = 0;
int a, b, c;
node k;
for (i = 0; i < m; i++)
{
cin >> k.from >> k.to >> k.p;
pq.push(k);
}
krustal(m);
return 0;
}
6.多机调度
- NP完全问题:用贪心找近似解
- 贪心选择:最长处理时间作业优先
n≤m 时,只要将机器 i 的 [0, ti] 时间区间分配给作业 i 即可,算法只需要O(1)。
n>m 时,首先将 n 个作业依其所需的处理时间从大到小排序。然后依此顺序将作业分配给空闲的处理机。算法时间复杂度为O(nlogn)。