OptaPlanner:按学生-单科目分组并单独执行penalize的实现问题
我的PlanningEntity定义如下:
@PlanningEntity public class Participation { @PlanningId private long id; private Student student; private Lesson lesson; @PlanningVariable(valueRangeProviderRefs = "possibleEnrollments") private Boolean enrolled; }
其中Lesson包含方法public List<Subject> getSubjects()(支持多科目关联)。
我需要实现一个约束:按学生+单科目的组合分组所有Participation,比如课程A关联科目1、课程B关联科目2、课程C关联科目1和2时,要得到两个(Student, List<Participation>)分组:
- 科目1:包含课程A和C的Participation
- 科目2:包含课程B和C的Participation
但直接用以下方式无法实现需求:
constraintFactory .forEach(Participation.class) .groupBy(Participation::getStudent, Participation::getSubjects, toList())
因为它会按Participation::getSubjects返回的整个List分组,导致关联多科目的课程C会被单独分成一组,不符合预期。
目前我通过自定义UniConstraintCollector实现了部分功能:
return constraintFactory .forEach(Participation.class) .groupBy(Participation::getStudent, new UniConstraintCollector<Participation, Map<Subject, List<Participation>>, Map<Subject, List<Participation>>>() { @Override public Supplier<Map<Subject, List<Participation>>> supplier() { return HashMap::new; } @Override public BiFunction<Map<Subject, List<Participation>>, Participation, Runnable> accumulator() { return (map, participation) -> { for(Subject s : participation.getSubjects()) { if(!map.containsKey(s)) { map.put(s, new ArrayList<>()); } map.get(s).add(participation); } return () -> { for(Map.Entry<Subject, List<Participation>> entry: map.entrySet()) { entry.getValue().remove(participation); } }; }; } @Override public Function<Map<Subject, List<Participation>>, Map<Subject, List<Participation>>> finisher() { return Function.identity(); } }).penalize(("name", HardSoftScore.ONE_SOFT, (student, participationMap) -> { ... });)
但这个方案只能对所有科目-列表组合统一计算惩罚值,我希望对每个科目-列表组合单独执行penalize,有没有实现方法?
另外,我尝试从Subject角度实现,将Subject设为PlanningSolution的ProblemFact,使用如下代码:
return constraintFactory .forEach(Subject.class) .join(constraintFactory.forEach(Participation.class), JoinerSupport.getJoinerService().newBiJoiner(List::of, JoinerType.INTERSECTING, Participation::getSubjects) )
但使用JoinerType.INTERSECTING时,会在AbstractLeftHandSide中抛出"Unsupported Joiner Type"异常。
方法一:自定义Collector输出扁平化分组项
改写UniConstraintCollector,让它直接输出(Student, Subject, List<Participation>)的扁平化流,而非Map,这样就能对每个科目-学生组合单独处理:
public class StudentSubjectParticipationCollector implements UniConstraintCollector<Participation, Map<Student, Map<Subject, List<Participation>>>, Stream<Tuple3<Student, Subject, List<Participation>>>> { @Override public Supplier<Map<Student, Map<Subject, List<Participation>>>> supplier() { return HashMap::new; } @Override public BiFunction<Map<Student, Map<Subject, List<Participation>>>, Participation, Runnable> accumulator() { return (studentMap, participation) -> { Student student = participation.getStudent(); studentMap.computeIfAbsent(student, k -> new HashMap<>()); Map<Subject, List<Participation>> subjectMap = studentMap.get(student); List<Subject> subjects = participation.getSubjects(); subjects.forEach(subject -> { subjectMap.computeIfAbsent(subject, k -> new ArrayList<>()).add(participation); }); // 撤销操作 return () -> { subjects.forEach(subject -> { subjectMap.get(subject).remove(participation); if (subjectMap.get(subject).isEmpty()) { subjectMap.remove(subject); } }); if (subjectMap.isEmpty()) { studentMap.remove(student); } }; }; } @Override public Function<Map<Student, Map<Subject, List<Participation>>>, Stream<Tuple3<Student, Subject, List<Participation>>>> finisher() { return studentMap -> studentMap.entrySet().stream() .flatMap(studentEntry -> studentEntry.getValue().entrySet().stream() .map(subjectEntry -> Tuple3.of(studentEntry.getKey(), subjectEntry.getKey(), subjectEntry.getValue()))); } // 静态工厂方法方便调用 public static StudentSubjectParticipationCollector toStudentSubjectParticipations() { return new StudentSubjectParticipationCollector(); } }
使用时通过flatMap展开每个分组,再单独执行penalize:
constraintFactory.forEach(Participation.class) .groupBy(toStudentSubjectParticipations()) .flatMap(Function.identity()) .penalize("每个学生-科目组合单独惩罚", HardSoftScore.ONE_SOFT, (tuple) -> { Student student = tuple.getFirst(); Subject subject = tuple.getSecond(); List<Participation> participations = tuple.getThird(); // 针对当前学生-科目组合计算惩罚值 return calculatePenalty(participations); });
方法二:拆分Participation的科目关联(推荐)
如果业务允许,在问题构建阶段将关联多科目的Participation拆分为多个单科目实例(比如课程C关联科目1和2,就拆成两个Participation,分别关联科目1和科目2)。这样就能直接用标准groupBy实现需求:
constraintFactory.forEach(Participation.class) .groupBy(Participation::getStudent, Participation::getSubject, toList()) .penalize("学生-科目组合惩罚", HardSoftScore.ONE_SOFT, (student, subject, participations) -> calculatePenalty(participations));
这种方式避免了自定义Collector的复杂度,规则更直观,也更符合OptaPlanner的约束设计习惯。
关于INTERSECTING JoinerType的问题
OptaPlanner的JoinerType.INTERSECTING目前不支持集合与集合的关联匹配,所以会抛出不支持的异常。如果要从Subject出发构建约束,可以改用filter方式:
constraintFactory.forEach(Subject.class) .join(Participation.class) .filter((subject, participation) -> participation.getSubjects().contains(subject)) .groupBy((subject, participation) -> participation.getStudent(), (subject, participation) -> subject, toList((subject, participation) -> participation)) .penalize("学生-科目组合惩罚", HardSoftScore.ONE_SOFT, (student, subject, participations) -> calculatePenalty(participations));
但这种方式性能略低于提前拆分Participation的方案,因为每次都要执行contains检查。
内容的提问来源于stack exchange,提问作者Radical

