leetcode刷题日记之———并查集

219 阅读9分钟

并查集

文章都是自己创作的,如有建议请在留言区评论,将持续更新面试、工作中遇见的问题

本文将介绍并查集相关算法题单,并介绍Go、Java语言的实现;

并查集是一种非常简洁的数据结构,核心思想非常简单,常见的应用在测试在拓扑中两个点是否联通

涉及到的常见的算法题目:

数据结构与接口定义

并查集需要实现的方法有:

  1. isSameSet(a,e):测试a、e是否连通
  2. findFather(i):寻找i节点的父节点
  3. union(a,e):将a,e两个节点相连
  4. setCount(): 返回并查集中孤岛的数量

算法流程

  • 构建思路:通过形成森林的方式构造并查集,预期子节点指向父节点,根节点的指针指向自己(用来判断是否为根节点的依据);
    • 起始时,每一个节点的指针都指向自己;
    • findFather方法,通过迭代找到根节点(根节点的表示标识是自己指向自己)
    • union方法,分别找到两个传入参数i, j的根节点,然后将其中任意一个根节点成为另一个根节点的父亲节点,即完成合并的过程

img

初始化状态

为了使得并查集的查询性能稳定,需要有以下两个技巧:

  • 技巧1:平衡性优化,为避免出现头重脚轻的问题——union的时候将小一点的树接到大一点的树上(使用size数组记录每一棵树的重量,即节点的个数)

  • 技巧2:路径压缩,为了使在寻找根节点的过程中,时间复杂度是常数——在find的过程中进行压缩,最终所有的树的高度不会超过3

img

稳定时每个集合的状态

实现

首先会从最简化的方式说起,但是其性能会比较低

最简化实现

首先从最简化版本看起,有助于把握算法的核心思想

type UnionSet struct {
   parents []int //parents[i] = k, 表示i的父亲节点索引为k
}

// NewUnionSet 初始化一个容量为n的并查集,后续加入的所有的节点的值需要小于n
func NewUnionSet(n int) *UnionSet {
   parents := make([]int, n)
   for i := 0; i < len(parents); i++ {
      parents[i] = i //初始时每一个节点都指向自己
   }
   return &UnionSet{parents: parents}
}


// Find 查找节点i的根节点
func (u *UnionSet) Find(i int) int {
   if i >= len(u.parents) {
      panic("invalid node")
   }
   help := i
   for u.parents[help] != help {
      help = u.parents[help] //逐渐向根节点迭代
   }
   return help
}

// Union 将i,j两个所在的节点的集合相连
func (u *UnionSet) Union(i, j int) {
   if i >= len(u.parents) || j >= len(u.parents) {
      panic("invalid node")
   }
   f, f2 := u.Find(i), u.Find(j)
   if f == f2 { //已经相连
      return
   }
   u.parents[f] = f2 //将f挂载到f2集合中
}

优化思路

首先我们考虑在findFather方法的效率:

  • 从每个集合的状态这张图,我们可以看到一个集合本质上是一个多叉树
  • 所以在从叶子结点遍历到根节点的过程中,效率的核心影响因素为树的深度
  • 期望尽可能降低树的深度

img

稳定时每个集合的状态

可以这样优化:

  • 路径压缩:在findFather方法中,将遍历的到的每个节点都直接挂载i的根节点上,这样深度直接降为2
  • 平衡化:在union的时候,直接任意选的一个连在另一个根节点上;可以采取空间换时间的策略,记录每一个集合的节点个数数量(其实就是根节点,比如上图的集合中的1就是这个集合的代表节点),合并的时候将数量较小的合并到数量较大的集合中

优化后的算法

type UnionSet struct {
   parents []int //parents[i] = k, 表示i的父亲节点索引为k
   sizes []int

   count int //独立集合的数量
}

// NewUnionSet 初始化一个容量为n的并查集,后续加入的所有的节点的值需要小于n
func NewUnionSet(n int) *UnionSet {
   parents := make([]int, n)
   sizes := make([]int, n)
   for i := 0; i < len(parents); i++ {
      parents[i] = i //初始时每一个节点都指向自己
      sizes[i] = 1 //初始化每个集合都只有一个节点,且独立
   }
   return &UnionSet{parents: parents, sizes: sizes, count: n}
}


// Find 查找节点i的根节点
func (u *UnionSet) Find(i int) int {
   if i >= len(u.parents) {
      panic("invalid node")
   }
   help := i
   parent := u.parents[help]
   for help != parent {
      u.parents[help] = u.parents[parent] //压缩路径
      help = parent
      parent = u.parents[help]
   }
   return help
}

// Union 将i,j两个所在的节点的集合相连
func (u *UnionSet) Union(i, j int) {
   if i >= len(u.parents) || j >= len(u.parents) {
      panic("invalid node")
   }
   if i == j {
      return
   }
   f, f2 := u.Find(i), u.Find(j)
   if f == f2 { //已经相连
      return
   }
   if u.sizes[f] > u.sizes[f2] {
      u.sizes[f] += u.sizes[f2]
      u.parents[f2] = f
   } else {
      u.sizes[f2] += u.sizes[f]
      u.parents[f] = f2
   }
   u.count--
}

func (u *UnionSet) IsSameSet(i, j int) bool {
   return u.Find(i) == u.Find(j)
}


func (u *UnionSet) Size() int {
   return u.count
}

应用

547. 省份数量

n 个城市,其中一些彼此相连,另一些没有相连。如果城市 a 与城市 b 直接相连,且城市 b 与城市 c 直接相连,那么城市 a 与城市 c 间接相连。

省份 是一组直接或间接相连的城市,组内不含其他没有相连的城市。

给你一个 n x n 的矩阵 isConnected ,其中 isConnected[i][j] = 1 表示第 i 个城市和第 j 个城市直接相连,而 isConnected[i][j] = 0 表示二者不直接相连。

返回矩阵中 省份 的数量。

func findCircleNum(isConnected [][]int) int {
   n := len(isConnected)
   unionSet := NewUnionSet(n)

   for i := 0; i < n; i++ {
      for j := i+1; j < n; j++ {
         if isConnected[i][j] == 1 { //连通
            unionSet.Union(i, j)
         }
      }
   }
   return unionSet.Size()
}

200. 岛屿数量

给你一个由 '1'(陆地)和 '0'(水)组成的的二维网格,请你计算网格中岛屿的数量。

岛屿总是被水包围,并且每座岛屿只能由水平方向和/或竖直方向上相邻的陆地连接形成。

此外,你可以假设该网格的四条边均被水包围。

示例 1:

输入:grid = [  ["1","1","1","1","0"],
  ["1","1","0","1","0"],
  ["1","1","0","0","0"],
  ["0","0","0","0","0"]
]
输出:1

示例 2:

输入:grid = [  ["1","1","0","0","0"],
  ["1","1","0","0","0"],
  ["0","0","1","0","0"],
  ["0","0","0","1","1"]
]
输出:3
  • 基于递归(dfs)的实现
//200 岛屿数量
public static int numIslands(char[][] grid) {
    int res = 0 ;  //计数器的作用
    for(int k = 0 ; k < grid.length ;k++){
        for(int m= 0 ; m< grid[k].length ; m++){
            if(grid[k][m] == '1'){    //能够进入几次if语句块就有个岛屿
                infect(grid,k,m);     //因为只要进入感染一次,所有的‘1’都会变成‘2’
                ++res;
            }
        }
    }
    return res;
}
//第i行,第j列向外感染
public static void infect(char[][] grid, int i, int j) {
    if (i < 0 || i >= grid.length || j < 0 || j >= grid[i].length || grid[i][j] != '1') {
        return;
    }
    grid[i][j] = '2';   //标识为已经处理过该位置,下次不会进入继续
    infect(grid, i + 1, j);
    infect(grid, i, j + 1);
    infect(grid, i - 1, j);
    infect(grid, i, j - 1);
}
  • 解法二:基于并查集的实现

和上面基本代码都一致

连通网络的操作次数

用以太网线缆将 n 台计算机连接成一个网络,计算机的编号从 0 到 n-1。线缆用 connections 表示,其中 connections[i] = [a, b] 连接了计算机 a 和 b

网络中的任何一台计算机都可以通过网络直接或者间接访问同一个网络中其他任意一台计算机。

给你这个计算机网络的初始布线 connections,你可以拔开任意两台直连计算机之间的线缆,并用它连接一对未直连的计算机。请你计算并返回使所有计算机都连通所需的最少操作次数。如果不可能,则返回 -1 。 

 

示例 1:

输入: n = 4, connections = [[0,1],[0,2],[1,2]]
输出: 1
解释: 拔下计算机 12 之间的线缆,并将它插到计算机 13 上。

示例 2:

输入: n = 6, connections = [[0,1],[0,2],[0,3],[1,2],[1,3]]
输出: 2

示例 3:

输入: n = 6, connections = [[0,1],[0,2],[0,3],[1,2]]
输出: -1
解释: 线缆数量不足。

示例 4:

输入: n = 5, connections = [[0,1],[0,2],[3,4],[2,3]]
输出: 0

题目分析

在图论中,当然也是数学常识,在一个平面上,将n个点连通,最少需要n-1个点;其余的连接,其实都是冗余的,所以其实可以拆的就是这些冗余的线

在上面的题目中,为了从逻辑上更加清晰,我们先把每一个连通的集群中所有的冗余的线都拆下来,计算一下他的数量,如果这个数量大于连接其他集群需要的数量,那么就可以成功连通

那怎么计算连接其他集群需要的数量?我们可以这样理解,每个集群在连接之前其实就是孤立的点(逻辑上),假如有4个已经连通的集群,那么将这4个集群连通,需要 4-1=3 条线

  1. 集群数量:经过上面的算法模块的分析,并查集这种数据结构就可以很方便地查询集群的数量,直接调用Size()方法即可获取
  2. 冗余度的计算:在每次进行Union()方法的时候,如果根节点相等,那就是冗余的连接;所以我们需要一个计数器,来对每一个集群的冗余度进行计数;所有的孤立集群的冗余度之和就等于整个集群(大小为n)的冗余度

Code

type UnionSetWithCount struct {
   parents []int //parents[i] = k, 表示i的父亲节点索引为k
   sizes []int
   redundancies []int //记录节点中的冗余数量

   count int //独立集合的数量
}

// NewUnionSetWithCount 初始化一个容量为n的并查集,后续加入的所有的节点的值需要小于n
func NewUnionSetWithCount(n int) *UnionSetWithCount {
   parents := make([]int, n)
   sizes := make([]int, n)
   redu := make([]int, n)
   for i := 0; i < len(parents); i++ {
      parents[i] = i //初始时每一个节点都指向自己
      sizes[i] = 1 //初始化每个集合都只有一个节点,且独立
   }
   return &UnionSetWithCount{parents: parents, sizes: sizes, count: n, redundancies: redu}
}


// Find 查找节点i的根节点
func (u *UnionSetWithCount) Find(i int) int {
   if i >= len(u.parents) {
      panic("invalid node")
   }
   help := i
   parent := u.parents[help]
   for help != parent {
      u.parents[help] = u.parents[parent] //压缩路径
      help = parent
      parent = u.parents[help]
   }
   return help
}

// Union 将i,j两个所在的节点的集合相连
func (u *UnionSetWithCount) Union(i, j int) {
   if i >= len(u.parents) || j >= len(u.parents) {
      panic("invalid node")
   }
   if i == j {
      return
   }
   f, f2 := u.Find(i), u.Find(j)
   if f == f2 { //已经相连
      //记录冗余数量
      u.redundancies[f]++
      return
   }
   if u.sizes[f] > u.sizes[f2] {
      u.sizes[f] += u.sizes[f2]
      u.parents[f2] = f
   } else {
      u.sizes[f2] += u.sizes[f]
      u.parents[f] = f2
   }
   u.count--
}


func (u *UnionSetWithCount) IsSameSet(i, j int) bool {
   return u.Find(i) == u.Find(j)
}


func (u *UnionSetWithCount) Size() int {
   return u.count
}


func (u *UnionSetWithCount) Redundancies() int {
   sum := 0
   for i := 0; i < len(u.redundancies); i++ {
      sum += u.redundancies[i]
   }
   return sum
}

func makeConnected(n int, connections [][]int) int {
   set := NewUnionSetWithCount(n)
   for _, connection := range connections {
      a, b := connection[0], connection[1]
      set.Union(a, b)
   }
   redu := set.Redundancies() //冗余度
   if redu < set.Size() - 1 {
      return -1
   }
   return set.Size()-1
}

等式方程的可满足性

给定一个由表示变量之间关系的字符串方程组成的数组,每个字符串方程 equations[i] 的长度为 4,并采用两种不同的形式之一:"a==b" 或 "a!=b"。在这里,a 和 b 是小写字母(不一定不同),表示单字母变量名。

只有当可以将整数分配给变量名,以便满足所有给定的方程时才返回 true,否则返回 false。 

 

示例 1:

输入: ["a==b","b!=a"]
输出: false
解释: 如果我们指定,a = 1b = 1,那么可以满足第一个方程,但无法满足第二个方程。没有办法分配变量同时满足这两个方程。

示例 2:

输入: ["b==a","a==b"]
输出: true
解释: 我们可以指定 a = 1b = 1 以满足满足这两个方程。

示例 3:

输入: ["a==b","b==c","a==c"]
输出: true

示例 4:

输入: ["a==b","b!=c","c==a"]
输出: false

示例 5:

输入: ["c==c","b==d","x!=z"]
输出: true

 

提示:

  1. 1 <= equations.length <= 500
  2. equations[i].length == 4
  3. equations[i][0] 和 equations[i][3] 是小写字母
  4. equations[i][1] 要么是 '=',要么是 '!'
  5. equations[i][2] 是 '='

题目分析

  • 如果事物 a 和 b 有共同点,可以把它们划分到同一集合,同一类。
  • 把同一个类的 a、b 看作是相连的节点,不相连的节点不是一类。

思路: 由于变量都是小写字母,准备一个容量为26的并查集,在同一个集合中的小写字母,都是相等的

  • 第一次遍历equations数组,将所有的等式变量进行连通,完成并查集的创建,并记录不等式的索引
  • 遍历不等式,如果不等式两边两个变量在同一个集合中,则出现冲突;如果所有不等式都不冲突,则可以成立
const (
	eq = '='
	ne = '!'

	base uint8 = 'a'
)


func equationsPossible(equations []string) bool {
	set := NewUnionSet(26)
	neQueue := make([]int, 0) 
	for i, equation := range equations {
		l, e1, _, r := equation[0], equation[1], equation[2], equation[3]
		if e1 == eq {
			set.Union(int(l-base), int(r-base))
		} else {
			neQueue = append(neQueue, i)
		}
	}

	for _, index := range neQueue {
		equation := equations[index]
		l, _, _, r := equation[0], equation[1], equation[2], equation[3]
		if set.IsSameSet(int(l-base), int(r-base)) {
			return false
		}
	}
	
	return true
}