El jugador más rápido para puntos y cajas

16

El desafío es escribir un solucionador para el clásico juego de lápiz y papel Dots and Boxes . Su código debe tomar dos enteros my ncomo entrada que especifica el tamaño de la placa.

Comenzando con una cuadrícula de puntos vacía, los jugadores se turnan y agregan una sola línea horizontal o vertical entre dos puntos adyacentes no unidos. Un jugador que completa el cuarto lado de un cuadro de 1 × 1 gana un punto y toma otro turno. (Los puntos se registran típicamente colocando en el cuadro una marca de identificación del jugador, como una inicial). El juego termina cuando no se pueden colocar más líneas. El ganador del juego es el jugador con más puntos.

ingrese la descripción de la imagen aquí

Puede suponer que o n = mo n = m - 1y mes al menos 2.

El desafío es solveel juego de puntos y cajas más grande posible en menos de un minuto. El tamaño de un juego es simple n*m. La salida de su código debe serwin , drawo losecuál debería ser el resultado para el primer jugador, suponiendo que ambos jugadores jueguen de manera óptima.

Su código debe ser compilable / ejecutable en ubuntu usando herramientas fáciles de instalar y gratuitas. Informe su puntaje como el área más grande que puede resolver en su computadora en 1 minuto junto con el tiempo. Luego probaré el código en mi computadora y haré una tabla ordenada de entradas.

En el caso de un desempate, el ganador será el código más rápido en el tablero de mayor tamaño que pueda resolver en menos de un minuto.


Sería mejor si el código generado no solo ganara o perdiera, sino también el puntaje real. Esto hace que se verifique la corrección de la cordura.


fuente
2
¿Tenemos que usar minimax?
qwr
@qwr ¿Puede decirme qué otra opción tenía en mente?
Espera, ¿hay un ganador predecible en este juego basado únicamente en el tamaño de la cuadrícula?
No es que Charles
@Charles Sí, si ambos jugadores juegan de manera óptima.
1
@PeterTaylor Creo que obtienes dos puntos pero solo un turno extra.

Respuestas:

15

C99 - tablero 3x3 en 0.084s

Editar: modifiqué mi código e hice un análisis más profundo de los resultados.

Ediciones adicionales: poda adicional por simetrías. Esto hace 4 configuraciones de algoritmo: con o sin simetrías X con o sin poda alfa-beta

Ediciones más lejanas: lejanas agregó la memorización usando una tabla hash, finalmente logrando lo imposible: ¡resolver un tablero 3x3!

Características principales:

  • implementación directa de minimax con poda alfa-beta
  • muy poca administración de memoria (mantiene dll de movimientos válidos; O (1) actualizaciones por rama en la búsqueda del árbol)
  • segundo archivo con poda por simetrías. Todavía logra actualizaciones O (1) por rama (técnicamente O (S) donde S es el número de simetrías. Esto es 7 para tableros cuadrados y 3 para tableros no cuadrados)
  • los archivos tercero y cuarto agregan memorización. Tienes control sobre el tamaño de la tabla hash ( #define HASHTABLE_BITWIDTH). Cuando este tamaño es mayor o igual que el número de paredes, garantiza que no haya colisiones y actualizaciones de O (1). Las tablas hash más pequeñas tendrán más colisiones y serán un poco más lentas.
  • compilar con -DDEBUGimpresiones

Posibles mejoras:

  • arregla una pequeña pérdida de memoria arreglada en la primera edición
  • poda alfa / beta agregada en la 2da edición
  • podar simetrías agregadas en la tercera edición (tenga en cuenta que las simetrías no se manejan mediante la memorización, por lo que sigue siendo una optimización separada).
  • memoria agregada en la 4ta edición
  • Actualmente la memorización utiliza un bit indicador para cada muro. Una tabla de 3x4 tiene 31 paredes, por lo que este método no podría manejar tablas de 4x4 independientemente de las limitaciones de tiempo. la mejora sería emular enteros de X bits, donde X es al menos tan grande como el número de muros.

Código

Debido a la falta de organización, el número de archivos se ha ido de las manos. Todo el código se ha movido a este repositorio de Github . En la edición de la memorización, agregué un archivo MAKE y un script de prueba.

Resultados

Gráfico de registro de tiempos de ejecución

Notas sobre la complejidad

Los enfoques de fuerza bruta a los puntos y cuadros explotan en complejidad muy rápidamente .

Considere un tablero con Rfilas y Ccolumnas. Hay R*Ccuadrados, R*(C+1)paredes verticales y C*(R+1)paredes horizontales. Eso es un total deW = 2*R*C + R + C .

Debido a que Lembik nos pidió que resolvamos el juego con minimax, debemos atravesar las hojas del árbol del juego. Ignoremos la poda por ahora, porque lo que importa son órdenes de magnitud.

Hay Wopciones para el primer movimiento. Para cada uno de ellos, el siguiente jugador puede jugar cualquiera de los W-1muros restantes, etc. Eso nos da un espacio de búsqueda de SS = W * (W-1) * (W-2) * ... * 1, o SS = W!. Los factoriales son enormes, pero eso es solo el comienzo. SSes el número de nodos hoja en el espacio de búsqueda. Más relevante para nuestro análisis es el número total de decisiones que tuvieron que tomarse (es decir, el número de ramas B en el árbol). La primera capa de ramas tiene Wopciones. Para cada uno de ellos, el siguiente nivel tiene W-1, etc.

B = W + W*(W-1) + W*(W-1)*(W-2) + ... + W!

B = SUM W!/(W-k)!
  k=0..W-1

Veamos algunos tamaños de mesa pequeños:

Board Size  Walls  Leaves (SS)      Branches (B)
---------------------------------------------------
1x1         04     24               64
1x2         07     5040             13699
2x2         12     479001600        1302061344
2x3         17     355687428096000  966858672404689

Estas cifras se están volviendo ridículas. Al menos explican por qué el código de fuerza bruta parece colgar para siempre en una placa de 2x3. El espacio de búsqueda de una placa de 2x3 es 742560 veces mayor que 2x2 . Si 2x2 tarda 20 segundos en completarse, una extrapolación conservadora predice más de 100 días de tiempo de ejecución para 2x3. Claramente necesitamos podar.

Análisis de poda

Comencé agregando podas muy simples usando el algoritmo alfa-beta. Básicamente, deja de buscar si un oponente ideal nunca le daría sus oportunidades actuales. "Oye, mira, ¡gano mucho si mi oponente me permite obtener todas las casillas!", Pensó AI, nunca.

editar También he agregado poda basada en tablas simétricas. No uso un enfoque de memorización, por si algún día agrego memorización y quiero mantener ese análisis por separado. En cambio, funciona así: la mayoría de las líneas tienen un "par simétrico" en otro lugar de la cuadrícula. Hay hasta 7 simetrías (horizontal, vertical, rotación 180, rotación 90, rotación 270, diagonal y la otra diagonal). Los 7 se aplican a tableros cuadrados, pero los últimos 4 no se aplican a tableros no cuadrados. Cada pared tiene un puntero a su "par" para cada una de estas simetrías. Si, al entrar en un turno, el tablero es horizontalmente simétrico, entonces solo se necesita jugar uno de cada par horizontal .

editar editar ¡Memorización! Cada muro obtiene una identificación única, que convenientemente configuré para ser un bit indicador; la enésima pared tiene la identificación 1 << n. El hash de un tablero, entonces, es solo el OR de todas las paredes jugadas. Esto se actualiza en cada rama en el tiempo O (1). El tamaño de la tabla hash se establece en a #define. Todas las pruebas se ejecutaron con tamaño 2 ^ 12, porque ¿por qué no? Cuando hay más paredes que bits que indexan la tabla hash (12 bits en este caso), los 12 menos significativos se enmascaran y se usan como índice. Las colisiones se manejan con una lista vinculada en cada índice de tabla hash. El siguiente cuadro es mi análisis rápido y sucio de cómo el tamaño de la tabla hash afecta el rendimiento. En una computadora con RAM infinita, siempre establecemos el tamaño de la mesa en el número de paredes. Una tabla de 3x4 tendría una tabla hash de 2 ^ 31 de largo. Por desgracia no tenemos ese lujo.

Efectos del tamaño hashtable

Ok, volvamos a la poda. Al detener la búsqueda en lo alto del árbol, podemos ahorrar mucho tiempo al no bajar a las hojas. El 'Factor de poda' es la fracción de todas las ramas posibles que tuvimos que visitar. La fuerza bruta tiene un factor de poda de 1. Cuanto más pequeño es, mejor.

Registro de parcela de ramas tomadas

Gráfico de registro de factores de poda

wrongu
fuente
23s parece notablemente lento para un lenguaje rápido como C. ¿Eres un forzado bruto?
qwr
Fuerza bruta con una pequeña cantidad de poda de alfa beta. Estoy de acuerdo en que 23s es sospechoso, pero no veo ninguna razón en mi código para que sea inconsistente ... En otras palabras, es un misterio
incorrecto
1
La entrada tiene el formato especificado por la pregunta. dos enteros separados por espacios que rows columnsespecifican el tamaño de la placa
wrongu
1
@Lembik No creo que quede nada por hacer. ¡Ya terminé con este loco proyecto!
wrongu
1
Creo que tu respuesta merece un lugar especial. Lo busqué y 3 por 3 es el tamaño de problema más grande que se haya resuelto antes y su código es casi instantáneo. Si puede resolver 3 por 4 o 4 por 4, podría agregar el resultado a la página wiki y ser famoso :)
4

Python - 2x2 en 29s

Publicaciones cruzadas de rompecabezas . No está especialmente optimizado, pero puede ser un punto de partida útil para otros participantes.

from collections import defaultdict

VERTICAL, HORIZONTAL = 0, 1

#represents a single line segment that can be drawn on the board.
class Line(object):
    def __init__(self, x, y, orientation):
        self.x = x
        self.y = y
        self.orientation = orientation
    def __hash__(self):
        return hash((self.x, self.y, self.orientation))
    def __eq__(self, other):
        if not isinstance(other, Line): return False
        return self.x == other.x and self.y == other.y and self.orientation == other.orientation
    def __repr__(self):
        return "Line({}, {}, {})".format(self.x, self.y, "HORIZONTAL" if self.orientation == HORIZONTAL else "VERTICAL")

class State(object):
    def __init__(self, width, height):
        self.width = width
        self.height = height
        self.whose_turn = 0
        self.scores = {0:0, 1:0}
        self.lines = set()
    def copy(self):
        ret = State(self.width, self.height)
        ret.whose_turn = self.whose_turn
        ret.scores = self.scores.copy()
        ret.lines = self.lines.copy()
        return ret
    #iterate through all lines that can be placed on a blank board.
    def iter_all_lines(self):
        #horizontal lines
        for x in range(self.width):
            for y in range(self.height+1):
                yield Line(x, y, HORIZONTAL)
        #vertical lines
        for x in range(self.width+1):
            for y in range(self.height):
                yield Line(x, y, VERTICAL)
    #iterate through all lines that can be placed on this board, 
    #that haven't already been placed.
    def iter_available_lines(self):
        for line in self.iter_all_lines():
            if line not in self.lines:
                yield line

    #returns the number of points that would be earned by a player placing the line.
    def value(self, line):
        assert line not in self.lines
        all_placed = lambda seq: all(l in self.lines for l in seq)
        if line.orientation == HORIZONTAL:
            #lines composing the box above the line
            lines_above = [
                Line(line.x,   line.y+1, HORIZONTAL), #top
                Line(line.x,   line.y,   VERTICAL),   #left
                Line(line.x+1, line.y,   VERTICAL),   #right
            ]
            #lines composing the box below the line
            lines_below = [
                Line(line.x,   line.y-1, HORIZONTAL), #bottom
                Line(line.x,   line.y-1, VERTICAL),   #left
                Line(line.x+1, line.y-1, VERTICAL),   #right
            ]
            return all_placed(lines_above) + all_placed(lines_below)
        else:
            #lines composing the box to the left of the line
            lines_left = [
                Line(line.x-1, line.y+1, HORIZONTAL), #top
                Line(line.x-1, line.y,   HORIZONTAL), #bottom
                Line(line.x-1, line.y,   VERTICAL),   #left
            ]
            #lines composing the box to the right of the line
            lines_right = [
                Line(line.x,   line.y+1, HORIZONTAL), #top
                Line(line.x,   line.y,   HORIZONTAL), #bottom
                Line(line.x+1, line.y,   VERTICAL),   #right
            ]
            return all_placed(lines_left) + all_placed(lines_right)

    def is_game_over(self):
        #the game is over when no more moves can be made.
        return len(list(self.iter_available_lines())) == 0

    #iterates through all possible moves the current player could make.
    #Because scoring a point lets a player go again, a move can consist of a collection of multiple lines.
    def possible_moves(self):
        for line in self.iter_available_lines():
            if self.value(line) > 0:
                #this line would give us an extra turn.
                #so we create a hypothetical future state with this line already placed, and see what other moves can be made.
                future = self.copy()
                future.lines.add(line)
                if future.is_game_over(): 
                    yield [line]
                else:
                    for future_move in future.possible_moves():
                        yield [line] + future_move
            else:
                yield [line]

    def make_move(self, move):
        for line in move:
            self.scores[self.whose_turn] += self.value(line)
            self.lines.add(line)
        self.whose_turn = 1 - self.whose_turn

    def tuple(self):
        return (tuple(self.lines), tuple(self.scores.items()), self.whose_turn)
    def __hash__(self):
        return hash(self.tuple())
    def __eq__(self, other):
        if not isinstance(other, State): return False
        return self.tuple() == other.tuple()

#function decorator which memorizes previously calculated values.
def memoized(fn):
    answers = {}
    def mem_fn(*args):
        if args not in answers:
            answers[args] = fn(*args)
        return answers[args]
    return mem_fn

#finds the best possible move for the current player.
#returns a (move, value) tuple.
@memoized
def get_best_move(state):
    cur_player = state.whose_turn
    next_player = 1 - state.whose_turn
    if state.is_game_over():
        return (None, state.scores[cur_player] - state.scores[next_player])
    best_move = None
    best_score = float("inf")
    #choose the move that gives our opponent the lowest score
    for move in state.possible_moves():
        future = state.copy()
        future.make_move(move)
        _, score = get_best_move(future)
        if score < best_score:
            best_move = move
            best_score = score
    return [best_move, -best_score]

n = 2
m = 2
s = State(n,m)
best_move, relative_value = get_best_move(s)
if relative_value > 0:
    print("win")
elif relative_value == 0:
    print("draw")
else:
    print("lose")
Kevin
fuente
Se puede acelerar hasta 18 segundos usando pypy.
2

Javascript: placa 1x2 en 20 ms

Demostración en línea disponible aquí (advertencia: muy lenta si es mayor que 1x2 con profundidad de búsqueda completa ): https://dl.dropboxusercontent.com/u/141246873/minimax/index.html

Fue desarrollado para los criterios de victoria originales (código golf) y no para velocidad.

Probado en google chrome v35 en windows 7.

//first row is a horizontal edges and second is vertical
var gameEdges = [
    [false, false],
    [false, false, false],
    [false, false]
]

//track all possible moves and score outcome
var moves = []

function minimax(edges, isPlayersTurn, prevScore, depth) {

    if (depth <= 0) {
        return [prevScore, 0, 0];
    }
    else {

        var pointValue = 1;
        if (!isPlayersTurn)
            pointValue = -1;

        var moves = [];

        //get all possible moves and scores
        for (var i in edges) {
            for (var j in edges[i]) {
                //if edge is available then its a possible move
                if (!edges[i][j]) {

                    //if it would result in game over, add it to the scores array, otherwise, try the next move
                    //clone the array
                    var newEdges = [];
                    for (var k in edges)
                        newEdges.push(edges[k].slice(0));
                    //update state
                    newEdges[i][j] = true;
                    //if closing this edge would result in a complete square, get another move and get a point
                    //square could be formed above, below, right or left and could get two squares at the same time

                    var currentScore = prevScore;
                    //vertical edge
                    if (i % 2 !== 0) {//i === 1
                        if (newEdges[i] && newEdges[i][j - 1] && newEdges[i - 1] && newEdges[i - 1][j - 1] && newEdges[parseInt(i) + 1] && newEdges[parseInt(i) + 1][j - 1])
                            currentScore += pointValue;
                        if (newEdges[i] && newEdges[i][parseInt(j) + 1] && newEdges[i - 1] && newEdges[i - 1][j] && newEdges[parseInt(i) + 1] && newEdges[parseInt(i) + 1][j])
                            currentScore += pointValue;
                    } else {//horizontal
                        if (newEdges[i - 2] && newEdges[i - 2][j] && newEdges[i - 1][j] && newEdges[i - 1][parseInt(j) + 1])
                            currentScore += pointValue;
                        if (newEdges[parseInt(i) + 2] && newEdges[parseInt(i) + 2][j] && newEdges[parseInt(i) + 1][j] && newEdges[parseInt(i) + 1][parseInt(j) + 1])
                            currentScore += pointValue;
                    }

                    //leaf case - if all edges are taken then there are no more moves to evaluate
                    if (newEdges.every(function (arr) { return arr.every(Boolean) })) {
                        moves.push([currentScore, i, j]);
                        console.log("reached end case with possible score of " + currentScore);
                    }
                    else {
                        if ((isPlayersTurn && currentScore > prevScore) || (!isPlayersTurn && currentScore < prevScore)) {
                            //gained a point so get another turn
                            var newMove = minimax(newEdges, isPlayersTurn, currentScore, depth - 1);

                            moves.push([newMove[0], i, j]);
                        } else {
                            //didnt gain a point - opponents turn
                            var newMove = minimax(newEdges, !isPlayersTurn, currentScore, depth - 1);

                            moves.push([newMove[0], i, j]);
                        }
                    }



                }


            }

        }//end for each move

        var bestMove = moves[0];
        if (isPlayersTurn) {
            for (var i in moves) {
                if (moves[i][0] > bestMove[0])
                    bestMove = moves[i];
            }
        }
        else {
            for (var i in moves) {
                if (moves[i][0] < bestMove[0])
                    bestMove = moves[i];
            }
        }
        return bestMove;
    }
}

var player1Turn = true;
var squares = [[0,0],[0,0]]//change to "A" or "B" if square won by any of the players
var lastMove = null;

function output(text) {
    document.getElementById("content").innerHTML += text;
}

function clear() {
    document.getElementById("content").innerHTML = "";
}

function render() {
    var width = 3;
    if (document.getElementById('txtWidth').value)
        width = parseInt(document.getElementById('txtWidth').value);
    if (width < 2)
        width = 2;

    clear();
    //need to highlight the last move taken and show who has won each square
    for (var i in gameEdges) {
        for (var j in gameEdges[i]) {
            if (i % 2 === 0) {
                if(j === "0")
                    output("*");
                if (gameEdges[i][j] && lastMove[1] == i && lastMove[2] == j)
                    output(" <b>-</b> ");
                else if (gameEdges[i][j])
                    output(" - ");
                else
                    output("&nbsp;&nbsp;&nbsp;");
                output("*");
            }
            else {
                if (gameEdges[i][j] && lastMove[1] == i && lastMove[2] == j)
                    output("<b>|</b>");
                else if (gameEdges[i][j])
                    output("|");
                else
                    output("&nbsp;");

                if (j <= width - 2) {
                    if (squares[Math.floor(i / 2)][j] === 0)
                        output("&nbsp;&nbsp;&nbsp;&nbsp;");
                    else
                        output("&nbsp;" + squares[Math.floor(i / 2)][j] + "&nbsp;");
                }
            }
        }
        output("<br />");

    }
}

function nextMove(playFullGame) {
    var startTime = new Date().getTime();
    if (!gameEdges.every(function (arr) { return arr.every(Boolean) })) {

        var depth = 100;
        if (document.getElementById('txtDepth').value)
            depth = parseInt(document.getElementById('txtDepth').value);

        if (depth < 1)
            depth = 1;

        var move = minimax(gameEdges, true, 0, depth);
        gameEdges[move[1]][move[2]] = true;
        lastMove = move;

        //if a square was taken, need to update squares and whose turn it is

        var i = move[1];
        var j = move[2];
        var wonSquare = false;
        if (i % 2 !== 0) {//i === 1
            if (gameEdges[i] && gameEdges[i][j - 1] && gameEdges[i - 1] && gameEdges[i - 1][j - 1] && gameEdges[parseInt(i) + 1] && gameEdges[parseInt(i) + 1][j - 1]) {
                squares[Math.floor(i / 2)][j - 1] = player1Turn ? "A" : "B";
                wonSquare = true;
            }
            if (gameEdges[i] && gameEdges[i][parseInt(j) + 1] && gameEdges[i - 1] && gameEdges[i - 1][j] && gameEdges[parseInt(i) + 1] && gameEdges[parseInt(i) + 1][j]) {
                squares[Math.floor(i / 2)][j] = player1Turn ? "A" : "B";
                wonSquare = true;
            }
        } else {//horizontal
            if (gameEdges[i - 2] && gameEdges[i - 2][j] && gameEdges[i - 1] && gameEdges[i - 1][j] && gameEdges[i - 1] && gameEdges[i - 1][parseInt(j) + 1]) {
                squares[Math.floor((i - 1) / 2)][j] = player1Turn ? "A" : "B";
                wonSquare = true;
            }
            if (gameEdges[i + 2] && gameEdges[parseInt(i) + 2][j] && gameEdges[parseInt(i) + 1] && gameEdges[parseInt(i) + 1][j] && gameEdges[parseInt(i) + 1] && gameEdges[parseInt(i) + 1][parseInt(j) + 1]) {
                squares[Math.floor(i / 2)][j] = player1Turn ? "A" : "B";
                wonSquare = true;
            }
        }

        //didnt win a square so its the next players turn
        if (!wonSquare)
            player1Turn = !player1Turn;

        render();

        if (playFullGame) {
            nextMove(playFullGame);
        }
    }

    var endTime = new Date().getTime();
    var executionTime = endTime - startTime;
    document.getElementById("executionTime").innerHTML = 'Execution time: ' + executionTime;
}

function initGame() {

    var width = 3;
    var height = 2;

    if (document.getElementById('txtWidth').value)
        width = document.getElementById('txtWidth').value;
    if (document.getElementById('txtHeight').value)
        height = document.getElementById('txtHeight').value;

    if (width < 2)
        width = 2;
    if (height < 2)
        height = 2;

    var depth = 100;
    if (document.getElementById('txtDepth').value)
        depth = parseInt(document.getElementById('txtDepth').value);

    if (depth < 1)
        depth = 1;

    if (width > 2 && height > 2 && !document.getElementById('txtDepth').value)
        alert("Warning. Your system may become unresponsive. A smaller grid or search depth is highly recommended.");

    gameEdges = [];
    for (var i = 0; i < height; i++) {
        if (i == 0) {
            gameEdges.push([]);
            for (var j = 0; j < (width - 1) ; j++) {
                gameEdges[i].push(false);
            }
        }
        else {
            gameEdges.push([]);
            for (var j = 0; j < width; j++) {
                gameEdges[(i * 2) - 1].push(false);
            }
            gameEdges.push([]);
            for (var j = 0; j < (width - 1) ; j++) {
                gameEdges[i*2].push(false);
            }
        }
    }

    player1Turn = true;

    squares = [];
    for (var i = 0; i < (height - 1) ; i++) {
        squares.push([]);
        for (var j = 0; j < (width - 1); j++) {
            squares[i].push(0);
        }
    }

    lastMove = null;

    render();
}

document.addEventListener('DOMContentLoaded', initGame, false);
rdans
fuente
¡La demo es realmente genial! El 3 x 3 es realmente interesante ya que el ganador cambia de un lado a otro a medida que aumenta la profundidad de búsqueda. ¿Puedo verificar si su minimax se detiene a la mitad de un turno? Lo que quiero decir es que si alguien obtiene un cuadrado, ¿siempre se extiende hasta el final de su turno?
2x2 es 3 puntos por 3. ¿Está seguro de que su código puede resolver eso exactamente en 20 ms?
"Si alguien obtiene un cuadrado, ¿siempre se extiende hasta el final de su turno?" - Si el jugador obtiene un cuadrado, todavía se mueve al siguiente turno, pero ese próximo turno es para el mismo jugador, es decir, obtiene un turno adicional para completar un cuadrado. "2x2 es 3 puntos por 3" - Vaya. En ese caso mi puntaje es 1x1.
rdans