并查集,连通域问题模板

原理

并查集用来维护无向图的联通分支数(判断两个节点是否在同一个连通域内,或增加一条边是否会产生环),凡是输入数据是图的连通关系(比如直接给每一条边的两个节点)的题几乎都可以使用并查集解决。
可以将各个节点比喻成江湖大侠,一开始各路江湖大侠各自为战,一言不合大打出手,打输了就认赢家当大哥,于是慢慢的就形成了一个个帮派。有了帮派之后两位大侠一言不合先自报家门,A:“我是斧头帮的!我大哥张斧头”, B: “我是榔头帮的,我大哥李榔头”, 这就不是一家人,直接往死里整。要是B说,“巧了,我也是斧头帮的,我大哥赵二斧” 那就是自家兄弟,和气生财。于是每个节点需要知道自己是哪一个帮派的,他大哥是谁,并查集的思想是从帮派内推举一个人当帮主,他是最高级的大哥,帮主的大哥就是他自己。每位大侠只需要记录自己的大哥是谁,通过层层递归就可以找到自己的帮主,初始状态,n位大侠各自自成一派,每一个大侠的大哥都是他自己:

MonoSet::MonoSet(const int& n)m_father(vector<int>(n,0)){
	for(int i=0; i<n; i++) m_father[i] = i;
}

于是每一个大侠都需要自己得帮主是谁,使用一个辅助数组father来记录每一个大侠的直接上层大哥,通过大哥找大哥的递归关系就可以找出这个帮派的帮主是谁,只要帮主是同一个人,那就是一个帮派的。
于是查询大侠帮主的find函数如下:

int MonoSet::find(const int& x){
	return x == m_father[x] ? x : find(m_father[x]);
}

对并查集而言,大侠打一架的过程不是输了就认赢家当大哥,而是堵上帮派命运的战斗,打输的一方整个帮派都得并入赢家的帮派里面,我们先不考虑大侠打架的具体胜负规则,为方便理解,暂时以”先上场的赢“作为胜负标准。于是帮派合并让其中一个帮派的帮主认另一个帮派的帮主当帮主即可:

bool MonoSet:: join(const int& x, const int& y)
{
	int fx = find(x);
	int fy = find(y);
	if(fx==fy) 	
		return false;   // 自家兄弟,这一架没打成
	m_father[fx] = fy; 				// 不是一个帮派的,直接兼并
	return true;
}

如此,并查集的基本功能就完备了,但是每次两个大侠要打架之前都要先找自己大哥让大哥再去找大哥一直找到各自的帮主看是不是一个帮的,当层级多了帮派兼并效率很低,大侠等着也难受,所以得推广扁平化管理,最好就是每一个大侠的大哥都是帮主,只查询一次就知道是不是一个帮的了。所以我们在每次为大侠A找到帮主之后,就把A的大哥设成帮派的帮主,后面大侠查询自己的帮派就更容易了:

int MonoSet:: find(const int& x)
{
	if(x != m_father[x])
		m_father[x] = find(m_father[x]);
	return m_father[x];
}

再考虑帮派兼并的过程,初始版本只是简单的把两个帮派合并了,对谁兼并谁没有仔细深究,实际上应当让两个帮主打一架,赢得当帮主才能服众。这里的打一架对应到数据结构我们考虑的是如何兼并才能是得树树高更小,也即更加扁平化。比如如下A B两棵树,A的树高是2,B树高是3,
在这里插入图片描述
如果A并入B则最大树高是3,而B并入A最大数高是4,所以让A并入B。也即帮主比武拼的是谁的团队“更不扁平”,更扁平的一方被兼并。
在这里插入图片描述
在这里插入图片描述
为此增加一个数组来记录每个节点的秩,将每一个节点的秩都初始化为1,合并两个集合时按秩合并:

bool MonoSet::join(const int& x, const int& y)
{
	int fx = find(x);
	int fy = find(y);
	if(fx == fy)
		return false;
	if(m_rank[fx] < m_rank[fy])
	{
		m_father[fx] = fy;
	}
	else{
		m_father[fy] = fx;
		if(m_rank[fx] == m_rank[fy])
			m_rank[fx]++;
	}
	return true;
}

于是整个并查集的代码如下:

class MonoSet {
public:
	MonoSet(const int& n) :
		m_father(vector<int>(n, 0)),
		m_rank(vector<int>(n, 1)) {
		m_sets.clear();
		for (int i = 0; i < n; i++) m_father[i] = i;
	}
	~MonoSet() {
		m_father.clear();
		m_rank.clear();
	}
	int find(const int& x)
	{
		if (x != m_father[x])
			m_father[x] = find(m_father[x]);
		return m_father[x];
	}
	bool join(const int& x, const int& y)
	{
		int fx = find(x);
		int fy = find(y);
		if (fx == fy)
			return false;
		if (m_rank[fx] < m_rank[fy])
		{
			m_father[fx] = fy;
		}
		else {
			m_father[fy] = fx;
			if (m_rank[fx] == m_rank[fy])
				m_rank[fx]++;
		}
		return true;
	}
	// 返回所有连通域(帮主和帮派成员)
	unordered_map<int, vector<int>> getSets() {
		if (m_sets.empty())
		{
			for (int i = 0; i < m_father.size(); i++)
			{
				m_sets[find(i)].push_back(i);
			}
		}
		return m_sets;
	}
private:
	vector<int> m_father;
	vector<int> m_rank;
	unordered_map<int, vector<int>> m_sets;
};


注意

  • 方便采用并查集的题,一般来说数据范围与输入数据长度相差不大,比如684.冗余链接,数据范围和输入数据长度几乎一样。
  • 输入数据给的是m个键值对的无向边时,考略直接套模板用并查集解。
  • 由于此实现使用元素值用作m_parent的下标索引, 在输入数据含有负数时会导致下标越界,此时一般可以采用别的方法。
  • 输入数据是mn的矩阵给出的图而不是m2给出的边的时候,一般可以考虑 深搜或者广搜。也可以使用二维的并查集。此时需要将二维坐标展开到一维来处理,可以使用如下的loc2Num实现一维映射,并使用num2Loc反解出row和col:
// 假设输入矩阵大小为m*n
int loc2Num(const int&row ,const int& col) { 
	return row*n + col; 
}
pair<int,int> num2Loc(const int& loc){
	int row = loc / n;
	int col = loc % n;
	return {row,col};
}

应用

684. 冗余连接

在这里插入图片描述
在这里插入图片描述
凡直接给图的边关系的题几乎都可以用并查集解决,此题是找连通域多余的边,直接使用并查集模板,当join函数返回false时即找到一条多余的边。于是只需遍历输入的边建立并查集即可, 由于输入数据指定最大值是1000,所以并查集的容量初始化为1001. 代码:

    vector<int> findRedundantConnection(vector<vector<int>>& edges) {
        MonoSet tmpSet(1001);
        int loc=-1;
        for(int i=0; i<edges.size();i++)
           if( !tmpSet.join(edges[i][0],edges[i][1]) ) loc=i;
        return loc==-1?vector<int>():edges[loc];
    }

130. 被围绕的区域

在这里插入图片描述
依据提示可以知道,题目是要找出所有O的连通域,并且把不在边界上的连通域置为背景(将O设置成X),连通域问题都可以使用并查集解决。可以设置一个虚拟节点,将所有在边界上的O都跟这个虚拟节点放到一个集合,这样就很容易分辨哪些需要被改成O了。二维矩阵上使用并查集需要将二维坐标映射到一维,这样并查集的模板代码全部不用改。映射与反映射函数分别为:

    int loc2Num(const int& row, const int& col){return row*n+col;}
    // 于是row = num/n; col= num%n; n是矩阵的列数
    

遍历整个矩阵,如果遇到O则判断它是否在边界上,在边界上则与虚拟节点放到一个集合,否则查找它周围是否有O,有就把它与周围的O放到一个集合。遍历完之后所有O的连通域都被并查集管理起来了,只需要判断他们是否在虚拟节点的集合内便可决定是否改成X,代码如下(并查集类的代码见原理处):

	int m, n;
    const vector<pair<int,int>> locVec = {{-1,0},{1,0},{0,1},{0,-1}};
    inline int adjRow(const int& row, const int& k){return row + locVec[k].first; }
    inline int adjCol(const int& col, const int& k){return col + locVec[k].second; }
    inline bool validLoc(const int& row, const int& col){
        return row<m && row >=0 && col<n && col>=0;
    }
    int loc2Num(const int& row, const int& col){return row*n+col;}

    void solve(vector<vector<char>>& board) {
        if(board.size()<2) return;
        m=board.size(), n=board[0].size();
        MonoSet mono(m*n+1);
        int flagNode = m*n;
        int niberRow=-1,niberCol = -1;
        for(int i=0; i<m; i++)
            for(int j=0; j<n; j++)
           		if(board[i][j] == 'O' ){
	                for(int k=0; k<4;k++)
	                {        
	                    niberRow = adjRow(i,k), niberCol=adjCol(j,k);                    
                        if(!validLoc(niberRow,niberCol)){
                            mono.join(flagNode,loc2Num(i,j));
                            break;
                        }
                        else if(board[adjRow(i,k)][adjCol(j,k)] == 'O'){
                            mono.join(loc2Num(i,j), loc2Num(niberRow,niberCol));
                        }
                        // else  孤立的节点不必单独处理,他已经在并查集初始化时管理好了  
                    }                    
                }
        auto sets = mono.getSets();
        unordered_set<int> tmp;
        for(auto &p:sets)
            for(auto &num: p.second)
                if(mono.find(num) != mono.find(flagNode))
                    board[num/n][num%n] = 'X';
        return;
    }

几乎所有二维图上连通域的问题都可以套用此模板,区别只在插入并查集的逻辑不同以及最后的求解过程不同,例如下面的岛屿最大面积实际上是要找1的连通域的最大节点,直接抄代码略微改一点合并条件和最后的求解即可

剑指 Offer II 105. 岛屿的最大面积

在这里插入图片描述在这里插入图片描述

直观看题就是找最大连通域的元素个数,没什么说的,直接上并查集。套上一题的代码模板,略微改一点点就可以了:

int m, n;
    const vector<pair<int,int>> locVec = {{-1,0},{1,0},{0,1},{0,-1}};
    inline int adjRow(const int& row, const int& k){return row + locVec[k].first; }
    inline int adjCol(const int& col, const int& k){return col + locVec[k].second; }
    inline bool validLoc(const int& row, const int& col){
        return row<m && row >=0 && col<n && col>=0;
    }
    int loc2Num(const int& row, const int& col){return row*n+col;}

    int maxAreaOfIsland(vector<vector<int>>& grid) {
        if(grid.empty()) return 0;
        m=grid.size(), n=grid[0].size();
        MonoSet mono(m*n);
        int niberRow=-1,niberCol = -1;
        bool flag = true;
        for(int i=0; i<m; i++)
            for(int j=0; j<n; j++)
                if(grid[i][j] == 1 ){
                    flag = false;
                    for(int k=0; k<4;k++)
                    {        
                        niberRow = adjRow(i,k), niberCol=adjCol(j,k);                        
                        if(validLoc(niberRow,niberCol) && grid[niberRow][niberCol] == 1){
                            mono.join(loc2Num(i,j), loc2Num(niberRow,niberCol));
                        }
                        // else  孤立的节点不必单独处理,他已经在并查集初始化时管理好了  
                    }
                    // 标记减少重复处理
                    grid[i][j] = 2;                  
                }
        if(flag) return 0; //一个1都没有的时候,并查集里面也维护了孤立节点的,需要单独处理
        auto sets = mono.getSets();
        size_t res = 0;
        unordered_set<int> tmp;
        for(auto &p:sets)
            res = max(res,p.second.size());
        return res;
    }

765. 情侣牵手

在这里插入图片描述
由于座无虚席,只有有一对情侣不能牵手,必然会影响另一对情侣也不能牵手,因此如果存在不能牵手的情况至少是两队情侣不能牵手,这时候交换一次位置即可让他们全部牵手成功。

  • 当有两对情侣相互坐错了位置,将需要牵手的双方用一条边连接起来,ta们两对之间形成了一个环。需要进行一次交换,使得每队情侣独立(相互牵手)
  • 如果三对情侣相互坐错了位置,将需要牵手的双方用一条边连接起来,ta们三对之间形成了一个环,需要进行两次交换,使得每队情侣独立(相互牵手)
  • 如果四对情侣相互坐错了位置,将需要牵手的双方用一条边连接起来,ta们四对之间形成了一个环,需要进行三次交换,使得每队情侣独立(相互牵手)

也就是说,如果我们有 k 对情侣形成了一个错误环,需要交换 k - 1 次才能让这k对情侣牵手, 于是问题转化为求解总共有多个个错误环(连通域),以及每个错误环内的情侣有多少对,累加起来就是总共需要调整的次数。因此选用并查集来完成。

每次处理两个位置,把他俩合并到一起,如果他们本来就是一对情侣,则不会影像其他情侣对是否交换, 如果他们不是一对情侣,由于情侣双方必然位于一个集合(也即floor(i/2) == floor((i+1)/2, i= 0,2,4,6,8,…)。随着并查集的合并必然会将他们的另一半放到同一个集合内形成一个连通域。代码:

    int minSwapsCouples(vector<int>& row) {
        int len = row.size()/2;
        MonoSet tmpSet(len);
        for(int i = 0; i < row.size() ; i+=2){
            tmpSet.insert(row[i] / 2, row[i + 1] / 2);
        }
        auto sets = tmpSet.getSets();
        int res = 0;
        for(const auto & p: sets)
            res += p.second.size() -1;
        return res;
    }
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值