11.3 对话树的遍历与校验
客户端任务与对话分支
里的「对话树」是客户端组织与显示任务对话的方式——它关心的是当前该显示哪个节点、选项按钮怎么排。本节把同一批节点当成一张有向图整体来看:节点是点,选项的 goto 是边。一旦这样看,三类结构缺陷就都成了可判定的性质,不需要人去逐条读。
11.1 校验的是单条边的局部性质(目标在不在)。本节做的是全局性质:整张图能不能走通、有没有绕不出去的圈、有没有走进死路。前者是每条边各自成立,后者必须遍历整张图才能回答。
三类缺陷
| 缺陷 | 定义 | 检出方法 | 后果 |
|---|---|---|---|
| 环 | 沿 goto 走一圈回到自己 | DFS 三色标记 | 某条路径可以无限循环,穷举不终止 |
| 不可达 | 从起点走不到 | BFS 可达性 | 写了这段对话,玩家永远看不到 |
| 死路 | 无选项且非结局 | 出度 + ending 判定 | 走到这里故事停住,却没有结局 |
三种缺陷要用三种遍历,因为问的是三个不同的问题:有没有环是「沿边一直走会不会回来」,用深度优先最自然;能不能到达是「从起点能扩散到哪些点」,用广度优先最自然;是不是死路只问一个点的出度与它是不是结局,是局部判定,但要在全图上跑一遍才能列出所有死路。
S 层:遍历报告与层号
脚本把 48 个节点建成有向图,跑三种遍历,并顺带输出层号(从起点出发的最短跳数)。BFS 最短距离在有环图上也有定义,并满足每条可达边的距离上界;它不是拓扑序。DAG 最长路另用 Kahn 拓扑排序,在所有前驱处理完后再松弛后继。
#!/usr/bin/env python3
# file: sim/na_11_3_dialogue_traversal.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/na_11_3_dialogue_traversal.py
"""卷一 11.3 对话树的遍历与校验(S 层):把 48 个对话节点当成一张有向图,
用 DFS / BFS 三种遍历各查一类结构缺陷。
三类缺陷(本节的口径):
1) 环:沿 goto 边走一圈能回到自己 —— DFS 三色标记检出;
2) 不可达节点:从起点出发 BFS 走不到的节点 —— 从未进入任何路径;
3) 死路:既无选项、又不是结局的节点 —— 走到这里故事停住但没给结局。
同时给出 BFS 最短距离(起点0)与另行按拓扑序计算的 DAG 最长边数。
本案例实测三类缺陷均为 0,与 na_00_spec_check.py 的 BFS 穷举口径互证。
产出 out/na_11_3_dialogue_traversal.csv(逐节点层号表)。
"""
from __future__ import annotations
import csv
import json
import os
import sys
from collections import deque
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
OUT = os.path.join(ROOT, "out")
CSV_PATH = os.path.join(OUT, "na_11_3_dialogue_traversal.csv")
def load_spec():
with open(os.path.join(ROOT, "config/story_spec.json"), encoding="utf-8") as fh:
return json.load(fh)
def build_graph(dlg):
"""返回 (邻接表, 节点表, 边集)。邻接表按 id 排序,保证遍历确定。"""
adj = {n["id"]: sorted({c["goto"] for c in (n.get("choices") or [])}) for n in dlg}
node = {n["id"]: n for n in dlg}
edges = {(n["id"], c["goto"]) for n in dlg for c in (n.get("choices") or [])}
return adj, node, edges
def find_cycle(adj):
"""DFS 三色标记找环,返回一条环上的节点序列(无环返回 [])。"""
WHITE, GRAY, BLACK = 0, 1, 2
color = {k: WHITE for k in adj}
parent: dict[str, str] = {}
for root in sorted(adj):
if color[root] != WHITE:
continue
stack = [(root, iter(adj[root]))]
color[root] = GRAY
while stack:
u, it = stack[-1]
advanced = False
for v in it:
if color[v] == GRAY:
path, x = [v], u
while x != v:
path.append(x)
x = parent[x]
return list(reversed(path + [v]))[:-1]
if color[v] == WHITE:
color[v] = GRAY
parent[v] = u
stack.append((v, iter(adj[v])))
advanced = True
break
if not advanced:
color[u] = BLACK
stack.pop()
return []
def bfs_layers(adj, start):
"""从 start 出发的 BFS 层号;不可达节点不入表。"""
layer = {start: 0}
q = deque([start])
while q:
u = q.popleft()
for v in adj[u]:
if v not in layer:
layer[v] = layer[u] + 1
q.append(v)
return layer
def longest_dag_depths(adj, start):
"""可达子图按 Kahn 拓扑序松弛,返回从 start 出发的最长边数。"""
if start not in adj or any(v not in adj for vs in adj.values() for v in vs):
raise ValueError("起点或目标节点不存在")
reachable = set(bfs_layers(adj, start))
indegree = {u: 0 for u in reachable}
for u in reachable:
for v in adj[u]:
indegree[v] += 1
queue = deque(sorted(u for u, degree in indegree.items() if degree == 0))
depth = {start: 0}
processed = 0
while queue:
u = queue.popleft()
processed += 1
for v in adj[u]:
depth[v] = max(depth.get(v, -1), depth[u] + 1)
indegree[v] -= 1
if indegree[v] == 0:
queue.append(v)
if processed != len(reachable):
raise ValueError("可达子图有环,不能按 DAG 计算最长路")
return depth
def main() -> int:
spec = load_spec()
dlg = spec["dialogue"]
start = spec["meta"]["start"]
adj, node, edges = build_graph(dlg)
print(f"spec={spec['meta']['title']} 起点 {start}")
print(f"有向图:节点 {len(adj)} 选项 {sum(len(n.get('choices') or []) for n in dlg)} 条"
f",去重后唯一跳转 {len(edges)} 条(多个选项汇到同一节点时会合并)")
# 1) 环
cyc = find_cycle(adj)
print(f"1) DFS 找环:检出环 {len(cyc)} 个 {cyc if cyc else '(无环,图是 DAG)'}")
# 2) 不可达
layer = bfs_layers(adj, start)
unreach = sorted(set(adj) - set(layer))
print(f"2) BFS 可达性:从起点可达 {len(layer)} / {len(adj)} 个节点;不可达 {len(unreach)} 个 {unreach}")
# 3) 死路
dead = sorted(n for n in adj if not adj[n] and not node[n].get("ending"))
sinks = sorted(n for n in adj if not adj[n])
print(f"3) 死路检测:出度为 0 的节点 {len(sinks)} 个;其中非结局(真死路){len(dead)} 个 {dead}")
print(f" 5 个结局节点:{sinks}")
# 最短距离分层,不能把这个顺序当作拓扑序
max_layer = max(layer.values())
hist: dict[int, int] = {}
for v in layer.values():
hist[v] = hist.get(v, 0) + 1
print(f"层号(BFS 最短跳数):最深 {max_layer} 层;逐层节点数 "
+ " ".join(f"L{k}={hist.get(k, 0)}" for k in range(max_layer + 1)))
back = [(u, v) for u in adj for v in adj[u] if layer[v] > layer[u] + 1]
print(f"分层合法性:每条边满足 layer[v] <= layer[u]+1 的反例 {len(back)} 条")
depth = longest_dag_depths(adj, start)
print(f"拓扑深度(最长路径,图无环故良定义):最深 {max(depth.values())} 层")
reach_end = sorted(n for n in layer if node[n].get("ending"))
print(f"可达的结局节点 {len(reach_end)} / {len(sinks)}:{reach_end}")
rows = [{"id": n["id"], "layer": layer.get(n["id"], -1), "out_degree": len(adj[n["id"]]),
"kind": "ending" if node[n["id"]].get("ending") else "choice"}
for n in sorted(dlg, key=lambda x: (layer.get(x["id"], 99), x["id"]))]
# 与 na_00 的口径互证:三者必须全为 0
ok = (len(cyc) == 0) and (len(unreach) == 0) and (len(dead) == 0)
print(f"三类缺陷(环 / 不可达 / 死路)计数:{len(cyc)} / {len(unreach)} / {len(dead)}"
f";与 na_00_spec_check.py 的 BFS 口径一致:{ok}")
if not ok:
print("FAIL 对话图存在结构缺陷")
return 1
os.makedirs(OUT, exist_ok=True)
with open(CSV_PATH, "w", encoding="utf-8", newline="") as fh:
w = csv.DictWriter(fh, fieldnames=["id", "layer", "out_degree", "kind"])
w.writeheader()
w.writerows(rows)
print(f"wrote out/na_11_3_dialogue_traversal.csv rows={len(rows)}")
print("OK na_11_3_dialogue_traversal:无环、全可达、无死路,DAG 层号已输出")
return 0
if __name__ == "__main__":
sys.exit(main())
本机实跑 /opt/homebrew/bin/python3 sim/na_11_3_dialogue_traversal.py:
spec=雾港夜航 起点 d_pier_1
有向图:节点 48 选项 115 条,去重后唯一跳转 56 条(多个选项汇到同一节点时会合并)
1) DFS 找环:检出环 0 个 (无环,图是 DAG)
2) BFS 可达性:从起点可达 48 / 48 个节点;不可达 0 个 []
3) 死路检测:出度为 0 的节点 5 个;其中非结局(真死路)0 个 []
5 个结局节点:['e_dark', 'e_lit', 'e_loss', 'e_silence', 'e_truth']
层号(BFS 最短跳数):最深 22 层;逐层节点数 L0=1 L1=1 L2=2 L3=2 L4=2 L5=1 L6=2 L7=2 L8=2 L9=1 L10=2 L11=2 L12=2 L13=3 L14=3 L15=3 L16=1 L17=2 L18=2 L19=2 L20=2 L21=2 L22=6
分层合法性:每条边满足 layer[v] <= layer[u]+1 的反例 0 条
拓扑深度(最长路径,图无环故良定义):最深 31 层
可达的结局节点 5 / 5:['e_dark', 'e_lit', 'e_loss', 'e_silence', 'e_truth']
三类缺陷(环 / 不可达 / 死路)计数:0 / 0 / 0;与 na_00_spec_check.py 的 BFS 口径一致:True
wrote out/na_11_3_dialogue_traversal.csv rows=48
OK na_11_3_dialogue_traversal:无环、全可达、无死路,DAG 层号已输出
判读:三类缺陷都是 0,与穷举口径互证
第一行是规模:48 个节点、115 条选项,但去重后只有 56 条唯一跳转。这个落差是本节第一个值得读的数字——它说明「不同的说法汇到同一个下场」是这份规格的常态:两个选项写的是两句不同的话,goto 却指向同一个节点。这属于 8.1 讲的汇聚,也是对话图比选项数看起来更「窄」的原因。
第二行是无环:DFS 三色标记没有检出任何环,图是一个 DAG。无环使 DAG 最长路动态规划适用;BFS 最短距离并不要求图无环,两种层号不能混用。第三行是全可达:48 个节点从起点全部可达,不可达 0 个。第四行是无死路:5 个出度为 0 的节点恰好就是 5 个结局节点,既无选项又不是结局的节点为 0 个。
这三行与 na_00_spec_check.py 的穷举口径互证:那边报的是「不可达节点 / 选项 0 / 0」「卡死路径 0」,这边报的是「不可达 0、死路 0」。两条链用的是不同的算法——一条是 BFS 穷举状态空间(9 895 423 个可达状态),一条是遍历节点图(48 个点、56 条边)。两者给出一致的结构结果,但裸节点图忽略条件和效果,不能单独证明带条件状态空间的可达性;后者仍需对应的完整枚举。
层号分布也值得一看:最深 22 层,但逐层节点数在 1 到 3 之间,只有最后一层(L22)因为 5 个结局节点加 1 个前置节点而到 6。这说明对话图是一条又深又窄的通道——从起点到任一结局,最短也要走二十来步,而每一步的宽度大多是一到三个节点。按拓扑序得到的最长路径为 31 条边,比最短距离最大值 22 多 9。旧稿按 BFS 层序只松弛一次,错误报告为 23;同层或反向距离的边会让更新来得太晚,不能据此推断绕路空间小。
| 观察 | 数字 | 读法 |
|---|---|---|
| 选项 → 唯一跳转 | 115 → 56 | 汇聚是常态,多个说法常汇到同一节点 |
| 环 | 0 | 图是 DAG,可按拓扑序计算最长路 |
| 不可达节点 | 0 / 48 | 没有「写了但走不到」的对话 |
| 死路 | 0 | 5 个无出口节点全是结局 |
| 最大最短距离 / DAG 最长边数 | 22 / 31 | 两种定义不同,不能用 BFS 层序求最长路 |
自检表
| 检查项 | 合格标准 |
|---|---|
| 环可检出 | DFS 三色标记在环上能返回一条环路径 |
| 可达性有基准 | 不可达节点数 == 节点总数 − 可达节点数 |
| 死路定义明确 | 「无选项」与「非结局」同时成立才算死路 |
| BFS 最短距离 | 允许有环;每条可达边满足 layer[v] <= layer[u]+1 |
| DAG 最长路 | 先拓扑排序;有环拒绝,不能以 BFS 层序代替 |
| 与穷举互证 | 节点图遍历与状态空间穷举的结构缺陷计数一致 |
到这里,对话树的数据结构、条件渲染与遍历校验都齐了。剩下的问题是:节点上那些文本——line、text——本身作为规格的一部分,是怎么被组织和渲染的?下一章把文本从对话节点里单独抽出来看。
阅读导航:上一节:11.2 台词的条件渲染 · 下一节:12.1 模板与变量 。
最短与最长的独立反例回归
保存下面的标准库回归文件。四节点反例中,s→a→end 只需两步,s→b→a→end 需要三步;BFS 同层先处理 a,会漏掉 b 后到的最长路更新。真实规格再用递归记忆化求到终点的最长后缀,独立核对31。
# file: sim/na_11_3_graph_contract.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/na_11_3_graph_contract.py
import unittest
from functools import lru_cache
from na_11_3_dialogue_traversal import bfs_layers, build_graph, load_spec, longest_dag_depths
class GraphContract(unittest.TestCase):
def test_shortcut_is_not_topological_order(self):
graph = {"s": ["a", "b"], "a": ["end"], "b": ["a"], "end": []}
self.assertEqual(bfs_layers(graph, "s")["end"], 2)
self.assertEqual(longest_dag_depths(graph, "s")["end"], 3)
# 改存储/邻接顺序不应改变答案。
flipped = {key: list(reversed(graph[key])) for key in reversed(graph)}
self.assertEqual(longest_dag_depths(flipped, "s")["end"], 3)
def test_actual_spec_against_recursive_reference(self):
spec = load_spec()
graph, _, _ = build_graph(spec["dialogue"])
@lru_cache(None)
def suffix(node):
return max((1 + suffix(v) for v in graph[node]), default=0)
self.assertEqual(suffix(spec["meta"]["start"]), 31)
self.assertEqual(max(longest_dag_depths(graph, spec["meta"]["start"]).values()), 31)
self.assertEqual(max(bfs_layers(graph, spec["meta"]["start"]).values()), 22)
def test_cycle_has_bfs_distance_but_no_dag_longest_path(self):
graph = {"s": ["a"], "a": ["s"]}
self.assertEqual(bfs_layers(graph, "s"), {"s": 0, "a": 1})
with self.assertRaises(ValueError):
longest_dag_depths(graph, "s")
def test_bad_reference_rejected(self):
with self.assertRaises(ValueError):
longest_dag_depths({"s": ["missing"]}, "s")
if __name__ == "__main__":
unittest.main()
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。