LCA(Least Common Ancestors)是指树结构中两个结点的最低的公共祖先。而LCA算法则是用于求两个结点的LCA。当只需要求一对结点的LCA时,我们很容易可以利用递归算法在O(n)的时间复杂度内实现,其中n为树中的结点数目。但是有时候我们会要求计算多组结点对各自的LCA,这样总的时间复杂度将会到达O(nq),其中q为问题总数,这往往是无法接受的。
LCA离线算法用于计算一组预先给出的结点对各自的LCA(即我们允许在拥有所有结点对信息之后再进行解答)。Tarjan算法即是一种LCA离线算法。我们需要为结点维护一下属性:实现并查集所需要的属性p和rank,children用于保存所有直接孩子结点,ancestor用于记录结点的祖先,visited用于记录结点是否已经访问过,questionList用于记录与node相关的LCA问题。
1 tarjan(node) 2 for child in node.children 3 tarjan(child) 4 union(child, node) 5 node.findSet().ancestor = node 6 node.visited = true 7 for question in node.questionList 8 other = another node specified by question //将other设定为question中涉及到的另外一个问题 9 if(other.visited) 10 question.answer = other.findSet().ancestor
上面就是LCA的所有部分了。其中union和findSet分别用于合并并查集以及查找结点所在并查集的代表结点。要解决所有的LCA问题,只需要用树中的根结点调用tarjan函数即可。
先说明时间复杂度,由于并查集的所有操作摊还代价都可以视作为O(1),因此tarjan函数的2~6行实际上就是一个普通的深度优先搜索而已,其时间复杂度为O(n)。而第7~10行每次循环都会扫描一个问题,且每个问题只涉及两个结点,故最多只会被扫描两次,因此只会被调用O(q)次,故tarjan函数总共花费的时间复杂度为O(n+q),这无疑是优秀的时间复杂度。
再说明算法的正确性:只需要说明每个问题都被正确求解了。从两方面说明,1.每个问题的answer属性都被设置过.2.每次对问题的answer属性进行设置时,其值总是正确的。
由于每个问题都会被扫描两次,在第一次扫描结束后,之后会执行第6行将结点设置为已访问。而在第二次扫描时,发现另外一个结点已经被访问过了,因此会执行第10行代码,对answer属性进行设置。因此我们保证每个问题都会被解答,且以第二次扫描时的答案为最终答案。因此方面1被成功证明。
假设问题Q问的是结点u和v的LCA,并且假设其LCA为a。分三种情况讨论,1是u=v,2是u!=v=a,3是u、v、a三者均不同。当情况1发生时,u在对所有孩子递归完后,扫描涉及自身的问题时,会将问题解决两次,而每次都将答案设置为自身,故这种情况下赋值是正确的。当第2种情况发生时,我们在v对孩子进行递归完毕后,会将所有孩子都合并到v所在的集合中,并将v所在集合的代表结点的祖先设置为v。而之后扫描问题时会遇到Q,此时由于u已经被访问过了,会将答案设置为v,此时答案是正确的(并且对该问题的第二次扫描已经完成,问题不会被重复赋值)。对于情况3,不妨设u在v之前被访问。由于a是u和v的LCA,因此a的任意子结点都不可能是u和v的LCA,即u和v挂在a的两个不同的子结点下。故当我们访问完u,并回溯到a时,会将u加入到a所在集合,并将a所在集合的祖先设置为a。而之后搜索到v后,v在扫描到Q时,会将Q的answer值设置为u.findSet().ancestor,此时u依旧处于a所在集合中,而a所在集合的祖先始终为a(因为第4~5行代码只会将以当前结点为根的子树中的结点加入到自身所代表的集合中,而由于a的流程尚未走完),因此answer为a,答案正确。