Алгоритм оптимизации матчевой игры с известной очередью

10

Я пытаюсь написать решатель на C # .NET для игры под названием Flowerz. Для справки, вы можете сыграть в MSN здесь: http://zone.msn.com/gameplayer/gameplayer.aspx?game=flowerz . Я пишу это для удовольствия, а не для какого-либо задания или чего-либо связанного с работой. Из-за этого единственным ограничением является мой компьютер (ядро Intel i7 с 8 ГБ ОЗУ). Насколько мне известно, ему не нужно никуда бежать.

Короче, его правила таковы:

  • Там очередь заполнена цветными цветами. Его длина произвольна
    • На очередь нельзя повлиять
    • Очередь генерируется в начале уровня
  • Цветы имеют один или два цвета.
    • Если есть два цвета, то есть внешний цвет и внутренний цвет. В случае двух цветов внешний цвет используется для соответствия.
    • Если есть совпадение, то внешний цвет исчезает, и цветок теперь является цветком одного цвета того же цвета, что и внутренний цветок.
  • Цель игры - создать спички из трех (или более) одного цвета
    • Когда цветок одного цвета является частью матча, он удаляется с игрового поля, создавая пустое пространство
    • Вы можете сопоставить один цветной цветок с внешним цветом двухцветного цветка. В этом случае исчезает один цветной цветок, внешний цвет двухцветного цветка исчезает, а внутренний цвет остается
  • Вы выигрываете раунд, когда очередь пуста, и осталось хотя бы одно пустое место
  • Возможны каскадные матчи. Каскад - это когда три (или более) внешних цветка исчезают, и когда их внутренние цвета образуют другую цепочку из 3 (или более цветов).
  • Игровое поле всегда 7х7
  • Некоторые места на поле покрыты камнями
    • Вы не можете поместить цветы на скалах
  • Очередь также может содержать лопату, которую можно использовать для перемещения любого размещенного цветка в незанятое пространство.
    • Вы должны использовать лопату, но на самом деле вам не нужно перемещать цветок: совершенно законно положить его обратно туда, откуда он пришел
  • Очередь также может содержать цветную бабочку. Когда вы используете эту бабочку на цветке, тогда цветок приобретает цвет бабочки
    • Применение бабочки к цветку с двумя цветами приводит к тому, что цветок приобретает только один цвет, а именно цвет бабочки.
    • Вы можете тратить бабочки на пустое место или цветок, который уже имеет этот цвет
  • Очистка поля не выигрывает игру

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

Излишне говорить, что пространство поиска быстро увеличивается с увеличением очереди, поэтому о грубой силе не может быть и речи. Очередь начинается с 15 и увеличивается с 5 каждые два или три уровня, если я правильно помню. И, конечно же, размещение первого цветка в (0,0), а второго в (0,1) отличается от размещения первого в (1,0) и второго цветка в (0,0), особенно когда поле уже заполнено цветами из более раннего раунда. Такое простое решение может иметь значение при принятии или нет.

У меня есть следующие вопросы:

  • Что это за проблема? (подумайте о коммивояжере, рюкзаке или о какой-то другой комбинаторной проблеме). Знание этого может сделать мой Google-фу чуть лучше.
  • Какой алгоритм может дать мне хорошие результаты, быстро?

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

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

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

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

user849924
источник
Однажды я понял, что пространство поиска какой-нибудь сложной игры, над которой я работал, будет 32 Гб. В то время (у меня был дисковод на 20 Мб) это было бы невозможно, но в наши дни это почти выполнимо в ОЗУ для некоторых компьютеров.
Джонатан
Цветы только одного цвета полностью исчезают при совпадении? И могут ли цветы с двумя цветами сопоставить их внешний слой с одним цветом одноцветного цветка? Я полагаю, что по обоим пунктам, но они никогда явно не указаны в описании проблемы ...
Стивен Стадницки
@StevenStadnicki Спасибо! Я добавил эту информацию к первоначальному вопросу.
user849924
1
В качестве небольшой заметки, между прочим, в подавляющем большинстве случаев вполне вероятно, что «булева» версия этой проблемы (есть ли какой-нибудь способ поместить цветы в очередь, чтобы доска оставалась полностью пустой в конце?), Является NP-полной; она имеет очевидное сходство с проблемой Clickomania ( erikdemaine.org/clickomania ), которая является NP-полной, и проблема не сложнее, чем NP, потому что, учитывая предполагаемое решение (полиномиальной длины), это легко проверить, просто запустив симуляцию. Это означает, что проблема оптимизации, вероятно, в FP ^ NP.
Стивен Стадницки

Ответы:

9

На первый взгляд , мне кажется, это проблема поиска одного агента . То есть: у вас есть один агент (ИИ «игрок»). Существует игровое состояние, представляющее состояние игрового поля и очереди, и у вас есть функция-преемник, которая может генерировать новые состояния из данного состояния.

Есть также критерий цели, который говорит вам, когда состояние является «решенным» состоянием. А стоимость пути - стоимость перехода в заданное состояние (в данном случае всегда «1 ход»).

Одной из типичных головоломок такого типа является 15 Puzzle . И типичный способ ее решения - это информированный поиск - например, классический эвристический поиск A * и его варианты.


Однако есть проблема с этим на первый взгляд подходом. Алгоритмы типа A * предназначены для того, чтобы дать вам кратчайший путь к цели (например, наименьшее количество ходов). В вашем случае, количество ходов всегда фиксировано - нет кратчайшего пути - так эвристический поиск будет просто дать вам на пути к более завершенной игре.

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

Итак, что вы должны сделать, это немного перевернуть проблему. Вместо игрового поля, являющегося «состоянием», последовательность ходов становится «состоянием». (То есть: поместить элементы в очередь в позиции «D2, A5, C7, B3, A3, ...»)

Это означает, что нам все равно, как генерируются эти состояния. Сама доска является второстепенной, требуется только для оценки качества данного состояния.

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

Прототипом головоломки такого рода является головоломка « Восемь королев» .

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

Для вашей задачи целевая функция может возвращать значение от 0 до N для количества элементов в очереди, которые были израсходованы до достижения состояния сбоя (где N - длина очереди). И, в противном случае, значение N + M, где M - количество пробелов, оставшихся на доске после того, как очередь пуста. Как таковое - чем выше значение, тем «объективно лучше» решение.

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


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

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

Одним из конкретных вариантов этого является генетический алгоритм ( Википедия ). Основные шаги для генетического алгоритма:

  1. Определите способ преобразования состояния в какую-либо строку. В вашем случае это может быть строка цифр длины очереди от 1 до 49 (представляющая все возможные размещения на доске 7x7, вероятно, сохраненные 1 байт каждое). (Ваша часть "лопаты" может быть представлена ​​двумя последующими записями очереди для каждой фазы перемещения.)
  2. Произвольно выбирайте размножающуюся популяцию, давая более высокую вероятность состояниям, которые лучше приспособлены . Размножающаяся популяция должна быть того же размера, что и исходная популяция - вы можете выбирать штаты из исходной популяции несколько раз.
  3. Соедините состояния в размножающейся популяции (первое идет со вторым, третье идет с четвертым и т. Д.)
  4. Произвольно выберите точки пересечения для каждой пары (позиция в строке).
  5. Создайте двух потомков для каждой пары, поменяв местами часть строки после точки пересечения.
  6. Произвольно мутирует каждое из состояний потомства. Например: произвольно выбрать, чтобы изменить случайную позицию в строке на случайное значение.
  7. Повторяйте этот процесс с новой популяцией, пока популяция не сойдет на одно или несколько решений (или после определенного числа поколений, или пока не будет найдено достаточно хорошее решение).

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

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

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

Как и в случае со всеми нетривиальными проблемами ИИ, подобными этой, это потребует значительных усилий.

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


Для этого ответа, в частности, чтобы понять всю терминологию, мне нужно было найти учебник по искусственному интеллекту университета «Искусственный интеллект: современный подход», разработанный Расселом и Норвигом. Не уверен, что это «хорошо» (у меня нет других текстов ИИ для сравнения), но это не плохо. По крайней мере, он довольно большой;)

Эндрю Рассел
источник
Я также идентифицировал эту проблему с кроссовером: вполне возможно, что у ребенка есть больше предметов, чем доступно в очереди (своего рода отсутствие GA для TSP: он может посещать города дважды или больше (или не посещать вообще!) После кроссовер. Возможно, сработал бы упорядоченный кроссовер ( permutationcity.co.uk/projects/mutants/tsp.html ). Это особенно применимо, когда вы выполняете последовательность перемещений состояния.
user849924
Не уверен, что это правильно - на мой взгляд, состояние неудачи состоит в том, что фигура помещается в положение, которое уже занято (таким образом, рано заканчивая эту игру, что приводит к низкому показателю пригодности). Таким образом, длина очереди соответствует длине генетической строки - она ​​никогда не бывает неправильной длины. Тем не менее - вы можете быть на что-то с идеей обмена и заказа. Если данный заказ приводит к завершенной игре, и вы меняете два хода, я полагаю, что гораздо более вероятно, что мутировавшее состояние также будет завершенной игрой, чем если бы вы просто устанавливали одну (или две?) Позиции случайным образом ,
Эндрю Рассел
Состояние сбоя - когда у вас больше нет вариантов размещения ходов, т. Е. Когда у вас закончились пустые места и с этим ходом не найдено совпадений. Подобно тому, что вы говорите: вы должны поместить его в положение, которое уже занято (но это верно только тогда, когда больше нет мест для начала). Кроссовер, который я разместил, может быть интересным. Хромосома А имеет элементы, расположенные на А1, В1, ..., G1, А2, В2 и С2, а хромосома В на G7 ... A7, G6, F6 и E6. Выберите несколько случайностей из A и сохраните их индекс. Выберите дополнение A из B и сохраните их индекс и объедините для ребенка.
user849924
«Проблема» в этом кроссовере состоит в том, что допускается несколько ходов в одном месте. Но это должно быть легко решаемо с помощью чего-то похожего на SimulateAutomaticChanges из решения Стефана К.: примените набор движений / состояние ребенка к базовому состоянию (просто примените все ходы, один за другим) игрового поля и, если это состояние принятия (пустая очередь) ) не может быть достигнуто (потому что вы должны поместить цветок на занятом месте), тогда ребенок становится инвалидом, и нам нужно будет снова размножаться. Вот где появляется ваше состояние отказа. Я понял это сейчас, хе. : D
user849924
Я принимаю это как ответ по двум причинам. Первое: вы дали мне идею, которая мне нужна, чтобы заставить Г.А. работать над этой проблемой. Второе: ты был первым. ; p
user849924
2

Категоризация

Ответ не прост. Теория игр имеет несколько классификаций для игр, но, похоже, не существует четкого соответствия 1: 1 для этой игры специальной теории. Это особая форма комбинаторной проблемы.

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

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

Википедия дает некоторые советы по классификации здесь: http://en.wikipedia.org/wiki/Game_theory#Types_of_games

Я бы классифицировал это как «проблему оптимального управления в дискретном времени» ( http://en.wikipedia.org/wiki/Optimal_control ), но я не думаю, что это поможет вам.

Алгоритмы

Если вы действительно знаете полную очередь, вы можете применить алгоритмы поиска по дереву. Как вы сказали, сложность проблемы растет очень быстро с длиной очереди. Я предлагаю использовать алгоритм типа «Поиск в глубину (DFS)», который не требует большого количества памяти. Поскольку для вас значение не имеет значения, вы можете просто остановиться, найдя первое решение. Чтобы решить, какую ветвь искать сначала, вы должны применить эвристику для упорядочения. Это означает, что вы должны написать функцию оценки (например: количество пустых полей; чем сложнее это поле, тем лучше), которая дает оценку, чтобы сравнить, какой следующий шаг является наиболее перспективным.

Тогда вам нужны только следующие части:

  1. модель состояния игры, в которой хранится вся информация об игре (например, статус доски / карта, очередь, номер хода / позиция в очереди)
  2. генератор ходов, который дает вам все действительные ходы для данного игрового состояния
  3. функция «сделать ход» и «отменить ход»; которые применяют / отменяют данный (действительный) переход в игровое состояние. Принимая во внимание, что функция «do move» должна хранить некоторую «информацию об отмене» для функции «отменить». Копирование состояния игры и его изменение в каждой итерации значительно замедляет поиск! Попробуйте хотя бы сохранить состояние в стеке (= локальные переменные, без динамического выделения с использованием «new»).
  4. функция оценки, которая дает сопоставимый счет для каждого игрового состояния
  5. функция поиска

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

public class Item
{
    // TODO... represents queue items (FLOWER, SHOVEL, BUTTERFLY)
}

public class Field
{
    // TODO... represents field on the board (EMPTY or FLOWER)
}

public class Modification {
    int x, y;
    Field originalValue, newValue;

    public Modification(int x, int y, Field originalValue, newValue) {
        this.x = x;
        this.y = y;
        this.originalValue = originalValue;
        this.newValue = newValue;
    }

    public void Do(GameState state) {
        state.board[x,y] = newValue;
    }

    public void Undo(GameState state) {
        state.board[x,y] = originalValue;
    }
}

class Move : ICompareable {

    // score; from evaluation function
    public int score; 

    // List of modifications to do/undo to execute the move or to undo it
    Modification[] modifications;

    // Information for later knowing, what "control" action has been chosen
    public int x, y;   // target field chosen
    public int x2, y2; // secondary target field chosen (e.g. if moving a field)


    public Move(GameState state, Modification[] modifications, int score, int x, int y, int x2 = -1, int y2 = -1) {
        this.modifications = modifications;
        this.score = score;
        this.x = x;
        this.y = y;
        this.x2 = x2;
        this.y2 = y2;
    }

    public int CompareTo(Move other)
    {
        return other.score - this.score; // less than 0, if "this" precededs "other"...
    }

    public virtual void Do(GameState state)
    {
        foreach(Modification m in modifications) m.Do(state);
        state.queueindex++;
    }

    public virtual void Undo(GameState state)
    {
        --state.queueindex;
        for (int i = m.length - 1; i >= 0; --i) m.Undo(state); // undo modification in reversed order
    }
}

class GameState {
    public Item[] queue;
    public Field[][] board;
    public int queueindex;

    public GameState(Field[][] board, Item[] queue) {
        this.board = board;
        this.queue = queue;
        this.queueindex = 0;
    }

    private int Evaluate()
    {
        int value = 0;
        // TODO: Calculate some reasonable value for the game state...

        return value;
    }

    private List<Modification> SimulateAutomaticChanges(ref int score) {
        List<Modification> modifications = new List<Modification>();
        // TODO: estimate all "remove" flowers or recoler them according to game rules 
        // and store all changes into modifications...
        if (modifications.Count() > 0) {
            foreach(Modification modification in modifications) modification.Do(this);

            // Recursively call this function, for cases of chain reactions...
            List<Modification> moreModifications = SimulateAutomaticChanges();

            foreach(Modification modification in modifications) modification.Undo(this);

            // Add recursively generated moves...
            modifications.AddRange(moreModifications);
        } else {
            score = Evaluate();
        }

        return modifications;
    }

    // Helper function for move generator...
    private void MoveListAdd(List<Move> movelist, List<Modifications> modifications, int x, int y, int x2 = -1, int y2 = -1) {
        foreach(Modification modification in modifications) modification.Do(this);

        int score;
        List<Modification> autoChanges = SimulateAutomaticChanges(score);

        foreach(Modification modification in modifications) modification.Undo(this);

        modifications.AddRange(autoChanges);

        movelist.Add(new Move(this, modifications, score, x, y, x2, y2));
    }


    private List<Move> getValidMoves() {
        List<Move> movelist = new List<Move>();
        Item nextItem = queue[queueindex];
        const int MAX = board.length * board[0].length + 2;

        if (nextItem.ItemType == Item.SHOVEL)
        {

            for (int x = 0; x < board.length; ++x)
            {
                for (int y = 0; y < board[x].length; ++y)
                {
                    // TODO: Check if valid, else "continue;"

                    for (int x2 = 0; x2 < board.length; ++x2)
                    {
                        for(int y2 = 0; y2 < board[x].length; ++y2) {
                            List<Modifications> modifications = new List<Modifications>();

                            Item fromItem = board[x][y];
                            Item toItem = board[x2][y2];
                            modifications.Add(new Modification(x, y, fromItem, Item.NONE));
                            modifications.Add(new Modification(x2, y2, toItem, fromItem));

                            MoveListAdd(movelist, modifications, x, y, x2, y2);
                        }
                    }
                }
            }

        } else {

            for (int x = 0; x < board.length; ++x)
            {
                for (int y = 0; y < board[x].length; ++y)
                {
                    // TODO: check if nextItem may be applied here... if not "continue;"

                    List<Modifications> modifications = new List<Modifications>();
                    if (nextItem.ItemType == Item.FLOWER) {
                        // TODO: generate modifications for putting flower at x,y
                    } else {
                        // TODO: generate modifications for putting butterfly "nextItem" at x,y
                    }

                    MoveListAdd(movelist, modifications, x, y);
                }
            }
        }

        // Sort movelist...
        movelist.Sort();

        return movelist;
    }


    public List<Move> Search()
    {
        List<Move> validmoves = getValidMoves();

        foreach(Move move in validmoves) {
            move.Do(this);
            List<Move> solution = Search();
            if (solution != null)
            {
                solution.Prepend(move);
                return solution;
            }
            move.Undo(this);
        }

        // return "null" as no solution was found in this branch...
        // this will also happen if validmoves == empty (e.g. lost game)
        return null;
    }
}

Этот код не проверен на работоспособность, не является ни компилируемым, ни полным. Но это должно дать вам представление, как это сделать. Наиболее важной работой является функция оценки. Чем сложнее, тем неправильнее «пытается» алгоритм попытается (и должен будет отменить) позже. Это чрезвычайно снижает сложность.

Если это слишком медленно, вы также можете попытаться применить некоторые методы игр с двумя людьми как HashTables. Для этого вам придется вычислять (итеративный) ключ хеш-функции для каждого оцениваемого состояния игры и отмечать состояния, которые не приводят к решению. Например, каждый раз, когда метод Search () возвращает «ноль», необходимо создать запись HashTable, и при входе в Search () вы будете проверять, достигнуто ли это состояние до сих пор без положительного результата, и в этом случае возвращать «ноль» без дальнейшее расследование. Для этого вам понадобится огромная хеш-таблица, и вам придется принимать «коллизии хешей», которые могут привести к тому, что вы, вероятно, не найдете существующее решение, но это очень маловероятно, если ваши хеш-функции достаточно хороши и ваша таблица достаточно большой (это риск вычислимого риска).

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

SDwarfs
источник
Да, я могу знать полную очередь. Будет ли реализация функции оценки также рассматривать правильное, но потенциально плохое размещение? Потенциально плохо, если вы будете двигаться рядом с цветком другого цвета, когда на поле уже есть такой же цвет? Или поместить цветок где-нибудь, где блоки совершенно другого соответствия из-за недостатка места?
user849924
Этот ответ дал мне идеи для модели и того, как работать с правилами игры, поэтому я буду голосовать за нее. Спасибо за ваш вклад!
user849924
@ user849924: Да, конечно, функция оценки должна рассчитать для нее «значение» оценки. Чем больше текущее игровое состояние становится хуже (близким к проигрышу), тем хуже должно быть возвращаемое оценочное значение. Самая простая оценка - вернуть количество пустых полей. Вы можете улучшить это, добавив 0,1 для каждого цветка, расположенного рядом с цветком аналогичного цвета. Чтобы проверить свою функцию, выберите несколько случайных игровых состояний, вычислите их значение и сравните их. Если вы считаете, что состояние A лучше, чем состояние B, то оценка A должна быть лучше, чем оценка для B.
SDwarfs