这是我参与更文挑战的第8天,活动详情查看: 更文挑战
内容概要
上前几个篇章,我们学习了OptaPlanner关键的几个概念,也搞学会如何创建一个规划问题求解类,那么今天我们来学习,如何使用OptaPlanner来进行求解。
Solver接口
求解器接口
public interface Solver<Solution_> {
Solution_ solve(Solution_ problem);
...
}
一个Solver实例求解器一次只能解决一个规划问题实例。它是由一个SolverFactory构建的,不需要自己去实现它。
除了那些在javadoc中被明确记载为线程安全的方法外,一个Solver只能从一个线程中访问。
solve()方法占用了当前线程。这可能会导致REST服务的HTTP超时,并且需要额外的代码来并行解决多个数据集。为了避免这些问题,可以使用SolverManager来代替。
问题求解
求解一个问题是相当容易的:
- 一个由求解器配置构建的求解器
- 一个代表规划问题实例的
@PlanningSolution只要将规划问题作为参数提供给solve()方法,它就会返回找到的最佳解决方案。
NQueens problem = ...;
NQueens bestSolution = solver.solve(problem);
例如,在n个皇后中,solve()方法将返回一个NQueens实例,每个皇后被分配到一个行。
四个皇后之谜的最佳解法
solve(Solution)方法可能需要很长的时间(取决于问题的大小和求解器的配置)。求解器智能地访问可能的解决方案的搜索空间,并记住它在解算过程中遇到的最佳解决方案。取决于许多因素(包括问题大小、求解器有多少时间、求解器配置......),这个最佳解可能是,也可能不是一个最佳解。
调用方法solve(solution)的解决方案实例是由Solver改变的,但不要以为它是最佳解决方案,因为问题大小、求解配置、约束规则的编写等等原因,有可能不是一个最优解。
环境配置
环境模式允许检测实现中的常见错误,可以在求解器配置的XML文件中设置环境模式:
<solver xmlns="https://www.optaplanner.org/xsd/solver" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="https://www.optaplanner.org/xsd/solver https://www.optaplanner.org/xsd/solver/solver.xsd">
<environmentMode>FAST_ASSERT</environmentMode>
...</solver>
一个求解器有一个单一的随机实例。一些求解器的配置比其他求解器更多地使用随机实例。例如,模拟退火高度依赖于随机数,而禁忌搜索只依赖于它来处理分数相同的问题。环境模式会影响该随机实例的种子。(看不懂这一块没有关系,后续会单独讲解)
支持以下配置:
FULL_ASSERT
FULL_ASSERT模式开启了所有的断言(例如断言每一步棋的增量分数计算没有被破坏),以便在Move实现、约束、引擎本身等方面出现错误时快速失败。
这种模式是可重复的(见可重复模式)。它也是侵入性的,因为它比非断言模式更频繁地调用calculateScore()方法。
FULL_ASSERT模式是非常慢的(因为它不依赖于增量分数计算)。
NON_INTRUSIVE_FULL_ASSERT
NON_INTRUSIVE_FULL_ASSERT开启了几个断言,以便在Move实现、约束条件、引擎本身等方面的错误时快速失败。
这种模式是可重复的(见可重复模式)。它是非侵入性的,因为它不会比非断言模式更频繁地调用方法calculateScore()。
NON_INTRUSIVE_FULL_ASSERT模式就是龟速(因为它不依赖增量分数计算)。
FAST_ASSERT
FAST_ASSERT模式开启了大多数断言(例如断言一个undoMove的分数与移动前相同),以便在移动实现、约束条件、引擎本身等方面出现错误时快速失败。
这种模式是可重复的(见可重复模式)。它也是侵入性的,因为它比非断言模式更频繁地调用方法calculateScore()。
FAST_ASSERT模式也很慢。
建议写一个测试用例,在FAST_ASSERT模式下对你的规划问题做一个简短的运行。
REPRODUCIBLE (默认的)
可重现模式是默认模式,因为它在开发过程中被推荐。在这种模式下,同一OptaPlanner中的两次运行将以相同的顺序执行相同的代码。这两个运行在每一步都会有相同的结果,除非下面的注解变化。这使能够一致地重现bug。它还允许你在不同的运行中公平地对某些重构(如分数约束的性能优化)进行基准测试。
这个配置十分有用,我们在调试开发过程中,需要复现上一次的求解结果来发现问题。
尽管有可重现的模式,应用程序仍然可能无法完全重现,因为:
使用无序的HashSet(或另一个在JVM运行之间具有不一致顺序的集合)作为规划实体或规划值(但不是正常的问题事实)的集合,特别是在解决方案的实现中,注意用LinkedHashSet代替它。
将依赖时间梯度的算法(最明显的是模拟退火法)与花费时间的终止结合起来。分配的CPU时间有足够大的差异会影响时间梯度值。用Late Acceptance代替Simulated Annealing。或者用步数终止来代替花费时间的终止。(这种情况不常见)
可重现的模式可能比不可重现的模式稍慢一些。如果你的生产环境可以从可重复性中得到一些帮助,请在生产中使用这种模式。
在实践中,如果没有指定种子,这种模式会使用默认的、固定的随机种子,而且它还会禁用某些并发优化。
NON_REPRODUCIBLE
非重现模式可能比重现模式稍快。在开发过程中避免使用它,因为它使调试和修复错误变得非常痛苦。如果你的生产环境不关心可重复性,在生产中使用这种模式。 在实践中,如果没有指定种子,该模式不使用固定的随机种子。
日志级别
我们可以调整日志的级别,通过日志来观察求解器的每一步都做了什么,这对于我们调试问题是很有帮助的。
- error:记录错误,除了那些作为
RuntimeException抛给调用代码的错误。- 如果发生错误,OptaPlanner会快速失败:它抛出一个
RuntimeException的子类,并向调用代码提供详细的信息。它不会把它作为一个错误记录下来,以避免重复的日志信息。除非调用代码明确捕获并吃掉该RuntimeException,否则线程的默认ExceptionHandler会将其记录为错误。同时,代码会被终止。
- 如果发生错误,OptaPlanner会快速失败:它抛出一个
- warn:记录可疑的情况。
- info:记录每个阶段和解算器本身。
- debug:记录每个阶段的每个步骤。
- trace:记录每个阶段的每个步骤的每一个动作。
- trace级别下,会大大降低性能:通常会慢四倍。然而,在开发过程中,它对于发现瓶颈是非常宝贵的。 例如,将其设置为debug级别,以查看阶段性结束的时间和步骤的速度:
INFO Solving started: time spent (3), best score (-4init/0), random (JDK with seed 0).
DEBUG CH step (0), time spent (5), score (-3init/0), selected move count (1), picked move (Queen-2 {null -> Row-0}).
DEBUG CH step (1), time spent (7), score (-2init/0), selected move count (3), picked move (Queen-1 {null -> Row-2}).
DEBUG CH step (2), time spent (10), score (-1init/0), selected move count (4), picked move (Queen-3 {null -> Row-3}).
DEBUG CH step (3), time spent (12), score (-1), selected move count (4), picked move (Queen-0 {null -> Row-1}).
INFO Construction Heuristic phase (0) ended: time spent (12), best score (-1), score calculation speed (9000/sec), step total (4).
DEBUG LS step (0), time spent (19), score (-1), best score (-1), accepted/selected move count (12/12), picked move (Queen-1 {Row-2 -> Row-3}).
DEBUG LS step (1), time spent (24), score (0), new best score (0), accepted/selected move count (9/12), picked move (Queen-3 {Row-3 -> Row-2}).
INFO Local Search phase (1) ended: time spent (24), best score (0), score calculation speed (4000/sec), step total (2).
INFO Solving ended: time spent (24), best score (0), score calculation speed (7000/sec), phase total (2), environment mode (REPRODUCIBLE).
所有花费的时间值都是以毫秒为单位,所有的东西都被记录到SLF4J。
在logback.xml文件中配置org.optaplanner包的日志级别。
<configuration>
<logger name="org.optaplanner" level="debug"/>
...
</configuration>
SolverManager使用
SolverManager是一个用于一个或多个Solver实例的门面,以简化REST和其他企业服务中规划问题的解决。与Solver.solve(...)方法不同。
SolverManager.solve(...)立即返回:它为异步求解安排问题,而不阻塞调用线程。这避免了HTTP和其他技术的超时问题。
SolverManager.solve(...)可以并行地解决同一领域的多个规划问题。
在内部,SolverManager管理着一个调用Solver.solve(...)的求解器线程池,以及一个处理最佳解决方案变更事件的消费者线程池。
在Spring Boot中,SolverManager实例会自动注入到代码中。否则,用create(...)方法建立一个SolverManager实例。
SolverConfig solverConfig = SolverConfig.createFromXmlResource(".../cloudBalancingSolverConfig.xml");
SolverManager<CloudBalance, UUID> solverManager = SolverManager.create(solverConfig, new SolverManagerConfig());
提交给SolverManager.solve(...)方法的每个问题都需要一个唯一的problemId。以后对getSolverStatus(problemId)或terminateEarly(problemId)的调用会使用该problemId来区分规划问题。problemId必须是一个不可变的类,如Long、String或java.util.UUID。
SolverManagerConfig类有一个parallelSolverCount属性,它控制了多少个求解器被并行运行。例如,如果设置为4,提交五个问题时有四个问题立即解决,第五个问题在另一个问题结束时开始。如果这些问题每个问题解决5分钟,第五个问题需要10分钟才能完成。默认情况下,parallelSolverCount被设置为AUTO,这就解决了一半的CPU核心,与求解器的moveThreadCount无关。
要后去最佳解,在求解器正常终止后,使用SolverJob.getFinalBestSolution()。
CloudBalance problem1 = ...;
UUID problemId = UUID.randomUUID();// Returns immediately
SolverJob<CloudBalance, UUID> solverJob = solverManager.solve(problemId, problem1);
...
CloudBalance solution1;try {
// Returns only after solving terminates
solution1 = solverJob.getFinalBestSolution();
} catch (InterruptedException | ExecutionException e) {
throw ...;
}
批量求解
要并行解决多个数据集(受parallelSolverCount限制),需要每个数据集调用solve()。
public class TimeTableService {
private SolverManager<TimeTable, Long> solverManager;
// 立即返回,对每个数据集都调用它
public void solveBatch(Long timeTableId) {
solverManager.solve(timeTableId,
// 调用一次,当求解开始时
this::findById,
// 调用一次,当求解结束时
this::save);
}
public TimeTable findById(Long timeTableId) {...}
public void save(TimeTable timeTable) {...}
}
求解方案监听
当求解器运行时,终端用户正在等待解决方案,用户可能需要等待数分钟或数小时才能收到一个结果。为了向用户展示一切进展顺利,可以通过显示最佳解和到目前为止取得的最佳分数来显示进展。
要处理中间的最佳解决方案,可以使用solveAndListen()。
public class TimeTableService {
private SolverManager<TimeTable, Long> solverManager;
// 立即返回
public void solveLive(Long timeTableId) {
solverManager.solveAndListen(timeTableId,
// 调用一次,当求解开始时
this::findById,
// 多次调用,每一个最佳解决方案的改变都会调用
this::save);
}
public TimeTable findById(Long timeTableId) {...}
public void save(TimeTable timeTable) {...}
public void stopSolving(Long timeTableId) {
solverManager.terminateEarly(timeTableId);
}
}
如果对中间的最佳解决方案已经满意了,并且不想再等待更好的解决方案,就调用SolverManager.terminateEarly(problemId)结束即可。
总结
这一篇章主要学习了Solver的使用,大家在后续使用时,建议使用SolverManager。环境配置这一片段有些内容比较难懂,没有关系后续会单独讲解。
作业
大家可以在之前的例子上使用今天所学习的内容来进行实战,尽快掌握对SolverManger的使用。
结束语
下一篇章,我们将来学习最重要的一个环节,约束评分规则的编写,而且它也是相对复杂的。
创作不易,禁止未授权的转载。如果我的文章对您有帮助,就请点赞/收藏/关注鼓励支持一下吧❤❤❤❤❤❤