算法设计与分析课程学习-第四章 贪心算法

贪心算法的基本概念 

        "首先需要强调的是,01背包问题的前提是每件物品都只有两种状态:被选没有被选.并不能将一件物品的一部分装入背包.这正是01背包中01的意义.如果可以将一件物品的一部分装入背包,那么完全可以采用比动态规划更简单的思路:通过每件物品的价值/重量求出每件物品的价值密度(我自己编的名词).然后从高到低取物品即可.这种思路就是一种简单的贪心算法思路.因为01背包题目中的物品只能取或者不取,所以在其容量与物品重量有较大冲突时,用简单的贪心算法并不能得到正确的结果."

        上一章在介绍01背包问题时就简单地提到了贪心算法.在普通的背包问题中,向容量有限的背包中装入多种物品,每件物品存在重量与价值两个属性,并允许被拆分,要求计算背包可以容纳的最大价值.用贪心的思路去想,显然我们要优先装入单位重量价值更大的物品.根据这个思路,就可以轻松地解决背包问题了.

        贪心算法可以理解为在每一个局部选择局部的最优解,并得到一个整体的最优解.当然,在很多情况下,贪心算法得到的结果并不是真正的最优解,因为题目考察的可能是动态规划这种更具有考察价值的算法.但是,动态规划得到的结果往往会比较接近最优解.所以贪心算法在算法题中,可以作为动态规划题目找不到状态转移方程时的骗分手段,有那么多测试点,总得有贪心算法恰好是最优解的情况吧.

        当然,不去谈这些邪门歪道,贪心算法也是具有很深远的意义的,它更多的是一种解决问题的思路.例如面对单源最短路径问题的重要算法Dijkstra算法,以及面对最小生成树问题Prim算法Kruskal算法,都采用了贪心的思路.今天我们试着理解贪心算法,也主要通过上述的三个算法来进行.需要声明的是,由于笔者精力和能力的限制,上面的算法我的描述也许会相对抽象,不会有太多生动的模拟过程,读者如果感到理解吃力,我会附上推荐的学习链接地址.

Dijikstra算法解决单源最短路问题

单源最短路问题描述:

        在一个图中,求某个点其它所有点的最短路径的长度.

        在此我们采用下面的图例来模拟算法过程.如下图所示是一个图,图中包括多条边,每条边有不同的权值,所谓最短路径就是从一个点走到另一个点走过的边的权值之和最小.我们需要求出结点1到其它所有点的最短路长度.

        对于这张图,我们可以采用邻接矩阵的方式储存,在此我们设为Graph[][],其中Graph[i][j]代表结点i到结点j的边的权值.对两个并不直接相连的结点,它们之间的边权值被认为是无穷大.下面的表是对本案例我们初始的Graph[][]邻接矩阵.

123456
1012MAX_INTMAX_INTMAX_INT
2MAX_INT093MAX_INTMAX_INT
3MAX_INTMAX_INT0MAX_INT5MAX_INT
4MAX_INTMAX_INT401315
5MAX_INTMAX_INTMAX_INTMAX_INT04
6MAX_INTMAX_INTMAX_INTMAX_INTMAX_INT0


        除此之外,我们需要一个一维数组optimum[],其中optimum[p]代表结点1到结点p的最短路径长度.初始状态下,只有与结点1直接相连的结点存在一个当前的最短路,随着我们的计算,optimum[]数组被逐渐填满,当每个节点都被标记为确定时,optimum[]数组的数值就会确定,成为我们的最终答案.下图就是面对本案例我们初始的optimum[]数组情况.

123456
0112MAX_INT

MAX_INT

MAX_INT

         之所以认为贪心思路被应用到了Dijikstra算法中,是因为Dijikstra算法在每一步中,假如这一步中的optimal[x]为最短路径,那么我们就认为初始结点1到结点x的路径已经确定,这种取局部最优解的思路正是一种贪心思路.

Dijikstra算法的过程模拟

        下面我们来模拟Dijikstra算法的过程:

第一步:

        对于初始状态的optimun数组:

123456
0112MAX_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[]的相关数值:

123456
01104

MAX_INT

MAX_INT

         除了已经确定的节点1,2,我们发现节点4具有optimum[4]=4是当前最短的路径,我们认为节点4的最短路径已经确定.

        

         根据上面的算法过程,我们继续模拟,详细解析不再赘述.

第三步:

        

123456
0184

17

19

第四步:

123456
01 8 4

13

19

 第五步:

123456
01 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算法更适合稠密图求最小生成树.这是在应用时,两者的最主要区别.

  • 3
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 2
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值