Симуляція руху кубика
Привіт, сьогодні ми розглянемо модифікацію задачі оптимізації для знаходження найкоротшого (найдешевшого) шляху. Умова така: маємо кубик, на кожній стороні якого позначено додатнє число.
Коли куб знаходиться на цій стороні - це число додається до загальної вартості шляху. Також маємо площину, поділену на рівні частини (квадрати), які відповідають розміру граней куба. Кубик може переміщуватися по цій площині в чотирьох напрямках (вперед, назад, вправо, вліво) і рух відбувається поворотом у відповідних напрямках відносно осі, яка відповідає ребрам нижньої грані для кожного з напрямків. Простими словами, кубик “перекочується” з однієї грані на іншу, в цей момент ми додаємо до загального шляху “вартість” цієї грані.
Отож, потрібно знайти найдешевший маршрут з точки старту на цій площині до точки фінішу.
Почнемо з представлення даного кубика в коді (будемо використовувати Python 3.7 для прикладів коду). Це просто об’єкт, який зберігає значення вартості для кожної з граней.
1 | class Cube(object): |
Тепер визначимо методи для руху кубика у всіх чотирьох напрямках, які будуть повертати новий екземляр після кожного такого кроку.
1 | def move_right(self): |
Нічого особливого, але потрібно бути уважним під час зміни граней, щоб кожна опинилася на відповідному місці. Для представлення куба будемо використовувати одну з 11 можливих розгорток.
1 | def __str__(self): |
Тут ми використовуємо синтаксис форматування рядків для кращої наочності. Створимо екземляр, що буде відповідати звичайному гральному кубику і подивимося на результат виводу в консоль.
1 | cube = Cube( |
В результаті маємо таку розгортку:
Перейдемо до представлення грального поля: найпростіший варіант - це двовимірний масив, кожен елемент якого відповідає клітинці поля. Цей підхід затратний по пам’яті, оскільки нам потрібно зберігати клітинки поля, які можуть взагалі не використовуватися впродовж виконання програми. Знаходити шлях для початку будемо найочевиднішим способом за допомогою алгоритму пошуку в ширину. Спочатку ми пробуємо перемістити кубик в усі 4 можливі напрямки.
Кожен з нових положень кубиків ми додаємо в чергу. На карті позначаємо вартість цього шляху: це вартість шляху з попереднього положення + значення грані, на яку ми перемістилися. На кожному наступному кроці ми повторюємо ту ж саму процедуру для кожного елементу в черзі, але записуємо вартість шляху в клітинку поля тільки якщо дана клітинка ще не відвідана раніше, або якщо вартість поточного шляху менша за уже існуючу. Повторюємо це доки не досягли фінішної точки і доки залишилися елементи в нашій черзі.
Черга буде містити пари вигляду (позиція, стан кубика), тож перейдемо до представлення поля і позиції. Для позиції ми використовуємо пару координат на полі
1 | class Position(object): |
Для кожної позиції теж є чотири методи, що повертають нову позицію в залежності від напрямку руху по полю. Також ми перевизначаємо методи __eq__
і __hash__
для порівняння позицій і використання в якості ключів для словника поля. Поле - це словник, де ключі відповідають позиції на полі, а значення - це вартість найкоротшого маршруту, що приводить в дану позицію.
Прийшов час створити клас безпосередньо для самої гри
1 | import queue |
На вхід потрібно отримати початковий стан кубика, точку початку та точку кінця. Також ми зберігаємо поле, чергу для перебору шляхів і поточну мінімальну ціну шляху. Почнемо покроково розглядати наш найголовніший метод solve
1 | self.queue.put((self.start, self.initial_state)) |
Ми додаємо в чергу пару початкової позиції та початкового стану, а на полі позначаємо вартість шляху - це значення нижньої грані в поточний момент. І поки черга не порожня, ми перебираємо усі можливі варіанти переміщення і фільтруємо для наступних кроків лише оптимальні з них.
1 | while not self.queue.empty(): |
Кожен крок починаємо з отримання останнього (FIFO) елементу в черзі. Для початку робимо перевірку, чи досягнутий кінець шляху і чи вартість поточного маршруту є найменшою.
1 | for direction in ('forward', 'right', 'backward', 'left'): |
Далі перебираємо всі можливі напрямки і для кожного з них отримуємо нову координату і нове положення кубика, а також отриману вартість шляху за умови, що ми продовжимо рух в цьому напрямку.
1 | if new_value <= current_value: |
Якщо шлях оптимальний - продовжуємо рухатися ним (додаємо в чергу для наступного кроку і записуємо значення в клітинку поля). В кінці функції повертаємо результатом вартість найдешевшого маршруту return self._min_score
.
Побудова шляху
Отримати вартість найдешевшого шляху - це лише одна частина. Набагато цікавіше - дізнатися сам маршрут, по якому потрібно рухатися кубику. Найочевидніший варіант - це зберігати у черзі замість пар трійку значень: (позиція; стан; послідовність кроків, яка привела в дану клітинку). Такий підхід теж дуже затратний по пам’яті, оскільки вимагає зберігання шляхів для кожної з точок поля, а їх кількість зростає поліноміально з кожним кроком, та і сама довшина шляху є лінійно зростаючою. Краще додати ще один додатковий крок, який буде рухатися з фінішної точки в зворотному напрямку по уже побудованому мінімальному шляху, а потім просто розвернути цей шлях в іншому напрямку.
Спочатку визначаємо словник, який допоможе нам створити зворотній напрямок
1 | REVERSED_DIRECTIONS_ABBR = { |
І стоворимо додаткову функцію, яка допоможе обрати мінімальний напрямок із заданої позиції
1 | def _get_min_direction(self, pos): |
Для кожної клітинки розглядаємо її сусідів і вибираємо найоптимальніший напрям. Залишилося описати саму функцію пошуку шляху.
1 | def get_path(self): |
Працює вона описаним вище шляхом: починаючи з фінішу, рухаємося до мінімальних сусідів і додаємо “обернене” ім’я кроку до загального шляху. Досягнувши старту - повертаємо шлях у зворотному напрямку, що уже містить правильні “обернені” назви для напрямків руху. Залишилося найважливіше - запустити і перевірити алгоритм в дії:
1 | cube = Cube( |
На моєму ноутбуці результат виглядає так:
1 | 22 |
Двічі рухаємося вправо, тричі вгору і після останнього повороту вправо досягаємо фінішу за 22 одиниці. Результат може відрізнятися, якщо змінити порядок вибору напрямків ('forward', 'right', 'backward', 'left')
, але мінімальна вартість буде завджи така ж сама.
Що далі?
Отож, ми створили представлення кубика та поля в коді для розв’язанн задачі з пошуку мінімального шляху. Існує припущення, що дану задачу можна вирішити алгоритмом, що працює за лінійний час. Ми спробуємо довести це в наступних частинах цієї статті. Також створимо набагато кращу візуалізацію цього процесу і проведемо детальний аналіз затрат по часу та пам’яті для кожного з розглянутих рішень. Сподіваюсь, щось з цього було корисно, до зустрічі ;)