算法竞赛进阶指南 搜索 0x21 树与图的遍历

6 篇文章 0 订阅
2 篇文章 0 订阅
本文详细介绍了图与树的深度优先遍历(DFS)方法,包括邻接表存储、时间戳、DFS序、树的重心、图的连通块划分等概念。同时,探讨了DFS在拓扑排序、可达性统计等问题中的应用,并提供了相应的算法实现。此外,还讨论了广度优先遍历(BFS)及其在求解树的深度、层次、拓扑排序等方面的作用。
摘要由CSDN通过智能技术生成

树与图最常见的存储方式就是使用一个邻接表保存它们的边集。除非特殊声明,本书在接下来的各章节中默认,给定N个点的树或图时,其节点编号均为1~N,无向图中的边看作成对出现的双向边,树看作一张具有N - 1条边的无向图,它们的边都存储在一个邻接表中,邻接表以head数组为表头,使用ver和edge数组分别存储边的终点和权值,使用next数组模拟链表指针。

树与图的深度优先遍历,树的DFS序、深度和重心

深度优先遍历,就是在每个点x上面对多条分支时,任意选一条边走下去,执行递归,直至回溯到点x后,再考虑走向其他的边。
根据上面提到的存储方式,我们可以采用下面的代码,调用dfs(1),对一张图进行深度优先遍历。

void dfs(int x) {
    v[x] = 1;
    for (int i = head[x]; i; i = next[i]) {
        int y = ver[i];
        if (v[y]) continue;
        dfs(y);
    }
}

这段代码访问每个点和每条边恰好1次(如果是无向边,正反向各访问1次),其时间复杂度为 O ( N + M ) O(N+M) O(N+M),其中M为边数。

0、时间戳

按照上述深度优先遍历的过程,以每个节点第一次被访问v[x]被赋值为1时)的顺序,依次给予这N个节点1~N的整数标记,该标记就被称为时间戳,记为dfn
在这里插入图片描述
dfn[1] = 1, dfn[2] = 2, dfn[8] = 3, dfn[5] = 4, dfn[7] = 5, dfn[4] = 6, dfn[3] = 7, dfn[9] = 8, dfn[6] = 9

1、树的DFS序

一般来讲,我们在对树进行深度优先遍历时,对于每个节点,在刚进入递归后以及即将回溯前各记录一次该点的编号,最后产生的长度为2N的节点序列就称为树的DFS序

DFS序的特点是:每个节点x的编号在序列中恰好出现两次。设这两次出现的位置为L[x]与R[x],那么闭区间[L[x], R[x]]就是以x为根的子树的DFS序。这使我们在很多与树相关的问题中,可以通过DFS序把子树统计转化为序列上的区间统计。
在这里插入图片描述
另外,二叉树先序、中序与后序遍历序列,也是通过深度优先遍历产生的。

2、树的重心

当然,也有许多信息是自底向上进行统计的,比如以每个节点x为根的子树大小size[x]。对于叶子节点,我们已知“以它为根的子树”大小为1。若节点x有k个子节点 y 1 y_1 y1 ~ y k y_k yk为根的子树大小分别是size[y_1], size[y_2], … , size[y_k],则以x为根的子树的大小就是 size[x] = size[y_1] + size[y_2] + … + size[y_k] + 1。

对于一个节点x,如果我们把它从树中删除,那么原来的一棵树可能会分成若干个不相连的部分,其中每一部分都是一棵树。设max_part(x)表示在删除节点x后产生的子树中,最大的一棵的大小,使max_part函数取到最小值的节点p就称为整棵树的重心。(最大值最小)

void dfs(int x) {
    v[x] = 1;
    size[x] = 1;
    int max_part = 0;
    for (int i = head[x]; i; i = next[i]) {
        int y = ver[i];
        if (v[y]) continue;
        dfs(y);
        size[x] += size[y];
        max_part = max(max_part, size[y]);
    }
    max_part = max(max_part, n - size[x]);
    if (max_part < ans) {
        ans = max_part;
        pos = x;
    }
}

3、图的连通块划分

通过多次深度优先遍历,可以划分出一张无向图中的各个连通块。同理,对一个森林进行深度优先遍历,可以划分出森林中的每棵树。如下面的代码所示,cnt就是无向图包含的连通块的个数,v数组标记了每个点属于哪一个连通块。

void dfs(int x) {
    v[x] = cnt;
    for (int i = head[x]; i; i = next[i]) {
        int y = cver[i];
        if (v[y]) continue;
        dfs(y);
    }
}
for (int i = 1; i <= n; ++ i) {
    if (!v[i]) {
        cnt ++ ;
        dfs(i);
    }
}

树与图的广度优先遍历,拓扑排序

树与图的广度优先遍历需要使用一个队列来实现。起初,队列中仅包含一个起点(例如1号节点)。在广度优先遍历的过程中,我们不断从队头取出一个节点x,对于x面对的多条分支,把沿着每条分支到达的下一个节点(如果尚未访问过)插入队尾。重复执行上述过程直到队列为空。

我们可以采用下面的代码对一张图进行广度优先遍历

void bfs() {
    memset(d, 0, sizeof d);
    queue<int> q;
    q.push(1); d[1] = 1;
    while (q.size() > 0) {
        int x = q.front(); q.pop();
        for (int i = head[x]; i; i = next[i]) {
            int y = ver[i];
            if (d[y]) continue;
            d[y] = d[x] + 1;
            q.push(y);
        }
    }
}

在上面的代码中,我们在广度优先遍历的过程中顺便求出了一个d数组。对于一棵树来讲,d[x]就是点x在树中的深度。对于一张图来讲,d[x]被称为点x的层次(从起点1走到点x需要经过的最少点数)。从代码中我们可以发现,广度优先遍历是一种按照层次顺序进行访问的方法,它具有如下两个重要性质:
1、在访问完所有的第i层节点后,才会开始访问第i + 1层节点
2、任意时刻,队列中至多有两个层次的节点。若其中一部分节点属于第i层,则另一部分节点属于第i + 1层,并且所有第i层节点排在第i + 1层节点之前。也就是说,广度优先遍历队列中的元素关于层次满足“两段性”和“单调性”。

这两条性质是所有广度优先思想的基础。与深度优先遍历一样,上面这段代码的时间复杂度也 O ( N + M ) O(N+M) O(N+M)

1、拓扑排序

给定一张有向无环图(在有向图中,从一个节点出发,最终回到它自身的路径被称为“”。不存在环的有向图即为 有向无环图。),若一个由图中所有点构成的序列A满足:对于图中的每条边(x, y),x在A中都出现在y之前,则称A是该有向无环图顶点的一个拓扑序。求解序列A的过程就称为拓扑排序

拓扑排序的思想非常简单,我们只需要不断选择图中 入度(在有向图中,以节点x为终点的有向边的条数被称为x的入度;在无向图中,以x为端点的无向边的条数被称为x的为0的节点x,然后把x连向的点的入度减1.我们可以结合广度优先遍历的框架来高效地实现这个过程:
1、建立空的拓扑序列A
2、预处理出所有点的入度deg[i],起初把所有入度为0的点入队
3、取出队头节点x,把x加入拓扑序列A的末尾
4、对于从x出发的每条边(x, y),把deg[y]减1。若被减为0,则把y入队
5、重复第3~4步直到队列为空,此时A即为所求

拓扑排序可以判定有向图中是否存在环。我们可以对任意有向图执行上述过程,在完成后检查A序列的长度。若A序列的长度小于图中点的数量,则说明某些节点未被遍历,进而说明图中存在环。

void add(int a, int b) {
    e[idx] = b; ne[idx] = h[a]; h[a] = idx ++ ;
    deg[b] ++ ;
}
void topsort() {
    queue<int> q;
    for (int i = 1; i <= n; ++ i) {
        if (!deg[i]) {
            q.push(i);
        }
    }
    while (q.size()) {
        int x = q.front(); q.pop();
        a[ ++ cnt] = x;
        for (int i = head[x]; ~i; i = ne[i]) {
            int y = e[i];
            if ( -- deg[y] == 0) {
                q.push(y);
            }
        }
    }
}
int main() {
    cin >> n >> m;
    for (int i = 1; i <= m; ++ i) {
        int x, y;
        cin >> x >> y;
        add(x, y);
    }
    topsort();
    for (int i = 1; i <= cnt; ++ i) {
        cout << a[i] << ' ';
    }
}

2、AcWing 164. 可达性统计

题意 :

  • 给定一张 N 个点 M 条边的有向无环图,分别统计从每个点出发能够到达的点的数量。
  • 1≤N,M≤30000

思路 :

  • 设从点x出发能够到达的点构成的集合是f(x),显然有:
    在这里插入图片描述
  • 也就是说,从x出发能够到达的点,是从“x的各个后继节点y”出发能够到达的点的并集,再加上点x自身。所以,在计算出一个点的所有后继节点的f值之后,就可以计算出该点的f值。这启发我们先用拓扑排序算法求出一个拓扑序,然后按照拓扑序的倒序进行计算——因为在拓扑序中,对任意的一条边(x, y),x都排在y之前。
  • 回想起在第0x01节中学到的状态压缩方法,我们可以使用一个N位二进制数存储每个f(x),其中第i位是1表示x能到i,0表示不能到i。这样一来,对若干个集合求并,就相当于对若干个N位二进制数做“按位或”运算。最后,每个f(x)中1的个数就是从x出发能够到达的节点数量。
  • 这个N位二进制数可以压缩成 N / 32 + 1个无符号32位整数 unsigned int 进行存储,更简便的方法是直接使用C++ STL中为我们提供的bitset(参见第0x71节),bitset支持我们上述所需的所有运算。该拓扑排序+状态压缩算法花费的时间规模约为 N (N + M) / 32,所需空间大小约为 N^2 / 8 字节。
  • f[i].count()表示f[i]这个bitset中1的个数
#include <iostream>
#include <cstring>
#include <bitset>
#include <queue>
using namespace std;
const int N = 3e4 + 10;

int n, m;
int h[N], e[N], ne[N], idx;
int deg[N], seq[N], tot;
bitset<N> f[N];

void add(int a, int b) {
    e[idx] = b; ne[idx] = h[a]; h[a] = idx ++ ;
}
void topsort() {
    queue<int> que;
    for (int i = 1; i <= n; ++ i) {
        if (!deg[i]) {
            que.push(i);
        }
    }
    while (que.size()) {
        int t = que.front(); que.pop();
        seq[ ++ tot] = t;
        for (int i = h[t]; ~i; i = ne[i]) {
            int j = e[i];
            if ( -- deg[j] == 0) {
                que.push(j);
            }
        }
    }
}

int main() {
    memset(h, -1, sizeof h);
    scanf("%d%d", &n, &m);
    for (int i = 0, a, b; i < m; ++ i) {
        scanf("%d%d", &a, &b);
        add(a, b);
        ++ deg[b];
    }
    topsort();
    for (int i = n; i >= 1; -- i) {
        int j = seq[i];
        f[j][j] = 1;
        for (int k = h[j]; ~k; k = ne[k]) {
            f[j] |= f[e[k]];
        }
    }
    for (int i = 1; i <= n; ++ i) {
        printf("%d\n", f[i].count());
    }
}
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值