摘要:遗传算法(Genetic Algorithm)是一种基于自然选择过程,模拟生物进化的AI模型,它可以在模拟的生物进化过程中逐代搜索到最优解的一种方法。本文利用遗传算法实现了一个简单的程序来对课程进行排程。
本文分享自华为云社区《如何用遗传算法进行智能排课》,作者: jackwangcumt。
根据百度百科的定义,遗传算法(Genetic Algorithm)是一种基于自然选择过程,模拟生物进化的AI模型,它可以在模拟的生物进化过程中逐代搜索到最优解的一种方法。遗传算法不能直接对问题进行求解,而是需要借助编码规则,将问题中的核心要素抽象为染色体上的基因,并通过基因的交叉、变异等过程,迭代选择优良基因进行繁殖,生成下一代,直到求得最优解或者满意的优化解。目前遗传算法的使用范围非常广泛,常应用于运筹、机器学习、人工智能等领域。
1 遗传算法过程图解
遗传算法核心的任务是要通过编码体系,给出解决方案的染色体表现规则,首先需要随机初始化一定数量的种群(population),而种群则由一定数目的个体(individual)构成。每个个体实际上是染色体(chromosome),可以通过规则计算出适应度(fitness)。初代种群产生之后,按照优胜劣汰的进化原理,逐代进化产生出优秀的后代。
在每代进化过程中,根据个体的适应度大小来选择个体,并借助于自然遗传学的遗传算子(genetic operators)进行交叉(crossover)和变异(mutation),产生新的种群。末代种群中的最优个体经过解码(decoding),可以作为问题近似最优解。退出条件一般为达到最大的迭代次数,比如10000次,另外,就是适应度满足要去,比如达到0.99。基本的流程示意图如下所示:
2 课程编排要求
实际的课程编排,由于涉及到大量的老师、班级、教室和课程等要素,因此非常的复杂,借助遗传算法也可能求不出最优解,而只是求出局部最优解,但是利用遗传算法辅助课程编排仍然是一个非常好的手段。一般来说,课程编排过程中,必须满足几个限制条件,否则,给出的课程安排是无效的,具体说明如下:
- 同一时刻,一个教室只能开设一门课程;
- 一个教室是有座位个数限制的,上课的学生总数不能超过教室座位数;
- 同一个时刻,同一个老师或班级学生只能参与一门课程,而不能参与多个课程;
- 教室分多媒体教室和普通教室,有的课程需要多媒体教室,因此,教室配置必须满足课程要求;
以上4条限制,都满足的情况下,给出的课程安排才是有效的,但请注意,不一定是最优的,它并未考虑优化条件,比如同一个老师,如果在一天按照多门课程,那么显然有点超负荷工作,或者同一门课程,在同一天,连续开设多次,这样对于老师和学生来说,都有点吃不消。
3 课程编排中要素数据结构
前面提到,课程编排过程中,涉及到老师、班级(学生组),教室和课程等要素,下面给出各要素的数据结构说明:
课程 : 课程对象的实现类名为Course ,其中包含课程ID和课程名称2个字段。
教室 : 教室对象的实现类名为Room ,其中包含教室ID、教室名称、座位数、是否是多媒体教室这4个字段。
教师 : 教师对象的实现类名为Professor,其中包含教师ID、教师名称和该教室需要教授的所有课程班(CourseClass)这3个字段。
课程班 : 课程班对象的实现类名为CourseClass ,其中包含课程授课老师、教授的课程、上课的班级、需要的座位数(多个班级人数求和)、是否需要多媒体教室和上课时长这6个字段。此类还提供方法GroupsOverlap(CourseClass c)来判断自己和参数c是否有班级的重叠,同理,方法 ProfessorOverlaps(CourseClass c)可判断自己和参数c是否有老师的重叠。
班级 : 班级对象的实现类名为StudentGroup,其中包含班级ID、班级名称、班级人数和该班级需要上的所有课程班(CourseClass)这4个字段。
染色体表现(Chromosome Representation): 为了应用遗传算法,需要重点考虑如何用基因序列的方式来表示问题的解,一般来说,基因序列是一长串有序的序列,这里可以将多维的课程安排要素通过降维方式,压缩到一维数组上,而数组(后续称为插槽Slots)的长度就是 :
一周上课天数 * 每天的上课时长数 * 教室数
举例来说,一周上课天数假设为5,表示周一到周五,每日上课时长为12小时,比如早上从8点开始上课,晚上到20点结束。而教室数为了简单起见,假设是2个,因此总数组长度为 5 * 12 * 2 = 120 ,这个一维数组中的每个元素,可以放置课程班CourseClass ,而不同的课程班组合就代表了不同的课程排课方案。排课方案可以用如下示意图进行表示:
注意:上述一个插槽Slot代表一个小时单位,也可以表示课程的位置索引,其中可指向具体的课程班CourseClass实例,表示该插槽位有对应的课程安排。
4 适应度Fitness
基于上述的染色体表现,我们需要计算某一个个体的适应度,计算的方法如下:
- 遍历代表染色体表现的一维数组中的每个课程班信息,如果同一时刻教室不存在多个课程的重叠,那么增加适应度分值。
- 遍历代表染色体表现的一维数组中的每个课程班信息,如果课程对于多媒体的要求和教室匹配,那么增加适应度分值。
- 遍历代表染色体表现的一维数组中的每个课程班信息,如果课程参与的班级总人数小于等于教室的座位数,那么增加适应度分值。
- 遍历代表染色体表现的一维数组中的每个课程班信息,如果老师同一时刻不会再多个教室进行授课,那么增加适应度分值。
- 遍历代表染色体表现的一维数组中的每个课程班信息,如果班级同一时刻不会在多个课程班进行学习,那么增加适应度分值。
而这上述5个增加适应度分值的指标,是否满足,可以通过额外的数据结构进行表示,即一个检验规则数组表示,索引为0到4,共5个值。课程重叠,用红色R表示,不重叠,用绿色R表示。教室是否有足够的座位,不足用红色S表示,否则用绿色S表示。教室是否和课程的多媒体要求匹配,不匹配用红色L表示,否则用绿色L表示。课程班中的教师是否有重叠,重叠用红色P表示,否则用绿色P表示。课程班中的班级是否有重叠,重叠用红色G表示,否则用绿色G表示。而个体的适应度值为一个float类型的值,等于 :
score/ ( number_of_classes * 5)
范围为0到1。而对于课程排课场景来说,适应度分值越高,给出的解决方案越好。因此,进化过程中的个体选择要优选适应度分值的个体。
5 遗传算法模拟实现
而对于课程排课场景来说,适应度分值越高,给出的解决方案越好。因此,进化过程中的个体选择要优选适应度分值的个体。下面给出算法模拟的进化过程(选择、交叉和变异)的核心代码片段,示例如下:
List<Schedule> offspring = new List<Schedule>(); offspring.resize(_replaceByGeneration); for (int j = 0; j < _replaceByGeneration; j++) { //随机选择 Schedule p1 = _chromosomes[RandomNumbers.NextNumber() % _chromosomes.Count]; Schedule p2 = _chromosomes[RandomNumbers.NextNumber() % _chromosomes.Count]; //交叉 offspring[j] = p1.Crossover(p2); //变异 offspring[j].Mutation(); }
从上述代码可知,后代offspring根据参数_replaceByGeneration来确定需要进化的个体大小,针对每一个进化的后代,首先通过随机方法选择两个父代p1和p2,首先通过p1.Crossover(p2)获取到交叉操作后的后代,然后在对其进行变异处理offspring[j].Mutation()。其中交叉操作核心代码如下:
public Schedule Crossover(Schedule parent2) { // 根据概率确定是否需要交叉操作 if (RandomNumbers.NextNumber() % 100 > _crossoverProbability) //直接返回 return new Schedule(this, false); //拷贝生成新的chromosome object Schedule n = new Schedule(this, true); int size = (int)_classes.Count; //交叉点数组初始化 List<bool> cp = new List<bool>(); for (int k = 0; k < size; k++) { cp.Add(false); } // 随机确定交叉位置 for (int i = _numberOfCrossoverPoints; i > 0; i--) { while (true) { int p = RandomNumbers.NextNumber() % size; if (!cp[p]) { cp[p] = true; break; } } } Dictionary<CourseClass, int>.Enumerator it1 = _classes.GetEnumerator(); Dictionary<CourseClass, int>.Enumerator it2 = parent2._classes.GetEnumerator(); //交替用父个体组合交叉生产新的个体 bool first = RandomNumbers.NextNumber() % 2 == 0; for (int i = 0; i < size; i++) { it1.MoveNext(); it2.MoveNext(); if (first) { //添加新的课程 n._classes.Add(it1.Current.Key, it1.Current.Value); for (int j = it1.Current.Key.GetDuration() - 1; j >= 0; j--) n._slots[it1.Current.Value + j].AddLast(it1.Current.Key); } else { //添加新的课程 n._classes.Add(it2.Current.Key, it2.Current.Value); for (int j = it2.Current.Key.GetDuration() - 1; j >= 0; j--) n._slots[it2.Current.Value + j].AddLast(it2.Current.Key); } //在交叉位置交替进行课程更新 if (cp[i]) first = !first; } //计算适应度 n.CalculateFitness(); //返回更好的后代 return n; }
由上述代码可知,其中的 _crossoverProbability表示一个交叉的概率,并不是每次调用交叉操作都要执行具体的交叉操作,当随机生成的数大于设定的概率后,才进行交叉具体的操作。其中的交叉点位置也是随机生成的,交叉点的个数通过参数_numberOfCrossoverPoints给定。交叉操作的本质是将两个父类中所指向的课程集合进行随机的组合交换,也就是说,交换的是课程信息以及课程的索引位置。而变异过程相对简单,就是对需要实行变异操作的个体,当满足变异概率时,随机选定一个课程并将其移动到另一个随机选择的插槽(Slots)中。变异过程核心代码如下:
public void Mutation() { //按照概率决定是否需要突变 if (RandomNumbers.NextNumber() % 100 > _mutationProbability) return; int numberOfClasses = (int)_classes.Count; int size = (int)_slots.Count; // 随机决定突变 for (int i = _mutationSize; i > 0; i--) { int count = _classes.Count; int mpos = RandomNumbers.NextNumber() % numberOfClasses; int pos1 = 0; Dictionary<CourseClass, int>.Enumerator it = _classes.GetEnumerator(); if (mpos == 0) { it.MoveNext(); } for (; mpos > 0; it.MoveNext(), mpos--) ; pos1 = it.Current.Value; CourseClass cc1 = it.Current.Key; // 随机确定课程的索引位置 int nr = Configuration.GetInstance().GetNumberOfRooms(); int dur = cc1.GetDuration(); int day = RandomNumbers.NextNumber() % DefineConstantsSchedule.DAYS_NUM; int room = RandomNumbers.NextNumber() % nr; int time = RandomNumbers.NextNumber() % (DefineConstantsSchedule.DAY_HOURS + 1 - dur); int pos2 = day * nr * DefineConstantsSchedule.DAY_HOURS + room * DefineConstantsSchedule.DAY_HOURS + time; for (int k = dur - 1; k >= 0; k--) { //移除不需要的课程 LinkedList<CourseClass> cl = _slots[pos1 + k]; for (LinkedList<CourseClass>.Enumerator it3 = cl.GetEnumerator(); it3.MoveNext(); ) { if (it3.Current == cc1) { cl.Remove(it3.Current); break; } } //移动课程到新的插槽位置 _slots[pos2 + k].AddLast(cc1); } // 更新课程位置 _classes[cc1] = pos2; } CalculateFitness(); }
课程排程,需要提供一些基础的数据,比如教师资源情况、班级情况、课程情况、教室情况等。下面给出资源数据模板:
#prof id = 1 name = 李老师 #end #prof id = 2 name = 张老师 #end #prof id = 3 name = 汪老师 #end ... #course id = 1 name = Web编程 #end #course id = 4 name = 电子商务 #end ... #course id = 8 name = 数据库原理 #end #room name = C101 lab = false size = 80 #end #room name = C102 lab = true size = 90 #end #group id = 1 name = 电商1班 size = 22 #end ... #group id = 4 name = 会计2班 size = 27 #end #class professor = 1 course = 1 duration = 2 group = 1 group = 2 #end ... #class professor = 12 course = 8 duration = 2 group = 3 group = 4 #end
下面给出初始化种群中的个体染色体表现型,具体代码如下,种群大小可通过参数给定,通过循环调用MakeNewFromPrototype()生成不同的个体,并添加到初代种群中。MakeNewFromPrototype方法核心代码如下:
public Schedule MakeNewFromPrototype() { //插槽个数 int size = (int)_slots.Count; //生成新的个体 chromosome Schedule newChromosome = new Schedule(this, true); //随机获取CourseClass信息 LinkedList<CourseClass> c = Configuration.GetInstance().GetCourseClasses(); for (LinkedList<CourseClass>.Enumerator it = c.GetEnumerator(); it.MoveNext(); ) { //随机获取课程位置 int nr = Configuration.GetInstance().GetNumberOfRooms(); int dur = (it.Current).GetDuration(); int day = RandomNumbers.NextNumber() % DefineConstantsSchedule.DAYS_NUM; int room = RandomNumbers.NextNumber() % nr; int time = RandomNumbers.NextNumber() % (DefineConstantsSchedule.DAY_HOURS + 1 - dur); int pos = day * nr * DefineConstantsSchedule.DAY_HOURS + room * DefineConstantsSchedule.DAY_HOURS + time; //将CourseClass信息放于随机的插槽位上 for (int i = dur - 1; i >= 0; i--) newChromosome._slots[pos + i].AddLast(it.Current); //添加课程class信息 newChromosome._classes.Add(it.Current, pos); } //计算适应度 newChromosome.CalculateFitness(); return newChromosome; }
在UI上,采用C# GDI+进行绘制,示例如下:
protected void paint(PaintEventArgs e) { string baseFile = AppDomain.CurrentDomain.BaseDirectory; string filename = baseFile + "GaSchedule.cfg"; Configuration.GetInstance().ParseFile(ref filename); Graphics gac = e.Graphics; Rectangle clientRect = e.ClipRectangle; try { this.Invoke((MethodInvoker)delegate { sx = -GetScrollPos(this.Handle, SB_HORZ); sy = -GetScrollPos(this.Handle, SB_VERT); }); } catch (Exception ex) { Console.WriteLine(ex.Message); sx = 0; sy = 0; } Color newColor = System.Drawing.Color.FromArgb(49, 147, 120); Color bzColor = System.Drawing.Color.FromArgb(49, 147, 120); Color errorColor = System.Drawing.Color.FromArgb(206, 0, 0); Brush bgBrush = System.Drawing.Brushes.White; gac.FillRectangle(bgBrush, clientRect); Font tableHeadersFont = new Font("Microsoft YaHei", 12); Font tableTextFont = new Font("Microsoft YaHei", 10); Font roomDescFont = new Font("Microsoft YaHei", 10); Font criteriaFont = new Font("Microsoft YaHei", 12); SolidBrush classBrush = new SolidBrush(Color.DarkOrchid); classBrush.Color = Color.FromArgb(255, 255, 245); SolidBrush overlapBrush = new SolidBrush(Color.DarkOrchid); overlapBrush.Color = Color.FromArgb(255, 0, 0); HatchBrush myHatchBrush = new HatchBrush(HatchStyle.BackwardDiagonal, Color.Red,Color.Transparent); int nr = Configuration.GetInstance().GetNumberOfRooms(); for (int k = 0; k < nr; k++) { for (int i = 0; i < ROOM_COLUMN_NUMBER; i++) { for (int j = 0; j < ROOM_ROW_NUMBER; j++) { int l = k % 2; int m = k / 2; if (i == 0 && j == 0) { Rectangle rect2 = new Rectangle( sx+ROOM_MARGIN_WIDTH + ROOM_TABLE_WIDTH * l, sy+ROOM_MARGIN_HEIGHT, ROOM_CELL_WIDTH, ROOM_CELL_HEIGHT); gac.DrawRectangle(Pens.Black, rect2); Rectangle rect3 = new Rectangle(rect2.X, rect2.Y + 8, rect2.Width, rect2.Height - 16); string str; str = string.Format("教室:{0}", Configuration.GetInstance().GetRoomById(k).GetName()); TextRenderer.DrawText(gac, str, roomDescFont, rect3, Color.FromArgb(0, 0, 0), TextFormatFlags.VerticalCenter | TextFormatFlags.HorizontalCenter); } if (i == 0 && j > 0) { string str = string.Format("{0} - {1}", 8 + j - 1, 8 + j); Rectangle rect3 = new Rectangle( sx + ROOM_MARGIN_WIDTH + ROOM_TABLE_WIDTH * l , sy + ROOM_MARGIN_HEIGHT + ROOM_CELL_HEIGHT * (j), ROOM_CELL_WIDTH, ROOM_CELL_HEIGHT); TextRenderer.DrawText(gac, str, tableHeadersFont, rect3, Color.FromArgb(0, 0, 0), TextFormatFlags.VerticalCenter | TextFormatFlags.HorizontalCenter); gac.DrawRectangle(Pens.Black, rect3); } if (j == 0 && i > 0) { string[] days = { "周一", "周二", "周三", "周四", "周五" }; Rectangle rect3 = new Rectangle( sx + ROOM_MARGIN_WIDTH + ROOM_TABLE_WIDTH * l + ROOM_CELL_WIDTH * (i), sy + ROOM_MARGIN_HEIGHT, ROOM_CELL_WIDTH, ROOM_CELL_HEIGHT); TextRenderer.DrawText(gac, days[i - 1], tableHeadersFont, rect3, Color.FromArgb(0, 0, 0), TextFormatFlags.VerticalCenter | TextFormatFlags.HorizontalCenter); gac.DrawRectangle(Pens.Black, rect3); } } } } if (_schedule != null) { Dictionary<CourseClass, int> classes = _schedule.GetClasses(); int ci = 0; for (Dictionary<CourseClass, int>.Enumerator it = classes.GetEnumerator(); it.MoveNext(); ci += 5) { CourseClass c = it.Current.Key; int p = it.Current.Value; int t = p % (nr * DAY_HOURS); int d = p / (nr * DAY_HOURS) + 1; int r = t / DAY_HOURS; t = t % DAY_HOURS + 1; int l = r % 2; int m = r / 2; Rectangle rect = new Rectangle( sx + ROOM_TABLE_WIDTH * l + ROOM_MARGIN_WIDTH + d * ROOM_CELL_WIDTH , sy + ROOM_TABLE_HEIGHT * m + ROOM_MARGIN_HEIGHT + t * ROOM_CELL_HEIGHT , ROOM_CELL_WIDTH , c.GetDuration() * ROOM_CELL_HEIGHT); string str = string.Format("{0}\n({1})\n", c.GetCourse().GetName(), c.GetProfessor().GetName()); for (LinkedList<StudentsGroup>.Enumerator it2 = c.GetGroups().GetEnumerator(); it2.MoveNext(); ) { str += (it2.Current).GetName(); str += "/"; } str=str.TrimEnd('/'); if (c.IsLabRequired()) str += "[多媒体]"; gac.FillRectangle(classBrush, rect); gac.DrawRectangle(Pens.Black, rect); TextRenderer.DrawText(gac, str, tableTextFont, rect, Color.FromArgb(0, 0, 0), TextFormatFlags.WordBreak); if (!_schedule.GetCriteria()[ci + 0]) { bzColor = errorColor; TextRenderer.DrawText(gac, "R", tableTextFont, new Point(rect.Left, rect.Bottom - 20), bzColor); gac.FillRectangle(myHatchBrush, rect); } else { TextRenderer.DrawText(gac, "R", tableTextFont, new Point(rect.Left, rect.Bottom - 20), bzColor); } bzColor = newColor; if (!_schedule.GetCriteria()[ci + 1]) { bzColor = errorColor; TextRenderer.DrawText(gac, "S", tableTextFont, new Point(rect.Left + 10, rect.Bottom - 20), bzColor); } else { TextRenderer.DrawText(gac, "S", tableTextFont, new Point(rect.Left + 10, rect.Bottom - 20), bzColor); } bzColor = newColor; if (!_schedule.GetCriteria()[ci + 2]) { bzColor = errorColor; TextRenderer.DrawText(gac, "L", tableTextFont, new Point(rect.Left + 20, rect.Bottom -20), bzColor); } else { TextRenderer.DrawText(gac, "L", tableTextFont, new Point(rect.Left + 20, rect.Bottom - 20), bzColor); } bzColor = newColor; if (!_schedule.GetCriteria()[ci + 3]) { bzColor = errorColor; TextRenderer.DrawText(gac, "P", tableTextFont, new Point(rect.Left + 30, rect.Bottom -20), bzColor); } else { TextRenderer.DrawText(gac, "P", tableTextFont, new Point(rect.Left + 30, rect.Bottom -20), bzColor); } bzColor = newColor; if (!_schedule.GetCriteria()[ci + 4]) { bzColor = errorColor; TextRenderer.DrawText(gac, "G", tableTextFont, new Point(rect.Left + 40, rect.Bottom - 20), bzColor); } else { TextRenderer.DrawText(gac, "G", tableTextFont, new Point(rect.Left + 40, rect.Bottom - 20), bzColor); } } } }
执行后,遗传算法多次迭代后,显示的UI界面如下:
中间环节,还不能得到可行解的迭代过程,可能显示如下的界面:
由于周一的【8-10】和【9-11】有两个课程同时占用了同一个教室,因此,UI上会显示红色的斜纹,同时R(Room)为红色。至此,我们基本实现了一个用C#语言实现的遗传算法,来进行简单的课程排程操作。最后,本博客参考 https://www.codeproject.com/articles/23111/making-a-class-schedule-using-a-genetic-algorithm 一文。