最近UU前端团队内部兴起了新一波技术迭代,学习的风潮,很多同学在尝试利用新的技术栈去重构旧的项目,其中就有很多同学重写了九宫格拼图游戏,有些利用vue3+vite重构游戏组件,有些利用canvas重新实现了游戏逻辑,身为前端组一员,这种活动怎能错过,于是另辟捷径,为九宫格拼图开发一个自动拼图功能

希望通过这篇文章,抛砖引玉,帮助前端同学:

1,通过对算法的学习,对于数据驱动视图有更深入的了解

2,将算法和视图结合后,可以直观的了解算法的运行过程,避免了学习算法的枯燥

3,通过学习理解算法,可以让我们更好的理解框架,从而更好的使用框架,提高页面性能

题目说明

九宫格拼图,通过将一张图分成9张小图,打乱顺序后3X3排列,随机抽掉一张子图作为空白格,玩家每次移动空白格相邻的子图到空白格,不断通过移动,最终还原成原来的大图。

效果展示

问题分析:

九宫格拼图/数字拼图,其实就是八数码问题,给定一个初始状态和目标状态,求如何移动,完成从初始状态到目标状态

  • 数据/视图拆分

首先要将视图与数据拆分开,分开后的9张小图,从1-9来表示,空白格用0来表示,这样问题就从怎样还原图片变成怎样还原数据的问题。

  • 能否还原

首先有个问题,表示九宫格数据的数组,并不是每次随机打乱后,都能够还原成原始状态,就像魔方一样,正常魔方一定可以被还原,但如果将魔方的块扣下来,打乱后手动拼起来,就有很大的几率是无法被还原的,所以还需要判断是否能被还原,如何判断,则可以利用逆序对的来进行判断,

什么是逆序对,引用百度百科的解释:

通俗点来说,就是一个数组里,如果一个排在前边的数A,跟在它后边的数B做比较,如果A>B,则数组的逆数对数量+1

如果两个数组的逆序奇偶性相同,则可相互转换,否则不可转换。

  • 如何还原

假设将最后一张图作为空白格,还原成功后的大图,可以这样用数组表示:[1,2,3,4,5,6,7,8,0],只要将打乱后的数据,还原成这样,就表示拼图还原成功。

  • 搜索算法

九宫格的结构包含行,列,本质上是属于数据结构图的一种,用数组表示,即[[1,2,3,],[4,5,6],[7,8,9]]这样的二维数组,每一个子数组表示一行,子数组的元素表示列,如果使用一维数组,需要针对行,列位置进行处理。

我们使用状态空间搜索算法,即将问题求解过程,解释成寻找从初始状态到目标状态的过程。

常用的状态空间搜索有深度优先搜索和广度优先搜索。

1,深度优先搜索 (Depth First Search)

简称DFS,一种用来遍历树或图的算法,策略是尽可能深的搜索树的分支。从根节点开始,从该结点的子结点一直搜索,直到该路径上的最后一个结点,然后再从未被搜索过的的相邻节点进行搜索,不断重复,直至所有结点都被搜索到,搜索结束。

2,广度优先搜索 (Breath First Search)

简称BFS,以广度优先进行搜索,是一种盲目搜寻算法,会展开所有节点,搜索结果,也就是说,不会考虑结果可能存在的位置,而是搜索所有节点,直到找到结果为止,先访问根节点,然后访问其子节点,然后再依次进行被访问点的子节点,就像枪靶一样,一层一层往外扩展,搜索完一层,再搜索外边一层,直至最外层,搜索结束。

关于两种搜索策略的理解

举个例子:你跟你的同学四个人,站在一个十字路口,需要去一个地方,但是不知道方向,这里有两种选择:

第一种选择:随机选择一个方向,然后四个人一起走,知道走到路的尽头,如果不对,回到十字路口,再选择其他方向尝试,知道找到目的地。

第二种选择: 四个人各自选择一个方向,每次大家同时往外走一步,如果没有找到目的地,然后再同时往外一起走一步,重复这个过程,知道找到目的地。

  • 搜索算法与八数码问题

无论是DFS还是BFS,起始节点都是固定的,但八数码问题,每次操作的块并不都一样,所以用操作的块作为节点是不合适的,意味着起始节点一直在变化。

那么我们换个思路,将每次移动后9个块的位置状态作为一个节点,再跟正确顺序的状态作为节点做比较,是不是就可以了,同时每次移动之后,都会有三种或者四种后续移动方案,那么就相当于当前节点的后续子节点,如下图:

BFS和DFS有一个缺陷,都是在一个给定的状态空间范围内搜索,在状态空间不大的情况下比较适合,当状态空间比较大,而且不可预测的时候效率就会变低,而八数码问题的数据集,[1,2,3,4,5,6,7,8,0]总共有362880种排列组合,即9!种状态

考虑到时间和空间的限制,需要新的搜索策略,

  • A*寻路算法

A*寻路策略,属于启发式搜索的一种,在状态空间中搜索时,对每一次搜索的状态进行评估,计算出最优的状态,再从这个状态进行扩展搜索,可以理解为广泛搜索+深度搜索,在搜索过程中,对每一种状态的估价是十分重要的,采用不同的估价会产生不同的效果。

估价函数:f(n) = h(n) + h(n)

f(n)表示节点n的估价函数

g(n) 表示在当前状态空间中,从初始节点到N节点的实际代价

h(n) 表示在当前状态空间中,从N节点到目标节点的实际代价

在八数码问题中,我们可以计算出每个块到达目标位置的曼哈顿距离,例如下图将数字8移动到数字0的位置只需要1步,而移动到数字6的位置,则需要2步,

结合上边的公式,每个块的估价f(n) = g(n) 从上一位置到当前位置的步数 + h(n) 从当前位置到目标位置

状态的估值 = 所有数字的估值之和

结合上边的说明,每次移动后,分别计算后续移动方案的估值,然后选择一个估值最小的方案继续搜索。

原理讲完了,下边上完整代码:

class BlockState {
    //状态估值
    f;
    //从当前状态到下一状态的步数
    g;
    //下一状态到最终状态的步数
    h;

    blocks = [];
    parentBlockState;
    successBlocks = [1, 2, 3, 4, 5, 6, 7, 8, 0]
    moveBlocks = [];
    //拼图规格
    rows = 3;
    cols = 3;

    moveStep;


    constructor(blocks, parentBlockState) {
        this.blocks = blocks;
        this.parentBlockState = parentBlockState;
    }


    setMoveBlocks(from, to) {
        this.moveStep = {
            from,
            to
        }
    }

    //获取块的行列数据  一维数组需要特殊处理行列数据
    getPlaceBlock(index) {
        let row = Math.floor(index / this.rows);
        let col = index < this.cols ? index : index % this.cols;
        return { row, col };
    }

    //转成字符串
    getBlockData() {
        return this.blocks.toString();
    }

    //计算评估值
    getF() {
        this.f = this.f ? this.f : this.calcG() + this.calcH();
        return this.f;
    }

    //计算从父状态到当前状态的步数
    calcG() {
        this.g = 0;
        this.parentBlockState.blocks.forEach((e, i) => {
            let p = this.getPlaceBlock(i);
            let c = this.getPlaceBlock(this.blocks.indexOf(e));
            this.g += this.calcDistance(p, c);
        })
        return this.g;
    }


    //从当前状态到完成状态的步数
    calcH() {
        this.h = 0;
        this.blocks.forEach((e, i) => this.h += this.calcDistance(this.getPlaceBlock(i), this.getPlaceBlock(e == 0 ? this.successBlocks.length - 1 : e - 1)));
        return this.h;
    }

    //计算距离,曼哈顿距离
    calcDistance(from, to) {
        return Math.abs(from.row - to.row) + Math.abs(from.col - to.col);
    }

    //扩展子状态
    expand() {
        let index0 = this.blocks.indexOf(0);
        let childBlocks = [];
        
        this.blocks.forEach((e, i) => {
            //通过距离空位距离为1来查找后续的状态
            if (this.calcDistance(this.getPlaceBlock(index0), this.getPlaceBlock(i)) == 1) {
                let nextBlocks = _.cloneDeep(this.blocks);
                [nextBlocks[i], nextBlocks[index0]] = [nextBlocks[index0], nextBlocks[i]];
                let nextBlockState = new BlockState(nextBlocks, this);
                nextBlockState.getF();
                nextBlockState.setMoveBlocks(i + 1, index0 + 1);
                childBlocks.push(nextBlockState);
            }
        })
        return childBlocks;
    }
    //获取上级移动步骤
    getSuccessMove() {
        let moveSteps = []
        let getParentMoveStep = (state) => {
            if (state.parentBlockState) {
                moveSteps.unshift(state.moveStep)
                getParentMoveStep(state.parentBlockState);
            }
        }
        getParentMoveStep(this);
        return moveSteps;
    }
}




class Robot {

    openStateList = [];
    closeStateList = [];

    //开始数据组合
    startBlocks = [];


    //成功组合
    successBlocks = [1, 2, 3, 4, 5, 6, 7, 8, 0];
    constructor(startBlocks) {
        this.startBlocks = startBlocks;
    }


    //运行
    crackRun() {
        console.log('开始还原')
        //根据逆序对检查是否可还原
        let copyStartBlocks = _.cloneDeep(this.startBlocks);
        let copySuccessBlocks = _.cloneDeep(this.successBlocks);

        let checkRes = this.checkCrash(copyStartBlocks, copySuccessBlocks);

        if (!checkRes) {    
            throw new Error('当前拼图不可被还原')
        }

        let successState;

        //初始化块
        let startBlockState = new BlockState(this.startBlocks);
        this.openStateList.push(startBlockState);
        let isCracked = false;
        while (this.openStateList.length > 0) {
            let checkState = this.openStateList.shift();
            this.closeStateList.push(checkState);
            console.log('当前验证的状态', checkState.getBlockData())
            if (checkState.getBlockData() === this.successBlocks.toString()) {
                successState = checkState
                isCracked = true;
                break;
            }
            //扩展子状态
            let childBlocks = checkState.expand();
            if (childBlocks) {
                //判断状态是否已经校验过
                childBlocks = childBlocks.filter(e => this.closeStateList.findIndex(b => {
                    return b.getBlockData() === e.getBlockData();
                }) === -1)
                this.openStateList.push(...childBlocks);
                //根据估值进行排序
                this.openStateList.sort((a, b) => {
                    return a.getF() - b.getF();
                })
            }
        }
        if (isCracked && successState) {
            let steps = successState.getSuccessMove();
            console.log('成功解出,按照以下步骤还原')
            return steps;
        } else {
            throw new Error('臣妾做不到')
        }
    }


    //判断是否可以解出
    checkCrash(startBlock, successBlocks) {
        return this.inverseCount(startBlock) === this.inverseCount(successBlocks);
    }
    //计算逆序对 这里直接暴力查询,也可采用归并排序查询
    inverseCount(blocks) {
        let num = 0;
        blocks.splice(blocks.indexOf(0), 1);
        blocks.forEach((item, index) => {
            for (let i = 0; i < index; i++) {
                if (blocks[i] != 0 && blocks[i] > item) {
                    num++;
                }
            }
        });
        return num % 2 ? 1 : 0;
    }

}

发表回复

您的电子邮箱地址不会被公开。 必填项已用 * 标注