【可能是個假前端】掃雷之平鋪演算法

前端深夜告解室發表於2018-06-05

前言

作為一名前端攻城獅,寫個假前端的 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. 把握好細節

下一步,我們需要的是給雷區裡的格子加上各種狀態,來隱藏地雷。同時給格子們綁上事件。

在繫結事件的過程中,又有好玩的思考。期待一下吧。

相關文章