window.cnblogsConfig = { homeTopImg: [ "https://cdn.luogu.com.cn/upload/image_hosting/clcd8ydf.png", "https://cdn.luogu.com.cn/upload/image_hosting/clcd8ydf.png" ], }

图论算法

链式前向星

首先先初始化一个数组: \(head[N]\) 代表每一个 \(u\) 指向第一个 $ v$。

初始化时需要将其初始化为 \(-1\),表示每个 \(u\) 没有连的边

初始化

图1

链式前向星使用的是插头法:每一个元素与插入链表中都是再最前面的。

插入

例如:输入一条 \(2 - 5\) 的边,此时调用函数 \(add(2,5)\),那么在 \(2\) 这个链表的首元素插入 \(5\)。如图:

  • 第一步,先给 \(5\) 设置一个 $ id$( \(id\) 是每插入一个元素都++的)
  • 第二步,将 \(5\)\(nxt\) 数组等于 $ head[2]$ ,\(head[2]\) 代表的就是原本 \(-1\)\(id\),那么将 \(5\) 的下一个元素设为这个 \(-1\)\(id\),其实 \(nxt\) 数组可以理解伪指针。
  • 第三步,把 \(head[2]\) 的指向变成 \(5\),改变指针
  • 第四步,用一个数组记录每一个 \(id\) 的值,即 $ 5$ 的 \(id == 1\),那么 \(vel[1] = 5\)

图2

类似的,再插入一个元素也一样

图3

遍历

  • 将$ i $指向要遍历的 \(u\)(此处为 \(2\))的 \(head\)
  • 每次 \(for\) 循环 \(i=nxt[i]\),即指向下一个指针。
  • 如果 \(i==-1\) 那么代表已经走到尽头了,所以就作为终止条件

图4

链式前向星一定要记得初始化!
// head[u] 和 cnt 的初始值都为 -1
void add(int u, int v) {
  nxt[++cnt] = head[u];  // 当前边的后继
  head[u] = cnt;         // 起点 u 的第一条边
  to[cnt] = v;           // 当前边的终点
}

// 遍历 u 的出边
for (int i = head[u]; ~i; i = nxt[i]) {  // ~i 表示 i != -1
  int v = to[i];
}

copy一份oiwiki模板

强连通分量

Tarjan

抽象难懂的算法

第一次接触链式前向星,本算法储存方式为链式前向星,用vector不香吗

神犇的blog

通俗易懂的讲解

通俗题解

抽象难懂的讲解

P1656题解-生动形象讲解割边

这个算法很多什么low啊,dfn啊,把你搞得很晕……

其实整个算法就是基于DFS然后再加上玄学打标记搞定的。

这里只介绍Tarjan求割点 (因为其他我不会

前置知识

链式前向星

见前面

割点

通俗的讲,就是一个图只要把这个点割了,就不连通了。

时间戳

在进行DFS的时候对每个点编号的方式,就是dfs第几个遍历到的。

搜索树

用DFS在一个 \(n\) 的图中构建一个 \(n-1\) 的数,感觉跟生成树比较像,但是是用DFS构造的

追溯值:low

low值得定义

书面定义:

设以x为根的搜索树子树为subtree(x),low(x)为x以下结点的dfn的最小值

  • sub(x)中的结点
  • 通过一条不是搜索树中的边到达一个sub(x)的点

很晕对吧,我也不懂(copy别人的

于是我就用我理解的语言来讲解

这里就盗用OI wiki中的图片来讲解

我认为这个就是tarjan中令人难懂的一个点。

假设,我们已经对dfn进行了初始化。每个点上的值就是其dfn的值。图中黑色的边就是树边,也就是一个dfs生成树中的边。

而有一条红色的边,他不是树边,它却指向了一个dfn值比他小的边,也就是直接指向了自己某个祖宗。所以这个边就叫做返祖边

1

如果此时还有一条返祖边(7,8)。(如图所示):

2

结论:那么对于结点7他的low值就是1

为什么呢?

因为有(7,1)和(7,8)这两条返祖边使得结点7连向了结点1和结点8,因为 \(1<8\) 所以 \(low_7\)\(1\)

接下来,我们考虑如何计算low值

初始化:很容易想到 \(low_i\) = \(dfn_i\)。因为连到自己也是可以的嘛。

如果对于 \(x\),有一个 (\(x\),\(y\))的连边。

  • 如果y是在搜索树中x的子树内,那么 \(low_x\) = \(min(low_x,low_y)\)。因为如果它的子树有一条连到更小的点的返祖边,那么肯定也得算上它自己

  • 如果(\(x,y\))不是一条树边(就例如7和9吧)。那么只能用 \(dfn_y\) 来更新 \(low_x\) ,因为他们又不是一个搜索树中,所以最多就只能说是 \(x\) 有连了一条边到了 \(y\) 。那么最多也就是更新一下到达的点中最小的值而已(就是low)。再根据定义,它不满足上一条条件吗,所以无法继承y的low值。

计算割点

Tarjan算法告诉我们,如果 \(y\)\(x\) 的子节点且 \(low_y > dfn_x\)。那么 \(x\) 就是割点。

为什么呢?因为根据树的定义,\(y\) 有且仅有一条边连向它的祖先(包括他父节点\(x\))。那如果 \(y\) 除了这条连向祖先的边,只能访问到比\(x\)后遍历到的结点(也就是 \(dfn\) \(dfn_x\) 还大 的结点)。也就意味着如果把 \((x,y)\) 这条边给断掉了,\(y\)无法访问到比\(x\)的dfn小的结点。那么就不连通了,所以此时 \(x\) 就是割点

但是:这个对于根节点肯定是行不通的!!

2

说明:本图片摘自此博客。谢谢@ MnZn大佬的图片。

就像上面的那个图,

如果根节点用以上方法判定,那就会判定成割点,但是显然不是。所以引出了新的判断根节点的方式:如果搜索树的根节点有两棵即以上的子树,那么根节点为割点。显然,如果把根节点割去,那么两个子树不互相连通,即为割点。

OI-wiki P3388 代码

    /*
    洛谷 P3388 【模板】割点(割顶)
    */
    #include <bits/stdc++.h>
    using namespace std;
    int n, m;  // n:点数 m:边数
    int dfn[100001], low[100001], inde, res;
    // dfn:记录每个点的时间戳
    // low:能不经过父亲到达最小的编号,inde:时间戳,res:答案数量
    bool vis[100001], flag[100001];  // flag: 答案 vis:标记是否重复
    vector<int> edge[100001];        // 存图用的
    void Tarjan(int u, int father) {  // u 当前点的编号,father 自己爸爸的编号
      vis[u] = true;                  // 标记
      low[u] = dfn[u] = ++inde;  // 打上时间戳
      int child = 0;             // 每一个点儿子数量
      for (auto v : edge[u]) {   // 访问这个点的所有邻居 (C++11)
        if (!vis[v]) {
          child++;                       // 多了一个儿子
          Tarjan(v, u);                  // 继续
          low[u] = min(low[u], low[v]);  // 更新能到的最小节点编号
          if (father != u && low[v] >= dfn[u] &&
              !flag
                  [u])  // 主要代码
                        // 如果不是自己,且不通过父亲返回的最小点符合割点的要求,并且没有被标记过
                        // 要求即为:删了父亲连不上去了,即为最多连到父亲
          {
            flag[u] = true;
            res++;  // 记录答案
          }
        } else if (v != father)
          low[u] =
              min(low[u], dfn[v]);  // 如果这个点不是自己,更新能到的最小节点编号
      }
      if (father == u && child >= 2 &&
          !flag[u]) {  // 主要代码,自己的话需要 2 个儿子才可以
        flag[u] = true;
        res++;  // 记录答案
      }
    }
    int main() {
      cin >> n >> m;                  // 读入数据
      for (int i = 1; i <= m; i++) {  // 注意点是从 1 开始的
        int x, y;
        cin >> x >> y;
        edge[x].push_back(y);
        edge[y].push_back(x);
      }                             // 使用 vector 存图
      for (int i = 1; i <= n; i++)  // 因为 Tarjan 图不一定连通
        if (!vis[i]) {
          inde = 0;      // 时间戳初始为 0
          Tarjan(i, i);  // 从第 i 个点开始,父亲为自己
        }
      cout << res << endl;
      for (int i = 1; i <= n; i++)
        if (flag[i]) cout << i << " ";  // 输出结果
      return 0;
    }

参考文献都在这

最小生成树

kruskal

前置知识:并查集

按照边的权值递增去取,如果形成一个环,那么就不选。

环是用并查集来维护。

#include<bits/stdc++.h>
using namespace std;
const int N = 5005,M = 2E5 + 1;
struct node{
    int u,v,w;
}a[M];
bool cmp(node a,node b){
    return a.w < b.w;
}
int s[N];
int find(int x){
    //并查集
    if(s[x]!=x){
        s[x] = find(s[x]);//路径压缩
    }
    return s[x];
}
int n,m;
void kruskal(){
    sort(a+1,a+m+1,cmp);
    for(int i=1;i<=n;i++) s[i]=i;//并查集初始化,每一个元素都是独立的集合
    int ans=0,cnt=0;
    for(int i = 1;i <= m;i++){
        //逐步遍历每一条边
        if(cnt == n-1) break;//优化,在已经足够的边之下可以退出
        int e1 = find(a[i].u);
        int e2 = find(a[i].v);;
        if(e1==e2)continue;//在一个集合内,有公共祖先,代表有环退出
        else{
            ans += a[i].w;
            s[e1]=e2;//并入一个集合
            cnt++;//已增加一条边
        }
    }
    if(cnt == n-1)cout<<ans;
    else cout<<"orz";
    return ;
}
int main(){
    cin >> n >> m;
    for(int i = 1;i <= m;i++) {
        scanf("%d%d%d",&a[i].u,&a[i].v,&a[i].w);
    }
    kruskal();
    return 0;
}

prim

和dij思想差不多(同一个人发明的

选择任意节点出发,每一次加入没有走过的点的最小权值。

//prim 普利姆算法
#include <bits/stdc++.h>
using namespace std;
const int N = 5005,M = 2e5+5;
struct node{
    int to,w;
};
int n,m;
bool done[N];//是否加入了生成树中
vector <node> g[M];
struct data{
    int id,dis;//点,边
    bool operator < (const data & u)const {return dis > u.dis;}
};
void prim(){
    int s=1;//从任一点开始
    for(int i = 1;i <= n;i++) done[i]=false;//初始化
    priority_queue<data> q;
    q.push({s,0});
    int ans=0,cnt=0;
    while(!q.empty()){
        data f = q.top();
        q.pop();
        if(done[f.id]) continue;//这个点已经在生成树中了,跳过
        done[f.id]=1;
        ans+=f.dis;
        cnt++;
        for(auto it:g[f.id]){
            //遍历下面的点
            if(done[it.to])continue;
            q.push({it.to,it.w});
        }
    }
    if(cnt==n)cout << ans;
    else cout <<"orz";
}
int main(){
    cin >> n >> m;
    for(int i = 1;i <= m;i++){
        int u,v,w;
        cin >> u >> v >> w;
        g[u].push_back({v,w});
        g[v].push_back({u,w});
    }
    prim();
    return 0;
}

两种算法在不同的图中有不同的优势

kruskal: \(O(m\log m)\)

prim: \(O((n+m)\log n)\)(二叉堆优化,像dij一样)

最短路

copy老师的代码

@_xuefeng

具体没什么讲的,就放个代码行了。

语法错误谅解一下。。

#include<bits/stdc++.h>
using namespace std;
void SPFA(int x){
    memset(dis,0x3f,sizeof dis);
    memset(vis,0,sizoef vis);
    queue<int> q;

    vis[x]=1;dis[x]=0;
    q.push(x);
    while(!q.empty()){
        int now=q.front();
        for(int i=head[now];i;i=nxt[i])if(dis[to[i]]>dis[now]+c[i]){
            dis[to[i]]=dis[now]+c[i];
            if(!vis[dis[to[i]]]) q.push(to[i]),vis[dis[to[i]]]=1;
        }
        vis[now]=0;q.pop();
    }
}
/*
    不同点也许是Dij用的是优先队列,而spfa用的是队列?
    Dij保证每次取出都是没更新过中的最小值,而spfa通过不断的松弛达到最优解。
    还是用Dij比较稳,复杂度有保证,但spfa可以处理负环而Dij不能。
*/
void Dijkstra(int x){
    memset(dis,0x3f,sizeof dis);
    memset(vis,0,sizeof vis);
    priority_queue<pair<int,int> >q;

    dis[x]=0;q.push_back(make_pair(0,x));
    while(!q.empty()){
        int y=q.top().second;q.pop();
        if(vis[y])continue;vis[y]=1;
        for(int i=head[y];i;i=nxt[i])if(dis[to[i]]>dis[y]+c[i]){
            dis[to[i]]=dis[y]+c[i];
            q.push(make_pair(-dis[to[i]],to[i]));
        }
    }
}

void Floyd(){ 
    for(int k=1;k<=n;++k)
        for(int i=1;i<=n;++i)
            for(int j=1;j<=n;++j)
                dis[i][j]=min(dis[i][j],dis[i][k]+dis[k][j]);
}

LCA(最近公共祖先)模板

这个算法用到的的知识点是三体表(ST表)和最近爸爸(Lca最近公共祖先)

ST表

模板题面

首先先要算出每一个点的深度(预处理)

然后对于 \(f\) 数组进行初始化

再进行LCA。lca先将两个结点都变为同意一个深度,如果此时在一个点,那么就返回这一个点(显然是最近公共祖先)。如果不是同一个点,那么就同时网上跳,用倍增来优化,效率很高。

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

const int N =5e5+5;
int n,m,s;
vector<int> mp[N<<1];
int dep[N];
int f[N][30];
void add(int x,int y){
    mp[x].push_back(y);
}
void dfs(int x){
    dep[x] = dep[f[x][0]]+1;
    for(auto it:mp[x]){
        if(it!=f[x][0]) f[it][0] = x,dfs(it);
    }
}
int lca(int x,int y){
    if(dep[x] < dep[y]) swap(x,y);
    for(int i = log2(dep[x]);~i;--i){
        if(dep[f[x][i]] >= dep[y]) x = f[x][i];
    }
    if(x==y) return x;
    for(int i = log2(dep[x]);~i;i--){
        if(f[x][i] != f[y][i]) x=f[x][i],y=f[y][i];
    }
    return f[x][0];
}
int main(){
    cin >> n >> m >> s;
    for(int i = 1,x,y;i <= n-1;i++){
        scanf("%d%d",&x,&y);
        add(x,y);
        add(y,x);
    }
    dfs(s);
    for(int i = 1;(1 << i) <= n;i++){
        for(int j = 1;j <= n;++j){
            f[j][i] = f[f[j][i-1]][i-1];
        }
    }
    for(int i = 1,x,y;i <= m;i++)
            scanf("%d%d",&x,&y),printf("%d\n",lca(x,y));

    return 0;
}

二分图

定义:

二分图抽象的说,可以分为两个集合 \(A\)\(B\)。集合 \(A\) 的点能连到 \(B\),集合 \(B\) 的点也能连到 \(A\)

顾名思义,这就是二分图。

二分图是可以不连通的,而且自环不是二分图。

如果有重边需要去除。

判定二分图(染色法)

可以类似洪水填充的思想,如果这个点没有被染过色,那么从这个点开始 dfs 染色。只不过与洪水填充的区别是,洪水填充都是填的一个数。然而这个是 \(1\)\(2\) 间隔着填。如果在填充的时候遇到了矛盾的情况,那么就是不合法的(不是二分图)。

//判定二分图 
#include <bits/stdc++.h>
using namespace std;
const int N=1e5+5;
int n,m;
vector<int> mp[N];
int vis[N];
void dfs(int x,int color){
    for(auto it:mp[x]){
        if(!vis[it]){
            vis[it] = 3-color;//交替1和2
            dfs(it,3-color);
        }else if(vis[it] == color){//如果矛盾,那么就不是二分图。
            cout<<"No\n";
            exit(0);
        }
    }
}
set<int> st[N];
int main(){
    cin >> n>>m;
    for(int i=1;i<=m;i++){
        int u,v;
        cin>>u>>v;
        if(u==v){//自环
            cout<<"No\n";
            exit(0);
        }
        if(st[u].find(v) == st[u].end()){//set用来避免重边。
            mp[u].push_back(v);
            mp[v].push_back(u);        
            st[u].insert(v);
            st[v].insert(u);
        }
    }
    for(int i = 1;i <= n;i++){
        if(!vis[i]){//染色填充
            dfs(i,1);
        }
    }
    cout<<"Yes";
    return 0;
}

接下来,进入正题,讲解如何使用匈牙利算法解决二分图最大匹配的问题。

二分图的最大匹配(匈牙利算法)

首先二分图左边是 \(n1\) 个点,右边是 \(n2\) 个点。其中左边的点只能连到右边的点,右边的点只能连到左边的点,边数为 \(e\)。下文称左边的集合为 \(A\),右边的集合石 \(B\)

二分图的最大匹配的意思就是:\(A\) 集合的点和\(B\)集合的点可以配对的最大数量。

如图:这里面就有两个最大的匹配数量。(出自洛谷板子样例)

其中红线和蓝线就分别是个匹配。

匈牙利算法呢,就是要找到最大的匹配个数。

思路:

  • 遍历 \(A\) 集合,随便选一个 \(B\) 作为配对。

  • 如果这个 \(B\) 中的 \(j\) 已经被配对过了,你可以找 \(j\) 之前是和谁配对的,他以前配对的是 \(A\) 中的 \(k\)。那么就让 \(k\) 重新配对,腾个位置给 \(i\) 来配对 \(j\)。因此可以递归下去。

注意:对一个 \(i\) 想要在 \(B\) 中找一个 \(j\) 来配对,肯定不能重复访问这个 \(j\),所以 \(ask_j\) 代表着现在已经像 \(j\) 查询过能否配对了,相当于一个标记。

\(vis_i\) 代表的是 \(B\) 中的 \(i\) 目前和 \(A\) 中的 \(vis_i\) 配对着。

所以这里就使用 dfs 来实现。注意要清空 \(ask\) 数组。因为对于 \(A\) 中的每一个 \(i\),他们的 \(ask\) 都是互相独立的。

时间复杂度是 \(O(n1 \times e + n2)\)

const int N = 505;
int n1,n2,m;
vector<int> mp[N];
int vis[N],ask[N];
bool dfs(int x){
    for(auto it:mp[x]){
        if(ask[it])continue;
        ask[it]=1;
        if(!vis[it] || dfs(vis[it])){
            vis[it]=x;
            return true;
        }
    }return false;
}
int match(){
    int ans=0;memset(vis,0,sizeof vis);
    for(int i = 1;i <= n1;i++){
        memset(ask,0,sizeof ask);
        if(dfs(i)) ans++;
    }return ans;
}
void solve(){
    cin>>n1>>n2>>m;
    for(int i = 1;i <= m;i++) {int u,v;cin>>u>>v;mp[u].push_back(v);}
    cout<<match();
}
posted @ 2023-11-24 05:36  gsczl71  阅读(26)  评论(0编辑  收藏  举报