1.3 策略空间的规模控制

策略空间 |A|^D 会爆炸,深度 25 时是 2.8×10^19 条。本节把规模压回可算的三道闸——剪枝、去重、采样——逐道量化:合法序列占粗上界 107‰,去重后深度 8 只剩 1877 个状态;采样器先在可枚举的深度 6 上校准(均值差 111‰),再外推到不可枚举的深度 12,并给出「何时改用采样」的整数判据 D*=8。

《小游戏开发实战》卷一 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合法序列(累计)去重状态(累计)去重/合法
1661000‰
23722594‰
316067418‰
4624177283‰
52599385148‰
61088471165‰
744630118826‰
8181165187710‰

深度 8 时,181165 条合法序列只对应 1877 个不同状态——平均一个状态被 96 条序列到达。序列数继续指数涨,状态数涨得慢得多。这就是为什么「算可达状态」比「算策略空间」便宜。

闸三:采样——枚举不动就抽

当需要的深度超过枚举上限,就只剩采样。采样器用自实现的 Lehmer MINSTD PRNG(ashlib.rng_next)在每一步从合法动作里均匀抽一个,串成一条合法序列,跑出快照,再跨样本聚合。

采样器必须先校准,否则它的估计不可信。校准的做法是:在能枚举的深度(D=6)上,把枚举真值和采样估计并排放:

量枚举真值(8285 条)采样估计(4096 条)差
平均 dealt1820+2(111‰)
胜率 winPermille000

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 支配关系:严格与弱 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「gamedesign」更多文章

  1. 附录 A 品类参数表、机制距离矩阵与数据集索引
  2. 9.3 自走棋的涌现
  3. 9.2 自走棋的经济与平衡