前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >《算法竞赛进阶指南》0x21 树与图的遍历

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

作者头像
一只野生彩色铅笔
发布2022-10-31 11:05:12
5810
发布2022-10-31 11:05:12
举报
文章被收录于专栏:彩铅的随笔博客

本章节开始的所有图和树,如果没有额外声明,都是采用邻接表存储的,点的下标为

1 \sim n

,无向边存储以两条有向边等价存储

树与图的深度优先遍历

树的深度优先遍历

深度优先遍历,就是在每个点

x

上面对多条分支时,任意选一条边走下去,执行递归,直到回溯到点

x

后,再考虑走向其他边

代码语言:javascript
复制
void dfs(int x)
{
    v[x] = 1;   //记录点 x 被访问过, v 是 visit 的缩写
    for (int i = h[x]; ~i; i = ne[i])
    {
        int y = e[i];
        if (v[y]) continue; //点 y 已经被访问过
        dfs(y);
    }
}

这段代码访问每个点和每条边

1

次,其时间复杂度为

O(N + M)

,其中

N

为点数,

M

为边数

以该段代码作为框架,我们可以统计许多关于树和图的信息

图的连通块划分

树的深度优先遍历,每从

x

开始一次遍历,就会访问

x

能够到达的所有点和边

因此通过多次深度优先遍历,可以划分出一张无向图中的各个连通分块

同理对一个森林进行深度优先遍历,可以划分出森林的每一棵树

代码语言:javascript
复制
void dfs(int x)
{
    v[x] = cnt;
    for (int i = h[x]; ~i; i = ne[i])
    {
        int y = e[i];
        if (v[y]) continue;
        dfs(y);
    }
}
// main 函数下
for (int i = 1; i <= n; i ++ )
{
    if (!v[i])
    {
        cnt ++ ;
        dfs(i);
    }
}

树的 DFS 序

时间戳

按照上述深度优先遍历的过程,以每个节点第一次被访问(v[x] 被赋值为 1 时)的顺序

依次给这

N

个点

1 \sim N

的整数标记,该标记就被称为 时间戳,记为

dfn

树的 DFS 序

一般来讲,对树进行深度优先遍历时,对于每个结点,在 刚进入递归后即将回溯前 各记录一次该点的编号

最后产生的长度为

2N

的结点序列就称为 树的 DFS 序

代码语言:javascript
复制
void dfs(int x)
{
    a[ ++ m] = x;   // a数组存储DFS序
    v[x] = 1;       // 记录点x被访问过
    for (int i = h[x]; ~i; i = ne[i])
    {
        int y = e[i];
        if (v[y]) continue;
        dfs(v[y]);
    }
    a[ ++ m] = x;
}

DFS 序 的特点是:每个节点

x

的编号在序列中恰好出现两次

设这两次出现的位置为

L[x]

R[x]

,则闭区间

[L[x], r[x]]

就是以

x

为根的子树的 DFS 序

这样做,使得我们在很多与树相关的问题中,可以通过 DFS 序把子树统计转化为序列上的区间统计

这也是树链剖分的基本思想:将树上路径问题,剖分为多个线段来维护,不过树剖的 dfs 序是要求是重轻儿子 dfs 序

此外,二叉树的先序、中序和后序遍历,也是通过深度优先遍历产生的,由于很简单,就不具体展开了

树的深度

树中各个结点的深度是一种 自顶向下 的统计信息,起初已知根节点的深度为 0

若结点

x

的深度为

d[x]

,则他的子节点

y

的深度为

d[y] = d[x] + 1

在深度优先遍历的过程中,结合自顶向下的递推,就可以求出每个结点的深度

d

请读者列举还有哪些信息一般是 自顶向下 进行统计的

代码语言:javascript
复制
void dfs(int x)
{
    v[x] = 1;
    for (int i = h[x]; ~i; i = ne[i])
    {
        int y = e[x];
        if (v[y]) continue;
        d[y] = d[x] + 1;    // 总父节点 x 到子节点 y 递推,计算深度
        dfs(y);
    }
}

树的重心

也有许多信息是 自底向上 进行统计的,比如以每个结点

x

为根的子树大小

size[x]

对于叶子结点,我们已知 “以它为根的子树” 大小为

1

若结点

x

k

个子节点

y_1, \cdots, y_k

,并且以

y_1, \cdots, y_k

为根的子树大小分别是

size[y_1], \cdots, size[y_k]

则以

x

为根节点的子树大小就是

size[x] = size[y_1] + \cdots + size[y_k] + 1

请读者列举还有哪些信息一般是 自底向上 进行统计的

对于一个结点

x

,如果我们把它从树中删除,那么原来的一棵树可能分成若干个不相连的部分,其中每一部分都是一棵子树

max\_part(x)

表示在删除结点

x

后产生的子树中,最大的一棵的大小

使

max\_part

函数取到最小值的结点

p

就称为整棵树的 重心

通过如下代码,可以求出树的重心:

代码语言:javascript
复制
void dfs(int x)
{
    v[x] = size[x] = 1; // 子树 x 的大小
    int max_part = 0;   //删掉 x 后分成的最大子树的大小
    for (int i = h[x]; ~i; i = ne[i])
    {
        int y = e[i];
        if (v[y]) continue;
        dfs(v[y]);
        size[x] += size[y]; // 子节点向根节点递推
        max_part = max(max_part, size[y]);
    }
    max_part = max(max_part, n - size[x]);  // n为整棵树的结点数目
    if (max_part < ans)
    {
        ans = max_part; // 全局变量 ans 记录重心对应的 max_part
        pos = x;        // 全局变量 pos 记录了重心
    }
}

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

树与图的广度优先遍历需要使用一个队列来实现,起初队列中仅包含一个起点

在广度优先遍历中,不断从队头取出一个结点

x

,对于

x

面对多条分支,把沿着每条分支到达的下一个结点(如果未被访问过)插入队尾

重复执行上述过程,直到队列为空

代码语言:javascript
复制
void bfs()
{
    memset(d, 0, sizeof(d));
    queue<int> q;
    q.push(1); d[1] = 1;
    while (q.size())
    {
        int x = q.front(); q.pop();
        for (int i = h[x]; ~i; i = ne[i])
        {
            int y = e[i];
            if (d[y]) continue;
            d[y] = d[x] + 1;
            q.push(y);
        }
    }
}

其中,

d[x]

为点

x

在树中的深度,也可以在图中的层次(从起点

1

走到点

x

需要经过的点数)

广度优先遍历是一种按照层次顺序进行访问的,它具有如下两个重要性质:

  1. 在访问完所有的第
i

层结点后,才会开始访问第

i+1

层结点

  1. 任意时刻,队列中至多有两个层次的结点,即 广度优先遍历队列中的元素,关于层次满足 “二段性” 和 “单调性”

该性质会在后续章节 0x26 “广搜变形” 中再次提及并探讨

广度优先遍历的时间复杂度和深度优先遍历一样,为

O(N + M)

拓扑排序

给定一个有向无环图DAG,若一个图中所有点构成的序列

A

满足:对于图中的每条边

(x,y)

x

A

中都出现在

y

之前,则称

A

是该有向无环图顶点的一个 拓扑序,求解序列

A

的过程就称为 拓扑排序

拓扑排序的思想:不断选择图中入度为

0

的结点

x

,然后把

x

连向的点的入度减

1

于是可以结合广度优先遍历的框架来高效的实现这个过程:

  1. 建立空的拓扑序列
A
  1. 预处理出所有点的入度
deg[i]

,起初把所有入度为

0

的点入队

  1. 取出队头结点
x

,把

x

加入拓扑序列

A

的末尾

  1. 对于从
x

出发的每条边

(x,y)

,把

deg[y]

1

,若被减为

0

,则把

y

入队

  1. 重复第 3 ~ 4 步,直到队列为空,此时
A

即为所求

拓扑排序可以检测 “一个有向图是否有环”:对任意有向图执行上述拓扑排序,完成后检查

A

序列的长度,若长度小于图中点的数量,则说明某些节点未被遍历,进而说明图中存在环

代码语言:javascript
复制
void add(int x, int y)
{
    e[idx] = b, ne[idx] = h[a], h[a] = idx ++ ;
    deg[y] ++ ;
}
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 = h[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 <= n; i ++ )
    {
        int x, y;
        cin >> x >> y;
        add(x, y);
    }
    topsort();
    for (int i = 1; i <= n; i ++ )
        cout << a[i] << " ";
    return 0;
}

习题

可达性统计

题目描述

给定一张

N

个点

M

条边的有向无环图,分别统计从每个点出发能够到达的点的数量。

输入格式

第一行两个整数

N,M

,接下来

M

行每行两个整数

x,y

,表示从

x

y

的一条有向边。

输出格式

输出共

N

行,表示每个点能够到达的点的数量。

数据范围

1≤N,M≤30000

输入样例

代码语言:javascript
复制
10 10
3 8
2 3
2 5
5 9
5 9
2 3
3 9
4 8
2 10
4 9

输出样例

代码语言:javascript
复制
1
6
3
3
2
1
1
1
1
1

解析

如果这是一棵树,则每个结点有且仅有一个前驱父节点,因此对于一个结点的两棵子树,他们的子节点不可能有交集。于是可以直接用

f(x)

表示当前子树中的节点个数,进行统计。 但是本题研究对象是有向无环图,可能存在某个结点的前驱有多种情况,于是上述方法不能使用,因此考虑用状态来表示结点可达的集合

设从点

x

能到达的点构成的结合为

f(x)

,则有递推式:

[ f(x) = \{ x \} \cup \bigg({ \bigcup_{\exists (x,y)} f(y) }\bigg) ]

x

出发能够到达的结点,是从

x

的所有后继结点

y

出发能够到达的点的并集,再加上

x

自身

所有在计算所有后继结点的

f

值之后,就可以计算出该点的

f

这启发我们用拓扑排序算法求出一个拓扑序,然后按照拓扑序的逆序进行计算(因为在拓扑序中,对任意一条边

(x,y)

x

都排在

y

之前)

关于状态存储,由于至多有

30000

个点要存储,即

30000

个二进制位,而一个 unsigned int

32

因此一个点的状态需要

\dfrac{N}{32} - 1

unsigned int 来存储,即设计数组 f[N][N / 32 - 1] 来存储状态

为了方便统计,考虑用 STL 的 bitset 来实现这

30000

个二进制位的存储

代码语言:javascript
复制
void add(int a, int b)
{
    e[idx] = b, ne[idx] = h[a], h[a] = idx ++ ;
    deg[b] ++ ;
}
void topsort()
{
    int hh = 1, tt = 0;
    for (int i = 1; i <= n; i ++ )
        if (!deg[i])
            q[ ++ tt] = i;
    while (hh <= tt)
    {
        int x = q[hh ++ ];
        for (int i = h[x]; ~i; i = ne[i])
        {
            int y = e[i];
            if ( -- deg[y] == 0)
                q[ ++ tt] = y;
        }
    }
}
int main()
{
    memset(h, -1, sizeof(h));
    scanf("%d%d", &n, &m);
    while (m -- )
    {
        int a, b;
        scanf("%d%d", &a, &b);
        add(a, b);
    }
    topsort();
    for (int i = n; i; i -- )
    {
        int x = q[i];
        f[x][x] = 1;
        for (int i = h[x]; ~i; i = ne[i])
        {
            int y = e[i];
            f[x] |= f[y];
        }
    }
    for (int i = 1; i <= n; i ++ ) printf("%d\n", f[i].count());
    return 0;
}
本文参与 腾讯云自媒体同步曝光计划,分享自作者个人站点/博客。
原始发表:2022-02-10,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 作者个人站点/博客 前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 树与图的深度优先遍历
  • 树的 DFS 序
  • 树的深度
  • 树的重心
  • 树与图的广度优先遍历,拓扑排序
  • 习题
    • 可达性统计
      • 题目描述
      • 解析
相关产品与服务
对象存储
对象存储(Cloud Object Storage,COS)是由腾讯云推出的无目录层次结构、无数据格式限制,可容纳海量数据且支持 HTTP/HTTPS 协议访问的分布式存储服务。腾讯云 COS 的存储桶空间无容量上限,无需分区管理,适用于 CDN 数据分发、数据万象处理或大数据计算与分析的数据湖等多种场景。
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档