优化工具OptaPlanner使用介绍-以课表设计Time Table问题为例

最近项目里需要使用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

所以设计的类一共有四个:TimeTableLessonRoomTimeSlot

1.3 映射到OptaPlanner

对于这个问题,想要获得的结果是一个TimeTable,所以在构建项目时,对这个类加上@PlanningSolution,标识这是我们想要解决得到的结果。

那如何构成TimeTable呢?细想LessonRoomTimeSlot三者之间的关系,哪些是会变化,哪些是不变的?彼此之间的分配关系又是什么呢?

我们实际上是把RoomTimeSlot分配给Lesson的。一定要明白这个逻辑关系。

(具体谁分配给谁这个逻辑自己要确定好,我感觉这个不是固定的,一个问题也不是只有一种建模方式,目前是参考着官网给出的方案进行说明。)

所以说,RoomTimeSlot是不会变化的,Room的名字一直保持如此,TimeSlot表示的时间段也一直如此,而变化的是Lesson,因为它所在的Room和所在的TimeSlot是需要被分配的。

那么,会变化的Lesson类就用@PlanningEntity标识。

另外,在TimeTable类中,也需要标识出来这三个部分。会变化的Lesson@PlanningEntityCollectionProperty标识,不会变化的RoomTimeSlot@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使用的一些总结了,如果有什么不对的请指正。

我觉得最难的一步就是如果对你想要解决的问题进行建模,建模后就要明确互相之间的约束关系,然后就可以确立整个问题了。

建议可以多看官网和例程。