Trouver toutes les combinaisons de polyominos libres dans une zone spécifique avec un solveur SAT (Python)

15

Je suis nouveau dans le monde des solveurs SAT et j'aurais besoin de conseils concernant le problème suivant.

Étant donné que:

❶ J'ai une sélection de 14 cellules adjacentes dans une grille 4 * 4

❷ J'ai 5 polyominos (A, B, C, D, E) de tailles 4, 2, 5, 2 et 1

❸ ces polyominos sont libres , c'est-à-dire que leur forme n'est pas fixe et peut former des motifs différents

entrez la description de l'image ici

Comment puis-je calculer toutes les combinaisons possibles de ces 5 polyominos libres à l'intérieur de la zone sélectionnée (cellules en gris) avec un solveur SAT?

En empruntant à la fois à la réponse perspicace de @ spinkus et à la documentation des outils OR, je pourrais créer l'exemple de code suivant (exécuté dans un bloc-notes Jupyter):

from ortools.sat.python import cp_model

import numpy as np
import more_itertools as mit
import matplotlib.pyplot as plt
%matplotlib inline


W, H = 4, 4 #Dimensions of grid
sizes = (4, 2, 5, 2, 1) #Size of each polyomino
labels = np.arange(len(sizes))  #Label of each polyomino

colors = ('#FA5454', '#21D3B6', '#3384FA', '#FFD256', '#62ECFA')
cdict = dict(zip(labels, colors)) #Color dictionary for plotting

inactiveCells = (0, 1) #Indices of disabled cells (in 1D)
activeCells = set(np.arange(W*H)).difference(inactiveCells) #Cells where polyominoes can be fitted
ranges = [(next(g), list(g)[-1]) for g in mit.consecutive_groups(activeCells)] #All intervals in the stack of active cells



def main():
    model = cp_model.CpModel()


    #Create an Int var for each cell of each polyomino constrained to be within Width and Height of grid.
    pminos = [[] for s in sizes]
    for idx, s in enumerate(sizes):
        for i in range(s):
            pminos[idx].append([model.NewIntVar(0, W-1, 'p%i'%idx + 'c%i'%i + 'x'), model.NewIntVar(0, H-1, 'p%i'%idx + 'c%i'%i + 'y')])



    #Define the shapes by constraining the cells relative to each other

    ## 1st polyomino -> tetromino ##
    #                              #      
    #                              # 
    #            #                 # 
    #           ###                # 
    #                              # 
    ################################

    p0 = pminos[0]
    model.Add(p0[1][0] == p0[0][0] + 1) #'x' of 2nd cell == 'x' of 1st cell + 1
    model.Add(p0[2][0] == p0[1][0] + 1) #'x' of 3rd cell == 'x' of 2nd cell + 1
    model.Add(p0[3][0] == p0[0][0] + 1) #'x' of 4th cell == 'x' of 1st cell + 1

    model.Add(p0[1][1] == p0[0][1]) #'y' of 2nd cell = 'y' of 1st cell
    model.Add(p0[2][1] == p0[1][1]) #'y' of 3rd cell = 'y' of 2nd cell
    model.Add(p0[3][1] == p0[1][1] - 1) #'y' of 3rd cell = 'y' of 2nd cell - 1



    ## 2nd polyomino -> domino ##
    #                           #      
    #                           # 
    #           #               # 
    #           #               # 
    #                           # 
    #############################

    p1 = pminos[1]
    model.Add(p1[1][0] == p1[0][0])
    model.Add(p1[1][1] == p1[0][1] + 1)



    ## 3rd polyomino -> pentomino ##
    #                              #      
    #            ##                # 
    #            ##                # 
    #            #                 # 
    #                              #
    ################################

    p2 = pminos[2]
    model.Add(p2[1][0] == p2[0][0] + 1)
    model.Add(p2[2][0] == p2[0][0])
    model.Add(p2[3][0] == p2[0][0] + 1)
    model.Add(p2[4][0] == p2[0][0])

    model.Add(p2[1][1] == p2[0][1])
    model.Add(p2[2][1] == p2[0][1] + 1)
    model.Add(p2[3][1] == p2[0][1] + 1)
    model.Add(p2[4][1] == p2[0][1] + 2)



    ## 4th polyomino -> domino ##
    #                           #      
    #                           # 
    #           #               #   
    #           #               # 
    #                           # 
    #############################

    p3 = pminos[3]
    model.Add(p3[1][0] == p3[0][0])
    model.Add(p3[1][1] == p3[0][1] + 1)



    ## 5th polyomino -> monomino ##
    #                             #      
    #                             # 
    #           #                 # 
    #                             # 
    #                             # 
    ###############################
    #No constraints because 1 cell only



    #No blocks can overlap:
    block_addresses = []
    n = 0
    for p in pminos:
        for c in p:
            n += 1
            block_address = model.NewIntVarFromDomain(cp_model.Domain.FromIntervals(ranges),'%i' % n)
                model.Add(c[0] + c[1] * W == block_address)
                block_addresses.append(block_address)

    model.AddAllDifferent(block_addresses)



    #Solve and print solutions as we find them
    solver = cp_model.CpSolver()

    solution_printer = SolutionPrinter(pminos)
    status = solver.SearchForAllSolutions(model, solution_printer)

    print('Status = %s' % solver.StatusName(status))
    print('Number of solutions found: %i' % solution_printer.count)




class SolutionPrinter(cp_model.CpSolverSolutionCallback):
    ''' Print a solution. '''

    def __init__(self, variables):
        cp_model.CpSolverSolutionCallback.__init__(self)
        self.variables = variables
        self.count = 0

    def on_solution_callback(self):
        self.count += 1


        plt.figure(figsize = (2, 2))
        plt.grid(True)
        plt.axis([0,W,H,0])
        plt.yticks(np.arange(0, H, 1.0))
        plt.xticks(np.arange(0, W, 1.0))


        for i, p in enumerate(self.variables):
            for c in p:
                x = self.Value(c[0])
                y = self.Value(c[1])
                rect = plt.Rectangle((x, y), 1, 1, fc = cdict[i])
                plt.gca().add_patch(rect)

        for i in inactiveCells:
            x = i%W
            y = i//W
            rect = plt.Rectangle((x, y), 1, 1, fc = 'None', hatch = '///')
            plt.gca().add_patch(rect)

entrez la description de l'image ici

Le problème est que j'ai codé en dur 5 polyominos uniques / fixes et je ne sais pas comment définir les contraintes afin que chaque modèle possible pour chaque polyomino soit pris en compte (à condition que cela soit possible).

solub
la source
J'entends parler de Google OR-tools pour la première fois. Est - il possible d'utiliser des bibliothèques standard Python telles que itertools, numpy, networkx?
mathfux
Je préférerais utiliser de préférence un solveur ou des outils.
solub
@solub, il est assez facile de modéliser / résoudre ce genre de problème en utilisant le langage MiniZinc, car il existe des contraintes de haut niveau pour placer des objets irréguliers sur une surface. Si vous parcourez le cours gratuit "Modélisation avancée pour l'optimisation discrète" sur Coursera , vous allez réellement apprendre à le faire et vous donner des exemples pratiques (et plus complexes). Or-Tools possède une interface pour le langage MiniZinc, vous pouvez donc toujours exploiter sa puissance pour trouver une solution rapide.
Patrick Trentin
1
Semble intéressant, merci pour le pointeur. Je ne suis pas sûr que cela répondra au problème spécifique que j'ai (définir des contraintes impliquant des polyominos libres, pas statiques) mais je vais certainement y jeter un œil.
solub
1
Je dois m'excuser, j'avais complètement oublié cette question. Il y a eu une question connexe dans la minizincbalise avec une réponse détaillée qui couvre ma suggestion précédente sur l'utilisation minizinc.
Patrick Trentin

Réponses:

10

EDIT: J'ai raté le mot "gratuit" dans la réponse originale et ai donné la réponse en utilisant OR-Tools pour les polyominos fixes. Ajout d'une section pour répondre afin d'inclure une solution pour les polyominos libres - qu'AFAICT s'avère assez difficile à exprimer précisément dans la programmation par contraintes avec OR-Tools.

POLYOMINOS FIXES AVEC OUTILS OU:

Oui, vous pouvez le faire avec la programmation par contraintes dans OR-Tools. OR-Tools ne sait rien de la géométrie de la grille 2D, vous devez donc encoder la géométrie de chaque forme que vous avez en termes de contraintes de position. C'est-à-dire qu'une forme est une collection de blocs / cellules qui doivent avoir une certaine relation les uns avec les autres, doivent être dans les limites de la grille et ne doivent pas se chevaucher. Une fois que vous avez votre modèle de contrainte, il vous suffit de demander au solveur CP-SAT de le résoudre, dans votre cas, pour toutes les solutions possibles.

Voici une preuve de concept très simple avec deux formes rectangulaires sur une grille 4x4 (vous voudrez probablement aussi ajouter une sorte de code d'interpréteur pour passer des descriptions de formes à un ensemble de variables et de contraintes OR-Tools dans un problème à plus grande échelle car la saisie manuelle des contraintes est un peu fastidieuse).

from ortools.sat.python import cp_model

(W, H) = (3, 3) # Width and height of our grid.
(X, Y) = (0, 1) # Convenience constants.


def main():
  model = cp_model.CpModel()
  # Create an Int var for each block of each shape constrained to be within width and height of grid.
  shapes = [
    [
      [ model.NewIntVar(0, W, 's1b1_x'), model.NewIntVar(0, H, 's1b1_y') ],
      [ model.NewIntVar(0, W, 's1b2_x'), model.NewIntVar(0, H, 's1b2_y') ],
      [ model.NewIntVar(0, W, 's1b3_x'), model.NewIntVar(0, H, 's1b3_y') ],
    ],
    [
      [ model.NewIntVar(0, W, 's2b1_x'), model.NewIntVar(0, H, 's2b1_y') ],
      [ model.NewIntVar(0, W, 's2b2_x'), model.NewIntVar(0, H, 's2b2_y') ],
    ]
  ]

  # Define the shapes by constraining the blocks relative to each other.
  # 3x1 rectangle:
  s0 = shapes[0]
  model.Add(s0[0][Y] == s0[1][Y])
  model.Add(s0[0][Y] == s0[2][Y])
  model.Add(s0[0][X] == s0[1][X] - 1)
  model.Add(s0[0][X] == s0[2][X] - 2)
  # 1x2 rectangle:
  s1 = shapes[1]
  model.Add(s1[0][X] == s1[1][X])
  model.Add(s1[0][Y] == s1[1][Y] - 1)

  # No blocks can overlap:
  block_addresses = []
  for i, block in enumerate(blocks(shapes)):
    block_address = model.NewIntVar(0, (W+1)*(H+1), 'b%d' % (i,))
    model.Add(block[X] + (H+1)*block[Y] == block_address)
    block_addresses.append(block_address)
  model.AddAllDifferent(block_addresses)

  # Solve and print solutions as we find them
  solver = cp_model.CpSolver()
  solution_printer = SolutionPrinter(shapes)
  status = solver.SearchForAllSolutions(model, solution_printer)
  print('Status = %s' % solver.StatusName(status))
  print('Number of solutions found: %i' % solution_printer.count)


def blocks(shapes):
  ''' Helper to enumerate all blocks. '''
  for shape in shapes:
    for block in shape:
      yield block


class SolutionPrinter(cp_model.CpSolverSolutionCallback):
    ''' Print a solution. '''

    def __init__(self, variables):
        cp_model.CpSolverSolutionCallback.__init__(self)
        self.variables = variables
        self.count = 0

    def on_solution_callback(self):
      self.count += 1
      solution = [(self.Value(block[X]), self.Value(block[Y])) for shape in self.variables for block in shape]
      print((W+3)*'-')
      for y in range(0, H+1):
        print('|' + ''.join(['#' if (x,y) in solution else ' ' for x in range(0, W+1)]) + '|')
      print((W+3)*'-')


if __name__ == '__main__':
  main()

Donne:

...
------
|    |
| ###|
|  # |
|  # |
------
------
|    |
| ###|
|   #|
|   #|
------
Status = OPTIMAL
Number of solutions found: 60

POLYOMINOES GRATUITS:

Si nous considérons la grille de cellules comme un graphique, le problème peut être réinterprété comme trouvant une k-partition des cellules de la grille où chaque partition a une taille spécifique et en plus chaque partition est un composant connecté . C'est-à-dire AFAICT, il n'y a pas de différence entre un composant connecté et un polyomino et le reste de cette réponse fait cette hypothèse.

La recherche de toutes les "k-partitions des cellules de la grille où chaque partition a une taille spécifique" est assez banale à exprimer dans la programmation des contraintes OR-Tools. Mais la partie connectivité est difficile AFAICT (j'ai essayé et échoué pendant un bon moment ...). Je pense que la programmation par contraintes OR-Tools n'est pas la bonne approche. J'ai remarqué que la référence OR-Tools C ++ pour les bibliothèques d'optimisation de réseau contient des informations sur les composants connectés qui pourraient valoir le coup d'œil, mais je ne les connais pas. D'un autre côté, la solution de recherche récursive naïve en Python est assez faisable.

Voici une solution naïve "à la main". C'est assez lent mais supportable pour votre boîtier 4x4. Les adresses sont utilisées pour identifier chaque cellule de la grille. (Notez également que la page wiki fait allusion à quelque chose comme cet algorithme en tant que solution naïve et semble suggérer des solutions plus efficaces pour des problèmes de polyomino similaires).

import numpy as np
from copy import copy
from tabulate import tabulate

D = 4 # Dimension of square grid.
KCC = [5,4,2,2] # List of the sizes of the required k connected components (KCCs).
assert(sum(KCC) <= D*D)
VALID_CELLS = range(2,D*D)

def search():
  solutions = set() # Stash of unique solutions.
  for start in VALID_CELLS: # Try starting search from each possible starting point and expand out.
    marked = np.zeros(D*D).tolist()
    _search(start, marked, set(), solutions, 0, 0)
  for solution in solutions:  # Print results.
    print(tabulate(np.array(solution).reshape(D, D)))
  print('Number of solutions found:', len(solutions))

def _search(i, marked, fringe, solutions, curr_count, curr_part):
  ''' Recursively find each possible KCC in the remaining available cells the find the next, until none left '''
  marked[i] = curr_part+1
  curr_count += 1
  if curr_count == KCC[curr_part]: # If marked K cells for the current CC move onto the next one.
    curr_part += 1
    if curr_part == len(KCC): # If marked K cells and there's no more CCs left we have a solution - not necessarily unique.
      solutions.add(tuple(marked))
    else:
      for start in VALID_CELLS:
        if marked[start] == 0:
          _search(start, copy(marked), set(), solutions, 0, curr_part)
  else:
    fringe.update(neighbours(i, D))
    while(len(fringe)):
      j = fringe.pop()
      if marked[j] == 0:
        _search(j, copy(marked), copy(fringe), solutions, curr_count, curr_part)

def neighbours(i, D):
  ''' Find the address of all cells neighbouring the i-th cell in a DxD grid. '''
  row = int(i/D)
  n = []
  n += [i-1] if int((i-1)/D) == row and (i-1) >= 0 else []
  n += [i+1] if int((i+1)/D) == row and (i+1) < D**2 else []
  n += [i-D] if (i-D) >=0 else []
  n += [i+D] if (i+D) < D**2 else []
  return filter(lambda x: x in VALID_CELLS, n)

if __name__ == '__main__':
  search()

Donne:

...
-  -  -  -
0  0  1  1
2  2  1  1
4  2  3  1
4  2  3  0
-  -  -  -
-  -  -  -
0  0  4  3
1  1  4  3
1  2  2  2
1  1  0  2
-  -  -  -
Number of solutions found: 3884
spinkus
la source
C'est très utile, merci beaucoup. Une chose qui pose problème est que votre exemple ne fonctionne que pour les polyominos de formes fixes, la question concerne les polyominos libres (nombre fixe de cellules mais avec des formes différentes, la question sera éditée pour plus de clarté). Suivant votre exemple, il faudrait coder en dur toutes les formes possibles (+ rotations + réflexions) pour chaque polyomino de taille S ... ce qui n'est pas viable. La question demeure, est-il possible de mettre en œuvre de telles contraintes avec des outils OR?
solub
Oh a manqué la partie "gratuite". Hmmm, le problème peut être posé "trouver une 5-partition d'un 25-omino où le 25-omino est contraint à une grille WxH, et chacune des 5 partitions est aussi X-omino pour X = (7,6,6 , 4,2) .. ". Je suppose qu'il est possible de le faire dans OR-Tools, mais il semble que ce serait plus facile d'implémenter directement la recherche de profondeur de suivi arrière CSP: trouvez des 25-ominos possibles. Pour chaque 25-omino possible, effectuez une recherche de retour arrière CSP en choisissant un X construisant un X-omino dans le domino 25, jusqu'à ce que vous trouviez une solution complète ou que vous deviez revenir en arrière.
spinkus
Ajout de quelque chose comme la solution naïve basée sur la recherche directe à laquelle j'ai fait allusion dans le commentaire précédent pour être complète.
spinkus
5

Une façon relativement simple de contraindre une région simplement connectée dans OR-Tools est de contraindre sa frontière à être un circuit . Si tous vos polyominos doivent avoir une taille inférieure à 8, nous n'avons pas à nous soucier des non-connectés.

Ce code trouve toutes les 3884 solutions:

from ortools.sat.python import cp_model

cells = {(x, y) for x in range(4) for y in range(4) if x > 1 or y > 0}
sizes = [4, 2, 5, 2, 1]
num_polyominos = len(sizes)
model = cp_model.CpModel()

# Each cell is a member of one polyomino
member = {
    (cell, p): model.NewBoolVar(f"member{cell, p}")
    for cell in cells
    for p in range(num_polyominos)
}
for cell in cells:
    model.Add(sum(member[cell, p] for p in range(num_polyominos)) == 1)

# Each polyomino contains the given number of cells
for p, size in enumerate(sizes):
    model.Add(sum(member[cell, p] for cell in cells) == size)

# Find the border of each polyomino
vertices = {
    v: i
    for i, v in enumerate(
        {(x + i, y + j) for x, y in cells for i in [0, 1] for j in [0, 1]}
    )
}
edges = [
    edge
    for x, y in cells
    for edge in [
        ((x, y), (x + 1, y)),
        ((x + 1, y), (x + 1, y + 1)),
        ((x + 1, y + 1), (x, y + 1)),
        ((x, y + 1), (x, y)),
    ]
]
border = {
    (edge, p): model.NewBoolVar(f"border{edge, p}")
    for edge in edges
    for p in range(num_polyominos)
}
for (((x0, y0), (x1, y1)), p), border_var in border.items():
    left_cell = ((x0 + x1 + y0 - y1) // 2, (y0 + y1 - x0 + x1) // 2)
    right_cell = ((x0 + x1 - y0 + y1) // 2, (y0 + y1 + x0 - x1) // 2)
    left_var = member[left_cell, p]
    model.AddBoolOr([border_var.Not(), left_var])
    if (right_cell, p) in member:
        right_var = member[right_cell, p]
        model.AddBoolOr([border_var.Not(), right_var.Not()])
        model.AddBoolOr([border_var, left_var.Not(), right_var])
    else:
        model.AddBoolOr([border_var, left_var.Not()])

# Each border is a circuit
for p in range(num_polyominos):
    model.AddCircuit(
        [(vertices[v0], vertices[v1], border[(v0, v1), p]) for v0, v1 in edges]
        + [(i, i, model.NewBoolVar(f"vertex_loop{v, p}")) for v, i in vertices.items()]
    )

# Print all solutions
x_range = range(min(x for x, y in cells), max(x for x, y in cells) + 1)
y_range = range(min(y for x, y in cells), max(y for x, y in cells) + 1)
solutions = 0


class SolutionPrinter(cp_model.CpSolverSolutionCallback):
    def OnSolutionCallback(self):
        global solutions
        solutions += 1
        for y in y_range:
            print(
                *(
                    next(
                        p
                        for p in range(num_polyominos)
                        if self.Value(member[(x, y), p])
                    )
                    if (x, y) in cells
                    else "-"
                    for x in x_range
                )
            )
        print()


solver = cp_model.CpSolver()
solver.SearchForAllSolutions(model, SolutionPrinter())
print("Number of solutions found:", solutions)
Anders Kaseorg
la source
4

Pour chaque polyonomino et chaque cellule supérieure gauche possible, vous avez une variable booléenne qui indique si cette cellule est la partie supérieure gauche du rectangle englobant.

Pour chaque cellule et chaque polyomino, vous avez une variable booléenne qui indique si cette cellule est occupée par ce polyomino.

Maintenant, pour chaque cellule et chaque polyomino, vous avez une série d'implications: la cellule supérieure gauche est sélectionnée implique que chaque cellule est réellement occupée par ce polyomino.

Ensuite les contraintes: pour chaque cellule, au plus un polyomino l'occupe pour chaque polyomino, il y a exactement une cellule qui est sa partie supérieure gauche.

c'est un pur problème booléen.

Laurent Perron
la source
Merci beaucoup pour la réponse ! Honnêtement, je n'ai aucune idée de comment implémenter cela avec or-tools, y a-t-il un exemple (parmi les exemples de python disponibles fournis) que vous suggéreriez en particulier pour m'aider à démarrer?
solub
Je suis vraiment désolé car je ne comprends pas vraiment votre réponse. Vous ne savez pas à quoi "rectangle englobant" fait référence ou comment "pour chaque cellule et chaque polyomino" serait traduit dans le code (boucle imbriquée "pour"?). Quoi qu'il en soit, pourriez-vous me dire si votre explication concerne le cas des polyominos libres (la question a été modifiée pour plus de clarté).
solub