注:本文章基于学校优化计算方法选修课程的实验内容,可供研究相关领域的初学者作为入门学习以及编程实现的案例参照。
一. 指派问题(任务分配问题):
分为以下几类:
- 线性任务分配问题:P是二元组(a,b)的集合,其中a和b分别是集合A和B中的元素。C是某一函数,并满足特定约束条件,例如:A的每一个元素必须在P中出现一次,或者B的每一个元素必须在P中出现一次,或者以上二者都必须满足。线性任务分配问题的目标就是最大化或者最小化C(a,b)之和。该问题是线性的,因为代价函数C()只取决于特定的二元组(a,b),而与其它的二元组没有任何关系。
- 二次任务分配问题:给定n家工厂和n个库房。每个库房被分配给一家工厂。很显然有n!种不同的分配组合。每家工厂和它的库房间的代价函数被定义为二者间的距离和物流量的乘积。如何分配以使所有的代价总和最小?
举例
- 有一些员工要完成一些任务。各个员工完成不同任务所花费的时间都不同。每个员工只分配一项任务。每项任务只被分配给一个员工。怎样分配员工与任务以使所花费的时间最少?
- 婚配问题:有一些男人和一些女人,各位男人如果和某位女人结婚则其婚姻稳定程度具有不同的稳定数值。如何匹配可以使得所有配对的稳定值总和最大?也称婚姻匹配问题。
--------维基百科
本文代码实现面向的问题是基础的员工分配问题,实验用的素材是学校选修课提供的100x100的矩阵。
二. 遗传算法:
具体算法细节在此不做过多赘述,网上有很多。参考建议:遗传算法的简介与应用 - 知乎。本文的具体实现采用顺序编码、循环交叉、交换变异、GENITOR更新策略。
三. 编程实现:
话不多说直接看代码~
代码的主体一共两个类,Population和DNA。
// Population类是用来存放DNA的集合,是Ga算法执行的主体。
// 内部定义了初始化、迭代、变异以及交叉的方法。
class Population{
private int numOfMum; // 群体中DNA的个数
private int length; // 单个DNA长度
public int[][] matrix; // 工人的效用矩阵
private int gen; // 迭代次数
private int A = 0; // 历史最优解
private int f = 0; // 当前最优解
public List<DNA> com= new ArrayList<>(); // 同来储存所有DNA
private String path = "/Users/Henry/Desktop/task/data/assign100_.csv";
// 构造方法
public Population(int numOfMum, int length, int gen) {
this.numOfMum = numOfMum;
this.length = length;
this.gen = gen;
for (int i = 0; i < this.numOfMum; i++) {
this.com.add(new DNA(setRandS()));
}
}
// 迭代求解
public void iterate(){
ReadCsv rc = new ReadCsv(this.path);
this.matrix = rc.getArray(100);
for (int i = 0; i < this.gen; i++) {
// 第一步,先交叉
cross();
// 第二步,变异
var();
// 第三步,排个序
this.com.sort(new Comparator<DNA>() {
// 重写比较器的比较方法
@Override
public int compare(DNA o1, DNA o2) {
o1.countVal(matrix);
o2.countVal(matrix);
return o2.val - o1.val;
}
});
// 第四步,使用GENITOR策略进行更新,取种群前numOfMum个个体
int curSize = this.com.size();
for (int j = 0; j < curSize - this.numOfMum; j++) {
this.com.remove(this.numOfMum);
}
// 第五步,更新f、A
this.f = this.com.get(0).val;
if (this.f > this.A){
this.A = this.f;
}
// 打印日志
if((i + 1) % 10 == 0){
System.out.println("第" + (i + 1) + "代: 历史最优值A: " + this.A + "当前最优值f: " + this.f);
}
}
}
// setRandS是随机初始化DNA的方法
public int[] setRandS(){
ArrayList<Double> arr1 = new ArrayList<>();
ArrayList<Double> arr2 = new ArrayList<>();
int[]arr = new int[this.length];
// 随机初始化arr1
for (int i = 0; i < this.length; i++) {
arr1.add(Math.random());
}
// copy arr1给arr2
for (int i = 0; i < this.length; i++) {
arr2.add(arr1.get(i));
}
arr2.sort(Comparator.naturalOrder());
for (int i = 0; i < this.length; i++) {
int idx = arr2.indexOf(arr1.get(i));
arr[idx] = i;
}
return arr;
}
// 变个异
public void var(){
Random random = new Random();
for (int i = 0; i < this.com.size(); i++) {
if(Math.random() < 0.05){
int rand1 = random.nextInt(this.length);
int rand2 = random.nextInt(this.length);
while(rand1 == rand2){
rand2 = random.nextInt(this.length);
}
int temp;
temp = this.com.get(i).dnaList[rand1];
this.com.get(i).dnaList[rand1] = this.com.get(i).dnaList[rand2];
this.com.get(i).dnaList[rand2] = temp;
}
}
}
// 杂交水稻
public void cross(){
// 设置随机数用来决定是否进行交叉
Random random = new Random();
int prtPairs = this.com.size() / 2;
int flg = this.com.size();
for (int i = 0; i < prtPairs; i++) {
int rnd1 = random.nextInt(flg);
DNA prt1 = this.com.get(rnd1);
int rnd2 = random.nextInt(flg);
while(rnd2 != rnd1){
rnd2 = random.nextInt(flg);
}
DNA prt2 = this.com.get(rnd2);
if(Math.random() < 0.9){
ArrayList<DNA> dnaLis = doCross(prt1, prt2);
this.com.add(dnaLis.get(0));
this.com.add(dnaLis.get(1));
}
}
}
// 实现两个DNA进行循环交叉的方法
public ArrayList<DNA> doCross(DNA prt1, DNA prt2) {
int startFlag = 0;
int curFlag = startFlag;
// allPth用来存储所有循环交叉的记录, curRcd用来存储单次的记录
ArrayList<ArrayList<Integer>> allRcd = new ArrayList<>();
ArrayList<Integer> curRcd = new ArrayList<>();
// 分别定义两个顺序表以及链表存储父代信息,后续进行交叉循环会用到
LinkedList<Integer> lkPrt1 = new LinkedList<>();
ArrayList<Integer> arPrt1 = new ArrayList<>();
ArrayList<Integer> arPrt2 = new ArrayList<>();
for (int i = 0; i < prt1.dnaList.length; i++) {
lkPrt1.add(prt1.dnaList[i]);
}
for (int i = 0; i < prt1.dnaList.length; i++) {
arPrt1.add(prt1.dnaList[i]);
arPrt2.add(prt2.dnaList[i]);
}
// 循环交叉
while(!lkPrt1.isEmpty()){
if(arPrt1.get(startFlag).equals(arPrt2.get(curFlag))){
curRcd.add(curFlag);
allRcd.add(curRcd);
lkPrt1.remove(lkPrt1.indexOf(arPrt1.get(curFlag)));
if(!lkPrt1.isEmpty()){
curRcd = new ArrayList<>();
startFlag = arPrt1.indexOf(lkPrt1.get(0));
curFlag = startFlag;
}
}else{
curRcd.add(curFlag);
int prt1Num = arPrt1.get(curFlag);
lkPrt1.remove(lkPrt1.indexOf(prt1Num));
int prt2Num = arPrt2.get(curFlag);
curFlag = arPrt1.indexOf(prt2Num);
}
}
// 得到可进行循环交叉的点位后,创建两个新的DNA子代
DNA kid1 = new DNA(prt1.dnaList.clone());
DNA kid2 = new DNA(prt2.dnaList.clone()); // 注意这里!!!一定要用dnaList.clone()方法进行拷贝再传入,否则,哼哼~
// 奇数循环保留,偶数循环交叉
for (int i = 0; i < allRcd.size() / 2; i++) {
for (int j = 0; j < allRcd.get(2 * i).size(); j++) {
kid1.dnaList[allRcd.get(2 * i).get(j)] = prt2.dnaList[allRcd.get(2 * i).get(j)];
kid2.dnaList[allRcd.get(2 * i).get(j)] = prt1.dnaList[allRcd.get(2 * i).get(j)];
}
}
// 将两个子代包装一下再返回
ArrayList<DNA> dnas = new ArrayList<>();
dnas.add(kid1);
dnas.add(kid2);
return dnas;
}
}
Population类中实现的难点主要在用来执行循环交叉的Docross方法。在手动实现时,要特别注意应用到的各种数据结构之间的耦合关系;此外,
DNA kid1 = new DNA(prt1.dnaList.clone());
DNA kid2 = new DNA(prt2.dnaList.clone());
这两句代码在写的时候一定要注意初始化构造子代DNA对象时要传入父代的拷贝,要不然会导致子代与父代共用一个DNA,现象便是随着迭代共用着相同地址的DNA信息的个体会越来越多,跑不了多少代所有DNA就会全部趋同。
开始时我便疏忽了这些不起眼的小细节,在写完代码运行时发现运行日志里适值变化很快,迭个几十代就不再变了,我还一度以为是算法性能太好了哈哈^_^。后来咋想咋不对,慢慢调试才发现好家伙很多个体DNA的地址一毛一样,这哪是遗传这是直接克隆了,然后往这个方向找才发现症结所在。改正之后算法最终的收敛解也提高了不少。细节决定成败呀~
哦对差点忘了还有DNA类的实现:
class DNA{
public int[] dnaList;
// val代表此DNA当前排布方式的适应值
public int val;
public DNA(int[] dnaList) {
this.dnaList = dnaList;
}
// 计算当前适应值
public void countVal(int[][] fm){
this.val = 0;
for (int i = 0; i < dnaList.length; i++) {
this.val += fm[i][dnaList[i]];
}
}
}
运行效果:
码完,收工∠(°ゝ°)