Цель этой статьи — пробудить интерес читателей к удивительному миру и показать различные способы решения таких же интересных головоломок, как «Пятнашки». Создайте свою базу данных с шаблонами и начните решать головоломки менее чем за 50 миллисекунд. Материалом делимся к старту курса по разработке на C#. Искусственный интеллект связан с процессом разработки неживого рационального агента. Рациональный агент — это сущность, способная воспринимать окружение и, исходя из полученных представлений, действовать рационально. Под рациональностью здесь понимается принятие агентом оптимальных решений для достижения наилучшего ожидаемого результата. Люди — лучший пример живых рациональных агентов, постоянно получающих информацию из своего окружения и реагирующих на неё принятием решений, которые обычно идут на пользу. Человеческий интеллект остаётся самым способным, сложным, совершенным в известной нам части Вселенной. А если это так и в ближайшем будущем вряд ли что-либо сравнится с ним по мощи, которой он сегодня обладает, то зачем создавать ИИ?  В некоторых специфических условиях реакция на поступающую из окружения информацию у ИИ лучше, чем у людей. ИИ способен за короткое время вычислять миллионы инструкций и выдавать осуществимое, а иногда оптимальное решение очень сложных задач.  Поэтому, хотя у людей в плане мышления интеллект более развит, в вычислениях они компьютерам уступают и именно в вычислительной среде создают ИИ, способный ускорить процесс получения максимального результата. Люди не способны мгновенно и правильно выполнять сложные вычисления, а калькулятор облегчает им жизнь. Можно привести другой пример ИИ с использованием рационального агента — программы на C# для решения головоломки «Пятнашки». Рациональность агента здесь обеспечивается алгоритмом поиска A*, который связан с эвристикой, направляющей поиск к максимальному результату. Считается, что головоломку «Пятнашки» в 1870-х гг. создал шахматист и составитель шахматных задач Сэм Лойд (1841–1911). Она состоит из игрового поля размером N x M (рис. 1), в каждой клетке которого может быть всё что угодно: цифра, буква, изображение и т. д.: Цель головоломки — переставить клетки из начального состояния так, чтобы все они в итоге располагались в соответствии с конфигурацией целевого (рис. 2). Целевое состояние достигается за несколько ходов. Каждый ход — это замена пустой клетки на соседнюю. Вот решение предыдущего примера: Чтобы достичь цели необходима последовательность движений; один ход — это замена пустой плитки на другую плитку. Предыдущий пример решается, как показано на рисунке 3. У читателя может возникнуть логичный вопрос: возможно ли вообще из начального состояния ниже достичь целевого? Сэм Лойд однажды пообещал $1000 любому, кто решит эту головоломку: Кое-кто утверждал, что нашёл решение, но на самом деле решения нет. Как же узнать, есть ли решение у исходной конфигурации? Доказано, что конфигурация имеет решение при выполнении следующих условий: Если ширина поля — нечётное число, для нахождения решения нужно сделать чётное число перестановок. Если ширина поля — чётное число, для нахождения решения нужно сделать: а) чётное число перестановок, если пустая клетка находится на нечётной строке, считая снизу; б) нечётное число перестановок, если пустая клетка находится на чётной строке, считая снизу. Перестановка происходит, когда за одной клеткой следует другая с меньшим значением. В целевом состоянии перестановок не будет. Например, в головоломке 4 х 4, где все числа идут по порядку, кроме числа 12 (оно в левом верхнем углу), будет 11 перестановок. В общем случае две клетки a и b меняются местами, если b идёт после a, но b меньше a. Агентом, который мы будем разрабатывать, выполняется поиск в пространстве состояний (пространстве всех возможных конфигураций игрового поля) с использованием информированного поиска A*. Поиск «информируется» при выборе следующего шага с учётом знаний предметной области, которые представлены значением функции, связанной с каждым состоянием задачи (рис. 5). Возможные состояния (вверх, вниз, влево и вправо) соответствуют направлениям перемещения пустой клетки: Ключевой элемент A* находится в значении, которое присваивается каждому состоянию и даёт возможность резко сократить время на поиск целевого состояния из заданного начального состояния (рис. 6). Функция, которой выводится значение, привязанное к каждому состоянию s, раскладывается так: где g — это стоимость достижения s из начального состояния (число ходов от начального состояния до s), h — рациональный компонент агента (эвристика), позволяющий определять стоимость всех возможных путей, начинающихся в s: Эвристической функцией к агенту привязываются эмпирические правила. Поэтому, чем лучше добавляемые правила, тем скорее достигается целевое состояние. Функция MisplacedTiles(s), которой выводится число неправильно расположенных клеток для состояния s, может считаться потенциально эвристической: по ней всегда понятно, насколько далеко мы от целевого состояния. При создании или включении эвристики важна её допустимость, необходимая, чтобы гарантировать полноту и оптимальность алгоритма A*, то есть что алгоритм находит решение и это решение оптимальное. Эвристика допустима, если минимальная стоимость достижения целевого состояния из узла s никогда не завышается: её значение не должно быть больше стоимости кратчайшего пути от s до целевого состояния. Эвристика неправильно расположенных клеток допустима, ведь каждая из них хотя бы раз должна быть перемещена, чтобы правильно расположить их. Как показано на рисунке 6, набор состояний можно смоделировать в виде графа, где дочерние элементы узла состояния получаются перемещением пустой клетки во всех возможных направлениях. Чтобы определить целевое состояние, в такой графовой задаче логично использовать алгоритм поиска по графу. Основа алгоритма A* — это поиск в ширину. При поиске в ширину значение всех узлов равно 1, поэтому не важно, какой узел расширять: в A* всегда расширяется узел, которым максимизируется нужный результат. В случае с головоломкой это узел с минимальной стоимостью, то есть кратчайшим путём до целевого состояния. На рисунке 7 показано выполнение алгоритма A* (в качестве эвристической функции используются неправильно расположенные клетки): Важно отметить, что при вычислении любой эвристики пустая клетка никогда не учитывается. Иначе реальная стоимость кратчайшего пути до целевого состояния может быть завышена, а эвристика станет недопустимой. Посмотрите, что бы произошло в предыдущем примере, если учесть пустую клетку: В предыдущем узле мы всего в шаге от целевого состояния, но согласно эвристике до него остаётся не менее двух шагов (неправильно расположены 8-я и пустая клетка). Это явное завышение реальной стоимости, а потому недопустимо. Приступим к анализу части кода и рассмотрим класс StateNode в первом листинге, который понадобится для представления состояний или конфигураций на игровом поле. Листинг класса StateNode Первое, на что следует обратить внимание в классе StateNode, — это его дженерик-функция. Дженерик нужен, чтобы головоломка была с цифрами, буквами, изображениями и т. д., а универсальный тип T поддерживает любой из предыдущих типов. Нужно также, чтобы тип T был сравниваемым, это может оказаться полезным — даже необходимым — для проведения теста на решаемость. Помните, что нужно будет рассчитать число перестановок, сравнивая элементы. Также обратите внимание на допущение, что ширина и высота игрового поля одинаковы (n = m), а пустая клетка равна 0. Вот описание каждого поля и свойства: Value — это значение f = g + h; T[,] State — конфигурация или состояние, представленные данным узлом; EmptyCol — номер столбца, в котором находится пустая клетка; EmptyRow — номер строки, в которой находится пустая клетка; Depth — глубина этого состояния от начального; StrRepresentation — строковое представление конфигурации (то есть 1, 2, 3, 4, 5, 6, 7, 8, 0). Строится при создании объекта в конструкторе. Метод Print() в формате CSV выводит значения клеток на игровом поле, а свойство Size возвращает его размер. Учитывая, что мы наследуем от IComparable<StateNode>, нужно реализовать метод CompareTo, его логика проста. Класс AStar представлен во втором листинге. Листинг 2: класс AStar Снова описание каждого поля и свойства: StatesVisited указывает число посещённых во время поиска состояний, равное числу удалённых из очереди узлов; Словарь PatternDatabase используется в эвристике базы данных с шаблонами (подробнее о ней — позже), целью является цель StateNode; Empty это пустая клетка; queue — очередь с минимальным приоритетом, чтобы легко и эффективно получать из набора узлов узел состояния с минимальным значением f; hashSet — хеш-множество для хранения строкового представления каждого найденного состояния. Головоломка циклическая: начиная с исходного состояния, после ряда ходов мы можем оказаться в нём же. Чтобы избежать бесконечного цикла, каждое посещённое состояние сохраняем в хеше. Теперь рассмотрим методы Execute() и Expand (узел StateNode) в листингах 3 и 4. Остальное относится к эвристике и понадобится в конце при запуске алгоритма и сравнениях. Листинг 3: метод Execute Это очень простой метод, напоминает тело алгоритма поиска в ширину. Мы добавляем строковое представление начального состояния, а затем попадаем в цикл, который заканчивается, когда в _queue нет узлов. В текущей переменной всегда есть узел с минимальным значением, который и будет расширен. Расширение состоит из всевозможных перемещений от текущего состояния. Листинг 4: метод ExpandNode В каждом операторе if проверяется, возможно ли то или иное перемещение. Если да, клонируется текущее состояние (помните: массивы являются ссылочными типами), а затем пустая клетка меняется местами с клеткой, куда она перемещается. Помещаем строковое представление newNode в очередь и добавляем в хеш, если его там нет. Описав «скелет» алгоритма A*, переходим к главному компоненту поиска — эвристике. Листинг 5: эвристический метод Первую и, вероятно, самую простую эвристику для этой задачи (числа клеток, которые находятся не на своих местах) мы уже описали. В листинге 6 показана лаборатория для тестирования эвристики. Листинг 6: лаборатория эвристики (неправильно расположенные клетки) Для тестирования эвристики возьмём одну из худших конфигураций для игрового поля 3 x 3, показанную на рисунке 9. Для неё требуется 31 ход: Вот полученные результаты: В алгоритме A* с эвристикой неправильно расположенных клеток на поиск целевого состояния уходит ~2,5 с. Попробуем найти более умную эвристику с меньшим временем и числом посещённых узлов. Манхэттенское расстояние, или расстояние городских кварталов между точками A = (x1, y1) и B = (x2, y2), определяется как сумма абсолютной разности их соответствующих координат: В листинге 7 манхэттенское расстояние допустимо как эвристика: по каждой клетке возвращается минимальное число шагов для перемещения в её целевое положение. Листинг 7: эвристика 2 (манхэттенское расстояние) Вот результаты применения A* с манхэттенским расстоянием: Сокращение времени и посещённых узлов значительное — информация для поиска лучше, поэтому цель находится гораздо быстрее. В эвристике линейного конфликта даётся информация о необходимых ходах, которые не учитываются при расчёте манхэттенского расстояния. Клетки tj и tk находятся в линейном конфликте, если они и их целевые положения располагаются на одной линии, при этом tj — справа от tk, а целевое положение tj — слева от целевого положения tk. На рисунке 12 клетки 3 и 1 хотя и в одной строке, но не на своих местах: Чтобы привести их в целевые положения, нужно переместить одну из них вниз, а затем снова вверх (эти перемещения не учитываются в манхэттенском расстоянии). Клетка не может быть более чем в одном конфликте, ведь разрешение определённого конфликта может подразумевать разрешение других конфликтов в той же строке или столбце. Поэтому, если клетка 1 связана конфликтом с клеткой 3, она не может быть связана конфликтом с клеткой 2 — это может привести к завышению кратчайшего пути до целевого состояния и сделать эвристику недопустимой. Методы, реализующие эту эвристику, представлены в листинге 8. Листинг 8: метод LinearConflict Чтобы протестировать эвристику линейного конфликта, понадобится игровое поле 4 x 4 (рис. 13), где до целевого состояния нужно сделать 55 ходов. Значение узла s теперь будет определяться как f(s) = depth(s) + md(s) + lc(s). Объединим обе эвристики, ведь их ходы не пересекаются и завышения не будет: Вот результаты: Через пару минут алгоритм выдал результат. То же игровое поле протестировали с одним лишь манхэттенским расстоянием, и после 5-минутного ожидания результата получено не было. Эвристика базы данных с шаблонами (рис. 15) определяется базой данных разных состояний игры. Каждое состояние связано с минимальным числом ходов, необходимым для приведения шаблона (подмножества клеток) в его целевое положение. Для этого случая мы создали небольшую базу данных с шаблонами, проведя поиск в ширину в обратном направлении — начиная с целевого состояния (3 x 3). Результаты сохранили в файле .txt всего с 60 000 записей. Для базы данных выбрали шаблон fringe с клетками из верхней строки и самого левого столбца: Эвристическая функция базы данных с шаблонами в листинге 9 вычисляется с помощью функции табличного поиска. В данном случае это поиск по словарю с 60 000 сохранённых шаблонов. Он принципиально схож с техниками динамического программирования и подходом «разделяй и властвуй». Листинг 9: эвристическая функция базы данных с шаблонами Вот результаты: Чем больше записей в базе данных, тем меньше времени нужно на поиск целевого состояния. В нашем случае память и время сочетаются так, чтобы задействовать больший объём памяти для улучшения времени выполнения. Механизм работы здесь обычно такой: используется больше памяти, чтобы сократить время выполнения. Эвристика базы данных с шаблонами — это определяющая альтернатива при решении головоломки с игровым полем не менее чем 4 x 4. Арнальдо Перес Кастаньо — кубинский специалист компьютерных наук, автор серии книг по программированию (JavaScript Fácil, HTML, CSS Fácil и Python Fácil (Marcombo S.A.). Он имеет опыт работы с Visual Basic, C#, .NET Framework, ИИ и предлагает услуги фрилансера на nubelo.com. Его хобби — кино и музыка. Связаться с ним можно по адресу arnaldo.skywalker@gmail.com. А мы поможем вам прокачать скиллы или освоить новую профессию, которая останется актуальной в любое время: Профессия C#-разработчик Профессия Data Scientist Выбрать другую востребованную профессию. Data Science и Machine Learning Профессия Data Scientist Профессия Data Analyst Курс «Математика для Data Science» Курс «Математика и Machine Learning для Data Science» Курс по Data Engineering Курс «Machine Learning и Deep Learning» Курс по Machine Learning Python, веб-разработка Профессия Fullstack-разработчик на Python Курс «Python для веб-разработки» Профессия Frontend-разработчик Профессия Веб-разработчик Мобильная разработка Профессия iOS-разработчик Профессия Android-разработчик Java и C# Профессия Java-разработчик Профессия QA-инженер на JAVA Профессия C#-разработчик Профессия Разработчик игр на Unity От основ — в глубину Курс «Алгоритмы и структуры данных» Профессия C++ разработчик Профессия Этичный хакер А также Курс по DevOps Все курсы