引言
均匀随机生成树(Uniform Spanning Tree, UST)是从图中所有可能的生成树中均匀随机选取一棵的算法。与Prim或Kruskal等确定性最小生成树算法不同,UST追求的不是最优性,而是随机性中的均匀性。
Wilson算法由David Wilson于1996年提出,利用循环擦除随机游走(Loop-Erased Random Walk, LERW)来高效生成UST。这一算法不仅具有理论上的优雅性,更是无偏迷宫生成的绝佳工具——生成的迷宫没有Prim算法常见的长走廊偏见,通道分布更加自然。
本文将用Java实现Wilson算法,并展示如何将其应用于迷宫生成。
算法原理
循环擦除随机游走(LERW)
标准随机游走的路径中会包含许多循环(访问同一节点多次)。LERW在游走过程中实时检测并擦除这些循环:
- 从起点 $u$ 开始随机游走
- 每到达一个新节点,记录访问路径
- 如果到达一个已访问过的节点 $v$,则擦除从 $v$ 到当前路径末尾的循环部分
- 继续游走直到到达目标集合
Wilson算法的核心步骤
给定连通图 $G=(V, E)$:
- 任选根节点 $r$,将其标记为”已在树中”
- 当还有未加入树的节点时:
a. 任选一个未加入树的节点 $u$ 作为起点
b. 从 $u$ 执行LERW,直到到达”已在树中”的节点
c. 将LERW的路径加入生成树 - 最终得到的边集即为均匀随机生成树
Wilson证明了这一算法生成的每棵生成树的概率恰好为 $1/\tau(G)$,其中 $\tau(G)$ 是图的生成树总数(由Kirchhoff矩阵树定理计算)。
Java实现
import java.util.*;
/**
* Wilson算法实现:均匀随机生成树与无偏迷宫生成
*/
public class WilsonAlgorithm {
// 迷宫方向:上、下、左、右
private static final int[][] DIRS = {{-1, 0}, {1, 0}, {0, -1}, {0, 1}};
private static final String[] DIR_NAMES = {"UP", "DOWN", "LEFT", "RIGHT"};
/**
* 图的节点类
*/
static class Node {
int id;
List<Edge> edges = new ArrayList<>();
Node(int id) { this.id = id; }
void addEdge(Node to, int weight) {
edges.add(new Edge(this, to, weight));
}
Node getRandomNeighbor(Random rand) {
if (edges.isEmpty()) return null;
return edges.get(rand.nextInt(edges.size())).to;
}
}
static class Edge {
Node from, to;
int weight;
Edge(Node from, Node to, int weight) {
this.from = from;
this.to = to;
this.weight = weight;
}
}
/**
* Wilson算法:生成均匀随机生成树
* @param nodes 图的所有节点
* @param root 根节点
* @return 生成树的边集合
*/
public static Set<Edge> generateUST(List<Node> nodes, Node root) {
Random rand = new Random();
Set<Edge> treeEdges = new HashSet<>();
Set<Node> inTree = new HashSet<>();
inTree.add(root);
while (inTree.size() < nodes.size()) {
// 选择一个不在树中的节点作为起点
Node start = null;
for (Node node : nodes) {
if (!inTree.contains(node)) {
start = node;
break;
}
}
if (start == null) break;
// 执行循环擦除随机游走
List<Node> path = loopErasedRandomWalk(start, inTree, rand);
// 将路径加入生成树
for (int i = 0; i < path.size() - 1; i++) {
Node from = path.get(i);
Node to = path.get(i + 1);
// 找到对应的边并加入树
for (Edge e : from.edges) {
if (e.to == to) {
treeEdges.add(e);
break;
}
}
inTree.add(from);
}
}
return treeEdges;
}
/**
* 循环擦除随机游走(LERW)
* 从start出发,随机游走直到到达inTree集合中的节点
* 过程中擦除所有循环
*/
private static List<Node> loopErasedRandomWalk(Node start, Set<Node> inTree, Random rand) {
Map<Node, Integer> firstVisitIndex = new HashMap<>();
List<Node> path = new ArrayList<>();
Node current = start;
while (!inTree.contains(current)) {
// 如果当前节点已在路径中,说明形成了循环,擦除它
if (firstVisitIndex.containsKey(current)) {
int loopStart = firstVisitIndex.get(current);
// 移除循环部分(从loopStart之后的所有节点)
for (int i = loopStart + 1; i < path.size(); i++) {
firstVisitIndex.remove(path.get(i));
}
path = new ArrayList<>(path.subList(0, loopStart + 1));
} else {
firstVisitIndex.put(current, path.size());
}
path.add(current);
current = current.getRandomNeighbor(rand);
}
// 最后加入目标节点(已在树中的节点)
path.add(current);
return path;
}
// ==================== 迷宫生成专用实现 ====================
/**
* 使用Wilson算法生成迷宫
* 迷宫表示为网格,墙和通道交替排列
*/
public static char[][] generateMaze(int width, int height) {
Random rand = new Random();
// 迷宫单元格(奇数坐标是墙,偶数坐标是通道)
int cellW = (width - 1) / 2;
int cellH = (height - 1) / 2;
// 用并查集或简单标记来跟踪已访问的单元格
boolean[][] inMaze = new boolean[cellH][cellW];
char[][] maze = new char[height][width];
// 初始化全部为墙
for (int i = 0; i < height; i++) {
Arrays.fill(maze[i], '#');
}
// Wilson算法在网格图上执行
// 随机选择一个起始单元格
int startR = rand.nextInt(cellH);
int startC = rand.nextInt(cellW);
inMaze[startR][startC] = true;
maze[startR * 2 + 1][startC * 2 + 1] = ' ';
// 对剩余单元格执行LERW
for (int r = 0; r < cellH; r++) {
for (int c = 0; c < cellW; c++) {
if (inMaze[r][c]) continue;
// 从这个单元格开始LERW
List<int[]> path = gridLERW(r, c, inMaze, cellH, cellW, rand);
// 打通路径上的墙
for (int i = 0; i < path.size() - 1; i++) {
int[] from = path.get(i);
int[] to = path.get(i + 1);
// 标记单元格为通道
maze[from[0] * 2 + 1][from[1] * 2 + 1] = ' ';
// 打通两个单元格之间的墙
int wallR = from[0] + to[0] + 1;
int wallC = from[1] + to[1] + 1;
maze[wallR][wallC] = ' ';
inMaze[from[0]][from[1]] = true;
}
// 标记终点
int[] last = path.get(path.size() - 1);
maze[last[0] * 2 + 1][last[1] * 2 + 1] = ' ';
}
}
// 设置入口和出口
maze[1][0] = 'S';
maze[height - 2][width - 1] = 'E';
return maze;
}
/**
* 网格上的循环擦除随机游走
*/
private static List<int[]> gridLERW(int startR, int startC,
boolean[][] inMaze,
int rows, int cols, Random rand) {
Map<String, Integer> visitIndex = new HashMap<>();
List<int[]> path = new ArrayList<>();
int r = startR, c = startC;
while (!inMaze[r][c]) {
String key = r + "," + c;
if (visitIndex.containsKey(key)) {
int loopStart = visitIndex.get(key);
for (int i = loopStart + 1; i < path.size(); i++) {
visitIndex.remove(path.get(i)[0] + "," + path.get(i)[1]);
}
path = new ArrayList<>(path.subList(0, loopStart + 1));
} else {
visitIndex.put(key, path.size());
}
path.add(new int[]{r, c});
// 随机选择邻居
List<int[]> neighbors = new ArrayList<>();
for (int[] d : DIRS) {
int nr = r + d[0];
int nc = c + d[1];
if (nr >= 0 && nr < rows && nc >= 0 && nc < cols) {
neighbors.add(new int[]{nr, nc});
}
}
int[] next = neighbors.get(rand.nextInt(neighbors.size()));
r = next[0];
c = next[1];
}
path.add(new int[]{r, c}); // 添加已访问的终点
return path;
}
/**
* 打印迷宫
*/
public static void printMaze(char[][] maze) {
for (char[] row : maze) {
System.out.println(new String(row));
}
}
/**
* 创建简单网格图用于UST演示
*/
public static List<Node> createGridGraph(int rows, int cols) {
List<Node> nodes = new ArrayList<>();
Node[][] grid = new Node[rows][cols];
for (int i = 0; i < rows; i++) {
for (int j = 0; j < cols; j++) {
grid[i][j] = new Node(i * cols + j);
nodes.add(grid[i][j]);
}
}
// 添加四邻域边
for (int i = 0; i < rows; i++) {
for (int j = 0; j < cols; j++) {
if (i > 0) grid[i][j].addEdge(grid[i-1][j], 1);
if (j > 0) grid[i][j].addEdge(grid[i][j-1], 1);
}
}
return nodes;
}
public static void main(String[] args) {
// 测试1:简单网格图的UST
System.out.println("=== Wilson算法:3x3网格图的均匀随机生成树 ===");
List<Node> gridGraph = createGridGraph(3, 3);
Set<Edge> ust = generateUST(gridGraph, gridGraph.get(0));
System.out.println("生成树边数: " + ust.size());
for (Edge e : ust) {
System.out.printf("边: %d -> %d%n", e.from.id, e.to.id);
}
// 测试2:迷宫生成
System.out.println("\n=== Wilson算法生成迷宫(21x11)===");
char[][] maze = generateMaze(21, 11);
printMaze(maze);
// 测试3:多次生成,观察随机性
System.out.println("\n=== 迷宫多样性测试(3个不同迷宫)===");
for (int i = 0; i < 3; i++) {
System.out.println("迷宫 " + (i + 1) + ":");
char[][] m = generateMaze(15, 9);
printMaze(m);
System.out.println();
}
}
}
算法分析
时间复杂度
Wilson算法的时间复杂度取决于LERW的期望长度。对于 $n \times n$ 的网格图,LERW的期望覆盖时间为 $O(n^2 \log n)$,而Wilson算法的总期望时间为 $O(n^2 \log n)$,与图的大小成近乎线性关系。
对比其他迷宫生成算法:
– DFS递归回溯:$O(n^2)$,但有明显长走廊偏见
– Prim算法:$O(n^2 \log n)$,有短通道聚集偏见
– Wilson算法:$O(n^2 \log n)$,无偏均匀
空间复杂度
需要存储图结构和LERW的当前路径,空间复杂度为 $O(V + E)$。对于网格迷宫,$E \approx 2V$,因此为 $O(V)$。
均匀性证明的关键
Wilson算法的均匀性依赖于两个深层数学结果:
1. 循环擦除随机游走的轨迹分布与图的生成树分布通过Kirchhoff定理关联
2. 路径拼接时,各LERW之间的独立性保证了全局均匀性
扩展应用
有偏Wilson算法
通过修改随机游走的转移概率,可以生成具有特定统计特性的迷宫:
– 偏好直走:提高继续沿当前方向前进的概率,生成更”蜿蜒”的迷宫
– 偏好转弯:降低继续直走的概率,生成更多交叉路口的迷宫
这些变体牺牲了严格均匀性,但在游戏设计中更受欢迎。
与其他算法的比较
| 算法 | 偏见类型 | 死胡同比例 | 长走廊比例 | 适用场景 |
|---|---|---|---|---|
| DFS回溯 | 长走廊 | 低 | 高 | 经典迷宫游戏 |
| Prim | 短通道聚集 | 高 | 低 | 复杂网络迷宫 |
| Kruskal | 随机聚集 | 中 | 中 | 均匀分布需求 |
| Wilson | 无偏 | 自然 | 自然 | 数学研究、公平竞技 |
高维推广
Wilson算法可以自然地推广到三维甚至更高维网格,生成三维迷宫或洞穴系统。在三维情况下,通道和墙壁的拓扑结构更加复杂,适合开放世界游戏中的地下城生成。
总结
Wilson算法是随机图论中的一颗明珠,它将循环擦除随机游走与均匀随机生成树巧妙结合,既具有深厚的数学理论基础,又在实践中展现出优雅的简洁性。相比于传统的DFS或Prim迷宫生成算法,Wilson算法生成的迷宫没有任何系统性偏见,通道和死胡同的分布完全符合统计均匀性。在游戏开发、统计物理和组合数学中,掌握这一算法将为你提供一种全新的、无偏的随机结构生成工具。