Estrutura de dados para jogos de tabuleiro bidimensionais em idiomas funcionais

9

Estou criando uma simples implementação MiniMax na linguagem de programação funcional Elixir. Como existem muitos jogos de conhecimento perfeito (jogo da velha, jogo quatro, damas, xadrez, etc), essa implementação pode ser uma estrutura para criar AIs de jogos para qualquer um desses jogos.

Um problema que estou enfrentando, no entanto, é como armazenar adequadamente um estado de jogo em uma linguagem funcional. Esses jogos lidam principalmente com tabuleiros de jogo bidimensionais, onde as seguintes operações são frequentes:

  • Leia o conteúdo de um local específico do quadro
  • Atualize o conteúdo de um local específico do quadro (ao retornar uma nova possibilidade de movimento)
  • Considerando o conteúdo de um ou mais locais conectados ao local atual (ou seja, o próximo ou anterior local horizontal, vertical ou diagonal)
  • Considerando o conteúdo de vários locais conectados em qualquer direção.
  • Considerando o conteúdo de arquivos inteiros, classificações e diagonais.
  • Girar ou espelhar a placa (para verificar simetrias que fornecem o mesmo resultado que algo já calculado).

A maioria das linguagens funcionais usa listas e tuplas vinculadas como blocos básicos de construção de estruturas de dados com vários elementos. No entanto, estes parecem muito mal feitos para o trabalho:

  • As listas vinculadas têm tempo de pesquisa O (n) (linear). Além disso, como não podemos 'digitalizar e atualizar o quadro' em uma única varredura, usar listas parece muito impraticável.
  • As tuplas têm tempo de pesquisa O (1) (constante). No entanto, representar o quadro como uma tupla de tamanho fixo dificulta a iteração sobre classificações, arquivos, diagonais ou outros tipos de quadrados consecutivos. Além disso, tanto Elixir, e Haskell (que são as duas línguas funcionais que eu conheço) falta sintaxe para ler o n º elemento de uma tupla. Isso tornaria impossível escrever uma solução dinâmica que funcionasse para placas de tamanho arbitrário.

O Elixir possui uma estrutura de dados de mapa incorporada (que a Haskell possui Data.Map) que permite acesso a O (log n) (logarítmico) aos elementos. Agora eu uso um mapa, com x, ytuplas que representam a posição como chaves.

Isso 'funciona', mas parece errado abusar dos mapas dessa maneira, embora eu não saiba exatamente o porquê. Estou procurando uma maneira melhor de armazenar um tabuleiro de jogo bidimensional em uma linguagem de programação funcional.

Qqwy
fonte
11
Não posso falar sobre práxis, mas Haskell me vem à mente de duas coisas: zíperes , permitindo "etapas" em tempo constante sobre estruturas de dados, e comônadas, que são relacionadas a zíperes por alguma teoria que não me lembro nem entendo adequadamente; )
phipsgabler 15/06
Qual é o tamanho deste tabuleiro? Big O caracteriza como um algoritmo é escalonado, não sua velocidade. Em um quadro pequeno (digamos, menos de 100 em cada direção), é improvável que O (1) vs. O (n) importe muito, se você tocar em cada quadrado apenas uma vez.
Robert Harvey
@RobertHarvey Isso varia. Mas, para dar um exemplo: no xadrez, temos um tabuleiro de 64x64, mas todos os cálculos para verificar quais movimentos são possíveis e para determinar o valor heurístico da posição atual (diferença no material, rei ou xeque, peões passados ​​etc.) todos precisam acessar os quadrados do tabuleiro.
Qqwy
11
Você tem um tabuleiro 8x8 no xadrez. Em uma linguagem mapeada em memória como C, você pode fazer um cálculo matemático para obter o endereço exato de uma célula, mas isso não é verdade em linguagens gerenciadas por memória (onde o endereçamento ordinal é um detalhe da implementação). Não me surpreenderia se pular (no máximo) 14 nós levasse aproximadamente a mesma quantidade de tempo que o endereçamento de um elemento de matriz em uma linguagem gerenciada por memória.
Robert Harvey
Veja também stackoverflow.com/q/9611904/124319
coredump 15/16

Respostas:

6

A Mapé precisamente a estrutura de dados de base correta aqui. Não sei por que isso a deixaria desconfortável. Tem bons tempos de pesquisa e atualização, é dinâmico em tamanho e é muito fácil criar estruturas de dados derivadas. Por exemplo (em haskell):

filterWithKey (\k _ -> (snd k) == column) -- All pieces in given column
filterWithKey (\k _ -> (fst k) == row)    -- All pieces in given row
mapKeys (\(x, y) -> (-x, y))              -- Mirror

A outra coisa que é frequentemente difícil para os programadores entenderem quando começam a programar com imutabilidade total é que você não precisa se ater a apenas uma estrutura de dados. Você geralmente escolhe uma estrutura de dados como sua "fonte da verdade", mas pode criar quantos derivados quiser, até derivados de derivados, e sabe que eles permanecerão sincronizados pelo tempo que você precisar.

Isso significa que você pode usar um Mapno nível mais alto, alternar para Listsou Arrayspara análise de linha, Arraysindexar o outro caminho para análise de colunas, máscaras de bits para análise de padrões e, em seguida, Stringspara exibição. Programas funcionais bem projetados não passam uma única estrutura de dados. Eles são uma série de etapas que incorporam uma estrutura de dados e emitem uma nova adequada para a próxima etapa.

Desde que você possa sair do outro lado com uma mudança em um formato que o nível superior possa entender, não precisa se preocupar com a reestruturação dos dados. É imutável, por isso é possível traçar um caminho de volta à fonte da verdade no nível superior.

Karl Bielefeldt
fonte
4

Fiz isso recentemente em F # e acabei usando uma lista unidimensional (em F #, essa é uma lista de link único). Na prática, a velocidade do indexador da lista O (n) não é um gargalo para tamanhos de placas utilizáveis ​​por humanos. Eu experimentei outros tipos, como matriz 2D, mas no final, foi o compromisso de escrever meu próprio código de verificação de igualdade de valor ou uma tradução de classificação e arquivo para indexar e voltar. O último foi mais simples. Eu diria que funcione primeiro e, em seguida, otimize seu tipo de dados mais tarde, se necessário. Não é provável que faça uma diferença suficientemente grande para importar.

No final, sua implementação não deve importar tanto, desde que as operações da sua diretoria sejam devidamente encapsuladas por tipo e operações da diretoria. Por exemplo, aqui está como alguns dos meus testes podem parecer para montar uma placa:

let pos r f = {Rank = r; File = f} // immutable record type
// or
let pos r f = OnBoard (r, f) // algebraic type
...
let testBoard =
    Board.createEmpty ()
    |> Board.setPiece p (pos 1 2)
    |> ...

Para esse código (ou qualquer código de chamada), não importa como a placa foi representada. O conselho é representado pelas operações nele mais do que sua estrutura subjacente.

Kasey Speakman
fonte
Olá, você tem o código publicado em algum lugar? Atualmente, estou trabalhando em um jogo de xadrez em F # (um projeto divertido), e mesmo usando um mapa <Square, Piece> para representar o tabuleiro, eu adoraria ver como você o encapsulou em um tabuleiro tipo e módulo.
Asibahi # 12/16
Não, não é publicado em nenhum lugar.
Kasey Speakman
você se importaria de dar uma olhada na minha implementação atual e aconselhar como eu poderia melhorá-la?
Asibahi # 12/16
Olhando para os tipos, decidi por uma implementação muito semelhante até chegar ao tipo Posição. Farei uma análise mais aprofundada sobre a Revisão de Código posteriormente.
Kasey Speakman