Русский
Русский
English
Статистика
Реклама

Задачи для программистов

Можно порешать задача про лидарное облако откоманды беспилотных автомобилей Яндекса

20.10.2020 12:19:01 | Автор: admin


Меня зовут Андрей Гладков, я разработчик в направлении беспилотных автомобилей. Сегодня я поделюсь с сообществом Хабра задачей, которая связана с важнейшим сенсором беспилотника лидаром, и с тем, как мы обрабатываем лидарные данные. Вы можете попробовать решить задачу самостоятельно на платформе Контест. Система проверит решение с помощью автотестов и сразу сообщит результат. Разбор и код решения в спойлерах ближе к концу поста. Тем, кто был на митапе в нашем цехе в прошлом году, задача покажется знакомой мы предлагали её в качестве входного билета, но публично никогда ей не делились.

Условие

Ограничение времени 15 секунд
Ограничение памяти 64 МБ
Ввод стандартный ввод или input.txt
Вывод стандартный вывод или output.txt
Беспилотный автомобиль стоит на ровной асфальтовой площадке, на крыше автомобиля установлен лидар. Даны измерения, полученные лидаром за один период сканирования.

Измерения представляют собой множество из $N$ точек, имеющих координаты $x$, $y$ и $z$. Больше 50% точек принадлежат дороге. Моделью положения принадлежащих дороге точек в пространстве является плоскость с параметризацией

$A\cdot x+B \cdot y + C \cdot z + D=0.$

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

Необходимо найти параметры $A, B, C$ и $D$ соответствующей дороге плоскости. Число точек, отклоняющихся от модели не больше чем на $p$, должно составлять не менее 50% от общего числа точек.

Формат ввода


Входные данные заданы в текстовом формате. Первая строка содержит фиксированный порог $p$$(0.01p0.5)$. Вторая строка содержит число точек $N$$(3 N 25 000)$. Следующие $N$ строк содержат координаты $x$, $y$ и $z$$(100x, y, z100)$ для каждой точки, разделителем является символ табуляции (формат строки "x[TAB]y[TAB]z"). Вещественные числа имеют не более 6 десятичных знаков.

Формат вывода


Выведите параметры $A$, $B$, $C$ и $D$ соответствующей дороге плоскости. Числа разделяйте пробелами. Выведенные параметры должны задавать корректную плоскость.

Пример 1

Ввод Вывод
0.013200010-10010100
0.000000 0.000000 1.000000 0.000000

Пример 2

Ввод Вывод
0.013200310-10210102
-0.099504 0.000000 0.995037 -0.995037

Пример 3

Ввод Вывод
0.011020-100.22000.220100.215-100.151500.1515100.1510-100.110100.120181.715-151.2
-0.010000 0.000000 0.999950 0.000000
Данные примеры синтетические. В реальном облаке точек объектов значительно больше: поработайте над edge-кейсами.

Где порешать


Попробовать свои силы можно на Контесте: https://contest.yandex.ru/contest/12698/enter/

Разбор и готовый код


Разбор
Сначала давайте задумаемся над тем, что должны представлять из себя входные данные. Больше 50% точек, как сказано в условии, относятся к дороге, остальные, как мы можем догадаться, к другим объектам, которые возвышаются над дорогой. Это могут быть другие автомобили, пешеходы, столбы и т. д. Их возвышение над дорогой может быть достаточно большим по сравнению с заданной величиной отклонения точек дороги $p$.

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

Применительно к задаче шаг его итерации можно описать так:

  • Берем случайные три точки, вычисляем по ним параметры плоскости (гипотезу).
  • Оцениваем, насколько гипотеза хороша сколько точек с учетом заданного порога $p$ можно отнести к плоскости дороги, а сколько нужно признать выбросами.
  • Обновляем лучшую гипотезу, если текущая оказалась лучше.

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

Код на C++
#include <algorithm>#include <array>#include <cmath>#include <cstdint>#include <iostream>#include <random>#include <vector>struct Point3d {  double X = 0.0;  double Y = 0.0;  double Z = 0.0;};using PointCloud = std::vector<Point3d>;struct Plane {  double A = 0.0;  double B = 0.0;  double C = 0.0;  double D = 0.0;};bool CreatePlane(    Plane& plane,    const Point3d& p1,    const Point3d& p2,    const Point3d& p3) {  const double matrix[3][3] =    {{          0,           0,           0},  // this row is not used     {p2.X - p1.X, p2.Y - p1.Y, p2.Z - p1.Z},     {p3.X - p1.X, p3.Y - p1.Y, p3.Z - p1.Z}};  auto getMatrixSignedMinor = [&matrix](size_t i, size_t j) {    const auto& m = matrix;    return (m[(i + 1) % 3][(j + 1) % 3] * m[(i + 2) % 3][(j + 2) % 3] -            m[(i + 2) % 3][(j + 1) % 3] * m[(i + 1) % 3][(j + 2) % 3]);  };  const double A = getMatrixSignedMinor(0, 0);  const double B = getMatrixSignedMinor(0, 1);  const double C = getMatrixSignedMinor(0, 2);  const double D = -(A * p1.X + B * p1.Y + C * p1.Z);  const double norm_coeff = std::sqrt(A * A + B * B + C * C);  const double kMinValidNormCoeff = 1.0e-6;  if (norm_coeff < kMinValidNormCoeff) {    return false;  }  plane.A = A / norm_coeff;  plane.B = B / norm_coeff;  plane.C = C / norm_coeff;  plane.D = D / norm_coeff;  return true;}double CalcDistance(const Plane& plane, const Point3d& point) {  // assume that the plane is valid  const auto numerator = std::abs(      plane.A * point.X + plane.B * point.Y + plane.C * point.Z + plane.D);  const auto denominator = std::sqrt(      plane.A * plane.A + plane.B * plane.B + plane.C * plane.C);  return numerator / denominator;}int CountInliers(    const Plane& plane,    const PointCloud& cloud,    double threshold) {  return std::count_if(cloud.begin(), cloud.end(),      [&plane, threshold](const Point3d& point) {        return CalcDistance(plane, point) <= threshold; });}Plane FindPlaneWithFullSearch(const PointCloud& cloud, double threshold) {  const size_t cloud_size = cloud.size();  Plane best_plane;  int best_number_of_inliers = 0;  for (size_t i = 0; i < cloud_size - 2; ++i) {    for (size_t j = i + 1; j < cloud_size - 1; ++j) {      for (size_t k = j + 1; k < cloud_size; ++k) {        Plane sample_plane;        if (!CreatePlane(sample_plane, cloud[i], cloud[j], cloud[k])) {          continue;        }        const int number_of_inliers = CountInliers(            sample_plane, cloud, threshold);        if (number_of_inliers > best_number_of_inliers) {          best_plane = sample_plane;          best_number_of_inliers = number_of_inliers;        }      }    }  }  return best_plane;}Plane FindPlaneWithSimpleRansac(    const PointCloud& cloud,    double threshold,    size_t iterations) {  const size_t cloud_size = cloud.size();  // use uint64_t to calculate the number of all combinations  // for N <= 25000 without overflow  const auto cloud_size_ull = static_cast<uint64_t>(cloud_size);  const auto number_of_combinations =      cloud_size_ull * (cloud_size_ull - 1) * (cloud_size_ull - 2) / 6;  if (number_of_combinations <= iterations) {    return FindPlaneWithFullSearch(cloud, threshold);  }  std::random_device rd;  std::mt19937 gen(rd());  std::uniform_int_distribution<size_t> distr(0, cloud_size - 1);  Plane best_plane;  int best_number_of_inliers = 0;  for (size_t i = 0; i < iterations; ++i) {    std::array<size_t, 3> indices;    do {      indices = {distr(gen), distr(gen), distr(gen)};      std::sort(indices.begin(), indices.end());    } while (indices[0] == indices[1] || indices[1] == indices[2]);    Plane sample_plane;    if (!CreatePlane(sample_plane,                     cloud[indices[0]],                     cloud[indices[1]],                     cloud[indices[2]])) {      continue;    }    const int number_of_inliers = CountInliers(        sample_plane, cloud, threshold);    if (number_of_inliers > best_number_of_inliers) {      best_plane = sample_plane;      best_number_of_inliers = number_of_inliers;    }  }  return best_plane;}int main() {  double p = 0.0;  std::cin >> p;  size_t points_number = 0;  std::cin >> points_number;  PointCloud cloud;  cloud.reserve(points_number);  for (size_t i = 0; i < points_number; ++i) {    Point3d point;    std::cin >> point.X >> point.Y >> point.Z;    cloud.push_back(point);  }  const Plane plane = FindPlaneWithSimpleRansac(cloud, p, 100);  std::cout << plane.A << ' '            << plane.B << ' '            << plane.C << ' '            << plane.D << std::endl;  return 0;}

Комментарии к коду
Посмотрим на кусок кода, представленного выше:

  const double matrix[3][3] =    {{          0,           0,           0},  // this row is not used     {p2.X - p1.X, p2.Y - p1.Y, p2.Z - p1.Z},     {p3.X - p1.X, p3.Y - p1.Y, p3.Z - p1.Z}};  auto getMatrixSignedMinor = [&matrix](size_t i, size_t j) {    const auto& m = matrix;    return (m[(i + 1) % 3][(j + 1) % 3] * m[(i + 2) % 3][(j + 2) % 3] -            m[(i + 2) % 3][(j + 1) % 3] * m[(i + 1) % 3][(j + 2) % 3]);  };  const double A = getMatrixSignedMinor(0, 0);  const double B = getMatrixSignedMinor(0, 1);  const double C = getMatrixSignedMinor(0, 2);  const double D = -(A * p1.X + B * p1.Y + C * p1.Z);

В этих строках выполняется вычисление параметров плоскости по трем точкам (ссылка на Википедию). В первой строке $matrix$ должны стоять элементы $x - p1.X$, $y - p1.Y$ и $z - p1.Z$. Если вычислять определитель этой матрицы через алгебраические дополнения для первой строки, то дополнения войдут в итоговое выражение как коэффициенты при $x$, $y$ и $z$, то есть будут как раз коэффициентами $A$, $B$ и $C$ плоскости.

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

Opener 2020 Самый сок

23.06.2020 16:19:11 | Автор: admin

Введение


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

Opener это ежегодный открытый интеллектуальный конкурс для студентов, аспирантов и магистрантов Республики Беларусь (по крайней мере на призы претендовать могут только они), а участвовать все, кто захочет. Данный конкурс проходит уже 8 лет. В этом году был добавлен командный зачёт со своими дополнительными задачами.

Призовой фонд конкурса составил 12 Apple Ipad Pro для личного и $4000 для командного зачётов. Наша команда, состоящая из Артура (petuhovskiy), Вячеслава, Игоря (lodthe), Кирилла и меня выиграла 4 Ipada.

Мы добрались практически до самого конца конкурса, и я бы хотел показать вам, какие задачи показались нам самыми интересными. Надеюсь, что прочитав ход решения, посмотрев на командную работу в Google таблицах, увидев скриншоты, вы осознаете уровень проработки задач и не сможете не сказать: Вау!

В конце статьи вас ждёт задача попробуйте её решить!



Основная часть


Как всё начиналось


Стоит заметить, что я уже принимал участие в данном конкурсе в 2019 году. Узнал о нём я совершенно случайно и решил первых задач 10 на парах. Спустя год мне на почту пришло письмо-приглашение. Теперь я знал чего ожидать и меня это не заинтересовало, ведь конкурс самый настоящий марафон на месяц. Я просто поделился письмом и ссылкой на регистрацию с подписчиками своего канала в Telegram.

Спустя несколько дней мой друг Слава пересылает в общий с Игорем чат данный пост и спрашивает: Что по этому?. Игорь интересуется про род задач, которые там будут, а я ему подробно отвечаю. Слово за слово, приходим к тому, что никто не против порешать чего-нибудь просто так, а зарегистрировавшись, мы точно ничего не потеряем.

Команды от трёх человек, максимум пять. Мы поставили в чат себе уведомление на день начала конкурса и успешно про всё забыли. В последний день регистрации хотелось укомплектовать команду полностью, но на примете никого не было. Игорь написал своим знакомым с предложением поучаствовать, не надеясь на положительный ответ, однако, именно так к команде присоединился Артур и Кирилл. Примечательно то, что последний участник зарегистрировался за минуту до окончания формирования команд.

Артур, Игорь, Кирилл и Слава учатся в Высшей Школе Экономики, в школьные годы увлекались спортивным программированием в Республике Беларусь.

Организационные моменты


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

  • Опять таблетки забыли принять?
  • Последовательность бык
  • Где???
  • Вы вопрос задайте
  • Подумать, нет?


Telegram бот, координирование команды


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

Мало того, что это очень удобно (ведь мы получали уведомления и были в курсе всего), так ещё и получилась система логирования всех исправлений как участников, так и организаторов. Это помогало получить дополнительную информацию, так как организаторы затирали в вопросах участников слова, которые могли хоть как-то намекнуть куда копать. Подсказки вообще могли молча обновляться, а бот всегда держал нас в курсе.

Общение команды шло через чат. К тому времени я написал бота для интеграции Zoom в Telegram, он был на хайпе и мы им активно пользовались, но где-то в середине месяца они опять вернули своё ограничение на 40 минут конференции и мы перешли в Discord, так как любили засесть ночью на часика четыре.

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



Задачи


Как я уже упомянул во введении, задачи делятся на два типа: командные и личные. Одновременно можно было решать задачи обоих типов, но командные задачи были заблокированы для просмотра, пока не решены предыдущие из личного или командного зачётов. Удивительно то, что организаторы не запрещали, а даже открыто заявляли, что задачи личного зачёта можно решать вместе с командой (при её наличии, конечно же). Ими было введено ограничение, которое не позволяло забрать больше четырёх айпадов на одну команду. Мы посчитали, что решать всем вместе будет рациональнее. Так и поступили.

Конечно, выходило и так, что мы застревали на командных задачах, но продолжали решать их из личного зачёта. Чаще всего выходило, что они были более интерактивными и многоэтапными. Все задачи, описанные ниже, с выездом на локацию в Минске, как раз и являются командными. Олимпиадникам больше понравились личные задачи, тогда как я просто тащился от командных. На протяжении всего конкурса я ждал реквизит, про который говорилось в правилах конкурса, но эти задачи были заменены на примитивные из-за пандемии, которые решались за несколько минут. Поняли мы это благодаря открытой корневой папки CloudFronta, получив все приложения задач ещё в самом начале конкурса. Так же это дало нам понимание о количестве задач, что предстоит решить. После конкурса я узнал, что никто не справился с абсолютно всеми задачами, а именно на последней и нужен был реквизит.

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

Задача с 104 гифками


Исходными данными к задаче является архив, содержащий в себе 104 GIF файла с разными названиями. Название каждого файла имеет префикс part_. Название архива by8_128x128px_2layers.zip. Сразу замечаем, что это никакие не гифки, а просто изображения. Перегоняем в другой формат:


Разбираемся с названием файла и трактуем его следующим образом: здесь 2 слоя по 4 картинки. Замечаем рамки у изображений, пишим код на Python с использованием PIL, который без пересечений будет склеивать изображения, получаем 52 уголка:


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


Подсказка: Если вы дошли до слов, то нужно найти одинокие буквы по столбикам

Слишком очевидная подсказка, к счастью, коих было мало. Составляем куски фразы, а потом и саму фразу:


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


Собираем число, вставляем в любимый Python и получаем ответ!

2**9 * 3**5 * 5**3 * 7**2 * 11**2 * 13**2 * 17**2 * 19**1 * 23**1 * 29**1 * 31**1 * 37**1 * 41**1 * 43**1 * 47**1 * 53**1 * 59**1 * 61**1 * 67 * 71 * 73 * 79 * 83 * 89 * 97 * 101

Ответ: 2054221614063184107682218077003539824552559296000

Задача с девочками



Подсказка: Цвета какие-то ненатуральные, явно отфотошопил девчонок кто-то

Отличная подсказка, с неё мы и начали решать эту задачу, сами незаметив. Я начал со стенографии, усердно пытаясь достать хоть что-то, но все попытки были тщетны. Была идея поиграться с ползунками в фотошопе, как-то выровнять баланс цветов, приблизить к натуральным, но нет. Заметили, конечно, и пятна из разных цветов. Выделили их более яркими не помогло.


Спустя некоторое время получаем подсказку в качестве кода на Ruby:

def h(a);     a.reduce(Hash.new(0)) {|h,e|h[e]+=1;h};end

Наш лучик надежды. Разбираемся в коде, понимаем, что это ничто иное, как функция, которая возвращает словарь с количеством вхождений чисел в массиве. Недолго думая, понимаем в картинке считать нечего, кроме как пиксели, но продолжаем сидеть без малейшего понятия как это делать правильно.
Подсказка: А что это за такое? А такое есть у картинки? Три? А имя файла?

Получив эти вопросы после кода выше, стараемся дать на них вразумительные ответы. Под тем, что это за такое, подразумеваем количественную характеристику. У картинки, как мы и думали, есть пиксели, а три это три канала: red, green, blue. На имена файлов мы всегда обращали внимание, в данной задаче оно z.png, однако это нам абсолютно ничего не дало, кроме как трёх букв. Только потом мы осознали, что это отсылка к координатам Когда ты в тупике иди в Google. Слава так и поступил: начал вбивать поисковые запросы на подобии: количественная характеристика и как-то связывать это с изображением. Так он пришёл на сайт с генератором гистограммы по изображению. Загрузив наше исходное изображение он заметил интересную картину и поделился сразу с нами:


Это правда выглядит очень необычно. Вооружившись Python с PIL заводим три словаря под каждый канал, проходимся по каждому пикселю изображения, получая RGB и в каждый словарь инкрементим значение, ключ которого наш канал. Например, для словаря red вы возьмём от RGB R за ключ, а в значение добавим +1. Прям как тот код, что дали нам на Ruby. Так проделаем с остальными двумя каналами и О чудо!, Вы только взгляните на это! Практически везде идеальная картина, количество цветов в каждом канале совпадает!


Весь полученный файл доступен тут. Меньше, чем в пяти местах, значения отличались, но мы посчитали, что, возможно, у авторов задачи не получилось сделать идеальное совпадение. Ведь то, что мы увидели, уже величие! Не останавливаясь, видим отсутствие цветов, ведь не все идут по порядку. А почему бы не выписать: какие есть, а каких нет? Так и делаем, дописав к нашему прошлому коду цикл! Для трёх каналов получаем три бинарные строки длиной в 255, где цвет отсутствует 0, а где есть 1:

Red
0000111100000000000000000000000000001111000000001111000011110000111111111111111100001111000011111111000011110000111111110000000000001111111111110000000000001111000000000000000011111111000000001111000011111111000000001111000011110000000011110000111111110000

Green
0000111100000000000000000000000000000000111111111111000011111111111100000000111100001111000011110000111100001111000011110000111100000000000011111111111100001111000011111111000011110000000000001111111100000000000011111111000011110000000011110000000011111111

Blue
0000111100000000000000000000000000001111111100001111111100000000000011111111000000000000000000000000000000000000000011110000000000000000000011111111000000000000111100000000111100000000111111110000111111111111000011110000000011110000111111111111111100000000

Выглядит страшно и непонятно, но вы ведь тоже видите, что везде количество единиц кратно 4? А ведь с нулями такая же картина сжимаем!

Red
0100000001001010111101011010110001110001000011001011001010010110

Green
0100000000111011100101010101010100011101011010001100011010010011

Blue
0100000001101100011000000000010000011000100100110111010010111100

Вот Уже и не так страшно, легко влезает в поле зрения. Нужно думать дальше, чем это может быть, но тут олимпиадное предположение играет нам на руку: А что, если это double?. И правда, а ведь что если? Пробуем перевести двоичные последовательности в тип double (делаем reinterpret_cast в даблы), получаем следующие значения:

53.919325
27.58333
227.0005

Посещая уроки географии в школе, понимаем, что наше первое число: плюс-минус наша широта, а в целом, три числа как раз и обозначают координаты. Тут и должна была сыграть подсказка в имени файла, но мы уже пулей летим в Google карты:


Получилось! Координаты соответствуют углу офиса компании-организатора конкурса. Понимаем, что скорее всего придется туда приехать. Первый этап прошли.

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

Жарко стоять уже :(
Только что прошел кашляющий человек( он был без маски

Ответ организаторов не заставил долго ждать:
Там видео секунд на 30, не больше
Никуда заходить не нужно, можно приехать/прийти одному и повтыкать. Там три координаты, три, Карл, не две (как и две первые, третья отсчитывается от некого выбранного начала, а не от подошв кед). Не ходите, не спрашивайте, нужно рассмотреть со стороны.

Эти ответы перевернули всё. Проверяем высоту над уровнем моря, на которой находится офис, это где-то ~200. Предполагаем, что координата 227 плюс-минус может быть на крыше офиса. В это же время один из нас находился неподалёку он и отправился на локацию. Достигнув угла здания, как на Google карте, поднимаем глаза. Анимированный логотип это не то, что на аватарке в группе у компании. Вместо логотипа тетрис:


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

Мы на правильном пути. Дело Google таблиц! Покадрово разбираем отснятый материал, восстанавливаем блоки, цвета, записываем порядок появления блоков на видео. За счёт равной задержки между появлением фигур узнаём о наличии других, внутри отсутствующей части логотипа.


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


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

Я получаю 3 читабельных слова из 4, но считаю это за совпадение, ухожу делать перестановку букв по словарю, с учётом тех, в которых был уверен по цветам. Таким цветом являлся синий и обозначал букву Т. Засылаю огромную пачку различных комбинаций в чатик. Почитав их все вместе, забываем про эту идею. Спустя некоторое время я со Славой возвращаюсь к расстановке букв, имея справа на экране свой первый вариант. Присоединяется Артур, который чутка отсутствовал на задаче, разбирается в том, чем мы тут занимаемся. Спустя некоторое время Артур обращает внимание на мой прошлый вариант, где найденные три слова выделены зелёным цветом, а под последним написано слово input. Это то слово, что очень хотелось собрать, но не сходилось по одной букве. Артур просто читает строку полностью: Input fifty pi int.

Умножаем число Пи на 50 в целочисленном типе, отсылаем в систему Получаем положительный вердикт: задача длинною в дней 5 решена!


Задача с ответами и вопросами



Подсказка: Где же поблизости найти вопросы, ответы на которые нужно вписать в матрицы?

Вспомним тот факт, что всё общение с организаторами проходит через обсуждения в группе ВК, следовательно, сейчас мы в обсуждениях, а максимальное близкое к нам место с вопросами или другая страница текущей темы, или другой раздел.

Так как первое является невозможным, то мы поднимаемся на уровень выше и смотрим на список обсуждений. Их не много, всего 4: обсуждение задач, подсказки, обсуждение конкурса, правила. Первые два откидываем сразу, к остальным присматриваемся. Зайдя в правила конкурса, видим вторым сообщением секцию с часто задаваемыми вопросами. Вот они, вопросы! Ответы у нас даны в задаче (подписи к матрицам) по ключевым словам ищем их и выписываем!


Подсказка: Зеленые начало укладки

Отлично, выходит что-то надо куда-то уложить. Куда это очевидно, так как знаем начало. Под Что, принимаем вопросы, которые нашли на первом шаге. Проверим свою догадку: все матрицы 10х10 (что даёт нам 100 клеток для символов). Посмотрим на длину строк ответов: все они больше 100. Попробуем откинуть знаки пунктуации и символ пробела, посчитаем просто количество букв у всех вопросов по 100 букв!


Остаётся последний шаг: узнать как укладывать. Там уже посмотрим на буквы в жёлтых клетках и может, что понятнее станет. Ломаем голову, от меня проскальзывает предложение: Давайте рассматривать, как граф, которое успешно игнорируется всеми остальными. Через некоторое время предлагается загуглить по изображению: вырезаем первую матрицу, скармливаем Google ничего, идём в Яндекс что-то есть!


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

Идём в Google, находим программу для решения оригинальной головоломки поиска цикла, а не путей, как в статье, которую мы нашли. Программа оказалась только для Windows, ни у кого её не оказалось под рукой, пошли поднимать виртуалку. Подняли, запустили, программа моментально находила решения.

Решение представляет собой обход поля:


Заходим всей командой в Google таблицу, накладываем скриншоты полученных решений на наши, перенесённые с задания, матрицы. В инструментах для разработчиков браузера задаём элементам с скриншотами прозрачность и заполняем матрицы буквами из ранее найденных вопросов.


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

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

Это в точности 4638590332229999253 и является ответом к задаче.



Задача с монетой


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


В какие дебри мы только не зашли без подсказок. Это и деноминация в Беларуси, и состав монеты, и её размеры. Не оставили без внимания и словое opener с опечаткой. Я сразу же перевёл орёл и решка на английский несколькими способами, гуглил, пришёл к компании Open Retail, которая в 2009 году что-то там, а владеет она торговой сетью монетка. Смотрели на конкурсы прошлых лет. В общем дошли до тупика и пошли заваливать вопросами организаторов:
В 19 надо учитывать номинал?
да, и слово там не опечатка

монета или монета Республики Беларусь?
вопрос непонятен, скорее это не важно (слово монета не важно), важно то, что нарисовано

В 19ой есть что-то связанное с английской розницей?
только на втором этапе, на первом важны только буквы и числа на монетах

можно еще что-нибудь по 19, цифры буквы деньги, ничего не складывается?
деньги на втором этапе, на первом нужно соотнести числа и последовательности букв

Как в 19ой понять, что первый этап пройден? Должна получиться какая-то фраза? Если должны получиться числа, то как можно понять, что они правильные?
получится место. Нет, не числа должны получиться. Но этот в Минске, так можно понять, что он правильный.

мафия причастна к 19 задаче или просто тихий?
вероятно, ни первое, ни второе. Для определения места там числа и последовательности букв.

А можете как-то направить? А то вообще непонятно, как подходить к 19-ой задаче(ещё в первом этапе). Что делать с этими буквами и цифрами)
число связано с тем, как написано слово. на левой аналогичная связь

Получив просто тонну различной информации, стало понятно, что есть некая функция, которая принимает в себя строку и число и как-то переставляет в ней буквы. Реализовываем различные сдвиги, вводим слово openre и выводим каждую итерацию в консоль, смотрим примерно на 20 позицию, потому что без понятия что там по +-1. При определённом алгоритме встречаем opener на 21 позиции, а это 20 если с нуля.


Меняем входное значение на то, что находится на другой стороне монеты u9pgezed6. Повторяем тоже самое, но уже знаем про +1 и смотрим на 2010 итерацию, вместо 2009 приходим к u9edez6gp (файл с сгенерированными строками).

Из подсказок знаем, что конец первого этапа место. Ищем способы задать точное место строкой, натыкаемся на Plus коды Google, но формат не тот. Ищем альтернативные варианты и находим GeoHash! Для эксперимента вбиваем на этом сайте город Минск и сравниваем две строки. Похожи! Вводим нашу полученную строку с монет:


Место выглядит абы-где, как будто не туда, куда надо, указано, но я был там летом (проходил тестирование) и знаю, что там есть офис Itransition. Это точно то, что нам надо! По приходу на место бросается эта красота:


Первым делом я гуглю картинку, выхожу на ask девушки, потом на её instagram, интересуюсь в директе, откуда у неё эта фотография, как она с ней связана и прочее (объяснив ситуацию с конкурсом, конечно же). Оказывается, что девушка просто неподалеку отдыхала в каком-то заведении, а на улице была эта стена, которую она сфотографировала и опубликовала.

Присмотревшись к стене, находим ссылки на авторов ими оказались две девушки. Одна ссылка на Instagram профиль там, как раз, процесс создания этого шедевра. Вторая ссылка пользователь Telegram. Перейдя по второй, похвалив за проделанную работу (правда ведь красиво), спрашиваю: Связано ли это с конкурсом как-то?, на что получаю положительный ответ: Да ребята мы рисовали по строгому задания там нужно найти закономерность в этих чудовищных больше ничего сказать не могу. Нас не трогать.

Продолжаем залипать на стену, рассматривать монстров. Думаю, что-то в сторону конечностей. Не помню, кто именно, но точно не я, замечает, что это матрица 8x8, где каждый элемент монстр. Теперь и скобки [] в условии задачи выглядят для нас не как целая часть числа, а как обозначение матрицы.

Возвращаемся к переводу орёл и решка на английский язык (к этому подстегнула музыкальная подсказка с песней Лепса Уходи по-английски), закидываю идею посчитать heads и tails каждого монстра в отдельных матрицах. Долго сопротивляемся, но решаем попробовать. Сопротивляемся по той причине, что чёрт ногу сломит тут понять что хвост, а что голова и сколько их. Матрицы перемножаем:


Замечаем подозрительное количество четвёрок (да и в знаменателе она же). Игорь заменяет все ненулевые элементы на 4, так как могли ошибиться при подсчёте и получает следующую картину:


Игорь ничего не разглядел на этой матрице и начал циклически сдвигать вправо авось, что проявится. Отчаявшись, скинул в чатик:



Заключение


Всего было полсотни задач и рассказывать красочно о каждой, в рамках одной статьи, просто нецелесообразно. Мы отобрали те, где есть, что показать и рассказать. К сожалению, сюда не вошли алгоритмические задачи, коих было достаточное количество.

Чего мы только не вытворяли, чтобы достигнуть решения. Писали код на двух языках в одном файле, который будет без проблем выполняться в разных компиляторах. Коллизии MD5 хэш трёх разных файлов с кодом, игрались с RFC 2898 + AES, изучили флаги стран по цветам, выучили Ruby, пописали на asme, подеобфусцировали кучу раз код и многое-многое другое

Это был незабываемый месяц! Рекомендуем принять участие в следующем году! А вот и обещанная задача про Python (без решения):
Написать скрипт на языке Python 3, который: состоит не более чем из 4000 латинских букв и круглых или угловых скобок; при запуске печатает на консоль переданные в скрипт аргументы в обратном порядке (каждый на отдельной строке).

Дополню, что пробелы использовать запрещено. Исключительно то, что написано в условии. Жду вас в комментариях!

Спасибо, что дочитали аж до сюда! Всем МурмурХеша.
Подробнее..

Из песочницы AI на минималках пишем свой Сокобан и учим компьютер его решать

07.11.2020 00:05:43 | Автор: admin

Компьютер играет в Сокобан


В этой статье я расскажу как написать свою реализацию известной игрушки Сокобан, а также алгоритм для её решения с нуля. Заодно применю на практике некоторые шаблоны проектирования и принципы SOLID.


Весь код расположен по адресу


Предыстория


Я пользуюсь кнопочным телефоном. Из развлечений на нём только радио и единственная игра Сокобан из 15 уровней. Из них я решил только 10 и застрял на одиннадцатом. Как-то раз я целый день ехал в поезде и решал этот злосчастный 11 уровень, но не преуспел. Тогда я решил прибегнуть к помощи компьютера, благо опыт программирования имею достаточный для такой задачи. Поставил цель: самостоятельно написать реализацию с решением этой игрушки. По результатам появилась эта статья.


Тот самый 11 уровень (решение в конце статьи):


11


Сама игрушка представляет собой прямоугольное 2D поле, на котором раскиданы ящики, стены и метки. Ящики можно толкать, но не тянуть, в этом вся сложность. Ваша цель: переместить все ящики на клетки с метками. Пример игры:


Пример уровня


Пишем реализацию


Давайте не будем усложнять себе задачу и создавать отдельный GUI со спрайтами и редактором уровней. Вместо этого остановимся на консольном варианте а-ля Rogue-like, где уровни будут отрисовываться построчно и загружаться из текстового файла. Сначала нужно придумать какие символы будут обозначать элементы на уровне. Я сделал такой выбор:


  • Решётка # это стена, через неё нельзя пройти и продвинуть ящик.
  • Буква O это ящик.
  • Буква X это метка, куда нужно переместить ящик.
  • Собака @ означает игрока.
  • Точка . означает пустое место.
  • Буква G это ящик на метке.

Теперь самый ответственный момент выбор архитектуры приложения. Если ошибиться, то можно сильно повредить всей дальнейшей разработке. В нашем случае идеально подходит шаблон MVC Модель, Представление, Контроллер. Модель хранит внутреннее игровое состояние и поле, даёт доступ к данным, не знает ничего о контроллере и представлении. Представление только печатает состояние модели в консоль. Контроллер считывает символы с клавиатуры и изменяет модель. Типичная ошибка новичков добавлять бизнес-логику в контроллер вместо модели. В результате получаются переполненные чужим кодом контроллеры, нарушающие первую букву SOLID. Согласно ей, каждый класс должен брать на себя только одну ответственность. Представление печатать уровни в консоль, модель хранить состояние игрушки и логику работы с ним, контроллер обрабатывать действия пользователя. То есть контроллер не должен знать как именно двигать ящики и игрока по полю, это ответственность модели. Контроллеру всего лишь нужно вызвать соответствующий метод. Ещё хорошо бы скрыть реализации всех трёх сущностей за интерфейсами. Это даёт следующее:


  • Можно спокойно менять реализации, например, написать GUI представление. На остальной код это никак не повлияет.
  • В совокупности с инъекцией зависимостей значительно упрощается модульное тестирование.

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


public class ConsoleController implements Controller{    private final Model model;    private final View view;    public ConsoleController(final Model model)    {        this.model = model;        this.view = new ConsoleView(model);    }    // methods}

Здесь первая зависимость model была установлена как надо, через ссылку в параметре конструктора, а вторая view создана напрямую. Это плохо хотя бы потому, что теперь ConsoleController должен знать не только о View, но и ConsoleView, что повышает зацепление. Изменения ConsoleView могут повлечь за собой изменения в ConsoleController, чего можно избежать, написав вот так:


public class ConsoleController implements Controller{    private final Model model;    private final View view;    public ConsoleController(final Model model, final View view)    {        this.model = model;        this.view = view;    }    // methods}

Теперь класс ConsoleController стало проще тестировать. Суть буквы D в SOLID в том, что класс не должен контролировать как именно удовлетворяются его зависимости. Эта ответственность теперь ложится на клиентов класса. Есть множество механизмов разрешения зависимостей, самый популярный из которых контейнер зависимостей. Это отдельный модуль, обычно реализованный каким-нибудь фреймворком типа Spring или библиотекой, который сам может прокинуть все необходимые экземпляры в конструкторы или сеттеры. От вас нужно только их объявить.


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


  • Квадратное поле из различных плиток
  • Набор ящиков и меток
  • Игрока
    Каждую клетку на поле можно моделировать двумерным вектором (row, col), где row это номер строки, а col столбца клетки, начиная с нуля. Каждой клетке будет соответствовать плитка пустое место, куда можно сходить или стена. Ящики не пронумерованы, любой ящик можно поставить на любую метку, поэтому можно смоделировать их как множества клеток, на которых расположены ящики и метки. Позиция игрока также моделируется клеткой.

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


Архитектура приложения


Заметьте, здесь модель представлена не одним классом, а целым пакетом Model. Главный класс Sokoban не скрыт за интерфейсом, потому что представляет единственную реализацию модели игрушки. Метод move() двигает игрока в одну из четырёх сторон. Сам Sokoban можно изменить только вызвав этот метод. Методы getCrates() и getMarks() возвращают неизменяемое представление множеств. Забегая вперёд, скажу что я сразу заложил два алгоритма решения Сокобана: обход графа конфигураций в ширину и поиск оптимального пути от начальной конфигурации до выигрышной с помощью A* (A star algorithm).


Метод run() запускает цикл "отрисовка уровня -> считывание движения -> обновление модели -> снова отрисовка"


Уровни будут загружаться из текстового файла со строками символов клеток, например:


############.@..O..X.############

Чтение и парсинг файла лучше тоже вынести в отдельный класс-создатель модели. Здесь идеально подходит шаблон "Абстрактная фабрика". Вкратце: внутри класса CharSokobanFactory пишем код чтения и парсинга файла, создаём игрока, поле, множество меток и ящиков. Важно, чтобы конструктор класса Sokoban оставался чистым, то есть содержал только присвоения зависимостей, это упрощает тестирование:


    public Sokoban(final Field field, final Set<Point> crates, final Set<Point> marks, final Point player)    {        this.field = field;        this.crates = crates;        this.marks = marks;        this.player = player;    }

Созданием этих зависимостей как раз и займётся CharSokobanFactory. Фабрику нужно скрыть за интерфейсом, чтобы не привязываться к созданию модели через символьные строки. В будущем можно использовать другой формат хранения уровней.


Абстрактная фабрика


Вместо стрелок я решил выбрать vi-like раскладку:


  • Буква j шаг вниз
  • Буква k вверх
  • Буква h влево
  • Буква l вправо

Игра считается завершённой, если множества ящиков и меток равны:


class Sokoban {    // some stuff    public boolean solved()    {        return marks.equals(crates);    }    // other stuff}

Чтобы не городить кучу свичей и if-ов в контроллере, я создал перечисление Direction, где выписал какой символ отвечает за какое направление. Затем создал класс Move, который инкапсулирует Direction и вызывает метод move(direction) у модели. Также у него есть статичный метод Move.resolve, который создаёт экземпляр по клавише пользователя.
Это хороший пример шаблона "Фабричный Метод". Плюс такого подхода: если я захочу чтобы игрок мог ходить по диагонали, то мне нужно будет добавить только 4 строчки в перечисление.
Таким образом соблюдается буква O в SOLID, Open-Closed Principle: классы должны быть открыты для расширений и закрыты для изменений. То есть, мы должны иметь возможность добавлять новый функционал без изменения старого. Очень часто происходит так, что новые фичи ломают старые, а исправления ошибок только вводят новые. Это как раз из-за того, что программисты не знают как правильно изолировать различные аспекты задачи по классам так, чтобы изменения одних не затрагивали другие.


Получается такая схема:


Direction


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


class ConsoleController {//    @Override    public void run()    {        view.say("Sokoban Starts");        char symbol = '0';        view.render();        final List<Move> history = new LinkedList<>();        while (symbol != 'q')        {            final Move move = Move.resolve(symbol);            if (move != null)            {                history.add(move);                move.perform(sokoban);                view.render();                if (sokoban.solved())                {                    view.say("YOU WIN!");                    break;                }            }            try            {                symbol = (char) System.in.read();            }            catch (IOException io)            {                view.say("Не получилось считать команду:");                throw new IllegalStateException(io);            }        }        view.say("Your moves: " +  Move.compress(history));    }//}

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


Реализация представления тоже проста до невозможности:


class ConsoleView {//    @Override    public void render()    {        clearConsole();        for (int i = 0; i < sokoban.numberOfFieldRows(); i++)        {            for (int j = 0; j < sokoban.numberOfFieldCols(); j++)            {                final Point at = new Point(i, j);                final Tile tileAt = sokoban.tile(at);                if (tileAt == null)                    break;                final char symbolToPrint = tileAt == Tile.CRATE && sokoban.isMarkAt(at) ? Tile.CRATE_ON_MARK.symbol() : tileAt.symbol();                System.out.print(symbolToPrint);            }            System.out.println();        }    }//}

Строка 15 обрабатывает клетку с ящиком на метке. Буква G (Good) специально предназначена, чтобы обозначать ящик, задвинутый на метку. Однако такая клетка никак не моделируется, поэтому существует только в представлении.


Остаётся только научить модель передвигать игрока. Нужно учесть следующие моменты:


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

Проверить эти условия несложно, в этом поможет флаг "walkable" в перечислении Tile:


public enum Tile {    WALL('#', false),    GRASS('.', true),    CRATE('O', false),    MARK('X', true),    CRATE_ON_MARK('G', false),    PLAYER('@', true);    private final char symbol;    private final boolean walkable;    Tile(final char symbol, final boolean walkable) {        this.symbol = symbol;        this.walkable = walkable;    }    public boolean isWalkable() {        return walkable;    }}

Тогда проверка на то, можно ли сходить на данную ячейку сократится до нескольких строк:


public Sokoban {// класс Sokoban    public Tile tile(final Point at) {        if (player.equals(at))            return Tile.PLAYER;        //        if (crates.contains(at))            return Tile.CRATE;        //        return field.tileAt(at);    }    public boolean isWalkableTileAt(final Point at) {        return tile(at).isWalkable();    }// класс Sokoban}

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


public class Main {    public static void main(String[] args) {        final Sokoban sokoban = CharSokobanFactory.fromFile(args[0]).make();        final View view = new ConsoleView(sokoban);        final Controller game = new ConsoleController(sokoban, view);        // запуск        game.run();    }}

Для теста возьмём вот этот уровень:


#############.XO.......##.XO......@##.XO.......#############

В результате получаем именно то, что хотим:


Решение


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


Пишем свой искусственный интеллект


Что мы делаем когда передвигаем ящики в Сокобане? Мы меняем конфигурацию уровня. Конфигурация здесь это расположение игрока и ящиков на уровне. У каждой конфигурации есть несколько дочерних, в которые можно перейти, передвинув ящик на соседнюю клетку:


Граф конфигураций


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


Граф инцидентности


Игрок может перемещаться только по правой компоненте не трогая ящики, конфигурация от этого не изменится. Однако, если поместить игрока на клетку (1:1), он попадёт в левую и решить такой уровень будет невозможно.


Игра в Сокобан представляет собой прогулку по графу конфигураций в поиске выигрышной вершины. Решение это путь в таком графе от начальной конфигурации до выигрышной.
Рёбра в графе помечены шагами игрока от его исходной позиции до передвижения ящика. Вес ребра это количество таких шагов. Нам интересно не просто определить имеет ли решение конкретный уровень, но ещё и найти это самое решение. В зависимости от уровня такой граф может иметь любую структуру, поэтому нам нужен алгоритм поиска пути, работающий для любых графов. Если забыть что рёбра взвешенные, то отлично подойдёт алгоритм поиска в ширину Breadth-First Search (BFS).


Поиск решения с помощью BFS


В отличие от своего "кузена" поиска в глубину (Depth-First Search) в невзвешанном графе BFS строит кратчайшие пути от исходной вершины до всех достижимых, постепенно выращивая дерево родителей. Кстати, эти два алгоритма отличаются только порядком извлечения вершин из списка. BFS извлекает вершины в порядке очереди (FIFO), а DFS в порядке стека (LIFO), то есть это по сути один и тот же алгоритм. Псевдокод BFS:


BFS(G, s)1. Инициализировать граф, поставить у каждой вершины:    * v.p = null // преды    * v.marked = false // посещали ли уже эту вершину2. Создать очередь Q3. Q.enqueue(s)4. Пока Q не пуста:    4.1 v = q.poll()    4.2 v.marked = true    4.3 Если v это искомая вершина, то прекратить выполнение    4.4 Цикл по всем инцидентным v вершинам, child:        4.4.1 Если не child.marked, то:            4.4.1.2 child.p = v            4.4.1.3. q.enqueue(child)

Здесь параметр v.p указывает на предшествующую ей вершину на пути от начальной до v. Поднятый флаг v.marked показывает, что v уже посещали. Чтобы воссоздать кратчайший путь до v надо "промотать" список v -> v.p -> v.p.p -> ... -> s до начальной задом-наперёд. Под искомой здесь понимается вершина с решённой конфигурацией.


Нам нужно уметь порождать все соседние конфигурации из данной. Каждая соседняя конфигурация порождается движением ящика в одну из четырёх сторон. Причём по бокам
клетки должны быть пустыми. Например, если двигать ящик вправо, то надо чтобы игрок мог добраться до клетки слева от ящика и чтобы справа была свободная клетка. Аналогично с вертикальным движением. Также при проверке можно отсекать заведомо проигрышные конфигурации, когда ящик прислоняется к стене без дальнейшей возможности движения:


Deadlock


Комбинируя все замечания, получаем вот такой код:


public class BFSSolver {//    protected List<Pair<Sokoban, List<Direction>>> deriveChildren(final Sokoban parent) {        final Map<Point, Point> walkablePoints = shortestPathsFromPlayer(parent);        final List<Pair<Sokoban, List<Direction>>> result = new LinkedList<>();        for (final Point crate : parent.getCrates()) {            final Point[][] pairs = new Point[][]{{crate.left(), crate.right()}, {crate.right(), crate.left()},                    {crate.up(), crate.down()}, {crate.down(), crate.up()}};            for (Point[] pair : pairs) {                final Point playerWillStand = pair[0];                final Point crateWillGo = pair[1];                if (canMoveCrate(parent, playerWillStand, crateWillGo, walkablePoints) && !isDeadPosition(parent, crateWillGo)) {                    final LinkedList<Direction> pathToChild = unwindWalk(walkablePoints, playerWillStand);                    pathToChild.add(crate.derive(crateWillGo));                    final Sokoban child = parent.derive(crate, crateWillGo);                    result.add(Pair.pair(child, pathToChild));                }            }        }        return result;    }//}

На первой строчке метод shortestPathsFromPlayer создаёт дерево предшественников в кратчайших путях от parent до всех достижимых вершин. Словарь walkablePoints отображает клетки v на v.p. Метод isDeadPosition как раз проверяет чтобы конфигурация не была заведомо проигрышной. Метод derive из класса Sokoban создаёт "копию" конфигурации со сдвинутым ящиком:


    public Sokoban derive(final Point crateToRemove, final Point crateToInsert)    {        final Set<Point> childConfiguration = new HashSet<>(crates);        childConfiguration.remove(crateToRemove);        childConfiguration.add(crateToInsert);        return new Sokoban(this.field, childConfiguration, Collections.unmodifiableSet(this.marks), crateToRemove);    }

Обратите внимание, что "порождённая" таким методом копия не меняет множество меток. Кстати, класс Point я специально сделал неизменяемым (immutable). Нет смысла создавать отдельную структуру данных "Граф", заполнять его вершинами и рёбрами, а потом уже запускать BFS, это только пустая трата циклов процессора. Свойства v.p и v.marked можно симулировать ассоциативным словарём и множеством.


Теперь у нас в арсенале есть всё необходимое для реализации самого алгоритма:


public class BFSSolver {//    public List<Move> solve(final Sokoban start) {        final Map<Sokoban, Pair<Sokoban, List<Direction>>> childToParentAndDirection = new HashMap<>();        final Set<Sokoban> visited = new HashSet<>();        final Queue<Sokoban> toVisit = new LinkedList<>();        toVisit.add(start);        boolean found = false;        Sokoban parent = null;        while (!toVisit.isEmpty()) {            parent = toVisit.remove();            if (parent.solved()) {                found = true;                break;            }            visited.add(parent);            for (final Pair<Sokoban, List<Direction>> pair : deriveChildren(parent)) {                final Sokoban child = pair.first;                final List<Direction> walkFromParentToChild = pair.second;                if (!visited.contains(child)) {                    childToParentAndDirection.put(child, Pair.pair(parent, walkFromParentToChild));                    toVisit.add(child);                }            }        }        return found? unwind(parent, childToParentAndDirection) : new LinkedList<>();    }//}

Такой алгоритм находит решение, но далеко не факт что оно будет оптимальным, потому что BFS не учитывает вес ребра, то есть рассматривает граф конфигураций как невзвешенный. Чтобы найти наименьший путь во взвешенном графе можно взять, например, алгоритм Дейкстры, однако тут есть одна загвоздка. Понятно, что даже у простого уровня граф конфигураций может быть гиганским, а оптимальный путь чуть ли не единственным. Алгоритм,
который будет рассматривать вообще "весь" граф, будет работать слишком долго. Причём в графе будет много тупиковых ветвей, куда заходить вообще не надо. Было бы здорово указать на это машине.


На помощь приходит алгоритм А* (A star), который по сути является Дейкстрой с одним отличием. А* вводит т.н. эвристическую функцию h оценки расстояния от текущей вершины до решения. Значение h складывается с текущей оценкой пути. Идея следующая если алгоритм заходит на одну из таких тупиковых ветвей, то большое значение h превысит текущее оптимальное и алгоритм просто дальше не пойдёт. Нужно только придумать достаточно
"хорошую" эвристику. Я взял сумму расстояний ящиков до ближайших к ним меток. Класс AStarSolver реализует описанную логику. Код преводить не буду чтобы не загружать статью, всё есть по адресу.


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


public class Main {    public static void main(String[] args) {        final Sokoban sokoban = CharSokobanFactory.fromFile(args[0]).make();        final View view = new ConsoleView(sokoban);        final Solver solver = new AStarSolver();        final int sleepBetweenMovesMillis = 300;        final Controller game = new AIController(sokoban, view, sleepBetweenMovesMillis, solver);        // запуск        game.run();    }}

Выводы


Мы создали свою реализацию игрушки Сокобан, применили на практике шаблоны проектирования "Абстрактная Фабрика", "Фабричный Метод", "Стратегия", рассмотрели принципы S, O и D из SOLID и реализовали алгоритмы BFS и A*.


Буду рад любым комментириям как по коду, так и по самой статье. Увидимся!


Я в телеграмме: @outofbound

Подробнее..

Категории

Последние комментарии

  • Имя: Макс
    24.08.2022 | 11:28
    Я разраб в IT компании, работаю на арбитражную команду. Мы работаем с приламы и сайтами, при работе замечаются постоянные баны и лаги. Пацаны посоветовали сервис по анализу исходного кода,https://app Подробнее..
  • Имя: 9055410337
    20.08.2022 | 17:41
    поможем пишите в телеграм Подробнее..
  • Имя: sabbat
    17.08.2022 | 20:42
    Охренеть.. это просто шикарная статья, феноменально круто. Большое спасибо за разбор! Надеюсь как-нибудь с тобой связаться для обсуждений чего-либо) Подробнее..
  • Имя: Мария
    09.08.2022 | 14:44
    Добрый день. Если обладаете такой информацией, то подскажите, пожалуйста, где можно найти много-много материала по Yggdrasil и его уязвимостях для написания диплома? Благодарю. Подробнее..
© 2006-2024, personeltest.ru