【可能是个假前端】扫雷之平铺算法

2,666 阅读6分钟

前言

作为一名前端攻城狮,写个假前端的 Topic ,什么鬼?你说一个好好的前端不做,搞什么假前端?

问号

FBI WARNING:正宗的前端知识移步专栏里隔壁大神系列

FBI WARNING

If you want authentic front-end knowledge, get out and turn left, see Lao Wang.

言归正传,这个 Topic 系列的文章我会尽量多说一些可能与前端知识关系不太大但非常有意思的东西,是希望将自己实践中遇到的一些逻辑问题和算法问题以及一些其他知识与大家分享。

扫雷

在工作之余,有课外开发的习惯。目的是将自己从重复的业务代码中摆脱出来,做一些有意思的东西。小时候非常爱玩的一个游戏就是扫雷,于是就有了这个系列的第一个文章集 -- 假前端之扫雷系列。

规则小结

在我写出这个东西以后,找了一些同学去玩:

“扫雷啊~~不会啊~”

“不知道怎么玩~~以前都是瞎点。”

鉴于此,科普一些规则,熟悉的同学跳过:

扫雷就是点格子!!!

当然,还是有点技巧的:格子数字代表周围一圈 8 个格子里面藏着多少个雷。

不小心暴露了单身 20 年的手速~

初始化游戏实现

很多同学用 canvas 来实现游戏,其原因是方便数据渲染视图和游戏状态的刷新。

这个系列的扫雷,我起了 vue 来实现我的数据与视图的同步,为什么不用 canvas ?假前端系列未来会有一大波 canvas 的文章,就暂时不用再这里了。

画格子

这一步,就要考虑我们要用一个怎样的数据结构来表示整个游戏雷区。

没错,一个二维数组。但是我们能不能就用一个一维数组实现呢?完全可以。这里,我们就用二维数组来实现,直观。

const SAFE_CELL = 0

export default {
  // ...
  // 生成一个 15 行 10 列的二位数组
  data () {
    return {
      dataList: (new Array(15))
        .fill(0)
        .map(
          it => (new Array(10))
          .fill(SAFE_CELL)
        )
    }
  }
  // ...
}

我们设置了 const SAFE_CELL = 0 作为默认填充,这个 0 表示周围都没有地雷,这样一个空白的地雷区域就出现了。

平铺算法布雷

游戏中,地雷的分布是完全了随机的。需要你通过一定的逻辑判断找出来。这里,我选择了 const MINE_CELL = 9 作为地雷标识,原因是 1~8 作为标识周围雷数需要用到,这个 1~8 的数字就是标识周围有多少个地雷。

那么,我们如何将一定数量的地雷随机分布到整个二维数组中呢?

这里的随机分布的要求很简单:

  1. 雷的数量是固定的
  2. 每个格子是否是雷的概率是一样的

可能首先想到的方法,是【替换法】:在这个二维数组里随机找出不是地雷的格子,将其替换成地雷就可以了。

看起来似乎是不错的方案。实际上, 是有问题的 。问题在哪?

如果雷数密度到达一定高度,挑出一个不是地雷的格子是相当困难的,例如:一个 10 * 10 的雷区,里面有 99 个地雷,那么第 99 个地雷想找出剩下的两个 SAFE_CELL 非常困难,如果进行判断:是地雷,再重新随机挑选一个格子。不仅消耗时间,还很容易进入一个死循环,这个方案只能放弃。

那么我不进行替换,有没有别的方法?

【插入法】,生成一个 SAFE_CELL 数量的一维数组,将雷随机插入到数组中,再裂成一个二维数组。例如 10 * 10 的雷区有 10 个雷,我先生成长度 90 的以为数组,再将 10 个地雷随机插入到数组中,最后裂成一个 10 * 10 的二维数组。

看似完美解决了无限循环的问题,但是我们知道,对数组元素进行添删操作是非常消耗性能的,我们在数组中增减一个元素,其后的每一个元素的下表随之需要移位。

这里,我介绍下我的平铺思路:

生成一个包含所有地雷和空白区域的一维有序数组, 利用 洗牌算法 将数组的顺序打乱,最后裂成二维数组。

const SAFE_CELL = 0
const MINE_CELL = 9

export default {
  methods: {
    //...
    // 初始化数据
    initData () {
      const rows = 15
      const cols = 10
      const mines = 10
      const safeCellNum = rows * cols - mines
      const safeArea = (new Array(safeCellNum)).fill(SAFE_CELL)
      const mineArea = (new Array(mines)).fill(MINE_CELL)
      let totalArea = safeArea.concat(mineArea)
      totalArea = this.mineShuffle(totalArea) // 洗牌
      this.dataList = totalArea.reduce((memo, curr, index) => {
        if (index % cols === 0) memo.push([curr])
        else memo[memo.length - 1].push(curr)
        return memo
      }, [])
    }
  }
}

这里面涉及到一个洗牌算法,我简单的介绍一下。前辈们实现的洗牌算法多种多样,性能和效果各异。这里我选用的是我认为性能和效果兼优,实现也非常简单的 Fisher–Yates shuffle 算法。如果你注意过 lodash 源码的话,lodash 里面的 shuffle 也是用这个算法实现的

其思路就是从尾部开始将未打乱的元素与一个随机的未打乱的剩余元素进行调换,直到数组的所有元素都被打乱。

下面给出我的实现:

export default {
  methods: {
    //...
    // Fisher–Yates shuffle 算法
    mineShuffle (array, mine = array.length) {
      let count = mine
      let index
      while (count) {
        index = Math.floor(Math.random() * count--)
        ;[array[count], array[index]] = [array[index], array[count]]
      }
      return array
    }
  }
}

代码中,元素调换是利用 es6 的解构赋值,由于是就地调换元素的值,所以不存在性能问题。

图中对比明显可以看出: 百万长度的数组,在浏览器环境下 Fisher-Yates 洗牌算法稳定在 70 ms 左右;而同样是 O(n) 时间复杂度的插入算法,在处理同样长度的数组时,性能落后非常多!

完成洗牌后,我们将数组裂为二维数组交给 vue 渲染。此时,我们的视图呈现:

计算环境数字

雷区有了地雷,我们就该计算地雷周围的环境数字了,这个数字的意义是标识这个数字周围隐藏着多少个地雷,这个在规则一节中有讲。

计算环境数字很简单,循环一遍二维数组,如果遇到这个格子是个地雷,周围所有个字的数字 +1 就行了。注意格子在边缘的情况。

const AROUND = [
  [-1, -1],
  [-1, 0],
  [-1, 1],
  [0, -1],
  [0, 1],
  [1, -1],
  [1, 0],
  [1, 1]
]
const MINE_CELL = 9

export default {
  methods: {
    // ...
    // 设置环境数字
    setEnvNum () {
      this.dataList.forEach((row, rowIndex) => {
        row.forEach((cell, colIndex) => {
          if (cell === MINE_CELL) {
            AROUND.forEach(offset => {
              const row = rowIndex + offset[0]
              const col = colIndex + offset[1]
              if (
                this.dataList[row] &&
                this.dataList[row][col] !== undefined &&
                this.dataList[row][col] !== MINE_CELL
              ) this.dataList[row][col]++
            })
          }
        })
      })
    }
  }
}

此时:

小总结

到此为止,我们终于生成个一个扫雷的初始雷区,包含了随机分布的地雷以及地雷周围正确的数字。

在实现的过程中,我们做一下总结:

  1. 先思考再动手。
  2. 时常保持对代码逻辑边际情况的考虑,多想想这么写会怎么崩溃。
  3. 把握好细节

下一步,我们需要的是给雷区里的格子加上各种状态,来隐藏地雷。同时给格子们绑上事件。

在绑定事件的过程中,又有好玩的思考。期待一下吧。