优化工具OptaPlanner使用介绍-以课表设计Time Table问题为例
OptaPlanner使用
最近项目里需要使用optaplanner,学习了一下,来做个总结。
官网:OptaPlanner官网
例程下载:OptaPlanner例子
OptaPlanner Quick Start
参考:OptaPlanner相关资料
0. 总述
OptaPlanner是用于规划问题的优化,也就是对于有约束问题,如何设计实现目标函数的最优。官网中提到了很多可以用OptaPlanner求解的优化问题,比如员工排班、课表设计、资源分配等。
OptaPlanner整体的逻辑是对问题建模后,对某个方案进行评估,得到一个分数,越高的分数说明方案越好。
我主要通过quick start中spring boot下的课表规划Time Table问题说起。
1. 问题建立
1.1 问题说明
我们为学校优化设计出一个适合学生和教师的时间表,每个课程Lesson
需要被分配时间 Timeslot
、房间Room
、教师Teacher
、学生Student
。
具体来说,这个问题有以下的要求:
- 一个Room同时至多只有一个Lesson
- 一个Teacher同时至多教一个Lesson
- 一个Student同时至多参加一个Lesson
- 一个Teacher更倾向于在同一个Room教所有的Lesson
- 一个Teacher更倾向于教的Lesson之间没有时间间隔
1.2 问题建模与类的设计
首先要明确我们的目标是为了获得什么? -> 一个时间表TimeTable
如何获得一个Time Table? -> 通过把每个Lesson分到不同的Time Slot和Room
所以我们能设计得到如下的类图:
通过这些类的具体分配关系,可以得到TimeTable
。
所以设计的类一共有四个:TimeTable
、Lesson
、Room
、TimeSlot
1.3 映射到OptaPlanner
对于这个问题,想要获得的结果是一个TimeTable
,所以在构建项目时,对这个类加上@PlanningSolution
,标识这是我们想要解决得到的结果。
那如何构成TimeTable
呢?细想Lesson
、Room
、TimeSlot
三者之间的关系,哪些是会变化,哪些是不变的?彼此之间的分配关系又是什么呢?
我们实际上是把Room
和TimeSlot
分配给Lesson
的。一定要明白这个逻辑关系。
(具体谁分配给谁这个逻辑自己要确定好,我感觉这个不是固定的,一个问题也不是只有一种建模方式,目前是参考着官网给出的方案进行说明。)
所以说,Room
和TimeSlot
是不会变化的,Room
的名字一直保持如此,TimeSlot
表示的时间段也一直如此,而变化的是Lesson
,因为它所在的Room
和所在的TimeSlot
是需要被分配的。
那么,会变化的Lesson
类就用@PlanningEntity
标识。
另外,在TimeTable
类中,也需要标识出来这三个部分。会变化的Lesson
用@PlanningEntityCollectionProperty
标识,不会变化的Room
和TimeSlot
用@ProblemFactCollectionProperty
标识。
1.4 类设计相关代码
构造器啥的我就不放了,根据文章开头的链接可以直接下载源码的,这里主要是为了说明注解的使用和类的设计,所以主要放一下每个类的属性。
Room类:
@Entity
public class Room {
@PlanningId
@Id
@GeneratedValue
private Long id;
private String name;
}
TimeSlot类:
public class Timeslot {
@PlanningId
@Id
@GeneratedValue
private Long id;
private DayOfWeek dayOfWeek;
private LocalTime startTime;
private LocalTime endTime;
}
Lesson类:
@PlanningEntity
public class Lesson {
@PlanningId
@Id
@GeneratedValue
private Long id;
private String subject;
private String teacher;
private String studentGroup;
@PlanningVariable
@ManyToOne
private Timeslot timeslot;
@PlanningVariable
@ManyToOne
private Room room;
}
TimeTable类:
@PlanningSolution
public class TimeTable {
@ValueRangeProvider
@ProblemFactCollectionProperty
private List<Timeslot> timeslotList;
@ValueRangeProvider
@ProblemFactCollectionProperty
private List<Room> roomList;
@PlanningEntityCollectionProperty
private List<Lesson> lessonList;
@PlanningScore
private HardSoftScore score;
}
注意注解的使用。
这些类都放在domain文件夹下。
至此,问题建模就完成了。
2. 约束建立
我们再回顾一下前面提到的问题约束:
- 一个Room同时至多只有一个Lesson
- 一个Teacher同时至多教一个Lesson
- 一个Student同时至多参加一个Lesson
- 一个Teacher更倾向于在同一个Room教所有的Lesson
- 一个Teacher更倾向于教的Lesson之间没有时间间隔
从这些描述也可以看出,这些约束的严格性是有等级的。前三个是比较严格的要求,称为硬约束Hard,后面两个的严格性低一些,称为软约束Soft。不同的约束会影响这个solution
接下来,我们就要用代码描述这些约束了。
2.1 约束类
一个约束类里就可以写好所有的约束,是通过继承ConstraintProvider
接口,并重写其中的defineConstraints
方法实现的,比如该问题的约束:
public class TimeTableConstraintProvider implements ConstraintProvider {
@Override
public Constraint[] defineConstraints(ConstraintFactory constraintFactory) {
return new Constraint[] {
// Hard constraints
roomConflict(constraintFactory),
teacherConflict(constraintFactory),
studentGroupConflict(constraintFactory),
// Soft constraints
teacherRoomStability(constraintFactory),
teacherTimeEfficiency(constraintFactory),
studentGroupSubjectVariety(constraintFactory)
};
}
}
defineConstraints方法里就可以把所有设定的约束方法放进去。
2.2约束方法编写
具体的,约束方法会返回一个Constraint类的对象,也是有一些编写规范的,包括约束的设定和分值的计算。
以第一个约束的代码为例,来说明一下怎么写一个约束。
Constraint roomConflict(ConstraintFactory constraintFactory) {
// A room can accommodate at most one lesson at the same time.
return constraintFactory
// Select each pair of 2 different lessons ...
.forEachUniquePair(Lesson.class,
// ... in the same timeslot ...
Joiners.equal(Lesson::getTimeslot),
// ... in the same room ...
Joiners.equal(Lesson::getRoom))
// ... and penalize each pair with a hard weight.
.penalize(HardSoftScore.ONE_HARD)
.asConstraint("Room conflict");
}
约束是通过ConstraintFactory构造出来的,直接通过这个类下的各种方法就能设定约束。
forEach(T)
是遍历所有给定的T类型的实例,后面也是对这个类的实例进行进一步的约束。
join
和SQL语句中join的含义类似,就是连结两个实例。
penalize
就是说如果有满足的实例,那就要对这个方案减分。
所以说这段代码的含义就是:选出每两个timeslot相同,而且room相同的lesson,如果存在这样的一对lesson,那就要减分。
具体关于penalize
里这个分数的设定,以及加分reward()
分数的设定方法,参考官网:分值的计算。
定义一个constraint还有许多其他会用到的方法,具体参考官网:约束的设定
简单说一下其他的方法:
filter
方法就是进一步过滤出符合要求的实例,比如:
.filter((lesson1, lesson2) -> lesson1.getRoom() != lesson2.getRoom())
那就是选出两个Room不同的Lesson。
至此,约束就已经都建立好了。
3. 约束测试
可以对已经建立好的约束进行验证。
首先创建一个验证器:
@Autowired
ConstraintVerifier<TimeTableConstraintProvider, TimeTable> constraintVerifier;
然后可以创建一些数据:
Lesson firstLesson = new Lesson(1, "Subject1", "Teacher1", "Group1", TIMESLOT1, ROOM1);
Lesson conflictingLesson = new Lesson(2, "Subject2", "Teacher2", "Group2", TIMESLOT1, ROOM1);
Lesson nonConflictingLesson = new Lesson(3, "Subject3", "Teacher3", "Group3", TIMESLOT2, ROOM1);
之后验证这些数据对于某个约束,或者所有约束的打破与否:
constraintVerifier.verifyThat(TimeTableConstraintProvider::roomConflict)
.given(firstLesson, conflictingLesson, nonConflictingLesson)
.penalizesBy(1);
verifyThat
里就写上要验证哪个约束,如果不填的话那就是对所有约束进行验证。
given
里就写上对哪些实例进行验证。
penalizesBy
就是对打破的约束进行扣分,这个里面填的分数要和你设定的实例与约束的关系相对应好。
比如说这个例子里,有两个Lesson都是是在同一TimeSlot的同一Room,所以根据前面设定的roomConflict方法,会扣一分,因此penalizesBy里写的是1分。
大家可以填填别的分数看看报错提醒就明白了。
4. 总结
以上就是我对OptaPlanner使用的一些总结了,如果有什么不对的请指正。
我觉得最难的一步就是如果对你想要解决的问题进行建模,建模后就要明确互相之间的约束关系,然后就可以确立整个问题了。
建议可以多看官网和例程。