想象一位主厨要操办五百人的婚宴。他在黑板上画下一张后厨作战图:每个圆圈代表一道菜的加工台,箭头代表“必须先完成 A,才能开始 B”——高汤没吊好,佛跳墙就不能开火。这张作战图,就是今天的主角:计算图。 AI 编译器面对神经网络时干的正是同一件事:把模型翻译成由算子和数据流组成的图,然后在图上“走来走去”完成各种优化。怎么把模型画成图(构建)?怎么把图存进电脑(存储表示)?怎么排出合理的执行顺序(拓扑排序)?怎么选择前序或后序去走图(遍历)?怎么认出图中的固定套路并整体替换(图匹配)?这篇文章用后厨作比喻,把这些问题一次讲透。

岗位关联

计算图的构建与遍历是 AI 编译器优化 Pass 的地基,读懂它,你就能看懂 XLA、TVM 等框架一半以上的优化逻辑,也是编译器岗位面试的必考基础。

目录
  1. 可视化演示
  2. 计算图构建
  3. 图的存储表示
  4. 拓扑排序
  5. 前序遍历
  6. 后序遍历
  7. 图匹配与替换
  8. AI 应用场景
  9. Python 演示
  10. 常见误区
  11. 面试题

可视化演示

展示图遍历三维度:拓扑排序(Kahn算法入度0优先入队O(V+E)保证执行顺序)、前序vs后序遍历(前序根→左→右用于常量折叠/后序左→右→根用于自动微分反向传播)、图模式匹配(Conv+BN+Relu子图模式匹配命中后替换为单个Fused算子),三模式切换理解编译器优化Pass执行机制。

本节配有交互动效(进入视口后自动加载,约 3KB)。

'+C1+'

计算图构建

主厨画作战图的第一步,不是开火,而是把每道菜的原料关系写在黑板上:“A 菜需要 B 菜提供的半成品”。在编译器里,这一步叫计算图构建:把神经网络的前向代码逐行翻译成一张图。图中的圆圈叫节点,对应一个算子(卷积、加法、激活函数等一次运算操作);箭头叫边,对应张量的数据流向。构建流程通常是:框架解析前向函数,遇到一个算子就注册一个新节点,再把算子输入对应的上游节点连到它身上。PyTorch 的 torch.fx、TensorFlow 的 GraphDef 都在做这件事。

计算图必须满足有向无环图(DAG)约束:任何节点都不能直接或间接依赖自己,否则就会出现死循环般的依赖。后厨里不可能存在“这道菜需要自己出锅后的汤汁”这种菜谱。编译器在构建时会对每条新建的边做校验,发现环就报错拒绝编译。

作战图画好只是第一步;黑板上的图必须落成便于查阅的备忘录,否则上千道菜的关系根本记不住。这就是下一节的存储表示。

G=(V,E),E⊆{(u,v)∣u,v∈V, u≠v}\mathcal{G}=(\mathcal{V},\mathcal{E}),\quad \mathcal{E}\subseteq\{(u,v)\mid u,v\in\mathcal{V},\,u\neq v\}
计算图 G 由节点集 V 和边集 E 组成,边 (u,v) 表示 u 的输出流向 v,且不允许出现自环。
  1. 节点集V 是图中所有算子的集合,每个算子对应一次张量运算,相当于黑板上每个加工台。
    V={v1,v2,…,vn}\mathcal{V}=\{v_1,v_2,\dots,v_n\}
  2. 边集E 是 V 上有序对的子集,边 (u,v) 表示从 u 到 v 的数据依赖,相当于“要先做好 u 才能开工 v”的箭头。
    E⊆{(u,v)∣u,v∈V, u≠v}\mathcal{E}\subseteq\{(u,v)\mid u,v\in\mathcal{V},\,u\neq v\}
  3. 无环约束DAG 要求不存在从某节点出发又能绕回该节点的可达路径,否则执行顺序无法确定。
    ∄ v∈V: (v,v)∈E+\nexists\,v\in\mathcal{V}:\ (v,v)\in\mathcal{E}^{+}

图的存储表示

后厨作战图怎么保存?有两种极端方案:一种是每个加工台贴一张“我完成后通知 B、C 两台”的小纸条;另一种是画一张超大的总表,横竖都列满所有菜名,在对应的格子里打勾。前者是邻接表,后者是邻接矩阵。

邻接表为每个节点存一份后继节点列表,内存开销是 O(V+E),适合边稀疏的大图;邻接矩阵用 n×n 的 0/1 方阵表示任意两点之间是否有边,查询只需 O(1),但要吃掉 O(V²) 空间。AI 编译器更常用的是 CSR(压缩稀疏行) 格式:把邻接表按行拼接,用三个数组记录每行的偏移、列号和数值,既省内存又利于顺序访问。CSR 相当于把小纸条按加工台顺序装订成册,每页只写“本台通知谁”。

大模型的计算图动辄上万节点、数万条边,用邻接矩阵意味着上亿个格子,绝大多数是 0,浪费惊人。因此工业界的图 IR(如 XLA 的 HLO、TVM 的 Relay)几乎都采用邻接表或 CSR 一类紧凑表示。

婚宴菜单不可能印全所有菜的两两关系;现实中的编译器几乎都选邻接表或 CSR,省下的内存留给真正有用的优化。

Auv={1,(u,v)∈E0,(u,v)∉EA_{uv}=\begin{cases}1,&(u,v)\in\mathcal{E}\\0,&(u,v)\notin\mathcal{E}\end{cases}
邻接矩阵 A 是 n 行 n 列的 0/1 方阵,第 u 行第 v 列是否为 1,刻画了 u 到 v 是否存在数据依赖。
  1. 矩阵定义邻接矩阵是 n 行 n 列的方阵,行和列都按节点编号排列。
    A∈{0,1}n×n,n=∣V∣\mathbf{A}\in\{0,1\}^{n\times n},\quad n=|\mathcal{V}|
  2. 元素判定若边 (u,v) 存在,则 A 中对应位置为 1,否则为 0。
    Auv=1  ⟺  (u,v)∈EA_{uv}=1 \iff (u,v)\in\mathcal{E}
  3. 空间对比邻接矩阵固定占用 n² 个格子,邻接表只记录实际存在的边。
    矩阵: O(n2),邻接表: O(∣V∣+∣E∣)\text{矩阵: }O(n^{2}),\qquad \text{邻接表: }O(|\mathcal{V}|+|\mathcal{E}|)

拓扑排序

作战图确定后,主厨要排上菜顺序:炖汤要提前三小时吊水,凉菜可以最后十分钟拼盘。规则只有一条——箭头必须从先做到后做。把这种顺序排出来,就是拓扑排序。

拓扑排序把 DAG 的所有节点排成一个线性序列,保证每条边的起点都出现在终点之前。最常用的 Kahn 算法 很直白:反复找出“入度为 0 的节点”(也就是原料齐全、随时可以开工的加工台),把它输出,再把它所有后继的入度减 1,重复直到图被清空。另一个实现来自 DFS:对每个节点做后序 DFS,把回溯完成的节点依次记录,最后将序列逆序输出(即逆后序),同样得到合法拓扑序;若输出的节点数少于总节点数,说明图中存在环——这就是环检测。

编译器拿到拓扑序后,就按这个顺序下发算子执行指令,保证每个算子执行时它的输入都已经算好。这就像主厨按拓扑序把任务单贴到各加工台,厨房才不会出现“汤还没吊好就开始炖佛跳墙”的混乱。

topo(G)=[v1,v2,…,vn],∀ (u,v)∈E: pos(u)<pos(v)\text{topo}(\mathcal{G})=[v_1,v_2,\dots,v_n],\qquad \forall\,(u,v)\in\mathcal{E}:\ \text{pos}(u)<\text{pos}(v)
拓扑序列是节点的一个排列;在这个排列里,每条边的起点都排在终点之前。
  1. 合法序列长度为 n 的排列中,任意边 (u,v) 都满足 u 的位置在 v 之前。
    pos(u)<pos(v),∀ (u,v)∈E\text{pos}(u)<\text{pos}(v),\quad \forall\,(u,v)\in\mathcal{E}
  2. Kahn 流程每轮取出入度为 0 的节点输出,再将其所有后继的入度减 1。
    in(v)=0  ⇒  v 可输出\text{in}(v)=0 \;\Rightarrow\; v\ \text{可输出}
  3. 环检测若输出节点数少于 |V|,剩余节点互相依赖形成环。
    ∣order∣<∣V∣  ⇒  ∃ cycle|\text{order}|<|\mathcal{V}| \;\Rightarrow\; \exists\ \text{cycle}

前序遍历

主厨巡视厨房时有一种走法:走到一个加工台,先把任务交代清楚(“你这台负责出高汤”),再往下走到需要高汤的台子继续交代。这种“先访问父节点、再依次访问子节点”的走法,就是前序遍历。

前序遍历从根节点出发:先处理当前节点,再递归遍历它的所有直接后继。在 AI 编译器中,自顶向下的 Pass 正是依赖这种顺序——例如形状推断(shape inference):父节点把张量的形状信息传给子节点,子节点据此推导自己的输出形状,信息像水流一样从根流向叶子。前序遍历的递归实现非常简洁:先访问自己,再对每个子节点调用同样的函数。

要注意,前序遍历结果一般不满足拓扑序。举个反例:根节点 A 先访问子节点 B,再访问子节点 C,但图中存在边 C→B,序列里 B 却排在 C 前面,就违反了“起点在终点之前”的约束。所以前序适合“从根到叶传播信息”的任务,不适合安排执行顺序。

前序遍历像主厨“从上到下布置任务”,信息一路向下游传递;但布置任务和真正开火是两回事,真正的开火顺序是反过来的——这就是下一节要讲的从下往上的走法。

pre(u)=[u]+∑v∈succ(u)pre(v)\text{pre}(u)=[u]+\sum_{v\in\text{succ}(u)}\text{pre}(v)
pre(u) 表示以 u 为根的前序序列——先放自己,再按访问顺序拼接每个子节点的前序序列。
  1. 访问自身序列的第一个元素是当前节点 u。
    pre(u)=[u]+⋯\text{pre}(u)=[u]+\cdots
  2. 递归子节点对每个直接后继 v 递归地求 pre(v)。
    ∀ v∈succ(u): pre(v)\forall\,v\in\text{succ}(u):\ \text{pre}(v)
  3. 拼接顺序所有子节点的前序序列按访问次序依次拼接在 u 之后。
    pre(u)=[u]+pre(v1)+pre(v2)+⋯\text{pre}(u)=[u]+\text{pre}(v_1)+\text{pre}(v_2)+\cdots

后序遍历

真正的开火顺序是反着的:高汤吊好、配菜切好,佛跳墙的加工台才开始拼装;拼装完成,才轮到下一道依赖它的菜。这种“子节点全部处理完,才处理父节点”的走法,就是后序遍历。

后序遍历先递归处理所有子节点,再把当前节点放进序列,因此它是自底向上 Pass 的骨架。常量折叠(constant folding)先递归把子表达式算成常量,再把父算子替换成常量结果;内存规划先处理子节点的生命周期,父节点的输出才能安全复用缓冲区。代码生成里“先编译子图、再拼接父算子”也遵循同样的逻辑。

后序遍历与拓扑排序关系密切:把后序序列取逆序,就得到一个合法的拓扑序(这正是 DFS 实现拓扑排序的原理)。动画演示里 G、E、F 先被点亮,D 在其后,A 最后——每个节点出现时它的下游都已就绪,逆序读出来就是"A → C → B → D → F → E → G"这样的执行顺序。

后厨里“备料齐了才开火”的顺序,正好就是编译器执行算子、回收内存的顺序——从上到下布置,从下到上执行,两者配合才能让整张图顺畅跑起来。

post(u)=∑v∈succ(u)post(v)+[u]\text{post}(u)=\sum_{v\in\text{succ}(u)}\text{post}(v)+[u]
post(u) 表示以 u 为根的后序序列——先把所有子节点的后序序列排完,最后再放 u 自己。
  1. 递归子节点先把所有子节点的后序序列按顺序拼接起来。
    post(u)=∑v∈succ(u)post(v)+⋯\text{post}(u)=\sum_{v\in\text{succ}(u)}\text{post}(v)+\cdots
  2. 最后访问自身u 放在所有子节点之后,这是与前序唯一的顺序差异。
    ⋯+[u]\cdots+[u]
  3. 逆序即拓扑序对 DAG,后序序列的逆序满足每条边的起点在终点之前。
    reverse(post(G))=topo(G)\text{reverse}(\text{post}(G))=\text{topo}(G)

图匹配与替换

老师傅看到菜谱里“糖、醋、生抽、水淀粉、小火收汁”连续五步,立刻认出这就是糖醋汁配方,直接拿出调好的酱料替换整段步骤。编译器里的图匹配做的就是这件事:在大图里认出某个固定的子图结构,然后用一个等价但更高效的算子把它整体替换掉。

具体做法分三步:定义模式(pattern)——一张小 DAG,比如“Conv 后接 BN”;在目标图中寻找与模式结构相同的子图;命中后执行替换,例如把 Conv+BN 融合成一个 ConvBN 算子,省掉一次中间张量的写盘与读盘。算子融合、精度回退(fp32→fp16 的模式替换)、布局转换,都是图匹配的典型应用。

这里的“认出”不是靠算子名字比对,而是子图同构(subgraph isomorphism):存在一个从模式节点到目标图节点的映射,把模式里每条边都保持到图里的边。子图同构在一般图上是个难问题(NP 完全),所以编译器会用锚定节点、拓扑序剪枝、模式哈希库等技巧加速,这也是图匹配工程实现真正的难点。

就像老师傅用现成酱料替换五步烹制,图匹配把一个 6 节点的子图折叠成 1 个融合算子,推理引擎的延迟与显存占用因此大幅下降——这就是计算图遍历与匹配在真实世界的价值。

∃ ϕ:Vp→V,(u,v)∈Ep  ⟺  (ϕ(u),ϕ(v))∈E\exists\,\phi:\mathcal{V}_p\to\mathcal{V},\qquad (u,v)\in\mathcal{E}_p\iff(\phi(u),\phi(v))\in\mathcal{E}
模式图和目标图之间若存在保边的映射 φ,就说明目标图中隐藏着这段“配方”,可以安全替换。
  1. 模式定义模式图 Vp 是待识别的小结构,如"Conv→BN"两个节点加一条边。
    Vp={p1,…,pk}\mathcal{V}_p=\{p_1,\dots,p_k\}
  2. 保边映射φ 把每个模式节点映到目标图节点,且边的关系完全保持。
    ϕ:Vp→V,(u,v)  ⟺  (ϕ(u),ϕ(v))\phi:\mathcal{V}_p\to\mathcal{V},\quad (u,v)\iff(\phi(u),\phi(v))
  3. 替换动作把命中的子图折叠成一个融合算子,并保持外部边的语义不变。
    Subgraph(S)→vfused\text{Subgraph}(S)\to v_{\text{fused}}

AI 应用场景

推理引擎的算子融合优化

阶段:模型优化与编译

解决的问题:传统推理引擎逐算子执行,Conv 的输出要写回显存、BN 再读出来,来回搬动量巨大。AI 编译器用后序遍历 + 图匹配把 Conv、BN、ReLU 识别并融合成一个内核,消除中间张量的写读。

具体案例:ONNX Runtime 与 TensorRT 在部署 ResNet 类模型时,通过模式匹配把连续的 Conv+BN+ReLU 替换为融合算子,实测单算子 kernel 启动次数从数百降到几十,端到端延迟下降约 30%。

模型导出与静态图执行

阶段:模型部署准备

解决的问题:动态图脚本难以提前掌握整张图的结构。torch.fx、ONNX exporter 把 Python 前向代码逐算子翻译成静态计算图,再做拓扑排序确定执行顺序,保证每个算子执行时输入已就绪。

具体案例:把 PyTorch 训练的 Transformer 导出为 ONNX 时,exporter 构建约五百个节点的 DAG,拓扑排序后按序下发到 TensorRT;若图中出现环(如错误的循环控制流),编译器会直接报错拒绝导出。

自动微分的反向图构建

阶段:训练框架设计

解决的问题:反向传播需要为每个前向算子安插对应的梯度算子。框架先构建前向计算图,再以逆后序(即拓扑序)为基础,自底向上依次为每个节点插入梯度子图,最终形成可用于训练的正反向联合图。

具体案例:PyTorch 的 autograd 在前向执行时动态记录图,backward 时按拓扑逆序触发每个节点的梯度函数;理解了后序遍历与拓扑序的关系,就能明白为什么梯度链总是从 loss 一路回传到最底层参数。

Python 演示

python81 行
class Node:    def __init__(self, name, op):        self.name = name        self.op = op        self.inputs = []  class Graph:    def __init__(self):        self.nodes = {}     def add(self, name, op, inputs):        n = Node(name, op)        n.inputs = list(inputs)        self.nodes[name] = n     def successors(self, u):        return [v for v, n in self.nodes.items() if u in n.inputs]     def topo_order(self):        indeg = {name: len(n.inputs) for name, n in self.nodes.items()}        ready = [name for name, d in indeg.items() if d == 0]        order = []        while ready:            u = ready.pop(0)            order.append(u)            for v in self.successors(u):                indeg[v] -= 1                if indeg[v] == 0:                    ready.append(v)        if len(order) != len(self.nodes):            raise RuntimeError("cycle detected")        return order     def postorder_from(self, u, visited, out):        visited.add(u)        for v in self.successors(u):            if v not in visited:                self.postorder_from(v, visited, out)        out.append(u)     def postorder(self):        visited = set()        out = []        for name, n in self.nodes.items():            if not n.inputs and name not in visited:                self.postorder_from(name, visited, out)        return out  def match_pattern(g, src_op, dst_op):    pairs = []    for dst, n in g.nodes.items():        if n.op == dst_op:            for src in n.inputs:                if g.nodes[src].op == src_op:                    pairs.append((src, dst))    return pairs  def main():    g = Graph()    g.add("x", "input", [])    g.add("w1", "const", [])    g.add("conv1", "conv", ["x", "w1"])    g.add("bn1", "bn", ["conv1"])    g.add("relu1", "relu", ["bn1"])    g.add("w2", "const", [])    g.add("conv2", "conv", ["relu1", "w2"])    g.add("bn2", "bn", ["conv2"])    g.add("relu2", "relu", ["bn2"])    g.add("out", "add", ["relu1", "relu2"])     print("topo: ", g.topo_order())    print("post: ", g.postorder())    pairs = match_pattern(g, "conv", "bn")    print("conv-bn pairs: ", pairs)  if __name__ == "__main__":    main()
目的:演示计算图的构建、Kahn 拓扑排序、后序遍历,以及用简单的边匹配发现可融合的 Conv-BN 算子对。
  1. self.nodes = {}用字典按名字索引节点,相当于给每个加工台贴一张名牌。
  2. def add(self, name, op, inputs)构建图的入口:每遇到一个算子就注册一个节点并记录输入依赖。
  3. indeg = {name: len(n.inputs) for name, n in self.nodes.items()}Kahn 算法的起点:统计每个节点的入度,即“还差几样原料”。
  4. ready = [name for name, d in indeg.items() if d == 0]原料齐全(入度为 0)的加工台先开工。
  5. for v in self.successors(u): indeg[v] -= 1节点完成后通知下游:入度减 1,可能有新加工台备齐原料。
  6. raise RuntimeError("cycle detected")输出节点数不足即存在环,编译器因此拒绝执行。
  7. def postorder_from(self, u, visited, out):后序 DFS 核心:先递归子节点,最后把自身放入序列。
  8. out.append(u)这一行的位置决定了是前序还是后序,是遍历中最关键的一行。
  9. pairs = match_pattern(g, "conv", "bn")图匹配入口:找出所有可融合的 Conv→BN 边。

常见误区

误区我以为一套图只有一种合法的拓扑序,编译器给出的顺序就是唯一标准答案,换个顺序就会出错。

正解只要每条边的起点都排在终点之前,任何排列都是合法拓扑序。Kahn 算法每次从多个入度为 0 的节点里任选一个,选择不同结果就不同;不同编译器甚至不同版本,都可能排出不同但同样正确的顺序。只有链路状图(每个节点只有一个前驱)才会得到唯一拓扑序。

为何常见:教材通常只演示一条执行路径,初学者容易把“一种可行解”误当成“唯一解”。

误区既然前序是从父节点开始往下走,那它一定是“先父后子”,应该天然满足拓扑排序的要求吧?

正解不一定。前序只保证“父先于子被访问”,但父节点有多个子节点时,先访问的子节点可能依赖后访问的子节点,序列就违反拓扑序。例如根 A 先访 B 再访 C,若存在边 C→B,序列中 B 在 C 前,就违背了“起点在终点之前”。真正与拓扑序有确定关系的是后序的逆序。

为何常见:把“先父后子”的直觉等同于“按依赖排序”,忽略了兄弟节点之间也可能存在依赖。

误区我把网络想象成一条或多条互不相交的链,觉得一个算子只会被一个下游算子使用,图上每个节点只有一个父节点。

正解计算图是 DAG,允许“扇出”:一个节点的输出可以喂给多个下游节点。例如残差网络里一个 ReLU 的结果同时进入两条分支。正因为存在共用子图,后序遍历才重要——保证共用子图只被处理一次;图匹配也常常要处理这类共享结构。

为何常见:初学时常从单条前向链理解网络,忽略真实模型中大量存在的分支与共享。

误区我以为只要两个节点算子名相同,就能当作同一个模式匹配上,直接替换。

正解图匹配要求子图同构:不仅算子类型要对应,边与边的连接关系也要完全保持。两个名字相同的算子如果处在不同的连接结构中,往往不能命中同一个模式。并且一般图上的子图同构是 NP 完全问题,编译器必须用锚定节点、剪枝、模式哈希库等手段加速,否则上万节点的图根本搜不完。

为何常见:人眼识别“套路”很快,容易低估程序在图上做结构性搜索的难度。

面试题

Q1. 计算图中的一个节点通常对应什么?

单选题 · easy

  • A.卷积内核的权重参数
  • B.一次算子计算操作← 正确答案
  • C.一份训练数据集样本
  • D.反向传播的梯度值

答案:B。计算图的节点表示算子(一次张量运算),边表示张量的数据流。权重是沿边流动的数据之一,属于边的“货物”而非节点本体;训练样本和梯度同样不是静态图的节点定义。理解节点=算子,是读懂一切图优化的前提。

Q2. 静态计算图必须满足哪条结构约束?

单选题 · easy

  • A.必须是完全二叉树的形态
  • B.必须至少包含一条反向边
  • C.必须是无环的有向图 DAG← 正确答案
  • D.任意两节点之间必须连通

答案:C。静态数据流计算图必须是无环有向图。有环意味着存在节点依赖自己,执行顺序无法确定。树形、连通都不是必要约束——一个合法的 DAG 可以有多个输入、多个输出,节点无需两两连通。

Q3. 常量折叠优化通常采用哪种遍历方式?

单选题 · easy

  • A.前序遍历
  • B.随机遍历
  • C.后序遍历← 正确答案
  • D.层序遍历

答案:C。常量折叠需要把子表达式先算成常量,才能替换父算子,属于“子节点处理完再处理父节点”的自底向上逻辑,正是后序遍历的典型应用。前序适合自顶向下传播信息,不适合这类需要子结果的任务。

Q4. 判断一个排列是否为合法拓扑序的依据是?

单选题 · mid

  • A.所有节点的访问深度严格递增
  • B.每条边的起点都排在终点前面← 正确答案
  • C.所有入度为零的节点排在最前
  • D.输出的节点总数恰好等于边数

答案:B。拓扑序的唯一判定标准是“每条边的起点都在终点之前”。入度为 0 的节点排在最前只是 Kahn 算法的操作方式,深度、边数都不是判定依据。牢记这个定义,就能快速检验编译器给出的执行顺序是否合法。

Q5. Kahn 算法每一轮优先处理的节点特征是?

单选题 · mid

  • A.出度达到全图最大值的那个节点
  • B.入度为零即原料齐全的节点← 正确答案
  • C.深度最浅且层数最低的那个节点
  • D.后继节点数量最多的那个计算节点

答案:B。Kahn 算法反复取出“入度为 0”的节点——相当于原料已齐、随时可开工的加工台——输出后将其后继的入度减 1。选节点的依据只有入度是否为 0,与出度、深度、后继数量无关。

Q6. 前序遍历访问某节点时,它的子节点的状态是?

单选题 · mid

  • A.所有子节点已经全部访问完成
  • B.子节点尚未被递归地访问到← 正确答案
  • C.部分子节点已经被提前替换掉
  • D.子节点按照后序完成了排序

答案:B。前序的顺序是“先访问父节点,再递归子节点”,所以访问父节点时子节点一个都还没访问。若子节点已全部访问完,那是后序。这个差异决定了前序适合自顶向下传播信息,后序适合自底向上汇总结果。

Q7. 关于 DAG 的 DFS 后序遍历,哪个说法正确?

单选题 · hard

  • A.后序序列的逆序是合法的拓扑序← 正确答案
  • B.后序序列本身就是唯一的拓扑序
  • C.只有二叉树结构才能做后序遍历
  • D.后序与前序的访问顺序完全相同

答案:A。后序访问时终点先出、起点后出,恰好与拓扑序“起点在前”相反,因此把后序序列逆序输出就是合法拓扑序,这也是 DFS 实现拓扑排序的原理。后序序列本身不是拓扑序,而且拓扑序并不唯一;后序也不只适用于二叉树。

Q8. 在 AI 编译器中,形状推断通常使用哪种遍历方式?

单选题 · mid

  • A.前序遍历,信息从根向叶子传播← 正确答案
  • B.后序遍历,信息从叶子向根传播
  • C.随机遍历,不依赖任何固定顺序
  • D.层序遍历,每次只访问同一层节点

答案:A。形状推断是自顶向下的 Pass:父节点把张量形状传给子节点,子节点据此推导输出形状,信息从根流向叶子,对应前序遍历。后序适合常量折叠这类需要子结果的任务,层序和随机遍历在编译优化中极少作为主顺序。

Q9. 关于 CSR 存储格式,下列说法正确的有?

多选题 · hard

  • A.用三个数组实现稀疏图的压缩存储← 正确答案
  • B.在稀疏大图上比邻接矩阵省内存← 正确答案
  • C.每个节点都额外存一份完整邻接矩阵
  • D.能快速定位节点的出边邻接范围← 正确答案

答案:ABD。CSR 用行偏移、列号、数值三个数组压缩存储稀疏图,内存远小于 O(V²) 的邻接矩阵,且通过偏移数组能 O(degree) 遍历某节点的出边。C 描述的是邻接矩阵而非 CSR;正因省内存,工业级图 IR 才普遍采用 CSR。

Q10. 关于计算图构建流程,下列说法正确的有?

多选题 · hard

  • A.每个算子注册为图中的一个节点← 正确答案
  • B.算子的输入决定了上游连接边← 正确答案
  • C.构建完成后需要做环结构的检测← 正确答案
  • D.图的构建完全不需要关心张量形状

答案:ABC。构建流程是“每个算子注册节点、按输入建立边、最后校验无环”,三点缺一不可。D 错误:构建阶段虽然主要记录拓扑关系,但多数框架会在建边时同时登记张量形状信息,为后续形状推断做准备。