Dynamic Programming contents
DP on a Broken Profile: Domino Tilings
Count the ways to tile a grid with dominoes by processing cell by cell and remembering a bitmask of the frontier, the broken profile.
Read first: Bit Manipulation, Introduction to Dynamic Programming
Profile DP (also called broken profile or bitmask DP on a grid) solves problems of the form
- count the ways to completely fill a grid with some figures (dominoes, L-shapes, ...),
- find a fill using the minimum number of figures,
- find a partial fill leaving the fewest cells uncovered, or such that no further figure fits.
The state is the current position in the grid together with a bitmask describing the frontier, the profile, which cells near the current position are already covered. The grid must be narrow (about to columns) because the number of states is .
The problem "Parquet"
Given an grid, count the ways to cover it completely with dominoes (each cell covered exactly once, no overlaps).
First idea: row by row
Let dp[i][mask] be the number of ways to fill rows completely, where mask says which cells of row are already covered by vertical dominoes sticking down from row . Filling row then needs a small search over how the free cells are covered (horizontally, or by verticals into row ). Correct, but each transition enumerates many masks.
Better: cell by cell (the broken profile)
Process the cells in row-major order, one at a time, and keep a mask of bits with the meaning:
- for the cells not yet processed in the current row (columns ): bit is set if the cell is already covered (by a vertical domino from above, or by a horizontal domino started at );
- for the already processed cells of the current row (columns ): bit is set if the cell below is already covered by a vertical domino started at .
Bit always refers to "the cell at the current position, or the one directly below it once we leave". At cell there are three cases:
- The cell is already covered (bit is set): nothing to place, clear the bit (the cell below is not covered).
- Place a vertical domino covering and (needs ): set bit (it now describes the cell below).
- Place a horizontal domino covering and (needs and free, i.e. bit clear): set bit ; bit stays clear.
Every cell has transitions per state, so the total is .
from collections import defaultdict
def domino_tilings(n, m, blocked=frozenset()):
"""Number of ways to tile an n x m grid with dominoes; `blocked` cells (i, j) must stay empty."""
dp = {0: 1}
for i in range(n):
for j in range(m):
bit = 1 << j
new = defaultdict(int)
for mask, ways in dp.items():
if (i, j) in blocked:
if not mask & bit: # nothing may already cover a blocked cell
new[mask] += ways
continue
if mask & bit: # already covered from above / the left
new[mask ^ bit] += ways
continue
if i + 1 < n and (i + 1, j) not in blocked: # vertical domino
new[mask | bit] += ways
if j + 1 < m and not mask & (bit << 1) and (i, j + 1) not in blocked: # horizontal
new[mask | (bit << 1)] += ways
dp = new
return dp.get(0, 0)
assert [domino_tilings(2, k) for k in range(1, 9)] == [1, 2, 3, 5, 8, 13, 21, 34] # Fibonacci numbers
assert domino_tilings(3, 4) == 11
assert domino_tilings(4, 4) == 36
assert domino_tilings(6, 6) == 6728
assert domino_tilings(8, 8) == 12988816
assert domino_tilings(3, 3) == 0 # odd number of cells
assert domino_tilings(3, 3, frozenset({(1, 1)})) == 2 # a ring of 8 cells: two tilingsThe answer is dp[0]: after the last cell no cell may stick out below the grid. The dictionary keeps only reachable masks, which helps a lot: many masks can never occur.
Testing against exhaustive search
A backtracking counter that always covers the first free cell:
import random
def tilings_brute(n, m, blocked):
grid = [[(i, j) in blocked for j in range(m)] for i in range(n)]
def go(pos):
while pos < n * m and grid[pos // m][pos % m]:
pos += 1
if pos == n * m:
return 1
i, j = divmod(pos, m)
total = 0
grid[i][j] = True
if j + 1 < m and not grid[i][j + 1]:
grid[i][j + 1] = True
total += go(pos + 1)
grid[i][j + 1] = False
if i + 1 < n and not grid[i + 1][j]:
grid[i + 1][j] = True
total += go(pos + 1)
grid[i + 1][j] = False
grid[i][j] = False
return total
return go(0)
random.seed(1)
for _ in range(300):
n, m = random.randint(1, 5), random.randint(1, 5)
blocked = frozenset((random.randrange(n), random.randrange(m)) for _ in range(random.randint(0, 3)))
assert domino_tilings(n, m, blocked) == tilings_brute(n, m, blocked)Speed in Python
The number of live states matters. For an board, domino_tilings visits a few hundred masks per cell and runs in a few milliseconds; for a board masks per cell, about transitions in all. Make the narrow side the mask:
import time
def tilings_narrow(n, m):
return domino_tilings(max(n, m), min(n, m))
start = time.perf_counter()
assert tilings_narrow(2, 12) == 233
assert tilings_narrow(10, 10) == 258584046368
assert time.perf_counter() - start < 30Variations
- Minimum number of figures / maximum coverage: store the best value instead of the count (
minormaxin the transition), with an extra "skip this cell" option for partial fills. - Other figures (L-trominoes, squares): the profile must cover the cells the figure can touch, i.e. two rows for L-shapes; the same cell-by-cell scheme works with a wider mask.
- Counting with obstacles (as above), or weighted placements.
- Hamiltonian paths and cycles on grids ("plug DP") use a profile of pairs of connected boundary cells instead of a plain bitmask.
The key technique is the same: choose an order of the cells so that the frontier between "decided" and "undecided" cells is as small as possible, and let the DP remember exactly that frontier.