“经典SSA构造分成不可互换的两阶段。第一阶段运行φ 节点放置算法,为每个变量在所需汇合块插入未完全命名的 φ。第二阶段取得由支配树算法计算的树,从入口深度优先遍历,并为每个原变量 $v$ 维…”
在一个有固定入口的有向图中,节点
支配树把所有这些必经关系压缩成一棵树。Lengauer–Tarjan(LT)算法则通过 DFS、半支配点和延迟处理,在不枚举路径的情况下构造这棵树。理解算法的关键,是区分最后要找的立即支配点与中间用来计算的半支配点。
形式陈述
支配关系为什么能够组成树
设流图为
按此约定,
一个节点的严格支配点沿必经关系线性排列,因此立即支配点唯一。把
例如入口
不做优化时,可以从定义推导出集合方程
它说:除
算法需要维护哪些量
下面区分“节点”与“节点的编号”,避免最常见的实现混淆。
dfn[v] 是不再改变的 DFS 编号,vertex[i] 是编号 semi[v] 最初等于 dfn[v],处理完成后保存 vertex[semi[v]],不能在采用任意原始节点编号时直接把 semi[v] 当节点使用。
ancestor[v] 是动态森林中的父指针,label[v] 是路径查询维护的最小半支配值代表。bucket[s] 收集半支配点已经确定为
最后的 dom[v] 在逆序阶段可能只是一个待追溯的候选节点,正序修正后才变为 idom 并不会使它在整个算法期间始终具有最终语义。
直觉
DFS 编号提供一个可计算的替代量
从
对
其中每个内部节点都满足
一条直接边没有内部节点,所以每个前驱都可以作为候选起点。DFS 父边又保证半支配点的编号小于
半支配点只要求存在一条特殊路径;支配点却要求所有入口路径都经过它。存在量词与全称量词不同,所以两个概念不能直接合并。
一个半支配点不等于立即支配点的图
取边集
并让 DFS 按
对
然而
| 节点 | DFS 父节点 | 半支配点 | 立即支配点 |
|---|---|---|---|
这个四点例子也会真正触发 LT 的最后修正过程,因而比所有半支配点都恰好正确的树形例子更适合检查实现。
同一控制流图的 DFS 树可以是一条链,支配树却是以入口为中心的星形;DFS 祖先关系不能代替支配关系。
LINK 与 EVAL:查询的是路径最小值
算法按 DFS 编号逆序处理节点。处理过的节点会用 LINK(parent[w], w) 接回 DFS 父节点,形成一组动态祖先链。
对已链接的 EVAL(v) 返回当前森林中从 semi 最小的代表节点。若
以下是简单 LINK 配合路径压缩的版本;NIL 表示没有父指针:
LINK(p, v):
ancestor[v] = p
COMPRESS(v):
a = ancestor[v]
if a != NIL and ancestor[a] != NIL:
COMPRESS(a)
if semi[label[a]] < semi[label[v]]:
label[v] = label[a]
ancestor[v] = ancestor[a]
EVAL(v):
if ancestor[v] == NIL:
return v
COMPRESS(v)
return label[v]
压缩时先处理上方路径,再比较两个代表的 semi,最后缩短指针。这里与并查集相似的是路径压缩机制;要维护的却是带权祖先路径信息,而不是仅仅判断两个元素是否属于同一集合。
补充示意图
这幅补充图使用另一组边,DFS 编号为
例子与边界
逆序计算、桶处理与正序修正
DFS 完成后,对全部可达节点初始化 semi[v]=dfn[v]、label[v]=v、ancestor[v]=NIL、桶为空。只把可达前驱加入 pred[v]。入口不参与以下逆序循环。
for i = n, n-1, ..., 2:
w = vertex[i]
for v in pred[w]:
u = EVAL(v)
semi[w] = min(semi[w], semi[u])
bucket[vertex[semi[w]]].append(w)
p = parent[w]
LINK(p, w)
for v in bucket[p]:
u = EVAL(v)
if semi[u] < semi[v]:
dom[v] = u
else:
dom[v] = p
bucket[p].clear()
for i = 2, 3, ..., n:
w = vertex[i]
if dom[w] != vertex[semi[w]]:
dom[w] = dom[dom[w]]
扫描前驱时,尚未处理的前驱返回自身,提供直接边候选;已经处理的前驱通过 EVAL 概括相应已链接路径上的半支配信息。原论文的半支配递推定理保证,这些候选正好覆盖定义所需的最小值。
桶负责“到时再算”。节点 bucket[s]。随着逆序处理和链接进行,轮到查询这条路径时,才把它转换为直接答案或待修正候选。
这一转换由下面的定理支撑。令
由于 semi[w] 更大。这正对应桶中的两种分支。第二种情形先记录 dom[w]=dom[dom[w]]。这不是可以随意省略的压缩优化,而是算法正确性的一部分。[1]
四点例子中的延迟发生在哪里
处理 semi[c]=dfn(a)=2,把
这时处理 EVAL(c) 在排除根 dom[c]=b。这个暂存值尚不是答案。
逆序阶段结束时,dom[c]=b 与 sdom(c)=a 不同,于是令 dom[c]=dom[b]=r,与所有入口路径的定义完全一致。
复杂度取决于 LINK 的具体实现
设已抽出的可达子图有
上面展示的简单 LINK 加路径压缩版本具有
这两个版本不能混写。普通并查集“按秩合并加路径压缩”的近线性结论,也不能仅因代码里出现路径压缩,就自动移植到 LT 的简单链接过程。若文章给出的是简单 ancestor[v]=p,应报告与它匹配的上界。
存储可达子图与其前驱表使用 COMPRESS 在长链上还可能达到线性调用深度,工程实现可以改写为显式栈,而不改变算法的数学含义。
推论与应用
与相邻概念及应用的区别
强连通分量描述节点之间能否互相到达;支配关系描述从指定入口出发是否有绕行路径。改变入口可以改变支配树,却不改变原图的强连通分量。low-link也使用 DFS 最小编号信息,但其路径约束和目标与半支配点不同,不能把两个数组按名字相似直接替换。
在编译器中,支配树支持支配边界与SSA 构造。例如一个定义要支配某次使用,才能保证每条到达该使用的控制流路径都已经经过定义。不可达代码应按编译器约定另行处理,不能让“根本没有入口路径”的空真命题污染通常的支配树。
后支配关系常通过反转边、从出口分析得到。多个正常出口可以连接到一个虚拟出口;但无法到达出口的无限循环区域,需要单独说明语义。只对终止路径定义后支配,与把无限执行也纳入路径量词,并不总是同一个概念。
检验实现时,小图上按“删除候选节点后是否仍可到达”直接求支配关系,是一个与 LT 内部机制不同的可靠对照。测试应包含交叉边、回边、多前驱、不可达节点,以及上述半支配点不等于立即支配点的例子,而不只是几棵树。
参考资料
[1] Thomas Lengauer and Robert E. Tarjan, “A Fast Algorithm for Finding Dominators in a Flowgraph,” ACM Transactions on Programming Languages and Systems 1(1), 1979, pp. 121–141,半支配定理、简单实现与改进实现的原始论文。
[2] Keith D. Cooper, Timothy J. Harvey, and Ken Kennedy, “A Simple, Fast Dominance Algorithm,” Rice University technical report, 2001,适合对照理解的迭代式支配算法。
[3] Robert E. Tarjan, Data Structures and Network Algorithms, SIAM, 1983,路径查询与图算法数据结构背景。