4

I'm trying to create a special perfect maze generator.

Instead of the standard case that has rooms and walls, I'm dealing with a grid of cells filled with blocks, where I can remove blocks from some cells:

  • to connect two given cells (for example, to connect the top-left cell to the bottom-left cell)
  • in order to have a maximum blocks removed
  • each removed block cells must me joinable from each other using one way

I use a DFS algorithm to dig the path maze but I can't find a way to be sure that the two given cells are connected.

The normal case goes from here

+-+-+
| | |
+-+-+
| | |
+-+-+

to here

+-+-+
| | |
+ + +
|   |
+-+-+

In my case, I'm trying to connect the top-left cell to the bottom-right cell:

##
##

to here

.#
..

or here

..
#.

but not here (because the bottom-right cell is blocked)

..
.#

and not here (the two cells are not connected)

.#
#.

and not here (the maze is not perfect, the cells are connected by more than one path)

..
..

Here two more 8x8 examples :

Good one (perfect maze, and there is a path from the top-left cell to the bottom-right cell):

..#.....
.#.#.##.
.......#
.#.#.##.
.##...#.
..#.#...
.##.#.#.
...###..

Bad one (perfect maze, but there is no path from the top-left cell to the bottom-right cell):

...#....
.##..#.#
....##..
#.#...##
#..##...
..#..#.#
#...#...
##.###.#

Some nice 1000x1000 solved generated maze

ruakh
  • 175,680
  • 26
  • 273
  • 307
Karim DRIDI
  • 101
  • 6

2 Answers2

5

It looks like it's actually quite reasonable to generate mazes meeting your criteria using a two-step process:

  1. Generate a random maze without regards to whether it's possible to get to the bottom-right corner from the top-left corner.

  2. Repeat step (1) until there's a path to the bottom-right corner.

I've coded this up using two strategies, one based on a randomized depth-first search and one based on a randomized breadth-first search. The randomized depth-first search, on grids of size 100 × 100, generates mazes where the bottom-right corner is reachable from the top-left corner 82% of time. With a randomized breadth-first search, the success rate on 100 × 100 grids is around 70%. So this strategy does indeed appear to be viable; you'll need to generate, on average, about 1.2 mazes with DFS and around 1.4 mazes with BFS before you'll find one that works.

The mechanism I used to generate mazes without loops is based on a generalization of the idea from regular BFS and DFS. In both of those algorithms, we pick some location that (1) we haven't yet visited but (2) is adjacent to somewhere we have, then add the new location in with the previous location as its parent. That is, the newly-added location ends up being adjacent to exactly one of the previously-visited cells. I adapted this idea by using this rule:

Do not convert a full cell to an empty cell if it's adjacent to more than one empty cell.

This rule ensures that we never get any cycles (if something is adjacent to two or more empty locations and we empty it out, we create a cycle by getting to the first location, then moving to the newly-emptied square, then moving to the second location).

Here's a sample 30 × 30 maze generated using the DFS approach:

.#.........#...#.#....#.#..##.
.#.#.#.#.#.#.#.....##....#....
..#...#..#.#.##.#.#.####.#.#.#
#..#.##.##.#...#..#......#.#..
.#..#...#..####..#.#.####..##.
...#..##..#.....#..#....##..#.
.##.#.#.#...####..#.###...#.#.
..#.#.#.###.#....#..#.#.#..##.
#.#...#....#..#.###....###....
...#.###.#.#.#...#..##..#..#.#
.#....#..#.#.#.#.#.#..#..#.#..
..####..#..###.#.#...###..#.#.
.#.....#.#.....#.########...#.
#..#.##..#######.....#####.##.
..##...#........####..###..#..
.#..##..####.#.#...##..#..#..#
..#.#.#.#....#.###...#...#..#.
.#....#.#.####....#.##.#.#.#..
.#.#.#..#.#...#.#...#..#.#...#
.#..##.#..#.#..#.##..##..###..
.#.#...##....#....#.#...#...#.
...#.##...##.####..#..##..##..
#.#..#.#.#.......#..#...#..#.#
..#.#.....#.####..#...##..##..
##..###.#..#....#.#.#....#..#.
...#...#..##.#.#...#####...#..
.###.#.#.#...#.#.#..#...#.#..#
.#...#.##..##..###.##.#.#.#.##
.#.###..#.##.#....#...#.##...#
......#.......#.#...#.#....#..

Here's a sample 30 × 30 maze generated using BFS:

.#..#..#...#......#..##.#.....
..#.#.#.#.#..#.##...#....#.#.#
#...#.......###.####..##...#.#
.#.#..#.#.##.#.......#.#.#..#.
.....#..#......#.#.#.#..#..##.
#.#.#.###.#.##..#.#....#.#....
..##.....##..#.##...##.#...#.#
#....#.#...#..##.##...#.#.##..
.#.#..##.##..##...#.#...##...#
....#...#..#....#.#.#.##..##..
#.##..#.##.##.##...#..#..##..#
....#.##.#..#...#.####.#...#..
.#.##......#..##.#.#.....#..#.
#....#.#.#..#........#.#.#.##.
.#.###..#..#.#.##.#.#...####..
.#.#...#.#...#..#..###.#.#...#
....##.#.##.#..#.####.....#.#.
.#.#.......###.#.#.#.##.##....
#..#.#.#.##.#.#........###.#.#
.#..#..#........##.#.####..#..
...#.#.#.#.#.##.#.###..#.##..#
#.#..#.##..#.#.#...#.#.....#..
....#...##.#.....#.....##.#..#
#.#.#.##...#.#.#.#.#.##..#.##.
...#..#..##..#..#...#..#.#....
#.#.#.##...#.##..##...#....#.#
..#..#...##....##...#...#.##..
#...#..#...#.#..#.#.#.#..#...#
..#..##..##..#.#..#..#.##.##..
#.#.#...#...#...#..#........#.

And, for fun, here's the code I used to generate these numbers and these mazes. First, the DFS code:

#include <iostream>
#include <algorithm>
#include <set>
#include <vector>
#include <string>
#include <random>
using namespace std;

/* World Dimensions */
const size_t kNumRows = 30;
const size_t kNumCols = 30;

/* Location. */
using Location = pair<size_t, size_t>; // (row, col)

/* Adds the given point to the frontier, assuming it's legal to do so. */
void updateFrontier(const Location& loc, vector<string>& maze, vector<Location>& frontier,
                    set<Location>& usedFrontier) {
  /* Make sure we're in bounds. */
  if (loc.first >= maze.size() || loc.second >= maze[0].size()) return;

  /* Make sure this is still a wall. */
  if (maze[loc.first][loc.second] != '#') return;

  /* Make sure we haven't added this before. */
  if (usedFrontier.count(loc)) return;

  /* All good! Add it in. */
  frontier.push_back(loc);
  usedFrontier.insert(loc);
}

/* Given a location, adds that location to the maze and expands the frontier. */
void expandAt(const Location& loc, vector<string>& maze, vector<Location>& frontier,
              set<Location>& usedFrontier) {
  /* Mark the location as in use. */
  maze[loc.first][loc.second] = '.';

  /* Handle each neighbor. */
  updateFrontier(Location(loc.first, loc.second + 1), maze, frontier, usedFrontier);
  updateFrontier(Location(loc.first, loc.second - 1), maze, frontier, usedFrontier);
  updateFrontier(Location(loc.first + 1, loc.second), maze, frontier, usedFrontier);
  updateFrontier(Location(loc.first - 1, loc.second), maze, frontier, usedFrontier);
}

/* Chooses and removes a random element of the frontier. */
Location sampleFrom(vector<Location>& frontier, mt19937& generator) {
  uniform_int_distribution<size_t> dist(0, frontier.size() - 1);

  /* Pick our spot. */
  size_t index = dist(generator);

  /* Move it to the end and remove it. */
  swap(frontier[index], frontier.back());

  auto result = frontier.back();
  frontier.pop_back();
  return result;
}

/* Returns whether a location is empty. */
bool isEmpty(const Location& loc, const vector<string>& maze) {
  return loc.first < maze.size() && loc.second < maze[0].size() && maze[loc.first][loc.second] == '.';
}

/* Counts the number of empty neighbors of a given location. */
size_t neighborsOf(const Location& loc, const vector<string>& maze) {
  return !!isEmpty(Location(loc.first - 1, loc.second), maze) +
         !!isEmpty(Location(loc.first + 1, loc.second), maze) +
         !!isEmpty(Location(loc.first, loc.second - 1), maze) +
         !!isEmpty(Location(loc.first, loc.second + 1), maze);
}

/* Returns whether a location is in bounds. */
bool inBounds(const Location& loc, const vector<string>& world) {
  return loc.first < world.size() && loc.second < world[0].size();
}

/* Runs a recursive DFS to fill in the maze. */
void dfsFrom(const Location& loc, vector<string>& world, mt19937& generator) {
  /* Base cases: out of bounds? Been here before? Adjacent to too many existing cells? */
  if (!inBounds(loc, world) || world[loc.first][loc.second] == '.' ||
      neighborsOf(loc, world) > 1) return;

  /* All next places. */
  vector<Location> next = {
    { loc.first - 1, loc.second },
    { loc.first + 1, loc.second },
    { loc.first, loc.second - 1 },
    { loc.first, loc.second + 1 }
  };
  shuffle(next.begin(), next.end(), generator);

  /* Mark us as filled. */
  world[loc.first][loc.second] = '.';

  /* Explore! */
  for (const Location& nextStep: next) {
    dfsFrom(nextStep, world, generator);
  }
}

/* Generates a random maze. */
vector<string> generateMaze(size_t numRows, size_t numCols, mt19937& generator) {
  /* Create the maze. */
  vector<string> result(numRows, string(numCols, '#'));

  /* Build the maze! */
  dfsFrom(Location(0, 0), result, generator);

  return result;
}

int main() {
  random_device rd;
  mt19937 generator(rd());

  /* Run some trials. */
  size_t numTrials = 0;
  size_t numSuccesses = 0;

  for (size_t i = 0; i < 10000; i++) {
    numTrials++;

    auto world = generateMaze(kNumRows, kNumCols, generator);

    /* Can we get to the bottom? */
    if (world[kNumRows - 1][kNumCols - 1] == '.') {
      numSuccesses++;

      /* Print the first maze that works. */
      if (numSuccesses == 1) {
        for (const auto& row: world) {
          cout << row << endl;
        }
        cout << endl;
      }
    }
  }

  cout << "Trials:    " << numTrials << endl;
  cout << "Successes: " << numSuccesses << endl;
  cout << "Percent:   " << (100.0 * numSuccesses) / numTrials << "%" << endl;


  cout << endl;
  return 0;
}

Next, the BFS code:

#include <iostream>
#include <algorithm>
#include <set>
#include <vector>
#include <string>
#include <random>
using namespace std;

/* World Dimensions */
const size_t kNumRows = 30;
const size_t kNumCols = 30;

/* Location. */
using Location = pair<size_t, size_t>; // (row, col)

/* Adds the given point to the frontier, assuming it's legal to do so. */
void updateFrontier(const Location& loc, vector<string>& maze, vector<Location>& frontier,
                    set<Location>& usedFrontier) {
  /* Make sure we're in bounds. */
  if (loc.first >= maze.size() || loc.second >= maze[0].size()) return;

  /* Make sure this is still a wall. */
  if (maze[loc.first][loc.second] != '#') return;

  /* Make sure we haven't added this before. */
  if (usedFrontier.count(loc)) return;

  /* All good! Add it in. */
  frontier.push_back(loc);
  usedFrontier.insert(loc);
}

/* Given a location, adds that location to the maze and expands the frontier. */
void expandAt(const Location& loc, vector<string>& maze, vector<Location>& frontier,
              set<Location>& usedFrontier) {
  /* Mark the location as in use. */
  maze[loc.first][loc.second] = '.';

  /* Handle each neighbor. */
  updateFrontier(Location(loc.first, loc.second + 1), maze, frontier, usedFrontier);
  updateFrontier(Location(loc.first, loc.second - 1), maze, frontier, usedFrontier);
  updateFrontier(Location(loc.first + 1, loc.second), maze, frontier, usedFrontier);
  updateFrontier(Location(loc.first - 1, loc.second), maze, frontier, usedFrontier);
}

/* Chooses and removes a random element of the frontier. */
Location sampleFrom(vector<Location>& frontier, mt19937& generator) {
  uniform_int_distribution<size_t> dist(0, frontier.size() - 1);

  /* Pick our spot. */
  size_t index = dist(generator);

  /* Move it to the end and remove it. */
  swap(frontier[index], frontier.back());

  auto result = frontier.back();
  frontier.pop_back();
  return result;
}

/* Returns whether a location is empty. */
bool isEmpty(const Location& loc, const vector<string>& maze) {
  return loc.first < maze.size() && loc.second < maze[0].size() && maze[loc.first][loc.second] == '.';
}

/* Counts the number of empty neighbors of a given location. */
size_t neighborsOf(const Location& loc, const vector<string>& maze) {
  return !!isEmpty(Location(loc.first - 1, loc.second), maze) +
         !!isEmpty(Location(loc.first + 1, loc.second), maze) +
         !!isEmpty(Location(loc.first, loc.second - 1), maze) +
         !!isEmpty(Location(loc.first, loc.second + 1), maze);
}

/* Generates a random maze. */
vector<string> generateMaze(size_t numRows, size_t numCols, mt19937& generator) {
  /* Create the maze. */
  vector<string> result(numRows, string(numCols, '#'));

  /* Worklist of free locations. */
  vector<Location> frontier;

  /* Set of used frontier sites. */
  set<Location> usedFrontier;

  /* Seed the starting location. */
  expandAt(Location(0, 0), result, frontier, usedFrontier);

  /* Loop until there's nothing left to expand. */
  while (!frontier.empty()) {
    /* Select a random frontier location to expand at. */
    Location next = sampleFrom(frontier, generator);

    /* If this spot has exactly one used neighbor, add it. */
    if (neighborsOf(next, result) == 1) {   
      expandAt(next, result, frontier, usedFrontier);
    }
  }

  return result;
}

int main() {
  random_device rd;
  mt19937 generator(rd());

  /* Run some trials. */
  size_t numTrials = 0;
  size_t numSuccesses = 0;

  for (size_t i = 0; i < 10000; i++) {
    numTrials++;

    auto world = generateMaze(kNumRows, kNumCols, generator);

    /* Can we get to the bottom? */
    if (world[kNumRows - 1][kNumCols - 1] == '.') {
      numSuccesses++;

      /* Print the first maze that works. */
      if (numSuccesses == 1) {
        for (const auto& row: world) {
          cout << row << endl;
        }
        cout << endl;
      }
    }
  }

  cout << "Trials:    " << numTrials << endl;
  cout << "Successes: " << numSuccesses << endl;
  cout << "Percent:   " << (100.0 * numSuccesses) / numTrials << "%" << endl;


  cout << endl;
  return 0;
}

Hope this helps!

templatetypedef
  • 362,284
  • 104
  • 897
  • 1,065
  • Could you improve the success ratio by performing the search from both ends? First open the start cell and one adjacent cell. Then open the finish cell and one adjacent cell. Continue alternating until the two meet somewhere. – Jim Mischel Jun 20 '19 at 18:09
  • @JimMischel I bet you could probably do something like that. With the success rate being as high as it is, I *suspect* that this wouldn't be necessary to use this approach in practice, though. – templatetypedef Jun 20 '19 at 18:10
  • 2
    @templatetypedef Not necessary, for sure. However it would make the success rate close to 100%. – btilly Jun 20 '19 at 19:03
  • I've already imagined your solution without counting the stats. However, it's impossible to calculate the BigO notation : the worst case => it never ends. However, it's the best solution from now ;-) – Karim DRIDI Jun 20 '19 at 19:27
  • You are correct that this can run for an unbounded amount of time, but it might still be possible to derive, say, the *expected* runtime. For example, if (as seems to be the case empirically) the probability of success is a constant (say, roughly 70% or 80%), then the probability that you'd need to run this procedure more than k times before hitting a valid maze will be something of the form 1 / c^k for some constant c, something that's extremely unlikely for any reasonably large k. – templatetypedef Jun 20 '19 at 19:30
0

Below I describe one easy way to build a perfect maze.

The idea is that you have three types of cells: closed cells, open cells, and frontier cells.

  • A closed cell is a cell that is still blocked: there is no path from the starting cell to that cell.
  • If there is a path from the starting cell to a cell, then that cell is open.
  • A frontier cell is a closed cell that is adjacent to an open cell.

This figure shows open, closed, and frontier cells.

+--+--+--+--+--+--+--+--+--+--+
|** **|FF|  |  |  |  |  |  |  |
+--+  +--+--+--+--+--+--+--+--+
|FF|**|FF|  |  |  |  |  |  |  |
+--+  +--+--+--+--+--+--+--+--+
|** **|FF|  |  |  |  |  |  |  |
+  +--+--+--+--+--+--+--+--+--+
|**|FF|FF|FF|  |  |  |  |  |  |
+  +--+--+--+--+--+--+--+--+--+
|** ** ** **|FF|  |  |  |  |  |
+--+--+--+  +--+--+--+--+--+--+
|FF|FF|FF|**|FF|  |  |  |  |  |
+--+--+--+--+--+--+--+--+--+--+
|  |  |FF|FF|  |  |  |  |  |  |
+--+--+--+--+--+--+--+--+--+--+
|  |  |  |  |  |  |  |  |  |  |
+--+--+--+--+--+--+--+--+--+--+
|  |  |  |  |  |  |  |  |  |  |
+--+--+--+--+--+--+--+--+--+--+
|  |  |  |  |  |  |  |  |  |  |
+--+--+--+--+--+--+--+--+--+--+

Cells with '**' in them are open. Cells with 'FF' in them are frontier cells. Blank cells are closed cells.

The idea is that you start with every cell in your grid as closed.

Then, create an initially empty list of cells. That is your frontier.

Open the starting cell and one of the adjacent cells, and add all cells adjacent to those two to the frontier. So if the upper left is the starting cell, then the first two rows are.

+--+--+--+--+--+--+--+--+--+--+
|** **|  |  |  |  |  |  |  |  |
+--+--+--+--+--+--+--+--+--+--+
|  |  |  |  |  |  |  |  |  |  |

And your frontier array contains {[0,2],[1,0],[1,1]}.

Now, perform the following loop until the frontier array is empty:

  1. Randomly select a cell from the frontier array.
  2. Swap that cell with the last cell in the frontier array.
  3. Remove the last cell from the frontier array.
  4. Open that selected frontier cell into an adjacent open cell.
  5. Add any closed cells that are adjacent to the newly-opened cell to the frontier array.

That is guaranteed to create a maze that has a single path from start to finish.

If you don't want to open all of the cells in the graph, then modify the program to stop when the finish cell is selected from the frontier and opened.

Time complexity is O(height * width). As I recall, the maximum size that the frontier array will reach is (2*height*width)/3. In practice, I've never seen it get quite that large.

Jim Mischel
  • 131,090
  • 20
  • 188
  • 351
  • 1
    I think you've misunderstood the OP's question. He is explicitly *not* building a maze with cells that may or may not have walls between them, but rather, a maze with cells that may or may not be covered with blocks. So your step #4, "Open that selected frontier cell into an adjacent open cell", is not possible; if you "open" a cell, then you will connect it to *all* adjacent open cells, not just one. – ruakh Jun 20 '19 at 17:05
  • 1
    It's entirely possible that I'm misinterpreting the OP's question, but my sense is that they're trying to lay out a path through a grid in a way that's a bit different from what you're proposing here. Specifically, they don't have notions of walls between cells, since each cell is either completely empty or completely full. – templatetypedef Jun 20 '19 at 17:05
  • 1
    @templatetypedef Interesting. I think my proposed algorithm still works, with one modification. You can't open a frontier cell if it's adjacent to more than one open cell. Oh ... except then you might not be able to open the finish cell. Hmmmm . . . – Jim Mischel Jun 20 '19 at 18:05
  • I believe that can be made to work (see my answer, which talks about how to do this), but some extra handling is needed because such a maze isn't guaranteed to include the bottom-right and top-left corner. – templatetypedef Jun 20 '19 at 18:07
  • @JimMischel "Oh ... except then you might not be able to open the finish cell. Hmmmm..". It's exactly the point :p – Karim DRIDI Jun 20 '19 at 19:25
  • @KarimDRIDI As I pointed out in a comment on the other answer, you can increase your likelihood of success by doing the search for both ends. – Jim Mischel Jun 20 '19 at 19:35
  • @JimMischel I tried that way but it sometimes generates two path that can join each other. – Karim DRIDI Jun 21 '19 at 06:49
  • I think the bottom-right corner is easy. If you "open" a cell adjacent to it, also immediately open the bottom-right corner. There's one scenario with the bottom right corner that can prevent a solution, so just special case that: http://coliru.stacked-crooked.com/a/798c421b01ca28c9 – Mooing Duck Jun 21 '19 at 23:55