并查集、Kruskal算法及其应用

并查集

入门

并查集(union-find set)是一种不算太“低级”的数据结构,在算法竞赛中比较常见。简而言之,它专门用来高效地处理不相交集合(disjoint sets)的合并及查询问题。

Bernard A. Galler和Michael J. Fischer于1964年提出了并查集的森林表示形式:用一棵(多叉)树来表示一个集合,树中的每个节点都保存着对它的父节点的引用,所有的不相交集合即可形成一个森林,并且每个集合的“代表”就是对应的树的根节点。

根据上述表示形式,我们就可以定义并查集的三种基本操作。下面用伪码表示之。

  • 从单节点x创建集合
    每个集合刚开始都只有一个元素,通过后面的合并操作再形成多元素的集合。
function make(x) {
  x.parent = x;
}
  • 查询x位于哪个集合
    注意返回的是x所在集合的根节点,前面说过,根节点可以作为集合的“代表”。
function find(x) {
  if (x.parent == x) {
    return x;
  } else {
    return find(x.parent);
  }
}

当然也可以写成迭代形式。

function find(x) {
  while (x.parent != x) {
    x = x.parent;
  }
  return x;
}
  • 合并x、y所在的两个集合
    就是分别找到它们所处集合的根节点,并将两个集合的根连接在一起。
function union(x, y) {
  rootx = find(x);
  rooty = find(y);
  if (rootx == rooty) {
    return;
  }
  rootx.parent = rooty;
}

上面的写法是最朴素的逻辑,虽然简单,但性能有风险——因为集合形成的树有可能会非常不平衡,甚至成为单枝树(退化成线性的)。所以在实际应用时会采取两方面优化:一是路径压缩(path compression),二是按秩合并(union by rank)。

路径压缩

根据并查集结构的定义,树的每个节点其实都可以直接连接到根节点,而树表示的集合是不变的。那么我们在执行查询操作时,可以顺便对查询路径中经过的每个节点都递归地更改指向,让它们连接到根节点上,使得树尽量扁平化,之后再执行查询操作遍历的节点就会变少,效率提高。最理想情况下,树应该只有两层,顶层只有根节点,第二层是所有叶子节点。

下面给出路径压缩思路下查询操作的逻辑。

function find(x) {
  if (x.parent != x) {
    x.parent = find(x.parent);
  }
  return x.parent;
}

以及等价的迭代写法,即先将父节点指向祖父节点,再将当前节点指向父节点。

function find(x) {
  while (x.parent != x) {
    x.parent = x.parent.parent;
    x = x.parent;
  }
  return x;
}

按秩合并

我们已经知道,树的深度会影响并查集算法的效率,那么在两个集合合并时,可以人为地将深度较小的树连接到深度较大的树的根节点上。合并的结果树的深度要么不会增加,要么只增加1(即当两棵树的深度相同时)。这里引入“秩”(rank)的概念:

  • 单元素集合树的秩为0;
  • 当两个秩同为r的集合树做合并操作时,结果树的秩为r + 1。

实际实现时,可以将秩存储在根节点。为什么不直接沿用深度的概念呢?因为按秩合并几乎都与路径压缩一同使用,路径压缩之后树会变扁平,深度和秩就不是一回事了。

下面给出按秩合并思路下创建集合和合并操作的逻辑。

function make(x) {
  x.parent = x;
  x.rank = 0;
}

function union(x, y) {
  rootx = find(x);
  rooty = find(y);
  if (rootx == rooty) {
    return;
  }
  if (rootx.rank < rooty.rank) {
    rootx.parent = rooty;
  } else if (rootx.rank > rooty.rank) {
    rooty.parent = rootx;
  } else {
    rooty.parent = rootx;
    rootx.rank++;
  }
}

复杂度

如果同时使用上述两种优化,如果在有n个元素的并查集上进行m次查询和合并操作,其时间复杂度为O(mα(n)),其中α(n)为阿克曼函数A(n, n)的反函数。证明过程非常硬核,详见《算法导论》第21章第4节。

英文维基中《Proof of O(log*n) time complexity of union–find》一文也给出了平摊时间复杂度为O(mlog*n)的证明,相对容易明白一些。其中log*为迭代对数,即:

不管是反阿克曼函数还是迭代对数,它们的增长速度都极其缓慢,接近常数级别,这也从侧面说明了并查集的高效性。

应用举例

并查集常常用来解决网络中的连通性问题。这里的网络是广义的,除了计算机网络之外,还可以是社交体系里的人际关系网、生物学里的食物网等等。如果将网络抽象为图结构,那么并查集可以方便地确定两点是否连通(是否在同一个集合内),以及共有多少个连通分量(不相交集合数)。下面举个简单的例子。

LeetCode 547:Friend Circles

题目大意:一个班级有N(N≤200)位学生,给出一个N*N的矩阵M,M[i][j]=M[j][i]=1表示第i位和第j位学生是朋友关系。确定这些学生中有多少个“朋友圈”,“朋友圈”指的是一组有直接或间接朋友关系的人。

这就是上述“在人际关系网中确定连通分量数”的例子,朋友圈的数量其实就是并查集内不相交集合的数量。它的数据规模比较小,就算是用朴素解法也可以AC,但是作为例题,我们还是老老实实写一个完全版,代码如下。

class Solution {
  final int MAXN = 201;
  int[] parent = new int[MAXN];
  int[] rank = new int[MAXN];

  void make(int x) {
    parent[x] = x;
    rank[x] = 0;
  }

  int find(int x) {
    while (parent[x] != x) {
      parent[x] = parent[parent[x]];
      x = parent[x];
    }
    return x;
  }

  boolean union(int x, int y) {
    int rx = find(x), ry = find(y);
    if (rx != ry) {
      if (rank[rx] <= rank[ry]) {
        parent[rx] = ry;
        rank[ry] = Math.max(rank[rx] + 1, rank[ry]);
      } else {
        parent[ry] = rx;
        rank[rx] = Math.max(rank[rx], rank[ry] + 1);
      }
      return true;
    }
    return false;
  }

  public int findCircleNum(int[][] M) {
    int n = M.length, result = n;
    for (int i = 0; i < n; i++) {
      make(i);
    }
    for (int i = 0; i < n; i++) {
      for (int j = i; j < n; j++) {
        if (M[i][j] == 1) {
          if (union(i, j)) {
            result--;
          }
        }
      }
    }
    return result;
  }
}

在实际应用中,并查集用简单的数组就能表示:parent数组存储某节点的父节点,rank数组存储(根)节点的秩。另外,为了避免合并完之后再重新扫描统计一遍根节点的数量,可以在每一次成功的合并之后将总数减去1(初始的朋友圈数量自然是N)。

如果题目改成判断两个人x、y是否位于同一个朋友圈内,使用条件find(x) == find(y)即可。

根据本节开头对连通性问题的描述,这道题(以及其他不少可以用并查集解决的题目)用DFS解决也比较自然,解法就略去了。不过,并查集无法获知两点之间的路径,而DFS可以,所以要根据实际情况灵活使用。

Kruskal算法与并查集

算法介绍

Kruskal算法是图的两种经典最小生成树(MST)算法之一(另外一个是Prim),在大学数据结构和图论课程中肯定会讲到。这里先简介一下它的算法流程:

  1. 新建图G',其中包含原图G中相同的顶点,但没有边;
  2. 将原图G中所有的边按照权值从小到大排序;
  3. 从权值最小的边开始,如果这条边连接的两个顶点在G'里不存在于同一个连通分量中(即这条边是桥边,添加它不会构成环),则将它添加到G';
  4. 重复上一步,直到G'中的所有顶点都位于同一个连通分量,即形成最小生成树。

为了方便理解,下面的图示出在一张图上执行Kruskal算法的流程。绿色边是最小生成树的边,红色边是不满足上述步骤3条件的边。

由Kruskal算法的流程可见,关键点有二:一是需要判断两个点是否位于同一个连通分量,二是合并两个连通分量(添加桥边之后),这恰好是并查集所擅长的。

该算法的时间复杂度可以这样考虑:第1步初始化为O(|V|),第2步的排序为O(|E|log|E|)(很多排序算法都是这个复杂度),而第3步并查集操作的复杂度是O(|E|α(|V|))。由于这几步是顺序执行的,整个算法的时间复杂度应该取上面三个的最大者,即O(|E|log|E|)。

下面还是举个例题来说明。

应用举例

LeetCode上好像没几个MST的题目,从当年整天刷的POJ挑一个简单的吧。

POJ 1251:Jungle Roads

纯粹的MST题目,输入各个村庄之间道路的长度,求能够将这些村庄全部连接起来的最小路径总长。

根据题意,图的顶点数小于27,边数小于75,规模也很小,所以Kruskal+路径压缩并查集就可以水过去了。代码如下。

const int MAXN = 101;
int n, m, cnt, w;
char u[4], v[4];
int parent[MAXN];

struct Edge {
  int u, v, w;
}e[MAXN];

bool comp(Edge a, Edge b) {
  return a.w < b.w;
}

int find(int x) {
  return x == parent[x] ? x : parent[x] = find(parent[x]);
}

void unite(int x, int y) {
  x = find(x);
  y = find(y);
  parent[x] = y;
}

int kruskal(int n) {
  int result = 0;
  for (int i = 0; i < n; i++) {
    if (find(e[i].u) != find(e[i].v)) {
      unite(e[i].u, e[i].v);
      result += e[i].w;
    }
  }
  return result;
}

int main() {
  while (scanf("%d", &n) && n) {
    cnt = 0;
    for (int i = 0; i < n; i++) {
      parent[i] = i;
    }
    for (int i = 0; i < n - 1; i++) {
      scanf("%s%d", u, &m);
      for (int j = 0; j < m; j++) {
        scanf("%s%d", v, &w);
        e[cnt++] = {u[0] - 'A', v[0] - 'A', w};
      }
    }
    sort(e, e + cnt, comp);
    printf("%d\n", kruskal(cnt));
  }
  return 0;
}

成功AC,民那晚安。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 159,117评论 4 362
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,328评论 1 293
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,839评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,007评论 0 206
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,384评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,629评论 1 219
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,880评论 2 313
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,593评论 0 198
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,313评论 1 243
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,575评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,066评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,392评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,052评论 3 236
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,082评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,844评论 0 195
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,662评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,575评论 2 270