谜题框架(A Puzzle Framework)
递归算法的并行化的一种引用就是解决一些谜题,这些谜题都需要找出一系列的操作从初始状态转换到目标状态,例如类似与“搬箱子”,“四色方柱”和其他棋牌谜题。
我们将谜题定义为:包含了一个初始位置,一个目标位置,以及用于判断是否是有效移动的规则集。规则集包含两个部分:计算从指定位置开始的所有合法移动,以及每次移动的结果位置。
8-13给出了表示谜题的抽象类,其中的类型参数P和M表示位置类和移动类。根据这个接口,我们可以写一个简单的串行求解程序,该程序将在谜题空间(Puzzle Space)中查找,直到找到一个解答或者找遍了整个空间都没有发现答案。
// 8-13 表示“搬箱子”之类谜题的抽象类
public interface Puzzle <P, M> {
P initialPosition(); //设置初始位置
boolean isGoal(P position); //判断是否目标位置
Set<M> legalMoves(P position); //判断是否有效移动的规则集
P move(P position, M move); //移动位置
}
在8-14中的PuzzleNode代表通过一系列的移动到达的一个位置,其中保存了到达该位置的移动以及前一个PuzzleNode,只要沿着PuzzleNode链接逐步回溯,就可以重新构建出到达当前位置的移动序列。
// 8-14 用于谜题解决框架的链表节点
@Immutable
static class PuzzleNode<P, M> {
final P pos; //当前位置
final M move; //记录了前一个Node到这个Node的移动
final PuzzleNode<P, M> prev; //前一个Node
PuzzleNode(P pos, M move, PuzzleNode<P, M> prev) {
this.pos=pos;
this.move=move;
this.prev=prev;
}
List<M> asMoveList() { //回溯链接
List<M> solution = new LinkedList<M>();
for (PuzzleNode<P, M> n = this; n.move != null; n = n.prev)
solution.add(0, n.move);
return solution;
}
}
在8-15中给出了谜题框架的串行解决方案,它在谜题空间中执行一个深度优先搜索,当找到解答方案(不一定是最短的解决方案)后结束搜索。
为了避免无限循环,在串行版本中引入了一个Set对象,其中保存了之前已经搜索过的所有位置。
// 8-15 串行的谜题解答器
public class SequentialPuzzleSolver<P,M> {
private final Puzzle<P,M> puzzle;
private final Set<P> seen=new HashSet<P>(); //已遍历位置的集合
public SequentialPuzzleSolver(Puzzle<P,M> puzzle){
this.puzzle=puzzle;
}
public List<M> solve(){
P pos=puzzle.initialPosition(); //设置初始位置
return search(new PuzzleNode<P,M>(pos,null,null)); //返回回溯后的路径
}
private List<M> search(PuzzleNode<P,M> node){
if(!seen.contains(node.pos)){ //如果没走过则加入,走过则返回null
seen.add(node.pos);
if(puzzle.isGoal(node.pos))
return node.asMoveList(); //如果是目标位置,则返回路径
for(M move:puzzle.legalMoves(node.pos)){ //legalMoves是存放该位置所有有效的移动
P pos=puzzle.move(node.pos, move); //pos记录移动后的位置
PuzzleNode<P,M> child=new PuzzleNode<P,M>(pos,move,node);
List<M> result=search(child); //深度优先算法
if(result!=null)
return result; //只要不是找到目标位置都返回null
}
}
return null;
}
}
通过修改解决方案以利用并发性,可以以并发方式来计算下一步移动以及目标条件,因为计算某次移动的过程很大程度上与计算其他移动的过程是相互独立的。(之所以是很大程度上,是因为在各个任务之间会共享一些可变状态,例如已遍历位置的集合)如果有多个处理器可用,这将减少寻找解决方案所花费的时间。
在8-16中使用了一个内部类SolverTask,这个类继承了PuzzleNode并实现了Runnable,大多数工作都是在run方法中完成的:首先计算下一步可能到达的所有位置,并去掉已经到达的所有位置,然后判断(这个任务或者其他某个任务)是否已经成功地完成,最后将尚未搜索过的位置提交给Executor。
// 8-16 并发的谜题解答题
public class ConcurrentPuzzleSolver <P, M> {
private final Puzzle<P, M> puzzle;
private final ExecutorService exec;
private final ConcurrentMap<P, Boolean> seen; //用线程安全的Map来记录该点是否被遍历过
//ValueLatch中使用CountDownLatch来实现所需的闭锁行为,并且使用锁定机制来确保解答只会被设置一次
//设置为protected,这样包中其他类才能调用
protected final ValueLatch<PuzzleNode<P, M>> solution = new ValueLatch<PuzzleNode<P, M>>();
public ConcurrentPuzzleSolver(Puzzle<P, M> puzzle) {
this.puzzle = puzzle;
this.exec = initThreadPool();
this.seen = new ConcurrentHashMap<P, Boolean>();
/*
* 要避免处理RejectedExecutionException,需要将拒绝处理器设置为“抛弃已提交的任务”。
* 然后,所有未完成的任务最终将执行完成,并且在执行任何新任务时都会失败,从而使Executor结束。
* (如果任务运行的时间过长,那么可以中断它们而不是等它们完成)。
*/
if (exec instanceof ThreadPoolExecutor) {
ThreadPoolExecutor tpe = (ThreadPoolExecutor) exec;
tpe.setRejectedExecutionHandler(new ThreadPoolExecutor.DiscardPolicy());//当新提交的任务无法保存到队列中等待执行时,DiscardPolicy会悄悄抛弃该任务。
}
}
private ExecutorService initThreadPool() {
return Executors.newCachedThreadPool();
}
public List<M> solve() throws InterruptedException {
try {
P p = puzzle.initialPosition();
exec.execute(newTask(p, null, null));
//阻塞直到找到第一个解答,阻塞方法在闭锁solution中,在此之前,主线程需要等待,ValueLatch提供了一种方式来保存这个值,只有第一次调用才会设置它。
//在第一次调用setValue时,将更新解答方案,并且CountDownLatch会递减,从getValue中释放主线程。于是开始执行下面
PuzzleNode<P, M> solnPuzzleNode = solution.getValue();
return (solnPuzzleNode == null) ? null : solnPuzzleNode.asMoveList(); //返回解答方案的路径
} finally {
exec.shutdown(); //第一个找到解答的线程还会关闭Executor,从而阻止接受新的任务。
}
}
protected Runnable newTask(P p, M m, PuzzleNode<P, M> n) {
return new SolverTask(p, m, n);
}
protected class SolverTask extends PuzzleNode<P, M> implements Runnable { //继承了Puzzle实现了Runnable
SolverTask(P pos, M move, PuzzleNode<P, M> prev) {
super(pos, move, prev); //调用PuzzleNode 的构造函数
}
/*
* 首先计算下一步可能到达的所有位置,并去掉已经到达的所有位置,
* 然后判断(这个任务或者其他某个任务)是否已经成功地完成,最后将尚未搜索过的位置提交给Executor。
*/
public void run() {
//每个任务首先查询solution闭锁,找到一个解答就停止。
if (solution.isSet()
|| seen.putIfAbsent(pos, true) != null) //已经找到了解答或者已经遍历了这个位置
return; // already solved or seen this position
if (puzzle.isGoal(pos))
solution.setValue(this); //在第一次调用setValue时,将更新解答方案,并且CountDownLatch会递减,从getValue中释放主线程。
else
for (M m : puzzle.legalMoves(pos))
//将任务提交到工作者线程中
exec.execute(newTask(puzzle.move(pos, m), m, this));
}
}
}
为了避免无限循环,在串行版本中引入了一个Set对象,其中保存了之前已经搜索过的所有位置。在
ConcurrentPuzzleSolver使用了ConcurrentHashMap来实现相同的功能。这种做法不仅提供了线程安全性,还避免了在更新共享集合时存在的竞态条件,因为putIfAbsent只有在之前没有遍历过的某个位置才会通过原子方式添加到集合中。ConcurrentPuzzleSolver使用线程池内工作队列而不是调用栈来保存搜索的状态。
串行版本的程序执行深度优先搜索,因此搜索过程将受限与栈的大小。并发版本的程序执行广度优先搜索,因此不会受到栈大小的限制(但如果待搜索的或者已搜索的位置集合的大小超过了可能的内存总量,那么人可能耗尽内存)、
为了在找到某个解答后停止搜索,需要通过某种方式来检查是否有线程已经找到了一个解答。如果需要第一个找到的解答,那么还需要在其他任务都没有找打解答时更新解答。这些需求描述的一种闭锁(Latch)机制(5.5.1中),具体的说,是一种包含结果的闭锁。在8-17的ValueLatch中使用CountDownLatch来实现所需的闭锁行为,并且使用锁定机制来确保解答只会被设置一次。
// 8-17 由ConcurrentPuzzleSolver使用的携带结果的闭锁
public class ValueLatch<T>{
private T value=null;
//CountDownLatch(倒计时闭锁)是一种灵活的闭锁实现。它可以使一个或多个线程在等待一组事件发生。闭锁状态包括一个计数器,该计数器被初始化为一个正数,表示需要等待的事件数量。
private final CountDownLatch done=new CountDownLatch(1); //这里表示只要解答出现一个即可
public boolean isSet(){ //如果得到了则返回true,否则false
return (done.getCount()==0);
}
public synchronized void setValue(T newValue){
if(!isSet()){
value=newValue;
done.countDown(); //递减
}
}
public T getValue() throws InterruptedException{
done.await(); //当闭锁值为0是才能执行,之前一直阻塞
synchronized (this) {
return value;
}
}
}
每个任务首先查询solution闭锁,找到一个解答就停止。而在此之前,主线程需要等待,ValueLatch提供了一种方式来保存这个值,只有第一次调用才会设置它。调用者能够判断这个值是否已经被设置,以及阻塞等待它被设置。在第一次调用setValue时,将更新解答方案,并且CountDownLatch会递减,从getValue中释放主线程。
第一个找到解答的线程还会关闭Executor,从而阻止接受新的任务。要避免处理RejectedExecutionException,需要将拒绝处理器设置为“抛弃已提交的任务”。然后,所有未完成的任务最终将执行完成,并且在执行任何新任务时都会失败,从而使Executor结束。(如果任务运行的时间过长,那么可以中断它们而不是等它们完成)。
如果不存在解答,那么ConcurrentPuzzleSolver就不能很好地处理这种情况:如果已经遍历了所有的移动和位置都没有找到答案,那么在getSolution调用中将永远等待下去。当遍历了整个所有空间时,串行版本将结束,但要结束并发程序会更困难。其中一种方法是:记录活动任务的数量,当该值为0时将解答设置为null,如8-18
// 8-18 在解决器中找不到解答
public class PuzzleSolver<P,M> extends ConcurrentPuzzleSolver<P, M>{
PuzzleSolver(Puzzle<P, M> puzzle){
super(puzzle);
}
private final AtomicInteger taskCount=new AtomicInteger(0);
protected Runnable newTask(P p,M m,PuzzleNode<P,M> n){
return new CountingSolverTask(p,m,n);
}
class CountingSolverTask extends SolverTask{
CountingSolverTask(P pos,M move,PuzzleNode<P,M> prev) {
super(pos,move,prev);
taskCount.incrementAndGet(); //开始一个任务递增
}
public void run(){
try{
super.run();
}finally{
if(taskCount.decrementAndGet()==0) //结束一个任务递减,最后值为0则解答为null
solution.setValue(null);
}
}
}
}
找到解答的时间可能比等待的时间 要长,因此在解决器中需要包含几个结束条件。其中一个结束条件时候时间限制的,这容易实现:在ValueLatch中实现一个限时的getValue(其中使用限时版本的await),如果getValue超时,那么关闭Executor并声明出现了一个失败。另一个结束条件是某种特定与谜题的标准,例如只搜索特定数量的位置。此外,还可以提供一种取消机制,由用户自己决定何时停止搜索。