Algoritmo para otimizar um jogo de correspondência com fila conhecida

10

Estou tentando escrever um solucionador em C # .NET para um jogo conhecido como Flowerz. Para sua referência, você pode reproduzi-lo no MSN, aqui: http://zone.msn.com/gameplayer/gameplayer.aspx?game=flowerz . Estou escrevendo por diversão, não para qualquer tipo de tarefa ou qualquer coisa relacionada ao trabalho. Por esse motivo, o único limite é o meu computador (um núcleo intel i7, com 8 GB de RAM). Não precisa ser executado em nenhum outro lugar, no que me diz respeito.

Em suma, suas regras são assim:

  • Há uma fila cheia de flores coloridas. Seu comprimento é arbitrário
    • A fila não pode ser influenciada
    • A fila é gerada no início do nível
  • As flores têm uma ou duas cores.
    • Se houver duas cores, haverá uma cor externa e uma cor interna. No caso de duas cores, a cor externa é usada para correspondência.
    • Se houver uma correspondência, a cor externa desaparecerá e a flor agora será uma única cor com a mesma cor da flor interna
  • O objetivo do jogo é criar partidas de três (ou mais) da mesma cor
    • Quando uma flor de uma única cor faz parte de uma partida, é removida do campo de jogo, criando um espaço vazio
    • Você pode combinar uma flor de cor única com a cor externa de uma flor de duas cores. Nesse caso, a flor de uma cor desaparece, a cor externa da flor de duas cores desaparece e a cor interna permanece
  • Você vence a rodada quando a fila está vazia e há pelo menos um espaço vazio sobrando
  • Jogos em cascata são possíveis. Uma cascata ocorre quando três (ou mais) flores externas desaparecem e quando suas cores internas formam outra cadeia de 3 (ou mais flores).
  • O campo de jogo é sempre 7x7
  • Alguns espaços no campo são cobertos por pedras
    • Você não pode colocar flores nas rochas
  • A fila também pode conter uma pá que você pode usar para mover qualquer flor colocada para um espaço desocupado
    • Você precisa usar a pá, mas na verdade não precisa mover a flor: é perfeitamente legal colocá-la de volta de onde veio
  • A fila também pode conter uma borboleta colorida. Quando você usa essa borboleta em uma flor, a flor fica com a cor da borboleta
    • A aplicação de uma borboleta a uma flor com duas cores resulta na obtenção de apenas uma única cor, a saber, a da borboleta
    • Você pode desperdiçar a borboleta em um espaço vazio ou em uma flor que já tenha essa cor
  • Limpar o campo não ganha o jogo

O objetivo do solucionador é simples: encontre uma maneira de esvaziar a fila, com o maior número possível de espaços restantes no campo de jogo. Basicamente, a IA joga o jogo para mim. A saída do solucionador é uma lista com os movimentos encontrados. Não estou interessado em marcar, mas em sobreviver o maior tempo possível, portanto, estou interessado nos movimentos que deixam o maior número possível de espaços abertos.

Desnecessário dizer que o espaço de pesquisa cresce rapidamente quanto maior a fila, de modo que uma força bruta está fora de questão. A fila começa às 15 e cresce com 5 a cada dois ou três níveis, se bem me lembro. E, é claro, colocar a primeira flor em (0,0) e a segunda em (0,1) é diferente de colocar a primeira em (1,0) e a segunda flor em (0,0), especialmente quando o campo já está preenchido com flores de uma rodada anterior. Uma decisão tão simples pode fazer a diferença em fazê-lo ou não.

As perguntas que tenho são as seguintes:

  • Que tipo de problema é esse? (pense em vendedor ambulante, mochila ou algum outro problema combinatório). Saber disso poderia tornar meu Google-fu um pouco melhor.
  • Que tipo de algoritmo poderia me dar bons resultados, rápido?

Com relação a este último: no começo, tentei escrever meu próprio algoritmo heurístico (basicamente: como eu o resolveria se eu conhecesse a fila?), Mas isso resulta em muitos casos extremos e correspondência de pontuação que posso perder.

Eu estava pensando em usar um algoritmo genético (porque pelo menos sei como usá-lo ...), mas estou tendo alguns problemas para decidir sobre uma representação binária do quadro. Depois, há o problema do cruzamento, mas isso pode ser resolvido com um operador de cruzamento ordenado ou um tipo semelhante de operação.

Meu palpite é que o solucionador deve sempre conhecer a configuração da placa e a fila que está tentando esvaziar.

Conheço alguns outros algoritmos heurísticos, como redes neurais e sistemas lógicos nebulosos, mas não tenho experiência para saber qual é o melhor aplicável ou se existem outros que são mais adequados para a tarefa em questão.

user849924
fonte
Certa vez, descobri que o espaço de pesquisa de um jogo complexo em que eu trabalhava seria 32 GB. Na época (eu tinha uma unidade de disco de 20 Mb), isso seria inviável, mas hoje em dia é praticamente factível na RAM para alguns computadores.
31513 Jonathan
Flores com apenas uma cor desaparecem completamente quando combinadas? E as flores com duas cores podem combinar sua camada externa com a cor única de uma flor de uma cor? Presumo então em ambos os casos, mas estes nunca são explicitamente especificado na descrição do problema ...
Steven Stadnicki
@StevenStadnicki Thanks! Adicionei essas informações à pergunta original.
User849924
1
Como uma pequena observação, aliás, é extremamente provável que a versão 'booleana' desse problema (existe alguma maneira de colocar as flores na fila para deixar o quadro completamente vazio no final?) É NP-completo; possui semelhanças óbvias com o problema Clickomania ( erikdemaine.org/clickomania ), que é NP completo, e o problema não é mais difícil que o NP, porque, dada uma solução pretendida (de comprimento polinomial), é fácil verificar apenas executando a simulação. Isso significa que o problema de otimização provavelmente está no FP ^ NP.
Steven Stadnicki

Respostas:

9

À primeira vista , isso me parece um problema de busca de um único agente . Ou seja: você tem um agente (o "jogador" da IA). Há um estado do jogo que representa o estado do tabuleiro e da fila do jogo, e você tem uma função sucessora que pode gerar novos estados a partir de um determinado estado.

Há também um critério de objetivo que informa quando o estado é "resolvido". E um custo de caminho - o custo de avançar para um determinado estado (sempre "1 movimento" neste caso).

Um quebra-cabeça prototípico desse tipo é o 15 Puzzle . E a maneira típica de resolvê-lo é com uma pesquisa informada - por exemplo, a pesquisa heurística clássica A * e suas variantes.


No entanto, há um problema com essa abordagem à primeira vista. Algoritmos como A * são projetados para fornecer o caminho mais curto para um objetivo (por exemplo: menor número de movimentos). No seu caso, o número de movimentos é sempre fixa - não há caminho mais curto - para uma busca heurística vai apenas dar-lhe um caminho para um jogo completo.

O que você quer é uma sequência de movimentos que lhe proporcionem o melhor estado de jogo concluído.

Então, o que você deve fazer é mudar um pouco o problema. Em vez de o tabuleiro de jogo ser o "estado", a sequência de movimentos se torna o "estado". (Ou seja: coloque os itens na fila nas posições "D2, A5, C7, B3, A3, ...")

Isso significa que realmente não nos importamos como esses estados são gerados. O próprio conselho é incidental, necessário apenas para avaliar a qualidade de um determinado estado.

Isso transforma o problema em um problema de otimização , que pode ser resolvido com um algoritmo de pesquisa local (que basicamente significa criar estados em torno de um determinado estado e selecionar o melhor estado, sem se preocupar com o caminho entre os estados).

O quebra-cabeça prototípico desse tipo é o quebra-cabeça das Oito Rainhas .

Nesta classe de problema, você está pesquisando no espaço de estados para encontrar uma boa solução, onde "bom" é avaliado por uma função objetivo (também chamada de função de avaliação ou, para algoritmos genéticos, uma função de adequação ).

Para o seu problema, uma função objetivo pode retornar um valor entre 0 e N, para o número de itens na fila que foram usados ​​antes de atingir um estado de falha (em que N é o comprimento da fila). E, caso contrário, um valor de N + M, em que M é o número de espaços em branco deixados no quadro após a fila estar vazia. Como tal - quanto maior o valor, "objetivamente melhor" a solução.

(Vale a pena notar, neste ponto, que você deve otimizar a porcaria do código que executa o jogo - que transforma um estado em um quadro pronto que pode ser usado para a função objetivo).


Quanto aos exemplos de algoritmos de pesquisa local : O padrão básico é uma pesquisa de escalada que pega um determinado estado, o modifica e se move para o próximo estado que fornece um resultado melhor.

Obviamente, isso pode ficar preso nos máximos locais (e similares). Nesta forma, é chamada de pesquisa local gananciosa . Existem várias variações para lidar com esse e outros problemas (a Wikipedia abordou ). Alguns dos quais (por exemplo: busca por feixe local ) acompanham vários estados ao mesmo tempo.

Uma variação específica disso é o algoritmo genético ( Wikipedia ). As etapas básicas para um algoritmo genético são:

  1. Determine uma maneira de converter um estado em uma sequência de algum tipo. No seu caso, pode ser uma sequência de dígitos do tamanho da fila de 1 a 49 (representando todos os canais possíveis em uma placa 7x7, provavelmente armazenados 1 byte cada). (Sua peça "spade" pode ser representada por duas entradas subsequentes na fila, para cada fase da movimentação.)
  2. Selecione aleatoriamente uma população reprodutora, com maior probabilidade de estados com melhor condicionamento físico . A população reprodutora deve ter o mesmo tamanho da população original - você pode escolher estados da população original várias vezes.
  3. Emparelhe estados na população reprodutora (primeiro vai com o segundo, terceiro vai com o quarto, etc.)
  4. Selecione aleatoriamente pontos de cruzamento para cada par (uma posição na sequência).
  5. Crie dois filhos para cada par trocando a parte da sequência após o ponto de cruzamento.
  6. Mude aleatoriamente cada um dos estados da prole. Por exemplo: escolha aleatoriamente alterar uma posição aleatória na sequência para um valor aleatório.
  7. Repita o processo com a nova população até que a população converja em uma ou mais soluções (ou após um determinado número de gerações ou seja encontrada uma solução suficientemente boa).

Parece que uma solução de algoritmo genético pode ser apropriada para o seu problema - com alguns ajustes. A maior dificuldade que vejo é que, com a representação das cordas acima, você descobrirá que mudar as metades traseiras de estados com metades frontais muito diferentes provavelmente resultará em estados "mortos" (devido a movimentos conflitantes entre as duas metades, esse resultado em uma baixa pontuação de condicionamento físico).

Talvez seja possível superar esse problema. Uma idéia que vem à mente é tornar mais provável que estados com metades da frente semelhantes se tornem pares reprodutores. Isso pode ser tão simples quanto classificar a população reprodutora dos estados, antes de emparelhá-los. Também pode ajudar a mover gradualmente a posição provável do cruzamento, do início ao fim da cadeia, à medida que o número de geração aumenta.

Também pode ser possível apresentar uma representação de movimentos dentro de um estado que é mais resistente (talvez até totalmente imune) a encontrar o estado de falha "quadrado está cheio". Talvez representando movimentos como coordenadas relativas do movimento anterior. Ou, com movimentos, selecione o espaço vazio mais próximo da posição especificada.

Como em todos os problemas de IA não triviais como esse, será necessário algum conserto significativo.

E, como mencionei antes, o outro grande desafio é simplesmente otimizar sua função objetivo. Tornar isso mais rápido permitirá pesquisar uma grande quantidade de espaço e buscar soluções para jogos com filas mais longas.


Para esta resposta, particularmente para acertar toda a terminologia, tive que desenterrar meu livro de IA da universidade, "Inteligência Artificial: Uma Abordagem Moderna", de Russell e Norvig. Não tenho certeza se é "bom" (não tenho outros textos de IA para compará-lo), mas não é ruim. Pelo menos é bem grande;)

Andrew Russell
fonte
Também identifiquei esse problema com um cruzamento: é muito possível que uma criança tenha mais itens colocados do que disponíveis na fila (tipo de falta de GA para TSP: ele pode visitar cidades duas ou mais vezes (ou não!) Depois de uma Talvez um cruzamento ordenado ( permutationcity.co.uk/projects/mutants/tsp.html ) possa funcionar. Isso é especialmente aplicável quando você faz a sequência de movimentos no estado.
user849924
Não tenho certeza se isso está certo - na minha opinião, o estado de falha é que uma peça é colocada em uma posição que já está ocupada (terminando assim o jogo mais cedo, resultando em uma baixa pontuação de condicionamento físico). Portanto, o comprimento da fila corresponde ao comprimento da cadeia genética - nunca é o comprimento errado. Ainda assim - você pode estar interessado em algo com a ideia de trocar e fazer pedidos. Se uma determinada ordem resultar em um jogo completo e você trocar dois movimentos, imagino que haja uma chance muito maior de o estado mutado também ser um jogo completo do que se você simplesmente definisse as posições de um (ou dois?) Movimentos aleatoriamente .
Andrew Russell
O estado de falha é quando você não tem mais opções para fazer movimentos, ou seja, quando você fica sem espaços vazios e nenhuma correspondência ocorre com esse movimento. Semelhante ao que você está dizendo: você deve colocá-lo em uma posição que já esteja ocupada (mas isso só acontece quando não há mais lugares para começar). O crossover que publiquei pode ser interessante. O cromossomo A possui itens colocados em A1, B1, ..., G1, A2, B2 e C2, e o cromossomo B em G7 ... A7, G6, F6 e E6. Selecione alguns randoms de A e mantenha seu índice. Selecione o complemento de A em B e mantenha o índice e a mesclagem para um filho.
User849924
O "problema" desse cruzamento é que são permitidos vários movimentos no mesmo local. Mas isso deve ser facilmente solucionado com algo semelhante ao SimulateAutomaticChanges da solução de Stefan K: aplique o conjunto de movimentos / estado do filho ao estado base (basta aplicar todos os movimentos, um por um) do campo de jogo e se o estado de aceitação (fila vazia) ) não pode ser alcançado (porque você precisa colocar uma flor em um local ocupado), a criança é inválida e precisamos reproduzir novamente. Aqui é onde sua condição de falha aparece. Eu entendo essa agora, heh. : D
user849924
Estou aceitando isso como resposta, por duas razões. Primeiro: você me deu a idéia de que eu precisava para o GA trabalhar para esse problema. Segundo: você foi o primeiro. ; p
user849924
2

Categorização

A resposta não é fácil. A teoria dos jogos tem algumas classificações para os jogos, mas parece não haver uma combinação clara de 1: 1 para esse jogo com uma teoria especial. É uma forma especial de problema combinatório.

Não é um vendedor ambulante, que decidiria um pedido no qual você visita "nós" com algum custo para alcançar o próximo nó a partir do último. Você não pode reordenar a fila nem precisa usar todos os campos no mapa.

A mochila não corresponde porque alguns campos ficam vazios ao colocar alguns itens na "mochila". Portanto, talvez seja uma forma estendida disso, mas o mais provável é que os algoritmos não sejam aplicáveis ​​por causa disso.

A Wikipedia fornece algumas dicas sobre categorização aqui: http://en.wikipedia.org/wiki/Game_theory#Types_of_games

Eu o categorizaria como "problema de controle ideal em tempo discreto" ( http://en.wikipedia.org/wiki/Optimal_control ), mas não acho que isso o ajude.

Algoritmos

Caso você realmente conheça a fila completa, poderá aplicar algoritmos de pesquisa em árvore. Como você disse, a complexidade do problema cresce muito rapidamente com o comprimento da fila. Sugiro usar um algoritmo como "Pesquisa de profundidade (DFS)", que não requer muita memória. Como a pontuação não importa para você, você pode parar depois de encontrar a primeira solução. Para decidir qual sub-ramificação pesquisar primeiro, você deve aplicar uma heurística ao pedido. Isso significa que você deve escrever uma função de avaliação (por exemplo: número de campos vazios; quanto mais sofisticado esse for, melhor), que fornece uma pontuação para comparar qual passo seguinte é o mais promissor.

Você precisa apenas das seguintes partes:

  1. modelo do estado do jogo, que armazena todas as informações do jogo (por exemplo, status / mapa do tabuleiro, fila, mover número / posição na fila)
  2. um gerador de movimentos, que fornece todos os movimentos válidos para um determinado estado do jogo
  3. uma função "mover" e uma "desfazer movimento"; que aplicam / desfazem uma determinada movimentação (válida) para um estado de jogo. Enquanto a função "mover" deve armazenar algumas informações "desfazer" para a função "desfazer". Copiar o estado do jogo e modificá-lo em cada iteração diminui significativamente a pesquisa! Tente pelo menos armazenar o estado na pilha (= variáveis ​​locais, sem alocação dinâmica usando "novo").
  4. uma função de avaliação, que fornece uma pontuação comparável para cada estado do jogo
  5. função de pesquisa

Aqui está uma implementação de referência incompleta para a pesquisa profunda:

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;
    }
}

Este código não está verificado para funcionar, nem é compilável ou completo. Mas isso deve lhe dar uma idéia de como fazê-lo. O trabalho mais importante é a função de avaliação. Quanto mais sofisticado, as "tentativas" erradas o algoritmo tentará (e precisará desfazer) mais tarde. Isso reduz extremamente a complexidade.

Se isso for muito lento, você também pode tentar aplicar alguns métodos de jogos para duas pessoas como HashTables. Para isso, você terá que calcular uma chave de hash (iterativa) para cada estado do jogo que avaliar e marcar estados que não levam a uma solução. Por exemplo, toda vez que o método Search () retornar "null", uma entrada HashTable deve ser criada e, ao entrar em Search (), você verificaria se esse estado já foi alcançado até agora sem resultado positivo e, em caso afirmativo, retornará "null" sem Investigação aprofundada. Para isso, você precisará de uma enorme tabela de hash e terá que aceitar "colisões de hash", o que pode causar o provavelmente não encontrar uma solução existente, mas isso é muito improvável, se suas funções de hash forem boas o suficiente e sua tabela for grande o suficiente (é um risco de risco calculável).

Eu acho que não há outro algoritmo para resolver esse problema (conforme descrito por você) mais eficiente, assumindo que sua função de avaliação é ideal ...

SDwarfs
fonte
Sim, eu posso conhecer a fila completa. Uma implementação da função de avaliação também consideraria um posicionamento válido, mas potencialmente ruim? É potencialmente ruim ser um movimento como colocá-lo ao lado da flor de uma cor diferente quando já existe uma cor semelhante no campo? Ou colocar uma flor em algum lugar que bloqueie uma combinação totalmente diferente por falta de espaço?
User849924
Essa resposta me deu idéias para o modelo e como trabalhar com as regras do jogo, então eu vou votá-lo. Obrigado pela sua contribuição!
user849924
@ user849924: Sim, é claro que a função de avaliação deve calcular um "valor" de avaliação para isso. Quanto mais o estado atual do jogo piorar (quase perdendo), pior será o valor da avaliação retornada. A avaliação mais fácil seria retornar o número de campos vazios. Você pode melhorar isso adicionando 0,1 para cada flor colocada ao lado de uma flor de cor semelhante. Para verificar sua função, escolha alguns estados aleatórios do jogo, calcule seu valor e compare-os. Se você acha Estado A é melhor do que estado B, a pontuação tona Um deve ser melhor do que aquele para o B.
SDwarfs