引言
遗传算法是一种传统的启发式算法,常用于求解作业调度、路径规划、资源分配等组合优化问题。使用启发式算法求解的步骤一般为:
- 定义好优化目标函数、解的形式和迭代用的算子;
- 进行多轮贪心迭代,在每一轮迭代中保留局部最优解;
- 在满足迭代终止条件后输出一个逼近全局最优的解。
对于遗传算法,目标函数为适应度函数;用一条染色体来代表一个解;迭代用的算子有交叉、变异等。下面我们将介绍一个“到家服务”的派单场景,然后使用遗传算法进行求解。完整代码:github.com/jiafeiwang/…
环境:C++ 11
1. 业务场景介绍
有一个上门服务的业务,用户需提前预约,预约后生成一个任务。要求每天给各任务分配师傅,达到以下目的:
- 给每个用户分配的师傅评分(score)尽可能高;
- 每个师傅在完成所分配的任务时走的路程(distance)尽可能短;
- 师傅完成上一单去下一单的时间间隔(interval)尽可能短。
提供的数据有:
任务(task): taskID、任务的开始时间、任务耗费时长、任务对应用户的地理位置坐标;
师傅(worker): workerID、评分、初始地理位置坐标。
其中位置坐标单位为米,评分取值范围0~1,预约时间粒度为30分钟。
约束条件:
- worker要在预约时间前到达,不能迟到;
- 每个task只需分配一个worker,但一个worker可先后完成多个task;
- 每个worker负责的多个任务在开始时间与结束时间上不能冲突;
- 地理坐标下任意两点的距离为欧式距离;
- 每个worker在路上行驶速度为相同的固定值;
目标函数
需要优化worker评分(score)、路程(distance)、worker相邻两单时间间隔(interval);目标函数为:
worthy = α*Score + β*distance + γ*interval;
式中0<α<1、-1<β<0、-1<γ<0为系数。
2. 算法设计
染色体设计
图-1 染色体结构示意图
染色体设计如图-1所示:每条染色体由多个worker片段构成,每个woker片段上有相同数量的时间槽(slot),在时间槽中进行task分配。由于时间颗粒度为半小时,且按天分配,时间槽数量(slotNum)最多48。本场景下,预约时间6:00~20:00,最晚一个task完成时间为21:00,时间槽个数为30。染色体长度length=slotNum*workerNum
算法中需要对每个slot进行基因(gene)编码,方便在较低计算开销下对task进行相关操作。在本例中我们采用二进制方式编码,单个gene中需要编入slot、workerID、taskID、task所需时长(required)信息。本例中task数量为1978、worker数量为1209,32位无符号整数足够对这些信息进行编码。如果task、worker数量太多,32位不够用可以换64位无符号整数。taskID、workerID、required的起始值为1,如果slot上没分配task,对应的taskID、required值为0。
图-2展示了taskID = 1124, required = 3, workerID = 892, slotID = 3信息的编码值geneVal=1757966211。
图-2 gene编码结构示意图
其中slotId占5位、workerId占12位、taskId占12位、required占3位。static constexpr int SLOT_BITS = 5; //用于时间槽编码的位数,共5位0~32最大值31
static constexpr int WORKER_BITS = 12; //用于workerId编码的位数
static constexpr int TASK_ID_BITS = 12; //用于taskId编码的位数
static constexpr int TASK_REQ_BITS = 3; //用于task required编码的位数
static constexpr int IDX_BITS = SLOT_BITS+WORKER_BITS;
static constexpr int TASK_BITS = TASK_ID_BITS+TASK_REQ_BITS;
struct Gene{
u32 val; //值
Gene();
}
task在各个slot上的基因操作有移动(move):将目标task移动到空的slot上:
void operator>(Gene &gene, Gene &whiteGene){
if(!whiteGene.isWhite()){
std::cout<<"gene "<<whiteGene.val<<" is not white"<<std::endl;
throw 100;
}
// 获取task信息
u32 taskInfo = gene.val>>IDX_BITS<<IDX_BITS;
// 将task信息移入到whiteGene上
whiteGene.val|=taskInfo;
gene.val<<=TASK_BITS;
gene.val>>=TASK_BITS;
}
交换操作(exchange),交换两个slot上task信息,如果其中一个slot上task信息为空,效果等同于move操作:
void operator*(Gene &gene1, Gene &gene2){
u32 taskInfo_1 = gene1.val>>IDX_BITS<<IDX_BITS;
u32 taskInfo_2 = gene2.val>>IDX_BITS<<IDX_BITS;
// 交换task信息
gene1.val<<= TASK_BITS;
gene1.val>>= TASK_BITS;
gene2.val<<= TASK_BITS;
gene2.val>>= TASK_BITS;
gene1.val|=taskInfo_2;
gene2.val|=taskInfo_1;
}
生成初代种群
在满足各个约束条件前提下,为每个task随机分配worker,生成初代种群染色体。单条染色体的生成方式如下:
void Chromosome::InitialChromosome(const std::vector<Task> &tasks, const std::vector<Worker> &workers, int maxCkTimes) {
taskNum = tasks.size();
// tasks 的idx随机打乱,用来随机分配
std::vector<int> idx;
for(int i=0; i<taskNum; i++){
idx.push_back(i);
}
std::random_shuffle(idx.begin(),idx.end());
for(int i=0; i<taskNum; i++){
Task task = tasks[idx[i]];
u32 taskId = task.getId();
u32 startSlot = task.getStartSlot();
u32 required = task.getRequired();
// 随机选取对应时间槽空闲的worker
u32 workerId = randChooseFreeWid(task, startSlot, required, tasks, maxCkTimes);
int endSlot = startSlot+required;
int prod = (workerId-1)*slotNum;
// task worker信息编码进对应gene
for(int slot=startSlot; slot<endSlot; slot++){
genes[prod+slot] = Gene::geneEncode(slot, workerId, taskId, required);
}
taskMp[taskId] = workerId;
}
// 重置tag和fitnessVal
isInitial=true;
fitness(tasks, workers);
}
通过迭代生成多条染色体得到初代种群:
void Ga::initialChromosome(int maxCkTimes) {
for(int i=0; i<heldChromosomeNum; i++){
// 初始化
Chromosome chromosome = Chromosome(slotNum, workerNum);
chromosome.InitialChromosome(tasks, workers, maxCkTimes);
chromosomes.push_back(chromosome);
}
// 更新tag
isInitial = true;
}
遗传操作:
与基因级别的操作类似,染色体的遗传操作有移动(Move)、交换(Exchange)、选择(Select),Move、Exchange操作都在单条染色体基因间进行,不涉及染色体间的操作,归结到基因层面都是调用上面介绍的基因操作代码中重载的“ * ”运算符,只在约束条件判定上有区别:
// 随机选取目标task
u32 targetWid;
// 每propFreq次进行一次randChoosePropWid, Prop开销比Free大
if(propFreq>0&&i%propFreq==propFreq-1){
targetWid = chromosome.randChoosePropWid(task, startSlot, required, tasks, maxCkTimes);
}else{
targetWid = chromosome.randChooseFreeWid(task, startSlot, required, tasks, maxCkTimes);
}
// 记录两个worker下操作前的fitness之和
double fitnessPro = chromosome.workerFitness(workers[rawWid-1],tasks) +
chromosome.workerFitness(workers[targetWid-1],tasks);
mutateSingle(chromosome, taskId, targetWid, startSlot, endSlot);
// 记录操作后的fitness之和
double fitnessAfter = chromosome.workerFitness(workers[rawWid-1],tasks) +
chromosome.workerFitness(workers[targetWid-1],tasks);
// 如果反倒变差了就交换回去,等同于此轮不做操作
if(fitnessAfter<fitnessPro){
mutateSingle(chromosome, taskId, rawWid, startSlot, endSlot);
}
move操作用randChooseFreeWid随机选取workerID,exchange操作用randChoosePropWid随机选取workerID。如果操作后fitnessVal提升了,则对操作进行保留,没有提升再进行一次“*”运算换回到原状态。调用randChooseFreeWid、mutateSingle开销很小,但是调用randChoosePropWid开销较大,我们采用参数propFreq控制每多少次迭代进行一次randChoosePropWid,选取exchange操作所用的workerId。
最后根据fitnessVal对chromosome排序,保留fitnessVal高的染色体:
void Ga::select() {
// 按fitness排序
std::sort(chromosomes.begin(), chromosomes.end(),[](Chromosome chromosome1, Chromosome chromosome2){
return chromosome1.fitnessVal>chromosome2.fitnessVal;
});
// 选取最优
chromosomes.resize(heldChromosomeNum);
}
3. 实验结果
不同迭代次数
条件:
heldChromosomeNum=5,每个epoch下保留5条染色体;
mutateTimes=10,每个epoch下保留的染色体随机抽取10次;
propFreq=10,每次操作抽取一条染色体,在操作中每10次迭代进行1次Exchange操作,其他9次进行Move。
图-3展示单条染色体迭代次数(taskMtTimes)分别为1万、5万、10万的优化结果:
图-3 fitnessVal epoch分布图
taskMtTimes=10w,50个epoch后fitness收敛在0.691。在相同迭代次数下,Exchange操作比Move操作效果要更好,虽然说一次Exchange操作在原理上等效于多次Move操作。如果不进行Exchange操作只单纯进行move操作,结果会稍差,对比如下表所示:
表-1 纯Move操作和Move+1/10Exchange操作结果对比
预留染色体数量
预留染色体数量(heldChromosomeNum)过多或过少都会影响最终结果,单条染色体上操作较少、影响程度越大。
表-2 不同heldChromosomeNum-taskMtTimes结果对比
不同迭代策略
在每个epoch迭代次数相同前提下,两种不同的策略:mutateTimes=20, taskMtTimes=5w与mutateTimes=10, taskMtTimes=10w结果对比如图-4所示,10x10w迭代收敛更快,但最终结果差异不大。两种策略区别在于:mutate级别操作可以并行,而taskMt不能并行。如果使用了并行计算,高的mutateTimes可以缩短运行时间。
图-4 不同迭代策略20x5w与10x10wfitness对比
4. 小结
遗传算法求解思路其实很简单,关键是染色体怎么设计,遗传操作算子如何定义;这两个问题解决之后剩下的就是顺着适应度升高的方向暴力迭代了。