图论总述篇

图论总述

本博客由fengqiao17整理,难度系数比较简单,如果觉得太简单可以跳过,如果喜欢可以给我一个赞!

参考了部分OI-wiki上的内容,在此表示感谢。

感谢YZ周老师puck以及lzm杨老师apple365

目前的源码站:
语雀
洛谷
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 则表示两点之间没有边。

举例:

邻接矩阵

以上这个图的邻接矩阵为:

*01234
000101
100011
210011
301100
411100

以下是一个用临街矩阵存储图的示例:

#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;
}

习题:B3643 B3613 留给读者自己完成。


树的定义

树是一种特殊的图,它满足以下性质:

  • 每两个点之间有且仅有一条路径
  • 没有环

以下是一棵树的示例:

树

树的存储

树有两种存储方式,一种是父亲表示法,另一种是孩子表示法。

数组存储

我们可以用一个一维数组来存储一棵树,比如说有一个节点 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;
}

其他练习:P6867 P3916 P1330 P2420

自己可以多做做,不会的可以自行看题解。


特殊的树

二叉树

二叉树的定义

二叉树是树的一种,并且每个结点至多只有两棵子树。

二叉树

二叉树的性质
  1. 满二叉树:若二叉树的高度为h,则其结点总数为 2 h − 1 2^h-1 2h1
  2. 完全二叉树:若二叉树的高度为h,除第h层外,其它各层(1至h-1)结点数都达到最大个数,第h层结点数为 [ 0 , 2 h − 1 − 1 ] [0,2^{h-1}-1] [0,2h11]
二叉树的遍历

二叉树的遍历是指从根结点出发,按照某种次序依次访问二叉树中所有结点,使得每个结点被访问一次且仅被访问一次。

二叉树的遍历方式有三种:先序遍历、中序遍历、后序遍历。

  1. 先序遍历:先访问根结点,然后遍历左子树,再遍历右子树。
  2. 中序遍历:先遍历左子树,然后访问根结点,再遍历右子树。
  3. 后序遍历:先遍历左子树,再遍历右子树,最后访问根结点。

代码实现用递归即可。

例题:P1305 P1827

堆是一种特殊的完全二叉树,其中任一结点的值均不大于(或不小于)其左右孩子的值。

堆分为大根堆和小根堆,大根堆要求根结点的值最大,小根堆要求根结点的值最小。

堆在程序实现中通常用c++中自带的 priority_queue 来实现。

一些基本的操作:

  1. pq.top():返回堆顶元素。
  2. pq.push(x):往堆中插入元素 x。
  3. pq.pop():删除堆顶元素。
  4. pq.empty():判断堆是否为空。
  5. 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;
}

例题:P1631 P1801

并查集

并查集是一种树形的数据结构,用于处理一些不相交集合的合并及查询问题。

并查集的实现一般使用父亲表示法,即用一棵树表示一个集合,每个集合的根节点有且只有一个,每个节点指向其父亲。

并查集的常用操作有:

  • 查找:查找某个元素所在的集合,并返回其根节点。
  • 合并:将两个集合合并为一个集合。

并查集优化:

  1. 路径压缩:在查找操作中,将某个节点的父节点指向该节点,从而减少树的高度。
  2. 按秩合并:将树按照高度进行排序,高度小的节点指向高度大的节点。

并查集的实现:

#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;
}

例题:P2814 P1892 P1111 P1197


最短路算法

最短路算法分为单源最短路和多源最短路,单源最短路就是求从一个起点到其他点的最短距离,多源最短路就是求从多个起点到其他点的最短距离。

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;
}

例题:P2888 P1119 P6175

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;
}

三种最短路的对比:

*FloydDijkstraSpfa
类型多源单源单源
时间复杂度 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)
处理负权图yesnoyes
本质dp贪心dp

这个网上例题很多,我只放两道:P1119 P1462


拓扑排序

拓扑排序是指,将一个有向无环图(Directed Acyclic Graph,简称 DAG)进行排序进而得到一个有序序列的过程。

拓扑排序的算法流程:

  1. 找到有向无环图的入度为 0 的点,将其放入一个队列中。
  2. 每次从队列中取出一个点,并将其输出。
  3. 更新该点的所有邻接点的入度,如果入度为 0,则放入队列中。
  4. 重复 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;
}

例题:P3243 P4017 P8655


最小生成树

最小生成树(MST)是指一个连通图的最小权重生成树,通俗来说就是在一张图里选取一棵树,使得边权之和最小。

最小生成树性质

  1. 最小生成树一定是一棵树。
  2. 最小生成树的边数一定是 n − 1 n-1 n1
  3. 边权之和一定是最小的。

kruskal

kruskal 算法是求最小生成树的算法,其时间复杂度为 O ( n 2 ) O(n^2) O(n2)

kruskal 算法的基本思想是:

  1. 首先将图中的所有边按照权值从小到大排序。
  2. 每次选取权值最小的边,如果这条边的两个端点不属于同一棵树,则将这条边加入最小生成树中,否则舍去。
  3. 重复步骤 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;
}

例题:P3073 P8074 P2504 P4826


树形DP

树形DP定义

在树形结构上的DP问题叫做树形DP,非常简单的解释。

树形DP的子问题一般是一颗子树的DP,最小子问题一般为叶子节点。

一般有两种形式的DP:

  1. 从上到下转移,先转移再递归;
  2. 从下到上转移,先递归再转移。

第一类树形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;
}

例题:P1352 P1131 P4084

这几题比较简单,只用设计出状态,套模板即可。

第二类树形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,jk)

这里的 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,jk1+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,他一般具有以下特点:

  • 树中没有指定根节点;
  • 采用不同的节点为根,算出来的解不同。

解决方法:

  1. 先指定任意节点作为根;
  2. 搜索完成指定根的答案计算,得到指定根的解;
  3. 二次扫描,由父节点推算出相邻子节点的解。
例题: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. 从点 1 开始跑一遍树形DP,求得DP值;
  2. f 1 ← d p 1 f_1 \leftarrow dp_1 f1dp1
  3. 从点 1 开始换根,由 f f a f_fa ffa f c u r f_cur fcur 转移
  4. 最后答案为 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} fcurffa+n2×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=fcursiznxt×w+(totsiznxt)×w

拓展:P5658 [CSP-S2019] 括号树

题目分析
  1. 给定一棵有 n n n 个节点的树,点 1 是根节点;
  2. 每个节点上有 1 个括号,可能为(或者)

目标:对于每一个节点 i i i,从点 1 到点 i i i 的路径形成的括号串,求其合法括号子串的数量 k i k_i ki,并输出 i × k i i \times k_i i×ki 的异或和。

思路分析
  1. 将树上问题转化为序列问题

状态: 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 dpi0
  • 当前字符为),此时可以匹配,令此时距离最近的左括号下标为 u u u d p i ← d p u − 1 + 1 dp_i \leftarrow dp_{u-1}+1 dpidpu1+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 dpidpw+1

此时需要注意,路径之间有公共部分,于是在 dfs 结束之后要重新将弹出的左括号重新入栈。

  1. 最终答案需要取前缀和再异或
代码展示
#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;
}
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值