图论总述
本博客由fengqiao17整理,难度系数比较简单,如果觉得太简单可以跳过,如果喜欢可以给我一个赞!
参考了部分OI-wiki上的内容,在此表示感谢。
目前的源码站:
语雀
洛谷
CSDN
,不一定更新,洛谷上保持最新状态啥时候洛谷没有云剪贴板再说吧
写作不易,如要参考请附上来源哦!
更新记录
U p d a t e Update Update:2023.8.17-15.27 初稿于长沙市YZ机房。
U p d a t e Update Update:2023.8.17-15.28 修改了一个bug,感谢sb857的贡献。
U p d a t e Update Update:2023.8.17-16.05 增加了“数组存储”部分。
U p d a t e Update Update:2023.8.17-16.28 增加了图片解释,电脑卡爆了。
U p d a t e Update Update:2023.9.2-14.32 增加“树形DP”部分,来自于lzm机房。
U
p
d
a
t
e
Update
Update:2023.9.2-16.26 第二类树形背包写完了,机房电脑都这么垃圾吗
U
p
d
a
t
e
Update
Update:2023.9.10-11.24 修改了部分格式,果然win11很牛逼
U p d a t e Update Update:2023.9.24-9.04 加了点内容
U p d a t e Update Update:2023.11.19-9:49 写了点东西,格式有时间再改。
图和树的基础
图的定义
图由结点和边组成,结点用圆圈表示,边用线表示。
如下图,就是一张有五个节点、五条边的图:
图的一些特性
- 连通图:任意两点之间都有路径
- 简单图:不存在自环和重边
- 有向图:边是有方向的
- 无向图:边没有方向
图的存储
图的存储一共有两种方式,分别为邻接矩阵与邻接表。
(注:由于链式前向星太过于复杂,大家可以上网自行搜索学习)
邻接矩阵
我们定义一个数组 a i , j a_{i,j} ai,j 表示点 i i i 到点 j j j 的边的边权,如果 a i , j a_{i,j} ai,j 为 0 0 0 则表示两点之间没有边。
举例:
以上这个图的邻接矩阵为:
* | 0 | 1 | 2 | 3 | 4 |
---|---|---|---|---|---|
0 | 0 | 0 | 1 | 0 | 1 |
1 | 0 | 0 | 0 | 1 | 1 |
2 | 1 | 0 | 0 | 1 | 1 |
3 | 0 | 1 | 1 | 0 | 0 |
4 | 1 | 1 | 1 | 0 | 0 |
以下是一个用临街矩阵存储图的示例:
#include <bits/stdc++.h>
using namespace std;
const int N = 105;
int a[N][N]; // 邻接矩阵存图
int main() {
int n, m;
cin >> n >> m;
for (int i = 1; i <= m; i++) {
int x, y, w;
cin >> x >> y >> w;
a[x][y] = w; // 单向边只用存一次,双向边需要a[y][x] = w;
}
return 0;
}
邻接表
我们可以用一个 vector 记录一下每个点所连的点,这样就实现了邻接表。在存储时,有边权的边可以存一个结构体。
需要注意的是,如果两点之间有两条边,那么 vector 里会存两条边,所以需要去重(视题目要求而定)。
举例:
以上这个图的邻接表为:
G[0]:2 4
G[1]:3 4
G[2]:0 3 4
G[3]:1 2
G[4]:0 1 2
以下是邻接表的示例:
#include <bits/stdc++.h>
using namespace std;
const int N = 105;
struct node { // 结构体
int x, w; // x表示连接的点的编号,w表示边权
};
vector<node> G[N]; // 邻接表存图
int main() {
int n, m;
cin >> n >> m;
for (int i = 1; i <= m; i++) {
int x, y, w;
cin >> x >> y >> w;
G[x].push_back((node){y, w}); // 一条由x指向y且边权为w的边
// G[y].push_back((node){x , w}); //双向边
}
return 0;
}
树的定义
树是一种特殊的图,它满足以下性质:
- 每两个点之间有且仅有一条路径
- 没有环
以下是一棵树的示例:
树的存储
树有两种存储方式,一种是父亲表示法,另一种是孩子表示法。
数组存储
我们可以用一个一维数组来存储一棵树,比如说有一个节点 x x x,那么 x x x 的左儿子就是 2 × x 2 \times x 2×x ,右儿子就是 2 × x + 1 2 \times x + 1 2×x+1,好处是容易查找,坏处是不能存边权(实在想存也可以,只不过很麻烦),所以比较少用。
父亲表示法
由于树上的节点的父亲是唯一的,所以我们可以记录下每一个节点的父亲是谁,这样就可以通过孩子找到父亲,从而找到整棵树。
#include <bits/stdc++.h>
using namespace std;
const int N = 105;
int fa[N]; // 记录父亲的数组,fa[x]表示fa[x]是x的父亲
int main() {
int n;
cin >> n;
for (int i = 1; i < n; i++) {
int x, y;
cin >> x >> y;
fa[y] = x; // x是y的父亲
}
return 0;
}
孩子表示法
和父亲表示法差不多,这里就不赘述了。
图/树的遍历
有两种方法,深度优先搜索和广度优先搜索。这个……你不知道的话,可以出门左拐学习一下。
这里我们以一道例题来讲解:洛谷P5318【深基18.例3】查找文献
题目的意思其实就是给你一张有向图,要你把它深搜一遍再广搜一遍。
那么这题我们可以很轻松的用邻接表解决。需要注意的是,题目中要求如果有很多篇文章可以参阅,请先看编号较小的那篇,那么我们还需要给每一个 vector 按编号排序。
代码如下:
#include <bits/stdc++.h>
using namespace std;
const int N = 1e5 + 5;
vector<int> G[N];
bool vis[N];
void dfs(int cur) { // 深搜
if (vis[cur] == 1) {
return;
}
vis[cur] = 1;
cout << cur << " ";
for (int i = 0; i < G[cur].size(); i++) {
int tmp = G[cur][i];
dfs(tmp);
}
return;
}
void bfs() { // 广搜
queue<int> q;
q.push(1);
while (!q.empty()) {
int cur = q.front();
q.pop();
if (vis[cur] == 1) {
continue;
}
vis[cur] = 1;
cout << cur << " ";
for (int i = 0; i < G[cur].size(); i++) {
q.push(G[cur][i]);
}
}
return;
}
int main() {
int n, m;
cin >> n >> m;
for (int i = 1; i <= m; i++) {
int x, y;
cin >> x >> y;
G[x].push_back(y); // 邻接表存图
}
for (int i = 1; i <= n; i++) {
sort(G[i].begin(), G[i].end()); // 排序
}
dfs(1);
cout << endl;
memset(vis, 0, sizeof(vis)); // 数组不清空,爆零两行泪
bfs();
return 0;
}
自己可以多做做,不会的可以自行看题解。
特殊的树
二叉树
二叉树的定义
二叉树是树的一种,并且每个结点至多只有两棵子树。
二叉树的性质
- 满二叉树:若二叉树的高度为h,则其结点总数为 2 h − 1 2^h-1 2h−1。
- 完全二叉树:若二叉树的高度为h,除第h层外,其它各层(1至h-1)结点数都达到最大个数,第h层结点数为 [ 0 , 2 h − 1 − 1 ] [0,2^{h-1}-1] [0,2h−1−1]。
二叉树的遍历
二叉树的遍历是指从根结点出发,按照某种次序依次访问二叉树中所有结点,使得每个结点被访问一次且仅被访问一次。
二叉树的遍历方式有三种:先序遍历、中序遍历、后序遍历。
- 先序遍历:先访问根结点,然后遍历左子树,再遍历右子树。
- 中序遍历:先遍历左子树,然后访问根结点,再遍历右子树。
- 后序遍历:先遍历左子树,再遍历右子树,最后访问根结点。
代码实现用递归即可。
堆
堆是一种特殊的完全二叉树,其中任一结点的值均不大于(或不小于)其左右孩子的值。
堆分为大根堆和小根堆,大根堆要求根结点的值最大,小根堆要求根结点的值最小。
堆在程序实现中通常用c++中自带的 priority_queue 来实现。
一些基本的操作:
- pq.top():返回堆顶元素。
- pq.push(x):往堆中插入元素 x。
- pq.pop():删除堆顶元素。
- pq.empty():判断堆是否为空。
- pq.size():返回堆中元素的个数。
掌握这些基本操作即可。
手写堆模板(洛谷P3378):
#include <bits/stdc++.h>
using namespace std;
const int N = 1e6 + 5;
struct node {
int tree[N], len;
void push(int &x) { // 入堆操作
tree[++len] = x;
for (int i = len; i > 1 && tree[i] < tree[i / 2]; i >>= 1) {
swap(tree[i], tree[i / 2]); // 上浮该点
}
}
int top() { // 返回堆顶元素
return tree[1];
}
void pop() { // 出堆操作
tree[1] = tree[len--]; // 交换堆顶
for (int i = 1; (i << 1) <= len;) {
int j = i << 1;
if (j < len && tree[j] > tree[j + 1]) {
++j;
}
if (tree[i] < tree[j]) {
break;
} else {
swap(tree[i], tree[j]); // 下沉该点
}
i = j;
}
return;
}
};
node h;
int main() {
int n;
cin >> n;
while (n--) {
int op;
cin >> op;
if (op == 1) {
int v;
cin >> v;
h.push(v);
} else if (op == 2) {
cout << h.top() << '\n';
} else {
h.pop();
}
}
return 0;
}
并查集
并查集是一种树形的数据结构,用于处理一些不相交集合的合并及查询问题。
并查集的实现一般使用父亲表示法,即用一棵树表示一个集合,每个集合的根节点有且只有一个,每个节点指向其父亲。
并查集的常用操作有:
- 查找:查找某个元素所在的集合,并返回其根节点。
- 合并:将两个集合合并为一个集合。
并查集优化:
- 路径压缩:在查找操作中,将某个节点的父节点指向该节点,从而减少树的高度。
- 按秩合并:将树按照高度进行排序,高度小的节点指向高度大的节点。
并查集的实现:
#include <bits/stdc++.h>
using namespace std;
const int N = 1e4 + 5;
int fa[N];
int find(int x) { // 查找某个节点的根节点
return fa[x] == x ? x : fa[x] = find(fa[x]); // 路径压缩
}
void unionn(int x, int y) { // 合并两个集合
x = find(x), y = find(y), fa[y] = (x != y ? x : fa[y]);
// 这里并没有用到按秩合并,因为用处不大
}
int main() {
int n, q;
cin >> n >> q;
for (int i = 1; i <= n; i++) { // 初始化,将自己的父亲设为自己
fa[i] = i;
}
while (q--) {
int op;
cin >> op;
if (op == 1) {
int x, y;
cin >> x >> y;
unionn(x, y);
} else if (op == 2) {
int x;
cin >> x;
cout << find(x) << endl;
} else {
int cnt = 0;
for (int i = 1; i <= n; i++) {
// 如果一个点是根节点,那么他一定指向自己,又因为
// 每一个集合只有一个根节点,于是我们只需用统计这
// 种点的数量即可
if (fa[i] == i) {
cnt++;
}
}
cout << cnt << endl;
}
}
return 0;
}
最短路算法
最短路算法分为单源最短路和多源最短路,单源最短路就是求从一个起点到其他点的最短距离,多源最短路就是求从多个起点到其他点的最短距离。
Floyd
Floyd 算法是求多源最短路的一种算法,本质思想其实是个 dp,其时间复杂度为 O ( n 3 ) O(n^3) O(n3),空间复杂度为 O ( n 2 ) O(n^2) O(n2)。
算法实现(B3647 【模板】Floyd 算法):
#include <bits/stdc++.h>
using namespace std;
const int N = 105;
int a[N][N];
void floyd(int n) { // Floyd核心代码
for (int k = 1; k <= n; k++) {
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= n; j++) {
a[i][j] = min(a[i][j], a[i][k] + a[k][j]); // 状态转移方程
}
}
}
return;
}
int main() {
memset(a, 0x3f, sizeof(a)); // 初始化为极大值
int n, m;
cin >> n >> m;
for (int i = 1; i <= n; i++) {
a[i][i] = 0;
}
for (int i = 1; i <= m; i++) {
int x, y, w;
cin >> x >> y >> w;
a[x][y] = a[y][x] = min(a[x][y], w); // 双向边,取min保险防止重边
}
floyd(n);
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= n; j++) {
cout << a[i][j] << " ";
}
cout << endl;
}
return 0;
}
dijkstra
dijkstra 算法是求单源最短路的一种算法,其时间复杂度为 O ( n 2 ) O(n^2) O(n2),空间复杂度为 O ( n ) O(n) O(n)。
其主要思路是贪心,我们每次选取一个离源点最近的点,然后以这个点为中间点,更新其他点到源点的距离。
正是因为这样,他无法处理负权边,具体原因大家可以自己思考一下。
#include <bits/stdc++.h>
#define int long long
using namespace std;
const int N = 5e5 + 5;
struct node {
int x, dis;
bool friend operator<(const node x, const node y) {
return x.dis > y.dis;
}
};
struct E {
int id, w;
};
vector<E> G[N];
int dis[N];
bool vis[N];
int n, m, s;
void dijkstra(int s) {
memset(vis, 0, sizeof(vis));
priority_queue<node> pq; // 排序我们用堆代替
node cur = {s, 0};
pq.push(cur);
for (int i = 1; i <= n; i++) {
dis[i] = 2147483647;
}
dis[s] = 0;
while (!pq.empty()) {
int tmp = pq.top().x; // 每次取出离源点最近的点
pq.pop();
if (vis[tmp]) {
continue;
}
vis[tmp] = 1;
for (int i = 0; i < G[tmp].size(); i++) {
int nxt = G[tmp][i].id, w = G[tmp][i].w;
if (dis[tmp] + w < dis[nxt]) { // 更新其他点
dis[nxt] = dis[tmp] + w;
pq.push({nxt, dis[nxt]}); // 进入队列
}
}
}
}
signed main() {
cin >> n >> m >> s;
for (int i = 1; i <= m; i++) {
int x, y, w;
cin >> x >> y >> w;
G[x].push_back({y, w}); // 有向图
}
dijkstra(s);
for (int i = 1; i <= n; i++) {
cout << dis[i] << " ";
}
return 0;
}
当图为稠密图( m m m 为 n 2 n^2 n2 级别),此时暴力比优化要快。(笑
SPFA
SPFA 算法是求单源最短路径的队列优化版本,其时间复杂度为 O ( k m ) O(km) O(km),其中 k k k 为常数(最坏情况可以卡到 O ( n m ) O(nm) O(nm))。
#include <bits/stdc++.h>
using namespace std;
const int N = 5e5 + 5;
struct node {
int x, dis;
};
vector<node> G[N];
int dis[N];
bool vis[N];
int n, m, s;
void spfa(int s) {
priority_queue<int> pq;
for (int i = 1; i <= n; i++) {
dis[i] = 2147483647;
}
dis[s] = 0, vis[s] = 1;
pq.push(s); // 源点入队
while (!pq.empty()) {
int cur = pq.top(); // 出队
pq.pop();
vis[cur] = 0;
for (int i = 0; i < G[cur].size(); i++) {
int nxt = G[cur][i].x, w = G[cur][i].dis;
if (dis[nxt] > dis[cur] + w) { // 可以放缩
dis[nxt] = dis[cur] + w;
if (vis[nxt] == 0) { // 如果不在队列中
vis[nxt] = 1;
pq.push(nxt);
}
}
}
}
}
int main() {
cin >> n >> m >> s;
for (int i = 1; i <= m; i++) {
int x, y, w;
cin >> x >> y >> w;
G[x].push_back({y, w});
}
spfa(s);
for (int i = 1; i <= n; i++) {
cout << dis[i] << " ";
}
return 0;
}
三种最短路的对比:
* | Floyd | Dijkstra | Spfa |
---|---|---|---|
类型 | 多源 | 单源 | 单源 |
时间复杂度 | O ( n 3 ) O(n^3) O(n3) | O ( m log 2 m ) O(m \log_{2} m) O(mlog2m) | O ( k m ) O(km) O(km) |
处理负权图 | yes | no | yes |
本质 | dp | 贪心 | dp |
拓扑排序
拓扑排序是指,将一个有向无环图(Directed Acyclic Graph,简称 DAG)进行排序进而得到一个有序序列的过程。
拓扑排序的算法流程:
- 找到有向无环图的入度为 0 的点,将其放入一个队列中。
- 每次从队列中取出一个点,并将其输出。
- 更新该点的所有邻接点的入度,如果入度为 0,则放入队列中。
- 重复 2、3 两步,直到队列为空。
接下来是例题B3644 【模板】拓扑排序 / 家谱树
#include <bits/stdc++.h>
using namespace std;
const int N = 105;
vector<int> G[N];
int cnt[N];
int tot, ans[N];
void kahn(int n) {
queue<int> q;
for (int i = 1; i <= n; i++) {
if (cnt[i] == 0) {
q.push(i); // 一开始入度为0
}
}
while (!q.empty()) {
int u = q.front();
q.pop();
for (int i = 0; i < G[u].size(); i++) {
int v = G[u][i];
cnt[v]--; // 出度减一
if (cnt[v] == 0) { // 假如入度为0,入队
q.push(v);
}
}
ans[++tot] = u;
}
for (int i = 1; i <= tot; i++) {
cout << ans[i] << " ";
}
return;
}
int main() {
int n;
cin >> n;
for (int i = 1; i <= n; i++) {
int x;
cin >> x;
while (x != 0) {
cnt[x]++;
G[i].push_back(x);
cin >> x;
}
}
kahn(n);
return 0;
}
最小生成树
最小生成树(MST)是指一个连通图的最小权重生成树,通俗来说就是在一张图里选取一棵树,使得边权之和最小。
最小生成树性质
- 最小生成树一定是一棵树。
- 最小生成树的边数一定是 n − 1 n-1 n−1。
- 边权之和一定是最小的。
kruskal
kruskal 算法是求最小生成树的算法,其时间复杂度为 O ( n 2 ) O(n^2) O(n2)。
kruskal 算法的基本思想是:
- 首先将图中的所有边按照权值从小到大排序。
- 每次选取权值最小的边,如果这条边的两个端点不属于同一棵树,则将这条边加入最小生成树中,否则舍去。
- 重复步骤 2,直到选取完所有边。
kruskal 算法的具体实现如下(P3366 【模板】最小生成树):
#include <bits/stdc++.h>
using namespace std;
const int N = 2e5 + 5;
struct node {
int x, y, w;
} e[N];
int fa[N];
int n, m;
int sum, cnt;
inline int find(int x) { return fa[x] == x ? x : fa[x] = find(fa[x]); };
inline int unionn(int x, int y) { return x = find(x), y = find(y), fa[x] = x != y ? y : fa[x]; } // 并查集判断是否在同一棵树上
inline bool cmp(node x, node y) { return x.w < y.w; }
inline void kruskal() {
for (int i = 1; i <= n; i++) {
fa[i] = i;
}
sort(e + 1, e + 1 + m, cmp);
for (int i = 1; i <= m; i++) {
int x = find(e[i].x), y = find(e[i].y);
if (x == y) {
continue;
}
sum = sum + e[i].w; // 加上边权和
unionn(x, y);
cnt++;
if (cnt == n - 1) { // 达到n-1条边就已经选取完成
return;
}
}
return;
}
int main() {
cin >> n >> m;
for (int i = 1; i <= m; i++) {
cin >> e[i].x >> e[i].y >> e[i].w;
}
kruskal();
if (cnt < n - 1) {
cout << "orz";
} else {
cout << sum;
}
return 0;
}
树形DP
树形DP定义
在树形结构上的DP问题叫做树形DP,非常简单的解释。
树形DP的子问题一般是一颗子树的DP,最小子问题一般为叶子节点。
一般有两种形式的DP:
- 从上到下转移,先转移再递归;
- 从下到上转移,先递归再转移。
第一类树形DP
第一类树形DP定义
状态中兄弟没有数量上的约束关系,例如和为 100 等条件。
常用状态定义: d p i dp_i dpi 表示以 i i i 为根节点的XXX的最大值/最小值/方案数。
例题:P1122 最大子树和
状态定义: d p i dp_i dpi 表示以 i i i 为根节点的最大子段和。
答案: max ( d p i ) \max(dp_i) max(dpi)
转移方程:
d
p
c
u
r
=
max
(
d
p
c
u
r
,
d
p
c
u
r
+
d
p
n
x
t
)
dp_{cur}=\max(dp_{cur},dp_{cur}+dp_{nxt})
dpcur=max(dpcur,dpcur+dpnxt)
代码:
#include <bits/stdc++.h>
using namespace std;
const int N = 16005;
int a[N], dp[N];
vector<int> G[N];
void dfs(int cur, int fa) {
dp[cur] = a[cur];
for (int i = 0; i < G[cur].size(); i++) {
int nxt = G[cur][i];
if (nxt == fa) {
continue;
}
dfs(nxt, cur);
dp[cur] = max(dp[cur], dp[cur] + dp[nxt]);
}
return;
}
int main() {
int n;
cin >> n;
for (int i = 1; i <= n; i++) {
cin >> a[i];
}
for (int i = 1; i < n; i++) {
int x, y;
cin >> x >> y;
G[x].push_back(y);
G[y].push_back(x);
}
dfs(1, 0);
int maxi = -1e9;
for (int i = 1; i <= n; i++) {
maxi = max(maxi, dp[i]);
}
cout << maxi;
return 0;
}
这几题比较简单,只用设计出状态,套模板即可。
第二类树形DP
第二类树形DP定义
类似于一个在树上的背包问题,它可以将子节点作为背包中的物品,可以决定选和不选的树形DP。
例题:P2014 [CTSC1997] 选课
状态定义: d p i , j dp_{i , j} dpi,j 表示以 i i i 为根节点,选择 j j j 门课的最大学分。
答案发现很难处理,因为题目中是一个森林,于是我们可以设置一个虚拟点 0 作为根节点。
答案: d p 0 , m + 1 dp_{0,m+1} dp0,m+1。
状态转移:
d
p
c
u
r
,
j
=
max
(
d
p
c
u
r
,
j
,
d
p
n
x
t
,
k
+
d
p
c
u
r
,
j
−
k
)
dp_{cur,j} = \max(dp_{cur,j},dp_{nxt,k}+dp_{cur,j-k})
dpcur,j=max(dpcur,j,dpnxt,k+dpcur,j−k)
这里的 j j j 代表这个一个节点可以选取的数量, k k k 代表这下一个节点可以选取的数量。
代码:
#include <bits/stdc++.h>
using namespace std;
const int N = 305;
const int M = 305;
vector<int> G[N];
int dp[N][M], a[N];
int n, m;
int dfs(int cur) {
int sum = 1; // 统计cur为根的子树中节点的个数
dp[cur][1] = a[cur];
for (int i = 0; i < G[cur].size(); i++) {
int nxt = G[cur][i];
int tmp = dfs(nxt);
sum += tmp;
for (int j = min(sum + 1, m + 1); j >= 1; j--) {
for (int k = 1; k < j; k++) {
dp[cur][j] = max(dp[cur][j], dp[nxt][k] + dp[cur][j - k]);
}
}
}
return sum;
}
int main() {
cin >> n >> m;
for (int i = 1; i <= n; i++) {
int x, w;
cin >> x >> w;
G[x].push_back(i);
a[i] = w;
}
dfs(0);
cout << dp[0][m + 1];
return 0;
}
这一类题目我觉得比较重要,所以多放一道。
例题:P2015 二叉苹果树
状态: d p i , j dp_{i,j} dpi,j 表示以 i i i 为根节点的子树,边的数量为 j j j 的最大苹果数量。
答案: d p 1 , p dp_{1,p} dp1,p
状态转移:
d
p
c
u
r
,
j
=
max
(
d
p
c
u
r
,
j
,
d
p
n
x
t
,
k
+
d
p
c
u
r
,
j
−
k
−
1
+
w
)
dp_{cur,j}=\max(dp_{cur,j},dp_{nxt,k}+dp_{cur,j-k-1}+w)
dpcur,j=max(dpcur,j,dpnxt,k+dpcur,j−k−1+w)
代码:
#include <bits/stdc++.h>
using namespace std;
const int N = 105;
struct node {
int x, w;
};
vector<node> G[N];
int dp[N][N];
int n, p;
int dfs(int cur, int fa) {
int sum = 0;
for (int i = 0; i < G[cur].size(); i++) {
int nxt = G[cur][i].x, w = G[cur][i].w;
if (nxt == fa) {
continue;
}
sum += dfs(nxt, cur) + 1;
for (int j = min(sum, p); j >= 1; j--) {
for (int k = 0; k < j; k++) {
dp[cur][j] = max(dp[cur][j], dp[nxt][k] + dp[cur][j - k - 1] + w);
}
}
}
return sum;
}
int main() {
cin >> n >> p;
for (int i = 1; i < n; i++) {
int x, y, w;
cin >> x >> y >> w;
G[x].push_back({y, w});
G[y].push_back({x, w});
}
dfs(1, 0);
cout << dp[1][p];
return 0;
}
注意转移时要取
m
a
x
max
max,被老师坑了一大把
例题:P1273
第三类树形DP
定义
换根DP,顾名思义,就是一类根节点不固定的树形DP,他一般具有以下特点:
- 树中没有指定根节点;
- 采用不同的节点为根,算出来的解不同。
解决方法:
- 先指定任意节点作为根;
- 搜索完成指定根的答案计算,得到指定根的解;
- 二次扫描,由父节点推算出相邻子节点的解。
例题:P3478 [POI2008] STA-Station
这个就不讲了吧,看看代码也看得懂
其实是没时间写了,以后会写的,咕咕
这个题有暴力做法:枚举每一个成为根节点的节点 i i i,每一个都跑一遍树形DP即可,喜提 50 分。
代码:
#include<bits/stdc++.h>
using namespace std;
const int N = 1e6 + 5;
vector<int> G[N];
int dep[N] , dp[N];
void dfs(int cur , int fa){
dep[cur] = dep[fa] + 1;
dp[cur] = dep[cur];
for(int i = 0 ; i < G[cur].size() ; i++){
int nxt = G[cur][i];
if(nxt == fa){
continue;
}
dfs(nxt , cur);
dp[cur] += dp[nxt];
}
}
int main(){
int n;
cin>>n;
for(int i = 1 ; i < n ; i++){
int u , v;
cin>>u>>v;
G[u].push_back(v);
G[v].push_back(u);
}
int maxi = 0;
int ans = 0;
for(int i = 1 ; i <= n ; i++){
dp[0] = -1;
dfs(i , 0);
if(dp[i] > maxi){
maxi = dp[i];
ans = i;
}
}
cout<<ans;
return 0;
}
正解:
- 从点 1 开始跑一遍树形DP,求得DP值;
- f 1 ← d p 1 f_1 \leftarrow dp_1 f1←dp1
- 从点 1 开始换根,由 f f a f_fa ffa 向 f c u r f_cur fcur 转移
- 最后答案为 max ( f i ) \max(f_i) max(fi)
状态转移:
f c u r ← f f a + n − 2 × s i z e c u r f_{cur} \leftarrow f_{fa}+n-2 \times size_{cur} fcur←ffa+n−2×sizecur
例题:P2986 [USACO10MAR] Great Cow Gathering G
状态: d p i dp_i dpi 表示以 i i i 为根节点的子树内的牛走到 i i i 的距离之和, f i f_i fi 表示以 i i i 为根节点,所有牛走到 i i i 的距离之和。
答案: min ( f i ) \min(f_i) min(fi)
状态转移:
d p c u r = ∑ d p n x t + s i z n x t × w dp_{cur} = \sum dp_{nxt} + siz_{nxt} \times w dpcur=∑dpnxt+siznxt×w
f n x t = f c u r − s i z n x t × w + ( t o t − s i z n x t ) × w f_{nxt} = f_{cur} - siz_{nxt} \times w + (tot - siz_{nxt}) \times w fnxt=fcur−siznxt×w+(tot−siznxt)×w
拓展:P5658 [CSP-S2019] 括号树
题目分析
- 给定一棵有 n n n 个节点的树,点 1 是根节点;
- 每个节点上有 1 个括号,可能为
(
或者)
。
目标:对于每一个节点 i i i,从点 1 到点 i i i 的路径形成的括号串,求其合法括号子串的数量 k i k_i ki,并输出 i × k i i \times k_i i×ki 的异或和。
思路分析
- 将树上问题转化为序列问题
状态: d p i dp_i dpi 表示已 i i i 结尾的合法子串的数量。
答案: i × d p i i \times dp_i i×dpi 并求异或和。
- 当前字符为
(
, d p i ← 0 dp_i \leftarrow 0 dpi←0;- 当前字符为
)
,此时可以匹配,令此时距离最近的左括号下标为 u u u, d p i ← d p u − 1 + 1 dp_i \leftarrow dp_{u-1}+1 dpi←dpu−1+1
- 放到树上去
状态转移:令此时祖先节点最近的左括号下标为 u u u, u u u 的父节点 f a u fa_u fau 为 w w w,那么 d p i ← d p w + 1 dp_i \leftarrow dp_{w}+1 dpi←dpw+1。
此时需要注意,路径之间有公共部分,于是在 dfs 结束之后要重新将弹出的左括号重新入栈。
- 最终答案需要取前缀和再异或
代码展示
#include<bits/stdc++.h>
#define int long long
using namespace std;
const int N = 5e5 + 5;
int fa[N] , dp[N];
vector<int> G[N];
stack<int> stk;
int n;
string s;
void dfs(int cur){
int tmp;
bool flag = 0;
dp[cur] = 0;
if(s[cur] == '('){//当前是一个左括号,无法匹配
stk.push(cur);
}
else if(!stk.empty()){//是一个右括号,并且栈不为空
tmp = stk.top();
stk.pop();
flag = 1;//标记已经匹配成功
dp[cur] = dp[fa[tmp]] + 1;
}
for(int i = 0 ; i < G[cur].size() ; i++){
dfs(G[cur][i]);
}
if(flag){//递归回溯时要把他放回去
stk.push(tmp);
}
else if(s[cur] == '('){
stk.pop();//回溯弹出
}
return ;
}
void get_sum(int cur){
for(int i = 0 ; i < G[cur].size() ; i++){
dp[G[cur][i]] += dp[cur];//将父节点的答案加到子结点上
get_sum(G[cur][i]);//求和
}
return ;
}
signed main(){
cin>>n>>s;
s = '.' + s;
for(int i = 2 ; i <= n ; i++){
cin>>fa[i];
G[fa[i]].push_back(i);
}
dfs(1);
get_sum(1);
int ans = 0;
for(int i = 1 ; i <= n ; i++){
ans = ans ^ (i * dp[i]);//答案异或,注意要开long long,不然会爆炸
}
cout<<ans;
return 0;
}