《小游戏开发实战》卷一 6.1 核心循环拆解
用 S 层把一局切成时间片、量每片的时长占比;本节量的是策略空间的规模——|A|^D 长到多大、用哪几道闸把它压回可算。同一份 config/play_spec.json,一个是时间轴上的分割,一个是状态空间上的计数,互不重叠。上一节把合法空间枚举到了深度 8,本节处理那道「枚举深度」与「结果深度」之间的墙。
1.3 策略空间的规模控制
上一节结尾留了一道墙:深度 8 时结果熵是 0,因为分出胜负要 20 步以上;而 6^25 ≈ 2.8 × 10^19 条序列根本枚举不完。这不是《灰烬牌库》独有的问题——任何有限机制的策略空间都随深度指数爆炸。可算的办法不是硬枚举,而是三道闸依次把空间压小,最后一道在压不动时换成采样。
| 闸 | 做什么 | 压的是哪一维 | 代价 |
|---|---|---|---|
| ① 剪枝 | 只生成合法动作 | 非法分支 | 无(非法指令本就不该算) |
| ② 去重 | 按 digest 合并同状态 | 冗余序列 | 丢失「怎么走到的」 |
| ③ 采样 | 抽合法序列估指标 | 全部深度 | 估计有误差 |
三道闸的性质不同:剪枝是无损的(砍掉的本来就不合法),去重是有损但语义安全的(同状态的不同序列对后续无差别),采样是有统计误差的。什么时候用哪一道,是本节最后要给的判据。
闸一:剪枝——砍掉非法分支
剪枝把「生成全部 |A|^D 条序列」变成「生成合法序列」。它的强度上一节量过:深度 8 的粗上界 1679616 条里,合法只剩 181165 条,107‰。剪枝的力量随深度增加——深度 2 时几乎全部合法,深度 8 时只剩一成。
闸二:去重——序列不是状态
剪枝之后还有冗余:p0 p1 和 p1 p0 可能到达同一个状态(先打哪张牌,只要两张都打出去了,终态相同)。去重就是按 ashlib.digest 把到达同一状态的不同序列合并。这个闸比剪枝狠得多:
| 深度 d | 合法序列(累计) | 去重状态(累计) | 去重/合法 |
|---|---|---|---|
| 1 | 6 | 6 | 1000‰ |
| 2 | 37 | 22 | 594‰ |
| 3 | 160 | 67 | 418‰ |
| 4 | 624 | 177 | 283‰ |
| 5 | 2599 | 385 | 148‰ |
| 6 | 10884 | 711 | 65‰ |
| 7 | 44630 | 1188 | 26‰ |
| 8 | 181165 | 1877 | 10‰ |
深度 8 时,181165 条合法序列只对应 1877 个不同状态——平均一个状态被 96 条序列到达。序列数继续指数涨,状态数涨得慢得多。这就是为什么「算可达状态」比「算策略空间」便宜。
闸三:采样——枚举不动就抽
当需要的深度超过枚举上限,就只剩采样。采样器用自实现的 Lehmer MINSTD PRNG(ashlib.rng_next)在每一步从合法动作里均匀抽一个,串成一条合法序列,跑出快照,再跨样本聚合。
采样器必须先校准,否则它的估计不可信。校准的做法是:在能枚举的深度(D=6)上,把枚举真值和采样估计并排放:
| 量 | 枚举真值(8285 条) | 采样估计(4096 条) | 差 |
|---|---|---|---|
平均 dealt | 18 | 20 | +2(111‰) |
胜率 winPermille | 0 | 0 | 0 |
meanDealt 差 2(相对 111‰)是 4096 个样本的采样误差;胜率两边都是 0,因为深度 6 还赢不了。校准通过后,把采样器推到不能枚举的深度 12:6^12 = 2176782336 条序列,采样 4096 条得到 meanDealt = 44、winPermille = 294——这是枚举永远给不出的深度上的估计。
S 层:三道闸的实现与判据
sim/gd_play_01_scale.py 依次跑剪枝(序列 BFS)、去重(状态 BFS)、采样(PRNG),并算出判据 D*:满足「合法序列数 ≤ 枚举上限」的最大深度。全整数,无浮点,无随机,报告总耗时。
#!/usr/bin/env python3
# file: sim/gd_play_01_scale.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/gd_play_01_scale.py
"""卷二 1.3 策略空间的规模控制(S 层):剪枝、去重、采样三道闸。
策略空间 |A|^D 会爆炸(|A|=6,D=25 时 6^25≈2.8e19)。本节把「怎么把它压回可算」拆成
三道**可量化**的闸,并给出「何时改用采样」的整数判据:
① 剪枝:只生成合法动作(非法指令在生成时丢弃,游戏结束后扇出为 0)。
② 去重:按 digest 合并到达同一状态的不同序列(序列 ≠ 状态)。
③ 采样:枚举撞上上限后,用自实现可播种 PRNG 抽合法序列,估一个代理指标。
采样器先在**能枚举**的深度(D=6)上校准:同一深度,枚举均值 vs 采样均值之差即
采样误差;再推到**不能枚举**的深度(D=12,6^12≈2.2e9)给出估计与分辨率。
判据(全整数):设 NODE_CAP 为枚举上限,D* = 满足「合法序列数 ≤ NODE_CAP」的最大深度。
D ≤ D* → 枚举;D > D* → 采样,分辨率 = ceil(1000 / 采样数) ‰。
全整数、无浮点、无 random。产出 out/play01_scale.{json,csv,svg},并报告总耗时。
"""
from __future__ import annotations
import json
import os
import sys
import time
import xml.etree.ElementTree as ET
import ashlib
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
OUT = os.path.join(ROOT, "out")
CONFIG = os.path.join(ROOT, "config/gd_play_01_space.json")
def alphabet(spec: dict) -> list[str]:
return ["p" + str(i) for i in range(spec["params"]["handSize"])] + ["e"]
def cost(spec: dict, cid: str) -> int:
return next((c["cost"] for c in spec["cards"] if c["id"] == cid), 999999)
def legal(spec: dict, s: dict) -> list[str]:
if s["over"]:
return []
a = ["p" + str(i) for i, cid in enumerate(s["hand"]) if cost(spec, cid) <= s["energy"]]
a.append("e")
return a
def seq_bfs(spec: dict, depth: int, cap: int) -> tuple:
"""按**序列**枚举:返回 (逐深度 legal 数, 累计, 撞上限的深度或 None)。"""
frontier: list[list[str]] = [[]]
edges, rows, hit = 0, [], None
for d in range(1, depth + 1):
nxt = []
for seq in frontier:
for a in legal(spec, ashlib.run_spec(spec, seq)):
if edges >= cap:
hit = d
break
edges += 1
nxt.append(seq + [a])
if hit:
break
rows.append((d, len(nxt), edges))
if hit or not nxt:
break
frontier = nxt
return rows, hit
def state_bfs(spec: dict, depth: int) -> list:
"""按 digest 去重枚举:返回 (深度, 该深度新增状态, 累计可达状态)。"""
seen = {ashlib.digest(ashlib.run_spec(spec, []))}
frontier, rows = [[]], []
for d in range(1, depth + 1):
nxt, new = [], 0
for seq in frontier:
for a in legal(spec, ashlib.run_spec(spec, seq)):
g = ashlib.digest(ashlib.run_spec(spec, seq + [a]))
if g in seen:
continue
seen.add(g)
new += 1
nxt.append(seq + [a])
rows.append((d, new, len(seen)))
if not nxt:
break
frontier = nxt
return rows
def sample(spec: dict, depth: int, count: int, seed: int) -> tuple:
"""自实现 PRNG 抽合法序列,返回 (样本快照列表, 末态 PRNG state)。"""
snaps = []
for _ in range(count):
seq, state = [], seed % ashlib.RNG_MOD or 1
for _ in range(depth):
acts = legal(spec, ashlib.run_spec(spec, seq))
if not acts:
break
state = ashlib.rng_next(state)
seq.append(acts[state % len(acts)])
snaps.append(ashlib.run_spec(spec, seq))
seed = state
return snaps, seed
def mean_dealt(snaps: list) -> int:
return sum(s["dealt"] for s in snaps) // len(snaps)
def win_permille(snaps: list) -> int:
return 1000 * sum(1 for s in snaps if s["won"]) // len(snaps)
def scale_svg(path: str, rows: list, srows: list) -> int:
root = ashlib.svg_begin(680, 300)
ashlib.svg_text(root, 20, 30, "1.3 剪枝与去重把空间压回可算(占 |A|^d 的 ‰)", 13)
for i, (d, legal_n, _cum) in enumerate(rows):
raw = 6 ** d
y = 50 + i * 28
ashlib.svg_text(root, 20, y + 15, f"d={d}", 12)
ashlib.svg_bar(root, 60, y, 250, 11, legal_n, raw, "#38bdf8")
st = next((s[2] for s in srows if s[0] == d), 0)
ashlib.svg_bar(root, 60, y + 12, 250, 11, st, raw, "#f59e0b")
ashlib.svg_text(root, 320, y + 15, f"合法 {legal_n} / 去重 {st} / 上界 {raw}", 11)
ashlib.svg_text(root, 20, 288, "蓝 = 合法序列 橙 = 去重状态;两者都远小于 |A|^d", 11, "#94a3b8")
return ashlib.svg_save(root, path)
def main() -> int:
t0 = time.perf_counter_ns()
spec = ashlib.load_spec()
with open(CONFIG, encoding="utf-8") as f:
cfg = json.load(f)
A = len(alphabet(spec))
D, cap = cfg["enumDepth"], cfg["maxNodes"]
S, SD = cfg["sampleCount"], cfg["sampleDepth"]
rows, hit = seq_bfs(spec, D + 1, cap) # 多探一层,看上限在哪一深度撞上
srows = state_bfs(spec, D + 1)
d_star = (hit - 1) if hit else rows[-1][0] # 撞上限的上一层才是可完整枚举的最大深度
# 采样器校准:在可枚举的深度 6 上,枚举真值 vs 采样估计
cal_depth = 6
enum_snaps = []
frontier = [[]]
for _ in range(cal_depth):
nxt = []
for seq in frontier:
nxt += [seq + [a] for a in legal(spec, ashlib.run_spec(spec, seq))]
frontier = nxt
enum_snaps = [ashlib.run_spec(spec, seq) for seq in frontier]
samp_snaps, _ = sample(spec, cal_depth, S, cfg["seeds"][0])
cal = {"depth": cal_depth, "enumerated": len(enum_snaps), "sampled": S,
"enumMeanDealt": mean_dealt(enum_snaps), "sampMeanDealt": mean_dealt(samp_snaps),
"deltaMeanDealt": mean_dealt(samp_snaps) - mean_dealt(enum_snaps),
"deltaPermille": abs(mean_dealt(samp_snaps) - mean_dealt(enum_snaps)) * 1000
// max(1, mean_dealt(enum_snaps)),
"enumWinPermille": win_permille(enum_snaps),
"sampWinPermille": win_permille(samp_snaps)}
# 采样器外推:在不可枚举的深度 12 上给估计
deep_snaps, _ = sample(spec, SD, S, cfg["seeds"][1])
deep = {"depth": SD, "rawSpace": A ** SD, "meanDealt": mean_dealt(deep_snaps),
"winPermille": win_permille(deep_snaps),
"resolutionPermille": -(-1000 // S)}
n = ashlib.write_csv(os.path.join(OUT, "play01_scale.csv"),
["depth", "legalSeq", "cumLegal", "distinctState"],
[[d, lg, cum, next((s[2] for s in srows if s[0] == d), 0)]
for d, lg, cum in rows])
b = scale_svg(os.path.join(OUT, "play01_scale.svg"), rows, srows)
elapsed = (time.perf_counter_ns() - t0) // 1000000
report = {"id": "gd_play_01_scale", "alphabetSize": A, "maxNodes": cap,
"dStar": d_star, "capHitAtDepth": hit, "seqRows": rows, "stateRows": srows,
"calibration": cal, "deepSample": deep, "elapsedMs": elapsed}
with open(os.path.join(OUT, "play01_scale.json"), "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2, sort_keys=True)
f.write("\n")
print(f"|A|={A} maxNodes={cap} 判据 D*={d_star}(合法序列 ≤ 上限的最大深度)")
print("depth legalAtDepth cumLegal distinctStates |A|^depth")
for d, lg, cum in rows:
st = next((s[2] for s in srows if s[0] == d), 0)
print(f"{d} {lg} {cum} {st} {A ** d}")
print(f"capHitAtDepth={hit}(第 {hit} 层撞上上限,故枚举只到 D*={d_star})")
print(f"calibration@{cal_depth}: enumerated={cal['enumerated']} sampled={cal['sampled']} "
f"meanDealt {cal['enumMeanDealt']} vs {cal['sampMeanDealt']} "
f"(Δ={cal['deltaMeanDealt']:+d}, {cal['deltaPermille']}‰) "
f"winPermille {cal['enumWinPermille']} vs {cal['sampWinPermille']}")
print(f"deepSample@{SD}: rawSpace={deep['rawSpace']} meanDealt={deep['meanDealt']} "
f"winPermille={deep['winPermille']} resolution={deep['resolutionPermille']}‰")
print(f"wrote out/play01_scale.csv rows={n}")
print(f"wrote out/play01_scale.svg bytes={b}")
print(f"elapsed={elapsed} ms")
ET.parse(os.path.join(OUT, "play01_scale.svg"))
return 0
if __name__ == "__main__":
sys.exit(main())
本机实跑 /opt/homebrew/bin/python3 sim/gd_play_01_scale.py(elapsed 是墙钟采样,每次运行都不同):
|A|=6 maxNodes=200000 判据 D*=8(合法序列 ≤ 上限的最大深度)
depth legalAtDepth cumLegal distinctStates |A|^depth
1 6 6 6 6
2 31 37 22 36
3 123 160 67 216
4 464 624 177 1296
5 1975 2599 385 7776
6 8285 10884 711 46656
7 33746 44630 1188 279936
8 136535 181165 1877 1679616
9 18835 200000 2827 10077696
capHitAtDepth=9(第 9 层撞上上限,故枚举只到 D*=8)
calibration@6: enumerated=8285 sampled=4096 meanDealt 18 vs 20 (Δ=+2, 111‰) winPermille 0 vs 0
deepSample@12: rawSpace=2176782336 meanDealt=44 winPermille=294 resolution=1‰
wrote out/play01_scale.csv rows=9
wrote out/play01_scale.svg bytes=5662
elapsed=1106 ms
判读:判据 D*=8,三档深度各用一道闸
判据 D* = 8:在 200000 的枚举上限下,合法序列数到深度 8 是 181165 条,还没超;深度 9 一撞就满(capHitAtDepth=9,累计正好卡在 200000)。所以深度 ≤ 8 用枚举,深度 > 8 用采样。
去重的收益在深度 8 最明显:合法序列 181165 条只对应 1877 个状态,压缩比 10‰。如果关心的是「能走到哪些状态」而不是「有多少种走法」,去重能把空间砍掉两个数量级。但要提醒一句:去重丢掉了「怎么走到的」——同一个状态的 96 条前驱序列在支配分析(第 2 章)里可能并不等价,所以去重只适合状态可达性问题,不适合策略优劣问题。
采样器校准通过:在深度 6 上,枚举真值 meanDealt = 18、采样估计 20,差 2(111‰)。这个差就是 4096 样本的采样误差量级——它不大,但也不小,足以说明采样结果必须带误差声明,不能当精确值用。
外推到深度 12:6^12 = 2176782336 条序列无法枚举,采样 4096 条得到 meanDealt = 44、winPermille = 294。这是本节唯一一个「枚举给不出、只有采样能给」的深度。它的分辨率是 ceil(1000/4096) = 1‰——胜率的估计粒度是千分之一。
何时改用采样:一条整数判据
| 条件 | 用什么 | 理由 |
|---|---|---|
合法序列数(D) ≤ NODE_CAP | 枚举 | 完整、无误差、可复现 |
合法序列数(D) > NODE_CAP | 采样 | 枚举跑不完,只能估 |
| 只问「能否到达」 | 状态 BFS + 去重 | 状态数远小于序列数 |
| 要比较策略优劣 | 不能去重 | 同状态的不同前驱策略不等价 |
判据本身是全整数的:给定 NODE_CAP,D* 由「合法序列数 ≤ 上限」唯一确定,改上限只改 D*,不改代码。这条判据会被后面几章反复引用——第 2 章的支配检测、第 6 章的相变扫描,都要先问一句「这一步是在 D* 以内还是以外」。
| 检查项 | 合格标准 |
|---|---|
| 三道闸齐全 | 剪枝 / 去重 / 采样各有一段可跑实现 |
| 剪枝无损 | 砍掉的都是非法指令,合法空间不被误伤 |
| 去重有界 | 明确说明去重丢掉了「怎么走到的」 |
| 采样先校准 | 在可枚举深度上比对枚举真值,报告误差 |
| 判据整数化 | D* 由整数比较确定,不依赖浮点阈值 |
| 耗时上报 | 脚本报告总耗时,分钟级可跑完 |
第 1 章到此结束:策略有两种写法(序列与树),策略空间可以枚举、可以用熵刻画、可以在爆炸时压回可算。下一章把这三件事拧成一个问题——在这个空间里,有没有一条策略永远不比别的差。
阅读导航:上一节:1.2 策略空间枚举与 Shannon 熵 · 下一节:2.1 支配关系:严格与弱 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。