DBFS解二阶魔方——一次c++学习之旅

本文介绍了C++初学者如何使用深度优先搜索(DBFS)算法来最小步数还原二阶魔方。作者详细阐述了构思解法、优化方案,并提供了代码和注释,包括状态定义、转动、查重等关键步骤。通过位运算和数据结构优化,实现了高效查重和存储,降低了搜索时间和空间占用。最后,给出了输入、输出及实用代码片段。
摘要由CSDN通过智能技术生成

目录

前言

构思解法

优化方案

代码及详细注释

1.定义魔方的一个状态

2.状态初始化

3.转动

4.查重

5.双向广搜

6.输出

7.输入

8.主函数

几段实用代码


前言

本人是c++初学者,对魔方有浓厚的兴趣,希望用c++最小步还原魔方。本文是我对DBFS还原二阶魔方的详细思考过程,文章末尾还记录了学习c++的几条笔记。希望各位大神批评指正!

参考文章:

写一个解二阶魔方的程序 - 终末之冬 - 博客园

研究非常透彻,还做了交互式网页。

二阶魔方求解算法研究(44页)-原创力文档

对最小步还原二阶魔方的算法的详尽剖析,优化也做得非常好。

https://pan.baidu.com/s/1inzGNldd_EHc6nE4pvaYqw 

这是本文优化前后的代码。提取码:5d8y

构思解法

一、DBFS而非BFS

魔方状态数有7!*3^6=3671460种,从优化后的DBFS算法看来,30ms搜索25000种状态,10s之内是可以单向广搜完370万种状态的,内存占用预计不会超过100MB。单向广搜费时费空间,毕竟我的目的并非用它测试c++和电脑的运行效率。

二、预计搜索量及时间

由二阶魔方的对称性,六个面随意转动相当于只转动其中的三个面,每个面有顺时针90度、180度、270度三种,因此每一步有9种扩展方法。又已知二阶魔方的QTM最小步数为14,HTM(上述的9种扩展{U,U2,U’,F,F2,F’,R,R2,R’})的最小步数是11。每一步对某一个面的转动是完全的,下一步不需要考虑搜索上一步转动的面,因此第一步搜索有9种扩展方法,以后每一步只有6条分支。

采取双向广搜DBFS,每一个广搜分支最大深度为6,最大搜索量不会超过9*6^5+9*6^4=69984+11664=81648,预计最长搜索时间小于100ms,这比单向广搜优化了不少。

三、定义魔方状态

最简单的想法是定义21个面的颜色(<b,h,d>角块不参与转动,24-3=21),每次转动进行12次赋值。因为每个块的朝向和位置是独立的,我们可以分别定义处于7个位置上的块的编号和朝向,每次转动只需要赋值8次,而且节省空间。

魔方状态的定义,理论上最少只需要(3+2)*7=35bit(0~6共7种位置,012共3种朝向,分别需要3个、2个bit来存储),一个long int(64bit)就能够按位存储一种魔方状态。然而频繁按位读取、写入比较麻烦,因此我采用两个short int[7]数组,共14个元素,记录每一种状态的位置和朝向,考虑用string记录搜索路径。

四、查重

首先用to_string()将14个位置和状态数连接成字符串,作为成魔方的标识,再运用map容器查找是否已搜索过。

优化方案

一、用unordered_map代替map

unordered_map散列哈希表的时间O(1)比map红黑树O(logn)快。结果证明,unordered_map是map查找时间的一半以下。

二、位运算及位存储

查重时,运用移位的方法,分别把两个数组的前6个元素存储到同一个int中,(3+2)*6<32,来作为魔方状态的标识。这样既减少了to_string()时间消耗,查找也更快,时间直接减少至原来的1/3。

按位int比string存储路径快得多,每4位存储一步搜索路径,加上必要的终止符“1111”(15),(6+1)*4<32,时间减少到原算法的一半。

%16,、%4、%2可以用&15、&3、&1代替,按位与 比 取余快得多。

三、int改为short int

代码中大部分数据是小于10的整型,int存储浪费空间,可以考虑只占用1个字节的char。但是char字符数组‘\0’与‘0’无法区分,操作不方便,因此使用short类型。

四、查重时免查己方路径

从结果看来,6步以内重复状态数很少,多扩展节点数也就几十到一百个,但是查询己方路径的时间开销远大于重复扩展的时间,因此可以免查己方路径。

代码及详细注释

1.定义魔方的一个状态

typedef struct Cube{
	int pos[7];
	int state[7];
	string path;
	int last;
	}st; 

state[i]表示第i∈{0,1,2,3,4,5,6}个位置的现有角块序号,如图2表示为state[7]={2,3,6,1,0,4,5}

path路径,比如从起始节点通过{R,U2,F}扩展而来的状态,path=“613”

last上一次转动,last∈{0,1,2,3,4,5,6,7,8}

pos[i]表示第i个位置朝向,怎么定义朝向呢?可以参考盲拧高、中、低级色的定义: 

定义上、下面为0号面,前、后面为1号面,左、右面为2号面。不妨通过整体旋转使得7号位黄色或白色向下,那么对于0~6号位的角块,黄色或白色在几号面上,它的朝向就是几。

 如图4,7号位置是<白,橙,绿>,白色已经在底面。此时5号位的<黄,蓝,红>的黄色面朝前(即1号面),因此pos[5]=1。

2.状态初始化

st org,rest;
org为被打乱需要复原的状态,rest目标状态
void shuffle()
{
	int i;
	for(i=0;i<7;i++)
	{
		rest.pos[i]=0;
		rest.state[i]=i;
	}
设置目标状态rest每个白面都朝上,黄面都朝下,每个块的序号与位置对应
	int mv[11]={1,7,3,0,7,0,7,4,0,4,0};
	//int mv[10]={7,1,8,1,3,6,0,5,6,0};
	//int mv[7]={7,1,4,6,1,7,0};
三组从rest开始打乱的测试公式,分别为11、10、7步
	org=rest;
	for(i=0;i<11;i++)
	{
	    org=exchange(mv[i],org);
	}
	rest.path="";
	org.path="";
    rest.last=10;
org.last=10;
初始化搜索路径,last=10本来不存在,但能达到第一次扩展进行9种旋转的目的。
}

3.转动

将{U,U2,U’,F,F2,F’,R,R2,R’}映射到0~8每个数字,记一次转动为int num,

st exchange(int num,st sat)            //num为0~8转动,sat为父状态
{
	int x=num/3,y=num%3+1,qi,ho,i;
	st wen=sat;                        //新的子状态,这样拷贝似乎不会出问题~
	int change[3][4]={{2,1,0,3},{0,1,5,4},{2,6,5,1}};
                           //change的每组4个元素,分别代表U、F、R面参与转动的有序位置循环
	for(i=0;i<4;i++)
	{
		qi=change[x][i];                //转动前的位置qi,i与qi、ho一一对应
		ho=change[x][(i+y)%4];            //转动后的位置ho
		wen.state[ho]=sat.state[qi];    //将sat的qi位置块赋值给wen的ho位置块
		if(y==2) //若旋转180°
		    wen.pos[ho]=sat.pos[qi];    //所有块转动前后朝向不变
		else if(sat.pos[qi]==x)        //若白/黄面在转动的面上,转动前后朝向不变
	        wen.pos[ho]=x;
		else                             //操作是90°或270°, 且白/黄面不在转动的面上
		    wen.pos[ho]=(3-sat.pos[qi]-x)%3;    //ho朝向是qi朝向除去转动面外的另一个数
                             //例如:pos[qi]==1,转动2号面(特指R面),必然有pos[ho]==0
	}
	wen.path=sat.path+ to_string(num);    //int转string并添加在path末尾
	wen.last=num;
	return wen;
}

4.查重

#include<map>
map<string,string> app[2];
map<string,string>::iterator ite;
bool found=false;                        //指示是否找到
string fro,bhd;                        //成功找到路径后,分别存储两个搜索方向的路径

bool isappear(st sat,short p)        //sat某一状态,p=0或1,区分两个搜索方向
{
	int i,dex=(p+1)%2;                //p=0则dex=1,p=1则dex=0
	string wt="";
	for(i=0;i<7;i++)           //将pos、state每个元素顺次连接成字符串,作为该状态的识别码
	{
		wt+=to_string(sat.state[i]);
		wt+=to_string(sat.pos[i]);
	}
	ite=app[dex].find(wt);            //在对面容器是否搜索过wt?

	if (ite!=app[dex].end())        //对面搜索过,表示已成功找到路径
	{
		fro=sat.path;
	    bhd=ite->second;
        found=true;
        return true;}
	else //对面未曾搜索过
	{
	    ite=app[p].find(wt);            //自己是否曾经搜索过?
	    if(ite==app[p].end())            //自己也没搜索过
	    {
	    	app[p][wt]=sat.path;        //wt为key,对应path值,加入自己这边的容器
	    	return true;
	    }
	    else return false;            //自己搜索过,不用扩展节点了
	}
}

5.双向广搜

st pcr[2][70000];                //用数组构造先进先出队列
int DBFS()
{
	pcr[0][0]=org;
	pcr[1][0]=rest;             //初始、目标状态入队
	isappear(org,0);
	isappear(rest,1);            //在map容器里标记 
	int i,dex[2]={1,1},mk,j, count=-1;        //dex[i]表示在队尾添加节点时的数组下标
	st now,tp;
	
	while (!found)
	{
	    count++;
		for(i=0;i<2;i++)
		{
		    now=pcr[i][count];               //分别取pcr[0]、pcr[1]的第count个节点扩展
		    mk=(now.last)/3;                 //父节点最后一次转动
		    for(j=0;j<9;j++)                 //9种转动
		    {
		        if (j/3!=mk)                 //如果它上一次不转这个面
		        {
		            tp=exchange(j,now);      //按j转动
		            if (isappear(tp,i))      //若可以扩展
		            {
    		            pcr[i][dex[i]]=tp;   //加入队尾
    		            dex[i]++;
		                if (found)           //若成功碰头
		                {
		                    cout<<"search joints:"<<dex[0]+dex[1]<<endl;
                            //输出总搜索节点数
		                    return 0;
		                }
		            }
		        }
		    }
		}
	}
	return 0;
}

6.输出

例如:打乱公式shf为:UFUF2R2URF2U2R’F’

还原公式slv为:FRU2F2R’U’R2F2U’F’U’

而搜索得到的是:fro=”074030”, bhd=”84163”

分别对应fro:UR2F2UFU ,bhd:R’F2U2RF

欲得shf:反序读取fro并对bhd进行处理(转动面不变,90度与270度互换,180度不变)

欲得slv:反序读取bhd并正序处理fro

void output()
{
    short i,t1,t2;
    string shf="",slv="",tp="";          //shf打乱步骤,slv解决公式,二者互逆
	string output[9]={"U","U2","U'","F","F2","F'","R","R2","R'"};
	char c1[20],c2[20],c; 
    strcpy(c1,fro.c_str());
    strcpy(c2,bhd.c_str());         //把fro、bhd从string转化成字符数组,再拷贝到c1、c2中
    t1=fro.size();
    t2=bhd.size();
	for(i=0;i<t1;i++)
	{
        shf+=output[c1[i]-48];          //利用char的字符、数字两重性,如:‘9’-‘0’==9
        c=c1[t1-1-i]-48;                //逆序读取
        tp+=output[c/3*3+2-c%3];        //处理后,再串联成字符串
	}
	for(i=0;i<t2;i++)
	{
        slv+=output[c2[i]-48];
        c=c2[t2-1-i]-48;
        shf+=output[c/3*3+2-c%3];
    }
    slv+=tp;
	cout<<"shuffle:"<<shf<<endl;
	cout<<"steps:"<<t1+t2<<endl;
	cout<<"solution:"<<slv<<endl;
}

7.输入

void input()
{
	short i;
	for(i=0;i<7;i++)
	    scanf("%hd",&org.state[i]);
	for(i=0;i<7;i++)
	    scanf("%hd",&org.pos[i]);
}

因为懒,没有写检查输入是否合法的语句,但千万要注意输入的格式!前7个是位置为0~6的块的编号,不重不漏。后7个是pos,pos[i]∈{0,1,2},且pos[i]之和为3的倍数,否则得到的结果是错误的。

样例输入:

        2 4 1 5 0 6 3 1 0 2 2 1 0 0 (14个数字,每敲入一个后,按回车换行)

样例输出:

        search joints:2896

        shuffle:F’UR2U2F’U’RF2

        steps:8

        solution:F2R’UFU2R2U’F

        76.888000ms

8.主函数

int main()
{
	clock_t t1=clock();
	shuffle();
	input();        //若注释掉这一行,可以用shuffle()里的打乱公式进行测试
    DBFS();
    output();
    float dt=clock()-t1;
    printf("%fms",dt/1000);
    return 0;
}

 

几段实用代码

以下是笔者学习过程中认为挺实用的代码。

1.测量时间间隔

#include<time.h>
clock_t start=clock();

…主程序…

float duration=clock()-start;
printf("%f ms",duration/1000);

2.自定义数据结构

typedef struct Student{
       int id;
       char *name;}st;

Student是结构名称,st是调用关键字,调用如下:

st stu1;
st.id=20220502;

3.字符串

字符串不能直接赋值,只能拷贝:
        strcpy(c1,c2); //将c2拷贝到c1
区别于拷贝数组:
        memcpy(b,a,sizeof(a));
字符数组:
        char p[]=”I am a student”;
c2拼接到c1末尾:
        strcat(c1,c2);
获取长度(注意与 sizeof(c1) 区别)
        c1.size() 或者 c1.length()
string 转 char 数组:
        char c1[]=”I am a student”;
        string c2=c1.c_str();
反转字符串:
        #include<algorithm>
        reverse( c1.begin(), c1.end() );

4.队列

#include<queue>//或者priority_queue用法类似
定义队列:queue <string> a;
队头元素:a.top
非空:if ( !a.empty() )
元素个数:a.size()
在队尾加入元素:a.push(i)
弹出队头:a.pop()

5.map容器

#include<map> //unordered_map用法类似
声明:map<string,int> app;
迭代器声明:
    map<string,int>::iterator it;
赋值有3种方法,最简洁的一种:
    app[“one”]=1;
查找:
    it=app.find(“two”);
    if (it==app.end())
    //若为真,则未找到;若为假,则容器中已存在
遍历访问:
    正向遍历:
        map<string,int>::iterator it;
        for( it=app.begin; it!=app.end(); it++ )
    逆向遍历:
        map<string,int>::reverse_iterator it;
        for( it=app.rbegin; it!=app.rend(); it++ )

6.其他

(1)三目运算符

Money=(age>12) ? 80 : 20;
i ? isappear1(tp) : isappear2(tp);
变量d=(判断语句c)?(a):(b)//如果c真,执行a或者将a赋值给d,反之b

(2)指针操作

用指针访问优缺点并存,缺点是容易出错,优点提高运行效率、简洁。

(3)预定义函数

定义函数:#define Swap(a,b) {int tp=a;a=b;b=tp;}
定义常量:#define LEN “please press any key to continue…”

(4)整型的位运算

乘法:a=a*4  <=>  a<<2
     a=a*7  <=>  a=a<<2+a<<1+a
整除:b=b/4  <=>  b=b>>2
取余:x=w%8  <=>  x=w&7

只有2^n才能移位整除、按位与求余!

(本文完)

  • 3
    点赞
  • 13
    收藏
    觉得还不错? 一键收藏
  • 2
    评论
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值