Как найти кратчайший путь в лабиринте

Учитывая лабиринт в виде бинарной прямоугольной матрицы найти длину кратчайшего пути в лабиринте от заданного источника до заданного пункта назначения.

Путь может быть построен только из ячеек со значением 1, и в любой момент мы можем двигаться только на один шаг в одном из четырех направлений. Допустимые ходы:

Go Top: (x, y) ——> (x – 1, y)
Go Left: (x, y) ——> (x, y – 1)
Go Down: (x, y) ——> (x + 1, y)
Go Right: (x, y) ——> (x, y + 1)

Например, рассмотрим следующую бинарную матрицу. Если source = (0, 0) а также destination = (7, 5), кратчайший путь от источника к месту назначения имеет длину 12.

 [ 1  1  1  1  1  0  0  1  1  1 ]
 [ 0  1  1  1  1  1  0  1  0  1 ]
 [ 0  0  1  0  1  1  1  0  0  1 ]
 [ 1  0  1  1  1  0  1  1  0  1 ]
 [ 0  0  0  1  0  0  0  1  0  1 ]
 [ 1  0  1  1  1  0  0  1  1  0 ]
 [ 0  0  0  0  1  0  0  1  0  1 ]
 [ 0  1  1  1  1  1  1  1  0  0 ]
 [ 1  1  1  1  1  0  0  1  1  1 ]
 [ 0  0  1  0  0  1  1  0  0  1 ]

Потренируйтесь в этой проблеме

Мы уже обсуждали Backtracking решение в предыдущий пост. Временная сложность решения с Backtracking будет выше, поскольку необходимо пройти все пути. Однако, поскольку это задача о кратчайшем пути, Поиск в ширину (BFS) будет идеальным выбором.

 
The Алгоритм Ли является одним из возможных решений задач маршрутизации в лабиринтах, основанных на поиске в ширину. Он всегда дает оптимальное решение, если оно существует, но работает медленно и требует много памяти. Вот полный алгоритм:

  1. Создать пустой queue и поставить в queue исходную ячейку, имеющую расстояние 0 от источника (самого себя), и пометить ее как посещенную.
  2. Цикл до тех пор, пока queue не станет пустой.
    • Удалите из очереди передний узел.
    • Если выдвинутый узел является целевым узлом, верните его расстояние.
    • В противном случае для каждой из четырех соседних ячеек текущей ячейки поставьте в queue каждую допустимую ячейку с помощью +1 расстояние и отметить их как посещенные.
  3. Если все узлы queue обработаны, а место назначения не достигнуто, вернуть false.

Обратите внимание, что в BFS сначала посещаются все ячейки, имеющие кратчайший путь, равный 1, а затем соседние ячейки, имеющие кратчайший путь, равный 1. 1 + 1 = 2 и т. д. Итак, если мы достигнем любого узла в BFS, его кратчайший путь на единицу больше, чем кратчайший путь родителя. Таким образом, первое вхождение ячейки назначения дает нам результат, и мы можем остановить наш поиск на этом. Невозможно, чтобы кратчайший путь существовал из какой-то другой клетки, для которой мы еще не достигли данного узла. Если бы такой путь был возможен, мы бы его уже исследовали.

Ниже приведена программа на C++, Java и Python, которая демонстрирует это:

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

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

83

84

85

86

87

88

89

90

91

92

93

94

95

96

97

98

99

100

101

102

103

104

105

106

107

108

109

110

111

112

113

114

115

116

117

118

119

120

121

122

123

124

125

126

127

128

129

130

131

132

#include <iostream>

#include <queue>

#include <vector>

#include <climits>

#include <cstring>

using namespace std;

// Узел queue

struct Node

{

    // (x, y) представляет собой координаты ячейки матрицы, а

    // `dist` представляет их минимальное расстояние от источника

    int x, y, dist;

};

// Ниже массивы детализируют все четыре возможных перемещения из ячейки

int row[] = { 1, 0, 0, 1 };

int col[] = { 0, 1, 1, 0 };

// Функция проверки возможности перехода на позицию (строка, столбец)

// от текущей позиции. Функция возвращает false, если (строка, столбец)

// недопустимая позиция или имеет значение 0 или уже посещено.

bool isValid(vector<vector<int>> const &mat, vector<vector<bool>> &visited,

        int row, int col) {

    return (row >= 0 && row < mat.size()) && (col >= 0 && col < mat[0].size())

        && mat[row][col] && !visited[row][col];

}

// Находим кратчайший маршрут в матрице `mat` из источника

// ячейка (i, j) в ячейку назначения (x, y)

int findShortestPathLength(vector<vector<int>> const &mat, pair<int, int> &src,

                    pair<int, int> &dest)

{

    // базовый случай: неверный ввод

    if (mat.size() == 0 || mat[src.first][src.second] == 0 ||

            mat[dest.first][dest.second] == 0) {

        return 1;

    }

    // Матрица `M × N`

    int M = mat.size();

    int N = mat[0].size();

    // строим матрицу `M × N` для отслеживания посещенных ячеек

    vector<vector<bool>> visited;

    visited.resize(M, vector<bool>(N));

    // создаем пустую queue

    queue<Node> q;

    // получаем исходную ячейку (i, j)

    int i = src.first;

    int j = src.second;

    // помечаем исходную ячейку как посещенную и ставим исходный узел в queue

    visited[i][j] = true;

    q.push({i, j, 0});

    // сохраняет длину самого длинного пути от источника к месту назначения

    int min_dist = INT_MAX;

    // цикл до тех пор, пока queue не станет пустой

    while (!q.empty())

    {

        // удалить передний узел из очереди и обработать его

        Node node = q.front();

        q.pop();

        // (i, j) представляет текущую ячейку, а `dist` хранит ее

        // минимальное расстояние от источника

        int i = node.x, j = node.y, dist = node.dist;

        // если пункт назначения найден, обновляем `min_dist` и останавливаемся

        if (i == dest.first && j == dest.second)

        {

            min_dist = dist;

            break;

        }

        // проверяем все четыре возможных перемещения из текущей ячейки

        // и ставим в queue каждое допустимое движение

        for (int k = 0; k < 4; k++)

        {

            // проверяем, можно ли выйти на позицию

            // (i + row[k], j + col[k]) от текущей позиции

            if (isValid(mat, visited, i + row[k], j + col[k]))

            {

                // отметить следующую ячейку как посещенную и поставить ее в queue

                visited[i + row[k]][j + col[k]] = true;

                q.push({ i + row[k], j + col[k], dist + 1 });

            }

        }

    }

    if (min_dist != INT_MAX) {

        return min_dist;

    }

    return 1;

}

int main()

{

    vector<vector<int>> mat =

    {

        { 1, 1, 1, 1, 1, 0, 0, 1, 1, 1 },

        { 0, 1, 1, 1, 1, 1, 0, 1, 0, 1 },

        { 0, 0, 1, 0, 1, 1, 1, 0, 0, 1 },

        { 1, 0, 1, 1, 1, 0, 1, 1, 0, 1 },

        { 0, 0, 0, 1, 0, 0, 0, 1, 0, 1 },

        { 1, 0, 1, 1, 1, 0, 0, 1, 1, 0 },

        { 0, 0, 0, 0, 1, 0, 0, 1, 0, 1 },

        { 0, 1, 1, 1, 1, 1, 1, 1, 0, 0 },

        { 1, 1, 1, 1, 1, 0, 0, 1, 1, 1 },

        { 0, 0, 1, 0, 0, 1, 1, 0, 0, 1 },

    };

    pair<int, int> src = make_pair(0, 0);

    pair<int, int> dest = make_pair(7, 5);

    int min_dist = findShortestPathLength(mat, src, dest);

    if (min_dist != 1)

    {

        cout << «The shortest path from source to destination «

                «has length « << min_dist;

    }

    else {

        cout << «Destination cannot be reached from a given source»;

    }

    return 0;

}

Скачать  Выполнить код

результат:

The shortest path from source to destination has length 12

Java

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

83

84

85

86

87

88

89

90

91

92

93

94

95

96

97

98

99

100

101

102

103

104

105

106

107

108

109

110

111

112

113

114

115

116

117

118

119

120

121

122

123

124

125

import java.util.ArrayDeque;

import java.util.Queue;

// Узел queue

class Node

{

    // (x, y) представляет собой координаты ячейки матрицы, а

    // `dist` представляет их минимальное расстояние от источника

    int x, y, dist;

    Node(int x, int y, int dist)

    {

        this.x = x;

        this.y = y;

        this.dist = dist;

    }

}

class Main

{

    // Ниже массивы детализируют все четыре возможных перемещения из ячейки

    private static final int[] row = { 1, 0, 0, 1 };

    private static final int[] col = { 0, 1, 1, 0 };

    // Функция проверки возможности перехода на позицию (строка, столбец)

    // от текущей позиции. Функция возвращает false, если (строка, столбец)

    // недопустимая позиция или имеет значение 0 или уже посещено.

    private static boolean isValid(int[][] mat, boolean[][] visited, int row, int col)

    {

        return (row >= 0) && (row < mat.length) && (col >= 0) && (col < mat[0].length)

                && mat[row][col] == 1 && !visited[row][col];

    }

    // Находим кратчайший маршрут в матрице `mat` из источника

    // ячейка (i, j) в ячейку назначения (x, y)

    private static int findShortestPathLength(int[][] mat, int i, int j, int x, int y)

    {

        // базовый случай: неверный ввод

        if (mat == null || mat.length == 0 || mat[i][j] == 0 || mat[x][y] == 0) {

            return 1;

        }

        // Матрица `M × N`

        int M = mat.length;

        int N = mat[0].length;

        // создаем матрицу для отслеживания посещенных ячеек

        boolean[][] visited = new boolean[M][N];

        // создаем пустую queue

        Queue<Node> q = new ArrayDeque<>();

        // помечаем исходную ячейку как посещенную и ставим исходный узел в queue

        visited[i][j] = true;

        q.add(new Node(i, j, 0));

        // сохраняет длину самого длинного пути от источника к месту назначения

        int min_dist = Integer.MAX_VALUE;

        // цикл до тех пор, пока queue не станет пустой

        while (!q.isEmpty())

        {

            // удалить передний узел из очереди и обработать его

            Node node = q.poll();

            // (i, j) представляет текущую ячейку, а `dist` хранит ее

            // минимальное расстояние от источника

            i = node.x;

            j = node.y;

            int dist = node.dist;

            // если пункт назначения найден, обновляем `min_dist` и останавливаемся

            if (i == x && j == y)

            {

                min_dist = dist;

                break;

            }

            // проверяем все четыре возможных перемещения из текущей ячейки

            // и ставим в queue каждое допустимое движение

            for (int k = 0; k < 4; k++)

            {

                // проверяем, можно ли выйти на позицию

                // (i + row[k], j + col[k]) от текущей позиции

                if (isValid(mat, visited, i + row[k], j + col[k]))

                {

                    // отметить следующую ячейку как посещенную и поставить ее в queue

                    visited[i + row[k]][j + col[k]] = true;

                    q.add(new Node(i + row[k], j + col[k], dist + 1));

                }

            }

        }

        if (min_dist != Integer.MAX_VALUE) {

            return min_dist;

        }

        return 1;

    }

    public static void main(String[] args)

    {

        int[][] mat =

        {

            { 1, 1, 1, 1, 1, 0, 0, 1, 1, 1 },

            { 0, 1, 1, 1, 1, 1, 0, 1, 0, 1 },

            { 0, 0, 1, 0, 1, 1, 1, 0, 0, 1 },

            { 1, 0, 1, 1, 1, 0, 1, 1, 0, 1 },

            { 0, 0, 0, 1, 0, 0, 0, 1, 0, 1 },

            { 1, 0, 1, 1, 1, 0, 0, 1, 1, 0 },

            { 0, 0, 0, 0, 1, 0, 0, 1, 0, 1 },

            { 0, 1, 1, 1, 1, 1, 1, 1, 0, 0 },

            { 1, 1, 1, 1, 1, 0, 0, 1, 1, 1 },

            { 0, 0, 1, 0, 0, 1, 1, 0, 0, 1 },

        };

        int min_dist = findShortestPathLength(mat, 0, 0, 7, 5);

        if (min_dist != 1) {

            System.out.println(«The shortest path from source to destination « +

                    «has length « + min_dist);

        } else {

            System.out.println(«Destination cannot be reached from source»);

        }

    }

}

Скачать  Выполнить код

результат:

The shortest path from source to destination has length 12

Python

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

83

84

85

86

87

88

89

90

91

92

93

94

95

96

97

98

99

100

101

102

103

104

import sys

from collections import deque

# Ниже перечислены все четыре возможных перемещения из ячейки.

row = [1, 0, 0, 1]

col = [0, 1, 1, 0]

# Функция проверки возможности перехода на позицию (строка, столбец)

# с текущей позиции. Функция возвращает false, если строка, столбец

# не является действительной позицией или имеет значение 0 или уже посещено.

def isValid(mat, visited, row, col):

    return (row >= 0) and (row < len(mat)) and (col >= 0) and (col < len(mat[0]))

           and mat[row][col] == 1 and not visited[row][col]

# Найти кратчайший возможный маршрут в матрице `mat` от источника `src` до

# пункт назначения `dest`

def findShortestPathLength(mat, src, dest):

    # получить исходную ячейку (i, j)

    i, j = src

    # получить ячейку назначения (x, y)

    x, y = dest

    # Базовый случай: неверный ввод

    if not mat or len(mat) == 0 or mat[i][j] == 0 or mat[x][y] == 0:

        return 1

    # Матрица `M × N`

    (M, N) = (len(mat), len(mat[0]))

    # создает матрицу для отслеживания посещенных ячеек.

    visited = [[False for x in range(N)] for y in range(M)]

    # создает пустую queue

    q = deque()

    # помечает исходную ячейку как посещенную и ставит исходный узел в queue.

    visited[i][j] = True

    # (i, j, dist) представляет собой координаты ячейки матрицы, а их

    # минимальное расстояние от источника

    q.append((i, j, 0))

    # хранит длину самого длинного пути от источника до пункта назначения.

    min_dist = sys.maxsize

    # Цикл # до тех пор, пока queue не станет пустой

    while q:

        # удаляет передний узел из очереди и обрабатывает его

        (i, j, dist) = q.popleft()

        # (i, j) представляет текущую ячейку, а `dist` хранит ее

        # минимальное расстояние от источника

        #, если пункт назначения найден, обновить `min_dist` и остановить

        if i == x and j == y:

            min_dist = dist

            break

        # проверяет все четыре возможных перемещения из текущей ячейки

        # и ставить в queue каждое допустимое движение

        for k in range(4):

            # проверить, можно ли выйти на позицию

            # (i + row[k], j + col[k]) из текущей позиции

            if isValid(mat, visited, i + row[k], j + col[k]):

                # помечает следующую ячейку как посещенную и ставит ее в queue

                visited[i + row[k]][j + col[k]] = True

                q.append((i + row[k], j + col[k], dist + 1))

    if min_dist != sys.maxsize:

        return min_dist

    else:

        return 1

if __name__ == ‘__main__’:

    mat = [

        [1, 1, 1, 1, 1, 0, 0, 1, 1, 1],

        [0, 1, 1, 1, 1, 1, 0, 1, 0, 1],

        [0, 0, 1, 0, 1, 1, 1, 0, 0, 1],

        [1, 0, 1, 1, 1, 0, 1, 1, 0, 1],

        [0, 0, 0, 1, 0, 0, 0, 1, 0, 1],

        [1, 0, 1, 1, 1, 0, 0, 1, 1, 0],

        [0, 0, 0, 0, 1, 0, 0, 1, 0, 1],

        [0, 1, 1, 1, 1, 1, 1, 1, 0, 0],

        [1, 1, 1, 1, 1, 0, 0, 1, 1, 1],

        [0, 0, 1, 0, 0, 1, 1, 0, 0, 1]

    ]

    src = (0, 0)

    dest = (7, 5)

    min_dist = findShortestPathLength(mat, src, dest)

    if min_dist != 1:

        print(«The shortest path from source to destination has length», min_dist)

    else:

        print(«Destination cannot be reached from source»)

Скачать  Выполнить код

результат:

The shortest path from source to destination has length 12

Временная сложность предлагаемого решения равна O(M × N) и требует O(M × N) дополнительное пространство, где M а также N являются размерами матрицы.

 
Упражнение: Расширьте решение, чтобы напечатать кратчайший путь от источника к месту назначения.

Алгоритмы поиска решений лабиринтов и их практическое применение в реальном мире — Кит Берроуз и Ванесса Клотцман

Время на прочтение
18 мин

Количество просмотров 4.6K

Истоки понятия «лабиринт»

Первое упоминание термина “maze” датируется тринадцатым веком, а “labyrinth” — к четырнадцатым. Сама концепция лабиринтов восходит к эпохе греческого мифологического героя Тесея — древнего героя, успешно прошедшего Кносский лабиринт и сразившего Минотавра.

Однако в более современном контексте лабиринты не имеют ничего общего с убийством мифологических существ. Теперь лабиринты чаще всего представляют из себя прямоугольную головоломку, состоящую из коридоров и поворотов, которые в конечном итоге ведут к выходу. И точно так же, как древний герой Тесей путешествовал по лабиринту, чтобы сразить Минотавра, современный человек решает задачу поиска пути в лабиринте не только для того, чтобы найти выход из лабиринта, но и для гораздо более широкого круга целей — решения связанных задач наиболее эффективным и доступным образом.

Введение

Лабиринты являются неотъемлемой частью нашей реальности. Хотя лабиринты в реальном физическом мире редко походят на стереотипное описание лабиринтов (черно-белая прямоугольная головоломка), общая идея и концепция лабиринтов находят свое отражение во многих аспектах нашего быта. В нашей повседневной жизни мы часто сталкиваемся с ситуациями, в которых необходимо найти самый быстрый и эффективный маршрут к заданному пункту назначения. Например, когда мы ищем в продуктовом магазине отдел с молоком, мы можем просто просмотреть каждый отдел, пока мы не найдем молоко. Однако это не самый эффективный способ. Если воспользоваться подсказками в магазине или полученными ранее воспоминаниями о том, куда ведут разные пути в магазине, поиск молока может стать гораздо более эффективным процессом. В некотором смысле мы должны использовать этот же процесс при решении задачи поиска пути в реальных лабиринтах. Метод/алгоритм, выбранный нами для поиска решения лабиринта, влияет на эффективность процесса.

Эффективность

Эффективность вычислительного алгоритма — это количество вычислительных ресурсов, используемых указанным алгоритмом, и время, необходимое для получения желаемого результата. Можно повысить эффективность алгоритма, удалив вложенные циклы for, кэшируя промежуточные или предыдущие результаты и уменьшив временную сложность алгоритма.

Временная сложность

Временная сложность измеряет время выполнения каждого оператора в коде алгоритма. При анализе фрагмента кода на предмет его временной сложности используется нотация большого “O”. Временная сложность — это функция от размера входных данных, равная максимальному количеству элементарных операций, проделываемых алгоритмом для решения экземпляра задачи указанного размера. «Существует связь между объемом входных данных (n) и количеством выполненных операций (N) относительно времени».

Константное (постоянное) время — O(1)

Линейное время — O(n)

Логарифмическое время — O(log(n))

Квадратичное время — O(n²)

Кубическое время — O(n³)

Как правило, мы стремимся создать алгоритм, который будет выполняться за константное время: O(1). Это значило бы, что время выполнения алгоритма одинаково независимо от размера набора данных или количества входных данных. Логарифмическая временная сложность — O(log (n)) — указывает на то, что по мере роста размера входных данных количество операций растет логарифмически (или достаточно медленно). С другой стороны, алгоритм, который может похвастаться кубической временной сложностью — O (n³) — имеет время выполнения, пропорциональное кубу размера набора данных. В результате добросовестный программист попытается свести к минимуму временную сложность своих алгоритмов и методов, чтобы уменьшить время выполнения и затрачиваемые ресурсы.

На приведенном ниже графике показано сравнение различных временных сложностей:

Длина кода

Не следует путать временную сложность с количеством строк кода. Такой показатель, как количество строк кода чрезвычайно легко измерить, но почти невозможно интерпретировать. В первую очередь это потому, что один алгоритм может иметь меньшую временную сложность при количестве строк кода большем, чем у другого алгоритма со сравнительно большей временной сложностью. Ошибочно считать, что временная сложность напрямую связана с количеством строк кода.

Давайте рассмотрим это на примере конкретной задачи: даны массив целых чисел nums и целое число target, нам нужно вернуть индексы двух чисел, которые в сумме равны target. Алгоритмы, решающие эту задачу, могут иметь разные временные сложности.

Реализация с временной сложностью O(n²):

Реализация с временной сложностью O(n):

Оба этих подхода возвращают один и тот же результат. Однако мы прекрасно видим, что, несмотря на наличие дополнительной строки кода, второй пример выполняет задачу с временной сложностью O(n), а не O(n²), что в подавляющем большинстве случаев намного быстрее.

Несмотря на это, алгоритмы с меньшим количеством строк кода зачастую менее сложны, более читабельны и проще в использовании. Далее мы будем рассматривать разные алгоритмические подходы к одной и той же задаче, поэтому вполне ожидаемо, что все алгоритмы поиска решения лабиринта будут приблизительно одинакового объема. Следовательно, длину кода алгоритма все таки следует рассматривать как критерий при сравнении и противопоставлении рассматриваемых нами алгоритмов.

Алгоритмы поиска решения лабиринта

Алгоритмы поиска решения лабиринта представляют собой автоматизированные методы прохода. Отталкиваясь от конкретного лабиринт, можно использовать множество различных алгоритмов для нахождения его решения. В этой статье мы обсудим и проанализируем следующие хорошо известные алгоритмы поиска решения лабиринта:

  • Полный перебор/”Грубая сила” (Bruteforce Algorithm)

  • Алгоритм Тремо (Trémaux Algorithm)

  • Алгоритм случайного поведения мыши (Random Mouse Algorithm)

  • Метод следования вдоль стены (Wall-Follower Method)

  • Метод обнаружения тупиков (Dead-End Filling)

Алгоритм полного перебора

Алгоритм поиска решения лабиринта путем полного перебора (в простонародье “брутфорс”) исследует каждый проход, пока не найдет правильный путь. Работа алгоритмов такого типа обычно заключается в проверке всех возможных путей через лабиринт с постоянным перезапуском, когда сгенерированный путь оказывается неудачным. Такой подход зачастую имеет высокую временную сложность и делает программу неэффективной, поскольку она выполняет избыточный код. В результате временная сложность алгоритма такого типа часто составляет O(n!). С другой стороны, алгоритм полного перебора очень легок в реализации, поскольку он не требует маркировки развилок (что требуется для алгоритма Тремо) и зачастую состоит из небольшого количества операторов повторяющихся в цикле.

Алгоритм Тремо

Алгоритм Тремо, изобретенный Шарлем Пьером Тремо (Charles Pierre Trémaux), представляет из себя метод поиска решения лабиринта, который, чтобы обозначить путь, рисует линии и точки на протяжении всего лабиринта. Существует ряд правил, которым необходимо следовать в рамках этого алгоритма:

  • Выберите случайный проход и следуйте по нему до следующей развилки.

  • Помечайте начало и конец каждого прохода по мере их прохождения (на анимации ниже в качестве меток используются точки).

  • Если вы идете по проходу в первый раз, то вам нужно помечать его одной точкой. 

  • Если вы зашли в тупик, вернитесь назад по тому же пути до первой развилки, пометив этот проход вторыми точками (на анимации две точки представлены крестом).

  • Проход, который отмечен двумя точками (на анимации это крест), не подлежит проходу и считается тупиком.

  • На развилке всегда выбирайте проход, отмеченный наименьшим количеством точек (в идеале не отмеченный ни одной точкой).

Этот алгоритм накладывает дополнительное требование помечать каждую пройденную развилку. Однако этот алгоритм более эффективен и имеет меньшую временную сложность, чем алгоритмы полного перебора и случайной мыши. Цена этой эффективности — более высокая сложность реализации. Впоследствии этот алгоритм был обобщен и назван “поиск в глубину”.

*Демонстрация работы алгоритма Тремо:

Пример реализации алгоритма Тремо на Python:

import random
from walker_base import WalkerBase
FOUND_COLOR = 'red'
VISITED_COLOR = 'gray70'
class Tremaux(WalkerBase):
    class Node(object):
        __slots__ = 'passages'
        def __init__(self):
            self.passages = set()
    def __init__(self, maze):
        super(Tremaux, self).__init__(maze, maze.start(), self.Node())
        self._maze.clean()
        self._last = None     # Заготовка на будущее
    def _is_junction(self, cell):
        return cell.count_halls() > 2
    def _is_visited(self, cell):
        return len(self.read_map(cell).passages) > 0
    def _backtracking(self, cell, last):
        return last in self.read_map(cell).passages
        

    def step(self):
        # Сердечно прошу простить меня за этот ход
        if self._cell is self._maze.finish():
            self._isDone = True
            self.paint(self._cell, FOUND_COLOR)
            return


        # print self._cell.get_position()
        paths = self._cell.get_paths(last=self._last)
        # print paths
        random.shuffle(paths)
        if self._is_visited(self._cell):
            # Мы уже были здесь раньше
            if self._backtracking(self._cell, self._last):
                # Возвращаемся назад; проверяем, если какие-нибудь непосещенные отрезки пути
                unvisited = filter(lambda c: not self._is_visited(c), paths)
                if len(unvisited) > 0:
                    self._last = self._cell
                    self._cell = unvisited.pop()
                else:   
                    # Непосещенных отрезков пути нет
                    self.paint(self._cell, VISITED_COLOR)
                    # Ищем путь назад
                    passages = self.read_map(self._cell).passages
                    unvisited = set(self._cell.get_paths()).difference(passages)
                    self._last = self._cell
                    self._cell = unvisited.pop()
            else:
                # Мы пришли к уже посещенному ранее отрезку; разворачиваемся назад
                self._cell, self._last = self._last, self._cell
        else:
            # Новый отрезок; двигаемся в случайном порядке
            if len(paths) > 0:
                # Не тупик
                self.paint(self._cell, FOUND_COLOR)
                self._last = self._cell
                self._cell = paths.pop()
            else:
                # Тупик; возвращаемся
                self.paint(self._cell, VISITED_COLOR)
                self._cell, self._last = self._last, self._cell

        self.read_map(self._last).passages.add(self._cell)
        # print self.read_map(self._cell).passages
        # print self.read_map(self._last).passages
        # raw_input('...')

Алгоритм случайного поведения мыши

Алгоритм случайного поведения мыши — крайне неинтеллектуальный и простой алгоритм. Часто он реализуется в качестве самого быстрого и простого варианта. В рамках этого алгоритма, как только мы начинаем двигаться по лабиринту и достигает развилки, мы “как мышь в лабиринте” случайным образом выбираем направление, в котором продолжим путь:

  • Следуем по текущему проходу, пока на пути нам не встретится развилка.

  • Затем мы случайным образом принимаем решение о следующем направлении, по которому мы будем следовать.

Этот алгоритм практически вслепую ищет выход из лабиринта и обычно занимает очень много времени. Несмотря на все это, мы всегда получим решение, так как алгоритм (“мышь”) в конечном итоге обязательно найдет правильный путь через лабиринт. Случайность и неэффективность, связанные с алгоритмами случайного поведения мыши, почти всегда являются причиной высокой временной сложности. Однако, этот алгоритм может случайным образом найти правильный путь с первой попытки/цикла, что дает нам временную сложность в лучшем случае O(1). Но, скорее всего, этот алгоритм будет неоднократно перебирать неудачные варианты, которые мы уже опробовали.

Пример кода алгоритма случайного поведения мыши на Python:

from random import choice
from maze_constants import *
from walker_base import WalkerBase
MOUSE_COLOR = 'brown'
class RandomMouse(WalkerBase):
    def __init__(self, maze):
        super(RandomMouse, self).__init__(maze, maze.start())
        self._maze.clean()
        self._last = None
        self._delay = 50
    def step(self):
        if self._cell is self._maze.finish():
            self._isDone = True
            return
        paths = self._cell.get_paths(last=self._last)
        if len(paths) == 0:
            # Мы попали в тупик
            self._cell, self._last = self._last, self._cell
        else:
            self._last = self._cell
            self._cell = choice(paths)
        self.paint(self._cell, MOUSE_COLOR)
        self.paint(self._last, OPEN_FILL)

Метод следования вдоль стены

Одним из наиболее широко известных методов поиска решения лабиринтов является метод следования вдоль стены, также известный как “правило левой/правой руки”. Этот метод основан на внешней связности лабиринта — все стены  должны быть соединены с внешней границей лабиринта. Если это так, то всегда можно найти выход из лабиринта, непрерывно следуя либо по левой, либо по правой стороне на протяжении всего лабиринта. Однако в тех случаях, когда не все стены соединены с внешними границами, этот метод не всегда будет находить решение. Этот метод/алгоритм полезен, если нам известно, что стены лабиринта связаны между собой. Алгоритм очень эффективен, так как не требует маркировки развилок или перезапуска при неудачных попытках; алгоритм просто следует по левой или правой стороне лабиринта.

Программирование метода следования вдоль стеной, вероятно, будет относительно простым и не потребует большого количества строк кода. В результате временная сложность, несомненно, будет лучше, чем у алгоритма полного перебора или случайного поведения мыши. Тем не менее, явная бесполезность этого метода для множество лабиринтов является большим минусом.

Пример реализации алгоритма следования вдоль стены.

Метод обнаружения тупиков

Алгоритм обнаружения тупиков использует другой подход к поиску решения лабиринта. Вместо того, чтобы пытаться пройти лабиринт как можно быстрее и эффективнее, алгоритм обнаружения тупиков заполняют все тупики, оставляя только правильный(е) путь(и). Правила этого алгоритма:

  • Найти все тупики в лабиринте.

  • Заполнить каждый путь из каждого тупика.

  • Последний (оставшийся) путь является правильным путем.

Преимущества использования алгоритма обнаружения тупиков заключаются в том, что он может найти несколько решений лабиринта, если они существуют, и алгоритм должен работать для любого типа лабиринта. Несмотря на это, алгоритм очень неэффективен, особенно если лабиринт большой. Алгоритм должен проверять каждый тупик, а затем заполнять/отмечать его. Это отнимает много времени и наводит меня на мысль, что любая более-менее разумная программа с реализацией этого алгоритма будет иметь временную сложность не менее O(n²). Видео алгоритма в действии можно посмотреть ниже:

Пример приложения с роботом

Я решила внедрить вышеперечисленные алгоритмы поиска решения лабиринта в настоящего робота, который будет пытаться найти выход из реального лабиринта. Сделать это можно с помощью Raspberri Pi Pico и MicroPython.

В этом проекте используется робот Yahboom Raspberry Pi Pico Robot:

В рамках этого руководства вам не помешало бы некоторое знание Python. Робот Yahboom Pico поставляется с Raspberry Pi Pico, и вы также можете докупить множество дополнительных в рамках набора Pico Sensor Kit. В этом проекте не будут использоваться датчики из набора Sensor Kit, поэтому вам достаточно приобрести только Pico Robot.

Для начала нам нужно будет собрать робота Yahboom из отдельных частей и Raspberry Pi Pico, а затем подготовить для запуска MicroPython. Колеса должны быть тщательно выровнены, чтобы избежать ошибок в движений при выполнении кода. MicroPython — это язык программирования, написанный на C и оптимизированный для работы на микроконтроллерах и аппаратных средствах. Он позволяет нам управлять оборудованием, подключенным к Raspberry Pi Pico, не сталкиваясь с трудностями, связанными с языками более низкого уровня, такими как C или C++.

После того, как все будет подготовлено, мы сможем протестировать различные алгоритмы на реальном лабиринте, собранном из цветных листов бумаги и пенопласта.

Установка программного обеспечения

Raspberry Pi Pico можно запрограммировать, подключив его к компьютеру через кабель micro-USB. Но для начала нам нужно установить MicroPython, после чего мы сможем начать программирование на устройстве. Самые актуальные версии файлов MicroPython UF2 можно найти в документации Raspberri Pi по MicroPython.

*Обратите внимание, что на Pico нужно удерживать BOOTSEL, чтобы библиотека отобразилась в Windows/Mac.

Кроме того, должны быть установлены компиляторы Thonny Python и MicroPython. Thonny позволит нам общаться с Raspberry Pi Pico и устройствами, подключенными к его контактам. Если MicroPython установлен на Raspberry Pi Pico, то соответствующая опция должна быть доступна в настройках интерпретатора Thonny:

После успешного подключения к Thonny мы сможем получить доступ к Raspberry Pi Pico и начать программирование. Python-библиотека, которую рекомендуют производители робота, называется “Pico_Car”. Ее можно скачать здесь в разделе “библиотека” и впоследствии установить на Raspberry Pi Pico через Thonny. Кроме того, производители предоставляют приложение YahboomRobot на iOS для управления роботом. Чтобы использовать приложение, на Raspberry Pi Pico нужно загрузить и запустить “Bluetooth Control.py”. Файл можно скачать здесь в каталоге: “3. Robotics Course”. Если все правильно установлено и успешно запущено, то приложение должно выглядеть как на рисунке ниже, предоставляя нам возможность управлять роботом в режиме реального времени:

Чтобы управлять Raspberry Pi Pico без необходимости использовать это IOS-приложение, которое не позволяет нам запускать свой код, нам нужно реализовать управление через Bluetooth самостоятельно. Для этого мы должны использовать приложение для Android — Serial Bluetooth Terminal. Это приложение позволяет нам подключиться к Bluetooth-модулю Raspberry Pi Pico. Как только Pico получает ввод, определенный код может быть запущен или остановлен. Это позволяет нам запускать и останавливать программы по беспроводной связи и без необходимости подключать Pico к компьютеру каждый раз, когда нам нужно запустить фрагмент кода.

Значения M1 и M2 должны быть установлены в шестнадцатеричном формате в 31 и 32, чтобы запустить и остановить лабиринт соответственно.

Теперь наконец настало время реализовать на практике алгоритмы, которые мы обсуждали в первой части этой статьи. Для начала нам нужно написать код полного перебора, адаптированный к аппаратному обеспечению Raspberry Pi Pico. В этом случае нам не нужно беспокоиться об использовании датчика цвета, поскольку мы просто используем уже инициализированный 2D-массив в качестве нашего лабиринта. Используя шаги, которые программа возвращает для решения лабиринта (например, влево, вправо, вверх или вниз), мы можем заставить робота двигаться в этих направлениях. Адаптированный код можно посмотреть здесь.

Используя реализованный алгоритм поиска решения лабиринта методом полного перебора (ссылка выше), робот может пройти предварительно указанный ей лабиринт. Если немного подкорректировать размеры лабиринта и ввести в код в виде двумерного массива под именем maze, то алгоритм может быть приспособлен для решения любого лабиринта. Стены/границы представлены в массиве как 1, а открытое пространство, через которое робот может пройти, представлено как 0:

Если бы это был реальный лабиринт, он выглядел бы следующим образом (начиная с левого верхнего индекса):

Поскольку Raspberry Pi Pico и его код теперь настроены, мы можем воссоздать лабиринт в реальной жизни, чтобы наш робот мог его пройти. Обратите внимание, что для bruteforcesolverRPIPICO.py, может потребоваться корректировка переменных speed и runtime в соответствии с масштабом и размерами лабиринта. Значение по умолчанию:

runtime = 2 #seconds

rotate_pause = 2 #seconds

speed = 175

Теперь робот может успешно пройти описанный выше лабиринт. Однако мы должны воссоздать лабиринт в реальной жизни. В этом случае для строительства стен лабиринта я буду использовать блоки пенопласта.

Пример робота, проходящего через лабиринт, можно найти в видео на YouTube:

В результате мы:

  1. Собрали робота

  2. Установили необходимое программное обеспечение (MicroPython и Thonny)

  3. Убедились, что MicroPython установлен на Raspberry Pi

  4. Настроили и запустили скрипт, не забыв взглянуть на уже готовое приложение под iOS.

  5. Построили лабиринт и запустите скрипт с помощью Thonny, подключив робота к ПК

Реальные сценарии

Способность проходить лабиринты с помощью робота захватывает, но может быть трудно реализовать альтернативные применения этих алгоритмов в реальной жизни. Поиск решения лабиринта — это упрощенная версия задачи навигации, поэтому реализация алгоритмов навигации зиждется на тех же концепциях, что и поиск решения лабиринта.

На примере ниже мы можем увидеть работу навигации Google Maps, где для построения пути от Mcdonald’s до Walgreens Google тоже использует алгоритм для определения самого быстрого маршрута. Дороги в этом случае эквивалентны проходам в лабиринте (индексы со значениями 0 в массиве лабиринта). Google также пытается найти самый быстрый маршрут — обычно маршрут с наименьшим количеством поворотов и совокупным расстоянием.

Кроме того, в 2022 году Uber начал эксперименты с доставкой еды при помощи роботов; используя технологию поиска пути и GPS, холодильник на колесах будет доставлять покупателям их еду.

Дело в том, что аналогичные процессы и алгоритмы, используемые для нахождения решений лабиринтов, используются в интересных и очень полезных реальных приложениях. Не следует рассматривать концепцию поиска решения лабиринтов как изолированную область с немногими полезными применениями в реальной жизни, это не так.

Заключение

Роботы, способные находить путь в лабиринтах, — это концепция, которую многие могут счесть неинтересной или не столь ценной для общества. Однако для реализации этого проекта потребовалось всего около сотни строк кода. Кроме того, если бы алгоритм решения лабиринта полным перебором был адаптирован для использования датчика света или цвета, робот мог бы пройти любой лабиринт, не требуя предварительной информации о его размерах и конфигурации стен. Если бы у меня было больше времени, я бы обязательно реализовала эту идею в данном проекте. В ходе этого исследования я погрузилась в новую область удивительного мира алгоритмов, временной сложности и эффективности программ. Этот проект позволил мне в комфортном для меня темпе открыть для себя ранее неизведанную область исследований и ее связь с системами GPS и множеством перспективных идей и стартапов. В мире, которым вполне вероятно скоро будет править ИИ и сложные роботы, я чувствую себя немного спокойнее, зная, что получила важный опыт, экспериментируя с основами робототехники и алгоритмов.

Полезные ссылки:

  1. “A Brief History of Mazes: National Building Museum.” National Building Museum |, 17 Mar. 2017, https://www.nbm.org/brief-history-mazes/.

  2. “Simple Maze Solution (Brute Force, Depth First), Python.” Gist, https://gist.github.com/Chuwiey/1e34ed9e65d41b735d8c.

  3. mikeburnfire. “Trémaux’s Algorithm.” YouTube, YouTube, 10 July 2020, https://www.youtube.com/watch?v=RjWSlz-aEr8.

  4. Maze Solution #3 — Tremaux’s Algorithm: V19FA Intro to Computer Science (CIS-1100-VU01). https://vsc.instructure.com/courses/6476/modules/items/713459.

  5. Anubabajide. “Anubabajide/Maze-Runner: Autonomous Maze Navigation Robot Using a Raspberry Pi.” GitHub, https://github.com/anubabajide/Maze-Runner.

  6. Genetic Algorithm for Maze Solving Bot — GitHub Pages. https://shepai.github.io/downloads/GeneticAlgorithmVsBrute_by_Dexter_Shepherd.pdf.

  7. “Trémaux’s Algorithm” Sample Maze Solved [1] — Researchgate. https://www.researchgate.net/figure/Tremauxs-algorithm-sample-maze-solved-1_fig8_315969093.

  8. Cedars-Sinai Medical Center. “With Smiles and Beeps.” Robots Help Nurses Get the Job Done, Cedars-Sinai Medical Center, 26 Nov. 2021, https://www.cedars-sinai.org/newsroom/robots-help-nurses-get-the-job-donewith-smiles-and-beeps/.

  9. “Raspberry Pi Documentation.” MicroPython, https://www.raspberrypi.com/documentation/microcontrollers/micropython.html.

  10. McFarland, Matt. “Uber to Test Delivering Food with Robots.” CNN, Cable News Network, 13 May 2022, https://www.cnn.com/2022/05/13/cars/uber-robot-delivery-la

  11. Jarednielsen. “Big O Factorial Time Complexity.” Jarednielsencom RSS, https://jarednielsen.com/big-o-factorial-time-complexity/.


Сегодня вечером состоится открытый вебинар на тему «Создание ассоциативного массива», на котором мы начнем реализовывать популярную структуру данных «ассоциативный массив» для хранения пар (ключ, значение). Рассмотрим несколько алгоритмов для решения этой задачи и сравним их эффективность:

1. Параллельные массивы;
2. Отсортированные массивы;
3. Двоичные деревья поиска.

Регистрация для всех желающих доступна по ссылке.

From Wikipedia, the free encyclopedia

A maze-solving algorithm is an automated method for solving a maze. The random mouse, wall follower, Pledge, and Trémaux’s algorithms are designed to be used inside the maze by a traveler with no prior knowledge of the maze, whereas the dead-end filling and shortest path algorithms are designed to be used by a person or computer program that can see the whole maze at once.

Mazes containing no loops are known as «simply connected», or «perfect» mazes, and are equivalent to a tree in graph theory. Maze-solving algorithms are closely related to graph theory. Intuitively, if one pulled and stretched out the paths in the maze in the proper way, the result could be made to resemble a tree.[1]

Random mouse algorithm[edit]

This is a trivial method that can be implemented by a very unintelligent robot or perhaps a mouse. It is simply to proceed following the current passage until a junction is reached, and then to make a random decision about the next direction to follow. Although such a method would always eventually find the right solution, this algorithm can be extremely slow.

Wall follower[edit]

Traversal using right-hand rule

The best-known rule for traversing mazes is the wall follower, also known as either the left-hand rule or the right-hand rule. If the maze is simply connected, that is, all its walls are connected together or to the maze’s outer boundary, then by keeping one hand in contact with one wall of the maze the solver is guaranteed not to get lost and will reach a different exit if there is one; otherwise, the algorithm will return to the entrance having traversed every corridor next to that connected section of walls at least once. The algorithm is a depth-first in-order tree traversal.

Another perspective into why wall following works is topological. If the walls are connected, then they may be deformed into a loop or circle.[2] Then wall following reduces to walking around a circle from start to finish. To further this idea, notice that by grouping together connected components of the maze walls, the boundaries between these are precisely the solutions, even if there is more than one solution (see figures on the right).

If the maze is not simply-connected (i.e. if the start or endpoints are in the center of the structure surrounded by passage loops, or the pathways cross over and under each other and such parts of the solution path are surrounded by passage loops), this method will not necessarily reach the goal.

Another concern is that care should be taken to begin wall-following at the entrance to the maze. If the maze is not simply-connected and one begins wall-following at an arbitrary point inside the maze, one could find themselves trapped along a separate wall that loops around on itself and containing no entrances or exits. Should it be the case that wall-following begins late, attempt to mark the position in which wall-following began. Because wall-following will always lead you back to where you started, if you come across your starting point a second time, you can conclude the maze is not simply-connected, and you should switch to an alternative wall not yet followed. See the Pledge Algorithm, below, for an alternative methodology.

Wall-following can be done in 3D or higher-dimensional mazes if its higher-dimensional passages can be projected onto the 2D plane in a deterministic manner. For example, if in a 3D maze «up» passages can be assumed to lead Northwest, and «down» passages can be assumed to lead southeast, then standard wall following rules can apply. However, unlike in 2D, this requires that the current orientation is known, to determine which direction is the first on the left or right.

Pledge algorithm[edit]

Left: Left-turn solver trapped
Right: Pledge algorithm solution

Disjoint (where walls are not connected to the outer boundary/boundary is not closed) mazes can be solved with the wall follower method, so long as the entrance and exit to the maze are on the outer walls of the maze. If however, the solver starts inside the maze, it might be on a section disjoint from the exit, and wall followers will continually go around their ring. The Pledge algorithm (named after John Pledge of Exeter) can solve this problem.[3][4]

The Pledge algorithm, designed to circumvent obstacles, requires an arbitrarily chosen direction to go toward, which will be preferential. When an obstacle is met, one hand (say the right hand) is kept along the obstacle while the angles turned are counted (clockwise turn is positive, counter-clockwise turn is negative). When the solver is facing the original preferential direction again, and the angular sum of the turns made is 0, the solver leaves the obstacle and continues moving in its original direction.

The hand is removed from the wall only when both «sum of turns made» and «current heading» are at zero. This allows the algorithm to avoid traps shaped like an upper case letter «G». Assuming the algorithm turns left at the first wall, one gets turned around a full 360 degrees by the walls. An algorithm that only keeps track of «current heading» leads into an infinite loop as it leaves the lower rightmost wall heading left and runs into the curved section on the left hand side again. The Pledge algorithm does not leave the rightmost wall due to the «sum of turns made» not being zero at that point (note 360 degrees is not equal to 0 degrees). It follows the wall all the way around, finally leaving it heading left outside and just underneath the letter shape.

This algorithm allows a person with a compass to find their way from any point inside to an outer exit of any finite two-dimensional maze, regardless of the initial position of the solver. However, this algorithm will not work in doing the reverse, namely finding the way from an entrance on the outside of a maze to some end goal within it.

Trémaux’s algorithm[edit]

Trémaux’s algorithm. The large green dot shows the current position, the small blue dots show single marks on entrances, and the red crosses show double marks. Once the exit is found, the route is traced through the singly-marked entrances.

Note that two marks are placed simultaneously each time the green dot arrives at a junction. This is a quirk of the illustration; each mark should in actuality be placed whenever the green dot passes through the location of the mark.

Trémaux’s algorithm, invented by Charles Pierre Trémaux,[5] is an efficient method to find the way out of a maze that requires drawing lines on the floor to mark a path, and is guaranteed to work for all mazes that have well-defined passages,[6] but it is not guaranteed to find the shortest route.

An entrance of a passage is either unvisited, marked once or marked twice. Note that marking an entrance is not the same as marking a junction or a passage, because a junction may have multiple entrances, and a passage has an entrance at both ends. Dead ends can be thought of as junctions with one entrance (imagine there being a room at each dead end).

The algorithm works according to the following rules:

  • Whenever you pass through an entrance of a passage, whether it is to enter or exit a junction, leave a mark at the entrance as you pass.
  • When you are at a junction, use the first applicable rule below to pick an entrance to exit through:
    1. If only the entrance you just came from is marked, pick an arbitrary unmarked entrance, if any. This rule also applies if you’re just starting in the middle of the maze and there are no marked entrances at all.
    2. Pick the entrance you just came from, unless it is marked twice. This rule will apply whenever you reach a dead end.
    3. Pick any entrance with the fewest marks (zero if possible, else one).

The «turn around and return» rule effectively transforms any maze with loops into a simply connected one; whenever we find a path that would close a loop, we treat it as a dead end and return. Without this rule, it is possible to cut off one’s access to still-unexplored parts of a maze if, instead of turning back, we arbitrarily pick another entrance.

When you finally reach the solution, entrances marked exactly once will indicate a way back to the start. If there is no exit, this method will take you back to the start where all entrances are marked twice.
In this case each passage is walked down exactly twice, once in each direction. The resulting walk is called a bidirectional double-tracing.[7]

Essentially, this algorithm, which was discovered in the 19th century, has been used about a hundred years later as depth-first search.[8][9]

Dead-end filling[edit]

External video
video icon Maze Strategy: Dead End Filling
video icon Maze Solving algorithm

Dead-end filling is an algorithm for solving mazes that fills all dead ends, leaving only the correct ways unfilled. It can be used for solving mazes on paper or with a computer program, but it is not useful to a person inside an unknown maze since this method looks at the entire maze at once. The method is to

  1. find all of the dead-ends in the maze, and then
  2. «fill in» the path from each dead-end until the first junction is met.

Note that some passages won’t become parts of dead end passages until other dead ends are removed first. A video of dead-end filling in action can be seen to the right.

Dead-end filling cannot accidentally «cut off» the start from the finish since each step of the process preserves the topology of the maze. Furthermore, the process won’t stop «too soon» since the result cannot contain any dead-ends. Thus if dead-end filling is done on a perfect maze (maze with no loops), then only the solution will remain. If it is done on a partially braid maze (maze with some loops), then every possible solution will remain but nothing more. [1]

Recursive algorithm[edit]

If given an omniscient view of the maze, a simple recursive algorithm can tell one how to get to the end. The algorithm will be given a starting X and Y value. If the X and Y values are not on a wall, the method will call itself with all adjacent X and Y values, making sure that it did not already use those X and Y values before. If the X and Y values are those of the end location, it will save all the previous instances of the method as the correct path.

This is in effect a depth-first search expressed in term of grid points. The omniscient view prevents entering loops by memorization. Here is a sample code in Java:

boolean[][] maze = new boolean[width][height]; // The maze
boolean[][] wasHere = new boolean[width][height];
boolean[][] correctPath = new boolean[width][height]; // The solution to the maze
int startX, startY; // Starting X and Y values of maze
int endX, endY;     // Ending X and Y values of maze

public void solveMaze() {
    maze = generateMaze(); // Create Maze (false = path, true = wall)

    // Below assignment to false is redundant as Java assigns array elements to false by default 
    for (int row = 0; row < maze.length; row++)  
        // Sets boolean Arrays to default values
        for (int col = 0; col < maze[row].length; col++){
            wasHere[row][col] = false;
            correctPath[row][col] = false;
        }
    boolean b = recursiveSolve(startX, startY);
    // Will leave you with a boolean array (correctPath) 
    // with the path indicated by true values.
    // If b is false, there is no solution to the maze
}
public boolean recursiveSolve(int x, int y) {
    if (x == endX && y == endY) return true; // If you reached the end
    if (maze[x][y] || wasHere[x][y]) return false;
    // If you are on a wall or already were here
    wasHere[x][y] = true;
    if (x != 0) // Checks if not on left edge
        if (recursiveSolve(x-1, y)) { // Recalls method one to the left
            correctPath[x][y] = true; // Sets that path value to true;
            return true;
        }
    if (x != width - 1) // Checks if not on right edge
        if (recursiveSolve(x+1, y)) { // Recalls method one to the right
            correctPath[x][y] = true;
            return true;
        }
    if (y != 0)  // Checks if not on top edge
        if (recursiveSolve(x, y-1)) { // Recalls method one up
            correctPath[x][y] = true;
            return true;
        }
    if (y != height - 1) // Checks if not on bottom edge
        if (recursiveSolve(x, y+1)) { // Recalls method one down
            correctPath[x][y] = true;
            return true;
        }
    return false;
}

Maze-routing algorithm[edit]

The maze-routing algorithm [10] is a low overhead method to find the way between any two locations of the maze. The algorithm is initially proposed for chip multiprocessors (CMPs) domain and guarantees to work for any grid-based maze. In addition to finding paths between two locations of the grid (maze), the algorithm can detect when there is no path between the source and destination. Also, the algorithm is to be used by an inside traveler with no prior knowledge of the maze with fixed memory complexity regardless of the maze size; requiring 4 variables in total for finding the path and detecting the unreachable locations. Nevertheless, the algorithm is not to find the shortest path.

Maze-routing algorithm uses the notion of Manhattan distance (MD) and relies on the property of grids that the MD increments/decrements exactly by 1 when moving from one location to any 4 neighboring locations. Here is the pseudocode without the capability to detect unreachable locations.

Point src, dst;// Source and destination coordinates
// cur also indicates the coordinates of the current location
int MD_best = MD(src, dst);// It stores the closest MD we ever had to dst
// A productive path is the one that makes our MD to dst smaller
while (cur != dst) {
    if (there exists a productive path) {
        Take the productive path;
    } else {
        MD_best = MD(cur, dst);
        Imagine a line between cur and dst;
        Take the first path in the left/right of the line; // The left/right selection affects the following hand rule
        while (MD(cur, dst) != MD_best || there does not exist a productive path) {
            Follow the right-hand/left-hand rule; // The opposite of the selected side of the line
    }
}

Shortest path algorithm[edit]

A maze with many solutions and no dead-ends, where it may be useful to find the shortest path

When a maze has multiple solutions, the solver may want to find the shortest path from start to finish. There are several algorithms to find shortest paths, most of them coming from graph theory. One such algorithm finds the shortest path by implementing a breadth-first search, while another, the A* algorithm, uses a heuristic technique. The breadth-first search algorithm uses a queue to visit cells in increasing distance order from the start until the finish is reached. Each visited cell needs to keep track of its distance from the start or which adjacent cell nearer to the start caused it to be added to the queue. When the finish location is found, follow the path of cells backwards to the start, which is the shortest path. The breadth-first search in its simplest form has its limitations, like finding the shortest path in weighted graphs.

See also[edit]

  • Mazes
  • Maze generation algorithm

References[edit]

  1. ^ Maze to Tree on YouTube
  2. ^ Maze Transformed on YouTube
  3. ^ Abelson; diSessa (1980), Turtle Geometry: the computer as a medium for exploring mathematics, ISBN 9780262510370
  4. ^ Seymour Papert, «Uses of Technology to Enhance Education», MIT Artificial Intelligence Memo No. 298, June 1973
  5. ^ Public conference, December 2, 2010 – by professor Jean Pelletier-Thibert in Academie de Macon (Burgundy – France) – (Abstract published in the Annals academic, March 2011 – ISSN 0980-6032)
    Charles Tremaux (° 1859 – † 1882) Ecole Polytechnique of Paris (X:1876), French engineer of the telegraph
  6. ^ Édouard Lucas: Récréations Mathématiques Volume I, 1882.
  7. ^ H. Fleischner: Eulerian Graphs and related Topics. In: Annals of Discrete Mathematics No. 50 Part 1 Volume 2, 1991, page X20.
  8. ^ Even, Shimon (2011), Graph Algorithms (2nd ed.), Cambridge University Press, pp. 46–48, ISBN 978-0-521-73653-4.
  9. ^ Sedgewick, Robert (2002), Algorithms in C++: Graph Algorithms (3rd ed.), Pearson Education, ISBN 978-0-201-36118-6.
  10. ^ Fattah, Mohammad; et, al. (2015-09-28). «A Low-Overhead, Fully-Distributed, Guaranteed-Delivery Routing Algorithm for Faulty Network-on-Chips». NOCS ’15 Proceedings of the 9th International Symposium on Networks-on-Chip. Nocs ’15: 1–8. doi:10.1145/2786572.2786591. ISBN 9781450333962. S2CID 17741498.

External links[edit]

  • Think Labyrinth: Maze algorithms (details on these and other maze-solving algorithms)
  • MazeBlog: Solving mazes using image analysis
  • Video: Maze solving simulation
  • Simon Ayrinhac, Electric current solves mazes, © 2014 IOP Publishing Ltd.

Maze

Любите ли вы проходить лабиринты так же как и я? В моём детстве я очень любил рисовать ручкой путь из лабиринтов, которые публиковали в журналах «Мурзилка», но всегда сталкивался с тем, что я заходил в тупики. И уже тогда я задумывался о том, что возможно есть какой-то универсальный способ нахождения выхода из лабиринта, чтобы не блуждать по одним и тем же закуткам по много-много раз. А есть ли способ найти кратчайший путь? Давайте попробуем разобраться.

Конечный вид приложения

Эта статья поможет разобраться с алгоритмом Эйлера для генерации односвязных лабиринтов и алгоритмом поиска кратчайшего пути из лабиринта. Реализация хоть и выполнена на C++ в контексте OpenFrameworks, но думаю не составит труда адаптировать её под любой объектно-ориентированный язык программирования.

Для лучшего усвоения желательно знание основ алгоритмов и структур данных, а также минимального понимания принципов наследования классов.

Содержание

  1. Алгоритм Эйлера для генерации лабиринтов
    1. Алгоритм
    2. Реализация на C++
  2. Визуализация лабиринта и алгоритма поиска кратчайшего пути на openFrameworks
    1. Визуализация лабиринта
    2. Алгоритм поиска кратчайшего пути
    3. Реализация алгоритма поиска кратчайшего пути на C++
    4. Визуализация алгоритма поиска кратчайшего пути на OF
  3. Заключение
  4. Используемая литература

1. Алгоритм Эйлера для генерации лабиринтов

Для генерации лабиринта воспользуемся алгоритмом Эйлера для построения односвязного лабиринта. Термин «односвязный лабиринт» хорошо раскрывает в своей статье Джирл Уолкер:

Этот термин означает, что лабиринт не содержит замкнутых маршрутов, т.е. таких, которые образуют замкнутую петлю. Замкнутый маршрут возникает в том случае, если существует ограниченный стенками «остров», который не соединяется с другими стенками лабиринта. Лабиринт с одним или более островами называется многосвязным. [2]

1.1 Алгоритм

Примечание: условимся, что все левые ячейки лабиринта имеют левую стену, а все правые ячейки имеют правую стену.

  1. Создайте первую строку лабиринта. Ни одна ячейка не будет принадлежать какому-либо множеству.
  2. Присвоить каждой ячейке, которая не входит ни в одно множество, своё уникальное множество.
  3. Создайте правые стены для ячеек, двигаясь слева направо, следующим образом:
    • Случайным образом решите, добавлять стену или нет
      • Если текущая ячейка и ячейка справа являются членами одного и того же множества, всегда создавайте между ними стену (это предотвратит петли)
      • Если вы решите не добавлять стену, то объедините множества, к которым относятся текущая ячейка и ячейка справа
  4. Создайте нижние стены, двигаясь слева направо:
    • Случайным образом решите, добавлять нижнюю стену или нет. Важно: Убедитесь, что каждая область имеет по крайней мере одну ячейку без нижней стены (это предотвратит создание изолированных областей)
      • Если ячейка является единственным членом своего множества, то не создавайте нижнюю стену
      • Если ячейка является единственным членом своего множества, которая не имеет нижней стены, то не создавайте нижнюю стену
  5. Решите, продолжать добавлять строки или остановиться и завершить лабиринт
    • Если вы решите добавить еще одну строку:
      • скопируйте текущую строку
      • удалите в новой строку все правые стены
      • удалите ячейки с нижней стеной из их множества
      • удалите все нижние стены
      • продолжить с шага 2.
    • Если вы решили закончить лабиринт:
      • добавьте нижнюю стену каждой ячейке
      • перемещайтесь слева направо:
        • Если текущая ячейка и ячейка справа являются членами разных множеств, то:
          • удалить правую стену
          • объедините множества, к которым принадлежат текущая ячейка и ячейка справа
          • вывод итоговой строки

При первом прочтении алгоритм может показаться слегка запутанным. Для лучшего понимания разберем пример:

Шаг 1. Создайте первую строку лабиринта

Это будет просто пустая строка:

 ___ ___ ___ ___ ___ ___ ___ ___
|                               |

Шаг 2. Присвоить каждой ячейке, которая не входит ни в одно множество, своё уникальное множество.

 ___ ___ ___ ___ ___ ___ ___ ___
| 1   2   3   4   5   6   7   8 |

Шаг 3. Создайте правые стены для ячеек, двигаясь слева направо

 ___ ___ ___ ___ ___ ___ ___ ___
|(1   2)  3   4   5   6   7   8 |

Если мы решили не добавлять стену, объединим множества

 ___ ___ ___ ___ ___ ___ ___ ___
| 1  (1   3)  4   5   6   7   8 |
 ___ ___ ___ ___ ___ ___ ___ ___
| 1   1  (1   4)  5   6   7   8 |
    ...
 ___ ___ ___ ___ ___ ___ ___ ___
| 1   1   1 | 4   4 | 6   6   6 |

Шаг 4. Создайте нижние стены, двигаясь слева направо

Убедитесь, что каждая область имеет по меньшей мере одну ячейку с нижним проходом (то есть без нижней стенки). Невыполнение этого требования приведет к изолированным областям.

 ___ ___ ___ ___ ___ ___ ___ ___
| 1  _1_ _1_| 4  _4_| 6   6  _6_|

Шаг 5.А Если вы решите добавить еще одну строку

  • скопируйте текущую строку
 ___ ___ ___ ___ ___ ___ ___ ___
| 1  _1_ _1_| 4  _4_| 6   6  _6_|  <- строка закончена и имеет конечный вид
| 1  _1_ _1_| 4  _4_| 6   6  _6_|  <- скопированная строка, становиться текущей
  • удалите в новой строку все правые стены
 ___ ___ ___ ___ ___ ___ ___ ___
| 1  _1_ _1_| 4  _4_| 6   6  _6_|
| 1  _1_ _1_  4  _4_  6   6  _6_|
  • удалите ячейки с нижней стеной из их множества
 ___ ___ ___ ___ ___ ___ ___ ___
| 1  _1_ _1_| 4  _4_| 6   6  _6_|
| 1  ___ ___  4  ___  6   6  ___|
  • удалите все нижние стены
 ___ ___ ___ ___ ___ ___ ___ ___
| 1  _1_ _1_| 4  _4_| 6   6  _6_|
| 1           4       6   6     |
  • продолжить с шага 2.

Шаг 2. Присвоить каждой ячейке, которая не входит ни в одно множество, своё уникальное множество.

 ___ ___ ___ ___ ___ ___ ___ ___
|    ___ ___|    ___|        ___|
| 1   2   3   4   5   6   6   7 |

Продолжая шаг 3. Добавьте правые стены

 ___ ___ ___ ___ ___ ___ ___ ___
|    ___ ___|    ___|        ___|
|(1 | 2)  3   4   5   6   6   7 |  <- Добавлена стена
 ___ ___ ___ ___ ___ ___ ___ ___
|    ___ ___|    ___|        ___|
| 1 |(2   3)  4   5   6   6   7 |  <- Не добавлена стена, множества 2 и 3 объединяются в одно
 ___ ___ ___ ___ ___ ___ ___ ___
|    ___ ___|    ___|        ___|
| 1 | 2  (2   4)  5   6   6   7 |  <- Не добавлена стена, множества 2 и 4 объединяются в одно
 ___ ___ ___ ___ ___ ___ ___ ___
|    ___ ___|    ___|        ___|
| 1 | 2   2  (2 | 5)  6   6   7 |  <- Добавлена стена
 ___ ___ ___ ___ ___ ___ ___ ___
|    ___ ___|    ___|        ___|
| 1 | 2   2   2 |(5 | 6)  6   7 |  <- Добавлена стена

Следующие две ячейки являются членами одного множества, поэтому мы должны добавить стену. Невыполнение этого правила создает петли в нашем лабиринте.

 ___ ___ ___ ___ ___ ___ ___ ___
|    ___ ___|    ___|        ___|
| 1 | 2   2   2 | 5 |(6 | 6)  7 |  <- ДОЛЖНЫ добавить стену
 ___ ___ ___ ___ ___ ___ ___ ___
|    ___ ___|    ___|        ___|
| 1 | 2   2   2 | 5 | 6 |(6   7)|  <- Не добавлена стена, множества 6 и 7 объединяются в одно

Продолжая шаг 4. Добавьте нижние стены

 ___ ___ ___ ___ ___ ___ ___ ___
|    ___ ___|    ___|        ___|
| 1 | 2   2   2 | 5 | 6 | 6   6 |

Помните: по крайней мере одна ячейка из каждого набора должна иметь нижний проход (т.е. не должна иметь нижнюю стенку).

 ___ ___ ___ ___ ___ ___ ___ ___
|    ___ ___|    ___|        ___|
| 1 | 2  _2_ _2_| 5 |_6_| 6  _6_|

Вы можете добавить столько строк, сколько хотите.

 ___ ___ ___ ___ ___ ___ ___ ___
|    ___ ___|    ___|        ___|
|   |    ___ ___|   |___|    ___|
|_1_  1 | 3   3 | 7  _7_ _7_| 8 |

Шаг 5.Б Если вы решили закончить лабиринт

Последняя строка отличается от всех остальных двумя пунктами:

  • Каждая ячейка имеет нижнюю стенку
  • Все ячейки принадлежат одному и тому же множеству

Сделать каждую ячейку одного и того же набора просто. Просто удалите стены между ячейками, которые являются членами разных множеств, пока все ячейки не станут членами одного и того же множества. Не удаляйте стену, если она разделяет две ячейки, которые являются членами одного и того же множества

Начните с создания нормальной строки и добавьте нижнюю стенку в каждую ячейку

 ___ ___ ___ ___ ___ ___ ___ ___
|    ___ ___|    ___|        ___|
|   |    ___ ___|   |___|    ___|
|___    |       |    ___ ___|   |
|_1_ _1_|_3_|_3_|_7_ _7_ _7_|_8_|

Завершающим шагом нам необходимо удалить все стены между ячейками, которые принадлежат разным множествам

 ___ ___ ___ ___ ___ ___ ___ ___
|    ___ ___|    ___|        ___|
|   |    ___ ___|   |___|    ___|
|___    |       |    ___ ___|   |
|_1_ (1_|_3)|_3_|_7_ _7_ _7_|_8_|
 ___ ___ ___ ___ ___ ___ ___ ___
|    ___ ___|    ___|        ___|
|   |    ___ ___|   |___|    ___|
|___    |       |    ___ ___|   |
|_1_ _1_ _1_|(1_|_7) _7_ _7_|_8_|
 ___ ___ ___ ___ ___ ___ ___ ___
|    ___ ___|    ___|        ___|
|   |    ___ ___|   |___|    ___|
|___    |       |    ___ ___|   |
|_1_ _1_ _1_|_1_ _1_ _1_ (1_|_8)|
 ___ ___ ___ ___ ___ ___ ___ ___
|    ___ ___|    ___|        ___|
|   |    ___ ___|   |___|    ___|
|___    |       |    ___ ___|   |
|_1_ _1_ _1_|_1_ _1_ _1_ _1_ _1_|

Теперь у нас должен получиться полноценный односвязный лабиринт. Нет никаких зацикленных областей (чтобы существовал только один путь между любыми двумя ячейками), и никакие области не являются замкнутыми (изолированными от остальной части лабиринта). Вы можете назначить любые две ячейки входом и выходом.

 ___ ___ ___ ___ ___ ___ ___ ___
|    ___ ___|    ___|        ___|
|   |    ___ ___|   |___|    ___|
|___    |       |    ___ ___|   |
|___ ___ ___|___ ___ ___ ___ ___|

Ещё одним достоинством этого алгоритма является то, что можно сгенерировать лабиринт любых размеров.

Также в данном алгоритме возможно изменять внешний вид лабиринта, добавив смещение к генератору случайных чисел, чтобы вероятность появления правой стенки была большей (или меньшей), чем вероятность появления нижней стенки. Например, вы можете создавать лабиринты с более длинными горизонтальными проходами, реже создавая правые стенки и чаще — стены снизу. Но в данном контексте мы оставим алгоритм таким, как он описан изначально и приступим к реализации этого алгоритма на C++.

1.2 Реализация на C++

Для простоты понимания, основной функционал алгоритма мы напишем на C++ без привязки к каким-либо графическим библиотекам или движкам, чтобы этот код можно было легко переиспользовать в любом удобном для вас виде. Графическую оболочку мы добавим далее в этой статье.

Для начала создадим класс, который будет реализовывать этот алгоритм. Результат (наш сгенерированный лабиринт) будем возвращать в виде двумерного динамического массива. Для удобства воспользуемся стандартным контейнером std::vector. Назовем наш класс MazeGenerator. Пусть он будет содержать только один статический метод generate(unsigned width, unsigned height), который будет возвращать указатель shared_ptr на матрицу с лабиринтом, представленную в виде std::vector<std::vector<char>>:

#pragma once
#include <vector>
#include <memory>

class MazeGenerator
{
public:
	static std::shared_ptr<std::vector<std::vector<char>>> generate(unsigned width, unsigned height);

private:
	MazeGenerator() { }
};

Результатом этого метода будет вектор с векторами из символов (матрица из символов), где:

  • символом « «(пробел) является пустая ячейка;
  • символом «#«(решётка) — стена.

Так как одна ячейка массива может быть либо пустой, либо стеной, то для генерации стен справа и снизу мы условимся что ячейка в понятии алгоритма — в массиве будет являться областью 2×2, чем мы упростим (? или усложним) себе задачу по отображению лабиринта в дальнейшем. В области 2×2 каждая ячейка будет иметь свой символ:

  • ячейка в левом верхнем углу области будет отображать значение ячейки из алгоритма (может быть как пустой ячейкой, так и стеной);
  • ячейка в правом верхнем углу — стена справа (может быть как пустой ячейкой, так и стеной);
  • ячейка в левом нижнем углу — стена снизу (может быть как пустой ячейкой, так и стеной);
  • ячейка в правом нижнем углу — всегда будет стеной, или для понимания назовем её опорой.

В связи с оговоренными условиями, сначала инициализируем размеры для векторов:

static std::shared_ptr<std::vector<std::vector<char>>> generate(unsigned width, unsigned height)
{
	// Проверим ограничения параметров на 0
	if ((width < 1) || (height < 1))
		return nullptr;

	auto top_limit = std::numeric_limits<unsigned>::max();
	// Проверим ограничения по максимальному допустимому размеру
	if (((top_limit - 1) / 2 <= width) || ((top_limit - 1) / 2 <= height))
		return nullptr;

	// Инициализируем размер конечной матрицы maze
	// Ячейки будут представлять из себя фрагменты 2x2 + 1 одно значение сверху и слева для стен
	unsigned output_height = height * 2 + 1;
	unsigned output_width = width * 2 + 1;
	// Инициализируем указатель на лабиринт
	auto maze = std::make_shared<std::vector<std::vector<char>>>();
	// Зарезервируем размер лабиринта по высоте
	maze.get()->reserve(output_height);

	// ...

	// вернем указатель на полученный лабиринт
	return maze;
}

После зарезервированного объема, инициализируем наш лабиринт начальными данными. Ограничим лабиринт по периметру стенами. Всю область лабиринта, за исключением верхней строки со стеной и левого столбца со стеной можно условно разделить на области 2×2. Изначально кроме стен по периметру — у каждой ячейки справа и снизу не будет стен. Но в каждой области 2×2 справа снизу установим стену, так как это не функциональная область, которая не рассматривается в алгоритме. У нас она всегда будет «опорой».

// Инициализируем построчно пустой лабиринт со стенами по периметру и "опорами" (стенами) в нижнем правом углу ячеек 2x2
// #######
// #     #
// # # # #
// #     #
// #######
for (unsigned i = 0; i < output_height; ++i)
{
	std::vector<char> row;
	row.reserve(output_width);
	for (unsigned j = 0; j < output_width; ++j)
		// Если этот элемент в строке является ячейкой в левом верхнем угле области 2x2 - то это пустая ячейка в лабиринте
		if ((i % 2 == 1) && (j % 2 == 1))
			row.push_back(' ');
		else
			// Если это область для стены справа или область для стены снизу - то инициализируем этот элемент пустой ячейкой в лабиринте
			if (((i % 2 == 1) && (j % 2 == 0) && (j != 0) && (j != output_width - 1)) ||
				((j % 2 == 1) && (i % 2 == 0) && (i != 0) && (i != output_height - 1)))
				row.push_back(' ');
			else 
				// Во всех остальных случаях устанавливаем стену
				row.push_back('#');
	maze.get()->push_back(std::move(row));
}

Теперь нам надо организовать цикл, к котором будут реализованы все шаги алгоритма. Т.к. высота лабиринта передается параметром, то достаточно использовать цикл for:

//1. Создайте первую строку лабиринта. Ни одна ячейка не будет принадлежать какому - либо множеству.
// Инициализируем вспомогательную строку, которая будет содержать в себе принадлежность ко множеству для ячейки из алгоритма
std::vector<unsigned> row_set;
row_set.reserve(width);
// 0 - будет означать, что ячейка не принадлежит никакому множеству
for (unsigned i = 0; i < width; ++i)
	row_set.push_back(0);
// И инициализируем счетчик для множеств
unsigned set = 1;
// Инициализируем генератор случайных чисел
std::random_device rd;
std::mt19937 mt(rd());
// от 0 до 2 (2 не входит) и после привидения к int будет либо 0 - где стены нет, либо 1 - стену решили установить
std::uniform_int_distribution<int> dist(0, 2);
// Организуем цикл алгоритма Эйлера
for (unsigned i = 0; i < height; ++i)
{			
	//2. Присвоить каждой ячейке, которая не входит ни в одно множество, своё уникальное множество.
	//3. Создайте правые стены для ячеек, двигаясь слева направо, следующим образом :
	//	* Случайным образом решите, добавлять стену или нет
	//		* Если текущая ячейка и ячейка справа являются членами одного и того же множества, всегда создавайте между ними стену(это предотвратит петли)
	//		* Если вы решите не добавлять стену, то объедините множества, к которым относятся текущая ячейка и ячейка справа
	//4. Создайте нижние стены, двигаясь слева направо :
	//	* Случайным образом решите, добавлять нижнюю стену или нет. *Важно : *Убедитесь, что каждая область имеет по крайней мере одну ячейку без нижней стены(это предотвратит создание изолированных областей)
	//		* Если ячейка является единственным членом своего множества, то не создавайте нижнюю стену
	//		* Если ячейка является единственным членом своего множества, которая не имеет нижней стены, то не создавайте нижнюю стену
	//5. Решите, продолжать добавлять строки или остановиться и завершить лабиринт
	//	* Если вы решите добавить еще одну строку :
	//		* скопируйте текущую строку
	//		* удалите в новой строку все правые стены
	//		* удалите ячейки с нижней стеной из их множества
	//		* удалите все нижние стены
	//		* продолжить с шага 2.
}

//	* Если вы решили закончить лабиринт :
//		*добавьте нижнюю стену каждой ячейке
//		* перемещайтесь слева направо :
//			*Если текущая ячейка и ячейка справа являются членами разных множеств, то :
//			*удалить правую стену
//			* объедините множества, к которым принадлежат текущая ячейка и ячейка справа
//			* вывод итоговой строки

Теперь по шагам приступим к реализации:

//2. Присвоить каждой ячейке, которая не входит ни в одно множество, своё уникальное множество.
for (unsigned j = 0; j < width; ++j)
	if (row_set[j] == 0)
		row_set[j] = set++;

На 3ем шаге алгоритма выберем, с помощью генератора случайных чисел, будем мы добавлять стену или нет. Выполняя все условия шага 3 мы получим:

//3. Создайте правые стены для ячеек, двигаясь слева направо, следующим образом:
for (unsigned j = 0; j < width - 1; ++j)
{
    //  * Случайным образом решите, добавлять стену или нет
    const auto right_wall = dist(mt);
    //      * Если текущая ячейка и ячейка справа являются членами одного и того же множества, всегда создавайте между ними стену(это предотвратит петли)
    if ((right_wall == 1) || (row_set[j] == row_set[j + 1]))
        maze.get()->at(i * 2 + 1/*верхний ряд в i-ом ряду ячеек 2x2*/).at(j * 2 + 2/*Правый столбец в (i;j) ячейке 2x2*/) = '#';/*Создаем стену*/               
    else
    {
        //      * Если вы решите не добавлять стену, то объедините множества, к которым относятся текущая ячейка и ячейка справа
        const auto changing_set = row_set[j + 1];
        for (unsigned l = 0; l < width; ++l)
            if (row_set[l] == changing_set)
                row_set[l] = row_set[j];
    }
}

Аналогичным образом строим нижние стены:

//4. Создайте нижние стены, двигаясь слева направо:
for (unsigned j = 0; j < width; ++j)
{
    //  * Случайным образом решите, добавлять нижнюю стену или нет. 
    const auto bottom_wall = dist(mt);
    //      * Если ячейка является единственным членом своего множества, то не создавайте нижнюю стену
    unsigned int count_current_set = 0;
    for (unsigned l = 0; l < width; ++l)
        // считаем количество ячеек текущего множества
        if (row_set[j] == row_set[l])
            count_current_set++;
    //      * Если ячейка является единственным членом своего множества, которая не имеет нижней стены, то не создавайте нижнюю стену
    if ((bottom_wall == 1) && (count_current_set != 1))
        maze.get()->at(i * 2 + 2).at(j * 2 + 1) = '#';
}

Не забудем и про предупреждение в алгоритме. Необходимо убедиться, что, хотя бы одна ячейка из множества должна быть без нижней стены, чтобы предотвратить создание изолированных областей:

//*Важно: *Убедитесь, что каждая область имеет по крайней мере одну ячейку без нижней стены(это предотвратит создание изолированных областей)
// Только если это не последняя строка
if (i != height - 1)
{
    for (unsigned j = 0; j < width; ++j) {
        unsigned count_hole = 0;
        for (unsigned l = 0; l < width; ++l)
            if ((row_set[l] == row_set[j]) && (maze.get()->at(i * 2 + 2).at(l * 2 + 1) == ' '))
                count_hole++;
        if (count_hole == 0)
            maze.get()->at(i * 2 + 2).at(j * 2 + 1) = ' ';
    }
}

На 5ом шаге, если мы еще не дошли до последней строки лабиринта, то производим необходимые манипуляции с вектором множеств:

//5. Решите, продолжать добавлять строки или остановиться и завершить лабиринт
//  * Если вы решите добавить еще одну строку :
if (i != height - 1)
{
    //      * скопируйте текущую строку
    //      * удалите в новой строку все правые стены
    /// Правые стенки в инициализированном массиве у нас уже отсутствуют в каждой новой строке
    //      * удалите ячейки с нижней стеной из их множества
    for (unsigned j = 0; j < width; ++j)
        if (maze.get()->at(i * 2 + 2/* Проверим наличие нижней стены у текущего ряда*/).at(j * 2 + 1) == '#')
            // Если стенка есть, то удаляем ячейку из множества
            row_set[j] = 0;
    //      * удалите все нижние стены
    /// Нижние стены в каждом новом ряду ячеек отсутствуют (заложено при инициализации)
}
//      * продолжить с шага 2.

Цикл заканчивается и остался последний шаг 5.Б по завершению лабиринта. На данном этапе структура метода, примерно следующая:

static std::shared_ptr<std::vector<std::vector<char>>> generate(unsigned width, unsigned height)
{
    // Проверки
    // ...

    // Инициализация
    // ...
    
    //1. Создайте первую строку лабиринта. Ни одна ячейка не будет принадлежать какому - либо множеству.
    // ... реализация шага 1 ...

    // Организуем цикл алгоритма Эйлера
    for (unsigned i = 0; i < height; ++i)
    {           
        //2. Присвоить каждой ячейке, которая не входит ни в одно множество, своё уникальное множество.
        // ... реализация шага 2 ...

        //3. Создайте правые стены для ячеек, двигаясь слева направо, следующим образом:
        // ... реализация шага 3 ...

        //4. Создайте нижние стены, двигаясь слева направо:
        // ... реализация шага 4 ...

        //5. Решите, продолжать добавлять строки или остановиться и завершить лабиринт
        // ... реализация шага 5.A
        //      * продолжить с шага 2.
    }

    // TODO: Осталось добавить реализацию завершения лабиринта.

    //  * Если вы решили закончить лабиринт :
    //      *добавьте нижнюю стену каждой ячейке
    //      * перемещайтесь слева направо:
    //          *Если текущая ячейка и ячейка справа являются членами разных множеств, то:
    //          *удалить правую стену
    //          * объедините множества, к которым принадлежат текущая ячейка и ячейка справа
    //          * вывод итоговой строки

    // вернем указатель на полученный лабиринт
    return maze;
}

Завершение алгоритма будет выглядеть следующим образом:

//	5.Б Если вы решили закончить лабиринт:
//		*добавьте нижнюю стену каждой ячейке
/// Нижняя стена построена при инициализации лабиринта
//		* перемещайтесь слева направо:
for (unsigned int j = 0; j < width - 1; ++j)
{
	//			*Если текущая ячейка и ячейка справа являются членами разных множеств, то :
	if (row_set[j] != row_set[j + 1])
		//			*удалить правую стену
		maze.get()->at(output_height - 2).at(j * 2 + 2) = ' ';
	//			* объедините множества, к которым принадлежат текущая ячейка и ячейка справа
		/// Это делать не обязательно, так как row_set мы больше не будем использовать,
		/// а все множества в конечном итоге станут одним, после удаления стен
	//			* вывод итоговой строки
}

Теперь, чтобы проверить работоспособность алгоритма, можем добавить метод в этот же класс для вывода сгенерированного лабиринта в консоль. На вход метод будет принимать указатель на лабиринт и построчно выводить его в консоль:

#pragma once
#include <vector>
#include <memory>
#include <random>

class MazeGenerator
{
public:
    static std::shared_ptr<std::vector<std::vector<char>>> generate(unsigned width, unsigned height)
    {
        // ... Алгоритм Эйлера для генерации лабиринта ...
    }

    static void print(const std::shared_ptr<std::vector<std::vector<char>>>& maze)
    {
        // Проверяем указатель на nullptr
        if (maze == nullptr)
            return;

        // Построчно считываем и выводим в консоль
        for (unsigned i = 0; i < maze.get()->size(); ++i)
        {
            for (unsigned j = 0; j < maze.get()->at(0).size(); ++j)
                std::cout << maze.get()->at(i).at(j);
            std::cout << std::endl;
        }
    }

private:
    MazeGenerator() = default;
};

И проверим как выглядит работоспособность алгоритма вызвав оба эти метода на примере лабиринта 10×5:

#include "MazeGenerator.h"

//========================================================================
int main( ){
    MazeGenerator::print(MazeGenerator::generate(10, 5));
    return 0;
}

А в результате выполнения этой программы вы увидите примерно следующее:

Результат выполнения программы

Вроде выглядит уже неплохо. Замкнутых областей нету, как и нету «островов». Из-за межстрочного интервала в консоли, немного трудновато разглядеть все тонкости. Поэтому давайте реализуем графическую визуализацию.

2. Визуализация лабиринта и алгоритма поиска кратчайшего пути на openFrameworks

Для визуализации лабиринта и алгоритма поиска кратчайшего пути мы воспользуемся очень удобным и простым в освоении Фреймворком openFrameworks, с основами которого вы можете ознакомиться на страничке Learning. Для демонстрации алгоритмов, описанных в этой статье, я постараюсь подробно описать процесс взаимодействия с OF (сокращенно от OpenFramworks).

2.1 Визуализация лабиринта

Для начала необходимо сгенерировать проект на основе OF. Для этого:

  • Скачайте и распакуйте архив с одной из последних версий OF, подходящей для вашей операционной системы и среды разработки, либо клонируйте репозиторий с сайта GitHub. Note: Версия c GitHub требует дополнительной настройки библиотек. Подробная инструкция есть так же в этом репозитории. Но для простоты, советую воспользоваться стабильной версией с офф.сайта.
  • Помимо стандартных аддонов, которые идут в комплекте с OF, так же советую клонировать мой fork для аддона Tweener или скачать его в оригинале с сайта-источника. Но, насколько я помню, оригинал имеет проблемы с компиляцией из по Visual Studio, поэтому рекомендую воспользоваться моим форком. Скачанный аддон положить в папку с openFrameworks и в подпапку addons, чтобы получилось что-то вроде e:ProjectsopenFrameworksaddonsofxTweener. Для установки аддона — этого достаточно. Осталось только включить его в процессе генерации проекта.
  • Для того чтобы сгенерировать новый проект на OF, необходимо запустить генератор проектов, который находиться по пути %OF_PATH%projectGenerator-vsprojectGenerator.exe, конечно если вы используете VisualStudio. В случае использования другой операционной системы и/или другой IDE, подробности по генерации проекта ищите на офф.сайте. После запуска необходимо указать имя проекта (в моем случае это Maze), путь, где будет храниться этот проект, и подключить необходимые аддоны. Забегая вперед, я скажу, что, возможно, нам понадобятся ofxPoco(если такой имеется в наличии), ofxTweener, ofxXmlSettings и ofxGui(для быстрого и простого отображения пользовательского интерфейса). Если каких-то аддонов у вас не хватает, вы можете найти их на офф.сайте, скачать и положить в папку %OF_PATH%addons. После всех манипуляций, нажмите кнопку Generate и проект будет создан и готов к компиляции. Осталось только его открыть в IDE. У меня окно генератора выглядело следующим образом:

Окно генератора проектов

После открытия проекта в VisualStudio, я добавил, уже написанный нами класс с генератором лабиринта, в папку src/functional. После я скомпилировал и запустил проект, чтобы проверить его работоспособность. Компиляция прошла без ошибок, я структура Solution выглядит следующим образом:

Solution structure

Создадим дополнительную папку и фильтр в проекте с именем visualization. Добавим туда новый класс для визуализации лабиринта. Назовем его, например, Maze и оформим его по правилам используемого нами Фреймворка. Содержимое Maze.h:

// Maze.h
#pragma once

#include <vector>
#include <memory>

class Maze
{
public:
    // метод для инициализации начальных значений (аналог конструктора)
    void setup();
    // вызов метода происходит перед прорисовкой каждого кадра (предназначен для расчетов)
    void update();
    // метод для отрисовки кадра и отображения
    void draw();

    // метод, в котором мы будем просчитывать координаты и scale для лабиринта,
    // чтобы на следующем кадре он был отображен корректно по центру экрана
    void show_in_center();

    // Обработчики для событий самого приложения
    void windowResized(int w, int h);
    void keyPressed(int key);
    void keyReleased(int key);
    void mouseDragged(int x, int y, int button);
    void mousePressed(int x, int y, int button);
    void mouseReleased(int x, int y, int button);
    void mouseScrolled(int x, int y, float scrollX, float scrollY);

private:
    // Указатель на сгенерированный лабиринт
    std::shared_ptr<std::vector<std::vector<char>>> maze_;
    // Коэффициент увеличения для корректного отображения на экране
    float scale_ = 0;
    // Смещение всего лабиринта относительно левого верхнего угла экрана
    int pos_x_ = 0;
    int pos_y_ = 0;
    // Последние значения позиции мыши 
    // (понадобятся для того, чтобы в дальнейшем мы могли "двигать" мышкой лабиринт)
    int last_mouse_pos_x_ = 0;
    int last_mouse_pos_y_ = 0;
};

В методе setup() инициализируем лабиринт с помощью ранее написанного генератора. Для этого добавим #include "MazeGenerator.h". Также, для изначального отображения в центре экрана, после генерации лабиринта, вызовем метод show_in_center(), где подсчитаем масштаб и смещение лабиринта относительно текущего размера окна.

// Maze.cpp
#include "Maze.h"
#include "MazeGenerator.h"
#include "ofMesh.h"

void Maze::setup()
{
    // Если ранее лабиринт был уже создан
    if (maze_ != nullptr)
        // то сбрасываем счетчик указателя
        maze_.reset();
    // Генерируем новый лабиринт
    maze_ = MazeGenerator::generate(10, 10);
    // Нарисуем лабиринт в центре экрана
    show_in_center();
}

void Maze::show_in_center()
{
    // Проверяем, создан ли лабиринт
    if (maze_ == nullptr)
        return;
    // Получаем размеры окна
    auto w = ofGetWindowWidth();
    auto h = ofGetWindowHeight();
    // считаем коэффициент соотношения размеров окна к размерам матрицы по горизонтали и вертикали соответственно
    auto k = static_cast<float>(w) / maze_.get()->at(0).size();
    auto kh = static_cast<float>(h) / maze_.get()->size();
    // выбираем коэффициент в зависимости от того, какое соотношение меньше
    k = k < kh ? k : kh;
    // Масштаб возьмем равный 75% от размера экрана, чтобы изображение было не до самых краев
    scale_ = k * 0.75;
    // И сместим к центру в зависимости от масштаба
    pos_x_ = (w - maze_.get()->at(0).size() * scale_) / 2;
    pos_y_ = (h - maze_.get()->size() * scale_) / 2;
}

Для того чтобы нарисовать наш лабиринт, напишем в методе draw() логику отрисовки каждого кадра:

// Maze.cpp
// ...
void Maze::draw()
{
    // Проверяем, создан ли лабиринт
    if (maze_ == nullptr)
        return;
    // Запомним изначальную матрицу трансформирования сцены
    ofPushMatrix();
    // Сделаем сдвиг на подсчитанное смещение
    ofTranslate(pos_x_, pos_y_);
    // Увеличим размер конечного изображения на подсчитанный заранее масштаб
    ofScale(scale_, scale_);
    // Зададим общий фон ячеек прозрачным серо-голубым цветом
    ofSetHexColor(0x777777FF);
    // Нарисуем фон в виде прямоугольника
    ofDrawRectangle(0, 0, maze_.get()->at(0).size(), maze_.get()->size());
    // Зададим черный цвет для стен
    ofSetHexColor(0x000000);
    // И пробежим по массиву с лабиринтом
    for (size_t i = 0; i < maze_.get()->size(); ++i)
        for (size_t j = 0; j < maze_.get()->at(0).size(); j++)
            if (maze_.get()->at(i).at(j) == '#')
                // Отрисовывая при этом стены
                ofDrawRectangle(j, i, 1, 1);
    // Вернем матрицу трансформирования сцены в изначальное состояние
    ofPopMatrix();
}
// ...

Добавим небольшой интерактив для просмотра нашего лабиринта. Добавим перерисовку лабиринта при изменении размеров окна, добавим возможность скролла (приближения/удаления) к лабиринту, добавим возможность перемещать лабиринт мышкой.

// Maze.cpp
// ...

void Maze::windowResized(int w, int h)
{
	show_in_center();
}

void Maze::keyPressed(int key)
{
}

void Maze::keyReleased(int key)
{
}

void Maze::mouseDragged(int x, int y, int button)
{
	// При движении мыши с зажатой кнопкой, смещаем изображение на смещение мыши
	pos_x_ -= last_mouse_pos_x_ - x;
	pos_y_ -= last_mouse_pos_y_ - y;
	// И снова запоминаем расположение мыши
	mousePressed(x, y, button);
}

void Maze::mousePressed(int x, int y, int button)
{
	// Запоминаем расположение мыши при нажатии на кнопку
	last_mouse_pos_x_ = x;
	last_mouse_pos_y_ = y;
}

void Maze::mouseReleased(int x, int y, int button)
{
}

void Maze::mouseScrolled(int x, int y, float scrollX, float scrollY)
{
	// Если скролл отрицательный и уменьшение масштаба будет меньше единицы
	if ((scrollY < 0) && (scale_ * 0.9 <= 1.0))
		// То ничего не делаем
		return;
	// Иначе считаем разницу между позицией мыши и смещением лабиринта и делим на масштаб, чтобы определить смещение без масштаба
	auto deltaX = static_cast<double>(x - pos_x_) / scale_;
	auto deltaY = static_cast<double>(y - pos_y_) / scale_;
	// Масштаб увеличиваем в 10/9 в случае положительного скролла и в 0.9 в случае отрицательного
	scale_ *= scrollY < 0 ? 0.9 : 10.0 / 9.0;
	// Рассчитываем смещение с новым масштабом
	pos_x_ = x - deltaX * scale_;
	pos_y_ = y - deltaY * scale_;
}

// ...

Теперь, чтобы это все скомпилировалось и запустилось, нам необходимо изменить класс ofApp, который и отвечает за отображение окна приложения. В ofApp.h мы добавим приватную переменную типа Maze и в ofApp.cpp пропишем обращение к ней во всех соответствующих методах.

// ofApp.h
#pragma once

#include "ofMain.h"
#include "visualization/Maze.h"

class ofApp : public ofBaseApp{

    public:
        void setup();
        void update();
        void draw();

        void keyPressed(int key);
        void keyReleased(int key);
        void mouseMoved(int x, int y );
        void mouseDragged(int x, int y, int button);
        void mousePressed(int x, int y, int button);
        void mouseReleased(int x, int y, int button);
        void mouseEntered(int x, int y);
        void mouseExited(int x, int y);
        void mouseScrolled(int x, int y, float scrollX, float scrollY);
        void windowResized(int w, int h);
        void dragEvent(ofDragInfo dragInfo);
        void gotMessage(ofMessage msg);

    private:
        Maze maze_;
};

// ofApp.cpp
#include "ofApp.h"
#include "MazeGenerator.h"

//--------------------------------------------------------------
void ofApp::setup(){
    maze_.setup();
}

//--------------------------------------------------------------
void ofApp::update(){
    maze_.update();
}

//--------------------------------------------------------------
void ofApp::draw(){
    // Изменим скучный серый фон на что-нибудь поинтересней
    ofBackgroundGradient(ofColor::azure, ofColor::orange);
    maze_.draw();
}

//--------------------------------------------------------------
void ofApp::keyPressed(int key){
    maze_.keyPressed(key);
}

//--------------------------------------------------------------
void ofApp::keyReleased(int key){
    maze_.keyReleased(key);
}

//--------------------------------------------------------------
void ofApp::mouseMoved(int x, int y ){

}

//--------------------------------------------------------------
void ofApp::mouseDragged(int x, int y, int button){
    maze_.mouseDragged(x, y, button);
}

//--------------------------------------------------------------
void ofApp::mousePressed(int x, int y, int button){
    maze_.mousePressed(x, y, button);
}

//--------------------------------------------------------------
void ofApp::mouseReleased(int x, int y, int button){
    maze_.mouseReleased(x, y, button);
}

//--------------------------------------------------------------
void ofApp::mouseEntered(int x, int y){

}

//--------------------------------------------------------------
void ofApp::mouseExited(int x, int y){

}

//--------------------------------------------------------------
void ofApp::mouseScrolled(int x, int y, float scrollX, float scrollY) {
    maze_.mouseScrolled(x, y, scrollX, scrollY);
}

//--------------------------------------------------------------
void ofApp::windowResized(int w, int h){
    maze_.windowResized(w, h);
}

//--------------------------------------------------------------
void ofApp::gotMessage(ofMessage msg){

}

//--------------------------------------------------------------
void ofApp::dragEvent(ofDragInfo dragInfo){ 

}

Примерно следующую картинку мы получим при запуске приложения:

Первый запуск

Смотрится лабиринт гораздо симпатичней чем в консоли, не правда ли? ;)

А примерно так выглядит приближенное начало сгенерированного лабиринта размером 1000×1000:

1000x1000

Теперь, чтобы лабиринт мы могли генерировать любого размера, давайте вынесем настройки ширины и длины в пользовательский интерфейс. Для созданию своего UI, нам пришлось бы проделывать достаточно большую работу, поэтому воспользуемся доступным нам аддоном ofxGui. Для этого необходимо подключить в заголовке файла ofApp.h доступный файл ofxGui.h и добавить к приватным переменным UI панель, на которой будут располагаться элементы управления: два слайдера, для задания ширины и длинны, и кнопку для генерации лабиринта. A так же нам необходимо добавить три метода, для обработки событий с элементов управления слайдерами и кнопки. Выглядеть это будет следующим образом:

// ofApp.h
#pragma once

#include "ofMain.h"
#include "visualization/Maze.h"
#include "ofxGui.h"

class ofApp : public ofBaseApp {
	// ... Some methodts
		void widthMazeChanged(int &width);
		void heightMazeChanged(int &height);
		void generateMazeButtonClick();		
		// и еще необходим стандартный метод exit, который вызывается при закрытии окна,
		// чтобы мы имели возможность отписаться от событий с элементов управления
		void exit();

	private:
		Maze maze_;

		ofxPanel mazeUiPanel_;
		ofxIntSlider widthMaze_;
		ofxIntSlider heightMaze_;
		ofxButton generateMazeButton_;
};

В файле ofApp.cpp в методе setup() необходимо инициализировать элементы управления и добавить их на панель. В методе draw() нам необходимо отрисовать саму панель, а она уже отрисует все добавленные элементы управления. Причем, если мы хотим, чтобы панель была поверх всего, то и вызывать метод draw() у панели надо в последнюю очередь. В методе exit() мы отписываемся от событий. А в обработчике клика по кнопке, необходимо вызвать метод инициализации для нашего лабиринта с установленными на слайдерах значениями.

// ofApp.cpp
#include "ofApp.h"

//--------------------------------------------------------------
void ofApp::setup(){
	// Подписываемся на события элементов управления
	widthMaze_.addListener(this, &ofApp::widthMazeChanged);
	heightMaze_.addListener(this, &ofApp::heightMazeChanged);
	generateMazeButton_.addListener(this, &ofApp::generateMazeButtonClick);
	// Вызываем метод setup у панели
	mazeUiPanel_.setup();
	// И последовательно добавляем все элементы управления
	mazeUiPanel_.add(
		widthMaze_.setup("Width Maze" /* Подпись */,
			3/* Значение при старте */, 
			2 /* минимальное значение */, 
			100 /* максимальное значение */)
		);
	mazeUiPanel_.add(
		heightMaze_.setup("Height Maze" /* Подпись */, 
			3/* Значение при старте */, 
			2 /* минимальное значение */, 
			100 /* максимальное значение */)
		);
	mazeUiPanel_.add(generateMazeButton_.setup("Generate Maze" /* подпись */));

	maze_.setup();
}

//--------------------------------------------------------------
void ofApp::update(){
	maze_.update();
}

//--------------------------------------------------------------
void ofApp::draw(){
	ofBackgroundGradient(ofColor::azure, ofColor::orange);
	maze_.draw();

	// Рисуем панель поверх всего
	mazeUiPanel_.draw();
}

void ofApp::exit()
{
	// Отписываемся от событий
	heightMaze_.removeListener(this, &ofApp::heightMazeChanged);
	widthMaze_.removeListener(this, &ofApp::widthMazeChanged);
	generateMazeButton_.removeListener(this, &ofApp::generateMazeButtonClick);
}

void ofApp::widthMazeChanged(int& width)
{
}

void ofApp::heightMazeChanged(int& height)
{
}

void ofApp::generateMazeButtonClick()
{
	// Вызываем событие инициализации для лабиринта
	maze_.setup(widthMaze_, heightMaze_);
}

Но так как у нашего, написанного ранее класса Maze, метод setup() не принимает никаких параметров, то нам необходимо их добавить:

// Maze.h
#pragma once

#include <vector>
#include <memory>

class Maze
{
public:
	// метод для инициализации начальных значений (аналог конструктора)
	void setup(int width, int height);
	// ...
}

// Maze.cpp
#include "Maze.h"
#include "MazeGenerator.h"
#include "ofMesh.h"
#include "ofBitmapFont.h"

void Maze::setup(int width, int height)
{
	// Если ранее лабиринт был уже создан
	if (maze_ != nullptr)
		// то сбрасываем счетчик указателя
		maze_.reset();
	// Генерируем новый лабиринт
	maze_ = MazeGenerator::generate(width, height);
	// Нарисуем лабиринт в центре экрана
	show_in_center();
}

// ofApp.cpp
// Ну и в методе ofApp::setup уберем первоначальную инициализацию для лабиринта,
// чтобы он генерировался не при старте приложения, а только по клику кнопки.
#include "ofApp.h"

//--------------------------------------------------------------
void ofApp::setup(){
	// Подписываемся на события элементов управления
	widthMaze_.addListener(this, &ofApp::widthMazeChanged);
	heightMaze_.addListener(this, &ofApp::heightMazeChanged);
	generateMazeButton_.addListener(this, &ofApp::generateMazeButtonClick);
	// Вызываем метод setup у панели
	mazeUiPanel_.setup();
	// И последовательно добавляем все элементы управления
	mazeUiPanel_.add(
		widthMaze_.setup("Width Maze" /* Подпись */,
			3/* Значение при старте */, 
			2 /* минимальное значение */, 
			100 /* максимальное значение */)
		);
	mazeUiPanel_.add(
		heightMaze_.setup("Height Maze" /* Подпись */, 
			3/* Значение при старте */, 
			2 /* минимальное значение */, 
			100 /* максимальное значение */)
		);
	mazeUiPanel_.add(generateMazeButton_.setup("Generate Maze" /* подпись */));

	//maze_.setup();
}

Теперь при запуске приложения мы можем выбирать размеры лабиринта в пределах от 2 до 100 включительно и генерировать его по клику кнопки.

Simple UI

2.2 Алгоритм поиска кратчайшего пути

Для понимания некоторых моментов, советую ознакомиться со статьей Джирла Уолкера «Как пройти через лабиринт не заблудившись». Описанных в этой статье основ достаточно, чтобы понять суть этого несложного алгоритма.

Самое главное, что нужно знать, это то что в односвязном лабиринте к точке выхода ведет всегда только один путь. Еще один момент, который нужно учитывать, что добраться до выхода можно используя «Правило руки»:

…Один из методов состоит в том, чтобы в каждой узловой точке выбирать одно и то же направление. Например, можно всегда сворачивать на крайнюю правую ветвь. Если этот путь закончится тупиком, следует вернуться к узловой точке и выбрать следующую ветвь (если считать справа). Может оказаться, что в результате вы пройдете по каждой ветви дважды — по одному разу в каждом направлении, но в конце концов вы доберётесь до цели. На обратном пути можно либо продолжать выбирать крайние правые ветви в каждом узле (и в этом случае вы, вероятно, пройдёте по новым областям лабиринта), либо каждый раз сворачивать на крайнюю левую ветвь (и тогда вы в точности повторите первоначальный маршрут). Метод выбора одной и той же — правой или левой — ветви я называю соответственно правилом правой или левой руки… [2]

Исходя из этого мы можем «запоминать» уже посещенные ячейки. И если, поблуждав в каком-то тупике, мы возвращаемся на правильный маршрут, мы можем исключить все дважды посещенные точки из списка с посещёнными. Длина этого списка, в конечном итоге, и будет являться длиной кратчайшего маршрута.

Для описания алгоритма введем такие абстрактные понятия как игрок, точка входа и точка выхода.

Игрок — это некоторая сущность, которая будет передвигаться по лабиринту от точки входа до точки выхода. Игрок будет иметь свои координаты нахождения в лабиринте и свое направление движения. Так же, для поиска кратчайшего пути, игроку потребуется какая-то история его передвижений, чтобы определять, был он уже в этой точке лабиринта, или не был.

Точка входа — позиция в лабиринте, с которой начинает свое движение игрок.

Точка выхода — местоположение, достигнув которого, игрок прекращает поиск.

Воспользуемся правилом левой руки.
Условимся, что точка входа не может быть и точкой выхода одновременно. При желании конечно можно реализовать и это, но для простоты оставим это фактом. Опишем алгоритм передвижения игрока от точки входа, до точки выхода:

  1. Зададим начальную позицию и направление игрока.
  2. Запоминаем координаты текущей ячейки, с учетом порядка посещения ячеек
  3. Игрок поворачивается на 90 градусов против часовой стрелки
  4. Игрок проверяет ячейку, которая находиться перед ним:
    1. Если это стена:
      • игрок поворачивается на 90 градусов по часовой стрелке
      • игрок повторяет шаг 4
    2. Если это свободная ячейка:
      • игрок выполняет шаг 5
  5. Игрок переходит на новую свободную ячейку
  6. По истории всех посещенных ранее ячеек, проверяем был игрок в этой новой ячейке или нет:
    1. Если игрок был в этой ячейке:
      • удаляем из истории посещенных ячеек все записи, начиная с последней, до координат текущей ячейки включительно
      • переходим к шагу 7
    2. Если игрок не был ранее в этой ячейке, то сразу переходим к шагу 7
  7. Проверяем, является ли текущая ячейка точкой выхода:
    1. Если она не является точкой выхода, то повторяем с шага 2
    2. Если это точка выхода, то заканчиваем алгоритм.

Список посещенных точек, в порядке их посещения, включая текущую — является кратчайшим путем от точки входа до точки выхода.

Теперь приступим к реализации.

2.3 Реализация алгоритма поиска кратчайшего пути на C++

Для удобства работы с координатами, добавим в папку functional файл Position2D.h в которой опишем структуру для хранения координат.

// Position2D.h
#pragma once

struct Position2D
{
	unsigned X;
	unsigned Y;

	// Переопределим "operator < "
	// Это необходимо для дальнейшего использования этой структуры в качестве ключа для std::map
	bool operator < (const Position2D &p1) const
	{
		if (Y < p1.Y)
			return true;
		if (Y > p1.Y)
			return false;
		if (Y == p1.Y)
			return X < p1.X;
		return false;
	}
};

Еще добавим один файл для описания направления движения, который назовем Direction2D.h:

// Direction2D.h
#pragma once

enum Direction2D
{
	RIGHT,	// Направление НАПРАВО (или НА ВОСТОК)
	DOWN,	// Направление ВНИЗ (или НА ЮГ)
	LEFT,	// Направление ВЛЕВО (или НА ЗАПАД)
	UP,		// Направление ВВЕРХ (или НА СЕВЕР)
};

Добавим в ранее созданный проект в папку functional новый класс, который назовем PathFinder. И добавим в класс поля, которые будут реализовывать описанные выше вспомогательные части для понятия игрок.

// PathFinder.h
#pragma once

#include "Position2D.h"
#include "Direction2D.h"
#include <vector>
#include <map>
#include <memory>

class PathFinder
{
public:
	PathFinder();
	~PathFinder();
	// Метод для инициализации игрока
	void init(Position2D start_position, std::shared_ptr<std::vector<std::vector<char>>> maze);	
	// Метод для следующего шага игрока
	void nextStep();

	// Метод для получения количества посещенных ячеек лабиринта
	unsigned getCountVisitedCells() { return map_.size() + 1; }
	// Метод для получения длинны кратчайшего пути (в процессе поиска пути - он может быть не достоверный)
	unsigned getShortWayLenght() { return short_way_.size() + 1; }
	// Метод, чтобы спросить, добрался ли игрок до точки выхода
	bool isWin() { return is_win_; }

protected:
	bool is_win_;	// Флаг, который отображает, добрался ли игрок до точки выхода

	Position2D current_position_;							// Текущая позиция игрока
	Direction2D current_direction_;							// Текущее направление игрока

	std::map<Position2D, bool> map_;						// Все посещённые ячейки. Абстрактная "карта"
	std::vector<Position2D> short_way_;						// Вектор, который будет хранить координаты только кратчайшего пути
	
	std::shared_ptr<std::vector<std::vector<char>>> maze_;	// Указатель на лабиринт, по которому мы будем передвигаться
};

Давайте так же условимся, что для обозначения точки выхода мы модифицируем наш сгенерированный ранее лабиринт. Поставим точку выхода, например, в правом нижнем углу лабиринта и обозначим её как символ «X«. А так же сразу в методе setup в файле Maze.cpp организуем цикл для проверки алгоритма поиска пути, пусть он даже пока и не реализован, но у нас уже есть интерфейс для работы с ним. Допишем в setup следующие строки:

// Maze.cpp
// #include ...
#include "PathFinder.h"

void Maze::setup(int width, int height)
{
	// Если ранее лабиринт был уже создан
	if (maze_ != nullptr)
		// то сбрасываем счетчик указателя
		maze_.reset();
	// Генерируем новый лабиринт
	maze_ = MazeGenerator::generate(width, height);

	// Добавим в лабиринт "точку выхода" в правый нижний угол учитывая наличие стен
	maze_.get()->at(maze_.get()->size() - 2).at(maze_.get()->at(0).size() - 2) = 'X';

	// Создадим "игрока", который будет искать точку выхода
	PathFinder player;
	// Зададим точку входа в левом верхнем углу лабиринта с учетом стен и передадим ему информацию о сгенерированном лабиринте
	player.init(Position2D{ 1, 1 }, maze_);
	// До тех пор пока игрок не достигнет цели
	while (!player.isWin())
		// игрок будет совершать шаги
		player.nextStep();
	// Выведем в консоль количество совершенных шагов
	std::cout << "Count visited cells: " << player.getCountVisitedCells() << std::endl;
	// И длину кратчайшего пути
	std::cout << "Short way lenght: " << player.getShortWayLenght() << std::endl;

	// Нарисуем лабиринт в центре экрана
	show_in_center();
}

Ещё в этом же файле, для отображения точки выхода, допишем в методе draw отрисовку точки «X«:

void Maze::draw()
{
	// ...
	// И пробещим по массивы с лабиринтом
	for (size_t i = 0; i < maze_.get()->size(); ++i)
		for (size_t j = 0; j < maze_.get()->at(0).size(); j++)
			if (maze_.get()->at(i).at(j) == '#')
				// Отрисовывая при этом стены
				ofDrawRectangle(j, i, 1, 1);
	// Проверим точку выхода
	if (maze_.get()->at(maze_.get()->size() - 2).at(maze_.get()->at(0).size() - 2) == 'X')
	{
		// Зададим Зеленый цвет
		ofSetHexColor(0xFF00FF00);
		// Нарисуем точку выхода
		ofDrawRectRounded(maze_.get()->at(0).size() - 2, maze_.get()->size() - 2, 1, 1, 0.3);
	}

	// Вернем матрицу трансформирования сцены в изначальное состояние
	ofPopMatrix();
}

Приложение пока не будем запускать, так как еще не реализована логика передвижения игрока и в этом методе приложение просто повиснет в бесконечном цикле while. Поэтому давайте теперь реализуем последовательно инициализацию для игрока и логику для совершения следующего шага.

// PathFinder.cpp
#include "PathFinder.h"

PathFinder::PathFinder() { }

PathFinder::~PathFinder() { }

void PathFinder::init(Position2D start_position, std::shared_ptr<std::vector<std::vector<char>>> maze)
{
	// Очищаем историю перемещений 
	map_.clear();
	short_way_.clear();
	// Заполучаем лабиринт для проверки каждого шага
	maze_ = maze;
	// На старте мы еще не нашли точку выхода
	is_win_ = false;
	// 1. Зададим начальную позицию и направление игрока.
	// Стартовая позиция будет задана в крайней ячейке левого верхнего угла лабиринта
	current_position_ = start_position;
	// А начальное направление пусть будет "направо" (на восток)
	current_direction_ = RIGHT;	
}

void PathFinder::nextStep()
{
	// Если мы уже добрались до точки выхода, то не имеет смысла делать дополнительные шаги
	if (is_win_) return;

	// 2. Запоминаем координаты текущей ячейки, с учетом порядка посещения ячеек
	map_[current_position_] = true;
	short_way_.push_back(current_position_);

	// 3. Игрок поворачивается на 90 градусов **против часовой стрелки**
	current_direction_ = current_direction_ == UP ? LEFT : current_direction_ == LEFT ? DOWN : current_direction_ == DOWN ? RIGHT : UP;
	// Инициализируем временную переменную для проверки впереди стоящей ячейки
	Position2D forward_cell;
	do {
		// 4. Игрок проверяет ячейку, которая находиться перед ним :
		forward_cell = current_position_;
		switch (current_direction_) 
		{ 
			case RIGHT: forward_cell.X++; break;
			case DOWN: forward_cell.Y++; break;
			case LEFT: forward_cell.X--; break;
			case UP: forward_cell.Y--; break;
		}
		// 4.1. Если это стена :
			// * игрок поворачивается на 90 градусов **по часовой стрелке**
		current_direction_ = current_direction_ == UP ? RIGHT : current_direction_ == LEFT ? UP : current_direction_ == DOWN ? LEFT : DOWN;
			// * игрок повторяет _шаг 4_
	} while (maze_.get()->at(forward_cell.Y).at(forward_cell.X) == '#');

	// 4.2. Если это свободная ячейка :
		// * игрок выполняет _шаг 5_
	// Восстановим правильное направление
	current_direction_ = current_direction_ == UP ? LEFT : current_direction_ == LEFT ? DOWN : current_direction_ == DOWN ? RIGHT : UP;
	
	// 5. Игрок переходит на новую свободную ячейку
	current_position_ = forward_cell;
	// 6. По истории всех посещенных ранее ячеек, проверяем был игрок в этой новой ячейке или нет :
	auto foundedIter = map_.find(forward_cell);
	// 6.1. Если игрок был в этой ячейке :
	if (foundedIter != map_.end())
	{
		// * удаляем из истории посещенных ячеек все записи, начиная с последней, до координат текущей ячейки 
		if (!short_way_.empty()) {
			while ((!short_way_.empty()) && ((short_way_.back().X != foundedIter->first.X) || (short_way_.back().Y != foundedIter->first.Y)))
			{
				short_way_.erase(--short_way_.end());
			}
			// **включительно**
			if (!short_way_.empty()) {
				short_way_.erase(--short_way_.end());
			}
		}
		// * переходим к _шагу 7_
	}
	// 6.2. Если игрок не был ранее в этой ячейке, то сразу переходим к _шагу 7_
	// 7. Проверяем, является ли текущая ячейка `точкой выхода`:
	// 7.1. Если она не является `точкой выхода`, то повторяем с _шага 2_
	// 7.2. Если это `точка выхода`, то _заканчиваем алгоритм_.	
	is_win_ = maze_.get()->at(current_position_.Y).at(current_position_.X) == 'X';
}

После запуска приложения вы можете проверить на маленьком лабиринте, что алгоритм действительно правильно считает кратчайший путь и количество посещенных ячеек по методу «левой руки», если двигаться от левой верхней ячейки до выхода.

Тест реализации

Но это не наглядно. Давайте реализуем визуализацию для нашего алгоритма.

2.4 Визуализация алгоритма поиска кратчайшего пути на OF

Теперь вспомним немного о возможностях наследования и создадим класс для визуализации алгоритма поиска пути и назовем его, например, ofPathFinder. Пусть он будет храниться в папке visualization и повторять структуру OF. А так же пусть он будет дочерним классом от уже написанного нами PathFinder. Мы добавим ему не только отрисовку, но еще и анимированное плавное перемещение из одной точки в другую. Для этого добавим приватную переменную-флаг, которая будет фиксировать состояние отрисовки, т.е. анимирован он в данный момент или нет и еще один callback метод, который будет срабатывать в конце анимации. Добавим метод void run(), для запуска алгоритма. Изначально игрок будет появляться в начальной точке, а по команде — начинал свой путь. Также нам понадобятся экранные координаты для отрисовки. Ведь, в родительском классе координаты позиции меняются моментально, а в процессе анимации экранные координаты будут меняться постепенно.

// ofPathFinder.h
#pragma once

#include "PathFinder.h"

class ofPathFinder : public PathFinder
{
public:
	void setup(Position2D start_position, std::shared_ptr<std::vector<std::vector<char>>> maze);
	void update();
	void draw();
	void run();

	void endAnimation(float *arg) { is_animated_ = false; }	// Callback функция, которая будет срабатывать, когда анимация заканчивается

private:
	bool is_animated_;	// Флаг для отслеживания состояния, находиться ли объект в процессе анимации (перемещения между ячейками)
	bool is_runed_;		// Флаг для отображения, запущен игрок или нет (в процессе ли он поиска)
	float screen_x_;	// Экранная координата X
	float screen_y_;	// Экранная координата Y
};

Для анимации мы ранее скачали и установили аддон ofxTweener. Сам аддон создает глобальную переменную Tweener, доступ к которой можно получить из любого места в проекте, просто подключив аддон с помощью директивы #include "ofxTweener.h". Чтобы он правильно пересчитывал все значения, на каждом тике update его надо обновлять, но только строго в одном месте, чтобы все анимации пересчитывались правильно. Можно добавить в файл ofApp.cpp следующие строки:

// ofApp.cpp
#include "ofApp.h"
#include "ofxTweener.h"		// аддон для анимаций

// ...

//--------------------------------------------------------------
void ofApp::update(){
	Tweener.update();	// обновление анимаций
	maze_.update();
}

// ...

Также в классе ofPathFinder нам необходимо переопределить метод nextStep(), а для этого в базовом классе добавим в определении метода virtual void nextStep(); и в классе ofPathFinder в разделе public добавим строку void nextStep() override;.

Теперь реализуем методы для класса ofPathFinder. Надеюсь комментариев в коде будет достаточно.

// ofPathFinder.cpp
#include "ofPathFinder.h"
#include "ofxTweener.h"

void ofPathFinder::setup(Position2D start_position, std::shared_ptr<std::vector<std::vector<char>>> maze)
{
	// При первоначальной инициализации удалим все ранее запущенные анимации
	Tweener.removeAllTweens();
	is_animated_ = false;
	is_runed_ = false;
	// Запустим инициализацию базового класса
	init(start_position, maze);
	// Инициализируем начальные экранные координаты
	screen_x_ = current_position_.X;
	screen_y_ = current_position_.Y;
}

void ofPathFinder::update()
{
	// Если не запущен - то ничего не делаем
	if (!is_runed_) return;
	// Если запущен и уже добрался до конца - ничего не делаем
	if (is_win_) return;
	// Если еще не добрался до выхода, запущен и анимирован, то делаем следующий шаг
	if (!is_animated_)
		nextStep();
}

void ofPathFinder::draw()
{
	// Проверяем лабиринт
	if (maze_ == nullptr) return;	
	// Сначала серым закрасим все посещенные ранее ячейки
	ofSetHexColor(0x33555555);
	for (auto& pos : map_)
		ofDrawRectangle(pos.first.X, pos.first.Y, 1, 1);
	// Теперь зеленым отметим кратчайший путь
	ofSetHexColor(0x5500FF00);
	for (auto& pos : short_way_)
		ofDrawRectangle(pos.X, pos.Y, 1, 1);
	// И красным нарисуем самого "игрока"
	ofSetHexColor(0x77FF0000);
	ofDrawRectangle(screen_x_, screen_y_, 1, 1);
}

void ofPathFinder::run()
{
	// Запускаем игрока
	is_runed_ = true;
}

void ofPathFinder::nextStep()
{
	// Запоминаем текущие координты игрока
	screen_x_ = current_position_.X;
	screen_y_ = current_position_.Y;
	// Создаем callback, который будет вызываться при окончании анимации
	auto callback = std::bind(&ofPathFinder::endAnimation, this, std::placeholders::_1);
	// Вызывем базовый метод для выполнения следующего шага
	PathFinder::nextStep();
	// И анимируем перемещение игрока
	if (is_win_)
	{
		Tweener.addTween(screen_x_, static_cast<float>(current_position_.X), /* Длительность анимации */0.1F, &ofxTransitions::linear);
		Tweener.addTween(screen_y_, static_cast<float>(current_position_.Y), /* Длительность анимации */0.1F, &ofxTransitions::linear);
	} else
	{
		Tweener.addTween(screen_x_, static_cast<float>(current_position_.X), /* Длительность анимации */0.1F, &ofxTransitions::linear, callback);
		Tweener.addTween(screen_y_, static_cast<float>(current_position_.Y), /* Длительность анимации */0.1F, &ofxTransitions::linear, callback);
	}
}

Основной функционал прописан для визуализации, осталось только добавить его вызовы в класс Maze. Первым делом удалите часть кода из метода setup(), которая проверяла базовый класс PathFinder и выводила в консоль результат. В Maze.h добавьте объявление закрытой переменной класса ofPathFinder и добавьте публичный метод для запуска void run().

// Maze.h
#pragma once

#include <vector>
#include <memory>
#include "ofPathFinder.h"

class Maze
{
public:
    // ...
	// Метод для запуска игрока
	void run() { player_.run(); }
	// ...
private:
	// ...
	// Игрок
	ofPathFinder player_;
};

В методах setup, update и draw в файле Maze.cpp так же необходимо внести изменения. А именно, в setup вызвать инициализацию игрока, в update — его обновление и в draw — нарисовать игрока и, в случае если он добрался до точки выхода, вывести информацию на экран.

// Maze.cpp
#include "Maze.h"
#include "MazeGenerator.h"
#include "ofMesh.h"
#include "ofBitmapFont.h"

void Maze::setup(int width, int height)
{
	// ...

	// Добавим в лабиринт "точку выхода" в правый нижний угол учитывая наличие стен
	maze_.get()->at(maze_.get()->size() - 2).at(maze_.get()->at(0).size() - 2) = 'X';
	
	// Инициализация игрока
	player_.setup(Position2D{ 1, 1 }, maze_);

	// Нарисуем лабиринт в центре экрана
	show_in_center();
}

void Maze::update()
{
	// Обновление 
	player_.update();
}

void Maze::draw()
{
	// ...

	// Отрисовка игрока (причем она должна быть до того как мы вызовем ofPopMatrix)
	player_.draw();

	// Вернем матрицу трансформирования сцены в изначальное состояние
	ofPopMatrix();

	// А сообщение о конце пути, выводим после ofPopMatrix
	if (player_.isWin())
	{
		std::stringstream reportStr;
		reportStr << "Short way FOUNDED! Need " << player_.getShortWayLenght() << " steps";
		ofDrawBitmapStringHighlight(reportStr.str(), 100, ofGetWindowHeight() - 100, ofColor::orangeRed, ofColor::black);
	}
}
// ...

Теперь только нехватает кнопки, чтобы запустить игрока. Для этого в ofApp.h добавим еще одну переменную ofxButton runPlayerButton_; и метод для обработки события клика по этой кнопке void runPlayerClick();. А в файле ofApp.cpp в методе setup добавим обработчик события для этой кнопки и добавим её на панель. В методе exit отпишемся от этого события. А в методе runPlayerClick пропишем вызов запуска поиска пути.

// ofApp.h
#pragma once

#include // ..

class ofApp : public ofBaseApp{

	public:
		// ...
		void runPlayerClick();
		// ...
	private:
		// ...
		ofxButton runPlayerButton_;
};

// ofApp.cpp
#include "ofApp.h"
#include "ofxTweener.h"		// аддон для анимаций

//--------------------------------------------------------------
void ofApp::setup(){
	// Подписываемся на события элементов управления
	widthMaze_.addListener(this, &ofApp::widthMazeChanged);
	heightMaze_.addListener(this, &ofApp::heightMazeChanged);
	generateMazeButton_.addListener(this, &ofApp::generateMazeButtonClick);
	runPlayerButton_.addListener(this, &ofApp::runPlayerClick);
	// Вызываем метод setup у панели
	mazeUiPanel_.setup();
	// И последовательно добавляем все элементы управления
	mazeUiPanel_.add(widthMaze_.setup("Width Maze", 3, 2, 100));
	mazeUiPanel_.add(heightMaze_.setup("Height Maze", 3, 2, 100));
	mazeUiPanel_.add(generateMazeButton_.setup("Generate Maze"));
	mazeUiPanel_.add(runPlayerButton_.setup("Find short way"));
}
// ...
void ofApp::exit()
{
	// Отписываемся от событий
	heightMaze_.removeListener(this, &ofApp::heightMazeChanged);
	widthMaze_.removeListener(this, &ofApp::widthMazeChanged);
	generateMazeButton_.removeListener(this, &ofApp::generateMazeButtonClick);
	runPlayerButton_.removeListener(this, &ofApp::runPlayerClick);
}
// ...
void ofApp::runPlayerClick()
{
	maze_.run();
}
// ...

И теперь визуализация полностью готова. Если вы скомпилируете и запустите приложение, то увидите примерно следующую картину:

Maze path finder

Вот бы журнал «Мурзилка» с его лабиринтами можно было бы программировать)))

3. Заключение

Я хотел добавить еще несколько функциональных частей к нашему приложению, но статья получилась и так уже достаточно длинная. Я очень надеюсь, что несмотря на это, статья оказалась интересной и полезной. Я программирую на C++ только в качестве хобби, поэтому я буду рад всем замечаниям как к коду, так и к тексту статьи.

Написанное приложение без особых проблем можно собрать для Android, iOs, для Linux, Mac или Windows. Так же, приложив немного фантазии, можно сделать игру с управлением двумя игроками, например, из разных углов лабиринта к центру, либо на разделённом экране, либо при связи по блютуз или через интернет. Можно поупражняться в оформлении, добавить различных эффектов, particle systems, и/или различные бонусы, бусты, врагов.

Также я надеюсь на ваш интерес и участие в этом проекте. Вот ссылка на GitHub. Ставьте звездочку, делайте fork и добавляйте ваши замечания и предложения в issues или по любому из указанных ниже контактов ;-)

И напоследок, небольшой челлендж от меня. Я знаю как с небольшими изменениями в описанном проекте сделать 3D лабиринт в виде куба с разными слоями (этажами) для прохождения. А вы?)

Используемая литература

  1. Eller’s Algorithm
  2. Как пройти через лабиринт не заблудившись. ДЖИРЛ УОЛКЕР
  3. openFrameworks learning

lPestl, специально для канала UniLecs

telegram: 	@lpestl
skype: 		lp.e.s.tl
E-mail:		lpestlname@gmail.com

Секреты Большого Путешествия. Нахождение оптимального пути на этапе Лабиринт «Большое Путешествие — старшие»

Регламент соревнований “Большое Путешествие — старшие” определяет максимально возможное количество набранных баллов 400. И ключевым этапом этого вида соревнований является этап Лабиринта, так как Регламент начисляет 80 баллов только при обратном прохождении Лабиринта по кратчайшему пути, в противном случае 40. Поэтому перед каждым наставником стоит задача, какой алгоритм применять для определения кратчайшего пути. Эта же задача встала перед нами,  когда мы решили подготовить робота  для этого вида соревнований в нашем кружке спортивной робототехники с возможностью набора 400 баллов и прохождения всего маршрута менее 100 сек. У нас уже был опыт подготовки роботов для линейных лабиринтов с нахождением оптимального пути и опыт прохождения лабиринтов по правилу левой или правой руки, а также в настоящий момент мы готовим роботов типа микромышь для соревнований Лабиринт по регламенту КОР Белоруссия, где используем алгоритм “Flood fill”.

Исходя из этого опыта мы начали искать решение для нахождения кратчайшего пути при прохождении Лабиринта в обратном направлении. Первое что необходимо учесть в исходных данных — это то, что Лабиринт в Большом Путешествии имеет ряд ограничений, а именно:

  1. Лабиринт имеет всегда размер 5х5 клеток.
  2. Лабиринт всегда замкнутый.
  3. Вход  и Выход в Лабиринте не меняются и остаются во всех комбинациях на определенных регламентом местах.
  4. Количество клеток при прохождению по правилу “Левой или Правой руки”  всегда одинаково.

Анализ этих ограничений позволил сделать вывод, что существует ряд вариантов Лабиринтов, когда оптимальный путь можно найти при прохождении в обе стороны, но не для всех комбинаций Лабиринтов. И тогда стало понятно, что надо при прохождении Лабиринта по правилу Левой/Правой руки в направлении “Туда”  построить модель лабиринта и пути прохождения в памяти робота и при достижении черной поперечной линии следующего этапа Инверсная Линия при выходе из финишной ячейки оптимизировать эту модель для прохождения роботом пути “Обратно” по кратчайшему пути.

Сначала мы решили использовать алгоритм “Flood fill”, но этот вариант не позволяет использовать среду Scratch в виду ее ограничений  в которой наши школьники работают до 6-7 класса и он достаточно сложен в реализации, а ведь у нас простой закрытый Лабиринт. И после долгих поисков пришло решение — применить алгоритм изложенный в статье “Teaching a Robot to Solve a Line Maze” by Richard T Vannoy II April 2009″  /www.pololu.com/file//line-mazealgorithm.pdf для Лабиринта напечатанного на бумаге с некоторыми доработками для Лабиринта со стенками. Этот алгоритм существенно проще, подходит только для замкнутых Лабиринтов и не требует построения в памяти модели Лабиринта, а строит только модель пути и позволяет ее оптимизировать до нахождения кратчайшего пути. Но как использовать доработанный Алгоритм для Лабиринта со стенками?

Рассмотрим суть алгоритма. Она заключается в следующем:

  1. Робот проходит лабиринт по правилу Левой или Правой руки.
  2. В процессе прохождения он определяет узловые ячейки.
  3. Находясь в узловой ячейке робот определяет тип дальнейшего движения, а именно: Поворот налево на 90 градусов (L) и прямо, Поворот направо на 90 градусов (R) и прямо, Движение прямо (S), Разворот на 180 градусов (B) и прямо.
  4. Результат определения дальнейшего движения в  виде символов указанных в  п. 3 записывается в одномерный массив в виде последовательности символов.
  5. Робот совершает выбранный тип движения, до определения следующей узловой ячейки, в которой п.п. 3-4 повторяются.
  6. При достижении финишной ячейки ( определяется по черной поперечной полосе начала инверсного поля) робот строит в памяти кратчайший маршрут движения методом исключения дважды пройденных участков. В результате мы получим символьный массив соответствующий кратчайшему пути.
  7. При прохождении лабиринта в обратном направлении робот также определяет узловые ячейки, но выполняет движение соответствующее символу в одномерном массиве по правилу: первая узловая ячейка — первый символ, вторая — второй и т. д.
  8. В итоге робот проходит обратный маршрут кратчайшим путем.

Как определить узловую ячейку?

Для определения узловой ячейки робот должен иметь три дальномера, а именно: фронтальный, левый и правый для определения наличия стенок с трех сторон робота. Если робот в процессе движения прямо определил следующие варианты:

  1. Присутствует стенка слева и отсутствуют стенки впереди и справа.
  2. Присутствует стенка справа и отсутствуют стенки впереди и слева.
  3. Присутствуют стенки впереди и слева и отсутствует стенка справа.
  4. Присутствуют стенки впереди и справа и отсутствует стенка слева.
  5. Отсутствуют стенки спереди, слева и справа.
  6. Присутствуют стенки слева, справа и спереди.

то ячейка в которой находится робот является узловой. Неузловая ячейка всегда имеет две стенки слева и справа и не имеет стенки впереди.

Правило приведения одномерного массива

Для получения кратчайшего маршрута используется следующий алгоритм:

  1. Находим в одномерном массиве символ “B” .
  2. Находим рядом с ним два символа — один слева, другой справа.
  3. Заменяем эти три символа  на один  следующим образом RBL — B, RBS — L, SBR — L, RBR — S  и т. д. Остальные комбинации замен можно подобрать в зависимости от лабиринта и движения по правилу Левой/Правой руки.
  4. Проводим замены до тех пор пока в массиве не останется ни одного символа “B”.

Примеры определения кратчайшего пути

На обеих рисунках ниже красными точками отмечены узловые ячейки. Синим цветом проложен маршрут по правилу левой руки на первом рисунке и по правилу правой руки на втором рисунке. На обоих рисунках синим цветом показан одномерный массив с правилом приведения к кратчайшему маршруту. Красным цветом показан одномерный массив в результате приведения к кратчайшему пути. на первом рисунке красным цветом отмечен кратчайший маршрут, а на втором удаляемая ветвь.

%20Maze_2.png

Пример прохождения роботом первого Лабиринта по кратчайшему пути в отладочном режиме приведен здесь https://disk.yandex.ru/i/7vdHsj6DNtTEPw.

Maze.png

Понравилась статья? Поделить с друзьями:
  • Как найти объект в файле word
  • Как найти в программе 1с счет фактуры
  • Как исправить фонетические ошибки в английском языке
  • Как расстаться с девушкой если нашел другую
  • Как найти свой автосервис