文章目录
- 图论
- 前言
- 第一节——最短路问题
- 最小生成树
- Kruskal算法
- Prim
- 最小生成树的应用
- 例题1:[北极网络](https://www.acwing.com/problem/content/description/389/)
- 例题2:[沙漠之王](https://www.acwing.com/problem/content/description/350/)
- 例题3:[黑暗城堡](https://www.acwing.com/problem/content/description/351/)
- 例题4[野餐规划](https://www.acwing.com/problem/content/description/349/)
- 例题5:[四叶草魔杖](https://www.acwing.com/problem/content/description/390/)
- 树的直径与LCA
- 基环树
- 负环与差分约束系统
图论
前言
图论是一个综合性很强,实用性和研究性兼具的板块,是算法竞赛常考的东东
图论算法本身不难,难在对于问题的转化与建模,譬如如何想到这个问题是考察的图论知识,该如何建立一张图来解决问题,本文会在介绍知识点的同时,介绍经典的套路以及思维模式。
第一节——最短路问题
基本概念:由于无向边可以看作两条相反的有向边,于是我们默然按照有向边的形式讨论
存图方式:
- 邻接矩阵:空间复杂度 O ( n 2 ) O(n^2) O(n2),优点: O ( 1 ) O(1) O(1)查找 x − > y x->y x−>y的边是否存在,方便
scanf("%d%d%d",&u,&v,&w);
a[u][v]=w;//邻接矩阵
- 邻接表:空间复杂度 O ( m ) O(m) O(m)
void add(int u,int v,int w){//u->v的边权为w的边
nxt[++tot]=head[u],ver[tot]=v,cost[tot]=w,head[u]=tot;
}
//遍历时:
for(int i=head[u];i;i=nxt[i])……
- vector,这个和邻接表相比无任何突出特点,故略去
邻接表是最常用的
单源最短路问题(SSSP)
为了方便叙述,下面没特殊说明默认 d i s t dist dist数组为存储距离的数组, c o s t u , v cost_{u,v} costu,v表示边 u − > v u->v u−>v的长度,默认使用邻接表存图,默认 u u u为父节点, v v v为子节点
Dijkstra算法
应用于非负权图上,基于贪心思想
它的过程是这样的
- 寻找到所有未被标记的的节点中 d i s t dist dist值最小的,将其取出并标记,记为 u u u
- 遍历从 u u u出发的所有边,将这些边所连后继节点 v v v的 d i s t dist dist值全部用 d i s t u + c o s t u , v dist_u+cost_{u,v} distu+costu,v更新,
- 重复上述步骤直到全部节点被标记
性质:由于是非负权图,我们取出的 u u u节点的 d i s t dist dist一定是无法被更新的节点,此时的 d i s t dist dist值就是起点到 u u u的最短距离
上述算法的复杂度为 O ( n 2 ) O(n^2) O(n2)
我们发现,算法的瓶颈在于第一步,对于这种情况,我们可以采取二叉堆优化
详细地说,我们采用一个小根堆,以 d i s t dist dist值为第一关键字进行优化,那么我们的步骤就可以变为
1.取出堆顶节点 u u u,若被标记,则继续取出堆顶直到堆顶无标记为止(懒惰删除法)
2.更新 u u u的所有后继节点 ,如果被成功更新,则将 v v v入队
3.直到堆为空时,算法结束
实现细节
1.如果懒得重载运算符,建议把
d
i
s
t
dist
dist值跟随节点入队时取反,拿出来时再取反,这就可以吧默认大根堆改为小根堆
2.时刻记得memset(dist,0x3f,sizeof dist);
代码如下:
// 堆优化Dijkstra算法,O(mlogn)
const int N = 100010, M = 1000010;
int head[N], ver[M], edge[M], Next[M], d[N];
bool v[N];
int n, m, tot;
// 大根堆(优先队列),pair的第二维为节点编号
// pair的第一维为dist的相反数(利用相反数变成小根堆,参见0x71节)
priority_queue< pair<int, int> > q;
void add(int x, int y, int z) {
ver[++tot] = y, edge[tot] = z, Next[tot] = head[x], head[x] = tot;
}
void dijkstra() {
memset(d, 0x3f, sizeof(d)); // dist数组
memset(v, 0, sizeof(v)); // 节点标记
d[1] = 0;
q.push(make_pair(0, 1));
while (q.size()) {
// 取出堆顶
int x = q.top().second; q.pop();
if (v[x]) continue;
v[x] = 1;
// 扫描所有出边
for (int i = head[x]; i; i = Next[i]) {
int y = ver[i], z = edge[i];
if (d[y] > d[x] + z) {
// 更新,把新的二元组插入堆
d[y] = d[x] + z;
q.push(make_pair(-d[y], y));
}
}
}
}
int main() {
cin >> n >> m;
// 构建邻接表
for (int i = 1; i <= m; i++) {
int x, y, z;
scanf("%d%d%d", &x, &y, &z);
add(x, y, z);
}
// 求单源最短路径
dijkstra();
for (int i = 1; i <= n; i++)
printf("%d\n", d[i]);
}
它的复杂度是 O ( ( m + n ) log n ) O((m+n)\log n) O((m+n)logn)
Bellman-ford与SPFA
在国际上,
S
P
F
A
SPFA
SPFA其实被称为队列优化的
B
e
l
l
m
a
n
−
f
o
r
d
Bellman-ford
Bellman−ford算法,这两种算法也是求解单源最短路径问题的算法,与
d
i
j
k
s
t
r
a
dijkstra
dijkstra算法不同的是,这两种算法在负权图上一样可以正常工作,由于
S
P
F
A
SPFA
SPFA是
B
e
l
l
m
a
n
−
f
o
r
d
Bellman-ford
Bellman−ford算法的优化形式,下面就只讲述
S
P
F
A
SPFA
SPFA算法
首先,在一张有向图上,若是对于任意边
(
u
,
v
,
w
)
(u,v,w)
(u,v,w)都有:
d
i
s
t
[
u
]
+
w
≥
d
i
s
t
[
v
]
dist[u]+w\ge dist[v]
dist[u]+w≥dist[v],(此不等式又叫三角形不等式),那么我们所求的
d
i
s
t
dist
dist数组就是最短路径,而
S
P
F
A
SPFA
SPFA算法便是通过若干次更新迭代,使所有边都满足三角形不等式,下面我们介绍它的详细流程
- 建立一个队列,最初队里只有起点 s s s, d i s t [ s ] = 0 dist[s]=0 dist[s]=0其余均为正无穷
- 取出队头 u u u,将所有队头出发的后继节点 v v v进行更新,若更新成功,则将 v v v入队
- 重复第二步直到队列为空
代码模板
// SPFA算法
const int N = 100010, M = 1000010;
int head[N], ver[M], edge[M], Next[M], d[N];
int n, m, tot;
queue<int> q;
bool v[N];
void add(int x, int y, int z) {
ver[++tot] = y, edge[tot] = z, Next[tot] = head[x], head[x] = tot;
}
void spfa() {
memset(d, 0x3f, sizeof(d)); // dist数组
memset(v, 0, sizeof(v)); // 是否在队列中
d[1] = 0; v[1] = 1;
q.push(1);
while (q.size()) {
// 取出队头
int x = q.front(); q.pop();
v[x] = 0;
// 扫描所有出边
for (int i = head[x]; i; i = Next[i]) {
int y = ver[i], z = edge[i];
if (d[y] > d[x] + z) {
// 更新,把新的二元组插入堆
d[y] = d[x] + z;
if (!v[y]) q.push(y), v[y] = 1;
}
}
}
}
int main() {
cin >> n >> m;
// 构建邻接表
for (int i = 1; i <= m; i++) {
int x, y, z;
scanf("%d%d%d", &x, &y, &z);
add(x, y, z);
}
// 求单源最短路径
spfa();
for (int i = 1; i <= n; i++)
printf("%d\n", d[i]);
}
在随机图的情况下,SPFA算法的时间复杂度是
O
(
k
n
)
O(kn)
O(kn),其中k是一个较小的常数,但可以构造数据卡,使其退化到
O
(
n
m
)
O(nm)
O(nm)级别,并且之前我在知乎上看到一篇文章,几乎所有的SPFA算法优化都在上面被卡了
同样,正是因为SPFA使得三角形不等式绝对收敛,以至于其可以在负权图上工作,当位于正权图的时候,一样可以采取堆优化,最后得到的算法与dijkstra算法一模一样,一个是基于贪心的非负权算法,一个是基于三角形不等式收敛的通用算法,在非负权图上却是殊途同归
特殊情形下的线性算法
- 在 D A G DAG DAG中,我们可以按照拓扑序或者记忆化搜索 O ( n ) O(n) O(n)递推出最短路
- 在边权只为0/1的情况下,我们可以不使用优先队列,直接将dijkstra算法里的优先队列替换为双端队列,对于一个新加入的节点,与其父节点连边的边权是1就插入队尾,是0就插入队头
- 形式上来说,单源最短路径问题很类似与优先队列BFS,本质上来说是一致的,所以类似于 A ∗ A^* A∗算法的估价函数一样可以使用
全源最短路径算法:Floyd
简单来说就是在一张图上要求出任意两点间的最短路径,我们使用Floyd算法,时间复杂度为
O
(
n
3
)
O(n^3)
O(n3),一般使用邻接矩阵存图
使用动态规划的思想,设
D
[
k
,
i
,
j
]
D[k,i,j]
D[k,i,j]表示i到j的路径只经过节点编号小于k的路径的最短长度,则有状态转移方程
D
[
k
,
i
,
j
]
=
min
{
D
[
k
−
1
,
i
,
j
]
,
D
[
k
−
1
,
i
,
k
]
+
D
[
k
−
1
,
k
,
j
]
}
D[k,i,j]=\min \lbrace D[k-1,i,j],D[k-1,i,k]+D[k-1,k,j]\rbrace
D[k,i,j]=min{D[k−1,i,j],D[k−1,i,k]+D[k−1,k,j]},类似于01背包那样,k这一维度可以省略
值得一提的是,在动态规划算法中,对k的枚举才是阶段,i,j只是附加状态而已。于是循环顺序应该是
k
−
>
i
−
>
j
k->i->j
k−>i−>j
int d[310][310];
int n, m;
int main() {
cin >> n >> m;
// 把d数组初始化为邻接矩阵
memset(d, 0x3f, sizeof(d));
for (int i = 1; i <= n; i++) d[i][i] = 0;
for (int i = 1; i <= m; i++) {
int x, y, z;
scanf("%d%d%d", &x, &y, &z);
d[x][y] = min(d[x][y], z);
}
// floyd求任意两点间最短路径
for (int k = 1; k <= n; k++)
for (int i = 1; i <= n; i++)
for (int j = 1; j <= n; j++)
d[i][j] = min(d[i][j], d[i][k] + d[k][j]);
// 输出
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= n; j++) printf("%d ", d[i][j]);
puts("");
}
}
Floyd与传递闭包
传递闭包问题是这样的:给定
n
n
n个元素和
m
m
m个二元关系,关系具有传递性,求推出最多的关系。
建立邻接矩阵
d
d
d,如果
i
,
j
有关系,那么令
d
[
i
,
j
]
=
1
i,j\text{有关系,那么令}d[i,j]=1
i,j有关系,那么令d[i,j]=1,特别的,令
d
[
i
,
i
]
=
1
d[i,i]=1
d[i,i]=1,其余均为0,对d执行
F
l
o
y
d
Floyd
Floyd即可求出传递闭包关系,它的原理是Floyd本身的递推式就是在做关系的传递(仅属个人见解)
// Floyd传递闭包
#include <iostream>
#include <cstdio>
#include <cstring>
#include <algorithm>
using namespace std;
bool d[310][310];
int n, m;
int main() {
cin >> n >> m;
for (int i = 1; i <= n; i++) d[i][i] = 1;
for (int i = 1; i <= m; i++) {
int x, y;
scanf("%d%d", &x, &y);
d[x][y] = d[y][x] = 1;
}
for (int k = 1; k <= n; k++)
for (int i = 1; i <= n; i++)
for (int j = 1; j <= n; j++)
d[i][j] |= d[i][k] & d[k][j];
}
最短路算法的灵活运用
题目1:通信线路
题意:在无向图上求出一条1到n的路径,使得第K+1大的边权尽量小,共有N个点P条边
介绍两种解法,两种思想
1.分层图最短路
具体的,我们利用动态规划的思想,设
d
[
i
,
j
]
d[i,j]
d[i,j]表示1到i的路径中第j+1大的边权的最小值,于是对于一条边
(
u
,
v
,
w
)
(u,v,w)
(u,v,w)我们可以用
max
(
w
,
d
[
u
,
k
]
)
\max(w,d[u,k])
max(w,d[u,k])去更新
d
[
v
,
k
]
d[v,k]
d[v,k],用
d
[
u
,
k
]
d[u,k]
d[u,k]去更新
d
[
v
,
k
+
1
]
d[v,k+1]
d[v,k+1]。在我的另一篇文章中曾经提到过DP实际上也是对状态空间进行的一张有向无环图的遍历,显然这个方程有后效性,但是我们可以使用SPFA去消去后效性,因为三角形不等式会令其不断收敛,但又因为全是正权图的原因就可以使用堆优化。
如果从图论的角度来看,无疑我们可以把点的状态抽象为二维的,用二元组
(
u
,
k
)
(u,k)
(u,k)代表一个节点,那么就等同于节点
(
u
,
k
)
(u,k)
(u,k)与节点
(
v
,
k
)
(v,k)
(v,k)有着一条边权为
w
w
w的边,与节点
(
v
,
k
+
1
)
(v,k+1)
(v,k+1)有着一条边权为0的边,这是一个具有N * K个点,P * K条边的广义最短路问题,又被称为分层图最短路
#define x second.first
#define y second.second
#define mp make_pair
int head[1050],ver[105000],cost[105000],nxt[105000],tot,n,m,k,dist[1050][1050],vis[1050][1050];
priority_queue<pair<int,pair<int,int> > >q;
void add(int u,int v,int w) {
ver[++tot]=v,cost[tot]=w,nxt[tot]=head[u],head[u]=tot;
}
void dijkstra(){
memset(dist,0x3f,sizeof(dist));
dist[1][0]=0;
q.push(mp(0,mp(1,0)));
while (!q.empty()) {
int u=q.top().x,dep=q.top().y;
q.pop();
if (vis[u][dep])continue;
vis[u][dep]=1;
for (int i=head[u];i;i=nxt[i]) {
int v=ver[i],w=cost[i];
if (dist[v][dep]>max(dist[u][dep],w)) {
dist[v][dep]=max(dist[u][dep],w);
q.push(mp(-dist[v][dep],mp(v,dep)));
}
if (dep < k && dist[v][dep+1]>dist[u][dep]) {
dist[v][dep+1]=dist[u][dep];
q.push(mp(-dist[v][dep+1],mp(v,dep+1)));
}
}
}
}
int main() {
scanf("%d%d%d",&n,&m,&k);
for (int i=1;i<=m;i++) {
int u,v,w;
scanf("%d%d%d",&u,&v,&w);
add(u,v,w);
add(v,u,w);
}
dijkstra();
printf("%d\n",dist[n][k]==0x3f3f3f3f?-1:(k!=n?dist[n][k]:0));//最后一组数据:N=2K=2????不是K<N吗
}
2.二分转化判定
很明显的单调性,我们可以二分第K+1大的边权的最小值,设为
m
i
d
mid
mid
然后我们把所有边权大于 m i d mid mid的边权看作1,小于等于的看作0,使用双端队列bfs在 O ( n ) O(n) O(n)时间内求出最短路,当最短路超过k的时候上界收敛,否则下界收敛
// 解法二:二分答案+双端队列BFS
const int MAX_N = 1005, MAX_M = 20005;
int head[MAX_N], ver[MAX_M], edge[MAX_M], nxt[MAX_M], tot;
int n, m, k, d[MAX_N];
deque<int> q;
// 插入一条从x到y长度z的有向边
void add(int x, int y, int z) {
tot++;ver[tot] = y; edge[tot] = z;nxt[tot] = head[x],head[x]=tot; // 在head[x]这条链的开头插入新点
}
bool solve(int t) {
memset(d, 0x7f, sizeof(d));
d[1] = 0;
q.push_back(1);
while (!q.empty()) {
int x = q.front();
q.pop_front();
for (int i = head[x]; i; i = nxt[i]) {
int y = ver[i], z = edge[i] > t ? 1 : 0;
if (d[y] > d[x] + z) {
d[y] = d[x] + z;
if (z == 0) q.push_front(y); else q.push_back(y);
}
}
}
return d[n] <= k;
}
int main() {
cin >> n >> m >> k;
for (int i = 1; i <= m; i++) {
int x, y, z;
scanf("%d%d%d", &x, &y, &z);
add(x, y, z);
add(y, x, z);
}
int L = 0, R = 1000001;
while (L < R) {
int mid = (L + R) >> 1;
if (solve(mid)) R = mid; else L = mid + 1;
}
if (L == 1000001) puts("-1"); else cout << L << endl;
}
这道题告诉我们的是分层图的最短路,借用动态规划思想解决最短路径问题,还有第x大的最小值是二分,可以抽象为二维节点,也可以把每一个节点的边都建立出来
题目2:道路与航线
题目描述:
分析:本题是一个带有负权的单源最短路径问题,但是SPFA被卡了,于是我们需要运用图上的性质:道路都是双向的,航线都是单向的,且如果一条航线从A->B,那么不可能存在一条道路使得B->A,这句话的意思就是说任何一条航线都是这张图的一个割边,并且只有这个割边的边权为负数,对于这样的图,我们可以拆图,将这张图拆为一个个连通块和割边,详细的说,我们最开始无视所有的航线,只进行对道路的计算,无疑,这些道路全是正权,我们可以使用dijkstra算法求出一个个连通块内的最短路,又由于割边的存在,我们把每一个连通块看成一个点,那么这张图就成了一个
D
A
G
DAG
DAG,我们就可以使用线性的动态规划求出答案,这个顺序需要我们预处理出拓扑序
流程如下:
- 只加入道路,也就是双向边
- 使用深度优先搜索划分连通块
- 统计每个连通块的入度,记作 i n in in
- 建立一个用于拓扑排序的队列,其中只存储入度为0的连通块编号,当然也包含起点所在的连通块编号
- 取出队头连通块执行
d
i
j
k
s
t
r
a
dijkstra
dijkstra
(1).将连通块所有点加入优先队列,取出 d d d最小的节点 u u u进行更新
(2).若队头被扩展过,重复执行(1)
(3).扫描 u u u的所有出边 ( u , v , w ) (u,v,w) (u,v,w),用 d [ u ] + w < d [ v ] 更新 d [ v ] d[u]+w<d[v]更新d[v] d[u]+w<d[v]更新d[v]
(4).如果u,v再同一连通块内并且d[v]被更新,则将v加入堆
(5).如果不在同一连通块内,将 v v v所在连通块的入度减少一,直到减少到零,此时就将v所在联通快加入拓扑排序的队列中
(6).重复执行1至5直到优先队列为空 - 重复执行5直到队列为空
代码如下
#include<iostream>
#include<cstdio>
#include<queue>
#include<cstring>
#include<vector>
#define int long long
using namespace std;
queue<int>q;
priority_queue<pair<int,int> >p;
int t,r,n,m,P,s,cnt,head[300005],nxt[300005],ver[300005],cost[300005],tot,vis[300005],c[300005],deg[300005],kind[300005],dist[300005];
vector<int>a[300005];
void add(int u,int v,int w,int k){
nxt[++tot]=head[u],ver[tot]=v,cost[tot]=w,head[u]=tot,kind[tot]=k;//1是单向边,0是有向边
}
void dfs(int u,int id){
c[u]=id;
a[id].push_back(u);
for(int i=head[u];i;i=nxt[i]){
if(kind[i])continue;
if(!c[ver[i]])dfs(ver[i],id);
}
}
void dijkstra(int id){//求编号为id的联通块内最短路
int len=a[id].size();
for(int i=0;i<len;i++){
p.push(make_pair(-dist[a[id][i]],a[id][i]));
}
while(p.size()){
int u=p.top().second,w=dist[u];p.pop();
if(vis[u])continue;
vis[u]=1;
for(int i=head[u];i;i=nxt[i]){
int v=ver[i],z=cost[i];
if(c[v]==c[u]&&dist[v]>w+z){
dist[v]=w+z;
p.push(make_pair(-dist[v],v));
}
if(c[v]!=c[u]){
dist[v]=min(w+z,dist[v]);
deg[c[v]]--;
if(!deg[c[v]])q.push(c[v]);
}
}
}
}
signed main(){
int maxx=0;
scanf("%lld%lld%lld%lld",&n,&m,&P,&s);
for(int i=1;i<=m;i++){
int u,v,w;
scanf("%lld%lld%lld",&u,&v,&w);
add(u,v,w,0);
add(v,u,w,0);
}
for(int i=1;i<=P;i++){
int u,v,w;
scanf("%lld%lld%lld",&u,&v,&w);
add(u,v,w,1);
}
for(int i=1;i<=n;i++){
if(!c[i])dfs(i,++cnt);
}
for(int u=1;u<=n;u++){
for(int i=head[u];i;i=nxt[i]){
if(kind[i]){
deg[c[ver[i]]]++;
}
}
}
for(int i=1;i<=cnt;i++){
if(!deg[i])q.push(i);
}
for(int i=1;i<=n;i++)dist[i]=0x3f3f3f3f3f3f3f3f;
dist[s]=0;
while(q.size()){
int k=q.front();
q.pop();
dijkstra(k);
}
for(int i=1;i<=n;i++){
if(dist[i]<=n*10000)printf("%lld\n",dist[i]);
else puts("NO PATH");
}
return 0;
}
这道题告诉我们,合理运用题目性质,对于图的明显性质,我们可以将一张图分为几个块,使用不同的算法进行处理,本题就是将连通块内使用dijkstra,然后缩点成DAG使用线性递推直接求解
题目三Sightseeing
题意简述:给定一张有向图,求起点S到终点F的最短路径条数和次短路径条数
对于这个问题的维护,我们只需要把dijkstra算法稍稍改造一下就可以了
我们开一个数组
c
n
t
cnt
cnt,表示路径条数,同时
d
i
s
t
,
c
n
t
dist,cnt
dist,cnt都是二维,第二维只能是0/1表示最短路/次短路
下面我们简单谈谈它的维护
首先在优先队列里我们记录当前状态是由次短路还是最短路更新,记为k,那么在我们执行正常dijkstra算法的时候就会出现四种情况
1.
d
i
s
t
[
v
]
[
0
]
>
d
i
s
t
[
u
]
[
k
]
+
w
dist[v][0]>dist[u][k]+w
dist[v][0]>dist[u][k]+w,此时令
d
i
s
t
[
v
]
[
1
]
=
d
i
s
t
[
v
]
[
0
]
,
d
i
s
t
[
v
]
[
0
]
=
d
i
s
t
[
u
]
[
k
]
,
c
n
t
[
v
]
[
1
]
=
c
n
t
[
v
]
[
0
]
,
c
n
t
[
v
]
[
0
]
=
c
n
t
[
u
]
[
k
]
dist[v][1]=dist[v][0],dist[v][0]=dist[u][k],cnt[v][1]=cnt[v][0],cnt[v][0]=cnt[u][k]
dist[v][1]=dist[v][0],dist[v][0]=dist[u][k],cnt[v][1]=cnt[v][0],cnt[v][0]=cnt[u][k],并将两个都插入队列
2.
d
i
s
t
[
v
]
[
1
]
>
d
i
s
t
[
u
]
[
k
]
+
w
>
d
i
s
t
[
v
]
[
0
]
dist[v][1]>dist[u][k]+w>dist[v][0]
dist[v][1]>dist[u][k]+w>dist[v][0],此时令
d
i
s
t
[
v
]
[
1
]
=
d
i
s
t
[
u
]
[
k
]
+
w
dist[v][1]=dist[u][k]+w
dist[v][1]=dist[u][k]+w,
3.
d
i
s
t
[
v
]
[
0
]
=
d
i
s
t
[
u
]
[
k
]
+
w
dist[v][0]=dist[u][k]+w
dist[v][0]=dist[u][k]+w
4.
d
i
s
t
[
v
]
[
1
]
=
d
i
s
t
[
u
]
[
k
]
+
w
dist[v][1]=dist[u][k]+w
dist[v][1]=dist[u][k]+w
只有在这四种情况下才会更新
const int N=1010,M=100010;
int T,t,n,m,s,head[N],ver[M],cost[M],nxt[M],tot,d[N][2],cnt[N][2],vis[N][2];
struct node{
int id,k,d;
bool operator<(const node& node)const {
return d>node.d;
}
};
priority_queue<node>q;
void add(int u,int v,int w){
ver[++tot]=v,cost[tot]=w,nxt[tot]=head[u],head[u]=tot;
}
int dijkstra(){
memset(vis,0,sizeof vis);
memset(d,0x3f,sizeof d);
d[s][0]=0,cnt[s][0]=1;
q.push({s,0,0});
while(q.size()){
int u=q.top().id,k=q.top().k,z=d[u][k];q.pop();
if(vis[u][k])continue;
vis[u][k]=true;
for(int i=head[u];i;i=nxt[i]){
int v=ver[i],w=cost[i];
if(d[v][0]>z+w){
d[v][1]=d[v][0],cnt[v][1]=cnt[v][0];
q.push({v,1,d[v][1]});
d[v][0]=z+w,cnt[v][0]=cnt[u][k];
q.push({v,0,d[v][0]});
}
else if(d[v][0]==z+w)cnt[v][0]+=cnt[u][k];
else if(d[v][1]>z+w){
d[v][1]=z+w,cnt[v][1]=cnt[u][k];
q.push({v,1,d[v][1]});
}
else if(d[v][1]==z+w)cnt[v][1]+=cnt[u][k];
}
}
//printf("%d %d %d %d\n",d[t][0],cnt[t][0],d[t][1],cnt[t][1]);
return cnt[t][0]+(d[t][1]==d[t][0]+1)*cnt[t][1];
}
int main(){
scanf("%d",&T);
while(T--){
tot=0;
memset(head,0,sizeof head);
scanf("%d%d",&n,&m);
for(int i=1;i<=m;i++){
int u,v,w;
scanf("%d%d%d",&u,&v,&w);
add(u,v,w);
}
scanf("%d%d",&s,&t);
printf("%d\n",dijkstra());
}
return 0;
}
例题4:排序
题目描述
一个不同的值的升序排序数列指的是一个从左到右元素依次增大的序列,例如,一个有序的数列 A , B , C , D A,B,C,D A,B,C,D 表示 A < B , B < C , C < D A<B,B<C,C<D A<B,B<C,C<D。在这道题中,我们将给你一系列形如 A < B A<B A<B 的关系,并要求你判断是否能够根据这些关系确定这个数列的顺序。
输入格式
第一行有两个正整数
n
,
m
n,m
n,m,
n
n
n 表示需要排序的元素数量,
2
≤
n
≤
26
2\leq n\leq 26
2≤n≤26,第
1
1
1 到
n
n
n 个元素将用大写的
A
,
B
,
C
,
D
…
A,B,C,D\dots
A,B,C,D… 表示。
m
m
m 表示将给出的形如
A
<
B
A<B
A<B 的关系的数量。
接下来有
m
m
m 行,每行有
3
3
3 个字符,分别为一个大写字母,一个 <
符号,一个大写字母,表示两个元素之间的关系。
输出格式
若根据前
x
x
x 个关系即可确定这
n
n
n 个元素的顺序 yyy..y
(如 ABC
),输出
Sorted sequence determined after xxx relations: yyy...y
.
若根据前
x
x
x 个关系即发现存在矛盾(如
A
<
B
,
B
<
C
,
C
<
A
A<B,B<C,C<A
A<B,B<C,C<A),输出
Inconsistency found after x relations.
若根据这
m
m
m 个关系无法确定这
n
n
n 个元素的顺序,输出
Sorted sequence cannot be determined.
(提示:确定
n
n
n 个元素的顺序后即可结束程序,可以不用考虑确定顺序之后出现矛盾的情况)
提示
2
≤
n
≤
26
,
1
≤
m
≤
600
2 \leq n \leq 26,1 \leq m \leq 600
2≤n≤26,1≤m≤600。
很明显的,这是一道传递闭包问题,对于
i
<
j
i<j
i<j的式子,令
f
[
i
]
[
j
]
=
1
f[i][j]=1
f[i][j]=1,其余的全部置为0,使用Floyd即可求出传递闭包,然后若
∃
i
,
j
∈
[
1
,
n
]
,
使得
f
[
i
]
[
j
]
=
f
[
j
]
[
i
]
\exists i,j\in[1,n],使得f[i][j]=f[j][i]
∃i,j∈[1,n],使得f[i][j]=f[j][i]则矛盾,确定所有的顺序即
∀
i
,
j
∈
[
1
,
n
]
有
f
[
i
]
[
j
]
+
f
[
j
]
[
i
]
=
1
\forall i,j\in[1,n]有f[i][j]+f[j][i]=1
∀i,j∈[1,n]有f[i][j]+f[j][i]=1
套传递闭包即可。至于确定顺序之后输出,我们只需要将有多少个数小于当前数统计出来,这就是实质上的排名
注意这里不可以使用二分法,是因为二分很可能出现前面可以确定顺序后面发生矛盾却是二分到了矛盾的地方
int e[55][55],d[55][55],n,m;
pair<int,char>ans[105];
int floyd(){
memcpy(e, d, sizeof(e));
for(int k=1;k<=n;k++)
for(int i=1;i<=n;i++)
for(int j=1;j<=n;j++){
e[i][j]|=e[i][k]&e[k][j];
if(e[i][j]&&e[j][i])return -1;
}
for(int i=1;i<=n;i++)
for(int j=1;j<=n;j++){
if(e[i][j]==e[j][i]&&!e[i][j]&&i!=j)return 0;
}
return 1;
}
int main(){
while(~scanf("%d%d",&n,&m)&&n&&m){
int op=1;
memset(d,0,sizeof d);
for(int i=1;i<=m;i++){
char a[3];
scanf("%s",a);
d[(int)a[0]-64][(int)a[2]-64]=1;
if(op==1){
int x=floyd();
if(x==1){
printf("Sorted sequence determined after %d relations: ",i);
for(int i=1;i<=n;i++){
ans[i].first=0;
for(int j=1;j<=n;j++){
if(!e[i][j])ans[i].first++;
}
ans[i].second=(char)(i+64);
}
sort(ans+1,ans+n+1);
for(int i=1;i<=n;i++){
printf("%c",ans[i].second);
}
puts(".");
op=0;
}
else if(x==-1){
printf("Inconsistency found after %d relations.\n",i);
op=0;
}
}
}
if(op){
puts("Sorted sequence cannot be determined.");
}
}
}
例题5观光之旅
这是经典的最小环问题
给定一张无向图,求图中一个至少包含 3 个点的环,环上的节点不重复,并且环上的边的长度之和最小。
该问题称为无向图的最小环问题。
你需要输出最小环的方案,若最小环不唯一,输出任意一个均可。
这里我们使用Floyd算法,因为Floyd算法外层循环的k代表节点编号不超过k,于是一个环可以表示为:
f
[
i
]
[
j
]
+
a
[
i
]
[
k
]
+
a
[
k
]
[
j
]
f[i][j]+a[i][k]+a[k][j]
f[i][j]+a[i][k]+a[k][j],当然,这是还没有进行第k层递推的时候,我们就相当于是枚举了k在环上的左右边,当然不相连的时候就是无穷大。至于方案递归输出就可
这样枚举完取最小值得到的环是满足以下条件的最小环
1.经过k
2.由编号不超过k的节点构成
由对称性可知,这样做不会影响结果
int ans[10005],num,cnt,d[105][105],a[105][105],h[105][105],m,n;
long long sum=0x3f3f3f3f;
void get_ans(int i,int j){
if(h[i][j]==0)return ;
get_ans(i,h[i][j]);
ans[++num]=h[i][j];
get_ans(h[i][j],j);
}//求方案
void floyd(){
for(int k=1;k<=n;k++){
for(int i=1;i<k;i++)
for(int j=i+1;j<k;j++)
if((long long)d[i][j]+a[i][k]+a[k][j]<sum){
sum=(long long)d[i][j]+a[i][k]+a[k][j];
num=0;
ans[++num]=i;
get_ans(i,j);
ans[++num]=j;
ans[++num]=k;
}
for(int i=1;i<=n;i++)
for(int j=1;j<=n;j++){
if(d[i][j]>d[i][k]+d[k][j])h[i][j]=k,d[i][j]=d[i][k]+d[k][j];//注意这里必须严格
}
}
}
int main(){
scanf("%d%d",&n,&m);
memset(a,0x3f,sizeof a);
for(int i=1;i<=n;i++)a[i][i]=0;
for(int i=1;i<=m;i++){
int u,v,w;
scanf("%d%d%d",&u,&v,&w);
a[u][v]=a[v][u]=min(a[u][v],w);
}
memcpy(d,a,sizeof a);
floyd();
if(sum==0x3f3f3f3f)puts("No solution.");
else{
int k=n+1,p=n+1;
for(int i=1;i<=num;i++)if(ans[i]<p)k=i,p=ans[i];//顺序输出
for(int i=k;i<=k+num-1;i++){
printf("%d ",ans[(i-1)%num+1]);
}
puts("");
}
return 0;
}
有向图的最小环更简单,我们枚举起点 s = 1 ∼ n s=1\sim n s=1∼n,对此进行单源最短路径算法,并且第一次取出s对节点进行更新之后把d[s]改为无穷大,当s第二次被取出的时候这个长度就是最小环长度
牛站
给定一张由 T 条边构成的无向图,点的编号为 1∼1000 之间的整数。
求从起点 S 到终点 E 恰好经过 N 条边(可以重复经过)的最短路。
2≤T≤100,
2≤N≤106
注意: 数据保证一定有解。
发现边很少,于是我们将点离散化,使用邻接矩阵存图
定义矩阵运算:设矩阵A,B,定义A*B=C,其中
C
[
i
]
[
j
]
=
min
k
=
1
n
{
A
[
i
]
[
k
]
+
B
[
k
]
[
j
]
}
C[i][j]=\min_{k=1}^n\lbrace A[i][k]+B[k][j] \rbrace
C[i][j]=mink=1n{A[i][k]+B[k][j]}
若矩阵A就是我们存图的邻接矩阵,那么
A
m
[
i
]
[
j
]
A^m[i][j]
Am[i][j]就是从i到j的经过m条边的最短路,于是我们使用快速幂即可得到答案,因为这个新矩阵乘法很明显具备结合律
#include<iostream>
#include<cstdio>
#include<map>
#include<cstring>
using namespace std;
map<int,int>H;
int a[105][105],n,m,s,t,b[10005],cnt,S,E;
void mul(int a[][105],int b[][105]){
int c[105][105];
memset(c,0x3f,sizeof c);
for(int i=1;i<=cnt;i++)
for(int j=1;j<=cnt;j++)
for(int k=1;k<=cnt;k++)
c[i][j]=min(c[i][j],a[i][k]+b[k][j]);
for(int i=1;i<=cnt;i++)
for(int j=1;j<=cnt;j++)
a[i][j]=c[i][j];
}
void power(int a[][105],int m){
int ans[105][105];
memset(ans,0x3f,sizeof ans);
ans[s][s]=0;
while(m){
if(m&1)mul(ans,a);
mul(a,a);
m>>=1;
}
for(int i=1;i<=cnt;i++)
for(int j=1;j<=cnt;j++)
a[i][j]=ans[i][j];
}
int main(){
// freopen("relays.12.in","r",stdin);
scanf("%d%d%d%d",&n,&m,&S,&E);
memset(a,0x3f,sizeof a);
for(int i=1;i<=m;i++){
int u,v,w;
scanf("%d%d%d",&w,&u,&v);
int x=u,y=v;
if(H[u])u=H[u];
else H[u]=++cnt,u=cnt;
if(H[v])v=H[v];
else H[v]=++cnt,v=cnt;
if(x==S)s=u;
if(y==S)s=v;
if(x==E)t=u;
if(y==E)t=v;
a[u][v]=a[v][u]=min(w,a[u][v]);
}
power(a,n);
printf("%d\n",a[s][t]);
}
总结
三个最短路算法必须掌握
然后就是经典问题
1.次短路及路径方案统计
2.最小环问题
3.传递闭包问题
经典思想
1.分层图
2.二分答案转判定
3.拆分路径统计答案
4.拆图分别计算
最小生成树
定义:在无向图
G
=
(
V
,
E
)
G=(V,E)
G=(V,E)中,一颗连接所有节点的树(无根树)被称为这个无向图的生成树,其中边权之和最小的那一颗被称为最小生成树
定理:最小生成树一定包含无向图中权值最小的边
证明:
反证法,假设无向图G=(V,E)存在一颗最小生成树不包含权值最小的边,把这条边加入最小生成树集合之后会形成一个环,这个环上任意一条边都比新加入的边的权值大,任意断开一条可以得到一个权值和更小的生成树,假设不成立,故命题成立
推论:
给定一张无向图
G
=
(
V
,
E
)
,
n
=
∣
V
∣
,
m
=
∣
E
∣
G=(V,E),n=|V|,m=|E|
G=(V,E),n=∣V∣,m=∣E∣从
E
E
E中选出
k
<
n
−
1
k<n-1
k<n−1条边组成
G
G
G的一个生成森林,若再从剩余
m
−
k
m-k
m−k条边中选
n
−
1
−
k
n-1-k
n−1−k条加入生成森林使其变成一颗生成树,并且选出的边权值最小,则该生成树一定包含这
m
−
k
m-k
m−k条边中连接生成森林的两个不连通节点的权值最小的边
Kruskal算法
K
r
u
s
k
a
l
Kruskal
Kruskal算法就是基于上述推论的。
K
r
u
s
k
a
l
Kruskal
Kruskal算法总是维护无向图的最小生成森林,最初可以认为生成森林是由零条边构成,每个节点各自构成一颗仅包含一个节点的树
在任意时刻
K
r
u
s
k
a
l
Kruskal
Kruskal算法从剩余的边中选出一条权值最小的,并把这条边的两个端点属于的生成树连通,图中节点的连接情况可以用并查集维护
详细的说,步骤如下
- 建立并查集,每个点各自构成一个集合
- 把所有边按权值从小到大排序,依次扫描每一条边 ( u , v , w ) (u,v,w) (u,v,w)
- 如果 u , v u,v u,v属于同一集合,忽略,继续扫描下一条
- 将答案累加上 w w w,并合并 u , v u,v u,v所在集合
- 重复3.4直到所有边扫描完成
时间复杂度为 O ( m log m ) O(m\log m) O(mlogm)
struct node{
int u,v,w;
bool operator<(const node b)const{
w<b.w;
}
}a[100005];
int f[100005],n,m,ans;
int find(int x){return x==f[x]?x:f[x]=find(f[x]);}
int main(){
scanf("%d%d",&n,&m);
for(int i=1;i<=m;i++)scanf("%d%d%d",&a[i].u,&a[i].v,&a[i].w);
sort(a+1,a+m+1);
for(int i=1;i<=n;i++)f[i]=i;
for(int i=1;i<=m;i++){
int u=find(a[i].u),v=find(a[i].v);
if(u==v)continue;
f[u]=v;
ans+=a[i].w;
}
printf("%d",ans);
}
Prim
P
r
i
m
Prim
Prim同样基于推论,与
K
r
u
s
k
a
l
Kruskal
Kruskal不同的是,
p
r
i
m
prim
prim算法始终维护最小生成树的一部分,不断扩大这个部分,最终扩展为一颗最小生成树
我们设
S
S
S表示我们维护的最小生成树集合,
T
T
T表示剩余节点集合,我们每一次需要找到一条边,两个端点分属两个集合且边权最小,然后将这条边累计上答案,属于
T
T
T的那个端点加入到
S
S
S中,直到
∣
S
∣
=
∣
V
∣
|S|=|V|
∣S∣=∣V∣
在具体实现中,最初
S
S
S中只有一号节点,我们维护一个数组
d
d
d,对于每个节点
x
x
x,若
x
∈
S
x\in S
x∈S,则
d
[
x
]
d[x]
d[x]表示
x
x
x加入
S
S
S时所连接的最小边的权值,若
x
∈
T
x\in T
x∈T,则
d
[
x
]
d[x]
d[x]表示
x
x
x与
S
S
S中节点相连的最小边权
类比
D
i
j
k
s
t
r
a
Dijkstra
Dijkstra算法,我们也对其进行更新操作,用数组
v
i
s
vis
vis标记节点是否属于
S
S
S,然后找到不属于
S
S
S的节点的
d
d
d值最小的,将其加入
S
S
S,并且累计答案,然后这个节点对其所有出边且属于
T
T
T的节点进行更新操作,直到所有节点更新完毕
时间复杂度为
O
(
n
2
)
O(n^2)
O(n2)可以使用二叉堆优化为
O
(
m
log
n
)
O(m\log n )
O(mlogn),但这就不如
K
r
u
s
k
a
l
Kruskal
Kruskal算法了,代码复杂度和常数复杂度都是
由于
p
r
i
m
prim
prim算法的时间复杂度仅与节点个数有关,所以常用于稠密图尤其是完全图的最小生成树求解
int prim(){
memset(d,0x3f,sizeof d);
memset(vis,0,sizeof vis);
d[1]=0;
for(int i=1;i<n;i++){
int num=-1;
for(int j=1;j<=n;j++){
if(!vis[j]&&(num==-1||d[num]>d[j]))num=j;
}
vis[num]=1;
for(int j=1;j<=n;j++){
if(!vis[j])d[j]=min(d[j],a[num][j]);
}
}
for(int i=2;i<=n;i++)ans+=d[i];
return ans;
}
因为 d d d数组的定义,于是 ∑ i = 1 n d [ i ] \sum_{i=1}^nd[i] ∑i=1nd[i]就是答案
最小生成树的应用
例题1:北极网络
国防部(DND)希望通过无线网络连接几个北部前哨站。
在建立网络时将使用两种不同的通信技术:每个前哨站都有一个无线电收发器,一些前哨站还有一个通信卫星。
任意两个拥有通信卫星的前哨站不论它们的位置如何,都可以通过卫星进行通信。
而如果利用无线电进行通信,则需要两个前哨站的距离不能超过 D D D 方可进行通信。
而 D D D 的大小取决于收发器的功率,收发器的功率越大, D D D 也就越大,但是需要的成本也就越高。
出于采购和维护的考虑,所有的前哨站都采用相同的收发器,也就是说所有前哨站的无线电通信距离 D D D 都是相同的。
你需要确定在保证任意两个前哨站之间都能进行通信(直接或间接)的情况下, D D D 的最小值是多少。
输入格式
第一行包含整数
N
N
N,表示共有
N
N
N 组测试数据。
每组数据的第一行包含两个整数 S S S 和 P P P,其中 S S S 为卫星个数, P P P 为前哨站个数。
接下来 P P P 行每行包含两个整数 x x x 和 y y y,分别表示一个前哨站的横纵坐标。
输出格式
输出一个实数,表示
D
D
D 的最小值,结果保留两位小数。
数据范围
1
≤
S
≤
100
,
1≤S≤100,
1≤S≤100,
S
≤
P
≤
500
,
S≤P≤500,
S≤P≤500,
0
≤
x
,
y
≤
10000
0≤x,y≤10000
0≤x,y≤10000
分析:
首先这道题是一个完全图,我们需要对每一个前哨站互相连边
算法1:二分答案
单调性很容易发现,
D
D
D更大肯定比
D
D
D更小使得可以通信的前哨站更多,于是我们可以二分答案,设当前二分的值是
m
i
d
mid
mid,我们将边权大于
m
i
d
mid
mid的边赋值为
1
1
1,小于
m
i
d
mid
mid的边赋值为0,很明显,我们假设所有边权为
0
0
0的边所连接到的点构成的若干个连通块,而我们的卫星可以使得连通块相连,即对每一个连通块安装一个卫星,就可以连通整张图,于是我们只需要统计连通块的个数,与
S
S
S相比较即可确定二分上下界变化,对于连通块的求解需要使用并查集维护
const int N = 510;
const double eps = 1e-4;
int n, m, x[N], y[N], fa[N];
double d[N][N];
int findfa(int x) { return x == fa[x] ? x : fa[x] = findfa(fa[x]); }
bool check(double now) {
for (int i = 1; i <= n; i++) fa[i] = i;
for (int i = 1; i < n; i++)
for (int j = i + 1; j <= n; j++)
if (d[i][j] <= now) fa[findfa(i)] = findfa(j);
int sum = 0;
for (int i = 1; i <= n; i++)
if (fa[i] == i) sum++;
return sum <= m;
}
int main() {
int tt; cin >> tt;
while (tt--) {
cin >> m >> n;
for (int i = 1; i <= n; i++)
scanf("%d%d", &x[i], &y[i]);
for (int i = 1; i < n; i++)
for (int j = i + 1; j <= n; j++)
d[i][j] = (double)sqrt((x[i] - x[j]) * (x[i] - x[j]) + (y[i] - y[j]) * (y[i] - y[j]));
double l = 0.0, r = 14145.0, mid;
while (l + eps < r) {
mid = (l + r) / 2;
if (check(mid)) r = mid;
else l = mid;
}
printf("%.2lf\n", r);
}
return 0;
}
算法2:
K
r
u
s
k
a
l
Kruskal
Kruskal
由上文推论,在本题中,我们实质上需要维护最小生成树的一个子图,满足子图上最大边的边权不超过
D
D
D,由推论性质得到,我们最小生成树有
P
−
1
P-1
P−1条边,而卫星可以使得
S
−
1
S-1
S−1条边不计入,于是我们只需要统计最小生成树里第
P
−
1
−
(
S
−
1
)
=
P
−
S
P-1-(S-1)=P-S
P−1−(S−1)=P−S大的边的边权就是答案
int p,s,f[100005],cnt,vis[100005],m;
struct node{
int len,u,v;
bool operator<(const node b)const{
return len<b.len;
}
}a[1000005];
pair<int,int>in[100005];
int get(pair<int,int> a,pair<int ,int> b){
return (a.first-b.first)*(a.first-b.first)+(a.second-b.second)*(a.second-b.second);
}
int find(int x){
return x==f[x]?x:f[x]=find(f[x]);
}
void init(){
m=0;
scanf("%d%d",&s,&p);
for(int i=1;i<=p;i++)scanf("%d%d",&in[i].first,&in[i].second);
for(int i=1;i<=p;i++){
for(int j=i+1;j<=p;j++){
a[++m]={get(in[i],in[j]),i,j};
}
}
sort(a+1,a+m+1);
}
int Kruskal(){
memset(vis,0,sizeof vis);
for(int i=1;i<=p;i++)f[i]=i;
cnt=0;
for(int i=1;i<=m;i++){
int u=a[i].u,v=a[i].v;
u=find(u),v=find(v);
if(v==u)continue;
if(++cnt==p-s)return a[i].len;
f[u]=v;
}
}
int main(){
freopen("1.in","r",stdin);
int n;
scanf("%d",&n);
while(n--){
init();
printf("%.2f",sqrt(Kruskal()));
// for(int i=1;i<=m;i++)printf("%.3f ",sqrt(a[i].len));
puts("");
}
}
算法1的时间复杂度是
O
(
n
2
log
V
)
O(n^2\log V)
O(n2logV),算法2的时间复杂度是
O
(
n
2
log
n
)
O(n^2\log n)
O(n2logn),理论上讲算法2更优
这道题启发我们:
1.发掘题目单调性,使用二分判定答案
2.考虑Kruskal和prim思想的本质,理解最小生成树定理
例题2:沙漠之王
简单的说题意,就是求最优比率生成树,即每条边有成本
c
o
s
t
cost
cost和长度
l
e
n
len
len,要求
∑
c
o
s
t
∑
l
e
n
\frac{\sum cost}{\sum len}
∑len∑cost的最小值
很明显,这是一道0/1分数规划与最小生成树的综合题
我们只需要建立一张图,每个边的权值是
c
o
s
t
i
−
m
i
d
×
l
e
n
i
cost_i-mid\times len_i
costi−mid×leni,对这张图求最小生成树,若边权和非负0则令
l
=
m
i
d
l=mid
l=mid,否则令
r
=
m
i
d
r=mid
r=mid
为什么是最小生成树而不是最大生成树呢?因为只有取最小生成树的时候,我们满足这张图其余生成树的答案一定不小于最小生成树的比率,否则就可以继续减小(这里蓝书上出错了)
double cost[1005][1005];int n,vis[1005];
double d[1005][1005],dis[1005];
double b[1005][1005];
struct node{
int x,y,h;
}a[1005];
double get(node a,node b){
return sqrt((a.x-b.x)*(a.x-b.x)+(a.y-b.y)*(a.y-b.y));
}
void init(){
for(int i=1;i<=n;i++){
for(int j=i;j<=n;j++){
cost[i][j]=cost[j][i]=get(a[i],a[j]);
d[i][j]=d[j][i]=abs(a[i].h-a[j].h);
}
}
}
bool check(double mid){
// printf("%.5f\n",mid);
for(int i=1;i<=n;i++){
for(int j=i;j<=n;j++){
if(i==j){
b[i][j]=2e9;
continue;
}
b[i][j]=b[j][i]=d[i][j]-mid*cost[i][j];
// printf("%.5f ",b[i][j]);
}
// puts("");
}
memset(vis,0,sizeof vis);
for(int i=1;i<=n;i++)dis[i]=2e9;
dis[1]=0;
for(int i=1;i<n;i++){
double now;int id=0;
for(int j=1;j<=n;j++){
if(!vis[j]&&(id==0||(now>dis[j])))id=j,now=dis[j];
}
// printf("%d\n",id);
vis[id]=1;
for(int j=1;j<=n;j++){
if(!vis[j]){
// printf("A%d %.4f ",j,dis[j]);
dis[j]=min(dis[j],b[id][j]);
// printf("%.4f\n",dis[j]);
}
}
}
double ans=0;
for(int i=2;i<=n;i++)ans+=dis[i];
//printf("%.3f\n",ans);
return ans>=0;
}
int main(){
//freopen("10.in","r",stdin);
while(~scanf("%d",&n)&&n){
for(int i=1;i<=n;i++){
scanf("%d%d%d",&a[i].x,&a[i].y,&a[i].h);
}
init();
double l=0,r=50;
while(r-l>1e-8){
double mid=(l+r)/2;
if(check(mid))l=mid;
else r=mid;
}
printf("%.3f\n",(l+r)/2);
}
}
这道题是最优比率生成树模型,非常重要
例题3:黑暗城堡
在顺利攻破 L o r d l s p Lordlsp Lordlsp 的防线之后, l q r lqr lqr 一行人来到了 L o r d l s p Lordlsp Lordlsp 的城堡下方。
L o r d l s p Lord lsp Lordlsp 黑化之后虽然拥有了强大的超能力,能够用意念力制造建筑物,但是智商水平却没怎么增加。
现在 l q r lqr lqr 已经搞清楚黑暗城堡有 N N N 个房间, M M M 条可以制造的双向通道,以及每条通道的长度。
l q r lqr lqr 深知 L o r d l s p Lord lsp Lordlsp 的想法,为了避免每次都要琢磨两个房间之间的最短路径, L o r d l s p Lord lsp Lordlsp 一定会把城堡修建成树形的。
但是,为了尽量提高自己的移动效率, L o r d l s p Lord lsp Lordlsp 一定会使得城堡满足下面的条件:
设 D [ i ] D[i] D[i] 为如果所有的通道都被修建,第 i i i 号房间与第 1 1 1 号房间的最短路径长度;而 S [ i ] S[i] S[i] 为实际修建的树形城堡中第 i i i 号房间与第 1 1 1 号房间的路径长度;要求对于所有整数 i i i,有 S [ i ] = D [ i ] S[i]=D[i] S[i]=D[i] 成立 。
为了打败 L o r d l s p Lord lsp Lordlsp, l q r lqr lqr 想知道有多少种不同的城堡修建方案。
你需要输出答案对 2 3 1 – 1 2^31–1 231–1 取模之后的结果。
输入格式
第一行有两个整数
N
N
N 和
M
M
M。
之后 M M M 行,每行三个整数 X X X, Y Y Y 和 L L L,表示可以修建 X X X 和 Y Y Y 之间的一条长度为 L L L 的通道。
输出格式
一个整数,表示答案对 231–1 取模之后的结果。
数据范围
2
≤
N
≤
1000
,
2≤N≤1000,
2≤N≤1000,
N
−
1
≤
M
≤
N
(
N
−
1
)
/
2
,
N−1≤M≤N(N−1)/2,
N−1≤M≤N(N−1)/2,
1
≤
L
≤
100
1≤L≤100
1≤L≤100
简单来说,本题让我们统计在一张无向图里有多少最短路径生成树,最短路径生成树是指对于任意边
(
u
,
v
,
w
)
(u,v,w)
(u,v,w)都有
D
[
u
]
+
u
=
D
[
v
]
D[u]+u=D[v]
D[u]+u=D[v]
我们可以先使用
D
i
j
k
s
t
r
a
Dijkstra
Dijkstra求出D数组,然后统计
∀
v
∈
[
1
,
n
]
\forall v\in[1,n]
∀v∈[1,n]有多少个
u
u
u满足
D
[
u
]
+
w
u
,
v
=
D
[
v
]
D[u]+w_{u,v}=D[v]
D[u]+wu,v=D[v],记为
c
n
t
v
cnt_v
cntv,最后把所有的cnt乘起来即可,至于原因,因为正权图的原因,对于cnt的计算可以使用对D从小到大排序之后按顺序统计,由于数据较小我就直接上
n
2
n^2
n2了其实是我没调好
正确性证明:很明显,当
u
u
u满足
D
[
u
]
+
w
u
,
v
=
D
[
v
]
D[u]+w_{u,v}=D[v]
D[u]+wu,v=D[v],此时从v连接所有的u都不会影响最短路径生成树,由乘法原理即可得到,这是无后效性的,因为最终n个节点必定会连通
#define x first
#define y second
#define mp make_pair
#define int long long
#define p ((1ll<<31)-1)
priority_queue<pair<int,int> >q;
pair<int,int>a[100005];
int head[10005],nxt[1000005],ver[1000005],cost[1000005],d[1000005],vis[1000005],tot,n,m,cnt[100050];
void add(int u,int v,int w){
nxt[++tot]=head[u],ver[tot]=v,cost[tot]=w,head[u]=tot;
}
void dijkstra(){
q.push(mp(0,1));
memset(d,0x3f,sizeof d);
d[1]=0;
while(q.size()){
int u=q.top().y,w=d[u];q.pop();
if(vis[u])continue;
vis[u]=1;
for(int i=head[u];i;i=nxt[i]){
int v=ver[i],z=cost[i];
if(d[v]>w+z){
d[v]=w+z;
q.push(mp(-d[v],v));
}
}
}
//for(int i=1;i<=n;i++)printf("%d ",d[i]);
//puts("");
}
int prim(){
for(int u=1;u<=n;u++){
for(int i=head[u];i;i=nxt[i]){
int v=ver[i];
if(d[u]+cost[i]==d[v])cnt[v]++;
}
}
int ans=1;
for(int i=1;i<=n;i++)if(cnt[i])ans=ans*cnt[i]%p;
return ans;
}
signed main(){
// freopen("castle8.in","r",stdin);
scanf("%lld%lld",&n,&m);
for(int i=1;i<=m;i++){
int u,v,w;
scanf("%lld%lld%lld",&u,&v,&w);
add(u,v,w);
add(v,u,w);
}
dijkstra();
printf("%lld\n",prim());
}
这道题是关于最短路径生成树模型的,并且求方案数,结论务必理解记忆
例题4野餐规划
一群小丑演员,以其出色的柔术表演,可以无限量的钻进同一辆汽车中,而闻名世界。
现在他们想要去公园玩耍,但是他们的经费非常紧缺。
他们将乘车前往公园,为了减少花费,他们决定选择一种合理的乘车方式,可以使得他们去往公园需要的所有汽车行驶的总公里数最少。
为此,他们愿意通过很多人挤在同一辆车的方式,来减少汽车行驶的总花销。
由此,他们可以很多人驾车到某一个兄弟的家里,然后所有人都钻进一辆车里,再继续前进。
公园的停车场能停放的车的数量有限,而且因为公园有入场费,所以一旦一辆车子进入到公园内,就必须停在那里,不能再去接其他人。
现在请你想出一种方法,可以使得他们全都到达公园的情况下,所有汽车行驶的总路程最少。
第一行包含整数 n,表示人和人之间或人和公园之间的道路的总数量。
接下来 n 行,每行包含两个字符串 A、B 和一个整数 L,用以描述人 A 和人 B 之前存在道路,路长为 L,或者描述某人和公园之间存在道路,路长为 L。
道路都是双向的,并且人数不超过 20,表示人的名字的字符串长度不超过 10,公园用 Park 表示。
再接下来一行,包含整数 s,表示公园的最大停车数量。
你可以假设每个人的家都有一条通往公园的道路。
简述题意,其实就是说,求图上的一颗生成树,使得1号节点的入度不超过S的情况下权值和最小
这里介绍一种并不常用的最小生成树算法:消圈算法
简单来说,就是在无向图上随便指定一颗生成树,然后考虑剩下的边,加入一个就会产生环,然后把环上最大的边断开,这样重复操作直到加入的边比环上最大边还要大为止,这样就得到了最小生成树,只不过由于程序实现比较复杂,复杂度也并不突出,于是就很少用(最古老的最小生成树算法)
对于本题,我们需要使用这种思想
首先,我们将1号节点与其他节点的边断开,这样我们就得到了若干个连通块,对每一个连通块分别求最小生成树,设有T个连通块,若T>S本题无解,因为不可能连通
然后我们尝试对于一个连通块T_i,T_i中所有与1有边的节点,我们选择最小的那一个进行连接,这样我们就连通了这个连通块,以此类推,我们就得到了原图的一棵生成树
接着我们就使用消圈算法的思想,尝试改动S-T条边使得答案更优,由于每个连通块内部再无需改动了,因为连通块内部求了最小生成树,于是我们的改动也只与1号节点有关
我们考虑与1号节点有边但边没有选入生成树的节点,将其加入生成树后势必会形成一个环,在环上找最大的边(假设这个点是x,则我们需要找到的最大的边就是原生成树上1->x的最大边)进行断开,就得到了一个权值和更小的生成树,重复这个步骤直到改动完S-T条边或者改动的边无法加入生成树为止,这个步骤需要我们维护1号节点在生成树上到每个节点所经过的边的边权最大值,可以使用深度优先遍历维护
int cnt,m,s,deg,ans,a[32][32],d[32],lst[32],vis[32],c[32],t[32][32],ver[32],p,f[32],mxx[32],mxy[32];
//t数组维护目前的最小生成树森林,f、mxx、mxy维护的是1->x的路径上的最大边
map<string,int>H;
void prim(int rt){//求连通块内部最小生成树,使用prim更好处理
d[rt]=0;
for(int i=1;i<=p;i++){
int u=0;
for(int j=1;j<=p;j++)
if(!vis[ver[j]] &&(u==0||d[ver[j]]<d[u]))u=ver[j];
vis[u]=1;
for(int j=1;j<=p;j++){
int v=ver[j];
if(!vis[v]&&d[v]>a[u][v])
d[v]=a[u][v],lst[v]=u;
}
}
int maxx=rt;
for(int i=1;i<=p;i++){
int u=ver[i];
if(rt==u)continue;
ans+=d[u];
t[lst[u]][u]=t[u][lst[u]]=d[u];
if(a[1][u]<a[1][maxx])maxx=u;
}
deg++;
ans += a[1][maxx];
t[1][maxx]=t[maxx][1]=a[1][maxx];
}
void dfs(int u){//划分连通块
ver[++p]=u;
c[u]=1;
for(int v=1;v<=cnt;v++)
if(a[u][v] != 0x3f3f3f3f && !c[v])dfs(v);
}
void Prim(){//求各个连通块内的最小生成树
memset(d,0x3f,sizeof(d));
memset(vis,0,sizeof(vis));
memset(t,0x3f,sizeof(t));
c[1]=1;
for(int i=2;i<=cnt;i++)
if(!c[i]){
p=0;
dfs(i);
prim(i);
}
}
void dp(int u){//计算f、mxx、mxy,
vis[u]=1;
for(int v=2;v<=cnt;v++)
if(t[u][v] != 0x3f3f3f3f && !vis[v]){
if(f[u]>t[u][v])
f[v]=f[u],mxx[v]=mxx[u],mxy[v]=mxy[u];
else
f[v]=t[u][v],mxx[v]=u,mxy[v]=v;
dp(v);
}
vis[u]=0;
}
bool solve(){//执行最后的消圈过程
int mn=1<<30,id;
for(int i=2;i<=cnt;i++){
if(t[1][i]!=0x3f3f3f3f||a[1][i]==0x3f3f3f3f)continue;
if(a[1][i]-t[mxx[i]][mxy[i]]<mn){
mn=a[1][i]-t[mxx[i]][mxy[i]];
id=i;
}
}
if(mn>=0)return 0;
ans+=mn,t[1][id]=t[id][1]=a[1][id];
t[mxx[id]][mxy[id]]=t[mxy[id]][mxx[id]]=0x3f3f3f3f;
f[id]=a[1][id],mxx[id]=1,mxy[id]=id;
vis[1]=1;
dp(id);
return 1;
}
int main(){
H["Park"]=1;cnt=1;//注意P是大写的(调试2h就因为这个)
scanf("%d",&m);
memset(a,0x3f,sizeof(a));
for(int i=1;i<=m;i++){
int u,v,w;
char x[15],y[15];
scanf("%s%s%d",x,y,&w);
if(!H[x])H[x]=++cnt;
if(!H[y])H[y]=++cnt;
u=H[x],v=H[y];
a[u][v]=a[v][u]=min(a[u][v], w);
}
scanf("%d",&s);
Prim();
memset(vis,0,sizeof(vis));
dp(1);
while(deg<s){
if(!solve())break;
deg++;
}
printf("Total miles driven: %d\n",ans);
}
此题启发我们消圈思想,拆图计算,合并消圈
例题5:四叶草魔杖
题目描述
给定一张无向图,结点和边均有权值。所有结点权值之和为 0,点权可以沿边传递,传递点权的代价为边的权值。求让所有结点权值化为 0 的最小代价。
解法
容易想到本题与最小生成树有关。一种不难想出的思路是求出原图的最小生成树,将最小生成树上所有边的权值之和作为答案。
但经过思考,可以发现这样得到的不一定是最优解。首先,原图可能并不联通;其次,可以将原图划分为若干个点权之和均为 0 的子图,在这些子图中分别转移点权,最后将答案合并。这样得到的方案或许会更优。
此时我们发现划分方案不止一种,如何确定最终的方案成了需要解决的最大问题。
注意到本题中 N N N 范围较小,允许我们把所有点权和为 0 的子图(以下简称“合法子图”)的最小生成树全部求出。因此可以先枚举原图点集的所有子集,对于每个点权和为 0 的点集,用这些点和连接它们的边构造一张合法子图。我们能够轻易求出这些合法子图的最小生成树。但有些合法子图或许并不联通,为避免对之后的求解造成影响,需要把这些子图的最小生成树边权和设为 ∞ ∞ ∞.
接下来需要把这些子图中的若干个合并起来,得到全局最优解。与划分的情形相同,合并这些子图的方案也有多种。可以使用 D P DP DP 得到最优解。
具体地,考虑进行类似背包的 D P DP DP,将每个合法子图视作可以放入背包的一个物品。设 A 、 B A、B A、B为两个不同合法子图的点集,合法子图的最小生成树边权和为 S S S,可以写出如下状态转移方程:
f
A
∪
B
=
min
{
f
A
∪
B
,
f
A
+
S
B
}
,
A
∩
B
=
⊘
f_{A∪B}=\min\lbrace f_{A∪B},f_A+S_B\rbrace,A∩B=⊘
fA∪B=min{fA∪B,fA+SB},A∩B=⊘
最终
f
2
n
−
1
f_{2^n-1}
f2n−1 即为所求的答案。至此,本题得到解决。
int n,m,a[20],fa[20],s[1<<16],f[1<<16],ans[1<<16];
struct node{
int u,v,w;
bool operator <(const node b)const{
return w<b.w;
}
}t[150];
int find(int x){return fa[x]==x?x:fa[x]=find(fa[x]);}
int kruskal(int S){
int ans=0;
for(int i=0;i<n;i++)
if(S&(1<<i))fa[i]=i;
for(int i=1;i<=m;i++){
if(!(S&(1<<t[i].u)&&S&(1<<t[i].v)))continue;
int u=find(t[i].u),v=find(t[i].v);
if(u!=v){
fa[u]=v;
ans+=t[i].w;
}
}
int op=-1;
for(int i=0;i<n;i++)
if(S&(1<<i))
if(op==-1)op=find(i);
else if(find(i)!=op)return 0x3f3f3f3f;
return ans;
}
int main(){
scanf("%d%d",&n,&m);
for(int i=0;i<n;i++)
scanf("%d",&a[i]);
for(int i=1;i<(1<<n);i++)
for(int j=0;j<n;j++)
if(i&(1<<j))s[i]+=a[j];
if(!m){
if(s[(1<<n)-1])puts("Impossible");
else puts("0");
return 0;
}
for(int i=1;i<=m;i++)
scanf("%d%d%d",&t[i].u,&t[i].v,&t[i].w);
sort(t+1,t+m+1);
for(int i=1;i<(1<<n);i++){
if(s[i]==0)ans[i]=kruskal(i);
f[i]=0x3f3f3f3f;
}
f[0]=0;
for(int i=1;i<(1<<n);i++){
if(s[i])continue;
for(int j=0;j<(1<<n);j++)
if(!(i&j))
f[i|j]=min(f[i|j],f[j]+ans[i]);
}
if(f[(1<<n)-1]==0x3f3f3f3f)printf("Impossible");
else printf("%d",f[(1<<n)-1]);
}
这题是一道动态规划与最小生成树的综合题,主要启发我们缜密思考题目,学会把题目转化为图论模型
总的来说,今天这一节需要掌握的知识点有
- 最小生成树的kruskal和prim算法,消圈算法思想
- 最小生成树定理及推论
- 最优比率生成树,结合0/1分数规划模型
- 最短路径生成树的定义与性质
需要掌握的思想有 - 拆分图形,分而治之
- 单调性思想
- DP结合图论
树的直径与LCA
树的直径
定义:设
d
i
s
[
i
,
j
]
dis[i,j]
dis[i,j]表示
i
,
j
i,j
i,j在树中的距离,则树的直径(
d
i
a
m
e
t
e
r
diameter
diameter,本文简记
d
i
a
dia
dia)
d
i
a
=
d
i
s
[
u
,
v
]
(
∀
i
,
j
,
d
i
s
[
i
,
j
]
≤
d
i
s
[
u
,
v
]
)
dia=dis[u,v](\forall i,j,dis[i,j]\le dis[u,v])
dia=dis[u,v](∀i,j,dis[i,j]≤dis[u,v]),通俗的讲,树的直径是树中最长的一条链
性质:
- 一棵树可能有不止一个直径
- 一棵树的直径有唯一的中点
- 我们称树的不同直径的公共边为必须边
- 树的所有必须边构成一条链
关于性质4的证明:
反证法,我们假设必须边构成两条链
由必须边和树的直径的定义,这两条链一定在树的一条直径 F F F上,那么设这两条链中间部分路径为 T T T, F F F上两条链中间为 S S S,则有 v a l ( T ) = v a l ( S ) val(T)=val(S) val(T)=val(S),而由树的直径的定义, v a l ( S ) val(S) val(S)最大且唯一,以此类推n条链的情况,可知假设不成立,原命题成立
树的直径的求法
1.DP法,使用较少,掌握BFS就可以了
void dp(int x) {
v[x] = 1;
for (int i = head[x]; i; i = Next[i]) {
int y = ver[i];
if (v[y]) continue;
dp(y);
ans = max(ans, d[x] + d[y] + edge[i]);
d[x] = max(d[x], d[y] + edge[i]);
}
}
2.DFS/BFS法
概述,我们先任选一个节点,假设1,求出所有节点到1的距离d,然后找到d值最大的节点p,再求出所有节点到p的距离d1,d1值最大的节点q,p->q的路径就是树的直径
证明:我们p节点就是树的最深的一端,然后以p为根再求出q,我们就相当于求出了树的两个最深的端点,连起来就是答案,证明限于篇幅,感性理解就行
对于这个求d数组的过程,使用DFS/BFS都可以,一般来讲BFS已经足够,DFS就略去,反正也一样的道理
tot=1;
int bfs(int t){
memset(d,-1,sizeof d);
d[t]=0;
queue<int>q;q.push(t);
while(q.size()){
int u=q.front();q.pop();
for(int i=head[u];i;i=nxt[i]){
int v=ver[i];
if(d[v]!=-1)continue;
lst[v]=i;
d[v]=d[u]+cost[i];
q.push(v);
}
}
int p=-1;
for(int i=1;i<=n;i++)if(p==-1||s[p]<s[i])p=i;
return p;
}
//主函数内调用
dfs(1);
int p=find();
dfs(p);
int q=find();
while(p!=q){
b[++cnt]=q;
q=ver[lst[q]^1];
}
b[++cnt]=p;
reverse(b+1,b+cnt+1);
//求树的直径整个链,因为倒着回来,所以在讲究顺序的时候需要翻转
例题1树网的核
题目描述
设
T
=
(
V
,
E
,
W
)
T=(V,E,W)
T=(V,E,W) 是一个无圈且连通的无向图(也称为无根树),每条边都有正整数的权,我们称
T
T
T 为树网(treenetwork
),其中
V
V
V,
E
E
E 分别表示结点与边的集合,
W
W
W 表示各边长度的集合,并设
T
T
T 有
n
n
n 个结点。
路径:树网中任何两结点
a
a
a,
b
b
b 都存在唯一的一条简单路径,用
d
(
a
,
b
)
d(a, b)
d(a,b) 表示以
a
,
b
a, b
a,b 为端点的路径的长度,它是该路径上各边长度之和。我们称
d
(
a
,
b
)
d(a, b)
d(a,b) 为
a
,
b
a, b
a,b 两结点间的距离。
D ( v , P ) = min { d ( v , u ) } D(v, P)=\min\{d(v, u)\} D(v,P)=min{d(v,u)}, u u u 为路径 P P P 上的结点。
树网的直径:树网中最长的路径成为树网的直径。对于给定的树网 T T T,直径不一定是唯一的,但可以证明:各直径的中点(不一定恰好是某个结点,可能在某条边的内部)是唯一的,我们称该点为树网的中心。
偏心距 E C C ( F ) \mathrm{ECC}(F) ECC(F):树网 T T T 中距路径 F F F 最远的结点到路径 F F F 的距离,即
E C C ( F ) = max { D ( v , F ) , v ∈ V } \mathrm{ECC}(F)=\max\{D(v, F),v \in V\} ECC(F)=max{D(v,F),v∈V}
任务:对于给定的树网
T
=
(
V
,
E
,
W
)
T=(V, E, W)
T=(V,E,W) 和非负整数
s
s
s,求一个路径
F
F
F,他是某直径上的一段路径(该路径两端均为树网中的结点),其长度不超过
s
s
s(可以等于
s
s
s),使偏心距
E
C
C
(
F
)
\mathrm{ECC}(F)
ECC(F) 最小。我们称这个路径为树网
T
=
(
V
,
E
,
W
)
T=(V, E, W)
T=(V,E,W) 的核(Core
)。必要时,
F
F
F 可以退化为某个结点。一般来说,在上述定义下,核不一定只有一个,但最小偏心距是唯一的。
下面的图给出了树网的一个实例。图中,
A
−
B
A-B
A−B 与
A
−
C
A-C
A−C 是两条直径,长度均为
20
20
20。点
W
W
W 是树网的中心,
E
F
EF
EF 边的长度为
5
5
5。如果指定
s
=
11
s=11
s=11,则树网的核为路径DEFG
(也可以取为路径DEF
),偏心距为
8
8
8。如果指定
s
=
0
s=0
s=0(或
s
=
1
s=1
s=1、
s
=
2
s=2
s=2),则树网的核为结点
F
F
F,偏心距为
12
12
12。
输入格式
共 n n n 行。
第 1 1 1 行,两个正整数 n n n 和 s s s,中间用一个空格隔开。其中 n n n 为树网结点的个数, s s s 为树网的核的长度的上界。设结点编号以此为 1 , 2 … , n 1,2\dots,n 1,2…,n。
从第
2
2
2 行到第
n
n
n 行,每行给出
3
3
3 个用空格隔开的正整数
u
,
v
,
w
u, v, w
u,v,w,依次表示每一条边的两个端点编号和长度。例如,2 4 7
表示连接结点
2
2
2 与
4
4
4 的边的长度为
7
7
7。
输出格式
一个非负整数,为指定意义下的最小偏心距。
分析
仔细观察题目,我们可以得到如下性质
- 对于两条路径 S 1 , S 2 S1,S2 S1,S2,若 S 1 ∈ S 2 S1\in S2 S1∈S2,则 E C C ( S 2 ) ≤ E C C ( S 1 ) ECC(S2)\le ECC(S1) ECC(S2)≤ECC(S1),对于这个性质,我们可以得到一个很强的推论,即在长度不超过s的情况下,路径越长越好
- 最小偏心距具有单调性
- 不同直径求出来的最小偏心距相同,证明:不同直径有唯一中点,那么对于两条不同直径,我们可以通过组合变成四条直径,也就是把原本两条直径按中点砍成两半一共四个链,此时一定存在两组链长度相等,根据直径的最长性,不同组上的链上的点u的D值就在另一组的两条链的末端都满足,进一步可以扩展到n条链,得证
于是我们得到一个类似DP的算法
先求出树的直径,设直径上的点集为 u 1 ∼ u t u_1\sim u_t u1∼ut,d数组也可以被我们 O ( n ) d f s O(n)dfs O(n)dfs预处理出来,表示不经过直径上的其他节点在树上的最远距离,则将题目所给偏心距公式可写为
以 u i , u j u_i,u_j ui,uj为两个端点的树网的核的偏心距为
max
1
≤
i
≤
j
≤
t
,
d
i
s
(
i
,
j
)
≤
s
(
max
i
≤
k
≤
j
{
u
k
}
,
d
i
s
(
u
1
,
u
i
)
,
d
i
s
(
u
j
,
u
t
)
)
\max_{1\le i\le j\le t,dis(i,j)\le s}\left(\max_{i\le k \le j}\lbrace u_k\rbrace,dis(u_1,u_i),dis(u_j,u_t)\right)
1≤i≤j≤t,dis(i,j)≤smax(i≤k≤jmax{uk},dis(u1,ui),dis(uj,ut))
由于
i
≤
j
,
i
,
j
i\le j,i,j
i≤j,i,j都是单调递增的,满足使用单调队列优化的性质,于是我们采用单调队列对式子
max
i
≤
k
≤
j
{
u
k
}
\max_{i\le k\le j}\lbrace u_k \rbrace
maxi≤k≤j{uk}进行优化,就可以做到
O
(
n
)
O(n)
O(n)
但我们还可以进一步进行优化,由于直径的最长性,任何从直径上的点
u
i
,
u
j
u_i,u_j
ui,uj分叉出去的子树,对于
u
j
u_j
uj的距离不可能比
d
i
s
(
u
i
,
u
j
)
dis(u_i,u_j)
dis(ui,uj)更大,否则直径就该换得了,于是
max
i
≤
k
≤
j
{
u
k
}
可化为
max
1
≤
k
≤
t
{
u
k
}
\max_{i\le k\le j}\lbrace u_k \rbrace可化为\max_{1\le k\le t}\lbrace u_k \rbrace
maxi≤k≤j{uk}可化为max1≤k≤t{uk},于是原式可变为
max
1
≤
i
≤
j
≤
t
,
d
i
s
(
i
,
j
)
≤
s
(
max
1
≤
k
≤
t
{
u
k
}
,
d
i
s
(
u
1
,
u
i
)
,
d
i
s
(
u
j
,
u
t
)
)
\max_{1\le i\le j\le t,dis(i,j)\le s}\left(\max_{1\le k\le t}\lbrace u_k \rbrace,dis(u_1,u_i),dis(u_j,u_t)\right)
1≤i≤j≤t,dis(i,j)≤smax(1≤k≤tmax{uk},dis(u1,ui),dis(uj,ut))
至于
i
,
j
i,j
i,j的枚举,采用双指针扫描法可以在
O
(
n
)
O(n)
O(n)的复杂度搞出来
综上,我们得到了一个
O
(
n
)
O(n)
O(n)的优秀算法(据说NOIP原题数据范围
n
≤
300
n\le 300
n≤300)
int ver[1000005],nxt[1000005],head[500005],cost[1000005],tot=1,d[1000005],f[1000005],pre[1000005],vis[1000005],n,s,mx=0xcfcfcfcf,ans=0x3f3f3f3f;
int t[1000005],num,sum[1000005],b[1000005],a[1000005];
void add(int u,int v,int w){
nxt[++tot]=head[u],ver[tot]=v,cost[tot]=w,head[u]=tot;
}
queue<int>p;
int bfs(int s){
memset(d,-1,sizeof d);
p.push(s);d[s]=0;
while(p.size()){
int u=p.front();p.pop();
for(int i=head[u];i;i=nxt[i]){
int v=ver[i];
if(d[v]!=-1)continue;
d[v]=d[u]+cost[i];
pre[v]=i;
p.push(v);
}
}
int q=-1;
for(int i=1;i<=n;i++)if(q==-1||d[q]<d[i])q=i;
return q;
}
void dfs(int u){
vis[u]=1;
for(int i=head[u];i;i=nxt[i]){
int v=ver[i];
if(vis[v])continue;
dfs(v);
f[u]=max(f[u],f[v]+cost[i]);
}
}
int main(){
scanf("%d%d",&n,&s);
for(int i=1;i<n;i++){
int u,v,w;
scanf("%d%d%d",&u,&v,&w);
add(u,v,w);
add(v,u,w);
}
int p=bfs(1);
int q=bfs(p);
while(p!=q){
a[++num]=q;
b[num+1]=cost[pre[q]];
q=ver[pre[q]^1];
}
a[++num]=p;
for(int i=1;i<=num;i++)vis[a[i]]=1;
for(int i=1;i<=num;i++){
dfs(a[i]);
mx=max(mx,f[a[i]]);
sum[i]=sum[i-1]+b[i];
}
for(int i=1,j=1;i<=num;i++){
while(j<num&&sum[j+1]-sum[i]<=s)j++;
ans=min(ans,max(mx,max(sum[i],sum[num]-sum[j])));
}
printf("%d\n",ans);
return 0;
}
例题2直径
题目描述
小Q最近学习了一些图论知识。根据课本,有如下定义。树:无回路且连通的无向图,每条边都有正整数的权值来表示其长度。如果一棵树有 N N N个节点,可以证明其有且仅有 N − 1 N-1 N−1 条边。
路径:一棵树上,任意两个节点之间最多有一条简单路径。我们用 d i s ( a , b ) dis(a,b) dis(a,b)表示点 a a a和点 b b b的路径上各边长度之和。称 d i s ( a , b ) dis(a,b) dis(a,b)为 a 、 b a、b a、b两个节点间的距离。
直径:一棵树上,最长的路径为树的直径。树的直径可能不是唯一的。
现在小Q想知道,对于给定的一棵树,其直径的长度是多少,以及有多少条边满足所有的直径都经过该边。
输入格式
第一行包含一个整数N,表示节点数。 接下来N-1行,每行三个整数a, b, c ,表示点 a和点b之间有一条长度为c的无向边。
输出格式
共两行。第一行一个整数,表示直径的长度。第二行一个整数,表示被所有直径经过的边的数量。
分析
我们上文提到的树的性质,第一问就不说了,板子。此题实际上就是让我们求必须边的数量,由必须边的性质:由所有的必须边组成一条链
于是我们的问题变成了如何在直径上找到这样一条链
直接寻找必须边比较复杂,我们可以采用容斥思想,找到所有的非必须边
设这棵树的点集为
V
V
V,我们定义一条直径上的点为
u
1
,
u
2
,
…
u
t
u_1,u_2,…u_t
u1,u2,…ut,组成集合
D
D
D,设
d
[
u
i
]
d[u_i]
d[ui]表示点
u
i
u_i
ui不经过直径上的其他点所能在树中达到的最远距离,即
d
[
u
i
]
=
max
k
∈
(
V
−
D
)
(
d
i
s
(
u
i
,
k
)
)
d[u_i]=\max_{k\in(V-D)}(dis(u_i,k))
d[ui]=maxk∈(V−D)(dis(ui,k))
关于d的求法,与上题的dfs函数无二,复杂度
O
(
n
)
O(n)
O(n)
那么显而易见的,一条链
u
i
−
>
u
j
(
i
<
j
)
u_i->u_j(i<j)
ui−>uj(i<j)是非必须边,当且仅当满足:
d
i
s
(
u
1
,
u
j
)
=
d
[
u
j
]
或
d
i
s
(
u
t
,
u
j
)
=
d
[
u
j
]
dis(u_1,u_j)=d[u_j]或dis(u_t,u_j)=d[u_j]
dis(u1,uj)=d[uj]或dis(ut,uj)=d[uj],所以
i
i
i这一维可以固定为1或t
于是我们可以用两个指针
l
,
r
l,r
l,r分别从两端扫描,最后
r
−
l
r-l
r−l就是答案
#include<bits/stdc++.h>
using namespace std;
#define int long long
int vis[2000005],lst[2000005],d[2000005],s[2000005],b[5000005],cnt,c[5000005],n;
int head[2000005],ver[5000005],nxt[5000005],cost[5000005],tot=1,e[1000005];
void add(int u,int v,int w){
nxt[++tot]=head[u],ver[tot]=v,cost[tot]=w,head[u]=tot;
}
int bfs(int t){
memset(s,-1,sizeof s);
s[t]=0;
queue<int>q;q.push(t);
while(q.size()){
int u=q.front();q.pop();
for(int i=head[u];i;i=nxt[i]){
int v=ver[i];
if(s[v]!=-1)continue;
lst[v]=i;
s[v]=s[u]+cost[i];
q.push(v);
}
}
int p=-1;
for(int i=1;i<=n;i++)if(p==-1||s[p]<s[i])p=i;
return p;
}
void dfs(int u){
vis[u]=1;
for(int i=head[u];i;i=nxt[i]){
int v=ver[i];
if(vis[v])continue;
dfs(v);
d[u]=max(d[u],d[v]+cost[i]);
}
}
int init(){
int p=bfs(1);
int q=bfs(p);
printf("%lld\n",s[q]);
while(p!=q){
b[++cnt]=q;
q=ver[lst[q]^1];
}
b[++cnt]=p;
reverse(b+1,b+cnt+1);
memset(vis,0,sizeof vis);
for(int i=1;i<=cnt;i++)vis[b[i]]=1;
for(int i=1;i<=cnt;i++){
dfs(b[i]);
}
int l=1,r=cnt,cur=0;
for(int i=1;i<=cnt;i++){
if(cur==d[b[i]])l=i;
if(i<cnt)cur+=cost[lst[b[i+1]]];
}
cur=0;
for(int i=cnt;i>0;i--){
if(cur==d[b[i]])r=i;
if(i>0)cur+=cost[lst[b[i]]];
}
return r-l;
}
signed main(){
//freopen("dia1.in","r",stdin);
scanf("%lld",&n);
for(int i=1;i<n;i++){
int u,v,w;
scanf("%lld%lld%lld",&u,&v,&w);
add(u,v,w);
add(v,u,w);
}
printf("%lld",init());
}
最近公共祖先(LCA,Least Common Ancestors)
定义:对于节点
x
,
y
x,y
x,y,若节点
z
z
z既是
x
x
x的祖先,也是
y
y
y的祖先,则称
z
z
z是
x
,
y
x,y
x,y的公共祖先,
L
C
A
(
x
,
y
)
LCA(x,y)
LCA(x,y)就是
x
,
y
x,y
x,y的公共祖先里深度最大的那一个
性质:
L
C
A
(
x
,
y
)
LCA(x,y)
LCA(x,y)是
x
x
x到
y
y
y的简单路径上深度最小的节点
求 L C A LCA LCA的方法有很多,朴素就不说了
在线做法:
- 树链剖分 L C A LCA LCA,预处理 O ( n ) O(n) O(n),查询一次 O ( log n ) O(\log n) O(logn),
- S T ST ST表 L C A LCA LCA,预处理 O ( n log n ) O(n\log n) O(nlogn),查询 O ( 1 ) O(1) O(1)
- 倍增 L C A LCA LCA,这里我们详细讲述这个,预处理 O ( n log n ) , O(n\log n), O(nlogn),查询 O ( log n ) O(\log n) O(logn)
- 其实
R
M
Q
RMQ
RMQ问题
S
T
ST
ST做法还有优化,可以把时间复杂度优化至
O
(
n
)
O(n)
O(n),叫约束
R
M
Q
RMQ
RMQ,因为它满足相邻两个数最多差1,但代码实现太过复杂,常数也较大,对于
1
0
6
10^6
106及以下的数据甚至不如树剖倍增,而
1
0
6
10^6
106以上的数据就只得
O
(
n
)
O(n)
O(n)做了
我们谈谈三种做法的优劣,只不过我们只详细讲倍增,其他两种只做了解
在空间上, S T ST ST只需要一个 2 n 2n 2n级别的数组,然后需要一个 2 n log M A X N 2n\log MAX_N 2nlogMAXN的f数组
倍增需要一个队列,一个长度为 n n n的 d e p dep dep数组,一个 2 n log M A X N 2n\log MAX_N 2nlogMAXN的f数组,总的和 S T ST ST相差无几
树剖需要 s o n son son, f f f, d e p dep dep等数组,但是空间复杂度严格 O ( n ) O(n) O(n)
从严格时间复杂度来说,我们假设询问次数与n同级
ST>树剖>倍增
在一般情况下,实际效率是树剖约等于倍增>ST
倍增树剖常数极小,ST有点大
但是树剖很容易写丑,倍增就那样
实际从严格理论上,我记得某集训队大佬的一篇论文里有严格证明一般情况下树剖常数是倍增的 1 2 \frac{1}{2} 21
于是综合考量,树剖最优秀,代码也很短
1. 树链剖分LCA
思路:先预处理链之类的,然后对于两个点不断跳链直到跳到一条链上,此时深度较小的节点就是LCA
struct edge{
int to,ne;
}e[1000005];
int n,m,s,ecnt,head[500005],dep[500005],siz[500005],son[500005],top[500005],f[500005];
void add(int x,int y){
e[++ecnt].to=y;
e[ecnt].ne=head[x];
head[x]=ecnt;
}
void dfs1(int x){
siz[x]=1;
dep[x]=dep[f[x]]+1;
for(int i=head[x];i;i=e[i].ne){
int dd=e[i].to;
if(dd==f[x])continue;
f[dd]=x;
dfs1(dd);
siz[x]+=siz[dd];
if(!son[x]||siz[son[x]]<siz[dd])
son[x]=dd;
}
}
void dfs2(int x,int tv){
top[x]=tv;
if(son[x])dfs2(son[x],tv);
for(int i=head[x];i;i=e[i].ne){
int dd=e[i].to;
if(dd==f[x]||dd==son[x])continue;
dfs2(dd,dd);
}
}
int main(){
scanf("%d%d%d",&n,&m,&s);
for(int i=1;i<n;++i){
int x,y;
scanf("%d%d",&x,&y);
add(x,y);
add(y,x);
}
dfs1(s);
dfs2(s,s);
for(int i=1;i<=m;++i){
int x,y;
scanf("%d%d",&x,&y);
while(top[x]!=top[y]){
if(dep[top[x]]>=dep[top[y]])x=f[top[x]];
else y=f[top[y]];
}
printf("%d\n",dep[x]<dep[y]?x:y);
}
}
2. ST表
使用欧拉序,欧拉序是指在深度优先遍历整棵树的是时候,节点刚递归进入的时候标记,退出的时候再标记,这样就可以有一个性质,节点 u u u的子树全部在序列两个u之间,于是我们就可以把倍增里左边的x和右边的y所构成的区间里求深度最小的节点,这个节点就是LCA(x,y)
3. 倍增LCA
设
f
[
i
]
[
k
]
f[i][k]
f[i][k]表示节点
i
i
i的
2
k
2^k
2k级祖先,有
f
[
i
]
[
k
]
=
f
[
f
[
i
]
[
k
−
1
]
]
[
k
−
1
]
f[i][k]=f[f[i][k-1]][k-1]
f[i][k]=f[f[i][k−1]][k−1],初值
f
[
i
]
[
0
]
=
f
a
[
i
]
f[i][0]=fa[i]
f[i][0]=fa[i]
DP的顺序我们需要知道
i
i
i节点的所有祖先,即
i
i
i的所有祖先都被
D
P
DP
DP后再
D
P
i
DPi
DPi节点,这种以深度为顺序的,就是广度优先遍历,于是我们可以使用
B
F
S
BFS
BFS来实现这个
D
P
DP
DP过程
queue<int>q;
void bfs(int rt){
q.push(rt);
dep[rt]=1;
while(q.size()){
int u=q.front();q.pop();
for(int i=head[u];i;i=nxt[i]){
int v=ver[i];
if(dep[v])continue;
dep[v]=dep[u]+1;
f[v][0]=u;
for(int i=1;i<=t;i++){//t=log2(n)向上取整的结果
f[v][i]=f[f[v][i-1]][i-1];
}
q.push(v);
}
}
}
然后对于查询过程,我们先将 x , y x,y x,y调整至同一深度,这里默认最初 d e p [ y ] > d e p [ x ] dep[y]>dep[x] dep[y]>dep[x],即我们将y不断上拉,直到 d e p [ y ] = d e p [ x ] dep[y]=dep[x] dep[y]=dep[x]此时有两种情况,即 x x x本就是 y y y的祖先,此时 x = y x=y x=y直接返回即可,或者是我们同时将两个节点向上拉
int lca(int x,int y){
if(dep[x]>dep[y])swap(x,y);
for(int i=t;i>=0;i--)if(dep[f[y][i]]>=dep[x])y=f[y][i];
if(x==y)return x;
for(int i=t;i>=0;i--)if(f[x][i]!=f[y][i])x=f[x][i],y=f[y][i];
return f[x][0];
}
离线做法(tarjan算法)
前言:stO tarjan Orz,tarjan是真牛
时间复杂度:
O
(
n
+
m
)
O(n+m)
O(n+m)
在任意时刻,深度优先遍历的节点分为三类
- 已经完全结束了回溯的节点,标记2
- 已经访问但未回溯,标记1
- 尚未访问到的节点,标记0
对于一个正在访问的节点
x
x
x,它的祖先的标记一定都是1,那么对于一个已经访问完成的节点
y
y
y,
y
y
y向上走,走到的第一个标记为1的节点就是
L
C
A
(
x
,
y
)
LCA(x,y)
LCA(x,y)
对于这个过程,我们可以使用并查集进行优化,当一个节点
x
x
x获得了2的标记时,我们将
y
y
y和
f
a
(
y
)
fa(y)
fa(y)合并为一个集合(此时
f
a
(
y
)
fa(y)
fa(y)肯定是集合的代表元,且
f
a
(
y
)
fa(y)
fa(y)的标记为1)
这样我们执行
f
i
n
d
(
y
)
find(y)
find(y)查询代表元的时候,实际上就是查找到了第一个
y
y
y的祖先中标记为1的节点,即
L
C
A
(
x
,
y
)
LCA(x,y)
LCA(x,y)
// Tarjan算法离线求LCA (模板题:HDOJ2586)
const int SIZE = 50010;
int ver[2 * SIZE], Next[2 * SIZE], edge[2 * SIZE], head[SIZE];
int fa[SIZE], d[SIZE], v[SIZE], lca[SIZE], ans[SIZE];
vector<int> query[SIZE], query_id[SIZE];
int T, n, m, tot, t;
void add(int x, int y, int z) {
ver[++tot] = y; edge[tot] = z; Next[tot] = head[x]; head[x] = tot;
}
void add_query(int x, int y, int id) {
query[x].push_back(y), query_id[x].push_back(id);
query[y].push_back(x), query_id[y].push_back(id);
}
int get(int x) {
if (x == fa[x]) return x;
return fa[x] = get(fa[x]);
}
void tarjan(int x) {
v[x] = 1;
for (int i = head[x]; i; i = Next[i]) {
int y = ver[i];
if (v[y]) continue;
d[y] = d[x] + edge[i];
tarjan(y);
fa[y] = x;
}
for (int i = 0; i < query[x].size(); i++) {
int y = query[x][i];
int id = query_id[x][i];
if (v[y] == 2) {
int lca = get(y);
ans[id] = min(ans[id], d[x] + d[y] - 2 * d[lca]);
}
}
v[x] = 2;
}
int main() {
cin >> T;
while (T--) {
cin >> n >> m;
for (int i = 1; i <= n; i++) {
head[i] = 0;
query[i].clear(), query_id[i].clear();
fa[i] = i, v[i] = 0;
}
tot = 0;
for (int i = 1; i < n; i++) {
int x, y, z;
scanf("%d%d%d", &x, &y, &z);
add(x, y, z), add(y, x, z);
}
for (int i = 1; i <= m; i++) {
int x, y;
scanf("%d%d", &x, &y);
if (x == y) ans[i] = 0;
else {
add_query(x, y, i);
ans[i] = 1 << 30;
}
}
tarjan(1);
for (int i = 1; i <= m; i++) printf("%d\n", ans[i]);
}
}
树上差分
在前缀和与差分中,我们实现了序列上的区间修改,单点查询问题,现在我们要对这个思想运用到树中,实现
x
−
y
x-y
x−y的路径上修改,单点查询,这种操作被称为树上差分
原来的前缀和变成了子树和,区间操作对应路径操作
树上差分的两种形式
1.点权形式,将
u
−
>
v
u->v
u−>v的路径上的点权全部加上
d
d
d,我们的操作是将
v
a
l
[
x
]
+
=
d
,
v
a
l
[
y
]
+
=
d
,
v
a
l
[
L
C
A
(
x
,
y
)
]
−
=
d
,
v
a
l
[
f
a
(
L
C
A
(
x
,
y
)
)
]
−
=
d
val[x]+=d,val[y]+=d,val[LCA(x,y)]-=d,val[fa(LCA(x,y))]-=d
val[x]+=d,val[y]+=d,val[LCA(x,y)]−=d,val[fa(LCA(x,y))]−=d
这种操作的本质是
L
C
A
(
x
,
y
)
LCA(x,y)
LCA(x,y)也需要进行操作
2.边权形式,将
u
−
>
v
u->v
u−>v的路径上的边权全部加上
d
d
d,我们的操作是将
v
a
l
[
x
]
+
=
d
,
v
a
l
[
y
]
+
=
d
,
v
a
l
[
L
C
A
(
x
,
y
)
]
−
=
2
∗
d
val[x]+=d,val[y]+=d,val[LCA(x,y)]-=2*d
val[x]+=d,val[y]+=d,val[LCA(x,y)]−=2∗d
这种操作的本质是我们在操作的时候默认边权下放到了点权,这就使得
L
C
A
(
u
,
v
)
−
>
f
a
(
L
C
A
(
u
,
v
)
)
LCA(u,v)->fa(LCA(u,v))
LCA(u,v)−>fa(LCA(u,v))的边权下放到了
L
C
A
(
u
,
v
)
LCA(u,v)
LCA(u,v),一个也不能统计到,于是需要减去两倍的
d
d
d
例题1雨天的尾巴
深绘里一直很讨厌雨天。
灼热的天气穿透了前半个夏天,后来一场大雨和随之而来的洪水,浇灭了一切。
虽然深绘里家乡的小村落对洪水有着顽固的抵抗力,但也倒了几座老房子,几棵老树被连根拔起,以及田地里的粮食被弄得一片狼藉。
无奈的深绘里和村民们只好等待救济粮来维生。
不过救济粮的发放方式很特别。首先村落里的一共有
n
n
n 座房屋,并形成一个树状结构。然后救济粮分
m
m
m 次发放,每次选择两个房屋
(
x
,
y
)
(x,~y)
(x, y),然后对于
x
x
x 到
y
y
y 的路径上(含
x
x
x 和
y
y
y)每座房子里发放一袋
z
z
z 类型的救济粮。
然后深绘里想知道,当所有的救济粮发放完毕后,每座房子里存放的最多的是哪种救济粮。
输入格式
输入的第一行是两个用空格隔开的正整数,分别代表房屋的个数 n n n 和救济粮发放的次数 m m m。
第 2 2 2 到 第 n n n 行,每行有两个用空格隔开的整数 a , b a,~b a, b,代表存在一条连接房屋 a a a 和 b b b 的边。
第 ( n + 1 ) (n + 1) (n+1) 到第 ( n + m ) (n + m) (n+m) 行,每行有三个用空格隔开的整数 x , y , z x,~y,~z x, y, z,代表一次救济粮的发放是从 x x x 到 y y y 路径上的每栋房子发放了一袋 z z z 类型的救济粮。
输出格式
输出 n n n 行,每行一个整数,第 i i i 行的整数代表 i i i 号房屋存放最多的救济粮的种类,如果有多种救济粮都是存放最多的,输出种类编号最小的一种。
如果某座房屋没有救济粮,则输出 0 0 0。
提示
- 对于 20 % 20\% 20% 的数据,保证 n , m ≤ 100 n, m \leq 100 n,m≤100。
- 对于 50 % 50\% 50% 的数据,保证 n , m ≤ 2 × 1 0 3 n, m \leq 2 \times 10^3 n,m≤2×103。
- 对于 100 % 100\% 100% 测试数据,保证 1 ≤ n , m ≤ 1 0 5 1 \leq n, m \leq 10^5 1≤n,m≤105, 1 ≤ a , b , x , y ≤ n 1 \leq a,b,x,y \leq n 1≤a,b,x,y≤n, 1 ≤ z ≤ 1 0 5 1 \leq z \leq 10^5 1≤z≤105。
对于这道题,我们需要查询每个位置上的救济粮的最大值,于是我们就需要统计每个位置所有的救济粮数量,朴素的思想是开一个计数数组
a
a
a,
a
[
i
,
j
]
a[i,j]
a[i,j]表示节点i上存放节点j的数量,每一次在x到y的路径上朴素修改,我们就得到了一个时间复杂度
O
(
n
m
)
O(nm)
O(nm),空间复杂度
O
(
n
m
)
O(nm)
O(nm)的优秀算法。考虑进行优化
优化1. 树上路径操作可以使用树上差分,具体的我们对于一条指令
(
x
,
y
,
z
)
(x,y,z)
(x,y,z),将
a
[
x
,
z
]
+
+
,
a
[
y
,
z
]
+
+
,
a
[
l
c
a
(
x
,
y
)
,
z
]
−
−
,
a
[
f
a
(
l
c
a
(
x
,
y
)
)
,
z
]
−
−
a[x,z]++,a[y,z]++,a[lca(x,y),z]--,a[fa(lca(x,y)),z]--
a[x,z]++,a[y,z]++,a[lca(x,y),z]−−,a[fa(lca(x,y)),z]−−,这样我们就成功地将修改复杂度降到了
O
(
1
)
O(1)
O(1),但时间复杂度没有实际变化(合并查询的时候仍然需要
O
(
n
m
)
O(nm)
O(nm))
优化2. 针对优化1的继续优化,我们发现,合并查询的时候复杂度过高,而修改复杂度较低,我们就可以想办法均衡一下。这个均衡需要靠数据结构来实现,观察数据支持
O
(
(
n
+
m
)
log
n
)
O((n+m)\log n)
O((n+m)logn),于是我们大胆猜测穿一个
log
\log
log级别的数据结构来维护,很明显,线段树合并算法登场
详细的说,我们为了节省空间,先对z进行离散化,然后对于每一个节点都开一颗线段树存储,注意线段树用动态开点,这样我们的空间复杂度就会降低到
O
(
m
log
n
)
O(m\log n)
O(mlogn),原因是线段树上修改一个叶子节点需要
⌈
log
n
⌉
\lceil\log n\rceil
⌈logn⌉个节点,总共修改
O
(
m
)
O(m)
O(m)次,并且线段树合并算法时间复杂度也只有
O
(
n
log
n
)
O(n\log n)
O(nlogn)
int head[100050],ver[200500],nxt[200500],tot;//图
int f[100005][25],dep[100005];//LCA
int ans[100005],n,m,T,root[100005],zmx,a[100005],b[100005],cnt;
struct edge{
int x,y,z;
}que[100005];//question
struct node{
int lc,rc,id,mx;
}t[5000000];
#define ls t[x].lc
#define rs t[x].rc
int new_node(){
t[++cnt]={0,0,0,0};
return cnt;
}
void pushup(int x){
t[x].id=t[ls].id,t[x].mx=t[ls].mx;
if(t[x].mx<t[rs].mx)t[x].mx=t[rs].mx,t[x].id=t[rs].id;
}
void update(int L,int R,int xb,int d,int x){
if(L==R){
t[x].mx+=d;
t[x].id=xb;
return ;
}
int mid=L+R>>1;
if(xb<=mid){
if(!ls)ls=new_node();
update(L,mid,xb,d,ls);
}
else {
if(!rs)rs=new_node();
update(mid+1,R,xb,d,rs);
}
pushup(x);
}
int merge(int p,int q,int l,int r){
if(!p)return q;
if(!q)return p;
if(l==r){
t[p].mx=t[q].mx+t[p].mx;
return p;
}
int mid=l+r>>1;
t[p].lc=merge(t[p].lc,t[q].lc,l,mid);
t[p].rc=merge(t[p].rc,t[q].rc,mid+1,r);
pushup(p);
return p;
}
//以上线段树动态开点加合并
queue<int>q;
void bfs(){
dep[1]=1;
q.push(1);
while(q.size()){
int u=q.front();q.pop();
for(int i=head[u];i;i=nxt[i]){
int v=ver[i];
if(dep[v])continue;
dep[v]=dep[u]+1;
f[v][0]=u;
for(int i=1;i<=T;i++)f[v][i]=f[f[v][i-1]][i-1];
q.push(v);
}
}
}
int lca(int x,int y){
if(dep[x]>dep[y])swap(x,y);
for(int i=T;i>=0;i--)if(dep[f[y][i]]>=dep[x])y=f[y][i];
if(x==y)return x;
for(int i=T;i>=0;i--)if(f[y][i]!=f[x][i])x=f[x][i],y=f[y][i];
return f[x][0];
}
//以上LCA
void dfs(int u){
for(int i=head[u];i;i=nxt[i]){
int v=ver[i];
if(dep[v]>dep[u]){
dfs(v);
root[u]=merge(root[u],root[v],1,zmx);
}
}
ans[u]=t[root[u]].mx?t[root[u]].id:0;
}
//以上统计答案
void change(int x,int y,int z){
int fa=lca(x,y);
update(1,zmx,z,1,root[x]);
update(1,zmx,z,1,root[y]);
update(1,zmx,z,-1,root[fa]);
if(f[fa][0])update(1,zmx,z,-1,root[f[fa][0]]);
}
//修改操作
void add(int u,int v){
nxt[++tot]=head[u];ver[tot]=v,head[u]=tot;
}
int main(){
// freopen("1.in","r",stdin);
scanf("%d%d",&n,&m);
for(int i=1;i<=n;i++)root[i]=++cnt;
T=log(n)/log(2.0)+1;
for(int i=1;i<n;i++){
int u,v;
scanf("%d%d",&u,&v);
add(u,v);
add(v,u);
}
bfs();
for(int i=1;i<=m;i++){
scanf("%d%d%d",&que[i].x,&que[i].y,&que[i].z);
a[i]=b[i]=que[i].z;
}
sort(a+1,a+m+1);
zmx=unique(a+1,a+m+1)-a-1;
for(int i=1;i<=m;i++){
b[i]=lower_bound(a+1,a+zmx+1,b[i])-a;
}
for(int i=1;i<=m;i++){
change(que[i].x,que[i].y,b[i]);
}
dfs(1);
for(int i=1;i<=n;i++){
printf("%d\n",a[ans[i]]);
}
return 0;
}
关于动态开点线段树合并时间复杂度的简要证明
我们可以发现,线段树合并的时间与两棵树重合的节点相关,即最坏情况下也不会大于小的那颗树的节点个数,类似于启发式合并,我们之前也证明了至多会创建
O
(
m
log
n
)
O(m\log n)
O(mlogn)个节点,也即合并复杂度就是这个数
总时间复杂度为
O
(
(
n
+
m
)
log
(
n
+
m
)
)
O((n+m)\log (n+m))
O((n+m)log(n+m))
例题2天天爱跑步
题目描述
小c
同学认为跑步非常有趣,于是决定制作一款叫做《天天爱跑步》的游戏。《天天爱跑步》是一个养成类游戏,需要玩家每天按时上线,完成打卡任务。
这个游戏的地图可以看作一一棵包含 n n n 个结点和 n − 1 n-1 n−1 条边的树,每条边连接两个结点,且任意两个结点存在一条路径互相可达。树上结点编号为从 1 1 1 到 n n n 的连续正整数。
现在有 m m m 个玩家,第 i i i 个玩家的起点为 s i s_i si,终点为 t i t_i ti。每天打卡任务开始时,所有玩家在第 0 0 0 秒同时从自己的起点出发,以每秒跑一条边的速度,不间断地沿着最短路径向着自己的终点跑去,跑到终点后该玩家就算完成了打卡任务。 (由于地图是一棵树,所以每个人的路径是唯一的)
小c
想知道游戏的活跃度,所以在每个结点上都放置了一个观察员。在结点
j
j
j 的观察员会选择在第
w
j
w_j
wj 秒观察玩家,一个玩家能被这个观察员观察到当且仅当该玩家在第
w
j
w_j
wj 秒也正好到达了结点
j
j
j 。小c
想知道每个观察员会观察到多少人?
注意:我们认为一个玩家到达自己的终点后该玩家就会结束游戏,他不能等待一 段时间后再被观察员观察到。 即对于把结点 j j j 作为终点的玩家:若他在第 w j w_j wj 秒前到达终点,则在结点 j j j 的观察员不能观察到该玩家;若他正好在第 w j w_j wj 秒到达终点,则在结点 j j j 的观察员可以观察到这个玩家。
输入格式
第一行有两个整数 n n n 和 m m m。其中 n n n 代表树的结点数量, 同时也是观察员的数量, m m m 代表玩家的数量。
接下来 n − 1 n-1 n−1 行每行两个整数 u u u 和 v v v,表示结点 u u u 到结点 v v v 有一条边。
接下来一行 n n n 个整数,其中第 j j j 个整数为 w j w_j wj , 表示结点 j j j 出现观察员的时间。
接下来 m m m 行,每行两个整数 s i s_i si,和 t i t_i ti,表示一个玩家的起点和终点。
对于所有的数据,保证 1 ≤ s i , t i ≤ n , 0 ≤ w j ≤ n 1\leq s_i,t_i\leq n, 0\leq w_j\leq n 1≤si,ti≤n,0≤wj≤n。
输出格式
输出 1 1 1 行 n n n 个整数,第 j j j 个整数表示结点 j j j 的观察员可以观察到多少人。
分析
首先
s
i
−
t
i
s_i-t_i
si−ti的路径上,观察员分为两类,一类是在
s
i
−
l
c
a
(
s
i
,
t
i
)
s_i-lca(s_i,t_i)
si−lca(si,ti)上的,一类是在
l
c
a
(
s
i
,
t
i
)
−
t
i
lca(s_i,t_i)-t_i
lca(si,ti)−ti上的
我们处理出所有点的深度记为
d
d
d
那么玩家
i
i
i能够被观察员
j
j
j观察到当且仅当满足以下两个条件之一
1.
d
[
s
i
]
−
d
[
j
]
=
w
j
d[s_i]-d[j]=w_j
d[si]−d[j]=wj
2.
d
[
s
i
]
+
d
[
j
]
−
2
×
d
[
l
c
a
(
s
i
,
t
i
)
]
=
w
j
d[s_i]+d[j]-2\times d[lca(s_i,t_i)]=w_j
d[si]+d[j]−2×d[lca(si,ti)]=wj
因为这两个条件具有互斥性,所以我们可以分开统计贡献,下面以统计满足条件1的节点数量
我们对条件一进行变式得到
d
[
s
i
]
=
d
[
j
]
+
w
j
d[s_i]=d[j]+w_j
d[si]=d[j]+wj,这样我们就分离了两个变量,那么下一步,我们尝试把它变成区间修改,类似上题雨天的尾巴,开一个计数数组
a
[
i
,
j
]
a[i,j]
a[i,j](表示节点
i
i
i有
a
[
i
,
j
]
个
j
a[i,j]个j
a[i,j]个j)判断,于是我们可以将
s
i
−
l
c
a
(
s
i
,
t
i
)
s_i-lca(s_i,t_i)
si−lca(si,ti)的路径上的点的
a
[
u
]
[
d
[
s
i
]
]
(
u
∈
s
i
−
l
c
a
(
s
i
,
t
i
)
)
+
+
a[u][d[s_i]](u\in s_i-lca(s_i,t_i))++
a[u][d[si]](u∈si−lca(si,ti))++,条件2类似,将
t
i
−
l
c
a
(
s
i
,
t
i
)
t_i-lca(s_i,t_i)
ti−lca(si,ti)上的点的
a
[
u
]
[
d
[
s
i
]
−
2
×
d
[
l
c
a
(
s
i
,
t
i
)
]
]
(
u
∈
s
i
−
l
c
a
(
s
i
,
t
i
)
)
+
+
a[u][d[s_i]-2\times d[lca(s_i,t_i)]](u\in s_i-lca(s_i,t_i))++
a[u][d[si]−2×d[lca(si,ti)]](u∈si−lca(si,ti))++,答案就是
∑
i
=
1
n
(
a
[
i
]
[
d
[
i
]
+
w
i
]
+
a
[
i
]
[
w
i
−
d
[
i
]
]
)
\sum_{i=1}^n\left(a[i][d[i]+w_i]+a[i][w_i-d[i]]\right)
i=1∑n(a[i][d[i]+wi]+a[i][wi−d[i]])
对于操作一样使用树上差分,设b为差分计数数组,则由于本题记录的是边权,于是对于路径
u
−
v
u-v
u−v我们只需要修改
u
,
v
,
l
c
a
(
u
,
v
)
u,v,lca(u,v)
u,v,lca(u,v)三个节点即可,所以
a
[
i
,
j
]
=
∑
v
∈
s
o
n
(
i
)
b
[
v
]
[
j
]
a[i,j]=\sum_{v\in son(i)}b[v][j]
a[i,j]=∑v∈son(i)b[v][j],其中
s
o
n
son
son代表子树节点集合
但题目最大数据点
n
,
m
≤
3
×
1
0
5
n,m\le 3\times 10^5
n,m≤3×105,采用上一题的线段树合并算法由于空间过大很容易
M
L
E
MLE
MLE掉,当然就算空间过了常数也不行,于是线段树,它死了启发我们需要一个更加高效的算法
我们发现,这道题具备区间减法性质,且每个点只问一个特殊值的数量,于是我们可以采用前缀和的思想方式,利用区间减法性质,进行“树上前缀和”
我们发现,由于我们采用树上差分的操作,使得我们在
b
b
b数组中至多只会有
O
(
m
)
O(m)
O(m)个值有意义,其余的值是冗余操作,于是我们为了节省空间,可以在每一个节点开一个
v
e
c
t
o
r
vector
vector,存
i
,
j
i,j
i,j表示在树中有
j
j
j个值为
i
i
i,这样我们达到了一定的时空平衡。每一次树上差分操作只需要在
v
e
c
t
o
r
vector
vector后面插入一个节点即可,反正最多所有节点加起来也才
O
(
m
)
O(m)
O(m),这个插入的节点j只可能为-1/1,避免了在
v
e
c
t
o
r
vector
vector里再去查找
i
i
i,然后修改对应
j
j
j的操作
然后我们开一个全局的计数数组
c
c
c(一维),
c
i
c_i
ci代表目前值为
i
i
i的有
c
i
c_i
ci个,我们可以通过一次深度优先遍历,在遍历到节点
x
x
x的时候,我们开两个辅助变量
c
n
t
1
,
c
n
t
2
cnt1,cnt2
cnt1,cnt2,分别记录
c
[
d
[
x
]
+
w
x
]
,
c
[
w
x
−
d
[
x
]
]
c[d[x]+w_x],c[w_x-d[x]]
c[d[x]+wx],c[wx−d[x]]的值,之后我们就把当前节点的vector全部累加上c数组,继续遍历子节点,最后回溯的时候做一个类似前缀和的操作,
a
n
s
[
x
]
=
c
[
d
[
x
]
+
w
x
]
−
c
n
t
1
+
c
[
w
x
−
d
[
x
]
]
−
c
n
t
2
ans[x]=c[d[x]+w_x]-cnt1+c[w_x-d[x]]-cnt2
ans[x]=c[d[x]+wx]−cnt1+c[wx−d[x]]−cnt2,我们就可以得到子树中
d
[
x
]
+
w
x
,
w
x
−
d
[
x
]
d[x]+w_x,w_x-d[x]
d[x]+wx,wx−d[x]的值的数量,注意
w
x
−
d
[
x
]
w_x-d[x]
wx−d[x]需要平移数组
总得来说,这题最后的统计答案具备区间减法性质,这个性质一样可以扩展到树上,也即一段区间信息能由其他两段信息推出,我们就可以采用类似前缀和的方式进行优化,而上一题是
max
\max
max操作,不满足区间减法性质,于是我们就不能够这样优化
const int MAXN = 6e5 + 10;
const int MAXM = 1e6 + 21000;
int n, m, cnt, s[MAXN], t[MAXN], lc[MAXN], lenth[MAXN];
int head[MAXN], dep[MAXN], fa[MAXN], son[MAXN], siz[MAXN], top[MAXN], val[MAXN], c[MAXN];
int nxt[MAXM], to[MAXM];
int start[MAXN], cntt[MAXM << 1], a[MAXN], len[MAXN], ans[MAXN];
vector<int> anc[MAXN], tail[MAXN];
void add(int x, int y) {
cnt++;
nxt[cnt] = head[x];
head[x] = cnt;
to[cnt] = y;
}
void dfs1(int u, int fat) {
siz[u] = 1, fa[u] = fat, dep[u] = dep[fat] + 1;
for (int i = head[u]; i; i = nxt[i]) {
int v = to[i];
if (v != fa[u]) {
c[v] = c[u] + 1;
dfs1(v, u);
siz[u] += siz[v];
if (siz[son[u]] < siz[v])
son[u] = v;
}
}
}
void dfs2(int u, int tp) {
top[u] = tp;
if (!son[u])
return;
dfs2(son[u], tp);
for (int i = head[u]; i; i = nxt[i]) {
int v = to[i];
if (v != son[u] && v != fa[u]) {
dfs2(v, v);
}
}
}
int lca(int x, int y) {
while (top[x] != top[y]) {
if (dep[top[x]] < dep[top[y]])
swap(x, y);
x = fa[top[x]];
}
return dep[x] < dep[y] ? x : y;
}//树剖LCA
void dfs3(int u) {
int xx = cntt[dep[u] + val[u]], yy = cntt[val[u] - dep[u] + MAXN];
for (int i = head[u]; i; i = nxt[i]) {
int v = to[i];
if (v == fa[u])
continue;
dfs3(v);
}
cntt[dep[u]] += start[u];
for (int i = 0; i < tail[u].size(); i++) {
int v = tail[u][i];
cntt[lenth[v] - dep[t[v]] + MAXN]++;
}
ans[u] += cntt[dep[u] + val[u]] - xx + cntt[val[u] - dep[u] + MAXN] - yy;
for (int i = 0; i < anc[u].size(); i++) {
int v = anc[u][i];
cntt[dep[s[v]]]--, cntt[lenth[v] - dep[t[v]] + MAXN]--;
}
return;
}
int main() {
scanf("%d%d",&n,&m);
for (int i = 1; i < n; ++i) {
int x,y;
scanf("%d%d",&x,&y);
add(x, y), add(y, x);
}
for (int i = 1; i <= n; ++i)
scanf("%d",&val[i]);
dfs1(1, 0);
dfs2(1, 1);
for (int i = 1; i <= m; ++i) {
scanf("%d%d",&s[i],&t[i]);
lc[i] = lca(s[i], t[i]);
lenth[i] = c[s[i]] + c[t[i]] - c[lc[i]] * 2;
anc[lc[i]].push_back(i);
tail[t[i]].push_back(i);
start[s[i]]++;
if (dep[s[i]] == dep[lc[i]] + val[lc[i]])
ans[lc[i]]--;
}
dfs3(1);
for (int i = 1; i <= n; ++i)
cout << ans[i] << ' ';
return 0;
}
LCA的综合运用
例题1:次小生成树
题意:给定一张无向连通图,求其严格次小生成树
n
≤
1
0
5
,
m
≤
3
×
1
0
5
n\le 10^5,m\le 3\times 10^5
n≤105,m≤3×105
分析
首先,常用思路是我们先求出最小生成树,然后尝试加边,毫无疑问,设我们对最小生成树加入一条边
(
u
,
v
,
w
)
(u,v,w)
(u,v,w)就会导致原最小生成树上
u
−
v
u-v
u−v的路径上会形成环,这时我们将环上断开一条边就得到了一个次小生成树的候选答案,下面我们思考应该断开怎样一条边
首先由最小生成树的性质,原
u
−
v
u-v
u−v路径上的任何一条边的边权都应该小于或等于
w
w
w,那么我们比较两条边,边权为
x
1
,
x
2
x_1,x_2
x1,x2,设
(
x
1
<
x
2
(x_1<x_2
(x1<x2,那么断开
x
1
x_1
x1后,原最小生成树权值总和增加了
w
−
x
1
w-x_1
w−x1,同理断开
x
2
x_2
x2后增加了
w
−
x
2
w-x_2
w−x2,由于
x
1
<
x
2
x_1<x_2
x1<x2,所以
w
−
x
2
<
w
−
x
1
w-x_2<w-x_1
w−x2<w−x1,而我们所求生成树是严格次小的,于是我们应该使得增加的尽量小,于是断开
x
2
x_2
x2更优,使用数学归纳法易证,我们应该使用原
u
−
v
u-v
u−v路径上最大的边断开,接上
(
u
,
v
,
w
)
(u,v,w)
(u,v,w)
但这样就完全正确了吗,注意,我们要求的是“严格”次小生成树,于是当图中
u
−
v
u-v
u−v最大边与
w
w
w相等的时候就无法使用,这启发我们再维护一个严格次大边
于是我们的问题就变成了如何求任意两点路径上的最大边权和严格次大边权。
考虑DP,朴素DP应该很容易写出状态转移方程,但复杂度无疑是
O
(
n
m
)
O(nm)
O(nm)的,这不可能完成
1
0
5
10^5
105级别的数据
于是我们考虑优化,这个朴素的DP似乎不具有使用数据结构优化的前提,于是我们就只剩下一种优化方法,倍增
因为本题的DP满足区间加法和可拼凑性,满足倍增优化DP的前提
我们设
f
[
x
,
k
]
f[x,k]
f[x,k]表示
x
x
x的
2
k
2^k
2k倍祖先,这个可以
O
(
n
)
O(n)
O(n)倍增预处理
那么我们设
g
[
x
,
k
,
0
/
1
]
g[x,k,0/1]
g[x,k,0/1]表示
x
x
x到
x
x
x的
2
k
2^k
2k倍祖先的最大边权和严格次大边权,
k
=
0
k=0
k=0时最大边权是自己,次大边权为负无穷,则有
g
[
x
,
k
,
0
]
=
m
a
x
(
g
[
x
,
k
−
1
,
0
]
,
g
[
f
[
x
,
k
−
1
]
,
k
−
1
,
0
]
)
g[x,k,0]=max(g[x,k-1,0],g[f[x,k-1],k-1,0])
g[x,k,0]=max(g[x,k−1,0],g[f[x,k−1],k−1,0])
{
g
[
x
,
k
,
1
]
=
max
(
g
[
f
[
x
,
k
−
1
]
,
k
−
1
,
0
]
,
g
[
x
,
k
−
1
,
1
]
)
(
g
[
x
,
k
−
1
,
0
]
>
g
[
f
[
x
,
k
−
1
]
,
k
−
1
,
0
]
)
g
[
x
,
k
,
1
]
=
max
(
g
[
x
,
k
−
1
,
0
]
,
g
[
f
[
x
,
k
−
1
]
,
k
−
1
,
1
]
)
(
g
[
x
,
k
−
1
,
0
]
)
<
g
[
f
[
x
,
k
−
1
]
,
k
−
1
,
0
]
)
g
[
x
,
k
,
1
]
=
max
(
g
[
x
,
k
−
1
,
1
]
,
g
[
f
[
x
,
k
−
1
]
,
k
−
1
,
1
]
)
(
g
[
x
,
k
−
1
,
0
]
=
g
[
f
[
x
,
k
−
1
]
,
k
−
1
,
0
]
)
\left\{ \begin{aligned} g[x,k,1]&=\max(g[f[x,k-1],k-1,0],g[x,k-1,1])&(g[x,k-1,0]>g[f[x,k-1],k-1,0])\\ g[x,k,1]&=\max(g[x,k-1,0],g[f[x,k-1],k-1,1])&(g[x,k-1,0])<g[f[x,k-1],k-1,0])\\ g[x,k,1]&=\max(g[x,k-1,1],g[f[x,k-1],k-1,1])&(g[x,k-1,0]=g[f[x,k-1],k-1,0])\\ \end{aligned} \right.
⎩
⎨
⎧g[x,k,1]g[x,k,1]g[x,k,1]=max(g[f[x,k−1],k−1,0],g[x,k−1,1])=max(g[x,k−1,0],g[f[x,k−1],k−1,1])=max(g[x,k−1,1],g[f[x,k−1],k−1,1])(g[x,k−1,0]>g[f[x,k−1],k−1,0])(g[x,k−1,0])<g[f[x,k−1],k−1,0])(g[x,k−1,0]=g[f[x,k−1],k−1,0])
至于对于路径
u
−
v
u-v
u−v的最大边权和严格次大边权可以看作
l
c
a
(
u
,
v
)
lca(u,v)
lca(u,v)与
u
,
v
u,v
u,v之间的连边按照处理
g
g
g数组的方式处理出来,然后合并两条路径也按照
g
g
g数组的处理方式来,就可以求出了,然后我们就可以
O
(
log
n
)
O(\log n)
O(logn)求出一个严格次小生成树的候选答案
时间复杂度
- 处理 f f f数组和倍增 l c a lca lca,需要 O ( n log n ) O(n\log n) O(nlogn)的时间
- 处理 g g g数组的动态规划,需要 O ( n log n ) O(n\log n) O(nlogn)的时间
- 枚举边找最小候选答案,需要
O
(
m
log
n
)
O(m\log n)
O(mlogn)的时间
总时间复杂度为 O ( n log n + m log n ) = O ( ( m + n ) log n ) = O ( m log n ) O(n\log n+m\log n)=O((m+n)\log n)=O(m\log n) O(nlogn+mlogn)=O((m+n)logn)=O(mlogn)
#include<bits/stdc++.h>
using namespace std;
typedef long long ll;
const int N = 3e5 + 7, M = 3e5 + 7, MM = 3e5 + 7;
const ll INF = 0x7ffffffffff;;
int n, m;
ll sum;
int cnt, head[MM], ver[MM], nex[MM], edge[MM];
int tree[MM], pre[N], ppre[N][23], depth[N], lg[N];
ll maxf[N][23], minf[N][23];
struct E {
int from, to, w;
E() {}
E(int from, int to, int w) : from(from), to(to), w(w) {}
bool operator < (const E& b)const {
return w < b.w;
}
}e[M];
void add(int x, int y, int w) {
ver[++cnt] = y;
nex[cnt] = head[x];
edge[cnt] = w;
head[x] = cnt;
}
int find(int x) {
return x == pre[x] ? x : pre[x] = find(pre[x]);
}
void read() {
scanf("%d%d", &n, &m);
for (int i = 1; i <= m; i++)
scanf("%d%d%d", &e[i].from, &e[i].to, &e[i].w);
for (int i = 0; i < N; i++)
pre[i] = i;
}
void work1() {
sort(e + 1, e + m + 1);
for (int i = 1; i <= m; i++) {
int x = e[i].from, y = e[i].to, w = e[i].w;
int fx = find(x), fy = find(y);
if (fx != fy) {
pre[fx] = fy;
sum += w;
add(x, y, w);
add(y, x, w);
tree[i] = 1;
}
}
}
void dfs(int f, int fa, int w) {
depth[f] = depth[fa] + 1;
ppre[f][0] = fa;
minf[f][0] = -INF;
maxf[f][0] = w;
for (int i = 1; (1 << i) <= depth[f]; i++) {
ppre[f][i] = ppre[ppre[f][i - 1]][i - 1];
maxf[f][i] = max(maxf[f][i - 1], maxf[ppre[f][i - 1]][i - 1]);
minf[f][i] = max(minf[f][i - 1], minf[ppre[f][i - 1]][i - 1]);//这里分清次小关系
if (maxf[f][i - 1] > maxf[ppre[f][i - 1]][i - 1]) minf[f][i] = max(minf[f][i], maxf[ppre[f][i - 1]][i - 1]);
else if (maxf[f][i - 1] < maxf[ppre[f][i - 1]][i - 1]) minf[f][i] = max(minf[f][i], maxf[f][i - 1]);
}
for (int i = head[f]; i; i = nex[i]) {
int y = ver[i], w = edge[i];
if (y != fa) {
dfs(y, f, w);
}
}
}
int lca(int x, int y) {
if (depth[x] < depth[y]) swap(x, y);
while (depth[x] > depth[y])
x = ppre[x][lg[depth[x] - depth[y]] - 1];
if (x == y) return x;
for (int i = lg[depth[x]] - 1; i >= 0; i--) {
if (ppre[x][i] != ppre[y][i])
x = ppre[x][i], y = ppre[y][i];
}
return ppre[x][0];
}
ll qmax(int x, int y, int maxx) {
ll ans = -INF;
for (int i = lg[depth[x]] - 1; i >= 0; i--) {
if (depth[ppre[x][i]] >= depth[y]) {
if (maxx != maxf[x][i]) ans = max(ans, maxf[x][i]);
else ans = max(ans, minf[x][i]);
x = ppre[x][i];
}
}
return ans;
}
void work2() {
for (int i = 1; i <= n; i++)
lg[i] = lg[i - 1] + (1 << lg[i - 1] == i);
dfs(1, 0, 0);
ll ans = INF;
for (int i = 1; i <= m; i++) {
if (tree[i]) continue;
int x = e[i].from, y = e[i].to, w = e[i].w;
int lc = lca(x, y);
ll maxx = qmax(x, lc, w);
ll maxv = qmax(y, lc, w);
ans = min(ans, sum - max(maxx, maxv) + w);
}
printf("%lld\n", ans);
}
int main() {
read();
work1();
work2();
return 0;
}
例题2:疫情控制
题目描述
H 国有 n n n 个城市,这 n n n 个城市用 n − 1 n-1 n−1 条双向道路相互连通构成一棵树, 1 1 1 号城市是首都,也是树中的根节点。
H 国的首都爆发了一种危害性极高的传染病。当局为了控制疫情,不让疫情扩散到边境城市(叶子节点所表示的城市),决定动用军队在一些城市建立检查点,使得从首都到边境城市的每一条路径上都至少有一个检查点,边境城市也可以建立检查点。但特别要注意的是,首都是不能建立检查点的。
现在,在 H 国的一些城市中已经驻扎有军队,且一个城市可以驻扎多个军队。一支军队可以在有道路连接的城市间移动,并在除首都以外的任意一个城市建立检查点,且只能在一个城市建立检查点。一支军队经过一条道路从一个城市移动到另一个城市所需要的时间等于道路的长度(单位:小时)。
请问最少需要多少个小时才能控制疫情。注意:不同的军队可以同时移动。
分析
本题很明显满足单调性。使用贪心思想,一个军队很明显靠根节点越近越好。所以我们考虑二分答案,贪心判定
设二分的值为
m
i
d
mid
mid,那么所有的军队分为两类
- m i d mid mid的时间内不可以走到根节点的子节点
- 可以走到根节点的子节点
很明显,第一类节点就尽全力向上走就可以了,走完之后我们设 s o n ( r t ) son(rt) son(rt)是根节点的子节点集合,我们递归判断 ∀ s ∈ s o n ( r t ) \forall s\in son(rt) ∀s∈son(rt),是否已经控制了疫情,这一步我们可以把军队驻扎的点标记,若递归遇到标记节点之间返回1,否则递归子节点,当一个子节点返回false的时候就代表整个不行,设T是还有叶子节点没有被管辖的节点的集合
第二类节点有两个决策,一是留在原地不动,二是去支援T中节点,我们使用一个三元组来表示第二类节点, ( x , y , z ) (x,y,z) (x,y,z)分别表示编号为 x x x的军队在子节点 y y y的子树内,移动到 y y y还剩下 z z z的时间
这里有一个性质,即对于一个三元组 ( x , y , z ) (x,y,z) (x,y,z),若 z < 2 × d i s ( r t , y ) z<2\times dis(rt,y) z<2×dis(rt,y),则这个军队就驻扎在 y y y,不需要移动了。道理很简单,若这个军队要出去驻扎,那么设它驻扎在节点 i i i,则有 d i s ( r t , y ) > d i s ( r t , i ) dis(rt,y)>dis(rt,i) dis(rt,y)>dis(rt,i),若此时有另一个三元组 ( x ′ , y ′ , z ′ ) (x',y',z') (x′,y′,z′)跨根节点驻扎在 y y y(原来的走了,新的得来),则总路程为 d i s ( y , r t ) + d i s ( r t , y ′ ) dis(y,rt)+dis(rt,y') dis(y,rt)+dis(rt,y′),所以三元组 ( x ′ , y ′ , z ′ ) (x',y',z') (x′,y′,z′)也一定可以去驻扎在 i i i,这样还不如直接 ( x , y , z ) (x,y,z) (x,y,z)就不动,然后让 ( x ′ , y ′ , z ′ ) (x',y',z') (x′,y′,z′)去驻扎其他节点,因为 ( x , y , z ) (x,y,z) (x,y,z)能驻扎的节点 ( x ′ , y ′ , z ′ ) (x',y',z') (x′,y′,z′)都可以,它有更多的决策可能性,也更少浪费时间,具备决策包容性,所以对于一个三元组 ( x , y , z ) (x,y,z) (x,y,z),若 z < 2 × d i s ( r t , y ) z<2\times dis(rt,y) z<2×dis(rt,y),则这个军队就驻扎在 y y y,不需要移动了。
于是我们可以再一次统计这样的三元组,把它们从 T T T里面扔出去,只对剩下的进行讨论
这时候,我们把闲置的三元组按照 z − d i s ( y , r t ) z-dis(y,rt) z−dis(y,rt)从小到大排序,把T中节点按照 d i s ( r t , v ) dis(rt,v) dis(rt,v)从小到大排序,使用双指针扫描就可以得到答案
正确性很显然,把大的留在后面有更多可能性,由决策包容性可知成立
最后判断能否把 T T T中节点合理分出去,就可以确定 m i d mid mid的正确性了
int cnt, tot, sum, n, m, dep[50005], gap[50005], ans, mid, head[50005], dist[50005][30], f[50005][30], number[50005], dis, tie[100006], tot2;bool edn[50005];
pair<int, int>cup[50005];
bool vis[50005];
struct node {
int v, nxt, w;
}e[1000005];
void add(int u, int v, int w) {
++cnt;
e[cnt].v = v, e[cnt].w = w, e[cnt].nxt = head[u], head[u] = cnt;
}
void bfs() {
queue<int>q;
q.push(1);
int t = log2(n) + 1;
dep[1] = 1;
while (!q.empty()) {
int u = q.front(); q.pop();
for (int i = head[u]; i; i = e[i].nxt) {
int v = e[i].v;
if (dep[v])continue;
dep[v] = dep[u] + 1;
f[v][0] = u, dist[v][0] = e[i].w;
for (int j = 1; j <= t; j++) {
f[v][j] = f[f[v][j - 1]][j - 1];
dist[v][j] = dist[v][j - 1] + dist[f[v][j - 1]][j - 1];
}
q.push(v);
}
}
}
bool dfs(int u) {
bool vis2 = false;
if (edn[u])return 1;
for (int i = head[u]; i; i = e[i].nxt) {
int v = e[i].v;
if (dep[v] < dep[u])continue;
vis2 = true;
if (!dfs(v))return 0;
}
if (!vis2)return 0;
return 1;
}
bool check() {
memset(cup, 0, sizeof cup);
memset(gap, 0, sizeof gap);
memset(tie, 0, sizeof tie);
memset(vis, 0, sizeof vis);
memset(edn, 0, sizeof edn);
int t = log2(n);
int sum = 0;
tot = dis = tot2 = 0;
for (int i = 1; i <= m; i++) {
int u = number[i];
sum = 0;
for (int i = t; i >= 0; i--) {
if (f[u][i] > 1 && sum + dist[u][i] <= mid) {
sum += dist[u][i];
u = f[u][i];
}
}
if (f[u][0] == 1 && sum + dist[u][0] <= mid) {
cup[++tot].first = mid - (sum + dist[u][0]);
cup[tot].second = u;
}//还能走
else {
edn[u] = 1;//标记
}
}
for (int i = head[1]; i; i = e[i].nxt) {
int v = e[i].v;
if (!dfs(v)) {
vis[v] = 1;
}
}
sort(cup + 1, cup + tot + 1);
for (int i = 1; i <= tot; i++) {
int time = cup[i].first;
int u = cup[i].second;
if (vis[u] && dist[u][0] > time) {
vis[u] = 0;
}
else {
tie[++dis] = time;
}
}
for (int i = head[1]; i; i = e[i].nxt) {
if (vis[e[i].v])gap[++tot2] = dist[e[i].v][0];
}
if (dis < tot2)return false;
sort(tie + 1, tie + dis + 1);
sort(gap + 1, gap + tot2 + 1);
int l = 1, r = 1;//双指针扫描
while (l <= dis && r <= tot2) {
if (tie[l] >= gap[r]) {
l++, r++;
}
else {
l++;
}
}
if (r > tot2) {
return true;
}
return false;
}
int r;
int query() {
int l = 0;
while (l <= r) {
mid = l + r >> 1;
if (check())r = mid - 1;
else l = mid + 1;
}
return l;
}
int main() {
ios::sync_with_stdio(false);
cin.tie(0);
cin >> n;
r = 0;
for (int i = 1; i < n; i++) {
int u,v , w;
cin >> u >> v >> w;
add(u, v, w);
add(v, u, w);
r += w;
}
bfs();
cin >> m;
for (int i = 1; i <= m; i++) {
cin >> number[i];
}
cout << query();
return 0;
}
好的下面让我们来总结本节要点
知识点:
- 树的直径定义,最长性
- 树的直径中点唯一性
- 树的直径必须边组成唯一一条链,这条链的求法,双端收缩范围
- 树的偏心距及树网的核,贪心思想
- LCA的求法,树剖,倍增,tarjan
- LCA的性质,树上路径
- 树上差分的两种类型
- 利用区间加法性质,计数数组快速合并改为线段树合并算法
- 树上路径最大+严格次大边权的
O
(
n
log
n
)
O(n\log n)
O(nlogn)求法,倍增优化
经典思想 - 双指针扫描法:从中间扩展,从两端收缩,从一端扫描匹配
- 贪心思想:邻项交换,决策包容性
- 比较两个不同决策找性质
- tarjan算法的离线标记思想
- 利用区间减法性质,以前缀和思想减少空间开销
- 时空平衡,减少空间开销,遇到实际元素量很少,但范围很大的时候使用vector时间换空间
- 次小生成树一题中的倍增优化DP思想
- 容斥原理,正面不行从反面入手
基环树
众所周知,一颗树是由
N
N
N个点
N
−
1
N-1
N−1条边组成的连通图,我们在树上任意加上一条边,树上就会产生一个环,这样
N
N
N个点
N
N
N条边组成的连通无向图就是基环树,当然,若不一定连通,这也可能是一个由基环树构成的森林,简称基环树森林
在有向图中,也有类似的概念,
N
N
N个点
N
N
N条边,每个点有且仅有一条出边的连通有向图内部有一个环,而其他边就好像向内收缩的样子,我们称这种有向图为内向基环树
类似的,每个点有且仅有一条入边的有连通向图好像对向外扩展,这种有向图被称为外向基环树
若不保证连通,也有可能是内/外向树森林
[外链图片转存中…(img-heXIjT3P-1661348574934)])
对于基环树的结构虽然简单,但比一般的树要复杂一些,因此常常成为一些经典模型的扩展,如基环树直径,基环树上两点路径,基环树上动态规划……
对于解决基环树问题,我们一般是先找到树上的环,并且以环作为基环树的“广义根节点”,把除了环以外的节点作为若干颗子树进行处理,然后考虑和环有一起计算
无论如何,基环树找环基本是必备操作,但我们上面提到的三种基环树,找环的方法也不尽相同
先看第一个普通基环树找环的过程:
void get(int u,int v,int z){
sum[1]=z;//像下文说的求sum数组那样,先把断开的这条边的权值加上
while(v!=u){
h[++cnt]=v;
sum[cnt+1]=cost[lst[v]];
v=ver[lst[v]^1];
}//凭借标记不断回跳
h[++cnt]=u;
for(int i=1; i <= cnt; i++){
vis[h[i]]=true;
sum[i]+=sum[i-1];
}
}
void dfs(int u){
dfn[u]=++num;
for(int i=head[u];i;i=nxt[i]){
int v=ver[i];
if(!dfn[v]){
lst[v]=i;
dfs(v);
}
else if(i^1!=lst[u]&&dfn[v]>dfn[u])get(u,v,cost[i]);
/*对环判定的解释:dfn是dfs序,之所以以i^1!=lst[u]&&dfn[v]>dfn[u]作为环的判断条件,是因为
1.i^1!=lst[u]是在特判父亲节点,因为无向图的成对存储
2.需要dfn[v]>dfn[u]是强制要求环在u的子树内出现,如此便不会重复统计
*/
}
}
对于内向基环树的环可以使用拓扑排序求得,当然拓扑排序本身就是有向图判环的绝招
scanf("%d%d",&n,&m);
for(int i=1;i <= n;i++){
scanf("%d",&f[i]);
in[f[i]]++;//i->f[i]
}
for(int i=1;i<=n;i++)
if(!in[i])q.push(i);
while(q.size()){
int x=q.front();
q.pop();
if(!--in[f[x]])q.push(f[x]);
}
对于外向基环树,我们发现,拓扑排序不行,因为每一个点都有且仅有1的入度,于是我们使用跳父亲的方式,即不断往上跳,因为每一个节点都有父亲,于是我们肯定可以跳到环上,然后就得到了环
while(!vis[fa[rt]])vis[fa[rt]]=1,rt=fa[rt];
u=rt,v=fa[rt];
while(u!=v){
s[++num]=v;
v=fa[v];
}
s[++num]=u;
下面我们讨论几个经典模型在基环树上的扩展
基环树的直径
同样的,基环树的直径是基环树上最长的链,下面我们讨论它的求法
很明显,基环树的直径有两种可能,一是在环上某个节点的子树上,一是跨环的直径
对于第一种情况,我们只需要找出环,然后将环上节点标记,对每一个环上的节点的子树上进行一般的求直径的方法更新答案即可,我们设环上节点为
s
[
i
]
s[i]
s[i],一共有
c
n
t
cnt
cnt个节点
对于第二种情况,我们需要先
d
f
s
dfs
dfs找出环上每个节点作为一端的在自己子树上的最长链,这个通过
d
f
s
/
b
f
s
dfs/bfs
dfs/bfs可以
O
(
n
)
O(n)
O(n)求出,记作
d
[
s
[
i
]
]
d[s[i]]
d[s[i]]
然后第二步,我们对环上的节点做前缀和,记作
s
u
m
[
i
]
sum[i]
sum[i],也即
s
u
m
[
i
]
=
c
o
s
t
[
s
[
1
]
,
s
[
c
n
t
]
]
+
∑
j
=
2
i
c
o
s
t
[
s
[
j
−
1
]
,
s
[
j
]
]
sum[i]=cost[s[1],s[cnt]]+\sum_{j=2}^{i}cost[s[j-1],s[j]]
sum[i]=cost[s[1],s[cnt]]+∑j=2icost[s[j−1],s[j]],于是我们需要解决的问题就变成了:找到一对
i
,
j
i,j
i,j,使得
d
[
s
[
i
]
]
+
d
[
s
[
j
]
]
+
m
a
x
(
s
u
m
[
c
n
t
]
−
a
b
s
(
s
u
m
[
s
[
i
]
]
−
s
u
m
[
s
[
j
]
]
)
,
a
b
s
(
s
u
m
[
s
[
i
]
]
−
s
u
m
[
s
[
j
]
]
)
)
d[s[i]]+d[s[j]]+max(sum[cnt]-abs(sum[s[i]]-sum[s[j]]),abs(sum[s[i]]-sum[s[j]]))
d[s[i]]+d[s[j]]+max(sum[cnt]−abs(sum[s[i]]−sum[s[j]]),abs(sum[s[i]]−sum[s[j]])),我们之所以在处理前缀和的时候直接加上了
1
−
c
n
t
1-cnt
1−cnt的边的边权,是因为我们需要做前缀和,这个并不影响答案并且更好处理,具体原因看代码实现并自己手玩一下就可以理解了
至于这个找
i
,
j
i,j
i,j的过程,我们可以将环复制一倍变成链来处理,直接进行DP,因为我们将环变成了两倍,于是我们就不必考虑
max
\max
max函数,这是因为在最优化DP中,我们只需要考虑最优解是否在决策集合内,即
i
,
j
i,j
i,j,在环中会出现为
i
+
c
n
t
,
j
+
c
n
t
,
i
,
j
i+cnt,j+cnt,i,j
i+cnt,j+cnt,i,j四个,若我们设定
i
<
j
i<j
i<j的话,我们只会考虑两个决策,即
i
,
j
i,j
i,j和
j
,
i
+
c
n
t
j,i+cnt
j,i+cnt,反之同理,这样的两个决策就对应了
max
\max
max函数里的两个参数,于是我们的DP式就变成了:
max
j
<
i
,
i
−
j
<
c
n
t
{
s
u
m
[
s
[
i
]
]
−
s
u
m
[
s
[
j
]
]
+
d
[
s
[
i
]
]
+
d
[
s
[
j
]
]
}
\max_{j<i,i-j<cnt}\lbrace sum[s[i]]-sum[s[j]] +d[s[i]]+d[s[j]]\rbrace
j<i,i−j<cntmax{sum[s[i]]−sum[s[j]]+d[s[i]]+d[s[j]]}
对于这个式子,我们可以采用单调队列优化,将
s
u
m
[
s
[
i
]
]
+
d
[
s
[
i
]
]
sum[s[i]]+d[s[i]]
sum[s[i]]+d[s[i]]提出来,使用单调队列维护单调递增的
d
[
s
[
j
]
]
−
s
u
m
[
s
[
j
]
]
d[s[j]]-sum[s[j]]
d[s[j]]−sum[s[j]],直接
O
(
c
n
t
)
O(cnt)
O(cnt)DP更新答案即可
我们梳理一下这个过程
- 找到图中的环,并对其做前缀和
- 对环上每一个节点的子树找到直径更新答案
- 对环上每一个节点进行dfs/bfs/dp找到子树内以自己为一端的最长链
- dp求出跨环的答案并更新
其中过程 2 , 3 2,3 2,3是可以合为一步的,还记得 d p dp dp求直径吗,那个 d d d数组就是我们需要的d数组
给一道模板题
岛屿
题目就是给定基环树森林求每一颗基环树的直径
#include<bits/stdc++.h>
using namespace std;
const int N=1000005;
int ver[N<<1],cost[N<<1],nxt[N<<1],head[N],tot=1,n,cnt,num,dfn[N],lst[N],h[N<<1],q[N<<1],vis[N];
long long d[N],sum[N<<1],ans,Ans;
void add(int u,int v,int w){
nxt[++tot]=head[u],ver[tot]=v,cost[tot]=w,head[u]=tot;
}
void get(int u,int v,int z){
sum[1]=z;
while(v!=u){
h[++cnt]=v;
sum[cnt+1]=cost[lst[v]];
v=ver[lst[v]^1];
}
h[++cnt]=u;
for(int i=1; i <= cnt; i++){
vis[h[i]]=true;
h[cnt+i]=h[i];
sum[cnt+i]=sum[i];
}
for(int i=1;i<=cnt+cnt;i++)sum[i]+=sum[i-1];
}//得到环之后的预处理
void dfs(int u){
dfn[u]=++num;
for(int i=head[u];i;i=nxt[i]){
int v=ver[i];
if(!dfn[v]){
lst[v]=i;
dfs(v);
}
else if(i^1!=lst[u]&&dfn[v]>dfn[u])get(u,v,cost[i]);
}
}//处理找环
void dp(int u){
vis[u]=true;
for(int i=head[u];i;i=nxt[i]){
int v=ver[i];
if(!vis[v]){
dp(v);
ans=max(ans,d[u]+d[v]+cost[i]);
d[u]=max(d[u],d[v]+cost[i]);
}
}
}//d数组就是需要的最长链
int main(){
scanf("%d",&n);
for(int u=1;u<=n;u++){
int v,w;
scanf("%d%d",&v,&w);
add(u,v,w);add(v,u,w);
}
for(int u=1;u<=n;u++)
if(!dfn[u]){
cnt=0;ans=0;
dfs(u);
for(int i=1;i<=cnt;i++)dp(h[i]);
int l=1,r=0;
for(int i=1;i<=cnt<<1;i++){
while(l<=r&&q[l]<=i-cnt)l++;
if(l<=r)ans=max(ans,d[h[i]]+d[h[q[l]]]+sum[i]-sum[q[l]]);
while(l<=r&&d[h[q[r]]]-sum[q[r]]<=d[h[i]]-sum[i])r--;
q[++r]=i;
}//单调队列统计答案
Ans+=ans;
}
printf("%lld",Ans);
}
基环树上路径
例题:Freda的传呼机
为了随时与
r
a
i
n
b
o
w
rainbow
rainbow 快速交流,
F
r
e
d
a
Freda
Freda 制造了两部传呼机。
F
r
e
d
a
Freda
Freda 和
r
a
i
n
b
o
w
rainbow
rainbow 所在的地方有
N
N
N 座房屋、
M
M
M 条双向光缆。
每条光缆连接两座房屋,传呼机发出的信号只能沿着光缆传递,并且传呼机的信号从光缆的其中一端传递到另一端需要花费
t
t
t 单位时间。
现在
F
r
e
d
a
Freda
Freda 要进行
Q
Q
Q 次试验,每次选取两座房屋,并想知道传呼机的信号在这两座房屋之间传递至少需要多长时间。
N
N
N 座房屋通过光缆一定是连通的,并且这 M 条光缆有以下三类连接情况:
A
A
A:光缆不形成环,也就是光缆仅有
N
−
1
N−1
N−1条。
B
B
B:光缆只形成一个环,也就是光缆仅有
N
N
N 条。
C
C
C:每条光缆仅在一个环中。
请你帮帮他们。
输入格式
第一行包含三个用空格隔开的整数,
N
、
M
N、M
N、M 和
Q
Q
Q。
接下来 M M M 行每行三个整数 x 、 y 、 t x、y、t x、y、t,表示房屋 x x x 和 y y y 之间有一条传递时间为 t t t 的光缆。
最后 Q Q Q 行每行两个整数 x 、 y x、y x、y,表示 F r e d a Freda Freda 想知道在 x x x 和 y y y之间传呼最少需要多长时间。
输出格式
输出
Q
Q
Q 行,每行一个整数,表示
F
r
e
d
a
Freda
Freda 每次试验的结果。
数据范围
2
≤
N
≤
10000
,
2≤N≤10000,
2≤N≤10000,
N
−
1
≤
M
≤
12000
,
N−1≤M≤12000,
N−1≤M≤12000,
Q
=
10000
,
Q=10000,
Q=10000,
1
≤
x
,
y
≤
N
,
1≤x,y≤N,
1≤x,y≤N,
1
≤
t
<
32768
1≤t<32768
1≤t<32768
30
30%
30的数据,
M
=
N
−
1
M=N-1
M=N−1
50
50%
50的数据,
M
=
N
M=N
M=N
20
20%
20的数据,
M
>
N
M>N
M>N
分析
看到本题会发现这是一个全源最短路径问题,嗯此时你想到了
F
l
o
y
d
Floyd
Floyd,对不起,你只有
5
p
t
s
5pts
5pts
我们先来考虑
M
=
N
−
1
M=N-1
M=N−1的数据,很明显
N
N
N个村庄构成一颗无根树,我们任选一个根节点进行
d
f
s
dfs
dfs,求出
d
d
d数组,其中
d
[
i
]
d[i]
d[i]表示节点
i
i
i与根节点的距离,我们再倍增/树剖处理出
L
C
A
LCA
LCA,对于一个询问
u
,
v
u,v
u,v,答案就是
d
[
u
]
+
d
[
v
]
−
2
×
d
[
l
c
a
(
u
,
v
)
]
d[u]+d[v]-2\times d[lca(u,v)]
d[u]+d[v]−2×d[lca(u,v)]
再来考虑 N = M N=M N=M的数据,此时的询问有两种可能性
- u , v u,v u,v都在环上某个节点的子树上,此时我们按照一棵树处理即可
-
u
,
v
u,v
u,v分别在环上某个节点的子树上
对于第二种情况,我们设 s [ 1 ] ∼ s [ c n t ] s[1]\sim s[cnt] s[1]∼s[cnt]为环上的节点, s u m [ i ] = c o s t [ s [ 1 ] , s [ c n t ] ] + ∑ j = 2 i c o s t [ s [ j ] , s [ j − 1 ] ] sum[i]=cost[s[1],s[cnt]]+\sum_{j=2}^icost[s[j],s[j-1]] sum[i]=cost[s[1],s[cnt]]+∑j=2icost[s[j],s[j−1]], b [ i ] b[i] b[i]表示节点 i i i属于环上哪一个节点的子树,这里需要注意的是,我们的 s u m sum sum使用的是 i i i来做下标,那么我们还需要一个 r a n ran ran数组与 s s s建立映射才能正确通过节点查找前缀和,那么答案就是
d [ u ] + d [ v ] + m i n ( s u m [ c n t ] − ∣ s u m [ r a n [ b [ u ] ] ] − s u m [ r a n [ b [ v ] ] ] ∣ , ∣ s u m [ r a n [ b [ u ] ] ] − s u m [ r a n [ b [ v ] ] ] ∣ ) d[u]+d[v]+min(sum[cnt]-|sum[ran[b[u]]]-sum[ran[b[v]]]|,|sum[ran[b[u]]]-sum[ran[b[v]]]|) d[u]+d[v]+min(sum[cnt]−∣sum[ran[b[u]]]−sum[ran[b[v]]]∣,∣sum[ran[b[u]]]−sum[ran[b[v]]]∣)
第三种情况超出了讨论范围,涉及到数据结构仙人掌树,也即圆方树
#define int long long
int s[1000005],num,head[1000005],ver[1000005],nxt[2000005],cost[2000005],tot=1,cnt,f[1000005][25];
int dep[1000005],b[1000005],d[1000005],sum[1000005],t,vis[1000005],dfn[1000006],lst[1000006],n,m,ran[1000005];
void add(int u,int v,int w){
nxt[++tot]=head[u],ver[tot]=v,cost[tot]=w,head[u]=tot;
}
void bfs(int s){
queue<int>q;
q.push(s);
b[s]=s;
d[s]=0,dep[s]=1;
while(!q.empty()){
int u=q.front();q.pop();
for(int i=head[u];i;i=nxt[i]){
int v=ver[i];
if(dep[v]||vis[v])continue;
dep[v]=dep[u]+1;
b[v]=s;
d[v]=d[u]+cost[i];
f[v][0]=u;
for(int i=1;i<=t;i++)f[v][i]=f[f[v][i-1]][i-1];
q.push(v);
}
}
}//处理倍增LCA,d,b,dep
int lca(int x,int y){
if(dep[x]>dep[y])swap(x,y);
for(int i=t;i>=0;--i)if(dep[f[y][i]]>=dep[x])y=f[y][i];
if(x==y)return x;
for(int i=t;i>=0;--i)if(f[y][i]!=f[x][i])x=f[x][i],y=f[y][i];
return f[x][0];
}
void get(int u,int v,int z){
sum[1]=z;
while(u!=v){
s[++cnt]=v;
ran[v]=cnt;
sum[cnt+1]=cost[lst[v]];
v=ver[lst[v]^1];
}
s[++cnt]=u;
ran[u]=cnt;
for(int i=1;i<=cnt;i++){
sum[i]+=sum[i-1];
vis[s[i]]=1;
}
}//找环,注意ran数组建立映射
void dfs(int u){
dfn[u]=++num;
for(int i=head[u];i;i=nxt[i]){
int v=ver[i];
if(!dfn[v]){
lst[v]=i;
dfs(v);
}
else if(i^1!=lst[u]&&dfn[v]>dfn[u])get(u,v,cost[i]);
}
}
void init(){
if(n-1==m){
bfs(1);
return ;
}
dfs(1);
for(int i=1;i<=cnt;i++)bfs(s[i]);
}
int solve(int u,int v){
if(n-1==m||b[u]==b[v]){
return d[u]+d[v]-2*d[lca(u,v)];
}
else {
int ans=d[u]+d[v];
return ans+min(sum[cnt]-abs(sum[ran[b[u]]]-sum[ran[b[v]]]),abs(sum[ran[b[u]]]-sum[ran[b[v]]]));//注意这里的计算唉
}
}
signed main(){
// freopen("communicate9.in","r",stdin);
int q;
scanf("%lld%lld%lld",&n,&m,&q);
t=log(n)/log(2)+1;
for(int i=1;i<=m;i++){
int u,v,w;
scanf("%lld%lld%lld",&u,&v,&w);
add(u,v,w);
add(v,u,w);
}
init();
while(q--){
int u,v;
scanf("%lld%lld",&u,&v);
printf("%lld\n",solve(u,v));
}
}
//这份代码已经可以获得80%的分数了
例题2会合
给定一个 n n n 个顶点的有向图,每个顶点有且仅有一条出边。
对于顶点 i i i,记它的出边为 ( i , a [ i ] ) (i,a[i]) (i,a[i])。
再给出 q q q 组询问,每组询问由两个顶点 a 、 b a、b a、b组成,要求输出满足下面条件的 x 、 y x、y x、y:
从顶点
a
a
a 沿着出边走
x
x
x 步和从顶点
b
b
b 沿着出边走
y
y
y 步后到达的顶点相同。
在满足条件
1
1
1 的情况下,如果解不唯一,则还需要令
max
(
x
,
y
)
\max(x,y)
max(x,y) 最小。
在满足条件
1
1
1 和
2
2
2 的情况下,如果解不唯一,则还需要令
min
(
x
,
y
)
\min(x,y)
min(x,y) 最小。
在满足条件
1
、
2
1、2
1、2 和
3
3
3 的情况下,如果解不唯一,则还需要令
x
≥
y
x≥y
x≥y。
如果不存在满足条件
1
1
1 的
x
、
y
x、y
x、y,输出
−
1
−
1
-1 -1
−1−1。
输入格式
第一行两个正整数
n
n
n 和
q
q
q。
第二行 n 个正整数 a [ 1 ] , a [ 2 ] , … , a [ n ] a[1],a[2],…,a[n] a[1],a[2],…,a[n]。
下面 q 行,每行两个正整数 a,b,表示一组询问。
输出格式
输出 q 行,每行两个整数。
数据范围
n
,
q
≤
500000
,
n,q≤500000,
n,q≤500000,
a
[
i
]
≤
n
,
a[i]≤n,
a[i]≤n,
a
,
b
≤
n
a,b\le n
a,b≤n
分析
本题的图是一个外向树森林,于是我们可以使用拓扑排序找环,然后我们分开讨论几种情况下的答案
- 无解的情况。很明显就是两点不在一棵基环树上,这个可以使用并查集或者标记数组即可实现
- 对于两个节点都在同一个基环树环上节点的子树内,此时满足“从顶点 a a a 沿着出边走 x x x 步和从顶点 b b b 沿着出边走 y y y 步后到达的顶点相同。”的所走到的节点无疑就是 a , b a,b a,b的公共祖先,而因限制条件的存在,合法的答案就是走到两个节点的 L C A LCA LCA这个处理一下也很简单
- 两个节点在基环树环上不同节点的子树内,此时肯定是需要两个节点先跳至环上,设跳到了 u , v u,v u,v,此时我们就需要考虑是 u − > v u->v u−>v走还是 v − > u v->u v−>u走,由给定的三个限制条件可以很轻松得到
#define pe pair<int,int>
#define x first
#define y second
pe cmp(pe a,pe b){
if(max(a.x,a.y)<max(b.x,b.y))return a;
if(max(a.x,a.y)>max(b.x,b.y))return b;
if(min(a.x,a.y)<min(b.x,b.y))return a;
if(min(a.x,a.y)>min(b.x,b.y))return b;
return a.x >= a.y ? a : b;
}
//主函数中的调用:
pe a,b;
int sx=s[id[x]],sy=s[id[y]],now=num[pos[id[x]]];//跳至环上节点sx,sy并得到这个环的大小now,我们直接将两个决策存为a,b再比较谁优秀
a.x=dep[x]+(sy-sx+now)%now;//代码小技巧:abs(sy-sx)%now=(sy-sx+now)%now,前提:sy-sx>=-now
a.y=dep[y];
b.x=dep[x];
b.y=dep[y]+(sx-sy+now)%now;
pe ans=cmp(a,b);
于是我们很容易的就可以完成这道题
流程如下:
- 建图
- 拓扑排序找环,统计环上信息
- 倍增准备LCA,顺带处理dep和连通块
- 接受提问和答案
#include<bits/stdc++.h>
#define pe pair<int,int>
#define x first
#define y second
using namespace std;
const int N=500006;
int n,m,t,f[N][20],in[N],pos[N],cnt,num[N],s[N],dep[N],id[N];
vector<int>e[N];
queue<int>q;
void bfs(){//处理各个环上节点的子树信息,如归属id,dep等
for(int i=1;i<=n;i++)
if(pos[i]){
id[i]=i;
q.push(i);
}
else e[f[i][0]].push_back(i);
while(q.size()){
int x=q.front();
q.pop();
for(int i=0;i<e[x].size();i++){
int y=e[x][i];
dep[y]=dep[x]+1;
id[y]=id[x];
q.push(y);
}
}
}
int lca(int x,int y){
if(dep[x]>dep[y])swap(x,y);
for(int i=t;i>=0;i--)if(dep[f[y][i]]>=dep[x])y=f[y][i];
if(x==y)return x;
for(int i=t;i>=0;i--)if(f[x][i]!=f[y][i])x=f[x][i],y=f[y][i];
return f[x][0];
}
pe cmp(pe a,pe b){
if(max(a.x,a.y)<max(b.x,b.y))return a;
if(max(a.x,a.y)>max(b.x,b.y))return b;
if(min(a.x,a.y)<min(b.x,b.y))return a;
if(min(a.x,a.y)>min(b.x,b.y))return b;
return a.x >= a.y ? a : b;
}
int main(){
scanf("%d%d",&n,&m);
for(int i=1;i <= n;i++){
scanf("%d",&f[i][0]);
in[f[i][0]]++;
}
t=log(n)/log(2);
for(int i=1;i<=t;i++)
for(int x=1;x<=n;x++)
f[x][i]=f[f[x][i-1]][i-1];//直接处理倍增数组
for(int i=1;i<=n;i++)
if(!in[i])q.push(i);
while(q.size()){
int x=q.front();
q.pop();
if(!--in[f[x][0]])q.push(f[x][0]);
}//拓扑排序
for(int i=1;i<=n;i++)
if(in[i]&&!pos[i]){//拓扑排序之后还有入度的节点就是环上的节点,此时处理连通块
++cnt;
for(int j=i;!pos[j];j=f[j][0]){//标记整个环
pos[j]=cnt;
s[j]=++num[cnt];
}
}
//处理连通块
bfs();
while(m--){
int x,y;
scanf("%d %d",&x,&y);
if(pos[id[x]] != pos[id[y]])puts("-1 -1");
else if(id[x]==id[y]){
int p=lca(x,y);
printf("%d %d\n",dep[x]-dep[p],dep[y]-dep[p]);
}
else{
pe a,b;
int sx=s[id[x]],sy=s[id[y]],now=num[pos[id[x]]];
a.x=dep[x]+(sy-sx+now)%now;
a.y=dep[y];
b.x=dep[x];
b.y=dep[y]+(sx-sy+now)%now;
pe ans=cmp(a,b);
printf("%d %d\n",ans.x,ans.y);
}
}
return 0;
}
套路总结
遇题先分析哪种基环树(森林),第二步找环,第三步处理需要的信息,第四步就是按照题目要求统计答案,注意我们基环树对sum的定义方式
基环树上DP
对于基环树上的DP,无非就是树形DP还要处理环的叠加,众所周知,我们对于带环的DP处理方式有两种,一是进行两次DP,一次断开,一次强制连接(通过赋值特判等实现),另一个是将环复制一倍进行DP
对于基环树上的DP,这两种做法也有着不同的实现方式
第一种做法与树形DP相似,只是加入了特判
第二种做法需要我们将子树信息统计完整之后直接转为序列DP
当然应用得更多的还是第一种
下面以一道例题来说明这种情况
创世纪
上帝手中有 N 种世界元素,每种元素可以限制另外 1 种元素,把第 i 种世界元素能够限制的那种世界元素记为 a[i]。
现在,上帝要把它们中的一部分投放到一个新的空间中去建造世界。
为了世界的和平与安宁,上帝希望所有被投放的世界元素都有至少一个没有被投放的世界元素限制它。
上帝希望知道,在此前提下,他最多可以投放多少种世界元素?
分析
因为每一种元素可以限制另外一种元素,于是如果我们将
i
,
a
[
i
]
i,a[i]
i,a[i]进行连边的话,就会形成一个基环树森林,但是我们仍然需要注意的是,我们该建立内向基环树森林还是外向基环树森林
于是我们来思考哪一种方式更加容易实现代码
这道题很明显是一道DP题,如果我们设
f
[
u
,
0
/
1
]
f[u,0/1]
f[u,0/1]表示(不)放节点u的时候,最多可以投放多少元素,容易写出状态转移方程
f
[
u
,
0
]
=
∑
a
[
v
]
=
u
max
(
f
[
v
,
0
]
,
f
[
v
,
1
]
)
f[u,0]=\sum_{a[v]=u}\max(f[v,0],f[v,1])
f[u,0]=a[v]=u∑max(f[v,0],f[v,1])
上式的含义是:当不放置节点
u
u
u的时候,所有限制它的元素都可以放置,类似的,若放置
u
u
u,则方程为
f
[
u
,
1
]
=
1
+
max
a
[
x
]
=
u
{
f
[
x
,
0
]
+
∑
a
[
v
]
=
u
,
v
≠
x
max
(
f
[
v
,
0
]
,
f
[
v
,
1
]
)
}
f[u,1]=1+\max_{a[x]=u}\lbrace f[x,0]+\sum_{a[v]=u,v\ne x}\max(f[v,0],f[v,1])\rbrace
f[u,1]=1+a[x]=umax{f[x,0]+a[v]=u,v=x∑max(f[v,0],f[v,1])}
上式的含义是,如果要投放元素
u
u
u,则至少有一个限制它的元素不投放,剩余的随意
对于这个状态转移方程式,它告诉我们,我们需要快速使用
x
x
x查找到所有
a
[
v
]
=
x
a[v]=x
a[v]=x的
v
v
v,于是我们不妨把
x
x
x的子节点都设为
a
[
v
]
=
x
a[v]=x
a[v]=x的节点
v
v
v,具体的,我们把每个
a
[
i
]
a[i]
a[i]向
i
i
i连有向边,
a
[
i
]
a[i]
a[i]为父节点
于是乎,我们就建立了一个每个节点有且仅有一条入边的有向图,即外向基环树森林
所以我们可以使用不断跳父亲节点的方式找到环,跳到最后的两个节点
p
,
a
[
p
]
p,a[p]
p,a[p],我们断开它们的连接,在以p为根节点的树上进行一次DP,这一次DP就相当于没有用上
p
p
p可以限制
a
[
p
]
a[p]
a[p]的条件,两个答案都可以更新
第一次DP只有一种情况没有考虑到,即
a
[
p
]
a[p]
a[p]被
p
p
p所限制,对于这个的解决办法就是,我们进行第二次DP,通过特判使得其强制性的被
p
p
p所限制,即dp到节点
a
[
p
]
a[p]
a[p]的时候,强制性的令
f
[
a
[
p
]
,
1
]
=
f
[
a
[
p
]
,
0
]
+
1
f[a[p],1]=f[a[p],0]+1
f[a[p],1]=f[a[p],0]+1,也即不需要任何一个子节点来限制它(已被p所限制),最后的答案我们就只取
f
[
p
,
0
]
f[p,0]
f[p,0]更新答案即可
#define N 1000005
int n,a[N],root,f[N][2],ans,head[N],ver[N],nxt[N],tot,vis[N];
void add(int u,int v){
nxt[++tot]=head[u],ver[tot]=v,head[u]=tot;
}
int dp(int u,int t){
f[u][0]=f[u][1]=0;
vis[u]=1;
int mn=0x3f3f3f3f;
for(int i=head[u];i;i=nxt[i]){
int v=ver[i];
if(v!=root){
int x=dp(v,t);
mn=min(mn,x-f[v][0]),f[u][0]+=x;
}
}
f[u][1]=f[u][0]-mn+1;
if(t&&u==a[root])f[u][1]+=mn;
return max(f[u][0],f[u][1]);
}
int solve(int u){
root=u;
while(!vis[a[root]])vis[root]=1,root=a[root];
int ans=dp(root,0);
dp(root,1);
return max(ans,f[root][0]);
}
int main() {
scanf("%d",&n);
for(int i=1;i<=n;i++){
scanf("%d",&a[i]);
add(a[i],i);
}
for(int i=1;i<=n;i++)if(!vis[i])ans+=solve(i);
printf("%d\n",ans);
return 0;
}
本节知识点及需要背的板子:
- 三种基环树(森林)的定义与性质
- 三种基环树不同的找环方法
- 基环树经典模型扩展:路径问题,直径,动态规划
- 基环树的环上统计信息时的细节注意,sum数组的细节
- 使用回溯找到环
- 示例代码中的代码技巧
本节重要思想 - 基环树问题的通用思路,将环视作广义根节点
- 环上节点的子树分别计算,最后合并的分治思想
- 基环树上动态规划的环形解决
负环与差分约束系统
负环
简单点说,就是我们的图上存在着一个环,使得环上总边权为负,这样的的环被称为负环,类似的,我们也有对正环的定义,需要注意的是,无向图中我们按两条相反有向边储存本身就等于是一个自环
对于存在负环的图,最短路问题永远不可能求出解,因为负环的存在会导致环上节点的三角不等式永远无法收敛,因为跑圈的同时会无限更新
类似的,对于存在正环的图,最长路问题也永远不可能求出解
介于正环和负环之间的就是零环,在一些题目中,零环没有任何意义,往往需要缩点缩掉
至于负环的求法,根据抽屉原理,一个负环必定会存在负权边,而存在负权边的最短路问题一般是采用
B
e
l
l
m
a
n
−
f
o
r
d
Bellman-ford
Bellman−ford或者是
S
P
F
A
SPFA
SPFA算法处理,类似的,我们可以使用它们来判断负环
边权为负的无向边本身就是一个负环,很多时候有向图数据经过构造有可能会出现重边,反向边等,需要注意,必要时特判
求法
-
B
e
l
l
m
a
n
−
f
o
r
d
Bellman-ford
Bellman−ford算法求负环:
很简单,就是当经过了 n − 1 n-1 n−1轮迭代之后再次扫描数组,若仍未收敛则证明存在负环 -
S
P
F
A
SPFA
SPFA求负环,对于
S
P
F
A
SPFA
SPFA求负环一般有两种方式
第一种方式是:由于一个节点的入队次数代表着被更新的次数,按照 S P F A SPFA SPFA的流程,若一个节点重复出队 n n n次及以上,就存在着负环,具体的,我们可以用一个数组记录,出队时累加次数并判断即可
第二种方式是:由于一个节点的更新次数与父节点的更新次数相关,于是我们可以使用 c n t cnt cnt数组,初始全为零,当节点 v v v被节点 u u u更新时,则 c n t [ v ] = c n t [ u ] + 1 cnt[v]=cnt[u]+1 cnt[v]=cnt[u]+1,当 c n t [ v ] ≥ n cnt[v]\ge n cnt[v]≥n时,存在负环
一般来说,第二种方式是优于第一种方式的,原因是第一种方式一般需要绕环 n n n次,第二种方式只需要一次
实现方式采用 B F S BFS BFS
一些常见优化 - 当数据范围过大的时候,普通 S P F A SPFA SPFA算法复杂度难以承受,我们就可以设定一个阈值,当 n , m n,m n,m的值较大,一般在 5 × 1 0 4 5\times 10^4 5×104以上的时候(使用二分等增加复杂度的另算),当队列的出队次数大于这个阈值的时候就自动认为有负环,这个算法的正确性难以保证,但只要阈值计算合理,正确率极高,当然,一般需要自己构造数据或者人为找到这个阈值,根据经验,这个阈值一般不会小于五十倍的 m m m,注意阈值的设定不可太高,否则会 T L E TLE TLE,但也不可低,否则会 W A WA WA
- 当图上大概率有负环的时候可以采用 D F S DFS DFS实现上面找负环的过程,需要注意的是,这样确实可以提高找到负环的效率,但若没有负环,复杂度极有可能达到上界 O ( n m ) O(nm) O(nm),相反, B F S BFS BFS就很稳定,只是有负环并且环比较大的时候会跑满,所以除非图上极大概率有负环的情况下,一般不会使用这个方法
bool spfa(int mid){
memset(dis,0x3f,sizeof dis);
memset(cnt,0,sizeof cnt);
queue<int>q;
dis[0]=0;
q.push(0);
while(!q.empty()){
int u=q.front();q.pop();
for(int i=head[u];i;i=nxt[i]){
int v=ver[i],w=cost[i];
if(dis[v]>dis[u]+w){
dis[v]=dis[u]+w;
cnt[v]=cnt[u]+1;
if(cnt[v]>=n)return 0;//存在负环
q.push(v);
}
}
}
return 1;//不存在负环
}
同理,正环的求法就是把最短路 S P F A SPFA SPFA换成最长路 S P F A SPFA SPFA,照样更新即可
最优高铁环
幻影国建成了当今世界上最先进的高铁,该国高铁分为以下几类:
S
S
S—高速光子动力列车—时速
1000
k
m
/
h
1000km/h
1000km/h
G
G
G—高速动车—时速
500
k
m
/
h
500km/h
500km/h
D
D
D—动车组—时速
300
k
m
/
h
300km/h
300km/h
T
T
T—特快—时速
200
k
m
/
h
200km/h
200km/h
K
K
K—快速—时速
150
k
m
/
h
150km/h
150km/h
该国列车车次标号由上述字母开头,后面跟着一个正整数
(
≤
1000
)
(≤1000)
(≤1000)构成。
由于该国地形起伏不平,各地铁路的适宜运行速度不同。
因此该国的每一条行车路线都由 K K K 列车次构成。
例如: K = 5 K=5 K=5 的一条路线为: T 120 − D 135 − S 1 − G 12 − K 856 T120−D135−S1−G12−K856 T120−D135−S1−G12−K856。
当某一条路线的末尾车次与另一条路线的开头车次相同时,这两条路线可以连接起来变为一条更长的行车路线。
显然若干条路线连接起来有可能构成一个环。
若有 3 条行车路线分别为:
x
1
−
x
2
−
x
3
x1−x2−x3
x1−x2−x3
x
3
−
x
4
x3−x4
x3−x4
x
4
−
x
5
−
x
1
x4−x5−x1
x4−x5−x1
x
1
∼
x
5
x1∼x5
x1∼x5 车次的速度分别为
v
1
∼
v
5
v1∼v5
v1∼v5。
定义高铁环的值为(环上各条行车路线速度和)的平均值,即:
[
(
v
1
+
v
2
+
v
3
)
+
(
v
3
+
v
4
)
+
(
v
4
+
v
5
+
v
1
)
]
/
3
[(v1+v2+v3)+(v3+v4)+(v4+v5+v1)]/3
[(v1+v2+v3)+(v3+v4)+(v4+v5+v1)]/3
所有高铁环的值的最大值称为最优高铁环的值。
给出 M M M 条行车路线,求最优高铁环的值(四舍五入为整数)。
分析
首先这个路线内部不需要管是什么,只需要知道两端即可,一条路线就等于是一条边,边权就是速度
不难发现,这道题涉及除法,并且最优高铁环的值明显可以二分,这就是一个0/1分数规划问题
我们设一个环为
G
=
(
V
,
E
)
G=(V,E)
G=(V,E),
V
V
V是点集,
E
E
E是边集,那么我们的答案就是找到一个
G
G
G,使得下式值最大
∑
i
∈
E
c
o
s
t
[
i
]
∑
u
∈
V
1
=
∑
i
∈
E
c
o
s
t
[
i
]
∣
V
∣
\frac{\sum_{i\in E}cost[i]}{\sum_{u\in V}1}=\frac{\sum_{i\in E}cost[i]}{|V|}
∑u∈V1∑i∈Ecost[i]=∣V∣∑i∈Ecost[i]
直接寻找明显很困难,下式又可以二分答案,那么我们考虑使用二分答案,设二分的值为
m
i
d
mid
mid,经过变式,有两种可能性
1.
∑
i
∈
E
c
o
s
t
[
i
]
∑
u
∈
V
1
≤
m
i
d
\frac{\sum_{i\in E}cost[i]}{\sum_{u\in V}1}\le mid
∑u∈V1∑i∈Ecost[i]≤mid
2.
∑
i
∈
E
c
o
s
t
[
i
]
∑
u
∈
V
1
>
m
i
d
\frac{\sum_{i\in E}cost[i]}{\sum_{u\in V}1}>mid
∑u∈V1∑i∈Ecost[i]>mid
我们以2为例子进行分析
因为是有向图,所以环上的边一样可以使用以这条边为入边的节点来作为长度
变式即得
∑ u ∈ V ( m i d − c o s t [ u ] ) < 0 \sum_{u\in V}(mid-cost[u])<0 u∈V∑(mid−cost[u])<0
此时这个环就好找了,就是判定图中有没有负环,若有负环说明此式成立,令
l
=
m
i
d
l=mid
l=mid,否则令
r
=
m
i
d
r=mid
r=mid,二分结束时,就得到了答案
在本题中存在特殊构造的数据,具有重边和自环,对于每一个自环,答案一定不会小于这些自环的边权,在代码中见最后的一个
max
\max
max,至于重边,使用贪心不难证明重边只需要保留边权最小的一条即可
另外,本题数据非常紧,达到了
50000
50000
50000的程度,需要使用上文所说的优化1进行优化,经实际测试50倍足以通过
#define N 50050
int num,ver[N],nxt[N],head[N],tot;float cost[N];float dis[N];int cnt[N],n,m,ms,to,mn=2e9;
struct node{
int u,v,w;
}que[N];
map<string,int>H;
map<pair<int,int>,pair<int,int> >edge;//判断重边,自环
int get(string n){
if(!H[n])H[n]=++num;
return H[n];
}//离散化,字符串化整数
int get_cost(char n){
if(n=='S')return 1000;
if(n=='G')return 500;
if(n=='D')return 300;
if(n=='T')return 200;
if(n=='K')return 150;
return 0;
}//得到权值
void add(int u,int v,int w){
nxt[++tot]=head[u],ver[tot]=v,cost[tot]=w,head[u]=tot;
}
void in(string x){
int u=-1,v=-1,w=0,len=x.size();
string y="";
for(int i=0;i<len;i++){
if(x[i]=='-'){
if(u==-1)u=get(y);
y="";
}
else w+=get_cost(x[i]),y+=x[i];
}
v=get(y);
ms+=w;
if(edge[make_pair(v,u)].second)mn=min(mn,edge[make_pair(v,u)].first);//自环
if(edge[make_pair(u,v)].second){//重边
if(edge[make_pair(u,v)].first<=w)return ;
edge[make_pair(u,v)].first=w;
que[edge[make_pair(u,v)].second].w=w;
return ;
}
que[++to]={u,v,w};
edge[make_pair(u,v)]=make_pair(w,to);
}
void init(float mid){//建新图
memset(head,0,sizeof head);
tot=1;
for(int i=1;i<=to;i++){
add(que[i].u,que[i].v,mid-que[i].w);
}
}
bool spfa(){
memset(cnt,0,sizeof cnt);
for(int i=1;i<=num;i++)dis[i]=2e9;
queue<int>q;
q.push(1);
dis[1]=0;
int t=0;
while(!q.empty()){
t++;
if(m>40000&&t>400000)return false;//优化1
int u=q.front();q.pop();
for(int i=head[u];i;i=nxt[i]){
int v=ver[i];
if(dis[v]>dis[u]+cost[i]){
cnt[v]=cnt[u]+1;
if(cnt[v]>=num)return false;
q.push(v);
dis[v]=dis[u]+cost[i];
}
}
}
return true;
}
float solve(){
float l=0,r=ms;
while(r-l>1e-1){
float mid=(l+r)/2;
init(mid);
if(spfa())r=mid;
else l=mid;
}
return l<1e-1?-1.5:l;//四舍五入
}
int main(){
cin>>m;
for(int i=1;i<=m;i++){
string x;
cin>>x;
in(x);
}
//puts("AS");
int ans=solve()+0.5;
if(mn<2e9)ans=max(ans,mn);
printf("%d",ans);
}
差分约束系统
概述
所谓差分约束系统是指一个包含
X
1
∼
X
n
X_1\sim X_n
X1∼Xn的未知数,
m
m
m个限制条件,每个限制条件是形如
X
i
−
X
j
≤
c
k
X_i-X_j\le c_k
Xi−Xj≤ck的不等式,其中
c
k
c_k
ck是任意常数,求其一组合法解
很明显,若我们找到了一组合法解,设为
a
1
∼
a
n
a_1\sim a_n
a1∼an,那么
a
+
Δ
,
a
2
+
Δ
…
…
a
n
+
Δ
a+\Delta,a_2+\Delta……a_n+\Delta
a+Δ,a2+Δ……an+Δ也是一组合法解,其中
Δ
\Delta
Δ为任意实数,因为两个变量做差会消去
Δ
\Delta
Δ
所以我们完全可以限制先找到一组负数解,然后通过变换找出所有解
我们发现,
X
i
−
X
j
≤
c
k
X_i-X_j\le c_k
Xi−Xj≤ck进行变式之后
X
i
≤
X
j
+
c
k
X_i\le X_j+c_k
Xi≤Xj+ck,这与三角形不等式很相似,这启发我们使用
S
P
F
A
SPFA
SPFA将其转换为图论问题进行求解,具体的,我们对于每一个约束条件都在图上加入边
(
j
,
i
,
c
k
)
(j,i,c_k)
(j,i,ck),注意是
j
−
>
i
j->i
j−>i的有向边,最后如果这张图跑
S
P
F
A
SPFA
SPFA最短路最后能够收敛(无负环),就说明这个差分约束系统有解,其中一组解为
d
i
s
t
dist
dist数组,若无法收敛(存在负环),则差分约束系统无解
在实际应用中,差分约束系统常常不会将所有的限制条件摆在明面上,我们还需要在题目上挖掘隐藏条件使得解有意义,例如一个非严格单调递增序列就具备隐含条件,
s
k
≥
s
k
−
1
s_k\ge s_{k-1}
sk≥sk−1,这些往往可以从答案的相对大小关系,答案有意义的条件等方面进行寻找。有时,这个差分约束系统会变样,比如变为
X
i
−
X
j
≥
c
k
X_i-X_j\ge c_k
Xi−Xj≥ck,遇到这种情况一个解决办法是照样建图,找正环,最长路,另一个方案就是变式为
X
j
−
X
i
≤
−
c
k
X_j-X_i\le -c_k
Xj−Xi≤−ck进行处理,亦或者需要前缀和等东东,比如
∑
k
=
i
i
+
d
a
k
≤
c
k
\sum_{k=i}^{i+d}a_k\le c_k
∑k=ii+dak≤ck,其中
d
,
i
d,i
d,i是常数,这种就可以使用前缀和来变成一般形式进行处理,有时我们会涉及到某些约束条件变成了三元,但多出来的一元都是一样的,这样我们可以二分答案进行处理,具体例子由下面的例题给出
雇佣收银员
一家超市要每天 24 24 24 小时营业,为了满足营业需求,需要雇佣一大批收银员。
已知不同时间段需要的收银员数量不同,为了能够雇佣尽可能少的人员,从而减少成本,这家超市的经理请你来帮忙出谋划策。
经理为你提供了一个各个时间段收银员最小需求数量的清单 R ( 0 ) , R ( 1 ) , R ( 2 ) , … , R ( 23 ) R(0),R(1),R(2),…,R(23) R(0),R(1),R(2),…,R(23)。
R ( 0 ) R(0) R(0) 表示午夜 00 : 00 00:00 00:00 到凌晨 01 : 00 01:00 01:00 的最小需求数量, R ( 1 ) R(1) R(1) 表示凌晨 01 : 00 01:00 01:00 到凌晨 02 : 00 02:00 02:00 的最小需求数量,以此类推。
一共有 N N N 个合格的申请人申请岗位,第 i i i 个申请人可以从 t i t_i ti 时刻开始连续工作 8 8 8 小时。
收银员之间不存在替换,一定会完整地工作 8 小时,收银台的数量一定足够。
现在给定你收银员的需求清单,请你计算最少需要雇佣多少名收银员。
分析
记每个时刻有
s
[
i
]
s[i]
s[i]个收银员可以开始工作,我们为了避免边界问题,将
R
R
R数组整体向后平移一位,即平移后
R
[
k
]
R[k]
R[k]表示
(
k
−
1
)
:
00
∼
k
:
00
(k-1):00\sim k:00
(k−1):00∼k:00,特别的,
R
[
0
]
R[0]
R[0]表示
24
:
00
∼
0
:
00
24:00\sim 0:00
24:00∼0:00
设
f
[
i
]
f[i]
f[i]表示在
[
0
,
i
]
[0,i]
[0,i]的时间内(单位:hour),我们选用的开始工作和工作完成的收银员的总数量(体现了上文提到的前缀和思想),这个
f
f
f一定满足以下条件
- f [ 0 ] = f [ 24 ] f[0]=f[24] f[0]=f[24],也即 0 ≥ f [ 0 ] − f [ 24 ] ≥ 0 0\ge f[0]-f[24]\ge 0 0≥f[0]−f[24]≥0
- ∀ i ∈ [ 8 , 24 ] , f [ i ] − f [ i − 8 ] ≥ R [ i ] \forall i\in[8,24],f[i]-f[i-8]\ge R[i] ∀i∈[8,24],f[i]−f[i−8]≥R[i]
- ∀ i ∈ [ 0 , 7 ] , f [ i ] + f [ 24 ] − f [ 16 + i ] ≥ R [ i ] \forall i\in[0,7],f[i]+f[24]-f[16+i]\ge R[i] ∀i∈[0,7],f[i]+f[24]−f[16+i]≥R[i]
-
s
[
i
]
≥
f
[
i
]
−
f
[
i
−
1
]
≥
0
s[i]\ge f[i]-f[i-1]\ge 0
s[i]≥f[i]−f[i−1]≥0
对于 f f f数组,我们发现可以使用差分约束系统进行求解,在代码实现中我使用的是最长路找正环的解法(上文提到的此种情况的解决方案1)
不过需要注意的是,对于第三个条件突兀的冒出来了个 f [ 24 ] f[24] f[24],并且 f [ 24 ] f[24] f[24]也是最终的答案,明显其是具有单调性的,我们可以二分求这个值,使用差分约束系统判断是否有解,毕竟题目也是要让求最小值
int n,m,t,k,head[25],cost[105],ver[105],nxt[105],tot,R[25],s[25],dis[25],cnt[105];
void add(int u,int v,int w){
nxt[++tot]=head[u],ver[tot]=v,cost[tot]=w,head[u]=tot;
}
bool spfa(int mid){
memset(dis,0xcf,sizeof dis);
memset(cnt,0,sizeof cnt);
queue<int>q;
dis[0]=0;
q.push(0);
while(!q.empty()){
int u=q.front();q.pop();
for(int i=head[u];i;i=nxt[i]){
int v=ver[i],w=cost[i]-(i>65)*mid;
if(dis[v]<dis[u]+w){
dis[v]=dis[u]+w;
cnt[v]=cnt[u]+1;
if(cnt[v]>=25)return 0;
q.push(v);
}
}
}
return 1;
}
int main() {
scanf("%d",&t);
while(t--){
tot=0;
for(int i=1;i<=24;i++)scanf("%d",&R[i]);
scanf("%d",&n);
memset(head,0,sizeof head);
memset(s,0,sizeof s);
for(int i=1;i<=n;i++){
scanf("%d",&m);
++s[m+1];
}
for(int i=1;i<=24;i++)add(i,i-1,-s[i]),add(i-1,i,0);
for(int i=8;i<=24;i++)add(i-8,i,R[i]);
for(int i=1;i<=7;i++)add(i+16,i,R[i]);
add(24,0,0);
add(0,24,0);
int l=0,r=n+1;
while(l<=r) {
int mid=l+r>>1;
cost[tot]=mid<<1;
if(spfa(mid))r=mid-1;
else l=mid+1;
}
if(r==n+1)printf("No Solution\n");
else printf("%d\n",l);
}
return 0;
}