LLVM中诸多pass需要对控制流图进行分析、转化,因此引入了众多图论相关的算法与工具,典型的如dfs_iterator,post_order_iterator等各种遍历迭代器。在这些工具中,强连通分量迭代器(scc_iterator)功能强大,用途广泛(如判断callgraph中某个函数是否递归等),但其实现也相对复杂。本文将先介绍用于计算强连通分量的Tarjan算法,之后讲解scc_iterator的实现及其使用,帮助读者彻底掌握该工具。
有向图G中,如果子图U中的任意两点可达,那么就称U是一个强连通分量。如果不存在包含U的更大子图满足任意两点可达,那么就称U是极大的。一般而言,我们谈论的连通分量都是指极大连通分量。下图是一个有向图及其连通分量的例子。可以看到,如果将连通分量缩成一个点,那么原来的有向图就变成了一个DAG。
要证明缩点后的图是DAG也很简单,假设不是DAG,则有环,那么就能构成一个更大的连通分量,与缩点前每个连通分量是极大的矛盾。
如果我们给每个点v一个dfs的编号,记为\(dfn(v)\)。那么定义强连通分量中,\(dfn\)最小的点为这个分量的根。例如,在图1的例子中,SCC1的根是\(v_1\),SCC2与SCC3的根分别是\(v_3\)与\(v_7\)。不难发现,根是强连通分量中其余节点的祖先。
证明:
对于任意点v,我们定义其\(lowelink\)值为:从点v出发,经过0或多条树边后,再经过至多一条返祖边或横跨边所能到达的连通分量内的点的最小编号。同时,我们定义符合该约束的路径为\(lowelink\ path\)。那么\(lowlink(v)\)就是所有\(lowlink\ path\)所能到达的点的最小值。
例如图1中,\(v_2\)的\(lowlink\ path\)有:\(v_2\),\(v_2 \rightarrow v_6\),\(v_2 \rightarrow v_6 \rightsquigarrow v_1\)。因此\(lowlink(v_2) = 1\)(注意\(v_2 \rightarrow v_6 \rightarrow v_7\)不是\(lowlnk\ path\),因为\(v_7\)不在\(v_2\)的连通分量内)。
因为每个点自身就是一个候选点,所以有\(lowlink(v) \leq dfn(v)\)。我们可以证明强连通分量中,根的\(lowlink\)等于自身的编号,其余节点的\(lowlink\)小于自身的编号。形式化描述如下:
\[\begin{align} lowlink(v) &= dfn(v) && 如果v是强连通分量的根\\ lowlink(v) &< dfn(v) && 否则 \end{align}\]证明:
根据定义,我们可以马上设计出一种算法:对于节点v,我们初始化\(lowlink(v) = dfn(v)\),然后遍历它的所有\(lowlink\ path\),不断更新\(lowlink(v)\)为更小值。那么遍历完之后就能得到\(lowlink(v)\)了。
算法的正确性是显然的,它几乎就是对定义的直接翻译,我们需要处理的就是如何遍历点v的\(lowlink\ path\),并更新\(lowlink(v)\)。
考虑点v的所有出边,设其为\(v \rightsquigarrow w\)。那么有如下几种情况:
用伪代码描述以上单点lowlink的计算过程如下:
calculate_single_lowlink(v, G) // v is the point to be calculated, G is the graph
lowlink[v] = dfn[v];
for (v, w) in G
if (v, w) is a tree edge // case 1,2
lowlink[v] = min(lowlink[v], lowlink[w]);
else if w is in the same SCC with v // case 3
lowlink[v] = min(lowlink[v], dfn[w]);
该算法的其余部分比较简单,但是要判断v,w是否位于同一个强连通分量则较为复杂。Tarjan提出了一种算法,通过维护一个栈来判断一条边是否跨强连通分量,如果遍历到非树边,目标节点位于栈中,则属于同一个scc,否则跨scc。相关算法见下一节。
该算法只需一次dfs就能计算图的所有连通分量以及每个节点的\(lowlink\)。其伪代码如下:
TarjanDFS(v)
dfn[v] = lowlink[v] = ++timestamp; // initialize dfn and lowlink of node v
visit[v] = true;
stack.push(v);
for (v, w) in G
if visit[w] is false // tree edge
TarjanDFS(w);
lowlink[v] = min(lowlink[v], lowlink[w]); // lowlink case1, 2
else if w is in stack // case 3
lowlink[v] = min(lowlink[v], dfn[w]);
// now lowlink[v] is calculated
if lowlink[v] == dfn[v] // root of scc
while dfn[stack.top()] >= dfn[v]
stack.pop(); // all poped node is in the same scc rooted at v
算法的关键是引入了一个栈。当dfs到一个新点时将其入栈,回溯时计算每个点的\(lowlink\)值,如果该点的\(lowlink\)等于\(dfn\)(即该点是一个SCC的根),将该点以及栈顶之间的点弹出。弹出的点构成一个强连通分量。我们现在来证明该算法的正确性。
证明:
scc_iterator能以scc DAG的逆拓扑序方式返回scc。并且返回的scc中按照编号由大到小降序排列(根在最后)。该迭代器采用了Tarjan算法进行实现,但是为了保存迭代状态,采用了栈形式的dfs。同时对非树边的处理也略有不同。
由于dfs无法记录迭代过程的状态,需要用到栈形式的dfs。在这种实现中,入栈出栈操作与递归调用栈类似,每dfs到一个新点就入栈,回溯完一个点就出栈。为了记住每个节点还有那些子节点没有处理,每个栈元素保存了一个子节点迭代器,指向还未遍历到的子节点,只有当子节点迭代器无效时,该点才能回溯。下面就是scc_iterator中的的栈元素数据类型:
struct StackElement {
NodeRef Node; // 当前节点
ChildItTy Child; // 下一个要遍历的子节点
unsigned MinVisited; // lowlink
};
用栈实现dfs的思路如下:
StackDFS(v)
stack.push(StackElement{v, v.first_child, ...})
while stack is not empty
while stack.top().Child is valid // v 还有未遍历的子节点
CurrChild = stack.top().Child;
++stack.top().Child; // 更新child为下一个子节点
if CurrChild is not visited
stack.push(StackElement{CurrChild, CurrChild.first_child, ...}); // 子节点入栈,表示dfs到下一个节点
// 回溯
stack.pop(); // 弹出当前节点
scc_iterator并没有设置一个专门的数据结构判断某个已访问点是否在栈中,而是将已出栈的scc节点的\(dfn\)值设为无穷大(~0U)。这样可以用\(lowlink(v) = min(lowlink(v), dfn(w))\)直接处理非树边从而跳过是否在栈中的判断。
StackTarjan(v)
stack.push(StackElement{v, v.first_child, ++visitNum}); // dfs栈
while stack is not empty
while stack.top().Child is valid
CurrChild = stack.top().Child;
++stack.top().Child;
if CurrChild is not visited
++visitNum;
SCCNodeStack.push(CurrChild); // Tarjan算法维护的栈
nodesVisitNum[CurrChild] = visitNum;
stack.push(StackElement{CurrChild, CurrChild.first_child, visitNum});
else
stack.top().MinVisited = min(stack.top().MinVisited, nodesVisitNum[CurrChild]); // CurrChild如果不在栈中,其值为无穷,不影响结果
// 回溯
lowlink = stack.top().MinVisited;
node = stack.top().Node;
stack.pop();
// 更新父节点
if stack is not empty
stack.top().MinVisited = min(stack.top().MinVisited, lowlink);
// 判断是否是根
if lowlink is equial to nodesVisitNum[node]
do
CurrentSCC.push_back(SCCNodeStack.top());
SCCNodeStack.pop();
nodeVistiNum[CurrentSCC.back()] = ~0U; // 出栈的点设为无穷
while CurrentSCC.back() is not node