JHHK

欢迎来到我的个人网站
行者常至 为者常成

【进阶】6、并查集

目录

并查集(Union Find)

一、需求分析

假设有n个村庄,有些村庄之间有连接的路,有些村庄之间并没有连接的路

img

设计一个数据结构,能够快速执行2个操作
查询2个村庄之间是否有连接的路
连接2个村庄

数组、链表、平衡二叉树、集合(Set)?
查询、连接的时间复杂度都是:O(n)

并查集能够办到查询、连接的均摊时间复杂度都是 O(α(n)) ,α(n) < 5
并查集非常适合解决这类“连接”相关的问题

二、并查集(Union Find)介绍

并查集也叫作不相交集合(Disjoint Set)

并查集有2个核心操作
查找(Find):查找元素所在的集合(这里的集合并不是特指Set这种数据结构,是指广义的数据集合)
合并(Union):将两个元素所在的集合合并为一个集合

有2种常见的实现思路

1、Quick Find
✓ 查找(Find) 的时间复杂度:O(1)
✓ 合并(Union)的时间复杂度:O(n)

2、Quick Union
✓ 查找(Find) 的时间复杂度:O(logn),可以优化至 O(α(n)) ,α(n) < 5
✓ 合并(Union)的时间复杂度:O(logn),可以优化至 O(α(n)) ,α(n) < 5

三、如何存储数据

假设并查集处理的数据都是整型,那么可以用整型数组来存储数据

img

不难看出
0、1、3 属于同一集合
2 单独属于一个集合
4、5、6、7 属于同一集合

因此,并查集是可以用数组实现的树形结构(二叉堆、优先级队列也是可以用数组实现的树形结构)

并查集(Union Find)实现

一、接口定义

/// 检查v1、v2是否属于同一个集合
/// @param v1 元素v1
/// @param v2 元素v2
-(BOOL)isSameWithV1:(int)v1 v2:(int)v2;


/// 查找v所属的集合,返回根节点
/// @param v 元素v
-(int)findV:(int)v;


/// 合并v1、v2所属的集合
/// @param v1 元素v1
/// @param v2 元素v2
-(void)unionWithV1:(int)v1 v2:(int)v2;

二、初始化

初始化时,每个元素各自属于一个单元素集合

img

/// 初始化一个并查集
/// @param capacity 容量
-(instancetype)initWithCapacity:(int)capacity{
    
    self = [super init];
    if (self) {
        self.parents = [NSMutableArray arrayWithCapacity:capacity];
        for (int i = 0; i<capacity; i++) {
            self.parents[i] = @(i);
        }
    }
    return self;
}

三、Quick Find

1、Quick Find - union

Quick Find 的 union(v1, v2):让 v1 所在集合的所有元素都指向 v2 的根节点

img

img

2、Quick Find - Find

img

◼ find(0) == 2
◼ find(1) == 2
◼ find(3) == 4
◼ find(2) == 2

3、代码实现

/// 检查v1、v2是否属于同一个集合
/// @param v1 元素v1
/// @param v2 元素v2
-(BOOL)isSameWithV1:(int)v1 v2:(int)v2{
    return [self findV:v1] == [self findV:v2];
}

//时间复杂度:O(1)
-(int)findV:(int)v{
    [self rangeCheck:v];
    
    /**
    * 父节点就是根节点
    */
    return [self.parents[v] intValue];
}


//时间复杂度:O(n)
-(void)unionWithV1:(int)v1 v2:(int)v2{
    int p1 = [self findV:v1];
    int p2 = [self findV:v2];
    if(p1 == p2) return;
    
    /**
    * 将v1所在集合的所有元素,都嫁接到v2的父节点上
    */
    for (int i = 0; i<self.parents.count; i++) {
        if (p1 == [self.parents[i] intValue]) {
            self.parents[i] = @(p2);
        }
    }
}

四、Quick Union

1、Quick Union - union

Quick Union 的 union(v1, v2):让 v1 的根节点指向 v2 的根节点

img

img

2、Quick Union - find

img

◼ find(0) == 2
◼ find(1) == 2
◼ find(3) == 2
◼ find(2) == 2

3、代码实现

/**
 * 通过parent链条不断地向上找,直到找到根节点
 * 时间复杂度:O(logn)

 */
-(int)findV:(int)v{
    [self rangeCheck:v];
    while (v != [self.parents[v] intValue]) {
        v = [self.parents[v] intValue];
    }
    return v;
}



/**
 * 将v1的根节点嫁接到v2的根节点上
 * 时间复杂度:O(logn)
 */
-(void)unionWithV1:(int)v1 v2:(int)v2{
    int p1 = [self findV:v1];
    int p2 = [self findV:v2];
    if(p1 == p2) return;
    self.parents[p1] = @(p2);
}

并查集(Union Find)优化

在Union的过程中,可能会出现树不平衡的情况,甚至退化成链表

img

有2种常见的优化方案
基于size的优化:元素少的树 嫁接到 元素多的树
基于rank的优化:矮的树 嫁接到 高的树

一、QuickUnion_Size优化

img

-(instancetype)initWithCapacity:(int)capacity{
    self = [super initWithCapacity:capacity];
    if (self) {
        //存放根节点对应集合的元素数量
        self.sizes = [NSMutableArray arrayWithCapacity:capacity];
        for (int i= 0; i<capacity; i++) {
            //初始化时数量为1
            self.sizes[i] = @(1);
        }
    }
    return self;
}

-(int)findV:(int)v{
    [self rangeCheck:v];
    while (v != [self.parents[v] intValue]) {
        v = [self.parents[v] intValue];
    }
    return v;
}



//基于size的优化:数量比较少的嫁接到数量比较多的 尽量保持树的平衡
-(void)unionWithV1:(int)v1 v2:(int)v2{
    int p1 = [self findV:v1];
    int p2 = [self findV:v2];
    if (p1 == p2) return;
    
    
    //数量比较少的嫁接到数量比较多的 尽量保持树的平衡
    int p1Size = [self.sizes[p1] intValue];
    int p2Size = [self.sizes[p2] intValue];
    if (p1Size < p2Size) {
        //p1 合并到 p2
        self.parents[p1] = @(p2);
        
        //更新p2节点所在集合的元素数量
        self.sizes[p2] = @(p1Size + p2Size);
    }else{
        //p2 合并到 p1
        self.parents[p2] = @(p1);
        
        //更新p1节点所在集合的元素数量
        self.sizes[p1] = @(p1Size + p2Size);
    }
}

二、QuickUnion_Rank优化

基于size的优化,也可能会存在树不平衡的问题

img

基于rank的优化

img

-(instancetype)initWithCapacity:(int)capacity{
    self = [super initWithCapacity:capacity];
    if (self) {
        //存放根节点对应结合的树的高度
        self.ranks = [NSMutableArray arrayWithCapacity:capacity];
        for (int i = 0; i<capacity; i++) {
            //初始化时高度为1
            self.ranks[i] = @(1);
        }
    }
    return self;
}

-(int)findV:(int)v{
    [self rangeCheck:v];
    while (v != [self.parents[v] intValue]) {
        v = [self.parents[v] intValue];
    }
    return v;
}


//基于rank的优化:高度比较少的嫁接到高度比较多的 保持树的平衡
-(void)unionWithV1:(int)v1 v2:(int)v2{
    int p1 = [self findV:v1];
    int p2 = [self findV:v2];
    if (p1 == p2) return;
    
    int p1Rank = [self.ranks[p1] intValue];
    int p2Rank = [self.ranks[p2] intValue];
    
    if (p1Rank < p2Rank) {
        self.parents[p1] = @(p2);
        //不需要更新树的高度
    }else if(p2Rank < p1Rank){
        self.parents[p2] =@(p1);
        //不需要更新树的高度
    }else{
        //相等时,谁合并谁都可以,采用v1 合并到 v2
        self.parents[p1] = @(p2);
        
        //更新p2Rank的高度
        self.ranks[p2] = @(p2Rank+1);
    }
}

三、QuickUnion_Rank_PathCompression优化

img

//基于 QuickUnion_Rank_PathCompression(路径压缩) 的优化
-(int)findV:(int)v{
    [self rangeCheck:v];
    
    
    int parent =  [self.parents[v] intValue];
    if (v != parent) {
        /**
         会一路找到根节点,并将沿途节点的父节点都赋值为根节点
         
         比如:1->4->8->10
         
         find:4
         1->4->10
            8->10
         
         find:1
         1->10
         4->10
         8->10
         
         
         存在的问题:有一定的性能开销
         */
        self.parents[v] = @([self findV:parent]);
    }
    return [self.parents[v] intValue];
}

路径压缩使路径上的所有节点都指向根节点,所以实现成本稍高

还有2种更优的做法,不但能降低树高,实现成本也比路径压缩低
路径分裂(Path Spliting)
路径减半(Path Halving)
路径分裂、路径减半的效率差不多,但都比路径压缩要好

四、QuickUnion_Rank_PathSplitting优化

路径分裂:使路径上的每个节点都指向其祖父节点(parent的parent)

img

//基于 QuickUnion_Rank_PathSplitting(路径分裂) 的优化
-(int)findV:(int)v{
    [self rangeCheck:v];
    
    while (v!= [self.parents[v] intValue]) {
        
        int parent =  [self.parents[v] intValue];
        int grand = [self.parents[parent] intValue];
        
        //将v的父节点变为其祖父节点
        self.parents[v] = @(grand);
        
        //下轮查看其父节点
        v = parent;
    }
   
    return v;
}

五、QuickUnion_Rank_PathHalving优化

路径减半:使路径上每隔一个节点就指向其祖父节点(parent的parent)

img

//基于 QuickUnion_Rank_PathHalving(路径减半) 的优化
-(int)findV:(int)v{
    [self rangeCheck:v];
    
    while (v != [self.parents[v] intValue]) {
        int parent =  [self.parents[v] intValue];
        int grand = [self.parents[parent] intValue];
        
        //将v的父节点变为其祖父节点
        self.parents[v] = @(grand);
        
        //下轮查看其祖父节点
        v = grand;
    }
   
    return v;
}

总结

摘自《维基百科》: https://en.wikipedia.org/wiki/Disjoint-set_data_structure#Time_complexity

img

大概意思是 使用路径压缩、分裂或减半 + 基于rank或者size的优化
✓ 可以确保每个操作的均摊时间复杂度为 O(𝛼(𝑛)) ,α(𝑛) < 5

个人建议的搭配
✓ Quick Union
✓ 基于 rank 的优化
✓ Path Halving 或 Path Spliting

关于自定义类型

之前的使用都是基于整型数据,如果其他自定义类型也想使用并查集呢?

方案一:通过一些方法将自定义类型转为整型后使用并查集(比如生成哈希值)
方案二:使用链表+映射(Map)

@interface LCGenericUnionFind : NSObject

/// 设置一个集合元素
/// @param v 元素
-(void)makeSet:(id)v;


/// 查找v所属的集合,返回根节点
/// @param v 元素v
-(id)findV:(id)v;


/// 检查v1、v2是否属于同一个集合
/// @param v1 元素v1
/// @param v2 元素v2
-(BOOL)isSameWithV1:(id)v1 v2:(id)v2;


/// 合并v1、v2所属的集合
/// @param v1 元素v1
/// @param v2 元素v2
-(void)unionWithV1:(id)v1 v2:(id)v2;

@end

#import "LCGenericUnionFind.h"



#pragma mark - LCUFNode

@interface LCUFNode : NSObject
@property(nonatomic,strong)id           value;  //value
@property(nonatomic,strong)LCUFNode *   parent; //父节点
@property(nonatomic,assign)int          rank;   //节点高度
@end;


@implementation LCUFNode

-(instancetype)init{
    if (self = [super init]) {
        self.parent = self;
        self.rank = 1;
    }
    return self;
}


-(instancetype)initWithValue:(id)value{
    if(self = [self init]){
         self.value = value;
    }
    return self;
}
@end



#pragma mark - LCGenericUnionFind

@interface LCGenericUnionFind()
@property(nonatomic,strong)NSMutableDictionary * nodes;
@end


@implementation LCGenericUnionFind

-(NSMutableDictionary *)nodes{
    if (_nodes == nil) {
        _nodes = [NSMutableDictionary dictionary];
    }
    return _nodes;
}

//v:node,放入nodes
-(void)makeSet:(id)v{
    if ([self __nodesGet:v]) return;
    LCUFNode * node = [[LCUFNode alloc] initWithValue:v];
    [self __nodesPutKey:v value:node];
}



/// 查找v所属的集合,返回根节点
/// @param v 元素v
-(id)findV:(id)v{
    LCUFNode * rootNode = [self __findRootNodeV:v];
    return rootNode?rootNode.value:nil;
}



/// 检查v1、v2是否属于同一个集合
/// @param v1 元素v1
/// @param v2 元素v2
-(BOOL)isSameWithV1:(id)v1 v2:(id)v2{
    return [[self findV:v1] isEqual:[self findV:v2]];
}




/// 合并v1、v2所属的集合
/// @param v1 元素v1
/// @param v2 元素v2
-(void)unionWithV1:(id)v1 v2:(id)v2{
    
    LCUFNode* v1Root = [self __findRootNodeV:v1];
    LCUFNode* v2Root = [self __findRootNodeV:v2];
    
    if ([v1Root isEqual:v2Root]) return;
    
    if (v1Root.rank < v2Root.rank) {
        v1Root.parent = v2Root;
    }else if(v2Root.rank < v1Root.rank){
        v2Root.parent = v1Root;
    }else{
        v1Root.parent = v2Root;
        v2Root.rank +=1;
    }
}


                      
#pragma mark - 内部方法
                      
                      
-(id)__nodesGet:(id)key{
    NSString * keyHash = [NSString stringWithFormat:@"%ld",[key hash]];
    return  [self.nodes objectForKey:keyHash];
}

-(void)__nodesPutKey:(id)key value:(id)value{
    NSString * keyHash = [NSString stringWithFormat:@"%ld",[key hash]];
    [self.nodes setObject:value forKey:keyHash];
}
                

-(LCUFNode*)__findRootNodeV:(id)v{
    
    LCUFNode * node = [self __nodesGet:v];
    if (node == nil) return nil;
    
    while (![node isEqual:node.parent]) {
        node = node.parent;
    }
    
    return node;
}

@end

使用示例:

-(void)test9_2{
    
    LCGenericUnionFind * unionFind = [[LCGenericUnionFind alloc] init];
    
    NSObject * obj1 = [[NSObject alloc] init];
    NSObject * obj2 = [[NSObject alloc] init];
    NSObject * obj3 = [[NSObject alloc] init];
    NSObject * obj4 = [[NSObject alloc] init];
    
    
    [unionFind makeSet:obj1];
    [unionFind makeSet:obj2];
    [unionFind makeSet:obj3];
    [unionFind makeSet:obj4];
    [unionFind unionWithV1:obj1 v2:obj2];
    [unionFind unionWithV1:obj1 v2:obj3];
    [unionFind unionWithV1:obj1 v2:obj4];
    
    [LCAssert AssertWithValue:[unionFind isSameWithV1:obj2 v2:obj4]];

    
    NSObject * obj5 = [[NSObject alloc] init];
    NSObject * obj6 = [[NSObject alloc] init];
    [unionFind makeSet:obj5];
    [unionFind makeSet:obj6];
    [unionFind unionWithV1:obj5 v2:obj6];
    [LCAssert AssertWithValue:[unionFind isSameWithV1:obj5 v2:obj6]];
    [LCAssert AssertWithValue:![unionFind isSameWithV1:obj3 v2:obj6]];
    [unionFind unionWithV1:obj2 v2:obj6];
    [LCAssert AssertWithValue:[unionFind isSameWithV1:obj1 v2:obj6]];


    
    NSObject * obj7 = [[NSObject alloc] init];
    NSObject * obj8 = [[NSObject alloc] init];
    [unionFind makeSet:obj7];
    [unionFind makeSet:obj8];
    [unionFind unionWithV1:obj7 v2:obj8];
    [LCAssert AssertWithValue:[unionFind isSameWithV1:obj7 v2:obj8]];
    [LCAssert AssertWithValue:![unionFind isSameWithV1:obj1 v2:obj8]];
    [unionFind unionWithV1:obj5 v2:obj8];
    [LCAssert AssertWithValue:[unionFind isSameWithV1:obj1 v2:obj8]];
}

行者常至,为者常成!





R
Valine - A simple comment system based on Leancloud.