☰
机试坐标点最短距离:最小生成树、最近点对与TSP模型解析
2026/10/15 14:22:44 网站建设 项目流程

1. 从"连接坐标点找最短距离"说起:这道题到底在考什么

第一次看到"连接坐标点找最短距离"这个题目,很多人脑子里蹦出来的第一反应是:这不就是平面上给一堆点,然后求一条路径把它们全串起来、总长度最短吗?方向对了,但细节里藏着不少门道。这道题在机试里出现的频率相当高,它表面上是一道几何题,骨子里考的是最小生成树或者最近点对这两类经典算法模型,具体是哪一种,得看题目对"连接"的定义。

我先把两种最常见的题意摆出来,你对照一下自己遇到的是哪一种。

第一种:给n个二维坐标点,要求用若干条线段把所有点连通起来,使得所有线段长度之和最小。这就是标准的最小生成树(MST)问题,只不过边权是两点之间的欧氏距离。你不需要真的把每一条边都建出来再跑Kruskal,因为完全图有n(n-1)/2条边,n稍微大一点内存就爆了。正确做法是用Prim算法,配合邻接矩阵或者直接用点集维护"到生成树的最小距离",复杂度O(n²),对于机试常见的n≤1000完全够用。

第二种:给n个点,求所有点对中距离最近的那一对的距离。这是最近点对问题,暴力是O(n²),标准解法是分治,复杂度O(n log n)。这道题有时候会包装成"找出距离最近的两个坐标点",注意和MST区分开。

还有一种变体是旅行商问题(TSP),要求走一条经过所有点且回到起点的最短闭合路径。这个在机试里如果出现,n一定非常小(通常n≤10),因为TSP是NP难问题,只能用状压DP或者全排列暴力。

提示:拿到题目第一件事,先确认"连接"到底是"连通即可"还是"形成一条路径"。前者是MST,后者是TSP,两者难度和解法天差地别。我见过太多人一上来就写MST,结果题目要的是路径,白白丢分。

为什么这道题值得单独拿出来讲?因为它是图论建模能力的试金石。很多同学学了一堆算法,但看到实际问题就不知道往哪个模型上套。坐标点、距离、连接——这三个词组合在一起,能衍生出至少四种不同的算法方向,能不能快速判断,直接决定了你在考场上能不能在有限时间里拿到分。

接下来我会把这道题从建模判断、算法选择、代码实现到调试避坑,完整地拆一遍。不管你是刚接触图论的新手,还是想查漏补缺的老手,都能从里面找到对自己有用的东西。

2. 判断题型:三种"最短距离"模型的识别方法

2.1 最小生成树模型的特征与判定

MST模型的典型描述是:"用最短的总长度将所有点连接起来"、"铺设线路使所有城市互通且总成本最低"、"修建道路使所有村庄可达"。关键词是所有点连通和总长度最小,注意它不要求形成一条路径,只要求整个图连通。

判定方法很简单:如果题目允许一个点连接多个其他点(即形成树状结构而非链状结构),那就是MST。比如三个点A、B、C,MST允许A连B、A连C,总长|AB|+|AC|;而TSP要求A→B→C→A,总长|AB|+|BC|+|CA|。前者是树,后者是环。

MST的边权计算方式也要注意。绝大多数情况是欧氏距离,即sqrt((x1-x2)²+(y1-y2)²)。但有些题目会定义成曼哈顿距离|x1-x2|+|y1-y2|,或者切比雪夫距离max(|x1-x2|,|y1-y2|)。这个必须看清楚,算错了边权后面全错。

还有一个容易忽略的点:是否需要输出具体的连接方案。有些题目只要求输出最小总长度,有些要求输出每条边的两个端点。前者只需要在Prim过程中累加距离,后者需要记录每个点是从哪个点扩展来的(即parent数组)。

2.2 最近点对模型的特征与判定

最近点对模型的描述通常是:"找出距离最近的两个点"、"求所有点对中的最小距离"。它不要求连通所有点,只关心一对点。这个模型的关键词是"两个点"和"最近"。

暴力解法是双重循环枚举所有点对,复杂度O(n²)。当n≤1000时,暴力完全可行,代码简单不易出错。但当n达到10⁵级别时,就必须用分治法降到O(n log n)。

分治法的核心思路是:按x坐标排序,从中间分成左右两半,递归求左半部分的最小距离d1和右半部分的最小距离d2,取d=min(d1,d2)。然后考虑跨越中线的点对,只需要检查中线两侧距离d范围内的点,按y坐标排序后每个点最多检查后面7个点。这个"7"是经过严格证明的常数,不是随便定的。

机试中如果n不大,我建议直接用暴力,省时省力。分治法虽然优雅,但边界条件多,写错了调试很痛苦。只有在n明确很大或者题目专门考察分治时,才值得上分治。

2.3 旅行商模型的特征与判定

TSP模型的描述是:"从起点出发,经过所有点恰好一次,最后回到起点,求最短路径"、"遍历所有城市的最短路线"。关键词是恰好一次和回到起点(有些变体不要求回到起点,那叫哈密顿路径)。

TSP在机试中出现时,n通常不超过15。解法有两种:全排列枚举(n≤10时可用)和状压DP(n≤20时可用)。状压DP的状态是dp[mask][i],表示已经访问的点集为mask、当前在点i的最小路径长度。转移方程是dp[mask|(1<<j)][j] = min(dp[mask][i] + dist[i][j])。

三种模型的对比我整理成了一张表,方便你快速对照:

模型核心要求典型n范围推荐算法复杂度
最小生成树所有点连通,总长最小n≤1000PrimO(n²)
最近点对找一对最近的点n≤1000用暴力暴力/分治O(n²)/O(nlogn)
旅行商经过所有点恰好一次n≤15状压DPO(2ⁿ·n²)

注意:有些题目会同时涉及多个模型,比如先求MST再在MST上求直径。这种复合题需要你把每个子问题拆开,分别用对应算法解决。

3. Prim算法实现MST:从邻接矩阵到点集维护的取舍

3.1 为什么不用Kruskal而选Prim

Kruskal算法需要把所有边排序,对于完全图来说边数是n(n-1)/2。当n=1000时,边数约50万条,排序的时间复杂度O(E log E)≈50万×19≈950万次操作,其实也能接受。但问题是存储:50万条边,每条边存两个端点和权值,用结构体数组大概需要50万×12字节≈6MB,还行。但如果n=5000,边数就达到1250万条,内存直接爆掉。

Prim算法不需要显式存储所有边,只需要一个邻接矩阵或者直接在点集上操作。邻接矩阵是n×n的,n=1000时是100万个double,约8MB,可以接受。如果n更大,可以用两个一维数组维护每个点到当前生成树的最小距离和是否已访问,空间O(n)。

所以选择很明确:稠密图用Prim,稀疏图用Kruskal。坐标点问题默认是完全图(任意两点之间都有边),属于稠密图,Prim是更优选择。

3.2 Prim的核心逻辑与手算模拟

Prim的贪心策略是:从任意一个点开始,每次选择距离当前生成树最近的一个未访问点加入,直到所有点都被加入。这个策略的正确性由切分定理保证,这里不展开证明,你只需要记住它是对的。

我用一个具体例子手算一遍,帮你建立直觉。假设有4个点:A(0,0)、B(3,0)、C(0,4)、D(3,4)。

距离矩阵:

  • AB=3, AC=4, AD=5
  • BC=5, BD=4, CD=3

从A开始:

  • 初始:已访问{A},候选距离:B=3, C=4, D=5
  • 选B(距离3),总长=3,已访问{A,B}
  • 更新候选:C=min(4, BC=5)=4, D=min(5, BD=4)=4
  • 选C(距离4),总长=7,已访问{A,B,C}
  • 更新候选:D=min(4, CD=3)=3
  • 选D(距离3),总长=10

最终MST总长=10,边为AB、AC、CD。你可以验证一下,这确实是最小的。

3.3 代码实现与关键细节

下面是Prim算法的完整C++实现,适用于n≤1000的坐标点MST问题:

#include <bits/stdc++.h> using namespace std; const int MAXN = 1005; const double INF = 1e18; double x[MAXN], y[MAXN]; double dist[MAXN]; // 每个点到生成树的最小距离 bool visited[MAXN]; double getDist(int i, int j) { return sqrt((x[i]-x[j])*(x[i]-x[j]) + (y[i]-y[j])*(y[i]-y[j])); } double prim(int n) { // 初始化 for (int i = 0; i < n; i++) { dist[i] = INF; visited[i] = false; } dist[0] = 0; // 从点0开始 double total = 0; for (int iter = 0; iter < n; iter++) { // 找未访问点中dist最小的 int u = -1; double minD = INF; for (int i = 0; i < n; i++) { if (!visited[i] && dist[i] < minD) { minD = dist[i]; u = i; } } if (u == -1) break; // 图不连通 visited[u] = true; total += dist[u]; // 更新其他点的dist for (int v = 0; v < n; v++) { if (!visited[v]) { double d = getDist(u, v); if (d < dist[v]) { dist[v] = d; } } } } return total; } int main() { int n; while (cin >> n && n) { for (int i = 0; i < n; i++) { cin >> x[i] >> y[i]; } printf("%.2f\n", prim(n)); } return 0; }

几个关键细节需要说明:

第一,dist数组的含义是"该点到当前生成树中任意一点的最小距离",不是到起点的距离。这个理解错了,整个算法就错了。

第二,每次选点后要更新所有未访问点的dist,更新公式是dist[v] = min(dist[v], getDist(u,v))。这里不需要判断v是否已经在树中,因为visited数组已经过滤了。

第三,如果图不连通,某次迭代会找不到u(u==-1),此时应该break。对于坐标点问题,完全图一定是连通的,所以这个情况不会出现,但写上更保险。

第四,输出格式要注意。有些题目要求保留两位小数,有些要求整数(如果距离定义为平方和),看清楚再输出。

3.4 复杂度分析与优化空间

上面的实现是O(n²)的,对于n=1000,大约需要100万次内层循环,每次循环做一次sqrt和比较,总操作量在千万级别,C++下大约几十毫秒,完全没问题。

如果n达到5000,O(n²)就是2500万次,可能需要几百毫秒,接近超时边缘。这时候可以考虑用优先队列优化到O(n log n),但实现复杂度会上升。机试中n通常不会超过1000,所以O(n²)的Prim是性价比最高的选择。

还有一个优化点是避免重复计算距离。上面的代码在每次更新时都调用getDist,实际上同一个点对的距离会被计算多次。可以预先计算距离矩阵,但空间O(n²)在n=1000时是8MB,可以接受。如果n更大,就不预存,现算现用。

提示:sqrt操作比较耗时,如果题目只要求比较距离大小而不要求输出具体值,可以用平方距离代替,避免开方。但如果要输出总长度,最后必须开方。

4. 最近点对:暴力与分治的适用边界

4.1 暴力解法的适用场景与写法

暴力解法就是双重循环,枚举所有点对,维护最小距离。代码简单到不需要解释:

double closestPairBrute(int n) { double minD = INF; for (int i = 0; i < n; i++) { for (int j = i+1; j < n; j++) { double d = getDist(i, j); if (d < minD) minD = d; } } return minD; }

这个解法的时间复杂度是O(n²),当n=1000时是50万次距离计算,每次一个sqrt,总共大约几毫秒,完全可行。当n=5000时是1250万次,可能需要几百毫秒。当n=10⁵时就是50亿次,必然超时。

所以判断标准很简单:n≤3000用暴力,n>3000考虑分治。机试中如果出现最近点对问题,n通常不会太大,暴力往往是更稳妥的选择,因为分治法写错的概率远高于暴力。

4.2 分治法的核心步骤拆解

分治法的流程分为四步:

第一步,按x坐标排序。所有点先按x从小到大排好,如果x相同按y排。

第二步,递归求解左右两半。取中间位置mid,左半部分是[0, mid],右半部分是[mid+1, n-1]。递归调用得到左半最小距离d1和右半最小距离d2,令d=min(d1,d2)。

第三步,处理跨越中线的点对。收集所有x坐标在中线两侧d范围内的点,按y坐标排序。然后对每个点,只需要检查它后面最多7个点(因为在这7个点之外,y方向的距离已经超过d了)。

第四步,返回min(d, 跨中线的最小距离)。

为什么是7个点?因为在一个d×2d的矩形区域内,最多能放下8个点使得任意两点距离不小于d(这是鸽巢原理的结论)。所以每个点只需要和后面7个点比较。

4.3 分治法的代码实现与易错点

struct Point { double x, y; }; bool cmpX(const Point& a, const Point& b) { return a.x < b.x || (a.x == b.x && a.y < b.y); } bool cmpY(const Point& a, const Point& b) { return a.y < b.y; } double divide(vector<Point>& pts, int left, int right) { if (right - left <= 1) { if (right == left) return INF; return sqrt(pow(pts[left].x-pts[right].x,2) + pow(pts[left].y-pts[right].y,2)); } int mid = (left + right) / 2; double midX = pts[mid].x; double d = min(divide(pts, left, mid), divide(pts, mid+1, right)); // 收集中线附近的点 vector<Point> strip; for (int i = left; i <= right; i++) { if (fabs(pts[i].x - midX) < d) { strip.push_back(pts[i]); } } sort(strip.begin(), strip.end(), cmpY); // 检查strip中的点对 for (int i = 0; i < strip.size(); i++) { for (int j = i+1; j < strip.size() && (strip[j].y - strip[i].y) < d; j++) { double dd = sqrt(pow(strip[i].x-strip[j].x,2) + pow(strip[i].y-strip[j].y,2)); if (dd < d) d = dd; } } return d; }

易错点有三个:

第一,递归终止条件。当区间只有一个点时返回INF,两个点时直接返回它们的距离。这个边界条件写错了会导致死循环或错误结果。

第二,strip的收集范围。是fabs(pts[i].x - midX) < d,不是<=。用小于号可以避免一些边界情况。

第三,内层循环的终止条件。是strip[j].y - strip[i].y < d,一旦y方向距离超过d就break,因为后面的点y更大,距离只会更远。

注意:分治法中每次递归都要对strip排序,如果每次都用sort,总复杂度会变成O(n log²n)。优化方法是在递归过程中维护一个按y排序的数组,但实现更复杂。机试中O(n log²n)通常也能过,不必过度优化。

5. 调试与避坑:那些年我踩过的坐标点问题

5.1 精度问题:浮点数比较的陷阱

坐标点问题几乎都涉及浮点数运算,精度问题是最常见的坑。比如判断两个距离是否相等,不能直接用==,而应该用fabs(a-b) < eps,eps通常取1e-8或1e-9。

输出时也要注意。如果题目要求保留两位小数,用printf("%.2f")。但有时候计算结果和标准答案差0.01,这是因为浮点误差累积。解决办法是在最后输出前加上一个极小的偏移量,比如printf("%.2f", ans + 1e-9),这样可以避免0.005被截断成0.00的情况。

还有一个隐蔽的坑:距离的平方和开方。如果题目只要求比较距离大小,全程用平方距离可以避免开方,提高精度和速度。但最后输出时必须开方,别忘了。

5.2 输入输出格式:多组数据与终止条件

很多机试题是多组测试数据,终止条件是n=0或者EOF。上面的Prim代码用了while(cin >> n && n),这是处理n=0终止的标准写法。如果是EOF终止,用while(cin >> n)。

输出格式也要注意。有些题目要求每组数据后输出一个空行,有些不要。有些要求输出"Case 1: 10.00"这样的格式,有些只输出数字。这些细节看似小,但错了就是0分。

我建议在本地测试时,把题目给的样例输入输出完整跑一遍,逐字符对比。不要只看数字对不对,空格、换行、大小写都要一致。

5.3 边界情况:n=1、n=2、重复点

n=1时,MST的总长度是0(不需要连接任何边),最近点对无解(或者返回INF)。n=2时,MST总长度就是两点距离,最近点对也是两点距离。

重复点的情况也要考虑。如果两个点坐标完全相同,它们之间的距离是0。在MST中,这条0权边会被优先选中。在最近点对中,答案就是0。这些边界情况在代码中要能正确处理,不能崩溃或返回错误结果。

还有一个容易忽略的:坐标范围。如果坐标值很大(比如10⁹),距离计算时平方会溢出int,必须用double或long long。用double最保险,因为sqrt的参数和返回值都是double。

5.4 算法选择错误:MST和最近点对的混淆

这是最致命的错误。我见过有同学把MST的代码交到最近点对的题目上,结果输出的是生成树总长而不是最近点对距离,直接0分。

避免方法:拿到题目后,先用一句话概括题目要求,然后对照三种模型的特征。如果题目说"连接所有点",那是MST;如果说"找最近的两个点",那是最近点对;如果说"经过所有点一次",那是TSP。概括不出来就再读一遍题,不要急着写代码。

提示:机试中时间宝贵,但读题的时间不能省。花两分钟确认题意,比花二十分钟调试错误代码划算得多。

6. 从机试到实战:坐标点问题的延伸思考

6.1 机试中的变体与应对策略

机试中的坐标点问题经常会有变体。比如:

变体一:带权坐标点。每个点除了坐标还有一个权值,要求MST的总权值最小。这时候边权不再是距离,而是某种组合,比如距离乘以两个点权值的和。解法还是Prim,只是边权计算公式变了。

变体二:限制连接数量。每个点最多只能连接k个其他点。这就变成了度约束最小生成树问题,是NP难问题,机试中如果出现,n一定很小,可以用搜索或者状压DP。

变体三:三维坐标。点从二维变成三维,距离公式变成sqrt((x1-x2)²+(y1-y2)²+(z1-z2)²)。算法完全不变,只是距离计算多一项。

变体四:动态添加点。初始给一些点,然后不断添加新点,每次添加后询问当前MST总长。这需要增量式Prim,每次添加一个点时,只需要计算它到当前树的最小距离,然后更新其他点的dist。复杂度O(n)每次,总体O(n²)。

应对策略:先识别核心模型,再看变体改了什么。大多数变体只是在边权计算或约束条件上做文章,核心算法不变。

6.2 实际项目中的坐标点问题

坐标点问题在实际项目中也有很多应用。比如:

物流配送路线规划:给定若干配送点和仓库,要求规划一条路线使总行驶距离最短。如果允许多辆车,就是车辆路径问题(VRP);如果只有一辆车,就是TSP。

网络布线:给定若干机房的位置,要求铺设光缆使所有机房连通且总长度最短。这是标准的MST问题,边权是机房之间的铺设成本。

聚类分析:给定若干数据点,要求把它们分成k个簇,使得簇内距离最小。这是K-Means聚类,和最近点对有一定关联,但目标不同。

实际项目中,n往往很大(几千到几万),而且可能有各种约束(道路不能交叉、某些点之间不能直连等)。这时候就需要更高级的算法,比如启发式搜索、遗传算法等。但机试中的坐标点问题是这些实际问题的简化版,掌握好基础模型,才能在实际中灵活应用。

6.3 刷题建议与学习路径

如果你想系统掌握坐标点问题,我建议按以下顺序刷题:

第一阶段:纯模板题。找几道标准的MST、最近点对、TSP题目,把模板代码背熟。这个阶段不求快,求稳,确保每道题都能一次AC。

第二阶段:变体题。找带权、三维、动态添加点等变体,练习在模板基础上修改。这个阶段重点是识别变体改了什么,以及如何最小化修改代码。

第三阶段:复合题。找同时涉及多个模型的题目,比如先求MST再求直径,或者先聚类再求TSP。这个阶段锻炼的是拆解问题的能力。

第四阶段:限时训练。在模拟考试环境下做题,训练读题速度和代码速度。机试的时间压力很大,平时不练,考场上容易慌。

我个人在实际刷题中的体会是:模板要背,但不能死背。背模板是为了节省考场上的时间,但理解模板背后的原理才能应对变体。我见过太多人背了Prim的代码,但题目稍微一变就不会了,就是因为没理解dist数组的含义和更新逻辑。

最后再分享一个小技巧:写代码前先在纸上画个简单的例子,手算一遍。比如三个点,手动跑一遍Prim,看看每一步选哪个点、dist怎么更新。这个过程只需要两分钟,但能帮你发现很多逻辑错误。直接写代码然后调试,往往要花十倍的时间。

坐标点问题看起来简单,但真正写好、写对、写快,需要大量的练习和总结。希望这篇内容能帮你少走一些弯路,在机试中稳稳拿下这类题目。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询