文章涉及源码:https://gitee.com/xtaifghy/wugui
目录
引子
在十几年前玩过一个叫做《天河传说》的游戏,在游戏快通关的时候有一个解密游戏,见下图:
玩家要过这关,除了杀死敌人外,还要通过踩乌龟背上的几个点,来控制乌龟达到某种状态,然后大乌龟会载着玩家游到对岸。其具体操作规则为:
初始状态:1只大乌龟,有1个头,1个尾巴,左右各3条腿,头、尾巴、腿都是伸在外面的。
玩家操作:龟背上有9个可以踩的控制点,踩中间的点,乌龟的头、尾巴、左中腿、右中腿共4个部位改变伸缩状态,踩其它8个点可以控制与其相邻3个部位的伸缩状态。
达成条件:乌龟的头伸出,其他8个部分缩回。
很简单的一个解密游戏,当时试了很多次都没有通过,被困了很长时间,最后只好求助度娘。虽然最后过了这一关,但是为了挽回自己作为一名码农的面子,决定通过代码解决这个问题。
我的方法很简单粗暴,就是随机生成操作,不停的踩龟背上的点,直到达成目标条件。代码很快就写出来了,顺利求出了解,先自我陶醉了一下。但是马上又发现了新的问题,运行的时候有时候会得到不同的解。
那么问题来了:
- 具体有多少种解呢?
- 如果初始状态和目标状态有变化,都能得到解吗?
- 有没有比暴力求解更好的方法呢?
为解决心中的疑惑,我不断思考,不断修改代码,找到了更快、更nice的解题方式,也让我对算法优化、布尔运算有了更深的理解。下面,我将整个过程跟大家做一分享:
一、将问题抽象化
1、问题抽象化:
用3X3共九个按钮表示龟背上的9个点,用8个小灯,来表示龟的8个可伸缩部位,并给它们加以编号,我是按行进行编号的,现在看有点别扭,不过顺序无所谓,不影响解决问题。如图所示,左边是初始状态,右边是目标状态。
2、定义数据类型
//使用boolean数组表示每个灯的状态,true表示亮,false表示灭。
boolean [8] state = {true,true,true,true,true,true,true,true};//初始状态
boolean [8] goalState = {false,true,false,false,false,false,false,false};//目标状态
boolean[9] path={false,false,false,false,false,false,false,false,false};//问题的解:path[i]为true表示需要点击第i个按钮;
ArrayList<boolean[9]> paths;//问题的多个解,存储到ArrayList中
3、定义接口
定义了一个游戏操作的接口:
public interface GameStateInterface {
public ArrayList<boolean[]> getPaths();//计算问题的解
public void randomGoalState();//随机生成目标状态
public void setState(boolean [] state);//设置当前状态
public boolean [] getState();//获取当前状态
public void setGoalState(boolean [] goalState);//设置目标状态
public boolean [] getGoalState();//获取目标状态
public void doAction(int i);//执行1个点击按钮的操作,i代表按钮编号,执行此操作会改变当前状态
public boolean testState();//判断当前状态和目标状态是否一致
}
二、求解方式的实现
1、随机暴力求解法
比较简单,直接贴代码。
public class GameState1 implements GameStateInterface {
private boolean [] state = {true,true,true,true,true,true,true,true};
private boolean [] goalState = {false,true,false,false,false,false,false,false};
private static Random random = new Random();//getPaths()和randomGoalState()都会用到,所以在函数外面定义了1个
//actions[i]表示第i个按钮会影响到的灯的序号,如actions[0]={0,1,3},表示如若按下按钮0,则第0,1,3灯需要改变状态。
public static final int[][] actions={
{0,1,3},//0
{0,1,2},//1
{1,2,4},//2
{0,5,3},//3
{1,4,6,3},//4
{2,4,7},//5
{6,5,3},//6
{6,5,7},//7
{6,4,7},//8
};
@Override
public ArrayList<boolean[]> getPaths() {
GameState1 tempGame = new GameState1();//为了不改变内部状态,使用了一个临时对象存储数据
tempGame.setState(getState());
tempGame.setGoalState(getGoalState());
boolean [] path = {false,false,false,false,false,false,false,false,false};
while(!tempGame.testState()){//不停尝试,直到找到解
int i = random.nextInt(9);
path[i] = !path[i];
tempGame.doAction(i);
}
ArrayList<boolean[]> result = new ArrayList<>();
result.add(path);
return result;
}
@Override
public void doAction(int i){
if(i<0||i>actions.length)return;
for (int l:actions[i]) {
state[l]=!state[l];
}
}
//其它函数省略
}
这里有一点需要简单说明一下,为什么可以用boolean[] path 表示解,难道不会有1个按钮按了多次的情况吗?
稍加思考就会发现,如果按一个按钮两次,与它相关的灯会改变两次状态,等于没按;另外灯的状态只和按了哪些按钮有关,与顺序无关,即先按后按都一样。
通过运行,暴力破解法成功找到了问题解,速度也还可以。在我2006年买的笔记本上测试,运行10万次求解过程用时约4.6秒(以下测试都是在我的古董笔记本上进行的)。但是这个方法只能找到1种解,如果有多种解的话,每次运行得到的解是随机的。如何把所有的解都找出来呢?请接着往下看。
2、搜索法
在实现暴力破解法的时候,我们发现,问题的解是9个按钮不同状态的组合,每个按钮有两种可能(按或不按),且与顺序无关,共有2的9次方种不同组合,即512种可能,我们把所有可能都尝试一遍,就可以从中找到问题的所有解。代码如下:
public ArrayList<boolean[]> getPaths(){
ArrayList<boolean[]> paths = new ArrayList<>();
for (int n = 0; n < 512; n++) {
boolean[] path = Util.intToBArr(n,9);//将整数转换成boolean数组
if(testPath(state,goalState,path)){//testPath判断给定初始状态state是否可经由路径path到达目标状态goalState
paths.add(path);
//break;//找到1个解之后,停止
}
}
return paths;
}
/*
*此方法在Util类中,功能是将一个整数n,转换成长度为l的boolean数组
*如输入n=3,l=4,则返回bArr={true,true,false,false},与3的二进制0011对应
*因为数组是从左向右读,二进制数是从右向左读,所以顺序相反
*/
public static boolean[] intToBArr(int n, int l){
boolean [] bArr = new boolean[l];
for (int i = 0; i < l; i++) {
bArr[i] = ((n&(0x01<<i))>0);//1向左移i位后与n进行&操作,如果大于0,说明n的第i位为1,否则为0
}
return bArr;
}
通过运行,搜索法找到了问题的所有解,由初始状态到达目标状态,基本上都有2种路径(初始状态和目标状态相同时,其中1个解是不按任何按钮)。为什么都有2种解?回答这个问题,需要通过布尔代数来证明,放到最后说,我们先来分析一下搜索算法的效率。
通过测试,运行10万次求解过程,共耗时约28.5秒。如果找到1个解后就停止的话,则运行时间可缩减到10秒左右,还不如用暴力求解法瞎蒙呢。
我们分析一下,效率不高的原因。每检验一条路径,我们需要:
- 从将初始状态state加上1个path操作来改变其状态,这个操作记为1PATH;
- 1个path操作,需要按0-9个按钮,每按1个按钮的操作,记为1BUTTON,那么检验1个解平均需要按4.5个按钮。
1PATH≈4.5BUTTON
- 按下1个按钮,会改变3-4个灯的状态,每改变1个灯的状态,记为1LIGHT,9个按钮中除了第4个按钮会改变4个灯的状态,其它8个按钮分别改变3个灯的状态,(8*3+1*4)/9≈3LIGHT。
1BUTTON≈3LIGHT
/*testPath判断给定初始状态state是否可经由路径path到达目标状态goalState
*执行1次testPath的运算量记为1PATH
*/
private static boolean testPath(boolean[] initState, boolean[] goalState, boolean[]path){
boolean[] tempState = initState.clone();
for (int i = 0; i < path.length; i++) {
if(path[i]) {//执行1次点击按钮操作的运算量,记为1BUTTON
for (int j:actions[i]) {
tempState[j] = !tempState[j];//改变1次灯的状态的运算量,记为1LIGHT
}
}
}
for (int i = 0; i < tempState.length; i++) {
if(tempState[i]!=goalState[i]) return false;
}
return true;
}
在所有512种可能的解中,有2种为正解,每次随机选一种进行测试,选到正确解的概率为1/256,平均需要256次可以找到1个正解。按这么算的话,随机求解法需要:256PATH≈256*4.5BUTTON≈1152BUTTON,即试出1个正确解,平均需要1152次按钮操作。但是我们的代码中,生成随机解的时候,每次只随机更改1个按钮的状态,这样测试1种解的用时从4.5BUTTON减少到了1BUTTON,因为只是部分随机,所以得到一个正确解所需的测试次数从256次升高到了294次(运行了10万次求解过程平均得来的)。
也就是我们的随机求解法,得到1个正确解需要的操作量为:294BUTTON≈294*3LIGHT≈882LIGHT,即改变882次灯的状态。
//随机暴力求解法
while(!tempGame.testState()){
int a = random.nextInt(9);
path[a] = !path[a];
tempGame.doAction(a);//每次只随机选择1个按钮进行操作
}
在搜索法中,找到所有解,共需要对512个解进行检测。其操作量为:
512PATH≈512*4.5BUTTON≈2304BUTTON≈6912LIGHT,即得到所有解,需要改变6912次灯的状态。
可以看出搜索法执行的操作量是随机求解法的几倍。(当然,还有其他的运算我们这里没有计算进来,这里只是做一个大概的比较)。
3、搜索法的改进
我们对搜索法进行改进,以提高其求解速度。主要从以下几个方面着手:
-
是不是必须检测全部512个可能路径?可以在查找到1个解或2个解的时候停止搜索。
-
检测1个路径是否可以利用前一次的结果,让1PATH<4.5BUTTON? 因为1个path和另一个path可能只有1个按钮的状态不同,所以我们可以利用上次的结果来减少计算量。
-
能够通过1个操作改变多个灯的状态,让1BUTTON=1LIGHT? 通过,改变内部实现,通过位操作的方法,实现1次操作改变多个灯的状态。
下面是其实现类:
public class GameState3 implements GameStateInterface{
private int state = 0b11111111;//用1个整数的前8位来表示初始状态
private int goalState = 0b00000010;
Random rand = new Random();
private static int[] actions={
0b1011,//{0,1,3},按钮0
0b111,//{0,1,2},按钮1
0b10110,//{1,2,4},按钮2
0b101001,//{0,5,3},按钮3
0b1011010,//{1,4,6,3},按钮4
0b10010100,//{2,4,7},按钮5
0b1101000,//{6,5,3},按钮6
0b11100000,//{6,5,7},按钮7
0b11010000,//{6,4,7},按钮8
};
public void doAction(int i){//只需执行1个异或操作便可同时改变多个灯的状态
state = state^actions[i];
}
public boolean testState(){//检测当前状态是否和目标状态一致,也非常简单
return(state==goalState);
}
public ArrayList<boolean[]> getPaths(){
ArrayList<boolean[]> paths = new ArrayList<>();
GameState3 gs = new GameState3();//为了使这个操作不改变当前状态,用一个临时对象来保存运行时数据
gs.setState(getState());
gs.setGoalState(getGoalState());
treeSearch(gs,new boolean[9],0,paths);
return paths;
}
//将解空间映射成二叉树,并进行搜索,用到了递归调用
private void treeSearch(GameStateInterface gs, boolean[] action, int depth, ArrayList<boolean[]>paths){
//if(paths.size()>0) return;//找到1个解后停止搜索
//if(paths.size()==2) return;//找到2个解后停止搜索
if(depth==9){
if(gs.testState()){
paths.add(action.clone());
}
}else if(depth<9){
action[depth]=true;
gs.doAction(depth);
treeSearch(gs,action,depth+1,paths);
gs.doAction(depth);
action[depth]=false;
treeSearch(gs,action,depth+1,paths);
}
}
public void randomGoalState(){//生成随机状态也只需1步
goalState = rand.nextInt(256);
}
//省略了几个getter,setter函数
}
通过测试,运行10万次求解过程,共耗时约2.5秒。如果找到2个解后停止搜索,耗时约1.8秒,找到1个节后停止搜索,则耗时约1.3秒。不错了,比瞎蒙强了。
4.终极解法(布尔运算直接求解)
此方法以布尔代数,直接计算出来,解释了为什么会有2个正确解,为什么总会有解。
直接上代码:
public class GameState4 implements GameStateInterface {
//为了配合直接求解法,又换回了boolean数组的表示方式
private boolean [] state = {true,true,true,true,true,true,true,true};
private boolean [] goalState = {false,false,false,true,true,true,true,true};
@Override
public ArrayList<boolean[]> getPaths(){
ArrayList<boolean[]> paths = new ArrayList<>();
boolean[] path = new boolean[9];
path[1] = true;
generatePath(path);
paths.add(path.clone());
path[1] = false;
generatePath(path);
paths.add(path);
return paths;
}
private void generatePath(boolean[] path){
path[2]=state[0]^goalState[0]^state[2]^goalState[2]^state[3]^goalState[3]^state[6]^goalState[6]^state[7]^goalState[7];
path[0]=state[0]^goalState[0]^state[2]^goalState[2]^state[4]^goalState[4]^state[5]^goalState[5]^state[6]^goalState[6];
path[4]=path[0]^path[1]^path[2]^state[1]^goalState[1];
path[3]=path[0]^path[1]^state[0]^goalState[0];
path[5]=path[1]^path[2]^state[2]^goalState[2];
path[6]=path[0]^path[3]^path[4]^state[3]^goalState[3];
path[7]=path[2]^path[4]^state[4]^goalState[4]^state[7]^goalState[7];
path[8]=path[5]^path[7]^state[7]^goalState[7];
}
//省略了一些方法
}
通过测试,这个方法的执行效率快了1个数量级,运行10万次求解过程,共耗时约0.13秒,而且全部两个解都求出来了,什么随机求解法,什么搜索算法,简直都弱爆了,真正的降维打击。
有兴趣的可以尝试计算一下。不难,但是要特别细心,一不小心就会算错。(计算过程附后)
5.空间换时间
直接计算应该是最好的求解方法了吧?是的,反正我是想不出更好的方法了。但是我们还有一招,以空间换时间,这一招也是挺常用的,就是乘法口诀一样,我们把运算结果记下来,下次用到直接查,不用再计算了。
public class GameState5 implements GameStateInterface{
private ArrayList<ArrayList<boolean[]>> dict;//用来存储正确的解
private int state = 0b11111111;
private int goalState = 0b00000010;
public GameState5() {
//randomGoalState();
initDict();//初始化字典
}
private void initDict(){
dict = new ArrayList<ArrayList<boolean[]>>();
GameState4 gs4 = new GameState4();
gs4.setState(Util.intToBArr(0xff,8));
for (int i = 0; i < 256; i++) {//对256种可能的目标状态逐一求解,并将结果存到dict中
gs4.setGoalState(Util.intToBArr(i,8));
dict.add(gs4.getPaths());
}
}
@Override
public ArrayList<boolean[]> getPaths() {
return dict.get(0x000000ff^(goalState^state));//直接从dict中取出解
}
//省略了一些函数
}
通过测试,运行10万次求解过程,共耗时约20毫秒(包含了初始化字典的时间),运行100万次求解过程,共耗时约80毫秒。可以看出时间换空间的方法,效果还是非常明显的,求解速度又提升了1个数量级。但是这种方式有其局限性,字典不能太大,在需要很多重复计算的场景非常有用。在这个例子中是不需要的,在这个例子中任何一种方法都能满足使用要求。
三、几种求解方式的比较
求解方式 | 10万次(1解) | 10万次(2解) | 100万次(2解) |
---|---|---|---|
随机暴力求解法 | 4.6s | ---- | ---- |
搜索法 | 10s | 28.5s | ---- |
改进的搜索法 | 1.3s | 2.5s | 20.1s |
布尔代数直接计算法 | 80ms | 130ms | 900ms |
空间换时间法 | ---- | 20ms | 80ms |
文章涉及源码:https://gitee.com/xtaifghy/wugui
如有更好实现方式,欢迎探讨!
附:布尔代数求解过程
根据控制规则,可列出8个式子。如式1表示:第0号灯的目标状态G0可由初始状态S0加上相关按钮{B0、B1、B3}的影响得到,其影响方式与异或运算相同。
根据22和24式,可知B0 和B2可由初始状态和目标状态直接求出。将结果依次代入式20可求B3,代入式19可求B4,代入式17可求B5,代入式14可求B6,代入式12可求B7,代入式9可求B8,根据最终结果可知,B0、B2、B6、B8的值是确定的,B3、B4、B5、B7的值依赖于B1,而B1的取值可能为true或false,所以最终会有两种解。