并查集进阶

并查集进阶

文章目录

  • 并查集进阶
    • 绪论
    • 普通并查集
      • 初始化
      • 搜索
      • 合并
    • 带权并查集
      • 初始化
      • 搜索
      • 合并
    • 种类并查集
    • 例题
      • HDU3047(种类并查集)
      • HDU3635(带权并查集)
      • POJ1988(带权并查集)
      • POJ2912(种类并查集)
      • POJ1456(普通并查集)

绪论

我本来以为并查集是一种很简单的数据结构,但是实在是被kuangbin的专题虐的死去活来,故在此总结一下

并查集目前有三部分: 普通并查集,带权并查集,种类并查集

普通并查集

并查集有三种基本操作,初始化(?),搜索,合并

对于普通并查集,我们只关心两个问题(或者是实现两个功能),即

  1. 判断两个元素是否在一个集合中
  2. 将两个不在同一集合的元素放在一个集合中

故我们采用一个数组(下称int F[ ] ),F[x]表示x的父亲节点,根节点的父亲节点是其本身

初始化

因为主要用到F[ ]数组,所以初始化也就是针对于F[ ]的初始化,初始时候,每个节点各成一个集合,对于大小为n的数据集,就得到了n的大小为1的集合.即令任意x都有F[x]=x

void UFSInit(){
    for(int i=0;i<=n;++i){
        F[i]=i;
    }
}

搜索

所谓搜索就是自一个节点一直向上寻找,一直查询到根节点为止,同一集合中其根节点一定相同,所以这也是并查集工作的基本原理.

void Find(int x){
    if(x==F[x])	// 查询到根节点
        return x;
  
    // 路径压缩,也就是让每个节点直接连集合的根节点,将查询操作简化为O(1)
    return F[x]=Find(x);
}

合并

合并操作首先需要查询根节点(根据上文的描述,此处已经压缩了路径,让待合并的两个节点已经直接与其根节点相连),然后合并根节点(让其中一个根节点成为另一个的父亲节点)

void Union(int x,int y){
    int fx=Find(x);
    int fy=Find(y);
    if(fx!=fy){	// 不在同一集合
        F[fy]=fx;
    }
}

当然,还有的操作可以让Union()函数返回值,即合并成功为1,合并失败为0,但是并没有太多用途

截止到目前为止这个普通的并查集就展现了所有操作,其可用于克鲁斯卡尔算法中用以判断是否加某边是否会造成环.

带权并查集

从这里开始就引入了新的概念:权值,我们用数组value[ ]表示,权值目前暂且当做描述某节点与其父节点的关系的一个量,而且具有可加性 ,即儿子到根节点的权值和等于儿子->父亲->…->根这条链上的所有边权值之和.

初始化

void UFSInit(){
    for(int i=0;i<=n;++i){
        F[i]=i;
        value[i]=0; // 我们暂定为根节点的value值为0,除此之外无变化
    }
}

搜索

void Find(int x){
    if(x!=F[x]){
        int tmp = F[x];
        F[x] = Find(F[x]); // 此时进行了两个操作,一个是令其父节点的value值向上合并至根节点
        value[x] += value[tmp]; // 此处是将其value加上其父节点的value进行更新
    }
    return F[x];
}

合并

这个稍微复杂一点,涉及到并查集的一个定义,或者是图解(稍后放上)

首先我们为了简化问题,不妨设定为已经是一个路径压缩过的并查集,x的父节点是fx,其同时也是根节点,然后还有一对 对称的 y与fy,也符合此关系

我们定义 合并操作接口 void Union(int x,int y,int w),表示一条自y指向x的有向线段,其权值为w,箭头的指向表示自儿子节点指向父亲节点,所以我们可以把此种线段当成向量处理,这也能解释之前查询路径合并时向上加权值的操作.也可以解释根节点的value值为零的问题.

所以我们得到了此图,但是我们想要把它变成路径压缩后那种类似蒲公英的形状,我们认为将y与x相连之后,fy与fx也会相连,但这样就会产生一个菱形,就违背了并查集的初衷(一个节点只有一个父亲),但是我们仔细思索,不难发现我们想要的情况实际上是fy整个树作为fx的子节点,也就是我们只需要更新fy就可以了(当然此处y并没有压缩路径),我们还是利用上文提到的菱形,我们就发现,沿y到fx的两条路的距离应该相等,

即有value[x]+w == value[y] + value[fy],式中 value[fy]未知,这恰好也是我们要求的量

value[fy] == value[x] + w - value[y]

综上所述给出代码

void Union(int x,int y,int w){
    int fx=Find(x);
    int fy=Find(y);
    if(fy!=fx){
        F[fy]=fx;
        value[fy] = value[x]+ w - value[y];
    }
}

然后这里还会有一个经典问题就是给你一条边问是否权值正确,我们也可以用上面那个恒等式来解决问题,即式中的w成为未知量,我们变换等式不难得出

w == value[fy] - value[x] + value[y];仅需判断这个式子的正确性即可

我们定义接口 bool Check(int x,int y,int w)

bool Check(int x,int y,int w){
    int fx = Find(x);
    int fy = Find(y);
    if(fx==fy){
        return w == value[fy] - value[x] + value[y];
    }
    return true; 
}

然后我们其实还可以把这个函数和Union直接合并

bool Union(int x,int y,int w){  // 返回值表示加边是否成功
    int fx=Find(x);
    int fy=Find(y);
    if(fx!=fy){ // 两个处于不同集合,加入此边一定不会成环
        F[fy]=fx;
        value[fy]= -value[y]+ w + value[x];
        return true;
    }else{  // 处于相同集合,需要检查此边合法性
        return w == value[fy] - value[x] + value[y];  // 然而这里实际上有一步优化,即fy一定是一个根节点,其value值一定为零,因此这句写成 return w == value[y] - value[x] 也可,虽然有一点点违反直觉,但确实是对的,和数学上定义相反了,因此只要记住那个菱形的恒等式,就可以推出了. 
    }
}

种类并查集

这个还是基于前两个的思想,还是以向量的角度思考.对于"边",我们不仅仅要求去表述其边的权值,我们还要求表示其与父节点的关系(不仅仅是父子关系,更广义上的,属性上的关系)

我们定义一个数组名字叫 rel[ ],rel[x]表示其与父亲的关系.

一道经典例题 食物链

动物王国中有三类动物A,B,C,这三类动物的食物链构成了有趣的环形。A吃B, B吃C,C吃A。

现有N个动物,以1-N编号。每个动物都是A,B,C中的一种,但是我们并不知道它到底是哪一种。
有人用两种说法对这N个动物所构成的食物链关系进行描述:
第一种说法是"1 X Y",表示X和Y是同类。
第二种说法是"2 X Y",表示X吃Y。
此人对N个动物,用上述两种说法,一句接一句地说出K句话,这K句话有的是真的,有的是假的。当一句话满足下列三条之一时,这句话就是假话,否则就是真话。
1) 当前的话与前面的某些真的话冲突,就是假话;
2) 当前的话中X或Y比N大,就是假话;
3) 当前的话表示X吃X,就是假话。
你的任务是根据给定的N(1 <= N <= 50,000)和K句话(0 <= K <= 100,000),输出假话的总数。

我们直接看此博客中提到的方法二

一个子节点 i 与父亲的关系无非三种:

  • i 与父亲同级 rel[i] = x0
  • i 吃父亲 rel[i] = x1
  • i 被父亲吃 rel[i] = x2

这三种情况我们不加证明地将其归结于取模问题

不妨令 x0=0,x1=1,x2=2,则在模意义下,满足以下式子

子->父 父->爷 子->爷(数字) 子->爷(逻辑)
x0(同级) x0(同级) x0+x0=0%3=0=x0 同级
x0(同级) x1(吃) x0+x1=1%3=1=x1
x0(同级) x2(被吃) x0+x2=2%3=2=x2 被吃
x1(吃) x0(同级) x1+x0=1%3=1=x1
x1(吃) x1(吃) x1+x1=2%3=2=x2 被吃
x1(吃) x2(被吃) x1+x2=3%3=0=x0 同级
x2(被吃) x0(同级) x2+x0=2%3=2=x2 被吃
x2(被吃) x1(吃) x2+x1=3%3=0=x0 同级
x2(被吃) x2(被吃) x2+x2=4%3=1=x1

然后我们不难得出,对于这个循环关系,rel[x]数组可以向上不断累加得到当前节点与根节点的关系.

void Init(){ // 初始化
    for(int i=0;i<=n;++i){
        F[i]=i;
        rel[i]=0; // 依据前文"同级"关系,每个节点与其本身同级
    }
}

int Find(int x){ 
    if(x!=F[x]){
        int tmp=F[x];
        F[x] = Find(F[x]);
        rel[x]=(rel[x]+rel[tmp])%3;
    }
    return F[x];
}

然后我们也采取和上文相同的分析方法,不难得出,如果不在乎模意义的话,rel[ ]数组 和 value[ ]在处理上没有本质区别,所以依然有rel[x]+flag=rel[y]+rel[fy]成立,

所以合并时也是根据两个节点是否处在同一个集合分为两个情况.

也就是分别利用flag == rel[y]+rel[fy]-rel[x](判断已存在的边的权值是否合法)和rel[fy]=rel[x]+flag-rel[y](合并fx与fy)

bool Union(int x,int y,int flag){
    int fx=Find(x);
    int fy=Find(y);
    if(fx!=fy){
        F[fx]=fy;
        rel[fy]=(3+flag+rel[x]-rel[y])%3;
    	return true;
    }else{
        return flag == (3+rel[y]+rel[fy]-rel[x])%3; // 当然和前文一样,此处的rel[fy]确实是为0,可以省略,但为了保持格式统一,依然保留
    }
}

例题

HDU3047(种类并查集)

几乎是模板题,这个种类根据题意就是[0,300),所以取模的数就是三百,而边的权值是x,y相距的列数,这里要注意唯一的坑点是在合并集合时,比如rel[fy] = (300 - rel[y] + rel[x] + w) % 300;一定要记得在括号中加300,否则会导致负数取模(结果是负数).

// HDU 3047 种类并查集 已AC
#include 
#include 
#include 

#define MAXN 50100
using namespace std;
int F[MAXN];
int rel[MAXN];
int n, m;

void Init() {
    for (int i = 0; i <= n; ++i) {
        F[i] = i;
        rel[i] = 0;
    }
}

int Find(int x) {
    if (x != F[x]) {
        int tmp = F[x];
        F[x] = Find(F[x]);
        rel[x] = (rel[x] + rel[tmp]) % 300;
    }
    return F[x];
}

bool Mix(int x, int y, int w) {
    int fx = Find(x);
    int fy = Find(y);
    if (fx != fy) {
        F[fy] = fx;
        rel[fy] = (300 - rel[y] + rel[x] + w) % 300; // 唯一坑点
        return true;
    } else {
        return w == (300 + rel[fy] + rel[y] - rel[x]) % 300;
    }
}

int main() {
    ios::sync_with_stdio(0);
    cin.tie(0);
    cout.tie(0);
    while (cin >> n >> m) {
        Init();
        int u, v, w;
        int ans = 0;
        for (int i = 0; i < m; ++i) {
            cin >> u >> v >> w;
            if (!Mix(u, v, w)) {
                ans++;
                // cout << i + 1 << endl;
            }
        }
        cout << ans << endl;
    }

    return 0;
}

HDU3635(带权并查集)

从此题不难看出,种类并查集重要的是思想而非模板,此题就新引入了一个数组child[ ],而其仍然保留着value[ ]数组,以表示子节点与父节点的关系,也就是说种类并查集提供了子节点与父节点关系如何合并的框架,而对于待求值可以将问题转化至此方面.

对于此题,要深刻理解两个根节点合并时,child[ ]数组的变化,即y的孩子数量需加上fx的所有孩子数量和其本身共child[fx]+1个孩子,然后value[fx]也仍然可以仿照前文菱形恒等式进行推导,但是我们不难发现,此题中所有的边的权值都是1,所以也可以简化为 value[fx]=1,而且合并时fx fy谁作为新的根节点也未有定论,也是需要因地制宜,具体情况具体分析.

// 另找的带权并查集,HDU3635 已AC
#include 
#include 
#include 
#include 

using namespace std;
#define MAXN 10500
int n, q;
int F[MAXN];
int trans[MAXN];
int child[MAXN];
int value[MAXN];

void Init() {
    for (int i = 0; i <= n; ++i) {
        F[i] = i;
        value[i] = 0;
        child[i] = 0;
    }
}

int Find(int x) {
  //  cout << "x== " << x << " F[x]== " << F[x] << endl;
    if (x != F[x]) {
        int tmp = F[x];
        F[x] = Find(F[x]);
        value[x] += value[tmp];
    }
    return F[x];
}

void Mix(int x, int y) {
    int fx = Find(x);
    int fy = Find(y);
   // cout << "fx == " << fx << " fy == " << fy << endl;
    if (fx != fy) {
        F[fx] = fy;
        child[fy] += child[fx] + 1;
        value[fx] = 1;
    }
}

int main() {
    ios::sync_with_stdio(0);
    cin.tie(0);
    cout.tie(0);
    int T;
    cin >> T;
    int cnt = 0;
    while (T--) {
        cout << "Case " << ++cnt << ":" << endl;
        cin >> n >> q;
        Init();
        char cmd;
        int t1, t2;
        for (int i = 1; i <= q; ++i) {
            cin >> cmd;
            if (cmd == 'T') {
                cin >> t1 >> t2;
            //    cout << "t1 == " << t1 << " t2 == " << t2 << endl;
                Mix(t1, t2);
            } else {
                cin >> t1;
                int ans = Find(t1);
                cout << ans << " " << child[ans]+1 << " " << value[t1] << endl;
            }
        }
    }
    return 0;
}

POJ1988(带权并查集)

这个题一开始确实有思维量,还是根据情况设置了两个数组,value[ ]和 child[ ],value和前文定义相同,表示父子关系,child[ x ]表示以x为栈底时,其上面的节点的数量,重点在合并时存在关系:

fx距离fy的权重值 == fy的子节点数量+1``````合并后fy的孩子节点数量 =合并前fy的孩子节点数量+fx孩子节点的数量+1

// 此题是另找的带权并查集的题,POJ 1988 ,已AC
#include 
#include 
#include 
#include 
#include 
#include 
#include 

#define MAXN 30500
using namespace std;
int n, m;
int p;

int F[MAXN];
int value[MAXN];
int child[MAXN];

void Init() {
    for (int i = 0; i <= n; ++i) {
        F[i] = i;
        value[i] = 0;
        child[i] = 0;
    }
}

int Find(int x) {
    if (x == F[x])
        return x;
    int tmp = F[x];
    F[x] = Find(F[x]);
    value[x] += value[tmp];
    return F[x];
}

// value[i] 表示 比i低的元素个数
void Mix(int x, int y) {
    int fx = Find(x);
    int fy = Find(y);
    if (fx != fy) {
        F[fx] = fy;
        value[fx] += child[fy] + 1;
        child[fy] += child[fx] + 1;
    }
}

int main() {
    ios::sync_with_stdio(0);
    cin.tie(0);
    cout.tie(0);
    n = 30500;
    while (cin >> p) {
        Init();
        char cmd;
        int t1, t2;
        for (int i = 0; i < p; i++) {
            cin >> cmd;
            if (cmd == 'M') {
                cin >> t1 >> t2;
                Mix(t1, t2);
            } else {
                cin >> t1;
                Find(t1);
                cout << value[t1] << endl;
            }
        }
    }
    return 0;
}

POJ2912(种类并查集)

此题确实是一道好题,首先依据前文模板(对3取模)可打出种类并查集搜索和合并的函数,然后问题在于如何判定裁判

解法非常巧妙也很自然,就是枚举每个人,先假定他是裁判,然后与他相关的游戏全部跳过,判断剩下的合理性,如果完全合理,ans(判断多少合法的人选)就自增1,变量person会记录下这个人的编号,如果不合理,则记录下其第一次发生不合理情况的行数,因为如果只有一个人合理,只要知道了其他所有人不合理的行数的最大值,就用排除法判断出了这个合理的人,所以此’不合理行数’的最大值就是判断出谁是裁判的行数,非常巧妙

对于ans我们分类讨论

  • 如果ans==0,说明没有一个人合理,那么就是"不可能出现"(即Impossible)
  • 如果ans==1,就说明情况合理,只需要按照格式输出此人编号和’不合理行数’的最大值
  • 如果ans>1,说明现有条件不足,超过一种可能解,故为"不能决定"(即Can not determine)
#include 
#include 
#include 

#define MAXN 550
using namespace std;
int n, m;
int F[MAXN];
int rel[MAXN];

inline int trans(char ch) {
    if (ch == '=')
        return 0;
    else if (ch == '<')
        return 1;
    else
        return 2;
}

struct cmd {
    int t1;
    char c;
    int t2;

    cmd(int t1, char c, int t2) : t1(t1), c(c), t2(t2) {}
};

void Init() {
    for (int i = 0; i <= n; ++i) {
        F[i] = i;
        rel[i] = 0;
    }
}

int Find(int x) {
    if (x != F[x]) {
        int tmp = F[x];
        F[x] = Find(F[x]);
        rel[x] = (rel[x] + rel[tmp]) % 3;
    }
    return F[x];
}

bool Mix(int x, int y, int w) {
    int fx = Find(x);
    int fy = Find(y);
    if (fx != fy) {
        F[fy] = fx;
        rel[fy] = (3 - rel[y] + w + rel[x]) % 3;
        return true;
    } else {
        return w == (3 + rel[fy] + rel[y] - rel[x]) % 3;
    }
}

int main() {
    ios::sync_with_stdio(0);
    cin.tie(0);
    cout.tie(0);
    while (cin >> n >> m) {
        if (!m) {
            cout << "Player 0 can be determined to be the judge after 0 lines" << endl;
            continue;
        }
        vector<cmd> vc;
        for (int i = 0; i < m; ++i) {
            char ch;
            int a, b;
            cin >> a >> ch >> b;
            vc.push_back(cmd(a, ch, b));
        }
        int ans = 0;
        int maxLine = -1;
        int person = -1;
        for (int i = 0; i < n; i++) { // 枚举裁判
            Init();
            bool flag = false; // 是否会产生矛盾
            for (int j = 0; j < m; j++) {
                if (vc[j].t1 == i || vc[j].t2 == i) // 跳过裁判所在行
                    continue;
                if (!Mix(vc[j].t1, vc[j].t2, trans(vc[j].c))) { // 出现矛盾
                    flag = true;
                    maxLine = max(maxLine, j + 1); // 记录'不合理行数'的最大值
                    break;// 天呐 最后居然在这里出BUG,如果这里不跳出循环,就会改变maxLine的值,也就是这里发生了错误的情况下,就不能继续向下搜索
                }
            }
            if (!flag) { // 这是个合理解
                person = i;
                ans++;
            }
        }
        if (ans > 1) {
            cout << "Can not determine" << endl;
        } else if (ans == 1) {
            cout << "Player " << person << " can be determined to be the judge after " << maxLine << " lines" << endl;
        } else {
            cout << "Impossible" << endl;
        }
    }

    return 0;
}

POJ1456(普通并查集)

虽然并没有上面的例题那样有技巧性,而且此题还有贪心的算法(堆优化),但是此题的解法确实更加巧妙而且并查集速度上更快

此题依旧采用贪心的思想,首先将所有商品依据价格排序,然后逐步处理

在每次处理过程中,判断其根节点是否大于0,即是否还有能供其销售的位置,如果有,则在总收入中加入其价值,然后令

F[fx]=fx-1,即将其根节点指向前一个位置,关于此操作的意义,可以这样理解,所谓根节点在此题中表示其最晚售卖的时间,如果其在根节点处被售卖了,其根节点需要向前移动,以保证和它有同样保质期的商品的销售时间比它靠前一天而不引起冲突,若根节点为零表示其前面的时间都被价值更高的物品排满了,它也就无法插入

所以此题还有一个问题是并查集的初始化的范围应当根据所有商品中最长的保质期来决定,这一点要注意

// B题并查集做法,已AC
#include 
#include 
#include 
#include 

using namespace std;
#define  MAXN 10010
int n;
int F[MAXN];

struct sale {
    int p;
    int d;

    sale(int p, int d) : p(p), d(d) {}
};

bool cmp(sale a, sale b) {
    return a.p > b.p;
}


void Init(int n) {
    for (int i = 0; i <= n; ++i) {
        F[i] = i;
    }
}

int Find(int x) {
    if (x == F[x])
        return x;
    return F[x] = Find(F[x]);
}

int main() {
    ios::sync_with_stdio(0);
    cin.tie(0);
    cout.tie(0);
    while (cin >> n) {
        vector<sale> vs;
        int p, d;
        int maxn = -1;
        for (int i = 0; i < n; ++i) {
            cin >> p >> d;
            vs.push_back(sale(p, d));
            maxn = max(maxn, d);
        }
        Init(maxn); // 以保质期最长的时间来初始化
        sort(vs.begin(), vs.end(), cmp);
        int ans = 0;
        for (int i = 0; i < n; ++i) {
            int fx = Find(vs[i].d);
            if (fx > 0) {
                F[fx] = fx - 1; // 巧妙
                ans += vs[i].p; // 计入总收入
            }
        }
        cout << ans << endl;
    }

    return 0;
}

你可能感兴趣的:(数据结构,acm)