贪心算法的基本概念
"首先需要强调的是,01背包问题的前提是每件物品都只有两种状态:被选和没有被选.并不能将一件物品的一部分装入背包.这正是01背包中01的意义.如果可以将一件物品的一部分装入背包,那么完全可以采用比动态规划更简单的思路:通过每件物品的价值/重量求出每件物品的价值密度(我自己编的名词).然后从高到低取物品即可.这种思路就是一种简单的贪心算法思路.因为01背包题目中的物品只能取或者不取,所以在其容量与物品重量有较大冲突时,用简单的贪心算法并不能得到正确的结果."
上一章在介绍01背包问题时就简单地提到了贪心算法.在普通的背包问题中,向容量有限的背包中装入多种物品,每件物品存在重量与价值两个属性,并允许被拆分,要求计算背包可以容纳的最大价值.用贪心的思路去想,显然我们要优先装入单位重量价值更大的物品.根据这个思路,就可以轻松地解决背包问题了.
贪心算法可以理解为在每一个局部选择局部的最优解,并得到一个整体的最优解.当然,在很多情况下,贪心算法得到的结果并不是真正的最优解,因为题目考察的可能是动态规划这种更具有考察价值的算法.但是,动态规划得到的结果往往会比较接近最优解.所以贪心算法在算法题中,可以作为动态规划题目找不到状态转移方程时的骗分手段,有那么多测试点,总得有贪心算法恰好是最优解的情况吧.
当然,不去谈这些邪门歪道,贪心算法也是具有很深远的意义的,它更多的是一种解决问题的思路.例如面对单源最短路径问题的重要算法Dijkstra算法,以及面对最小生成树问题的Prim算法与Kruskal算法,都采用了贪心的思路.今天我们试着理解贪心算法,也主要通过上述的三个算法来进行.需要声明的是,由于笔者精力和能力的限制,上面的算法我的描述也许会相对抽象,不会有太多生动的模拟过程,读者如果感到理解吃力,我会附上推荐的学习链接地址.
Dijikstra算法解决单源最短路问题
单源最短路问题描述:
在一个图中,求某个点到其它所有点的最短路径的长度.
在此我们采用下面的图例来模拟算法过程.如下图所示是一个图,图中包括多条边,每条边有不同的权值,所谓最短路径就是从一个点走到另一个点走过的边的权值之和最小.我们需要求出结点1到其它所有点的最短路长度.
对于这张图,我们可以采用邻接矩阵的方式储存,在此我们设为Graph[][],其中Graph[i][j]代表结点i到结点j的边的权值.对两个并不直接相连的结点,它们之间的边权值被认为是无穷大.下面的表是对本案例我们初始的Graph[][]邻接矩阵.
1 | 2 | 3 | 4 | 5 | 6 | |
1 | 0 | 1 | 2 | MAX_INT | MAX_INT | MAX_INT |
2 | MAX_INT | 0 | 9 | 3 | MAX_INT | MAX_INT |
3 | MAX_INT | MAX_INT | 0 | MAX_INT | 5 | MAX_INT |
4 | MAX_INT | MAX_INT | 4 | 0 | 13 | 15 |
5 | MAX_INT | MAX_INT | MAX_INT | MAX_INT | 0 | 4 |
6 | MAX_INT | MAX_INT | MAX_INT | MAX_INT | MAX_INT | 0 |
除此之外,我们需要一个一维数组optimum[],其中optimum[p]代表结点1到结点p的最短路径长度.初始状态下,只有与结点1直接相连的结点存在一个当前的最短路,随着我们的计算,optimum[]数组被逐渐填满,当每个节点都被标记为确定时,optimum[]数组的数值就会确定,成为我们的最终答案.下图就是面对本案例我们初始的optimum[]数组情况.
1 | 2 | 3 | 4 | 5 | 6 |
0 | 1 | 12 | MAX_INT | MAX_INT | MAX_INT |
之所以认为贪心思路被应用到了Dijikstra算法中,是因为Dijikstra算法在每一步中,假如这一步中的optimal[x]为最短路径,那么我们就认为初始结点1到结点x的路径已经确定,这种取局部最优解的思路正是一种贪心思路.
Dijikstra算法的过程模拟
下面我们来模拟Dijikstra算法的过程:
第一步:
对于初始状态的optimun数组:
1 | 2 | 3 | 4 | 5 | 6 |
0 | 1 | 12 | MAX_INT | MAX_INT | MAX_INT |
除了1结点自身对自身的距离为0,,我们发现当前的最短路径是1->2的路径,距离为1,那么我们认为1->2的最短路径已经确定就是1,我们将结点2加入已经确定的图中.
我们可以理解为当前只考虑了下面的局部图,其中绿色的节点代表最短路已经确定的集合:
第二步:
既然节点2已经确定,不妨将节点2的直接相邻节点也加入optimum[]数组,如果1->2已经是最短的了,那么1->2的临点也会有可能最短的通路.首先,2->3,且Graph[2][3]+optimum[2]=1+9=10<optimum[3]=12,其次,2->4,且Graph[2][4]+optimum[2]=1+3=4<optimum[4]=MAX_INT,这两个点通过新加入的节点2取得了更短的路径,所以我们要修改optimum[]的相关数值:
1 | 2 | 3 | 4 | 5 | 6 |
0 | 1 | 10 | 4 | MAX_INT | MAX_INT |
除了已经确定的节点1,2,我们发现节点4具有optimum[4]=4是当前最短的路径,我们认为节点4的最短路径已经确定.
根据上面的算法过程,我们继续模拟,详细解析不再赘述.
第三步:
1 | 2 | 3 | 4 | 5 | 6 |
0 | 1 | 8 | 4 | 17 | 19 |
第四步:
1 | 2 | 3 | 4 | 5 | 6 |
0 | 1 | 8 | 4 | 13 | 19 |
第五步:
1 | 2 | 3 | 4 | 5 | 6 |
0 | 1 | 8 | 4 | 13 | 17 |
至此,每个节点都已经加入我们的确定集合,也就是说,optimum[]数组中的每一个元素都已经是最短路径的数值了.这就是Dijikstra算法的过程.
Dijikstra算法的编码实现
Dijikstra算法的实现如下:
void Dijkstra(int u,bool* vis,int* optimum,int Graph[][]){
for(int t=1;t<=n;t++){
optimum[t]=Graph[u][t];
}
vis[u]=1; //vis[]数组标记已经确定的节点
for(int t=1;t<n;t++)
{
int minn=Inf,temp;
for(int i=1;i<=n;i++) //找到目前这一步的最短路是到哪个节点的
{
if(!vis[i]&&optimum[i]<minn)//当前节点还没有确定,并且目前到当前节点的最短路最小
{
minn=optimum[i];
temp=i;
}
}
vis[temp]=1;
for(int i=1;i<=n;i++)
{
if(Graph[temp][i]+optimum[temp]<optimum[i]) //新节点的加入产生了更短的路径长度
{
optimum[i]=Graph[temp][i]+optimum[temp];
}
}
}
}
最小生成树问题
最小生成树问题描述
在连通网的所有生成树中,所有边的代价和最小的生成树,称为最小生成树。我们的目标就是找到一个连通图中的最小生成树。
例如对下图:
存在最小生成树:
面对这个问题,经典的算法有两种:Prim算法与Kruskal算法。接下来我们会分别讨论两种算法的实现和它们的联系。
Kruskal算法
Kruskal算法过程模拟:
Kruskal算法可以理解为加边法。在初始状态下,我们认为所有节点都是孤立存在的,然后将边由短到长加入到图中,直到这个图成为一个连通图。这也是一个典型的贪心思路:我希望路径长度总和最短,那么在每一步,我都希望取到尽量短的路径。
值得注意的是,除了要保证新加入的边是最短的,也要保证新加入的边可以将两个连通分量连接,如果这条边的加入不能减少连通分量的个数,那么它是没有意义的。
下面我们来模拟这个过程:
初始状态:
此时图中的点都是孤立存在的。接下来我们要做的就是在图中加边:
第一步:
显然在原图中,最短的边是A-C的边,长度为1,同时,它的加入也连接了A与C两个连通分量。
在添加这条边后,显然这张图仍然不是连通图。所以我们需要继续算法。
第二步:
在图中尚未使用的边中,最短的是D-F边,长度为2。它显然也连接了两个连通分量。
在添加这条边后,仍然不是连通图,所以我们需要继续算法。
第三步:
在图中尚未使用的边中,最短的是B-E边,长度为3。它显然也连接了两个连通分量。
在添加这条边后,仍然不是连通图,所以我们需要继续算法。
第四步:
在图中尚未使用的边中,最短的是C-F边,长度为4。它显然也连接了两个连通分量。
在添加这条边后,仍然不是连通图,所以我们需要继续算法。
第五步:
在图中尚未使用的边中,最短的是B-C边与C-D边,它们的长度都为5,此时我们需要选择可以减少连通分量个数的边。
假如我选择添加C-D边:
在添加边之前和之后,连通分量都是两个,所以这条边没有意义,反而增加了路径的开销。此外,图中出现了环路,也不再是一棵树。
而选择B-C边可以满足我们的要求:
此时,整张图已经连通,算法结束,这个过程中我们生成的图就是满足要求的最小生成树。
Kruskal算法编码实现:
Kruskal算法并不是那种理解了原理就可以轻松编码的算法.在编码中,我们面临多个难点.
1.如何存储一张图?
最简单的方式方式,就是通过一个邻接矩阵来存储.然而,对于本题目而言,我们需要选择最短的边,然后是次短的边...如果采用邻接矩阵,在每次选择边的时候,都要将二维数组遍历,这是一个难以接受的时间复杂度.所以我们采用了一种以边为核心的存储方式:通过一个一维数组存储所有的边.一条边可以由两个端点和它的长度组成,所以这个数据结构的实现如下:
struct edge{ //存储图中的边
int pointA;
int pointB;
int value;
};
edge e[N];
这样的话,我们只需要对保存边的e[]数组根据value值进行排序,就可以轻松地按长度依次取出图中的边了.
2.如何描述图的连通度这个特征?怎么确定新加入的边是否减少了图的连通分量数量?
这里引入一个用来解决图的连通问题的非常重要的子算法:并查集算法.并查集是专门用来维护图的连通性,或者判断图中是否有环的重要算法.顾名思义,并查集就是对集合的合并和查询操作.关于并查集的内容完全可以作为一个独立的课题,这里我们就题论题.实现对节点所在集合的维护即可.
使用并查集,就要将每个联通分量看作一棵树,判断两个节点是否在同一个连通分量中,本质上就是判断两个节点所在的树的根节点是否相同.下面给出实现这一过程的代码:
首先,通过par[]数组存储每个节点的父节点,如果本身是根节点,父节点就是本身.
int par[N];
int high[N];
对于本题目而言,在初始状态下,每个节点都是一棵树,且都是父节点.随着边加入图中,树需要进行合并.下面的代码实现了这一过程.
void init(int n,int* par,int* high){ //初始化n个节点的并查集
for(int i=0;i<n;i++){
par[i]=i; //初始状态下每个节点的双亲为自己
high[i]=0; //初始状态下每个节点的高度为0
}
}
int findPar(int p,int* par){ //查找节点的根
return par[p]==p?p:findPar(par[p],par);//如果当前节点的双亲是自己,就返回自己的编号,否则查找其双亲节点
}
void merge(int x,int y,int* par,int* high){ //合并两个集合
x=findPar(x,par); //找到两个节点的根节点
y=findPar(y,par);
if(x==y) return; //两个节点已经是同根了,直接返回
if(high[x]<high[y]) par[x]=y; //y节点更高,就将x的父亲设置为y
else if(high[y]<high[x]){
par[y]=x;
}else{
high[x]++; //如果两者高度不同,就将x的高度增加,并将y的双亲设为x
par[y]=x;
}
}
下面给出本题目整体的代码:
#include<iostream>
#include<algorithm>
using namespace std;
#define N 100
#define INF MAX_INT
struct edge{ //存储图中的边
int pointA;
int pointB;
int value;
};
void init(int n,int* par,int* high){ //初始化n个节点的并查集
for(int i=0;i<n;i++){
par[i]=i; //初始状态下每个节点的双亲为自己
high[i]=0; //初始状态下每个节点的高度为0
}
}
void initEdge(int n,int m,edge* e){ //初始化图,这里直接注入静态的图,就是前面图示的样例
e[0].pointA=1;
e[0].pointB=2;
e[0].value=6;
e[1].pointA=1;
e[1].pointB=3;
e[1].value=1;
e[2].pointA=1;
e[2].pointB=4;
e[2].value=5;
e[3].pointA=2;
e[3].pointB=3;
e[3].value=5;
e[4].pointA=3;
e[4].pointB=4;
e[4].value=5;
e[5].pointA=2;
e[5].pointB=5;
e[5].value=3;
e[6].pointA=3;
e[6].pointB=5;
e[6].value=6;
e[7].pointA=5;
e[7].pointB=6;
e[7].value=6;
e[8].pointA=3;
e[8].pointB=6;
e[8].value=4;
e[9].pointA=4;
e[9].pointB=6;
e[9].value=2;
}
int findPar(int p,int* par){ //查找节点的根
return par[p]==p?p:findPar(par[p],par);//如果当前节点的双亲是自己,就返回自己的编号,否则查找其双亲节点
}
void merge(int x,int y,int* par,int* high){ //合并两个集合
x=findPar(x,par); //找到两个节点的根节点
y=findPar(y,par);
if(x==y) return; //两个节点已经是同根了,直接返回
if(high[x]<high[y]) par[x]=y; //y节点更高,就将x的父亲设置为y
else if(high[y]<high[x]){
par[y]=x;
}else{
high[x]++; //如果两者高度不同,就将x的高度增加,并将y的双亲设为x
par[y]=x;
}
}
bool same(int x,int y){
return findPar[x]==findPar[y];
}
bool cmp(edge a,edge b){
return a.value<b.value;
}
void kruskal(int n,int m,int* par,int* high,edge* e){//n个点,m条边,进行kruskal算法
int sumValue=0; //总权值
int numEdge=0; //总边数,显然在最小生成树中边数为节点数-1
sort(e,e+m,cmp);//对图中m条边进行升序排列
init(n,par,high); //初始化并查集
for(int i=0;i<m;i++){ //将边由短到长便利一遍
int pointA=e[i].pointA;
int pointB=e[i].pointB;
if(findPar[pointA-1]!=findPar[pointB-1]){ //并查集下表起点是0,图的节点起点是1。当两个点根节点不同时,将边加入结果集,并合并两个集合
cout<<pointA<<" "<<pointB<<" "<<e[i].value<<endl;
sumValue+=e[i].value;
numEdge++;
merge(pointA-1,pointB-1,par,high); //合并两个集合
}
if(numEdge>=n-1){ //已加入的边达到了n-1时,必然已经满足了要求,直接退出循环
break;
}
}
cout<<"最小生成树的总价值是:"<<sumValue<<endl;
return;
}
int main(){
edge e[N];
int n=6;
int m=10;
int par[N];
int high[N];
initEdge(6,10,e); //初始化图
kruskal(n,m,par,high,e);
return 0;
}
最后输出在最小生成树中保留的边的属性.
Prim算法
Prim算法过程模拟
Prim算法可以理解为加点法.在初始状态下,我们认为图中只有一个节点,然后根据当前图中最短的边,添加与最短边相连的节点,直到原图中所有的点全部相互连接,就得到了我们的最小生成树.
与Kruskal算法相对应的,在选择最短边时,要保证最短边连接的节点尚未被连接,否则这条边将是没有意义的.
下面我们来模拟这个过程:
仍然使用这张图作为例子:
初始状态:
我们以A点作为初始节点,开始这个算法.当然,使用任意节点都是可以得到最小生成树的.
第一步:
观察当前的图的情况,发现最短的边是权值为1的这一条,同时这条边所连接的节点必然还没有加入当前的图中,因此我们将长度为1的边连接的节点加入图中.
第二步:
此时我们观察图中所有的边,发现最短的是长度为4的这一条.且其连接的节点尚未加入图中,因此我们将新节点加入图中.
第三步:
同理,将长度为2的边连接的点加入图中.
第四步:
第五步:
此时,所有节点都已经加入了图中.标记出已经使用过的边,发现这便是一棵最小生成树.
另外,图中除了红色的边,其它边都不是真实存在的.只是在添加新节点时,我们需要考虑这些边,画出它们是为了便于观察.
Prim算法的编码实现
唯一需要注意的是,根据题目的需求,我们这次存储图使用了点和边结构体相嵌套.事实上这钟写法的复杂度已经与邻接矩阵区别不大了.在面对图论的题目时,图的存储结构是一个需要谨慎的点,好的数据结构可以尽可能降低时间复杂度.
与Kruskal算法相比,Prim算法没有太多实现上的困难,在此不再赘述.直接展示笔者的实现代码.
笔者没有进行精心的优化,可以看到,在暴力查找最短节点的时候,算法的时间复杂度可以达到O(n^3),欢迎笔者提出优化方案.
#include<iostream>
#include<algorithm>
#include<vector>
using namespace std;
#define N 100
struct edge{
int pointA;
int pointB;
int value;
};
struct point{ //节点结构体,可以存储本节点连接的边
edge e[N];
int eNum;
};
bool cmp(edge a,edge b){
return a.value<b.value;
}
void initPoints(point* p){ //初始化图,将原图输入
p[1].eNum=3;
p[1].e[0].pointA=1;
p[1].e[0].pointB=3;
p[1].e[0].value=1;
p[1].e[1].pointA=1;
p[1].e[1].pointB=2;
p[1].e[1].value=6;
p[1].e[2].pointA=1;
p[1].e[2].pointB=4;
p[1].e[2].value=5;
p[2].e[0].pointA=2;
p[2].e[0].pointB=3;
p[2].e[0].value=5;
p[2].eNum=3;
p[2].e[1].pointA=2;
p[2].e[1].pointB=5;
p[2].e[1].value=3;
p[2].e[2].pointA=2;
p[2].e[2].pointB=1;
p[2].e[2].value=6;
p[4].eNum=3;
p[4].e[0].pointA=4;
p[4].e[0].pointB=1;
p[4].e[0].value=5;
p[4].e[1].pointA=4;
p[4].e[1].pointB=3;
p[4].e[1].value=5;
p[4].e[2].pointA=4;
p[4].e[2].pointB=6;
p[4].e[2].value=2;
p[3].eNum=5;
p[3].e[0].pointA=3;
p[3].e[0].pointB=1;
p[3].e[0].value=1;
p[3].e[1].pointA=3;
p[3].e[1].pointB=2;
p[3].e[1].value=5;
p[3].e[2].pointA=3;
p[3].e[2].pointB=4;
p[3].e[2].value=4;
p[3].e[3].pointA=3;
p[3].e[3].pointB=5;
p[3].e[3].value=6;
p[3].e[4].pointA=3;
p[3].e[4].pointB=6;
p[3].e[4].value=4;
p[5].eNum=3;
p[5].e[0].pointA=5;
p[5].e[0].pointB=2;
p[5].e[0].value=3;
p[5].e[1].pointA=5;
p[5].e[1].pointB=3;
p[5].e[1].value=6;
p[5].e[2].pointA=5;
p[5].e[2].pointB=6;
p[5].e[2].value=6;
p[6].eNum=3;
p[6].e[0].pointA=6;
p[6].e[0].pointB=4;
p[6].e[0].value=2;
p[6].e[1].pointA=6;
p[6].e[1].pointB=3;
p[6].e[1].value=4;
p[6].e[2].pointA=6;
p[6].e[2].pointB=5;
p[6].e[2].value=6;
for(int i=1;i<=6;i++){
sort(p[i].e,p[i].e+p[i].eNum-1,cmp);//将边按照从短到长排序
}
}
void prim(point* p,bool* visited,int visitedSum,vector<point>visitedPoints){
while(visitedSum<6){
edge minEdge;
minEdge.value=0x4f;
for(int i=0;i<visitedPoints.size();i++){
for(int j=0;j<visitedPoints[i].eNum;j++){ //遍历查找当前最短的边
if(visitedPoints[i].e[j].value<minEdge.value&&visited[visitedPoints[i].e[j].pointB]==false){
minEdge=visitedPoints[i].e[j];
}
}
}
cout<<minEdge.pointA<<"---"<<minEdge.pointB<<" "<<minEdge.value<<endl;
visited[minEdge.pointB]=true;
visitedPoints.push_back(p[minEdge.pointB]);
visitedSum++;
}
}
int main(){
point p[N];
initPoints(p); //初始化图
bool visited[6];
vector<point>visitedPoints; //存储已经遍历到的点
int visitedSum=1; //初始状态下只有第一个点遍历过了
visited[1]=true;
visitedPoints.push_back(p[1]);
prim(p,visited,visitedSum,visitedPoints);
return 0;
}
总结
我们把边数远远小于相同节点组成的完全图的图称为稀疏图.把边数接近完全图的图称为稠密图.由于Kruskal算法是通过加边得到的最小生成树,在边过多时必然会降低效率,所以Kruskal算法更适合稀疏图求最小生成树.相比较而言,Prim算法每次寻找的是节点,所以即使在图非常稠密的情况下,这种思路可以忽略很多没必要遍历的节点.因此Prim算法更适合稠密图求最小生成树.这是在应用时,两者的最主要区别.