11.2 增长与衰减的闭式与递推
《成长曲线》那篇 讲的是曲线形状——什么时候陡、什么时候平、玩家的能力怎么跟上;本节讲的是更靠下的一件事:同一条曲线,用闭式算和用递推算,在整数域上必须逐项对上。如果对不上,那这份规格就是「看你用哪个公式」的两份规格——同一个种子、同一个初始值,换一条实现路径就长出两条曲线。11.1 已经把单点计算统一到整数,本节把「随步数长起来」的量也钉死。
两条路:闭式与递推
几何增长与衰减有两条等价的实现路径:
- 闭式:直接算第 k 项,
v_k = N0 * num^k // den^k。 - 递推:从
v_0 = N0出发,v_{k+1} = v_k * num // den。
在实数域上两者恒等(都等于 N0 * (num/den)^k)。在整数域上就不一定了——因为地板除会丢余数,而递推每步都丢一次。于是「闭式 ≡ 递推」不是天上掉下来的,是必须主动维护的一条性质。维护的办法很简单:递推不要中途截断,把分子一路留着,最后一次性地板除。
三条路线与它们的分歧
| 路线 | 递推式 | 是否截断 | 与闭式的关系 |
|---|---|---|---|
| 闭式 | N0 * num^k // den^k | 只除一次 | 基准 |
| 精确递推 | 保分子:N_k = N0 * num^k,值 = N_k // den^k | 只除一次 | 逐项恒等 |
| 朴素递推 | u_{k+1} = u_k * num // den | 每步都除 | 逐项偏低,漂移累积 |
三者的差别只在「什么时候除」。闭式与精确递推都在最后除一次,所以它们必然恒等——这不是巧合,是同一表达式的两种写法。朴素递推每步都除,余数在每一步被扔掉,误差只增不减。下面用两组真实参数把分歧量出来:增长用 115/100(每级 +15%,初始 playerHp=60),衰减用 90/100(每级 -10%,初始 enemyHp=50)。
#!/usr/bin/env python3
# file: sim/gd_mech_11_growth.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/gd_mech_11_growth.py
"""11.2 增长与衰减的闭式与递推(S 层)。
增长与衰减在整数域上必须「闭式 ≡ 递推」逐项一致,否则同一份规格在不同宿主上
会算出不同的曲线。比例写成有理数 num/den,三条路线:
闭式 v_k = N0 * num**k // den**k (只除一次)
精确递推 N_k = N0 * num**k,v_k = N_k // den**k(保分子,与闭式逐项恒等)
朴素递推 u_{k+1} = u_k * num // den (每步地板除,截断漂移累积)
前两者是本节要证的等价;第三者是反例——用来量出「中途截断」的代价。
两组参数:增长 115/100(每级 +15%),衰减 90/100(每级 -10%)。全程整数,无浮点。
"""
from __future__ import annotations
import os
import ashlib
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
STEPS = 10
def closed(n0: int, num: int, den: int, k: int) -> int:
return n0 * num ** k // den ** k
def exact_recurrence(n0: int, num: int, den: int, k: int) -> int:
acc = n0
for _ in range(k):
acc *= num
return acc // den ** k
def naive_recurrence(n0: int, num: int, den: int, k: int) -> int:
acc = n0
for _ in range(k):
acc = acc * num // den
return acc
def series(name: str, n0: int, num: int, den: int) -> list:
rows = []
for k in range(STEPS + 1):
c = closed(n0, num, den, k)
e = exact_recurrence(n0, num, den, k)
u = naive_recurrence(n0, num, den, k)
rows.append([name, k, c, e, u, u - c])
return rows
def main() -> int:
spec = ashlib.load_spec()
p = spec["params"]
rows = series("growth", p["playerHp"], 115, 100) + series("decay", p["enemyHp"], 90, 100)
header = ["series", "k", "closed", "exact", "naive", "drift"]
out = os.path.join(ROOT, "out", "mech11_growth.csv")
n = ashlib.write_csv(out, header, rows)
print(" ".join(header))
for r in rows:
print(" ".join(str(x) for x in r))
g = series("growth", p["playerHp"], 115, 100)
d = series("decay", p["enemyHp"], 90, 100)
closed_eq_exact = all(r[2] == r[3] for r in rows)
drift_max = max(abs(r[5]) for r in rows)
print(f"closed_eq_exact={closed_eq_exact}")
print(f"growth_k10_closed={g[10][2]} growth_k10_naive={g[10][4]}")
print(f"decay_k10_closed={d[10][2]} decay_k10_naive={d[10][4]}")
print(f"drift_max={drift_max}")
print(f"wrote out/mech11_growth.csv rows={n}")
return 0 if closed_eq_exact else 1
if __name__ == "__main__":
raise SystemExit(main())
实跑 /opt/homebrew/bin/python3 sim/gd_mech_11_growth.py:
series k closed exact naive drift
growth 0 60 60 60 0
growth 1 69 69 69 0
growth 2 79 79 79 0
growth 3 91 91 90 -1
growth 4 104 104 103 -1
growth 5 120 120 118 -2
growth 6 138 138 135 -3
growth 7 159 159 155 -4
growth 8 183 183 178 -5
growth 9 211 211 204 -7
growth 10 242 242 234 -8
decay 0 50 50 50 0
decay 1 45 45 45 0
decay 2 40 40 40 0
decay 3 36 36 36 0
decay 4 32 32 32 0
decay 5 29 29 28 -1
decay 6 26 26 25 -1
decay 7 23 23 22 -1
decay 8 21 21 19 -2
decay 9 19 19 17 -2
decay 10 17 17 15 -2
closed_eq_exact=True
growth_k10_closed=242 growth_k10_naive=234
decay_k10_closed=17 decay_k10_naive=15
drift_max=8
wrote out/mech11_growth.csv rows=22
closed_eq_exact=True 是本节要证的等价:闭式与精确递推 22 项全部相等。而 naive 列从第 3 级起就落后:增长十级后 242 对 234,差 8;衰减十级后 17 对 15,差 2。drift 列全是负数,因为截断只会让值偏低——误差方向是确定的,这本身就是一条可以写进测试的判据。
为什么必须走 BigInt
num^k 长得很快:115^10 = 404555773570791015625,早已越过 2^53 ≈ 9.007e15。若用 JS 的 Number 算 115 ** 10,中间就丢位,闭式与递推各自都不可信,更谈不上互相对齐。所以 core/src/ashdeck/growth.ts 全程用 BigInt:
// snippet: core/src/ashdeck/growth.ts 的关键函数(完整版见工作区)
/** 闭式:N0 * num^k / den^k,只做一次地板除。 */
export function closedForm(n0: number, num: number, den: number, k: number): bigint {
return (BigInt(n0) * bigPow(num, k)) / bigPow(den, k);
}
/** 精确递推:保分子,逐步只乘不除,最后一次性除以 den^k。 */
export function exactRecurrence(n0: number, num: number, den: number, k: number): bigint {
let acc = BigInt(n0);
const n = BigInt(num);
for (let i = 0; i < k; i += 1) acc *= n;
return acc / bigPow(den, k);
}
BigInt 的 / 对非负数就是地板除,与 Python 的 // 同义——这正是跨语言对齐的支点。P 层测试把「逐项恒等」与「漂移量」都断言下来。
// file: core/test/gd_mech_11_growth.test.ts
// tier: P
// verify: node --test 'core/test/gd_mech_11_growth.test.ts'
//
// 11.2 增长与衰减的闭式与递推:断言 closedForm 与 exactRecurrence 对 k = 0..10
// **逐项恒等**,且与 Python 侧 sim/gd_mech_11_growth.py 的实跑输出逐位相同。
// 反例:naiveRecurrence 每步地板除,漂移随 k 单调扩大——这就是「必须保分子」的理由。
import { test } from "node:test";
import assert from "node:assert/strict";
import {
bigPow, closedForm, exactRecurrence, naiveRecurrence,
} from "../src/ashdeck/growth.ts";
const STEPS = 10;
const GROWTH: [number, number, number] = [60, 115, 100]; // playerHp, +15%/级
const DECAY: [number, number, number] = [50, 90, 100]; // enemyHp, -10%/级
function closedSeries(p: [number, number, number]): bigint[] {
const out: bigint[] = [];
for (let k = 0; k <= STEPS; k += 1) out.push(closedForm(p[0], p[1], p[2], k));
return out;
}
test("闭式 ≡ 精确递推:k = 0..10 逐项恒等(增长与衰减)", () => {
for (const p of [GROWTH, DECAY]) {
for (let k = 0; k <= STEPS; k += 1) {
assert.equal(exactRecurrence(p[0], p[1], p[2], k), closedForm(p[0], p[1], p[2], k));
}
}
});
test("闭式序列与 Python 侧逐位相同(增长 k=10 = 242,衰减 k=10 = 17)", () => {
const g = closedSeries(GROWTH);
const d = closedSeries(DECAY);
assert.deepEqual(g, [60n, 69n, 79n, 91n, 104n, 120n, 138n, 159n, 183n, 211n, 242n]);
assert.deepEqual(d, [50n, 45n, 40n, 36n, 32n, 29n, 26n, 23n, 21n, 19n, 17n]);
});
test("反例:朴素递推逐步截断,漂移随 k 扩大", () => {
const g = closedSeries(GROWTH);
const gNaive = Array.from({ length: STEPS + 1 }, (_, k) => naiveRecurrence(60, 115, 100, k));
assert.equal(gNaive[3], 90n); // 闭式 91,已差 -1
assert.equal(gNaive[10], 234n); // 闭式 242,差 -8
assert.equal(g[10] - gNaive[10], 8n);
const drift = g.map((v, i) => v - gNaive[i]);
assert.equal(drift.every((x) => x >= 0n), true); // 截断只可能偏低,闭式 >= 朴素
assert.equal(drift[0], 0n);
assert.equal(drift[10], 8n);
});
test("整数幂必须走 BigInt:115^10 已越过 2^53", () => {
const p10 = bigPow(115, 10);
assert.equal(p10 > BigInt(Number.MAX_SAFE_INTEGER), true);
assert.equal(p10, 404555773570791015625n);
});
实跑 node --test '/Users/bingrong.yan/.gdbook/ashdeck/core/test/gd_mech_11_growth.test.ts':
✔ 闭式 ≡ 精确递推:k = 0..10 逐项恒等(增长与衰减) (0.544959ms)
✔ 闭式序列与 Python 侧逐位相同(增长 k=10 = 242,衰减 k=10 = 17) (0.454416ms)
✔ 反例:朴素递推逐步截断,漂移随 k 扩大 (0.12825ms)
✔ 整数幂必须走 BigInt:115^10 已越过 2^53 (0.103334ms)
ℹ tests 4
ℹ suites 0
ℹ pass 4
ℹ fail 0
判读:等价是靠「只除一次」换来的
- 第一条断言覆盖两条曲线、22 个点,
exactRecurrence === closedForm无一处例外。这说明等价不是运气,是构造。 - 第二条把两侧的具体数字钉死:
[60,69,79,91,104,120,138,159,183,211,242]与[50,45,40,36,32,29,26,23,21,19,17],与 Python 侧打印的closed列逐位相同。 - 第三条是反例:它要求朴素递推必须漂移,并且漂移量正好是
8。一条「恒等于闭式」的朴素递推会让这条断言失败——这正是我们要的,反例自己也得能被证伪。 - 第四条守住前提:
115^10 > Number.MAX_SAFE_INTEGER,所以 BigInt 不是可选项。
| 检查项 | 合格标准 |
|---|---|
| 闭式 ≡ 精确递推 | 每个 k 都相等,无例外 |
| 递推保分子 | 中途只乘不除,末尾除一次 |
| 反例 | 朴素递推必须漂移,且漂移量可断言 |
| 幂运算 | num^k 越界前走 BigInt / Python 大整数 |
| 跨语言 | 两侧序列逐位相同 |
曲线对上了,单点也对上了。但「可复现」还有一层没讲:哪些量可以要求逐字相等,哪些量只能要求落在容差内。下一节把这条边界划清楚。
阅读导航:上一节:11.1 整数化与跨语言一致 · 下一节:11.3 数值的可复现 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。