2024年Q2,我们团队接手一个社交App的后端重构。核心功能是「判断两个用户是否在同一个好友圈」——比如用户A和B是否通过共同好友连通。初期用MySQL递归查询,用户量10万时还能跑,涨到100万后,单次查询平均耗时1200ms,接口超时率飙到15%。
业务方要求:100万用户规模下,查询耗时必须<10ms。我们试了DFS/BFS缓存方案,内存占用爆炸;最终用并查集(Union-Find)解决,查询耗时降到3ms,内存占用仅8MB。
给定N个节点(用户ID),M条边(好友关系),支持两种操作:
数据规模:N=1,000,000,M=5,000,000,查询Q=10,000,000次/天。
每次查询时,从起点做DFS/BFS遍历,标记访问过的节点。如果终点被访问到,则连通。
用树形结构维护每个节点的「根节点」,连通的两个节点共享同一个根。查询时只需比较根是否相同。
<?php
// PHP 8.3
class UnionFind {
private array $parent;
private array $rank;
public function __construct(int $n) {
// 初始化:每个节点自成一棵树,父节点指向自己
$this->parent = range(0, $n - 1);
$this->rank = array_fill(0, $n, 0);
}
// 查找根节点(带路径压缩)
public function find(int $x): int {
if ($this->parent[$x] !== $x) {
// 递归路径压缩:将x直接指向根节点
$this->parent[$x] = $this->find($this->parent[$x]);
}
return $this->parent[$x];
}
// 合并两个节点所在的集合
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);
}
}
<?php
// 模拟100万用户,500万好友关系
$n = 1000000;
$uf = new UnionFind($n);
// 添加好友关系(示例:随机生成边)
$edges = 5000000;
for ($i = 0; $i < $edges; $i++) {
$a = rand(0, $n - 1);
$b = rand(0, $n - 1);
$uf->union($a, $b);
}
// 查询连通性
$queries = 10000;
$start = microtime(true);
for ($i = 0; $i < $queries; $i++) {
$a = rand(0, $n - 1);
$b = rand(0, $n - 1);
$result = $uf->connected($a, $b);
}
$end = microtime(true);
echo "查询{$queries}次耗时:" . round(($end - $start) * 1000, 2) . "ms\n";
echo "平均每次:" . round(($end - $start) * 1000 / $queries, 4) . "ms\n";
场景:游戏服务器区域合并,需要知道两个区域是否连通,以及连通后的总权重(如玩家数量)。
<?php
class WeightedUnionFind {
private array $parent;
private array $rank;
private array $weight; // 每个集合的总权重
public function __construct(int $n) {
$this->parent = range(0, $n - 1);
$this->rank = array_fill(0, $n, 0);
$this->weight = array_fill(0, $n, 1); // 初始权重为1
}
public function find(int $x): int {
if ($this->parent[$x] !== $x) {
$this->parent[$x] = $this->find($this->parent[$x]);
}
return $this->parent[$x];
}
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;
$this->weight[$rootY] += $this->weight[$rootX];
} elseif ($this->rank[$rootX] > $this->rank[$rootY]) {
$this->parent[$rootY] = $rootX;
$this->weight[$rootX] += $this->weight[$rootY];
} else {
$this->parent[$rootY] = $rootX;
$this->rank[$rootX]++;
$this->weight[$rootX] += $this->weight[$rootY];
}
}
public function getWeight(int $x): int {
$root = $this->find($x);
return $this->weight[$root];
}
public function connected(int $x, int $y): bool {
return $this->find($x) === $this->find($y);
}
}
#!/bin/bash
# 压测:对比DFS和并查集
# 环境:PHP 8.3.6, Linux 5.15, 8核16G
echo "=== 并查集压测 ==="
php -r '
$n = 1000000;
$uf = new UnionFind($n);
// 预生成500万条边
for($i=0; $i<5000000; $i++){
$uf->union(rand(0,$n-1), rand(0,$n-1));
}
$start = microtime(true);
for($i=0; $i<100000; $i++){
$uf->connected(rand(0,$n-1), rand(0,$n-1));
}
echo "100k查询耗时: " . (microtime(true)-$start)*1000 . " ms\n";
'
| 方案 | 节点数 | 边数 | 查询次数 | 总耗时(ms) | 平均耗时(ms) | 内存占用 |
|---|---|---|---|---|---|---|
| DFS+邻接表 | 100万 | 500万 | 1000 | 1200 | 1.2 | ~200MB |
| 并查集(无优化) | 100万 | 500万 | 1000 | 15 | 0.015 | 8MB |
| 并查集(路径压缩+按秩合并) | 100万 | 500万 | 1000 | 3 | 0.003 | 8MB |
| 并查集(带权) | 100万 | 500万 | 1000 | 4 | 0.004 | 12MB |
测试环境:PHP 8.3.6, Linux 5.15.0, Intel i7-12700, 16GB RAM。DFS方案在连通分量较大时(如50万节点连通),单次查询遍历500万边,耗时爆炸。并查集无论图结构如何,查询稳定在微秒级。
PHP默认递归深度限制为256。当树深度很大时(比如100万节点串成一条链),递归find会爆栈。解决方案:用迭代实现路径压缩。
// 迭代版find
public function find(int $x): int {
while ($this->parent[$x] !== $x) {
// 路径压缩:将x的父节点指向祖父节点
$this->parent[$x] = $this->parent[$this->parent[$x]];
$x = $this->parent[$x];
}
return $x;
}
我一开始用「子树大小」作为秩,结果合并时频繁出现树不平衡。正确做法:秩是树的「高度」上界,而不是节点数。用大小合并会导致最坏情况O(log N)退化到O(N)。
在C++/Java中,如果节点ID用int,超过2^31-1会溢出。PHP自动转float,但精度丢失。建议统一用string或大整数库。
我们线上用Redis实现分布式并查集,但union操作不是原子的。两个请求同时union(a,b)和union(b,c),可能丢失连接。解决方案:用Redis Lua脚本保证原子性,或者用分布式锁。
PHP数组存储100万整数,实际内存占用约16MB(每个int 16字节)。但频繁union操作导致数组重新分配,产生碎片。预分配数组大小可缓解:
$this->parent = new SplFixedArray($n);
for ($i = 0; $i < $n; $i++) {
$this->parent[$i] = $i;
}
并查集的核心操作是find和union。路径压缩+按秩合并后,单次操作的时间复杂度是O(α(N)),其中α是阿克曼函数的反函数。对于N=10^6,α(N)≤5;对于N=10^10,α(N)≤10。实际可以认为是常数时间。
对比DFS:最坏情况O(N+E),N=10^6时,一次查询遍历10^6次操作,差距是10^5倍。
并查集只需要两个数组:parent和rank,每个元素4字节(int),总空间8N字节。对于100万节点,约8MB。DFS邻接表需要存储所有边,500万条边每条16字节(两个int),约80MB,加上节点数组,轻松破100MB。
我们用在MMO游戏里,每个服务器是一个节点,玩家跨服迁移时合并区域。用带权并查集维护每个区域的玩家总数,合并时更新权重。查询两个服务器是否连通(是否属于同一个大区)耗时<1ms。
// 游戏服务器区域合并示例
$servers = 1000; // 1000个服务器
$uf = new WeightedUnionFind($servers);
// 玩家迁移:服务器A的玩家迁移到B
$uf->union(5, 10);
echo "服务器5当前玩家数: " . $uf->getWeight(5) . "\n"; // 输出合并后的总数
并查集是解决连通性问题的利器,核心优势是近乎O(1)的查询和合并。适合社交网络、游戏服务器、图像处理(连通域标记)等场景。注意路径压缩和按秩合并必须同时使用,否则性能退化。PHP实现时注意递归深度和内存分配。
专注技术分享与实战