夏令营讲课内容整理 Day 3.

时间:2023-03-09 17:40:05
夏令营讲课内容整理 Day 3.
本日主要内容是树与图。
1.树
    • 树的性质
    • 树的遍历
    • 树的LCA
    • 树上前缀和
树的基本性质:
对于一棵有n个节点的树,必定有n-1条边。任意两个点之间的路径是唯一确定的。
回到题目上,如果题目读入的是树上所有的边,则我们应该想到:
    1. 每个点的父亲是谁
    2. 每个点的深度
    3. 每个点距离根节点的距离
    4. 其他的附加信息(例如:子树和,子树最大值。。)
遍历整个树的代码如下:
 void dfs(int now)
{
deep[now]=deep[fa[now]]+;
sum[now]=value[now]; maxx[now]=value[now];
for 遍历从now出发的每一条边,边到达的点是v
if (v != fa[now])
{
fa[v]=now;
dfs(v);
sum[now]+=sum[v]; maxx[now]=max(maxx[now], maxx[v]);
}
}
实际上,树也有类似于图的邻接表存储结构。关于树的邻接表存法本来应该是Day5 zhn讲到的,我为了方便把这一块放在了Day 3的整理上。
我们用u[i],v[i]表示第i条边的两个端点,在有根树中一般认为v是儿子,u是父亲。w[i]代表边权,first[i]代表当前以i为端点的最后一条边,next[i]代表第i号点的下一条边。
struct Edge_tree{
int u,v,w;
int next; };
Edge_tree edge[maxn];
int cnt = ;
int first[maxn];
void add_edge(int from,int to,int dis){
edge[++cnt].u = from;
edge[cnt].v = to;
edge[cnt].w = dis;
edge[cnt].next = fisrt[from];
first[from] =cnt; /*
作为一棵无向树,还需要反向进行加边操作。
图的邻接表不也是这样吗?
*/
edge[++cnt].v = from;
edge[cnt].u = to;
edge[cnt].w = dis;
edge[cnt].next = first[to];
first[to] = cnt;
/*
这超酷,是不是?
以前我还从来没有想过可以使用邻接表存一棵树!
这可以说是最新操作了。
*/
} void dfs_tree(int x,int fa){
//cout << x << " ";
for (int i = first[x];i!=;i = edge[i].next)
if (edge[i].v != fa)
dfs_tree(edge[i].v,x);
}
树的LCA与倍增思想
LCA指树上两个节点的「最近公共祖先」。
一个比较简单的求法:我们找到这两个点到达根节点的路径,然后去寻找这两条路径的交集,交集上深度最大的点便是LCA。
具体到实现,我们可以先让深度较大的点先蹦到与深度较小的点的同深度位置,然后这两个点一起向上蹦,直到重合。
这个操作的时间复杂度是O(n)的,在数据量不大的时候可以使用,或者用来对拍。
正常的方法是倍增处理。
何为倍增?倍增是根据已经得到的信息,将考虑的范围扩大一倍,从而加速操作的一种思想,它在变化规则相同的情况下,加速状态转移。
运用倍增思想的算法有:倍增查找LCA,归并排序,快速幂,基于ST表的RMQ(这个今年没讲),当然还有FFT啊后缀数组啊等不在NOIP考纲范围的奇怪的东西。。。
这次我们重点考虑LCA。
我们用一个数组f[i][j]表示i这个点向上跳2^j次,会跳到什么地方。
我们j从小到大枚举,用j较小的更新j较大的:
就有f[i][j]=f[f[i][j-1]][j-1],边界f[i][0]=fa[i]
有了这一步预处理,接下来的操作就和朴素的做法类似了。
分成两个阶段。第一阶段把让两个点处在同一深度,第二阶段我们就让这两个点跳到重合位置。
但因为我们每次是跳2^j次(倍增,加速处理嘛,所以有些时候会“跳过头”,怎么办?
试探法。试着去跳2^j个格子,如果不跳过头,就可以跳,否则不这样跳。
现在我们让两个点处在同一深度了,然后我们从大到小枚举j,看跳2^j步会不会跳到一个地方。如果会,不跳;如果不会,跳。这样跳完了以后,x和y这两个点将在距离lca仅一步的位置。再跳一步就好了。。
我在我的电脑里找到了我大概半年之前写的这个LCA。。。
 #include<iostream>
#include<cstring>
#include<cstdio>
#include<vector>
#define maxn 2333
using namespace std;
int f[maxn][maxn];
int father[maxn];
int deep[maxn];
vector<int> tree; void dfs(int x){
f[x][] = father[x];
for (int i=;i<=n;i++)
f[x][i] = f[f[x][i-]][i-];
for (int i=;i<tree[x].size();i++){
if (tree[x][i]!=father[x]){
int y = tree[x][i];
father[y] = x;
deep[y] = deep[x]+;
dfs(y);
}
}
}//从根节点开始dfs,预处理f数组 //查询LCA:
int lca(int x,int y){
if (deep[x]<deep[y])
swap(x,y);
for (int i=n;i>=;i--)
if (deep[y] <= deep[f[x][i]])
x = f[x][i];
if (x==y)
return x;
for (int i=n;i>=;i--)
if (f[x][i]!=f[y][i]){
x = f[x][i];
y = f[y][i];
}
return f[x][];
} int main(){
//do something
return ;
}
树的前缀和
类似线性表的前缀和。
还记得线性表的前缀和吗?
sum[i]表示a[1]~a[i]的和
用处1:求i~j的和sum[j]-sum[i-1]
用处2:区间修改。设置一个change数组。当区间[i,j]上要加k时,我们令change[i]+=k,令change[j+1]-=k。如果我们对change数组求前缀和的话,前缀和sum_change[i]就是i这个位置变动的值
树的前缀和有两种。第一种是子树前缀和sum1[i],指i的子树(包括i本身)所有节点的权值之和。第二种是根路径前缀和sum2[i],指i到根节点所有节点的权值之和。
用处1:用来求路径节点的前缀和
用处2:路径修改
    • 根路径前缀和
我们要求x点到y点的前缀和,设z = lca(x,y),则有Sx-y = sum[x] + sum[y] - 2sum[z] + val[z].
    • 子树前缀和
可用来做路径修改。设定一个修改数组change。如果要对x到y路径上的所有点权值+k,lca为z。那么change[x]+=k,change[y]+=k,change[z]-=k,change[fa[z]]-=k。这样如果最后对change[i]求前缀和的话,最后得到的结果就是i权值的修改量
特点:可以O(1)修改,但是只能一次查询(因为要求前缀和O(n))
    • 邻接矩阵 && 邻接表
    • 图的最短路径算法
    • 最小生成树
    • 拓扑排序(我之前好像总结过
存图方式
1.邻接矩阵:比较直观的一种存图方式,使用二维数组存图,下表i,j表示的是节点,而记录的值就代表这两个点的关系。
优点:直观好写,理解简单
缺点:无法适用于太大的数据范围
2.邻接表:应用最广泛的存图方式,它的本质是若干串链表。
虽说是链表,但实际使用中一般使用数组模拟链表进行存储。以每个节点作为一串链表中的头结点,其后继点代表着与这个点相连的点。
优点:速度快,支持的数据范围较为广泛
缺点:如果对链表不是很理解的话理解代码时会存在一定困难
链表按难度应该是基础班的东西。我相信看我这些随笔的人应该都具有数据结构基础班或数据结构基础班以上的实力,我就不再写链表是怎么一回事了。。
实现方式:手写
 struct Edge{
long long int from,to,dis;
};
Edge edge[maxn];
long long int head[maxn];
long long int cnt = ;
void add_edge(long long int from,long long int to,long long int dis){
edge[++cnt].from = head[from];
edge[cnt].to = to;
edge[cnt].dis = dis;
head[from] = cnt;
}

这是我最常用的邻接表。开一个struct存边。

加边操作其实就是往链表里塞一个节点罢了。那个head数组表示第i个点连接的下一条边的编号。

求最短路径
图上求最短路的算法大体分三种。
1.floyed算法,可求任意两点之间的最短路径,一般配合邻接矩阵食用。时间复杂度O(n3),在一些数据量较小的题中还是有些作用的。
2.Dijkstra算法。基于贪心思想的只能处理无负权图的单源最短路算法。未优化时的复杂度是O(n2),使用堆(一般使用优先队列)进行优化后时间复杂度降为O((n+m)log(n+m)),n是点数,m是边数。
给出堆优化之后的代码:
 #include<cstdio>
#include<cstring>
#include<iostream>
#include<queue>
#include<vector>
#include<algorithm>
#define ll long long
#define INF 2147483647
using namespace std;
int n,m,s,head[],cnt;
ll dis[];
bool used[];
struct Edge{
int to,from,dis;
}edge[]; void add_edge(int u,int v,int dis){
edge[cnt].to=v;
edge[cnt].from=head[u];
edge[cnt].dis=dis;
head[u]=cnt++;
}
typedef pair<int,int> P;
void dijkstra(int s){
priority_queue<P,vector<P>,greater<P> > q;
fill(dis,dis+n+,INF);
fill(used,used+n+,false);
dis[s]=;
q.push(P(,s));
while(!q.empty()){
P p=q.top();q.pop();
int u=p.second;
if(used[u]) continue;
used[u]=true;
int pp=head[u];
while(pp!=-){
int v=edge[pp].to;
if(!used[v]&&dis[v]>dis[u]+edge[pp].dis){
dis[v]=dis[u]+edge[pp].dis;
q.push(P(dis[v],v));
}
pp=edge[pp].from;
}
}
}
int main(){
memset(head,-,sizeof(head));
cin>>n>>m>>s;
for(int i=;i<=m;i++){
int u,v,d;
scanf("%d%d%d",&u,&v,&d);
add_edge(u,v,d);
}
dijkstra(s);
for(int i=;i<=n;i++) printf("%lld ",dis[i]);
return ;
}

用了一个pair,对组,用来存放最短路径

priority_queue<P,vector<P>,greater<P> > q;这样声明,便成了小根堆,我们每次都要取最小的边。

3.SPFA算法,即经过队列优化的bellman-ford算法,也是我个人最喜欢使用的求最短路的算法,它支持存在负边的图,并且可以在判定出负环后及时退出。时间复杂度是O(kE) O(RP),其中E代表边数,k是一个玄学常数,均值为2。
(k的大小与当前写代码的人有没有穿女装有很大关系
但SPFA也是存在缺点的。当你试图在一个稠密图(点少边巨多)的图上跑SPFA时,它将会变得非常慢,这与它的扩展方式有密切关系。
 #include<iostream>
#include<cstdio>
#include<cstring>
#include<queue>
#define maxn 5000015
#define INF 2147483647
#define ms(x) memset(x,0,sizeof(x));
using namespace std;
struct Edge{
long long int from,to,dis;
};
Edge edge[maxn];
long long int n,m,s,u,v,d;
long long int head[maxn];
long long int dis[maxn];
bool inq[maxn];
long long int cnt = ;
void add_edge(long long int from,long long int to,long long int dis){
edge[++cnt].from = head[from];
edge[cnt].to = to;
edge[cnt].dis = dis;
head[from] = cnt;
} void spfa(void){
queue<long long int> q;
q.push(s);
ms(inq);
inq[s] = true;
for (int i=;i<=n;i++)
dis[i] = INF;
dis[s] = ;
while (!q.empty()){
long long int u = q.front();
q.pop();
inq[s] = false;
for (int i=head[u];i!=;i=edge[i].from){
long long int v = edge[i].to;
long long int w = edge[i].dis;
if (dis[u]+w < dis[v]){
dis[v] = w+ dis[u];
if (!inq[v]){
q.push(v);
inq[v] = true;
}
}
}
} } int main(){
cin >> n >> m >> s;
for (int i=;i<=m;i++){
cin >> u >> v >> d;
add_edge(u,v,d);
}
spfa();
for (int i=;i<=n;i++)
cout << dis[i] << " ";
return ;
}

如果要判断负环的话,再加一个数组记录每个点入队的次数,如果在入队操作时发现一个点的入队次数超过n,则一定存在负环。

特殊的方式:
如果一个图的边权都是1,那么我们有什么简便方法求最短路呢?
BFS与SPFA算法的扩展非常类似,并且由于BFS算法的性质,找到的第一个解必定最优,也就是最短。
最小生成树
昨天卖的关子现在该揭开了。
最小生成树的官方定义比较麻烦,用通俗一点的话来说就是在这个图中找到“一棵连接了所有节点的树”,并且树上的所有边权值是最小的。
最小生成树有两种算法。第一种是基于贪心思想的prim,我平常不怎么用。。
第二种就是我现在要说的这个kruskal。
首先,我们把所有边按照边权从小到大排序,并认为每个点在初始状态时都是孤立的。然后我们桉顺序枚举每条边,若当前枚举的这条边连接两个不同的集合,那么这条边就一定属于最小生成树,同时将这两个集合合并。若当前枚举的这条边连接相同的集合,则不选这条边。
根据树的基本性质,一个拥有n个节点的树有n-1条边,则使用kruskal找到n-1条边后终止就好了。
给出伪代码:
 .初始化 father[x] = [x],tot =
.对所有边进行边权排序,设边数为m
.for (int i=;i<=m;i++){
  if 当前的这条边连接的两个点不属于同一集合{
    合并两集合,并把边(u,v)加入最小生成树
    tot += w(u,v),k++
    if (k==n-)
      break;
  }
}
关于这个正确性的证明。。我不太会。。反正它肯定是对的就是了。。
tips:有的题目没有很明显的字眼要求你使用图论算法,但是看起来必须要用图论,那么我们就要考虑适当的建模,把原题转化为图论问题。
(这个想法在做某些数学题的时候也适用23333