Perfect maze
Perfect maze又称standard maze,指没有回路,没有不可达区域的迷宫。用图论的语言来说,就是可以用spanning tree表示的迷宫,保证迷宫中任意两个格子间都有唯一的路径。本文旨在探讨如何随机生成棋盘状perfect maze。迷宫格子的邻居的定义采用von Neumann neighborhood,即水平竖直方向相邻的四个格子。
变形Kruskal算法
说得通俗点,就是“随机拆墙”。
一开始假设所有墙都在。如果当前不满足“任意两个格子间都有唯一路径”,那么就随机选择一堵墙,要求墙两端的格子不连通,即它们对应的两个顶点不在一个connected component里。
把这堵墙拆掉,即在后墙两端的格子对应的顶点间建立一条边,此时connected component数就减少一了。不断地找墙、拆墙,最后就能形成一个看上去挺随机的迷宫。
伪代码
W = 所有墙
T = {}
number_of_components = N*N
while number_of_components > 1
(u, v) = W.pop
if component(u) != component(v)
T << (u, v)
merge component(u) and component(v)
最后T
集合就是spanning tree中的边,即所需拆除的墙。
实现
考虑转化为图论模型后我们需要的操作,只有两个,即:
- 判断两个元素是否在一个集合中(两个顶点是否在同一个connected component里),
- 合并两个集合(合并两个connected component)。
对于这个问图,有经典的disjoin-set forest算法可以在近似线性的时间复杂度里解决这个问题。
Aldous-Broder算法
很简单,随机选择一个格子作为起点,每次随机选择一个邻居格子走过去,如果目标格子不与当前格子连通则打通它们之间的墙。
Haskell实现
以下代码改编自某个Haskell的recursive backtracking算法:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
|
import Control.Applicative
import Control.Monad
import Control.Monad.Cont
import Control.Monad.ST
import Data.Array
import Data.Array.ST
import Data.STRef
import System.Random
import Debug.Trace
data Maze = Maze { rightWalls, belowWalls :: Array (Int, Int) Bool }
rand ::
Random a => (a, a) ->
STRef s
StdGen ->
ST s a
rand range g =
do
(a, g') <- liftM (randomR range) $ readSTRef g
a <$ writeSTRef g g'
maze ::
Int ->
Int ->
StdGen ->
ST s
Maze
maze w h gen =
do
let mk = newArray ((
0,
0), (w
-1,h
-1)) ::
Bool ->
ST s (
STArray s (
Int,
Int)
Bool)
visited <- mk
False
right <- mk
True
bottom <- mk
True
gen <- newSTRef gen
let
walk
1 (x,y) = return ()
walk c u@(x,y) =
do
writeArray visited u
True
let ns = [(x
-1,y) | x >
0] ++ [(x+
1,y) | x+
1 < w] ++ [(x,y
-1) | y >
0] ++ [(x,y+
1) | y+
1 < h] :: [(
Int,
Int)]
i <- rand (
0, length ns -
1) gen
let v@(x', y') = ns !! i
wall =
if x == x'
then bottom
else right
g = (min x x', min y y')
hasWall <- readArray wall g
seen <- readArray visited v
if seen
then
walk c v
else
writeArray wall g
False >> walk (c
-1) v
(,) <$> rand (
0, w
-1) gen <*> rand (
0, h
-1) gen >>= walk (w*h)
Maze <$> freeze right <*> freeze bottom
maze ::
Int ->
Int ->
StdGen ->
ST s
Maze
maze w h gen =
do
let mk = newArray ((
0,
0), (w
-1,h
-1)) ::
Bool ->
ST s (
STArray s (
Int,
Int)
Bool)
visited <- mk
False
right <- mk
True
bottom <- mk
True
gen <- newSTRef gen
let
walk
1 gen (x,y) = return ()
walk c gen u@(x,y) =
do
writeArray visited u
True
let ns = [(x
-1,y) | x >
0] ++ [(x+
1,y) | x+
1 < w] ++ [(x,y
-1) | y >
0] ++ [(x,y+
1) | y+
1 < h] :: [(
Int,
Int)]
i <- rand (
0, length ns -
1) gen
let v@(x', y') = ns !! i
wall =
if x == x'
then bottom
else right
g = (min x x', min y y')
hasWall <- readArray wall g
seen <- readArray visited v
if seen
then
walk c gen v
else
writeArray wall g
False >> walk (c
-1) gen v
(,) <$> rand (
0, w
-1) gen <*> rand (
0, h
-1) gen >>= walk (w*h) gen
Maze <$> freeze right <*> freeze bottom
printMaze ::
Maze ->
IO ()
printMaze (
Maze right bottom) =
do
putStrLn $ concat (replicate (maxX +
1)
"._") ++
"."
forM_ [
0..maxY] $ \y ->
do
putStr
"|"
forM_ [
0..maxX] $ \x ->
do
putStr $
if bottom ! (x, y)
then
"_"
else
" "
putStr $
if right ! (x, y)
then
"|"
else
"."
putChar '\n'
where
(maxX, maxY) = snd $ bounds right
main = getStdGen >>= stToIO . maze
12
13 >>= printMaze
|
该算法的优点是能等概率地生成每一棵spanning tree。
Recursive backtracking算法
上述随机拆墙使用到的Kruskal算法是求minimum spanning tree问题最广为人知的两个算法之一,另一个就是Prim算法,那么后者能不能解决这个问题呢?有种类似变形Prim的算法。
和变形Kruskal算法一样,一开始假设所有墙都存在,伪代码如下:
S = {(0,0)}
T = {}
while visited.size != n * n
u = S.pop
for v in neighbors(u).random_shuffle
if component(v) != component(u)
T << (u, v)
S << v
最后T
集合就是spanning tree中的边,即所需拆除的墙。
如果经典的Prim算法实现看作是维护了一个priority queue来维护connected component中的点,那么这里S
可以用比priority queue简单的stack或queue来实现。原因是我们求的不是minimum spanning tree,所有顶点的权值可以看作是相同的;所有边的权值也可以看作是相同的。
如果用stack代替priority queue,那么我们就甚至无需显示维护一个stack了,直接使用程序函数调用时系统维护的栈。
- 随机选择一个顶点作为起点
- 对于当前点的每一个邻点,如果这个邻点和当前点不在同一个connected component,
那么就在它们之间建立一条边,即把它们之间的墙拆掉,并且递归调用该步骤,当前点设置为这个邻点。
Haskell实现
下面实现来自http://megasnippets.com/source-codes/haskell/maze_generation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
|
import Control.Monad
import Control.Monad.ST
import Data.Array
import Data.Array.ST
import Data.STRef
import System.Random
rand ::
Random a => (a, a) ->
STRef s
StdGen ->
ST s a
rand range gen =
do
(a, g) <- liftM (randomR range) $ readSTRef gen
gen `writeSTRef` g
return a
data Maze = Maze {rightWalls, belowWalls :: Array (Int, Int) Bool}
maze ::
Int ->
Int ->
StdGen ->
ST s
Maze
maze width height gen =
do
visited <- mazeArray
False
rWalls <- mazeArray
True
bWalls <- mazeArray
True
gen <- newSTRef gen
liftM2 (,) (rand (
0, maxX) gen) (rand (
0, maxY) gen) >>=
visit gen visited rWalls bWalls
liftM2
Maze (freeze rWalls) (freeze bWalls)
where visit gen visited rWalls bWalls here =
do
writeArray visited here
True
let ns = neighbors here
i <- rand (
0, length ns -
1) gen
forM_ (ns !! i : take i ns ++ drop (i +
1) ns) $ \there ->
do
seen <- readArray visited there
unless seen $
do
removeWall here there
visit gen visited rWalls bWalls there
where removeWall (x1, y1) (x2, y2) = writeArray
(
if x1 == x2
then bWalls
else rWalls)
(min x1 x2, min y1 y2)
False
neighbors (x, y) =
(
if x ==
0
then []
else [(x -
1, y )]) ++
(
if x == maxX
then []
else [(x +
1, y )]) ++
(
if y ==
0
then []
else [(x, y -
1)]) ++
(
if y == maxY
then []
else [(x, y +
1)])
maxX = width -
1
maxY = height -
1
mazeArray = newArray ((
0,
0), (maxX, maxY))
::
Bool ->
ST s (
STArray s (
Int,
Int)
Bool)
printMaze ::
Maze ->
IO ()
printMaze (
Maze rWalls bWalls) =
do
putStrLn $ '+' : (concat $ replicate (maxX +
1)
"---+")
forM_ [
0 .. maxY] $ \y ->
do
putStr
"|"
forM_ [
0 .. maxX] $ \x ->
do
putStr
" "
putStr $
if rWalls ! (x, y)
then
"|"
else
" "
putStrLn
""
forM_ [
0 .. maxX] $ \x ->
do
putStr
"+"
putStr $
if bWalls ! (x, y)
then
"---"
else
" "
putStrLn
"+"
where maxX = fst (snd $ bounds rWalls)
maxY = snd (snd $ bounds rWalls)
main = getStdGen >>= stToIO . maze
11
8 >>= printMaze
|
C实现
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
|
#
#include <stdbool.h>
#
#include <stdio.h>
#
#include <stdlib.h>
#
#include <string.h>
#
#define N 92
bool R[N][N], D[N][N], v[N][N];
int m, n;
void dfs(int r, int c)
{
int d = rand() %
4, dd = rand()%
2 ?
1 :
3;
v[r][c] =
true;
for (
int i =
0; i <
4; i++) {
int rr = r + (
int[]){
-1,
0,
1,
0}[d],
cc = c + (
int[]){
0,
-1,
0,
1}[d];
if ((
unsigned)rr < m && (
unsigned)cc < n && ! v[rr][cc]) {
if (d %
2)
R[r][c - (d ==
1)] =
true;
else
D[r - (d ==
0)][c] =
true;
dfs(rr, cc);
}
d = (d + dd) %
4;
}
}
int main()
{
scanf(
"%d%d", &m, &n);
dfs(
0,
0);
for (
int c =
0; c < n; c++)
printf(
"._");
printf(
".\n");
for (
int r =
0; r < m; r++) {
printf(
"|");
for (
int c =
0; c < n; c++) {
putchar(D[r][c] ?
' ' :
'_');
putchar(R[r][c] ?
'.' :
'|');
}
printf(
"\n");
}
}
|
Eller算法(以行为单位的随机拆墙)
对于直接输出的算法来说,上述算法额外数据结构的空间复杂度都是和迷宫大小同阶的,其实我们只需要和迷宫宽(或高)同阶的额外空间就够了。
这个算法我所了解的出处是某届IOCCC的一个作品。http://homepages.cwi.nl/~tromp/maze.html给出了该作品的描述,后文我给出的实现也借鉴了链接中给出的代码。
先考虑第一行,随机拆掉一些格子的右墙。维护若干双线循环链表,每个链表表示这行连通的格子。所以拆墙后,如果第一行有x
堵墙,那么就有x-1
条链表。之后要决定哪些格子和下面一行相连。
._._._._._._.
|_. |_._. | |
在上图中,有2堵墙,有三个格子被决定和下面一行相连。最右边的格子没有和左边相连,但是只要它的下墙被拆除,那么它最终还是会和所有格子连通。因此这堵下墙的拆除是必需的。
接下来要处理第二行了,依旧是决定哪些格子的右墙须要被拆除。但要注意如果格子i
的右墙即格子i
和i+1
之间的墙被拆除了,那么必须保证在上面各行格子i
和i+1
不连通。到这里为什么要使用链表维护连通性就明显了,我们需要快速判断格子i
和i+1
在当前行之前有没有连通来决定它们俩之间的墙能否被拆除,用union-find算法当然可行,但链表更方便。
在决定完拆除第二行一些格子的右墙后,考虑哪些格子需要和下一行(即第三行)相连,假设我们的决定是这样的:
._._._._._._.
|_. |_._. | |
| |_._._. . |
这里注意到第二行最左边的格子,它的右墙没有被拆除,所以它的下墙必须被拆除以保证最终它会和其他格子连通。
然后考虑第三行哪些格子的右墙要被拆除:
._._._._._._.
|_. |_._. | |
| |_._._. . |
|_._. | |_| |
注意上图第三行最后两个格子,它们之间有墙,这堵墙不能被拆除,理由是第二行时它们已经连通了。
之后几行如法炮制:
._._._._._._.
|_. |_._. | |
| |_._._. . |
|_._. | |_| |
|_._._. | | |
| | ._._. | |
| |_._._| ._|
然后开始处理最后一行。最后一行的任务有点特殊,因为下墙不能被拆除(迷宫边界),需要拆除若干右墙使得最后一行的几个连通分支并在一起。发现不得不写成这样:
._._._._._._.
|_. |_._. | |
| |_._._. . |
|_._. | |_| |
|_._._. | | |
| | ._._. | |
| |_._._| ._|
|_._._._._._|
Disjoin-set forest的删除元素操作
之前的例子中我们发现每一行我们做了两步决策,拆右墙和拆下墙。有些时候右墙不能被拆除,是因为墙两边的格子在之前行已经被连通了。拆右墙就相当于合并两个集合。
右墙决策产生之后,有些下墙是必须拆的,即当它自成一个集合时。如果不拆下墙,那么在下一行,这个格子就和其他不连通了,所以得自成一个集合。
推敲我们需要实现的操作:并、查、从集合中删除一个元素,如果只有连两个操作,那么很简单,直接套用 disjoin-set forest 就好了。难点就在于如何支持“从集合中删除一个元素”这一操作。删除其实也是更新,引入时间因素后,就可以用一条更新记录来代替一个删除操作,但要注意把被删除的记录置为无效状态。删除一个元素,可以看作:引入一个新顶点,改变格子指向顶点的映射。
删除元素
如图,在格子0删除下墙前没有虚线边以及虚线顶点3。删除格子0的下墙后,需要让格子0对应的元素(顶点0)自成一个集合。做法是把蓝线(格子指向顶点的映射)换成红色虚线。尽管有顶点1和2在disjoin-set forest中已经指向顶点3,但是我们做的这个删除操作不会影响到它们。
用linked list实现disjoin sets
上一节通过改造disjoin-set forest使其支持删除元素的操作,但是改造后空间复杂度就没有吸引力了,因为所有产生的顶点数目可能是迷宫大小级别。
仔细研究我们用到的操作:
- 合并:只有相邻格子所对应集合会合并;
- 比较两个格子是否在一个集合中:只有相邻格子会被比较。
用一个链表表示一个connected component,并且让链表的元素按编号排序。那么对于格子i
,查找其对应的链表中节点的后继是否对应格子i+1
就能知道格子i
和i+1
是否在同一个connected component。对于合并操作,以及让一个格子自成一个集合的操作,链表也都能出色地完成任务。
第一行
再来看:
._._._._._._.
|_. |_._. | |
行间有两堵墙,需要三个链表:
L[0] = 1, L[1] = 0; R[0] = 1, R[1] = 0
L[2] = 4, L[3] = 2, L[4] = 3; R[2] = 3, R[3] = 4, R[4] = 2
L[5] = 5; R[5] = 5
第二行
现在考虑第二行,上面的链表状态代表的迷宫如下:
._._._._._._.
|_. |_._. | |
| | | | | | |
决定拆除一些墙后:
._._._._._._.
|_. |_._. | |
| | . . . . |
相应地,链表状态变成:
L[0] = 0; R[0] = 0
L[1] = 5, L[2] = 1, L[3] = 2, L[4] = 3, L[5] = 4; R[1] = 2, R[2] = 3, R[3] = 4, R[4] = 5, R[5] = 1
接着决定拆掉一些下墙:
._._._._._._.
|_. |_._. | |
| |_._._. . |
那么格子1 、2、3都得自成一个集合,链表状态变成:
L[0] = 0; R[0] = 0
L[1] = 1; R[1] = 2
L[2] = 2; R[2] = 2
L[3] = 3; R[3] = 3
L[4] = 5, L[5] = 4; R[4] = 5, R[5] = 4
第三行
目前第三行尚未考虑拆墙,由上面的链表状态得出的连通信息:格子0、1、2、3互不连通,格子4、5连通,恰好能反应下图:
._._._._._._.
|_. |_._. | |
| |_._._. . |
| | | | | | |
OCaml实现
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
|
let eller m n =
let l =
Array.create (n+
1)
0
in
let r =
Array.create (n+
1)
0
in
for i =
0
to n-
1
do
print_string
"._";
l.(i) <- i;
r.(i) <- i
done;
l.(n) <- n-
1;
print_string
".\n|";
for y =
0
to m-
2
do
for x =
0
to n-
1
do
let w = l.(x+
1)
in
let pat1 =
if x <> w &&
Random.
int
3 <>
0
then
begin
r.(w) <- r.(x);
l.(r.(w)) <- w;
r.(x) <- x+
1;
l.(x+
1) <- x;
'.'
end
else
'|'
in
let pat0 =
if x <> l.(x) &&
Random.
int
3 <>
0
then
begin
l.(r.(x)) <- l.(x);
r.(l.(x)) <- r.(x);
l.(x) <- x;
r.(x) <- x;
'_'
end
else
' '
in
print_char pat0;
print_char pat1
done;
print_string
"\n|"
done;
for x =
0
to n-
1
do
let w = l.(x+
1)
in
let pat1 =
if x <> w && (x == l.(x) ||
Random.
int
3 <>
0)
then
begin
r.(w) <- r.(x);
l.(r.(w)) <- w;
r.(x) <- x+
1;
l.(x+
1) <- x;
'.'
end
else
'|'
in
l.(r.(x)) <- l.(x);
r.(l.(x)) <- r.(x);
l.(x) <- x;
r.(x) <- x;
print_char
'_';
print_char pat1
done;;
Scanf.scanf
"%d %d" eller
|
C实现
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
|
#
#include <stdio.h>
#
#include <stdlib.h>
#
#define N 92
int L[N], R[N];
int main()
{
char pat[
3] = {};
int m, n;
scanf(
"%d%d", &m, &n);
for (
int i =
0; i < n; i++) {
printf(
"._");
L[i] = R[i] = i;
}
L[n] = n -
1;
printf(
".\n|");
while (m--) {
for (
int i =
0; i < n; i++) {
int j = L[i +
1];
if (i != j && rand() %
3) {
L[R[j] = R[i]] = j;
L[R[i] = i +
1] = i;
pat[
1] =
'.';
}
else
pat[
1] =
'|';
if (i != L[i] && rand() %
3) {
L[R[i]] = L[i];
R[L[i]] = R[i];
L[i] = R[i] = i;
pat[
0] =
'_';
}
else
pat[
0] =
' ';
printf(pat);
}
printf(
"\n|");
}
pat[
0] =
'_';
for (
int i =
0; i < n; i++) {
int j = L[i +
1];
if (i != j && (i == L[i] || rand() %
3)) {
L[R[j] = R[i]] = j;
L[R[i] = i +
1] = i;
pat[
1] =
'.';
}
else
pat[
1] =
'|';
L[R[i]] = L[i];
R[L[i]] = R[i];
L[i] = R[i] = i;
printf(pat);
}
}
|
推荐阅读
Maze Generation: Eller’s Algorithm给出了关于Eller算法的详细描述。
Maze Classification给出了非常详细的迷宫生成算法和解法列表。