算法与数据结构 11 - 图论大杂烩
目录
- 部分运算的规定
- 图相关算法
- 最短路
- 单源最短路
- 多源最短路
- 最小生成树
- 并查集
- Tarjan
- 强连通分量
- 边双连通分量、割边(桥)
- 点双连通分量、割点
- 二分图
- 二分图染色
- 二分图匹配
- 拓扑排序
- 分数规划
- 最短路
- 树相关算法
- 最近公共祖先(倍增法)
- 树链剖分
0. 部分运算的规定
若无特殊说明,所有变量均为整数,整数除法自动向下取整。
本文约定 \(\log x=\log_2x\)。
当提到变量 \(n,m\) 时,若无特殊说明,认为 \(n\) 是图或树的结点数,\(m\) 是图的边数。
文字中点、节点、结点、顶点混用,但指的是同一种东西。
1.1 最短路
最短路问题分为单源最短路和全源最短路。单源最短路,即给定一个起始点 \(u\),求其他点到 \(u\) 的最短路;多源最短路,即求图上任意两点 \(u,v\) 之间的最短路。
求解单源最短路的常见方法是 Dijkstra 算法和 SPFA 算法,求解多源最短路的常见方法是 Floyd 算法和 Johnson 算法。
1.1.1 单源最短路
Dijkstra 是应用较广的非负权图最短路算法。常见的实现方法是用堆实现。它的核心思想是:初始将起点的距离设为 \(0\),其它点的距离设为 \(+\infty\),并维护一个堆,将起点插入它;运算时,每次从优先队列中取出未标记过的,距离最小的点,松弛与它相连的边,并标记这个点。使用斐波那契堆优化的时间复杂度是 \(\mathcal{O}(n\log n+m)\)。但也可以使用优先队列替代堆维护点集,此时的时间复杂度是 \(\mathcal{O}(m\log m)\)。下面是一份实现(优先队列优化):
void dijkstra(int v0) {
priority_queue<Node> q;
memset(d, 0x3f, sizeof(d));
memset(vis, false, sizeof(vis));
d[v0] = 0;
q.push((Node) {v0, 0});
while (!q.empty()) {
Node n1 = q.top();
q.pop();
int x = n1.y;
if(vis[x]) continue;
vis[x] = true;
for (int j = 0; j < g[x].size(); j++) {
Edge next = g[x][j];
int y = next.y, w = next.w;
if (vis[y]) continue;
if(d[x] + w < d[y]) {
d[y] = d[x] + w;
q.push((Node) {y, d[y]});
}
}
}
}
当图中存在负劝边时,Dijkstra 就无能为力,此时可以请出 SPFA。SPFA 是一种特殊的 Bellman-Ford 算法,它的核心思想是尝试对每条可以被松弛的边进行松弛操作,而显然只有上一次被松弛的结点的连边,才有可能在下一次被松弛,因此用队列维护此类结点即可。
需要注意的是,如果到达某个点经过了 \(n\) 条及以上的边,说明从起点到这个点的路径包含负环(即一个环的边权和是负数)。任何能经过负环到达的点,距离均为 \(-\infty\)。它在大多数随机图上的时间复杂度可视为 \(\mathcal{O}(km)\),其中 \(k\) 是远小于 \(n\) 的常数;但在可以构造的图上,它可能会退化为普通的 Bellman-Ford 算法,时间复杂度为 \(\mathcal{O}(nm)\)。考虑到 C++ 使用 STL 优先队列实现 Dijkstra 比较简单,应当非必要不适用 SPFA。
例题:P3403 跳楼机
求小于 \(h\) 的自然数中,能使用若干个 \(x,y,z\) 相加得到的数字个数。
因为 \(h\) 很大,所以直接 DP 是不可能的;或许你会想到小凯的疑惑,但类似做法可能达到 \(\mathcal{O}(n^2)\) 量级,不能通过。注意到,如果数字 \(i\mod x\) 符合要求,则 \((i+y)\mod x\) 和 \((i+z)\mod x\) 也符合要求。于是考虑设 \(d_i\) 为最小的符合要求的 \(\mod x=i\) 的数,则符合要求的 \(\mod x=i\) 的数有这些:\(d_i,d_i+x,d_i+2x,\cdots\),因此符合要求的 \(\mod x=i\) 的数的个数为 \(\frac{h-1-d_i}x+1\)(若 \(d_i<h\))。要求解 \(d_i\),可以建立 \(x\) 个点,编号为 \(0\sim x-1\) 的有向图,连接 \(i\rightarrow(i+y)\mod x\) 与 \(i\rightarrow(i+z)\mod x\),边权分别为 \(y\) 和 \(z\),此时 \(d_i\) 就是以 \(0\) 为起点的最短路。这种转化数论问题为最短路问题的做法被称为“同余最短路”。
点击查看代码
#include <bits/stdc++.h>
using namespace std;
#define gc getchar
template<typename T> void read(T &x) {
char c = gc(); int f = 1; x = 0;
while (!isdigit(c)) f = (c == '-' ? -f : f), c = gc();
while (isdigit(c)) x = (x << 3) + (x << 1) + c - '0', c = gc();
x *= f;
}
template<typename T, typename ... Args> void read(T &x, Args& ... args) {read(x); read(args...);}
#define rep(i, s, t) for(int i=(s);i<=(t);i++)
#define per(i, s, t) for(int i=(s);i>=(t);i--)
#define ll long long
#define ull unsigned long long
#define pii pair<int,int>
#define lll __int128
template<typename T> void chkmx(T &x, const T &y) {x = max(x, y);}
template<typename T> void chkmn(T &x, const T &y) {x = min(x, y);}
const int N = 100005;
ll h, x, y, z, d[N];
bool vis[N];
vector<pair<ll, ll> > g[N];
// bool operator < (pair<ll, ll> a, pair<ll, ll> b) {return a.second > b.second;}
priority_queue<pair<ll, ll> > q;
void addedge(int u, int v, int w) {g[u].push_back({v, w});}
void dijkstra(ll t) {
for (int i = 0; i <= x; i++) d[i] = 9223372036854775807; d[t] = 0; q.push({0, t});
while (!q.empty()) {
pair<ll, ll> tp = q.top(); q.pop();
ll l = tp.first, u = tp.second;
if (vis[u]) continue; vis[u] = 1;
rep(i, 0, (ll)g[u].size() - 1) {
int v = g[u][i].first, w = g[u][i].second;
if (d[v] > d[u] + w) {
d[v] = d[u] + w;
q.push({-d[v], v});
}
}
}
}
void pd(int task_id = 0) {
read(h, x, y, z); --h;
rep(i, 0, x - 1) addedge(i, (i + y) % x, y), addedge(i, (i + z) % x, z);
dijkstra(0);
ll ans = 0;
rep(i, 0, x - 1) if (d[i] <= h) ans += 1 + (h - d[i]) / x;
printf("%lld\n", ans);
}
int main() {
pd();
return 0;
}
给定 \(n\) 个正整数,每个数可以减去 \(1\sim m\) 的整数从而变成另一个正整数。每个数都有无限个,求最大的不能用若干个数之和表示的数,或判断不存在最大值。
上一题的加强版。可以发现,“可以减去”的这个条件等价于给定 \(nm\) 个数,再删掉其中非正的。不难发现,如果这不超过 \(nm\) 个数的 gcd 不等于 \(1\),或这些数中存在 \(1\),则不存在最大值,否则存在最大值。用同余最短路求出最小的可以表示的 \(\mod x=i\) 的数 \(d_i\) 后(\(x\) 是给定数中最小的)后,最大的不能被表示的就是 \(\max(d_i-x)\)(因为如果 \(d_i-x\) 能被表示,则 \(d_i\) 应该等于 \(d_i-x\))。
点击查看代码
#include <bits/stdc++.h>
using namespace std;
#define gc getchar
template<typename T> void read(T &x) {
char c = gc(); int f = 1; x = 0;
while (!isdigit(c)) f = (c == '-' ? -f : f), c = gc();
while (isdigit(c)) x = (x << 3) + (x << 1) + c - '0', c = gc();
x *= f;
}
template<typename T, typename ... Args> void read(T &x, Args& ... args) {read(x); read(args...);}
#define rep(i, s, t) for(int i=(s);i<=(t);i++)
#define per(i, s, t) for(int i=(s);i>=(t);i--)
#define ll long long
#define ull unsigned long long
#define pii pair<int,int>
#define lll __int128
template<typename T> void chkmx(T &x, const T &y) {x = max(x, y);}
template<typename T> void chkmn(T &x, const T &y) {x = min(x, y);}
const int N = 3005;
ll n, m, l[N * N], tot = 0, gcd, dis[N], inq[N];
struct edge {ll to, w;};
const bool operator < (const edge &x, const edge &y) {return x.w > y.w;}
vector<edge> g[N];
priority_queue<edge> pq;
void addedge(int u, int v, int w) {g[u].push_back({v, w});}
void dijkstra(int t) {
rep(i, 0, l[tot]) dis[i] = 2147483647, inq[i] = 0; dis[t] = 0; pq.push({t, 0});
while (!pq.empty()) {
edge tp = pq.top(); pq.pop();
int u = tp.to, l = tp.w;
if (inq[u]) continue; inq[u] = 1;
rep(i, 0, (int)g[u].size() - 1) {
int v = g[u][i].to, w = g[u][i].w;
if (dis[v] > dis[u] + w) {
dis[v] = dis[u] + w;
pq.push({v, dis[v]});
}
}
}
}
void pd() {
read(n, m);
rep(i, 1, n) {
int u; read(u);
per(i, u, max(1ll, u - m)) if ((l[++tot] = i) == 1) {puts("-1"); return;};
}
gcd = l[1]; rep(i, 2, tot) gcd = __gcd(gcd, l[i]);
if (gcd != 1) {puts("-1"); return;}
sort(l + 1, l + tot + 1);
tot = unique(l + 1, l + tot + 1) - l - 1;
rep(i, 0, l[1] - 1) rep(j, 2, tot) addedge(i, (i + l[j]) % l[1], l[j]);
dijkstra(0);
ll ans = 0;
rep(i, 0, l[1] - 1) ans = max(ans, dis[i] - l[1]);
printf("%lld\n", ans);
return;
}
int main() {
pd();
return 0;
}
1.1.2 全源最短路
Floyd 是简单的求全源最短路算法。它的核心思想是使用 \(f_{k,i,j}\) 表示只允许经过 \(1\sim k\) 号点时,\(i\) 号点和 \(j\) 号点之间的距离。每当 \(k\) 步进,就对以 \(k\) 为中转的路径距离进行更新:具体的,有 \(f_{k,i,j}=\min(f_{k-1,i,j},f_{k-1,i,k}+f_{k-1,k,j})\)。状态中的 \(k\) 可以省略,因此它的时间复杂度是 \(\mathcal{O}(n^3)\),空间复杂度 \(\mathcal{O}(n^2)\)。值得注意的是,对于有负环的图,Floyd 不能正确处理此类情况。
for (int k = 1; k <= n; k++)
for (int i = 1; i <= n; i++)
for (int j = 1; j <= n; j++)
f[i][j] = min(f[i][j], f[i][k] + f[k][j]);
跑 \(n\) 次 Dijkstra 的时间复杂度其实在稀疏图上比 Floyd 更优,为 \(\mathcal{O}(nm\log m)\)(优先队列实现)。但 Dijkstra 无法处理有负权边的图,因此需要使用 Johnson 算法。Johnson 算法的思想是对图上的各边边权重新分配,使得每条边的边权都非负,于是就可以跑 \(n\) 次 Dijkstra 了。
考虑一条从点 \(s\) 到点 \(t\) 的最短路径 \(s\rightarrow u_1\rightarrow u_2\rightarrow\cdots\rightarrow u_k\rightarrow t\),路径的边权和是 \(\sum w\)。假如我们给每个点赋一个“势能”\(h_i\),使从 \(u\) 到 \(v\) 的边权由原来的 \(w\) 变为 \(h_u-h_v+w\),那么这条路径的边权和就变成了 \(\sum w+h_s-h_{u_1}+h_{u_1}-h_{u_2}+\cdots+h_{u_k}-h_t=\sum w+h_s-h_t\)。如果能给每个点赋的“势能”合适,使得每条边的 \(h_u-h_v+w\) 都非负,就能在用 \(n\) 次 Dijkstra 求新图全源最短路之后,对每个点对 \((u,v)\),从 \(u\) 到 \(v\) 的距离减去 \(h_u-h_v\),从而求出原图的全源最短路了。
问题变为如何赋合适的 \(h_i\)。因为需要让每条边 \(u\rightarrow v\) 的新边权 \(h_u-h_v+w\ge 0\),变形一下就是 \(h_v\le h_u+w\),这让我们想到最短路中的松弛操作。因此考虑新建一个虚拟结点 \(0\),从 \(0\) 向每个点连一条边权为 \(0\) 的边,此时从 \(0\) 到点 \(u\) 的最短路边权和(即距离)\(d_u\) 恰好满足上面的性质。因此可以以 \(0\) 为起点跑一次 Bellman-Ford(比如 SPFA),求出距离 \(d_i\),然后更新边权,再跑 \(n\) 次 Dijkstra 就解决了这个问题。时间复杂度瓶颈在 Dijkstra 上。
点击查看代码
#include <bits/stdc++.h>
using namespace std;
#define gc getchar
template<typename T> void read(T &x) {
char c = gc(); int f = 1; x = 0;
while (!isdigit(c)) f = (c == '-' ? -f : f), c = gc();
while (isdigit(c)) x = (x << 3) + (x << 1) + c - '0', c = gc();
x *= f;
}
template<typename T, typename ... Args> void read(T &x, Args& ... args) {read(x); read(args...);}
#define rep(i, s, t) for(int i=(s);i<=(t);i++)
#define per(i, s, t) for(int i=(s);i>=(t);i--)
#define ll long long
#define ull unsigned long long
#define pii pair<int,int>
#define lll __int128
template<typename T> void chkmx(T &x, const T &y) {x = max(x, y);}
template<typename T> void chkmn(T &x, const T &y) {x = min(x, y);}
const int N = 3e3 + 5, M = 6e3 + 5;
ll n, m, h[N], dis[N][N], cnt[N];
bool inq[N], vis[N];
struct edge {ll to, w;};
const bool operator < (const edge &x, const edge &y) {return x.w > y.w;}
vector<edge> g[N];
queue<ll> q;
priority_queue<edge> pq;
void addedge(int u, int v, int w) {g[u].push_back({v, w});}
bool spfa(int t) {
rep(i, 0, n) h[i] = 2147483647, inq[i] = 0; h[t] = 0; inq[t] = 1; q.push(t);
while (!q.empty()) {
int u = q.front(); q.pop(); inq[u] = 0;
rep(i, 0, (int)g[u].size() - 1) {
int v = g[u][i].to, w = g[u][i].w;
if (h[v] > h[u] + w) {
h[v] = h[u] + w; cnt[v] = cnt[u] + 1;
if (cnt[v] > n) return 1;
if (!inq[v]) q.push(v), inq[v] = 1;
}
}
}
return 0;
}
void dijkstra(int t) {
rep(i, 1, n) dis[t][i] = 2147483647, inq[i] = 0; dis[t][t] = 0; pq.push({t, 0});
while (!pq.empty()) {
edge tp = pq.top(); pq.pop();
int u = tp.to, l = tp.w;
if (inq[u]) continue; inq[u] = 1;
rep(i, 0, (int)g[u].size() - 1) {
int v = g[u][i].to, w = g[u][i].w;
if (dis[t][v] > dis[t][u] + w) {
dis[t][v] = dis[t][u] + w;
pq.push({v, dis[t][v]});
}
}
}
}
void pd() {
read(n, m);
rep(i, 1, m) {
int u, v, w;
read(u, v, w);
addedge(u, v, w);
}
rep(i, 1, n) addedge(0, i, 0);
if (spfa(0)) {puts("-1"); return;}
rep(i, 1, n) rep(j, 0, (int)g[i].size() - 1) g[i][j].w += h[i] - h[g[i][j].to];
rep(i, 1, n) dijkstra(i);
rep(i, 1, n) {
ll ans = 0;
rep(j, 1, n) {
dis[i][j] = dis[i][j] - h[i] + h[j];
if (dis[i][j] > 1000000000) dis[i][j] = 1000000000;
ans += 1ll * j * dis[i][j];
}
printf("%lld\n", ans);
}
return;
}
int main() {
pd();
return 0;
}
1.2 最小生成树(MST)
无向图上的最小生成树是边权和最小的生成树。常见的求解算法是 Kruskal 算法和 Prim 算法。
Kruskal 算法的核心思想是贪心:将图上的边按边权从小到大排序。在尝试向生成树上加入边时,考察这条边是否连接了两个连通块:若是,则加入这条边;若不是,则不加入这条边。维护图上两点间的连通关系可以使用并查集,详见下一节。容易证明这个算法是对的。
void kruskal() {
int tot = 0;
int ans = 0;
for (int i = 1; i <= m; i++) {
int xr = findroot(g[i].u), yr = findroot(g[i].v);
if (xr != yr) {
Merge(xr, yr);
tot++;
ans += g[i].w;
}
if (tot >= (n - 1)) {
cout << ans << '\n';
return;
}
}
cout << "orz\n";
}
Prim 算法的核心也是贪心:维护能通过一条边到达已加入 MST 的点的点集,每次从点集中选择距离已选择 MST 距离最小的点加入 MST。维护点集可以使用类似 Dijkstra 堆优化的方法,但稀疏图上 Prim 算法的时间复杂度劣于 Kruskal。所以一般情况下都使用 Kruskal。
每个点有点权 \(h_i\),只能从 \(i\) 到达 \(h_j\le h_i\) 的点 \(j\),求以 \(1\) 为起点,能移动到的点组成的最小生成树(组成的边必须在以 \(1\) 为起点时能够通过)。
这道题需要按照点权建立有向图,建图后写 Kruskal 和 Prim 都可以。需要注意的是,对边或点排序时,应将边的终点高度作为第一关键字,边权作为第二关键字排序。这是为了让高度更高的点先连边。示例代码使用了 Prim。
点击查看代码
#include <bits/stdc++.h>
using namespace std;
#define gc getchar
template<typename T> void read(T &x) {
char c = gc(); int f = 1; x = 0;
while (!isdigit(c)) f = (c == '-' ? -f : f), c = gc();
while (isdigit(c)) x = (x << 3) + (x << 1) + c - '0', c = gc();
x *= f;
}
template<typename T, typename ... Args> void read(T &x, Args& ... args) {read(x); read(args...);}
#define rep(i, s, t) for(int i=(s);i<=(t);i++)
#define per(i, s, t) for(int i=(s);i>=(t);i--)
#define ll long long
#define ull unsigned long long
#define pii pair<int,int>
#define lll __int128
template<typename T> void chkmx(T &x, const T &y) {x = max(x, y);}
template<typename T> void chkmn(T &x, const T &y) {x = min(x, y);}
const int N = 200005;
ll n, m, h[N], dis[N], cnt = 0, len = 0;
bool vis[N];
struct edge {ll to, w;};
vector<edge> g[N];
bool operator < (const edge &x, const edge &y) {return h[x.to] != h[y.to]? h[x.to] < h[y.to] : x.w > y.w;}
priority_queue<edge> pq;
void addedge(ll u, ll v, ll w) {g[u].push_back({v, w});}
void prim(ll t) {
rep(i, 1, n) dis[i] = 0x7f7f7f7f7f7f7f7f; dis[t] = 0;
pq.push({t, 0});
while (!pq.empty()) {
auto [u, l] = pq.top(); pq.pop();
if (vis[u]) continue; vis[u] = 1;
len += l; cnt++;
rep(i, 0, (int)g[u].size() - 1) {
if (dis[g[u][i].to] > g[u][i].w) {
dis[g[u][i].to] = g[u][i].w;
pq.push(g[u][i]);
}
}
}
}
void pd() {
read(n, m); rep(i, 1, n) read(h[i]);
rep(i, 1, m) {
ll u, v, w;
read(u, v, w);
if (h[u] == h[v]) addedge(u, v, w), addedge(v, u, w);
else {if (h[u] < h[v]) swap(u, v); addedge(u, v, w);}
}
prim(1);
printf("%lld %lld\n", cnt, len);
return;
}
int main() {
pd();
return 0;
}
1.3 并查集
并查集被用于维护元素所属集合。顾名思义,并查集支持两种操作:
- 合并两个集合
- 查询某元素所属的集合
由于并查集的结构类似于森林,而且求 MST 的 Kruskal 算法使用并查集,这里将它归类为图论算法。
并查集的核心思想是,将集合用树形结构表示,每个元素为一个结点,维护每个结点的“父节点”,通过查询树根的方法查询某一元素所属的集合,通过将代表某集合的树的根连接到另一个集合的树根上的方法合并集合。
int getfa(int x) {return x == fa[x] ? x : getfa(fa[x]);}
void merge(int x, int y) {
x = getfa(x), y = getfa(y);
if (x == y) continue;
fa[x] = y;
}
显然这样的时间复杂度过高。因此可以考虑路径压缩和启发式合并。
路径压缩,就是在查询某一元素所属的集合时,直接将这个元素所在结点连接到树根上。这样,下次查询时就能省去从下往上访问树的时间。路径压缩后,并查集将不支持撤销合并。
int getfa(int x) {return x == fa[x] ? x : fa[x] = getfa(fa[x]);}
启发式合并,就是将结点较少或深度较小的树合并到另一棵树上,而不是相反。通常情况下,节点较少与深度较小这两个标准中选择其中一个即可。下面以节点较少为例实现:
void merge(int x, int y) {
x = getfa(x), y = getfa(y);
if (x == y) return;
if (siz[x] > siz[y]) swap(x, y);
fa[x] = y;
siz[y] += siz[x];
}
同时使用路径压缩和启发式合并时,每次操作的平均时间复杂度为 \(\mathcal{O}(\alpha(m,n))\),可以被视为很小的常数;如果只使用路径压缩,单次操作最坏时间复杂度为 \(\mathcal{O}(n)\),平均时间复杂度为 \(\mathcal{O}(\alpha(m,n))\);如果只使用启发式合并,则时间复杂度为 \(\mathcal{O}(\log n)\)。由于路径压缩会破坏元素之间的上下级关系,在可持久化等场景下不适用路径压缩,仅适用启发式合并。
给定无向图和 \(s,t\) 两点,求 \(s\) 到 \(t\) 的所有路径中,最长边与最短边长度之比最小的可能值,或报告不连通。
将所有边按长度从小到大排序,枚举一条边作为最短边,不断尝试将更长的边加入,直到 \(s,t\) 连通。连通性可以用并查集维护。时间复杂度 \(\mathcal{O}(m^2)\)。
点击查看代码
#include <bits/stdc++.h>
using namespace std;
#define gc getchar
template<typename T> void read(T &x) {
char c = gc(); int f = 1; x = 0;
while (!isdigit(c)) f = (c == '-' ? -f : f), c = gc();
while (isdigit(c)) x = (x << 3) + (x << 1) + c - '0', c = gc();
x *= f;
}
template<typename T, typename ... Args> void read(T &x, Args& ... args) {read(x); read(args...);}
#define rep(i, s, t) for(int i=(s);i<=(t);i++)
#define per(i, s, t) for(int i=(s);i>=(t);i--)
#define ll long long
#define ull unsigned long long
#define pii pair<int,int>
#define lll __int128
template<typename T> void chkmx(T &x, const T &y) {x = max(x, y);}
template<typename T> void chkmn(T &x, const T &y) {x = min(x, y);}
const int N = 5005;
int n, m, s, t, fa[N];
void init() {
rep(i, 1, n) fa[i] = i;
}
int getfa(int x) {return x == fa[x] ? x : fa[x] = getfa(fa[x]);}
void merge(int x, int y) {
x = getfa(x), y = getfa(y);
if (x == y) return;
fa[x] = y;
}
struct edge {
int u, v, w;
} e[N];
int main() {
read(n, m);
rep(i, 1, m) {
int u, v, w;
read(u, v, w);
e[i] = {u, v, w};
}
read(s, t);
sort(e + 1, e + m + 1, [](const edge &a, const edge &b) {return a.w < b.w;});
double ans = 1e9 + 7; int a = 0, b = -1;
rep(i, 1, m) {
init();
rep(j, i, m) {
merge(e[j].u, e[j].v);
if (getfa(s) == getfa(t)) {
if ((double)e[j].w / e[i].w < ans) {
ans = (double)e[j].w / e[i].w;
a = e[j].w;
b = e[i].w;
break;
}
}
}
}
if (b == -1) {puts("IMPOSSIBLE"); return 0;}
int g = __gcd(a, b);
a /= g; b /= g;
if (b == 1) printf("%d\n", a);
else printf("%d/%d\n", a, b);
return 0;
}
1.4 Tarjan
Tarjan 是一类用处广泛的算法,能够解决强联通分量、边/点双连通分量、割点、割边(桥)等问题。
1.4.1 强连通分量(SCC)
一个图 G 强连通是指 G 中任意两点连通。强连通分量是极大的强连通子图。
在有向图上 DFS 时,
- 通过一条边到达此前未访问结点,则此边为树边。所有树边会构成以起点为根的搜索树。
- 一条边从搜索树的子结点指向祖先结点,则此边为返祖边。
- 一条边从搜索树的祖先结点连向子结点,则此边为前向边。
- 一条边从搜索树上的一个结点连向非祖先结点、非子结点的结点,则此边为横叉边。
如果一个点 \(u\) 是其所在 SCC 中第一个被访问到的节点,则 SCC 中其余节点在 \(u\) 子树内。这似乎是废话,但还是有必要进行证明:假设存在一个节点 \(v\) 使其不在 \(u\) 所在的子树内,则存在一条从 \(u\) 子树连向外界的边,以便 \(u\) 连通到 \(v\)。而这种边不能是树边或前向边(否则 \(v\) 在 \(u\) 的子树内),也不能是横叉边或反祖边(否则 \(v\) 需要比 \(u\) 先访问到,与前设矛盾)。因此这样的 \(v\) 不存在。
因此考虑在 DFS 时维护访问次序。考虑设 \(dfn_u\) 表示 DFS 时 \(u\) 是第几个被访问的点,\(low_u\) 表示通过 \(u\) 子树内树边和至多一条非树边连接的各点 \(dfn\) 之最小值。不难发现,各点 \(low\) 小于等于这点 \(dfn\)。若某个点的 \(low\) 和它的 \(dfn\) 相等,则说明这个点是它所在 SCC 中 \(dfn\) 最小的那个。若某个点的 \(low\) 小于该点 \(dfn\),则说明这个点能通过一条返祖边或横叉边连通比它先 DFS 到的某个结点。如果是第一种情况,则这个点不是它所在 SCC 中编号最小的,因为存在祖先与它共 SCC;如果是第二种情况,则这个点不一定是它所在 SCC 中编号最小的,因为它通过横叉边连接到的点不一定能连回到它。
为了解决这个问题,需要对 \(low\) 的定义做一些修改。考虑用栈维护搜索到的结点,但每找全一个 SCC,就把这个 SCC 中的所有结点弹出,这个操作当且仅当 \(dfn_u=low_u\) 时进行。此时,将 \(low\) 的定义修改为:通过 \(u\) 子树内树边,和至多一条非树边连接的暂未确定 SCC 归属的各点 \(dfn\) 之最小值。这样,如果某个点 \(u\) 通过横叉边连接到不在该点子树内的点 \(v\),但已经确认 \(v\) 归属的 SCC 从而使 \(v\) 出栈,则 \(u\) 与 \(v\) 不可能同属一个 SCC(否则 \(u\) 应当在 \(v\) 所在 SCC 中 \(dfn\) 最小的点的子树内,与 \(v\) 一起被出栈),不应当更新 \(low_u\)。于是,\(low\) 相同的结点必然属于同一个 SCC。
这样,我们就确认了计算 \(dfn\) 和 \(low\) 的方法:
func dfs(u):
low[u], dfn[u] <- ++tot
insert u into stack;
for v in neighbors of u:
if v do not have dfn:
// v has not been searched yet
dfs(v)
low[u] <- min(low[u], low[v])
else if v is in stack:
// v is in u's SCC
low[u] <- min(low[u], dfn[v])
else:
// v is not in u's SCC
do nothing
if dfn[u] = low[u]:
// find SCC, u is the top node
while stask is not empty:
now <- stack top
stack pop
if now = u: break
不难发现,如果将同一 SCC 内的结点合并成一个大点(这个操作被称为“缩点”),则缩点后形成的新图必然是一个有向无环图(DAG),因为如果不是 DAG 则存在环,而环内会形成新的 SCC,与前设矛盾。这是一个很好的性质。
给定有向图,求:1. 最大 SCC 的结点数;2. 最小的使原图强连通的加边数。
第一问在求解 SCC 时可以一同解决。对于第二问,考虑缩点,形成一个 DAG。此时 DAG 中有一些点入度为 \(0\),成为 A 类点;一些点出度为 \(0\),成为 B 类点。为了让原图强连通,需要从 B 类点引出新边,连到 A 类点上。如果 A、B 类点数相等,则新边恰好需要点数条;反之,则多出一些 A 类或 B 类点,需要额外的边才能把这些点加入 SCC。因此,第二问的答案是缩点后 DAG 中 A 类、B 类点数量的较大值。
点击查看代码
#include <bits/stdc++.h>
using namespace std;
#define gc getchar
template<typename T> void read(T &x) {
char c = gc(); int f = 1; x = 0;
while (!isdigit(c)) f = (c == '-' ? -f : f), c = gc();
while (isdigit(c)) x = (x << 3) + (x << 1) + c - '0', c = gc();
x *= f;
}
template<typename T, typename ... Args> void read(T &x, Args& ... args) {read(x); read(args...);}
#define rep(i, s, t) for(int i=(s);i<=(t);i++)
#define per(i, s, t) for(int i=(s);i>=(t);i--)
#define ll long long
#define ull unsigned long long
#define pii pair<int,int>
#define lll __int128
template<typename T> void chkmx(T &x, const T &y) {x = max(x, y);}
template<typename T> void chkmn(T &x, const T &y) {x = min(x, y);}
const int N = 100005;
int n, m, dfn[N], low[N], ins[N], num[N], du[N], tot = 0, scccnt;
vector<int> g[N], scc[N], G[N];
stack<int> s;
void tarjan(int u) {
dfn[u] = low[u] = ++tot;
s.push(u); ins[u] = 1;
rep(i, 0, (int)g[u].size() - 1) {
int v = g[u][i];
if (!dfn[v]) {
tarjan(v);
low[u] = min(low[u], low[v]);
} else if (ins[v]) low[u] = min(low[u], dfn[v]);
}
if (dfn[u] == low[u]) {
scccnt++;
while (!s.empty()) {
int now = s.top();
s.pop();
scc[scccnt].push_back(now);
num[now] = scccnt;
ins[now] = 0;
if (now == u) break;
}
}
}
int main() {
read(n, m);
rep(i, 1, m) {int u, v; read(u, v); g[u].push_back(v);}
rep(i, 1, n) if (!dfn[i]) tarjan(i);
int ans = 0;
rep(i, 1, scccnt) ans = max(ans, (int)scc[i].size());
rep(u, 1, n) {
rep(j, 0, (int)g[u].size() - 1) {
int v = g[u][j];
if (num[u] != num[v]) {
G[num[u]].push_back(num[v]);
du[num[v]]++;
}
}
}
int ans1 = 0, ans2 = 0;
rep(i, 1, scccnt) ans1 += (G[i].size() == 0), ans2 += (du[i] == 0);
printf("%d\n%d\n", ans, (scccnt == 1) ? 0 : max(ans1, ans2));
return 0;
}
1.4.2 边双连通分量(E-BCC)、割边(桥)
一个图 G 边双连通是指 G 中去掉任意一条边后 G 仍然连通。边双连通分量是极大的边双连通子图。边双连通关系具有传递性。如果删掉一条边后图中的连通分量数增加了,则称这条边为割边(亦称桥)。因此,无向图上割边一定连接两个不同的 E-BCC。
在无向图上 DFS 时,
- 通过一条边到达此前未访问结点,则此边为树边。所有树边会构成以起点为根的搜索树。
- 通过一条边到达此前已访问结点,则此边为非树边。
根据割边的定义,如果在 DFS 时发现某个点不能通过 DFS 树上该点子树内的树边和至多一条非树边返回它的祖先,则这个点与它的父结点相连的这条边必然是割边,且割边必然满足上述性质。因此,可以参考求解强连通分量时的 \(dfn\) 和 \(low\) 进行定义。
这里仍然设 \(dfn_u\) 表示 DFS 时 \(u\) 是第几个被访问的点,\(low_u\) 改为通过 \(u\) 子树内树边,和至多一条非树边连接的暂未确定 E-BCC 归属的各点 \(dfn\) 之最小值。
非树边连接的两个结点必然存在祖先——后代关系,这可以通过 DFS 的性质证明。由此,计算 \(low\) 时不再需要判断边连向的点是否在栈内。但如果直接套用求解强连通分量的代码,会出现问题:因为实现时通常用两条方向相反的有向边存储无向边,这会导致代码认为每条边连接的两个点都成环。为了解决这种情况,可以在建图时为每条边赋唯一编号,在运行 Tarjan 算法时判断当前访问的边是否是从父结点连向自己的那一条。需要注意的是,不能用判断连向的是否是父结点的方法判断,因为如果图上存在重边则这样计算可能导致错误。
而求 E-BCC 时,可以顺便求出图上的所有割边:如果某个点的 \(low\) 值大于它父结点的 \(dfn\),说明这个点所在 DFS 子树内没有连向自己祖先的边,因此这个点与它的父结点分属两个 E-BCC。
func dfs(u, pre_edge):
low[u], dfn[u] <- ++tot
insert u into stack;
for e in edges of u:
if e is pre_edge: continue
v <- destination of e
if v do not have dfn:
// v has not been searched yet
dfs(v, e)
low[u] <- min(low[u], low[v])
if low[v] > dfn[u]:
// e is a bridge
insert e into bridge_list
else:
low[u] <- min(low[u], dfn[v])
if dfn[u] = low[u]:
// find BCC, u is the top node
while stask is not empty:
now <- stack top
stack pop
if now = u: break
不难发现,如果将连通图上的 E-BCC 缩点,则缩点后形成的新图必然是一棵树,因为如果不是树则存在环,而环内会形成新的 E-BCC,与前设矛盾。
给定无向图,要求选择一些点和一些边,要求删除图中的任意一条边(如果边被选择则不会真正删除)后被选择的点之间仍然连通。求选择方案数模 \(10^9+7\)。
由于边双连通分量缩点后原图变成一棵树,树上每个结点内部的边可以自由选择,因此最后答案乘 \(2^{内部边数}\)。
设 \(dp_u\) 表示 \(u\) 的子树内选择至少一个点,且选择的点与 \(u\) 连通的方案数。
首先,\(u\) 子树可以除了 \(u\) 自己以外全不选,因此 \(dp_u\) 初始化为 \(1\)。
考虑 \(u\) 的一个儿子 \(v\)。
- 如果 \(v\) 的子树中有点被选择,则贡献为 \(dp_v\)。
- 如果 \(v\) 的子树中没有点被选择,则 \(v\) 子树内的边和 \((u, v)\) 边可以随便选,贡献为 \(2^{v 子树边数}\)。
因此,有 \(dp_u = \sum\limits_{v \in son(u)}(dp_v + 2^{v 子树边数})\)。
由于 \(u\) 表示的边双连通分量中的点可以任选,因此 \(dp_u\) 要乘 \(2^{u 代表的 BCC 点数}\)。
最后,因为 \(u\) 子树内可能没有选点,因此 $dp_u 要减去 \(2^{u 子树边数}\)。
统计答案时,假设 \(u\) 是所有选择点的 LCA。因为 \(dp_u\) 包含 \(u\) 不是 LCA 的情况,因此需要去除这些情况。仍考虑 \(u\) 的一个儿子 \(v\),假如所有选点在 \(v\) 子树内,则除了 \(v\) 子树内的边和 \((u, v)\) 边不能动外其他边都可以任选,于是 \(dp_u\) 应当减去 \(dp_v \times 2^{树上总边数 - v 子树内边数 - 1}\)。
这道题给出的图无重边、无自环,所以在 Tarjan 内部可以不检查边编号,只检查其指向。
点击查看代码
#include <bits/stdc++.h>
using namespace std;
#define gc getchar
template<typename T> void read(T &x) {
char c = gc(); int f = 1; x = 0;
while (!isdigit(c)) f = (c == '-' ? -f : f), c = gc();
while (isdigit(c)) x = (x << 3) + (x << 1) + c - '0', c = gc();
x *= f;
}
template<typename T, typename ... Args> void read(T &x, Args& ... args) {read(x); read(args...);}
#define rep(i, s, t) for(int i=(s);i<=(t);i++)
#define per(i, s, t) for(int i=(s);i>=(t);i--)
#define ll long long
#define ull unsigned long long
#define pii pair<int,int>
#define lll __int128
template<typename T> void chkmx(T &x, const T &y) {x = max(x, y);}
template<typename T> void chkmn(T &x, const T &y) {x = min(x, y);}
const int N = 1e6 + 5, mod = 1e9 + 7;
int n, m, dfn[N], low[N], bcccnt = 0, tot = 0, bccno[N], siz[N];
int ednum[N], mul[N], dp[N], lnk[N], ans = 0, nb = 0;
bool ins[N];
stack<int> s;
vector<int> g[N], ng[N];
vector<pii> edge;
void tarjan(int u, int fa = 0) {
dfn[u] = low[u] = ++tot;
s.push(u); ins[u] = 1;
for (auto v : g[u]) {
if (v == fa) continue;
if (!dfn[v]) {
tarjan(v, u);
low[u] = min(low[u], low[v]);
} else low[u] = min(low[u], dfn[v]);
}
if (dfn[u] == low[u]) {
bcccnt++;
while (!s.empty()) {
int now = s.top();
s.pop();
siz[bcccnt]++;
ins[now] = false;
bccno[now] = bcccnt;
if (now == u) break;
}
}
}
void init(int u, int fa = 0) {
lnk[u] = 0;
for (auto v : ng[u]) {
if (v == fa) continue;
init(v, u);
lnk[u] += lnk[v] + 1;
}
}
void dfs(int u, int fa = 0) {
dp[u] = 1;
for (auto v : ng[u]) {
if (v == fa) continue;
dfs(v, u);
dp[u] = (1ll * dp[v] + mul[lnk[v] + 1]) % mod * dp[u] % mod;
}
dp[u] = 1ll * dp[u] * mul[siz[u]] % mod;
dp[u] = (1ll * dp[u] + mod - mul[lnk[u]]) % mod;
}
void pd(int u, int fa = 0) {
int tans = dp[u];
for (auto v : ng[u]) {
if (v == fa) continue;
pd(v, u);
int tmp = 1ll * dp[v] * mul[lnk[u] - lnk[v] - 1] % mod;
tans = (1ll * tans - tmp + mod) % mod;
}
tans = 1ll * tans * mul[m - nb - lnk[u]] % mod;
ans = (1ll * ans + tans) % mod;
}
int main() {
mul[0] = 1; rep(i, 1, N - 1) mul[i] = mul[i - 1] * 2ll % mod;
read(n, m);
rep(i, 1, m) {int u, v; read(u, v); g[u].push_back(v); g[v].push_back(u); edge.push_back({u, v});}
tarjan(1);
rep(i, 0, m - 1) {
auto [u, v] = edge[i];
if (bccno[u] == bccno[v]) ednum[bccno[u]]++, nb++;
else ng[bccno[u]].push_back(bccno[v]), ng[bccno[v]].push_back(bccno[u]);
}
init(1);
dfs(1);
pd(1);
printf("%d\n", (int)(1ll * ans * mul[nb] % mod));
return 0;
}
1.4.3 点双连通分量(V-BCC)、割点
一个图 G 点双连通是指 G 中去掉任意一个点后 G 仍然连通。点双连通分量是极大的点双连通子图。点双连通关系不具有传递性。如果删掉一个点后图中的连通块增多,则称这个点为割点。
结点数 \(\ge 3\) 的点双连通图一定是边双连通的,反之则不然。求解 V-BCC 在形式上和 E-BCC 有较大不同。
这里继续沿用求 E-BCC 的思路,不同的是这次先求割点、再划分 BCC。不难发现,如果某个点的 \(low\) 值 \(\ge\)(不同于割边的 \(>\))它父结点的 \(dfn\),则父结点是一个割点。
应当注意到,割点同时属于多个 V-BCC。因此,弹栈时不应将当前 DFS 到的点弹出,而是在访问这个点的祖先时弹出它。由于点双连通关系不具有传递性,每当找到一个割点时就应将子树内的所有点(除割点外)全部弹出,而不是遍历完割点下方的所有点后再操作。此外,当前搜索树的树根是割点,当且仅当搜索树上树根有多于一个子结点,因此对于树根应当进行特判。
重边不影响割点的判定。因此,DFS 时不需要检查下一个点是否是父结点。
func dfs(u):
low[u], dfn[u] <- ++tot
insert u into stack;
if u is root and u has no son:
u itself is a V-BCC
return
for v in neighbors of u:
if v do not have dfn:
dfs(v)
low[u] <- min(low[u], low[v])
if low[v] >= dfn[u]:
// ufind V-BCC, u is the top node or/and cut vertex
if u is not root or u has >= 2 sons: u is a cut vertex
while stask is not empty:
now <- stack top
stack pop
if now = v: break
else:
low[u] <- min(low[u], dfn[v])
1.5 二分图
二分图是一类特殊的图,其特征是顶点可以恰好划分为两个子集,使得每条边连接的两个点不属于同一个子集。二分图具有许多优秀的性质,例如:
- 二分图可以用至多 \(2\) 种颜色染色,使得同一条边连接的两个点颜色不同。
- 二分图上没有奇环。
因此,判断一个图是否是二分图,可以用染色法较为简单的解决。
1.5.1 二分图染色
二分图染色就是用 BFS 或 DFS 为一张图的每个点染色,并尝试让相邻两点的颜色不同。一旦发现颜色冲突则判定不是二分图,没有颜色冲突则是二分图。如果染色成功,则同种颜色的点属于二分图中的同一个子集。
给定一个长为 \(n\) 的排列 \(a\),要求通过以下四种操作使得 \(a\) 升序,并给出字典序最小的操作序列,或判断无解:a. \(a\) 的第一个元素入栈 \(1\);b. 栈 \(1\) 的第一个元素出栈;c. \(a\) 的第一个元素入栈 \(2\);d. 栈 \(2\) 的第一个元素出栈;
两个元素 \(a_i,a_j(i<j)\) 不能进入同一个栈,当且仅当存在 \(k>j\) 使得 \(a_k<a_i<a_j\)。因此,可以 \(\mathcal{O}(n^2)\) 地预处理每对元素能否进入同一个栈。一个排列是可排序的,当且仅当可以把排列中的元素恰好划分为两个子集,使得子集内没有不能进入同一个栈的元素对,从而使两个子集中的元素分别进入两个不同的栈并分别排序,再合并。这等价于按不能进入同一个栈的关系连边后形成一个二分图,故连边后进行二分图染色以划分子集,再确认操作序列。最小化操作序列是贪心的。
点击查看代码
#include <bits/stdc++.h>
using namespace std;
#define gc getchar
template<typename T> void read(T &x) {
char c = gc(); int f = 1; x = 0;
while (!isdigit(c)) f = (c == '-' ? -f : f), c = gc();
while (isdigit(c)) x = (x << 3) + (x << 1) + c - '0', c = gc();
x *= f;
}
template<typename T, typename ... Args> void read(T &x, Args& ... args) {read(x); read(args...);}
#define rep(i, s, t) for(int i=(s);i<=(t);i++)
#define per(i, s, t) for(int i=(s);i>=(t);i--)
#define ll long long
#define ull unsigned long long
#define pii pair<int,int>
#define lll __int128
template<typename T> void chkmx(T &x, const T &y) {x = max(x, y);}
template<typename T> void chkmn(T &x, const T &y) {x = min(x, y);}
const int N = 1005;
int n, a[N], rmn[N], col[N];
bool can[N][N], ok = 1;
vector<int> g[N];
void dfs(int u, int c) {
if (!ok) return;
col[u] = c;
for (auto v : g[u]) {
if (col[v]) {
if (col[v] != c) continue;
else {ok = 0; break;}
}
dfs(v, c ^ 3);
}
}
stack<int> s[2];
int main() {
read(n);
rep(i, 1, n) read(a[i]);
rmn[n] = 0x3f3f3f3f; per(i, n - 1, 1) rmn[i] = min(rmn[i + 1], a[i + 1]);
rep(i, 1, n) {
can[i][i] = 1;
rep(j, i + 1, n) {
can[i][j] = can[j][i] = (a[i] > a[j]) || (rmn[j] > a[i]);
}
}
rep(i, 1, n) rep(j, 1, n) if (!can[i][j]) g[i].push_back(j), g[j].push_back(i);
rep(i, 1, n) if (!col[i]) dfs(i, 1);
if (!ok) {puts("0"); return 0;}
int lst = 0;
vector<char> op;
rep(i, 1, n) {
if (col[i] == 1) {
while (!s[0].empty() && s[0].top() == lst + 1) {
lst = s[0].top();
s[0].pop();
op.push_back('b');
}
if (!s[0].empty() && s[0].top() < a[i]) {
while (!s[1].empty() && s[1].top() == lst + 1) {
lst = s[1].top();
s[1].pop();
op.push_back('d');
}
while (!s[0].empty() && s[0].top() == lst + 1) {
lst = s[0].top();
s[0].pop();
op.push_back('b');
}
}
s[0].push(a[i]);
op.push_back('a');
while (!s[0].empty() && s[0].top() == lst + 1) {
lst = s[0].top();
s[0].pop();
op.push_back('b');
}
} else {
while (!s[0].empty() && s[0].top() == lst + 1) {
lst = s[0].top();
s[0].pop();
op.push_back('b');
}
while (!s[1].empty() && s[1].top() == lst + 1) {
lst = s[1].top();
s[1].pop();
op.push_back('d');
}
s[1].push(a[i]);
op.push_back('c');
while (!s[1].empty() && s[1].top() == lst + 1) {
lst = s[1].top();
s[1].pop();
op.push_back('d');
}
}
}
while (!s[0].empty() && !s[1].empty()) {
if (s[0].top() < s[1].top()) {
s[0].pop();
op.push_back('b');
} else {
s[1].pop();
op.push_back('d');
}
}
while (!s[0].empty()) {
s[0].pop();
op.push_back('b');
}
while (!s[1].empty()) {
s[1].pop();
op.push_back('d');
}
bool flag;
pt: flag = false;
per(i, (int)op.size() - 1, 1) {
if (op[i] == 'a' && op[i - 1] == 'd') swap(op[i - 1], op[i]), flag = true;
if (op[i] == 'b' && op[i - 1] == 'c') swap(op[i - 1], op[i]), flag = true;
}
if (flag) goto pt;
rep(i, 0, (int)op.size() - 1) putchar(op[i]), putchar(" \n"[i == op.size() - 1]);
putchar('\n');
return 0;
}
1.5.2 二分图匹配
注:网络许多公开资料对此处描述混乱,笔者可能有理解错误,如有请批评指正。
匹配,是一张图中没有重复顶点的边的子集。边数最多的匹配叫最大匹配。加权图中边权和最大的匹配叫最大权匹配。图中每个顶点都在匹配里的叫完美匹配。
求最大匹配的核心思想是找“增广路”,也就是始于非匹配点、终与非匹配点,且匹配边与非匹配边交错出现的路径。显然增广路上除了两端都是匹配点。因为增广路上非匹配边比匹配边多一条,而翻转增广路(即匹配边和非匹配边互换)后匹配仍然合法(因为这样做不会改变增广路上匹配点连边的条数,且将两端的非匹配点改为匹配点后连边数一定为 \(1\)),所以这样做一定能扩大匹配,这种操作叫做“增广”。Berge 引理告诉我们,当不存在增广路时,匹配已经变得最大。
一种求二分图最大匹配的算法是 Kuhn 算法,在 OI-Wiki 上它被描述为匈牙利算法的一部分。它能以 \(\mathcal{O}(nm)\) 的时间复杂度找到最大匹配。这个算法的思想是这样的:考虑所有从左部点出发的增广路,将所有非匹配边的方向设为从左到右,匹配边的方向设为从右到左。从左部点中的某个非匹配点跑一遍 BFS 或 DFS,就能找到一条增广路。因为一次 BFS 或 DFS 的时间复杂度为 \(\mathcal{O}(m)\),每个左部点最多跑一次,所以总时间复杂度是 $\mathcal{O}(nm)。
此外,还有另一种对匈牙利算法的解释:对于每个左部点,尝试为其寻找一个未被占用的右部点,或对一个右部点占用的左部点进行调整使得它被释放出来。这两种解释应该是等价的。
一种常见的实现如下:
int now;
bool find(int u) {
for (auto v : g[u]) {
if (num[v] != now) { // num 为时间戳,表示这个点在这一轮 DFS 中被占用
num[v] = now;
if (!match[v] || find(match[v])) {
match[v] = u;
return true;
}
}
}
return false;
}
int main() {
...
int ans = 0;
for (int i = 1; i <= n; i++) { // n 为左部点数量
now = i;
if (find(i)) ans++;
}
printf("%d\n", ans);
}
给定 \(n\) 个物品,每个物品有两个属性 \(a_i\) 和 \(b_i\)。要求选出最多的物品,使得存在一种选出的物品的序列,满足序列中第 \(i\) 个物品的两个属性中有一个是 \(i\)。
注:在这道题的洛谷题解中有一种并查集求法,有兴趣的读者可以研究。
把属性放在左边,物品放在右边,物品和它拥有的两个属性连边,形成一个二分图。左部点从小到大尝试增广,一旦发现某个点无法增广则说明找到答案。
点击查看代码
#include <bits/stdc++.h>
using namespace std;
#define gc getchar
template<typename T> void read(T &x) {
char c = gc(); int f = 1; x = 0;
while (!isdigit(c)) f = (c == '-' ? -f : f), c = gc();
while (isdigit(c)) x = (x << 3) + (x << 1) + c - '0', c = gc();
x *= f;
}
template<typename T, typename ... Args> void read(T &x, Args& ... args) {read(x); read(args...);}
#define rep(i, s, t) for(int i=(s);i<=(t);i++)
#define per(i, s, t) for(int i=(s);i>=(t);i--)
#define ll long long
#define ull unsigned long long
#define pii pair<int,int>
#define lll __int128
template<typename T> void chkmx(T &x, const T &y) {x = max(x, y);}
template<typename T> void chkmn(T &x, const T &y) {x = min(x, y);}
const int N = 1000005;
int n, now, num[N], match[N];
vector<int> g[N];
bool find(int u) {
for (auto v : g[u]) {
if (num[v] != now) {
num[v] = now;
if (!match[v] || find(match[v])) {
match[v] = u;
return true;
}
}
}
return false;
}
int main() {
read(n);
rep(i, 1, n) {
int a, b;
read(a, b);
g[a].push_back(i); g[b].push_back(i);
}
for (int i = 1; i <= n; i++) {
now = i;
if (!find(i)) {printf("%d\n", i - 1); return 0;}
}
printf("%d\n", n);
return 0;
}
1.6 拓扑排序
拓扑排序是一种用于解决有向无环图(DAG)上计数问题的算法,因许多问题中条件的依赖关系能够转化为 DAG 而很有用。常用的拓扑排序算法是 Kahn 算法。它是核心是每轮查找时处理入度为 \(0\) 的所有点,处理沿这些点出发的边对指向点的数据。下面是一种常见的写法:
func topo():
create a queue named q
for u in nodes:
if no edge points u: push u into q
while q is not empty:
let u <- front element of q
pop front element of q
for e in edges of u:
let v <- destination of e
do something for u and v
delete e
if no edge points v: push v into q
给定 \(n,m,q\),和长度为 \(n\) 的序列 \(a\)。有 \(m\) 种操作,分为三类:1. 单点加;2. 全局乘;3. 按一定顺序执行一些操作(保证不直接或间接执行自己)。现在要对 \(a\) 进行 \(q\) 次操作,每次的操作已给出,求操作完毕后 \(a\) 的每个数是什么。
这道题乍一看可以使用线段树,但试试就会发现线段树完全不可做。现在考察某个元素 \(x\),假设它被按顺序执行以下操作:\(+a,\times b, +c, \times d\),则它会变成 \(((x+a)\times b+c)\times d=bdx+abd+cd\)。如果把最初的 \(x\) 视为对 \(0\) 的一次 \(+x\),则每次乘法操作相当于把前面的加法操作数乘上自己。
由此,可以得到一种没有操作 3 的计算方法:把所有操作按相反顺序进行,同时记录一个加法系数。每遇到一次乘法,则系数乘以操作数;每遇到一次加法,则给相应数加上操作数与系数的乘积。但这样计算还是过于复杂。考虑到,假如执行操作 \(+a\) 时系数为 \(b\),执行 \(+ab\) 等价于 \(+a\) 被执行 \(b\) 次。因此,可以用这种方法将所有操作 \(2\) 转化为操作 \(1\) 的执行次数。
现在考察操作 3 的处理方法。操作的调用过程可以建成一个 DAG(无环是因为数据保证不递归),在这个 DAG 上运行拓扑排序,计算出每个操作 1 因操作 2 被执行多少次,然后以此执行每个操作 1。为了方便处理,你可以将 \(q\) 个待执行封装成一个新操作,拓扑排序时直接处理这个新操作。
点击查看代码
#include <bits/stdc++.h>
using namespace std;
#define gc getchar
template<typename T> void read(T &x) {
char c = gc(); int f = 1; x = 0;
while (!isdigit(c)) f = (c == '-' ? -f : f), c = gc();
while (isdigit(c)) x = (x << 3) + (x << 1) + c - '0', c = gc();
x *= f;
}
template<typename T, typename ... Args> void read(T &x, Args& ... args) {read(x); read(args...);}
#define ll long long
#define ull unsigned long long
#define pii pair<int,int>
#define lll __int128
#define rep(i, s, t) for(ll i=(s);i<=(t);i++)
#define per(i, s, t) for(ll i=(s);i>=(t);i--)
template<typename T> void chkmx(T &x, const T &y) {x = max(x, y);}
template<typename T> void chkmn(T &x, const T &y) {x = min(x, y);}
const ll N = 100005, mod = 998244353;
ll n, m, q, a[N], t[N], A[N];
ll p[N], v[N], f[N], du[N];
vector<ll> g[N];
ll cnt[N], mk[N], mul = 1;
// 第 i 个操作被执行多少次, 第 i 个操作会使前面执行的操作次数乘多少,初始值被乘多少
queue<ll> que;
void dfs(ll u) {
if (t[u] == 2) {mk[u] = v[u]; return;}
if (t[u] == 1) {mk[u] = 1; return;}
mk[u] = 1;
per(i, (ll)g[u].size() - 1, 0) {
ll v = g[u][i];
if (mk[v] == -1) dfs(v);
mk[u] = ((ll)mk[u] * mk[v]) % mod;
}
}
void topo() {
p[m + 1] = q; mk[m + 1] = 0;
rep(i, 1, q) {
g[m + 1].push_back(f[i]); du[f[i]]++;
}
rep(i, 1, m + 1) mk[i] = -1;
rep(i, 1, m + 1) {
if (mk[i] == -1) dfs(i);
}
per(i, q, 1) {
cnt[f[i]] = (cnt[f[i]] + mul) % mod;
mul = mul * mk[f[i]] % mod;
}
rep(i, 1, m + 1) {
if (!du[i]) {
que.push(i);
}
}
while (!que.empty()) {
int u = que.front();
que.pop();
if (t[u] == 1) continue;
else if (t[u] == 2) continue;
per(i, (int)g[u].size() - 1, 0) {
ll v = g[u][i];
cnt[v] = (cnt[v] + cnt[u]) % mod;
cnt[u] = (cnt[u] * mk[v]) % mod;
du[v]--;
if (!du[v]) que.push(v);
}
}
rep(i, 1, m) if (t[i] == 1) A[p[i]] = (A[p[i]] + v[i] * cnt[i] % mod) % mod;
}
int main() {
read(n);
rep(i, 1, n) read(a[i]);
read(m);
rep(i, 1, m) {
read(t[i]);
if (t[i] == 1) {
read(p[i], v[i]);
} else if (t[i] == 2) {
read(v[i]);
} else {
read(p[i]);
rep(j, 1, p[i]) {
ll u;
read(u);
g[i].push_back(u);
du[u]++;
}
}
}
read(q);
rep(i, 1, q) read(f[i]);
topo();
// rep(i, 1, m + 1) cout << mk[i] << ' ' << cnt[i] << '\n';cout << mul << '\n';
rep(i, 1, n) printf("%lld%c", (a[i] * mul % mod + A[i]) % mod, " \n"[i == n]);
return 0;
}
1.7 分数规划
我也不知道为什么,就是突然想在图论知识点里加一个分数规划。
分数规划通常用于求解分式的最小、最大值。其形式化表述是,求一组 \(w_i\in \{0,1\}\),使得
最大或最小。
求解分数规划问题的常见思路是使用二分法。根据
得知可以二分 \(mid\) 求解。
2.1 最近公共祖先(LCA)
在有根树上,两个点的 LCA,就是这两个点的公共祖先里面,离根最远的那个点;同时也是两点间的唯一简单路径上,离根最近的那个点。
求解 LCA,常见的方法是倍增法。预处理阶段,设 \(l_{i,j}\) 是编号为 \(i\) 的点,向根的方向移动 \(2^j\) 条边之后到达的点编号,那么有:\(l_{i,j}=l_{l_{i,j-1},j-1}\)(\(j>0\))。查询阶段,假设需要求两个点 \(u,v\) 的 LCA,不妨设 \(u\) 的深度大于 \(v\)(若深度一样则跳过第一步)。
第一步,u 向着根方向移动,直到 \(u\) 的深度和 \(v\) 相同,这一步应当使用 \(l\) 数组加速跳转过程。简单来说,枚举变量 \(k\) 从 \(\log n\) 到 \(0\),若 \(l_{u,k}\) 的深度大于 \(v\) 的,则令 \(u\leftarrow l_{u,k}\)。
在进行第二步前,应当检查 \(u\) 是否已经跳转到 \(v\)。若是,则直接返回 \(u\) 即可。
第二步,枚举变量 \(k\) 从 \(\log n\) 到 \(0\),检查 \(l_{u,k}\) 是否等于 \(l_{v,k}\)。若不是,则令 \(u\leftarrow l_{u,k}\),\(v=\leftarrow l_{v,k}\)。这是为了让 \(u\) 和 \(v\) 在跳转结束后,拥有共同的父节点。第二部结束后,返回 \(u\) 的父节点作为查询结果。
预处理的时间复杂度是 \(\mathcal{O}(n\log n)\),查询 \(\mathcal{O}(\log n)\)。
例题:P2416 泡芙
给定 \(n\) 个点 \(m\) 条边的无向图,边有 \(0\) 或 \(1\) 的边权。有 \(q\) 组询问,查询 \(u\) 和 \(v\) 之间是否存在一条无重边的路径,使得路径上至少一条边的边权为 \(1\),根据结果输出
YES或NO。
不难证明,同一个边双连通分量内的任意两个点间,如果这个分量内有边权为 \(1\) 的边,则必然为 YES,反之必然为 NO。对于不在同一分量内的两个点,由于缩点后为一棵树,可以预处理从树根(随便指定一个)到点 \(u\) 的边权和+点权和,然后询问 \(u,v\) 时直接查询两点所属的分量间边权和和点权和即可。
2.2 树链剖分
请查阅 算法与数据结构 9 - 重链剖分。
本文来自博客园,作者:cwkapn,转载请注明原文链接:https://www.cnblogs.com/cwkapn/p/22117508


浙公网安备 33010602011771号