数学建模:最详细的遗传算法 解决tsp问题(C语言实现)

1.遗传算法是什么?

遗传算法的概念是由Holland于1973年受生物进化论的启发而首次提出的,它是一种通过模拟生物界自然选择和遗传机制的随机搜索算法。该算法通过数学的方式,利用计算机仿真运算,将问题的求解过程转换成类似生物进化中的染色体基因的交叉、变异等过程。在求解较为复杂的组合优化问题时,相对一些常规的优化算法,通常能够较快地获得较好的优化结果。遗传算法已被人们广泛地应用于组合优化、机器学习、信号处理、自适应控制和人工生命等领域。
如果你能看懂并理解这段话,我觉得你已经不需要再学习本文章了~~
解释的再直白点,遗传算法是一种启发式搜索算法,不同于以前那些朴素的类似于dfs、bfs之类的算法,遗传算法的核心是在给定的解空间中搜索全局最优解,如果不能得知所有的解空间,那么显然不能应用遗传算法这一类的智能算法。

2.TSP问题是什么?

旅行商问题,即TSP问题(Traveling Salesman Problem)又译为旅行推销员问题、货郎担问题,是数学领域中著名问题之一。假设有一个旅行商人要拜访n个城市,他必须选择所要走的路径,路径的限制是每个城市只能拜访一次,而且最后要回到原来出发的城市。 路径的选择目标是要求得的路径路程为所有路径之中的最小值。非常难懂的数学式子就不再罗列了~~
由tsp问题的定义可以得知,如果要用暴力求解算法,那么tsp问题的时间复杂度就是O(n!),显然这是普通的计算机无法承受的,再进一步思考,可以采用一跳的贪心算法,给定一个起点,遍历其余所有未被访问过的城市,寻找出路径最短的那个城市,更新出发点城市。当然这样的求解方法也是劣于遗传算法的。
接下来,具体介绍遗传算法解决tsp问题

第一步:编码+种群初始化

编码的原理很简单,就是用一种方法(染色体)来表示所有的解空间。
针对于TSP问题,这是一个离散化的问题,一个既定的解就是一个所走过的城市的序列,而城市的编号为从1~city_num,所以显然可以用一个数组来维护城市序列(解空间),然后需要生成初始的种群(即一开始状态下的解空间),即从1到city_num的全排列问题。
具体生成方式采用不断交换的思路(简单易懂)

int chrom[sizepop+5][lenchrom+5]; // 种群

void init()
{/*种群初始化,生成sizepop个路径,一个路径就是一个城市的顺序,包含lenchrom个城市*/
	int num = 0;
	
	for(int i=0;i<sizepop;i++) //chrom的下标从0开始 
		for(int j=0;j<lenchrom;j++)
			chrom[i][j] = j+1;
	num++;
		
	while(num<sizepop)
	{                              
		for(int i=0;i<lenchrom-1;i++) 
		{	for(int j=i+1;j<lenchrom;j++)
			{ //交换,进而产生不同的路径顺序 
				
				swap(chrom[num][j],chrom[num][i]);
				
				num++;
				if(num>=sizepop)
					break; 
			}
			if(num>=sizepop) break;
		}
		
	/*如果经过上述方式后,还是无法生成足够的染色体,则需要通过随机交换的方式进行补充*/
		while(num<sizepop)
		{
			double r1 = ((double)rand()/(RAND_MAX+1.0)); //0~1之间的小数(不会等于1) 
			double r2 = ((double)rand()/(RAND_MAX+1.0));
			int p1 = (int)(lenchrom*r1); //位置1,范围为 [0,lenchrom-1],因为下标从0开始,所以不会等于lenchrom 
			int p2 = (int)(lenchrom*r2); //位置2
		
			swap(chrom[num][p1],chrom[num][p2]);
			num++; 
		}
	}	
} 

第二步:确定适应度函数

进化论中的适应度,是表示某一个体对环境的适应能力,也表示该个体繁殖后代的能力。遗传算法的适应度函数也叫评价函数,是用来判断群体中的个体的优劣程度的指标,它是根据所求问题的目标函数来进行评估的。遗传算法在搜索进化过程中一般不需要其他外部信息,仅用评估函数来评估个体或解的优劣,并作为以后遗传操作的依据。
简单来说就是,遗传算法选择后代的依据就是适应度函数,一个种群中谁的适应度高,我就选择谁留下来! 而针对TSP问题,一个个体的适应度显然就是它从第一个城市出发,游历一周后回到出发点城市所走过的距离的倒数(距离越小,适应度越大)

double distance(double *city1,double *city2)
{ // 计算两个城市(即两个点)的距离 
	double x1,x2,y1,y2,dis;
	x1 = *city1; x2 = *city2;
	y1 = *(city1+1); y2 = *(city2+1);
	
	dis = sqrt((x1-x2)*(x1-x2)+(y1-y2)*(y1-y2));
	return dis; 
}

double path_len(int *arr)
{ //求解一条路径的长度,它的倒数即为适应度
	double path = 0;//初始化路径长度
	int index = *arr;   //定位到第一个城市的序号(从1开始的,所以下面定位到数组时要注意减1) 
	
	for(int i=0;i<lenchrom-1;i++)
	{
		int index1 = *(arr+i); //下标为i的城市 
		int index2 = *(arr+i+1);// city_pos储存某个城市的x坐标,y坐标 
		double dis = distance(city_pos[index1-1],city_pos[index2-1]);
		path+=dis;
	} 
/*
注意:tsp问题要求最后要回到起始位置
*/	int last_index = *(arr+lenchrom-1);//最后一个城市的序号
	int first_index = *arr;//第一个城市的序号
	double last_dis = distance(city_pos[last_index-1],city_pos[first_index-1]);
	path+=last_dis;
	
	return path; //返回走完这条路径所需要的总长度 
} 

第三步:选择操作

个体被选中的概率与适应度成正比,适应度越高,个体被选中的概率越大。最简单直接的选择算子就是轮盘赌算法,求出每个个体的适应度概率,再求出每个个体的累积适应度概率,生成一个随机数,与累积适应度概率进行比较,从而确定选择哪个个体保留下来!不懂轮盘赌算法的可以去查阅资料,这里只通过代码来介绍。

void choice()
{ // 选择操作 
	double pick;//随机数选择概率 
	double choice_arr[sizepop+5][lenchrom+5];//中间变量,存储选择到的个体 
	double fit_pro[sizepop]; //每个个体适应度占总适应度和的概率 
	double sum = 0;   //该种群所有个体的适应度之和 
	double fit[sizepop+5]; //适应度函数的数组(距离的倒数)
	
	for(int j=0;j<sizepop;j++)
	{
		double path = path_len(chrom[j]);//第j个个体的路径长度
		double fitness = 1/path; //适应度
		fit[j] = fitness; 
		sum+=fit[j];	 //总适应度
	} 
	
	for(int j=0;j<sizepop;j++)
		fit_pro[j] = fit[j]/sum; //适应度的概率数组 
	
//开始轮盘赌(注意是累计概率)
	for(int i=0;i<sizepop;i++)
	{
		pick = ((double)rand())/RAND_MAX; //0-1之间的随机数	
		
		while(pick<0.0001)  //如果生成的随机数太小,则需要抛弃 
			pick = ((double)rand())/RAND_MAX;
		
		for(int j=0;j<sizepop;j++)
		{
			pick-=fit_pro[j];
			if(pick<=0)
			{
				for(int k=0;k<lenchrom;k++)
					choice_arr[i][k] = chrom[j][k];//选中一个个体
				break; 
			}
		}
	} 
	
//轮盘赌结束后,把数组重新转移到chrom中
	for(int i=0;i<sizepop;i++)
		for(int j=0;j<lenchrom;j++)
			chrom[i][j] = choice_arr[i][j]; 
} 

第四步:交叉操作

交叉操作是遗传算法最重要的操作,是产生新个体的主要来源,直接关系到算法的全局寻优能力。
我采用的交叉算子是Partial-Mapped Crossover (PMX)部分交叉,但是注意:编写代码的时候要合理地变通,因为会存在多对多映射的关系,所以我采用单个交换,交换多次的思想,然后只需要解决一个冲突,从而避免了出现多对多映射的状况。


void cross()
{
	double pick;
	double pick1;
	int choice1,choice2;//选择的个体的序号
	int pos1;  
	int move = 0; //当前移动的位置
	
	while(move<sizepop-1)
	{
		pick = ((double)rand())/RAND_MAX; //0-1之间的随机数	
		
		if(pick<pcross)
		{
			move+=2;
			continue; //本次不进行交叉操作	
		}	
	//采用部分映射方法进行交叉 
		choice1 = move; //用于选取交叉的两个父代 
		choice2 = move+1;  //注意避免下标越界 
		
		int cross_num = lenchrom/2;
		
		while(cross_num--)
		{
			pick1 = ((double)rand()/(RAND_MAX+1.0)); //0~1之间的小数(不会等于1) 
			pos1 = (int)(pick1*lenchrom); //杂交点的第一个位置,[0,lenchrom-1] 
			
			swap(chrom[choice1][pos1],chrom[choice2][pos1]);//单点交换,执行多次 
	
			for(int i=0;i<lenchrom;i++)
			{//解决冲突,因为一个染色体中,一个城市的序号只能出现一次!
				if(i == pos1) continue;
				if(chrom[choice1][i] == chrom[choice1][pos1])
					chrom[choice1][i] = chrom[choice2][pos1];
				if(chrom[choice2][i] == chrom[choice2][pos1])
					chrom[choice2][i] = chrom[choice1][pos1];
			}		
		} 
		move+=2;
	} 
}  

第五步:变异操作

变异操作比较简单,对于某个给定的染色体(即城市序列),随机选择两个position,交换这两个位置上的城市编号即可,代码实现比较简单。
遗传算法引入变异的目的有两个:一是使遗传算法具有局部的随机搜索能力。当遗传算法通过交叉算子已接近最优解邻域时,利用变异算子的这种局部随机搜索能力可以加速向最优解收敛。显然,此种情况下的变异概率应取较小值,否则接近最优解的积木块会因变异而遭到破坏。二是使遗传算法可维持群体多样性,以防止出现未成熟收敛现象。此时收敛概率应取较大值。


void mutation()
{
	double pick,pick1,pick2;
	int pos1,pos2,temp;
	
	for(int i=0;i<sizepop;i++)
	{
		pick = ((double)rand())/RAND_MAX; //0-1之间的随机数	
		
		if(pick>pmutation)
			continue;
		pick1 = ((double)rand()/(RAND_MAX+1.0)); //0~1之间的小数(不会等于1) 
		pick2 = ((double)rand()/(RAND_MAX+1.0)); //0~1之间的小数(不会等于1) 
		pos1 = (int)(lenchrom*pick1); //变异位置的选取 
		pos2 = (int)(lenchrom*pick2); 
		
		while(pos1>lenchrom-1)
		{//当然,此情况不可能发生 
			pick1 = ((double)rand())/(RAND_MAX+1.0);
            pos1 = (int)(pick1*lenchrom);	
		} 
		while(pos2 > lenchrom-1)
        {
            pick2 = ((double)rand())/(RAND_MAX+1.0);
            pos2 = (int)(pick2*lenchrom);
        }
		swap(chrom[i][pos1],chrom[i][pos2]);
	}
}

第六步: 进化逆转操作

参考资料:https://www.cnblogs.com/lyrichu/p/6152928.html
具体思路为:对于给定的一个染色体(城市序列),随机生成一个区间(即两个随机数之间),然后逆转这个区间的城市序号,操作即完成!


void reverse()
{
	double pick1,pick2;
	double dis,reverse_dis;//逆转前的距离,逆转后的距离。如果逆转后的距离变大了,显然不保留此次逆转结果
	int n;
    int flag,pos1,pos2,temp;
    int reverse_arr[lenchrom];//暂时储存逆转后的城市序列
    
    for(int i=0;i<sizepop;i++)
    { //对所有的个体都要进行一遍逆转操作 
    	flag = 0; //用于控制本次逆转是否有效(如果无效则不会进行逆转)
		int re_num = 0; 
		while(flag == 0)
		{
			pick1 = ((double)rand())/(RAND_MAX+1.0);//[0,1) 之间的浮点数 
            pick2 = ((double)rand())/(RAND_MAX+1.0);	
            pos1 = (int)(pick1*lenchrom); // 选取进行逆转操作的位置
            pos2 = (int)(pick2*lenchrom);//得到的结果为 [0,lenchrom-1] 

            while(pos1 > lenchrom-1)
            { //我认为是没有作用的操作!!! 
                pick1 = ((double)rand())/(RAND_MAX+1.0);
                pos1 = (int)(pick1*lenchrom);
            }
            while(pos2 > lenchrom -1)
            {
                pick2 = ((double)rand())/(RAND_MAX+1.0);
                pos2 = (int)(pick2*lenchrom);
            }
            
			if(pos1 > pos2)
            	swap(pos1,pos2);// 交换使得pos1 <= pos2
            
            if(pos1<pos2)
            {//如果pos1==pos2,也就没有逆转的必要了
				for(int j=0;j<lenchrom;j++)
					reverse_arr[j] = chrom[i][j]; //先复制一遍chrom数组
				
				n = 0;//逆转进行的元素数目
				
				for(int j=pos1;j<=pos2;j++)
				{
					reverse_arr[j] = chrom[i][pos2-n];
					n++;	
				} 
            	
            	reverse_dis = path_len(reverse_arr); //逆转后的距离 
            	dis = path_len(chrom[i]); //初始没有逆转前的距离 
				if(reverse_dis<dis)
				{
					for(int j=0;j<lenchrom;j++)
						chrom[i][j] = reverse_arr[j]; //更新个体 
					flag = 1;   
				} 
			}
			re_num++;
			if(re_num==10) break;// 防止因一直没有更好的逆转路径而陷入死循环 
		} 
	}
}

最后确定终止条件,可以设置终止代数maxgen=200,然后综合调用这些函数即可求得结果。虽然有时候遗传算法因为算子的原因无法做到逐代收敛,但是对于全局最优解的求解结果还是不错的!

你可能感兴趣的:(搜索)