2022-2023算法总结

· · 个人记录

2022-2023算法总结

一、图论

1.图的基本概念

2.图的存储

图的存储有两种常见方式,分为邻接矩阵和邻接表( vector 和链式前向星)。

设图中有 n 个点,用一个 n\times n 的矩阵(二维数组) g 保存图的信息。

图示: ![邻接矩阵图示](https://cdn.luogu.com.cn/upload/image_hosting/78ixcmk7.png?x-oss-process=image/resize,m_lfit,h_170,w_225) 邻接矩阵的特点主要表现为: 代码实现简单,无向图的邻接矩阵是对称的。 查找和删除某条边的时间复杂度是 $O(1)$ ,遍历某个点的邻居时间复杂度是 $O(n)$ ,存储空间为 $O(n^2) $ 。 - **邻接表** 基本原理是让每个结点的邻居形成一个链表。 如图: ![邻接表存图](https://cdn.luogu.com.cn/upload/image_hosting/mgajkcxi.png?x-oss-process=image/resize,m_lfit,h_170,w_225) 代码实现: 用 vector 数组实现 ```cpp const int maxn=10005,maxm=10005; vector<pair <int,int> > E[maxn]; int main(){ int n,m,u,v,w; cin>>n>>m; for(int i=1;i<=m;i++){ cin>>u>>v>>w; E[u].push_back(make_pair(v,w)); E[v].push_back(make_pair(u,w)); } //... int now; for(int i=0;i<E[now].size();i++){ int v=E[now][i].first; int dis=E[now][i].second; //... } } ``` 链式前向星实现: ```cpp const int maxn=10005,maxm=10005; int head[maxn],edgenum; struct edge{ int to,nxt,w; }e[maxm]; void add_edge(int u,int v,int w){ e[++edgenum].nxt=head[u]; e[edgenum].to=v; e[edgenum].w=w; head[u]=edgenum; } int main(){ int n,m,u,v,w; cin>>n>>m; for(int i=1;i<=m;i++){ cin>>u>>v>>w; add_edge(u,v,w); //add_edge(v,u,w); 若是无向边加边需要加两遍 } for(int i=1;i<=n;i++){ for(int j=head[i];j;j=e[j].nxt){ //...遍历图 } } } ``` - 邻接表和邻接矩阵的对比 1st:邻接矩阵较直观,容易理解,可以 $O(1)$ 查询两点之间的关系,但其空间复杂度高,对于稀疏图会浪费很多空间,对于带权图也不能处理重边,而且要查询与某一顶点邻接的所有边,需要枚举 $n$ 次,容易超时。 2nd:链式前向星不改变边表原来的存储顺序,只是建立了新的逻辑关系,降低了空间复杂度,能快速找到某个顶点所有邻接的顶点,而不需要访问无关顶点。 3rd:邻接表的空间复杂度是 $O(m)$ ,遍历每一条边的时间复杂度也是 $O(m)$ ,如果一个图是稀疏图,则 $m$ 远小于 $n^2$ ,所以对于稀疏图,邻接表要好很多。 **3.图的遍历** 从图 $G$ 中的某一个初始点出发,按照一定的搜索方法对图中每一个结点访问且仅访问一次的过程,称为图的遍历。遍历时可以处理节点的信息,如输出、查找节点,通常有深度优先搜索和广度优先搜索两种遍历方式。 - 图的深搜遍历(dfs) 从一个结点出发扫描出边,到点没有访问过则以到点继续 dfs 。与深搜原理类似。 代码实现: ```cpp #include<bits/stdc++.h> using namespace std; const int maxn = 10005; const int maxm = 10005; int head[maxn], vis[maxn], num; struct Edge{ int to, next; }edge[maxm]; void addedge(int from, int to){ edge[++num].next = head[from]; edge[num].to = to; head[from] = num; } void dfs(int u){ vis[u] = 1; cout << u << ' '; for(int i = head[u]; i; i = edge[i].next){ int v = edge[i].to; if(!vis[v]) dfs(v); } } int main(){ int n, m, u, v; cin >> n >> m; for(int i = 1; i <= m; i++){ cin >> u >> v; addedge(u, v); addedge(v, u); } dfs(1); return 0; } ``` - 图的广搜遍历 与 bfs 类似,这里不再赘述。 代码实现: ```cpp void bfs(int t){ vis[t] = 1; q.push(t); while(!q.empty()){ int u = q.front(); cout << u << ' '; q.pop(); for(int i = head[u]; i; i = edge[i].next){ int v = edge[i].to; if(!vis[v]){ q.push(v); vis[v] = 1; } } } } ``` 例题: [P3916 图的遍历](https://www.luogu.com.cn/problem/P3916) [P2819 图的m着色问题](https://www.luogu.com.cn/problem/P2819) **4.图的最短路问题** 以前写过[总结](https://www.luogu.com.cn/blog/nlxxanpwp/zui-duan-lu-jing-suan-fa),戳链接 $\leftarrow

5.拓扑排序

DAG:有向无环图

在一个DAG中,一条有向边的起点叫做其终点的前驱节点,同理,终点是起点的后继结点。

把 DAG 中所有活动排成一个序列,使得每个活动的所有前驱都在该活动的前面,这个过程叫拓扑排序。 (拓扑排序的结果可能并不唯一)

拓扑排序的过程?

如何实现?

首先统计每个点的入度,使入度为0的点入队,队首指针的点的关联边的 to 点入度减 1 ,继续第二步直到队列为空,也就是所有点的入度均为 0 。最后队列数组的内容就是拓扑排序的结果。

代码实现:

#include <bits/stdc++.h>
using namespace std;

void add_edge(int from, int to){
    edge[++edgenum].next = head[from];
    edge[edgenum].from = from;
    edge[edgenum].to = to;
    head[from] = edgenum;
    ++in[to];
}

void topo_sort(){
    cin >> n >> m;
    int h = 0, t = 0;
    for(int i = 1; i <= n; i++)
        if(in[i] == 0) q[t++] = i;
    while(h <= t){
        int x = q[h++];
        for(int i = head[x]; i; i = edge[i].next){
            --in[edge[i].to];
            if(!in[edge[i].to]) 
                q[t++] = edge[i].to;
        }
    }
}

6.最小生成树

相关概念:

主要介绍两种算法: Prim 算法和 Kruskal 算法

Prim 算法基于贪心思想,它将无向连通图 G 中的所有顶点 V 分成两个集合 VaVbVa 为已选好的连接入生成树的点,否则属于 Vb

最开始 Va 只包含任意一个点,每次添加一个 Vb 中的点到 Va ,条件是该点是 Vb 中到 Va 中距离最小的一个点,直到所有点都属于 Va 。出发点不同,最小生成树形态就不同。

实现步骤:

选择一个未标记的点 k ,并且 d_k 的值是最小的,标记点 k 进入集合 Va ,以 k 为中间点,修改未标记点 j ,即 Vb 中的点到 Va 的距离值。

代码实现:

邻接矩阵存图:

void prim(int v0){
    int minn;
    for(int i=1;i<=n;i++)
        dis[i]=INF;
    dis[v0]=0;
    for(int i=1;i<=n;i++){
        minn=INF;
        for(int j=1;j<=n;j++){
            if(vis[j]==0 && minn>dis[j]){
                minn=dis[j];
                k=j;
            }
        }
        vis[k]=1;
        ans+=minn;
        for(int j=1;j<=n;j++){
            if(vis[j]==0 && dis[j]>g[k][j])
                dis[j]=g[k][j];
        }
    }
}

Vector 存图:

#include <bits/stdc++.h>
using namespace std;

void Prim(int v0){
    int mark, minn, ans = 0;
    for(int i = 1; i <= n; i++)
        d[i] = INF;
    d[v0] = 0;
    for(int i = 1; i <= n; i++){
        minn = INF;
        for(int j = 1; j <= n; j++)
            if(!vis[j] && minn > d[j]){
                minn = d[j];
                mark = j;
            }
        vis[mark] = 1;
        for(int j = 0; j < E[mark].size(); j++){
            int v = E[mark][j].first;
            if(!vis[v] && d[v] > E[mark][j].second)
                d[v] = E[mark][j].second;
        }
    }
}

算法优化:

暴力 Prim 算法的时间复杂度:每一个点要扫一遍所有点求最小 d_i 值,所以应为 O(n^2) ,准确点说应该是 O(n^2+m) ,因为每条边的边权值会用来更新 d的 值。可以考虑用堆优化求 d 的最小值。

堆优化 Prim 算法的时间复杂度:每条边的边权值会用来更新 d 值并丢进堆维护,这部分是 m \log m ;每个点都会取一次堆顶元素得到 d 的最小值,这部分是 n \log m ;因此总的时间复杂度是 O((n+m) \log m)

也是基于贪心思想。首先将边按权值排序,每次从剩下的边集中选择权值最小的且与不前面所选边构成环的边加入生成树中,直到加入了 n-1 条边。

判断环用并查集:待加边的两个端点若已在生成树中,则必定构成环。

时间复杂度为: O(n \log n)

代码实现:

#include<bits/stdc++.h>
using namespace std;
int n,m,fa[5005],cnt,ans;
struct node{
    int u,v,w;
}a[200005];
bool cmp(node p,node q){
    return p.w<q.w;
}
int find(int x){
    if(fa[x]==x) return x;
    return fa[x]=find(fa[x]);//并查集的基础上
}
int main()
{
    cin>>n>>m;
    for(int i=1;i<=n;i++) fa[i]=i;
    for(int i=1;i<=m;i++){
        cin>>a[i].u>>a[i].v>>a[i].w;
    }
    sort(a+1,a+m+1,cmp);
    for(int i=1;i<=m;i++){
        int t1,t2;
        t1=find(a[i].u);
        t2=find(a[i].v);
        if(t1!=t2){
            fa[t1]=t2;
            cnt++;
            ans+=a[i].w;
        }
        if(cnt==n-1) break;
    }
    if(cnt==n-1) cout<<ans;
    else cout<<"orz";//判负权环
    return 0;
}

7.最近公共祖先(LCA)

最近公共祖先简称 LCA ,所谓 LCA ,是当给定一个有根树 T 时,对于任意两个结点 uv,找到一个离根最远的结点 x ,使得x同时是 uv 的祖先,x 便是uv 的最近公共祖先。

如上图,结点 3 和结点 4 的最近公共祖先是结点 2 ,即 \operatorname{LCA} (3,4) =2 ,结点 3 和结点 2 的最近公共祖先为 2 ,即 \operatorname{LCA} (3,2) =2。类似地,\operatorname{LCA} (5,6) =4\operatorname{LCA} (6,10) =1

LCA 的基础性质

可以每次找深度比较大的那个点,让它向上跳。显然在树上,这两个点最后一定会相遇,相遇的位置就是想要求的 LCA 。

或者先向上调整深度较大的点,令他们深度相同,然后再共同向上跳转,最后也一定会相遇。

朴素算法预处理时需要 dfs 整棵树,时间复杂度为 O(n) ,单次查询的时间复杂度为 O(n) ,但由于随机树高为 O( \log n) ,所以朴素算法在随机树上的单次查询时间复杂度为 O( \log n)

倍增算法求 LCA?

朴素算法的改进算法。

通过预处理 fa_{x,i} 数组,游标可以快速移动,大幅减少了游标跳转次数。

$fa_{x,i}$ 数组可以通过 dfs 预处理出来。 _**如何优化这些跳转?**_ 在调整游标的第一阶段中,我们要将 $u$ 、$v$ 两点跳转到同一深度。我们可以计算出$u$ 、$v$ 两点的深度之差,设其为$y$ 。通过将$y$ 进行二进制拆分,我们将 $y$ 次游标跳转优化为“ $y$ 的二进制表示所含1的个数”次游标跳转。 在第二阶段中,我们从最大的 $i$ 开始循环尝试,一直尝试到 0 (包括 0 ),如果$fa_{u,i} !=fa_{v,i}$ ,则$u=fa_{u,i}$ , $v=fa_{v,i}$ ,那么最后的 LCA 为$fa_{u,0}$。 倍增算法的预处理时间复杂度为 $O(n \log n)$ ,单次查询的时间复杂度为 $O( \log n)

代码实现:

void dfs(int u, int fa){
    dep[u] = dep[fa] + 1;
    for(int i = 0; i <= 19; i++)
        fa[u][i+1] = f[f[u][i]][i];
    for(int i = head[u]; i; i = edge[i].nxt){
        int v = edge[i].to;
        if(v == fa) continue;
        f[v][0] = u;
        dfs(v, u);
    }
}
//预处理出每个点的深度和f数组 
int lca(int x, int y){
    if(dep[x] < dep[y]) swap(x, y);
    for(int i = 20; i >= 0; i--){
        if(dep[f[x][i]] >= dep[y]) x = f[x][i];
        if(x == y) return x; 
    }
    for(int i = 20; i >= 0; i--){
        if(f[x][i] != f[y][i]){
            x = f[x][i];
            y = f[y][i];
        }
    }
    return f[x][0];
}

upd:在写lca例题的一点感想。

lca算法所用到的倍增思想非常经典,st表也是运用了相似的倍增思想,所以可以积累一下(。

还有tarjan求lca的方法,有些神秘题里还是有必要的。(虽然我不会!)

8.强联通分量

相关基本概念

图例:(先鸽着)

Tarjan 算法求强联通分量

图示:鸽着。懒得写了。

代码实现:

void tarjan(int u)
{
    dfn[u]=low[u]=++cnt;
    vis[u]=1;
    st.push(u);
    for(int i=head[u];i;i=e[i].next)
    {
        int v=e[i].to;
        if (!dfn[v])
        {
            tarjan(v);
            low[u]=min(low[u], low[v]);
        }
        else if(vis[v])
            low[u]=min(low[u], dfn[v]);
    }
    if(dfn[u]==low[u])
    {
        tot++;
        int v;
        do
        {
            v=st.top();
            st.pop();
            col[v]=tot;
            vis[v]=0;
        }while(v!=u);
    }
}

很经典的一道题:采蘑菇(算是 SPFA 和缩点的有机合成物?)

参考代码:

#include <bits/stdc++.h>
using namespace std;
const int N = 8e4 + 10, M = 2e5 + 10;
int n, m, t, cnt, tot, T, s, ans;
int head[N], dfn[N], low[N], vis[N];
int col[N], H[N], sum[N], dis[N], V[N];
struct edge
{
    int from, to, next, val, k;
} e[M], E[M];
stack<int> st;
queue<int> q;
void addedge(int u, int v, int w, int k)
{
    e[++t] = (edge){u, v, head[u], w, k};
    head[u] = t;
}
void add(int u, int v, int w)
{
    E[++T].to = v;
    E[T].next = H[u];
    E[T].val = w;
    H[u] = T;
}
void tarjan(int u)//tarjan缩点模板
{
    dfn[u] = low[u] = ++cnt;
    vis[u] = 1;
    st.push(u);
    for (int i = head[u]; i; i = e[i].next)
    {
        int v = e[i].to;
        if (!dfn[v])
        {
            tarjan(v);
            low[u] = min(low[u], low[v]);
        }
        else if (vis[v])
            low[u] = min(low[u], dfn[v]);
    }
    if (dfn[u] == low[u])
    {
        tot++;
        int v;
        do
        {
            v = st.top();
            st.pop();
            col[v] = tot;
            vis[v] = 0;
        } while (v != u);
    }
}
void spfa(int s)//spfa跑最长路
{
    memset(dis, -1, sizeof(dis));
    memset(V, 0, sizeof(V));
    dis[s] = sum[s];
    V[s] = 1;
    q.push(s);
    while (!q.empty())
    {
        int u = q.front();
        q.pop();
        V[u] = 0;
        for (int i = H[u]; i; i = E[i].next)
        {
            int v = E[i].to, w = E[i].val;
            if (dis[v] < dis[u] + w + sum[v])
            {
                dis[v] = dis[u] + w + sum[v];
                if (!V[v])
                {
                    V[v] = 1;
                    q.push(v);
                }
            }
        }
    }
}
int main()
{
    scanf("%d%d", &n, &m);
    for (int i = 1; i <= m; i++)
    {
        int x, y, z;
        double k;
        scanf("%d%d%d%lf", &x, &y, &z, &k);
        addedge(x, y, z, k * 10);//先让恢复系数乘10
    }
    for (int i = 1; i <= n; i++)
        if (!dfn[i])
            tarjan(i);
    for (int i = 1; i <= m; i++)
    {
        int x = e[i].from, y = e[i].to, z = e[i].val, k = e[i].k;
        if (col[x] == col[y])
            while (z)//同一个强连通分量就统计蘑菇数
            {
                sum[col[x]] += z;
                z = z * k / 10;//再整除10
            }
        else
            add(col[x], col[y], z);//不是同一个强连通分量就建边
    }
    scanf("%d", &s);
    s = col[s];
    spfa(s);
    for (int i = 1; i <= tot; i++)
        ans = max(ans, dis[i]);//终点是未知的,所以通过比较dis找到最长路
    printf("%d\n", ans);
    return 0;
}

9.双连通分量

相关概念:

(这里有两张图,先鸽了。我是鸽子。)

Tarjan 求割点

暴力求解割点的方法就是枚举每个点,然后再 dfs 整个图看是否连通,复杂度太高。

考虑使用 Tarjan 算法,设 dfn 数组表示 dfs 访问顺序(时间戳),low 数组表示存储不经过其父亲能到达的最小的时间戳。