# 1043 八数码问题

## Q1：每一个状态及每一次操作的表示方法？

有许多表示方法，比如一个3\*3的八数码盘可以压缩成一个int值表示，但不适用于15 puzzle或大于8 的puzzle问题。如果对空间要求很高，应该还可以再压缩。本文采用一个int表示的方法。

表示方法如下：由于int的表示范围大于1e9，所以我们取一个int的低9位，为了方便寻找空格的位置，int的个位我们用来放空格的位置（1-9）。而前8位，按照行从上到下，列从左到右的顺序依次记录对应位置上的数字。例如：

可以表示成 2 3 1 5 8 4 6 7 5 ，个位的5表示空格在第5位，前八位数按顺序记录。坐标转换公式为：

num（压缩后的int） x y（求x行y列,1记起）1e(n)为 1乘10的n次

```
    int temp=(x-1)*3+y
    if  temp > num%10 then return (num / 1e(9-temp+1)) %10
    else return (num / 1e(9-temp) )%10
```

为了方便本文介绍，取目标状态为：1 2 3 4 5 6 7 8 9 即-->

操作本文用 u r d l 分别表示 空格的向上 向右 向下向左四个操作。比如，在简介中的图包括两步操作 l d ，可能与平时玩这类游戏的习惯不符合，但这是为了和ACM例题相统一。

对应地，每种操作引起的状态变化如下：

```
r ：num值++              l ：num值--
u ：有点复杂
```

```
    int t0 = 9-num%10 + 1
    int t1 = num / 1e(t0)
    int t2 = t1%1000
    t1= t1- t2 + (t2 % 100) * 10 + t2 / 100
    t1*= 1e(t0)
    return (t1 + ( (num % t0) - 3))
    d ：return前面同u操作， return返回  (t1 + ( (num % t0) + 3))
```

## Q2：判断是否存在中间状态使START 到达END？

用组合数学的方法可以快速地进行判断，例如SOJ 2061题 2360题中都是关于此类的问题。但八数码的判断方法比他们简单多了。

本文用的方法是计算排列的逆序数值，以2 3 1 5 8 4 6 7 5 为例子，5表示的是空格，不计算，那么求23158467 的逆序值为

```
    0 + 0 + 2 (1<2 1<3 ) + 0 + 0 + 1 ( 4<5 ) + 1 ( 6<8 ) + 1 ( 7<8 ) = 5
```

目标状态1 2 3 4 5 6 7 8 9 的逆序自然就是0。

两个状态之间是否可达，可以通过计算两者的逆序值，若两者奇偶性相同则可达，不然两个状态不可达。

简单证明一下：

l 和 r 操作，不会影响状态的逆序值，因为只会改变个位数（空格的位置）。

u和d操作是使某个位置的数字 右/左移两位。由于数字序列的每一次移动会使逆序值奇偶性改变，所以 移动两次后奇偶性不变。

所以四个操作均不会影响序列的奇偶性。

## Q3：如何寻找一系列的中间状态及遇到的问题？

要寻找这一系列中间状态的方法是搜索，但搜索很容易遇到时间和空间上的问题。以下就是搜索的基本原理：

由1 3 7 2 4 6 8 5 2 状态可以衍生三个状态，假如选择了1 2 3 7 4 6 8 5 5 ，则又衍生三个状态，继续按某策略进行选择，一直到衍生出的新状态为目标状态END 为止。

容易看出，这样的搜索类似于从树根开始向茎再向叶搜索目标叶子一样的树型状。由于其规模的不断扩大，其叶子也愈加茂密，最终的规模会大到无法控制。这样在空间上会大大加大搜索难度，在时间上也要消耗许多。

在普通搜索中遇到以下问题：

* a  搜索中易出现循环，即访问某一个状态后又来访问该状态。
* b 搜索路径不佳便无法得到较好的中间状态集（即中间状态集的元素数量过大）。
* c 搜索过程中访问了过多的无用状态，这些状态对最后的结果无帮助。

以上三个问题中，a为致命问题，应该它可能导致程序死循环；b和c是非致命的，但若不处理好可能导致性能急剧下降。

## Q4：怎样避免重复访问一个状态？

最直接的方法是记录每一个状态访问否，然后再衍生状态时不衍生那些已经访问的状态了。思想是，给每个状态标记一个flag，若该状态flag = true则不衍生，若为false则衍生并修改flag为true。 在某些算法描述里，称有两个链表，一个为活链表（待访问），一个为死链表（访问完）。每一次衍生状态时，先判断它是否已在两个链表中，若存在，则不衍生；若不存在，将其放入活链表。对于被衍生的那个状态，放入死链表。

为了记录每一个状态是否被访问过，我们需要有足够的空间。八数码问题一共有9!，这个数字并不是很大，但迎面而来的另一个问题是我们如何快速访问这些状态，如果是单纯用链表的话，那么在规模相当大，查找的状态数目十分多的时候就不能快速找到状态，其复杂度为O(n),为了解决这个问题，本文将采用哈希函数的方法，使复杂度减为O(1)。

这里的哈希函数是用能对许多全排列问题适用的方法。取n!为基数，状态第n位的逆序值为哈希值第n位数。对于空格，取其（9-位置）再乘以8!。例如，1 3 7 2 4 6 8 5 8 的哈希值等于： &#x30;_&#x30;! + &#x30;_&#x31;! + &#x30;_&#x32;! + &#x32;_&#x33;! + &#x31;_&#x34;! + &#x31;_&#x35;! + &#x30;_&#x36;! + &#x33;_&#x37;! + (9-8)\*8! = 55596 <9!

具体的原因可以去查查一些数学书，其中1 2 3 4 5 6 7 8 9 的哈希值是0 最小，8 7 6 5 4 3 2 1 0 的哈希值是（9!-1）最大，而其他值都在0 到（9!-1） 中，且均唯一。

## Q5：如何使搜索只求得最佳的解？

普通的搜索称为DFS（深度优先搜索）。除了DFS，还有BFS，从概念上讲，两者只是在扩展时的方向不同，DFS向深扩张，而BFS向广扩张。在八数码问题的解集树中，树的深度就表示了从初始态到目标态的步数，DFS一味向深，所以很容易找出深度较大的解。

BFS可以保证解的深度最少，因为在未将同一深度的状态全部访问完前，BFS不会去访问更深的状态，因此比较适合八数码问题，至少能解决求最佳解的难题。

但是BFS和DFS一样不能解决问题c ，因为每个状态都需要扩张，所以广搜很容易使待搜状态的数目膨胀。最终影响效率。

## Q6：该如何减少因广搜所扩张的与目标状态及解无关的状态？

前面所说的都是从START状态向END状态搜索，那么，将END状态与START状态倒一下，其实会有另一条搜索路径（Q8策略三讨论），但简单的交换END与START并不能缩小状态膨胀的规模。我们可以将正向与反向的搜索结合起来，这就是双向广度搜索。

双向广搜是指同时从START和END两端搜，当某一端所要访问的一个状态是被另一端访问过的时候，即找到解，搜索结束。它的好处是可以避免广搜后期状态的膨胀。

采用双向广度搜索可以将空间和时间节省一半！

## Q7：决定一个快的检索策略？

双向广搜能大大减少时间和空间，但在有的情况下我们并不需要空间的节省，比如在Q4中已经决定了我们需要使用的空间是9！，所以不需要节省。这样我们可以把重点放在时间的缩短上。

启发式搜索是在路径搜索问题中很实用的搜索方式，通过设计一个好的启发式函数来计算状态的优先级，优先考虑优先级高的状态，可以提早搜索到达目标态的时间。A\*是一种启发式搜索的，他的启发式函数f ' ()=g' () + h' () 能够应用到八数码问题中来。

```
    g' () ----- 从起始状态到当前状态的实际代价g*()的估计值，g' () >= g*()
    h' () ----- 从当前状态到目标状态的实际代价h*()的估计值，h' () <= h*()
```

注意两个限制条件：

（1）h' () <= h\*() （2）任意状态的f '()值必须大于其父状态的f '()值，即f '()单调递增。

其中，g' () 是搜索的深度， h' () 则是一个估计函数，用以估计当前态到目标态可能的步数。解八数码问题时一般有两种估计函数。比较简单的是difference ( Status a, Status b )， 其返回值是a 和b状态各位置上数字不同的次数。另一种比较经典的是曼哈顿距离 manhattan ( Status a, Status b )，其返回的是各个数字从a的位置到b的位置的距离（见例子）。

例如状态 1 3 7 2 4 6 8 5 2 和状态 1 2 3 4 5 6 7 8 9 的difference 是5（不含空格）。而他的manhattan 距离是： 1 (7d一次) + 1 (2u一次) + 2 (4l两次) + 3 (6r两次u一次) + 2 (5u一次l一次) = 9

单个数字的manhattan应该小于5，因为对角的距离才4，若大于4则说明计算有误。

无论是difference还是manhattan，估计为越小越接近END，所以优先级高。

在计算difference和manhattan时，推荐都将空格忽略，因为在difference中空格可有可无，对整体搜索影响不大。

本文后面的实现将使用manhattan 不计空格的方法。其实，每移动一步，不计空格，相当于移动一个数字。如果每次移动都是完美的，即把一个数字归位，那么START态到END态的距离就是manhattan。反过来说，manhattan是START到END态的至少走的步数。

回到f '()=g' ()+ h' ()，其实广度搜索是h' ()=0的一种启发式搜索的特例，而深度搜索是 f ' ()=0 的一般搜索。h' ()对于优化搜索速度有很重要的作用。

## Q8：能否进一步优化检索策略？

答案是肯定的。

A\*搜索策略的优劣就是看h' ()的决定好坏。前面列举了两个h' ()的函数，但光有这两个是不够的。经过实验分析，在f '()中，g '()决定的是START态到END态中求得的解距离最优解的距离。 而h' () 能影响搜索的速度。

所以优化的第一条策略是，放大h' ()，比如，让h '()= 10 _manhattan()，那么f '()= g' ()+1&#x30;_&#x6D;anhattan()，可能提高搜索速度。可惜的是所得的解将不再会是最优的了。

为什么放大h'()能加快搜索速度，我们可以想象一下，h'()描述的是本状态到END态的估计距离，估计距离越短自然快一点到达END态。而 g' ()描述的是目前的深度，放大h' ()的目的是尽量忽略深度的因素，是一种带策略的深搜，自然速度会比广搜和深搜都快，而因为减少考虑了深度因素，所以离最优解就越来越远了。关于h' ()放大多少，是很有趣的问题，有兴趣可以做些实验试试。

第二条是更新待检查的状态，由于A\*搜索会需要一个待检查的序列。首先，在Q4已经提到用哈希避免重复访问同一状态。而在待检查队列中的状态是未完成扩张的状态，如果出现了状态相同但其g '()比原g '()出色的情况，那么我们更希望的是搜索新状态，而不是原状态。这样，在待检查队列中出现重复状态时，只需更新其g'() 就可以了。

第三条是注意实现程序的方法，在起初我用sort排序f '()后再找出权值最大的状态，而后发现用make\_heap要更快。想一想，由于需要访问的接点较多，待访问队列一大那么自然反复排序对速度会有影响，而堆操作则比排序更好。另一点是，实现更新待检查队列时的搜索也要用比较好的方法实现。我在JAVA的演示程序中用的PriorityQueue，可是结果不是很令人满意。

第四条优化策略是使用ID&#x41;_&#x7684;算法，这是&#x41;_&#x7B97;法的一种，ID名为Iterative deepening是迭代加深的意思。思想是如下： 顺便准备一个记录一次循环最小值的temp=MAX, h' 取 manhattan距离

```
先估算从START态到END态的h'() 记录为MIN，将START放入待访问队列
读取队列下一个状态，到队列尾则GOTO⑦
若g'() > MIN GOTO ⑥
若g'() + h'()  > MIN 是否为真，真GOTO ⑥，否 GOTO ⑤
扩展该状态，并标记此状态已访问。找到END态的话就结束该算法。GOTO ②
temp = min(manhattan , temp),GOTO ③
若无扩展过状态，MIN=temp （ID的意思在这里体现）从头开始循环GOTO ②
```

第五条优化策略本身与搜索无关，在做题时也没能帮上忙，不过从理论上讲是有参考价值的。记得Q6中介绍的从END开始搜起吗？如果我们的任务是对多个START 与END进行搜索，那么我们可以在每搜索完一次后记录下路径，这个路径很重要，因为在以后的搜索中如果存在START和END的路径已经被记录过了，那么可以直接调出结果。

从END搜起，可以方便判断下一次的START是否已经有路径到END了。当前一次搜索完时，其已访问状态是可以直接使用的，若START不在其中，则从待访问的状态链表中按搜索策略找下一个状态，等于接着上一次的搜索结果开始找。

之所以没能在速度上帮上忙，是因为这个优化策略需要加大g' ()的比重，否则很容易出现深度相当大的情况，由于前一次搜索的策略与下一次的基本无关，导致前一次的路径无法预料，所以就会出现深度过大的情况。解决方法是加大g' ()。

策略五类似给程序加一个缓冲区，避免重复计算。如果要做八数码的应用，缓冲区会帮上忙的。

## Q10：怎样记录找到的路径？

当找到解的时候我们就需要有类似回退的工作来整理一条解路径，由于使用了不是简单的DFS，所以不能借助通过函数调用所是使用的程序栈。

我们可以手工加一个模拟栈。在Q4中解决了哈希的问题，利用哈希表就能快速访问状态对应的值，在这里，我们把原来的bool值改为char或其他能表示一次操作（至少需要5种状态，除了u r l d 外还要能表示状态已访问）的值就行了。

在搜索到解时，记录下最后一个访问的状态值，然后从读取该状态对应的操作开始，就像栈操作的退栈一样，不停往回搜，直到找到搜索起点为止。记录好栈退出来的操作值，就是一条路径。
