2.2 短路与复杂度

短路是求值器的一条优化纪律:and 左假即停、or 左真即停,但它绝不能改变结果。本节给出短路与不短路两种实现的节点访问步数对照——本规格 57 个位点在 16 个代表状态下 eager 1008 步、短路 992 步——并声明复杂度上界:树内 n 个节点时短路步数恒不超过 n。

2.2 短路与复杂度

独立游戏的叙事设计与分支对话 里有一类经验谈:条件写得越少越好,能用一句话说清就别堆三层判断——那是从玩家可读性出发的建议。本节换一个角度问同一个问题:求值器执行一个条件,要访问条件树里的多少个节点?短路这条几乎人人都在用的纪律,到底省下了什么、又必须不改变什么?

答案分两半。短路确实省节点,但它省的是常数而不是渐进量级;而且它有一条铁律——只减步数、不改结果。前一半是复杂度,后一半是正确性。这一节两半都要。

短路是什么

["and", c1, c2] 左假即停:若 c1 为假,无论 c2 是什么,整个合取都是假,于是 c2 的子树不必访问。["or", c1, c2] 左真即停:若 c1 为真,整体为真,c2 不必访问。这两条是布尔代数的恒等式——false ∧ x = false、true ∨ x = true——在执行上就成了一条剪枝。

["not", c] 是单目,没有可省的子树;flag / item / aff 是叶子,本来就只有一次读。所以短路只发生在 and / or 两个内点上。

短路不能改结果

短路是纯优化:它只改变「访问了哪些节点」,不改变「返回什么值」。要让这句话成立,求值器必须满足两条:

  1. 无副作用:访问右子树除了花时间,不能有别的可观测作用(不写状态、不打日志、不抛「只在右子树里才触发」的异常)。
  2. 纯函数:结果只由状态决定,与「之前访问过什么」无关。

违反任一条,短路就会从优化变成语义变更。正因为风险在这里,测试的第一组用例不是去数步数,而是把短路与不短路两种模式都与内核的 condOk 对齐:只要两模式在全部位点、全部代表状态上同值,短路就是安全的。

复杂度上界

设一棵条件树的节点数为 n(叶子 + 内点)。不短路(eager)实现两侧子树一律访问,步数恰为 n。短路下每个内点至多省掉一棵子树的访问,故步数 ≤ n。两者都是 O(n)——短路不改变渐进复杂度,只降低常数。

真正的差别在树的形状。当条件树是一条很长的 and 链、且首项为假时,eager 要走完整条链(O(n)),短路访问完首项就返回(O(1))。所以「短路值不值」取决于条件树的形状,而不取决于节点总数。

S 层:两种实现的步数对照

S 层脚本对同一批条件(全规格 57 个位点)在 16 个代表状态上分别跑短路与不短路两种实现,累加被访问的节点数;另给一个长度 k 的 and 链合成对照,展示形状对步数的影响:

#!/usr/bin/env python3
# file: sim/ne_02_2_short_circuit.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/ne_02_2_short_circuit.py
"""卷二 2.2 短路与复杂度(S 层):同一批条件在短路与不短路两种实现下的节点访问次数对照。

短路语义:["and", c1, c2] 左假即停(不再访问 c2 子树);["or", c1, c2] 左真即停。
不短路(eager)语义:两侧子树一律访问。两种实现必须给出**同一个布尔值**——短路只影响
步数,不影响结果。步数定义 = 条件树里被访问的节点数(含叶子与内点)。

代表状态 = found_yan_journal ∈ {0,1} × yan ∈ {0,10,39,40,41,60,85,100} 共 16 个。
另给一个合成对照:长度 k 的 and 链、首项为假,展示短路把线性步数压到常数。
复用 sim/na_00_spec_check.py 的 load_spec。产出 out/ne_02_2_short_circuit.json。
"""
from __future__ import annotations

import json
import os
import sys

HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
import na_00_spec_check as base  # noqa: E402  复用 load_spec

OUT = os.path.join(os.path.dirname(HERE), "out/ne_02_2_short_circuit.json")


def sites(spec: dict) -> list:
    out = []
    for n in spec["dialogue"]:
        if n.get("line_when"):
            out.append((f"{n['id']}.line_when", n["line_when"]))
        for c in (n.get("choices") or []):
            if c.get("when"):
                out.append((f"{n['id']}/{c['id']}", c["when"]))
    for e in spec["scene_graph"]["edges"]:
        if e.get("when"):
            out.append((f"{e['from']}->{e['to']}", e["when"]))
    for nd in spec["scene_graph"]["nodes"]:
        if nd.get("enter_when"):
            out.append((f"node {nd['id']}", nd["enter_when"]))
    for loc in spec["world"]["locations"]:
        if loc.get("unlock_when"):
            out.append((f"location {loc['id']}", loc["unlock_when"]))
    for e in spec["endings"]:
        if e.get("when"):
            out.append((f"ending {e['id']}", e["when"]))
    return out


def ev(c, s, short: bool, steps: list) -> bool:
    """带步数计数的求值器。short=True 时 and/or 短路。"""
    if c is None:
        return True
    steps[0] += 1
    k = c[0]
    if k == "flag":
        return s["flags"].get(c[1], 0) == c[2]
    if k == "item":
        return s["items"].get(c[1], 0) == c[2]
    if k == "aff":
        v, op, n = s["aff"].get(c[1], 0), c[2], c[3]
        return v >= n if op == ">=" else v < n if op == "<" else v == n if op == "==" else v != n
    if k == "and":
        left = ev(c[1], s, short, steps)
        if short and not left:
            return False
        right = ev(c[2], s, short, steps)
        return left and right
    if k == "or":
        left = ev(c[1], s, short, steps)
        if short and left:
            return True
        right = ev(c[2], s, short, steps)
        return left or right
    return not ev(c[1], s, short, steps)  # not


def count(c, s, short: bool) -> tuple:
    steps = [0]
    val = ev(c, s, short, steps)
    return val, steps[0]


def battery(spec: dict) -> list:
    out = []
    for flag in (0, 1):
        for yan in (0, 10, 39, 40, 41, 60, 85, 100):
            st = {"node": "d_dq_2",
                  "aff": {v: next(c["affinity"]["init"] for c in spec["characters"] if c["id"] == v)
                          for v in spec["state"]["vars"]},
                  "flags": {f: 0 for f in spec["state"]["flags"]},
                  "items": {i: 0 for i in spec["state"]["items"]}, "path": []}
            st["flags"]["found_yan_journal"] = flag
            st["aff"]["yan"] = yan
            out.append(st)
    return out


def chain(k: int) -> dict:
    """长度 k 的 and 链,首项为假。节点数 = 2k-1(k 个叶子 + k-1 个 and 内点)。"""
    atom = ["flag", "found_yan_journal", 1]
    c = atom
    for _ in range(k - 1):
        c = ["and", atom, c]
    return c


def main() -> int:
    spec = base.load_spec()
    all_sites = sites(spec)
    states = battery(spec)
    zero = {"node": "x", "aff": {v: 0 for v in spec["state"]["vars"]},
            "flags": {f: 0 for f in spec["state"]["flags"]},
            "items": {i: 0 for i in spec["state"]["items"]}, "path": []}

    eager_total = short_total = 0
    mismatch = 0
    for _, c in all_sites:
        for s in states:
            v1, n1 = count(c, s, short=False)
            v2, n2 = count(c, s, short=True)
            eager_total += n1
            short_total += n2
            if v1 != v2:
                mismatch += 1

    print(f"条件位点 {len(all_sites)} 个 / 代表状态 {len(states)} 个")
    print(f"不短路(eager)总步数 {eager_total}")
    print(f"短路(short-circuit)总步数 {short_total}")
    print(f"省下 {eager_total - short_total} 步({(eager_total - short_total) * 100 // eager_total}%)")
    print(f"两种实现结果不一致次数 {mismatch}(必须为 0)")
    print("合成对照(长度 k 的 and 链,首项为假):")
    syn = []
    for k in (1, 2, 4, 8, 16):
        c = chain(k)
        nodes = 2 * k - 1
        _, e = count(c, zero, short=False)
        _, sh = count(c, zero, short=True)
        syn.append({"k": k, "nodes": nodes, "eager": e, "short": sh})
        print(f"  k={k:<2} 节点 {nodes:<2} eager {e:<2} 短路 {sh}")
    print(f"上界:树内节点数 n 时,eager 步数 = n,短路步数 ≤ n")
    report = {"sites": len(all_sites), "states": len(states), "eager_total": eager_total,
              "short_total": short_total, "saved": eager_total - short_total,
              "mismatch": mismatch, "synthetic": syn}
    os.makedirs(os.path.dirname(OUT), exist_ok=True)
    with open(OUT, "w", encoding="utf-8") as fh:
        json.dump(report, fh, ensure_ascii=False, indent=2)
        fh.write("\n")
    if mismatch:
        print(f"FAIL 短路改变了求值结果 {mismatch} 次")
        return 1
    print("OK ne_02_2_short_circuit:短路只减步数、不改结果")
    return 0


if __name__ == "__main__":
    sys.exit(main())

本机实跑 /opt/homebrew/bin/python3 sim/ne_02_2_short_circuit.py:

条件位点 57 个 / 代表状态 16 个
不短路(eager)总步数 1008
短路(short-circuit)总步数 992
省下 16 步(1%)
两种实现结果不一致次数 0(必须为 0)
合成对照(长度 k 的 and 链,首项为假):
  k=1  节点 1  eager 1  短路 1
  k=2  节点 3  eager 3  短路 2
  k=4  节点 7  eager 7  短路 2
  k=8  节点 15 eager 15 短路 2
  k=16 节点 31 eager 31 短路 2
上界:树内节点数 n 时,eager 步数 = n,短路步数 ≤ n
OK ne_02_2_short_circuit:短路只减步数、不改结果

P 层:带计数的求值器

P 层是同一台求值器的 TypeScript 版,多了一个 short 开关与一个步数计数器。测试同时跑两种模式并与内核对齐:

// file: core/src/inkwell/ne_02_2_short_circuit.ts
// tier: P
// verify: node --test 'core/test/ne_02_2_short_circuit.test.ts'
// 卷二 2.2:带步数计数的条件求值器。short=true 时 and 左假即停、or 左真即停;
// short=false 时两侧子树一律访问(eager)。短路只减步数、不改结果——两模式必须同值。

import type { Cond, State } from "./engine.ts";

export type Counted = { value: boolean; steps: number };

/** 求值并数「被访问的条件树节点数」(含叶子与内点)。 */
export function evalCounted(c: Cond | null | undefined, s: State, short: boolean): Counted {
  let steps = 0;
  const ev = (x: Cond | null | undefined): boolean => {
    if (!x) return true;
    steps += 1;
    const k = x[0];
    if (k === "flag") return (s.flags[x[1] as string] ?? 0) === (x[2] as number);
    if (k === "item") return (s.items[x[1] as string] ?? 0) === (x[2] as number);
    if (k === "aff") {
      const v = s.aff[x[1] as string] ?? 0;
      const op = x[2] as string;
      const n = x[3] as number;
      if (op === ">=") return v >= n;
      if (op === "<") return v < n;
      if (op === "==") return v === n;
      return v !== n;
    }
    if (k === "and") {
      const l = ev(x[1] as Cond);
      if (short && !l) return false;      // 左假即停
      const r = ev(x[2] as Cond);          // eager 下必须真访问右子树
      return l && r;
    }
    if (k === "or") {
      const l = ev(x[1] as Cond);
      if (short && l) return true;         // 左真即停
      const r = ev(x[2] as Cond);
      return l || r;
    }
    return !ev(x[1] as Cond);              // not
  };
  return { value: ev(c), steps };
}

/** 长度 k 的 and 链,首项为假;节点数 2k-1。用于展示短路把线性步数压到常数。 */
export function andChain(k: number): Cond {
  const atom: Cond = ["flag", "found_yan_journal", 1];
  let c: Cond = atom;
  for (let i = 1; i < k; i += 1) c = ["and", atom, c];
  return c;
}

本机实跑 node --test 'core/test/ne_02_2_short_circuit.test.ts':

短路对照:57 位点 × 16 状态 × 2 模式 = 1824 次同值
代表状态 16 个:eager 1008 步 / 短路 992 步 / 省 16 步
✔ 短路与不短路两种模式都与 condOk 同值(短路只减步数) (1.656917ms)
✔ 步数上界:eager = 树内节点数,短路 ≤ 树内节点数 (0.350375ms)
✔ 实测总步数:eager 1008 / 短路 992,省 16 步 (0.414417ms)
✔ 合成对照:长度 k 的 and 链首项为假,eager 2k-1 步、短路 ≤ 2 步 (0.139917ms)
ℹ tests 4
ℹ suites 0
ℹ pass 4
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 148.13475

判读:本规格省得少,但上界守得住

第一,这份规格里短路几乎不省。 57 个位点、16 个代表状态,eager 1008 步、短路 992 步,只省 16 步(1%)。原因直接来自 2.1 的用量表:63 个树节点里只有 2 个 and、0 个 or,其余是叶子与 not。短路的收益 = 2 个 and 位点在「左假」状态下的 1 步 × 状态数,就是这个 16。树浅、且几乎没有合取,短路自然省不动。

第二,上界与形状才是要点。 合成对照把形状的影响放大给人看:

and 链长度 k树内节点eager 步数短路步数
1111
2332
4772
815152
1631312

首项为假的 and 链:eager 走完整条链(2k−1 步),短路访问完首项就返回(k=1 时是那唯一一个叶子 1 步,k≥2 时恒 2 步)。这不是说这份规格「慢」——1008 步在任何机器上都是零成本——而是说:短路的收益由条件树的形状决定,节点总数只给出上界。规格作者真正能控制的,是把「便宜的判据放左边」。

第三,短路只减步数、不改结果,这条有 1824 次同值作证。 两种模式在 57 位点 × 16 状态上全部同值——短路是纯优化,不是语义变更。

自检表

检查项合格标准
短路不改结果短路与不短路两模式在全部位点全部状态上同值
上界成立eager 步数恒等于树内节点数,短路步数恒不超过它
无副作用访问右子树除花时间外无可观测作用
形状敏感收益随条件树形状变化,而不随节点总数线性变化
步数是 proxy只做相对比较(短路 vs 不短路),不声称「更快」

短路管的是「一次求值花多少步」。下一个执行侧的问题更根本:一个条件在状态域里到底有没有解——它会不会恒假、是不是写了个永远不成立的门槛。下一节把可满足性做成可判定的。

阅读导航:上一节:2.1 条件表达式的求值器 · 下一节:2.3 条件的可满足性 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「narrative」更多文章

  1. 15.3 规模告警
  2. 15.2 求值与遍历的开销
  3. 15.1 规格规模的增长