由LCA引发的问题--RMQ,Tarjan,并查集等

引入LCA问题及其在线和离线算法    

    两个月前有一次一个电话面试问到了一个问题:“怎样求二叉树中距离两个叶子节点最近的祖先节点。”当时不会,后来在网上查了查发现是一个比较经典的题目,也有几种算法可以解决这个问题,我学习了一下,在这儿记下来。这个问题更宽泛的定义是:如何求树(不限于二叉树)中两个节点(不限于叶子节点)的最近公共祖先节点。这个问题被称为LCA(Lowest Common Ancestor)问题。

    我找到的关于这个问题解答的最全面的文章是这篇:二叉树中两个节点的最近公共祖先

    按照文中的说法,解决这个问题有两个思路,一个为离线算法,另一个为在线算法。我查了一下这两组名词的含义,“在计算机科学中,一个在线算法是指它可以以序列化的方式一个个的处理输入,也就是说在开始时并不需要已经知道所有的输入。相对的,对于一个离线算法,在开始时就需要知道问题的所有输入数据,而且在解决一个问题后就要立即输出结果。例如,选择排序在排序前就需要知道所有待排序元素,然而插入排序就不必。”

    此问题的离线和在线算法都涉及到了深度优先搜寻,在线算法引出了另外一个问题,称为RMQ(Range Minimum/Maximum Query)问题,而离线算法是一个专门的由一个外国人发明的Tarjan算法。


由在线算法引出RMQ问题,及LCA与RMQ的相互转化

    在查找到的资料中,LCA与RMQ是可以相互转化的,LCA向RMQ的转化是在在线算法的思路下进行的。下面说明一下他们之间的关系,这其中参考了一个PPT。

    问题的提出:

    LCA:基于有根树最近公共祖先问题。 LCA(T, u, v):在有根树T中,询问一个距离根最远的结点x,使得x同时为结点u、v的祖先。

    RMQ:区间最小值询问问题。 RMQ(A, i, j):对于线性序列A中,询问区间[i, j]上的最小(最大)值。

    RMQ向LCA的转化:

    考察一个长度为N的序列A,按照如下方法将其递归建立为一棵树:

    1)设序列中最小值为Ak,建立优先级为Ak的根节点Tk;

    2)将A(1...k-1)递归建树作为Tk的左子树;

    3)将A(k+1...N)递归建树作为Tk的右子树;

    如序列A=(7, 5, 8, 1, 10)建树的结果为:

   

    对于RMQ(A, i, j):

    1)设序列中的最小值为Ak,若i<=k<=j,那么答案为k;

    2)若k>j,那么答案为RMQ(A1...k-1, i, j);

    3)若k

    不难发现RMQ(A, i, j)=LCA(T, i, j),这就证明了RMQ问题可以转化为LCA问题。

    LCA向RMQ问题的转化:

    对有根树T进行DFS,将遍历到的节点按顺序记录下来,我们将得到一个长度为2N-1的序列,称之为T的欧拉序列F。每个节点都在欧拉序列中出现,我们记录节点u在欧拉序列中第一次出现的位置为pos(u)。如下树:


    根据DFS的性质,对于两个节点u,v,从pos(u)遍历到pos(v)的过程中经过LCA(u, v)有且仅有一次,且深度是深度序列B[pos(u)...pos(v)]中最小的,即LCA(T, u, v)=RMQ(F, pos(u), pos(v)),并且问题的规模仍然是O(N)的。这就证明了LCA问题可以转化为RMQ问题。


RMQ的ST算法

    RMQ问题:

    RMQ问题是求给定区间中的最值问题,如下图所示:

   

    假设一个算法的预处理时间为f(n),查询时间为g(n),则这个算法的时间复杂度标记为:

    当然,最简单的算法是O(n)的,但是对于查询次数很多m(假设有100万次),则这个算法的时间复杂度为O(mn),显然时间效率太低。可以用线段树将查询算法优化到O(logn),而线段树的预处理时间复杂度为O(n),线段树整体复杂度为,这个还没有研究。不过,Sparse_Table算法(简称ST算法)才是最好的:它可以在O(nlogn)的预处理以后实现O(1)的查询效率,即整体时间复杂度为。ST(Sparse Table)算法的基本思想是,预先计算从起点A[i]开始长度为2的j次方
(j=0,1...logn)的区间的最小值,然后在查询时将任何一个区间A[i..j]划分为两个预处理好的可能重叠的区间,取这两个重叠区间的最小值。下面把ST算法分为预处理和查询两部分。

    1.预处理

    预处理使用DP思想,用f(i, j)表示[i, i+2^j-1]区间中的最小值,即f(i, j)表示从第i个数起连续2^j个数中的最小值(例如,f(1, 0)表示[1, 1]中的最小值,就是num[1]),而任意一个长度为2^j的区间都可以划分为两个长度为2^(j-1)的区间,其中第一个区间的范围为:i...i+2^(j-1)-1,第二个区间范围为:i+2^(j-1)...i+2^j-1。所以f(i, j)可以由f(i, j-1)和f(i+2^(j-1), j-1)导出,而递推的初值(所有的f(i, 0)=num[i])都是已知的,所以我们可以采用自底向上的方法递推地给出所有符合条件的f(i, j)的值。ST的状态转移方程为:

    

    2.查询

    假设要查询从m到n这一段的最小值,m到n的区间长度为n-m+1,求出一个最大的k,使得k满足2^k<=(n-m+1),那么这个区间[m, n]可以被两个部分重叠的长度为2^k的区间完全覆盖,这两个区间为[m, m+2^k-1]和[n-2^k+1, n]。而我们之前已经求出了f(m, k)为[m, m+2^k-1]的最小值,f(n-2^k+1, k)为[n-2^k+1, n]的最小值。我们只要返回其中更小的那个,就是答案,算法时间复杂度为O(1)。


    参考了一篇博客:RMQ问题的ST算法

    ST算法在数组上的实现如下:

// Using ST(Sparse Table) to solve RMQ Problem
// M[i][j] stands for the subscript of the minimum number in the array range A[i,i+2^j-1]
 
#include 
#include 
#define K 100

int M[K][K];
//  
int RMQinit(int a[], int len)
{
    int i, j;
    //int len=sizeof(a)/sizeof(int);  犯错误了 
    //printf("%d\n",len);
    
    int lenJ=sqrt(len);
    //int lenJ=log(len)/log(2);
    
    // Initilization
    for(i=0;i


LCA的在线算法程序

    根据上文中“LCA向RMQ问题的转化”的算法完成程序, 时间复杂度为O(nlogn)的预处理+O(1)的查询,n为问题规模:
// 二叉树LCA在线算法, 转化为RMQ问题
// c/c++是大小写敏感的, NULL=0, null只是一个符号 
   
#include 
#include 

#define MAX 10

using namespace std; 

int dfs[2*MAX];
int depth[2*MAX];
int pos[MAX];  // first apparence position
int M[2*MAX][2*MAX]; 
int len=0;  // denote the subscript of the array

struct Node
{
             int value;
             Node *left;
             Node *right;
             
             Node(int val, Node *l, Node *r)
             {
                         value=val;
                         left=l;
                         right=r;
             }
};

// Depth First Search
int DFS(Node *root, int d)
{
    if(root==NULL)
        return 0;
    dfs[len]=root->value;
    depth[len]=d;
    pos[root->value-1]=len;  //
    len++;
    
    //DFS(root->left, ++d);
    DFS(root->left, d+1);
    // left backtrack 
    if(root->left!=NULL)
    {
                       dfs[len]=root->value;
                       depth[len]=d;
                       len++;
    }
    //DFS(root->right, ++d);
    DFS(root->right, d+1);
    // right backtrack
    if(root->right!=NULL)
    {
                    dfs[len]=root->value;
                    depth[len]=d;
                    len++;
    }
    
    return 0;
}

// 
int RMQ()
{
    int i, j;
    int lenJ=(int)sqrt(len);
    
    for(i=0;ivalue-1], j=pos[b->value-1];
    int temp;
    if(i>j)
    {
           temp=j;
           i=j;
           j=temp;
    }
    int k=(int)sqrt(j-i+1);
    
    // pay attention to the relation
    int minpos=(depth[M[i][k]]value, b->value, nodeval);
    
    return 0;
}

int main()
{
    Node *n3 = new Node(3, NULL, NULL);
    Node *n5 = new Node(5, NULL, NULL);
    Node *n6 = new Node(6, NULL, NULL);
    Node *n8 = new Node(8, NULL, NULL);
    Node *n4 = new Node(4, n5, n6);
    Node *n7 = new Node(7, NULL, n8);
    Node *n2 = new Node(2, n3, n4);
    Node *n1 = new Node(1, n2, n7);
    
    DFS(n1, 0); // pass parameter depth
    RMQ();
    Query(n4, n8);
    
    return 0;
}
构造的二叉树为:
  
  
程序的运行结果为:


LCA的离线Tarjan算法说明及程序

    Tarjan算法是由Robert Tarjan在1979年发现的一种高效的离线算法,也就是说,它首先读入所有的询问(求一次LCA叫做一次询问),然后并不一定按照原来的顺序处理这些询问,而打乱这个顺序正是这个算法的巧妙之处,该算法的 时间复杂度为O(n+q),n为问题规模,q为询问次数。
    首先需要一些预备知识:1,基本图论。 2,并查集
    关于并查集的知识,我是在这篇文章中学到的: 并查集的初级应用及进阶
    并查集是一种处理元素之间等价关系的数据结构,一开始我们假设元素都是分别属于一个独立的集合,其主要支持两种操作:
    1)Find():判断元素所属的集合并返回代表节点。其中并查集的优化可以在Find()函数中实现,在寻找元素所属集合的过程中完成路径压缩。
    2)Union():合并两个集合。可以利用一个标识来代表两个集合中的节点数,这样判断后将元素少的集合合并到元素多的集合上可以达到一定的优化。
     Tarjan算法的基本思想是:
    Tarjan算法基于深度优先搜索的框架,对于新搜索到的一个结点,首先创建由这个结点构成的集合,再对当前结点的每一个子树进行搜索,每搜索完一棵子树,则可确定子树内的LCA询问都已解决。其他的LCA询问的结果必然在这个子树之外,这时把子树所形成的集合与当前结点的集合合并,并将当前结点设为这个集合的祖先。之后继续搜索下一棵子树,直到当前结点的所有子树搜索完。这时把当前结点也设为已被检查过的,同时可以处理有关当前结点的LCA询问,如果有一个从当前结点到结点v的询问,且v已被检查过,则由于进行的是深度优先搜索,当前结点与v的最近公共祖先一定还没有被检查,而这个最近公共祖先的包涵v的子树一定已经搜索过了,那么这个最近公共祖先一定是v所在集合的祖先。
    Tarjan算法的伪代码:    
    
     function   TarjanOLCA(u)
        MakeSet(u);
        u.ancestor = u;     //将集合u的祖先指向自己
        for each v  in u.children  do    //DFS所有孩子
            TarjanOLCA(v);
            Union(u, v);     //与根节点u合并
            Find(u).ancestor = u;     //将u所在的集合根的祖先指向u
        u.color = black;     //当所有孩子都已遍历,则标记根已完成
        for each v such that {u, v}  in do    //找到所有与u相关的查询
            if v.color == black;     //如果另一个节点v是前面标记过的,则输出递归向上返回根的祖先
                print Find(v).ancestor

     解释:
    ①Union(x, y)
    将集合 x 与集合 y 合并,使用 rank 值优化,将 rank 值较低的集合指向 rank 值较高的集合。每个节点的父节点(node.parent)和祖先节点(node.ancestor)是不同的:由于使用 rank启发式函数,使集合 A 和集合 B 在合并时,先考虑集合 A、B 的各自 rank 值,使较小rank 值的集合挂在较大 rank 值的集合下面。 当一个节点u 与它的子树 v合并时,u.rank


    ②Find(u).ancestor = u
    查找 u 的当前根结点,然后再将此结点祖先设为 u。这样就使得合并后集合中的所有结点 x,经过find(x),均能找到共同的根结点 v,通过 v.ancestor值求得它的祖先值。如图 2、图 3 中祖先 ancestor均是 u(根) ,但合并后的根节点都是v 了。 
    ③rank:优化合并集合的操作
    资料中说rank是树的层数,利用rank值可以降低树的高度,我在下面实现的程序里的rank是代表集合中的元素个数, 其中主要的优化效果都是通过Find()函数来实现的,比如对单链退化树的压缩,主要是在Union()函数中开始用到的Find()函数优化的。这样将rank值小的集合合并到rank值大的集合中,可以有效地降低树的高度,从而在Find()操作时能够快速到达树根,求得它的祖先值。如果不使用rank优化,对于下图的数据有可能出现单链的情况,这使得Find()操作的效率大大降低。

    
    对于Tarjan离线算法的一个最佳程序实践是 POJ1330
    参考了文章: 最近公共祖先LCA:Tarjan算法,实现了一个程序,其中用到了C++中的vector和其内置的size()函数,使得程序的动态性大大增加,非常的巧妙。

// POJ 1330 
// Use Tarjan to solve LCA problem

#include 
#include 

#define MAX 100

using namespace std;

int parent[MAX];
int rank[MAX];
int ancestor[MAX];
int indegree[MAX];
int visit[MAX];   // record 
vector Tree[MAX], Ques[MAX];

// 加入初始化为多次运行清理环境 
int Init(int n)
{
    int i;
    for(i=1;i<=n;i++)
    {
                     parent[i]=0;
                     rank[i]=0;
                     ancestor[i]=0;
                     indegree[i]=0;
                     visit[i]=0;
                     Tree[i].clear();
                     Ques[i].clear();
    }
    
    return 0;
} 

int MakeSet(int i)
{
    parent[i]=i;
    rank[i]=1;
    //ancestor[i]=i;
    
    return 0;
}

int Find(int i)
{
    if(parent[i]==i)
        return i;
    else
        parent[i]=Find(parent[i]);    
    return parent[i];
}

int Union(int x, int y)
{
    // 寻找集合的根
    int a=Find(x);
    int b=Find(y); 
    
    if(a==b)
        return 0;
    else if(rank[a]>rank[b])
    {
                       parent[b]=a;
                       rank[a]+=rank[b];
    }
    else
    {
         parent[a]=b;
         rank[b]+=rank[a];
    }
    /*
    else if(a!=b)
    {
         parent[b]=a;
         rank[a]+=1;
    }
    */
       
    return 0;
}


int LCA(int u)
{
    MakeSet(u);
    ancestor[u]=u;
    
    int size=Tree[u].size(); //
    for(int i=0;i>cnt;
    while(cnt--)
    {
                int s, t, i;
                cin>>n;
                //for(int i=0;i>s>>t;
                        Tree[s].push_back(t);
                        indegree[t]++;
                }

                cin>>s>>t;
                Ques[s].push_back(t);
                Ques[t].push_back(s); // 离线算法,两次询问
                
                //for(int i=0;i
程序的运行效果如下:


参考资料:《RMQ和LCA讲稿》及网络上的各种资料文章

你可能感兴趣的:(基础知识)