文章目录
- 序言
- 1 求解算法代码(python)
- 2 思路细节
- 2.1 定义拼图与阵型
- 2.2 穷举复杂度
- 2.3 使用缓存进行改进(×)
- 2.3.1 LRU缓存
- 2.3.2 将2.2的solve函数改写为可缓存装饰的
- 2.4 使用剪枝进行改进(×)
- 2.5 使用更好的状态表示进行动态规划(√)
- 2.5.1 第1节代码解析
- 2.5.2 如何找到确切的解?
- 2.6 局限性与讨论
- 3 后记
序言
问题如上(每个阵型都可以进行旋转或翻转,最终填满日历拼图),其实这是之前诸葛亮列传的兵法题(可惜狗宝更新后把能这个能白嫖资源的列传给删了,emmm),那个网上有很多现成的答案,现在这个每天都不一样,用脑子想还是得花点时间。
代码如下可自取,感觉作为一道leetcode题不错,但是不用一些特殊的寄巧处理还是挺慢的,穷举情况实在是太多。目前常规7×7拼图求解时间1000秒左右,如果是5×5拼图的话,只需要0.1秒。
求解思路暂时搁置,其实本质是一个整数规划,所以关键在于怎么样能更快,一开始写了个纯穷举+DP,结果跑个5×5的都要将近半个小时,7×7的更是估算要100万个小时。后来根据问题的特殊性做了一些剪枝处理,复杂度大大降低。
贴一个明天(12月6日)的求解结果(这个求解结果要旋转180度,1表示拼图上原本就不能放的格子,2-9分别表示不同阵型的块):
[[1. 1. 1. 1. 2. 2. 2.]
[5. 6. 6. 6. 2. 2. 2.]
[5. 6. 6. 7. 7. 7. 7.]
[5. 5. 9. 9. 9. 3. 7.]
[5. 1. 4. 4. 9. 3. 3.]
[1. 1. 4. 8. 9. 8. 3.]
[1. 4. 4. 8. 8. 8. 3.]]
用pygame对上面的矩阵做一个可视化的结果:
# -*- coding: utf-8 -*-
# @author: caoyang
# @email: caoyang@stu.sufe.edu.cn
import pygame
from pygame.locals import *
class Puzzle:
color_map = {
"white": (255, 255, 255),
"black": (0, 0, 0),
"red": (255, 0, 0),
"orange": (255, 165, 0),
"yellow": (255, 255, 0),
"green": (0, 255, 0),
"blue": (0, 255, 0),
"turquoise": (64, 224, 208),
"purple": (128, 0, 128),
"grey": (128, 128, 128),
}
block_index_to_color = list(color_map.keys())
def __init__(self,
n_puzzle_rows = 7,
n_puzzle_columns = 7,
window_height_pixel = 800,
window_width_pixel = 800,
):
self.n_puzzle_rows = n_puzzle_rows
self.n_puzzle_columns = n_puzzle_columns
self.window_height_pixel = window_height_pixel
self.window_width_pixel = window_width_pixel
self.block_height_pixel = window_height_pixel // n_puzzle_rows
self.block_width_pixel = window_width_pixel // n_puzzle_columns
self.window = self.initialize_window()
self.puzzle = self.initialize_puzzle()
# 初始化窗口
def initialize_window(self):
window = pygame.display.set_mode((self.window_height_pixel, self.window_width_pixel))
pygame.display.set_caption("Calendar Puzzle")
window.fill(self.color_map["white"])
return window
# 初始化拼图及每个块
def initialize_puzzle(self):
puzzle = []
for row in range(self.n_puzzle_rows):
puzzle.append(list())
for column in range(self.n_puzzle_columns):
block = {"row": row,
"column": column,
"x_location": column * self.block_width_pixel,
"y_location": row * self.block_height_pixel,
"color": self.color_map["white"],
}
puzzle[row].append(block)
return puzzle
# 绘制网格线
def draw_grid(self):
row_interval_pixel = self.window_height_pixel // self.n_puzzle_rows
height_interval_pixel = self.window_width_pixel // self.n_puzzle_columns
for i in range(self.n_puzzle_rows):
pygame.draw.line(
self.window,
self.color_map["black"],
(0, i * row_interval_pixel),
(self.window_width_pixel, i * row_interval_pixel),
)
for j in range(self.n_puzzle_columns):
pygame.draw.line(
self.window,
self.color_map["black"],
(j * height_interval_pixel, 0),
(j * height_interval_pixel, self.window_height_pixel),
)
# 绘制块
def draw_block(self, block):
pygame.draw.rect(
self.window,
block["color"],
(block["x_location"],
block["y_location"],
self.block_width_pixel,
self.block_height_pixel,
),
)
# 绘制拼图
def draw_puzzle(self):
for row in range(self.n_puzzle_rows):
for column in range(self.n_puzzle_columns):
self.draw_block(self.puzzle[row][column])
self.draw_grid()
pygame.display.update()
# 简单展示一个拼图
def display(self, puzzle_matrix = None):
run = True
while run:
if puzzle_matrix is not None:
for row in range(self.n_puzzle_rows):
for column in range(self.n_puzzle_columns):
block_index = int(puzzle_matrix[row][column])
self.puzzle[row][column]["color"] = self.block_index_to_color[block_index]
self.draw_block(self.puzzle[row][column])
self.draw_grid()
pygame.display.update()
for event in pygame.event.get():
if event.type == pygame.QUIT:
run = False
if event.type == KEYDOWN:
if event.key == K_ESCAPE:
run = False
# 主程序(交互)
def interact(self):
run = True
while run:
self.draw_puzzle()
for event in pygame.event.get():
if event.type == pygame.QUIT:
run = False
if event.type == KEYDOWN:
if event.key == K_ESCAPE:
run = False
left, center, right = pygame.mouse.get_pressed()
# TODO: 事件触发(目前不必要)
if __name__ == "__main__":
puzzle = Puzzle(
n_puzzle_rows = 7,
n_puzzle_columns = 7,
window_height_pixel = 800,
window_width_pixel = 800,
)
puzzle.display(
[[1, 1, 1, 1, 2, 2, 2,],
[5, 6, 6, 6, 2, 2, 2,],
[5, 6, 6, 7, 7, 7, 7,],
[5, 5, 9, 9, 9, 3, 7,],
[5, 1, 4, 4, 9, 3, 3,],
[1, 1, 4, 8, 9, 8, 3,],
[1, 4, 4, 8, 8, 8, 3,],]
)
可视化结果如下所示:
1 求解算法代码(python)
代码自取:
# -*- coding: utf-8 -*-
# @author: caoyang
# @email: caoyang@stu.sufe.edu.cn
import time
import numpy
from pprint import pprint
from copy import deepcopy
from functools import lru_cache
FORMATION_ABBR_TO_NAME = {
"BDQX": "北斗七星阵",
"FS": "锋矢阵",
"SLCS": "双龙出水阵",
"JQHH": "九曲黄河阵",
"JS": "金锁阵",
"F": "方阵",
"XX": "玄襄阵",
"GX": "钩形阵",
"YZCS": "一字长蛇阵",
"SC": "三才阵",
"TM": "天门阵",
"HY": "混元阵",
"YY": "鸳鸯阵",
}
FORMATION_NAME_TO_ABBR = {_abbr: _name for _name, _abbr in FORMATION_ABBR_TO_NAME.items()}
# 生成给定日期的日历拼图
def generate_calendar_puzzle(month = 12, day = 3):
month_days = [31, 28, 31, 30, 31, 60, 31, 31, 30, 31, 30, 31]
calendar_puzzle = [[0] * 7 for i in range(7)]
for i in range(35 - month_days[month - 1]):
calendar_puzzle[6][7 - i - 1] = 1
calendar_puzzle[(day - 1) // 7 + 2][(day - 1) % 7] = 1
calendar_puzzle[0][6] = 1
calendar_puzzle[1][6] = 1
calendar_puzzle[(month - 1) // 6][(month - 1) % 6] = 1
return calendar_puzzle
# 定义阵型及其变体
def define_formation_variants(names):
abbrs = map(lambda _name: FORMATION_NAME_TO_ABBR[_name], names)
# 北斗七星阵
BDQX = [
[[1, 0], [1, 1], [0, 1], [0, 1]],
[[0, 1], [1, 1], [1, 0], [1, 0]],
[[1, 0], [1, 0], [1, 1], [0, 1]],
[[0, 1], [0, 1], [1, 1], [1, 0]],
[[0, 0, 1, 1], [1, 1, 1, 0]],
[[1, 1, 0, 0], [0, 1, 1, 1]],
[[0, 1, 1, 1], [1, 1, 0, 0]],
[[1, 1, 1, 0], [0, 0, 1, 1]],
]
# 锋矢阵
FS = [
[[1, 1, 1], [1, 0, 0], [1, 0, 0]],
[[1, 1, 1], [0, 0, 1], [0, 0, 1]],
[[0, 0, 1], [0, 0, 1], [1, 1, 1]],
[[1, 0, 0], [1, 0, 0], [1, 1, 1]],
]
# 双龙出水阵
SLCS = [
[[1, 1], [1, 0], [1, 1]],
[[1, 1], [0, 1], [1, 1]],
[[1, 1, 1], [1, 0, 1]],
[[1, 0, 1], [1, 1, 1]],
]
# 九曲黄河阵
JQHH = [
[[0, 1, 1], [0, 1, 0], [1, 1, 0]],
[[1, 1, 0], [0, 1, 0], [0, 1, 1]],
[[1, 0, 0], [1, 1, 1], [0, 0, 1]],
[[0, 0, 1], [1, 1, 1], [1, 0, 0]],
]
# 金锁阵
JS = [
[[1, 0], [1, 1], [1, 1]],
[[0, 1], [1, 1], [1, 1]],
[[1, 1, 1], [1, 1, 0]],
[[1, 1, 1], [0, 1, 1]],
[[1, 1], [1, 1], [1, 0]],
[[1, 1], [1, 1], [0, 1]],
[[1, 1, 0], [1, 1, 1]],
[[0, 1, 1], [1, 1, 1]],
]
# 方阵
F = [
[[1, 1, 1], [1, 1, 1]],
[[1, 1], [1, 1], [1, 1]],
]
# 玄襄阵
XX = [
[[1, 1, 1, 1], [1, 0, 0, 0]],
[[1, 1, 1, 1], [0, 0, 0, 1]],
[[1, 1], [0, 1], [0, 1], [0, 1]],
[[1, 1], [1, 0], [1, 0], [1, 0]],
[[1, 0, 0, 0], [1, 1, 1, 1]],
[[0, 0, 0, 1], [1, 1, 1, 1]],
[[0, 1], [0, 1], [0, 1], [1, 1]],
[[1, 0], [1, 0], [1, 0], [1, 1]],
]
# 钩形阵
GX = [
[[1, 0], [1, 0], [1, 1], [1, 0]],
[[0, 1], [0, 1], [1, 1], [0, 1]],
[[1, 1, 1, 1], [0, 1, 0, 0]],
[[1, 1, 1, 1], [0, 0, 1, 0]],
[[0, 1], [1, 1], [0, 1], [0, 1]],
[[1, 0], [1, 1], [1, 0], [1, 0]],
[[0, 1, 0, 0], [1, 1, 1, 1]],
[[0, 0, 1, 0], [1, 1, 1, 1]],
]
# 一字长蛇阵
YZCS = [
[[1, 1, 1, 1, 1]],
[[1], [1], [1], [1], [1]],
]
# 三才阵
SC = [
[[1, 1, 1], [0, 1, 0], [0, 1, 0]],
[[0, 1, 0], [0, 1, 0], [1, 1, 1]],
[[1, 0, 0], [1, 1, 1], [1, 0, 0]],
[[0, 0, 1], [1, 1, 1], [0, 0, 1]],
]
# 天门阵
TM = [
[[0, 1, 0], [1, 1, 1], [0, 1, 0]],
]
# 混元阵
HY = [
[[0, 1, 0], [0, 1, 1], [1, 1, 0]],
[[0, 1, 0], [1, 1, 0], [0, 1, 1]],
[[1, 0, 0], [1, 1, 1], [0, 1, 0]],
[[0, 0, 1], [1, 1, 1], [0, 1, 0]],
[[0, 1, 1], [1, 1, 0], [0, 1, 0]],
[[1, 1, 0], [0, 1, 1], [0, 1, 0]],
[[0, 1, 0], [1, 1, 1], [0, 0, 1]],
[[0, 1, 0], [1, 1, 1], [1, 0, 0]],
]
# 鸳鸯阵
YY = [
[[0, 1, 1], [1, 1, 0], [1, 0, 0]],
[[1, 1, 0], [0, 1, 1], [0, 0, 1]],
[[0, 0, 1], [0, 1, 1], [1, 1, 0]],
[[1, 0, 0], [1, 1, 0], [0, 1, 1]],
]
formation_variants = dict()
for abbr in abbrs:
formations = eval(abbr)
index = -1
new_abbr = abbr
while True:
if not new_abbr in formation_variants:
formation_variants[new_abbr] = list(map(lambda _formation: {"matrix": _formation}, formations))
break
else:
# 可能出现的重复阵型,添加不同的序号后缀作为命名区分
index += 1
new_abbr = f"{abbr}_{index}"
return formation_variants
# 使用动态规划:这里我们用编码,所以无需用到稀疏表示,matrix即可
def solve_dp(puzzle, # 原始日历拼图
formation_variants, # 可用阵型及其变形,形如:{"XX": List[Dict{matrix: List[List[Int]], sparse: List[Tuple(x, y)]}], "XXX": 类似前面}
for_solution = False, # 是否找到解法
):
# 将给定的puzzle矩阵转为puzzle_height×puzzle_width长度的编码向量(以零一字符串表示)
def _puzzle_to_code(_puzzle):
return ''.join([''.join(map(str, _puzzle_row)) for _puzzle_row in _puzzle])
# _puzzle_to_code的逆运算
def _code_to_puzzle(_puzzle_code):
_puzzle = numpy.zeros((puzzle_height, puzzle_width))
_pointer = -1
for _i in range(puzzle_height):
for _j in range(puzzle_width):
_pointer += 1
_puzzle[_i, _j] = _puzzle_code[_pointer]
return _puzzle
# 注意到一个puzzle通过旋转/翻转操作,一共会有8种等价的变体(自身与翻转)
# 取最大的编码向量(以零一字符串的数值作为表示)作为唯一编码值,以减少很多重复
# 特别地,每日演兵是一个正方阵,所以连行列都不需要区分,这在下面的formation编码中有很大的助益
# 最大编码值对应的拼图,即块集中在左上角
def _puzzle_to_unique_code(_puzzle):
_rotate_puzzle_copy = _puzzle.copy()
_rotate_puzzle_copy_flip = numpy.fliplr(_rotate_puzzle_copy)
_codes = [_puzzle_to_code(_rotate_puzzle_copy), _puzzle_to_code(_rotate_puzzle_copy_flip)]
# 旋转3次
for _ in range(3):
_rotate_puzzle_copy = numpy.rot90(_rotate_puzzle_copy)
_rotate_puzzle_copy_flip = numpy.rot90(_rotate_puzzle_copy_flip)
_codes.append(_puzzle_to_code(_rotate_puzzle_copy))
_codes.append(_puzzle_to_code(_rotate_puzzle_copy_flip))
return max(_codes)
# 生成formation编码()的方法:
# - 将formation_matrix每行扩展到跟puzzle_width(零填充)
# - 然后类似_puzzle_to_code的方法,但是要把右侧的零给排除
# - _block_number表示用哪个数字表示块,一般用1,但是为了能看到最终求解的结果,还需要存一份区分不同阵型编码的(即colorful,五彩斑斓的)的阵型编码
def _formation_to_code(_formation_matrix, _block_number = 1):
_formation_matrix = numpy.array(_formation_matrix, dtype=int) * _block_number
_formation_matrix_expand = numpy.concatenate([_formation_matrix, numpy.zeros((_formation_matrix.shape[0], puzzle_width - _formation_matrix.shape[1]))], axis=1)
_formation_matrix_expand = numpy.asarray(_formation_matrix_expand, dtype=int)
_formation_code = ''.join([''.join(map(str, _formation_row)) for _formation_row in _formation_matrix_expand])
return _formation_code.rstrip('0')
# 将formation在指定的位置(pointer)插入puzzle
# 如果指定的位置无法插入,则返回False与空字符串
# 否则返回True和插入后的puzzle_code
def add_formation_code_to_puzzle_code(_puzzle_code, _formation_code, _pointer):
# print(_puzzle_code, len(_puzzle_code))
# print(_formation_code, len(_formation_code))
_result_code = str()
# 插入部分:_pointer, _pointer + 1, ..., _pointer + len(_formation_code) - 1
_start_pointer, _end_pointer = _pointer, _pointer + len(_formation_code)
for _i in range(_start_pointer, _end_pointer):
_formation_char_int = int(_formation_code[_i - _pointer])
_puzzle_char_int = int(_puzzle_code[_i])
if _formation_char_int == 0 or _puzzle_char_int == 0:
# 阵型和拼图至少有一个在当前位置是空的
_result_code += str(_formation_char_int + _puzzle_char_int)
else:
# 否则无法插入阵型
return False, None
# 补上头尾
_result_code = _puzzle_code[: _start_pointer] + _result_code + _puzzle_code[_end_pointer: ]
return True, _result_code
# 注意到,一个形状为(formation_height, formation_width)的阵型,只可能在以下的_pointer插入拼图:
# - _pointer = i × puzzle_width + j
# - 其中:i取值范围是(0, 1, ..., puzzle_height - formation_height),j取值范围是(0, 1, ...., puzzle_width - formation_width)
def _generate_possible_pointer(_formation_height, _formation_width):
for _i in range(puzzle_height - _formation_height + 1):
for _j in range(puzzle_width - _formation_width + 1):
yield _i * puzzle_width + _j
# 递归算法
@lru_cache(None)
def _dp(_puzzle_unique_code, # Str 当前拼图的编码值(唯一编码值)
_remained_formation_ids, # Tuple 剩余可用的阵型编码
_for_solution = False, # 是否需要找到确切的解法
):
# 终止条件:_puzzle_unique_code全是1,或者_remained_formation_ids为空
if not _remained_formation_ids:
# 找到一组解
print("成功找到一组解!")
for _char in _puzzle_unique_code:
assert int(_char) > 0
if _for_solution:
pprint(_code_to_puzzle(_puzzle_unique_code))
with open("solution.txt", 'w', encoding="utf8") as f:
f.write(str(_code_to_puzzle(_puzzle_unique_code)))
return True
# 遍历每个可用的阵型
for _remained_formation_id in _remained_formation_ids:
# 遍历每个可用阵型的变体
_can_put_in = False
if for_solution:
formation_variant_codes = formation_codes_num[_remained_formation_id]
else:
formation_variant_codes = formation_codes[_remained_formation_id]
for _formation_variant_code, (_formation_height, _formation_width) in zip(formation_variant_codes, formation_sizes[_remained_formation_id]):
# 遍历每个可能可以插入的位置
for _possible_pointer in _generate_possible_pointer(_formation_height, _formation_width):
# 试着插入
_add_flag, _updated_puzzle_code = add_formation_code_to_puzzle_code(
_puzzle_code = _puzzle_unique_code,
_formation_code = _formation_variant_code,
_pointer = _possible_pointer,
)
# 成功:可以插入阵型
if _add_flag:
# 则迭代
_remained_formation_ids_list_copy = list(_remained_formation_ids)
_remained_formation_ids_list_copy.remove(_remained_formation_id)
_updated_remained_formation_ids = tuple(_remained_formation_ids_list_copy)
_result_flag = _dp(
_puzzle_unique_code = _updated_puzzle_code,
_remained_formation_ids = _updated_remained_formation_ids,
_for_solution = _for_solution,
)
_can_put_in = True
if _result_flag:
# 找到一个即可
return True
# 失败:此处不可以插入阵型
else:
pass
if not _can_put_in:
# 当前阵型以及它的所有变体无法在任何位置插入
return False
# 将puzzle转为矩阵
if isinstance(puzzle, list):
puzzle = numpy.array(puzzle, dtype=int)
puzzle_height, puzzle_width = puzzle.shape
assert puzzle_height == puzzle_width, "动态规划算法目前仅支持正方阵的求解"
total_formation = len(formation_variants)
print(f"拼图形状:{(puzzle_height, puzzle_width)}")
print(f"阵型总数:{total_formation}")
# 将阵型转为矩阵:List[List[Str(formation_code)]]
formation_codes = list() # 零一字符串(块统一用1表示)
formation_codes_num = list() # 每个阵型的块用不同的数字表示
formation_sizes = list() # 记录阵型的高和宽
for i, formation_name in enumerate(formation_variants):
formation_variant_codes = list()
formation_variant_codes_num = list()
formation_variant_sizes = list()
for formation_variant in formation_variants[formation_name]:
formation_matrix = formation_variant["matrix"]
formation_variant_codes.append(_formation_to_code(formation_matrix, _block_number = 1))
formation_variant_codes_num.append(_formation_to_code(formation_matrix, _block_number = i + 2))
formation_variant_sizes.append((len(formation_matrix), len(formation_matrix[0])))
formation_codes.append(formation_variant_codes)
formation_codes_num.append(formation_variant_codes_num)
formation_sizes.append(formation_variant_sizes)
_puzzle_unique_code = _puzzle_to_unique_code(puzzle)
# _dp(_puzzle_unique_code, _remained_formation_ids=tuple(range(total_formation)), _for_solution = False)
_dp(_puzzle_unique_code, _remained_formation_ids=tuple(range(total_formation)), _for_solution = for_solution)
# 运行每日练兵
def run():
# 生成今日拼图
month, day = int(time.strftime("%m")), int(time.strftime("%d"))
calendar_puzzle = generate_calendar_puzzle(month, day)
print(f"{month}月{day}日拼图:")
pprint(calendar_puzzle)
# 生成今日阵型
names = ["方阵", "北斗七星阵", "九曲黄河阵", "钩形阵", "金锁阵", "玄襄阵", "双龙出水阵", "锋矢阵"]
formation_variants = define_formation_variants(names)
solve_dp(calendar_puzzle, formation_variants, for_solution=True)
if __name__ == "__main__":
run()
2 思路细节
2.1 定义拼图与阵型
拼图类问题大多可以转化为动态规划解决,关键在于如何状态的设计以及递归形式。
但不管使用什么样的方法,首先还是要把所有的阵型以及拼图表示为矩阵(二维数组)形式,我们先用列表来定义
# -*- coding: utf-8 -*-
# @author: caoyang
# @email: caoyang@stu.sufe.edu.cn
import time
import numpy
from pprint import pprint
from copy import deepcopy
from functools import lru_cache
FORMATION_ABBR_TO_NAME = {
"BDQX": "北斗七星阵",
"FS": "锋矢阵",
"SLCS": "双龙出水阵",
"JQHH": "九曲黄河阵",
"JS": "金锁阵",
"F": "方阵",
"XX": "玄襄阵",
"GX": "钩形阵",
"YZCS": "一字长蛇阵",
"SC": "三才阵",
"TM": "天门阵",
"HY": "混元阵",
"YY": "鸳鸯阵",
}
FORMATION_NAME_TO_ABBR = {_abbr: _name for _name, _abbr in FORMATION_ABBR_TO_NAME.items()}
# 生成给定日期的日历拼图
def generate_calendar_puzzle(month = 12, day = 3):
month_days = [31, 28, 31, 30, 31, 60, 31, 31, 30, 31, 30, 31]
calendar_puzzle = [[0] * 7 for i in range(7)]
for i in range(35 - month_days[month - 1]):
calendar_puzzle[6][7 - i - 1] = 1
calendar_puzzle[(day - 1) // 7 + 2][(day - 1) % 7] = 1
calendar_puzzle[0][6] = 1
calendar_puzzle[1][6] = 1
calendar_puzzle[(month - 1) // 6][(month - 1) % 6] = 1
return calendar_puzzle
# 定义阵型及其变体
def define_formation_variants(names):
abbrs = map(lambda _name: FORMATION_NAME_TO_ABBR[_name], names)
# 北斗七星阵
BDQX = [
[[1, 0], [1, 1], [0, 1], [0, 1]],
[[0, 1], [1, 1], [1, 0], [1, 0]],
[[1, 0], [1, 0], [1, 1], [0, 1]],
[[0, 1], [0, 1], [1, 1], [1, 0]],
[[0, 0, 1, 1], [1, 1, 1, 0]],
[[1, 1, 0, 0], [0, 1, 1, 1]],
[[0, 1, 1, 1], [1, 1, 0, 0]],
[[1, 1, 1, 0], [0, 0, 1, 1]],
]
# 锋矢阵
FS = [
[[1, 1, 1], [1, 0, 0], [1, 0, 0]],
[[1, 1, 1], [0, 0, 1], [0, 0, 1]],
[[0, 0, 1], [0, 0, 1], [1, 1, 1]],
[[1, 0, 0], [1, 0, 0], [1, 1, 1]],
]
# 双龙出水阵
SLCS = [
[[1, 1], [1, 0], [1, 1]],
[[1, 1], [0, 1], [1, 1]],
[[1, 1, 1], [1, 0, 1]],
[[1, 0, 1], [1, 1, 1]],
]
# 九曲黄河阵
JQHH = [
[[0, 1, 1], [0, 1, 0], [1, 1, 0]],
[[1, 1, 0], [0, 1, 0], [0, 1, 1]],
[[1, 0, 0], [1, 1, 1], [0, 0, 1]],
[[0, 0, 1], [1, 1, 1], [1, 0, 0]],
]
# 金锁阵
JS = [
[[1, 0], [1, 1], [1, 1]],
[[0, 1], [1, 1], [1, 1]],
[[1, 1, 1], [1, 1, 0]],
[[1, 1, 1], [0, 1, 1]],
[[1, 1], [1, 1], [1, 0]],
[[1, 1], [1, 1], [0, 1]],
[[1, 1, 0], [1, 1, 1]],
[[0, 1, 1], [1, 1, 1]],
]
# 方阵
F = [
[[1, 1, 1], [1, 1, 1]],
[[1, 1], [1, 1], [1, 1]],
]
# 玄襄阵
XX = [
[[1, 1, 1, 1], [1, 0, 0, 0]],
[[1, 1, 1, 1], [0, 0, 0, 1]],
[[1, 1], [0, 1], [0, 1], [0, 1]],
[[1, 1], [1, 0], [1, 0], [1, 0]],
[[1, 0, 0, 0], [1, 1, 1, 1]],
[[0, 0, 0, 1], [1, 1, 1, 1]],
[[0, 1], [0, 1], [0, 1], [1, 1]],
[[1, 0], [1, 0], [1, 0], [1, 1]],
]
# 钩形阵
GX = [
[[1, 0], [1, 0], [1, 1], [1, 0]],
[[0, 1], [0, 1], [1, 1], [0, 1]],
[[1, 1, 1, 1], [0, 1, 0, 0]],
[[1, 1, 1, 1], [0, 0, 1, 0]],
[[0, 1], [1, 1], [0, 1], [0, 1]],
[[1, 0], [1, 1], [1, 0], [1, 0]],
[[0, 1, 0, 0], [1, 1, 1, 1]],
[[0, 0, 1, 0], [1, 1, 1, 1]],
]
# 一字长蛇阵
YZCS = [
[[1, 1, 1, 1, 1]],
[[1], [1], [1], [1], [1]],
]
# 三才阵
SC = [
[[1, 1, 1], [0, 1, 0], [0, 1, 0]],
[[0, 1, 0], [0, 1, 0], [1, 1, 1]],
[[1, 0, 0], [1, 1, 1], [1, 0, 0]],
[[0, 0, 1], [1, 1, 1], [0, 0, 1]],
]
# 天门阵
TM = [
[[0, 1, 0], [1, 1, 1], [0, 1, 0]],
]
# 混元阵
HY = [
[[0, 1, 0], [0, 1, 1], [1, 1, 0]],
[[0, 1, 0], [1, 1, 0], [0, 1, 1]],
[[1, 0, 0], [1, 1, 1], [0, 1, 0]],
[[0, 0, 1], [1, 1, 1], [0, 1, 0]],
[[0, 1, 1], [1, 1, 0], [0, 1, 0]],
[[1, 1, 0], [0, 1, 1], [0, 1, 0]],
[[0, 1, 0], [1, 1, 1], [0, 0, 1]],
[[0, 1, 0], [1, 1, 1], [1, 0, 0]],
]
# 鸳鸯阵
YY = [
[[0, 1, 1], [1, 1, 0], [1, 0, 0]],
[[1, 1, 0], [0, 1, 1], [0, 0, 1]],
[[0, 0, 1], [0, 1, 1], [1, 1, 0]],
[[1, 0, 0], [1, 1, 0], [0, 1, 1]],
]
formation_variants = dict()
for abbr in abbrs:
formations = eval(abbr)
index = -1
new_abbr = abbr
while True:
if not new_abbr in formation_variants:
formation_variants[new_abbr] = list(map(lambda _formation: {"matrix": _formation, "sparse": generate_sparse_formation(_formation)}, formations))
break
else:
# 可能出现的重复阵型,添加不同的序号后缀作为命名区分
index += 1
new_abbr = f"{abbr}_{index}"
return formation_variants
# 阵型稀疏表示:即只存储formation矩阵中数值为1的坐标
def generate_sparse_formation(formation):
sparse_formation = list()
formation_height, formation_width = len(formation), len(formation[0])
for i in range(formation_height):
for j in range(formation_width):
if formation[i][j] == 1:
sparse_formation.append((i, j))
return sparse_formation
这里用1来表示拼图(或阵型)上已有的块,0则表示该位置是空,如:
12月7日日历拼图:
[[0, 0, 0, 0, 0, 0, 1],
[0, 0, 0, 0, 0, 1, 1],
[0, 0, 0, 0, 0, 0, 1],
[0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 1, 1, 1, 1]]
由于阵型是可以自由旋转和翻转的,因此同一个阵型最多可以对应8种不同的变换形式(4次旋转,2次翻转,4×2=8),比如在上面define_formation_variants
函数的定义,北斗七星阵有8种变体(有4×2
和2×4
两种形状的矩阵),锋矢阵只有4种(因为它是轴对称图形,都是3×3
),一字长蛇阵就只有2种变体(1×5
和5×1
)。
2.2 穷举复杂度
为了便于描述,我们做一些必要的数学标记:
- H H H:拼盘的高度
- W W W:拼盘的宽度
- h h h:阵型的高度
- w w w:阵型的宽度
- n n n:可用的阵型数
在当前问题背景下: H = W = 7 , n = 8 H=W=7, n=8 H=W=7,n=8, h h h和 w w w不固定,存在多种不同的组合,例如, ( h , w ) = ( 2 , 3 ) , ( 2 , 4 ) , ( 3 , 3 ) , ( 1 , 5 ) (h,w)=(2,3),(2,4),(3,3),(1,5) (h,w)=(2,3),(2,4),(3,3),(1,5)
不过,我们还是可以简单估计一下穷举的复杂度,比如,可以假定所有阵型都相同,且不区分它们的高度和宽度,那么每个阵型的每种变体至多有 ( H − h + 1 ) ( W − w + 1 ) (H-h+1)(W-w+1) (H−h+1)(W−w+1)种可能的放法,而每个阵型又至多有 8 8 8种变体,那么穷举的复杂度就是:
N = ( 8 ( H − h + 1 ) ( W − w + 1 ) ) n N=(8(H-h+1)(W-w+1))^n N=(8(H−h+1)(W−w+1))n
而且在这 N N N种情况中,每种情况由 n n n个阵型及其对应的放置位置 L = { ( x 1 , y 1 ) , . . . , ( x n , y n ) } L=\{(x_1,y_1),...,(x_n,y_n)\} L={(x1,y1),...,(xn,yn)}构成,因此,算法还需判断第 i i i个阵型是否可以在 ( x i , y i ) (x_i,y_i) (xi,yi)处被安置(可能根本就放不下),这需要还遍历每个阵型的所有块,这将需要 h w hw hw次检查:
- 不过,在2.1的代码的155行(
formation_variants[new_abbr] = list(map(lambda _formation: {"matrix": _formation, "sparse": generate_sparse_formation(_formation)}, formations))
),我们为每个阵型的变体存储了矩阵形式(matrix
),以及稀疏表示形式(sparse
),通过稀疏表示(只存储阵型上块的位置,而非整个 h × w h\times w h×w矩阵),这样可以一定程度将 h w hw hw次的检查减少一定数量,但数量级相差很小。
因此,穷举的总复杂度是:
Θ ( H , W , h , w , n ) = Θ ( N ⋅ h w ) = Θ ( 8 n h w ( H − h + 1 ) n ( W − w + 1 ) n ) \Theta(H,W,h,w,n)=\Theta(N\cdot hw)=\Theta(8^nhw(H-h+1)^n(W-w+1)^n) Θ(H,W,h,w,n)=Θ(N⋅hw)=Θ(8nhw(H−h+1)n(W−w+1)n)
按照 H = W = 7 , n = 8 , h = w = 3 H=W=7, n=8, h=w=3 H=W=7,n=8,h=w=3来估计,约有 4.26 × 1 0 21 4.26×10^{21} 4.26×1021种情况需要穷举。
显然,这样的复杂度是不可接受的。
不过我们还是提供一版穷举搜索的递归函数solve
:
def solve(puzzle, # List[List[Int]]
formation_matrix_and_sparse_list, # 可用阵型,形如:({"matrix": List[List[Int]], "sparse": List[Tuple(x, y)]}, ...)
solution_id = list(), # 用于迭代的求解列表List[Int],存储阵型的序号
solution_xy = list(), # 用于迭代的求解列表List[(x, y)],存储阵型放入位置的坐标
puzzle_height = 7,
puzzle_width = 7,
):
# 检查拼图上的空余块数是否和剩余阵型的总块数匹配,如果不匹配,问题显然无解
total_formation_blocks = 0
for formation_id, formation_matrix_and_sparse in enumerate(formation_matrix_and_sparse_list):
if not formation_id in solution_id:
total_formation_blocks += len(formation_matrix_and_sparse["sparse"])
total_empty_blocks = sum([row.count(0) for row in puzzle])
if total_formation_blocks != total_empty_blocks:
print(f"问题无解:当前所有阵型的块数总和{total_formation_blocks},拼图剩余块数{total_empty_blocks}")
return False
elif total_formation_blocks == 0:
# 拼图填满,成功!
print(f"成功找到一组解:{solution_id}, {solution_xy}")
with open("solution.txt", 'a', encoding="utf8") as f:
f.write(f"{solution_id}\t{solution_xy}\n")
return True
# 准备一个拼图的副本
puzzle_copy = deepcopy(puzzle)
for formation_id, formation_matrix_and_sparse in enumerate(formation_matrix_and_sparse_list):
# 取一个阵型,试着把它放入拼图
# 首先检查该阵型是否已经被使用过了
if formation_id in solution_id:
continue
# 放入策略是该阵型的左上角的块(即formation[0][0])依次放入拼图的每一个位置
# 检查合法性,如果不合法则依次移动窗口
formation_matrix, formation_sparse = formation_matrix_and_sparse["matrix"], formation_matrix_and_sparse["sparse"]
formation_height, formation_width = len(formation_matrix), len(formation_matrix[0])
is_accommodate = False
for x in range(puzzle_height - formation_height + 1):
for y in range(puzzle_width - formation_width + 1):
# 遍历拼图每一个可以容纳阵型的位置:
# x: 0 => puzzle_height - formation_height
# y: 0 => puzzle_width - formation_width
# 判断当前阵型是否可以放入拼图的(x, y)位置
can_put_in = True
puzzle_updated = deepcopy(puzzle_copy)
for i, j in formation_sparse:
# 检查阵型的每一个块是否可以被容纳(基于阵型矩阵的稀疏表示来搜索,这样循环的次数会少一些)
# i, j就是阵型上块所在的相对坐标
# x + i, y + j是阵型上的块放入拼图的绝对坐标
if puzzle_copy[x + i][y + j] == 1:
# 当前位置已经被其他阵型的块占据,也可能本来就不能放入
can_put_in = False
break
else:
# 当前块空闲,直接放入
puzzle_updated[x + i][y + j] = 1
if can_put_in:
# 阵型可以放入拼图的(x, y)位置,则放入
# 更新拼图,求解列表
is_accommodate = True
solution_id_updated = solution_id + [formation_id]
solution_xy_updated = solution_xy + [(x, y)]
result_flag = solve(
deepcopy(puzzle_updated),
formation_matrix_and_sparse_list,
solution_id = solution_id_updated,
solution_xy = solution_xy_updated,
puzzle_height = puzzle_height,
puzzle_width = puzzle_width,
)
if result_flag:
# 说明接下来的存在某分支找到了正确的解答,则终止(需求只要找到一个正解即可)
return True
else:
# 不可放入,直接删除puzzle_update,释放内存
del puzzle_updated
if not is_accommodate:
# 说明当前阵型无法在拼图上的任何位置被放入
# 此时到达叶子节点
# print(f"分支无解:第{formation_id}块无法被放入拼图")
# pprint(puzzle)
return False
# 应该是到不了这里的
return False
def test_1():
with open("solution.txt", 'w', encoding="utf8") as f:
pass
calendar_puzzle = [[0] * 5 for i in range(5)]
pprint(calendar_puzzle)
formation_variants = define_formation_variants(names = ["双龙出水阵", "玄襄阵", "九曲黄河阵", "三才阵", "金锁阵"])
pprint(formation_variants)
n_products = 1
for formations in formation_variants.values():
n_products *= len(formations)
print(f"共计{n_products}种不同的阵型组合")
# 遍历所有阵型并求解
for i, formation_matrix_and_sparse_list in enumerate(itertools.product(*formation_variants.values())):
if i % 1 == 0:
print(i, time.strftime("%Y-%m-%d %H:%M:%S"))
# pprint(formation_matrix_and_sparse_list)
solve(puzzle = calendar_puzzle,
formation_matrix_and_sparse_list = formation_matrix_and_sparse_list,
solution_id = [],
solution_xy = [],
puzzle_height = len(calendar_puzzle),
puzzle_width = len(calendar_puzzle[0]),
)
test_1()
在上面这个例子中,我们测试的是一个5×5的拼图👇
而运行情况是这样的:
5个运行结果存储在同目录下的solution.txt
中(记录每个阵型左上角放置的位置👇,解不唯一):
[0, 1, 2, 3, 4] [(2, 0), (0, 3), (1, 1), (2, 2), (0, 0)]
[0, 1, 2, 3, 4] [(0, 0), (1, 3), (1, 1), (0, 2), (3, 0)]
从上面的运行截图可以看到,5个阵型的变体一共有4096种组合(4×4×4×8×8=4096),而每秒钟只能处理2种情况,一共需要半个多小时。如果是7×7的拼图,一共会有50万以上的阵型组合数,而每种组合的处理时间只会更长(事实上这需要2个小时,全部穷举完估计需要100万小时,那确实不如开动脑筋相出解法)
2.3 使用缓存进行改进(×)
2.3.1 LRU缓存
一种提升思路是使用缓存,LRU缓存(最近最少使用)较为常用,此外还有LFU和FIFO是常见的缓存算法。
在Python中,直接使用lru_cache
即可,刷DP题常用方法:
from functools import lru_cache
@lru_cache(None)
def f(a, b):
print("Run f ...")
return a + b + c
c = 100
print(f(1, 2))
c = 102
print(f(1, 2))
运行结果:
Run f ...
103
103
- 这个例子其实可以看出,缓存有时是危险的,因为它只检查两次调用
f
时的参数是否一致(其实如果第二次是f(1., 2.)
也是会直接调用缓存,但是如果f
只有一个参数,那么两次数据类型不同就不会调用缓存了,版本特色了属于是),但是外部的c
改变了之后,事实上运行结果应该改变,但是第二次因为没有真正调用f
(没有显示Run f ...
),所以结果依然是103
,但应该是105
但是能带lru_cache
装饰器的函数,要求参数列表必须是hashable的,比如Int, Str, Boolean
这些简单数据类型都是可以的,而List, Dict
则不行,不过Tuple
是可行的,所以可以用Tuple
替代掉List
,来实现hashable👇
from functools import lru_cache
@lru_cache(None)
def f(a, b):
print("Run f ...")
return a[0] + b[0] + c
c = 100
print(f((1, 2), (2, 3)))
c = 102
print(f([1, 2], [2, 3]))
运行结果:
Run f ...
103
Traceback (most recent call last):
File "game.py", line 11, in <module>
print(f([1, 2], [2, 3]))
TypeError: unhashable type: 'list'
2.3.2 将2.2的solve函数改写为可缓存装饰的
def solve(puzzle, # List[List[Int]]
formation_matrix_and_sparse_list, # 可用阵型,形如:({"matrix": List[List[Int]], "sparse": List[Tuple(x, y)]}, ...)
solution_id = list(), # 用于迭代的求解列表List[Int],存储阵型的序号
solution_xy = list(), # 用于迭代的求解列表List[(x, y)],存储阵型放入位置的坐标
puzzle_height = 7,
puzzle_width = 7,
):
...
2.2的solve
函数列表中有三个参数是List
,我们要把它们处理掉:
puzzle
可以直接扔了,因为我们可以直接根据原始日历拼图,以及solution_id
和solution_xy
直接复原这个puzzle
,虽然需要费点时间solution_id
和solution_xy
直接改写为tuple
即可
# 使用缓存加速(失败,没有加速效果)
def solve_cached(puzzle, # 原始日历拼图
formation_matrix_and_sparse_list, # 可用阵型,形如:({"matrix": List[List[Int]], "sparse": List[Tuple(x, y)]}, ...)
puzzle_height = 7,
puzzle_width = 7,
total_formation = 8,
is_prune = False,
):
@lru_cache(None)
def _solve_cached(_solution_id, # 以元组表示(列表不支持哈希缓存),比如给定5个阵型,已经放入第0个和第2个阵型,则可以记录为(0, 2)
_solution_xy, # 以元组表示,比如上面例举的阵型(列表不支持哈希缓存),第0个放在(0, 0),第2个放在(3, 4),则记录为((0, 0), (3, 4))
):
# 算法终止
if len(_solution_id) == total_formation:
# 阵型全部放入,意味着拼图填满,成功!
print(f"成功找到一组解:{_solution_id}, {_solution_xy}")
with open("solution.txt", 'a', encoding="utf8") as f:
f.write(f"{_solution_id}\t{_solution_xy}\n")
return True
# 根据_solution_id和_solution_xy复原当前的日历拼图情况
_puzzle = deepcopy(puzzle)
for _formation_id, (_x, _y) in zip(_solution_id, _solution_xy):
# 遍历每一个阵型及其放置的位置
_formation_sparse = formation_matrix_and_sparse_list[_formation_id]["sparse"]
for _i, _j in _formation_sparse:
assert not _puzzle[_x + _i][_y + _j], f"{(_x, _i, _y, _j)}, {_current_puzzle}"
_puzzle[_x + _i][_y + _j] = 1
# # 剪枝
# if is_prune:
# if _solution_id and can_prune(_puzzle, puzzle_height, puzzle_width):
# print("剪枝")
# return False
# 准备一个拼图的副本
_puzzle_copy = deepcopy(_puzzle)
for _formation_id, _formation_matrix_and_sparse in enumerate(formation_matrix_and_sparse_list):
# 取一个阵型,试着把它放入拼图
# 首先检查该阵型是否已经被使用过了
if _formation_id in _solution_id:
continue
# 放入策略是该阵型的左上角的块(即formation[0][0])依次放入拼图的每一个位置
# 检查合法性,如果不合法则依次移动窗口
_formation_matrix, _formation_sparse = _formation_matrix_and_sparse["matrix"], _formation_matrix_and_sparse["sparse"]
_formation_height, _formation_width = len(_formation_matrix), len(_formation_matrix[0])
_is_accommodate = False
for _x in range(puzzle_height - _formation_height + 1):
for _y in range(puzzle_width - _formation_width + 1):
# 遍历拼图每一个可以容纳阵型的位置:
# _x: 0 => puzzle_height - _formation_height
# _y: 0 => puzzle_width - _formation_width
# 判断当前阵型是否可以放入拼图的(_x, _y)位置
_can_put_in = True
_puzzle_updated = deepcopy(_puzzle_copy)
for _i, _j in _formation_sparse:
# 检查阵型的每一个块是否可以被容纳(基于阵型矩阵的稀疏表示来搜索,这样循环的次数会少一些)
# _i, _j就是阵型上块所在的相对坐标
# _x + _i, _y + _j是阵型上的块放入拼图的绝对坐标
if _puzzle_copy[_x + _i][_y + _j] == 1:
# 当前位置已经被其他阵型的块占据,也可能本来就不能放入
_can_put_in = False
break
else:
# 当前块空闲,直接放入
_puzzle_updated[_x + _i][_y + _j] = 1
if _can_put_in:
# 阵型可以放入拼图的(x, y)位置,则放入
# 更新拼图,求解列表
_is_accommodate = True
_solution_id_updated = list(_solution_id) + [_formation_id]
_solution_xy_updated = list(_solution_xy) + [(_x, _y)]
# # 我感觉不需要排序,因为_solution_id_updated应该本来就是升序的
# _xy_to_id = {_xy: _id for _id, _xy in zip(_solution_id_updated, _solution_xy_updated)}
# _solution_id_updated = sorted(_solution_id_updated)
# _solution_xy_updated = sorted(_solution_xy_updated, key = lambda _xy: _xy_to_id[_xy])
_result_flag = _solve_cached(
_solution_id = tuple(_solution_id_updated),
_solution_xy = tuple(_solution_xy_updated),
)
if _result_flag:
# 说明接下来的存在某分支找到了正确的解答,则终止(需求只要找到一个正解即可)
return True
else:
# 不可放入,直接删除puzzle_update,释放内存
del _puzzle_updated
if not _is_accommodate:
# 说明当前阵型无法在拼图上的任何位置被放入
# 此时到达叶子节点
return False
# 应该是到不了这里的
return False
_solve_cached(_solution_id = tuple(), _solution_xy = tuple())
_solve_cached.cache_clear() # 清除缓存
很不幸,这样的改进,几乎不能给到任何提升,原因是这里本质上,选取了(solution_id, solution_xy)
作为状态,但这个状态事实上是很少发生碰撞的。
2.4 使用剪枝进行改进(×)
那就想到是否可以提前做一些剪枝来减少复杂度,正如2.3代码solve_cached
被注释掉的30-34行:
# # 剪枝
# if is_prune:
# if _solution_id and can_prune(_puzzle, puzzle_height, puzzle_width):
# print("剪枝")
# return False
也就是根据当前拼图的情况_puzzle
,来判断是否还需要继续递归搜索?
关键在于如何设计can_prune
函数,这里笔者给到一种思路:
# 快速检查当前拼图是否还可能有解
def can_prune(puzzle, puzzle_height, puzzle_width):
# 注意到阵型至少由5个块组成,且除了方阵之外,其他所有阵型出现的块数都是5
# 因此可以在代码中,把方阵放在第一个位置,优先放入,接下来只需要判断剩余连通块中的空余块的数量,是否是5的倍数即可
# 使用BFS递归算法判断给定坐标(_x, _y)所在连通块的连通块数
checked_block = list() # 存储已经检查过的拼图上的空闲块的坐标
def _bfs(_x, _y):
checked_block.append((_x, _y))
_neighbors = list()
if _x > 0:
_neighbors.append((_x - 1, _y)) # 上
if _y > 0:
_neighbors.append((_x, _y - 1)) # 左
if _x < puzzle_height - 1:
_neighbors.append((_x + 1, _y)) # 下
if _y < puzzle_width - 1:
_neighbors.append((_x, _y + 1)) # 右
for _i, _j in _neighbors:
if (_i, _j) in checked_block:
# 已经遍历过该邻居了
continue
else:
_bfs(_i, _j)
for x in range(puzzle_height):
for y in range(puzzle_width):
if puzzle[x][y] == 1 or (x, y) in checked_block:
# 我们只找空闲的连通块
continue
_bfs(x, y)
if len(checked_block) % 5 == 0:
continue
else:
return True
return False
- 注意到阵型至少由5个块组成,且除了方阵之外,其他所有阵型出现的块数都是5
- 因此可以在代码中,把方阵放在组合的第一个位置,优先递归搜索,接下来只需要判断剩余连通块中的空余块的数量,是否是5的倍数即可
- 使用BFS递归算法判断给定坐标(_x, _y)所在连通块的连通块数
- PS:关于这个拼图中连通块的寻找算法,在以前的扫雷还有围棋判断棋子死活中是常用的
这里已经用到很多每日演兵拼图问题特有的特点进行剪枝了,很不幸,这样剪枝,提升速度还是不大,以2.2中5×5的拼图穷举搜索为例,也就是从1秒2个循环加快到1秒3个循环,杯水车薪。
当然,我认为肯定是有更好的剪枝方法,这里也是BFS本身也有时间成本。
2.5 使用更好的状态表示进行动态规划(√)
2.5.1 第1节代码解析
因此,最后我们回到第1节中的最终代码:
# 使用动态规划:这里我们用编码,所以无需用到稀疏表示,matrix即可
def solve_dp(puzzle, # 原始日历拼图
formation_variants, # 可用阵型及其变形,形如:{"XX": List[Dict{matrix: List[List[Int]], sparse: List[Tuple(x, y)]}], "XXX": 类似前面}
for_solution = False, # 是否找到解法
):
...
这里我们将状态表示为 (当前拼图,剩余可用阵型),与之前穷举不同的地方在于:
- 穷举法会事先给定好每个阵型的变体,然后把8个给定的阵型变体组合好,输入到
solve
函数中求解,这样在solve
函数外面,还需要套一层循环来遍历所有的阵型变体组合,比如5×5
的例子中,有4096种组合,7×7
的每日演兵下,则有50万以上的组合:def test_1(): ... # 遍历所有阵型并求解 for i, formation_matrix_and_sparse_list in enumerate(itertools.product(*formation_variants.values())): if i % 1 == 0: print(i, time.strftime("%Y-%m-%d %H:%M:%S")) # pprint(formation_matrix_and_sparse_list) solve(puzzle = calendar_puzzle, formation_matrix_and_sparse_list = formation_matrix_and_sparse_list, solution_id = [], solution_xy = [], puzzle_height = len(calendar_puzzle), puzzle_width = len(calendar_puzzle[0]), )
- PS:阵型组合数,也就是对应复杂度中的 8 n 8^n 8n这一项(其中 n = 8 n=8 n=8表示可用阵型数),当然如前所述,不是所有的阵型都有8个变体,只是至多8个变体。
- 而在目前的状态 (当前拼图,剩余可用阵型) 中,剩余可用阵型 并不是阵型的变体,而就是所有可用的阵型编号,我们会在
solve_dp
中考虑它们的旋转/翻转得到的至多8种变体,因此solve_dp
函数外面不需要再套一层循环了:# 运行每日练兵 def run(): # 生成今日拼图 month, day = int(time.strftime("%m")), int(time.strftime("%d")) calendar_puzzle = generate_calendar_puzzle(month, day) print(f"{month}月{day}日拼图:") pprint(calendar_puzzle) # 生成今日阵型 names = ["方阵", "北斗七星阵", "九曲黄河阵", "钩形阵", "金锁阵", "玄襄阵", "双龙出水阵", "锋矢阵"] formation_variants = define_formation_variants(names) solve_dp(calendar_puzzle, formation_variants, for_solution=True)
那么怎么样才能减少穷举的数量呢?
- 我们想到可以把7×7的拼图拉成一个“直线”,这样就是一个长度49的零一字符串
puzzle_code
- 那么每个阵型同样可以拉成一个长度为
h
w
hw
hw的零一字符串
formation_code
,任务就变成把formation_code
插入到puzzle_code
中去
这两步非常容易实现:
def solve_dp(...):
...
# 将给定的puzzle矩阵转为puzzle_height×puzzle_width长度的编码向量(以零一字符串表示)
def _puzzle_to_code(_puzzle):
return ''.join([''.join(map(str, _puzzle_row)) for _puzzle_row in _puzzle])
# _puzzle_to_code的逆运算
def _code_to_puzzle(_puzzle_code):
_puzzle = numpy.zeros((puzzle_height, puzzle_width))
_pointer = -1
for _i in range(puzzle_height):
for _j in range(puzzle_width):
_pointer += 1
_puzzle[_i, _j] = _puzzle_code[_pointer]
return _puzzle
# 生成formation编码()的方法:
# - 将formation_matrix每行扩展到跟puzzle_width(零填充)
# - 然后类似_puzzle_to_code的方法,但是要把右侧的零给排除
# - _block_number表示用哪个数字表示块,一般用1,但是为了能看到最终求解的结果,还需要存一份区分不同阵型编码的(即colorful,五彩斑斓的)的阵型编码
def _formation_to_code(_formation_matrix, _block_number = 1):
_formation_matrix = numpy.array(_formation_matrix, dtype=int) * _block_number
_formation_matrix_expand = numpy.concatenate([_formation_matrix, numpy.zeros((_formation_matrix.shape[0], puzzle_width - _formation_matrix.shape[1]))], axis=1)
_formation_matrix_expand = numpy.asarray(_formation_matrix_expand, dtype=int)
_formation_code = ''.join([''.join(map(str, _formation_row)) for _formation_row in _formation_matrix_expand])
return _formation_code.rstrip('0')
这里就出现一个很关键的点,阵型可以旋转翻折,拼图同样可以!
所以一个当前拼图的编码puzzle_code
,事实上等价于另外7种旋转翻折的拼图,我们只需要考虑其中一种即可,那就是取puzzle_code
最大的那个(也就是1更靠前的puzzle_code
,视觉上就是块集中在左上角):
def solve_dp(...):
...
# 注意到一个puzzle通过旋转/翻转操作,一共会有8种等价的变体(自身与翻转)
# 取最大的编码向量(以零一字符串的数值作为表示)作为唯一编码值,以减少很多重复
# 特别地,每日演兵是一个正方阵,所以连行列都不需要区分,这在下面的formation编码中有很大的助益
# 最大编码值对应的拼图,即块集中在左上角
def _puzzle_to_unique_code(_puzzle):
_rotate_puzzle_copy = _puzzle.copy()
_rotate_puzzle_copy_flip = numpy.fliplr(_rotate_puzzle_copy)
_codes = [_puzzle_to_code(_rotate_puzzle_copy), _puzzle_to_code(_rotate_puzzle_copy_flip)]
# 旋转3次
for _ in range(3):
_rotate_puzzle_copy = numpy.rot90(_rotate_puzzle_copy)
_rotate_puzzle_copy_flip = numpy.rot90(_rotate_puzzle_copy_flip)
_codes.append(_puzzle_to_code(_rotate_puzzle_copy))
_codes.append(_puzzle_to_code(_rotate_puzzle_copy_flip))
return max(_codes)
那接下来的问题就简单,把阵型放到拼图中去:
- 注意到,一个形状为
(formation_height, formation_width)
的阵型,只可能在以下的位置插入拼图:_pointer = i × puzzle_width + j
- 其中:
i
取值范围是(0, 1, ..., puzzle_height - formation_height)
,j
取值范围是(0, 1, ...., puzzle_width - formation_width)
def solve_dp(...):
...
# 注意到,一个形状为(formation_height, formation_width)的阵型,只可能在以下的_pointer插入拼图:
# - _pointer = i × puzzle_width + j
# - 其中:i取值范围是(0, 1, ..., puzzle_height - formation_height),j取值范围是(0, 1, ...., puzzle_width - formation_width)
def _generate_possible_pointer(_formation_height, _formation_width):
for _i in range(puzzle_height - _formation_height + 1):
for _j in range(puzzle_width - _formation_width + 1):
yield _i * puzzle_width + _j
# 将formation在指定的位置(pointer)插入puzzle
# 如果指定的位置无法插入,则返回False与空字符串
# 否则返回True和插入后的puzzle_code
def add_formation_code_to_puzzle_code(_puzzle_code, _formation_code, _pointer):
# print(_puzzle_code, len(_puzzle_code))
# print(_formation_code, len(_formation_code))
_result_code = str()
# 插入部分:_pointer, _pointer + 1, ..., _pointer + len(_formation_code) - 1
_start_pointer, _end_pointer = _pointer, _pointer + len(_formation_code)
for _i in range(_start_pointer, _end_pointer):
_formation_char_int = int(_formation_code[_i - _pointer])
_puzzle_char_int = int(_puzzle_code[_i])
if _formation_char_int == 0 or _puzzle_char_int == 0:
# 阵型和拼图至少有一个在当前位置是空的
_result_code += str(_formation_char_int + _puzzle_char_int)
else:
# 否则无法插入阵型
return False, None
# 补上头尾
_result_code = _puzzle_code[: _start_pointer] + _result_code + _puzzle_code[_end_pointer: ]
return True, _result_code
最后我们写一段带缓存的递归算法:
def solve_dp(...):
...
# 递归算法
@lru_cache(None)
def _dp(_puzzle_unique_code, # Str 当前拼图的编码值(唯一编码值)
_remained_formation_ids, # Tuple 剩余可用的阵型编码
_for_solution = False, # 是否需要找到确切的解法
):
# 终止条件:_puzzle_unique_code全是1,或者_remained_formation_ids为空
if not _remained_formation_ids:
# 找到一组解
print("成功找到一组解!")
for _char in _puzzle_unique_code:
assert int(_char) > 0
if _for_solution:
pprint(_code_to_puzzle(_puzzle_unique_code))
with open("solution.txt", 'w', encoding="utf8") as f:
f.write(str(_code_to_puzzle(_puzzle_unique_code)))
return True
# 遍历每个可用的阵型
for _remained_formation_id in _remained_formation_ids:
# 遍历每个可用阵型的变体
_can_put_in = False
if for_solution:
formation_variant_codes = formation_codes_num[_remained_formation_id]
else:
formation_variant_codes = formation_codes[_remained_formation_id]
for _formation_variant_code, (_formation_height, _formation_width) in zip(formation_variant_codes, formation_sizes[_remained_formation_id]):
# 遍历每个可能可以插入的位置
for _possible_pointer in _generate_possible_pointer(_formation_height, _formation_width):
# 试着插入
_add_flag, _updated_puzzle_code = add_formation_code_to_puzzle_code(
_puzzle_code = _puzzle_unique_code,
_formation_code = _formation_variant_code,
_pointer = _possible_pointer,
)
# 成功:可以插入阵型
if _add_flag:
# 则迭代
_remained_formation_ids_list_copy = list(_remained_formation_ids)
_remained_formation_ids_list_copy.remove(_remained_formation_id)
_updated_remained_formation_ids = tuple(_remained_formation_ids_list_copy)
_result_flag = _dp(
_puzzle_unique_code = _updated_puzzle_code,
_remained_formation_ids = _updated_remained_formation_ids,
_for_solution = _for_solution,
)
_can_put_in = True
if _result_flag:
# 找到一个即可
return True
# 失败:此处不可以插入阵型
else:
pass
if not _can_put_in:
# 当前阵型以及它的所有变体无法在任何位置插入
return False
2.5.2 如何找到确切的解?
有人可能发现了,如果只是用零一字符串表示puzzle
,动态规划的状态 (当前拼图,剩余可用阵型)(即_dp
函数的_puzzle_unique_code
和_remained_formations_ids
) 中也没有包含任何和解相关的信息,最终的结果中根本显示不出解的形式,算法只是找到了一个解,但并不知道那个解长啥样,怎么办呢?
一种方法就是在_puzzle_unique_code
上做文章,既然零一字符串不行,那就给每个阵型插进去的块赋予不同的标记,比如8个阵型,可以用2,3,4,5,6,7,8,9
这8个数字来表示它们的块,这样填进去_puzzle_unique_code
就是一个由0~9
十个字符构成的字符串了(因为拼图上原始会有一些块本来就不能放,那些位置需要表示成1
,所以阵型用的是2~9
)
这样虽然解决了找到确切解的问题,但是却使得状态 (当前拼图,剩余可用阵型) 的总数大大增加,可能会影响性能。
好消息是,这次并没有使得性能下降太多,较于只用零一字符串的_puzzle_unique_code
,几乎是没有任何变化的。
def solve_dp(...):
...
# 将puzzle转为矩阵
if isinstance(puzzle, list):
puzzle = numpy.array(puzzle, dtype=int)
puzzle_height, puzzle_width = puzzle.shape
assert puzzle_height == puzzle_width, "动态规划算法目前仅支持正方阵的求解"
total_formation = len(formation_variants)
print(f"拼图形状:{(puzzle_height, puzzle_width)}")
print(f"阵型总数:{total_formation}")
# 将阵型转为矩阵:List[List[Str(formation_code)]]
formation_codes = list() # 零一字符串(块统一用1表示)
formation_codes_num = list() # 每个阵型的块用不同的数字表示
formation_sizes = list() # 记录阵型的高和宽
for i, formation_name in enumerate(formation_variants):
formation_variant_codes = list()
formation_variant_codes_num = list()
formation_variant_sizes = list()
for formation_variant in formation_variants[formation_name]:
formation_matrix = formation_variant["matrix"]
formation_variant_codes.append(_formation_to_code(formation_matrix, _block_number = 1))
formation_variant_codes_num.append(_formation_to_code(formation_matrix, _block_number = i + 2))
formation_variant_sizes.append((len(formation_matrix), len(formation_matrix[0])))
formation_codes.append(formation_variant_codes)
formation_codes_num.append(formation_variant_codes_num)
formation_sizes.append(formation_variant_sizes)
_puzzle_unique_code = _puzzle_to_unique_code(puzzle)
# _dp(_puzzle_unique_code, _remained_formation_ids=tuple(range(total_formation)), _for_solution = False)
_dp(_puzzle_unique_code, _remained_formation_ids=tuple(range(total_formation)), _for_solution = for_solution)
这里用_for_solution
参数来区分是否需要找到确切的解(即使用0-9
十个字符),还是只要确定拼图是否有解(即使用零一字符串)👆
2.6 局限性与讨论
2.5动态规划算法的一个局限性是只能处理方阵,因为旋转不改变形状,这样就不需要重新考虑阵型插入位置可能发生的变化了(即_generate_possible_pointer
函数取巧)。
不过解决手头的任务也足够了吧。
此外2.5的算法中没有考虑其他的剪枝,实际上从人的思考角度来看,一般会把拼图中凹凸不平的地方优先处理掉,使得剩下的区域较为平整(最好是矩形),可能这种思路可以进一步改进算法速度,但缺少理论保证。
3 后记
以前总是习惯留一些后记,如今似乎也没什么好说的。
明天高百总决赛,参加的学校加油吧,有生之年希望真的能站上一次高百总决赛的赛道。
分享一些KR同人作品(原作者:B站@戈谭噩梦),细节拉满,有些东西AI还是做不到的,生活还是需要一些激情的。