【复习笔记】Week 2 图论
NCC79601
2019-10-27 23:04:41
最常用的化解技巧是建虚点。
# DAY 1
跑最短路也是可以建虚点的。
## Dijsktra
Dijsktra 中也需要记录 `vis[]` 表示当前点的最短路是否已经确定,否则可能造成重复枚举同一个点周围的边,导致冗余的松弛尝试,使时间变慢。
只要出现负权边,Dijsktra 即不能使用。
注意:对于二重费用(在第一重费用最小的同时使第二重费用最小)的问题,用 SPFA 极易 TLE。这时可以采用重新重载小于号的方法。在普通的 Dijsktra 中,堆顶存放的是 `dis` 最小的点,每次取出这个点对其周围的点进行松弛;而在二重费用的题目中,则可以把这个规则进行改进,即堆顶保存 `dis` 和 `fee` 都最小的点。这样可以保证每个点从堆中取出来的时候,都取到了二重费用的最小值。
```cpp
struct node {
int u; ll dis, fee;
bool operator < (const node &rhs) const {
if (dis != rhs.dis)
return dis > rhs.dis;
return fee > rhs.fee;
// 二重费用的重载小于号
}
node(int src_u, ll src_dis, ll src_fee) {
u = src_u;
dis = src_dis;
fee = src_fee;
}
};
priority_queue<node> q;
void dijsktra() {
memset(dis, 0x3f, sizeof(dis));
memset(fee, 0x3f, sizeof(fee));
dis[s] = fee[s] = 0;
q.push(node(s, 0, 0));
while (!q.empty()) {
int u = q.top().u;
q.pop();
if (vis[u])
continue;
vis[u] = true;
for (int i = head[u]; i; i = edge[i].next) {
int v = edge[i].to; ll w = edge[i].w, c = edge[i].c;
if (dis[u] + w < dis[v]) {
dis[v] = dis[u] + w;
fee[v] = fee[u] + c;
// 更新最小费用
q.push(node(v, dis[v], fee[v]));
} else if (dis[u] + w == dis[v] && fee[u] + c < fee[v]) {
fee[v] = fee[u] + c;
q.push(node(v, dis[v], fee[v]));
// 似乎这里不再入堆也是可以的
}
}
}
return ;
}
```
## SPFA
记得每次从队列取出点的时候,**要把 `vis[u]` 清为 `false`**,否则原地爆炸。
SPFA 判负环时,dfs - SPFA 固然是个又好又快的方法,然而我觉得通用性不如下面这种方法强:记录每个点当前最短路径上的点数 `cnt[u]`,若发现 `cnt[u] > n` 则说明出现负环。当然,这种做法的复杂度为$O(mn)$,很容易就被卡掉了;不过,大部分情况下,加上快读、`inline`、`register` 还是能够卡过。
```cpp
bool SPFA() {
memset(dis, 0x3f, sizeof(dis));
memset(vis, 0, sizeof(vis));
while (!q.empty())
q.pop();
dis[1] = 0;
cnt[1] = 1;
// 节点数从 1 开始
q.push(1);
while (!q.empty()) {
int u = q.front();
vis[u] = false;
q.pop();
for (int i = head[u]; i; i = edge[i].next) {
int v = edge[i].to, w = edge[i].w;
if (dis[u] + w < dis[v]) {
dis[v] = dis[u] + w;
cnt[v] = cnt[u] + 1;
if (cnt[v] > n)
return true;
if (!vis[v]) {
vis[v] = true;
q.push(v);
}
}
}
}
return false;
}
```
## 差分约束
差分约束系统推出的条件,是跑完 SPFA 之后整张图所满足的。所以,等价条件 `dis[u] + w <= dis[v]` 该跑 **最长路**,`dis[u] + w >= dis[v]` 该跑 **最短路**。
最长路型差分约束系统的条件转换:
1. $a-b\geq c \Leftrightarrow$ `dis[b] + c <= dis[a]` $\Leftrightarrow$ `build_edge(b, a, c)`
2. $a-b\leq c \Leftrightarrow$ `dis[a] - c <= dis[b]` $\Leftrightarrow$ `build_edge(a, b, -c)`
3. $a=b\Leftrightarrow$ `build_edge(a, b, 0), build_edge(b, a, 0)`
建完所有关系边之后,为了获取整张图是否存在负环的信息,一种方法是一个一个连通块地跑;另一种方法是**新建虚点** `0` 并从虚点向所有点连一条长度为$0$的有向边,最后 `SPFA(0)` 即可。
## 最短路树
**等长路径字典序最小** 的最短路树的生成方式:维护一个 `conn[]` 表示当前点是否被连入最短路树。按编号从小到大枚举节点$u$,这样就能保证是按照字典序从小到大枚举;寻找$u$所指向的所有结点$v$,如果 `conn[v] == false` 并且这条边满足最短路树的性质,则证明当前这条边可以连入最短路树,那么直接在 `edge2` 上建边即可。
```cpp
// https://www.luogu.org/problem/P5201
void build_tree() {
conn[1] = true;
for (int u = 1; u <= n; u++)
for (int i = head1[u]; i; i = edge1[i].next) {
int v = edge1[i].to, w = edge1[i].w;
if (conn[v])
continue;
if (dis[u] + w == dis[v]) {
conn[v] = true;
build_edge(edge2, head2, id2, u, v, 1);
build_edge(edge2, head2, id2, v, u, 1);
}
}
return ;
}
```
# DAY 2
## 缩点
缩完点建新图的时候,一定要记住 **新图里点 $u$ 的编号是 `scc[u]`**,千万不要逮着原图的点建图(多次死亡)。
缩点的时候要考虑到 **两点环** 的可能,所以不能判断$v$是否为父亲,而应该考虑$v$是否在栈内。
```cpp
void tarjan(int u) {
dfn[u] = low[u] = ++cnt;
s[++top] = u, ins[u] = true;
for (register int i = h1[u]; i; i = e1[i].next) {
int v = e1[i].to;
if (!dfn[v]) {
tarjan(v);
low[u] = my_min(low[u], low[v]);
} else if (ins[v])
low[u] = my_min(low[u], dfn[v]);
}
if (dfn[u] == low[u]) {
scc_cnt++;
int v;
do {
v = s[top--];
ins[v] = false;
scc[v] = scc_cnt;
p2[scc_cnt] += p[v];
} while (v != u);
}
}
```
## 割点
割点需要特判 dfs 树的树根(也就是代码中的 `fa` ),具体为:若 `fa` 在 dfs 树中的儿子个数$\geq2$,则 `fa` 也是一个割点。
而对于一般的点$u$,只有在没有搜过$u$的情况下才会考察$u$是否为一个割点;不然就用 `dfn[v]` 更新 `low[u]` 即可。
```cpp
void tarjan(int u, int fa) {
dfn[u] = low[u] = ++dfscnt;
int cnt = 0;
for (int i = head[u]; i; i = edge[i].next) {
int v = edge[i].to;
if (!dfn[v]) {
tarjan(v, fa);
// 注意是把 fa 下传
low[u] = min(low[u], low[v]);
if (u != fa && low[v] >= dfn[u])
cut[u] = true;
// 普通的点
if (u == fa)
cnt++;
// 特判 fa 的情况
} else
low[u] = min(low[u], dfn[v]);
// 这里不需要再带上判割点的语句
}
if (u == fa && cnt > 1)
cut[u] = true;
return ;
}
```
## 桥
在基环树上,断掉所有的桥,剩下的边就是环上的边。
tarjan 找桥的过程中,需要判断反向边。
```cpp
void tarjan(int u, int pre) {
dfn[u] = low[u] = ++dfscnt;
for (int i = head[u]; i; i = edge[i].next) {
if (i == (pre ^ 1))
continue;
int v = edge[i].to;
if (!dfn[v]) {
tarjan(v, i);
if (low[v] > dfn[u])
cut[i] = true;
low[u] = min(low[u], low[v]);
} else
low[u] = min(low[u], dfn[v]);
}
return ;
}
```
# DAY 3
## 欧拉回路
半欧拉回路的起点必须为奇数度数点,而欧拉图的起点可以任意取。注意:有可能需要倒序记录路径。
```cpp
void hierholzer(int u) {
for (int v = 'A'; v <= 'z'; v++)
if (G[u][v]) {
G[u][v] = G[v][u] = 0;
hierholzer(v);
}
res[tail--] = u;
return ;
}
```
## 最小生成树
### Kruskal
Kruskal 的复杂度由边数$m$决定,因此太稠密的图不能跑 Kruskal(然而这种情况并不多见)。
### Prim
Prim 的思路类似 Dijsktra,并且主函数也只有一小点区别。也就是说,维护一个 `dis[u]` 表示点$u$到当前生成树的最短距离;每次找出不在生成树中的 `dis[]` 最小的点,并连入生成树中。当然,由于思路和 Dijsktra 相似,Prim 也是可以加堆优化的。不加堆优化的 Prim 在超级稠密图中的性能优于 Kruskal。
```cpp
// Prim 的邻接矩阵写法
void prim() {
memset(dis, 0x3f, sizeof(dis));
for (int i = 1; i <= n; i++)
dis[i] = min(dis[i], G[1][i]);
int u = 1;
while (++tot < n) {
vis[u] = true;
int minn = 0x3f3f3f3f;
for (int i = 1; i <= n; i++)
if (!vis[i] && dis[i] < minn) {
u = i;
minn = dis[i];
}
ans += minn;
for (int i = 1; i <= n; i++)
if (!vis[i] && G[u][i] < dis[i])
dis[i] = G[u][i];
}
return ;
}
```
## 最小生成森林
最小生成森林可以看作所有树的根都连到超级根上的最小生成树,所以从超级根向每一个可以作为树根的点连一条边,即可获得最小生成森林。这里又用到了 **建虚点** 的方法。当然,最小生成森林也可以简单地用并查集维护,只需要统计 `fa[i] == i` 的点即可。
事实上,只要涉及到 **一条边减少一个联通块** 的问题,极有可能与最小生成树 / 最小生成森林相关。
# DAY 4
## 二分图染色
只要一个图中没有奇环,那么这个图就是一个二分图。通常可以使用黑白染色法把一个图转化为二分图。二分图的核心在于 **如何建图**。
二分图的题目中,点和边的实际意义是很灵活的,比如可以把坐标系上的点$P$当成边,从$x_P$向$y_P$连一条边;也可以把一个网格图黑白染色后两两连边,每条边代表将这条边所连的两个点放在同一个矩形里。
不过,因为二分图中的点只有 `x[]` 和 `y[]` 两列,所以对于二维网格图上的点,需要先把点映射到一个数字上,然后才能放到点列当中。
## 二分图匹配
**匈牙利算法** 的本质是寻找增广路(也就是进行匹配协商)。复杂度为$O(nm)$。由于 `vis[u]` 记录的是点$u$是否在当前增广路上,所以 **每次找增广路后都需要把 `vis[]` 清零**。
```cpp
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 1e3 + 10;
int cx[MAXN], cy[MAXN], G[MAXN][MAXN];
bool vis[MAXN];
int n, m, e, ans = 0;
bool hungary(int x) {
for (int y = 1; y <= m; y++)
if (!vis[y] && G[x][y]) {
vis[y] = true;
// 标记当前点已经在增广路上
if (!cy[y] || hungary(cy[y])) {
// 若还能找到增广路 / 进行协商
cx[x] = y;
cy[y] = x;
return true;
}
}
return false;
}
int main() {
memset(cx, 0, sizeof(cx));
memset(cy, 0, sizeof(cy));
// 必须初始化
scanf("%d %d %d", &n, &m, &e);
for (int u, v; e; e--) {
scanf("%d %d", &u, &v);
if (u > n || v > m)
continue;
G[u][v] = 1;
}
for (int i = 1; i <= n; i++) {
memset(vis, 0, sizeof(vis));
// 每次都必须 memset()
ans += hungary(i);
}
// 从每个点尝试寻找增广路,若找到增广路则 ans++
printf("%d", ans);
return 0;
}
```
当然,每次对 `vis[]` 进行一次 `memset()` 比较慢。为了加速,可以把 `vis[]` 改成时间戳,也就是下面的写法:
```cpp
bool hungary(int x) {
for (int y = 1; y <= n; y++)
if (G[x][y] && vis[y] != cur) {
vis[y] = cur;
if (!cy[y] || hungary(cy[y])) {
cx[x] = y;
cy[y] = x;
return true;
}
}
return false;
}
int match() {
memset(cx, 0, sizeof(cx));
memset(cy, 0, sizeof(cy));
memset(vis, 0, sizeof(vis));
cur = 1;
int res = 0;
for (int i = 1; i <= n; i++, cur++)
res += hungary(i);
return res;
}
```
## 二分图的一些性质
### 最小点覆盖
**最小点覆盖:** 选取最少的一些点使得所有边都与这些点相连。
二分图中,**最小点覆盖$=$最大匹配**。
**例题:** 有一些障碍物位于一张$n\times n$的网格图上,每次可以消除一行或者一列上的所有障碍物,求最少需要消除多少次才能清除所有障碍物。
**分析:** 对于每个障碍物$(x,y)$,从$x$向$y$连一条边,每条边代表一个障碍物。那么可以看出,只要选出一个点$x$,与$x$相连的所有边(即横坐标为$x$的所有障碍物)都会被覆盖;而题目要求所有边都被覆盖。所以,问题就转化为了最小点覆盖。
```cpp
// http://poj.org/problem?id=3041
#include <iostream>
#include <stdio.h>
#include <cstring>
using namespace std;
const int MAXN = 510;
int n, k;
int G[MAXN][MAXN], cx[MAXN], cy[MAXN];
bool vis[MAXN];
bool hungary(int x) {
for (int y = 1; y <= n; y++)
if (G[x][y] && !vis[y]) {
vis[y] = true;
if (!cy[y] || hungary(cy[y])) {
cx[x] = y;
cy[y] = x;
return true;
}
}
return false;
}
int match() {
int res = 0;
for (int i = 1; i <= n; i++) {
memset(vis, 0, sizeof(vis));
res += hungary(i);
}
return res;
}
int main() {
memset(cx, 0, sizeof(cx));
memset(cy, 0, sizeof(cy));
scanf("%d %d", &n, &k);
for (int x, y; k; k--) {
scanf("%d %d", &x, &y);
G[x][y] = 1;
}
printf("%d", match());
return 0;
}
```
### 最小边覆盖
**最小边覆盖:** 选取最少的一些边使得所有点都与这些边相连。
二分图中,**最小边覆盖$=$总点数$-$最大匹配数**。
**例题:** 有一个网格,上面有一些可选点,每次可以覆盖一个$1\times2$大小的矩形,求出把所有点都覆盖住的最小覆盖次数。
**分析:** 考虑如何建图。先把图黑白染色,对于每个黑色可选点,向上下左右的白色可选点连边。对于任意一条边,选取这条边代表这条边所连的两点在同一矩形内;而题目要求所有点都被覆盖。所以,问题就转化为了最小边覆盖。
```cpp
// poj.org/problem?id=3020
#include <iostream>
#include <stdio.h>
#include <cstring>
using namespace std;
const int MAXN = 3e3 + 10;
const int dx[] = {-1, 1, 0, 0};
const int dy[] = {0, 0, -1, 1};
int G[MAXN][MAXN];
int T, h, w, n;
char s[MAXN][MAXN];
int cx[MAXN], cy[MAXN];
bool vis[MAXN];
bool accept(int x, int y) {
return x > 0 && x <= h && y > 0 && y <= w;
}
int getid(int x, int y) {
return (x - 1) * w + y;
}
// 需要把点映射到一个数上,方便连边
bool hungary(int x) {
for (int y = 1; y <= n; y++)
if (!vis[y] && G[x][y]) {
vis[y] = true;
if (!cy[y] || hungary(cy[y])) {
cx[x] = y;
cy[y] = x;
return true;
}
}
return false;
}
int match() {
int res = 0;
for (int i = 1; i <= n; i++) {
memset(vis, 0, sizeof(vis));
res += hungary(i);
}
return res;
}
int init() {
memset(G, 0, sizeof(G));
memset(cx, 0, sizeof(cx));
memset(cy, 0, sizeof(cy));
int res = 0;
for (int x = 1; x <= h; x++)
for (int y = 1; y <= w; y++)
if (s[x][y] == '*') {
res++;
if ((x + y) & 1)
continue;
for (int k = 0; k < 4; k++) {
int nx = x + dx[k];
int ny = y + dy[k];
if (!accept(nx, ny))
continue;
if (s[nx][ny] == '*')
G[getid(x, y)][getid(nx, ny)] = 1;
}
}
return res;
}
int main() {
scanf("%d", &T);
while (T-- && scanf("%d %d", &h, &w)) {
for (int i = 1; i <= h; i++)
scanf("%s", s[i] + 1);
n = h * w;
int tot = init();
printf("%d\n", tot - match());
}
return 0;
}
```
# DAY 5
## 树链剖分
剖完以后$u,v$的跳转非常易错,需要判断的是 **`top[u]` 和 `top[v]` 的深度关系** 而不是 `u` 和 `v`。
```cpp
void Update(int u, int v, ll c) {
c %= p;
while (top[u] != top[v]) {
if (dep[top[u]] < dep[top[v]])
swap(u, v);
// 注意是 dep[top[u]] < dep[top[v]]
edit_tree(1, dfn[top[u]], dfn[u], c);
u = fa[top[u]];
}
if (dep[u] > dep[v])
swap(u, v);
edit_tree(1, dfn[u], dfn[v], c);
return ;
}
```
# DAY 6
## 树上问题
一般树上问题的解决思路是考虑子树,然后考虑子树能够上传些什么给父节点。比如树上链问题,单独考虑一棵子树的话,子树内的链可以分为三种情况:
1. 链在子树内,且不经过根节点;
2. 链在子树内,且经过根节点;
3. 链不全在子树内。
分别考虑每种情况对应的处理方法,就可以分治地解决一些树上问题(2018 D1T3)。
## 树的重心
定义:选取树上的一个点作为树根,使得最大子树最小,则这个点为树的重心。
求法:以任意点为根跑一遍 dfs,然后统计 `max(size[v], n - size[u] - 1)` 最小的点,即可获得重心。
### 一些性质
树中所有点到某节点的距离和中,到重心的距离和是最小的;若有两个重心,则其距离和相等;
把两个树通过一条边相连得到一棵新树,那么新树的重心在连接原来两个树的重心的路径上;
添加或删除一个叶子,那么树的重心最多只移动一个点。
## 树的直径
定义:树上任意两点的最长距离为树的直径。
直径共有两种求法,都是$O(n)$。
贪心求法:正反两遍 dfs,第一次以任意节点为根 dfs 到最远的点$u$,第二次从$u$开始 dfs 到最远的点$v$,则$u\rightarrow v$即树的直径。
```cpp
int dia = 0, p;
void dfs(int u, int fa, int pre) {
if (pre > dia) {
dia = pre;
p = u;
}
for (int i = head[u]; i; i = edge[i].next) {
int v = edge[i].to, w = edge[i].w;
if (v == fa)
continue;
dfs(v, u, pre + w);
}
return ;
}
inline void prework() {
dfs2(1, 1, 0);
dfs2(p, p, 0);
return ;
}
```
动规求法(适用于所有情况):$f[u]$表示从$u$开始向下能够延伸的最长链长度,每次对于一个儿子$v$,先用$f[u]+f[v]+dis[u\rightarrow v]$更新答案,然后用$f[v]+dis[u\rightarrow v]$更新$f[u]$。这样可以保证不会重复计算同一条链。
### 一些性质
设两棵树的直径分别是$(u_1\rightarrow v_1), (u_2\rightarrow v_2)$,则用一条边将两棵树相连,新的直径$(u\rightarrow v)$一定满足$u,v\in \{u_1,u_2,v_1,v_2\}$;
若给树加一个节点,新的直径最多改变一个端点。
# 总结
再次强调图论的经典处理方法:建反图,建虚点,边排序。
在这些知识点中,有关树的部分很可能是和树形 dp 结合考察,因此要向两个方向试探,不能局限思维。点和边的意义也不能局限于常见的套路,比如二分图中,边的意义甚至是点。涉及到树上查询的问题,一般是用树上倍增实现;如果用树链剖分 + 线段树则时间复杂度会多出一个$logn$,这种情况下就需要用 ST 表使得单次查询复杂度达到$O(1)$。
总而言之,图论的难点就在于建模。
---
# $\footnotesize\text{Weathering With You}$
![0249c3e1ddc8751e891a8c98d0fbec85.png](https://img.ffis.me/images/2019/11/02/0249c3e1ddc8751e891a8c98d0fbec85.png)
![bc70240efe3cb848bbc83ca2f02f54b4.png](https://img.ffis.me/images/2019/11/02/bc70240efe3cb848bbc83ca2f02f54b4.png)
![da0aecfae654a461bca35a68f7b24b83.png](https://img.ffis.me/images/2019/11/02/da0aecfae654a461bca35a68f7b24b83.png)
重拾那份爱的勇气,纵使世界就这样疯狂下去,我毫不在意
跨越无尽黑夜,逃离阳光无法企及之地
无论是晴是雨,不管多远距离,也一定要去见你
当大地失去了重力,千年一遇的奇迹
我和你,十指相扣,翱翔天际
「呐,现在开始就要放晴了哦。」
——希望百分百晴天女孩的祈祷,也能驱散你心中的阴云。
$\large\texttt{by NCC-79601}$
$\footnotesize\text{Weathering With You}$