魔方吧 关注:507,296贴子:11,811,806
  • 5回复贴,共1

魔方还原算法(二)科先巴的二阶段算法

取消只看楼主收藏回复

本文来具体介绍一种具体的魔方还原算法——科先巴的二阶段算法,有一部分相关内容在前篇帖子讲述,主要是方向定义那一块儿,没有看的建议先看一下。也可以在我的公号中看原文,格式更好,更方便阅读。
二阶段,顾名思义,解决问题分为两步,先完成一个目标,再最终复原。对于二阶段算法有一个生动的比喻,复原魔方就像是一条小船要在汪洋大海上行驶到一个固定的目的地。二阶段算法就是先让小船行驶到一个固定的特殊水域,再驶向最终的目的地,这显然比直接寻找目的地要容易简单的多。
比喻归比喻,终归抽象,我们来具体看看,本篇很长很长,我分开来说。
一、总述
为方便叙述,也避免搞混淆,我们定义任意打乱的魔方称为随机状态或者初始状态,处于特殊水域的那些状态称为目标状态,目的地为还原状态。
初始状态可以看作是由 U,R,F,D,L,B 这 6 个基本转动复合而成的,由这 6 个转动生成的群记为 G=<U,R,F,D,L,B> 。
而科先巴定义的目标状态是只由 U,D,L2,R2,F2,B2 这 6 种转动复合生成的,同样的记为 G1=<U,D,L2,R2,F2,B2>。
Ⅰ 目标状态性质
还记得上篇文章计算状态数时是怎么定义方向的么?再来复习复习重新看看:
1. 棱边有 2 种状态,未翻转(flip)用 0 表示,翻转用 1 表示, 只有 F,B 层的转动能够改变方向。
2. 角块有 3 种状态,未扭转(twist)用 0 表示,顺时针扭转用 1 表示,逆时针扭转用 2 表示,只有 U,D 层转动不改变角块方向。
目标状态由 U,D,L2,R2,F2,B2 这 6 种转动生成,而这 6 种转动不会引起角块和棱块方向的变化。因为不论怎么转动,角块拥有的 U 层面或 D 层面始终处于 U 层或者 D 层,所以方向不变。也不会引起棱块方向变化,因为只有 F,B 的转动会引起棱块方向的变化,而像 F2 这样的连续转动两次,翻转再反转棱块相当于未翻转,所以棱块的方向也是不会变化的。这 6 种转动也不会将中层块带到 U 层或者 D 层,所以处于中层的块只能处于中层。
因此目标状态的性质总结如下:
1. 棱块角块方向不变,即各自的方向值总和都为 0。
2. 还原状态下的中层块处于中层
Ⅱ 搜索算法
科先巴的二阶段算法使用的搜索算法是 IDA* 算法,也就是迭代加深搜索算法,而且两个阶段使用的都是 IDA* 算法。
关于IDA*,还有个 A*算法大家可能不太熟悉,也没什么神秘的,简单说说。A* 就是具有启发函数的广搜。IDA*就是有搜索深度限制,有启发函数的深搜。点到为止,详细的解释请百度 CSDN。
一般的,广搜是能够找到最短路径的,深搜不能,IDA*虽然使用的是深搜,但是它有深度限制,也是能够找到最短路径。比如说我搜索深度定为 1 ,没搜到,再定为 2 重新搜索,依次下去,就能够得到最短路径。
所以两个阶段都是能够得到相应的最短路径,能够使用最少的步数达到每个阶段的目标,但合起来会是最优解吗?
答案是否定的,因为阶段二得到的最优解是相对于阶段一来说的,举个例子

两个阶段都相应最优就好比上述方法 1 得到的距离,但两个阶段合起来明显不是最短距离,两点之间线段最短应该是方法 4。
那怎么解决呢,如何得到更优甚至最优的解法?也如上图所示,我们加深第一阶段的搜索深度,虽然第一阶段的路径变长了,但是第二阶段可能找到更短路径,这样它们合起来边可能更优。这样一步步下去,是总能找到最优解的,也就是说科先巴的二阶段算法一次搜索可能得不到最优解,但是只要给它时间,是一定能够找到最优解的。但是对于科先巴算法并不是拿来寻找最优解的,目的是短时间内找到一组比较优的解。
对于科先巴的二阶段算法应该有了很直观的基本认识了吧,如果还不清楚,再针对魔友举一个很形象的例子。咱们一般使用的 CFOP 还原公式,F2L 之后就应该是 OLL,PLL 的对吧,如果我们正常的按照 CFOP 的公式来,这几个阶段都得到的是最优解,合起来的话也是比较优的。但是如果我们在 F2L 时多花几步,是可能跳 O 甚至跳 P 的,这就会节省很多后续转动步骤,得到一个更优的解法。
上述有关两点之间线段最短的例子只是比喻,实际情况稍微有点不一样,前面说过二阶段算法就是小船先驶向一片特殊水域,再驶向目的地,但是某些情况的最短路径可能根本就不经过那片水域,如果还是先经过特殊水域的话,自然也就得不到最优解。那这是不是就与上述所讲的冲突呢?也不然,还原状态其实也是目标状态之一是吧,所以如果解决阶段一之后发现已经还原了,阶段二所需要的步骤降为 0 ,那这就是最优解。就好比完成 F2L 之后惊奇的发现跳 O 又 条 P 已经还原了。
好了,上述就是对科先巴的二阶段算法的一个总述,应该是很容易理解的。下面是算法细节,挺多,感兴趣的朋友可以看看,对魔方没什么兴趣的了解到这一步大概就够了。
不过学计算机的,关于魔方的还原算法还是很值得一看的。不知道大家有没有做过八数码的题,解魔方与解八数码本质上是一样的,英文也都用 puzzle 这个词来表示,只不过魔方的状态数比八数码要大得多,而且魔方方方正正的,有许多特殊的性质可以利用来优化算法,下面来具体看看:


IP属地:北京1楼2021-09-20 19:54回复
    唉,贴吧这文字格式有点醉人,据贴友说好像要签到一个月才有文字编辑功能,对新人不太友好,就先这样吧,有兴趣的可以去公号里去看原文。
    重发一次,上次的组合数那儿有点格式问题,没显现出来。
    接着上文:
    二、定义魔方
    定义魔方从两个层次来定义,一个是块层次,一个是坐标层次
    定义魔方,首先就得对角块,棱块这些基本元素标号,可按如下方式这样标号:

    代码表示如下:
    typedef enum {URF,UFL,ULB,UBR,DFR,DLF,DBL,DRB} Corner;
    typedef enum {UR,UF,UL,UB,DR,DF,DL,DB,FR,FL,BL,BR} Edge;
    对角边进行标号了之后就可以从块这个角度来定义一个魔方:

    怎么存储的魔方状态?举个例子 co[URF] = {URF, 0}表示 URF 这个位置存储的 URF 这个块,方向为 0。co[UFL] = {UFL, 1} 表示 URF 这个位置存储的 UFL 这个块,方向为0 。
    棱块同理,而在篇一就说过,我们不用考虑中心块,中心块保持不动,当做参考系。如此就可以用上述的 CubieCube 结构体来表示一个魔方状态,定义一个魔方。
    Ⅱ 坐标层次(索引)
    这名字是从英文名中直译过来的,这一层次简单来说就是我们要对魔方状态进行编码/哈希,给不同状态分配一个唯一数来标识,主要用来作为变换表,剪枝表的索引。当然不可能真的对魔方不同的完整状态进行编码,这状态数太多了,不现实。而是对魔方的部分状态编码,一个魔方的完整状态有 8 个角块,12 个棱块以及它们的方向组成,而算法就是将它们分割,比如某些棱块的位置,角块位置,角块方向,棱块方向等等,对它们进行编码哈希,这样数量就会小上很多。
    因此主要是对于位置,方向两个指标编码,而编码方法主要用到以下三种:
    ① 方向
    方向分为角块方向和棱块的方向,两者类似,在这儿以角块方向举例,假如有如下所示的一个带方向的角块排列:

    第一行表示位置,第二行表示该位置上存储的角块和其方向,使用下面的方法进行编码:

    应该很直观也很好理解吧,因为当 7 个角块方向确定之后,最后一个角块的方向也就确定了,所以不用编码最后一个角块的方向。代码如下:

    ② 排列
    对于位置变化常使用排列数,期间我们可能会用到多种排列,比如 8 个角块的排列,6 条边的排列等等,这些都是魔方部分状态的表示,我们需要进行编码,有关排列的编码方法有个名字叫做康托展开,同样直接来看一个例子来说明康托展开这种编码方式,假如 8 个角块有如下排列:

    第一行表示位置,第二行表示对应位置上放的角块,由标号知道咱们的块是有大小之分的:URF<UFL<ULB<UBR<DFR<DLF<DBL<DRB 所以第三行就表示位于左边的块比自己大的块有几个,比如说 URF 块(看第二行,第一行表示位置,第二行才是该位置上存放的块)左边有 3 个块,都比自己大,所以第三行值为 3;再比如 UBR 块,左边有 7 个块,比自己大的有 4 个,所以值为 4 。DFR 块处于第一个位置,左边没有块了,所以编码时第一个块不用考虑进去。
    具体的编码:

    这个式子与上面第三行一一对应起来应该很好理解就不解释了,代码描述如下:

    ③ 组合
    对于位置变化有时也会用到组合数,需要对组合数进行编码,在阶段一我们会用到关于中间层 4 个棱块的一个组合数编码,阶段一的目标状态要求本来在中间层的棱块要处在中间层,所以算阶段一的棱块位置状态我们只用考虑中间层的 4 个棱块,有 12*11*10*9 = 11880 种状态,但是中间层的 4 个棱块之间并无位置要求,所以还要除以它们自身的位置排列 4!= 24 ,所以总共 495 种状态,而这就是一个组合数 Choose(12,4 ,我们要对这 495 种状态编码:

    第一行表示位置,第二行表示存储的块,只用考虑中间层 4 个块的位置也就是 FR,FL,BL,BR 4 个棱块, 因为中间层的 4 个棱块无位置要求,所以用 X 代替,表明它们是相同的。第三行表示 X 对应的组合数是多少,以第二个举例说明怎么编码:

    直接用到了组合数的值,所以要提前写一个求组合数的函数,n 选 k,如果 k>n 规定等于 0,写函数时判断以下即可。上述编码也可以对另外的 8 个组合数和来表示,感兴趣的可以自己试试。
    上述是编码过程,魔方的坐标层次表示,也就是将魔方的部分状态转变成一个特定的数来表示,编码方式不唯一,合理即可。我们还应需要一个逆过程,从编码到实际的魔方状态,这样才能实现上述两种层次的定义相互转化,这里不再赘述了,基本上就是完全反着来一遍。
    上述就是定义魔方,从块层次和坐标层次来定义,特别是坐标层次,这在整个程序中至关重要。由于只能插入 10 张图片,又没法打公式,就只好一些图片和文字放在一起截图了,还请见谅。


    IP属地:北京7楼2021-09-20 21:01
    回复
      三、定义转动
      前面定义了魔方本身,这里来定义魔方操作,也就是定义转动
      Ⅰ 位置变化
      先来看篇一提到的 {0,1,2,3} 的排列问题,假如有两个排列,在程序中用数组如下表示:
      int a[4] = {0, 1, 2, 3};
      int b[4] = {1, 2, 3, 0};
      int c[4] = {};
      数组这个数据结构有两要素,一个是下标表示位置,一个是元素,都是同等重要的,但往往我们可能会忽略掉下标的作用。
      b 这个数组表示什么?当然可以表示一个排列,0号位存的 1,1号位存的2 ,2号位存的3,3号位存的0。
      这只是常规认识,还表示什么?本身就可以表示一个变换,它表示 0 号位的元素被 1 号位元素替代,1 号位的元素被 2 号位元素替代,2 号位的元素被 3 号位元素替代,3 号位的元素被 0 号位元素替代。使用这种替代的角度来描述变换有很直观的等式关系,比如,0 号位的元素被 1 号位的元素替代,可以写成 a[0] = a[1] 。
      所以给定一个排列,它不仅是排列,还可以看作是一个变换,这是因为排列本身就存在次序,从数组的角度来就是有个隐含的下标来表示元素所处的位置,通过一个元素前后次序的变化就可以说明一个变换操作。
      特别的如果元素个数也就是下标取值范围等于元素的取值范围,我们就可以很方便的在其上定义封闭的变换。比如说上述的排列元素个数为 4,所以下标的取值范围为 0~3,而元素本身的取值也是 0~3 这 4 个数。这种情况就很方便定义变化操作,比如在 a 这个排列上做 b变换,得到的结果存在排列 c 里面,进行如下操作:
      for(int i = 0; i < 4; i++)
      c[i] = a[b[i]];
      总结下来就是如果给定一个排列,如果要把它当作是一个变换的话,那么不仅下标表示的是位置,它的元素也表示的是位置。如 b[2] = 3就表示位置 2 的元素被位置 3 的元素替代。
      理解了上面之后,魔方转动的定义也就迎刃而解了,并不需要什么新的数据结构,前一节定义的魔方状态就可以一个变换,只不过在不同需求时我们对其解读不同罢了。
      Ⅱ 方向变化
      一个位置存储的不仅有块本身,还有块的方向,块可以按照上述替代的角度来看,但方向不行。
      举个例子,在 a 状态下做 F 操作,前面说过转动操作也可以看作一个状态,我们把 F 操作记为 b ,得到的结果记为 ab,以 URF 这个位置为例来说明。
      F 转动使得 URF 槽的元素被 UFL 槽的元素替代,可以写成

      但是方向的取值不能用替代来表示,也就是说 ab.co[URF].o != a.co[UFL].o,F 转动下,UFL 位置上的元素移动到 URF 位置上,并顺时针扭转,所以对于 F 转动,方向应有以下变化:

      Ⅲ 基本转动
      因此我们应该能很容易的写出基本转动的定义,来看个例子 F :

      Ⅳ 转动函数
      转动分为角块转动和棱块转动,所以代码如下:

      这个函数就是角块转动和棱块转动函数的封装,以角块的转动函数为例说明:

      与科先巴的源程序不太一样,原程序还处理了镜像等情况,这里先不管,后面再说。上面讲述的角度是将 a 看作魔方的一个状态,b 看作是一个变换,但同样也可以将 a 看作是一个变换,那么 ab 就相当于一个复合操作,一样的道理,只是理解角度不同而已。
      Ⅴ 转动表
      转动在程序里面会经常用,每次都去做一遍变换操作太费时间,我们要建立转动表,只要在第一次运行程序时建立好这个表,以后的转动操作直接去查表就行了。
      转动表有很多,取决于使用了哪些坐标来表示魔方状态,以角块方向为例:

      这里就用上了魔方状态定义的坐标层次,把它当作索引。举个例子说明上面二维数组的意思,假如有twistMove[a][U] = b ,说明状态 a 下,进行 U 操作,得到状态 b。a, b 是两个数,分别唯一标识着魔方的角块方向状态。
      这个二维数组初始化也很容易,直接看码:

      这部分是定义转动和转动表,就是将魔方的基本操作用程序语言给表示出来,就像是数学中的矩阵变换一样,只是这里使用了程序语言写了出来,前面这些应该还是很好理解的,后面讲述对称


      IP属地:北京8楼2021-09-20 21:22
      回复
        四、对称
        魔方是一个高度对称的物体,我们可以利用对称性来减少时间空间上的开销。
        对称的形式有 4 种基本情况,原本状态,只颜色变化,只位置变化,镜像。

        它们都是等价的,如果我们能解决其中一个,就能解决其他的,只要在解法上加上相应的对称变化即可。
        来看一个同位置不同颜色的例子,看看原状态经过怎样的变化后得到的一种等价状态:
        假如对还原状态的魔方进行 A(一个转动序列) 操作得到如下状态:

        我们要得到它的一个等价状态,进行如下操作,还原状态先绕 UD 轴逆时针转动 90°,再做 A 操作,最后绕 UD 轴顺时针转动 90°。

        看到这大家有没有联想到什么,是不是很像矩阵的相似变换,有一些还原算法就是把魔方存成矩阵的形式,魔方变换就是相应的矩阵变换,道理都是相通的。
        Ⅰ 对称变换
        ① 基本变换
        上述的一些等价形式都是通过对称变换得来的,对于每个状态加上自己有 48 种变换形式,一般来说每个状态也就有 48 种等价形式,它们都是由 4 种“基本对称”组合而成:
        1. S_U4:围绕 UD 这个轴转动 90°,4 种
        2. S_F2:围绕 FD 这个轴转动 180°,2 种
        3. S_URF3:围绕 URF 这个角块转动120°,3 种
        4. S_LR2:关于 LR 层的镜像,2 种
        合起来 4*2*3*2=48 种
        具体怎么转动的我就不具体画图表示了(唉主要是变换的 3D 制图太难了,尝试了一下,效果不好,见谅),既然是变换操作,所以对称变换还是可以用状态的块层次来表示,具体来看 4 种基本对称变换的定义,从块的替代关系中应该就能理解具体是怎么变换的了。

        48 种对称变换操作初始化也很容易,4 个 for 循环,调用 multiply 函数进行复合操作,得到的结果填到 symCube[48] 数组里面去,这里就不赘述重复操作了。
        注意上面的镜像变换,定义角块方向变化全部加 3 ,也就是说镜像的角块方向取值为 [3,5]。
        ② 逆变换
        上述为了得到等价状态做了两次对称变换,两种变换互为逆过程,群论里面叫做互为逆元,我们这需要为每一个对称变换找到它们对应的逆变换。
        那怎么得到相应的逆变换?在群论里面互逆的两个元素复合操作等于幺元,也就是相当于乘法里面的互为倒数的两个数相乘为 1,而在魔方里面还原状态就是幺元就是那个 1 。所以我们只要对 48 个变换进行两两复合操作,看结果是否达到还原状态即可。

        Ⅱ 等价

        因此对于每种状态我们只需要记录属于哪一个集合(行号),所使用的对称变换(列号) ,这么一个集合我们称为等价类。如此一来就可以省下很多空间和时间,因为存的信息少了,搜索的状态空间也少了。在科先巴的二阶段算法里,保持了 UD 轴不动,所以只用到了 16 种对称变换。
        加入一个状态属于等价类 y ,使用的对称变换为 i,那么我们就可以用新坐标 y*16+i 来描述原状态。但是并不是每个等价类里面都有不同的 16 种状态,也就是说不是每个状态都有 16 种不同对称状态。比如说有等价类 y y 和对称变换 i,j,y*16+i 与 y*16+j 是有可能描述的同一个状态的。这个在后面剪枝部分还会再说明
        关键问题在于我们如何得知一个坐标它属于哪一个等价类,使用的是哪一个对称变换?直接来看伪码分析:

        Ⅲ 对称变换表
        上述的代码里面已经使用过了,类似前面的转动表,对称变换,转动都是变换操作,转动有转动表,对称变换也得有相应的变换表。比如定义以下的表:

        具体的初始化操作同转动表,都是建立在状态的复合操作之上,以角块方向的对称变换表举例说明:

        源程序里面命名用的是 conjugate 这个词,共轭,我这儿直接用的 Sym 来说明,私以为可能更直观一些。
        这部分讲述对称性,根据篇一种计算的状态数我们知道,一个三阶魔方的状态数多到爆炸,但还好魔方是个高度对称的物体,我们可以使用对称性来减少计算量。下一部分算法核心:剪枝


        IP属地:北京20楼2021-09-21 13:37
        回复
          @哀木涕🔥 申请一个精品贴


          IP属地:北京来自Android客户端21楼2021-09-21 22:55
          回复
            首发 ɠźĥ:Rand_cs,求关注支持,格式烂观感不好的问题请移步 ɠźĥ,贴吧我实在没办法,只能这样
            五、剪枝
            剪枝,应该是科先巴的二阶段算法的核心了,综合运用了前面定义的所有数据结构来构建一个新的查找表——剪枝表,表里面存放的信息是距离目标状态(阶段一)或者还原状态(阶段二)最少还需要多少步。如果这个步数大于了还能走的步数,剪掉回溯。
            两个阶段的剪枝表可能有多张,每个阶段都要选取最大的那个数作为到目标/还原状态的最少需要步数,比如说表一告诉我当前状态至少还需要 5 步到达目标状态,表二告诉我至少还需要 6 步,则将 6 作为至少需要步数。还是很好理解的是吧,表一表二分别描述了魔方的一部分状态,每部分状态都达到目标状态才算达到目标状态,所以要选取最大的。这也是 IDA* 算法里面的 h(n) 这个函数。
            Ⅰ 建立剪枝表
            剪枝表竟然能够出到目标状态的最少步数?是不是很神奇,其实没什么神奇之处,就是一个暴力广搜得到的结果。设定目标状态的深度为 0 ,然后对目标状态进行 18 种转动操作,得到第一层的状态结点深度为 1,再对第一层的每种状态转动,得到第二层状态结点,深度为2..........具体看下面伪码:

            本质上就是广搜,只是形式上与我们平常看到的框架可能不太一样,这种形式费点时但省空间不用像常见广搜框架那样储存节点,魔方的状态数很多,我们要采用更省空间的形式
            Ⅱ 存储技巧
            如果知道初始状态距离目标状态有多远,而后每执行一步,距离目标状态要么要么加 1,要么不变,要么减 1,就这三种情况,那么我们就能够计算出转动后的状态距离目标状态多远。所以其实剪枝表里面我们只需要 2个 bit 来存储 实际距离 mod 3 的值。根据当前状态的距离,再根据剪枝表里面查到的 mod3 值就能够算出转动后的状态到目标状态的距离的。如此就大大减少了空间的使用。
            但关键在于初始状态到目标状态距离有多远?你可能会说,查表嘛,查表没错,但是要记住,现在剪枝表里面存放的不是实际距离了,存放的是只用了 2 bits 的 mod3 距离。那怎么办呢?我们直接看伪码分析解决办法:

            根据伪码应该能看出大概意思吧,因为我们从剪枝表里面查出来的是 mod3 距离,所以 mod3 距离从 3 到 0 一直循环的递减,实际距离从 0 开始一直递增,直到到达目标状态。因为我们每次更新两个距离值都是要转动后距离目标状态更近,所以最终得出的实际距离 depth 一定是初始状态到目标状态需要的最少步骤。这似乎都已经把魔方解了一遍了,但其实并不是,因为 index 只是魔方部分状态的坐标索引,所以这个函数算出来的值只是当作一个初始指标。


            IP属地:北京39楼2022-02-12 19:48
            回复