【解雨臣的算法课】插播课:Kruskal 生成树与同构树——把分散的机关连成一体

【解雨臣的算法课】插播课:Kruskal 生成树与同构树——把分散的机关连成一体

“墓里的机关,单个看没什么,连起来就是杀阵。你得知道怎么把它们连起来最省力,还得认出来是不是同一个机关阵。”
——解雨臣


一、前言

胖子:“不是说好讲最短路吗?怎么插进来个生成树?”
我:“因为你昨天在墓里乱踩机关,把地板踩塌了。要是你懂最小生成树,就知道怎么走最安全。”
黑瞎子:“那同构树呢?”
我:“黑爷,你上次把两个墓室的机关阵认反了,差点放出血尸。”
两人:“……学,我们学!”

这节课临时插播,讲两个重要的树相关算法:

  • Kruskal 最小生成树:怎么用最少的代价把所有机关连起来
  • 树的同构:怎么判断两个机关阵是不是同一个

二、什么是生成树?

2.1 基本概念

生成树:在一个无向连通图中,取 n-1 条边,使得所有 n 个点连通,且没有环。

最小生成树(MST):边权之和最小的生成树。

原图(5个点,7条边)         最小生成树(4条边,总权值最小)
   1       3                    1       3
  / \     / \                  / \     / 
 2---4---5   6                2---4---5
  \ /                              \
   7                                7

2.2 为什么需要最小生成树?

场景 应用
铺设电缆 用最少的线连接所有村庄
修路 用最短的路连通所有城市
机关阵 用最少的能量连接所有机关

三、Kruskal 算法

3.1 算法思想

解雨臣说:“Kruskal 就像相亲——每次都选最合适的,直到所有人都配对。”

贪心策略

  1. 把所有边按权值从小到大排序
  2. 从小到大依次考虑每条边
  3. 如果这条边连接的两个点还没有连通,就选它
  4. 如果已经连通,就跳过(否则会形成环)

关键:如何快速判断两个点是否连通?
并查集

3.2 并查集复习

#include<bits/stdc++.h>

using namespace std;

const int N = 100010;

int n;
int fa[N];

void init () 
{
    for (int i = 1; i <= n; i++) 
    {
        fa[i] = i;
    }
}

int find (int x) 
{
    if (fa[x] != x) 
    {
        fa[x] = find (fa[x]);
    }
    
    return fa[x];
}

void unite (int x, int y) 
{
    int rx = find (x);
    int ry = find (y);
    
    if (rx != ry) 
    {
        fa[rx] = ry;
    }
}

3.3 Kruskal 代码实现

#include<bits/stdc++.h>

using namespace std;

const int N = 100010;
const int M = 200010;

int n;
int m;
int fa[N];

struct Edge 
{
    int u;
    int v;
    int w;
} e[M];

bool cmp (Edge a, Edge b) 
{
    return a.w < b.w;
}

int find (int x) 
{
    if (fa[x] != x) 
    {
        fa[x] = find (fa[x]);
    }
    
    return fa[x];
}

int main () 
{
    cin >> n >> m;
    
    for (int i = 1; i <= m; i++) 
    {
        cin >> e[i].u >> e[i].v >> e[i].w;
    }
    
    sort (e + 1, e + m + 1, cmp);
    
    for (int i = 1; i <= n; i++) 
    {
        fa[i] = i;
    }
    
    int ans = 0;
    int cnt = 0;
    
    for (int i = 1; i <= m; i++) 
    {
        int u = e[i].u;
        int v = e[i].v;
        int w = e[i].w;
        
        if (find (u) != find (v)) 
        {
            fa[find (u)] = find (v);
            ans += w;
            cnt++;
        }
    }
    
    if (cnt == n - 1) 
    {
        cout << ans << endl;
    }
    else 
    {
        cout << "orz" << endl;
    }
    
    return 0;
}

3.4 复杂度分析

  • 排序:O(m \log m)
  • 并查集操作:O(m \alpha(n)),近似 O(m)
  • 总复杂度:O(m \log m)

四、经典例题:解雨臣铺电缆

题目背景

解家要在 n 个墓室之间铺设电缆,每个墓室是一个点。

已知 m 条可能的线路,每条线路有造价 w_i

问:用最少的钱把所有墓室连通,最少需要多少钱?

故事版

胖子:“这不就是修路吗?我选最便宜的线路!”
我:“你选最便宜的,万一形成环了呢?”
胖子:“啥是环?”
我:“就是……你从A到B再到C再回到A,兜了一圈,浪费钱。”
黑瞎子:“Kruskal 就是专门治你这个毛病的。”

代码

#include<bits/stdc++.h>

using namespace std;

const int N = 1005;
const int M = 10005;

int n;
int m;
int fa[N];

struct Edge 
{
    int u;
    int v;
    int w;
} e[M];

bool cmp (Edge a, Edge b) 
{
    return a.w < b.w;
}

int find (int x) 
{
    if (fa[x] != x) 
    {
        fa[x] = find (fa[x]);
    }
    
    return fa[x];
}

int main () 
{
    cin >> n >> m;
    
    for (int i = 1; i <= m; i++) 
    {
        cin >> e[i].u >> e[i].v >> e[i].w;
    }
    
    sort (e + 1, e + m + 1, cmp);
    
    for (int i = 1; i <= n; i++) 
    {
        fa[i] = i;
    }
    
    int ans = 0;
    int cnt = 0;
    
    for (int i = 1; i <= m; i++) 
    {
        int u = e[i].u;
        int v = e[i].v;
        int w = e[i].w;
        
        if (find (u) != find (v)) 
        {
            fa[find (u)] = find (v);
            ans += w;
            cnt++;
        }
    }
    
    if (cnt == n - 1) 
    {
        cout << ans << endl;
    }
    else 
    {
        cout << "不可能连通" << endl;
    }
    
    return 0;
}

五、什么是树的同构?

5.1 问题引入

我:“胖子,你记不记得上次我们进的墓室,机关阵长什么样?”
胖子:“记得!中间一个大的,周围一圈小的。”
我:“那这次这个呢?”
胖子:“也是中间一个大的,周围一圈小的啊!”
我:“那它们是不是同一个机关阵?”
胖子:“这……长得像就是吧?”

问题:两个树,形状看起来一样吗?

定义:如果两棵树可以通过重新标记节点变成完全一样,就称它们是同构的。

5.2 例子

树A:        树B:
    1            5
   / \          / \
  2   3        6   7
     / \          / \
    4   5        8   9

这两棵树是同构的(结构一样,只是编号不同)
树C:        树D:
    1            1
   / \          / \
  2   3        2   3
     / \          / \
    4   5        4   5
       /              \
      6                6

这两棵树不同构(一个在左边多一层,一个在右边)

六、树的同构判定——树哈希

6.1 算法思想

解雨臣说:“给每个节点算一个‘指纹’,如果两棵树根节点的指纹一样,就是同构的。”

核心:给树分配一个哈希值,同构的树哈希值相同。

6.2 哈希公式

对于节点 u,设它的子节点为 v_1, v_2, \ldots, v_k,子节点的哈希值为 h(v_i)

常用的哈希公式:
$$h(u) = 1 + \sum_{i=1}^k \text{hash}(h(v_i))$$

其中 \text{hash}(x) 是一个顺序无关的函数,比如:
$$h(u) = 1 + \sum_{i=1}^k p^{h(v_i)} \quad \text{或} \quad h(u) = \prod_{i=1}^k (p + h(v_i))$$

为了与顺序无关,我们通常:

  • 对子节点的哈希值排序
  • 然后按顺序组合

6.3 树哈希代码

#include<bits/stdc++.h>

using namespace std;

const int N = 100010;
const int MOD = 1e9 + 7;
const int P = 131;

int n;
vector <int> g[N];

int dfs (int u, int fa) 
{
    vector <int> ch;
    
    for (int v : g[u]) 
    {
        if (v == fa) 
        {
            continue;
        }
        
        ch.push_back (dfs (v, u));
    }
    
    sort (ch.begin (), ch.end ());
    
    int h = 1;
    
    for (int x : ch) 
    {
        h = (1LL * h * P + x) % MOD;
    }
    
    return h;
}

int main () 
{
    cin >> n;
    
    for (int i = 1; i < n; i++) 
    {
        int u;
        int v;
        
        cin >> u >> v;
        g[u].push_back (v);
        g[v].push_back (u);
    }
    
    int root = 1;
    int h = dfs (root, 0);
    
    cout << h << endl;
    
    return 0;
}

6.4 无根树的同构

对于无根树,我们需要考虑所有可能的根

方法一:找到树的重心(最多两个),以重心为根计算哈希。

方法二:用AHU算法——对树进行规范化编码。

6.5 找重心

int n;
int sz[N];
int mx[N];
int root;
vector <int> g[N];

void dfs_size (int u, int fa) 
{
    sz[u] = 1;
    mx[u] = 0;
    
    for (int v : g[u]) 
    {
        if (v == fa) 
        {
            continue;
        }
        
        dfs_size (v, u);
        sz[u] += sz[v];
        mx[u] = max (mx[u], sz[v]);
    }
    
    mx[u] = max (mx[u], n - sz[u]);
    
    if (mx[u] < mx[root]) 
    {
        root = u;
    }
}

七、经典例题:解雨臣的机关阵

题目背景

解雨臣在两个墓室里各发现了一个机关阵,每个机关阵是一个树形结构(节点是机关,边是能量线)。

他想知道:这两个机关阵是不是同一个?(即是否同构)

代码实现

#include<bits/stdc++.h>

using namespace std;

const int N = 105;
const int MOD = 1e9 + 7;
const int P = 131;

int n1;
int n2;
vector <int> g1[N];
vector <int> g2[N];
int sz[N];
int mx[N];
int root;

void dfs_size (vector <int> g[], int u, int fa, int n) 
{
    sz[u] = 1;
    mx[u] = 0;
    
    for (int v : g[u]) 
    {
        if (v == fa) 
        {
            continue;
        }
        
        dfs_size (g, v, u, n);
        sz[u] += sz[v];
        mx[u] = max (mx[u], sz[v]);
    }
    
    mx[u] = max (mx[u], n - sz[u]);
    
    if (mx[u] < mx[root]) 
    {
        root = u;
    }
}

int find_center (vector <int> g[], int n) 
{
    root = 1;
    mx[0] = n;
    
    dfs_size (g, 1, 0, n);
    
    return root;
}

int dfs_hash (vector <int> g[], int u, int fa) 
{
    vector <int> ch;
    
    for (int v : g[u]) 
    {
        if (v == fa) 
        {
            continue;
        }
        
        ch.push_back (dfs_hash (g, v, u));
    }
    
    sort (ch.begin (), ch.end ());
    
    int h = 1;
    
    for (int x : ch) 
    {
        h = (1LL * h * P + x) % MOD;
    }
    
    return h;
}

bool isomorphic () 
{
    if (n1 != n2) 
    {
        return false;
    }
    
    int r1 = find_center (g1, n1);
    int r2 = find_center (g2, n2);
    
    vector <int> h1;
    vector <int> h2;
    
    h1.push_back (dfs_hash (g1, r1, 0));
    
    for (int v : g1[r1]) 
    {
        if (mx[v] == mx[r1]) 
        {
            h1.push_back (dfs_hash (g1, v, 0));
        }
    }
    
    h2.push_back (dfs_hash (g2, r2, 0));
    
    for (int v : g2[r2]) 
    {
        if (mx[v] == mx[r2]) 
        {
            h2.push_back (dfs_hash (g2, v, 0));
        }
    }
    
    for (int a : h1) 
    {
        for (int b : h2) 
        {
            if (a == b) 
            {
                return true;
            }
        }
    }
    
    return false;
}

int main () 
{
    cin >> n1;
    
    for (int i = 1; i < n1; i++) 
    {
        int u;
        int v;
        
        cin >> u >> v;
        g1[u].push_back (v);
        g1[v].push_back (u);
    }
    
    cin >> n2;
    
    for (int i = 1; i < n2; i++) 
    {
        int u;
        int v;
        
        cin >> u >> v;
        g2[u].push_back (v);
        g2[v].push_back (u);
    }
    
    if (isomorphic ()) 
    {
        cout << "同一个机关阵" << endl;
    }
    else 
    {
        cout << "不同的机关阵" << endl;
    }
    
    return 0;
}

八、解雨臣踩过的坑

坑1:Kruskal 忘记判断连通性

if (cnt == n - 1) 
{
    // 连通
} 
else 
{
    // 不连通,无法生成树
}

解家箴言:选完边后检查一下,cnt 是否等于 n-1。

坑2:并查集路径压缩写错

// 错误
int find (int x) 
{
    if (fa[x] != x) 
    {
        return find (fa[x]);
    }
    
    return fa[x];
}

// 正确
int find (int x) 
{
    if (fa[x] != x) 
    {
        fa[x] = find (fa[x]);
    }
    
    return fa[x];
}

解家箴言:路径压缩要赋值回去!

坑3:树哈希没排序

// 错误:直接按子节点顺序组合
int h = 1;

for (int v : g[u]) 
{
    h = (h * P + dfs (v, u)) % MOD;
}

// 正确:排序后再组合
sort (ch.begin (), ch.end ());

for (int x : ch) 
{
    h = (h * P + x) % MOD;
}

解家箴言:顺序不影响同构,所以要排序。

坑4:无根树只考虑一个根

// 错误:只以1为根
int h1 = dfs_hash (g1, 1, 0);
int h2 = dfs_hash (g2, 1, 0);

// 正确:考虑重心
int r1 = find_center (g1, n1);
int r2 = find_center (g2, n2);

解家箴言:无根树要以重心为根。


九、课后习题(解家的考验)

基础题

  1. 最小生成树模板:给定 n 个点 m 条边的无向图,求 MST 的权值和。

  2. 解雨臣的局域网:有 n 台电脑,m 条网线,每条网线有带宽。问用最少的网线把所有电脑连起来,最大带宽是多少?(提示:最大生成树)

进阶题

  1. 解雨臣的电缆升级:现有 n 个墓室已经用电缆连通了。现在要升级,每条电缆的升级费用不同。问升级后仍然连通,且费用最小的方案。

  2. 树的同构判定:给两棵树,判断它们是否同构。

挑战题

  1. 解雨臣的三棵机关树:给三棵树,判断它们是否两两同构。

  2. 解雨臣的森林:有 n 个墓室,m 条能量线(可能不连通)。现在要添加最少的能量线,使得所有墓室连通,并且添加的能量线总长度最小。问最小总长度是多少?


十、下集预告

“生成树是把分散的东西连起来,同构树是认出长得一样的东西。下节课我们讲最短路——怎么从起点最快到终点。”
——解雨臣

胖子:“那我下节课是不是就能找到出口了?”
我:“前提是你先别乱跑。”
黑瞎子:“他乱跑也没事,反正也跑不远。”
胖子:“……”

下节课:最短路算法——Dijkstra、Bellman-Ford、Floyd

下课。


(本系列课程由解雨臣独家赞助,信友队论坛首发。转载需注明出处。)

1 个赞

今天结束,本花下播
Suggestion