并查集之扩展域和边带权——以“食物链”为例

我本来不想写关于并查集的题解的。因为在我印象中并查集不是一个很难的算法,不管是算法理解的难度还是实现的难度都如此。但直到我今天又看到了《食物链》这道题,我才回忆起了并查集那波澜不惊表面下的汹涌澎湃(

并查集算法简单回顾

在这篇文章中,我不打算再重申并查集的具体实现原理,有需要的同学可以先移步其它专门介绍并查集的博文中。本文仅对并查集的算法、优化、扩展作简单回顾。

并查集,英文名 Disjoint-Set,是一个可以动态维护若干个不重叠的集合,并且支持查询和合并的数据结构。——引自《算法竞赛进阶指南》

并查集基本思想是“代表元”,简单来说就是在属同一个集合的元素中选一个作为代表,并在这个代表元素上标记这个集合的性质,其它元素只需标记跟代表元的关系即可。这个思想类似于前缀和算法中只记录每个元素到起始位置的数值总和,只不过前缀和算法里代表元是约定俗成有且仅有一个——第一个元素。而对于并查集,因为要维护多个集合,所以需要多个代表元。代表元的选择是任意的。

有了代表元的思想,我们可以将所有集合抽象为森林,每个集合是一棵树,代表元即为树根。因此,我们常常使用标记每个点的父节点的方式来构建并查集。

并查集有两个常见的优化方式:路径压缩按秩合并。路径压缩即尽量将每个非代表元元素只指向代表元而不指向其它节点,从而优化 find 函数找根的时间复杂度。按秩合并,又名启发式合并,可以将节点较少的集合合并到节点较多的集合中,这样也同样可以优化 find 函数的时间复杂度。通过以上两个函数,可以将 find 函数的均摊复杂度优化到 O ( α ( n ) ) O(\alpha(n)) O(α(n)),其中 α \alpha α 表示反阿克曼函数,在这篇论文中被证明。关于这个无需了解太多,只需知道这个函数增长之缓慢,几乎接近常值函数了,因此几乎可以把 find 函数看作是 O ( 1 ) O(1) O(1) 的。

并查集还有两个常见扩展,扩展域边带权。这是本文介绍的重点,因为本题主要用到的就是这两种思想。我们放在后文中慢慢讲。

扩展域

题面在各大主流 OJ 平台都有收录,不再赘述了。

在这里我们模拟一下样例:

100 7
1 101 1
2 1 2
2 2 3
2 3 3
1 1 3
2 3 1
1 5 5

显而易见,下面几句话是假话:

  • 1 1 1 句话, 101 > 100 101 > 100 101>100,必为假话;
  • 4 4 4 句话,表示自残,必为假话;

还有一句假话,需要仔细理解题意。很多同学做这道题容易读到后面忽视前面这句话:

动物王国中有三类动物 A , B , C A,B,C A,B,C,这三类动物的食物链构成了有趣的环形。 A A A B B B B B B C C C C C C A A A。……每个动物都是 A , B , C A,B,C A,B,C 中的一种。

这句话十分关键。它告诉我们,两种动物之间只存在“捕食”和“同类”的关系,且“捕食”是单向的,不会存在 A A A B B B 同时 B B B 也能吃 A A A 的情况。

其次,由于只存在三种动物,并且其捕食关系构成环,因此一定有 A A A 的猎物的猎物是 A A A 的天敌,而不可能是 A A A 的同类或者猎物。

因此,我们可以发现,由于 2 2 2 1 1 1 的猎物(第 2 2 2 句话), 3 3 3 2 2 2 的猎物(第 3 3 3 句话),因此 3 3 3 1 1 1 的猎物的猎物,也就是 1 1 1 的天敌。但是第 5 5 5 句话说 1 1 1 3 3 3 是同类,也必为假话。

综上,对于该样例,答案为 3 3 3

对于“同类”关系很好处理,因为“同类”关系总是满足传递性,也即 A A A 的同类的同类必为 A A A 的同类。这正是并查集最擅长维护的。

但对于“捕食”关系,并不好维护。因为“捕食”不具备传递性。相反, A A A 的猎物的猎物竟然是 A A A 的天敌,这个关系并查集没法维护,它只能维护无向关系。

并查集维护有向关系,一个常见的技巧就是——“扩展域”。对于这个题,如下所示,可以如下维护:

维护一个并查集,由三个域组成:同类域,猎物域,天敌域。

A A A B B B 是同类,则一定有:

  • A A A 的同类与 B B B 的同类同类。
  • A A A 的猎物与 B B B 的猎物同类。
  • A A A 的天敌与 B B B 的天敌同类。

A A A 捕猎 B B B,则一定有:

  • A A A 的同类是 B B B 的天敌。
  • A A A 的猎物是 B B B 的同类。
  • A A A 的天敌是 B B B 的猎物。(在“只有三种动物”的情况下成立)

以上即可。

for (int i = 1; i <= m; i++) {
    int op, a, b;
    scanf("%d%d%d", &op, &a, &b);
    if ((a > n) or (b > n)) {
        ans++;
        continue;
    }
    if (op == 1) {
        if (judge(b, a+N) or judge(a, b+N)) {
            ans++;
            continue;
        }
        merge(a + N*0, b + N*0);
        merge(a + N*1, b + N*1);
        merge(a + N*2, b + N*2);
    } else {
        if (judge(a, b) or judge(a, b+N)) {
            ans++;
            continue;
        }
        merge(a + N*0, b + N*2);
        merge(a + N*1, b + N*0);
        merge(a + N*2, b + N*1);
    }
}

边带权

边带权给并查集的父子节点边加上了权值,这个权值可以用来描述父子关系。因为权值相较于扩展域开空间来表示关系的方式更加灵活,所以边带权比扩展域而言可以描述更加复杂的关系,应用范围因而更广。

但相较于扩展域而言,边带权无论从代码难度上还是思维难度上都要更高。因此,我个人觉得,能用扩展域尽量用扩展域。

以这道题为例。每两个动物之间只会存在三种有向关系:同类、捕食、被捕食。我们可以用权值来表达这三种关系。

假设 A A A 为并查集某棵树上的代表元。如果 A A A B B B 存在一条权值为 0 0 0 的边,那么表示 A A A B B B 是同类;如果权值为 1 1 1,那么表示 B B B A A A 的猎物;如果权值为 2 2 2,那么表示 B B B A A A 的天敌。

由此,我们可以在读取每一句话后,通过权值检验某两个动物的关系。

那么如果要检验的两个动物没有一个是代表元怎么办?没有关系。因为三个动物间存在捕食,所以可以把权值想象成绕着环沿着捕食方向走多少格。因此我们只需要求它们关于代表元的关系,即可获得它们之间的关系。例如 B B B 跟代表元 A A A 间权值为 1 1 1(即 B B B A A A 的猎物),而 C C C A A A 间权值为 2 2 2(即 C C C A A A 的天敌),那么一定可以推出 B B B C C C 的天敌,这可以通过 AB − AC = 1 − 2 = − 1 ≡ 2 ( mod  3 ) \text{AB} - \text{AC} = 1 - 2 = -1 \equiv 2 (\text{mod}\ 3) ABAC=12=12(mod 3) 得到。我们将每个节点与其代表元之间的权值记为 dis,即距离。

food-chain

这一点表明了边带权的可行性。下面我们讨论怎么在并查集中维护这个权值。

我们发现,并查集中有两个地方会涉及到权值的更改:find 函数路径压缩时和 merge 函数转移代表元时。我们分别讨论这两个情况如何维护。

维护 find 函数

路径压缩时,我们需要把父节点的权值叠加到子节点。根据上面的环,这并不会影响到动物的关系。

find

但这也提醒我们,在路径压缩后,边的权值不再是 [ 0 , 2 ] [0,2] [0,2] 间的数。但这个问题很好解决,只需对权值模 3 3 3 取余即可。

维护 merge 函数

如下图,若 B B B D D D 分属两树,且现知 B B B D D D 同类,应该如何 merge

merge

显然,我们需要在两棵树的代表元 A A A C C C 间建立一条带权值的边。这条边的权值需要保证 B B B D D D 之间的关系能够被保证。设这条边的权值为 w w w,则有:

dis B + 0 ≡ dis D + w ( mod  3 ) \text{dis}_{\text{B}} + 0 \equiv \text{dis}_\text{D} + w(\text{mod}\ 3) disB+0disD+w(mod 3)

其中 + 0 +0 +0 的意思是两动物同类。类似的,如果是猎物关系则 + 1 +1 +1,天敌关系 + 2 +2 +2。于是解得:

w ≡ dis B + 0 − dis D ( mod  3 ) w \equiv \text{dis}_{\text{B}} + 0 - \text{dis}_\text{D}(\text{mod}\ 3) wdisB+0disD(mod 3)

调用 judge 函数

在边带权中,judge 函数不仅承担了判断两节点是否属于同一集合的作用,还被用来判断两节点间的关系。

具体的,在这道题中,只要两节点同属一个集合中,我们只需要将两节点 dis 值相减,看看差模 3 3 3 的余数即可。

但差有可能为负数。所以我们这里采取一个小技巧:判断 a a a b b b 除以 m m m 的余数是否等于 k k k,只需要 (a - b - k) %m == 0` 即可。这个由同余基本性质可以证明。

通过这个小技巧,我们也无需担心上面的 w w w 的正负问题了。因为在 C++ 中,(-3) % 3 的结果也是 0 0 0。所以上面 w w w 的计算式就被简化为:

w = dis B + 0 − dis D ( mod  3 ) w = \text{dis}_{\text{B}} + 0 - \text{dis}_\text{D}(\text{mod}\ 3) w=disB+0disD(mod 3)

代码示例

以上便是分析的过程。下面是关键代码,为了方便理解,我将 merge 函数和 judge 函数写在 main 函数里了,但 find 函数因为要递归,就只能写在外面了。

int find(int a) {
    if (djs[a] != a) {
        int t = find(djs[a]);
        dis[a] += dis[djs[a]];
        djs[a] = t;
    }
    return djs[a];
}

for (int i = 1; i <= n; i++)
    djs[i] = i;
for (int i = 1; i <= m; i++) {
    int op, a, b;
    scanf("%d%d%d", &op, &a, &b);
    if ((a > n) or (b > n)) {
        ans++;
        continue;
    }
    int ra = find(a), rb = find(b);
    if (op == 1) {
        if (ra == rb and (dis[a] - dis[b]) % 3) {
            ans++;
        } else if (ra != rb) {
            djs[ra] = rb;
            dis[ra] = dis[b] - dis[a];
        }
    } else {
        if (ra == rb and (dis[a] - dis[b] - 1) % 3) {
            ans++;
        } else if (ra != rb) {
            djs[ra] = rb;
            dis[ra] = dis[b] - dis[a] + 1;
        }
    }
}

这个代码没有用启发式合并,因为我没写出来

小结

从这道题中,我们可以领悟到:

  • 扩展域并查集可以维护只有有限关系的有向图的连通性,例如食物链中的“捕食”和“同类”。
  • 边带权可以自由地描述节点间的关系,并且通过简单的数学公式即可表达。但同时,因为代码较为抽象,可读性和调试难易程度不如扩展域强。

参考文献

  1. 《算法竞赛进阶指南》
  2. Efficiency of a Good But Not Linear Set Union Algorithm: https://dl.acm.org/doi/10.1145/321879.321884
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值