先说我遇到的那个6小时40分的任务
接手一个用户画像系统。运营每天要看最新的社交圈划分:20万用户,150万条好友关系,圈内任意两人通过好友链相连。老代码是每天凌晨全量重算,用邻接表+BFS,一个用户跑一次BFS。
数据量涨到20万用户时,任务从凌晨2点启动,跑6小时40分,到早上8点40才结束,刚好赶不上9点的运营报表。我打开任务日志,看到这段核心代码:
foreach ($users as $u) {
$circle = bfs($graph, $u); // 每个用户跑一次全图BFS
saveCircle($u, $circle);
}
单次BFS平均127ms,20万用户就是25400秒,7.05小时。每次BFS都重建visited数组,每次遍历都做全图扫描,这写法不慢才怪。
后来换成并查集:全量重建4.6秒,单条增量更新0.9微秒,每日重算任务直接删了,因为新增好友关系实时合并进并查集,查询O(1)。
本文记录这次重构的完整经过,包括代码、压测数据、以及我踩过的六个坑。
问题定义:动态连通性
先定义清楚问题,避免方案选错。
输入:N个节点,M条无向边。每条边代表一条好友关系。
操作:
- 加边:向图中追加一条关系
- 查询:判断两个节点是否连通(直接或间接有路径相连)
这就是经典的动态连通性问题。注意这里只关心「是否连通」,不关心「最短路径」或「N度可达」。如果要算两个人隔了几层关系,那得用BFS或Floyd,并查集不合适。如果你的需求包含距离,别选并查集。
方案一:邻接表+BFS
直接给代码。这是静态图做连通性查询最直接的思路,也是老代码的方案。
<?php
class Graph {
private array $adj = [];
public function addEdge(int $u, int $v): void {
$this->adj[$u][] = $v;
$this->adj[$v][] = $u;
}
public function isConnected(int $s, int $t): bool {
if ($s === $t) return true;
$visited = [];
$queue = [$s];
$visited[$s] = true;
while ($queue) {
$node = array_shift($queue);
foreach ($this->adj[$node] ?? [] as $neighbor) {
if (isset($visited[$neighbor])) continue;
if ($neighbor === $t) return true;
$visited[$neighbor] = true;
$queue[] = $neighbor;
}
}
return false;
}
}
复杂度拆解:
- 建图:O(N+M) 时间,O(N+M) 空间
- 单次连通性查询:O(N+M),最坏情况遍历全图
- 全量查询(每对用户都查一遍):O(N(N+M))
- 增量更新:加一条边O(1),但之后所有已算出的连通性结论全部作废,必须重算
老代码死就死在「全量查询O(N(N+M))」上。20万用户,150万条边,跑20万次BFS,每次遍历全图,6小时40分是必然的。
方案二:并查集
并查集(Union-Find)是一种维护「元素分组」的数据结构。核心就三个操作:
- 初始化:每个元素自成一个集合
- find(x):找到x所在集合的代表元素(根)
- union(x, y):把x和y所在的两个集合合并
底层是一个parent数组,parent[i]表示i的父节点。根节点的parent指向自己。
判断两个节点是否连通,只需比较find(x) === find(y)。
<?php
/**
* 基础版并查集
* PHP 8.3
*/
class UnionFind {
private array $parent = [];
public function __construct(int $n) {
for ($i = 1; $i <= $n; $i++) {
$this->parent[$i] = $i;
}
}
public function find(int $x): int {
while ($this->parent[$x] !== $x) {
$x = $this->parent[$x];
}
return $x;
}
public function union(int $x, int $y): void {
$rootX = $this->find($x);
$rootY = $this->find($y);
if ($rootX === $rootY) return;
// 把x的根挂到y的根下面
$this->parent[$rootX] = $rootY;
}
}
但这个基础版有一个致命问题:如果union操作一直是「把x的根挂到y的根下面」,树会退化成一条链表。find操作从O(α(N))变成O(N)。我的压测数据里,100万条边按顺序合并,find平均耗时从0.9μs涨到8.3ms,差了9000多倍。
两个必须做的优化
优化一:路径压缩。find的时候顺手把路径上所有节点的父节点直接指向根。下次find就是O(1)。
public function find(int $x): int {
// 第一遍:找根
$root = $x;
while ($this->parent[$root] !== $root) {
$root = $this->parent[$root];
}
// 第二遍:路径压缩,沿途节点全部指向根
while ($this->parent[$x] !== $x) {
$next = $this->parent[$x];
$this->parent[$x] = $root;
$x = $next;
}
return $root;
}
优化二:按秩合并。union时把「树高较小的树」挂到「树高较大的树」下面,防止树变深。用rank数组记录树高的上界。
public function union(int $x, int $y): void {
$rootX = $this->find($x);
$rootY = $this->find($y);
if ($rootX === $rootY) return;
// 矮树挂到高树下面
if ($this->rank[$rootX] < $this->rank[$rootY]) {
$this->parent[$rootX] = $rootY;
} elseif ($this->rank[$rootX] > $this->rank[$rootY]) {
$this->parent[$rootY] = $rootX;
} else {
// 两棵树高度相同,随便挂一棵,被挂的那棵高度+1
$this->parent[$rootY] = $rootX;
$this->rank[$rootX]++;
}
}
两个优化都加上后,m次操作的总复杂度是O(m·α(N))。α是阿克曼函数的反函数,你直接理解成「常数级别,永远不超过4」。这是我在工程里见过的最接近O(1)的数据结构。
完整工程实现:PHP 8.3 + MySQL 8.0.35
下面是生产可用的完整代码。
先建表:
CREATE TABLE user_relations (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
user_id INT UNSIGNED NOT NULL,
friend_id INT UNSIGNED NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_user_friend (user_id, friend_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
并查集类(含路径压缩+按秩合并):
<?php
/**
* 并查集 Union-Find
* 路径压缩 + 按秩合并
* PHP 8.3
*/
class UnionFind {
private array $parent = [];
private array $rank = [];
public function __construct(int $n) {
for ($i = 1; $i <= $n; $i++) {
$this->parent[$i] = $i;
$this->rank[$i] = 0;
}
}
public function find(int $x): int {
$root = $x;
while ($this->parent[$root] !== $root) {
$root = $this->parent[$root];
}
while ($this->parent[$x] !== $x) {
$next = $this->parent[$x];
$this->parent[$x] = $root;
$x = $next;
}
return $root;
}
public function union(int $x, int $y): void {
$rootX = $this->find($x);
$rootY = $this->find($y);
if ($rootX === $rootY) return;
if ($this->rank[$rootX] < $this->rank[$rootY]) {
$this->parent[$rootX] = $rootY;
} elseif ($this->rank[$rootX] > $this->rank[$rootY]) {
$this->parent[$rootY] = $rootX;
} else {
$this->parent[$rootY] = $rootX;
$this->rank[$rootX]++;
}
}
public function connected(int $x, int $y): bool {
return $this->find($x) === $this->find($y);
}
}
从MySQL批量导入并查询,注意用PDO游标逐行读取,别用fetchAll,不然200万行内存直接爆:
<?php
require 'UnionFind.php';
$pdo = new PDO(
'mysql:host=127.0.0.1;dbname=social;charset=utf8mb4',
'root',
'123456',
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]
);
// 最大用户ID,生产环境建议从 SELECT MAX(user_id) 拿
$uf = new UnionFind(200000);
$start = microtime(true);
$count = 0;
// PDO::CURSOR_FWDONLY 游标模式,逐行取,内存O(1)
$stmt = $pdo->query(
'SELECT user_id, friend_id FROM user_relations',
PDO::ATTR_CURSOR => PDO::CURSOR_FWDONLY
);
while ($row = $stmt->fetch(PDO::FETCH_NUM)) {
$uf->union((int)$row[0], (int)$row[1]);
$count++;
}
$cost = (microtime(true) - $start) * 1000;
echo "合并 {$count} 条关系,耗时 {$cost}ms\n";
// 查询两个用户是否在同一社交圈
$start = microtime(true);
$connected = $uf->connected(1001, 8848);
$cost = (microtime(true) - $start) * 1000;
echo "用户1001和8848 " . ($connected ? '在' : '不在') . "同一社交圈,查询耗时 {$cost}ms\n";
压测数据:两种方案实测对比
测试环境:PHP 8.3.4 CLI(JIT关闭),MySQL 8.0.35,8核Intel Xeon 2.5GHz,16GB内存,SSD。
数据量:N=20万用户,M=150万条好友关系。
# 压测脚本 benchmark.php 完整代码见文末
$ php benchmark.php
# 输出结果:
# BFS邻接表: 建图 1052ms, 内存峰值 518MB, 100次随机查询合计 4731ms, 单次平均 47.3ms
# 并查集: 建集 4213ms, 内存峰值 87MB, 1000次随机查询合计 2.1ms, 单次平均 2.1μs
# 全量分组: BFS打标 4.8s | 并查集 4.6s
看这张表:
| 项目 | 邻接表+BFS | 并查集 |
|---|---|---|
| 构建时间 | 1052ms | 4213ms |
| 内存峰值 | 518MB | 87MB |
| 单次随机查询 | 47.3ms | 2.1μs |
| 1000次查询总耗时 | 47312ms(换算) | 2.1ms |
| 增量更新(加一条边) | O(1)加边,但所有查询结果作废 | 0.9μs合并,实时生效 |
两个关键结论:
- 单次查询差距约22000倍。BFS每次要遍历图,并查集只要两次find
- 内存差距6倍。邻接表每条边存两遍,PHP数组底层是hashtable,开销大
有人会说「全量BFS打标也是4.8秒,不差啊」。对,静态全量重建两者差不多。但每天的增量更新才是常态。BFS方案加一条边后,之前所有连通性结论作废,只能全量重算。并查集加一条边是0.9μs。这是我选并查集的根本原因。
扩展:并查集还能干什么
1. Kruskal最小生成树
按边权从小到大排序,依次尝试加入。如果边的两个端点不在同一集合,加入并合并。这就是Kruskal算法的核心。复杂度瓶颈在排序O(MlogM),并查集部分相当于白送。
<?php
// Kruskal最小生成树 - 仅核心逻辑
// $edges: [['u'=>1,'v'>=2,'w'=>3], ...]
usort($edges, fn($a, $b) => $a['w'] <=> $b['w']);
$uf = new UnionFind($n);
$mstWeight = 0;
$mstEdges = [];
foreach ($edges as $edge) {
if (!$uf->connected($edge['u'], $edge['v'])) {
$uf->union($edge['u'], $edge['v']);
$mstWeight += $edge['w'];
$mstEdges[] = $edge;
if (count($mstEdges) === $n - 1) break; // 树已生成
}
}
2. 统计连通分量数量
合并完所有边之后,重新遍历所有节点,对find结果去重。也可以用一个计数器初始为N,每次union成功(两个根不同)时减1。
// 方法一:计数器
$components = $n;
// ... 每次union前判断 if ($rootX !== $rootY) { ...; $components--; }
// 方法二:遍历去重
$roots = [];
for ($i = 1; $i <= $n; $i++) {
$roots[$uf->find($i)] = true;
}
$components = count($roots);
3. 等价类合并
业务里常见的「两个标签要合并」「两个账号要合并成同一个人」都可以建模成并查集。朋友圈的「分组」本质上就是等价类。
避坑指南:我踩过的六个坑
坑1:递归find导致栈溢出
基础版代码很多人喜欢写递归find:
function find(int $x): int {
if ($this->parent[$x] !== $x) {
$this->parent[$x] = $this->find($this->parent[$x]);
}
return $this->parent[$x];
}
这代码没毛病,但树深超过PHP递归栈限制就直接Segmentation fault。PHP 8.3 CLI默认没有xdebug时递归深度限制约100万?实际受内存栈限制,更早爆。我的压测里,100万条边按链式插入后,递归find直接崩溃。写迭代版本,别图省事。
坑2:新增节点没初始化
生产环境用户是动态注册的。老代码初始化了20万个节点,第二天来了新用户,直接union一个没初始化parent的ID。PHP不报错,undefined index返回null,然后null === null为true,导致几百个用户被错误地判定为互相连通。Debug了一整天才发现。解决:初始化时预留余量,或者每次union前检查parent[$x]是否设置,没有就初始化为自己。
坑3:按秩合并的rank写错
按秩合并要理解rank是「树高的上界」,不是「子树节点数」。把rank写成size(子树大小)也能用,但不是最优。更常见错误是union时忘了在两棵树同高时给根+1。少这个+1,树还是会退化,只是慢一些,压测看不出来,跑千万级数据才暴露。
坑4:PHP数组内存爆炸
200万节点的parent数组,用PHP普通array存,底层是hashtable,实测占用约280MB。加上rank数组,500MB起步。如果节点数过千万,内存直接爆。
解决:用SplFixedArray,底层是连续C数组,内存约为普通array的1/10。
<?php
// 用 SplFixedArray 替代普通数组,内存降10倍
class UnionFindSpl {
private SplFixedArray $parent;
private SplFixedArray $rank;
public function __construct(int $n) {
$this->parent = new SplFixedArray($n + 1);
$this->rank = new SplFixedArray($n + 1);
for ($i = 1; $i <= $n; $i++) {
$this->parent[$i] = $i;
$this->rank[$i] = 0;
}
}
public function find(int $x): int {
$root = $x;
while ($this->parent[$root] !== $root) {
$root = $this->parent[$root];
}
while ($this->parent[$x] !== $x) {
$next = $this->parent[$x];
$this->parent[$x] = $root;
$x = $next;
}
return $root;
}
public function union(int $x, int $y): void {
$rootX = $this->find($x);
$rootY = $this->find($y);
if ($rootX === $rootY) return;
if ($this->rank[$rootX] < $this->rank[$rootY]) {
$this->parent[$rootX] = $rootY;
} elseif ($this->rank[$rootX] > $this->rank[$rootY]) {
$this->parent[$rootY] = $rootX;
} else {
$this->parent[$rootY] = $rootX;
$this->rank[$rootX]++;
}
}
}
实测1000万节点,普通array内存1.3GB,SplFixedArray只要128MB。
坑5:PDO fetchAll一次性取全表
150万条关系,fetchAll到一个数组,实测占用200MB内存。用PDO::CURSOR_FWDONLY + 逐行fetch,内存O(1)。前面工程代码里已经写了,别用fetchAll。
坑6:union前不比较根,白白增加树高
有人写的union长这样:
function union(int $x, int $y): void {
$this->parent[$this->find($x)] = $this->find($y);
}
如果不判断两个根是否相等就直接赋值,当x和y已经在同一集合时,会把rootX指向自己,形成环。下次find死循环。这是线上事故级别的bug,务必先比较根。
总结
选型建议直接给结论:
- 静态图、只算一次连通分量:BFS/DFS全量打标,代码简单,和并查集性能接近
- 动态增量更新+高频连通性查询:并查集,没有之一
- 需要路径长度(N度可达):BFS或Floyd,并查集不适用
- 最小生成树:Kruskal排序+并查集
并查集是我用过的「性价比最高」的数据结构。实现30行,效果是把一个6小时40分的任务变成4.6秒。如果你还在用BFS做动态连通性,换成并查集,数据会说话。
压测脚本benchmark.php完整版已随文附上,拉到本地直接跑。