隨機生成指定面積單連通區域

原文連接: https://xcoder.in/2018/04/01/random-connected-area/

最近在知乎上看到一個問題,「隨機生成指定面積單連通區域?」,感受還挺有意思的,因而整理一下寫一篇新文章。javascript

問題闡述

以下圖所示,在 10x10 的區域中,隨機生成面積爲 6 的單連通區域,該「隨機」包括「位置隨機」以及「形狀隨機」。java

示意圖

注意:算法

  1. 單連通區域定義是該區域每個區塊上下左右至少連着另外一個區塊;
  2. 採用週期性結構,超出右邊移到最左邊,以此類推。
其中點 2 能夠分採用和不採用週期性結構分別討論。

隨便說說

這個問題,我不知道原題提問者想要作什麼事。可是就這題自己而言,咱們能夠拿它去生成一個隨機地圖,例如:segmentfault

建造、等待的沙盒類手遊,遊戲中有一個空島,玩家能在上面建造本身的建築而後等待各類事件完成。 空島形狀隨機生成,而且都聯通且面積必定,這樣每一個玩家進去的時候就能獲得不一樣地形

解決一下

在得知了問題原題以後,咱們就能夠照着題目的意思開始解決了。數組

DFS

其實這麼一個問題一出現,腦子裏面就瞬間涌出幾個詞彙:DFSFlood fill並查集等等。xcode

那麼其實這最粗暴的辦法至關於你假想有一個連通區域,而後你去 Flood fill 它——至於牆在哪,在遞歸的每個節點的時候隨機一下搜索方向的順序就能夠了框架

實現外殼

咱們先實現一個類的框架吧(我是 Node.js 開發者,天然用 JavaScript 進行 Demo 的輸出)。dom

const INIT = Symbol("init");

class Filler {
    /**
     * Filler 構造函數
     * @constructor
     * @param {Number} length 地圖總寬高
     * @param {Number} needArea 須要填充的區域面積
     */
    constructor(length, needArea) {
        this.length = length;
        this.needArea = needArea;
    }

    /**
     * 初始化地圖
     */
    [INIT]() {
        /**
         * 爲了方便,地圖就用一個二維字符數組表示
         *
         *   + . 表明空地
         *   + x 表明填充
         */
        this.map = [];
        this.count = 0;
        for(let i = 0; i < this.length; i++) {
            let row = [];
            for (let j = 0; j < this.length; j++) row.push(".");
            this.map.push(row);
        }
     }

     /**
      * 填充遞歸函數
      * @param {Number} x 座標 X 軸的值
      * @param {Number} y 座標 Y 軸的值
      * @return 填充好的地圖二維數組
      */
     fill(x, y) {
        // 待實現
     }
}

非週期性實現

有了架子以後,咱們就能夠實現遞歸函數 fill 了,整理一下流程以下:svg

  1. 隨機一個起點位置,並以此開始遞歸搜索;
  2. fill(x, y) 進入遞歸搜索:函數

    1. 若是須要初始化地圖就調用 this[INIT]()
    2. this.count++,表示填充區域面積加了 1,並在數組中將該位置填充爲 x
    3. this.count 是否等於所須要的面積:

      1. 若等於,則返回當前的地圖狀態;
      2. 若不等於,則繼續 2.4;
    4. 隨機四個方向的順序;
    5. 對四個方向進行循環:

      1. xy 軸的值按當前方向走一個算出新的座標值 newXnewY
      2. 判斷座標是否合法(越界算非法):

        1. 若非法則回 2.5 繼續下一個方向;
        2. 若合法則繼續 2.5.3;
      3. 遞歸 fill(newX, newY) 獲得結果,如有結果則返回;
    6. 若循環完四個方向都還沒返回結果則會跳到這一步來,這個時候進行狀態還原,遞歸跳回上一層進行下一個狀態的搜索。
在這裏「狀態還原」表示把 this.count-- 還原回當前座標填充前的狀態,而且把當前填充的 'x' 給還原回 '.'

照着上面的流程很快就能得出代碼結論:

const _ = require("lodash");

class Filler {
    ...

    fill(x, y) {
        // 初始化地圖
        const needInit = !arguments[2];
        if(needInit) this[INIT]();

        // 若是當前座標已被填充,則返回空
        if(this.map[x][y] === "x") return;

        // 填充當前座標
        this.count++;
        this.map[x][y] = "x";

        // 填充滿了則返回當前地圖
        if(this.count === this.needArea) return Object.assign([], this.map);

        // 隨機四個方向的順序
        const dirs = _.shuffle([ [ 0, 1 ], [ 0, -1 ], [ 1, 0 ], [ -1, 0 ] ]);

        // 循環四個方向
        for(let i = 0; i < 4; i++) {
            const dir = dirs[i];
            let newX = x + dir[0];
            let newY = y + dir[1];

            // 判斷邊界
            {
                if(newX < 0 || newX >= this.length || newY < 0 || newY >= this.length) continue;
            }

            // 進入下一層遞歸併獲得結果
            const ret = this.fill(newX, newY, true);

            // 若結果非空則返回結果
            if(ret) return ret;
        }

        // 狀態還原
        this.count--;
        this.map[x][y] = ".";
    }
}

這麼一來,類就寫好了。接下去咱們只要實現一些交互的代碼,就能夠看效果了。

點我進入 JSFiddle 看效果。

若是懶得進入 JSFiddle 看,也能夠看看下面的幾個截圖:

10x10 填 50 效果圖

10x10 填 6 效果圖

50x50 填 50 效果圖

週期性實現

其實原題說了一個條件,那就是採用週期性結構,超出右邊移到最左邊,以此類推

而咱們前文的代碼實際上是照着非週期性結構來實現的。不過若是咱們要將其改爲週期性實現也很簡單,只須要把前文代碼中邊界判斷的那一句代碼改成周期性計算的代碼便可,也就是說要把這段代碼:

// 判斷邊界
{
    if(newX < 0 || newX >= this.length || newY < 0 || newY >= this.length) continue;
}

改成:

// 週期性計算
{
    if(newX < 0) newX = this.length - 1;
    if(newX >= this.length) newX = 0;
    if(newY < 0) newY = this.length - 1;
    if(newY >= this.length) newY = 0;
}

這個時候出來的效果就是這樣的了:

10x10 填 50 週期性效果圖

拋棄狀態還原

至此爲止 DFS 的代碼基本上完成了。不過目前來講,固然這個算法的一個缺陷就是,當須要面積與總面積比例比較大的時候,有可能陷入搜索的死循環(或者說效率特別低),由於要不斷覆盤。

因此咱們能夠作點改造——因爲咱們不是真的爲了搜索到某個狀態,而只是爲了填充咱們的小點點,那麼 DFS 中比較經典的「狀態還原」就不須要了,也就是說:

this.count--;
this.mat[x][y] = ".";

這兩行代碼能夠刪掉了,用刪掉上面兩行代碼的代碼跑一下,我用 50x50 填充 800 格子的效果:

繼續以前的 50x50 填充 50:

生成「胖胖的」區域

上面 DFS 的方法,因爲每次都要走完一條路,雖然會轉彎致使黏連,但在填充區域很小的狀況下,很容易生成「瘦瘦的區域」。

這裏再給出另外一個方法,一個 for 搞定的,思路以下:

  1. 先隨機一個起始點,並將該點加入邊界池;
  2. 循環 N - 1 次(N 爲所須要填充的面積):

    1. 從邊界池中隨機取出一個邊界;
    2. 算出與其接壤的四個點,取出還未被填充的點;
    3. 在取出的點中隨機一個將其填充;
    4. 填充後計算改點接壤的四個點是否有全都是已經填充了的,若不是,則將該座標加入邊界池;
    5. 拿着剛纔計算的接壤的四個點,分別判斷其是否周邊四個點都已被填充,如果且該點在邊界池中,則從邊界池拿走;
    6. 回到第二大步繼續循環;
  3. 返回填充好的結果。

給出代碼 Demo:

function random(max) {
    return Math.round(Math.random() * max);
}

class Filler2 {
    constructor(length, needArea) {
        this.length = length;
        this.needArea = needArea;
    }

    _getContiguous(frontier) {
        return Filler2.DIRS.map(dir => ({
            x: frontier.x + dir[0],
            y: frontier.y + dir[1]
        }));
    }

    fill() {
        const mat = [];
        for (let i = 0; i < this.length; i++) {
            let row = [];
            for (let j = 0; j < this.length; j++) row.push(".");
            mat.push(row);
        }

        const start = {
            x: random(this.length - 1),
            y: random(this.length - 1)
        };
        mat[start.x][start.y] = "x";

        let frontierCount = 1;
        const frontiers = {
            [`${start.x}:${start.y}`]: true
        };

        for (let i = 1; i < this.needArea; i++) {
            // 取出一個邊界
            const randIdx = random(frontierCount - 1);
            const frontier = Object.keys(frontiers)[randIdx].split(":").map(n => parseInt(n));

            // _getContiguous 算出接壤座標,filter 去除無用座標
            const newCoors = this._getContiguous({
                x: frontier[0],
                y: frontier[1]
            }).filter(coor => {
                if (coor.x < 0 || coor.y < 0 || coor.x >= this.length || coor.y >= this.length) return false;
                if (mat[coor.x][coor.y] === "x") return false;
                return true;
            });

            // 隨機取一個座標
            const newCoor = newCoors[random(0, newCoors.length - 1)];

            // 填充進去
            mat[newCoor.x][newCoor.y] = "x";

            // 獲取接壤座標
            const contiguousOfNewCoor = this._getContiguous(newCoor).filter(coor => {
                if (coor.x < 0 || coor.y < 0 || coor.x >= this.length || coor.y >= this.length) return false;
                return true;
            });

            // 如有一個接壤點爲空,就認爲當前座標是邊界,如果邊界則把當前座標加入對象
            if (contiguousOfNewCoor.reduce((ret, coor) => {
                    if (mat[coor.x][coor.y] === "x") return ret;
                    return true;
                }, false)) {
                frontiers[`${newCoor.x}:${newCoor.y}`] = true;
                frontierCount++;
            }

            // 再檢查接壤的座標是否繼續爲邊界
            for (let i = 0; i < contiguousOfNewCoor.length; i++) {
                const cur = contiguousOfNewCoor[i];

                const isFrontier = this._getContiguous(cur).filter(coor => {
                    if (coor.x < 0 || coor.y < 0 || coor.x >= this.length || coor.y >= this.length) return false;
                    return true;
                }).reduce((ret, coor) => {
                    if (mat[coor.x][coor.y] === "x") return ret;
                    return true;
                }, false);

                // 若不是邊界的話,只管刪除
                if (!isFrontier && frontiers[`${cur.x}:${cur.y}`]) {
                    delete frontiers[`${cur.x}:${cur.y}`];
                    frontierCount--;
                }
            }
        }

        // 一圈下來,就出結果了
        return mat;
    }
}

Filler2.DIRS = [ [ 0, 1 ], [ 0, -1 ], [ 1, 0 ], [ -1, 0 ] ];
注意:上面的代碼是我一溜煙寫出來的,因此並無後續優化代碼簡潔度,其實不少地方的代碼能夠抽象並複用的,懶得改了,能看就行了。用的時候就跟以前 DFS 代碼同樣 new 一個 Filler2 出來並 fill 就行了。

效果依然能夠去 JSFiddle 看。

或者也能夠直接看效果圖:

50x50 填充 800 胖胖的區域

50x50 填充 50 胖胖的區域

顯而易見,跟以前 DFS 生成出來的奇形怪狀相比,這種算法生成的連通區域更像是一塊 Mainland,而前者則更像是一個窪地沼澤或者叢林。

結合一下?

前面兩種算法,一個是生成瘦瘦的稀奇古怪的面積,一個是生成胖胖的區域。有沒有辦法說在生成胖胖的區域的狀況下容許必定的稀奇古怪的形狀呢?

其實將兩種算法結合一下就行了。結合的作法有不少,這裏舉一個例子,你們能夠本身再去想一些出來。

  1. 首先將須要的區域對半分(即配比 1 : 1),例如若是須要 800,就分爲 400 跟 400。(爲了長得好看,其實這個比例能夠自行調配)
  2. 將前一半的區域用 for 生成胖胖的區域;
  3. 將剩下的區域隨機幾回,每次隨機一個剩下所須要的面積之內的數,將這個數字做爲 DFS 所須要生成的面積量,並從邊界數組中隨機取一個邊界座標並計算其合法接壤座標開始進行 DFS 獲得結果;
  4. 循環第 3 步知道所需區域面積符合要求爲止。
注意:爲了保證每次 DFS 一開始的時候都能取到最新的邊界座標,在 DFS 流程中的時候每標一個區域填充也必須走一遍邊界座標更新的邏輯。

具體代碼就不放文章裏面解析了,你們也能夠到 JSFiddle 中去觀看。

或者也能夠直接看效果圖:

50x50 填充 800 混合區域(配比 3 : 1)

50x50 填充 50 胖胖的區域(配比 4 : 1)

還能更喪心病狂嗎?

結合了兩種算法,咱們獲得了一個我認爲可能會更好看一點的區域。

此外,咱們還能繼續「喪心病狂」一點,例如兩種方式交替出現,流程以下:

  1. 指定特定方法和麪積,奇數次用 for,偶數次用 DFS;

    1. 若是是 for 則隨機一個 Math.min(剩餘面積, 總面積 / 4) 的數字;
    2. 若是是 DFS 則隨機一個 Math.min(剩餘面積, 總面積 / 10) 的數字;
  2. 從邊界數組中取一個座標,並從合法接壤座標中取一個座標出來;
  3. 以第 2 步取出的座標爲起點,使用第 1 步指定的方法生成第 1 步指定的面積的單連通區域;
  4. 若是生成面積仍小於指定面積,則回到第 1 步繼續循環,不然返回當前結果。

依舊是給出 JSFiddle 的預覽

或者也能夠直接看效果圖:

50x50 填充 800 喪病區域

50x50 填充 800 喪病區域

注意:這裏只給出思路,具體配比和詳細流程你們能夠繼續優化。

幾張效果對比圖

最後,這裏給出幾張 10x10 填 50 的效果圖放一塊兒對比一下。

DFS 週期性 DFS 非還原 DFS 非還原週期性 DFS 胖胖的 結合 更喪病

以及,幾張 50x50 填充 800 面積的效果圖對比。

DFS 週期性 DFS 非還原 DFS 非還原週期性 DFS 胖胖的 結合 更喪病

我錯了之『真·單連通區域』

之因此多出一節來,是由於我在寫回答以及這篇文章的時候腦抽了一下,迷迷糊糊想成了連通區域,感謝評論區童鞋的提醒。實際上單連通區域要稍微複雜一些。

在拓撲學中,單連通是拓撲空間的一種性質。直觀地說,單連通空間中全部閉曲線都能連續地搜索至一點。此性質能夠由空間的基本羣刻畫。

連通區域

<center>這個空間不是單連通的,它有三個洞</center>

——單連通@Wikipedia

對於非週期性的區域來講,生成一個單連通區域只要在上面的方法裏面加點料就能夠了。即在一個位置填充的時候,判斷一下將它填充進去以後是否會出現所謂的「洞」。而這一點在非週期性區域中,因爲在填充當前座標前,已存在的區域已是一個單連通區域,因此枚舉一下幾種狀況便可排除非單連通區域的狀況:

  1. 新加的座標其上下都有填充,但其左右爲空;或者左右都有填充,但其上下爲空;
  2. 新加的座標只有一面相鄰有填充,但該面對面的邊所對應的兩個角對過去至少有一個角與其它座標共享頂點;
  3. 新加的座標同一個頂點的兩條邊有接壤,且其對角頂點對過去的座標與其共享頂點。
而對於週期性的區域來講,暫時我還沒想到很好的辦法。

對於狀況一而言,若是處於對面的兩接壤座標都有填充,且再多一個接壤面的話,原小區域內只有多是「」型,那麼填充進去只會造成一個 2x3 的實心區域,而若是隻有處於對面的兩個接壤座標有填充的話,說明原小區域有兩個面對面隔空的區域,它們造成單連通區域的大前提就是從其它地方繞出去將它們連起來,若這個時候將它們閉合的話,勢必會造成一個空洞,以下圖所示:

狀況一

對於狀況二而言,若是隻有一面有填充,可是對面的頂點有共享的話,能夠類比爲狀況一,舉例以下:

狀況二

對於狀況三而言,其實就是狀況二加一條邊有填充,若是在狀況二的狀況下,在上圖「原」的區域下方的空若已有填充,那麼在「新」的位置填充進去,就形不成空洞了。畢竟若是「空」的位置已有填充的話,若先前狀態生成沒有洞的連通區域,則「空」下方也一定不是一個空洞的區域。

在解析完三種狀況後,算法就明朗起來——在上面的 DFS 算法每次執行填充操做的時候,都判斷一下當前填充是否符合剛纔列舉的三種狀況,若符合,則不填充該點。

因此只需對 DFS 的那個代碼作一下修改就行了,首先把狀態還原兩行代碼刪掉,而後在以前

if (newX < 0 || newX >= this.length || newY < 0 || newY >= this.length) continue;

這句代碼之下加一個條件判斷就行了:

if(this.willBreak(newX, newY)) {
    continue;
}

剩下的就是去實現 this.willBreak() 函數:

class Filler {
    ...
    
    willBreak(x, y) {
        // 九宮格除本身之外的其它格狀態
        let u = false, d = false, l = false, r = false;
        let lu = false, ld = false, ru = false, rd = false;
        if(x - 1 >= 0 && this.map[x - 1][y] === 'x') u = true;
        if(x + 1 < this.length && this.map[x + 1][y] === 'x') d = true;
        if(y - 1 >= 0 && this.map[x][y - 1] === 'x') l = true;
        if(y + 1 < this.length && this.map[x][y + 1] === 'x') r = true;
        if(x - 1 >= 0 && y - 1 >= 0 && this.map[x - 1][y - 1] === 'x') lu = true;
        if(x - 1 >= 0 && y + 1 < this.length && this.map[x - 1][y + 1] === 'x') ru = true;
        if(x + 1 < this.length && y - 1 >= 0 && this.map[x + 1][y - 1] === 'x') ld = true;
        if(x + 1 < this.length && y + 1 < this.length && this.map[x + 1][y + 1] === 'x') rd = true;
        
        // 狀況 1
        if((l & r) ^ (u & d)) return true;
        
        // 狀況 2
        if(l + r + u + d === 1) {
            if(l && (ru || rd)) return true;
            if(r && (lu || ld)) return true;
            if(u && (ld || rd)) return true;
            if(d && (lu || ru)) return true;
        }
        
        // 狀況 3
        if(l + r + u + d === 2) {
            // 狀況 1 已經被 return 了,因此相加爲 2 的確定是共享頂點
            if(l && u && rd) return true;
            if(l && d && ru) return true;
            if(r && u && ld) return true;
            if(r && d && lu) return true;
        }
        
        return false;
    }
}
JSFiddle 看完整代碼。

而後是 50x50 填充 800 的效果:

以及 10x10 填充 50:

注意:左下角的洞看起來是洞,其實是處於邊界了,而填充區域沒法與邊界合成閉合區域,實際上將地圖往外想一想空一格就能夠知道它並非一個洞了。固然若是讀者執意不容許這種狀況發生,那麼只須要在 willBreak() 函數判斷的時候作點手腳就能夠了,至於怎麼作手腳你們本身想吧。

這種狀況生成的地圖比較像迷宮,哪怕是針對「胖胖的區域」作這個改進,JSFiddle 出來的也是下面的效果:

因此呢,繼續優化——咱們知道有三種狀況是會生成非單連通區域的,因此當咱們探測到這種狀況的時候,去 BFS 它內外區域,看看到底是哪一個區域被封閉出一個空洞來,探測出來以後再看看咱們目前還須要填充的區域面積跟這個空洞的面積是否夠用,若夠用則將空洞補起來,不夠用則當前一步從新來過——即再隨機一個座標看看行不行。

思想說出來了,具體的實現仍是看看我寫在 JSFiddle 裏面的代碼吧。

50x50 填充 800 的效果以下:

這麼一來,咱們很容易能跟 DFS 的算法結合起來,即以前說過的更喪病的算法。結合方法很簡單,分別把改進過的 DFS 和胖胖區域的算法一塊兒融合進以前喪病算法的代碼中就行了。老樣子我仍是把代碼更新到了 JSFiddle 裏面。你們看看 50x50 填充 800 的效果吧:

最後,因爲一開始文章寫的概念性錯誤給你們帶來的不變表示很是抱歉,好在最後我仍是補全了一下文章。

小結

本文主要仍是講了,如何隨機生成一個指定面積的單連通區域。從一開始拍腦殼就能想到 DFS 開始,延伸到胖胖的區域,而後從我的認爲「圖很差看」開始,想辦法如何結合一下兩種算法使其變得更天然。

針對同一件事的算法們並不是一成不變或者不可結合的。不是說該 DFS 就只能 DFS,該 for 就只能 for,稍微結合一下也許食用效果更佳哦。

哦對了,在這以前還有一個例子就是我在三年多前寫的主題色提取的文章《圖片主題色提取算法小結》,其中就講到我最後的方法就是結合了八叉樹算法和最小差值法,使其在提取比較貼近的顏色同時又可以規範化提取出來的顏色。

總之就是多想一想,與諸君共勉。

相關文章
相關標籤/搜索