树上问题

本文深入探讨树的基本概念、存储方式及算法应用,包括树的遍历、无根树与有根树的区别,以及树的直径、中心、重心等特性。详细讲解了树上动态规划、背包问题、LCA查询、链剖分等高级算法,为读者提供全面的树结构编程指南。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

树的基本知识

树的存储

就按图的方式存储,因为树也是一种图啊~

无根树

如果你脑海里对于无根树的印象,只有“长得像树的东西”这一条,你就完蛋了…… \qquad ——于纪平

有根树

有一个节点为根,除根以外,每个节点有一个父节点。所有节点都能通过父亲的关系走到根,父节点不会形成环。

树的算法

因为树就是图,所以图的算法在树上都可以用。
无根树 ⟺ \Longleftrightarrow 无向图
有根树 ⟺ \Longleftrightarrow 有向图 (父亲指向孩子或者孩子指向父亲)


树的遍历

树的 D F S DFS DFS

从根开始 D F S DFS DFS ,根据访问的顺序可以得到一个 D F S DFS DFS 序列。
优点:一个子树在序列中对应着一个区间。可以将子树相关的题目转为序列上的区间问题,进而用线段树等方法解决。

void dfs2(int u, int tp) {
    top[u] = tp; tid[u] = ++tim; rks[tim] = u;
    if(son[u] == -1) return;
    dfs2(son[u], tp);
    for(int i = head[u]; ~i; i = edg[i].nxt) {
        int v = edg[i].to;
        if(v == son[u] || v == father[u]) continue;
        dfs2(v, v);
    }
}

无根树转有根树

选取一个点作为根:

  • 一般情况下可以任取一个点作为,例如 1 1 1 号点。
  • 但有些题目可能选取某些特殊的点更加方便

从根开始 D F S \ B F S DFS \backslash BFS DFS\BFS ,即可得出其他所有点的父亲。

有根树转无根树

把它变成一个图。 \qquad ——孙万华

树上的路径长度

假设边上带权。

  1. 求某点到其他点的距离: D F S \ B F S DFS \backslash BFS DFS\BFS 均正确。 O ( n ) O(n) O(n)
  2. 求所有点对间的距离:对于每一个点做一遍上面的操作。 O ( n 2 ) O(n^2) O(n2)
  3. 求两点间的距离:下面会提到 Q A Q QAQ QAQ

树的直径

定义:最远的两个点的路径。
求法:任取一个点 A A A O ( n ) O(n) O(n) 求出距离A最远的某个点 B B B O ( n ) O(n) O(n) 求出距离 B B B 最远的某个点 C C C 。则 B B B C C C 之间的路径是一条直径。
直径可能不唯一,但是直径的长度一定唯一。

但是,当树上的边权为负值时,这个方法就是错误的。这时,我们就需要通过树形 d p dp dp 来求树的直径。


上图就是当边权为负值时的一个典型例子。我们通过树的直径的定义可知:直径是一棵树上边权值最大的那条路径。所以这棵树的直径应该是 6 → 7 6 \rightarrow 7 67 。而当我们按照两次 B F S BFS BFS 跑出来以后,我们发现第一次先跑到 4 4 4 号点,第二次时,无论是跑到 6 6 6 号点还是 7 7 7 号点,这条路径都不是这棵树的直径。所以这个算法是有局限性的。
d p dp dp 做法:
我们不妨设 1 1 1 号节点为根,“ N N N 个点 N − 1 N - 1 N1 条边的无向图”就可以看作“有根树”。
D [ x ] D[x] D[x] 表示从节点 x x x 出发走向以 x x x 为根的子树,能够到达最远节点的距离。设 x x x 的子节点为 y 1 , y 2 , y 3 . . . . . . y t y_1, y_2, y_3 ...... y_t y1,y2,y3......yt e d g e ( x , y t ) edge(x, y_t) edge(x,yt) 表示边权,显然有: D [ x ] = m a x 1 ≤ i ≤ t { D [ y i ] + e d g e ( x , y i ) } D[x] = max_{1 ≤ i ≤ t} \left\{ D[y_i] + edge(x, y_i) \right\} D[x]=max1it{D[yi]+edge(x,yi)} 接下来,我们就可以考虑对于每个节点 x x x 求出“经过节点 x x x 的最长链的长度”—— F [ x ] F[x] F[x],那整棵树的直径就是 m a x 1 ≤ i ≤ t F [ x ] max_{1 ≤ i ≤ t}{F[x]} max1itF[x]
那我们如何来求出 F [ x ] F[x] F[x] 呢?
对于 x x x 的任意连个节点 y i y_i yi y j y_j yj ,“经过节点 x x x 的最长链的长度” 可以通过四个部分构成:从 y i y_i yi y i y_i yi 子树中最远的距离,边 e d g e ( y i , x ) edge(y_i, x) edge(yix) ,边 e d g e ( x , y j ) edge(x, y_j) edge(x,yj) ,和从 y j y_j yj y j y_j yj 子树中最远的距离。不妨设 j &lt; i j &lt; i j<i ,因此: F [ x ] = m a x 1 ≤ j ≤ i ≤ t { D [ y i ] + D [ y j ] + e d g e ( y i , x ) + e d g e ( x , y j ) } F[x] = max_{1 ≤ j ≤ i ≤ t} \left\{ D[y_i] + D[y_j] + edge(y_i, x) + edge(x, y_j) \right\} F[x]=max1jit{D[yi]+D[yj]+edge(yi,x)+edge(x,yj)} 但是我们没有必要使用两层循环来枚举 i , j i, j i,j 。在子节点的循环将要枚举到 i i i 时, D [ x ] D[x] D[x] 恰好就保存了从节点 x x x 出发走向“以 y i ( j &lt; i ) y_i (j &lt; i) yi(j<i) 为根的子树”,能够到达的最远节点的距离,这个距离就是 m a x 1 ≤ j &lt; i { D [ y j ] + e d g e ( x , y j ) } max_{1 ≤ j &lt; i} \left\{ D[y_j] + edge(x, y_j) \right\} max1j<i{D[yj]+edge(x,yj)} 。所以,此时我们先用 D [ x ] + D [ y i ] + e d g e ( x , y i ) D[x] + D[y_i] + edge(x, y_i) D[x]+D[yi]+edge(x,yi) 更新 F [ x ] F[x] F[x] ,再用 D [ y i ] + e d g e ( x , y i ) D[y_i] + edge(x, y_i) D[yi]+edge(x,yi) 更新 D [ x ] D[x] D[x]

struct E {
	int nxt, to, val;
}e[maxn<<1];

int head[maxn];

void add(int u, int v, int w) {
	static int cnt = 0;
	e[++cnt] = (E) {head[u], v, w};
	head[u] = cnt;
}

void dp(int x) {
	v[x] = 1;
	for(int i = head[x]; ~i; i = e[i].nxt) {
		int v = e[i].to;
		if(v[v]) continue;
		dp(v);
		ans = max(ans, d[x] + d[y] + e[i].val);
		d[x] = max(d[x], d[y] + e[i].val);
	}
}

有兴趣的同学可以做一下APIO2010 巡逻

树的中心

树的中点就是直径的中点。可能在节点上,也可能在边上。
求出直径就能求出中心了,但是要注意边权不为 1 1 1 的情况
中心是唯一的

求树的重心

查询 K K K 级祖先

每次给出点 x x x k k k ,问 x x x 向根走 k k k 条边是哪个点。

暴力

复杂度: O ( h ) O(h) O(h)

倍增

复杂度: O ( n l o g n ) O(nlogn) O(nlogn)

O ( 1 ) O(1) O(1)询问做法

类似分块的思想。
S = n S = \sqrt{n} S=n ,预处理每个点的 1 , 2 , 3...... S − 1 , S , 2 S , 3 S . . . . . . 1, 2, 3 ...... S - 1, S, 2S, 3S ...... 1,2,3......S1,S,2S,3S...... 级的祖先。(这一步的时间复杂度 O ( n 1.5 ) O(n^{1.5}) O(n1.5)
对于每次询问,通过两次查表得到答案。


树上动态规划

如果是无根树,先转为有根树。
需要按照自底向上或者自顶向下的顺序来处理
一般情况下在 D F S \ B F S DFS \backslash BFS DFS\BFS的时候顺便处理就可以了

树上背包 d p dp dp

emmmm…把背包放在树上做

多叉树变二叉树

Luogu1273 有线电视网

建模的方法
  1. 树的孩子兄弟表示法
    在这里插入图片描述
    学习树形结构时一定接触了一个多叉树变二叉树的方法,就是把每个点与它的第一个儿子连边,然后将它的儿子依次连接起来。可以结合下面的图片理解这句话。
    在这里插入图片描述
    S o : So: So: 第一个儿子是左子树,其他儿子是左子树的右子树。
  2. D F S DFS DFS 序表示法
    D F S DFS DFS 序就是对一个树进行一个 D F S DFS DFS ,然后对于每个点记录一个时间戳 D F N DFN DFN 数组,这个时间戳就是这个节点的 D F S DFS DFS 序,然后我们再记录一个 s i z e size size 数组,表示以每个节点为根的子树中节点的数量。
    假设根节点是 u u u ,那么可以容易的推出:第一个儿子的 D F S DFS DFS d f n [ f i r s t s o n ] dfn[first_{son}] dfn[firstson] 就是 d f n [ u ] + 1 dfn[u] + 1 dfn[u]+1 ,第二个儿子的 D F S DFS DFS d f n [ s e c o n d s o n ] dfn[second_{son}] dfn[secondson] 就是 d f n [ u ] + s i z e [ f i r s t s o n ] + 1 dfn[u] + size[first_{son}] + 1 dfn[u]+size[firstson]+1 。那么我们可以推出 u u u 的下一个兄弟的 D F S DFS DFS d f n [ n e x t b r o t h e r ] dfn[next_{brother}] dfn[nextbrother] 就是 d f n [ u ] + s i z e [ u ] + 1 dfn[u]+size[u]+1 dfn[u]+size[u]+1
    这两种方法大多用于树形 d p dp dp这两个方法可以使决策转移的过程变为 O ( 1 ) O(1) O(1)
常见模型

n n n 个物品,有一个 m m m 大小的包,每个物品有 w i w_i wi 物重与 v i v_i vi 物品价值,物品之间存在只有装了物品 a a a ,才能装物品 b b b n − 1 n - 1 n1 条关系(就是一个树)。问能取得的最大价值。
简要分析:显然是一个多叉树,那我们就要考虑转换。

  1. 树的孩子兄弟表示法
    对于一个节点 i i i ,设 d p [ i ] [ j ] dp[i][j] dp[i][j] 表示在以 i i i 为根的树中,用大小为 j j j 的包能取得的最大价值,那么 d p [ i ] [ j ] = m a x ( d p [ l e f t [ i ] ] [ j − w [ i ] ] + v [ i ] , d p [ r i g h t [ i ] ] [ j ] ) dp[i][j] = max(dp[left[i]][j-w[i]] + v[i], dp[right[i]][j]) dp[i][j]=max(dp[left[i]][jw[i]]+v[i],dp[right[i]][j])
    注意,这里的left[i]是i在原树中的第一个儿子,right[i]是i在原树中的下一个兄弟。
    复杂度:O(nm)
  2. D F S DFS DFS 序表示法
    对于 D F S DFS DFS 序为 i i i 的节点 u u u ,同样设 d p [ i ] [ j ] dp[i][j] dp[i][j] 表示在用大小为 j j j 的包能取得的最大价值,那么 d p [ i ] [ j ] + v [ i ] → d p [ i + 1 ] [ j − w [ i ] ] dp[i][j] + v[i] \rightarrow dp[i+1][j-w[i]] dp[i][j]+v[i]dp[i+1][jw[i]]

分组的树形背包

Luogu1272 重建道路

分析

下面针对这道题来分析,能够解决多叉树的,分组的树形背包。
此时,我们发现儿子与父亲之间并不存在依赖性关系。所以我们设 d p [ i ] ] [ j ] dp[i]][j] dp[i]][j] 表示第 i i i 个节点切掉 j j j 个节点所需要的最少的边数。那么我们就可以得到递推式 d p [ u ] [ j ] = min ⁡ ( d p [ u ] [ j ] , d p [ u ] [ j − k ] + d p [ v ] [ k ] ) ( u 当 前 节 点 , v 子 节 点 ) dp[u][j] = \min (dp[u][j], dp[u][j-k] + dp[v][k]) (u当前节点, v子节点) dp[u][j]=min(dp[u][j],dp[u][jk]+dp[v][k])(uv) 同时还要注意 d p dp dp 方程的边界问题: d p [ u ] [ s i z [ u ] ] = 1 dp[u][siz[u]]=1 dp[u][siz[u]]=1 将整个子树与切掉只需切掉与父节点的连边; d p [ u ] [ 0 ] = 0 dp[u][0]=0 dp[u][0]=0 不切掉任何边; d p [ 1 ] [ s i z [ 1 ] ] = 0 dp[1][siz[1]] = 0 dp[1][siz[1]]=0 根节点无法与父亲节点切开。

#include <iostream>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <algorithm>
using namespace std;
const int maxn = 237;
const int INF = 0x3f3f3f;

int read() {
    int x = 0, f = 1; char c = getchar();
    while(c < '0' || c > '9') {if(c == '-') f = -1; c = getchar();}
    while(c >= '0' && c <= '9') {x = x * 10 + c - '0'; c = getchar();}
    return f * x;
}

struct E {
    int nxt, to;
}e[maxn<<1];

int head[maxn];

void add(int u, int v) {
    static int cnt = 0;
    e[++cnt] = (E) {head[u], v};
    head[u] = cnt;
}

int n, p, k;
int siz[maxn], dp[maxn][maxn];

void dfs1(int u, int fa) {
    siz[u]++;
    for(int i = head[u]; ~i; i = e[i].nxt) {
        int v = e[i].to;
        if(v == fa) continue;
        dfs1(v, u);
        siz[u] += siz[v];
    }
    dp[u][siz[u]] = 1;
    dp[u][0] = 0;
}

int ans = 0x3f3f3f;

void dfs2(int u, int fa) {
    for(int i = head[u]; ~i; i = e[i].nxt) {
        int v = e[i].to;
        if(v == fa) continue;
        dfs2(v, u);
        for(int j = siz[u] - 1; j; j--) {
            for(int k = 0; k <= j; k++) {
                dp[u][j] = min(dp[u][j], dp[u][j-k] + dp[v][k]);
            }
        }
    }
    if(siz[u] - p >= 0) {
        ans = min(ans, dp[u][siz[u]-p] + dp[u][siz[u]]);
    }
}

int main() {
    memset(head, -1, sizeof(head));
    memset(dp, 0x3f3f3f, sizeof(dp));
    n = read(), p = read();
    for(int i = 1; i < n; i++) {
        int u = read(), v = read();
        add(u, v); add(v, u);
    }
    dfs1(1, 1);
    dp[1][siz[1]] = 0;
    dfs2(1, 1);
    printf("%d", ans);
    return 0;
}
/*
11 6
1 2
1 3
1 4
1 5
2 6
2 7
2 8
4 9
4 10
4 11
*/

有根树求两点间的 L C A LCA LCA

暴力

每次询问是 O ( h ) O(h) O(h)

向上标记法求 L C A LCA LCA

复杂度: O ( n ) O(n) O(n)(对于每次询问)
x x x 向上走到根节点,并标记所有经过的节点。
y y y 向上走到根节点,当第一次遇到已标记的节点时,就找到了 l c a ( x , y ) lca(x, y) lca(x,y)

倍增求 L C A LCA LCA

复杂度:O(nlogn)

#include <iostream>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <algorithm>
using namespace std;
const int maxn = 5e5 + 37;

int read() {
    int x = 0, f = 1; char c = getchar();
    while(c < '0' || c > '9') {if(c == '-') f = -1; c = getchar();}
    while(c >= '0' && c <= '9') {x = x * 10 + c - '0'; c = getchar();}
    return f * x;
}

struct E {
    int nxt, to;
}e[maxn<<1];

int head[maxn];

void add(int u, int v) {
    static int cnt = 0;
    e[++cnt] = (E) {head[u], v};
    head[u] = cnt;
}

int dep[maxn], fa[maxn][25], lg[maxn];

void dfs(int x, int father) {
    dep[x] = dep[father] + 1;
    fa[x][0] = father;
    for(int i = 1; (1 << i) <= dep[x]; i++)
        fa[x][i] = fa[fa[x][i-1]][i-1];
    for(int i = head[x]; ~i; i = e[i].nxt) 
        if(e[i].to != father) 
            dfs(e[i].to, x);
}
int lca(int x, int y) {
    if(dep[x] < dep[y]) swap(x, y);
    while(dep[x] > dep[y]) x = fa[x][lg[dep[x]-dep[y]]-1];
    if(x == y) return x;
    for(int k = lg[dep[x]]; k >= 0; k--) {
        if(fa[x][k] != fa[y][k]) {
            x = fa[x][k], y = fa[y][k];
        }
    }
    return fa[x][0];
}
int n, m, s;

void init() {
    for(int i = 1; i <= n; i++) 
        lg[i] = lg[i-1] + (1 << lg[i-1] == i);
}

int main() {
    memset(head, -1, sizeof(head));
    n = read(), m = read(), s = read();
    for(int i = 1; i < n; i++) {
        int u = read(), v = read();
        add(u, v); add(v, u);
    }
    dfs(s, 0);
    init();
    for(int i = 1; i <= m; i++) {
        int u = read(), v = read();
        printf("%d\n", lca(u, v));
    }
    return 0;
}

树链剖分求 L C A LCA LCA

#include <iostream>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <algorithm>
using namespace std;
const int maxn = 5e5 + 37;

int read() {
	int x = 0, f = 1; char c = getchar();
	while(c < '0' || c > '9') {if(c == '-') f = -1; c = getchar();}
	while(c >= '0' && c <= '9') {x = x * 10 + c - '0'; c = getchar();}
	return f * x;
}

struct E {
	int nxt, to;
}e[maxn<<1];

int head[maxn];

void add(int u, int v) {
	static int cnt = 0;
	e[++cnt] = (E) {head[u], v};
	head[u] = cnt;
}

int fa[maxn], dep[maxn], siz[maxn], son[maxn], top[maxn];

void dfs1(int x) {
	siz[x] = 1; dep[x] = dep[fa[x]] + 1;
	for(int i = head[x]; ~i; i = e[i].nxt) {
		int v = e[i].to;
		if(v == fa[x]) continue;
		fa[v] = x;
		dfs1(v);
		siz[x] += siz[v];
		if(!son[x] || siz[son[x]] < siz[v]) son[x] = v;
	}
}

void dfs2(int x, int tp) {
	top[x] = tp;
	if(son[x]) dfs2(son[x] , tp);
	for(int i = head[x]; ~i; i = e[i].nxt) {
		int v = e[i].to;
		if(v == fa[x] || v == son[x]) continue;
		dfs2(v, v);
	}
}

int lca(int x, int y) {
	while(top[x] != top[y]) {
		if(dep[top[x]] > dep[top[y]]) x = fa[top[x]];
		else y = fa[top[y]];
	}
	return dep[x] < dep[y] ? x : y;
}

int n, m, s;

int main() {
	memset(head, -1, sizeof(head));
	n = read(), m = read(), s = read();
	for(int i = 1; i < n; i++) {
		int u = read(), v = read();
		add(u, v); add(v, u);
	}
	dfs1(s);
	dfs2(s, s);
	for(int i = 1; i <= m; i++) {
		int x = read(), y = read();
		printf("%d\n", lca(x, y));
	}
	return 0;
}

T a r j a n Tarjan Tarjan L C A LCA LCA

树上两点间距离

暴力

复杂度: O ( n 2 ) O(n^2) O(n2)

L C A LCA LCA

复杂度:O(nlogn)
如果是无根树,先转有根树。预处理出 d [ x ] d[x] d[x] 表示从根到位置 x x x 的边的权值和,则从 i i i j j j 的距离为: d [ i ] + d [ j ] − 2 d [ l c a ( i , j ) ] d[i] + d[j] - 2d[lca(i, j)] d[i]+d[j]2d[lca(i,j)]
Q:那如果求 x x x y y y 的路径上,边权的最大值呢?
A:在 l c a lca lca 的倍增数组之外,额外预处理 f ( i , j ) f(i, j) f(i,j) 表示从 i i i 向根走 2 j 2^j 2j 条边时,路径上的最大值。
Q:如果求的是路径上的点权值的和呢?
A:同样,现将无根树转为有根树。预处理出 d [ x ] d[x] d[x] 表示从根到位置 x x x 的点的权值和,则从 i i i j j j 的距离为 : d [ i ] + d [ j ] − 2 d [ l c a ( i , j ) ] + v a l [ l c a ( i , j ) ] d[i] + d[j] - 2d[lca(i, j)] + val[lca(i, j)] d[i]+d[j]2d[lca(i,j)]+val[lca(i,j)]


有根树的链剖分

链剖分

定义一棵树的链剖分:从所有的边中选出一个子集,称为“实边”。如果这个集合满足:对于任何一个节点,它连向孩子的边至多有一条为实边(即在集合中);那么我们称这个集合为这棵树的一种链剖分。树的链剖分可能不唯一。不在集合中的边称为“虚边”。
Q:为什么叫链剖分?
A:形象地来看,假如删掉所有的虚边,仅保留实边,则树剩下了若干条不相交的链,且每个点恰好在一条链上。(链的长度可能为 1 1 1

树链剖分

一般来说,“轻重链剖分”、“重链剖分”都是指的这个。
剖分依据:
预处理 S ( x ) S(x) S(x) ,表示点 x x x 的子树中节点个数。
对于每个点,选取 S S S 最大的孩子,将这条边选为实边。
可以 O ( n ) O(n) O(n) 完成剖分

树链剖分的性质

任何一个点到跟的路径上,至多有 O ( l o g n ) O(logn) O(logn) 条轻边。这是因为,每经过一条轻边,子树大小(即 S S S 值)至少变为 2 2 2 倍。也就是说,任何一点到根的路径,可以分成 O ( l o g n ) O(logn) O(logn) 段,每一段都是某条重链的连续一段。
**推论:**推论:对于任意一个点 x x x ,和 x x x 的某个祖先 y y y ,从 x x x y y y 的路径可以分成 O ( l o g n ) O(logn) O(logn) 段,每一段都是某条重链的连续一段。
**结论:**对于任意两个点 x x x y y y ,从 x x x y y y 的路径可以分成 O ( l o g n ) O(logn) O(logn) 段,每一段都是某条重链的连续一段。

树链剖分序列

树链优先 D F S DFS DFS 序列

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值