用归纳图生成迷宫(2017)
Generating Mazes with Inductive Graphs (2017)

原始链接: https://jelv.is/blog/Generating-Mazes-with-Inductive-Graphs/

本文详细介绍了如何使用函数式图库 (fgl) 中的归纳图在 Haskell 中生成迷宫。该方法通过在表示迷宫的网格上生成一个随机生成树来创建一个完美的迷宫。算法使用随机深度优先搜索 (DFS):从一个单元格开始,随机选择一个未访问的邻居,移动到它,并移除它们之间的墙。如果没有未访问的邻居,则回溯。 fgl 中的归纳图允许将图视为归纳数据类型,可以使用模式匹配更轻松地进行操作。文章解释了如何使用 `matchAny` 和 `match` 函数来分解图和遍历图。核心 DFS 算法被呈现,然后被修改为返回边的列表,这对于迷宫生成至关重要。使用 `MonadRandom` 类引入随机性,以在 DFS 期间对相邻边进行洗牌。最后,代码创建一个带标签边的网格图,表示墙壁,并使用修改后的 DFS 通过识别要移除的墙壁来生成迷宫,从而生成美观的迷宫。代码还使用 cairo 展示了如何将迷宫绘制到 png 文件中。

这个Hacker News帖子讨论了使用归纳图生成迷宫,特别关注迷宫的“风格”及其内在偏差。Kazinator指出,递归分支过程生成的迷宫有一个对应于树根的“起点”。他认为将入口设置在这个点上可以提供最多的分支选择。 Smartmic提到了《程序员的迷宫》(Mazes for Programmers),强调完美的迷宫(没有循环)保证任何两个单元格之间都有一条路径,因此可以选择任意位置作为入口和出口。然而,Kazinator进一步解释说,生成过程(例如,从左上到右下)会引入方向偏差。他提供了一个迷宫示例,并分析了返回入口所需的“上”、“下”、“左”和“右”方向的频率。分析显示向左和向上有偏差,表明反向导航更容易。Jaxan提出了一个反驳观点,认为可以选择边界上的任何位置。

原文
Generating Mazes with Inductive Graphs | jelv.is

A few years ago—back in high school—I spent a little while writing programs to automatically generate mazes. It was a fun exercise and helped me come to grips with recursion: the first time I implemented it (in Java), I couldn’t get the recursive version to work properly so ended up using a while loop with an explicit stack!

Making random mazes is actually a really good programming exercise: it’s relatively simple, produces cool pictures and does a good job of covering graph algorithms. It’s especially interesting for functional programming because it relies on graphs and randomness, two things generally viewed as tricky in a functional style.

So lets look at how to implement a maze generator in Haskell using inductive graphs for our graph traversal. Here’s what we’re aiming for:

Inductive graphs are provided by Haskell’s “Functional Graph Library” fgl.

All of the code associated with this post is up on GitHub so you can load it into GHCi and follow along. It’s also a good starting point if you want to hack together your own mazes later on. It’s all under a BSD3 license, so you can use it however you like.

The Algorithm

We can generate a perfect maze by starting with a graph representing a grid and generating a random spanning tree. A perfect maze has exactly one path between any two points—no cycles or walled-off areas. A spanning tree of a graph is a tree that connects every single node in the graph.

There are multiple algorithms we can use to generate such a tree. Let’s focus on the simplest one which is just a randomized depth first search (DFS):

  1. start with a grid that has every possible wall filled in
  2. choose a cell to begin
  3. from your current cell, choose a random neighbor that you haven’t visited yet
  4. move to the chosen neighbor, knocking down the wall between it
  5. if there are no unvisited neighbors, backtrack to the previous cell you were in and repeat
  6. otherwise, repeat from your new cell

Inductive Data Types

To write our DFS, we need some way to represent a graph. Unfortunately, graphs are often inconvenient functional languages: standard representations like adjacency matrices or adjacency lists were designed with an imperative mindset. While you can certainly use them in Haskell, the resulting code would be relatively awkward.

But what sorts of structures does Haskell handle really well? Trees and lists come to mind: we can write very natural code by pattern matching. A very common pattern is to inspect a list as a head element and a tail, recursing on the tail:

actual graph view in fgl is a bit different and supports node and edge labels, which I’ve left out for simplicity. The fundamental ideas are the same.

Consider a small example graph:

We could decompose this graph into the node 1, its context and the rest of the graph:

However, we could just as easily decompose the same example graph into node 2 and the rest:

This means we can’t use this algebraic definition directly. Instead the actual graph type is abstract and we just view it using contexts, like above. Unlike normal pattern matching, viewing an abstract type is not necessarily the inverse of constructing it.

We accomplish this by using a matching function that takes a graph and returns a context decomposition. Since there is no “natural” first node to return, the simplest matching function matchAny returns an arbitrary (implementation defined) decomposition:

ViewPatterns, which allow us to call functions inside a pattern. Here’s a prettier version of ghead which does exactly the same thing:

Graph typeclasses rather than a concrete implementation. This typeclass mechanism is great since it allows multiple implementations of inductive graphs. Unfortunately, it also breaks type inference in ways that are sometimes hard to track down so, for simplicity, we’ll just use the provided implementation: Gr. Gr n e is a graph that has nodes labeled with n and edges labeled with e.

Map

The “Hello, World!” of recursive list functions is map, so lets start by looking at a version of map for graphs. The idea is to apply a function to every node label in the graph.

For reference, here’s list map:

  • stack: [7, 6]

    result: [3]

  • stack: [4, 2, 6]

    result: [3, 7]

  • stack: [1, 2, 6]

    result: [3, 7, 4]

  • stack: [6, 5, 2, 6]

    result: [3, 7, 4, 1]

  • stack: [2, 5, 2, 6]

    result: [3, 7, 4, 1, 6]

  • stack: [5, 5, 2, 6]

    result: [3, 7, 4, 1, 6, 2]

  • stack: [5, 2, 6]

    result: [3, 7, 4, 1, 6, 2, 5]

Often—like for generating mazes—we don’t care about which node to start from. This is where ghead comes in since it selects an arbitrary node for us! The only thing to consider is that ghead will fail on an empty graph.

EDFS

dfs gives us nodes in the order that they were visited. But for mazes, we really care about the edges we followed rather than just nodes. So lets modify our dfs into an edfs which returns a list of edges rather than a list of nodes. In fgl, an edge is just a tuple of two nodes: (Node, Node).

The modifications from our original dfs are actually quite slight: we keep a stack of edges instead of a stack of nodes. This requires modifying our starting condition:

lNeighbors because it actually returns labeled edges.

Randomness

The final change we need to generate a maze is adding randomness. We want to shuffle the list of neighboring edges before putting it on the stack. We’re going to use the MonadRandom class, which is compatible with a bunch of other monads like IO. I wrote a naïve O(n²) shuffle:

Running edfsR over a starting maze will give us the list of walls that were knocked down—they’re the ones we don’t want to draw. We can easily go from this to the compliment list of walls to draw using the list different operator \\\\ from Data.List:

Draw.hs and uses cairo for the actual drawing. Cairo is a C library that provides something very similar to an HTML Canvas but for GtK; it can also output images. If you just want to play around with the mazes, you can draw one to a png with:

More Fun

We now have a basic maze generating system using inductive graphs and randomness. If you want to play around with the code a bit, there are two interesting ways to extend this code: generating mazes from other shapes and using different graph algorithms.

Other Shapes

Our system always assumes that mazes are generated from a grid of cells. However, the actual graph code doesn’t care about this at all! The grid-specific parts are just the starting graph (ie grid 40 40) and the drawing code.

A fun challenge is to look at what mazes over other sorts of graphs look like. Try writing a maze generator based on tiled hexagons, polar rectangles or even arbitrary (maybe random?) plane tilings. Or try to generate mazes in 3D!

Other Algorithms

Apart from modifying the graph, we can also modify our traversal. Play around with the DFS code: for example, you can substitute in a biased shuffle and get other shapes of mazes. If you make the shuffle more likely to choose horizontal walls than vertical ones, you will get a maze with longer vertical passages and shorter horizontal ones. How else can you change the DFS?

Randomized DFS is a nice algorithm for generating mazes because it’s simple and produces aesthetically pleasant mazes. But it’s not the only possible algorithm. You could take some other algorithms that produce spanning trees and randomize those.

One particular trick is to take an algorithm for generating minimum spanning trees, and apply it to a graph with random edge weights. The two common minimum spanning tree algorithms are Prim’s algorithm and Kruskal’s algorithm—implementing those is a good Haskell exercise and will produce subtly different looking mazes. Take a look through Minimum Spanning Tree Algorithms on Wikipedia for more inspiration.

I think this code is a great example of how, once you’ve learned a bit, many tasks in functional programming are easier than they may seem at first. Starting from the right abstractions, working with graphs or randomness need not be difficult, even in a purely functional language like Haskell.

Ultimately, this is a good exercise both for becoming a better Haskell programmer and for realizing just how versatile the language can be.

联系我们 contact @ memedata.com