美文网首页
图算法(一)遍历,拓扑排序

图算法(一)遍历,拓扑排序

作者: maxkibble | 来源:发表于2017-10-19 19:40 被阅读0次

    本文介绍图的几种基本操作:BFS,DFS,求有向图连通分量的Tarjan算法以及拓扑排序。

    图的表示

    一张图是由若干顶点和顶点之间的边组成的,可以形式化为G(V, E),V代表顶点集合,E代表边集,本文中为了书写方便,定义顶点数|V|=n,边数|E|=m。根据边是否有方向,可以分为有向图和无向图,本文讨论的内容都是有向图。下面是一个无向图的示例。

    graph.png

    在编码中,图的存储方式常见的有两种:邻接表和邻解矩阵,在C++中的写法是:

    // adjacency matrix
    const int MAXV = 1e5;
    int G1[MAXV][MAXV];
    
    //adjacency lists
    struct Edge1 {
        int dst, dis;
    };
    std::vector<std::vector<Edge1> > G2;
    

    这两种存储方法对应的基本操作时间复杂度如下:

    空间复杂度 查看u,v之间边的权值 遍历所有的边
    邻接表 O(m+n) O(degree(u)) O(m + n)
    邻接矩阵 O(n^2) O(1) O(n^2)

    一般来说,邻接表的方式使用的多一些,因为稠密图并不是很常见,在图的遍历上邻接表有优势。但也有特定场景邻接矩阵会更方便,如Floyd算法的实现。

    图的遍历(BFS & DFS)

    图算法中最基础的就是图的遍历了,基本方法有两种:广度优先搜索(BFS)和深度优先搜索(DFS)。为了方便描述,我们定义图上从顶点i到顶点j的一条简单路径为一系列的点i, k1, k2, ..., j,其中没有重复出现的顶点,连续出现的点之间有边相连,路径上点的个数减1代表路径的长度。

    BFS的大致思想就是先遍历和起点最短路径长度为0的点(起点本身),再遍历长度为1的点(从起点出发1步可达的点),再遍历长度为2的点....直到所有点都被访问过。时间复杂度O(m+n),下图是一个更形式化的描述:

    bfs.png

    在C++中,BFS常配合队列(queue)这一数据结构实现:

    std::vector<std::vector<Edge> > g;
    bool vis[MAXV];
    
    // implementation of breath-first search
    std::vector<int> bfs(int s) {
        std::vector<int> seq;
        std::queue<int> q;
        memset(vis, 0, sizeof(vis));  // initialize data
        q.push(s); vis[s] = true;   // insert starting node into queue
        while(!q.empty()) {
            int hd = q.front();
            q.pop();
            seq.push_back(hd);
            for(int i = 0; i < g[hd].size(); i++) {
                int next = g[hd][i];
                if(vis[next]) continue;
                q.push(next); vis[next] = true;
            }
        }
        return seq;
    }
    

    DFS的大致思想是从当前点出发,沿着一条简单路径走到没有点可以访问,再回溯到之前访问过的节点,沿着另一条简单路径走下去。直到所有点都被访问一遍,时间复杂度同样是O(m+n)。DFS通常通过递归实现。

    std::vector<std::vector<Edge> > g;
    std::vector<int> seq;   // store the visiting order
    bool vis[MAXV];
    
    // implementation of depth-first search
    void dfs(int s) {
        if(vis[s]) return;
        seq.push_back(s); vis[s] = true;
        for(int i = 0; i < g[s].size(); i++)
            dfs(g[s][i]);
    }
    

    回顾BFS/DFS的搜索过程,除了起点,每个点都是通过父节点指向它的一条边被引入的,如果把这个过程建图,那么这个图中一共有n个节点和n-1条边,且整个图是连通的(假设图中的边是无向的),满足这两条性质的无向图称之为,通过BFS/DFS得到的树被称为搜索树。

    最后补充一点,在状态数很多的搜索问题中,BFS被认为是完备的,即解如果存在,一定可以搜到,DFS则不是,可能需要和迭代加深这些策略配合。

    Tarjan算法

    在一次BFS或DFS中,我们其实并不能保证一定访问到图中的所有节点,因为有些图可能是不连通的。我们把从一个点出发,所有可达点的集合称为这个点所在的连通分量。给定一个无向图,我们找所有连通分量的方法叫做灌水法(Flood Fill),其实就是对当前未访问过的点做BFS/DFS,直到所有的点都被访问过1次。
    Tarjan算法是为了解决有向图中类似的问题提出的。只不过有向图中我们可以定义强连通分量,有向图中一个强连通分量中的任意两个点u,v都是强连通的,即存在从u到v的路径,也存在从v到u的路径。

    strong-connected.png

    很明显,Flood Fill并不能用来求强连通分量。但只使用BFS/DFS,我们可以给出一个求给定点所在强连通分量的方法:1) 从该点出发做一次BFS/DFS;2) 把所有边反向,再从这个点做一次BFS/DFS;3) 把两次搜索访问的顶点集合做一次交,就可以得到该点所在的极大强连通分量。如果用这种方法求所有强连通分量的话,需要对每个点做两次BFS/DFS,时间复杂度为O(n^2)。更好的方法是Kosaraju算法或Tarjan算法,这里只介绍Tarjan。

    Tarjan算法中,图中每个节点维护两个属性:

    1. dfu(i):节点i在DFS中第dfu(i)个被访问到(时间戳);
    2. low(i):DFS搜索树中,以节点i为根节点的子树中的节点集合记为T(i)。T(i)中的点在原图中所指向的点的集合记为S(i)。S(i)中最小的时间戳就是low(i),low(i)可以用下面的递归式表示:
      low(i) = min(dfn(i), dfn(j), low(j)) (j为i的子节点)

    Tarjan算法的描述如下:

    1. 初始化一个空的栈和一个每访问一个节点加1的计时器
    2. 对图做DFS,每次访问新的顶点时,设定dfn(i)为当前时间,把该节点压栈,接着求low(i):对于还没被访问的后继节点j,递归访问j,low(i) = min(dfn(i), low(j)),对于已经访问过的后继节点k,low(i) = min(low(i), dfn(j))。如果最终得到的low(i) = dfn(i),就把栈中当前节点以上的节点全部弹出,这些节点就是一个极大强连通分量

    Tarjan算法只需要做一遍DFS,所以一定会终止,时间复杂度O(m+n)

    C++实现:

    void tarjan(int u) {
        dfn[u] = low[u] = ++cnt;
        st[++top] = u;
        instack[u] = true;
    
        for(Edge *p = e[u]; p; p = p->next) {
            int v = p->dst;
            if(!dfn[v]) {
                tarjan(v);
                low[u] = std::min(low[u], low[v]);
            }
            else if(instack[v])
                low[u] = std::min(low[u], dfn[v]);
        }
        
        if(dfn[u] == low[u]) {
            cluster++;
            int hd;
            do {
                hd = st[top--];
                instack[hd] = false;
                belong[hd] = cluster;
            }
            while(hd != u);
        }
    }
    

    (PS:一道经典的求强连通分量的题 Networks of School

    拓扑排序

    在图论中,我们经常讨论有向无环图DAG(Directed Acyclic Graph),这类图常用来描述节点之间的依赖关系(先修课程、软件包的安装依赖)。对于DAG,我们可以对其进行拓扑排序。
    一个DAG的拓扑排序是图中所有顶点的一个排列:v1, v2, ..., vn,对于原图中每条边(vi, vj)都有i < j。下图就展示了一个拓扑排序的例子:

    topological.png

    拓扑排序可以通过一个很直观的策略求得:选取当前所有入度为0的点,把它们加入拓扑序列中,再把这些点的出边删去,反复这两个操作,直到所有的点都加入拓扑序列中。对应的C++实现如下:

    std::vector<int> topsort(const std::vector<std::vector<int> > &g) {
        int n = g.size();
        std::vector<int> d(n);
        for(int i = 0; i < n; i++)
            for(int j = 0; j < g[i].size(); j++)
                d[g[i][j]]++;
        
        std::queue<int> q;
        std::vector<int> seq;
        for(int i = 0; i < n; i++)
            if(!d[i]) q.push(i);
        while(!q.empty()) {
            int hd = q.front();
            q.pop();
            seq.push_back(hd);
            for(int i = 0; i < g[hd].size(); i++) {
                int dst = g[hd][i];
                d[dst]--;
                if(!d[dst]) q.push(dst);
            }
        }
        return seq;
    }
    

    正确性证明:
    结论1:算法一定会终结。队列中最多会压入n个顶点,每次循环都会取出第一个元素,因此最多循环n次,同时,每次循环内会访问当前队列第一个点的所有出边,这个点不会再加入队列,因此每条边最多被访问一次,算法总的复杂度O(m+n)。
    结论2:对于连通的DAG,算法一定会返回一个长度为n的序列。如果当前序列长度不足n,说明连通图中还有点没有被加入序列,而此时如果队列为空,则说明没有入度为0的点了,这在无环图是不可能的。这同时提醒我们,对于没有保证连通性的图,需要多次的拓扑排序已确保每个连通分量中的点都加入了序列。同时,如果考虑所有连通分量后,最终返回序列的长度小于n,那么说明原图中有环。
    结论3:对于返回的序列v1, v2, ..., vn,原图中不存在边(vi, vj)使得i > j。假如有这样一条边从vi指向vj,且在拓扑序列中vj在vi之前。算法中vj在被删除时,vi还没被删除,因此vj的入度不会为0,矛盾!

    (PS:一道不错的拓扑排序的题 All Discs Considered

    本文图片来自 Lecture Slides for Alogorithm Design by Jon Kleinberg and Éva Tardos.

    相关文章

      网友评论

          本文标题:图算法(一)遍历,拓扑排序

          本文链接:https://www.haomeiwen.com/subject/riuwyxtx.html