最近点对问题(Closest Pair Problem)是计算几何中最经典的问题之一:给定平面上 n 个点,找到欧氏距离最小的一对点。暴力枚举所有 C(n,2) 对的做法需要 O(n^2) 时间,而分治法可以将复杂度降至 O(n log n)——这一结果在 1975 年由 Shamos 和 Hoey 给出。更令人惊讶的是,Rabin 在 1976 年提出了一种随机化方法,可以在期望 O(n) 时间内解决这个问题。
本篇从暴力解法出发,逐步推导分治算法的正确性(特别是中间带最多检查 7 个邻居的证明),然后深入 Rabin 随机化算法的网格哈希思想,最后将视角拉高到随机增量构造(RIC)和 Clarkson-Shor 技术等通用框架,讨论高维最近邻的困难以及工程实践中的注意事项。
一、问题定义与暴力基线
1.1 形式化定义
给定平面上 n 个点的集合 P = {p_1, p_2, …, p_n},其中 p_i = (x_i, y_i)。定义两点 p_i, p_j 之间的欧氏距离为:
d(p_i, p_j) = sqrt((x_i - x_j)^2 + (y_i - y_j)^2)
目标:找到一对点 (p_i, p_j)(i ≠ j)使得 d(p_i, p_j) 最小。
需要注意几个边界情形:
- 当 n < 2 时,问题无意义。
- 当存在重合点(距离为 0)时,答案就是任意一对重合点。
- 在实际浮点运算中,距离比较通常使用距离的平方来避免开方运算。
1.2 暴力算法
最直接的方法是枚举所有点对:
double brute_force(Point *pts, int n, int *idx1, int *idx2) {
double min_dist = DBL_MAX;
for (int i = 0; i < n; i++) {
for (int j = i + 1; j < n; j++) {
double d = dist_sq(pts[i], pts[j]);
if (d < min_dist) {
min_dist = d;
*idx1 = i;
*idx2 = j;
}
}
}
return min_dist;
}时间复杂度 O(n^2),空间复杂度 O(1)。当 n 在千万级别时,这个方法完全不可接受。
1.3 下界讨论
在代数决策树模型(algebraic decision tree model)下,最近点对问题的下界是 Omega(n log n)。这个下界可以通过规约元素唯一性问题(element uniqueness problem)来证明:如果我们能在 o(n log n) 时间内找到最近点对,就能在 o(n log n) 时间内判断一组数是否存在重复元素——而后者的下界是 Omega(n log n)。
然而,随机化算法可以绕过这个下界,因为代数决策树模型不允许随机性和哈希操作。Rabin 算法正是利用了这一点。
二、分治算法:O(n log n) 的经典方案
2.1 算法思路
分治法的核心思想如下:
- 预排序:将所有点按 x 坐标排序。
- 分割:用一条竖直线(通常取中位数点的 x 坐标)将点集分为左右两半,各包含 n/2 个点。
- 递归:分别递归求解左半部分和右半部分的最近点对,得到距离 delta_L 和 delta_R。
- 合并:令 delta = min(delta_L, delta_R)。最近点对可能跨越分割线,一个在左半部分、一个在右半部分。只需要在距离分割线不超过 delta 的”中间带”(strip)内寻找可能更近的点对。
- 中间带处理:将中间带内的点按 y 坐标排序,对每个点只需检查其后续的若干个邻居。
2.2 中间带最多检查 7 个邻居的证明
这是分治法的核心引理,也是整个算法正确性和复杂度分析的关键。
引理:在中间带内,对于按 y 坐标排序后的点序列,任意一个点 p 最多只需与其后续 7 个点比较。
证明:
考虑以分割线为中心、宽 2*delta 的竖直带。设 p 是中间带内的一个点,我们考虑 y 坐标在 [p.y, p.y + delta] 范围内且在中间带内的所有点。
这些点都落在一个 2*delta x delta 的矩形内。将这个矩形沿分割线分为两个 delta x delta 的正方形。
关键观察:在每个 delta x delta 的正方形内,任意两点之间的距离至少为 delta(因为同侧的最近距离已经是 delta_L 或 delta_R,而 delta = min(delta_L, delta_R))。
在一个边长为 delta 的正方形内,能放下多少个两两距离至少为 delta 的点?答案是最多 4 个(将正方形等分为 4 个 delta/2 x delta/2 的小正方形,每个小正方形的对角线长度为 delta/sqrt(2) < delta,所以每个小正方形内最多一个点)。
因此:
- 左半边的 delta x delta 正方形内最多 4 个点。
- 右半边的 delta x delta 正方形内最多 4 个点。
- 总共最多 8 个点(包括 p 自身)。
- 去掉 p 自身,最多需要检查 7 个邻居。
这保证了中间带处理的时间复杂度是 O(n),前提是中间带内的点已经按 y 排序。
2.3 递推关系与复杂度
算法的时间复杂度满足递推:
T(n) = 2T(n/2) + O(n)
其中 O(n) 来自中间带的处理(按 y 排序可以通过归并排序在递归过程中顺带完成,不需要额外的 O(n log n))。
由主定理,T(n) = O(n log n)。
2.4 实现细节
分治法实现中有几个关键细节:
- 基础情形:当 n <= 3 时直接暴力计算。
- 归并排序与 y 排序的结合:在递归返回时,将左右两半按 y 坐标归并排序。这样中间带提取出来的点自然就是按 y 排序的。
- 浮点精度:比较距离时使用距离的平方,避免不必要的开方。
- 去重处理:如果有大量重合点,算法可以在找到距离为 0 时提前终止。
下面的示意图展示了分治过程中的中间带检查:
三、分治算法的完整 C 实现
#include <stdio.h>
#include <stdlib.h>
#include <float.h>
#include <math.h>
#include <string.h>
typedef struct {
double x, y;
int id; /* 原始编号,方便追踪 */
} Point;
typedef struct {
int i, j; /* 最近点对的索引 */
double dist_sq; /* 距离的平方 */
} ClosestResult;
/* ---- 工具函数 ---- */
static inline double dist_sq(Point a, Point b) {
double dx = a.x - b.x;
double dy = a.y - b.y;
return dx * dx + dy * dy;
}
static int cmp_x(const void *a, const void *b) {
const Point *pa = (const Point *)a;
const Point *pb = (const Point *)b;
if (pa->x < pb->x) return -1;
if (pa->x > pb->x) return 1;
if (pa->y < pb->y) return -1;
if (pa->y > pb->y) return 1;
return 0;
}
static int cmp_y(const void *a, const void *b) {
const Point *pa = (const Point *)a;
const Point *pb = (const Point *)b;
if (pa->y < pb->y) return -1;
if (pa->y > pb->y) return 1;
if (pa->x < pb->x) return -1;
if (pa->x > pb->x) return 1;
return 0;
}
/* ---- 暴力求解小规模 ---- */
static ClosestResult brute_force(Point *pts, int n) {
ClosestResult res;
res.dist_sq = DBL_MAX;
res.i = res.j = -1;
for (int i = 0; i < n; i++) {
for (int j = i + 1; j < n; j++) {
double d = dist_sq(pts[i], pts[j]);
if (d < res.dist_sq) {
res.dist_sq = d;
res.i = pts[i].id;
res.j = pts[j].id;
}
}
}
return res;
}
/* ---- 分治核心 ---- */
static ClosestResult closest_pair_rec(Point *px, int n, Point *tmp) {
/* 基础情形:暴力求解 */
if (n <= 3) {
qsort(px, n, sizeof(Point), cmp_y);
return brute_force(px, n);
}
int mid = n / 2;
double mid_x = px[mid].x;
/* 递归求解左右两半 */
ClosestResult left = closest_pair_rec(px, mid, tmp);
ClosestResult right = closest_pair_rec(px + mid, n - mid, tmp);
/* 取较小者 */
ClosestResult best = (left.dist_sq <= right.dist_sq) ? left : right;
/* 归并:按 y 坐标合并左右两半(此时两半各自已按 y 排序) */
int i = 0, j = mid, k = 0;
while (i < mid && j < n) {
if (px[i].y <= px[j].y)
tmp[k++] = px[i++];
else
tmp[k++] = px[j++];
}
while (i < mid) tmp[k++] = px[i++];
while (j < n) tmp[k++] = px[j++];
memcpy(px, tmp, n * sizeof(Point));
/* 提取中间带内的点 */
int strip_cnt = 0;
for (int s = 0; s < n; s++) {
double dx = px[s].x - mid_x;
if (dx * dx < best.dist_sq) {
tmp[strip_cnt++] = px[s];
}
}
/* 在中间带内检查——每个点最多看后续 7 个 */
for (int s = 0; s < strip_cnt; s++) {
for (int t = s + 1; t < strip_cnt && t <= s + 7; t++) {
double dy = tmp[t].y - tmp[s].y;
if (dy * dy >= best.dist_sq) break;
double d = dist_sq(tmp[s], tmp[t]);
if (d < best.dist_sq) {
best.dist_sq = d;
best.i = tmp[s].id;
best.j = tmp[t].id;
}
}
}
return best;
}
/* ---- 对外接口 ---- */
ClosestResult closest_pair_dc(Point *pts, int n) {
/* 复制一份,避免修改原数组 */
Point *px = (Point *)malloc(n * sizeof(Point));
Point *tmp = (Point *)malloc(n * sizeof(Point));
memcpy(px, pts, n * sizeof(Point));
/* 按 x 排序 */
qsort(px, n, sizeof(Point), cmp_x);
ClosestResult res = closest_pair_rec(px, n, tmp);
free(px);
free(tmp);
return res;
}几个关键实现要点:
- 用
dist_sq代替dist省去了开方运算,仅在最终输出时开方。 - 递归过程中顺带做归并排序,使得中间带的点自然按 y 排序,避免了额外的 O(n log n) 排序开销。
tmp数组在递归的不同层共享,但由于同一层不会同时使用同一段内存,所以是安全的——不过实现时需要小心。这里我们用了一个与px等长的临时数组,在归并和中间带提取中复用。- 中间带检查用了
t <= s + 7的循环上界和dy * dy >= best.dist_sq的提前退出条件,双重保障正确性。
四、Rabin 随机化算法:期望 O(n)
4.1 网格哈希的基本思想
Rabin 算法的核心观察是:如果我们已经知道了一个距离上界 delta,就可以将平面划分为边长为 delta 的网格。此时最近点对一定位于同一个网格单元或相邻的网格单元中。对于每个点,只需检查其所在网格和周围 8 个相邻网格中的点,就能找到比 delta 更近的点对(如果存在的话)。
将每个点映射到网格单元的操作是 O(1)(用哈希表存储),遍历所有点并检查邻居的总代价是 O(n)——因为每个点只检查常数个邻居格子。
问题在于:初始的 delta 从哪里来?
4.2 两轮采样策略
Rabin 算法使用两轮采样来获得一个好的初始 delta:
第一轮:从 n 个点中均匀随机采样 sqrt(n) 个点(不放回),暴力计算这些样本点的最近点对距离 delta_1。
- 采样 sqrt(n) 个点,暴力计算需要 O(sqrt(n)^2) = O(n) 时间。
- delta_1 是所有 n 个点的最近点对距离的一个上界(采样点的最近距离不会小于全集的最近距离)。
构建网格:以 delta_1 为边长构建网格,将所有 n 个点插入网格。
第二轮:对于每个点,检查其所在网格和相邻 8 个网格中的所有点,更新最近距离。
算法伪代码:
RABIN-CLOSEST-PAIR(P):
n = |P|
随机打乱 P
取前 m = ceil(sqrt(n)) 个点作为样本 S
delta = BRUTE_FORCE(S)
if delta == 0:
return 0 /* 存在重合点 */
构建边长为 delta 的网格 G
将 P 中所有点插入 G
for each point p in P:
for each 相邻格子 c (包括自身所在格子):
for each point q in c:
if p != q:
delta = min(delta, dist(p, q))
return delta
4.3 为什么期望线性:向后分析
证明 Rabin 算法期望 O(n) 的关键是向后分析(backward analysis)。
设真正的最近点对距离为 delta。我们需要证明:第一轮采样得到的 delta_1 不会比 delta 大太多,从而网格不会太粗糙,每个格子内的期望点数是 O(1)。
更精确地说,我们需要证明:当以 delta_1 为边长建网格时,格子中点的总数的期望是 O(n)。
向后分析的论证:
考虑将 n 个点按随机顺序逐一插入。设 delta_i 是前 i 个点的最近点对距离。当我们插入第 i 个点时,“当前的最近距离变小”这一事件等价于”第 i 个点参与了前 i 个点中的最近点对”。
对于 i 个随机排列的点中的最近点对,参与最近点对的两个点各有 1/i 的概率是最后插入的那个。因此最近距离因第 i 次插入而减小的概率最多为 2/i。
基于这个思想,可以推导出:
- 网格中非空格子的期望数量是 O(n)。
- 每个非空格子中期望包含 O(1) 个点。
- 因此遍历所有点并检查邻居格子的总代价期望是 O(n)。
完整的分析需要更加细致的概率论证,但向后分析给出了直觉上的理解。
4.4 Rabin 算法的两阶段改进
上述基本版本有一个问题:第一轮采样可能得到很差的 delta_1(虽然概率很小),导致第二轮扫描实际运行时间很差。一种改进是使用两阶段策略:
- 第一阶段:随机选 sqrt(n) 个点,暴力求最近对距离 delta_1。
- 以 delta_1 建网格,插入所有点并扫描,得到更精确的 delta_2。
- 如果需要,以 delta_2 重建网格,再扫描一次。
实际上,一轮就足够了——期望复杂度已经是 O(n)。两阶段策略的意义在于减小常数因子和方差。
4.5 Rabin 算法的完整 C 实现
#include <stdio.h>
#include <stdlib.h>
#include <float.h>
#include <math.h>
#include <string.h>
#include <time.h>
typedef struct {
double x, y;
int id;
} Point;
/* ---- 哈希网格 ---- */
typedef struct GridNode {
int pt_idx; /* 指向 pts 数组的下标 */
struct GridNode *next;
} GridNode;
typedef struct {
GridNode **buckets;
int capacity;
double cell_size;
} Grid;
/* 将 (gx, gy) 映射为哈希值 */
static unsigned int grid_hash(long long gx, long long gy, int cap) {
/* 用一个简单的乘法哈希 */
unsigned long long h = (unsigned long long)(gx * 1000000007LL + gy * 998244353LL);
h ^= (h >> 16);
h *= 0x45d9f3bULL;
h ^= (h >> 16);
return (unsigned int)(h % (unsigned long long)cap);
}
static Grid *grid_create(int capacity, double cell_size) {
Grid *g = (Grid *)malloc(sizeof(Grid));
g->capacity = capacity;
g->cell_size = cell_size;
g->buckets = (GridNode **)calloc(capacity, sizeof(GridNode *));
return g;
}
static void grid_destroy(Grid *g) {
for (int i = 0; i < g->capacity; i++) {
GridNode *node = g->buckets[i];
while (node) {
GridNode *tmp = node->next;
free(node);
node = tmp;
}
}
free(g->buckets);
free(g);
}
static void grid_insert(Grid *g, Point *pts, int idx) {
long long gx = (long long)floor(pts[idx].x / g->cell_size);
long long gy = (long long)floor(pts[idx].y / g->cell_size);
unsigned int h = grid_hash(gx, gy, g->capacity);
GridNode *node = (GridNode *)malloc(sizeof(GridNode));
node->pt_idx = idx;
node->next = g->buckets[h];
g->buckets[h] = node;
}
/* 查询 (gx, gy) 格子中的所有点,与 pt 比较 */
static double grid_query_cell(Grid *g, Point *pts, int pt_idx,
long long gx, long long gy,
double best_sq, int *best_j)
{
unsigned int h = grid_hash(gx, gy, g->capacity);
for (GridNode *node = g->buckets[h]; node; node = node->next) {
int j = node->pt_idx;
if (j == pt_idx) continue;
/* 需要确认这个点确实在 (gx, gy) 格子里(哈希冲突) */
long long jgx = (long long)floor(pts[j].x / g->cell_size);
long long jgy = (long long)floor(pts[j].y / g->cell_size);
if (jgx != gx || jgy != gy) continue;
double dx = pts[pt_idx].x - pts[j].x;
double dy = pts[pt_idx].y - pts[j].y;
double d = dx * dx + dy * dy;
if (d < best_sq) {
best_sq = d;
*best_j = j;
}
}
return best_sq;
}
/* 查询点 pt_idx 周围 3x3 格子 */
static double grid_query_neighbors(Grid *g, Point *pts, int pt_idx,
double best_sq, int *best_j)
{
long long gx = (long long)floor(pts[pt_idx].x / g->cell_size);
long long gy = (long long)floor(pts[pt_idx].y / g->cell_size);
for (int dx = -1; dx <= 1; dx++) {
for (int dy = -1; dy <= 1; dy++) {
best_sq = grid_query_cell(g, pts, pt_idx,
gx + dx, gy + dy,
best_sq, best_j);
}
}
return best_sq;
}
/* ---- Fisher-Yates 洗牌 ---- */
static void shuffle(int *arr, int n) {
for (int i = n - 1; i > 0; i--) {
int j = rand() % (i + 1);
int tmp = arr[i];
arr[i] = arr[j];
arr[j] = tmp;
}
}
/* ---- Rabin 随机化最近点对 ---- */
typedef struct {
int i, j;
double dist_sq;
} RabinResult;
RabinResult closest_pair_rabin(Point *pts, int n) {
RabinResult res;
res.dist_sq = DBL_MAX;
res.i = res.j = -1;
if (n < 2) return res;
srand((unsigned)time(NULL));
/* 生成随机排列 */
int *perm = (int *)malloc(n * sizeof(int));
for (int i = 0; i < n; i++) perm[i] = i;
shuffle(perm, n);
/* 第一轮:取 sqrt(n) 个样本,暴力求最近对 */
int m = (int)ceil(sqrt((double)n));
if (m > n) m = n;
double delta_sq = DBL_MAX;
int bi = -1, bj = -1;
for (int i = 0; i < m; i++) {
for (int j = i + 1; j < m; j++) {
double dx = pts[perm[i]].x - pts[perm[j]].x;
double dy = pts[perm[i]].y - pts[perm[j]].y;
double d = dx * dx + dy * dy;
if (d < delta_sq) {
delta_sq = d;
bi = perm[i];
bj = perm[j];
}
}
}
if (delta_sq == 0.0) {
res.dist_sq = 0.0;
res.i = bi;
res.j = bj;
free(perm);
return res;
}
double delta = sqrt(delta_sq);
/* 第二轮:建网格,插入所有点,查询邻居 */
int cap = 2 * n + 1;
Grid *g = grid_create(cap, delta);
for (int i = 0; i < n; i++) {
grid_insert(g, pts, i);
}
res.dist_sq = delta_sq;
res.i = bi;
res.j = bj;
for (int i = 0; i < n; i++) {
int best_j = -1;
double d = grid_query_neighbors(g, pts, i, res.dist_sq, &best_j);
if (d < res.dist_sq) {
res.dist_sq = d;
res.i = i;
res.j = best_j;
}
}
grid_destroy(g);
free(perm);
return res;
}五、随机增量构造(RIC)的一般范式
5.1 什么是 RIC
随机增量构造(Randomized Incremental Construction)是计算几何中一种通用的算法设计范式。其基本思想是:
- 将输入对象随机打乱。
- 逐一插入,每次插入后更新当前的”解”。
- 利用随机性保证每次更新的期望代价很小。
Rabin 的最近点对算法可以视为 RIC 的一个特例。更一般地,RIC 可以用于:
- 最小包围圆(最小外接圆)
- 线性规划(Seidel 的随机化算法)
- Delaunay 三角化
- 梯形分解(trapezoidal decomposition)
- 凸包
5.2 RIC 的一般模式
RIC(S):
随机排列 S = {s_1, s_2, ..., s_n}
初始化解 F_0 = 基于前 r 个对象的解(r 为常数)
for i = r+1 to n:
将 s_i 插入当前解 F_{i-1}
if s_i 导致冲突(conflict):
更新 F_i(可能需要重建部分结构)
else:
F_i = F_{i-1}
return F_n
5.3 冲突图(Conflict Graph)
RIC 的高效实现依赖于冲突图的维护。冲突图是一个二部图:
- 一侧是尚未插入的对象。
- 另一侧是当前解的组成部分(如三角形、梯形等)。
- 边表示”这个对象与这个解组件冲突”(即插入这个对象会影响这个组件)。
当新对象 s_i 被插入时:
- 通过冲突图找到所有与 s_i 冲突的组件(O(冲突数) 时间)。
- 删除这些组件,创建新组件。
- 更新冲突图(将未插入对象重新关联到新组件)。
如果每次更新的期望冲突数是常数,整个算法就是期望线性的。
5.4 最近点对的 RIC 视角
在最近点对问题中,RIC 可以这样理解:
- “当前解”是当前点集的最近距离 delta 和对应的网格。
- “插入一个点”就是把新点放进网格,检查其邻居。
- “冲突”就是新点与某个已有点的距离小于当前 delta。
- 如果发生冲突,就需要用新的 delta 重建网格。
关键问题:重建网格的代价是 O(i)(需要将前 i 个点重新插入新网格)。但期望重建次数是多少?
由向后分析,第 i 次插入导致最近距离变化的概率至多为 2/i。因此总期望重建代价为:
sum_{i=1}^{n} (2/i) * O(i) = sum_{i=1}^{n} O(1) = O(n)
这就是期望 O(n) 的来源。
六、Clarkson-Shor 技术:统一分析框架
6.1 动机
在许多计算几何问题中,我们需要分析”给定 n 个对象,某种几何结构的复杂度是多少”。Clarkson 和 Shor 在 1989 年提出了一种优雅的随机抽样框架,可以统一处理这类问题。
6.2 基本设定
设 H 是 n 个”半平面”(或超平面,更一般地是几何约束)的集合。对于 H 的一个子集 R,定义:
- 顶点(vertex):由恰好 d 个约束确定的点(d 是维度)。
- 级别(level):一个顶点的级别是”违反”它的约束数量。
- 0 级顶点:不被任何约束违反的顶点,即当前排列(arrangement)的顶点。
6.3 Clarkson-Shor 定理
定理:设 R 是从 H 中均匀随机选出的 r 个元素。设 f_0(R) 是 R 确定的 0 级顶点数量,f_{<=k}(H) 是 H 中级别不超过 k 的顶点数量。则:
E[f_0(R)] = Theta(f_{<=n/r}(H) * (r/n)^d)
这个定理的意义在于:通过随机抽样,我们可以从全集的组合复杂度推导出样本的期望复杂度,反之亦然。
6.4 应用举例
最近点对:
- 将 n 个点中的 C(n,2) 个距离看作”约束”。
- 一对点的”级别”是比它更近的点对数量。
- 0 级点对就是最近点对。
- Clarkson-Shor 框架可以分析随机采样 r 个点后,采样点的最近距离期望有多好。
Delaunay 三角化:
- n 个点的 Delaunay 三角化有 O(n) 个三角形。
- 随机增量构造每步期望代价 O(1),总代价 O(n log n)(因为还需要点定位)。
线段求交:
- n 条线段的交点可能有 O(n^2) 个。
- Clarkson-Shor 框架给出了输出敏感的 O(n log n + k) 算法的基础(k 为交点数)。
6.5 与最近点对分析的联系
用 Clarkson-Shor 的语言重新表述 Rabin 算法的分析:
随机选取 r = sqrt(n) 个点作为样本 R。R 的最近距离 delta_R 满足:以 delta_R 为半径的圆盘内,全集 H 中期望有 O(n/r) = O(sqrt(n)) 个点。
因此,以 delta_R 为边长的网格中,每个格子期望只有 O(1) 个点。总共 n 个点,遍历邻居的总代价是 O(n)。
这种分析比直接的向后分析更加优雅,且能自然地推广到更复杂的问题。
七、高维最近邻的困难:维度诅咒
7.1 维度诅咒概述
上述 O(n log n) 的分治算法和 O(n) 的随机化算法都是针对二维平面的。当维度 d 增大时,情况会急剧恶化。
分治法的推广:
在 d 维空间中,分治法的中间带处理变为:每个点最多需要检查 O(C(d)) 个邻居,其中 C(d) 是一个与维度相关的常数,它随 d 指数增长。具体来说,中间带内的 delta x … x delta(d-1 维)的超矩形中能放下的两两距离至少为 delta 的点数,是关于 d 的指数函数。
分治法的递推关系变为:
T(n) = 2T(n/2) + O(n * C(d))
解为 O(C(d) * n log n),其中 C(d) = 2^{O(d)}。
网格哈希的推广:
在 d 维空间中,每个格子有 3^d - 1 个邻居格子需要检查。当 d = 20 时,3^20 约等于 35 亿——这完全不可行。
7.2 近似最近邻
由于精确最近邻在高维下的困难,实际应用中常常转向近似最近邻(Approximate Nearest Neighbor, ANN):
- 局部敏感哈希(Locality-Sensitive Hashing, LSH):通过随机投影将高维点映射到低维空间,保证近距离的点有较高概率映射到同一桶。可以在 O(n^{1+epsilon}) 空间和 O(n^{epsilon}) 查询时间内实现 (1+delta)-近似。
- 随机投影:Johnson-Lindenstrauss 引理保证,将 n 个高维点投影到 O(log n / epsilon^2) 维空间,可以 (1 +/- epsilon) 倍保持所有点对距离。
7.3 实际维度与内在维度
在实际数据中,虽然名义维度可能很高(如图像数据可能有数千维),但数据往往集中在一个低维流形上。这个”内在维度”(intrinsic dimension)通常远小于名义维度。许多数据结构的实际性能取决于内在维度而非名义维度。
倍增维度(doubling dimension)是一种常用的内在维度度量:一个度量空间的倍增维度是最小的 d,使得任何半径为 r 的球都可以被 2^d 个半径为 r/2 的球覆盖。在倍增维度为 d 的空间中,存在 2^{O(d)} * n log n 的最近邻算法。
八、k-d 树与球树
8.1 k-d 树
k-d 树是一种经典的空间划分数据结构,特别适合低维最近邻查询。
构造:
- 在根节点按第一个维度的中位数分割。
- 在第二层按第二个维度的中位数分割。
- 依此类推,交替选择分割维度。
- 构造时间 O(n log n),空间 O(n)。
最近邻查询:
- 从根节点开始递归搜索,先搜索查询点所在的一侧。
- 利用当前最优距离进行剪枝:如果查询点到分割超平面的距离已经大于当前最优,则无需搜索另一侧。
- 最坏情况 O(n),但在低维且数据分布均匀时,期望 O(log n)。
typedef struct KDNode {
Point pt;
int split_dim;
struct KDNode *left;
struct KDNode *right;
} KDNode;
/* 构建 k-d 树(二维情形) */
KDNode *kd_build(Point *pts, int n, int depth) {
if (n <= 0) return NULL;
int dim = depth % 2; /* 0 = x, 1 = y */
/* 选中位数作为根 */
int mid = n / 2;
/* 用 nth_element 的思路,这里简化为排序 */
if (dim == 0)
qsort(pts, n, sizeof(Point), cmp_x);
else
qsort(pts, n, sizeof(Point), cmp_y);
KDNode *node = (KDNode *)malloc(sizeof(KDNode));
node->pt = pts[mid];
node->split_dim = dim;
node->left = kd_build(pts, mid, depth + 1);
node->right = kd_build(pts + mid + 1, n - mid - 1, depth + 1);
return node;
}
/* 最近邻查询 */
void kd_nearest(KDNode *root, Point query, double *best_sq, Point *best_pt) {
if (!root) return;
double d = dist_sq(root->pt, query);
if (d < *best_sq) {
*best_sq = d;
*best_pt = root->pt;
}
int dim = root->split_dim;
double diff = (dim == 0) ? query.x - root->pt.x : query.y - root->pt.y;
KDNode *near_side = (diff < 0) ? root->left : root->right;
KDNode *far_side = (diff < 0) ? root->right : root->left;
kd_nearest(near_side, query, best_sq, best_pt);
/* 剪枝:分割面距离的平方 */
if (diff * diff < *best_sq) {
kd_nearest(far_side, query, best_sq, best_pt);
}
}8.2 球树(Ball Tree)
球树使用最小包围球来划分空间,而非轴对齐的超平面。
优点:
- 对于分布不均匀的数据,球树的边界通常更紧致。
- 在高维空间中,球树的剪枝效果通常优于 k-d 树。
缺点:
- 构造代价较高(需要计算最小包围球)。
- 球之间可能有重叠,查询时需要同时搜索两个子球。
8.3 k-d 树与球树的对比
| 方面 | k-d 树 | 球树 |
|---|---|---|
| 分割方式 | 轴对齐超平面 | 最小包围球 |
| 构造时间 | O(n log n) | O(n log n) |
| 查询(低维) | O(log n) 期望 | O(log n) 期望 |
| 查询(高维) | 退化到 O(n) | 比 k-d 树稍好 |
| 空间 | O(n) | O(n) |
| 适用场景 | 低维、均匀分布 | 中维、非均匀分布 |
| 实现复杂度 | 简单 | 中等 |
8.4 与最近点对的关系
k-d 树和球树主要用于查询场景:给定一个查询点,找到数据集中离它最近的点。而最近点对问题是在整个数据集内部找距离最小的一对。
两者的联系:
- 可以将最近点对问题转化为 n 次最近邻查询:对每个点 p_i,在其余 n-1 个点中找最近邻。总时间 O(n log n)(低维)。
- 但这不如分治法高效(常数因子更大)。
- k-d 树更适合在线场景:先建树,然后处理陆续到来的查询点。
- 分治法和 Rabin 算法更适合离线(batch)场景。
九、工程实践与陷阱
9.1 工程陷阱汇总表
| 陷阱 | 症状 | 解决方案 |
|---|---|---|
| 距离开方 | 大量 sqrt 调用拖慢速度 | 全程使用距离平方比较,仅最终输出时开方 |
| 浮点精度 | 边界判断出错(如 dx*dx < delta_sq 误判) | 加 epsilon 容差;或使用精确算术库 |
| 重合点 | 距离为 0 导致网格边长为 0、除零 | 特判:发现距离为 0 立即返回 |
| 哈希冲突 | 网格哈希中不同格子映射到同一桶 | 查询时验证格子坐标;使用更好的哈希函数 |
| 网格大小 | 哈希表容量不足导致链过长 | 容量设为 2n 以上,负载因子 < 0.5 |
| 整数溢出 | 格子坐标乘法溢出 | 使用 long long 或 double 计算格子坐标 |
| 随机种子 | 固定种子导致对特定输入退化 | 使用系统熵源(/dev/urandom)初始化 |
| 内存碎片 | 大量小块 malloc 导致缓存命中率低 | 预分配节点池(arena allocation) |
| 递归深度 | 分治法在极端不平衡情况下栈溢出 | 限制递归深度;小规模切换暴力法 |
| 排序稳定性 | 不稳定排序影响相同 x/y 坐标点的处理 | 使用稳定排序或增加二级排序键 |
| 数据退化 | 所有点共线导致中间带包含所有点 | 分治法仍正确,但实际性能退化到 O(n log^2 n) |
| SIMD 优化 | 距离计算未向量化 | 使用 SOA 布局,利用 SSE/AVX 并行计算 |
9.2 性能优化策略
缓存友好的内存布局:
传统的 AOS(Array of Structures)布局:
/* AOS: 每个点的 x, y 相邻 */
Point pts[N]; /* {x0,y0}, {x1,y1}, ... */SOA(Structure of Arrays)布局更利于 SIMD:
/* SOA: 所有 x 值连续,所有 y 值连续 */
double xs[N], ys[N];当需要批量计算距离时,SOA 布局可以利用 SIMD 指令一次处理 4 个(SSE)或 8 个(AVX)点对的距离计算。
小规模切换阈值:
分治法在 n <= 某个阈值时切换到暴力法。最佳阈值取决于硬件,通常在 16-64 之间。过小则递归开销大,过大则暴力法的 O(n^2) 开始显现。
#define BRUTE_THRESHOLD 32
static ClosestResult closest_pair_rec(Point *px, int n, Point *tmp) {
if (n <= BRUTE_THRESHOLD) {
qsort(px, n, sizeof(Point), cmp_y);
return brute_force(px, n);
}
/* ... 分治逻辑 ... */
}并行化:
分治法天然适合并行化:左右两半的递归求解可以并行执行。使用 OpenMP 或线程池,可以在多核 CPU 上获得近线性加速。
/* OpenMP 并行版本(伪代码) */
#pragma omp parallel sections
{
#pragma omp section
left = closest_pair_rec(px, mid, tmp_l);
#pragma omp section
right = closest_pair_rec(px + mid, n - mid, tmp_r);
}不过需要注意:并行化引入了额外的同步开销,只有在 n 足够大时才有意义。
十、算法对比与选择指南
10.1 三种算法的对比
| 维度 | 暴力法 | 分治法 | Rabin 随机化 |
|---|---|---|---|
| 时间复杂度 | O(n^2) | O(n log n) | O(n) 期望 |
| 最坏时间 | O(n^2) | O(n log n) | O(n^2) |
| 空间复杂度 | O(1) | O(n) | O(n) |
| 实现难度 | 简单 | 中等 | 较高(哈希表) |
| 确定性 | 是 | 是 | 否 |
| 适用维度 | 任意 | 低维(d <= 10) | 低维(d <= 5) |
| 实际小规模性能 | 好(n < 100) | 递归开销 | 哈希开销 |
| 常数因子 | 小 | 中等 | 较大 |
| 在线/离线 | 均可 | 离线 | 离线 |
| 并行友好 | 否 | 是 | 有限 |
10.2 选择建议
根据我的经验:
- n < 100:直接暴力。别折腾了。
- 100 < n < 10^6,低维:分治法。实现清晰,性能稳定,最坏情况有保障。
- n > 10^6,低维:Rabin 随机化。常数因子虽大,但线性与 n log n 的差距在大数据量下很显著。
- 高维:考虑近似算法(LSH)或使用 k-d 树/球树做精确查询。
- 在线查询:建 k-d 树或球树,然后逐个查询。
- 竞赛/面试:分治法是标准答案,务必能手写。
十一、完整测试驱动程序
下面给出一个完整的测试程序,同时运行分治法和 Rabin 算法并比较结果:
#include <stdio.h>
#include <stdlib.h>
#include <math.h>
#include <time.h>
#include <assert.h>
/* 假设上面的分治法和 Rabin 算法代码已经包含在内 */
/* ---- 测试生成器 ---- */
static Point *gen_random_points(int n, double range) {
Point *pts = (Point *)malloc(n * sizeof(Point));
for (int i = 0; i < n; i++) {
pts[i].x = ((double)rand() / RAND_MAX) * range;
pts[i].y = ((double)rand() / RAND_MAX) * range;
pts[i].id = i;
}
return pts;
}
static Point *gen_collinear_points(int n) {
Point *pts = (Point *)malloc(n * sizeof(Point));
for (int i = 0; i < n; i++) {
pts[i].x = (double)i;
pts[i].y = 0.0;
pts[i].id = i;
}
return pts;
}
static Point *gen_clustered_points(int n, int clusters) {
Point *pts = (Point *)malloc(n * sizeof(Point));
for (int i = 0; i < n; i++) {
int c = i % clusters;
double cx = c * 100.0;
double cy = c * 100.0;
pts[i].x = cx + ((double)rand() / RAND_MAX) * 10.0;
pts[i].y = cy + ((double)rand() / RAND_MAX) * 10.0;
pts[i].id = i;
}
return pts;
}
/* ---- 主测试 ---- */
int main(void) {
srand((unsigned)time(NULL));
int test_sizes[] = {10, 100, 1000, 10000};
int num_tests = sizeof(test_sizes) / sizeof(test_sizes[0]);
for (int t = 0; t < num_tests; t++) {
int n = test_sizes[t];
printf("=== 测试规模 n = %d ===\n", n);
Point *pts = gen_random_points(n, 1000.0);
/* 分治法 */
clock_t start = clock();
ClosestResult dc_res = closest_pair_dc(pts, n);
clock_t end = clock();
double dc_time = (double)(end - start) / CLOCKS_PER_SEC;
/* Rabin 随机化 */
start = clock();
RabinResult rb_res = closest_pair_rabin(pts, n);
end = clock();
double rb_time = (double)(end - start) / CLOCKS_PER_SEC;
printf(" 分治法: dist = %.10f, 点 (%d, %d), 耗时 %.6f s\n",
sqrt(dc_res.dist_sq), dc_res.i, dc_res.j, dc_time);
printf(" Rabin: dist = %.10f, 点 (%d, %d), 耗时 %.6f s\n",
sqrt(rb_res.dist_sq), rb_res.i, rb_res.j, rb_time);
/* 验证两种方法结果一致 */
double diff = fabs(dc_res.dist_sq - rb_res.dist_sq);
assert(diff < 1e-9 && "两种方法结果不一致!");
printf(" 验证通过。\n\n");
free(pts);
}
/* 退化测试:共线点 */
printf("=== 退化测试:共线点 ===\n");
Point *collinear = gen_collinear_points(1000);
ClosestResult col_res = closest_pair_dc(collinear, 1000);
printf(" 最近距离: %.10f (应为 1.0)\n", sqrt(col_res.dist_sq));
assert(fabs(col_res.dist_sq - 1.0) < 1e-9);
free(collinear);
/* 退化测试:聚类点 */
printf("=== 退化测试:聚类点 ===\n");
Point *clustered = gen_clustered_points(5000, 10);
ClosestResult cls_res = closest_pair_dc(clustered, 5000);
printf(" 最近距离: %.10f\n", sqrt(cls_res.dist_sq));
free(clustered);
printf("\n所有测试通过。\n");
return 0;
}十二、个人思考
12.1 算法选择的现实考量
在我多年的实践中,最近点对问题给我的最大感触是:理论最优不等于实践最优。
Rabin 算法在理论上是期望 O(n) 的,比分治法的 O(n log n) 更优。但在实际工程中,我很少推荐使用 Rabin 算法,原因如下:
- 常数因子大:哈希表的构建、维护和查询都有不小的常数开销。在 n < 10^6 时,分治法的实际运行时间往往更短。
- 最坏情况无保障:随机化算法有 O(n^2) 的最坏情况。虽然概率极小,但在安全关键(safety-critical)系统中不可接受。
- 实现复杂度高:哈希网格的正确实现需要处理大量边界情况(哈希冲突、整数溢出、内存管理等),远比分治法容易出错。
- 调试困难:随机化算法的非确定性使得 bug 难以复现。
分治法是我心中最近点对问题的”默认选择”。它确定、高效、实现清晰、易于调试、容易并行化。
12.2 随机化的真正价值
但这并不意味着随机化没有价值。恰恰相反,我认为随机化思想在计算几何中的价值是方法论层面的:
- RIC 范式让我们看到,许多看似需要复杂数据结构的问题,通过简单的随机顺序插入就能解决。
- 向后分析是一种极其优雅的分析工具,它将”前向构造”的复杂概率分析转化为”如果随机删除一个元素会怎样”的简单思考。
- Clarkson-Shor 技术提供了一个统一框架,让我们不用为每个问题重新发明轮子。
这些思想的价值远超最近点对这一个问题。理解了它们,你就掌握了一套分析和设计计算几何算法的通用工具。
12.3 高维的现实
维度诅咒是计算几何中最深刻的困难之一。当维度超过 10-15 时,几乎所有基于空间划分的精确算法都会退化到接近暴力枚举的性能。
这不是算法设计者的失败——这是问题本身的固有困难。高维空间的几何直觉完全不同于低维:所有点都近乎等距,“近邻”的概念变得模糊。
实际应用中的应对策略:
- 降维:PCA、随机投影、t-SNE、UMAP 等,利用数据的内在低维结构。
- 近似:LSH、量化方法、图索引(HNSW),牺牲精确性换取速度。
- 问题转化:有时候”找最近的”不是真正的需求,“找足够近的”就够了。
12.4 竞赛与面试
如果你是为了竞赛或面试准备,我的建议是:
- 必须掌握:分治法的思路和复杂度分析。中间带检查 7 个邻居的证明。能够手写分治法实现。
- 了解即可:Rabin 算法的思路、RIC 范式、Clarkson-Shor 技术。
- 不需要会:Rabin 算法的完整实现细节(竞赛中基本不会用到,面试中最多口述思路)。
最近点对是一个极好的”分治法教学样例”——它体现了分治法的三个关键步骤(分割、递归、合并),而合并步骤的巧妙处理(中间带的 O(n) 合并)是整个算法的精华。理解了这一点,你对分治法的理解就上了一个台阶。
参考文献
- Shamos, M. I., & Hoey, D. (1975). Closest-point problems. Proceedings of the 16th Annual Symposium on Foundations of Computer Science (FOCS), 151-162.
- Rabin, M. O. (1976). Probabilistic algorithms. Algorithms and Complexity: New Directions and Recent Results, 21-39.
- Clarkson, K. L., & Shor, P. W. (1989). Applications of random sampling in computational geometry, II. Discrete & Computational Geometry, 4(1), 387-421.
- Cormen, T. H., Leiserson, C. E., Rivest, R. L., & Stein, C. (2009). Introduction to Algorithms (3rd ed.). MIT Press. Chapter 33: Computational Geometry.
- de Berg, M., Cheong, O., van Kreveld, M., & Overmars, M. (2008). Computational Geometry: Algorithms and Applications (3rd ed.). Springer-Verlag.
- Mulmuley, K. (1994). Computational Geometry: An Introduction Through Randomized Algorithms. Prentice Hall.
- Har-Peled, S. (2011). Geometric Approximation Algorithms. American Mathematical Society.
- Indyk, P., & Motwani, R. (1998). Approximate nearest neighbors: towards removing the curse of dimensionality. Proceedings of the 30th Annual ACM Symposium on Theory of Computing (STOC), 604-613.
- Friedman, J. H., Bentley, J. L., & Finkel, R. A. (1977). An algorithm for finding best matches in logarithmic expected time. ACM Transactions on Mathematical Software, 3(3), 209-226.
- Kleinberg, J. M. (1997). Two algorithms for nearest-neighbor search in high dimensions. Proceedings of the 29th Annual ACM Symposium on Theory of Computing (STOC), 599-608.
算法系列导航:上一篇:点定位与梯形分解 | 下一篇:树形 DP