遗传算法(Genetic Algorithm,GA)最早是由美国的 John holland于20世纪70年代提出,该算法是根据大自然中生物体进化规律而设计提出的。是模拟达尔文生物进化论的自然选择和遗传学机理的生物进化过程的计算模型,是一种通过模拟自然进化过程搜索最优解的方法。本文利用遗传算法解决经典的NP问题——旅行商问题,并加深对该算法的理解。

问题描述

有若干个城市,每个城市给定一个坐标,一个旅行商需要经过每个城市各一遍且不能重复经过城市,起点可以任意选择,求旅行商经过所有城市的总距离的最小值及其最优路径。

数据结构与算法设计

数据结构设计

  • struct point

从文本提取的城市的坐标数据,包含 id, x, y.

  • const int idNum = 100; // 种群个体数

表示种群的个体数目,即每次迭代所包含的数据的个数。

  • const double variProbability = 0.05; // 变异概率

遗传过程可能导致变异,变异次数 = 变异概率 * 种群个体数。

  • vector<point> coords; // 各点坐标

从文本文件中提取的坐标向量。

  • vector<vector<double>> distance; // 各点距离

两城市之间的距离矩阵,distance[i][j]的值为城市i与城市j的距离。

  • vector<vector<int>> route; // 路线种群

种群向量,每个 route[i] 表示一个个体,即 TSP 问题的一个解路径。

  • vector<pair<double, int>> fitness; // 各路线的适应度(适应度,种群下标)

各路线个体的适应度,fitness[i].second 表示该个体在 route 向量中的下标,即route[fitness[i].second] 的适应度为 fitness[i].first.

  • vector<int> bestRoute; // 最优路线

当前迭代过程中出现过的最优个体。

  • double minDistance = INT32_MAX; // 最短路径

当前迭代过程中出现过的最短路径。

算法设计

  • void geneticsAlgorithm(const string& path)

算法总体设计,首先根据指定的path路径读取文本并提取数据于 coords 向量,再根据 coords 数据初始化距离矩阵,并随机生成初始路线。

在迭代循环中,依次执行计算适应度、自然选择、交叉遗传、随机变异操作,并在每 100 次迭代后打印当前最优解。

  • vector<point> extraData(const string& path)

根据指定的 path 路径从文本中提取数据,实现算法为以指定文本文件初始化文件 ifstream 对象 fileStream,并每次用字符串 s 接收 fileStream 输出的值,并使用 stoi() 函数将其转换成 int 类型并存储。循环往复,直到文件末尾。

  • void initDistance()

根据 coords 中的数据初始化城市距离矩阵,算法实现为简单的二重循环+计算,在此不过多赘述。

  • void initRoute()

随机生成初始种群,实现算法为先将序号 0~coords.size() 顺序排序,再使用 random_shuffle() 函数将其打乱顺序作为个体放入 route 中,按此方式生成 idNum 个个体,作为初始种群。

  • void updateFitness()

更新适应度,首先计算各个体 route[i] 的距离总和D,并更新此时的最短距离及最优解,计算适应度为 fitness = 1 / D,即总距离越大,适应度越低,越有可能被淘汰。

  • void selectAlgorithm()

根据个体的适应度进行自然选择,自然选择的算法很多,例如轮盘赌算法等,本实验采用的是最简单的优先级淘汰,即每次淘汰适应度排名后 50% 的个体。

  • void crossAlgorithm()

交叉遗传算法,本算法为遗传算法的核心部分,目的是根据自然选择后的个体之间进行交叉遗传得到新的个体。交叉遗传算法同样有很多,本实验采用的是 Subtour Exchange Crossover 算法,算法原理如下:

  1. 首先随机选取双亲之一 parent1 的基因序列的一段。

  2. 依次遍历另一个双亲 parent2 的基因序列,若当前遍历的基因值与步骤1中随机选取的基因段中某个基因值相同,则按基因出现顺序进行交换。

  • void variationAlgorithm()

随机变异,实现算法为选取前 idNum * variProbability 个个体随机变异,变异方式为随机选取两个城市进行顺序交换。

算法结果分析

输入数据

为一个文本文件 data.txt,每行有三个整数并以空格分隔开,依次为城市编号,城市所在横坐标,城市所在纵坐标。

输出结果

输出结果分析

可见,随着迭代次数的增加,最短路径变化的越来越慢,最终收敛于一个确定的值,至此,可认为算法已经基本找到了最优解。

此外,根据数据不难看出,遗传算法在收敛速度较慢,这是由于种群进化到后期,个体之间的差异越来越小,得到更优个体的概率也越小,越到后期取得的收益越小,因此可以考虑在最优解连续一定次数(如 500 次)时终止算法,并认为此时得到的基本上为最优解。

完整代码

#include<iostream>
#include<vector>
#include<algorithm>
#include<fstream>
#include<string>
using namespace std;

struct point {
	int id;
	int x, y;
	point(int id, int x, int y) : id(id), x(x), y(y) {}
};

class TSP {
private:
	const int idNum = 100; // 种群个体数
	const double variProbability = 0.05; // 变异概率
	vector<point> coords; // 各点坐标
	vector<vector<double>> distance; // 各点距离
	vector<vector<int>> route; // 路线种群
	vector<pair<double, int>> fitness; // 各路线的适应度(适应度,种群下标)
	vector<int> bestRoute; // 最优路线
	double minDistance = INT32_MAX; // 最短路径

	vector<point> extraData(const string& path) {
		ifstream fileStream(path);
		int id = 0, x = 0, y = 0;
		vector<point> res;
		while (!fileStream.eof()) {
			string s;
			fileStream >> s;
			id = stoi(s);
			fileStream >> s;
			x = stoi(s);
			fileStream >> s;
			y = stoi(s);
			res.emplace_back(id, x, y);
		}
		fileStream.close();
		return res;
	}
	void initDistance() {
		int n = coords.size();
		distance = vector<vector<double>>(n, vector<double>(n, 0));
		for (int i = 0; i < n; ++i) {
			for (int j = 0; j < n; ++j) {
				double dis = sqrt(pow(coords[i].x - coords[j].x, 2) + pow(coords[i].y - coords[j].y, 2));
				distance[i][j] = dis;
			}
		}
	}
	void initRoute() {
		vector<int> temp;
		for (int i = 0; i < coords.size(); ++i) {
			temp.emplace_back(i);
		}
		for (int i = 0; i < idNum; ++i) {
			random_shuffle(temp.begin(), temp.end());
			route.emplace_back(temp);
		}
		fitness = vector<pair<double, int>>(idNum); // 初始化适应度大小
	}
	void updateFitness() { // 适应度函数 f = 1/D(D为路线的距离总和)
		for (int i = 0; i < idNum; ++i) {
			double D = 0;
			for (int j = 0; j < route[i].size(); ++j) {
				int xi = 0, yi = 0;
				if (j != route[i].size() - 1) {
					xi = route[i][j], yi = route[i][j + 1];
				}
				else {
					xi = route[i][j], yi = route[i][0];
				}
				D += sqrt(pow(coords[xi].x - coords[yi].x, 2) + pow(coords[xi].y - coords[yi].y, 2));
			}
			if (minDistance > D) {
				minDistance = D;
				bestRoute = route[i];
			}
			fitness[i].first = 1.0 / D;
			fitness[i].second = i;
		}
	}
	void selectAlgorithm() { // 根据适应度带权选择个体
		sort(fitness.begin(), fitness.end(), greater<pair<double, int>>());
		vector<vector<int>> newRoute = route;
		for (int i = 0; i < idNum; ++i) {
			newRoute[i] = route[fitness[i / 2].second];
		}
		route = newRoute;
		random_shuffle(route.begin(), route.end());
	}
	void crossAlgorithm() {
		vector<int> child1;
		vector<int> child2;
		for (int i = 0; i < idNum - 1; i += 2) {
			// route[i] 与 route[i + 1] 交叉
			child1 = route[i];
			child2 = route[i + 1];
			int rVal1 = rand() % route[0].size();
			int rVal2 = rand() % route[0].size();
			int p1 = min(rVal1, rVal2), p2 = max(rVal1, rVal2); // 随机确定左右区间
			int k = p1;
			for (int j = 0; j < route[0].size(); ++j) {
				auto it = find(route[i].begin() + p1, route[i].begin() + p2 + 1, route[i + 1][j]);
				if (it != route[i].begin() + p2 + 1) {
					swap(child2[j], child1[k++]);
				}
			}
			route[i] = child1;
			route[i + 1] = child2;
		}
	}
	void variationAlgorithm() { // 随机交换路线中的两城市顺序
		for (int i = 0; i < idNum * variProbability; ++i) {
			int randInd = rand() % idNum;
			int rVal1 = rand() % route[0].size();
			int rVal2 = rand() % route[0].size();
			swap(route[randInd][rVal1], route[randInd][rVal2]);
		}
	}
public:
	void geneticsAlgorithm(const string& path) {
		coords = extraData(path); // 从文本提取坐标数据
		initDistance(); // 初始化距离矩阵
		initRoute(); // 随机生成初始路线
		for (int i = 0; i < 20000; ++i) {
			updateFitness(); // 计算适应度
			selectAlgorithm(); // 自然选择
			crossAlgorithm(); // 交叉遗传
			variationAlgorithm(); // 随机变异
			if (i % 100 == 0) { // 每 100 次打印当前最优解
				cout << "迭代次数:" << i << "\t" << "当前最短路径:" << minDistance << endl;
			}
		}
	}
	vector<int> getBestRoute() { return bestRoute; }
	double getMinDistance() { return minDistance; }
	vector<point> getCoords() { return coords; }
};

int main() {
	srand((unsigned)time(nullptr));
	TSP tsp;
	tsp.geneticsAlgorithm("./data.txt");
	vector<int> bestRoute = tsp.getBestRoute();
	double minDistance = tsp.getMinDistance();
	cout << "最短路径长度为:" << minDistance << endl;
	cout << "最短路径如下所示:" << endl;
	for (int i = 0; i < bestRoute.size(); ++i) {
		cout << bestRoute[i] << "\t";
		if (i > 0 && (i + 1) % 10 == 0) cout << endl;
	}

	// debug
	cout << "\n---------------------------------------------------" << endl;
	double D = 0.0;
	auto coords = tsp.getCoords();
	for (int j = 0; j < coords.size(); ++j) {
		int xi = 0, yi = 0;
		if (j != coords.size() - 1) {
			xi = bestRoute[j], yi = bestRoute[j + 1];
		}
		else {
			xi = bestRoute[j], yi = bestRoute[0];
		}
		D += sqrt(pow(coords[xi].x - coords[yi].x, 2) + pow(coords[xi].y - coords[yi].y, 2));
	}
	cout << "计算得到 D = " << D << endl;
}

总结

本次实验通过一个旅行商问题的例子,让我很好地理解了遗传算法的设计思路及其用来求解问题的方式。

实验中遇到的最多的问题还是在交叉遗传部分,对于该部分算法的选择与设计花了不少功夫,起初为了方便起见,本想直接采取双亲基因序列各取一部分的方式,但这种方式在 TSP 问题中显然是不适用的,因为这样拼接而成的子代无法保证 TSP 问题所要求的“每个城市各走一遍”的原则。最终在权衡之下,选择了 Subtour Exchange Crossover 算法,代码实现不算复杂且性能良好。

另一个问题就是遗传算法的收敛速度较慢,我在多次进行试验之后发现对于一个 127 个点的数据,算法普遍会在 15000 次之后基本收敛,因此循环次数设定为了 20000 次,以便更好地对数据进行分析。

变异概率的设定同样值得考虑,一般设定在 0.01~0.1,因为变异并不总是有利的,因此不宜过大;同样不宜过小,否则不易得到最优的结果,收敛速度将更慢。