【算法】数据结构_带权并查集

目录

一、认识带权并查集

[1. 什么是带权并查集?](#1. 什么是带权并查集?)

[2. 实际问题示例](#2. 实际问题示例)

[3. 带权并查集的实现 - 距离版本](#3. 带权并查集的实现 - 距离版本)

(1)初始化

[(2)查询根节点: find](#(2)查询根节点: find)

(3)合并操作:union

(4)查询距离:query

二、带权并查集的例题

[1. P2024 食物链 - 洛谷](#1. P2024 食物链 - 洛谷)

[2. P1196 银河英雄传说 - 洛谷](#2. P1196 银河英雄传说 - 洛谷)


一、认识带权并查集

1. 什么是带权并查集?

对于普通的并查集,我们只能解决关于两个元素在不在同一个集合中的问题。而带权并查集则 在普通并查集的基础上,为每个结点增加了一个权值。这个权值用来表示当前结点与其父结点之间的关联信息,比如关系(朋友关系,敌人关系),距离等。

注意:在实现带权并查集的时候,因为要进行路径压缩操作,所以这里的权值的意义表示的就是当前结点与其根结点之间的关联信息。

2. 实际问题示例

那么,带权并查集是用于解决什么样的问题的呢?

如果权值表示的当前结点距离父结点的距离。如果这些数在数轴中,则对于权值信息的值,若是正数,则表示当前节点在父节点的右边,负数则表示当前节点在其父节点的左边。那么对于一组数据 A~E,它们的权值分别是 0, 2, -3, 5, -2 ,则有:

如果权值表示的是关系,如朋友与敌人的关系(朋友用1表示,敌人用0表示),则对于一组数据可以得到:

3. 带权并查集的实现 - 距离版本

下面我们实现一个可以查询任意两点之间的距离的并查集。这需要实现一个距离版本的并查集,其权值表示的是当前节点距离根节点的距离(都是正数)。

(1)初始化

我们需要注意的变量如下:

  • 需要一个数组 fa ,维护在并查集中找父亲,即 fa i 表示 i 节点的父节点。初始时各个结点自成一个集合,所以初始化: fa i = i 。
  • 需要一个数组 d ,表示当前节点的权值。即 d i 表示 i 节点的权值。初始时,自己的父亲就是自己,所以距离为 0 。(对于不同的权值,d 数组的初始化值可能不同)

实现代码为:

cpp 复制代码
const int N = 1e5 + 10, INF = 0x3f3f3f3f;
int fa[N];
int d[N]; // 存储权值-距离

int n; // 当前节点的数目

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

注意这里的INF是用于后续查找距离的操作中使用的。

(2)查询根节点: find

和之前普通并查集的find操作不一样,在带权并查集的find操作中,因为执行了路径压缩操作,我们就还需要更新权值。

原因:

因为不进行路径压缩时,各个节点中的权值表示的就是它距离它父节点的距离,在执行路径压缩后,当前节点就接在根节点后面了,但是此时的权值还是它距离它原本父节点的距离,所以find操作还需要将当前节点的权值更新为它距离根节点的距离。

怎么处理?请看如图:在路径压缩前,dC 表示C(C的父亲)之间的距离,dB 表示B和A(B的父亲)之间的距离。在路径压缩后,因为B就是根的子节点,所以dB不需要改变,而此时dC 应该表示A和C之间的距离,而A和C之间的距离就等于C的父亲与根的距离加上C和它父亲的距离,所以此时的 dC = dC(更新前) + dB ,也就是 d C = d C (更新前) + d fa\[ C ]

我们的代码实现如下:

cpp 复制代码
// 查询节点+路径压缩
int find(int x)
{
    if(fa[x] == x) return x;

    int t = find(fa[x]); // 先找到当前集合的根,一定要先执行
    d[x] += d[fa[x]]; // 再更新路径

    return fa[x] = t; // 最后路径压缩
}

注意:其中 int t = find(fax); 一定要先执行。其原因如下:

  • 因为如果先执行dx += dfa\[x],那么此时x父亲的权值表示的是 fax 距离 fax 父亲的距离,则此时更新完后dx 表示的就是x距离x父亲的父亲的距离,而不是x距离根节点的距离了,所以权值就错了。
  • 而如果先执行 int t = find(fax); 后,x的父亲所在的节点就挂在了根节点的后面了,此时x的父亲的权值表示的就是它距离根节点的距离,然后我们再执行dx += dfa\[x],此时的 dfa\[x] 表示的就是fax 距离根节点的距离。

图解:

(3)合并操作:union

当我们知道 x 和 y 之间的距离 w 之后,我们就可以将其合并了。

首先x和y必须是在两个不同集合中,才能进行合并。此时,我们找到x和y的根节点fx和fy,然后将fx的父亲设为fy即可。合并过程如下所示:

所以合并时,因为fx的有父亲了,所以需要更新dfx ,而x和y的父亲都没变,所以不用更新。

所以合并过程的实现代码:

cpp 复制代码
// 以x和y之间存在权值w的联系来合并
void un(int x, int y, int w)
{
    int fx = find(x), fy = find(y);

    if(fx != fy) // 不在一个集合就需要合并
    {
        fa[fx] = fy;
        d[fx] = d[y] + w - d[x]; // 根据实际问题推导
    }
}

(4)查询距离:query

query操作是用来找两个元素之间根据权值推导出来的问题的答案的,一般是根据距离问题距离分析。

在权值是距离的这个问题中,query一般就是来找两个元素x和y之间的距离,答案就是它们两个权值之差,实现代码为:

cpp 复制代码
// 查询x和y之间距离
int query(int x, int y)
{
    int fx = find(x), fy = find(y);

    if(fx != fy) return INF; // 不在同一个集合,距离未知
    else return abs(d[y] - d[x]); 
}

二、带权并查集的例题

1. P2024 食物链 - 洛谷

题目链接:P2024 NOI2001 食物链 - 洛谷

问题内容:

解决方法:

从前往后遍历每一句话,判断是否与之前的话矛盾:

  1. 若矛盾,则舍去这句话,并统计个数;
  2. 若不矛盾,则通过带权并查集维护它们之间的关系。

怎么使用带权并查集来维护这个关系呢?

首先,根据题意,我们可以知道A,B,C这三种动物是一个环形的互相吃的关系,如果为A种类的动物有A1,A2,A3,...(这里的A1,A2...表示的只是A种类中某一个动物),B种类的动物有B1,B2,B3,...,C种类的动物有C1,C2,C3,...,那么可能会出现如下情况:A1吃B1,B1吃C1,C1吃A2,A2吃B2,B2吃C2,C2吃A3,...,这就是一个周期新问题了,每三个就是一个重复的事情,都是A吃B,B吃C,C吃A 。如果设为A1编号为0,则B1编号为1,C1编号为2,A2编号为3,...,则此时我们就会发现一个规律:设 di 表示动物 i 的编号,任意两个动物 x 和 y ,dx 表示动物x的编号,dy表示动物y的编号,可以得到如图所示的规律:

这里将A1编号设为0,则其他编号就表示它距离A1的距离了。所以这里带权并查集的权值 就可以设为当前节点距离其父节点的距离,以数组d来表示 。以A1作为根节点,则得到如图:这时,如果我们要判断C1和B2的关系,则可以通过dB2-dC1 取模的结果来判断它们之间的关系,这里 ((dB2-dC1 ) % 3 + 3 ) % 3 = 2 ,表示的就是B2被C1吃,即B被C吃的结论。

实现这个带权并查集,必须要实现find操作,这里的find操作需要实现路径压缩,所以在find操作中更新权值时时就需要执行 int t = find(fax); dx += dfa\[x] (距离理解方法和上面的距离版本并查集的实现一样)。

所以,对于当前这个问题,当得到一句话时,我们就可以通过带权并查集来判断它是不是真话,若是,则就可以将两个节点合并。合并时,需要进行分类:

如果x和y是同类,则x离y的距离就可以是0,或者3,或者6,......,若取0则有:

如果x吃y,则x距离y的距离就可以是 2,5,8,...,这里我们取2即可,得:

只是它们传递的x和y的距离不同。

如何判断真假呢?

当遇到说x和y是同类时,我们首先需要判断它们是否在同一个集合中,因为如果它们不在同一个集合中,那它们的关系就是未知的,就是真话;然后如果在同一个集合中,则判断它们的距离支持取模,即 t = (dy-dx) % 3 + 3 ) % 3 ,如果 t 不等于0 ,则表示它们不是同类,是假话。

当遇到说x 吃y时,则也要先判断它们是否在同一个集合中,若不在,则关系位置,为真话;若在,则判断 t = (dy-dx) % 3 + 3 ) % 3,如果 t 不等于 1 ,则表示不是 x 吃 y ,为假话。

总结:

  • 若是x和y是同类,则判断 fx == fy 和 t != 0,只要不满足其中一个,就是假话,需要统计;
  • 若是x吃y,则判断 fx == fy 和 t != 1,只要不满足其中一个,就是假话,需要统计;

综上,我们的代码实现如下:

cpp 复制代码
#include <iostream>
using namespace std;

const int N = 5e4 + 10;
int fa[N], d[N];
int n, k;

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

    int t = find(fa[x]);
    d[x] += d[fa[x]];
    return fa[x] = t;
}

void un(int x, int y, int w)
{
    int fx = find(x), fy = find(y);

    if(fx != fy)
    {
        fa[fx] = fy;
        d[fx] = d[y] + w - d[x];
    }
}

int main()
{
    cin >> n >> k;

    // 初始化
    for(int i = 1; i <= n; i++) fa[i] = i;
    
    int ret = 0;
    while(k--)
    {
        int op, x, y;
        cin >> op >> x >> y;
        int fx = find(x), fy = find(y);

        if(x > n || y > n) ret++; // 假话  
        else if(op == 1) // 是同类?
        {
            if(fx == fy && ((d[y] - d[x]) % 3 + 3) % 3 != 0) ret++;
            else un(x, y, 0);
        }
        else // x->y 
        {
            if(fx == fy && ((d[y] - d[x]) % 3 + 3) % 3 != 1) ret++;
            else un(x, y, 2);
        }
    }
    cout << ret << endl;
    return 0;
}

2. P1196 银河英雄传说 - 洛谷

题目链接:P1196 NOI2002 银河英雄传说 - 洛谷

问题内容:

题目分析:

这道题是让我们根据指令对这30000个战舰进行操作,指令如下:

  • M i j :表示将第 i 号战舰所在的整个战舰队列,作为一个整体(头在前尾在后)接至第 j 号战舰所在的战舰队列的尾部。
  • C i j :表示询问第 i 号战舰与第 j 号战舰当前是否在同一列中,如果在同一列中,那么它们之间布置有多少战舰。

**解决方法:**利用带权并查集维护指令

  1. 权值d i 表示为:当前节点和它父节点之间的距离(路径压缩后就是当前节点和根节点之间的距离),设的是两个相邻战舰之间的距离是1.
  2. 初始时,这30000战舰自己就是一个集合,此时它们的父亲fa i = i ,权值 d i = 0 (因为自己距离自己的距离就是0)。
  3. 遇到 M i j 指令,将 i 所在集合与 j 所在集合合并。因为要保证 i 所在集合接在 j 的尾部,所以是以 j 的根作为合并后的最终根节点(即 fafi = fj)。如何更新权值?因为是 i 所在集合接在 j 的尾部,更具实际意义来看,只需要修改 fi 的权值为 j 所在集合元素总数即可(所以还需要一个数cntj 表示 j 号星域战场当前的战舰总数),更新操作为 d fi = cntj,然后cntj += cnti
  4. 遇到 C i j 指令,进行查询操作,先判断是否在同一集合(进行了find操作,所以i和j的权值都表示的是它们距离其根节点的距离),若在,则计算它们之间有多少个战舰,也就是计算 i 距离其根的距离和 j 和其根的距离之差,再减一就是最终结果。

总结:

  • 遇到 M i j 指令,执行 un(i, j) ;
  • 遇到 C i j 指令,执行 query(i, j) ;

实现代码:

cpp 复制代码
#include <iostream>
using namespace std;

const int N = 3e4 + 10;
int fa[N], d[N], cnt[N];

void init()
{
    for(int i = 1; i <= N; i++)
    {
        fa[i] = i;
        d[i] = 0;
        cnt[i] = 1;
    }
}

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

    int t = find(fa[x]);
    d[x] += d[fa[x]];

    return fa[x] = t;
}

void un(int x, int y)
{
    int fx = find(x), fy = find(y);

    if(fx != fy) // 不在同一个集合
    {
        fa[fx] = fy;

        d[fx] = cnt[fy];
        cnt[fy] += cnt[fx];
    }
}

void query(int x, int y)
{
    int fx = find(x), fy = find(y);
    if(fx != fy) cout << -1 << endl;
    else cout << abs(d[x] - d[y]) - 1 << endl;
}

int main()
{
    init();

    int t; cin >> t;
    while (t--)
    {
        char op; int x, y; cin >> op >> x >> y;
        if(op == 'M') // 合并
        {
            un(x, y); // x接到y后面
        }
        else 
        {
            query(x, y);
        }
    }
    
    return 0;
}

总结

带权并查集就是在普通并查集的基础上为节点增加权值,用于维护节点间的附加关系(如距离、敌友等)。其核心操作包括:

  1. 初始化:fai = i,di = 0(权值初始化为0或特定值)。
  2. 路径压缩:find操作中需先递归更新父节点权值,再累加当前节点权值(dx += dfa\[x])。(需要根据实际问题推导)
  3. 合并:根据实际问题推导权值更新公式(如距离问题中dfx = dy + w - dx)。
  4. 查询:通过权值差判断关系(如距离差模3判断物种关系)。

例题应用

  • 食物链(NOI2001):权值模3表示物种关系(0同类,1捕食,2被捕食),合并时分类处理。
  • 银河英雄传说(NOI2002):权值记录战舰间距,合并时更新尾部距离和集合大小。

感谢各位观看!希望大家多多支持!