以一个小例子谈一下算法的优化

文章涉及源码:https://gitee.com/xtaifghy/wugui

目录

引子

一、将问题抽象化

1、问题抽象化:

2、定义数据类型

3、定义接口

二、求解方式的实现

1、随机暴力求解法

2、搜索法

3、搜索法的改进

4.终极解法(布尔运算直接求解)

5.空间换时间

三、几种求解方式的比较

附:布尔代数求解过程

引子

在十几年前玩过一个叫做《天河传说》的游戏,在游戏快通关的时候有一个解密游戏,见下图:

玩家要过这关,除了杀死敌人外,还要通过踩乌龟背上的几个点,来控制乌龟达到某种状态,然后大乌龟会载着玩家游到对岸。其具体操作规则为:

初始状态: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个按钮。

1PATH4.5BUTTON

  • 按下1个按钮,会改变3-4个灯的状态,每改变1个灯的状态,记为1LIGHT9个按钮中除了第4个按钮会改变4个灯的状态,其它8个按钮分别改变3个灯的状态,(8*3+1*4)/9≈3LIGHT

1BUTTON3LIGHT​​​​

/*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个正解。按这么算的话,随机求解法需要:256PATH256*4.5BUTTON1152BUTTON,即试出1个正确解,平均需要1152次按钮操作。但是我们的代码中,生成随机解的时候,每次只随机更改1个按钮的状态,这样测试1种解的用时从4.5BUTTON减少到了1BUTTON,因为只是部分随机,所以得到一个正确解所需的测试次数从256次升高到了294次(运行了10万次求解过程平均得来的)。

也就是我们的随机求解法,得到1个正确解需要的操作量为:294BUTTON294*3LIGHT882LIGHT即改变882次灯的状态。

//随机暴力求解法
while(!tempGame.testState()){
            int a = random.nextInt(9);
            path[a] = !path[a];
            tempGame.doAction(a);//每次只随机选择1个按钮进行操作
        }

在搜索法中,找到所有解,共需要对512个解进行检测。其操作量为:

512PATH512*4.5BUTTON2304BUTTON6912LIGHT,即得到所有解,需要改变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--------
搜索法10s28.5s----
改进的搜索法1.3s2.5s20.1s
布尔代数直接计算法80ms130ms900ms
空间换时间法----20ms80ms

文章涉及源码:https://gitee.com/xtaifghy/wugui

如有更好实现方式,欢迎探讨!

附:布尔代数求解过程

根据控制规则,可列出8个式子。如式1表示:第0号灯的目标状态G0可由初始状态S0加上相关按钮{B0、B1、B3}的影响得到,其影响方式与异或运算相同。

根据2224式,可知B0 和B2可由初始状态和目标状态直接求出。将结果依次代入式20可求B3,代入式19可求B4,代入式17可求B5,代入式14可求B6,代入式12可求B7,代入式9可求B8,根据最终结果可知,B0B2B6B8的值是确定的,B3B4B5B7的值依赖于B1,而B1的取值可能为truefalse,所以最终会有两种解。

 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值